pi-jev-effort 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.
Files changed (5) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +180 -0
  3. package/index.ts +504 -0
  4. package/package.json +43 -0
  5. package/usage.ts +237 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Hyunwoo Nam
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,180 @@
1
+ # pi-jev-effort
2
+
3
+ Sets the thinking level of a [pi](https://pi.dev) session for every prompt, from a
4
+ [TypeSafe Jev](https://typesafe.ai) judgement of how hard the prompt actually is — and caps it
5
+ by how much of your quota is left.
6
+
7
+ ```
8
+ [jev-effort] score=0.00 conf=1.00 budget=openrouter:58% burn=$0.00064/min ceiling=none high -> minimal
9
+ [jev-effort] score=2.38 conf=0.54 budget=openrouter:58% burn=$0.00046/min ceiling=none high -> high
10
+ [jev-effort] score=2.36 conf=0.55 budget=local:0% burn=$0.00114/min ceiling=low high -> low
11
+ ```
12
+
13
+ Jev is a System One model: it answers a typed question with a distribution instead of prose, so
14
+ one call returns a score on your rubric plus the confidence behind it. A judgement takes about
15
+ 250ms and costs about $0.000015, and Jev bills input only.
16
+
17
+ ## Why an extension and not a hook
18
+
19
+ Pi's command hooks can read the thinking level but not write it. `setThinkingLevel` exists only on
20
+ the extension API, so the decision has to live inside pi's process. The same is true elsewhere:
21
+ Claude Code hooks read `$CLAUDE_EFFORT` but cannot set it (that needs a Mod), and Codex hook output
22
+ has no field that changes session settings at all.
23
+
24
+ ## Install
25
+
26
+ ```sh
27
+ pi install npm:pi-jev-effort
28
+ ```
29
+
30
+ Or work on it in place, which keeps `/reload` working:
31
+
32
+ ```sh
33
+ git clone https://github.com/namenu/pi-jev-effort ~/src/pi-jev-effort
34
+ ln -s ~/src/pi-jev-effort ~/.pi/agent/extensions/jev-effort
35
+ ```
36
+
37
+ Then give it a key. Either works, and `auto` prefers the first it finds:
38
+
39
+ | Transport | Key | Endpoint |
40
+ |---|---|---|
41
+ | TypeSafe direct | `TYPESAFE_API_KEY`, or `apiKey` in `~/.jev/config.json` | `POST https://api.typesafe.ai/v1/systemone` |
42
+ | OpenRouter | the OpenRouter login pi already holds — nothing to set | `POST https://openrouter.ai/api/alpha/decisions` |
43
+
44
+ The OpenRouter path needs no configuration: the extension asks pi for the key with
45
+ `modelRegistry.getApiKeyForProvider("openrouter")` and re-resolves it every turn, so a refreshed
46
+ OAuth token keeps working. If your OpenRouter workspace filters providers, allow **TypeSafe** under
47
+ Guardrails first, or every call comes back `404 provider-not-allowed-by-guardrail`.
48
+
49
+ There are no npm dependencies. Both transports take the same body and return the same answer shape.
50
+
51
+ ## How a level gets chosen
52
+
53
+ **1. Jev scores the prompt** against a four-level rubric — trivial, routine, substantial, hard —
54
+ and returns an expected score with its confidence. Measured against `~typesafe/jev-latest`
55
+ (resolved as `typesafe/jev-1.13-20260917`):
56
+
57
+ | Prompt | Score | Confidence | Latency | Cost |
58
+ |---|---|---|---|---|
59
+ | `list the files in this directory, nothing else` | 0.00 | 1.00 | — | — |
60
+ | `rename the variable foo to bar in utils.ts` | 0.43 | 0.57 | 281ms | $0.0000149 |
61
+ | `why does the run index drift from the runner status file after a crash` | 2.38 | 0.54 | — | — |
62
+ | `find why the scheduler deadlocks under concurrent compaction and fix it` | 2.87 | 0.87 | 235ms | $0.0000151 |
63
+
64
+ **2. Hysteresis decides whether to move.** An upgrade needs confidence ≥ 0.3, a downgrade ≥ 0.6.
65
+ Thinking more than necessary costs tokens; thinking less costs the answer, so the bars are not
66
+ symmetric. The third row above is why: at 0.54 the score leans substantial but not firmly enough to
67
+ give up a level you already have.
68
+
69
+ **3. The budget caps the result.** A ceiling from quota pressure is a hard cap, not an opinion — it
70
+ applies whether or not Jev was confident. Everything else about the turn is unchanged.
71
+
72
+ Short prompts never reach step 1. "continue" or "yes" carries no signal of its own, and classifying
73
+ it would drag the level down in the middle of hard work, so anything under 12 characters keeps the
74
+ current level and makes no call.
75
+
76
+ ## Budget, burn rate and reset
77
+
78
+ The same places `ccusage` and CodexBar look: the transcripts the agent already writes, plus whatever
79
+ the provider volunteers about its own limits.
80
+
81
+ | Source | Where it comes from | Gives |
82
+ |---|---|---|
83
+ | `headers` | rate-limit headers captured in `after_provider_response` | remaining fraction, reset time |
84
+ | `openrouter` | `GET /api/v1/credits` | remaining fraction of purchased credits (no reset) |
85
+ | `local` | `~/.pi/agent/sessions/**/*.jsonl`, which record `usage.cost` per message | spend in the window against `limitUsd`, reset at the window edge |
86
+
87
+ `auto` tries them in that order. The **burn rate is always local**: the extension sums the cost of
88
+ assistant messages inside a rolling window and divides by the span, re-reading a transcript only
89
+ when its size changed. Windows sit on a UTC grid, the way ccusage treats Claude's 5-hour blocks, so
90
+ the reset time is the edge of the current block.
91
+
92
+ Pressure lowers the ceiling as the quota drains, and a burn rate that would empty the window before
93
+ it resets counts as one threshold worse:
94
+
95
+ ```json
96
+ "pressure": [
97
+ { "remainingBelow": 0.3, "ceiling": "medium" },
98
+ { "remainingBelow": 0.1, "ceiling": "low" }
99
+ ]
100
+ ```
101
+
102
+ The footer carries it: `jev: high (0.87) · 58% · resets 2h11m`.
103
+
104
+ ## Command
105
+
106
+ ```
107
+ /jev-effort # status, including budget source, burn and reset
108
+ /jev-effort on # enable, and clear a manual pause
109
+ /jev-effort off # disable for this session
110
+ ```
111
+
112
+ Change the level yourself with `/effort` or `Ctrl+Shift+E` and automatic routing pauses for the
113
+ session — your hands beat the model's judgement. A level change caused by switching models does not
114
+ count as manual, so clamping to a model's capabilities will not pause anything.
115
+
116
+ ## Configuration
117
+
118
+ Optional, at `~/.pi/agent/jev-effort.json`. Defaults:
119
+
120
+ ```json
121
+ {
122
+ "enabled": true,
123
+ "provider": "auto",
124
+ "apiKey": null,
125
+ "baseUrl": null,
126
+ "model": null,
127
+ "timeoutMs": 4000,
128
+ "levels": ["minimal", "low", "medium", "high"],
129
+ "minUpgradeConfidence": 0.3,
130
+ "minDowngradeConfidence": 0.6,
131
+ "floor": null,
132
+ "ceiling": null,
133
+ "minPromptChars": 12,
134
+ "budget": {
135
+ "source": "auto",
136
+ "windowHours": 5,
137
+ "limitUsd": null,
138
+ "pressure": [
139
+ { "remainingBelow": 0.3, "ceiling": "medium" },
140
+ { "remainingBelow": 0.1, "ceiling": "low" }
141
+ ],
142
+ "refreshMs": 60000
143
+ },
144
+ "notify": false,
145
+ "debug": false
146
+ }
147
+ ```
148
+
149
+ `levels` maps rubric scores 0–3 onto pi thinking levels, so a model with `xhigh` and `max` can use
150
+ them: `["low", "medium", "high", "max"]`. `floor` and `ceiling` clamp every result. `JEV_EFFORT_DEBUG=1`
151
+ prints one line per judgement to stderr, as at the top of this README.
152
+
153
+ Pi clamps whatever it is given to what the model supports, so `minimal` can land as `low`. That is
154
+ pi, not this extension, and the status line shows what actually took effect.
155
+
156
+ ## Failure is always open
157
+
158
+ No key, a timeout, an HTTP error, a guardrail rejection or a malformed answer leaves the level
159
+ untouched and the turn running. A judgement layer that can cost you a turn is worse than no
160
+ judgement layer.
161
+
162
+ ## Development
163
+
164
+ ```sh
165
+ node --test
166
+ ```
167
+
168
+ The tests cover the hysteresis rule, the hard cap, the wire contract of both transports with `fetch`
169
+ mocked, transcript parsing and burn rate, rate-limit header shapes, and the UTC window grid. For an
170
+ end-to-end check, point `baseUrl` at a local server that answers
171
+
172
+ ```json
173
+ { "answers": { "effort": { "type": "score", "score": 0, "confidence": 0.85 } } }
174
+ ```
175
+
176
+ and run `JEV_EFFORT_DEBUG=1 pi -p "<a prompt of at least 12 characters>"`.
177
+
178
+ ## License
179
+
180
+ MIT
package/index.ts ADDED
@@ -0,0 +1,504 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { homedir } from "node:os";
3
+ import { basename, join } from "node:path";
4
+ import { readFileSync } from "node:fs";
5
+ import {
6
+ humanizeReset,
7
+ openRouterCredits,
8
+ parseRateLimitHeaders,
9
+ pressureCeiling,
10
+ readSpend,
11
+ windowBounds,
12
+ type Budget,
13
+ type Pressure,
14
+ type RateLimit,
15
+ } from "./usage.ts";
16
+
17
+ export type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
18
+
19
+ /** Ordered so a level can be compared against another by index. */
20
+ const LEVELS: ThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
21
+
22
+ /** Jev speaks the same wire format on both; only the URL and the model id differ. */
23
+ export type Provider = "typesafe" | "openrouter";
24
+
25
+ const ENDPOINTS: Record<Provider, { baseUrl: string; path: string; model: string }> = {
26
+ typesafe: { baseUrl: "https://api.typesafe.ai", path: "/v1/systemone", model: "jev-latest" },
27
+ openrouter: {
28
+ baseUrl: "https://openrouter.ai",
29
+ path: "/api/alpha/decisions",
30
+ model: "~typesafe/jev-latest",
31
+ },
32
+ };
33
+
34
+ const CONFIG_PATH = join(homedir(), ".pi", "agent", "jev-effort.json");
35
+ const STATUS_KEY = "jev-effort";
36
+
37
+ export interface Config {
38
+ enabled: boolean;
39
+ /** "auto" prefers a TypeSafe key and falls back to the OpenRouter key pi already holds. */
40
+ provider: "auto" | Provider;
41
+ apiKey: string | null;
42
+ baseUrl: string | null;
43
+ model: string | null;
44
+ timeoutMs: number;
45
+ /** Rubric index -> thinking level. */
46
+ levels: ThinkingLevel[];
47
+ minUpgradeConfidence: number;
48
+ minDowngradeConfidence: number;
49
+ floor: ThinkingLevel | null;
50
+ ceiling: ThinkingLevel | null;
51
+ /** Prompts shorter than this keep the current level without a Jev call. */
52
+ minPromptChars: number;
53
+ budget: BudgetConfig;
54
+ notify: boolean;
55
+ debug: boolean;
56
+ }
57
+
58
+ export interface BudgetConfig {
59
+ /** "auto" takes provider headers, then the OpenRouter balance, then a local budget. */
60
+ source: "auto" | "headers" | "openrouter" | "local" | "none";
61
+ /** Rolling block used for the burn rate, and for the local budget window. */
62
+ windowHours: number;
63
+ /** Dollars allowed per window; only the "local" source needs it. */
64
+ limitUsd: number | null;
65
+ /** Ceilings applied once the remaining fraction drops below each threshold. */
66
+ pressure: Pressure[];
67
+ /** How long a balance lookup is reused before it is refetched. */
68
+ refreshMs: number;
69
+ }
70
+
71
+ export const DEFAULTS: Config = {
72
+ enabled: true,
73
+ provider: "auto",
74
+ apiKey: null,
75
+ baseUrl: null,
76
+ model: null,
77
+ timeoutMs: 4000,
78
+ levels: ["minimal", "low", "medium", "high"],
79
+ // Asymmetric on purpose: spending more thinking than needed costs tokens,
80
+ // spending less costs a wrong answer, so upgrades clear a lower bar.
81
+ minUpgradeConfidence: 0.3,
82
+ minDowngradeConfidence: 0.6,
83
+ floor: null,
84
+ ceiling: null,
85
+ minPromptChars: 12,
86
+ budget: {
87
+ source: "auto",
88
+ windowHours: 5,
89
+ limitUsd: null,
90
+ pressure: [
91
+ { remainingBelow: 0.3, ceiling: "medium" },
92
+ { remainingBelow: 0.1, ceiling: "low" },
93
+ ],
94
+ refreshMs: 60_000,
95
+ },
96
+ notify: false,
97
+ debug: false,
98
+ };
99
+
100
+ /** Indexed from zero; the index is the effort score Jev returns. */
101
+ const RUBRIC = [
102
+ "Trivial or mechanical. A greeting, a short factual question, reading or listing files, " +
103
+ "a rename, a one-line edit whose answer is already in view.",
104
+ "Routine. A small well-specified change in one or two files, a bug with a clear repro, " +
105
+ "writing a short test, following a pattern that already exists in the codebase.",
106
+ "Substantial. Changes spanning several files, unfamiliar code that has to be traced first, " +
107
+ "an ambiguous failure, designing a small API, tradeoffs that are not obvious.",
108
+ "Hard. Architecture, migration or concurrency work, a subtle performance or correctness bug, " +
109
+ "conflicting constraints, work that needs a plan and verification before it can be trusted.",
110
+ ] as const;
111
+
112
+ const INSTRUCTIONS =
113
+ "A developer sent this prompt to a coding agent. How much step-by-step reasoning does " +
114
+ "answering it well require? Judge the work the prompt asks for, not how politely it is " +
115
+ "written and not how long it is.";
116
+
117
+ export const rankOf = (level: string): number => {
118
+ const i = LEVELS.indexOf(level as ThinkingLevel);
119
+ return i === -1 ? 0 : i;
120
+ };
121
+
122
+ const clampLevel = (level: ThinkingLevel, cfg: Config): ThinkingLevel => {
123
+ let rank = rankOf(level);
124
+ if (cfg.floor) rank = Math.max(rank, rankOf(cfg.floor));
125
+ if (cfg.ceiling) rank = Math.min(rank, rankOf(cfg.ceiling));
126
+ return LEVELS[rank];
127
+ };
128
+
129
+ /**
130
+ * Map an expected score onto a level, then apply hysteresis against the level
131
+ * already in effect. Returns null when the current level should stand.
132
+ */
133
+ export const decide = (
134
+ current: ThinkingLevel,
135
+ score: number,
136
+ confidence: number,
137
+ cfg: Config,
138
+ ): ThinkingLevel | null => {
139
+ const index = Math.min(cfg.levels.length - 1, Math.max(0, Math.round(score)));
140
+ const target = clampLevel(cfg.levels[index], cfg);
141
+ const delta = rankOf(target) - rankOf(current);
142
+ if (delta === 0) return null;
143
+ const needed = delta > 0 ? cfg.minUpgradeConfidence : cfg.minDowngradeConfidence;
144
+ return confidence >= needed ? target : null;
145
+ };
146
+
147
+ /**
148
+ * The level to switch to, or null to stay put. The judgement decides direction
149
+ * and has to clear its confidence bar; the ceiling is a hard cap that does not,
150
+ * because a budget that has run out is not an opinion.
151
+ */
152
+ export const plan = (
153
+ current: ThinkingLevel,
154
+ score: number,
155
+ confidence: number,
156
+ cfg: Config,
157
+ ceiling: ThinkingLevel | null,
158
+ ): ThinkingLevel | null => {
159
+ const proposed = decide(current, score, confidence, { ...cfg, ceiling: null }) ?? current;
160
+ const capped = clampLevel(proposed, { ...cfg, ceiling });
161
+ return capped === current ? null : capped;
162
+ };
163
+
164
+ export const loadConfig = (env: NodeJS.ProcessEnv = process.env): Config => {
165
+ let fromFile: Partial<Config> = {};
166
+ try {
167
+ fromFile = JSON.parse(readFileSync(CONFIG_PATH, "utf8")) as Partial<Config>;
168
+ } catch {
169
+ // No config file is the normal case; a malformed one falls back to defaults.
170
+ }
171
+ const cfg = { ...DEFAULTS, ...fromFile, budget: { ...DEFAULTS.budget, ...fromFile.budget } };
172
+ if (env.JEV_EFFORT_DEBUG) cfg.debug = true;
173
+ return cfg;
174
+ };
175
+
176
+ /** jev-cli and the other Jev tools keep the key here; reuse it rather than ask twice. */
177
+ const jevConfigKey = (): string | null => {
178
+ try {
179
+ const raw = JSON.parse(readFileSync(join(homedir(), ".jev", "config.json"), "utf8"));
180
+ return typeof raw?.apiKey === "string" ? raw.apiKey : null;
181
+ } catch {
182
+ return null;
183
+ }
184
+ };
185
+
186
+ export interface Route {
187
+ provider: Provider;
188
+ url: string;
189
+ apiKey: string;
190
+ model: string;
191
+ }
192
+
193
+ export const buildRoute = (provider: Provider, apiKey: string, cfg: Config): Route => {
194
+ const e = ENDPOINTS[provider];
195
+ return {
196
+ provider,
197
+ apiKey,
198
+ model: cfg.model ?? e.model,
199
+ url: `${cfg.baseUrl ?? e.baseUrl}${e.path}`,
200
+ };
201
+ };
202
+
203
+ export interface Judgement {
204
+ score: number;
205
+ confidence: number;
206
+ }
207
+
208
+ export const classify = async (
209
+ state: Record<string, unknown>,
210
+ route: Route,
211
+ cfg: Config,
212
+ ): Promise<Judgement | null> => {
213
+ const res = await fetch(route.url, {
214
+ method: "POST",
215
+ headers: {
216
+ Authorization: `Bearer ${route.apiKey}`,
217
+ "Content-Type": "application/json",
218
+ },
219
+ body: JSON.stringify({
220
+ model: route.model,
221
+ state,
222
+ questions: {
223
+ effort: { type: "score", instructions: INSTRUCTIONS, criteria: RUBRIC },
224
+ },
225
+ }),
226
+ signal: AbortSignal.timeout(cfg.timeoutMs),
227
+ });
228
+ if (!res.ok) {
229
+ throw new Error(`${route.provider} ${res.status}: ${(await res.text()).slice(0, 300)}`);
230
+ }
231
+ const body = (await res.json()) as {
232
+ answers?: { effort?: { score?: unknown; confidence?: unknown } };
233
+ };
234
+ const { score, confidence } = body.answers?.effort ?? {};
235
+ if (typeof score !== "number" || typeof confidence !== "number") return null;
236
+ return { score, confidence };
237
+ };
238
+
239
+ export default function (pi: ExtensionAPI) {
240
+ const cfg = loadConfig();
241
+ let paused = false;
242
+ let lastApplied: ThinkingLevel | null = null;
243
+ let lastJudgement: Judgement | null = null;
244
+ let lastRoute: Route | null = null;
245
+ let lastBudget: Budget | null = null;
246
+ let observedLimit: RateLimit | null = null;
247
+ let balance: { at: number; remainingFraction: number } | null = null;
248
+ // Set while we call setThinkingLevel so the resulting event is not read as a
249
+ // manual override. Model changes clamp the level too, hence the timestamp.
250
+ let applying = false;
251
+ let levelChurnUntil = 0;
252
+
253
+ const log = (msg: string) => {
254
+ if (cfg.debug) process.stderr.write(`[jev-effort] ${msg}\n`);
255
+ };
256
+
257
+ /**
258
+ * Resolved per turn rather than cached: pi refreshes the OpenRouter OAuth
259
+ * token in the background, and a stale copy would fail the rest of the session.
260
+ */
261
+ const resolveRoute = async (ctx: ExtensionContext): Promise<Route | null> => {
262
+ if (cfg.provider !== "openrouter") {
263
+ const key = cfg.apiKey ?? process.env.TYPESAFE_API_KEY ?? jevConfigKey();
264
+ if (key) return buildRoute("typesafe", key, cfg);
265
+ if (cfg.provider === "typesafe") return null;
266
+ }
267
+ const key =
268
+ (cfg.provider === "openrouter" ? cfg.apiKey : null) ??
269
+ process.env.OPENROUTER_API_KEY ??
270
+ (await ctx.modelRegistry.getApiKeyForProvider("openrouter")) ??
271
+ null;
272
+ return key ? buildRoute("openrouter", key, cfg) : null;
273
+ };
274
+
275
+ /**
276
+ * Remaining quota, how fast it is going, and when it returns. The spend half
277
+ * is always local; only the ceiling half needs the provider to say anything.
278
+ */
279
+ const resolveBudget = async (route: Route | null): Promise<Budget> => {
280
+ const b = cfg.budget;
281
+ const { start, end } = windowBounds(b.windowHours);
282
+ const spend = readSpend(start);
283
+ const none: Budget = {
284
+ source: "none",
285
+ remainingFraction: null,
286
+ resetAt: null,
287
+ spend,
288
+ exhaustsBeforeReset: false,
289
+ };
290
+ if (b.source === "none") return none;
291
+
292
+ if (b.source === "auto" || b.source === "headers") {
293
+ const l = observedLimit;
294
+ if (l?.limit && l.remaining !== undefined) {
295
+ const fraction = Math.max(0, Math.min(1, l.remaining / l.limit));
296
+ return {
297
+ source: "headers",
298
+ remainingFraction: fraction,
299
+ resetAt: l.resetAt ?? null,
300
+ spend,
301
+ exhaustsBeforeReset: false,
302
+ };
303
+ }
304
+ if (b.source === "headers") return none;
305
+ }
306
+
307
+ if ((b.source === "auto" || b.source === "openrouter") && route?.provider === "openrouter") {
308
+ const fresh = balance && Date.now() - balance.at < b.refreshMs;
309
+ if (!fresh) {
310
+ try {
311
+ const credits = await openRouterCredits(route.apiKey, cfg.timeoutMs);
312
+ balance = credits ? { at: Date.now(), remainingFraction: credits.remainingFraction } : null;
313
+ } catch {
314
+ // A balance lookup is advisory; losing it must not change the turn.
315
+ }
316
+ }
317
+ if (balance) {
318
+ return {
319
+ source: "openrouter",
320
+ remainingFraction: balance.remainingFraction,
321
+ // Purchased credits do not come back on a schedule.
322
+ resetAt: null,
323
+ spend,
324
+ exhaustsBeforeReset: false,
325
+ };
326
+ }
327
+ if (b.source === "openrouter") return none;
328
+ }
329
+
330
+ if ((b.source === "auto" || b.source === "local") && b.limitUsd) {
331
+ const remainingUsd = Math.max(0, b.limitUsd - spend.usd);
332
+ const minutesLeft = (end - Date.now()) / 60000;
333
+ return {
334
+ source: "local",
335
+ remainingFraction: Math.min(1, remainingUsd / b.limitUsd),
336
+ resetAt: end,
337
+ spend,
338
+ exhaustsBeforeReset:
339
+ spend.burnUsdPerMin > 0 && remainingUsd / spend.burnUsdPerMin < minutesLeft,
340
+ };
341
+ }
342
+ return none;
343
+ };
344
+
345
+ /** The tighter of the configured ceiling and the one budget pressure forces. */
346
+ const effectiveCeiling = (budget: Budget): ThinkingLevel | null => {
347
+ const forced = pressureCeiling(budget, cfg.budget.pressure) as ThinkingLevel | null;
348
+ if (!forced) return cfg.ceiling;
349
+ if (!cfg.ceiling) return forced;
350
+ return rankOf(forced) < rankOf(cfg.ceiling) ? forced : cfg.ceiling;
351
+ };
352
+
353
+ const status = (ctx: ExtensionContext) => {
354
+ if (!ctx.hasUI) return;
355
+ if (!cfg.enabled || paused) {
356
+ ctx.ui.setStatus(STATUS_KEY, paused ? "jev: paused" : undefined);
357
+ return;
358
+ }
359
+ const j = lastJudgement;
360
+ const b = lastBudget;
361
+ const parts = [j ? `jev: ${pi.getThinkingLevel()} (${j.confidence.toFixed(2)})` : "jev: auto"];
362
+ if (b?.remainingFraction !== null && b !== null) {
363
+ parts.push(`${Math.round(b.remainingFraction! * 100)}%`);
364
+ }
365
+ const reset = humanizeReset(b?.resetAt ?? null);
366
+ if (reset) parts.push(`resets ${reset}`);
367
+ ctx.ui.setStatus(STATUS_KEY, parts.join(" · "));
368
+ };
369
+
370
+ pi.on("session_start", async (_event, ctx) => {
371
+ levelChurnUntil = Date.now() + 2000;
372
+ if (cfg.enabled) {
373
+ const route = await resolveRoute(ctx);
374
+ if (!route) {
375
+ if (ctx.hasUI) {
376
+ ctx.ui.notify(
377
+ "jev-effort: no key. Set TYPESAFE_API_KEY, or sign in to OpenRouter in pi.",
378
+ "warning",
379
+ );
380
+ ctx.ui.setStatus(STATUS_KEY, "jev: no key");
381
+ }
382
+ return;
383
+ }
384
+ log(`route ${route.provider} ${route.url} model=${route.model}`);
385
+ }
386
+ status(ctx);
387
+ });
388
+
389
+ pi.on("after_provider_response", async (event, _ctx) => {
390
+ // Free of charge: whatever the provider already said about its own limits.
391
+ const seen = parseRateLimitHeaders(event.headers ?? {});
392
+ if (seen) observedLimit = { ...observedLimit, ...seen };
393
+ });
394
+
395
+ pi.on("model_select", async (_event, ctx) => {
396
+ // A model change can clamp the thinking level; that is not the user typing /effort.
397
+ levelChurnUntil = Date.now() + 1000;
398
+ status(ctx);
399
+ });
400
+
401
+ pi.on("thinking_level_select", async (event, ctx) => {
402
+ log(`level -> ${event.level} (applying=${applying})`);
403
+ if (applying || Date.now() < levelChurnUntil) return;
404
+ if (event.level === lastApplied) return;
405
+ if (cfg.enabled && !paused) {
406
+ paused = true;
407
+ if (ctx.hasUI) {
408
+ ctx.ui.notify(`jev-effort: manual ${event.level}, auto paused. /jev-effort on`, "info");
409
+ }
410
+ }
411
+ status(ctx);
412
+ });
413
+
414
+ pi.on("before_agent_start", async (event, ctx) => {
415
+ if (!cfg.enabled || paused) return;
416
+ const prompt = (event.prompt ?? "").trim();
417
+ // Short follow-ups ("continue", "yes") carry no signal of their own; classifying
418
+ // them would drag the level back down in the middle of hard work.
419
+ if (prompt.length < cfg.minPromptChars) return;
420
+
421
+ const current = pi.getThinkingLevel() as ThinkingLevel;
422
+ let judgement: Judgement | null = null;
423
+ try {
424
+ const route = await resolveRoute(ctx);
425
+ if (!route) return;
426
+ lastRoute = route;
427
+ judgement = await classify(
428
+ {
429
+ prompt,
430
+ project: basename(ctx.cwd),
431
+ model: ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : null,
432
+ current_thinking_level: current,
433
+ },
434
+ route,
435
+ cfg,
436
+ );
437
+ } catch (err) {
438
+ // Fail open: a judgement layer must never cost the user their turn.
439
+ log(`classify failed: ${String(err)}`);
440
+ return;
441
+ }
442
+ if (!judgement) return;
443
+ lastJudgement = judgement;
444
+
445
+ const budget = await resolveBudget(lastRoute);
446
+ lastBudget = budget;
447
+ const ceiling = effectiveCeiling(budget);
448
+ const next = plan(current, judgement.score, judgement.confidence, cfg, ceiling);
449
+ const left =
450
+ budget.remainingFraction === null
451
+ ? "n/a"
452
+ : `${Math.round(budget.remainingFraction * 100)}%`;
453
+ log(
454
+ `score=${judgement.score.toFixed(2)} conf=${judgement.confidence.toFixed(2)} ` +
455
+ `budget=${budget.source}:${left} burn=$${budget.spend.burnUsdPerMin.toFixed(5)}/min ` +
456
+ `ceiling=${ceiling ?? "none"} ${current} -> ${next ?? current}`,
457
+ );
458
+ if (next) {
459
+ applying = true;
460
+ try {
461
+ pi.setThinkingLevel(next);
462
+ } finally {
463
+ applying = false;
464
+ }
465
+ // Pi clamps to what the model supports, so record what actually took effect.
466
+ lastApplied = pi.getThinkingLevel() as ThinkingLevel;
467
+ if (cfg.notify && ctx.hasUI) ctx.ui.notify(`jev-effort: ${current} -> ${next}`, "info");
468
+ }
469
+ status(ctx);
470
+ });
471
+
472
+ pi.registerCommand("jev-effort", {
473
+ description: "Jev-driven automatic thinking level: on | off | status",
474
+ handler: async (args, ctx) => {
475
+ const arg = args.trim().toLowerCase();
476
+ if (arg === "on") {
477
+ cfg.enabled = true;
478
+ paused = false;
479
+ } else if (arg === "off") {
480
+ cfg.enabled = false;
481
+ } else if (arg && arg !== "status") {
482
+ ctx.ui.notify("jev-effort: usage /jev-effort [on|off|status]", "warning");
483
+ return;
484
+ }
485
+ const state = !cfg.enabled ? "off" : paused ? "paused" : "on";
486
+ const j = lastJudgement;
487
+ const b = lastBudget ?? (await resolveBudget(lastRoute));
488
+ lastBudget = b;
489
+ const left = b.remainingFraction === null ? "?" : `${Math.round(b.remainingFraction * 100)}%`;
490
+ const reset = humanizeReset(b.resetAt);
491
+ ctx.ui.notify(
492
+ `jev-effort ${state} · via ${lastRoute?.provider ?? "unresolved"} · ` +
493
+ `level ${pi.getThinkingLevel()}` +
494
+ (j ? ` · last score ${j.score.toFixed(2)} @ ${j.confidence.toFixed(2)}` : "") +
495
+ ` · budget ${b.source} ${left}` +
496
+ (reset ? ` (resets ${reset})` : "") +
497
+ ` · ${b.spend.tokens.toLocaleString()} tok / $${b.spend.usd.toFixed(3)} ` +
498
+ `in ${cfg.budget.windowHours}h`,
499
+ "info",
500
+ );
501
+ status(ctx);
502
+ },
503
+ });
504
+ }
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "pi-jev-effort",
3
+ "version": "0.1.0",
4
+ "description": "Pi extension that sets the session's thinking level per prompt from a TypeSafe Jev judgement, clamped by the quota you have left",
5
+ "keywords": [
6
+ "pi-package",
7
+ "pi",
8
+ "coding-agent",
9
+ "extension",
10
+ "jev",
11
+ "typesafe",
12
+ "openrouter",
13
+ "thinking-level",
14
+ "reasoning-effort",
15
+ "usage"
16
+ ],
17
+ "author": "namenu <namenu@gmail.com>",
18
+ "license": "MIT",
19
+ "type": "module",
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/namenu/pi-jev-effort.git"
23
+ },
24
+ "homepage": "https://github.com/namenu/pi-jev-effort#readme",
25
+ "bugs": {
26
+ "url": "https://github.com/namenu/pi-jev-effort/issues"
27
+ },
28
+ "engines": {
29
+ "node": ">=22.6"
30
+ },
31
+ "files": [
32
+ "index.ts",
33
+ "usage.ts",
34
+ "README.md",
35
+ "LICENSE"
36
+ ],
37
+ "scripts": {
38
+ "test": "node --test"
39
+ },
40
+ "pi": {
41
+ "extensions": ["./index.ts"]
42
+ }
43
+ }
package/usage.ts ADDED
@@ -0,0 +1,237 @@
1
+ /**
2
+ * Budget signal: how much of the current quota is left, how fast it is going,
3
+ * and when it comes back. Read the way ccusage and CodexBar read theirs — from
4
+ * the local transcripts the agent already writes, plus whatever the provider
5
+ * says about its own limits.
6
+ */
7
+ import { homedir } from "node:os";
8
+ import { join } from "node:path";
9
+ import { readFileSync, readdirSync, statSync } from "node:fs";
10
+
11
+ const SESSIONS_DIR = join(homedir(), ".pi", "agent", "sessions");
12
+
13
+ export interface Spend {
14
+ /** Dollars spent inside the window. */
15
+ usd: number;
16
+ tokens: number;
17
+ /** Start of the window actually observed, as epoch ms. */
18
+ since: number;
19
+ /** Dollars per minute over the observed span; 0 when there is nothing to divide by. */
20
+ burnUsdPerMin: number;
21
+ }
22
+
23
+ interface FileCache {
24
+ size: number;
25
+ /** [timestampMs, usd, tokens] per assistant message, ascending. */
26
+ rows: [number, number, number][];
27
+ }
28
+
29
+ const cache = new Map<string, FileCache>();
30
+
31
+ /** Assistant messages carry `usage.cost.total`; everything else is skipped. */
32
+ const parseRows = (path: string): [number, number, number][] => {
33
+ const rows: [number, number, number][] = [];
34
+ for (const line of readFileSync(path, "utf8").split("\n")) {
35
+ if (!line || !line.includes('"usage"')) continue;
36
+ try {
37
+ const entry = JSON.parse(line) as {
38
+ type?: string;
39
+ timestamp?: string;
40
+ message?: {
41
+ usage?: { totalTokens?: number; cost?: { total?: number } };
42
+ };
43
+ };
44
+ const usage = entry.message?.usage;
45
+ const at = Date.parse(entry.timestamp ?? "");
46
+ if (!usage || Number.isNaN(at)) continue;
47
+ rows.push([at, usage.cost?.total ?? 0, usage.totalTokens ?? 0]);
48
+ } catch {
49
+ // A half-written last line is normal while a session is live.
50
+ }
51
+ }
52
+ return rows;
53
+ };
54
+
55
+ /**
56
+ * Sum what the agent spent since `since`. Files are re-read only when their
57
+ * size changed, so a long session costs one parse per turn at most.
58
+ */
59
+ export const readSpend = (since: number, dir = SESSIONS_DIR): Spend => {
60
+ let usd = 0;
61
+ let tokens = 0;
62
+ let earliest = Number.POSITIVE_INFINITY;
63
+ let latest = 0;
64
+ let projects: string[];
65
+ try {
66
+ projects = readdirSync(dir);
67
+ } catch {
68
+ return { usd: 0, tokens: 0, since, burnUsdPerMin: 0 };
69
+ }
70
+ for (const project of projects) {
71
+ let files: string[];
72
+ try {
73
+ files = readdirSync(join(dir, project));
74
+ } catch {
75
+ continue;
76
+ }
77
+ for (const name of files) {
78
+ if (!name.endsWith(".jsonl")) continue;
79
+ const path = join(dir, project, name);
80
+ let size: number;
81
+ let mtimeMs: number;
82
+ try {
83
+ const st = statSync(path);
84
+ size = st.size;
85
+ mtimeMs = st.mtimeMs;
86
+ } catch {
87
+ continue;
88
+ }
89
+ // A file untouched since the window opened cannot hold rows inside it.
90
+ if (mtimeMs < since) {
91
+ cache.delete(path);
92
+ continue;
93
+ }
94
+ let entry = cache.get(path);
95
+ if (!entry || entry.size !== size) {
96
+ entry = { size, rows: parseRows(path) };
97
+ cache.set(path, entry);
98
+ }
99
+ for (const [at, cost, tok] of entry.rows) {
100
+ if (at < since) continue;
101
+ usd += cost;
102
+ tokens += tok;
103
+ if (at < earliest) earliest = at;
104
+ if (at > latest) latest = at;
105
+ }
106
+ }
107
+ }
108
+ const spanMin = latest > earliest ? (latest - earliest) / 60000 : 0;
109
+ return {
110
+ usd,
111
+ tokens,
112
+ since,
113
+ burnUsdPerMin: spanMin > 0 ? usd / spanMin : 0,
114
+ };
115
+ };
116
+
117
+ export interface RateLimit {
118
+ limit?: number;
119
+ remaining?: number;
120
+ /** Epoch ms. */
121
+ resetAt?: number;
122
+ }
123
+
124
+ /**
125
+ * Pull whatever a provider volunteered about its own limits. Header names
126
+ * differ per provider and many send nothing, so every field is optional.
127
+ */
128
+ export const parseRateLimitHeaders = (headers: Record<string, string>): RateLimit | null => {
129
+ const h: Record<string, string> = {};
130
+ for (const [k, v] of Object.entries(headers)) h[k.toLowerCase()] = v;
131
+ const num = (...keys: string[]): number | undefined => {
132
+ for (const k of keys) {
133
+ const v = Number(h[k]);
134
+ if (h[k] !== undefined && !Number.isNaN(v)) return v;
135
+ }
136
+ return undefined;
137
+ };
138
+ const limit = num("x-ratelimit-limit", "anthropic-ratelimit-unified-limit");
139
+ const remaining = num(
140
+ "x-ratelimit-remaining",
141
+ "anthropic-ratelimit-unified-remaining",
142
+ "x-ratelimit-remaining-tokens",
143
+ );
144
+ const rawReset = num(
145
+ "x-ratelimit-reset",
146
+ "anthropic-ratelimit-unified-reset",
147
+ "x-ratelimit-reset-tokens",
148
+ );
149
+ const retryAfter = num("retry-after");
150
+ let resetAt: number | undefined;
151
+ if (rawReset !== undefined) {
152
+ // Seconds-since-epoch, milliseconds-since-epoch and seconds-from-now all appear.
153
+ resetAt =
154
+ rawReset > 1e12 ? rawReset : rawReset > 1e9 ? rawReset * 1000 : Date.now() + rawReset * 1000;
155
+ } else if (retryAfter !== undefined) {
156
+ resetAt = Date.now() + retryAfter * 1000;
157
+ }
158
+ if (limit === undefined && remaining === undefined && resetAt === undefined) return null;
159
+ return { limit, remaining, resetAt };
160
+ };
161
+
162
+ export interface Budget {
163
+ source: "headers" | "openrouter" | "local" | "none";
164
+ /** 0..1, or null when nothing credible says what the ceiling is. */
165
+ remainingFraction: number | null;
166
+ /** Epoch ms when the quota comes back, or null for a balance that does not reset. */
167
+ resetAt: number | null;
168
+ spend: Spend;
169
+ /** True when the current burn rate empties the quota before it resets. */
170
+ exhaustsBeforeReset: boolean;
171
+ }
172
+
173
+ export interface Pressure {
174
+ remainingBelow: number;
175
+ ceiling: string;
176
+ }
177
+
178
+ /**
179
+ * Pick the lowest ceiling whose threshold the remaining fraction has crossed.
180
+ * A projected exhaustion before reset counts as one threshold worse.
181
+ */
182
+ export const pressureCeiling = (budget: Budget, rules: Pressure[]): string | null => {
183
+ if (budget.remainingFraction === null) return null;
184
+ const sorted = [...rules].sort((a, b) => b.remainingBelow - a.remainingBelow);
185
+ let hit: string | null = null;
186
+ let index = -1;
187
+ sorted.forEach((rule, i) => {
188
+ if (budget.remainingFraction! < rule.remainingBelow) {
189
+ hit = rule.ceiling;
190
+ index = i;
191
+ }
192
+ });
193
+ if (budget.exhaustsBeforeReset) {
194
+ const next = sorted[index + 1];
195
+ if (next) return next.ceiling;
196
+ if (!hit && sorted.length) return sorted[0].ceiling;
197
+ }
198
+ return hit;
199
+ };
200
+
201
+ /** OpenRouter reports purchased credits rather than a window, so this never resets. */
202
+ export const openRouterCredits = async (
203
+ apiKey: string,
204
+ timeoutMs: number,
205
+ ): Promise<{ remainingFraction: number; remainingUsd: number } | null> => {
206
+ const res = await fetch("https://openrouter.ai/api/v1/credits", {
207
+ headers: { Authorization: `Bearer ${apiKey}` },
208
+ signal: AbortSignal.timeout(timeoutMs),
209
+ });
210
+ if (!res.ok) return null;
211
+ const body = (await res.json()) as { data?: { total_credits?: number; total_usage?: number } };
212
+ const total = body.data?.total_credits;
213
+ const used = body.data?.total_usage;
214
+ if (typeof total !== "number" || typeof used !== "number" || total <= 0) return null;
215
+ const remainingUsd = Math.max(0, total - used);
216
+ return { remainingFraction: Math.min(1, remainingUsd / total), remainingUsd };
217
+ };
218
+
219
+ export const humanizeReset = (resetAt: number | null, now = Date.now()): string => {
220
+ if (resetAt === null) return "";
221
+ const mins = Math.max(0, Math.round((resetAt - now) / 60000));
222
+ if (mins < 60) return `${mins}m`;
223
+ return `${Math.floor(mins / 60)}h${String(mins % 60).padStart(2, "0")}m`;
224
+ };
225
+
226
+ /**
227
+ * Fixed rolling blocks on a UTC grid, the way ccusage treats Claude's 5-hour
228
+ * windows: everyone with the same window length agrees on where it starts.
229
+ */
230
+ export const windowBounds = (
231
+ windowHours: number,
232
+ now = Date.now(),
233
+ ): { start: number; end: number } => {
234
+ const ms = Math.max(1, windowHours) * 3600_000;
235
+ const start = Math.floor(now / ms) * ms;
236
+ return { start, end: start + ms };
237
+ };