@stemtrooper/learningcode 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,49 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 TLC (The Learning Curve) / stemtrooper
4
+
5
+ learningcode is a distribution wrapper. It does not contain source copied from
6
+ Pi; it depends on @earendil-works/pi-coding-agent at runtime.
7
+
8
+ That dependency is MIT licensed:
9
+
10
+ MIT License
11
+ Copyright (c) Mario Zechner (and contributors)
12
+
13
+ Permission is hereby granted, free of charge, to any person obtaining a copy
14
+ of this software and associated documentation files (the "Software"), to deal
15
+ in the Software without restriction, including without limitation the rights
16
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
17
+ copies of the Software, and to permit persons to whom the Software is
18
+ furnished to do so, subject to the following conditions:
19
+
20
+ The above copyright notice and this permission notice shall be included in all
21
+ copies or substantial portions of the Software.
22
+
23
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
24
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
25
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
26
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
27
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
28
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
29
+ SOFTWARE.
30
+
31
+ The MIT licence terms below govern learningcode itself.
32
+
33
+ Permission is hereby granted, free of charge, to any person obtaining a copy
34
+ of this software and associated documentation files (the "Software"), to deal
35
+ in the Software without restriction, including without limitation the rights
36
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
37
+ copies of the Software, and to permit persons to whom the Software is
38
+ furnished to do so, subject to the following conditions:
39
+
40
+ The above copyright notice and this permission notice shall be included in all
41
+ copies or substantial portions of the Software.
42
+
43
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
44
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
45
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
46
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
47
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
48
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
49
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,91 @@
1
+ # learningcode
2
+
3
+ A terminal coding agent for TLC students, wired to [Spark](../spark) — the
4
+ inference gateway that holds per-student tokens, timetable policy, seat limits
5
+ and weekly quota.
6
+
7
+ Students run one command:
8
+
9
+ ```bash
10
+ npm i -g @stemtrooper/learningcode
11
+ learningcode
12
+ ```
13
+
14
+ On first run it asks for the personal `spark_live_` token from the Spark bench,
15
+ caches it with `0600`, writes the TLC-Spark provider config, and hands off to
16
+ Pi with the model pinned.
17
+
18
+ ```
19
+ baseURL https://spark.learning.com.my/v1
20
+ model TLC-Spark
21
+ ```
22
+
23
+ ## What students get
24
+
25
+ Pi's four built-in tools — `read`, `bash`, `edit`, `write` — against
26
+ Qwen3.8-27B. Two extra commands, because Spark is not a plain OpenAI endpoint:
27
+
28
+ | Command | Why it exists |
29
+ |---|---|
30
+ | `/quota` | Tokens used against today's limit, and whether AI is enabled for your account |
31
+ | `/seats` | Live seat queue, so a student can see why a turn is waiting |
32
+
33
+ `session_start` also warns once if you cross 80% of your daily quota or if a
34
+ teacher has switched AI off for your account.
35
+
36
+ ## Why not just ship Pi?
37
+
38
+ Because Spark's limits are the design constraint, not the model:
39
+
40
+ - **32K max context, 16K default.** Agent sessions burn this fast. `learningcode`
41
+ starts Pi with `--no-skills`, since skills are pure prompt-token spend.
42
+ - **One active generation per student.** Subagents and parallel tool calls would
43
+ only earn `429`s. Pi is serial by default and this wrapper does not turn that off.
44
+ - **250K tokens per week.** Frugality is the product, so the budget is visible
45
+ instead of mysterious.
46
+
47
+ Stock Pi does not know `/v1/me/quota` or `/v1/queue` exist. That gap is the whole
48
+ reason this wrapper exists.
49
+
50
+ ## Configuration
51
+
52
+ `~/.learningcode/agent/models.json` is created on first run and **never
53
+ overwritten** — local edits survive upgrades. The provider is seeded with these
54
+ compatibility flags, each of which matches a field Spark either rejects or drops:
55
+
56
+ | Flag | Value | Spark behaviour |
57
+ |---|---|---|
58
+ | `supportsDeveloperRole` | `false` | message union is `system \| user \| assistant \| tool`; there is no `developer` |
59
+ | `maxTokensField` | `"max_tokens"` | `v1.ts` reads `body.max_tokens` only |
60
+ | `supportsReasoningEffort` | `false` | not forwarded to the runtime |
61
+ | `supportsStore` | `false` | not implemented |
62
+
63
+ Environment variables:
64
+
65
+ | Variable | Purpose |
66
+ |---|---|
67
+ | `LEARNINGCODE_DIR` | agent directory (default `~/.learningcode/agent`) |
68
+ | `LEARNINGCODE_SPARK_BASE_URL` | point at another Spark deployment |
69
+ | `LEARNINGCODE_TOKEN` | Spark token, skips the cached file |
70
+ | `LEARNINGCODE_PI_FLAGS` | extra flags appended to every launch |
71
+
72
+ ```bash
73
+ learningcode --show-config # resolved paths, model, token prefix
74
+ learningcode --base-url http://localhost:3000/v1 # local bench
75
+ learningcode --login # re-enter a rotated token
76
+ learningcode -c # continue last session
77
+ learningcode -p "explain main.py"
78
+ learningcode --mode json # machine-readable event stream
79
+ ```
80
+
81
+ Anything not listed as a `learningcode` flag is passed straight to Pi.
82
+
83
+ ## Pinning
84
+
85
+ `@earendil-works/pi-coding-agent` is pinned to an exact version, not a range. Pi
86
+ ships breaking changes daily, and a student's install must not change underneath
87
+ them mid-term. Bump it deliberately, after testing against a live pod.
88
+
89
+ ## Licence
90
+
91
+ MIT. Depends on Pi, also MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,310 @@
1
+ #!/usr/bin/env node
2
+ import { spawn } from "node:child_process";
3
+ import { existsSync } from "node:fs";
4
+ import { createRequire } from "node:module";
5
+ import { dirname, join } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import {
8
+ MODEL_ID,
9
+ PI_AGENT_DIR_ENV,
10
+ PROVIDER_ID,
11
+ TOKEN_ENV,
12
+ UserError,
13
+ agentDir,
14
+ sparkBaseUrl,
15
+ } from "../lib/config.mjs";
16
+ import { ensureSparkProvider, modelsPath, retargetProvider } from "../lib/models.mjs";
17
+ import { hasToken, looksLikeToken, resolveToken, writeCachedToken } from "../lib/token.mjs";
18
+
19
+ const here = dirname(fileURLToPath(import.meta.url));
20
+ const require = createRequire(import.meta.url);
21
+
22
+ const OWN_FLAGS = new Set(["--help", "-h", "--version", "-v", "--login", "--show-config"]);
23
+ const OWN_FLAGS_WITH_VALUE = new Set(["--token", "--base-url"]);
24
+
25
+ const HELP = `learningcode - TLC Spark coding agent
26
+
27
+ Usage
28
+ learningcode [options] [pi options] [@files...] [message...]
29
+
30
+ Setup
31
+ --token <tok> Use this Spark token instead of the cached one
32
+ --base-url <url> Point TLC-Spark at a different Spark deployment
33
+ --login Re-enter and cache your Spark token
34
+ --show-config Print resolved paths and settings, then exit
35
+ -h, --help Show this help
36
+ -v, --version Show version
37
+
38
+ Everything else is passed straight through to Pi, so the usual flags work:
39
+
40
+ learningcode -c continue the last session
41
+ learningcode -p "explain main.py" one-shot, non-interactive
42
+ learningcode --mode json machine-readable event stream
43
+ learningcode --list-models every model Pi can reach
44
+
45
+ Other providers
46
+ Any --model other than tlc-spark/... skips the Spark token and the Spark
47
+ health check, so you can work without a token or while Spark is down:
48
+
49
+ export OPENCODE_API_KEY=...
50
+ learningcode --model opencode-go/glm-5.3-flash
51
+ learningcode --list-models opencode
52
+
53
+ Environment
54
+ LEARNINGCODE_DIR agent directory (default ~/.learningcode/agent)
55
+ LEARNINGCODE_SPARK_BASE_URL default https://spark.learning.com.my/v1
56
+ LEARNINGCODE_TOKEN Spark token, skips the cached file
57
+ LEARNINGCODE_PI_FLAGS extra flags appended to every Pi launch
58
+ OPENCODE_API_KEY auth for the opencode-go / opencode-zen providers
59
+ ${PI_AGENT_DIR_ENV} set automatically; points Pi at the learningcode agent dir
60
+ `;
61
+
62
+ /** Split our flags from the ones meant for Pi. */
63
+ function parseArgs(argv) {
64
+ const own = { help: false, version: false, login: false, showConfig: false };
65
+ const toPi = [];
66
+ let explicitToken;
67
+ let baseUrl;
68
+
69
+ for (let i = 0; i < argv.length; i += 1) {
70
+ const arg = argv[i];
71
+
72
+ if (OWN_FLAGS.has(arg)) {
73
+ if (arg === "--help" || arg === "-h") own.help = true;
74
+ if (arg === "--version" || arg === "-v") own.version = true;
75
+ if (arg === "--login") own.login = true;
76
+ if (arg === "--show-config") own.showConfig = true;
77
+ continue;
78
+ }
79
+
80
+ if (OWN_FLAGS_WITH_VALUE.has(arg)) {
81
+ const value = argv[i + 1];
82
+ if (!value || value.startsWith("-")) {
83
+ fail(`${arg} requires a value.`);
84
+ }
85
+ i += 1;
86
+ if (arg === "--token") explicitToken = value;
87
+ if (arg === "--base-url") baseUrl = value;
88
+ continue;
89
+ }
90
+
91
+ // Support --token=value / --base-url=value.
92
+ const eq = arg.indexOf("=");
93
+ if (arg.startsWith("--") && eq > 0) {
94
+ const name = arg.slice(0, eq);
95
+ if (OWN_FLAGS_WITH_VALUE.has(name)) {
96
+ if (name === "--token") explicitToken = arg.slice(eq + 1);
97
+ if (name === "--base-url") baseUrl = arg.slice(eq + 1);
98
+ continue;
99
+ }
100
+ }
101
+
102
+ toPi.push(arg);
103
+ }
104
+
105
+ return { own, toPi, explicitToken, baseUrl };
106
+ }
107
+
108
+ /** The model spec the caller asked for, in either `--model X` or `--model=X` form. */
109
+ function readModel(toPi) {
110
+ for (let i = 0; i < toPi.length; i += 1) {
111
+ if (toPi[i] === "--model" && toPi[i + 1]) return toPi[i + 1];
112
+ if (toPi[i].startsWith("--model=")) return toPi[i].slice("--model=".length);
113
+ }
114
+ return undefined;
115
+ }
116
+
117
+ function fail(message, code = 1) {
118
+ process.stderr.write(`learningcode: ${message}\n`);
119
+ process.exit(code);
120
+ }
121
+
122
+ /**
123
+ * Locate Pi's bundled CLI entry. Going through the resolved package entry keeps
124
+ * this working on Windows, where the .bin shim is a .cmd file that cannot be
125
+ * spawned directly without a shell.
126
+ *
127
+ * Must use `import.meta.resolve`: the published package declares only an
128
+ * `import` condition in its exports map, so `require.resolve` fails with
129
+ * ERR_PACKAGE_PATH_NOT_EXPORTED.
130
+ */
131
+ function resolvePiEntry() {
132
+ const candidates = [];
133
+
134
+ try {
135
+ const entry = fileURLToPath(import.meta.resolve("@earendil-works/pi-coding-agent"));
136
+ candidates.push(join(dirname(entry), "bundle", "cli.js"));
137
+ } catch {
138
+ /* fall through to the node_modules walk below */
139
+ }
140
+
141
+ // Walk up from this file in case resolution was blocked (odd global layouts,
142
+ // pnpm-style stores, a bundled single-file install).
143
+ let dir = here;
144
+ for (let depth = 0; depth < 8; depth += 1) {
145
+ candidates.push(
146
+ join(dir, "node_modules", "@earendil-works", "pi-coding-agent", "dist", "bundle", "cli.js"),
147
+ );
148
+ const parent = dirname(dir);
149
+ if (parent === dir) break;
150
+ dir = parent;
151
+ }
152
+
153
+ return candidates.find((candidate) => existsSync(candidate)) ?? null;
154
+ }
155
+
156
+ /** Non-fatal reachability probe so students get a useful message, not a stack trace. */
157
+ async function checkEndpoint(baseUrl, token) {
158
+ const controller = new AbortController();
159
+ const timer = setTimeout(() => controller.abort(), 5000);
160
+ try {
161
+ const response = await fetch(`${baseUrl}/models`, {
162
+ headers: { Authorization: `Bearer ${token}` },
163
+ signal: controller.signal,
164
+ });
165
+ if (response.ok) return { ok: true };
166
+ if (response.status === 401) return { ok: false, reason: "token rejected (401)" };
167
+ return { ok: false, reason: `HTTP ${response.status}` };
168
+ } catch (error) {
169
+ const reason = error?.name === "AbortError" ? "timed out after 5s" : error?.message;
170
+ return { ok: false, reason };
171
+ } finally {
172
+ clearTimeout(timer);
173
+ }
174
+ }
175
+
176
+ async function main() {
177
+ const argv = process.argv.slice(2);
178
+ const { own, toPi, explicitToken, baseUrl } = parseArgs(argv);
179
+
180
+ if (own.help) {
181
+ process.stdout.write(HELP);
182
+ return;
183
+ }
184
+
185
+ const { version } = require("../package.json");
186
+ if (own.version) {
187
+ process.stdout.write(`${version}\n`);
188
+ return;
189
+ }
190
+
191
+ const dir = agentDir();
192
+ const piEntry = resolvePiEntry();
193
+ if (!piEntry) {
194
+ fail(
195
+ "could not locate @earendil-works/pi-coding-agent.\n" +
196
+ "Reinstall with: npm i -g @stemtrooper/learningcode",
197
+ );
198
+ }
199
+
200
+ if (baseUrl) {
201
+ await ensureSparkProvider(dir).catch(() => {});
202
+ await retargetProvider(dir, baseUrl);
203
+ }
204
+ await ensureSparkProvider(dir);
205
+
206
+ // `--login` forces a fresh paste even when a token is already cached.
207
+ const forced = [];
208
+ const requestedModel = readModel(toPi);
209
+ const effectiveModel = requestedModel ?? `${PROVIDER_ID}/${MODEL_ID}`;
210
+ if (!requestedModel) forced.push("--model", effectiveModel);
211
+ // Spark enforces one active generation per student, so subagents and parallel
212
+ // tool calls would only earn 429s. Pi is serial by default; skills and rules
213
+ // are pure prompt-token spend against a 32K ceiling, so they stay off.
214
+ forced.push("--no-skills");
215
+ forced.push("--extension", join(here, "..", "extensions", "spark-quota.ts"));
216
+ if (process.env.LEARNINGCODE_PI_FLAGS) {
217
+ forced.push(...process.env.LEARNINGCODE_PI_FLAGS.split(/\s+/).filter(Boolean));
218
+ }
219
+
220
+ // Everything Spark-specific is skipped when another provider is selected, so
221
+ // `learningcode --model opencode-go/...` keeps working while Spark or your RunPod
222
+ // is down, and without holding a token. `--list-models` only enumerates what Pi
223
+ // can reach and makes no request, so it never needs a Spark token either.
224
+ const listsModels = toPi.includes("--list-models") || toPi.some((arg) => arg.startsWith("--list-models="));
225
+ const usesSpark = !listsModels && effectiveModel.startsWith(`${PROVIDER_ID}/`);
226
+ let token;
227
+ let tokenSource = "not required";
228
+
229
+ if (usesSpark) {
230
+ // With no token anywhere, a bare `learningcode` opens a prompt that looks like a
231
+ // dead end. Name the hosted escape hatch before asking.
232
+ if (!(await hasToken(dir, own.login ? undefined : explicitToken))) {
233
+ process.stderr.write(
234
+ "No Spark token configured, defaulting to " +
235
+ `${PROVIDER_ID}/${MODEL_ID}.\n` +
236
+ " Using OpenCode Go instead? learningcode --model opencode-go/glm-5.3-flash\n" +
237
+ " Full list: learningcode --list-models opencode-go\n",
238
+ );
239
+ }
240
+
241
+ const resolved = await resolveToken(dir, own.login ? undefined : explicitToken);
242
+ token = resolved.token;
243
+ tokenSource = resolved.source;
244
+
245
+ if (!looksLikeToken(token)) {
246
+ process.stderr.write(
247
+ 'learningcode: warning - token does not start with "spark_live_"; ' +
248
+ "it may not be a Spark token.\n",
249
+ );
250
+ }
251
+ if (resolved.shouldCache) await writeCachedToken(dir, token);
252
+ } else if (explicitToken) {
253
+ process.stderr.write(
254
+ `learningcode: --token only applies to ${PROVIDER_ID}; ignoring it for ${effectiveModel}.\n`,
255
+ );
256
+ }
257
+
258
+ if (own.showConfig) {
259
+ process.stdout.write(
260
+ `version ${version}\n` +
261
+ `agent dir ${dir}\n` +
262
+ `models.json ${modelsPath(dir)}\n` +
263
+ `model ${effectiveModel}\n` +
264
+ `spark base ${sparkBaseUrl()}\n` +
265
+ `spark token ${token ? `${token.slice(0, 12)}... (from ${tokenSource})` : "not required"}\n` +
266
+ `pi entry ${piEntry}\n`,
267
+ );
268
+ return;
269
+ }
270
+
271
+ if (usesSpark) {
272
+ const health = await checkEndpoint(sparkBaseUrl(), token);
273
+ if (!health.ok) {
274
+ process.stderr.write(
275
+ `learningcode: warning - Spark at ${sparkBaseUrl()} is not answering (${health.reason}).\n` +
276
+ "Continuing anyway; check your network or token if the first request fails.\n",
277
+ );
278
+ }
279
+ }
280
+
281
+ const child = spawn(process.execPath, [piEntry, ...forced, ...toPi], {
282
+ stdio: "inherit",
283
+ env: {
284
+ ...process.env,
285
+ [PI_AGENT_DIR_ENV]: dir,
286
+ SPARK_BASE_URL: sparkBaseUrl(),
287
+ // Unset rather than empty when not on Spark: Pi treats an empty key as
288
+ // configured for some providers, which would shadow OPENCODE_API_KEY.
289
+ ...(token ? { [TOKEN_ENV]: token } : {}),
290
+ },
291
+ });
292
+
293
+ // Forward interrupts so Ctrl+C in the terminal reaches Pi rather than
294
+ // orphaning it behind this wrapper.
295
+ for (const signal of ["SIGINT", "SIGTERM"]) {
296
+ process.on(signal, () => child.kill(signal));
297
+ }
298
+
299
+ child.on("error", (error) => fail(`failed to start Pi: ${error.message}`));
300
+ child.on("exit", (code, signal) => {
301
+ if (signal) process.kill(process.pid, signal);
302
+ else process.exit(code ?? 0);
303
+ });
304
+ }
305
+
306
+ main().catch((error) => {
307
+ // Anything the user can act on gets one line. A stack trace is noise for a
308
+ // student who mistyped a token; keep it for genuine bugs.
309
+ fail(error instanceof UserError ? error.message : error?.stack || String(error));
310
+ });
@@ -0,0 +1,113 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+
3
+ /**
4
+ * Spark-aware status for the learningcode client.
5
+ *
6
+ * Spark exposes two endpoints that no stock harness knows about, because they
7
+ * are not part of the OpenAI API:
8
+ *
9
+ * GET /v1/me/quota per-student weekly spend, job count, and whether the
10
+ * account is allowed to use AI at all right now
11
+ * GET /v1/queue live seat queue, so a student can see why a turn is waiting
12
+ *
13
+ * Both need the same personal token the agent already authenticates with, so we
14
+ * read it from the environment the launcher sets rather than asking again.
15
+ */
16
+
17
+ const BASE_URL = (process.env.SPARK_BASE_URL || "https://spark.learning.com.my/v1").replace(/\/+$/, "");
18
+ const TOKEN = process.env.SPARK_API_KEY || "";
19
+
20
+ async function call(path: string): Promise<{ ok: true; body: unknown } | { ok: false; error: string }> {
21
+ if (!TOKEN) return { ok: false, error: "no Spark token in the environment" };
22
+ try {
23
+ const response = await fetch(`${BASE_URL}${path}`, {
24
+ headers: { Authorization: `Bearer ${TOKEN}` },
25
+ });
26
+ if (!response.ok) return { ok: false, error: `HTTP ${response.status}` };
27
+ return { ok: true, body: await response.json() };
28
+ } catch (error) {
29
+ return { ok: false, error: error instanceof Error ? error.message : "unreachable" };
30
+ }
31
+ }
32
+
33
+ const num = (value: unknown): number => (typeof value === "number" && Number.isFinite(value) ? value : 0);
34
+
35
+ function quotaLine(body: Record<string, unknown>): string {
36
+ const used = num(body.tokensUsed);
37
+ const limit = num(body.dailyTokenLimit);
38
+ const pct = limit > 0 ? ` (${Math.round((used / limit) * 100)}%)` : "";
39
+ return [
40
+ `Spark quota - ${used.toLocaleString()}/${limit.toLocaleString()} tokens${pct}`,
41
+ `jobs ${num(body.jobsUsed)}/${num(body.dailyJobLimit)}`,
42
+ `day ${String(body.day ?? "?")}`,
43
+ body.aiEnabled === false ? "AI DISABLED for your account - ask your teacher" : "",
44
+ ]
45
+ .filter(Boolean)
46
+ .join(" | ");
47
+ }
48
+
49
+ /**
50
+ * The queue view shape is not guaranteed, so surface the fields we recognise
51
+ * and fall back to raw JSON rather than inventing structure.
52
+ */
53
+ function queueLine(body: unknown): string {
54
+ if (!body || typeof body !== "object") return `Spark queue: ${JSON.stringify(body)}`;
55
+ const record = body as Record<string, unknown>;
56
+
57
+ const recognised = ["position", "queued", "waiting", "depth", "active", "seats", "available"];
58
+ const known = Object.entries(record).filter(([key]) => recognised.includes(key));
59
+ if (known.length) {
60
+ return `Spark seats - ${known.map(([k, v]) => `${k} ${JSON.stringify(v)}`).join(", ")}`;
61
+ }
62
+ return `Spark seats - ${JSON.stringify(record).slice(0, 200)}`;
63
+ }
64
+
65
+ export default function sparkQuota(pi: ExtensionAPI) {
66
+ pi.registerCommand("quota", {
67
+ description: "Show your Spark token spend for today",
68
+ handler: async (_args, ctx) => {
69
+ const result = await call("/me/quota");
70
+ ctx.ui.notify(
71
+ result.ok ? quotaLine(result.body as Record<string, unknown>) : `quota unavailable: ${result.error}`,
72
+ result.ok ? "info" : "warning",
73
+ );
74
+ },
75
+ });
76
+
77
+ pi.registerCommand("seats", {
78
+ description: "Show how many Spark seats are free",
79
+ handler: async (_args, ctx) => {
80
+ const result = await call("/queue");
81
+ ctx.ui.notify(
82
+ result.ok ? queueLine(result.body) : `seat info unavailable: ${result.error}`,
83
+ result.ok ? "info" : "warning",
84
+ );
85
+ },
86
+ });
87
+
88
+ // One check at startup rather than after every turn: the quota moves by at
89
+ // most one request per turn, and a fetch on each `agent_end` would add
90
+ // latency to the thing students are waiting on.
91
+ pi.on("session_start", async (_event, ctx) => {
92
+ const result = await call("/me/quota");
93
+ if (!result.ok) return;
94
+ const body = result.body as Record<string, unknown>;
95
+
96
+ if (body.aiEnabled === false) {
97
+ ctx.ui.notify(
98
+ "AI is switched off for your Spark account. Ask your teacher, then run /quota.",
99
+ "warning",
100
+ );
101
+ return;
102
+ }
103
+
104
+ const used = num(body.tokensUsed);
105
+ const limit = num(body.dailyTokenLimit);
106
+ if (limit > 0 && used / limit >= 0.8) {
107
+ ctx.ui.notify(
108
+ `Heads up: ${Math.round((used / limit) * 100)}% of today's Spark quota is gone. Run /quota for detail.`,
109
+ "warning",
110
+ );
111
+ }
112
+ });
113
+ }
package/lib/config.mjs ADDED
@@ -0,0 +1,52 @@
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+
4
+ /**
5
+ * An error the user caused or can act on. The launcher prints these as a single
6
+ * line instead of a stack trace, because a student mistyping a token should not
7
+ * be shown a JavaScript call stack.
8
+ */
9
+ export class UserError extends Error {}
10
+
11
+ /**
12
+ * Spark exposes exactly one model. These identifiers must match
13
+ * `inference.model_id` in the spark repo's spark.config.yaml.
14
+ */
15
+ export const PROVIDER_ID = "tlc-spark";
16
+ export const MODEL_ID = "TLC-Spark";
17
+
18
+ /** Public Spark deployment. Override with --base-url or LEARNINGCODE_SPARK_BASE_URL. */
19
+ export const DEFAULT_BASE_URL = "https://spark.learning.com.my/v1";
20
+
21
+ /**
22
+ * Mirrors the Spark machine profile. `default_context_tokens` is 16384 and
23
+ * `max_context_tokens` is 32768 in spark.config.yaml; we advertise the ceiling
24
+ * so Pi's compaction triggers before Spark rejects the request.
25
+ */
26
+ export const CONTEXT_WINDOW = 32768;
27
+ export const DEFAULT_MAX_OUTPUT_TOKENS = 4096;
28
+
29
+ /** Env var that makes Pi read its config, models.json and sessions from our agent dir. */
30
+ export const PI_AGENT_DIR_ENV = "PI_CODING_AGENT_DIR";
31
+
32
+ /** Read by the provider config via `"apiKey": "$SPARK_API_KEY"`. */
33
+ export const TOKEN_ENV = "SPARK_API_KEY";
34
+
35
+ /** Where the personal `spark_live_` token is cached between runs. */
36
+ export const TOKEN_FILE = "spark-token";
37
+
38
+ /**
39
+ * Pi resolves its agent directory from PI_CODING_AGENT_DIR when set, so
40
+ * learningcode keeps its own config instead of sharing ~/.pi with a stock Pi
41
+ * install on the same machine.
42
+ */
43
+ export function agentDir() {
44
+ const override = process.env.LEARNINGCODE_DIR;
45
+ if (override) return override.replace(/^~(?=$|\/)/, homedir());
46
+ return join(homedir(), ".learningcode", "agent");
47
+ }
48
+
49
+ export function sparkBaseUrl() {
50
+ const raw = process.env.LEARNINGCODE_SPARK_BASE_URL || DEFAULT_BASE_URL;
51
+ return raw.replace(/\/+$/, "");
52
+ }
package/lib/models.mjs ADDED
@@ -0,0 +1,107 @@
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+ import {
4
+ CONTEXT_WINDOW,
5
+ DEFAULT_MAX_OUTPUT_TOKENS,
6
+ PROVIDER_ID,
7
+ MODEL_ID,
8
+ UserError,
9
+ sparkBaseUrl,
10
+ } from "./config.mjs";
11
+
12
+ export function modelsPath(agentDir) {
13
+ return join(agentDir, "models.json");
14
+ }
15
+
16
+ /**
17
+ * The TLC-Spark provider definition.
18
+ *
19
+ * Every `compat` flag here is load-bearing. They are not defensive padding --
20
+ * each one corresponds to a field that Spark's OpenAI-compatible layer either
21
+ * rejects or silently drops:
22
+ *
23
+ * - `supportsDeveloperRole: false` Spark's message union is
24
+ * `system | user | assistant | tool`. There is no `developer` variant, so a
25
+ * reasoning-capable client must send `system` instead or the turn is lost.
26
+ * - `maxTokensField: "max_tokens"` Spark reads `body.max_tokens` only, so
27
+ * `max_completion_tokens` would be ignored and the server default used.
28
+ * - `supportsReasoningEffort: false` `reasoning_effort` is not forwarded to
29
+ * the runtime, so sending it is a no-op that misleads Pi's level mapping.
30
+ * - `supportsStore: false` `store` is not implemented.
31
+ *
32
+ * `reasoning: false` because Spark bills chain-of-thought tokens but never
33
+ * forwards them to the client (`src/http/v1.ts` drops `chunk.reasoning`), so
34
+ * Pi would expose thinking controls that can never produce output.
35
+ */
36
+ export function providerDefinition() {
37
+ return {
38
+ name: "TLC Spark",
39
+ baseUrl: sparkBaseUrl(),
40
+ api: "openai-completions",
41
+ // Resolved by Pi from the environment, which the launcher sets from the
42
+ // cached personal token. Avoids writing the token into a config file.
43
+ apiKey: "$SPARK_API_KEY",
44
+ compat: {
45
+ supportsDeveloperRole: false,
46
+ supportsReasoningEffort: false,
47
+ maxTokensField: "max_tokens",
48
+ supportsStore: false,
49
+ },
50
+ models: [
51
+ {
52
+ id: MODEL_ID,
53
+ name: "TLC-Spark (Qwen3.8-27B)",
54
+ reasoning: false,
55
+ input: ["text"],
56
+ contextWindow: CONTEXT_WINDOW,
57
+ maxTokens: DEFAULT_MAX_OUTPUT_TOKENS,
58
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
59
+ },
60
+ ],
61
+ };
62
+ }
63
+
64
+ async function readModels(path) {
65
+ try {
66
+ const parsed = JSON.parse(await readFile(path, "utf-8"));
67
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed;
68
+ } catch (error) {
69
+ if (error?.code !== "ENOENT") {
70
+ throw new UserError(
71
+ `Could not parse ${path}: ${error.message}\nFix or delete the file, then rerun.`,
72
+ );
73
+ }
74
+ }
75
+ return {};
76
+ }
77
+
78
+ /**
79
+ * Ensure `models.json` describes TLC-Spark, without discarding edits a student
80
+ * may have made. An existing provider entry always wins; we only fill in the
81
+ * provider when it is absent, so a local experiment survives upgrades.
82
+ */
83
+ export async function ensureSparkProvider(agentDir) {
84
+ const path = modelsPath(agentDir);
85
+ const models = await readModels(path);
86
+
87
+ if (models.providers?.[PROVIDER_ID]) return { path, created: false };
88
+
89
+ models.providers = { ...models.providers, [PROVIDER_ID]: providerDefinition() };
90
+ await mkdir(dirname(path), { recursive: true });
91
+ await writeFile(path, `${JSON.stringify(models, null, 2)}\n`, "utf-8");
92
+ return { path, created: true };
93
+ }
94
+
95
+ /**
96
+ * Point an existing provider entry at a new Spark host. Used when a student
97
+ * moves between the local bench and the public deployment.
98
+ */
99
+ export async function retargetProvider(agentDir, baseUrl) {
100
+ const path = modelsPath(agentDir);
101
+ const models = await readModels(path);
102
+ const provider = models.providers?.[PROVIDER_ID];
103
+ if (!provider) throw new UserError(`${PROVIDER_ID} is not configured in ${path}`);
104
+ provider.baseUrl = baseUrl.replace(/\/+$/, "");
105
+ await writeFile(path, `${JSON.stringify(models, null, 2)}\n`, "utf-8");
106
+ return provider.baseUrl;
107
+ }
package/lib/token.mjs ADDED
@@ -0,0 +1,124 @@
1
+ import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+ import { createInterface } from "node:readline/promises";
4
+ import { TOKEN_ENV, TOKEN_FILE, UserError } from "./config.mjs";
5
+
6
+ const TOKEN_PREFIX = "spark_live_";
7
+
8
+ export function tokenPath(agentDir) {
9
+ return join(agentDir, TOKEN_FILE);
10
+ }
11
+
12
+ async function readCachedToken(agentDir) {
13
+ try {
14
+ const value = (await readFile(tokenPath(agentDir), "utf-8")).trim();
15
+ return value || undefined;
16
+ } catch (error) {
17
+ if (error?.code !== "ENOENT") throw error;
18
+ return undefined;
19
+ }
20
+ }
21
+
22
+ async function writeCachedToken(agentDir, token) {
23
+ const path = tokenPath(agentDir);
24
+ await mkdir(dirname(path), { recursive: true });
25
+ await writeFile(path, `${token}\n`, { encoding: "utf-8", mode: 0o600 });
26
+ // `mode` only applies at creation; tighten it if the file already existed.
27
+ await chmod(path, 0o600).catch(() => {});
28
+ }
29
+
30
+ /** Read a secret without echoing it. Falls back to a plain readline if raw mode is unavailable. */
31
+ async function promptSecret(prompt) {
32
+ const input = process.stdin;
33
+ const output = process.stdout;
34
+
35
+ if (!input.isTTY) {
36
+ const rl = createInterface({ input, output });
37
+ const answer = await rl.question(prompt);
38
+ rl.close();
39
+ return answer.trim();
40
+ }
41
+
42
+ process.stdout.write(prompt);
43
+ return new Promise((resolve) => {
44
+ const stdin = input;
45
+ stdin.setRawMode(true);
46
+ stdin.resume();
47
+ stdin.setEncoding("utf-8");
48
+ let value = "";
49
+
50
+ const onData = (chunk) => {
51
+ for (const char of chunk) {
52
+ switch (char) {
53
+ case "\r":
54
+ case "\n":
55
+ case "":
56
+ stdin.setRawMode(false);
57
+ stdin.pause();
58
+ stdin.removeListener("data", onData);
59
+ process.stdout.write("\n");
60
+ resolve(value.trim());
61
+ return;
62
+ case "":
63
+ stdin.setRawMode(false);
64
+ stdin.pause();
65
+ stdin.removeListener("data", onData);
66
+ process.stdout.write("\n");
67
+ resolve(undefined);
68
+ return;
69
+ case "":
70
+ case "\b":
71
+ if (value.length) {
72
+ value = value.slice(0, -1);
73
+ process.stdout.write("\b \b");
74
+ }
75
+ break;
76
+ default:
77
+ // Ignore control characters, accept printable input.
78
+ if (char >= " ") {
79
+ value += char;
80
+ process.stdout.write("*");
81
+ }
82
+ }
83
+ }
84
+ };
85
+
86
+ stdin.on("data", onData);
87
+ });
88
+ }
89
+
90
+ /**
91
+ * Resolve the personal Spark token, in precedence order:
92
+ * explicit flag, environment, cached file, interactive prompt.
93
+ *
94
+ * Caching matters because a token is issued once and shown only once in the
95
+ * Spark bench; a student who loses it has to ask for a rotation.
96
+ */
97
+ export async function resolveToken(agentDir, explicit) {
98
+ if (explicit) return { token: explicit.trim(), source: "--token" };
99
+
100
+ const fromEnv = process.env.LEARNINGCODE_TOKEN || process.env[TOKEN_ENV];
101
+ if (fromEnv) return { token: fromEnv.trim(), source: "environment" };
102
+
103
+ const cached = await readCachedToken(agentDir);
104
+ if (cached) return { token: cached, source: "cached" };
105
+
106
+ const entered = await promptSecret(
107
+ "Paste your Spark API token (Bench > Issue / rotate token): ",
108
+ );
109
+ if (!entered) throw new UserError("No token entered. Run `learningcode --help` for non-interactive setup.");
110
+ return { token: entered, source: "prompt", shouldCache: true };
111
+ }
112
+
113
+ export function looksLikeToken(token) {
114
+ return token.startsWith(TOKEN_PREFIX);
115
+ }
116
+
117
+ /** True when a token can be found without asking the student anything. */
118
+ export async function hasToken(agentDir, explicit) {
119
+ if (explicit) return true;
120
+ if (process.env.LEARNINGCODE_TOKEN || process.env[TOKEN_ENV]) return true;
121
+ return Boolean(await readCachedToken(agentDir));
122
+ }
123
+
124
+ export { writeCachedToken };
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@stemtrooper/learningcode",
3
+ "version": "0.1.0",
4
+ "description": "TLC Spark coding agent for students: Pi wired to the Spark OpenAI-compatible endpoint with per-student tokens, quota and seat-queue awareness.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "bin": {
8
+ "learningcode": "./bin/learningcode.mjs"
9
+ },
10
+ "files": [
11
+ "bin",
12
+ "lib",
13
+ "extensions",
14
+ "README.md",
15
+ "LICENSE"
16
+ ],
17
+ "scripts": {
18
+ "start": "node ./bin/learningcode.mjs",
19
+ "test": "node --test \"test/**/*.test.mjs\""
20
+ },
21
+ "dependencies": {
22
+ "@earendil-works/pi-coding-agent": "1.0.4"
23
+ },
24
+ "devDependencies": {
25
+ "jiti": "2.7.0"
26
+ },
27
+ "engines": {
28
+ "node": ">=22.19.0"
29
+ },
30
+ "keywords": [
31
+ "coding-agent",
32
+ "education",
33
+ "cli",
34
+ "tui",
35
+ "tlc",
36
+ "spark"
37
+ ],
38
+ "author": "TLC / stemtrooper",
39
+ "repository": {
40
+ "type": "git",
41
+ "url": "git+https://github.com/stemtrooper/learningcode.git"
42
+ }
43
+ }