@stemtrooper/learningcode 0.4.4 → 0.4.6

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 CHANGED
@@ -1,4 +1,13 @@
1
- # learningcode
1
+ ```text
2
+ ██╗ ███████╗ █████╗ ██████╗ ███╗ ██╗██╗███╗ ██╗ ██████╗ ██████╗ ██████╗ ██████╗ ███████╗
3
+ ██║ ██╔════╝██╔══██╗██╔══██╗████╗ ██║██║████╗ ██║██╔════╝ ██╔════╝██╔═══██╗██╔══██╗██╔════╝
4
+ ██║ █████╗ ███████║██████╔╝██╔██╗ ██║██║██╔██╗ ██║██║ ███╗██║ ██║ ██║██║ ██║█████╗
5
+ ██║ ██╔══╝ ██╔══██║██╔══██╗██║╚██╗██║██║██║╚██╗██║██║ ██║██║ ██║ ██║██║ ██║██╔══╝
6
+ ███████╗███████╗██║ ██║██║ ██║██║ ╚████║██║██║ ╚████║╚██████╔╝╚██████╗╚██████╔╝██████╔╝███████╗
7
+ ╚══════╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═══╝╚═╝╚═╝ ╚═══╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝
8
+ ```
9
+
10
+ *The Learning Curve · Sarawak*
2
11
 
3
12
  A terminal coding agent for **The Learning Curve** students, running on TLC's own
4
13
  inference through **TLC-Spark**.
@@ -171,10 +180,6 @@ learningcode --mode json # machine-readable output
171
180
  The TLC brand theme and banner ship with the package, so there is nothing to
172
181
  configure.
173
182
 
174
- LearningCode suppresses Pi's built-in startup header so the TLC banner appears
175
- without the Pi logo flashing first. Use `learningcode --verbose` to show Pi's
176
- startup details and loaded-resource list as well.
177
-
178
183
  ```
179
184
  ██╗ ███████╗ █████╗ ██████╗ ███╗ ██╗██╗███╗ ██╗ ██████╗ ██████╗ ██████╗ ██████╗ ███████╗
180
185
  ██║ ██╔════╝██╔══██╗██╔══██╗████╗ ██║██║████╗ ██║██╔════╝ ██╔════╝██╔═══██╗██╔══██╗██╔════╝
@@ -188,12 +193,22 @@ startup details and loaded-resource list as well.
188
193
  On a narrower terminal it steps down to a condensed banner, then to the wordmark,
189
194
  rather than drawing art that would be clipped.
190
195
 
196
+ LearningCode suppresses Pi's built-in startup header so its own banner appears
197
+ without the Pi logo flashing first. Use `learningcode --verbose` to show Pi's
198
+ startup header and loaded-resource list as well. An explicit `quietStartup`
199
+ setting in `~/.learningcode/agent/settings.json` is respected.
200
+
191
201
  A footer shows your remaining quota whenever you are connected to Spark:
192
202
 
193
203
  ```
194
204
  █████░░░░░ 50% 125k / 250k today
195
205
  ```
196
206
 
207
+ Quota-exempt accounts show `unlimited`. An account with no daily token cap but
208
+ still subject to a weekly cap shows `no daily token cap` instead; Spark reports
209
+ those as different states, and the client must not mistake a missing daily
210
+ limit for either zero allowance or unlimited use.
211
+
197
212
  ---
198
213
 
199
214
  ## Troubleshooting
@@ -243,7 +258,7 @@ to Spark, and no telemetry is sent.
243
258
  learningcode --show-config # show resolved paths and settings
244
259
  learningcode --login # enter a new token
245
260
  learningcode --list-models # every reachable model
246
- learningcode --theme dark # use the upstream theme instead of TLC's
261
+ learningcode --use-theme dark # use the upstream theme instead of TLC's
247
262
  ```
248
263
 
249
264
  **Your edits are kept.** If you change the theme or the model configuration,
@@ -240,13 +240,22 @@ async function main() {
240
240
  const effectiveModel = requestedModel ?? `${PROVIDER_ID}/${MODEL_ID}`;
241
241
  if (!requestedModel) forced.push("--model", effectiveModel);
242
242
 
243
- // Default to the TLC theme, but never override an explicit choice: --theme on
244
- // the command line, or LEARNINGCODE_THEME in the environment.
243
+ // Default to the TLC theme without overriding an explicit CLI choice.
244
+ // LEARNINGCODE_THEME flows through preferredTheme(), so it becomes the
245
+ // forced value rather than suppressing it. NB: Pi's --theme loads a theme
246
+ // *file or directory*, while --use-theme selects a theme *by name*.
247
+ // Passing "tlc-dark" to --theme made Pi warn "[Theme conflicts] ...
248
+ // theme path does not exist" on every login, so the name goes to
249
+ // --use-theme.
245
250
  const userPickedTheme = toPi.some(
246
- (arg) => arg === "--theme" || arg.startsWith("--theme="),
251
+ (arg) =>
252
+ arg === "--theme" ||
253
+ arg.startsWith("--theme=") ||
254
+ arg === "--use-theme" ||
255
+ arg.startsWith("--use-theme="),
247
256
  );
248
- if (!userPickedTheme && !process.env.LEARNINGCODE_THEME) {
249
- forced.push("--theme", preferredTheme());
257
+ if (!userPickedTheme) {
258
+ forced.push("--use-theme", preferredTheme());
250
259
  }
251
260
 
252
261
  // Spark enforces one active generation per student, so subagents and parallel
@@ -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 = { tokensUsed: number; dailyTokenLimit: number; aiEnabled: boolean };
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, dailyTokenLimit } = quota;
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, dailyTokenLimit)} ${pct(tokensUsed, dailyTokenLimit)}`,
50
- `${Math.round(tokensUsed / 1000)}k/${Math.round(dailyTokenLimit / 1000)}k today`,
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 bar = theme.fg("accent", meter(tokensUsed, dailyTokenLimit));
100
- const percent = theme.fg("muted", pct(tokensUsed, dailyTokenLimit));
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(dailyTokenLimit / 1000)}k today`,
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
- function quotaLine(body: Record<string, unknown>): string {
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
- const limit = num(body.dailyTokenLimit);
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 Record<string, unknown>) : `quota unavailable: ${result.error}`,
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/themes.mjs CHANGED
@@ -39,7 +39,7 @@ export function preferredTheme(env = process.env) {
39
39
  if (forced) return forced;
40
40
  // Windows Terminal defaults to a dark scheme for most profiles; macOS and
41
41
  // Linux terminals are more often light. This is a guess either way, which is
42
- // why an explicit --theme still wins.
42
+ // why an explicit --use-theme still wins.
43
43
  return env.LEARNINGCODE_LIGHT_THEME === "1" ? "tlc-light" : "tlc-dark";
44
44
  }
45
45
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stemtrooper/learningcode",
3
- "version": "0.4.4",
3
+ "version": "0.4.6",
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",