@a-t-h-i/bot-lobby 0.6.38 → 0.6.40
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 +54 -11
- package/package.json +1 -1
- package/prompts/global.md +12 -0
- package/prompts/master.md +21 -3
- package/prompts/quickfix.md +10 -3
- package/prompts/scout.md +8 -4
- package/prompts/worker.md +5 -3
- package/src/classifier/chat.ts +122 -0
- package/src/classifier/classifier.ts +46 -11
- package/src/classifier/files.ts +91 -23
- package/src/classifier/instance.ts +35 -2
- package/src/classifier/models.ts +115 -0
- package/src/execution/git.ts +108 -2
- package/src/index.ts +3 -0
- package/src/knowledge/file-notes.ts +104 -0
- package/src/knowledge/repo-map.ts +165 -0
- package/src/lobby/host.ts +2 -0
- package/src/lobby/quickfix.ts +55 -2
- package/src/lobby/service.ts +2 -1
- package/src/master/master.ts +39 -3
- package/src/master/scout-cache.ts +70 -0
- package/src/master/synthesis.ts +10 -2
- package/src/pi/model-settings.ts +5 -3
- package/src/pi/plan-checklist.ts +2 -1
- package/src/pi/tool-budget.ts +66 -0
- package/src/pi/tools.ts +1 -0
- package/src/roles/markdown.ts +12 -4
- package/src/roles/scout.ts +2 -1
- package/src/schemas/configuration.ts +21 -2
- package/src/schemas/findings.ts +2 -0
- package/src/state/work-time.ts +4 -2
- package/src/webui/api/plan-facts.ts +17 -2
- package/src/webui/api/settings.ts +2 -1
- package/src/webui/api/tasks.ts +1 -1
- package/src/webui/dev/fake-service.ts +1 -0
- package/src/webui/protocol.ts +18 -1
- package/src/workflow/workflow.ts +41 -2
- package/webui/dist/assets/index-BV9aLD_v.js +109 -0
- package/webui/dist/assets/index-CzPF26MA.js +109 -0
- package/webui/dist/assets/index-Glz3FAPp.css +2 -0
- package/webui/dist/assets/index-uTndyUfm.css +2 -0
- package/webui/dist/build.json +17 -21
- package/webui/dist/index.html +2 -2
- package/webui/dist/assets/index-BFsMFIr5.js +0 -110
- package/webui/dist/assets/index-Ccc6rsPD.css +0 -2
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The repository map: what an agent would otherwise spend its first turns on
|
|
3
|
+
* `find`, `grep` and `read` to learn. The repository's layout, and the files
|
|
4
|
+
* whose path, opening comment or top-level names match the step, each with
|
|
5
|
+
* what it says it is for, the names it declares, and what an earlier scout
|
|
6
|
+
* noted about it while the file is unchanged. It is read from the file index
|
|
7
|
+
* (refreshed by size and modification time) with no model, so it works with
|
|
8
|
+
* the classifier off; a large repository that is not indexed in time is left
|
|
9
|
+
* out rather than waited for.
|
|
10
|
+
*/
|
|
11
|
+
import type { FileHinter, FileScope, IndexedFile } from "../classifier/files.ts";
|
|
12
|
+
import { indexFiles, terms } from "../classifier/files.ts";
|
|
13
|
+
import { dataRoot } from "../state/project.ts";
|
|
14
|
+
import { currentNotes, type FileNote } from "./file-notes.ts";
|
|
15
|
+
|
|
16
|
+
export interface RepoMapOptions {
|
|
17
|
+
exclude?: readonly string[];
|
|
18
|
+
/** The map's size, header included. */
|
|
19
|
+
maxChars?: number;
|
|
20
|
+
/** Matching files listed at most. */
|
|
21
|
+
maxFiles?: number;
|
|
22
|
+
/** How long to wait for the index; the map is left out after that (the index still finishes and is cached). */
|
|
23
|
+
budgetMs?: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const REPO_MAP_CHARS = 3500;
|
|
27
|
+
export const REPO_MAP_FILES = 20;
|
|
28
|
+
export const REPO_MAP_BUDGET_MS = 2500;
|
|
29
|
+
/** A file needs at least this much in common with the step to be listed: a path word, or a name and an excerpt word. */
|
|
30
|
+
const MIN_SCORE = 3;
|
|
31
|
+
const LINE_CHARS = 240;
|
|
32
|
+
const NAMES_SHOWN = 8;
|
|
33
|
+
const PURPOSE_SHOWN = 90;
|
|
34
|
+
|
|
35
|
+
function escapeRegExp(text: string): string {
|
|
36
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A word matches a token that is the same word, or the same stem when both are four letters or more (`classifier`, `classifiers`). */
|
|
40
|
+
function matches(word: string, tokens: ReadonlySet<string>): boolean {
|
|
41
|
+
if (tokens.has(word)) return true;
|
|
42
|
+
if (word.length < 4) return false;
|
|
43
|
+
for (const token of tokens) if (token.length >= 4 && (token.startsWith(word) || word.startsWith(token))) return true;
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** How much a file has in common with the step: named outright, then words in its path, names and excerpt. */
|
|
48
|
+
function scoreFile(file: IndexedFile, words: readonly string[], query: string, note?: FileNote): number {
|
|
49
|
+
const path = file.path.toLowerCase();
|
|
50
|
+
const name = path.slice(path.lastIndexOf("/") + 1);
|
|
51
|
+
const stem = name.replace(/\.[^.]+$/, "");
|
|
52
|
+
let score = 0;
|
|
53
|
+
if (query.includes(path)) score += 10;
|
|
54
|
+
else if (stem.length >= 4 && new RegExp(`(^|[^\\w-])${escapeRegExp(name)}\\b|\\b${escapeRegExp(stem)}\\.\\w+\\b`).test(query)) score += 6;
|
|
55
|
+
const pathTokens = new Set(terms(file.path));
|
|
56
|
+
const symbolTokens = new Set(terms((file.symbols ?? []).join(" ")));
|
|
57
|
+
const excerptTokens = new Set(terms(file.excerpt));
|
|
58
|
+
const noteTokens = new Set(note ? terms(note.note) : []);
|
|
59
|
+
for (const word of words) {
|
|
60
|
+
if (matches(word, pathTokens)) score += 3;
|
|
61
|
+
if (matches(word, symbolTokens)) score += 2;
|
|
62
|
+
if (matches(word, excerptTokens)) score += 1;
|
|
63
|
+
if (matches(word, noteTokens)) score += 1;
|
|
64
|
+
}
|
|
65
|
+
return score;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The files most worth listing for a step, best first: at most `max`, each sharing enough with it. */
|
|
69
|
+
export function rankForMap(query: string, files: readonly IndexedFile[], max = REPO_MAP_FILES, notes: ReadonlyMap<string, FileNote> = new Map()): IndexedFile[] {
|
|
70
|
+
const words = terms(query);
|
|
71
|
+
const lowered = query.toLowerCase();
|
|
72
|
+
return files
|
|
73
|
+
.map((file, index) => ({ file, index, score: scoreFile(file, words, lowered, notes.get(file.path)), depth: file.path.split("/").length }))
|
|
74
|
+
.filter((entry) => entry.score >= MIN_SCORE)
|
|
75
|
+
.sort((a, b) => b.score - a.score || a.depth - b.depth || a.index - b.index)
|
|
76
|
+
.slice(0, max)
|
|
77
|
+
.map((entry) => entry.file);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The layout in one line: top-level folders by size, with the folders inside the large ones, then root files. */
|
|
81
|
+
export function layoutLine(files: readonly IndexedFile[], max = 600): string {
|
|
82
|
+
const top = new Map<string, { count: number; inner: Map<string, number> }>();
|
|
83
|
+
const rootFiles: string[] = [];
|
|
84
|
+
for (const { path } of files) {
|
|
85
|
+
const parts = path.split("/");
|
|
86
|
+
if (parts.length === 1) {
|
|
87
|
+
rootFiles.push(path);
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
const dir = top.get(parts[0]!) ?? { count: 0, inner: new Map<string, number>() };
|
|
91
|
+
dir.count += 1;
|
|
92
|
+
if (parts.length > 2) dir.inner.set(parts[1]!, (dir.inner.get(parts[1]!) ?? 0) + 1);
|
|
93
|
+
top.set(parts[0]!, dir);
|
|
94
|
+
}
|
|
95
|
+
const dirs = [...top].sort((a, b) => b[1].count - a[1].count).map(([name, dir]) => {
|
|
96
|
+
const inner = [...dir.inner].sort((a, b) => b[1] - a[1]).slice(0, 8).map(([child]) => child);
|
|
97
|
+
return dir.count >= 20 && inner.length > 1 ? `${name}/ (${dir.count}: ${inner.join(", ")})` : `${name}/ (${dir.count})`;
|
|
98
|
+
});
|
|
99
|
+
const line = [...dirs, ...rootFiles.slice(0, 6)].join(" · ");
|
|
100
|
+
return line.length > max ? `${line.slice(0, max - 1).replace(/\s*·?\s*\S*$/, "")} …` : line;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** One file's map entry: path, purpose and names on one line, an unchanged file's scout note under it. */
|
|
104
|
+
function fileEntry(file: IndexedFile, note?: FileNote): string {
|
|
105
|
+
const symbols = file.symbols ?? [];
|
|
106
|
+
const names = symbols.length > NAMES_SHOWN ? `${symbols.slice(0, NAMES_SHOWN).join(", ")}, …` : symbols.join(", ");
|
|
107
|
+
const purpose = file.purpose && file.purpose.length > PURPOSE_SHOWN ? `${file.purpose.slice(0, PURPOSE_SHOWN - 1).replace(/\s+\S*$/, "")}…` : file.purpose;
|
|
108
|
+
const head = [`- \`${file.path}\``, purpose ? ` — ${purpose}` : "", names ? ` · ${names}` : ""].join("");
|
|
109
|
+
const line = head.length > LINE_CHARS ? `${head.slice(0, LINE_CHARS - 1)}…` : head;
|
|
110
|
+
if (!note) return line;
|
|
111
|
+
return `${line}\n scout (${note.domain}, ${note.at.slice(0, 10)}${note.lines ? `, lines ${note.lines}` : ""}): ${note.note}`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const MAP_INTRO = "The repository's layout and the files that match this step, read from the files themselves: path — what the file says it is for · the top-level names it declares. Scout notes are what an earlier scout found in a file that has not changed since. Go straight to the file you need; search only for what the map does not answer, and verify what you rely on.";
|
|
115
|
+
|
|
116
|
+
/** The map's text for ranked files, kept within `maxChars`; "" when no file matches. */
|
|
117
|
+
export function renderRepoMap(layout: string, files: readonly IndexedFile[], notes: ReadonlyMap<string, FileNote>, maxChars = REPO_MAP_CHARS): string {
|
|
118
|
+
if (files.length === 0) return "";
|
|
119
|
+
const lines = ["## Repository map", "", MAP_INTRO, "", `Layout: ${layout}`, "", "Files that match this step:"];
|
|
120
|
+
let size = lines.join("\n").length;
|
|
121
|
+
let listed = 0;
|
|
122
|
+
for (const file of files) {
|
|
123
|
+
const entry = fileEntry(file, notes.get(file.path));
|
|
124
|
+
if (size + entry.length + 1 > maxChars) break;
|
|
125
|
+
lines.push(entry);
|
|
126
|
+
size += entry.length + 1;
|
|
127
|
+
listed += 1;
|
|
128
|
+
}
|
|
129
|
+
return listed > 0 ? lines.join("\n") : "";
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Resolve with the value, or undefined once `ms` pass first. */
|
|
133
|
+
function within<T>(promise: Promise<T>, ms: number): Promise<T | undefined> {
|
|
134
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
135
|
+
const late = new Promise<undefined>((resolve) => {
|
|
136
|
+
timer = setTimeout(() => resolve(undefined), ms);
|
|
137
|
+
});
|
|
138
|
+
return Promise.race([promise, late]).finally(() => clearTimeout(timer));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** The repository map for a step; "" when nothing matches, the index fails or it is not ready within the budget. */
|
|
142
|
+
export async function repoMap(scope: FileScope, query: string, options: RepoMapOptions = {}): Promise<string> {
|
|
143
|
+
const indexing = indexFiles(scope, options.exclude ?? []);
|
|
144
|
+
indexing.catch(() => undefined);
|
|
145
|
+
const files = await within(indexing.catch(() => undefined), options.budgetMs ?? REPO_MAP_BUDGET_MS);
|
|
146
|
+
if (!files || files.length === 0) return "";
|
|
147
|
+
const max = options.maxFiles ?? REPO_MAP_FILES;
|
|
148
|
+
// Rank twice: notes only exist for some files, so read them for a wider first cut, then rank with them.
|
|
149
|
+
const wide = rankForMap(query, files, max * 2);
|
|
150
|
+
const notes = await currentNotes(dataRoot(scope.root, scope.configDir), scope.cwd, wide.map((file) => file.path));
|
|
151
|
+
return renderRepoMap(layoutLine(files), rankForMap(query, wide, max, notes), notes, options.maxChars ?? REPO_MAP_CHARS);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** A hinter that also hands each step the repository map, while `on()` says so. */
|
|
155
|
+
export function withRepoMap(hinter: FileHinter, scope: FileScope, settings: () => { on: boolean; exclude: readonly string[] }): FileHinter {
|
|
156
|
+
return {
|
|
157
|
+
async block(query, signal, context) {
|
|
158
|
+
const { on, exclude } = settings();
|
|
159
|
+
const map = on ? repoMap(scope, query, { exclude }).catch(() => "") : Promise.resolve("");
|
|
160
|
+
const [likely, mapped] = await Promise.all([hinter.block(query, signal, context), map]);
|
|
161
|
+
return [likely, mapped].filter(Boolean).join("\n\n");
|
|
162
|
+
},
|
|
163
|
+
tools: () => hinter.tools(),
|
|
164
|
+
};
|
|
165
|
+
}
|
package/src/lobby/host.ts
CHANGED
|
@@ -159,6 +159,8 @@ export interface LobbyService {
|
|
|
159
159
|
configChanged?(): void;
|
|
160
160
|
/** The models Pi offers, for the settings page's picker. */
|
|
161
161
|
models?(): ModelChoice[];
|
|
162
|
+
/** The classifier models pi can call with its logins, for the classifier's picker. */
|
|
163
|
+
classifierModels?(): Array<{ id: string; label: string }>;
|
|
162
164
|
/** The model this window's session runs on (`provider/id`), when it has one. */
|
|
163
165
|
sessionModel?(): string | undefined;
|
|
164
166
|
/** The Issues tab is switched on (`lobby.issues`). */
|
package/src/lobby/quickfix.ts
CHANGED
|
@@ -21,7 +21,7 @@ import { fellShort, profileLabel, routeLabel, type EffortRoute, type EffortRoute
|
|
|
21
21
|
import { existsSync } from "node:fs";
|
|
22
22
|
import { isAbsolute, join, relative } from "node:path";
|
|
23
23
|
import type { LintConfig } from "../schemas/configuration.ts";
|
|
24
|
-
import { repositoryTop } from "../execution/git.ts";
|
|
24
|
+
import { changedFiles, commitFiles, commitMessage, MAX_COMMIT_SUBJECT, repositoryTop } from "../execution/git.ts";
|
|
25
25
|
import type { LintExec } from "../execution/lint.ts";
|
|
26
26
|
import { scriptsFor, type ScriptPicker } from "../scripts/pick.ts";
|
|
27
27
|
import { dataRoot } from "../state/project.ts";
|
|
@@ -72,6 +72,10 @@ export interface QuickFixJob {
|
|
|
72
72
|
routed?: boolean;
|
|
73
73
|
/** An earlier run was cut off when pi stopped: some of its edits may already be in the tree. */
|
|
74
74
|
interrupted?: boolean;
|
|
75
|
+
/** Files (relative to the repository) that had uncommitted changes before its first run: never its to commit. */
|
|
76
|
+
before?: string[];
|
|
77
|
+
/** The commit its changes went in, and its message's first line. */
|
|
78
|
+
commit?: { sha: string; subject: string };
|
|
75
79
|
}
|
|
76
80
|
|
|
77
81
|
/** How a job enters the queue besides its prompt. */
|
|
@@ -136,6 +140,24 @@ export function quickFixPrompt(instructions?: string): string {
|
|
|
136
140
|
return custom ? `${loadPrompt("quickfix.md")}\n\n## Custom Instructions\n\n${custom}` : loadPrompt("quickfix.md");
|
|
137
141
|
}
|
|
138
142
|
|
|
143
|
+
/**
|
|
144
|
+
* The commit message a quick fix's report gives under `## Commit`, kept short
|
|
145
|
+
* (see `commitMessage`); the request's first line, clipped, when it gives none
|
|
146
|
+
* or one too long.
|
|
147
|
+
*/
|
|
148
|
+
export function quickFixCommitMessage(job: Pick<QuickFixJob, "prompt" | "report">): string {
|
|
149
|
+
const section = /^## Commit[^\n]*\n([\s\S]*?)(?=^## |(?![\s\S]))/m.exec(job.report ?? "")?.[1]?.replace(/^\s*```[^\n]*\n?|```\s*$/g, "").trim();
|
|
150
|
+
if (section) {
|
|
151
|
+
try {
|
|
152
|
+
return commitMessage(section);
|
|
153
|
+
} catch {
|
|
154
|
+
// Too long or empty: the request names it instead.
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
const title = jobTitle(job).replace(/\s+/g, " ");
|
|
158
|
+
return title.length > MAX_COMMIT_SUBJECT ? `${title.slice(0, MAX_COMMIT_SUBJECT - 1).trimEnd()}…` : title;
|
|
159
|
+
}
|
|
160
|
+
|
|
139
161
|
/** First line of a prompt, for lists. */
|
|
140
162
|
export function jobTitle(job: Pick<QuickFixJob, "prompt">): string {
|
|
141
163
|
return job.prompt.split("\n").find((line) => line.trim())?.trim() ?? "(empty)";
|
|
@@ -301,6 +323,8 @@ export class QuickFixQueue {
|
|
|
301
323
|
}
|
|
302
324
|
this.deps.feed?.log(QUICK_FIX_SOURCE, `started: ${jobTitle(job)}`, "info", job.startedAt);
|
|
303
325
|
this.edits.set(job.id, new EditLog(this.deps.cwd));
|
|
326
|
+
// What was uncommitted before it ran is someone else's; a run cut off and resumed keeps its first look.
|
|
327
|
+
job.before ??= (await changedFiles(this.deps.cwd))?.files ?? [];
|
|
304
328
|
try {
|
|
305
329
|
const [likely, route, scripts] = await Promise.all([
|
|
306
330
|
this.likely(job.prompt, controller.signal),
|
|
@@ -363,6 +387,12 @@ export class QuickFixQueue {
|
|
|
363
387
|
job.error = result.error;
|
|
364
388
|
job.usage = result.usage;
|
|
365
389
|
if (result.model) job.model = result.model;
|
|
390
|
+
const edited = this.edits.get(job.id)?.list() ?? [];
|
|
391
|
+
if (job.status === "success" && edited.length > 0) {
|
|
392
|
+
// Lint reads what changed since HEAD, so it goes before the commit.
|
|
393
|
+
await this.lintAfter(job, edited);
|
|
394
|
+
await this.commitAfter(job, edited);
|
|
395
|
+
}
|
|
366
396
|
} catch (error) {
|
|
367
397
|
job.status = "failed";
|
|
368
398
|
job.error = (error as Error).message;
|
|
@@ -440,11 +470,34 @@ export class QuickFixQueue {
|
|
|
440
470
|
this.deps.feed?.log(QUICK_FIX_SOURCE, `${outcome}: ${jobTitle(job)}`, ok ? "success" : job.status === "cancelled" ? "warning" : "error", job.finishedAt);
|
|
441
471
|
appendMetrics(this.deps.root, this.deps.configDir, [quickFixMetric(job)]);
|
|
442
472
|
this.deps.notify?.(`bot-lobby quick fix ${outcome}: ${jobTitle(job)}`, ok ? "info" : "warning");
|
|
443
|
-
if (ok && edited.length > 0) void this.lintAfter(job, edited);
|
|
444
473
|
this.changed();
|
|
445
474
|
this.pump();
|
|
446
475
|
}
|
|
447
476
|
|
|
477
|
+
/**
|
|
478
|
+
* Commit what a finished quick fix edited, with the message its report
|
|
479
|
+
* gives: only its own files, never one that held uncommitted changes before
|
|
480
|
+
* it ran. A refused commit is a line in the activity log; the fix stands.
|
|
481
|
+
*/
|
|
482
|
+
private async commitAfter(job: QuickFixJob, edited: readonly string[]): Promise<void> {
|
|
483
|
+
const top = await repositoryTop(this.deps.cwd);
|
|
484
|
+
if (!top) return;
|
|
485
|
+
const before = new Set(job.before ?? []);
|
|
486
|
+
const files = edited.map((file) => relative(top, file).split("\\").join("/")).filter((file) => file && !file.startsWith("..") && !isAbsolute(file) && !before.has(file));
|
|
487
|
+
const own = relative(top, dataRoot(this.deps.root, this.deps.configDir)).split("\\").join("/");
|
|
488
|
+
const ours = own && !own.startsWith("..") ? files.filter((file) => !file.startsWith(`${own}/`)) : files;
|
|
489
|
+
if (ours.length === 0) return;
|
|
490
|
+
const message = quickFixCommitMessage(job);
|
|
491
|
+
try {
|
|
492
|
+
const commit = await commitFiles(top, ours, message);
|
|
493
|
+
if (!commit) return;
|
|
494
|
+
job.commit = { sha: commit.commit, subject: message.split("\n")[0]! };
|
|
495
|
+
this.deps.feed?.log(QUICK_FIX_SOURCE, `committed ${commit.commit.slice(0, 7)}: ${job.commit.subject}`, "info");
|
|
496
|
+
} catch (error) {
|
|
497
|
+
this.deps.feed?.log(QUICK_FIX_SOURCE, `not committed: ${(error as Error).message.split("\n")[0]}`, "warning");
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
448
501
|
/**
|
|
449
502
|
* The lint gate's pass over what a quick fix touched (only problems on the
|
|
450
503
|
* lines it changed are its own): a line in the activity log, never a hold,
|
package/src/lobby/service.ts
CHANGED
|
@@ -33,7 +33,7 @@ import { startPlannedTask } from "../pi/start-task.ts";
|
|
|
33
33
|
import { pendingRequest, startRequest } from "../pi/route.ts";
|
|
34
34
|
import { deliverComments, setAuto } from "../pi/owner.ts";
|
|
35
35
|
import { chatFromEntries, lobbyFeed, type ChatEntry } from "./feed.ts";
|
|
36
|
-
import { classifier, effortFor, hintsFor } from "../classifier/instance.ts";
|
|
36
|
+
import { availableClassifiers, classifier, effortFor, hintsFor } from "../classifier/instance.ts";
|
|
37
37
|
import { launchPi, SessionRegistry, type BackgroundSession, type SessionLauncher } from "./sessions.ts";
|
|
38
38
|
import { SessionChats } from "./session-files.ts";
|
|
39
39
|
import { answerMessage, questionnaires, settledQuestions } from "./ask.ts";
|
|
@@ -801,6 +801,7 @@ export function createLobbyService(state: Runtime): LobbyService {
|
|
|
801
801
|
return [];
|
|
802
802
|
}
|
|
803
803
|
},
|
|
804
|
+
classifierModels: () => availableClassifiers().map((model) => ({ id: model.id, label: model.label })),
|
|
804
805
|
issuesEnabled: () => loadConfig().lobby.issues,
|
|
805
806
|
recovered: () => state.recovered,
|
|
806
807
|
seatLabel: (member) => {
|
package/src/master/master.ts
CHANGED
|
@@ -15,6 +15,10 @@ import { parseWorkerResult, validateWorkerResult } from "../roles/worker.ts";
|
|
|
15
15
|
import { parseReviewResult, validateReviewResult } from "../roles/reviewer.ts";
|
|
16
16
|
import { truncate } from "../text.ts";
|
|
17
17
|
import { isScoutResultUsable, parseScoutResult, validateScoutResult } from "../roles/scout.ts";
|
|
18
|
+
import { treeState } from "../execution/git.ts";
|
|
19
|
+
import { toolBudgetEnv, toolBudgetNote } from "../pi/tool-budget.ts";
|
|
20
|
+
import { recordFileNotes } from "../knowledge/file-notes.ts";
|
|
21
|
+
import { readCachedScout, scoutKey, writeCachedScout } from "./scout-cache.ts";
|
|
18
22
|
import type { FileHinter } from "../classifier/files.ts";
|
|
19
23
|
import type { KnowledgePaths, KnowledgePicker } from "../classifier/knowledge.ts";
|
|
20
24
|
import { fellShort, profileLabel, routeLabel, type EffortRoute, type EffortRouter } from "../classifier/effort.ts";
|
|
@@ -24,6 +28,8 @@ export interface ScoutOutcome {
|
|
|
24
28
|
run: AgentRun;
|
|
25
29
|
issues: string[];
|
|
26
30
|
usable: boolean;
|
|
31
|
+
/** When these findings were taken, if they were reused: the same question on a tree that has not changed since. */
|
|
32
|
+
reused?: string;
|
|
27
33
|
}
|
|
28
34
|
|
|
29
35
|
export interface ScoutRequest {
|
|
@@ -97,7 +103,7 @@ function scoutContext(request: ScoutRequest, domain: Domain, selected: Knowledge
|
|
|
97
103
|
task: request.taskText,
|
|
98
104
|
...selected,
|
|
99
105
|
instructions: request.config.agents[domain].instructions,
|
|
100
|
-
workflowContext: [`Task state: scouting. Domain: ${domain}. Read-only reconnaissance; no implementation.`, likely, request.time?.note ?? ""].filter(Boolean).join("\n\n"),
|
|
106
|
+
workflowContext: [`Task state: scouting. Domain: ${domain}. Read-only reconnaissance; no implementation.`, toolBudgetNote(request.config.scout.maxToolCalls), likely, request.time?.note ?? ""].filter(Boolean).join("\n\n"),
|
|
101
107
|
};
|
|
102
108
|
}
|
|
103
109
|
|
|
@@ -139,8 +145,38 @@ function toOutcome(run: AgentRun, domain: Domain): ScoutOutcome {
|
|
|
139
145
|
return { result, run, issues: validateScoutResult(result), usable: isScoutResultUsable(result) };
|
|
140
146
|
}
|
|
141
147
|
|
|
142
|
-
/**
|
|
148
|
+
/**
|
|
149
|
+
* Run the selected domain scouts concurrently and persist their findings. A
|
|
150
|
+
* domain asked the same thing before on an unchanged tree reuses its earlier
|
|
151
|
+
* findings (`workflow.reuseScouts`); every new finding's Relevant Files are
|
|
152
|
+
* kept as file notes for the repository map.
|
|
153
|
+
*/
|
|
143
154
|
export async function runScouts(request: ScoutRequest, run: ProcessRunner = spawnPiProcess): Promise<ScoutOutcome[]> {
|
|
155
|
+
const dataDir = request.dataRoots[0];
|
|
156
|
+
const tree = dataDir && request.config.workflow.reuseScouts ? await treeState(request.cwd, request.dataRoots) : undefined;
|
|
157
|
+
const keys = request.domains.map((domain) => scoutKey(domain, scoutInstruction(request, domain), request.taskText));
|
|
158
|
+
const cached = keys.map((key) => (dataDir && tree ? readCachedScout(dataDir, key, tree) : undefined));
|
|
159
|
+
const fresh = request.domains.filter((_domain, index) => !cached[index]);
|
|
160
|
+
const ran = fresh.length > 0 ? await scoutDomains({ ...request, domains: fresh }, run) : [];
|
|
161
|
+
let next = 0;
|
|
162
|
+
const outcomes = request.domains.map((_domain, index) => cached[index] ?? ran[next++]!);
|
|
163
|
+
saveScoutResults(request.taskDir, outcomes);
|
|
164
|
+
if (dataDir) await remember(dataDir, request, outcomes, keys, tree);
|
|
165
|
+
return outcomes;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Keep what new scouts found: their answers for reuse while the tree is as it was, their files' notes for the map. */
|
|
169
|
+
async function remember(dataDir: string, request: ScoutRequest, outcomes: readonly ScoutOutcome[], keys: readonly string[], tree: string | undefined): Promise<void> {
|
|
170
|
+
const found = outcomes.map((outcome, index) => ({ outcome, key: keys[index]! })).filter(({ outcome }) => !outcome.reused && outcome.usable);
|
|
171
|
+
if (found.length === 0) return;
|
|
172
|
+
// Scouts only read: a tree that changed meanwhile was changed by someone else, and their answers may not match it.
|
|
173
|
+
if (tree && (await treeState(request.cwd, request.dataRoots)) === tree) {
|
|
174
|
+
for (const { outcome, key } of found) writeCachedScout(dataDir, key, tree, outcome);
|
|
175
|
+
}
|
|
176
|
+
await Promise.all(found.map(({ outcome }) => recordFileNotes(dataDir, request.cwd, outcome.result.domain, outcome.result.relevantFiles).catch(() => 0)));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function scoutDomains(request: ScoutRequest, run: ProcessRunner): Promise<ScoutOutcome[]> {
|
|
144
180
|
// Each scout gets the files most likely to answer its own instruction and focus.
|
|
145
181
|
const [likely, selected] = await Promise.all([
|
|
146
182
|
Promise.all(request.domains.map((domain) => likelyFor(request.hints, `${request.instruction}\n${domainSpec(domain).scoutFocus}`, request.signal, request.taskText))),
|
|
@@ -156,6 +192,7 @@ export async function runScouts(request: ScoutRequest, run: ProcessRunner = spaw
|
|
|
156
192
|
instruction: scoutInstruction(request, domain),
|
|
157
193
|
context: scoutContext(request, domain, selected[index]!, likely[index]),
|
|
158
194
|
...(extraTools.length > 0 ? { extraTools } : {}),
|
|
195
|
+
env: toolBudgetEnv(request.config.scout.maxToolCalls),
|
|
159
196
|
...profileFields(request.config, request.profile, domain, "scout"),
|
|
160
197
|
...(request.time ? { time: { ...request.time } } : {}),
|
|
161
198
|
cwd: request.cwd,
|
|
@@ -177,7 +214,6 @@ export async function runScouts(request: ScoutRequest, run: ProcessRunner = spaw
|
|
|
177
214
|
return position >= 0 ? toOutcome(reruns[position]!, reruns[position]!.domain) : outcome;
|
|
178
215
|
});
|
|
179
216
|
}
|
|
180
|
-
saveScoutResults(request.taskDir, outcomes);
|
|
181
217
|
return outcomes;
|
|
182
218
|
}
|
|
183
219
|
export function scoutResultPath(taskDir: string, domain: Domain): string {
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scout findings kept for reuse. A scout asked the same thing (same domain,
|
|
3
|
+
* same instruction, same task) on a work tree that has not changed since it
|
|
4
|
+
* last answered gives the same answer, so the engine hands the oracle the
|
|
5
|
+
* earlier findings instead of running it again: after a crash and resume, or
|
|
6
|
+
* when the oracle repeats a question. Any change to the tree (a commit, an
|
|
7
|
+
* edit, a new file) makes every cached answer stale.
|
|
8
|
+
*/
|
|
9
|
+
import { createHash } from "node:crypto";
|
|
10
|
+
import { mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import type { Domain } from "../schemas/agent.ts";
|
|
13
|
+
import type { ScoutOutcome } from "./master.ts";
|
|
14
|
+
|
|
15
|
+
interface CachedScout {
|
|
16
|
+
version: 1;
|
|
17
|
+
tree: string;
|
|
18
|
+
at: string;
|
|
19
|
+
outcome: ScoutOutcome;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Cached answers older than this are dropped when the next one is written. */
|
|
23
|
+
export const SCOUT_CACHE_DAYS = 7;
|
|
24
|
+
|
|
25
|
+
export function scoutCacheDir(dataDir: string): string {
|
|
26
|
+
return join(dataDir, "cache", "scouts");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The question a scout is asked, as a file name. */
|
|
30
|
+
export function scoutKey(domain: Domain, instruction: string, taskText: string): string {
|
|
31
|
+
return createHash("sha256").update(JSON.stringify([domain, instruction.trim(), taskText.trim()])).digest("hex").slice(0, 32);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The earlier answer to this question on this tree, marked with when it was taken; undefined when there is none or the tree changed. */
|
|
35
|
+
export function readCachedScout(dataDir: string, key: string, tree: string): ScoutOutcome | undefined {
|
|
36
|
+
try {
|
|
37
|
+
const cached = JSON.parse(readFileSync(join(scoutCacheDir(dataDir), `${key}.json`), "utf8")) as Partial<CachedScout>;
|
|
38
|
+
if (cached.version !== 1 || cached.tree !== tree || !cached.outcome?.usable || !cached.at) return undefined;
|
|
39
|
+
return { ...cached.outcome, reused: cached.at };
|
|
40
|
+
} catch {
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function pruneScoutCache(dir: string, now: number): void {
|
|
46
|
+
for (const name of readdirSync(dir)) {
|
|
47
|
+
const path = join(dir, name);
|
|
48
|
+
try {
|
|
49
|
+
if (now - statSync(path).mtimeMs > SCOUT_CACHE_DAYS * 86_400_000) rmSync(path, { force: true });
|
|
50
|
+
} catch {
|
|
51
|
+
// Gone already.
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Keep a usable answer for this question on this tree; failures are ignored (the cache only saves time). */
|
|
57
|
+
export function writeCachedScout(dataDir: string, key: string, tree: string, outcome: ScoutOutcome, now = new Date()): void {
|
|
58
|
+
if (!outcome.usable) return;
|
|
59
|
+
const dir = scoutCacheDir(dataDir);
|
|
60
|
+
try {
|
|
61
|
+
mkdirSync(dir, { recursive: true });
|
|
62
|
+
pruneScoutCache(dir, now.getTime());
|
|
63
|
+
const path = join(dir, `${key}.json`);
|
|
64
|
+
const { reused: _reused, ...fresh } = outcome;
|
|
65
|
+
writeFileSync(`${path}.tmp`, JSON.stringify({ version: 1, tree, at: now.toISOString(), outcome: fresh } satisfies CachedScout));
|
|
66
|
+
renameSync(`${path}.tmp`, path);
|
|
67
|
+
} catch {
|
|
68
|
+
// Without the cache the scout just runs next time.
|
|
69
|
+
}
|
|
70
|
+
}
|
package/src/master/synthesis.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Domain } from "../schemas/agent.ts";
|
|
2
|
+
import type { RelevantFile } from "../schemas/findings.ts";
|
|
2
3
|
import { truncate } from "../text.ts";
|
|
3
4
|
import type { ScoutOutcome } from "./master.ts";
|
|
4
5
|
|
|
@@ -34,16 +35,23 @@ export function detectSharedFiles(outcomes: ScoutOutcome[]): { path: string; dom
|
|
|
34
35
|
.map(([path, domains]) => ({ path, domains: [...domains] }));
|
|
35
36
|
}
|
|
36
37
|
|
|
38
|
+
/** A relevant file as `path:lines — why`, the way a brief should point at it. */
|
|
39
|
+
export function fileAnchor(file: RelevantFile, reasonChars = 140): string {
|
|
40
|
+
const anchor = file.lines ? `${file.path}:${file.lines}` : file.path;
|
|
41
|
+
return file.reason ? `${anchor} — ${truncate(file.reason, reasonChars)}` : anchor;
|
|
42
|
+
}
|
|
43
|
+
|
|
37
44
|
function describeOutcome(outcome: ScoutOutcome, rawLimit: number): string {
|
|
38
45
|
const { result } = outcome;
|
|
46
|
+
const reused = outcome.reused ? `, reused from ${outcome.reused.slice(0, 16).replace("T", " ")} UTC: same question, tree unchanged` : "";
|
|
39
47
|
const lines = [
|
|
40
|
-
`### ${result.domain} (${outcome.run.status}${outcome.usable ? "" : ", unusable"}) — confidence: ${result.confidence}`,
|
|
48
|
+
`### ${result.domain} (${outcome.run.status}${outcome.usable ? "" : ", unusable"}${reused}) — confidence: ${result.confidence}`,
|
|
41
49
|
result.scope ? `Scope: ${result.scope}` : "",
|
|
42
50
|
...result.findings.slice(0, 12).map((finding) => `- ${finding}`),
|
|
43
51
|
...result.risks.slice(0, 5).map((risk) => `- RISK: ${risk}`),
|
|
44
52
|
...result.recommendations.slice(0, 5).map((rec) => `- REC: ${rec}`),
|
|
45
53
|
result.relevantFiles.length > 0
|
|
46
|
-
? `Files
|
|
54
|
+
? `Files:\n${result.relevantFiles.slice(0, 10).map((file) => `- ${fileAnchor(file)}`).join("\n")}`
|
|
47
55
|
: "",
|
|
48
56
|
outcome.issues.length > 0 ? `Issues: ${outcome.issues.join("; ")}` : "",
|
|
49
57
|
result.raw.trim() ? `Raw (truncated): ${truncate(result.raw.trim(), rawLimit)}` : "",
|
package/src/pi/model-settings.ts
CHANGED
|
@@ -47,7 +47,7 @@ interface EntryPatch {
|
|
|
47
47
|
export function patchEntry(config: BotLobbyConfig, kind: SettingsKind, patch: EntryPatch): BotLobbyConfig {
|
|
48
48
|
const next: BotLobbyConfig = { ...config, agents: { ...config.agents } };
|
|
49
49
|
if (kind === "master") next.master = { ...config.master, ...patch } as AgentModelConfig;
|
|
50
|
-
else if (kind === "scout") next.scout = { model: patch.model ?? config.scout.model, timeoutMs: patch.timeoutMs ?? config.scout.timeoutMs };
|
|
50
|
+
else if (kind === "scout") next.scout = { ...config.scout, model: patch.model ?? config.scout.model, timeoutMs: patch.timeoutMs ?? config.scout.timeoutMs };
|
|
51
51
|
else if (kind === "researcher") next.researcher = { ...config.researcher, ...patch } as AgentModelConfig;
|
|
52
52
|
else if (kind === "quickfix") next.quickFix = { ...config.quickFix, ...patch } as AgentModelConfig;
|
|
53
53
|
else if (kind === "planner") next.planner = { ...config.planner, ...patch } as AgentModelConfig;
|
|
@@ -83,10 +83,12 @@ export function prefillModels(config: BotLobbyConfig, sessionModel: string | und
|
|
|
83
83
|
return { config: next, filled };
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
-
/** `on · OpenCode Zen · jev-1.13-free · key stored in pi
|
|
86
|
+
/** `on · OpenCode Zen · jev-1.13-free · key stored in pi` (or `on · openai/gpt-5-nano · your pi login`), for the settings menu and `/bot-lobby config`. */
|
|
87
87
|
export function classifierSummary(config: BotLobbyConfig, status: StatusSource | undefined): string {
|
|
88
|
+
const state = config.classifier.enabled ? "on" : "off";
|
|
89
|
+
if (config.classifier.engine) return [state, config.classifier.engine, "your pi login"].join(" · ");
|
|
88
90
|
const { host, model } = jevEndpoint(config.classifier, status);
|
|
89
91
|
const via = config.classifier.provider === "auto" ? `${host.label} (auto)` : host.label;
|
|
90
|
-
return [
|
|
92
|
+
return [state, via, model, `key ${describeKey(host, status)}`].join(" · ");
|
|
91
93
|
}
|
|
92
94
|
|
package/src/pi/plan-checklist.ts
CHANGED
|
@@ -438,7 +438,8 @@ export function stepWork(steps: readonly string[], runs: readonly AgentRun[], no
|
|
|
438
438
|
const ms = Number.isFinite(start) && Number.isFinite(end) ? Math.max(0, end - start) : 0;
|
|
439
439
|
for (const index of covered) {
|
|
440
440
|
if (!run.parentRunId) work[index]!.ms += ms;
|
|
441
|
-
|
|
441
|
+
// A run that has finished is off the step, whatever its last status said.
|
|
442
|
+
if (run.status === "running" && !run.finishedAt) {
|
|
442
443
|
work[index]!.active = true;
|
|
443
444
|
work[index]!.activeAgentCount += 1;
|
|
444
445
|
work[index]!.runIds.push(run.runId);
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A hard limit on a subagent's tool calls, enforced inside its own process so
|
|
3
|
+
* it holds on every provider and model. The engine sets the budget through
|
|
4
|
+
* `BOT_LOBBY_TOOL_BUDGET` (scouts get `scout.maxToolCalls`). At 60% the next
|
|
5
|
+
* tool result tells the agent to wrap up; past the limit every call is
|
|
6
|
+
* refused with the reason, so the agent writes its report with what it has.
|
|
7
|
+
* An agent that keeps calling anyway is stopped after a few refusals.
|
|
8
|
+
*/
|
|
9
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
10
|
+
|
|
11
|
+
export const TOOL_BUDGET_ENV = "BOT_LOBBY_TOOL_BUDGET";
|
|
12
|
+
|
|
13
|
+
export interface ToolBudget {
|
|
14
|
+
/** The call after which the agent is told to wrap up. */
|
|
15
|
+
warnAt: number;
|
|
16
|
+
/** Calls past this are refused. */
|
|
17
|
+
max: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Refusals in a row after which the run is stopped rather than refused again. */
|
|
21
|
+
export const MAX_REFUSALS = 5;
|
|
22
|
+
|
|
23
|
+
/** The env that gives a subagent `max` tool calls; none for 0 (no limit). */
|
|
24
|
+
export function toolBudgetEnv(max: number): Record<string, string> {
|
|
25
|
+
return max > 0 ? { [TOOL_BUDGET_ENV]: String(Math.round(max)) } : {};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function parseToolBudget(value: string | undefined): ToolBudget | undefined {
|
|
29
|
+
const max = Number(value);
|
|
30
|
+
if (!value || !Number.isFinite(max) || max < 1) return undefined;
|
|
31
|
+
return { warnAt: Math.max(1, Math.round(max * 0.6)), max: Math.round(max) };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** What the agent is told up front, in its workflow context. */
|
|
35
|
+
export function toolBudgetNote(max: number): string {
|
|
36
|
+
const budget = parseToolBudget(String(max));
|
|
37
|
+
return budget ? `Tool budget: at most ${budget.max} tool calls. After ${budget.warnAt} you are told to wrap up; past ${budget.max} every call is refused and you report with what you have.` : "";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function budgetWarning(used: number, budget: ToolBudget): string {
|
|
41
|
+
return `bot-lobby: ${used} of your ${budget.max} tool calls are used. Stop exploring: check only what your answer still needs, then write your report.`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function budgetRefusal(budget: ToolBudget): string {
|
|
45
|
+
return `bot-lobby: your ${budget.max} tool calls are spent, so this call was not run. Write your report now in the required format with what you have, and mark what you did not verify as an assumption.`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Count the agent's own tool calls (not those a tool made for it), warn once, then refuse. */
|
|
49
|
+
export function registerToolBudget(pi: ExtensionAPI, budget = parseToolBudget(process.env[TOOL_BUDGET_ENV])): void {
|
|
50
|
+
if (!budget) return;
|
|
51
|
+
let used = 0;
|
|
52
|
+
let refused = 0;
|
|
53
|
+
let warned = false;
|
|
54
|
+
pi.on("tool_call", (event) => {
|
|
55
|
+
if (event.parentToolCallId) return undefined;
|
|
56
|
+
used += 1;
|
|
57
|
+
if (used <= budget.max) return undefined;
|
|
58
|
+
refused += 1;
|
|
59
|
+
return { block: true, reason: budgetRefusal(budget), ...(refused >= MAX_REFUSALS ? { terminate: true } : {}) };
|
|
60
|
+
});
|
|
61
|
+
pi.on("tool_result", (event) => {
|
|
62
|
+
if (event.parentToolCallId || warned || used < budget.warnAt || used > budget.max) return undefined;
|
|
63
|
+
warned = true;
|
|
64
|
+
return { content: [...event.content, { type: "text" as const, text: budgetWarning(used, budget) }] };
|
|
65
|
+
});
|
|
66
|
+
}
|
package/src/pi/tools.ts
CHANGED
|
@@ -60,6 +60,7 @@ const OrchestrateSchema = Type.Object({
|
|
|
60
60
|
),
|
|
61
61
|
roster: Type.Optional(Type.Array(Type.String(), { description: "track: who takes part — any of designer, backend, qa, researcher" })),
|
|
62
62
|
text: Type.Optional(Type.String({ description: "decide/complete/knowledge/script: the decision, completion summary, knowledge text, or the script itself" })),
|
|
63
|
+
message: Type.Optional(Type.String({ description: "complete: the commit message for the agents' changes, which the engine commits (only their files): a first line of 72 characters or fewer saying what changed, at most three short lines below it" })),
|
|
63
64
|
file: Type.Optional(Type.String({ description: "compact: the knowledge file to rewrite, e.g. knowledge.md; script: a script a worker drafted, to save instead of text" })),
|
|
64
65
|
name: Type.Optional(Type.String({ description: "whiteboard: name for the new Excalidraw session; script: the file name, what it does in snake_case with its language's extension (seed_public_holidays.py, run_changed_tests.sh)" })),
|
|
65
66
|
purpose: Type.Optional(Type.String({ description: "script: one line on what the script does" })),
|
package/src/roles/markdown.ts
CHANGED
|
@@ -56,9 +56,17 @@ export function bullets(text: string | undefined): string[] {
|
|
|
56
56
|
.filter((line) => line.length > 0);
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
-
/**
|
|
60
|
-
|
|
59
|
+
/** Split a `path:12-40` (or `path#L12-L40`) anchor into the path and its lines. */
|
|
60
|
+
function splitAnchor(anchor: string): { path: string; lines?: string } {
|
|
61
|
+
const match = /^(.+?)(?::|#L)(\d+)(?::\d+)?(?:\s*[-–]\s*L?(\d+)(?::\d+)?)?$/.exec(anchor);
|
|
62
|
+
if (!match) return { path: anchor };
|
|
63
|
+
return { path: match[1]!, lines: match[3] && match[3] !== match[2] ? `${match[2]}-${match[3]}` : match[2]! };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Parse a `path — reason` bullet (the path may carry lines: `path:12-40`) into its parts. */
|
|
67
|
+
export function parseFileBullet(text: string): { path: string; lines?: string; reason: string } {
|
|
61
68
|
const match = /^`?([^`\s]+)`?\s*(?:[—–]|--|-)\s*(.*)$/.exec(text);
|
|
62
|
-
|
|
63
|
-
|
|
69
|
+
const [anchor, reason] = match ? [match[1]!, match[2]!.trim()] : [text.trim().replace(/^`|`$/g, ""), ""];
|
|
70
|
+
const { path, lines } = splitAnchor(anchor);
|
|
71
|
+
return lines ? { path, lines, reason } : { path, reason };
|
|
64
72
|
}
|
package/src/roles/scout.ts
CHANGED
|
@@ -12,7 +12,8 @@ export const scoutSpec: RoleSpec = {
|
|
|
12
12
|
"Respond with exactly these sections and nothing else:",
|
|
13
13
|
"`## Scope`, `## Findings`, `## Relevant Files`, `## Existing Patterns`,",
|
|
14
14
|
"`## Risks`, `## Recommendations`, `## Confidence`.",
|
|
15
|
-
"Use `- ` bullets. `## Relevant Files` entries are `- \\`path\\` — reason
|
|
15
|
+
"Use `- ` bullets. `## Relevant Files` entries are `- \\`path:start-end\\` — reason`, with the lines of the code that matters",
|
|
16
|
+
"(`path:42` for one line, the bare `path` only when the whole file does), so the worker opens the exact place.",
|
|
16
17
|
"`## Confidence` is exactly one of `High`, `Medium`, `Low`.",
|
|
17
18
|
"Keep the whole response under 400 words. Report uncertainty; do not implement.",
|
|
18
19
|
"An optional `## Pushback` (`**Request:**`, `**Reason:**`, optional `**Alternative:**`) flags a change you",
|