@ilya-lesikov/pi-pi 0.5.0 → 0.6.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/3p/pi-ask-user/index.ts +65 -49
- package/3p/pi-subagents/src/agent-manager.ts +8 -0
- package/3p/pi-subagents/src/agent-runner.ts +112 -19
- package/3p/pi-subagents/src/index.ts +3 -0
- package/extensions/orchestrator/agents/brainstorm-reviewer.ts +32 -19
- package/extensions/orchestrator/agents/code-reviewer.ts +31 -17
- package/extensions/orchestrator/agents/constraints.ts +55 -0
- package/extensions/orchestrator/agents/explore.ts +13 -13
- package/extensions/orchestrator/agents/librarian.ts +12 -9
- package/extensions/orchestrator/agents/plan-reviewer.ts +31 -19
- package/extensions/orchestrator/agents/planner.ts +28 -23
- package/extensions/orchestrator/agents/registry.ts +2 -1
- package/extensions/orchestrator/agents/repo-context.ts +11 -0
- package/extensions/orchestrator/agents/task.ts +14 -11
- package/extensions/orchestrator/agents/tool-routing.ts +17 -32
- package/extensions/orchestrator/ast-search.ts +2 -1
- package/extensions/orchestrator/cbm.test.ts +35 -0
- package/extensions/orchestrator/cbm.ts +43 -13
- package/extensions/orchestrator/command-handlers.test.ts +390 -19
- package/extensions/orchestrator/command-handlers.ts +68 -28
- package/extensions/orchestrator/commands.test.ts +255 -2
- package/extensions/orchestrator/commands.ts +108 -10
- package/extensions/orchestrator/config.test.ts +289 -68
- package/extensions/orchestrator/config.ts +630 -121
- package/extensions/orchestrator/context.test.ts +177 -10
- package/extensions/orchestrator/context.ts +115 -14
- package/extensions/orchestrator/custom-footer.ts +2 -1
- package/extensions/orchestrator/doctor.test.ts +559 -0
- package/extensions/orchestrator/doctor.ts +664 -0
- package/extensions/orchestrator/event-handlers.test.ts +84 -22
- package/extensions/orchestrator/event-handlers.ts +1177 -341
- package/extensions/orchestrator/exa.test.ts +46 -0
- package/extensions/orchestrator/exa.ts +16 -10
- package/extensions/orchestrator/flant-infra.test.ts +224 -0
- package/extensions/orchestrator/flant-infra.ts +110 -41
- package/extensions/orchestrator/index.ts +13 -2
- package/extensions/orchestrator/integration.test.ts +2866 -118
- package/extensions/orchestrator/log.test.ts +219 -0
- package/extensions/orchestrator/log.ts +153 -0
- package/extensions/orchestrator/model-registry.test.ts +238 -0
- package/extensions/orchestrator/model-registry.ts +282 -0
- package/extensions/orchestrator/model-version.test.ts +27 -0
- package/extensions/orchestrator/model-version.ts +19 -0
- package/extensions/orchestrator/orchestrator.test.ts +206 -56
- package/extensions/orchestrator/orchestrator.ts +287 -140
- package/extensions/orchestrator/phases/brainstorm.test.ts +10 -7
- package/extensions/orchestrator/phases/brainstorm.ts +41 -36
- package/extensions/orchestrator/phases/implementation.ts +7 -11
- package/extensions/orchestrator/phases/machine.test.ts +27 -8
- package/extensions/orchestrator/phases/machine.ts +13 -0
- package/extensions/orchestrator/phases/planning.ts +57 -31
- package/extensions/orchestrator/phases/review-task.ts +3 -7
- package/extensions/orchestrator/phases/review.ts +38 -39
- package/extensions/orchestrator/phases/spawn-blocking.test.ts +69 -0
- package/extensions/orchestrator/phases/verdict.test.ts +139 -0
- package/extensions/orchestrator/phases/verdict.ts +82 -0
- package/extensions/orchestrator/plannotator.test.ts +85 -0
- package/extensions/orchestrator/plannotator.ts +24 -6
- package/extensions/orchestrator/pp-menu.test.ts +134 -0
- package/extensions/orchestrator/pp-menu.ts +2557 -347
- package/extensions/orchestrator/repo-utils.test.ts +151 -0
- package/extensions/orchestrator/repo-utils.ts +67 -0
- package/extensions/orchestrator/spawn-cleanup.test.ts +57 -0
- package/extensions/orchestrator/spawn-cleanup.ts +35 -0
- package/extensions/orchestrator/state.test.ts +76 -6
- package/extensions/orchestrator/state.ts +89 -26
- package/extensions/orchestrator/subagent-session-marker.test.ts +36 -0
- package/extensions/orchestrator/test-helpers.ts +217 -0
- package/extensions/orchestrator/tracer.test.ts +132 -0
- package/extensions/orchestrator/tracer.ts +127 -0
- package/extensions/orchestrator/transition-controller.test.ts +207 -0
- package/extensions/orchestrator/transition-controller.ts +259 -0
- package/extensions/orchestrator/usage-tracker.test.ts +435 -0
- package/extensions/orchestrator/usage-tracker.ts +23 -2
- package/extensions/orchestrator/validate-artifacts.test.ts +83 -1
- package/package.json +2 -1
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
5
|
+
import { createUsageTracker, dumpUsageSummary, loadUsageSummary } from "./usage-tracker.js";
|
|
6
|
+
|
|
7
|
+
const tempDirs: string[] = [];
|
|
8
|
+
|
|
9
|
+
function makeTempDir(): string {
|
|
10
|
+
const dir = mkdtempSync(join(tmpdir(), "pi-pi-usage-tracker-"));
|
|
11
|
+
tempDirs.push(dir);
|
|
12
|
+
return dir;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
delete process.env.PI_CODING_AGENT_DIR;
|
|
17
|
+
for (const dir of tempDirs.splice(0)) {
|
|
18
|
+
rmSync(dir, { recursive: true, force: true });
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe("usage-tracker", () => {
|
|
23
|
+
it("createUsageTracker starts with zero counts", () => {
|
|
24
|
+
const tracker = createUsageTracker();
|
|
25
|
+
|
|
26
|
+
expect(tracker.getMainInputTokens()).toBe(0);
|
|
27
|
+
expect(tracker.getMainOutputTokens()).toBe(0);
|
|
28
|
+
expect(tracker.getMainCacheReadTokens()).toBe(0);
|
|
29
|
+
expect(tracker.getMainCacheWriteTokens()).toBe(0);
|
|
30
|
+
expect(tracker.getMainCost()).toBe(0);
|
|
31
|
+
expect(tracker.getTotalInputTokens()).toBe(0);
|
|
32
|
+
expect(tracker.getTotalOutputTokens()).toBe(0);
|
|
33
|
+
expect(tracker.getTotalCost()).toBe(0);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("loadFromSummary is idempotent and does not double-count subagents", () => {
|
|
37
|
+
const tracker = createUsageTracker();
|
|
38
|
+
const summary = {
|
|
39
|
+
subagents: [
|
|
40
|
+
{ description: "a", agentType: "Explore", modelId: "m", inputTokens: 100, outputTokens: 50, cost: 0.5, toolUses: 1, durationMs: 10 },
|
|
41
|
+
],
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
tracker.loadFromSummary(summary);
|
|
45
|
+
tracker.loadFromSummary(summary);
|
|
46
|
+
|
|
47
|
+
expect(tracker.getSubagentList()).toHaveLength(1);
|
|
48
|
+
const totals = tracker.getSubagentTotals();
|
|
49
|
+
expect(totals.inputTokens).toBe(100);
|
|
50
|
+
expect(totals.outputTokens).toBe(50);
|
|
51
|
+
expect(totals.cost).toBeCloseTo(0.5);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("recordTurn accumulates input output and cache tokens", () => {
|
|
55
|
+
const tracker = createUsageTracker();
|
|
56
|
+
|
|
57
|
+
tracker.recordTurn("openai/gpt-5", "openai", 10, 20, 3, 4, 0.1, true);
|
|
58
|
+
tracker.recordTurn("openai/gpt-5", "openai", 5, 7, 2, 1, 0.2, true);
|
|
59
|
+
|
|
60
|
+
expect(tracker.getMainInputTokens()).toBe(15);
|
|
61
|
+
expect(tracker.getMainOutputTokens()).toBe(27);
|
|
62
|
+
expect(tracker.getMainCacheReadTokens()).toBe(5);
|
|
63
|
+
expect(tracker.getMainCacheWriteTokens()).toBe(5);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("recordTurn accumulates cost", () => {
|
|
67
|
+
const tracker = createUsageTracker();
|
|
68
|
+
|
|
69
|
+
tracker.recordTurn("openai/gpt-5", "openai", 1, 1, 0, 0, 0.25, false);
|
|
70
|
+
tracker.recordTurn("openai/gpt-5", "openai", 1, 1, 0, 0, 0.5, false);
|
|
71
|
+
|
|
72
|
+
expect(tracker.getMainCost()).toBe(0.75);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("recordTurn handles non-finite numeric input as zero", () => {
|
|
76
|
+
const tracker = createUsageTracker();
|
|
77
|
+
|
|
78
|
+
tracker.recordTurn("openai/gpt-5", "openai", Number.NaN, Number.POSITIVE_INFINITY, 0, 0, Number.NaN, false);
|
|
79
|
+
|
|
80
|
+
expect(tracker.getMainInputTokens()).toBe(0);
|
|
81
|
+
expect(tracker.getMainOutputTokens()).toBe(0);
|
|
82
|
+
expect(tracker.getMainCost()).toBe(0);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("recordTurn tracks per-model usage", () => {
|
|
86
|
+
const tracker = createUsageTracker();
|
|
87
|
+
|
|
88
|
+
tracker.recordTurn("openai/gpt-5", "openai", 10, 5, 1, 0, 0.2, false);
|
|
89
|
+
tracker.recordTurn("anthropic/claude-opus-4-6", "anthropic", 7, 3, 0, 0, 0.3, false);
|
|
90
|
+
|
|
91
|
+
expect(tracker.getPerModelUsage()).toEqual({
|
|
92
|
+
"openai/gpt-5": {
|
|
93
|
+
inputTokens: 10,
|
|
94
|
+
outputTokens: 5,
|
|
95
|
+
cacheReadTokens: 1,
|
|
96
|
+
cacheWriteTokens: 0,
|
|
97
|
+
cacheSupported: false,
|
|
98
|
+
turns: 1,
|
|
99
|
+
},
|
|
100
|
+
"anthropic/claude-opus-4-6": {
|
|
101
|
+
inputTokens: 7,
|
|
102
|
+
outputTokens: 3,
|
|
103
|
+
cacheReadTokens: 0,
|
|
104
|
+
cacheWriteTokens: 0,
|
|
105
|
+
cacheSupported: false,
|
|
106
|
+
turns: 1,
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("recordTurn cacheSupported sets model cache support", () => {
|
|
112
|
+
const tracker = createUsageTracker();
|
|
113
|
+
|
|
114
|
+
tracker.recordTurn("openai/gpt-5", "openai", 1, 1, 0, 0, 0, false);
|
|
115
|
+
tracker.recordTurn("openai/gpt-5", "openai", 1, 1, 0, 0, 0, true);
|
|
116
|
+
|
|
117
|
+
expect(tracker.getPerModelUsage()["openai/gpt-5"]?.cacheSupported).toBe(true);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("recordTurn uses unknown-model key when model id is empty", () => {
|
|
121
|
+
const tracker = createUsageTracker();
|
|
122
|
+
|
|
123
|
+
tracker.recordTurn("", "openai", 3, 2, 0, 0, 0.1, false);
|
|
124
|
+
|
|
125
|
+
expect(tracker.getPerModelUsage()["unknown-model"]).toMatchObject({
|
|
126
|
+
inputTokens: 3,
|
|
127
|
+
outputTokens: 2,
|
|
128
|
+
turns: 1,
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("recordSubagentCompletion accumulates subagent tokens", () => {
|
|
133
|
+
const tracker = createUsageTracker();
|
|
134
|
+
|
|
135
|
+
tracker.recordSubagentCompletion({ input: 11, output: 4 } as any, 0.15);
|
|
136
|
+
tracker.recordSubagentCompletion({ input: 9, output: 6 }, 0.25);
|
|
137
|
+
|
|
138
|
+
expect(tracker.getSubagentTotals()).toEqual({
|
|
139
|
+
inputTokens: 20,
|
|
140
|
+
outputTokens: 10,
|
|
141
|
+
cost: 0.4,
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("recordSubagentCompletion defaults metadata fields when missing", () => {
|
|
146
|
+
const tracker = createUsageTracker();
|
|
147
|
+
|
|
148
|
+
tracker.recordSubagentCompletion({ input: 1, output: 2 } as any);
|
|
149
|
+
|
|
150
|
+
expect(tracker.getSubagentList()[0]).toMatchObject({
|
|
151
|
+
description: "unknown",
|
|
152
|
+
agentType: "unknown",
|
|
153
|
+
modelId: "unknown",
|
|
154
|
+
durationMs: 0,
|
|
155
|
+
toolUses: 0,
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("recordSubagentCompletion uses tokens.cost over fallback cost", () => {
|
|
160
|
+
const tracker = createUsageTracker();
|
|
161
|
+
|
|
162
|
+
tracker.recordSubagentCompletion({ input: 1, output: 1, cost: 0.6 } as any, 0.2);
|
|
163
|
+
|
|
164
|
+
expect(tracker.getSubagentTotals().cost).toBe(0.6);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("recordSubagentCompletion stores subagent metadata", () => {
|
|
168
|
+
const tracker = createUsageTracker();
|
|
169
|
+
|
|
170
|
+
tracker.recordSubagentCompletion(
|
|
171
|
+
{ input: 5, output: 2, cacheRead: 8, cacheWrite: 1 } as any,
|
|
172
|
+
0.2,
|
|
173
|
+
{ description: "Planner", agentType: "planner", modelId: "openai/gpt-5", durationMs: 900, toolUses: 3 },
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
expect(tracker.getSubagentList()).toEqual([
|
|
177
|
+
{
|
|
178
|
+
description: "Planner",
|
|
179
|
+
agentType: "planner",
|
|
180
|
+
modelId: "openai/gpt-5",
|
|
181
|
+
inputTokens: 5,
|
|
182
|
+
outputTokens: 2,
|
|
183
|
+
cacheReadTokens: 8,
|
|
184
|
+
cacheWriteTokens: 1,
|
|
185
|
+
cacheSupported: true,
|
|
186
|
+
cost: 0.2,
|
|
187
|
+
durationMs: 900,
|
|
188
|
+
toolUses: 3,
|
|
189
|
+
},
|
|
190
|
+
]);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("recordSubagentCompletion uses total when input and output are zero", () => {
|
|
194
|
+
const tracker = createUsageTracker();
|
|
195
|
+
|
|
196
|
+
tracker.recordSubagentCompletion({ total: 77, input: 0, output: 0 });
|
|
197
|
+
|
|
198
|
+
expect(tracker.getSubagentTotals().inputTokens).toBe(77);
|
|
199
|
+
expect(tracker.getSubagentTotals().outputTokens).toBe(0);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("getTotalInputTokens includes main and subagent input", () => {
|
|
203
|
+
const tracker = createUsageTracker();
|
|
204
|
+
|
|
205
|
+
tracker.recordTurn("openai/gpt-5", "openai", 10, 0, 0, 0, 0, false);
|
|
206
|
+
tracker.recordSubagentCompletion({ input: 4, output: 0 });
|
|
207
|
+
|
|
208
|
+
expect(tracker.getTotalInputTokens()).toBe(14);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("getTotalOutputTokens includes main and subagent output", () => {
|
|
212
|
+
const tracker = createUsageTracker();
|
|
213
|
+
|
|
214
|
+
tracker.recordTurn("openai/gpt-5", "openai", 0, 7, 0, 0, 0, false);
|
|
215
|
+
tracker.recordSubagentCompletion({ input: 0, output: 3 } as any);
|
|
216
|
+
|
|
217
|
+
expect(tracker.getTotalOutputTokens()).toBe(10);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("getTotalCost includes main and subagent cost", () => {
|
|
221
|
+
const tracker = createUsageTracker();
|
|
222
|
+
|
|
223
|
+
tracker.recordTurn("openai/gpt-5", "openai", 0, 0, 0, 0, 1.2, false);
|
|
224
|
+
tracker.recordSubagentCompletion({ input: 0, output: 0 }, 0.8);
|
|
225
|
+
|
|
226
|
+
expect(tracker.getTotalCost()).toBe(2);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("getCacheHitRate calculates ratio", () => {
|
|
230
|
+
const tracker = createUsageTracker();
|
|
231
|
+
|
|
232
|
+
tracker.recordTurn("openai/gpt-5", "openai", 30, 0, 10, 0, 0, true);
|
|
233
|
+
|
|
234
|
+
expect(tracker.getCacheHitRate()).toBeCloseTo(10 / 40);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("getCacheHitRate returns zero when denominator is zero", () => {
|
|
238
|
+
const tracker = createUsageTracker();
|
|
239
|
+
expect(tracker.getCacheHitRate()).toBe(0);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it("isCacheSupported true when any model or subagent has cache", () => {
|
|
243
|
+
const tracker = createUsageTracker();
|
|
244
|
+
expect(tracker.isCacheSupported()).toBe(false);
|
|
245
|
+
|
|
246
|
+
tracker.recordTurn("openai/gpt-5", "openai", 1, 1, 0, 0, 0, true);
|
|
247
|
+
expect(tracker.isCacheSupported()).toBe(true);
|
|
248
|
+
|
|
249
|
+
tracker.reset();
|
|
250
|
+
tracker.recordSubagentCompletion({ input: 1, output: 1, cacheRead: 1 } as any);
|
|
251
|
+
expect(tracker.isCacheSupported()).toBe(true);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it("getPerModelUsage returns a copy", () => {
|
|
255
|
+
const tracker = createUsageTracker();
|
|
256
|
+
tracker.recordTurn("openai/gpt-5", "openai", 2, 3, 0, 0, 0, false);
|
|
257
|
+
|
|
258
|
+
const usage = tracker.getPerModelUsage();
|
|
259
|
+
usage["openai/gpt-5"]!.inputTokens = 999;
|
|
260
|
+
|
|
261
|
+
expect(tracker.getPerModelUsage()["openai/gpt-5"]!.inputTokens).toBe(2);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it("toSummary returns complete summary", () => {
|
|
265
|
+
const tracker = createUsageTracker();
|
|
266
|
+
tracker.recordTurn("openai/gpt-5", "openai", 10, 7, 3, 2, 0.1234567, true);
|
|
267
|
+
tracker.recordSubagentCompletion({ input: 4, output: 1 } as any, 0.2, { description: "Explore", agentType: "explore", modelId: "google/gemini-3.1-pro" });
|
|
268
|
+
|
|
269
|
+
const summary = tracker.toSummary() as any;
|
|
270
|
+
|
|
271
|
+
expect(summary.startedAt).toBeTypeOf("string");
|
|
272
|
+
expect(summary.endedAt).toBeTypeOf("string");
|
|
273
|
+
expect(summary.totals).toEqual({
|
|
274
|
+
inputTokens: 10,
|
|
275
|
+
outputTokens: 7,
|
|
276
|
+
cacheReadTokens: 3,
|
|
277
|
+
cacheWriteTokens: 2,
|
|
278
|
+
cost: 0.123457,
|
|
279
|
+
turns: 1,
|
|
280
|
+
});
|
|
281
|
+
expect(summary.subagents).toHaveLength(1);
|
|
282
|
+
expect(summary.models["openai/gpt-5"]).toMatchObject({ inputTokens: 10, outputTokens: 7, turns: 1 });
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it("loadFromSummary restores state", () => {
|
|
286
|
+
const tracker = createUsageTracker();
|
|
287
|
+
tracker.loadFromSummary({
|
|
288
|
+
startedAt: "2026-01-01T00:00:00.000Z",
|
|
289
|
+
totals: {
|
|
290
|
+
inputTokens: 100,
|
|
291
|
+
outputTokens: 50,
|
|
292
|
+
cacheReadTokens: 20,
|
|
293
|
+
cacheWriteTokens: 5,
|
|
294
|
+
cost: 1.5,
|
|
295
|
+
turns: 3,
|
|
296
|
+
},
|
|
297
|
+
models: {
|
|
298
|
+
"openai/gpt-5": {
|
|
299
|
+
inputTokens: 100,
|
|
300
|
+
outputTokens: 50,
|
|
301
|
+
cacheReadTokens: 20,
|
|
302
|
+
cacheWriteTokens: 5,
|
|
303
|
+
cacheSupported: true,
|
|
304
|
+
turns: 3,
|
|
305
|
+
},
|
|
306
|
+
},
|
|
307
|
+
subagents: [
|
|
308
|
+
{
|
|
309
|
+
description: "Reviewer",
|
|
310
|
+
agentType: "reviewer",
|
|
311
|
+
modelId: "anthropic/claude-opus-4-6",
|
|
312
|
+
inputTokens: 7,
|
|
313
|
+
outputTokens: 2,
|
|
314
|
+
cacheReadTokens: 0,
|
|
315
|
+
cacheWriteTokens: 0,
|
|
316
|
+
cacheSupported: false,
|
|
317
|
+
cost: 0.4,
|
|
318
|
+
durationMs: 200,
|
|
319
|
+
toolUses: 2,
|
|
320
|
+
},
|
|
321
|
+
],
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
expect(tracker.getMainInputTokens()).toBe(100);
|
|
325
|
+
expect(tracker.getMainOutputTokens()).toBe(50);
|
|
326
|
+
expect(tracker.getMainCacheReadTokens()).toBe(20);
|
|
327
|
+
expect(tracker.getMainCacheWriteTokens()).toBe(5);
|
|
328
|
+
expect(tracker.getMainCost()).toBe(1.5);
|
|
329
|
+
expect(tracker.getSubagentTotals()).toEqual({ inputTokens: 7, outputTokens: 2, cost: 0.4 });
|
|
330
|
+
expect(tracker.getPerModelUsage()["openai/gpt-5"]?.turns).toBe(3);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it("loadFromSummary handles malformed summary entries", () => {
|
|
334
|
+
const tracker = createUsageTracker();
|
|
335
|
+
|
|
336
|
+
tracker.loadFromSummary({
|
|
337
|
+
startedAt: 123,
|
|
338
|
+
totals: "bad",
|
|
339
|
+
models: {
|
|
340
|
+
"openai/gpt-5": {
|
|
341
|
+
inputTokens: "x",
|
|
342
|
+
outputTokens: 2,
|
|
343
|
+
cacheSupported: "yes",
|
|
344
|
+
turns: "z",
|
|
345
|
+
},
|
|
346
|
+
},
|
|
347
|
+
subagents: [{ inputTokens: "bad", outputTokens: 4 }],
|
|
348
|
+
} as any);
|
|
349
|
+
|
|
350
|
+
expect(tracker.getMainInputTokens()).toBe(0);
|
|
351
|
+
expect(tracker.getMainOutputTokens()).toBe(0);
|
|
352
|
+
expect(tracker.getPerModelUsage()["openai/gpt-5"]).toMatchObject({
|
|
353
|
+
inputTokens: 0,
|
|
354
|
+
outputTokens: 2,
|
|
355
|
+
cacheSupported: false,
|
|
356
|
+
turns: 0,
|
|
357
|
+
});
|
|
358
|
+
expect(tracker.getSubagentList()[0]).toMatchObject({
|
|
359
|
+
inputTokens: 0,
|
|
360
|
+
outputTokens: 4,
|
|
361
|
+
});
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
it("loadFromSummary and toSummary preserve data round-trip", () => {
|
|
365
|
+
const source = createUsageTracker();
|
|
366
|
+
source.recordTurn("openai/gpt-5", "openai", 13, 8, 2, 1, 0.5, true);
|
|
367
|
+
source.recordSubagentCompletion({ input: 3, output: 1, cacheRead: 1 } as any, 0.1, {
|
|
368
|
+
description: "Librarian",
|
|
369
|
+
agentType: "librarian",
|
|
370
|
+
modelId: "google/gemini-3.1-pro",
|
|
371
|
+
durationMs: 321,
|
|
372
|
+
toolUses: 4,
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
const mid = source.toSummary() as Record<string, unknown>;
|
|
376
|
+
const restored = createUsageTracker();
|
|
377
|
+
restored.loadFromSummary(mid);
|
|
378
|
+
const roundTrip = restored.toSummary() as any;
|
|
379
|
+
|
|
380
|
+
expect(roundTrip.totals).toEqual((mid as any).totals);
|
|
381
|
+
expect(roundTrip.models).toEqual((mid as any).models);
|
|
382
|
+
expect(roundTrip.subagents).toEqual((mid as any).subagents);
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
it("reset clears all state", () => {
|
|
386
|
+
const tracker = createUsageTracker();
|
|
387
|
+
tracker.recordTurn("openai/gpt-5", "openai", 1, 2, 3, 4, 0.2, true);
|
|
388
|
+
tracker.recordSubagentCompletion({ input: 5, output: 6 } as any, 0.1);
|
|
389
|
+
|
|
390
|
+
tracker.reset();
|
|
391
|
+
|
|
392
|
+
expect(tracker.getMainInputTokens()).toBe(0);
|
|
393
|
+
expect(tracker.getMainOutputTokens()).toBe(0);
|
|
394
|
+
expect(tracker.getMainCacheReadTokens()).toBe(0);
|
|
395
|
+
expect(tracker.getMainCacheWriteTokens()).toBe(0);
|
|
396
|
+
expect(tracker.getMainCost()).toBe(0);
|
|
397
|
+
expect(tracker.getSubagentTotals()).toEqual({ inputTokens: 0, outputTokens: 0, cost: 0 });
|
|
398
|
+
expect(tracker.getPerModelUsage()).toEqual({});
|
|
399
|
+
expect(tracker.getSubagentList()).toEqual([]);
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
it("dumpUsageSummary and loadUsageSummary write and read JSON", () => {
|
|
403
|
+
const dir = makeTempDir();
|
|
404
|
+
process.env.PI_CODING_AGENT_DIR = dir;
|
|
405
|
+
const tracker = createUsageTracker();
|
|
406
|
+
tracker.recordTurn("openai/gpt-5", "openai", 11, 12, 1, 0, 0.9, true);
|
|
407
|
+
|
|
408
|
+
dumpUsageSummary(tracker, "session-1");
|
|
409
|
+
const loaded = loadUsageSummary("session-1") as any;
|
|
410
|
+
|
|
411
|
+
expect(loaded.sessionId).toBe("session-1");
|
|
412
|
+
expect(loaded.totals.inputTokens).toBe(11);
|
|
413
|
+
expect(loaded.totals.outputTokens).toBe(12);
|
|
414
|
+
expect(loaded.models["openai/gpt-5"].turns).toBe(1);
|
|
415
|
+
|
|
416
|
+
const filePath = join(dir, "extensions", "pp", "usage", "session-1.json");
|
|
417
|
+
const raw = readFileSync(filePath, "utf-8");
|
|
418
|
+
expect(raw.endsWith("\n")).toBe(true);
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
it("loadUsageSummary returns null for missing and invalid files", () => {
|
|
422
|
+
const dir = makeTempDir();
|
|
423
|
+
process.env.PI_CODING_AGENT_DIR = dir;
|
|
424
|
+
|
|
425
|
+
expect(loadUsageSummary("missing")).toBeNull();
|
|
426
|
+
|
|
427
|
+
const usageDir = join(dir, "extensions", "pp", "usage");
|
|
428
|
+
const invalidPath = join(usageDir, "broken.json");
|
|
429
|
+
rmSync(usageDir, { recursive: true, force: true });
|
|
430
|
+
mkdirSync(usageDir, { recursive: true });
|
|
431
|
+
writeFileSync(invalidPath, "{broken", "utf-8");
|
|
432
|
+
|
|
433
|
+
expect(loadUsageSummary("broken")).toBeNull();
|
|
434
|
+
});
|
|
435
|
+
});
|
|
@@ -7,11 +7,12 @@ export interface ModelUsage {
|
|
|
7
7
|
outputTokens: number;
|
|
8
8
|
cacheReadTokens: number;
|
|
9
9
|
cacheWriteTokens: number;
|
|
10
|
+
cacheSupported: boolean;
|
|
10
11
|
turns: number;
|
|
11
12
|
}
|
|
12
13
|
|
|
13
14
|
export interface UsageTracker {
|
|
14
|
-
recordTurn(modelId: string, provider: string, input: number, output: number, cacheRead: number, cacheWrite: number, cost: number): void;
|
|
15
|
+
recordTurn(modelId: string, provider: string, input: number, output: number, cacheRead: number, cacheWrite: number, cost: number, cacheSupported?: boolean): void;
|
|
15
16
|
recordSubagentCompletion(tokens: { input?: number; output?: number; total?: number }, cost?: number, meta?: { description?: string; agentType?: string; modelId?: string; durationMs?: number; toolUses?: number }): void;
|
|
16
17
|
loadFromSummary(summary: Record<string, unknown>): void;
|
|
17
18
|
getTotalInputTokens(): number;
|
|
@@ -25,6 +26,7 @@ export interface UsageTracker {
|
|
|
25
26
|
getMainCacheWriteTokens(): number;
|
|
26
27
|
getMainCost(): number;
|
|
27
28
|
getCacheHitRate(): number;
|
|
29
|
+
isCacheSupported(): boolean;
|
|
28
30
|
getPerModelUsage(): Record<string, ModelUsage>;
|
|
29
31
|
getSubagentTotals(): { inputTokens: number; outputTokens: number; cost: number };
|
|
30
32
|
getSubagentList(): SubagentUsage[];
|
|
@@ -40,6 +42,7 @@ export interface SubagentUsage {
|
|
|
40
42
|
outputTokens: number;
|
|
41
43
|
cacheReadTokens: number;
|
|
42
44
|
cacheWriteTokens: number;
|
|
45
|
+
cacheSupported: boolean;
|
|
43
46
|
cost: number;
|
|
44
47
|
durationMs: number;
|
|
45
48
|
toolUses: number;
|
|
@@ -96,7 +99,7 @@ export function createUsageTracker(): UsageTracker {
|
|
|
96
99
|
const state = createInitialState();
|
|
97
100
|
|
|
98
101
|
return {
|
|
99
|
-
recordTurn(modelId: string, _provider: string, input: number, output: number, cacheRead: number, cacheWrite: number, cost: number): void {
|
|
102
|
+
recordTurn(modelId: string, _provider: string, input: number, output: number, cacheRead: number, cacheWrite: number, cost: number, cacheSupported?: boolean): void {
|
|
100
103
|
const safeInput = toFiniteNumber(input);
|
|
101
104
|
const safeOutput = toFiniteNumber(output);
|
|
102
105
|
const safeCacheRead = toFiniteNumber(cacheRead);
|
|
@@ -116,6 +119,7 @@ export function createUsageTracker(): UsageTracker {
|
|
|
116
119
|
outputTokens: 0,
|
|
117
120
|
cacheReadTokens: 0,
|
|
118
121
|
cacheWriteTokens: 0,
|
|
122
|
+
cacheSupported: false,
|
|
119
123
|
turns: 0,
|
|
120
124
|
};
|
|
121
125
|
|
|
@@ -123,6 +127,7 @@ export function createUsageTracker(): UsageTracker {
|
|
|
123
127
|
usage.outputTokens += safeOutput;
|
|
124
128
|
usage.cacheReadTokens += safeCacheRead;
|
|
125
129
|
usage.cacheWriteTokens += safeCacheWrite;
|
|
130
|
+
if (cacheSupported) usage.cacheSupported = true;
|
|
126
131
|
usage.turns += 1;
|
|
127
132
|
state.models.set(key, usage);
|
|
128
133
|
},
|
|
@@ -145,6 +150,8 @@ export function createUsageTracker(): UsageTracker {
|
|
|
145
150
|
state.subagentOutputTokens += safeOutput;
|
|
146
151
|
state.subagentCost += safeCost;
|
|
147
152
|
|
|
153
|
+
const cacheSupported = typeof tokens.cacheRead === "number" || typeof tokens.cacheWrite === "number";
|
|
154
|
+
|
|
148
155
|
state.subagents.push({
|
|
149
156
|
description: meta?.description ?? "unknown",
|
|
150
157
|
agentType: meta?.agentType ?? "unknown",
|
|
@@ -153,6 +160,7 @@ export function createUsageTracker(): UsageTracker {
|
|
|
153
160
|
outputTokens: safeOutput,
|
|
154
161
|
cacheReadTokens: safeCacheRead,
|
|
155
162
|
cacheWriteTokens: safeCacheWrite,
|
|
163
|
+
cacheSupported,
|
|
156
164
|
cost: safeCost,
|
|
157
165
|
durationMs: toFiniteNumber(meta?.durationMs),
|
|
158
166
|
toolUses: toFiniteNumber(meta?.toolUses),
|
|
@@ -172,6 +180,10 @@ export function createUsageTracker(): UsageTracker {
|
|
|
172
180
|
state.totalTurns = toFiniteNumber(totals.turns);
|
|
173
181
|
}
|
|
174
182
|
if (Array.isArray(summary.subagents)) {
|
|
183
|
+
state.subagents = [];
|
|
184
|
+
state.subagentInputTokens = 0;
|
|
185
|
+
state.subagentOutputTokens = 0;
|
|
186
|
+
state.subagentCost = 0;
|
|
175
187
|
for (const sa of summary.subagents as Record<string, unknown>[]) {
|
|
176
188
|
const entry: SubagentUsage = {
|
|
177
189
|
description: typeof sa.description === "string" ? sa.description : "unknown",
|
|
@@ -181,6 +193,7 @@ export function createUsageTracker(): UsageTracker {
|
|
|
181
193
|
outputTokens: toFiniteNumber(sa.outputTokens),
|
|
182
194
|
cacheReadTokens: toFiniteNumber(sa.cacheReadTokens),
|
|
183
195
|
cacheWriteTokens: toFiniteNumber(sa.cacheWriteTokens),
|
|
196
|
+
cacheSupported: sa.cacheSupported === true,
|
|
184
197
|
cost: toFiniteNumber(sa.cost),
|
|
185
198
|
durationMs: toFiniteNumber(sa.durationMs),
|
|
186
199
|
toolUses: toFiniteNumber(sa.toolUses),
|
|
@@ -198,6 +211,7 @@ export function createUsageTracker(): UsageTracker {
|
|
|
198
211
|
outputTokens: toFiniteNumber(usage.outputTokens),
|
|
199
212
|
cacheReadTokens: toFiniteNumber(usage.cacheReadTokens),
|
|
200
213
|
cacheWriteTokens: toFiniteNumber(usage.cacheWriteTokens),
|
|
214
|
+
cacheSupported: (usage as any).cacheSupported === true,
|
|
201
215
|
turns: toFiniteNumber(usage.turns),
|
|
202
216
|
});
|
|
203
217
|
}
|
|
@@ -255,6 +269,13 @@ export function createUsageTracker(): UsageTracker {
|
|
|
255
269
|
return totalCacheRead / denominator;
|
|
256
270
|
},
|
|
257
271
|
|
|
272
|
+
isCacheSupported(): boolean {
|
|
273
|
+
for (const usage of state.models.values()) {
|
|
274
|
+
if (usage.cacheSupported) return true;
|
|
275
|
+
}
|
|
276
|
+
return state.subagents.some((sa) => sa.cacheSupported);
|
|
277
|
+
},
|
|
278
|
+
|
|
258
279
|
getPerModelUsage(): Record<string, ModelUsage> {
|
|
259
280
|
const out: Record<string, ModelUsage> = {};
|
|
260
281
|
for (const [model, usage] of state.models.entries()) {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
|
-
import { validatePlan, validateUserRequest } from "./validate-artifacts.js";
|
|
2
|
+
import { validateArtifact, validatePlan, validateResearch, validateUserRequest } from "./validate-artifacts.js";
|
|
3
3
|
|
|
4
4
|
describe("validateUserRequest", () => {
|
|
5
5
|
it("rejects placeholder distillation and constraints content", () => {
|
|
@@ -86,3 +86,85 @@ Ship minimal fix.
|
|
|
86
86
|
expect(result.errors).toContain("Section ## Scope is empty. Expected 2-4 lines summarizing scope and constraints.");
|
|
87
87
|
});
|
|
88
88
|
});
|
|
89
|
+
|
|
90
|
+
describe("validateResearch", () => {
|
|
91
|
+
it("accepts valid research content", () => {
|
|
92
|
+
const content = `## Affected Code
|
|
93
|
+
src/main.ts:run — entry point
|
|
94
|
+
|
|
95
|
+
## Architecture Context
|
|
96
|
+
- Main flow calls run and dispatches handlers
|
|
97
|
+
|
|
98
|
+
## Constraints & Edge Cases
|
|
99
|
+
- MUST: Keep behavior backward compatible
|
|
100
|
+
- RISK: Regression in startup path
|
|
101
|
+
|
|
102
|
+
## Open Questions
|
|
103
|
+
Need confirmation about deprecated flag
|
|
104
|
+
`;
|
|
105
|
+
|
|
106
|
+
const result = validateResearch(content);
|
|
107
|
+
|
|
108
|
+
expect(result.ok).toBe(true);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("fails when required sections are missing", () => {
|
|
112
|
+
const content = `## Affected Code
|
|
113
|
+
src/main.ts:run — entry point
|
|
114
|
+
|
|
115
|
+
## Constraints & Edge Cases
|
|
116
|
+
- MUST: Keep behavior backward compatible
|
|
117
|
+
`;
|
|
118
|
+
|
|
119
|
+
const result = validateResearch(content);
|
|
120
|
+
|
|
121
|
+
expect(result.ok).toBe(false);
|
|
122
|
+
if (result.ok) return;
|
|
123
|
+
expect(result.errors.some((error) => error.includes("Missing required section: ## Architecture Context"))).toBe(true);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("fails when affected code section is empty", () => {
|
|
127
|
+
const content = `## Affected Code
|
|
128
|
+
...
|
|
129
|
+
|
|
130
|
+
## Architecture Context
|
|
131
|
+
- Main flow calls run and dispatches handlers
|
|
132
|
+
|
|
133
|
+
## Constraints & Edge Cases
|
|
134
|
+
- MUST: Keep behavior backward compatible
|
|
135
|
+
`;
|
|
136
|
+
|
|
137
|
+
const result = validateResearch(content);
|
|
138
|
+
|
|
139
|
+
expect(result.ok).toBe(false);
|
|
140
|
+
if (result.ok) return;
|
|
141
|
+
expect(result.errors).toContain("Section ## Affected Code is empty. Expected non-empty content.");
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe("validateArtifact", () => {
|
|
146
|
+
it("accepts valid artifact with top-level title", () => {
|
|
147
|
+
const content = `# Risk Analysis
|
|
148
|
+
|
|
149
|
+
- First risk
|
|
150
|
+
- Second risk
|
|
151
|
+
`;
|
|
152
|
+
|
|
153
|
+
const result = validateArtifact(content);
|
|
154
|
+
|
|
155
|
+
expect(result.ok).toBe(true);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("fails when top-level title heading is missing", () => {
|
|
159
|
+
const content = `## Risk Analysis
|
|
160
|
+
|
|
161
|
+
- First risk
|
|
162
|
+
`;
|
|
163
|
+
|
|
164
|
+
const result = validateArtifact(content);
|
|
165
|
+
|
|
166
|
+
expect(result.ok).toBe(false);
|
|
167
|
+
if (result.ok) return;
|
|
168
|
+
expect(result.errors.some((error) => error.includes("Expected a top-level heading"))).toBe(true);
|
|
169
|
+
});
|
|
170
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ilya-lesikov/pi-pi",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Pi-Pi Coding Agent: based on Pi Coding Agent, but twice as good",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
|
@@ -52,6 +52,7 @@
|
|
|
52
52
|
"@pierre/diffs": "^1.2.0",
|
|
53
53
|
"effect": "^3.0.0",
|
|
54
54
|
"minimatch": "^10.0.0",
|
|
55
|
+
"pino": "^10.3.1",
|
|
55
56
|
"proper-lockfile": "^4.1.2",
|
|
56
57
|
"turndown": "^7.2.0",
|
|
57
58
|
"typebox": "^1.0.0"
|