@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 +49 -0
- package/README.md +91 -0
- package/bin/learningcode.mjs +310 -0
- package/extensions/spark-quota.ts +113 -0
- package/lib/config.mjs +52 -0
- package/lib/models.mjs +107 -0
- package/lib/token.mjs +124 -0
- package/package.json +43 -0
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
|
+
}
|