@ferris1225/pi-subagents 4.3.10 → 4.3.12
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/CHANGELOG.md +32 -186
- package/README.md +87 -66
- package/agents/artisan.md +7 -11
- package/agents/scout.md +7 -11
- package/agents/sentinel.md +7 -8
- package/agents/steward.md +8 -9
- package/index.ts +5 -1
- package/package.json +1 -1
- package/src/delegation/dispatch.ts +9 -32
- package/src/delegation/prompt.ts +8 -14
- package/src/lifecycle/completion.ts +26 -5
- package/src/lifecycle/thread-lifecycle.ts +1 -0
- package/src/lifecycle/tools.ts +1 -1
- package/src/presentation/announcements.ts +7 -1
- package/src/presentation/cost-footer.ts +201 -0
- package/src/presentation/cost-ledger.ts +286 -0
- package/src/presentation/monitor.ts +1 -1
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-model spend ledger for the main window's own generation.
|
|
3
|
+
*
|
|
4
|
+
* Sub-agent runs are deliberately not tracked here: every child reports its
|
|
5
|
+
* usage with its model ref when it settles (completion block, widget row,
|
|
6
|
+
* per-model completion totals), so mixing its live spend into the parent's
|
|
7
|
+
* footer would just re-create the cross-model sum this ledger exists to
|
|
8
|
+
* avoid. The ledger covers the main window only — across `/model` switches,
|
|
9
|
+
* each model keeps its own tally — plus its live token throughput.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
13
|
+
import { modelRef } from "../configuration/models.ts";
|
|
14
|
+
|
|
15
|
+
export interface ModelSpend {
|
|
16
|
+
input: number;
|
|
17
|
+
output: number;
|
|
18
|
+
cacheRead: number;
|
|
19
|
+
cacheWrite: number;
|
|
20
|
+
cost: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ModelSpendRow {
|
|
24
|
+
/** Full `provider/model` ref. */
|
|
25
|
+
model: string;
|
|
26
|
+
/** True while this is the window's current model. */
|
|
27
|
+
current: boolean;
|
|
28
|
+
spend: ModelSpend;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface MainStreamSpeed {
|
|
32
|
+
model: string;
|
|
33
|
+
tokensPerSecond: number;
|
|
34
|
+
/** True while the assistant is still streaming (value is an estimate). */
|
|
35
|
+
streaming: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface StreamState {
|
|
39
|
+
model: string;
|
|
40
|
+
startedAt: number;
|
|
41
|
+
estimatedChars: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Rough chars-per-token for the live estimate; the exact rate replaces it at
|
|
45
|
+
* `message_end`, where real output tokens are known. */
|
|
46
|
+
const ESTIMATED_CHARS_PER_TOKEN = 4;
|
|
47
|
+
|
|
48
|
+
/** pi Usage shape: numeric token buckets plus a cost that is either already a
|
|
49
|
+
* number (child tallies) or the provider object with a `total`. */
|
|
50
|
+
interface UsageLike {
|
|
51
|
+
input?: unknown;
|
|
52
|
+
output?: unknown;
|
|
53
|
+
cacheRead?: unknown;
|
|
54
|
+
cacheWrite?: unknown;
|
|
55
|
+
cost?: unknown;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function emptySpend(): ModelSpend {
|
|
59
|
+
return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** `provider/model` for a message; falls back to the bare model id when a
|
|
63
|
+
* providerless message sneaks through. */
|
|
64
|
+
export function messageModelRef(message: { provider?: string; model?: string }): string | undefined {
|
|
65
|
+
const model = message.model?.trim();
|
|
66
|
+
if (!model) return undefined;
|
|
67
|
+
return message.provider?.trim() ? `${message.provider.trim()}/${model}` : model;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function finiteUsage(usage: UsageLike | undefined): ModelSpend {
|
|
71
|
+
const num = (value: unknown): number => (typeof value === "number" && Number.isFinite(value) ? value : 0);
|
|
72
|
+
const cost = usage?.cost;
|
|
73
|
+
return {
|
|
74
|
+
input: num(usage?.input),
|
|
75
|
+
output: num(usage?.output),
|
|
76
|
+
cacheRead: num(usage?.cacheRead),
|
|
77
|
+
cacheWrite: num(usage?.cacheWrite),
|
|
78
|
+
cost: num(typeof cost === "object" && cost !== null ? (cost as { total?: unknown }).total : cost),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export class CostLedger {
|
|
83
|
+
private rows = new Map<string, ModelSpend>();
|
|
84
|
+
/** First-seen order, so row order is stable between notifications. */
|
|
85
|
+
private order: string[] = [];
|
|
86
|
+
private currentModel: string | undefined;
|
|
87
|
+
private stream: StreamState | undefined;
|
|
88
|
+
private lastSpeed: MainStreamSpeed | undefined;
|
|
89
|
+
private subscribers = new Set<() => void>();
|
|
90
|
+
|
|
91
|
+
record(model: string, spend: UsageLike | undefined): void {
|
|
92
|
+
const ref = model.trim();
|
|
93
|
+
if (!ref) return;
|
|
94
|
+
const row = this.rows.get(ref) ?? emptySpend();
|
|
95
|
+
const add = finiteUsage(spend);
|
|
96
|
+
row.input += add.input;
|
|
97
|
+
row.output += add.output;
|
|
98
|
+
row.cacheRead += add.cacheRead;
|
|
99
|
+
row.cacheWrite += add.cacheWrite;
|
|
100
|
+
row.cost += add.cost;
|
|
101
|
+
if (!this.rows.has(ref)) this.order.push(ref);
|
|
102
|
+
this.rows.set(ref, row);
|
|
103
|
+
this.notify();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
markCurrentModel(model: string | undefined): void {
|
|
107
|
+
const ref = model?.trim() || undefined;
|
|
108
|
+
if (ref === this.currentModel) return;
|
|
109
|
+
this.currentModel = ref;
|
|
110
|
+
this.notify();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
getCurrentModel(): string | undefined {
|
|
114
|
+
return this.currentModel;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Current model first, everything else in first-seen order. A current
|
|
118
|
+
* model with no spend yet still gets a zero row so the footer keeps its
|
|
119
|
+
* shape from the first render. */
|
|
120
|
+
snapshot(): ModelSpendRow[] {
|
|
121
|
+
const rows: ModelSpendRow[] = [];
|
|
122
|
+
if (this.currentModel) {
|
|
123
|
+
rows.push({ model: this.currentModel, current: true, spend: this.rows.get(this.currentModel) ?? emptySpend() });
|
|
124
|
+
}
|
|
125
|
+
for (const model of this.order) {
|
|
126
|
+
if (model === this.currentModel) continue;
|
|
127
|
+
rows.push({ model, current: false, spend: this.rows.get(model) ?? emptySpend() });
|
|
128
|
+
}
|
|
129
|
+
return rows;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
reset(): void {
|
|
133
|
+
this.rows.clear();
|
|
134
|
+
this.order = [];
|
|
135
|
+
this.currentModel = undefined;
|
|
136
|
+
this.stream = undefined;
|
|
137
|
+
this.lastSpeed = undefined;
|
|
138
|
+
this.notify();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
subscribe(cb: () => void): () => void {
|
|
142
|
+
this.subscribers.add(cb);
|
|
143
|
+
return () => {
|
|
144
|
+
this.subscribers.delete(cb);
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
noteStreamStart(model: string): void {
|
|
149
|
+
this.stream = { model, startedAt: Date.now(), estimatedChars: 0 };
|
|
150
|
+
this.lastSpeed = undefined;
|
|
151
|
+
this.notify();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
noteStreamDelta(chars: number): void {
|
|
155
|
+
if (!this.stream || chars <= 0) return;
|
|
156
|
+
this.stream.estimatedChars += chars;
|
|
157
|
+
this.notify();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
isStreaming(): boolean {
|
|
161
|
+
return this.stream !== undefined;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
noteStreamEnd(model: string, outputTokens: number): void {
|
|
165
|
+
const stream = this.stream;
|
|
166
|
+
this.stream = undefined;
|
|
167
|
+
if (stream && outputTokens > 0) {
|
|
168
|
+
const seconds = Math.max(0.001, (Date.now() - stream.startedAt) / 1_000);
|
|
169
|
+
this.lastSpeed = { model, tokensPerSecond: outputTokens / seconds, streaming: false };
|
|
170
|
+
}
|
|
171
|
+
this.notify();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Live estimate while streaming, exact rate of the last completed
|
|
175
|
+
* assistant message afterwards; undefined before any generation. */
|
|
176
|
+
speed(): MainStreamSpeed | undefined {
|
|
177
|
+
if (this.stream) {
|
|
178
|
+
const seconds = Math.max(0.001, (Date.now() - this.stream.startedAt) / 1_000);
|
|
179
|
+
return {
|
|
180
|
+
model: this.stream.model,
|
|
181
|
+
tokensPerSecond: this.stream.estimatedChars / ESTIMATED_CHARS_PER_TOKEN / seconds,
|
|
182
|
+
streaming: true,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
return this.lastSpeed;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
private notify(): void {
|
|
189
|
+
for (const cb of this.subscribers) {
|
|
190
|
+
try {
|
|
191
|
+
cb();
|
|
192
|
+
} catch {
|
|
193
|
+
/* subscriber errors must not break accounting */
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export const costLedger = new CostLedger();
|
|
200
|
+
|
|
201
|
+
/** Latest event context, so the footer renders against live session state
|
|
202
|
+
* (context usage, cwd, session name) without holding a stale install-time ctx. */
|
|
203
|
+
let latestContext: ExtensionContext | undefined;
|
|
204
|
+
|
|
205
|
+
/** Wire the main window's own generation into the ledger: assistant messages
|
|
206
|
+
* carry the serving model and exact usage, model switches re-key the current
|
|
207
|
+
* row, and compaction calls land on the model that made them. */
|
|
208
|
+
export function registerMainCostTracking(pi: ExtensionAPI): void {
|
|
209
|
+
const remember = (ctx: ExtensionContext): void => {
|
|
210
|
+
latestContext = ctx;
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
pi.on("message_start", async (event, ctx) => {
|
|
214
|
+
remember(ctx);
|
|
215
|
+
const message = (event as { message?: { role?: string } }).message;
|
|
216
|
+
if (message?.role !== "assistant") return;
|
|
217
|
+
const ref = messageModelRef(message as { provider?: string; model?: string });
|
|
218
|
+
if (ref) costLedger.noteStreamStart(ref);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
pi.on("message_update", async (event, ctx) => {
|
|
222
|
+
remember(ctx);
|
|
223
|
+
const delta = (event as { assistantMessageEvent?: { type?: string; delta?: string } }).assistantMessageEvent;
|
|
224
|
+
if (delta?.type !== "text_delta" && delta?.type !== "thinking_delta") return;
|
|
225
|
+
// `message_start` does not always carry the model yet; the first delta
|
|
226
|
+
// of the stream is just as good a clock start.
|
|
227
|
+
if (!costLedger.isStreaming()) {
|
|
228
|
+
const partial = (event as { message?: { role?: string } }).message;
|
|
229
|
+
if (partial?.role === "assistant") {
|
|
230
|
+
const ref = messageModelRef(partial as { provider?: string; model?: string });
|
|
231
|
+
if (ref) costLedger.noteStreamStart(ref);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
costLedger.noteStreamDelta(delta.delta?.length ?? 0);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
pi.on("message_end", async (event, ctx) => {
|
|
238
|
+
remember(ctx);
|
|
239
|
+
const message = (event as { message?: { role?: string; provider?: string; model?: string; usage?: UsageLike } }).message;
|
|
240
|
+
if (message?.role !== "assistant") return;
|
|
241
|
+
const ref = messageModelRef(message) ?? costLedger.getCurrentModel();
|
|
242
|
+
if (!ref) return;
|
|
243
|
+
const usage = finiteUsage(message.usage);
|
|
244
|
+
costLedger.noteStreamEnd(ref, usage.output);
|
|
245
|
+
costLedger.record(ref, usage);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
pi.on("model_select", async (event, ctx) => {
|
|
249
|
+
remember(ctx);
|
|
250
|
+
costLedger.markCurrentModel(modelRef(event.model));
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
pi.on("session_compact", async (event, ctx) => {
|
|
254
|
+
remember(ctx);
|
|
255
|
+
const usage = (event as { compactionEntry?: { usage?: UsageLike } }).compactionEntry?.usage;
|
|
256
|
+
const ref = costLedger.getCurrentModel() ?? (latestContext?.model ? modelRef(latestContext.model) : undefined);
|
|
257
|
+
if (usage && ref) costLedger.record(ref, finiteUsage(usage));
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Rebuild the ledger from the persisted session after a reload or session
|
|
262
|
+
* switch, then key the current row to the window's model. Compaction and
|
|
263
|
+
* branch-summary LLM calls carry no model of their own, so they land on the
|
|
264
|
+
* current model — the one that made them. */
|
|
265
|
+
export function seedCostLedgerFromSession(ctx: {
|
|
266
|
+
sessionManager: { getEntries(): Array<{ type: string; message?: unknown; usage?: UsageLike }> };
|
|
267
|
+
model?: { provider: string; id: string } | undefined;
|
|
268
|
+
}): void {
|
|
269
|
+
costLedger.reset();
|
|
270
|
+
costLedger.markCurrentModel(ctx.model ? modelRef(ctx.model) : undefined);
|
|
271
|
+
const current = costLedger.getCurrentModel();
|
|
272
|
+
for (const entry of ctx.sessionManager.getEntries()) {
|
|
273
|
+
if (entry.type === "message") {
|
|
274
|
+
const message = entry.message as { role?: string; provider?: string; model?: string; usage?: UsageLike } | undefined;
|
|
275
|
+
if (message?.role !== "assistant") continue;
|
|
276
|
+
const ref = messageModelRef(message);
|
|
277
|
+
if (ref) costLedger.record(ref, finiteUsage(message.usage));
|
|
278
|
+
} else if ((entry.type === "compaction" || entry.type === "branch_summary") && entry.usage && current) {
|
|
279
|
+
costLedger.record(current, finiteUsage(entry.usage));
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export function latestTrackedContext(): ExtensionContext | undefined {
|
|
285
|
+
return latestContext;
|
|
286
|
+
}
|
|
@@ -264,7 +264,7 @@ export function shrinkRunLabel(text: string, maxWidth: number): string {
|
|
|
264
264
|
return `${TASK_SUMMARY_ELLIPSIS}${tailGraphemes(chars, maxWidth - 1)}`;
|
|
265
265
|
}
|
|
266
266
|
|
|
267
|
-
function formatTokens(count: number): string {
|
|
267
|
+
export function formatTokens(count: number): string {
|
|
268
268
|
if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`;
|
|
269
269
|
if (count >= 1_000) return `${(count / 1_000).toFixed(1)}k`;
|
|
270
270
|
return String(count);
|