@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.
Files changed (45) hide show
  1. package/README.md +54 -11
  2. package/package.json +1 -1
  3. package/prompts/global.md +12 -0
  4. package/prompts/master.md +21 -3
  5. package/prompts/quickfix.md +10 -3
  6. package/prompts/scout.md +8 -4
  7. package/prompts/worker.md +5 -3
  8. package/src/classifier/chat.ts +122 -0
  9. package/src/classifier/classifier.ts +46 -11
  10. package/src/classifier/files.ts +91 -23
  11. package/src/classifier/instance.ts +35 -2
  12. package/src/classifier/models.ts +115 -0
  13. package/src/execution/git.ts +108 -2
  14. package/src/index.ts +3 -0
  15. package/src/knowledge/file-notes.ts +104 -0
  16. package/src/knowledge/repo-map.ts +165 -0
  17. package/src/lobby/host.ts +2 -0
  18. package/src/lobby/quickfix.ts +55 -2
  19. package/src/lobby/service.ts +2 -1
  20. package/src/master/master.ts +39 -3
  21. package/src/master/scout-cache.ts +70 -0
  22. package/src/master/synthesis.ts +10 -2
  23. package/src/pi/model-settings.ts +5 -3
  24. package/src/pi/plan-checklist.ts +2 -1
  25. package/src/pi/tool-budget.ts +66 -0
  26. package/src/pi/tools.ts +1 -0
  27. package/src/roles/markdown.ts +12 -4
  28. package/src/roles/scout.ts +2 -1
  29. package/src/schemas/configuration.ts +21 -2
  30. package/src/schemas/findings.ts +2 -0
  31. package/src/state/work-time.ts +4 -2
  32. package/src/webui/api/plan-facts.ts +17 -2
  33. package/src/webui/api/settings.ts +2 -1
  34. package/src/webui/api/tasks.ts +1 -1
  35. package/src/webui/dev/fake-service.ts +1 -0
  36. package/src/webui/protocol.ts +18 -1
  37. package/src/workflow/workflow.ts +41 -2
  38. package/webui/dist/assets/index-BV9aLD_v.js +109 -0
  39. package/webui/dist/assets/index-CzPF26MA.js +109 -0
  40. package/webui/dist/assets/index-Glz3FAPp.css +2 -0
  41. package/webui/dist/assets/index-uTndyUfm.css +2 -0
  42. package/webui/dist/build.json +17 -21
  43. package/webui/dist/index.html +2 -2
  44. package/webui/dist/assets/index-BFsMFIr5.js +0 -110
  45. 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`). */
@@ -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,
@@ -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) => {
@@ -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
- /** Run the selected domain scouts concurrently and persist their findings. */
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
+ }
@@ -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: ${result.relevantFiles.slice(0, 10).map((file) => file.path).join(", ")}`
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)}` : "",
@@ -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`, for the settings menu and `/bot-lobby config`. */
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 [config.classifier.enabled ? "on" : "off", via, model, `key ${describeKey(host, status)}`].join(" · ");
92
+ return [state, via, model, `key ${describeKey(host, status)}`].join(" · ");
91
93
  }
92
94
 
@@ -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
- if (run.status === "running") {
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" })),
@@ -56,9 +56,17 @@ export function bullets(text: string | undefined): string[] {
56
56
  .filter((line) => line.length > 0);
57
57
  }
58
58
 
59
- /** Parse a `path — reason` bullet into its parts. */
60
- export function parseFileBullet(text: string): { path: string; reason: string } {
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
- if (match) return { path: match[1]!, reason: match[2]!.trim() };
63
- return { path: text.trim(), reason: "" };
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
  }
@@ -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",