@bongos/core 1.24.14 → 1.24.16

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.
Files changed (165) hide show
  1. package/.bongos-core.json +308 -138
  2. package/.claude/skills/blocker-review/SKILL.md +2 -0
  3. package/.claude/skills/session-board/SKILL.md +29 -0
  4. package/.claude/skills/strand-fix/SKILL.md +1 -1
  5. package/clients/bongos-client/README.md +1 -1
  6. package/clients/bongos-client/bongos-client.global.js +14 -0
  7. package/clients/bongos-client/index.cjs +14 -0
  8. package/clients/bongos-client/index.d.ts +23 -2
  9. package/clients/bongos-client/index.mjs +14 -0
  10. package/docs/adr/0385-money-lives-with-a-processor-credits-stay-a-project-meter.md +4 -0
  11. package/docs/api/openapi.json +369 -6
  12. package/docs/api-reference.md +19 -4
  13. package/docs/architecture.md +6 -0
  14. package/docs/copy-inventory.md +231 -204
  15. package/docs/copy-registry.json +488 -255
  16. package/docs/file-map.md +1 -0
  17. package/docs/module-api-changelog.md +4 -2
  18. package/docs/page-inventory.json +7 -1
  19. package/docs/page-readings.json +1132 -1103
  20. package/migrations/core_289_task_short_name.sql +34 -0
  21. package/modules/ai-studio/db.js +10 -0
  22. package/modules/ai-studio/gemini.js +73 -5
  23. package/modules/ai-studio/openrouter.js +17 -2
  24. package/modules/ai-studio/routes/ai-studio.js +7 -1
  25. package/modules/ai-studio/studio.js +8 -0
  26. package/modules/builder-settings/checkout-freshness.js +71 -0
  27. package/modules/builder-settings/routes/settings.js +26 -0
  28. package/modules/builder-settings/settings.js +2 -0
  29. package/modules/economy/migrations/economy_002_project_account.sql +83 -0
  30. package/modules/economy/module.json +1 -1
  31. package/modules/economy/project-account.js +166 -0
  32. package/modules/economy/routes/project-account.js +40 -0
  33. package/modules/hall-ui/public/ai-studio.css +3 -0
  34. package/modules/hall-ui/public/ai-studio.html +2 -1
  35. package/modules/hall-ui/public/ai-studio.js +72 -1
  36. package/modules/hall-ui/public/atlas.html +1 -1
  37. package/modules/hall-ui/public/blockers.html +1 -1
  38. package/modules/hall-ui/public/board-room.html +1 -1
  39. package/modules/hall-ui/public/city.html +1 -1
  40. package/modules/hall-ui/public/collab.html +1 -1
  41. package/modules/hall-ui/public/commands.html +1 -1
  42. package/modules/hall-ui/public/copy-desk.html +1 -1
  43. package/modules/hall-ui/public/deploy.html +1 -1
  44. package/modules/hall-ui/public/diagrams.html +1 -1
  45. package/modules/hall-ui/public/drachmae.html +1 -1
  46. package/modules/hall-ui/public/fleet.html +1 -1
  47. package/modules/hall-ui/public/gate.html +1 -1
  48. package/modules/hall-ui/public/github-chip.js +85 -0
  49. package/modules/hall-ui/public/goal-map.html +1 -1
  50. package/modules/hall-ui/public/goals.html +1 -1
  51. package/modules/hall-ui/public/government.html +1 -1
  52. package/modules/hall-ui/public/idea.html +1 -1
  53. package/modules/hall-ui/public/ideas.html +1 -1
  54. package/modules/hall-ui/public/index.html +1 -1
  55. package/modules/hall-ui/public/market-watch-item.html +2 -2
  56. package/modules/hall-ui/public/market-watch-item.js +10 -3
  57. package/modules/hall-ui/public/market-watch-item.states.json +41 -0
  58. package/modules/hall-ui/public/market-watch-lib.js +36 -5
  59. package/modules/hall-ui/public/market-watch-rules.html +1 -1
  60. package/modules/hall-ui/public/market-watch-settings.html +1 -1
  61. package/modules/hall-ui/public/market-watch.css +5 -0
  62. package/modules/hall-ui/public/market-watch.html +2 -2
  63. package/modules/hall-ui/public/modules.html +1 -1
  64. package/modules/hall-ui/public/my-agents.html +1 -1
  65. package/modules/hall-ui/public/my-agents.js +3 -0
  66. package/modules/hall-ui/public/panel.css +5 -0
  67. package/modules/hall-ui/public/primer.html +1 -1
  68. package/modules/hall-ui/public/profile.html +1 -1
  69. package/modules/hall-ui/public/project-settings.html +1 -1
  70. package/modules/hall-ui/public/ranks.html +1 -1
  71. package/modules/hall-ui/public/roadmap.html +1 -1
  72. package/modules/hall-ui/public/roster.html +1 -1
  73. package/modules/hall-ui/public/sessions.html +1 -1
  74. package/modules/hall-ui/public/settings-age.js +65 -0
  75. package/modules/hall-ui/public/settings.html +27 -1
  76. package/modules/hall-ui/public/settings.js +1 -0
  77. package/modules/hall-ui/public/shell.js +22 -22
  78. package/modules/hall-ui/public/street.html +1 -1
  79. package/modules/hall-ui/public/studio.html +1 -1
  80. package/modules/hall-ui/public/style.css +28 -0
  81. package/modules/hall-ui/public/task.html +1 -1
  82. package/modules/hall-ui/public/thinking.html +1 -1
  83. package/modules/hall-ui/public/tweak-editor.html +1 -1
  84. package/modules/hall-ui/public/watch.html +1 -1
  85. package/modules/hall-ui/public/work.html +1 -1
  86. package/modules/ideas/blockers.js +13 -7
  87. package/modules/lifecycle/db-claim-reads.js +1 -1
  88. package/modules/lifecycle/db-ship.js +2 -2
  89. package/modules/lifecycle/db-tasks.js +4 -4
  90. package/modules/lifecycle/migrations/lifecycle_019_pay_type_gates.sql +50 -0
  91. package/modules/lifecycle/module.json +1 -0
  92. package/modules/lifecycle/pay-type-gates.js +180 -0
  93. package/modules/lifecycle/publish-reconciler.js +31 -1
  94. package/modules/lifecycle/rebase-gate.js +3 -2
  95. package/modules/lifecycle/routes/claims.js +3 -2
  96. package/modules/lifecycle/routes/pay-type-gates.js +59 -0
  97. package/modules/lifecycle/routes/project-settings.js +4 -0
  98. package/modules/lifecycle/routes/task-write-routes.js +43 -0
  99. package/modules/lifecycle/ship-card.js +4 -1
  100. package/modules/lifecycle/task-name-db.js +55 -0
  101. package/modules/lifecycle/task-name.js +140 -0
  102. package/modules/market-watch/db.js +10 -1
  103. package/modules/market-watch/migrations/market_watch_004_entry_corrections.sql +20 -0
  104. package/modules/market-watch/pages.js +20 -1
  105. package/modules/market-watch/proposals.js +57 -7
  106. package/modules/market-watch/routes/pages.js +2 -0
  107. package/modules/market-watch/routes/rules.js +4 -0
  108. package/modules/platform-identity/terms-acceptance.js +9 -9
  109. package/modules/public-landing/public/account-google.states.json +14 -0
  110. package/modules/public-landing/public/assets/cosmos.css +12 -0
  111. package/modules/public-landing/public/assets/hub-bar.js +44 -1
  112. package/modules/public-landing/public/privacy.html +5 -1
  113. package/modules/public-landing/public/projects-google.states.json +14 -0
  114. package/modules/public-landing/public/projects.html +39 -0
  115. package/modules/public-landing/public/terms.html +12 -2
  116. package/modules/ui-design/kit/fixtures/me-google.json +10 -0
  117. package/modules/ui-design/kit/fixtures/me-sign-in-methods-google.json +4 -0
  118. package/modules/ui-design/kit/serve.js +7 -1
  119. package/package-lock.json +2 -2
  120. package/package.json +1 -1
  121. package/release-notes.json +55 -1
  122. package/scripts/bongos/claim.js +4 -2
  123. package/scripts/bongos/land-watch.js +1 -0
  124. package/scripts/bongos/report-checkout.js +30 -0
  125. package/scripts/bongos/run-unit-tests.js +11 -0
  126. package/scripts/bongos/sessions-board.js +264 -0
  127. package/scripts/bongos/ship-finish.js +1 -0
  128. package/scripts/bongos/ship-flow.js +5 -2
  129. package/scripts/bongos/strand-lease.js +5 -3
  130. package/scripts/bongos/strand-watch.js +38 -7
  131. package/scripts/gds/report-checkout.js +7 -0
  132. package/scripts/gds/sessions-board.js +7 -0
  133. package/src/bongos/agent-standing-rules.js +13 -3
  134. package/src/bongos/routes/me.js +7 -1
  135. package/src/module-api.js +1 -1
  136. package/tests/agent_standing_rules.mjs +18 -1
  137. package/tests/ai_studio_billing.mjs +13 -0
  138. package/tests/ai_studio_gemini_models.mjs +81 -0
  139. package/tests/ai_studio_requests.mjs +24 -0
  140. package/tests/blocker_detail_hydration.mjs +5 -3
  141. package/tests/blocker_tasks_named_db.mjs +74 -0
  142. package/tests/claim_gate_ci_unblock.mjs +1 -1
  143. package/tests/github_chip.mjs +217 -0
  144. package/tests/hall_ai_studio.mjs +129 -5
  145. package/tests/host_topology_skips.mjs +1 -0
  146. package/tests/hub_account_page.mjs +3 -2
  147. package/tests/landing_legal_pages.mjs +3 -2
  148. package/tests/market_watch_data_db.mjs +6 -0
  149. package/tests/market_watch_pages.mjs +87 -2
  150. package/tests/market_watch_rules.mjs +101 -5
  151. package/tests/market_watch_rules_db.mjs +42 -0
  152. package/tests/nav_permission_atoms.mjs +1 -1
  153. package/tests/pay_terms.mjs +8 -0
  154. package/tests/pay_type_gates.mjs +234 -0
  155. package/tests/pay_type_gates_db.mjs +84 -0
  156. package/tests/project_account.mjs +125 -0
  157. package/tests/project_account_db.mjs +97 -0
  158. package/tests/self_healing_start.mjs +162 -0
  159. package/tests/sessions_board.mjs +153 -0
  160. package/tests/settings_age.mjs +110 -0
  161. package/tests/stop_refcheck.mjs +40 -0
  162. package/tests/task_name.mjs +237 -0
  163. package/tests/task_name_db.mjs +79 -0
  164. package/tests/terms_acceptance.mjs +25 -3
  165. package/tests/terms_grandfather_db.mjs +11 -4
