icoa-cli 2.19.62 → 2.19.64

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.
@@ -308,7 +308,7 @@ export async function handleChatMessage(input) {
308
308
  // ═══════════════════════════════════════════════════════════════
309
309
  // Real-exam AI4CTF chat mode (Q31-38)
310
310
  // ═══════════════════════════════════════════════════════════════
311
- function printExamAi4ctfWelcome(q, qNum) {
311
+ function printExamAi4ctfWelcome(q, qNum, existingAnswer) {
312
312
  const config = getConfig();
313
313
  const modelName = config.geminiModel || 'gemma-4-31b-it';
314
314
  const isResume = chatTokensUsed > 0;
@@ -317,6 +317,9 @@ function printExamAi4ctfWelcome(q, qNum) {
317
317
  if (isResume) {
318
318
  console.log(chalk.gray(` (resuming — prior chat is not remembered, but tokens already used stay deducted)`));
319
319
  }
320
+ if (existingAnswer) {
321
+ console.log(chalk.gray(` Current answer: `) + chalk.yellow(existingAnswer) + chalk.gray(` (submit again to change)`));
322
+ }
320
323
  console.log();
321
324
  console.log(chalk.cyan(' ┌─────────────────────────────────────────────'));
322
325
  console.log(chalk.cyan(' │ ') + chalk.bold.white(`Q${qNum} [${q.category}] · ${q.points || 6} pts`));
@@ -391,9 +394,27 @@ export async function startExamAi4ctfChat(qNum, question) {
391
394
  chatTokensUsed = usedSoFar;
392
395
  tokensLocked = false;
393
396
  examChatCtx = { qNum, question, usageField: 'ai4ctf' };
394
- printExamAi4ctfWelcome(question, qNum);
397
+ const existingAnswer = state.answers?.[qNum];
398
+ printExamAi4ctfWelcome(question, qNum, existingAnswer);
395
399
  return true;
396
400
  }
401
+ // Usage warnings shown once each when crossing thresholds. Keyed by cap.
402
+ function maybeWarnTokenUsage(used, cap, warnedSet) {
403
+ const pct = (used / cap) * 100;
404
+ if (pct >= 95 && !warnedSet.has('95')) {
405
+ console.log(chalk.red.bold(` ⚠ ${Math.round(pct)}% of section AI budget used — only ~${cap - used} tokens left.`));
406
+ warnedSet.add('95');
407
+ }
408
+ else if (pct >= 80 && !warnedSet.has('80')) {
409
+ console.log(chalk.yellow(` ⚠ 80% of section AI budget used.`));
410
+ warnedSet.add('80');
411
+ }
412
+ else if (pct >= 50 && !warnedSet.has('50')) {
413
+ console.log(chalk.gray(` Note: 50% of section AI budget used.`));
414
+ warnedSet.add('50');
415
+ }
416
+ }
417
+ const _ai4ctfWarned = new Set();
397
418
  async function handleExamAi4ctfMessage(input) {
398
419
  if (!examChatCtx || !chatSession)
399
420
  return 'exit';
@@ -449,7 +470,8 @@ async function handleExamAi4ctfMessage(input) {
449
470
  console.log(chalk.red(` Q${examChatCtx.qNum} not found in state.`));
450
471
  return 'continue';
451
472
  }
452
- // Store answer (validated on server at exam submit)
473
+ // Store answer (validated on server at exam submit). Stay in chat so the
474
+ // contestant can iterate on the flag without having to re-enter.
453
475
  const prevAnswer = state.answers[examChatCtx.qNum];
454
476
  if (!state.interactions)
455
477
  state.interactions = [];
@@ -461,33 +483,30 @@ async function handleExamAi4ctfMessage(input) {
461
483
  result: 'via ai4ctf chat',
462
484
  });
463
485
  state.answers[examChatCtx.qNum] = flag;
464
- state._lastQ = examChatCtx.qNum;
486
+ // Advance _lastQ to the next question in the section so that a subsequent
487
+ // `ai4ctf` (after exit) picks up Q+1 automatically, no `exam q N` needed.
488
+ const nextQ = examChatCtx.qNum + 1;
489
+ state._lastQ = nextQ <= 38 ? nextQ : examChatCtx.qNum;
465
490
  saveExamState(state);
466
491
  console.log();
467
- console.log(chalk.green.bold(` ✓ Answer for Q${examChatCtx.qNum} recorded: ${flag}`));
492
+ if (prevAnswer) {
493
+ console.log(chalk.green(` ✓ Q${examChatCtx.qNum} answer updated: `) + chalk.yellow(flag));
494
+ console.log(chalk.gray(` Previous: ${prevAnswer}`));
495
+ }
496
+ else {
497
+ console.log(chalk.green.bold(` ✓ Answer for Q${examChatCtx.qNum} recorded: ${flag}`));
498
+ }
468
499
  console.log(chalk.gray(' (Grading happens at exam submit — you cannot preview correctness during the exam.)'));
469
500
  console.log();
470
- // Auto-navigate to next unanswered AI4CTF question
471
- const nextQ = examChatCtx.qNum + 1;
472
- const savedQ = examChatCtx.qNum;
473
- chatActive = false;
474
- chatSession = null;
475
- examChatCtx = null;
501
+ console.log(chalk.gray(' ') + chalk.white('submit ICOA{...}') + chalk.gray(' again to change, or ') + chalk.white('exit') + chalk.gray(' to move on'));
476
502
  if (nextQ <= 38) {
477
- console.log(chalk.cyan(' ─────────────────────────────────────────────'));
478
- console.log(chalk.white(' Next: ') + chalk.bold.green(`Q${nextQ}`));
479
- console.log(chalk.gray(' → ') + chalk.bold.cyan(`exam q ${nextQ}`) + chalk.gray(' jump to question'));
480
- console.log(chalk.gray(' → ') + chalk.bold.cyan('ai4ctf') + chalk.gray(' start AI chat for Q') + String(nextQ));
481
- console.log(chalk.cyan(' ─────────────────────────────────────────────'));
503
+ console.log(chalk.gray(' After exit: ') + chalk.cyan('ai4ctf') + chalk.gray(` will open Q${nextQ}`));
482
504
  }
483
505
  else {
484
- console.log(chalk.cyan(' ─────────────────────────────────────────────'));
485
- console.log(chalk.bold.white(' AI4CTF section complete — Q39 begins CTF4AI.'));
486
- console.log(chalk.gray(' → ') + chalk.bold.red('ctf4ai') + chalk.gray(' start CTF4AI for Q39'));
487
- console.log(chalk.cyan(' ─────────────────────────────────────────────'));
506
+ console.log(chalk.gray(' After exit: AI4CTF section complete. Next: ') + chalk.red('ctf4ai') + chalk.gray(' on Q39'));
488
507
  }
489
508
  console.log();
490
- return 'exit';
509
+ return 'continue';
491
510
  }
492
511
  // Shell command
493
512
  if (input.startsWith('!')) {
@@ -511,13 +530,26 @@ async function handleExamAi4ctfMessage(input) {
511
530
  }
512
531
  // Exit chat
513
532
  if (lower === 'exit' || lower === 'back' || lower === 'quit') {
533
+ const savedQ = examChatCtx.qNum;
534
+ const { getExamState } = await import('../lib/exam-state.js');
535
+ const exitState = getExamState();
536
+ const hasAnswer = exitState && exitState.answers[savedQ] != null;
537
+ const lastQ = (exitState?._lastQ) || savedQ;
514
538
  chatActive = false;
515
539
  chatSession = null;
516
- const savedQ = examChatCtx.qNum;
517
540
  examChatCtx = null;
541
+ _ai4ctfWarned.clear();
518
542
  console.log();
519
543
  console.log(chalk.gray(` AI4CTF chat ended for Q${savedQ}.`));
520
- console.log(chalk.gray(' Resume answering: ') + chalk.white(`exam q ${savedQ}`) + chalk.gray(' · Re-enter chat: ') + chalk.white('ai4ctf'));
544
+ if (hasAnswer && lastQ > savedQ && lastQ <= 38) {
545
+ console.log(chalk.white(' Next: ') + chalk.bold.green(`Q${lastQ}`) + chalk.gray(' — type ') + chalk.cyan('ai4ctf') + chalk.gray(' to continue'));
546
+ }
547
+ else if (hasAnswer && savedQ >= 38) {
548
+ console.log(chalk.white(' AI4CTF section complete — ') + chalk.bold.red('ctf4ai') + chalk.gray(' for Q39'));
549
+ }
550
+ else {
551
+ console.log(chalk.gray(' Resume: ') + chalk.white(`exam q ${savedQ}`) + chalk.gray(' · Re-enter: ') + chalk.white('ai4ctf'));
552
+ }
521
553
  console.log();
522
554
  return 'exit';
523
555
  }
@@ -556,6 +588,7 @@ async function handleExamAi4ctfMessage(input) {
556
588
  console.log();
557
589
  printMarkdown(response.text);
558
590
  drawTokenBar();
591
+ maybeWarnTokenUsage(chatTokensUsed, EXAM_AI4CTF_CAP, _ai4ctfWarned);
559
592
  console.log();
560
593
  }
561
594
  catch (err) {
@@ -176,7 +176,7 @@ export async function handleCtf4aiMessage(input) {
176
176
  // ═══════════════════════════════════════════════════════════════
177
177
  // Real-exam CTF4AI chat mode (Q39-40)
178
178
  // ═══════════════════════════════════════════════════════════════
179
- function printExamCtf4aiWelcome(q, qNum) {
179
+ function printExamCtf4aiWelcome(q, qNum, existingAnswer) {
180
180
  const config = getConfig();
181
181
  const modelName = config.geminiModel || 'gemma-4-31b-it';
182
182
  const isResume = ctf4aiTokens > 0;
@@ -185,6 +185,9 @@ function printExamCtf4aiWelcome(q, qNum) {
185
185
  if (isResume) {
186
186
  console.log(chalk.gray(` (resuming — prior chat is not remembered, but tokens already used stay deducted)`));
187
187
  }
188
+ if (existingAnswer) {
189
+ console.log(chalk.gray(` Current answer: `) + chalk.yellow(existingAnswer) + chalk.gray(` (submit again to change)`));
190
+ }
188
191
  console.log();
189
192
  console.log(chalk.red(' ┌─────────────────────────────────────────────'));
190
193
  console.log(chalk.red(' │ ') + chalk.bold.white(`Q${qNum} [${q.category}] · adversarial AI`));
@@ -263,9 +266,26 @@ export async function startExamCtf4aiChat(qNum, question) {
263
266
  ctf4aiActive = true;
264
267
  ctf4aiTokens = usedSoFar;
265
268
  examCtf4aiCtx = { qNum, question };
266
- printExamCtf4aiWelcome(question, qNum);
269
+ const existingAnswer = state.answers?.[qNum];
270
+ printExamCtf4aiWelcome(question, qNum, existingAnswer);
267
271
  return true;
268
272
  }
273
+ const _ctf4aiWarned = new Set();
274
+ function maybeWarnCtf4aiUsage(used, cap) {
275
+ const pct = (used / cap) * 100;
276
+ if (pct >= 95 && !_ctf4aiWarned.has('95')) {
277
+ console.log(chalk.red.bold(` ⚠ ${Math.round(pct)}% of CTF4AI budget used — only ~${cap - used} tokens left.`));
278
+ _ctf4aiWarned.add('95');
279
+ }
280
+ else if (pct >= 80 && !_ctf4aiWarned.has('80')) {
281
+ console.log(chalk.yellow(` ⚠ 80% of CTF4AI budget used.`));
282
+ _ctf4aiWarned.add('80');
283
+ }
284
+ else if (pct >= 50 && !_ctf4aiWarned.has('50')) {
285
+ console.log(chalk.gray(` Note: 50% of CTF4AI budget used.`));
286
+ _ctf4aiWarned.add('50');
287
+ }
288
+ }
269
289
  async function handleExamCtf4aiMessage(input) {
270
290
  if (!examCtf4aiCtx || !ctf4aiSession)
271
291
  return 'exit';
@@ -323,32 +343,29 @@ async function handleExamCtf4aiMessage(input) {
323
343
  result: 'via ctf4ai chat',
324
344
  });
325
345
  state.answers[examCtf4aiCtx.qNum] = flag;
326
- state._lastQ = examCtf4aiCtx.qNum;
346
+ // Advance _lastQ so `ctf4ai` after exit picks up Q40 automatically.
347
+ const nextQ = examCtf4aiCtx.qNum + 1;
348
+ state._lastQ = nextQ <= 40 ? nextQ : examCtf4aiCtx.qNum;
327
349
  saveExamState(state);
328
350
  console.log();
329
- console.log(chalk.green.bold(` ✓ Answer for Q${examCtf4aiCtx.qNum} recorded: ${flag}`));
351
+ if (prevAnswer) {
352
+ console.log(chalk.green(` ✓ Q${examCtf4aiCtx.qNum} answer updated: `) + chalk.yellow(flag));
353
+ console.log(chalk.gray(` Previous: ${prevAnswer}`));
354
+ }
355
+ else {
356
+ console.log(chalk.green.bold(` ✓ Answer for Q${examCtf4aiCtx.qNum} recorded: ${flag}`));
357
+ }
330
358
  console.log(chalk.gray(' (Grading happens at exam submit.)'));
331
359
  console.log();
332
- const savedQ = examCtf4aiCtx.qNum;
333
- ctf4aiActive = false;
334
- ctf4aiSession = null;
335
- examCtf4aiCtx = null;
336
- if (savedQ === 39) {
337
- console.log(chalk.red(' ─────────────────────────────────────────────'));
338
- console.log(chalk.white(' Next: ') + chalk.bold.red('Q40 — final question'));
339
- console.log(chalk.gray(' → ') + chalk.bold.cyan('exam q 40') + chalk.gray(' jump to Q40'));
340
- console.log(chalk.gray(' → ') + chalk.bold.red('ctf4ai') + chalk.gray(' start CTF4AI for Q40'));
341
- console.log(chalk.red(' ─────────────────────────────────────────────'));
360
+ console.log(chalk.gray(' ') + chalk.white('submit ICOA{...}') + chalk.gray(' again to change, or ') + chalk.white('exit') + chalk.gray(' to move on'));
361
+ if (nextQ <= 40) {
362
+ console.log(chalk.gray(' After exit: ') + chalk.red('ctf4ai') + chalk.gray(` will open Q${nextQ}`));
342
363
  }
343
364
  else {
344
- console.log(chalk.green(' ─────────────────────────────────────────────'));
345
- console.log(chalk.bold.green(' All 40 questions answered! Time to submit.'));
346
- console.log(chalk.gray(' → ') + chalk.bold.cyan('exam review') + chalk.gray(' sanity-check your answers'));
347
- console.log(chalk.gray(' → ') + chalk.bold.cyan('exam submit') + chalk.gray(' final submission'));
348
- console.log(chalk.green(' ─────────────────────────────────────────────'));
365
+ console.log(chalk.gray(' After exit: all 40 answered → ') + chalk.cyan('exam submit'));
349
366
  }
350
367
  console.log();
351
- return 'exit';
368
+ return 'continue';
352
369
  }
353
370
  // Shell
354
371
  if (input.startsWith('!')) {
@@ -373,12 +390,25 @@ async function handleExamCtf4aiMessage(input) {
373
390
  // Exit
374
391
  if (lower === 'exit' || lower === 'back' || lower === 'quit') {
375
392
  const savedQ = examCtf4aiCtx.qNum;
393
+ const { getExamState } = await import('../lib/exam-state.js');
394
+ const exitState = getExamState();
395
+ const hasAnswer = exitState && exitState.answers[savedQ] != null;
396
+ const lastQ = (exitState?._lastQ) || savedQ;
376
397
  ctf4aiActive = false;
377
398
  ctf4aiSession = null;
378
399
  examCtf4aiCtx = null;
400
+ _ctf4aiWarned.clear();
379
401
  console.log();
380
402
  console.log(chalk.gray(` CTF4AI chat ended for Q${savedQ}.`));
381
- console.log(chalk.gray(' Resume: ') + chalk.white(`exam q ${savedQ}`) + chalk.gray(' · Re-enter: ') + chalk.white('ctf4ai'));
403
+ if (hasAnswer && savedQ === 39 && lastQ <= 40) {
404
+ console.log(chalk.white(' Next: ') + chalk.bold.red('Q40') + chalk.gray(' — type ') + chalk.red('ctf4ai') + chalk.gray(' to continue'));
405
+ }
406
+ else if (hasAnswer && savedQ === 40) {
407
+ console.log(chalk.bold.green(' All 40 answered! ') + chalk.gray('Review + submit: ') + chalk.cyan('exam review') + chalk.gray(' · ') + chalk.cyan('exam submit'));
408
+ }
409
+ else {
410
+ console.log(chalk.gray(' Resume: ') + chalk.white(`exam q ${savedQ}`) + chalk.gray(' · Re-enter: ') + chalk.white('ctf4ai'));
411
+ }
382
412
  console.log();
383
413
  return 'exit';
384
414
  }
@@ -409,6 +439,7 @@ async function handleExamCtf4aiMessage(input) {
409
439
  console.log();
410
440
  const pct = Math.round((ctf4aiTokens / EXAM_CTF4AI_CAP) * 100);
411
441
  console.log(chalk.gray(` [${ctf4aiTokens}/${EXAM_CTF4AI_CAP} CTF4AI tokens · ${pct}%]`));
442
+ maybeWarnCtf4aiUsage(ctf4aiTokens, EXAM_CTF4AI_CAP);
412
443
  console.log();
413
444
  }
414
445
  catch (err) {
@@ -11,6 +11,29 @@ const installCmd = platform() === 'win32'
11
11
  : 'sudo npm install -g icoa-cli@latest';
12
12
  const __dirname = dirname(fileURLToPath(import.meta.url));
13
13
  const SIX_HOURS_MS = 6 * 60 * 60 * 1000;
14
+ function versionsBehind(latest, current) {
15
+ // Count PATCH-level diff only (e.g. 2.19.40 vs 2.19.46 = 6)
16
+ const l = latest.split('.').map(Number);
17
+ const c = current.split('.').map(Number);
18
+ if (l[0] !== c[0] || l[1] !== c[1])
19
+ return -1; // major/minor diff
20
+ return (l[2] ?? 0) - (c[2] ?? 0);
21
+ }
22
+ function printUpdateBanner(current, latest) {
23
+ const behind = versionsBehind(latest, current);
24
+ const behindStr = behind > 0 ? chalk.gray(` (${behind} version${behind === 1 ? '' : 's'} behind)`) : '';
25
+ const line = chalk.yellow(' ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
26
+ console.log();
27
+ console.log(line);
28
+ console.log(chalk.bold.yellow(' ⬆ New version available!'));
29
+ console.log();
30
+ console.log(chalk.white(' Current: ') + chalk.gray('v' + current));
31
+ console.log(chalk.white(' Latest: ') + chalk.bold.green('v' + latest) + behindStr);
32
+ console.log();
33
+ console.log(chalk.white(' Update: ') + chalk.bold.cyan(installCmd));
34
+ console.log(line);
35
+ console.log();
36
+ }
14
37
  function isNewer(latest, current) {
15
38
  const l = latest.split('.').map(Number);
16
39
  const c = current.split('.').map(Number);
@@ -38,9 +61,7 @@ export function checkForUpdates() {
38
61
  if (Date.now() - cache.lastCheck < SIX_HOURS_MS) {
39
62
  // Still within cooldown — but show banner if cached version is newer
40
63
  if (cache.latestVersion && isNewer(cache.latestVersion, current)) {
41
- console.log(chalk.yellow(' \u2B06 Update available: ') +
42
- chalk.white('v' + current + ' \u2192 v' + cache.latestVersion) +
43
- chalk.gray(` Run: ${installCmd}`));
64
+ printUpdateBanner(current, cache.latestVersion);
44
65
  }
45
66
  return;
46
67
  }
@@ -71,9 +92,7 @@ export function checkForUpdates() {
71
92
  writeFileSync(cacheFile, JSON.stringify(cache, null, 2));
72
93
  // Print banner if newer
73
94
  if (isNewer(latest, current)) {
74
- console.log(chalk.yellow(' \u2B06 Update available: ') +
75
- chalk.white('v' + current + ' \u2192 v' + latest) +
76
- chalk.gray(` Run: ${installCmd}`));
95
+ printUpdateBanner(current, latest);
77
96
  }
78
97
  }
79
98
  catch {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "icoa-cli",
3
- "version": "2.19.62",
3
+ "version": "2.19.64",
4
4
  "description": "ICOA CLI — The world's first CLI-native CTF competition terminal",
5
5
  "type": "module",
6
6
  "bin": {