@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.
@@ -13,11 +13,13 @@ import { scriptPicker } from "./scripts.ts";
13
13
  import type { ScriptPicker } from "../scripts/pick.ts";
14
14
  import { registerJevProvider, type KeySource, type KeyStatus } from "./hosts.ts";
15
15
  import { fileHinter, type FileHinter, type FileScope } from "./files.ts";
16
+ import { withRepoMap } from "../knowledge/repo-map.ts";
16
17
  import { knowledgePicker, type KnowledgePicker } from "./knowledge.ts";
17
18
  import { lobbyFeed } from "../lobby/feed.ts";
18
19
  import { triageLine, triageWithContext } from "./triage.ts";
19
20
  import type { TaskTriage } from "../schemas/task.ts";
20
21
  import { effortRouter, type EffortRouter } from "./effort.ts";
22
+ import { engineAnswer, engineClassifiers, type EngineAnswer, type EngineModel, type Registry } from "./models.ts";
21
23
 
22
24
  interface Binding {
23
25
  cwd: string;
@@ -25,6 +27,8 @@ interface Binding {
25
27
  configDir: string;
26
28
  keys: KeySource;
27
29
  status: (piProvider: string) => KeyStatus | undefined;
30
+ engine: EngineAnswer;
31
+ registry: Registry;
28
32
  notify?: (message: string, level: "info" | "warning" | "error") => void;
29
33
  }
30
34
 
@@ -35,6 +39,10 @@ export function classifier(): Classifier {
35
39
  instance ??= new Classifier({
36
40
  config: () => loadConfig().classifier,
37
41
  keys: async (piProvider) => binding?.keys(piProvider),
42
+ engine: (ref, request, options) => {
43
+ if (!binding) throw new Error(`${ref} is not reachable before a pi session starts`);
44
+ return binding.engine(ref, request, options);
45
+ },
38
46
  metrics: (record) => {
39
47
  if (binding) appendMetrics(binding.root, binding.configDir, [record]);
40
48
  },
@@ -43,9 +51,13 @@ export function classifier(): Classifier {
43
51
  return instance;
44
52
  }
45
53
 
46
- /** Likely files for agents working in a tree, with what was found logged to the lobby's activity feed. */
54
+ /**
55
+ * Likely files for agents working in a tree, with what was found logged to the
56
+ * lobby's activity feed, and the repository map while `workflow.repoMap` is on.
57
+ */
47
58
  export function hintsFor(scope: FileScope): FileHinter {
48
- return fileHinter(classifier(), scope, (text) => lobbyFeed.log("CLASSIFIER", text, "info"));
59
+ const hinter = fileHinter(classifier(), scope, (text) => lobbyFeed.log("CLASSIFIER", text, "info"));
60
+ return withRepoMap(hinter, scope, () => ({ on: loadConfig().workflow.repoMap, exclude: classifier().config.exclude }));
49
61
  }
50
62
 
51
63
  /** Relevant knowledge and shared scripts for agents, with what Jev kept and matched logged to the lobby's activity feed. */
@@ -84,6 +96,24 @@ export function keyStatus(piProvider: string): KeyStatus | undefined {
84
96
  return binding?.status(piProvider);
85
97
  }
86
98
 
99
+ let classifierModels: EngineModel[] = [];
100
+
101
+ /** Refresh the list of classifier models pi can call with its logins (it is read from the network). */
102
+ async function refreshClassifierModels(): Promise<void> {
103
+ if (!binding) return;
104
+ try {
105
+ classifierModels = await engineClassifiers(binding.registry);
106
+ } catch {
107
+ // Keep the last list: the picker still offers chat models and custom ids.
108
+ }
109
+ }
110
+
111
+ /** The classifier models pi can call with the logins it holds (Jev on each host, clef, d1, …); refreshed as it is read. */
112
+ export function availableClassifiers(): EngineModel[] {
113
+ void refreshClassifierModels();
114
+ return classifierModels;
115
+ }
116
+
87
117
  /** Register the `typesafe` login entry and bind the classifier to each session's key store. */
88
118
  export function registerClassifier(pi: ExtensionAPI, configDir: string): void {
89
119
  registerJevProvider(pi);
@@ -93,6 +123,8 @@ export function registerClassifier(pi: ExtensionAPI, configDir: string): void {
93
123
  root: detectProjectRoot(ctx.cwd, configDir),
94
124
  configDir,
95
125
  keys: (piProvider) => ctx.modelRegistry.getApiKeyForProvider(piProvider),
126
+ registry: ctx.modelRegistry,
127
+ engine: engineAnswer(ctx.modelRegistry),
96
128
  status: (piProvider) => {
97
129
  try {
98
130
  return ctx.modelRegistry.getProviderAuthStatus(piProvider);
@@ -103,5 +135,6 @@ export function registerClassifier(pi: ExtensionAPI, configDir: string): void {
103
135
  // Subagents have nobody to tell; their runs still land in the metrics.
104
136
  ...(ctx.hasUI && !isSubagentProcess() ? { notify: (message: string, level: "info" | "warning" | "error") => ctx.ui.notify(message, level) } : {}),
105
137
  };
138
+ if (!isSubagentProcess()) void refreshClassifierModels();
106
139
  });
107
140
  }
@@ -0,0 +1,115 @@
1
+ /**
2
+ * The classifier's model when it is not Jev on a host: whatever
3
+ * `classifier.engine` names (`provider/id`), looked up in pi's own model
4
+ * registry, so it is called with the logins pi already holds (an OpenAI or
5
+ * Anthropic account, an API key, OpenRouter, …). A classifier model (Jev,
6
+ * Cloudflare's clef, Liquid's d1, Upstage's solar-decide, …) answers through
7
+ * pi's `classify`; a chat model answers the same questions as JSON (see
8
+ * `chat.ts`).
9
+ */
10
+ import { clampThinkingLevel, type Api, type ClassifierApi, type ClassifierContext, type ClassifierModel, type Model } from "@earendil-works/pi-ai";
11
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
12
+ import type { Answer, SystemOneRequest, SystemOneResponse } from "./client.ts";
13
+ import { chatClassify } from "./chat.ts";
14
+
15
+ export type Registry = Pick<ExtensionContext["modelRegistry"], "find" | "findOfType" | "classify" | "streamSimple" | "getAvailableOfType">;
16
+
17
+ /** One model the classifier can use, as the settings picker lists it. */
18
+ export interface EngineModel {
19
+ id: string;
20
+ label: string;
21
+ kind: "classifier" | "chat";
22
+ }
23
+
24
+ export interface AnswerOptions {
25
+ signal?: AbortSignal;
26
+ timeoutMs: number;
27
+ /** A chat model gets at least this long: it writes its answers, a classifier model only scores them. */
28
+ chatTimeoutMs: number;
29
+ }
30
+
31
+ /** Answers a request with the model `ref` names; throws when pi has no such model or the call fails. */
32
+ export type EngineAnswer = (ref: string, request: SystemOneRequest, options: AnswerOptions) => Promise<SystemOneResponse>;
33
+
34
+ /** `provider/id` split at the first slash (ids may hold more: `openrouter/liquid/d1`). */
35
+ export function splitRef(ref: string): { provider: string; id: string } | undefined {
36
+ const slash = ref.indexOf("/");
37
+ if (slash <= 0 || slash === ref.length - 1) return undefined;
38
+ return { provider: ref.slice(0, slash), id: ref.slice(slash + 1) };
39
+ }
40
+
41
+ /** pi's `bool` answers are Jev's `noul`; the rest carry over as they are. */
42
+ function fromPi(answers: Record<string, unknown>): Record<string, Answer> {
43
+ const out: Record<string, Answer> = {};
44
+ for (const [key, answer] of Object.entries(answers)) {
45
+ const value = answer as { type?: string; probability?: number };
46
+ out[key] = value.type === "bool" ? { type: "noul", noul: value.probability ?? 0 } : (answer as Answer);
47
+ }
48
+ return out;
49
+ }
50
+
51
+ /** Jev's request as pi's classifier context: `noul` questions are `bool` there, everything else is sent as it is. */
52
+ function toPi(request: SystemOneRequest): ClassifierContext {
53
+ return {
54
+ state: request.state,
55
+ questions: Object.fromEntries(Object.entries(request.questions).map(([key, question]) => [key, question.type === "noul" ? { ...question, type: "bool" } : question])),
56
+ } as unknown as ClassifierContext;
57
+ }
58
+
59
+ async function classifyWith(registry: Registry, model: ClassifierModel<ClassifierApi>, request: SystemOneRequest, options: AnswerOptions): Promise<SystemOneResponse> {
60
+ const result = await registry.classify(model, toPi(request), { timeoutMs: options.timeoutMs, ...(options.signal ? { signal: options.signal } : {}) });
61
+ if (result.stopReason !== "stop") throw new Error(result.errorMessage || (result.stopReason === "aborted" ? "cancelled" : "the classifier failed"));
62
+ return {
63
+ model: `${model.provider}/${result.model || model.id}`,
64
+ answers: fromPi(result.answers as Record<string, unknown>),
65
+ ...(result.usage ? { usage: { input_tokens: result.usage.input, output_tokens: result.usage.output } } : {}),
66
+ };
67
+ }
68
+
69
+ /** The least thinking a model allows: none when it can switch it off. */
70
+ function leastThinking(model: Model<Api>): "minimal" | "low" | "medium" | "high" | "xhigh" | "max" | undefined {
71
+ if (!model.reasoning) return undefined;
72
+ const level = clampThinkingLevel(model, "off");
73
+ return level === "off" ? undefined : level;
74
+ }
75
+
76
+ async function chatWith(registry: Registry, model: Model<Api>, request: SystemOneRequest, options: AnswerOptions): Promise<SystemOneResponse> {
77
+ const reasoning = leastThinking(model);
78
+ const timeoutMs = Math.max(options.timeoutMs, options.chatTimeoutMs);
79
+ const controller = new AbortController();
80
+ const onAbort = () => controller.abort(options.signal?.reason);
81
+ options.signal?.addEventListener("abort", onAbort, { once: true });
82
+ const timer = setTimeout(() => controller.abort(new Error(`timed out after ${timeoutMs} ms`)), timeoutMs);
83
+ try {
84
+ return await chatClassify(request, async ({ system, user, maxTokens }) => {
85
+ const stream = registry.streamSimple(model, { systemPrompt: system, messages: [{ role: "user", content: user, timestamp: Date.now() }] }, { maxTokens, signal: controller.signal, ...(reasoning ? { reasoning } : {}) });
86
+ const aborted = new Promise<never>((_, reject) => controller.signal.addEventListener("abort", () => reject(controller.signal.reason), { once: true }));
87
+ const reply = await Promise.race([stream.result(), aborted]);
88
+ if (reply.stopReason === "error" || reply.stopReason === "aborted") throw new Error(reply.errorMessage || `the model stopped (${reply.stopReason})`);
89
+ const text = reply.content.filter((part) => part.type === "text").map((part) => part.text).join("");
90
+ return { text, model: `${model.provider}/${model.id}`, usage: { input_tokens: reply.usage.input, output_tokens: reply.usage.output } };
91
+ }, Boolean(reasoning));
92
+ } finally {
93
+ clearTimeout(timer);
94
+ options.signal?.removeEventListener("abort", onAbort);
95
+ }
96
+ }
97
+
98
+ /** Answers with pi's registry: a classifier model natively, else a chat model through a JSON prompt. */
99
+ export function engineAnswer(registry: Registry): EngineAnswer {
100
+ return async (ref, request, options) => {
101
+ const parts = splitRef(ref);
102
+ if (!parts) throw new Error(`"${ref}" is not a provider/model reference`);
103
+ const classifierModel = registry.findOfType("classifier", parts.provider, parts.id);
104
+ if (classifierModel) return classifyWith(registry, classifierModel, request, options);
105
+ const chatModel = registry.find(parts.provider, parts.id);
106
+ if (chatModel) return chatWith(registry, chatModel, request, options);
107
+ throw new Error(`pi has no model ${ref}: pick one you are signed in to in Settings → Classifier`);
108
+ };
109
+ }
110
+
111
+ /** The classifier models pi can call with the logins it holds, as the settings picker lists them. */
112
+ export async function engineClassifiers(registry: Registry): Promise<EngineModel[]> {
113
+ const models = await registry.getAvailableOfType("classifier");
114
+ return models.map((model) => ({ id: `${model.provider}/${model.id}`, label: model.name && model.name !== model.id ? model.name : `${model.provider}/${model.id}`, kind: "classifier" as const }));
115
+ }
@@ -1,6 +1,7 @@
1
1
  import { execFile } from "node:child_process";
2
- import { readFileSync, statSync } from "node:fs";
3
- import { join } from "node:path";
2
+ import { createHash } from "node:crypto";
3
+ import { readFileSync, realpathSync, statSync } from "node:fs";
4
+ import { join, relative } from "node:path";
4
5
  import { promisify } from "node:util";
5
6
  import { truncate } from "../text.ts";
6
7
 
@@ -31,6 +32,48 @@ export async function headCommit(cwd: string): Promise<string | undefined> {
31
32
  }
32
33
  }
33
34
 
35
+ /** A path with its links resolved (git reports real paths), or as it is when it does not exist. */
36
+ function realPath(path: string): string {
37
+ try {
38
+ return realpathSync(path);
39
+ } catch {
40
+ return path;
41
+ }
42
+ }
43
+
44
+ /** Changed files stat'ed at most for a tree's state; past this the status text alone stands for them. */
45
+ const MAX_STATED = 2000;
46
+
47
+ /**
48
+ * A fingerprint of the work tree: HEAD, and each changed or untracked file's
49
+ * status, size and modification time, leaving out paths under `ignore`
50
+ * (bot-lobby's own data). Two equal fingerprints mean nothing in the tree
51
+ * changed in between; undefined outside git.
52
+ */
53
+ export async function treeState(cwd: string, ignore: readonly string[] = []): Promise<string | undefined> {
54
+ try {
55
+ const top = await git(cwd, ["rev-parse", "--show-toplevel"]);
56
+ const head = (await headCommit(top)) ?? "";
57
+ const { stdout } = await run("git", ["status", "--porcelain=v1", "-z", "--untracked-files=all"], { cwd: top, maxBuffer: MAX_BUFFER });
58
+ const skipped = ignore.map((dir) => relative(top, realPath(dir))).filter((dir) => dir && !dir.startsWith("..")).map((dir) => `${dir.split("\\").join("/")}/`);
59
+ const entries = stdout.split("\0").filter((entry) => entry.length > 3 && !skipped.some((dir) => entry.slice(3).startsWith(dir)));
60
+ const hash = createHash("sha1").update(head);
61
+ entries.forEach((entry, index) => {
62
+ hash.update(`\0${entry}`);
63
+ if (index >= MAX_STATED) return;
64
+ try {
65
+ const info = statSync(join(top, entry.slice(3)));
66
+ hash.update(`\0${info.size}:${info.mtimeMs}`);
67
+ } catch {
68
+ hash.update("\0gone");
69
+ }
70
+ });
71
+ return hash.digest("hex");
72
+ } catch {
73
+ return undefined;
74
+ }
75
+ }
76
+
34
77
  /** The repository's top folder, or undefined (no git, not a repository). */
35
78
  export async function repositoryTop(cwd: string): Promise<string | undefined> {
36
79
  try {
package/src/index.ts CHANGED
@@ -20,6 +20,7 @@ import { registerFreshContext } from "./pi/fresh-context.ts";
20
20
  import { registerAskTool } from "./ask/tool.ts";
21
21
  import { registerWebTools } from "./web/tools.ts";
22
22
  import { registerExcalidrawTools } from "./excalidraw/tools.ts";
23
+ import { registerToolBudget } from "./pi/tool-budget.ts";
23
24
 
24
25
  export default function (pi: ExtensionAPI): void {
25
26
  registerAgentPermissions(pi);
@@ -54,4 +55,6 @@ export default function (pi: ExtensionAPI): void {
54
55
  if (isSubagentProcess()) registerDeskClient(pi);
55
56
  // Subagents look files up with the classifier (the engine allows the tool only while file hints are on).
56
57
  if (isSubagentProcess()) registerClassifierTools(pi);
58
+ // A subagent given a tool budget (scouts) is warned, then refused, so it reports instead of searching on.
59
+ if (isSubagentProcess()) registerToolBudget(pi);
57
60
  }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * What scouts learned about files, kept across tasks. Each scout's Relevant
3
+ * Files (`path:lines — why it matters`) are recorded with a hash of the
4
+ * file's content, and the repository map shows a note again only while the
5
+ * file is unchanged, so a later agent starts from what an earlier one found
6
+ * instead of searching for it again. A changed file's note is never shown:
7
+ * it may no longer be true.
8
+ */
9
+ import { createHash } from "node:crypto";
10
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
11
+ import { readFile, stat } from "node:fs/promises";
12
+ import { dirname, isAbsolute, join, relative } from "node:path";
13
+ import type { Domain } from "../schemas/agent.ts";
14
+ import type { RelevantFile } from "../schemas/findings.ts";
15
+
16
+ export interface FileNote {
17
+ note: string;
18
+ /** The lines it is about (`12-40`), when the scout gave them. */
19
+ lines?: string;
20
+ /** sha1 of the file's content when the note was taken. */
21
+ hash: string;
22
+ domain: Domain;
23
+ at: string;
24
+ }
25
+
26
+ interface NotesFile {
27
+ version: 1;
28
+ files: Record<string, FileNote>;
29
+ }
30
+
31
+ /** Most notes kept; the oldest go first. */
32
+ export const MAX_NOTES = 2000;
33
+ const MAX_NOTE_CHARS = 240;
34
+ /** Files larger than this are not hashed (the index skips them too). */
35
+ const MAX_HASHED_BYTES = 256 * 1024;
36
+
37
+ export function fileNotesPath(dataDir: string): string {
38
+ return join(dataDir, "cache", "file-notes.json");
39
+ }
40
+
41
+ export function readFileNotes(dataDir: string): Record<string, FileNote> {
42
+ try {
43
+ const parsed = JSON.parse(readFileSync(fileNotesPath(dataDir), "utf8")) as Partial<NotesFile>;
44
+ return parsed.version === 1 && parsed.files && typeof parsed.files === "object" ? parsed.files : {};
45
+ } catch {
46
+ return {};
47
+ }
48
+ }
49
+
50
+ function writeFileNotes(dataDir: string, files: Record<string, FileNote>): void {
51
+ const path = fileNotesPath(dataDir);
52
+ try {
53
+ mkdirSync(dirname(path), { recursive: true });
54
+ writeFileSync(`${path}.tmp`, JSON.stringify({ version: 1, files } satisfies NotesFile));
55
+ renameSync(`${path}.tmp`, path);
56
+ } catch {
57
+ // Notes are an optimisation; without them agents search as before.
58
+ }
59
+ }
60
+
61
+ /** A file's content hash, or undefined when it is missing, not a file or too large. */
62
+ export async function contentHash(cwd: string, path: string): Promise<string | undefined> {
63
+ try {
64
+ const full = join(cwd, path);
65
+ const info = await stat(full);
66
+ if (!info.isFile() || info.size > MAX_HASHED_BYTES) return undefined;
67
+ return createHash("sha1").update(await readFile(full)).digest("hex");
68
+ } catch {
69
+ return undefined;
70
+ }
71
+ }
72
+
73
+ /** A reported path relative to the tree (`./a.ts`, `/abs/tree/a.ts` → `a.ts`); undefined outside it. */
74
+ export function treePath(cwd: string, path: string): string | undefined {
75
+ const plain = path.trim().replace(/^\.\//, "");
76
+ if (!plain) return undefined;
77
+ const rel = isAbsolute(plain) ? relative(cwd, plain) : plain;
78
+ return !rel || rel.startsWith("..") || isAbsolute(rel) ? undefined : rel.split("\\").join("/");
79
+ }
80
+
81
+ /** Record a scout's Relevant Files as notes, each with its file's current hash; files that are not there are skipped. */
82
+ export async function recordFileNotes(dataDir: string, cwd: string, domain: Domain, files: readonly RelevantFile[], now = new Date()): Promise<number> {
83
+ const entries = await Promise.all(files.filter((file) => file.reason.trim()).map(async (file): Promise<[string, FileNote] | undefined> => {
84
+ const path = treePath(cwd, file.path);
85
+ const hash = path ? await contentHash(cwd, path) : undefined;
86
+ if (!path || !hash) return undefined;
87
+ const note = file.reason.trim().replace(/\s+/g, " ").slice(0, MAX_NOTE_CHARS);
88
+ return [path, { note, ...(file.lines ? { lines: file.lines } : {}), hash, domain, at: now.toISOString() }];
89
+ }));
90
+ const fresh = entries.filter((entry): entry is [string, FileNote] => entry !== undefined);
91
+ if (fresh.length === 0) return 0;
92
+ const notes = { ...readFileNotes(dataDir), ...Object.fromEntries(fresh) };
93
+ const kept = Object.entries(notes).sort((a, b) => b[1].at.localeCompare(a[1].at)).slice(0, MAX_NOTES);
94
+ writeFileNotes(dataDir, Object.fromEntries(kept));
95
+ return fresh.length;
96
+ }
97
+
98
+ /** The notes for `paths` whose file is unchanged since the note was taken. */
99
+ export async function currentNotes(dataDir: string, cwd: string, paths: readonly string[]): Promise<Map<string, FileNote>> {
100
+ const notes = readFileNotes(dataDir);
101
+ const wanted = paths.filter((path) => notes[path]);
102
+ const hashes = await Promise.all(wanted.map((path) => contentHash(cwd, path)));
103
+ return new Map(wanted.filter((path, index) => hashes[index] === notes[path]!.hash).map((path) => [path, notes[path]!]));
104
+ }
@@ -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`). */
@@ -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 {