pi-jar 0.1.8 → 0.2.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.
- package/README.md +26 -13
- package/docs/DISCUSSION_BROKER_PLAN.md +333 -0
- package/docs/INTEGRATIONS.md +1 -1
- package/docs/LONG_SESSION_PERFORMANCE_PLAN.md +731 -0
- package/docs/SUBAGENT-TOOLS.md +2 -0
- package/extensions/index.ts +304 -76
- package/package.json +2 -2
- package/src/activity-view.ts +10 -6
- package/src/advisor.ts +226 -73
- package/src/composer.ts +21 -4
- package/src/context-diet.ts +94 -0
- package/src/delegate-worktree.ts +33 -13
- package/src/delegate.ts +736 -429
- package/src/democracy.ts +6 -4
- package/src/discussion-hub.ts +418 -0
- package/src/discussion.ts +419 -187
- package/src/footer-settings.ts +3 -29
- package/src/model-roles.ts +29 -9
- package/src/perf.ts +419 -0
- package/src/profile-wizard.ts +61 -0
- package/src/profiles.ts +148 -0
- package/src/quota.ts +65 -25
- package/src/settings-ui.ts +46 -14
- package/src/settings.ts +6 -13
- package/src/shell-notifications.ts +33 -6
- package/src/shells.ts +138 -29
- package/src/side-model.ts +3 -1
- package/src/welcome.ts +6 -1
package/src/footer-settings.ts
CHANGED
|
@@ -1,32 +1,6 @@
|
|
|
1
|
-
import { readFileSync, mkdirSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
|
-
import { join } from "node:path";
|
|
3
|
-
|
|
4
1
|
export const FOOTER_FIELDS = ["model", "effort", "sessionName", "cwd", "context", "memory", "cost", "quota", "roles", "extras", "branch"] as const;
|
|
5
2
|
export type FooterField = (typeof FOOTER_FIELDS)[number];
|
|
6
3
|
export type FooterSettings = Record<FooterField, boolean>;
|
|
7
|
-
export const DEFAULT_FOOTER_SETTINGS: FooterSettings = Object.fromEntries(
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
try {
|
|
11
|
-
const value: unknown = JSON.parse(readFileSync(join(directory, "pi-jar-footer.json"), "utf8"));
|
|
12
|
-
if (!value || typeof value !== "object" || Array.isArray(value)) return { ...DEFAULT_FOOTER_SETTINGS };
|
|
13
|
-
const entries = value as Record<string, unknown>;
|
|
14
|
-
return Object.fromEntries(FOOTER_FIELDS.map((field) => [field,
|
|
15
|
-
typeof entries[field] === "boolean" ? entries[field] : true])) as FooterSettings;
|
|
16
|
-
} catch {
|
|
17
|
-
return { ...DEFAULT_FOOTER_SETTINGS };
|
|
18
|
-
}
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export function saveFooterSettings(directory: string, settings: FooterSettings): void {
|
|
22
|
-
mkdirSync(directory, { recursive: true });
|
|
23
|
-
const target = join(directory, "pi-jar-footer.json");
|
|
24
|
-
const temporary = `${target}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
|
|
25
|
-
try {
|
|
26
|
-
writeFileSync(temporary, JSON.stringify(settings, null, 2) + "\n", { flag: "wx" });
|
|
27
|
-
renameSync(temporary, target);
|
|
28
|
-
} catch (error) {
|
|
29
|
-
try { unlinkSync(temporary); } catch { /* No temporary file to clean up. */ }
|
|
30
|
-
throw error;
|
|
31
|
-
}
|
|
32
|
-
}
|
|
4
|
+
export const DEFAULT_FOOTER_SETTINGS: FooterSettings = Object.fromEntries(
|
|
5
|
+
FOOTER_FIELDS.map((field) => [field, true])
|
|
6
|
+
) as FooterSettings;
|
package/src/model-roles.ts
CHANGED
|
@@ -139,7 +139,12 @@ function writeConfig(file: string, config: RoleConfig): void {
|
|
|
139
139
|
}
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
-
|
|
142
|
+
function profileRolePath(directory: string, profileId = "default"): string {
|
|
143
|
+
if (!/^[a-z0-9-]+$/.test(profileId)) throw new Error("Invalid profile ID");
|
|
144
|
+
return profileId === "default" ? join(directory, ROLE_FILE) : join(directory, "pi-jar-profiles", `${profileId}-roles.json`);
|
|
145
|
+
}
|
|
146
|
+
export const profileRoleFile = (directory: string, profileId?: string) => profileRolePath(directory, profileId);
|
|
147
|
+
export const projectRoleFile = (cwd: string, profileId?: string) => profileRolePath(join(cwd, ".pi"), profileId);
|
|
143
148
|
|
|
144
149
|
export function describeRole(row: RoleRow): string {
|
|
145
150
|
const target = row.error ? "⚠ " + row.error
|
|
@@ -152,6 +157,7 @@ export class ModelRoleManager {
|
|
|
152
157
|
private global: RoleConfig = readConfig(join(getAgentDir(), ROLE_FILE));
|
|
153
158
|
private project: RoleConfig = { version: 2, roles: {} };
|
|
154
159
|
private cwd: string | undefined;
|
|
160
|
+
private profileId = "default";
|
|
155
161
|
private active: string | undefined;
|
|
156
162
|
private readonly pi: ExtensionAPI;
|
|
157
163
|
/** True while pi-jar itself is switching models, so its own changes are not seen as manual. */
|
|
@@ -166,11 +172,21 @@ export class ModelRoleManager {
|
|
|
166
172
|
}
|
|
167
173
|
|
|
168
174
|
/** Reload global and project files; project assignments override global ones per role. */
|
|
169
|
-
load(cwd?: string): void {
|
|
170
|
-
|
|
175
|
+
load(cwd?: string, profileRoles?: RoleConfig, profileId = "default"): void {
|
|
176
|
+
const profileFile = profileRoleFile(getAgentDir(), profileId);
|
|
177
|
+
this.global = profileRoles && !existsSync(profileFile) ? parseRoleConfig(profileRoles) : readConfig(profileFile);
|
|
171
178
|
this.cwd = cwd;
|
|
172
|
-
this.
|
|
179
|
+
this.profileId = profileId;
|
|
180
|
+
this.project = cwd ? readConfig(projectRoleFile(cwd, profileId)) : { version: 2, roles: {} };
|
|
181
|
+
}
|
|
182
|
+
activeProfileId(): string { return this.profileId; }
|
|
183
|
+
/** Restore the label only, after a failed profile transaction restored model and effort. */
|
|
184
|
+
restoreActiveRole(role: string | undefined, ctx: ExtensionContext): void {
|
|
185
|
+
if (role !== undefined && !isRoleName(role)) throw new Error("Invalid role name");
|
|
186
|
+
this.active = role;
|
|
187
|
+
this.status(ctx);
|
|
173
188
|
}
|
|
189
|
+
profileRoleConfig(): RoleConfig { return structuredClone(this.global); }
|
|
174
190
|
|
|
175
191
|
private merged(): Record<string, string> { return { ...this.global.roles, ...this.project.roles }; }
|
|
176
192
|
activeRole(): string | undefined { return this.active; }
|
|
@@ -288,7 +304,7 @@ export class ModelRoleManager {
|
|
|
288
304
|
const epoch = this.manualEpoch;
|
|
289
305
|
return async () => {
|
|
290
306
|
// A model or effort the user picked during the workflow wins over the saved one.
|
|
291
|
-
if (this.manualEpoch !== epoch)
|
|
307
|
+
if (this.manualEpoch !== epoch) return;
|
|
292
308
|
this.applying = true;
|
|
293
309
|
try { if (previousModel) await this.pi.setModel(previousModel); this.pi.setThinkingLevel(previousThinking); }
|
|
294
310
|
finally { this.applying = false; }
|
|
@@ -310,6 +326,9 @@ export class ModelRoleManager {
|
|
|
310
326
|
return await this.activate(next, ctx) ? next : undefined;
|
|
311
327
|
}
|
|
312
328
|
|
|
329
|
+
/** Invalidate temporary role restorations after a profile change. */
|
|
330
|
+
invalidateTemporaryRestores(): void { this.manualEpoch++; this.active = undefined; }
|
|
331
|
+
|
|
313
332
|
/** Assign (or clear with undefined) a role in the chosen scope. */
|
|
314
333
|
update(role: string, spec: string | undefined, scope: RoleScope = this.scopeOf(role) ?? "global"): void {
|
|
315
334
|
if (!isRoleName(role)) throw new Error("Invalid role name: " + role);
|
|
@@ -320,7 +339,7 @@ export class ModelRoleManager {
|
|
|
320
339
|
const roles = { ...target.roles };
|
|
321
340
|
if (normalized) roles[role] = normalized; else delete roles[role];
|
|
322
341
|
const next: RoleConfig = { ...target, version: 2, roles };
|
|
323
|
-
writeConfig(scope === "project" ? projectRoleFile(this.cwd
|
|
342
|
+
writeConfig(scope === "project" ? projectRoleFile(this.cwd!, this.profileId) : profileRoleFile(getAgentDir(), this.profileId), next);
|
|
324
343
|
if (scope === "project") this.project = next; else this.global = next;
|
|
325
344
|
}
|
|
326
345
|
|
|
@@ -346,11 +365,11 @@ export class ModelRoleManager {
|
|
|
346
365
|
if (scope === "project" && !this.cwd) throw new Error("Project roles need a working directory");
|
|
347
366
|
const target = scope === "project" ? this.project : this.global;
|
|
348
367
|
const next: RoleConfig = { ...target, fallbacks: { ...target.fallbacks, [role]: [...new Set(normalized)] } };
|
|
349
|
-
writeConfig(scope === "project" ? projectRoleFile(this.cwd
|
|
368
|
+
writeConfig(scope === "project" ? projectRoleFile(this.cwd!, this.profileId) : profileRoleFile(getAgentDir(), this.profileId), next);
|
|
350
369
|
if (scope === "project") this.project = next; else this.global = next;
|
|
351
370
|
}
|
|
352
371
|
|
|
353
|
-
register(openUi?: (ctx: ExtensionContext) => Promise<void>, options: { activateDefault?: () => boolean } = {}): void {
|
|
372
|
+
register(openUi?: (ctx: ExtensionContext) => Promise<void>, options: { activateDefault?: () => boolean; beforeSessionStart?: (ctx: ExtensionContext) => { roles?: RoleConfig; profileId?: string } | undefined } = {}): void {
|
|
354
373
|
const manual = (_event: unknown, ctx: ExtensionContext) => {
|
|
355
374
|
if (this.applying) return;
|
|
356
375
|
this.manualEpoch++;
|
|
@@ -359,7 +378,8 @@ export class ModelRoleManager {
|
|
|
359
378
|
this.pi.on("model_select", manual);
|
|
360
379
|
this.pi.on("thinking_level_select", manual);
|
|
361
380
|
this.pi.on("session_start", async (_event, ctx) => {
|
|
362
|
-
|
|
381
|
+
const profile = options.beforeSessionStart?.(ctx);
|
|
382
|
+
this.load(ctx.cwd, profile?.roles, profile?.profileId);
|
|
363
383
|
this.active = undefined;
|
|
364
384
|
// Delegated children already received their role model/effort via CLI arguments.
|
|
365
385
|
if (options.activateDefault?.() !== false && this.resolveCandidates("default").length) await this.activate("default", ctx, true);
|
package/src/perf.ts
ADDED
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import type { SubagentStats } from "./delegate.ts";
|
|
3
|
+
import type { DiscussionStats } from "./discussion.ts";
|
|
4
|
+
import type { QuotaStats } from "./quota.ts";
|
|
5
|
+
import type { ShellStats } from "./shells.ts";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Long-session budgets. Every frame Pi draws re-renders the whole transcript, so the cost of one
|
|
9
|
+
* background repaint grows with the session: background sources share one adaptive cadence here,
|
|
10
|
+
* and facts that walk the session (context usage) are cached and refreshed on boundaries.
|
|
11
|
+
* Diagnostics are in-memory counters read on demand by `/jar perf`; nothing here polls.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** Branch size where Pi's regular TUI redraw cost becomes noticeable (also the large-session hint). */
|
|
15
|
+
export const LARGE_SESSION_ENTRIES = 800;
|
|
16
|
+
|
|
17
|
+
/** `foreground`: a user action; `transition`: a visible state change; `background`: a progress delta. */
|
|
18
|
+
export type RenderKind = "foreground" | "transition" | "background";
|
|
19
|
+
export interface RenderTier {
|
|
20
|
+
name: "small" | "medium" | "large" | "very large";
|
|
21
|
+
/** First branch entry count in this tier. */
|
|
22
|
+
minEntries: number;
|
|
23
|
+
/** Minimum gap between background repaints; undefined: state transitions only. */
|
|
24
|
+
backgroundMs?: number;
|
|
25
|
+
/** Live-update coalescing for the Activity view, which also re-renders the transcript beneath it. */
|
|
26
|
+
focusedMs: number;
|
|
27
|
+
}
|
|
28
|
+
export const RENDER_TIERS: readonly RenderTier[] = [
|
|
29
|
+
{ name: "small", minEntries: 0, backgroundMs: 250, focusedMs: 100 },
|
|
30
|
+
{ name: "medium", minEntries: 300, backgroundMs: 500, focusedMs: 150 },
|
|
31
|
+
{ name: "large", minEntries: LARGE_SESSION_ENTRIES, backgroundMs: 1000, focusedMs: 250 },
|
|
32
|
+
{ name: "very large", minEntries: 2000, focusedMs: 500 }
|
|
33
|
+
];
|
|
34
|
+
export function renderTier(entries: number): RenderTier {
|
|
35
|
+
let tier = RENDER_TIERS[0]!;
|
|
36
|
+
for (const candidate of RENDER_TIERS) if (entries >= candidate.minEntries) tier = candidate;
|
|
37
|
+
return tier;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface RenderStats {
|
|
41
|
+
entries: number;
|
|
42
|
+
tier: RenderTier["name"];
|
|
43
|
+
/** Current background gap; undefined while background repaints wait for the next frame. */
|
|
44
|
+
intervalMs?: number;
|
|
45
|
+
focusedMs: number;
|
|
46
|
+
focused: boolean;
|
|
47
|
+
requested: number;
|
|
48
|
+
kinds: Record<RenderKind, number>;
|
|
49
|
+
/** Background requests folded into another frame: a pending repaint, ours or Pi's own. */
|
|
50
|
+
coalesced: number;
|
|
51
|
+
/** Background requests left for the next frame (very large sessions, focused Activity view). */
|
|
52
|
+
deferred: number;
|
|
53
|
+
/** Frames requested while no view was attached. */
|
|
54
|
+
skipped: number;
|
|
55
|
+
/** Frames requested from Pi by pi-jar, `background` of them from background sources. */
|
|
56
|
+
actual: number;
|
|
57
|
+
background: number;
|
|
58
|
+
/** Footer paints, including Pi's own frames (streaming, typing, overlays). */
|
|
59
|
+
frames: number;
|
|
60
|
+
backgroundLastMinute: number;
|
|
61
|
+
/** Requests per source, most first. */
|
|
62
|
+
sources: Array<[string, number]>;
|
|
63
|
+
/** Sources of the latest frames pi-jar requested, oldest first. */
|
|
64
|
+
recent: string[];
|
|
65
|
+
pending: boolean;
|
|
66
|
+
dirty: boolean;
|
|
67
|
+
ageMs: number;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const MAX_SOURCES = 16;
|
|
71
|
+
const RECENT_FRAMES = 8;
|
|
72
|
+
/** Enough timestamps for the fastest tier's full minute (250 ms → 240). */
|
|
73
|
+
const RATE_SAMPLES = 256;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Central footer repaint budget. User actions and visible state transitions render at once (Pi
|
|
77
|
+
* still coalesces them per tick); background deltas mark the footer dirty and share one repaint
|
|
78
|
+
* per tier interval, measured from the last frame of any origin. Any footer paint, including Pi's
|
|
79
|
+
* own, satisfies pending background work. Very large sessions and a focused Activity view leave
|
|
80
|
+
* background deltas to the next frame instead.
|
|
81
|
+
*/
|
|
82
|
+
export class RenderScheduler {
|
|
83
|
+
private entryCount = 0;
|
|
84
|
+
private focused = false;
|
|
85
|
+
private disposed = false;
|
|
86
|
+
private timer: NodeJS.Timeout | undefined;
|
|
87
|
+
private readonly pendingSources = new Set<string>();
|
|
88
|
+
private lastFrameAt = Number.NEGATIVE_INFINITY;
|
|
89
|
+
private dirty = false;
|
|
90
|
+
private readonly startedAt: number;
|
|
91
|
+
private readonly counts = { requested: 0, coalesced: 0, deferred: 0, skipped: 0, actual: 0, background: 0, frames: 0 };
|
|
92
|
+
private readonly kinds: Record<RenderKind, number> = { foreground: 0, transition: 0, background: 0 };
|
|
93
|
+
private readonly sources = new Map<string, number>();
|
|
94
|
+
private readonly recent: string[] = [];
|
|
95
|
+
private readonly backgroundTimes: number[] = [];
|
|
96
|
+
private readonly render: () => boolean;
|
|
97
|
+
private readonly now: () => number;
|
|
98
|
+
|
|
99
|
+
/** `render` asks Pi for a frame and reports whether a view was attached to draw it. */
|
|
100
|
+
constructor(render: () => boolean, now: () => number = () => Date.now()) {
|
|
101
|
+
this.render = render;
|
|
102
|
+
this.now = now;
|
|
103
|
+
this.startedAt = now();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Branch entries: set on session start/tree/compact, then counted per message, never re-walked. */
|
|
107
|
+
get entries(): number { return this.entryCount; }
|
|
108
|
+
setEntries(count: number): void { this.entryCount = Math.max(0, Math.floor(count)); }
|
|
109
|
+
addEntries(count = 1): void { this.entryCount += count; }
|
|
110
|
+
get tier(): RenderTier { return renderTier(this.entryCount); }
|
|
111
|
+
intervalMs(): number | undefined { return this.focused ? undefined : this.tier.backgroundMs; }
|
|
112
|
+
focusedMs(): number { return this.tier.focusedMs; }
|
|
113
|
+
/** The Activity view repaints its own live updates, and with them the footer beneath it. */
|
|
114
|
+
setFocused(focused: boolean): void { this.focused = focused; }
|
|
115
|
+
|
|
116
|
+
request(source: string, kind: RenderKind = "background"): void {
|
|
117
|
+
if (this.disposed) return;
|
|
118
|
+
this.counts.requested++;
|
|
119
|
+
this.kinds[kind]++;
|
|
120
|
+
const key = this.sources.has(source) || this.sources.size < MAX_SOURCES ? source : "other";
|
|
121
|
+
this.sources.set(key, (this.sources.get(key) ?? 0) + 1);
|
|
122
|
+
if (kind !== "background") { this.flush(source, kind); return; }
|
|
123
|
+
this.dirty = true;
|
|
124
|
+
const interval = this.intervalMs();
|
|
125
|
+
if (interval === undefined) { this.counts.deferred++; return; }
|
|
126
|
+
this.pendingSources.add(key);
|
|
127
|
+
if (this.timer) { this.counts.coalesced++; return; }
|
|
128
|
+
this.timer = setTimeout(() => { this.timer = undefined; this.flush(undefined, "background"); },
|
|
129
|
+
Math.max(0, this.lastFrameAt + interval - this.now()));
|
|
130
|
+
this.timer.unref?.();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** The footer drew a frame (ours or Pi's own); call before reading state so pending deltas are on screen. */
|
|
134
|
+
painted(): void {
|
|
135
|
+
this.counts.frames++;
|
|
136
|
+
this.lastFrameAt = this.now();
|
|
137
|
+
this.dirty = false;
|
|
138
|
+
if (!this.timer) return;
|
|
139
|
+
clearTimeout(this.timer);
|
|
140
|
+
this.timer = undefined;
|
|
141
|
+
this.pendingSources.clear();
|
|
142
|
+
this.counts.coalesced++;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
stats(): RenderStats {
|
|
146
|
+
const now = this.now();
|
|
147
|
+
const tier = this.tier;
|
|
148
|
+
const interval = this.intervalMs();
|
|
149
|
+
let lastMinute = 0;
|
|
150
|
+
for (const at of this.backgroundTimes) if (now - at <= 60_000) lastMinute++;
|
|
151
|
+
return {
|
|
152
|
+
entries: this.entryCount, tier: tier.name, ...(interval !== undefined ? { intervalMs: interval } : {}),
|
|
153
|
+
focusedMs: tier.focusedMs, focused: this.focused, ...this.counts, kinds: { ...this.kinds },
|
|
154
|
+
backgroundLastMinute: lastMinute, sources: [...this.sources].sort((a, b) => b[1] - a[1]), recent: [...this.recent],
|
|
155
|
+
pending: this.timer !== undefined, dirty: this.dirty, ageMs: now - this.startedAt
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
dispose(): void {
|
|
160
|
+
this.disposed = true;
|
|
161
|
+
clearTimeout(this.timer);
|
|
162
|
+
this.timer = undefined;
|
|
163
|
+
this.pendingSources.clear();
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
private flush(source: string | undefined, kind: RenderKind): void {
|
|
167
|
+
// A prompt frame also shows whatever a pending background repaint was waiting to show.
|
|
168
|
+
if (this.timer) { clearTimeout(this.timer); this.timer = undefined; this.counts.coalesced++; }
|
|
169
|
+
const label = source ?? ([...this.pendingSources].join("+") || "background");
|
|
170
|
+
this.pendingSources.clear();
|
|
171
|
+
if (!this.render()) { this.counts.skipped++; return; }
|
|
172
|
+
const at = this.now();
|
|
173
|
+
this.counts.actual++;
|
|
174
|
+
this.lastFrameAt = at;
|
|
175
|
+
this.dirty = false;
|
|
176
|
+
if (kind === "background") {
|
|
177
|
+
this.counts.background++;
|
|
178
|
+
this.backgroundTimes.push(at);
|
|
179
|
+
if (this.backgroundTimes.length > RATE_SAMPLES) this.backgroundTimes.shift();
|
|
180
|
+
}
|
|
181
|
+
this.recent.push(label);
|
|
182
|
+
if (this.recent.length > RECENT_FRAMES) this.recent.shift();
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Mid-turn context recomputation happens at most this often; boundaries refresh at once. */
|
|
187
|
+
export const CONTEXT_SAMPLE_MS = 750;
|
|
188
|
+
export interface ContextUsageLike { percent?: number | null }
|
|
189
|
+
export interface ContextSamplingStats {
|
|
190
|
+
label: string;
|
|
191
|
+
calls: number;
|
|
192
|
+
errors: number;
|
|
193
|
+
/** message_end marks that asked for a sample. */
|
|
194
|
+
marks: number;
|
|
195
|
+
totalMs: number;
|
|
196
|
+
maxMs: number;
|
|
197
|
+
/** Median over the latest samples. */
|
|
198
|
+
medianMs?: number;
|
|
199
|
+
lastSampleAgeMs?: number;
|
|
200
|
+
dirty: boolean;
|
|
201
|
+
pending: boolean;
|
|
202
|
+
}
|
|
203
|
+
const DURATION_SAMPLES = 32;
|
|
204
|
+
|
|
205
|
+
export function contextLabel(usage: ContextUsageLike | undefined): string {
|
|
206
|
+
return usage?.percent == null || !Number.isFinite(usage.percent) ? "ctx ?" : `ctx ${Math.round(usage.percent)}%`;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Cached `getContextUsage()`. Pi ends a message for every user, assistant and tool-result message,
|
|
211
|
+
* and the computation can walk the session, so message ends only mark the cache dirty; one sample
|
|
212
|
+
* per throttle window follows. The footer and welcome read the cached label only.
|
|
213
|
+
*/
|
|
214
|
+
export class ContextSampler {
|
|
215
|
+
private current = "ctx ?";
|
|
216
|
+
private read: (() => ContextUsageLike | undefined) | undefined;
|
|
217
|
+
private timer: NodeJS.Timeout | undefined;
|
|
218
|
+
private lastSampleAt: number | undefined;
|
|
219
|
+
private stale = false;
|
|
220
|
+
private readonly counts = { calls: 0, errors: 0, marks: 0, totalMs: 0, maxMs: 0 };
|
|
221
|
+
private readonly durations: number[] = [];
|
|
222
|
+
private readonly changed: () => void;
|
|
223
|
+
private readonly throttleMs: number;
|
|
224
|
+
private readonly now: () => number;
|
|
225
|
+
|
|
226
|
+
/** `changed` runs when a throttled sample changed the label; boundary refreshes leave repainting to the caller. */
|
|
227
|
+
constructor(changed: () => void, throttleMs = CONTEXT_SAMPLE_MS, now: () => number = () => Date.now()) {
|
|
228
|
+
this.changed = changed;
|
|
229
|
+
this.throttleMs = throttleMs;
|
|
230
|
+
this.now = now;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
get label(): string { return this.current; }
|
|
234
|
+
get dirty(): boolean { return this.stale; }
|
|
235
|
+
|
|
236
|
+
/** Lifecycle boundary (start, tree, compact, model, rename, settle): recompute now. */
|
|
237
|
+
refresh(read: () => ContextUsageLike | undefined): void {
|
|
238
|
+
clearTimeout(this.timer);
|
|
239
|
+
this.timer = undefined;
|
|
240
|
+
this.read = read;
|
|
241
|
+
this.sample();
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** A message moved the context window: recompute once the throttle window allows. */
|
|
245
|
+
markDirty(read: () => ContextUsageLike | undefined): void {
|
|
246
|
+
this.read = read;
|
|
247
|
+
this.stale = true;
|
|
248
|
+
this.counts.marks++;
|
|
249
|
+
if (this.timer) return;
|
|
250
|
+
this.timer = setTimeout(() => {
|
|
251
|
+
this.timer = undefined;
|
|
252
|
+
if (!this.stale) return;
|
|
253
|
+
const before = this.current;
|
|
254
|
+
this.sample();
|
|
255
|
+
if (this.current !== before) this.changed();
|
|
256
|
+
}, Math.max(0, (this.lastSampleAt ?? Number.NEGATIVE_INFINITY) + this.throttleMs - this.now()));
|
|
257
|
+
this.timer.unref?.();
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
stats(): ContextSamplingStats {
|
|
261
|
+
const sorted = [...this.durations].sort((a, b) => a - b);
|
|
262
|
+
return {
|
|
263
|
+
label: this.current, ...this.counts,
|
|
264
|
+
...(sorted.length ? { medianMs: sorted[Math.floor(sorted.length / 2)]! } : {}),
|
|
265
|
+
...(this.lastSampleAt !== undefined ? { lastSampleAgeMs: this.now() - this.lastSampleAt } : {}),
|
|
266
|
+
dirty: this.stale, pending: this.timer !== undefined
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Session shutdown: no timer, and no reference to the old session's context. */
|
|
271
|
+
dispose(): void {
|
|
272
|
+
clearTimeout(this.timer);
|
|
273
|
+
this.timer = undefined;
|
|
274
|
+
this.read = undefined;
|
|
275
|
+
this.stale = false;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
private sample(): void {
|
|
279
|
+
const read = this.read;
|
|
280
|
+
if (!read) return;
|
|
281
|
+
const started = performance.now();
|
|
282
|
+
try { this.current = contextLabel(read()); }
|
|
283
|
+
catch { this.current = "ctx ?"; this.counts.errors++; }
|
|
284
|
+
const elapsed = performance.now() - started;
|
|
285
|
+
this.counts.calls++;
|
|
286
|
+
this.counts.totalMs += elapsed;
|
|
287
|
+
this.counts.maxMs = Math.max(this.counts.maxMs, elapsed);
|
|
288
|
+
this.durations.push(elapsed);
|
|
289
|
+
if (this.durations.length > DURATION_SAMPLES) this.durations.shift();
|
|
290
|
+
this.lastSampleAt = this.now();
|
|
291
|
+
this.stale = false;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** Summed RSS of `pids` in bytes from one `ps` call; undefined on Windows, failure or no live pid. */
|
|
296
|
+
export function processRss(pids: readonly number[]): Promise<number | undefined> {
|
|
297
|
+
const valid = pids.filter((pid) => Number.isSafeInteger(pid) && pid > 0);
|
|
298
|
+
if (!valid.length || process.platform === "win32") return Promise.resolve(undefined);
|
|
299
|
+
return new Promise((resolve) => {
|
|
300
|
+
execFile("ps", ["-o", "rss=", "-p", valid.join(",")], { timeout: 1000, encoding: "utf8" }, (_error, stdout) => {
|
|
301
|
+
// ps exits 1 when one of the pids already exited, yet still reports the others.
|
|
302
|
+
let kib = 0;
|
|
303
|
+
let found = 0;
|
|
304
|
+
for (const line of String(stdout ?? "").split("\n")) {
|
|
305
|
+
const value = Number(line.trim());
|
|
306
|
+
if (line.trim() && Number.isFinite(value) && value >= 0) { kib += value; found++; }
|
|
307
|
+
}
|
|
308
|
+
resolve(found ? kib * 1024 : undefined);
|
|
309
|
+
});
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export interface PerfSnapshot {
|
|
314
|
+
entries: number;
|
|
315
|
+
context: string;
|
|
316
|
+
rssBytes: number;
|
|
317
|
+
render?: RenderStats;
|
|
318
|
+
sampling?: ContextSamplingStats;
|
|
319
|
+
subagents?: SubagentStats;
|
|
320
|
+
childRssBytes?: number;
|
|
321
|
+
shells?: ShellStats;
|
|
322
|
+
discussion?: DiscussionStats;
|
|
323
|
+
quota?: QuotaStats;
|
|
324
|
+
sideCalls?: number;
|
|
325
|
+
now: number;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const count = (value: number) => Math.round(value).toLocaleString("en-US");
|
|
329
|
+
const mib = (bytes: number) => `${(bytes / 1048576).toFixed(bytes < 10 * 1048576 ? 1 : 0)} MiB`;
|
|
330
|
+
const kib = (bytes: number) => bytes < 1048576 ? `${(bytes / 1024).toFixed(1)} KiB` : mib(bytes);
|
|
331
|
+
const chars = (value: number) => value < 10_000 ? count(value) : value < 1_000_000 ? `${(value / 1000).toFixed(1)}k` : `${(value / 1_000_000).toFixed(1)}M`;
|
|
332
|
+
const ms = (value: number) => value < 10 ? `${value.toFixed(2)} ms` : value < 1000 ? `${Math.round(value)} ms` : `${(value / 1000).toFixed(1)} s`;
|
|
333
|
+
function age(value: number): string {
|
|
334
|
+
const elapsed = Math.max(0, value);
|
|
335
|
+
if (elapsed < 1000) return `${Math.round(elapsed)} ms`;
|
|
336
|
+
if (elapsed < 60_000) return `${(elapsed / 1000).toFixed(1)} s`;
|
|
337
|
+
const seconds = Math.floor(elapsed / 1000);
|
|
338
|
+
return seconds < 3600 ? `${Math.floor(seconds / 60)}m ${seconds % 60}s` : `${Math.floor(seconds / 3600)}h ${Math.floor(seconds / 60) % 60}m`;
|
|
339
|
+
}
|
|
340
|
+
/** Consecutive repeats collapse: `subagents ×3 · shells`. */
|
|
341
|
+
function runs(labels: readonly string[]): string {
|
|
342
|
+
const parts: string[] = [];
|
|
343
|
+
for (let index = 0; index < labels.length;) {
|
|
344
|
+
let end = index;
|
|
345
|
+
while (end < labels.length && labels[end] === labels[index]) end++;
|
|
346
|
+
parts.push(end - index > 1 ? `${labels[index]} ×${end - index}` : labels[index]!);
|
|
347
|
+
index = end;
|
|
348
|
+
}
|
|
349
|
+
return parts.join(" · ") || "none";
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** `/jar perf`: one on-demand snapshot of the long-session budgets; sections without data are omitted. */
|
|
353
|
+
export function formatPerf(snapshot: PerfSnapshot): string {
|
|
354
|
+
const lines = ["pi-jar perf"];
|
|
355
|
+
const section = (title: string, rows: ReadonlyArray<readonly [string, string] | undefined>) => {
|
|
356
|
+
lines.push("", title);
|
|
357
|
+
for (const row of rows) if (row) lines.push(` ${row[0].padEnd(20)}${row[1]}`);
|
|
358
|
+
};
|
|
359
|
+
const { render, sampling, subagents, shells, discussion, quota } = snapshot;
|
|
360
|
+
section("session", [
|
|
361
|
+
["branch entries", count(snapshot.entries)],
|
|
362
|
+
["context", snapshot.context.replace(/^ctx /, "")],
|
|
363
|
+
["parent rss", mib(snapshot.rssBytes)]
|
|
364
|
+
]);
|
|
365
|
+
if (subagents) section("subagents", [
|
|
366
|
+
["retained", count(subagents.retained)],
|
|
367
|
+
["live children", count(subagents.live) + (subagents.pids.length ? ` (pid ${subagents.pids.slice(0, 8).join(", ")}${subagents.pids.length > 8 ? ", …" : ""})` : "")],
|
|
368
|
+
["hibernated", count(subagents.hibernated)],
|
|
369
|
+
["child rss", snapshot.childRssBytes !== undefined ? mib(snapshot.childRssBytes) : subagents.live ? "unavailable" : "none"],
|
|
370
|
+
["recovery worktrees", `${count(subagents.recovery)} / ${count(subagents.recoveryLimit)}`]
|
|
371
|
+
]);
|
|
372
|
+
if (render) section("render", [
|
|
373
|
+
["tier", `${render.tier} · ${render.focused ? "activity view focused" : render.intervalMs !== undefined ? `background ≥ ${render.intervalMs} ms` : "transitions only"} · activity ${render.focusedMs} ms`],
|
|
374
|
+
["requested", `${count(render.requested)} (foreground ${count(render.kinds.foreground)} · transition ${count(render.kinds.transition)} · background ${count(render.kinds.background)})`],
|
|
375
|
+
["coalesced", count(render.coalesced)],
|
|
376
|
+
["deferred", count(render.deferred)],
|
|
377
|
+
["actual", `${count(render.actual)} (background ${count(render.background)})` + (render.skipped ? ` · ${count(render.skipped)} without a view` : "")],
|
|
378
|
+
["frames painted", count(render.frames)],
|
|
379
|
+
["background rate", `${count(render.backgroundLastMinute)} / last minute`],
|
|
380
|
+
["sources", render.sources.slice(0, 5).map(([source, value]) => `${source} ${count(value)}`).join(" · ") || "none"],
|
|
381
|
+
["last sources", runs(render.recent)],
|
|
382
|
+
["pending", render.pending ? "repaint scheduled" : render.dirty ? "next frame" : "no"]
|
|
383
|
+
]);
|
|
384
|
+
if (sampling) section("context sampling", [
|
|
385
|
+
["calls", count(sampling.calls) + (sampling.errors ? ` (${count(sampling.errors)} failed)` : "")],
|
|
386
|
+
["dirty marks", count(sampling.marks)],
|
|
387
|
+
["total time", ms(sampling.totalMs)],
|
|
388
|
+
["max time", ms(sampling.maxMs) + (sampling.medianMs !== undefined ? ` (median ${ms(sampling.medianMs)})` : "")],
|
|
389
|
+
["last sample age", sampling.lastSampleAgeMs !== undefined ? age(sampling.lastSampleAgeMs) : "never"],
|
|
390
|
+
["pending", sampling.pending ? "sample scheduled" : sampling.dirty ? "yes" : "no"]
|
|
391
|
+
]);
|
|
392
|
+
if (shells) section("shells", [
|
|
393
|
+
["live", count(shells.live) + (shells.services ? ` (${count(shells.services)} service${shells.services === 1 ? "" : "s"})` : "")],
|
|
394
|
+
["retained finished", count(shells.finished)],
|
|
395
|
+
["output retained", `${chars(shells.retainedChars)} / ${chars(shells.budgetChars)} chars`],
|
|
396
|
+
shells.oldestLiveStartedAt !== undefined ? ["oldest live", age(snapshot.now - shells.oldestLiveStartedAt)] : undefined
|
|
397
|
+
]);
|
|
398
|
+
if (discussion) section("discussion", [
|
|
399
|
+
["transport", discussion.transport + (discussion.listening ? "" : " (not listening)")],
|
|
400
|
+
["agents", count(discussion.agents)],
|
|
401
|
+
["messages", `${count(discussion.messages)} / ${count(discussion.maxMessages)}`
|
|
402
|
+
+ (discussion.oldestId !== undefined && discussion.newestId !== undefined ? ` (#${discussion.oldestId}–#${discussion.newestId})` : "")],
|
|
403
|
+
["bytes", `${kib(discussion.bytes)} / ${kib(discussion.maxBytes)}`],
|
|
404
|
+
["unanswered", count(discussion.unanswered) + (discussion.unread ? ` (${count(discussion.unread)} unread)` : "")],
|
|
405
|
+
["requests", count(discussion.requests) + (discussion.errors ? ` (${count(discussion.errors)} failed)` : "")]
|
|
406
|
+
]);
|
|
407
|
+
if (quota) section("quota", [
|
|
408
|
+
["lookups", quota.enabled ? "on" : "off"],
|
|
409
|
+
...(quota.providers.length ? quota.providers.map((item) => [item.provider, [
|
|
410
|
+
item.ageMs !== undefined ? `cached ${age(item.ageMs)} ago` : "not cached",
|
|
411
|
+
item.ok === false ? `failed ×${item.failures}` : item.ok ? "ok" : "",
|
|
412
|
+
item.retryInMs ? `next in ${age(item.retryInMs)}` : ""
|
|
413
|
+
].filter(Boolean).join(" · ")] as const) : [["provider cache", "empty"] as const]),
|
|
414
|
+
["pending", quota.providers.some((item) => item.pending) ? "yes" : "no"],
|
|
415
|
+
["requests", count(quota.requests) + (quota.failures ? ` (${count(quota.failures)} failed)` : "")],
|
|
416
|
+
snapshot.sideCalls !== undefined ? ["side calls", count(snapshot.sideCalls)] : undefined
|
|
417
|
+
]);
|
|
418
|
+
return lines.join("\n");
|
|
419
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { JarVisualSettings } from "./settings.ts";
|
|
3
|
+
import type { JarProfile, ProfileStore } from "./profiles.ts";
|
|
4
|
+
import { isRoleName, normalizeSpec, type RoleConfig, type ModelRoleManager } from "./model-roles.ts";
|
|
5
|
+
|
|
6
|
+
export interface ProfileWizardOptions {
|
|
7
|
+
themes: readonly string[];
|
|
8
|
+
settings: JarVisualSettings;
|
|
9
|
+
roles: RoleConfig;
|
|
10
|
+
roleManager?: ModelRoleManager;
|
|
11
|
+
create(name: string, theme: string, settings: JarVisualSettings, roles: RoleConfig): JarProfile;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Every step is cancellable; creation happens only after the final confirmation. */
|
|
15
|
+
export async function createProfileWizard(ctx: ExtensionContext, store: ProfileStore, options: ProfileWizardOptions): Promise<JarProfile | undefined> {
|
|
16
|
+
if (!ctx.hasUI || ctx.mode !== "tui") return undefined;
|
|
17
|
+
const name = await ctx.ui.input("New profile name (1–32 characters)", "");
|
|
18
|
+
if (name === undefined) return undefined;
|
|
19
|
+
if (/[\u0000-\u001f\u007f-\u009f]/.test(name)) { ctx.ui.notify("Profile name cannot contain control characters", "warning"); return undefined; }
|
|
20
|
+
const normalized = name.trim().replace(/\s+/g, " ");
|
|
21
|
+
if (!normalized || normalized.length > 32 || /[\\/\0]/.test(normalized)) {
|
|
22
|
+
ctx.ui.notify("Profile name must be 1–32 characters and cannot contain slashes", "warning"); return undefined;
|
|
23
|
+
}
|
|
24
|
+
if (store.list().some((profile) => profile.name.toLocaleLowerCase() === normalized.toLocaleLowerCase())) {
|
|
25
|
+
ctx.ui.notify("A profile with that name already exists", "warning"); return undefined;
|
|
26
|
+
}
|
|
27
|
+
if (!options.themes.length) { ctx.ui.notify("No themes are available for profiles", "warning"); return undefined; }
|
|
28
|
+
const selectedTheme = await ctx.ui.select("Choose a theme for " + normalized, ["Follow Pi", ...options.themes.filter((item) => item !== "follow")]);
|
|
29
|
+
if (!selectedTheme) return undefined;
|
|
30
|
+
const theme = selectedTheme === "Follow Pi" ? "follow" : selectedTheme;
|
|
31
|
+
const roles = structuredClone(options.roles);
|
|
32
|
+
const roleNames = options.roleManager?.list().map((role) => role.role) ?? [];
|
|
33
|
+
if (options.roleManager) {
|
|
34
|
+
for (const role of roleNames) {
|
|
35
|
+
const current = roles.roles[role];
|
|
36
|
+
const answer = await ctx.ui.input(`${role} role assignment (blank keeps current${current ? `: ${current}` : ""}; none clears)`, "");
|
|
37
|
+
if (answer === undefined) return undefined;
|
|
38
|
+
if (!answer.trim()) continue;
|
|
39
|
+
if (answer.trim().toLowerCase() === "none") { delete roles.roles[role]; continue; }
|
|
40
|
+
const model = normalizeSpec(answer.trim());
|
|
41
|
+
if (!model) { ctx.ui.notify("Enter a valid provider/model, @role, *, or none for " + role, "warning"); return undefined; }
|
|
42
|
+
roles.roles[role] = model;
|
|
43
|
+
}
|
|
44
|
+
} else {
|
|
45
|
+
const roleInput = await ctx.ui.input("Role assignments as role=provider/model, comma-separated (optional)", "");
|
|
46
|
+
if (roleInput === undefined) return undefined;
|
|
47
|
+
for (const assignment of roleInput.split(",").map((item) => item.trim()).filter(Boolean)) {
|
|
48
|
+
const split = assignment.indexOf("=");
|
|
49
|
+
if (split <= 0) { ctx.ui.notify("Use role=provider/model for each assignment", "warning"); return undefined; }
|
|
50
|
+
const role = assignment.slice(0, split).trim().toLowerCase();
|
|
51
|
+
const model = normalizeSpec(assignment.slice(split + 1).trim());
|
|
52
|
+
if (!isRoleName(role) || !model) { ctx.ui.notify("Invalid role assignment: " + assignment, "warning"); return undefined; }
|
|
53
|
+
roles.roles[role] = model;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const roleSummary = Object.entries(roles.roles).map(([role, target]) => `${role} → ${target}`).join("\n") || "No roles assigned";
|
|
57
|
+
const confirmed = await ctx.ui.confirm("Create profile", `${normalized}\nTheme: ${theme === "follow" ? "Follow Pi" : theme}\n${roleSummary}`);
|
|
58
|
+
if (!confirmed) return undefined;
|
|
59
|
+
try { return options.create(normalized, theme, options.settings, roles); }
|
|
60
|
+
catch (error) { ctx.ui.notify("Could not create profile: " + (error as Error).message, "error"); return undefined; }
|
|
61
|
+
}
|