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,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* llama.cpp runtime for the on-device safety classifier.
|
|
3
|
+
*
|
|
4
|
+
* Loads the pinned GGUF once (lazily, kicked off at client creation so the
|
|
5
|
+
* model warms while the kid reads the greeting) and serializes generation
|
|
6
|
+
* calls through one context sequence. Wrapper segments are tokenized with
|
|
7
|
+
* special tokens enabled; judged segments are tokenized as plain text, so
|
|
8
|
+
* nothing a kid types or a model writes can inject chat-control tokens.
|
|
9
|
+
*
|
|
10
|
+
* Every failure path throws; the safety pipeline turns any throw into a
|
|
11
|
+
* fail-closed block. This module never fails open.
|
|
12
|
+
*/
|
|
13
|
+
import { buildInputSegments, buildOutputSegments, guardVerdict, parseGuardReading, } from './localguard.js';
|
|
14
|
+
import { GUARD_MODEL, guardModelPath, guardModelReady, sha256OfFile } from './modelstore.js';
|
|
15
|
+
/** The verdict is three short lines; anything longer is already garbage. */
|
|
16
|
+
export const GUARD_MAX_TOKENS = 96;
|
|
17
|
+
/** Per-call generation budget. Model load and queue wait are not counted. */
|
|
18
|
+
export const GUARD_TIMEOUT_MS = 20_000;
|
|
19
|
+
/** One-time model load budget. A hung native load must not hang the chat. */
|
|
20
|
+
export const GUARD_LOAD_TIMEOUT_MS = 60_000;
|
|
21
|
+
/** Plenty for the wrapper plus both judged segments at their caps. */
|
|
22
|
+
const GUARD_CONTEXT_SIZE = 8192;
|
|
23
|
+
async function loadRuntime() {
|
|
24
|
+
// Readiness checks only the size (cheap, per check); the load re-verifies
|
|
25
|
+
// the pinned digest so a same-size file swapped in by a curious kid never
|
|
26
|
+
// runs as the guard. Same threat actor the HMAC-signed settings defend
|
|
27
|
+
// against; a failed digest fails closed through every classify call.
|
|
28
|
+
const digest = await sha256OfFile(guardModelPath());
|
|
29
|
+
if (digest !== GUARD_MODEL.sha256) {
|
|
30
|
+
throw new Error('guard-model-tampered');
|
|
31
|
+
}
|
|
32
|
+
const nlc = await import('node-llama-cpp');
|
|
33
|
+
const llama = await nlc.getLlama({ logLevel: nlc.LlamaLogLevel.error });
|
|
34
|
+
const model = await llama.loadModel({ modelPath: guardModelPath() });
|
|
35
|
+
const context = await model.createContext({ contextSize: GUARD_CONTEXT_SIZE });
|
|
36
|
+
const completion = new nlc.LlamaCompletion({ contextSequence: context.getSequence() });
|
|
37
|
+
return {
|
|
38
|
+
async generate(segments, signal) {
|
|
39
|
+
const prompt = nlc.LlamaText(segments.map((s) => s.kind === 'fixed' ? new nlc.SpecialTokensText(s.text) : s.text));
|
|
40
|
+
return completion.generateCompletion(prompt, {
|
|
41
|
+
maxTokens: GUARD_MAX_TOKENS,
|
|
42
|
+
customStopTriggers: ['<|im_end|>'],
|
|
43
|
+
signal,
|
|
44
|
+
});
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
/** Rejects when the promise takes longer than ms. The load stays bounded. */
|
|
49
|
+
function withDeadline(promise, ms, reason) {
|
|
50
|
+
return new Promise((resolve, reject) => {
|
|
51
|
+
const timer = setTimeout(() => reject(new Error(reason)), ms);
|
|
52
|
+
promise.then((value) => {
|
|
53
|
+
clearTimeout(timer);
|
|
54
|
+
resolve(value);
|
|
55
|
+
}, (err) => {
|
|
56
|
+
clearTimeout(timer);
|
|
57
|
+
reject(err instanceof Error ? err : new Error(String(err)));
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Builds the guard client, or null when the model file is not in place.
|
|
63
|
+
* Creation starts the model load; classify calls wait for it (bounded by
|
|
64
|
+
* the load deadline), then get their own generation timeout. Queue wait is
|
|
65
|
+
* deliberately unbudgeted: every queued call is itself bounded, so a long
|
|
66
|
+
* file's later chunks wait their turn instead of spuriously failing closed.
|
|
67
|
+
*/
|
|
68
|
+
export function createGuardClient(opts = {}) {
|
|
69
|
+
if (!guardModelReady()) {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
const timeoutMs = opts.timeoutMs ?? GUARD_TIMEOUT_MS;
|
|
73
|
+
const loadTimeoutMs = opts.loadTimeoutMs ?? GUARD_LOAD_TIMEOUT_MS;
|
|
74
|
+
const runtime = (opts.loadRuntimeImpl ?? loadRuntime)();
|
|
75
|
+
// A load failure must surface on classify calls, not as a process crash.
|
|
76
|
+
void runtime.catch(() => undefined);
|
|
77
|
+
let queue = Promise.resolve();
|
|
78
|
+
async function run(segments) {
|
|
79
|
+
const rt = await withDeadline(runtime, loadTimeoutMs, 'guard-load-timeout');
|
|
80
|
+
const controller = new AbortController();
|
|
81
|
+
const timer = setTimeout(() => controller.abort(new Error('guard-timeout')), timeoutMs);
|
|
82
|
+
try {
|
|
83
|
+
// The deadline backstops a native call that ignores the abort signal:
|
|
84
|
+
// without it, one hung generation would queue every later check
|
|
85
|
+
// behind it and freeze the chat for good.
|
|
86
|
+
const raw = await withDeadline(rt.generate(segments, controller.signal), timeoutMs * 2, 'guard-timeout');
|
|
87
|
+
return guardVerdict(parseGuardReading(raw));
|
|
88
|
+
}
|
|
89
|
+
finally {
|
|
90
|
+
clearTimeout(timer);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
function enqueue(segments) {
|
|
94
|
+
const next = queue.then(() => run(segments));
|
|
95
|
+
queue = next.catch(() => undefined);
|
|
96
|
+
return next;
|
|
97
|
+
}
|
|
98
|
+
return {
|
|
99
|
+
classifyInput: (text) => enqueue(buildInputSegments(text)),
|
|
100
|
+
classifyOutput: (kidText, producedText) => enqueue(buildOutputSegments(kidText, producedText)),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Accessor that hot-attaches the guard. It returns null while the model
|
|
105
|
+
* file is still downloading, then builds the client once on the first check
|
|
106
|
+
* after the file lands and reuses it for the rest of the session. The stat
|
|
107
|
+
* call per check is trivial next to a model call.
|
|
108
|
+
*/
|
|
109
|
+
export function lazyGuardAccessor(enabled, opts = {}) {
|
|
110
|
+
let client = null;
|
|
111
|
+
return () => {
|
|
112
|
+
if (!enabled) {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
if (client === null) {
|
|
116
|
+
try {
|
|
117
|
+
client = createGuardClient(opts);
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
client = null;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return client;
|
|
124
|
+
};
|
|
125
|
+
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* On-device safety classifier contract for Qwen3Guard-Gen-0.6B.
|
|
3
|
+
*
|
|
4
|
+
* Pure functions only: prompt segments, output parsing, and the mapping
|
|
5
|
+
* from guard categories onto Termi's taxonomy. The llama.cpp runtime that
|
|
6
|
+
* feeds these lives in guardrunner.ts so everything here stays testable
|
|
7
|
+
* without a model file.
|
|
8
|
+
*
|
|
9
|
+
* The prompt wrapper reproduces the classification template that ships in
|
|
10
|
+
* the model's tokenizer config, byte for byte. Judged text never becomes
|
|
11
|
+
* part of the wrapper: the runner tokenizes wrapper segments and judged
|
|
12
|
+
* segments separately, so special tokens inside kid content stay plain text.
|
|
13
|
+
*/
|
|
14
|
+
import { blockMessage, severityBlocks } from './taxonomy.js';
|
|
15
|
+
/** Category names exactly as the guard model prints them. */
|
|
16
|
+
export const GUARD_CATEGORIES = [
|
|
17
|
+
'Violent',
|
|
18
|
+
'Non-violent Illegal Acts',
|
|
19
|
+
'Sexual Content or Sexual Acts',
|
|
20
|
+
'PII',
|
|
21
|
+
'Suicide & Self-Harm',
|
|
22
|
+
'Unethical Acts',
|
|
23
|
+
'Politically Sensitive Topics',
|
|
24
|
+
'Copyright Violation',
|
|
25
|
+
'Jailbreak',
|
|
26
|
+
];
|
|
27
|
+
/** Where each guard category lands in Termi's taxonomy. */
|
|
28
|
+
export const GUARD_TO_TERMI = {
|
|
29
|
+
Violent: 'violence',
|
|
30
|
+
'Non-violent Illegal Acts': 'illicit',
|
|
31
|
+
'Sexual Content or Sexual Acts': 'sexual',
|
|
32
|
+
PII: 'pii',
|
|
33
|
+
'Suicide & Self-Harm': 'self_harm',
|
|
34
|
+
'Unethical Acts': 'illicit',
|
|
35
|
+
'Politically Sensitive Topics': 'adult_advice',
|
|
36
|
+
'Copyright Violation': 'copyright',
|
|
37
|
+
Jailbreak: 'jailbreak',
|
|
38
|
+
};
|
|
39
|
+
const CATEGORY_BLOCK = GUARD_CATEGORIES.map((c) => `${c}.`).join('\n');
|
|
40
|
+
const CATEGORY_BLOCK_NO_JAILBREAK = GUARD_CATEGORIES.filter((c) => c !== 'Jailbreak')
|
|
41
|
+
.map((c) => `${c}.`)
|
|
42
|
+
.join('\n');
|
|
43
|
+
const INPUT_HEADER = '<|im_start|>user\n' +
|
|
44
|
+
'# Task:\n' +
|
|
45
|
+
"1) Evaluate whether THE LAST USER's query belongs to safe or unsafe content based on our safety policy.\n" +
|
|
46
|
+
'2) Identify the category of unsafe content found.\n' +
|
|
47
|
+
'\n' +
|
|
48
|
+
'<BEGIN SAFETY POLICY>\n' +
|
|
49
|
+
"**Safety: Safe**: The USER's query does not contain any prohibited content or content that could lead to prohibited content.\n" +
|
|
50
|
+
"**Safety: Unsafe**: The USER's query contains prohibited content.\n" +
|
|
51
|
+
"**Safety: Controversial**: The USER's query does not explicitly contain prohibited content, but its intent, context, or potential responses could be misinterpreted, misused, or exploited to generate unsafe content under certain conditions.\n" +
|
|
52
|
+
'<END SAFETY POLICY>\n' +
|
|
53
|
+
'\n' +
|
|
54
|
+
'<BEGIN UNSAFE CONTENT CATEGORIES>\n' +
|
|
55
|
+
`${CATEGORY_BLOCK}\n` +
|
|
56
|
+
'<END UNSAFE CONTENT CATEGORIES>\n' +
|
|
57
|
+
'\n' +
|
|
58
|
+
'<BEGIN CONVERSATION>\n' +
|
|
59
|
+
'\n';
|
|
60
|
+
const INPUT_FOOTER = '\n' +
|
|
61
|
+
'\n' +
|
|
62
|
+
'<END CONVERSATION>\n' +
|
|
63
|
+
'\n' +
|
|
64
|
+
"Provide your safety assessment for ONLY THE LAST **USER's query** in the above conversation:\n" +
|
|
65
|
+
" - The first line must be one of: 'Safety: Safe', 'Safety: Unsafe', 'Safety: Controversial'.\n" +
|
|
66
|
+
" - The second line should start with 'Categories:' followed by a list of any unsafe content categories, separated by commas. If the content is safe, use 'Categories: None'.<|im_end|>\n" +
|
|
67
|
+
'<|im_start|>assistant\n' +
|
|
68
|
+
'<think>\n' +
|
|
69
|
+
'\n' +
|
|
70
|
+
'</think>\n' +
|
|
71
|
+
'\n';
|
|
72
|
+
const OUTPUT_HEADER = '<|im_start|>user\n' +
|
|
73
|
+
'# Task:\n' +
|
|
74
|
+
"1) Evaluate whether THE LAST ASSISTANT's response belongs to safe or unsafe content based on our safety policy.\n" +
|
|
75
|
+
'2) Identify the category of unsafe content found.\n' +
|
|
76
|
+
"3) Determine if THE LAST ASSISTANT's response is a refusal to the USER's query.\n" +
|
|
77
|
+
'\n' +
|
|
78
|
+
'<BEGIN SAFETY POLICY>\n' +
|
|
79
|
+
"**Safety: Safe**: The ASSISTANT's response does not contain any prohibited content or content that could lead to prohibited content.\n" +
|
|
80
|
+
"**Safety: Unsafe**: The ASSISTANT's response contains prohibited content.\n" +
|
|
81
|
+
"**Safety: Controversial**: The ASSISTANT's response is not explicitly harmful, but could become risky or offensive depending on context or audience.\n" +
|
|
82
|
+
'<END SAFETY POLICY>\n' +
|
|
83
|
+
'\n' +
|
|
84
|
+
'<BEGIN UNSAFE CONTENT CATEGORIES>\n' +
|
|
85
|
+
`${CATEGORY_BLOCK_NO_JAILBREAK}\n` +
|
|
86
|
+
'<END UNSAFE CONTENT CATEGORIES>\n' +
|
|
87
|
+
'\n' +
|
|
88
|
+
'<BEGIN REFUSAL POLICY>\n' +
|
|
89
|
+
"**Refusal: Yes**: The ASSISTANT refused to answer or did not comply with the USER's request.\n" +
|
|
90
|
+
"**Refusal: No**: The ASSISTANT directly answered or complied with the USER's request.\n" +
|
|
91
|
+
'<END REFUSAL POLICY>\n' +
|
|
92
|
+
'\n' +
|
|
93
|
+
'<BEGIN CONVERSATION>\n' +
|
|
94
|
+
'\n';
|
|
95
|
+
const OUTPUT_FOOTER = '\n' +
|
|
96
|
+
'\n' +
|
|
97
|
+
'<END CONVERSATION>\n' +
|
|
98
|
+
'\n' +
|
|
99
|
+
"Provide your safety assessment for ONLY THE LAST **ASSISTANT's response** in the above conversation:\n" +
|
|
100
|
+
" - The first line must be one of: 'Safety: Safe', 'Safety: Unsafe', 'Safety: Controversial'.\n" +
|
|
101
|
+
" - The second line should start with 'Categories:' followed by a list of any unsafe content categories, separated by commas. If the content is safe, use 'Categories: None'.\n" +
|
|
102
|
+
" - The third line must be one of: 'Refusal: Yes', 'Refusal: No'.<|im_end|>\n" +
|
|
103
|
+
'<|im_start|>assistant\n' +
|
|
104
|
+
'<think>\n' +
|
|
105
|
+
'\n' +
|
|
106
|
+
'</think>\n' +
|
|
107
|
+
'\n';
|
|
108
|
+
/** Longest judged text accepted per call. Callers chunk well below this. */
|
|
109
|
+
export const GUARD_TEXT_CAP = 6000;
|
|
110
|
+
/**
|
|
111
|
+
* Judged text is data. The wrapper's conversation markers are defanged and
|
|
112
|
+
* chat-control glyph pairs are broken so judged content can never read as
|
|
113
|
+
* template structure. Token-level injection is impossible regardless (the
|
|
114
|
+
* runner never parses special tokens in judged segments); this guards the
|
|
115
|
+
* plain-text layer.
|
|
116
|
+
*/
|
|
117
|
+
export function sanitizeJudged(text) {
|
|
118
|
+
return text
|
|
119
|
+
.slice(0, GUARD_TEXT_CAP)
|
|
120
|
+
.replace(/\u0000/g, '')
|
|
121
|
+
.replace(/<\|/g, '< |')
|
|
122
|
+
.replace(/<(\/?)(BEGIN|END) (CONVERSATION|SAFETY POLICY|UNSAFE CONTENT CATEGORIES|REFUSAL POLICY)>/gi, '($2 $3)')
|
|
123
|
+
// Verdict-shaped lines inside judged text lose their colon so an echo
|
|
124
|
+
// can never look like the guard's own output (the parser is anchored
|
|
125
|
+
// to the first line anyway; this also avoids needless blocks when a
|
|
126
|
+
// kid literally types a verdict-shaped line).
|
|
127
|
+
.replace(/^(\s*)(Safety|Categories|Refusal)(\s*):/gim, '$1$2$3;');
|
|
128
|
+
}
|
|
129
|
+
/** Prompt segments for judging something the kid typed. */
|
|
130
|
+
export function buildInputSegments(kidText) {
|
|
131
|
+
return [
|
|
132
|
+
{ kind: 'fixed', text: `${INPUT_HEADER}USER: ` },
|
|
133
|
+
{ kind: 'judged', text: sanitizeJudged(kidText) },
|
|
134
|
+
{ kind: 'fixed', text: INPUT_FOOTER },
|
|
135
|
+
];
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Prompt segments for judging text the app produced (a reply or file text).
|
|
139
|
+
* The kid's last message gives the model the exchange it was trained on;
|
|
140
|
+
* file checks pass an empty string and get a neutral stand-in.
|
|
141
|
+
*/
|
|
142
|
+
export function buildOutputSegments(kidText, producedText) {
|
|
143
|
+
const kid = sanitizeJudged(kidText.slice(0, 500)).trim() || 'Please help me with my project.';
|
|
144
|
+
return [
|
|
145
|
+
{ kind: 'fixed', text: `${OUTPUT_HEADER}USER: ` },
|
|
146
|
+
{ kind: 'judged', text: kid },
|
|
147
|
+
{ kind: 'fixed', text: '\n\nASSISTANT: ' },
|
|
148
|
+
{ kind: 'judged', text: sanitizeJudged(producedText) },
|
|
149
|
+
{ kind: 'fixed', text: OUTPUT_FOOTER },
|
|
150
|
+
];
|
|
151
|
+
}
|
|
152
|
+
/** Flattens segments into the exact prompt string (tests and debugging). */
|
|
153
|
+
export function renderSegments(segments) {
|
|
154
|
+
return segments.map((s) => s.text).join('');
|
|
155
|
+
}
|
|
156
|
+
const GUARD_NAME_LOOKUP = new Map(GUARD_CATEGORIES.map((c) => [c.toLowerCase(), c]));
|
|
157
|
+
/**
|
|
158
|
+
* Parses the guard completion. The verdict must LEAD it: the first
|
|
159
|
+
* non-empty line has to be the Safety line, and Categories/Refusal are
|
|
160
|
+
* read only from the two lines after it. A completion that opens with
|
|
161
|
+
* anything else, including an induced echo of judged text, throws, and
|
|
162
|
+
* the caller turns that into a fail-closed block.
|
|
163
|
+
*/
|
|
164
|
+
export function parseGuardReading(raw) {
|
|
165
|
+
const lines = raw
|
|
166
|
+
.split('\n')
|
|
167
|
+
.map((line) => line.trim())
|
|
168
|
+
.filter((line) => line.length > 0);
|
|
169
|
+
const safety = /^Safety:\s*(Safe|Unsafe|Controversial)\b/i.exec(lines[0] ?? '');
|
|
170
|
+
if (safety === null) {
|
|
171
|
+
throw new Error('guard-verdict-missing');
|
|
172
|
+
}
|
|
173
|
+
const matched = safety[1].toLowerCase();
|
|
174
|
+
const level = (matched.charAt(0).toUpperCase() + matched.slice(1));
|
|
175
|
+
const window = lines.slice(1, 3);
|
|
176
|
+
const categories = [];
|
|
177
|
+
const catLine = window.map((l) => /^Categories:\s*(.+)$/i.exec(l)).find((m) => m !== null);
|
|
178
|
+
if (catLine !== undefined && catLine !== null) {
|
|
179
|
+
for (const piece of catLine[1].split(',')) {
|
|
180
|
+
const name = piece.trim().replace(/[.]+$/, '').trim();
|
|
181
|
+
if (name.length === 0 || /^none$/i.test(name)) {
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
const known = GUARD_NAME_LOOKUP.get(name.toLowerCase());
|
|
185
|
+
if (known !== undefined && !categories.includes(known)) {
|
|
186
|
+
categories.push(known);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
const refusalLine = window.map((l) => /^Refusal:\s*(Yes|No)\b/i.exec(l)).find((m) => m !== null);
|
|
191
|
+
const refusal = refusalLine === undefined || refusalLine === null ? null : /yes/i.test(refusalLine[1]);
|
|
192
|
+
return { level, categories, refusal };
|
|
193
|
+
}
|
|
194
|
+
/** Severity for a guard level: Unsafe 2, Controversial 1, Safe 0. */
|
|
195
|
+
export function guardSeverity(level) {
|
|
196
|
+
if (level === 'Unsafe')
|
|
197
|
+
return 2;
|
|
198
|
+
if (level === 'Controversial')
|
|
199
|
+
return 1;
|
|
200
|
+
return 0;
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Maps a guard reading onto Termi's verdict shape. Unsafe always blocks
|
|
204
|
+
* (every category's threshold is at or below severity 2, and an Unsafe
|
|
205
|
+
* reading with no recognizable category still blocks generically rather
|
|
206
|
+
* than sliding through). Controversial blocks only the severity-1
|
|
207
|
+
* categories (pii, jailbreak), matching the taxonomy thresholds.
|
|
208
|
+
*/
|
|
209
|
+
export function guardVerdict(reading) {
|
|
210
|
+
const severity = guardSeverity(reading.level);
|
|
211
|
+
const categories = [];
|
|
212
|
+
for (const guardCategory of reading.categories) {
|
|
213
|
+
const mapped = GUARD_TO_TERMI[guardCategory];
|
|
214
|
+
if (!categories.includes(mapped)) {
|
|
215
|
+
categories.push(mapped);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
const blocked = reading.level === 'Unsafe' ? true : severityBlocks(categories, severity);
|
|
219
|
+
return {
|
|
220
|
+
allowed: !blocked,
|
|
221
|
+
categories,
|
|
222
|
+
severity,
|
|
223
|
+
selfHarmConcern: categories.includes('self_harm'),
|
|
224
|
+
failClosed: false,
|
|
225
|
+
kidMessage: blocked ? blockMessage(categories) : null,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* On-disk store for the local safety-classifier model.
|
|
3
|
+
*
|
|
4
|
+
* One pinned artifact: repo, file, byte size, and sha256 are constants.
|
|
5
|
+
* Downloads stream to a temp file in the models directory, the digest is
|
|
6
|
+
* computed while writing, and only a byte-for-byte verified file is
|
|
7
|
+
* renamed into place. A partial or tampered download can never be loaded
|
|
8
|
+
* because readiness checks the exact size and load happens only after the
|
|
9
|
+
* atomic rename.
|
|
10
|
+
*/
|
|
11
|
+
import crypto from 'node:crypto';
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import { pipeline } from 'node:stream/promises';
|
|
15
|
+
import { Readable } from 'node:stream';
|
|
16
|
+
import { modelsDir } from '../config/paths.js';
|
|
17
|
+
/** The pinned classifier artifact. Update size and digest together. */
|
|
18
|
+
export const GUARD_MODEL = {
|
|
19
|
+
/** Friendly name shown to parents. */
|
|
20
|
+
name: 'Qwen3Guard 0.6B safety checker',
|
|
21
|
+
fileName: 'Qwen3Guard-Gen-0.6B.Q6_K.gguf',
|
|
22
|
+
url: 'https://huggingface.co/QuantFactory/Qwen3Guard-Gen-0.6B-GGUF/resolve/main/' +
|
|
23
|
+
'Qwen3Guard-Gen-0.6B.Q6_K.gguf',
|
|
24
|
+
bytes: 622733312,
|
|
25
|
+
sha256: '33a70125c0fff6805e1a1b8b99f59981c6cd3f724a06bf3a99c16dfa8326e585',
|
|
26
|
+
displaySize: '623 MB',
|
|
27
|
+
};
|
|
28
|
+
export function guardModelPath() {
|
|
29
|
+
return path.join(modelsDir(), GUARD_MODEL.fileName);
|
|
30
|
+
}
|
|
31
|
+
/** True when the model file exists with exactly the pinned size. */
|
|
32
|
+
export function guardModelReady() {
|
|
33
|
+
try {
|
|
34
|
+
return fs.statSync(guardModelPath()).size === GUARD_MODEL.bytes;
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
export function removeGuardModel() {
|
|
41
|
+
fs.rmSync(guardModelPath(), { force: true });
|
|
42
|
+
}
|
|
43
|
+
/** Where an in-progress download accumulates. Stable name enables resume. */
|
|
44
|
+
export function guardPartialPath(artifact = GUARD_MODEL) {
|
|
45
|
+
return `${path.join(modelsDir(), artifact.fileName)}.partial`;
|
|
46
|
+
}
|
|
47
|
+
/** Streams a whole file through sha256. The disk contents are the truth. */
|
|
48
|
+
export async function sha256OfFile(filePath) {
|
|
49
|
+
const hash = crypto.createHash('sha256');
|
|
50
|
+
await new Promise((resolve, reject) => {
|
|
51
|
+
const stream = fs.createReadStream(filePath);
|
|
52
|
+
stream.on('data', (chunk) => hash.update(chunk));
|
|
53
|
+
stream.on('end', resolve);
|
|
54
|
+
stream.on('error', reject);
|
|
55
|
+
});
|
|
56
|
+
return hash.digest('hex');
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Downloads and verifies the model, resuming a previous partial download
|
|
60
|
+
* when the server supports byte ranges. Verification happens against the
|
|
61
|
+
* file ON DISK after the transfer, immediately before the rename, so bytes
|
|
62
|
+
* written by anything else (a concurrent Termi process racing the same
|
|
63
|
+
* partial path) can never slip an unverified file into place.
|
|
64
|
+
*
|
|
65
|
+
* Failure behavior, by cause:
|
|
66
|
+
* - interrupted transfer: the partial file stays for the next resume.
|
|
67
|
+
* - digest or oversize problem: the partial is poisoned and deleted.
|
|
68
|
+
* Only a byte-for-byte verified file is renamed into place.
|
|
69
|
+
*/
|
|
70
|
+
export async function downloadGuardModel(opts = {}) {
|
|
71
|
+
const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
|
|
72
|
+
const artifact = opts.artifact ?? GUARD_MODEL;
|
|
73
|
+
const finalPath = path.join(modelsDir(), artifact.fileName);
|
|
74
|
+
const partialPath = guardPartialPath(artifact);
|
|
75
|
+
fs.mkdirSync(path.dirname(finalPath), { recursive: true });
|
|
76
|
+
let offset = 0;
|
|
77
|
+
try {
|
|
78
|
+
const size = fs.statSync(partialPath).size;
|
|
79
|
+
if (size > 0 && size < artifact.bytes) {
|
|
80
|
+
offset = size;
|
|
81
|
+
}
|
|
82
|
+
else if (size >= artifact.bytes) {
|
|
83
|
+
// A partial at or past the full size can never verify; start over.
|
|
84
|
+
fs.rmSync(partialPath, { force: true });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
// No partial: fresh download.
|
|
89
|
+
}
|
|
90
|
+
const res = await fetchImpl(artifact.url, offset > 0 ? { headers: { range: `bytes=${offset}-` } } : undefined);
|
|
91
|
+
if (!res.ok || res.body === null) {
|
|
92
|
+
throw new Error(`guard-download-failed:http-${res.status}`);
|
|
93
|
+
}
|
|
94
|
+
if (res.status !== 206) {
|
|
95
|
+
// The server sent the whole file (or ignored the range): restart clean.
|
|
96
|
+
offset = 0;
|
|
97
|
+
}
|
|
98
|
+
let written = offset;
|
|
99
|
+
try {
|
|
100
|
+
const source = Readable.fromWeb(res.body);
|
|
101
|
+
source.on('data', (chunk) => {
|
|
102
|
+
written += chunk.length;
|
|
103
|
+
opts.onProgress?.(written, artifact.bytes);
|
|
104
|
+
});
|
|
105
|
+
await pipeline(source, fs.createWriteStream(partialPath, { mode: 0o600, flags: offset > 0 ? 'a' : 'w' }));
|
|
106
|
+
}
|
|
107
|
+
catch (err) {
|
|
108
|
+
// Transfer error: keep the partial so the next attempt resumes.
|
|
109
|
+
throw err instanceof Error ? err : new Error(String(err));
|
|
110
|
+
}
|
|
111
|
+
// Judge the file as it exists on disk, not the bytes this process saw.
|
|
112
|
+
const diskSize = fs.statSync(partialPath).size;
|
|
113
|
+
if (diskSize < artifact.bytes) {
|
|
114
|
+
// Short. Keep the partial; the next attempt resumes.
|
|
115
|
+
throw new Error('guard-download-failed:interrupted');
|
|
116
|
+
}
|
|
117
|
+
if (diskSize > artifact.bytes) {
|
|
118
|
+
fs.rmSync(partialPath, { force: true });
|
|
119
|
+
throw new Error('guard-download-failed:size-mismatch');
|
|
120
|
+
}
|
|
121
|
+
const digest = await sha256OfFile(partialPath);
|
|
122
|
+
if (digest !== artifact.sha256) {
|
|
123
|
+
fs.rmSync(partialPath, { force: true });
|
|
124
|
+
throw new Error('guard-download-failed:digest-mismatch');
|
|
125
|
+
}
|
|
126
|
+
fs.renameSync(partialPath, finalPath);
|
|
127
|
+
}
|