icoa-cli 2.19.348 → 2.19.349
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/dist/commands/ai4ctf.js +1 -1
- package/dist/commands/arena-eai.js +1 -1
- package/dist/commands/connect.js +1 -1
- package/dist/commands/ctf.js +1 -1
- package/dist/commands/ctf4ai-demo.js +1 -1
- package/dist/commands/ctf4vla.js +1 -1
- package/dist/commands/exam.js +1 -1
- package/dist/index.js +355 -1
- package/dist/lib/access.js +184 -1
- package/dist/lib/arena-submit.js +21 -1
- package/dist/lib/budget.js +6 -1
- package/dist/lib/challenge-dir.js +16 -1
- package/dist/lib/comms.js +212 -1
- package/dist/lib/config.js +93 -1
- package/dist/lib/country-lang.js +39 -1
- package/dist/lib/demo-exam.js +478 -1
- package/dist/lib/demo-flags.js +27 -1
- package/dist/lib/demo-stats.js +62 -1
- package/dist/lib/demo2-progress.js +102 -1
- package/dist/lib/exam-client.js +54 -1
- package/dist/lib/exam-state.js +273 -1
- package/dist/lib/gemini.js +247 -1
- package/dist/lib/integrity-snapshot.js +88 -1
- package/dist/lib/interactive-spawn.js +55 -1
- package/dist/lib/ipynb-input.js +65 -1
- package/dist/lib/kernel-protocol.js +88 -1
- package/dist/lib/kernel.js +146 -2
- package/dist/lib/learn-curricula.js +309 -1
- package/dist/lib/learn-i18n.js +184 -1
- package/dist/lib/log-sync.js +155 -1
- package/dist/lib/logger.js +49 -1
- package/dist/lib/open-file.js +55 -1
- package/dist/lib/paper-upgrade.js +119 -1
- package/dist/lib/render-card.js +112 -1
- package/dist/lib/repl-asker.js +67 -1
- package/dist/lib/sample-runner.js +227 -1
- package/dist/lib/shell-split.js +69 -1
- package/dist/lib/sim-cooldown.js +75 -1
- package/dist/lib/theme.js +119 -1
- package/dist/lib/token-format.js +74 -1
- package/dist/lib/toolset-hash.js +48 -1
- package/dist/lib/translations-fetcher.js +95 -1
- package/dist/lib/ui.js +99 -1
- package/dist/lib/version.js +24 -1
- package/dist/postinstall.js +48 -1
- package/dist/repl.js +2251 -1
- package/dist/types/index.js +63 -1
- package/package.json +1 -1
package/dist/lib/kernel.js
CHANGED
|
@@ -1,4 +1,28 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* NotebookKernel — the Node driver for a Jupyter kernel, Phase 1 of the CLI
|
|
3
|
+
* notebook arena (see `project_cli_notebook_arena_plan`).
|
|
4
|
+
*
|
|
5
|
+
* icoa (Node) can't talk to a Jupyter kernel directly, so we spawn a small
|
|
6
|
+
* Python BRIDGE under the aienv venv. The bridge owns the kernel via
|
|
7
|
+
* `jupyter_client` and exchanges line-delimited JSON with us (parsed by
|
|
8
|
+
* `kernel-protocol.ts`). This keeps the kernel a PLUGGABLE backend: Phase 1
|
|
9
|
+
* runs it locally; Phase 2/4 point the same bridge at a remote Kernel Gateway
|
|
10
|
+
* with zero change to the Node side.
|
|
11
|
+
*
|
|
12
|
+
* Isolation: the bridge runs as `<venv>/bin/python`, and it registers its
|
|
13
|
+
* kernelspec INSIDE the venv's own `share/jupyter` tree — so the kernel is the
|
|
14
|
+
* venv interpreter, never the system python3 (BUG3 invariant), and nothing is
|
|
15
|
+
* written outside the venv. On posix the kernel uses IPC transport (unix
|
|
16
|
+
* sockets) so it opens no TCP ports.
|
|
17
|
+
*/
|
|
18
|
+
import { spawn } from 'node:child_process';
|
|
19
|
+
import { writeFileSync } from 'node:fs';
|
|
20
|
+
import { join } from 'node:path';
|
|
21
|
+
import { foldCellOutputs, parseKernelEvent } from './kernel-protocol.js';
|
|
22
|
+
// The bridge. Ships as a string (like sample-runner's RUNNER_PY) and is written
|
|
23
|
+
// next to the venv at start. stdout carries ONLY protocol JSON; kernel/runtime
|
|
24
|
+
// chatter stays on the kernel subprocess's own stderr.
|
|
25
|
+
export const KERNEL_BRIDGE_PY = String.raw `import sys, os, json, queue
|
|
2
26
|
|
|
3
27
|
def emit(o):
|
|
4
28
|
sys.stdout.write(json.dumps(o) + "\n")
|
|
@@ -78,4 +102,124 @@ def main():
|
|
|
78
102
|
os._exit(0)
|
|
79
103
|
|
|
80
104
|
main()
|
|
81
|
-
`;
|
|
105
|
+
`;
|
|
106
|
+
/**
|
|
107
|
+
* A live notebook kernel. Executes are serialized (one cell at a time, as a
|
|
108
|
+
* notebook does) so the line-buffered output demux stays unambiguous.
|
|
109
|
+
*/
|
|
110
|
+
export class NotebookKernel {
|
|
111
|
+
proc = null;
|
|
112
|
+
buf = '';
|
|
113
|
+
nextId = 1;
|
|
114
|
+
ready = false;
|
|
115
|
+
// Resolver for the in-flight cell (id → its event accumulator + resolve fn).
|
|
116
|
+
pending = null;
|
|
117
|
+
onReady = null;
|
|
118
|
+
onExit = null;
|
|
119
|
+
opts;
|
|
120
|
+
bridgePath;
|
|
121
|
+
constructor(opts) {
|
|
122
|
+
this.opts = opts;
|
|
123
|
+
this.bridgePath = join(opts.venvRoot, 'icoa-kernel-bridge.py');
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Write the bridge + spawn it; resolves once the kernel reports `ready`.
|
|
127
|
+
* Safe to call again after shutdown() to get a fresh kernel (restart) — all
|
|
128
|
+
* per-run state is reset here.
|
|
129
|
+
*/
|
|
130
|
+
start() {
|
|
131
|
+
this.buf = '';
|
|
132
|
+
this.ready = false;
|
|
133
|
+
this.pending = null;
|
|
134
|
+
this.nextId = 1;
|
|
135
|
+
writeFileSync(this.bridgePath, KERNEL_BRIDGE_PY);
|
|
136
|
+
const proc = spawn(this.opts.venvPython, [this.bridgePath], {
|
|
137
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
138
|
+
});
|
|
139
|
+
this.proc = proc;
|
|
140
|
+
proc.stdout.setEncoding('utf-8');
|
|
141
|
+
proc.stdout.on('data', (chunk) => this.onStdout(chunk));
|
|
142
|
+
proc.on('exit', () => {
|
|
143
|
+
this.ready = false;
|
|
144
|
+
this.onExit?.();
|
|
145
|
+
});
|
|
146
|
+
const timeoutMs = this.opts.readyTimeoutMs ?? 60000;
|
|
147
|
+
return new Promise((resolve, reject) => {
|
|
148
|
+
const timer = setTimeout(() => reject(new Error('kernel did not become ready in time')), timeoutMs);
|
|
149
|
+
this.onReady = () => {
|
|
150
|
+
clearTimeout(timer);
|
|
151
|
+
resolve();
|
|
152
|
+
};
|
|
153
|
+
this.onExit = () => {
|
|
154
|
+
clearTimeout(timer);
|
|
155
|
+
if (!this.ready)
|
|
156
|
+
reject(new Error('kernel process exited before ready'));
|
|
157
|
+
};
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
/** Run one cell; resolves with its folded outputs after the `done` event. */
|
|
161
|
+
execute(code) {
|
|
162
|
+
if (!this.proc || !this.ready)
|
|
163
|
+
return Promise.reject(new Error('kernel not ready'));
|
|
164
|
+
if (this.pending)
|
|
165
|
+
return Promise.reject(new Error('a cell is already executing'));
|
|
166
|
+
const id = this.nextId++;
|
|
167
|
+
return new Promise((resolve) => {
|
|
168
|
+
this.pending = { id, events: [], resolve };
|
|
169
|
+
this.proc?.stdin.write(`${JSON.stringify({ id, code })}\n`);
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
/** Ask the bridge to shut the kernel down; resolves when the process exits. */
|
|
173
|
+
shutdown() {
|
|
174
|
+
const proc = this.proc;
|
|
175
|
+
if (!proc)
|
|
176
|
+
return Promise.resolve();
|
|
177
|
+
return new Promise((resolve) => {
|
|
178
|
+
this.onExit = () => resolve();
|
|
179
|
+
try {
|
|
180
|
+
proc.stdin.write(`${JSON.stringify({ cmd: 'shutdown' })}\n`);
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
proc.kill();
|
|
184
|
+
}
|
|
185
|
+
// Hard backstop if the bridge ignores us.
|
|
186
|
+
setTimeout(() => {
|
|
187
|
+
if (this.proc) {
|
|
188
|
+
this.proc.kill();
|
|
189
|
+
resolve();
|
|
190
|
+
}
|
|
191
|
+
}, 5000);
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
onStdout(chunk) {
|
|
195
|
+
this.buf += chunk;
|
|
196
|
+
let nl = this.buf.indexOf('\n');
|
|
197
|
+
while (nl !== -1) {
|
|
198
|
+
const line = this.buf.slice(0, nl);
|
|
199
|
+
this.buf = this.buf.slice(nl + 1);
|
|
200
|
+
this.handleEvent(parseKernelEvent(line));
|
|
201
|
+
nl = this.buf.indexOf('\n');
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
handleEvent(ev) {
|
|
205
|
+
if (!ev)
|
|
206
|
+
return;
|
|
207
|
+
if (ev.type === 'ready') {
|
|
208
|
+
this.ready = true;
|
|
209
|
+
this.onReady?.();
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
const p = this.pending;
|
|
213
|
+
if (!p)
|
|
214
|
+
return; // event outside any execution (shouldn't happen) — ignore
|
|
215
|
+
if (ev.type === 'done') {
|
|
216
|
+
if (ev.id !== p.id)
|
|
217
|
+
return;
|
|
218
|
+
const folded = foldCellOutputs(p.events.concat(ev));
|
|
219
|
+
this.pending = null;
|
|
220
|
+
p.resolve(folded);
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
p.events.push(ev);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
@@ -1 +1,309 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Learn-mode curriculum content.
|
|
3
|
+
*
|
|
4
|
+
* Phase 0: hardcoded LEARNDEMO01 (n=10 cards, free for everyone).
|
|
5
|
+
* Phase 1+: full EAxxxxxxxx tokens load curriculum from server.
|
|
6
|
+
*
|
|
7
|
+
* Card types:
|
|
8
|
+
* knowledge — text + diagram + "ok / next" to advance
|
|
9
|
+
* mcq — 4-choice question with instant feedback + explanation
|
|
10
|
+
* practical — drop into sandbox Python, return when done
|
|
11
|
+
* sim_demo — open MuJoCo viewer, watch action play out
|
|
12
|
+
* milestone — section completion celebration with ASCII trophy
|
|
13
|
+
*/
|
|
14
|
+
/** Returns a copy of `card` with locale-aware fields applied.
|
|
15
|
+
*
|
|
16
|
+
* As of v2.19.204, all curriculum content is fetched from the server
|
|
17
|
+
* with _zh and check fields already merged (see panda/seed-server-curricula.js).
|
|
18
|
+
* This function just picks the localised text out of card._zh when
|
|
19
|
+
* the requested locale starts with 'zh'. The auto-overlay maps that
|
|
20
|
+
* used to live in learn-phases-zh.ts and learn-phases-checks.ts are
|
|
21
|
+
* no longer client-side — they were merged at seed time.
|
|
22
|
+
*
|
|
23
|
+
* Spread-then-merge keeps undefined keys clean. */
|
|
24
|
+
export function localized(card, lang) {
|
|
25
|
+
if (!lang.startsWith('zh') || !card._zh)
|
|
26
|
+
return card;
|
|
27
|
+
const zh = card._zh;
|
|
28
|
+
const out = { ...card };
|
|
29
|
+
for (const k of Object.keys(zh)) {
|
|
30
|
+
if (zh[k] !== undefined)
|
|
31
|
+
out[k] = zh[k];
|
|
32
|
+
}
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Shape guard for a server curriculum payload.
|
|
37
|
+
*
|
|
38
|
+
* The learn-validate + curriculum endpoints are lenient: a NON-learn token
|
|
39
|
+
* (e.g. an EXAM token typed into `learn`) can return success:true with a
|
|
40
|
+
* payload that is not a curriculum. Before this guard, fetchCurriculum cast it
|
|
41
|
+
* straight to Curriculum, it passed the `!curriculum` null-check in learn.ts,
|
|
42
|
+
* then renderWelcome/newLearnState read `.cards` / `.totalCards` on undefined →
|
|
43
|
+
* TypeError → uncaught → worker exit → "validating… processing… <crash> menu".
|
|
44
|
+
*
|
|
45
|
+
* Returning false makes loadCurriculumById yield null, so learn.ts shows a
|
|
46
|
+
* clean error instead of crashing. Checks only stable core fields.
|
|
47
|
+
*/
|
|
48
|
+
export function isValidCurriculum(data) {
|
|
49
|
+
if (!data || typeof data !== 'object')
|
|
50
|
+
return false;
|
|
51
|
+
const c = data;
|
|
52
|
+
return (typeof c.id === 'string' && typeof c.totalCards === 'number' && Array.isArray(c.cards) && Array.isArray(c.modules));
|
|
53
|
+
}
|
|
54
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
55
|
+
// v2.19.204: Card content moved server-side
|
|
56
|
+
//
|
|
57
|
+
// All curricula (demos + 96 + 360, including LEARNDEMO01) are stored on
|
|
58
|
+
// the server in `learn.db.curricula` and fetched via
|
|
59
|
+
// GET /api/icoa/learn/curriculum/<id>. Client caches in ~/.icoa/learn-cache/
|
|
60
|
+
// to keep cold-start fast and survive brief server outages.
|
|
61
|
+
//
|
|
62
|
+
// Cache strategy:
|
|
63
|
+
// - On every loadCurriculumById(id), check ~/.icoa/learn-cache/<id>.json
|
|
64
|
+
// - If cached AND ageMs < CACHE_TTL_MS, return cached immediately
|
|
65
|
+
// - Else fetch from server, write cache, return
|
|
66
|
+
// - If fetch fails AND cache exists (stale OK in this case), return cached
|
|
67
|
+
// - If fetch fails AND no cache, return null
|
|
68
|
+
//
|
|
69
|
+
// LEARNDEMO01 is no longer baked into the bundle — first-ever launch
|
|
70
|
+
// requires network, but subsequent runs (with cache) are offline-ok.
|
|
71
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
72
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, statSync, utimesSync } from 'node:fs';
|
|
73
|
+
import { join } from 'node:path';
|
|
74
|
+
import { homedir } from 'node:os';
|
|
75
|
+
const CACHE_DIR = join(homedir(), '.icoa', 'learn-cache');
|
|
76
|
+
const CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days — content rarely changes
|
|
77
|
+
// Learn is token-only (no login/join) and ALL learn tokens + curricula live on
|
|
78
|
+
// the canonical learn server (practice). Every learn call below is pinned to
|
|
79
|
+
// LEARN_SERVER and deliberately ignores cfg.ctfdUrl — a learner who joined a
|
|
80
|
+
// competition box (mel/au.icoa2026.au, which has no learn data) must still learn.
|
|
81
|
+
// Token access stays server-authoritative: practice validates the token; no
|
|
82
|
+
// valid token → no curriculum_id → no access. Pinning the host changes WHO we
|
|
83
|
+
// ask, never WHETHER the token is checked.
|
|
84
|
+
const LEARN_SERVER = 'https://practice.icoa2026.au';
|
|
85
|
+
// Curriculum bodies are large (360-tier ≈ 2.8 MB). The target cohort spans
|
|
86
|
+
// low-bandwidth nations; a 10 s cap aborted the download mid-stream on slow
|
|
87
|
+
// links (≈2.3 Mbps needed for 2.8 MB/10 s) → null → "Failed to fetch
|
|
88
|
+
// curriculum". 60 s covers ~50 KB/s links. An unreachable host still fails
|
|
89
|
+
// fast at connect; this only extends the budget for slow-but-live downloads.
|
|
90
|
+
const CURRICULUM_FETCH_TIMEOUT_MS = 60000;
|
|
91
|
+
function cachePath(id) {
|
|
92
|
+
// Sanitize id for filename — only allow [A-Za-z0-9_-]
|
|
93
|
+
const safe = id.replace(/[^A-Za-z0-9_-]/g, '_');
|
|
94
|
+
return join(CACHE_DIR, `${safe}.json`);
|
|
95
|
+
}
|
|
96
|
+
function etagPath(id) {
|
|
97
|
+
const safe = id.replace(/[^A-Za-z0-9_-]/g, '_');
|
|
98
|
+
return join(CACHE_DIR, `${safe}.etag`);
|
|
99
|
+
}
|
|
100
|
+
function readCache(id) {
|
|
101
|
+
const p = cachePath(id);
|
|
102
|
+
if (!existsSync(p))
|
|
103
|
+
return null;
|
|
104
|
+
try {
|
|
105
|
+
const stat = statSync(p);
|
|
106
|
+
const ageMs = Date.now() - stat.mtimeMs;
|
|
107
|
+
const curriculum = JSON.parse(readFileSync(p, 'utf-8'));
|
|
108
|
+
return { curriculum, ageMs };
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
function readEtag(id) {
|
|
115
|
+
const p = etagPath(id);
|
|
116
|
+
if (!existsSync(p))
|
|
117
|
+
return null;
|
|
118
|
+
try {
|
|
119
|
+
return readFileSync(p, 'utf-8').trim() || null;
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
function writeCache(id, curriculum, etag) {
|
|
126
|
+
try {
|
|
127
|
+
mkdirSync(CACHE_DIR, { recursive: true });
|
|
128
|
+
writeFileSync(cachePath(id), JSON.stringify(curriculum));
|
|
129
|
+
if (etag)
|
|
130
|
+
writeFileSync(etagPath(id), etag);
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
// Cache write failure is non-fatal; we just refetch next time.
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
function touchCache(id) {
|
|
137
|
+
// After a 304, mark the cached file fresh so the TTL window resets.
|
|
138
|
+
try {
|
|
139
|
+
const p = cachePath(id);
|
|
140
|
+
if (existsSync(p)) {
|
|
141
|
+
const now = Date.now() / 1000;
|
|
142
|
+
utimesSync(p, now, now);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
// non-fatal
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
async function fetchCurriculum(id) {
|
|
150
|
+
// Always the canonical learn server — never cfg.ctfdUrl. See LEARN_SERVER note.
|
|
151
|
+
const url = LEARN_SERVER.replace(/\/$/, '') + '/api/icoa/learn/curriculum/' + encodeURIComponent(id);
|
|
152
|
+
const headers = {};
|
|
153
|
+
const prevEtag = readEtag(id);
|
|
154
|
+
if (prevEtag)
|
|
155
|
+
headers['If-None-Match'] = prevEtag;
|
|
156
|
+
try {
|
|
157
|
+
const r = await fetch(url, {
|
|
158
|
+
headers,
|
|
159
|
+
signal: AbortSignal.timeout(CURRICULUM_FETCH_TIMEOUT_MS),
|
|
160
|
+
});
|
|
161
|
+
if (r.status === 304)
|
|
162
|
+
return { kind: 'unchanged' };
|
|
163
|
+
if (!r.ok)
|
|
164
|
+
return { kind: 'error' };
|
|
165
|
+
const j = (await r.json());
|
|
166
|
+
if (!j.success || !j.data)
|
|
167
|
+
return { kind: 'error' };
|
|
168
|
+
// Reject a JSON-valid but non-curriculum payload (e.g. a non-learn token
|
|
169
|
+
// that the server accepted) so it never gets cached or rendered.
|
|
170
|
+
if (!isValidCurriculum(j.data))
|
|
171
|
+
return { kind: 'error' };
|
|
172
|
+
const newEtag = r.headers.get('etag') || r.headers.get('ETag') || undefined;
|
|
173
|
+
return { kind: 'fresh', curriculum: j.data, etag: newEtag || undefined };
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
return { kind: 'error' };
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Resolves a curriculum by id. Order:
|
|
181
|
+
* 1. Fresh cache (< 7 days) → instant return, no network
|
|
182
|
+
* 2. Server fetch + cache write → if reachable; uses If-None-Match
|
|
183
|
+
* 304 Not Modified → cache content unchanged, just refresh mtime
|
|
184
|
+
* 200 OK → rewrite cache + etag
|
|
185
|
+
* 3. Stale cache as fallback → if server unreachable but cache exists
|
|
186
|
+
* 4. null → no cache, no server
|
|
187
|
+
*/
|
|
188
|
+
export async function loadCurriculumById(id) {
|
|
189
|
+
// Backward-compat alias: ctf4eai-12 maps to LEARNDEMO01 on the server.
|
|
190
|
+
if (id === 'ctf4eai-12')
|
|
191
|
+
id = 'LEARNDEMO01';
|
|
192
|
+
// 1. Fresh cache — only if it is actually a curriculum (a past bad write or
|
|
193
|
+
// a non-learn token's payload must never be returned as one).
|
|
194
|
+
const cached = readCache(id);
|
|
195
|
+
if (cached && cached.ageMs < CACHE_TTL_MS && isValidCurriculum(cached.curriculum)) {
|
|
196
|
+
return cached.curriculum;
|
|
197
|
+
}
|
|
198
|
+
// 2. Server fetch (fetchCurriculum already shape-checks before 'fresh')
|
|
199
|
+
const result = await fetchCurriculum(id);
|
|
200
|
+
if (result.kind === 'fresh') {
|
|
201
|
+
writeCache(id, result.curriculum, result.etag);
|
|
202
|
+
return result.curriculum;
|
|
203
|
+
}
|
|
204
|
+
if (result.kind === 'unchanged' && cached && isValidCurriculum(cached.curriculum)) {
|
|
205
|
+
// Server says content is the same as what we have. Treat as fresh.
|
|
206
|
+
touchCache(id);
|
|
207
|
+
return cached.curriculum;
|
|
208
|
+
}
|
|
209
|
+
// 3. Stale cache fallback
|
|
210
|
+
if (cached && isValidCurriculum(cached.curriculum)) {
|
|
211
|
+
return cached.curriculum;
|
|
212
|
+
}
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
export async function validateEAToken(token) {
|
|
216
|
+
// Always validates against the canonical learn server — never cfg.ctfdUrl.
|
|
217
|
+
// A learn token is registered on practice; a competition box returns
|
|
218
|
+
// "Not found". See the LEARN_SERVER note for why this is safe.
|
|
219
|
+
const url = LEARN_SERVER.replace(/\/$/, '') + '/api/icoa/learn/validate';
|
|
220
|
+
try {
|
|
221
|
+
const r = await fetch(url, {
|
|
222
|
+
method: 'POST',
|
|
223
|
+
headers: { 'Content-Type': 'application/json' },
|
|
224
|
+
body: JSON.stringify({ token: token.toUpperCase() }),
|
|
225
|
+
signal: AbortSignal.timeout(8000),
|
|
226
|
+
});
|
|
227
|
+
if (!r.ok) {
|
|
228
|
+
const err = (await r.json().catch(() => ({})));
|
|
229
|
+
return { ok: false, message: err.message || `HTTP ${r.status}` };
|
|
230
|
+
}
|
|
231
|
+
const data = (await r.json());
|
|
232
|
+
if (!data.success || !data.data) {
|
|
233
|
+
return { ok: false, message: data.message || 'Validation failed' };
|
|
234
|
+
}
|
|
235
|
+
return {
|
|
236
|
+
ok: true,
|
|
237
|
+
curriculumId: data.data.curriculum_id,
|
|
238
|
+
status: data.data.status,
|
|
239
|
+
validUntil: data.data.valid_until,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
catch (e) {
|
|
243
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
244
|
+
return { ok: false, message: `Network error: ${msg}` };
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Best-effort progress sync to server. Silent failure — local state is
|
|
249
|
+
* always authoritative; server is for cross-device persistence + analytics.
|
|
250
|
+
*/
|
|
251
|
+
export async function syncProgress(token, event) {
|
|
252
|
+
if (token.toUpperCase() === 'LEARNDEMO01')
|
|
253
|
+
return; // demo is local-only
|
|
254
|
+
// Progress is recorded where the token lives — the canonical learn server.
|
|
255
|
+
const url = LEARN_SERVER.replace(/\/$/, '') + '/api/icoa/learn/progress/' + token.toUpperCase();
|
|
256
|
+
try {
|
|
257
|
+
await fetch(url, {
|
|
258
|
+
method: 'POST',
|
|
259
|
+
headers: { 'Content-Type': 'application/json' },
|
|
260
|
+
body: JSON.stringify({
|
|
261
|
+
card_number: event.card_number,
|
|
262
|
+
event_type: event.event_type,
|
|
263
|
+
mcq_answer: event.mcq_answer,
|
|
264
|
+
mcq_correct: event.mcq_correct ? 1 : 0,
|
|
265
|
+
check_answer: event.check_answer,
|
|
266
|
+
check_correct: event.check_correct ? 1 : 0,
|
|
267
|
+
time_on_card_ms: event.time_on_card_ms,
|
|
268
|
+
}),
|
|
269
|
+
signal: AbortSignal.timeout(5000),
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
catch {
|
|
273
|
+
// Silent — local state still saved
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Best-effort card-feedback submission (the `e` "report a problem" channel).
|
|
278
|
+
* Crowdsourced card QA — a learner flags an error/confusion on a card; we
|
|
279
|
+
* record it server-side bound to (curriculum_id, card_number) so the author
|
|
280
|
+
* team can triage. Fire-and-forget, same contract as syncProgress.
|
|
281
|
+
*
|
|
282
|
+
* Unlike syncProgress, demo (LEARNDEMO01) feedback IS sent — the demo is the
|
|
283
|
+
* highest-traffic curriculum, so its cards benefit most from crowdsourced QA.
|
|
284
|
+
* The server records feedback for any token-shaped string (no learn_tokens
|
|
285
|
+
* membership required; feedback is low-risk user text).
|
|
286
|
+
*
|
|
287
|
+
* Card identity is the (curriculum_id, card_number) composite — card numbers
|
|
288
|
+
* are curriculum-LOCAL, so curriculum_id is required to disambiguate.
|
|
289
|
+
*/
|
|
290
|
+
export async function syncCardFeedback(token, payload) {
|
|
291
|
+
// Feedback is recorded on the canonical learn server alongside the curriculum.
|
|
292
|
+
const url = LEARN_SERVER.replace(/\/$/, '') + '/api/icoa/learn/feedback/' + token.toUpperCase();
|
|
293
|
+
try {
|
|
294
|
+
await fetch(url, {
|
|
295
|
+
method: 'POST',
|
|
296
|
+
headers: { 'Content-Type': 'application/json' },
|
|
297
|
+
body: JSON.stringify({
|
|
298
|
+
curriculum_id: payload.curriculum_id,
|
|
299
|
+
card_number: payload.card_number,
|
|
300
|
+
feedback_text: payload.feedback_text,
|
|
301
|
+
time_on_card_ms: payload.time_on_card_ms,
|
|
302
|
+
}),
|
|
303
|
+
signal: AbortSignal.timeout(5000),
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
catch {
|
|
307
|
+
// Silent — feedback is best-effort
|
|
308
|
+
}
|
|
309
|
+
}
|
package/dist/lib/learn-i18n.js
CHANGED
|
@@ -1 +1,184 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Chrome (UI scaffolding) i18n for learn mode.
|
|
3
|
+
*
|
|
4
|
+
* Card *content* localisation lives on each card via the `_zh` field on
|
|
5
|
+
* Card types (see learn-curricula.ts). This file is for the rendered
|
|
6
|
+
* frame around the content: welcome screen labels, prompts, dashboards.
|
|
7
|
+
*/
|
|
8
|
+
const EN = {
|
|
9
|
+
// Welcome screen
|
|
10
|
+
academy_title: 'ICOA Embodied AI Security Academy',
|
|
11
|
+
welcome_new_demo: 'Welcome — this is the free demo.',
|
|
12
|
+
welcome_new: 'Welcome — your curriculum is ready.',
|
|
13
|
+
welcome_back: 'Welcome back — last seen',
|
|
14
|
+
just_now: 'just now',
|
|
15
|
+
ago_hours: 'h ago',
|
|
16
|
+
ago_days: 'd ago',
|
|
17
|
+
module: 'Module:',
|
|
18
|
+
progress: 'Progress:',
|
|
19
|
+
streak: 'Streak:',
|
|
20
|
+
next_card: 'Next card:',
|
|
21
|
+
of: 'of',
|
|
22
|
+
continue_start: 'start the curriculum',
|
|
23
|
+
continue_resume: 'resume at card',
|
|
24
|
+
status_label: 'continue',
|
|
25
|
+
status_desc: ' ',
|
|
26
|
+
status_full: 'full progress dashboard',
|
|
27
|
+
bookmarks_label: 'bookmarks',
|
|
28
|
+
bookmarks_desc: 'cards bookmarked for review',
|
|
29
|
+
quit_label: 'quit',
|
|
30
|
+
quit_desc: 'exit learn mode',
|
|
31
|
+
// Card chrome
|
|
32
|
+
card: 'Card',
|
|
33
|
+
unknown_module: 'Unknown',
|
|
34
|
+
press_ok_continue: 'Press',
|
|
35
|
+
to_continue: 'to continue',
|
|
36
|
+
ok: 'ok',
|
|
37
|
+
next: 'next',
|
|
38
|
+
bookmark: 'bookmark',
|
|
39
|
+
bookmark_desc: 'mark for later review',
|
|
40
|
+
back: 'back',
|
|
41
|
+
back_desc: 'previous card',
|
|
42
|
+
continue_to_next: 'continue to next card',
|
|
43
|
+
icoa_connection: 'ICOA connection',
|
|
44
|
+
// MCQ
|
|
45
|
+
type_to_answer: 'to answer',
|
|
46
|
+
correct: '✓ Correct!',
|
|
47
|
+
one_point: '+1 point',
|
|
48
|
+
not_quite: '✗ Not quite.',
|
|
49
|
+
you_chose: 'You chose',
|
|
50
|
+
answer_is: 'the answer is',
|
|
51
|
+
explanation: 'Explanation:',
|
|
52
|
+
mcq_accuracy_so_far: 'MCQ accuracy so far:',
|
|
53
|
+
// Practical
|
|
54
|
+
try_in_sandbox: 'Try it in the sandbox:',
|
|
55
|
+
drops_into_python: '(drops you into Python REPL)',
|
|
56
|
+
when_done: "When you're done:",
|
|
57
|
+
done_label: 'done',
|
|
58
|
+
done_desc: 'I figured it out — show me the answer',
|
|
59
|
+
skip_label: 'skip',
|
|
60
|
+
skip_desc: 'skip (counts as incomplete)',
|
|
61
|
+
practical_recorded: '✓ Practical recorded',
|
|
62
|
+
reference_answer: 'Reference answer:',
|
|
63
|
+
starter_code: 'Starter code (copy + edit in your editor or sandbox):',
|
|
64
|
+
// sim_demo
|
|
65
|
+
sim_launch_label: 'sim',
|
|
66
|
+
sim_launch_desc: 'launch MuJoCo viewer, watch Franka arm',
|
|
67
|
+
sim_play_action: 'play out action:',
|
|
68
|
+
sim_skip_desc: 'skip simulation, continue to next card',
|
|
69
|
+
sim_requires: '(Requires: pip install mujoco; or use icoa/sandbox-vla)',
|
|
70
|
+
// Milestone
|
|
71
|
+
milestone_header: '✦ ✦ ✦ MILESTONE ✦ ✦ ✦',
|
|
72
|
+
in_wild_corresponds: 'In the wild, this level corresponds to:',
|
|
73
|
+
whats_next: "What's next:",
|
|
74
|
+
demo_complete: 'Demo complete! 🎉',
|
|
75
|
+
unlock_full: 'To unlock the full',
|
|
76
|
+
curriculum_name: 'n=480 PhD-entry curriculum',
|
|
77
|
+
contact_team_leader: "contact your country's team leader to request an",
|
|
78
|
+
ea_token: 'EA',
|
|
79
|
+
learn_token: 'learn token,',
|
|
80
|
+
or_email: 'or email',
|
|
81
|
+
for_partnership: 'for ICOA partnership.',
|
|
82
|
+
type_quit: 'Type',
|
|
83
|
+
to_exit: 'to exit, or',
|
|
84
|
+
for_dashboard: 'for the dashboard.',
|
|
85
|
+
// Status dashboard
|
|
86
|
+
status_title: 'ICOA Embodied AI Security — Status',
|
|
87
|
+
total_progress: 'Total progress:',
|
|
88
|
+
longest: 'longest:',
|
|
89
|
+
mcq_accuracy: 'MCQ accuracy:',
|
|
90
|
+
practicals_done: 'Practicals done:',
|
|
91
|
+
bookmarked: 'Bookmarked:',
|
|
92
|
+
achievements: 'Achievements:',
|
|
93
|
+
achievements_none: 'Achievements: none yet — push to the first milestone!',
|
|
94
|
+
};
|
|
95
|
+
const ZH = {
|
|
96
|
+
academy_title: 'ICOA 具身智能安全学院',
|
|
97
|
+
welcome_new_demo: '欢迎 —— 这是免费 demo。',
|
|
98
|
+
welcome_new: '欢迎 —— 你的课程已就绪。',
|
|
99
|
+
welcome_back: '欢迎回来 —— 上次访问',
|
|
100
|
+
just_now: '刚才',
|
|
101
|
+
ago_hours: '小时前',
|
|
102
|
+
ago_days: '天前',
|
|
103
|
+
module: '模块:',
|
|
104
|
+
progress: '进度:',
|
|
105
|
+
streak: '连续天数:',
|
|
106
|
+
next_card: '下一张卡:',
|
|
107
|
+
of: '/',
|
|
108
|
+
continue_start: '开始课程',
|
|
109
|
+
continue_resume: '继续到卡',
|
|
110
|
+
status_label: 'continue',
|
|
111
|
+
status_desc: ' ',
|
|
112
|
+
status_full: '完整进度面板',
|
|
113
|
+
bookmarks_label: 'bookmarks',
|
|
114
|
+
bookmarks_desc: '张卡标记待复习',
|
|
115
|
+
quit_label: 'quit',
|
|
116
|
+
quit_desc: '退出学习模式',
|
|
117
|
+
// Card chrome
|
|
118
|
+
card: '卡片',
|
|
119
|
+
unknown_module: '未知',
|
|
120
|
+
press_ok_continue: '按',
|
|
121
|
+
to_continue: '继续',
|
|
122
|
+
ok: 'ok',
|
|
123
|
+
next: 'next',
|
|
124
|
+
bookmark: 'bookmark',
|
|
125
|
+
bookmark_desc: '标记本卡待复习',
|
|
126
|
+
back: 'back',
|
|
127
|
+
back_desc: '回到上一张卡',
|
|
128
|
+
continue_to_next: '进入下一张卡',
|
|
129
|
+
icoa_connection: 'ICOA 关联',
|
|
130
|
+
// MCQ
|
|
131
|
+
type_to_answer: '作答',
|
|
132
|
+
correct: '✓ 正确!',
|
|
133
|
+
one_point: '+1 分',
|
|
134
|
+
not_quite: '✗ 差一点。',
|
|
135
|
+
you_chose: '你选了',
|
|
136
|
+
answer_is: '正确答案是',
|
|
137
|
+
explanation: '解析:',
|
|
138
|
+
mcq_accuracy_so_far: '目前选择题准确率:',
|
|
139
|
+
// Practical
|
|
140
|
+
try_in_sandbox: '在沙盒里试一下:',
|
|
141
|
+
drops_into_python: '(进入 Python REPL)',
|
|
142
|
+
when_done: '搞定后:',
|
|
143
|
+
done_label: 'done',
|
|
144
|
+
done_desc: '我想出来了 —— 给我看答案',
|
|
145
|
+
skip_label: 'skip',
|
|
146
|
+
skip_desc: '跳过 (计为未完成)',
|
|
147
|
+
practical_recorded: '✓ 实操已记录',
|
|
148
|
+
reference_answer: '参考答案:',
|
|
149
|
+
starter_code: '起始代码 (复制到你的编辑器或沙盒里改):',
|
|
150
|
+
// sim_demo
|
|
151
|
+
sim_launch_label: 'sim',
|
|
152
|
+
sim_launch_desc: '打开 MuJoCo,观察 Franka 机械臂',
|
|
153
|
+
sim_play_action: '执行动作:',
|
|
154
|
+
sim_skip_desc: '跳过仿真,进入下一张卡',
|
|
155
|
+
sim_requires: '(需要: pip install mujoco; 或用 icoa/sandbox-vla)',
|
|
156
|
+
// Milestone
|
|
157
|
+
milestone_header: '✦ ✦ ✦ 里程碑 ✦ ✦ ✦',
|
|
158
|
+
in_wild_corresponds: '在真实世界,这个水平对应:',
|
|
159
|
+
whats_next: '接下来:',
|
|
160
|
+
demo_complete: 'Demo 完成! 🎉',
|
|
161
|
+
unlock_full: '想解锁完整的',
|
|
162
|
+
curriculum_name: 'n=480 博士级先锋课程',
|
|
163
|
+
contact_team_leader: '联系你所在国家的领队申请',
|
|
164
|
+
ea_token: 'EA',
|
|
165
|
+
learn_token: 'learn token,',
|
|
166
|
+
or_email: '或邮件至',
|
|
167
|
+
for_partnership: '洽谈 ICOA 合作。',
|
|
168
|
+
type_quit: '输入',
|
|
169
|
+
to_exit: '退出,或',
|
|
170
|
+
for_dashboard: '查看面板。',
|
|
171
|
+
// Status dashboard
|
|
172
|
+
status_title: 'ICOA 具身智能安全 —— 状态',
|
|
173
|
+
total_progress: '总进度:',
|
|
174
|
+
longest: '最长:',
|
|
175
|
+
mcq_accuracy: '选择题准确率:',
|
|
176
|
+
practicals_done: '已完成实操:',
|
|
177
|
+
bookmarked: '已收藏:',
|
|
178
|
+
achievements: '成就:',
|
|
179
|
+
achievements_none: '成就: 还没有 —— 冲到第一个里程碑!',
|
|
180
|
+
};
|
|
181
|
+
export function t(key, lang) {
|
|
182
|
+
const pack = lang.startsWith('zh') ? ZH : EN;
|
|
183
|
+
return pack[key] ?? EN[key] ?? key;
|
|
184
|
+
}
|