@a-t-h-i/bot-lobby 0.6.1 → 0.6.2
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 +177 -834
- package/package.json +1 -1
- package/prompts/master.md +17 -0
- package/prompts/panel.md +3 -1
- package/prompts/planner.md +14 -0
- package/prompts/quickfix.md +3 -1
- package/prompts/scout.md +3 -0
- package/prompts/worker.md +3 -0
- package/src/classifier/answers.ts +110 -0
- package/src/classifier/classifier.ts +171 -0
- package/src/classifier/client.ts +239 -0
- package/src/classifier/effort.ts +143 -0
- package/src/classifier/files.ts +427 -0
- package/src/classifier/hosts.ts +157 -0
- package/src/classifier/instance.ts +98 -0
- package/src/classifier/limits.ts +37 -0
- package/src/classifier/seats.ts +102 -0
- package/src/classifier/tools.ts +58 -0
- package/src/classifier/triage.ts +203 -0
- package/src/execution/agent-runner.ts +5 -0
- package/src/index.ts +6 -0
- package/src/lobby/planner.ts +305 -27
- package/src/lobby/quickfix.ts +120 -8
- package/src/lobby/runtime.ts +12 -1
- package/src/lobby/tabs/metrics.ts +28 -1
- package/src/lobby/tabs/plan.ts +27 -4
- package/src/lobby/tabs/quickfix.ts +5 -1
- package/src/lobby/view.ts +28 -4
- package/src/master/master.ts +95 -29
- package/src/pi/commands.ts +4 -2
- package/src/pi/events.ts +11 -2
- package/src/pi/run-summary.ts +3 -1
- package/src/pi/settings-ui.ts +138 -2
- package/src/pi/start-task.ts +4 -0
- package/src/pi/tools.ts +7 -2
- package/src/schemas/configuration.ts +125 -1
- package/src/schemas/findings.ts +4 -0
- package/src/schemas/task.ts +22 -0
- package/src/state/metrics.ts +74 -1
- package/src/workflow/workflow.ts +49 -1
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where Jev is called and where its key lives. Every host speaks the same
|
|
3
|
+
* System One API; only the URL, the default model and the key differ, and
|
|
4
|
+
* every key is one pi already stores:
|
|
5
|
+
*
|
|
6
|
+
* - OpenCode Zen serves Jev with the OpenCode key pi uses for Zen and Go
|
|
7
|
+
* (`/login opencode` or `opencode-go`, or OPENCODE_API_KEY), on the free
|
|
8
|
+
* `jev-1.13-free` model while OpenCode offers it.
|
|
9
|
+
* - TypeSafe direct: bot-lobby registers a `typesafe` provider so `/login
|
|
10
|
+
* typesafe` stores the key in pi's auth.json.
|
|
11
|
+
* - OpenRouter and Vercel AI Gateway use the key pi holds for them.
|
|
12
|
+
*
|
|
13
|
+
* `auto` (the default) takes OpenCode when pi holds an OpenCode key, else
|
|
14
|
+
* TypeSafe.
|
|
15
|
+
*/
|
|
16
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
17
|
+
import type { ClassifierConfig, JevHostName } from "../schemas/configuration.ts";
|
|
18
|
+
|
|
19
|
+
export type JevHostId = Exclude<JevHostName, "auto">;
|
|
20
|
+
|
|
21
|
+
export interface JevHost {
|
|
22
|
+
name: JevHostId;
|
|
23
|
+
label: string;
|
|
24
|
+
/** pi's provider ids that hold this host's key, in the order they are tried; the first is the one to `/login`. */
|
|
25
|
+
piProviders: readonly string[];
|
|
26
|
+
/** The environment variable pi falls back to for that key. */
|
|
27
|
+
env: string;
|
|
28
|
+
baseUrl: string;
|
|
29
|
+
model: string;
|
|
30
|
+
headers?: Readonly<Record<string, string>>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const JEV_HOST_TABLE: Readonly<Record<JevHostId, JevHost>> = Object.freeze({
|
|
34
|
+
opencode: { name: "opencode", label: "OpenCode Zen", piProviders: ["opencode", "opencode-go"], env: "OPENCODE_API_KEY", baseUrl: "https://opencode.ai/zen", model: "jev-1.13-free" },
|
|
35
|
+
typesafe: { name: "typesafe", label: "TypeSafe", piProviders: ["typesafe"], env: "TYPESAFE_API_KEY", baseUrl: "https://api.typesafe.ai", model: "jev-latest" },
|
|
36
|
+
openrouter: { name: "openrouter", label: "OpenRouter", piProviders: ["openrouter"], env: "OPENROUTER_API_KEY", baseUrl: "https://openrouter.ai/api", model: "jev-latest", headers: { "HTTP-Referer": "https://github.com/a-t-h-i/bot-lobby", "X-Title": "bot-lobby" } },
|
|
37
|
+
vercel: { name: "vercel", label: "Vercel AI Gateway", piProviders: ["vercel-ai-gateway"], env: "AI_GATEWAY_API_KEY", baseUrl: "https://ai-gateway.vercel.sh/typesafe", model: "typesafe-ai/jev" },
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
/** The hosts `auto` tries, in order: OpenCode's free Jev first. */
|
|
41
|
+
export const AUTO_ORDER: readonly JevHostId[] = ["opencode", "typesafe"];
|
|
42
|
+
|
|
43
|
+
/** How a host name reads in settings. */
|
|
44
|
+
export function hostLabel(name: JevHostName): string {
|
|
45
|
+
return name === "auto" ? "Auto (OpenCode's free Jev, else TypeSafe)" : JEV_HOST_TABLE[name].label;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Resolves a pi provider's API key (stored credential, then its environment variable). */
|
|
49
|
+
export type KeySource = (piProvider: string) => Promise<string | undefined>;
|
|
50
|
+
|
|
51
|
+
/** Where a provider's key comes from, without the key: pi's own auth status. */
|
|
52
|
+
export interface KeyStatus {
|
|
53
|
+
configured: boolean;
|
|
54
|
+
source?: string;
|
|
55
|
+
label?: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export type StatusSource = (piProvider: string) => KeyStatus | undefined;
|
|
59
|
+
|
|
60
|
+
/** The first of a host's pi providers holding a key, with its status. */
|
|
61
|
+
function configuredProvider(host: JevHost, status: StatusSource | undefined): { provider: string; status: KeyStatus } | undefined {
|
|
62
|
+
for (const provider of host.piProviders) {
|
|
63
|
+
const found = status?.(provider);
|
|
64
|
+
if (found?.configured) return { provider, status: found };
|
|
65
|
+
}
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Whether pi (or the environment) holds a key for a host, without reading it. */
|
|
70
|
+
export function hostConfigured(host: JevHost, status: StatusSource | undefined, env: NodeJS.ProcessEnv = process.env): boolean {
|
|
71
|
+
return Boolean(configuredProvider(host, status) || env[host.env]?.trim());
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The host a config calls, judged by which keys pi holds; `auto` falls back to TypeSafe when none does. */
|
|
75
|
+
export function chooseHost(config: Pick<ClassifierConfig, "provider">, status?: StatusSource, env: NodeJS.ProcessEnv = process.env): JevHost {
|
|
76
|
+
if (config.provider !== "auto") return JEV_HOST_TABLE[config.provider];
|
|
77
|
+
return JEV_HOST_TABLE[AUTO_ORDER.find((name) => hostConfigured(JEV_HOST_TABLE[name], status, env)) ?? "typesafe"];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The URL and model for a host, with the config's overrides. */
|
|
81
|
+
export function endpointFor(host: JevHost, config: Pick<ClassifierConfig, "baseUrl" | "model">): { host: JevHost; baseUrl: string; model: string } {
|
|
82
|
+
return { host, baseUrl: config.baseUrl || host.baseUrl, model: config.model || host.model };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The host, URL and model a config calls (for display; the classifier resolves keys itself). */
|
|
86
|
+
export function jevEndpoint(config: ClassifierConfig, status?: StatusSource, env: NodeJS.ProcessEnv = process.env): { host: JevHost; baseUrl: string; model: string } {
|
|
87
|
+
return endpointFor(chooseHost(config, status, env), config);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** A host's key: each of its pi providers in turn, then its environment variable. */
|
|
91
|
+
export async function resolveKey(host: JevHost, source: KeySource | undefined, env: NodeJS.ProcessEnv = process.env): Promise<string | undefined> {
|
|
92
|
+
for (const provider of host.piProviders) {
|
|
93
|
+
try {
|
|
94
|
+
const key = (await source?.(provider))?.trim();
|
|
95
|
+
if (key) return key;
|
|
96
|
+
} catch {
|
|
97
|
+
// Try the next provider.
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return env[host.env]?.trim() || undefined;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The host and key a config calls with. `auto` takes the first host in
|
|
105
|
+
* `AUTO_ORDER` that has a key; undefined `key` means none was found.
|
|
106
|
+
*/
|
|
107
|
+
export async function resolveTarget(config: ClassifierConfig, source: KeySource | undefined, env: NodeJS.ProcessEnv = process.env): Promise<{ host: JevHost; baseUrl: string; model: string; key?: string }> {
|
|
108
|
+
const hosts = config.provider === "auto" ? AUTO_ORDER.map((name) => JEV_HOST_TABLE[name]) : [JEV_HOST_TABLE[config.provider]];
|
|
109
|
+
for (const host of hosts) {
|
|
110
|
+
const key = await resolveKey(host, source, env);
|
|
111
|
+
if (key) return { ...endpointFor(host, config), key };
|
|
112
|
+
}
|
|
113
|
+
return endpointFor(hosts[0]!, config);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** How to give bot-lobby a key for this config, in one sentence. */
|
|
117
|
+
export function keyHint(config: Pick<ClassifierConfig, "provider">): string {
|
|
118
|
+
const opencode = JEV_HOST_TABLE.opencode;
|
|
119
|
+
const typesafe = JEV_HOST_TABLE.typesafe;
|
|
120
|
+
const how = (host: JevHost) => (host.name === "typesafe" ? `/login typesafe (Use an API key) or set ${host.env}` : `/login ${host.piProviders[0]} or set ${host.env}`);
|
|
121
|
+
if (config.provider === "auto") return `sign in to OpenCode for its free Jev (${how(opencode)}), or ${how(typesafe)}`;
|
|
122
|
+
return how(JEV_HOST_TABLE[config.provider]);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** `ts_ab…cd`: enough to recognise which key is loaded, never the key. */
|
|
126
|
+
export function maskKey(key: string): string {
|
|
127
|
+
if (key.length <= 8) return "*".repeat(key.length);
|
|
128
|
+
return `${key.slice(0, 4)}…${key.slice(-4)}`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** One line on where a host's key comes from, for settings and `/bot-lobby config`. */
|
|
132
|
+
export function describeKey(host: JevHost, status: StatusSource | undefined, env: NodeJS.ProcessEnv = process.env): string {
|
|
133
|
+
const found = configuredProvider(host, status);
|
|
134
|
+
if (found) {
|
|
135
|
+
if (found.status.source === "stored") return `stored in pi (/login ${found.provider})`;
|
|
136
|
+
if (found.status.source === "environment") return `from ${found.status.label ?? host.env}`;
|
|
137
|
+
return `configured (${found.status.label ?? found.status.source ?? "pi"})`;
|
|
138
|
+
}
|
|
139
|
+
if (env[host.env]?.trim()) return `from ${host.env}`;
|
|
140
|
+
return `missing: ${host.name === "typesafe" ? "/login typesafe → Use an API key" : `/login ${host.piProviders[0]}`}, or set ${host.env}`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Register TypeSafe as a pi provider with no chat models, so `/login
|
|
145
|
+
* typesafe` stores the Jev key with pi's own locking and `/logout` removes
|
|
146
|
+
* it. Nothing is added to `/model`. A pi that refuses the registration only
|
|
147
|
+
* loses the login entry: the environment variable still works.
|
|
148
|
+
*/
|
|
149
|
+
export function registerJevProvider(pi: ExtensionAPI): boolean {
|
|
150
|
+
const host = JEV_HOST_TABLE.typesafe;
|
|
151
|
+
try {
|
|
152
|
+
pi.registerProvider(host.piProviders[0]!, { name: "TypeSafe (Jev classifier)", baseUrl: host.baseUrl, apiKey: `$${host.env}`, models: [] });
|
|
153
|
+
return true;
|
|
154
|
+
} catch {
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The process's classifier. pi's key store and the project are bound at
|
|
3
|
+
* session start; the lobby, the workflow and (inside subagents) the file
|
|
4
|
+
* tools all read the same instance, so the breaker and the one-time notices
|
|
5
|
+
* are shared.
|
|
6
|
+
*/
|
|
7
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import { appendMetrics } from "../state/metrics.ts";
|
|
9
|
+
import { detectProjectRoot, loadConfig } from "../state/project.ts";
|
|
10
|
+
import { isSubagentProcess } from "../pi/quiet.ts";
|
|
11
|
+
import { Classifier } from "./classifier.ts";
|
|
12
|
+
import { registerJevProvider, type KeySource, type KeyStatus } from "./hosts.ts";
|
|
13
|
+
import { fileHinter, type FileHinter, type FileScope } from "./files.ts";
|
|
14
|
+
import { lobbyFeed } from "../lobby/feed.ts";
|
|
15
|
+
import { triageLine, triageWithContext } from "./triage.ts";
|
|
16
|
+
import type { TaskTriage } from "../schemas/task.ts";
|
|
17
|
+
import { effortRouter, type EffortRouter } from "./effort.ts";
|
|
18
|
+
|
|
19
|
+
interface Binding {
|
|
20
|
+
cwd: string;
|
|
21
|
+
root: string;
|
|
22
|
+
configDir: string;
|
|
23
|
+
keys: KeySource;
|
|
24
|
+
status: (piProvider: string) => KeyStatus | undefined;
|
|
25
|
+
notify?: (message: string, level: "info" | "warning" | "error") => void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
let binding: Binding | undefined;
|
|
29
|
+
let instance: Classifier | undefined;
|
|
30
|
+
|
|
31
|
+
export function classifier(): Classifier {
|
|
32
|
+
instance ??= new Classifier({
|
|
33
|
+
config: () => loadConfig().classifier,
|
|
34
|
+
keys: async (piProvider) => binding?.keys(piProvider),
|
|
35
|
+
metrics: (record) => {
|
|
36
|
+
if (binding) appendMetrics(binding.root, binding.configDir, [record]);
|
|
37
|
+
},
|
|
38
|
+
warn: (message) => binding?.notify?.(message, "warning"),
|
|
39
|
+
});
|
|
40
|
+
return instance;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Likely files for agents working in a tree, with what was found logged to the lobby's activity feed. */
|
|
44
|
+
export function hintsFor(scope: FileScope): FileHinter {
|
|
45
|
+
return fileHinter(classifier(), scope, (text) => lobbyFeed.log("CLASSIFIER", text, "info"));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Triage a request for a task in a tree, logged to the lobby's activity feed; undefined when triage is off or fails. */
|
|
49
|
+
export async function triageFor(scope: FileScope, request: string, signal?: AbortSignal): Promise<TaskTriage | undefined> {
|
|
50
|
+
const triage = await triageWithContext(classifier(), scope, request, signal);
|
|
51
|
+
if (triage) lobbyFeed.log("CLASSIFIER", triageLine(triage), "info");
|
|
52
|
+
return triage;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Effort routing for the process's classifier; `clamp` fits a thinking level
|
|
57
|
+
* to what a model supports. Routes are logged to the lobby's activity feed.
|
|
58
|
+
*/
|
|
59
|
+
export function effortFor(clamp?: (model: string, thinking: string) => string): EffortRouter {
|
|
60
|
+
return effortRouter(classifier(), { ...(clamp ? { clamp } : {}), log: (text) => lobbyFeed.log("CLASSIFIER", text, "info") });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The tree this process's session works in, once it has started. */
|
|
64
|
+
export function sessionScope(): FileScope | undefined {
|
|
65
|
+
return binding ? { cwd: binding.cwd, root: binding.root, configDir: binding.configDir } : undefined;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Where a pi provider's key comes from (never the key), once a session has started. */
|
|
69
|
+
export function keyStatus(piProvider: string): KeyStatus | undefined {
|
|
70
|
+
return binding?.status(piProvider);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Tests: use this classifier for the process (undefined restores the default). */
|
|
74
|
+
export function useClassifier(next: Classifier | undefined): void {
|
|
75
|
+
instance = next;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Register the `typesafe` login entry and bind the classifier to each session's key store. */
|
|
79
|
+
export function registerClassifier(pi: ExtensionAPI, configDir: string): void {
|
|
80
|
+
registerJevProvider(pi);
|
|
81
|
+
pi.on("session_start", (_event, ctx) => {
|
|
82
|
+
binding = {
|
|
83
|
+
cwd: ctx.cwd,
|
|
84
|
+
root: detectProjectRoot(ctx.cwd, configDir),
|
|
85
|
+
configDir,
|
|
86
|
+
keys: (piProvider) => ctx.modelRegistry.getApiKeyForProvider(piProvider),
|
|
87
|
+
status: (piProvider) => {
|
|
88
|
+
try {
|
|
89
|
+
return ctx.modelRegistry.getProviderAuthStatus(piProvider);
|
|
90
|
+
} catch {
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
// Subagents have nobody to tell; their runs still land in the metrics.
|
|
95
|
+
...(ctx.hasUI && !isSubagentProcess() ? { notify: (message: string, level: "info" | "warning" | "error") => ctx.ui.notify(message, level) } : {}),
|
|
96
|
+
};
|
|
97
|
+
});
|
|
98
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Request bounds, enforced before anything is sent. Jev reads about 32K
|
|
3
|
+
* tokens (roughly 120K characters of English); the budget leaves room for the
|
|
4
|
+
* question text and JSON overhead.
|
|
5
|
+
*/
|
|
6
|
+
import type { SystemOneRequest } from "./client.ts";
|
|
7
|
+
|
|
8
|
+
export const LIMITS = {
|
|
9
|
+
/** Characters kept per item (a file excerpt, a message, a plan). */
|
|
10
|
+
itemChars: 4000,
|
|
11
|
+
/** Characters allowed for the whole serialised request. */
|
|
12
|
+
requestChars: 110_000,
|
|
13
|
+
/** Options per `choice` question (the API accepts up to 255). */
|
|
14
|
+
choiceOptions: 250,
|
|
15
|
+
} as const;
|
|
16
|
+
|
|
17
|
+
export const CLIP_MARKER = " […]";
|
|
18
|
+
|
|
19
|
+
/** Keep the head of a text within `max` characters, marking the cut. */
|
|
20
|
+
export function clip(text: string, max: number = LIMITS.itemChars): string {
|
|
21
|
+
if (text.length <= max) return text;
|
|
22
|
+
return `${text.slice(0, Math.max(0, max - CLIP_MARKER.length)).trimEnd()}${CLIP_MARKER}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Keep the tail of a text within `max` characters (the newest part of a conversation). */
|
|
26
|
+
export function clipTail(text: string, max: number = LIMITS.itemChars): string {
|
|
27
|
+
if (text.length <= max) return text;
|
|
28
|
+
return `[…] ${text.slice(text.length - Math.max(0, max - 4)).trimStart()}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function requestSize(request: SystemOneRequest): number {
|
|
32
|
+
return JSON.stringify(request).length;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function fitsBudget(request: SystemOneRequest): boolean {
|
|
36
|
+
return requestSize(request) <= LIMITS.requestChars;
|
|
37
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which planning seats run this round. Every seat is a full pi run that
|
|
3
|
+
* re-reads the repository, so one classifier call asks, per seat, whether the
|
|
4
|
+
* idea (round 1) or the user's latest answers touch anything only that seat
|
|
5
|
+
* would decide or check. A seat that was READY needs stronger evidence to come
|
|
6
|
+
* back. Any seat the classifier did not answer for sits, so a partial answer
|
|
7
|
+
* never silences a domain.
|
|
8
|
+
*/
|
|
9
|
+
import type { ClassifierThresholds } from "../schemas/configuration.ts";
|
|
10
|
+
import type { Classifier } from "./classifier.ts";
|
|
11
|
+
import { noul, yesOf, type Answer, type SystemOneRequest } from "./client.ts";
|
|
12
|
+
import { clip, clipTail } from "./limits.ts";
|
|
13
|
+
|
|
14
|
+
export interface SeatCandidate {
|
|
15
|
+
member: string;
|
|
16
|
+
/** DEV, DESIGN, QA, RESEARCH. */
|
|
17
|
+
label: string;
|
|
18
|
+
/** What the seat owns, in plain words. */
|
|
19
|
+
owns: string;
|
|
20
|
+
/** How the seat left the last round it sat. */
|
|
21
|
+
lastStatus?: "ready" | "open";
|
|
22
|
+
notes?: readonly string[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface SeatInput {
|
|
26
|
+
round: number;
|
|
27
|
+
/** The idea as first described (and the source issue, if any). */
|
|
28
|
+
idea: string;
|
|
29
|
+
/** The user's newest message: answers, comments, a new direction. */
|
|
30
|
+
latest: string;
|
|
31
|
+
draft?: string;
|
|
32
|
+
candidates: readonly SeatCandidate[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface SeatDecision {
|
|
36
|
+
/** Members that sit this round. */
|
|
37
|
+
seat: Set<string>;
|
|
38
|
+
/** The probability each candidate was judged needed. */
|
|
39
|
+
probabilities: Map<string, number>;
|
|
40
|
+
ms: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function questionKey(member: string): string {
|
|
44
|
+
return `seat_${member}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function seatRequest(input: SeatInput): SystemOneRequest {
|
|
48
|
+
const seats: Record<string, { owns: string; last_round: string; notes: string[] }> = {};
|
|
49
|
+
for (const candidate of input.candidates) {
|
|
50
|
+
seats[candidate.label] = {
|
|
51
|
+
owns: candidate.owns,
|
|
52
|
+
last_round: candidate.lastStatus === "ready" ? "READY: nothing open for this seat" : candidate.lastStatus === "open" ? "OPEN: had questions" : "has not sat yet",
|
|
53
|
+
notes: (candidate.notes ?? []).slice(0, 6).map((note) => clip(note, 300)),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
const first = input.round <= 1;
|
|
57
|
+
const questions = Object.fromEntries(input.candidates.map((candidate) => [
|
|
58
|
+
questionKey(candidate.member),
|
|
59
|
+
noul(
|
|
60
|
+
first
|
|
61
|
+
? `Does \`idea\` involve anything that the ${candidate.label} seat (\`seats.${candidate.label}.owns\`) must decide or check before the plan can be built?`
|
|
62
|
+
: `Given \`idea\` and the \`draft\` plan, does the user's \`latest\` message raise or leave open anything that only the ${candidate.label} seat (\`seats.${candidate.label}.owns\`) would ask about or check?`,
|
|
63
|
+
`Yes: there is a decision, constraint or risk in the ${candidate.label} seat's domain that is not settled yet.`,
|
|
64
|
+
`No: nothing in it touches the ${candidate.label} seat's domain, or everything there is already settled.`,
|
|
65
|
+
),
|
|
66
|
+
]));
|
|
67
|
+
return {
|
|
68
|
+
state: {
|
|
69
|
+
idea: clip(input.idea, 3000),
|
|
70
|
+
latest: first ? "" : clipTail(input.latest, 3000),
|
|
71
|
+
draft: clip(input.draft ?? "", 5000),
|
|
72
|
+
seats,
|
|
73
|
+
},
|
|
74
|
+
questions,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Seat each candidate at `seatAt`, or `reseatReadyAt` when it was READY; unanswered candidates sit. */
|
|
79
|
+
export function decideSeats(answers: Record<string, Answer> | undefined, input: SeatInput, thresholds: Pick<ClassifierThresholds, "seatAt" | "reseatReadyAt">): Omit<SeatDecision, "ms"> {
|
|
80
|
+
const seat = new Set<string>();
|
|
81
|
+
const probabilities = new Map<string, number>();
|
|
82
|
+
for (const candidate of input.candidates) {
|
|
83
|
+
const probability = yesOf(answers, questionKey(candidate.member));
|
|
84
|
+
if (probability === undefined) {
|
|
85
|
+
seat.add(candidate.member);
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
probabilities.set(candidate.member, probability);
|
|
89
|
+
const bar = candidate.lastStatus === "ready" ? thresholds.reseatReadyAt : thresholds.seatAt;
|
|
90
|
+
if (probability >= bar) seat.add(candidate.member);
|
|
91
|
+
}
|
|
92
|
+
return { seat, probabilities };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** One call for every candidate seat; undefined when the classifier is off or fails. */
|
|
96
|
+
export async function chooseSeats(classifier: Classifier, input: SeatInput, signal?: AbortSignal): Promise<SeatDecision | undefined> {
|
|
97
|
+
if (input.candidates.length === 0) return undefined;
|
|
98
|
+
const thresholds = classifier.config.thresholds;
|
|
99
|
+
const result = await classifier.ask("seats", seatRequest(input), { ...(signal ? { signal } : {}), saved: (answers) => input.candidates.length - decideSeats(answers, input, thresholds).seat.size });
|
|
100
|
+
if (!result) return undefined;
|
|
101
|
+
return { ...decideSeats(result.answers, input, classifier.config.thresholds), ms: result.ms };
|
|
102
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `find_relevant_files` inside a subagent: the agent describes what it is
|
|
3
|
+
* looking for in plain words and gets the repository's files ranked by the
|
|
4
|
+
* classifier, instead of walking the tree with find and grep. The engine adds
|
|
5
|
+
* the tool to an agent's allowlist only while file hints are on; when the
|
|
6
|
+
* classifier is off or does not answer, the tool says so and the agent
|
|
7
|
+
* searches as usual.
|
|
8
|
+
*/
|
|
9
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import { Type } from "typebox";
|
|
11
|
+
import { classifier, sessionScope } from "./instance.ts";
|
|
12
|
+
import { FIND_FILES_TOOL, likelyFiles, type LikelyFiles } from "./files.ts";
|
|
13
|
+
import type { Classifier } from "./classifier.ts";
|
|
14
|
+
import type { FileScope } from "./files.ts";
|
|
15
|
+
|
|
16
|
+
function text(value: string) {
|
|
17
|
+
return { content: [{ type: "text" as const, text: value }], details: undefined };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const MAX_TOP_K = 25;
|
|
21
|
+
|
|
22
|
+
/** The tool's answer: ranked paths with their relevance, or why there are none. */
|
|
23
|
+
export function formatLikely(query: string, result: LikelyFiles | undefined): string {
|
|
24
|
+
if (!result) return "The classifier did not answer; search with grep and find instead.";
|
|
25
|
+
if (result.files.length === 0) return `No file stands out for "${query}" (any file relevant: ${result.anyRelevant.toFixed(2)}, ${result.judged} judged); search with grep and find instead.`;
|
|
26
|
+
return [
|
|
27
|
+
`Files most likely needed for "${query}" (any file relevant: ${result.anyRelevant.toFixed(2)}; ${result.judged} judged):`,
|
|
28
|
+
...result.files.map((file, index) => `${index + 1}. ${file.path} — ${file.relevance.toFixed(2)}`),
|
|
29
|
+
"Read the top ones first; relevance is a hint, not a fact.",
|
|
30
|
+
].join("\n");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Run one lookup; exported for tests. */
|
|
34
|
+
export async function findRelevantFiles(jev: Classifier, scope: FileScope | undefined, query: string, topK: number | undefined, signal?: AbortSignal): Promise<string> {
|
|
35
|
+
if (!jev.enabled("files")) return "The classifier's file hints are off; search with grep and find instead.";
|
|
36
|
+
if (!scope) return "No session scope yet; search with grep and find instead.";
|
|
37
|
+
const k = Math.max(1, Math.min(MAX_TOP_K, Math.round(topK ?? 10)));
|
|
38
|
+
const result = await likelyFiles(jev, scope, query, { topK: k, ...(signal ? { signal } : {}) });
|
|
39
|
+
return formatLikely(query, result);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function registerClassifierTools(pi: ExtensionAPI): void {
|
|
43
|
+
pi.registerTool({
|
|
44
|
+
name: FIND_FILES_TOOL,
|
|
45
|
+
label: "Find relevant files",
|
|
46
|
+
description:
|
|
47
|
+
"Rank this repository's files by how likely your task needs them, with a fast classifier (no index to build, no embeddings). " +
|
|
48
|
+
"Describe what you are looking for in plain words — \"where the session cookie is validated\", \"tests for the export endpoint\" — not keywords. " +
|
|
49
|
+
"Returns up to top_k paths with a relevance from 0 to 1, and how likely any file answers at all. Use it before a broad find or grep, then read the top files.",
|
|
50
|
+
parameters: Type.Object({
|
|
51
|
+
query: Type.String({ description: "What you are looking for, in plain words" }),
|
|
52
|
+
top_k: Type.Optional(Type.Number({ description: `How many files to return (1-${MAX_TOP_K}, default 10)` })),
|
|
53
|
+
}),
|
|
54
|
+
async execute(_id, params, signal) {
|
|
55
|
+
return text(await findRelevantFiles(classifier(), sessionScope(), params.query, params.top_k, signal));
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Triage: what the classifier makes of a request when a task starts — its
|
|
3
|
+
* size, which domains it touches, whether it needs outside facts or a
|
|
4
|
+
* clarifying question, and what kind of work it is — in one call. The Master
|
|
5
|
+
* reads the answers as hints (so it can skip reasoning them out and skip
|
|
6
|
+
* scouts it does not need); nothing here becomes a workflow rule. The same
|
|
7
|
+
* size question holds back a quick fix that is really a task, and a clarify
|
|
8
|
+
* question whose recommended option is clearly right is answered here.
|
|
9
|
+
*/
|
|
10
|
+
import type { Domain } from "../schemas/agent.ts";
|
|
11
|
+
import type { TaskTriage, TriageSize } from "../schemas/task.ts";
|
|
12
|
+
import type { Classifier } from "./classifier.ts";
|
|
13
|
+
import { choice, choiceOf, noul, score, scoreOf, yesOf, type SystemOneRequest } from "./client.ts";
|
|
14
|
+
import { clip } from "./limits.ts";
|
|
15
|
+
import { autoAnswer, type AutoAnswer } from "./answers.ts";
|
|
16
|
+
import { ANY_RELEVANT_FLOOR, indexFiles, likelyFiles, type FileScope, type IndexedFile } from "./files.ts";
|
|
17
|
+
import { DOMAIN_SPECS } from "../agents/registry.ts";
|
|
18
|
+
import { DOMAINS } from "../schemas/agent.ts";
|
|
19
|
+
|
|
20
|
+
export const TRIAGE_SIZES: readonly TriageSize[] = ["trivial", "small", "medium", "large"];
|
|
21
|
+
|
|
22
|
+
const SIZE_LEVELS = [
|
|
23
|
+
"trivial: one small, mechanical edit in one file (a rename, a copy change, a one-line fix)",
|
|
24
|
+
"small: a few files in one area, following an existing pattern",
|
|
25
|
+
"medium: several files or two areas (for example an endpoint and its screen), with some design decisions",
|
|
26
|
+
"large: a cross-cutting feature, a new subsystem, a migration or an architecture change",
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
export const TRIAGE_KINDS: Record<string, string> = {
|
|
30
|
+
feature: "New capability or behaviour",
|
|
31
|
+
bugfix: "Existing behaviour is wrong, crashes or differs from what it should do",
|
|
32
|
+
refactor: "Restructure code without changing behaviour",
|
|
33
|
+
tests: "Add or fix tests only",
|
|
34
|
+
docs: "Documentation only",
|
|
35
|
+
chore: "Configuration, dependencies, build, tooling or cleanup",
|
|
36
|
+
investigation: "Understand, explain or diagnose something; no change requested yet",
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export interface TriageInput {
|
|
40
|
+
request: string;
|
|
41
|
+
/** Top-level layout of the repository, e.g. `src (80 files)`. */
|
|
42
|
+
layout?: readonly string[];
|
|
43
|
+
/** What each domain owns, for the domain questions. */
|
|
44
|
+
domains: ReadonlyArray<{ domain: Domain; owns: string }>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function sizeQuestion(subject: string) {
|
|
48
|
+
return score(`How big is the change ${subject} asks for, judged by what it would take to build and verify in this repository?`, SIZE_LEVELS);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function triageRequest(input: TriageInput): SystemOneRequest {
|
|
52
|
+
const questions: SystemOneRequest["questions"] = {
|
|
53
|
+
size: sizeQuestion("`request`"),
|
|
54
|
+
needs_research: noul(
|
|
55
|
+
"Does building `request` depend on facts from outside the repository — library or API versions, third-party services, standards, current documentation?",
|
|
56
|
+
"Yes: a decision needs outside evidence that cannot be read from the code.",
|
|
57
|
+
"No: the repository and the request are enough.",
|
|
58
|
+
),
|
|
59
|
+
ambiguous: noul(
|
|
60
|
+
"Would building `request` as written probably produce the wrong thing without first asking the user a question?",
|
|
61
|
+
"Yes: a decision that changes what gets built is missing or contradictory.",
|
|
62
|
+
"No: a competent engineer could build it as written, deciding details from the codebase.",
|
|
63
|
+
),
|
|
64
|
+
kind: choice("What kind of work does `request` ask for?", TRIAGE_KINDS),
|
|
65
|
+
};
|
|
66
|
+
for (const { domain, owns } of input.domains) {
|
|
67
|
+
questions[`domain_${domain}`] = noul(`Does building \`request\` require changes in this area: ${owns}?`);
|
|
68
|
+
}
|
|
69
|
+
return {
|
|
70
|
+
state: { request: clip(input.request, 6000), ...(input.layout && input.layout.length > 0 ? { repository_layout: input.layout.join(", ") } : {}) },
|
|
71
|
+
questions,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The request's triage, or undefined when the classifier is off or fails. */
|
|
76
|
+
export async function triageTask(classifier: Classifier, input: TriageInput, signal?: AbortSignal): Promise<TaskTriage | undefined> {
|
|
77
|
+
if (!classifier.enabled("triage") || !input.request.trim()) return undefined;
|
|
78
|
+
const result = await classifier.ask("triage", triageRequest(input), signal ? { signal } : {});
|
|
79
|
+
if (!result) return undefined;
|
|
80
|
+
const size = scoreOf(result.answers, "size");
|
|
81
|
+
if (!size) return undefined;
|
|
82
|
+
const domains: Partial<Record<Domain, number>> = {};
|
|
83
|
+
for (const { domain } of input.domains) {
|
|
84
|
+
const probability = yesOf(result.answers, `domain_${domain}`);
|
|
85
|
+
if (probability !== undefined) domains[domain] = probability;
|
|
86
|
+
}
|
|
87
|
+
const kind = choiceOf(result.answers, "kind");
|
|
88
|
+
return {
|
|
89
|
+
size: TRIAGE_SIZES[Math.max(0, Math.min(TRIAGE_SIZES.length - 1, size.level))]!,
|
|
90
|
+
sizeConfidence: size.confidence,
|
|
91
|
+
domains,
|
|
92
|
+
research: yesOf(result.answers, "needs_research") ?? 0,
|
|
93
|
+
ambiguous: yesOf(result.answers, "ambiguous") ?? 0,
|
|
94
|
+
...(kind ? { kind: kind.choice, kindProbability: kind.probability } : {}),
|
|
95
|
+
at: new Date().toISOString(),
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The repository's top level: directories with their file counts, then a few root files. */
|
|
100
|
+
export function repositoryLayout(files: readonly IndexedFile[], max = 14): string[] {
|
|
101
|
+
const dirs = new Map<string, number>();
|
|
102
|
+
const rootFiles: string[] = [];
|
|
103
|
+
for (const { path } of files) {
|
|
104
|
+
const slash = path.indexOf("/");
|
|
105
|
+
if (slash < 0) rootFiles.push(path);
|
|
106
|
+
else dirs.set(path.slice(0, slash), (dirs.get(path.slice(0, slash)) ?? 0) + 1);
|
|
107
|
+
}
|
|
108
|
+
const listed = [...dirs].sort((a, b) => b[1] - a[1]).map(([dir, count]) => `${dir}/ (${count} files)`);
|
|
109
|
+
return [...listed, ...rootFiles.slice(0, 8)].slice(0, max);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Triage a new task's request with the repository's layout, and attach the
|
|
114
|
+
* likely files when file hints are on. Undefined when triage is off or fails.
|
|
115
|
+
*/
|
|
116
|
+
export async function triageWithContext(classifier: Classifier, scope: FileScope, request: string, signal?: AbortSignal): Promise<TaskTriage | undefined> {
|
|
117
|
+
if (!classifier.enabled("triage")) return undefined;
|
|
118
|
+
let layout: string[] = [];
|
|
119
|
+
try {
|
|
120
|
+
layout = repositoryLayout(await indexFiles(scope, classifier.config.exclude));
|
|
121
|
+
} catch {
|
|
122
|
+
layout = [];
|
|
123
|
+
}
|
|
124
|
+
const domains = DOMAINS.map((domain) => ({ domain, owns: DOMAIN_SPECS[domain].scoutFocus }));
|
|
125
|
+
const [triage, likely] = await Promise.all([
|
|
126
|
+
triageTask(classifier, { request, layout, domains }, signal),
|
|
127
|
+
likelyFiles(classifier, scope, request, { topK: 6, budgetMs: classifier.config.fileHints.budgetMs, ...(signal ? { signal } : {}) }),
|
|
128
|
+
]);
|
|
129
|
+
if (!triage) return undefined;
|
|
130
|
+
const files = likely && likely.anyRelevant >= ANY_RELEVANT_FLOOR ? likely.files.map((file) => file.path) : [];
|
|
131
|
+
return files.length > 0 ? { ...triage, likelyFiles: files } : triage;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** The size of a quick fix prompt, or undefined when the classifier is off or fails. */
|
|
135
|
+
export async function quickFixSize(classifier: Classifier, prompt: string, signal?: AbortSignal): Promise<{ size: TriageSize; confidence: number } | undefined> {
|
|
136
|
+
if (!classifier.enabled("triage")) return undefined;
|
|
137
|
+
const largeAt = classifier.config.thresholds.quickFixLargeAt;
|
|
138
|
+
const result = await classifier.ask("triage", { state: { prompt: clip(prompt, 6000) }, questions: { size: sizeQuestion("`prompt`") } }, {
|
|
139
|
+
...(signal ? { signal } : {}),
|
|
140
|
+
saved: (answers) => {
|
|
141
|
+
const sized = scoreOf(answers, "size");
|
|
142
|
+
return sized && sized.level >= TRIAGE_SIZES.length - 1 && sized.confidence >= largeAt ? 1 : 0;
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
const size = scoreOf(result?.answers, "size");
|
|
146
|
+
if (!size) return undefined;
|
|
147
|
+
return { size: TRIAGE_SIZES[Math.max(0, Math.min(TRIAGE_SIZES.length - 1, size.level))]!, confidence: size.confidence };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const HINT = 0.5;
|
|
151
|
+
|
|
152
|
+
/** The one-line path the triage suggests to the Master. */
|
|
153
|
+
export function suggestedPath(triage: TaskTriage): string {
|
|
154
|
+
const touched = Object.entries(triage.domains).filter(([, probability]) => (probability ?? 0) >= HINT).map(([domain]) => domain);
|
|
155
|
+
if (triage.ambiguous >= HINT) return "clarify first: the request as written probably misses a decision.";
|
|
156
|
+
const smallish = (triage.size === "trivial" || triage.size === "small") && triage.sizeConfidence >= 0.6;
|
|
157
|
+
const steps: string[] = [];
|
|
158
|
+
if (smallish && touched.length === 1) steps.push(`single-domain shortcut (${touched[0]}): skip the scout round and the proposal ceremony, state the short plan and delegate`);
|
|
159
|
+
else if (touched.length > 0) steps.push(`scout only ${touched.join(", ")}`);
|
|
160
|
+
if (triage.research >= HINT) steps.push("summon the researcher for the outside facts");
|
|
161
|
+
return steps.length > 0 ? `${steps.join("; ")}.` : "no strong signal; decide from the request.";
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* The Master's hint block, while the task is still being shaped. Hints only:
|
|
166
|
+
* the Master decides, and the engine enforces nothing from them.
|
|
167
|
+
*/
|
|
168
|
+
export function triageContext(triage: TaskTriage | undefined): string {
|
|
169
|
+
if (!triage) return "";
|
|
170
|
+
const domains = Object.entries(triage.domains)
|
|
171
|
+
.sort((a, b) => (b[1] ?? 0) - (a[1] ?? 0))
|
|
172
|
+
.map(([domain, probability]) => `${domain} ${(probability ?? 0).toFixed(2)}`)
|
|
173
|
+
.join(" · ");
|
|
174
|
+
return [
|
|
175
|
+
"Classifier triage (hints from a fast model; you decide):",
|
|
176
|
+
`- Size: ${triage.size} (confidence ${triage.sizeConfidence.toFixed(2)})`,
|
|
177
|
+
domains ? `- Domains touched: ${domains}` : "",
|
|
178
|
+
`- Outside research: ${triage.research >= HINT ? "likely needed" : "not needed"} (${triage.research.toFixed(2)})`,
|
|
179
|
+
`- ${triage.ambiguous >= HINT ? "Probably ambiguous as written" : "Clear as written"} (${triage.ambiguous.toFixed(2)})`,
|
|
180
|
+
triage.kind ? `- Kind: ${triage.kind}${triage.kindProbability !== undefined ? ` (${triage.kindProbability.toFixed(2)})` : ""}` : "",
|
|
181
|
+
triage.likelyFiles && triage.likelyFiles.length > 0 ? `- Likely files: ${triage.likelyFiles.join(", ")}` : "",
|
|
182
|
+
`- Suggested path: ${suggestedPath(triage)}`,
|
|
183
|
+
].filter(Boolean).join("\n");
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** A one-line summary for the activity log. */
|
|
187
|
+
export function triageLine(triage: TaskTriage): string {
|
|
188
|
+
const touched = Object.entries(triage.domains).filter(([, probability]) => (probability ?? 0) >= HINT).map(([domain]) => domain);
|
|
189
|
+
return `triage: ${triage.size} (${triage.sizeConfidence.toFixed(2)}) · ${touched.length > 0 ? touched.join(", ") : "no clear domain"}${triage.research >= HINT ? " · research" : ""}${triage.ambiguous >= HINT ? " · ambiguous" : ""}${triage.kind ? ` · ${triage.kind}` : ""}`;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* A clarify question the classifier can answer: its pick must be the
|
|
194
|
+
* recommended option (marked `(Recommended)`, else the first), confident and
|
|
195
|
+
* clearly ahead; otherwise the question goes to the user as before.
|
|
196
|
+
*/
|
|
197
|
+
export async function answerClarify(classifier: Classifier, question: string, options: readonly string[], context: { request: string; notes: string; proposal?: string }, signal?: AbortSignal): Promise<AutoAnswer | undefined> {
|
|
198
|
+
if (options.length < 2 || !classifier.enabled("answers")) return undefined;
|
|
199
|
+
const labels = options.map((option) => option.replace(/\s*\(recommended\)\s*/i, " ").trim());
|
|
200
|
+
const marked = options.findIndex((option) => /\(recommended\)/i.test(option));
|
|
201
|
+
const decided = await autoAnswer(classifier, [{ index: 0, from: "MASTER", text: question, options: labels.map((label) => ({ label, description: "" })), recommended: labels[marked >= 0 ? marked : 0]! }], { request: context.request, conversation: context.notes, ...(context.proposal ? { draft: context.proposal } : {}) }, signal);
|
|
202
|
+
return decided?.[0];
|
|
203
|
+
}
|