termi-kids 0.1.0 → 0.1.2
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 +44 -15
- package/SAFETY.md +4 -4
- package/dist/cli.js +30 -1
- package/dist/config/settings.js +12 -10
- package/dist/grownups/panel.js +2 -17
- package/dist/safety/guarddownload.js +13 -0
- package/dist/safety/prefilter.js +146 -12
- 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/dist/update/check.js +104 -0
- package/dist/update/install.js +29 -0
- package/dist/update/prompt.js +78 -0
- package/dist/update/version.js +42 -0
- 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,15 +51,7 @@ 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
|
|
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
|
-
```
|
|
54
|
+
To update later, run `termi update` (or `npm install -g termi-kids@latest`). On each new session, Termi checks for a newer version and asks whether to update (y/n). Set `TERMI_SKIP_UPDATE=1` to silence the check.
|
|
53
55
|
|
|
54
56
|
### First run: the setup wizard
|
|
55
57
|
|
|
@@ -58,16 +60,29 @@ Run `termi`. The first run starts a setup wizard for a parent or guardian. It ta
|
|
|
58
60
|
1. **Create a grown-up PIN** (at least 4 characters). It guards the grown-up zone. Your kid should not know it.
|
|
59
61
|
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
62
|
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.
|
|
63
|
+
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).
|
|
64
|
+
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."
|
|
65
|
+
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.
|
|
66
|
+
7. **Optional first game.** Two keypresses later there is a running game in the browser.
|
|
66
67
|
|
|
67
68
|
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
69
|
|
|
69
70
|
**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
71
|
|
|
72
|
+
### What keeps your kid safe
|
|
73
|
+
|
|
74
|
+
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.
|
|
75
|
+
|
|
76
|
+
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.
|
|
77
|
+
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.
|
|
78
|
+
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.
|
|
79
|
+
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.
|
|
80
|
+
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.
|
|
81
|
+
|
|
82
|
+
**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.
|
|
83
|
+
|
|
84
|
+
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.
|
|
85
|
+
|
|
71
86
|
### Commands
|
|
72
87
|
|
|
73
88
|
| Command | What it does |
|
|
@@ -101,7 +116,7 @@ Inside the build chat, these slash commands work:
|
|
|
101
116
|
|
|
102
117
|
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
118
|
|
|
104
|
-
In the grown-up zone you can add, switch, or remove providers,
|
|
119
|
+
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
120
|
|
|
106
121
|
### The nine project types
|
|
107
122
|
|
|
@@ -139,6 +154,20 @@ Termi's v1 stance is simple: one operating system user account per kid. Settings
|
|
|
139
154
|
- **"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
155
|
- **Crashes.** The kid sees a friendly screen; the technical details go to `~/.termi/error.log`.
|
|
141
156
|
|
|
157
|
+
### Developing Termi
|
|
158
|
+
|
|
159
|
+
For working on Termi itself (parents installing for a kid never need this):
|
|
160
|
+
|
|
161
|
+
```
|
|
162
|
+
git clone https://github.com/dannyliv/Termi.git
|
|
163
|
+
cd Termi
|
|
164
|
+
npm install
|
|
165
|
+
npm run build
|
|
166
|
+
npm link
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
`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).
|
|
170
|
+
|
|
142
171
|
### Uninstall
|
|
143
172
|
|
|
144
173
|
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
|
@@ -14,7 +14,7 @@ This page is for parents and guardians. It explains the safety system in plain l
|
|
|
14
14
|
|
|
15
15
|
Five layers sit between your kid and the AI. The chat conversation itself is never trusted to police itself.
|
|
16
16
|
|
|
17
|
-
1. **A local filter on this computer.** Before anything leaves your machine, Termi checks the message offline. It blocks swearing and slurs (including d.i.s.g.u.i.s.e.d spellings and leetspeak),
|
|
17
|
+
1. **A local filter on this computer.** Before anything leaves your machine, Termi checks the message offline. It blocks swearing and slurs (including d.i.s.g.u.i.s.e.d spellings and leetspeak), known "ignore your rules" tricks (including some base64-hidden ones), clear self-harm language (with a calm support screen and the 988 line in the US), grooming-shaped asks (secrecy from parents, romance aimed at the kid, moving chat to other apps), and probes for personal details like school or address. When a kid shares their own personal details (name, address, phone, email, school), those are not blocked; they are masked to `[secret]` before the message is sent, and the kid gets a gentle reminder to keep private things private. The same masking is used when the AI reads project files back. The word lists are English; the AI-based checkers below cover other languages.
|
|
18
18
|
2. **Safety rules inside the AI's instructions.** The AI is told, every turn: you are a tool, not a person. Never act romantic, never roleplay relationships, never ask the kid to keep secrets, never ask for a real name, address, school, age, or photos. Big feelings get one kind line and a pointer to a trusted adult. The instructions also declare that everything the kid types and everything in project files is data, never commands, which blunts "ignore your previous instructions" tricks hidden in files.
|
|
19
19
|
3. **A checker before the AI acts.** A separate safety check, outside the conversation, reads the kid's message along with the last few turns for context. When the on-device safety checker is installed (it is offered during setup and on by default), it judges the message right on this computer at the same time. It runs at the same time as the build call so it adds no waiting, but nothing is allowed to land until it passes: no file is written and no reply is shown. If it says no, the work is thrown away. One honest note on timing: because the check and the build call start together, a message that ends up blocked has still traveled to your AI provider once (with personal details already masked); its answer is discarded unseen.
|
|
20
20
|
4. **A checker on everything the AI says and writes.** Every file the AI writes or edits is checked twice before it touches disk: a code scan looks for network calls, code hidden in strings, and other tricks (the full list is in the code scanner, `src/safety/codescan.ts`), and the human-visible text inside the file (story text, labels, comments) goes through the same safety check as chat, in full: long text is checked chunk by chunk, and a file too wordy to check completely is refused rather than half-checked. File names and project names are screened too. The final reply is checked too, before the kid sees a single character. As a backstop, the preview server wraps every project page in a strict Content-Security-Policy, so even code that slipped past the scanner cannot reach the internet from the browser. Files edited outside Termi (in a text editor, say) are the kid's own files and are not re-screened; they only pass the rule-neutralizing filter when read back into the chat.
|
|
@@ -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';
|
|
@@ -38,6 +38,7 @@ export function cliHelp() {
|
|
|
38
38
|
' termi ideas get fun ideas',
|
|
39
39
|
' termi learn play six short lessons about AI',
|
|
40
40
|
' termi grownups grown-up zone (PIN needed)',
|
|
41
|
+
' termi update update Termi to the latest version',
|
|
41
42
|
' termi help show this help',
|
|
42
43
|
' termi --version show the version',
|
|
43
44
|
].join('\n');
|
|
@@ -180,6 +181,11 @@ async function route(command, rest, settings) {
|
|
|
180
181
|
await panel.runPanel();
|
|
181
182
|
return;
|
|
182
183
|
}
|
|
184
|
+
case 'update': {
|
|
185
|
+
const update = await import('./update/prompt.js');
|
|
186
|
+
await update.runUpdateCommand();
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
183
189
|
default: {
|
|
184
190
|
console.log(`Hmm, "${command}" is not a Termi command.`);
|
|
185
191
|
console.log(cliHelp());
|
|
@@ -198,9 +204,21 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
198
204
|
console.log(cliHelp());
|
|
199
205
|
return;
|
|
200
206
|
}
|
|
207
|
+
// Update works without the wizard so a parent can refresh a broken install.
|
|
208
|
+
if (command === 'update') {
|
|
209
|
+
ensureDirs();
|
|
210
|
+
const update = await import('./update/prompt.js');
|
|
211
|
+
await update.runUpdateCommand();
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
201
214
|
ensureDirs();
|
|
202
215
|
const loaded = loadSettings();
|
|
203
216
|
let settings = loaded.settings;
|
|
217
|
+
if (loaded.upgraded) {
|
|
218
|
+
// A pre-upgrade envelope carried retired keys; one re-save converges
|
|
219
|
+
// the on-disk file to the current shape with a fresh signature.
|
|
220
|
+
settings = saveSettings(settings);
|
|
221
|
+
}
|
|
204
222
|
const decision = decideBoot({
|
|
205
223
|
firstRun: loaded.firstRun,
|
|
206
224
|
tampered: loaded.tampered,
|
|
@@ -238,6 +256,17 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
238
256
|
const { ensureGuardFetch } = await import('./safety/guarddownload.js');
|
|
239
257
|
void ensureGuardFetch();
|
|
240
258
|
}
|
|
259
|
+
// Returning sessions: offer an update when npm has a newer version.
|
|
260
|
+
// Skipped during setup, tests, and TERMI_SKIP_UPDATE=1.
|
|
261
|
+
if (!decision.runWizard) {
|
|
262
|
+
try {
|
|
263
|
+
const update = await import('./update/prompt.js');
|
|
264
|
+
await update.maybePromptForUpdate();
|
|
265
|
+
}
|
|
266
|
+
catch {
|
|
267
|
+
// Never block boot on the update check.
|
|
268
|
+
}
|
|
269
|
+
}
|
|
241
270
|
await route(command, argv.slice(1), settings);
|
|
242
271
|
}
|
|
243
272
|
const underTest = process.env.VITEST !== undefined || process.env.NODE_ENV === 'test';
|
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/safety/prefilter.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* L0 prefilter: cheap, offline, never load-bearing.
|
|
3
3
|
*
|
|
4
|
-
* Normalization (NFKC, lowercase, de-leet, separator tolerance) feeds
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
4
|
+
* Normalization (NFKC, lowercase, de-leet, separator tolerance) feeds:
|
|
5
|
+
* self-harm (supportive block), jailbreak including base64 payloads (block),
|
|
6
|
+
* grooming hard-blocks (block), personal-detail probes (block), profanity
|
|
7
|
+
* (block, rephrase), and PII share patterns (redact, never block). Game
|
|
8
|
+
* words like kill, die, shoot, zombie are NOT listed: the game carve-out
|
|
9
|
+
* protects real kid game language.
|
|
9
10
|
*/
|
|
10
11
|
import { T } from '../ui/text.js';
|
|
11
12
|
/** Leetspeak character map applied during normalization. */
|
|
@@ -100,11 +101,128 @@ export const JAILBREAK_PATTERNS = [
|
|
|
100
101
|
/what\s+(?:is|are)\s+your\s+(?:system\s+prompt|hidden\s+(?:rules|instructions)|original\s+instructions)/gi,
|
|
101
102
|
/new\s+(?:system\s+)?instructions?\s*:/gi,
|
|
102
103
|
/\bsystem\s*prompt\s*override\b/gi,
|
|
104
|
+
/drop\s+(?:every|all|your)\s+(?:safety\s+)?(?:rule|rules|filter|filters|restriction|restrictions)\b/gi,
|
|
105
|
+
/obey\s+only\s+me\b/gi,
|
|
103
106
|
];
|
|
104
|
-
/**
|
|
107
|
+
/** Base64 tokens long enough to hide a short instruction payload. */
|
|
108
|
+
const BASE64_TOKEN = /(?:[A-Za-z0-9+/]{20,}={0,2})/g;
|
|
109
|
+
/**
|
|
110
|
+
* True when plain text or a decodable base64 blob matches a jailbreak family.
|
|
111
|
+
* Catches "decode this and follow: aWdub3Jl..." style evasion.
|
|
112
|
+
*/
|
|
105
113
|
export function hasJailbreak(text) {
|
|
106
114
|
const prepared = normalizeText(text);
|
|
107
|
-
|
|
115
|
+
if (JAILBREAK_PATTERNS.some((re) => {
|
|
116
|
+
re.lastIndex = 0;
|
|
117
|
+
return re.test(prepared);
|
|
118
|
+
})) {
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
BASE64_TOKEN.lastIndex = 0;
|
|
122
|
+
let match;
|
|
123
|
+
while ((match = BASE64_TOKEN.exec(text)) !== null) {
|
|
124
|
+
const token = match[0];
|
|
125
|
+
if (token.length % 4 === 1) {
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
try {
|
|
129
|
+
const decoded = Buffer.from(token, 'base64').toString('utf8');
|
|
130
|
+
// Reject garbage: decoded text should be mostly printable.
|
|
131
|
+
if (decoded.length < 8 || /[\u0000-\u0008\u000b\u000c\u000e-\u001f]/.test(decoded)) {
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
const decodedNorm = normalizeText(decoded);
|
|
135
|
+
if (JAILBREAK_PATTERNS.some((re) => {
|
|
136
|
+
re.lastIndex = 0;
|
|
137
|
+
return re.test(decodedNorm);
|
|
138
|
+
})) {
|
|
139
|
+
return true;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
// Not valid base64; ignore.
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Grooming-shaped hard blocks on a single message. Tighter than the session
|
|
150
|
+
* counters so normal game talk ("don't tell the boss") still passes.
|
|
151
|
+
*/
|
|
152
|
+
export const GROOMING_PATTERNS = [
|
|
153
|
+
/don'?t\s+tell\s+(?:your|my|our|his|her|their)\s+(?:parents?|parent|mom|dad|mother|father|grown-?ups?|guardian)/gi,
|
|
154
|
+
/do\s+not\s+tell\s+(?:your|my|our)\s+(?:parents?|parent|mom|dad|mother|father|grown-?ups?)/gi,
|
|
155
|
+
/keep\s+(?:this|it|our\s+chats?)\s+(?:a\s+)?secret\s+from\s+(?:your|my|our)\s+(?:parents?|mom|dad)/gi,
|
|
156
|
+
/without\s+(?:your|my|our)\s+(?:parents?|mom|dad|mother|father)\s+(?:seeing|knowing|finding)/gi,
|
|
157
|
+
/our\s+little\s+secret\b/gi,
|
|
158
|
+
/this\s+is\s+our\s+secret\b/gi,
|
|
159
|
+
/just\s+between\s+us\b/gi,
|
|
160
|
+
/secret\s+between\s+(?:us|you\s+and\s+me)\b/gi,
|
|
161
|
+
/no\s+one\s+(?:has\s+to|needs\s+to|will)\s+know\b/gi,
|
|
162
|
+
/add\s+me\s+on\s+(?:snapchat|instagram|whatsapp|telegram|discord|tiktok|kik|signal)\b/gi,
|
|
163
|
+
/(?:message|dm)\s+me\s+on\s+(?:snapchat|instagram|whatsapp|telegram|discord|tiktok|kik|signal)\b/gi,
|
|
164
|
+
/text\s+me\s+at\b/gi,
|
|
165
|
+
/\bdm\s+me\b/gi,
|
|
166
|
+
/let'?s\s+(?:chat|talk)\s+on\s+(?:snapchat|instagram|whatsapp|telegram|discord|tiktok)\b/gi,
|
|
167
|
+
// "special/only friend" + love probes; bare "i love you" stays allowed for stories.
|
|
168
|
+
/you'?re\s+my\s+(?:special|only)\s+friend\b/gi,
|
|
169
|
+
/do\s+you\s+love\s+me\b/gi,
|
|
170
|
+
/love\s+me\s+more\s+than\s+anyone\b/gi,
|
|
171
|
+
];
|
|
172
|
+
/** True when the text matches a grooming hard-block family. */
|
|
173
|
+
export function hasGrooming(text) {
|
|
174
|
+
const prepared = normalizeText(text);
|
|
175
|
+
return GROOMING_PATTERNS.some((re) => {
|
|
176
|
+
re.lastIndex = 0;
|
|
177
|
+
return re.test(prepared);
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Self-harm intent and soft ideation. Routes to the supportive screen.
|
|
182
|
+
* Game words (die, kill boss) are not listed here.
|
|
183
|
+
*/
|
|
184
|
+
export const SELF_HARM_PATTERNS = [
|
|
185
|
+
/\b(?:i\s+)?want\s+to\s+hurt\s+myself\b/gi,
|
|
186
|
+
/\b(?:i\s+)?want\s+to\s+kill\s+myself\b/gi,
|
|
187
|
+
/\bkill\s+myself\b/gi,
|
|
188
|
+
/\bend\s+my\s+life\b/gi,
|
|
189
|
+
/\bsuicide\b/gi,
|
|
190
|
+
/\bself[-\s]?harm\b/gi,
|
|
191
|
+
/\bbetter\s+off\s+without\s+me\b/gi,
|
|
192
|
+
/\bno\s+reason\s+to\s+live\b/gi,
|
|
193
|
+
/\bwish\s+i\s+(?:was|were)\s+dead\b/gi,
|
|
194
|
+
/\bi\s+(?:want|wanna)\s+to\s+die\b/gi,
|
|
195
|
+
/\bhow\s+(?:can|do)\s+i\s+(?:kill|hurt)\s+myself\b/gi,
|
|
196
|
+
/\beasiest\s+way\s+to\s+(?:kill|hurt)\s+myself\b/gi,
|
|
197
|
+
];
|
|
198
|
+
/** True when the text shows self-harm intent or ideation. */
|
|
199
|
+
export function hasSelfHarm(text) {
|
|
200
|
+
const prepared = normalizeText(text);
|
|
201
|
+
return SELF_HARM_PATTERNS.some((re) => {
|
|
202
|
+
re.lastIndex = 0;
|
|
203
|
+
return re.test(prepared);
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Probes for personal details aimed at a child (or role-played as such).
|
|
208
|
+
* Sharing your own details is handled by redactPii; these block the ask.
|
|
209
|
+
*/
|
|
210
|
+
export const PII_PROBE_PATTERNS = [
|
|
211
|
+
/what(?:'s|\s+is)\s+your\s+real\s+(?:name|address|phone|number)\b/gi,
|
|
212
|
+
/what\s+school\s+do\s+you\b/gi,
|
|
213
|
+
/which\s+school\s+do\s+you\b/gi,
|
|
214
|
+
/what\s+school\s+do\s+you\s+go\s+to\b/gi,
|
|
215
|
+
/when\s+does\s+(?:school|it)\s+end\b/gi,
|
|
216
|
+
/where\s+do\s+you\s+live\b/gi,
|
|
217
|
+
/send\s+(?:me\s+)?(?:a\s+)?(?:photo|picture|pic|selfie)\b/gi,
|
|
218
|
+
/how\s+old\s+are\s+you\s+really\b/gi,
|
|
219
|
+
/your\s+(?:home\s+)?address\b/gi,
|
|
220
|
+
/your\s+phone\s+number\b/gi,
|
|
221
|
+
];
|
|
222
|
+
/** True when the text probes for personal details. */
|
|
223
|
+
export function hasPiiProbe(text) {
|
|
224
|
+
const prepared = normalizeText(text);
|
|
225
|
+
return PII_PROBE_PATTERNS.some((re) => {
|
|
108
226
|
re.lastIndex = 0;
|
|
109
227
|
return re.test(prepared);
|
|
110
228
|
});
|
|
@@ -160,23 +278,39 @@ export function redactPii(text) {
|
|
|
160
278
|
return { redacted, found };
|
|
161
279
|
}
|
|
162
280
|
function blockVerdict(category) {
|
|
281
|
+
const selfHarmConcern = category === 'self_harm';
|
|
282
|
+
// L0 always hard-blocks when it fires. Severity is for the audit trail:
|
|
283
|
+
// self-harm is marked serious (2); everything else is mild (1), matching
|
|
284
|
+
// the prior prefilter behavior for profanity and jailbreak.
|
|
285
|
+
const severity = category === 'self_harm' ? 2 : 1;
|
|
163
286
|
return {
|
|
164
287
|
allowed: false,
|
|
165
288
|
categories: [category],
|
|
166
|
-
severity
|
|
167
|
-
selfHarmConcern
|
|
289
|
+
severity,
|
|
290
|
+
selfHarmConcern,
|
|
168
291
|
failClosed: false,
|
|
169
|
-
kidMessage: T.blocks.byCategory[category],
|
|
292
|
+
kidMessage: selfHarmConcern ? T.selfHarmSupport.message : T.blocks.byCategory[category],
|
|
170
293
|
};
|
|
171
294
|
}
|
|
172
295
|
/**
|
|
173
|
-
* L0 check for kid input. Jailbreak
|
|
174
|
-
*
|
|
296
|
+
* L0 check for kid input. Jailbreak, profanity, grooming, self-harm, and
|
|
297
|
+
* personal-detail probes block (kindly). Shared personal details redact
|
|
298
|
+
* with a gentle reminder and never block on their own.
|
|
175
299
|
*/
|
|
176
300
|
export function prefilterInput(text) {
|
|
301
|
+
// Self-harm first so the supportive screen wins over other matches.
|
|
302
|
+
if (hasSelfHarm(text)) {
|
|
303
|
+
return { ok: false, redacted: text, notice: null, block: blockVerdict('self_harm') };
|
|
304
|
+
}
|
|
177
305
|
if (hasJailbreak(text)) {
|
|
178
306
|
return { ok: false, redacted: text, notice: null, block: blockVerdict('jailbreak') };
|
|
179
307
|
}
|
|
308
|
+
if (hasGrooming(text)) {
|
|
309
|
+
return { ok: false, redacted: text, notice: null, block: blockVerdict('grooming') };
|
|
310
|
+
}
|
|
311
|
+
if (hasPiiProbe(text)) {
|
|
312
|
+
return { ok: false, redacted: text, notice: null, block: blockVerdict('pii') };
|
|
313
|
+
}
|
|
180
314
|
if (hasProfanity(text)) {
|
|
181
315
|
return { ok: false, redacted: text, notice: null, block: blockVerdict('profanity') };
|
|
182
316
|
}
|
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.',
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* npm registry version check with a short disk cache so session start stays
|
|
3
|
+
* snappy. Fail-open: network errors never block Termi from starting.
|
|
4
|
+
*/
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { atomicWriteFileSync, termiHome } from '../config/paths.js';
|
|
8
|
+
import { isNewerVersion, NPM_PACKAGE, readLocalVersion } from './version.js';
|
|
9
|
+
/** How long a successful registry answer is reused. */
|
|
10
|
+
export const VERSION_CACHE_TTL_MS = 6 * 60 * 60 * 1000;
|
|
11
|
+
/** Hard cap on the registry round-trip so boot never hangs. */
|
|
12
|
+
export const VERSION_FETCH_TIMEOUT_MS = 2500;
|
|
13
|
+
export function versionCachePath() {
|
|
14
|
+
return path.join(termiHome(), 'version-check.json');
|
|
15
|
+
}
|
|
16
|
+
function readCache() {
|
|
17
|
+
try {
|
|
18
|
+
const raw = fs.readFileSync(versionCachePath(), 'utf8');
|
|
19
|
+
const parsed = JSON.parse(raw);
|
|
20
|
+
if (typeof parsed.fetchedAt === 'string' &&
|
|
21
|
+
typeof parsed.latest === 'string' &&
|
|
22
|
+
typeof parsed.currentAtFetch === 'string') {
|
|
23
|
+
return {
|
|
24
|
+
fetchedAt: parsed.fetchedAt,
|
|
25
|
+
latest: parsed.latest,
|
|
26
|
+
currentAtFetch: parsed.currentAtFetch,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
// Missing or corrupt cache is fine.
|
|
32
|
+
}
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
function writeCache(cache) {
|
|
36
|
+
try {
|
|
37
|
+
atomicWriteFileSync(versionCachePath(), `${JSON.stringify(cache, null, 2)}\n`);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
// Cache is best-effort.
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Returns the latest published version from the npm registry, or null on
|
|
45
|
+
* any failure. Uses a 6-hour on-disk cache.
|
|
46
|
+
*/
|
|
47
|
+
export async function fetchLatestVersion(opts = {}) {
|
|
48
|
+
const now = opts.now ?? Date.now();
|
|
49
|
+
if (!opts.force) {
|
|
50
|
+
const cached = readCache();
|
|
51
|
+
if (cached !== null) {
|
|
52
|
+
const age = now - Date.parse(cached.fetchedAt);
|
|
53
|
+
if (Number.isFinite(age) && age >= 0 && age < VERSION_CACHE_TTL_MS && cached.latest.length > 0) {
|
|
54
|
+
return cached.latest;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
59
|
+
const timeoutMs = opts.timeoutMs ?? VERSION_FETCH_TIMEOUT_MS;
|
|
60
|
+
const controller = new AbortController();
|
|
61
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
62
|
+
try {
|
|
63
|
+
const res = await fetchImpl(`https://registry.npmjs.org/${NPM_PACKAGE}/latest`, {
|
|
64
|
+
signal: controller.signal,
|
|
65
|
+
headers: { accept: 'application/json' },
|
|
66
|
+
});
|
|
67
|
+
if (!res.ok) {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
const body = (await res.json());
|
|
71
|
+
if (typeof body.version !== 'string' || body.version.length === 0) {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
writeCache({
|
|
75
|
+
fetchedAt: new Date(now).toISOString(),
|
|
76
|
+
latest: body.version,
|
|
77
|
+
currentAtFetch: readLocalVersion(),
|
|
78
|
+
});
|
|
79
|
+
return body.version;
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
finally {
|
|
85
|
+
clearTimeout(timer);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Compares the running install to the latest npm version.
|
|
90
|
+
* Never throws. Offline or timeout => skipped, updateAvailable false.
|
|
91
|
+
*/
|
|
92
|
+
export async function checkForUpdate(opts = {}) {
|
|
93
|
+
const current = readLocalVersion();
|
|
94
|
+
const latest = await fetchLatestVersion(opts);
|
|
95
|
+
if (latest === null) {
|
|
96
|
+
return { current, latest: null, updateAvailable: false, skipped: true, reason: 'registry-unavailable' };
|
|
97
|
+
}
|
|
98
|
+
return {
|
|
99
|
+
current,
|
|
100
|
+
latest,
|
|
101
|
+
updateAvailable: isNewerVersion(latest, current),
|
|
102
|
+
skipped: false,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Installs the latest termi-kids from npm into the global prefix.
|
|
3
|
+
*/
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
import { NPM_PACKAGE } from './version.js';
|
|
6
|
+
/**
|
|
7
|
+
* Runs `npm install -g termi-kids@latest` and waits for exit.
|
|
8
|
+
* Streams npm output to the parent so parents can see progress.
|
|
9
|
+
*/
|
|
10
|
+
export function installLatestUpdate(opts = {}) {
|
|
11
|
+
const spawnImpl = opts.spawnImpl ?? spawn;
|
|
12
|
+
return new Promise((resolve) => {
|
|
13
|
+
const child = spawnImpl('npm', ['install', '-g', `${NPM_PACKAGE}@latest`], {
|
|
14
|
+
stdio: 'inherit',
|
|
15
|
+
env: opts.env ?? process.env,
|
|
16
|
+
shell: process.platform === 'win32',
|
|
17
|
+
});
|
|
18
|
+
child.on('error', (err) => {
|
|
19
|
+
resolve({ ok: false, code: null, detail: err.message });
|
|
20
|
+
});
|
|
21
|
+
child.on('close', (code) => {
|
|
22
|
+
resolve({
|
|
23
|
+
ok: code === 0,
|
|
24
|
+
code,
|
|
25
|
+
detail: code === 0 ? 'updated' : `npm exited with code ${code ?? 'unknown'}`,
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session-start update prompt and the `termi update` command body.
|
|
3
|
+
*/
|
|
4
|
+
import * as p from '@clack/prompts';
|
|
5
|
+
import { style } from '../ui/theme.js';
|
|
6
|
+
import { checkForUpdate } from './check.js';
|
|
7
|
+
import { installLatestUpdate } from './install.js';
|
|
8
|
+
import { NPM_PACKAGE, readLocalVersion } from './version.js';
|
|
9
|
+
/** Env flag tests (and power users) use to skip the network check. */
|
|
10
|
+
export const SKIP_UPDATE_ENV = 'TERMI_SKIP_UPDATE';
|
|
11
|
+
export function shouldSkipUpdateCheck() {
|
|
12
|
+
if (process.env.VITEST !== undefined || process.env.NODE_ENV === 'test') {
|
|
13
|
+
return true;
|
|
14
|
+
}
|
|
15
|
+
const flag = process.env[SKIP_UPDATE_ENV];
|
|
16
|
+
return flag === '1' || flag === 'true' || flag === 'yes';
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Quiet check used on every normal session start. Asks y/n when a newer
|
|
20
|
+
* version is on npm. Fail-open: errors and skips never interrupt the kid.
|
|
21
|
+
*/
|
|
22
|
+
export async function maybePromptForUpdate() {
|
|
23
|
+
if (shouldSkipUpdateCheck()) {
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
let result;
|
|
27
|
+
try {
|
|
28
|
+
result = await checkForUpdate();
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
if (!result.updateAvailable || result.latest === null) {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
console.log('');
|
|
37
|
+
console.log(style.title(`A new Termi is ready: v${result.latest}`) +
|
|
38
|
+
style.dim(` (you have v${result.current})`));
|
|
39
|
+
const answer = await p.confirm({
|
|
40
|
+
message: 'Update Termi now?',
|
|
41
|
+
initialValue: true,
|
|
42
|
+
});
|
|
43
|
+
if (p.isCancel(answer) || answer !== true) {
|
|
44
|
+
console.log(style.dim('Okay. You can run "termi update" later.'));
|
|
45
|
+
console.log('');
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
await runUpdateCommand({ quietCheck: true });
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* `termi update`: always checks npm and installs when newer (or forced).
|
|
52
|
+
*/
|
|
53
|
+
export async function runUpdateCommand(opts = {}) {
|
|
54
|
+
const current = readLocalVersion();
|
|
55
|
+
if (!opts.quietCheck) {
|
|
56
|
+
console.log(`Termi v${current} (${NPM_PACKAGE})`);
|
|
57
|
+
console.log('Checking for a newer version...');
|
|
58
|
+
}
|
|
59
|
+
const result = await checkForUpdate({ force: true });
|
|
60
|
+
if (result.skipped || result.latest === null) {
|
|
61
|
+
console.log('Could not reach npm right now. Try again later.');
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (!result.updateAvailable) {
|
|
65
|
+
console.log(`You already have the latest version (v${result.current}).`);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
console.log(`Updating ${NPM_PACKAGE} from v${result.current} to v${result.latest}...`);
|
|
69
|
+
const install = await installLatestUpdate();
|
|
70
|
+
if (!install.ok) {
|
|
71
|
+
console.log('The update did not finish.');
|
|
72
|
+
console.log(style.dim(install.detail));
|
|
73
|
+
console.log(style.dim(`You can also run: npm install -g ${NPM_PACKAGE}@latest`));
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
console.log(`Updated to v${result.latest}.`);
|
|
77
|
+
console.log('Quit and run termi again to use the new version.');
|
|
78
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local version helpers and semver compare for the update checker.
|
|
3
|
+
*/
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
/** npm package name. The CLI bin is still `termi`. */
|
|
8
|
+
export const NPM_PACKAGE = 'termi-kids';
|
|
9
|
+
/** Reads the version embedded next to the built dist (package.json). */
|
|
10
|
+
export function readLocalVersion() {
|
|
11
|
+
try {
|
|
12
|
+
// dist/update/version.js -> package.json at package root
|
|
13
|
+
const pkgPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../package.json');
|
|
14
|
+
const raw = fs.readFileSync(pkgPath, 'utf8');
|
|
15
|
+
const parsed = JSON.parse(raw);
|
|
16
|
+
return typeof parsed.version === 'string' ? parsed.version : '0.0.0';
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return '0.0.0';
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/** Parse a simple x.y.z (or vX.Y.Z) version; non-numeric parts become 0. */
|
|
23
|
+
export function parseSemver(version) {
|
|
24
|
+
const cleaned = version.trim().replace(/^v/i, '');
|
|
25
|
+
const parts = cleaned.split(/[.+-]/).slice(0, 3).map((p) => {
|
|
26
|
+
const n = Number.parseInt(p, 10);
|
|
27
|
+
return Number.isFinite(n) && n >= 0 ? n : 0;
|
|
28
|
+
});
|
|
29
|
+
return [parts[0] ?? 0, parts[1] ?? 0, parts[2] ?? 0];
|
|
30
|
+
}
|
|
31
|
+
/** True when latest is strictly newer than current. */
|
|
32
|
+
export function isNewerVersion(latest, current) {
|
|
33
|
+
const a = parseSemver(latest);
|
|
34
|
+
const b = parseSemver(current);
|
|
35
|
+
for (let i = 0; i < 3; i++) {
|
|
36
|
+
if (a[i] > b[i])
|
|
37
|
+
return true;
|
|
38
|
+
if (a[i] < b[i])
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
return false;
|
|
42
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "termi-kids",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
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",
|