@bongos/core 1.25.11 → 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.
- package/.bongos-core.json +120 -30
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +16 -0
- package/clients/bongos-client/index.cjs +16 -0
- package/clients/bongos-client/index.d.ts +22 -0
- package/clients/bongos-client/index.mjs +16 -0
- package/docs/adr/0406-every-project-gets-a-sandbox-that-follows-main-and-live-moves-only-when-a-person-promotes.md +108 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +376 -3
- package/docs/api-reference.md +13 -1
- package/docs/file-map.md +1 -0
- package/docs/module-api-changelog.md +4 -0
- package/modules/front-door-guide/CLAUDE.md +15 -0
- package/modules/front-door-guide/claude.js +55 -0
- package/modules/front-door-guide/context.js +54 -0
- package/modules/front-door-guide/db.js +137 -0
- package/modules/front-door-guide/guide.js +148 -0
- package/modules/front-door-guide/limits.js +87 -0
- package/modules/front-door-guide/migrations/front_door_guide_001_conversations.sql +48 -0
- package/modules/front-door-guide/migrations/front_door_guide_002_plan_and_account.sql +37 -0
- package/modules/front-door-guide/module.json +31 -0
- package/modules/front-door-guide/plan.js +93 -0
- package/modules/front-door-guide/pollers/retention-sweep.js +67 -0
- package/modules/front-door-guide/routes/front-door-guide-plan.js +144 -0
- package/modules/front-door-guide/routes/front-door-guide.js +196 -0
- package/modules/front-door-guide/settings.js +72 -0
- package/modules/provisioning/module.json +2 -1
- package/modules/provisioning/seams.js +10 -0
- package/modules/provisioning/starter-bundles.js +3 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +16 -0
- package/scripts/bongos/publish-manifest.js +1 -0
- package/scripts/bongos/run-unit-tests.js +4 -0
- package/src/bongos/module-scope-map.js +6 -0
- package/src/module-api.js +1 -1
- package/tests/front_door_guide.mjs +410 -0
- package/tests/front_door_guide_db.mjs +110 -0
- package/tests/front_door_guide_plan.mjs +344 -0
- package/tests/module_loader.mjs +1 -1
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// modules/front-door-guide/routes/front-door-guide-plan.js — where the start-a-project chat
|
|
2
|
+
// ends (task 1005273): the visitor's plan, then the chat and plan moving onto the account
|
|
3
|
+
// they sign up as.
|
|
4
|
+
//
|
|
5
|
+
// POST /front-door-guide/conversations/:id/plan write (or re-read) the plan { token, website? }
|
|
6
|
+
// POST /front-door-guide/conversations/:id/attach put the chat on my account { token }
|
|
7
|
+
// GET /front-door-guide/mine my newest chat and its plan
|
|
8
|
+
//
|
|
9
|
+
// The plan is ONE structured-output call per chat (a JSON schema, never parsed prose),
|
|
10
|
+
// asked once the visitor has said something; a second request re-reads the stored plan
|
|
11
|
+
// and spends nothing. Its modules come only from the live catalog (context.moduleCatalog,
|
|
12
|
+
// the project-creation picker's own list). It is held to the site's daily and monthly
|
|
13
|
+
// budget like a reply, and its cost lands in the same ledger; it is not one of the
|
|
14
|
+
// visitor's messages.
|
|
15
|
+
//
|
|
16
|
+
// Attaching is the sign-up hand-off's call (task 1005274): right after the visitor signs
|
|
17
|
+
// in, the page that still holds the chat's token sends it here. A chat already on another
|
|
18
|
+
// account is never moved. A chat nobody attaches is deleted by the retention sweep
|
|
19
|
+
// (pollers/retention-sweep.js) after BONGOS_FRONT_DOOR_GUIDE_RETENTION_DAYS.
|
|
20
|
+
|
|
21
|
+
'use strict';
|
|
22
|
+
|
|
23
|
+
const express = require('express');
|
|
24
|
+
const api = require('../../../src/module-api');
|
|
25
|
+
const G = require('../guide');
|
|
26
|
+
const P = require('../plan');
|
|
27
|
+
const db = require('../db');
|
|
28
|
+
const claude = require('../claude');
|
|
29
|
+
const { readSettings } = require('../settings');
|
|
30
|
+
const L = require('../limits');
|
|
31
|
+
const C = require('../context');
|
|
32
|
+
|
|
33
|
+
const log = api.logger('front-door-guide');
|
|
34
|
+
const ID_RE = /^\d{1,18}$/;
|
|
35
|
+
const TOKEN_RE = /^[A-Za-z0-9_-]{16,64}$/;
|
|
36
|
+
|
|
37
|
+
// The words for "write my plan now", sent as the visitor's last turn of the plan call.
|
|
38
|
+
const PLAN_ASK = 'Please write my first plan now, from what I have told you.';
|
|
39
|
+
|
|
40
|
+
function planAnswer(conv, settings) {
|
|
41
|
+
return {
|
|
42
|
+
ok: true, mode: 'plan', plan: conv.plan, next: P.NEXT_STEP,
|
|
43
|
+
message: G.END_REASONS.planned,
|
|
44
|
+
saved: G.savedNotice(settings.retentionDays),
|
|
45
|
+
conversation: G.conversationView(conv, settings),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// What a signed-in builder sees of their chat: never the visitor hash.
|
|
50
|
+
function mineView(conv, settings) {
|
|
51
|
+
return {
|
|
52
|
+
id: String(conv.id),
|
|
53
|
+
status: conv.status,
|
|
54
|
+
end_reason: conv.end_reason || null,
|
|
55
|
+
attached_at: conv.attached_at || null,
|
|
56
|
+
transcript: G.transcriptView(conv.messages),
|
|
57
|
+
plan: conv.plan || null,
|
|
58
|
+
...(conv.plan ? { next: P.NEXT_STEP } : {}),
|
|
59
|
+
messages_used: Number(conv.visitor_messages) || 0,
|
|
60
|
+
max_message_chars: settings.maxMessageChars,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
module.exports = function buildFrontDoorGuidePlanRouter() {
|
|
65
|
+
const router = express.Router();
|
|
66
|
+
let current = readSettings();
|
|
67
|
+
const fresh = (req, res, next) => { current = readSettings(); next(); };
|
|
68
|
+
const planLimit = L.makeLimiter({ windowMs: 60_000, max: () => current.messagesPerMinute, scope: 'front-door-guide-plan' });
|
|
69
|
+
|
|
70
|
+
// rank: public — the visitor has no account yet.
|
|
71
|
+
router.post('/front-door-guide/conversations/:id/plan', fresh, planLimit, api.asyncHandler('POST /front-door-guide/conversations/:id/plan', async (req, res) => {
|
|
72
|
+
if (api.validateOrRespond(req, res, {
|
|
73
|
+
token: { required: true, type: 'string', maxLength: 64 },
|
|
74
|
+
website: { type: 'string', maxLength: 500 },
|
|
75
|
+
})) return;
|
|
76
|
+
const s = current;
|
|
77
|
+
const body = req.body || {};
|
|
78
|
+
if (!ID_RE.test(String(req.params.id)) || !TOKEN_RE.test(body.token)) return res.status(400).json({ ok: false, error: 'bad_request' });
|
|
79
|
+
const conv = await db.getConversation(req.params.id, L.tokenHash(body.token));
|
|
80
|
+
if (!conv) return res.status(404).json({ ok: false, error: 'not_found' });
|
|
81
|
+
if (conv.plan) return res.json(planAnswer(conv, s));
|
|
82
|
+
if (!(Number(conv.visitor_messages) > 0)) return res.status(409).json({ ok: false, error: 'too_early' });
|
|
83
|
+
if (G.botCheck({ body, userAgent: req.get('user-agent'), now: Date.now(), minFirstMessageMs: s.minFirstMessageMs })) {
|
|
84
|
+
return res.json(G.formAnswer('check_failed'));
|
|
85
|
+
}
|
|
86
|
+
if (!s.apiKey) return res.json(G.formAnswer('no_key'));
|
|
87
|
+
|
|
88
|
+
const catalog = await C.moduleCatalog();
|
|
89
|
+
const schema = P.planSchema(catalog.optional.map((m) => m.key));
|
|
90
|
+
const system = `${G.systemPrompt(C.productName())}\n\n${P.planInstructions(catalog)}`;
|
|
91
|
+
const messages = [...(conv.messages || []), { role: 'user', content: PLAN_ASK }];
|
|
92
|
+
const estimate = G.estimateMicro({ model: s.model, system: system + JSON.stringify(schema), messages, maxReplyTokens: s.maxPlanTokens });
|
|
93
|
+
const budget = await C.siteBudgetReason(s, estimate);
|
|
94
|
+
if (budget) return res.json(G.formAnswer(budget));
|
|
95
|
+
|
|
96
|
+
const visitor = conv.visitor_hash;
|
|
97
|
+
const release = C.reservations.take(visitor, estimate);
|
|
98
|
+
if (!release) return res.status(429).json({ ok: false, error: 'busy', retry_after_seconds: 2 });
|
|
99
|
+
let result;
|
|
100
|
+
try {
|
|
101
|
+
result = await claude.askGuide({ client: claude.createClient(s.apiKey), settings: s, system, messages, schema });
|
|
102
|
+
} finally {
|
|
103
|
+
release();
|
|
104
|
+
}
|
|
105
|
+
if (!result.ok) {
|
|
106
|
+
log.warn({ conversation_id: String(conv.id), error: result.error }, 'front-door-guide: plan call failed');
|
|
107
|
+
return res.json(G.formAnswer('unavailable'));
|
|
108
|
+
}
|
|
109
|
+
const u = result.usage || {};
|
|
110
|
+
const call = {
|
|
111
|
+
visitorHash: visitor, model: result.model, costMicro: G.costMicro(result.model, u), stopReason: result.stopReason || null,
|
|
112
|
+
inputTokens: Number(u.input_tokens) || 0, outputTokens: Number(u.output_tokens) || 0,
|
|
113
|
+
cacheReadTokens: Number(u.cache_read_input_tokens) || 0, cacheWriteTokens: Number(u.cache_creation_input_tokens) || 0,
|
|
114
|
+
};
|
|
115
|
+
// A refused or cut-off reply may not match the schema: it costs what it cost, and the
|
|
116
|
+
// visitor is offered the form rather than half a plan.
|
|
117
|
+
const plan = result.stopReason === 'end_turn' ? P.parsePlan(G.replyText(result.content), catalog) : null;
|
|
118
|
+
if (!plan) {
|
|
119
|
+
await db.recordPlanSpend(conv.id, call);
|
|
120
|
+
log.warn({ conversation_id: String(conv.id), stop_reason: result.stopReason }, 'front-door-guide: no usable plan');
|
|
121
|
+
return res.json(G.formAnswer('unavailable'));
|
|
122
|
+
}
|
|
123
|
+
const saved = await db.savePlan(conv.id, plan, call);
|
|
124
|
+
res.json(planAnswer(saved || { ...conv, plan }, s));
|
|
125
|
+
}));
|
|
126
|
+
|
|
127
|
+
// rank: builder — the visitor has just signed in; the chat moves onto their account.
|
|
128
|
+
router.post('/front-door-guide/conversations/:id/attach', api.requireBuilder, fresh, api.asyncHandler('POST /front-door-guide/conversations/:id/attach', async (req, res) => {
|
|
129
|
+
if (api.validateOrRespond(req, res, { token: { required: true, type: 'string', maxLength: 64 } })) return;
|
|
130
|
+
const token = req.body.token;
|
|
131
|
+
if (!ID_RE.test(String(req.params.id)) || !TOKEN_RE.test(token)) return res.status(400).json({ ok: false, error: 'bad_request' });
|
|
132
|
+
const conv = await db.attachToBuilder(req.params.id, L.tokenHash(token), req.builder.id);
|
|
133
|
+
if (!conv) return res.status(404).json({ ok: false, error: 'not_found' });
|
|
134
|
+
res.json({ ok: true, conversation: mineView(conv, current) });
|
|
135
|
+
}));
|
|
136
|
+
|
|
137
|
+
// rank: builder — my newest chat and plan (the creation wizard's pre-fill reads it).
|
|
138
|
+
router.get('/front-door-guide/mine', api.requireBuilder, fresh, api.asyncHandler('GET /front-door-guide/mine', async (req, res) => {
|
|
139
|
+
const conv = await db.latestForBuilder(req.builder.id);
|
|
140
|
+
res.json({ ok: true, conversation: conv ? mineView(conv, current) : null });
|
|
141
|
+
}));
|
|
142
|
+
|
|
143
|
+
return router;
|
|
144
|
+
};
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
// modules/front-door-guide/routes/front-door-guide.js — the start-a-project guide's HTTP
|
|
2
|
+
// surface (task 1005272). A signed-out visitor talks with an AI guide about what they
|
|
3
|
+
// want to build before signing up.
|
|
4
|
+
//
|
|
5
|
+
// GET /front-door-guide/status can the guide talk right now
|
|
6
|
+
// POST /front-door-guide/conversations start a chat { website? }
|
|
7
|
+
// POST /front-door-guide/conversations/:id/messages send one message { token, text, website? }
|
|
8
|
+
// GET /front-door-guide/conversations/:id read it back header x-guide-token
|
|
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
|
+
//
|
|
13
|
+
// rank: public — every route. The visitor has no account yet; that is the point. What
|
|
14
|
+
// stands in for one: per-visitor rate limits, a bot check, a token per conversation, and
|
|
15
|
+
// every money cap enforced here before a call is made (the per-visitor message count and
|
|
16
|
+
// spend, the site's daily and monthly budget). A spent budget or a missing key answers
|
|
17
|
+
// `mode: 'form'`, never an error, so the front end always has somewhere to send the
|
|
18
|
+
// visitor. The response shape is documented in ../CLAUDE.md.
|
|
19
|
+
//
|
|
20
|
+
// The visitor's words are never logged.
|
|
21
|
+
|
|
22
|
+
'use strict';
|
|
23
|
+
|
|
24
|
+
const express = require('express');
|
|
25
|
+
const api = require('../../../src/module-api');
|
|
26
|
+
const G = require('../guide');
|
|
27
|
+
const db = require('../db');
|
|
28
|
+
const claude = require('../claude');
|
|
29
|
+
const { readSettings } = require('../settings');
|
|
30
|
+
const L = require('../limits');
|
|
31
|
+
const C = require('../context');
|
|
32
|
+
const P = require('../plan');
|
|
33
|
+
|
|
34
|
+
const log = api.logger('front-door-guide');
|
|
35
|
+
const ID_RE = /^\d{1,18}$/;
|
|
36
|
+
const TOKEN_RE = /^[A-Za-z0-9_-]{16,64}$/;
|
|
37
|
+
|
|
38
|
+
module.exports = function buildFrontDoorGuideRouter() {
|
|
39
|
+
const router = express.Router();
|
|
40
|
+
const { reservations, siteBudgetReason, salt, productName } = C;
|
|
41
|
+
let current = readSettings();
|
|
42
|
+
const knobs = () => current;
|
|
43
|
+
|
|
44
|
+
const statusLimit = L.makeLimiter({ windowMs: 60_000, max: () => 60, scope: 'front-door-guide-read' });
|
|
45
|
+
const startLimit = L.makeLimiter({ windowMs: 3_600_000, max: () => knobs().startsPerHour, scope: 'front-door-guide-start' });
|
|
46
|
+
const messageLimit = L.makeLimiter({ windowMs: 60_000, max: () => knobs().messagesPerMinute, scope: 'front-door-guide-message' });
|
|
47
|
+
// Settings are re-read on every request, so an owner's change applies without a restart.
|
|
48
|
+
const fresh = (req, res, next) => { current = readSettings(); next(); };
|
|
49
|
+
|
|
50
|
+
// rank: public — the landing asks before it offers the chat.
|
|
51
|
+
router.get('/front-door-guide/status', fresh, statusLimit, api.asyncHandler('GET /front-door-guide/status', async (req, res) => {
|
|
52
|
+
const s = knobs();
|
|
53
|
+
const reason = !s.apiKey ? 'no_key' : await siteBudgetReason(s, 0);
|
|
54
|
+
res.json({
|
|
55
|
+
ok: true,
|
|
56
|
+
mode: reason ? 'form' : 'chat',
|
|
57
|
+
...(reason ? { reason, message: G.FORM_REASONS[reason] } : {}),
|
|
58
|
+
limits: { max_messages: s.maxMessages, max_message_chars: s.maxMessageChars },
|
|
59
|
+
});
|
|
60
|
+
}));
|
|
61
|
+
|
|
62
|
+
// rank: public — start a chat. Answers the greeting and the conversation's token.
|
|
63
|
+
router.post('/front-door-guide/conversations', fresh, startLimit, api.asyncHandler('POST /front-door-guide/conversations', async (req, res) => {
|
|
64
|
+
if (api.validateOrRespond(req, res, { website: { type: 'string', maxLength: 500 } })) return;
|
|
65
|
+
const s = knobs();
|
|
66
|
+
const body = req.body || {};
|
|
67
|
+
if (G.botCheck({ body, userAgent: req.get('user-agent'), now: Date.now(), minFirstMessageMs: s.minFirstMessageMs })) {
|
|
68
|
+
return res.json(G.formAnswer('check_failed'));
|
|
69
|
+
}
|
|
70
|
+
if (!s.apiKey) return res.json(G.formAnswer('no_key'));
|
|
71
|
+
const budget = await siteBudgetReason(s, 0);
|
|
72
|
+
if (budget) return res.json(G.formAnswer(budget));
|
|
73
|
+
const visitor = L.visitorHash(L.clientIp(req), salt());
|
|
74
|
+
const used = await db.visitorUsage(visitor);
|
|
75
|
+
if (used.messages >= s.maxMessages || used.spendMicro >= s.visitorCapMicro) return res.json(G.formAnswer('visitor_limit'));
|
|
76
|
+
const token = L.newToken();
|
|
77
|
+
const conv = await db.createConversation(L.tokenHash(token), visitor);
|
|
78
|
+
res.status(201).json({
|
|
79
|
+
ok: true, mode: 'chat',
|
|
80
|
+
conversation: { ...G.conversationView(conv, s), token },
|
|
81
|
+
greeting: G.GREETING,
|
|
82
|
+
saved: G.savedNotice(s.retentionDays),
|
|
83
|
+
});
|
|
84
|
+
}));
|
|
85
|
+
|
|
86
|
+
// rank: public — one visitor message, one guide reply.
|
|
87
|
+
router.post('/front-door-guide/conversations/:id/messages', fresh, messageLimit, api.asyncHandler('POST /front-door-guide/conversations/:id/messages', async (req, res) => {
|
|
88
|
+
// The schema's ceiling on `text` is loose on purpose: the owner's own length limit is
|
|
89
|
+
// checked below, so a visitor hears `too_long` with the number, not a generic refusal.
|
|
90
|
+
if (api.validateOrRespond(req, res, {
|
|
91
|
+
token: { required: true, type: 'string', maxLength: 64 },
|
|
92
|
+
text: { required: true, type: 'string', maxLength: 100_000 },
|
|
93
|
+
website: { type: 'string', maxLength: 500 },
|
|
94
|
+
})) return;
|
|
95
|
+
const s = knobs();
|
|
96
|
+
const body = req.body || {};
|
|
97
|
+
if (!ID_RE.test(String(req.params.id)) || typeof body.token !== 'string' || !TOKEN_RE.test(body.token)) {
|
|
98
|
+
return res.status(400).json({ ok: false, error: 'bad_request' });
|
|
99
|
+
}
|
|
100
|
+
if (typeof body.text !== 'string' || !body.text.trim()) return res.status(400).json({ ok: false, error: 'empty' });
|
|
101
|
+
const text = body.text.trim();
|
|
102
|
+
if (text.length > s.maxMessageChars) {
|
|
103
|
+
return res.status(400).json({ ok: false, error: 'too_long', max_message_chars: s.maxMessageChars });
|
|
104
|
+
}
|
|
105
|
+
const conv = await db.getConversation(req.params.id, L.tokenHash(body.token));
|
|
106
|
+
if (!conv) return res.status(404).json({ ok: false, error: 'not_found' });
|
|
107
|
+
const view = () => G.conversationView(conv, s);
|
|
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
|
+
});
|
|
112
|
+
if (conv.status === 'ended') return res.json(ended(conv.end_reason));
|
|
113
|
+
|
|
114
|
+
const bot = G.botCheck({
|
|
115
|
+
body, userAgent: req.get('user-agent'), now: Date.now(), minFirstMessageMs: s.minFirstMessageMs,
|
|
116
|
+
startedAt: new Date(conv.created_at).getTime(), firstMessage: Number(conv.visitor_messages) === 0,
|
|
117
|
+
});
|
|
118
|
+
if (bot) return res.json(G.formAnswer('check_failed'));
|
|
119
|
+
if (!s.apiKey) return res.json(G.formAnswer('no_key'));
|
|
120
|
+
|
|
121
|
+
// The visitor's caps count every chat they started today, not just this one.
|
|
122
|
+
const visitor = conv.visitor_hash;
|
|
123
|
+
if (reservations.busy(visitor)) return res.status(429).json({ ok: false, error: 'busy', retry_after_seconds: 2 });
|
|
124
|
+
const used = await db.visitorUsage(visitor);
|
|
125
|
+
if (used.messages >= s.maxMessages || Number(conv.visitor_messages) >= s.maxMessages) {
|
|
126
|
+
await db.endConversation(conv.id, 'message_cap');
|
|
127
|
+
return res.json(ended('message_cap'));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const system = G.systemPrompt(productName());
|
|
131
|
+
const messages = [...(conv.messages || []), { role: 'user', content: text }];
|
|
132
|
+
const estimate = G.estimateMicro({ model: s.model, system, messages, maxReplyTokens: s.maxReplyTokens });
|
|
133
|
+
if (used.spendMicro + estimate > s.visitorCapMicro) {
|
|
134
|
+
await db.endConversation(conv.id, 'visitor_budget');
|
|
135
|
+
return res.json(ended('visitor_budget'));
|
|
136
|
+
}
|
|
137
|
+
const budget = await siteBudgetReason(s, estimate);
|
|
138
|
+
if (budget) return res.json(G.formAnswer(budget));
|
|
139
|
+
|
|
140
|
+
const release = reservations.take(visitor, estimate);
|
|
141
|
+
if (!release) return res.status(429).json({ ok: false, error: 'busy', retry_after_seconds: 2 });
|
|
142
|
+
let result;
|
|
143
|
+
try {
|
|
144
|
+
result = await claude.askGuide({ client: claude.createClient(s.apiKey), settings: s, system, messages });
|
|
145
|
+
} finally {
|
|
146
|
+
release();
|
|
147
|
+
}
|
|
148
|
+
if (!result.ok) {
|
|
149
|
+
log.warn({ conversation_id: String(conv.id), error: result.error }, 'front-door-guide: call failed');
|
|
150
|
+
return res.json(G.formAnswer('unavailable'));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const cost = G.costMicro(result.model, result.usage);
|
|
154
|
+
const refused = result.stopReason === 'refusal';
|
|
155
|
+
const reply = refused ? G.REFUSAL_REPLY : (G.replyText(result.content) || G.EMPTY_REPLY);
|
|
156
|
+
// A declined message is not sent again on the next turn, or it would be declined again.
|
|
157
|
+
const kept = refused ? [...(conv.messages || []), { role: 'user', content: '(The visitor\'s message was declined.)' }] : messages;
|
|
158
|
+
const nextMessages = [...kept, { role: 'assistant', content: reply }];
|
|
159
|
+
let endReason = null;
|
|
160
|
+
if (Number(conv.visitor_messages) + 1 >= s.maxMessages || used.messages + 1 >= s.maxMessages) endReason = 'message_cap';
|
|
161
|
+
else if (used.spendMicro + cost >= s.visitorCapMicro) endReason = 'visitor_budget';
|
|
162
|
+
const u = result.usage || {};
|
|
163
|
+
const saved = await db.recordTurn(conv.id, { messages: nextMessages, status: endReason ? 'ended' : 'open', endReason }, {
|
|
164
|
+
visitorHash: visitor, model: result.model, costMicro: cost, stopReason: result.stopReason || null,
|
|
165
|
+
inputTokens: Number(u.input_tokens) || 0, outputTokens: Number(u.output_tokens) || 0,
|
|
166
|
+
cacheReadTokens: Number(u.cache_read_input_tokens) || 0, cacheWriteTokens: Number(u.cache_creation_input_tokens) || 0,
|
|
167
|
+
});
|
|
168
|
+
res.json({
|
|
169
|
+
ok: true,
|
|
170
|
+
mode: endReason ? 'ended' : 'chat',
|
|
171
|
+
...(endReason ? { reason: endReason, message: G.END_REASONS[endReason], next_step: 'plan' } : {}),
|
|
172
|
+
...(refused ? { declined: true } : {}),
|
|
173
|
+
reply: { text: reply },
|
|
174
|
+
conversation: G.conversationView(saved || { ...conv, visitor_messages: Number(conv.visitor_messages) + 1 }, s),
|
|
175
|
+
});
|
|
176
|
+
}));
|
|
177
|
+
|
|
178
|
+
// rank: public — the transcript, for the plan and the sign-up hand-off.
|
|
179
|
+
router.get('/front-door-guide/conversations/:id', fresh, statusLimit, api.asyncHandler('GET /front-door-guide/conversations/:id', async (req, res) => {
|
|
180
|
+
const token = String(req.get('x-guide-token') || '');
|
|
181
|
+
if (!ID_RE.test(String(req.params.id)) || !TOKEN_RE.test(token)) return res.status(400).json({ ok: false, error: 'bad_request' });
|
|
182
|
+
const conv = await db.getConversation(req.params.id, L.tokenHash(token));
|
|
183
|
+
if (!conv) return res.status(404).json({ ok: false, error: 'not_found' });
|
|
184
|
+
res.json({
|
|
185
|
+
ok: true,
|
|
186
|
+
conversation: { ...G.conversationView(conv, knobs()), status: conv.status, end_reason: conv.end_reason || null },
|
|
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,
|
|
192
|
+
});
|
|
193
|
+
}));
|
|
194
|
+
|
|
195
|
+
return router;
|
|
196
|
+
};
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// modules/front-door-guide/settings.js — the owner's knobs for the start-a-project
|
|
2
|
+
// guide (task 1005272): which model answers, how many messages and how much money one
|
|
3
|
+
// visitor may use, the site's daily and monthly budget, and the per-message length.
|
|
4
|
+
//
|
|
5
|
+
// Every knob is configuration, read from the environment under the instance's prefix
|
|
6
|
+
// (BONGOS_FRONT_DOOR_GUIDE_<NAME>, via the doorway's resolveEnv), and DEFAULTS below is
|
|
7
|
+
// the one place the starting values are written. A value that is missing, blank or not
|
|
8
|
+
// a usable number falls back to its default, so a typo never opens a cap wider.
|
|
9
|
+
//
|
|
10
|
+
// Money is integer micro-dollars (1 dollar = 1,000,000), the ai-studio convention, so
|
|
11
|
+
// no budget comparison ever rounds a float. The env takes cents, which owners read.
|
|
12
|
+
|
|
13
|
+
'use strict';
|
|
14
|
+
|
|
15
|
+
// The starting values: the owner's settings for testing (2026-11-13).
|
|
16
|
+
const DEFAULTS = Object.freeze({
|
|
17
|
+
MODEL: 'claude-opus-5-5',
|
|
18
|
+
EFFORT: 'low', // a short planning chat, not hard reasoning
|
|
19
|
+
MAX_MESSAGES: 8, // visitor messages per visitor per day
|
|
20
|
+
VISITOR_CAP_CENTS: 50, // what one visitor may spend per day
|
|
21
|
+
DAILY_BUDGET_CENTS: 200, // the whole site, per UTC day
|
|
22
|
+
MONTHLY_BUDGET_CENTS: 2500, // the whole site, per UTC calendar month
|
|
23
|
+
MAX_MESSAGE_CHARS: 1000, // one visitor message
|
|
24
|
+
MAX_REPLY_TOKENS: 1500, // one guide reply, thinking included
|
|
25
|
+
STARTS_PER_HOUR: 5, // new conversations per visitor per hour
|
|
26
|
+
MESSAGES_PER_MINUTE: 6, // messages per visitor per minute
|
|
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
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const EFFORTS = new Set(['low', 'medium', 'high', 'xhigh', 'max']);
|
|
33
|
+
const MODEL_RE = /^claude-[a-z0-9.-]{1,60}$/;
|
|
34
|
+
|
|
35
|
+
function defaultResolve(suffix) {
|
|
36
|
+
return require('../../src/module-api').resolveEnv(suffix);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// A positive whole number from the env, or the default.
|
|
40
|
+
function intKnob(read, name) {
|
|
41
|
+
const raw = read(`FRONT_DOOR_GUIDE_${name}`);
|
|
42
|
+
const n = Number(raw);
|
|
43
|
+
return raw !== undefined && raw !== '' && Number.isInteger(n) && n > 0 ? n : DEFAULTS[name];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// readSettings({ resolve }) → the resolved knobs, money in micro-dollars, plus the key.
|
|
47
|
+
// The key is read here and only handed to the client builder; nothing returns it to a
|
|
48
|
+
// browser. `resolve` is injectable so a test needs no environment.
|
|
49
|
+
function readSettings({ resolve = defaultResolve } = {}) {
|
|
50
|
+
const read = (s) => { try { return resolve(s); } catch (_) { return undefined; } };
|
|
51
|
+
const model = String(read('FRONT_DOOR_GUIDE_MODEL') || '').trim();
|
|
52
|
+
const effort = String(read('FRONT_DOOR_GUIDE_EFFORT') || '').trim();
|
|
53
|
+
const key = String(read('FRONT_DOOR_GUIDE_ANTHROPIC_API_KEY') || '').trim();
|
|
54
|
+
return {
|
|
55
|
+
model: MODEL_RE.test(model) ? model : DEFAULTS.MODEL,
|
|
56
|
+
effort: EFFORTS.has(effort) ? effort : DEFAULTS.EFFORT,
|
|
57
|
+
apiKey: key || null,
|
|
58
|
+
maxMessages: intKnob(read, 'MAX_MESSAGES'),
|
|
59
|
+
visitorCapMicro: intKnob(read, 'VISITOR_CAP_CENTS') * 10_000,
|
|
60
|
+
dailyBudgetMicro: intKnob(read, 'DAILY_BUDGET_CENTS') * 10_000,
|
|
61
|
+
monthlyBudgetMicro: intKnob(read, 'MONTHLY_BUDGET_CENTS') * 10_000,
|
|
62
|
+
maxMessageChars: intKnob(read, 'MAX_MESSAGE_CHARS'),
|
|
63
|
+
maxReplyTokens: intKnob(read, 'MAX_REPLY_TOKENS'),
|
|
64
|
+
startsPerHour: intKnob(read, 'STARTS_PER_HOUR'),
|
|
65
|
+
messagesPerMinute: intKnob(read, 'MESSAGES_PER_MINUTE'),
|
|
66
|
+
minFirstMessageMs: intKnob(read, 'MIN_FIRST_MESSAGE_MS'),
|
|
67
|
+
maxPlanTokens: intKnob(read, 'MAX_PLAN_TOKENS'),
|
|
68
|
+
retentionDays: intKnob(read, 'RETENTION_DAYS'),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
module.exports = { readSettings, DEFAULTS };
|
|
@@ -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 };
|
|
@@ -82,7 +82,9 @@ const ALWAYS_ON_CORE = Object.freeze([
|
|
|
82
82
|
// bills AI generation through Cloud Bongos's own payment processor account, so it stays on
|
|
83
83
|
// the hub until a project can bring its own billing. `market-watch` sat here while it was
|
|
84
84
|
// empty; task 1004938 shipped the owner's on/off switch and moved it to OPTIONAL_MODULES.
|
|
85
|
-
|
|
85
|
+
// `front-door-guide` (task 1005272) is the hub's start-a-project chat on Cloud Bongos's own
|
|
86
|
+
// Anthropic key, so a new project gains nothing by switching it on.
|
|
87
|
+
const HUB_ONLY_MODULES = Object.freeze(['platform-identity', 'provisioning', 'npm-release', 'render-deploy', 'raise', 'ai-studio', 'front-door-guide']);
|
|
86
88
|
|
|
87
89
|
// The OPT-IN catalog as the creation picker renders it (BV1.R20, task 1002339):
|
|
88
90
|
// every module an owner may actually choose, in the order the picker lists them,
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.25.
|
|
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.
|
|
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.
|
|
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",
|
package/release-notes.json
CHANGED
|
@@ -11772,5 +11772,21 @@
|
|
|
11772
11772
|
"id": "1005268",
|
|
11773
11773
|
"text": "An Archon can now switch the card requirement on or off for a builder straight from the People roster."
|
|
11774
11774
|
}
|
|
11775
|
+
],
|
|
11776
|
+
"1.25.12": [
|
|
11777
|
+
{
|
|
11778
|
+
"id": "1005272",
|
|
11779
|
+
"text": "Visitors who press start a project can now talk with an AI guide about what they want to build, with strict limits on messages and spending, and a plain form whenever the guide is unavailable."
|
|
11780
|
+
},
|
|
11781
|
+
{
|
|
11782
|
+
"id": "1005288",
|
|
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
|
+
}
|
|
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
|
+
}
|
|
11775
11791
|
]
|
|
11776
11792
|
}
|
|
@@ -78,6 +78,7 @@ const PUBLISH_ALLOWLIST = [
|
|
|
78
78
|
'modules/render-deploy/', // the deploy page of a project whose app deploys through Render (task 1004270). default:false. MUST be here for the npm-release reason: modules/hall-ui/ ships as one subtree, so a released core without it carries deploy.js's loader for #rd-panel with no routes behind it.
|
|
79
79
|
'modules/market-watch/', // the weekly AI market watch (task 1004935, goal 1000137). default:false and empty for now, but MUST be here: shipped is not enabled, and a released core without it answers "unknown module" to an instance that turns it on; tests/market_watch_module.mjs (publishable) also reads its manifest.
|
|
80
80
|
'modules/ai-studio/', // AI generation for artists (task 1004916, ADR 0392): keys, prepaid balance, generate. default:false.
|
|
81
|
+
'modules/front-door-guide/', // the start-a-project guide (task 1005272): a capped AI chat for a signed-out visitor on the platform's key. default:false, hub first; shipped is not enabled.
|
|
81
82
|
'modules/raise/', // the /raise investor page (task 1004847, ADR 0384) — ships in core, enabled ONLY on the hub, like platform-identity. Owner decision 2026-10-25: the whole page, its copy and starting numbers included, lives in the repo so builders can contribute to it, accepting that it publishes with the core. Secrets never do: the investor password is RAISE_PASSWORD_HASH in the instance env. default:false.
|
|
82
83
|
'modules/platform-identity/', // hub identity provider (ADR 0141) — ships in core, enabled ONLY on the hub (cloudbongos.com). MUST be here or the hub's enabledModules() throws "unknown module" and provision.js's federation load MODULE_NOT_FOUNDs (task 1002235, same rationale as provisioning above). default:false — shipped ≠ enabled; a vanilla/self-hosted instance never turns it on.
|
|
83
84
|
// --- neutral (customer-0) config starters + their schemas (the filled-in *.json twins are denied) ---
|
|
@@ -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.
|
|
@@ -186,6 +186,12 @@ const MODULE_GLOBS = {
|
|
|
186
186
|
'ai-studio': [
|
|
187
187
|
'modules/ai-studio/',
|
|
188
188
|
],
|
|
189
|
+
// The start-a-project guide (task 1005272, goal 1000106): a capped AI chat for a
|
|
190
|
+
// signed-out visitor. Its test sits at the repo root, listed by exact path.
|
|
191
|
+
'front-door-guide': [
|
|
192
|
+
'modules/front-door-guide/', 'tests/front_door_guide.mjs',
|
|
193
|
+
'tests/front_door_guide_plan.mjs', 'tests/front_door_guide_db.mjs',
|
|
194
|
+
],
|
|
189
195
|
// The /raise investor page (task 1004847, ADR 0384): hub-only.
|
|
190
196
|
raise: [
|
|
191
197
|
'modules/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.
|
|
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');
|