@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.
@@ -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
+
@@ -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;