@a-t-h-i/bot-lobby 0.6.39 → 0.6.41

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.
@@ -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
 
@@ -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
+ }
@@ -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",
@@ -38,6 +38,8 @@ export interface ScoutConfig {
38
38
  mcpTools?: string[];
39
39
  model: ModelRef;
40
40
  timeoutMs: number;
41
+ /** Tool calls a scout may make: it is told to wrap up at 60% and refused more past this, so it reports with what it has; 0 = no limit. */
42
+ maxToolCalls: number;
41
43
  }
42
44
 
43
45
  /** Subagent kinds with their own settings entry. */
@@ -91,6 +93,10 @@ export interface WorkflowConfig {
91
93
  routeQuickFixes: boolean;
92
94
  /** A new task gets a git branch or worktree of its own, named after it; `--branch` and `--worktree` decide for one task. */
93
95
  gitIsolation: GitIsolation;
96
+ /** Scouts, workers, quick fixes and the planner get the repository map: the layout and the files matching their step, with each file's purpose, names and unchanged scout notes. */
97
+ repoMap: boolean;
98
+ /** A scout asked the same thing again for the same task, on a tree that has not changed, reuses its findings instead of running. */
99
+ reuseScouts: boolean;
94
100
  }
95
101
 
96
102
  /**
@@ -206,9 +212,15 @@ export interface ClassifierThresholds {
206
212
  quickFixLargeAt: number;
207
213
  }
208
214
 
209
- /** The Jev classifier: a fast model for obvious decisions, so large models spend fewer tokens on them. */
215
+ /** The classifier: a fast model for obvious decisions, so large models spend fewer tokens on them. */
210
216
  export interface ClassifierConfig {
211
217
  enabled: boolean;
218
+ /**
219
+ * The model that answers, as `provider/id`: any classifier model pi lists
220
+ * (Jev, Cloudflare's clef, Liquid's d1, …) or any chat model you are signed
221
+ * in to (OpenAI, Anthropic, Google, …). Empty calls Jev on `provider`.
222
+ */
223
+ engine: string;
212
224
  /** Where Jev is called; the key comes from pi's own key store for that host. */
213
225
  provider: JevHostName;
214
226
  /** A pinned Jev model; empty uses the host's default. */
@@ -250,7 +262,7 @@ export const DEFAULT_CONFIG: BotLobbyConfig = {
250
262
  backend: { model: INHERIT_MODEL, thinking: DEFAULT_THINKING, instructions: "", timeoutMs: 15 * 60 * 1000 },
251
263
  qa: { model: INHERIT_MODEL, thinking: DEFAULT_THINKING, instructions: "", timeoutMs: 15 * 60 * 1000 },
252
264
  },
253
- scout: { model: INHERIT_MODEL, timeoutMs: 8 * 60 * 1000 },
265
+ scout: { model: INHERIT_MODEL, timeoutMs: 8 * 60 * 1000, maxToolCalls: 30 },
254
266
  researcher: { model: INHERIT_MODEL, thinking: "low", instructions: "", timeoutMs: 10 * 60 * 1000 },
255
267
  quickFix: { model: INHERIT_MODEL, thinking: "low", instructions: "", timeoutMs: 10 * 60 * 1000 },
256
268
  planner: { model: INHERIT_MODEL, thinking: "high", instructions: "", timeoutMs: 5 * 60 * 1000 },
@@ -272,6 +284,8 @@ export const DEFAULT_CONFIG: BotLobbyConfig = {
272
284
  briefCheck: true,
273
285
  routeQuickFixes: true,
274
286
  gitIsolation: "off",
287
+ repoMap: true,
288
+ reuseScouts: true,
275
289
  },
276
290
  lint: { mode: "advise", command: "", extensions: [], timeoutMs: 2 * 60 * 1000 },
277
291
  knowledge: {
@@ -292,6 +306,7 @@ export const DEFAULT_CONFIG: BotLobbyConfig = {
292
306
  },
293
307
  classifier: {
294
308
  enabled: false,
309
+ engine: "",
295
310
  provider: "auto",
296
311
  model: "",
297
312
  baseUrl: "",
@@ -346,6 +361,7 @@ function normalizeScout(override: Partial<ScoutConfig> | undefined): ScoutConfig
346
361
  return {
347
362
  model: typeof override?.model === "string" && override.model.trim() ? override.model : base.model,
348
363
  timeoutMs: positive(override?.timeoutMs) ?? base.timeoutMs,
364
+ maxToolCalls: typeof override?.maxToolCalls === "number" && Number.isFinite(override.maxToolCalls) && override.maxToolCalls >= 0 ? Math.round(override.maxToolCalls) : base.maxToolCalls,
349
365
  ...(mcpTools.length ? { mcpTools } : {}),
350
366
  };
351
367
  }
@@ -432,6 +448,7 @@ function normalizeClassifier(value: unknown): ClassifierConfig {
432
448
  const provider = typeof source.provider === "string" && (JEV_HOSTS as readonly string[]).includes(source.provider) ? source.provider as JevHostName : defaults.provider;
433
449
  return {
434
450
  enabled: flag(source.enabled, defaults.enabled),
451
+ engine: text(source.engine, defaults.engine).trim(),
435
452
  provider,
436
453
  model: text(source.model, defaults.model),
437
454
  baseUrl: text(source.baseUrl, defaults.baseUrl),
@@ -456,6 +473,8 @@ export function resolveConfig(partial: unknown): BotLobbyConfig {
456
473
  workflow.fastTrack = workflow.fastTrack !== false;
457
474
  workflow.briefCheck = workflow.briefCheck !== false;
458
475
  workflow.routeQuickFixes = workflow.routeQuickFixes !== false;
476
+ workflow.repoMap = workflow.repoMap !== false;
477
+ workflow.reuseScouts = workflow.reuseScouts !== false;
459
478
  workflow.gitIsolation = isGitIsolation(workflow.gitIsolation) ? workflow.gitIsolation : DEFAULT_CONFIG.workflow.gitIsolation;
460
479
  workflow.taskBudgetMinutes = typeof workflow.taskBudgetMinutes === "number" && workflow.taskBudgetMinutes > 0 ? Math.min(24 * 60, Math.round(workflow.taskBudgetMinutes)) : 0;
461
480
  const knowledge = { ...DEFAULT_CONFIG.knowledge, ...(src.knowledge as Partial<KnowledgeConfig> | undefined) };
@@ -3,6 +3,8 @@ import type { Blocker, Verdict } from "./task.ts";
3
3
 
4
4
  export interface RelevantFile {
5
5
  path: string;
6
+ /** The lines that matter (`42` or `12-40`), when the scout gave them as `path:12-40`. */
7
+ lines?: string;
6
8
  reason: string;
7
9
  }
8
10
 
@@ -90,7 +90,8 @@ export function settingsGet(ctx: ApiContext): SettingsInfo {
90
90
  config = prefilled.config;
91
91
  pushNotice(`${prefilled.filled.map(kindLabel).join(", ")} now run on the session model; change them here any time.`);
92
92
  }
93
- return { config: stripSecrets(config), models: ctx.service.models?.() ?? [] };
93
+ const classifiers = ctx.service.classifierModels?.() ?? [];
94
+ return { config: stripSecrets(config), models: ctx.service.models?.() ?? [], ...(classifiers.length > 0 ? { classifierModels: classifiers } : {}) };
94
95
  }
95
96
 
96
97
  /** The linters the project configures (its top and a few folders below), and whether each is installed. */
@@ -455,6 +455,7 @@ export function createFixtureService(name: string, feed = new LobbyFeed()): Lobb
455
455
  },
456
456
  configChanged: () => {},
457
457
  models: () => models.map((model) => ({ id: model.id, label: model.label, thinkingLevels: [...model.thinkingLevels] })),
458
+ classifierModels: () => [{ id: "opencode/jev-1.13-free", label: "Jev 1.13 (free)" }, { id: "openrouter/liquid/d1", label: "Liquid D1" }],
458
459
  issuesEnabled: () => fixture.status.issuesEnabled,
459
460
  workspace: () => ({ ...fixture.status.workspace }),
460
461
  zen: () => ({ ...(fixture.zen.task ? { task: { ...fixture.zen.task } } : {}), runs: [...fixture.zen.runs] }),
@@ -200,6 +200,14 @@ export interface SettingsLinter {
200
200
  export interface SettingsInfo {
201
201
  config: BotLobbyConfig;
202
202
  models: SettingsModelInfo[];
203
+ /** Classifier models pi can call with the logins it holds (Jev on each host, Cloudflare's clef, Liquid's d1, …). */
204
+ classifierModels?: SettingsClassifierModel[];
205
+ }
206
+
207
+ /** A classifier model for the classifier's picker: `provider/id` and its name. */
208
+ export interface SettingsClassifierModel {
209
+ id: string;
210
+ label: string;
203
211
  }
204
212
 
205
213
  /** One message on the event stream (`GET /api/events`, server-sent events). */