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.
Files changed (48) hide show
  1. package/dist/commands/ai4ctf.js +1 -1
  2. package/dist/commands/arena-eai.js +1 -1
  3. package/dist/commands/connect.js +1 -1
  4. package/dist/commands/ctf.js +1 -1
  5. package/dist/commands/ctf4ai-demo.js +1 -1
  6. package/dist/commands/ctf4vla.js +1 -1
  7. package/dist/commands/exam.js +1 -1
  8. package/dist/index.js +355 -1
  9. package/dist/lib/access.js +184 -1
  10. package/dist/lib/arena-submit.js +21 -1
  11. package/dist/lib/budget.js +6 -1
  12. package/dist/lib/challenge-dir.js +16 -1
  13. package/dist/lib/comms.js +212 -1
  14. package/dist/lib/config.js +93 -1
  15. package/dist/lib/country-lang.js +39 -1
  16. package/dist/lib/demo-exam.js +478 -1
  17. package/dist/lib/demo-flags.js +27 -1
  18. package/dist/lib/demo-stats.js +62 -1
  19. package/dist/lib/demo2-progress.js +102 -1
  20. package/dist/lib/exam-client.js +54 -1
  21. package/dist/lib/exam-state.js +273 -1
  22. package/dist/lib/gemini.js +247 -1
  23. package/dist/lib/integrity-snapshot.js +88 -1
  24. package/dist/lib/interactive-spawn.js +55 -1
  25. package/dist/lib/ipynb-input.js +65 -1
  26. package/dist/lib/kernel-protocol.js +88 -1
  27. package/dist/lib/kernel.js +146 -2
  28. package/dist/lib/learn-curricula.js +309 -1
  29. package/dist/lib/learn-i18n.js +184 -1
  30. package/dist/lib/log-sync.js +155 -1
  31. package/dist/lib/logger.js +49 -1
  32. package/dist/lib/open-file.js +55 -1
  33. package/dist/lib/paper-upgrade.js +119 -1
  34. package/dist/lib/render-card.js +112 -1
  35. package/dist/lib/repl-asker.js +67 -1
  36. package/dist/lib/sample-runner.js +227 -1
  37. package/dist/lib/shell-split.js +69 -1
  38. package/dist/lib/sim-cooldown.js +75 -1
  39. package/dist/lib/theme.js +119 -1
  40. package/dist/lib/token-format.js +74 -1
  41. package/dist/lib/toolset-hash.js +48 -1
  42. package/dist/lib/translations-fetcher.js +95 -1
  43. package/dist/lib/ui.js +99 -1
  44. package/dist/lib/version.js +24 -1
  45. package/dist/postinstall.js +48 -1
  46. package/dist/repl.js +2251 -1
  47. package/dist/types/index.js +63 -1
  48. package/package.json +1 -1
