diff --git a/.env.example b/.env.example index 52674f83..a5a70d61 100644 --- a/.env.example +++ b/.env.example @@ -40,6 +40,14 @@ DATABASE_URL=postgresql://nomos:nomos@localhost:5432/nomos # EMBEDDING_MODEL=gemini-embedding-001 # VERTEX_AI_LOCATION=global +# ─── Studio beauty-ops sidecar (optional, hosted Studio) ───────── +# The deterministic retouch/reshape sidecar (nomos-studio-sidecar). When absent, +# retouch falls back to the cloud (Gemini, consent-gated). Either point at an +# already-running instance, OR let the daemon spawn `uv run` from a sibling clone. +# NOMOS_STUDIO_SIDECAR_URL=http://127.0.0.1:8799 +# NOMOS_STUDIO_SIDECAR_PATH=../nomos-studio-sidecar +# NOMOS_STUDIO_SIDECAR_PORT=8799 + # ─── Permission Mode (optional) ────────────────────────────────── # Controls how tool usage is handled: default, acceptEdits, plan, dontAsk # NOMOS_PERMISSION_MODE=acceptEdits diff --git a/.gitignore b/.gitignore index a259978a..0394f611 100644 --- a/.gitignore +++ b/.gitignore @@ -63,3 +63,8 @@ skills/webapp-testing/ skills/xlsx/ skills/.anthropic-skills-fetched .gstack/ + +# Locally-installed agent skills (e.g. `npx skills add Banuba/ai-skills`) — a tool +# for the assistant, not part of this repo. +.claude/skills/ +skills-lock.json diff --git a/CLAUDE.md b/CLAUDE.md index 3463c695..8a6f6bae 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -174,7 +174,7 @@ See `.env.example` for the full set of optional variables (model, permissions, c - **`sdk/`** -- Claude Agent SDK wrapper: - `session.ts` -- wraps `query()`, supports V2 session API with feature detection. `RunSessionParams` accepts `systemPrompt` (full override), `anthropicBaseUrl` (custom API endpoint), and `systemPromptAppend` (append to preset). The `ANTHROPIC_BASE_URL` env var is propagated to child processes via the `env` option. - `tools.ts` -- in-process MCP server exposing `memory_search` and `user_model_recall` tools - - `cost-tracker.ts` -- per-session and per-model token usage and USD cost tracking with `CostTracker` class, model pricing tiers, formatting utilities, and `getCostTracker()` singleton + - `cost-tracker.ts` -- per-session and per-model token usage and USD cost tracking with `CostTracker` class, model pricing tiers, and `getCostTracker()` singleton - `token-estimation.ts` -- heuristic-based token counting (`roughTokenCount`, `bytesPerTokenForFileType`, `roughTokenCountForBlock/Content/Messages`, `formatTokenCount`) - `retry.ts` -- `withRetry()` async retry with exponential backoff + jitter, 429/529 handling, retry-after header parsing, persistent mode for daemon, abort signal support - `cache-break-detection.ts` -- `PromptCacheTracker` class that detects cache-invalidating changes to system prompt, tool schemas, model, or betas across API calls diff --git a/eval/agent-eval.ts b/eval/agent-eval.ts index 555bd007..87f91a0b 100644 --- a/eval/agent-eval.ts +++ b/eval/agent-eval.ts @@ -1304,6 +1304,106 @@ async function runStyleProfiles(): Promise { if (!KEEP) await db.deleteFrom("style_profiles").where("user_id", "in", [A, B]).execute(); } +async function runStudioLearn(): Promise { + // Studio learning: drive the REAL capture -> distill -> store path. Four committed + // edits fill recordEditSignal's buffer and trip flushPhotoStyle, which distills them + // (forked Haiku) into an editable photo-style.md vault note + photo_style user_model + // entries -- the exact rows suggestEdits + auto-enhance read back to personalize. + // Asserts both durable effects, the apply-side read, and per-user isolation. + const { recordEditSignal, readPhotoStyle, flushPhotoStyle } = + await import("../src/studio/learn.ts"); + const db = getKysely(); + const A = "eval-photo-a"; + const B = "eval-photo-b"; + const clear = async (): Promise => { + await db + .deleteFrom("vault_notes") + .where("user_id", "in", [A, B]) + .where("path", "=", "photo-style.md") + .execute(); + await db + .deleteFrom("user_model") + .where("user_id", "in", [A, B]) + .where("category", "=", "photo_style") + .execute(); + }; + const styleNote = (): Promise<{ content: string } | undefined> => + db + .selectFrom("vault_notes") + .select(["content"]) + .where("user_id", "=", A) + .where("path", "=", "photo-style.md") + .executeTakeFirst(); + const photoPrefCount = async (userId: string): Promise => + Number( + ( + await db + .selectFrom("user_model") + .select((eb) => eb.fn.countAll().as("n")) + .where("user_id", "=", userId) + .where("category", "=", "photo_style") + .executeTakeFirst() + )?.n ?? 0, + ); + await clear(); + + // Capture + read gate on NOMOS_ADAPTIVE_MEMORY (same flag as all other learning). + const priorAdaptive = process.env.NOMOS_ADAPTIVE_MEMORY; + process.env.NOMOS_ADAPTIVE_MEMORY = "true"; + try { + if (!hasLLM) { + skip( + "[studio-learn] distills applied edits into a photo-style vault note + user_model", + "no LLM provider configured", + ); + return; + } + + const edits = [ + "warm up the photo and add a soft golden-hour glow", + "smooth the skin but keep the pores and natural texture", + "deepen the contrast and make the colors pop", + "brighten the eyes and gently whiten the teeth", + ]; + const signals = edits.map((instruction) => ({ op: "editSemantic", instruction })); + // The real path: 4 edits fill the buffer and trip the flush (FLUSH_EVERY). + for (const s of signals) await recordEditSignal(A, s.op, s.instruction); + + // recordEditSignal swallows flush errors by design (fire-and-forget); if nothing + // landed, drive the distiller directly so a genuine failure surfaces with a reason. + let note = await styleNote(); + if (!note?.content) { + await flushPhotoStyle(A, signals); + note = await styleNote(); + } + + check( + "[studio-learn] writes an editable photo-style.md vault note", + !!note?.content && note.content.trim().length > 0, + note?.content?.slice(0, 80), + ); + check( + "[studio-learn] accumulates photo_style preferences in the user model", + (await photoPrefCount(A)) >= 1, + `count=${await photoPrefCount(A)}`, + ); + // Apply side: readPhotoStyle is what the engine (auto-enhance) + suggestEdits inject. + check( + "[studio-learn] readPhotoStyle surfaces the learned style for injection", + (await readPhotoStyle(A)).length > 0, + ); + // Per-user scoped: B applied no edits, so it has neither the note nor any prefs. + check( + "[studio-learn] B (no edits) has no photo-style note or prefs (per-user scoped)", + (await readPhotoStyle(B)).length === 0 && (await photoPrefCount(B)) === 0, + ); + } finally { + if (priorAdaptive === undefined) delete process.env.NOMOS_ADAPTIVE_MEMORY; + else process.env.NOMOS_ADAPTIVE_MEMORY = priorAdaptive; + if (!KEEP) await clear(); + } +} + async function runWikiArticles(): Promise { // Derived store: wiki_articles. Deterministic write + per-user isolation, then // the full LLM compile (2 Sonnet passes) pointed at a temp NOMOS_WIKI_DIR so it @@ -2979,6 +3079,7 @@ async function runEval(): Promise { await runRelationshipStats(); await runManagedFiles(); await runStyleProfiles(); + await runStudioLearn(); await runGraphMetadata(); await runBacklinks(); await runMetadataColumns(); diff --git a/eval/feature-manifest.ts b/eval/feature-manifest.ts index b7a9aef4..1ee4a66c 100644 --- a/eval/feature-manifest.ts +++ b/eval/feature-manifest.ts @@ -202,6 +202,127 @@ export const FEATURES: FeatureSpec[] = [ entry: ["registerDeltaSyncJobs"], effects: [{ claim: "emits ingest:trigger for delta runs (behavioral)", notExercised: true }], }, + { + id: "studio-gc", + summary: + "Daily Studio object/row cleanup per owner: expire unconfirmed uploads (assets stuck pending past a TTL) and aged intermediate edit results no longer at the chain head, dropping their objects. Originals + the live head output are kept; the DB is the single clock (rows expired before object delete).", + trigger: { kind: "cron", sentinel: "__studio_gc__", schedule: "24h", fanOut: true }, + entry: ["runStudioGc", "runStudioGcForUser"], + effects: [ + { + claim: "GC marks expired Studio rows (status = 'expired')", + sql: { + query: "SELECT count(*) FROM studio_edits WHERE status = 'expired'", + expect: "nonzero", + }, + notExercised: true, // the eval does not age rows past the TTL + }, + ], + invariants: [ + "the original asset object is never deleted by GC", + "a row is marked expired before its object is deleted", + "every GC query is user_id-filtered", + ], + }, + + // ── Studio (hosted-only feature) ── + { + id: "studio", + summary: + "Hosted-only media asset + edit pipeline (gated). Immutable original + a non-destructive op chain: validate op -> consent gate (cloud ops only) -> append (optimistic concurrency: parent must be a done+output edit) + idempotency -> provider (local-sharp deterministic / mediapipe-sidecar deterministic / GCP generative) -> identity gate (face-risk ops) -> persist output + preview. Manual on-device renders (adjust/makeup/reshape/hair/body) commit via the deviceRender op (the client uploads its own pixels, re-encoded server-side). retouch routes to the deterministic sidecar when up, else the generative cloud fallback. Phase-3 depth ops (muscle/hairstyle/beard/relight/expand/sky) are generative. Per-user scoped.", + trigger: { kind: "turn", gate: "studio" }, + entry: [ + "buildStudioMcpServer", + "buildStudioEngine", + "assertIdentityPreserved", + "ensureStudioSidecar", + "listAssets", + "suggestEdits", + ], + effects: [ + { + claim: "uploaded originals are recorded as studio_assets rows", + sql: { query: "SELECT count(*) FROM studio_assets", expect: "nonzero" }, + notExercised: true, + }, + { + claim: "each edit appends a completed studio_edits op row", + sql: { + query: "SELECT count(*) FROM studio_edits WHERE status = 'done'", + expect: "nonzero", + }, + notExercised: true, + }, + { + claim: "on-device renders commit as deviceRender edits (client-uploaded pixels)", + sql: { + query: "SELECT count(*) FROM studio_edits WHERE op = 'deviceRender' AND status = 'done'", + expect: "nonzero", + }, + notExercised: true, + }, + { + claim: "one-tap retouch records a done studio_edits row (sidecar or cloud fallback)", + sql: { + query: "SELECT count(*) FROM studio_edits WHERE op = 'retouch' AND status = 'done'", + expect: "nonzero", + }, + notExercised: true, + }, + { + claim: "Phase-3 generative depth ops record done studio_edits rows", + sql: { + query: + "SELECT count(*) FROM studio_edits WHERE op IN ('muscle','hairstyle','beard','relight','expand','sky') AND status = 'done'", + expect: "nonzero", + }, + notExercised: true, + }, + { + claim: "op params are stored as a jsonb object, never double-encoded", + noDoubleEncode: { table: "studio_edits", column: "params" }, + notExercised: true, + }, + ], + invariants: [ + "the original asset row is never mutated by an edit", + "every studio_assets / studio_edits query is user_id-filtered (zero-trust)", + "every generative (cloud) op is gated by the cloudAI consent toggle", + "every face-touching generative op passes the identity gate (assertIdentityPreserved)", + "a retried edit with a committed idempotency_key returns the existing row, never re-charges", + "an edit only chains onto a parent that is done with an output (no half-built chain)", + "deviceRender requires client bytes and is free + never consent/identity-gated (WYSIWYG)", + "a client-supplied mask must resolve to a studio asset owned by the same user", + ], + }, + { + id: "studio-learn", + summary: + "Studio learns the user's photo-editing taste from the edits they apply. Each committed editSemantic fires a signal (recordEditSignal); a background pass every few edits distills them (Haiku) into an editable photo-style.md vault note + photo_style user_model entries. It's injected back as personalized recommendations (suggestEdits style block) and a personalized auto-enhance (editSemantic personalize flag -> styleHint in the generative prompt), never overriding an explicit typed edit. Gated by NOMOS_ADAPTIVE_MEMORY; per-user scoped.", + trigger: { kind: "turn", gate: "studio" }, + entry: ["recordEditSignal", "flushPhotoStyle", "readPhotoStyle"], + effects: [ + { + // Exercised by runStudioLearn: 4 edits -> flushPhotoStyle distills the note. + claim: "learned editing taste is written as an editable photo-style.md vault note", + sql: { + query: "SELECT count(*) FROM vault_notes WHERE path = 'photo-style.md'", + expect: "nonzero", + }, + }, + { + claim: "structured photo_style preferences accumulate in the user model", + sql: { + query: "SELECT count(*) FROM user_model WHERE category = 'photo_style'", + expect: "nonzero", + }, + }, + ], + invariants: [ + "learning is gated by NOMOS_ADAPTIVE_MEMORY and is per-user scoped", + "personalization biases auto-enhance + suggestions, never an explicit typed edit", + ], + }, // ── Per-turn (memory-indexer) ── { diff --git a/package.json b/package.json index 8416e202..7c193edf 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,7 @@ "@bufbuild/protobuf": "^2.12.0", "@connectrpc/connect": "^2.1.1", "@connectrpc/connect-node": "^2.1.1", + "@google/genai": "^2.8.0", "@googleworkspace/cli": "^0.22.5", "@grpc/grpc-js": "^1.14.3", "@grpc/proto-loader": "^0.8.0", @@ -99,6 +100,7 @@ "playwright": "^1.50.0", "postgres": "^3.4.7", "react": "^19.2.4", + "sharp": "^0.35.1", "strip-ansi": "^7.2.0", "ws": "^8.19.0", "zod": "^4.3.6" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 47a7d99d..1a5ea021 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: '@connectrpc/connect-node': specifier: ^2.1.1 version: 2.1.1(@bufbuild/protobuf@2.12.0)(@connectrpc/connect@2.1.1(@bufbuild/protobuf@2.12.0)) + '@google/genai': + specifier: ^2.8.0 + version: 2.8.0(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6)) '@googleworkspace/cli': specifier: ^0.22.5 version: 0.22.5 @@ -49,7 +52,7 @@ importers: version: 7.14.1 '@whiskeysockets/baileys': specifier: 7.0.0-rc.9 - version: 7.0.0-rc.9(sharp@0.34.5) + version: 7.0.0-rc.9(sharp@0.35.1) better-sqlite3: specifier: ^12.6.2 version: 12.6.2 @@ -128,6 +131,9 @@ importers: react: specifier: ^19.2.4 version: 19.2.4 + sharp: + specifier: ^0.35.1 + version: 0.35.1 strip-ansi: specifier: ^7.2.0 version: 7.2.0 @@ -398,6 +404,9 @@ packages: '@emnapi/core@1.8.1': resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==} + '@emnapi/runtime@1.11.1': + resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} + '@emnapi/runtime@1.8.1': resolution: {integrity: sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==} @@ -560,6 +569,15 @@ packages: cpu: [x64] os: [win32] + '@google/genai@2.8.0': + resolution: {integrity: sha512-pc2ayxqO5+O7AvnHBqpNHIk7PAZkHZgL31tbyx0gJZBSS9qPYiQoqwK7oYOw/ePmG6QY4EMSu+304vD5QlhXAw==} + engines: {node: '>=20.0.0'} + peerDependencies: + '@modelcontextprotocol/sdk': ^1.25.2 + peerDependenciesMeta: + '@modelcontextprotocol/sdk': + optional: true + '@googleworkspace/cli@0.22.5': resolution: {integrity: sha512-Cej4nnkjphwRF+i7KWx4esp0p41yZ7Rv7A+P9hmFQrMStcngTASZBpeN/Lptk58oXxnSHvEcvM69S0e0y/GlvA==} engines: {node: '>=18'} @@ -595,140 +613,149 @@ packages: peerDependencies: hono: ^4 - '@img/colour@1.0.0': - resolution: {integrity: sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==} + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} engines: {node: '>=18'} - '@img/sharp-darwin-arm64@0.34.5': - resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-darwin-arm64@0.35.1': + resolution: {integrity: sha512-T15JRWOubQ3f5+GxnWeIvo47u5qV0M9HBgJhT+f2gE1e9e6OhR6K73Re52Hm80qWcu1DNb3GweKmpr/MnuP2Ow==} + engines: {node: '>=20.9.0'} cpu: [arm64] os: [darwin] - '@img/sharp-darwin-x64@0.34.5': - resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-darwin-x64@0.35.1': + resolution: {integrity: sha512-t1CPD0cr7XCHjwUj6tQ5MC0pCi866I+gUW6zbUX4aFPnKd1DFBtk0M+gWcjX8VeEzgfCNiSiNTVFZ6b7kvdbnQ==} + engines: {node: '>=20.9.0'} cpu: [x64] os: [darwin] - '@img/sharp-libvips-darwin-arm64@1.2.4': - resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} + '@img/sharp-freebsd-wasm32@0.35.1': + resolution: {integrity: sha512-MBSQXqNPThW9EcZ905H6N4sEdX5EwZEYzGx5EBq9ncDCGJALMiY1xPFJxNdzuB1iBjLOpIfxajM6YxdvwmQSLA==} + engines: {node: '>=20.9.0'} + os: [freebsd] + + '@img/sharp-libvips-darwin-arm64@1.3.0': + resolution: {integrity: sha512-EKbmBKtyTH+GPFDRw2TgK2oV6hyxxlJVIar4hoTYSNmIwipgMFdxPQqR392GmfdsPGWga0mCFN1cCKjRb9cljw==} cpu: [arm64] os: [darwin] - '@img/sharp-libvips-darwin-x64@1.2.4': - resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} + '@img/sharp-libvips-darwin-x64@1.3.0': + resolution: {integrity: sha512-Pl2OmOvrJ42adUllESxBsG54PfXLo1OYg9i3c5/5Ln/qJ0gZuTM9YMhQJPIbXqwidLRc/c2zuHt4RsrymmNv7A==} cpu: [x64] os: [darwin] - '@img/sharp-libvips-linux-arm64@1.2.4': - resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} + '@img/sharp-libvips-linux-arm64@1.3.0': + resolution: {integrity: sha512-C0SqjoFKnszqa44EQ7xoaT48nnO0lOyXEULfXMWi8krrjOPGYkeK30Okzla6ATbBYsyZ0ySinK0FVkpv3DwzfQ==} cpu: [arm64] os: [linux] - '@img/sharp-libvips-linux-arm@1.2.4': - resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} + '@img/sharp-libvips-linux-arm@1.3.0': + resolution: {integrity: sha512-A8UpHoUDW4DwnXoV6+q3C1s7QLRAHtPDEjWuNZjwHMyoCNZnm0GeNN8ls9f/bsEYTRQRW96C/n34XJQHJ2fT7A==} cpu: [arm] os: [linux] - '@img/sharp-libvips-linux-ppc64@1.2.4': - resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} + '@img/sharp-libvips-linux-ppc64@1.3.0': + resolution: {integrity: sha512-WOpkVxAjFd369iaIzEgNRreFD+gWdUMIGD5zplhNKNeqS6mm5dac3q2AFyCBmzYoAdouzZvRBgxy4z8QHZb4/A==} cpu: [ppc64] os: [linux] - '@img/sharp-libvips-linux-riscv64@1.2.4': - resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} + '@img/sharp-libvips-linux-riscv64@1.3.0': + resolution: {integrity: sha512-DRWw0mOHusrCCuw2rqP87oLg6PGlkomVDFqw2hIwsSfwWpu4k3XLcBPaKKl6ct/GtL/cwNkgwjV/tc0Mqht3VA==} cpu: [riscv64] os: [linux] - '@img/sharp-libvips-linux-s390x@1.2.4': - resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} + '@img/sharp-libvips-linux-s390x@1.3.0': + resolution: {integrity: sha512-9APy+nFWhHS+kzLgWZfLcyrUd7YqnAQVa4BPOo4xkoHpdoktOAPG4cEr9+Jpl0TtqfVmcMJimNL5qNTyyOHZNA==} cpu: [s390x] os: [linux] - '@img/sharp-libvips-linux-x64@1.2.4': - resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} + '@img/sharp-libvips-linux-x64@1.3.0': + resolution: {integrity: sha512-y9RNUYDe2A1UAdhLyfeOodGRszQdaEoe4nfOpp/sNVPl2CWIcUyFaDoCh4vPLPxu19803j2naLqZup2WxDXCLA==} cpu: [x64] os: [linux] - '@img/sharp-libvips-linuxmusl-arm64@1.2.4': - resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} + '@img/sharp-libvips-linuxmusl-arm64@1.3.0': + resolution: {integrity: sha512-cC1wkC0Mlucd0KSiGrLkJnB/ZqPvZCntc/Lk7ZnYO5ZSbF2euNek4Xvxafojq+wN1q/W0eprdpUIjUr/EV2PBg==} cpu: [arm64] os: [linux] - '@img/sharp-libvips-linuxmusl-x64@1.2.4': - resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} + '@img/sharp-libvips-linuxmusl-x64@1.3.0': + resolution: {integrity: sha512-LiYMhUZicB1QG//+RvmYZpXJO8fYRENfp+MZUCnG9aw+AKvGAy9gPaCnuwsPcBFs8EV66M0NNxj9VHcNklE8zw==} cpu: [x64] os: [linux] - '@img/sharp-linux-arm64@0.34.5': - resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linux-arm64@0.35.1': + resolution: {integrity: sha512-ErCRyGU7LeoaFBZ0xW8hhLlXzhAg80sc4vxePB86qvtEvW1jEhhmbiNBP4oEzZfPMnu6HwHXfzD2W2kBU+RnCw==} + engines: {node: '>=20.9.0'} cpu: [arm64] os: [linux] - '@img/sharp-linux-arm@0.34.5': - resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linux-arm@0.35.1': + resolution: {integrity: sha512-jygmR02PpCYypt7xB7nst1vqjZp/BpRA/Kf9nK7qRponJ/KrLPaZWEG4G15z1d2FZ6XqI+T0350ha3RSnKx24A==} + engines: {node: '>=20.9.0'} cpu: [arm] os: [linux] - '@img/sharp-linux-ppc64@0.34.5': - resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linux-ppc64@0.35.1': + resolution: {integrity: sha512-LUWZ2+r2UoLCd8j0RLCwQ4gL6w47+Y7igxtVnPIDXOOEjV86LpBkAHq5VpJeg+GHbw0KN/JWlPJOdZjyZnFqFQ==} + engines: {node: '>=20.9.0'} cpu: [ppc64] os: [linux] - '@img/sharp-linux-riscv64@0.34.5': - resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linux-riscv64@0.35.1': + resolution: {integrity: sha512-i7x6J3mwF4JgT0sM4V4WlAWdJ0bucPtA9rzO1bTji1n5qgBq/W5nn87RvOQPleuuxahNoLdTngByD8/vDDLArw==} + engines: {node: '>=20.9.0'} cpu: [riscv64] os: [linux] - '@img/sharp-linux-s390x@0.34.5': - resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linux-s390x@0.35.1': + resolution: {integrity: sha512-0zSaTUjTF0kIWTSYxD4EG/nvCU4jez53+3RdURtoY3HvbXtIQ98W90JnrGz/oLRFuEnfIy9+7xeq883euc0ZWw==} + engines: {node: '>=20.9.0'} cpu: [s390x] os: [linux] - '@img/sharp-linux-x64@0.34.5': - resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linux-x64@0.35.1': + resolution: {integrity: sha512-NbJD4mWdeyrNQKluO/tR/wBDOelcowSVGNBWxI0e3ZtlXc6F/UOVKDj1MLD4zl3oHTuvKW3s+MA9N54YTldAYw==} + engines: {node: '>=20.9.0'} cpu: [x64] os: [linux] - '@img/sharp-linuxmusl-arm64@0.34.5': - resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linuxmusl-arm64@0.35.1': + resolution: {integrity: sha512-VoW2sQCWI+0YIKQEmWJ8vzaQjTg9wIyfkFpvEfAS2h43X6iHu7GTk1hhOgB4IpSzCHe8UwQZIcx7b81VTaOrJA==} + engines: {node: '>=20.9.0'} cpu: [arm64] os: [linux] - '@img/sharp-linuxmusl-x64@0.34.5': - resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linuxmusl-x64@0.35.1': + resolution: {integrity: sha512-LjBoSd/c5JU0/K5MwzDMlgsSRP2bPn98JQGFFQAOLQ0bU/1z4ekxUdSKY9BmlwSh/cA+OrvpgsWqfZyYfVHBRw==} + engines: {node: '>=20.9.0'} cpu: [x64] os: [linux] - '@img/sharp-wasm32@0.34.5': - resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-wasm32@0.35.1': + resolution: {integrity: sha512-PCQUoQdZyE8tp3HpbevuihfUmgSP4qWI0FGEPWoeXqaS+cUrFfemabHQiebUmUmlUhCuNnQMxGrQ+CPqK4hnxg==} + engines: {node: '>=20.9.0'} + + '@img/sharp-webcontainers-wasm32@0.35.1': + resolution: {integrity: sha512-xU2ml2bU2OPxYVvW2A6ae4M1g5QKyhKG06P4FAt+YEaFQQO0919Qx+XxIZEUuWTMoDViLpMws2/dQwoe/VcA6A==} + engines: {node: '>=20.9.0'} cpu: [wasm32] - '@img/sharp-win32-arm64@0.34.5': - resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-win32-arm64@0.35.1': + resolution: {integrity: sha512-IkmHwuFhYpd3bTsN5SAahjwhiAcyXPooBt8vEUgxY3T0IP70sSJ0nU1xiPzZY8AH/OB1XpV3j8aZSVSOSfTbdA==} + engines: {node: '>=20.9.0'} cpu: [arm64] os: [win32] - '@img/sharp-win32-ia32@0.34.5': - resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-win32-ia32@0.35.1': + resolution: {integrity: sha512-wQahqCi9MD8Yxzg4gVM4fNrZxh+r6vD55PyIg+WJPaM5ZRUyF35iQpwJCuma3r6viU9/8Pxlc+XHV+woVa6nCQ==} + engines: {node: ^20.9.0} cpu: [ia32] os: [win32] - '@img/sharp-win32-x64@0.34.5': - resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-win32-x64@0.35.1': + resolution: {integrity: sha512-WzBtkYtZHATLPe8XRharxZXxQ9cdLrQWHiwxt+BJ5rBsisQrKeeV86ErxPSVhcG6xCEuNhs0SqLpWr7XDa2k6w==} + engines: {node: '>=20.9.0'} cpu: [x64] os: [win32] @@ -2851,6 +2878,11 @@ packages: engines: {node: '>=10'} hasBin: true + semver@7.8.4: + resolution: {integrity: sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==} + engines: {node: '>=10'} + hasBin: true + send@1.2.1: resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} engines: {node: '>= 18'} @@ -2862,9 +2894,9 @@ packages: setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} - sharp@0.34.5: - resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + sharp@0.35.1: + resolution: {integrity: sha512-lW979AMi+ESidzMv/Lnv+F9bknzLyxLqFI05Sm433vOeRcltgxQmXpnfOOFIAlKtwXU/ksupm2srQoFCkR214g==} + engines: {node: '>=20.9.0'} shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} @@ -3561,6 +3593,11 @@ snapshots: tslib: 2.8.1 optional: true + '@emnapi/runtime@1.11.1': + dependencies: + tslib: 2.8.1 + optional: true + '@emnapi/runtime@1.8.1': dependencies: tslib: 2.8.1 @@ -3649,6 +3686,19 @@ snapshots: '@esbuild/win32-x64@0.27.3': optional: true + '@google/genai@2.8.0(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))': + dependencies: + google-auth-library: 10.5.0 + p-retry: 4.6.2 + protobufjs: 7.5.8 + ws: 8.19.0 + optionalDependencies: + '@modelcontextprotocol/sdk': 1.29.0(zod@4.3.6) + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + '@googleworkspace/cli@0.22.5': {} '@grammyjs/types@3.24.0': {} @@ -3681,100 +3731,110 @@ snapshots: dependencies: hono: 4.12.14 - '@img/colour@1.0.0': {} + '@img/colour@1.1.0': {} - '@img/sharp-darwin-arm64@0.34.5': + '@img/sharp-darwin-arm64@0.35.1': optionalDependencies: - '@img/sharp-libvips-darwin-arm64': 1.2.4 + '@img/sharp-libvips-darwin-arm64': 1.3.0 optional: true - '@img/sharp-darwin-x64@0.34.5': + '@img/sharp-darwin-x64@0.35.1': optionalDependencies: - '@img/sharp-libvips-darwin-x64': 1.2.4 + '@img/sharp-libvips-darwin-x64': 1.3.0 optional: true - '@img/sharp-libvips-darwin-arm64@1.2.4': + '@img/sharp-freebsd-wasm32@0.35.1': + dependencies: + '@img/sharp-wasm32': 0.35.1 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.3.0': optional: true - '@img/sharp-libvips-darwin-x64@1.2.4': + '@img/sharp-libvips-darwin-x64@1.3.0': optional: true - '@img/sharp-libvips-linux-arm64@1.2.4': + '@img/sharp-libvips-linux-arm64@1.3.0': optional: true - '@img/sharp-libvips-linux-arm@1.2.4': + '@img/sharp-libvips-linux-arm@1.3.0': optional: true - '@img/sharp-libvips-linux-ppc64@1.2.4': + '@img/sharp-libvips-linux-ppc64@1.3.0': optional: true - '@img/sharp-libvips-linux-riscv64@1.2.4': + '@img/sharp-libvips-linux-riscv64@1.3.0': optional: true - '@img/sharp-libvips-linux-s390x@1.2.4': + '@img/sharp-libvips-linux-s390x@1.3.0': optional: true - '@img/sharp-libvips-linux-x64@1.2.4': + '@img/sharp-libvips-linux-x64@1.3.0': optional: true - '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + '@img/sharp-libvips-linuxmusl-arm64@1.3.0': optional: true - '@img/sharp-libvips-linuxmusl-x64@1.2.4': + '@img/sharp-libvips-linuxmusl-x64@1.3.0': optional: true - '@img/sharp-linux-arm64@0.34.5': + '@img/sharp-linux-arm64@0.35.1': optionalDependencies: - '@img/sharp-libvips-linux-arm64': 1.2.4 + '@img/sharp-libvips-linux-arm64': 1.3.0 optional: true - '@img/sharp-linux-arm@0.34.5': + '@img/sharp-linux-arm@0.35.1': optionalDependencies: - '@img/sharp-libvips-linux-arm': 1.2.4 + '@img/sharp-libvips-linux-arm': 1.3.0 optional: true - '@img/sharp-linux-ppc64@0.34.5': + '@img/sharp-linux-ppc64@0.35.1': optionalDependencies: - '@img/sharp-libvips-linux-ppc64': 1.2.4 + '@img/sharp-libvips-linux-ppc64': 1.3.0 optional: true - '@img/sharp-linux-riscv64@0.34.5': + '@img/sharp-linux-riscv64@0.35.1': optionalDependencies: - '@img/sharp-libvips-linux-riscv64': 1.2.4 + '@img/sharp-libvips-linux-riscv64': 1.3.0 optional: true - '@img/sharp-linux-s390x@0.34.5': + '@img/sharp-linux-s390x@0.35.1': optionalDependencies: - '@img/sharp-libvips-linux-s390x': 1.2.4 + '@img/sharp-libvips-linux-s390x': 1.3.0 optional: true - '@img/sharp-linux-x64@0.34.5': + '@img/sharp-linux-x64@0.35.1': optionalDependencies: - '@img/sharp-libvips-linux-x64': 1.2.4 + '@img/sharp-libvips-linux-x64': 1.3.0 optional: true - '@img/sharp-linuxmusl-arm64@0.34.5': + '@img/sharp-linuxmusl-arm64@0.35.1': optionalDependencies: - '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.0 optional: true - '@img/sharp-linuxmusl-x64@0.34.5': + '@img/sharp-linuxmusl-x64@0.35.1': optionalDependencies: - '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + '@img/sharp-libvips-linuxmusl-x64': 1.3.0 optional: true - '@img/sharp-wasm32@0.34.5': + '@img/sharp-wasm32@0.35.1': dependencies: - '@emnapi/runtime': 1.8.1 + '@emnapi/runtime': 1.11.1 optional: true - '@img/sharp-win32-arm64@0.34.5': + '@img/sharp-webcontainers-wasm32@0.35.1': + dependencies: + '@img/sharp-wasm32': 0.35.1 optional: true - '@img/sharp-win32-ia32@0.34.5': + '@img/sharp-win32-arm64@0.35.1': optional: true - '@img/sharp-win32-x64@0.34.5': + '@img/sharp-win32-ia32@0.35.1': + optional: true + + '@img/sharp-win32-x64@0.35.1': optional: true '@ioredis/commands@1.10.0': {} @@ -4377,7 +4437,7 @@ snapshots: '@vladfrangu/async_event_emitter@2.4.7': {} - '@whiskeysockets/baileys@7.0.0-rc.9(sharp@0.34.5)': + '@whiskeysockets/baileys@7.0.0-rc.9(sharp@0.35.1)': dependencies: '@cacheable/node-cache': 1.7.6 '@hapi/boom': 9.1.4 @@ -4388,7 +4448,7 @@ snapshots: p-queue: 9.1.0 pino: 9.14.0 protobufjs: 7.5.4 - sharp: 0.34.5 + sharp: 0.35.1 ws: 8.19.0 transitivePeerDependencies: - bufferutil @@ -5917,6 +5977,8 @@ snapshots: semver@7.7.4: {} + semver@7.8.4: {} + send@1.2.1: dependencies: debug: 4.4.3 @@ -5944,36 +6006,37 @@ snapshots: setprototypeof@1.2.0: {} - sharp@0.34.5: + sharp@0.35.1: dependencies: - '@img/colour': 1.0.0 + '@img/colour': 1.1.0 detect-libc: 2.1.2 - semver: 7.7.4 + semver: 7.8.4 optionalDependencies: - '@img/sharp-darwin-arm64': 0.34.5 - '@img/sharp-darwin-x64': 0.34.5 - '@img/sharp-libvips-darwin-arm64': 1.2.4 - '@img/sharp-libvips-darwin-x64': 1.2.4 - '@img/sharp-libvips-linux-arm': 1.2.4 - '@img/sharp-libvips-linux-arm64': 1.2.4 - '@img/sharp-libvips-linux-ppc64': 1.2.4 - '@img/sharp-libvips-linux-riscv64': 1.2.4 - '@img/sharp-libvips-linux-s390x': 1.2.4 - '@img/sharp-libvips-linux-x64': 1.2.4 - '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 - '@img/sharp-libvips-linuxmusl-x64': 1.2.4 - '@img/sharp-linux-arm': 0.34.5 - '@img/sharp-linux-arm64': 0.34.5 - '@img/sharp-linux-ppc64': 0.34.5 - '@img/sharp-linux-riscv64': 0.34.5 - '@img/sharp-linux-s390x': 0.34.5 - '@img/sharp-linux-x64': 0.34.5 - '@img/sharp-linuxmusl-arm64': 0.34.5 - '@img/sharp-linuxmusl-x64': 0.34.5 - '@img/sharp-wasm32': 0.34.5 - '@img/sharp-win32-arm64': 0.34.5 - '@img/sharp-win32-ia32': 0.34.5 - '@img/sharp-win32-x64': 0.34.5 + '@img/sharp-darwin-arm64': 0.35.1 + '@img/sharp-darwin-x64': 0.35.1 + '@img/sharp-freebsd-wasm32': 0.35.1 + '@img/sharp-libvips-darwin-arm64': 1.3.0 + '@img/sharp-libvips-darwin-x64': 1.3.0 + '@img/sharp-libvips-linux-arm': 1.3.0 + '@img/sharp-libvips-linux-arm64': 1.3.0 + '@img/sharp-libvips-linux-ppc64': 1.3.0 + '@img/sharp-libvips-linux-riscv64': 1.3.0 + '@img/sharp-libvips-linux-s390x': 1.3.0 + '@img/sharp-libvips-linux-x64': 1.3.0 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.0 + '@img/sharp-libvips-linuxmusl-x64': 1.3.0 + '@img/sharp-linux-arm': 0.35.1 + '@img/sharp-linux-arm64': 0.35.1 + '@img/sharp-linux-ppc64': 0.35.1 + '@img/sharp-linux-riscv64': 0.35.1 + '@img/sharp-linux-s390x': 0.35.1 + '@img/sharp-linux-x64': 0.35.1 + '@img/sharp-linuxmusl-arm64': 0.35.1 + '@img/sharp-linuxmusl-x64': 0.35.1 + '@img/sharp-webcontainers-wasm32': 0.35.1 + '@img/sharp-win32-arm64': 0.35.1 + '@img/sharp-win32-ia32': 0.35.1 + '@img/sharp-win32-x64': 0.35.1 shebang-command@2.0.0: dependencies: diff --git a/proto/nomos.proto b/proto/nomos.proto index ccab57f1..109a34e9 100644 --- a/proto/nomos.proto +++ b/proto/nomos.proto @@ -198,6 +198,18 @@ service MobileApi { rpc ListLoops (Empty) returns (MLoopsResponse); rpc SetLoopEnabled (MSetLoopEnabledRequest) returns (MAck); rpc DeleteLoop (MLoopDeleteRequest) returns (MAck); + + // Studio (hosted-only feature). Blobs move via presigned PUT/GET, never gRPC. + rpc StudioCreateAsset (MStudioCreateAssetRequest) returns (MStudioCreateAssetResponse); + rpc StudioGetAssetUrl (MStudioAssetRef) returns (MStudioAssetUrlResponse); + rpc StudioEdit (MStudioEditRequest) returns (stream MStudioEvent); + rpc StudioHistory (MStudioAssetRef) returns (MStudioHistoryResponse); + // Recent editing sessions for the Home launchpad ("Pick up where you left off"). + rpc StudioListAssets (MStudioListAssetsRequest) returns (MStudioListAssetsResponse); + // AI-native: a vision model looks at the photo and proposes tap-to-apply edits. + rpc StudioSuggestEdits (MStudioAssetRef) returns (MStudioSuggestionsResponse); + // The on-device identity check reports its score for an edit (0..1). + rpc StudioReportIdentity (MStudioIdentityReport) returns (MAck); } // Loops (autonomous recurring jobs) @@ -560,3 +572,97 @@ message DepositResponse { // Opaque integration id assigned by the customer instance (for revocation). string integration_id = 3; } + +// ── Studio (hosted-only feature) ────────────────────────────────────── +// Register an uploaded original. The client uploads the (downscaled, transcoded) +// image to upload_url (presigned PUT), then confirms by calling StudioEdit or +// StudioHistory; unconfirmed rows are reaped by __studio_gc__. +message MStudioCreateAssetRequest { + string mime = 1; // e.g. image/jpeg (HEIC transcoded client-side) + string content_hash = 2; // sha256 of the bytes + int32 width = 3; + int32 height = 4; + int32 bytes = 5; +} +message MStudioCreateAssetResponse { + string asset_id = 1; + string upload_url = 2; // presigned PUT + string object_key = 3; + int64 expires_at = 4; // ms epoch +} + +message MStudioAssetRef { + string asset_id = 1; + bool original = 2; // GetAssetUrl: presign the ORIGINAL (for before/after compare) +} +message MStudioAssetUrlResponse { + string url = 1; // presigned GET for the current head (or original) + int64 expires_at = 2; +} + +// Apply one op. params_json is the JSON-encoded op params (validated server-side +// against the op registry). parent_edit_id "" means "on the current head". +message MStudioEditRequest { + string asset_id = 1; + string op = 2; // adjust | editSemantic | cutout | upscale | restore | ... + string params_json = 3; + string parent_edit_id = 4; + string idempotency_key = 5; + string mask_key = 6; // optional device/tap mask object key + bytes input_image = 7; // deviceRender only: the on-device-rendered output bytes +} +message MStudioEvent { + string kind = 1; // progress | done | error + string edit_id = 2; + string status = 3; // pending | running | done | failed + string preview_key = 4; + string output_key = 5; + double cost_usd = 6; + string message = 7; +} + +message MStudioEdit { + string id = 1; + string op = 2; + string status = 3; + string preview_key = 4; + string output_key = 5; + double cost_usd = 6; + string parent_edit_id = 7; + string created_at = 8; +} +message MStudioHistoryResponse { + repeated MStudioEdit edits = 1; + string head_edit_id = 2; +} + +message MStudioIdentityReport { + string edit_id = 1; + double score = 2; // face-embedding similarity in 0..1 +} + +// A recent editing session for the Home launchpad. An asset + the gist of its chain. +message MStudioListAssetsRequest { + int32 limit = 1; // max sessions (server caps; default ~30) +} +message MStudioAssetSummary { + string asset_id = 1; + string preview_url = 2; // presigned GET (~thumbnail): head preview, else original + int64 updated_at = 3; // ms epoch + bool finalized = 4; // a stored final artifact (vs in-progress) + int32 edit_count = 5; + string head_op = 6; // op of the head edit ("" if none) — client humanizes + int64 expires_at = 7; // ms epoch for preview_url +} +message MStudioListAssetsResponse { + repeated MStudioAssetSummary assets = 1; +} + +// A per-photo edit suggestion: a short chip label + the instruction it applies. +message MStudioSuggestion { + string label = 1; + string prompt = 2; +} +message MStudioSuggestionsResponse { + repeated MStudioSuggestion suggestions = 1; +} diff --git a/scripts/isolation-check.ts b/scripts/isolation-check.ts index 615980eb..98996855 100644 --- a/scripts/isolation-check.ts +++ b/scripts/isolation-check.ts @@ -23,6 +23,9 @@ import { deleteContact, } from "../src/identity/contacts.ts"; import { upsertArticle, searchArticles, listArticles, deleteArticle } from "../src/db/wiki.ts"; +import type { TenantContext } from "../src/auth/tenant-context.ts"; +import { appendEdit, createAsset, getAsset, listEdits } from "../src/studio/assets.ts"; +import { validateOp } from "../src/studio/ops.ts"; const A = "iso-user-a"; const B = "iso-user-b"; @@ -145,6 +148,44 @@ async function main(): Promise { (await listArticles(A)).every((a) => a.user_id === A), ); + // ── studio (assets + edits) ── + const tA: TenantContext = { orgId: "local", userId: A }; + const tB: TenantContext = { orgId: "local", userId: B }; + const saA = await createAsset(tA, { + objectKey: "org/local/studio/isoA/original.jpg", + contentHash: "ha", + mime: "image/jpeg", + }); + const saB = await createAsset(tB, { + objectKey: "org/local/studio/isoB/original.jpg", + contentHash: "hb", + mime: "image/jpeg", + }); + const { edit: eaA } = await appendEdit(tA, { + assetId: saA.id, + parentEditId: null, + idempotencyKey: "iso-ka", + op: validateOp({ op: "adjust", params: { exposure: 0.2 } }), + }); + const { edit: eaB } = await appendEdit(tB, { + assetId: saB.id, + parentEditId: null, + idempotencyKey: "iso-kb", + op: validateOp({ op: "adjust", params: { exposure: 0.2 } }), + }); + check("studio: A cannot read B's asset", (await getAsset(tA, saB.id)) === null); + check("studio: B cannot read A's asset", (await getAsset(tB, saA.id)) === null); + check("studio: A reads its own asset", (await getAsset(tA, saA.id))?.id === saA.id); + check("studio: A history excludes B's edits", (await listEdits(tA, saB.id)).length === 0); + check( + "studio: A history has its own edit", + (await listEdits(tA, saA.id)).some((e) => e.id === eaA.id), + ); + check( + "studio: B history has its own edit", + (await listEdits(tB, saB.id)).some((e) => e.id === eaB.id), + ); + // ── Cleanup ── await vaultDelete(A, "secret.md"); await vaultDelete(B, "secret.md"); @@ -164,6 +205,8 @@ async function main(): Promise { await db.deleteFrom("user_model").where("user_id", "=", uid).execute(); await db.deleteFrom("contacts").where("user_id", "=", uid).execute(); await db.deleteFrom("wiki_articles").where("user_id", "=", uid).execute(); + await db.deleteFrom("studio_edits").where("user_id", "=", uid).execute(); + await db.deleteFrom("studio_assets").where("user_id", "=", uid).execute(); } await closeDb(); diff --git a/scripts/studio-blob-e2e.ts b/scripts/studio-blob-e2e.ts new file mode 100644 index 00000000..75f8ab47 --- /dev/null +++ b/scripts/studio-blob-e2e.ts @@ -0,0 +1,77 @@ +/** + * Real HTTP e2e for the local-fs blob serving (the iOS upload path). + * presign PUT -> real HTTP PUT -> daemon serves it -> presign GET -> real HTTP GET. + * Mirrors the Connect server's wrapper. No DB needed. + * + * Run: pnpm tsx scripts/studio-blob-e2e.ts + */ + +import "dotenv/config"; +import { createServer } from "node:http"; +import { + getObjectStore, + handleBlobRequest, + objectKey, + resetObjectStoreForTest, +} from "../src/storage/object-store.ts"; + +async function main(): Promise { + const port = 8788; + process.env.NOMOS_OBJECT_STORE_DRIVER = "local"; + process.env.NOMOS_OBJECT_STORE_PUBLIC_URL = `http://localhost:${port}`; + resetObjectStoreForTest(); + const store = getObjectStore(); + + // Exactly the Connect server's wrapper: blob route first, else 404. + const server = createServer((req, res) => { + void handleBlobRequest(req, res).then((handled) => { + if (!handled) res.writeHead(404).end("not a blob route"); + }); + }); + await new Promise((r) => server.listen(port, () => r())); + + const key = objectKey("studio", "e2e-blob", "original.jpg"); + const bytes = Buffer.from("hello studio blob upload", "utf8"); + let ok = true; + + // 1) presign + real HTTP PUT (the iOS upload) + const put = await store.presignPut(key, { contentType: "image/jpeg" }); + console.log("PUT url:", put.url); + const putResp = await fetch(put.url, { + method: "PUT", + body: bytes, + headers: { "content-type": "image/jpeg" }, + }); + console.log(`PUT -> ${putResp.status}`); + if (putResp.status !== 200) ok = false; + + // 2) the bytes actually landed in the store + const stored = Buffer.from(await store.get(key)); + console.log(`stored=${stored.length}B match=${stored.equals(bytes)}`); + if (!stored.equals(bytes)) ok = false; + + // 3) presign + real HTTP GET (refreshImage) + const get = await store.presignGet(key); + const getResp = await fetch(get.url); + const got = Buffer.from(await getResp.arrayBuffer()); + console.log( + `GET -> ${getResp.status} ${got.length}B match=${got.equals(bytes)} ct=${getResp.headers.get("content-type")}`, + ); + if (getResp.status !== 200 || !got.equals(bytes)) ok = false; + + // 4) a tampered signature must be rejected + const bad = put.url.replace(/sig=[a-f0-9]+/, "sig=deadbeef"); + const badResp = await fetch(bad, { method: "PUT", body: bytes }); + console.log(`tampered PUT -> ${badResp.status} (expect 403)`); + if (badResp.status !== 403) ok = false; + + await store.delete(key); + server.close(); + console.log(ok ? "\nBLOB E2E: PASS" : "\nBLOB E2E: FAIL"); + if (!ok) process.exit(1); +} + +main().catch((err) => { + console.error("BLOB E2E: FAIL", err); + process.exit(1); +}); diff --git a/scripts/studio-e2e.ts b/scripts/studio-e2e.ts new file mode 100644 index 00000000..031ea407 --- /dev/null +++ b/scripts/studio-e2e.ts @@ -0,0 +1,164 @@ +/** + * Real end-to-end Studio exercise (dev verification, NOT a CI test). + * + * Drives the full pipeline against the local DB + local-fs object store: + * create asset -> deterministic adjust (local-sharp) -> idempotent retry -> + * real generative edit (Gemini via GOOGLE_API_KEY) -> assert rows + objects. + * + * Run: pnpm tsx scripts/studio-e2e.ts (needs DATABASE_URL + GOOGLE_API_KEY in .env) + * Cleans up its own rows + restores the consent toggle. + */ + +import "dotenv/config"; +import { randomUUID } from "node:crypto"; +import sharp from "sharp"; +import type { TenantContext } from "../src/auth/tenant-context.ts"; +import { closeDb, getKysely } from "../src/db/client.ts"; +import { buildStudioEngine } from "../src/sdk/studio-mcp.ts"; +import { createAsset, listEdits } from "../src/studio/assets.ts"; +import { isCloudAIEnabled, setCloudAIEnabled } from "../src/studio/consent.ts"; +import { getObjectStore, objectKey } from "../src/storage/object-store.ts"; + +const ctx: TenantContext = { orgId: "local", userId: "e2e-studio" }; +const log = (...a: unknown[]) => console.log(...a); + +async function main(): Promise { + const store = getObjectStore(); + const priorConsent = await isCloudAIEnabled(); + await setCloudAIEnabled(true); + + // Use a REAL photo (synthetic/solid images trip Gemini's IMAGE_RECITATION guard). + // picsum returns a random CC0 photo; fall back to a synthetic image offline. + let img: Buffer; + try { + const resp = await fetch("https://picsum.photos/640/800"); + if (!resp.ok) throw new Error(`picsum ${resp.status}`); + img = Buffer.from(await resp.arrayBuffer()); + log("source: real photo (picsum)"); + } catch { + img = await sharp({ + create: { width: 256, height: 256, channels: 3, background: { r: 130, g: 95, b: 75 } }, + }) + .jpeg() + .toBuffer(); + log("source: synthetic (picsum unreachable)"); + } + const meta = await sharp(img).metadata(); + const key = objectKey("studio", randomUUID(), "original.jpg"); + await store.put(key, new Uint8Array(img), "image/jpeg"); + const asset = await createAsset(ctx, { + objectKey: key, + contentHash: "e2e", + mime: meta.format === "png" ? "image/png" : "image/jpeg", + width: meta.width ?? 0, + height: meta.height ?? 0, + bytes: img.byteLength, + }); + log(`asset ${asset.id} status=${asset.status}`); + + const engine = buildStudioEngine(); + + // deterministic adjust (local-sharp) end to end + preview + const adjust = await engine.edit(ctx, { + assetId: asset.id, + op: { op: "adjust", params: { exposure: 0.3, saturation: 0.2 } }, + parentEditId: asset.headEditId, + idempotencyKey: randomUUID(), + }); + if (!adjust.outputKey) throw new Error("adjust produced no output"); + const outBytes = await store.get(adjust.outputKey); + const prevBytes = adjust.previewKey ? await store.get(adjust.previewKey) : null; + log( + `adjust edit=${adjust.id} status=${adjust.status} provider=${adjust.provider} out=${outBytes.byteLength}B preview=${prevBytes?.byteLength ?? 0}B`, + ); + + // idempotent retry: same key -> same edit, no re-charge, no new row + const retryKey = randomUUID(); + const r1 = await engine.edit(ctx, { + assetId: asset.id, + op: { op: "adjust", params: { contrast: 0.1 } }, + parentEditId: adjust.id, + idempotencyKey: retryKey, + }); + const r2 = await engine.edit(ctx, { + assetId: asset.id, + op: { op: "adjust", params: { contrast: 0.1 } }, + parentEditId: adjust.id, + idempotencyKey: retryKey, + }); + log(`idempotent retry same edit: ${r1.id === r2.id}`); + + // deviceRender: the client uploads on-device-rendered pixels (here simulated by a + // sharp tint) and the engine re-encodes + stores them as the edit output. Free, + // not consent-gated, not identity-gated. + const rendered = new Uint8Array( + await sharp(img).modulate({ saturation: 1.3, brightness: 1.05 }).jpeg().toBuffer(), + ); + const dev = await engine.edit(ctx, { + assetId: asset.id, + op: { op: "deviceRender", params: { tool: "makeup", detail: "lips" } }, + parentEditId: r1.id, + idempotencyKey: randomUUID(), + inlineInputBytes: rendered, + }); + const devBytes = dev.outputKey ? await store.get(dev.outputKey) : null; + log( + `DEVICE edit=${dev.id} status=${dev.status} provider=${dev.provider} cost=$${dev.costUsd} out=${devBytes?.byteLength ?? 0}B`, + ); + if (dev.status !== "done" || !devBytes) throw new Error("deviceRender produced no output"); + + // real generative edit via Gemini (GOOGLE_API_KEY). Best-effort; logs on failure. + try { + const gen = await engine.edit(ctx, { + assetId: asset.id, + op: { op: "editSemantic", params: { instruction: "make it warmer and a bit brighter" } }, + parentEditId: dev.id, + idempotencyKey: randomUUID(), + }); + const genBytes = gen.outputKey ? await store.get(gen.outputKey) : null; + log( + `GEMINI edit=${gen.id} status=${gen.status} provider=${gen.provider} cost=$${gen.costUsd} out=${genBytes?.byteLength ?? 0}B`, + ); + + // A Phase 3 generative depth op (relight) over the same Gemini path. + const depth = await engine.edit(ctx, { + assetId: asset.id, + op: { op: "relight", params: { mood: "warm golden hour" } }, + parentEditId: gen.id, + idempotencyKey: randomUUID(), + }); + const depthBytes = depth.outputKey ? await store.get(depth.outputKey) : null; + log( + `DEPTH edit=${depth.id} op=relight status=${depth.status} provider=${depth.provider} cost=$${depth.costUsd} out=${depthBytes?.byteLength ?? 0}B`, + ); + } catch (err) { + log(`GEMINI generative edit FAILED: ${err instanceof Error ? err.message : err}`); + } + + // effect SQL goes nonzero (what the manifest audit asserts) + const db = getKysely(); + const rows = await db + .selectFrom("studio_edits") + .selectAll() + .where("user_id", "=", ctx.userId) + .execute(); + const doneCount = rows.filter((r) => r.status === "done").length; + log(`EFFECT studio_edits rows=${rows.length} done=${doneCount}`); + const chain = await listEdits(ctx, asset.id); + log(`CHAIN ${chain.map((e) => `${e.op}[${e.status}]`).join(" -> ")}`); + log( + `HEAD ${(await db.selectFrom("studio_assets").select("head_edit_id").where("id", "=", asset.id).executeTakeFirst())?.head_edit_id}`, + ); + + // cleanup + await db.deleteFrom("studio_edits").where("user_id", "=", ctx.userId).execute(); + await db.deleteFrom("studio_assets").where("user_id", "=", ctx.userId).execute(); + await setCloudAIEnabled(priorConsent); + await closeDb(); + log("CLEANED UP"); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/studio-safety-probe.ts b/scripts/studio-safety-probe.ts new file mode 100644 index 00000000..9875d9c8 --- /dev/null +++ b/scripts/studio-safety-probe.ts @@ -0,0 +1,98 @@ +/** + * A/B probe for the studio generative-safety fix. Runs the SAME portrait + edit + * through the model twice against your real creds (the daemon's surface): + * + * OLD: no config -> reproduces the `IMAGE_SAFETY` refusal + * NEW: relaxed safetySettings (the fix in gemini-image.ts) -> should pass + * + * This is the real-run verification the unit tests can't do (the SDK is mocked + * there). It needs whatever creds the daemon uses (GEMINI_API_KEY / GOOGLE_API_KEY, + * or GOOGLE_CLOUD_PROJECT + ADC for Vertex). Run it in the SAME shell that runs + * hosted-google.sh so the env matches. + * + * Usage: + * pnpm tsx scripts/studio-safety-probe.ts ["edit instruction"] + */ + +import "dotenv/config"; +import { readFile } from "node:fs/promises"; +import { GoogleGenAI } from "@google/genai"; +import { createGoogleGenAIImageClient } from "../src/studio/providers/gemini-image.ts"; + +function surface(): { ai: GoogleGenAI; model: string } { + const model = process.env.NOMOS_STUDIO_GEMINI_MODEL ?? "gemini-2.5-flash-image"; + const apiKey = process.env.GEMINI_API_KEY ?? process.env.GOOGLE_API_KEY; + const kind = process.env.NOMOS_STUDIO_PROVIDER ?? (apiKey ? "gemini" : "vertex"); + const ai = + kind === "vertex" + ? new GoogleGenAI({ + vertexai: true, + project: process.env.GOOGLE_CLOUD_PROJECT, + location: process.env.CLOUD_ML_REGION ?? "us-central1", + }) + : new GoogleGenAI({ apiKey }); + console.log(`surface=${kind} model=${model}`); + return { ai, model }; +} + +async function rawNoConfig(imageBase64: string, mime: string, prompt: string): Promise { + const { ai, model } = surface(); + const resp = await ai.models.generateContent({ + model, + contents: [ + { + role: "user", + parts: [{ inlineData: { mimeType: mime, data: imageBase64 } }, { text: prompt }], + }, + ], + }); + const cand = resp.candidates?.[0]; + const hasImage = (cand?.content?.parts ?? []).some((p) => p.inlineData?.data); + return hasImage ? "OK (image returned)" : `REFUSED (${cand?.finishReason ?? "no image"})`; +} + +async function main(): Promise { + const path = process.argv[2]; + const prompt = + process.argv[3] ?? "Subtly even out the skin tone and soften shine. Keep identity."; + if (!path) { + console.error("usage: pnpm tsx scripts/studio-safety-probe.ts [instruction]"); + process.exit(2); + } + const bytes = await readFile(path); + const mime = path.endsWith(".png") ? "image/png" : "image/jpeg"; + const imageBase64 = bytes.toString("base64"); + console.log(`image=${path} (${bytes.length}B) prompt=${JSON.stringify(prompt)}\n`); + + let oldResult = "n/a"; + try { + oldResult = await rawNoConfig(imageBase64, mime, prompt); + } catch (err) { + oldResult = `ERROR ${err instanceof Error ? err.message : String(err)}`; + } + console.log(`OLD (no safetySettings): ${oldResult}`); + + let newResult = "n/a"; + try { + const out = await createGoogleGenAIImageClient().editImage({ + imageBase64, + mimeType: mime, + prompt, + }); + newResult = `OK (image returned, ${Buffer.from(out.base64, "base64").length}B)`; + } catch (err) { + newResult = `REFUSED/ERROR ${err instanceof Error ? err.message : String(err)}`; + } + console.log(`NEW (relaxed safetySettings): ${newResult}`); + + console.log( + newResult.startsWith("OK") + ? "\nSAFETY PROBE: PASS — the fix lets the edit through." + : "\nSAFETY PROBE: still refused — likely a NON-configurable block (minors / public figure / CSAM) or a different instruction. The error message is now legible end-to-end.", + ); +} + +main().catch((err) => { + console.error("SAFETY PROBE: FAIL", err); + process.exit(1); +}); diff --git a/scripts/studio-sidecar-check.ts b/scripts/studio-sidecar-check.ts new file mode 100644 index 00000000..ddda4c38 --- /dev/null +++ b/scripts/studio-sidecar-check.ts @@ -0,0 +1,85 @@ +/** + * Real local check for the Studio sidecar path (dev verification, NOT CI). + * + * Points the engine at a running `nomos-studio-sidecar` (NOMOS_STUDIO_SIDECAR_URL, + * default http://127.0.0.1:8799), seeds an asset, runs a `retouch` edit, and + * asserts it routed to the deterministic sidecar (free) and produced output. + * + * Start the sidecar first: (cd ../nomos-studio-sidecar && uv run nomos-studio-sidecar) + * Run: pnpm tsx scripts/studio-sidecar-check.ts + */ + +import "dotenv/config"; +import { randomUUID } from "node:crypto"; +import sharp from "sharp"; +import type { TenantContext } from "../src/auth/tenant-context.ts"; +import { closeDb, getKysely } from "../src/db/client.ts"; +import { buildStudioEngine } from "../src/sdk/studio-mcp.ts"; +import { createAsset } from "../src/studio/assets.ts"; +import { ensureStudioSidecar, getStudioSidecarUrl } from "../src/studio/sidecar-launcher.ts"; +import { getObjectStore, objectKey } from "../src/storage/object-store.ts"; + +const ctx: TenantContext = { orgId: "local", userId: "e2e-sidecar" }; +const log = (...a: unknown[]) => console.log(...a); + +async function main(): Promise { + process.env.NOMOS_STUDIO_SIDECAR_URL ??= "http://127.0.0.1:8799"; + const url = await ensureStudioSidecar(); + if (!url) { + log(`SIDECAR not reachable at ${process.env.NOMOS_STUDIO_SIDECAR_URL}. Start it and retry.`); + process.exit(1); + } + log(`sidecar url=${getStudioSidecarUrl()}`); + + const store = getObjectStore(); + // A noisy image so the bilateral smoothing is measurable. + const noise = Buffer.alloc(256 * 256 * 3); + for (let i = 0; i < noise.length; i++) noise[i] = Math.floor((i * 2654435761) % 256); + const img = await sharp(noise, { raw: { width: 256, height: 256, channels: 3 } }) + .jpeg({ quality: 92 }) + .toBuffer(); + const key = objectKey("studio", randomUUID(), "original.jpg"); + await store.put(key, new Uint8Array(img), "image/jpeg"); + const asset = await createAsset(ctx, { + objectKey: key, + contentHash: "sidecar", + mime: "image/jpeg", + width: 256, + height: 256, + bytes: img.byteLength, + }); + log(`asset ${asset.id}`); + + const engine = buildStudioEngine(); + const edit = await engine.edit(ctx, { + assetId: asset.id, + op: { op: "retouch", params: { strength: 0.9 } }, + parentEditId: asset.headEditId, + idempotencyKey: randomUUID(), + }); + const outBytes = edit.outputKey ? await store.get(edit.outputKey) : null; + log( + `RETOUCH edit=${edit.id} status=${edit.status} provider=${edit.provider} cost=$${edit.costUsd} out=${outBytes?.byteLength ?? 0}B`, + ); + if (edit.status !== "done" || !outBytes) throw new Error("retouch produced no output"); + if (edit.provider !== "mediapipe-sidecar") { + throw new Error(`expected provider mediapipe-sidecar, got ${edit.provider}`); + } + if (edit.costUsd !== 0) throw new Error(`expected $0 (deterministic), got ${edit.costUsd}`); + + const db = getKysely(); + await db.deleteFrom("studio_edits").where("user_id", "=", ctx.userId).execute(); + await db.deleteFrom("studio_assets").where("user_id", "=", ctx.userId).execute(); + await closeDb(); + log("SIDECAR OK; cleaned up"); +} + +main().catch(async (err) => { + console.error(err); + try { + await closeDb(); + } catch { + // ignore + } + process.exit(1); +}); diff --git a/scripts/studio-wire-check.ts b/scripts/studio-wire-check.ts new file mode 100644 index 00000000..741753eb --- /dev/null +++ b/scripts/studio-wire-check.ts @@ -0,0 +1,171 @@ +/** + * Real gRPC wire check for the Studio deviceRender path (dev verification, NOT CI). + * + * Boots the ACTUAL grpc-js MobileApi server (the one iOS talks to) on a test port + * in power-user mode (no JWT -> LOCAL_TENANT), then over a real grpc-js client: + * - StudioEdit op=deviceRender + input_image bytes -> asserts a `done` event, + * a stored JPEG output, and a studio_edits row; + * - StudioEdit op=adjust + input_image bytes -> asserts the handler REJECTS it + * ("input_image is only valid for deviceRender"). + * + * This exercises the wire layer studio-e2e bypasses: proto decode of the `bytes` + * field -> handler guards -> engine.edit. Run: pnpm tsx scripts/studio-wire-check.ts + */ + +import "dotenv/config"; +import { randomUUID } from "node:crypto"; +import { existsSync, readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import * as grpc from "@grpc/grpc-js"; +import * as protoLoader from "@grpc/proto-loader"; +import sharp from "sharp"; +import { LOCAL_TENANT } from "../src/auth/tenant-context.ts"; +import { GrpcServer } from "../src/daemon/grpc-server.ts"; +import type { MessageQueue } from "../src/daemon/message-queue.ts"; +import { closeDb, getKysely } from "../src/db/client.ts"; +import { createAsset } from "../src/studio/assets.ts"; +import { getObjectStore, objectKey } from "../src/storage/object-store.ts"; + +const PORT = 18767; +const log = (...a: unknown[]) => console.log(...a); + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const PROTO_PATH = existsSync(resolve(__dirname, "../proto/nomos.proto")) + ? resolve(__dirname, "../proto/nomos.proto") + : resolve(__dirname, "../../proto/nomos.proto"); + +interface EditEvent { + kind: string; + editId: string; + status: string; + outputKey: string; + message: string; +} + +function makeClient(): { + StudioEdit: (req: unknown) => grpc.ClientReadableStream; + close: () => void; +} { + const def = protoLoader.loadSync(PROTO_PATH, { + keepCase: false, + longs: String, + enums: String, + defaults: true, + oneofs: true, + }); + const pkg = grpc.loadPackageDefinition(def).nomos as { + MobileApi: new (addr: string, creds: grpc.ChannelCredentials) => Record; + }; + const client = new pkg.MobileApi(`127.0.0.1:${PORT}`, grpc.credentials.createInsecure()); + return { + StudioEdit: (req) => + (client.StudioEdit as (r: unknown) => grpc.ClientReadableStream)(req), + close: () => (client as { close: () => void }).close(), + }; +} + +function runEdit( + client: ReturnType, + req: Record, +): Promise { + return new Promise((resolveP, rejectP) => { + const events: EditEvent[] = []; + const stream = client.StudioEdit(req); + stream.on("data", (ev: EditEvent) => events.push(ev)); + stream.on("end", () => resolveP(events)); + stream.on("error", (err) => rejectP(err)); + }); +} + +async function main(): Promise { + const store = getObjectStore(); + const server = new GrpcServer({} as MessageQueue, PORT); + await server.start(); + const client = makeClient(); + const ctx = LOCAL_TENANT; + + // Seed an asset under the tenant the wire resolves to (power-user -> LOCAL_TENANT). + const original = await sharp({ + create: { width: 640, height: 480, channels: 3, background: { r: 120, g: 90, b: 70 } }, + }) + .jpeg() + .toBuffer(); + const key = objectKey("studio", randomUUID(), "original.jpg"); + await store.put(key, new Uint8Array(original), "image/jpeg"); + const asset = await createAsset(ctx, { + objectKey: key, + contentHash: "wire", + mime: "image/jpeg", + width: 640, + height: 480, + bytes: original.byteLength, + }); + log(`asset ${asset.id}`); + + // The "on-device render": a tinted variant the client uploads inline. + const rendered = await sharp(original).modulate({ saturation: 1.4 }).jpeg().toBuffer(); + + // 1) deviceRender over the wire -> done + stored output. + const ok = await runEdit(client, { + assetId: asset.id, + op: "deviceRender", + paramsJson: JSON.stringify({ tool: "makeup", detail: "lips" }), + idempotencyKey: randomUUID(), + inputImage: rendered, + }); + const done = ok.find((e) => e.kind === "done"); + const err1 = ok.find((e) => e.kind === "error"); + if (err1) throw new Error(`deviceRender wire error: ${err1.message}`); + if (!done?.outputKey) throw new Error("deviceRender produced no output over the wire"); + const outBytes = await store.get(done.outputKey); + const meta = await sharp(Buffer.from(outBytes)).metadata(); + log( + `WIRE deviceRender: status=${done.status} out=${outBytes.byteLength}B fmt=${meta.format} ${meta.width}x${meta.height}`, + ); + if (meta.format !== "jpeg") throw new Error("output is not a jpeg"); + + // 2) Guard: input_image with a non-deviceRender op is rejected at the handler. + const guarded = await runEdit(client, { + assetId: asset.id, + op: "adjust", + paramsJson: JSON.stringify({ exposure: 0.2 }), + idempotencyKey: randomUUID(), + inputImage: rendered, + }); + const guardErr = guarded.find((e) => e.kind === "error"); + if (!guardErr || !/only valid for deviceRender/.test(guardErr.message)) { + throw new Error(`expected op-guard rejection, got: ${JSON.stringify(guarded)}`); + } + log(`WIRE guard: rejected as expected -> "${guardErr.message}"`); + + // Effect SQL: a done deviceRender row exists for this tenant. + const db = getKysely(); + const rows = await db + .selectFrom("studio_edits") + .selectAll() + .where("user_id", "=", ctx.userId) + .where("op", "=", "deviceRender") + .where("status", "=", "done") + .execute(); + log(`EFFECT deviceRender done rows=${rows.length}`); + if (rows.length < 1) throw new Error("no done deviceRender row persisted"); + + // cleanup + await db.deleteFrom("studio_edits").where("user_id", "=", ctx.userId).execute(); + await db.deleteFrom("studio_assets").where("user_id", "=", ctx.userId).execute(); + client.close(); + await server.stop(); + await closeDb(); + log("WIRE OK; cleaned up"); +} + +main().catch(async (err) => { + console.error(err); + try { + await closeDb(); + } catch { + // ignore + } + process.exit(1); +}); diff --git a/src/config/mode.ts b/src/config/mode.ts index f5cafd77..91c3d2fd 100644 --- a/src/config/mode.ts +++ b/src/config/mode.ts @@ -84,6 +84,13 @@ export const FEATURES = { /** /admin/* power-user pages in the Settings UI (database explorer, raw SQL, etc.). */ adminPowerUserPages: (): boolean => !isHosted(), + /** + * Studio (hosted-only feature). The inverse of the BYO gates above: + * the one feature that is OFF in power-user mode and ON in hosted, because it + * depends on the hosted object-store + per-tenant Vertex credential. + */ + studio: (): boolean => isHosted(), + // Features that stay ON in both modes (declared explicitly so the contract is documented): autoDream: (): boolean => true, magicDocs: (): boolean => true, diff --git a/src/daemon/agent-runtime.ts b/src/daemon/agent-runtime.ts index 36b2882a..653b36f5 100644 --- a/src/daemon/agent-runtime.ts +++ b/src/daemon/agent-runtime.ts @@ -29,6 +29,7 @@ import { } from "../sdk/telegram-mcp.ts"; import { isGoogleWorkspaceConfiguredAsync } from "../sdk/google-workspace-mcp.ts"; import { buildGoogleMcpServers, buildGoogleIntegrationPrompt } from "../sdk/google-mcp.ts"; +import { buildStudioMcpServer } from "../sdk/studio-mcp.ts"; import { buildVaultMcpServer } from "../sdk/vault-mcp.ts"; import { buildThinkMcpServer } from "../sdk/think-mcp.ts"; import { buildLoopMcpServer } from "../sdk/loop-mcp.ts"; @@ -963,6 +964,14 @@ export class AgentRuntime { isLoopContext: source?.platform === "cron" || (sessionKey?.startsWith("cron:") ?? false), }), }; + // Studio (hosted-only feature), scoped to this owner. Gated so power-user + // installs never load the extra tooling. + if (FEATURES.studio()) { + const studioServers: Record> = { + "nomos-studio": buildStudioMcpServer(vaultUserId), + }; + mcpServers = { ...mcpServers, ...studioServers }; + } let googlePrompt = ""; if (isHosted() && userId) { try { diff --git a/src/daemon/connect-server.ts b/src/daemon/connect-server.ts index 9877e0d4..b13f0eca 100644 --- a/src/daemon/connect-server.ts +++ b/src/daemon/connect-server.ts @@ -19,6 +19,7 @@ import type { ConnectRouter, HandlerContext } from "@connectrpc/connect"; import { ConnectError, Code } from "@connectrpc/connect"; import { MobileApi } from "../gen/nomos_pb.ts"; import { resolveContext } from "../auth/grpc-interceptor.ts"; +import { handleBlobRequest } from "../storage/object-store.ts"; import { buildMobileApiHandlers } from "./mobile-api.ts"; import type { MessageQueue } from "./message-queue.ts"; import type { DraftManager } from "./draft-manager.ts"; @@ -148,11 +149,33 @@ export class ConnectServer { getVaultNote: unary(handlers.GetVaultNote), writeVaultNote: unary(handlers.WriteVaultNote), deleteVaultNote: unary(handlers.DeleteVaultNote), + + // ── Loops ── + listLoops: unary(handlers.ListLoops), + setLoopEnabled: unary(handlers.SetLoopEnabled), + deleteLoop: unary(handlers.DeleteLoop), + + // ── Studio (hosted-only). Without these the iOS app's Studio calls hit a + // 404 over Connect even though the gRPC server (Mac/CLI) has them. ── + studioCreateAsset: unary(handlers.StudioCreateAsset), + studioGetAssetUrl: unary(handlers.StudioGetAssetUrl), + studioEdit: serverStream(handlers.StudioEdit), // server-streaming, like chat + studioHistory: unary(handlers.StudioHistory), + studioListAssets: unary(handlers.StudioListAssets), + studioSuggestEdits: unary(handlers.StudioSuggestEdits), + studioReportIdentity: unary(handlers.StudioReportIdentity), } as unknown as Parameters>[1]); }; return new Promise((resolveStart, reject) => { - this.server = createServer(connectNodeAdapter({ routes })); + // Signed blob PUT/GET for the local-fs object store are served here too (same + // host:port the client already reached us on); everything else is Connect RPC. + const connectHandler = connectNodeAdapter({ routes }); + this.server = createServer((req, res) => { + void handleBlobRequest(req, res).then((handled) => { + if (!handled) connectHandler(req, res); + }); + }); this.server.listen(this.deps.port, "0.0.0.0", () => { log.info(`Connect server listening on 0.0.0.0:${this.deps.port}`); resolveStart(); @@ -227,6 +250,76 @@ function unary(grpcHandler: grpc.handleUnaryCall) { }; } +/** + * Adapt a gRPC SERVER-STREAMING handler — `(call) => void`, emitting via + * `call.write()` and finishing on `call.end()` / `call.destroy()` — to a Connect + * async-generator handler. We synthesize a writable `call` that funnels writes into + * a queue the generator drains, mirroring `unary()` for auth (the gRPC handler reads + * the JWT off `call.metadata`). + */ +function serverStream(grpcHandler: grpc.handleServerStreamingCall) { + return async function* (req: TReq, ctx: HandlerContext): AsyncGenerator { + const metadata = new grpc.Metadata(); + const auth = ctx.requestHeader.get("authorization"); + if (auth) metadata.set("authorization", auth); + + const queue: TRes[] = []; + let resolveNext: ((v: TRes | null) => void) | null = null; + let ended = false; + const box: { failure: Error | null } = { failure: null }; + + const wake = (v: TRes | null) => { + if (resolveNext) { + const r = resolveNext; + resolveNext = null; + r(v); + } + }; + const finish = (err?: Error) => { + if (err && !box.failure) box.failure = err; + ended = true; + wake(null); + }; + + const fakeCall = { + request: req, + metadata, + cancelled: false, + write: (msg: TRes) => { + if (resolveNext) wake(msg); + else queue.push(msg); + return true; + }, + end: () => finish(), + destroy: (err?: Error) => finish(err), + on: () => fakeCall, + once: () => fakeCall, + off: () => fakeCall, + removeListener: () => fakeCall, + emit: () => false, + } as unknown as grpc.ServerWritableStream; + + // Kick off the handler; it writes events + ends/destroys the fake call. + grpcHandler(fakeCall); + + while (true) { + if (queue.length > 0) { + yield queue.shift() as TRes; + continue; + } + if (ended) break; + const next = await new Promise((r) => { + resolveNext = r; + }); + if (next === null) break; + yield next; + } + if (box.failure) { + throw new ConnectError(box.failure.message || "internal", Code.Internal); + } + }; +} + function grpcErrorMessage(err: grpc.ServiceError | Partial): string { if ("details" in err && typeof err.details === "string" && err.details.length > 0) { return err.details; diff --git a/src/daemon/cron-engine.ts b/src/daemon/cron-engine.ts index e3223400..1adb4059 100644 --- a/src/daemon/cron-engine.ts +++ b/src/daemon/cron-engine.ts @@ -124,6 +124,20 @@ export class CronEngine { return; } + // Intercept studio-gc sentinel -- clean up Studio objects/rows per owner + // (unconfirmed uploads + aged intermediate edit results). DB is the clock. + if (job.prompt === "__studio_gc__") { + log.info("Firing studio GC"); + (async () => { + const { runStudioGc } = await import("../studio/gc.ts"); + const r = await runStudioGc(); + log.info(r, "Studio GC complete"); + })().catch((err) => { + log.error({ err: err instanceof Error ? err.message : err }, "Studio GC failed"); + }); + return; + } + // Intercept magic-docs sentinel -- refresh stale self-updating docs. if (job.prompt === "__magic_docs__") { log.info("Firing magic-docs refresh"); diff --git a/src/daemon/gateway.ts b/src/daemon/gateway.ts index 8a4c7468..391c42ac 100644 --- a/src/daemon/gateway.ts +++ b/src/daemon/gateway.ts @@ -409,6 +409,45 @@ export class Gateway { process.emit("cron:refresh" as never); } + // Studio GC: clean up Studio objects/rows daily (hosted-only feature, so + // seed only when Studio is enabled; the runner is a no-op without rows). + if (FEATURES.studio() && !(await cronStore.getJobByName("studio-gc"))) { + await cronStore.createJob({ + userId: systemTenant().userId, + name: "studio-gc", + schedule: "24h", + scheduleType: "every", + sessionTarget: "isolated", + deliveryMode: "none", + prompt: "__studio_gc__", + enabled: true, + errorCount: 0, + }); + log.info("Registered studio GC cron job (every 24h)"); + process.emit("cron:refresh" as never); + } + + // Studio: install the optional server-side face embedder for the identity + // gate when a model is configured (NOMOS_FACE_MODEL_PATH). No-op otherwise; + // the privacy-preferred path is the on-device check via StudioReportIdentity. + if (FEATURES.studio()) { + try { + const { installServerFaceEmbedder } = await import("../studio/face-embedder.ts"); + await installServerFaceEmbedder(); + } catch (err) { + log.warn({ err }, "studio: face embedder install skipped"); + } + // Best-effort: bring up the deterministic beauty-ops sidecar (external URL + // or `uv run` from the sibling clone). Non-fatal — retouch falls back to + // the cloud provider when it's absent. + try { + const { ensureStudioSidecar } = await import("../studio/sidecar-launcher.ts"); + await ensureStudioSidecar(); + } catch (err) { + log.warn({ err }, "studio: sidecar launch skipped"); + } + } + // Style analysis: re-derive the user's writing voice daily. Self-gates on // config.styleMatching at fire time, so the job is harmless when the // feature is off (and reflects a later toggle without reseeding). @@ -584,6 +623,14 @@ export class Gateway { await this.grpcServer.stop(); await this.connectServer.stop(); await this.wsServer.stop(); + if (FEATURES.studio()) { + try { + const { stopStudioSidecar } = await import("../studio/sidecar-launcher.ts"); + await stopStudioSidecar(); + } catch { + // best-effort + } + } await closeBrowser(); log.info("Daemon stopped"); diff --git a/src/daemon/mobile-api.ts b/src/daemon/mobile-api.ts index 0741d91a..138e693d 100644 --- a/src/daemon/mobile-api.ts +++ b/src/daemon/mobile-api.ts @@ -45,6 +45,21 @@ import { CronStore } from "../cron/store.ts"; import { createLogger } from "../lib/logger.ts"; import type { TenantContext } from "../auth/tenant-context.ts"; import { resolveMemoryUserId } from "../auth/tenant-context.ts"; +import { buildStudioEngine } from "../sdk/studio-mcp.ts"; +import { + confirmAsset, + createAsset, + getAsset, + getEdit, + listAssets, + listEdits, + recordIdentityScore, + StaleParentError, +} from "../studio/assets.ts"; +import { ConsentRequiredError, isCloudAIEnabled, setCloudAIEnabled } from "../studio/consent.ts"; +import { readPhotoStyle } from "../studio/learn.ts"; +import { suggestEdits } from "../studio/suggest.ts"; +import { getObjectStore, objectKey } from "../storage/object-store.ts"; const log = createLogger("mobile-api"); @@ -143,6 +158,29 @@ export function buildMobileApiHandlers(deps: MobileApiDeps) { DeleteLoop: withAuthUnary("/nomos.MobileApi/DeleteLoop", (call, ctx) => handleDeleteLoop(call, ctx), ), + + // Studio (hosted-only feature) + StudioCreateAsset: withAuthUnary("/nomos.MobileApi/StudioCreateAsset", (call, ctx) => + handleStudioCreateAsset(call, ctx), + ), + StudioGetAssetUrl: withAuthUnary("/nomos.MobileApi/StudioGetAssetUrl", (call, ctx) => + handleStudioGetAssetUrl(call, ctx), + ), + StudioEdit: withAuthStream("/nomos.MobileApi/StudioEdit", (call, ctx) => + handleStudioEdit(call, ctx), + ), + StudioHistory: withAuthUnary("/nomos.MobileApi/StudioHistory", (call, ctx) => + handleStudioHistory(call, ctx), + ), + StudioListAssets: withAuthUnary("/nomos.MobileApi/StudioListAssets", (call, ctx) => + handleStudioListAssets(call, ctx), + ), + StudioSuggestEdits: withAuthUnary("/nomos.MobileApi/StudioSuggestEdits", (call, ctx) => + handleStudioSuggestEdits(call, ctx), + ), + StudioReportIdentity: withAuthUnary("/nomos.MobileApi/StudioReportIdentity", (call, ctx) => + handleStudioReportIdentity(call, ctx), + ), }; } @@ -615,6 +653,9 @@ async function handleGetSettings(ctx: TenantContext) { }, ], permissions: [ + // Studio cloud-AI consent: a real toggle (the iOS app surfaces it as its own + // "Cloud AI" row) plumbed through UpdatePermission → setCloudAIEnabled. + { id: "studio_cloud_ai", label: "Cloud AI photo edits", enabled: await isCloudAIEnabled() }, { id: "p1", label: "Read emails", enabled: true }, { id: "p2", label: "Draft replies", enabled: true }, { id: "p3", label: "Send (with approval)", enabled: true }, @@ -660,7 +701,14 @@ async function handleUpdateTrustTier( async function handleUpdatePermission( call: grpc.ServerUnaryCall, ): Promise<{ success: boolean; message: string }> { - await setConfigKey(`permission.${(call.request as any).id}`, (call.request as any).enabled); + const id = String((call.request as { id?: string }).id ?? ""); + const enabled = Boolean((call.request as { enabled?: boolean }).enabled); + if (id === "studio_cloud_ai") { + // Studio cloud-AI consent → the boolean key the consent gate reads. + await setCloudAIEnabled(enabled); + } else { + await setConfigKey(`permission.${id}`, enabled); + } return { success: true, message: "ok" }; } @@ -904,6 +952,297 @@ async function handleDeleteLoop( return { success: true, message: "deleted" }; } +// ──────────── Studio (hosted-only feature) ──────────── +// Blobs move via presigned PUT/GET, never gRPC. Every handler is user_id-scoped +// through the authenticated TenantContext. + +function notFound(message: string): Error { + return Object.assign(new Error(message), { code: grpc.status.NOT_FOUND }); +} + +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +const isUuid = (s: string): boolean => UUID_RE.test(s); + +async function handleStudioCreateAsset( + call: grpc.ServerUnaryCall, + ctx: TenantContext, +): Promise<{ + assetId: string; + uploadUrl: string; + objectKey: string; + expiresAt: number; +}> { + const req = call.request as { + mime?: string; + contentHash?: string; + width?: number; + height?: number; + bytes?: number; + }; + const mime = req.mime || "image/jpeg"; + const ext = mime === "image/png" ? "png" : "jpg"; + // The object key must embed the asset's OWN id (not a throwaway uuid): a mask + // uploaded via this same RPC is later passed back as `maskKey`, and the engine + // resolves it by extracting `/studio//` and requiring getAsset() to + // exist. A mismatched id is exactly what produced "invalid mask reference". + const assetId = randomUUID(); + const key = objectKey("studio", assetId, `original.${ext}`); + const asset = await createAsset(ctx, { + id: assetId, + objectKey: key, + contentHash: req.contentHash ?? "", + mime, + width: req.width ?? null, + height: req.height ?? null, + bytes: req.bytes ?? 0, + }); + const presigned = await getObjectStore().presignPut(key, { contentType: mime }); + return { + assetId: asset.id, + uploadUrl: presigned.url, + objectKey: key, + expiresAt: presigned.expiresAt, + }; +} + +async function handleStudioGetAssetUrl( + call: grpc.ServerUnaryCall, + ctx: TenantContext, +): Promise<{ url: string; expiresAt: number }> { + const req = call.request as { assetId?: string; original?: boolean }; + const assetId = req.assetId ?? ""; + if (!isUuid(assetId)) throw notFound("studio asset not found"); + const asset = await getAsset(ctx, assetId); + if (!asset) throw notFound("studio asset not found"); + // The immutable original (before/after compare) vs the current head. + let key = asset.objectKey; + if (!req.original && asset.headEditId) { + const head = await getEdit(ctx, asset.headEditId); + if (head?.outputKey) key = head.outputKey; + } + const presigned = await getObjectStore().presignGet(key); + return { url: presigned.url, expiresAt: presigned.expiresAt }; +} + +async function handleStudioEdit( + call: grpc.ServerWritableStream, + ctx: TenantContext, +): Promise { + // Everything is inside try/finally so the stream ALWAYS ends cleanly (a thrown + // getAsset/engine error emits an error event instead of destroying the stream). + try { + const req = call.request as { + assetId?: string; + op?: string; + paramsJson?: string; + parentEditId?: string; + idempotencyKey?: string; + maskKey?: string; + inputImage?: Uint8Array; + }; + const assetId = req.assetId ?? ""; + if (!isUuid(assetId)) { + call.write({ kind: "error", message: "invalid asset id" }); + return; + } + // Inline device-render bytes are only valid for the deviceRender op, and are + // capped well above a 4096px JPEG to keep the request bounded. + const inputImage = req.inputImage && req.inputImage.length > 0 ? req.inputImage : null; + if (inputImage && inputImage.length > 12 * 1024 * 1024) { + call.write({ kind: "error", message: "input image too large" }); + return; + } + if (inputImage && req.op !== "deviceRender") { + call.write({ kind: "error", message: "input_image is only valid for deviceRender" }); + return; + } + // A client-supplied mask must live under this tenant's object prefix; never + // let a request read an arbitrary (or another tenant's) object as a mask. + if (req.maskKey && !req.maskKey.startsWith(`org/${ctx.orgId}/`)) { + call.write({ kind: "error", message: "invalid mask reference" }); + return; + } + let params: unknown = {}; + if (req.paramsJson) { + try { + params = JSON.parse(req.paramsJson); + } catch { + call.write({ kind: "error", message: "invalid params_json" }); + return; + } + } + + const asset = await getAsset(ctx, assetId); + if (!asset) { + call.write({ kind: "error", message: "studio asset not found" }); + return; + } + const parentEditId = + req.parentEditId && req.parentEditId.length > 0 ? req.parentEditId : asset.headEditId; + + call.write({ kind: "progress", status: "running", message: `applying ${req.op ?? ""}` }); + try { + // engine.edit confirms a pending asset, gates consent, and runs the op. + const engine = buildStudioEngine(); + const edit = await engine.edit(ctx, { + assetId: asset.id, + op: { op: req.op ?? "", params }, + parentEditId, + idempotencyKey: req.idempotencyKey || randomUUID(), + maskKey: req.maskKey || null, + inlineInputBytes: inputImage, + }); + call.write({ + kind: "done", + editId: edit.id, + status: edit.status, + previewKey: edit.previewKey ?? "", + outputKey: edit.outputKey ?? "", + costUsd: edit.costUsd, + }); + } catch (err) { + const message = + err instanceof ConsentRequiredError + ? "Cloud AI is turned off. Enable it in Studio settings to use this edit." + : err instanceof StaleParentError + ? "This photo changed since you started. Refresh and try again." + : err instanceof Error + ? err.message + : String(err); + call.write({ kind: "error", message }); + } + } catch (err) { + call.write({ kind: "error", message: err instanceof Error ? err.message : String(err) }); + } finally { + call.end(); + } +} + +async function handleStudioHistory( + call: grpc.ServerUnaryCall, + ctx: TenantContext, +): Promise<{ + edits: Array<{ + id: string; + op: string; + status: string; + previewKey: string; + outputKey: string; + costUsd: number; + parentEditId: string; + createdAt: string; + }>; + headEditId: string; +}> { + const assetId = (call.request as { assetId?: string }).assetId ?? ""; + if (!isUuid(assetId)) return { edits: [], headEditId: "" }; + const asset = await getAsset(ctx, assetId); + // Fetching history means the client is using this asset; confirm it out of + // `pending` so the orphan-upload GC sweep never reaps an in-use original. + if (asset?.status === "pending") await confirmAsset(ctx, asset.id); + const edits = await listEdits(ctx, assetId); + return { + edits: edits.map((e) => ({ + id: e.id, + op: e.op, + status: e.status, + previewKey: e.previewKey ?? "", + outputKey: e.outputKey ?? "", + costUsd: e.costUsd, + parentEditId: e.parentEditId ?? "", + createdAt: e.createdAt.toISOString(), + })), + headEditId: asset?.headEditId ?? "", + }; +} + +async function handleStudioListAssets( + call: grpc.ServerUnaryCall, + ctx: TenantContext, +): Promise<{ + assets: Array<{ + assetId: string; + previewUrl: string; + updatedAt: number; + finalized: boolean; + editCount: number; + headOp: string; + expiresAt: number; + }>; +}> { + const limit = Number((call.request as { limit?: number }).limit ?? 0) || 30; + const sessions = await listAssets(ctx, limit); + const store = getObjectStore(); + const assets = await Promise.all( + sessions.map(async (s) => { + // Thumbnail: the head edit's ~256px preview, else its full output, else the original. + const key = s.headPreviewKey ?? s.headOutputKey ?? s.objectKey; + let previewUrl = ""; + let expiresAt = 0; + try { + const presigned = await store.presignGet(key); + previewUrl = presigned.url; + expiresAt = presigned.expiresAt; + } catch { + // A missing/unreadable object just yields no thumbnail; the card still lists. + } + return { + assetId: s.id, + previewUrl, + updatedAt: s.updatedAt.getTime(), + finalized: s.finalized, + editCount: s.editCount, + headOp: s.headOp ?? "", + expiresAt, + }; + }), + ); + return { assets }; +} + +async function handleStudioSuggestEdits( + call: grpc.ServerUnaryCall, + ctx: TenantContext, +): Promise<{ suggestions: Array<{ label: string; prompt: string }> }> { + const assetId = (call.request as { assetId?: string }).assetId ?? ""; + if (!isUuid(assetId)) return { suggestions: [] }; + // Analysis sends the photo to the cloud vision model, so it rides the SAME Cloud-AI + // consent as editing. Off -> empty, and the client falls back to its static chips. + if (!(await isCloudAIEnabled())) return { suggestions: [] }; + const asset = await getAsset(ctx, assetId); + if (!asset) return { suggestions: [] }; + // Analyze the CURRENT head so the chips reflect the latest result, not the original. + let key = asset.objectKey; + if (asset.headEditId) { + const head = await getEdit(ctx, asset.headEditId); + if (head?.outputKey) key = head.outputKey; + } + let bytes: Uint8Array; + try { + bytes = await getObjectStore().get(key); + } catch { + return { suggestions: [] }; + } + // Personalize: bias the suggestions toward the user's learned editing taste. + const style = await readPhotoStyle(ctx.userId); + const suggestions = await suggestEdits(bytes, asset.mime, { style: style || undefined }); + return { suggestions }; +} + +async function handleStudioReportIdentity( + call: grpc.ServerUnaryCall, + ctx: TenantContext, +): Promise<{ success: boolean; message: string }> { + const req = call.request as { editId?: string; score?: number }; + const editId = req.editId ?? ""; + if (!isUuid(editId)) return { success: false, message: "invalid edit id" }; + const score = Math.max(0, Math.min(1, Number(req.score ?? 0))); + const edit = await recordIdentityScore(ctx, editId, score); + return edit + ? { success: true, message: "recorded" } + : { success: false, message: "edit not found" }; +} + // Helpers async function listIntegrationsForUser(userId: string) { const all = await listIntegrations(); diff --git a/src/db/schema.sql b/src/db/schema.sql index 75c5ad5c..dc7946c3 100644 --- a/src/db/schema.sql +++ b/src/db/schema.sql @@ -851,3 +851,62 @@ DO $$ BEGIN WHERE jsonb_typeof(metadata) = 'string'; END IF; END $$; + +-- ── Studio: image assets + edit chains (hosted-only feature) ──────────── +-- studio_assets: one row per uploaded original. Blobs live in object storage +-- (org//studio/...); the row holds the object key + metadata. The original +-- is immutable: edits never mutate it, they append to studio_edits. status is +-- 'pending' until the client confirms its presigned upload; the __studio_gc__ +-- sentinel expires unconfirmed rows. head_edit_id is the current chain head +-- (NULL = the original is the head). Per-user scoped on top of db-per-customer. +CREATE TABLE IF NOT EXISTS studio_assets ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id TEXT NOT NULL DEFAULT 'local', + object_key TEXT NOT NULL, + content_hash TEXT NOT NULL, + mime TEXT NOT NULL, + width INT, + height INT, + bytes INT NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'pending', -- 'pending' | 'ready' | 'expired' + head_edit_id UUID, + metadata JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_studio_assets_user ON studio_assets(user_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_studio_assets_status ON studio_assets(status, created_at); +CREATE INDEX IF NOT EXISTS idx_studio_assets_hash ON studio_assets(user_id, content_hash); + +-- studio_edits: the non-destructive op chain. Each row is one validated op +-- (src/studio/ops.ts) applied on top of parent_edit_id (NULL = on the original). +-- idempotency_key makes a retried-but-committed edit a no-op (UNIQUE per asset). +-- parent_edit_id + an optimistic head check give a linear chain; a stale parent +-- is rejected. preview_key is the ~256px history preview. identity_score is the +-- face-embedding gate result for face-touching generative ops (NULL = n/a). +-- params is the op params jsonb (stored object, never double-encoded). +CREATE TABLE IF NOT EXISTS studio_edits ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + asset_id UUID NOT NULL REFERENCES studio_assets(id) ON DELETE CASCADE, + user_id TEXT NOT NULL DEFAULT 'local', + parent_edit_id UUID, + idempotency_key TEXT NOT NULL, + op TEXT NOT NULL, + op_spec_version INT NOT NULL DEFAULT 1, + params JSONB NOT NULL DEFAULT '{}', + provider TEXT, + input_key TEXT, + output_key TEXT, + preview_key TEXT, + status TEXT NOT NULL DEFAULT 'pending', -- pending|running|done|failed|expired + cost_usd REAL NOT NULL DEFAULT 0, + identity_score REAL, + error TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (asset_id, idempotency_key) +); +CREATE INDEX IF NOT EXISTS idx_studio_edits_asset ON studio_edits(asset_id, created_at); +CREATE INDEX IF NOT EXISTS idx_studio_edits_user ON studio_edits(user_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_studio_edits_status ON studio_edits(status, created_at); +CREATE INDEX IF NOT EXISTS idx_studio_edits_parent ON studio_edits(parent_edit_id); diff --git a/src/db/types.ts b/src/db/types.ts index 9da9981c..eb126404 100644 --- a/src/db/types.ts +++ b/src/db/types.ts @@ -398,6 +398,43 @@ export interface KgEdgesTable { user_id: Generated; } +export interface StudioAssetsTable { + id: Generated; + user_id: Generated; + object_key: string; + content_hash: string; + mime: string; + width: number | null; + height: number | null; + bytes: Generated; + status: Generated; + head_edit_id: string | null; + metadata: ColumnType, string | undefined, string>; + created_at: Generated; + updated_at: Generated; +} + +export interface StudioEditsTable { + id: Generated; + asset_id: string; + user_id: Generated; + parent_edit_id: string | null; + idempotency_key: string; + op: string; + op_spec_version: Generated; + params: ColumnType, string | undefined, string>; + provider: string | null; + input_key: string | null; + output_key: string | null; + preview_key: string | null; + status: Generated; + cost_usd: Generated; + identity_score: number | null; + error: string | null; + created_at: Generated; + updated_at: Generated; +} + // --------------------------------------------------------------------------- // Database interface // --------------------------------------------------------------------------- @@ -432,6 +469,8 @@ export interface Database { cate_inbound: CateInboundTable; kg_nodes: KgNodesTable; kg_edges: KgEdgesTable; + studio_assets: StudioAssetsTable; + studio_edits: StudioEditsTable; } // --------------------------------------------------------------------------- diff --git a/src/gen/nomos_pb.ts b/src/gen/nomos_pb.ts index a6b682a9..0c4ec29b 100644 --- a/src/gen/nomos_pb.ts +++ b/src/gen/nomos_pb.ts @@ -10,7 +10,7 @@ import type { Message } from "@bufbuild/protobuf"; * Describes the file nomos.proto. */ export const file_nomos: GenFile /*@__PURE__*/ = fileDesc( - "Cgtub21vcy5wcm90bxIFbm9tb3MiBwoFRW1wdHkiMwoLQ2hhdFJlcXVlc3QSDwoHY29udGVudBgBIAEoCRITCgtzZXNzaW9uX2tleRgCIAEoCSIwCgpBZ2VudEV2ZW50EgwKBHR5cGUYASABKAkSFAoManNvbl9wYXlsb2FkGAIgASgJIjYKDkNvbW1hbmRSZXF1ZXN0Eg8KB2NvbW1hbmQYASABKAkSEwoLc2Vzc2lvbl9rZXkYAiABKAkiMwoPQ29tbWFuZFJlc3BvbnNlEg8KB3N1Y2Nlc3MYASABKAgSDwoHbWVzc2FnZRgCIAEoCSJPCg5TdGF0dXNSZXNwb25zZRIPCgdydW5uaW5nGAEgASgIEhkKEWNvbm5lY3RlZF9jbGllbnRzGAIgASgFEhEKCXBsYXRmb3JtcxgDIAMoCSIlCg5TZXNzaW9uUmVxdWVzdBITCgtzZXNzaW9uX2tleRgBIAEoCSJVCg9TZXNzaW9uUmVzcG9uc2USCgoCaWQYASABKAkSEwoLc2Vzc2lvbl9rZXkYAiABKAkSDQoFbW9kZWwYAyABKAkSEgoKY3JlYXRlZF9hdBgEIAEoCSI3CgtTZXNzaW9uTGlzdBIoCghzZXNzaW9ucxgBIAMoCzIWLm5vbW9zLlNlc3Npb25SZXNwb25zZSIfCgtEcmFmdEFjdGlvbhIQCghkcmFmdF9pZBgBIAEoCSIxCg1EcmFmdFJlc3BvbnNlEg8KB3N1Y2Nlc3MYASABKAgSDwoHbWVzc2FnZRgCIAEoCSItCglEcmFmdExpc3QSIAoGZHJhZnRzGAEgAygLMhAubm9tb3MuRHJhZnRJdGVtInIKCURyYWZ0SXRlbRIKCgJpZBgBIAEoCRIPCgdjb250ZW50GAIgASgJEhAKCHBsYXRmb3JtGAMgASgJEhIKCmNoYW5uZWxfaWQYBCABKAkSDgoGc3RhdHVzGAUgASgJEhIKCmNyZWF0ZWRfYXQYBiABKAkiIQoMUG9uZ1Jlc3BvbnNlEhEKCXRpbWVzdGFtcBgBIAEoAyIoCgRNQWNrEg8KB3N1Y2Nlc3MYASABKAgSDwoHbWVzc2FnZRgCIAEoCSIjChFNVmF1bHRMaXN0UmVxdWVzdBIOCgZwcmVmaXgYASABKAkiRAoRTVZhdWx0Tm90ZVN1bW1hcnkSDAoEcGF0aBgBIAEoCRINCgV0aXRsZRgCIAEoCRISCgp1cGRhdGVkX2F0GAMgASgJIj0KEk1WYXVsdExpc3RSZXNwb25zZRInCgVub3RlcxgBIAMoCzIYLm5vbW9zLk1WYXVsdE5vdGVTdW1tYXJ5IiAKEE1WYXVsdEdldFJlcXVlc3QSDAoEcGF0aBgBIAEoCSJeCgpNVmF1bHROb3RlEgwKBHBhdGgYASABKAkSDQoFdGl0bGUYAiABKAkSDwoHY29udGVudBgDIAEoCRISCgp1cGRhdGVkX2F0GAQgASgJEg4KBmV4aXN0cxgFIAEoCCJCChJNVmF1bHRXcml0ZVJlcXVlc3QSDAoEcGF0aBgBIAEoCRIPCgdjb250ZW50GAIgASgJEg0KBXRpdGxlGAMgASgJIiMKE01WYXVsdERlbGV0ZVJlcXVlc3QSDAoEcGF0aBgBIAEoCSI0CgxNQ2hhdFJlcXVlc3QSDwoHY29udGVudBgBIAEoCRITCgtzZXNzaW9uX2tleRgCIAEoCSIwCgpNQ2hhdEV2ZW50EgwKBHR5cGUYASABKAkSFAoManNvbl9wYXlsb2FkGAIgASgJIkwKE01HZXRNZXNzYWdlc1JlcXVlc3QSEwoLc2Vzc2lvbl9rZXkYASABKAkSDQoFbGltaXQYAiABKAUSEQoJYmVmb3JlX2lkGAMgASgJIkkKCE1NZXNzYWdlEgoKAmlkGAEgASgJEgwKBHJvbGUYAiABKAkSDwoHY29udGVudBgDIAEoCRISCgpjcmVhdGVkX2F0GAQgASgJIjkKFE1HZXRNZXNzYWdlc1Jlc3BvbnNlEiEKCG1lc3NhZ2VzGAEgAygLMg8ubm9tb3MuTU1lc3NhZ2UiIAoMTURyYWZ0QWN0aW9uEhAKCGRyYWZ0X2lkGAEgASgJIj0KFE1EcmFmdEFjdGlvbldpdGhFZGl0EhAKCGRyYWZ0X2lkGAEgASgJEhMKC2VkaXRlZF90ZXh0GAIgASgJIjgKFE1EcmFmdEFjdGlvblJlc3BvbnNlEg8KB3N1Y2Nlc3MYASABKAgSDwoHbWVzc2FnZRgCIAEoCSIuCg1NSW5ib3hSZXF1ZXN0Eg4KBnN0YXR1cxgBIAEoCRINCgVsaW1pdBgCIAEoBSKYAQoKTUluYm94SXRlbRIKCgJpZBgBIAEoCRISCgpmcm9tX2xhYmVsGAIgASgJEhIKCnRydXN0X3RpZXIYAyABKAkSDwoHc3ViamVjdBgEIAEoCRIMCgR0aW1lGAUgASgJEhMKC2JvbmRfYW1vdW50GAYgASgJEg4KBnVucmVhZBgHIAEoCBISCgpjcmVhdGVkX2F0GAggASgJIkkKDk1JbmJveFJlc3BvbnNlEiAKBWl0ZW1zGAEgAygLMhEubm9tb3MuTUluYm94SXRlbRIVCg1ibG9ja2VkX2NvdW50GAIgASgFIiQKEE1FbnZlbG9wZVJlcXVlc3QSEAoIaW5ib3hfaWQYASABKAkijQEKDU1DYXRlRW52ZWxvcGUSCwoDZGlkGAEgASgJEhIKCnRydXN0X3RpZXIYAiABKAkSDgoGaW50ZW50GAMgASgJEhUKDWNvbnNlbnRfZ3JhbnQYBCABKAkSDQoFc3RhbXAYBSABKAkSEwoLYm9uZF9hbW91bnQYBiABKAkSEAoIcmF3X2pzb24YByABKAkiNwoTTUluYm94QWN0aW9uUmVxdWVzdBIQCghpbmJveF9pZBgBIAEoCRIOCgZhY3Rpb24YAiABKAkiOAoUTUluYm94QWN0aW9uUmVzcG9uc2USDwoHc3VjY2VzcxgBIAEoCBIPCgdtZXNzYWdlGAIgASgJImoKBk1Ta2lsbBIMCgRuYW1lGAEgASgJEhMKC2Rlc2NyaXB0aW9uGAIgASgJEg4KBnNvdXJjZRgDIAEoCRIPCgdlbmFibGVkGAQgASgIEg0KBWNlcnRzGAUgAygJEg0KBXByaWNlGAYgASgJIjAKD01Ta2lsbHNSZXNwb25zZRIdCgZza2lsbHMYASADKAsyDS5ub21vcy5NU2tpbGwiNAoTTVNraWxsVG9nZ2xlUmVxdWVzdBIMCgRuYW1lGAEgASgJEg8KB2VuYWJsZWQYAiABKAgiOAoUTVNraWxsVG9nZ2xlUmVzcG9uc2USDwoHc3VjY2VzcxgBIAEoCBIPCgdtZXNzYWdlGAIgASgJIiIKEE1FYXJuaW5nc1JlcXVlc3QSDgoGcGVyaW9kGAEgASgJIooBChFNRWFybmluZ3NSZXNwb25zZRIZChF0aGlzX3BlcmlvZF9jZW50cxgBIAEoAxITCgtib25kc19jb3VudBgCIAEoAxIWCg5hdmdfYm9uZF9jZW50cxgDIAEoAxIXCg9hY2NlcHRfcmF0ZV9wY3QYBCABKAUSFAoMc2VyaWVzX2NlbnRzGAUgAygDIi0KDU1HcmFwaFJlcXVlc3QSDQoFa2luZHMYASADKAkSDQoFbGltaXQYAiABKAUiXgoWTUdyYXBoTmVpZ2hib3JzUmVxdWVzdBIPCgdub2RlX2lkGAEgASgJEg0KBWRlcHRoGAIgASgFEhEKCXJlbF90eXBlcxgDIAMoCRIRCglkaXJlY3Rpb24YBCABKAkiMwoTTUdyYXBoU2VhcmNoUmVxdWVzdBINCgVxdWVyeRgBIAEoCRINCgVsaW1pdBgCIAEoBSKXAQoKTUdyYXBoTm9kZRIKCgJpZBgBIAEoCRIMCgRraW5kGAIgASgJEgwKBG5hbWUYAyABKAkSDwoHYWxpYXNlcxgEIAMoCRIPCgdzdW1tYXJ5GAUgASgJEhIKCmNvbmZpZGVuY2UYBiABKAESFQoNZXh0ZXJuYWxfa2luZBgHIAEoCRIUCgxleHRlcm5hbF9yZWYYCCABKAkiaAoKTUdyYXBoRWRnZRIKCgJpZBgBIAEoCRIOCgZzcmNfaWQYAiABKAkSDgoGZHN0X2lkGAMgASgJEhAKCHJlbF90eXBlGAQgASgJEgwKBGZhY3QYBSABKAkSDgoGd2VpZ2h0GAYgASgBIlQKDk1HcmFwaFJlc3BvbnNlEiAKBW5vZGVzGAEgAygLMhEubm9tb3MuTUdyYXBoTm9kZRIgCgVlZGdlcxgCIAMoCzIRLm5vbW9zLk1HcmFwaEVkZ2UiXgoKTVRydXN0VGllchIKCgJpZBgBIAEoCRIMCgRuYW1lGAIgASgJEhMKC2Rlc2NyaXB0aW9uGAMgASgJEgwKBG1vZGUYBCABKAkSEwoLYm9uZF9hbW91bnQYBSABKAkiOQoLTVBlcm1pc3Npb24SCgoCaWQYASABKAkSDQoFbGFiZWwYAiABKAkSDwoHZW5hYmxlZBgDIAEoCCKJAQoMTUludGVncmF0aW9uEgoKAmlkGAEgASgJEg0KBWxhYmVsGAIgASgJEgwKBGljb24YAyABKAkSEQoJY29ubmVjdGVkGAQgASgIEhUKDWFjY291bnRfZW1haWwYBSABKAkSFAoMc2VuZF9lbmFibGVkGAYgASgIEhAKCHByb3ZpZGVyGAcgASgJImgKCE1Qcm9maWxlEgwKBG5hbWUYASABKAkSDAoEcGxhbhgCIAEoCRIVCg1tZXNzYWdlX2NvdW50GAMgASgDEhQKDGVhcm5lZF9jZW50cxgEIAEoAxITCgtzYXZlZF9jZW50cxgFIAEoAyKxAQoRTVNldHRpbmdzUmVzcG9uc2USIAoHcHJvZmlsZRgBIAEoCzIPLm5vbW9zLk1Qcm9maWxlEiYKC3RydXN0X3RpZXJzGAIgAygLMhEubm9tb3MuTVRydXN0VGllchInCgtwZXJtaXNzaW9ucxgDIAMoCzISLm5vbW9zLk1QZXJtaXNzaW9uEikKDGludGVncmF0aW9ucxgEIAMoCzITLm5vbW9zLk1JbnRlZ3JhdGlvbiIxCg9NQ29uc2VudFJlcXVlc3QSEAoIcGxhdGZvcm0YASABKAkSDAoEbW9kZRgCIAEoCSJCChFNVHJ1c3RUaWVyUmVxdWVzdBIKCgJpZBgBIAEoCRIMCgRtb2RlGAIgASgJEhMKC2JvbmRfYW1vdW50GAMgASgJIjEKEk1QZXJtaXNzaW9uUmVxdWVzdBIKCgJpZBgBIAEoCRIPCgdlbmFibGVkGAIgASgIIkIKFU1JbnRlZ3JhdGlvbnNSZXNwb25zZRIpCgxpbnRlZ3JhdGlvbnMYASADKAsyEy5ub21vcy5NSW50ZWdyYXRpb24iKAoUTVN0YXJ0Q29ubmVjdFJlcXVlc3QSEAoIcHJvdmlkZXIYASABKAkiKgoVTVN0YXJ0Q29ubmVjdFJlc3BvbnNlEhEKCW9hdXRoX3VybBgBIAEoCSJDChJNRGlzY29ubmVjdFJlcXVlc3QSFgoOaW50ZWdyYXRpb25faWQYASABKAkSFQoNYWNjb3VudF9lbWFpbBgCIAEoCSI0ChVNQ29ubmVjdEdvb2dsZVJlcXVlc3QSDAoEY29kZRgBIAEoCRINCgVzdGF0ZRgCIAEoCSI/ChVNU2V0R29vZ2xlU2VuZFJlcXVlc3QSFQoNYWNjb3VudF9lbWFpbBgBIAEoCRIPCgdlbmFibGVkGAIgASgIIlEKD01EZXZpY2VSZWdpc3RlchIXCg9leHBvX3B1c2hfdG9rZW4YASABKAkSEAoIcGxhdGZvcm0YAiABKAkSEwoLYXBwX3ZlcnNpb24YAyABKAkiLAoRTURldmljZVVucmVnaXN0ZXISFwoPZXhwb19wdXNoX3Rva2VuGAEgASgJIuwBCg5EZXBvc2l0UmVxdWVzdBIQCghwcm92aWRlchgBIAEoCRIPCgd1c2VyX2lkGAIgASgJEhQKDGFjY2Vzc190b2tlbhgDIAEoCRIVCg1yZWZyZXNoX3Rva2VuGAQgASgJEhIKCmV4cGlyZXNfYXQYBSABKAMSDgoGc2NvcGVzGAYgASgJEjUKCG1ldGFkYXRhGAcgAygLMiMubm9tb3MuRGVwb3NpdFJlcXVlc3QuTWV0YWRhdGFFbnRyeRovCg1NZXRhZGF0YUVudHJ5EgsKA2tleRgBIAEoCRINCgV2YWx1ZRgCIAEoCToCOAEiSwoPRGVwb3NpdFJlc3BvbnNlEg8KB3N1Y2Nlc3MYASABKAgSDwoHbWVzc2FnZRgCIAEoCRIWCg5pbnRlZ3JhdGlvbl9pZBgDIAEoCTLkAwoKTm9tb3NBZ2VudBIvCgRDaGF0EhIubm9tb3MuQ2hhdFJlcXVlc3QaES5ub21vcy5BZ2VudEV2ZW50MAESOAoHQ29tbWFuZBIVLm5vbW9zLkNvbW1hbmRSZXF1ZXN0GhYubm9tb3MuQ29tbWFuZFJlc3BvbnNlEjAKCUdldFN0YXR1cxIMLm5vbW9zLkVtcHR5GhUubm9tb3MuU3RhdHVzUmVzcG9uc2USMAoMTGlzdFNlc3Npb25zEgwubm9tb3MuRW1wdHkaEi5ub21vcy5TZXNzaW9uTGlzdBI7CgpHZXRTZXNzaW9uEhUubm9tb3MuU2Vzc2lvblJlcXVlc3QaFi5ub21vcy5TZXNzaW9uUmVzcG9uc2USLAoKTGlzdERyYWZ0cxIMLm5vbW9zLkVtcHR5GhAubm9tb3MuRHJhZnRMaXN0EjgKDEFwcHJvdmVEcmFmdBISLm5vbW9zLkRyYWZ0QWN0aW9uGhQubm9tb3MuRHJhZnRSZXNwb25zZRI3CgtSZWplY3REcmFmdBISLm5vbW9zLkRyYWZ0QWN0aW9uGhQubm9tb3MuRHJhZnRSZXNwb25zZRIpCgRQaW5nEgwubm9tb3MuRW1wdHkaEy5ub21vcy5Qb25nUmVzcG9uc2UyyQ4KCU1vYmlsZUFwaRIwCgRDaGF0EhMubm9tb3MuTUNoYXRSZXF1ZXN0GhEubm9tb3MuTUNoYXRFdmVudDABEkYKC0dldE1lc3NhZ2VzEhoubm9tb3MuTUdldE1lc3NhZ2VzUmVxdWVzdBobLm5vbW9zLk1HZXRNZXNzYWdlc1Jlc3BvbnNlEkAKDEFwcHJvdmVEcmFmdBITLm5vbW9zLk1EcmFmdEFjdGlvbhobLm5vbW9zLk1EcmFmdEFjdGlvblJlc3BvbnNlEj8KC1JlamVjdERyYWZ0EhMubm9tb3MuTURyYWZ0QWN0aW9uGhsubm9tb3MuTURyYWZ0QWN0aW9uUmVzcG9uc2USUAoUQXBwcm92ZURyYWZ0V2l0aEVkaXQSGy5ub21vcy5NRHJhZnRBY3Rpb25XaXRoRWRpdBobLm5vbW9zLk1EcmFmdEFjdGlvblJlc3BvbnNlEjgKCUxpc3RJbmJveBIULm5vbW9zLk1JbmJveFJlcXVlc3QaFS5ub21vcy5NSW5ib3hSZXNwb25zZRJACg9HZXRDYXRlRW52ZWxvcGUSFy5ub21vcy5NRW52ZWxvcGVSZXF1ZXN0GhQubm9tb3MuTUNhdGVFbnZlbG9wZRJJCg5BY3RPbkluYm94SXRlbRIaLm5vbW9zLk1JbmJveEFjdGlvblJlcXVlc3QaGy5ub21vcy5NSW5ib3hBY3Rpb25SZXNwb25zZRIyCgpMaXN0U2tpbGxzEgwubm9tb3MuRW1wdHkaFi5ub21vcy5NU2tpbGxzUmVzcG9uc2USRgoLVG9nZ2xlU2tpbGwSGi5ub21vcy5NU2tpbGxUb2dnbGVSZXF1ZXN0Ghsubm9tb3MuTVNraWxsVG9nZ2xlUmVzcG9uc2USQAoLR2V0RWFybmluZ3MSFy5ub21vcy5NRWFybmluZ3NSZXF1ZXN0Ghgubm9tb3MuTUVhcm5pbmdzUmVzcG9uc2USNwoIR2V0R3JhcGgSFC5ub21vcy5NR3JhcGhSZXF1ZXN0GhUubm9tb3MuTUdyYXBoUmVzcG9uc2USSQoRR2V0R3JhcGhOZWlnaGJvcnMSHS5ub21vcy5NR3JhcGhOZWlnaGJvcnNSZXF1ZXN0GhUubm9tb3MuTUdyYXBoUmVzcG9uc2USQAoLU2VhcmNoR3JhcGgSGi5ub21vcy5NR3JhcGhTZWFyY2hSZXF1ZXN0GhUubm9tb3MuTUdyYXBoUmVzcG9uc2USNQoLR2V0U2V0dGluZ3MSDC5ub21vcy5FbXB0eRoYLm5vbW9zLk1TZXR0aW5nc1Jlc3BvbnNlEjQKDVVwZGF0ZUNvbnNlbnQSFi5ub21vcy5NQ29uc2VudFJlcXVlc3QaCy5ub21vcy5NQWNrEjgKD1VwZGF0ZVRydXN0VGllchIYLm5vbW9zLk1UcnVzdFRpZXJSZXF1ZXN0Ggsubm9tb3MuTUFjaxI6ChBVcGRhdGVQZXJtaXNzaW9uEhkubm9tb3MuTVBlcm1pc3Npb25SZXF1ZXN0Ggsubm9tb3MuTUFjaxI+ChBMaXN0SW50ZWdyYXRpb25zEgwubm9tb3MuRW1wdHkaHC5ub21vcy5NSW50ZWdyYXRpb25zUmVzcG9uc2USVAoXU3RhcnRDb25uZWN0SW50ZWdyYXRpb24SGy5ub21vcy5NU3RhcnRDb25uZWN0UmVxdWVzdBocLm5vbW9zLk1TdGFydENvbm5lY3RSZXNwb25zZRJBChRDb25uZWN0R29vZ2xlQWNjb3VudBIcLm5vbW9zLk1Db25uZWN0R29vZ2xlUmVxdWVzdBoLLm5vbW9zLk1BY2sSOgoNU2V0R29vZ2xlU2VuZBIcLm5vbW9zLk1TZXRHb29nbGVTZW5kUmVxdWVzdBoLLm5vbW9zLk1BY2sSPwoVRGlzY29ubmVjdEludGVncmF0aW9uEhkubm9tb3MuTURpc2Nvbm5lY3RSZXF1ZXN0Ggsubm9tb3MuTUFjaxI1Cg5SZWdpc3RlckRldmljZRIWLm5vbW9zLk1EZXZpY2VSZWdpc3RlchoLLm5vbW9zLk1BY2sSOQoQVW5yZWdpc3RlckRldmljZRIYLm5vbW9zLk1EZXZpY2VVbnJlZ2lzdGVyGgsubm9tb3MuTUFjaxJFCg5MaXN0VmF1bHROb3RlcxIYLm5vbW9zLk1WYXVsdExpc3RSZXF1ZXN0Ghkubm9tb3MuTVZhdWx0TGlzdFJlc3BvbnNlEjoKDEdldFZhdWx0Tm90ZRIXLm5vbW9zLk1WYXVsdEdldFJlcXVlc3QaES5ub21vcy5NVmF1bHROb3RlEjgKDldyaXRlVmF1bHROb3RlEhkubm9tb3MuTVZhdWx0V3JpdGVSZXF1ZXN0Ggsubm9tb3MuTUFjaxI6Cg9EZWxldGVWYXVsdE5vdGUSGi5ub21vcy5NVmF1bHREZWxldGVSZXF1ZXN0Ggsubm9tb3MuTUFjazJICgxPQXV0aERlcG9zaXQSOAoHRGVwb3NpdBIVLm5vbW9zLkRlcG9zaXRSZXF1ZXN0GhYubm9tb3MuRGVwb3NpdFJlc3BvbnNlYgZwcm90bzM", + "Cgtub21vcy5wcm90bxIFbm9tb3MiBwoFRW1wdHkijgEKCExvb3BJbmZvEgoKAmlkGAEgASgJEgwKBG5hbWUYAiABKAkSEAoIc2NoZWR1bGUYAyABKAkSDwoHZW5hYmxlZBgEIAEoCBIOCgZzb3VyY2UYBSABKAkSEwoLZXJyb3JfY291bnQYBiABKAUSEAoIbGFzdF9ydW4YByABKAkSDgoGcHJvbXB0GAggASgJIioKCExvb3BMaXN0Eh4KBWxvb3BzGAEgAygLMg8ubm9tb3MuTG9vcEluZm8iNgoVU2V0TG9vcEVuYWJsZWRSZXF1ZXN0EgwKBG5hbWUYASABKAkSDwoHZW5hYmxlZBgCIAEoCCIhChFMb29wRGVsZXRlUmVxdWVzdBIMCgRuYW1lGAEgASgJIjYKEkxvb3BBY3Rpb25SZXNwb25zZRIPCgdzdWNjZXNzGAEgASgIEg8KB21lc3NhZ2UYAiABKAkiMwoLQ2hhdFJlcXVlc3QSDwoHY29udGVudBgBIAEoCRITCgtzZXNzaW9uX2tleRgCIAEoCSIwCgpBZ2VudEV2ZW50EgwKBHR5cGUYASABKAkSFAoManNvbl9wYXlsb2FkGAIgASgJIjYKDkNvbW1hbmRSZXF1ZXN0Eg8KB2NvbW1hbmQYASABKAkSEwoLc2Vzc2lvbl9rZXkYAiABKAkiMwoPQ29tbWFuZFJlc3BvbnNlEg8KB3N1Y2Nlc3MYASABKAgSDwoHbWVzc2FnZRgCIAEoCSJPCg5TdGF0dXNSZXNwb25zZRIPCgdydW5uaW5nGAEgASgIEhkKEWNvbm5lY3RlZF9jbGllbnRzGAIgASgFEhEKCXBsYXRmb3JtcxgDIAMoCSIlCg5TZXNzaW9uUmVxdWVzdBITCgtzZXNzaW9uX2tleRgBIAEoCSJVCg9TZXNzaW9uUmVzcG9uc2USCgoCaWQYASABKAkSEwoLc2Vzc2lvbl9rZXkYAiABKAkSDQoFbW9kZWwYAyABKAkSEgoKY3JlYXRlZF9hdBgEIAEoCSI3CgtTZXNzaW9uTGlzdBIoCghzZXNzaW9ucxgBIAMoCzIWLm5vbW9zLlNlc3Npb25SZXNwb25zZSIfCgtEcmFmdEFjdGlvbhIQCghkcmFmdF9pZBgBIAEoCSIxCg1EcmFmdFJlc3BvbnNlEg8KB3N1Y2Nlc3MYASABKAgSDwoHbWVzc2FnZRgCIAEoCSItCglEcmFmdExpc3QSIAoGZHJhZnRzGAEgAygLMhAubm9tb3MuRHJhZnRJdGVtInIKCURyYWZ0SXRlbRIKCgJpZBgBIAEoCRIPCgdjb250ZW50GAIgASgJEhAKCHBsYXRmb3JtGAMgASgJEhIKCmNoYW5uZWxfaWQYBCABKAkSDgoGc3RhdHVzGAUgASgJEhIKCmNyZWF0ZWRfYXQYBiABKAkiIQoMUG9uZ1Jlc3BvbnNlEhEKCXRpbWVzdGFtcBgBIAEoAyKLAQoFTUxvb3ASCgoCaWQYASABKAkSDAoEbmFtZRgCIAEoCRIQCghzY2hlZHVsZRgDIAEoCRIPCgdlbmFibGVkGAQgASgIEg4KBnNvdXJjZRgFIAEoCRITCgtlcnJvcl9jb3VudBgGIAEoBRIQCghsYXN0X3J1bhgHIAEoCRIOCgZwcm9tcHQYCCABKAkiLQoOTUxvb3BzUmVzcG9uc2USGwoFbG9vcHMYASADKAsyDC5ub21vcy5NTG9vcCI3ChZNU2V0TG9vcEVuYWJsZWRSZXF1ZXN0EgwKBG5hbWUYASABKAkSDwoHZW5hYmxlZBgCIAEoCCIiChJNTG9vcERlbGV0ZVJlcXVlc3QSDAoEbmFtZRgBIAEoCSIoCgRNQWNrEg8KB3N1Y2Nlc3MYASABKAgSDwoHbWVzc2FnZRgCIAEoCSIjChFNVmF1bHRMaXN0UmVxdWVzdBIOCgZwcmVmaXgYASABKAkiRAoRTVZhdWx0Tm90ZVN1bW1hcnkSDAoEcGF0aBgBIAEoCRINCgV0aXRsZRgCIAEoCRISCgp1cGRhdGVkX2F0GAMgASgJIj0KEk1WYXVsdExpc3RSZXNwb25zZRInCgVub3RlcxgBIAMoCzIYLm5vbW9zLk1WYXVsdE5vdGVTdW1tYXJ5IiAKEE1WYXVsdEdldFJlcXVlc3QSDAoEcGF0aBgBIAEoCSJeCgpNVmF1bHROb3RlEgwKBHBhdGgYASABKAkSDQoFdGl0bGUYAiABKAkSDwoHY29udGVudBgDIAEoCRISCgp1cGRhdGVkX2F0GAQgASgJEg4KBmV4aXN0cxgFIAEoCCJCChJNVmF1bHRXcml0ZVJlcXVlc3QSDAoEcGF0aBgBIAEoCRIPCgdjb250ZW50GAIgASgJEg0KBXRpdGxlGAMgASgJIiMKE01WYXVsdERlbGV0ZVJlcXVlc3QSDAoEcGF0aBgBIAEoCSI0CgxNQ2hhdFJlcXVlc3QSDwoHY29udGVudBgBIAEoCRITCgtzZXNzaW9uX2tleRgCIAEoCSIwCgpNQ2hhdEV2ZW50EgwKBHR5cGUYASABKAkSFAoManNvbl9wYXlsb2FkGAIgASgJIkwKE01HZXRNZXNzYWdlc1JlcXVlc3QSEwoLc2Vzc2lvbl9rZXkYASABKAkSDQoFbGltaXQYAiABKAUSEQoJYmVmb3JlX2lkGAMgASgJIkkKCE1NZXNzYWdlEgoKAmlkGAEgASgJEgwKBHJvbGUYAiABKAkSDwoHY29udGVudBgDIAEoCRISCgpjcmVhdGVkX2F0GAQgASgJIjkKFE1HZXRNZXNzYWdlc1Jlc3BvbnNlEiEKCG1lc3NhZ2VzGAEgAygLMg8ubm9tb3MuTU1lc3NhZ2UiIAoMTURyYWZ0QWN0aW9uEhAKCGRyYWZ0X2lkGAEgASgJIj0KFE1EcmFmdEFjdGlvbldpdGhFZGl0EhAKCGRyYWZ0X2lkGAEgASgJEhMKC2VkaXRlZF90ZXh0GAIgASgJIjgKFE1EcmFmdEFjdGlvblJlc3BvbnNlEg8KB3N1Y2Nlc3MYASABKAgSDwoHbWVzc2FnZRgCIAEoCSIuCg1NSW5ib3hSZXF1ZXN0Eg4KBnN0YXR1cxgBIAEoCRINCgVsaW1pdBgCIAEoBSKYAQoKTUluYm94SXRlbRIKCgJpZBgBIAEoCRISCgpmcm9tX2xhYmVsGAIgASgJEhIKCnRydXN0X3RpZXIYAyABKAkSDwoHc3ViamVjdBgEIAEoCRIMCgR0aW1lGAUgASgJEhMKC2JvbmRfYW1vdW50GAYgASgJEg4KBnVucmVhZBgHIAEoCBISCgpjcmVhdGVkX2F0GAggASgJIkkKDk1JbmJveFJlc3BvbnNlEiAKBWl0ZW1zGAEgAygLMhEubm9tb3MuTUluYm94SXRlbRIVCg1ibG9ja2VkX2NvdW50GAIgASgFIiQKEE1FbnZlbG9wZVJlcXVlc3QSEAoIaW5ib3hfaWQYASABKAkijQEKDU1DYXRlRW52ZWxvcGUSCwoDZGlkGAEgASgJEhIKCnRydXN0X3RpZXIYAiABKAkSDgoGaW50ZW50GAMgASgJEhUKDWNvbnNlbnRfZ3JhbnQYBCABKAkSDQoFc3RhbXAYBSABKAkSEwoLYm9uZF9hbW91bnQYBiABKAkSEAoIcmF3X2pzb24YByABKAkiNwoTTUluYm94QWN0aW9uUmVxdWVzdBIQCghpbmJveF9pZBgBIAEoCRIOCgZhY3Rpb24YAiABKAkiOAoUTUluYm94QWN0aW9uUmVzcG9uc2USDwoHc3VjY2VzcxgBIAEoCBIPCgdtZXNzYWdlGAIgASgJImoKBk1Ta2lsbBIMCgRuYW1lGAEgASgJEhMKC2Rlc2NyaXB0aW9uGAIgASgJEg4KBnNvdXJjZRgDIAEoCRIPCgdlbmFibGVkGAQgASgIEg0KBWNlcnRzGAUgAygJEg0KBXByaWNlGAYgASgJIjAKD01Ta2lsbHNSZXNwb25zZRIdCgZza2lsbHMYASADKAsyDS5ub21vcy5NU2tpbGwiNAoTTVNraWxsVG9nZ2xlUmVxdWVzdBIMCgRuYW1lGAEgASgJEg8KB2VuYWJsZWQYAiABKAgiOAoUTVNraWxsVG9nZ2xlUmVzcG9uc2USDwoHc3VjY2VzcxgBIAEoCBIPCgdtZXNzYWdlGAIgASgJIiIKEE1FYXJuaW5nc1JlcXVlc3QSDgoGcGVyaW9kGAEgASgJIooBChFNRWFybmluZ3NSZXNwb25zZRIZChF0aGlzX3BlcmlvZF9jZW50cxgBIAEoAxITCgtib25kc19jb3VudBgCIAEoAxIWCg5hdmdfYm9uZF9jZW50cxgDIAEoAxIXCg9hY2NlcHRfcmF0ZV9wY3QYBCABKAUSFAoMc2VyaWVzX2NlbnRzGAUgAygDIi0KDU1HcmFwaFJlcXVlc3QSDQoFa2luZHMYASADKAkSDQoFbGltaXQYAiABKAUiXgoWTUdyYXBoTmVpZ2hib3JzUmVxdWVzdBIPCgdub2RlX2lkGAEgASgJEg0KBWRlcHRoGAIgASgFEhEKCXJlbF90eXBlcxgDIAMoCRIRCglkaXJlY3Rpb24YBCABKAkiMwoTTUdyYXBoU2VhcmNoUmVxdWVzdBINCgVxdWVyeRgBIAEoCRINCgVsaW1pdBgCIAEoBSKXAQoKTUdyYXBoTm9kZRIKCgJpZBgBIAEoCRIMCgRraW5kGAIgASgJEgwKBG5hbWUYAyABKAkSDwoHYWxpYXNlcxgEIAMoCRIPCgdzdW1tYXJ5GAUgASgJEhIKCmNvbmZpZGVuY2UYBiABKAESFQoNZXh0ZXJuYWxfa2luZBgHIAEoCRIUCgxleHRlcm5hbF9yZWYYCCABKAkiaAoKTUdyYXBoRWRnZRIKCgJpZBgBIAEoCRIOCgZzcmNfaWQYAiABKAkSDgoGZHN0X2lkGAMgASgJEhAKCHJlbF90eXBlGAQgASgJEgwKBGZhY3QYBSABKAkSDgoGd2VpZ2h0GAYgASgBIlQKDk1HcmFwaFJlc3BvbnNlEiAKBW5vZGVzGAEgAygLMhEubm9tb3MuTUdyYXBoTm9kZRIgCgVlZGdlcxgCIAMoCzIRLm5vbW9zLk1HcmFwaEVkZ2UiXgoKTVRydXN0VGllchIKCgJpZBgBIAEoCRIMCgRuYW1lGAIgASgJEhMKC2Rlc2NyaXB0aW9uGAMgASgJEgwKBG1vZGUYBCABKAkSEwoLYm9uZF9hbW91bnQYBSABKAkiOQoLTVBlcm1pc3Npb24SCgoCaWQYASABKAkSDQoFbGFiZWwYAiABKAkSDwoHZW5hYmxlZBgDIAEoCCKJAQoMTUludGVncmF0aW9uEgoKAmlkGAEgASgJEg0KBWxhYmVsGAIgASgJEgwKBGljb24YAyABKAkSEQoJY29ubmVjdGVkGAQgASgIEhUKDWFjY291bnRfZW1haWwYBSABKAkSFAoMc2VuZF9lbmFibGVkGAYgASgIEhAKCHByb3ZpZGVyGAcgASgJImgKCE1Qcm9maWxlEgwKBG5hbWUYASABKAkSDAoEcGxhbhgCIAEoCRIVCg1tZXNzYWdlX2NvdW50GAMgASgDEhQKDGVhcm5lZF9jZW50cxgEIAEoAxITCgtzYXZlZF9jZW50cxgFIAEoAyKxAQoRTVNldHRpbmdzUmVzcG9uc2USIAoHcHJvZmlsZRgBIAEoCzIPLm5vbW9zLk1Qcm9maWxlEiYKC3RydXN0X3RpZXJzGAIgAygLMhEubm9tb3MuTVRydXN0VGllchInCgtwZXJtaXNzaW9ucxgDIAMoCzISLm5vbW9zLk1QZXJtaXNzaW9uEikKDGludGVncmF0aW9ucxgEIAMoCzITLm5vbW9zLk1JbnRlZ3JhdGlvbiIxCg9NQ29uc2VudFJlcXVlc3QSEAoIcGxhdGZvcm0YASABKAkSDAoEbW9kZRgCIAEoCSJCChFNVHJ1c3RUaWVyUmVxdWVzdBIKCgJpZBgBIAEoCRIMCgRtb2RlGAIgASgJEhMKC2JvbmRfYW1vdW50GAMgASgJIjEKEk1QZXJtaXNzaW9uUmVxdWVzdBIKCgJpZBgBIAEoCRIPCgdlbmFibGVkGAIgASgIIkIKFU1JbnRlZ3JhdGlvbnNSZXNwb25zZRIpCgxpbnRlZ3JhdGlvbnMYASADKAsyEy5ub21vcy5NSW50ZWdyYXRpb24iKAoUTVN0YXJ0Q29ubmVjdFJlcXVlc3QSEAoIcHJvdmlkZXIYASABKAkiKgoVTVN0YXJ0Q29ubmVjdFJlc3BvbnNlEhEKCW9hdXRoX3VybBgBIAEoCSJDChJNRGlzY29ubmVjdFJlcXVlc3QSFgoOaW50ZWdyYXRpb25faWQYASABKAkSFQoNYWNjb3VudF9lbWFpbBgCIAEoCSI0ChVNQ29ubmVjdEdvb2dsZVJlcXVlc3QSDAoEY29kZRgBIAEoCRINCgVzdGF0ZRgCIAEoCSI/ChVNU2V0R29vZ2xlU2VuZFJlcXVlc3QSFQoNYWNjb3VudF9lbWFpbBgBIAEoCRIPCgdlbmFibGVkGAIgASgIIlEKD01EZXZpY2VSZWdpc3RlchIXCg9leHBvX3B1c2hfdG9rZW4YASABKAkSEAoIcGxhdGZvcm0YAiABKAkSEwoLYXBwX3ZlcnNpb24YAyABKAkiLAoRTURldmljZVVucmVnaXN0ZXISFwoPZXhwb19wdXNoX3Rva2VuGAEgASgJIuwBCg5EZXBvc2l0UmVxdWVzdBIQCghwcm92aWRlchgBIAEoCRIPCgd1c2VyX2lkGAIgASgJEhQKDGFjY2Vzc190b2tlbhgDIAEoCRIVCg1yZWZyZXNoX3Rva2VuGAQgASgJEhIKCmV4cGlyZXNfYXQYBSABKAMSDgoGc2NvcGVzGAYgASgJEjUKCG1ldGFkYXRhGAcgAygLMiMubm9tb3MuRGVwb3NpdFJlcXVlc3QuTWV0YWRhdGFFbnRyeRovCg1NZXRhZGF0YUVudHJ5EgsKA2tleRgBIAEoCRINCgV2YWx1ZRgCIAEoCToCOAEiSwoPRGVwb3NpdFJlc3BvbnNlEg8KB3N1Y2Nlc3MYASABKAgSDwoHbWVzc2FnZRgCIAEoCRIWCg5pbnRlZ3JhdGlvbl9pZBgDIAEoCSJtChlNU3R1ZGlvQ3JlYXRlQXNzZXRSZXF1ZXN0EgwKBG1pbWUYASABKAkSFAoMY29udGVudF9oYXNoGAIgASgJEg0KBXdpZHRoGAMgASgFEg4KBmhlaWdodBgEIAEoBRINCgVieXRlcxgFIAEoBSJqChpNU3R1ZGlvQ3JlYXRlQXNzZXRSZXNwb25zZRIQCghhc3NldF9pZBgBIAEoCRISCgp1cGxvYWRfdXJsGAIgASgJEhIKCm9iamVjdF9rZXkYAyABKAkSEgoKZXhwaXJlc19hdBgEIAEoAyI1Cg9NU3R1ZGlvQXNzZXRSZWYSEAoIYXNzZXRfaWQYASABKAkSEAoIb3JpZ2luYWwYAiABKAgiOgoXTVN0dWRpb0Fzc2V0VXJsUmVzcG9uc2USCwoDdXJsGAEgASgJEhIKCmV4cGlyZXNfYXQYAiABKAMinwEKEk1TdHVkaW9FZGl0UmVxdWVzdBIQCghhc3NldF9pZBgBIAEoCRIKCgJvcBgCIAEoCRITCgtwYXJhbXNfanNvbhgDIAEoCRIWCg5wYXJlbnRfZWRpdF9pZBgEIAEoCRIXCg9pZGVtcG90ZW5jeV9rZXkYBSABKAkSEAoIbWFza19rZXkYBiABKAkSEwoLaW5wdXRfaW1hZ2UYByABKAwiiQEKDE1TdHVkaW9FdmVudBIMCgRraW5kGAEgASgJEg8KB2VkaXRfaWQYAiABKAkSDgoGc3RhdHVzGAMgASgJEhMKC3ByZXZpZXdfa2V5GAQgASgJEhIKCm91dHB1dF9rZXkYBSABKAkSEAoIY29zdF91c2QYBiABKAESDwoHbWVzc2FnZRgHIAEoCSKcAQoLTVN0dWRpb0VkaXQSCgoCaWQYASABKAkSCgoCb3AYAiABKAkSDgoGc3RhdHVzGAMgASgJEhMKC3ByZXZpZXdfa2V5GAQgASgJEhIKCm91dHB1dF9rZXkYBSABKAkSEAoIY29zdF91c2QYBiABKAESFgoOcGFyZW50X2VkaXRfaWQYByABKAkSEgoKY3JlYXRlZF9hdBgIIAEoCSJRChZNU3R1ZGlvSGlzdG9yeVJlc3BvbnNlEiEKBWVkaXRzGAEgAygLMhIubm9tb3MuTVN0dWRpb0VkaXQSFAoMaGVhZF9lZGl0X2lkGAIgASgJIjcKFU1TdHVkaW9JZGVudGl0eVJlcG9ydBIPCgdlZGl0X2lkGAEgASgJEg0KBXNjb3JlGAIgASgBIikKGE1TdHVkaW9MaXN0QXNzZXRzUmVxdWVzdBINCgVsaW1pdBgBIAEoBSKcAQoTTVN0dWRpb0Fzc2V0U3VtbWFyeRIQCghhc3NldF9pZBgBIAEoCRITCgtwcmV2aWV3X3VybBgCIAEoCRISCgp1cGRhdGVkX2F0GAMgASgDEhEKCWZpbmFsaXplZBgEIAEoCBISCgplZGl0X2NvdW50GAUgASgFEg8KB2hlYWRfb3AYBiABKAkSEgoKZXhwaXJlc19hdBgHIAEoAyJHChlNU3R1ZGlvTGlzdEFzc2V0c1Jlc3BvbnNlEioKBmFzc2V0cxgBIAMoCzIaLm5vbW9zLk1TdHVkaW9Bc3NldFN1bW1hcnkiMgoRTVN0dWRpb1N1Z2dlc3Rpb24SDQoFbGFiZWwYASABKAkSDgoGcHJvbXB0GAIgASgJIksKGk1TdHVkaW9TdWdnZXN0aW9uc1Jlc3BvbnNlEi0KC3N1Z2dlc3Rpb25zGAEgAygLMhgubm9tb3MuTVN0dWRpb1N1Z2dlc3Rpb24yngUKCk5vbW9zQWdlbnQSLwoEQ2hhdBISLm5vbW9zLkNoYXRSZXF1ZXN0GhEubm9tb3MuQWdlbnRFdmVudDABEjgKB0NvbW1hbmQSFS5ub21vcy5Db21tYW5kUmVxdWVzdBoWLm5vbW9zLkNvbW1hbmRSZXNwb25zZRIwCglHZXRTdGF0dXMSDC5ub21vcy5FbXB0eRoVLm5vbW9zLlN0YXR1c1Jlc3BvbnNlEjAKDExpc3RTZXNzaW9ucxIMLm5vbW9zLkVtcHR5GhIubm9tb3MuU2Vzc2lvbkxpc3QSOwoKR2V0U2Vzc2lvbhIVLm5vbW9zLlNlc3Npb25SZXF1ZXN0GhYubm9tb3MuU2Vzc2lvblJlc3BvbnNlEiwKCkxpc3REcmFmdHMSDC5ub21vcy5FbXB0eRoQLm5vbW9zLkRyYWZ0TGlzdBI4CgxBcHByb3ZlRHJhZnQSEi5ub21vcy5EcmFmdEFjdGlvbhoULm5vbW9zLkRyYWZ0UmVzcG9uc2USNwoLUmVqZWN0RHJhZnQSEi5ub21vcy5EcmFmdEFjdGlvbhoULm5vbW9zLkRyYWZ0UmVzcG9uc2USKgoJTGlzdExvb3BzEgwubm9tb3MuRW1wdHkaDy5ub21vcy5Mb29wTGlzdBJJCg5TZXRMb29wRW5hYmxlZBIcLm5vbW9zLlNldExvb3BFbmFibGVkUmVxdWVzdBoZLm5vbW9zLkxvb3BBY3Rpb25SZXNwb25zZRJBCgpEZWxldGVMb29wEhgubm9tb3MuTG9vcERlbGV0ZVJlcXVlc3QaGS5ub21vcy5Mb29wQWN0aW9uUmVzcG9uc2USKQoEUGluZxIMLm5vbW9zLkVtcHR5GhMubm9tb3MuUG9uZ1Jlc3BvbnNlMokUCglNb2JpbGVBcGkSMAoEQ2hhdBITLm5vbW9zLk1DaGF0UmVxdWVzdBoRLm5vbW9zLk1DaGF0RXZlbnQwARJGCgtHZXRNZXNzYWdlcxIaLm5vbW9zLk1HZXRNZXNzYWdlc1JlcXVlc3QaGy5ub21vcy5NR2V0TWVzc2FnZXNSZXNwb25zZRJACgxBcHByb3ZlRHJhZnQSEy5ub21vcy5NRHJhZnRBY3Rpb24aGy5ub21vcy5NRHJhZnRBY3Rpb25SZXNwb25zZRI/CgtSZWplY3REcmFmdBITLm5vbW9zLk1EcmFmdEFjdGlvbhobLm5vbW9zLk1EcmFmdEFjdGlvblJlc3BvbnNlElAKFEFwcHJvdmVEcmFmdFdpdGhFZGl0Ehsubm9tb3MuTURyYWZ0QWN0aW9uV2l0aEVkaXQaGy5ub21vcy5NRHJhZnRBY3Rpb25SZXNwb25zZRI4CglMaXN0SW5ib3gSFC5ub21vcy5NSW5ib3hSZXF1ZXN0GhUubm9tb3MuTUluYm94UmVzcG9uc2USQAoPR2V0Q2F0ZUVudmVsb3BlEhcubm9tb3MuTUVudmVsb3BlUmVxdWVzdBoULm5vbW9zLk1DYXRlRW52ZWxvcGUSSQoOQWN0T25JbmJveEl0ZW0SGi5ub21vcy5NSW5ib3hBY3Rpb25SZXF1ZXN0Ghsubm9tb3MuTUluYm94QWN0aW9uUmVzcG9uc2USMgoKTGlzdFNraWxscxIMLm5vbW9zLkVtcHR5GhYubm9tb3MuTVNraWxsc1Jlc3BvbnNlEkYKC1RvZ2dsZVNraWxsEhoubm9tb3MuTVNraWxsVG9nZ2xlUmVxdWVzdBobLm5vbW9zLk1Ta2lsbFRvZ2dsZVJlc3BvbnNlEkAKC0dldEVhcm5pbmdzEhcubm9tb3MuTUVhcm5pbmdzUmVxdWVzdBoYLm5vbW9zLk1FYXJuaW5nc1Jlc3BvbnNlEjcKCEdldEdyYXBoEhQubm9tb3MuTUdyYXBoUmVxdWVzdBoVLm5vbW9zLk1HcmFwaFJlc3BvbnNlEkkKEUdldEdyYXBoTmVpZ2hib3JzEh0ubm9tb3MuTUdyYXBoTmVpZ2hib3JzUmVxdWVzdBoVLm5vbW9zLk1HcmFwaFJlc3BvbnNlEkAKC1NlYXJjaEdyYXBoEhoubm9tb3MuTUdyYXBoU2VhcmNoUmVxdWVzdBoVLm5vbW9zLk1HcmFwaFJlc3BvbnNlEjUKC0dldFNldHRpbmdzEgwubm9tb3MuRW1wdHkaGC5ub21vcy5NU2V0dGluZ3NSZXNwb25zZRI0Cg1VcGRhdGVDb25zZW50EhYubm9tb3MuTUNvbnNlbnRSZXF1ZXN0Ggsubm9tb3MuTUFjaxI4Cg9VcGRhdGVUcnVzdFRpZXISGC5ub21vcy5NVHJ1c3RUaWVyUmVxdWVzdBoLLm5vbW9zLk1BY2sSOgoQVXBkYXRlUGVybWlzc2lvbhIZLm5vbW9zLk1QZXJtaXNzaW9uUmVxdWVzdBoLLm5vbW9zLk1BY2sSPgoQTGlzdEludGVncmF0aW9ucxIMLm5vbW9zLkVtcHR5Ghwubm9tb3MuTUludGVncmF0aW9uc1Jlc3BvbnNlElQKF1N0YXJ0Q29ubmVjdEludGVncmF0aW9uEhsubm9tb3MuTVN0YXJ0Q29ubmVjdFJlcXVlc3QaHC5ub21vcy5NU3RhcnRDb25uZWN0UmVzcG9uc2USQQoUQ29ubmVjdEdvb2dsZUFjY291bnQSHC5ub21vcy5NQ29ubmVjdEdvb2dsZVJlcXVlc3QaCy5ub21vcy5NQWNrEjoKDVNldEdvb2dsZVNlbmQSHC5ub21vcy5NU2V0R29vZ2xlU2VuZFJlcXVlc3QaCy5ub21vcy5NQWNrEj8KFURpc2Nvbm5lY3RJbnRlZ3JhdGlvbhIZLm5vbW9zLk1EaXNjb25uZWN0UmVxdWVzdBoLLm5vbW9zLk1BY2sSNQoOUmVnaXN0ZXJEZXZpY2USFi5ub21vcy5NRGV2aWNlUmVnaXN0ZXIaCy5ub21vcy5NQWNrEjkKEFVucmVnaXN0ZXJEZXZpY2USGC5ub21vcy5NRGV2aWNlVW5yZWdpc3RlchoLLm5vbW9zLk1BY2sSRQoOTGlzdFZhdWx0Tm90ZXMSGC5ub21vcy5NVmF1bHRMaXN0UmVxdWVzdBoZLm5vbW9zLk1WYXVsdExpc3RSZXNwb25zZRI6CgxHZXRWYXVsdE5vdGUSFy5ub21vcy5NVmF1bHRHZXRSZXF1ZXN0GhEubm9tb3MuTVZhdWx0Tm90ZRI4Cg5Xcml0ZVZhdWx0Tm90ZRIZLm5vbW9zLk1WYXVsdFdyaXRlUmVxdWVzdBoLLm5vbW9zLk1BY2sSOgoPRGVsZXRlVmF1bHROb3RlEhoubm9tb3MuTVZhdWx0RGVsZXRlUmVxdWVzdBoLLm5vbW9zLk1BY2sSMAoJTGlzdExvb3BzEgwubm9tb3MuRW1wdHkaFS5ub21vcy5NTG9vcHNSZXNwb25zZRI8Cg5TZXRMb29wRW5hYmxlZBIdLm5vbW9zLk1TZXRMb29wRW5hYmxlZFJlcXVlc3QaCy5ub21vcy5NQWNrEjQKCkRlbGV0ZUxvb3ASGS5ub21vcy5NTG9vcERlbGV0ZVJlcXVlc3QaCy5ub21vcy5NQWNrElgKEVN0dWRpb0NyZWF0ZUFzc2V0EiAubm9tb3MuTVN0dWRpb0NyZWF0ZUFzc2V0UmVxdWVzdBohLm5vbW9zLk1TdHVkaW9DcmVhdGVBc3NldFJlc3BvbnNlEksKEVN0dWRpb0dldEFzc2V0VXJsEhYubm9tb3MuTVN0dWRpb0Fzc2V0UmVmGh4ubm9tb3MuTVN0dWRpb0Fzc2V0VXJsUmVzcG9uc2USPgoKU3R1ZGlvRWRpdBIZLm5vbW9zLk1TdHVkaW9FZGl0UmVxdWVzdBoTLm5vbW9zLk1TdHVkaW9FdmVudDABEkYKDVN0dWRpb0hpc3RvcnkSFi5ub21vcy5NU3R1ZGlvQXNzZXRSZWYaHS5ub21vcy5NU3R1ZGlvSGlzdG9yeVJlc3BvbnNlElUKEFN0dWRpb0xpc3RBc3NldHMSHy5ub21vcy5NU3R1ZGlvTGlzdEFzc2V0c1JlcXVlc3QaIC5ub21vcy5NU3R1ZGlvTGlzdEFzc2V0c1Jlc3BvbnNlEk8KElN0dWRpb1N1Z2dlc3RFZGl0cxIWLm5vbW9zLk1TdHVkaW9Bc3NldFJlZhohLm5vbW9zLk1TdHVkaW9TdWdnZXN0aW9uc1Jlc3BvbnNlEkEKFFN0dWRpb1JlcG9ydElkZW50aXR5Ehwubm9tb3MuTVN0dWRpb0lkZW50aXR5UmVwb3J0Ggsubm9tb3MuTUFjazJICgxPQXV0aERlcG9zaXQSOAoHRGVwb3NpdBIVLm5vbW9zLkRlcG9zaXRSZXF1ZXN0GhYubm9tb3MuRGVwb3NpdFJlc3BvbnNlYgZwcm90bzM", ); /** @@ -24,6 +24,142 @@ export type Empty = Message<"nomos.Empty"> & {}; */ export const EmptySchema: GenMessage /*@__PURE__*/ = messageDesc(file_nomos, 0); +/** + * @generated from message nomos.LoopInfo + */ +export type LoopInfo = Message<"nomos.LoopInfo"> & { + /** + * @generated from field: string id = 1; + */ + id: string; + + /** + * @generated from field: string name = 2; + */ + name: string; + + /** + * @generated from field: string schedule = 3; + */ + schedule: string; + + /** + * @generated from field: bool enabled = 4; + */ + enabled: boolean; + + /** + * system | bundled | user | agent + * + * @generated from field: string source = 5; + */ + source: string; + + /** + * @generated from field: int32 error_count = 6; + */ + errorCount: number; + + /** + * ISO-8601, empty if never run + * + * @generated from field: string last_run = 7; + */ + lastRun: string; + + /** + * @generated from field: string prompt = 8; + */ + prompt: string; +}; + +/** + * Describes the message nomos.LoopInfo. + * Use `create(LoopInfoSchema)` to create a new message. + */ +export const LoopInfoSchema: GenMessage /*@__PURE__*/ = messageDesc(file_nomos, 1); + +/** + * @generated from message nomos.LoopList + */ +export type LoopList = Message<"nomos.LoopList"> & { + /** + * @generated from field: repeated nomos.LoopInfo loops = 1; + */ + loops: LoopInfo[]; +}; + +/** + * Describes the message nomos.LoopList. + * Use `create(LoopListSchema)` to create a new message. + */ +export const LoopListSchema: GenMessage /*@__PURE__*/ = messageDesc(file_nomos, 2); + +/** + * @generated from message nomos.SetLoopEnabledRequest + */ +export type SetLoopEnabledRequest = Message<"nomos.SetLoopEnabledRequest"> & { + /** + * @generated from field: string name = 1; + */ + name: string; + + /** + * @generated from field: bool enabled = 2; + */ + enabled: boolean; +}; + +/** + * Describes the message nomos.SetLoopEnabledRequest. + * Use `create(SetLoopEnabledRequestSchema)` to create a new message. + */ +export const SetLoopEnabledRequestSchema: GenMessage /*@__PURE__*/ = + messageDesc(file_nomos, 3); + +/** + * @generated from message nomos.LoopDeleteRequest + */ +export type LoopDeleteRequest = Message<"nomos.LoopDeleteRequest"> & { + /** + * @generated from field: string name = 1; + */ + name: string; +}; + +/** + * Describes the message nomos.LoopDeleteRequest. + * Use `create(LoopDeleteRequestSchema)` to create a new message. + */ +export const LoopDeleteRequestSchema: GenMessage /*@__PURE__*/ = messageDesc( + file_nomos, + 4, +); + +/** + * @generated from message nomos.LoopActionResponse + */ +export type LoopActionResponse = Message<"nomos.LoopActionResponse"> & { + /** + * @generated from field: bool success = 1; + */ + success: boolean; + + /** + * @generated from field: string message = 2; + */ + message: string; +}; + +/** + * Describes the message nomos.LoopActionResponse. + * Use `create(LoopActionResponseSchema)` to create a new message. + */ +export const LoopActionResponseSchema: GenMessage /*@__PURE__*/ = messageDesc( + file_nomos, + 5, +); + /** * @generated from message nomos.ChatRequest */ @@ -43,7 +179,7 @@ export type ChatRequest = Message<"nomos.ChatRequest"> & { * Describes the message nomos.ChatRequest. * Use `create(ChatRequestSchema)` to create a new message. */ -export const ChatRequestSchema: GenMessage /*@__PURE__*/ = messageDesc(file_nomos, 1); +export const ChatRequestSchema: GenMessage /*@__PURE__*/ = messageDesc(file_nomos, 6); /** * @generated from message nomos.AgentEvent @@ -68,7 +204,7 @@ export type AgentEvent = Message<"nomos.AgentEvent"> & { * Describes the message nomos.AgentEvent. * Use `create(AgentEventSchema)` to create a new message. */ -export const AgentEventSchema: GenMessage /*@__PURE__*/ = messageDesc(file_nomos, 2); +export const AgentEventSchema: GenMessage /*@__PURE__*/ = messageDesc(file_nomos, 7); /** * @generated from message nomos.CommandRequest @@ -91,7 +227,7 @@ export type CommandRequest = Message<"nomos.CommandRequest"> & { */ export const CommandRequestSchema: GenMessage /*@__PURE__*/ = messageDesc( file_nomos, - 3, + 8, ); /** @@ -115,7 +251,7 @@ export type CommandResponse = Message<"nomos.CommandResponse"> & { */ export const CommandResponseSchema: GenMessage /*@__PURE__*/ = messageDesc( file_nomos, - 4, + 9, ); /** @@ -144,7 +280,7 @@ export type StatusResponse = Message<"nomos.StatusResponse"> & { */ export const StatusResponseSchema: GenMessage /*@__PURE__*/ = messageDesc( file_nomos, - 5, + 10, ); /** @@ -163,7 +299,7 @@ export type SessionRequest = Message<"nomos.SessionRequest"> & { */ export const SessionRequestSchema: GenMessage /*@__PURE__*/ = messageDesc( file_nomos, - 6, + 11, ); /** @@ -197,7 +333,7 @@ export type SessionResponse = Message<"nomos.SessionResponse"> & { */ export const SessionResponseSchema: GenMessage /*@__PURE__*/ = messageDesc( file_nomos, - 7, + 12, ); /** @@ -214,7 +350,7 @@ export type SessionList = Message<"nomos.SessionList"> & { * Describes the message nomos.SessionList. * Use `create(SessionListSchema)` to create a new message. */ -export const SessionListSchema: GenMessage /*@__PURE__*/ = messageDesc(file_nomos, 8); +export const SessionListSchema: GenMessage /*@__PURE__*/ = messageDesc(file_nomos, 13); /** * @generated from message nomos.DraftAction @@ -230,7 +366,7 @@ export type DraftAction = Message<"nomos.DraftAction"> & { * Describes the message nomos.DraftAction. * Use `create(DraftActionSchema)` to create a new message. */ -export const DraftActionSchema: GenMessage /*@__PURE__*/ = messageDesc(file_nomos, 9); +export const DraftActionSchema: GenMessage /*@__PURE__*/ = messageDesc(file_nomos, 14); /** * @generated from message nomos.DraftResponse @@ -253,7 +389,7 @@ export type DraftResponse = Message<"nomos.DraftResponse"> & { */ export const DraftResponseSchema: GenMessage /*@__PURE__*/ = messageDesc( file_nomos, - 10, + 15, ); /** @@ -270,7 +406,7 @@ export type DraftList = Message<"nomos.DraftList"> & { * Describes the message nomos.DraftList. * Use `create(DraftListSchema)` to create a new message. */ -export const DraftListSchema: GenMessage /*@__PURE__*/ = messageDesc(file_nomos, 11); +export const DraftListSchema: GenMessage /*@__PURE__*/ = messageDesc(file_nomos, 16); /** * @generated from message nomos.DraftItem @@ -311,7 +447,7 @@ export type DraftItem = Message<"nomos.DraftItem"> & { * Describes the message nomos.DraftItem. * Use `create(DraftItemSchema)` to create a new message. */ -export const DraftItemSchema: GenMessage /*@__PURE__*/ = messageDesc(file_nomos, 12); +export const DraftItemSchema: GenMessage /*@__PURE__*/ = messageDesc(file_nomos, 17); /** * @generated from message nomos.PongResponse @@ -329,7 +465,124 @@ export type PongResponse = Message<"nomos.PongResponse"> & { */ export const PongResponseSchema: GenMessage /*@__PURE__*/ = messageDesc( file_nomos, - 13, + 18, +); + +/** + * Loops (autonomous recurring jobs) + * + * @generated from message nomos.MLoop + */ +export type MLoop = Message<"nomos.MLoop"> & { + /** + * @generated from field: string id = 1; + */ + id: string; + + /** + * @generated from field: string name = 2; + */ + name: string; + + /** + * @generated from field: string schedule = 3; + */ + schedule: string; + + /** + * @generated from field: bool enabled = 4; + */ + enabled: boolean; + + /** + * system | bundled | user | agent + * + * @generated from field: string source = 5; + */ + source: string; + + /** + * @generated from field: int32 error_count = 6; + */ + errorCount: number; + + /** + * ISO-8601, empty if never run + * + * @generated from field: string last_run = 7; + */ + lastRun: string; + + /** + * @generated from field: string prompt = 8; + */ + prompt: string; +}; + +/** + * Describes the message nomos.MLoop. + * Use `create(MLoopSchema)` to create a new message. + */ +export const MLoopSchema: GenMessage /*@__PURE__*/ = messageDesc(file_nomos, 19); + +/** + * @generated from message nomos.MLoopsResponse + */ +export type MLoopsResponse = Message<"nomos.MLoopsResponse"> & { + /** + * @generated from field: repeated nomos.MLoop loops = 1; + */ + loops: MLoop[]; +}; + +/** + * Describes the message nomos.MLoopsResponse. + * Use `create(MLoopsResponseSchema)` to create a new message. + */ +export const MLoopsResponseSchema: GenMessage /*@__PURE__*/ = messageDesc( + file_nomos, + 20, +); + +/** + * @generated from message nomos.MSetLoopEnabledRequest + */ +export type MSetLoopEnabledRequest = Message<"nomos.MSetLoopEnabledRequest"> & { + /** + * @generated from field: string name = 1; + */ + name: string; + + /** + * @generated from field: bool enabled = 2; + */ + enabled: boolean; +}; + +/** + * Describes the message nomos.MSetLoopEnabledRequest. + * Use `create(MSetLoopEnabledRequestSchema)` to create a new message. + */ +export const MSetLoopEnabledRequestSchema: GenMessage /*@__PURE__*/ = + messageDesc(file_nomos, 21); + +/** + * @generated from message nomos.MLoopDeleteRequest + */ +export type MLoopDeleteRequest = Message<"nomos.MLoopDeleteRequest"> & { + /** + * @generated from field: string name = 1; + */ + name: string; +}; + +/** + * Describes the message nomos.MLoopDeleteRequest. + * Use `create(MLoopDeleteRequestSchema)` to create a new message. + */ +export const MLoopDeleteRequestSchema: GenMessage /*@__PURE__*/ = messageDesc( + file_nomos, + 22, ); /** @@ -351,7 +604,7 @@ export type MAck = Message<"nomos.MAck"> & { * Describes the message nomos.MAck. * Use `create(MAckSchema)` to create a new message. */ -export const MAckSchema: GenMessage /*@__PURE__*/ = messageDesc(file_nomos, 14); +export const MAckSchema: GenMessage /*@__PURE__*/ = messageDesc(file_nomos, 23); /** * Vault (long-term memory / knowledge base) @@ -371,7 +624,7 @@ export type MVaultListRequest = Message<"nomos.MVaultListRequest"> & { */ export const MVaultListRequestSchema: GenMessage /*@__PURE__*/ = messageDesc( file_nomos, - 15, + 24, ); /** @@ -400,7 +653,7 @@ export type MVaultNoteSummary = Message<"nomos.MVaultNoteSummary"> & { */ export const MVaultNoteSummarySchema: GenMessage /*@__PURE__*/ = messageDesc( file_nomos, - 16, + 25, ); /** @@ -419,7 +672,7 @@ export type MVaultListResponse = Message<"nomos.MVaultListResponse"> & { */ export const MVaultListResponseSchema: GenMessage /*@__PURE__*/ = messageDesc( file_nomos, - 17, + 26, ); /** @@ -438,7 +691,7 @@ export type MVaultGetRequest = Message<"nomos.MVaultGetRequest"> & { */ export const MVaultGetRequestSchema: GenMessage /*@__PURE__*/ = messageDesc( file_nomos, - 18, + 27, ); /** @@ -475,7 +728,7 @@ export type MVaultNote = Message<"nomos.MVaultNote"> & { * Describes the message nomos.MVaultNote. * Use `create(MVaultNoteSchema)` to create a new message. */ -export const MVaultNoteSchema: GenMessage /*@__PURE__*/ = messageDesc(file_nomos, 19); +export const MVaultNoteSchema: GenMessage /*@__PURE__*/ = messageDesc(file_nomos, 28); /** * @generated from message nomos.MVaultWriteRequest @@ -503,7 +756,7 @@ export type MVaultWriteRequest = Message<"nomos.MVaultWriteRequest"> & { */ export const MVaultWriteRequestSchema: GenMessage /*@__PURE__*/ = messageDesc( file_nomos, - 20, + 29, ); /** @@ -522,7 +775,7 @@ export type MVaultDeleteRequest = Message<"nomos.MVaultDeleteRequest"> & { */ export const MVaultDeleteRequestSchema: GenMessage /*@__PURE__*/ = messageDesc( file_nomos, - 21, + 30, ); /** @@ -548,7 +801,7 @@ export type MChatRequest = Message<"nomos.MChatRequest"> & { */ export const MChatRequestSchema: GenMessage /*@__PURE__*/ = messageDesc( file_nomos, - 22, + 31, ); /** @@ -570,7 +823,7 @@ export type MChatEvent = Message<"nomos.MChatEvent"> & { * Describes the message nomos.MChatEvent. * Use `create(MChatEventSchema)` to create a new message. */ -export const MChatEventSchema: GenMessage /*@__PURE__*/ = messageDesc(file_nomos, 23); +export const MChatEventSchema: GenMessage /*@__PURE__*/ = messageDesc(file_nomos, 32); /** * @generated from message nomos.MGetMessagesRequest @@ -598,7 +851,7 @@ export type MGetMessagesRequest = Message<"nomos.MGetMessagesRequest"> & { */ export const MGetMessagesRequestSchema: GenMessage /*@__PURE__*/ = messageDesc( file_nomos, - 24, + 33, ); /** @@ -632,7 +885,7 @@ export type MMessage = Message<"nomos.MMessage"> & { * Describes the message nomos.MMessage. * Use `create(MMessageSchema)` to create a new message. */ -export const MMessageSchema: GenMessage /*@__PURE__*/ = messageDesc(file_nomos, 25); +export const MMessageSchema: GenMessage /*@__PURE__*/ = messageDesc(file_nomos, 34); /** * @generated from message nomos.MGetMessagesResponse @@ -649,7 +902,7 @@ export type MGetMessagesResponse = Message<"nomos.MGetMessagesResponse"> & { * Use `create(MGetMessagesResponseSchema)` to create a new message. */ export const MGetMessagesResponseSchema: GenMessage /*@__PURE__*/ = - messageDesc(file_nomos, 26); + messageDesc(file_nomos, 35); /** * @generated from message nomos.MDraftAction @@ -667,7 +920,7 @@ export type MDraftAction = Message<"nomos.MDraftAction"> & { */ export const MDraftActionSchema: GenMessage /*@__PURE__*/ = messageDesc( file_nomos, - 27, + 36, ); /** @@ -690,7 +943,7 @@ export type MDraftActionWithEdit = Message<"nomos.MDraftActionWithEdit"> & { * Use `create(MDraftActionWithEditSchema)` to create a new message. */ export const MDraftActionWithEditSchema: GenMessage /*@__PURE__*/ = - messageDesc(file_nomos, 28); + messageDesc(file_nomos, 37); /** * @generated from message nomos.MDraftActionResponse @@ -712,7 +965,7 @@ export type MDraftActionResponse = Message<"nomos.MDraftActionResponse"> & { * Use `create(MDraftActionResponseSchema)` to create a new message. */ export const MDraftActionResponseSchema: GenMessage /*@__PURE__*/ = - messageDesc(file_nomos, 29); + messageDesc(file_nomos, 38); /** * Inbox @@ -739,7 +992,7 @@ export type MInboxRequest = Message<"nomos.MInboxRequest"> & { */ export const MInboxRequestSchema: GenMessage /*@__PURE__*/ = messageDesc( file_nomos, - 30, + 39, ); /** @@ -797,7 +1050,7 @@ export type MInboxItem = Message<"nomos.MInboxItem"> & { * Describes the message nomos.MInboxItem. * Use `create(MInboxItemSchema)` to create a new message. */ -export const MInboxItemSchema: GenMessage /*@__PURE__*/ = messageDesc(file_nomos, 31); +export const MInboxItemSchema: GenMessage /*@__PURE__*/ = messageDesc(file_nomos, 40); /** * @generated from message nomos.MInboxResponse @@ -820,7 +1073,7 @@ export type MInboxResponse = Message<"nomos.MInboxResponse"> & { */ export const MInboxResponseSchema: GenMessage /*@__PURE__*/ = messageDesc( file_nomos, - 32, + 41, ); /** @@ -839,7 +1092,7 @@ export type MEnvelopeRequest = Message<"nomos.MEnvelopeRequest"> & { */ export const MEnvelopeRequestSchema: GenMessage /*@__PURE__*/ = messageDesc( file_nomos, - 33, + 42, ); /** @@ -888,7 +1141,7 @@ export type MCateEnvelope = Message<"nomos.MCateEnvelope"> & { */ export const MCateEnvelopeSchema: GenMessage /*@__PURE__*/ = messageDesc( file_nomos, - 34, + 43, ); /** @@ -914,7 +1167,7 @@ export type MInboxActionRequest = Message<"nomos.MInboxActionRequest"> & { */ export const MInboxActionRequestSchema: GenMessage /*@__PURE__*/ = messageDesc( file_nomos, - 35, + 44, ); /** @@ -937,7 +1190,7 @@ export type MInboxActionResponse = Message<"nomos.MInboxActionResponse"> & { * Use `create(MInboxActionResponseSchema)` to create a new message. */ export const MInboxActionResponseSchema: GenMessage /*@__PURE__*/ = - messageDesc(file_nomos, 36); + messageDesc(file_nomos, 45); /** * Skills @@ -982,7 +1235,7 @@ export type MSkill = Message<"nomos.MSkill"> & { * Describes the message nomos.MSkill. * Use `create(MSkillSchema)` to create a new message. */ -export const MSkillSchema: GenMessage /*@__PURE__*/ = messageDesc(file_nomos, 37); +export const MSkillSchema: GenMessage /*@__PURE__*/ = messageDesc(file_nomos, 46); /** * @generated from message nomos.MSkillsResponse @@ -1000,7 +1253,7 @@ export type MSkillsResponse = Message<"nomos.MSkillsResponse"> & { */ export const MSkillsResponseSchema: GenMessage /*@__PURE__*/ = messageDesc( file_nomos, - 38, + 47, ); /** @@ -1024,7 +1277,7 @@ export type MSkillToggleRequest = Message<"nomos.MSkillToggleRequest"> & { */ export const MSkillToggleRequestSchema: GenMessage /*@__PURE__*/ = messageDesc( file_nomos, - 39, + 48, ); /** @@ -1047,7 +1300,7 @@ export type MSkillToggleResponse = Message<"nomos.MSkillToggleResponse"> & { * Use `create(MSkillToggleResponseSchema)` to create a new message. */ export const MSkillToggleResponseSchema: GenMessage /*@__PURE__*/ = - messageDesc(file_nomos, 40); + messageDesc(file_nomos, 49); /** * Earnings @@ -1069,7 +1322,7 @@ export type MEarningsRequest = Message<"nomos.MEarningsRequest"> & { */ export const MEarningsRequestSchema: GenMessage /*@__PURE__*/ = messageDesc( file_nomos, - 41, + 50, ); /** @@ -1112,7 +1365,7 @@ export type MEarningsResponse = Message<"nomos.MEarningsResponse"> & { */ export const MEarningsResponseSchema: GenMessage /*@__PURE__*/ = messageDesc( file_nomos, - 42, + 51, ); /** @@ -1142,7 +1395,7 @@ export type MGraphRequest = Message<"nomos.MGraphRequest"> & { */ export const MGraphRequestSchema: GenMessage /*@__PURE__*/ = messageDesc( file_nomos, - 43, + 52, ); /** @@ -1181,7 +1434,7 @@ export type MGraphNeighborsRequest = Message<"nomos.MGraphNeighborsRequest"> & { * Use `create(MGraphNeighborsRequestSchema)` to create a new message. */ export const MGraphNeighborsRequestSchema: GenMessage /*@__PURE__*/ = - messageDesc(file_nomos, 44); + messageDesc(file_nomos, 53); /** * @generated from message nomos.MGraphSearchRequest @@ -1204,7 +1457,7 @@ export type MGraphSearchRequest = Message<"nomos.MGraphSearchRequest"> & { */ export const MGraphSearchRequestSchema: GenMessage /*@__PURE__*/ = messageDesc( file_nomos, - 45, + 54, ); /** @@ -1258,7 +1511,7 @@ export type MGraphNode = Message<"nomos.MGraphNode"> & { * Describes the message nomos.MGraphNode. * Use `create(MGraphNodeSchema)` to create a new message. */ -export const MGraphNodeSchema: GenMessage /*@__PURE__*/ = messageDesc(file_nomos, 46); +export const MGraphNodeSchema: GenMessage /*@__PURE__*/ = messageDesc(file_nomos, 55); /** * @generated from message nomos.MGraphEdge @@ -1299,7 +1552,7 @@ export type MGraphEdge = Message<"nomos.MGraphEdge"> & { * Describes the message nomos.MGraphEdge. * Use `create(MGraphEdgeSchema)` to create a new message. */ -export const MGraphEdgeSchema: GenMessage /*@__PURE__*/ = messageDesc(file_nomos, 47); +export const MGraphEdgeSchema: GenMessage /*@__PURE__*/ = messageDesc(file_nomos, 56); /** * @generated from message nomos.MGraphResponse @@ -1322,7 +1575,7 @@ export type MGraphResponse = Message<"nomos.MGraphResponse"> & { */ export const MGraphResponseSchema: GenMessage /*@__PURE__*/ = messageDesc( file_nomos, - 48, + 57, ); /** @@ -1363,7 +1616,7 @@ export type MTrustTier = Message<"nomos.MTrustTier"> & { * Describes the message nomos.MTrustTier. * Use `create(MTrustTierSchema)` to create a new message. */ -export const MTrustTierSchema: GenMessage /*@__PURE__*/ = messageDesc(file_nomos, 49); +export const MTrustTierSchema: GenMessage /*@__PURE__*/ = messageDesc(file_nomos, 58); /** * @generated from message nomos.MPermission @@ -1389,7 +1642,7 @@ export type MPermission = Message<"nomos.MPermission"> & { * Describes the message nomos.MPermission. * Use `create(MPermissionSchema)` to create a new message. */ -export const MPermissionSchema: GenMessage /*@__PURE__*/ = messageDesc(file_nomos, 50); +export const MPermissionSchema: GenMessage /*@__PURE__*/ = messageDesc(file_nomos, 59); /** * @generated from message nomos.MIntegration @@ -1443,7 +1696,7 @@ export type MIntegration = Message<"nomos.MIntegration"> & { */ export const MIntegrationSchema: GenMessage /*@__PURE__*/ = messageDesc( file_nomos, - 51, + 60, ); /** @@ -1480,7 +1733,7 @@ export type MProfile = Message<"nomos.MProfile"> & { * Describes the message nomos.MProfile. * Use `create(MProfileSchema)` to create a new message. */ -export const MProfileSchema: GenMessage /*@__PURE__*/ = messageDesc(file_nomos, 52); +export const MProfileSchema: GenMessage /*@__PURE__*/ = messageDesc(file_nomos, 61); /** * @generated from message nomos.MSettingsResponse @@ -1513,7 +1766,7 @@ export type MSettingsResponse = Message<"nomos.MSettingsResponse"> & { */ export const MSettingsResponseSchema: GenMessage /*@__PURE__*/ = messageDesc( file_nomos, - 53, + 62, ); /** @@ -1539,7 +1792,7 @@ export type MConsentRequest = Message<"nomos.MConsentRequest"> & { */ export const MConsentRequestSchema: GenMessage /*@__PURE__*/ = messageDesc( file_nomos, - 54, + 63, ); /** @@ -1568,7 +1821,7 @@ export type MTrustTierRequest = Message<"nomos.MTrustTierRequest"> & { */ export const MTrustTierRequestSchema: GenMessage /*@__PURE__*/ = messageDesc( file_nomos, - 55, + 64, ); /** @@ -1592,7 +1845,7 @@ export type MPermissionRequest = Message<"nomos.MPermissionRequest"> & { */ export const MPermissionRequestSchema: GenMessage /*@__PURE__*/ = messageDesc( file_nomos, - 56, + 65, ); /** @@ -1610,7 +1863,7 @@ export type MIntegrationsResponse = Message<"nomos.MIntegrationsResponse"> & { * Use `create(MIntegrationsResponseSchema)` to create a new message. */ export const MIntegrationsResponseSchema: GenMessage /*@__PURE__*/ = - messageDesc(file_nomos, 57); + messageDesc(file_nomos, 66); /** * @generated from message nomos.MStartConnectRequest @@ -1629,7 +1882,7 @@ export type MStartConnectRequest = Message<"nomos.MStartConnectRequest"> & { * Use `create(MStartConnectRequestSchema)` to create a new message. */ export const MStartConnectRequestSchema: GenMessage /*@__PURE__*/ = - messageDesc(file_nomos, 58); + messageDesc(file_nomos, 67); /** * @generated from message nomos.MStartConnectResponse @@ -1650,7 +1903,7 @@ export type MStartConnectResponse = Message<"nomos.MStartConnectResponse"> & { * Use `create(MStartConnectResponseSchema)` to create a new message. */ export const MStartConnectResponseSchema: GenMessage /*@__PURE__*/ = - messageDesc(file_nomos, 59); + messageDesc(file_nomos, 68); /** * @generated from message nomos.MDisconnectRequest @@ -1675,7 +1928,7 @@ export type MDisconnectRequest = Message<"nomos.MDisconnectRequest"> & { */ export const MDisconnectRequestSchema: GenMessage /*@__PURE__*/ = messageDesc( file_nomos, - 60, + 69, ); /** @@ -1702,7 +1955,7 @@ export type MConnectGoogleRequest = Message<"nomos.MConnectGoogleRequest"> & { * Use `create(MConnectGoogleRequestSchema)` to create a new message. */ export const MConnectGoogleRequestSchema: GenMessage /*@__PURE__*/ = - messageDesc(file_nomos, 61); + messageDesc(file_nomos, 70); /** * @generated from message nomos.MSetGoogleSendRequest @@ -1724,7 +1977,7 @@ export type MSetGoogleSendRequest = Message<"nomos.MSetGoogleSendRequest"> & { * Use `create(MSetGoogleSendRequestSchema)` to create a new message. */ export const MSetGoogleSendRequestSchema: GenMessage /*@__PURE__*/ = - messageDesc(file_nomos, 62); + messageDesc(file_nomos, 71); /** * @generated from message nomos.MDeviceRegister @@ -1754,7 +2007,7 @@ export type MDeviceRegister = Message<"nomos.MDeviceRegister"> & { */ export const MDeviceRegisterSchema: GenMessage /*@__PURE__*/ = messageDesc( file_nomos, - 63, + 72, ); /** @@ -1773,7 +2026,7 @@ export type MDeviceUnregister = Message<"nomos.MDeviceUnregister"> & { */ export const MDeviceUnregisterSchema: GenMessage /*@__PURE__*/ = messageDesc( file_nomos, - 64, + 73, ); /** @@ -1833,7 +2086,7 @@ export type DepositRequest = Message<"nomos.DepositRequest"> & { */ export const DepositRequestSchema: GenMessage /*@__PURE__*/ = messageDesc( file_nomos, - 65, + 74, ); /** @@ -1864,53 +2117,533 @@ export type DepositResponse = Message<"nomos.DepositResponse"> & { */ export const DepositResponseSchema: GenMessage /*@__PURE__*/ = messageDesc( file_nomos, - 66, + 75, ); /** - * @generated from service nomos.NomosAgent + * ── Studio (hosted-only feature) ────────────────────────────────────── + * Register an uploaded original. The client uploads the (downscaled, transcoded) + * image to upload_url (presigned PUT), then confirms by calling StudioEdit or + * StudioHistory; unconfirmed rows are reaped by __studio_gc__. + * + * @generated from message nomos.MStudioCreateAssetRequest */ -export const NomosAgent: GenService<{ +export type MStudioCreateAssetRequest = Message<"nomos.MStudioCreateAssetRequest"> & { /** - * Send a chat message and receive a stream of agent events + * e.g. image/jpeg (HEIC transcoded client-side) * - * @generated from rpc nomos.NomosAgent.Chat + * @generated from field: string mime = 1; */ - chat: { - methodKind: "server_streaming"; - input: typeof ChatRequestSchema; - output: typeof AgentEventSchema; - }; + mime: string; + /** - * Send a command (e.g., /compact) + * sha256 of the bytes * - * @generated from rpc nomos.NomosAgent.Command + * @generated from field: string content_hash = 2; */ - command: { - methodKind: "unary"; - input: typeof CommandRequestSchema; - output: typeof CommandResponseSchema; - }; + contentHash: string; + /** - * Get daemon status - * - * @generated from rpc nomos.NomosAgent.GetStatus + * @generated from field: int32 width = 3; */ - getStatus: { - methodKind: "unary"; - input: typeof EmptySchema; - output: typeof StatusResponseSchema; - }; + width: number; + /** - * Session management - * - * @generated from rpc nomos.NomosAgent.ListSessions + * @generated from field: int32 height = 4; */ - listSessions: { - methodKind: "unary"; - input: typeof EmptySchema; - output: typeof SessionListSchema; - }; + height: number; + + /** + * @generated from field: int32 bytes = 5; + */ + bytes: number; +}; + +/** + * Describes the message nomos.MStudioCreateAssetRequest. + * Use `create(MStudioCreateAssetRequestSchema)` to create a new message. + */ +export const MStudioCreateAssetRequestSchema: GenMessage /*@__PURE__*/ = + messageDesc(file_nomos, 76); + +/** + * @generated from message nomos.MStudioCreateAssetResponse + */ +export type MStudioCreateAssetResponse = Message<"nomos.MStudioCreateAssetResponse"> & { + /** + * @generated from field: string asset_id = 1; + */ + assetId: string; + + /** + * presigned PUT + * + * @generated from field: string upload_url = 2; + */ + uploadUrl: string; + + /** + * @generated from field: string object_key = 3; + */ + objectKey: string; + + /** + * ms epoch + * + * @generated from field: int64 expires_at = 4; + */ + expiresAt: bigint; +}; + +/** + * Describes the message nomos.MStudioCreateAssetResponse. + * Use `create(MStudioCreateAssetResponseSchema)` to create a new message. + */ +export const MStudioCreateAssetResponseSchema: GenMessage /*@__PURE__*/ = + messageDesc(file_nomos, 77); + +/** + * @generated from message nomos.MStudioAssetRef + */ +export type MStudioAssetRef = Message<"nomos.MStudioAssetRef"> & { + /** + * @generated from field: string asset_id = 1; + */ + assetId: string; + + /** + * GetAssetUrl: presign the ORIGINAL (for before/after compare) + * + * @generated from field: bool original = 2; + */ + original: boolean; +}; + +/** + * Describes the message nomos.MStudioAssetRef. + * Use `create(MStudioAssetRefSchema)` to create a new message. + */ +export const MStudioAssetRefSchema: GenMessage /*@__PURE__*/ = messageDesc( + file_nomos, + 78, +); + +/** + * @generated from message nomos.MStudioAssetUrlResponse + */ +export type MStudioAssetUrlResponse = Message<"nomos.MStudioAssetUrlResponse"> & { + /** + * presigned GET for the current head (or original) + * + * @generated from field: string url = 1; + */ + url: string; + + /** + * @generated from field: int64 expires_at = 2; + */ + expiresAt: bigint; +}; + +/** + * Describes the message nomos.MStudioAssetUrlResponse. + * Use `create(MStudioAssetUrlResponseSchema)` to create a new message. + */ +export const MStudioAssetUrlResponseSchema: GenMessage /*@__PURE__*/ = + messageDesc(file_nomos, 79); + +/** + * Apply one op. params_json is the JSON-encoded op params (validated server-side + * against the op registry). parent_edit_id "" means "on the current head". + * + * @generated from message nomos.MStudioEditRequest + */ +export type MStudioEditRequest = Message<"nomos.MStudioEditRequest"> & { + /** + * @generated from field: string asset_id = 1; + */ + assetId: string; + + /** + * adjust | editSemantic | cutout | upscale | restore | ... + * + * @generated from field: string op = 2; + */ + op: string; + + /** + * @generated from field: string params_json = 3; + */ + paramsJson: string; + + /** + * @generated from field: string parent_edit_id = 4; + */ + parentEditId: string; + + /** + * @generated from field: string idempotency_key = 5; + */ + idempotencyKey: string; + + /** + * optional device/tap mask object key + * + * @generated from field: string mask_key = 6; + */ + maskKey: string; + + /** + * deviceRender only: the on-device-rendered output bytes + * + * @generated from field: bytes input_image = 7; + */ + inputImage: Uint8Array; +}; + +/** + * Describes the message nomos.MStudioEditRequest. + * Use `create(MStudioEditRequestSchema)` to create a new message. + */ +export const MStudioEditRequestSchema: GenMessage /*@__PURE__*/ = messageDesc( + file_nomos, + 80, +); + +/** + * @generated from message nomos.MStudioEvent + */ +export type MStudioEvent = Message<"nomos.MStudioEvent"> & { + /** + * progress | done | error + * + * @generated from field: string kind = 1; + */ + kind: string; + + /** + * @generated from field: string edit_id = 2; + */ + editId: string; + + /** + * pending | running | done | failed + * + * @generated from field: string status = 3; + */ + status: string; + + /** + * @generated from field: string preview_key = 4; + */ + previewKey: string; + + /** + * @generated from field: string output_key = 5; + */ + outputKey: string; + + /** + * @generated from field: double cost_usd = 6; + */ + costUsd: number; + + /** + * @generated from field: string message = 7; + */ + message: string; +}; + +/** + * Describes the message nomos.MStudioEvent. + * Use `create(MStudioEventSchema)` to create a new message. + */ +export const MStudioEventSchema: GenMessage /*@__PURE__*/ = messageDesc( + file_nomos, + 81, +); + +/** + * @generated from message nomos.MStudioEdit + */ +export type MStudioEdit = Message<"nomos.MStudioEdit"> & { + /** + * @generated from field: string id = 1; + */ + id: string; + + /** + * @generated from field: string op = 2; + */ + op: string; + + /** + * @generated from field: string status = 3; + */ + status: string; + + /** + * @generated from field: string preview_key = 4; + */ + previewKey: string; + + /** + * @generated from field: string output_key = 5; + */ + outputKey: string; + + /** + * @generated from field: double cost_usd = 6; + */ + costUsd: number; + + /** + * @generated from field: string parent_edit_id = 7; + */ + parentEditId: string; + + /** + * @generated from field: string created_at = 8; + */ + createdAt: string; +}; + +/** + * Describes the message nomos.MStudioEdit. + * Use `create(MStudioEditSchema)` to create a new message. + */ +export const MStudioEditSchema: GenMessage /*@__PURE__*/ = messageDesc(file_nomos, 82); + +/** + * @generated from message nomos.MStudioHistoryResponse + */ +export type MStudioHistoryResponse = Message<"nomos.MStudioHistoryResponse"> & { + /** + * @generated from field: repeated nomos.MStudioEdit edits = 1; + */ + edits: MStudioEdit[]; + + /** + * @generated from field: string head_edit_id = 2; + */ + headEditId: string; +}; + +/** + * Describes the message nomos.MStudioHistoryResponse. + * Use `create(MStudioHistoryResponseSchema)` to create a new message. + */ +export const MStudioHistoryResponseSchema: GenMessage /*@__PURE__*/ = + messageDesc(file_nomos, 83); + +/** + * @generated from message nomos.MStudioIdentityReport + */ +export type MStudioIdentityReport = Message<"nomos.MStudioIdentityReport"> & { + /** + * @generated from field: string edit_id = 1; + */ + editId: string; + + /** + * face-embedding similarity in 0..1 + * + * @generated from field: double score = 2; + */ + score: number; +}; + +/** + * Describes the message nomos.MStudioIdentityReport. + * Use `create(MStudioIdentityReportSchema)` to create a new message. + */ +export const MStudioIdentityReportSchema: GenMessage /*@__PURE__*/ = + messageDesc(file_nomos, 84); + +/** + * A recent editing session for the Home launchpad. An asset + the gist of its chain. + * + * @generated from message nomos.MStudioListAssetsRequest + */ +export type MStudioListAssetsRequest = Message<"nomos.MStudioListAssetsRequest"> & { + /** + * max sessions (server caps; default ~30) + * + * @generated from field: int32 limit = 1; + */ + limit: number; +}; + +/** + * Describes the message nomos.MStudioListAssetsRequest. + * Use `create(MStudioListAssetsRequestSchema)` to create a new message. + */ +export const MStudioListAssetsRequestSchema: GenMessage /*@__PURE__*/ = + messageDesc(file_nomos, 85); + +/** + * @generated from message nomos.MStudioAssetSummary + */ +export type MStudioAssetSummary = Message<"nomos.MStudioAssetSummary"> & { + /** + * @generated from field: string asset_id = 1; + */ + assetId: string; + + /** + * presigned GET (~thumbnail): head preview, else original + * + * @generated from field: string preview_url = 2; + */ + previewUrl: string; + + /** + * ms epoch + * + * @generated from field: int64 updated_at = 3; + */ + updatedAt: bigint; + + /** + * a stored final artifact (vs in-progress) + * + * @generated from field: bool finalized = 4; + */ + finalized: boolean; + + /** + * @generated from field: int32 edit_count = 5; + */ + editCount: number; + + /** + * op of the head edit ("" if none) — client humanizes + * + * @generated from field: string head_op = 6; + */ + headOp: string; + + /** + * ms epoch for preview_url + * + * @generated from field: int64 expires_at = 7; + */ + expiresAt: bigint; +}; + +/** + * Describes the message nomos.MStudioAssetSummary. + * Use `create(MStudioAssetSummarySchema)` to create a new message. + */ +export const MStudioAssetSummarySchema: GenMessage /*@__PURE__*/ = messageDesc( + file_nomos, + 86, +); + +/** + * @generated from message nomos.MStudioListAssetsResponse + */ +export type MStudioListAssetsResponse = Message<"nomos.MStudioListAssetsResponse"> & { + /** + * @generated from field: repeated nomos.MStudioAssetSummary assets = 1; + */ + assets: MStudioAssetSummary[]; +}; + +/** + * Describes the message nomos.MStudioListAssetsResponse. + * Use `create(MStudioListAssetsResponseSchema)` to create a new message. + */ +export const MStudioListAssetsResponseSchema: GenMessage /*@__PURE__*/ = + messageDesc(file_nomos, 87); + +/** + * A per-photo edit suggestion: a short chip label + the instruction it applies. + * + * @generated from message nomos.MStudioSuggestion + */ +export type MStudioSuggestion = Message<"nomos.MStudioSuggestion"> & { + /** + * @generated from field: string label = 1; + */ + label: string; + + /** + * @generated from field: string prompt = 2; + */ + prompt: string; +}; + +/** + * Describes the message nomos.MStudioSuggestion. + * Use `create(MStudioSuggestionSchema)` to create a new message. + */ +export const MStudioSuggestionSchema: GenMessage /*@__PURE__*/ = messageDesc( + file_nomos, + 88, +); + +/** + * @generated from message nomos.MStudioSuggestionsResponse + */ +export type MStudioSuggestionsResponse = Message<"nomos.MStudioSuggestionsResponse"> & { + /** + * @generated from field: repeated nomos.MStudioSuggestion suggestions = 1; + */ + suggestions: MStudioSuggestion[]; +}; + +/** + * Describes the message nomos.MStudioSuggestionsResponse. + * Use `create(MStudioSuggestionsResponseSchema)` to create a new message. + */ +export const MStudioSuggestionsResponseSchema: GenMessage /*@__PURE__*/ = + messageDesc(file_nomos, 89); + +/** + * @generated from service nomos.NomosAgent + */ +export const NomosAgent: GenService<{ + /** + * Send a chat message and receive a stream of agent events + * + * @generated from rpc nomos.NomosAgent.Chat + */ + chat: { + methodKind: "server_streaming"; + input: typeof ChatRequestSchema; + output: typeof AgentEventSchema; + }; + /** + * Send a command (e.g., /compact) + * + * @generated from rpc nomos.NomosAgent.Command + */ + command: { + methodKind: "unary"; + input: typeof CommandRequestSchema; + output: typeof CommandResponseSchema; + }; + /** + * Get daemon status + * + * @generated from rpc nomos.NomosAgent.GetStatus + */ + getStatus: { + methodKind: "unary"; + input: typeof EmptySchema; + output: typeof StatusResponseSchema; + }; + /** + * Session management + * + * @generated from rpc nomos.NomosAgent.ListSessions + */ + listSessions: { + methodKind: "unary"; + input: typeof EmptySchema; + output: typeof SessionListSchema; + }; /** * @generated from rpc nomos.NomosAgent.GetSession */ @@ -1945,6 +2678,32 @@ export const NomosAgent: GenService<{ input: typeof DraftActionSchema; output: typeof DraftResponseSchema; }; + /** + * Autonomous loops (recurring background jobs — audit + disable) + * + * @generated from rpc nomos.NomosAgent.ListLoops + */ + listLoops: { + methodKind: "unary"; + input: typeof EmptySchema; + output: typeof LoopListSchema; + }; + /** + * @generated from rpc nomos.NomosAgent.SetLoopEnabled + */ + setLoopEnabled: { + methodKind: "unary"; + input: typeof SetLoopEnabledRequestSchema; + output: typeof LoopActionResponseSchema; + }; + /** + * @generated from rpc nomos.NomosAgent.DeleteLoop + */ + deleteLoop: { + methodKind: "unary"; + input: typeof LoopDeleteRequestSchema; + output: typeof LoopActionResponseSchema; + }; /** * Health check * @@ -2209,6 +2968,96 @@ export const MobileApi: GenService<{ input: typeof MVaultDeleteRequestSchema; output: typeof MAckSchema; }; + /** + * Loops tab (autonomous recurring jobs — audit + disable agent-created loops) + * + * @generated from rpc nomos.MobileApi.ListLoops + */ + listLoops: { + methodKind: "unary"; + input: typeof EmptySchema; + output: typeof MLoopsResponseSchema; + }; + /** + * @generated from rpc nomos.MobileApi.SetLoopEnabled + */ + setLoopEnabled: { + methodKind: "unary"; + input: typeof MSetLoopEnabledRequestSchema; + output: typeof MAckSchema; + }; + /** + * @generated from rpc nomos.MobileApi.DeleteLoop + */ + deleteLoop: { + methodKind: "unary"; + input: typeof MLoopDeleteRequestSchema; + output: typeof MAckSchema; + }; + /** + * Studio (hosted-only feature). Blobs move via presigned PUT/GET, never gRPC. + * + * @generated from rpc nomos.MobileApi.StudioCreateAsset + */ + studioCreateAsset: { + methodKind: "unary"; + input: typeof MStudioCreateAssetRequestSchema; + output: typeof MStudioCreateAssetResponseSchema; + }; + /** + * @generated from rpc nomos.MobileApi.StudioGetAssetUrl + */ + studioGetAssetUrl: { + methodKind: "unary"; + input: typeof MStudioAssetRefSchema; + output: typeof MStudioAssetUrlResponseSchema; + }; + /** + * @generated from rpc nomos.MobileApi.StudioEdit + */ + studioEdit: { + methodKind: "server_streaming"; + input: typeof MStudioEditRequestSchema; + output: typeof MStudioEventSchema; + }; + /** + * @generated from rpc nomos.MobileApi.StudioHistory + */ + studioHistory: { + methodKind: "unary"; + input: typeof MStudioAssetRefSchema; + output: typeof MStudioHistoryResponseSchema; + }; + /** + * Recent editing sessions for the Home launchpad ("Pick up where you left off"). + * + * @generated from rpc nomos.MobileApi.StudioListAssets + */ + studioListAssets: { + methodKind: "unary"; + input: typeof MStudioListAssetsRequestSchema; + output: typeof MStudioListAssetsResponseSchema; + }; + /** + * AI-native: a vision model looks at the photo and proposes tap-to-apply edits. + * + * @generated from rpc nomos.MobileApi.StudioSuggestEdits + */ + studioSuggestEdits: { + methodKind: "unary"; + input: typeof MStudioAssetRefSchema; + output: typeof MStudioSuggestionsResponseSchema; + }; + /** + * The on-device identity check reports its score for an edit (0..1). + * + * @generated from rpc nomos.MobileApi.StudioReportIdentity + */ + studioReportIdentity: { + methodKind: "unary"; + input: typeof MStudioIdentityReportSchema; + output: typeof MAckSchema; + }; }> /*@__PURE__*/ = serviceDesc(file_nomos, 1); /** diff --git a/src/sdk/compact-prompt.ts b/src/sdk/compact-prompt.ts deleted file mode 100644 index 291cfe6a..00000000 --- a/src/sdk/compact-prompt.ts +++ /dev/null @@ -1,159 +0,0 @@ -/** - * Conversation compaction prompts. - * - * Structured 9-section summarization adapted from Claude Code's compact service. - * Used when conversations approach context limits to preserve critical details - * while reducing token count. - * - * The scratchpad block improves summary quality and is stripped - * before the summary reaches the conversation context. - */ - -const ANALYSIS_INSTRUCTION = `Before providing your final summary, wrap your analysis in tags to organize your thoughts and ensure you've covered all necessary points. In your analysis process: - -1. Chronologically analyze each message and section of the conversation. For each section thoroughly identify: - - The user's explicit requests and intents - - Your approach to addressing the user's requests - - Key decisions, technical concepts and code patterns - - Specific details like: - - file names - - full code snippets - - function signatures - - file edits - - Errors that you ran into and how you fixed them - - Pay special attention to specific user feedback that you received, especially if the user told you to do something differently. -2. Double-check for technical accuracy and completeness, addressing each required element thoroughly.`; - -export const COMPACT_PROMPT = `Your task is to create a detailed summary of the conversation so far, paying close attention to the user's explicit requests and your previous actions. -This summary should be thorough in capturing technical details, code patterns, and architectural decisions that would be essential for continuing development work without losing context. - -${ANALYSIS_INSTRUCTION} - -Your summary should include the following sections: - -1. Primary Request and Intent: Capture all of the user's explicit requests and intents in detail -2. Key Technical Concepts: List all important technical concepts, technologies, and frameworks discussed. -3. Files and Code Sections: Enumerate specific files and code sections examined, modified, or created. Pay special attention to the most recent messages and include full code snippets where applicable and include a summary of why this file read or edit is important. -4. Errors and fixes: List all errors that you ran into, and how you fixed them. Pay special attention to specific user feedback that you received, especially if the user told you to do something differently. -5. Problem Solving: Document problems solved and any ongoing troubleshooting efforts. -6. All user messages: List ALL user messages that are not tool results. These are critical for understanding the users' feedback and changing intent. -7. Pending Tasks: Outline any pending tasks that you have explicitly been asked to work on. -8. Current Work: Describe in detail precisely what was being worked on immediately before this summary request, paying special attention to the most recent messages from both user and assistant. Include file names and code snippets where applicable. -9. Optional Next Step: List the next step that you will take that is related to the most recent work you were doing. IMPORTANT: ensure that this step is DIRECTLY in line with the user's most recent explicit requests, and the task you were working on immediately before this summary request. If your last task was concluded, then only list next steps if they are explicitly in line with the users request. Do not start on tangential requests or really old requests that were already completed without confirming with the user first. - If there is a next step, include direct quotes from the most recent conversation showing exactly what task you were working on and where you left off. This should be verbatim to ensure there's no drift in task interpretation. - -Here's an example of how your output should be structured: - - - -[Your thought process, ensuring all points are covered thoroughly and accurately] - - - -1. Primary Request and Intent: - [Detailed description] - -2. Key Technical Concepts: - - [Concept 1] - - [Concept 2] - - [...] - -3. Files and Code Sections: - - [File Name 1] - - [Summary of why this file is important] - - [Summary of the changes made to this file, if any] - - [Important Code Snippet] - - [File Name 2] - - [Important Code Snippet] - - [...] - -4. Errors and fixes: - - [Detailed description of error 1]: - - [How you fixed the error] - - [User feedback on the error if any] - - [...] - -5. Problem Solving: - [Description of solved problems and ongoing troubleshooting] - -6. All user messages: - - [Detailed non tool use user message] - - [...] - -7. Pending Tasks: - - [Task 1] - - [Task 2] - - [...] - -8. Current Work: - [Precise description of current work] - -9. Optional Next Step: - [Optional Next step to take] - - - - -Please provide your summary based on the conversation so far, following this structure and ensuring precision and thoroughness in your response.`; - -export const PARTIAL_COMPACT_PROMPT = `Your task is to create a detailed summary of the RECENT portion of the conversation — the messages that follow earlier retained context. The earlier messages are being kept intact and do NOT need to be summarized. Focus your summary on what was discussed, learned, and accomplished in the recent messages only. - -${ANALYSIS_INSTRUCTION} - -Your summary should include the same 9 sections as a full compact, but focused only on recent messages: - -1. Primary Request and Intent -2. Key Technical Concepts -3. Files and Code Sections -4. Errors and fixes -5. Problem Solving -6. All user messages (from recent portion only) -7. Pending Tasks -8. Current Work -9. Optional Next Step - -Provide your summary based on the RECENT messages only.`; - -/** - * Format a compact summary by stripping the drafting scratchpad - * and extracting the content. - */ -export function formatCompactSummary(summary: string): string { - let formatted = summary; - - // Strip analysis section — drafting scratchpad that improves quality - // but has no informational value once the summary is written - formatted = formatted.replace(/[\s\S]*?<\/analysis>/, ""); - - // Extract and format summary section - const summaryMatch = formatted.match(/([\s\S]*?)<\/summary>/); - if (summaryMatch) { - const content = summaryMatch[1] ?? ""; - formatted = formatted.replace(/[\s\S]*?<\/summary>/, `Summary:\n${content.trim()}`); - } - - // Clean up extra whitespace - formatted = formatted.replace(/\n\n+/g, "\n\n"); - - return formatted.trim(); -} - -/** - * Build a continuation message after compaction, including the summary - * and instructions to resume work. - */ -export function buildCompactContinuationMessage(summary: string, transcriptPath?: string): string { - const formatted = formatCompactSummary(summary); - - let message = `This session is being continued from a previous conversation that ran out of context. The summary below covers the earlier portion of the conversation. - -${formatted}`; - - if (transcriptPath) { - message += `\n\nIf you need specific details from before compaction (like exact code snippets, error messages, or content you generated), read the full transcript at: ${transcriptPath}`; - } - - message += `\nContinue the conversation from where it left off without asking the user any further questions. Resume directly — do not acknowledge the summary, do not recap what was happening, do not preface with "I'll continue" or similar. Pick up the last task as if the break never happened.`; - - return message; -} diff --git a/src/sdk/cost-tracker.ts b/src/sdk/cost-tracker.ts index def253a8..f08ad168 100644 --- a/src/sdk/cost-tracker.ts +++ b/src/sdk/cost-tracker.ts @@ -8,8 +8,6 @@ * Pricing data from https://docs.anthropic.com/en/docs/about-claude/pricing */ -import { formatTokenCount } from "./token-estimation.ts"; - // ── Pricing Tiers ── export interface ModelCosts { @@ -268,76 +266,6 @@ export class CostTracker { } } -// ── Formatting ── - -/** - * Format a cost in USD for display. - */ -export function formatCost(cost: number): string { - if (cost >= 0.5) { - return `$${cost.toFixed(2)}`; - } - return `$${cost.toFixed(4)}`; -} - -/** - * Format a duration in milliseconds for display. - */ -export function formatDuration(ms: number): string { - if (ms < 1000) return `${ms}ms`; - const seconds = ms / 1000; - if (seconds < 60) return `${seconds.toFixed(1)}s`; - const minutes = Math.floor(seconds / 60); - const remainingSeconds = Math.round(seconds % 60); - return `${minutes}m ${remainingSeconds}s`; -} - -/** - * Format a full session cost summary for CLI display. - */ -export function formatSessionSummary(summary: SessionCostSummary): string { - const lines: string[] = []; - - const costStr = formatCost(summary.totalCostUsd); - lines.push(`Total cost: ${costStr}`); - lines.push(`Duration: ${formatDuration(summary.durationMs)}`); - lines.push(`Turns: ${summary.totalTurns}`); - lines.push(""); - - // Per-model breakdown - const models = Object.entries(summary.modelUsage); - if (models.length > 0) { - lines.push("Usage by model:"); - for (const [model, usage] of models) { - const short = model.replace("claude-", ""); - lines.push( - ` ${short}: ${formatTokenCount(usage.inputTokens)} in, ${formatTokenCount(usage.outputTokens)} out` + - (usage.cacheReadTokens > 0 - ? `, ${formatTokenCount(usage.cacheReadTokens)} cache read` - : "") + - (usage.cacheWriteTokens > 0 - ? `, ${formatTokenCount(usage.cacheWriteTokens)} cache write` - : "") + - ` (${formatCost(usage.costUsd)})`, - ); - } - } - - return lines.join("\n"); -} - -/** - * Format model pricing for display (e.g., "$3/$15 per Mtok"). - */ -export function formatModelPricing(model: string): string | undefined { - const canonical = canonicalizeModel(model); - const costs = MODEL_PRICING[canonical]; - if (!costs) return undefined; - - const fmtPrice = (n: number) => (Number.isInteger(n) ? `$${n}` : `$${n.toFixed(2)}`); - return `${fmtPrice(costs.inputTokens)}/${fmtPrice(costs.outputTokens)} per Mtok`; -} - // ── Helpers ── /** diff --git a/src/sdk/studio-mcp.test.ts b/src/sdk/studio-mcp.test.ts new file mode 100644 index 00000000..d421649b --- /dev/null +++ b/src/sdk/studio-mcp.test.ts @@ -0,0 +1,21 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { StudioEngine } from "../studio/engine.ts"; +import { buildStudioEngine, buildStudioMcpServer } from "./studio-mcp.ts"; + +describe("studio-mcp wiring", () => { + const prev = { ...process.env }; + afterEach(() => { + process.env = { ...prev }; + }); + + it("builds an engine (deterministic provider only) without Google creds", () => { + delete process.env.GEMINI_API_KEY; + delete process.env.GOOGLE_CLOUD_PROJECT; + expect(buildStudioEngine()).toBeInstanceOf(StudioEngine); + }); + + it("builds the per-user MCP server without throwing", () => { + const server = buildStudioMcpServer("u1"); + expect(server).toBeDefined(); + }); +}); diff --git a/src/sdk/studio-mcp.ts b/src/sdk/studio-mcp.ts new file mode 100644 index 00000000..aa4b090d --- /dev/null +++ b/src/sdk/studio-mcp.ts @@ -0,0 +1,281 @@ +/** + * In-process MCP server exposing the Studio tools (vault-mcp pattern: built per + * turn, scoped to the requesting user). Hosted-only; injected when + * FEATURES.studio() is on. The agent calls a tool, the engine executes + records + * it, the app fetches the result. Tool descriptions are defined inline below. + */ + +import { randomUUID } from "node:crypto"; +import { + createSdkMcpServer, + type McpSdkServerConfigWithInstance, + tool, +} from "@anthropic-ai/claude-agent-sdk"; +import { z } from "zod/v4"; +import type { TenantContext } from "../auth/tenant-context.ts"; +import { createLogger } from "../lib/logger.ts"; +import { getAsset, listEdits } from "../studio/assets.ts"; +import { ConsentRequiredError } from "../studio/consent.ts"; +import { StudioEngine, type StudioProvider } from "../studio/engine.ts"; +import { + createGoogleGenAIImageClient, + GeminiImageProvider, +} from "../studio/providers/gemini-image.ts"; +import { LocalSharpProvider, makePreview } from "../studio/providers/local-sharp.ts"; +import { SidecarProvider } from "../studio/providers/mediapipe-sidecar.ts"; +import { getStudioSidecarUrl } from "../studio/sidecar-launcher.ts"; + +const log = createLogger("studio-mcp"); + +const ok = (text: string) => ({ content: [{ type: "text" as const, text }] }); +const fail = (text: string) => ({ content: [{ type: "text" as const, text }], isError: true }); + +function tenantFor(userId: string): TenantContext { + return { orgId: process.env.NOMOS_ORG_ID ?? "local", userId }; +} + +/** Wire the engine with the deterministic provider always, the GCP provider when configured. */ +export function buildStudioEngine(): StudioEngine { + const providers: StudioProvider[] = [new LocalSharpProvider()]; + // The Phase-3 sidecar (when up) runs deterministic ops free; register it BEFORE + // Gemini so a reachable sidecar wins and retouch only falls back to the cloud + // when the sidecar is absent. + const sidecarUrl = getStudioSidecarUrl(); + if (sidecarUrl) providers.push(new SidecarProvider(sidecarUrl)); + const apiKey = process.env.GEMINI_API_KEY ?? process.env.GOOGLE_API_KEY; + if (apiKey || process.env.GOOGLE_CLOUD_PROJECT) { + try { + providers.push( + new GeminiImageProvider(createGoogleGenAIImageClient(), { + name: process.env.NOMOS_STUDIO_PROVIDER ?? (apiKey ? "gemini" : "vertex"), + }), + ); + } catch (err) { + log.warn({ err }, "studio: GCP image provider unavailable; generative ops disabled"); + } + } + return new StudioEngine({ providers, makePreview }); +} + +async function applyOp( + engine: StudioEngine, + userId: string, + assetId: string, + op: { op: string; params?: unknown }, +) { + const ctx = tenantFor(userId); + const asset = await getAsset(ctx, assetId); + if (!asset) return fail(`No photo with id ${assetId}.`); + try { + const edit = await engine.edit(ctx, { + assetId, + op, + parentEditId: asset.headEditId, + idempotencyKey: randomUUID(), + }); + const cost = edit.costUsd ? ` (cost $${edit.costUsd.toFixed(3)})` : ""; + return ok(`Applied ${op.op}. Edit ${edit.id} is ${edit.status}${cost}.`); + } catch (err) { + if (err instanceof ConsentRequiredError) { + return fail( + "Cloud edits are turned off. Ask the user to enable Cloud AI in Studio settings, then try again.", + ); + } + return fail(`${op.op} failed: ${err instanceof Error ? err.message : String(err)}`); + } +} + +/** Build the per-user Studio MCP server. Manifest entry symbol. */ +export function buildStudioMcpServer(userId: string): McpSdkServerConfigWithInstance { + const engine = buildStudioEngine(); + + const studioEdit = tool( + "studio_edit", + "Apply a natural-language edit to the user's photo (e.g. 'remove the person in the background', 'warm up the lighting', 'make the sky bluer'). Cloud edit, requires Cloud AI consent.", + { asset_id: z.string().describe("The Studio asset id"), instruction: z.string() }, + async (a) => + applyOp(engine, userId, a.asset_id, { + op: "editSemantic", + params: { instruction: a.instruction }, + }), + ); + + const studioAdjust = tool( + "studio_adjust", + "Adjust the photo's tone with sliders in the range -1..1 (exposure, contrast, saturation, temperature). Free and instant, no cloud call.", + { + asset_id: z.string(), + exposure: z.number().min(-1).max(1).optional(), + contrast: z.number().min(-1).max(1).optional(), + saturation: z.number().min(-1).max(1).optional(), + temperature: z.number().min(-1).max(1).optional(), + }, + async (a) => + applyOp(engine, userId, a.asset_id, { + op: "adjust", + params: { + exposure: a.exposure, + contrast: a.contrast, + saturation: a.saturation, + temperature: a.temperature, + }, + }), + ); + + const studioRetouch = tool( + "studio_retouch", + "One-tap portrait retouch: even out skin and soften blemishes while keeping it natural (strength 0..1). Free + deterministic when the on-device/sidecar pipeline is available; otherwise a cloud edit (requires Cloud AI consent).", + { asset_id: z.string(), strength: z.number().min(0).max(1).optional() }, + async (a) => + applyOp(engine, userId, a.asset_id, { + op: "retouch", + params: a.strength === undefined ? {} : { strength: a.strength }, + }), + ); + + const studioCutout = tool( + "studio_cutout", + "Remove the background from the photo, keeping the main subject.", + { asset_id: z.string() }, + async (a) => applyOp(engine, userId, a.asset_id, { op: "cutout", params: {} }), + ); + + const studioUpscale = tool( + "studio_upscale", + "Increase the photo's resolution and sharpness (2x or 4x).", + { asset_id: z.string(), factor: z.union([z.literal(2), z.literal(4)]).optional() }, + async (a) => + applyOp(engine, userId, a.asset_id, { + op: "upscale", + params: a.factor ? { factor: a.factor } : {}, + }), + ); + + const studioRestore = tool( + "studio_restore", + "Restore an old or damaged photo: repair scratches, denoise, recover color.", + { asset_id: z.string() }, + async (a) => applyOp(engine, userId, a.asset_id, { op: "restore", params: {} }), + ); + + // ── Phase 3 generative depth bets (cloud, consent-gated) ────────────── + const studioMuscle = tool( + "studio_muscle", + "Add natural, photorealistic muscle definition (e.g. abs, arms, chest). Cloud edit, requires Cloud AI consent.", + { + asset_id: z.string(), + area: z.enum(["abs", "arms", "chest", "full"]).optional(), + strength: z.number().min(0).max(1).optional(), + }, + async (a) => + applyOp(engine, userId, a.asset_id, { + op: "muscle", + params: { + ...(a.area ? { area: a.area } : {}), + ...(a.strength === undefined ? {} : { strength: a.strength }), + }, + }), + ); + + const studioHairstyle = tool( + "studio_hairstyle", + "Restyle the person's hair (e.g. 'short bob', 'long wavy', 'undercut'). Cloud edit, requires Cloud AI consent.", + { asset_id: z.string(), style: z.string() }, + async (a) => + applyOp(engine, userId, a.asset_id, { op: "hairstyle", params: { style: a.style } }), + ); + + const studioBeard = tool( + "studio_beard", + "Add, remove, or trim facial hair. Cloud edit, requires Cloud AI consent.", + { + asset_id: z.string(), + action: z.enum(["add", "remove", "trim"]).optional(), + style: z.string().optional(), + }, + async (a) => + applyOp(engine, userId, a.asset_id, { + op: "beard", + params: { + ...(a.action ? { action: a.action } : {}), + ...(a.style ? { style: a.style } : {}), + }, + }), + ); + + const studioRelight = tool( + "studio_relight", + "Relight the photo (change the light direction or mood). Cloud edit, requires Cloud AI consent.", + { + asset_id: z.string(), + direction: z.enum(["left", "right", "front", "back", "top"]).optional(), + mood: z.string().optional(), + }, + async (a) => + applyOp(engine, userId, a.asset_id, { + op: "relight", + params: { + ...(a.direction ? { direction: a.direction } : {}), + ...(a.mood ? { mood: a.mood } : {}), + }, + }), + ); + + const studioExpand = tool( + "studio_expand", + "Generatively expand (outpaint / uncrop) the photo, extending the scene. Cloud edit, requires Cloud AI consent.", + { + asset_id: z.string(), + direction: z + .enum(["all", "horizontal", "vertical", "left", "right", "up", "down"]) + .optional(), + }, + async (a) => + applyOp(engine, userId, a.asset_id, { + op: "expand", + params: a.direction ? { direction: a.direction } : {}, + }), + ); + + const studioSky = tool( + "studio_sky", + "Replace the sky / background (e.g. 'dramatic sunset', 'clear blue', 'night stars'). Cloud edit, requires Cloud AI consent.", + { asset_id: z.string(), style: z.string() }, + async (a) => applyOp(engine, userId, a.asset_id, { op: "sky", params: { style: a.style } }), + ); + + const studioHistory = tool( + "studio_history", + "List the edit history (op chain) of a photo, oldest first.", + { asset_id: z.string() }, + async (a) => { + const edits = await listEdits(tenantFor(userId), a.asset_id); + if (edits.length === 0) return ok("No edits yet on this photo."); + const lines = edits.map( + (e, i) => `${i + 1}. ${e.op} [${e.status}]${e.costUsd ? ` $${e.costUsd.toFixed(3)}` : ""}`, + ); + return ok(lines.join("\n")); + }, + { annotations: { readOnlyHint: true } }, + ); + + return createSdkMcpServer({ + name: "nomos-studio", + version: "1.0.0", + tools: [ + studioEdit, + studioAdjust, + studioRetouch, + studioCutout, + studioUpscale, + studioRestore, + studioMuscle, + studioHairstyle, + studioBeard, + studioRelight, + studioExpand, + studioSky, + studioHistory, + ], + }); +} diff --git a/src/storage/object-store.test.ts b/src/storage/object-store.test.ts new file mode 100644 index 00000000..b7744041 --- /dev/null +++ b/src/storage/object-store.test.ts @@ -0,0 +1,195 @@ +import { promises as fs } from "node:fs"; +import type { IncomingMessage, ServerResponse } from "node:http"; +import os from "node:os"; +import path from "node:path"; +import { Readable } from "node:stream"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + assertSafeKey, + getObjectStore, + handleBlobRequest, + LocalFsObjectStore, + objectKey, + resetObjectStoreForTest, +} from "./object-store.ts"; + +describe("object key safety", () => { + it("builds an org-scoped key", () => { + expect(objectKey("studio", "abc123", "original.jpg")).toBe( + "org/local/studio/abc123/original.jpg", + ); + }); + + it("rejects traversal, absolute, and bad keys", () => { + expect(() => assertSafeKey("../etc/passwd")).toThrow(); + expect(() => assertSafeKey("/abs/path")).toThrow(); + expect(() => assertSafeKey("a\\b")).toThrow(); + expect(() => assertSafeKey("a/../b")).toThrow(); + expect(() => assertSafeKey("trailing/")).toThrow(); + expect(() => assertSafeKey("")).toThrow(); + expect(() => assertSafeKey("ok/key_1.jpg")).not.toThrow(); + }); +}); + +describe("LocalFsObjectStore", () => { + let dir: string; + let store: LocalFsObjectStore; + + beforeEach(async () => { + dir = await fs.mkdtemp(path.join(os.tmpdir(), "nomos-objstore-")); + store = new LocalFsObjectStore(dir); + }); + + afterEach(async () => { + await fs.rm(dir, { recursive: true, force: true }); + }); + + it("round-trips bytes and reports size + content hash", async () => { + const bytes = new TextEncoder().encode("hello studio"); + const res = await store.put("org/local/studio/a/original.jpg", bytes, "image/jpeg"); + expect(res.size).toBe(bytes.byteLength); + expect(res.contentHash).toMatch(/^[a-f0-9]{64}$/); + const got = await store.get("org/local/studio/a/original.jpg"); + expect(new TextDecoder().decode(got)).toBe("hello studio"); + }); + + it("head returns stat with content type, or null when absent", async () => { + await store.put("org/local/studio/a/x.png", new Uint8Array([1, 2, 3]), "image/png"); + const stat = await store.head("org/local/studio/a/x.png"); + expect(stat?.size).toBe(3); + expect(stat?.contentType).toBe("image/png"); + expect(await store.head("org/local/studio/a/missing.png")).toBeNull(); + }); + + it("delete removes the object and is idempotent", async () => { + await store.put("org/local/studio/a/y.jpg", new Uint8Array([9])); + await store.delete("org/local/studio/a/y.jpg"); + expect(await store.head("org/local/studio/a/y.jpg")).toBeNull(); + await expect(store.delete("org/local/studio/a/y.jpg")).resolves.toBeUndefined(); + }); + + it("lists keys under a prefix (and excludes content-type sidecars)", async () => { + await store.put("org/local/studio/a/1.jpg", new Uint8Array([1]), "image/jpeg"); + await store.put("org/local/studio/a/2.jpg", new Uint8Array([2]), "image/jpeg"); + await store.put("org/local/studio/b/3.jpg", new Uint8Array([3])); + const keys = await store.list("org/local/studio/a/"); + expect(keys).toEqual(["org/local/studio/a/1.jpg", "org/local/studio/a/2.jpg"]); + }); + + it("refuses keys that escape the base dir", async () => { + await expect(store.get("../../../etc/hosts")).rejects.toThrow(); + }); + + it("presign returns a file:// url in dev", async () => { + const put = await store.presignPut("org/local/studio/a/z.jpg", { contentType: "image/jpeg" }); + expect(put.method).toBe("PUT"); + expect(put.url.startsWith("file://")).toBe(true); + expect(put.expiresAt).toBeGreaterThan(0); + }); +}); + +describe("local-fs blob HTTP serving (dev presign)", () => { + const prev = { ...process.env }; + const base = "http://localhost:8767"; + let dir = ""; + + beforeEach(async () => { + dir = await fs.mkdtemp(path.join(os.tmpdir(), "blob-")); + process.env.NOMOS_OBJECT_STORE_DRIVER = "local"; + process.env.NOMOS_OBJECT_STORE_PATH = dir; + process.env.NOMOS_OBJECT_STORE_PUBLIC_URL = base; + resetObjectStoreForTest(); + }); + afterEach(async () => { + process.env = { ...prev }; + resetObjectStoreForTest(); + await fs.rm(dir, { recursive: true, force: true }); + }); + + const fakeReq = (method: string, url: string, body?: Buffer): IncomingMessage => { + const r = Readable.from(body ? [body] : []) as unknown as IncomingMessage; + (r as { url: string }).url = url; + (r as { method: string }).method = method; + (r as { headers: Record }).headers = { "content-type": "image/jpeg" }; + return r; + }; + const fakeRes = () => { + const res = { + statusCode: 0, + headersSent: false, + body: undefined as Buffer | string | undefined, + writeHead(code: number) { + this.statusCode = code; + this.headersSent = true; + return this; + }, + end(chunk?: Buffer | string) { + this.body = chunk; + return this; + }, + }; + return res as unknown as ServerResponse & typeof res; + }; + + it("presigns an HTTP blob URL and round-trips PUT then GET", async () => { + const store = getObjectStore(); + const key = "org/local/studio/a/orig.jpg"; + const put = await store.presignPut(key, { contentType: "image/jpeg" }); + expect(put.url.startsWith(`${base}/studio-blob/${key}?`)).toBe(true); + + const resPut = fakeRes(); + expect( + await handleBlobRequest( + fakeReq("PUT", put.url.slice(base.length), Buffer.from([1, 2, 3])), + resPut, + ), + ).toBe(true); + expect(resPut.statusCode).toBe(200); + + const get = await store.presignGet(key); + const resGet = fakeRes(); + expect(await handleBlobRequest(fakeReq("GET", get.url.slice(base.length)), resGet)).toBe(true); + expect(resGet.statusCode).toBe(200); + expect(Buffer.from(resGet.body as Buffer).equals(Buffer.from([1, 2, 3]))).toBe(true); + }); + + it("rejects a bad signature with 403", async () => { + const exp = Date.now() + 10_000; + const res = fakeRes(); + await handleBlobRequest( + fakeReq( + "PUT", + `/studio-blob/org/local/studio/a/x.jpg?exp=${exp}&sig=deadbeef`, + Buffer.from([1]), + ), + res, + ); + expect(res.statusCode).toBe(403); + }); + + it("ignores non-blob requests (returns false, body untouched)", async () => { + const res = fakeRes(); + expect(await handleBlobRequest(fakeReq("POST", "/nomos.MobileApi/Chat"), res)).toBe(false); + expect(res.headersSent).toBe(false); + }); +}); + +describe("getObjectStore factory", () => { + const prev = { ...process.env }; + afterEach(() => { + process.env = { ...prev }; + resetObjectStoreForTest(); + }); + + it("returns the local-fs driver by default", () => { + process.env.NOMOS_OBJECT_STORE_DRIVER = "local"; + resetObjectStoreForTest(); + expect(getObjectStore()).toBeInstanceOf(LocalFsObjectStore); + }); + + it("throws a clear error for the not-yet-wired GCS driver (GCP-only)", () => { + process.env.NOMOS_OBJECT_STORE_DRIVER = "gcs"; + resetObjectStoreForTest(); + expect(() => getObjectStore()).toThrow(/@google-cloud\/storage/); + }); +}); diff --git a/src/storage/object-store.ts b/src/storage/object-store.ts new file mode 100644 index 00000000..f1d5d2b3 --- /dev/null +++ b/src/storage/object-store.ts @@ -0,0 +1,328 @@ +/** + * Object storage for Studio blobs (originals, edit results, previews). + * + * Two drivers behind one interface: + * - local-fs (`NOMOS_OBJECT_STORE_DRIVER=local`, default): a directory on disk. + * Lets power-user dev and `pnpm eval:agent` run with no cloud bucket. Presign + * returns a `file://` URL (dev-only; the engine reads/writes via put/get). + * - GCS (`NOMOS_OBJECT_STORE_DRIVER=gcs`): Google Cloud Storage, the prod driver. + * Same GCP stack as Vertex (ADC / workload identity, no AWS), V4 signed URLs. + * Lands with `@google-cloud/storage` when hosted infra is built (see + * the design doc "Build prerequisites"). + * + * All keys are org-scoped (`org//...`) so GDPR delete can drop a + * whole customer prefix, matching the per-customer storage prefix in HOSTED_PLAN. + * Blobs never transit gRPC: clients use presigned PUT/GET. + */ + +import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto"; +import { promises as fs } from "node:fs"; +import type { IncomingMessage, ServerResponse } from "node:http"; +import os from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { createLogger } from "../lib/logger.ts"; + +const log = createLogger("object-store"); + +export interface PutResult { + key: string; + size: number; + contentHash: string; +} + +export interface ObjectStat { + key: string; + size: number; + contentType?: string; +} + +export interface PresignedPut { + method: "PUT"; + url: string; + key: string; + expiresAt: number; +} + +export interface PresignedGet { + url: string; + expiresAt: number; +} + +export interface ObjectStore { + put(key: string, bytes: Uint8Array, contentType?: string): Promise; + get(key: string): Promise; + head(key: string): Promise; + delete(key: string): Promise; + list(prefix: string): Promise; + presignPut( + key: string, + opts?: { contentType?: string; ttlSeconds?: number }, + ): Promise; + presignGet(key: string, opts?: { ttlSeconds?: number }): Promise; +} + +const KEY_RE = /^[A-Za-z0-9._\-/]+$/; +const MAX_KEY_LEN = 1024; + +/** Reject traversal, absolute, backslash, null-byte, and out-of-charset keys. */ +export function assertSafeKey(key: string): void { + if (!key || key.length > MAX_KEY_LEN) { + throw new Error(`Invalid object key length: ${JSON.stringify(key)}`); + } + if ( + key.startsWith("/") || + key.includes("..") || + key.includes("\\") || + key.includes("\0") || + key.endsWith("/") || + !KEY_RE.test(key) + ) { + throw new Error(`Unsafe object key: ${JSON.stringify(key)}`); + } +} + +export function resolveOrgId(): string { + return process.env.NOMOS_ORG_ID ?? "local"; +} + +/** Build an org-scoped key, e.g. objectKey("studio", id, "original.jpg"). */ +export function objectKey(...parts: string[]): string { + const key = ["org", resolveOrgId(), ...parts].join("/"); + assertSafeKey(key); + return key; +} + +function sha256(bytes: Uint8Array): string { + return createHash("sha256").update(bytes).digest("hex"); +} + +// ── Local-fs blob HTTP serving (dev) ────────────────────────────────────────── +// local-fs can't hand a client a real presigned URL (a `file://` is useless to an +// HTTP client). When a public base URL is configured we instead serve signed +// PUT/GET over the daemon's Connect HTTP server, and presign returns those. Prod +// uses GCS V4 signed URLs and never touches this path. +const BLOB_PREFIX = "/studio-blob/"; +// Per-boot HMAC secret shared by presign (sign) + the route (verify), same process. +const blobSecret = randomBytes(32); + +function signBlob(method: string, key: string, exp: number): string { + return createHmac("sha256", blobSecret).update(`${method}\n${key}\n${exp}`).digest("hex"); +} + +function blobUrl(base: string, method: "PUT" | "GET", key: string, exp: number): string { + return `${base.replace(/\/+$/, "")}${BLOB_PREFIX}${key}?exp=${exp}&sig=${signBlob(method, key, exp)}`; +} + +function sigOk(a: string, b: string): boolean { + if (a.length !== b.length || a.length === 0) return false; + try { + return timingSafeEqual(Buffer.from(a, "hex"), Buffer.from(b, "hex")); + } catch { + return false; + } +} + +/** + * Answer a signed blob PUT/GET if `req` is one (path under `/studio-blob/`). + * Returns true once it has handled the request (caller then skips the Connect + * adapter), false for any non-blob request (the body is left untouched). Dev-only + * (local-fs); verifies the per-boot HMAC + expiry before touching disk. + */ +export async function handleBlobRequest( + req: IncomingMessage, + res: ServerResponse, +): Promise { + const rawUrl = req.url ?? ""; + if (!rawUrl.startsWith(BLOB_PREFIX)) return false; + const method = req.method ?? "GET"; + try { + const u = new URL(rawUrl, "http://blob.local"); + const key = decodeURIComponent(u.pathname.slice(BLOB_PREFIX.length)); + const exp = Number(u.searchParams.get("exp") ?? "0"); + const sig = u.searchParams.get("sig") ?? ""; + assertSafeKey(key); + if (method !== "PUT" && method !== "GET") { + res.writeHead(405).end(); + return true; + } + if (!exp || Date.now() > exp || !sigOk(sig, signBlob(method, key, exp))) { + res.writeHead(403).end("forbidden"); + return true; + } + const store = getObjectStore(); + if (method === "PUT") { + const chunks: Buffer[] = []; + for await (const c of req) chunks.push(c as Buffer); + const ct = req.headers["content-type"]; + await store.put(key, Buffer.concat(chunks), typeof ct === "string" ? ct : undefined); + res.writeHead(200).end(); + } else { + const stat = await store.head(key); + if (!stat) { + res.writeHead(404).end("not found"); + return true; + } + const bytes = await store.get(key); + res.writeHead(200, stat.contentType ? { "content-type": stat.contentType } : {}).end(bytes); + } + } catch (err) { + log.warn({ err }, "blob request failed"); + if (!res.headersSent) res.writeHead(404).end("not found"); + } + return true; +} + +export class LocalFsObjectStore implements ObjectStore { + // `publicBaseUrl` (e.g. http://localhost:8767) turns presign into signed HTTP + // URLs served by handleBlobRequest; without it, presign returns `file://` for + // server-side-only use (eval, the engine's own put/get). + constructor( + private readonly baseDir: string, + private readonly publicBaseUrl?: string, + ) {} + + private pathFor(key: string): string { + assertSafeKey(key); + const baseAbs = path.resolve(this.baseDir); + const abs = path.resolve(baseAbs, key); + if (abs !== baseAbs && !abs.startsWith(baseAbs + path.sep)) { + throw new Error(`Key escapes base dir: ${JSON.stringify(key)}`); + } + return abs; + } + + async put(key: string, bytes: Uint8Array, contentType?: string): Promise { + const p = this.pathFor(key); + await fs.mkdir(path.dirname(p), { recursive: true }); + await fs.writeFile(p, bytes); + if (contentType) await fs.writeFile(`${p}.ct`, contentType, "utf8"); + return { key, size: bytes.byteLength, contentHash: sha256(bytes) }; + } + + async get(key: string): Promise { + return fs.readFile(this.pathFor(key)); + } + + async head(key: string): Promise { + const p = this.pathFor(key); + try { + const st = await fs.stat(p); + let contentType: string | undefined; + try { + contentType = (await fs.readFile(`${p}.ct`, "utf8")) || undefined; + } catch { + contentType = undefined; + } + return { key, size: st.size, contentType }; + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") return null; + throw err; + } + } + + async delete(key: string): Promise { + const p = this.pathFor(key); + await fs.rm(p, { force: true }); + await fs.rm(`${p}.ct`, { force: true }); + } + + async list(prefix: string): Promise { + const root = path.resolve(this.baseDir); + const out: string[] = []; + const walk = async (dir: string): Promise => { + let entries: import("node:fs").Dirent[]; + try { + entries = await fs.readdir(dir, { withFileTypes: true }); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") return; + throw err; + } + for (const e of entries) { + const full = path.join(dir, e.name); + if (e.isDirectory()) { + await walk(full); + } else if (e.isFile() && !full.endsWith(".ct")) { + const key = path.relative(root, full).split(path.sep).join("/"); + if (key.startsWith(prefix)) out.push(key); + } + } + }; + await walk(root); + return out.sort(); + } + + async presignPut( + key: string, + opts?: { contentType?: string; ttlSeconds?: number }, + ): Promise { + const p = this.pathFor(key); + await fs.mkdir(path.dirname(p), { recursive: true }); + const expiresAt = Date.now() + (opts?.ttlSeconds ?? 900) * 1000; + return { + method: "PUT", + url: this.publicBaseUrl + ? blobUrl(this.publicBaseUrl, "PUT", key, expiresAt) + : pathToFileURL(p).href, + key, + expiresAt, + }; + } + + async presignGet(key: string, opts?: { ttlSeconds?: number }): Promise { + const expiresAt = Date.now() + (opts?.ttlSeconds ?? 900) * 1000; + return { + url: this.publicBaseUrl + ? blobUrl(this.publicBaseUrl, "GET", key, expiresAt) + : pathToFileURL(this.pathFor(key)).href, + expiresAt, + }; + } +} + +let singleton: ObjectStore | null = null; + +function resolveDriver(): string { + return (process.env.NOMOS_OBJECT_STORE_DRIVER ?? "local").trim().toLowerCase(); +} + +export function isObjectStoreConfigured(): boolean { + const driver = resolveDriver(); + if (driver === "local") return true; + if (driver === "gcs") return Boolean(process.env.NOMOS_OBJECT_STORE_BUCKET); + return false; +} + +export function getObjectStore(): ObjectStore { + if (singleton) return singleton; + const driver = resolveDriver(); + + if (driver === "gcs") { + // Prod driver: Google Cloud Storage via @google-cloud/storage (ADC / + // workload identity, V4 signed URLs). Lands with the hosted infra; see + // the design doc "Build prerequisites". GCP-only, no AWS. + throw new Error( + "NOMOS_OBJECT_STORE_DRIVER=gcs is not wired yet (add @google-cloud/storage, Phase 1a prod). Use 'local' for dev/eval.", + ); + } + if (driver !== "local") { + throw new Error(`Unknown NOMOS_OBJECT_STORE_DRIVER: ${driver}. Use 'local' or 'gcs'.`); + } + + const baseDir = + process.env.NOMOS_OBJECT_STORE_PATH ?? path.join(os.tmpdir(), "nomos-object-store"); + // When set (dev / local hosted stack), clients get signed HTTP blob URLs the + // daemon serves; otherwise presign falls back to file:// (server-side only). + const publicBaseUrl = process.env.NOMOS_OBJECT_STORE_PUBLIC_URL?.trim() || undefined; + singleton = new LocalFsObjectStore(baseDir, publicBaseUrl); + log.info( + { baseDir, blobUrls: publicBaseUrl ?? "file:// (server-side only)" }, + "object store: local-fs driver", + ); + return singleton; +} + +/** Test hook: drop the cached singleton so env changes take effect. */ +export function resetObjectStoreForTest(): void { + singleton = null; +} diff --git a/src/studio/assets.test.ts b/src/studio/assets.test.ts new file mode 100644 index 00000000..e3a01058 --- /dev/null +++ b/src/studio/assets.test.ts @@ -0,0 +1,273 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createMockDb } from "../db/test-helpers.ts"; + +const { db, addResult, getQueries, reset } = createMockDb(); +vi.mock("../db/client.ts", () => ({ getKysely: () => db })); + +import type { TenantContext } from "../auth/tenant-context.ts"; +import { + appendEdit, + createAsset, + getAsset, + listAssets, + listEdits, + markEditDone, + markEditFailed, + recordIdentityScore, + StaleParentError, + StudioAssetNotFoundError, +} from "./assets.ts"; +import { validateOp } from "./ops.ts"; + +const ctx = { orgId: "local", userId: "u1" } as TenantContext; + +function assetRow(over: Record = {}): Record { + return { + id: "a1", + user_id: "u1", + object_key: "org/local/studio/a1/original.jpg", + content_hash: "h", + mime: "image/jpeg", + width: 1024, + height: 1024, + bytes: 1000, + status: "ready", + head_edit_id: null, + metadata: {}, + created_at: new Date(), + updated_at: new Date(), + ...over, + }; +} + +function editRow(over: Record = {}): Record { + return { + id: "e1", + asset_id: "a1", + user_id: "u1", + parent_edit_id: null, + idempotency_key: "k1", + op: "adjust", + op_spec_version: 1, + params: { exposure: 0.3 }, + provider: null, + input_key: null, + output_key: null, + preview_key: null, + status: "pending", + cost_usd: 0, + identity_score: null, + error: null, + created_at: new Date(), + updated_at: new Date(), + ...over, + }; +} + +const sqlOf = (re: RegExp) => getQueries().some((q) => re.test(q.sql)); + +beforeEach(() => reset()); + +describe("createAsset", () => { + it("inserts a pending asset scoped to the user", async () => { + addResult([assetRow({ status: "pending" })]); + const asset = await createAsset(ctx, { + objectKey: "org/local/studio/a1/original.jpg", + contentHash: "h", + mime: "image/jpeg", + bytes: 1000, + }); + expect(asset.status).toBe("pending"); + expect(asset.objectKey).toBe("org/local/studio/a1/original.jpg"); + const insert = getQueries().find((q) => /insert into "studio_assets"/i.test(q.sql)); + expect(insert?.parameters).toContain("u1"); + }); +}); + +describe("getAsset", () => { + it("maps the row and filters by user_id", async () => { + addResult([assetRow()]); + const asset = await getAsset(ctx, "a1"); + expect(asset?.id).toBe("a1"); + const select = getQueries().find((q) => /from "studio_assets"/i.test(q.sql)); + expect(select?.parameters).toContain("u1"); + }); + + it("returns null when absent", async () => { + addResult([]); + expect(await getAsset(ctx, "missing")).toBeNull(); + }); +}); + +describe("appendEdit", () => { + it("appends on a matching head and advances the chain", async () => { + addResult([assetRow({ head_edit_id: null })]); // SELECT asset + addResult([]); // SELECT existing edit (none) + addResult([editRow({ id: "e1" })]); // INSERT edit + addResult([]); // UPDATE head + const op = validateOp({ op: "adjust", params: { exposure: 0.3 } }); + const { edit, created } = await appendEdit(ctx, { + assetId: "a1", + parentEditId: null, + idempotencyKey: "k1", + op, + }); + expect(created).toBe(true); + expect(edit.id).toBe("e1"); + expect(edit.op).toBe("adjust"); + expect(sqlOf(/insert into "studio_edits"/i)).toBe(true); + expect(sqlOf(/update "studio_assets"/i)).toBe(true); + expect(sqlOf(/for update/i)).toBe(true); // asset row locked + }); + + it("is idempotent: a committed key returns the existing edit, no insert", async () => { + addResult([assetRow()]); // SELECT asset + addResult([editRow({ id: "ePrev", idempotency_key: "k1" })]); // SELECT existing edit + const op = validateOp({ op: "adjust", params: {} }); + const { edit, created } = await appendEdit(ctx, { + assetId: "a1", + parentEditId: null, + idempotencyKey: "k1", + op, + }); + expect(created).toBe(false); + expect(edit.id).toBe("ePrev"); + expect(sqlOf(/insert into "studio_edits"/i)).toBe(false); + }); + + it("rejects a stale parent (a concurrent edit advanced the head)", async () => { + addResult([assetRow({ head_edit_id: "eHEAD" })]); + addResult([]); // no existing edit + const op = validateOp({ op: "adjust", params: {} }); + await expect( + appendEdit(ctx, { assetId: "a1", parentEditId: null, idempotencyKey: "k2", op }), + ).rejects.toBeInstanceOf(StaleParentError); + expect(sqlOf(/insert into "studio_edits"/i)).toBe(false); + }); + + it("rejects appending onto a parent that is not done yet (no half-built chain)", async () => { + addResult([assetRow({ head_edit_id: "ePar" })]); // SELECT asset (head == parent) + addResult([]); // no existing edit (idempotency) + addResult([{ status: "running", output_key: null }]); // SELECT parent edit -> not ready + const op = validateOp({ op: "adjust", params: {} }); + await expect( + appendEdit(ctx, { assetId: "a1", parentEditId: "ePar", idempotencyKey: "k3", op }), + ).rejects.toBeInstanceOf(StaleParentError); + expect(sqlOf(/insert into "studio_edits"/i)).toBe(false); + }); + + it("appends a chained edit when the parent is done with output", async () => { + addResult([assetRow({ head_edit_id: "ePar" })]); // SELECT asset + addResult([]); // no existing edit + addResult([{ status: "done", output_key: "out.jpg" }]); // SELECT parent edit -> ready + addResult([editRow({ id: "e2" })]); // INSERT edit + addResult([]); // UPDATE head + const op = validateOp({ op: "adjust", params: {} }); + const { edit, created } = await appendEdit(ctx, { + assetId: "a1", + parentEditId: "ePar", + idempotencyKey: "k4", + op, + }); + expect(created).toBe(true); + expect(edit.id).toBe("e2"); + }); + + it("throws when the asset does not exist for this user", async () => { + addResult([]); // SELECT asset -> none + const op = validateOp({ op: "adjust", params: {} }); + await expect( + appendEdit(ctx, { assetId: "missing", parentEditId: null, idempotencyKey: "k", op }), + ).rejects.toBeInstanceOf(StudioAssetNotFoundError); + }); +}); + +describe("markEditDone + listEdits", () => { + it("records the result blob keys and cost", async () => { + addResult([editRow({ id: "e1", status: "done", output_key: "out.jpg", preview_key: "p.jpg" })]); + const edit = await markEditDone(ctx, "e1", { + outputKey: "out.jpg", + previewKey: "p.jpg", + costUsd: 0.039, + }); + expect(edit?.status).toBe("done"); + expect(edit?.outputKey).toBe("out.jpg"); + expect(edit?.previewKey).toBe("p.jpg"); + }); + + it("returns the chain oldest-first, scoped to the user", async () => { + addResult([editRow({ id: "e1" }), editRow({ id: "e2" })]); + const edits = await listEdits(ctx, "a1"); + expect(edits.map((e) => e.id)).toEqual(["e1", "e2"]); + const select = getQueries().find((q) => /from "studio_edits"/i.test(q.sql)); + expect(select?.parameters).toContain("u1"); + }); + + it("recordIdentityScore writes the score scoped to the user", async () => { + addResult([editRow({ id: "e1", identity_score: 0.97 })]); + const edit = await recordIdentityScore(ctx, "e1", 0.97); + expect(edit?.identityScore).toBe(0.97); + const update = getQueries().find((q) => /update "studio_edits"/i.test(q.sql)); + expect(update?.parameters).toContain("u1"); + }); + + it("markEditFailed rolls the chain head back to the failed edit's parent", async () => { + addResult([editRow({ id: "eX", status: "failed", parent_edit_id: "ePrev", asset_id: "a1" })]); + addResult([]); // head-revert UPDATE + const edit = await markEditFailed(ctx, "eX", "boom"); + expect(edit?.status).toBe("failed"); + const headUpdate = getQueries().find((q) => /update "studio_assets"/i.test(q.sql)); + expect(headUpdate?.parameters).toContain("eX"); // WHERE head_edit_id = the failed edit + expect(headUpdate?.parameters).toContain("ePrev"); // SET head_edit_id = its parent + }); +}); + +describe("listAssets", () => { + it("returns ready sessions with head op, edit count, finalized flag; scoped to the user", async () => { + addResult([ + { + id: "a1", + objectKey: "org/local/studio/a1/original.jpg", + headEditId: "e9", + metadata: { finalizedAt: "2026-01-01T00:00:00Z" }, + updatedAt: new Date("2026-06-10T00:00:00Z"), + headOp: "editSemantic", + headPreviewKey: "org/local/studio/a1/e9.preview.jpg", + headOutputKey: "org/local/studio/a1/e9.jpg", + }, + { + id: "a2", + objectKey: "org/local/studio/a2/original.jpg", + headEditId: null, + metadata: {}, + updatedAt: new Date("2026-06-09T00:00:00Z"), + headOp: null, + headPreviewKey: null, + headOutputKey: null, + }, + ]); + addResult([{ asset_id: "a1", n: "3" }]); // counts query + + const sessions = await listAssets(ctx, 10); + + expect(sessions).toHaveLength(2); + expect(sessions[0].id).toBe("a1"); + expect(sessions[0].headOp).toBe("editSemantic"); + expect(sessions[0].editCount).toBe(3); + expect(sessions[0].finalized).toBe(true); + expect(sessions[1].editCount).toBe(0); + expect(sessions[1].finalized).toBe(false); + + const main = getQueries()[0]; + expect(main.sql).toMatch(/from "studio_assets"/i); + expect(main.parameters).toContain("u1"); // user-scoped + expect(main.parameters).toContain("ready"); // worked-on sessions only + }); + + it("skips the counts query when there are no sessions", async () => { + addResult([]); + const sessions = await listAssets(ctx, 10); + expect(sessions).toEqual([]); + expect(getQueries()).toHaveLength(1); // no second (counts) query + }); +}); diff --git a/src/studio/assets.ts b/src/studio/assets.ts new file mode 100644 index 00000000..1cfdd735 --- /dev/null +++ b/src/studio/assets.ts @@ -0,0 +1,471 @@ +/** + * Studio asset + edit-chain bookkeeping over `studio_assets` / `studio_edits`. + * + * Every function takes a `TenantContext` and filters by `user_id` at the query + * layer (zero-trust on top of database-per-customer). Originals are immutable; + * edits append to a linear chain. `appendEdit` enforces, in one transaction: + * - idempotency: a retried edit with a committed idempotency_key returns the + * existing row (stream-drop retries are free), + * - optimistic concurrency: the edit must build on the asset's current head, + * else StaleParentError (the client refreshes and retries). + * + * See the design doc sections 3 + 8 (decision 4). + */ + +import { type Selectable, sql } from "kysely"; +import type { TenantContext } from "../auth/tenant-context.ts"; +import { getKysely } from "../db/client.ts"; +import type { StudioAssetsTable, StudioEditsTable } from "../db/types.ts"; +import { OP_SPEC_VERSION, type StudioOp } from "./ops.ts"; + +export type StudioAssetStatus = "pending" | "ready" | "expired"; +export type StudioEditStatus = "pending" | "running" | "done" | "failed" | "expired"; + +export interface StudioAsset { + id: string; + userId: string; + objectKey: string; + contentHash: string; + mime: string; + width: number | null; + height: number | null; + bytes: number; + status: StudioAssetStatus; + headEditId: string | null; + metadata: Record; + createdAt: Date; + updatedAt: Date; +} + +export interface StudioEdit { + id: string; + assetId: string; + userId: string; + parentEditId: string | null; + idempotencyKey: string; + op: string; + opSpecVersion: number; + params: Record; + provider: string | null; + inputKey: string | null; + outputKey: string | null; + previewKey: string | null; + status: StudioEditStatus; + costUsd: number; + identityScore: number | null; + error: string | null; + createdAt: Date; + updatedAt: Date; +} + +export class StudioAssetNotFoundError extends Error { + constructor(public readonly assetId: string) { + super(`Studio asset not found: ${assetId}`); + this.name = "StudioAssetNotFoundError"; + } +} + +/** The edit's parent_edit_id no longer matches the asset head (a concurrent edit won). */ +export class StaleParentError extends Error { + constructor( + public readonly provided: string | null, + public readonly head: string | null, + ) { + super(`Stale parent edit: provided ${provided ?? "null"}, head is ${head ?? "null"}`); + this.name = "StaleParentError"; + } +} + +function mapAsset(r: Selectable): StudioAsset { + return { + id: r.id, + userId: r.user_id, + objectKey: r.object_key, + contentHash: r.content_hash, + mime: r.mime, + width: r.width, + height: r.height, + bytes: r.bytes, + status: r.status as StudioAssetStatus, + headEditId: r.head_edit_id, + metadata: r.metadata ?? {}, + createdAt: r.created_at, + updatedAt: r.updated_at, + }; +} + +function mapEdit(r: Selectable): StudioEdit { + return { + id: r.id, + assetId: r.asset_id, + userId: r.user_id, + parentEditId: r.parent_edit_id, + idempotencyKey: r.idempotency_key, + op: r.op, + opSpecVersion: r.op_spec_version, + params: r.params ?? {}, + provider: r.provider, + inputKey: r.input_key, + outputKey: r.output_key, + previewKey: r.preview_key, + status: r.status as StudioEditStatus, + costUsd: r.cost_usd, + identityScore: r.identity_score, + error: r.error, + createdAt: r.created_at, + updatedAt: r.updated_at, + }; +} + +/** Register an uploaded original. Starts `pending` until the client confirms the upload. */ +export async function createAsset( + ctx: TenantContext, + params: { + /** + * Explicit row id. Callers pass this so the object key can embed the SAME id + * (`.../studio//...`); the localized-edit path relies on that to resolve + * a mask back to a real asset (see `StudioEngine.resolveMask`). When omitted + * the DB generates one — but then the key must NOT be built from a throwaway + * uuid, or masks uploaded under that key fail with "invalid mask reference". + */ + id?: string; + objectKey: string; + contentHash: string; + mime: string; + width?: number | null; + height?: number | null; + bytes?: number; + metadata?: Record; + }, +): Promise { + const db = getKysely(); + const row = await db + .insertInto("studio_assets") + .values({ + ...(params.id ? { id: params.id } : {}), + user_id: ctx.userId, + object_key: params.objectKey, + content_hash: params.contentHash, + mime: params.mime, + width: params.width ?? null, + height: params.height ?? null, + bytes: params.bytes ?? 0, + // Pass the object (not JSON.stringify): kysely-postgres-js serializes it to + // jsonb once. A pre-stringified string would double-encode. Matches the + // guarded style_profiles.profile / auto_dream_state.state_json pattern. + metadata: (params.metadata ?? {}) as unknown as string, + }) + .returningAll() + .executeTakeFirstOrThrow(); + return mapAsset(row); +} + +/** Mark a `pending` asset `ready` once the client confirms its presigned upload. */ +export async function confirmAsset( + ctx: TenantContext, + assetId: string, +): Promise { + const db = getKysely(); + const row = await db + .updateTable("studio_assets") + .set({ status: "ready", updated_at: sql`now()` }) + .where("id", "=", assetId) + .where("user_id", "=", ctx.userId) + .returningAll() + .executeTakeFirst(); + return row ? mapAsset(row) : null; +} + +export async function getAsset(ctx: TenantContext, assetId: string): Promise { + const db = getKysely(); + const row = await db + .selectFrom("studio_assets") + .selectAll() + .where("id", "=", assetId) + .where("user_id", "=", ctx.userId) + .executeTakeFirst(); + return row ? mapAsset(row) : null; +} + +/** The result of appendEdit: the edit row + whether it was newly created (vs an idempotent hit). */ +export interface AppendEditResult { + edit: StudioEdit; + created: boolean; +} + +/** + * Append an op to the asset's chain. Transactional: lock the asset row + * (`FOR UPDATE`, so concurrent appends serialize), idempotency check, optimistic + * head check, then insert + advance head. `created: false` on an idempotent retry + * (the caller must NOT re-execute or re-charge); throws StaleParentError when the + * parent is not the head. + */ +export async function appendEdit( + ctx: TenantContext, + params: { + assetId: string; + parentEditId: string | null; + idempotencyKey: string; + op: StudioOp; + provider?: string | null; + inputKey?: string | null; + status?: StudioEditStatus; + }, +): Promise { + const db = getKysely(); + return db.transaction().execute(async (trx) => { + // Lock the asset row so two concurrent appends on the same asset serialize + // here (otherwise the optimistic head check is a stale snapshot under READ + // COMMITTED and the chain can fork / a duplicate key can 23505). + const asset = await trx + .selectFrom("studio_assets") + .selectAll() + .where("id", "=", params.assetId) + .where("user_id", "=", ctx.userId) + .forUpdate() + .executeTakeFirst(); + if (!asset) throw new StudioAssetNotFoundError(params.assetId); + + // Idempotent retry: a committed edit with this key wins, no re-charge. + const existing = await trx + .selectFrom("studio_edits") + .selectAll() + .where("asset_id", "=", params.assetId) + .where("user_id", "=", ctx.userId) + .where("idempotency_key", "=", params.idempotencyKey) + .executeTakeFirst(); + if (existing) return { edit: mapEdit(existing), created: false }; + + // Optimistic concurrency: the edit must build on the current head AND the + // parent must already be a finished, output-bearing edit. Without the status + // check, a second edit submitted while the parent is still running passes the + // head check (head is advanced at append time) and then silently builds on the + // ORIGINAL bytes (resolveInputKey falls back when outputKey is null). The + // asset row is locked above, so reading the parent here is race-free. + const head = asset.head_edit_id ?? null; + const parent = params.parentEditId ?? null; + if (parent !== head) throw new StaleParentError(parent, head); + if (parent) { + const parentEdit = await trx + .selectFrom("studio_edits") + .select(["status", "output_key"]) + .where("id", "=", parent) + .where("user_id", "=", ctx.userId) + .executeTakeFirst(); + if (!parentEdit || parentEdit.status !== "done" || !parentEdit.output_key) { + throw new StaleParentError(parent, head); + } + } + + const inserted = await trx + .insertInto("studio_edits") + .values({ + asset_id: params.assetId, + user_id: ctx.userId, + parent_edit_id: parent, + idempotency_key: params.idempotencyKey, + op: params.op.op, + op_spec_version: params.op.opSpecVersion ?? OP_SPEC_VERSION, + // Pass the object (not JSON.stringify) so jsonb is single-encoded. + params: params.op.params as unknown as string, + provider: params.provider ?? null, + input_key: params.inputKey ?? null, + status: params.status ?? "pending", + }) + .returningAll() + .executeTakeFirstOrThrow(); + + await trx + .updateTable("studio_assets") + .set({ head_edit_id: inserted.id, updated_at: sql`now()` }) + .where("id", "=", params.assetId) + .where("user_id", "=", ctx.userId) + .execute(); + + return { edit: mapEdit(inserted), created: true }; + }); +} + +export async function markEditRunning( + ctx: TenantContext, + editId: string, + provider: string, +): Promise { + const db = getKysely(); + const row = await db + .updateTable("studio_edits") + .set({ status: "running", provider, updated_at: sql`now()` }) + .where("id", "=", editId) + .where("user_id", "=", ctx.userId) + .returningAll() + .executeTakeFirst(); + return row ? mapEdit(row) : null; +} + +export async function markEditDone( + ctx: TenantContext, + editId: string, + result: { + outputKey: string; + previewKey?: string | null; + costUsd?: number; + identityScore?: number | null; + }, +): Promise { + const db = getKysely(); + const row = await db + .updateTable("studio_edits") + .set({ + status: "done", + output_key: result.outputKey, + preview_key: result.previewKey ?? null, + cost_usd: result.costUsd ?? 0, + identity_score: result.identityScore ?? null, + updated_at: sql`now()`, + }) + .where("id", "=", editId) + .where("user_id", "=", ctx.userId) + .returningAll() + .executeTakeFirst(); + return row ? mapEdit(row) : null; +} + +export async function markEditFailed( + ctx: TenantContext, + editId: string, + error: string, +): Promise { + const db = getKysely(); + return db.transaction().execute(async (trx) => { + const row = await trx + .updateTable("studio_edits") + .set({ status: "failed", error, updated_at: sql`now()` }) + .where("id", "=", editId) + .where("user_id", "=", ctx.userId) + .returningAll() + .executeTakeFirst(); + if (!row) return null; + // A failed edit must not stay the chain head: roll the head back to its + // parent so head always reflects the last successful state. Conditional on + // head still being this edit, so it is safe under concurrent appends. + await trx + .updateTable("studio_assets") + .set({ head_edit_id: row.parent_edit_id, updated_at: sql`now()` }) + .where("id", "=", row.asset_id) + .where("user_id", "=", ctx.userId) + .where("head_edit_id", "=", editId) + .execute(); + return mapEdit(row); + }); +} + +/** + * Record an identity-preservation score for an edit (e.g. the on-device Vision + * check reported by the client after fetching the result). Scoped to the user. + */ +export async function recordIdentityScore( + ctx: TenantContext, + editId: string, + score: number, +): Promise { + const db = getKysely(); + const row = await db + .updateTable("studio_edits") + .set({ identity_score: score, updated_at: sql`now()` }) + .where("id", "=", editId) + .where("user_id", "=", ctx.userId) + .returningAll() + .executeTakeFirst(); + return row ? mapEdit(row) : null; +} + +export async function getEdit(ctx: TenantContext, editId: string): Promise { + const db = getKysely(); + const row = await db + .selectFrom("studio_edits") + .selectAll() + .where("id", "=", editId) + .where("user_id", "=", ctx.userId) + .executeTakeFirst(); + return row ? mapEdit(row) : null; +} + +/** The full op chain for an asset, oldest first (the history strip + gallery). */ +export async function listEdits(ctx: TenantContext, assetId: string): Promise { + const db = getKysely(); + const rows = await db + .selectFrom("studio_edits") + .selectAll() + .where("asset_id", "=", assetId) + .where("user_id", "=", ctx.userId) + .orderBy("created_at", "asc") + .execute(); + return rows.map(mapEdit); +} + +/** A recent editing session: an asset + the gist of its head edit (for the Home cards). */ +export interface StudioAssetSummary { + id: string; + objectKey: string; + headEditId: string | null; + headOp: string | null; + headPreviewKey: string | null; + headOutputKey: string | null; + editCount: number; + finalized: boolean; + updatedAt: Date; +} + +/** + * Recent editing sessions for the Home launchpad ("Pick up where you left off"). + * Ready (worked-on) assets, newest first, each carrying the head edit's preview/op so + * the client renders a thumbnail + label without N follow-up calls. user_id-scoped. + */ +export async function listAssets(ctx: TenantContext, limit = 30): Promise { + const db = getKysely(); + const capped = Math.min(Math.max(Math.trunc(limit) || 30, 1), 100); + const rows = await db + .selectFrom("studio_assets as a") + .leftJoin("studio_edits as e", "e.id", "a.head_edit_id") + .select([ + "a.id as id", + "a.object_key as objectKey", + "a.head_edit_id as headEditId", + "a.metadata as metadata", + "a.updated_at as updatedAt", + "e.op as headOp", + "e.preview_key as headPreviewKey", + "e.output_key as headOutputKey", + ]) + .where("a.user_id", "=", ctx.userId) + .where("a.status", "=", "ready") + .orderBy("a.updated_at", "desc") + .limit(capped) + .execute(); + + const ids = rows.map((r) => r.id); + const counts = ids.length + ? await db + .selectFrom("studio_edits") + .where("asset_id", "in", ids) + .where("user_id", "=", ctx.userId) + .where("status", "=", "done") + .groupBy("asset_id") + .select("asset_id") + .select((eb) => eb.fn.countAll().as("n")) + .execute() + : []; + const countByAsset = new Map(counts.map((c) => [c.asset_id, Number(c.n)])); + + return rows.map((r) => ({ + id: r.id, + objectKey: r.objectKey, + headEditId: r.headEditId, + headOp: r.headOp ?? null, + headPreviewKey: r.headPreviewKey ?? null, + headOutputKey: r.headOutputKey ?? null, + editCount: countByAsset.get(r.id) ?? 0, + finalized: Boolean((r.metadata as { finalizedAt?: unknown } | null)?.finalizedAt), + updatedAt: r.updatedAt, + })); +} diff --git a/src/studio/consent.test.ts b/src/studio/consent.test.ts new file mode 100644 index 00000000..8c6ef92f --- /dev/null +++ b/src/studio/consent.test.ts @@ -0,0 +1,60 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { getConfigValue, setConfigValue } = vi.hoisted(() => ({ + getConfigValue: vi.fn(), + setConfigValue: vi.fn(), +})); +vi.mock("../db/config.ts", () => ({ getConfigValue, setConfigValue })); + +import { + assertCloudAIConsent, + CLOUD_AI_CONSENT_KEY, + ConsentRequiredError, + isCloudAIEnabled, + setCloudAIEnabled, +} from "./consent.ts"; + +beforeEach(() => { + getConfigValue.mockReset(); + setConfigValue.mockReset(); + delete process.env.NOMOS_STUDIO_CLOUD_AI; +}); + +describe("cloud AI consent", () => { + it("defaults to disabled when unset", async () => { + getConfigValue.mockResolvedValue(null); + expect(await isCloudAIEnabled()).toBe(false); + }); + + it("is enabled only for an explicit true", async () => { + getConfigValue.mockResolvedValue(true); + expect(await isCloudAIEnabled()).toBe(true); + getConfigValue.mockResolvedValue("true"); + expect(await isCloudAIEnabled()).toBe(false); // not the boolean true + }); + + it("assertCloudAIConsent throws when off", async () => { + await expect(assertCloudAIConsent(async () => false)).rejects.toBeInstanceOf( + ConsentRequiredError, + ); + }); + + it("assertCloudAIConsent passes when on", async () => { + await expect(assertCloudAIConsent(async () => true)).resolves.toBeUndefined(); + }); + + it("setCloudAIEnabled writes the consent config key", async () => { + await setCloudAIEnabled(true); + expect(setConfigValue).toHaveBeenCalledWith(CLOUD_AI_CONSENT_KEY, true); + }); + + it("honors the NOMOS_STUDIO_CLOUD_AI dev override even when the DB flag is off", async () => { + getConfigValue.mockResolvedValue(false); + process.env.NOMOS_STUDIO_CLOUD_AI = "1"; + expect(await isCloudAIEnabled()).toBe(true); + process.env.NOMOS_STUDIO_CLOUD_AI = "true"; + expect(await isCloudAIEnabled()).toBe(true); + process.env.NOMOS_STUDIO_CLOUD_AI = "0"; + expect(await isCloudAIEnabled()).toBe(false); // falsy override → DB flag (off) + }); +}); diff --git a/src/studio/consent.ts b/src/studio/consent.ts new file mode 100644 index 00000000..c5f785b9 --- /dev/null +++ b/src/studio/consent.ts @@ -0,0 +1,41 @@ +/** + * Cloud-AI consent gate. Generative Studio ops send the photo to Google + * (Vertex/Gemini), so they are gated behind an explicit org-level toggle. + * Default is OFF: consent is required until the user grants it. Deterministic / + * on-device ops are NEVER gated. Org-level because the config table is + * per-customer (database-per-customer). See the design doc section 3 (consent). + */ + +import { getConfigValue, setConfigValue } from "../db/config.ts"; + +export const CLOUD_AI_CONSENT_KEY = "studio.cloud_ai_enabled"; + +/** Dev/local override: force-enable cloud AI (e.g. the hosted-google.sh stack), so + * generative edits work without the per-customer DB toggle — which has no client UI + * yet. Never set in production; there the per-customer consent flag governs. */ +function devCloudAIOverride(): boolean { + const v = (process.env.NOMOS_STUDIO_CLOUD_AI ?? "").trim().toLowerCase(); + return v === "1" || v === "true" || v === "yes" || v === "on"; +} + +export async function isCloudAIEnabled(): Promise { + if (devCloudAIOverride()) return true; + return (await getConfigValue(CLOUD_AI_CONSENT_KEY)) === true; +} + +export async function setCloudAIEnabled(enabled: boolean): Promise { + await setConfigValue(CLOUD_AI_CONSENT_KEY, enabled); +} + +export class ConsentRequiredError extends Error { + constructor() { + super("Cloud AI consent required: turn on cloud edits to use generative tools."); + this.name = "ConsentRequiredError"; + } +} + +export async function assertCloudAIConsent( + isEnabled: () => Promise = isCloudAIEnabled, +): Promise { + if (!(await isEnabled())) throw new ConsentRequiredError(); +} diff --git a/src/studio/engine.test.ts b/src/studio/engine.test.ts new file mode 100644 index 00000000..43213bdd --- /dev/null +++ b/src/studio/engine.test.ts @@ -0,0 +1,399 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("./assets.ts", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getAsset: vi.fn(), + confirmAsset: vi.fn(), + getEdit: vi.fn(), + appendEdit: vi.fn(), + markEditRunning: vi.fn(), + markEditDone: vi.fn(), + markEditFailed: vi.fn(), + }; +}); + +import type { TenantContext } from "../auth/tenant-context.ts"; +import type { ObjectStore } from "../storage/object-store.ts"; +import * as assets from "./assets.ts"; +import type { StudioAsset, StudioEdit } from "./assets.ts"; +import { ConsentRequiredError } from "./consent.ts"; +import { NoProviderError, StudioEngine, type StudioProvider } from "./engine.ts"; +import { IdentityDriftError } from "./identity-gate.ts"; + +const ctx = { orgId: "local", userId: "u1" } as TenantContext; + +function fakeAsset(over: Partial = {}): StudioAsset { + return { + id: "a1", + userId: "u1", + objectKey: "org/local/studio/a1/original.jpg", + contentHash: "h", + mime: "image/jpeg", + width: 1024, + height: 1024, + bytes: 1000, + status: "ready", + headEditId: null, + metadata: {}, + createdAt: new Date(), + updatedAt: new Date(), + ...over, + }; +} + +function fakeEdit(over: Partial = {}): StudioEdit { + return { + id: "e1", + assetId: "a1", + userId: "u1", + parentEditId: null, + idempotencyKey: "k1", + op: "editSemantic", + opSpecVersion: 1, + params: {}, + provider: "fake", + inputKey: "org/local/studio/a1/original.jpg", + outputKey: null, + previewKey: null, + status: "pending", + costUsd: 0, + identityScore: null, + error: null, + createdAt: new Date(), + updatedAt: new Date(), + ...over, + }; +} + +function fakeStore(): ObjectStore { + return { + get: vi.fn(async () => Buffer.from([1, 2, 3])), + put: vi.fn(async (key: string) => ({ key, size: 1, contentHash: "h" })), + head: vi.fn(async () => null), + delete: vi.fn(async () => {}), + list: vi.fn(async () => []), + presignPut: vi.fn(async (key: string) => ({ + method: "PUT" as const, + url: "file://x", + key, + expiresAt: 1, + })), + presignGet: vi.fn(async () => ({ url: "file://x", expiresAt: 1 })), + }; +} + +function fakeProvider(over: Partial = {}): StudioProvider { + return { + name: "fake", + kind: "generative", + supports: () => true, + execute: vi.fn(async () => ({ + bytes: new Uint8Array([9]), + mime: "image/jpeg", + costUsd: 0.039, + provider: "fake", + })), + ...over, + }; +} + +beforeEach(() => { + vi.mocked(assets.getAsset).mockReset(); + vi.mocked(assets.confirmAsset).mockReset(); + vi.mocked(assets.getEdit).mockReset(); + vi.mocked(assets.appendEdit).mockReset(); + vi.mocked(assets.markEditRunning).mockReset(); + vi.mocked(assets.markEditDone).mockReset(); + vi.mocked(assets.markEditFailed).mockReset(); +}); + +describe("StudioEngine.edit", () => { + it("runs a generative op end to end: consent, provider, identity gate, store, record", async () => { + vi.mocked(assets.getAsset).mockResolvedValue(fakeAsset()); + vi.mocked(assets.appendEdit).mockResolvedValue({ + edit: fakeEdit({ status: "pending" }), + created: true, + }); + vi.mocked(assets.markEditRunning).mockResolvedValue(fakeEdit({ status: "running" })); + vi.mocked(assets.markEditDone).mockResolvedValue( + fakeEdit({ status: "done", outputKey: "out.jpg", identityScore: 0.97 }), + ); + const store = fakeStore(); + const provider = fakeProvider(); + const identityGate = vi.fn(async () => ({ checked: true, score: 0.97, passed: true })); + + const engine = new StudioEngine({ + providers: [provider], + store, + isCloudAIEnabled: async () => true, + identityGate, + }); + + const edit = await engine.edit(ctx, { + assetId: "a1", + op: { op: "editSemantic", params: { instruction: "warm it up" } }, + parentEditId: null, + idempotencyKey: "k1", + }); + + expect(edit.status).toBe("done"); + expect(provider.execute).toHaveBeenCalledOnce(); + expect(identityGate).toHaveBeenCalledOnce(); // editSemantic is high identity-risk + expect(store.put).toHaveBeenCalled(); + expect(assets.markEditDone).toHaveBeenCalled(); + }); + + it("resolves a localized mask whose key embeds the asset id (no 'invalid mask reference')", async () => { + // getAsset resolves the target a1 AND the asset embedded in the mask key. + vi.mocked(assets.getAsset).mockImplementation(async (_ctx, id) => + id === "a1" ? fakeAsset() : null, + ); + vi.mocked(assets.appendEdit).mockResolvedValue({ + edit: fakeEdit({ op: "eraser", status: "pending" }), + created: true, + }); + vi.mocked(assets.markEditRunning).mockResolvedValue( + fakeEdit({ op: "eraser", status: "running" }), + ); + vi.mocked(assets.markEditDone).mockResolvedValue( + fakeEdit({ op: "eraser", status: "done", outputKey: "out.jpg" }), + ); + const maskBytes = Buffer.from([7, 7, 7]); + const store = fakeStore(); + vi.mocked(store.get).mockImplementation(async (key: string) => + key.includes("mask-") ? maskBytes : Buffer.from([1, 2, 3]), + ); + const provider = fakeProvider(); + const engine = new StudioEngine({ + providers: [provider], + store, + isCloudAIEnabled: async () => true, + identityGate: vi.fn(async () => ({ checked: false, score: null, passed: true })), + }); + + const edit = await engine.edit(ctx, { + assetId: "a1", + op: { op: "eraser", params: { maskKey: "org/local/studio/a1/mask-1.png" } }, + parentEditId: null, + idempotencyKey: "k-mask", + }); + + expect(edit.status).toBe("done"); + const providerInput = vi.mocked(provider.execute).mock.calls[0][1]; + expect(Array.from(providerInput.maskBytes ?? [])).toEqual([7, 7, 7]); + }); + + it("rejects a mask whose key points at an asset that does not resolve for this user", async () => { + vi.mocked(assets.getAsset).mockImplementation(async (_ctx, id) => + id === "a1" ? fakeAsset() : null, + ); + vi.mocked(assets.appendEdit).mockResolvedValue({ + edit: fakeEdit({ op: "eraser", status: "pending" }), + created: true, + }); + vi.mocked(assets.markEditRunning).mockResolvedValue( + fakeEdit({ op: "eraser", status: "running" }), + ); + const provider = fakeProvider(); + const engine = new StudioEngine({ + providers: [provider], + store: fakeStore(), + isCloudAIEnabled: async () => true, + identityGate: vi.fn(async () => ({ checked: false, score: null, passed: true })), + }); + + await expect( + engine.edit(ctx, { + assetId: "a1", + op: { op: "eraser", params: { maskKey: "org/local/studio/zzz/mask-1.png" } }, + parentEditId: null, + idempotencyKey: "k-bad-mask", + }), + ).rejects.toThrow("invalid mask reference"); + expect(provider.execute).not.toHaveBeenCalled(); + expect(assets.markEditFailed).toHaveBeenCalled(); + }); + + it("blocks a generative op when cloud-AI consent is off, before any row is created", async () => { + vi.mocked(assets.getAsset).mockResolvedValue(fakeAsset()); + const provider = fakeProvider(); + const engine = new StudioEngine({ + providers: [provider], + store: fakeStore(), + isCloudAIEnabled: async () => false, + }); + await expect( + engine.edit(ctx, { + assetId: "a1", + op: { op: "editSemantic", params: { instruction: "x" } }, + parentEditId: null, + idempotencyKey: "k", + }), + ).rejects.toBeInstanceOf(ConsentRequiredError); + expect(assets.appendEdit).not.toHaveBeenCalled(); + expect(provider.execute).not.toHaveBeenCalled(); + }); + + it("does not gate a deterministic op on consent", async () => { + vi.mocked(assets.getAsset).mockResolvedValue(fakeAsset()); + vi.mocked(assets.appendEdit).mockResolvedValue({ + edit: fakeEdit({ op: "adjust", status: "pending" }), + created: true, + }); + vi.mocked(assets.markEditRunning).mockResolvedValue( + fakeEdit({ op: "adjust", status: "running" }), + ); + vi.mocked(assets.markEditDone).mockResolvedValue( + fakeEdit({ op: "adjust", status: "done", outputKey: "out.jpg" }), + ); + const provider = fakeProvider({ kind: "deterministic" }); + const identityGate = vi.fn(async () => ({ checked: false, score: null, passed: true })); + const engine = new StudioEngine({ + providers: [provider], + store: fakeStore(), + isCloudAIEnabled: async () => false, // off, but the provider is deterministic + identityGate, + }); + const edit = await engine.edit(ctx, { + assetId: "a1", + op: { op: "adjust", params: { exposure: 0.3 } }, + parentEditId: null, + idempotencyKey: "k2", + }); + expect(edit.status).toBe("done"); + expect(identityGate).not.toHaveBeenCalled(); // adjust is identityRisk none + expect(provider.execute).toHaveBeenCalled(); + }); + + it("deviceRender feeds the client-supplied bytes to the provider (not the chain source)", async () => { + vi.mocked(assets.getAsset).mockResolvedValue(fakeAsset()); + vi.mocked(assets.appendEdit).mockResolvedValue({ + edit: fakeEdit({ op: "deviceRender", status: "pending" }), + created: true, + }); + vi.mocked(assets.markEditRunning).mockResolvedValue( + fakeEdit({ op: "deviceRender", status: "running" }), + ); + vi.mocked(assets.markEditDone).mockResolvedValue( + fakeEdit({ op: "deviceRender", status: "done", outputKey: "out.jpg" }), + ); + const store = fakeStore(); + const provider = fakeProvider({ kind: "deterministic" }); + const rendered = new Uint8Array([42, 43, 44]); + const engine = new StudioEngine({ + providers: [provider], + store, + isCloudAIEnabled: async () => false, + }); + + const edit = await engine.edit(ctx, { + assetId: "a1", + op: { op: "deviceRender", params: { tool: "makeup" } }, + parentEditId: null, + idempotencyKey: "kd", + inlineInputBytes: rendered, + }); + + expect(edit.status).toBe("done"); + // The provider sees the uploaded render, and the source object is never fetched + // (identityRisk none + inline bytes present). + expect(provider.execute).toHaveBeenCalledWith( + expect.objectContaining({ op: "deviceRender" }), + expect.objectContaining({ bytes: rendered }), + ); + expect(store.get).not.toHaveBeenCalled(); + }); + + it("deviceRender without inline bytes fails the edit", async () => { + vi.mocked(assets.getAsset).mockResolvedValue(fakeAsset()); + vi.mocked(assets.appendEdit).mockResolvedValue({ + edit: fakeEdit({ op: "deviceRender", status: "pending" }), + created: true, + }); + vi.mocked(assets.markEditRunning).mockResolvedValue( + fakeEdit({ op: "deviceRender", status: "running" }), + ); + vi.mocked(assets.markEditFailed).mockResolvedValue( + fakeEdit({ op: "deviceRender", status: "failed" }), + ); + const engine = new StudioEngine({ + providers: [fakeProvider({ kind: "deterministic" })], + store: fakeStore(), + }); + await expect( + engine.edit(ctx, { + assetId: "a1", + op: { op: "deviceRender", params: { tool: "makeup" } }, + parentEditId: null, + idempotencyKey: "kd2", + }), + ).rejects.toThrow(/requires input_image/); + expect(assets.markEditFailed).toHaveBeenCalled(); + }); + + it("throws NoProviderError without creating a row when no provider supports the op", async () => { + vi.mocked(assets.getAsset).mockResolvedValue(fakeAsset()); + const engine = new StudioEngine({ providers: [], store: fakeStore() }); + await expect( + engine.edit(ctx, { + assetId: "a1", + op: { op: "adjust", params: {} }, + parentEditId: null, + idempotencyKey: "k", + }), + ).rejects.toBeInstanceOf(NoProviderError); + expect(assets.appendEdit).not.toHaveBeenCalled(); + }); + + it("marks the edit failed and rethrows when the identity gate rejects", async () => { + vi.mocked(assets.getAsset).mockResolvedValue(fakeAsset()); + vi.mocked(assets.appendEdit).mockResolvedValue({ + edit: fakeEdit({ status: "pending" }), + created: true, + }); + vi.mocked(assets.markEditRunning).mockResolvedValue(fakeEdit({ status: "running" })); + vi.mocked(assets.markEditFailed).mockResolvedValue(fakeEdit({ status: "failed" })); + const provider = fakeProvider(); + const engine = new StudioEngine({ + providers: [provider], + store: fakeStore(), + isCloudAIEnabled: async () => true, + identityGate: vi.fn(async () => { + throw new IdentityDriftError(0.4, 0.6); + }), + }); + await expect( + engine.edit(ctx, { + assetId: "a1", + op: { op: "editSemantic", params: { instruction: "x" } }, + parentEditId: null, + idempotencyKey: "k3", + }), + ).rejects.toBeInstanceOf(IdentityDriftError); + expect(assets.markEditFailed).toHaveBeenCalled(); + }); + + it("short-circuits an idempotent retry (created=false), never re-running the provider", async () => { + vi.mocked(assets.getAsset).mockResolvedValue(fakeAsset()); + vi.mocked(assets.appendEdit).mockResolvedValue({ + edit: fakeEdit({ status: "running", outputKey: null }), + created: false, + }); + const provider = fakeProvider(); + const engine = new StudioEngine({ + providers: [provider], + store: fakeStore(), + isCloudAIEnabled: async () => true, + }); + const edit = await engine.edit(ctx, { + assetId: "a1", + op: { op: "editSemantic", params: { instruction: "x" } }, + parentEditId: null, + idempotencyKey: "k1", + }); + expect(edit.status).toBe("running"); // returned the in-flight row as-is + expect(provider.execute).not.toHaveBeenCalled(); + }); +}); diff --git a/src/studio/engine.ts b/src/studio/engine.ts new file mode 100644 index 00000000..2a82fd89 --- /dev/null +++ b/src/studio/engine.ts @@ -0,0 +1,275 @@ +/** + * Studio engine: the capability router. One `edit()` entry turns a requested op + * into a persisted, executed edit: + * + * validate op -> load asset -> consent gate (generative only) -> append to the + * chain (OCC + idempotency) -> resolve input bytes -> run the provider -> the + * identity gate (face-risk ops) -> persist output + ~256px preview -> record. + * + * Providers, the object store, the identity gate, the consent check, and the + * preview maker are all injected, so the engine is testable without sharp, the + * Google SDK, a DB, or a bucket. Real providers (local-sharp, gemini) and the + * preview maker land alongside this. See the design doc sections 3 + 7. + */ + +import { createLogger } from "../lib/logger.ts"; +import type { TenantContext } from "../auth/tenant-context.ts"; +import { getObjectStore, type ObjectStore, objectKey } from "../storage/object-store.ts"; +import { + appendEdit, + confirmAsset, + getAsset, + getEdit, + markEditDone, + markEditFailed, + markEditRunning, + type StudioAsset, + StudioAssetNotFoundError, + type StudioEdit, +} from "./assets.ts"; +import { ConsentRequiredError, isCloudAIEnabled } from "./consent.ts"; +import { assertIdentityPreserved } from "./identity-gate.ts"; +import { readPhotoStyle, recordEditSignal } from "./learn.ts"; +import { OP_META, type StudioOp, type StudioOpName, validateOp } from "./ops.ts"; + +const log = createLogger("studio-engine"); + +export interface ProviderInput { + bytes: Uint8Array; + mime: string; + params: Record; + /** Device/tap mask for localized ops (mask-bounded paste-back happens in the provider). */ + maskBytes?: Uint8Array | null; + /** The user's learned photo-editing taste, injected into the prompt for a personalized + * edit (auto-enhance). Never set for an explicit typed edit. */ + styleHint?: string; +} + +export interface ProviderOutput { + bytes: Uint8Array; + mime: string; + costUsd?: number; + provider: string; +} + +export interface StudioProvider { + readonly name: string; + /** deterministic = pod CPU / on-device (free, never gated); generative = cloud (consent-gated). */ + readonly kind: "deterministic" | "generative"; + supports(op: StudioOpName): boolean; + execute(op: StudioOp, input: ProviderInput): Promise; +} + +export class NoProviderError extends Error { + constructor(public readonly op: string) { + super(`No studio provider supports op: ${op}`); + this.name = "NoProviderError"; + } +} + +export interface StudioEngineDeps { + providers: StudioProvider[]; + store?: ObjectStore; + /** Defaults to the config-backed org-level toggle. */ + isCloudAIEnabled?: () => Promise; + /** Defaults to the process identity gate. */ + identityGate?: typeof assertIdentityPreserved; + identityThreshold?: number; + /** ~256px preview maker (sharp). When absent, previews are skipped. */ + makePreview?: (bytes: Uint8Array, mime: string) => Promise; +} + +export interface EditRequest { + assetId: string; + op: { op: string; params?: unknown }; + parentEditId: string | null; + idempotencyKey: string; + /** Object key of a device/tap mask already uploaded for a localized op. */ + maskKey?: string | null; + /** Inline output bytes for a `deviceRender` op (the on-device render). Ignored for any other op. */ + inlineInputBytes?: Uint8Array | null; + /** Mime of `inlineInputBytes` (defaults to the asset mime). */ + inlineInputMime?: string; +} + +function extFor(mime: string): string { + if (mime === "image/png") return "png"; + if (mime === "image/heic" || mime === "image/heif") return "heic"; + if (mime === "image/webp") return "webp"; + return "jpg"; +} + +export class StudioEngine { + private readonly providers: StudioProvider[]; + private readonly store: ObjectStore; + private readonly isCloudAIEnabledFn: () => Promise; + private readonly identityGate: typeof assertIdentityPreserved; + private readonly identityThreshold?: number; + private readonly makePreview?: (bytes: Uint8Array, mime: string) => Promise; + + constructor(deps: StudioEngineDeps) { + this.providers = deps.providers; + this.store = deps.store ?? getObjectStore(); + this.isCloudAIEnabledFn = deps.isCloudAIEnabled ?? isCloudAIEnabled; + this.identityGate = deps.identityGate ?? assertIdentityPreserved; + this.identityThreshold = deps.identityThreshold; + this.makePreview = deps.makePreview; + } + + private resolveProvider(op: StudioOpName): StudioProvider { + const provider = this.providers.find((p) => p.supports(op)); + if (!provider) throw new NoProviderError(op); + return provider; + } + + /** + * Resolve + tenant-validate the mask for a localized op. The key may arrive on + * the transport field (`req.maskKey`) OR inside the op params (the op registry + * is the cross-interface contract — eraser requires `params.maskKey`). Studio + * object keys embed the owning asset (`.../studio//...`); we require + * that asset to resolve under THIS user (getAsset is user_id-scoped), so a + * client can never point the mask at another tenant's object. + */ + private async resolveMask( + ctx: TenantContext, + req: EditRequest, + op: StudioOp, + ): Promise { + const paramMask = (op.params as { maskKey?: string }).maskKey; + const key = req.maskKey ?? paramMask; + if (!key) return null; + const assetId = key.match(/\/studio\/([^/]+)\//)?.[1]; + if (!assetId || !(await getAsset(ctx, assetId))) { + throw new Error("invalid mask reference"); + } + return this.store.get(key); + } + + /** The input image for an edit is its parent's output, else the original. */ + private async resolveInputKey( + ctx: TenantContext, + asset: StudioAsset, + parentEditId: string | null, + ): Promise { + if (!parentEditId) return asset.objectKey; + const parent = await getEdit(ctx, parentEditId); + // Fail loud rather than silently building on the original: a non-null parent + // with no output means the chain is being mutated out from under us (the + // appendEdit OCC guards against this, this is defense in depth). + if (!parent) throw new StudioAssetNotFoundError(parentEditId); + if (parent.status !== "done" || !parent.outputKey) { + throw new Error(`studio parent edit ${parentEditId} is not ready (status=${parent.status})`); + } + return parent.outputKey; + } + + /** + * Execute one edit end to end. Entry symbol for the feature manifest. + */ + async edit(ctx: TenantContext, req: EditRequest): Promise { + const op = validateOp(req.op); + const meta = OP_META[op.op]; + + const asset = await getAsset(ctx, req.assetId); + if (!asset) throw new StudioAssetNotFoundError(req.assetId); + + // An edit request implies the upload completed: confirm the asset out of + // `pending` so __studio_gc__ never reaps the original of an in-use asset + // (the conversational/MCP path has no other confirm step). + if (asset.status === "pending") await confirmAsset(ctx, asset.id); + + // Resolve the provider before the consent gate, so consent keys off the + // provider that will ACTUALLY run, not the op's declared kind (which can be + // wrong, e.g. a "deterministic" op that only a cloud provider supports). + const provider = this.resolveProvider(op.op); + if (provider.kind === "generative" && !(await this.isCloudAIEnabledFn())) { + throw new ConsentRequiredError(); + } + + const inputKey = await this.resolveInputKey(ctx, asset, req.parentEditId); + + // Append to the chain (OCC + idempotency). An idempotent retry returns the + // existing row and must NOT re-execute or re-charge, whatever its status. + const { edit, created } = await appendEdit(ctx, { + assetId: req.assetId, + parentEditId: req.parentEditId, + idempotencyKey: req.idempotencyKey, + op, + provider: provider.name, + inputKey, + }); + if (!created) return edit; + + await markEditRunning(ctx, edit.id, provider.name); + try { + // For `deviceRender` the client ships the rendered pixels inline; every other + // op derives its input from the chain. Inline bytes are NEVER honored for + // another op (no source-bypass). The chain's source is still loaded when the + // identity gate needs an original-vs-result comparison. + const useInline = op.op === "deviceRender"; + if (useInline && (!req.inlineInputBytes || req.inlineInputBytes.length === 0)) { + throw new Error("deviceRender requires input_image bytes"); + } + const inlineBytes = useInline ? req.inlineInputBytes! : null; + const needSource = inlineBytes == null || meta.identityRisk !== "none"; + const sourceBytes = needSource ? await this.store.get(inputKey) : new Uint8Array(); + const providerBytes = inlineBytes ?? sourceBytes; + const providerMime = inlineBytes ? (req.inlineInputMime ?? asset.mime) : asset.mime; + const maskBytes = await this.resolveMask(ctx, req, op); + // Personalized edit (auto-enhance): inject the user's learned taste into the prompt. + const styleHint = + op.op === "editSemantic" && (op.params as { personalize?: boolean }).personalize + ? await readPhotoStyle(ctx.userId) + : undefined; + + const out = await provider.execute(op, { + bytes: providerBytes, + mime: providerMime, + params: op.params, + maskBytes, + styleHint: styleHint || undefined, + }); + + // Identity gate for face-risk ops (skips when no embedder is configured). + let identityScore: number | null = null; + if (meta.identityRisk !== "none") { + const result = await this.identityGate(sourceBytes, out.bytes, { + threshold: this.identityThreshold, + }); + identityScore = result.score; + } + + const ext = extFor(out.mime); + const outputKey = objectKey("studio", asset.id, `${edit.id}.${ext}`); + await this.store.put(outputKey, out.bytes, out.mime); + + let previewKey: string | null = null; + if (this.makePreview) { + const preview = await this.makePreview(out.bytes, out.mime); + if (preview) { + previewKey = objectKey("studio", asset.id, `${edit.id}.preview.jpg`); + await this.store.put(previewKey, preview, "image/jpeg"); + } + } + + const done = await markEditDone(ctx, edit.id, { + outputKey, + previewKey, + costUsd: out.costUsd ?? 0, + identityScore, + }); + // Learn the user's taste from the edits they apply (fire-and-forget). Only the + // rich natural-language edits carry a signal worth distilling. + if (op.op === "editSemantic") { + const instruction = String((op.params as { instruction?: unknown }).instruction ?? ""); + void recordEditSignal(ctx.userId, op.op, instruction).catch(() => {}); + } + return done ?? edit; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + log.warn({ editId: edit.id, op: op.op, err: message }, "studio edit failed"); + await markEditFailed(ctx, edit.id, message); + throw err; + } + } +} diff --git a/src/studio/face-embedder.test.ts b/src/studio/face-embedder.test.ts new file mode 100644 index 00000000..8c90aaa9 --- /dev/null +++ b/src/studio/face-embedder.test.ts @@ -0,0 +1,24 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { createOnnxFaceEmbedder, installServerFaceEmbedder } from "./face-embedder.ts"; + +describe("server face embedder (optional, graceful)", () => { + const prev = { ...process.env }; + afterEach(() => { + process.env = { ...prev }; + }); + + it("createOnnxFaceEmbedder returns null when onnxruntime is unavailable", async () => { + // onnxruntime-node is an optional dep and is not installed in this env. + expect(await createOnnxFaceEmbedder({ modelPath: "/nonexistent/model.onnx" })).toBeNull(); + }); + + it("installServerFaceEmbedder is a no-op without NOMOS_FACE_MODEL_PATH", async () => { + delete process.env.NOMOS_FACE_MODEL_PATH; + expect(await installServerFaceEmbedder()).toBe(false); + }); + + it("installServerFaceEmbedder returns false when the model cannot load", async () => { + process.env.NOMOS_FACE_MODEL_PATH = "/nonexistent/model.onnx"; + expect(await installServerFaceEmbedder()).toBe(false); + }); +}); diff --git a/src/studio/face-embedder.ts b/src/studio/face-embedder.ts new file mode 100644 index 00000000..de997e01 --- /dev/null +++ b/src/studio/face-embedder.ts @@ -0,0 +1,113 @@ +/** + * Optional server-side face embedder for the identity gate. Loads an + * operator-provided face-recognition ONNX model (NOMOS_FACE_MODEL_PATH) through + * onnxruntime-node, lazily, and embeds a face crop. Deliberately NOT bundled: it + * keeps the repo light, and the privacy-preferred path is the on-device check + * reported via the identity-report RPC (recordIdentityScore). When no model is + * configured, the gate stays a documented no-op (assertIdentityPreserved skips). + * + * onnxruntime-node is an optional dependency, imported by a non-literal specifier + * so typecheck/build do not require it to be installed. + */ + +import sharp from "sharp"; +import { createLogger } from "../lib/logger.ts"; +import { type FaceEmbedder, setFaceEmbedder } from "./identity-gate.ts"; + +const log = createLogger("studio-face-embedder"); + +interface OrtSession { + run(feeds: Record): Promise }>>; + inputNames: string[]; + outputNames: string[]; +} +interface OrtModule { + InferenceSession: { create(path: string): Promise }; + Tensor: new (type: string, data: Float32Array, dims: number[]) => unknown; +} + +const ORT_MODULE = "onnxruntime-node"; + +async function loadOrt(): Promise { + try { + return (await import(ORT_MODULE)) as unknown as OrtModule; + } catch { + log.warn("onnxruntime-node not installed; server face embedder unavailable"); + return null; + } +} + +export interface OnnxFaceEmbedderOptions { + modelPath: string; + /** Square model input edge in px (ArcFace-class default). */ + inputSize?: number; +} + +/** + * Build an embedder over a face-recognition ONNX model. Expects an already + * face-cropped image (alignment/detection is the caller's / on-device job). + * Returns null when onnxruntime or the model is unavailable. + */ +export async function createOnnxFaceEmbedder( + opts: OnnxFaceEmbedderOptions, +): Promise { + const ort = await loadOrt(); + if (!ort) return null; + + let session: OrtSession; + try { + session = await ort.InferenceSession.create(opts.modelPath); + } catch (err) { + log.error( + { err: err instanceof Error ? err.message : err, modelPath: opts.modelPath }, + "failed to load face model", + ); + return null; + } + + const size = opts.inputSize ?? 112; + const inputName = session.inputNames[0]; + const outputName = session.outputNames[0]; + + return async (image: Uint8Array): Promise => { + try { + const { data } = await sharp(Buffer.from(image)) + .resize(size, size, { fit: "cover" }) + .removeAlpha() + .raw() + .toBuffer({ resolveWithObject: true }); + // RGB -> CHW float32, normalized to [-1, 1] (ArcFace-style). + const plane = size * size; + const chw = new Float32Array(3 * plane); + for (let i = 0; i < plane; i++) { + chw[i] = (data[i * 3] / 255 - 0.5) / 0.5; + chw[plane + i] = (data[i * 3 + 1] / 255 - 0.5) / 0.5; + chw[2 * plane + i] = (data[i * 3 + 2] / 255 - 0.5) / 0.5; + } + const tensor = new ort.Tensor("float32", chw, [1, 3, size, size]); + const out = await session.run({ [inputName]: tensor }); + const emb = out[outputName]?.data; + return emb ? Array.from(emb) : null; + } catch (err) { + log.warn({ err: err instanceof Error ? err.message : err }, "face embed failed"); + return null; + } + }; +} + +/** + * Install the server embedder process-wide when NOMOS_FACE_MODEL_PATH is set. + * Returns true on success. Safe to call at boot; a no-op without the env var. + */ +export async function installServerFaceEmbedder(): Promise { + const modelPath = process.env.NOMOS_FACE_MODEL_PATH; + if (!modelPath) return false; + const inputSize = process.env.NOMOS_FACE_MODEL_INPUT + ? Number(process.env.NOMOS_FACE_MODEL_INPUT) + : undefined; + const embedder = await createOnnxFaceEmbedder({ modelPath, inputSize }); + if (!embedder) return false; + setFaceEmbedder(embedder); + log.info({ modelPath }, "studio: server face embedder installed"); + return true; +} diff --git a/src/studio/gc.test.ts b/src/studio/gc.test.ts new file mode 100644 index 00000000..57c32ad0 --- /dev/null +++ b/src/studio/gc.test.ts @@ -0,0 +1,61 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createMockDb } from "../db/test-helpers.ts"; + +const { db, addResult, getQueries, reset } = createMockDb(); +vi.mock("../db/client.ts", () => ({ getKysely: () => db })); + +import type { TenantContext } from "../auth/tenant-context.ts"; +import type { ObjectStore } from "../storage/object-store.ts"; +import { runStudioGcForUser } from "./gc.ts"; + +const ctx = { orgId: "local", userId: "u1" } as TenantContext; + +function fakeStore(): ObjectStore { + return { + get: vi.fn(), + put: vi.fn(), + head: vi.fn(), + delete: vi.fn(async () => {}), + list: vi.fn(), + presignPut: vi.fn(), + presignGet: vi.fn(), + } as unknown as ObjectStore; +} + +beforeEach(() => reset()); + +describe("runStudioGcForUser", () => { + it("expires unconfirmed uploads and aged intermediates, dropping their objects", async () => { + addResult([{ id: "a1", object_key: "org/local/studio/a1/original.jpg" }]); // pending assets + addResult([]); // UPDATE pending + addResult([{ id: "e1", output_key: "out1.jpg", preview_key: "prev1.jpg" }]); // intermediates + addResult([]); // UPDATE intermediates + const store = fakeStore(); + const r = await runStudioGcForUser(ctx, { store, now: Date.now() }); + expect(r.assetsExpired).toBe(1); + expect(r.editsExpired).toBe(1); + expect(r.objectsDeleted).toBe(3); // original + output + preview + expect(store.delete).toHaveBeenCalledWith("org/local/studio/a1/original.jpg"); + expect(store.delete).toHaveBeenCalledWith("out1.jpg"); + expect(store.delete).toHaveBeenCalledWith("prev1.jpg"); + // Regression: the pending sweep must only reap assets with NO edits, so an + // in-use original (head_edit_id set) is never deleted. + expect(getQueries().some((q) => /"head_edit_id" is null/i.test(q.sql))).toBe(true); + }); + + it("is a no-op when nothing is expirable", async () => { + addResult([]); // pending: none + addResult([]); // intermediates: none + const store = fakeStore(); + const r = await runStudioGcForUser(ctx, { store }); + expect(r).toEqual({ assetsExpired: 0, editsExpired: 0, objectsDeleted: 0 }); + expect(store.delete).not.toHaveBeenCalled(); + }); + + it("scopes its queries to the user", async () => { + addResult([]); + addResult([]); + await runStudioGcForUser(ctx, { store: fakeStore() }); + expect(getQueries().some((q) => q.parameters.includes("u1"))).toBe(true); + }); +}); diff --git a/src/studio/gc.ts b/src/studio/gc.ts new file mode 100644 index 00000000..b2fdeefc --- /dev/null +++ b/src/studio/gc.ts @@ -0,0 +1,145 @@ +/** + * Studio garbage collection. The single clock for object/row cleanup (chosen + * over GCS object-lifecycle rules so the DB and the bucket can never disagree and + * the history strip never shows a thumbnail whose object was reaped). Fired by the + * __studio_gc__ cron sentinel, per owner. + * + * Two sweeps: + * 1. Unconfirmed uploads (assets stuck `pending` past a TTL) -> expire + drop. + * 2. Aged intermediate edit results that are no longer the chain head -> expire + * + drop output/preview blobs. Originals (the asset object) and the live head + * output are always kept. Rows are marked `expired` BEFORE the object is + * deleted. See the design doc section 3 (object lifecycle). + */ + +import { sql } from "kysely"; +import type { TenantContext } from "../auth/tenant-context.ts"; +import { getKysely } from "../db/client.ts"; +import { createLogger } from "../lib/logger.ts"; +import { getObjectStore, type ObjectStore } from "../storage/object-store.ts"; + +const log = createLogger("studio-gc"); + +export const STUDIO_GC_SENTINEL = "__studio_gc__"; +const PENDING_TTL_HOURS = 24; +const INTERMEDIATE_TTL_DAYS = 30; + +export interface StudioGcResult { + assetsExpired: number; + editsExpired: number; + objectsDeleted: number; +} + +export interface StudioGcOptions { + store?: ObjectStore; + now?: number; + pendingTtlHours?: number; + intermediateTtlDays?: number; +} + +async function dropObject(store: ObjectStore, key: string | null): Promise { + if (!key) return false; + try { + await store.delete(key); + return true; + } catch (err) { + log.warn({ err: err instanceof Error ? err.message : err, key }, "gc: object delete failed"); + return false; + } +} + +/** Run both GC sweeps for one owner. Scoped by user_id. */ +export async function runStudioGcForUser( + ctx: TenantContext, + opts: StudioGcOptions = {}, +): Promise { + const db = getKysely(); + const store = opts.store ?? getObjectStore(); + const now = opts.now ?? Date.now(); + const pendingCutoff = new Date(now - (opts.pendingTtlHours ?? PENDING_TTL_HOURS) * 3_600_000); + const intermediateCutoff = new Date( + now - (opts.intermediateTtlDays ?? INTERMEDIATE_TTL_DAYS) * 86_400_000, + ); + + let objectsDeleted = 0; + + // 1) Unconfirmed uploads: ONLY assets that never got an edit (head_edit_id IS + // NULL). An asset with edits is in use regardless of `pending`, so it is never + // a candidate (the original is the chain input). DB is the clock: mark the rows + // expired FIRST, then delete the objects, so no reader ever sees a live row + // pointing at a deleted object. + const pending = await db + .selectFrom("studio_assets") + .select(["id", "object_key"]) + .where("user_id", "=", ctx.userId) + .where("status", "=", "pending") + .where("head_edit_id", "is", null) + .where("created_at", "<", pendingCutoff) + .execute(); + if (pending.length > 0) { + await db + .updateTable("studio_assets") + .set({ status: "expired", updated_at: sql`now()` }) + .where("user_id", "=", ctx.userId) + .where( + "id", + "in", + pending.map((a) => a.id), + ) + .execute(); + for (const a of pending) { + if (await dropObject(store, a.object_key)) objectsDeleted++; + } + } + + // 2) Aged intermediate edit results (anything that is no longer the chain head). + // Keys are captured before the row is nulled; mark expired FIRST, then delete. + const intermediates = await db + .selectFrom("studio_edits as e") + .innerJoin("studio_assets as a", "a.id", "e.asset_id") + .select(["e.id as id", "e.output_key as output_key", "e.preview_key as preview_key"]) + .where("e.user_id", "=", ctx.userId) + .where("e.status", "=", "done") + .where("e.created_at", "<", intermediateCutoff) + .where(sql`a.head_edit_id is distinct from e.id`) + .execute(); + if (intermediates.length > 0) { + await db + .updateTable("studio_edits") + .set({ status: "expired", output_key: null, preview_key: null, updated_at: sql`now()` }) + .where("user_id", "=", ctx.userId) + .where( + "id", + "in", + intermediates.map((e) => e.id), + ) + .execute(); + for (const e of intermediates) { + if (await dropObject(store, e.output_key)) objectsDeleted++; + if (await dropObject(store, e.preview_key)) objectsDeleted++; + } + } + + return { assetsExpired: pending.length, editsExpired: intermediates.length, objectsDeleted }; +} + +/** Fan out the GC over every memory owner. Called by the __studio_gc__ sentinel. */ +export async function runStudioGc(opts: StudioGcOptions = {}): Promise { + const { listMemoryOwners } = await import("../auth/org-members.ts"); + const totals: StudioGcResult = { assetsExpired: 0, editsExpired: 0, objectsDeleted: 0 }; + for (const userId of await listMemoryOwners()) { + try { + const ctx: TenantContext = { orgId: process.env.NOMOS_ORG_ID ?? "local", userId }; + const r = await runStudioGcForUser(ctx, opts); + totals.assetsExpired += r.assetsExpired; + totals.editsExpired += r.editsExpired; + totals.objectsDeleted += r.objectsDeleted; + } catch (err) { + log.error( + { err: err instanceof Error ? err.message : err, userId }, + "studio gc failed for owner", + ); + } + } + return totals; +} diff --git a/src/studio/identity-gate.test.ts b/src/studio/identity-gate.test.ts new file mode 100644 index 00000000..d7fdbf42 --- /dev/null +++ b/src/studio/identity-gate.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; +import { + assertIdentityPreserved, + cosineSimilarity, + type FaceEmbedder, + IdentityDriftError, +} from "./identity-gate.ts"; + +describe("cosineSimilarity", () => { + it("is 1 for identical vectors", () => { + expect(cosineSimilarity([1, 2, 3], [1, 2, 3])).toBeCloseTo(1); + }); + it("is 0 for orthogonal vectors", () => { + expect(cosineSimilarity([1, 0], [0, 1])).toBeCloseTo(0); + }); + it("is 0 for mismatched length or empty", () => { + expect(cosineSimilarity([1], [1, 2])).toBe(0); + expect(cosineSimilarity([], [])).toBe(0); + }); +}); + +describe("assertIdentityPreserved", () => { + const img = new Uint8Array([1, 2, 3]); + + it("skips (passes) when no embedder is configured", async () => { + const r = await assertIdentityPreserved(img, img); + expect(r.checked).toBe(false); + expect(r.passed).toBe(true); + }); + + it("passes when similarity meets the threshold", async () => { + const embedder: FaceEmbedder = async () => [1, 2, 3]; + const r = await assertIdentityPreserved(img, img, { embedder }); + expect(r.checked).toBe(true); + expect(r.score).toBeCloseTo(1); + expect(r.passed).toBe(true); + }); + + it("throws IdentityDriftError below the threshold", async () => { + let n = 0; + const embedder: FaceEmbedder = async () => (n++ === 0 ? [1, 0, 0] : [0, 1, 0]); // orthogonal + await expect( + assertIdentityPreserved(img, img, { embedder, threshold: 0.6 }), + ).rejects.toBeInstanceOf(IdentityDriftError); + }); + + it("skips when no face is found (embedder returns null)", async () => { + const embedder: FaceEmbedder = async () => null; + const r = await assertIdentityPreserved(img, img, { embedder }); + expect(r.checked).toBe(false); + expect(r.passed).toBe(true); + }); +}); diff --git a/src/studio/identity-gate.ts b/src/studio/identity-gate.ts new file mode 100644 index 00000000..367355d2 --- /dev/null +++ b/src/studio/identity-gate.ts @@ -0,0 +1,85 @@ +/** + * Identity gate: a face-touching generative edit must not change WHO is in the + * photo. It compares a face embedding of the input vs the output (cosine + * similarity); below threshold => IdentityDriftError, and the engine retries + * softer or surfaces "this changed your face too much". + * + * The embedder is pluggable (an on-device Vision embedding shipped up with the + * request, or a server model). When no embedder is configured the gate SKIPS and + * logs, so dev/eval run without an embedding model while the contract + wiring + * already exist. A manifest invariant requires every face-touching generative op + * to pass through here. See the design doc section 7. + */ + +import { createLogger } from "../lib/logger.ts"; + +const log = createLogger("studio-identity-gate"); + +/** Returns an embedding vector, or null when no face is detected (not a face edit). */ +export type FaceEmbedder = (image: Uint8Array) => Promise; + +export const DEFAULT_IDENTITY_THRESHOLD = 0.6; + +let configuredEmbedder: FaceEmbedder | null = null; + +/** Install the process-wide face embedder (e.g. a server model on boot). */ +export function setFaceEmbedder(embedder: FaceEmbedder | null): void { + configuredEmbedder = embedder; +} + +export class IdentityDriftError extends Error { + constructor( + public readonly score: number, + public readonly threshold: number, + ) { + super(`Identity drift: face similarity ${score.toFixed(3)} below ${threshold}`); + this.name = "IdentityDriftError"; + } +} + +export interface IdentityResult { + /** false = skipped (no embedder, or no face in either image). */ + checked: boolean; + score: number | null; + passed: boolean; +} + +export function cosineSimilarity(a: number[], b: number[]): number { + if (a.length === 0 || a.length !== b.length) return 0; + let dot = 0; + let na = 0; + let nb = 0; + for (let i = 0; i < a.length; i++) { + dot += a[i] * b[i]; + na += a[i] * a[i]; + nb += b[i] * b[i]; + } + if (na === 0 || nb === 0) return 0; + return dot / (Math.sqrt(na) * Math.sqrt(nb)); +} + +/** + * Throws IdentityDriftError when the output face has drifted below threshold from + * the input. Skips (passed=true, checked=false) when no embedder is configured or + * no face is present in either image. + */ +export async function assertIdentityPreserved( + input: Uint8Array, + output: Uint8Array, + opts?: { threshold?: number; embedder?: FaceEmbedder }, +): Promise { + const embedder = opts?.embedder ?? configuredEmbedder; + if (!embedder) { + log.warn("identity gate skipped: no face embedder configured"); + return { checked: false, score: null, passed: true }; + } + const [ein, eout] = await Promise.all([embedder(input), embedder(output)]); + if (!ein || !eout) { + // No face in one side -> not a face edit; nothing for this gate to protect. + return { checked: false, score: null, passed: true }; + } + const score = cosineSimilarity(ein, eout); + const threshold = opts?.threshold ?? DEFAULT_IDENTITY_THRESHOLD; + if (score < threshold) throw new IdentityDriftError(score, threshold); + return { checked: true, score, passed: true }; +} diff --git a/src/studio/learn.test.ts b/src/studio/learn.test.ts new file mode 100644 index 00000000..92f98fba --- /dev/null +++ b/src/studio/learn.test.ts @@ -0,0 +1,121 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { runForkedAgent } = vi.hoisted(() => ({ runForkedAgent: vi.fn() })); +const { vaultRead, vaultWrite } = vi.hoisted(() => ({ vaultRead: vi.fn(), vaultWrite: vi.fn() })); +const { upsertUserModel } = vi.hoisted(() => ({ upsertUserModel: vi.fn() })); +const { loadEnvConfig } = vi.hoisted(() => ({ loadEnvConfig: vi.fn() })); + +vi.mock("../sdk/forked-agent.ts", () => ({ runForkedAgent })); +vi.mock("../memory/vault.ts", () => ({ vaultRead, vaultWrite })); +vi.mock("../db/user-model.ts", () => ({ upsertUserModel })); +vi.mock("../config/env.ts", () => ({ loadEnvConfig })); + +import { flushPhotoStyle, parseStyle, readPhotoStyle, recordEditSignal } from "./learn.ts"; + +describe("parseStyle", () => { + it("parses the profile + prefs", () => { + const s = parseStyle('{"profile":"Warm, soft skin.","prefs":{"tone":"warm","skin":"smooth"}}'); + expect(s?.profile).toBe("Warm, soft skin."); + expect(s?.prefs.tone).toBe("warm"); + expect(s?.prefs.skin).toBe("smooth"); + }); + + it("strips code fences", () => { + expect(parseStyle('```json\n{"profile":"x"}\n```')?.profile).toBe("x"); + }); + + it("recovers JSON wrapped in prose", () => { + expect(parseStyle('Here is the profile:\n{"profile":"warm"}\nThanks!')?.profile).toBe("warm"); + }); + + it("recovers the first object when the model emits the fenced block twice", () => { + // Real Haiku failure mode: the same fenced object repeated back-to-back. + const dup = + '```json\n{"profile":"warm","prefs":{"tone":"warm"}}\n``````json\n{"profile":"warm"}\n```'; + const s = parseStyle(dup); + expect(s?.profile).toBe("warm"); + expect(s?.prefs.tone).toBe("warm"); + }); + + it("ignores braces inside string values when balancing", () => { + expect(parseStyle('{"profile":"a {nested} brace","prefs":{}}')?.profile).toBe( + "a {nested} brace", + ); + }); + + it("returns null without a usable profile", () => { + expect(parseStyle('{"prefs":{}}')).toBeNull(); + expect(parseStyle("sorry, not JSON")).toBeNull(); + }); +}); + +describe("flushPhotoStyle", () => { + beforeEach(() => { + vi.clearAllMocks(); + loadEnvConfig.mockReturnValue({ adaptiveMemory: true }); + vaultRead.mockResolvedValue(null); + }); + + it("distills the batch into the vault note + photo_style user_model entries", async () => { + runForkedAgent.mockResolvedValue({ + text: '{"profile":"Warm and punchy.","prefs":{"tone":"warm","color":"punchy"}}', + }); + await flushPhotoStyle("u1", [{ op: "editSemantic", instruction: "warm it up" }]); + + expect(vaultWrite).toHaveBeenCalledWith( + "u1", + "photo-style.md", + "Warm and punchy.", + expect.anything(), + ); + expect(upsertUserModel).toHaveBeenCalledWith( + expect.objectContaining({ category: "photo_style", key: "tone", value: "warm" }), + ); + expect(upsertUserModel).toHaveBeenCalledWith( + expect.objectContaining({ category: "photo_style", key: "color", value: "punchy" }), + ); + }); + + it("no-ops on an empty batch or an unparseable distillation", async () => { + await flushPhotoStyle("u1", []); + expect(runForkedAgent).not.toHaveBeenCalled(); + + runForkedAgent.mockResolvedValue({ text: "i couldn't" }); + await flushPhotoStyle("u1", [{ op: "editSemantic", instruction: "x" }]); + expect(vaultWrite).not.toHaveBeenCalled(); + }); +}); + +describe("recordEditSignal", () => { + beforeEach(() => { + vi.clearAllMocks(); + vaultRead.mockResolvedValue(null); + runForkedAgent.mockResolvedValue({ text: '{"profile":"p","prefs":{}}' }); + }); + + it("never learns when adaptive memory is off", async () => { + loadEnvConfig.mockReturnValue({ adaptiveMemory: false }); + for (let i = 0; i < 6; i++) await recordEditSignal("off-user", "editSemantic", `edit ${i}`); + expect(runForkedAgent).not.toHaveBeenCalled(); + }); + + it("distills once it has buffered the threshold of edits", async () => { + loadEnvConfig.mockReturnValue({ adaptiveMemory: true }); + for (let i = 0; i < 4; i++) await recordEditSignal("on-user", "editSemantic", `edit ${i}`); + expect(runForkedAgent).toHaveBeenCalledTimes(1); + expect(vaultWrite).toHaveBeenCalledTimes(1); + }); +}); + +describe("readPhotoStyle", () => { + beforeEach(() => vi.clearAllMocks()); + + it("returns the note content when enabled, '' when disabled", async () => { + loadEnvConfig.mockReturnValue({ adaptiveMemory: true }); + vaultRead.mockResolvedValue({ content: " Warm, soft skin. " }); + expect(await readPhotoStyle("u9")).toBe("Warm, soft skin."); + + loadEnvConfig.mockReturnValue({ adaptiveMemory: false }); + expect(await readPhotoStyle("u9")).toBe(""); + }); +}); diff --git a/src/studio/learn.ts b/src/studio/learn.ts new file mode 100644 index 00000000..cec233ab --- /dev/null +++ b/src/studio/learn.ts @@ -0,0 +1,173 @@ +/** + * Studio learning: turn the photo edits a user actually applies into a durable sense + * of their taste, then feed it back into auto-enhance + personalized recommendations — + * the same extract -> user-model/vault -> inject loop the rest of the app uses. + * + * Capture is a fire-and-forget signal per committed generative edit. Distillation is a + * cheap background pass (every few edits) that updates a `photo-style.md` vault note (so + * it lives in the wiki and the user can read/edit it) plus `photo_style` user_model + * entries. Gated behind NOMOS_ADAPTIVE_MEMORY (same flag as all other learning). + */ + +import { loadEnvConfig } from "../config/env.ts"; +import { upsertUserModel } from "../db/user-model.ts"; +import { createLogger } from "../lib/logger.ts"; +import { runForkedAgent } from "../sdk/forked-agent.ts"; +import { vaultRead, vaultWrite } from "../memory/vault.ts"; + +const log = createLogger("studio-learn"); + +const STYLE_NOTE = "photo-style.md"; +const FLUSH_EVERY = 4; // distill after this many newly-applied edits + +interface EditSignal { + op: string; + instruction: string; +} + +// Per-user in-memory buffer of recent edit signals + a guard against concurrent flushes. +// Un-flushed signals are lost on restart (only ~3) — the distilled profile is durable. +const buffers = new Map(); +const flushing = new Set(); + +function enabled(): boolean { + return loadEnvConfig().adaptiveMemory; +} + +const DISTILL_PROMPT = `You maintain a short profile of a user's PHOTO-EDITING taste, learned from the edits they actually apply. Update the CURRENT PROFILE given the NEW EDITS. + +Capture only well-supported tendencies: tone (warm / cool / neutral), brightness and contrast, color (punchy / muted / natural), skin (smooth vs keep natural texture), what they tend to REMOVE (busy backgrounds, objects, blemishes) and KEEP (freckles, texture, grain), and any recurring style. Do not speculate from a single weak signal; keep the profile to 4-6 concise sentences. + +Output ONLY JSON: {"profile": "", "prefs": {"tone": "...", "color": "...", "skin": "...", "contrast": "...", "removes": "...", "keeps": "..."}}. Use "" for any pref you cannot support yet.`; + +interface DistilledStyle { + profile: string; + prefs: Record; +} + +/** + * Scan out the first brace-balanced {...} object (string-aware), so we recover the + * JSON even when the model wraps it in prose or — as Haiku sometimes does — emits the + * same fenced object twice back-to-back (which defeats a naive first-{ ... last-}). + */ +function firstJsonObject(s: string): string | null { + const start = s.indexOf("{"); + if (start < 0) return null; + let depth = 0; + let inStr = false; + let esc = false; + for (let i = start; i < s.length; i++) { + const ch = s[i]; + if (inStr) { + if (esc) esc = false; + else if (ch === "\\") esc = true; + else if (ch === '"') inStr = false; + } else if (ch === '"') { + inStr = true; + } else if (ch === "{") { + depth++; + } else if (ch === "}") { + depth--; + if (depth === 0) return s.slice(start, i + 1); + } + } + return null; +} + +/** + * Tolerant parse of the distiller's JSON. Tries the whole (de-fenced) string first, + * then the first brace-balanced object — covering fenced, prose-wrapped, and + * duplicated-block outputs. + */ +export function parseStyle(text: string): DistilledStyle | null { + const cleaned = text + .trim() + .replace(/^```(?:json)?/i, "") + .replace(/```$/i, "") + .trim(); + const candidates = [cleaned]; + const obj = firstJsonObject(cleaned); + if (obj) candidates.push(obj); + + for (const candidate of candidates) { + let raw: { profile?: unknown; prefs?: unknown }; + try { + raw = JSON.parse(candidate) as { profile?: unknown; prefs?: unknown }; + } catch { + continue; + } + if (typeof raw.profile !== "string" || !raw.profile.trim()) continue; + const prefs: Record = {}; + if (raw.prefs && typeof raw.prefs === "object") { + for (const [k, v] of Object.entries(raw.prefs as Record)) { + if (typeof v === "string" && v.trim()) prefs[k] = v.trim().slice(0, 80); + } + } + return { profile: raw.profile.trim().slice(0, 1200), prefs }; + } + return null; +} + +/** Record one applied edit as a learning signal; distills in the background every few. */ +export async function recordEditSignal( + userId: string, + op: string, + instruction: string, +): Promise { + if (!enabled()) return; + const text = instruction.trim(); + if (!text) return; + const buf = buffers.get(userId) ?? []; + buf.push({ op, instruction: text.slice(0, 200) }); + buffers.set(userId, buf); + if (buf.length >= FLUSH_EVERY && !flushing.has(userId)) { + const batch = buf.splice(0, buf.length); + flushing.add(userId); + try { + await flushPhotoStyle(userId, batch); + } catch (err) { + log.debug({ err: err instanceof Error ? err.message : String(err) }, "style flush failed"); + } finally { + flushing.delete(userId); + } + } +} + +/** Distill the batch (+ current profile) into the photo-style note + user_model entries. */ +export async function flushPhotoStyle(userId: string, signals: EditSignal[]): Promise { + if (!signals.length) return; + const config = loadEnvConfig(); + const current = (await vaultRead(userId, STYLE_NOTE))?.content ?? ""; + const recent = signals.map((s) => `- ${s.op}: ${s.instruction}`).join("\n"); + const result = await runForkedAgent({ + label: "studio-style", + model: config.extractionModel ?? "claude-haiku-4-5", + allowedTools: [], + prompt: `${DISTILL_PROMPT}\n\nCURRENT PROFILE:\n${current || "(none yet)"}\n\nNEW EDITS THE USER JUST APPLIED:\n${recent}`, + }); + const parsed = parseStyle(result.text); + if (!parsed) { + log.debug({ chars: result.text.length }, "distill output not parseable; skipping"); + return; + } + // The editable prose profile, in the wiki/vault. + await vaultWrite(userId, STYLE_NOTE, parsed.profile, { title: "Photo editing style" }); + // Structured, confidence-weighted prefs for injection. + for (const [key, value] of Object.entries(parsed.prefs)) { + await upsertUserModel({ + userId, + category: "photo_style", + key, + value, + sourceIds: [], + confidence: 0.7, + }); + } +} + +/** The user's learned photo-editing style for prompt injection ("" if none / disabled). */ +export async function readPhotoStyle(userId: string): Promise { + if (!enabled()) return ""; + const note = await vaultRead(userId, STYLE_NOTE); + return note?.content.trim() ?? ""; +} diff --git a/src/studio/ops.test.ts b/src/studio/ops.test.ts new file mode 100644 index 00000000..de24f007 --- /dev/null +++ b/src/studio/ops.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; +import { + OP_META, + OP_SCHEMAS, + OP_SPEC_VERSION, + type StudioOpName, + UnknownOpError, + validateOp, +} from "./ops.ts"; + +describe("studio op registry", () => { + it("validates a tonal adjust op and stamps the spec version", () => { + const op = validateOp({ op: "adjust", params: { exposure: 0.34, contrast: -0.1 } }); + expect(op.op).toBe("adjust"); + expect(op.opSpecVersion).toBe(OP_SPEC_VERSION); + expect(op.params).toEqual({ exposure: 0.34, contrast: -0.1 }); + }); + + it("applies defaults (filter intensity)", () => { + const op = validateOp({ op: "filter", params: { id: "terracotta" } }); + expect(op.params).toEqual({ id: "terracotta", intensity: 1 }); + }); + + it("rejects an unknown op with UnknownOpError", () => { + expect(() => validateOp({ op: "facelift", params: {} })).toThrow(UnknownOpError); + }); + + it("rejects out-of-range slider values", () => { + expect(() => validateOp({ op: "adjust", params: { exposure: 5 } })).toThrow(z.ZodError); + }); + + it("rejects an eraser with no mask (mask-bounded only)", () => { + expect(() => validateOp({ op: "eraser", params: {} })).toThrow(z.ZodError); + }); + + it("strips unknown keys is OFF: extra params are rejected (strict)", () => { + expect(() => validateOp({ op: "upscale", params: { factor: 2, sharpen: true } })).toThrow( + z.ZodError, + ); + }); + + it("defaults params to {} when omitted (restore takes none)", () => { + const op = validateOp({ op: "restore" }); + expect(op).toEqual({ op: "restore", params: {}, opSpecVersion: OP_SPEC_VERSION }); + }); + + it("every op name has metadata for routing + the identity gate", () => { + for (const name of Object.keys(OP_SCHEMAS) as StudioOpName[]) { + expect(OP_META[name]).toBeDefined(); + expect(["deterministic", "generative"]).toContain(OP_META[name].kind); + expect(["none", "low", "high"]).toContain(OP_META[name].identityRisk); + } + }); + + it("face-touching generative ops are flagged high identity-risk", () => { + expect(OP_META.editSemantic.identityRisk).toBe("high"); + expect(OP_META.restore.identityRisk).toBe("high"); + expect(OP_META.adjust.identityRisk).toBe("none"); + }); + + it("cutout is a cloud op (generative) so the consent gate covers it", () => { + // Regression: cutout was mislabeled deterministic and bypassed consent. + expect(OP_META.cutout.kind).toBe("generative"); + expect(OP_META.adjust.kind).toBe("deterministic"); + expect(OP_META.crop.kind).toBe("deterministic"); + }); + + it("deviceRender is free, never consent- or identity-gated (WYSIWYG on-device)", () => { + const op = validateOp({ op: "deviceRender", params: { tool: "makeup", detail: "lips" } }); + expect(op.params).toEqual({ tool: "makeup", detail: "lips" }); + expect(OP_META.deviceRender.kind).toBe("deterministic"); + expect(OP_META.deviceRender.identityRisk).toBe("none"); + }); + + it("deviceRender requires a tool label", () => { + expect(() => validateOp({ op: "deviceRender", params: {} })).toThrow(z.ZodError); + }); + + it("retouch defaults strength to 0.5; deterministic + low identity-risk", () => { + const op = validateOp({ op: "retouch", params: {} }); + expect(op.params).toEqual({ strength: 0.5 }); + expect(OP_META.retouch.kind).toBe("deterministic"); + expect(OP_META.retouch.identityRisk).toBe("low"); + }); + + it("retouch rejects out-of-range strength", () => { + expect(() => validateOp({ op: "retouch", params: { strength: 2 } })).toThrow(z.ZodError); + }); + + it("Phase 3 depth ops validate + apply defaults", () => { + expect(validateOp({ op: "muscle", params: {} }).params).toEqual({ + area: "full", + strength: 0.5, + }); + expect(validateOp({ op: "beard", params: {} }).params).toEqual({ action: "add" }); + expect(validateOp({ op: "expand", params: {} }).params).toEqual({ direction: "all" }); + expect(validateOp({ op: "hairstyle", params: { style: "short bob" } }).params).toEqual({ + style: "short bob", + }); + expect(() => validateOp({ op: "hairstyle", params: {} })).toThrow(z.ZodError); + expect(() => validateOp({ op: "sky", params: {} })).toThrow(z.ZodError); + }); + + it("Phase 3 depth ops are generative transforms, NOT identity-gated", () => { + // identityRisk "none" is intentional: these change appearance, so the + // preservation gate must not block them. + for (const op of ["muscle", "hairstyle", "beard", "relight", "expand", "sky"] as const) { + expect(OP_META[op].kind).toBe("generative"); + expect(OP_META[op].identityRisk).toBe("none"); + } + }); +}); diff --git a/src/studio/ops.ts b/src/studio/ops.ts new file mode 100644 index 00000000..9d3d218c --- /dev/null +++ b/src/studio/ops.ts @@ -0,0 +1,207 @@ +/** + * Studio op registry: the single, versioned vocabulary every edit is recorded + * in. The iOS app, the engine, and the (Phase 3) sidecar all speak these ops, + * so "undo / before-after / redo softer" and re-editability work across + * interfaces and versions. + * + * Bump OP_SPEC_VERSION on any breaking param change. Swift mirrors these by hand + * in v1 (codegen is a tracked TODO); a contract test pins the Swift encodings + * against this version. See the design doc section 3 (op registry). + */ + +import { z } from "zod"; + +export const OP_SPEC_VERSION = 1; + +/** Normalized -1..1 slider value. */ +const unit = z.number().min(-1).max(1); + +const adjust = z.strictObject({ + exposure: unit.optional(), + contrast: unit.optional(), + highlights: unit.optional(), + shadows: unit.optional(), + temperature: unit.optional(), + tint: unit.optional(), + saturation: unit.optional(), + vibrance: unit.optional(), + clarity: unit.optional(), +}); + +const crop = z.strictObject({ + x: z.number().min(0).max(1), + y: z.number().min(0).max(1), + width: z.number().min(0).max(1), + height: z.number().min(0).max(1), + rotate: z.number().min(-180).max(180).optional(), +}); + +const filter = z.strictObject({ + id: z.string().min(1), + intensity: z.number().min(0).max(1).default(1), +}); + +/** Natural-language instruction edit. Localized (region-only paste-back) when a mask is present. */ +const editSemantic = z.strictObject({ + instruction: z.string().min(1).max(1000), + strength: z.number().min(0).max(1).optional(), + maskKey: z.string().optional(), + /** Lean the edit toward the user's learned taste (auto-enhance only). */ + personalize: z.boolean().optional(), +}); + +/** Mask-bounded object removal (magic eraser). */ +const eraser = z.strictObject({ + maskKey: z.string().min(1), +}); + +/** Background removal. Device mask when present, else server matte. */ +const cutout = z.strictObject({ + maskKey: z.string().optional(), +}); + +const upscale = z.strictObject({ + factor: z.union([z.literal(2), z.literal(4)]).default(2), +}); + +const restore = z.strictObject({}); + +/** + * Commit an on-device render (Core Image adjust, MediaPipe makeup/reshape) as an + * edit. The pixels are produced client-side (WYSIWYG) and uploaded with the + * request; the engine re-encodes them via sharp (strips metadata, clamps size) + * and stores them as the output. `tool`/`detail` label the chain for history. + */ +const deviceRender = z.strictObject({ + tool: z.string().min(1).max(40), + detail: z.string().max(120).optional(), +}); + +/** + * Edge-preserving skin smoothing (one-tap retouch). Deterministic on the Phase-3 + * MediaPipe sidecar (free); falls back to a generative Gemini pass (consent-gated) + * until the sidecar passes parity. Consent + cost follow the RESOLVED provider. + */ +const retouch = z.strictObject({ + strength: z.number().min(0).max(1).default(0.5), +}); + +// ── Phase 3 generative depth bets (all cloud, consent-gated) ────────────────── +/** AI Muscle: add natural muscle definition (six-pack, V-line, arms). */ +const muscle = z.strictObject({ + area: z.enum(["abs", "arms", "chest", "full"]).default("full"), + strength: z.number().min(0).max(1).default(0.5), +}); +/** Hairstyle transfer / restyle. */ +const hairstyle = z.strictObject({ + style: z.string().min(1).max(200), +}); +/** Beard add / remove / trim. */ +const beard = z.strictObject({ + action: z.enum(["add", "remove", "trim"]).default("add"), + style: z.string().max(200).optional(), +}); +/** Relight: change the lighting direction / mood. */ +const relight = z.strictObject({ + direction: z.enum(["left", "right", "front", "back", "top"]).optional(), + mood: z.string().max(200).optional(), +}); +/** Generative expand (outpaint / uncrop). */ +const expand = z.strictObject({ + direction: z + .enum(["all", "horizontal", "vertical", "left", "right", "up", "down"]) + .default("all"), +}); +/** Sky / background replacement. */ +const sky = z.strictObject({ + style: z.string().min(1).max(200), +}); + +export const OP_SCHEMAS = { + adjust, + crop, + filter, + editSemantic, + eraser, + cutout, + upscale, + restore, + deviceRender, + retouch, + muscle, + hairstyle, + beard, + relight, + expand, + sky, +} as const; + +export type StudioOpName = keyof typeof OP_SCHEMAS; + +export type StudioOpParams = { + [K in StudioOpName]: z.infer<(typeof OP_SCHEMAS)[K]>; +}; + +/** A validated op record as stored in the op chain. */ +export type StudioOp = { + [K in StudioOpName]: { op: K; params: StudioOpParams[K]; opSpecVersion: number }; +}[StudioOpName]; + +export interface OpMeta { + /** deterministic = pod CPU / on-device; generative = cloud model (Gemini/Vertex). */ + kind: "deterministic" | "generative"; + /** Engine composites region-only (mask-bounded paste-back) when a mask is available. */ + localized: boolean; + /** Identity-drift risk; drives per-op routing + the identity gate (plan section 7). */ + identityRisk: "none" | "low" | "high"; +} + +export const OP_META: Record = { + adjust: { kind: "deterministic", localized: false, identityRisk: "none" }, + crop: { kind: "deterministic", localized: false, identityRisk: "none" }, + filter: { kind: "deterministic", localized: false, identityRisk: "none" }, + editSemantic: { kind: "generative", localized: true, identityRisk: "high" }, + eraser: { kind: "generative", localized: true, identityRisk: "low" }, + // Background removal in v1 is a cloud matte (no local provider supports it), so + // it is generative and must be consent-gated. (On-device Vision cutout is Ph2.) + cutout: { kind: "generative", localized: false, identityRisk: "none" }, + upscale: { kind: "generative", localized: false, identityRisk: "low" }, + restore: { kind: "generative", localized: false, identityRisk: "high" }, + // The user previewed the exact pixels on-device (WYSIWYG), so it is free, + // never consent-gated, and not identity-gated (no cloud model to second-guess). + deviceRender: { kind: "deterministic", localized: false, identityRisk: "none" }, + // Deterministic on the sidecar; the kind here is informational — consent keys + // off the resolved provider (sidecar = free, Gemini fallback = consent-gated). + // identityRisk low so the gate runs against the original when an embedder exists. + retouch: { kind: "deterministic", localized: false, identityRisk: "low" }, + // Phase 3 generative transforms. identityRisk "none": these INTENTIONALLY change + // appearance (beard, hairstyle, muscle), so the preservation gate would + // false-positive and block the very edit the user asked for. + muscle: { kind: "generative", localized: false, identityRisk: "none" }, + hairstyle: { kind: "generative", localized: false, identityRisk: "none" }, + beard: { kind: "generative", localized: false, identityRisk: "none" }, + relight: { kind: "generative", localized: false, identityRisk: "none" }, + expand: { kind: "generative", localized: false, identityRisk: "none" }, + sky: { kind: "generative", localized: false, identityRisk: "none" }, +}; + +export function isStudioOpName(op: string): op is StudioOpName { + return Object.hasOwn(OP_SCHEMAS, op); +} + +export class UnknownOpError extends Error { + constructor(public readonly op: string) { + super(`Unknown studio op: ${op}`); + this.name = "UnknownOpError"; + } +} + +/** + * Validate + normalize an op record before it is appended to the chain. Throws + * UnknownOpError for an unknown op name, or a ZodError for invalid params. + */ +export function validateOp(input: { op: string; params?: unknown }): StudioOp { + if (!isStudioOpName(input.op)) throw new UnknownOpError(input.op); + const params = OP_SCHEMAS[input.op].parse(input.params ?? {}); + return { op: input.op, params, opSpecVersion: OP_SPEC_VERSION } as StudioOp; +} diff --git a/src/studio/providers/gemini-image.test.ts b/src/studio/providers/gemini-image.test.ts new file mode 100644 index 00000000..313b860f --- /dev/null +++ b/src/studio/providers/gemini-image.test.ts @@ -0,0 +1,185 @@ +import sharp from "sharp"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { validateOp } from "../ops.ts"; + +// Mock the SDK so the real client can be exercised without creds or a network. +const { generateContent } = vi.hoisted(() => ({ generateContent: vi.fn() })); +vi.mock("@google/genai", async (importOriginal) => { + const actual = await importOriginal(); + // A regular function (not an arrow) so `new GoogleGenAI(...)` is constructable; + // the returned object becomes the instance. + return { + ...actual, + GoogleGenAI: vi.fn(function () { + return { models: { generateContent } }; + }), + }; +}); + +import { + createGoogleGenAIImageClient, + type GenAIImageClient, + GeminiImageProvider, +} from "./gemini-image.ts"; + +async function solid( + w: number, + h: number, + color: { r: number; g: number; b: number }, +): Promise { + return new Uint8Array( + await sharp({ create: { width: w, height: h, channels: 3, background: color } }) + .png() + .toBuffer(), + ); +} + +function fakeClient(result: Uint8Array, mimeType = "image/png"): GenAIImageClient { + return { + model: "fake-model", + editImage: vi.fn(async () => ({ + base64: Buffer.from(result).toString("base64"), + mimeType, + })), + }; +} + +describe("GeminiImageProvider", () => { + it("supports generative ops only", () => { + const p = new GeminiImageProvider(fakeClient(new Uint8Array([1]))); + expect(p.supports("editSemantic")).toBe(true); + expect(p.supports("eraser")).toBe(true); + expect(p.supports("upscale")).toBe(true); + expect(p.supports("adjust")).toBe(false); + expect(p.supports("crop")).toBe(false); + }); + + it("editSemantic sends the instruction as the prompt and returns model bytes + cost", async () => { + const modelOut = await solid(10, 10, { r: 0, g: 255, b: 0 }); + const client = fakeClient(modelOut, "image/png"); + const provider = new GeminiImageProvider(client, { name: "gemini", estimateCostUsd: 0.039 }); + const op = validateOp({ op: "editSemantic", params: { instruction: "warm it up" } }); + const out = await provider.execute(op, { + bytes: await solid(10, 10, { r: 255, g: 0, b: 0 }), + mime: "image/jpeg", + params: op.params, + }); + // The instruction is sent, with the universal quality guard appended to every prompt. + const sent = vi.mocked(client.editImage).mock.calls[0][0].prompt; + expect(sent).toContain("warm it up"); + expect(sent).toMatch(/sharp|detail|do not soften|don't soften/i); + expect(out.provider).toBe("gemini"); + expect(out.costUsd).toBe(0.039); + expect(out.mime).toBe("image/png"); // no mask -> raw model output + }); + + it("eraser with a mask composites region-only and returns a jpeg", async () => { + const original = await solid(20, 20, { r: 255, g: 0, b: 0 }); + const modelOut = await solid(20, 20, { r: 0, g: 0, b: 255 }); + const mask = await solid(20, 20, { r: 255, g: 255, b: 255 }); + const provider = new GeminiImageProvider(fakeClient(modelOut)); + const op = validateOp({ op: "eraser", params: { maskKey: "m1" } }); + const out = await provider.execute(op, { + bytes: original, + mime: "image/jpeg", + params: op.params, + maskBytes: mask, + }); + expect(out.mime).toBe("image/jpeg"); // composited path + const meta = await sharp(Buffer.from(out.bytes)).metadata(); + expect(meta.format).toBe("jpeg"); + expect(meta.width).toBe(20); + }); +}); + +describe("createGoogleGenAIImageClient (real client over the mocked SDK)", () => { + const SAVED = [ + "GEMINI_API_KEY", + "GOOGLE_API_KEY", + "NOMOS_STUDIO_PROVIDER", + "GOOGLE_CLOUD_PROJECT", + ]; + const prev: Record = {}; + + beforeEach(() => { + generateContent.mockReset(); + for (const k of SAVED) { + prev[k] = process.env[k]; + delete process.env[k]; + } + }); + afterEach(() => { + for (const k of SAVED) { + if (prev[k] === undefined) delete process.env[k]; + else process.env[k] = prev[k]; + } + }); + + function okImage() { + generateContent.mockResolvedValue({ + candidates: [ + { content: { parts: [{ inlineData: { data: "QUJD", mimeType: "image/png" } }] } }, + ], + }); + } + function sentCategories(): string[] { + const arg = generateContent.mock.calls[0][0] as { + config?: { safetySettings?: { category: string; threshold: string }[] }; + }; + return (arg.config?.safetySettings ?? []).map((s) => s.category); + } + function sentThresholds(): string[] { + const arg = generateContent.mock.calls[0][0] as { + config?: { safetySettings?: { category: string; threshold: string }[] }; + }; + return (arg.config?.safetySettings ?? []).map((s) => s.threshold); + } + + it("Vertex surface: relaxes text AND image harm categories", async () => { + process.env.GOOGLE_CLOUD_PROJECT = "test-project"; + process.env.NOMOS_STUDIO_PROVIDER = "vertex"; + okImage(); + const out = await createGoogleGenAIImageClient({ model: "m" }).editImage({ + imageBase64: "x", + mimeType: "image/jpeg", + prompt: "warm it", + }); + + expect(out).toEqual({ base64: "QUJD", mimeType: "image/png" }); + const cats = sentCategories(); + // The IMAGE_* categories govern the IMAGE_SAFETY finish reason — Vertex only. + expect(cats).toContain("HARM_CATEGORY_IMAGE_SEXUALLY_EXPLICIT"); + expect(cats).toContain("HARM_CATEGORY_SEXUALLY_EXPLICIT"); + expect(cats).toHaveLength(8); + expect(sentThresholds().every((t) => t === "BLOCK_NONE")).toBe(true); + }); + + it("Gemini API surface: text categories only (image categories 400 there)", async () => { + process.env.GEMINI_API_KEY = "test-key"; // forces the API-key surface + okImage(); + await createGoogleGenAIImageClient({ model: "m" }).editImage({ + imageBase64: "x", + mimeType: "image/jpeg", + prompt: "warm it", + }); + + const cats = sentCategories(); + expect(cats).toContain("HARM_CATEGORY_SEXUALLY_EXPLICIT"); + expect(cats.some((c) => c.startsWith("HARM_CATEGORY_IMAGE_"))).toBe(false); + expect(cats).toHaveLength(4); + }); + + it("surfaces a safety finish reason as a human-readable refusal", async () => { + process.env.GEMINI_API_KEY = "test-key"; + generateContent.mockResolvedValue({ + candidates: [{ finishReason: "IMAGE_SAFETY", content: { parts: [] } }], + }); + await expect( + createGoogleGenAIImageClient({ model: "m" }).editImage({ + imageBase64: "x", + mimeType: "image/jpeg", + prompt: "p", + }), + ).rejects.toThrow(/content-safety filter/i); + }); +}); diff --git a/src/studio/providers/gemini-image.ts b/src/studio/providers/gemini-image.ts new file mode 100644 index 00000000..e3e91811 --- /dev/null +++ b/src/studio/providers/gemini-image.ts @@ -0,0 +1,273 @@ +/** + * Google generative image provider. Workhorse for the cloud ops (instruction + * edit, eraser, cutout, upscale, restore). One SDK, two surfaces: the Gemini API + * in dev, Vertex AI in prod (ADC / workload identity). GCP-only, no AWS. + * + * The model call goes through an injectable `GenAIImageClient`, so the provider + * is unit-testable without credentials or a network. `createGoogleGenAIImageClient` + * wraps `@google/genai` for the real path. Localized ops composite region-only + * (mask-bounded paste-back) so untouched pixels never drift. A safety refusal is + * surfaced as a typed ProviderRefusedError. See the design doc sections 2 + 6. + */ + +import { GoogleGenAI, HarmBlockThreshold, HarmCategory, type SafetySetting } from "@google/genai"; +import type { ProviderInput, ProviderOutput, StudioProvider } from "../engine.ts"; +import { OP_META, type StudioOp, type StudioOpName } from "../ops.ts"; +import { compositeRegion, cropRegion, regionBox } from "./local-sharp.ts"; + +const GENERATIVE_OPS: readonly StudioOpName[] = [ + "editSemantic", + "eraser", + "cutout", + "upscale", + "restore", + // Cloud fallback for retouch until the deterministic sidecar passes parity. + "retouch", + // Phase 3 generative depth bets. + "muscle", + "hairstyle", + "beard", + "relight", + "expand", + "sky", +]; + +export interface GenAIImageRequest { + imageBase64: string; + mimeType: string; + prompt: string; +} + +export interface GenAIImageResult { + base64: string; + mimeType: string; +} + +export interface GenAIImageClient { + readonly model: string; + editImage(req: GenAIImageRequest): Promise; +} + +/** The model refused (e.g. a safety filter on a face edit). The fallback lane / UI reacts. */ +export class ProviderRefusedError extends Error { + constructor( + public readonly op: string, + public readonly reason: string, + ) { + super(`Provider refused op ${op}: ${reason}`); + this.name = "ProviderRefusedError"; + } +} + +function promptFor(op: StudioOp): string { + switch (op.op) { + case "editSemantic": + return op.params.instruction; + case "eraser": + return "Remove the masked object and naturally fill the background behind it. Keep everything else unchanged."; + case "cutout": + return "Cleanly cut out the main subject and remove the background."; + case "upscale": + return "Increase resolution and sharpness without changing the content or the person's identity."; + case "restore": + return "Restore this old or damaged photo: repair scratches, denoise, recover natural color. Do not change identity."; + case "retouch": + return "Subtly retouch this portrait: even out skin tone, soften blemishes and shine while keeping pores and natural texture. Do not change the person's identity, features, or proportions."; + case "muscle": + return `Add natural, photorealistic muscle definition to the ${op.params.area} (athletic, believable, not exaggerated). Keep the person's face, identity, and pose unchanged.`; + case "hairstyle": + return `Restyle the person's hair: ${op.params.style}. Keep the face, skin, and identity unchanged.`; + case "beard": + return op.params.action === "remove" + ? "Cleanly remove the facial hair, leaving natural, realistic skin. Keep the person's identity unchanged." + : op.params.action === "trim" + ? `Neatly trim and tidy the facial hair${op.params.style ? `: ${op.params.style}` : ""}. Keep the person's identity unchanged.` + : `Add a realistic, well-groomed beard${op.params.style ? `: ${op.params.style}` : ""}. Keep the person's face and identity unchanged.`; + case "relight": + return `Relight this photo${op.params.direction ? ` from the ${op.params.direction}` : ""}${op.params.mood ? `, ${op.params.mood} mood` : ""} with natural, believable shadows and highlights. Keep the content and composition unchanged.`; + case "expand": + return `Outpaint and naturally extend the scene (${op.params.direction}), seamlessly continuing the existing content, lighting, perspective, and style.`; + case "sky": + return `Replace the sky with ${op.params.style}, matching the scene's lighting, white balance, and reflections so it looks natural.`; + default: + return "Edit this image."; + } +} + +/** + * Appended to EVERY generative prompt (typed edits, suggestion chips, auto-enhance, + * region edits, the agent path — all of them) so a re-render keeps or raises image + * quality instead of softening it, which the model tends to do by default. + */ +const QUALITY_GUARD = + "Keep the result sharp, clear, and high-detail: maintain or increase sharpness, fine detail, and resolution, and do NOT soften, blur, smear, over-denoise, or reduce quality anywhere — any skin smoothing must still retain pores and natural texture. Preserve the person's identity and a realistic, natural look."; + +export interface GeminiImageProviderOptions { + /** Display name + recorded provider ('gemini' dev, 'vertex' prod). */ + name?: string; + /** Rough per-op platform cost recorded for metered billing (internal economics). */ + estimateCostUsd?: number; +} + +export class GeminiImageProvider implements StudioProvider { + readonly name: string; + readonly kind = "generative" as const; + private readonly cost: number; + + constructor( + private readonly client: GenAIImageClient, + opts: GeminiImageProviderOptions = {}, + ) { + this.name = opts.name ?? "gemini"; + this.cost = opts.estimateCostUsd ?? 0.039; + } + + supports(op: StudioOpName): boolean { + return GENERATIVE_OPS.includes(op); + } + + async execute(op: StudioOp, input: ProviderInput): Promise { + const styleSuffix = input.styleHint + ? `\n\nThe user usually prefers this editing style: ${input.styleHint} Lean toward that taste where it fits, without overriding the explicit request above.` + : ""; + const prompt = `${promptFor(op)}\n\n${QUALITY_GUARD}${styleSuffix}`; + + // Region edit: when the user brushed a mask on a localized op, CROP to that area, + // edit just the crop (so the model focuses on what's marked — "remove this" works), + // and composite it back at the original dimensions. No whole-image replacement, no + // reframing. Falls through to a whole-image edit if the mask is empty. + if (input.maskBytes && OP_META[op.op].localized) { + const box = await regionBox(input.bytes, input.maskBytes); + if (box) { + const crop = await cropRegion(input.bytes, box); + const edited = await this.client.editImage({ + imageBase64: Buffer.from(crop).toString("base64"), + mimeType: "image/jpeg", + prompt, + }); + const editedBytes = new Uint8Array(Buffer.from(edited.base64, "base64")); + const out = await compositeRegion(input.bytes, editedBytes, input.maskBytes, box); + return { bytes: out, mime: "image/jpeg", costUsd: this.cost, provider: this.name }; + } + } + + const result = await this.client.editImage({ + imageBase64: Buffer.from(input.bytes).toString("base64"), + mimeType: input.mime, + prompt, + }); + const modelBytes = new Uint8Array(Buffer.from(result.base64, "base64")); + return { bytes: modelBytes, mime: result.mimeType, costUsd: this.cost, provider: this.name }; + } +} + +/** + * The user is editing THEIR OWN photo with explicit Cloud-AI consent, so the + * configurable safety filters are relaxed to BLOCK_NONE — a portrait edit was + * being refused outright (`IMAGE_SAFETY`) with the filters at their default. + * Non-configurable guards (e.g. minors, CSAM, public figures) are NOT affected. + * + * The set is SURFACE-DEPENDENT: the `HARM_CATEGORY_IMAGE_*` categories that drive + * the IMAGE_SAFETY finish reason 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; Vertex adds + * the 4 image ones. (On the API-key surface IMAGE_SAFETY is largely + * non-configurable; Vertex is where this fully takes effect.) + */ +const TEXT_HARM_CATEGORIES = [ + HarmCategory.HARM_CATEGORY_HARASSMENT, + HarmCategory.HARM_CATEGORY_HATE_SPEECH, + HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT, + HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT, +]; +const IMAGE_HARM_CATEGORIES = [ + HarmCategory.HARM_CATEGORY_IMAGE_HATE, + HarmCategory.HARM_CATEGORY_IMAGE_HARASSMENT, + HarmCategory.HARM_CATEGORY_IMAGE_DANGEROUS_CONTENT, + HarmCategory.HARM_CATEGORY_IMAGE_SEXUALLY_EXPLICIT, +]; + +export function relaxedSafetyFor(surface: "gemini" | "vertex"): SafetySetting[] { + const categories = + surface === "vertex" + ? [...TEXT_HARM_CATEGORIES, ...IMAGE_HARM_CATEGORIES] + : TEXT_HARM_CATEGORIES; + return categories.map((category) => ({ category, threshold: HarmBlockThreshold.BLOCK_NONE })); +} + +/** + * Turn a raw model finish reason into something the editor can show a person. + * A bare "IMAGE_SAFETY" is opaque; this names what happened without pretending + * the edit succeeded. + */ +export function humanizeRefusal(finishReason: string): string { + if (/SAFETY|PROHIBITED|BLOCK|RECITATION/i.test(finishReason)) { + return "the photo or instruction was blocked by the provider's content-safety filter"; + } + return finishReason; +} + +/** + * Real client over `@google/genai`. Dev uses the Gemini API key; prod uses Vertex + * (ADC / workload identity). Selected by NOMOS_STUDIO_PROVIDER, else inferred from + * GOOGLE_CLOUD_PROJECT. Never hard-wires the model. + */ +/** + * Construct the shared GoogleGenAI client + its normalized surface. Detection mirrors + * embeddings.ts: an API key (GOOGLE_API_KEY / GEMINI_API_KEY) -> Gemini API; otherwise + * GOOGLE_CLOUD_PROJECT -> Vertex (ADC). Overridable via NOMOS_STUDIO_PROVIDER. Reused by + * the image client AND the vision-suggestion path so both pick the same surface/creds. + */ +export function createGenAI(): { ai: GoogleGenAI; surface: "gemini" | "vertex" } { + const apiKey = process.env.GEMINI_API_KEY ?? process.env.GOOGLE_API_KEY; + const surface = + (process.env.NOMOS_STUDIO_PROVIDER ?? (apiKey ? "gemini" : "vertex")) === "vertex" + ? "vertex" + : "gemini"; + const ai = + surface === "vertex" + ? new GoogleGenAI({ + vertexai: true, + project: process.env.GOOGLE_CLOUD_PROJECT, + location: process.env.CLOUD_ML_REGION ?? "us-central1", + }) + : new GoogleGenAI({ apiKey }); + return { ai, surface }; +} + +export function createGoogleGenAIImageClient(opts?: { model?: string }): GenAIImageClient { + const model = opts?.model ?? process.env.NOMOS_STUDIO_GEMINI_MODEL ?? "gemini-2.5-flash-image"; + const { ai, surface } = createGenAI(); + // The image harm categories only exist on Vertex; sending them to the Gemini + // API surface 400s the request (see relaxedSafetyFor). + const safetySettings = relaxedSafetyFor(surface); + + return { + model, + async editImage(req: GenAIImageRequest): Promise { + const resp = await ai.models.generateContent({ + model, + contents: [ + { + role: "user", + parts: [ + { inlineData: { mimeType: req.mimeType, data: req.imageBase64 } }, + { text: req.prompt }, + ], + }, + ], + config: { safetySettings }, + }); + const candidate = resp.candidates?.[0]; + const parts = candidate?.content?.parts ?? []; + for (const part of parts) { + const data = part.inlineData?.data; + if (data) { + return { base64: data, mimeType: part.inlineData?.mimeType ?? "image/png" }; + } + } + const reason = candidate?.finishReason ?? "no image returned"; + throw new ProviderRefusedError("generate", humanizeRefusal(String(reason))); + }, + }; +} diff --git a/src/studio/providers/local-sharp.test.ts b/src/studio/providers/local-sharp.test.ts new file mode 100644 index 00000000..a9b24491 --- /dev/null +++ b/src/studio/providers/local-sharp.test.ts @@ -0,0 +1,195 @@ +import sharp from "sharp"; +import { describe, expect, it } from "vitest"; +import { validateOp } from "../ops.ts"; +import { + compositeMasked, + compositeRegion, + LocalSharpProvider, + makePreview, + maskBoundingBox, +} from "./local-sharp.ts"; + +async function solid( + w: number, + h: number, + color: { r: number; g: number; b: number }, +): Promise { + const buf = await sharp({ create: { width: w, height: h, channels: 3, background: color } }) + .jpeg() + .toBuffer(); + return new Uint8Array(buf); +} + +/** A black mask with a single white rectangle (the "brushed" region). */ +async function maskWithRect( + w: number, + h: number, + rect: { left: number; top: number; width: number; height: number }, +): Promise { + const white = await sharp({ + create: { + width: rect.width, + height: rect.height, + channels: 3, + background: { r: 255, g: 255, b: 255 }, + }, + }) + .png() + .toBuffer(); + const buf = await sharp({ + create: { width: w, height: h, channels: 3, background: { r: 0, g: 0, b: 0 } }, + }) + .composite([{ input: white, left: rect.left, top: rect.top }]) + .png() + .toBuffer(); + return new Uint8Array(buf); +} + +describe("LocalSharpProvider", () => { + const provider = new LocalSharpProvider(); + + it("supports only deterministic ops", () => { + expect(provider.supports("adjust")).toBe(true); + expect(provider.supports("crop")).toBe(true); + expect(provider.supports("deviceRender")).toBe(true); + expect(provider.supports("editSemantic")).toBe(false); + expect(provider.supports("upscale")).toBe(false); + }); + + it("deviceRender re-encodes the uploaded render to a clean jpeg, clamped to 4096px", async () => { + const img = await solid(5000, 2000, { r: 30, g: 60, b: 90 }); + const op = validateOp({ op: "deviceRender", params: { tool: "makeup", detail: "lips" } }); + const out = await provider.execute(op, { bytes: img, mime: "image/jpeg", params: op.params }); + expect(out.provider).toBe("local-sharp"); + expect(out.costUsd).toBe(0); + const meta = await sharp(Buffer.from(out.bytes)).metadata(); + expect(meta.format).toBe("jpeg"); + expect(Math.max(meta.width ?? 0, meta.height ?? 0)).toBeLessThanOrEqual(4096); + }); + + it("deviceRender rejects a malformed upload", async () => { + const op = validateOp({ op: "deviceRender", params: { tool: "makeup" } }); + await expect( + provider.execute(op, { + bytes: new Uint8Array([1, 2, 3]), + mime: "image/jpeg", + params: op.params, + }), + ).rejects.toThrow(); + }); + + it("applies a tonal adjust and returns a same-size jpeg at zero cost", async () => { + const img = await solid(64, 48, { r: 100, g: 110, b: 120 }); + const op = validateOp({ + op: "adjust", + params: { exposure: 0.3, contrast: 0.2, saturation: 0.1 }, + }); + const out = await provider.execute(op, { bytes: img, mime: "image/jpeg", params: op.params }); + expect(out.provider).toBe("local-sharp"); + expect(out.costUsd).toBe(0); + const meta = await sharp(Buffer.from(out.bytes)).metadata(); + expect(meta.format).toBe("jpeg"); + expect(meta.width).toBe(64); + expect(meta.height).toBe(48); + }); + + it("crops to the normalized rectangle", async () => { + const img = await solid(100, 100, { r: 10, g: 20, b: 30 }); + const op = validateOp({ op: "crop", params: { x: 0.25, y: 0.25, width: 0.5, height: 0.5 } }); + const out = await provider.execute(op, { bytes: img, mime: "image/jpeg", params: op.params }); + const meta = await sharp(Buffer.from(out.bytes)).metadata(); + expect(meta.width).toBe(50); + expect(meta.height).toBe(50); + }); +}); + +describe("makePreview", () => { + it("downscales to at most 256px on the long edge", async () => { + const img = await solid(1000, 500, { r: 50, g: 50, b: 50 }); + const preview = await makePreview(img, "image/jpeg"); + expect(preview).not.toBeNull(); + const meta = await sharp(Buffer.from(preview as Uint8Array)).metadata(); + expect(Math.max(meta.width ?? 0, meta.height ?? 0)).toBeLessThanOrEqual(256); + }); + + it("returns null for non-image bytes", async () => { + expect(await makePreview(new Uint8Array([1, 2, 3]), "image/jpeg")).toBeNull(); + }); +}); + +describe("compositeMasked (mask-bounded paste-back)", () => { + it("keeps the original outside the mask, the edit inside", async () => { + const original = await solid(20, 20, { r: 255, g: 0, b: 0 }); // red + const edited = await solid(20, 20, { r: 0, g: 0, b: 255 }); // blue + // mask: left half white (apply edit), right half black (keep original) + const whiteLeft = await sharp({ + create: { width: 10, height: 20, channels: 3, background: { r: 255, g: 255, b: 255 } }, + }) + .png() + .toBuffer(); + const mask = new Uint8Array( + await sharp({ + create: { width: 20, height: 20, channels: 3, background: { r: 0, g: 0, b: 0 } }, + }) + .composite([{ input: whiteLeft, left: 0, top: 0 }]) + .png() + .toBuffer(), + ); + + const out = await compositeMasked(original, edited, mask); + const { data, info } = await sharp(Buffer.from(out)) + .raw() + .toBuffer({ resolveWithObject: true }); + const px = (x: number, y: number): [number, number, number] => { + const i = (y * info.width + x) * info.channels; + return [data[i], data[i + 1], data[i + 2]]; + }; + const left = px(3, 10); + const right = px(17, 10); + expect(left[2]).toBeGreaterThan(left[0]); // blue dominant on the edited (left) side + expect(right[0]).toBeGreaterThan(right[2]); // red dominant on the kept (right) side + }); +}); + +describe("region edit helpers (crop-inpaint)", () => { + it("maskBoundingBox wraps the brushed region (padded) and is null when empty", async () => { + const mask = await maskWithRect(100, 100, { left: 40, top: 30, width: 20, height: 25 }); + const box = await maskBoundingBox(mask, 100, 100, 0.1); + expect(box).not.toBeNull(); + expect(box!.left).toBeLessThanOrEqual(40); // padded outward + expect(box!.top).toBeLessThanOrEqual(30); + expect(box!.left + box!.width).toBeGreaterThanOrEqual(60); + expect(box!.top + box!.height).toBeGreaterThanOrEqual(55); + expect(box!.left).toBeGreaterThanOrEqual(0); // clamped to the image + expect(box!.top + box!.height).toBeLessThanOrEqual(100); + + const black = await solid(50, 50, { r: 0, g: 0, b: 0 }); // nothing brushed + expect(await maskBoundingBox(black, 50, 50)).toBeNull(); + }); + + it("compositeRegion changes only the masked area, keeping the original dimensions", async () => { + const original = await solid(100, 100, { r: 255, g: 0, b: 0 }); // red + const box = { left: 40, top: 30, width: 20, height: 25 }; + const editedCrop = await solid(box.width, box.height, { r: 0, g: 0, b: 255 }); // blue + const mask = await maskWithRect(100, 100, box); + + const out = await compositeRegion(original, editedCrop, mask, box); + const meta = await sharp(Buffer.from(out)).metadata(); + expect(meta.width).toBe(100); + expect(meta.height).toBe(100); + + const { data } = await sharp(Buffer.from(out)) + .removeAlpha() + .raw() + .toBuffer({ resolveWithObject: true }); + const px = (x: number, y: number) => { + const i = (y * 100 + x) * 3; + return { r: data[i], g: data[i + 1], b: data[i + 2] }; + }; + const inside = px(50, 42); // within the box + mask + const outside = px(5, 5); // far away + expect(inside.b).toBeGreaterThan(inside.r); // turned blue-ish + expect(outside.r).toBeGreaterThan(200); // still red + expect(outside.b).toBeLessThan(60); + }); +}); diff --git a/src/studio/providers/local-sharp.ts b/src/studio/providers/local-sharp.ts new file mode 100644 index 00000000..0ce46719 --- /dev/null +++ b/src/studio/providers/local-sharp.ts @@ -0,0 +1,241 @@ +/** + * Pod-local deterministic provider (sharp / libvips). Runs the free, + * unmetered ops (tonal adjust, crop), generates the ~256px history preview, and + * provides the mask-bounded paste-back compositor that keeps untouched pixels + * bit-exact for localized generative ops. No network, no cost. + * + * Fine tonal control (highlights/shadows/clarity, true white balance) is Phase 2 + * on-device (Core Image); this is the server baseline for the agent path. + */ + +import sharp from "sharp"; +import type { ProviderInput, ProviderOutput, StudioProvider } from "../engine.ts"; +import type { StudioOp, StudioOpName } from "../ops.ts"; + +const DETERMINISTIC_OPS: readonly StudioOpName[] = ["adjust", "crop", "deviceRender"]; + +/** Hard ceiling on a committed device render's long edge (defense-in-depth). */ +const MAX_DEVICE_EDGE = 4096; + +const clampPos = (n: number): number => Math.max(0.01, n); + +type AdjustParams = { + exposure?: number; + contrast?: number; + saturation?: number; + temperature?: number; +}; + +function applyAdjust(img: sharp.Sharp, params: AdjustParams): sharp.Sharp { + let out = img; + const brightness = 1 + (params.exposure ?? 0) * 0.5; + const saturation = clampPos(1 + (params.saturation ?? 0)); + const hue = Math.round((params.temperature ?? 0) * 12); // warm/cool approximation + if ( + params.exposure !== undefined || + params.saturation !== undefined || + params.temperature !== undefined + ) { + out = out.modulate({ brightness: clampPos(brightness), saturation, hue }); + } + if (params.contrast !== undefined) { + const a = 1 + params.contrast * 0.5; // slope around mid-grey + const b = 128 * (1 - a); + out = out.linear(a, b); + } + return out; +} + +type CropParams = { x: number; y: number; width: number; height: number; rotate?: number }; + +async function applyCrop(img: sharp.Sharp, params: CropParams): Promise { + const meta = await img.metadata(); + const W = meta.width ?? 0; + const H = meta.height ?? 0; + const left = Math.min(W - 1, Math.max(0, Math.round(params.x * W))); + const top = Math.min(H - 1, Math.max(0, Math.round(params.y * H))); + const width = Math.max(1, Math.min(W - left, Math.round(params.width * W))); + const height = Math.max(1, Math.min(H - top, Math.round(params.height * H))); + let out = img.extract({ left, top, width, height }); + if (params.rotate) out = out.rotate(params.rotate); + return out; +} + +export class LocalSharpProvider implements StudioProvider { + readonly name = "local-sharp"; + readonly kind = "deterministic" as const; + + supports(op: StudioOpName): boolean { + return DETERMINISTIC_OPS.includes(op); + } + + async execute(op: StudioOp, input: ProviderInput): Promise { + let img = sharp(Buffer.from(input.bytes)); + if (op.op === "adjust") { + img = applyAdjust(img, op.params); + } else if (op.op === "crop") { + img = await applyCrop(img, op.params); + } else if (op.op === "deviceRender") { + // The bytes ARE the result (rendered on-device). Re-encode through sharp to + // strip EXIF/GPS, reject a malformed upload, and clamp the long edge. + img = img.rotate().resize(MAX_DEVICE_EDGE, MAX_DEVICE_EDGE, { + fit: "inside", + withoutEnlargement: true, + }); + } else { + throw new Error(`local-sharp does not support op: ${op.op}`); + } + const bytes = await img.jpeg({ quality: 92 }).toBuffer(); + return { bytes: new Uint8Array(bytes), mime: "image/jpeg", costUsd: 0, provider: this.name }; + } +} + +/** ~256px JPEG preview for the history strip. Returns null on a non-image input. */ +export async function makePreview(bytes: Uint8Array, _mime: string): Promise { + try { + const out = await sharp(Buffer.from(bytes)) + .resize(256, 256, { fit: "inside", withoutEnlargement: true }) + .jpeg({ quality: 80 }) + .toBuffer(); + return new Uint8Array(out); + } catch { + return null; + } +} + +/** + * Mask-bounded paste-back: composite the edited image over the original using a + * single-channel mask as alpha, so only the masked region changes and every + * untouched pixel stays exactly the original. The mask is resized to match. + */ +export async function compositeMasked( + original: Uint8Array, + edited: Uint8Array, + mask: Uint8Array, +): Promise { + const base = sharp(Buffer.from(original)); + const meta = await base.metadata(); + const width = meta.width ?? 0; + const height = meta.height ?? 0; + + const editedRgb = await sharp(Buffer.from(edited)) + .resize(width, height, { fit: "fill" }) + .removeAlpha() + .toBuffer(); + const alpha = await sharp(Buffer.from(mask)) + .resize(width, height, { fit: "fill" }) + .greyscale() + .toColourspace("b-w") + .toBuffer(); + + const editedWithAlpha = await sharp(editedRgb).joinChannel(alpha).png().toBuffer(); + const out = await base + .composite([{ input: editedWithAlpha, blend: "over" }]) + .jpeg({ quality: 92 }) + .toBuffer(); + return new Uint8Array(out); +} + +export interface MaskBox { + left: number; + top: number; + width: number; + height: number; +} + +/** + * The bounding box of the brushed (white) region of a mask, in the ORIGINAL's pixel + * space, padded by `padFrac` of the box's larger side and clamped to the image. Returns + * null when the mask is empty (caller falls back to a whole-image edit). This is what + * lets a region edit CROP to the marked area so the model focuses there. + */ +export async function maskBoundingBox( + mask: Uint8Array, + width: number, + height: number, + padFrac = 0.08, +): Promise { + if (width <= 0 || height <= 0) return null; + const { data } = await sharp(Buffer.from(mask)) + .resize(width, height, { fit: "fill" }) + .greyscale() + .raw() + .toBuffer({ resolveWithObject: true }); + let minX = width; + let minY = height; + let maxX = -1; + let maxY = -1; + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + if (data[y * width + x] > 127) { + if (x < minX) minX = x; + if (x > maxX) maxX = x; + if (y < minY) minY = y; + if (y > maxY) maxY = y; + } + } + } + if (maxX < 0) return null; // no brushed pixels + const pad = Math.round(Math.max(maxX - minX, maxY - minY) * padFrac) + 1; + const left = Math.max(0, minX - pad); + const top = Math.max(0, minY - pad); + const right = Math.min(width, maxX + pad + 1); + const bottom = Math.min(height, maxY + pad + 1); + return { left, top, width: Math.max(1, right - left), height: Math.max(1, bottom - top) }; +} + +/** The brushed-region box for an original + mask (reads the original's pixel dims). */ +export async function regionBox( + original: Uint8Array, + mask: Uint8Array, + padFrac = 0.08, +): Promise { + const meta = await sharp(Buffer.from(original)).metadata(); + return maskBoundingBox(mask, meta.width ?? 0, meta.height ?? 0, padFrac); +} + +/** Extract the masked region from the original (a JPEG crop the model edits in isolation). */ +export async function cropRegion(original: Uint8Array, box: MaskBox): Promise { + const out = await sharp(Buffer.from(original)) + .extract({ left: box.left, top: box.top, width: box.width, height: box.height }) + .jpeg({ quality: 95 }) + .toBuffer(); + return new Uint8Array(out); +} + +/** + * Paste an edited crop back into the original at `box`, blended by the brushed mask + * (cropped to the box + lightly feathered) so only the marked area changes and the rest + * stays bit-exact at the ORIGINAL dimensions — no reframing, no whole-image replacement. + */ +export async function compositeRegion( + original: Uint8Array, + editedCrop: Uint8Array, + mask: Uint8Array, + box: MaskBox, +): Promise { + const base = sharp(Buffer.from(original)); + const meta = await base.metadata(); + const width = meta.width ?? 0; + const height = meta.height ?? 0; + + const editedRgb = await sharp(Buffer.from(editedCrop)) + .resize(box.width, box.height, { fit: "fill" }) + .removeAlpha() + .toBuffer(); + const feather = Math.max(0.5, Math.min(box.width, box.height) * 0.02); + const alpha = await sharp(Buffer.from(mask)) + .resize(width, height, { fit: "fill" }) + .extract({ left: box.left, top: box.top, width: box.width, height: box.height }) + .greyscale() + .blur(feather) + .toColourspace("b-w") + .toBuffer(); + + const editedWithAlpha = await sharp(editedRgb).joinChannel(alpha).png().toBuffer(); + const out = await base + .composite([{ input: editedWithAlpha, left: box.left, top: box.top, blend: "over" }]) + .jpeg({ quality: 92 }) + .toBuffer(); + return new Uint8Array(out); +} diff --git a/src/studio/providers/mediapipe-sidecar.test.ts b/src/studio/providers/mediapipe-sidecar.test.ts new file mode 100644 index 00000000..2612a24a --- /dev/null +++ b/src/studio/providers/mediapipe-sidecar.test.ts @@ -0,0 +1,101 @@ +import sharp from "sharp"; +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import type { ProviderInput } from "../engine.ts"; +import { validateOp } from "../ops.ts"; +import { SidecarProvider } from "./mediapipe-sidecar.ts"; + +// Real JPEGs: the provider re-encodes the response through sharp at the trust +// boundary, so both the input and the mocked response must be valid images. +let inputJpeg: Buffer; +let responseJpeg: Buffer; +let input: ProviderInput; + +beforeAll(async () => { + inputJpeg = await sharp({ + create: { width: 16, height: 16, channels: 3, background: { r: 10, g: 20, b: 30 } }, + }) + .jpeg() + .toBuffer(); + responseJpeg = await sharp({ + create: { width: 16, height: 16, channels: 3, background: { r: 200, g: 100, b: 50 } }, + }) + .jpeg() + .toBuffer(); + input = { bytes: new Uint8Array(inputJpeg), mime: "image/jpeg", params: {} }; +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("SidecarProvider", () => { + it("is deterministic and supports retouch only", () => { + const p = new SidecarProvider("http://127.0.0.1:8799"); + expect(p.kind).toBe("deterministic"); + expect(p.supports("retouch")).toBe(true); + expect(p.supports("editSemantic")).toBe(false); + expect(p.supports("adjust")).toBe(false); + }); + + it("POSTs the op + base64 image and re-encodes the response to a clean jpeg", async () => { + const fetchMock = vi.fn(async (_url: string, _init?: RequestInit) => ({ + ok: true, + json: async () => ({ + image_b64: responseJpeg.toString("base64"), + mime: "image/jpeg", + cost_usd: 0, + }), + })); + vi.stubGlobal("fetch", fetchMock); + + const p = new SidecarProvider("http://127.0.0.1:8799"); + const op = validateOp({ op: "retouch", params: { strength: 0.8 } }); + const out = await p.execute(op, input); + + expect(fetchMock).toHaveBeenCalledOnce(); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe("http://127.0.0.1:8799/v1/edit"); + const body = JSON.parse(init?.body as string); + expect(body.op).toBe("retouch"); + expect(body.params).toEqual({ strength: 0.8 }); + expect(body.image_b64).toBe(Buffer.from(input.bytes).toString("base64")); + // Output is re-encoded through sharp (a valid jpeg), not the raw response bytes. + const meta = await sharp(Buffer.from(out.bytes)).metadata(); + expect(meta.format).toBe("jpeg"); + expect(out.costUsd).toBe(0); + expect(out.provider).toBe("mediapipe-sidecar"); + }); + + it("rejects a malformed (non-image) response at the trust boundary", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ + ok: true, + json: async () => ({ image_b64: Buffer.from([1, 2, 3, 4]).toString("base64") }), + })), + ); + const p = new SidecarProvider("http://127.0.0.1:8799"); + const op = validateOp({ op: "retouch", params: {} }); + await expect(p.execute(op, input)).rejects.toThrow(); + }); + + it("throws on a non-OK response", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ ok: false, status: 500 })), + ); + const p = new SidecarProvider("http://127.0.0.1:8799"); + const op = validateOp({ op: "retouch", params: {} }); + await expect(p.execute(op, input)).rejects.toThrow(/HTTP 500/); + }); + + it("throws on an empty image payload", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ ok: true, json: async () => ({ image_b64: "" }) })), + ); + const p = new SidecarProvider("http://127.0.0.1:8799"); + const op = validateOp({ op: "retouch", params: {} }); + await expect(p.execute(op, input)).rejects.toThrow(/empty response/); + }); +}); diff --git a/src/studio/providers/mediapipe-sidecar.ts b/src/studio/providers/mediapipe-sidecar.ts new file mode 100644 index 00000000..c13649ba --- /dev/null +++ b/src/studio/providers/mediapipe-sidecar.ts @@ -0,0 +1,79 @@ +/** + * Provider that runs the deterministic beauty ops on the Phase-3 Python sidecar + * (`nomos-studio-sidecar`, FastAPI + MediaPipe + OpenCV) over localhost HTTP. + * Registered BEFORE the Gemini provider so a reachable sidecar wins (free, + * deterministic); when absent the op falls through to the generative fallback. + * + * The launcher (`sidecar-launcher.ts`) owns the process + the base URL; this + * provider is only constructed once a URL is known. Pins the HTTP contract + * version and fails loudly on mismatch. + */ + +import sharp from "sharp"; +import type { ProviderInput, ProviderOutput, StudioProvider } from "../engine.ts"; +import type { StudioOp, StudioOpName } from "../ops.ts"; + +/** Ops the sidecar implements (v1). Must stay a subset of the op registry. */ +const SIDECAR_OPS: readonly StudioOpName[] = ["retouch"]; + +/** HTTP contract version the daemon pins; the sidecar reports its own in /healthz. */ +export const SIDECAR_CONTRACT_VERSION = "v1"; + +/** Reject an absurd response before decoding (~30MB decoded). */ +const MAX_RESPONSE_B64 = 40 * 1024 * 1024; +const MAX_EDGE = 4096; + +interface SidecarEditResponse { + image_b64: string; + mime?: string; + cost_usd?: number; + provider?: string; +} + +export class SidecarProvider implements StudioProvider { + readonly name = "mediapipe-sidecar"; + readonly kind = "deterministic" as const; + + constructor(private readonly baseUrl: string) {} + + supports(op: StudioOpName): boolean { + return SIDECAR_OPS.includes(op); + } + + async execute(op: StudioOp, input: ProviderInput): Promise { + const resp = await fetch(`${this.baseUrl}/v1/edit`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + op: op.op, + params: op.params, + image_b64: Buffer.from(input.bytes).toString("base64"), + mime: input.mime, + }), + }); + if (!resp.ok) { + throw new Error(`studio sidecar ${op.op} failed: HTTP ${resp.status}`); + } + const json = (await resp.json()) as SidecarEditResponse; + if (!json.image_b64) throw new Error(`studio sidecar ${op.op}: empty response`); + if (json.image_b64.length > MAX_RESPONSE_B64) { + throw new Error(`studio sidecar ${op.op}: response too large`); + } + // Trust boundary: Buffer.from(...,"base64") never throws on garbage, so + // re-encode through sharp — this validates it is a real image, strips any + // metadata, and clamps the size, mirroring the deviceRender path. + const decoded = Buffer.from(json.image_b64, "base64"); + if (decoded.length === 0) throw new Error(`studio sidecar ${op.op}: undecodable image`); + const safe = await sharp(decoded) + .rotate() + .resize(MAX_EDGE, MAX_EDGE, { fit: "inside", withoutEnlargement: true }) + .jpeg({ quality: 92 }) + .toBuffer(); + return { + bytes: new Uint8Array(safe), + mime: "image/jpeg", + costUsd: json.cost_usd ?? 0, + provider: this.name, + }; + } +} diff --git a/src/studio/sidecar-launcher.test.ts b/src/studio/sidecar-launcher.test.ts new file mode 100644 index 00000000..7e97aa6e --- /dev/null +++ b/src/studio/sidecar-launcher.test.ts @@ -0,0 +1,55 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + ensureStudioSidecar, + getStudioSidecarUrl, + setStudioSidecarUrl, +} from "./sidecar-launcher.ts"; + +const ENV = process.env.NOMOS_STUDIO_SIDECAR_URL; + +beforeEach(() => { + setStudioSidecarUrl(null); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + setStudioSidecarUrl(null); + if (ENV === undefined) delete process.env.NOMOS_STUDIO_SIDECAR_URL; + else process.env.NOMOS_STUDIO_SIDECAR_URL = ENV; +}); + +describe("ensureStudioSidecar (external URL mode)", () => { + it("uses a healthy external URL without spawning a process", async () => { + process.env.NOMOS_STUDIO_SIDECAR_URL = "http://127.0.0.1:9999"; + const fetchMock = vi.fn(async (_url: string, _init?: RequestInit) => ({ + ok: true, + json: async () => ({ status: "ok" }), + })); + vi.stubGlobal("fetch", fetchMock); + + const url = await ensureStudioSidecar(); + expect(url).toBe("http://127.0.0.1:9999"); + expect(getStudioSidecarUrl()).toBe("http://127.0.0.1:9999"); + expect(fetchMock.mock.calls[0]?.[0]).toBe("http://127.0.0.1:9999/healthz"); + }); + + it("returns null (cloud fallback) when the external URL is unhealthy, never spawning", async () => { + process.env.NOMOS_STUDIO_SIDECAR_URL = "http://127.0.0.1:9999"; + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ ok: false, status: 503 })), + ); + const url = await ensureStudioSidecar(); + expect(url).toBeNull(); + expect(getStudioSidecarUrl()).toBeNull(); + }); + + it("is idempotent: returns the cached URL without re-checking", async () => { + setStudioSidecarUrl("http://127.0.0.1:8799"); + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + const url = await ensureStudioSidecar(); + expect(url).toBe("http://127.0.0.1:8799"); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/studio/sidecar-launcher.ts b/src/studio/sidecar-launcher.ts new file mode 100644 index 00000000..02729a26 --- /dev/null +++ b/src/studio/sidecar-launcher.ts @@ -0,0 +1,167 @@ +/** + * Lifecycle for the Phase-3 Studio beauty-ops sidecar (`nomos-studio-sidecar`). + * Three launch modes, one HTTP contract: + * - NOMOS_STUDIO_SIDECAR_URL set -> use that already-running instance (no spawn). + * - else spawn `uv run --project nomos-studio-sidecar` from the sibling + * clone (default `../nomos-studio-sidecar`), like the imsg child process. + * - (prod) a pod sidecar container reachable on localhost via the URL form. + * + * Everything is best-effort: if the sidecar can't be reached/spawned, the URL + * stays null and retouch falls through to the generative fallback. The resolved + * URL is a daemon-scoped singleton read by `buildStudioEngine` (which runs + * per-turn), so the process is launched once, not per request. + */ + +import { type ChildProcess, spawn } from "node:child_process"; +import { createLogger } from "../lib/logger.ts"; + +const log = createLogger("studio-sidecar"); + +let sidecarUrl: string | null = null; +let child: ChildProcess | null = null; +let stopping = false; +let exitHookInstalled = false; + +/** Synchronous best-effort group-kill so a process.exit (incl. the + * uncaughtException path that skips the async gateway.stop) never orphans the + * `uv`/python tree. Registered once, when we first spawn. */ +function installExitBackstop(): void { + if (exitHookInstalled) return; + exitHookInstalled = true; + process.on("exit", () => { + if (child?.pid) { + try { + process.kill(-child.pid, "SIGTERM"); + } catch { + try { + child.kill("SIGTERM"); + } catch { + // already gone + } + } + } + }); +} + +export function getStudioSidecarUrl(): string | null { + return sidecarUrl; +} + +/** Test seam: point the engine at a known sidecar without spawning. */ +export function setStudioSidecarUrl(url: string | null): void { + sidecarUrl = url; +} + +async function healthOk(url: string): Promise { + try { + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), 1500); + const resp = await fetch(`${url}/healthz`, { signal: ctrl.signal }); + clearTimeout(timer); + if (!resp.ok) return false; + const j = (await resp.json()) as { status?: string }; + return j.status === "ok"; + } catch { + return false; + } +} + +/** + * Resolve a sidecar URL, spawning the process if needed. Returns the URL on + * success or null (caller treats null as "generative fallback"). Idempotent: + * returns the existing URL if already up. + */ +export async function ensureStudioSidecar(): Promise { + if (sidecarUrl) return sidecarUrl; + + const explicit = process.env.NOMOS_STUDIO_SIDECAR_URL; + if (explicit) { + if (await healthOk(explicit)) { + sidecarUrl = explicit; + log.info({ url: explicit }, "studio sidecar: using external instance"); + return explicit; + } + log.warn( + { url: explicit }, + "studio sidecar: external URL unreachable; retouch falls back to cloud", + ); + return null; + } + + const projectPath = process.env.NOMOS_STUDIO_SIDECAR_PATH ?? "../nomos-studio-sidecar"; + const port = process.env.NOMOS_STUDIO_SIDECAR_PORT ?? "8799"; + const url = `http://127.0.0.1:${port}`; + + // Adopt an instance already listening on the port (e.g. an orphan from a prior + // hard-kill) instead of spawning a duplicate that would hit EADDRINUSE. + if (await healthOk(url)) { + sidecarUrl = url; + log.info({ url }, "studio sidecar: adopted an instance already on the port"); + return url; + } + + stopping = false; + try { + // detached:true -> own process group, so teardown can group-kill the python + // grandchild uv spawns. stdio ignored so unread pipes can't fill/block or + // keep the event loop alive. + child = spawn("uv", ["run", "--project", projectPath, "nomos-studio-sidecar"], { + stdio: ["ignore", "ignore", "ignore"], + detached: true, + env: { ...process.env, NOMOS_STUDIO_SIDECAR_PORT: port }, + }); + installExitBackstop(); + const myPid = child.pid; + child.unref(); + child.on("error", (err) => { + if (!stopping) log.warn({ err }, "studio sidecar: spawn error; retouch falls back to cloud"); + }); + child.on("exit", (code) => { + if (!stopping) log.warn({ code }, "studio sidecar exited"); + // Only clear if THIS child is still the tracked one (guard re-adopt races). + if (child?.pid === myPid) { + child = null; + sidecarUrl = null; + } + }); + } catch (err) { + log.warn({ err }, "studio sidecar: could not spawn uv; retouch falls back to cloud"); + return null; + } + + // Cold `uv run` resolves + installs a heavy venv (MediaPipe/OpenCV) on first + // boot, which can far exceed 15s — make the budget generous + env-tunable. + const bootMs = Number(process.env.NOMOS_STUDIO_SIDECAR_BOOT_MS ?? "90000"); + const deadline = bootMs > 0 ? bootMs : 90000; + const stepMs = 500; + for (let waited = 0; waited < deadline; waited += stepMs) { + if (await healthOk(url)) { + sidecarUrl = url; + log.info({ url, projectPath }, "studio sidecar: ready"); + return url; + } + await new Promise((r) => setTimeout(r, stepMs)); + } + log.warn({ bootMs: deadline }, "studio sidecar: did not become healthy in time; tearing down"); + await stopStudioSidecar(); + return null; +} + +export async function stopStudioSidecar(): Promise { + stopping = true; + sidecarUrl = null; + if (child?.pid) { + // Kill the whole process group (uv + the python grandchild). Fall back to a + // direct kill if the group signal fails. + try { + process.kill(-child.pid, "SIGTERM"); + } catch { + try { + child.kill("SIGTERM"); + } catch { + // already gone + } + } + child = null; + } +} diff --git a/src/studio/suggest.test.ts b/src/studio/suggest.test.ts new file mode 100644 index 00000000..333aac63 --- /dev/null +++ b/src/studio/suggest.test.ts @@ -0,0 +1,77 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// Mock the SDK so the vision call is exercised without creds or a network. +const { generateContent } = vi.hoisted(() => ({ generateContent: vi.fn() })); +vi.mock("@google/genai", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + GoogleGenAI: vi.fn(function () { + return { models: { generateContent } }; + }), + }; +}); + +import { parseSuggestions, suggestEdits } from "./suggest.ts"; + +describe("parseSuggestions", () => { + it("parses a JSON array of {label, prompt}", () => { + const s = parseSuggestions('[{"label":"Brighten Face","prompt":"brighten the face"}]'); + expect(s).toEqual([{ label: "Brighten Face", prompt: "brighten the face" }]); + }); + + it("strips ```json code fences", () => { + const s = parseSuggestions('```json\n[{"label":"Warm","prompt":"warm it up"}]\n```'); + expect(s[0]).toEqual({ label: "Warm", prompt: "warm it up" }); + }); + + it("accepts a {suggestions:[...]} wrapper", () => { + const s = parseSuggestions('{"suggestions":[{"label":"A","prompt":"b"}]}'); + expect(s).toHaveLength(1); + }); + + it("drops malformed entries and clamps to count", () => { + const s = parseSuggestions( + '[{"label":"A","prompt":"a"},{"label":"B"},{"x":1},{"label":"C","prompt":"c"}]', + 5, + ); + expect(s.map((x) => x.label)).toEqual(["A", "C"]); + }); + + it("returns [] on non-JSON", () => { + expect(parseSuggestions("sorry, I can't")).toEqual([]); + }); +}); + +describe("suggestEdits", () => { + const SAVED = ["GEMINI_API_KEY", "GOOGLE_API_KEY", "NOMOS_STUDIO_PROVIDER"]; + const prev: Record = {}; + + beforeEach(() => { + generateContent.mockReset(); + for (const k of SAVED) { + prev[k] = process.env[k]; + delete process.env[k]; + } + process.env.GEMINI_API_KEY = "test-key"; // gemini surface + }); + afterEach(() => { + for (const k of SAVED) { + if (prev[k] === undefined) delete process.env[k]; + else process.env[k] = prev[k]; + } + }); + + it("returns parsed suggestions and requests JSON output", async () => { + generateContent.mockResolvedValue({ text: '[{"label":"Brighten","prompt":"brighten it"}]' }); + const s = await suggestEdits(new Uint8Array([1, 2, 3]), "image/jpeg"); + expect(s).toEqual([{ label: "Brighten", prompt: "brighten it" }]); + const arg = generateContent.mock.calls[0][0] as { config?: { responseMimeType?: string } }; + expect(arg.config?.responseMimeType).toBe("application/json"); + }); + + it("degrades to [] when the model throws", async () => { + generateContent.mockRejectedValue(new Error("boom")); + expect(await suggestEdits(new Uint8Array([1]), "image/jpeg")).toEqual([]); + }); +}); diff --git a/src/studio/suggest.ts b/src/studio/suggest.ts new file mode 100644 index 00000000..027fa638 --- /dev/null +++ b/src/studio/suggest.ts @@ -0,0 +1,115 @@ +/** + * AI-native edit suggestions. The heart of the editor dock: a vision model looks at + * THIS photo and proposes the highest-impact fixes as short, tap-to-apply prompts — + * so the chips are about the actual image, not a static toolbar. + * + * Gemini 2.5 Flash (vision -> text), reusing the studio surface/credentials. Text + * output, so no IMAGE_SAFETY path; the configurable text-safety categories are relaxed + * (the user is editing their own photo with consent). Always degrades to [] on any + * failure so the editor falls back to its static chips. + */ + +import { Buffer } from "node:buffer"; +import { createLogger } from "../lib/logger.ts"; +import { createGenAI, relaxedSafetyFor } from "./providers/gemini-image.ts"; + +const log = createLogger("studio-suggest"); + +export interface EditSuggestion { + /** 1-3 word chip label, Title Case (e.g. "Brighten Face"). */ + label: string; + /** The natural-language edit instruction applied on tap (editSemantic). */ + prompt: string; +} + +const SYSTEM = `You are an expert photo editor for a consumer beauty + photo app. Look at this photo and identify the edits that would most improve THIS specific image — judge its actual lighting, exposure, white balance, color, contrast, sharpness, composition, and distracting elements. + +In addition to the user's explicit request and any obvious quality fixes, proactively scan the photo for retouch opportunities from the families below and suggest only the ones that genuinely fit what you actually see. Treat these as optional, tasteful enhancements, never mandatory. Gate every suggestion on real visual evidence and on the type of shot: for a portrait, headshot, or selfie consider skin (smooth skin, clear blemishes, even skin tone, soften freckles, matte shine, calm redness, fresh glow), eyes (brighten eyes, whiten eyes, refresh under-eyes, open eyes), teeth (brighten smile), lips, hair (cover grays, fuller hair, tidy beard), and gentle facial refinement (slim face, define jawline, smooth chin, refine nose); only when a torso or full body is actually in frame consider figure work (slim waist, flatten tummy, lengthen legs, fix posture). Match age and condition to the fix: suggest wrinkle softening, smile-line softening, under-eye refresh, age-spot fading, or gray coverage only when you can see those signs on a clearly mature subject, and never propose wrinkle or age-spot removal on young, already-smooth skin. Freckles are often a feature people like, so only offer to soften them when they are heavy or uneven and reduction would clearly flatter — never by default. Never suggest body reshaping on a face-only crop, beard cleanup where there is no facial hair, or any change for a feature that is not visible. Suggest only what would flatter the specific subject, keep every edit subtle, realistic, and identity-preserving (same person, same bone structure, same expression, natural skin texture and pores retained), and avoid airbrushed, plastic, over-whitened, or warped results. When nothing genuinely applies, suggest nothing rather than forcing an edit. Be especially considerate with appearance-related suggestions: frame them as gentle, optional touch-ups, and err toward fewer, higher-confidence proposals over an exhaustive list. + +Every edit must IMPROVE technical quality: increase sharpness, clarity, and fine detail and keep or raise resolution — never produce a softer, blurrier, smeared, or lower-detail result. When the photo is soft, slightly out of focus, noisy, or low-detail, propose sharpening or detail enhancement. Every prompt you write must explicitly tell the editor to keep the image crisp and detailed and not to soften or blur it (skin smoothing must still retain pores and fine texture). + +Return the top 5 edits, ordered by impact. For each: +- "label": a 1-3 word button label in Title Case (e.g. "Sharpen Detail", "Brighten Face", "Smooth Wrinkles", "Soften Freckles", "Cover Grays", "Remove Clutter"). +- "prompt": a clear, natural editing instruction that achieves it (e.g. "soften the forehead and eye wrinkles for a refreshed look while keeping natural skin texture and the person's expression"). + +Be specific to what you actually see — never generic. Output ONLY a JSON array: [{"label":"...","prompt":"..."}].`; + +interface RawSuggestion { + label?: unknown; + prompt?: unknown; +} + +/** Analyze image bytes and return up to `count` tap-to-apply edit suggestions. */ +export async function suggestEdits( + bytes: Uint8Array, + mime: string, + opts?: { model?: string; count?: number; style?: string }, +): Promise { + const model = opts?.model ?? process.env.NOMOS_STUDIO_SUGGEST_MODEL ?? "gemini-2.5-flash"; + const count = opts?.count ?? 5; + const styleBlock = opts?.style + ? `\n\nThe user's learned photo-editing style: ${opts.style}\nFavor suggestions that match this taste WHEN they also genuinely fit this photo; never force the style.` + : ""; + try { + const { ai } = createGenAI(); + const resp = await ai.models.generateContent({ + model, + contents: [ + { + role: "user", + parts: [ + { inlineData: { mimeType: mime, data: Buffer.from(bytes).toString("base64") } }, + { text: SYSTEM + styleBlock }, + ], + }, + ], + config: { + responseMimeType: "application/json", + // Text output: only the configurable text categories apply (never the image + // ones, which would 400 on the Gemini API surface). + safetySettings: relaxedSafetyFor("gemini"), + }, + }); + return parseSuggestions(resp.text ?? textFromCandidates(resp), count); + } catch (err) { + log.warn({ err: err instanceof Error ? err.message : String(err) }, "studio suggest failed"); + return []; + } +} + +/** Some SDK shapes expose text only on the candidate parts; this is the fallback. */ +function textFromCandidates(resp: unknown): string { + const parts = + (resp as { candidates?: Array<{ content?: { parts?: Array<{ text?: string }> } }> }) + ?.candidates?.[0]?.content?.parts ?? []; + return parts.map((p) => p.text ?? "").join(""); +} + +/** Tolerant parse: trims code fences, validates shape, clamps lengths + count. */ +export function parseSuggestions(text: string, count = 5): EditSuggestion[] { + const cleaned = text + .trim() + .replace(/^```(?:json)?/i, "") + .replace(/```$/i, "") + .trim(); + let raw: unknown; + try { + raw = JSON.parse(cleaned); + } catch { + return []; + } + const arr: RawSuggestion[] = Array.isArray(raw) + ? (raw as RawSuggestion[]) + : Array.isArray((raw as { suggestions?: unknown }).suggestions) + ? (raw as { suggestions: RawSuggestion[] }).suggestions + : []; + const out: EditSuggestion[] = []; + for (const item of arr) { + if (typeof item?.label !== "string" || typeof item?.prompt !== "string") continue; + const label = item.label.trim().slice(0, 28); + const prompt = item.prompt.trim().slice(0, 280); + if (label && prompt) out.push({ label, prompt }); + if (out.length >= count) break; + } + return out; +}