@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
@@ -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
 
@@ -16,6 +16,8 @@ import { taskDirFor } from "./persistence.ts";
16
16
  export interface ActiveRun {
17
17
  runId: string;
18
18
  parentRunId?: string;
19
+ /** The worker's domain (`backend`, `designer`), for who is doing what. */
20
+ domain?: string;
19
21
  stepInstruction?: string;
20
22
  instruction: string;
21
23
  startedAt: string;
@@ -104,7 +106,7 @@ export function taskWork(root: string, configDir: string, taskId: string, now =
104
106
  const noted = new Map<string, Set<string>>();
105
107
 
106
108
  /** A worker run of the task started or ended: the steps it works on read as active while it runs. */
107
- export function noteWorkerRun(root: string, configDir: string, taskId: string, run: { runId: string; parentRunId?: string; stepInstruction?: string; instruction?: string; startedAt: string; status: string }): void {
109
+ export function noteWorkerRun(root: string, configDir: string, taskId: string, run: { runId: string; parentRunId?: string; stepInstruction?: string; instruction?: string; domain?: string; startedAt: string; status: string }): void {
108
110
  const running = run.status === "running";
109
111
  const ids = noted.get(taskId) ?? new Set<string>();
110
112
  if (running === ids.has(run.runId)) return;
@@ -113,7 +115,7 @@ export function noteWorkerRun(root: string, configDir: string, taskId: string, r
113
115
  noted.set(taskId, ids);
114
116
  updateWork(root, configDir, taskId, (work) => {
115
117
  work.active = work.active.filter((entry) => entry.runId !== run.runId);
116
- if (running) work.active.push({ runId: run.runId, instruction: run.instruction ?? "", startedAt: run.startedAt, ...(run.parentRunId ? { parentRunId: run.parentRunId } : {}), ...(run.stepInstruction ? { stepInstruction: run.stepInstruction } : {}) });
118
+ if (running) work.active.push({ runId: run.runId, instruction: run.instruction ?? "", startedAt: run.startedAt, ...(run.parentRunId ? { parentRunId: run.parentRunId } : {}), ...(run.domain ? { domain: run.domain } : {}), ...(run.stepInstruction ? { stepInstruction: run.stepInstruction } : {}) });
117
119
  });
118
120
  }
119
121
 
@@ -30,7 +30,7 @@ function activeRuns(task: Task, work: WorkProjection | undefined): AgentRun[] {
30
30
  const finished = new Set((task.workerRuns ?? []).map((record) => record.runId));
31
31
  return (work?.active ?? [])
32
32
  .filter((run) => !finished.has(run.runId))
33
- .map((run) => ({ runId: run.runId, parentRunId: run.parentRunId, stepInstruction: run.stepInstruction, taskId: task.id, domain: "backend" as const, role: "worker" as const, status: "running" as const, instruction: run.instruction, output: "", attempts: 1, startedAt: run.startedAt }));
33
+ .map((run) => ({ runId: run.runId, parentRunId: run.parentRunId, stepInstruction: run.stepInstruction, taskId: task.id, domain: (run.domain === "designer" || run.domain === "qa" ? run.domain : "backend") as AgentRun["domain"], role: "worker" as const, status: "running" as const, instruction: run.instruction, output: "", attempts: 1, startedAt: run.startedAt }));
34
34
  }
35
35
 
36
36
  /** One step as the lobby shows it: its state, and while a worker is on it, how long they have worked on it. */
@@ -40,10 +40,20 @@ export interface StepView {
40
40
  active: boolean;
41
41
  activeAgentCount: number;
42
42
  runIds: string[];
43
+ /** The agents on it right now: who, doing what, since when; a child names its parent. */
44
+ agents: StepAgent[];
43
45
  /** Worker time on the step, in ms. */
44
46
  workedMs: number;
45
47
  }
46
48
 
49
+ export interface StepAgent {
50
+ runId: string;
51
+ parentRunId?: string;
52
+ domain: string;
53
+ instruction: string;
54
+ startedAt: string;
55
+ }
56
+
47
57
  /**
48
58
  * The plan's steps with their state, which ones a worker is on right now and
49
59
  * the worker time on each. A step being worked on is never done, so steps run
@@ -54,8 +64,13 @@ export function stepViews(task: Task, work: WorkProjection | undefined, now = Da
54
64
  const runs = [...persistedTaskRuns(task), ...activeRuns(task, work)];
55
65
  const checklist = planChecklist(task.plan, runs);
56
66
  const times = stepWork(planSteps(task.plan), runs, now);
67
+ const byId = new Map(runs.map((run) => [run.runId, run]));
57
68
  return checklist.map((step, index) => {
58
69
  const time = times[index] ?? { ms: 0, active: false, activeAgentCount: 0, runIds: [] };
59
- return { text: step.text, status: time.active ? "current" : step.status, active: time.active, activeAgentCount: time.activeAgentCount, runIds: time.runIds, workedMs: Math.round(time.ms) };
70
+ const agents = time.runIds.flatMap((runId) => {
71
+ const run = byId.get(runId);
72
+ return run ? [{ runId, ...(run.parentRunId ? { parentRunId: run.parentRunId } : {}), domain: run.domain, instruction: (run.instruction ?? "").split("\n")[0]!.trim(), startedAt: run.startedAt }] : [];
73
+ });
74
+ return { text: step.text, status: time.active ? "current" : step.status, active: time.active, activeAgentCount: time.activeAgentCount, runIds: time.runIds, agents, workedMs: Math.round(time.ms) };
60
75
  });
61
76
  }
@@ -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. */
@@ -311,7 +311,7 @@ export function tasksGet(body: { taskId: string }, ctx: ApiContext): TaskDetail
311
311
  steps: stepViews(found, work, now).map((step) => ({
312
312
  text: step.text,
313
313
  status: step.status === "pending" ? "open" : step.status,
314
- ...(step.active ? { active: true, workedMs: step.workedMs, activeAgentCount: step.activeAgentCount } : {}),
314
+ ...(step.active ? { active: true, workedMs: step.workedMs, activeAgentCount: step.activeAgentCount, agents: step.agents } : {}),
315
315
  })),
316
316
  amendments: [...found.amendments],
317
317
  waiting: waitingOf(found),
@@ -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). */
@@ -420,7 +428,7 @@ export interface TaskDetail {
420
428
  * The plan as a checklist, in order; `current` is the step under way, `active` the steps a worker is on right now,
421
429
  * each with its worker time (ms, as of the reply).
422
430
  */
423
- steps: Array<{ text: string; status: "done" | "current" | "open"; active?: boolean; workedMs?: number; activeAgentCount?: number }>;
431
+ steps: Array<{ text: string; status: "done" | "current" | "open"; active?: boolean; workedMs?: number; activeAgentCount?: number; agents?: StepAgentInfo[] }>;
424
432
  /** How long the task's agents have worked on it, idle time left out (ms, as of the reply); `running` while they work. */
425
433
  work?: WorkClock;
426
434
  amendments: string[];
@@ -676,3 +684,12 @@ export interface Api {
676
684
  }
677
685
 
678
686
  export type ApiName = keyof Api;
687
+
688
+ /** An agent on a step right now: its domain, what it was told (first line), since when; a child names its parent. */
689
+ export interface StepAgentInfo {
690
+ runId: string;
691
+ parentRunId?: string;
692
+ domain: string;
693
+ instruction: string;
694
+ startedAt: string;
695
+ }
@@ -32,7 +32,7 @@ import { mapConcurrent } from "../execution/agent-runner.ts";
32
32
  import { parseWorkerResult } from "../roles/worker.ts";
33
33
  import { autoNote, DESK_TOOLS, DeskSession } from "../desk/session.ts";
34
34
  import type { Handover } from "../desk/desk.ts";
35
- import { changedFiles, commitBefore, headCommit, readRepositoryDiff } from "../execution/git.ts";
35
+ import { changedFiles, commitBefore, commitFiles, commitMessage, headCommit, readRepositoryDiff, uncommitted } from "../execution/git.ts";
36
36
  import { appendChange, explainChanges, provenanceLines, provenanceSummary, readChanges, type FileProvenance } from "../state/changes.ts";
37
37
  import { budgetLine, budgetState, formatMinutes, MIN_READ_MS, parseMinutes, qaAllotment, readBudget, readerAllotment, updateBudget, workerAllotment, type Allotment, type BudgetState, type TaskBudget } from "../state/budget.ts";
38
38
  import type { AgentTime } from "../execution/agent-runner.ts";
@@ -141,6 +141,8 @@ export interface OrchestrateParams {
141
141
  note?: string;
142
142
  reason?: string;
143
143
  text?: string;
144
+ /** complete: the commit message for the agents' changes. */
145
+ message?: string;
144
146
  /** track: the path the task takes. */
145
147
  track?: "fast" | "full";
146
148
  /** track: who takes part (designer, backend, qa, researcher). */
@@ -1570,6 +1572,7 @@ async function handleComplete(task: Task, params: OrchestrateParams, deps: Workf
1570
1572
  // The gate refuses before any side effect, so a refusal leaves the task untouched.
1571
1573
  const oversized = overThreshold(readDataRoots(deps.root, deps.configDir), deps.config.knowledge.compactionThreshold);
1572
1574
  if (oversized.length > 0) throw new Error(`cannot complete: knowledge files over the compaction threshold: ${oversized.map((entry) => `${entry.agent}/${entry.file} (${entry.chars})`).join(", ")}. First record domain-relevant facts where they belong with action=knowledge (domain=designer|backend|qa), then rewrite each oversized file with action=compact, then call complete again.`);
1575
+ const committed = await commitTaskWork(task, params, deps);
1573
1576
  const summary = params.text?.trim() || task.proposal || task.title;
1574
1577
  recordCompletion(deps, task, summary);
1575
1578
  removeTaskScratchpads(deps.root, deps.configDir, task.id);
@@ -1581,7 +1584,43 @@ async function handleComplete(task: Task, params: OrchestrateParams, deps: Workf
1581
1584
  flushDecisions(deps, task);
1582
1585
  const delivery = await offerDelivery(task, deps);
1583
1586
  const left = lint?.state === "failing" ? ` ${task.lintWaiver ? "Accepted by the user with lint errors" : "Lint (advisory)"}: ${lintSummary(lint)} Tell the user.` : "";
1584
- return `Task ${task.id} completed. History recorded and temporary scratchpads removed. ${delivery}${left}`;
1587
+ return `Task ${task.id} completed.${committed} History recorded and temporary scratchpads removed. ${delivery}${left}`;
1588
+ }
1589
+
1590
+ /** The files a task's own workers changed: not one that held changes before they started, nor another task's. */
1591
+ function agentsWork(files: readonly FileProvenance[]): string[] {
1592
+ return files.filter((file) => file.kinds.includes("planned") && !file.kinds.includes("pre-existing") && !file.kinds.includes("other-task")).map((file) => file.path);
1593
+ }
1594
+
1595
+ /**
1596
+ * The oracle commits what its agents changed before the task is called done,
1597
+ * with its own short message (`message`); nothing else in the tree is
1598
+ * staged or committed. "" when git is not in use or nothing is left to commit.
1599
+ */
1600
+ async function commitTaskWork(task: Task, params: OrchestrateParams, deps: WorkflowDeps): Promise<string> {
1601
+ const state = await changeState(deps, task, await reviewBase(task, deps));
1602
+ if (!state) return "";
1603
+ const ours = agentsWork(state.files);
1604
+ const dirty = await uncommitted(state.top, ours).catch(() => []);
1605
+ if (dirty.length === 0) return "";
1606
+ const listed = dirty.slice(0, 20).join(", ") + (dirty.length > 20 ? ` and ${dirty.length - 20} more` : "");
1607
+ if (!params.message?.trim()) throw new Error(`cannot complete: commit the agents' changes first. Call complete again with message: a concise commit message for them (a first line of 72 characters or fewer saying what changed, at most three short lines below it). The engine commits only these files: ${listed}`);
1608
+ let message: string;
1609
+ try {
1610
+ message = commitMessage(params.message);
1611
+ } catch (error) {
1612
+ throw new Error(`cannot complete: ${(error as Error).message}`);
1613
+ }
1614
+ let result: Awaited<ReturnType<typeof commitFiles>>;
1615
+ try {
1616
+ result = await commitFiles(state.top, dirty, message);
1617
+ } catch (error) {
1618
+ throw new Error(`cannot complete: git refused the commit: ${(error as Error).message}`);
1619
+ }
1620
+ if (!result) return "";
1621
+ const others = await uncommitted(state.top, state.files.map((file) => file.path).filter((path) => !ours.includes(path))).catch(() => []);
1622
+ const left = others.length > 0 ? ` Left uncommitted, not this task's agents' work: ${others.slice(0, 10).join(", ")}${others.length > 10 ? ` and ${others.length - 10} more` : ""}.` : "";
1623
+ return ` Committed ${result.files.length} file${result.files.length === 1 ? "" : "s"} as ${result.commit.slice(0, 7)}: "${message.split("\n")[0]}".${left}`;
1585
1624
  }
1586
1625
 
1587
1626
  /** Completion always offers delivery to the user, including in auto mode. */