@a-t-h-i/bot-lobby 0.6.37 → 0.6.39
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 +24 -5
- package/package.json +1 -1
- package/prompts/master.md +38 -2
- package/prompts/quickfix.md +5 -0
- package/prompts/worker.md +12 -0
- package/src/classifier/instance.ts +10 -2
- package/src/classifier/knowledge.ts +5 -2
- package/src/classifier/scripts.ts +76 -0
- package/src/execution/agent-runner.ts +2 -0
- package/src/execution/git.ts +63 -0
- package/src/knowledge/selector.ts +2 -0
- package/src/lobby/quickfix.ts +65 -4
- package/src/lobby/runtime.ts +2 -1
- package/src/master/master.ts +9 -1
- package/src/pi/activity.ts +2 -0
- package/src/pi/events.ts +4 -1
- package/src/pi/plan-checklist.ts +2 -1
- package/src/pi/tools.ts +7 -4
- package/src/prompts/compiler.ts +4 -1
- package/src/roles/worker.ts +15 -1
- package/src/schemas/configuration.ts +5 -2
- package/src/schemas/findings.ts +12 -0
- package/src/scripts/library.ts +168 -0
- package/src/scripts/pick.ts +79 -0
- package/src/state/work-time.ts +4 -2
- package/src/webui/api/plan-facts.ts +17 -2
- package/src/webui/api/tasks.ts +1 -1
- package/src/webui/protocol.ts +10 -1
- package/src/workflow/workflow.ts +90 -8
- package/webui/dist/assets/index-D8TuTAMK.js +109 -0
- package/webui/dist/assets/index-uTndyUfm.css +2 -0
- package/webui/dist/build.json +15 -19
- package/webui/dist/index.html +2 -2
- package/webui/dist/assets/index-BTk14vBb.css +0 -2
- package/webui/dist/assets/index-BwyOrEcV.js +0 -110
package/src/prompts/compiler.ts
CHANGED
|
@@ -15,6 +15,8 @@ export interface CompileInput {
|
|
|
15
15
|
standards?: string;
|
|
16
16
|
knowledge?: string;
|
|
17
17
|
decisions?: string;
|
|
18
|
+
/** The shared scripts that fit the step, or the library for the oracle. */
|
|
19
|
+
scripts?: string;
|
|
18
20
|
workflowContext?: string;
|
|
19
21
|
}
|
|
20
22
|
|
|
@@ -29,7 +31,7 @@ function section(title: string, body?: string): string {
|
|
|
29
31
|
|
|
30
32
|
/**
|
|
31
33
|
* Compose the layered prompt: global + domain + role + task context +
|
|
32
|
-
* standards + knowledge + decisions + workflow context + output contract.
|
|
34
|
+
* standards + knowledge + decisions + scripts + workflow context + output contract.
|
|
33
35
|
* Empty layers are dropped so agents never receive empty headings.
|
|
34
36
|
*/
|
|
35
37
|
export function compilePrompt(input: CompileInput): string {
|
|
@@ -43,6 +45,7 @@ export function compilePrompt(input: CompileInput): string {
|
|
|
43
45
|
section("Standards", input.standards),
|
|
44
46
|
section("Knowledge", input.knowledge),
|
|
45
47
|
section("Decisions", input.decisions),
|
|
48
|
+
section("Scripts", input.scripts),
|
|
46
49
|
section("Workflow Context", input.workflowContext),
|
|
47
50
|
spec?.contract ?? "",
|
|
48
51
|
];
|
package/src/roles/worker.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Domain, RoleSpec } from "../schemas/agent.ts";
|
|
2
2
|
import type { Blocker } from "../schemas/task.ts";
|
|
3
|
-
import type { FileChange, KnowledgeProposal, WorkerResult } from "../schemas/findings.ts";
|
|
3
|
+
import type { FileChange, KnowledgeProposal, ScriptProposal, WorkerResult } from "../schemas/findings.ts";
|
|
4
4
|
import { bullets, fieldValue, findSection, parseFileBullet, parsePushback, parseSections } from "./markdown.ts";
|
|
5
5
|
|
|
6
6
|
/**
|
|
@@ -18,6 +18,7 @@ export const workerSpec: RoleSpec = {
|
|
|
18
18
|
"Respond with exactly these sections and nothing else: `## Completed`, `## Files Changed`,",
|
|
19
19
|
"`## Verification`, `## Brief Check` (one `- criterion — met|not met — evidence` line per \"Done when\" item of your brief), `## Notes`, `## Blockers`, `## Dependencies Needed`, `## Architecture Changes`,",
|
|
20
20
|
"`## Knowledge Proposals` (`- knowledge: ...`, `- standard: ...`, or `- decision: ...`).",
|
|
21
|
+
"An optional `## Script Proposals` (`- \\`name.ext\\` — what it does — draft: \\`path\\``) proposes a script you wrote for a job that will come back, for the shared library.",
|
|
21
22
|
"`## Files Changed` entries are `- \\`path\\` — change`.",
|
|
22
23
|
"`## Verification` entries are `- command — result`.",
|
|
23
24
|
"Blocked work uses `**Blocker:**`, `**Tried:**`, `**Need:**` under `## Blockers`.",
|
|
@@ -39,6 +40,18 @@ export function parseKnowledgeProposals(domain: Domain, text: string | undefined
|
|
|
39
40
|
});
|
|
40
41
|
}
|
|
41
42
|
|
|
43
|
+
/** Parse `` - `name.ext` — purpose — draft: `path` `` proposals; one without a name is dropped. */
|
|
44
|
+
export function parseScriptProposals(domain: Domain, text: string | undefined): ScriptProposal[] {
|
|
45
|
+
return bullets(text).flatMap((entry): ScriptProposal[] => {
|
|
46
|
+
const draftMatch = /\bdraft\s*:\s*`?([^`\s]+)`?/i.exec(entry);
|
|
47
|
+
const rest = (draftMatch ? entry.slice(0, draftMatch.index) : entry).replace(/[\s—–(-]+$/, "");
|
|
48
|
+
const named = /^`?([A-Za-z0-9_.-]+\.[A-Za-z0-9]+)`?\s*(?:[—–:-]+\s*)?(.*)$/.exec(rest.trim());
|
|
49
|
+
if (!named) return [];
|
|
50
|
+
const purpose = named[2]!.replace(/^[—–:-]+\s*/, "").trim();
|
|
51
|
+
return [{ domain, name: named[1]!, purpose, ...(draftMatch ? { draft: draftMatch[1]! } : {}) }];
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
42
55
|
function splitList(text: string | undefined): string[] {
|
|
43
56
|
return (text ?? "")
|
|
44
57
|
.split(/[;\n]|\s+then\s+/)
|
|
@@ -100,6 +113,7 @@ export function parseWorkerResult(domain: Domain, raw: string, now = new Date().
|
|
|
100
113
|
notes: findSection(sections, "notes") ?? "",
|
|
101
114
|
blockers: parseBlockers(sections, domain, now),
|
|
102
115
|
knowledgeProposals: parseKnowledgeProposals(domain, findSection(sections, "knowledge proposals")),
|
|
116
|
+
scriptProposals: parseScriptProposals(domain, findSection(sections, "script proposals")),
|
|
103
117
|
pushback: parsePushback(sections),
|
|
104
118
|
dependencyNeeds: realAsks(bullets(findSection(sections, "dependencies needed"))),
|
|
105
119
|
architectureChanges: realAsks(bullets(findSection(sections, "architecture changes"))),
|
|
@@ -173,7 +173,7 @@ export interface LobbyConfig {
|
|
|
173
173
|
}
|
|
174
174
|
|
|
175
175
|
/** Decisions the classifier can make, each switched on or off on its own. */
|
|
176
|
-
export const CLASSIFIER_FEATURES = ["seats", "answers", "files", "triage", "effort", "review", "knowledge", "qa"] as const;
|
|
176
|
+
export const CLASSIFIER_FEATURES = ["seats", "answers", "files", "triage", "effort", "review", "knowledge", "scripts", "qa"] as const;
|
|
177
177
|
export type ClassifierFeature = (typeof CLASSIFIER_FEATURES)[number];
|
|
178
178
|
|
|
179
179
|
/** Hosts that serve Jev behind the same System One API; `auto` takes OpenCode's free Jev when pi holds an OpenCode key, else TypeSafe. */
|
|
@@ -194,6 +194,8 @@ export interface ClassifierThresholds {
|
|
|
194
194
|
fileRelevantAt: number;
|
|
195
195
|
/** A section of a knowledge file that is over the prompt budget stays in at this relevance. */
|
|
196
196
|
knowledgeRelevantAt: number;
|
|
197
|
+
/** A shared script is suggested for a step at this probability that it does part of it. */
|
|
198
|
+
scriptRelevantAt: number;
|
|
197
199
|
/** A step scored simple at this confidence runs one thinking level lower. */
|
|
198
200
|
simpleAt: number;
|
|
199
201
|
/** A step scored trivial at this confidence runs on the cheaper model. */
|
|
@@ -294,7 +296,7 @@ export const DEFAULT_CONFIG: BotLobbyConfig = {
|
|
|
294
296
|
model: "",
|
|
295
297
|
baseUrl: "",
|
|
296
298
|
timeoutMs: 4000,
|
|
297
|
-
features: { seats: true, answers: true, files: true, triage: true, effort: true, review: true, knowledge: true, qa: true },
|
|
299
|
+
features: { seats: true, answers: true, files: true, triage: true, effort: true, review: true, knowledge: true, scripts: true, qa: true },
|
|
298
300
|
thresholds: {
|
|
299
301
|
seatAt: 0.35,
|
|
300
302
|
reseatReadyAt: 0.6,
|
|
@@ -302,6 +304,7 @@ export const DEFAULT_CONFIG: BotLobbyConfig = {
|
|
|
302
304
|
autoAnswerMargin: 0.5,
|
|
303
305
|
fileRelevantAt: 0.5,
|
|
304
306
|
knowledgeRelevantAt: 0.4,
|
|
307
|
+
scriptRelevantAt: 0.5,
|
|
305
308
|
simpleAt: 0.7,
|
|
306
309
|
trivialAt: 0.8,
|
|
307
310
|
quickFixAt: 0.7,
|
package/src/schemas/findings.ts
CHANGED
|
@@ -24,6 +24,16 @@ export interface KnowledgeProposal {
|
|
|
24
24
|
content: string;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
/** A script a worker thinks the shared library should keep, for the oracle to save or ignore. */
|
|
28
|
+
export interface ScriptProposal {
|
|
29
|
+
domain: Domain;
|
|
30
|
+
/** The file name it proposes: `seed_public_holidays.py`. */
|
|
31
|
+
name: string;
|
|
32
|
+
purpose: string;
|
|
33
|
+
/** Where the worker drafted it, when it did. */
|
|
34
|
+
draft?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
27
37
|
export interface ScoutResult {
|
|
28
38
|
domain: Domain;
|
|
29
39
|
role: "scout";
|
|
@@ -51,6 +61,8 @@ export interface WorkerResult {
|
|
|
51
61
|
notes: string;
|
|
52
62
|
blockers: Blocker[];
|
|
53
63
|
knowledgeProposals: KnowledgeProposal[];
|
|
64
|
+
/** Scripts the worker proposes for the shared library. */
|
|
65
|
+
scriptProposals?: ScriptProposal[];
|
|
54
66
|
dependencyNeeds: string[];
|
|
55
67
|
architectureChanges: string[];
|
|
56
68
|
pushback?: Pushback;
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The scripts library: scripts every agent shares for jobs that come back
|
|
3
|
+
* (seeding data, regenerating fixtures, a set of checks, a migration), so a
|
|
4
|
+
* step done once is run the next time, not worked out again, which saves
|
|
5
|
+
* tokens and time. They live in `<data>/scripts/`, one file each, named for
|
|
6
|
+
* what they do in snake_case with the language's extension
|
|
7
|
+
* (`seed_public_holidays.py`, `run_changed_tests.sh`). The first lines of each
|
|
8
|
+
* file say what it is for and how to run it, and the library's index is read
|
|
9
|
+
* from them, so a script edited by hand stays listed. Only the oracle adds or
|
|
10
|
+
* replaces a script; workers propose one. A replaced script is kept under
|
|
11
|
+
* `archive/`.
|
|
12
|
+
*/
|
|
13
|
+
import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
|
|
16
|
+
/** The languages a script may be written in: how it is run and how its header lines are commented. */
|
|
17
|
+
export const SCRIPT_LANGUAGES = {
|
|
18
|
+
py: { runner: "python3", comment: "#" },
|
|
19
|
+
sh: { runner: "bash", comment: "#" },
|
|
20
|
+
bash: { runner: "bash", comment: "#" },
|
|
21
|
+
js: { runner: "node", comment: "//" },
|
|
22
|
+
mjs: { runner: "node", comment: "//" },
|
|
23
|
+
cjs: { runner: "node", comment: "//" },
|
|
24
|
+
ts: { runner: "node", comment: "//" },
|
|
25
|
+
rb: { runner: "ruby", comment: "#" },
|
|
26
|
+
pl: { runner: "perl", comment: "#" },
|
|
27
|
+
ps1: { runner: "pwsh -File", comment: "#" },
|
|
28
|
+
} as const;
|
|
29
|
+
export type ScriptLanguage = keyof typeof SCRIPT_LANGUAGES;
|
|
30
|
+
|
|
31
|
+
/** Longest name, extension included. */
|
|
32
|
+
export const MAX_SCRIPT_NAME = 64;
|
|
33
|
+
/** Largest script the library takes. */
|
|
34
|
+
export const MAX_SCRIPT_BYTES = 64 * 1024;
|
|
35
|
+
|
|
36
|
+
const NAME = new RegExp(`^[a-z][a-z0-9]*(?:_[a-z0-9]+)+\\.(${Object.keys(SCRIPT_LANGUAGES).join("|")})$`);
|
|
37
|
+
/** How many lines from the top the header is looked for in. */
|
|
38
|
+
const HEADER_LINES = 12;
|
|
39
|
+
|
|
40
|
+
export interface ScriptEntry {
|
|
41
|
+
/** The file name: `seed_public_holidays.py`. */
|
|
42
|
+
name: string;
|
|
43
|
+
path: string;
|
|
44
|
+
language: ScriptLanguage;
|
|
45
|
+
/** What it is for, one line. */
|
|
46
|
+
purpose: string;
|
|
47
|
+
/** How to run it, with its arguments; the runner and path when the header gives none. */
|
|
48
|
+
usage: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function scriptsDir(dataRoot: string): string {
|
|
52
|
+
return join(dataRoot, "scripts");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The words of a name (`seed_public_holidays.py` → `seed public holidays`), for matching it against a step. */
|
|
56
|
+
export function nameWords(name: string): string {
|
|
57
|
+
return name.replace(/\.[a-z0-9]+$/, "").replace(/_/g, " ");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The library name for what was asked: lower-cased, spaces and hyphens as
|
|
62
|
+
* underscores (`Seed public-holidays.py` → `seed_public_holidays.py`). Throws
|
|
63
|
+
* with what is wrong when it still does not read `<what_it_does>.<ext>`.
|
|
64
|
+
*/
|
|
65
|
+
export function scriptName(asked: string): string {
|
|
66
|
+
const name = asked.trim().toLowerCase().replace(/[\s-]+/g, "_").replace(/_+/g, "_");
|
|
67
|
+
const languages = Object.keys(SCRIPT_LANGUAGES).map((ext) => `.${ext}`).join(", ");
|
|
68
|
+
if (!/\.[a-z0-9]+$/.test(name) || !(name.split(".").pop()! in SCRIPT_LANGUAGES)) {
|
|
69
|
+
throw new Error(`a script name ends in its language's extension (${languages}): "${asked}"`);
|
|
70
|
+
}
|
|
71
|
+
if (name.length > MAX_SCRIPT_NAME) throw new Error(`a script name is at most ${MAX_SCRIPT_NAME} characters: "${asked}"`);
|
|
72
|
+
if (!NAME.test(name)) {
|
|
73
|
+
throw new Error(`a script is named for what it does, in snake_case words (seed_public_holidays.py): "${asked}"`);
|
|
74
|
+
}
|
|
75
|
+
return name;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function language(name: string): ScriptLanguage {
|
|
79
|
+
return name.split(".").pop() as ScriptLanguage;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function headerValue(lines: readonly string[], key: string): string | undefined {
|
|
83
|
+
const line = lines.find((entry) => new RegExp(`^\\s*(?:#|//)\\s*${key}\\s*:`, "i").test(entry));
|
|
84
|
+
return line?.replace(new RegExp(`^\\s*(?:#|//)\\s*${key}\\s*:\\s*`, "i"), "").trim() || undefined;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The command that runs a script when its header gives none. */
|
|
88
|
+
export function runCommand(entry: Pick<ScriptEntry, "path" | "language">): string {
|
|
89
|
+
return `${SCRIPT_LANGUAGES[entry.language].runner} ${entry.path}`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function readEntry(dir: string, name: string): ScriptEntry | undefined {
|
|
93
|
+
const path = join(dir, name);
|
|
94
|
+
try {
|
|
95
|
+
if (!statSync(path).isFile()) return undefined;
|
|
96
|
+
const lines = readFileSync(path, "utf8").split("\n").slice(0, HEADER_LINES);
|
|
97
|
+
const entry = { name, path, language: language(name) };
|
|
98
|
+
return { ...entry, purpose: headerValue(lines, "purpose") ?? nameWords(name), usage: headerValue(lines, "usage") ?? runCommand(entry) };
|
|
99
|
+
} catch {
|
|
100
|
+
return undefined;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Every script in the library, by name; files not named like a script are left out. */
|
|
105
|
+
export function listScripts(dataRoot: string): ScriptEntry[] {
|
|
106
|
+
const dir = scriptsDir(dataRoot);
|
|
107
|
+
if (!existsSync(dir)) return [];
|
|
108
|
+
return readdirSync(dir)
|
|
109
|
+
.filter((name) => NAME.test(name))
|
|
110
|
+
.sort()
|
|
111
|
+
.map((name) => readEntry(dir, name))
|
|
112
|
+
.filter((entry): entry is ScriptEntry => entry !== undefined);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The body with any header lines of its own taken out, and a shebang it starts with kept apart. */
|
|
116
|
+
function splitBody(body: string): { shebang?: string; rest: string } {
|
|
117
|
+
const lines = body.replace(/\r\n/g, "\n").split("\n");
|
|
118
|
+
const shebang = lines[0]?.startsWith("#!") ? lines.shift() : undefined;
|
|
119
|
+
const rest = lines.filter((line, index) => index >= HEADER_LINES || !/^\s*(?:#|\/\/)\s*(?:purpose|usage)\s*:/i.test(line));
|
|
120
|
+
while (rest.length > 0 && rest[0]!.trim() === "") rest.shift();
|
|
121
|
+
return { ...(shebang ? { shebang } : {}), rest: rest.join("\n") };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export interface SaveScript {
|
|
125
|
+
dataRoot: string;
|
|
126
|
+
name: string;
|
|
127
|
+
purpose: string;
|
|
128
|
+
/** How to run it, with its arguments; the runner and path when absent. */
|
|
129
|
+
usage?: string;
|
|
130
|
+
body: string;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export interface SavedScript {
|
|
134
|
+
entry: ScriptEntry;
|
|
135
|
+
/** Where the version it replaced was kept, when there was one. */
|
|
136
|
+
archived?: string;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Add a script to the library, or replace the one of that name (the old one
|
|
141
|
+
* is kept under `archive/`). The engine writes its header: purpose and usage
|
|
142
|
+
* as comments after any shebang, so the index never depends on the agent
|
|
143
|
+
* having written one.
|
|
144
|
+
*/
|
|
145
|
+
export function saveScript(input: SaveScript, now = new Date()): SavedScript {
|
|
146
|
+
const name = scriptName(input.name);
|
|
147
|
+
const purpose = input.purpose.replace(/\s+/g, " ").trim();
|
|
148
|
+
if (!purpose) throw new Error("a script needs its purpose: one line on what it does");
|
|
149
|
+
const { shebang, rest } = splitBody(input.body);
|
|
150
|
+
if (!rest.trim()) throw new Error("the script is empty");
|
|
151
|
+
const dir = scriptsDir(input.dataRoot);
|
|
152
|
+
const path = join(dir, name);
|
|
153
|
+
const lang = language(name);
|
|
154
|
+
const usage = input.usage?.replace(/\s+/g, " ").trim() || runCommand({ path, language: lang });
|
|
155
|
+
const { comment } = SCRIPT_LANGUAGES[lang];
|
|
156
|
+
const text = [...(shebang ? [shebang] : []), `${comment} purpose: ${purpose}`, `${comment} usage: ${usage}`, "", rest.replace(/\s+$/, ""), ""].join("\n");
|
|
157
|
+
if (Buffer.byteLength(text) > MAX_SCRIPT_BYTES) throw new Error(`a script is at most ${MAX_SCRIPT_BYTES / 1024} KB`);
|
|
158
|
+
mkdirSync(dir, { recursive: true });
|
|
159
|
+
let archived: string | undefined;
|
|
160
|
+
if (existsSync(path)) {
|
|
161
|
+
if (readFileSync(path, "utf8") === text) return { entry: readEntry(dir, name)! };
|
|
162
|
+
mkdirSync(join(dir, "archive"), { recursive: true });
|
|
163
|
+
archived = join(dir, "archive", `${name}.${now.toISOString().replace(/[:.]/g, "-")}`);
|
|
164
|
+
copyFileSync(path, archived);
|
|
165
|
+
}
|
|
166
|
+
writeFileSync(path, text, { mode: 0o755 });
|
|
167
|
+
return { entry: readEntry(dir, name)!, ...(archived ? { archived } : {}) };
|
|
168
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which of the library's scripts fit a step, and how an agent's prompt shows
|
|
3
|
+
* them. Jev judges each script against the step when it is on (see
|
|
4
|
+
* `classifier/scripts.ts`); without it, or when it fails, a script fits when
|
|
5
|
+
* its name or purpose shares words with the step. Either way the prompt names
|
|
6
|
+
* the library, so an agent can look for itself.
|
|
7
|
+
*/
|
|
8
|
+
import { score, tokenize } from "../knowledge/selector.ts";
|
|
9
|
+
import { listScripts, nameWords, scriptsDir, type ScriptEntry } from "./library.ts";
|
|
10
|
+
|
|
11
|
+
/** Most scripts one prompt suggests. */
|
|
12
|
+
export const MAX_SUGGESTED = 6;
|
|
13
|
+
/** Most scripts the oracle's prompt lists. */
|
|
14
|
+
export const MAX_LISTED = 40;
|
|
15
|
+
|
|
16
|
+
/** The scripts whose name or purpose shares the most words with the step, best first. */
|
|
17
|
+
export function keywordScripts(step: string, entries: readonly ScriptEntry[], limit = MAX_SUGGESTED): ScriptEntry[] {
|
|
18
|
+
const tokens = tokenize(step);
|
|
19
|
+
return entries
|
|
20
|
+
.map((entry, index) => ({ entry, index, hits: score(`${nameWords(entry.name)} ${entry.purpose}`, tokens) }))
|
|
21
|
+
.filter((scored) => scored.hits > 0)
|
|
22
|
+
.sort((a, b) => b.hits - a.hits || a.index - b.index)
|
|
23
|
+
.slice(0, limit)
|
|
24
|
+
.map((scored) => scored.entry);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Picks the scripts that fit a step. */
|
|
28
|
+
export interface ScriptPicker {
|
|
29
|
+
pick(step: string, entries: readonly ScriptEntry[], options?: { signal?: AbortSignal }): Promise<ScriptEntry[]>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The picker that goes by shared words alone. */
|
|
33
|
+
export const keywordPicker: ScriptPicker = { pick: async (step, entries) => keywordScripts(step, entries) };
|
|
34
|
+
|
|
35
|
+
function line(entry: ScriptEntry): string {
|
|
36
|
+
return `- \`${entry.name}\` — ${entry.purpose}\n run: \`${entry.usage}\``;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The Scripts layer of a worker's, scout's or QA agent's prompt: the scripts
|
|
41
|
+
* that fit this step and how to run each, then where the rest are. "" while
|
|
42
|
+
* the library is empty.
|
|
43
|
+
*/
|
|
44
|
+
export function scriptsBlock(dataRoot: string, entries: readonly ScriptEntry[], picked: readonly ScriptEntry[]): string {
|
|
45
|
+
if (entries.length === 0) return "";
|
|
46
|
+
const where = `The shared scripts library (${entries.length} script${entries.length === 1 ? "" : "s"}) is \`${scriptsDir(dataRoot)}\`; each file's first lines say what it does and how to run it.`;
|
|
47
|
+
if (picked.length === 0) return `None of the shared scripts fits this step. ${where}`;
|
|
48
|
+
return [
|
|
49
|
+
"These shared scripts do part of this step: run one instead of doing its work by hand, and check its output as you would your own.",
|
|
50
|
+
...picked.map(line),
|
|
51
|
+
where,
|
|
52
|
+
].join("\n");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The oracle's view of the library: every script by name and purpose, so it can point a brief at one. */
|
|
56
|
+
export function libraryBlock(dataRoot: string, entries: readonly ScriptEntry[] = listScripts(dataRoot)): string {
|
|
57
|
+
const dir = scriptsDir(dataRoot);
|
|
58
|
+
if (entries.length === 0) return `The shared scripts library (\`${dir}\`) is empty.`;
|
|
59
|
+
const shown = entries.slice(0, MAX_LISTED).map((entry) => `- \`${entry.name}\` — ${entry.purpose}`);
|
|
60
|
+
const more = entries.length > MAX_LISTED ? [`…and ${entries.length - MAX_LISTED} more in \`${dir}\`.`] : [];
|
|
61
|
+
return [`Shared scripts (\`${dir}\`):`, ...shown, ...more].join("\n");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The Scripts layer for one agent's step: the library read, the picker's
|
|
66
|
+
* choice (shared words when it fails), and the block. "" with no scripts.
|
|
67
|
+
*/
|
|
68
|
+
export async function scriptsFor(dataRoot: string | undefined, step: string, picker: ScriptPicker = keywordPicker, signal?: AbortSignal): Promise<string> {
|
|
69
|
+
if (!dataRoot) return "";
|
|
70
|
+
const entries = listScripts(dataRoot);
|
|
71
|
+
if (entries.length === 0) return "";
|
|
72
|
+
let picked: ScriptEntry[];
|
|
73
|
+
try {
|
|
74
|
+
picked = await picker.pick(step, entries, signal ? { signal } : {});
|
|
75
|
+
} catch {
|
|
76
|
+
picked = keywordScripts(step, entries);
|
|
77
|
+
}
|
|
78
|
+
return scriptsBlock(dataRoot, entries, picked.slice(0, MAX_SUGGESTED));
|
|
79
|
+
}
|
package/src/state/work-time.ts
CHANGED
|
@@ -16,6 +16,8 @@ import { taskDirFor } from "./persistence.ts";
|
|
|
16
16
|
export interface ActiveRun {
|
|
17
17
|
runId: string;
|
|
18
18
|
parentRunId?: string;
|
|
19
|
+
/** The worker's domain (`backend`, `designer`), for who is doing what. */
|
|
20
|
+
domain?: string;
|
|
19
21
|
stepInstruction?: string;
|
|
20
22
|
instruction: string;
|
|
21
23
|
startedAt: string;
|
|
@@ -104,7 +106,7 @@ export function taskWork(root: string, configDir: string, taskId: string, now =
|
|
|
104
106
|
const noted = new Map<string, Set<string>>();
|
|
105
107
|
|
|
106
108
|
/** A worker run of the task started or ended: the steps it works on read as active while it runs. */
|
|
107
|
-
export function noteWorkerRun(root: string, configDir: string, taskId: string, run: { runId: string; parentRunId?: string; stepInstruction?: string; instruction?: string; startedAt: string; status: string }): void {
|
|
109
|
+
export function noteWorkerRun(root: string, configDir: string, taskId: string, run: { runId: string; parentRunId?: string; stepInstruction?: string; instruction?: string; domain?: string; startedAt: string; status: string }): void {
|
|
108
110
|
const running = run.status === "running";
|
|
109
111
|
const ids = noted.get(taskId) ?? new Set<string>();
|
|
110
112
|
if (running === ids.has(run.runId)) return;
|
|
@@ -113,7 +115,7 @@ export function noteWorkerRun(root: string, configDir: string, taskId: string, r
|
|
|
113
115
|
noted.set(taskId, ids);
|
|
114
116
|
updateWork(root, configDir, taskId, (work) => {
|
|
115
117
|
work.active = work.active.filter((entry) => entry.runId !== run.runId);
|
|
116
|
-
if (running) work.active.push({ runId: run.runId, instruction: run.instruction ?? "", startedAt: run.startedAt, ...(run.parentRunId ? { parentRunId: run.parentRunId } : {}), ...(run.stepInstruction ? { stepInstruction: run.stepInstruction } : {}) });
|
|
118
|
+
if (running) work.active.push({ runId: run.runId, instruction: run.instruction ?? "", startedAt: run.startedAt, ...(run.parentRunId ? { parentRunId: run.parentRunId } : {}), ...(run.domain ? { domain: run.domain } : {}), ...(run.stepInstruction ? { stepInstruction: run.stepInstruction } : {}) });
|
|
117
119
|
});
|
|
118
120
|
}
|
|
119
121
|
|
|
@@ -30,7 +30,7 @@ function activeRuns(task: Task, work: WorkProjection | undefined): AgentRun[] {
|
|
|
30
30
|
const finished = new Set((task.workerRuns ?? []).map((record) => record.runId));
|
|
31
31
|
return (work?.active ?? [])
|
|
32
32
|
.filter((run) => !finished.has(run.runId))
|
|
33
|
-
.map((run) => ({ runId: run.runId, parentRunId: run.parentRunId, stepInstruction: run.stepInstruction, taskId: task.id, domain: "backend" as
|
|
33
|
+
.map((run) => ({ runId: run.runId, parentRunId: run.parentRunId, stepInstruction: run.stepInstruction, taskId: task.id, domain: (run.domain === "designer" || run.domain === "qa" ? run.domain : "backend") as AgentRun["domain"], role: "worker" as const, status: "running" as const, instruction: run.instruction, output: "", attempts: 1, startedAt: run.startedAt }));
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
/** One step as the lobby shows it: its state, and while a worker is on it, how long they have worked on it. */
|
|
@@ -40,10 +40,20 @@ export interface StepView {
|
|
|
40
40
|
active: boolean;
|
|
41
41
|
activeAgentCount: number;
|
|
42
42
|
runIds: string[];
|
|
43
|
+
/** The agents on it right now: who, doing what, since when; a child names its parent. */
|
|
44
|
+
agents: StepAgent[];
|
|
43
45
|
/** Worker time on the step, in ms. */
|
|
44
46
|
workedMs: number;
|
|
45
47
|
}
|
|
46
48
|
|
|
49
|
+
export interface StepAgent {
|
|
50
|
+
runId: string;
|
|
51
|
+
parentRunId?: string;
|
|
52
|
+
domain: string;
|
|
53
|
+
instruction: string;
|
|
54
|
+
startedAt: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
47
57
|
/**
|
|
48
58
|
* The plan's steps with their state, which ones a worker is on right now and
|
|
49
59
|
* the worker time on each. A step being worked on is never done, so steps run
|
|
@@ -54,8 +64,13 @@ export function stepViews(task: Task, work: WorkProjection | undefined, now = Da
|
|
|
54
64
|
const runs = [...persistedTaskRuns(task), ...activeRuns(task, work)];
|
|
55
65
|
const checklist = planChecklist(task.plan, runs);
|
|
56
66
|
const times = stepWork(planSteps(task.plan), runs, now);
|
|
67
|
+
const byId = new Map(runs.map((run) => [run.runId, run]));
|
|
57
68
|
return checklist.map((step, index) => {
|
|
58
69
|
const time = times[index] ?? { ms: 0, active: false, activeAgentCount: 0, runIds: [] };
|
|
59
|
-
|
|
70
|
+
const agents = time.runIds.flatMap((runId) => {
|
|
71
|
+
const run = byId.get(runId);
|
|
72
|
+
return run ? [{ runId, ...(run.parentRunId ? { parentRunId: run.parentRunId } : {}), domain: run.domain, instruction: (run.instruction ?? "").split("\n")[0]!.trim(), startedAt: run.startedAt }] : [];
|
|
73
|
+
});
|
|
74
|
+
return { text: step.text, status: time.active ? "current" : step.status, active: time.active, activeAgentCount: time.activeAgentCount, runIds: time.runIds, agents, workedMs: Math.round(time.ms) };
|
|
60
75
|
});
|
|
61
76
|
}
|
package/src/webui/api/tasks.ts
CHANGED
|
@@ -311,7 +311,7 @@ export function tasksGet(body: { taskId: string }, ctx: ApiContext): TaskDetail
|
|
|
311
311
|
steps: stepViews(found, work, now).map((step) => ({
|
|
312
312
|
text: step.text,
|
|
313
313
|
status: step.status === "pending" ? "open" : step.status,
|
|
314
|
-
...(step.active ? { active: true, workedMs: step.workedMs, activeAgentCount: step.activeAgentCount } : {}),
|
|
314
|
+
...(step.active ? { active: true, workedMs: step.workedMs, activeAgentCount: step.activeAgentCount, agents: step.agents } : {}),
|
|
315
315
|
})),
|
|
316
316
|
amendments: [...found.amendments],
|
|
317
317
|
waiting: waitingOf(found),
|
package/src/webui/protocol.ts
CHANGED
|
@@ -420,7 +420,7 @@ export interface TaskDetail {
|
|
|
420
420
|
* The plan as a checklist, in order; `current` is the step under way, `active` the steps a worker is on right now,
|
|
421
421
|
* each with its worker time (ms, as of the reply).
|
|
422
422
|
*/
|
|
423
|
-
steps: Array<{ text: string; status: "done" | "current" | "open"; active?: boolean; workedMs?: number; activeAgentCount?: number }>;
|
|
423
|
+
steps: Array<{ text: string; status: "done" | "current" | "open"; active?: boolean; workedMs?: number; activeAgentCount?: number; agents?: StepAgentInfo[] }>;
|
|
424
424
|
/** How long the task's agents have worked on it, idle time left out (ms, as of the reply); `running` while they work. */
|
|
425
425
|
work?: WorkClock;
|
|
426
426
|
amendments: string[];
|
|
@@ -676,3 +676,12 @@ export interface Api {
|
|
|
676
676
|
}
|
|
677
677
|
|
|
678
678
|
export type ApiName = keyof Api;
|
|
679
|
+
|
|
680
|
+
/** An agent on a step right now: its domain, what it was told (first line), since when; a child names its parent. */
|
|
681
|
+
export interface StepAgentInfo {
|
|
682
|
+
runId: string;
|
|
683
|
+
parentRunId?: string;
|
|
684
|
+
domain: string;
|
|
685
|
+
instruction: string;
|
|
686
|
+
startedAt: string;
|
|
687
|
+
}
|