@bongos/core 1.23.22 → 1.24.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.bongos-core.json +902 -407
- package/.claude/skills/blocker-review/SKILL.md +9 -1
- package/.claude/skills/blocker-solve/SKILL.md +1 -1
- package/.claude/skills/builder-claim/SKILL.md +1 -1
- package/.claude/skills/builder-reauth/SKILL.md +1 -1
- package/.claude/skills/builder-redteam/SKILL.md +1 -1
- package/.claude/skills/builder-sequence/SKILL.md +1 -1
- package/.claude/skills/builder-setup/SKILL.md +1 -1
- package/.claude/skills/builder-ship/SKILL.md +1 -1
- package/.claude/skills/collab-review/SKILL.md +5 -2
- package/.claude/skills/design/SKILL.md +1 -1
- package/.claude/skills/goal-create/SKILL.md +2 -2
- package/.claude/skills/grade-recover/SKILL.md +1 -1
- package/.claude/skills/merge-mode/SKILL.md +1 -1
- package/.claude/skills/owner-review/SKILL.md +1 -1
- package/.claude/skills/planning-session/SKILL.md +11 -11
- package/.claude/skills/read-session-export/SKILL.md +1 -1
- package/.claude/skills/recall/SKILL.md +1 -1
- package/.claude/skills/scan-before-install/SKILL.md +1 -1
- package/.claude/skills/session-handoff/SKILL.md +1 -1
- package/.claude/skills/strand-fix/SKILL.md +13 -0
- package/.claude/skills/worktree-clean/SKILL.md +1 -1
- package/AGENTS.md +15 -0
- package/GEMINI.md +5 -0
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +90 -0
- package/clients/bongos-client/index.cjs +90 -0
- package/clients/bongos-client/index.d.ts +149 -4
- package/clients/bongos-client/index.mjs +90 -0
- package/docs/adr/0054-session-token-reward-cost-plus.md +3 -1
- package/docs/adr/0055-server-mediated-branch-publish.md +1 -1
- package/docs/adr/0393-a-platform-account-is-a-person-and-github-is-one-way-in.md +37 -0
- package/docs/adr/0394-market-watch-searches-through-the-messages-api-on-the-projects-key-and-runs-from-its-own-poller.md +102 -0
- package/docs/adr/0395-a-task-says-what-it-needs-and-lists-show-only-what-you-can-do.md +36 -0
- package/docs/adr/0396-google-sign-in-a-stand-in-login-and-a-browser-approved-cli-sign-in.md +41 -0
- package/docs/adr/README.md +5 -1
- package/docs/api/openapi.json +8555 -5876
- package/docs/api-reference.md +63 -10
- package/docs/architecture.md +37 -1
- package/docs/canonical-permissions.md +1 -1
- package/docs/copy-inventory.md +420 -337
- package/docs/copy-registry.json +1321 -500
- package/docs/file-map.md +1 -0
- package/docs/module-api-changelog.md +5 -0
- package/docs/onboarding/diagrams/03-drachmae-karma.mmd +3 -3
- package/docs/onboarding/diagrams/README.md +1 -1
- package/docs/onboarding/drachmae-and-karma.md +15 -15
- package/docs/onboarding/primer.md +2 -2
- package/docs/page-inventory.json +55 -6
- package/docs/page-readings.json +1185 -1011
- package/docs/recipes/google-sign-in.md +60 -0
- package/docs/recipes/ops-gotchas.md +1 -1
- package/migrations/core_282_builders_lower_login_idx.sql +33 -0
- package/migrations/core_283_claimable_ignores_page_markers.sql +53 -0
- package/migrations/core_283_payout_approvals.sql +183 -0
- package/migrations/core_284_payout_gates.sql +179 -0
- package/migrations/core_285_task_needs.sql +74 -0
- package/modules/ai-studio/billing.js +67 -0
- package/modules/ai-studio/context.js +29 -0
- package/modules/ai-studio/db.js +172 -0
- package/modules/ai-studio/gemini.js +75 -0
- package/modules/ai-studio/generate.js +119 -0
- package/modules/ai-studio/keys.js +73 -0
- package/modules/ai-studio/migrations/ai_studio_001_keys.sql +22 -0
- package/modules/ai-studio/migrations/ai_studio_002_ledger.sql +48 -0
- package/modules/ai-studio/migrations/ai_studio_003_requests.sql +55 -0
- package/modules/ai-studio/openrouter.js +90 -0
- package/modules/ai-studio/payments.js +105 -0
- package/modules/ai-studio/pricing.js +92 -0
- package/modules/ai-studio/routes/ai-studio.js +235 -5
- package/modules/ai-studio/studio.js +121 -0
- package/modules/autonomy/fence.js +64 -1
- package/modules/builder-settings/builder-needs.js +27 -1
- package/modules/copy-desk/routes/copy-desk.js +5 -0
- package/modules/economy/achievements.js +22 -10
- package/modules/economy/cost-classify.js +9 -0
- package/modules/economy/cost-write-guard.js +52 -0
- package/modules/economy/cost.js +2 -1
- package/modules/economy/credits.js +18 -15
- package/modules/economy/goal-reward.js +161 -0
- package/modules/economy/reward.js +4 -0
- package/modules/economy/routes/cost.js +33 -7
- package/modules/government/catalog.js +6 -0
- package/modules/government/db.js +34 -0
- package/modules/government/docket.js +36 -0
- package/modules/government/migrations/government_035_payout_approve.sql +29 -0
- package/modules/government/pollers/board-expiry.js +108 -20
- package/modules/government/protected-surfaces.json +1 -0
- package/modules/government/routes/government.js +22 -0
- package/modules/government/routing.js +39 -5
- package/modules/government/waiting.js +74 -0
- package/modules/hall-ui/public/ai-studio-lib.js +268 -0
- package/modules/hall-ui/public/ai-studio.css +193 -0
- package/modules/hall-ui/public/ai-studio.html +98 -0
- package/modules/hall-ui/public/ai-studio.js +556 -0
- package/modules/hall-ui/public/ai-studio.states.json +94 -0
- package/modules/hall-ui/public/blockers-by-person.js +156 -0
- package/modules/hall-ui/public/blockers-lib.js +15 -1
- package/modules/hall-ui/public/blockers.html +8 -0
- package/modules/hall-ui/public/blockers.js +1 -1
- package/modules/hall-ui/public/claim-action.js +3 -0
- package/modules/hall-ui/public/collab.html +12 -0
- package/modules/hall-ui/public/craft-mode.js +4 -1
- package/modules/hall-ui/public/criterion-uat-ui.js +4 -1
- package/modules/hall-ui/public/drachmae.html +1 -1
- package/modules/hall-ui/public/genesis/r5-life-1-primordial-sea.jpg +0 -0
- package/modules/hall-ui/public/genesis/r5-life-2-first-cells-cut.webp +0 -0
- package/modules/hall-ui/public/genesis/r5-life-2-first-cells.jpg +0 -0
- package/modules/hall-ui/public/genesis/r5-life-3-genome-cut.webp +0 -0
- package/modules/hall-ui/public/genesis/r5-life-3-genome.jpg +0 -0
- package/modules/hall-ui/public/genesis/r5-life-4-membrane-cut.webp +0 -0
- package/modules/hall-ui/public/genesis/r5-life-4-membrane.jpg +0 -0
- package/modules/hall-ui/public/genesis/r5-life-5-nervous-system-cut.webp +0 -0
- package/modules/hall-ui/public/genesis/r5-life-5-nervous-system.jpg +0 -0
- package/modules/hall-ui/public/goal-inbox.js +1 -1
- package/modules/hall-ui/public/goals-render.js +4 -0
- package/modules/hall-ui/public/goals.html +8 -0
- package/modules/hall-ui/public/gov-living.js +6 -2
- package/modules/hall-ui/public/hall-ask.css +5 -0
- package/modules/hall-ui/public/hall-ask.js +39 -3
- package/modules/hall-ui/public/notes-panel.css +20 -0
- package/modules/hall-ui/public/notes-panel.js +146 -0
- package/modules/hall-ui/public/project-settings.html +1 -1
- package/modules/hall-ui/public/project-settings.js +7 -5
- package/modules/hall-ui/public/settings-ai-keys.js +124 -0
- package/modules/hall-ui/public/settings.html +15 -0
- package/modules/hall-ui/public/street-deck.js +12 -3
- package/modules/hall-ui/public/studio.css +90 -8
- package/modules/hall-ui/public/studio.html +39 -10
- package/modules/hall-ui/public/studio.js +148 -18
- package/modules/hall-ui/public/task.html +2 -0
- package/modules/hall-ui/public/task.js +4 -0
- package/modules/hall-ui/public/tweak-editor.js +12 -3
- package/modules/hall-ui/public/welcome-boot.css +42 -6
- package/modules/hall-ui/public/welcome-boot.js +20 -6
- package/modules/hall-ui/public/work.js +20 -0
- package/modules/hall-ui/records/ai-studio.md +36 -0
- package/modules/hall-ui/records/blockers-people.md +24 -0
- package/modules/lifecycle/db-claims.js +32 -4
- package/modules/lifecycle/db-goals.js +18 -5
- package/modules/lifecycle/db-tasks.js +15 -6
- package/modules/lifecycle/done-when.js +23 -1
- package/modules/lifecycle/lifecycle.js +5 -0
- package/modules/lifecycle/migrations/lifecycle_017_notes.sql +46 -0
- package/modules/lifecycle/module.json +2 -1
- package/modules/lifecycle/notes.js +219 -0
- package/modules/lifecycle/page-notice.js +124 -0
- package/modules/lifecycle/page-tweak-artist.js +18 -1
- package/modules/lifecycle/publish-reconciler.js +67 -20
- package/modules/lifecycle/routes/claims.js +5 -2
- package/modules/lifecycle/routes/goals.js +3 -1
- package/modules/lifecycle/routes/notes.js +91 -0
- package/modules/lifecycle/routes/project-settings.js +43 -35
- package/modules/lifecycle/routes/task-route-helpers.js +21 -0
- package/modules/lifecycle/routes/task-write-routes.js +21 -3
- package/modules/lifecycle/routes/tasks.js +12 -12
- package/modules/lifecycle/task-needs-gate.js +70 -0
- package/modules/market-watch/CLAUDE.md +15 -0
- package/modules/market-watch/db.js +340 -0
- package/modules/market-watch/key.js +58 -0
- package/modules/market-watch/migrations/market_watch_001_tables.sql +249 -0
- package/modules/market-watch/migrations/market_watch_002_run.sql +46 -0
- package/modules/market-watch/module.json +26 -0
- package/modules/market-watch/pollers/hub-seed.js +54 -0
- package/modules/market-watch/pollers/weekly-run.js +100 -0
- package/modules/market-watch/research.js +350 -0
- package/modules/market-watch/run.js +245 -0
- package/modules/market-watch/seed/market-data.json +38 -0
- package/modules/market-watch/seed/partners-data.json +497 -0
- package/modules/market-watch/seed.js +252 -0
- package/modules/market-watch/store.js +230 -0
- package/modules/memory/routes/memory.js +14 -2
- package/modules/memory/routes/search.js +8 -2
- package/modules/platform-identity/account-logins.js +192 -0
- package/modules/platform-identity/migrations/platform_identity_034_account_logins.sql +96 -0
- package/modules/platform-identity/platform-identity.js +5 -4
- package/modules/platform-identity/routes/my-projects.js +6 -0
- package/modules/platform-identity/routes/profile.js +18 -0
- package/modules/platform-identity/routes/public-profile.js +10 -1
- package/modules/platform-identity/routes/sso.js +26 -1
- package/modules/platform-identity/tests/platform-identity.mjs +2 -1
- package/modules/provisioning/credential-preflight.js +13 -3
- package/modules/provisioning/pollers/app-liveness.js +25 -5
- package/modules/provisioning/routes/provisioning.js +1 -1
- package/modules/provisioning/starter-bundles.js +6 -2
- package/modules/public-landing/public/account.html +10 -2
- package/modules/public-landing/public/assets/world/r2-bongos-chrome-b.jpg +0 -0
- package/modules/public-landing/public/assets/world/r2-bongos-chrome-b.jpg.json +1 -1
- package/modules/public-landing/public/assets/world/r2-bongos-chrome-b.webp +0 -0
- package/modules/public-landing/public/assets/world/r2-bongos-chrome-b.webp.json +1 -1
- package/modules/public-landing/public/assets/world/r2-chrome-blob-one-cut.webp +0 -0
- package/modules/public-landing/public/assets/world/r2-chrome-blob-one-cut.webp.json +1 -1
- package/modules/public-landing/public/assets/world/r2-chrome-blob-one.jpg +0 -0
- package/modules/public-landing/public/assets/world/r2-chrome-blob-one.jpg.json +1 -1
- package/modules/public-landing/public/assets/world/r2-chrome-blob-one.webp +0 -0
- package/modules/public-landing/public/assets/world/r2-chrome-blob-one.webp.json +1 -1
- package/modules/public-landing/public/assets/world/r2-chrome-blobs-set.jpg +0 -0
- package/modules/public-landing/public/assets/world/r2-chrome-blobs-set.jpg.json +1 -1
- package/modules/public-landing/public/assets/world/r2-chrome-blobs-set.webp +0 -0
- package/modules/public-landing/public/assets/world/r2-chrome-blobs-set.webp.json +1 -1
- package/modules/public-landing/public/assets/world/r3-frost-bead-cut.webp +0 -0
- package/modules/public-landing/public/assets/world/r3-frost-bead-cut.webp.json +1 -1
- package/modules/public-landing/public/assets/world/r3-frost-pair-cut.webp +0 -0
- package/modules/public-landing/public/assets/world/r3-frost-pair-cut.webp.json +1 -1
- package/modules/public-landing/public/assets/world/r3-gold-blob-cut.webp +0 -0
- package/modules/public-landing/public/assets/world/r3-gold-blob-cut.webp.json +1 -1
- package/modules/public-landing/public/assets/world/r3-gold-pair-cut.webp +0 -0
- package/modules/public-landing/public/assets/world/r3-gold-pair-cut.webp.json +1 -1
- package/modules/public-landing/public/assets/world/r3-obsidian-blob-cut.webp +0 -0
- package/modules/public-landing/public/assets/world/r3-obsidian-blob-cut.webp.json +1 -1
- package/modules/public-landing/public/assets/world/r3-obsidian-pair-cut.webp +0 -0
- package/modules/public-landing/public/assets/world/r3-obsidian-pair-cut.webp.json +1 -1
- package/modules/public-landing/public/assets/world/r3-space-nebula.jpg +0 -0
- package/modules/public-landing/public/assets/world/r3-space-nebula.jpg.json +1 -1
- package/modules/public-landing/public/assets/world/r4-card-chrome.jpg +0 -0
- package/modules/public-landing/public/assets/world/r4-card-chrome.jpg.json +1 -1
- package/modules/public-landing/public/assets/world/r4-card-nebula.jpg +0 -0
- package/modules/public-landing/public/assets/world/r4-card-nebula.jpg.json +1 -1
- package/modules/public-landing/public/assets/world/r5-life-1-primordial-sea.jpg +0 -0
- package/modules/public-landing/public/assets/world/r5-life-1-primordial-sea.jpg.json +1 -1
- package/modules/public-landing/public/assets/world/r5-life-2-first-cells-cut.webp +0 -0
- package/modules/public-landing/public/assets/world/r5-life-2-first-cells-cut.webp.json +1 -1
- package/modules/public-landing/public/assets/world/r5-life-2-first-cells.jpg +0 -0
- package/modules/public-landing/public/assets/world/r5-life-2-first-cells.jpg.json +1 -1
- package/modules/public-landing/public/assets/world/r5-life-3-genome-cut.webp +0 -0
- package/modules/public-landing/public/assets/world/r5-life-3-genome-cut.webp.json +1 -1
- package/modules/public-landing/public/assets/world/r5-life-3-genome.jpg +0 -0
- package/modules/public-landing/public/assets/world/r5-life-3-genome.jpg.json +1 -1
- package/modules/public-landing/public/assets/world/r5-life-4-membrane-cut.webp +0 -0
- package/modules/public-landing/public/assets/world/r5-life-4-membrane-cut.webp.json +1 -1
- package/modules/public-landing/public/assets/world/r5-life-4-membrane.jpg +0 -0
- package/modules/public-landing/public/assets/world/r5-life-4-membrane.jpg.json +1 -1
- package/modules/public-landing/public/assets/world/r5-life-5-nervous-system-cut.webp +0 -0
- package/modules/public-landing/public/assets/world/r5-life-5-nervous-system-cut.webp.json +1 -1
- package/modules/public-landing/public/assets/world/r5-life-5-nervous-system.jpg +0 -0
- package/modules/public-landing/public/assets/world/r5-life-5-nervous-system.jpg.json +1 -1
- package/modules/public-landing/public/community.html +1 -1
- package/modules/public-landing/public/connections.html +2 -2
- package/modules/public-landing/public/guild.html +1 -1
- package/modules/public-landing/public/invitations.html +2 -2
- package/modules/public-landing/public/projects.html +5 -5
- package/modules/raise/views/nda.html +2 -0
- package/modules/specialities/db.js +32 -1
- package/modules/specialities/routes/specialities.js +10 -6
- package/modules/status-ui/public/index.html +1 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +220 -0
- package/scripts/bongos/autobongos-tweak-lane.js +46 -1
- package/scripts/bongos/backfill-subagent-rewards.js +2 -2
- package/scripts/bongos/claim.js +22 -1
- package/scripts/bongos/db-backup-nightly.sh +29 -0
- package/scripts/bongos/deploy-divergence.js +7 -0
- package/scripts/bongos/doc-cli-guard.js +6 -0
- package/scripts/bongos/fitness.js +3 -2
- package/scripts/bongos/gen-diagrams.js +3 -3
- package/scripts/bongos/lib.sh +4 -5
- package/scripts/bongos/migration-namespace.js +13 -1
- package/scripts/bongos/plain-cards.js +4 -0
- package/scripts/bongos/provision-github-setup.js +3 -1
- package/scripts/bongos/provision-units.js +6 -1
- package/scripts/bongos/publish-manifest.js +3 -0
- package/scripts/bongos/release.js +2 -0
- package/scripts/bongos/run-unit-tests.js +268 -82
- package/scripts/bongos/seed-tasks.example.json +56 -0
- package/scripts/bongos/seed-tasks.js +222 -0
- package/scripts/bongos/seed-verify-criteria.js +1 -2
- package/scripts/bongos/ship-finish.js +2 -0
- package/scripts/bongos/ship-resume.js +2 -0
- package/scripts/bongos/strand-lease.js +250 -0
- package/scripts/bongos/strand-watch.js +26 -5
- package/scripts/bongos/version-literal-guard.js +2 -2
- package/scripts/discord/post.js +4 -3
- package/scripts/gds/seed-tasks.js +7 -0
- package/scripts/gds/{build-bongos.js → strand-lease.js} +3 -3
- package/scripts/hall-preview/server.js +21 -0
- package/src/bongos/auth-config.js +29 -1
- package/src/bongos/auth-discord.js +3 -3
- package/src/bongos/auth-github.js +3 -6
- package/src/bongos/auth-google.js +217 -0
- package/src/bongos/device-approval.js +140 -0
- package/src/bongos/llm-pricing.js +17 -1
- package/src/bongos/metrics.js +48 -2
- package/src/bongos/middleware/rate-limit.js +30 -11
- package/src/bongos/module-scope-map.js +12 -1
- package/src/bongos/optimizer.js +15 -1
- package/src/bongos/payout-approvals.js +342 -0
- package/src/bongos/payout-gates.js +364 -0
- package/src/bongos/prelaunch-gate.js +7 -9
- package/src/bongos/prelaunch-switch.js +20 -0
- package/src/bongos/route-rank-check.js +3 -0
- package/src/bongos/routes/auth-google.js +246 -0
- package/src/bongos/routes/auth.js +38 -1
- package/src/bongos/routes/backup.js +33 -9
- package/src/bongos/routes/builders.js +57 -30
- package/src/bongos/routes/github.js +9 -0
- package/src/bongos/routes/me.js +18 -0
- package/src/bongos/routes/my-payouts.js +133 -0
- package/src/bongos/routes/payout-approvals.js +312 -0
- package/src/bongos/routes/payout-gates.js +179 -0
- package/src/bongos/routes.js +15 -1
- package/src/bongos/secret-box.js +18 -3
- package/src/bongos/serve-internal.js +47 -19
- package/src/bongos/sign-in-pages.js +138 -0
- package/src/bongos/task-needs.js +146 -0
- package/src/bongos-downloads.js +18 -1
- package/src/instance-config.js +28 -0
- package/src/module-api.js +14 -1
- package/tests/account_logins.mjs +326 -0
- package/tests/account_logins_db.mjs +161 -0
- package/tests/ai_studio_billing.mjs +253 -0
- package/tests/ai_studio_generate.mjs +282 -0
- package/tests/ai_studio_keys.mjs +159 -0
- package/tests/ai_studio_requests.mjs +319 -0
- package/tests/auth_backchannel_logout_route.mjs +201 -0
- package/tests/auto_satisfy_criteria.mjs +10 -1
- package/tests/autobongos_tweak_lane.mjs +144 -0
- package/tests/autobongos_tweak_pin_identity.mjs +137 -0
- package/tests/backfill_subagent_rewards.mjs +17 -1
- package/tests/backup_trigger_lock.mjs +119 -0
- package/tests/blockers_people.mjs +198 -0
- package/tests/bounded_rate_maps.mjs +184 -0
- package/tests/claimable_blocker_status.mjs +1 -1
- package/tests/claims_batch_routes.mjs +421 -0
- package/tests/config_read_cache.mjs +171 -0
- package/tests/copy_desk_page_editor.mjs +12 -0
- package/tests/cost_write_guard.mjs +200 -0
- package/tests/craft_achievements.mjs +33 -10
- package/tests/criterion_goal_attach.mjs +1 -1
- package/tests/db_backup_nightly.mjs +73 -1
- package/tests/deploy_divergence_line.mjs +7 -0
- package/tests/effective_visibility_predicate.mjs +12 -0
- package/tests/fitness.mjs +19 -0
- package/tests/fixtures/seed-tasks/bongos-v1-idea-routing.json +256 -0
- package/tests/fixtures/seed-tasks/bv1-profiles.json +592 -0
- package/tests/fixtures/seed-tasks/bv2-ideator-surface.json +373 -0
- package/tests/fixtures/seed-tasks/fake-api.mjs +80 -0
- package/tests/github_one_trip.mjs +34 -0
- package/tests/github_repo_visibility.mjs +20 -0
- package/tests/goal_achievement.mjs +30 -1
- package/tests/goal_achievement_reward.mjs +205 -0
- package/tests/goal_sort_staleness.mjs +2 -1
- package/tests/goal_tier.mjs +11 -1
- package/tests/google_fonts_nonblocking.mjs +84 -0
- package/tests/google_sign_in.mjs +401 -0
- package/tests/government_board_expiry.mjs +94 -0
- package/tests/government_protected_surfaces.mjs +1 -1
- package/tests/government_routes.mjs +1 -1
- package/tests/hall_ai_studio.mjs +347 -0
- package/tests/hall_page_gate_map.mjs +3 -0
- package/tests/hall_studio_home.mjs +61 -2
- package/tests/home_attention_cards.mjs +1 -1
- package/tests/human_signal.mjs +141 -0
- package/tests/install_script_reader.mjs +51 -0
- package/tests/integration_fixture_ids.mjs +82 -0
- package/tests/invite_expiry_db.mjs +13 -1
- package/tests/lib_sh_resolution.mjs +0 -4
- package/tests/market_watch_data.mjs +244 -0
- package/tests/market_watch_data_db.mjs +308 -0
- package/tests/market_watch_module.mjs +114 -0
- package/tests/market_watch_run.mjs +503 -0
- package/tests/metrics.mjs +124 -0
- package/tests/migration_namespace.mjs +14 -0
- package/tests/module_api.mjs +2 -0
- package/tests/module_loader.mjs +1 -1
- package/tests/notes.mjs +182 -0
- package/tests/own_scope_guard.mjs +5 -0
- package/tests/page_tweak_marker_sql.mjs +76 -0
- package/tests/page_tweak_no_block.mjs +135 -0
- package/tests/page_tweak_notice.mjs +103 -0
- package/tests/payout_approvals.mjs +253 -0
- package/tests/payout_approvals_db.mjs +106 -0
- package/tests/payout_gates.mjs +322 -0
- package/tests/payout_gates_db.mjs +113 -0
- package/tests/planning_session_skill.mjs +13 -3
- package/tests/project_settings_page.mjs +29 -5
- package/tests/project_settings_spend_cap_route.mjs +235 -0
- package/tests/promotion_sweep.mjs +29 -1
- package/tests/provision.mjs +3 -2
- package/tests/provisioning_app_status.mjs +18 -0
- package/tests/provisioning_credential_preflight.mjs +53 -0
- package/tests/provisioning_env_manifest_edit_delete.mjs +223 -0
- package/tests/publish_reconciler_overlap.mjs +60 -0
- package/tests/rank_change_broadcast_port.mjs +151 -0
- package/tests/seed_tasks.mjs +176 -0
- package/tests/seed_verify_criteria.mjs +2 -6
- package/tests/site_classified_once.mjs +142 -0
- package/tests/speciality_db_shape.mjs +23 -0
- package/tests/speciality_session_skills.mjs +48 -2
- package/tests/strand_lease.mjs +249 -0
- package/tests/strand_watch.mjs +10 -0
- package/tests/task_criteria_routes.mjs +275 -0
- package/tests/task_criteria_unlink_db.mjs +123 -0
- package/tests/task_needs.mjs +233 -0
- package/tests/task_needs_db.mjs +110 -0
- package/tests/unit_runner_serial.mjs +266 -43
- package/tests/version_literals.mjs +5 -7
- package/tests/welcome_boot.mjs +16 -5
- package/tests/welcome_boot_browser.mjs +36 -4
- package/docs/recipes/bongos-cli-release.md +0 -66
- package/scripts/bongos/backfill-disciplines.js +0 -144
- package/scripts/bongos/backfill-session-earnings.js +0 -123
- package/scripts/bongos/backfill-session-types.js +0 -87
- package/scripts/bongos/build-bongos.js +0 -94
- package/scripts/bongos/cleanup-orphans.js +0 -74
- package/scripts/bongos/seed-bongos-v1-idea-routing-tasks.js +0 -152
- package/scripts/bongos/seed-bv1-profiles-tasks.js +0 -135
- package/scripts/bongos/seed-bv2-ideator-surface-tasks.js +0 -390
- package/scripts/bongos/smoke-batch-claim.sh +0 -238
- package/scripts/bongos/smoke-batch-release.sh +0 -316
- package/scripts/bongos/smoke-claim-consistency.sh +0 -222
- package/scripts/bongos/smoke-dependencies.sh +0 -143
- package/scripts/gds/backfill-disciplines.js +0 -7
- package/scripts/gds/backfill-session-earnings.js +0 -7
- package/scripts/gds/backfill-session-types.js +0 -7
- package/scripts/gds/cleanup-orphans.js +0 -7
- package/scripts/gds/seed-bongos-v1-idea-routing-tasks.js +0 -6
- package/scripts/gds/seed-bv1-profiles-tasks.js +0 -6
- package/scripts/gds/seed-bv2-ideator-surface-tasks.js +0 -6
- package/scripts/gds/smoke-batch-claim.sh +0 -8
- package/scripts/gds/smoke-batch-release.sh +0 -8
- package/scripts/gds/smoke-claim-consistency.sh +0 -8
- package/scripts/gds/smoke-dependencies.sh +0 -8
- package/tests/smoke_dependencies_witness.mjs +0 -46
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// modules/ai-studio/openrouter.js — the two OpenRouter calls a generation makes
|
|
2
|
+
// (task 1004917, goal 1000136, ADR 0392): a Gemini REPLY to the artist's
|
|
3
|
+
// submission, then IMAGES drawn from the prompt, the reply and the sketch.
|
|
4
|
+
//
|
|
5
|
+
// Why OpenRouter for both: one account, one key, and every response reports
|
|
6
|
+
// what the call really cost (`usage.cost`, in dollars), which is exactly the
|
|
7
|
+
// number the builder is billed on (cost x the multiplier). A call that does not
|
|
8
|
+
// report its cost is treated as a failure, so the hold is released and nobody
|
|
9
|
+
// is charged a guess.
|
|
10
|
+
//
|
|
11
|
+
// `fetch` is handed in, so tests never touch the network.
|
|
12
|
+
|
|
13
|
+
'use strict';
|
|
14
|
+
|
|
15
|
+
const ENDPOINT = 'https://openrouter.ai/api/v1/chat/completions';
|
|
16
|
+
const REPLY_MODEL = 'google/gemini-2.5-pro';
|
|
17
|
+
const IMAGE_MODEL = 'google/gemini-2.5-flash-image';
|
|
18
|
+
const TIMEOUT_MS = 120000;
|
|
19
|
+
|
|
20
|
+
class ProviderError extends Error {
|
|
21
|
+
constructor(code, message) { super(message); this.code = code; }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// The artist's files as chat content parts: images inline, PDFs as files.
|
|
25
|
+
function fileParts(files) {
|
|
26
|
+
return (files || []).map((f) => (f.content_type === 'application/pdf'
|
|
27
|
+
? { type: 'file', file: { filename: f.name || 'document.pdf', file_data: `data:application/pdf;base64,${f.base64}` } }
|
|
28
|
+
: { type: 'image_url', image_url: { url: `data:${f.content_type};base64,${f.base64}` } }));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function post(fetchImpl, key, body) {
|
|
32
|
+
const ctl = new AbortController();
|
|
33
|
+
const timer = setTimeout(() => ctl.abort(), TIMEOUT_MS);
|
|
34
|
+
let res;
|
|
35
|
+
try {
|
|
36
|
+
res = await fetchImpl(ENDPOINT, {
|
|
37
|
+
method: 'POST',
|
|
38
|
+
headers: { authorization: `Bearer ${key}`, 'content-type': 'application/json', 'x-title': 'Cloud Bongos AI studio' },
|
|
39
|
+
body: JSON.stringify({ ...body, usage: { include: true } }),
|
|
40
|
+
signal: ctl.signal,
|
|
41
|
+
});
|
|
42
|
+
} catch (err) {
|
|
43
|
+
throw new ProviderError(err.name === 'AbortError' ? 'provider_timeout' : 'provider_unreachable', 'The AI provider could not be reached.');
|
|
44
|
+
} finally {
|
|
45
|
+
clearTimeout(timer);
|
|
46
|
+
}
|
|
47
|
+
let json = null;
|
|
48
|
+
try { json = await res.json(); } catch (_) { /* fall through */ }
|
|
49
|
+
if (!res.ok || !json || json.error) {
|
|
50
|
+
throw new ProviderError('provider_error', `The AI provider refused the request (HTTP ${res.status}).`);
|
|
51
|
+
}
|
|
52
|
+
const cost = json.usage && Number(json.usage.cost);
|
|
53
|
+
if (!Number.isFinite(cost) || cost < 0) throw new ProviderError('provider_cost_unknown', 'The AI provider did not report what the call cost.');
|
|
54
|
+
const msg = (json.choices && json.choices[0] && json.choices[0].message) || {};
|
|
55
|
+
return { message: msg, costMicro: Math.ceil(cost * 1000000) };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Gemini's reply to the submission. Returns { text, costMicro }.
|
|
59
|
+
async function reply({ fetchImpl, key, system, prompt, files }) {
|
|
60
|
+
const out = await post(fetchImpl, key, {
|
|
61
|
+
model: REPLY_MODEL,
|
|
62
|
+
messages: [
|
|
63
|
+
{ role: 'system', content: system },
|
|
64
|
+
{ role: 'user', content: [{ type: 'text', text: prompt }, ...fileParts(files)] },
|
|
65
|
+
],
|
|
66
|
+
});
|
|
67
|
+
const text = typeof out.message.content === 'string' ? out.message.content.trim() : '';
|
|
68
|
+
if (!text) throw new ProviderError('provider_empty', 'The AI provider returned no reply.');
|
|
69
|
+
return { text, costMicro: out.costMicro };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Images drawn from the brief. Returns { images: [{ content_type, base64 }], costMicro }.
|
|
73
|
+
async function images({ fetchImpl, key, system, brief, files }) {
|
|
74
|
+
const out = await post(fetchImpl, key, {
|
|
75
|
+
model: IMAGE_MODEL,
|
|
76
|
+
modalities: ['image', 'text'],
|
|
77
|
+
messages: [
|
|
78
|
+
{ role: 'system', content: system },
|
|
79
|
+
{ role: 'user', content: [{ type: 'text', text: brief }, ...fileParts((files || []).filter((f) => f.content_type !== 'application/pdf'))] },
|
|
80
|
+
],
|
|
81
|
+
});
|
|
82
|
+
const list = (out.message.images || [])
|
|
83
|
+
.map((i) => /^data:(image\/(?:png|jpeg|webp));base64,(.+)$/.exec((i.image_url && i.image_url.url) || ''))
|
|
84
|
+
.filter(Boolean)
|
|
85
|
+
.map((m) => ({ content_type: m[1], base64: m[2] }));
|
|
86
|
+
if (!list.length) throw new ProviderError('provider_no_images', 'The AI provider returned no images.');
|
|
87
|
+
return { images: list, costMicro: out.costMicro };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
module.exports = { ENDPOINT, ProviderError, reply, images };
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// modules/ai-studio/payments.js — the payments port: where a builder's AI
|
|
2
|
+
// balance actually lives (task 1004920, goal 1000136, ADR 0392 decisions 2 and 7).
|
|
3
|
+
//
|
|
4
|
+
// Bongos holds no user money (ADR 0385). The balance is the PROCESSOR's; this
|
|
5
|
+
// port is how we read it and instruct holds against it. Two backends:
|
|
6
|
+
//
|
|
7
|
+
// 'none' (the default) payments are not set up: no balance, no top-up, and a
|
|
8
|
+
// paid generation is refused. A builder's own key still works.
|
|
9
|
+
// 'fake' (test mode) an in-memory stand-in for the processor, chosen only by
|
|
10
|
+
// AI_STUDIO_PAYMENTS=fake. Deterministic, so tests and the
|
|
11
|
+
// hall preview can exercise every path with no card.
|
|
12
|
+
//
|
|
13
|
+
// A real processor adapter joins this table once the owner supplies its keys in
|
|
14
|
+
// Settings; nothing outside this file changes when it does.
|
|
15
|
+
//
|
|
16
|
+
// Every amount is integer micro-dollars (pricing.js).
|
|
17
|
+
|
|
18
|
+
'use strict';
|
|
19
|
+
|
|
20
|
+
const crypto = require('node:crypto');
|
|
21
|
+
|
|
22
|
+
// ---- the 'fake' backend -------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
function createFakeBackend() {
|
|
25
|
+
const accounts = new Map(); // builderId -> { available, held }
|
|
26
|
+
const holds = new Map(); // holdId -> { builderId, amount, state }
|
|
27
|
+
const acct = (id) => {
|
|
28
|
+
const k = String(id);
|
|
29
|
+
if (!accounts.has(k)) accounts.set(k, { available: 0, held: 0 });
|
|
30
|
+
return accounts.get(k);
|
|
31
|
+
};
|
|
32
|
+
const ref = (p) => `fake_${p}_${crypto.randomBytes(6).toString('hex')}`;
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
name: 'fake',
|
|
36
|
+
testMode: true,
|
|
37
|
+
async balance(builderId) {
|
|
38
|
+
const a = acct(builderId);
|
|
39
|
+
return { available_micro: a.available, held_micro: a.held };
|
|
40
|
+
},
|
|
41
|
+
// A card payment of chargeMicro that credits creditedMicro to the balance.
|
|
42
|
+
async topUp(builderId, { chargeMicro, creditedMicro }) {
|
|
43
|
+
if (!(chargeMicro >= creditedMicro)) throw new Error('a top-up charge must cover what it credits');
|
|
44
|
+
acct(builderId).available += creditedMicro;
|
|
45
|
+
return { ref: ref('pay') };
|
|
46
|
+
},
|
|
47
|
+
async hold(builderId, amountMicro) {
|
|
48
|
+
const a = acct(builderId);
|
|
49
|
+
if (a.available < amountMicro) return { ok: false, reason: 'insufficient_funds', available_micro: a.available };
|
|
50
|
+
a.available -= amountMicro;
|
|
51
|
+
a.held += amountMicro;
|
|
52
|
+
const holdId = ref('hold');
|
|
53
|
+
holds.set(holdId, { builderId: String(builderId), amount: amountMicro, state: 'held' });
|
|
54
|
+
return { ok: true, holdId };
|
|
55
|
+
},
|
|
56
|
+
// Take amountMicro (at most the hold) and return the rest to the balance.
|
|
57
|
+
async capture(holdId, amountMicro) {
|
|
58
|
+
const h = holds.get(holdId);
|
|
59
|
+
if (!h || h.state !== 'held') throw new Error('no open hold');
|
|
60
|
+
if (amountMicro > h.amount) throw new Error('cannot capture more than was held');
|
|
61
|
+
const a = acct(h.builderId);
|
|
62
|
+
a.held -= h.amount;
|
|
63
|
+
a.available += h.amount - amountMicro;
|
|
64
|
+
h.state = 'captured';
|
|
65
|
+
return { captured_micro: amountMicro, released_micro: h.amount - amountMicro };
|
|
66
|
+
},
|
|
67
|
+
async release(holdId) {
|
|
68
|
+
const h = holds.get(holdId);
|
|
69
|
+
if (!h || h.state !== 'held') return { released_micro: 0 };
|
|
70
|
+
const a = acct(h.builderId);
|
|
71
|
+
a.held -= h.amount;
|
|
72
|
+
a.available += h.amount;
|
|
73
|
+
h.state = 'released';
|
|
74
|
+
return { released_micro: h.amount };
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ---- the 'none' backend -------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
const NONE = Object.freeze({
|
|
82
|
+
name: 'none',
|
|
83
|
+
testMode: false,
|
|
84
|
+
async balance() { return null; },
|
|
85
|
+
async topUp() { throw Object.assign(new Error('payments are not set up'), { code: 'payments_not_configured' }); },
|
|
86
|
+
async hold() { return { ok: false, reason: 'payments_not_configured' }; },
|
|
87
|
+
async capture() { throw new Error('payments are not set up'); },
|
|
88
|
+
async release() { return { released_micro: 0 }; },
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
let current = null;
|
|
92
|
+
|
|
93
|
+
// The backend this instance runs on. AI_STUDIO_PAYMENTS names it; anything
|
|
94
|
+
// unrecognised is 'none', so a typo never opens a free balance.
|
|
95
|
+
function backend() {
|
|
96
|
+
if (current) return current;
|
|
97
|
+
const want = String(process.env.AI_STUDIO_PAYMENTS || '').trim().toLowerCase();
|
|
98
|
+
current = want === 'fake' ? createFakeBackend() : NONE;
|
|
99
|
+
return current;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Tests swap in a fresh backend.
|
|
103
|
+
function _setBackend(b) { current = b; }
|
|
104
|
+
|
|
105
|
+
module.exports = { backend, createFakeBackend, NONE, _setBackend };
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// modules/ai-studio/pricing.js — what an AI generation costs a builder, and what
|
|
2
|
+
// a top-up charges their card (task 1004920, goal 1000136, ADR 0392).
|
|
3
|
+
//
|
|
4
|
+
// Every amount is an integer number of MICRO-DOLLARS (millionths of a dollar):
|
|
5
|
+
// a generation can cost a fraction of a cent, and floats lose money in rounding.
|
|
6
|
+
// The three numbers that set prices are named here and spelled nowhere else:
|
|
7
|
+
//
|
|
8
|
+
// AI_PRICE_MULTIPLIER provider cost x this = what the builder pays (owner,
|
|
9
|
+
// 2026-10-28: 1.08, an 8% markup on every call's real cost)
|
|
10
|
+
// PROCESSOR_PERCENT_BPS the card processor's percentage fee, in basis points
|
|
11
|
+
// PROCESSOR_FIXED_MICRO the card processor's fixed fee per payment
|
|
12
|
+
//
|
|
13
|
+
// The processor's fee is never taken out of the markup: a top-up is grossed up
|
|
14
|
+
// so the balance the builder asked for arrives whole (ADR 0392 decision 4).
|
|
15
|
+
//
|
|
16
|
+
// Pure: no DB, no network.
|
|
17
|
+
|
|
18
|
+
'use strict';
|
|
19
|
+
|
|
20
|
+
const MICRO_PER_USD = 1000000;
|
|
21
|
+
const MICRO_PER_CENT = 10000;
|
|
22
|
+
|
|
23
|
+
const AI_PRICE_MULTIPLIER = 1.08;
|
|
24
|
+
const PROCESSOR_PERCENT_BPS = 290; // 2.9%
|
|
25
|
+
const PROCESSOR_FIXED_MICRO = 300000; // $0.30
|
|
26
|
+
|
|
27
|
+
// Smallest and largest top-up a builder may ask for, so the fixed fee never
|
|
28
|
+
// dwarfs the balance and a typo cannot charge a fortune.
|
|
29
|
+
const MIN_TOPUP_MICRO = 5 * MICRO_PER_USD;
|
|
30
|
+
const MAX_TOPUP_MICRO = 500 * MICRO_PER_USD;
|
|
31
|
+
|
|
32
|
+
// The multiplier as an exact integer ratio (1.08 -> 10800 / 10000), so the
|
|
33
|
+
// arithmetic below never multiplies money by a float.
|
|
34
|
+
const MULTIPLIER_BPS = Math.round(AI_PRICE_MULTIPLIER * 10000);
|
|
35
|
+
|
|
36
|
+
function isMicro(n) { return Number.isSafeInteger(n) && n >= 0; }
|
|
37
|
+
|
|
38
|
+
// What a call that cost the provider `costMicro` costs the builder: the price,
|
|
39
|
+
// split into the provider's cost and our markup. Rounded UP to the micro-dollar,
|
|
40
|
+
// so the markup is never shaved below the multiplier.
|
|
41
|
+
function priceFor(costMicro) {
|
|
42
|
+
if (!isMicro(costMicro)) throw new TypeError('costMicro must be a non-negative integer');
|
|
43
|
+
const price = Math.ceil((costMicro * MULTIPLIER_BPS) / 10000);
|
|
44
|
+
return { provider_cost_micro: costMicro, markup_micro: price - costMicro, price_micro: price };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// The processor's fee on a card payment of `grossCents` (whole cents, as a card
|
|
48
|
+
// is charged), rounded to the cent the way processors round it.
|
|
49
|
+
function processorFeeCents(grossCents) {
|
|
50
|
+
return Math.round((grossCents * PROCESSOR_PERCENT_BPS) / 10000) + PROCESSOR_FIXED_MICRO / MICRO_PER_CENT;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// What to charge a card so that, after the processor's fee, `netMicro` reaches
|
|
54
|
+
// the builder's balance. Returns the charge, the fee and the credited amount
|
|
55
|
+
// (the net, rounded up to a whole cent). The smallest such charge is found by
|
|
56
|
+
// solving, then stepping a cent at a time past any rounding edge.
|
|
57
|
+
function topUpCharge(netMicro) {
|
|
58
|
+
if (!isMicro(netMicro)) throw new TypeError('netMicro must be a non-negative integer');
|
|
59
|
+
if (netMicro < MIN_TOPUP_MICRO) throw new RangeError(`a top-up is at least $${MIN_TOPUP_MICRO / MICRO_PER_USD}`);
|
|
60
|
+
if (netMicro > MAX_TOPUP_MICRO) throw new RangeError(`a top-up is at most $${MAX_TOPUP_MICRO / MICRO_PER_USD}`);
|
|
61
|
+
const netCents = Math.ceil(netMicro / MICRO_PER_CENT);
|
|
62
|
+
const fixedCents = PROCESSOR_FIXED_MICRO / MICRO_PER_CENT;
|
|
63
|
+
let gross = Math.ceil(((netCents + fixedCents) * 10000) / (10000 - PROCESSOR_PERCENT_BPS));
|
|
64
|
+
while (gross - processorFeeCents(gross) < netCents) gross += 1;
|
|
65
|
+
while ((gross - 1) - processorFeeCents(gross - 1) >= netCents) gross -= 1;
|
|
66
|
+
return {
|
|
67
|
+
charge_micro: gross * MICRO_PER_CENT,
|
|
68
|
+
processor_fee_micro: processorFeeCents(gross) * MICRO_PER_CENT,
|
|
69
|
+
credited_micro: netCents * MICRO_PER_CENT,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// "$0.0432" for a sub-cent amount, "$12.50" otherwise: a builder sees what a
|
|
74
|
+
// generation really cost, not a rounded zero.
|
|
75
|
+
function formatUsd(micro) {
|
|
76
|
+
const usd = micro / MICRO_PER_USD;
|
|
77
|
+
if (micro !== 0 && Math.abs(micro) < MICRO_PER_CENT) return `$${usd.toFixed(4)}`;
|
|
78
|
+
return `$${usd.toFixed(2)}`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
module.exports = {
|
|
82
|
+
AI_PRICE_MULTIPLIER,
|
|
83
|
+
PROCESSOR_PERCENT_BPS,
|
|
84
|
+
PROCESSOR_FIXED_MICRO,
|
|
85
|
+
MIN_TOPUP_MICRO,
|
|
86
|
+
MAX_TOPUP_MICRO,
|
|
87
|
+
MICRO_PER_USD,
|
|
88
|
+
priceFor,
|
|
89
|
+
processorFeeCents,
|
|
90
|
+
topUpCharge,
|
|
91
|
+
formatUsd,
|
|
92
|
+
};
|
|
@@ -1,15 +1,73 @@
|
|
|
1
|
-
// modules/ai-studio/routes/ai-studio.js — the AI studio's HTTP surface (
|
|
2
|
-
//
|
|
3
|
-
// provider-key, balance, submit and result routes beside this one.
|
|
1
|
+
// modules/ai-studio/routes/ai-studio.js — the AI studio's HTTP surface (goal
|
|
2
|
+
// 1000136, ADR 0392).
|
|
4
3
|
//
|
|
5
|
-
// GET
|
|
4
|
+
// GET /ai-studio/status is the module on
|
|
5
|
+
// GET /ai-studio/keys my provider keys: set or not, last four, when
|
|
6
|
+
// PUT /ai-studio/keys/:provider save or replace my key { key }
|
|
7
|
+
// DELETE /ai-studio/keys/:provider remove my key
|
|
8
|
+
// GET /ai-studio/balance my balance (read from the processor) + my recent ledger
|
|
9
|
+
// GET /ai-studio/topup/quote what a top-up would charge the card ?amount_usd=
|
|
10
|
+
// POST /ai-studio/topup add to my balance { amount_usd }
|
|
11
|
+
// POST /ai-studio/uploads upload one sketch / image / PDF { name, content_type, data }
|
|
12
|
+
// POST /ai-studio/requests send a request on my uploads { prompt, file_ids }
|
|
13
|
+
// GET /ai-studio/requests my recent requests
|
|
14
|
+
// GET /ai-studio/requests/:id one request: reply, images, price
|
|
15
|
+
// GET /ai-studio/files/:id one of my files (upload or output)
|
|
6
16
|
//
|
|
7
|
-
//
|
|
17
|
+
// A request answers 202 at once and runs in the background; the page polls
|
|
18
|
+
// GET /ai-studio/requests/:id until it is done, failed or refused.
|
|
19
|
+
//
|
|
20
|
+
// rank: builder — every route needs a signed-in builder, and a builder only
|
|
21
|
+
// ever touches their own keys. A key arrives here only from the Settings page;
|
|
22
|
+
// no route returns it, and no log line carries it or the request body.
|
|
8
23
|
|
|
9
24
|
'use strict';
|
|
10
25
|
|
|
11
26
|
const express = require('express');
|
|
12
27
|
const api = require('../../../src/module-api');
|
|
28
|
+
const K = require('../keys');
|
|
29
|
+
const db = require('../db');
|
|
30
|
+
const P = require('../pricing');
|
|
31
|
+
const payments = require('../payments');
|
|
32
|
+
const S = require('../studio');
|
|
33
|
+
const openrouter = require('../openrouter');
|
|
34
|
+
const gemini = require('../gemini');
|
|
35
|
+
const { runOnOurKey, runOnOwnKey } = require('../generate');
|
|
36
|
+
const { projectContext } = require('../context');
|
|
37
|
+
|
|
38
|
+
const log = api.logger('ai-studio');
|
|
39
|
+
|
|
40
|
+
const ID_RE = /^\d{1,18}$/;
|
|
41
|
+
const FILE_URL_PREFIX = '/api/bongos/ai-studio/files/';
|
|
42
|
+
const CLIENTS = { openrouter, gemini };
|
|
43
|
+
|
|
44
|
+
// Runs one request in the background, on the key `choice` names. Our key is
|
|
45
|
+
// the server's own OPENROUTER_API_KEY; without it a request is refused with a
|
|
46
|
+
// pointer to Settings. A builder's own key is decrypted here, inside the run,
|
|
47
|
+
// and handed only to the provider call: it is never logged or stored again.
|
|
48
|
+
function runInBackground(request, choice) {
|
|
49
|
+
(async () => {
|
|
50
|
+
const files = (await db.requestUploads(request.id)).map(S.forProvider);
|
|
51
|
+
const context = await projectContext();
|
|
52
|
+
if (choice.source === 'own') {
|
|
53
|
+
let key;
|
|
54
|
+
try { key = api.secretBox.decrypt(await db.getKeyEnc(request.builder_id, choice.provider)); } catch (_) { key = null; }
|
|
55
|
+
if (!key) {
|
|
56
|
+
await db.finishRequest(request.id, { status: 'failed', error: 'Your saved key could not be read. Enter it again in Settings. Nothing was charged.' });
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
await runOnOwnKey(request, files, { db, providers: CLIENTS[choice.provider], fetchImpl: fetch, key, provider: choice.provider, context, log });
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
await runOnOurKey(request, files, {
|
|
63
|
+
db, payments: payments.backend(), providers: openrouter, fetchImpl: fetch,
|
|
64
|
+
ourKey: process.env.OPENROUTER_API_KEY || '', context, log,
|
|
65
|
+
});
|
|
66
|
+
})().catch((err) => {
|
|
67
|
+
log.error({ request_id: request.id, err: err.message }, 'ai-studio: request run crashed');
|
|
68
|
+
db.finishRequest(request.id, { status: 'failed', error: 'The generation could not run. You were not charged.' }).catch(() => {});
|
|
69
|
+
});
|
|
70
|
+
}
|
|
13
71
|
|
|
14
72
|
module.exports = function buildAiStudioRouter() {
|
|
15
73
|
const router = express.Router();
|
|
@@ -18,5 +76,177 @@ module.exports = function buildAiStudioRouter() {
|
|
|
18
76
|
res.json({ ok: true, module: 'ai-studio' });
|
|
19
77
|
}));
|
|
20
78
|
|
|
79
|
+
router.get('/ai-studio/keys', api.requireBuilder, api.asyncHandler('GET /ai-studio/keys', async (req, res) => {
|
|
80
|
+
const rows = await db.listKeyMeta(req.builder.id);
|
|
81
|
+
res.json({ storage_configured: api.secretBox.isConfigured(), keys: K.listPublic(rows) });
|
|
82
|
+
}));
|
|
83
|
+
|
|
84
|
+
router.put('/ai-studio/keys/:provider', api.requireBuilder, api.asyncHandler('PUT /ai-studio/keys/:provider', async (req, res) => {
|
|
85
|
+
const { provider } = req.params;
|
|
86
|
+
if (api.validateOrRespond(req, res, { key: { required: true, type: 'string', maxLength: K.MAX_KEY } })) return;
|
|
87
|
+
if (!K.isProvider(provider)) return res.fail('unknown_provider', { status: 404, message: 'No such AI provider.' });
|
|
88
|
+
if (!api.secretBox.isConfigured()) {
|
|
89
|
+
return res.fail('storage_not_configured', { status: 409, message: 'Key storage is not set up on this project yet.' });
|
|
90
|
+
}
|
|
91
|
+
const cleaned = K.cleanKey(req.body && req.body.key);
|
|
92
|
+
if (cleaned.error) return res.fail(cleaned.error, { status: 400, message: 'That does not look like a key. Paste the whole key, with no spaces.' });
|
|
93
|
+
const row = await db.setKey(req.builder.id, provider, api.secretBox.encrypt(cleaned.key), K.last4(cleaned.key));
|
|
94
|
+
log.info({ builder_id: req.builder.id, provider }, 'provider key saved');
|
|
95
|
+
res.json({ key: K.publicKey(provider, row) });
|
|
96
|
+
}));
|
|
97
|
+
|
|
98
|
+
router.delete('/ai-studio/keys/:provider', api.requireBuilder, api.asyncHandler('DELETE /ai-studio/keys/:provider', async (req, res) => {
|
|
99
|
+
const { provider } = req.params;
|
|
100
|
+
if (!K.isProvider(provider)) return res.fail('unknown_provider', { status: 404, message: 'No such AI provider.' });
|
|
101
|
+
const removed = await db.clearKey(req.builder.id, provider);
|
|
102
|
+
log.info({ builder_id: req.builder.id, provider, removed }, 'provider key removed');
|
|
103
|
+
res.json({ key: K.publicKey(provider, null), removed });
|
|
104
|
+
}));
|
|
105
|
+
|
|
106
|
+
router.get('/ai-studio/balance', api.requireBuilder, api.asyncHandler('GET /ai-studio/balance', async (req, res) => {
|
|
107
|
+
const b = payments.backend();
|
|
108
|
+
const [balance, ledger] = await Promise.all([b.balance(req.builder.id), db.recentLedger(req.builder.id, { limit: 50 })]);
|
|
109
|
+
res.json({
|
|
110
|
+
payments: b.name,
|
|
111
|
+
test_mode: !!b.testMode,
|
|
112
|
+
price_multiplier: P.AI_PRICE_MULTIPLIER,
|
|
113
|
+
// What one generation may cost, so the Studio can say how much is set
|
|
114
|
+
// aside before a run without spelling the number itself (task 1004921).
|
|
115
|
+
max_cost_micro: S.MAX_COST_MICRO,
|
|
116
|
+
balance,
|
|
117
|
+
ledger: ledger.map(publicLedgerRow),
|
|
118
|
+
});
|
|
119
|
+
}));
|
|
120
|
+
|
|
121
|
+
// What a top-up of amount_usd would charge the card, before anything is paid
|
|
122
|
+
// (task 1004921): the Studio shows "your card is charged $20.91" from this, so
|
|
123
|
+
// the processor's fee stays spelled only in pricing.js. Writes nothing.
|
|
124
|
+
router.get('/ai-studio/topup/quote', api.requireBuilder, api.asyncHandler('GET /ai-studio/topup/quote', async (req, res) => {
|
|
125
|
+
const raw = String(req.query.amount_usd || '');
|
|
126
|
+
if (!/^\d{1,4}(\.\d{1,2})?$/.test(raw)) {
|
|
127
|
+
return res.fail('bad_amount', { status: 400, message: `Top up between ${P.MIN_TOPUP_MICRO / P.MICRO_PER_USD} and ${P.MAX_TOPUP_MICRO / P.MICRO_PER_USD}.` });
|
|
128
|
+
}
|
|
129
|
+
const usd = Number(raw);
|
|
130
|
+
let quote;
|
|
131
|
+
try { quote = P.topUpCharge(Math.round(usd * P.MICRO_PER_USD)); } catch (err) {
|
|
132
|
+
return res.fail('bad_amount', { status: 400, message: `Top up between ${P.MIN_TOPUP_MICRO / P.MICRO_PER_USD} and ${P.MAX_TOPUP_MICRO / P.MICRO_PER_USD}.` });
|
|
133
|
+
}
|
|
134
|
+
res.json({ quote, min_micro: P.MIN_TOPUP_MICRO, max_micro: P.MAX_TOPUP_MICRO });
|
|
135
|
+
}));
|
|
136
|
+
|
|
137
|
+
// Top up the processor-held balance. The card is charged enough that the
|
|
138
|
+
// amount asked for arrives whole after the processor's fee (ADR 0392 d.4).
|
|
139
|
+
router.post('/ai-studio/topup', api.requireBuilder, api.asyncHandler('POST /ai-studio/topup', async (req, res) => {
|
|
140
|
+
if (api.validateOrRespond(req, res, { amount_usd: { required: true, type: 'number' } })) return;
|
|
141
|
+
const b = payments.backend();
|
|
142
|
+
if (b.name === 'none') return res.fail('payments_not_configured', { status: 409, message: 'Paying for generations is not set up here yet.' });
|
|
143
|
+
let quote;
|
|
144
|
+
try { quote = P.topUpCharge(Math.round(req.body.amount_usd * P.MICRO_PER_USD)); } catch (err) {
|
|
145
|
+
return res.fail('bad_amount', { status: 400, message: `Top up between ${P.MIN_TOPUP_MICRO / P.MICRO_PER_USD} and ${P.MAX_TOPUP_MICRO / P.MICRO_PER_USD}.` });
|
|
146
|
+
}
|
|
147
|
+
const paid = await b.topUp(req.builder.id, { chargeMicro: quote.charge_micro, creditedMicro: quote.credited_micro });
|
|
148
|
+
await db.appendLedger({ builderId: req.builder.id, kind: 'topup', amountMicro: quote.credited_micro, processorFeeMicro: quote.processor_fee_micro, backend: b.name, ref: paid.ref, note: `card charged ${P.formatUsd(quote.charge_micro)}` });
|
|
149
|
+
log.info({ builder_id: req.builder.id, credited_micro: quote.credited_micro, backend: b.name }, 'ai balance topped up');
|
|
150
|
+
res.status(201).json({ topup: quote, balance: await b.balance(req.builder.id) });
|
|
151
|
+
}));
|
|
152
|
+
|
|
153
|
+
// One file per call, so a request body stays small; the global 64 KB parser
|
|
154
|
+
// skips this path (src/bongos/routes.js) and this parser takes it.
|
|
155
|
+
router.post('/ai-studio/uploads', api.requireBuilder, express.json({ limit: S.UPLOAD_BODY_LIMIT }), api.asyncHandler('POST /ai-studio/uploads', async (req, res) => {
|
|
156
|
+
if (api.validateOrRespond(req, res, {
|
|
157
|
+
content_type: { required: true, type: 'string', maxLength: 40 },
|
|
158
|
+
data: { required: true, type: 'string', maxLength: 12 * 1024 * 1024 },
|
|
159
|
+
name: { type: 'string', maxLength: 400 },
|
|
160
|
+
})) return;
|
|
161
|
+
const checked = S.checkFile(req.body);
|
|
162
|
+
if (checked.error) return res.fail(checked.error, { status: 400, message: S.MESSAGES[checked.error] });
|
|
163
|
+
const row = await db.addUpload(req.builder.id, checked.file);
|
|
164
|
+
res.status(201).json({ file: publicFile(row) });
|
|
165
|
+
}));
|
|
166
|
+
|
|
167
|
+
router.post('/ai-studio/requests', api.requireBuilder, api.asyncHandler('POST /ai-studio/requests', async (req, res) => {
|
|
168
|
+
if (api.validateOrRespond(req, res, {
|
|
169
|
+
prompt: { required: true, type: 'string', maxLength: S.MAX_PROMPT + 200 },
|
|
170
|
+
file_ids: { required: true, type: 'array', maxItems: S.MAX_FILES },
|
|
171
|
+
pay_with: { type: 'string', enum: ['own', 'ours'] },
|
|
172
|
+
})) return;
|
|
173
|
+
const checked = S.checkRequest(req.body);
|
|
174
|
+
if (checked.error) return res.fail(checked.error, { status: 400, message: S.MESSAGES[checked.error] });
|
|
175
|
+
const stored = (await db.listKeyMeta(req.builder.id)).map((k) => k.provider);
|
|
176
|
+
const choice = K.chooseKey(stored, req.body.pay_with);
|
|
177
|
+
if (choice.error) return res.fail(choice.error, { status: 409, message: 'You have no key of your own saved. Add one in Settings, or pay with your balance.' });
|
|
178
|
+
if (await db.countActiveRequests(req.builder.id) >= S.MAX_ACTIVE_REQUESTS) {
|
|
179
|
+
return res.fail('too_many_running', { status: 429, message: `You have ${S.MAX_ACTIVE_REQUESTS} requests running already. Wait for one to finish.` });
|
|
180
|
+
}
|
|
181
|
+
const request = await db.createRequest(req.builder.id, checked.prompt, checked.fileIds);
|
|
182
|
+
if (!request) return res.fail('file_not_found', { status: 404, message: 'One of those files is not yours, or was already sent with another request.' });
|
|
183
|
+
if (Number(request.attached) !== checked.fileIds.length) {
|
|
184
|
+
await db.finishRequest(request.id, { status: 'refused', error: 'One of those files was sent with another request at the same moment.' });
|
|
185
|
+
return res.fail('file_not_found', { status: 409, message: 'One of those files was already sent with another request.' });
|
|
186
|
+
}
|
|
187
|
+
log.info({ builder_id: req.builder.id, request_id: request.id, files: checked.fileIds.length, key_source: choice.source }, 'ai request queued');
|
|
188
|
+
runInBackground(request, choice);
|
|
189
|
+
res.status(202).json({ request: publicRequest({ ...request, files: [] }) });
|
|
190
|
+
}));
|
|
191
|
+
|
|
192
|
+
router.get('/ai-studio/requests', api.requireBuilder, api.asyncHandler('GET /ai-studio/requests', async (req, res) => {
|
|
193
|
+
const rows = await db.listRequests(req.builder.id, { limit: 30 });
|
|
194
|
+
res.json({ requests: rows.map((r) => publicRequest(r)) });
|
|
195
|
+
}));
|
|
196
|
+
|
|
197
|
+
router.get('/ai-studio/requests/:id', api.requireBuilder, api.asyncHandler('GET /ai-studio/requests/:id', async (req, res) => {
|
|
198
|
+
const id = String(req.params.id || '');
|
|
199
|
+
if (!ID_RE.test(id)) return res.fail('invalid_request_id', 400);
|
|
200
|
+
const row = await db.getRequest(req.builder.id, id);
|
|
201
|
+
if (!row) return res.fail('request_not_found', 404);
|
|
202
|
+
res.json({ request: publicRequest(row) });
|
|
203
|
+
}));
|
|
204
|
+
|
|
205
|
+
router.get('/ai-studio/files/:id', api.requireBuilder, api.asyncHandler('GET /ai-studio/files/:id', async (req, res) => {
|
|
206
|
+
const id = String(req.params.id || '');
|
|
207
|
+
if (!ID_RE.test(id)) return res.fail('invalid_file_id', 400);
|
|
208
|
+
const file = await db.getFile(req.builder.id, id);
|
|
209
|
+
if (!file) return res.fail('file_not_found', 404);
|
|
210
|
+
res.set('Content-Type', file.content_type);
|
|
211
|
+
res.set('X-Content-Type-Options', 'nosniff');
|
|
212
|
+
res.set('Content-Security-Policy', "default-src 'none'");
|
|
213
|
+
// Images show in the page; anything else (a PDF) downloads, so a document
|
|
214
|
+
// an artist uploaded never renders inside the hall's own origin.
|
|
215
|
+
const disposition = file.content_type.startsWith('image/') ? 'inline' : 'attachment';
|
|
216
|
+
res.set('Content-Disposition', `${disposition}; filename="${file.name}"`);
|
|
217
|
+
res.set('Cache-Control', 'private, max-age=3600');
|
|
218
|
+
res.send(file.bytes);
|
|
219
|
+
}));
|
|
220
|
+
|
|
21
221
|
return router;
|
|
22
222
|
};
|
|
223
|
+
|
|
224
|
+
function publicFile(f) {
|
|
225
|
+
return {
|
|
226
|
+
id: String(f.id), role: f.role, name: f.name, content_type: f.content_type,
|
|
227
|
+
size_bytes: Number(f.size_bytes) || 0, url: FILE_URL_PREFIX + f.id,
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// The request a builder sees: its status, the reply, the files, and the price
|
|
232
|
+
// with the provider's cost and our markup apart.
|
|
233
|
+
function publicRequest(r) {
|
|
234
|
+
const n = (v) => (v === null || v === undefined ? null : Number(v));
|
|
235
|
+
return {
|
|
236
|
+
id: String(r.id), prompt: r.prompt, status: r.status, key_source: r.key_source,
|
|
237
|
+
reply_md: r.reply_md || null, error: r.error || null,
|
|
238
|
+
provider_cost_micro: n(r.provider_cost_micro), markup_micro: n(r.markup_micro), price_micro: n(r.price_micro),
|
|
239
|
+
created_at: r.created_at, finished_at: r.finished_at || null,
|
|
240
|
+
files: (r.files || []).map(publicFile),
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// The ledger row a builder sees: every amount apart, nothing internal (no ref).
|
|
245
|
+
function publicLedgerRow(r) {
|
|
246
|
+
return {
|
|
247
|
+
id: String(r.id), kind: r.kind, request_id: r.request_id,
|
|
248
|
+
amount_micro: Number(r.amount_micro), provider_cost_micro: Number(r.provider_cost_micro),
|
|
249
|
+
markup_micro: Number(r.markup_micro), processor_fee_micro: Number(r.processor_fee_micro),
|
|
250
|
+
backend: r.backend, note: r.note, created_at: r.created_at,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// modules/ai-studio/studio.js — the pure half of a generation (task 1004917,
|
|
2
|
+
// goal 1000136, ADR 0392 decision 9): checking what the artist uploaded, and
|
|
3
|
+
// assembling the project context the AI is given on the SERVER, never from the
|
|
4
|
+
// browser.
|
|
5
|
+
//
|
|
6
|
+
// Uploads follow the page-notes rules (PNG, JPG, GIF, WebP, the declared type
|
|
7
|
+
// must match the file's own first bytes, SVG refused because it is executable),
|
|
8
|
+
// plus PDF for a documentation set. Video is not accepted yet: one clip costs
|
|
9
|
+
// more to send than a whole generation.
|
|
10
|
+
|
|
11
|
+
'use strict';
|
|
12
|
+
|
|
13
|
+
const MAX_FILES = 6;
|
|
14
|
+
const MAX_IMAGE_BYTES = 4 * 1024 * 1024;
|
|
15
|
+
const MAX_PDF_BYTES = 8 * 1024 * 1024;
|
|
16
|
+
const MAX_PROMPT = 4000;
|
|
17
|
+
const MIN_PROMPT = 3;
|
|
18
|
+
|
|
19
|
+
// The most one generation may cost the provider. The full price of this is held
|
|
20
|
+
// before the calls run; the real cost is captured after.
|
|
21
|
+
const MAX_COST_MICRO = 500000; // $0.50
|
|
22
|
+
|
|
23
|
+
// How many requests one builder may have queued or running at once, so a burst
|
|
24
|
+
// of clicks cannot pile up provider calls on any key.
|
|
25
|
+
const MAX_ACTIVE_REQUESTS = 2;
|
|
26
|
+
|
|
27
|
+
// How much of each context source the AI is given, so a long doc cannot crowd
|
|
28
|
+
// out the artist's own prompt.
|
|
29
|
+
const CONTEXT_BUDGET = { project: 4000, design: 6000 };
|
|
30
|
+
|
|
31
|
+
const SIGNATURES = {
|
|
32
|
+
'image/png': (b) => b.length > 8 && b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47,
|
|
33
|
+
'image/jpeg': (b) => b.length > 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff,
|
|
34
|
+
'image/gif': (b) => b.length > 6 && b.slice(0, 6).toString('latin1').startsWith('GIF8'),
|
|
35
|
+
'image/webp': (b) => b.length > 12 && b.slice(0, 4).toString('latin1') === 'RIFF' && b.slice(8, 12).toString('latin1') === 'WEBP',
|
|
36
|
+
'application/pdf': (b) => b.length > 5 && b.slice(0, 5).toString('latin1') === '%PDF-',
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
// The base64 a PDF at the size cap encodes to, plus headroom: the upload
|
|
40
|
+
// route's body parser limit.
|
|
41
|
+
const UPLOAD_BODY_LIMIT = '12mb';
|
|
42
|
+
|
|
43
|
+
// What each refusal says to the artist.
|
|
44
|
+
const MESSAGES = {
|
|
45
|
+
bad_file: 'That file could not be read. Try saving it again.',
|
|
46
|
+
unsupported_type: 'Send a PNG, JPG, GIF or WebP image, or a PDF.',
|
|
47
|
+
file_too_large: 'That file is too big: images up to 4 MB, PDFs up to 8 MB.',
|
|
48
|
+
type_mismatch: 'That file is not the kind of file its name says it is.',
|
|
49
|
+
prompt_required: 'Say what you would like, in a few words at least.',
|
|
50
|
+
prompt_too_long: `Keep the request under ${MAX_PROMPT} characters.`,
|
|
51
|
+
file_required: 'Add a sketch or an image to work from.',
|
|
52
|
+
too_many_files: `Send up to ${MAX_FILES} files at a time.`,
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
// Checks one uploaded file ({ name, content_type, data } with data in base64).
|
|
56
|
+
// Returns { file: { name, content_type, bytes } } or { error }.
|
|
57
|
+
function checkFile(f) {
|
|
58
|
+
if (!f || typeof f !== 'object') return { error: 'bad_file' };
|
|
59
|
+
const type = String(f.content_type || '').toLowerCase();
|
|
60
|
+
if (!SIGNATURES[type]) return { error: 'unsupported_type' };
|
|
61
|
+
if (typeof f.data !== 'string' || !f.data) return { error: 'bad_file' };
|
|
62
|
+
const bytes = Buffer.from(f.data, 'base64');
|
|
63
|
+
const max = type === 'application/pdf' ? MAX_PDF_BYTES : MAX_IMAGE_BYTES;
|
|
64
|
+
if (!bytes.length) return { error: 'bad_file' };
|
|
65
|
+
if (bytes.length > max) return { error: 'file_too_large' };
|
|
66
|
+
if (!SIGNATURES[type](bytes)) return { error: 'type_mismatch' };
|
|
67
|
+
const name = String(f.name || 'upload').replace(/[^\w.\- ]+/g, '_').slice(0, 120) || 'upload';
|
|
68
|
+
return { file: { name, content_type: type, bytes } };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Checks a request ({ prompt, file_ids }) once its files are uploaded.
|
|
72
|
+
// Returns { prompt, fileIds } or { error }.
|
|
73
|
+
function checkRequest(body) {
|
|
74
|
+
const prompt = typeof (body && body.prompt) === 'string' ? body.prompt.trim() : '';
|
|
75
|
+
if (prompt.length < MIN_PROMPT) return { error: 'prompt_required' };
|
|
76
|
+
if (prompt.length > MAX_PROMPT) return { error: 'prompt_too_long' };
|
|
77
|
+
const raw = Array.isArray(body.file_ids) ? body.file_ids : [];
|
|
78
|
+
if (!raw.length) return { error: 'file_required' };
|
|
79
|
+
if (raw.length > MAX_FILES) return { error: 'too_many_files' };
|
|
80
|
+
const fileIds = [...new Set(raw.map((x) => String(x)))];
|
|
81
|
+
if (!fileIds.every((x) => /^\d{1,18}$/.test(x))) return { error: 'bad_file' };
|
|
82
|
+
return { prompt, fileIds };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// A stored file as the provider calls want it.
|
|
86
|
+
function forProvider(row) {
|
|
87
|
+
return { name: row.name, content_type: row.content_type, base64: Buffer.from(row.bytes).toString('base64') };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function clip(text, n) {
|
|
91
|
+
const s = String(text || '').trim();
|
|
92
|
+
return s.length > n ? `${s.slice(0, n)}\n[…cut for length]` : s;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// The system prompt both calls share: who the project is, how it looks, and
|
|
96
|
+
// what the artist is asking for. Sources are read by the caller (so this stays
|
|
97
|
+
// pure): branding, the project context doc, and DESIGN.md.
|
|
98
|
+
function buildContext({ branding, projectDoc, designDoc }) {
|
|
99
|
+
const id = (branding && branding.identity) || {};
|
|
100
|
+
const palette = branding && branding.theme && branding.theme.colors ? JSON.stringify(branding.theme.colors).slice(0, 800) : '';
|
|
101
|
+
const parts = [
|
|
102
|
+
`You are the art director's assistant for ${id.productName || 'this project'}${id.worldName && id.worldName !== id.productName ? `, set in the world of ${id.worldName}` : ''}.`,
|
|
103
|
+
'An artist on the project has sent a sketch or reference images with a request. Answer as a thoughtful collaborator: say what you see, what works, and how to take it further, in plain words.',
|
|
104
|
+
'Keep everything you suggest consistent with the project as described below.',
|
|
105
|
+
];
|
|
106
|
+
if (palette) parts.push(`## Brand colours\n${palette}`);
|
|
107
|
+
if (projectDoc) parts.push(`## The project\n${clip(projectDoc, CONTEXT_BUDGET.project)}`);
|
|
108
|
+
if (designDoc) parts.push(`## The design system\n${clip(designDoc, CONTEXT_BUDGET.design)}`);
|
|
109
|
+
return parts.join('\n\n');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// What the image model is asked to draw: the artist's words first, then the
|
|
113
|
+
// reply's direction.
|
|
114
|
+
function imageBrief(prompt, replyText) {
|
|
115
|
+
return `Create finished artwork for this request from the project's artist. Follow the attached sketch's composition.\n\nThe artist asked:\n${prompt}\n\nArt direction:\n${clip(replyText, 1500)}`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
module.exports = {
|
|
119
|
+
MAX_FILES, MAX_IMAGE_BYTES, MAX_PROMPT, MAX_COST_MICRO, MAX_ACTIVE_REQUESTS, CONTEXT_BUDGET, UPLOAD_BODY_LIMIT, MESSAGES,
|
|
120
|
+
checkFile, checkRequest, forProvider, buildContext, imageBrief,
|
|
121
|
+
};
|