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 +1,102 @@
1
- import{mkdirSync as t,readFileSync as e,unlinkSync as r,writeFileSync as o}from"node:fs";import{join as n}from"node:path";import{homedir as a}from"node:os";const s=n(a(),".icoa","demo2-progress.json");export function loadDemo2Progress(){let t,r;try{t=e(s,"utf-8")}catch{return null}try{r=JSON.parse(t)}catch{return clearDemo2Progress(),null}if("number"!=typeof r.nextCardIndex||"number"!=typeof r.totalCards||"string"!=typeof r.lang||"number"!=typeof r.startedAt)return clearDemo2Progress(),null;const o=r.completedAt??r.startedAt;return"number"==typeof o&&Date.now()-o>6048e5?(clearDemo2Progress(),null):{nextCardIndex:r.nextCardIndex,totalCards:r.totalCards,lang:r.lang,startedAt:r.startedAt,completedAt:"number"==typeof r.completedAt?r.completedAt:void 0}}export function saveDemo2Progress(e){!function(){try{t(n(a(),".icoa"),{recursive:!0})}catch{}}();try{o(s,JSON.stringify(e))}catch{}}export function clearDemo2Progress(){try{r(s)}catch{}}export function markCardDone(t,e,r,o){saveDemo2Progress({nextCardIndex:t+1,totalCards:e,lang:r,startedAt:o})}export function markDemo2Complete(t,e,r){saveDemo2Progress({nextCardIndex:t,totalCards:t,lang:e,startedAt:r,completedAt:Date.now()})}
1
+ /**
2
+ * demo2 progress persistence — opt-in resume + replay hooks.
3
+ *
4
+ * State lives in `~/.icoa/demo2-progress.json`. Records the last card
5
+ * index the user finished plus a completedAt timestamp when they reach
6
+ * the outro. Auto-clears anything older than 7 days so re-runs after a
7
+ * gap feel like a fresh start (no manual cleanup needed — see
8
+ * feedback_no_manual_rm).
9
+ *
10
+ * Out of scope: scores, answers, language history. Those are run-scoped
11
+ * and re-initialised every call to runDemo2Once().
12
+ */
13
+ import { mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
14
+ import { join } from 'node:path';
15
+ import { homedir } from 'node:os';
16
+ const PROGRESS_FILE = join(homedir(), '.icoa', 'demo2-progress.json');
17
+ // Auto-expire stale partial progress so coming back next month re-greets
18
+ // the user fresh instead of dangling on card 5/10.
19
+ const STALE_AFTER_MS = 7 * 24 * 60 * 60 * 1000;
20
+ function ensureDir() {
21
+ try {
22
+ mkdirSync(join(homedir(), '.icoa'), { recursive: true });
23
+ }
24
+ catch {
25
+ // Non-fatal — caller's writeFileSync will surface the real failure
26
+ }
27
+ }
28
+ /**
29
+ * Load progress, auto-clearing anything stale. Returns null if no usable
30
+ * record exists.
31
+ */
32
+ export function loadDemo2Progress() {
33
+ let raw;
34
+ try {
35
+ raw = readFileSync(PROGRESS_FILE, 'utf-8');
36
+ }
37
+ catch {
38
+ return null;
39
+ }
40
+ let parsed;
41
+ try {
42
+ parsed = JSON.parse(raw);
43
+ }
44
+ catch {
45
+ clearDemo2Progress();
46
+ return null;
47
+ }
48
+ if (typeof parsed.nextCardIndex !== 'number' ||
49
+ typeof parsed.totalCards !== 'number' ||
50
+ typeof parsed.lang !== 'string' ||
51
+ typeof parsed.startedAt !== 'number') {
52
+ clearDemo2Progress();
53
+ return null;
54
+ }
55
+ const anchor = parsed.completedAt ?? parsed.startedAt;
56
+ if (typeof anchor === 'number' && Date.now() - anchor > STALE_AFTER_MS) {
57
+ clearDemo2Progress();
58
+ return null;
59
+ }
60
+ return {
61
+ nextCardIndex: parsed.nextCardIndex,
62
+ totalCards: parsed.totalCards,
63
+ lang: parsed.lang,
64
+ startedAt: parsed.startedAt,
65
+ completedAt: typeof parsed.completedAt === 'number' ? parsed.completedAt : undefined,
66
+ };
67
+ }
68
+ export function saveDemo2Progress(p) {
69
+ ensureDir();
70
+ try {
71
+ writeFileSync(PROGRESS_FILE, JSON.stringify(p));
72
+ }
73
+ catch {
74
+ // Non-fatal — progress is convenience, not correctness
75
+ }
76
+ }
77
+ export function clearDemo2Progress() {
78
+ try {
79
+ unlinkSync(PROGRESS_FILE);
80
+ }
81
+ catch {
82
+ // Already absent — fine
83
+ }
84
+ }
85
+ /** Convenience: record that the user finished card N (0-based). */
86
+ export function markCardDone(cardIndex, totalCards, lang, startedAt) {
87
+ saveDemo2Progress({
88
+ nextCardIndex: cardIndex + 1,
89
+ totalCards,
90
+ lang,
91
+ startedAt,
92
+ });
93
+ }
94
+ export function markDemo2Complete(totalCards, lang, startedAt) {
95
+ saveDemo2Progress({
96
+ nextCardIndex: totalCards,
97
+ totalCards,
98
+ lang,
99
+ startedAt,
100
+ completedAt: Date.now(),
101
+ });
102
+ }
@@ -1 +1,54 @@
1
- export class ExamClient{baseUrl;token;constructor(t,e){this.baseUrl=t.replace(/\/+$/,""),this.token=e}async request(t,e,r){const s=[`${this.baseUrl}/api/icoa/exams${e}`,`${this.baseUrl}:9090/api/icoa/exams${e}`];let a=null;for(const e of s)try{return await this._fetch(t,e,r)}catch(t){a=t}throw a||new Error("Exam API unreachable")}async _fetch(t,e,r){const s=await fetch(e,{method:t,headers:{Authorization:`Token ${this.token}`,"Content-Type":"application/json"},body:r?JSON.stringify(r):void 0,signal:AbortSignal.timeout(1e4)});if(!s.ok){const t=await s.text().catch(()=>"Unknown error");throw new Error(`Exam API error (${s.status}): ${t}`)}const a=await s.json();if(!1===a.success)throw new Error(a.message||"Exam API error");return a.data}async getExams(){return this.request("GET","")}async startExam(t){return this.request("POST",`/${t}/start`)}async submitExam(t,e){return this.request("POST",`/${t}/submit`,{answers:e})}async getResult(t){return this.request("GET",`/${t}/result`)}}
1
+ export class ExamClient {
2
+ baseUrl;
3
+ token;
4
+ constructor(baseUrl, token) {
5
+ this.baseUrl = baseUrl.replace(/\/+$/, '');
6
+ this.token = token;
7
+ }
8
+ async request(method, path, body) {
9
+ // Try nginx proxy first, fallback to direct port
10
+ const urls = [`${this.baseUrl}/api/icoa/exams${path}`, `${this.baseUrl}:9090/api/icoa/exams${path}`];
11
+ let lastError = null;
12
+ for (const url of urls) {
13
+ try {
14
+ return await this._fetch(method, url, body);
15
+ }
16
+ catch (e) {
17
+ lastError = e;
18
+ }
19
+ }
20
+ throw lastError || new Error('Exam API unreachable');
21
+ }
22
+ async _fetch(method, url, body) {
23
+ const res = await fetch(url, {
24
+ method,
25
+ headers: {
26
+ Authorization: `Token ${this.token}`,
27
+ 'Content-Type': 'application/json',
28
+ },
29
+ body: body ? JSON.stringify(body) : undefined,
30
+ signal: AbortSignal.timeout(10000),
31
+ });
32
+ if (!res.ok) {
33
+ const text = await res.text().catch(() => 'Unknown error');
34
+ throw new Error(`Exam API error (${res.status}): ${text}`);
35
+ }
36
+ const json = (await res.json());
37
+ if (json.success === false) {
38
+ throw new Error(json.message || 'Exam API error');
39
+ }
40
+ return json.data;
41
+ }
42
+ async getExams() {
43
+ return this.request('GET', '');
44
+ }
45
+ async startExam(examId) {
46
+ return this.request('POST', `/${examId}/start`);
47
+ }
48
+ async submitExam(examId, answers) {
49
+ return this.request('POST', `/${examId}/submit`, { answers });
50
+ }
51
+ async getResult(examId) {
52
+ return this.request('GET', `/${examId}/result`);
53
+ }
54
+ }
@@ -1 +1,273 @@
1
- import{readFileSync as t,writeFileSync as e,existsSync as n,unlinkSync as a,mkdirSync as r,chmodSync as o}from"node:fs";import{execFileSync as i}from"node:child_process";import{join as s}from"node:path";import{homedir as c}from"node:os";import{getIcoaDir as u}from"./config.js";export function practicalChatCommand(t,e){return e?"ctf4ai"===t?"ctf4ai":"ai4ctf"===t?"ai4ctf":"vla"===t?"ctf4eai":null:null}export function materializeAttachedData(t,a){if(!a)return null;let o;try{const i=getRealExamState(),u=i?.session?.workspaceDir,f=u&&n(u)?u:s(c(),"icoa-workspace");r(f,{recursive:!0}),o=s(f,`q${t}.txt`),e(o,a,"utf-8")}catch{return null}try{return i("docker",["cp",o,`icoa-sandbox:/home/competitor/challenges/q${t}.txt`],{stdio:"ignore",timeout:3e3}),`challenges/q${t}.txt`}catch{return o}}function f(){return s(u(),"exam-state.json")}function l(){return s(u(),"demo-state.json")}function m(t){return"demo-free"===t?l():f()}export function getRealExamState(){const e=f();if(!n(e))return null;try{const n=JSON.parse(t(e,"utf-8"));if("demo-free"===n?.session?.examId){try{a(e)}catch{}return null}return n}catch{return null}}export function getDemoState(){const e=l();if(!n(e))return null;try{return JSON.parse(t(e,"utf-8"))}catch{return null}}export function getExamState(){return getRealExamState()||getDemoState()}export function saveExamState(t){const n=m(t.session.examId);e(n,JSON.stringify(t,null,2));try{o(n,384)}catch{}}export function clearExamState(t){if(t){const e=m(t);n(e)&&a(e)}else{const t=getExamState();if(t){const e=m(t.session.examId);n(e)&&a(e)}}}function d(t){if(!t.session.durationMinutes)return null;const{deadlineServerMs:e,clockOffsetMs:n}=t.session;if("number"==typeof e&&"number"==typeof n)return new Date(e-n);const a=t.session.confirmedAt||t.session.startedAt,r=new Date(a).getTime();return new Date(r+60*t.session.durationMinutes*1e3)}export function getExamDeadline(){const t=getExamState();return t?d(t):null}export function getRealExamDeadline(){const t=getRealExamState();return t?d(t):null}let p=!1;export async function refetchQuestionDataIfStale(){const t=getRealExamState();if(!t)return null;if(p)return t;const e=t.session.token;if(!e)return t;if(!t.questions.some(t=>{if("ai4ctf"!==t.type&&"ctf4ai"!==t.type)return!1;const e=String(t.description||"");return(e.includes("attachedData")||/base64 blob/i.test(e))&&!t.attachedData}))return t;p=!0;try{const{getConfig:n}=await import("./config.js"),a=n(),r=a.ctfdUrl||"https://practice.icoa2026.au",o=a.language||"en",i=await fetch(`${r}/api/icoa/exam-token`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({token:e,deviceHash:"",lang:o}),signal:AbortSignal.timeout(8e3)});if(!i.ok)return t;const s=await i.json(),c=s?.data?.questions||[];if(0===c.length)return t;const u=new Map;for(const t of c)u.set(t.number,t);let f=0;for(const e of t.questions){const t=u.get(e.number);t&&(t.attachedData&&!e.attachedData&&(e.attachedData=t.attachedData,f++),t.description&&(!e.description||t.description.length>e.description.length)&&(e.description=t.description))}return f>0&&saveExamState(t),t}catch{return t}}
1
+ import { readFileSync, writeFileSync, existsSync, unlinkSync, mkdirSync, chmodSync } from 'node:fs';
2
+ import { execFileSync } from 'node:child_process';
3
+ import { join } from 'node:path';
4
+ import { homedir } from 'node:os';
5
+ import { getIcoaDir } from './config.js';
6
+ /**
7
+ * Write a practical question's attachedData blob to the contestant's
8
+ * workspace so they can use shell / python / editor tooling on it without
9
+ * pasting the whole base64 back in on the command line.
10
+ *
11
+ * Because `!shell` / `!python3` inside the REPL runs in the sandbox
12
+ * container (which only mounts its own challenges volume, not the host's
13
+ * workspace), we also mirror the file into the sandbox at
14
+ * /home/competitor/challenges/q<n>.txt via `docker cp` when the sandbox
15
+ * is running. Returns the path students should reference —
16
+ * sandbox-visible when the sandbox is up, else host — so the
17
+ * "Ways to solve" block points at a file that actually exists wherever
18
+ * `!command` executes.
19
+ *
20
+ * Silent on failure: null means no workspace file was materialized, and
21
+ * callers skip the solve-paths guidance.
22
+ */
23
+ /**
24
+ * Map a practical question's TYPE to the AI chat command a contestant should
25
+ * enter for it. The practical section is interleaved (standard paper:
26
+ * Q38 ctf4ai, Q39 ai4ctf, Q40 ctf4ai; Paper E: Q37-46 ai4ctf, Q47-48 ctf4ai),
27
+ * so routing by question NUMBER misroutes questions to the wrong engine — the
28
+ * root cause behind both the old printSectionIntro bug and the practical
29
+ * answer-nudge in repl.ts. Always route by type.
30
+ *
31
+ * Returns null for MCQ / short-answer / unknown types, and (when not a real
32
+ * exam, i.e. the demo) since the demo has no live AI chat sections.
33
+ */
34
+ export function practicalChatCommand(qType, isReal) {
35
+ if (!isReal)
36
+ return null;
37
+ if (qType === 'ctf4ai')
38
+ return 'ctf4ai';
39
+ if (qType === 'ai4ctf')
40
+ return 'ai4ctf';
41
+ if (qType === 'vla')
42
+ return 'ctf4eai';
43
+ return null;
44
+ }
45
+ export function materializeAttachedData(qNum, data) {
46
+ if (!data)
47
+ return null;
48
+ let hostPath;
49
+ try {
50
+ // Prefer the active exam's per-exam tmpdir (L1 anti-cheat workspace) so
51
+ // contestants see their question file in the same dir their `!cmd`
52
+ // invocations land in. Falls back to ~/icoa-workspace pre-start.
53
+ const state = getRealExamState();
54
+ const examWorkspace = state?.session?.workspaceDir;
55
+ const workspace = examWorkspace && existsSync(examWorkspace) ? examWorkspace : join(homedir(), 'icoa-workspace');
56
+ mkdirSync(workspace, { recursive: true });
57
+ hostPath = join(workspace, `q${qNum}.txt`);
58
+ writeFileSync(hostPath, data, 'utf-8');
59
+ }
60
+ catch {
61
+ return null;
62
+ }
63
+ // If the sandbox container is up, copy the file inside so `!command`
64
+ // invocations can reach it at a stable path. Uses execFileSync (no
65
+ // shell) because qNum is numeric and hostPath is trusted, but we still
66
+ // avoid string concatenation into a shell. A no-op when the container
67
+ // isn't running — we just swallow any error and fall back to host.
68
+ try {
69
+ execFileSync('docker', ['cp', hostPath, `icoa-sandbox:/home/competitor/challenges/q${qNum}.txt`], {
70
+ stdio: 'ignore',
71
+ timeout: 3000,
72
+ });
73
+ return `challenges/q${qNum}.txt`;
74
+ }
75
+ catch {
76
+ return hostPath;
77
+ }
78
+ }
79
+ // Demo and real exam use separate state files so they don't block each other
80
+ function stateFile() {
81
+ return join(getIcoaDir(), 'exam-state.json');
82
+ }
83
+ function demoStateFile() {
84
+ return join(getIcoaDir(), 'demo-state.json');
85
+ }
86
+ // Internal: pick the right file based on examId
87
+ function resolveStateFile(examId) {
88
+ return examId === 'demo-free' ? demoStateFile() : stateFile();
89
+ }
90
+ export function getRealExamState() {
91
+ const f = stateFile();
92
+ if (!existsSync(f))
93
+ return null;
94
+ try {
95
+ const state = JSON.parse(readFileSync(f, 'utf-8'));
96
+ // Pre-v2.19.45 versions stored demo state in exam-state.json with
97
+ // examId='demo-free'. After v2.19.45 demo moved to demo-state.json.
98
+ // Ignore stale demo-tagged content found in the real-exam file —
99
+ // it's contamination from an old install. Also auto-clean the file.
100
+ if (state?.session?.examId === 'demo-free') {
101
+ try {
102
+ unlinkSync(f);
103
+ }
104
+ catch { }
105
+ return null;
106
+ }
107
+ return state;
108
+ }
109
+ catch {
110
+ return null;
111
+ }
112
+ }
113
+ export function getDemoState() {
114
+ const f = demoStateFile();
115
+ if (!existsSync(f))
116
+ return null;
117
+ try {
118
+ return JSON.parse(readFileSync(f, 'utf-8'));
119
+ }
120
+ catch {
121
+ return null;
122
+ }
123
+ }
124
+ export function getExamState() {
125
+ // Real exam ALWAYS takes priority over demo when both exist.
126
+ // Reasoning: real exam is token-gated, has a timer, and represents a serious
127
+ // commitment. Demo is casual practice. If a real exam is in progress, all
128
+ // shared commands (exam q N, exam answer, ai4ctf, ctf4ai) must target the
129
+ // real exam — never silently fall back to demo questions, even if demo was
130
+ // run more recently. (Demo→Exam→Finals progression principle, exam.md §0)
131
+ const real = getRealExamState();
132
+ if (real)
133
+ return real;
134
+ return getDemoState();
135
+ }
136
+ export function saveExamState(state) {
137
+ const f = resolveStateFile(state.session.examId);
138
+ writeFileSync(f, JSON.stringify(state, null, 2));
139
+ // V16 (v2.19.181): exam-state.json holds session.token in plaintext.
140
+ // 0o600 so other users on shared hosts can't read it.
141
+ try {
142
+ chmodSync(f, 0o600);
143
+ }
144
+ catch {
145
+ /* ignore Windows / restricted FS */
146
+ }
147
+ }
148
+ export function clearExamState(examId) {
149
+ if (examId) {
150
+ const f = resolveStateFile(examId);
151
+ if (existsSync(f))
152
+ unlinkSync(f);
153
+ }
154
+ else {
155
+ // Clear the currently active exam
156
+ const state = getExamState();
157
+ if (state) {
158
+ const f = resolveStateFile(state.session.examId);
159
+ if (existsSync(f))
160
+ unlinkSync(f);
161
+ }
162
+ }
163
+ }
164
+ /** Pure: compute the deadline for a given exam state, or null if the
165
+ * session has no time limit. Shared by getExamDeadline (priority-aware)
166
+ * and getRealExamDeadline (real-exam file only). */
167
+ function computeDeadline(state) {
168
+ if (!state.session.durationMinutes)
169
+ return null; // 0 = no time limit
170
+ // Preferred path: server-time sync succeeded at exam start. deadlineServerMs
171
+ // is the authoritative server-time moment of expiry. Convert back to local
172
+ // clock domain so existing callers (which compare against Date.now()) stay
173
+ // correct even if the local clock drifts from server.
174
+ const { deadlineServerMs, clockOffsetMs } = state.session;
175
+ if (typeof deadlineServerMs === 'number' && typeof clockOffsetMs === 'number') {
176
+ return new Date(deadlineServerMs - clockOffsetMs);
177
+ }
178
+ // Fallback: local-clock-only (pre-v2.19.85 behavior). Accurate as long as
179
+ // client and server clocks agree; off by the clock skew otherwise.
180
+ const startTime = state.session.confirmedAt || state.session.startedAt;
181
+ const start = new Date(startTime).getTime();
182
+ return new Date(start + state.session.durationMinutes * 60 * 1000);
183
+ }
184
+ export function getExamDeadline() {
185
+ const state = getExamState();
186
+ return state ? computeDeadline(state) : null;
187
+ }
188
+ /** Deadline read from the real-exam state file specifically, bypassing
189
+ * getExamState()'s real-over-demo priority. Used by `exam demo` to
190
+ * detect a parked expired real exam that would otherwise block demo. */
191
+ export function getRealExamDeadline() {
192
+ const state = getRealExamState();
193
+ return state ? computeDeadline(state) : null;
194
+ }
195
+ /**
196
+ * Back-fill missing attachedData / description on practical questions by
197
+ * re-fetching from the server. Older state files (saved before v2.19.104)
198
+ * may have questions whose attachedData was dropped or never stored, leaving
199
+ * the contestant stuck on Q31-37 ("where is the attached file?"). This runs
200
+ * at most once per REPL session and is a no-op if:
201
+ * - no session token is stored (can't authenticate the refetch)
202
+ * - all practical questions already have attachedData
203
+ * - the network is unreachable
204
+ * Returns the (possibly updated) state so callers can use it directly.
205
+ */
206
+ let _resyncAttempted = false;
207
+ export async function refetchQuestionDataIfStale() {
208
+ const state = getRealExamState();
209
+ if (!state)
210
+ return null;
211
+ if (_resyncAttempted)
212
+ return state;
213
+ const token = state.session.token;
214
+ if (!token)
215
+ return state;
216
+ // Only refetch if at least one practical question references attachedData
217
+ // but doesn't have it locally — otherwise we'd burn a server round-trip
218
+ // on every ai4ctf entry.
219
+ const needsRefetch = state.questions.some((q) => {
220
+ const isPractical = q.type === 'ai4ctf' || q.type === 'ctf4ai';
221
+ if (!isPractical)
222
+ return false;
223
+ const desc = String(q.description || '');
224
+ const mentionsAttached = desc.includes('attachedData') || /base64 blob/i.test(desc);
225
+ return mentionsAttached && !q.attachedData;
226
+ });
227
+ if (!needsRefetch)
228
+ return state;
229
+ _resyncAttempted = true;
230
+ try {
231
+ const { getConfig } = await import('./config.js');
232
+ const config = getConfig();
233
+ const serverUrl = config.ctfdUrl || 'https://practice.icoa2026.au';
234
+ const lang = config.language || 'en';
235
+ const res = await fetch(`${serverUrl}/api/icoa/exam-token`, {
236
+ method: 'POST',
237
+ headers: { 'Content-Type': 'application/json' },
238
+ body: JSON.stringify({ token, deviceHash: '', lang }),
239
+ signal: AbortSignal.timeout(8000),
240
+ });
241
+ if (!res.ok)
242
+ return state;
243
+ const json = (await res.json());
244
+ const fresh = json?.data?.questions || [];
245
+ if (fresh.length === 0)
246
+ return state;
247
+ // Merge: for every local question, if the fresh copy has attachedData or
248
+ // a longer description, copy those fields in. Keep local answer state /
249
+ // eliminated options etc. untouched — we only patch read-only content.
250
+ const byNum = new Map();
251
+ for (const fq of fresh)
252
+ byNum.set(fq.number, fq);
253
+ let patched = 0;
254
+ for (const q of state.questions) {
255
+ const fq = byNum.get(q.number);
256
+ if (!fq)
257
+ continue;
258
+ if (fq.attachedData && !q.attachedData) {
259
+ q.attachedData = fq.attachedData;
260
+ patched++;
261
+ }
262
+ if (fq.description && (!q.description || fq.description.length > q.description.length)) {
263
+ q.description = fq.description;
264
+ }
265
+ }
266
+ if (patched > 0)
267
+ saveExamState(state);
268
+ return state;
269
+ }
270
+ catch {
271
+ return state;
272
+ }
273
+ }