@bongos/core 1.25.12 → 1.25.13

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 (31) hide show
  1. package/.bongos-core.json +65 -30
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +6 -0
  4. package/clients/bongos-client/index.cjs +6 -0
  5. package/clients/bongos-client/index.d.ts +11 -2
  6. package/clients/bongos-client/index.mjs +6 -0
  7. package/docs/api/openapi.json +192 -11
  8. package/docs/api-reference.md +5 -2
  9. package/docs/file-map.md +1 -1
  10. package/docs/module-api-changelog.md +2 -0
  11. package/modules/front-door-guide/claude.js +11 -7
  12. package/modules/front-door-guide/context.js +54 -0
  13. package/modules/front-door-guide/db.js +71 -6
  14. package/modules/front-door-guide/guide.js +10 -1
  15. package/modules/front-door-guide/migrations/front_door_guide_002_plan_and_account.sql +37 -0
  16. package/modules/front-door-guide/module.json +7 -3
  17. package/modules/front-door-guide/plan.js +93 -0
  18. package/modules/front-door-guide/pollers/retention-sweep.js +67 -0
  19. package/modules/front-door-guide/routes/front-door-guide-plan.js +144 -0
  20. package/modules/front-door-guide/routes/front-door-guide.js +19 -29
  21. package/modules/front-door-guide/settings.js +4 -0
  22. package/modules/provisioning/module.json +2 -1
  23. package/modules/provisioning/seams.js +10 -0
  24. package/package-lock.json +2 -2
  25. package/package.json +1 -1
  26. package/release-notes.json +6 -0
  27. package/scripts/bongos/run-unit-tests.js +4 -0
  28. package/src/bongos/module-scope-map.js +1 -0
  29. package/src/module-api.js +1 -1
  30. package/tests/front_door_guide_db.mjs +110 -0
  31. package/tests/front_door_guide_plan.mjs +344 -0
@@ -7,6 +7,9 @@
7
7
  // POST /front-door-guide/conversations/:id/messages send one message { token, text, website? }
8
8
  // GET /front-door-guide/conversations/:id read it back header x-guide-token
9
9
  //
10
+ // The plan, attaching a chat to an account at sign-up, and "my plan" are in
11
+ // routes/front-door-guide-plan.js (task 1005273).
12
+ //
10
13
  // rank: public — every route. The visitor has no account yet; that is the point. What
11
14
  // stands in for one: per-visitor rate limits, a bot check, a token per conversation, and
12
15
  // every money cap enforced here before a call is made (the per-visitor message count and
@@ -25,37 +28,16 @@ const db = require('../db');
25
28
  const claude = require('../claude');
26
29
  const { readSettings } = require('../settings');
27
30
  const L = require('../limits');
31
+ const C = require('../context');
32
+ const P = require('../plan');
28
33
 
29
34
  const log = api.logger('front-door-guide');
30
35
  const ID_RE = /^\d{1,18}$/;
31
36
  const TOKEN_RE = /^[A-Za-z0-9_-]{16,64}$/;
32
37
 
