icoa-cli 2.19.347 → 2.19.349
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/ai4ctf.js +1 -1
- package/dist/commands/aienv.js +1 -1
- package/dist/commands/arena-eai.js +1 -1
- package/dist/commands/connect.js +1 -1
- package/dist/commands/ctf.js +1 -1
- package/dist/commands/ctf4ai-demo.js +1 -1
- package/dist/commands/ctf4vla.js +1 -1
- package/dist/commands/exam.js +1 -1
- package/dist/index.js +355 -1
- package/dist/lib/access.js +184 -1
- package/dist/lib/aienv.d.ts +9 -0
- package/dist/lib/aienv.js +1 -1
- package/dist/lib/arena-submit.js +21 -1
- package/dist/lib/budget.js +6 -1
- package/dist/lib/challenge-dir.js +16 -1
- package/dist/lib/comms.js +212 -1
- package/dist/lib/config.js +93 -1
- package/dist/lib/country-lang.js +39 -1
- package/dist/lib/demo-exam.js +478 -1
- package/dist/lib/demo-flags.js +27 -1
- package/dist/lib/demo-stats.js +62 -1
- package/dist/lib/demo2-progress.js +102 -1
- package/dist/lib/exam-client.js +54 -1
- package/dist/lib/exam-state.js +273 -1
- package/dist/lib/gemini.js +247 -1
- package/dist/lib/integrity-snapshot.js +88 -1
- package/dist/lib/interactive-spawn.js +55 -1
- package/dist/lib/ipynb-input.js +65 -1
- package/dist/lib/kernel-protocol.js +88 -1
- package/dist/lib/kernel.js +146 -2
- package/dist/lib/learn-curricula.js +309 -1
- package/dist/lib/learn-i18n.js +184 -1
- package/dist/lib/log-sync.js +155 -1
- package/dist/lib/logger.js +49 -1
- package/dist/lib/open-file.js +55 -1
- package/dist/lib/paper-upgrade.js +119 -1
- package/dist/lib/render-card.js +112 -1
- package/dist/lib/repl-asker.js +67 -1
- package/dist/lib/sample-runner.js +227 -1
- package/dist/lib/shell-split.js +69 -1
- package/dist/lib/sim-cooldown.js +75 -1
- package/dist/lib/theme.js +119 -1
- package/dist/lib/token-format.js +74 -1
- package/dist/lib/toolset-hash.js +48 -1
- package/dist/lib/translations-fetcher.js +95 -1
- package/dist/lib/ui.js +99 -1
- package/dist/lib/version.js +24 -1
- package/dist/postinstall.js +48 -1
- package/dist/repl.js +2251 -1
- package/dist/types/index.js +63 -1
- package/package.json +1 -1
|
@@ -1 +1,227 @@
|
|
|
1
|
-
|
|
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
|
+
}
|
package/dist/lib/shell-split.js
CHANGED
|
@@ -1 +1,69 @@
|
|
|
1
|
-
|
|
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
|
+
}
|
package/dist/lib/sim-cooldown.js
CHANGED
|
@@ -1 +1,75 @@
|
|
|
1
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/lib/token-format.js
CHANGED
|
@@ -1 +1,74 @@
|
|
|
1
|
-
|
|
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
|
+
}
|
package/dist/lib/toolset-hash.js
CHANGED
|
@@ -1 +1,48 @@
|
|
|
1
|
-
import
|
|
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
|
+
}
|