@bongos/core 1.25.12 → 1.25.14
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 +106 -51
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +6 -0
- package/clients/bongos-client/index.cjs +6 -0
- package/clients/bongos-client/index.d.ts +13 -4
- package/clients/bongos-client/index.mjs +6 -0
- package/docs/adr/0328-a-hosted-projects-authority-is-its-membership-row-not-its-owner-column.md +1 -1
- package/docs/adr/0407-a-project-grant-is-made-by-the-owner-not-witnessed-at-sign-in.md +124 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +198 -11
- package/docs/api-reference.md +7 -4
- package/docs/copy-inventory.md +28 -34
- package/docs/copy-registry.json +41 -125
- package/docs/file-map.md +1 -1
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-inventory.json +1 -0
- package/docs/page-readings.json +3 -3
- package/modules/front-door-guide/claude.js +11 -7
- package/modules/front-door-guide/context.js +54 -0
- package/modules/front-door-guide/db.js +79 -6
- package/modules/front-door-guide/guide.js +18 -3
- package/modules/front-door-guide/migrations/front_door_guide_002_plan_and_account.sql +37 -0
- package/modules/front-door-guide/migrations/front_door_guide_003_starting_point.sql +12 -0
- package/modules/front-door-guide/module.json +7 -3
- package/modules/front-door-guide/plan.js +102 -0
- package/modules/front-door-guide/pollers/retention-sweep.js +67 -0
- package/modules/front-door-guide/routes/front-door-guide-plan.js +145 -0
- package/modules/front-door-guide/routes/front-door-guide.js +36 -33
- package/modules/front-door-guide/settings.js +4 -0
- package/modules/front-door-guide/starting-point.js +55 -0
- package/modules/hall-ui/public/genesis-home.js +29 -2
- package/modules/hall-ui/public/goals-page.js +3 -29
- package/modules/hall-ui/public/index.html +2 -1
- package/modules/hall-ui/public/task-lib.js +45 -0
- package/modules/hall-ui/public/work.js +3 -47
- package/modules/lifecycle/kickoff-seed.js +9 -5
- package/modules/onboarding/genesis-home.js +1 -1
- package/modules/provisioning/module.json +2 -1
- package/modules/provisioning/seams.js +10 -0
- package/package-lock.json +306 -3
- package/package.json +4 -2
- package/release-notes.json +24 -0
- package/scripts/bongos/package-core.js +4 -2
- package/scripts/bongos/run-unit-tests.js +4 -0
- package/src/bongos/module-scope-map.js +2 -0
- package/src/module-api.js +1 -1
- package/tests/cutover_codemod.mjs +1 -1
- package/tests/front_door_guide_db.mjs +115 -0
- package/tests/front_door_guide_existing.mjs +238 -0
- package/tests/front_door_guide_plan.mjs +344 -0
- package/tests/genesis_home.mjs +80 -2
- package/tests/package_core.mjs +11 -7
- package/tests/pruned_lockfile_integrity.mjs +4 -2
- package/tests/status_page_no_dead_uptime.mjs +1 -1
- package/tests/task_lib.mjs +46 -0
|
@@ -0,0 +1,145 @@
|
|
|
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
|
+
starting_point: conv.starting_point || null,
|
|
54
|
+
status: conv.status,
|
|
55
|
+
end_reason: conv.end_reason || null,
|
|
56
|
+
attached_at: conv.attached_at || null,
|
|
57
|
+
transcript: G.transcriptView(conv.messages),
|
|
58
|
+
plan: conv.plan || null,
|
|
59
|
+
...(conv.plan ? { next: P.NEXT_STEP } : {}),
|
|
60
|
+
messages_used: Number(conv.visitor_messages) || 0,
|
|
61
|
+
max_message_chars: settings.maxMessageChars,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
module.exports = function buildFrontDoorGuidePlanRouter() {
|
|
66
|
+
const router = express.Router();
|
|
67
|
+
let current = readSettings();
|
|
68
|
+
const fresh = (req, res, next) => { current = readSettings(); next(); };
|
|
69
|
+
const planLimit = L.makeLimiter({ windowMs: 60_000, max: () => current.messagesPerMinute, scope: 'front-door-guide-plan' });
|
|
70
|
+
|
|
71
|
+
// rank: public — the visitor has no account yet.
|
|
72
|
+
router.post('/front-door-guide/conversations/:id/plan', fresh, planLimit, api.asyncHandler('POST /front-door-guide/conversations/:id/plan', async (req, res) => {
|
|
73
|
+
if (api.validateOrRespond(req, res, {
|
|
74
|
+
token: { required: true, type: 'string', maxLength: 64 },
|
|
75
|
+
website: { type: 'string', maxLength: 500 },
|
|
76
|
+
})) return;
|
|
77
|
+
const s = current;
|
|
78
|
+
const body = req.body || {};
|
|
79
|
+
if (!ID_RE.test(String(req.params.id)) || !TOKEN_RE.test(body.token)) return res.status(400).json({ ok: false, error: 'bad_request' });
|
|
80
|
+
const conv = await db.getConversation(req.params.id, L.tokenHash(body.token));
|
|
81
|
+
if (!conv) return res.status(404).json({ ok: false, error: 'not_found' });
|
|
82
|
+
if (conv.plan) return res.json(planAnswer(conv, s));
|
|
83
|
+
if (!(Number(conv.visitor_messages) > 0)) return res.status(409).json({ ok: false, error: 'too_early' });
|
|
84
|
+
if (G.botCheck({ body, userAgent: req.get('user-agent'), now: Date.now(), minFirstMessageMs: s.minFirstMessageMs })) {
|
|
85
|
+
return res.json(G.formAnswer('check_failed'));
|
|
86
|
+
}
|
|
87
|
+
if (!s.apiKey) return res.json(G.formAnswer('no_key'));
|
|
88
|
+
|
|
89
|
+
const catalog = await C.moduleCatalog();
|
|
90
|
+
const schema = P.planSchema(catalog.optional.map((m) => m.key));
|
|
91
|
+
const system = `${G.systemPrompt(C.productName(), conv.starting_point)}\n\n${P.planInstructions(catalog, conv.starting_point)}`;
|
|
92
|
+
const messages = [...(conv.messages || []), { role: 'user', content: PLAN_ASK }];
|
|
93
|
+
const estimate = G.estimateMicro({ model: s.model, system: system + JSON.stringify(schema), messages, maxReplyTokens: s.maxPlanTokens });
|
|
94
|
+
const budget = await C.siteBudgetReason(s, estimate);
|
|
95
|
+
if (budget) return res.json(G.formAnswer(budget));
|
|
96
|
+
|
|
97
|
+
const visitor = conv.visitor_hash;
|
|
98
|
+
const release = C.reservations.take(visitor, estimate);
|
|
99
|
+
if (!release) return res.status(429).json({ ok: false, error: 'busy', retry_after_seconds: 2 });
|
|
100
|
+
let result;
|
|
101
|
+
try {
|
|
102
|
+
result = await claude.askGuide({ client: claude.createClient(s.apiKey), settings: s, system, messages, schema });
|
|
103
|
+
} finally {
|
|
104
|
+
release();
|
|
105
|
+
}
|
|
106
|
+
if (!result.ok) {
|
|
107
|
+
log.warn({ conversation_id: String(conv.id), error: result.error }, 'front-door-guide: plan call failed');
|
|
108
|
+
return res.json(G.formAnswer('unavailable'));
|
|
109
|
+
}
|
|
110
|
+
const u = result.usage || {};
|
|
111
|
+
const call = {
|
|
112
|
+
visitorHash: visitor, model: result.model, costMicro: G.costMicro(result.model, u), stopReason: result.stopReason || null,
|
|
113
|
+
inputTokens: Number(u.input_tokens) || 0, outputTokens: Number(u.output_tokens) || 0,
|
|
114
|
+
cacheReadTokens: Number(u.cache_read_input_tokens) || 0, cacheWriteTokens: Number(u.cache_creation_input_tokens) || 0,
|
|
115
|
+
};
|
|
116
|
+
// A refused or cut-off reply may not match the schema: it costs what it cost, and the
|
|
117
|
+
// visitor is offered the form rather than half a plan.
|
|
118
|
+
const plan = result.stopReason === 'end_turn' ? P.parsePlan(G.replyText(result.content), catalog, conv.starting_point) : null;
|
|
119
|
+
if (!plan) {
|
|
120
|
+
await db.recordPlanSpend(conv.id, call);
|
|
121
|
+
log.warn({ conversation_id: String(conv.id), stop_reason: result.stopReason }, 'front-door-guide: no usable plan');
|
|
122
|
+
return res.json(G.formAnswer('unavailable'));
|
|
123
|
+
}
|
|
124
|
+
const saved = await db.savePlan(conv.id, plan, call);
|
|
125
|
+
res.json(planAnswer(saved || { ...conv, plan }, s));
|
|
126
|
+
}));
|
|
127
|
+
|
|
128
|
+
// rank: builder — the visitor has just signed in; the chat moves onto their account.
|
|
129
|
+
router.post('/front-door-guide/conversations/:id/attach', api.requireBuilder, fresh, api.asyncHandler('POST /front-door-guide/conversations/:id/attach', async (req, res) => {
|
|
130
|
+
if (api.validateOrRespond(req, res, { token: { required: true, type: 'string', maxLength: 64 } })) return;
|
|
131
|
+
const token = req.body.token;
|
|
132
|
+
if (!ID_RE.test(String(req.params.id)) || !TOKEN_RE.test(token)) return res.status(400).json({ ok: false, error: 'bad_request' });
|
|
133
|
+
const conv = await db.attachToBuilder(req.params.id, L.tokenHash(token), req.builder.id);
|
|
134
|
+
if (!conv) return res.status(404).json({ ok: false, error: 'not_found' });
|
|
135
|
+
res.json({ ok: true, conversation: mineView(conv, current) });
|
|
136
|
+
}));
|
|
137
|
+
|
|
138
|
+
// rank: builder — my newest chat and plan (the creation wizard's pre-fill reads it).
|
|
139
|
+
router.get('/front-door-guide/mine', api.requireBuilder, fresh, api.asyncHandler('GET /front-door-guide/mine', async (req, res) => {
|
|
140
|
+
const conv = await db.latestForBuilder(req.builder.id);
|
|
141
|
+
res.json({ ok: true, conversation: conv ? mineView(conv, current) : null });
|
|
142
|
+
}));
|
|
143
|
+
|
|
144
|
+
return router;
|
|
145
|
+
};
|
|
@@ -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,17 @@ 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');
|
|
33
|
+
const SP = require('../starting-point');
|
|
28
34
|
|
|
29
35
|
const log = api.logger('front-door-guide');
|
|
30
36
|
const ID_RE = /^\d{1,18}$/;
|
|
31
37
|
const TOKEN_RE = /^[A-Za-z0-9_-]{16,64}$/;
|
|
32
38
|
|
|
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
39
|
module.exports = function buildFrontDoorGuideRouter() {
|
|
57
40
|
const router = express.Router();
|
|
58
|
-
const reservations =
|
|
41
|
+
const { reservations, siteBudgetReason, salt, productName } = C;
|
|
59
42
|
let current = readSettings();
|
|
60
43
|
const knobs = () => current;
|
|
61
44
|
|
|
@@ -68,7 +51,7 @@ module.exports = function buildFrontDoorGuideRouter() {
|
|
|
68
51
|
// rank: public — the landing asks before it offers the chat.
|
|
69
52
|
router.get('/front-door-guide/status', fresh, statusLimit, api.asyncHandler('GET /front-door-guide/status', async (req, res) => {
|
|
70
53
|
const s = knobs();
|
|
71
|
-
const reason = !s.apiKey ? 'no_key' : await siteBudgetReason(s, 0
|
|
54
|
+
const reason = !s.apiKey ? 'no_key' : await siteBudgetReason(s, 0);
|
|
72
55
|
res.json({
|
|
73
56
|
ok: true,
|
|
74
57
|
mode: reason ? 'form' : 'chat',
|
|
@@ -79,24 +62,30 @@ module.exports = function buildFrontDoorGuideRouter() {
|
|
|
79
62
|
|
|
80
63
|
// rank: public — start a chat. Answers the greeting and the conversation's token.
|
|
81
64
|
router.post('/front-door-guide/conversations', fresh, startLimit, api.asyncHandler('POST /front-door-guide/conversations', async (req, res) => {
|
|
82
|
-
if (api.validateOrRespond(req, res, {
|
|
65
|
+
if (api.validateOrRespond(req, res, {
|
|
66
|
+
website: { type: 'string', maxLength: 500 },
|
|
67
|
+
starting_point: { type: 'string', enum: SP.STARTING_POINTS },
|
|
68
|
+
})) return;
|
|
83
69
|
const s = knobs();
|
|
84
70
|
const body = req.body || {};
|
|
85
71
|
if (G.botCheck({ body, userAgent: req.get('user-agent'), now: Date.now(), minFirstMessageMs: s.minFirstMessageMs })) {
|
|
86
72
|
return res.json(G.formAnswer('check_failed'));
|
|
87
73
|
}
|
|
88
74
|
if (!s.apiKey) return res.json(G.formAnswer('no_key'));
|
|
89
|
-
const budget = await siteBudgetReason(s, 0
|
|
75
|
+
const budget = await siteBudgetReason(s, 0);
|
|
90
76
|
if (budget) return res.json(G.formAnswer(budget));
|
|
91
77
|
const visitor = L.visitorHash(L.clientIp(req), salt());
|
|
92
78
|
const used = await db.visitorUsage(visitor);
|
|
93
79
|
if (used.messages >= s.maxMessages || used.spendMicro >= s.visitorCapMicro) return res.json(G.formAnswer('visitor_limit'));
|
|
94
80
|
const token = L.newToken();
|
|
95
|
-
|
|
81
|
+
let conv = await db.createConversation(L.tokenHash(token), visitor);
|
|
82
|
+
if (SP.isStartingPoint(body.starting_point)) conv = (await db.setStartingPoint(conv.id, body.starting_point)) || conv;
|
|
96
83
|
res.status(201).json({
|
|
97
84
|
ok: true, mode: 'chat',
|
|
98
85
|
conversation: { ...G.conversationView(conv, s), token },
|
|
99
|
-
greeting:
|
|
86
|
+
greeting: SP.greetingFor(conv.starting_point),
|
|
87
|
+
starting_point_options: SP.OPTIONS,
|
|
88
|
+
saved: G.savedNotice(s.retentionDays),
|
|
100
89
|
});
|
|
101
90
|
}));
|
|
102
91
|
|
|
@@ -108,6 +97,7 @@ module.exports = function buildFrontDoorGuideRouter() {
|
|
|
108
97
|
token: { required: true, type: 'string', maxLength: 64 },
|
|
109
98
|
text: { required: true, type: 'string', maxLength: 100_000 },
|
|
110
99
|
website: { type: 'string', maxLength: 500 },
|
|
100
|
+
starting_point: { type: 'string', enum: SP.STARTING_POINTS },
|
|
111
101
|
})) return;
|
|
112
102
|
const s = knobs();
|
|
113
103
|
const body = req.body || {};
|
|
@@ -122,7 +112,10 @@ module.exports = function buildFrontDoorGuideRouter() {
|
|
|
122
112
|
const conv = await db.getConversation(req.params.id, L.tokenHash(body.token));
|
|
123
113
|
if (!conv) return res.status(404).json({ ok: false, error: 'not_found' });
|
|
124
114
|
const view = () => G.conversationView(conv, s);
|
|
125
|
-
const ended = (reason) => ({
|
|
115
|
+
const ended = (reason) => ({
|
|
116
|
+
ok: true, mode: 'ended', reason, message: G.END_REASONS[reason] || G.END_REASONS.message_cap,
|
|
117
|
+
next_step: reason === 'planned' ? 'sign_up' : 'plan', conversation: view(),
|
|
118
|
+
});
|
|
126
119
|
if (conv.status === 'ended') return res.json(ended(conv.end_reason));
|
|
127
120
|
|
|
128
121
|
const bot = G.botCheck({
|
|
@@ -141,14 +134,20 @@ module.exports = function buildFrontDoorGuideRouter() {
|
|
|
141
134
|
return res.json(ended('message_cap'));
|
|
142
135
|
}
|
|
143
136
|
|
|
144
|
-
|
|
137
|
+
// A starting point sent with the message (the visitor pressed one of the buttons)
|
|
138
|
+
// is stored first, so this reply already follows it.
|
|
139
|
+
if (SP.isStartingPoint(body.starting_point) && conv.starting_point !== body.starting_point) {
|
|
140
|
+
Object.assign(conv, { starting_point: body.starting_point });
|
|
141
|
+
await db.setStartingPoint(conv.id, body.starting_point);
|
|
142
|
+
}
|
|
143
|
+
const system = G.systemPrompt(productName(), conv.starting_point);
|
|
145
144
|
const messages = [...(conv.messages || []), { role: 'user', content: text }];
|
|
146
145
|
const estimate = G.estimateMicro({ model: s.model, system, messages, maxReplyTokens: s.maxReplyTokens });
|
|
147
146
|
if (used.spendMicro + estimate > s.visitorCapMicro) {
|
|
148
147
|
await db.endConversation(conv.id, 'visitor_budget');
|
|
149
148
|
return res.json(ended('visitor_budget'));
|
|
150
149
|
}
|
|
151
|
-
const budget = await siteBudgetReason(s, estimate
|
|
150
|
+
const budget = await siteBudgetReason(s, estimate);
|
|
152
151
|
if (budget) return res.json(G.formAnswer(budget));
|
|
153
152
|
|
|
154
153
|
const release = reservations.take(visitor, estimate);
|
|
@@ -182,7 +181,7 @@ module.exports = function buildFrontDoorGuideRouter() {
|
|
|
182
181
|
res.json({
|
|
183
182
|
ok: true,
|
|
184
183
|
mode: endReason ? 'ended' : 'chat',
|
|
185
|
-
...(endReason ? { reason: endReason, message: G.END_REASONS[endReason] } : {}),
|
|
184
|
+
...(endReason ? { reason: endReason, message: G.END_REASONS[endReason], next_step: 'plan' } : {}),
|
|
186
185
|
...(refused ? { declined: true } : {}),
|
|
187
186
|
reply: { text: reply },
|
|
188
187
|
conversation: G.conversationView(saved || { ...conv, visitor_messages: Number(conv.visitor_messages) + 1 }, s),
|
|
@@ -199,6 +198,10 @@ module.exports = function buildFrontDoorGuideRouter() {
|
|
|
199
198
|
ok: true,
|
|
200
199
|
conversation: { ...G.conversationView(conv, knobs()), status: conv.status, end_reason: conv.end_reason || null },
|
|
201
200
|
transcript: G.transcriptView(conv.messages),
|
|
201
|
+
plan: conv.plan || null,
|
|
202
|
+
...(conv.plan ? { next: P.NEXT_STEP } : {}),
|
|
203
|
+
saved: G.savedNotice(knobs().retentionDays),
|
|
204
|
+
attached: conv.builder_id != null,
|
|
202
205
|
});
|
|
203
206
|
}));
|
|
204
207
|
|
|
@@ -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
|
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// modules/front-door-guide/starting-point.js — fresh or existing (task 1005279). The
|
|
2
|
+
// guide asks early whether the visitor is starting from nothing or already has something
|
|
3
|
+
// (code, documents, artwork), and for an existing project it asks what they have and what
|
|
4
|
+
// they want to build next, then plans from their DESCRIPTION.
|
|
5
|
+
//
|
|
6
|
+
// THE OWNER'S HARD RULE: the platform never reads a visitor's repository or files on its
|
|
7
|
+
// own AI bill. So this branch adds no upload, no fetch and no link-reading path: the call
|
|
8
|
+
// carries text only and no tools (claude.buildRequest), and the guide is told it cannot
|
|
9
|
+
// open anything and must ask for a description instead. The deep read of their work is
|
|
10
|
+
// done later by their own AI, after sign-up (task 1005280).
|
|
11
|
+
//
|
|
12
|
+
// Pure.
|
|
13
|
+
|
|
14
|
+
'use strict';
|
|
15
|
+
|
|
16
|
+
const STARTING_POINTS = Object.freeze(['fresh', 'existing']);
|
|
17
|
+
|
|
18
|
+
// The two answers, for the front end's buttons under the greeting.
|
|
19
|
+
const OPTIONS = Object.freeze([
|
|
20
|
+
Object.freeze({ value: 'fresh', label: 'Starting fresh' }),
|
|
21
|
+
Object.freeze({ value: 'existing', label: 'I already have something' }),
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
const GREETINGS = Object.freeze({
|
|
25
|
+
unknown: 'Hi! Are you starting a new project from scratch, or do you already have something, like code, documents or artwork? Either way, tell me what you would like to build.',
|
|
26
|
+
fresh: 'Hi! Tell me what you would like to build and what you want it to do for you. A sentence or two is plenty.',
|
|
27
|
+
existing: 'Hi! Tell me about what you already have (code, documents, artwork, and how far along it is), and what you want to build next. Describe it in your own words: I cannot open files or links.',
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const isStartingPoint = (v) => STARTING_POINTS.includes(v);
|
|
31
|
+
|
|
32
|
+
function greetingFor(startingPoint) {
|
|
33
|
+
return GREETINGS[isStartingPoint(startingPoint) ? startingPoint : 'unknown'];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// The part of the guide's instructions that depends on the starting point. Every version
|
|
37
|
+
// carries the no-reading rule, because a visitor can mention existing work at any time.
|
|
38
|
+
function instructionsFor(startingPoint) {
|
|
39
|
+
const noReading = 'You cannot see, open or read any file, repository, code, document, image or link, and nothing the visitor has made is sent to you. Never ask them to paste code, upload files or share links. If they paste a link, say you cannot open links and ask them to describe it. Plan only from what they tell you in their own words; after sign-up, their own AI tools can look at their existing work in detail.';
|
|
40
|
+
if (startingPoint === 'existing') {
|
|
41
|
+
return [
|
|
42
|
+
'This visitor already has something. Early on, ask what they have (code, documents, artwork or something else, and how far along it is) and what they want to build next.',
|
|
43
|
+
noReading,
|
|
44
|
+
].join('\n');
|
|
45
|
+
}
|
|
46
|
+
if (startingPoint === 'fresh') {
|
|
47
|
+
return ['This visitor is starting fresh, from an idea.', noReading].join('\n');
|
|
48
|
+
}
|
|
49
|
+
return [
|
|
50
|
+
'You do not yet know whether this visitor is starting fresh or already has something. If it is not clear from what they say, ask early, in one short question. If they already have something, ask what they have (code, documents, artwork, how far along) and what they want to build next.',
|
|
51
|
+
noReading,
|
|
52
|
+
].join('\n');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
module.exports = { STARTING_POINTS, OPTIONS, GREETINGS, isStartingPoint, greetingFor, instructionsFor };
|
|
@@ -188,6 +188,30 @@
|
|
|
188
188
|
}).join('') + '</ol>';
|
|
189
189
|
}
|
|
190
190
|
|
|
191
|
+
// task 1005109: Copy prompt on each task still to do, so the founder pastes it into
|
|
192
|
+
// Claude without leaving Genesis. The Tasks board's own button (work.js), and only that
|
|
193
|
+
// one verb: the owner declined a Claim button here. A finished task offers nothing.
|
|
194
|
+
function copyButtonHtml(t) {
|
|
195
|
+
if (t.finished) return '';
|
|
196
|
+
return '<button class="task__copy" type="button" data-genesis-copy="' + esc(t.id) + '"'
|
|
197
|
+
+ ' title="Copy the task prompt to your clipboard" aria-label="Copy prompt for task ' + esc(t.id) + '">'
|
|
198
|
+
+ '<span class="task__copy-icon" aria-hidden="true">📋</span></button>';
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// The click: the stage task (with the description the genesis feed carries) handed
|
|
202
|
+
// to the hall's one Copy prompt (task-lib.js copyTaskPrompt). No wording of our own.
|
|
203
|
+
function copyPrompt(btn, state) {
|
|
204
|
+
var id = btn.getAttribute('data-genesis-copy');
|
|
205
|
+
var g = state.genesis;
|
|
206
|
+
var lib = window.OTBTaskLib;
|
|
207
|
+
var t = null;
|
|
208
|
+
(g ? g.stages : []).forEach(function (s) {
|
|
209
|
+
(s.tasks || []).forEach(function (x) { if (String(x.id) === String(id)) t = x; });
|
|
210
|
+
});
|
|
211
|
+
if (!t || !lib || typeof lib.copyTaskPrompt !== 'function') return Promise.resolve(false);
|
|
212
|
+
return lib.copyTaskPrompt(t, btn, toast);
|
|
213
|
+
}
|
|
214
|
+
|
|
191
215
|
function nowHtml(g, viewerId) {
|
|
192
216
|
var s = g.stages.find(function (x) { return x.state === 'now'; });
|
|
193
217
|
if (!s) return '';
|
|
@@ -195,7 +219,8 @@
|
|
|
195
219
|
? '<ul class="genesis__tasks">' + s.tasks.map(function (t) {
|
|
196
220
|
return '<li class="genesis__task' + (t.finished ? ' is-finished' : '') + '">'
|
|
197
221
|
+ '<a class="genesis__task-t" href="/task/' + encodeURIComponent(t.id) + '">' + esc(t.title) + '</a>'
|
|
198
|
-
+ window.OTB.statusPill(t.status, { small: true, extraClass: 'genesis__task-st' })
|
|
222
|
+
+ window.OTB.statusPill(t.status, { small: true, extraClass: 'genesis__task-st' })
|
|
223
|
+
+ copyButtonHtml(t) + '</li>';
|
|
199
224
|
}).join('') + '</ul>'
|
|
200
225
|
: '<p class="genesis__note">No tasks for this stage yet.</p>';
|
|
201
226
|
return '<section class="genesis__now" aria-labelledby="genesis-now-h">'
|
|
@@ -356,7 +381,9 @@
|
|
|
356
381
|
host.addEventListener('click', function (e) {
|
|
357
382
|
var t = e.target.closest('button');
|
|
358
383
|
if (!t || !host.contains(t)) return;
|
|
359
|
-
if (t.hasAttribute('data-genesis-
|
|
384
|
+
if (t.hasAttribute('data-genesis-copy')) {
|
|
385
|
+
copyPrompt(t, state);
|
|
386
|
+
} else if (t.hasAttribute('data-genesis-plan')) {
|
|
360
387
|
// The government picked here is filed as a charter amendment with the plan
|
|
361
388
|
// (task 1004656). When the founder's one yes decides it, saving the plan is
|
|
362
389
|
// that yes too, the same one-click fast-track the stage closes take.
|
|
@@ -390,20 +390,8 @@
|
|
|
390
390
|
// goal-title match — a single goal makes that redundant).
|
|
391
391
|
// Copy-prompt (work.js:454-503). deHashRefs stops a bare #NNN autolinking to
|
|
392
392
|
// GitHub in a pasted session; the prompt points the builder at the hall task URL.
|
|
393
|
-
// Clipboard write that says whether it worked (the bulk prompts report it).
|
|
394
|
-
|
|
395
|
-
try { await navigator.clipboard.writeText(text); return true; }
|
|
396
|
-
catch (_) {
|
|
397
|
-
try {
|
|
398
|
-
const ta = document.createElement('textarea');
|
|
399
|
-
ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0';
|
|
400
|
-
document.body.appendChild(ta); ta.select();
|
|
401
|
-
const ok = document.execCommand('copy');
|
|
402
|
-
ta.remove();
|
|
403
|
-
return ok;
|
|
404
|
-
} catch (_e) { return false; }
|
|
405
|
-
}
|
|
406
|
-
}
|
|
393
|
+
// Clipboard write that says whether it worked (the bulk prompts report it): task-lib's one copy.
|
|
394
|
+
const copyText = (text) => window.OTBTaskLib.copyText(text);
|
|
407
395
|
// task 1004587 (ADR 0366 D4) — the optional "Plan with Claude" hand-off; every planning form works without it.
|
|
408
396
|
async function planWithClaude() {
|
|
409
397
|
const d = state.details.get(String(state.current));
|
|
@@ -419,21 +407,7 @@
|
|
|
419
407
|
async function copyPromptForTask(taskId, btn) {
|
|
420
408
|
const t = state.work.todo.find((x) => String(x.id) === String(taskId));
|
|
421
409
|
if (!t) return;
|
|
422
|
-
|
|
423
|
-
const icon = btn.querySelector('.task__copy-icon');
|
|
424
|
-
try {
|
|
425
|
-
await navigator.clipboard.writeText(prompt);
|
|
426
|
-
if (icon) icon.textContent = '✓';
|
|
427
|
-
setTimeout(() => { if (icon) icon.textContent = '📋'; }, 1600);
|
|
428
|
-
toast(`Prompt for #${taskId} copied to your clipboard.`);
|
|
429
|
-
} catch (err) {
|
|
430
|
-
try {
|
|
431
|
-
const ta = document.createElement('textarea');
|
|
432
|
-
ta.value = prompt; ta.style.position = 'fixed'; ta.style.opacity = '0';
|
|
433
|
-
document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove();
|
|
434
|
-
toast(`Prompt for #${taskId} copied.`);
|
|
435
|
-
} catch (_) { toast('Could not copy to the clipboard. Try again.'); }
|
|
436
|
-
}
|
|
410
|
+
await window.OTBTaskLib.copyTaskPrompt(t, btn, toast);
|
|
437
411
|
}
|
|
438
412
|
|
|
439
413
|
// The claimable card (ported from work.js renderClaimable, work.js:345-394).
|
|
@@ -125,7 +125,8 @@
|
|
|
125
125
|
<script src="/builders/founding-mark.js?v=2026-09-30-founding" defer></script>
|
|
126
126
|
<script src="/builders/hall-widgets.js?v=2026-11-04-reading-room" defer></script>
|
|
127
127
|
<!-- The genesis home (task 1004423): a Home banner card that renders only in founding mode. -->
|
|
128
|
-
<script src="/builders/
|
|
128
|
+
<script src="/builders/task-lib.js" defer></script>
|
|
129
|
+
<script src="/builders/genesis-home.js?v=2026-11-13-copy" defer></script>
|
|
129
130
|
<!-- task 1005054: the project city — the drawing (pure) and the widget — in the
|
|
130
131
|
home-stats zone; the Goals card (goals.js) left Home with it. -->
|
|
131
132
|
<script src="/builders/city-draw.js" defer></script>
|
|
@@ -205,11 +205,56 @@
|
|
|
205
205
|
return (optionValues || []).includes(focusId) ? focusId : null;
|
|
206
206
|
}
|
|
207
207
|
|
|
208
|
+
// copyText(text) → Promise<boolean>: the hall's one clipboard write. The async
|
|
209
|
+
// clipboard first, then the hidden-textarea fallback for an insecure context or a
|
|
210
|
+
// browser without it; says whether it worked so the caller can tell the builder.
|
|
211
|
+
async function copyText(text) {
|
|
212
|
+
try { await navigator.clipboard.writeText(text); return true; } catch (_) { /* fall back */ }
|
|
213
|
+
try {
|
|
214
|
+
const ta = document.createElement('textarea');
|
|
215
|
+
ta.value = text;
|
|
216
|
+
ta.setAttribute('readonly', '');
|
|
217
|
+
ta.style.position = 'fixed';
|
|
218
|
+
ta.style.opacity = '0';
|
|
219
|
+
document.body.appendChild(ta);
|
|
220
|
+
ta.select();
|
|
221
|
+
const ok = document.execCommand('copy');
|
|
222
|
+
ta.remove();
|
|
223
|
+
return ok;
|
|
224
|
+
} catch (_) { return false; }
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// copyTaskPrompt(t, btn, toast) — the per-task "Copy prompt" button's whole click
|
|
228
|
+
// (task 1005109): buildPromptForTask's text onto the clipboard, the button's 📋 →
|
|
229
|
+
// ✓ flip for 1.6s, and the one toast wording. The Tasks board, a goal's to-do list
|
|
230
|
+
// and the Genesis stage list all call this, so the button behaves and reads the
|
|
231
|
+
// same everywhere. `t` needs its description, or the prompt says "(no description)".
|
|
232
|
+
async function copyTaskPrompt(t, btn, toast) {
|
|
233
|
+
const say = typeof toast === 'function' ? toast : () => {};
|
|
234
|
+
const ok = await copyText(buildPromptForTask(t));
|
|
235
|
+
if (!ok) { say('Could not copy to the clipboard. Try again.'); return false; }
|
|
236
|
+
if (btn) {
|
|
237
|
+
const icon = btn.querySelector && btn.querySelector('.task__copy-icon');
|
|
238
|
+
const label = btn.querySelector && btn.querySelector('.task__copy-label');
|
|
239
|
+
btn.setAttribute('data-copied', '1');
|
|
240
|
+
if (icon) icon.textContent = '✓';
|
|
241
|
+
if (label) label.textContent = 'Copied';
|
|
242
|
+
setTimeout(() => {
|
|
243
|
+
btn.setAttribute('data-copied', '0');
|
|
244
|
+
if (icon) icon.textContent = '📋';
|
|
245
|
+
if (label) label.textContent = 'Copy prompt';
|
|
246
|
+
}, 1600);
|
|
247
|
+
}
|
|
248
|
+
say(`Prompt for task ${t.id} copied to your clipboard.`);
|
|
249
|
+
return true;
|
|
250
|
+
}
|
|
251
|
+
|
|
208
252
|
const api = {
|
|
209
253
|
focusVersionDefault,
|
|
210
254
|
msOf, taskActivityMs, STALE_CHOICES, isStale,
|
|
211
255
|
priorityRank, touchesConflict, conflictPeers, conflictChipHtml, rankTagHtml, deHashRefs,
|
|
212
256
|
creditsPerMinute, buildPromptForTask, buildPlanPromptForGoal, friendlyBatchReason,
|
|
257
|
+
copyText, copyTaskPrompt,
|
|
213
258
|
};
|
|
214
259
|
if (typeof window !== 'undefined') window.OTBTaskLib = api;
|
|
215
260
|
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
|
@@ -641,54 +641,10 @@
|
|
|
641
641
|
async function copyPromptForTask(taskId, btn) {
|
|
642
642
|
const t = taskState.all.find((x) => String(x.id) === String(taskId));
|
|
643
643
|
if (!t) return;
|
|
644
|
-
|
|
645
|
-
const icon = btn.querySelector('.task__copy-icon');
|
|
646
|
-
const label = btn.querySelector('.task__copy-label');
|
|
647
|
-
try {
|
|
648
|
-
await navigator.clipboard.writeText(prompt);
|
|
649
|
-
btn.dataset.copied = '1';
|
|
650
|
-
if (icon) icon.textContent = '✓';
|
|
651
|
-
if (label) label.textContent = 'Copied';
|
|
652
|
-
setTimeout(() => {
|
|
653
|
-
btn.dataset.copied = '0';
|
|
654
|
-
if (icon) icon.textContent = '📋';
|
|
655
|
-
if (label) label.textContent = 'Copy prompt';
|
|
656
|
-
}, 1600);
|
|
657
|
-
toast(`Prompt for #${taskId} copied to your clipboard.`);
|
|
658
|
-
} catch (err) {
|
|
659
|
-
try {
|
|
660
|
-
const ta = document.createElement('textarea');
|
|
661
|
-
ta.value = prompt;
|
|
662
|
-
ta.style.position = 'fixed';
|
|
663
|
-
ta.style.opacity = '0';
|
|
664
|
-
document.body.appendChild(ta);
|
|
665
|
-
ta.select();
|
|
666
|
-
document.execCommand('copy');
|
|
667
|
-
ta.remove();
|
|
668
|
-
toast(`Prompt for #${taskId} copied.`);
|
|
669
|
-
} catch (_) {
|
|
670
|
-
toast('Could not copy to the clipboard. Try again.');
|
|
671
|
-
}
|
|
672
|
-
}
|
|
673
|
-
}
|
|
674
|
-
async function copyText(text) {
|
|
675
|
-
try { await navigator.clipboard.writeText(text); return true; }
|
|
676
|
-
catch (_) {
|
|
677
|
-
try {
|
|
678
|
-
const ta = document.createElement('textarea');
|
|
679
|
-
ta.value = text;
|
|
680
|
-
ta.style.position = 'fixed';
|
|
681
|
-
ta.style.opacity = '0';
|
|
682
|
-
document.body.appendChild(ta);
|
|
683
|
-
ta.select();
|
|
684
|
-
const ok = document.execCommand('copy');
|
|
685
|
-
ta.remove();
|
|
686
|
-
return ok;
|
|
687
|
-
} catch (_e) {
|
|
688
|
-
return false;
|
|
689
|
-
}
|
|
690
|
-
}
|
|
644
|
+
await window.OTBTaskLib.copyTaskPrompt(t, btn, toast);
|
|
691
645
|
}
|
|
646
|
+
// The hall's one clipboard write (task-lib.js), kept under this name for the ctx below.
|
|
647
|
+
const copyText = (text) => window.OTBTaskLib.copyText(text);
|
|
692
648
|
|
|
693
649
|
// ---- bulk selection (task 1759) -------------------------------------------
|
|
694
650
|
|
|
@@ -112,20 +112,24 @@ async function seedGenesisStages({ builderId = null, existing = false } = {}, de
|
|
|
112
112
|
return { seeded, versionId };
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
-
// genesisStageTasks(deps) → { stageKey: [{ id, title, status }] } for every
|
|
116
|
-
// task on the board, grouped by the stage its source_ref names. The genesis home
|
|
117
|
-
// them, and a stage close hands their ids to the Board Room's finished-tasks gate.
|
|
115
|
+
// genesisStageTasks(deps) → { stageKey: [{ id, title, status, description }] } for every
|
|
116
|
+
// genesis task on the board, grouped by the stage its source_ref names. The genesis home
|
|
117
|
+
// lists them, and a stage close hands their ids to the Board Room's finished-tasks gate.
|
|
118
|
+
// The description rides along so each row's Copy prompt carries the task's own words
|
|
119
|
+
// (task 1005109); the read stays bounded, it is only the few seeded genesis tasks.
|
|
118
120
|
async function genesisStageTasks(deps = {}) {
|
|
119
121
|
const pool = deps.pool || api.pool;
|
|
120
122
|
const { rows } = await pool.query(
|
|
121
|
-
`SELECT id, title, status, source_ref FROM tasks WHERE source = $1 ORDER BY id ASC`,
|
|
123
|
+
`SELECT id, title, status, description, source_ref FROM tasks WHERE source = $1 ORDER BY id ASC`,
|
|
122
124
|
[GENESIS_SOURCE],
|
|
123
125
|
);
|
|
124
126
|
const out = {};
|
|
125
127
|
for (const r of rows) {
|
|
126
128
|
const stage = String(r.source_ref || '').split(':')[1];
|
|
127
129
|
if (!stage) continue;
|
|
128
|
-
(out[stage] = out[stage] || []).push({
|
|
130
|
+
(out[stage] = out[stage] || []).push({
|
|
131
|
+
id: String(r.id), title: r.title, status: r.status, description: r.description || '',
|
|
132
|
+
});
|
|
129
133
|
}
|
|
130
134
|
return out;
|
|
131
135
|
}
|
|
@@ -146,7 +146,7 @@ const FINISHED = Object.freeze(['shipped', 'abandoned']); // the Board Room's ow
|
|
|
146
146
|
// shapeGenesis({ mode, plan, tasks, board, stageAt }) — PURE. The genesis home's view.
|
|
147
147
|
// mode foundingModeFor's shape (non-null)
|
|
148
148
|
// plan the saved plan or null (deciding)
|
|
149
|
-
// tasks { stage: [{ id, title, status }] }
|
|
149
|
+
// tasks { stage: [{ id, title, status, description }] }
|
|
150
150
|
// board { stage: genesisStageStatus row } or {}
|
|
151
151
|
// stageAt { stage: ISO } — when each stage began, for its due date
|
|
152
152
|
function shapeGenesis({ mode, plan, tasks = {}, board = {}, stageAt = {} }) {
|