termi-kids 0.1.0

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 (64) hide show
  1. package/LICENSE +34 -0
  2. package/README.md +148 -0
  3. package/SAFETY.md +187 -0
  4. package/bin/termi.js +22 -0
  5. package/dist/agent/context.js +126 -0
  6. package/dist/agent/loop.js +172 -0
  7. package/dist/agent/prompts/system.js +45 -0
  8. package/dist/agent/tools.js +335 -0
  9. package/dist/auth/keychain.js +146 -0
  10. package/dist/auth/oauth.js +375 -0
  11. package/dist/auth/tokens.js +219 -0
  12. package/dist/cli.js +258 -0
  13. package/dist/config/paths.js +92 -0
  14. package/dist/config/pin.js +150 -0
  15. package/dist/config/settings.js +131 -0
  16. package/dist/grownups/panel.js +483 -0
  17. package/dist/learn/lessons.js +490 -0
  18. package/dist/learn/runner.js +193 -0
  19. package/dist/preview/server.js +407 -0
  20. package/dist/projects/create.js +103 -0
  21. package/dist/projects/ideas.js +182 -0
  22. package/dist/projects/quests.js +277 -0
  23. package/dist/projects/scaffolds/art.js +484 -0
  24. package/dist/projects/scaffolds/biggames.js +554 -0
  25. package/dist/projects/scaffolds/characters.js +580 -0
  26. package/dist/projects/scaffolds/games.js +516 -0
  27. package/dist/projects/scaffolds/index.js +24 -0
  28. package/dist/projects/scaffolds/music.js +528 -0
  29. package/dist/projects/scaffolds/pets.js +567 -0
  30. package/dist/projects/scaffolds/quizzes.js +757 -0
  31. package/dist/projects/scaffolds/stories.js +620 -0
  32. package/dist/projects/scaffolds/vendor/KAPLAY-LICENSE.txt +35 -0
  33. package/dist/projects/scaffolds/vendor/kaplay.mjs +57 -0
  34. package/dist/projects/scaffolds/websites.js +474 -0
  35. package/dist/projects/snapshots.js +203 -0
  36. package/dist/projects/store.js +325 -0
  37. package/dist/providers/errors.js +207 -0
  38. package/dist/providers/index.js +316 -0
  39. package/dist/providers/models.js +38 -0
  40. package/dist/safety/audit.js +195 -0
  41. package/dist/safety/blocks.js +29 -0
  42. package/dist/safety/classifier.js +337 -0
  43. package/dist/safety/codescan.js +168 -0
  44. package/dist/safety/guarddownload.js +79 -0
  45. package/dist/safety/guardrunner.js +125 -0
  46. package/dist/safety/localguard.js +227 -0
  47. package/dist/safety/modelstore.js +127 -0
  48. package/dist/safety/prefilter.js +214 -0
  49. package/dist/safety/session.js +118 -0
  50. package/dist/safety/taxonomy.js +246 -0
  51. package/dist/safety/textextract.js +193 -0
  52. package/dist/setup/launcher.js +65 -0
  53. package/dist/setup/wizard.js +469 -0
  54. package/dist/surfaces/chat.js +439 -0
  55. package/dist/surfaces/commands.js +206 -0
  56. package/dist/surfaces/home.js +438 -0
  57. package/dist/types.js +5 -0
  58. package/dist/ui/banner.js +35 -0
  59. package/dist/ui/celebrate.js +141 -0
  60. package/dist/ui/errors.js +97 -0
  61. package/dist/ui/mascot.js +223 -0
  62. package/dist/ui/text.js +156 -0
  63. package/dist/ui/theme.js +92 -0
  64. package/package.json +67 -0
