@stemtrooper/learningcode 0.4.3 → 0.4.5
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 +11 -1
- package/bin/learningcode.mjs +4 -1
- package/extensions/footer.ts +48 -8
- package/extensions/spark-quota.ts +43 -4
- package/lib/settings.mjs +42 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -184,12 +184,22 @@ configure.
|
|
|
184
184
|
On a narrower terminal it steps down to a condensed banner, then to the wordmark,
|
|
185
185
|
rather than drawing art that would be clipped.
|
|
186
186
|
|
|
187
|
+
LearningCode suppresses Pi's built-in startup header so its own banner appears
|
|
188
|
+
without the Pi logo flashing first. Use `learningcode --verbose` to show Pi's
|
|
189
|
+
startup header and loaded-resource list as well. An explicit `quietStartup`
|
|
190
|
+
setting in `~/.learningcode/agent/settings.json` is respected.
|
|
191
|
+
|
|
187
192
|
A footer shows your remaining quota whenever you are connected to Spark:
|
|
188
193
|
|
|
189
194
|
```
|
|
190
195
|
█████░░░░░ 50% 125k / 250k today
|
|
191
196
|
```
|
|
192
197
|
|
|
198
|
+
Quota-exempt accounts show `unlimited`. An account with no daily token cap but
|
|
199
|
+
still subject to a weekly cap shows `no daily token cap` instead; Spark reports
|
|
200
|
+
those as different states, and the client must not mistake a missing daily
|
|
201
|
+
limit for either zero allowance or unlimited use.
|
|
202
|
+
|
|
193
203
|
---
|
|
194
204
|
|
|
195
205
|
## Troubleshooting
|
|
@@ -297,4 +307,4 @@ is also MIT. See [LICENSE](LICENSE).
|
|
|
297
307
|
|
|
298
308
|
Not affiliated with, endorsed by, or connected to Pi, OpenCode, Anomaly, or any of
|
|
299
309
|
the model providers reachable through this client. "The Learning Curve", TLC and
|
|
300
|
-
Spark are trademarks of The Learning Curve.
|
|
310
|
+
Spark are trademarks of The Learning Curve.
|
package/bin/learningcode.mjs
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
} from "../lib/config.mjs";
|
|
18
18
|
import { GO_PROVIDER, saveProviderKey } from "../lib/auth.mjs";
|
|
19
19
|
import { ensureSparkProvider, modelsPath, retargetProvider } from "../lib/models.mjs";
|
|
20
|
+
import { ensureQuietStartup } from "../lib/settings.mjs";
|
|
20
21
|
import { ensureThemes, preferredTheme } from "../lib/themes.mjs";
|
|
21
22
|
import { hasToken, looksLikeToken, resolveToken, writeCachedToken } from "../lib/token.mjs";
|
|
22
23
|
|
|
@@ -45,6 +46,7 @@ Everything else is passed straight through to Pi, so the usual flags work:
|
|
|
45
46
|
learningcode -p "explain main.py" one-shot, non-interactive
|
|
46
47
|
learningcode --mode json machine-readable event stream
|
|
47
48
|
learningcode --list-models every model Pi can reach
|
|
49
|
+
learningcode --verbose show Pi's startup header and resource list
|
|
48
50
|
|
|
49
51
|
Other providers
|
|
50
52
|
Any --model other than tlc-spark/... skips the Spark token and the Spark
|
|
@@ -230,6 +232,7 @@ async function main() {
|
|
|
230
232
|
}
|
|
231
233
|
await ensureSparkProvider(dir);
|
|
232
234
|
await ensureThemes(dir);
|
|
235
|
+
await ensureQuietStartup(dir);
|
|
233
236
|
|
|
234
237
|
// `--login` forces a fresh paste even when a token is already cached.
|
|
235
238
|
const forced = [];
|
|
@@ -369,4 +372,4 @@ main().catch((error) => {
|
|
|
369
372
|
// Anything the user can act on gets one line. A stack trace is noise for a
|
|
370
373
|
// student who mistyped a token; keep it for genuine bugs.
|
|
371
374
|
fail(error instanceof UserError ? error.message : error?.stack || String(error));
|
|
372
|
-
});
|
|
375
|
+
});
|
package/extensions/footer.ts
CHANGED
|
@@ -24,7 +24,13 @@ const TOKEN = process.env.SPARK_API_KEY || "";
|
|
|
24
24
|
/** Two minutes. Enough to stay current, rarely enough to be invisible. */
|
|
25
25
|
const POLL_MS = 120_000;
|
|
26
26
|
|
|
27
|
-
type Quota = {
|
|
27
|
+
type Quota = {
|
|
28
|
+
tokensUsed: number;
|
|
29
|
+
/** null means no daily cap, which is not the same as a cap of zero. */
|
|
30
|
+
dailyTokenLimit: number | null;
|
|
31
|
+
quotaExempt?: boolean;
|
|
32
|
+
aiEnabled: boolean;
|
|
33
|
+
};
|
|
28
34
|
|
|
29
35
|
const num = (value: unknown): number =>
|
|
30
36
|
typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
@@ -32,6 +38,9 @@ const num = (value: unknown): number =>
|
|
|
32
38
|
const pct = (used: number, limit: number): string =>
|
|
33
39
|
limit > 0 ? `${Math.min(100, Math.round((used / limit) * 100))}%` : "--";
|
|
34
40
|
|
|
41
|
+
/** Only Spark's explicit exemption removes every token ceiling. */
|
|
42
|
+
const isUnlimited = (quota: Quota): boolean => quota.quotaExempt === true;
|
|
43
|
+
|
|
35
44
|
/**
|
|
36
45
|
* A ten cell meter. Text alone ("82%") is easy to skim past; a bar is read at a
|
|
37
46
|
* glance, which is the entire reason this is a footer rather than a command.
|
|
@@ -44,10 +53,22 @@ function meter(used: number, limit: number, width = 10): string {
|
|
|
44
53
|
|
|
45
54
|
function format(quota: Quota): string[] {
|
|
46
55
|
if (quota.aiEnabled === false) return ["AI off — ask your teacher"];
|
|
47
|
-
const { tokensUsed
|
|
56
|
+
const { tokensUsed } = quota;
|
|
57
|
+
|
|
58
|
+
if (isUnlimited(quota)) {
|
|
59
|
+
// No bar and no percentage: a full meter would imply a ceiling that is not
|
|
60
|
+
// there, and any percentage of infinity is noise.
|
|
61
|
+
return [`${Math.round(tokensUsed / 1000)}k used today · unlimited`];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (quota.dailyTokenLimit === null || quota.dailyTokenLimit === undefined) {
|
|
65
|
+
return [`${Math.round(tokensUsed / 1000)}k used today · no daily token cap`];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const limit = num(quota.dailyTokenLimit);
|
|
48
69
|
return [
|
|
49
|
-
`${meter(tokensUsed,
|
|
50
|
-
`${Math.round(tokensUsed / 1000)}k/${Math.round(
|
|
70
|
+
`${meter(tokensUsed, limit)} ${pct(tokensUsed, limit)}`,
|
|
71
|
+
`${Math.round(tokensUsed / 1000)}k/${Math.round(limit / 1000)}k today`,
|
|
51
72
|
];
|
|
52
73
|
}
|
|
53
74
|
|
|
@@ -95,9 +116,28 @@ export default function sparkFooter(pi: ExtensionAPI) {
|
|
|
95
116
|
return [theme.fg("error", " AI disabled for your account — ask your teacher")];
|
|
96
117
|
}
|
|
97
118
|
|
|
119
|
+
// No ceiling: no meter and no percentage, because a full bar implies a
|
|
120
|
+
// limit that does not exist.
|
|
121
|
+
if (isUnlimited(quota)) {
|
|
122
|
+
return [
|
|
123
|
+
theme.fg("dim", ` ${Math.round(quota.tokensUsed / 1000)}k used today`) +
|
|
124
|
+
theme.fg("muted", " · unlimited"),
|
|
125
|
+
];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// A null daily cap does not mean unlimited: the weekly cap can still
|
|
129
|
+
// apply. Do not turn null into zero or show a misleading /0K allowance.
|
|
130
|
+
if (quota.dailyTokenLimit === null || quota.dailyTokenLimit === undefined) {
|
|
131
|
+
return [
|
|
132
|
+
theme.fg("dim", ` ${Math.round(quota.tokensUsed / 1000)}k used today`) +
|
|
133
|
+
theme.fg("muted", " · no daily token cap"),
|
|
134
|
+
];
|
|
135
|
+
}
|
|
136
|
+
|
|
98
137
|
const { tokensUsed, dailyTokenLimit } = quota;
|
|
99
|
-
const
|
|
100
|
-
const
|
|
138
|
+
const limit = dailyTokenLimit;
|
|
139
|
+
const bar = theme.fg("accent", meter(tokensUsed, limit));
|
|
140
|
+
const percent = theme.fg("muted", pct(tokensUsed, limit));
|
|
101
141
|
|
|
102
142
|
// Narrow terminals keep the bar and the percentage only; the absolute
|
|
103
143
|
// token counts are the part that can be dropped.
|
|
@@ -105,7 +145,7 @@ export default function sparkFooter(pi: ExtensionAPI) {
|
|
|
105
145
|
|
|
106
146
|
const numbers = theme.fg(
|
|
107
147
|
"dim",
|
|
108
|
-
`${Math.round(tokensUsed / 1000)}k / ${Math.round(
|
|
148
|
+
`${Math.round(tokensUsed / 1000)}k / ${Math.round(limit / 1000)}k today`,
|
|
109
149
|
);
|
|
110
150
|
return [` ${bar} ${percent} ${numbers}`];
|
|
111
151
|
},
|
|
@@ -125,4 +165,4 @@ export default function sparkFooter(pi: ExtensionAPI) {
|
|
|
125
165
|
}
|
|
126
166
|
|
|
127
167
|
// Exported for tests.
|
|
128
|
-
export const _internals = { format, meter, pct };
|
|
168
|
+
export const _internals = { format, isUnlimited, meter, pct };
|
|
@@ -34,9 +34,46 @@ async function call(path: string): Promise<{ ok: true; body: unknown } | { ok: f
|
|
|
34
34
|
|
|
35
35
|
const num = (value: unknown): number => (typeof value === "number" && Number.isFinite(value) ? value : 0);
|
|
36
36
|
|
|
37
|
-
|
|
37
|
+
type QuotaBody = {
|
|
38
|
+
day?: string;
|
|
39
|
+
tokensUsed?: number;
|
|
40
|
+
dailyTokenLimit?: number | null;
|
|
41
|
+
jobsUsed?: number;
|
|
42
|
+
dailyJobLimit?: number | null;
|
|
43
|
+
quotaExempt?: boolean;
|
|
44
|
+
aiEnabled?: boolean;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/** Only Spark's explicit exemption removes every token ceiling. */
|
|
48
|
+
const isUnlimited = (body: QuotaBody): boolean => body.quotaExempt === true;
|
|
49
|
+
|
|
50
|
+
const thousands = (value: number): string => `${Math.round(value / 1000)}k`;
|
|
51
|
+
|
|
52
|
+
function quotaLine(body: QuotaBody): string {
|
|
38
53
|
const used = num(body.tokensUsed);
|
|
39
|
-
|
|
54
|
+
if (isUnlimited(body)) {
|
|
55
|
+
return [
|
|
56
|
+
`Spark quota - ${used.toLocaleString()} tokens used, unlimited`,
|
|
57
|
+
"exempt",
|
|
58
|
+
`jobs ${num(body.jobsUsed)}`,
|
|
59
|
+
`day ${String(body.day ?? "?")}`,
|
|
60
|
+
body.aiEnabled === false ? "AI DISABLED for your account - ask your teacher" : "",
|
|
61
|
+
]
|
|
62
|
+
.filter(Boolean)
|
|
63
|
+
.join(" | ");
|
|
64
|
+
}
|
|
65
|
+
if (body.dailyTokenLimit === null || body.dailyTokenLimit === undefined) {
|
|
66
|
+
return [
|
|
67
|
+
`Spark quota - ${used.toLocaleString()} tokens used today, no daily token cap`,
|
|
68
|
+
`jobs ${num(body.jobsUsed)}/${num(body.dailyJobLimit)}`,
|
|
69
|
+
`day ${String(body.day ?? "?")}`,
|
|
70
|
+
body.aiEnabled === false ? "AI DISABLED for your account - ask your teacher" : "",
|
|
71
|
+
]
|
|
72
|
+
.filter(Boolean)
|
|
73
|
+
.join(" | ");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const limit = body.dailyTokenLimit;
|
|
40
77
|
const pct = limit > 0 ? ` (${Math.round((used / limit) * 100)}%)` : "";
|
|
41
78
|
return [
|
|
42
79
|
`Spark quota - ${used.toLocaleString()}/${limit.toLocaleString()} tokens${pct}`,
|
|
@@ -48,6 +85,8 @@ function quotaLine(body: Record<string, unknown>): string {
|
|
|
48
85
|
.join(" | ");
|
|
49
86
|
}
|
|
50
87
|
|
|
88
|
+
export const _internals = { quotaLine, isUnlimited, thousands, num };
|
|
89
|
+
|
|
51
90
|
/**
|
|
52
91
|
* The queue view shape is not guaranteed, so surface the fields we recognise
|
|
53
92
|
* and fall back to raw JSON rather than inventing structure.
|
|
@@ -70,7 +109,7 @@ export default function sparkQuota(pi: ExtensionAPI) {
|
|
|
70
109
|
handler: async (_args, ctx) => {
|
|
71
110
|
const result = await call("/me/quota");
|
|
72
111
|
ctx.ui.notify(
|
|
73
|
-
result.ok ? quotaLine(result.body as
|
|
112
|
+
result.ok ? quotaLine(result.body as QuotaBody) : `quota unavailable: ${result.error}`,
|
|
74
113
|
result.ok ? "info" : "warning",
|
|
75
114
|
);
|
|
76
115
|
},
|
|
@@ -177,4 +216,4 @@ export default function sparkQuota(pi: ExtensionAPI) {
|
|
|
177
216
|
);
|
|
178
217
|
}
|
|
179
218
|
});
|
|
180
|
-
}
|
|
219
|
+
}
|
package/lib/settings.mjs
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { UserError } from "./config.mjs";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Pi renders its built-in header before extension `session_start` handlers run.
|
|
7
|
+
* Its supported quietStartup setting prevents that header flashing before the
|
|
8
|
+
* LearningCode banner is installed. Only seed the setting when it is unset, so
|
|
9
|
+
* an explicit choice in the student's LearningCode settings remains theirs.
|
|
10
|
+
*/
|
|
11
|
+
export async function ensureQuietStartup(agentDir) {
|
|
12
|
+
const path = join(agentDir, "settings.json");
|
|
13
|
+
let settings;
|
|
14
|
+
|
|
15
|
+
try {
|
|
16
|
+
const contents = await readFile(path, "utf-8");
|
|
17
|
+
try {
|
|
18
|
+
settings = JSON.parse(contents.replace(/^\uFEFF/, ""));
|
|
19
|
+
} catch (error) {
|
|
20
|
+
throw new UserError(`Could not parse Pi settings at ${path}: ${error.message}`);
|
|
21
|
+
}
|
|
22
|
+
} catch (error) {
|
|
23
|
+
if (error instanceof UserError) throw error;
|
|
24
|
+
if (error?.code !== "ENOENT") {
|
|
25
|
+
throw new UserError(`Could not read Pi settings at ${path}: ${error.message}`);
|
|
26
|
+
}
|
|
27
|
+
settings = {};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (!settings || typeof settings !== "object" || Array.isArray(settings)) {
|
|
31
|
+
throw new UserError(`Pi settings at ${path} must contain a JSON object.`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (typeof settings.quietStartup === "boolean" || settings.quietStartup === "header") {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
settings.quietStartup = true;
|
|
39
|
+
await mkdir(agentDir, { recursive: true });
|
|
40
|
+
await writeFile(path, `${JSON.stringify(settings, null, 2)}\n`, "utf-8");
|
|
41
|
+
return true;
|
|
42
|
+
}
|
package/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stemtrooper/learningcode",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.5",
|
|
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",
|
|
7
7
|
"bin": {
|
|
8
|
-
"learningcode": "
|
|
8
|
+
"learningcode": "bin/learningcode.mjs"
|
|
9
9
|
},
|
|
10
10
|
"files": [
|
|
11
11
|
"assets",
|