@a-t-h-i/bot-lobby 0.6.38 → 0.6.40
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +54 -11
- package/package.json +1 -1
- package/prompts/global.md +12 -0
- package/prompts/master.md +21 -3
- package/prompts/quickfix.md +10 -3
- package/prompts/scout.md +8 -4
- package/prompts/worker.md +5 -3
- package/src/classifier/chat.ts +122 -0
- package/src/classifier/classifier.ts +46 -11
- package/src/classifier/files.ts +91 -23
- package/src/classifier/instance.ts +35 -2
- package/src/classifier/models.ts +115 -0
- package/src/execution/git.ts +108 -2
- package/src/index.ts +3 -0
- package/src/knowledge/file-notes.ts +104 -0
- package/src/knowledge/repo-map.ts +165 -0
- package/src/lobby/host.ts +2 -0
- package/src/lobby/quickfix.ts +55 -2
- package/src/lobby/service.ts +2 -1
- package/src/master/master.ts +39 -3
- package/src/master/scout-cache.ts +70 -0
- package/src/master/synthesis.ts +10 -2
- package/src/pi/model-settings.ts +5 -3
- package/src/pi/plan-checklist.ts +2 -1
- package/src/pi/tool-budget.ts +66 -0
- package/src/pi/tools.ts +1 -0
- package/src/roles/markdown.ts +12 -4
- package/src/roles/scout.ts +2 -1
- package/src/schemas/configuration.ts +21 -2
- package/src/schemas/findings.ts +2 -0
- package/src/state/work-time.ts +4 -2
- package/src/webui/api/plan-facts.ts +17 -2
- package/src/webui/api/settings.ts +2 -1
- package/src/webui/api/tasks.ts +1 -1
- package/src/webui/dev/fake-service.ts +1 -0
- package/src/webui/protocol.ts +18 -1
- package/src/workflow/workflow.ts +41 -2
- package/webui/dist/assets/index-BV9aLD_v.js +109 -0
- package/webui/dist/assets/index-CzPF26MA.js +109 -0
- package/webui/dist/assets/index-Glz3FAPp.css +2 -0
- package/webui/dist/assets/index-uTndyUfm.css +2 -0
- package/webui/dist/build.json +17 -21
- package/webui/dist/index.html +2 -2
- package/webui/dist/assets/index-BFsMFIr5.js +0 -110
- package/webui/dist/assets/index-Ccc6rsPD.css +0 -2
package/src/classifier/files.ts
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import { execFile } from "node:child_process";
|
|
16
16
|
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
17
|
-
import {
|
|
17
|
+
import { readdir, readFile, stat } from "node:fs/promises";
|
|
18
18
|
import { dirname, join } from "node:path";
|
|
19
19
|
import { promisify } from "node:util";
|
|
20
20
|
import { dataRoot } from "../state/project.ts";
|
|
@@ -36,6 +36,10 @@ export interface FileScope {
|
|
|
36
36
|
export interface IndexedFile {
|
|
37
37
|
path: string;
|
|
38
38
|
excerpt: string;
|
|
39
|
+
/** What the file says it is for: its opening comment (a Markdown file's first heading), one line; "" when it says nothing. */
|
|
40
|
+
purpose?: string;
|
|
41
|
+
/** The top-level names it declares or exports (a Markdown file's headings), in file order. */
|
|
42
|
+
symbols?: string[];
|
|
39
43
|
}
|
|
40
44
|
|
|
41
45
|
export interface LikelyFile {
|
|
@@ -122,9 +126,12 @@ const COMMENT = /^(\/\/|\/\*|\*|#(?![!#])|"""|'''|<!--|--\s|;)/;
|
|
|
122
126
|
* signatures (headings for Markdown), then its imports; the first lines when
|
|
123
127
|
* none of those exist.
|
|
124
128
|
*/
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
129
|
+
function isMarkdown(path: string): boolean {
|
|
130
|
+
return /\.(md|mdx|markdown|rst|txt)$/i.test(path);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** The words of a file's opening comment, a line each (at most four); none for Markdown or code that starts without one. */
|
|
134
|
+
function leadComment(lines: readonly string[], markdown: boolean): string[] {
|
|
128
135
|
const lead: string[] = [];
|
|
129
136
|
for (const line of lines) {
|
|
130
137
|
const trimmed = line.trim();
|
|
@@ -134,10 +141,17 @@ export function excerptOf(path: string, text: string, max = EXCERPT_CHARS): stri
|
|
|
134
141
|
}
|
|
135
142
|
if (trimmed.startsWith("#!")) continue;
|
|
136
143
|
if (markdown || !COMMENT.test(trimmed)) break;
|
|
137
|
-
const words = trimmed.replace(/^(\/\*+|\*+\/?|\/\/+|#+|"""|'''|<!--|--|;+)\s*/, "").replace(
|
|
144
|
+
const words = trimmed.replace(/^(\/\*+|\*+\/?|\/\/+|#+|"""|'''|<!--|--|;+)\s*/, "").replace(/\*\/$|-->$|"""$|'''$/, "").trim();
|
|
138
145
|
if (words) lead.push(words);
|
|
139
146
|
if (lead.length >= 4) break;
|
|
140
147
|
}
|
|
148
|
+
return lead;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function excerptOf(path: string, text: string, max = EXCERPT_CHARS): string {
|
|
152
|
+
const lines = text.split(/\r?\n/).slice(0, 600);
|
|
153
|
+
const markdown = isMarkdown(path);
|
|
154
|
+
const lead = leadComment(lines, markdown);
|
|
141
155
|
const signatures: string[] = [];
|
|
142
156
|
const imports: string[] = [];
|
|
143
157
|
for (const line of lines) {
|
|
@@ -155,10 +169,68 @@ export function excerptOf(path: string, text: string, max = EXCERPT_CHARS): stri
|
|
|
155
169
|
return clip(body, max);
|
|
156
170
|
}
|
|
157
171
|
|
|
172
|
+
const PURPOSE_CHARS = 120;
|
|
173
|
+
|
|
174
|
+
/** What a file says it is for, in one line: the first sentence of its opening comment, or a Markdown file's first heading; "" for a licence header or none. */
|
|
175
|
+
export function purposeOf(path: string, text: string): string {
|
|
176
|
+
const lines = text.split(/\r?\n/).slice(0, 80);
|
|
177
|
+
if (isMarkdown(path)) {
|
|
178
|
+
const heading = lines.map((line) => /^#{1,3}\s+(.+?)\s*#*\s*$/.exec(line.trim())?.[1]).find(Boolean);
|
|
179
|
+
return heading ? clip(heading, PURPOSE_CHARS) : "";
|
|
180
|
+
}
|
|
181
|
+
const lead = leadComment(lines, false).join(" ").replace(/^@(?:file|fileoverview|module)\s+/i, "");
|
|
182
|
+
if (!lead || /copyright|licen[cs]e|spdx-|eslint-|@ts-|prettier-ignore|biome-ignore|^use strict/i.test(lead)) return "";
|
|
183
|
+
const sentence = /^(.+?[.!?])(?:\s|$)/.exec(lead)?.[1] ?? lead;
|
|
184
|
+
return clip(sentence, PURPOSE_CHARS);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Top-level declarations by language; each pattern's groups are the names. Indented (nested) code is left out. */
|
|
188
|
+
const SYMBOL_PATTERNS: readonly RegExp[] = [
|
|
189
|
+
/^export\s+(?:default\s+)?(?:declare\s+)?(?:abstract\s+)?(?:async\s+)?(?:function\*?|class|interface|type|const\s+enum|enum|const|let|var|namespace)\s+([A-Za-z_$][\w$]*)/,
|
|
190
|
+
/^(?:async\s+)?def\s+([A-Za-z]\w*)/,
|
|
191
|
+
/^class\s+([A-Za-z]\w*)/,
|
|
192
|
+
/^func\s+(?:\([^)]*\)\s*)?([A-Za-z]\w*)/,
|
|
193
|
+
/^type\s+([A-Z]\w*)\s+(?:struct|interface|func|map|\[|\w)/,
|
|
194
|
+
/^pub(?:\([\w:]+\))?\s+(?:async\s+)?(?:unsafe\s+)?(?:fn|struct|enum|trait|type|const|static|mod)\s+([A-Za-z_]\w*)/,
|
|
195
|
+
/^(?:module|def)\s+([A-Za-z_][\w:.]*[?!]?)/,
|
|
196
|
+
/^(?:public\s+|internal\s+|open\s+|export\s+)?(?:abstract\s+|final\s+|sealed\s+|data\s+|static\s+)*(?:class|interface|enum|record|struct|object|protocol)\s+([A-Za-z_]\w*)/,
|
|
197
|
+
];
|
|
198
|
+
/** Plain top-level functions and classes, for a script that exports nothing. */
|
|
199
|
+
const LOCAL_PATTERNS: readonly RegExp[] = [/^(?:async\s+)?function\*?\s+([A-Za-z_$][\w$]*)/, /^(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?(?:\([^)]*\)\s*=>|function)/];
|
|
200
|
+
export const MAX_SYMBOLS = 12;
|
|
201
|
+
|
|
202
|
+
/** The top-level names a file declares or exports, in order, at most `max`; a Markdown file's section headings. */
|
|
203
|
+
export function symbolsOf(path: string, text: string, max = MAX_SYMBOLS): string[] {
|
|
204
|
+
const lines = text.split(/\r?\n/);
|
|
205
|
+
const found = new Set<string>();
|
|
206
|
+
const add = (name: string | undefined) => {
|
|
207
|
+
const clean = name?.trim();
|
|
208
|
+
if (clean && found.size < max && !clean.startsWith("_")) found.add(clean);
|
|
209
|
+
};
|
|
210
|
+
if (isMarkdown(path)) {
|
|
211
|
+
for (const line of lines) add(/^##\s+(.+?)\s*#*\s*$/.exec(line)?.[1]?.slice(0, 60));
|
|
212
|
+
return [...found];
|
|
213
|
+
}
|
|
214
|
+
for (const line of lines) {
|
|
215
|
+
if (found.size >= max) break;
|
|
216
|
+
const named = /^export\s*(?:type\s*)?\{([^}]*)\}/.exec(line);
|
|
217
|
+
if (named) for (const part of named[1]!.split(",")) add(part.split(/\s+as\s+/).pop()?.replace(/^type\s+/, ""));
|
|
218
|
+
else if (/^export\s+default\s+(?!(?:async\s+)?(?:function|class)\s+[A-Za-z_$])/.test(line)) add("default");
|
|
219
|
+
else for (const pattern of SYMBOL_PATTERNS) add(pattern.exec(line)?.[1]);
|
|
220
|
+
}
|
|
221
|
+
// A script that exports nothing still has its functions; a test's helpers are not worth naming.
|
|
222
|
+
if (found.size === 0 && /\.(c|m)?(j|t)sx?$/i.test(path) && !/(^|\/)(tests?|__tests__|spec)\/|\.(test|spec)\.\w+$/i.test(path)) {
|
|
223
|
+
for (const line of lines) for (const pattern of LOCAL_PATTERNS) add(pattern.exec(line)?.[1]);
|
|
224
|
+
}
|
|
225
|
+
return [...found];
|
|
226
|
+
}
|
|
227
|
+
|
|
158
228
|
interface CacheEntry {
|
|
159
229
|
mtimeMs: number;
|
|
160
230
|
size: number;
|
|
161
231
|
excerpt: string;
|
|
232
|
+
purpose?: string;
|
|
233
|
+
symbols?: string[];
|
|
162
234
|
}
|
|
163
235
|
|
|
164
236
|
interface CacheFile {
|
|
@@ -222,22 +294,17 @@ async function walkFiles(cwd: string): Promise<string[]> {
|
|
|
222
294
|
return found.slice(0, MAX_FILES);
|
|
223
295
|
}
|
|
224
296
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
const head = buffer.subarray(0, bytesRead);
|
|
231
|
-
return head.includes(0) ? undefined : head.toString("utf8");
|
|
232
|
-
} finally {
|
|
233
|
-
await handle.close();
|
|
234
|
-
}
|
|
297
|
+
/** A text file's opening 8 KB (for its excerpt) and all of it (for its names); undefined for a binary file. */
|
|
298
|
+
async function readText(path: string): Promise<{ head: string; text: string } | undefined> {
|
|
299
|
+
const buffer = await readFile(path);
|
|
300
|
+
const head = buffer.subarray(0, HEAD_BYTES);
|
|
301
|
+
return head.includes(0) ? undefined : { head: head.toString("utf8"), text: buffer.toString("utf8") };
|
|
235
302
|
}
|
|
236
303
|
|
|
237
304
|
/**
|
|
238
|
-
* The repository's files with their excerpts
|
|
239
|
-
* cache (same size and modification time); new or
|
|
240
|
-
*
|
|
305
|
+
* The repository's files with their excerpts, purposes and names. Unchanged
|
|
306
|
+
* files come from the cache (same size and modification time); new or
|
|
307
|
+
* changed ones are read and the cache is rewritten.
|
|
241
308
|
*/
|
|
242
309
|
export async function indexFiles(scope: FileScope, exclude: readonly string[] = []): Promise<IndexedFile[]> {
|
|
243
310
|
const listed = (await gitFiles(scope.cwd)) ?? (await walkFiles(scope.cwd));
|
|
@@ -255,11 +322,12 @@ export async function indexFiles(scope: FileScope, exclude: readonly string[] =
|
|
|
255
322
|
const info = await stat(join(scope.cwd, path));
|
|
256
323
|
if (!info.isFile() || info.size > MAX_FILE_BYTES) return undefined;
|
|
257
324
|
const hit = cached[path];
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
325
|
+
// An entry from before files had names is read again once.
|
|
326
|
+
if (hit && hit.mtimeMs === info.mtimeMs && hit.size === info.size && Array.isArray(hit.symbols)) return [path, hit];
|
|
327
|
+
const read = await readText(join(scope.cwd, path));
|
|
328
|
+
if (read === undefined) return undefined;
|
|
261
329
|
changed = true;
|
|
262
|
-
return [path, { mtimeMs: info.mtimeMs, size: info.size, excerpt: excerptOf(path, head) }];
|
|
330
|
+
return [path, { mtimeMs: info.mtimeMs, size: info.size, excerpt: excerptOf(path, read.head), purpose: purposeOf(path, read.head), symbols: symbolsOf(path, read.text) }];
|
|
263
331
|
} catch {
|
|
264
332
|
return undefined;
|
|
265
333
|
}
|
|
@@ -267,7 +335,7 @@ export async function indexFiles(scope: FileScope, exclude: readonly string[] =
|
|
|
267
335
|
for (const entry of entries) {
|
|
268
336
|
if (!entry) continue;
|
|
269
337
|
next[entry[0]] = entry[1];
|
|
270
|
-
indexed.push({ path: entry[0], excerpt: entry[1].excerpt });
|
|
338
|
+
indexed.push({ path: entry[0], excerpt: entry[1].excerpt, purpose: entry[1].purpose ?? "", symbols: entry[1].symbols ?? [] });
|
|
271
339
|
}
|
|
272
340
|
}
|
|
273
341
|
if (changed) writeCache(scope, next);
|
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
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
|
+
}
|
package/src/execution/git.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
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 {
|
|
@@ -214,3 +257,66 @@ export async function addedLines(top: string, base: string | undefined, files: r
|
|
|
214
257
|
return undefined;
|
|
215
258
|
}
|
|
216
259
|
}
|
|
260
|
+
|
|
261
|
+
/** Longest first line a commit message may have. */
|
|
262
|
+
export const MAX_COMMIT_SUBJECT = 72;
|
|
263
|
+
/** Most lines a commit message may add below its first. */
|
|
264
|
+
export const MAX_COMMIT_BODY_LINES = 3;
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* A commit message kept short: one first line of at most
|
|
268
|
+
* `MAX_COMMIT_SUBJECT` characters, then, after a blank line, at most
|
|
269
|
+
* `MAX_COMMIT_BODY_LINES` short lines. Throws saying what is too long.
|
|
270
|
+
*/
|
|
271
|
+
export function commitMessage(text: string): string {
|
|
272
|
+
const lines = text.replace(/\r\n?/g, "\n").trim().split("\n").map((line) => line.trimEnd());
|
|
273
|
+
const subject = lines[0]?.trim() ?? "";
|
|
274
|
+
if (!subject) throw new Error("the commit message is empty");
|
|
275
|
+
if (subject.length > MAX_COMMIT_SUBJECT) throw new Error(`the commit message's first line is ${subject.length} characters; keep it to ${MAX_COMMIT_SUBJECT} or fewer, saying what changed`);
|
|
276
|
+
const body = lines.slice(1).filter((line) => line.trim().length > 0);
|
|
277
|
+
if (body.length > MAX_COMMIT_BODY_LINES) throw new Error(`the commit message has ${body.length} lines below its first; keep it to ${MAX_COMMIT_BODY_LINES} or fewer`);
|
|
278
|
+
if (body.some((line) => line.trim().length > MAX_COMMIT_SUBJECT)) throw new Error(`keep each line of the commit message to ${MAX_COMMIT_SUBJECT} characters or fewer`);
|
|
279
|
+
return body.length > 0 ? `${subject}\n\n${body.map((line) => line.trim()).join("\n")}` : subject;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** `git status` of `files` (relative to `top`): each changed path with its two-letter code, a rename's old path beside its new one. */
|
|
283
|
+
async function statusOf(top: string, files: readonly string[]): Promise<Array<{ path: string; code: string }>> {
|
|
284
|
+
if (files.length === 0) return [];
|
|
285
|
+
const { stdout } = await run("git", ["status", "--porcelain", "-z", "--untracked-files=all", "--", ...files], { cwd: top, maxBuffer: MAX_BUFFER });
|
|
286
|
+
const entries = stdout.split("\0");
|
|
287
|
+
const out: Array<{ path: string; code: string }> = [];
|
|
288
|
+
for (let index = 0; index < entries.length; index += 1) {
|
|
289
|
+
const entry = entries[index]!;
|
|
290
|
+
if (entry.length < 4) continue;
|
|
291
|
+
const code = entry.slice(0, 2);
|
|
292
|
+
out.push({ path: entry.slice(3), code });
|
|
293
|
+
if (/[RC]/.test(code) && entries[index + 1]) out.push({ path: entries[++index]!, code: "D " });
|
|
294
|
+
}
|
|
295
|
+
return out;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** Which of `files` (relative to `top`) have changes git has not committed, with a rename's old path beside its new one. */
|
|
299
|
+
export async function uncommitted(top: string, files: readonly string[]): Promise<string[]> {
|
|
300
|
+
return [...new Set((await statusOf(top, files)).map((entry) => entry.path))];
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Commit `files` (relative to `top`) as they stand, and nothing else: what
|
|
305
|
+
* else is staged stays staged. The commit, or undefined when none of them
|
|
306
|
+
* has anything to commit. Throws with git's reason when it refuses (a hook).
|
|
307
|
+
*/
|
|
308
|
+
export async function commitFiles(top: string, files: readonly string[], message: string): Promise<{ commit: string; files: string[] } | undefined> {
|
|
309
|
+
const status = await statusOf(top, files);
|
|
310
|
+
const dirty = [...new Set(status.map((entry) => entry.path))];
|
|
311
|
+
if (dirty.length === 0) return undefined;
|
|
312
|
+
// Only what the working tree holds beyond the index needs adding; a staged deletion is no longer there to add.
|
|
313
|
+
const unstaged = [...new Set(status.filter((entry) => entry.code[1] !== " ").map((entry) => entry.path))];
|
|
314
|
+
try {
|
|
315
|
+
if (unstaged.length > 0) await run("git", ["add", "-A", "--", ...unstaged], { cwd: top, maxBuffer: MAX_BUFFER });
|
|
316
|
+
await run("git", ["commit", "-q", "-m", message, "--", ...dirty], { cwd: top, maxBuffer: MAX_BUFFER });
|
|
317
|
+
} catch (error) {
|
|
318
|
+
const failure = error as { stderr?: string; stdout?: string; message: string };
|
|
319
|
+
throw new Error(truncate((failure.stderr || failure.stdout || failure.message).trim(), 600));
|
|
320
|
+
}
|
|
321
|
+
return { commit: await git(top, ["rev-parse", "HEAD"]), files: dirty };
|
|
322
|
+
}
|
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
|
+
}
|