pi-pair-programmer 0.1.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/LICENSE +21 -0
- package/README.md +65 -0
- package/package.json +72 -0
- package/src/change-evidence.ts +840 -0
- package/src/finding-admission.ts +167 -0
- package/src/index.ts +952 -0
- package/src/logger.ts +396 -0
- package/src/model-usage.ts +285 -0
- package/src/pair-stats.ts +372 -0
- package/src/review-runner.ts +301 -0
- package/src/review-store.ts +268 -0
- package/src/reviewers.ts +165 -0
- package/src/stats-view.ts +172 -0
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
const FindingSchema = z.object({
|
|
4
|
+
id: z.string(),
|
|
5
|
+
duplicateKey: z.string().optional(),
|
|
6
|
+
reviewer: z.string(),
|
|
7
|
+
file: z.string(),
|
|
8
|
+
revision: z.string(),
|
|
9
|
+
line: z.number(),
|
|
10
|
+
title: z.string(),
|
|
11
|
+
evidence: z.string(),
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
const VerdictSchema = z.enum(["accept", "reject"]);
|
|
15
|
+
const EventSchema = z.discriminatedUnion("action", [
|
|
16
|
+
z.object({ action: z.literal("add"), finding: FindingSchema }),
|
|
17
|
+
z.object({ action: z.literal("deliver"), ids: z.array(z.string()) }),
|
|
18
|
+
z.object({
|
|
19
|
+
action: z.literal("decide"),
|
|
20
|
+
id: z.string(),
|
|
21
|
+
verdict: VerdictSchema,
|
|
22
|
+
reason: z.string().refine((reason) => reason.trim().length > 0),
|
|
23
|
+
}),
|
|
24
|
+
z.object({ action: z.literal("discard"), ids: z.array(z.string()) }),
|
|
25
|
+
z.object({ action: z.literal("enabled"), enabled: z.boolean() }),
|
|
26
|
+
z.object({ action: z.literal("clear") }),
|
|
27
|
+
]);
|
|
28
|
+
const EntrySchema = z.discriminatedUnion("type", [
|
|
29
|
+
z.object({
|
|
30
|
+
type: z.literal("custom"),
|
|
31
|
+
customType: z.literal("pair-programmer"),
|
|
32
|
+
data: EventSchema,
|
|
33
|
+
}),
|
|
34
|
+
z.object({ type: z.literal("reset_boundary") }),
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
export type Finding = z.infer<typeof FindingSchema>;
|
|
38
|
+
export type Verdict = z.infer<typeof VerdictSchema>;
|
|
39
|
+
type Event = z.infer<typeof EventSchema>;
|
|
40
|
+
|
|
41
|
+
export interface StoredFinding {
|
|
42
|
+
finding: Finding;
|
|
43
|
+
verdict?: Verdict;
|
|
44
|
+
reason?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface FindingState extends StoredFinding {
|
|
48
|
+
delivered: boolean;
|
|
49
|
+
discarded: boolean;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export class ReviewStore {
|
|
53
|
+
private readonly findings = new Map<string, FindingState>();
|
|
54
|
+
private readonly append: (data: unknown) => void;
|
|
55
|
+
private active = true;
|
|
56
|
+
|
|
57
|
+
constructor(
|
|
58
|
+
append: (data: unknown) => void,
|
|
59
|
+
branchEntries: readonly unknown[] = [],
|
|
60
|
+
) {
|
|
61
|
+
this.append = append;
|
|
62
|
+
for (const entry of branchEntries) {
|
|
63
|
+
const parsed = EntrySchema.safeParse(entry);
|
|
64
|
+
if (!parsed.success) continue;
|
|
65
|
+
if (parsed.data.type === "reset_boundary") this.findings.clear();
|
|
66
|
+
else this.apply(parsed.data.data);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
get enabled(): boolean {
|
|
71
|
+
return this.active;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
summary(): {
|
|
75
|
+
pending: number;
|
|
76
|
+
outstanding: number;
|
|
77
|
+
accepted: number;
|
|
78
|
+
rejected: number;
|
|
79
|
+
discarded: number;
|
|
80
|
+
} {
|
|
81
|
+
const counts = {
|
|
82
|
+
pending: 0,
|
|
83
|
+
outstanding: 0,
|
|
84
|
+
accepted: 0,
|
|
85
|
+
rejected: 0,
|
|
86
|
+
discarded: 0,
|
|
87
|
+
};
|
|
88
|
+
for (const state of this.findings.values()) {
|
|
89
|
+
if (state.verdict === "accept") counts.accepted += 1;
|
|
90
|
+
else if (state.verdict === "reject") counts.rejected += 1;
|
|
91
|
+
else if (state.discarded) counts.discarded += 1;
|
|
92
|
+
else if (state.delivered) counts.outstanding += 1;
|
|
93
|
+
else counts.pending += 1;
|
|
94
|
+
}
|
|
95
|
+
return counts;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
clear(): void {
|
|
99
|
+
this.record({ action: "clear" });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
setEnabled(enabled: boolean): void {
|
|
103
|
+
if (this.active === enabled) return;
|
|
104
|
+
if (!enabled) {
|
|
105
|
+
const ids: string[] = [];
|
|
106
|
+
for (const state of this.findings.values()) {
|
|
107
|
+
if (!state.delivered && !state.discarded) ids.push(state.finding.id);
|
|
108
|
+
}
|
|
109
|
+
if (ids.length > 0) this.record({ action: "discard", ids });
|
|
110
|
+
}
|
|
111
|
+
this.record({ action: "enabled", enabled });
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
history(file: string): readonly StoredFinding[] {
|
|
115
|
+
const result: StoredFinding[] = [];
|
|
116
|
+
for (const state of this.findings.values()) {
|
|
117
|
+
if (state.finding.file !== file) continue;
|
|
118
|
+
result.push({
|
|
119
|
+
finding: { ...state.finding },
|
|
120
|
+
...(state.verdict === undefined ? {} : { verdict: state.verdict }),
|
|
121
|
+
...(state.reason === undefined ? {} : { reason: state.reason }),
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
return result;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
add(finding: Finding): boolean {
|
|
128
|
+
if (!this.active || this.findings.has(finding.id)) return false;
|
|
129
|
+
this.record({ action: "add", finding: { ...finding } });
|
|
130
|
+
return true;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
ready(): readonly Finding[] {
|
|
134
|
+
if (!this.active) return [];
|
|
135
|
+
const result: Finding[] = [];
|
|
136
|
+
for (const state of this.findings.values()) {
|
|
137
|
+
if (!state.delivered && !state.discarded)
|
|
138
|
+
result.push({ ...state.finding });
|
|
139
|
+
}
|
|
140
|
+
return result;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
deliver(ids: readonly string[]): void {
|
|
144
|
+
const eligible = [...new Set(ids)].filter((id) => {
|
|
145
|
+
const state = this.findings.get(id);
|
|
146
|
+
return (
|
|
147
|
+
this.active &&
|
|
148
|
+
state !== undefined &&
|
|
149
|
+
!state.delivered &&
|
|
150
|
+
!state.discarded
|
|
151
|
+
);
|
|
152
|
+
});
|
|
153
|
+
if (eligible.length > 0) this.record({ action: "deliver", ids: eligible });
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
outstanding(): readonly Finding[] {
|
|
157
|
+
const result: Finding[] = [];
|
|
158
|
+
for (const state of this.findings.values()) {
|
|
159
|
+
if (state.delivered && state.verdict === undefined) {
|
|
160
|
+
result.push({ ...state.finding });
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return result;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
deliveredFinding(id: string): Finding | undefined {
|
|
167
|
+
const state = this.findings.get(id);
|
|
168
|
+
return state?.delivered === true && state.verdict === undefined
|
|
169
|
+
? { ...state.finding }
|
|
170
|
+
: undefined;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
decide(id: string, verdict: Verdict, reason: string): boolean {
|
|
174
|
+
const state = this.findings.get(id);
|
|
175
|
+
if (
|
|
176
|
+
state?.delivered !== true ||
|
|
177
|
+
state.verdict !== undefined ||
|
|
178
|
+
reason.trim().length === 0
|
|
179
|
+
) {
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
this.record({ action: "decide", id, verdict, reason });
|
|
183
|
+
return true;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
discardStale(file: string, currentRevision: string): void {
|
|
187
|
+
const ids: string[] = [];
|
|
188
|
+
for (const state of this.findings.values()) {
|
|
189
|
+
if (
|
|
190
|
+
state.finding.file === file &&
|
|
191
|
+
state.finding.revision !== currentRevision &&
|
|
192
|
+
!state.delivered &&
|
|
193
|
+
!state.discarded
|
|
194
|
+
) {
|
|
195
|
+
ids.push(state.finding.id);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
if (ids.length > 0) this.record({ action: "discard", ids });
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
private record(event: Event): void {
|
|
202
|
+
this.append(event);
|
|
203
|
+
this.apply(event);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
private apply(event: Event): void {
|
|
207
|
+
switch (event.action) {
|
|
208
|
+
case "add":
|
|
209
|
+
this.applyAdd(event.finding);
|
|
210
|
+
break;
|
|
211
|
+
case "deliver":
|
|
212
|
+
this.applyDelivery(event.ids);
|
|
213
|
+
break;
|
|
214
|
+
case "decide":
|
|
215
|
+
this.applyDecision(event.id, event.verdict, event.reason);
|
|
216
|
+
break;
|
|
217
|
+
case "discard":
|
|
218
|
+
this.applyDiscard(event.ids);
|
|
219
|
+
break;
|
|
220
|
+
case "enabled":
|
|
221
|
+
this.applyEnabled(event.enabled);
|
|
222
|
+
break;
|
|
223
|
+
case "clear":
|
|
224
|
+
this.findings.clear();
|
|
225
|
+
break;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
private applyAdd(finding: Finding): void {
|
|
230
|
+
if (!this.active || this.findings.has(finding.id)) return;
|
|
231
|
+
this.findings.set(finding.id, {
|
|
232
|
+
finding: { ...finding },
|
|
233
|
+
delivered: false,
|
|
234
|
+
discarded: false,
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
private applyDelivery(ids: readonly string[]): void {
|
|
239
|
+
if (!this.active) return;
|
|
240
|
+
for (const id of ids) {
|
|
241
|
+
const state = this.findings.get(id);
|
|
242
|
+
if (state !== undefined && !state.discarded) state.delivered = true;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
private applyDecision(id: string, verdict: Verdict, reason: string): void {
|
|
247
|
+
const state = this.findings.get(id);
|
|
248
|
+
if (state?.delivered !== true || state.verdict !== undefined) return;
|
|
249
|
+
state.verdict = verdict;
|
|
250
|
+
state.reason = reason;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
private applyDiscard(ids: readonly string[]): void {
|
|
254
|
+
for (const id of ids) {
|
|
255
|
+
const state = this.findings.get(id);
|
|
256
|
+
if (state !== undefined && !state.delivered) state.discarded = true;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
private applyEnabled(enabled: boolean): void {
|
|
261
|
+
this.active = enabled;
|
|
262
|
+
if (!enabled) {
|
|
263
|
+
for (const state of this.findings.values()) {
|
|
264
|
+
if (!state.delivered) state.discarded = true;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
package/src/reviewers.ts
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { minimatch } from "minimatch";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
|
|
7
|
+
export interface ReviewerConfig {
|
|
8
|
+
model: string;
|
|
9
|
+
prompt: string;
|
|
10
|
+
include: readonly string[];
|
|
11
|
+
exclude: readonly string[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const DEFAULT_REVIEWERS: readonly ReviewerConfig[] = [
|
|
15
|
+
{
|
|
16
|
+
model: "current",
|
|
17
|
+
prompt: "Does it add entropy ?",
|
|
18
|
+
include: ["**/*"],
|
|
19
|
+
exclude: [],
|
|
20
|
+
},
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
const matchOptions = { dot: true, nonegate: true, nocomment: true } as const;
|
|
24
|
+
|
|
25
|
+
function validGlobSyntax(pattern: string): boolean {
|
|
26
|
+
const closing: string[] = [];
|
|
27
|
+
for (let index = 0; index < pattern.length; index += 1) {
|
|
28
|
+
const character = pattern.charAt(index);
|
|
29
|
+
if (character === "[" || character === "{") {
|
|
30
|
+
closing.push(character === "[" ? "]" : "}");
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
if (
|
|
34
|
+
character === "(" &&
|
|
35
|
+
["@", "?", "+", "*", "!"].includes(pattern.charAt(index - 1))
|
|
36
|
+
) {
|
|
37
|
+
closing.push(")");
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
if (
|
|
41
|
+
(character === "]" ||
|
|
42
|
+
character === "}" ||
|
|
43
|
+
(character === ")" && closing.at(-1) === ")")) &&
|
|
44
|
+
closing.pop() !== character
|
|
45
|
+
) {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return closing.length === 0;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function validPattern(value: string): boolean {
|
|
53
|
+
return (
|
|
54
|
+
value.trim().length > 0 &&
|
|
55
|
+
value.trim() === value &&
|
|
56
|
+
!value.includes("\\") &&
|
|
57
|
+
!/\p{Cc}/u.test(value) &&
|
|
58
|
+
!path.posix.isAbsolute(value) &&
|
|
59
|
+
!path.win32.isAbsolute(value) &&
|
|
60
|
+
!/^[a-z]:/iu.test(value) &&
|
|
61
|
+
value
|
|
62
|
+
.split("/")
|
|
63
|
+
.every((part) => part !== "" && part !== "." && part !== "..") &&
|
|
64
|
+
validGlobSyntax(value)
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const PatternSchema = z.string().refine(validPattern, {
|
|
69
|
+
message:
|
|
70
|
+
"must be a nonempty project-relative POSIX glob with balanced syntax",
|
|
71
|
+
});
|
|
72
|
+
const ReviewerSchema = z.strictObject({
|
|
73
|
+
model: z.string().refine((value) => value.trim().length > 0),
|
|
74
|
+
prompt: z.string().refine((value) => value.trim().length > 0),
|
|
75
|
+
include: z.array(PatternSchema).min(1),
|
|
76
|
+
exclude: z.array(PatternSchema),
|
|
77
|
+
});
|
|
78
|
+
const ConfigSchema = z.strictObject({ reviewers: z.array(ReviewerSchema) });
|
|
79
|
+
const MissingConfigErrorSchema = z.object({ code: z.literal("ENOENT") });
|
|
80
|
+
|
|
81
|
+
export async function loadReviewers(
|
|
82
|
+
cwd: string,
|
|
83
|
+
): Promise<readonly ReviewerConfig[]> {
|
|
84
|
+
const file = path.join(cwd, "pair-programmer.reviewers.json");
|
|
85
|
+
let contents: string;
|
|
86
|
+
try {
|
|
87
|
+
contents = await readFile(file, "utf8");
|
|
88
|
+
} catch (error) {
|
|
89
|
+
if (MissingConfigErrorSchema.safeParse(error).success) {
|
|
90
|
+
return DEFAULT_REVIEWERS;
|
|
91
|
+
}
|
|
92
|
+
throw new Error(`${file}: could not read reviewer configuration`, {
|
|
93
|
+
cause: error,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
let parsed: unknown;
|
|
97
|
+
try {
|
|
98
|
+
parsed = JSON.parse(contents) as unknown;
|
|
99
|
+
} catch (error) {
|
|
100
|
+
throw new Error(`${file}: invalid JSON`, { cause: error });
|
|
101
|
+
}
|
|
102
|
+
const result = ConfigSchema.safeParse(parsed);
|
|
103
|
+
if (!result.success) {
|
|
104
|
+
const descriptions = result.error.issues.map((issue) => {
|
|
105
|
+
const segments = [
|
|
106
|
+
...issue.path,
|
|
107
|
+
...(issue.code === "unrecognized_keys" ? [issue.keys[0]] : []),
|
|
108
|
+
];
|
|
109
|
+
const location =
|
|
110
|
+
segments.length === 0
|
|
111
|
+
? "configuration"
|
|
112
|
+
: segments.reduce<string>((text, segment) => {
|
|
113
|
+
if (typeof segment === "number")
|
|
114
|
+
return `${text}[${String(segment)}]`;
|
|
115
|
+
const separator = text.length === 0 ? "" : ".";
|
|
116
|
+
return `${text}${separator}${String(segment)}`;
|
|
117
|
+
}, "");
|
|
118
|
+
return `${location} ${issue.message}`;
|
|
119
|
+
});
|
|
120
|
+
throw new Error(`${file}: ${descriptions.join("; ")}`);
|
|
121
|
+
}
|
|
122
|
+
return result.data.reviewers;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function matchingReviewers(
|
|
126
|
+
file: string,
|
|
127
|
+
reviewers: readonly ReviewerConfig[],
|
|
128
|
+
): ReviewerConfig[] {
|
|
129
|
+
if (
|
|
130
|
+
file.length === 0 ||
|
|
131
|
+
path.posix.isAbsolute(file) ||
|
|
132
|
+
path.win32.isAbsolute(file) ||
|
|
133
|
+
/^[a-z]:/iu.test(file) ||
|
|
134
|
+
/\p{Cc}/u.test(file)
|
|
135
|
+
) {
|
|
136
|
+
return [];
|
|
137
|
+
}
|
|
138
|
+
const normalized = path.posix.normalize(file.replaceAll("\\", "/"));
|
|
139
|
+
return normalized === "." ||
|
|
140
|
+
normalized === ".." ||
|
|
141
|
+
normalized.startsWith("../")
|
|
142
|
+
? []
|
|
143
|
+
: reviewers.filter(
|
|
144
|
+
(reviewer) =>
|
|
145
|
+
reviewer.include.some((pattern) =>
|
|
146
|
+
minimatch(normalized, pattern, matchOptions),
|
|
147
|
+
) &&
|
|
148
|
+
reviewer.exclude.every(
|
|
149
|
+
(pattern) => !minimatch(normalized, pattern, matchOptions),
|
|
150
|
+
),
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function reviewerKey(reviewer: ReviewerConfig): string {
|
|
155
|
+
return createHash("sha256")
|
|
156
|
+
.update(
|
|
157
|
+
JSON.stringify([
|
|
158
|
+
reviewer.model,
|
|
159
|
+
reviewer.prompt,
|
|
160
|
+
reviewer.include.toSorted((left, right) => left.localeCompare(right)),
|
|
161
|
+
reviewer.exclude.toSorted((left, right) => left.localeCompare(right)),
|
|
162
|
+
]),
|
|
163
|
+
)
|
|
164
|
+
.digest("hex");
|
|
165
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { MeasuredTotal, StatsSnapshot } from "./pair-stats.js";
|
|
3
|
+
import type { ReviewStore } from "./review-store.js";
|
|
4
|
+
|
|
5
|
+
function measured(
|
|
6
|
+
total: MeasuredTotal,
|
|
7
|
+
calls: number,
|
|
8
|
+
cost = false,
|
|
9
|
+
accountingComplete = true,
|
|
10
|
+
): string {
|
|
11
|
+
if (total.measured === 0) return "unavailable";
|
|
12
|
+
const value = cost ? `$${total.value.toFixed(6)}` : String(total.value);
|
|
13
|
+
const coverage =
|
|
14
|
+
accountingComplete && total.measured === calls ? "complete" : "partial";
|
|
15
|
+
return `${value} (${coverage} ${String(total.measured)}/${String(calls)})`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function terminalLabel(value: string): string {
|
|
19
|
+
let label = "";
|
|
20
|
+
for (const character of value)
|
|
21
|
+
label += character >= " " && character <= "~" ? character : "?";
|
|
22
|
+
return label;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function statsLines(
|
|
26
|
+
stats: StatsSnapshot,
|
|
27
|
+
store: Pick<ReviewStore, "enabled" | "summary">,
|
|
28
|
+
): string[] {
|
|
29
|
+
const review = stats.reviews;
|
|
30
|
+
const findings = store.summary();
|
|
31
|
+
const lines = [
|
|
32
|
+
`Pair Programmer statistics - ${store.enabled ? "on" : "off"}`,
|
|
33
|
+
"Snapshot on open; close and run /pair-stats again to refresh.",
|
|
34
|
+
"",
|
|
35
|
+
"Review activity: incurred in this session, across all branches",
|
|
36
|
+
`Running ${String(review.running)} | completed ${String(review.success)} | failed ${String(review.failed)}`,
|
|
37
|
+
`Cancelled ${String(review.cancelled)} | timed out ${String(review.timeout)} | obsolete ${String(review.obsolete)}`,
|
|
38
|
+
`Interrupted on reload ${String(review.interrupted)} (final outcome unknown)`,
|
|
39
|
+
stats.finished === 0
|
|
40
|
+
? "Review latency: unavailable (no finalized jobs)"
|
|
41
|
+
: `Review latency: average ${String(Math.round(stats.durationMs / stats.finished))} ms | max ${String(Math.round(stats.maxDurationMs))} ms`,
|
|
42
|
+
"",
|
|
43
|
+
"Findings: selected branch only (authoritative review state)",
|
|
44
|
+
`Pending ${String(findings.pending)} | awaiting decision ${String(findings.outstanding)}`,
|
|
45
|
+
`Accepted ${String(findings.accepted)} | rejected ${String(findings.rejected)} | discarded ${String(findings.discarded)}`,
|
|
46
|
+
"",
|
|
47
|
+
"Extension model usage: separate from main-agent host totals",
|
|
48
|
+
...(stats.incompleteJobs === 0
|
|
49
|
+
? []
|
|
50
|
+
: [
|
|
51
|
+
`INCOMPLETE accounting: ${String(stats.incompleteJobs)} review job(s) may have unreported calls or usage.`,
|
|
52
|
+
]),
|
|
53
|
+
"Per-field measured subtotals of recorded calls; missing usage is not zero.",
|
|
54
|
+
"Cost is an estimate in USD, not a billing total.",
|
|
55
|
+
];
|
|
56
|
+
if (stats.persistenceFailures > 0)
|
|
57
|
+
lines.push(
|
|
58
|
+
`Storage unavailable for ${String(stats.persistenceFailures)} record(s); current process snapshot retained.`,
|
|
59
|
+
);
|
|
60
|
+
if (stats.pendingWrites > 0)
|
|
61
|
+
lines.push(
|
|
62
|
+
`${String(stats.pendingWrites)} unsaved record(s); reopen this session before exiting to retry persistence.`,
|
|
63
|
+
);
|
|
64
|
+
if (stats.usage.length === 0)
|
|
65
|
+
lines.push("No finalized extension model calls recorded.");
|
|
66
|
+
for (const group of stats.usage) {
|
|
67
|
+
const prefix = group.provider === undefined ? "" : `${group.provider}/`;
|
|
68
|
+
const model =
|
|
69
|
+
group.model === undefined
|
|
70
|
+
? `unobserved (requested ${group.requestedModel})`
|
|
71
|
+
: `${prefix}${group.model}`;
|
|
72
|
+
lines.push(
|
|
73
|
+
"",
|
|
74
|
+
`${group.stage}: ${terminalLabel(model)}`,
|
|
75
|
+
`Calls ${String(group.calls)} | success ${String(group.outcomes.success)} | failed ${String(group.outcomes.failed)} | cancelled ${String(group.outcomes.cancelled)} | timed out ${String(group.outcomes.timeout)}`,
|
|
76
|
+
`Call latency average ${String(Math.round(group.durationMs / group.calls))} ms`,
|
|
77
|
+
`Input ${measured(group.inputTokens, group.calls, false, stats.incompleteJobs === 0)} | output ${measured(group.outputTokens, group.calls, false, stats.incompleteJobs === 0)}`,
|
|
78
|
+
`Cache read ${measured(group.cacheReadTokens, group.calls, false, stats.incompleteJobs === 0)} | cache write ${measured(group.cacheWriteTokens, group.calls, false, stats.incompleteJobs === 0)}`,
|
|
79
|
+
`Total tokens ${measured(group.totalTokens, group.calls, false, stats.incompleteJobs === 0)} | estimated cost ${measured(group.costUsd, group.calls, true, stats.incompleteJobs === 0)}`,
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
return lines;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export class StatsView {
|
|
86
|
+
private closeCurrent: (() => void) | undefined;
|
|
87
|
+
|
|
88
|
+
close(): void {
|
|
89
|
+
this.closeCurrent?.();
|
|
90
|
+
this.closeCurrent = undefined;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async open(ctx: ExtensionContext, lines: readonly string[]): Promise<void> {
|
|
94
|
+
this.close();
|
|
95
|
+
const unavailable = "/pair-stats requires an interactive terminal (TUI).";
|
|
96
|
+
if (!ctx.hasUI) throw new Error(unavailable);
|
|
97
|
+
|
|
98
|
+
const mode = (ctx as Partial<ExtensionContext>).mode;
|
|
99
|
+
if (mode !== undefined && mode !== "tui") {
|
|
100
|
+
ctx.ui.notify(unavailable, "warning");
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
const state = { mounted: false };
|
|
104
|
+
let close: (() => void) | undefined;
|
|
105
|
+
try {
|
|
106
|
+
await ctx.ui.custom<undefined>(
|
|
107
|
+
(tui, _theme, keys, done) => {
|
|
108
|
+
state.mounted = true;
|
|
109
|
+
let offset = 0;
|
|
110
|
+
let pageSize = 1;
|
|
111
|
+
let disposed = false;
|
|
112
|
+
close = (): void => {
|
|
113
|
+
if (disposed) return;
|
|
114
|
+
disposed = true;
|
|
115
|
+
done(undefined);
|
|
116
|
+
};
|
|
117
|
+
this.closeCurrent = close;
|
|
118
|
+
return {
|
|
119
|
+
render(width: number): string[] {
|
|
120
|
+
const wrapped: string[] = [];
|
|
121
|
+
const columns = Math.max(1, width);
|
|
122
|
+
for (const line of lines) {
|
|
123
|
+
if (line.length === 0) wrapped.push("");
|
|
124
|
+
for (let index = 0; index < line.length; index += columns)
|
|
125
|
+
wrapped.push(line.slice(index, index + columns));
|
|
126
|
+
}
|
|
127
|
+
pageSize = Math.max(1, tui.terminal.rows - 4);
|
|
128
|
+
offset = Math.min(offset, Math.max(0, wrapped.length - pageSize));
|
|
129
|
+
return [
|
|
130
|
+
...wrapped.slice(offset, offset + pageSize),
|
|
131
|
+
"",
|
|
132
|
+
"Up/Down/PgUp/PgDn scroll | Enter/Esc/q close".slice(
|
|
133
|
+
0,
|
|
134
|
+
columns,
|
|
135
|
+
),
|
|
136
|
+
];
|
|
137
|
+
},
|
|
138
|
+
handleInput(data: string): void {
|
|
139
|
+
if (
|
|
140
|
+
data === "q" ||
|
|
141
|
+
keys.matches(data, "tui.select.confirm") ||
|
|
142
|
+
keys.matches(data, "tui.select.cancel") ||
|
|
143
|
+
keys.matches(data, "app.clear")
|
|
144
|
+
)
|
|
145
|
+
close?.();
|
|
146
|
+
else {
|
|
147
|
+
if (keys.matches(data, "tui.select.up"))
|
|
148
|
+
offset = Math.max(0, offset - 1);
|
|
149
|
+
else if (keys.matches(data, "tui.select.down")) offset += 1;
|
|
150
|
+
else if (keys.matches(data, "tui.select.pageUp"))
|
|
151
|
+
offset = Math.max(0, offset - pageSize);
|
|
152
|
+
else if (keys.matches(data, "tui.select.pageDown"))
|
|
153
|
+
offset += pageSize;
|
|
154
|
+
tui.requestRender();
|
|
155
|
+
}
|
|
156
|
+
},
|
|
157
|
+
invalidate(): void {
|
|
158
|
+
return;
|
|
159
|
+
},
|
|
160
|
+
dispose(): void {
|
|
161
|
+
disposed = true;
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
},
|
|
165
|
+
{ overlay: true },
|
|
166
|
+
);
|
|
167
|
+
if (!state.mounted) throw new Error(unavailable);
|
|
168
|
+
} finally {
|
|
169
|
+
if (this.closeCurrent === close) this.closeCurrent = undefined;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|