@@ -0,0 +1,34 @@
1
+ -- core_289 — every task gets a short name of 2-3 plain words (task 1004958).
2
+ --
3
+ -- A session that mentions "task 1004745" tells the person nothing; "task 1004745 -
4
+ -- payout approvals" does. The name is stored so it is the same on every surface.
5
+ -- modules/lifecycle/task-name.js derives it from the title (createTask writes it on
6
+ -- every new task; the publish-reconciler's hourly backfill names the rows created
7
+ -- before this), and the task's author may change it through PATCH /tasks/:id.
8
+ --
9
+ -- The CHECK is the trust boundary, not decoration: startup notes inject text into
10
+ -- every session, so a name is held to at most three lowercase words of [a-z0-9-],
11
+ -- 40 characters in all — a topic and nothing else. task-name.js NAME_RE is the
12
+ -- same pattern; tests/task_name.mjs holds the two equal.
13
+ --
14
+ -- Additive and nullable with no default, so a rolled-back release that never names
15
+ -- the column keeps working, and a row not yet backfilled simply prints "task N".
16
+
17
+ BEGIN;
18
+
19
+ ALTER TABLE tasks ADD COLUMN IF NOT EXISTS short_name text;
20
+ ALTER TABLE tasks DROP CONSTRAINT IF EXISTS tasks_short_name_shape;
21
+ ALTER TABLE tasks ADD CONSTRAINT tasks_short_name_shape
22
+ CHECK (short_name IS NULL OR (
23
+ char_length(short_name) <= 40
24
+ AND short_name ~ '^[a-z0-9]+(-[a-z0-9]+)*( [a-z0-9]+(-[a-z0-9]+)*){0,2}$'
25
+ ));
26
+ -- The hourly backfill reads only unnamed rows. Once the first pass drains, that is a
27
+ -- handful of titles with no topic word, so a partial index keeps every later pass
28
+ -- a read of those few rows rather than a walk of the whole table.
29
+ CREATE INDEX IF NOT EXISTS tasks_short_name_missing_idx ON tasks (id) WHERE short_name IS NULL;
30
+
31
+ COMMIT;
32
+
33
+ INSERT INTO schema_migrations (version) VALUES ('core_289_task_short_name')
34
+ ON CONFLICT (version) DO NOTHING;
@@ -166,7 +166,17 @@ async function requestUploads(requestId, executor) {
166
166
  return rows;
167
167
  }
