@a-t-h-i/bot-lobby 0.6.37 → 0.6.38

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.
@@ -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
+ }
@@ -1,5 +1,5 @@
1
- import { existsSync, mkdirSync, realpathSync } from "node:fs";
2
- import { isAbsolute, join, relative } from "node:path";
1
+ import { existsSync, mkdirSync, readFileSync, realpathSync } from "node:fs";
2
+ import { isAbsolute, join, relative, resolve, sep } from "node:path";
3
3
  import { profileFor, type BotLobbyConfig, type ProfileResolver } from "../schemas/configuration.ts";
4
4
  import type { AgentRun, Pushback, ResearchResult, ReviewResult, WorkerResult } from "../schemas/findings.ts";
5
5
  import {
@@ -24,6 +24,7 @@ import { ExcalidrawBook } from "../excalidraw/sessions.ts";
24
24
  import { knowledgeDir, type KnowledgeAgent } from "../knowledge/paths.ts";
25
25
  import { lobbyTopics } from "../lobby/topics.ts";
26
26
  import { writeScratchpad } from "../state/persistence.ts";
27
+ import { saveScript } from "../scripts/library.ts";
27
28
  import { spawnPiProcess, type ProcessRunner, type RelayAsk } from "../execution/pi-runner.ts";
28
29
  import { previewDir } from "../ask/relay.ts";
29
30
  import type { AskQuestion, AskResult } from "../ask/types.ts";
@@ -90,6 +91,7 @@ export const ORCHESTRATE_ACTIONS = [
90
91
  "qa",
91
92
  "knowledge",
92
93
  "compact",
94
+ "script",
93
95
  "whiteboard",
94
96
  "complete",
95
97
  "block",
@@ -126,10 +128,14 @@ export interface OrchestrateParams {
126
128
  minutes?: number;
127
129
  /** knowledge: which persistent file the text belongs to. */
128
130
  kind?: KnowledgeKind;
129
- /** compact: the knowledge file being rewritten. */
131
+ /** compact: the knowledge file being rewritten; script: a drafted script to take instead of `text`. */
130
132
  file?: string;
131
- /** whiteboard: name for the new Excalidraw session. */
133
+ /** whiteboard: name for the new Excalidraw session; script: the script's file name (`seed_public_holidays.py`). */
132
134
  name?: string;
135
+ /** script: one line on what the script does. */
136
+ purpose?: string;
137
+ /** script: how to run it, with its arguments. */
138
+ usage?: string;
133
139
  approvalId?: string;
134
140
  decision?: "approved" | "rejected";
135
141
  note?: string;
@@ -635,6 +641,9 @@ function workerReport(outcome: WorkerOutcome, approvals: Approval[], pushback?:
635
641
  result.knowledgeProposals.length > 0
636
642
  ? `Knowledge proposals (accept with action=knowledge, or ignore): ${result.knowledgeProposals.map((proposal) => `${proposal.kind}: ${truncate(proposal.content, 160)}`).join("; ")}`
637
643
  : "",
644
+ result.scriptProposals?.length
645
+ ? `Script proposals (save with action=script name purpose usage file=<draft>, when the job will come back; or ignore): ${result.scriptProposals.map((proposal) => `${proposal.name}${proposal.purpose ? ` — ${truncate(proposal.purpose, 120)}` : ""}${proposal.draft ? ` (draft: ${proposal.draft})` : ""}`).join("; ")}`
646
+ : "",
638
647
  issues.length > 0 ? `Issues: ${issues.join("; ")}` : "",
639
648
  pushback && objection
640
649
  ? `Pushback recorded (${pushback.id}): ${truncate(objection.reason, 240)}. Resolve with action=resolve_approval before re-delegating ${result.domain}.`
@@ -1481,6 +1490,34 @@ function handleKnowledge(task: Task | undefined, params: OrchestrateParams, deps
1481
1490
  return `Recorded ${kind} for ${agent} in ${outcome.path}.`;
1482
1491
  }
1483
1492
 
1493
+ /**
1494
+ * The oracle adds a script to the shared library, or replaces one, so every
1495
+ * agent can run it the next time the job comes up. Its body is `text`, or a
1496
+ * file a worker drafted (`file`, inside the project or the task's directory).
1497
+ */
1498
+ function handleScript(task: Task | undefined, params: OrchestrateParams, deps: WorkflowDeps): string {
1499
+ const name = params.name?.trim();
1500
+ if (!name) throw new Error("script requires name: what it does in snake_case, with its extension (seed_public_holidays.py)");
1501
+ const purpose = params.purpose?.trim();
1502
+ if (!purpose) throw new Error("script requires purpose: one line on what it does");
1503
+ const body = params.file?.trim() ? readDraft(params.file.trim(), task, deps) : params.text;
1504
+ if (!body?.trim()) throw new Error("script requires text (the script) or file (a drafted script)");
1505
+ const saved = saveScript({ dataRoot: dataRoot(deps.root, deps.configDir), name, purpose, body, ...(params.usage?.trim() ? { usage: params.usage.trim() } : {}) });
1506
+ const { entry } = saved;
1507
+ if (task) recordDecision(task, `${saved.archived ? "Replaced" : "Saved"} the shared script ${entry.name}: ${truncate(entry.purpose, 160)}`);
1508
+ lobbyTopics.bump("knowledge");
1509
+ return [`${saved.archived ? "Replaced" : "Saved"} ${entry.name} in the shared scripts library (${entry.path}); run: ${entry.usage}.`, saved.archived ? `The previous version is kept at ${saved.archived}.` : "", "Agents whose steps it fits are told to run it."].filter(Boolean).join(" ");
1510
+ }
1511
+
1512
+ /** A drafted script, read only from inside the project, its worktree or the task's directory. */
1513
+ function readDraft(file: string, task: Task | undefined, deps: WorkflowDeps): string {
1514
+ const roots = [deps.cwd, deps.root, ...(task ? [taskDirFor(deps.root, deps.configDir, task.id)] : [])].map((dir) => resolve(dir));
1515
+ const path = isAbsolute(file) ? resolve(file) : resolve(deps.cwd, file);
1516
+ if (!roots.some((root) => path === root || path.startsWith(`${root}${sep}`))) throw new Error(`the drafted script must be inside the project: ${file}`);
1517
+ if (!existsSync(path)) throw new Error(`no drafted script at ${file}`);
1518
+ return readFileSync(path, "utf8");
1519
+ }
1520
+
1484
1521
  /**
1485
1522
  * §24: the Master rewrites the file (asking the user about ambiguity), the
1486
1523
  * engine archives the previous version and writes the compacted text.
@@ -1990,6 +2027,7 @@ const HANDLERS: Record<OrchestrateAction, (task: Task, params: OrchestrateParams
1990
2027
  resolve_approval: handleResolveApproval,
1991
2028
  qa: handleQa,
1992
2029
  knowledge: handleKnowledge,
2030
+ script: handleScript,
1993
2031
  compact: handleCompact,
1994
2032
  whiteboard: handleWhiteboard,
1995
2033
  complete: handleComplete,
@@ -2008,9 +2046,9 @@ const HANDLERS: Record<OrchestrateAction, (task: Task, params: OrchestrateParams
2008
2046
  */
2009
2047
  export async function runWorkflowAction(params: OrchestrateParams, deps: WorkflowDeps): Promise<WorkflowResult> {
2010
2048
  // Project knowledge can be learned without starting or changing a task.
2011
- if ((params.action === "knowledge" || params.action === "compact") && !params.taskId) {
2049
+ if ((params.action === "knowledge" || params.action === "compact" || params.action === "script") && !params.taskId) {
2012
2050
  try {
2013
- const message = params.action === "knowledge" ? handleKnowledge(undefined, params, deps) : handleCompact(undefined, params, deps);
2051
+ const message = params.action === "knowledge" ? handleKnowledge(undefined, params, deps) : params.action === "script" ? handleScript(undefined, params, deps) : handleCompact(undefined, params, deps);
2014
2052
  return { ok: true, taskId: "", state: "created", message };
2015
2053
  } catch (error) {
2016
2054
  return { ok: false, taskId: "", state: "created", message: `Rejected: ${(error as Error).message}` };
@@ -2050,6 +2088,11 @@ export async function runWorkflowAction(params: OrchestrateParams, deps: Workflo
2050
2088
  ...(deps.askQuestions ? { askQuestions: (...args: Parameters<NonNullable<WorkflowDeps["askQuestions"]>>) => wait(() => deps.askQuestions!(...args)) } : {}),
2051
2089
  // Every agent, diff and desk of a task with its own worktree works there.
2052
2090
  cwd: taskCwd(task, deps.cwd),
2091
+ // An agent can work for an hour: what the action has done so far (implementing, reviewing) is saved before it starts, so every window reads it.
2092
+ runProcess: (args, options) => {
2093
+ saveTask(deps.root, deps.configDir, task);
2094
+ return (deps.runProcess ?? spawnPiProcess)(args, options);
2095
+ },
2053
2096
  onUpdate: (run) => {
2054
2097
  if (run.status !== "running") {
2055
2098
  finished.set(run.runId, run);