@a-t-h-i/bot-lobby 0.6.3 → 0.6.5
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 +180 -17
- package/package.json +12 -6
- package/prompts/backend.md +3 -1
- package/prompts/designer.md +24 -1
- package/prompts/master.md +98 -19
- package/prompts/quickfix.md +16 -3
- package/prompts/researcher.md +8 -2
- package/prompts/reviewer.md +42 -0
- package/prompts/worker.md +7 -0
- package/src/agents/backend.ts +2 -2
- package/src/agents/designer.ts +2 -2
- package/src/ask/dialog.ts +167 -0
- package/src/ask/image.ts +202 -0
- package/src/ask/png.ts +179 -0
- package/src/ask/relay.ts +89 -0
- package/src/ask/state.ts +160 -0
- package/src/ask/tool.ts +126 -0
- package/src/ask/types.ts +55 -0
- package/src/ask/view.ts +159 -0
- package/src/classifier/triage.ts +24 -7
- package/src/execution/agent-runner.ts +103 -11
- package/src/execution/git.ts +111 -14
- package/src/execution/pi-runner.ts +164 -11
- package/src/index.ts +9 -0
- package/src/lobby/ask.ts +10 -120
- package/src/lobby/feed.ts +6 -1
- package/src/lobby/layout.ts +27 -8
- package/src/lobby/markdown.ts +36 -6
- package/src/lobby/planner.ts +1 -1
- package/src/lobby/quickfix.ts +50 -3
- package/src/lobby/runtime.ts +41 -40
- package/src/lobby/tabs/home.ts +112 -48
- package/src/lobby/tabs/issues.ts +4 -3
- package/src/lobby/tabs/plan.ts +4 -4
- package/src/lobby/tabs/quickfix.ts +8 -2
- package/src/lobby/tabs/tasks.ts +16 -5
- package/src/lobby/theme.ts +30 -0
- package/src/lobby/view.ts +36 -5
- package/src/master/decisions.ts +10 -2
- package/src/master/master.ts +41 -4
- package/src/master/research.ts +5 -2
- package/src/pi/commands.ts +108 -13
- package/src/pi/events.ts +48 -10
- package/src/pi/quiet.ts +22 -4
- package/src/pi/route.ts +180 -0
- package/src/pi/start-task.ts +56 -4
- package/src/pi/tools.ts +32 -9
- package/src/pi/ui.ts +6 -1
- package/src/pi/zen-metrics.ts +13 -3
- package/src/pi/zen.ts +16 -9
- package/src/roles/reviewer.ts +23 -4
- package/src/roles/worker.ts +28 -2
- package/src/schemas/configuration.ts +15 -0
- package/src/schemas/findings.ts +14 -0
- package/src/schemas/task.ts +52 -0
- package/src/state/budget.ts +274 -0
- package/src/state/changes.ts +231 -0
- package/src/text.ts +28 -2
- package/src/web/extract.ts +332 -0
- package/src/web/fetch.ts +232 -0
- package/src/web/html.ts +183 -0
- package/src/web/read.ts +113 -0
- package/src/web/search.ts +202 -0
- package/src/web/tools.ts +279 -0
- package/src/workflow/track.ts +436 -0
- package/src/workflow/workflow.ts +734 -46
package/src/classifier/triage.ts
CHANGED
|
@@ -48,6 +48,15 @@ function sizeQuestion(subject: string) {
|
|
|
48
48
|
return score(`How big is the change ${subject} asks for, judged by what it would take to build and verify in this repository?`, SIZE_LEVELS);
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
/** Whether one agent can just do it: the quick fix or the team. */
|
|
52
|
+
function soloQuestion(subject: string) {
|
|
53
|
+
return noul(
|
|
54
|
+
`Could one engineer do ${subject} alone, right away — in one file or one area of the code — with no contract to agree between frontend and backend, no unfamiliar codebase to survey first, no risky change (security, data, payments, migrations) and no decision the user must make first?`,
|
|
55
|
+
"Yes: one person can just do it now, even if it is a rich piece of work in one place.",
|
|
56
|
+
"No: it needs a team — several areas, a survey of the codebase, a plan to agree, or review before it lands.",
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
51
60
|
export function triageRequest(input: TriageInput): SystemOneRequest {
|
|
52
61
|
const questions: SystemOneRequest["questions"] = {
|
|
53
62
|
size: sizeQuestion("`request`"),
|
|
@@ -62,6 +71,7 @@ export function triageRequest(input: TriageInput): SystemOneRequest {
|
|
|
62
71
|
"No: a competent engineer could build it as written, deciding details from the codebase.",
|
|
63
72
|
),
|
|
64
73
|
kind: choice("What kind of work does `request` ask for?", TRIAGE_KINDS),
|
|
74
|
+
solo: soloQuestion("`request`"),
|
|
65
75
|
};
|
|
66
76
|
for (const { domain, owns } of input.domains) {
|
|
67
77
|
questions[`domain_${domain}`] = noul(`Does building \`request\` require changes in this area: ${owns}?`);
|
|
@@ -85,12 +95,14 @@ export async function triageTask(classifier: Classifier, input: TriageInput, sig
|
|
|
85
95
|
if (probability !== undefined) domains[domain] = probability;
|
|
86
96
|
}
|
|
87
97
|
const kind = choiceOf(result.answers, "kind");
|
|
98
|
+
const solo = yesOf(result.answers, "solo");
|
|
88
99
|
return {
|
|
89
100
|
size: TRIAGE_SIZES[Math.max(0, Math.min(TRIAGE_SIZES.length - 1, size.level))]!,
|
|
90
101
|
sizeConfidence: size.confidence,
|
|
91
102
|
domains,
|
|
92
103
|
research: yesOf(result.answers, "needs_research") ?? 0,
|
|
93
104
|
ambiguous: yesOf(result.answers, "ambiguous") ?? 0,
|
|
105
|
+
...(solo !== undefined ? { solo } : {}),
|
|
94
106
|
...(kind ? { kind: kind.choice, kindProbability: kind.probability } : {}),
|
|
95
107
|
at: new Date().toISOString(),
|
|
96
108
|
};
|
|
@@ -131,20 +143,25 @@ export async function triageWithContext(classifier: Classifier, scope: FileScope
|
|
|
131
143
|
return files.length > 0 ? { ...triage, likelyFiles: files } : triage;
|
|
132
144
|
}
|
|
133
145
|
|
|
134
|
-
/**
|
|
135
|
-
|
|
146
|
+
/**
|
|
147
|
+
* The size of a quick fix prompt, and how likely one engineer can do it alone,
|
|
148
|
+
* or undefined when the classifier is off or fails. A large prompt is held as
|
|
149
|
+
* a task unless it is still one engineer's work (a rich page in one file).
|
|
150
|
+
*/
|
|
151
|
+
export async function quickFixSize(classifier: Classifier, prompt: string, signal?: AbortSignal): Promise<{ size: TriageSize; confidence: number; solo?: number } | undefined> {
|
|
136
152
|
if (!classifier.enabled("triage")) return undefined;
|
|
137
|
-
const largeAt = classifier.config.thresholds
|
|
138
|
-
const result = await classifier.ask("triage", { state: { prompt: clip(prompt, 6000) }, questions: { size: sizeQuestion("`prompt`") } }, {
|
|
153
|
+
const { quickFixLargeAt: largeAt, quickFixAt } = classifier.config.thresholds;
|
|
154
|
+
const result = await classifier.ask("triage", { state: { prompt: clip(prompt, 6000) }, questions: { size: sizeQuestion("`prompt`"), solo: soloQuestion("`prompt`") } }, {
|
|
139
155
|
...(signal ? { signal } : {}),
|
|
140
156
|
saved: (answers) => {
|
|
141
157
|
const sized = scoreOf(answers, "size");
|
|
142
|
-
return sized && sized.level >= TRIAGE_SIZES.length - 1 && sized.confidence >= largeAt ? 1 : 0;
|
|
158
|
+
return sized && sized.level >= TRIAGE_SIZES.length - 1 && sized.confidence >= largeAt && (yesOf(answers, "solo") ?? 0) < quickFixAt ? 1 : 0;
|
|
143
159
|
},
|
|
144
160
|
});
|
|
145
161
|
const size = scoreOf(result?.answers, "size");
|
|
146
162
|
if (!size) return undefined;
|
|
147
|
-
|
|
163
|
+
const solo = yesOf(result?.answers, "solo");
|
|
164
|
+
return { size: TRIAGE_SIZES[Math.max(0, Math.min(TRIAGE_SIZES.length - 1, size.level))]!, confidence: size.confidence, ...(solo !== undefined ? { solo } : {}) };
|
|
148
165
|
}
|
|
149
166
|
|
|
150
167
|
const HINT = 0.5;
|
|
@@ -155,7 +172,7 @@ export function suggestedPath(triage: TaskTriage): string {
|
|
|
155
172
|
if (triage.ambiguous >= HINT) return "clarify first: the request as written probably misses a decision.";
|
|
156
173
|
const smallish = (triage.size === "trivial" || triage.size === "small") && triage.sizeConfidence >= 0.6;
|
|
157
174
|
const steps: string[] = [];
|
|
158
|
-
if (smallish && touched.length === 1) steps.push(`
|
|
175
|
+
if (smallish && touched.length === 1) steps.push(`fast track (${touched[0]}): no scouts, proposal or plan; delegate straight away`);
|
|
159
176
|
else if (touched.length > 0) steps.push(`scout only ${touched.join(", ")}`);
|
|
160
177
|
if (triage.research >= HINT) steps.push("summon the researcher for the outside facts");
|
|
161
178
|
return steps.length > 0 ? `${steps.join("; ")}.` : "no strong signal; decide from the request.";
|
|
@@ -4,7 +4,11 @@ import { roleSpec } from "../roles/registry.ts";
|
|
|
4
4
|
import { compilePrompt } from "../prompts/compiler.ts";
|
|
5
5
|
import { activityDetail, activityWord, describeToolCall } from "../pi/activity.ts";
|
|
6
6
|
import { shortDuration, truncate } from "../text.ts";
|
|
7
|
-
import {
|
|
7
|
+
import { EditLog } from "../state/changes.ts";
|
|
8
|
+
import { formatMinutes, REPORT_GRACE_MS } from "../state/budget.ts";
|
|
9
|
+
import { runPiAgent, spawnPiProcess, type PiStreamEvent, type ProcessRunner, type RelayAsk } from "./pi-runner.ts";
|
|
10
|
+
import { ASK_ENV } from "../ask/relay.ts";
|
|
11
|
+
import { ASK_TOOL } from "../ask/types.ts";
|
|
8
12
|
|
|
9
13
|
export interface AgentContext {
|
|
10
14
|
task: string;
|
|
@@ -58,8 +62,49 @@ export interface AgentRequest {
|
|
|
58
62
|
/** The classifier routed this run down from this configured profile (receipts and metrics show it). */
|
|
59
63
|
routedFrom?: string;
|
|
60
64
|
route?: string;
|
|
65
|
+
/** Under a task time budget: the run's allotted time, which replaces `timeoutMs`. */
|
|
66
|
+
time?: AgentTime;
|
|
67
|
+
/** Lets the agent ask the user (ask_user_question, relayed through the master); its clocks wait for the answers. */
|
|
68
|
+
onAsk?: RelayAsk;
|
|
61
69
|
}
|
|
62
70
|
|
|
71
|
+
/**
|
|
72
|
+
* A run under a task time budget. The agent is told its minutes up front,
|
|
73
|
+
* warned at 75%, and asked to stop and report when they run out. A worker
|
|
74
|
+
* then reports where it left off and how much more it needs; `onTimeUp`
|
|
75
|
+
* (the engine asking the user) grants more, and the same agent carries on.
|
|
76
|
+
*/
|
|
77
|
+
export interface AgentTime {
|
|
78
|
+
/** When the delegation's time runs out (epoch ms); moved out when more is granted, shared by retries and re-runs. */
|
|
79
|
+
endsAt: number;
|
|
80
|
+
/** The allotment, as the agent and the lobby see it. */
|
|
81
|
+
allotMs: number;
|
|
82
|
+
/** What the agent is told about its time, in its context. */
|
|
83
|
+
note?: string;
|
|
84
|
+
/** Workers only: decide on more time once it has reported where it left off; resolve with ms, 0 to stop it. */
|
|
85
|
+
onTimeUp?: (run: AgentRun, report: string) => Promise<number>;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The warning at 75% of an allotment. */
|
|
89
|
+
export function headsUpMessage(leftMs: number, allotMs: number): string {
|
|
90
|
+
return `bot-lobby: about ${formatMinutes(leftMs)} of your ${formatMinutes(allotMs)} for this step are left. Finish what you have started and make sure it works; leave nice-to-haves.`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The steer when an allotment is spent: a worker says where it left off and what more it needs; any other role reports what it has. */
|
|
94
|
+
export function timeUpMessage(allotMs: number, canAskForMore: boolean): string {
|
|
95
|
+
return canAskForMore
|
|
96
|
+
? `bot-lobby: your ${formatMinutes(allotMs)} for this step are up. Stop here: finish or revert the edit in progress so every file is consistent, start nothing new, and write your report now in the required format, adding \`## Left Off\` (what you were doing and what is still to do) and \`## More Time\` (how many more minutes you need, and why). The user decides whether you get more time to finish.`
|
|
97
|
+
: `bot-lobby: your ${formatMinutes(allotMs)} are up. Stop now and write your report in the required format with what you have.`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The follow-up that sends an agent on once the user gave it more time. */
|
|
101
|
+
export function carryOnMessage(extraMs: number): string {
|
|
102
|
+
return `bot-lobby: the user gave you ${formatMinutes(extraMs)} more. Carry on from where you left off and finish the step, then write your final report in the required format (leave out Left Off and More Time if you finish).`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Least time an attempt starts with; a retry with less than this left is not started. */
|
|
106
|
+
const MIN_ATTEMPT_MS = 60_000;
|
|
107
|
+
|
|
63
108
|
/** The watchdog fields of an AgentRequest, taken from the workflow config. */
|
|
64
109
|
export function watchdogOptions(workflow: {
|
|
65
110
|
maxAgentRetries: number;
|
|
@@ -91,13 +136,18 @@ function retryable(run: AgentRun): boolean {
|
|
|
91
136
|
export async function runAgent(request: AgentRequest, run: ProcessRunner = spawnPiProcess): Promise<AgentRun> {
|
|
92
137
|
const startedAt = new Date().toISOString();
|
|
93
138
|
const attempts = Math.max(1, (request.retries ?? 0) + 1);
|
|
139
|
+
// A failed attempt may have edited files before the retry: the run owns every edit.
|
|
140
|
+
const edits = new EditLog(request.cwd);
|
|
94
141
|
let last: AgentRun | undefined;
|
|
95
142
|
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
96
|
-
last = await runAgentOnce(request, run, attempt, startedAt);
|
|
143
|
+
last = await runAgentOnce(request, run, attempt, startedAt, edits);
|
|
97
144
|
request.onAttemptEnd?.(last);
|
|
98
145
|
if (!retryable(last)) break;
|
|
146
|
+
// Under a budget a retry only uses what is left of the allotment.
|
|
147
|
+
if (request.time && request.time.endsAt - Date.now() < MIN_ATTEMPT_MS) break;
|
|
99
148
|
}
|
|
100
|
-
|
|
149
|
+
const edited = edits.list();
|
|
150
|
+
return edited.length > 0 ? { ...last!, edited } : last!;
|
|
101
151
|
}
|
|
102
152
|
|
|
103
153
|
/** Abort every in-flight subagent (session shutdown, user cancel). */
|
|
@@ -120,31 +170,36 @@ function baseRun(request: AgentRequest, runId: string, startedAt: string, attemp
|
|
|
120
170
|
...(request.thinking ? { thinking: request.thinking } : {}),
|
|
121
171
|
...(request.routedFrom ? { routedFrom: request.routedFrom } : {}),
|
|
122
172
|
...(request.route ? { route: request.route } : {}),
|
|
173
|
+
...(request.time ? { allotMs: request.time.allotMs, endsAt: request.time.endsAt } : {}),
|
|
123
174
|
...(attempts > 1 ? { note: `retry ${attempts - 1} of ${(request.retries ?? 0)}`, noteKind: "warning" as const } : {}),
|
|
124
175
|
};
|
|
125
176
|
}
|
|
126
177
|
|
|
127
178
|
function toolsFor(request: AgentRequest): readonly string[] | undefined {
|
|
128
179
|
const tools = roleSpec(request.role).tools;
|
|
129
|
-
|
|
130
|
-
|
|
180
|
+
const extra = [...(request.extraTools ?? []), ...(request.onAsk ? [ASK_TOOL] : [])];
|
|
181
|
+
if (extra.length === 0) return tools;
|
|
182
|
+
return [...(tools ?? []), ...extra];
|
|
131
183
|
}
|
|
132
184
|
|
|
133
185
|
/**
|
|
134
186
|
* Run one domain/role agent in an isolated pi process. The role's tool
|
|
135
187
|
* allowlist comes from its spec, so read-only roles cannot modify anything.
|
|
136
188
|
*/
|
|
137
|
-
async function runAgentOnce(request: AgentRequest, run: ProcessRunner, attempt: number, startedAt: string): Promise<AgentRun> {
|
|
189
|
+
async function runAgentOnce(request: AgentRequest, run: ProcessRunner, attempt: number, startedAt: string, edits: EditLog): Promise<AgentRun> {
|
|
138
190
|
const base = baseRun(request, `${request.taskId}:${request.domain}:${request.role}:${Date.now().toString(36)}`, startedAt, attempt);
|
|
139
191
|
request.onUpdate?.(base);
|
|
140
|
-
const live = createLiveRun(base, request);
|
|
192
|
+
const live = createLiveRun(base, request, edits);
|
|
141
193
|
|
|
142
194
|
const controller = new AbortController();
|
|
143
195
|
activeControllers.add(controller);
|
|
144
196
|
const signal = request.signal ? AbortSignal.any([request.signal, controller.signal]) : controller.signal;
|
|
145
197
|
try {
|
|
146
198
|
const systemPrompt = compilePrompt({ domain: request.domain, role: request.role, ...request.context });
|
|
147
|
-
const
|
|
199
|
+
const time = request.time;
|
|
200
|
+
const left = time ? Math.max(MIN_ATTEMPT_MS, time.endsAt - Date.now()) : 0;
|
|
201
|
+
const wrapUp = time ? 0 : request.wrapUpAt && request.wrapUpAt > 0 && request.wrapUpAt < 1 ? Math.floor(request.timeoutMs * request.wrapUpAt) : 0;
|
|
202
|
+
const onTimeUp = time?.onTimeUp;
|
|
148
203
|
const result = await runPiAgent({
|
|
149
204
|
cwd: request.cwd,
|
|
150
205
|
task: request.instruction,
|
|
@@ -152,9 +207,29 @@ async function runAgentOnce(request: AgentRequest, run: ProcessRunner, attempt:
|
|
|
152
207
|
tools: toolsFor(request),
|
|
153
208
|
model: request.model,
|
|
154
209
|
thinking: request.thinking,
|
|
155
|
-
timeoutMs: request.timeoutMs,
|
|
210
|
+
timeoutMs: time ? left + REPORT_GRACE_MS : request.timeoutMs,
|
|
211
|
+
...(time ? {
|
|
212
|
+
time: {
|
|
213
|
+
upAtMs: left,
|
|
214
|
+
headsUpAtMs: left >= 4 * 60_000 ? Math.floor(left * 0.75) : 0,
|
|
215
|
+
headsUpMessage: headsUpMessage(left - Math.floor(left * 0.75), time.allotMs),
|
|
216
|
+
upMessage: timeUpMessage(time.allotMs, Boolean(onTimeUp)),
|
|
217
|
+
graceMs: REPORT_GRACE_MS,
|
|
218
|
+
...(onTimeUp ? {
|
|
219
|
+
onTimeUp: async (report: string) => {
|
|
220
|
+
const extraMs = await onTimeUp(live.current(), report);
|
|
221
|
+
if (extraMs > 0) {
|
|
222
|
+
time.endsAt = Date.now() + extraMs;
|
|
223
|
+
live.annotate({ note: `given ${formatMinutes(extraMs)} more`, noteKind: "info" });
|
|
224
|
+
}
|
|
225
|
+
return { extraMs, message: carryOnMessage(extraMs) };
|
|
226
|
+
},
|
|
227
|
+
} : {}),
|
|
228
|
+
},
|
|
229
|
+
} : {}),
|
|
156
230
|
signal,
|
|
157
|
-
|
|
231
|
+
...(request.onAsk ? { onAsk: request.onAsk } : {}),
|
|
232
|
+
env: request.onAsk ? { ...request.env, [ASK_ENV]: "1" } : request.env,
|
|
158
233
|
stallTimeoutMs: request.stallTimeoutMs,
|
|
159
234
|
toolStallTimeoutMs: request.toolStallTimeoutMs,
|
|
160
235
|
wrapUpAtMs: wrapUp,
|
|
@@ -174,6 +249,8 @@ async function runAgentOnce(request: AgentRequest, run: ProcessRunner, attempt:
|
|
|
174
249
|
model: result.model ?? current.model,
|
|
175
250
|
...(result.stalled ? { stalled: true } : {}),
|
|
176
251
|
...(result.wrappedUp ? { wrappedUp: true } : {}),
|
|
252
|
+
...(result.timeUp ? { timeUp: true } : {}),
|
|
253
|
+
...(result.extendedMs ? { extendedMs: result.extendedMs } : {}),
|
|
177
254
|
note: undefined,
|
|
178
255
|
noteKind: undefined,
|
|
179
256
|
};
|
|
@@ -198,7 +275,7 @@ const THROTTLE_MS = 2000;
|
|
|
198
275
|
* `onUpdate`: activity changes, retries and notes immediately, counters and
|
|
199
276
|
* heartbeats at most every couple of seconds.
|
|
200
277
|
*/
|
|
201
|
-
function createLiveRun(base: AgentRun, request: AgentRequest) {
|
|
278
|
+
function createLiveRun(base: AgentRun, request: AgentRequest, edits: EditLog) {
|
|
202
279
|
let state: AgentRun = base;
|
|
203
280
|
let lastEmit = 0;
|
|
204
281
|
const emit = (patch: Partial<AgentRun>, force: boolean) => {
|
|
@@ -212,6 +289,7 @@ function createLiveRun(base: AgentRun, request: AgentRequest) {
|
|
|
212
289
|
const onEvent = (event: PiStreamEvent) => {
|
|
213
290
|
switch (event.type) {
|
|
214
291
|
case "tool_execution_start": {
|
|
292
|
+
edits.note(event.toolName, event.args);
|
|
215
293
|
const activity = activityWord(event.toolName);
|
|
216
294
|
const detail = activityDetail(event.toolName, event.args);
|
|
217
295
|
const step = describeToolCall(event.toolName, event.args);
|
|
@@ -240,6 +318,20 @@ function createLiveRun(base: AgentRun, request: AgentRequest) {
|
|
|
240
318
|
case "wrap_up":
|
|
241
319
|
emit({ note: `asked to wrap up (${shortDuration(request.timeoutMs)} limit)`, noteKind: "warning", wrappedUp: true }, true);
|
|
242
320
|
return;
|
|
321
|
+
case "time_up":
|
|
322
|
+
emit({ note: request.time?.onTimeUp ? "out of time: reporting where it left off" : "out of time: reporting", noteKind: "warning" }, true);
|
|
323
|
+
return;
|
|
324
|
+
case "extended":
|
|
325
|
+
emit({ endsAt: request.time?.endsAt, extendedMs: (state.extendedMs ?? 0) + event.ms }, true);
|
|
326
|
+
return;
|
|
327
|
+
case "asking":
|
|
328
|
+
emit({ note: `waiting on you: ${event.questions} question${event.questions === 1 ? "" : "s"}`, noteKind: "info" }, true);
|
|
329
|
+
return;
|
|
330
|
+
case "answered":
|
|
331
|
+
// Its time waited with it.
|
|
332
|
+
if (request.time) request.time.endsAt += event.waitedMs;
|
|
333
|
+
emit({ note: "you answered; carrying on", noteKind: "info", ...(request.time ? { endsAt: request.time.endsAt } : {}) }, true);
|
|
334
|
+
return;
|
|
243
335
|
case "usage": {
|
|
244
336
|
const usage = state.usage ?? { input: 0, output: 0, cost: 0, turns: 0 };
|
|
245
337
|
const next = { input: usage.input + event.input, output: usage.output + event.output, cost: usage.cost + event.cost, turns: usage.turns + 1 };
|
package/src/execution/git.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
|
+
import { readFileSync, statSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
2
4
|
import { promisify } from "node:util";
|
|
3
5
|
import { truncate } from "../text.ts";
|
|
4
6
|
|
|
@@ -10,33 +12,128 @@ async function git(cwd: string, args: string[]): Promise<string> {
|
|
|
10
12
|
return stdout.trim();
|
|
11
13
|
}
|
|
12
14
|
|
|
13
|
-
/** `git diff HEAD
|
|
14
|
-
async function
|
|
15
|
+
/** `git diff <base>` (HEAD by default), falling back to index+worktree when HEAD does not exist yet. */
|
|
16
|
+
async function diffAgainst(cwd: string, base: string, extra: readonly string[] = [], paths: readonly string[] = []): Promise<string> {
|
|
15
17
|
try {
|
|
16
|
-
return await git(cwd, ["diff", "
|
|
18
|
+
return await git(cwd, ["diff", ...extra, base, "--", ...paths]);
|
|
17
19
|
} catch {
|
|
18
|
-
const [unstaged, staged] = await Promise.all([git(cwd, ["diff"]), git(cwd, ["diff", "--cached"])]);
|
|
20
|
+
const [unstaged, staged] = await Promise.all([git(cwd, ["diff", ...extra, "--", ...paths]), git(cwd, ["diff", ...extra, "--cached", "--", ...paths])]);
|
|
19
21
|
return [unstaged, staged].filter((part) => part.length > 0).join("\n");
|
|
20
22
|
}
|
|
21
23
|
}
|
|
22
24
|
|
|
25
|
+
/** The commit HEAD points at, or undefined (no commits yet, or no git). */
|
|
26
|
+
export async function headCommit(cwd: string): Promise<string | undefined> {
|
|
27
|
+
try {
|
|
28
|
+
return (await git(cwd, ["rev-parse", "--verify", "HEAD"])) || undefined;
|
|
29
|
+
} catch {
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The newest commit on HEAD made before `iso` (where a task started), or undefined. */
|
|
35
|
+
export async function commitBefore(cwd: string, iso: string): Promise<string | undefined> {
|
|
36
|
+
try {
|
|
37
|
+
return (await git(cwd, ["rev-list", "-1", `--before=${iso}`, "HEAD"])) || undefined;
|
|
38
|
+
} catch {
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface DiffOptions {
|
|
44
|
+
/** Bound on the whole text. */
|
|
45
|
+
limitChars?: number;
|
|
46
|
+
/** The commit to compare with (where the task started), so work already committed still shows; HEAD when absent. */
|
|
47
|
+
base?: string;
|
|
48
|
+
/** Paths (relative to `cwd`) left out: bot-lobby's own records. */
|
|
49
|
+
exclude?: readonly string[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Longest new file shown whole; past it (or binary) only its name is listed. */
|
|
53
|
+
const MAX_NEW_FILE_CHARS = 4000;
|
|
54
|
+
const MAX_NEW_FILE_BYTES = 200 * 1024;
|
|
55
|
+
|
|
23
56
|
/**
|
|
24
|
-
* Repository evidence for a reviewer: working-tree status
|
|
25
|
-
*
|
|
26
|
-
*
|
|
57
|
+
* Repository evidence for a reviewer: working-tree status, the files changed
|
|
58
|
+
* since `base` (listed first, so a long diff never hides which files moved),
|
|
59
|
+
* the diff itself, and the content of new files (which `git diff` leaves
|
|
60
|
+
* out). Returns an explanatory string instead of throwing when git is
|
|
61
|
+
* unusable so a review can still proceed on other evidence.
|
|
27
62
|
*/
|
|
28
|
-
export async function readRepositoryDiff(cwd: string,
|
|
63
|
+
export async function readRepositoryDiff(cwd: string, options: DiffOptions = {}): Promise<string> {
|
|
64
|
+
const limit = options.limitChars ?? 20_000;
|
|
65
|
+
const paths = options.exclude?.length ? [".", ...options.exclude.map((path) => `:(exclude)${path}`)] : [];
|
|
66
|
+
const base = options.base ?? "HEAD";
|
|
67
|
+
const label = options.base ? `since ${options.base.slice(0, 7)}, where the task started (committed work included)` : "HEAD";
|
|
29
68
|
try {
|
|
30
|
-
const [status, diff] = await Promise.all([
|
|
31
|
-
git(cwd, ["status", "--porcelain"]),
|
|
32
|
-
|
|
69
|
+
const [status, stat, diff, untracked] = await Promise.all([
|
|
70
|
+
git(cwd, ["status", "--porcelain", "--", ...paths]),
|
|
71
|
+
diffAgainst(cwd, base, ["--stat"], paths),
|
|
72
|
+
diffAgainst(cwd, base, [], paths),
|
|
73
|
+
git(cwd, ["ls-files", "--others", "--exclude-standard", "--", ...paths]),
|
|
33
74
|
]);
|
|
34
|
-
const
|
|
75
|
+
const head = [
|
|
35
76
|
status ? `Status:\n${status}` : "Status: clean working tree",
|
|
36
|
-
|
|
77
|
+
stat ? `Changed files (${label}):\n${stat}` : "",
|
|
78
|
+
].filter(Boolean).join("\n\n");
|
|
79
|
+
const fresh = untracked ? newFiles(cwd, untracked.split("\n").filter(Boolean)) : "";
|
|
80
|
+
const room = Math.max(0, limit - head.length);
|
|
81
|
+
// The diff and the new files share what is left; new files get at least a third when there are any.
|
|
82
|
+
const diffRoom = fresh ? Math.max(Math.floor(room * 0.66), room - fresh.length) : room;
|
|
83
|
+
const parts = [
|
|
84
|
+
head,
|
|
85
|
+
diff ? `Diff (${label}):\n${truncate(diff, diffRoom)}` : `Diff (${label}): none`,
|
|
86
|
+
fresh ? `New files (untracked):\n${truncate(fresh, Math.max(0, room - Math.min(diff.length, diffRoom)))}` : "",
|
|
37
87
|
];
|
|
38
|
-
return truncate(parts.join("\n\n"),
|
|
88
|
+
return truncate(parts.filter(Boolean).join("\n\n"), limit + 200);
|
|
39
89
|
} catch (error) {
|
|
40
90
|
return `Unable to read git state: ${(error as Error).message}`;
|
|
41
91
|
}
|
|
42
92
|
}
|
|
93
|
+
|
|
94
|
+
/** New files as a reviewer reads them: each whole when it is small text, otherwise just named. */
|
|
95
|
+
function newFiles(cwd: string, files: readonly string[]): string {
|
|
96
|
+
return files.map((file) => {
|
|
97
|
+
try {
|
|
98
|
+
const path = join(cwd, file);
|
|
99
|
+
if (statSync(path).size > MAX_NEW_FILE_BYTES) return `--- ${file} (large; read it directly)`;
|
|
100
|
+
const text = readFileSync(path, "utf8");
|
|
101
|
+
if (text.includes("\0")) return `--- ${file} (binary)`;
|
|
102
|
+
return `--- ${file}\n${truncate(text, MAX_NEW_FILE_CHARS)}`;
|
|
103
|
+
} catch {
|
|
104
|
+
return `--- ${file} (unreadable)`;
|
|
105
|
+
}
|
|
106
|
+
}).join("\n\n");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Most changed files one look reports; the rest are summarised by the diff. */
|
|
110
|
+
export const MAX_CHANGED_FILES = 400;
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The repository's top folder and every file changed, relative to it: what
|
|
114
|
+
* `git status` reports (renames by their new path, untracked files one by
|
|
115
|
+
* one), plus what was committed since `base`; undefined when git is unusable.
|
|
116
|
+
*/
|
|
117
|
+
export async function changedFiles(cwd: string, base?: string): Promise<{ top: string; files: string[] } | undefined> {
|
|
118
|
+
try {
|
|
119
|
+
const [top, status, committed] = await Promise.all([
|
|
120
|
+
git(cwd, ["rev-parse", "--show-toplevel"]),
|
|
121
|
+
run("git", ["status", "--porcelain", "-z", "--untracked-files=all"], { cwd, maxBuffer: MAX_BUFFER }),
|
|
122
|
+
// Work committed since the task started is still the task's to review.
|
|
123
|
+
base ? run("git", ["diff", "--name-only", "-z", base], { cwd, maxBuffer: MAX_BUFFER }).then((result) => result.stdout, () => "") : Promise.resolve(""),
|
|
124
|
+
]);
|
|
125
|
+
const entries = status.stdout.split("\0");
|
|
126
|
+
const files = new Set<string>();
|
|
127
|
+
for (let index = 0; index < entries.length && files.size < MAX_CHANGED_FILES; index += 1) {
|
|
128
|
+
const entry = entries[index]!;
|
|
129
|
+
if (entry.length < 4) continue;
|
|
130
|
+
files.add(entry.slice(3));
|
|
131
|
+
// A rename or copy is followed by the path it came from.
|
|
132
|
+
if (/[RC]/.test(entry.slice(0, 2))) index += 1;
|
|
133
|
+
}
|
|
134
|
+
for (const file of committed.split("\0")) if (file && files.size < MAX_CHANGED_FILES) files.add(file);
|
|
135
|
+
return { top, files: [...files] };
|
|
136
|
+
} catch {
|
|
137
|
+
return undefined;
|
|
138
|
+
}
|
|
139
|
+
}
|