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,227 @@
1
- import{chmodSync as e,existsSync as n,mkdirSync as t,readFileSync as s,writeFileSync as r}from"node:fs";import{homedir as a}from"node:os";import{join as i}from"node:path";const o=i(a(),".icoa","bin");export const RUNNER_PY=i(o,"icoa-judge.py");const p=i(o,"icoa-judge");function l(e){try{return(s(e,"utf-8").split("\n",2)[1]||"").includes("ICOA-RUNNER v1")}catch{return!1}}export function placeSampleRunner(){try{if(n(RUNNER_PY)&&l(RUNNER_PY))return{ok:!0,path:RUNNER_PY,updated:!1};if(t(o,{recursive:!0}),r(RUNNER_PY,'#!/usr/bin/env python3\n# ICOA-RUNNER v1\n"""icoa-judge — tiny local sample-test runner for the ioipy Python informatics track.\n\nRuns YOUR Python solution against sample input/expected-output pairs, compares stdout,\nand reports PASS / WRONG / TLE / RUNTIME-ERROR plus the wall-clock time of each run.\n\nThis is a self-check on the SAMPLE cases only a rough "is it correct and fast enough?"\nfeel. It is NOT the real judge (no hidden tests, no authoritative time/memory limit).\n\nUSAGE\n icoa-judge SOLUTION.py [SAMPLES]\n SAMPLES can be:\n - a directory holding 1.in/1.out, 2.in/2.out, ... (also accepts .ans/.expected/.exp)\n - a single case.in (its expected output is the sibling case.out)\n - two files: case.in case.out\n - omitted: auto-search ./samples, ./tests, a samples/ next to SOLUTION.py, or *.in here\n\nOPTIONS\n --tl SECONDS per-case soft time limit (default 5.0 — Python-friendly; a TLE here is a\n hint, not a verdict; the real limit is the contest\'s)\n --py CMD interpreter for your solution (default: python3; try --py pypy3 for speed)\n --raw exact byte compare (default: whitespace-normalized — trailing spaces and\n trailing blank lines are ignored, the usual contest checker behaviour)\n"""\nimport argparse, glob, os, subprocess, sys, time\n\nOUT_EXTS = (".out", ".ans", ".expected", ".exp")\nC = sys.stdout.isatty()\ndef col(s, c): return f"\\033[{c}m{s}\\033[0m" if C else s\ndef green(s): return col(s, "32")\ndef red(s): return col(s, "31")\ndef yellow(s): return col(s, "33")\ndef gray(s): return col(s, "90")\n\ndef find_expected(in_path):\n stem = in_path[:-3] if in_path.endswith(".in") else os.path.splitext(in_path)[0]\n for ext in OUT_EXTS:\n if os.path.exists(stem + ext):\n return stem + ext\n return None\n\ndef collect_cases(args):\n sols_dir = os.path.dirname(os.path.abspath(args.solution)) or "."\n # explicit: two files (in + out)\n if args.samples and args.expected:\n return [(args.samples, args.expected)]\n # explicit: a single .in file\n if args.samples and os.path.isfile(args.samples) and args.samples.endswith(".in"):\n exp = find_expected(args.samples)\n return [(args.samples, exp)] if exp else []\n # explicit: a directory\n if args.samples and os.path.isdir(args.samples):\n search = [args.samples]\n else:\n search = ["samples", "tests", os.path.join(sols_dir, "samples"), "."]\n for d in search:\n if not os.path.isdir(d):\n continue\n ins = sorted(glob.glob(os.path.join(d, "*.in")))\n cases = [(i, find_expected(i)) for i in ins]\n cases = [(i, o) for (i, o) in cases if o]\n if cases:\n return cases\n return []\n\ndef normalize(text, raw):\n if raw:\n return text\n lines = [ln.rstrip() for ln in text.replace("\\r\\n", "\\n").split("\\n")]\n while lines and lines[-1] == "":\n lines.pop()\n return "\\n".join(lines)\n\ndef first_diff(exp, got):\n e, g = exp.split("\\n"), got.split("\\n")\n for i in range(max(len(e), len(g))):\n ev = e[i] if i < len(e) else "<no line>"\n gv = g[i] if i < len(g) else "<no line>"\n if ev != gv:\n return i + 1, ev, gv\n return None\n\ndef run_case(py, solution, in_path, tl):\n with open(in_path, "rb") as f:\n data = f.read()\n t0 = time.perf_counter()\n try:\n p = subprocess.run([py, solution], input=data, stdout=subprocess.PIPE,\n stderr=subprocess.PIPE, timeout=tl)\n except subprocess.TimeoutExpired:\n return ("TLE", time.perf_counter() - t0, "", "")\n except FileNotFoundError:\n sys.exit(red(f"interpreter not found: {py} (install it, or pass --py python3)"))\n dt = time.perf_counter() - t0\n if p.returncode != 0:\n err = p.stderr.decode("utf-8", "replace").strip().splitlines()\n return ("RE", dt, p.stdout.decode("utf-8", "replace"), (err[-1] if err else f"exit {p.returncode}"))\n return ("OK", dt, p.stdout.decode("utf-8", "replace"), "")\n\ndef main():\n ap = argparse.ArgumentParser(prog="icoa-judge", add_help=True)\n ap.add_argument("solution")\n ap.add_argument("samples", nargs="?")\n ap.add_argument("expected", nargs="?")\n ap.add_argument("--tl", type=float, default=5.0)\n ap.add_argument("--py", default="python3")\n ap.add_argument("--raw", action="store_true")\n args = ap.parse_args()\n\n if not os.path.isfile(args.solution):\n sys.exit(red(f"solution file not found: {args.solution}"))\n cases = collect_cases(args)\n if not cases:\n sys.exit(yellow("no sample cases found. Put 1.in/1.out next to your solution (or in "\n "./samples), or pass a directory / a case.in case.out pair."))\n\n print(gray(f"icoa-judge — {args.py} · {len(cases)} sample case(s) · TL {args.tl:g}s "\n f"(sample self-check, not the real judge)"))\n passed = 0\n slow = False\n for idx, (in_path, exp_path) in enumerate(cases, 1):\n name = os.path.basename(in_path)\n status, dt, got, extra = run_case(args.py, args.solution, in_path, args.tl)\n ms = f"{dt * 1000:7.1f} ms"\n if status == "TLE":\n print(f" {red(\'TLE \')} {name:<16} {gray(\'> \' + format(args.tl, \'g\') + \'s\')}")\n slow = True\n continue\n if status == "RE":\n print(f" {red(\'ERR \')} {name:<16} {gray(ms)} {red(extra)}")\n continue\n exp = normalize(open(exp_path, encoding=\'utf-8\', errors=\'replace\').read(), args.raw)\n out = normalize(got, args.raw)\n if out == exp:\n print(f" {green(\'PASS\')} {name:<16} {gray(ms)}")\n passed += 1\n if dt > 0.5 * args.tl:\n slow = True\n else:\n d = first_diff(exp, out)\n where = f"line {d[0]}" if d else "output"\n print(f" {red(\'WRONG\')} {name:<16} {gray(ms)} {gray(where)}")\n if d:\n print(gray(f" expected: {d[1][:70]}"))\n print(gray(f" got: {d[2][:70]}"))\n\n print()\n tag = green("ALL PASS") if passed == len(cases) else yellow(f"{passed}/{len(cases)} passed")\n print(f" {tag}")\n if slow and args.py != "pypy3":\n print(gray(" (some runs were slow — if the real judge TLEs, try --py pypy3, faster I/O, "\n "or an iterative form. Sample timing is only a rough guide.)"))\n sys.exit(0 if passed == len(cases) else 1)\n\nif __name__ == "__main__":\n main()\n',"utf-8"),"win32"!==process.platform){try{e(RUNNER_PY,493)}catch{}r(p,`#!/bin/sh\nexec python3 "${RUNNER_PY}" "$@"\n`,"utf-8");try{e(p,493)}catch{}}return{ok:!0,path:RUNNER_PY,updated:!0}}catch(e){return{ok:!1,path:RUNNER_PY,updated:!1,why:e instanceof Error?e.message:String(e)}}}export function sampleRunnerInstalled(){return n(RUNNER_PY)&&l(RUNNER_PY)}
1
+ // Local sample-test runner for the ioipy (Python informatics) track — Hard Rule §4.
2
+ //
3
+ // A tiny STDLIB-ONLY Python script shipped to the student's machine by `env setup`.
4
+ // It runs their Python solution against sample input/output pairs, diffs stdout against
5
+ // the expected output (whitespace-normalized), and prints a rough wall-clock time so they
6
+ // get a *feel* for whether a solution is fast enough. It is deliberately NOT the
7
+ // server-authoritative online judge (that hidden-test layer is the deferred §3 project) —
8
+ // it only checks the SAMPLE cases the student already has, which is exactly what the learn
9
+ // cards (P2+) ask them to do ("implement in Python and run it on the provided samples").
10
+ //
11
+ // The script content travels as a string in the compiled JS, so there is no asset to
12
+ // bundle / resolve at runtime. `placeSampleRunner()` writes it to ~/.icoa/bin/.
13
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
14
+ import { homedir } from 'node:os';
15
+ import { join } from 'node:path';
16
+ // Bump when the embedded script below changes, so an existing stale copy is rewritten.
17
+ const SAMPLE_RUNNER_VERSION = '1';
18
+ const RUNNER_DIR = join(homedir(), '.icoa', 'bin');
19
+ export const RUNNER_PY = join(RUNNER_DIR, 'icoa-judge.py');
20
+ const RUNNER_SHIM = join(RUNNER_DIR, 'icoa-judge');
21
+ // The shipped runner. Pure Python 3 stdlib (subprocess, argparse, time, glob, sys, os).
22
+ // `# ICOA-RUNNER vN` on line 2 lets us detect/refresh a stale copy.
23
+ const JUDGE_SCRIPT = `#!/usr/bin/env python3
24
+ # ICOA-RUNNER v${SAMPLE_RUNNER_VERSION}
25
+ """icoa-judge — tiny local sample-test runner for the ioipy Python informatics track.
26
+
27
+ Runs YOUR Python solution against sample input/expected-output pairs, compares stdout,
28
+ and reports PASS / WRONG / TLE / RUNTIME-ERROR plus the wall-clock time of each run.
29
+
30
+ This is a self-check on the SAMPLE cases only — a rough "is it correct and fast enough?"
31
+ feel. It is NOT the real judge (no hidden tests, no authoritative time/memory limit).
32
+
33
+ USAGE
34
+ icoa-judge SOLUTION.py [SAMPLES]
35
+ SAMPLES can be:
36
+ - a directory holding 1.in/1.out, 2.in/2.out, ... (also accepts .ans/.expected/.exp)
37
+ - a single case.in (its expected output is the sibling case.out)
38
+ - two files: case.in case.out
39
+ - omitted: auto-search ./samples, ./tests, a samples/ next to SOLUTION.py, or *.in here
40
+
41
+ OPTIONS
42
+ --tl SECONDS per-case soft time limit (default 5.0 — Python-friendly; a TLE here is a
43
+ hint, not a verdict; the real limit is the contest's)
44
+ --py CMD interpreter for your solution (default: python3; try --py pypy3 for speed)
45
+ --raw exact byte compare (default: whitespace-normalized — trailing spaces and
46
+ trailing blank lines are ignored, the usual contest checker behaviour)
47
+ """
48
+ import argparse, glob, os, subprocess, sys, time
49
+
50
+ OUT_EXTS = (".out", ".ans", ".expected", ".exp")
51
+ C = sys.stdout.isatty()
52
+ def col(s, c): return f"\\033[{c}m{s}\\033[0m" if C else s
53
+ def green(s): return col(s, "32")
54
+ def red(s): return col(s, "31")
55
+ def yellow(s): return col(s, "33")
56
+ def gray(s): return col(s, "90")
57
+
58
+ def find_expected(in_path):
59
+ stem = in_path[:-3] if in_path.endswith(".in") else os.path.splitext(in_path)[0]
60
+ for ext in OUT_EXTS:
61
+ if os.path.exists(stem + ext):
62
+ return stem + ext
63
+ return None
64
+
65
+ def collect_cases(args):
66
+ sols_dir = os.path.dirname(os.path.abspath(args.solution)) or "."
67
+ # explicit: two files (in + out)
68
+ if args.samples and args.expected:
69
+ return [(args.samples, args.expected)]
70
+ # explicit: a single .in file
71
+ if args.samples and os.path.isfile(args.samples) and args.samples.endswith(".in"):
72
+ exp = find_expected(args.samples)
73
+ return [(args.samples, exp)] if exp else []
74
+ # explicit: a directory
75
+ if args.samples and os.path.isdir(args.samples):
76
+ search = [args.samples]
77
+ else:
78
+ search = ["samples", "tests", os.path.join(sols_dir, "samples"), "."]
79
+ for d in search:
80
+ if not os.path.isdir(d):
81
+ continue
82
+ ins = sorted(glob.glob(os.path.join(d, "*.in")))
83
+ cases = [(i, find_expected(i)) for i in ins]
84
+ cases = [(i, o) for (i, o) in cases if o]
85
+ if cases:
86
+ return cases
87
+ return []
88
+
89
+ def normalize(text, raw):
90
+ if raw:
91
+ return text
92
+ lines = [ln.rstrip() for ln in text.replace("\\r\\n", "\\n").split("\\n")]
93
+ while lines and lines[-1] == "":
94
+ lines.pop()
95
+ return "\\n".join(lines)
96
+
97
+ def first_diff(exp, got):
98
+ e, g = exp.split("\\n"), got.split("\\n")
99
+ for i in range(max(len(e), len(g))):
100
+ ev = e[i] if i < len(e) else "<no line>"
101
+ gv = g[i] if i < len(g) else "<no line>"
102
+ if ev != gv:
103
+ return i + 1, ev, gv
104
+ return None
105
+
106
+ def run_case(py, solution, in_path, tl):
107
+ with open(in_path, "rb") as f:
108
+ data = f.read()
109
+ t0 = time.perf_counter()
110
+ try:
111
+ p = subprocess.run([py, solution], input=data, stdout=subprocess.PIPE,
112
+ stderr=subprocess.PIPE, timeout=tl)
113
+ except subprocess.TimeoutExpired:
114
+ return ("TLE", time.perf_counter() - t0, "", "")
115
+ except FileNotFoundError:
116
+ sys.exit(red(f"interpreter not found: {py} (install it, or pass --py python3)"))
117
+ dt = time.perf_counter() - t0
118
+ if p.returncode != 0:
119
+ err = p.stderr.decode("utf-8", "replace").strip().splitlines()
120
+ return ("RE", dt, p.stdout.decode("utf-8", "replace"), (err[-1] if err else f"exit {p.returncode}"))
121
+ return ("OK", dt, p.stdout.decode("utf-8", "replace"), "")
122
+
123
+ def main():
124
+ ap = argparse.ArgumentParser(prog="icoa-judge", add_help=True)
125
+ ap.add_argument("solution")
126
+ ap.add_argument("samples", nargs="?")
127
+ ap.add_argument("expected", nargs="?")
128
+ ap.add_argument("--tl", type=float, default=5.0)
129
+ ap.add_argument("--py", default="python3")
130
+ ap.add_argument("--raw", action="store_true")
131
+ args = ap.parse_args()
132
+
133
+ if not os.path.isfile(args.solution):
134
+ sys.exit(red(f"solution file not found: {args.solution}"))
135
+ cases = collect_cases(args)
136
+ if not cases:
137
+ sys.exit(yellow("no sample cases found. Put 1.in/1.out next to your solution (or in "
138
+ "./samples), or pass a directory / a case.in case.out pair."))
139
+
140
+ print(gray(f"icoa-judge — {args.py} · {len(cases)} sample case(s) · TL {args.tl:g}s "
141
+ f"(sample self-check, not the real judge)"))
142
+ passed = 0
143
+ slow = False
144
+ for idx, (in_path, exp_path) in enumerate(cases, 1):
145
+ name = os.path.basename(in_path)
146
+ status, dt, got, extra = run_case(args.py, args.solution, in_path, args.tl)
147
+ ms = f"{dt * 1000:7.1f} ms"
148
+ if status == "TLE":
149
+ print(f" {red('TLE ')} {name:<16} {gray('> ' + format(args.tl, 'g') + 's')}")
150
+ slow = True
151
+ continue
152
+ if status == "RE":
153
+ print(f" {red('ERR ')} {name:<16} {gray(ms)} {red(extra)}")
154
+ continue
155
+ exp = normalize(open(exp_path, encoding='utf-8', errors='replace').read(), args.raw)
156
+ out = normalize(got, args.raw)
157
+ if out == exp:
158
+ print(f" {green('PASS')} {name:<16} {gray(ms)}")
159
+ passed += 1
160
+ if dt > 0.5 * args.tl:
161
+ slow = True
162
+ else:
163
+ d = first_diff(exp, out)
164
+ where = f"line {d[0]}" if d else "output"
165
+ print(f" {red('WRONG')} {name:<16} {gray(ms)} {gray(where)}")
166
+ if d:
167
+ print(gray(f" expected: {d[1][:70]}"))
168
+ print(gray(f" got: {d[2][:70]}"))
169
+
170
+ print()
171
+ tag = green("ALL PASS") if passed == len(cases) else yellow(f"{passed}/{len(cases)} passed")
172
+ print(f" {tag}")
173
+ if slow and args.py != "pypy3":
174
+ print(gray(" (some runs were slow — if the real judge TLEs, try --py pypy3, faster I/O, "
175
+ "or an iterative form. Sample timing is only a rough guide.)"))
176
+ sys.exit(0 if passed == len(cases) else 1)
177
+
178
+ if __name__ == "__main__":
179
+ main()
180
+ `;
181
+ function isCurrent(path) {
182
+ try {
183
+ const head = readFileSync(path, 'utf-8').split('\n', 2)[1] || '';
184
+ return head.includes(`ICOA-RUNNER v${SAMPLE_RUNNER_VERSION}`);
185
+ }
186
+ catch {
187
+ return false;
188
+ }
189
+ }
190
+ /**
191
+ * Write the runner to ~/.icoa/bin/icoa-judge.py (+ a `icoa-judge` shell shim on POSIX).
192
+ * Idempotent: rewrites only if missing or stale (older version line). Stdlib-only —
193
+ * needs nothing installed beyond a working python3 the student already has.
194
+ */
195
+ export function placeSampleRunner() {
196
+ try {
197
+ if (existsSync(RUNNER_PY) && isCurrent(RUNNER_PY)) {
198
+ return { ok: true, path: RUNNER_PY, updated: false };
199
+ }
200
+ mkdirSync(RUNNER_DIR, { recursive: true });
201
+ writeFileSync(RUNNER_PY, JUDGE_SCRIPT, 'utf-8');
202
+ if (process.platform !== 'win32') {
203
+ try {
204
+ chmodSync(RUNNER_PY, 0o755);
205
+ }
206
+ catch {
207
+ /* non-fatal */
208
+ }
209
+ // Convenience shim so the student can type `icoa-judge ...` once ~/.icoa/bin is on PATH.
210
+ writeFileSync(RUNNER_SHIM, `#!/bin/sh\nexec python3 "${RUNNER_PY}" "$@"\n`, 'utf-8');
211
+ try {
212
+ chmodSync(RUNNER_SHIM, 0o755);
213
+ }
214
+ catch {
215
+ /* non-fatal */
216
+ }
217
+ }
218
+ return { ok: true, path: RUNNER_PY, updated: true };
219
+ }
220
+ catch (e) {
221
+ return { ok: false, path: RUNNER_PY, updated: false, why: e instanceof Error ? e.message : String(e) };
222
+ }
223
+ }
224
+ /** Is a current copy of the runner present? (for `env` status display) */
225
+ export function sampleRunnerInstalled() {
226
+ return existsSync(RUNNER_PY) && isCurrent(RUNNER_PY);
227
+ }
@@ -1 +1,69 @@
1
- export function shellSplit(t){const e=[];let l="",n=!1,s=!1,h=!1;for(let o=0;o<t.length;o++){const p=t[o];n?("'"===p?n=!1:l+=p,h=!0):s?('"'===p?s=!1:"\\"!==p||'"'!==t[o+1]&&"\\"!==t[o+1]?l+=p:l+=t[++o],h=!0):"'"===p?(n=!0,h=!0):'"'===p?(s=!0,h=!0):"\\"===p&&o+1<t.length?(l+=t[++o],h=!0):/\s/.test(p)?h&&(e.push(l),l="",h=!1):(l+=p,h=!0)}return h&&e.push(l),e}
1
+ /**
2
+ * shellSplit — a minimal, quote-aware tokenizer for REPL commands that forward
3
+ * raw args straight to a child process (currently `aienv run` / `aienv python`).
4
+ *
5
+ * The icoa REPL is NOT a shell: the default command split on /\s+/ turns quotes
6
+ * into literal characters and tears spaced args apart, so `aienv run "my file.py"`
7
+ * and `aienv python -c "print(1 + 1)"` reach python mangled. This honours
8
+ * single/double quotes (and backslash escapes inside double quotes / outside
9
+ * quotes) the way a real shell would tokenize them. (Direct-CLI invocation never
10
+ * needs this — there the system shell does the tokenization for us.)
11
+ *
12
+ * Scope is deliberately small: word-splitting + quote removal + backslash
13
+ * escapes. No globbing, no variable/`$()` expansion, no `~` expansion — the
14
+ * child process / caller handles paths, and we explicitly do not want shell
15
+ * metacharacter semantics in the REPL.
16
+ */
17
+ export function shellSplit(input) {
18
+ const tokens = [];
19
+ let cur = '';
20
+ let inSingle = false;
21
+ let inDouble = false;
22
+ let hasTok = false; // so an explicit "" / '' still yields an empty-string arg
23
+ for (let i = 0; i < input.length; i++) {
24
+ const c = input[i];
25
+ if (inSingle) {
26
+ // Inside single quotes everything is literal until the closing quote.
27
+ if (c === "'")
28
+ inSingle = false;
29
+ else
30
+ cur += c;
31
+ hasTok = true;
32
+ }
33
+ else if (inDouble) {
34
+ if (c === '"')
35
+ inDouble = false;
36
+ else if (c === '\\' && (input[i + 1] === '"' || input[i + 1] === '\\'))
37
+ cur += input[++i];
38
+ else
39
+ cur += c;
40
+ hasTok = true;
41
+ }
42
+ else if (c === "'") {
43
+ inSingle = true;
44
+ hasTok = true;
45
+ }
46
+ else if (c === '"') {
47
+ inDouble = true;
48
+ hasTok = true;
49
+ }
50
+ else if (c === '\\' && i + 1 < input.length) {
51
+ cur += input[++i];
52
+ hasTok = true;
53
+ }
54
+ else if (/\s/.test(c)) {
55
+ if (hasTok) {
56
+ tokens.push(cur);
57
+ cur = '';
58
+ hasTok = false;
59
+ }
60
+ }
61
+ else {
62
+ cur += c;
63
+ hasTok = true;
64
+ }
65
+ }
66
+ if (hasTok)
67
+ tokens.push(cur);
68
+ return tokens;
69
+ }
@@ -1 +1,75 @@
1
- import{mkdirSync as t,readFileSync as n,writeFileSync as o}from"node:fs";import{join as r}from"node:path";import{homedir as e}from"node:os";export const COOLDOWN_SECONDS=60;const l=r(e(),".icoa","sim-cooldown.json");function a(){try{const t=n(l,"utf-8"),o=JSON.parse(t);return"object"==typeof o&&null!==o?o:{}}catch{return{}}}function s(n){try{t(r(e(),".icoa"),{recursive:!0});const s={...a(),...n};o(l,JSON.stringify(s))}catch{}}export function lastSimAt(){const t=a();return"number"==typeof t.lastCallAt?t.lastCallAt:0}export function markSimAt(t){s({lastCallAt:t})}export function cooldownRemaining(){const t=(Date.now()-lastSimAt())/1e3;return Math.max(0,Math.ceil(60-t))}export function lastBundledAt(){const t=a();return"number"==typeof t.lastBundledAt?t.lastBundledAt:0}export function markBundledAt(t){s({lastBundledAt:t})}export function lastArmAt(){const t=a();return"number"==typeof t.lastArmAt?t.lastArmAt:0}export function markArmAt(t){s({lastArmAt:t})}export function hasAnySimHistory(){const t=a();return"number"==typeof t.lastBundledAt&&t.lastBundledAt>0||"number"==typeof t.lastCallAt&&t.lastCallAt>0||"number"==typeof t.lastArmAt&&t.lastArmAt>0}
1
+ /**
2
+ * Shared 60s cooldown for any client-side caller of the MuJoCo sim
3
+ * endpoint (`/api/ai/vla/41/sim`). Both `icoa sim <scenario>` and
4
+ * `icoa demo2`'s parameter mode hit the same render pipeline; the
5
+ * cooldown lives in a file so they share one budget.
6
+ *
7
+ * Why: render is CPU-heavy shared infrastructure. 60s/student keeps
8
+ * concurrent load survivable.
9
+ *
10
+ * Two additional fields piggy-back on the same file so demo2 can detect
11
+ * what a user has already experienced (bundled dance / interactive arm).
12
+ * These do NOT gate anything — they are continuity signals only.
13
+ */
14
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
15
+ import { join } from 'node:path';
16
+ import { homedir } from 'node:os';
17
+ export const COOLDOWN_SECONDS = 60;
18
+ const COOLDOWN_FILE = join(homedir(), '.icoa', 'sim-cooldown.json');
19
+ function readFile() {
20
+ try {
21
+ const raw = readFileSync(COOLDOWN_FILE, 'utf-8');
22
+ const j = JSON.parse(raw);
23
+ return typeof j === 'object' && j !== null ? j : {};
24
+ }
25
+ catch {
26
+ return {};
27
+ }
28
+ }
29
+ function writeMerge(patch) {
30
+ try {
31
+ mkdirSync(join(homedir(), '.icoa'), { recursive: true });
32
+ const merged = { ...readFile(), ...patch };
33
+ writeFileSync(COOLDOWN_FILE, JSON.stringify(merged));
34
+ }
35
+ catch {
36
+ // Non-fatal: cooldown is convenience, not security
37
+ }
38
+ }
39
+ export function lastSimAt() {
40
+ const f = readFile();
41
+ return typeof f.lastCallAt === 'number' ? f.lastCallAt : 0;
42
+ }
43
+ export function markSimAt(t) {
44
+ writeMerge({ lastCallAt: t });
45
+ }
46
+ /** Seconds remaining (0 if clear). */
47
+ export function cooldownRemaining() {
48
+ const since = (Date.now() - lastSimAt()) / 1000;
49
+ return Math.max(0, Math.ceil(COOLDOWN_SECONDS - since));
50
+ }
51
+ // ─── Continuity signals (not used for gating) ────────────────────────────
52
+ export function lastBundledAt() {
53
+ const f = readFile();
54
+ return typeof f.lastBundledAt === 'number' ? f.lastBundledAt : 0;
55
+ }
56
+ export function markBundledAt(t) {
57
+ writeMerge({ lastBundledAt: t });
58
+ }
59
+ export function lastArmAt() {
60
+ const f = readFile();
61
+ return typeof f.lastArmAt === 'number' ? f.lastArmAt : 0;
62
+ }
63
+ export function markArmAt(t) {
64
+ writeMerge({ lastArmAt: t });
65
+ }
66
+ /**
67
+ * Has the user seen any sim render — bundled, server, or arm — at least once?
68
+ * Used by demo2 + boot screen to switch from "first time" to "continue" copy.
69
+ */
70
+ export function hasAnySimHistory() {
71
+ const f = readFile();
72
+ return ((typeof f.lastBundledAt === 'number' && f.lastBundledAt > 0) ||
73
+ (typeof f.lastCallAt === 'number' && f.lastCallAt > 0) ||
74
+ (typeof f.lastArmAt === 'number' && f.lastArmAt > 0));
75
+ }
package/dist/lib/theme.js CHANGED
@@ -1 +1,119 @@
1
- const e="]110]111]112",t="";function s(){if(!process.stdout.isTTY)return!1;const e=process.stdout.getColorDepth?.();return"number"!=typeof e||e>=8}function r(){return"1"===process.env.ICOA_TERMINAL}let o=!1;export function setTerminalTheme(n="dark"){if(!s())return;if(r())return;const c="high-contrast"===n?"]10;#FFFFFF]11;#000000]12;#FFFFFF":"]10;#A9B7C6]11;#2B2B2B]12;#A9B7C6",i="Apple_Terminal"===process.env.TERM_PROGRAM?"high-contrast"===n?"":"":"high-contrast"===n?"":"";if(process.stdout.write(c+i),!o){o=!0;const s=()=>{try{process.stdout.write(e+t)}catch{}};process.on("exit",s),process.on("SIGINT",()=>{s(),process.exit(130)}),process.on("SIGTERM",()=>{s(),process.exit(143)}),process.on("SIGHUP",()=>{s(),process.exit(129)})}}export function resetTerminalTheme(){s()&&(r()||process.stdout.write(e+t))}
1
+ // Unified Darcula terminal theme works across macOS Terminal.app, iTerm2,
2
+ // GNOME Terminal, Konsole, Windows Terminal (cmd/PowerShell/WSL).
3
+ //
4
+ // Three mechanisms are combined so every modern terminal gets the best it can:
5
+ //
6
+ // 1. OSC 10/11/12 sets the terminal's *default* fg/bg/cursor colors.
7
+ // Honored by iTerm2, GNOME Terminal, Konsole, Windows Terminal → lossless
8
+ // background, no scrollback or resize artifacts. Ignored by Terminal.app.
9
+ //
10
+ // 2. SGR 38;2/48;2 + \x1b[2J paints with 24-bit truecolor Darcula on every
11
+ // terminal that supports truecolor. This is the default path.
12
+ //
13
+ // 3. SGR 38;5/48;5 + \x1b[2J paints with 256-color approximation on macOS
14
+ // Terminal.app. Terminal.app does NOT support truecolor SGR and mis-parses
15
+ // `\x1b[48;2;43;43;43m` as a sequence of 16-color codes — the trailing
16
+ // `43` becomes ANSI "bg yellow", which is why v2.19.23/24 rendered with a
17
+ // yellow background there. Color 235 ≈ #262626 (dark gray, ~#2B2B2B) and
18
+ // color 250 ≈ #BCBCBC (light gray, ~#A9B7C6) are close enough.
19
+ //
20
+ // Legacy cmd.exe (pre-Win10 1809) can't run Node 22 anyway, so no separate
21
+ // fallback path is needed.
22
+ const OSC_INIT_DARK = '\x1b]10;#A9B7C6\x07' + // default fg
23
+ '\x1b]11;#2B2B2B\x07' + // default bg
24
+ '\x1b]12;#A9B7C6\x07'; // cursor color
25
+ // High-contrast: pure black bg + pure white fg. For students with low vision
26
+ // or screens where Darcula's subtle grays wash out (e.g., projectors, cheap
27
+ // LCDs under fluorescent light). Still works with existing chalk colors —
28
+ // cyan/green/yellow/red all show up clearly against pure black.
29
+ const OSC_INIT_HC = '\x1b]10;#FFFFFF\x07' + '\x1b]11;#000000\x07' + '\x1b]12;#FFFFFF\x07';
30
+ const OSC_RESET = '\x1b]110\x07' + // reset default fg
31
+ '\x1b]111\x07' + // reset default bg
32
+ '\x1b]112\x07'; // reset cursor color
33
+ const SGR_INIT_TRUECOLOR_DARK = '\x1b[38;2;169;183;198m' + // fg #A9B7C6
34
+ '\x1b[48;2;43;43;43m' + // bg #2B2B2B
35
+ '\x1b[2J' +
36
+ '\x1b[H';
37
+ const SGR_INIT_256_DARK = '\x1b[38;5;250m' + // fg ≈ #BCBCBC
38
+ '\x1b[48;5;235m' + // bg ≈ #262626
39
+ '\x1b[2J' +
40
+ '\x1b[H';
41
+ const SGR_INIT_TRUECOLOR_HC = '\x1b[38;2;255;255;255m' + // fg pure white
42
+ '\x1b[48;2;0;0;0m' + // bg pure black
43
+ '\x1b[2J' +
44
+ '\x1b[H';
45
+ const SGR_INIT_256_HC = '\x1b[38;5;231m' + // fg white (231 = pure white in 256)
46
+ '\x1b[48;5;16m' + // bg black (16 = pure black in 256)
47
+ '\x1b[2J' +
48
+ '\x1b[H';
49
+ const SGR_RESET = '\x1b[0m\x1b[2J\x1b[H';
50
+ function supportsAnsi() {
51
+ if (!process.stdout.isTTY)
52
+ return false;
53
+ const depth = process.stdout.getColorDepth?.();
54
+ if (typeof depth === 'number')
55
+ return depth >= 8;
56
+ return true;
57
+ }
58
+ // When icoa-cli runs inside the ICOA Terminal (Tauri + xterm.js), the host is
59
+ // already pre-themed to the exact Darcula palette we'd be setting. Every OSC
60
+ // and SGR we'd emit is a no-op in terms of color, but the \x1b[2J inside our
61
+ // init/reset sequences would clear the grid visibly. Skip the paint entirely
62
+ // in that environment so the banner simply appears in the shell cursor
63
+ // position and scrollback is preserved on exit.
64
+ function isIcoaTerminal() {
65
+ return process.env.ICOA_TERMINAL === '1';
66
+ }
67
+ // macOS Terminal.app does not implement SGR truecolor (\x1b[38;2;… / \x1b[48;2;…)
68
+ // and mis-parses those sequences as 16-color codes, producing e.g. a yellow bg.
69
+ // Detect it and fall back to 256-color SGR which Terminal.app handles correctly.
70
+ function isAppleTerminal() {
71
+ return process.env.TERM_PROGRAM === 'Apple_Terminal';
72
+ }
73
+ let armed = false;
74
+ export function setTerminalTheme(variant = 'dark') {
75
+ if (!supportsAnsi())
76
+ return;
77
+ if (isIcoaTerminal())
78
+ return; // host is already Darcula; nothing to do
79
+ const osc = variant === 'high-contrast' ? OSC_INIT_HC : OSC_INIT_DARK;
80
+ const sgr = isAppleTerminal()
81
+ ? variant === 'high-contrast'
82
+ ? SGR_INIT_256_HC
83
+ : SGR_INIT_256_DARK
84
+ : variant === 'high-contrast'
85
+ ? SGR_INIT_TRUECOLOR_HC
86
+ : SGR_INIT_TRUECOLOR_DARK;
87
+ process.stdout.write(osc + sgr);
88
+ if (!armed) {
89
+ armed = true;
90
+ // Belt-and-braces cleanup on every exit path. Without these, Ctrl+C leaves
91
+ // the user's shell stuck with our SGR state.
92
+ const cleanup = () => {
93
+ try {
94
+ process.stdout.write(OSC_RESET + SGR_RESET);
95
+ }
96
+ catch { }
97
+ };
98
+ process.on('exit', cleanup);
99
+ process.on('SIGINT', () => {
100
+ cleanup();
101
+ process.exit(130);
102
+ });
103
+ process.on('SIGTERM', () => {
104
+ cleanup();
105
+ process.exit(143);
106
+ });
107
+ process.on('SIGHUP', () => {
108
+ cleanup();
109
+ process.exit(129);
110
+ });
111
+ }
112
+ }
113
+ export function resetTerminalTheme() {
114
+ if (!supportsAnsi())
115
+ return;
116
+ if (isIcoaTerminal())
117
+ return; // nothing to undo
118
+ process.stdout.write(OSC_RESET + SGR_RESET);
119
+ }
@@ -1 +1,74 @@
1
- export const CROCKFORD="0123456789ABCDEFGHJKMNPQRSTVWXYZ";const o=new Set(CROCKFORD),e={};for(let n=0;n<32;n++)e[CROCKFORD[n]]=n;export const PREFIX_LEN=2;export const BODY_RANDOM_LEN=7;export const TOKEN_LEN=10;const t={O:"0",I:"1",L:"1",U:"V"};export function normalizeConfusables(o){let e="";for(const n of o.toUpperCase())e+=t[n]??n;return e}export function normalizeTokenBody(o){const e=o.trim().toUpperCase();return e.length<=2?e:e.slice(0,2)+normalizeConfusables(e.slice(2))}export function checksumChar(o){let t=0;for(const n of o)t+=e[n]??0;return CROCKFORD[t%32]}export function validBody(e){if(8!==e.length)return!1;for(const t of e)if(!o.has(t))return!1;return checksumChar(e.slice(0,7))===e[7]}export function validTokenChecksum(o){const e=o.trim().toUpperCase();return 10===e.length&&validBody(e.slice(2))}
1
+ /**
2
+ * Client mirror of panda/token_alphabet.py — the confusable-free token rule.
3
+ *
4
+ * Tokens are <2-char prefix> + <7 random Crockford chars> + <1 checksum char>.
5
+ * Crockford Base32 excludes I, L, O, U (look like 1, 1, 0, V) so a token copied
6
+ * off a PDF or dictated aloud can't be mistranscribed.
7
+ *
8
+ * Two helpers used at token entry:
9
+ * - normalizeTokenBody(): fix look-alikes a human typed (O→0, I/L→1, U→V) in
10
+ * the BODY only (prefix is a fixed track/country code, may contain I/O).
11
+ * - validTokenChecksum(): verify the trailing Crockford checksum.
12
+ *
13
+ * IMPORTANT backward-compat note: tokens issued before 2026-06-07 used the full
14
+ * 36-char alphabet and have NO valid checksum (and ~61% of learn tokens contain
15
+ * a real I/L/O/U in the body). So on the LEARN path normalization must be a
16
+ * FALLBACK (try literal first) and checksum must NEVER hard-block — the server
17
+ * stays authoritative (BUG-001). Exam tokens have always been Crockford+checksum
18
+ * so both helpers are safe to apply directly there.
19
+ */
20
+ export const CROCKFORD = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
21
+ const CROCKFORD_SET = new Set(CROCKFORD);
22
+ const CROCKFORD_INDEX = {};
23
+ for (let i = 0; i < CROCKFORD.length; i++)
24
+ CROCKFORD_INDEX[CROCKFORD[i]] = i;
25
+ export const PREFIX_LEN = 2;
26
+ export const BODY_RANDOM_LEN = 7;
27
+ export const TOKEN_LEN = PREFIX_LEN + BODY_RANDOM_LEN + 1; // 10
28
+ // Look-alikes a human types → their Crockford canonical char.
29
+ const CONFUSABLE_MAP = { O: '0', I: '1', L: '1', U: 'V' };
30
+ /** Replace confusable letters in a string (uppercased first). */
31
+ export function normalizeConfusables(s) {
32
+ let out = '';
33
+ for (const ch of s.toUpperCase())
34
+ out += CONFUSABLE_MAP[ch] ?? ch;
35
+ return out;
36
+ }
37
+ /**
38
+ * Normalize a token's BODY only — the 2-char prefix is a fixed track/country
39
+ * code (e.g. EI / IO legitimately contain I/O) and must NOT be transformed.
40
+ * Returns the trimmed, upper-cased token with body confusables canonicalized.
41
+ */
42
+ export function normalizeTokenBody(token) {
43
+ const t = token.trim().toUpperCase();
44
+ if (t.length <= PREFIX_LEN)
45
+ return t;
46
+ return t.slice(0, PREFIX_LEN) + normalizeConfusables(t.slice(PREFIX_LEN));
47
+ }
48
+ /** 1-char Crockford mod-32 checksum over the payload chars. */
49
+ export function checksumChar(payload) {
50
+ let total = 0;
51
+ for (const ch of payload)
52
+ total += CROCKFORD_INDEX[ch] ?? 0;
53
+ return CROCKFORD[total % 32];
54
+ }
55
+ /** True iff `body` is 7 Crockford chars + 1 correct checksum char. */
56
+ export function validBody(body) {
57
+ if (body.length !== BODY_RANDOM_LEN + 1)
58
+ return false;
59
+ for (const ch of body)
60
+ if (!CROCKFORD_SET.has(ch))
61
+ return false;
62
+ return checksumChar(body.slice(0, BODY_RANDOM_LEN)) === body[BODY_RANDOM_LEN];
63
+ }
64
+ /**
65
+ * Whole-token checksum check for a 10-char `<prefix><body>` token. Returns
66
+ * false for old (pre-checksum) tokens — only meaningful as a *soft* signal on
67
+ * the learn path; authoritative only for exam tokens.
68
+ */
69
+ export function validTokenChecksum(token) {
70
+ const t = token.trim().toUpperCase();
71
+ if (t.length !== TOKEN_LEN)
72
+ return false;
73
+ return validBody(t.slice(PREFIX_LEN));
74
+ }
@@ -1 +1,48 @@
1
- import{createHash as t}from"node:crypto";import{readFileSync as o,writeFileSync as r}from"node:fs";import{join as e}from"node:path";import{platform as n}from"node:os";import{getIcoaDir as s}from"./config.js";const h=()=>e(s(),"toolset-hash.json");export function computeAndCacheToolsetHash(o){const e=o.map(t=>`${t.name}=${(t.version||"").trim()}`).sort().join("\n"),s=t("sha256").update(e).digest("hex").slice(0,16),a={hash:s,platform:n(),count:o.length,computedAt:(new Date).toISOString()};try{r(h(),JSON.stringify(a))}catch{}return s}export function getCachedToolsetHash(){try{const t=JSON.parse(o(h(),"utf-8"));return"string"==typeof t.hash&&t.hash?t.hash:void 0}catch{return}}
1
+ import { createHash } from 'node:crypto';
2
+ import { readFileSync, writeFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { platform } from 'node:os';
5
+ import { getIcoaDir } from './config.js';
6
+ // T3 — environment fingerprint (FORENSIC, not a gate).
7
+ //
8
+ // Hashes the {name=version} of the sanctioned toolset detected on this host.
9
+ // Reported at exam start and stored server-side so a reviewer can compare
10
+ // environments across students (and spot a session whose env fingerprint
11
+ // changed). Honest limits: this proves which sanctioned tools/versions are
12
+ // PRESENT — it does NOT prove the ABSENCE of forbidden tools, and the expected
13
+ // value differs per platform (mac/ubuntu/WSL ship different versions). So it is
14
+ // a comparison fingerprint, never a pass/fail check. See icoa2026event/07.
15
+ const CACHE = () => join(getIcoaDir(), 'toolset-hash.json');
16
+ /** Compute the hash over (name=version) pairs, persist it, return the hash.
17
+ * Called once at the end of `env setup` so the exam-start path only READS. */
18
+ export function computeAndCacheToolsetHash(entries) {
19
+ const norm = entries
20
+ .map((e) => `${e.name}=${(e.version || '').trim()}`)
21
+ .sort()
22
+ .join('\n');
23
+ const hash = createHash('sha256').update(norm).digest('hex').slice(0, 16);
24
+ const rec = {
25
+ hash,
26
+ platform: platform(),
27
+ count: entries.length,
28
+ computedAt: new Date().toISOString(),
29
+ };
30
+ try {
31
+ writeFileSync(CACHE(), JSON.stringify(rec));
32
+ }
33
+ catch {
34
+ /* best-effort — a missing cache just means no env fingerprint is reported */
35
+ }
36
+ return hash;
37
+ }
38
+ /** Read the cached env-fingerprint hash. Returns undefined if `env setup` has
39
+ * not run (so the exam start path never blocks or spawns 30 version probes). */
40
+ export function getCachedToolsetHash() {
41
+ try {
42
+ const rec = JSON.parse(readFileSync(CACHE(), 'utf-8'));
43
+ return typeof rec.hash === 'string' && rec.hash ? rec.hash : undefined;
44
+ }
45
+ catch {
46
+ return undefined;
47
+ }
48
+ }