@stemtrooper/learningcode 0.3.1 → 0.4.1
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/README.md +12 -5
- package/extensions/spark-quota.ts +69 -2
- package/lib/token.mjs +12 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -114,12 +114,19 @@ Useful commands, typed inside the session:
|
|
|
114
114
|
| `/help` | list everything available |
|
|
115
115
|
| `/quota` | your token spend today |
|
|
116
116
|
| `/seats` | how many seats are free |
|
|
117
|
+
| `/spark-login` | check your TLC-Spark token, or find out how to get a new one |
|
|
117
118
|
| `/model` | switch model |
|
|
118
119
|
| `/hotkeys` | keyboard shortcuts |
|
|
119
120
|
|
|
120
|
-
`/quota` and `/
|
|
121
|
-
AI coding tool does not have, because those endpoints are not part of any
|
|
122
|
-
API.
|
|
121
|
+
`/quota`, `/seats` and `/spark-login` talk to Spark directly. They are the commands a
|
|
122
|
+
stock AI coding tool does not have, because those endpoints are not part of any
|
|
123
|
+
public API.
|
|
124
|
+
|
|
125
|
+
**Getting a new TLC-Spark token.** Use `/spark-login` to check the one you have;
|
|
126
|
+
it tells you whether Spark still accepts it. To actually replace it, exit and run
|
|
127
|
+
`learningcode --login`, which prompts without echoing. It is deliberately *not* a
|
|
128
|
+
`/` command: typing a token into the agent's own input box would leave it in your
|
|
129
|
+
scrollback, which is the wrong thing to do on a shared lab machine.
|
|
123
130
|
|
|
124
131
|
One-off, non-interactive:
|
|
125
132
|
|
|
@@ -167,8 +174,8 @@ You do not have a token yet, or it is not cached. Run `learningcode --login` to
|
|
|
167
174
|
enter a new one. If you never had one, ask your teacher.
|
|
168
175
|
|
|
169
176
|
**`Token rejected (401)`**
|
|
170
|
-
The token
|
|
171
|
-
`learningcode --login
|
|
177
|
+
The token was probably rotated, which revokes the previous one. Run `/spark-login`
|
|
178
|
+
to confirm, then `learningcode --login` to enter the new one.
|
|
172
179
|
|
|
173
180
|
**`AI off — ask your teacher` / `AI disabled for your account`**
|
|
174
181
|
Spark has AI switched off for your account — usually a timetable window or a
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { cachedTokenPrefix } from "../lib/token.mjs";
|
|
3
|
+
import { agentDir } from "../lib/config.mjs";
|
|
2
4
|
|
|
3
5
|
/**
|
|
4
6
|
* Spark-aware status for the learningcode client.
|
|
@@ -17,13 +19,13 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
|
17
19
|
const BASE_URL = (process.env.SPARK_BASE_URL || "https://spark.learning.com.my/v1").replace(/\/+$/, "");
|
|
18
20
|
const TOKEN = process.env.SPARK_API_KEY || "";
|
|
19
21
|
|
|
20
|
-
async function call(path: string): Promise<{ ok: true; body: unknown } | { ok: false; error: string }> {
|
|
22
|
+
async function call(path: string): Promise<{ ok: true; body: unknown } | { ok: false; error: string; status?: number }> {
|
|
21
23
|
if (!TOKEN) return { ok: false, error: "no Spark token in the environment" };
|
|
22
24
|
try {
|
|
23
25
|
const response = await fetch(`${BASE_URL}${path}`, {
|
|
24
26
|
headers: { Authorization: `Bearer ${TOKEN}` },
|
|
25
27
|
});
|
|
26
|
-
if (!response.ok) return { ok: false, error: `HTTP ${response.status}
|
|
28
|
+
if (!response.ok) return { ok: false, error: `HTTP ${response.status}`, status: response.status };
|
|
27
29
|
return { ok: true, body: await response.json() };
|
|
28
30
|
} catch (error) {
|
|
29
31
|
return { ok: false, error: error instanceof Error ? error.message : "unreachable" };
|
|
@@ -85,6 +87,71 @@ export default function sparkQuota(pi: ExtensionAPI) {
|
|
|
85
87
|
},
|
|
86
88
|
});
|
|
87
89
|
|
|
90
|
+
/**
|
|
91
|
+
* Check the TLC-Spark token and say what to do about it.
|
|
92
|
+
*
|
|
93
|
+
* This deliberately does not accept a pasted token. Two constraints make that
|
|
94
|
+
* the wrong place to type a secret:
|
|
95
|
+
*
|
|
96
|
+
* - Pi's input dialog has no mask option, so the value would sit in scrollback
|
|
97
|
+
* in plain text, which on a shared lab machine is worse than the masked
|
|
98
|
+
* prompt `learningcode --login` already uses.
|
|
99
|
+
* - Pi caches resolved environment values, so a token set mid-session would
|
|
100
|
+
* not reliably take effect until the next launch anyway.
|
|
101
|
+
*
|
|
102
|
+
* So this reports whether the token Spark is actually seeing is still good,
|
|
103
|
+
* which is the question a student has after rotating it, and points at the
|
|
104
|
+
* command that does the work.
|
|
105
|
+
*/
|
|
106
|
+
pi.registerCommand("spark-login", {
|
|
107
|
+
description: "Check your TLC-Spark token, or get a new one",
|
|
108
|
+
handler: async (_args, ctx) => {
|
|
109
|
+
const prefix = await cachedTokenPrefix(agentDir()).catch(() => undefined);
|
|
110
|
+
|
|
111
|
+
if (!TOKEN) {
|
|
112
|
+
ctx.ui.notify(
|
|
113
|
+
"No TLC-Spark token loaded. Exit and run: learningcode --login",
|
|
114
|
+
"warning",
|
|
115
|
+
);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const result = await call("/me/quota");
|
|
120
|
+
|
|
121
|
+
if (result.ok) {
|
|
122
|
+
const body = result.body as Record<string, unknown>;
|
|
123
|
+
const used = num(body.tokensUsed);
|
|
124
|
+
const limit = num(body.dailyTokenLimit);
|
|
125
|
+
ctx.ui.notify(
|
|
126
|
+
[
|
|
127
|
+
`Token OK${prefix ? ` (${prefix}…)` : ""}`,
|
|
128
|
+
`${used.toLocaleString()}/${limit.toLocaleString()} tokens today`,
|
|
129
|
+
"Rotating it? Exit and run: learningcode --login",
|
|
130
|
+
].join(" | "),
|
|
131
|
+
"info",
|
|
132
|
+
);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (result.status === 401 || result.status === 403) {
|
|
137
|
+
ctx.ui.notify(
|
|
138
|
+
[
|
|
139
|
+
`Spark rejected your token${prefix ? ` (${prefix}…)` : ""}.`,
|
|
140
|
+
"It was probably rotated, which revokes the old one.",
|
|
141
|
+
"Exit and run: learningcode --login",
|
|
142
|
+
].join(" "),
|
|
143
|
+
"warning",
|
|
144
|
+
);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
ctx.ui.notify(
|
|
149
|
+
`Cannot reach Spark at ${BASE_URL} (${result.error}). Check your network.`,
|
|
150
|
+
"warning",
|
|
151
|
+
);
|
|
152
|
+
},
|
|
153
|
+
});
|
|
154
|
+
|
|
88
155
|
// One check at startup rather than after every turn: the quota moves by at
|
|
89
156
|
// most one request per turn, and a fetch on each `agent_end` would add
|
|
90
157
|
// latency to the thing students are waiting on.
|
package/lib/token.mjs
CHANGED
|
@@ -121,4 +121,16 @@ export async function hasToken(agentDir, explicit) {
|
|
|
121
121
|
return Boolean(await readCachedToken(agentDir));
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
+
/**
|
|
125
|
+
* The leading fragment of the cached token, for display only.
|
|
126
|
+
*
|
|
127
|
+
* Spark keeps `raw.slice(0, 18)` as tokenPrefix for the same reason, so showing
|
|
128
|
+
* this much identifies a token without disclosing it. Nothing here ever returns
|
|
129
|
+
* the full value.
|
|
130
|
+
*/
|
|
131
|
+
export async function cachedTokenPrefix(agentDir, length = 18) {
|
|
132
|
+
const token = await readCachedToken(agentDir);
|
|
133
|
+
return token ? token.slice(0, length) : undefined;
|
|
134
|
+
}
|
|
135
|
+
|
|
124
136
|
export { writeCachedToken };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stemtrooper/learningcode",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
4
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
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|