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
package/src/logger.ts
ADDED
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { chmod, mkdir, open, readdir, unlink } from "node:fs/promises";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { pino } from "pino";
|
|
6
|
+
|
|
7
|
+
export type PairLogEvent =
|
|
8
|
+
| "session.start"
|
|
9
|
+
| "session.stop"
|
|
10
|
+
| "extension.shutdown"
|
|
11
|
+
| "extension.toggle"
|
|
12
|
+
| "extension.persistence_failed"
|
|
13
|
+
| "review.scheduled"
|
|
14
|
+
| "review.started"
|
|
15
|
+
| "review.finished"
|
|
16
|
+
| "review.skipped"
|
|
17
|
+
| "model.finished"
|
|
18
|
+
| "finding.filtered"
|
|
19
|
+
| "finding.admission"
|
|
20
|
+
| "finding.verdict"
|
|
21
|
+
| "delivery.sent"
|
|
22
|
+
| "delivery.wake";
|
|
23
|
+
|
|
24
|
+
export interface PairLogFields {
|
|
25
|
+
sessionId?: string;
|
|
26
|
+
jobId?: string;
|
|
27
|
+
reviewerId?: string;
|
|
28
|
+
modelId?: string;
|
|
29
|
+
stage?: "review" | "attribution" | "dedup";
|
|
30
|
+
outcome?: keyof typeof outcomes;
|
|
31
|
+
reasonCode?: keyof typeof reasonCodes;
|
|
32
|
+
count?: number;
|
|
33
|
+
durationMs?: number;
|
|
34
|
+
inputTokens?: number;
|
|
35
|
+
outputTokens?: number;
|
|
36
|
+
cacheReadTokens?: number;
|
|
37
|
+
cacheWriteTokens?: number;
|
|
38
|
+
totalTokens?: number;
|
|
39
|
+
costUsd?: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface PairLogger {
|
|
43
|
+
log(event: PairLogEvent, fields?: PairLogFields): void;
|
|
44
|
+
close(): Promise<void>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const events: Record<PairLogEvent, true> = {
|
|
48
|
+
"session.start": true,
|
|
49
|
+
"session.stop": true,
|
|
50
|
+
"extension.shutdown": true,
|
|
51
|
+
"extension.toggle": true,
|
|
52
|
+
"extension.persistence_failed": true,
|
|
53
|
+
"review.scheduled": true,
|
|
54
|
+
"review.started": true,
|
|
55
|
+
"review.finished": true,
|
|
56
|
+
"review.skipped": true,
|
|
57
|
+
"model.finished": true,
|
|
58
|
+
"finding.filtered": true,
|
|
59
|
+
"finding.admission": true,
|
|
60
|
+
"finding.verdict": true,
|
|
61
|
+
"delivery.sent": true,
|
|
62
|
+
"delivery.wake": true,
|
|
63
|
+
};
|
|
64
|
+
const numericFields: Record<string, true> = {
|
|
65
|
+
count: true,
|
|
66
|
+
durationMs: true,
|
|
67
|
+
inputTokens: true,
|
|
68
|
+
outputTokens: true,
|
|
69
|
+
cacheReadTokens: true,
|
|
70
|
+
cacheWriteTokens: true,
|
|
71
|
+
totalTokens: true,
|
|
72
|
+
costUsd: true,
|
|
73
|
+
};
|
|
74
|
+
const identifierFields: Record<string, true> = {
|
|
75
|
+
sessionId: true,
|
|
76
|
+
jobId: true,
|
|
77
|
+
reviewerId: true,
|
|
78
|
+
modelId: true,
|
|
79
|
+
};
|
|
80
|
+
const stages: Record<string, true> = {
|
|
81
|
+
review: true,
|
|
82
|
+
attribution: true,
|
|
83
|
+
dedup: true,
|
|
84
|
+
};
|
|
85
|
+
const outcomes = {
|
|
86
|
+
success: true,
|
|
87
|
+
failed: true,
|
|
88
|
+
cancelled: true,
|
|
89
|
+
timeout: true,
|
|
90
|
+
obsolete: true,
|
|
91
|
+
added: true,
|
|
92
|
+
unchanged: true,
|
|
93
|
+
accept: true,
|
|
94
|
+
reject: true,
|
|
95
|
+
invalid: true,
|
|
96
|
+
on: true,
|
|
97
|
+
off: true,
|
|
98
|
+
};
|
|
99
|
+
const reasonCodes = {
|
|
100
|
+
session_change: true,
|
|
101
|
+
shutdown: true,
|
|
102
|
+
disabled: true,
|
|
103
|
+
cleared: true,
|
|
104
|
+
superseded: true,
|
|
105
|
+
stale: true,
|
|
106
|
+
missing_model: true,
|
|
107
|
+
unchanged: true,
|
|
108
|
+
duplicate_config: true,
|
|
109
|
+
scheduled: true,
|
|
110
|
+
completed: true,
|
|
111
|
+
no_findings: true,
|
|
112
|
+
inherited: true,
|
|
113
|
+
attribution_fallback: true,
|
|
114
|
+
dedup_fallback: true,
|
|
115
|
+
persistence_failed: true,
|
|
116
|
+
wake_pending: true,
|
|
117
|
+
busy: true,
|
|
118
|
+
no_findings_ready: true,
|
|
119
|
+
already_presented: true,
|
|
120
|
+
ready: true,
|
|
121
|
+
outstanding: true,
|
|
122
|
+
passive: true,
|
|
123
|
+
baseline_unavailable: true,
|
|
124
|
+
configuration_failed: true,
|
|
125
|
+
ui_unavailable: true,
|
|
126
|
+
};
|
|
127
|
+
const maxFileBytes = 1024 * 1024;
|
|
128
|
+
const maxBufferedBytes = 64 * 1024;
|
|
129
|
+
const retainedFiles = 5;
|
|
130
|
+
const filePattern = /^pair-\d+-(\d+)-[\da-f-]+\.jsonl$/u;
|
|
131
|
+
const activeFilesKey = Symbol.for("pi-pair-programmer.active-log-files.v1");
|
|
132
|
+
const retainedFilesInProcess = Reflect.get(globalThis, activeFilesKey) as
|
|
133
|
+
Set<string> | undefined;
|
|
134
|
+
const activeFiles = retainedFilesInProcess ?? new Set<string>();
|
|
135
|
+
|
|
136
|
+
function safeFields(fields: PairLogFields): Record<string, string | number> {
|
|
137
|
+
const safe: Record<string, string | number> = {};
|
|
138
|
+
for (const [key, value] of Object.entries(fields)) {
|
|
139
|
+
const numeric =
|
|
140
|
+
Object.hasOwn(numericFields, key) &&
|
|
141
|
+
typeof value === "number" &&
|
|
142
|
+
Number.isFinite(value) &&
|
|
143
|
+
value >= 0;
|
|
144
|
+
const identifier =
|
|
145
|
+
Object.hasOwn(identifierFields, key) &&
|
|
146
|
+
typeof value === "string" &&
|
|
147
|
+
/^[\da-f-]{1,64}$/iu.test(value);
|
|
148
|
+
const code =
|
|
149
|
+
typeof value === "string" &&
|
|
150
|
+
((key === "stage" && Object.hasOwn(stages, value)) ||
|
|
151
|
+
(key === "outcome" && Object.hasOwn(outcomes, value)) ||
|
|
152
|
+
(key === "reasonCode" && Object.hasOwn(reasonCodes, value)));
|
|
153
|
+
if (numeric || identifier || code)
|
|
154
|
+
Object.defineProperty(safe, key, { value, enumerable: true });
|
|
155
|
+
}
|
|
156
|
+
return safe;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
type LogLevel = "debug" | "info" | "warn" | "error";
|
|
160
|
+
const warningReasons: Record<string, true> = {
|
|
161
|
+
attribution_fallback: true,
|
|
162
|
+
dedup_fallback: true,
|
|
163
|
+
missing_model: true,
|
|
164
|
+
baseline_unavailable: true,
|
|
165
|
+
ui_unavailable: true,
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
function severity(
|
|
169
|
+
event: PairLogEvent,
|
|
170
|
+
fields: Record<string, string | number>,
|
|
171
|
+
): LogLevel {
|
|
172
|
+
const outcome = fields["outcome"];
|
|
173
|
+
const reason = fields["reasonCode"];
|
|
174
|
+
if (
|
|
175
|
+
event === "extension.persistence_failed" ||
|
|
176
|
+
reason === "persistence_failed" ||
|
|
177
|
+
reason === "configuration_failed"
|
|
178
|
+
) {
|
|
179
|
+
return "error";
|
|
180
|
+
}
|
|
181
|
+
if (
|
|
182
|
+
outcome === "timeout" ||
|
|
183
|
+
outcome === "invalid" ||
|
|
184
|
+
Object.hasOwn(warningReasons, reason ?? "")
|
|
185
|
+
) {
|
|
186
|
+
return "warn";
|
|
187
|
+
}
|
|
188
|
+
if (outcome === "failed") {
|
|
189
|
+
return "error";
|
|
190
|
+
}
|
|
191
|
+
return outcome === "cancelled" ||
|
|
192
|
+
outcome === "obsolete" ||
|
|
193
|
+
event === "review.skipped" ||
|
|
194
|
+
event === "finding.filtered" ||
|
|
195
|
+
(event === "delivery.wake" && reason !== "ready")
|
|
196
|
+
? "debug"
|
|
197
|
+
: "info";
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function guardedLogger(
|
|
201
|
+
emit: (event: PairLogEvent, fields: Record<string, string | number>) => void,
|
|
202
|
+
finish: () => Promise<void>,
|
|
203
|
+
): PairLogger {
|
|
204
|
+
let closed = false;
|
|
205
|
+
let closing: Promise<void> | undefined;
|
|
206
|
+
return {
|
|
207
|
+
log(event, fields = {}) {
|
|
208
|
+
try {
|
|
209
|
+
if (!closed && Object.hasOwn(events, event)) {
|
|
210
|
+
emit(event, safeFields(fields));
|
|
211
|
+
}
|
|
212
|
+
} catch {}
|
|
213
|
+
},
|
|
214
|
+
close() {
|
|
215
|
+
closed = true;
|
|
216
|
+
closing ??= (async () => {
|
|
217
|
+
try {
|
|
218
|
+
await finish();
|
|
219
|
+
} catch {}
|
|
220
|
+
})();
|
|
221
|
+
return closing;
|
|
222
|
+
},
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function nativeLogger(host: unknown): PairLogger | undefined {
|
|
227
|
+
if (typeof host !== "object" || host === null || !("logger" in host)) {
|
|
228
|
+
return undefined;
|
|
229
|
+
}
|
|
230
|
+
const logger: unknown = host.logger;
|
|
231
|
+
if (
|
|
232
|
+
typeof logger !== "object" ||
|
|
233
|
+
logger === null ||
|
|
234
|
+
!("info" in logger) ||
|
|
235
|
+
typeof logger.info !== "function"
|
|
236
|
+
) {
|
|
237
|
+
return undefined;
|
|
238
|
+
}
|
|
239
|
+
return guardedLogger(
|
|
240
|
+
(event, fields) => {
|
|
241
|
+
const method: unknown = Reflect.get(logger, severity(event, fields));
|
|
242
|
+
if (typeof method !== "function") return;
|
|
243
|
+
const emit = method as (
|
|
244
|
+
message: string,
|
|
245
|
+
context: Record<string, string | number>,
|
|
246
|
+
) => void;
|
|
247
|
+
emit.call(logger, `pair-programmer.${event}`, fields);
|
|
248
|
+
},
|
|
249
|
+
() => Promise.resolve(),
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function processRetired(pid: number): boolean {
|
|
254
|
+
try {
|
|
255
|
+
process.kill(pid, 0);
|
|
256
|
+
return false;
|
|
257
|
+
} catch (error) {
|
|
258
|
+
return error instanceof Error && "code" in error && error.code === "ESRCH";
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async function pruneRetiredFiles(directory: string): Promise<void> {
|
|
263
|
+
const files = await readdir(directory);
|
|
264
|
+
const retired: string[] = [];
|
|
265
|
+
for (const name of files) {
|
|
266
|
+
const match = filePattern.exec(name);
|
|
267
|
+
if (match === null) {
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
if (Number(match[1]) === process.pid) {
|
|
271
|
+
if (!activeFiles.has(path.join(directory, name))) retired.push(name);
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
if (processRetired(Number(match[1]))) retired.push(name);
|
|
275
|
+
}
|
|
276
|
+
retired.sort((a, b) => a.localeCompare(b));
|
|
277
|
+
for (const name of retired.slice(
|
|
278
|
+
0,
|
|
279
|
+
Math.max(0, retired.length - retainedFiles),
|
|
280
|
+
)) {
|
|
281
|
+
try {
|
|
282
|
+
await unlink(path.join(directory, name));
|
|
283
|
+
} catch {}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
async function fileLogger(directory: string): Promise<PairLogger> {
|
|
288
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
289
|
+
|
|
290
|
+
await chmod(directory, 0o700);
|
|
291
|
+
await pruneRetiredFiles(directory);
|
|
292
|
+
const prefix = path.join(
|
|
293
|
+
directory,
|
|
294
|
+
`pair-${String(Date.now())}-${String(process.pid)}-${randomUUID()}`,
|
|
295
|
+
);
|
|
296
|
+
let generation = 0;
|
|
297
|
+
let filename = `${prefix}-${String(generation)}.jsonl`;
|
|
298
|
+
activeFiles.add(filename);
|
|
299
|
+
let destination: Awaited<ReturnType<typeof open>>;
|
|
300
|
+
try {
|
|
301
|
+
destination = await open(filename, "wx", 0o600);
|
|
302
|
+
} catch (error) {
|
|
303
|
+
activeFiles.delete(filename);
|
|
304
|
+
throw error;
|
|
305
|
+
}
|
|
306
|
+
const files = [filename];
|
|
307
|
+
const state = { failed: false };
|
|
308
|
+
let bytes = 0;
|
|
309
|
+
let bufferedBytes = 0;
|
|
310
|
+
let pending = Promise.resolve();
|
|
311
|
+
const logger = pino(
|
|
312
|
+
{ base: null, level: "debug" },
|
|
313
|
+
{
|
|
314
|
+
write(line: string) {
|
|
315
|
+
const length = Buffer.byteLength(line);
|
|
316
|
+
if (state.failed || bufferedBytes + length > maxBufferedBytes) {
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
bufferedBytes += length;
|
|
320
|
+
const previous = pending;
|
|
321
|
+
pending = (async () => {
|
|
322
|
+
await previous;
|
|
323
|
+
try {
|
|
324
|
+
if (state.failed) {
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
if (bytes + length > maxFileBytes) {
|
|
328
|
+
await destination.close();
|
|
329
|
+
generation++;
|
|
330
|
+
filename = `${prefix}-${String(generation)}.jsonl`;
|
|
331
|
+
activeFiles.add(filename);
|
|
332
|
+
|
|
333
|
+
destination = await open(filename, "wx", 0o600);
|
|
334
|
+
bytes = 0;
|
|
335
|
+
files.push(filename);
|
|
336
|
+
if (files.length > retainedFiles) {
|
|
337
|
+
for (const retired of files.splice(0, 1)) {
|
|
338
|
+
activeFiles.delete(retired);
|
|
339
|
+
|
|
340
|
+
await unlink(retired);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
await destination.writeFile(line);
|
|
345
|
+
bytes += length;
|
|
346
|
+
} catch {
|
|
347
|
+
state.failed = true;
|
|
348
|
+
} finally {
|
|
349
|
+
bufferedBytes -= length;
|
|
350
|
+
}
|
|
351
|
+
})();
|
|
352
|
+
},
|
|
353
|
+
},
|
|
354
|
+
);
|
|
355
|
+
return guardedLogger(
|
|
356
|
+
(event, fields) => {
|
|
357
|
+
logger[severity(event, fields)](fields, `pair-programmer.${event}`);
|
|
358
|
+
},
|
|
359
|
+
async () => {
|
|
360
|
+
await pending;
|
|
361
|
+
try {
|
|
362
|
+
await destination.close();
|
|
363
|
+
} finally {
|
|
364
|
+
for (const file of files) activeFiles.delete(file);
|
|
365
|
+
activeFiles.delete(filename);
|
|
366
|
+
await pruneRetiredFiles(directory);
|
|
367
|
+
}
|
|
368
|
+
},
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
export async function createPairLogger(
|
|
373
|
+
host: unknown,
|
|
374
|
+
options: { directory?: string } = {},
|
|
375
|
+
): Promise<PairLogger> {
|
|
376
|
+
Reflect.set(globalThis, activeFilesKey, activeFiles);
|
|
377
|
+
try {
|
|
378
|
+
const native = nativeLogger(host);
|
|
379
|
+
if (native !== undefined) {
|
|
380
|
+
return native;
|
|
381
|
+
}
|
|
382
|
+
const stateDirectory =
|
|
383
|
+
process.env["PI_CODING_AGENT_DIR"] ??
|
|
384
|
+
path.join(homedir(), ".pi", "agent");
|
|
385
|
+
return await fileLogger(
|
|
386
|
+
options.directory ?? path.join(stateDirectory, "logs", "pair-programmer"),
|
|
387
|
+
);
|
|
388
|
+
} catch {
|
|
389
|
+
return guardedLogger(
|
|
390
|
+
() => {
|
|
391
|
+
return;
|
|
392
|
+
},
|
|
393
|
+
() => Promise.resolve(),
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import { APIError, APITimeoutError, APIUserAbortError } from "@typesafe-ai/sdk";
|
|
2
|
+
|
|
3
|
+
export interface ModelUsage {
|
|
4
|
+
inputTokens?: number;
|
|
5
|
+
outputTokens?: number;
|
|
6
|
+
cacheReadTokens?: number;
|
|
7
|
+
cacheWriteTokens?: number;
|
|
8
|
+
totalTokens?: number;
|
|
9
|
+
costUsd?: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface ModelCallObservation {
|
|
13
|
+
stage: "review" | "attribution" | "dedup";
|
|
14
|
+
requestedModel: string;
|
|
15
|
+
model?: string;
|
|
16
|
+
provider?: string;
|
|
17
|
+
outcome: "success" | "failed" | "cancelled" | "timeout";
|
|
18
|
+
durationMs: number;
|
|
19
|
+
usage?: ModelUsage;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type ModelCallObserver = (observation: ModelCallObservation) => void;
|
|
23
|
+
type Outcome = ModelCallObservation["outcome"];
|
|
24
|
+
type Metadata = Pick<ModelCallObservation, "model" | "provider" | "usage">;
|
|
25
|
+
|
|
26
|
+
function record(value: unknown): Record<string, unknown> | undefined {
|
|
27
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
28
|
+
? (value as Record<string, unknown>)
|
|
29
|
+
: undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function numeric(value: unknown): value is number {
|
|
33
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function metadata(value: unknown, judge = false): Metadata {
|
|
37
|
+
const message = record(value);
|
|
38
|
+
const result: Metadata = {};
|
|
39
|
+
const model =
|
|
40
|
+
!judge && typeof message?.["responseModel"] === "string"
|
|
41
|
+
? message["responseModel"]
|
|
42
|
+
: message?.["model"];
|
|
43
|
+
if (typeof model === "string") result.model = model;
|
|
44
|
+
if (typeof message?.["provider"] === "string") {
|
|
45
|
+
result.provider = message["provider"];
|
|
46
|
+
}
|
|
47
|
+
const raw = record(message?.["usage"]);
|
|
48
|
+
if (raw === undefined) return result;
|
|
49
|
+
const usage: ModelUsage = {};
|
|
50
|
+
const fields: [keyof ModelUsage, unknown][] = judge
|
|
51
|
+
? [
|
|
52
|
+
["inputTokens", raw["input_tokens"]],
|
|
53
|
+
["outputTokens", raw["output_tokens"]],
|
|
54
|
+
]
|
|
55
|
+
: [
|
|
56
|
+
["inputTokens", raw["input"]],
|
|
57
|
+
["outputTokens", raw["output"]],
|
|
58
|
+
["cacheReadTokens", raw["cacheRead"]],
|
|
59
|
+
["cacheWriteTokens", raw["cacheWrite"]],
|
|
60
|
+
["totalTokens", raw["totalTokens"]],
|
|
61
|
+
["costUsd", record(raw["cost"])?.["total"]],
|
|
62
|
+
];
|
|
63
|
+
for (const [key, value] of fields) {
|
|
64
|
+
if (numeric(value) && (key === "costUsd" || Number.isSafeInteger(value))) {
|
|
65
|
+
Object.defineProperty(usage, key, { value, enumerable: true });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (Object.keys(usage).length > 0) result.usage = usage;
|
|
69
|
+
return result;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function notify(
|
|
73
|
+
observer: ModelCallObserver | undefined,
|
|
74
|
+
event: ModelCallObservation,
|
|
75
|
+
): void {
|
|
76
|
+
try {
|
|
77
|
+
observer?.(event);
|
|
78
|
+
} catch {}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function observeJudgment<T>(
|
|
82
|
+
stage: "attribution" | "dedup",
|
|
83
|
+
signal: AbortSignal,
|
|
84
|
+
observer: ModelCallObserver | undefined,
|
|
85
|
+
invoke: () => PromiseLike<T>,
|
|
86
|
+
): Promise<T> {
|
|
87
|
+
const start = performance.now();
|
|
88
|
+
let details: Metadata = {};
|
|
89
|
+
let outcome: Outcome = "success";
|
|
90
|
+
try {
|
|
91
|
+
const response = await invoke();
|
|
92
|
+
details = metadata(response, true);
|
|
93
|
+
return response;
|
|
94
|
+
} catch (error) {
|
|
95
|
+
if (signal.aborted || error instanceof APIUserAbortError) {
|
|
96
|
+
outcome = "cancelled";
|
|
97
|
+
} else {
|
|
98
|
+
outcome = error instanceof APITimeoutError ? "timeout" : "failed";
|
|
99
|
+
}
|
|
100
|
+
if (error instanceof APIError) details = metadata(error.body, true);
|
|
101
|
+
throw error;
|
|
102
|
+
} finally {
|
|
103
|
+
notify(observer, {
|
|
104
|
+
stage,
|
|
105
|
+
requestedModel: "jev-latest",
|
|
106
|
+
...details,
|
|
107
|
+
outcome,
|
|
108
|
+
durationMs: performance.now() - start,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export class ReviewEventStream {
|
|
114
|
+
private readonly model: string;
|
|
115
|
+
private readonly observer: ModelCallObserver | undefined;
|
|
116
|
+
private readonly outcome: () => Exclude<Outcome, "success">;
|
|
117
|
+
private readonly started = performance.now();
|
|
118
|
+
private readonly seen = new Set<string>();
|
|
119
|
+
private buffer = "";
|
|
120
|
+
private invalid = false;
|
|
121
|
+
private sequence = 0;
|
|
122
|
+
private pending: { started: number; details: Metadata } | undefined;
|
|
123
|
+
private answer: string | undefined;
|
|
124
|
+
private finished = false;
|
|
125
|
+
|
|
126
|
+
constructor(
|
|
127
|
+
model: string,
|
|
128
|
+
observer: ModelCallObserver | undefined,
|
|
129
|
+
outcome: () => Exclude<Outcome, "success">,
|
|
130
|
+
) {
|
|
131
|
+
this.model = model;
|
|
132
|
+
this.observer = observer;
|
|
133
|
+
this.outcome = outcome;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
push(chunk: string): void {
|
|
137
|
+
if (this.finished) return;
|
|
138
|
+
this.buffer += chunk;
|
|
139
|
+
let newline = this.buffer.indexOf("\n");
|
|
140
|
+
while (newline >= 0) {
|
|
141
|
+
const line = this.buffer.slice(0, newline).trim();
|
|
142
|
+
this.buffer = this.buffer.slice(newline + 1);
|
|
143
|
+
this.checkRecordSize(line);
|
|
144
|
+
this.accept(line);
|
|
145
|
+
newline = this.buffer.indexOf("\n");
|
|
146
|
+
}
|
|
147
|
+
this.checkRecordSize(this.buffer);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
private checkRecordSize(record: string): void {
|
|
151
|
+
if (Buffer.byteLength(record) <= 1024 * 1024) {
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
this.buffer = "";
|
|
156
|
+
this.invalid = true;
|
|
157
|
+
throw new Error("Reviewer output exceeded limit");
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
finish(): void {
|
|
161
|
+
if (this.finished) return;
|
|
162
|
+
if (this.buffer.trim().length > 0) this.accept(this.buffer.trim());
|
|
163
|
+
this.buffer = "";
|
|
164
|
+
this.finished = true;
|
|
165
|
+
if (this.pending === undefined) {
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
delete this.pending.details.usage;
|
|
170
|
+
this.emit(this.pending.details, this.outcome());
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
text(): string {
|
|
174
|
+
this.finish();
|
|
175
|
+
if (this.invalid)
|
|
176
|
+
throw new Error("Reviewer returned an invalid JSON event stream");
|
|
177
|
+
if (this.answer === undefined)
|
|
178
|
+
throw new Error("Reviewer returned no successful assistant answer");
|
|
179
|
+
return this.answer;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
private accept(line: string): void {
|
|
183
|
+
if (line.length === 0) return;
|
|
184
|
+
try {
|
|
185
|
+
const event = record(JSON.parse(line) as unknown);
|
|
186
|
+
if (typeof event?.["type"] !== "string") {
|
|
187
|
+
this.invalid = true;
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
this.event(event);
|
|
191
|
+
} catch {
|
|
192
|
+
this.invalid = true;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
private event(event: Record<string, unknown>): void {
|
|
197
|
+
const message = record(event["message"]);
|
|
198
|
+
switch (event["type"]) {
|
|
199
|
+
case "message_start":
|
|
200
|
+
if (message?.["role"] === "assistant") {
|
|
201
|
+
this.sequence += 1;
|
|
202
|
+
this.answer = undefined;
|
|
203
|
+
this.pending = {
|
|
204
|
+
started: performance.now(),
|
|
205
|
+
details: metadata(message),
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
break;
|
|
209
|
+
case "message_update": {
|
|
210
|
+
this.answer = undefined;
|
|
211
|
+
this.pending ??= { started: performance.now(), details: {} };
|
|
212
|
+
const details = metadata(message ?? event);
|
|
213
|
+
delete details.usage;
|
|
214
|
+
this.pending.details = { ...this.pending.details, ...details };
|
|
215
|
+
break;
|
|
216
|
+
}
|
|
217
|
+
case "message_end":
|
|
218
|
+
if (message === undefined) this.invalid = true;
|
|
219
|
+
else if (message["role"] === "assistant")
|
|
220
|
+
this.endMessage(event, message);
|
|
221
|
+
break;
|
|
222
|
+
default:
|
|
223
|
+
break;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
private endMessage(
|
|
228
|
+
event: Record<string, unknown>,
|
|
229
|
+
message: Record<string, unknown>,
|
|
230
|
+
): void {
|
|
231
|
+
const identity =
|
|
232
|
+
typeof event["messageId"] === "string"
|
|
233
|
+
? event["messageId"]
|
|
234
|
+
: `${String(this.sequence)}:${JSON.stringify(message)}`;
|
|
235
|
+
if (this.seen.has(identity)) return;
|
|
236
|
+
this.seen.add(identity);
|
|
237
|
+
const reason = message["stopReason"];
|
|
238
|
+
const successful =
|
|
239
|
+
reason === "stop" || reason === "length" || reason === "toolUse";
|
|
240
|
+
let outcome: Outcome = successful ? "success" : "failed";
|
|
241
|
+
if (reason === "aborted") {
|
|
242
|
+
outcome = this.outcome() === "timeout" ? "timeout" : "cancelled";
|
|
243
|
+
}
|
|
244
|
+
this.emit({ ...this.pending?.details, ...metadata(message) }, outcome);
|
|
245
|
+
this.answer = undefined;
|
|
246
|
+
if (!successful) return;
|
|
247
|
+
const content = message["content"];
|
|
248
|
+
if (!Array.isArray(content)) {
|
|
249
|
+
this.invalid = true;
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
this.answer = content
|
|
253
|
+
.flatMap((block: unknown) => {
|
|
254
|
+
const part = record(block);
|
|
255
|
+
return part?.["type"] === "text" && typeof part["text"] === "string"
|
|
256
|
+
? [part["text"]]
|
|
257
|
+
: [];
|
|
258
|
+
})
|
|
259
|
+
.join("\n");
|
|
260
|
+
if (Buffer.byteLength(this.answer) <= 256_000) {
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
this.answer = undefined;
|
|
265
|
+
this.invalid = true;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
private emit(details: Metadata, outcome: Outcome): void {
|
|
269
|
+
if (
|
|
270
|
+
outcome !== "success" &&
|
|
271
|
+
details.usage !== undefined &&
|
|
272
|
+
Object.values(details.usage).every((value) => value <= 0)
|
|
273
|
+
) {
|
|
274
|
+
delete details.usage;
|
|
275
|
+
}
|
|
276
|
+
notify(this.observer, {
|
|
277
|
+
stage: "review",
|
|
278
|
+
requestedModel: this.model,
|
|
279
|
+
...details,
|
|
280
|
+
outcome,
|
|
281
|
+
durationMs: performance.now() - (this.pending?.started ?? this.started),
|
|
282
|
+
});
|
|
283
|
+
this.pending = undefined;
|
|
284
|
+
}
|
|
285
|
+
}
|