168
168
 
169
+ // ---- age (provider terms: AI providers serve adults only) -----------------
170
+
171
+ // The latest age tier recorded for the builder (payout gates' record), or null.
172
+ async function latestAgeTier(builderId, executor) {
173
+ const { rows } = await exec(executor)(
174
+ 'SELECT tier FROM payout_age_records WHERE builder_id = $1 ORDER BY id DESC LIMIT 1', [builderId]);
175
+ return rows[0] ? rows[0].tier : null;
176
+ }
177
+
169
178
  module.exports = {
179
+ latestAgeTier,
170
180
  listKeyMeta, getKeyEnc, setKey, clearKey, appendLedger, recentLedger,
171
181
  addUpload, createRequest, markRunning, finishRequest, addOutput, getRequest, listRequests, getFile, requestUploads, countActiveRequests,
172
182
  };
@@ -13,17 +13,71 @@
13
13
 
14
14
  'use strict';
15
15
 
16
+ const crypto = require('node:crypto');
16
17
  const { ProviderError } = require('./openrouter');
17
18
 
18
19
  const BASE = 'https://generativelanguage.googleapis.com/v1beta/models';
19
20
  const MODELS = { reply: 'gemini-2.5-pro', images: 'gemini-2.5-flash-image' };
20
21
  const TIMEOUT_MS = 120000;
22
+ const LIST_TIMEOUT_MS = 15000;
23
+
24
+ // Google retires models on short notice, and a retired one answers 404. When
25
+ // that happens we ask the key which models it can use and take the newest one
26
+ // of the same kind, so a retirement never breaks the studio. The pick is
27
+ // remembered per key (under a hash, never the key itself), because two keys
28
+ // can be allowed different models.
29
+ const resolved = new Map();
30
+ const pickSlot = (key, kind) => `${crypto.createHash('sha256').update(String(key)).digest('hex').slice(0, 16)}:${kind}`;
21
31
 
22
32
  function parts(text, files) {
23
33
  return [{ text }, ...(files || []).map((f) => ({ inline_data: { mime_type: f.content_type, data: f.base64 } }))];
24
34
  }
25
35
 
