pulls.review 0.2.1 → 0.3.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.
- package/README.md +5 -0
- package/dist/client/assets/ActionButton-V4dY2wPS.js +1 -0
- package/dist/client/assets/ActionIconButton-4b5xelMn.js +1 -0
- package/dist/client/assets/ChatMessage-Ccffh_8Z.js +80 -0
- package/dist/client/assets/ChatWidget-BFjNWi3R.js +1 -0
- package/dist/client/assets/DisplayFileIcon-DWj5Zxib.js +151 -0
- package/dist/client/assets/GithubAvatar-DGJrpCsX.js +1 -0
- package/dist/client/assets/LandingHero-DWdx6_8g.js +1 -0
- package/dist/client/assets/LocalAgentSettings-COjAszEL.js +1 -0
- package/dist/client/assets/ModelPicker-CDTiUMZ3.js +3 -0
- package/dist/client/assets/SelectViewport-DF4P4Akh.js +1 -0
- package/dist/client/assets/_repo_-D3lApLQL.js +22 -0
- package/dist/client/assets/analyze-BgWIIbl3.js +1 -0
- package/dist/client/assets/anthropic-messages-Btz8sjqp.js +41 -0
- package/dist/client/assets/browser-agent-rpc-BXhoSh1z-DrvKG05N.js +1 -0
- package/dist/client/assets/core-messages-ClW97ZVa.js +1 -0
- package/dist/client/assets/de-BoIOg_UW.js +1 -0
- package/dist/client/assets/diagnostics-wATZy2jc.js +1 -0
- package/dist/client/assets/diff-CNRGXNmw.js +1 -0
- package/dist/client/assets/{diff-BKQFn0my.js → diff-Cx43r-a6.js} +3 -3
- package/dist/client/assets/diffs-store-D3BzIMVi.js +1431 -0
- package/dist/client/assets/en-CbWYrCM5.js +1 -0
- package/dist/client/assets/es-CqEudTln.js +1 -0
- package/dist/client/assets/floating-ui.dom-CfpSQvPs.js +1 -0
- package/dist/client/assets/fr-qiQQ-4AO.js +1 -0
- package/dist/client/assets/i18n-Cx8anq4V.js +2 -0
- package/dist/client/assets/{id-NIGs9Xhn.js → id-BkDzu8gq.js} +1 -1
- package/dist/client/assets/index-De-UtOit.css +1 -0
- package/dist/client/assets/index-KDAJXf4E.js +2 -0
- package/dist/client/assets/install-D-S32JY7.js +4 -0
- package/dist/client/assets/it-tZZZbUKN.js +1 -0
- package/dist/client/assets/{ja-BAKkDsQ5.js → ja-kGPK4Fmt.js} +1 -1
- package/dist/client/assets/ko-CULiae40.js +1 -0
- package/dist/client/assets/llm-Le5CPsNn.js +88 -0
- package/dist/client/assets/llm-store-CzMnTJNa.js +1 -0
- package/dist/client/assets/local-agents-BQLn-Ppw.js +1 -0
- package/dist/client/assets/models-D651LTTp.js +8 -0
- package/dist/client/assets/nanoid-Cya-tyvy.js +1 -0
- package/dist/client/assets/node.browser-C0q8i5sA.js +1 -0
- package/dist/client/assets/{openai-completions-CSvY9IVo.js → openai-completions-DvmbMyzl.js} +5 -5
- package/dist/client/assets/pages-ByEfDjFa.js +1 -0
- package/dist/client/assets/pages-local-CVPpEWZ_.js +1 -0
- package/dist/client/assets/pages-web-BLxMupU5.js +21 -0
- package/dist/client/assets/portalTarget-DQg8GgWm.js +1 -0
- package/dist/client/assets/pt-BR-BcwoZLSs.js +1 -0
- package/dist/client/assets/rpc-backends-DYtuJyeF.js +1 -0
- package/dist/client/assets/ru-DOordgLF.js +1 -0
- package/dist/client/assets/runtime-dom.esm-bundler-Du_ZCcVN.js +2 -0
- package/dist/client/assets/{source-hSji-yvG.js → source-CvnvCX42.js} +1 -1
- package/dist/client/assets/sync-interval-oKvsW-lT.js +1 -0
- package/dist/client/assets/{transform-messages-C8U1xonk.js → transform-messages-Cg_2u7I2.js} +1 -1
- package/dist/client/assets/upload-DRO7bkBV.js +1 -0
- package/dist/client/assets/useDocumentTitle-BQJCa6Lm.js +92 -0
- package/dist/client/assets/useNonce-CwUrgryd.js +1 -0
- package/dist/client/assets/{vue-i18n-DVI_S4Q5.js → vue-i18n-CGHHpbTP.js} +3 -3
- package/dist/client/assets/zh-CN-CmTqGY9s.js +1 -0
- package/dist/client/assets/zh-TW-DKGtbJQy.js +1 -0
- package/dist/client/index.html +9 -8
- package/dist/devframe-DH2vpasJ.mjs +1124 -0
- package/dist/devframe.mjs +1 -1
- package/dist/index.mjs +28 -8
- package/package.json +7 -3
- package/dist/client/assets/ChatWidget-BsnoQ6oS.js +0 -1
- package/dist/client/assets/DisplayFileIcon-DzBo3Ez5.js +0 -151
- package/dist/client/assets/GithubAvatar-CSsUV5vf.js +0 -1
- package/dist/client/assets/LandingHero-BkTBSu71.js +0 -1
- package/dist/client/assets/_repo_-Dcgj3PGg.js +0 -22
- package/dist/client/assets/analyze-509KQQBC.js +0 -1
- package/dist/client/assets/anthropic-messages-Cd2DkgOC.js +0 -41
- package/dist/client/assets/browser-agent-rpc-BXhoSh1z-DrxWeI3_.js +0 -1
- package/dist/client/assets/de-Bq9RfdAn.js +0 -1
- package/dist/client/assets/diff-Bx6SazPw.js +0 -1
- package/dist/client/assets/diffs-store-ncE62OUU.js +0 -1510
- package/dist/client/assets/dist-Dh0a8u-Y.js +0 -1
- package/dist/client/assets/en-8hQdak1X.js +0 -1
- package/dist/client/assets/es-QcvEBShr.js +0 -1
- package/dist/client/assets/fr-DAWKVvDP.js +0 -1
- package/dist/client/assets/i18n-CGV_MDS8.js +0 -2
- package/dist/client/assets/index-BCCy9wJJ.js +0 -2
- package/dist/client/assets/index-BDPZiDIt.css +0 -1
- package/dist/client/assets/install-B-q5w2uE.js +0 -4
- package/dist/client/assets/it-DjRuurmk.js +0 -1
- package/dist/client/assets/ko-DHculY2W.js +0 -1
- package/dist/client/assets/llm-DYDmh8Ki.js +0 -2
- package/dist/client/assets/llm-store-CVM33nlB.js +0 -2
- package/dist/client/assets/node.browser-BX-QLlU2.js +0 -1
- package/dist/client/assets/pages-B5mEqsbn.js +0 -1
- package/dist/client/assets/pages-local-x_ACBinc.js +0 -1
- package/dist/client/assets/pages-web-DitAPN6U.js +0 -21
- package/dist/client/assets/portalTarget-C60SzJiK.js +0 -1
- package/dist/client/assets/pt-BR-LLtJ99P6.js +0 -1
- package/dist/client/assets/ru-DvkrOLxc.js +0 -1
- package/dist/client/assets/runtime-dom.esm-bundler-CqL7__VR.js +0 -2
- package/dist/client/assets/upload-GnI9kmJu.js +0 -1
- package/dist/client/assets/useDocumentTitle-nuqxXiNo.js +0 -185
- package/dist/client/assets/zh-CN-BIibd6kG.js +0 -1
- package/dist/client/assets/zh-TW-3FtUHjoZ.js +0 -1
- package/dist/devframe-CX5butdC.mjs +0 -493
|
@@ -0,0 +1,1124 @@
|
|
|
1
|
+
import { mkdir, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import process from "node:process";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
import { join, relative, resolve } from "node:path";
|
|
6
|
+
import { AGENT_SESSION_LOST, LOCAL_AGENT_CHANNEL, LOCAL_DEVFRAME_ID, LOCAL_RPC, LocalAgentInfoSchema } from "@pulls.review/core/local-rpc";
|
|
7
|
+
import { defineDevframe, defineRpcFunction } from "devframe";
|
|
8
|
+
import { LOCAL_AGENT_NAMES } from "@pulls.review/core/analyze";
|
|
9
|
+
import { LOCALES } from "@pulls.review/core/locales";
|
|
10
|
+
import { AgentSessionRefSchema, DiffsPayloadSchema } from "@pulls.review/core/types";
|
|
11
|
+
import * as v from "valibot";
|
|
12
|
+
import { randomUUID } from "node:crypto";
|
|
13
|
+
import { AnalysisSchema, CLI_AGENT_CHAT_SECTION, analysisJsonSchema, buildAnalysisPrompt, buildCliAgentSystemPrompt, describeCoverageIssues, findCoverageIssues, renderFilesAsText, toGroupedResult } from "@pulls.review/core/llm";
|
|
14
|
+
import { execFile, spawn } from "node:child_process";
|
|
15
|
+
import { createInterface } from "node:readline";
|
|
16
|
+
import { createLocalSource, readRepoInfo } from "@pulls.review/core/local";
|
|
17
|
+
import { githubTokenFromEnv } from "@pulls.review/core/env";
|
|
18
|
+
import fsDriver from "unstorage/drivers/fs";
|
|
19
|
+
//#region package.json
|
|
20
|
+
var name = "pulls.review";
|
|
21
|
+
var version = "0.3.1";
|
|
22
|
+
//#endregion
|
|
23
|
+
//#region src/agents/run.ts
|
|
24
|
+
var AgentRunError = class extends Error {
|
|
25
|
+
code;
|
|
26
|
+
constructor(message, code) {
|
|
27
|
+
super(message);
|
|
28
|
+
this.code = code;
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
/** The system prompt an agent gets: the role, the patch's location, and the answer's shape. */
|
|
32
|
+
function systemPromptFor(diff, patchPath, extra = "") {
|
|
33
|
+
return `${buildCliAgentSystemPrompt({
|
|
34
|
+
patchPath,
|
|
35
|
+
repository: diff.ref.kind === "local"
|
|
36
|
+
})}${extra}\n\n<schema>\nThe grouping JSON must match this JSON Schema:\n${JSON.stringify(analysisJsonSchema())}\n</schema>`;
|
|
37
|
+
}
|
|
38
|
+
/** The files an agent's tool call touches, for the "reading" progress line. */
|
|
39
|
+
function pathsOf(message, cwd) {
|
|
40
|
+
if (message.role !== "assistant") return [];
|
|
41
|
+
const paths = [];
|
|
42
|
+
for (const part of message.content) {
|
|
43
|
+
if (part.type !== "toolCall") continue;
|
|
44
|
+
const args = part.arguments;
|
|
45
|
+
const value = args.file_path ?? args.filePath ?? args.path ?? args.pattern ?? args.command;
|
|
46
|
+
if (typeof value !== "string" || !value) continue;
|
|
47
|
+
const rel = relative(cwd, value);
|
|
48
|
+
paths.push(value.startsWith("/") && rel && !rel.startsWith("..") ? rel : value);
|
|
49
|
+
}
|
|
50
|
+
return paths;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* One subprocess of the agent: appends its messages to `transcript` (emitting the live
|
|
54
|
+
* transcript and progress as it goes) and resolves with its final answer. Rejects with
|
|
55
|
+
* `AgentRunError` when the CLI fails or has lost the session; a run the caller aborts
|
|
56
|
+
* rejects too, and the caller reads its own signal.
|
|
57
|
+
*/
|
|
58
|
+
async function runTurn(cli, input, transcript, { emit, signal, cwd, model, step }) {
|
|
59
|
+
const progress = (next) => emit({
|
|
60
|
+
kind: "progress",
|
|
61
|
+
progress: next
|
|
62
|
+
});
|
|
63
|
+
const outcome = {};
|
|
64
|
+
let failure;
|
|
65
|
+
const onMessage = (message) => {
|
|
66
|
+
transcript.push(message);
|
|
67
|
+
emit({
|
|
68
|
+
kind: "messages",
|
|
69
|
+
messages: [...transcript]
|
|
70
|
+
});
|
|
71
|
+
const paths = pathsOf(message, cwd);
|
|
72
|
+
if (paths.length) progress({
|
|
73
|
+
step,
|
|
74
|
+
kind: "reading",
|
|
75
|
+
paths
|
|
76
|
+
});
|
|
77
|
+
else if (message.role === "assistant") progress({
|
|
78
|
+
step,
|
|
79
|
+
kind: "thinking"
|
|
80
|
+
});
|
|
81
|
+
};
|
|
82
|
+
const onExit = (exit) => {
|
|
83
|
+
if (signal.aborted) throw new AgentRunError("Aborted");
|
|
84
|
+
if (exit.sessionLost) throw new AgentRunError(`${cli.label} no longer has this conversation.`, AGENT_SESSION_LOST);
|
|
85
|
+
if (failure !== void 0 || exit.code !== 0 && !outcome.final) throw new AgentRunError(failure || exit.stderr || `${cli.label} exited with code ${exit.code}.`);
|
|
86
|
+
};
|
|
87
|
+
progress({
|
|
88
|
+
step,
|
|
89
|
+
kind: "thinking"
|
|
90
|
+
});
|
|
91
|
+
for await (const event of cli.run({
|
|
92
|
+
...input,
|
|
93
|
+
signal
|
|
94
|
+
})) switch (event.kind) {
|
|
95
|
+
case "session":
|
|
96
|
+
outcome.session = {
|
|
97
|
+
agent: cli.name,
|
|
98
|
+
id: event.id,
|
|
99
|
+
model: event.model ?? model
|
|
100
|
+
};
|
|
101
|
+
break;
|
|
102
|
+
case "message":
|
|
103
|
+
onMessage(event.message);
|
|
104
|
+
break;
|
|
105
|
+
case "final":
|
|
106
|
+
if (event.isError) failure = event.text ?? "";
|
|
107
|
+
else outcome.final = {
|
|
108
|
+
text: event.text,
|
|
109
|
+
structured: event.structured
|
|
110
|
+
};
|
|
111
|
+
break;
|
|
112
|
+
case "exit": onExit(event);
|
|
113
|
+
}
|
|
114
|
+
return outcome;
|
|
115
|
+
}
|
|
116
|
+
/** Writes the patch the agent reads; resolves with its path and a cleanup. */
|
|
117
|
+
async function writePatch(patchDir, id, diff) {
|
|
118
|
+
await mkdir(patchDir, { recursive: true });
|
|
119
|
+
const patchPath = join(patchDir, `${id}.patch`);
|
|
120
|
+
await writeFile(patchPath, renderFilesAsText(diff.files));
|
|
121
|
+
return {
|
|
122
|
+
patchPath,
|
|
123
|
+
cleanup: () => rm(patchPath, { force: true })
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
const JSON_FENCE = /```(?:json)?\n([\s\S]*?)\n```/g;
|
|
127
|
+
/** The last fenced ```json block in an answer, parsed; else the whole text when it is JSON. */
|
|
128
|
+
function parseJsonAnswer(text) {
|
|
129
|
+
if (!text) return void 0;
|
|
130
|
+
const candidate = [...text.matchAll(JSON_FENCE)].at(-1)?.[1] ?? text;
|
|
131
|
+
try {
|
|
132
|
+
return JSON.parse(candidate);
|
|
133
|
+
} catch {
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
/** The reply without the fenced grouping it carried - the browser path shows a tool call there, not JSON. */
|
|
138
|
+
function withoutJsonFence(text) {
|
|
139
|
+
const last = [...text.matchAll(JSON_FENCE)].at(-1);
|
|
140
|
+
return last ? `${text.slice(0, last.index)}${text.slice(last.index + last[0].length)}`.trim() : text;
|
|
141
|
+
}
|
|
142
|
+
//#endregion
|
|
143
|
+
//#region src/agents/analyze.ts
|
|
144
|
+
const RETRY_FIX = "Fix these and answer again with the complete grouping as JSON.";
|
|
145
|
+
/** The accepted grouping, or what to tell the agent to fix. */
|
|
146
|
+
function checkAnswer(diff, answer) {
|
|
147
|
+
const parsed = v.safeParse(AnalysisSchema, answer);
|
|
148
|
+
if (!parsed.success) return { fix: `The answer is not a valid grouping: ${v.summarize(parsed.issues)}\n${RETRY_FIX}` };
|
|
149
|
+
const fix = describeCoverageIssues(findCoverageIssues(diff, parsed.output), RETRY_FIX);
|
|
150
|
+
return fix ? { fix } : { analysis: parsed.output };
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Analyzes a diff through an agent CLI the way `runAgent` does through a model: the same
|
|
154
|
+
* prompt, the patch on disk instead of `read_diffs`, a JSON answer instead of
|
|
155
|
+
* `submit_grouping`, and one retry when the grouping misses or invents paths.
|
|
156
|
+
*/
|
|
157
|
+
async function runAgentAnalysis({ cli, model, diff, locale, cwd, patchDir, emit, signal }) {
|
|
158
|
+
const { patchPath, cleanup } = await writePatch(patchDir, randomUUID(), diff);
|
|
159
|
+
try {
|
|
160
|
+
const system = systemPromptFor(diff, patchPath);
|
|
161
|
+
const prompt = buildAnalysisPrompt(diff, locale);
|
|
162
|
+
const transcript = [{
|
|
163
|
+
role: "system",
|
|
164
|
+
content: system,
|
|
165
|
+
timestamp: Date.now()
|
|
166
|
+
}, {
|
|
167
|
+
role: "user",
|
|
168
|
+
content: prompt,
|
|
169
|
+
timestamp: Date.now()
|
|
170
|
+
}];
|
|
171
|
+
emit({
|
|
172
|
+
kind: "messages",
|
|
173
|
+
messages: [...transcript]
|
|
174
|
+
});
|
|
175
|
+
const turn = {
|
|
176
|
+
emit,
|
|
177
|
+
signal,
|
|
178
|
+
cwd,
|
|
179
|
+
model
|
|
180
|
+
};
|
|
181
|
+
let outcome = await runTurn(cli, {
|
|
182
|
+
cwd,
|
|
183
|
+
system,
|
|
184
|
+
prompt,
|
|
185
|
+
model,
|
|
186
|
+
sessionDir: patchDir
|
|
187
|
+
}, transcript, {
|
|
188
|
+
...turn,
|
|
189
|
+
step: 1
|
|
190
|
+
});
|
|
191
|
+
let answer = checkAnswer(diff, outcome.final?.structured ?? parseJsonAnswer(outcome.final?.text));
|
|
192
|
+
if ("fix" in answer) {
|
|
193
|
+
if (!outcome.session) throw new AgentRunError(answer.fix);
|
|
194
|
+
transcript.push({
|
|
195
|
+
role: "user",
|
|
196
|
+
content: answer.fix,
|
|
197
|
+
timestamp: Date.now()
|
|
198
|
+
});
|
|
199
|
+
const retry = await runTurn(cli, {
|
|
200
|
+
cwd,
|
|
201
|
+
system,
|
|
202
|
+
prompt: answer.fix,
|
|
203
|
+
model,
|
|
204
|
+
resume: outcome.session.id,
|
|
205
|
+
sessionDir: patchDir
|
|
206
|
+
}, transcript, {
|
|
207
|
+
...turn,
|
|
208
|
+
step: 2
|
|
209
|
+
});
|
|
210
|
+
outcome = {
|
|
211
|
+
session: retry.session ?? outcome.session,
|
|
212
|
+
final: retry.final
|
|
213
|
+
};
|
|
214
|
+
answer = checkAnswer(diff, outcome.final?.structured ?? parseJsonAnswer(outcome.final?.text));
|
|
215
|
+
if ("fix" in answer) throw new AgentRunError(answer.fix);
|
|
216
|
+
}
|
|
217
|
+
emit({
|
|
218
|
+
kind: "progress",
|
|
219
|
+
progress: {
|
|
220
|
+
step: 2,
|
|
221
|
+
kind: "organizing"
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
const stamp = `${cli.name}/${outcome.session?.model ?? model ?? "default"}`;
|
|
225
|
+
emit({
|
|
226
|
+
kind: "result",
|
|
227
|
+
result: toGroupedResult(diff, answer.analysis, stamp, locale)
|
|
228
|
+
});
|
|
229
|
+
emit({
|
|
230
|
+
kind: "end",
|
|
231
|
+
stopReason: "done",
|
|
232
|
+
agent: outcome.session
|
|
233
|
+
});
|
|
234
|
+
} finally {
|
|
235
|
+
await cleanup();
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
//#endregion
|
|
239
|
+
//#region src/agents/types.ts
|
|
240
|
+
const ZERO_USAGE = {
|
|
241
|
+
input: 0,
|
|
242
|
+
output: 0,
|
|
243
|
+
cacheRead: 0,
|
|
244
|
+
cacheWrite: 0,
|
|
245
|
+
totalTokens: 0,
|
|
246
|
+
cost: {
|
|
247
|
+
input: 0,
|
|
248
|
+
output: 0,
|
|
249
|
+
cacheRead: 0,
|
|
250
|
+
cacheWrite: 0,
|
|
251
|
+
total: 0
|
|
252
|
+
}
|
|
253
|
+
};
|
|
254
|
+
/** An agent's turn as the pi message the app's transcript and chat render. */
|
|
255
|
+
function assistantMessage(provider, model, content) {
|
|
256
|
+
return {
|
|
257
|
+
role: "assistant",
|
|
258
|
+
content,
|
|
259
|
+
api: "anthropic-messages",
|
|
260
|
+
provider,
|
|
261
|
+
model,
|
|
262
|
+
usage: ZERO_USAGE,
|
|
263
|
+
stopReason: content.some((part) => part.type === "toolCall") ? "toolUse" : "stop",
|
|
264
|
+
timestamp: Date.now()
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
function toolCall(id, name, args) {
|
|
268
|
+
return {
|
|
269
|
+
type: "toolCall",
|
|
270
|
+
id,
|
|
271
|
+
name,
|
|
272
|
+
arguments: args && typeof args === "object" && !Array.isArray(args) ? args : {}
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
function toolResultMessage(toolCallId, toolName, text, isError = false) {
|
|
276
|
+
return {
|
|
277
|
+
role: "toolResult",
|
|
278
|
+
toolCallId,
|
|
279
|
+
toolName,
|
|
280
|
+
content: [{
|
|
281
|
+
type: "text",
|
|
282
|
+
text
|
|
283
|
+
}],
|
|
284
|
+
isError,
|
|
285
|
+
timestamp: Date.now()
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
//#endregion
|
|
289
|
+
//#region src/agents/chat.ts
|
|
290
|
+
function lastUserText(messages) {
|
|
291
|
+
const last = messages.findLast((message) => message.role === "user");
|
|
292
|
+
if (!last || last.role !== "user") return void 0;
|
|
293
|
+
return typeof last.content === "string" ? last.content : last.content.map((part) => part.type === "text" ? part.text : "").join("");
|
|
294
|
+
}
|
|
295
|
+
/** Drops the fenced grouping from the reply as shown; the notice that follows says what it did. */
|
|
296
|
+
function stripGrouping(transcript) {
|
|
297
|
+
const index = transcript.findLastIndex((message) => message.role === "assistant");
|
|
298
|
+
const reply = transcript[index];
|
|
299
|
+
if (!reply || reply.role !== "assistant") return;
|
|
300
|
+
const content = reply.content.map((part) => part.type === "text" ? {
|
|
301
|
+
...part,
|
|
302
|
+
text: withoutJsonFence(part.text)
|
|
303
|
+
} : part).filter((part) => part.type !== "text" || part.text);
|
|
304
|
+
transcript[index] = {
|
|
305
|
+
...reply,
|
|
306
|
+
content
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* Continues an agent CLI session with the reviewer's question. A fenced JSON grouping in
|
|
311
|
+
* the reply is applied like `update_grouping`: validated, emitted as `result`, and noted
|
|
312
|
+
* in the transcript; one that fails validation is noted as a tool error instead.
|
|
313
|
+
*/
|
|
314
|
+
async function runAgentChat({ cli, model, diff, locale, cwd, patchDir, emit, signal, session, text }) {
|
|
315
|
+
const resume = session.agent;
|
|
316
|
+
if (!resume || resume.agent !== cli.name) throw new AgentRunError(`This conversation was not started with ${cli.label}. Re-analyze to chat with it.`);
|
|
317
|
+
const message = text ?? lastUserText(session.messages);
|
|
318
|
+
if (!message) throw new AgentRunError("Nothing to send.");
|
|
319
|
+
const { patchPath, cleanup } = await writePatch(patchDir, randomUUID(), diff);
|
|
320
|
+
try {
|
|
321
|
+
const system = systemPromptFor(diff, patchPath, `\n\n${CLI_AGENT_CHAT_SECTION}`);
|
|
322
|
+
const transcript = text === void 0 ? [...session.messages] : [...session.messages, {
|
|
323
|
+
role: "user",
|
|
324
|
+
content: text,
|
|
325
|
+
timestamp: Date.now()
|
|
326
|
+
}];
|
|
327
|
+
emit({
|
|
328
|
+
kind: "messages",
|
|
329
|
+
messages: [...transcript]
|
|
330
|
+
});
|
|
331
|
+
const outcome = await runTurn(cli, {
|
|
332
|
+
cwd,
|
|
333
|
+
system,
|
|
334
|
+
prompt: message,
|
|
335
|
+
model,
|
|
336
|
+
resume: resume.id,
|
|
337
|
+
sessionDir: patchDir
|
|
338
|
+
}, transcript, {
|
|
339
|
+
emit,
|
|
340
|
+
signal,
|
|
341
|
+
cwd,
|
|
342
|
+
model,
|
|
343
|
+
step: 1
|
|
344
|
+
});
|
|
345
|
+
const answer = parseJsonAnswer(outcome.final?.text);
|
|
346
|
+
if (answer !== void 0) {
|
|
347
|
+
stripGrouping(transcript);
|
|
348
|
+
const checked = checkAnswer(diff, answer);
|
|
349
|
+
if ("analysis" in checked) {
|
|
350
|
+
const stamp = `${cli.name}/${outcome.session?.model ?? resume.model ?? model ?? "default"}`;
|
|
351
|
+
emit({
|
|
352
|
+
kind: "result",
|
|
353
|
+
result: toGroupedResult(diff, checked.analysis, stamp, locale)
|
|
354
|
+
});
|
|
355
|
+
transcript.push(toolResultMessage("grouping", "update_grouping", `Grouping updated: ${checked.analysis.groups.length} groups.`));
|
|
356
|
+
} else transcript.push(toolResultMessage("grouping", "update_grouping", checked.fix, true));
|
|
357
|
+
emit({
|
|
358
|
+
kind: "messages",
|
|
359
|
+
messages: [...transcript]
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
emit({
|
|
363
|
+
kind: "end",
|
|
364
|
+
stopReason: "done",
|
|
365
|
+
agent: outcome.session ?? resume
|
|
366
|
+
});
|
|
367
|
+
} finally {
|
|
368
|
+
await cleanup();
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
//#endregion
|
|
372
|
+
//#region src/agents/process.ts
|
|
373
|
+
/**
|
|
374
|
+
* Runs a CLI that reads its prompt from stdin and prints one JSON object per stdout line,
|
|
375
|
+
* yielding each parsed line and,
|
|
376
|
+
* last, how the process ended. Lines that are not JSON (banners, warnings) are skipped.
|
|
377
|
+
* Aborting the signal kills the process.
|
|
378
|
+
*/
|
|
379
|
+
async function* spawnJsonLines(bin, args, { cwd, signal, stdin }) {
|
|
380
|
+
const child = spawn(bin, args, {
|
|
381
|
+
cwd,
|
|
382
|
+
stdio: [
|
|
383
|
+
"pipe",
|
|
384
|
+
"pipe",
|
|
385
|
+
"pipe"
|
|
386
|
+
],
|
|
387
|
+
signal
|
|
388
|
+
});
|
|
389
|
+
child.stdin.on("error", () => {});
|
|
390
|
+
child.stdin.end(stdin);
|
|
391
|
+
let stderr = "";
|
|
392
|
+
child.stderr.setEncoding("utf8");
|
|
393
|
+
child.stderr.on("data", (chunk) => stderr = (stderr + chunk).slice(-4e3));
|
|
394
|
+
const exited = new Promise((resolve, reject) => {
|
|
395
|
+
child.on("error", (error) => {
|
|
396
|
+
if (error.name === "AbortError") resolve(null);
|
|
397
|
+
else reject(error.code === "ENOENT" ? /* @__PURE__ */ new Error(`${bin} is not installed (not on PATH).`) : error);
|
|
398
|
+
});
|
|
399
|
+
child.on("close", resolve);
|
|
400
|
+
});
|
|
401
|
+
for await (const text of createInterface({
|
|
402
|
+
input: child.stdout,
|
|
403
|
+
crlfDelay: Infinity
|
|
404
|
+
})) try {
|
|
405
|
+
yield { line: JSON.parse(text) };
|
|
406
|
+
} catch {}
|
|
407
|
+
yield { exit: {
|
|
408
|
+
code: await exited,
|
|
409
|
+
stderr: stderr.trim()
|
|
410
|
+
} };
|
|
411
|
+
}
|
|
412
|
+
/** The version number in the CLI's `--version`, or `undefined` when it is not on `PATH` or fails to run. */
|
|
413
|
+
function readVersion(bin, args = ["--version"]) {
|
|
414
|
+
return new Promise((resolve) => {
|
|
415
|
+
const child = spawn(bin, args, { stdio: [
|
|
416
|
+
"ignore",
|
|
417
|
+
"pipe",
|
|
418
|
+
"ignore"
|
|
419
|
+
] });
|
|
420
|
+
let stdout = "";
|
|
421
|
+
child.stdout.setEncoding("utf8");
|
|
422
|
+
child.stdout.on("data", (chunk) => stdout += chunk);
|
|
423
|
+
child.on("error", () => resolve(void 0));
|
|
424
|
+
child.on("close", (code) => resolve(code === 0 ? stdout.match(/\d+\.\d[\w.-]*/)?.[0] : void 0));
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
//#endregion
|
|
428
|
+
//#region src/agents/claude.ts
|
|
429
|
+
/** Claude Code aliases; any full model id can still be typed. */
|
|
430
|
+
const MODELS = [
|
|
431
|
+
{
|
|
432
|
+
id: "sonnet",
|
|
433
|
+
name: "Sonnet (latest)"
|
|
434
|
+
},
|
|
435
|
+
{
|
|
436
|
+
id: "opus",
|
|
437
|
+
name: "Opus (latest)"
|
|
438
|
+
},
|
|
439
|
+
{
|
|
440
|
+
id: "haiku",
|
|
441
|
+
name: "Haiku (latest)"
|
|
442
|
+
}
|
|
443
|
+
];
|
|
444
|
+
const READ_ONLY_TOOLS$1 = [
|
|
445
|
+
"Read",
|
|
446
|
+
"Grep",
|
|
447
|
+
"Glob"
|
|
448
|
+
];
|
|
449
|
+
const WRITE_TOOLS = [
|
|
450
|
+
"Bash",
|
|
451
|
+
"Edit",
|
|
452
|
+
"Write",
|
|
453
|
+
"MultiEdit",
|
|
454
|
+
"NotebookEdit",
|
|
455
|
+
"WebFetch",
|
|
456
|
+
"WebSearch",
|
|
457
|
+
"Task"
|
|
458
|
+
];
|
|
459
|
+
/** `claude -p --output-format stream-json --verbose`: one JSON object per line, these shapes. */
|
|
460
|
+
const Text = v.object({
|
|
461
|
+
type: v.literal("text"),
|
|
462
|
+
text: v.string()
|
|
463
|
+
});
|
|
464
|
+
const Content = v.variant("type", [
|
|
465
|
+
Text,
|
|
466
|
+
v.object({
|
|
467
|
+
type: v.literal("tool_use"),
|
|
468
|
+
id: v.string(),
|
|
469
|
+
name: v.string(),
|
|
470
|
+
input: v.unknown()
|
|
471
|
+
}),
|
|
472
|
+
v.object({
|
|
473
|
+
type: v.literal("tool_result"),
|
|
474
|
+
tool_use_id: v.string(),
|
|
475
|
+
content: v.optional(v.union([v.string(), v.array(v.union([Text, v.looseObject({ type: v.string() })]))])),
|
|
476
|
+
is_error: v.optional(v.boolean())
|
|
477
|
+
})
|
|
478
|
+
]);
|
|
479
|
+
const Line$1 = v.variant("type", [
|
|
480
|
+
v.object({
|
|
481
|
+
type: v.literal("system"),
|
|
482
|
+
subtype: v.string(),
|
|
483
|
+
session_id: v.string(),
|
|
484
|
+
model: v.optional(v.string())
|
|
485
|
+
}),
|
|
486
|
+
v.object({
|
|
487
|
+
type: v.literal("assistant"),
|
|
488
|
+
message: v.object({
|
|
489
|
+
content: v.array(Content),
|
|
490
|
+
model: v.optional(v.string())
|
|
491
|
+
})
|
|
492
|
+
}),
|
|
493
|
+
v.object({
|
|
494
|
+
type: v.literal("user"),
|
|
495
|
+
message: v.object({ content: v.union([v.string(), v.array(Content)]) })
|
|
496
|
+
}),
|
|
497
|
+
v.object({
|
|
498
|
+
type: v.literal("result"),
|
|
499
|
+
is_error: v.boolean(),
|
|
500
|
+
result: v.optional(v.string()),
|
|
501
|
+
structured_output: v.optional(v.unknown()),
|
|
502
|
+
session_id: v.string()
|
|
503
|
+
})
|
|
504
|
+
]);
|
|
505
|
+
function textOf$1(content) {
|
|
506
|
+
if (typeof content === "string") return content;
|
|
507
|
+
return (content ?? []).map((part) => typeof part.text === "string" ? part.text : "").join("");
|
|
508
|
+
}
|
|
509
|
+
/** Claude validates the answer itself against the grouping schema, so `final.structured` is already parsed. */
|
|
510
|
+
function args$2({ system, model, resume }) {
|
|
511
|
+
const list = [
|
|
512
|
+
"-p",
|
|
513
|
+
"--output-format",
|
|
514
|
+
"stream-json",
|
|
515
|
+
"--verbose",
|
|
516
|
+
"--system-prompt",
|
|
517
|
+
system,
|
|
518
|
+
"--permission-mode",
|
|
519
|
+
"dontAsk",
|
|
520
|
+
"--allowedTools",
|
|
521
|
+
...READ_ONLY_TOOLS$1,
|
|
522
|
+
"--disallowedTools",
|
|
523
|
+
...WRITE_TOOLS,
|
|
524
|
+
"--json-schema",
|
|
525
|
+
JSON.stringify(analysisJsonSchema())
|
|
526
|
+
];
|
|
527
|
+
if (model) list.push("--model", model);
|
|
528
|
+
if (resume) list.push("--resume", resume);
|
|
529
|
+
return list;
|
|
530
|
+
}
|
|
531
|
+
/** Maps one stream line; `toolNames` remembers each call's tool for the result that follows. */
|
|
532
|
+
function mapLine(line, model, toolNames) {
|
|
533
|
+
switch (line.type) {
|
|
534
|
+
case "system": return line.subtype === "init" ? [{
|
|
535
|
+
kind: "session",
|
|
536
|
+
id: line.session_id,
|
|
537
|
+
model: line.model
|
|
538
|
+
}] : [];
|
|
539
|
+
case "assistant": {
|
|
540
|
+
const content = [];
|
|
541
|
+
for (const part of line.message.content) if (part.type === "text") content.push({
|
|
542
|
+
type: "text",
|
|
543
|
+
text: part.text
|
|
544
|
+
});
|
|
545
|
+
else if (part.type === "tool_use") {
|
|
546
|
+
toolNames.set(part.id, part.name);
|
|
547
|
+
content.push(toolCall(part.id, part.name, part.input));
|
|
548
|
+
}
|
|
549
|
+
return content.length ? [{
|
|
550
|
+
kind: "message",
|
|
551
|
+
message: assistantMessage("claude", line.message.model ?? model, content)
|
|
552
|
+
}] : [];
|
|
553
|
+
}
|
|
554
|
+
case "user":
|
|
555
|
+
if (typeof line.message.content === "string") return [];
|
|
556
|
+
return line.message.content.flatMap((part) => part.type === "tool_result" ? [{
|
|
557
|
+
kind: "message",
|
|
558
|
+
message: toolResultMessage(part.tool_use_id, toolNames.get(part.tool_use_id) ?? "tool", textOf$1(part.content), part.is_error ?? false)
|
|
559
|
+
}] : []);
|
|
560
|
+
case "result": return [{
|
|
561
|
+
kind: "final",
|
|
562
|
+
text: line.result,
|
|
563
|
+
structured: line.structured_output,
|
|
564
|
+
isError: line.is_error
|
|
565
|
+
}];
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
const claude = {
|
|
569
|
+
name: "claude",
|
|
570
|
+
label: "Claude Code",
|
|
571
|
+
detect: () => readVersion("claude"),
|
|
572
|
+
models: async () => MODELS,
|
|
573
|
+
async *run(input) {
|
|
574
|
+
let model = input.model ?? "claude";
|
|
575
|
+
const toolNames = /* @__PURE__ */ new Map();
|
|
576
|
+
for await (const item of spawnJsonLines("claude", args$2(input), {
|
|
577
|
+
cwd: input.cwd,
|
|
578
|
+
signal: input.signal,
|
|
579
|
+
stdin: input.prompt
|
|
580
|
+
})) {
|
|
581
|
+
if ("exit" in item) {
|
|
582
|
+
yield {
|
|
583
|
+
kind: "exit",
|
|
584
|
+
...item.exit,
|
|
585
|
+
sessionLost: /No conversation found/i.test(item.exit.stderr)
|
|
586
|
+
};
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
const parsed = v.safeParse(Line$1, item.line);
|
|
590
|
+
if (!parsed.success) continue;
|
|
591
|
+
for (const event of mapLine(parsed.output, model, toolNames)) {
|
|
592
|
+
if (event.kind === "session" && event.model) model = event.model;
|
|
593
|
+
yield event;
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
};
|
|
598
|
+
//#endregion
|
|
599
|
+
//#region src/agents/opencode.ts
|
|
600
|
+
/** `opencode run --format json`: one event per line, each a part of the assistant's message. */
|
|
601
|
+
const Part = v.variant("type", [
|
|
602
|
+
v.object({
|
|
603
|
+
type: v.literal("text"),
|
|
604
|
+
text: v.string()
|
|
605
|
+
}),
|
|
606
|
+
v.object({
|
|
607
|
+
type: v.literal("tool"),
|
|
608
|
+
tool: v.string(),
|
|
609
|
+
callID: v.string(),
|
|
610
|
+
state: v.object({
|
|
611
|
+
status: v.string(),
|
|
612
|
+
input: v.optional(v.unknown()),
|
|
613
|
+
output: v.optional(v.string()),
|
|
614
|
+
error: v.optional(v.string())
|
|
615
|
+
})
|
|
616
|
+
}),
|
|
617
|
+
v.object({ type: v.literal("step-start") }),
|
|
618
|
+
v.object({ type: v.literal("step-finish") })
|
|
619
|
+
]);
|
|
620
|
+
const Line = v.object({
|
|
621
|
+
type: v.string(),
|
|
622
|
+
sessionID: v.optional(v.string()),
|
|
623
|
+
part: v.optional(Part)
|
|
624
|
+
});
|
|
625
|
+
/** `opencode models` prints one `provider/model` id per line; the signed-in providers only. */
|
|
626
|
+
async function listModels() {
|
|
627
|
+
const { stdout } = await promisify(execFile)("opencode", ["models"], { maxBuffer: 1048576 });
|
|
628
|
+
return stdout.split("\n").map((line) => line.trim()).filter((line) => line && !line.startsWith("{")).map((id) => ({
|
|
629
|
+
id,
|
|
630
|
+
name: id
|
|
631
|
+
}));
|
|
632
|
+
}
|
|
633
|
+
function args$1({ model, resume }) {
|
|
634
|
+
const list = [
|
|
635
|
+
"run",
|
|
636
|
+
"--format",
|
|
637
|
+
"json",
|
|
638
|
+
"--agent",
|
|
639
|
+
"plan"
|
|
640
|
+
];
|
|
641
|
+
if (model) list.push("--model", model);
|
|
642
|
+
if (resume) list.push("--session", resume);
|
|
643
|
+
return list;
|
|
644
|
+
}
|
|
645
|
+
function mapPart(part, model) {
|
|
646
|
+
if (part.type === "text") return [{
|
|
647
|
+
kind: "message",
|
|
648
|
+
message: assistantMessage("opencode", model, [{
|
|
649
|
+
type: "text",
|
|
650
|
+
text: part.text
|
|
651
|
+
}])
|
|
652
|
+
}];
|
|
653
|
+
if (part.type !== "tool") return [];
|
|
654
|
+
const failed = part.state.status === "error";
|
|
655
|
+
return [{
|
|
656
|
+
kind: "message",
|
|
657
|
+
message: assistantMessage("opencode", model, [toolCall(part.callID, part.tool, part.state.input)])
|
|
658
|
+
}, {
|
|
659
|
+
kind: "message",
|
|
660
|
+
message: toolResultMessage(part.callID, part.tool, failed ? part.state.error ?? "failed" : part.state.output ?? "", failed)
|
|
661
|
+
}];
|
|
662
|
+
}
|
|
663
|
+
const opencode = {
|
|
664
|
+
name: "opencode",
|
|
665
|
+
label: "OpenCode",
|
|
666
|
+
detect: () => readVersion("opencode"),
|
|
667
|
+
models: () => listModels().catch(() => []),
|
|
668
|
+
async *run(input) {
|
|
669
|
+
const { cwd, system, prompt, model, signal } = input;
|
|
670
|
+
let session;
|
|
671
|
+
let lastText;
|
|
672
|
+
for await (const item of spawnJsonLines("opencode", args$1(input), {
|
|
673
|
+
cwd,
|
|
674
|
+
signal,
|
|
675
|
+
stdin: `${system}\n\n${prompt}`
|
|
676
|
+
})) {
|
|
677
|
+
if ("exit" in item) {
|
|
678
|
+
if (item.exit.code === 0) yield {
|
|
679
|
+
kind: "final",
|
|
680
|
+
text: lastText
|
|
681
|
+
};
|
|
682
|
+
yield {
|
|
683
|
+
kind: "exit",
|
|
684
|
+
...item.exit,
|
|
685
|
+
sessionLost: /Session not found/i.test(item.exit.stderr)
|
|
686
|
+
};
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
689
|
+
const parsed = v.safeParse(Line, item.line);
|
|
690
|
+
if (!parsed.success) continue;
|
|
691
|
+
const { sessionID, part } = parsed.output;
|
|
692
|
+
if (sessionID && sessionID !== session) {
|
|
693
|
+
session = sessionID;
|
|
694
|
+
yield {
|
|
695
|
+
kind: "session",
|
|
696
|
+
id: sessionID
|
|
697
|
+
};
|
|
698
|
+
}
|
|
699
|
+
if (part?.type === "text") lastText = part.text;
|
|
700
|
+
if (part) yield* mapPart(part, model ?? "opencode");
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
};
|
|
704
|
+
//#endregion
|
|
705
|
+
//#region src/agents/pi.ts
|
|
706
|
+
const READ_ONLY_TOOLS = "read,grep,find,ls";
|
|
707
|
+
function args(input, sessionId) {
|
|
708
|
+
const list = [
|
|
709
|
+
"--mode",
|
|
710
|
+
"json",
|
|
711
|
+
input.resume ? "--session" : "--session-id",
|
|
712
|
+
input.resume ?? sessionId,
|
|
713
|
+
"--name",
|
|
714
|
+
"pulls.review",
|
|
715
|
+
"--system-prompt",
|
|
716
|
+
input.system,
|
|
717
|
+
"--tools",
|
|
718
|
+
READ_ONLY_TOOLS,
|
|
719
|
+
"--no-skills",
|
|
720
|
+
"--no-prompt-templates",
|
|
721
|
+
"--no-context-files",
|
|
722
|
+
"--no-approve"
|
|
723
|
+
];
|
|
724
|
+
if (input.sessionDir) list.push("--session-dir", input.sessionDir);
|
|
725
|
+
if (input.model) list.push("--model", input.model);
|
|
726
|
+
else if (process.env.PI_PROVIDER && process.env.PI_MODEL) list.push("--provider", process.env.PI_PROVIDER, "--model", process.env.PI_MODEL);
|
|
727
|
+
return list;
|
|
728
|
+
}
|
|
729
|
+
function record(value) {
|
|
730
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : void 0;
|
|
731
|
+
}
|
|
732
|
+
function isAssistantMessage(value) {
|
|
733
|
+
const message = record(value);
|
|
734
|
+
return message?.role === "assistant" && Array.isArray(message.content);
|
|
735
|
+
}
|
|
736
|
+
function isToolResultMessage(value) {
|
|
737
|
+
const message = record(value);
|
|
738
|
+
return message?.role === "toolResult" && Array.isArray(message.content);
|
|
739
|
+
}
|
|
740
|
+
function textOf(message) {
|
|
741
|
+
return message.content.filter((part) => part.type === "text").map((part) => part.text).join("") || void 0;
|
|
742
|
+
}
|
|
743
|
+
function modelOf(message) {
|
|
744
|
+
return message.provider && message.model ? `${message.provider}/${message.model}` : message.model;
|
|
745
|
+
}
|
|
746
|
+
function finalEvent(state) {
|
|
747
|
+
return {
|
|
748
|
+
kind: "final",
|
|
749
|
+
text: state.finalError ?? state.finalText,
|
|
750
|
+
isError: state.finalError !== void 0
|
|
751
|
+
};
|
|
752
|
+
}
|
|
753
|
+
function isLostSession(stderr) {
|
|
754
|
+
return /session.*not found/i.test(stderr) || /no session found/i.test(stderr) || /session.*does not exist/i.test(stderr) || /session found in different project/i.test(stderr);
|
|
755
|
+
}
|
|
756
|
+
function messageEvents(value, state) {
|
|
757
|
+
if (isToolResultMessage(value)) return [{
|
|
758
|
+
kind: "message",
|
|
759
|
+
message: value
|
|
760
|
+
}];
|
|
761
|
+
if (!isAssistantMessage(value)) return [];
|
|
762
|
+
const events = [{
|
|
763
|
+
kind: "message",
|
|
764
|
+
message: value
|
|
765
|
+
}];
|
|
766
|
+
const model = modelOf(value);
|
|
767
|
+
if (state.session && model && model !== state.model) {
|
|
768
|
+
state.model = model;
|
|
769
|
+
events.push({
|
|
770
|
+
kind: "session",
|
|
771
|
+
id: state.session,
|
|
772
|
+
model
|
|
773
|
+
});
|
|
774
|
+
}
|
|
775
|
+
const text = textOf(value);
|
|
776
|
+
if (text) state.finalText = text;
|
|
777
|
+
if (value.stopReason === "error" || value.stopReason === "aborted") state.finalError = value.errorMessage ?? text ?? `Pi stopped: ${value.stopReason}`;
|
|
778
|
+
return events;
|
|
779
|
+
}
|
|
780
|
+
function lineEvents(value, state) {
|
|
781
|
+
const line = record(value);
|
|
782
|
+
if (!line || typeof line.type !== "string") return [];
|
|
783
|
+
if (line.type === "session" && typeof line.id === "string") {
|
|
784
|
+
state.session = line.id;
|
|
785
|
+
return [{
|
|
786
|
+
kind: "session",
|
|
787
|
+
id: line.id
|
|
788
|
+
}];
|
|
789
|
+
}
|
|
790
|
+
if (line.type === "message_end") return messageEvents(line.message, state);
|
|
791
|
+
if (line.type === "agent_settled" && !state.finalSent) {
|
|
792
|
+
state.finalSent = true;
|
|
793
|
+
return [finalEvent(state)];
|
|
794
|
+
}
|
|
795
|
+
return [];
|
|
796
|
+
}
|
|
797
|
+
//#endregion
|
|
798
|
+
//#region src/agents/index.ts
|
|
799
|
+
const agents = {
|
|
800
|
+
claude,
|
|
801
|
+
opencode,
|
|
802
|
+
pi: {
|
|
803
|
+
name: "pi",
|
|
804
|
+
label: "Pi",
|
|
805
|
+
detect: () => readVersion("pi"),
|
|
806
|
+
models: async () => [],
|
|
807
|
+
async *run(input) {
|
|
808
|
+
const state = {
|
|
809
|
+
session: input.resume,
|
|
810
|
+
finalSent: false
|
|
811
|
+
};
|
|
812
|
+
const sessionId = randomUUID();
|
|
813
|
+
for await (const item of spawnJsonLines("pi", args(input, sessionId), {
|
|
814
|
+
cwd: input.cwd,
|
|
815
|
+
signal: input.signal,
|
|
816
|
+
stdin: input.prompt
|
|
817
|
+
})) {
|
|
818
|
+
if ("line" in item) {
|
|
819
|
+
yield* lineEvents(item.line, state);
|
|
820
|
+
continue;
|
|
821
|
+
}
|
|
822
|
+
if (!state.finalSent && (state.finalText !== void 0 || state.finalError !== void 0)) yield finalEvent(state);
|
|
823
|
+
yield {
|
|
824
|
+
kind: "exit",
|
|
825
|
+
...item.exit,
|
|
826
|
+
sessionLost: isLostSession(item.exit.stderr)
|
|
827
|
+
};
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
};
|
|
832
|
+
/** The agent CLIs on `PATH`, with their versions and model catalogs. */
|
|
833
|
+
async function detectAgents() {
|
|
834
|
+
return (await Promise.all(Object.values(agents).map(async (cli) => {
|
|
835
|
+
const version = await cli.detect();
|
|
836
|
+
return version ? [{
|
|
837
|
+
name: cli.name,
|
|
838
|
+
label: cli.label,
|
|
839
|
+
version,
|
|
840
|
+
models: await cli.models()
|
|
841
|
+
}] : [];
|
|
842
|
+
}))).flat();
|
|
843
|
+
}
|
|
844
|
+
//#endregion
|
|
845
|
+
//#region src/agents/rpc.ts
|
|
846
|
+
const agentName = v.picklist(LOCAL_AGENT_NAMES);
|
|
847
|
+
const locale = v.picklist(LOCALES.map((entry) => entry.code));
|
|
848
|
+
const AnalyzeArgs = v.object({
|
|
849
|
+
agent: agentName,
|
|
850
|
+
model: v.optional(v.string()),
|
|
851
|
+
diff: DiffsPayloadSchema,
|
|
852
|
+
locale
|
|
853
|
+
});
|
|
854
|
+
const ChatArgs = v.object({
|
|
855
|
+
...AnalyzeArgs.entries,
|
|
856
|
+
session: v.object({
|
|
857
|
+
messages: v.array(v.looseObject({ role: v.string() })),
|
|
858
|
+
chatStartIndex: v.number(),
|
|
859
|
+
agent: v.optional(AgentSessionRefSchema)
|
|
860
|
+
}),
|
|
861
|
+
text: v.optional(v.string())
|
|
862
|
+
});
|
|
863
|
+
/** `agent.*`: analysis and chat through a local agent CLI, streamed on `channel` (`plans/11-local-agents.md`). */
|
|
864
|
+
function agentRpcFunctions({ cwd, patchDir, channel }) {
|
|
865
|
+
const running = /* @__PURE__ */ new Map();
|
|
866
|
+
let detected;
|
|
867
|
+
/** Starts `run` on a fresh stream and returns its id at once; the stream carries the outcome. */
|
|
868
|
+
function start(args, run) {
|
|
869
|
+
const sink = channel.start();
|
|
870
|
+
const controller = new AbortController();
|
|
871
|
+
running.set(sink.id, controller);
|
|
872
|
+
sink.signal.addEventListener("abort", () => controller.abort());
|
|
873
|
+
(async () => {
|
|
874
|
+
const scratch = args.diff.ref.kind === "local" ? void 0 : join(patchDir, "workspace");
|
|
875
|
+
if (scratch) await mkdir(scratch, { recursive: true });
|
|
876
|
+
try {
|
|
877
|
+
await run({
|
|
878
|
+
cli: agents[args.agent],
|
|
879
|
+
model: args.model || void 0,
|
|
880
|
+
diff: args.diff,
|
|
881
|
+
locale: args.locale,
|
|
882
|
+
cwd: scratch ?? cwd,
|
|
883
|
+
patchDir,
|
|
884
|
+
emit: (event) => sink.write(event),
|
|
885
|
+
signal: controller.signal
|
|
886
|
+
});
|
|
887
|
+
} catch (error) {
|
|
888
|
+
if (controller.signal.aborted) sink.write({
|
|
889
|
+
kind: "end",
|
|
890
|
+
stopReason: "aborted"
|
|
891
|
+
});
|
|
892
|
+
else if (error instanceof AgentRunError) sink.write({
|
|
893
|
+
kind: "end",
|
|
894
|
+
stopReason: "error",
|
|
895
|
+
error: error.message,
|
|
896
|
+
code: error.code === AGENT_SESSION_LOST ? error.code : void 0
|
|
897
|
+
});
|
|
898
|
+
else sink.write({
|
|
899
|
+
kind: "end",
|
|
900
|
+
stopReason: "error",
|
|
901
|
+
error: error instanceof Error ? error.message : String(error)
|
|
902
|
+
});
|
|
903
|
+
} finally {
|
|
904
|
+
running.delete(sink.id);
|
|
905
|
+
sink.close();
|
|
906
|
+
}
|
|
907
|
+
})();
|
|
908
|
+
return { streamId: sink.id };
|
|
909
|
+
}
|
|
910
|
+
return [
|
|
911
|
+
defineRpcFunction({
|
|
912
|
+
name: LOCAL_RPC.agentList,
|
|
913
|
+
type: "query",
|
|
914
|
+
args: [],
|
|
915
|
+
returns: v.array(LocalAgentInfoSchema),
|
|
916
|
+
handler: () => detected ??= detectAgents()
|
|
917
|
+
}),
|
|
918
|
+
defineRpcFunction({
|
|
919
|
+
name: LOCAL_RPC.agentAnalyze,
|
|
920
|
+
type: "query",
|
|
921
|
+
args: [AnalyzeArgs],
|
|
922
|
+
returns: v.object({ streamId: v.string() }),
|
|
923
|
+
handler: (args) => start(args, runAgentAnalysis)
|
|
924
|
+
}),
|
|
925
|
+
defineRpcFunction({
|
|
926
|
+
name: LOCAL_RPC.agentChat,
|
|
927
|
+
type: "query",
|
|
928
|
+
args: [ChatArgs],
|
|
929
|
+
returns: v.object({ streamId: v.string() }),
|
|
930
|
+
handler: ({ session, text, ...args }) => start(args, (options) => runAgentChat({
|
|
931
|
+
...options,
|
|
932
|
+
session,
|
|
933
|
+
text
|
|
934
|
+
}))
|
|
935
|
+
}),
|
|
936
|
+
defineRpcFunction({
|
|
937
|
+
name: LOCAL_RPC.agentAbort,
|
|
938
|
+
type: "action",
|
|
939
|
+
args: [v.object({ streamId: v.string() })],
|
|
940
|
+
returns: v.void(),
|
|
941
|
+
handler: ({ streamId }) => {
|
|
942
|
+
running.get(streamId)?.abort();
|
|
943
|
+
}
|
|
944
|
+
})
|
|
945
|
+
];
|
|
946
|
+
}
|
|
947
|
+
//#endregion
|
|
948
|
+
//#region src/credentials.ts
|
|
949
|
+
/** The environment first, then the GitHub CLI's login, if `gh` is installed and signed in. */
|
|
950
|
+
async function resolveGithubToken(env) {
|
|
951
|
+
const fromEnv = githubTokenFromEnv(env);
|
|
952
|
+
if (fromEnv) return fromEnv;
|
|
953
|
+
try {
|
|
954
|
+
const { stdout } = await promisify(execFile)("gh", ["auth", "token"]);
|
|
955
|
+
return stdout.trim() || void 0;
|
|
956
|
+
} catch {
|
|
957
|
+
return;
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
//#endregion
|
|
961
|
+
//#region src/rpc.ts
|
|
962
|
+
const target = v.object({ target: v.string() });
|
|
963
|
+
/**
|
|
964
|
+
* Every function the `PR_LOCAL` SPA calls. Only a client devframe has trusted
|
|
965
|
+
* reaches them; inputs are still validated, since they decide what `git` runs.
|
|
966
|
+
*/
|
|
967
|
+
function localRpcFunctions({ cwd, driver, env }) {
|
|
968
|
+
const source = (text) => createLocalSource({
|
|
969
|
+
cwd,
|
|
970
|
+
target: text
|
|
971
|
+
});
|
|
972
|
+
return [
|
|
973
|
+
defineRpcFunction({
|
|
974
|
+
name: LOCAL_RPC.repoInfo,
|
|
975
|
+
type: "query",
|
|
976
|
+
handler: () => readRepoInfo(cwd)
|
|
977
|
+
}),
|
|
978
|
+
defineRpcFunction({
|
|
979
|
+
name: LOCAL_RPC.sourceKey,
|
|
980
|
+
type: "query",
|
|
981
|
+
args: [target],
|
|
982
|
+
returns: v.string(),
|
|
983
|
+
handler: ({ target }) => source(target).key()
|
|
984
|
+
}),
|
|
985
|
+
defineRpcFunction({
|
|
986
|
+
name: LOCAL_RPC.sourceFetch,
|
|
987
|
+
type: "query",
|
|
988
|
+
args: [target],
|
|
989
|
+
returns: DiffsPayloadSchema,
|
|
990
|
+
handler: ({ target }) => source(target).fetch()
|
|
991
|
+
}),
|
|
992
|
+
defineRpcFunction({
|
|
993
|
+
name: LOCAL_RPC.sourceFingerprint,
|
|
994
|
+
type: "query",
|
|
995
|
+
args: [target],
|
|
996
|
+
returns: v.string(),
|
|
997
|
+
handler: ({ target }) => source(target).fingerprint()
|
|
998
|
+
}),
|
|
999
|
+
defineRpcFunction({
|
|
1000
|
+
name: LOCAL_RPC.sourceLoadFile,
|
|
1001
|
+
type: "query",
|
|
1002
|
+
args: [v.object({
|
|
1003
|
+
target: v.string(),
|
|
1004
|
+
path: v.string(),
|
|
1005
|
+
sha: v.string()
|
|
1006
|
+
})],
|
|
1007
|
+
returns: v.optional(v.string()),
|
|
1008
|
+
handler: ({ target, path, sha }) => source(target).loadFile(path, sha)
|
|
1009
|
+
}),
|
|
1010
|
+
defineRpcFunction({
|
|
1011
|
+
name: LOCAL_RPC.storageGetItem,
|
|
1012
|
+
type: "query",
|
|
1013
|
+
args: [v.object({ key: v.string() })],
|
|
1014
|
+
returns: v.nullable(v.string()),
|
|
1015
|
+
handler: async ({ key }) => {
|
|
1016
|
+
const value = await driver.getItem(key, {});
|
|
1017
|
+
return typeof value === "string" ? value : null;
|
|
1018
|
+
}
|
|
1019
|
+
}),
|
|
1020
|
+
defineRpcFunction({
|
|
1021
|
+
name: LOCAL_RPC.storageSetItem,
|
|
1022
|
+
type: "action",
|
|
1023
|
+
args: [v.object({
|
|
1024
|
+
key: v.string(),
|
|
1025
|
+
value: v.string()
|
|
1026
|
+
})],
|
|
1027
|
+
returns: v.void(),
|
|
1028
|
+
handler: async ({ key, value }) => {
|
|
1029
|
+
await driver.setItem?.(key, value, {});
|
|
1030
|
+
}
|
|
1031
|
+
}),
|
|
1032
|
+
defineRpcFunction({
|
|
1033
|
+
name: LOCAL_RPC.storageRemoveItem,
|
|
1034
|
+
type: "action",
|
|
1035
|
+
args: [v.object({ key: v.string() })],
|
|
1036
|
+
returns: v.void(),
|
|
1037
|
+
handler: async ({ key }) => {
|
|
1038
|
+
await driver.removeItem?.(key, {});
|
|
1039
|
+
}
|
|
1040
|
+
}),
|
|
1041
|
+
defineRpcFunction({
|
|
1042
|
+
name: LOCAL_RPC.storageGetKeys,
|
|
1043
|
+
type: "query",
|
|
1044
|
+
args: [v.object({ base: v.string() })],
|
|
1045
|
+
returns: v.array(v.string()),
|
|
1046
|
+
handler: ({ base }) => driver.getKeys(base, {})
|
|
1047
|
+
}),
|
|
1048
|
+
defineRpcFunction({
|
|
1049
|
+
name: LOCAL_RPC.githubToken,
|
|
1050
|
+
type: "query",
|
|
1051
|
+
handler: () => resolveGithubToken(env)
|
|
1052
|
+
})
|
|
1053
|
+
];
|
|
1054
|
+
}
|
|
1055
|
+
//#endregion
|
|
1056
|
+
//#region src/storage.ts
|
|
1057
|
+
/**
|
|
1058
|
+
* The fs driver maps every `:` of a key to a directory, so `a:b` (a file) and `a:b:c`
|
|
1059
|
+
* (needing `a/b/` as a directory) can't both exist. Keys stay one directory per
|
|
1060
|
+
* collection prefix (`pr-meta:`, `review:`, ...) with the rest escaped into one file name.
|
|
1061
|
+
*/
|
|
1062
|
+
function flatKeys(driver) {
|
|
1063
|
+
const encode = (key) => {
|
|
1064
|
+
const [head = "", ...rest] = key.split(":");
|
|
1065
|
+
return rest.length ? `${head}:${encodeURIComponent(rest.join(":"))}` : head;
|
|
1066
|
+
};
|
|
1067
|
+
const decode = (key) => {
|
|
1068
|
+
const [head = "", ...rest] = key.split(/[:/]/);
|
|
1069
|
+
return rest.length ? `${head}:${decodeURIComponent(rest.join(":"))}` : head;
|
|
1070
|
+
};
|
|
1071
|
+
return {
|
|
1072
|
+
...driver,
|
|
1073
|
+
hasItem: (key, opts) => driver.hasItem(encode(key), opts),
|
|
1074
|
+
getItem: (key, opts) => driver.getItem(encode(key), opts),
|
|
1075
|
+
setItem: (key, value, opts) => driver.setItem?.(encode(key), value, opts),
|
|
1076
|
+
removeItem: (key, opts) => driver.removeItem?.(encode(key), opts),
|
|
1077
|
+
getKeys: async (base, opts) => (await driver.getKeys(encode(base.replace(/:$/, "")), opts)).map(decode)
|
|
1078
|
+
};
|
|
1079
|
+
}
|
|
1080
|
+
/** `<git-common-dir>/pulls-review`: shared by every worktree of the repo, never committed. */
|
|
1081
|
+
async function repoCacheDir(cwd) {
|
|
1082
|
+
const { stdout } = await promisify(execFile)("git", ["rev-parse", "--git-common-dir"], { cwd });
|
|
1083
|
+
return resolve(cwd, stdout.trim(), "pulls-review");
|
|
1084
|
+
}
|
|
1085
|
+
async function createRepoCacheDriver(cwd) {
|
|
1086
|
+
return flatKeys(fsDriver({ base: await repoCacheDir(cwd) }));
|
|
1087
|
+
}
|
|
1088
|
+
//#endregion
|
|
1089
|
+
//#region src/devframe.ts
|
|
1090
|
+
/**
|
|
1091
|
+
* The one definition every surface consumes: the `pulls.review` CLI (`index.ts`)
|
|
1092
|
+
* and, through `createPluginFromDevframe`, a devframe hub. The SPA is the app's
|
|
1093
|
+
* `PR_LOCAL` build, copied next to this file by the package build.
|
|
1094
|
+
*/
|
|
1095
|
+
var devframe_default = defineDevframe({
|
|
1096
|
+
id: LOCAL_DEVFRAME_ID,
|
|
1097
|
+
name: "pulls.review",
|
|
1098
|
+
version,
|
|
1099
|
+
packageName: name,
|
|
1100
|
+
description: "Review local git changes, grouped and summarized.",
|
|
1101
|
+
homepage: "https://pulls.review",
|
|
1102
|
+
icon: "ph:git-diff-duotone",
|
|
1103
|
+
clientAssets: fileURLToPath(new URL("./client", import.meta.url)),
|
|
1104
|
+
importMetaUrl: import.meta.url,
|
|
1105
|
+
cli: {
|
|
1106
|
+
command: "pulls.review",
|
|
1107
|
+
port: 7390
|
|
1108
|
+
},
|
|
1109
|
+
async setup(ctx) {
|
|
1110
|
+
const scope = ctx.scope(LOCAL_DEVFRAME_ID);
|
|
1111
|
+
const functions = [...localRpcFunctions({
|
|
1112
|
+
cwd: ctx.cwd,
|
|
1113
|
+
driver: await createRepoCacheDriver(ctx.cwd),
|
|
1114
|
+
env: process.env
|
|
1115
|
+
}), ...agentRpcFunctions({
|
|
1116
|
+
cwd: ctx.cwd,
|
|
1117
|
+
patchDir: join(await repoCacheDir(ctx.cwd), "agent"),
|
|
1118
|
+
channel: scope.rpc.streaming.create(LOCAL_AGENT_CHANNEL, { replayWindow: 4096 })
|
|
1119
|
+
})];
|
|
1120
|
+
for (const fn of functions) scope.rpc.register(fn);
|
|
1121
|
+
}
|
|
1122
|
+
});
|
|
1123
|
+
//#endregion
|
|
1124
|
+
export { devframe_default as t };
|