33
- function salt() {
34
- let s;
35
- try { s = api.resolveEnv('FRONT_DOOR_GUIDE_SALT'); } catch (_) { s = null; }
36
- if (s) return s;
37
- let name = 'bongos';
38
- try { name = api.instanceDbName() || name; } catch (_) { /* the default salt still separates visitors */ }
39
- return `front-door-guide:${name}`;
40
- }
41
-
42
- function productName() {
43
- try { return api.branding().identity.productName || 'Cloud Bongos'; } catch (_) { return 'Cloud Bongos'; }
44
- }
45
-
46
- // Can the site afford a call of `extraMicro` right now? → null, or the form reason.
47
- async function siteBudgetReason(settings, extraMicro, reservations) {
48
- const pending = reservations.siteMicro();
49
- // Spent is spent (>=), and a call must also fit in what is left (>).
50
- const over = (spent, cap) => spent + pending >= cap || spent + pending + extraMicro > cap;
51
- if (over(await db.siteSpendMicro('day'), settings.dailyBudgetMicro)) return 'daily_budget';
52
- if (over(await db.siteSpendMicro('month'), settings.monthlyBudgetMicro)) return 'monthly_budget';
53
- return null;
54
- }
55
-
56
38
  module.exports = function buildFrontDoorGuideRouter() {
57
39
  const router = express.Router();
58
- const reservations = L.makeReservations();
40
+ const { reservations, siteBudgetReason, salt, productName } = C;
59
41
  let current = readSettings();
60
42
  const knobs = () => current;
61
43
 
@@ -68,7 +50,7 @@ module.exports = function buildFrontDoorGuideRouter() {
68
50
  // rank: public — the landing asks before it offers the chat.
69
51
  router.get('/front-door-guide/status', fresh, statusLimit, api.asyncHandler('GET /front-door-guide/status', async (req, res) => {
70
52
  const s = knobs();
71
- const reason = !s.apiKey ? 'no_key' : await siteBudgetReason(s, 0, reservations);
53
+ const reason = !s.apiKey ? 'no_key' : await siteBudgetReason(s, 0);
72
54
  res.json({
73
55
  ok: true,
74
56
  mode: reason ? 'form' : 'chat',
@@ -86,7 +68,7 @@ module.exports = function buildFrontDoorGuideRouter() {
86
68
  return res.json(G.formAnswer('check_failed'));
87
69
  }
88
70
  if (!s.apiKey) return res.json(G.formAnswer('no_key'));
89
- const budget = await siteBudgetReason(s, 0, reservations);
71
+ const budget = await siteBudgetReason(s, 0);
90
72
  if (budget) return res.json(G.formAnswer(budget));
91
73
  const visitor = L.visitorHash(L.clientIp(req), salt());
92
74
  const used = await db.visitorUsage(visitor);
@@ -97,6 +79,7 @@ module.exports = function buildFrontDoorGuideRouter() {
97
79
  ok: true, mode: 'chat',
98
80
  conversation: { ...G.conversationView(conv, s), token },
99
81
  greeting: G.GREETING,
82
+ saved: G.savedNotice(s.retentionDays),
100
83
  });
101
84
  }));
102
85
 
@@ -122,7 +105,10 @@ module.exports = function buildFrontDoorGuideRouter() {
122
105
  const conv = await db.getConversation(req.params.id, L.tokenHash(body.token));
123
106
  if (!conv) return res.status(404).json({ ok: false, error: 'not_found' });
124
107
  const view = () => G.conversationView(conv, s);
125
- const ended = (reason) => ({ ok: true, mode: 'ended', reason, message: G.END_REASONS[reason] || G.END_REASONS.message_cap, conversation: view() });
108
+ const ended = (reason) => ({
109
+ ok: true, mode: 'ended', reason, message: G.END_REASONS[reason] || G.END_REASONS.message_cap,
110
+ next_step: reason === 'planned' ? 'sign_up' : 'plan', conversation: view(),
111
+ });
126
112
  if (conv.status === 'ended') return res.json(ended(conv.end_reason));
127
113
 
128
114
  const bot = G.botCheck({
@@ -148,7 +134,7 @@ module.exports = function buildFrontDoorGuideRouter() {
148
134
  await db.endConversation(conv.id, 'visitor_budget');
149
135
  return res.json(ended('visitor_budget'));
150
136
  }
151
- const budget = await siteBudgetReason(s, estimate, reservations);
137
+ const budget = await siteBudgetReason(s, estimate);
152
138
  if (budget) return res.json(G.formAnswer(budget));
153
139
 
154
140
  const release = reservations.take(visitor, estimate);
@@ -182,7 +168,7 @@ module.exports = function buildFrontDoorGuideRouter() {
182
168
  res.json({
183
169
  ok: true,
184
170
  mode: endReason ? 'ended' : 'chat',
185
- ...(endReason ? { reason: endReason, message: G.END_REASONS[endReason] } : {}),
171
+ ...(endReason ? { reason: endReason, message: G.END_REASONS[endReason], next_step: 'plan' } : {}),
186
172
  ...(refused ? { declined: true } : {}),
187
173
  reply: { text: reply },
188
174
  conversation: G.conversationView(saved || { ...conv, visitor_messages: Number(conv.visitor_messages) + 1 }, s),
@@ -199,6 +185,10 @@ module.exports = function buildFrontDoorGuideRouter() {
199
185
  ok: true,
200
186
  conversation: { ...G.conversationView(conv, knobs()), status: conv.status, end_reason: conv.end_reason || null },
201
187
  transcript: G.transcriptView(conv.messages),
188
+ plan: conv.plan || null,
189
+ ...(conv.plan ? { next: P.NEXT_STEP } : {}),
190
+ saved: G.savedNotice(knobs().retentionDays),
191
+ attached: conv.builder_id != null,
202
192
  });
203
193
  }));
204
194
 
@@ -25,6 +25,8 @@ const DEFAULTS = Object.freeze({
25
25
  STARTS_PER_HOUR: 5, // new conversations per visitor per hour
26
26
  MESSAGES_PER_MINUTE: 6, // messages per visitor per minute
27
27
  MIN_FIRST_MESSAGE_MS: 1500, // a person takes longer than this to type a first line
28
+ MAX_PLAN_TOKENS: 4000, // the plan, thinking included
29
+ RETENTION_DAYS: 30, // a chat nobody signed up for is deleted after this
28
30
  });
29
31
 
30
32
  const EFFORTS = new Set(['low', 'medium', 'high', 'xhigh', 'max']);
@@ -62,6 +64,8 @@ function readSettings({ resolve = defaultResolve } = {}) {
62
64
  startsPerHour: intKnob(read, 'STARTS_PER_HOUR'),
63
65
  messagesPerMinute: intKnob(read, 'MESSAGES_PER_MINUTE'),
64
66
  minFirstMessageMs: intKnob(read, 'MIN_FIRST_MESSAGE_MS'),
67
+ maxPlanTokens: intKnob(read, 'MAX_PLAN_TOKENS'),
68
+ retentionDays: intKnob(read, 'RETENTION_DAYS'),
65
69
  };
66
70
  }
67
71
 
@@ -39,7 +39,8 @@
39
39
  "provisioning.projectPlanets",
40
40
  "provisioning.projectOwnedBy",
41
41
  "provisioning.projectPublishIdentity",
42
- "provisioning.selfUpdateChannel"
42
+ "provisioning.selfUpdateChannel",
43
+ "provisioning.moduleCatalog"
43
44
  ],
44
45
  "consumes": []
45
46
  }
@@ -7,6 +7,7 @@ const api = require('../../src/module-api');
7
7
  const { pool } = api;
8
8
  const provisioning = require('./provisioning');
9
9
  const { selfUpdateChannel } = require('./core-upgrade');
10
+ const { ALWAYS_ON_CORE, optionalModules } = require('./starter-bundles');
10
11
 
11
12
  // The `provisioning.projectPublishIdentity` port (task 1003578, ADR 0247 D4): the name and
12
13
  // description the publish gate judges, for the control-plane project a hub catalog row was
@@ -44,6 +45,11 @@ async function projectPublishIdentity(db, { slug, host } = {}) {
44
45
  // question names both halves of the ADR 0246 projection.
45
46
  // • PROVIDES port `provisioning.projectPublishIdentity` ({ slug, host }) → { name,
46
47
  // description } | null — see projectPublishIdentity above.
48
+ // • PROVIDES port `provisioning.moduleCatalog` () → { core: [key], optional: [{ key,
49
+ // label, summary }] } — the modules a NEW project gets and may opt into, with the
50
+ // owner-facing words the creation picker shows (task 1005273). The start-a-project
51
+ // guide recommends only from this list, so its plan can never name a module the
52
+ // picker does not offer. Read-only; no database.
47
53
  function registerProvisioningSeams() {
48
54
  if (api.hasProvider('provisioning.instanceState')) return;
49
55
  api.registerProvider('provisioning.instanceState', (slug) => provisioning.instanceStateForSlug(pool, slug));
@@ -51,6 +57,10 @@ function registerProvisioningSeams() {
51
57
  api.registerProvider('provisioning.projectOwnedBy', (target) => provisioning.projectOwnedBy(pool, target));
52
58
  api.registerProvider('provisioning.projectPublishIdentity', (target) => projectPublishIdentity(pool, target));
53
59
  api.registerProvider('provisioning.selfUpdateChannel', () => selfUpdateChannel(pool));
60
+ api.registerProvider('provisioning.moduleCatalog', () => ({
61
+ core: [...ALWAYS_ON_CORE],
62
+ optional: optionalModules().map(({ key, label, summary }) => ({ key, label, summary })),
63
+ }));
54
64
  }
55
65
 
56
66
  module.exports = { registerProvisioningSeams, projectPublishIdentity };
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.25.12",
3
+ "version": "1.25.13",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.25.12",
9
+ "version": "1.25.13",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.25.12",
3
+ "version": "1.25.13",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -11782,5 +11782,11 @@
11782
11782
  "id": "1005288",
11783
11783
  "text": "Decided how every project gets a sandbox: a free copy of the app that always shows the newest finished work, while the real site only changes when a person presses promote. Fake data and test keys by default. Costs the owner n"
11784
11784
  }
11785
+ ],
11786
+ "1.25.13": [
11787
+ {
11788
+ "id": "1005273",
11789
+ "text": "The project guide now ends with a written plan (with recommended features drawn only from what Bongos really offers), saves the chat so visitors can come back to it, moves it onto their account when they sign up, and deletes i"
11790
+ }
11785
11791
  ]
11786
11792
  }
@@ -100,6 +100,10 @@ const INTEGRATION = new Set([
100
100
  // task 1004941: a rules proposal round-trips to a new version the weekly run's loader
101
101
  // reads, on real SQL (the one-active index, the CHECKs). DB-free sibling: market_watch_rules.
102
102
  'market_watch_rules_db',
103
+ // tasks 1005272/1005273: the start-a-project guide's SQL on a real engine: the migrations,
104
+ // the one-account attach rule, and the retention sweep keeping spend on the books.
105
+ // DB-free siblings: front_door_guide, front_door_guide_plan.
106
+ 'front_door_guide_db',
103
107
  // task 1004940: the weekly ask's SQL store — once per week by constraint, one pooled
104
108
  // proposal per similar answer, the project guard on an answer. DB-free sibling:
105
109
  // market_watch_ask.
@@ -190,6 +190,7 @@ const MODULE_GLOBS = {
190
190
  // signed-out visitor. Its test sits at the repo root, listed by exact path.
191
191
  'front-door-guide': [
192
192
  'modules/front-door-guide/', 'tests/front_door_guide.mjs',
193
+ 'tests/front_door_guide_plan.mjs', 'tests/front_door_guide_db.mjs',
193
194
  ],
194
195
  // The /raise investor page (task 1004847, ADR 0384): hub-only.
195
196
  raise: [
package/src/module-api.js CHANGED
@@ -81,7 +81,7 @@ const { sleep } = require('./sleep');
81
81
  // MAJOR (see allowBoxScope below): passes the request through untouched.
82
82
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
83
83
 
84
- const CORE_VERSION = '1.25.12'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
84
+ const CORE_VERSION = '1.25.13'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
85
85
 
86
86
  // A namespaced logger so a module's log lines are attributable + consistent.
87
87
  // Usage: const log = api.logger('discord'); log.info('mounted');
@@ -0,0 +1,110 @@
1
+ // tests/front_door_guide_db.mjs — the start-a-project guide's SQL against a REAL Postgres
2
+ // (tasks 1005272, 1005273).
3
+ //
4
+ // What only a real engine can say: both migrations apply, and apply again as no-ops; a
5
+ // turn and a plan land in one statement each; only replies count as a visitor's messages;
6
+ // a chat attaches to one account and is never moved to another; and the retention sweep
7
+ // deletes an abandoned chat after the set number of days while its spend stays in the
8
+ // daily and monthly sums.
9
+ //
10
+ // The module is default:false, so CI's migrate.sh does not apply its migrations; this
11
+ // file applies them itself. Rows use a visitor hash and builder ids no real instance has,
12
+ // and are deleted before and after. Self-skips without a reachable Postgres; in the
13
+ // INTEGRATION set (scripts/bongos/run-unit-tests.js) so the DB-free lane never runs it.
14
+ //
15
+ // Run: DATABASE_URL=... node tests/front_door_guide_db.mjs
16
+
17
+ import { createRequire } from 'node:module';
18
+ import { readFileSync } from 'node:fs';
19
+ import { strict as assert } from 'node:assert';
20
+ import { insertTestBuilders } from './helpers.mjs';
21
+
22
+ const require = createRequire(import.meta.url);
23
+ const { Pool } = require('pg');
24
+ const db = require('../modules/front-door-guide/db.js');
25
+ const sql = (n) => readFileSync(new URL(`../modules/front-door-guide/migrations/${n}`, import.meta.url), 'utf8');
26
+ const M1 = sql('front_door_guide_001_conversations.sql');
27
+ const M2 = sql('front_door_guide_002_plan_and_account.sql');
28
+
29
+ const pool = new Pool({ connectionString: process.env.DATABASE_URL || undefined });
30
+ try {
31
+ await pool.query('SELECT 1');
32
+ } catch (e) {
33
+ console.log(`front_door_guide_db.mjs SKIPPED — no reachable Postgres (${e.code || e.message}).`);
34
+ await pool.end().catch(() => {});
35
+ process.exit(0);
36
+ }
37
+
38
+ const x = (text, params) => pool.query(text, params);
39
+ const V = 't1005273-visitor';
40
+ const ALICE = 91005273, BOB = 91005274;
41
+
42
+ async function cleanup() {
43
+ await x(`DELETE FROM front_door_guide_calls WHERE visitor_hash LIKE $1`, [`${V}%`]).catch(() => {});
44
+ await x(`DELETE FROM front_door_guide_conversations WHERE visitor_hash LIKE $1`, [`${V}%`]).catch(() => {});
45
+ await x('DELETE FROM builders WHERE id = ANY($1)', [[ALICE, BOB]]).catch(() => {});
46
+ }
47
+
48
+ const call = (over = {}) => ({ visitorHash: V, model: 'claude-opus-5-5', inputTokens: 800, outputTokens: 200, cacheReadTokens: 0, cacheWriteTokens: 0, costMicro: 9000, stopReason: 'end_turn', ...over });
49
+
50
+ try {
51
+ for (const m of [M1, M2, M1, M2]) await x(m); // applies, then re-applies as a no-op
52
+ await cleanup();
53
+ await insertTestBuilders(pool, [{ id: ALICE, login: 't1005273-alice' }, { id: BOB, login: 't1005273-bob' }]);
54
+
55
+ const dayBefore = await db.siteSpendMicro('day', x);
56
+
57
+ // A turn and a plan.
58
+ const conv = await db.createConversation('hash-a-1005273', V, x);
59
+ const turned = await db.recordTurn(conv.id, { messages: [{ role: 'user', content: 'hi' }, { role: 'assistant', content: 'hello' }], status: 'open', endReason: null }, call(), x);
60
+ assert.equal(turned.visitor_messages, 1);
61
+ assert.equal(Number(turned.spend_micro), 9000);
62
+ const planned = await db.savePlan(conv.id, { summary: 'A plan' }, call({ costMicro: 20000 }), x);
63
+ assert.equal(planned.plan.summary, 'A plan');
64
+ assert.equal(planned.status, 'ended');
65
+ assert.equal(planned.end_reason, 'planned');
66
+ const again = await db.savePlan(conv.id, { summary: 'Overwrite?' }, call({ costMicro: 1 }), x);
67
+ assert.equal(again.plan.summary, 'A plan', 'a stored plan is never overwritten');
68
+ await db.recordPlanSpend(conv.id, call({ costMicro: 5 }), x);
69
+
70
+ const usage = await db.visitorUsage(V, x);
71
+ assert.equal(usage.messages, 1, 'only replies are messages');
72
+ assert.equal(usage.spendMicro, 9000 + 20000 + 1 + 5);
73
+
74
+ // Attach: the token holder's account, once; never moved to another.
75
+ assert.equal(await db.attachToBuilder(conv.id, 'wrong-hash', ALICE, x), null);
76
+ const mine = await db.attachToBuilder(conv.id, 'hash-a-1005273', ALICE, x);
77
+ assert.equal(Number(mine.builder_id), ALICE);
78
+ assert.ok(mine.attached_at);
79
+ assert.equal(await db.attachToBuilder(conv.id, 'hash-a-1005273', BOB, x), null);
80
+ assert.equal((await db.latestForBuilder(ALICE, x)).plan.summary, 'A plan');
81
+ assert.equal(await db.latestForBuilder(BOB, x), null);
82
+
83
+ // The sweep: an abandoned chat older than the setting goes; a recent one and an
84
+ // attached one stay; the abandoned chat's spend stays on the books.
85
+ const abandoned = await db.createConversation('hash-b-1005273', V, x);
86
+ await db.recordTurn(abandoned.id, { messages: [], status: 'open', endReason: null }, call({ costMicro: 7000 }), x);
87
+ const recent = await db.createConversation('hash-c-1005273', V, x);
88
+ await x(`UPDATE front_door_guide_conversations SET updated_at = now() - interval '15 days' WHERE id = ANY($1)`, [[abandoned.id, conv.id]]);
89
+ await x(`UPDATE front_door_guide_conversations SET updated_at = now() - interval '13 days' WHERE id = $1`, [recent.id]);
90
+ const before = await db.siteSpendMicro('day', x);
91
+
92
+ const deleted = await db.deleteAbandoned(14, x);
93
+ assert.ok(deleted >= 1);
94
+ const left = (await x(`SELECT id FROM front_door_guide_conversations WHERE visitor_hash = $1 ORDER BY id`, [V])).rows.map((r) => String(r.id));
95
+ assert.deepEqual(left, [String(conv.id), String(recent.id)], 'the abandoned chat is gone; the attached and the recent stay');
96
+ const orphan = await x(`SELECT conversation_id, cost_micro FROM front_door_guide_calls WHERE visitor_hash = $1 AND cost_micro = 7000`, [V]);
97
+ assert.equal(orphan.rows.length, 1);
98
+ assert.equal(orphan.rows[0].conversation_id, null);
99
+ assert.equal(await db.siteSpendMicro('day', x), before, 'the budgets still count the deleted chat\'s money');
100
+ assert.ok(before >= dayBefore + 9000 + 20000 + 1 + 5 + 7000);
101
+
102
+ // Refusals the schema owns.
103
+ await assert.rejects(x(`INSERT INTO front_door_guide_calls (visitor_hash, model, cost_micro, kind) VALUES ($1, 'm', 1, 'other')`, [V]));
104
+ await assert.rejects(x(`INSERT INTO front_door_guide_calls (visitor_hash, model, cost_micro) VALUES ($1, 'm', -1)`, [V]));
105
+
106
+ console.log('front_door_guide_db.mjs: all checks passed');
107
+ } finally {
108
+ await cleanup();
109
+ await pool.end();
110
+ }