pi-jar 0.2.2 → 0.2.4
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 +46 -14
- package/docs/DEVELOPMENT.md +2 -1
- package/docs/EFFICIENCY_PLAN.md +23 -0
- package/docs/INTEGRATIONS.md +24 -7
- package/docs/SUBAGENT-TOOLS.md +1 -1
- package/docs/WORKFLOWS.md +2 -2
- package/extensions/index.ts +87 -16
- package/package.json +1 -1
- package/src/advisor.ts +21 -9
- package/src/ask-tool.ts +2 -7
- package/src/cache-breaks-view.ts +56 -0
- package/src/cache-breaks.ts +521 -0
- package/src/compact-tools.ts +148 -8
- package/src/context-budget.ts +226 -0
- package/src/context-diet.ts +158 -17
- package/src/{democracy.ts → council.ts} +19 -22
- package/src/delegate.ts +80 -50
- package/src/discussion-hub.ts +9 -6
- package/src/discussion.ts +31 -22
- package/src/footer.ts +5 -1
- package/src/goal-loop.ts +3 -0
- package/src/info-panels.ts +15 -3
- package/src/model-roles.ts +26 -2
- package/src/panel.ts +1 -1
- package/src/plan.ts +4 -2
- package/src/profile-ui.ts +21 -6
- package/src/profiles.ts +8 -3
- package/src/quota.ts +71 -49
- package/src/roles-ui.ts +4 -2
- package/src/settings-ui.ts +30 -8
- package/src/settings.ts +24 -1
- package/src/shells.ts +77 -15
- package/src/subagent-tools.ts +1 -1
- package/src/suggest.ts +4 -11
- package/src/task-tool.ts +3 -9
- package/src/tool-activation.ts +14 -0
- package/src/usage-view.ts +8 -4
- package/src/usage.ts +4 -2
package/src/model-roles.ts
CHANGED
|
@@ -153,6 +153,30 @@ export function describeRole(row: RoleRow): string {
|
|
|
153
153
|
return row.role + " · " + (row.spec?.startsWith("@") ? row.spec + " → " : "") + target + (row.scope === "project" ? " · project" : "");
|
|
154
154
|
}
|
|
155
155
|
|
|
156
|
+
/** Delegated roles meant for a cheaper model than the main session: scouts discover, reviewers check. */
|
|
157
|
+
export const ECONOMY_ROLES = ["scout", "reviewer"] as const;
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Economy roles whose subagents run on the main session model although a cheaper model is available, as
|
|
161
|
+
* jar_delegate picks it: the role's first available candidate, then the default role's, then the current
|
|
162
|
+
* model. The fix names @smol when the Fast role already points at a cheaper available model.
|
|
163
|
+
*/
|
|
164
|
+
export function premiumRoleWarnings(roles: Pick<ModelRoleManager, "resolveCandidates">, ctx: Pick<ExtensionContext, "model" | "modelRegistry">): string[] {
|
|
165
|
+
const main = ctx.model;
|
|
166
|
+
if (!main) return [];
|
|
167
|
+
const mainId = main.provider + "/" + main.id;
|
|
168
|
+
const mainPrice = main.cost.input + main.cost.output;
|
|
169
|
+
const models = ctx.modelRegistry.getAvailable();
|
|
170
|
+
const cheaper = new Set(models.filter((model) => model.cost.input + model.cost.output < mainPrice).map((model) => model.provider + "/" + model.id));
|
|
171
|
+
// Without a cheaper option there is nothing to recommend: a main model that is already economical is fine.
|
|
172
|
+
if (!cheaper.size) return [];
|
|
173
|
+
const available = new Set(models.map((model) => model.provider + "/" + model.id));
|
|
174
|
+
const pick = (role: string) => roles.resolveCandidates(role).map((item) => item.provider + "/" + item.model).find((id) => available.has(id));
|
|
175
|
+
const fix = cheaper.has(pick("smol") ?? "") ? "@smol" : "provider/model";
|
|
176
|
+
return ECONOMY_ROLES.filter((role) => (pick(role) ?? pick("default") ?? mainId) === mainId)
|
|
177
|
+
.map((role) => `⚠ ${role} subagents run on the main model ${mainId}; use a mini/haiku-class model: /roles set ${role} ${fix}`);
|
|
178
|
+
}
|
|
179
|
+
|
|
156
180
|
export class ModelRoleManager {
|
|
157
181
|
private global: RoleConfig = readConfig(join(getAgentDir(), ROLE_FILE));
|
|
158
182
|
private project: RoleConfig = { version: 2, roles: {} };
|
|
@@ -392,8 +416,8 @@ export class ModelRoleManager {
|
|
|
392
416
|
const verb = (parts[0] ?? "").toLowerCase();
|
|
393
417
|
if (!verb || verb === "list") {
|
|
394
418
|
if (!verb && ctx.hasUI && ctx.mode === "tui" && openUi) { await openUi(ctx); return; }
|
|
395
|
-
ctx.ui.notify(this.list().map((row) => describeRole(row) + (this.fallbackSpecs(row.role).length
|
|
396
|
-
? " · fallbacks: " + this.fallbackSpecs(row.role).join(" → ") : "")).join("\n"), "info");
|
|
419
|
+
ctx.ui.notify([...this.list().map((row) => describeRole(row) + (this.fallbackSpecs(row.role).length
|
|
420
|
+
? " · fallbacks: " + this.fallbackSpecs(row.role).join(" → ") : "")), ...premiumRoleWarnings(this, ctx)].join("\n"), "info");
|
|
397
421
|
return;
|
|
398
422
|
}
|
|
399
423
|
if (verb === "fallback") {
|
package/src/panel.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { RowCache } from "./row-cache.ts";
|
|
|
5
5
|
export type Paint = (color: string, text: string) => string;
|
|
6
6
|
export interface PanelTab { name: string; render(width: number, fg: Paint): string[] }
|
|
7
7
|
|
|
8
|
-
/** Tabbed, scrollable info panel (Usage / Context). Tab or click switches tabs; Esc closes. */
|
|
8
|
+
/** Tabbed, scrollable info panel (Usage / Context / Cache). Tab or click switches tabs; Esc closes. */
|
|
9
9
|
export async function openPanel(ctx: ExtensionContext, tabs: readonly PanelTab[], initial = 0): Promise<void> {
|
|
10
10
|
if (!ctx.hasUI || ctx.mode !== "tui") return;
|
|
11
11
|
await ctx.ui.custom<void>((tui, theme, _keys, done) => {
|
package/src/plan.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { extractApproachSteps, isSafePlanCommand, PLAN_TEMPLATE, planTextFromSte
|
|
|
11
11
|
import { cleanText } from "./status.ts";
|
|
12
12
|
import { sessionBranch } from "./session-branch.ts";
|
|
13
13
|
import { type TodoStore } from "./tasks.ts";
|
|
14
|
+
import { setToolActive } from "./tool-activation.ts";
|
|
14
15
|
import { projectWorkflowMessages } from "./workflow-context.ts";
|
|
15
16
|
|
|
16
17
|
export const PLAN_ENTRY = "pi-jar.plan";
|
|
@@ -139,7 +140,8 @@ export class PlanMode {
|
|
|
139
140
|
this.enabled = true;
|
|
140
141
|
this.pendingReview = false;
|
|
141
142
|
this.reminders = 0;
|
|
142
|
-
|
|
143
|
+
// The submit tool is active only in plan mode; never restore it with the pre-plan loadout.
|
|
144
|
+
this.toolsBefore = this.pi.getActiveTools().filter((name) => name !== PLAN_SUBMIT_TOOL);
|
|
143
145
|
this.pi.setActiveTools(this.planTools(this.toolsBefore));
|
|
144
146
|
this.onEnter?.(ctx);
|
|
145
147
|
this.restoreRole = await this.roles.activateTemporary("plan", ctx);
|
|
@@ -414,7 +416,7 @@ export class PlanMode {
|
|
|
414
416
|
});
|
|
415
417
|
|
|
416
418
|
this.pi.on("before_agent_start", async () => {
|
|
417
|
-
if (!this.enabled) return;
|
|
419
|
+
if (!this.enabled) { setToolActive(this.pi, PLAN_SUBMIT_TOOL, false); return; }
|
|
418
420
|
return { message: { customType: "pi-jar.plan-context", content: this.context(), display: false } };
|
|
419
421
|
});
|
|
420
422
|
|
package/src/profile-ui.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import type { JarProfile, ProfileStore } from "./profiles.ts";
|
|
3
|
+
import type { JarVisualSettings } from "./settings.ts";
|
|
3
4
|
|
|
4
5
|
export type ProfileAction = "create" | "switch";
|
|
5
6
|
|
|
@@ -14,11 +15,16 @@ export interface ProfileUiOptions {
|
|
|
14
15
|
openRoles(): Promise<void>;
|
|
15
16
|
}
|
|
16
17
|
|
|
18
|
+
/** Opt-in preset for heavy parallel work: the current settings with a larger retained subagent pool. */
|
|
19
|
+
export const SWARM_SETTINGS = { maxSubagents: 4 } as const satisfies Partial<JarVisualSettings>;
|
|
17
20
|
const CREATE = "+ Create profile…";
|
|
21
|
+
const SWARM = `+ Create Swarm profile (${SWARM_SETTINGS.maxSubagents} subagents)…`;
|
|
18
22
|
const FOLLOW = "Follow Pi";
|
|
19
23
|
|
|
20
|
-
/** Name and theme are the only creation questions; roles are configured afterwards in the roles UI.
|
|
21
|
-
|
|
24
|
+
/** Name and theme are the only creation questions; roles are configured afterwards in the roles UI.
|
|
25
|
+
* A preset patches the copied settings once the copy is saved. */
|
|
26
|
+
export async function promptNewProfile(ctx: ExtensionContext, options: Pick<ProfileUiOptions, "store" | "themes" | "create">,
|
|
27
|
+
preset?: Partial<JarVisualSettings>): Promise<JarProfile | undefined> {
|
|
22
28
|
const name = await ctx.ui.input("New profile name (1–32 characters)", "");
|
|
23
29
|
if (name === undefined) return undefined;
|
|
24
30
|
if (/[\u0000-\u001f\u007f-\u009f]/.test(name)) { ctx.ui.notify("Profile name cannot contain control characters", "warning"); return undefined; }
|
|
@@ -31,21 +37,30 @@ export async function promptNewProfile(ctx: ExtensionContext, options: Pick<Prof
|
|
|
31
37
|
}
|
|
32
38
|
const selected = await ctx.ui.select("Choose a theme for " + normalized, [FOLLOW, ...options.themes.filter((item) => item !== "follow")]);
|
|
33
39
|
if (!selected) return undefined;
|
|
34
|
-
|
|
40
|
+
let profile: JarProfile;
|
|
41
|
+
try { profile = options.create(normalized, selected === FOLLOW ? "follow" : selected); }
|
|
35
42
|
catch (error) { ctx.ui.notify("Could not create profile: " + (error as Error).message, "error"); return undefined; }
|
|
43
|
+
if (!preset) return profile;
|
|
44
|
+
try { return options.store.update(profile.name, { settings: { ...profile.settings, ...preset } }); }
|
|
45
|
+
catch (error) {
|
|
46
|
+
ctx.ui.notify(`Created profile ${profile.name}, but could not apply its preset: ${(error as Error).message}`, "warning");
|
|
47
|
+
return profile;
|
|
48
|
+
}
|
|
36
49
|
}
|
|
37
50
|
|
|
38
51
|
/** /profiles and the settings Profiles tab: create a profile or switch to one, nothing else. */
|
|
39
52
|
export async function openProfiles(ctx: ExtensionContext, options: ProfileUiOptions, action?: ProfileAction): Promise<void> {
|
|
40
53
|
if (!ctx.hasUI || ctx.mode !== "tui") return;
|
|
41
54
|
const active = options.store.activeName;
|
|
55
|
+
let preset: Partial<JarVisualSettings> | undefined;
|
|
42
56
|
if (action !== "create") {
|
|
43
57
|
const labels = new Map(options.store.list().map((profile) => [profile.name === active ? `${profile.name} (active)` : profile.name, profile.name]));
|
|
44
|
-
const choice = await ctx.ui.select(action === "switch" ? "Switch profile" : "Profiles", action === "switch" ? [...labels.keys()] : [CREATE, ...labels.keys()]);
|
|
58
|
+
const choice = await ctx.ui.select(action === "switch" ? "Switch profile" : "Profiles", action === "switch" ? [...labels.keys()] : [CREATE, SWARM, ...labels.keys()]);
|
|
45
59
|
if (!choice) return;
|
|
46
|
-
if (choice !== CREATE) { await options.select(labels.get(choice)!); return; }
|
|
60
|
+
if (choice !== CREATE && choice !== SWARM) { await options.select(labels.get(choice)!); return; }
|
|
61
|
+
if (choice === SWARM) preset = SWARM_SETTINGS;
|
|
47
62
|
}
|
|
48
|
-
const profile = await promptNewProfile(ctx, options);
|
|
63
|
+
const profile = await promptNewProfile(ctx, options, preset);
|
|
49
64
|
if (!profile) return;
|
|
50
65
|
if (options.switchBlocked()) {
|
|
51
66
|
ctx.ui.notify(`Created profile ${profile.name} with ${active}'s model roles. Switch to it in a fresh session with /profiles, then set its roles with /roles.`, "info");
|
package/src/profiles.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { isRoleName, normalizeSpec, parseRoleConfig, type RoleConfig } from "./model-roles.ts";
|
|
4
|
-
import { isMaxSubagents, defaultVisualSettings, loadVisualSettings, type JarVisualSettings } from "./settings.ts";
|
|
4
|
+
import { isMaxSubagents, defaultVisualSettings, loadVisualSettings, parseContextBudget, DEFAULT_CONTEXT_BUDGET, type JarVisualSettings } from "./settings.ts";
|
|
5
5
|
import { ACCENT_NAMES } from "./accent.ts";
|
|
6
6
|
import { FOOTER_FIELDS } from "./footer-settings.ts";
|
|
7
7
|
import { ICON_SETS } from "./icons.ts";
|
|
@@ -41,7 +41,10 @@ function normalizeProfile(value: unknown): JarProfile | undefined {
|
|
|
41
41
|
|| !isPlainObject(settings.footer) || typeof settings.animations !== "boolean" || typeof settings.ui !== "boolean"
|
|
42
42
|
|| typeof settings.composer !== "boolean" || typeof settings.mascot !== "boolean" || typeof settings.suggestions !== "boolean"
|
|
43
43
|
|| typeof settings.advisor !== "boolean" || typeof settings.advisorGates !== "boolean"
|
|
44
|
-
|| (settings.contextDiet !== undefined && typeof settings.contextDiet !== "boolean")
|
|
44
|
+
|| (settings.contextDiet !== undefined && typeof settings.contextDiet !== "boolean")
|
|
45
|
+
|| (settings.cacheDiagnostics !== undefined && typeof settings.cacheDiagnostics !== "boolean")
|
|
46
|
+
|| (settings.contextBudget !== undefined && !parseContextBudget(settings.contextBudget))
|
|
47
|
+
|| (settings.readCache !== undefined && typeof settings.readCache !== "boolean") || !Number.isInteger(settings.goalRounds)
|
|
45
48
|
|| (settings.goalRounds as number) < 1 || (settings.goalRounds as number) > 50
|
|
46
49
|
|| !isMaxSubagents(settings.maxSubagents) || typeof settings.accent !== "string"
|
|
47
50
|
|| !(settings.accent === "follow" || settings.accent === "default" || ACCENT_NAMES.includes(settings.accent as never))
|
|
@@ -50,7 +53,9 @@ function normalizeProfile(value: unknown): JarProfile | undefined {
|
|
|
50
53
|
if (raw.name === DEFAULT_PROFILE && raw.id !== "default") return undefined;
|
|
51
54
|
if (raw.name !== DEFAULT_PROFILE && (typeof raw.id !== "string" || !/^profile-[a-z0-9-]{8,64}$/.test(raw.id))) return undefined;
|
|
52
55
|
for (const [role, spec] of Object.entries(raw.roles.roles)) if (!isRoleName(role) || typeof spec !== "string" || !normalizeSpec(spec)) return undefined;
|
|
53
|
-
|
|
56
|
+
const contextBudget = parseContextBudget(settings.contextBudget) ?? { ...DEFAULT_CONTEXT_BUDGET };
|
|
57
|
+
return { id: raw.id as string, name: raw.name, theme: raw.theme, settings: { ...raw.settings, contextDiet: settings.contextDiet ?? false,
|
|
58
|
+
cacheDiagnostics: settings.cacheDiagnostics ?? true, contextBudget, readCache: settings.readCache ?? false } as unknown as JarVisualSettings, roles: parseRoleConfig(raw.roles) };
|
|
54
59
|
}
|
|
55
60
|
function atomicSave(file: string, document: ProfileDocument): string {
|
|
56
61
|
mkdirSync(join(file, ".."), { recursive: true });
|
package/src/quota.ts
CHANGED
|
@@ -69,57 +69,70 @@ function codexAccountId(token: string): string | undefined {
|
|
|
69
69
|
} catch { return undefined; }
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
-
/**
|
|
72
|
+
/**
|
|
73
|
+
* Never persist credentials; resolve through Pi's public model registry only on explicit opt-in.
|
|
74
|
+
* Resolves undefined only when cancelled; every other miss throws a short, user-facing reason.
|
|
75
|
+
*/
|
|
73
76
|
export async function fetchQuota(provider: QuotaProvider, resolveAuth: (provider: string) => Promise<{ auth: { apiKey?: string }; source?: string } | undefined>, signal: AbortSignal): Promise<Quota | undefined> {
|
|
77
|
+
const resolved = await resolveAuth(provider);
|
|
78
|
+
if (signal.aborted) return undefined;
|
|
79
|
+
if (!resolved?.source?.toLowerCase().includes("oauth") || !resolved.auth.apiKey) throw new Error("needs a subscription login (/login), not an API key");
|
|
80
|
+
const token = resolved.auth.apiKey;
|
|
81
|
+
const headers: Record<string, string> = { Authorization: `Bearer ${token}`, Accept: "application/json" };
|
|
82
|
+
let url: string;
|
|
83
|
+
if (provider === "openai-codex") {
|
|
84
|
+
const id = codexAccountId(token);
|
|
85
|
+
if (!id) throw new Error("login token has no ChatGPT account id");
|
|
86
|
+
headers["ChatGPT-Account-Id"] = id;
|
|
87
|
+
url = "https://chatgpt.com/backend-api/wham/usage";
|
|
88
|
+
} else {
|
|
89
|
+
headers["anthropic-beta"] = "oauth-2025-04-20";
|
|
90
|
+
url = "https://api.anthropic.com/api/oauth/usage";
|
|
91
|
+
}
|
|
92
|
+
let response: Response;
|
|
74
93
|
try {
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
// to position only when the duration is absent.
|
|
104
|
-
const quota: Quota = {};
|
|
105
|
-
for (const [window, fallback] of [[rate.primary_window, "fiveHour"], [rate.secondary_window, "week"]] as const) {
|
|
106
|
-
const value = parse(window);
|
|
107
|
-
if (!value) continue;
|
|
108
|
-
const seconds = (window as Record<string, unknown>).limit_window_seconds;
|
|
109
|
-
const slot = typeof seconds === "number" && seconds > 0 ? (seconds <= 24 * 60 * 60 ? "fiveHour" : "week") : fallback;
|
|
110
|
-
if (!quota[slot]) quota[slot] = value;
|
|
111
|
-
}
|
|
112
|
-
return parseQuota(quota);
|
|
94
|
+
response = await fetch(url, { method: "GET", headers, signal: AbortSignal.any([signal, AbortSignal.timeout(5000)]) });
|
|
95
|
+
} catch (error) {
|
|
96
|
+
if (signal.aborted) return undefined;
|
|
97
|
+
throw new Error(error instanceof Error && error.name === "TimeoutError" ? "request timed out" : "request failed: " + String(error instanceof Error ? error.message : error));
|
|
98
|
+
}
|
|
99
|
+
// The Anthropic usage endpoint is shared with Claude Code and rate-limits aggressively.
|
|
100
|
+
if (!response.ok) throw new Error(response.status === 429 ? "rate limited by the provider (HTTP 429)" : `HTTP ${response.status}`);
|
|
101
|
+
const data: unknown = await response.json().catch(() => undefined);
|
|
102
|
+
if (!data || typeof data !== "object") throw new Error("unreadable usage response");
|
|
103
|
+
const body = data as Record<string, unknown>;
|
|
104
|
+
let quota: Quota | undefined;
|
|
105
|
+
if (provider === "openai-codex") {
|
|
106
|
+
const rate = body.rate_limit && typeof body.rate_limit === "object" ? body.rate_limit as Record<string, unknown> : {};
|
|
107
|
+
const parse = (window: unknown): QuotaWindow | undefined => {
|
|
108
|
+
if (!window || typeof window !== "object") return undefined;
|
|
109
|
+
const item = window as Record<string, unknown>;
|
|
110
|
+
return windowValue({ used: item.used_percent, resetsAt: typeof item.reset_at === "number" ? item.reset_at * 1000 : undefined });
|
|
111
|
+
};
|
|
112
|
+
// Window slots are not fixed: Plus accounts get a single weekly `primary_window` and a null
|
|
113
|
+
// secondary. Classify by `limit_window_seconds` (≤ 1 day is the short window), falling back
|
|
114
|
+
// to position only when the duration is absent.
|
|
115
|
+
const windows: Quota = {};
|
|
116
|
+
for (const [window, fallback] of [[rate.primary_window, "fiveHour"], [rate.secondary_window, "week"]] as const) {
|
|
117
|
+
const value = parse(window);
|
|
118
|
+
if (!value) continue;
|
|
119
|
+
const seconds = (window as Record<string, unknown>).limit_window_seconds;
|
|
120
|
+
const slot = typeof seconds === "number" && seconds > 0 ? (seconds <= 24 * 60 * 60 ? "fiveHour" : "week") : fallback;
|
|
121
|
+
if (!windows[slot]) windows[slot] = value;
|
|
113
122
|
}
|
|
123
|
+
quota = parseQuota(windows);
|
|
124
|
+
} else {
|
|
125
|
+
// `utilization` is already a percentage (7.0 means 7%), not a fraction.
|
|
114
126
|
const parse = (window: unknown): QuotaWindow | undefined => {
|
|
115
127
|
if (!window || typeof window !== "object") return undefined;
|
|
116
128
|
const item = window as Record<string, unknown>;
|
|
117
|
-
const utilization = item.utilization;
|
|
118
129
|
const resetsAt = typeof item.resets_at === "string" ? Date.parse(item.resets_at) : undefined;
|
|
119
|
-
return windowValue({ used:
|
|
130
|
+
return windowValue({ used: item.utilization, resetsAt });
|
|
120
131
|
};
|
|
121
|
-
|
|
122
|
-
}
|
|
132
|
+
quota = parseQuota({ fiveHour: parse(body.five_hour), week: parse(body.seven_day) });
|
|
133
|
+
}
|
|
134
|
+
if (!quota) throw new Error("no plan limits in the usage response");
|
|
135
|
+
return quota;
|
|
123
136
|
}
|
|
124
137
|
|
|
125
138
|
/**
|
|
@@ -128,7 +141,7 @@ export async function fetchQuota(provider: QuotaProvider, resolveAuth: (provider
|
|
|
128
141
|
* lookups start only from `refresh` at session boundaries.
|
|
129
142
|
*/
|
|
130
143
|
export class QuotaCache {
|
|
131
|
-
private entries = new Map<QuotaProvider, { value?: Quota; requestedAt: number; expiresAt: number; failures: number }>();
|
|
144
|
+
private entries = new Map<QuotaProvider, { value?: Quota; error?: string; requestedAt: number; expiresAt: number; failures: number }>();
|
|
132
145
|
private pending = new Map<QuotaProvider, AbortController>();
|
|
133
146
|
private requests = 0;
|
|
134
147
|
private failures = 0;
|
|
@@ -147,6 +160,12 @@ export class QuotaCache {
|
|
|
147
160
|
const cached = this.entries.get(provider);
|
|
148
161
|
return cached && cached.expiresAt > now ? cached.value : undefined;
|
|
149
162
|
}
|
|
163
|
+
/** Why the last lookup failed and when the next may start; undefined while pending, fresh, or never tried. */
|
|
164
|
+
failure(provider: string | undefined, now: number): { error: string; retryInMs: number } | undefined {
|
|
165
|
+
if (provider !== "openai-codex" && provider !== "anthropic" || !this.enabled || this.pending.has(provider)) return undefined;
|
|
166
|
+
const cached = this.entries.get(provider);
|
|
167
|
+
return cached?.error !== undefined && cached.expiresAt > now ? { error: cached.error, retryInMs: cached.expiresAt - now } : undefined;
|
|
168
|
+
}
|
|
150
169
|
/** Start one background lookup when nothing fresh is cached and no retry delay is pending; true if it started. */
|
|
151
170
|
refresh(provider: string | undefined, statuses: ReadonlyMap<string, string>, now: number): boolean {
|
|
152
171
|
if (provider !== "openai-codex" && provider !== "anthropic" || !this.enabled) return false;
|
|
@@ -167,15 +186,18 @@ export class QuotaCache {
|
|
|
167
186
|
if (this.pending.get(provider) === controller) this.pending.delete(provider);
|
|
168
187
|
return;
|
|
169
188
|
}
|
|
170
|
-
// A rejected lookup
|
|
171
|
-
void this.fetcher(provider, controller.signal).
|
|
189
|
+
// A rejected lookup backs off instead of retrying at once; its reason is kept for the usage panel.
|
|
190
|
+
void this.fetcher(provider, controller.signal).then(
|
|
191
|
+
(value) => ({ value, error: value ? undefined : "no usage returned" }),
|
|
192
|
+
(error: unknown) => ({ value: undefined, error: error instanceof Error ? error.message : String(error) })
|
|
193
|
+
).then(({ value, error }) => {
|
|
172
194
|
if (controller.signal.aborted) return;
|
|
173
195
|
const failures = value ? 0 : (this.entries.get(provider)?.failures ?? 0) + 1;
|
|
174
196
|
if (!value) this.failures++;
|
|
175
197
|
const ttl = value ? QUOTA_TTL_MS : Math.min(QUOTA_MAX_BACKOFF_MS, QUOTA_TTL_MS * 2 ** (failures - 1));
|
|
176
|
-
this.entries.set(provider, { ...(value ? { value } : {}), requestedAt: now, expiresAt: now + ttl, failures });
|
|
177
|
-
//
|
|
178
|
-
|
|
198
|
+
this.entries.set(provider, { ...(value ? { value } : { error: error ?? "no usage returned" }), requestedAt: now, expiresAt: now + ttl, failures });
|
|
199
|
+
// Either outcome replaces the "loading" state renders show.
|
|
200
|
+
this.changed();
|
|
179
201
|
}).finally(() => {
|
|
180
202
|
if (this.pending.get(provider) === controller) this.pending.delete(provider);
|
|
181
203
|
});
|
package/src/roles-ui.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { ExtensionContext, ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { Key, matchesKey, stripTerminalSequences, type TuiMouseEvent } from "@earendil-works/pi-tui";
|
|
3
3
|
import { promptText } from "./dialogs.ts";
|
|
4
|
-
import { isRoleName, normalizeSpec, THINKING, type ModelRoleManager, type RoleRow } from "./model-roles.ts";
|
|
4
|
+
import { isRoleName, normalizeSpec, premiumRoleWarnings, THINKING, type ModelRoleManager, type RoleRow } from "./model-roles.ts";
|
|
5
5
|
import { contentRows, optionList, sidebarWidth, splitFrame } from "./split-view.ts";
|
|
6
6
|
|
|
7
7
|
type RoleAction = "model" | "fallback" | "alias" | "thinking" | "scope" | "activate" | "clear" | "new" | "delete";
|
|
@@ -44,6 +44,8 @@ async function roleScreen(ctx: ExtensionContext, roles: ModelRoleManager, initia
|
|
|
44
44
|
let layout = { top: 1, rows: 0, leftWidth: 0, bodyX: 2, footerTop: 0 };
|
|
45
45
|
let width = 80;
|
|
46
46
|
const rows = () => roles.list();
|
|
47
|
+
// Read once per screen: every action closes the screen, and reopening it re-checks against the current model.
|
|
48
|
+
const warnings = premiumRoleWarnings(roles, ctx);
|
|
47
49
|
const finish = (action: RoleAction) => done({ action, index: selected });
|
|
48
50
|
return {
|
|
49
51
|
invalidate() {},
|
|
@@ -98,7 +100,7 @@ async function roleScreen(ctx: ExtensionContext, roles: ModelRoleManager, initia
|
|
|
98
100
|
const header = narrow ? [theme.fg("accent", `‹ ${selected + 1}/${list.length} ${row.role} ›`)] : [];
|
|
99
101
|
const actions = optionList(theme, ACTIONS.map((item) => item.label), -1, ACTIONS.map((item) => item.hint))
|
|
100
102
|
.map((line, at) => ACTIONS[at]!.action === "delete" && !row.custom ? theme.fg("dim", stripTerminalSequences(line)) : line);
|
|
101
|
-
const body = [...header, ...info];
|
|
103
|
+
const body = [...header, ...warnings.map((text) => theme.fg("warning", text)), ...info];
|
|
102
104
|
const split = splitFrame(theme, width, "pi-jar · roles", narrow ? [] : left, body, [
|
|
103
105
|
...actions,
|
|
104
106
|
theme.fg("dim", "↑↓ choose role · press a key or click an action · Esc close")
|
package/src/settings-ui.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { Key, matchesKey, truncateToWidth, visibleWidth, type TuiMouseEvent } from "@earendil-works/pi-tui";
|
|
3
3
|
import { FOOTER_FIELDS, type FooterField } from "./footer-settings.ts";
|
|
4
|
+
import { formatTokens } from "./context-budget.ts";
|
|
4
5
|
import { ICON_SETS } from "./icons.ts";
|
|
5
|
-
import { GOAL_ROUND_CHOICES, MAX_SUBAGENT_CHOICES, type JarAccent, type JarVisualSettings } from "./settings.ts";
|
|
6
|
+
import { CONTEXT_BUDGET_ACTIONS, CONTEXT_BUDGET_CHOICES, GOAL_ROUND_CHOICES, MAX_SUBAGENT_CHOICES, type JarAccent, type JarVisualSettings } from "./settings.ts";
|
|
6
7
|
import type { ProfileAction } from "./profile-ui.ts";
|
|
7
8
|
|
|
8
9
|
const LABELS: Record<FooterField, string> = {
|
|
@@ -39,10 +40,11 @@ export async function openJarSettings(
|
|
|
39
40
|
let width = 64;
|
|
40
41
|
const accents: JarAccent[] = ["follow", ...availableAccents.filter((name): name is JarAccent =>
|
|
41
42
|
name === "default" || ["gray", "pink", "teal", "azure", "violet", "amber"].includes(name))];
|
|
42
|
-
let choosing: "goalRounds" | "maxSubagents" | undefined;
|
|
43
|
-
const choices = () => choosing === "maxSubagents" ? MAX_SUBAGENT_CHOICES : GOAL_ROUND_CHOICES;
|
|
44
|
-
const fields = () => choosing ? choices().length : page === "footer" ? FOOTER_FIELDS.length : page === "pi" ?
|
|
45
|
-
|
|
43
|
+
let choosing: "goalRounds" | "maxSubagents" | "contextBudget" | undefined;
|
|
44
|
+
const choices = () => choosing === "maxSubagents" ? MAX_SUBAGENT_CHOICES : choosing === "contextBudget" ? CONTEXT_BUDGET_CHOICES : GOAL_ROUND_CHOICES;
|
|
45
|
+
const fields = () => choosing ? choices().length : page === "footer" ? FOOTER_FIELDS.length : page === "pi" ? 11 : page === "profiles" ? 2 : 7;
|
|
46
|
+
// Back on the row that opened the picker.
|
|
47
|
+
const finishChoice = () => { selected = choosing === "maxSubagents" ? 3 : choosing === "contextBudget" ? 9 : 2; choosing = undefined; };
|
|
46
48
|
const piError = (error: unknown) => ctx.ui.notify("Could not change Pi setting: " + (error as Error).message, "error");
|
|
47
49
|
const explainPool = () => choosing === "maxSubagents" || (page === "pi" && !choosing && selected === 3);
|
|
48
50
|
const pageSize = () => Math.min(fields(), Math.max(4, (process.stdout.rows ?? 24) - (explainPool() ? 10 : 9)));
|
|
@@ -52,6 +54,7 @@ export async function openJarSettings(
|
|
|
52
54
|
const state = current();
|
|
53
55
|
if (choosing) {
|
|
54
56
|
if (choosing === "maxSubagents") update({ ...state, maxSubagents: MAX_SUBAGENT_CHOICES[index]! });
|
|
57
|
+
else if (choosing === "contextBudget") update({ ...state, contextBudget: { ...state.contextBudget, softTokens: CONTEXT_BUDGET_CHOICES[index]! } });
|
|
55
58
|
else update({ ...state, goalRounds: GOAL_ROUND_CHOICES[index]! });
|
|
56
59
|
finishChoice();
|
|
57
60
|
} else if (page === "profiles") {
|
|
@@ -75,6 +78,16 @@ export async function openJarSettings(
|
|
|
75
78
|
else if (index === 4) update({ ...state, advisor: !state.advisor });
|
|
76
79
|
else if (index === 5) update({ ...state, advisorGates: !state.advisorGates });
|
|
77
80
|
else if (index === 6) update({ ...state, contextDiet: !state.contextDiet });
|
|
81
|
+
else if (index === 7) update({ ...state, cacheDiagnostics: !state.cacheDiagnostics });
|
|
82
|
+
else if (index === 8) {
|
|
83
|
+
const at = CONTEXT_BUDGET_ACTIONS.indexOf(state.contextBudget.action);
|
|
84
|
+
update({ ...state, contextBudget: { ...state.contextBudget, action: CONTEXT_BUDGET_ACTIONS[(at + 1) % CONTEXT_BUDGET_ACTIONS.length]! } });
|
|
85
|
+
}
|
|
86
|
+
else if (index === 9) {
|
|
87
|
+
choosing = "contextBudget";
|
|
88
|
+
selected = Math.max(0, CONTEXT_BUDGET_CHOICES.indexOf(state.contextBudget.softTokens as never));
|
|
89
|
+
}
|
|
90
|
+
else if (index === 10) update({ ...state, readCache: !state.readCache });
|
|
78
91
|
} else if (index === 0) {
|
|
79
92
|
const at = Math.max(0, accents.indexOf(state.accent));
|
|
80
93
|
update({ ...state, accent: accents[(at + 1) % accents.length]! });
|
|
@@ -150,8 +163,10 @@ export async function openJarSettings(
|
|
|
150
163
|
+ (profiles ? " " + theme.fg(page === "profiles" ? "accent" : "muted", "[ Profiles ]") : ""));
|
|
151
164
|
const prefs = pi?.get();
|
|
152
165
|
const divider = theme.fg("dim", "├" + "─".repeat(Math.max(0, width - 2)) + "┤");
|
|
166
|
+
const budgetOn = state.contextBudget.action !== "off";
|
|
167
|
+
const chosen = choosing === "contextBudget" ? state.contextBudget.softTokens : choosing ? state[choosing] : undefined;
|
|
153
168
|
const allRows = choosing
|
|
154
|
-
? choices().map((choice, index) => row(index, String(choice),
|
|
169
|
+
? choices().map((choice, index) => row(index, choosing === "contextBudget" ? formatTokens(choice) + " tokens" : String(choice), chosen === choice ? "CURRENT" : "", true))
|
|
155
170
|
: page === "footer"
|
|
156
171
|
? FOOTER_FIELDS.map((field, index) => row(index, LABELS[field], state.footer[field] ? "ON" : "OFF", state.footer[field]))
|
|
157
172
|
: page === "pi" ? [
|
|
@@ -161,7 +176,11 @@ export async function openJarSettings(
|
|
|
161
176
|
row(3, "Max subagents (retained live pool)", String(state.maxSubagents), true),
|
|
162
177
|
row(4, "Advisor (jar_advisor, /advisor)", state.advisor ? "ON" : "OFF", state.advisor),
|
|
163
178
|
row(5, "Advisor gates (loops, repeated failures)", state.advisorGates ? "ON" : "OFF", state.advisorGates),
|
|
164
|
-
row(6, "Context diet (prior-turn reasoning)", state.contextDiet ? "ON" : "OFF", state.contextDiet)
|
|
179
|
+
row(6, "Context diet (prior-turn reasoning)", state.contextDiet ? "ON" : "OFF", state.contextDiet),
|
|
180
|
+
row(7, "Cache diagnostics (explain cache breaks)", state.cacheDiagnostics ? "ON" : "OFF", state.cacheDiagnostics),
|
|
181
|
+
row(8, "Context budget (past the limit)", state.contextBudget.action.toUpperCase(), budgetOn),
|
|
182
|
+
row(9, "Context budget limit", formatTokens(state.contextBudget.softTokens), budgetOn),
|
|
183
|
+
row(10, "Repeated-read stub (unchanged re-reads)", state.readCache ? "ON" : "OFF", state.readCache)
|
|
165
184
|
] : page === "profiles" ? [
|
|
166
185
|
row(0, "Switch profile…", profiles!.activeName(), true),
|
|
167
186
|
row(1, "Create profile…", "OPEN", true)
|
|
@@ -178,11 +197,14 @@ export async function openJarSettings(
|
|
|
178
197
|
const rows = allRows.slice(start, start + pageSize());
|
|
179
198
|
return [top, tabs, divider,
|
|
180
199
|
line(theme.fg("accent", choosing === "maxSubagents" ? " MAX SUBAGENTS · RETAINED LIVE POOL"
|
|
200
|
+
: choosing === "contextBudget" ? " CONTEXT BUDGET · SOFT LIMIT"
|
|
181
201
|
: choosing === "goalRounds" ? " GOAL AUTO ROUNDS" : page === "footer" ? " FOOTER VISIBILITY" : page === "pi" ? " PI & WORKFLOWS" : page === "profiles" ? " PROFILES" : " APPEARANCE & MOTION")),
|
|
182
202
|
line(theme.fg("dim", explainPool() ? " Live pool includes idle/paused subagents"
|
|
183
203
|
: choosing ? " Select a value; Enter/click to save, Esc to cancel"
|
|
184
204
|
: page === "footer" ? ` Footer fields ${start + 1}–${start + rows.length}/${fields()}`
|
|
185
|
-
: page === "pi" ? "
|
|
205
|
+
: page === "pi" ? (selected === 7 ? " Names what changed when a call re-sent cached context (/cache-breaks)"
|
|
206
|
+
: selected === 8 || selected === 9 ? " Past the limit: suggest /compact or /new, or compact at a safe point"
|
|
207
|
+
: selected === 10 ? " An unchanged re-read of the same range returns a short stub" : " Fullscreen mode enables clicks and copy-on-select")
|
|
186
208
|
: page === "profiles" ? " Switch only in a fresh, idle session; roles via /roles" : " Accent cycles through loaded themes")),
|
|
187
209
|
...rows,
|
|
188
210
|
line(theme.fg("dim", choosing ? " ↑↓: choose · Enter/click: save · Esc: cancel"
|
package/src/settings.ts
CHANGED
|
@@ -25,11 +25,31 @@ export interface JarVisualSettings {
|
|
|
25
25
|
advisorGates: boolean;
|
|
26
26
|
/** Opt-in provider-context pruning of reasoning from completed earlier user turns. */
|
|
27
27
|
contextDiet: boolean;
|
|
28
|
+
/** Explain prompt-cache breaks: which tool, system section or message changed when a call re-sent cached context. */
|
|
29
|
+
cacheDiagnostics: boolean;
|
|
30
|
+
/** Context-size guard: past softTokens, suggest /compact or /new, or compact at the next safe point. */
|
|
31
|
+
contextBudget: ContextBudget;
|
|
32
|
+
/** Opt-in: an unchanged repeated `read` of the same range returns a short stub instead of the content. */
|
|
33
|
+
readCache: boolean;
|
|
28
34
|
/** Glyph set for footer, composer and views: unicode, Nerd Font icons, or plain ascii. */
|
|
29
35
|
icons: IconSet;
|
|
30
36
|
footer: FooterSettings;
|
|
31
37
|
}
|
|
32
38
|
|
|
39
|
+
export const CONTEXT_BUDGET_ACTIONS = ["off", "suggest", "compact"] as const;
|
|
40
|
+
export type ContextBudgetAction = (typeof CONTEXT_BUDGET_ACTIONS)[number];
|
|
41
|
+
export interface ContextBudget { softTokens: number; action: ContextBudgetAction }
|
|
42
|
+
export const CONTEXT_BUDGET_CHOICES = [80_000, 120_000, 160_000, 200_000] as const;
|
|
43
|
+
export const DEFAULT_CONTEXT_BUDGET: ContextBudget = { softTokens: 120_000, action: "suggest" };
|
|
44
|
+
/** Accepts any stored integer budget in a sane range, not only the picker's choices. */
|
|
45
|
+
export function parseContextBudget(value: unknown): ContextBudget | undefined {
|
|
46
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
47
|
+
const { softTokens, action } = value as Record<string, unknown>;
|
|
48
|
+
if (typeof softTokens !== "number" || !Number.isInteger(softTokens) || softTokens < 10_000 || softTokens > 2_000_000) return undefined;
|
|
49
|
+
if (!CONTEXT_BUDGET_ACTIONS.some((choice) => choice === action)) return undefined;
|
|
50
|
+
return { softTokens, action: action as ContextBudgetAction };
|
|
51
|
+
}
|
|
52
|
+
|
|
33
53
|
export const GOAL_ROUND_CHOICES = [4, 8, 12, 20] as const;
|
|
34
54
|
export const MAX_SUBAGENT_CHOICES = [2, 4, 6, 8, 16] as const;
|
|
35
55
|
export type MaxSubagents = (typeof MAX_SUBAGENT_CHOICES)[number];
|
|
@@ -39,7 +59,7 @@ export function isMaxSubagents(value: unknown): value is MaxSubagents {
|
|
|
39
59
|
|
|
40
60
|
export const SETTINGS_FILE = "pi-jar-settings.json";
|
|
41
61
|
export function defaultVisualSettings(footer: FooterSettings = DEFAULT_FOOTER_SETTINGS): JarVisualSettings {
|
|
42
|
-
return { version: 1, accent: "follow", animations: true, ui: true, composer: true, mascot: true, suggestions: true, goalRounds: 8, maxSubagents:
|
|
62
|
+
return { version: 1, accent: "follow", animations: true, ui: true, composer: true, mascot: true, suggestions: true, goalRounds: 8, maxSubagents: 2, advisor: true, advisorGates: false, contextDiet: false, cacheDiagnostics: true, contextBudget: { ...DEFAULT_CONTEXT_BUDGET }, readCache: false, icons: "unicode", footer: { ...footer } };
|
|
43
63
|
}
|
|
44
64
|
|
|
45
65
|
/** Legacy footer choices are imported only while the new file is absent. Never modify the old file. */
|
|
@@ -73,6 +93,9 @@ export function loadVisualSettings(directory: string): JarVisualSettings {
|
|
|
73
93
|
advisor: typeof value.advisor === "boolean" ? value.advisor : fallback.advisor,
|
|
74
94
|
advisorGates: typeof value.advisorGates === "boolean" ? value.advisorGates : fallback.advisorGates,
|
|
75
95
|
contextDiet: typeof value.contextDiet === "boolean" ? value.contextDiet : fallback.contextDiet,
|
|
96
|
+
cacheDiagnostics: typeof value.cacheDiagnostics === "boolean" ? value.cacheDiagnostics : fallback.cacheDiagnostics,
|
|
97
|
+
contextBudget: parseContextBudget(value.contextBudget) ?? fallback.contextBudget,
|
|
98
|
+
readCache: typeof value.readCache === "boolean" ? value.readCache : fallback.readCache,
|
|
76
99
|
icons: ICON_SETS.some((set) => set === value.icons) ? value.icons as IconSet : fallback.icons,
|
|
77
100
|
footer: Object.fromEntries(FOOTER_FIELDS.map((field) => [field,
|
|
78
101
|
typeof footer[field] === "boolean" ? footer[field] : fallback.footer[field]])) as FooterSettings
|