@erichll/pi-auto-review 0.16.0 → 0.18.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/CHANGELOG.md +31 -0
- package/README.md +6 -5
- package/package.json +4 -4
- package/src/broker/overrides.ts +1 -0
- package/src/index.ts +55 -1944
- package/src/policy-audit/classifier.ts +1 -1
- package/src/policy-audit/index.ts +2 -1
- package/src/policy.ts +19 -8
- package/src/review/audit.ts +14 -0
- package/src/review/complete.ts +347 -0
- package/src/review/config.ts +366 -0
- package/src/review/consts.ts +22 -0
- package/src/review/guards.ts +66 -0
- package/src/review/index.ts +61 -0
- package/src/review/input.ts +439 -0
- package/src/review/prompts.ts +33 -0
- package/src/review/provider.ts +583 -0
- package/src/review/types.ts +207 -0
- package/src/user-feedback.ts +34 -1
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import {
|
|
2
|
+
completeSimple,
|
|
3
|
+
type ApiStreamSimpleFunction,
|
|
4
|
+
} from "@earendil-works/pi-ai/compat";
|
|
5
|
+
import type { ModelDecision, TranscriptResult } from "../policy.ts";
|
|
6
|
+
import type { PolicyAuditConfig } from "../policy-audit/index.ts";
|
|
7
|
+
|
|
8
|
+
export type ReasoningLevel =
|
|
9
|
+
| "off"
|
|
10
|
+
| "minimal"
|
|
11
|
+
| "low"
|
|
12
|
+
| "medium"
|
|
13
|
+
| "high"
|
|
14
|
+
| "xhigh"
|
|
15
|
+
| "max";
|
|
16
|
+
|
|
17
|
+
export type BoundedSurface = "external_directory" | "path";
|
|
18
|
+
|
|
19
|
+
export type Config = {
|
|
20
|
+
model: string;
|
|
21
|
+
reasoning: ReasoningLevel;
|
|
22
|
+
timeoutMs: number;
|
|
23
|
+
maxTokens: number;
|
|
24
|
+
retries: number;
|
|
25
|
+
maxUserTranscriptTokens: number;
|
|
26
|
+
maxToolTranscriptTokens: number;
|
|
27
|
+
maxRelevantResultTokens: number;
|
|
28
|
+
maxReviewerInputTokens: number;
|
|
29
|
+
breakGlassEnabled: boolean;
|
|
30
|
+
failureMode: "deny" | "defer";
|
|
31
|
+
grantTtlMs: number;
|
|
32
|
+
autoConfirmBoundedAllows: readonly BoundedSurface[];
|
|
33
|
+
policyAudit: Readonly<PolicyAuditConfig>;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export type CompletionMessage = {
|
|
37
|
+
stopReason?: string;
|
|
38
|
+
responseModel?: string;
|
|
39
|
+
errorMessage?: string;
|
|
40
|
+
content?: Array<{ type?: string; text?: string }>;
|
|
41
|
+
usage?: {
|
|
42
|
+
input?: number;
|
|
43
|
+
output?: number;
|
|
44
|
+
cacheRead?: number;
|
|
45
|
+
cacheWrite?: number;
|
|
46
|
+
reasoning?: number;
|
|
47
|
+
totalTokens?: number;
|
|
48
|
+
};
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export type UsageAvailability =
|
|
52
|
+
| "reported"
|
|
53
|
+
| "estimated"
|
|
54
|
+
| "unavailable"
|
|
55
|
+
| "unknown_provenance";
|
|
56
|
+
|
|
57
|
+
export type ReviewUsage = {
|
|
58
|
+
input?: number;
|
|
59
|
+
output?: number;
|
|
60
|
+
cacheRead?: number;
|
|
61
|
+
cacheWrite?: number;
|
|
62
|
+
reasoning?: number;
|
|
63
|
+
totalTokens?: number;
|
|
64
|
+
observedInputTokens?: number;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export type ReviewAttemptStatus =
|
|
68
|
+
| "success"
|
|
69
|
+
| "format_error"
|
|
70
|
+
| "non_stop"
|
|
71
|
+
| "transport_failure"
|
|
72
|
+
| "timeout"
|
|
73
|
+
| "abort";
|
|
74
|
+
|
|
75
|
+
export type ReviewErrorClass =
|
|
76
|
+
| "none"
|
|
77
|
+
| "non_json"
|
|
78
|
+
| "schema"
|
|
79
|
+
| "empty_output"
|
|
80
|
+
| "output_limit"
|
|
81
|
+
| "provider_stop"
|
|
82
|
+
| "transient_connection"
|
|
83
|
+
| "transient_server"
|
|
84
|
+
| "rate_limit"
|
|
85
|
+
| "timeout"
|
|
86
|
+
| "abort"
|
|
87
|
+
| "authentication"
|
|
88
|
+
| "model_resolution"
|
|
89
|
+
| "request_configuration"
|
|
90
|
+
| "circuit_breaker"
|
|
91
|
+
| "critical_evidence_overflow"
|
|
92
|
+
| "required_profile_overflow"
|
|
93
|
+
| "reviewer_input_budget_exceeded"
|
|
94
|
+
| "unknown";
|
|
95
|
+
|
|
96
|
+
export type ReviewAttemptObservation = {
|
|
97
|
+
attempt: number;
|
|
98
|
+
model: string;
|
|
99
|
+
status: ReviewAttemptStatus;
|
|
100
|
+
errorClass: ReviewErrorClass;
|
|
101
|
+
stopReason: "stop" | "length" | "toolUse" | "error" | "aborted" | "deferred" | "unknown";
|
|
102
|
+
durationMs: number;
|
|
103
|
+
willRetry: boolean;
|
|
104
|
+
usageAvailability: UsageAvailability;
|
|
105
|
+
usage: ReviewUsage;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
export type PreflightPart = { characters: number; estimatedTokens: number };
|
|
109
|
+
|
|
110
|
+
export type ReviewPreflight = {
|
|
111
|
+
estimator: "conservative:cjk-aware";
|
|
112
|
+
maxReviewerInputTokens: number;
|
|
113
|
+
framingReserveTokens: number;
|
|
114
|
+
fixedPrompt: PreflightPart;
|
|
115
|
+
canonicalRequest: PreflightPart;
|
|
116
|
+
override: PreflightPart;
|
|
117
|
+
user: PreflightPart;
|
|
118
|
+
tool: PreflightPart;
|
|
119
|
+
relevantResult: PreflightPart;
|
|
120
|
+
framing: PreflightPart;
|
|
121
|
+
total: PreflightPart;
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
export type ReviewExecutionSummary = {
|
|
125
|
+
attempts: ReviewAttemptObservation[];
|
|
126
|
+
errorCounts: Partial<Record<Exclude<ReviewErrorClass, "none">, number>>;
|
|
127
|
+
durationMs: number;
|
|
128
|
+
transcript: TranscriptResult;
|
|
129
|
+
preflight: ReviewPreflight;
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
export type ReviewerTelemetryEvent =
|
|
133
|
+
| ({
|
|
134
|
+
type: "review_attempt";
|
|
135
|
+
requestId: string;
|
|
136
|
+
surface: string;
|
|
137
|
+
} & ReviewAttemptObservation)
|
|
138
|
+
| {
|
|
139
|
+
type: "review_complete";
|
|
140
|
+
requestId: string;
|
|
141
|
+
surface: string;
|
|
142
|
+
model: string;
|
|
143
|
+
reasoning: ReasoningLevel;
|
|
144
|
+
outcome: "allow" | "deny" | "defer";
|
|
145
|
+
failureMode?: "deny" | "defer";
|
|
146
|
+
attempts: number;
|
|
147
|
+
errorCounts: ReviewExecutionSummary["errorCounts"];
|
|
148
|
+
durationMs: number;
|
|
149
|
+
usageAvailability: UsageAvailability;
|
|
150
|
+
usage: ReviewUsage;
|
|
151
|
+
transcript: {
|
|
152
|
+
userCharacters: number;
|
|
153
|
+
toolCharacters: number;
|
|
154
|
+
relevantResultCharacters: number;
|
|
155
|
+
truncated: boolean;
|
|
156
|
+
selectedCandidates: TranscriptResult["selectedCandidates"];
|
|
157
|
+
failureCode?: TranscriptResult["failureCode"];
|
|
158
|
+
userAuthorizationCeiling: TranscriptResult["userAuthorizationCeiling"];
|
|
159
|
+
userConstraint: TranscriptResult["userConstraint"];
|
|
160
|
+
compactionState: TranscriptResult["compactionState"];
|
|
161
|
+
budgetRemovals: TranscriptResult["budgetRemovals"];
|
|
162
|
+
};
|
|
163
|
+
preflight: ReviewPreflight;
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
export type ReviewerRuntime = {
|
|
167
|
+
model: Parameters<typeof completeSimple>[0];
|
|
168
|
+
auth: {
|
|
169
|
+
apiKey?: string;
|
|
170
|
+
// ProviderHeaders allows null values (e.g. "clear" directives) in 0.84.x.
|
|
171
|
+
headers?: Record<string, string | null>;
|
|
172
|
+
env?: Record<string, string>;
|
|
173
|
+
};
|
|
174
|
+
streamSimple?: ApiStreamSimpleFunction;
|
|
175
|
+
sessionId: string;
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
// Model/stream metadata is re-resolved for every review instead of being
|
|
179
|
+
// cached for the session: pi can refresh models.json or re-register a
|
|
180
|
+
// provider while a session is live, and the resolved model's baseUrl, api
|
|
181
|
+
// or streamSimple may have changed, so a per-session cache could keep
|
|
182
|
+
// calling a stale endpoint. Authentication is also deliberately excluded
|
|
183
|
+
// because pi resolves models.json auth and headers dynamically on every
|
|
184
|
+
// request (including OAuth refresh); it is reacquired per model call in
|
|
185
|
+
// complete().
|
|
186
|
+
export type ReviewerMeta = Omit<ReviewerRuntime, "auth" | "sessionId">;
|
|
187
|
+
|
|
188
|
+
export type ReviewResult = {
|
|
189
|
+
decision: ModelDecision;
|
|
190
|
+
attempts: number;
|
|
191
|
+
retryErrors: ReviewErrorClass[];
|
|
192
|
+
durationMs: number;
|
|
193
|
+
transcript: TranscriptResult;
|
|
194
|
+
summary: ReviewExecutionSummary;
|
|
195
|
+
unavailable?: boolean;
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
export class ReviewExecutionError extends Error {
|
|
199
|
+
constructor(
|
|
200
|
+
readonly errorClass: ReviewErrorClass,
|
|
201
|
+
readonly summary: ReviewExecutionSummary,
|
|
202
|
+
) {
|
|
203
|
+
super(`automatic review failed (${errorClass})`);
|
|
204
|
+
this.name = "ReviewExecutionError";
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
package/src/user-feedback.ts
CHANGED
|
@@ -3,6 +3,8 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
|
3
3
|
export const USER_REVIEW_STATUS_KEY = "pi-auto-review";
|
|
4
4
|
export const USER_REVIEW_ENTRY_TYPE = "pi-auto-review";
|
|
5
5
|
export const USER_REVIEW_WIDGET_KEY = "pi-auto-review";
|
|
6
|
+
/** How long a successful completed widget stays above the editor. */
|
|
7
|
+
export const USER_REVIEW_SUCCESS_WIDGET_TTL_MS = 8_000;
|
|
6
8
|
|
|
7
9
|
export type UserReviewOutcome =
|
|
8
10
|
| "allow"
|
|
@@ -454,7 +456,7 @@ export function buildUserReviewGroupLines(
|
|
|
454
456
|
data: UserReviewGroupEntryData,
|
|
455
457
|
): UserReviewEntryData {
|
|
456
458
|
if (data.members.length === 1) {
|
|
457
|
-
const member = data.members[0]
|
|
459
|
+
const member = data.members[0]!;
|
|
458
460
|
const lines = buildUserReviewLines(member);
|
|
459
461
|
if (member.permissionResult === "deny" && member.outcome !== "deny") {
|
|
460
462
|
lines.push("Local confirmation · denied");
|
|
@@ -652,9 +654,14 @@ function setWidget(
|
|
|
652
654
|
}
|
|
653
655
|
}
|
|
654
656
|
|
|
657
|
+
function isTransientCompletedOutcome(outcome: UserReviewOutcome): boolean {
|
|
658
|
+
return outcome === "allow" || outcome === "auto_confirm";
|
|
659
|
+
}
|
|
660
|
+
|
|
655
661
|
/** Owns the single live widget and rejects stale concurrent completions. */
|
|
656
662
|
export class UserReviewWidgetController {
|
|
657
663
|
#generation = 0;
|
|
664
|
+
#dismissTimer: ReturnType<typeof setTimeout> | undefined;
|
|
658
665
|
/** Set while a `ctx.ui` prompt blocks the session (ui_prompt span). */
|
|
659
666
|
#waiting?: { kind?: unknown; title?: unknown };
|
|
660
667
|
#current?: {
|
|
@@ -664,6 +671,9 @@ export class UserReviewWidgetController {
|
|
|
664
671
|
data?: UserReviewWidgetData;
|
|
665
672
|
notice?: UserReviewNotice;
|
|
666
673
|
};
|
|
674
|
+
constructor(
|
|
675
|
+
private readonly successTtlMs = USER_REVIEW_SUCCESS_WIDGET_TTL_MS,
|
|
676
|
+
) {}
|
|
667
677
|
|
|
668
678
|
begin(
|
|
669
679
|
requestId: string,
|
|
@@ -671,6 +681,7 @@ export class UserReviewWidgetController {
|
|
|
671
681
|
input: { surface: string; target?: string; model?: string },
|
|
672
682
|
): number {
|
|
673
683
|
const generation = ++this.#generation;
|
|
684
|
+
this.#cancelDismiss();
|
|
674
685
|
this.#current = { requestId, generation, ctx, data: buildUserReviewingWidgetData(input) };
|
|
675
686
|
this.#render();
|
|
676
687
|
return generation;
|
|
@@ -715,8 +726,12 @@ export class UserReviewWidgetController {
|
|
|
715
726
|
return;
|
|
716
727
|
}
|
|
717
728
|
if (!this.#isCurrent(requestId, generation)) return;
|
|
729
|
+
this.#cancelDismiss();
|
|
718
730
|
this.#current = { requestId, generation, ctx, notice, data };
|
|
719
731
|
if (!setWidget(ctx, data)) notifyUserReview(ctx, notice);
|
|
732
|
+
if (isTransientCompletedOutcome(data.outcome)) {
|
|
733
|
+
this.#scheduleDismiss(generation, ctx);
|
|
734
|
+
}
|
|
720
735
|
}
|
|
721
736
|
|
|
722
737
|
permissionDecision(event: unknown): void {
|
|
@@ -738,18 +753,36 @@ export class UserReviewWidgetController {
|
|
|
738
753
|
type: current.notice?.type ?? data.type,
|
|
739
754
|
message: data.lines.join("\n"),
|
|
740
755
|
};
|
|
756
|
+
this.#cancelDismiss();
|
|
741
757
|
this.#current = { ...current, data, notice };
|
|
742
758
|
if (!setWidget(current.ctx, data)) notifyUserReview(current.ctx, notice);
|
|
743
759
|
}
|
|
744
760
|
|
|
745
761
|
clear(ctx?: ExtensionContext): void {
|
|
746
762
|
++this.#generation;
|
|
763
|
+
this.#cancelDismiss();
|
|
747
764
|
this.#waiting = undefined;
|
|
748
765
|
const current = this.#current;
|
|
749
766
|
this.#current = undefined;
|
|
750
767
|
if (current) setWidget(ctx ?? current.ctx, undefined);
|
|
751
768
|
}
|
|
752
769
|
|
|
770
|
+
#cancelDismiss(): void {
|
|
771
|
+
if (this.#dismissTimer === undefined) return;
|
|
772
|
+
clearTimeout(this.#dismissTimer);
|
|
773
|
+
this.#dismissTimer = undefined;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
#scheduleDismiss(generation: number, ctx: ExtensionContext): void {
|
|
777
|
+
if (this.successTtlMs <= 0) return;
|
|
778
|
+
this.#dismissTimer = setTimeout(() => {
|
|
779
|
+
this.#dismissTimer = undefined;
|
|
780
|
+
if (this.#current?.generation !== generation) return;
|
|
781
|
+
this.clear(ctx);
|
|
782
|
+
}, this.successTtlMs);
|
|
783
|
+
this.#dismissTimer.unref?.();
|
|
784
|
+
}
|
|
785
|
+
|
|
753
786
|
#isCurrent(requestId: string, generation: number): boolean {
|
|
754
787
|
return this.#current?.requestId === requestId &&
|
|
755
788
|
this.#current.generation === generation;
|