@mrclrchtr/supi-review 1.11.3 → 1.12.1

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrclrchtr/supi-core",
3
- "version": "1.11.3",
3
+ "version": "1.12.1",
4
4
  "description": "SuPi core — shared infrastructure for SuPi extensions (XML context tags, config system)",
5
5
  "license": "MIT",
6
6
  "repository": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrclrchtr/supi-review",
3
- "version": "1.11.3",
3
+ "version": "1.12.1",
4
4
  "description": "SuPi Review extension — structured code review via /supi-review command",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -20,7 +20,7 @@
20
20
  "README.md"
21
21
  ],
22
22
  "dependencies": {
23
- "@mrclrchtr/supi-core": "1.11.3"
23
+ "@mrclrchtr/supi-core": "1.12.1"
24
24
  },
25
25
  "bundledDependencies": [
26
26
  "@mrclrchtr/supi-core"
@@ -51,9 +51,7 @@
51
51
  ],
52
52
  "image": "https://raw.githubusercontent.com/mrclrchtr/supi/main/packages/supi-review/assets/logo.png"
53
53
  },
54
- "main": "src/api.ts",
55
54
  "exports": {
56
- "./api": "./src/api.ts",
57
55
  "./extension": "./src/extension.ts",
58
56
  "./package.json": "./package.json"
59
57
  }
package/src/git.ts CHANGED
@@ -1,4 +1,4 @@
1
- // biome-ignore lint/nursery/noExcessiveLinesPerFile: many tightly-coupled git helpers; splitting would create cross-ref overhead
1
+ // biome-ignore lint/style/noExcessiveLinesPerFile: many tightly-coupled git helpers; splitting would create cross-ref overhead
2
2
  import { execFile } from "node:child_process";
3
3
  import { readFile } from "node:fs/promises";
4
4
  import { basename, join } from "node:path";
@@ -1,4 +1,5 @@
1
1
  import type { SessionContext } from "@earendil-works/pi-coding-agent";
2
+ import { extractAssistantText } from "../tool/runner-helpers.ts";
2
3
 
3
4
  type ResolvedSessionMessage = SessionContext["messages"][number];
4
5
 