@@ -1,4 +1,28 @@
1
- import{spawn as e}from"node:child_process";import{writeFileSync as t}from"node:fs";import{join as i}from"node:path";import{foldCellOutputs as n,parseKernelEvent as r}from"./kernel-protocol.js";export const KERNEL_BRIDGE_PY=String.raw`import sys, os, json, queue
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
- `;export class NotebookKernel{proc=null;buf="";nextId=1;ready=!1;pending=null;onReady=null;onExit=null;opts;bridgePath;constructor(e){this.opts=e,this.bridgePath=i(e.venvRoot,"icoa-kernel-bridge.py")}start(){this.buf="",this.ready=!1,this.pending=null,this.nextId=1,t(this.bridgePath,KERNEL_BRIDGE_PY);const i=e(this.opts.venvPython,[this.bridgePath],{stdio:["pipe","pipe","pipe"]});this.proc=i,i.stdout.setEncoding("utf-8"),i.stdout.on("data",e=>this.onStdout(e)),i.on("exit",()=>{this.ready=!1,this.onExit?.()});const n=this.opts.readyTimeoutMs??6e4;return new Promise((e,t)=>{const i=setTimeout(()=>t(new Error("kernel did not become ready in time")),n);this.onReady=()=>{clearTimeout(i),e()},this.onExit=()=>{clearTimeout(i),this.ready||t(new Error("kernel process exited before ready"))}})}execute(e){if(!this.proc||!this.ready)return Promise.reject(new Error("kernel not ready"));if(this.pending)return Promise.reject(new Error("a cell is already executing"));const t=this.nextId++;return new Promise(i=>{this.pending={id:t,events:[],resolve:i},this.proc?.stdin.write(`${JSON.stringify({id:t,code:e})}\n`)})}shutdown(){const e=this.proc;return e?new Promise(t=>{this.onExit=()=>t();try{e.stdin.write(`${JSON.stringify({cmd:"shutdown"})}\n`)}catch{e.kill()}setTimeout(()=>{this.proc&&(this.proc.kill(),t())},5e3)}):Promise.resolve()}onStdout(e){this.buf+=e;let t=this.buf.indexOf("\n");for(;-1!==t;){const e=this.buf.slice(0,t);this.buf=this.buf.slice(t+1),this.handleEvent(r(e)),t=this.buf.indexOf("\n")}}handleEvent(e){if(!e)return;if("ready"===e.type)return this.ready=!0,void this.onReady?.();const t=this.pending;if(t){if("done"===e.type){if(e.id!==t.id)return;const i=n(t.events.concat(e));return this.pending=null,void t.resolve(i)}t.events.push(e)}}}
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
- export function localized(r,t){if(!t.startsWith("zh")||!r._zh)return r;const e=r._zh,c={...r};for(const r of Object.keys(e))void 0!==e[r]&&(c[r]=e[r]);return c}export function isValidCurriculum(r){if(!r||"object"!=typeof r)return!1;const t=r;return"string"==typeof t.id&&"number"==typeof t.totalCards&&Array.isArray(t.cards)&&Array.isArray(t.modules)}import{readFileSync as r,writeFileSync as t,mkdirSync as e,existsSync as c,statSync as n,utimesSync as a}from"node:fs";import{join as i}from"node:path";import{homedir as o}from"node:os";const u=i(o(),".icoa","learn-cache"),s="https://practice.icoa2026.au";function l(r){const t=r.replace(/[^A-Za-z0-9_-]/g,"_");return i(u,`${t}.json`)}function d(r){const t=r.replace(/[^A-Za-z0-9_-]/g,"_");return i(u,`${t}.etag`)}export async function loadCurriculumById(i){"ctf4eai-12"===i&&(i="LEARNDEMO01");const o=function(t){const e=l(t);if(!c(e))return null;try{const t=n(e),c=Date.now()-t.mtimeMs;return{curriculum:JSON.parse(r(e,"utf-8")),ageMs:c}}catch{return null}}(i);if(o&&o.ageMs<6048e5&&isValidCurriculum(o.curriculum))return o.curriculum;const m=await async function(t){const e=s.replace(/\/$/,"")+"/api/icoa/learn/curriculum/"+encodeURIComponent(t),n={},a=function(t){const e=d(t);if(!c(e))return null;try{return r(e,"utf-8").trim()||null}catch{return null}}(t);a&&(n["If-None-Match"]=a);try{const r=await fetch(e,{headers:n,signal:AbortSignal.timeout(6e4)});if(304===r.status)return{kind:"unchanged"};if(!r.ok)return{kind:"error"};const t=await r.json();if(!t.success||!t.data)return{kind:"error"};if(!isValidCurriculum(t.data))return{kind:"error"};const c=r.headers.get("etag")||r.headers.get("ETag")||void 0;return{kind:"fresh",curriculum:t.data,etag:c||void 0}}catch{return{kind:"error"}}}(i);return"fresh"===m.kind?(function(r,c,n){try{e(u,{recursive:!0}),t(l(r),JSON.stringify(c)),n&&t(d(r),n)}catch{}}(i,m.curriculum,m.etag),m.curriculum):"unchanged"===m.kind&&o&&isValidCurriculum(o.curriculum)?(function(r){try{const t=l(r);if(c(t)){const r=Date.now()/1e3;a(t,r,r)}}catch{}}(i),o.curriculum):o&&isValidCurriculum(o.curriculum)?o.curriculum:null}export async function validateEAToken(r){const t=s.replace(/\/$/,"")+"/api/icoa/learn/validate";try{const e=await fetch(t,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({token:r.toUpperCase()}),signal:AbortSignal.timeout(8e3)});if(!e.ok)return{ok:!1,message:(await e.json().catch(()=>({}))).message||`HTTP ${e.status}`};const c=await e.json();return c.success&&c.data?{ok:!0,curriculumId:c.data.curriculum_id,status:c.data.status,validUntil:c.data.valid_until}:{ok:!1,message:c.message||"Validation failed"}}catch(r){return{ok:!1,message:`Network error: ${r instanceof Error?r.message:String(r)}`}}}export async function syncProgress(r,t){if("LEARNDEMO01"===r.toUpperCase())return;const e=s.replace(/\/$/,"")+"/api/icoa/learn/progress/"+r.toUpperCase();try{await fetch(e,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({card_number:t.card_number,event_type:t.event_type,mcq_answer:t.mcq_answer,mcq_correct:t.mcq_correct?1:0,check_answer:t.check_answer,check_correct:t.check_correct?1:0,time_on_card_ms:t.time_on_card_ms}),signal:AbortSignal.timeout(5e3)})}catch{}}export async function syncCardFeedback(r,t){const e=s.replace(/\/$/,"")+"/api/icoa/learn/feedback/"+r.toUpperCase();try{await fetch(e,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({curriculum_id:t.curriculum_id,card_number:t.card_number,feedback_text:t.feedback_text,time_on_card_ms:t.time_on_card_ms}),signal:AbortSignal.timeout(5e3)})}catch{}}
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
+ }
@@ -1 +1,184 @@
1
- const e={academy_title:"ICOA Embodied AI Security Academy",welcome_new_demo:"Welcome — this is the free demo.",welcome_new:"Welcome — your curriculum is ready.",welcome_back:"Welcome back — last seen",just_now:"just now",ago_hours:"h ago",ago_days:"d ago",module:"Module:",progress:"Progress:",streak:"Streak:",next_card:"Next card:",of:"of",continue_start:"start the curriculum",continue_resume:"resume at card",status_label:"continue",status_desc:" ",status_full:"full progress dashboard",bookmarks_label:"bookmarks",bookmarks_desc:"cards bookmarked for review",quit_label:"quit",quit_desc:"exit learn mode",card:"Card",unknown_module:"Unknown",press_ok_continue:"Press",to_continue:"to continue",ok:"ok",next:"next",bookmark:"bookmark",bookmark_desc:"mark for later review",back:"back",back_desc:"previous card",continue_to_next:"continue to next card",icoa_connection:"ICOA connection",type_to_answer:"to answer",correct:"✓ Correct!",one_point:"+1 point",not_quite:"✗ Not quite.",you_chose:"You chose",answer_is:"the answer is",explanation:"Explanation:",mcq_accuracy_so_far:"MCQ accuracy so far:",try_in_sandbox:"Try it in the sandbox:",drops_into_python:"(drops you into Python REPL)",when_done:"When you're done:",done_label:"done",done_desc:"I figured it out — show me the answer",skip_label:"skip",skip_desc:"skip (counts as incomplete)",practical_recorded:"✓ Practical recorded",reference_answer:"Reference answer:",starter_code:"Starter code (copy + edit in your editor or sandbox):",sim_launch_label:"sim",sim_launch_desc:"launch MuJoCo viewer, watch Franka arm",sim_play_action:"play out action:",sim_skip_desc:"skip simulation, continue to next card",sim_requires:"(Requires: pip install mujoco; or use icoa/sandbox-vla)",milestone_header:"✦ ✦ ✦ MILESTONE ✦ ✦ ✦",in_wild_corresponds:"In the wild, this level corresponds to:",whats_next:"What's next:",demo_complete:"Demo complete! 🎉",unlock_full:"To unlock the full",curriculum_name:"n=480 PhD-entry curriculum",contact_team_leader:"contact your country's team leader to request an",ea_token:"EA",learn_token:"learn token,",or_email:"or email",for_partnership:"for ICOA partnership.",type_quit:"Type",to_exit:"to exit, or",for_dashboard:"for the dashboard.",status_title:"ICOA Embodied AI Security — Status",total_progress:"Total progress:",longest:"longest:",mcq_accuracy:"MCQ accuracy:",practicals_done:"Practicals done:",bookmarked:"Bookmarked:",achievements:"Achievements:",achievements_none:"Achievements: none yet — push to the first milestone!"},o={academy_title:"ICOA 具身智能安全学院",welcome_new_demo:"欢迎 —— 这是免费 demo。",welcome_new:"欢迎 —— 你的课程已就绪。",welcome_back:"欢迎回来 —— 上次访问",just_now:"刚才",ago_hours:"小时前",ago_days:"天前",module:"模块:",progress:"进度:",streak:"连续天数:",next_card:"下一张卡:",of:"/",continue_start:"开始课程",continue_resume:"继续到卡",status_label:"continue",status_desc:" ",status_full:"完整进度面板",bookmarks_label:"bookmarks",bookmarks_desc:"张卡标记待复习",quit_label:"quit",quit_desc:"退出学习模式",card:"卡片",unknown_module:"未知",press_ok_continue:"按",to_continue:"继续",ok:"ok",next:"next",bookmark:"bookmark",bookmark_desc:"标记本卡待复习",back:"back",back_desc:"回到上一张卡",continue_to_next:"进入下一张卡",icoa_connection:"ICOA 关联",type_to_answer:"作答",correct:"✓ 正确!",one_point:"+1 分",not_quite:"✗ 差一点。",you_chose:"你选了",answer_is:"正确答案是",explanation:"解析:",mcq_accuracy_so_far:"目前选择题准确率:",try_in_sandbox:"在沙盒里试一下:",drops_into_python:"(进入 Python REPL)",when_done:"搞定后:",done_label:"done",done_desc:"我想出来了 —— 给我看答案",skip_label:"skip",skip_desc:"跳过 (计为未完成)",practical_recorded:"✓ 实操已记录",reference_answer:"参考答案:",starter_code:"起始代码 (复制到你的编辑器或沙盒里改):",sim_launch_label:"sim",sim_launch_desc:"打开 MuJoCo,观察 Franka 机械臂",sim_play_action:"执行动作:",sim_skip_desc:"跳过仿真,进入下一张卡",sim_requires:"(需要: pip install mujoco; 或用 icoa/sandbox-vla)",milestone_header:"✦ ✦ ✦ 里程碑 ✦ ✦ ✦",in_wild_corresponds:"在真实世界,这个水平对应:",whats_next:"接下来:",demo_complete:"Demo 完成! 🎉",unlock_full:"想解锁完整的",curriculum_name:"n=480 博士级先锋课程",contact_team_leader:"联系你所在国家的领队申请",ea_token:"EA",learn_token:"learn token,",or_email:"或邮件至",for_partnership:"洽谈 ICOA 合作。",type_quit:"输入",to_exit:"退出,或",for_dashboard:"查看面板。",status_title:"ICOA 具身智能安全 —— 状态",total_progress:"总进度:",longest:"最长:",mcq_accuracy:"选择题准确率:",practicals_done:"已完成实操:",bookmarked:"已收藏:",achievements:"成就:",achievements_none:"成就: 还没有 —— 冲到第一个里程碑!"};export function t(a,n){return(n.startsWith("zh")?o:e)[a]??e[a]??a}
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
+ }