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 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 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
- ```
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. **Pick a safety level.** Strict is the default and the right choice for most kids.
62
- 5. **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. The download runs in the background: setup continues, your kid can start building right away, the home menu shows a progress bar, and the checker turns itself on the moment the verified file lands. An interrupted download resumes on the next start. Details are in [SAFETY.md](SAFETY.md).
63
- 6. **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."
64
- 7. **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.
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, change the safety level, 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.
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), and it blocks known "ignore your rules" tricks. It also looks for personal details: names, addresses, phone numbers, emails, school names. 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.
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. It runs in the background while setup finishes and while your kid builds; the home menu shows its progress bar, and the checker attaches itself to the running safety pipeline the moment the verified file is in place. 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.
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';
@@ -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 covers the envelope as written).
37
- * Older envelopes predate localClassifier: absent means on, the default.
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: _retired, ...kept } = raw;
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
- return { settings: normalizeSettings(envelope.settings), tampered: false, firstRun: false };
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.
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * The grown-ups panel: PIN gate, needs-attention banner, provider
3
- * management, safety level, model speed, the usage note, the audit log
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. */
@@ -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 three
5
- * checks: a profanity wordlist (block, ask to rephrase), PII patterns
6
- * (redact, never block), and jailbreak families (block on input; neutralize
7
- * in file context). Game words like kill, die, shoot, zombie are NOT here:
8
- * the game carve-out protects real kid game language.
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
- /** True when the text matches a jailbreak family. */
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
- return JAILBREAK_PATTERNS.some((re) => {
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: 1,
167
- selfHarmConcern: false,
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 and profanity block (kindly).
174
- * PII redacts with a gentle reminder, never blocks.
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
  }
@@ -1,6 +1,7 @@
1
1
  /**
2
- * The setup wizard: a parent flow (PIN, consent, AI provider, safety level)
3
- * followed by a kid flow (nickname, AI disclosure, launcher, first game).
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 so setup (and building) never waits on 623 MB. The
334
- * pipeline hot-attaches the checker the moment the verified file lands; the
335
- * home menu shows the progress bar until then. Declining turns the setting
336
- * off; a failed or interrupted download resumes on the next start.
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
- void ensureGuardFetch();
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();
@@ -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;
@@ -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 the first time the menu renders after it lands.
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 (guardWasLoading && fetchState.status === 'ready') {
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: 'Okay! It loads while you build and turns on by itself.',
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.0",
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",