termi-kids 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +34 -0
- package/README.md +148 -0
- package/SAFETY.md +187 -0
- package/bin/termi.js +22 -0
- package/dist/agent/context.js +126 -0
- package/dist/agent/loop.js +172 -0
- package/dist/agent/prompts/system.js +45 -0
- package/dist/agent/tools.js +335 -0
- package/dist/auth/keychain.js +146 -0
- package/dist/auth/oauth.js +375 -0
- package/dist/auth/tokens.js +219 -0
- package/dist/cli.js +258 -0
- package/dist/config/paths.js +92 -0
- package/dist/config/pin.js +150 -0
- package/dist/config/settings.js +131 -0
- package/dist/grownups/panel.js +483 -0
- package/dist/learn/lessons.js +490 -0
- package/dist/learn/runner.js +193 -0
- package/dist/preview/server.js +407 -0
- package/dist/projects/create.js +103 -0
- package/dist/projects/ideas.js +182 -0
- package/dist/projects/quests.js +277 -0
- package/dist/projects/scaffolds/art.js +484 -0
- package/dist/projects/scaffolds/biggames.js +554 -0
- package/dist/projects/scaffolds/characters.js +580 -0
- package/dist/projects/scaffolds/games.js +516 -0
- package/dist/projects/scaffolds/index.js +24 -0
- package/dist/projects/scaffolds/music.js +528 -0
- package/dist/projects/scaffolds/pets.js +567 -0
- package/dist/projects/scaffolds/quizzes.js +757 -0
- package/dist/projects/scaffolds/stories.js +620 -0
- package/dist/projects/scaffolds/vendor/KAPLAY-LICENSE.txt +35 -0
- package/dist/projects/scaffolds/vendor/kaplay.mjs +57 -0
- package/dist/projects/scaffolds/websites.js +474 -0
- package/dist/projects/snapshots.js +203 -0
- package/dist/projects/store.js +325 -0
- package/dist/providers/errors.js +207 -0
- package/dist/providers/index.js +316 -0
- package/dist/providers/models.js +38 -0
- package/dist/safety/audit.js +195 -0
- package/dist/safety/blocks.js +29 -0
- package/dist/safety/classifier.js +337 -0
- package/dist/safety/codescan.js +168 -0
- package/dist/safety/guarddownload.js +79 -0
- package/dist/safety/guardrunner.js +125 -0
- package/dist/safety/localguard.js +227 -0
- package/dist/safety/modelstore.js +127 -0
- package/dist/safety/prefilter.js +214 -0
- package/dist/safety/session.js +118 -0
- package/dist/safety/taxonomy.js +246 -0
- package/dist/safety/textextract.js +193 -0
- package/dist/setup/launcher.js +65 -0
- package/dist/setup/wizard.js +469 -0
- package/dist/surfaces/chat.js +439 -0
- package/dist/surfaces/commands.js +206 -0
- package/dist/surfaces/home.js +438 -0
- package/dist/types.js +5 -0
- package/dist/ui/banner.js +35 -0
- package/dist/ui/celebrate.js +141 -0
- package/dist/ui/errors.js +97 -0
- package/dist/ui/mascot.js +223 -0
- package/dist/ui/text.js +156 -0
- package/dist/ui/theme.js +92 -0
- package/package.json +67 -0
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
/**
|
|
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.
|
|
5
|
+
*
|
|
6
|
+
* The pure helpers (one-liners, attention lines, log parsing) are exported
|
|
7
|
+
* so tests cover them without driving the prompts.
|
|
8
|
+
*/
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import * as p from '@clack/prompts';
|
|
11
|
+
import { API_KEY_ACCOUNTS, deleteSecret, isFallbackActive, KEYCHAIN_SERVICE } from '../auth/keychain.js';
|
|
12
|
+
import { clearTokens, hasTokens, loadTokens } from '../auth/tokens.js';
|
|
13
|
+
import { hasPin, resetPin, verifyPin } from '../config/pin.js';
|
|
14
|
+
import { auditLogPath, authJsonPath, modelsDir, projectsDir, settingsPath, termiHome, } from '../config/paths.js';
|
|
15
|
+
import { defaultSettings, loadSettings, saveSettings } from '../config/settings.js';
|
|
16
|
+
import { moderationKeyAccessor } from '../providers/index.js';
|
|
17
|
+
import { modelLabel } from '../providers/models.js';
|
|
18
|
+
import { appendAudit, verifyAuditChain } from '../safety/audit.js';
|
|
19
|
+
import { ensureGuardFetch, guardProgressBar } from '../safety/guarddownload.js';
|
|
20
|
+
import { GUARD_MODEL, guardModelReady, removeGuardModel } from '../safety/modelstore.js';
|
|
21
|
+
import { style } from '../ui/theme.js';
|
|
22
|
+
import { T } from '../ui/text.js';
|
|
23
|
+
import { configureProvider, KEY_ACCOUNT, providerLabel } from '../setup/wizard.js';
|
|
24
|
+
/** Parses audit JSONL into display lines. Skips anchors and bad lines. */
|
|
25
|
+
export function parseAuditLog(raw) {
|
|
26
|
+
const out = [];
|
|
27
|
+
for (const line of raw.split('\n')) {
|
|
28
|
+
if (line.trim().length === 0) {
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
try {
|
|
32
|
+
const parsed = JSON.parse(line);
|
|
33
|
+
if (parsed['anchor'] === true) {
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
if (typeof parsed.ts !== 'string' || typeof parsed.event !== 'string') {
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
const entry = {
|
|
40
|
+
ts: parsed.ts,
|
|
41
|
+
layer: typeof parsed.layer === 'string' ? parsed.layer : 'system',
|
|
42
|
+
event: parsed.event,
|
|
43
|
+
};
|
|
44
|
+
if (typeof parsed.category === 'string') {
|
|
45
|
+
entry.category = parsed.category;
|
|
46
|
+
}
|
|
47
|
+
if (typeof parsed.severity === 'number') {
|
|
48
|
+
entry.severity = parsed.severity;
|
|
49
|
+
}
|
|
50
|
+
if (typeof parsed.direction === 'string') {
|
|
51
|
+
entry.direction = parsed.direction;
|
|
52
|
+
}
|
|
53
|
+
out.push(entry);
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
// A bad line is the chain verifier's problem, not the viewer's.
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
function shortTime(ts) {
|
|
62
|
+
const date = new Date(ts);
|
|
63
|
+
if (Number.isNaN(date.getTime())) {
|
|
64
|
+
return ts;
|
|
65
|
+
}
|
|
66
|
+
return date.toLocaleString([], {
|
|
67
|
+
month: 'short',
|
|
68
|
+
day: 'numeric',
|
|
69
|
+
hour: 'numeric',
|
|
70
|
+
minute: '2-digit',
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
const EVENT_PHRASES = {
|
|
74
|
+
block: 'a message was blocked',
|
|
75
|
+
redact: 'private info was hidden',
|
|
76
|
+
fail_closed: 'a safety check could not finish, so Termi paused',
|
|
77
|
+
settings_change: 'settings changed',
|
|
78
|
+
pin_fail: 'a wrong PIN was tried',
|
|
79
|
+
pin_reset: 'the PIN was reset',
|
|
80
|
+
provider_change: 'the AI helper account changed',
|
|
81
|
+
consent: 'consent was recorded',
|
|
82
|
+
auth_dead: 'the sign-in stopped working',
|
|
83
|
+
};
|
|
84
|
+
/**
|
|
85
|
+
* A neutral one-line summary of an audit entry: time, layer, what happened,
|
|
86
|
+
* category when present. Grooming flags get a loud, unmistakable prefix.
|
|
87
|
+
*/
|
|
88
|
+
export function auditOneLiner(entry) {
|
|
89
|
+
const time = shortTime(entry.ts);
|
|
90
|
+
const cat = entry.category !== undefined ? ` (${entry.category})` : '';
|
|
91
|
+
if (entry.event === 'grooming_flag') {
|
|
92
|
+
return `!! ${time} REVIEW FIRST: a chat raised a grooming warning${cat}. Please talk with your kid.`;
|
|
93
|
+
}
|
|
94
|
+
const phrase = EVENT_PHRASES[entry.event] ?? entry.event;
|
|
95
|
+
return `${time} [${entry.layer}] ${phrase}${cat}`;
|
|
96
|
+
}
|
|
97
|
+
/** Reads the live attention state for the banner. */
|
|
98
|
+
export function collectAttention(settings) {
|
|
99
|
+
const tokens = loadTokens();
|
|
100
|
+
const authDead = settings.activeProvider === 'openai-chatgpt' && (tokens === null || tokens.dead === true);
|
|
101
|
+
return { authDead, fallbackKeychain: isFallbackActive() };
|
|
102
|
+
}
|
|
103
|
+
/** Banner lines for the attention state. Empty means all is well. */
|
|
104
|
+
export function needsAttentionLines(state) {
|
|
105
|
+
const lines = [];
|
|
106
|
+
if (state.authDead) {
|
|
107
|
+
lines.push('The ChatGPT sign-in stopped working. Sign in again under Providers.');
|
|
108
|
+
}
|
|
109
|
+
if (state.fallbackKeychain) {
|
|
110
|
+
lines.push('Secrets are stored in a plain file, not the system keychain. That is less protected.');
|
|
111
|
+
}
|
|
112
|
+
return lines;
|
|
113
|
+
}
|
|
114
|
+
/** Plain-language note on how many AI calls one kid message makes. */
|
|
115
|
+
export function usageNote(activeProvider, freeModeration) {
|
|
116
|
+
const lines = [
|
|
117
|
+
'Each kid message makes up to 3 AI calls:',
|
|
118
|
+
' 1. A safety check on the message.',
|
|
119
|
+
' 2. The build call that writes code.',
|
|
120
|
+
' 3. A safety check on the answer.',
|
|
121
|
+
'Each file the AI changes adds one more safety check.',
|
|
122
|
+
];
|
|
123
|
+
if (freeModeration) {
|
|
124
|
+
lines.push('Safety checks here use a free checker. They do not use your plan.');
|
|
125
|
+
}
|
|
126
|
+
else if (activeProvider === 'openai-chatgpt') {
|
|
127
|
+
lines.push('On the ChatGPT sign-in, safety checks share your plan quota.');
|
|
128
|
+
}
|
|
129
|
+
else {
|
|
130
|
+
lines.push('Safety checks use your API account, like the build calls.');
|
|
131
|
+
}
|
|
132
|
+
lines.push('If Termi runs out of energy, it comes back when your plan resets.');
|
|
133
|
+
return lines.join('\n');
|
|
134
|
+
}
|
|
135
|
+
function audit(event, excerpt) {
|
|
136
|
+
try {
|
|
137
|
+
appendAudit({ ts: new Date().toISOString(), layer: 'system', event, excerpt });
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
// Best effort.
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
async function forgotPinFlow() {
|
|
144
|
+
const sure = await p.confirm({
|
|
145
|
+
message: 'Resetting wipes the PIN and every AI key, and turns strict settings back on. Continue?',
|
|
146
|
+
initialValue: false,
|
|
147
|
+
});
|
|
148
|
+
if (p.isCancel(sure) || !sure) {
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
resetPin();
|
|
152
|
+
try {
|
|
153
|
+
saveSettings(defaultSettings());
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
// Strict defaults are also what a missing file loads as.
|
|
157
|
+
}
|
|
158
|
+
audit('pin_reset', 'forgot-pin wipe, strict defaults restored');
|
|
159
|
+
p.log.info('Done. Setup will run again so you can add a new PIN and provider.');
|
|
160
|
+
try {
|
|
161
|
+
const wizard = await import('../setup/wizard.js');
|
|
162
|
+
await wizard.runWizard();
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
p.log.warn('Run termi again to redo setup.');
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* PIN gate for grown-up areas. True when verified. Handles lockout
|
|
170
|
+
* messaging and the forgot-PIN wipe path.
|
|
171
|
+
*/
|
|
172
|
+
export async function pinGate() {
|
|
173
|
+
if (!hasPin()) {
|
|
174
|
+
return true;
|
|
175
|
+
}
|
|
176
|
+
for (;;) {
|
|
177
|
+
const entry = await p.password({ message: T.grownups.pinPrompt });
|
|
178
|
+
if (p.isCancel(entry)) {
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
181
|
+
const result = verifyPin(entry);
|
|
182
|
+
if (result.ok) {
|
|
183
|
+
return true;
|
|
184
|
+
}
|
|
185
|
+
if (result.lockedForSeconds !== undefined) {
|
|
186
|
+
const minutes = Math.max(1, Math.ceil(result.lockedForSeconds / 60));
|
|
187
|
+
p.log.warn(T.grownups.lockout.replace('{minutes}', String(minutes)));
|
|
188
|
+
return false;
|
|
189
|
+
}
|
|
190
|
+
p.log.warn(T.grownups.wrongPin);
|
|
191
|
+
const next = await p.select({
|
|
192
|
+
message: 'What now?',
|
|
193
|
+
options: [
|
|
194
|
+
{ value: 'retry', label: 'Try again' },
|
|
195
|
+
{ value: 'forgot', label: 'I forgot the PIN' },
|
|
196
|
+
{ value: 'back', label: 'Go back' },
|
|
197
|
+
],
|
|
198
|
+
});
|
|
199
|
+
if (p.isCancel(next) || next === 'back') {
|
|
200
|
+
return false;
|
|
201
|
+
}
|
|
202
|
+
if (next === 'forgot') {
|
|
203
|
+
await forgotPinFlow();
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Pure settings transition for removing one provider: drops it from the
|
|
210
|
+
* configured list and moves the active pointer to the first remaining
|
|
211
|
+
* provider (or null). Exported so tests cover it without the prompts.
|
|
212
|
+
*/
|
|
213
|
+
export function removeProviderFromSettings(settings, id) {
|
|
214
|
+
const configured = settings.configuredProviders.filter((p) => p !== id);
|
|
215
|
+
const active = settings.activeProvider === id ? (configured[0] ?? null) : settings.activeProvider;
|
|
216
|
+
return { ...settings, configuredProviders: configured, activeProvider: active };
|
|
217
|
+
}
|
|
218
|
+
/** Deletes the stored credential for one provider (key or sign-in tokens). */
|
|
219
|
+
function deleteProviderCredential(id) {
|
|
220
|
+
if (id === 'openai-chatgpt') {
|
|
221
|
+
clearTokens();
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
deleteSecret(KEY_ACCOUNT[id]);
|
|
225
|
+
}
|
|
226
|
+
async function providersMenu(settings) {
|
|
227
|
+
let current = settings;
|
|
228
|
+
for (;;) {
|
|
229
|
+
const configuredNote = current.configuredProviders.length > 0
|
|
230
|
+
? current.configuredProviders
|
|
231
|
+
.map((id) => `${providerLabel(id)}${id === current.activeProvider ? ' (active)' : ''}`)
|
|
232
|
+
.join(', ')
|
|
233
|
+
: 'none yet';
|
|
234
|
+
p.log.info(`Configured: ${configuredNote}`);
|
|
235
|
+
const pick = await p.select({
|
|
236
|
+
message: 'Providers',
|
|
237
|
+
options: [
|
|
238
|
+
{ value: 'add', label: 'Add a provider' },
|
|
239
|
+
...(current.configuredProviders.length > 0
|
|
240
|
+
? [
|
|
241
|
+
{ value: 'switch', label: 'Switch the active provider' },
|
|
242
|
+
{ value: 'remove', label: 'Remove a provider' },
|
|
243
|
+
]
|
|
244
|
+
: []),
|
|
245
|
+
{ value: 'back', label: 'Back' },
|
|
246
|
+
],
|
|
247
|
+
});
|
|
248
|
+
if (p.isCancel(pick) || pick === 'back') {
|
|
249
|
+
return current;
|
|
250
|
+
}
|
|
251
|
+
if (pick === 'add') {
|
|
252
|
+
const updated = await configureProvider(current);
|
|
253
|
+
if (updated !== null) {
|
|
254
|
+
current = updated;
|
|
255
|
+
if (current.activeProvider === null && current.configuredProviders.length > 0) {
|
|
256
|
+
current = { ...current, activeProvider: current.configuredProviders[0] ?? null };
|
|
257
|
+
}
|
|
258
|
+
current = saveSettings(current);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
else if (pick === 'switch') {
|
|
262
|
+
const active = await p.select({
|
|
263
|
+
message: 'Which one should Termi use?',
|
|
264
|
+
options: current.configuredProviders.map((id) => ({
|
|
265
|
+
value: id,
|
|
266
|
+
label: providerLabel(id),
|
|
267
|
+
})),
|
|
268
|
+
...(current.activeProvider !== null ? { initialValue: current.activeProvider } : {}),
|
|
269
|
+
});
|
|
270
|
+
if (!p.isCancel(active)) {
|
|
271
|
+
current = saveSettings({ ...current, activeProvider: active });
|
|
272
|
+
audit('provider_change', `active ${active}`);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
else if (pick === 'remove') {
|
|
276
|
+
const target = await p.select({
|
|
277
|
+
message: 'Which one should Termi forget?',
|
|
278
|
+
options: current.configuredProviders.map((id) => ({
|
|
279
|
+
value: id,
|
|
280
|
+
label: providerLabel(id),
|
|
281
|
+
})),
|
|
282
|
+
});
|
|
283
|
+
if (p.isCancel(target)) {
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
const sure = await p.confirm({
|
|
287
|
+
message: `Remove ${providerLabel(target)}? Its saved key or sign-in is deleted.`,
|
|
288
|
+
initialValue: false,
|
|
289
|
+
});
|
|
290
|
+
if (p.isCancel(sure) || !sure) {
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
deleteProviderCredential(target);
|
|
294
|
+
current = saveSettings(removeProviderFromSettings(current, target));
|
|
295
|
+
audit('provider_change', `removed ${target}`);
|
|
296
|
+
p.log.success(`${providerLabel(target)} is removed.`);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
function showAuditViewer() {
|
|
301
|
+
const verification = verifyAuditChain();
|
|
302
|
+
if (verification.ok) {
|
|
303
|
+
p.log.success(`Log check: good. ${verification.entries} entries, none changed.`);
|
|
304
|
+
}
|
|
305
|
+
else {
|
|
306
|
+
p.log.warn(`Log check: problem at line ${verification.firstBadLine ?? 0}. Entries may be missing or changed.`);
|
|
307
|
+
}
|
|
308
|
+
let raw = '';
|
|
309
|
+
try {
|
|
310
|
+
raw = fs.readFileSync(auditLogPath(), 'utf8');
|
|
311
|
+
}
|
|
312
|
+
catch {
|
|
313
|
+
raw = '';
|
|
314
|
+
}
|
|
315
|
+
const entries = parseAuditLog(raw);
|
|
316
|
+
const recent = entries.slice(-20);
|
|
317
|
+
if (recent.length === 0) {
|
|
318
|
+
p.log.info('No events yet. That is good news.');
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
const grooming = recent.filter((e) => e.event === 'grooming_flag');
|
|
322
|
+
if (grooming.length > 0) {
|
|
323
|
+
p.log.warn(`${grooming.length} grooming warning(s) below. Read those first.`);
|
|
324
|
+
}
|
|
325
|
+
for (const entry of recent) {
|
|
326
|
+
const line = auditOneLiner(entry);
|
|
327
|
+
console.log(entry.event === 'grooming_flag' ? style.bad(line) : line);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
function showDataScreen() {
|
|
331
|
+
const accounts = ['pin-hash', 'setup-marker', 'hmac-key', 'install-id', ...API_KEY_ACCOUNTS];
|
|
332
|
+
p.note([
|
|
333
|
+
`Settings: ${settingsPath()}`,
|
|
334
|
+
`State folder: ${termiHome()}`,
|
|
335
|
+
`Kid projects: ${projectsDir()}`,
|
|
336
|
+
`Audit log: ${auditLogPath()}`,
|
|
337
|
+
`Sign-in tokens: ${authJsonPath()}`,
|
|
338
|
+
`Safety model: ${modelsDir()}`,
|
|
339
|
+
`Keychain service: ${KEYCHAIN_SERVICE}`,
|
|
340
|
+
`Keychain accounts: ${accounts.join(', ')}`,
|
|
341
|
+
'',
|
|
342
|
+
'To remove Termi fully, delete those folders and keychain entries.',
|
|
343
|
+
'Kid projects are plain files. Keep them if you want the games.',
|
|
344
|
+
].join('\n'), 'Your data');
|
|
345
|
+
}
|
|
346
|
+
/** One-line status for the on-device safety checker menu row. */
|
|
347
|
+
export function guardStatusLine(settings) {
|
|
348
|
+
if (!settings.localClassifier) {
|
|
349
|
+
return 'off';
|
|
350
|
+
}
|
|
351
|
+
return guardModelReady() ? 'on' : 'on, model not downloaded';
|
|
352
|
+
}
|
|
353
|
+
/** Manage the on-device safety checker: toggle, download, remove. */
|
|
354
|
+
async function guardMenu(settings) {
|
|
355
|
+
const ready = guardModelReady();
|
|
356
|
+
p.note([
|
|
357
|
+
`${GUARD_MODEL.name} screens every message on this computer,`,
|
|
358
|
+
'even with no internet. It checks: violence, illegal acts, sexual content,',
|
|
359
|
+
'personal details, self-harm, unethical acts, heavy political topics,',
|
|
360
|
+
'copying others\' work, and rule-breaking attempts.',
|
|
361
|
+
`Model file: ${ready ? 'downloaded' : `not downloaded (${GUARD_MODEL.displaySize})`}`,
|
|
362
|
+
].join('\n'), 'Safety checker');
|
|
363
|
+
const options = [];
|
|
364
|
+
if (!settings.localClassifier) {
|
|
365
|
+
options.push({ value: 'on', label: 'Turn it on' });
|
|
366
|
+
}
|
|
367
|
+
else {
|
|
368
|
+
options.push({ value: 'off', label: 'Turn it off' });
|
|
369
|
+
}
|
|
370
|
+
if (!ready) {
|
|
371
|
+
options.push({ value: 'download', label: `Download the model (${GUARD_MODEL.displaySize})` });
|
|
372
|
+
}
|
|
373
|
+
else {
|
|
374
|
+
options.push({ value: 'remove', label: 'Remove the model file' });
|
|
375
|
+
}
|
|
376
|
+
options.push({ value: 'back', label: 'Back' });
|
|
377
|
+
const pick = await p.select({ message: 'Safety checker', options });
|
|
378
|
+
if (p.isCancel(pick) || pick === 'back') {
|
|
379
|
+
return settings;
|
|
380
|
+
}
|
|
381
|
+
if (pick === 'on' || pick === 'off') {
|
|
382
|
+
const next = saveSettings({ ...settings, localClassifier: pick === 'on' });
|
|
383
|
+
audit('settings_change', `local classifier ${pick}`);
|
|
384
|
+
return next;
|
|
385
|
+
}
|
|
386
|
+
if (pick === 'remove') {
|
|
387
|
+
// Removing the file also turns the checker off; otherwise the next
|
|
388
|
+
// start would silently re-download 623 MB against the parent's intent.
|
|
389
|
+
removeGuardModel();
|
|
390
|
+
const next = saveSettings({ ...settings, localClassifier: false });
|
|
391
|
+
audit('settings_change', 'local classifier model removed and turned off');
|
|
392
|
+
p.log.info('Removed the model file and turned the checker off.');
|
|
393
|
+
return next;
|
|
394
|
+
}
|
|
395
|
+
// Joins the background fetch when one is already running (the shared
|
|
396
|
+
// manager is single-flight), otherwise starts it, and shows a live bar.
|
|
397
|
+
const spin = p.spinner();
|
|
398
|
+
spin.start(`${T.wizard.guardDownloading} ${guardProgressBar()}`);
|
|
399
|
+
const ticker = setInterval(() => {
|
|
400
|
+
spin.message(`${T.wizard.guardDownloading} ${guardProgressBar()}`);
|
|
401
|
+
}, 250);
|
|
402
|
+
const ok = await ensureGuardFetch();
|
|
403
|
+
clearInterval(ticker);
|
|
404
|
+
spin.stop(ok ? T.wizard.guardReady : T.wizard.guardFailed);
|
|
405
|
+
return settings;
|
|
406
|
+
}
|
|
407
|
+
/** The PIN-gated grown-ups panel. */
|
|
408
|
+
export async function runPanel() {
|
|
409
|
+
const ok = await pinGate();
|
|
410
|
+
if (!ok) {
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
let settings = loadSettings().settings;
|
|
414
|
+
const attention = needsAttentionLines(collectAttention(settings));
|
|
415
|
+
if (attention.length > 0) {
|
|
416
|
+
p.note(attention.join('\n'), T.grownups.needsAttention);
|
|
417
|
+
}
|
|
418
|
+
if (settings.activeProvider === 'openai-chatgpt' && !hasTokens()) {
|
|
419
|
+
p.log.warn(T.errors.auth);
|
|
420
|
+
}
|
|
421
|
+
for (;;) {
|
|
422
|
+
const pick = await p.select({
|
|
423
|
+
message: 'Grown-up zone',
|
|
424
|
+
options: [
|
|
425
|
+
{ value: 'providers', label: 'Providers (add or switch)' },
|
|
426
|
+
{ value: 'safety', label: `Safety level (now: ${settings.safetyLevel})` },
|
|
427
|
+
{ value: 'guard', label: `Safety checker on this computer (${guardStatusLine(settings)})` },
|
|
428
|
+
{ value: 'speed', label: `Model speed (now: ${modelLabel(settings.modelAlias)})` },
|
|
429
|
+
{ value: 'usage', label: 'Usage and quota note' },
|
|
430
|
+
{ value: 'audit', label: 'Safety log' },
|
|
431
|
+
{ value: 'data', label: 'Your data and uninstall' },
|
|
432
|
+
{ value: 'exit', label: 'Exit' },
|
|
433
|
+
],
|
|
434
|
+
});
|
|
435
|
+
if (p.isCancel(pick) || pick === 'exit') {
|
|
436
|
+
p.outro('Closed the grown-up zone.');
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
if (pick === 'providers') {
|
|
440
|
+
settings = await providersMenu(settings);
|
|
441
|
+
}
|
|
442
|
+
else if (pick === 'guard') {
|
|
443
|
+
settings = await guardMenu(settings);
|
|
444
|
+
}
|
|
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
|
+
else if (pick === 'speed') {
|
|
460
|
+
const alias = await p.select({
|
|
461
|
+
message: 'Pick the model speed.',
|
|
462
|
+
options: [
|
|
463
|
+
{ value: 'zippy', label: modelLabel('zippy'), hint: 'Fast and good. Uses less quota.' },
|
|
464
|
+
{ value: 'smart', label: modelLabel('smart'), hint: 'Slower, for tricky asks.' },
|
|
465
|
+
],
|
|
466
|
+
initialValue: settings.modelAlias,
|
|
467
|
+
});
|
|
468
|
+
if (!p.isCancel(alias) && alias !== settings.modelAlias) {
|
|
469
|
+
settings = saveSettings({ ...settings, modelAlias: alias });
|
|
470
|
+
audit('settings_change', `model speed ${alias}`);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
else if (pick === 'usage') {
|
|
474
|
+
console.log(usageNote(settings.activeProvider, moderationKeyAccessor() !== null));
|
|
475
|
+
}
|
|
476
|
+
else if (pick === 'audit') {
|
|
477
|
+
showAuditViewer();
|
|
478
|
+
}
|
|
479
|
+
else if (pick === 'data') {
|
|
480
|
+
showDataScreen();
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
}
|