@@ -0,0 +1,439 @@
1
+ /**
2
+ * The build session: live preview, safety wiring, the agent turn loop,
3
+ * heartbeat while waiting, typewriter reveal, badges, and chat commands.
4
+ *
5
+ * The agent loop and project snapshot modules load lazily so this file
6
+ * imports cleanly even while other wave modules are still landing.
7
+ */
8
+ import * as p from '@clack/prompts';
9
+ import { getSecret } from '../auth/keychain.js';
10
+ import { hasTokens } from '../auth/tokens.js';
11
+ import { startPreview } from '../preview/server.js';
12
+ import { createProviderClient, pickClassifierBackend, } from '../providers/index.js';
13
+ import { classifyProviderError } from '../providers/errors.js';
14
+ import { appendAudit } from '../safety/audit.js';
15
+ import { createSafetyPipeline } from '../safety/classifier.js';
16
+ import { ensureGuardFetch } from '../safety/guarddownload.js';
17
+ import { lazyGuardAccessor } from '../safety/guardrunner.js';
18
+ import { guardModelReady } from '../safety/modelstore.js';
19
+ import { createSessionState } from '../safety/session.js';
20
+ import { renderProviderError } from '../ui/errors.js';
21
+ import { heartbeatLine, mascot } from '../ui/mascot.js';
22
+ import { style } from '../ui/theme.js';
23
+ import { T } from '../ui/text.js';
24
+ import { questsFor, questStepLine } from '../projects/quests.js';
25
+ import { providerLabel } from '../setup/wizard.js';
26
+ import { executeDone, executeIdeas, executePreview, executeRedo, executeUndo, helpText, parseCommand, } from './commands.js';
27
+ import { awardBadge, loadBadges } from './home.js';
28
+ import { renderBadgeShelf } from '../ui/celebrate.js';
29
+ /** Milliseconds between heartbeat updates while the agent works. */
30
+ const HEARTBEAT_MS = 3500;
31
+ /** Per-character delay for the typewriter reveal. */
32
+ const TYPE_DELAY_MS = 12;
33
+ /** Ceiling on the whole reveal, so long replies never feel slow. */
34
+ const TYPE_TOTAL_BUDGET_MS = 1500;
35
+ function sleep(ms) {
36
+ return new Promise((resolve) => setTimeout(resolve, ms));
37
+ }
38
+ /**
39
+ * Reveals reply text one character at a time. Instant when the
40
+ * TERMI_FAST_TEXT=1 env flag is set (tests and impatient builders).
41
+ */
42
+ export async function typewriter(text, write = (chunk) => void process.stdout.write(chunk), delayMs = TYPE_DELAY_MS) {
43
+ // The reveal never exceeds the total budget: long replies speed up and
44
+ // print instantly once the per-character delay rounds down to zero.
45
+ const perChar = Math.min(delayMs, Math.floor(TYPE_TOTAL_BUDGET_MS / Math.max(1, text.length)));
46
+ if (process.env.TERMI_FAST_TEXT === '1' || perChar <= 0) {
47
+ write(`${text}\n`);
48
+ return;
49
+ }
50
+ for (const ch of text) {
51
+ write(ch);
52
+ await sleep(perChar);
53
+ }
54
+ write('\n');
55
+ }
56
+ /** True when the reply already offers what to try next. */
57
+ export function replyHasTryNext(reply) {
58
+ return /try (this|it|that|next)|next,? (try|you)|how about|you could/i.test(reply);
59
+ }
60
+ function mascotExpressionFrom(name) {
61
+ const known = [
62
+ 'happy',
63
+ 'thinking',
64
+ 'building',
65
+ 'celebrating',
66
+ 'oops',
67
+ 'gentleNo',
68
+ ];
69
+ return known.includes(name) ? name : 'gentleNo';
70
+ }
71
+ function providerAvailability(settings) {
72
+ return {
73
+ 'openai-chatgpt': hasTokens(),
74
+ 'openai-api': (getSecret('api-key-openai-api') ?? '').length > 0,
75
+ anthropic: (getSecret('api-key-anthropic') ?? '').length > 0,
76
+ // A Grok key only counts once a parent confirmed the adults-only terms.
77
+ xai: (getSecret('api-key-xai') ?? '').length > 0 && settings.xaiParentAck,
78
+ };
79
+ }
80
+ function say(text) {
81
+ console.log(text);
82
+ }
83
+ function offlineScreen() {
84
+ return [mascot('gentleNo'), '', T.offline.noProvider, T.offline.stillWorks].join('\n');
85
+ }
86
+ const noopSnapshots = {
87
+ beginTurn() {
88
+ // No snapshot module yet; undo and redo report nothing to do.
89
+ },
90
+ undo: () => false,
91
+ redo: () => false,
92
+ };
93
+ /** The chat screen for one open project. Resolves when the kid leaves. */
94
+ export async function runChat(project, settings) {
95
+ let preview = null;
96
+ try {
97
+ preview = await startPreview(project.dir, { openBrowser: true });
98
+ p.log.success(`${T.chat.previewOpened} ${style.dim(preview.url)}`);
99
+ }
100
+ catch {
101
+ p.log.warn('The preview could not start. We can still build.');
102
+ }
103
+ const audit = (event) => {
104
+ try {
105
+ appendAudit(event);
106
+ }
107
+ catch {
108
+ // The chat never falls over because the audit disk write failed.
109
+ }
110
+ };
111
+ const session = createSessionState();
112
+ let provider = null;
113
+ if (settings.activeProvider !== null) {
114
+ try {
115
+ provider = createProviderClient(settings.activeProvider);
116
+ }
117
+ catch {
118
+ provider = null;
119
+ }
120
+ }
121
+ const backend = pickClassifierBackend(settings, providerAvailability(settings));
122
+ // The guard hot-attaches: null while its model still downloads, live from
123
+ // the first check after the file lands. A missing file also (re)starts the
124
+ // background fetch, and is written to the safety log: a deleted model
125
+ // file must not detach a safety layer invisibly.
126
+ if (settings.localClassifier && !guardModelReady()) {
127
+ audit({
128
+ ts: new Date().toISOString(),
129
+ layer: 'system',
130
+ event: 'guard_missing',
131
+ excerpt: 'safety checker on, model file not present; background fetch started',
132
+ });
133
+ void ensureGuardFetch();
134
+ }
135
+ const safety = createSafetyPipeline({
136
+ classifierModel: () => backend.classifierClient !== null
137
+ ? backend.classifierClient.languageModel('classifier', settings.modelAlias)
138
+ : null,
139
+ moderationKey: () => backend.moderationKey,
140
+ localGuard: lazyGuardAccessor(settings.localClassifier),
141
+ audit,
142
+ });
143
+ let snapshots = noopSnapshots;
144
+ try {
145
+ const snapMod = await import('../projects/snapshots.js');
146
+ snapshots = snapMod.createSnapshotStore(project);
147
+ }
148
+ catch {
149
+ snapshots = noopSnapshots;
150
+ }
151
+ let runTurnFn = null;
152
+ try {
153
+ const loop = await import('../agent/loop.js');
154
+ runTurnFn = loop.runTurn;
155
+ }
156
+ catch {
157
+ runTurnFn = null;
158
+ }
159
+ let ideaPool = null;
160
+ let ideaNext = 0;
161
+ const nextIdeas = async (count) => {
162
+ if (ideaPool === null) {
163
+ try {
164
+ const mod = await import('../projects/ideas.js');
165
+ ideaPool = mod.getIdeas(project.meta.scaffoldId);
166
+ }
167
+ catch {
168
+ ideaPool = [];
169
+ }
170
+ }
171
+ if (ideaPool.length === 0) {
172
+ return [];
173
+ }
174
+ const out = [];
175
+ for (let i = 0; i < count && i < ideaPool.length; i += 1) {
176
+ const idea = ideaPool[ideaNext % ideaPool.length];
177
+ if (idea !== undefined) {
178
+ out.push(idea);
179
+ }
180
+ ideaNext += 1;
181
+ }
182
+ return out;
183
+ };
184
+ const activeLabel = settings.activeProvider !== null ? providerLabel(settings.activeProvider) : 'AI helper';
185
+ let prevTurnErrored = false;
186
+ let turnsTaken = 0;
187
+ const doTurn = async (kidMessage) => {
188
+ if (provider === null || runTurnFn === null) {
189
+ say(offlineScreen());
190
+ return 'offline';
191
+ }
192
+ const started = Date.now();
193
+ let heartbeatCleared = false;
194
+ const spin = p.spinner();
195
+ spin.start(T.chat.thinking);
196
+ const beat = setInterval(() => {
197
+ if (!heartbeatCleared) {
198
+ spin.message(heartbeatLine(Math.round((Date.now() - started) / 1000)));
199
+ }
200
+ }, HEARTBEAT_MS);
201
+ const clearHeartbeat = () => {
202
+ if (!heartbeatCleared) {
203
+ heartbeatCleared = true;
204
+ clearInterval(beat);
205
+ spin.stop(T.chat.working);
206
+ }
207
+ };
208
+ const ui = {
209
+ onActivity(line) {
210
+ clearHeartbeat();
211
+ say(style.dim(` ${line}`));
212
+ },
213
+ };
214
+ let result;
215
+ try {
216
+ result = await runTurnFn(kidMessage, {
217
+ provider,
218
+ modelAlias: settings.modelAlias,
219
+ safety,
220
+ session,
221
+ project,
222
+ snapshots,
223
+ preview,
224
+ audit,
225
+ ui,
226
+ });
227
+ }
228
+ catch (err) {
229
+ clearInterval(beat);
230
+ if (!heartbeatCleared) {
231
+ spin.stop();
232
+ }
233
+ say(renderProviderError(classifyProviderError(err), activeLabel));
234
+ prevTurnErrored = true;
235
+ return 'error';
236
+ }
237
+ clearInterval(beat);
238
+ if (!heartbeatCleared) {
239
+ spin.stop(T.chat.working);
240
+ }
241
+ turnsTaken += 1;
242
+ if (result.status === 'ok') {
243
+ const reply = result.replyText ?? '';
244
+ if (reply.length > 0) {
245
+ await typewriter(reply);
246
+ }
247
+ if (reply.length > 0 && !replyHasTryNext(reply)) {
248
+ const ideas = await nextIdeas(2);
249
+ if (ideas.length > 0) {
250
+ say(style.dim(['Try this next:', ...ideas.map((idea) => ` - ${idea}`)].join('\n')));
251
+ }
252
+ }
253
+ if (result.filesChanged.length > 0) {
254
+ await awardBadge('first-change');
255
+ }
256
+ if (prevTurnErrored) {
257
+ await awardBadge('bug-squasher');
258
+ }
259
+ prevTurnErrored = false;
260
+ return 'ok';
261
+ }
262
+ if (result.status === 'blocked') {
263
+ if (result.screen !== null) {
264
+ say([
265
+ mascot(mascotExpressionFrom(result.screen.mascotExpression)),
266
+ '',
267
+ style.title(result.screen.title),
268
+ result.screen.body,
269
+ ].join('\n'));
270
+ }
271
+ else {
272
+ say([mascot('gentleNo'), '', T.blocks.generic].join('\n'));
273
+ }
274
+ return 'blocked';
275
+ }
276
+ const error = result.error ?? { kind: 'server' };
277
+ say(renderProviderError(error, activeLabel));
278
+ prevTurnErrored = true;
279
+ return 'error';
280
+ };
281
+ const finish = async () => {
282
+ try {
283
+ if (turnsTaken > 0) {
284
+ project.updateTermiMd({ recapLine: `We worked on ${project.meta.prettyName}.` });
285
+ }
286
+ project.touch();
287
+ }
288
+ catch {
289
+ // Notes are best effort on the way out.
290
+ }
291
+ if (preview !== null) {
292
+ try {
293
+ await preview.stop();
294
+ }
295
+ catch {
296
+ // The process is leaving anyway.
297
+ }
298
+ }
299
+ };
300
+ p.log.message(style.dim(T.chat.doneHint));
301
+ if (questsFor(project.meta.scaffoldId).length > 0) {
302
+ p.log.message(style.dim(T.quest.hint));
303
+ }
304
+ let quest = null;
305
+ let questStep = 0;
306
+ const questAdvance = async () => {
307
+ if (quest === null) {
308
+ return;
309
+ }
310
+ questStep += 1;
311
+ if (questStep >= quest.steps.length) {
312
+ quest = null;
313
+ say(style.title(T.quest.finished));
314
+ await awardBadge('quest-hero');
315
+ }
316
+ else {
317
+ say(style.dim(T.quest.stepDone));
318
+ }
319
+ };
320
+ let hintIndex = 0;
321
+ for (;;) {
322
+ const hint = T.hints[hintIndex % T.hints.length] ?? '';
323
+ hintIndex += 1;
324
+ const step = quest !== null ? quest.steps[questStep] : undefined;
325
+ if (quest !== null && step !== undefined) {
326
+ say(style.title(questStepLine(quest, questStep)));
327
+ say(style.dim(T.quest.enterToSend.replace('{prompt}', step.prompt)));
328
+ }
329
+ const raw = await p.text({
330
+ message: T.chat.placeholder,
331
+ placeholder: step !== undefined ? step.prompt : hint,
332
+ });
333
+ if (p.isCancel(raw)) {
334
+ await finish();
335
+ return 'quit';
336
+ }
337
+ const cmd = parseCommand(raw);
338
+ switch (cmd.kind) {
339
+ case 'chat': {
340
+ // In a quest, plain Enter sends the suggested step prompt.
341
+ const text = cmd.text.length > 0 ? cmd.text : (step?.prompt ?? '');
342
+ if (text.length > 0) {
343
+ const outcome = await doTurn(text);
344
+ if (outcome === 'ok') {
345
+ await questAdvance();
346
+ }
347
+ }
348
+ break;
349
+ }
350
+ case 'quest': {
351
+ if (quest !== null) {
352
+ quest = null;
353
+ say(T.quest.stopped);
354
+ break;
355
+ }
356
+ const available = questsFor(project.meta.scaffoldId);
357
+ if (available.length === 0) {
358
+ say(T.quest.none);
359
+ break;
360
+ }
361
+ if (available.length === 1) {
362
+ quest = available[0] ?? null;
363
+ }
364
+ else {
365
+ const picked = await p.select({
366
+ message: T.quest.pick,
367
+ options: available.map((q) => ({ value: q.id, label: `${q.emoji} ${q.title}` })),
368
+ });
369
+ if (p.isCancel(picked)) {
370
+ break;
371
+ }
372
+ quest = available.find((q) => q.id === picked) ?? null;
373
+ }
374
+ if (quest !== null) {
375
+ questStep = 0;
376
+ say(T.quest.start);
377
+ }
378
+ break;
379
+ }
380
+ case 'undo':
381
+ executeUndo(snapshots, preview, say);
382
+ break;
383
+ case 'redo':
384
+ executeRedo(snapshots, preview, say);
385
+ break;
386
+ case 'preview':
387
+ await executePreview(preview, say);
388
+ break;
389
+ case 'ideas':
390
+ await executeIdeas(project.meta.scaffoldId, say);
391
+ break;
392
+ case 'badges':
393
+ say(renderBadgeShelf(loadBadges()));
394
+ break;
395
+ case 'learn':
396
+ try {
397
+ const learn = await import('../learn/runner.js');
398
+ await learn.runLearnMenu();
399
+ }
400
+ catch {
401
+ say('Learn mode is taking a nap. Try again soon.');
402
+ }
403
+ break;
404
+ case 'help':
405
+ say(helpText());
406
+ break;
407
+ case 'done':
408
+ await executeDone({
409
+ scaffoldId: project.meta.scaffoldId,
410
+ prettyName: project.meta.prettyName,
411
+ updateRecap: (line) => {
412
+ project.updateTermiMd({ recapLine: line });
413
+ },
414
+ }, (badgeId) => awardBadge(badgeId), say);
415
+ break;
416
+ case 'new':
417
+ await finish();
418
+ return 'new';
419
+ case 'quit':
420
+ say(T.errors.goodbye);
421
+ await finish();
422
+ return 'quit';
423
+ case 'grownups':
424
+ try {
425
+ const panel = await import('../grownups/panel.js');
426
+ await panel.runPanel();
427
+ }
428
+ catch {
429
+ say(T.grownups.needsGrownup);
430
+ }
431
+ break;
432
+ case 'unknown':
433
+ say(cmd.suggestion !== null
434
+ ? T.chat.didYouMean.replace('{command}', `/${cmd.suggestion}`)
435
+ : T.chat.unknownCommand);
436
+ break;
437
+ }
438
+ }
439
+ }
@@ -0,0 +1,206 @@
1
+ /**
2
+ * Chat command parsing and execution helpers.
3
+ *
4
+ * Parsing is pure and synchronous so it is easy to test. Execution helpers
5
+ * take every dependency as an argument, which keeps chat.ts a thin shell
6
+ * and keeps this module importable before the rest of the wave lands.
7
+ */
8
+ import { celebrate } from '../ui/celebrate.js';
9
+ import { T } from '../ui/text.js';
10
+ import { glyph } from '../ui/theme.js';
11
+ /** Every slash command the chat understands, in help order. */
12
+ export const COMMAND_NAMES = [
13
+ 'preview',
14
+ 'undo',
15
+ 'redo',
16
+ 'new',
17
+ 'ideas',
18
+ 'badges',
19
+ 'learn',
20
+ 'quest',
21
+ 'help',
22
+ 'done',
23
+ 'quit',
24
+ 'grownups',
25
+ ];
26
+ /** Commands a kid can type as a plain word, no slash needed. */
27
+ export const BARE_WORDS = [
28
+ 'undo',
29
+ 'help',
30
+ 'ideas',
31
+ 'done',
32
+ 'preview',
33
+ 'badges',
34
+ 'learn',
35
+ 'quest',
36
+ 'quit',
37
+ ];
38
+ /**
39
+ * Leaving words kids type on their own. Each maps to quit so a goodbye
40
+ * never becomes a paid AI turn.
41
+ */
42
+ export const QUIT_SYNONYMS = ['exit', 'stop', 'bye', 'leave'];
43
+ /** Classic edit distance. Small inputs only, so the simple table is fine. */
44
+ export function levenshtein(a, b) {
45
+ if (a === b)
46
+ return 0;
47
+ if (a.length === 0)
48
+ return b.length;
49
+ if (b.length === 0)
50
+ return a.length;
51
+ let prev = Array.from({ length: b.length + 1 }, (_, i) => i);
52
+ for (let i = 1; i <= a.length; i += 1) {
53
+ const next = [i];
54
+ for (let j = 1; j <= b.length; j += 1) {
55
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
56
+ next[j] = Math.min((next[j - 1] ?? 0) + 1, (prev[j] ?? 0) + 1, (prev[j - 1] ?? 0) + cost);
57
+ }
58
+ prev = next;
59
+ }
60
+ return prev[b.length] ?? 0;
61
+ }
62
+ /** The closest known command within maxDistance edits, or null. */
63
+ export function nearestCommand(word, maxDistance = 2) {
64
+ let best = null;
65
+ let bestDistance = maxDistance + 1;
66
+ for (const name of COMMAND_NAMES) {
67
+ const distance = levenshtein(word, name);
68
+ if (distance < bestDistance) {
69
+ bestDistance = distance;
70
+ best = name;
71
+ }
72
+ }
73
+ return bestDistance <= maxDistance ? best : null;
74
+ }
75
+ /**
76
+ * Turns raw kid input into a typed command.
77
+ * Slash commands match by their first word. Unknown slash words get a
78
+ * did-you-mean suggestion. Single bare words like "undo" also work.
79
+ * Everything else is a chat message for the agent.
80
+ */
81
+ export function parseCommand(raw) {
82
+ const input = raw.trim();
83
+ if (input.length === 0) {
84
+ return { kind: 'chat', text: '' };
85
+ }
86
+ if (input.startsWith('/')) {
87
+ const word = (input.slice(1).trim().split(/\s+/)[0] ?? '').toLowerCase();
88
+ if (COMMAND_NAMES.includes(word)) {
89
+ return { kind: word };
90
+ }
91
+ if (QUIT_SYNONYMS.includes(word)) {
92
+ return { kind: 'quit' };
93
+ }
94
+ return { kind: 'unknown', word, suggestion: nearestCommand(word) };
95
+ }
96
+ if (!/\s/.test(input)) {
97
+ const lower = input.toLowerCase();
98
+ if (BARE_WORDS.includes(lower)) {
99
+ return { kind: lower };
100
+ }
101
+ if (QUIT_SYNONYMS.includes(lower)) {
102
+ return { kind: 'quit' };
103
+ }
104
+ }
105
+ return { kind: 'chat', text: input };
106
+ }
107
+ /** The kid-facing command list. */
108
+ export function helpText() {
109
+ const rows = [
110
+ ['/preview', 'watch your project run'],
111
+ ['/undo', 'take back the last change'],
112
+ ['/redo', 'bring a change back'],
113
+ ['/new', 'start a fresh project'],
114
+ ['/ideas', 'get fun ideas'],
115
+ ['/badges', 'see your badges'],
116
+ ['/learn', 'play short AI lessons'],
117
+ ['/quest', 'build with me, step by step'],
118
+ ['/help', 'show this list'],
119
+ ['/done', 'finish and celebrate'],
120
+ ['/quit', 'stop for today'],
121
+ ['/grownups', 'grown-up zone, PIN needed'],
122
+ ];
123
+ const lines = rows.map(([cmd, what]) => ` ${cmd.padEnd(10)} ${what}`);
124
+ return [
125
+ 'Here is what I can do:',
126
+ ...lines,
127
+ 'Plain words work too, like undo, ideas, or quit.',
128
+ ].join('\n');
129
+ }
130
+ /** Undo the last change and tell the preview to reload. */
131
+ export function executeUndo(snapshots, preview, say) {
132
+ const ok = snapshots.undo();
133
+ if (ok) {
134
+ preview?.notifyChange();
135
+ say(T.chat.undoDone);
136
+ }
137
+ else {
138
+ say(T.chat.nothingToUndo);
139
+ }
140
+ return ok;
141
+ }
142
+ /** Bring back the change that undo removed. */
143
+ export function executeRedo(snapshots, preview, say) {
144
+ const ok = snapshots.redo();
145
+ if (ok) {
146
+ preview?.notifyChange();
147
+ say(T.chat.redoDone);
148
+ }
149
+ else {
150
+ say(T.chat.nothingToRedo);
151
+ }
152
+ return ok;
153
+ }
154
+ /** Prints idea lines for a scaffold. Loads the idea bank lazily. */
155
+ export async function executeIdeas(scaffoldId, say) {
156
+ let ideas = [];
157
+ try {
158
+ const mod = await import('../projects/ideas.js');
159
+ ideas = mod.getIdeas(scaffoldId);
160
+ }
161
+ catch {
162
+ ideas = [];
163
+ }
164
+ if (ideas.length === 0) {
165
+ say('I am out of ideas right now. Try /help.');
166
+ return;
167
+ }
168
+ const bullet = glyph('bulb');
169
+ say(['Here are some ideas:', ...ideas.map((idea) => ` ${bullet} ${idea}`)].join('\n'));
170
+ }
171
+ /** Reopens the browser at the preview URL. */
172
+ export async function executePreview(preview, say) {
173
+ if (preview === null) {
174
+ say('The preview is not running right now.');
175
+ return;
176
+ }
177
+ say(`${T.chat.previewOpened} ${preview.url}`);
178
+ try {
179
+ const mod = await import('open');
180
+ await mod.default(preview.url);
181
+ }
182
+ catch {
183
+ // The URL is printed above, so the kid can still open it by hand.
184
+ }
185
+ }
186
+ /** True for the project types where /done earns the Game Shipped badge. */
187
+ export function donEarnsGameBadge(scaffoldId) {
188
+ return scaffoldId === 'games' || scaffoldId === 'biggames';
189
+ }
190
+ /**
191
+ * The /done moment: celebration, the Game Shipped badge for game projects,
192
+ * and a recap line saved for the next session.
193
+ */
194
+ export async function executeDone(target, award, say) {
195
+ const isGame = donEarnsGameBadge(target.scaffoldId);
196
+ say(celebrate(isGame ? T.celebrations.gameShipped : T.celebrations.generic));
197
+ if (isGame) {
198
+ await award('game-shipped');
199
+ }
200
+ try {
201
+ target.updateRecap(`We finished ${target.prettyName}!`);
202
+ }
203
+ catch {
204
+ // Notes are best effort; the celebration already happened.
205
+ }
206
+ }