termi-kids 0.1.0 → 0.1.1
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/README.md +43 -16
- package/SAFETY.md +3 -3
- package/dist/cli.js +6 -1
- package/dist/config/settings.js +12 -10
- package/dist/grownups/panel.js +2 -17
- package/dist/safety/guarddownload.js +13 -0
- package/dist/setup/wizard.js +50 -20
- package/dist/surfaces/chat.js +7 -2
- package/dist/surfaces/home.js +4 -6
- package/dist/ui/text.js +7 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,3 +1,13 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="https://raw.githubusercontent.com/dannyliv/Termi/main/assets/termi-hero.png" alt="Termi, a friendly robot popping out of a terminal window surrounded by pixel game elements" width="720">
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
<p align="center">
|
|
6
|
+
<a href="https://www.npmjs.com/package/termi-kids"><img src="https://img.shields.io/npm/v/termi-kids" alt="npm version"></a>
|
|
7
|
+
<a href="https://github.com/dannyliv/Termi/actions/workflows/ci.yml"><img src="https://github.com/dannyliv/Termi/actions/workflows/ci.yml/badge.svg" alt="CI status"></a>
|
|
8
|
+
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue" alt="MIT license"></a>
|
|
9
|
+
</p>
|
|
10
|
+
|
|
1
11
|
# Termi
|
|
2
12
|
|
|
3
13
|
Termi is a friendly robot that helps you build things on a real computer.
|
|
@@ -41,16 +51,6 @@ npm install -g termi-kids
|
|
|
41
51
|
|
|
42
52
|
Then run `termi`. If npm reports a permission error, prefix the install with `sudo` on macOS and Linux, or run the terminal as administrator on Windows. To remove it later, run `npm rm -g termi-kids` (full cleanup steps are in SAFETY.md).
|
|
43
53
|
|
|
44
|
-
To hack on Termi instead, install from a clone:
|
|
45
|
-
|
|
46
|
-
```
|
|
47
|
-
git clone https://github.com/dannyliv/Termi.git
|
|
48
|
-
cd Termi
|
|
49
|
-
npm install
|
|
50
|
-
npm run build
|
|
51
|
-
npm link
|
|
52
|
-
```
|
|
53
|
-
|
|
54
54
|
### First run: the setup wizard
|
|
55
55
|
|
|
56
56
|
Run `termi`. The first run starts a setup wizard for a parent or guardian. It takes about five minutes:
|
|
@@ -58,16 +58,29 @@ Run `termi`. The first run starts a setup wizard for a parent or guardian. It ta
|
|
|
58
58
|
1. **Create a grown-up PIN** (at least 4 characters). It guards the grown-up zone. Your kid should not know it.
|
|
59
59
|
2. **Age band and consent.** You pick "Under 13" or "13 or older" and confirm that Termi uses an AI account you own and that you agree to watch how your kid uses it. This consent is recorded in the local safety log.
|
|
60
60
|
3. **Pick an AI provider.** The default is "ChatGPT sign-in": your browser opens, you sign in to your own ChatGPT account, and no API key is needed. The other options are a Claude API key, an OpenAI API key, or a Grok API key. Grok requires an extra confirmation because the xAI API terms are adults-only (details in SAFETY.md). You can add several providers and choose which one is active.
|
|
61
|
-
4. **
|
|
62
|
-
5. **
|
|
63
|
-
6. **
|
|
64
|
-
7. **
|
|
65
|
-
8. **Optional first game.** Two keypresses later there is a running game in the browser.
|
|
61
|
+
4. **Safety checker download.** Termi offers a small safety model (623 MB) that screens every message right on your computer, even with no internet. It is on by default and worth keeping. While it downloads, Termi tells you plainly that basic safety (the local filter plus the online checks) is already running, and asks whether you want to start building now or wait and watch the progress bar. Either way, the checker turns itself on the moment the verified file lands and Termi says so, even mid-session. The download is anonymous (no account or token needed) and resumes if interrupted. Details are in [SAFETY.md](SAFETY.md).
|
|
62
|
+
5. **Hand the keyboard to your kid.** The kid picks a made-up nickname (Termi asks them not to use their real name) and sees a clear disclosure: "Termi is a computer program, an AI. It is a tool a grown-up set up for you. It is not a person."
|
|
63
|
+
6. **Desktop shortcut.** Termi offers to write a double-clickable launcher on the Desktop (`Termi.command` on macOS, `Termi.bat` on Windows, `Termi.desktop` on Linux) so your kid can come back tomorrow without typing commands.
|
|
64
|
+
7. **Optional first game.** Two keypresses later there is a running game in the browser.
|
|
66
65
|
|
|
67
66
|
If you skip the provider step, Termi runs in offline mode: your kid can still create projects, play them in the preview, undo changes, and browse ideas. The chat shows a kind "ask a grown-up to set up the AI helper" screen.
|
|
68
67
|
|
|
69
68
|
**If your kid does not know where to start:** every project type has a Build Quest (`/quest` in the chat), a five-step guided build with a ready prompt at each step, and a large idea deck (`/ideas`). Quest steps go through the same safety pipeline as any other message.
|
|
70
69
|
|
|
70
|
+
### What keeps your kid safe
|
|
71
|
+
|
|
72
|
+
Five layers sit between your kid and the AI, bound by one rule: when a check cannot finish, Termi blocks rather than guesses. There is no setting that weakens this.
|
|
73
|
+
|
|
74
|
+
1. **A local filter on this computer.** Screens every message before it leaves the machine: profanity (including d.i.s.g.u.i.s.e.d spellings and leetspeak) and known "ignore your rules" tricks are blocked; personal details like names, addresses, phones, and school names are masked to `[secret]` before anything is sent.
|
|
75
|
+
2. **Safety rules inside the AI's instructions.** The AI is a tool, never a friend: no romance, no secrets, no asking for real names, addresses, schools, ages, or photos. Big feelings get one kind line and a pointer to a trusted adult.
|
|
76
|
+
3. **An on-device safety checker.** A small AI model (on by default, 623 MB) grades every message in and out right on your computer, even with no internet.
|
|
77
|
+
4. **Checks on everything the AI produces.** The reply, every file it writes, and the human-visible text inside those files are all screened before your kid sees a single character, plus a code scan for network calls and hidden tricks.
|
|
78
|
+
5. **A session-wide grooming watch.** Secrecy asks, affection aimed at the kid, probing for personal details, and attempts to move the chat to another app are counted across the whole conversation, and a hard block trips when the signals stack, no matter how innocent each message looked alone.
|
|
79
|
+
|
|
80
|
+
**What gets filtered:** sexual content, self-harm (answered with a calm, supportive screen and the 988 line in the US), violence past a mild cartoon ceiling, hate and bullying, dangerous how-tos, profanity, personal information, grooming patterns, heavy adult and political topics, wholesale copying of others' work, and attempts to break the AI's rules. Normal game talk ("make the zombie disappear when you hit it") is deliberately allowed.
|
|
81
|
+
|
|
82
|
+
Parents get a PIN-gated grown-up zone, a tamper-evident log of every block and settings change, and honest documentation: [SAFETY.md](SAFETY.md) explains how each layer works, what one message costs, and what a determined kid can and cannot get past.
|
|
83
|
+
|
|
71
84
|
### Commands
|
|
72
85
|
|
|
73
86
|
| Command | What it does |
|
|
@@ -101,7 +114,7 @@ Inside the build chat, these slash commands work:
|
|
|
101
114
|
|
|
102
115
|
Plain words work too: `undo`, `help`, `ideas`, `done`, `preview`, `badges`, `learn`, `quest`, and `quit` work on their own, no slash needed, and `exit`, `stop`, `bye`, and `leave` also quit. (`redo`, `new`, and `grownups` need the slash.) Misspelled commands get a "did you mean" suggestion.
|
|
103
116
|
|
|
104
|
-
In the grown-up zone you can add, switch, or remove providers,
|
|
117
|
+
In the grown-up zone you can add, switch, or remove providers, manage the on-device safety checker (turn it on or off, download or remove its model file), pick the model speed (Zippy, the fast default, or Extra smart for tricky asks), read the usage and quota note, review the safety log, and see exactly where your data lives. Removing a provider deletes its saved key or sign-in from this computer.
|
|
105
118
|
|
|
106
119
|
### The nine project types
|
|
107
120
|
|
|
@@ -139,6 +152,20 @@ Termi's v1 stance is simple: one operating system user account per kid. Settings
|
|
|
139
152
|
- **"Termi found changed settings. Safe settings are on now."** The settings file failed its integrity check, so Termi reverted to strict defaults. Review the grown-up zone.
|
|
140
153
|
- **Crashes.** The kid sees a friendly screen; the technical details go to `~/.termi/error.log`.
|
|
141
154
|
|
|
155
|
+
### Developing Termi
|
|
156
|
+
|
|
157
|
+
For working on Termi itself (parents installing for a kid never need this):
|
|
158
|
+
|
|
159
|
+
```
|
|
160
|
+
git clone https://github.com/dannyliv/Termi.git
|
|
161
|
+
cd Termi
|
|
162
|
+
npm install
|
|
163
|
+
npm run build
|
|
164
|
+
npm link
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
`npm link` puts the development build on your PATH as `termi`; remove it with `npm rm -g termi`. Tests run with `npm test` (vitest, 1000+ tests).
|
|
168
|
+
|
|
142
169
|
### Uninstall
|
|
143
170
|
|
|
144
171
|
See "Your data and how to remove Termi" in [SAFETY.md](SAFETY.md) for the exact folders, files, and keychain entries.
|
package/SAFETY.md
CHANGED
|
@@ -50,7 +50,9 @@ Grooming, personal info, and rule-breaking tricks block at a lower threshold tha
|
|
|
50
50
|
|
|
51
51
|
Termi includes an on-device safety checker: a small AI model (Qwen3Guard 0.6B, published by the Qwen team under the Apache 2.0 license) that runs entirely on your computer. Setup offers the download (623 MB) with a yes as the default; you can decline and add it later from the grown-up zone (`termi grownups`, then **Safety checker**).
|
|
52
52
|
|
|
53
|
-
The download never blocks anyone.
|
|
53
|
+
The download never blocks anyone. Setup says, in plain words, that basic safety (the local filter plus the online checks) is already on, then asks whether you want to start building now or wait and watch the progress bar. If you start now, the download continues in the background while your kid builds, the home menu shows its bar, and the checker attaches itself to the running safety pipeline the moment the verified file is in place; Termi announces it on screen ("Your safety helper is on."), even in the middle of a build session. If the download is interrupted (a closed laptop, a dropped connection), it resumes from where it stopped the next time Termi starts. Until the file lands, the online checks carry the load exactly as they would with the checker turned off.
|
|
54
|
+
|
|
55
|
+
The download itself is anonymous. The model file comes over HTTPS from a public model repository, with no account, sign-in, or token of any kind; nothing about you or your kid is sent with the request, and the file is checked against a pinned cryptographic fingerprint before it is used.
|
|
54
56
|
|
|
55
57
|
What it does:
|
|
56
58
|
|
|
@@ -109,8 +111,6 @@ The real levers, and they are good ones:
|
|
|
109
111
|
|
|
110
112
|
One more honest note: the keychain stores Termi's secrets on a best-effort basis. On systems without a usable keychain (some Linux setups), Termi falls back to a plain file at `~/.termi/secrets.json` and tells you so with a warning banner in the grown-up zone.
|
|
111
113
|
|
|
112
|
-
About the safety level setting: the wizard and the grown-up zone offer Strict and Standard. In the current version both levels apply the same blocking thresholds; the setting is recorded for future tuning. Leave it on Strict.
|
|
113
|
-
|
|
114
114
|
## Privacy and COPPA posture
|
|
115
115
|
|
|
116
116
|
Termi is built so that the developer never touches your family's data, because there is nothing to send it to:
|
package/dist/cli.js
CHANGED
|
@@ -10,7 +10,7 @@ import fs from 'node:fs';
|
|
|
10
10
|
import * as p from '@clack/prompts';
|
|
11
11
|
import { isSetupComplete } from './config/pin.js';
|
|
12
12
|
import { ensureDirs, errorLogPath } from './config/paths.js';
|
|
13
|
-
import { loadSettings } from './config/settings.js';
|
|
13
|
+
import { loadSettings, saveSettings } from './config/settings.js';
|
|
14
14
|
import { startPreview } from './preview/server.js';
|
|
15
15
|
import { appendAudit } from './safety/audit.js';
|
|
16
16
|
import { guardModelReady } from './safety/modelstore.js';
|
|
@@ -201,6 +201,11 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
201
201
|
ensureDirs();
|
|
202
202
|
const loaded = loadSettings();
|
|
203
203
|
let settings = loaded.settings;
|
|
204
|
+
if (loaded.upgraded) {
|
|
205
|
+
// A pre-upgrade envelope carried retired keys; one re-save converges
|
|
206
|
+
// the on-disk file to the current shape with a fresh signature.
|
|
207
|
+
settings = saveSettings(settings);
|
|
208
|
+
}
|
|
204
209
|
const decision = decideBoot({
|
|
205
210
|
firstRun: loaded.firstRun,
|
|
206
211
|
tampered: loaded.tampered,
|
package/dist/config/settings.js
CHANGED
|
@@ -25,7 +25,6 @@ export function defaultSettings() {
|
|
|
25
25
|
activeProvider: null,
|
|
26
26
|
configuredProviders: [],
|
|
27
27
|
modelAlias: 'zippy',
|
|
28
|
-
safetyLevel: 'strict',
|
|
29
28
|
xaiParentAck: false,
|
|
30
29
|
localClassifier: true,
|
|
31
30
|
lastProjectSlug: null,
|
|
@@ -33,12 +32,13 @@ export function defaultSettings() {
|
|
|
33
32
|
}
|
|
34
33
|
/**
|
|
35
34
|
* Fills fields added after a settings file was written and drops retired
|
|
36
|
-
* ones. Runs after the MAC check (the MAC
|
|
37
|
-
* Older envelopes predate localClassifier:
|
|
35
|
+
* ones (ollamaClassifier, safetyLevel). Runs after the MAC check (the MAC
|
|
36
|
+
* covers the envelope as written). Older envelopes predate localClassifier:
|
|
37
|
+
* absent means on, the default.
|
|
38
38
|
*/
|
|
39
39
|
export function normalizeSettings(stored) {
|
|
40
40
|
const raw = stored;
|
|
41
|
-
const { ollamaClassifier:
|
|
41
|
+
const { ollamaClassifier: _retiredOllama, safetyLevel: _retiredLevel, ...kept } = raw;
|
|
42
42
|
return { ...kept, localClassifier: raw.localClassifier ?? true };
|
|
43
43
|
}
|
|
44
44
|
/** Stable stringify: object keys sorted recursively, array order preserved. */
|
|
@@ -95,26 +95,28 @@ export function loadSettings() {
|
|
|
95
95
|
const setupMarkerExists = getSecret(SETUP_MARKER_ACCOUNT) !== null;
|
|
96
96
|
if (setupMarkerExists) {
|
|
97
97
|
// Setup finished before, yet the file is gone. Someone removed state.
|
|
98
|
-
return { settings: defaultSettings(), tampered: true, firstRun: false };
|
|
98
|
+
return { settings: defaultSettings(), tampered: true, firstRun: false, upgraded: false };
|
|
99
99
|
}
|
|
100
|
-
return { settings: defaultSettings(), tampered: false, firstRun: true };
|
|
100
|
+
return { settings: defaultSettings(), tampered: false, firstRun: true, upgraded: false };
|
|
101
101
|
}
|
|
102
102
|
let envelope;
|
|
103
103
|
try {
|
|
104
104
|
const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
105
105
|
if (!isEnvelopeShape(parsed)) {
|
|
106
|
-
return { settings: defaultSettings(), tampered: true, firstRun: false };
|
|
106
|
+
return { settings: defaultSettings(), tampered: true, firstRun: false, upgraded: false };
|
|
107
107
|
}
|
|
108
108
|
envelope = parsed;
|
|
109
109
|
}
|
|
110
110
|
catch {
|
|
111
|
-
return { settings: defaultSettings(), tampered: true, firstRun: false };
|
|
111
|
+
return { settings: defaultSettings(), tampered: true, firstRun: false, upgraded: false };
|
|
112
112
|
}
|
|
113
113
|
const expectedMac = signSettings(envelope.settings);
|
|
114
114
|
if (!macMatches(expectedMac, envelope.mac)) {
|
|
115
|
-
return { settings: defaultSettings(), tampered: true, firstRun: false };
|
|
115
|
+
return { settings: defaultSettings(), tampered: true, firstRun: false, upgraded: false };
|
|
116
116
|
}
|
|
117
|
-
|
|
117
|
+
const raw = envelope.settings;
|
|
118
|
+
const upgraded = 'ollamaClassifier' in raw || 'safetyLevel' in raw;
|
|
119
|
+
return { settings: normalizeSettings(envelope.settings), tampered: false, firstRun: false, upgraded };
|
|
118
120
|
}
|
|
119
121
|
/**
|
|
120
122
|
* Signs and writes settings atomically. Generates installId on first save.
|
package/dist/grownups/panel.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The grown-ups panel: PIN gate, needs-attention banner, provider
|
|
3
|
-
* management, safety
|
|
4
|
-
* viewer, and the data/uninstall screen.
|
|
3
|
+
* management, the on-device safety checker, model speed, the usage note,
|
|
4
|
+
* the audit log viewer, and the data/uninstall screen.
|
|
5
5
|
*
|
|
6
6
|
* The pure helpers (one-liners, attention lines, log parsing) are exported
|
|
7
7
|
* so tests cover them without driving the prompts.
|
|
@@ -423,7 +423,6 @@ export async function runPanel() {
|
|
|
423
423
|
message: 'Grown-up zone',
|
|
424
424
|
options: [
|
|
425
425
|
{ value: 'providers', label: 'Providers (add or switch)' },
|
|
426
|
-
{ value: 'safety', label: `Safety level (now: ${settings.safetyLevel})` },
|
|
427
426
|
{ value: 'guard', label: `Safety checker on this computer (${guardStatusLine(settings)})` },
|
|
428
427
|
{ value: 'speed', label: `Model speed (now: ${modelLabel(settings.modelAlias)})` },
|
|
429
428
|
{ value: 'usage', label: 'Usage and quota note' },
|
|
@@ -442,20 +441,6 @@ export async function runPanel() {
|
|
|
442
441
|
else if (pick === 'guard') {
|
|
443
442
|
settings = await guardMenu(settings);
|
|
444
443
|
}
|
|
445
|
-
else if (pick === 'safety') {
|
|
446
|
-
const level = await p.select({
|
|
447
|
-
message: T.wizard.safetyPick,
|
|
448
|
-
options: [
|
|
449
|
-
{ value: 'strict', label: 'Strict', hint: 'Best for most kids.' },
|
|
450
|
-
{ value: 'standard', label: 'Standard' },
|
|
451
|
-
],
|
|
452
|
-
initialValue: settings.safetyLevel,
|
|
453
|
-
});
|
|
454
|
-
if (!p.isCancel(level) && level !== settings.safetyLevel) {
|
|
455
|
-
settings = saveSettings({ ...settings, safetyLevel: level });
|
|
456
|
-
audit('settings_change', `safety level ${level}`);
|
|
457
|
-
}
|
|
458
|
-
}
|
|
459
444
|
else if (pick === 'speed') {
|
|
460
445
|
const alias = await p.select({
|
|
461
446
|
message: 'Pick the model speed.',
|
|
@@ -11,6 +11,17 @@ import { appendAudit } from './audit.js';
|
|
|
11
11
|
import { downloadGuardModel, GUARD_MODEL, guardModelReady, } from './modelstore.js';
|
|
12
12
|
let state = { status: 'idle', written: 0, total: GUARD_MODEL.bytes };
|
|
13
13
|
let inFlight = null;
|
|
14
|
+
let noticePending = false;
|
|
15
|
+
/**
|
|
16
|
+
* True exactly once after a background fetch completes, then clears. The
|
|
17
|
+
* chat loop and the home menu both call this, so the kid hears "your
|
|
18
|
+
* safety helper is on" one time, on whichever screen they are looking at.
|
|
19
|
+
*/
|
|
20
|
+
export function consumeGuardReadyNotice() {
|
|
21
|
+
const pending = noticePending;
|
|
22
|
+
noticePending = false;
|
|
23
|
+
return pending;
|
|
24
|
+
}
|
|
14
25
|
/** Snapshot of the background fetch. 'ready' wins once the file exists. */
|
|
15
26
|
export function guardFetchState() {
|
|
16
27
|
if (state.status !== 'downloading' && guardModelReady()) {
|
|
@@ -51,6 +62,7 @@ export function ensureGuardFetch(opts = {}) {
|
|
|
51
62
|
})
|
|
52
63
|
.then(() => {
|
|
53
64
|
state = { ...state, status: 'ready', written: state.total };
|
|
65
|
+
noticePending = true;
|
|
54
66
|
audit('local classifier model downloaded');
|
|
55
67
|
return true;
|
|
56
68
|
})
|
|
@@ -68,6 +80,7 @@ export function ensureGuardFetch(opts = {}) {
|
|
|
68
80
|
export function resetGuardFetchForTests() {
|
|
69
81
|
state = { status: 'idle', written: 0, total: GUARD_MODEL.bytes };
|
|
70
82
|
inFlight = null;
|
|
83
|
+
noticePending = false;
|
|
71
84
|
}
|
|
72
85
|
const BAR_SLOTS = 10;
|
|
73
86
|
/** A kid-friendly progress bar: [####______] 42% of 623 MB. */
|
package/dist/setup/wizard.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The setup wizard: a parent flow (PIN, consent, AI provider, safety
|
|
3
|
-
* followed by a kid flow (nickname, AI disclosure, launcher,
|
|
2
|
+
* The setup wizard: a parent flow (PIN, consent, AI provider, safety
|
|
3
|
+
* checker) followed by a kid flow (nickname, AI disclosure, launcher,
|
|
4
|
+
* first game).
|
|
4
5
|
*
|
|
5
6
|
* Pure decision helpers are exported so tests cover the logic without
|
|
6
7
|
* driving the prompts. Cancel anywhere keeps only the steps that finished;
|
|
@@ -12,7 +13,7 @@ import { setSecret } from '../auth/keychain.js';
|
|
|
12
13
|
import { hasPin, markSetupComplete, setPin } from '../config/pin.js';
|
|
13
14
|
import { defaultSettings, loadSettings, saveSettings } from '../config/settings.js';
|
|
14
15
|
import { appendAudit } from '../safety/audit.js';
|
|
15
|
-
import { ensureGuardFetch } from '../safety/guarddownload.js';
|
|
16
|
+
import { ensureGuardFetch, guardProgressBar } from '../safety/guarddownload.js';
|
|
16
17
|
import { guardModelReady } from '../safety/modelstore.js';
|
|
17
18
|
import { nameIsOkay } from '../safety/prefilter.js';
|
|
18
19
|
import { scaffoldById } from '../projects/scaffolds/index.js';
|
|
@@ -316,24 +317,15 @@ async function providerLoop(settings) {
|
|
|
316
317
|
}
|
|
317
318
|
return { ...current, activeProvider: null };
|
|
318
319
|
}
|
|
319
|
-
async function safetyStep(settings) {
|
|
320
|
-
const level = ensure(await p.select({
|
|
321
|
-
message: T.wizard.safetyPick,
|
|
322
|
-
options: [
|
|
323
|
-
{ value: 'strict', label: 'Strict', hint: 'Best for most kids.' },
|
|
324
|
-
{ value: 'standard', label: 'Standard' },
|
|
325
|
-
],
|
|
326
|
-
initialValue: 'strict',
|
|
327
|
-
}));
|
|
328
|
-
return { ...settings, safetyLevel: level };
|
|
329
|
-
}
|
|
330
320
|
/**
|
|
331
321
|
* Offers the on-device safety checker download. Default is yes: the
|
|
332
322
|
* classifier setting ships on, and this step starts fetching its model file
|
|
333
|
-
* in the background
|
|
334
|
-
*
|
|
335
|
-
*
|
|
336
|
-
*
|
|
323
|
+
* in the background. The parent hears, plainly, that basic safety (the
|
|
324
|
+
* local filter plus the online checks) is already on and that the checker
|
|
325
|
+
* strengthens it when the download lands, then chooses to start building
|
|
326
|
+
* now or wait and watch the bar. Either way the pipeline hot-attaches the
|
|
327
|
+
* checker the moment the verified file is in place. Declining turns the
|
|
328
|
+
* setting off; a failed or interrupted download resumes on the next start.
|
|
337
329
|
*/
|
|
338
330
|
async function localGuardStep(settings) {
|
|
339
331
|
if (guardModelReady()) {
|
|
@@ -347,8 +339,47 @@ async function localGuardStep(settings) {
|
|
|
347
339
|
audit('settings_change', 'local classifier off (declined in setup)');
|
|
348
340
|
return { ...settings, localClassifier: false };
|
|
349
341
|
}
|
|
350
|
-
|
|
342
|
+
const fetchDone = ensureGuardFetch();
|
|
351
343
|
p.log.info(T.wizard.guardBackground);
|
|
344
|
+
const wait = ensure(await p.select({
|
|
345
|
+
message: T.wizard.guardWaitPick,
|
|
346
|
+
options: [
|
|
347
|
+
{ value: 'now', label: T.wizard.guardStartNow, hint: T.wizard.guardStartNowHint },
|
|
348
|
+
{ value: 'wait', label: T.wizard.guardWaitHere, hint: T.wizard.guardWaitHereHint },
|
|
349
|
+
],
|
|
350
|
+
initialValue: 'now',
|
|
351
|
+
}));
|
|
352
|
+
if (wait === 'wait') {
|
|
353
|
+
// The wait shows a live bar with periodic escape hatches (after one
|
|
354
|
+
// minute, then every ten): a slow connection must not trap the parent
|
|
355
|
+
// in setup when the download finishes fine in the background anyway.
|
|
356
|
+
const escapeAfterMs = [60_000, 600_000];
|
|
357
|
+
let escapeIndex = 0;
|
|
358
|
+
for (;;) {
|
|
359
|
+
const spin = p.spinner();
|
|
360
|
+
spin.start(`${T.wizard.guardDownloading} ${guardProgressBar()}`);
|
|
361
|
+
const ticker = setInterval(() => {
|
|
362
|
+
spin.message(`${T.wizard.guardDownloading} ${guardProgressBar()}`);
|
|
363
|
+
}, 250);
|
|
364
|
+
const budget = escapeAfterMs[Math.min(escapeIndex, escapeAfterMs.length - 1)];
|
|
365
|
+
escapeIndex += 1;
|
|
366
|
+
const outcome = await Promise.race([
|
|
367
|
+
fetchDone.then((ok) => (ok ? 'ready' : 'failed')),
|
|
368
|
+
new Promise((resolve) => setTimeout(() => resolve('timeout'), budget).unref?.()),
|
|
369
|
+
]);
|
|
370
|
+
clearInterval(ticker);
|
|
371
|
+
if (outcome !== 'timeout') {
|
|
372
|
+
spin.stop(outcome === 'ready' ? T.wizard.guardReady : T.wizard.guardFailed);
|
|
373
|
+
break;
|
|
374
|
+
}
|
|
375
|
+
spin.stop(`${T.wizard.guardDownloading} ${guardProgressBar()}`);
|
|
376
|
+
const more = await p.confirm({ message: T.wizard.guardKeepWaiting, initialValue: true });
|
|
377
|
+
if (p.isCancel(more) || !more) {
|
|
378
|
+
p.log.info(T.wizard.guardBackground);
|
|
379
|
+
break;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
352
383
|
return { ...settings, localClassifier: true };
|
|
353
384
|
}
|
|
354
385
|
async function kidNicknameStep(settings) {
|
|
@@ -446,7 +477,6 @@ export async function runWizard() {
|
|
|
446
477
|
}
|
|
447
478
|
settings = await consentStep(settings);
|
|
448
479
|
settings = await providerLoop(settings);
|
|
449
|
-
settings = await safetyStep(settings);
|
|
450
480
|
settings = await localGuardStep(settings);
|
|
451
481
|
settings = saveSettings(settings);
|
|
452
482
|
markSetupComplete();
|
package/dist/surfaces/chat.js
CHANGED
|
@@ -13,7 +13,7 @@ import { createProviderClient, pickClassifierBackend, } from '../providers/index
|
|
|
13
13
|
import { classifyProviderError } from '../providers/errors.js';
|
|
14
14
|
import { appendAudit } from '../safety/audit.js';
|
|
15
15
|
import { createSafetyPipeline } from '../safety/classifier.js';
|
|
16
|
-
import { ensureGuardFetch } from '../safety/guarddownload.js';
|
|
16
|
+
import { consumeGuardReadyNotice, ensureGuardFetch } from '../safety/guarddownload.js';
|
|
17
17
|
import { lazyGuardAccessor } from '../safety/guardrunner.js';
|
|
18
18
|
import { guardModelReady } from '../safety/modelstore.js';
|
|
19
19
|
import { createSessionState } from '../safety/session.js';
|
|
@@ -122,7 +122,9 @@ export async function runChat(project, settings) {
|
|
|
122
122
|
// The guard hot-attaches: null while its model still downloads, live from
|
|
123
123
|
// the first check after the file lands. A missing file also (re)starts the
|
|
124
124
|
// background fetch, and is written to the safety log: a deleted model
|
|
125
|
-
// file must not detach a safety layer invisibly.
|
|
125
|
+
// file must not detach a safety layer invisibly. When the download lands
|
|
126
|
+
// mid-session, the kid hears about it at the next turn (once, shared with
|
|
127
|
+
// the home menu via consumeGuardReadyNotice).
|
|
126
128
|
if (settings.localClassifier && !guardModelReady()) {
|
|
127
129
|
audit({
|
|
128
130
|
ts: new Date().toISOString(),
|
|
@@ -319,6 +321,9 @@ export async function runChat(project, settings) {
|
|
|
319
321
|
};
|
|
320
322
|
let hintIndex = 0;
|
|
321
323
|
for (;;) {
|
|
324
|
+
if (consumeGuardReadyNotice()) {
|
|
325
|
+
p.log.success(T.home.guardOn);
|
|
326
|
+
}
|
|
322
327
|
const hint = T.hints[hintIndex % T.hints.length] ?? '';
|
|
323
328
|
hintIndex += 1;
|
|
324
329
|
const step = quest !== null ? quest.steps[questStep] : undefined;
|
package/dist/surfaces/home.js
CHANGED
|
@@ -11,7 +11,7 @@ import path from 'node:path';
|
|
|
11
11
|
import * as p from '@clack/prompts';
|
|
12
12
|
import { atomicWriteFileSync, termiHome } from '../config/paths.js';
|
|
13
13
|
import { saveSettings } from '../config/settings.js';
|
|
14
|
-
import { guardFetchState, guardProgressBar } from '../safety/guarddownload.js';
|
|
14
|
+
import { consumeGuardReadyNotice, guardFetchState, guardProgressBar, } from '../safety/guarddownload.js';
|
|
15
15
|
import { nameIsOkay } from '../safety/prefilter.js';
|
|
16
16
|
import { scaffolds, scaffoldById } from '../projects/scaffolds/index.js';
|
|
17
17
|
import { suggestProjectNames } from '../setup/wizard.js';
|
|
@@ -327,7 +327,6 @@ async function loadLastProject(settings) {
|
|
|
327
327
|
}
|
|
328
328
|
/** The home menu loop for a returning kid. */
|
|
329
329
|
export async function showHome(settings) {
|
|
330
|
-
let guardWasLoading = false;
|
|
331
330
|
const nickname = settings.kidNickname.trim();
|
|
332
331
|
console.log(mascot('happy'));
|
|
333
332
|
console.log(nickname.length > 0 ? T.home.welcomeBack.replace('{name}', nickname) : T.home.firstHello);
|
|
@@ -340,15 +339,14 @@ export async function showHome(settings) {
|
|
|
340
339
|
}
|
|
341
340
|
for (;;) {
|
|
342
341
|
// A quiet one-liner while the safety checker still downloads, and one
|
|
343
|
-
// "it is on now" note
|
|
342
|
+
// "it is on now" note when it lands (shared one-shot with the chat, so
|
|
343
|
+
// the kid never hears it twice across screens).
|
|
344
344
|
const fetchState = guardFetchState();
|
|
345
345
|
if (settings.localClassifier && fetchState.status === 'downloading') {
|
|
346
346
|
console.log(style.dim(`${T.home.guardLoading} ${guardProgressBar(fetchState)}`));
|
|
347
|
-
guardWasLoading = true;
|
|
348
347
|
}
|
|
349
|
-
else if (
|
|
348
|
+
else if (consumeGuardReadyNotice()) {
|
|
350
349
|
console.log(style.dim(T.home.guardOn));
|
|
351
|
-
guardWasLoading = false;
|
|
352
350
|
}
|
|
353
351
|
const options = [];
|
|
354
352
|
if (last !== null) {
|
package/dist/ui/text.js
CHANGED
|
@@ -43,11 +43,16 @@ export const T = {
|
|
|
43
43
|
consentIntro: 'Termi uses an AI account you own. You agree to watch how your kid uses it.',
|
|
44
44
|
providerPick: 'Pick the AI helper account Termi will use.',
|
|
45
45
|
xaiAck: 'This provider is for adults only. A parent must own it and watch it.',
|
|
46
|
-
safetyPick: 'Pick a safety level. Strict is best for most kids.',
|
|
47
46
|
guardOffer: 'Termi includes a safety checker that runs on this computer (a 623 MB download). ' +
|
|
48
47
|
'It screens every message even when the internet is down. Download it now?',
|
|
49
48
|
guardDownloading: 'Getting the safety file now...',
|
|
50
|
-
guardBackground: '
|
|
49
|
+
guardBackground: 'Basic safety is on right now. It gets stronger when the download is done.',
|
|
50
|
+
guardWaitPick: 'Start building now, or wait for it?',
|
|
51
|
+
guardStartNow: 'Start now',
|
|
52
|
+
guardStartNowHint: 'The checker turns on by itself later.',
|
|
53
|
+
guardWaitHere: 'Wait here',
|
|
54
|
+
guardWaitHereHint: 'Watch the download finish first.',
|
|
55
|
+
guardKeepWaiting: 'Still going. Keep waiting?',
|
|
51
56
|
guardReady: 'Your safety checker is set.',
|
|
52
57
|
guardFailed: 'The safety checker download did not finish. Termi still works; ' +
|
|
53
58
|
'you can retry from the grown-up zone any time.',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "termi-kids",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "Termi is a friendly coding buddy for kids. Build games, art, stories, and websites right from your computer, with a grown-up approved AI helper and strong safety rails.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"kids",
|