pi-jar 0.2.1 → 0.2.3
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 +17 -14
- package/docs/EFFICIENCY_PLAN.md +79 -0
- package/docs/INTEGRATIONS.md +22 -6
- package/docs/SUBAGENT-TOOLS.md +1 -1
- package/docs/WORKFLOWS.md +3 -3
- package/extensions/index.ts +84 -17
- package/package.json +1 -1
- package/src/advisor.ts +22 -10
- package/src/ask-tool.ts +2 -7
- 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 +99 -54
- package/src/discussion-hub.ts +9 -6
- package/src/discussion.ts +31 -22
- package/src/footer.ts +5 -1
- package/src/goal-loop.ts +11 -22
- package/src/info-panels.ts +6 -0
- package/src/model-roles.ts +26 -2
- package/src/perf.ts +21 -4
- package/src/plan.ts +12 -25
- package/src/profile-ui.ts +21 -6
- package/src/profiles.ts +9 -4
- package/src/quota.ts +71 -49
- package/src/roles-ui.ts +4 -2
- package/src/settings-ui.ts +27 -8
- package/src/settings.ts +21 -1
- package/src/shells.ts +77 -15
- package/src/side-model.ts +14 -0
- 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/workflow-context.ts +37 -0
package/src/goal-loop.ts
CHANGED
|
@@ -5,6 +5,8 @@ import type { Goal, GoalStore } from "./goals.ts";
|
|
|
5
5
|
import type { ModelRoleManager } from "./model-roles.ts";
|
|
6
6
|
import { isSafePlanCommand } from "./plan-utils.ts";
|
|
7
7
|
import type { Todo, TodoStore } from "./tasks.ts";
|
|
8
|
+
import { setToolActive } from "./tool-activation.ts";
|
|
9
|
+
import { projectWorkflowMessages } from "./workflow-context.ts";
|
|
8
10
|
|
|
9
11
|
export const GOAL_TOOL = "jar_goal";
|
|
10
12
|
export const DEFAULT_GOAL_ROUNDS = 8;
|
|
@@ -262,36 +264,23 @@ export class GoalLoop {
|
|
|
262
264
|
|
|
263
265
|
this.pi.on("before_agent_start", async (_event, ctx) => {
|
|
264
266
|
const goal = this.goal();
|
|
267
|
+
// jar_goal is only useful while a goal runs; inactive, it costs no schema or rules tokens.
|
|
268
|
+
setToolActive(this.pi, GOAL_TOOL, !!goal && goal.status !== "complete");
|
|
265
269
|
if (!goal || goal.status === "complete") return;
|
|
266
270
|
if (goal.status === "active" && !this.options.planActive()) await this.useRole(goal.phase === "audit" ? "advisor" : "implement", ctx);
|
|
267
271
|
return { message: { customType: CONTEXT_TYPE, content: this.context(goal), display: false } };
|
|
268
272
|
});
|
|
269
273
|
|
|
270
|
-
//
|
|
271
|
-
//
|
|
274
|
+
// Never drop an already-sent goal prompt mid-history while the goal runs: that would re-bill the
|
|
275
|
+
// previous run uncached. Repeated identical context is sent once; a finished goal drops all.
|
|
272
276
|
this.pi.on("context", async (event) => {
|
|
273
277
|
const goal = this.goal();
|
|
274
278
|
const active = !!goal && goal.status !== "complete";
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
if (type === CONTEXT_TYPE) {
|
|
281
|
-
if (!active || latestContext >= 0) needsPrune = true;
|
|
282
|
-
else latestContext = index;
|
|
283
|
-
} else if (type === CONTINUATION_TYPE) {
|
|
284
|
-
if (!active || latestContinuation >= 0) needsPrune = true;
|
|
285
|
-
else latestContinuation = index;
|
|
286
|
-
}
|
|
287
|
-
}
|
|
288
|
-
if (!needsPrune) return;
|
|
289
|
-
return { messages: event.messages.filter((raw, index) => {
|
|
290
|
-
const type = (raw as { customType?: string }).customType;
|
|
291
|
-
if (type === CONTEXT_TYPE) return active && index === latestContext;
|
|
292
|
-
if (type === CONTINUATION_TYPE) return active && index === latestContinuation;
|
|
293
|
-
return true;
|
|
294
|
-
}) };
|
|
279
|
+
const messages = projectWorkflowMessages(event.messages, {
|
|
280
|
+
[CONTEXT_TYPE]: { keep: active, dedupe: true },
|
|
281
|
+
[CONTINUATION_TYPE]: { keep: active }
|
|
282
|
+
});
|
|
283
|
+
if (messages) return { messages };
|
|
295
284
|
});
|
|
296
285
|
|
|
297
286
|
this.pi.on("agent_before_settle", async (event, ctx) => {
|
package/src/info-panels.ts
CHANGED
|
@@ -12,7 +12,10 @@ const DEFAULT_RESERVE = 16_384;
|
|
|
12
12
|
export interface InfoPanelDeps {
|
|
13
13
|
side: SideUsage;
|
|
14
14
|
quota(ctx: ExtensionContext): Quota | undefined;
|
|
15
|
+
quotaFailure(ctx: ExtensionContext): { error: string; retryInMs: number } | undefined;
|
|
15
16
|
quotaEnabled(): boolean;
|
|
17
|
+
/** Start a limit lookup when nothing fresh is cached: opening `/usage` is an explicit request. */
|
|
18
|
+
refreshQuota(ctx: ExtensionContext): void;
|
|
16
19
|
}
|
|
17
20
|
|
|
18
21
|
function reserveTokens(ctx: ExtensionContext): number {
|
|
@@ -61,10 +64,12 @@ export function registerInfoPanels(pi: ExtensionAPI, deps: InfoPanelDeps): void
|
|
|
61
64
|
{ name: "Usage", render: (width, fg) => {
|
|
62
65
|
usage ??= collectUsage(sessionBranch(ctx) as never, deps.side.all());
|
|
63
66
|
const quota = deps.quota(ctx);
|
|
67
|
+
const quotaFailure = quota ? undefined : deps.quotaFailure(ctx);
|
|
64
68
|
return usageLines({
|
|
65
69
|
stats: usage,
|
|
66
70
|
...(ctx.model?.provider ? { provider: ctx.model.provider } : {}),
|
|
67
71
|
...(quota ? { quota } : {}),
|
|
72
|
+
...(quotaFailure ? { quotaFailure } : {}),
|
|
68
73
|
quotaEnabled: deps.quotaEnabled(), now: Date.now()
|
|
69
74
|
}, width, fg);
|
|
70
75
|
} },
|
|
@@ -78,6 +83,7 @@ export function registerInfoPanels(pi: ExtensionAPI, deps: InfoPanelDeps): void
|
|
|
78
83
|
const open = async (ctx: ExtensionContext, tab: number) => {
|
|
79
84
|
try {
|
|
80
85
|
const pages = tabs(ctx);
|
|
86
|
+
if (tab === 0) deps.refreshQuota(ctx);
|
|
81
87
|
if (ctx.hasUI && ctx.mode === "tui") await openPanel(ctx, pages, tab);
|
|
82
88
|
else ctx.ui.notify(pages[tab]!.render(100, plain).join("\n"), "info");
|
|
83
89
|
} catch (error) { ctx.ui.notify("pi-jar: " + (error instanceof Error ? error.message : String(error)), "error"); }
|
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/perf.ts
CHANGED
|
@@ -185,6 +185,9 @@ export class RenderScheduler {
|
|
|
185
185
|
|
|
186
186
|
/** Mid-turn context recomputation happens at most this often; boundaries refresh at once. */
|
|
187
187
|
export const CONTEXT_SAMPLE_MS = 750;
|
|
188
|
+
/** Costly samples widen the window to keep mid-turn sampling near a 1% duty cycle, up to this cap. */
|
|
189
|
+
export const CONTEXT_SAMPLE_MAX_MS = 5000;
|
|
190
|
+
const CONTEXT_SAMPLE_DUTY = 100;
|
|
188
191
|
export interface ContextUsageLike { percent?: number | null }
|
|
189
192
|
export interface ContextSamplingStats {
|
|
190
193
|
label: string;
|
|
@@ -197,6 +200,8 @@ export interface ContextSamplingStats {
|
|
|
197
200
|
/** Median over the latest samples. */
|
|
198
201
|
medianMs?: number;
|
|
199
202
|
lastSampleAgeMs?: number;
|
|
203
|
+
/** Current mid-turn throttle window, widened from the median sample cost. */
|
|
204
|
+
windowMs: number;
|
|
200
205
|
dirty: boolean;
|
|
201
206
|
pending: boolean;
|
|
202
207
|
}
|
|
@@ -253,20 +258,31 @@ export class ContextSampler {
|
|
|
253
258
|
const before = this.current;
|
|
254
259
|
this.sample();
|
|
255
260
|
if (this.current !== before) this.changed();
|
|
256
|
-
}, Math.max(0, (this.lastSampleAt ?? Number.NEGATIVE_INFINITY) + this.
|
|
261
|
+
}, Math.max(0, (this.lastSampleAt ?? Number.NEGATIVE_INFINITY) + this.windowMs() - this.now()));
|
|
257
262
|
this.timer.unref?.();
|
|
258
263
|
}
|
|
259
264
|
|
|
260
265
|
stats(): ContextSamplingStats {
|
|
261
|
-
const
|
|
266
|
+
const median = this.medianMs();
|
|
262
267
|
return {
|
|
263
268
|
label: this.current, ...this.counts,
|
|
264
|
-
...(
|
|
269
|
+
...(median !== undefined ? { medianMs: median } : {}),
|
|
265
270
|
...(this.lastSampleAt !== undefined ? { lastSampleAgeMs: this.now() - this.lastSampleAt } : {}),
|
|
266
|
-
dirty: this.stale, pending: this.timer !== undefined
|
|
271
|
+
windowMs: this.windowMs(), dirty: this.stale, pending: this.timer !== undefined
|
|
267
272
|
};
|
|
268
273
|
}
|
|
269
274
|
|
|
275
|
+
private medianMs(): number | undefined {
|
|
276
|
+
if (!this.durations.length) return undefined;
|
|
277
|
+
return [...this.durations].sort((a, b) => a - b)[Math.floor(this.durations.length / 2)]!;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Cost-adaptive: large sessions whose usage walk is slow sample less often mid-turn. */
|
|
281
|
+
private windowMs(): number {
|
|
282
|
+
const cost = (this.medianMs() ?? 0) * CONTEXT_SAMPLE_DUTY;
|
|
283
|
+
return Math.max(this.throttleMs, Math.min(CONTEXT_SAMPLE_MAX_MS, Math.round(cost)));
|
|
284
|
+
}
|
|
285
|
+
|
|
270
286
|
/** Session shutdown: no timer, and no reference to the old session's context. */
|
|
271
287
|
dispose(): void {
|
|
272
288
|
clearTimeout(this.timer);
|
|
@@ -386,6 +402,7 @@ export function formatPerf(snapshot: PerfSnapshot): string {
|
|
|
386
402
|
["dirty marks", count(sampling.marks)],
|
|
387
403
|
["total time", ms(sampling.totalMs)],
|
|
388
404
|
["max time", ms(sampling.maxMs) + (sampling.medianMs !== undefined ? ` (median ${ms(sampling.medianMs)})` : "")],
|
|
405
|
+
["window", ms(sampling.windowMs) + " mid-turn"],
|
|
389
406
|
["last sample age", sampling.lastSampleAgeMs !== undefined ? age(sampling.lastSampleAgeMs) : "never"],
|
|
390
407
|
["pending", sampling.pending ? "sample scheduled" : sampling.dirty ? "yes" : "no"]
|
|
391
408
|
]);
|
package/src/plan.ts
CHANGED
|
@@ -11,6 +11,8 @@ 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";
|
|
15
|
+
import { projectWorkflowMessages } from "./workflow-context.ts";
|
|
14
16
|
|
|
15
17
|
export const PLAN_ENTRY = "pi-jar.plan";
|
|
16
18
|
export const PLAN_SUBMIT_TOOL = "jar_plan_submit";
|
|
@@ -138,7 +140,8 @@ export class PlanMode {
|
|
|
138
140
|
this.enabled = true;
|
|
139
141
|
this.pendingReview = false;
|
|
140
142
|
this.reminders = 0;
|
|
141
|
-
|
|
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);
|
|
142
145
|
this.pi.setActiveTools(this.planTools(this.toolsBefore));
|
|
143
146
|
this.onEnter?.(ctx);
|
|
144
147
|
this.restoreRole = await this.roles.activateTemporary("plan", ctx);
|
|
@@ -413,34 +416,18 @@ export class PlanMode {
|
|
|
413
416
|
});
|
|
414
417
|
|
|
415
418
|
this.pi.on("before_agent_start", async () => {
|
|
416
|
-
if (!this.enabled) return;
|
|
419
|
+
if (!this.enabled) { setToolActive(this.pi, PLAN_SUBMIT_TOOL, false); return; }
|
|
417
420
|
return { message: { customType: "pi-jar.plan-context", content: this.context(), display: false } };
|
|
418
421
|
});
|
|
419
422
|
|
|
420
|
-
//
|
|
421
|
-
//
|
|
422
|
-
// rewrites apply to a single LLM call, so this runs on every call.
|
|
423
|
+
// Plan context is identical every turn: send its first copy only, keeping the cached prefix
|
|
424
|
+
// stable. Reminders stay in place while active; leaving plan mode drops all of them.
|
|
423
425
|
this.pi.on("context", async (event) => {
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
if (type === "pi-jar.plan-context") {
|
|
430
|
-
if (!this.enabled || latestContext >= 0) needsPrune = true;
|
|
431
|
-
else latestContext = index;
|
|
432
|
-
} else if (type === "pi-jar.plan-reminder") {
|
|
433
|
-
if (!this.enabled || latestReminder >= 0) needsPrune = true;
|
|
434
|
-
else latestReminder = index;
|
|
435
|
-
}
|
|
436
|
-
}
|
|
437
|
-
if (!needsPrune) return;
|
|
438
|
-
return { messages: event.messages.filter((raw, index) => {
|
|
439
|
-
const type = (raw as { customType?: string }).customType;
|
|
440
|
-
if (type === "pi-jar.plan-context") return this.enabled && index === latestContext;
|
|
441
|
-
if (type === "pi-jar.plan-reminder") return this.enabled && index === latestReminder;
|
|
442
|
-
return true;
|
|
443
|
-
}) };
|
|
426
|
+
const messages = projectWorkflowMessages(event.messages, {
|
|
427
|
+
"pi-jar.plan-context": { keep: this.enabled, dedupe: true },
|
|
428
|
+
"pi-jar.plan-reminder": { keep: this.enabled }
|
|
429
|
+
});
|
|
430
|
+
if (messages) return { messages };
|
|
444
431
|
});
|
|
445
432
|
|
|
446
433
|
this.pi.on("input", (event) => {
|
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";
|
|
@@ -16,7 +16,8 @@ export function conversationStarted(entries: readonly unknown[]): boolean {
|
|
|
16
16
|
|| (entry.type === "message" && isPlainObject(entry.message) && entry.message.role === "user")));
|
|
17
17
|
}
|
|
18
18
|
export function pinnedProfileId(entries: readonly unknown[]): string | undefined {
|
|
19
|
-
for (
|
|
19
|
+
for (let index = entries.length - 1; index >= 0; index--) {
|
|
20
|
+
const entry = entries[index];
|
|
20
21
|
if (isPlainObject(entry) && entry.type === "custom" && entry.customType === PROFILE_ENTRY
|
|
21
22
|
&& isPlainObject(entry.data) && entry.data.version === 1 && typeof entry.data.id === "string") return entry.data.id;
|
|
22
23
|
}
|
|
@@ -40,7 +41,9 @@ function normalizeProfile(value: unknown): JarProfile | undefined {
|
|
|
40
41
|
|| !isPlainObject(settings.footer) || typeof settings.animations !== "boolean" || typeof settings.ui !== "boolean"
|
|
41
42
|
|| typeof settings.composer !== "boolean" || typeof settings.mascot !== "boolean" || typeof settings.suggestions !== "boolean"
|
|
42
43
|
|| typeof settings.advisor !== "boolean" || typeof settings.advisorGates !== "boolean"
|
|
43
|
-
|| (settings.contextDiet !== undefined && typeof settings.contextDiet !== "boolean")
|
|
44
|
+
|| (settings.contextDiet !== undefined && typeof settings.contextDiet !== "boolean")
|
|
45
|
+
|| (settings.contextBudget !== undefined && !parseContextBudget(settings.contextBudget))
|
|
46
|
+
|| (settings.readCache !== undefined && typeof settings.readCache !== "boolean") || !Number.isInteger(settings.goalRounds)
|
|
44
47
|
|| (settings.goalRounds as number) < 1 || (settings.goalRounds as number) > 50
|
|
45
48
|
|| !isMaxSubagents(settings.maxSubagents) || typeof settings.accent !== "string"
|
|
46
49
|
|| !(settings.accent === "follow" || settings.accent === "default" || ACCENT_NAMES.includes(settings.accent as never))
|
|
@@ -49,7 +52,9 @@ function normalizeProfile(value: unknown): JarProfile | undefined {
|
|
|
49
52
|
if (raw.name === DEFAULT_PROFILE && raw.id !== "default") return undefined;
|
|
50
53
|
if (raw.name !== DEFAULT_PROFILE && (typeof raw.id !== "string" || !/^profile-[a-z0-9-]{8,64}$/.test(raw.id))) return undefined;
|
|
51
54
|
for (const [role, spec] of Object.entries(raw.roles.roles)) if (!isRoleName(role) || typeof spec !== "string" || !normalizeSpec(spec)) return undefined;
|
|
52
|
-
|
|
55
|
+
const contextBudget = parseContextBudget(settings.contextBudget) ?? { ...DEFAULT_CONTEXT_BUDGET };
|
|
56
|
+
return { id: raw.id as string, name: raw.name, theme: raw.theme, settings: { ...raw.settings, contextDiet: settings.contextDiet ?? false,
|
|
57
|
+
contextBudget, readCache: settings.readCache ?? false } as unknown as JarVisualSettings, roles: parseRoleConfig(raw.roles) };
|
|
53
58
|
}
|
|
54
59
|
function atomicSave(file: string, document: ProfileDocument): string {
|
|
55
60
|
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")
|