26
- async function post(fetchImpl, key, model, body) {
36
+ // Google's own reason, folded into the status: one line of printable text,
37
+ // anything shaped like a Google key blanked, at most 160 characters.
38
+ function googleReason(json) {
39
+ const m = json && json.error && typeof json.error.message === 'string' ? json.error.message : '';
40
+ const clean = m.replace(/AIza[\w-]{10,}/g, '[key]').replace(/[^\x20-\x7e]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 160);
41
+ return clean ? `: ${clean.replace(/\.$/, '')}` : '';
42
+ }
43
+
44
+ // Higher is better: stable over preview, newer version first.
45
+ function versionOf(name) {
46
+ const m = /gemini-(\d+)(?:\.(\d+))?/.exec(name);
47
+ return m ? Number(m[1]) * 100 + Number(m[2] || 0) : 0;
48
+ }
49
+
50
+ // Choose the best model of a kind from a ListModels answer.
51
+ function pickModel(kind, models) {
52
+ const usable = (models || []).filter((m) => m && typeof m.name === 'string'
53
+ && Array.isArray(m.supportedGenerationMethods) && m.supportedGenerationMethods.includes('generateContent'))
54
+ .map((m) => m.name.replace(/^models\//, ''))
55
+ .filter((n) => /^gemini-/.test(n));
56
+ const fits = kind === 'images'
57
+ ? usable.filter((n) => /image/.test(n))
58
+ : usable.filter((n) => /-(pro|flash)\b/.test(n) && !/(image|tts|audio|live|embed|lite|robotics|computer)/.test(n));
59
+ const score = (n) => (/preview|exp/.test(n) ? 0 : 100000)
60
+ + versionOf(n) * 10
61
+ + (kind === 'reply' && /-pro\b/.test(n) ? 2 : 0)
62
+ + (kind === 'images' && !/lite/.test(n) ? 1 : 0);
63
+ return fits.sort((a, b) => score(b) - score(a) || a.localeCompare(b))[0] || null;
64
+ }
65
+
66
+ async function listModels(fetchImpl, key) {
67
+ const ctl = new AbortController();
68
+ const timer = setTimeout(() => ctl.abort(), LIST_TIMEOUT_MS);
69
+ try {
70
+ const res = await fetchImpl(`${BASE}?pageSize=1000`, { method: 'GET', headers: { 'x-goog-api-key': key }, signal: ctl.signal });
71
+ const json = await res.json();
72
+ return res.ok && json && Array.isArray(json.models) ? json.models : [];
73
+ } catch (_) {
74
+ return [];
75
+ } finally {
76
+ clearTimeout(timer);
77
+ }
78
+ }
79
+
80
+ async function call(fetchImpl, key, model, body) {
27
81
  const ctl = new AbortController();
28
82
  const timer = setTimeout(() => ctl.abort(), TIMEOUT_MS);
29
83
  let res;
@@ -41,15 +95,29 @@ async function post(fetchImpl, key, model, body) {
41
95
  }
42
96
  let json = null;
43
97
  try { json = await res.json(); } catch (_) { /* fall through */ }
98
+ return { res, json };
99
+ }
100
+
101
+ async function post(fetchImpl, key, kind, body) {
102
+ const slot = pickSlot(key, kind);
103
+ const model = resolved.get(slot) || MODELS[kind];
104
+ let { res, json } = await call(fetchImpl, key, model, body);
105
+ if (res.status === 404) {
106
+ const next = pickModel(kind, await listModels(fetchImpl, key));
107
+ if (next && next !== model) {
108
+ ({ res, json } = await call(fetchImpl, key, next, body));
109
+ if (res.ok) resolved.set(slot, next);
110
+ }
111
+ }
44
112
  if (!res.ok || !json || json.error) {
45
- throw new ProviderError('provider_error', `Google refused the request (HTTP ${res.status}). Check the key in Settings.`);
113
+ throw new ProviderError('provider_error', `Google refused the request (HTTP ${res.status}${googleReason(json)}). Check the key in Settings.`);
46
114
  }
47
115
  const cand = json.candidates && json.candidates[0];
48
116
  return (cand && cand.content && Array.isArray(cand.content.parts)) ? cand.content.parts : [];
49
117
  }
50
118
 
51
119
  async function reply({ fetchImpl, key, system, prompt, files }) {
52
- const out = await post(fetchImpl, key, MODELS.reply, {
120
+ const out = await post(fetchImpl, key, 'reply', {
53
121
  systemInstruction: { parts: [{ text: system }] },
54
122
  contents: [{ role: 'user', parts: parts(prompt, files) }],
55
123
  });
@@ -59,7 +127,7 @@ async function reply({ fetchImpl, key, system, prompt, files }) {
59
127
  }
60
128
 
61
129
  async function images({ fetchImpl, key, system, brief, files }) {
62
- const out = await post(fetchImpl, key, MODELS.images, {
130
+ const out = await post(fetchImpl, key, 'images', {
63
131
  systemInstruction: { parts: [{ text: system }] },
64
132
  contents: [{ role: 'user', parts: parts(brief, (files || []).filter((f) => f.content_type !== 'application/pdf')) }],
65
133
  generationConfig: { responseModalities: ['TEXT', 'IMAGE'] },
@@ -72,4 +140,4 @@ async function images({ fetchImpl, key, system, brief, files }) {
72
140
  return { images: list, costMicro: null };
73
141
  }
74
142
 
75
- module.exports = { BASE, reply, images };
143
+ module.exports = { BASE, reply, images, pickModel, googleReason, _resolved: resolved };
@@ -17,6 +17,21 @@ const REPLY_MODEL = 'google/gemini-2.5-pro';
17
17
  const IMAGE_MODEL = 'google/gemini-2.5-flash-image';
18
18
  const TIMEOUT_MS = 120000;
19
19
 
20
+ // What OpenRouter may route a call to (provider terms review, 2026-10-30): never
21
+ // a provider that keeps or trains on prompts, only zero-retention endpoints, and
22
+ // optionally only an allow-list of providers whose terms we have checked.
23
+ // OPENROUTER_ZDR=off allow endpoints that are not zero-retention
24
+ // OPENROUTER_ALLOWED_PROVIDERS=a,b route only to these providers
25
+ // OPENROUTER_DISTILLABLE_ONLY=on only models whose makers allow training on outputs
26
+ function providerPolicy(env = process.env) {
27
+ const policy = { data_collection: 'deny' };
28
+ if (env.OPENROUTER_ZDR !== 'off') policy.zdr = true;
29
+ const only = String(env.OPENROUTER_ALLOWED_PROVIDERS || '').split(',').map((x) => x.trim()).filter(Boolean);
30
+ if (only.length) policy.only = only;
31
+ if (env.OPENROUTER_DISTILLABLE_ONLY === 'on') policy.enforce_distillable_text = true;
32
+ return policy;
33
+ }
34
+
20
35
  class ProviderError extends Error {
21
36
  constructor(code, message) { super(message); this.code = code; }
22
37
  }
@@ -36,7 +51,7 @@ async function post(fetchImpl, key, body) {
36
51
  res = await fetchImpl(ENDPOINT, {
37
52
  method: 'POST',
38
53
  headers: { authorization: `Bearer ${key}`, 'content-type': 'application/json', 'x-title': 'Cloud Bongos AI studio' },
39
- body: JSON.stringify({ ...body, usage: { include: true } }),
54
+ body: JSON.stringify({ ...body, provider: providerPolicy(), usage: { include: true } }),
40
55
  signal: ctl.signal,
41
56
  });
42
57
  } catch (err) {
@@ -87,4 +102,4 @@ async function images({ fetchImpl, key, system, brief, files }) {
87
102
  return { images: list, costMicro: out.costMicro };
88
103
  }
89
104
 
90
- module.exports = { ENDPOINT, ProviderError, reply, images };
105
+ module.exports = { ENDPOINT, ProviderError, providerPolicy, reply, images };
@@ -69,11 +69,13 @@ function runInBackground(request, choice) {
69
69
  });
70
70
  }
71
71
 
72
+ const ADULTS_ONLY = 'AI Studio is for people 18 and over. Record your age in Settings to use it.';
73
+
72
74
  module.exports = function buildAiStudioRouter() {
73
75
  const router = express.Router();
74
76
 
75
77
  router.get('/ai-studio/status', api.requireBuilder, api.asyncHandler('GET /ai-studio/status', async (req, res) => {
76
- res.json({ ok: true, module: 'ai-studio' });
78
+ res.json({ ok: true, module: 'ai-studio', terms: S.PROVIDER_TERMS, adult: S.isAdult(await db.latestAgeTier(req.builder.id)) });
77
79
  }));
78
80
 
79
81
  router.get('/ai-studio/keys', api.requireBuilder, api.asyncHandler('GET /ai-studio/keys', async (req, res) => {
@@ -138,6 +140,7 @@ module.exports = function buildAiStudioRouter() {
138
140
  // amount asked for arrives whole after the processor's fee (ADR 0392 d.4).
139
141
  router.post('/ai-studio/topup', api.requireBuilder, api.asyncHandler('POST /ai-studio/topup', async (req, res) => {
140
142
  if (api.validateOrRespond(req, res, { amount_usd: { required: true, type: 'number' } })) return;
143
+ if (!S.isAdult(await db.latestAgeTier(req.builder.id))) return res.fail('adults_only', { status: 403, message: ADULTS_ONLY });
141
144
  const b = payments.backend();
142
145
  if (b.name === 'none') return res.fail('payments_not_configured', { status: 409, message: 'Paying for generations is not set up here yet.' });
143
146
  let quote;
@@ -152,6 +155,8 @@ module.exports = function buildAiStudioRouter() {
152
155
 
153
156
  // One file per call, so a request body stays small; the global 64 KB parser
154
157
  // skips this path (src/bongos/routes.js) and this parser takes it.
158
+ // Not adults-gated on purpose: an upload is stored here and reaches no AI
159
+ // company until a request sends it, and /ai-studio/requests is gated.
155
160
  router.post('/ai-studio/uploads', api.requireBuilder, express.json({ limit: S.UPLOAD_BODY_LIMIT }), api.asyncHandler('POST /ai-studio/uploads', async (req, res) => {
156
161
  if (api.validateOrRespond(req, res, {
157
162
  content_type: { required: true, type: 'string', maxLength: 40 },
@@ -170,6 +175,7 @@ module.exports = function buildAiStudioRouter() {
170
175
  file_ids: { required: true, type: 'array', maxItems: S.MAX_FILES },
171
176
  pay_with: { type: 'string', enum: ['own', 'ours'] },
172
177
  })) return;
178
+ if (!S.isAdult(await db.latestAgeTier(req.builder.id))) return res.fail('adults_only', { status: 403, message: ADULTS_ONLY });
173
179
  const checked = S.checkRequest(req.body);
174
180
  if (checked.error) return res.fail(checked.error, { status: 400, message: S.MESSAGES[checked.error] });
175
181
  const stored = (await db.listKeyMeta(req.builder.id)).map((k) => k.provider);
@@ -115,7 +115,15 @@ function imageBrief(prompt, replyText) {
115
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
116
  }
117
117
 
118
+ // Shown in the Studio, and agreed to by sending a request (OpenRouter terms 5.1:
119
+ // the people we serve must follow each model maker's terms).
120
+ const PROVIDER_TERMS = 'AI Studio is for people 18 and over. By sending a request you agree to follow the terms and usage policies of the AI company whose model answers it (listed at openrouter.ai), and not to use the results to train a competing AI model.';
121
+
122
+ // The AI providers' terms admit adults only; no recorded age means no.
123
+ const isAdult = (tier) => tier === 'adult';
124
+
118
125
  module.exports = {
126
+ PROVIDER_TERMS, isAdult,
119
127
  MAX_FILES, MAX_IMAGE_BYTES, MAX_PROMPT, MAX_COST_MICRO, MAX_ACTIVE_REQUESTS, CONTEXT_BUDGET, UPLOAD_BODY_LIMIT, MESSAGES,
120
128
  checkFile, checkRequest, forProvider, buildContext, imageBrief,
121
129
  };
@@ -0,0 +1,71 @@
1
+ // modules/builder-settings/checkout-freshness.js — how far behind this builder's local
2
+ // project folder is, as their own session last reported it (task 1005021).
3
+ //
4
+ // WHY. A builder's hooks, CLAUDE.md and skills run from the folder on THEIR computer. When
5
+ // that folder falls behind, every session there runs old rules and nobody is told: the
6
+ // owner's sat 1,000 changes behind on a side branch for a week (task 1005015). Session
7
+ // start now heals the common case itself (.claude/hooks/session-start.js); this records
8
+ // what it found, so the server can say so when it could NOT heal — in the standing rules
9
+ // every Conductor relays, stale ones included, and in the hall.
10
+ //
11
+ // Stored in one reserved key of the `behavior_prefs` jsonb (migration 119), the
12
+ // core-move-seen.js way: no migration. Written only by the builder's own session.
13
+
14
+ const RESERVED_KEY = 'checkout_freshness';
15
+ // How far behind before it is worth a word. A day of merges is routine.
16
+ const BEHIND_WORTH_SAYING = 50;
17
+ // A report older than this says nothing about today's folder.
18
+ const REPORT_TTL_MS = 3 * 24 * 60 * 60 * 1000;
19
+ const REASONS = ['up-to-date', 'healed', 'fast-forwarded', 'dirty-tree', 'local-commits-ahead', 'detached-head', 'recent-edits', 'unmerged-branch', 'heal-failed', 'unknown'];
20
+ const BRANCH_RE = /^[A-Za-z0-9._\/-]{1,120}$/;
21
+
22
+ /** Validate a PATCH body { behind, branch, reason }. PURE. */
23
+ function validatePatch(body) {
24
+ if (body == null || typeof body !== 'object' || Array.isArray(body)) return { ok: false, errors: [{ error: 'body_must_be_object' }] };
25
+ const errors = [];
26
+ const behind = Number(body.behind);
27
+ if (!Number.isInteger(behind) || behind < 0 || behind > 1000000) errors.push({ key: 'behind', error: 'invalid_value' });
28
+ const branch = body.branch == null ? '' : String(body.branch);
29
+ if (branch && !BRANCH_RE.test(branch)) errors.push({ key: 'branch', error: 'invalid_value' });
30
+ const reason = String(body.reason || '');
31
+ if (!REASONS.includes(reason)) errors.push({ key: 'reason', error: 'invalid_value', allowed: REASONS });
32
+ const extra = Object.keys(body).filter((k) => !['behind', 'branch', 'reason'].includes(k));
33
+ for (const key of extra) errors.push({ key, error: 'unknown_key' });
34
+ if (errors.length) return { ok: false, errors };
35
+ return { ok: true, report: { behind, branch, reason } };
36
+ }
37
+
38
+ /** behavior_prefs with the report stamped; sibling keys untouched. PURE. */
39
+ function applyReport(existing, report, nowMs = Date.now()) {
40
+ return { ...(existing || {}), [RESERVED_KEY]: { ...report, at: new Date(nowMs).toISOString() } };
41
+ }
42
+
43
+ /**
44
+ * What GET /me says about the folder: the last report, whether it is behind enough to
45
+ * matter, and — when it is — one plain sentence for the person and one for their agent.
46
+ * null when nothing was ever reported, or the report is too old to mean anything. PURE.
47
+ */
48
+ function describe(behaviorPrefs, nowMs = Date.now()) {
49
+ const r = behaviorPrefs && behaviorPrefs[RESERVED_KEY];
50
+ if (!r || typeof r !== 'object' || !Number.isInteger(r.behind)) return null;
51
+ const at = Date.parse(r.at);
52
+ if (!Number.isFinite(at) || nowMs - at > REPORT_TTL_MS) return null;
53
+ const behind = r.behind >= BEHIND_WORTH_SAYING;
54
+ const out = { behind_by: r.behind, branch: r.branch || null, reason: r.reason, reported_at: r.at, behind };
55
+ if (behind) {
56
+ const why = {
57
+ 'dirty-tree': 'it has unsaved changes',
58
+ 'recent-edits': 'it has recent unsaved changes',
59
+ 'local-commits-ahead': 'it has work that is not merged yet',
60
+ 'unmerged-branch': 'it is on a branch with work that is not merged yet',
61
+ 'detached-head': 'it is not on a branch',
62
+ 'heal-failed': 'the automatic update failed',
63
+ }[r.reason] || 'it could not update itself';
64
+ out.line = `Your project folder on this computer is ${r.behind} changes behind, and ${why}, so your AI sessions are running older rules.`;
65
+ out.agent_line = `This person's main project folder is ${r.behind} changes behind main and could not update itself (${r.reason}). `
66
+ + 'Tell them once, in plain words, and offer to update it for them (save anything unsaved on a backup branch first).';
67
+ }
68
+ return out;
69
+ }
70
+
71
+ module.exports = { validatePatch, applyReport, describe };
@@ -31,6 +31,7 @@ const lawLocks = require('../law-locks');
31
31
  const renderPrefs = require('../render-prefs');
32
32
  const profileNudgePrefs = require('../profile-nudge-prefs');
33
33
  const coreMoveSeen = require('../core-move-seen');
34
+ const checkoutFreshness = require('../checkout-freshness'); // task 1005021
34
35
  const displayName = require('../display-name');
35
36
  const filterViews = require('../filter-views');
36
37
  // task 1003208: structured logging (pino via the doorway) — was console.*.
@@ -517,6 +518,31 @@ module.exports = function buildBuilderSettingsRouter() {
517
518
  }
518
519
  });
519
520
 
521
+ // How far behind this builder's local project folder is (task 1005021,
522
+ // checkout-freshness.js). Written by their own session start; read back by GET /me,
523
+ // which tells the person (and their agent) when the folder could not update itself.
524
+ // Body: { behind, branch, reason }.
525
+ //
526
+ // rank: any-builder — a builder's own report about their own computer; same posture
527
+ // as /me/core-move-seen.
528
+ router.patch('/me/checkout-freshness', api.requireBuilder, async (req, res) => {
529
+ if (api.validateOrRespond(req, res, {
530
+ behind: { required: true, type: 'number' },
531
+ branch: { type: 'string', maxLength: 120 },
532
+ reason: { required: true, type: 'string', maxLength: 40 },
533
+ }, { strict: true })) return undefined;
534
+ try {
535
+ const v = checkoutFreshness.validatePatch(req.body || {});
536
+ if (!v.ok) return res.fail('invalid_patch', 422, { errors: v.errors });
537
+ const existing = await db.getBuilderBehaviorPrefs(req.builder.id);
538
+ const saved = await db.setBuilderBehaviorPrefs(req.builder.id, checkoutFreshness.applyReport(existing, v.report));
539
+ res.json({ ok: true, checkout: checkoutFreshness.describe(saved) });
540
+ } catch (err) {
541
+ log.error('PATCH /me/checkout-freshness', err);
542
+ res.fail('write_failed', { status: 500, message: 'internal error' });
543
+ }
544
+ });
545
+
520
546
  // Per-builder SAVED FILTER VIEWS (task 1004553; shares the behavior_prefs jsonb /
521
547
  // migration 119). A view is a named set of filter values for one hall page. They
522
548
  // are PERSONAL: every route reads and writes only req.builder.id's own prefs, so
@@ -34,6 +34,7 @@ const renderPrefs = require('./render-prefs');
34
34
  const builderNeeds = require('./builder-needs');
35
35
  const lawLocks = require('./law-locks');
36
36
  const coreMoveSeen = require('./core-move-seen');
37
+ const checkoutFreshness = require('./checkout-freshness'); // task 1005021
37
38
 
38
39
  module.exports = {
39
40
  // --- the raw behavior_prefs blob read (GET /me resolves this once, then shapes
@@ -57,6 +58,7 @@ module.exports = {
57
58
  // task 1004731: the core_upgrades row id this builder has acknowledged (the hall's
58
59
  // one-time "Upgraded to core X" notice), from the behavior_prefs blob; null if none.
59
60
  coreMoveSeen: (behaviorPrefs) => coreMoveSeen.seenId(behaviorPrefs),
61
+ describeCheckout: (behaviorPrefs) => checkoutFreshness.describe(behaviorPrefs),
60
62
 
61
63
  // --- the per-builder model ceiling for a skill (cascade-dispatch clamps to it;
62
64
  // never exceeds it). Mirrors the former `skillPrefs.getForBuilder` call.
@@ -0,0 +1,83 @@
1
+ -- economy_002_project_account.sql — the append-only record of every movement on the
2
+ -- project's account, each one naming its cause (task 1003182, goal 1000092; ADR 0385
3
+ -- decision 1, ADR 0397 decision 2).
4
+ --
5
+ -- THE ACCOUNT IS NOT HERE. The processor holds the project's money in the project's
6
+ -- connected account (ADR 0397); Bongos holds none and has no balance column. What
7
+ -- this table is: our own record of what moved and why, so the owner can answer
8
+ -- "where did my money go" and so a daily check can compare it to the processor's
9
+ -- figure (a later task). A balance shown from this table is a sum of the record, and
10
+ -- is labelled as ours beside the processor's.
11
+ --
12
+ -- funding money in: a charge settled into the project's account
13
+ -- payout money out to a builder, for a task: names the payee, the task and
14
+ -- the processor transfer
15
+ -- payout_return a payout the processor returned or reversed: money back in
16
+ -- fee a processor or platform fee taken from the account
17
+ -- adjustment a correction: names the movement it corrects; never an edit
18
+ -- hold a task's estimate set aside against the balance (ADR 0386);
19
+ -- moves no money
20
+ -- hold_release a hold let go on ship or release; moves no money
21
+ --
22
+ -- amount_micro is a positive integer count of micro-dollars. The direction comes from
23
+ -- the kind (an adjustment carries its own direction), so a row cannot disagree with
24
+ -- itself about whether it added or took away.
25
+ --
26
+ -- THE CAUSE IS THE POINT. A payout without a payee and a task, a funding or fee
27
+ -- without a processor reference, or an adjustment that corrects nothing is refused by
28
+ -- the DATABASE, not by whichever code path wrote it. One processor reference books one
29
+ -- movement of its kind, so a retried payout cannot pay twice (the idempotency the
30
+ -- payout task builds on). Rows are never updated or deleted: a wrong figure is fixed
31
+ -- by a new row.
32
+
33
+ CREATE TABLE IF NOT EXISTS economy_project_account_movements (
34
+ id BIGSERIAL PRIMARY KEY,
35
+ kind TEXT NOT NULL,
36
+ direction TEXT NOT NULL,
37
+ amount_micro BIGINT NOT NULL,
38
+ builder_id BIGINT REFERENCES builders(id),
39
+ task_id BIGINT REFERENCES tasks(id),
40
+ corrects_id BIGINT REFERENCES economy_project_account_movements(id),
41
+ processor_ref TEXT,
42
+ actor_id BIGINT REFERENCES builders(id),
43
+ note TEXT NOT NULL DEFAULT '',
44
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
45
+ CONSTRAINT economy_project_account_kind_chk CHECK (kind IN
46
+ ('funding', 'payout', 'payout_return', 'fee', 'adjustment', 'hold', 'hold_release')),
47
+ CONSTRAINT economy_project_account_direction_chk CHECK (direction IN ('in', 'out', 'none')),
48
+ CONSTRAINT economy_project_account_amount_chk CHECK (amount_micro > 0),
49
+ CONSTRAINT economy_project_account_note_chk CHECK (length(note) <= 300),
50
+ -- the direction is fixed by the kind; only an adjustment chooses
51
+ CONSTRAINT economy_project_account_direction_by_kind_chk CHECK (
52
+ (kind IN ('funding', 'payout_return') AND direction = 'in') OR
53
+ (kind IN ('payout', 'fee') AND direction = 'out') OR
54
+ (kind IN ('hold', 'hold_release') AND direction = 'none') OR
55
+ (kind = 'adjustment' AND direction IN ('in', 'out'))),
56
+ -- every movement names its cause
57
+ CONSTRAINT economy_project_account_cause_chk CHECK (
58
+ (kind = 'payout' AND builder_id IS NOT NULL AND task_id IS NOT NULL AND processor_ref IS NOT NULL) OR
59
+ (kind = 'payout_return' AND builder_id IS NOT NULL AND processor_ref IS NOT NULL) OR
60
+ (kind IN ('funding', 'fee') AND processor_ref IS NOT NULL) OR
61
+ (kind = 'adjustment' AND corrects_id IS NOT NULL AND actor_id IS NOT NULL AND length(note) > 0) OR
62
+ (kind IN ('hold', 'hold_release') AND task_id IS NOT NULL))
63
+ );
64
+
65
+ -- one processor reference, one movement of that kind: a retried payout is a no-op
66
+ CREATE UNIQUE INDEX IF NOT EXISTS economy_project_account_one_per_ref
67
+ ON economy_project_account_movements (kind, processor_ref) WHERE processor_ref IS NOT NULL;
68
+ CREATE INDEX IF NOT EXISTS economy_project_account_by_time ON economy_project_account_movements (created_at DESC, id DESC);
69
+ CREATE INDEX IF NOT EXISTS economy_project_account_by_builder ON economy_project_account_movements (builder_id, created_at DESC) WHERE builder_id IS NOT NULL;
70
+ CREATE INDEX IF NOT EXISTS economy_project_account_by_task ON economy_project_account_movements (task_id) WHERE task_id IS NOT NULL;
71
+
72
+ CREATE OR REPLACE FUNCTION economy_project_account_movements_append_only() RETURNS trigger AS $$
73
+ BEGIN
74
+ RAISE EXCEPTION 'economy_project_account_movements is append-only: record a correction as a new adjustment row';
75
+ END;
76
+ $$ LANGUAGE plpgsql;
77
+
78
+ DROP TRIGGER IF EXISTS economy_project_account_movements_no_rewrite ON economy_project_account_movements;
79
+ CREATE TRIGGER economy_project_account_movements_no_rewrite BEFORE UPDATE OR DELETE ON economy_project_account_movements
80
+ FOR EACH ROW EXECUTE FUNCTION economy_project_account_movements_append_only();
81
+
82
+ INSERT INTO schema_migrations (version) VALUES ('economy_002_project_account')
83
+ ON CONFLICT DO NOTHING;
@@ -7,7 +7,7 @@
7
7
  "category": { "parent": "money" },
8
8
  "default": true,
9
9
  "maintenance": { "status": "core-maintained" },
10
- "contributes": { "routes": ["cost", "credits", "earnings", "leaderboard", "reward"] },
10
+ "contributes": { "migrations": true, "routes": ["cost", "credits", "earnings", "leaderboard", "project-account", "reward"] },
11
11
  "provides": ["reward"],
12
12
  "consumes": []
13
13
  }