@@ -101,7 +102,7 @@ function serializeEntry(
101
102
  switch (message.role) {
102
103
  case "user":
103
104
  case "assistant": {
104
- const text = normalizeText(extractMessageText(message.content));
105
+ const text = normalizeText(extractAssistantText(message.content) ?? "");
105
106
  if (!text) return undefined;
106
107
  return {
107
108
  label: message.role === "user" ? "User" : "Assistant",
@@ -110,7 +111,7 @@ function serializeEntry(
110
111
  };
111
112
  }
112
113
  case "custom": {
113
- const text = normalizeText(extractMessageText(message.content));
114
+ const text = normalizeText(extractAssistantText(message.content) ?? "");
114
115
  if (!text) return undefined;
115
116
  return { label: "Custom", text, isSummary: false };
116
117
  }
@@ -129,25 +130,6 @@ function serializeEntry(
129
130
  }
130
131
  }
131
132
 
132
- function extractMessageText(content: unknown): string {
133
- if (typeof content === "string") {
134
- return content;
135
- }
136
-
137
- if (!Array.isArray(content)) {
138
- return "";
139
- }
140
-
141
- return content
142
- .map((part) => {
143
- if (typeof part !== "object" || !part) return "";
144
- const text = (part as { text?: unknown }).text;
145
- return typeof text === "string" ? text : "";
146
- })
147
- .filter((text) => text.length > 0)
148
- .join("\n");
149
- }
150
-
151
133
  function normalizeText(text: string): string {
152
134
  return text.replace(/\s+/g, " ").trim();
153
135
  }
@@ -1,8 +1,12 @@
1
1
  import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
2
- import { listReviewInstructionBlocks } from "../target/review-instruction-blocks.ts";
2
+ import { listReviewInstructionBlocks } from "../target/packet.ts";
3
3
  import { runBriefSynthesis } from "../tool/brief-runner.ts";
4
- import type { BriefSynthesisRunResult, ReviewProgress } from "../tool/runner-types.ts";
5
- import type { ReviewModelSelection, ReviewSnapshot } from "../types.ts";
4
+ import type {
5
+ BriefSynthesisRunResult,
6
+ ReviewModelSelection,
7
+ ReviewProgress,
8
+ ReviewSnapshot,
9
+ } from "../types.ts";
6
10
 
7
11
  const DIFF_EXCERPT_CHAR_BUDGET = 12_000;
8
12
 
package/src/review.ts CHANGED
@@ -7,8 +7,8 @@ import { synthesizeReviewBrief } from "./history/synthesize.ts";
7
7
  import { normalizeReviewResult } from "./review-result.ts";
8
8
  import { buildReviewPacket } from "./target/packet.ts";
9
9
  import { runReviewer } from "./tool/review-runner.ts";
10
- import type { BriefSynthesisRunResult } from "./tool/runner-types.ts";
11
10
  import type {
11
+ BriefSynthesisRunResult,
12
12
  RawReviewResult,
13
13
  ReviewPlan,
14
14
  ReviewResult,
@@ -5,10 +5,60 @@ import type {
5
5
  ReviewSnapshot,
6
6
  SynthesizedReviewBrief,
7
7
  } from "../types.ts";
8
- import {
9
- type ReviewInstructionBlock,
10
- resolveReviewInstructionBlocks,
11
- } from "./review-instruction-blocks.ts";
8
+ export interface ReviewInstructionBlock {
9
+ id: ReviewInstructionBlockId;
10
+ title: string;
11
+ instruction: string;
12
+ }
13
+
14
+ const REVIEW_INSTRUCTION_BLOCKS: readonly ReviewInstructionBlock[] = [
15
+ {
16
+ id: "public-surface",
17
+ title: "Public-surface / rename / merge audit",
18
+ instruction:
19
+ "Sweep source, tests, docs, user-facing strings, and debug/status lists for stale public names after renames, removals, or merges.",
20
+ },
21
+ {
22
+ id: "cross-layer",
23
+ title: "Cross-layer propagation audit",
24
+ instruction:
25
+ "Verify every provider/runtime/orchestration/presentation/test handoff and look for at least one end-to-end expectation covering the threaded behavior.",
26
+ },
27
+ {
28
+ id: "schema-widening",
29
+ title: "Enum / operation / schema widening audit",
30
+ instruction:
31
+ "Audit validation, unavailable paths, branch coverage, aliases, and negative tests for widened enums, operations, or schemas.",
32
+ },
33
+ {
34
+ id: "cleanup",
35
+ title: "Cleanup / deletion / orphan audit",
36
+ instruction:
37
+ "Check for orphan files, dead imports or re-exports, stale comments, and outdated expectations after deletions or consumer removals.",
38
+ },
39
+ ];
40
+
41
+ /** Return the full fixed catalog of review instruction blocks. */
42
+ export function listReviewInstructionBlocks(): readonly ReviewInstructionBlock[] {
43
+ return REVIEW_INSTRUCTION_BLOCKS;
44
+ }
45
+
46
+ /** Resolve a brief-selected block ID list into canonical host-owned prompt blocks. */
47
+ export function resolveReviewInstructionBlocks(
48
+ ids: readonly ReviewInstructionBlockId[],
49
+ ): ReviewInstructionBlock[] {
50
+ const resolved: ReviewInstructionBlock[] = [];
51
+ const seen = new Set<ReviewInstructionBlockId>();
52
+
53
+ for (const id of ids) {
54
+ if (seen.has(id)) continue;
55
+ seen.add(id);
56
+ const block = REVIEW_INSTRUCTION_BLOCKS.find((b) => b.id === id);
57
+ if (block) resolved.push(block);
58
+ }
59
+
60
+ return resolved;
61
+ }
12
62
 
13
63
  export interface DiffSection {
14
64
  file: string;
@@ -7,13 +7,14 @@ import {
7
7
  defineTool,
8
8
  SessionManager,
9
9
  } from "@earendil-works/pi-coding-agent";
10
- import type { SynthesizedReviewBrief } from "../types.ts";
11
10
  import type {
12
11
  BriefSynthesisInvocation,
13
12
  BriefSynthesisRunResult,
14
- ReviewProgress,
15
- } from "./runner-types.ts";
13
+ SynthesizedReviewBrief,
14
+ } from "../types.ts";
15
+ import { buildProgressTokens, extractLastAssistantText } from "./runner-helpers.ts";
16
16
  import { reviewBriefSchema } from "./schemas.ts";
17
+ import { type LifecycleCtx, runWithLifecycle } from "./session-lifecycle.ts";
17
18
 
18
19
  const DEFAULT_TIMEOUT_MS = 5 * 60 * 1_000;
19
20
 
@@ -78,51 +79,12 @@ async function createBriefSession(
78
79
  return session;
79
80
  }
80
81
 
81
- function extractLastAssistantText(session: AgentSession): string | undefined {
82
- const messages = session.messages;
83
- for (let i = messages.length - 1; i >= 0; i--) {
84
- const message = messages[i];
85
- if (message?.role !== "assistant") continue;
86
- const text = extractAssistantText(message.content);
87
- if (text) return text;
88
- }
89
- return undefined;
90
- }
91
-
92
- function extractAssistantText(content: unknown): string | undefined {
93
- if (typeof content === "string") {
94
- return content.length > 0 ? content : undefined;
95
- }
96
-
97
- if (!Array.isArray(content)) {
98
- return undefined;
99
- }
100
-
101
- const texts = content
102
- .map((part) => {
103
- if (typeof part !== "object" || !part) return "";
104
- const text = (part as { text?: unknown }).text;
105
- return typeof text === "string" ? text : "";
106
- })
107
- .filter((value) => value.length > 0);
108
-
109
- return texts.length > 0 ? texts.join("\n") : undefined;
110
- }
111
-
112
- function emitProgress(session: AgentSession, progress: ReviewProgress): ReviewProgress {
113
- try {
114
- const stats = session.getSessionStats();
115
- progress.tokens = stats?.tokens
116
- ? {
117
- input: stats.tokens.input ?? 0,
118
- output: stats.tokens.output ?? 0,
119
- total: stats.tokens.total ?? 0,
120
- }
121
- : undefined;
122
- } catch {
123
- // ignore missing stats
124
- }
125
- return { ...progress, activities: [...progress.activities] };
82
+ function emitBriefProgress(
83
+ ctx: LifecycleCtx<BriefSynthesisRunResult>,
84
+ invocation: BriefSynthesisInvocation,
85
+ ): void {
86
+ ctx.progress.tokens = buildProgressTokens(() => ctx.session.getSessionStats());
87
+ invocation.onProgress?.({ ...ctx.progress, activities: [...ctx.progress.activities] });
126
88
  }
127
89
 
128
90
  function handleAgentEnd(options: {
@@ -140,7 +102,7 @@ function handleAgentEnd(options: {
140
102
  if (brief) {
141
103
  return cleanup({ kind: "success", brief });
142
104
  }
143
- const lastText = extractLastAssistantText(session);
105
+ const lastText = extractLastAssistantText(session.messages);
144
106
  return cleanup({
145
107
  kind: "failed",
146
108
  reason: lastText
@@ -170,99 +132,50 @@ export async function runBriefSynthesis(
170
132
  };
171
133
  }
172
134
 
173
- const progress: ReviewProgress = { turns: 0, toolUses: 0, activities: [], tokens: undefined };
174
- const state = { settled: false, aborting: false };
175
- let cancelTeardown: (() => void) | undefined;
176
-
177
- const cleanup = (result: BriefSynthesisRunResult): BriefSynthesisRunResult => {
178
- if (state.settled) return result;
179
- state.settled = true;
180
- cancelTeardown?.();
181
- session.dispose();
182
- return result;
183
- };
184
-
185
- const handleEvent = (
186
- event: AgentSessionEvent,
187
- resolve: (result: BriefSynthesisRunResult) => void,
188
- ) => {
189
- switch (event.type) {
190
- case "turn_end":
191
- progress.turns++;
192
- invocation.onProgress?.(emitProgress(session, progress));
193
- break;
194
- case "tool_execution_start":
195
- progress.toolUses++;
196
- progress.activities = [
197
- event.toolName === "submit_review_brief" ? "submitting brief" : event.toolName,
198
- ];
199
- invocation.onProgress?.(emitProgress(session, progress));
200
- break;
201
- case "tool_execution_end":
202
- progress.activities = [];
203
- invocation.onProgress?.(emitProgress(session, progress));
204
- break;
205
- case "agent_end": {
206
- const result = handleAgentEnd({
207
- event,
208
- session,
209
- brief: resultHolder.value,
210
- state,
211
- cleanup,
212
- });
213
- if (result) {
214
- resolve(result);
135
+ return runWithLifecycle<BriefSynthesisRunResult>({
136
+ session,
137
+ prompt: invocation.prompt,
138
+ signal: invocation.signal,
139
+ timeoutMs: invocation.timeoutMs ?? DEFAULT_TIMEOUT_MS,
140
+ onEvent: (event, ctx) => {
141
+ switch (event.type) {
142
+ case "turn_end":
143
+ ctx.progress.turns++;
144
+ emitBriefProgress(ctx, invocation);
145
+ break;
146
+ case "tool_execution_start":
147
+ ctx.progress.toolUses++;
148
+ ctx.progress.activities = [
149
+ event.toolName === "submit_review_brief" ? "submitting brief" : event.toolName,
150
+ ];
151
+ emitBriefProgress(ctx, invocation);
152
+ break;
153
+ case "tool_execution_end":
154
+ ctx.progress.activities = [];
155
+ emitBriefProgress(ctx, invocation);
156
+ break;
157
+ case "agent_end": {
158
+ const result = handleAgentEnd({
159
+ event,
160
+ session,
161
+ brief: resultHolder.value,
162
+ state: ctx.state,
163
+ cleanup: ctx.cleanup,
164
+ });
165
+ if (result) {
166
+ ctx.resolve(result);
167
+ }
168
+ break;
215
169
  }
216
- break;
170
+ default:
171
+ break;
217
172
  }
218
- default:
219
- break;
220
- }
221
- };
222
-
223
- return new Promise<BriefSynthesisRunResult>((resolve) => {
224
- session.subscribe((event: AgentSessionEvent) => handleEvent(event, resolve));
225
-
226
- const onAbort = () => {
227
- if (state.settled) return;
228
- state.aborting = true;
229
- void session
230
- .abort()
231
- .catch(() => {})
232
- .finally(() => {
233
- resolve(cleanup({ kind: "canceled" }));
234
- });
235
- };
236
- invocation.signal?.addEventListener("abort", onAbort, { once: true });
237
-
238
- const timeoutId = setTimeout(() => {
239
- if (state.settled) return;
240
- state.aborting = true;
241
- void session
242
- .abort()
243
- .catch(() => {})
244
- .finally(() => {
245
- resolve(
246
- cleanup({ kind: "timeout", timeoutMs: invocation.timeoutMs ?? DEFAULT_TIMEOUT_MS }),
247
- );
248
- });
249
- }, invocation.timeoutMs ?? DEFAULT_TIMEOUT_MS);
250
- timeoutId.unref?.();
251
-
252
- cancelTeardown = () => {
253
- invocation.signal?.removeEventListener("abort", onAbort);
254
- clearTimeout(timeoutId);
255
- };
256
-
257
- session.prompt(invocation.prompt).catch((error: unknown) => {
258
- if (!state.settled) {
259
- resolve(
260
- cleanup({
261
- kind: "failed",
262
- reason: `Brief synthesis session error: ${error instanceof Error ? error.message : String(error)}`,
263
- }),
264
- );
265
- }
266
- });
173
+ },
174
+ canceledResult: () => ({ kind: "canceled" as const }),
175
+ failedResult: (reason) => ({
176
+ kind: "failed" as const,
177
+ reason,
178
+ }),
179
+ timeoutResult: (ms) => ({ kind: "timeout" as const, timeoutMs: ms }),
267
180
  });
268
181
  }
@@ -0,0 +1,123 @@
1
+ import type { AgentSession, AgentSessionEvent } from "@earendil-works/pi-coding-agent";
2
+ import type { ReviewFailureDebugInfo, ReviewProgress } from "../types.ts";
3
+ import { buildProgressTokens, extractAssistantText } from "./runner-helpers.ts";
4
+
5
+ export const RECENT_EVENTS_MAX = 10;
6
+ export const LAST_ASSISTANT_TEXT_DEBUG_MAX = 2_000;
7
+
8
+ export interface LastAssistantDebugInfo {
9
+ text?: string;
10
+ stopReason?: string;
11
+ errorMessage?: string;
12
+ toolCalls?: string[];
13
+ }
14
+
15
+ export function extractLastAssistantDebugFromMessages(
16
+ messages: ArrayLike<Record<string, unknown>> | undefined,
17
+ ): LastAssistantDebugInfo | undefined {
18
+ if (!messages) return undefined;
19
+ for (let i = messages.length - 1; i >= 0; i--) {
20
+ const message = messages[i];
21
+ if (message?.role !== "assistant") continue;
22
+
23
+ const text = extractAssistantText(message.content);
24
+ const stopReason =
25
+ typeof message.stopReason === "string" ? (message.stopReason as string) : undefined;
26
+ const errorMessage =
27
+ typeof message.errorMessage === "string" ? (message.errorMessage as string) : undefined;
28
+ const toolCalls = extractAssistantToolCalls(message.content);
29
+
30
+ return {
31
+ text: text ? truncateText(text, LAST_ASSISTANT_TEXT_DEBUG_MAX) : undefined,
32
+ stopReason,
33
+ errorMessage,
34
+ toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
35
+ };
36
+ }
37
+ return undefined;
38
+ }
39
+
40
+ export function extractLastAssistantDebug(
41
+ session: AgentSession,
42
+ ): LastAssistantDebugInfo | undefined {
43
+ return extractLastAssistantDebugFromMessages(
44
+ session.messages as unknown as Array<Record<string, unknown>>,
45
+ );
46
+ }
47
+
48
+ function extractAssistantToolCalls(content: unknown): string[] {
49
+ if (!Array.isArray(content)) return [];
50
+
51
+ return content
52
+ .map((part) => {
53
+ if (typeof part !== "object" || !part) return undefined;
54
+ const toolPart = part as { type?: unknown; name?: unknown };
55
+ return toolPart.type === "toolCall" && typeof toolPart.name === "string"
56
+ ? toolPart.name
57
+ : undefined;
58
+ })
59
+ .filter((name): name is string => !!name);
60
+ }
61
+
62
+ export function summarizeSessionEvent(event: AgentSessionEvent): string | undefined {
63
+ switch (event.type) {
64
+ case "message_end": {
65
+ const message = event.message as unknown as Record<string, unknown>;
66
+ if (message.role !== "assistant") return undefined;
67
+ const stopReason =
68
+ typeof message.stopReason === "string" ? String(message.stopReason) : undefined;
69
+ const suffix = stopReason ? `:${stopReason}` : "";
70
+ return `assistant:end${suffix}`;
71
+ }
72
+ case "tool_execution_start":
73
+ return `tool:start:${event.toolName}`;
74
+ case "tool_execution_end":
75
+ return `tool:end:${event.toolName}${event.isError ? ":error" : ""}`;
76
+ case "turn_end":
77
+ return "turn:end";
78
+ case "agent_end":
79
+ return `agent:end${event.willRetry ? ":retry" : ""}`;
80
+ case "auto_retry_start":
81
+ return `retry:start:${event.attempt}/${event.maxAttempts}`;
82
+ case "auto_retry_end":
83
+ return `retry:end:${event.success ? "success" : "failed"}`;
84
+ default:
85
+ return undefined;
86
+ }
87
+ }
88
+
89
+ export function pushRecentEvent(recentEvents: string[], summary: string | undefined): void {
90
+ if (!summary) return;
91
+ recentEvents.push(summary);
92
+ if (recentEvents.length > RECENT_EVENTS_MAX) {
93
+ recentEvents.splice(0, recentEvents.length - RECENT_EVENTS_MAX);
94
+ }
95
+ }
96
+
97
+ export interface BuildFailureDebugInput {
98
+ progress: ReviewProgress;
99
+ session: AgentSession;
100
+ recentEvents: string[];
101
+ }
102
+
103
+ export function buildFailureDebug(input: BuildFailureDebugInput): ReviewFailureDebugInfo {
104
+ input.progress.tokens = buildProgressTokens(() => input.session.getSessionStats());
105
+ const lastAssistant = extractLastAssistantDebug(input.session);
106
+
107
+ return {
108
+ turns: input.progress.turns,
109
+ toolUses: input.progress.toolUses,
110
+ activities: input.progress.activities.length > 0 ? [...input.progress.activities] : undefined,
111
+ tokens: input.progress.tokens,
112
+ recentEvents: input.recentEvents.length > 0 ? [...input.recentEvents] : undefined,
113
+ lastAssistantText: lastAssistant?.text,
114
+ lastAssistantStopReason: lastAssistant?.stopReason,
115
+ lastAssistantErrorMessage: lastAssistant?.errorMessage,
116
+ lastAssistantToolCalls: lastAssistant?.toolCalls,
117
+ };
118
+ }
119
+
120
+ function truncateText(text: string, maxLen: number): string {
121
+ if (text.length <= maxLen) return text;
122
+ return `${text.slice(0, maxLen)}... (${text.length - maxLen} more chars)`;
123
+ }