@a-t-h-i/bot-lobby 0.6.59 → 0.6.61
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/package.json +7 -7
- package/prompts/global.md +2 -1
- package/prompts/worker.md +2 -9
- package/src/classifier/files.ts +18 -6
- package/src/classifier/instance.ts +2 -2
- package/src/classifier/knowledge.ts +124 -1
- package/src/desk/board-client.ts +79 -12
- package/src/desk/board.ts +63 -52
- package/src/desk/delivery.ts +243 -0
- package/src/desk/ipc.ts +34 -1
- package/src/desk/lock.ts +81 -0
- package/src/desk/session.ts +85 -18
- package/src/execution/agent-runner.ts +10 -2
- package/src/execution/delegation.ts +160 -7
- package/src/execution/output-artifacts.ts +176 -0
- package/src/index.ts +3 -0
- package/src/knowledge/bookmarks.ts +452 -0
- package/src/knowledge/catalog-tools.ts +145 -0
- package/src/knowledge/catalogue.ts +550 -0
- package/src/knowledge/file-notes.ts +16 -6
- package/src/knowledge/repo-map.ts +67 -2
- package/src/knowledge/retrieval.ts +431 -0
- package/src/master/master.ts +132 -21
- package/src/pi/events.ts +31 -2
- package/src/pi/tools.ts +12 -1
- package/src/prompts/compiler.ts +24 -7
- package/src/schemas/configuration.ts +4 -0
- package/src/schemas/task.ts +32 -0
- package/src/state/checkpoint.ts +199 -0
- package/src/state/persistence.ts +138 -3
- package/src/workflow/workflow.ts +62 -11
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@a-t-h-i/bot-lobby",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.61",
|
|
4
4
|
"description": "Structured multi-agent software engineering orchestrator for Pi",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -47,16 +47,16 @@
|
|
|
47
47
|
"test": "node --test --test-reporter=dot test/*.test.ts"
|
|
48
48
|
},
|
|
49
49
|
"peerDependencies": {
|
|
50
|
-
"@earendil-works/pi-ai": ">=1.
|
|
51
|
-
"@earendil-works/pi-coding-agent": ">=1.
|
|
52
|
-
"@earendil-works/pi-tui": ">=1.
|
|
50
|
+
"@earendil-works/pi-ai": ">=1.4.0 <1.5.0",
|
|
51
|
+
"@earendil-works/pi-coding-agent": ">=1.4.0 <1.5.0",
|
|
52
|
+
"@earendil-works/pi-tui": ">=1.4.0 <1.5.0",
|
|
53
53
|
"typebox": "*"
|
|
54
54
|
},
|
|
55
55
|
"devDependencies": {
|
|
56
56
|
"@axe-core/playwright": "4.13.0",
|
|
57
|
-
"@earendil-works/pi-ai": "1.
|
|
58
|
-
"@earendil-works/pi-coding-agent": "1.
|
|
59
|
-
"@earendil-works/pi-tui": "1.
|
|
57
|
+
"@earendil-works/pi-ai": "1.4.0",
|
|
58
|
+
"@earendil-works/pi-coding-agent": "1.4.0",
|
|
59
|
+
"@earendil-works/pi-tui": "1.4.0",
|
|
60
60
|
"@fontsource-variable/dm-sans": "^5.3.0",
|
|
61
61
|
"@fontsource-variable/figtree": "^5.3.0",
|
|
62
62
|
"@fontsource-variable/fira-code": "^5.3.0",
|
package/prompts/global.md
CHANGED
|
@@ -38,7 +38,8 @@ are your authority, so:
|
|
|
38
38
|
- Start from what your context already gives you: the brief's paths and
|
|
39
39
|
`path:line` anchors, the **Repository map** (files matching your step, with
|
|
40
40
|
what each is for and the names it declares) and any **Likely files**. Search
|
|
41
|
-
only for what they do not answer.
|
|
41
|
+
only for what they do not answer. All of them are hints: verify what you
|
|
42
|
+
rely on.
|
|
42
43
|
- When you need several reads or searches that do not depend on each other,
|
|
43
44
|
make them all in one turn (several tool calls in one message): they run in
|
|
44
45
|
parallel and save a round trip each.
|
package/prompts/worker.md
CHANGED
|
@@ -9,10 +9,8 @@ domain-specific responsibility.
|
|
|
9
9
|
- Inspect existing patterns.
|
|
10
10
|
- Verify Scout findings against the repository.
|
|
11
11
|
- Grep/find callers before changing shared behavior.
|
|
12
|
-
-
|
|
13
|
-
|
|
14
|
-
with the `find_relevant_files` tool, describe what you need in plain words
|
|
15
|
-
before walking the tree. All of them are hints: verify what you rely on.
|
|
12
|
+
- When the `find_relevant_files` tool is available, describe what you need in
|
|
13
|
+
plain words before walking the tree.
|
|
16
14
|
- Identify relevant tests.
|
|
17
15
|
|
|
18
16
|
You may disagree with Scout findings when repository evidence contradicts
|
|
@@ -20,11 +18,6 @@ them.
|
|
|
20
18
|
|
|
21
19
|
## Implementation
|
|
22
20
|
|
|
23
|
-
- Follow the approved plan and your brief exactly: they are the Master's
|
|
24
|
-
decisions. Do not substitute your own design, rename things, or widen the
|
|
25
|
-
step. If the code contradicts the brief, do not improvise: report it.
|
|
26
|
-
- Follow domain boundaries.
|
|
27
|
-
|
|
28
21
|
If you need a new dependency, or you believe a significant architectural
|
|
29
22
|
change is required, do not make that change. Report it under the matching
|
|
30
23
|
section of your output instead and continue with the rest of the work.
|
package/src/classifier/files.ts
CHANGED
|
@@ -13,11 +13,12 @@
|
|
|
13
13
|
* indexed.
|
|
14
14
|
*/
|
|
15
15
|
import { execFile } from "node:child_process";
|
|
16
|
-
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
16
|
+
import { mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from "node:fs";
|
|
17
17
|
import { readdir, readFile, stat } from "node:fs/promises";
|
|
18
18
|
import { dirname, join } from "node:path";
|
|
19
19
|
import { promisify } from "node:util";
|
|
20
20
|
import { dataRoot } from "../state/project.ts";
|
|
21
|
+
import { tempName, withFileLock } from "../desk/lock.ts";
|
|
21
22
|
import type { Classifier } from "./classifier.ts";
|
|
22
23
|
import { noul, yesOf, type SystemOneRequest } from "./client.ts";
|
|
23
24
|
import { clip, LIMITS } from "./limits.ts";
|
|
@@ -240,7 +241,15 @@ interface CacheFile {
|
|
|
240
241
|
}
|
|
241
242
|
|
|
242
243
|
export function fileCachePath(scope: FileScope): string {
|
|
243
|
-
|
|
244
|
+
// One cache per canonical worktree (symlinks resolved): parallel worktrees share a data root but not a cache, so they stop evicting each other.
|
|
245
|
+
let slug = "default";
|
|
246
|
+
try {
|
|
247
|
+
slug = realpathSync(scope.cwd).replace(/[^A-Za-z0-9]+/g, "-").replace(/^-+/, "").slice(-48) || "default";
|
|
248
|
+
} catch {
|
|
249
|
+
// A missing tree gets no cache to thrash; the slug stays distinct from any real one.
|
|
250
|
+
slug = `missing-${scope.cwd.replace(/[^A-Za-z0-9]+/g, "-").replace(/^-+/, "").slice(-40)}` || "default";
|
|
251
|
+
}
|
|
252
|
+
return join(dataRoot(scope.root, scope.configDir), "cache", `files-${slug}.json`);
|
|
244
253
|
}
|
|
245
254
|
|
|
246
255
|
function readCache(scope: FileScope): Record<string, CacheEntry> {
|
|
@@ -255,11 +264,14 @@ function readCache(scope: FileScope): Record<string, CacheEntry> {
|
|
|
255
264
|
function writeCache(scope: FileScope, files: Record<string, CacheEntry>): void {
|
|
256
265
|
const path = fileCachePath(scope);
|
|
257
266
|
try {
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
267
|
+
withFileLock(path, () => {
|
|
268
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
269
|
+
const temp = tempName(path);
|
|
270
|
+
writeFileSync(temp, JSON.stringify({ version: 1, cwd: scope.cwd, files } satisfies CacheFile));
|
|
271
|
+
renameSync(temp, path);
|
|
272
|
+
});
|
|
261
273
|
} catch {
|
|
262
|
-
// The cache is an optimisation; a read-only tree just re-reads next time.
|
|
274
|
+
// The cache is an optimisation; a busy or read-only tree just re-reads next time.
|
|
263
275
|
}
|
|
264
276
|
}
|
|
265
277
|
|
|
@@ -14,7 +14,7 @@ import type { ScriptPicker } from "../scripts/pick.ts";
|
|
|
14
14
|
import type { KeySource, KeyStatus } from "./hosts.ts";
|
|
15
15
|
import { fileHinter, type FileHinter, type FileScope } from "./files.ts";
|
|
16
16
|
import { withRepoMap } from "../knowledge/repo-map.ts";
|
|
17
|
-
import { knowledgePicker, type KnowledgePicker } from "./knowledge.ts";
|
|
17
|
+
import { bookmarkSelector, knowledgePicker, type KnowledgePicker } from "./knowledge.ts";
|
|
18
18
|
import { lobbyFeed } from "../lobby/feed.ts";
|
|
19
19
|
import { triageLine, triageWithContext } from "./triage.ts";
|
|
20
20
|
import type { TaskTriage } from "../schemas/task.ts";
|
|
@@ -67,7 +67,7 @@ export function hintsFor(scope: FileScope): FileHinter {
|
|
|
67
67
|
/** Relevant knowledge and shared scripts for agents, with what Jev kept and matched logged to the lobby's activity feed. */
|
|
68
68
|
export function knowledgeFor(): KnowledgePicker {
|
|
69
69
|
const log = (text: string) => lobbyFeed.log("CLASSIFIER", text, "info");
|
|
70
|
-
return { ...knowledgePicker(classifier(), log), scripts: scriptPicker(classifier(), log) };
|
|
70
|
+
return { ...knowledgePicker(classifier(), log), scripts: scriptPicker(classifier(), log), bookmarks: bookmarkSelector(classifier(), log) };
|
|
71
71
|
}
|
|
72
72
|
|
|
73
73
|
/** Shared scripts for a lobby chat agent, with what Jev matched logged to the lobby's activity feed. */
|
|
@@ -12,10 +12,13 @@
|
|
|
12
12
|
* keyword selection, which is what agents got before.
|
|
13
13
|
*/
|
|
14
14
|
import { basename } from "node:path";
|
|
15
|
+
import { createHash } from "node:crypto";
|
|
15
16
|
import { score, selectRelevant, splitSections, tokenize, type KnowledgeInput, type KnowledgeSelection } from "../knowledge/selector.ts";
|
|
17
|
+
import { BOOKMARK_DEADLINE_MS, lexicalBookmarks, MAX_AUTO_BOOKMARKS, type Selection } from "../knowledge/bookmarks.ts";
|
|
18
|
+
import type { CatalogueEntry } from "../knowledge/catalogue.ts";
|
|
16
19
|
import type { Classifier } from "./classifier.ts";
|
|
17
|
-
import { noul, yesOf, type SystemOneRequest } from "./client.ts";
|
|
18
20
|
import { clip } from "./limits.ts";
|
|
21
|
+
import { noul, yesOf, type SystemOneRequest } from "./client.ts";
|
|
19
22
|
import type { ScriptPicker } from "../scripts/pick.ts";
|
|
20
23
|
|
|
21
24
|
/** How many characters of each knowledge file a prompt has room for. */
|
|
@@ -106,6 +109,8 @@ export interface KnowledgePicker {
|
|
|
106
109
|
select(task: string, files: KnowledgeInput, options?: { signal?: AbortSignal; paths?: KnowledgePaths }): Promise<KnowledgeSelection>;
|
|
107
110
|
/** Which shared scripts fit a step; shared words when absent. */
|
|
108
111
|
scripts?: ScriptPicker;
|
|
112
|
+
/** Picks assignment bookmarks from bounded catalogue metadata; absent leaves bookmarks to the lexical fallback. */
|
|
113
|
+
bookmarks?: BookmarkSelector;
|
|
109
114
|
}
|
|
110
115
|
|
|
111
116
|
const SLICES: readonly KnowledgeSlice[] = ["knowledge", "standards", "decisions"];
|
|
@@ -159,3 +164,121 @@ export function knowledgePicker(classifier: Classifier, log?: (text: string) =>
|
|
|
159
164
|
},
|
|
160
165
|
};
|
|
161
166
|
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Bookmark selection. The classifier sees bounded catalogue metadata — one
|
|
170
|
+
* short line per candidate reference, never a knowledge passage — and answers
|
|
171
|
+
* one yes/no per candidate. Candidates are the lexically best twelve, so the
|
|
172
|
+
* call is small and bounded; the whole preparation waits at most
|
|
173
|
+
* `BOOKMARK_DEADLINE_MS` and any failure, disablement or timeout falls back to
|
|
174
|
+
* the deterministic lexical selection, which never blocks work. Selections are
|
|
175
|
+
* cached by assignment input and catalogue revision, so unrelated task
|
|
176
|
+
* activity does not rebuild them.
|
|
177
|
+
*/
|
|
178
|
+
export const BOOKMARK_CANDIDATES = 12;
|
|
179
|
+
const META_LINE_CHARS = 400;
|
|
180
|
+
const CACHE_LIMIT = 64;
|
|
181
|
+
|
|
182
|
+
/** A candidate reference as the classifier sees it: id, title, kind, agent and a bounded description. */
|
|
183
|
+
export function metaLine(entry: CatalogueEntry): string {
|
|
184
|
+
const heading = entry.locator.heading ? ` > ${entry.locator.heading}` : "";
|
|
185
|
+
return clip(`${entry.id} · ${entry.title}${heading} · ${entry.kind} by ${entry.agent} · ${entry.description}`, META_LINE_CHARS);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** One request that asks about each candidate reference. */
|
|
189
|
+
export function refRequest(assignment: string, metas: readonly string[]): SectionCall {
|
|
190
|
+
const entries: Record<string, string> = {};
|
|
191
|
+
const questions: SystemOneRequest["questions"] = {};
|
|
192
|
+
const keys = new Map<string, number>();
|
|
193
|
+
metas.forEach((meta, position) => {
|
|
194
|
+
const key = `r${position + 1}`;
|
|
195
|
+
entries[key] = meta;
|
|
196
|
+
questions[key] = noul(`Should reference \`sections.${key}\` be bookmarked for an agent doing \`task\`? Yes when the knowledge, standard, decision or finding it points to bears on the code, files, conventions or choices the task touches; no when it is about something else or only shares words with it.`);
|
|
197
|
+
keys.set(key, position);
|
|
198
|
+
});
|
|
199
|
+
return { request: { state: { task: clip(assignment, 3000), sections: entries }, questions }, keys };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export interface BookmarkPick {
|
|
203
|
+
ref: string;
|
|
204
|
+
reason: string;
|
|
205
|
+
revision: number;
|
|
206
|
+
/** Who picked it: the classifier judged it, or the lexical fallback did. */
|
|
207
|
+
source: "classifier" | "lexical";
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export interface BookmarkSelectorInput {
|
|
211
|
+
assignment: string;
|
|
212
|
+
/** Non-stale catalogue entries this caller may see. */
|
|
213
|
+
entries: readonly CatalogueEntry[];
|
|
214
|
+
/** The catalogue revision the entries stand for; it forms the cache key. */
|
|
215
|
+
catalogueRevision: number;
|
|
216
|
+
/** Distinguishes parallel selections with the same assignment (task and recipient). */
|
|
217
|
+
cacheKey?: string;
|
|
218
|
+
signal?: AbortSignal;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export type BookmarkSource = BookmarkPick["source"];
|
|
222
|
+
|
|
223
|
+
export interface BookmarkSelector {
|
|
224
|
+
select(input: BookmarkSelectorInput): Promise<BookmarkPick[]>;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** The picks the classifier's answers keep: relevant candidates, best first, within the automatic cap. */
|
|
228
|
+
export function picksFromAnswers(relevance: ReadonlyMap<number, number>, candidates: readonly CatalogueEntry[], floor: number): BookmarkPick[] {
|
|
229
|
+
return [...relevance]
|
|
230
|
+
.filter(([, value]) => value >= floor)
|
|
231
|
+
.sort((a, b) => b[1] - a[1] || a[0] - b[0])
|
|
232
|
+
.slice(0, MAX_AUTO_BOOKMARKS)
|
|
233
|
+
.map(([index]) => ({ ref: candidates[index]!.id, reason: "judged relevant to this assignment", revision: candidates[index]!.revision, source: "classifier" as const }));
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function lexicalPicks(input: BookmarkSelectorInput): BookmarkPick[] {
|
|
237
|
+
const picks: Selection[] = lexicalBookmarks(input.assignment, input.entries);
|
|
238
|
+
return picks.map((pick) => ({ ref: pick.ref, reason: pick.reason, revision: pick.revision, source: "lexical" as const }));
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** The cache key: one assignment of one recipient, against one catalogue revision. */
|
|
242
|
+
function cacheKeyOf(input: BookmarkSelectorInput): string {
|
|
243
|
+
const assignment = createHash("sha1").update(input.assignment).digest("hex").slice(0, 16);
|
|
244
|
+
return `${input.cacheKey ?? ""}:${assignment}:${input.catalogueRevision}`;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const cache = new Map<string, BookmarkPick[]>();
|
|
248
|
+
|
|
249
|
+
/** Ask Jev about the lexically best candidates; anything short of an answer falls back to the lexical picks. */
|
|
250
|
+
async function classifierPicks(classifier: Classifier, input: BookmarkSelectorInput, log?: (text: string) => void): Promise<BookmarkPick[]> {
|
|
251
|
+
const ranked: Selection[] = lexicalBookmarks(input.assignment, input.entries, BOOKMARK_CANDIDATES);
|
|
252
|
+
if (ranked.length === 0) return [];
|
|
253
|
+
const byRef = new Map(ranked.map((pick) => [pick.ref, input.entries.find((entry) => entry.id === pick.ref)!]));
|
|
254
|
+
const candidates = ranked.map((pick) => byRef.get(pick.ref)!);
|
|
255
|
+
const call = refRequest(input.assignment, candidates.map(metaLine));
|
|
256
|
+
const result = await classifier.ask("knowledge", call.request, { ...(input.signal ? { signal: input.signal } : {}), timeoutMs: Math.min(classifier.config.timeoutMs, BOOKMARK_DEADLINE_MS) });
|
|
257
|
+
if (!result) return lexicalPicks(input);
|
|
258
|
+
const relevance = new Map<number, number>();
|
|
259
|
+
for (const [key, index] of call.keys) {
|
|
260
|
+
const value = yesOf(result.answers, key);
|
|
261
|
+
if (value !== undefined) relevance.set(index, value);
|
|
262
|
+
}
|
|
263
|
+
if (relevance.size === 0) return lexicalPicks(input);
|
|
264
|
+
const picks = picksFromAnswers(relevance, candidates, classifier.config.thresholds.knowledgeRelevantAt);
|
|
265
|
+
log?.(`bookmarks: kept ${picks.length} of ${candidates.length} catalogue references · ${result.ms} ms`);
|
|
266
|
+
return picks.length > 0 ? picks : lexicalPicks(input);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** A bookmark selector on Jev: metadata in, references out, deterministic fallback on any failure. */
|
|
270
|
+
export function bookmarkSelector(classifier: Classifier, log?: (text: string) => void): BookmarkSelector {
|
|
271
|
+
return {
|
|
272
|
+
async select(input) {
|
|
273
|
+
const key = cacheKeyOf(input);
|
|
274
|
+
const cached = cache.get(key);
|
|
275
|
+
if (cached) return cached;
|
|
276
|
+
const picks = !classifier.enabled("knowledge") || !input.assignment.trim()
|
|
277
|
+
? lexicalPicks(input)
|
|
278
|
+
: await classifierPicks(classifier, input, log).catch(() => lexicalPicks(input));
|
|
279
|
+
if (cache.size >= CACHE_LIMIT) cache.clear();
|
|
280
|
+
cache.set(key, picks);
|
|
281
|
+
return picks;
|
|
282
|
+
},
|
|
283
|
+
};
|
|
284
|
+
}
|
package/src/desk/board-client.ts
CHANGED
|
@@ -1,19 +1,38 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The task board inside a worker's pi process: `post_board` adds a typed post
|
|
3
3
|
* (through the desk when one runs, so peers hear about contracts and
|
|
4
|
-
* blockers at once; straight to the board's file otherwise)
|
|
5
|
-
* reads what the task's agents posted, filtered to the reader's role
|
|
4
|
+
* blockers at once; straight to the board's file otherwise), `read_board`
|
|
5
|
+
* reads what the task's agents posted, filtered to the reader's role, and
|
|
6
|
+
* `ack_board` records the events the worker actually incorporated — reading
|
|
7
|
+
* alone proves receipt, not incorporation.
|
|
6
8
|
*/
|
|
7
9
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
8
10
|
import { Type } from "typebox";
|
|
9
11
|
import { createDeskClient, type DeskClient } from "./ipc.ts";
|
|
10
|
-
import { appendPost, BOARD_ENV,
|
|
12
|
+
import { appendPost, BOARD_ENV, formatPosts, isPostKind, POST_KINDS, recentPost, readBoard, subscriptionFor, type BoardPost, type PostKind } from "./board.ts";
|
|
13
|
+
import { ackRelevant, deliveryPath, markDelivered, markHandled, markSeen, provenThrough, readDelivery } from "./delivery.ts";
|
|
11
14
|
import { deskAddress } from "./client-extension.ts";
|
|
12
15
|
|
|
13
16
|
function text(value: string) {
|
|
14
17
|
return { content: [{ type: "text" as const, text: value }], details: undefined };
|
|
15
18
|
}
|
|
16
19
|
|
|
20
|
+
/**
|
|
21
|
+
* A read proves receipt of what its window showed: the delivered cursor grows
|
|
22
|
+
* only over the posts relevant to the reader that the window included — it
|
|
23
|
+
* stops at the first one the window omitted — and shown posts beyond that are
|
|
24
|
+
* recorded out of order. Posts routed only by files wait for the desk that
|
|
25
|
+
* knows the claims. Nothing is marked handled: receipt is not incorporation.
|
|
26
|
+
*/
|
|
27
|
+
function recordRead(path: string, self: string, kinds: readonly PostKind[], shownPosts: readonly BoardPost[]): void {
|
|
28
|
+
const statePath = deliveryPath(path);
|
|
29
|
+
const prior = readDelivery(statePath).recipients[self]?.lastSeq ?? 0;
|
|
30
|
+
const shown = new Set(shownPosts.map((post) => post.seq));
|
|
31
|
+
const through = provenThrough(readBoard(path), shown, (post) => ackRelevant(post, self, kinds), prior);
|
|
32
|
+
if (through > prior) markDelivered(statePath, self, through);
|
|
33
|
+
for (const post of shownPosts) if (post.seq > through) markSeen(statePath, self, post.seq);
|
|
34
|
+
}
|
|
35
|
+
|
|
17
36
|
export function registerBoardClient(pi: ExtensionAPI, env: NodeJS.ProcessEnv = process.env, client?: DeskClient): void {
|
|
18
37
|
const path = env[BOARD_ENV];
|
|
19
38
|
if (!path) return;
|
|
@@ -27,21 +46,33 @@ export function registerBoardClient(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
|
|
|
27
46
|
name: "post_board",
|
|
28
47
|
label: "Post to the task board",
|
|
29
48
|
description:
|
|
30
|
-
"Post a short, typed entry on the task board that every agent on this task can read: `contract` (an interface others build against: a route, a payload shape, a type, an event name), `decision` (a choice others should follow), `blocker` (something stopping you that someone else must change), `question` (open, for the oracle), `done` (a piece is finished and ready to test), `note`.
|
|
49
|
+
"Post a short, typed entry on the task board that every agent on this task can read: `contract` (an interface others build against: a route, a payload shape, a type, an event name, file location), `decision` (a choice others should follow), `blocker` (something stopping you that someone else must change), `question` (open, for the oracle), `done` (a piece is finished and ready to test), `note`. Unaddressed contracts and blockers reach the other running workers at once; `to` (worker ids: backend, designer, qa, a child id) or `files` narrows who is pushed it. Post facts, not progress chatter.",
|
|
31
50
|
parameters: Type.Object({
|
|
32
51
|
kind: Type.Union(POST_KINDS.map((kind) => Type.Literal(kind))),
|
|
33
52
|
text: Type.String({ description: "The post: the exact facts, at most a short paragraph" }),
|
|
34
|
-
files: Type.Optional(Type.Array(Type.String(), { description: "Files the post is about" })),
|
|
53
|
+
files: Type.Optional(Type.Array(Type.String(), { description: "Files the post is about; pushed to the workers holding them" })),
|
|
54
|
+
to: Type.Optional(Type.Array(Type.String(), { description: "Recipients the post is addressed to: a domain (`backend`) reaches every worker of that role, an assignment id (`backend:9f2c1e`) that worker alone; pushed only to them" })),
|
|
35
55
|
}),
|
|
36
56
|
async execute(_id, params) {
|
|
57
|
+
const post = { from: self, kind: params.kind, text: params.text, ...(params.files ? { files: params.files } : {}), ...(params.to ? { to: params.to } : {}) };
|
|
37
58
|
if (desk) {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
59
|
+
let answer: { ok: boolean; text: string } | undefined;
|
|
60
|
+
try {
|
|
61
|
+
answer = await desk.request({ op: "post", from: self, kind: params.kind, text: params.text, files: params.files ?? [], targets: params.to ?? [] });
|
|
62
|
+
} catch {
|
|
63
|
+
// The request failed mid-flight; the post may already be on the board, so a retry must not duplicate it.
|
|
64
|
+
}
|
|
65
|
+
if (answer) {
|
|
66
|
+
// Only a desk without the board, or one never reached, leaves the post to the file below; any other answer may follow a write.
|
|
67
|
+
if (answer.ok || !/^This task has no board\.$|^the file desk is unavailable$/.test(answer.text)) return text(answer.text);
|
|
68
|
+
} else {
|
|
69
|
+
const landed = recentPost(path, self, params.kind, params.text);
|
|
70
|
+
if (landed) return text(`Posted #${landed.seq} (already on the board).`);
|
|
71
|
+
}
|
|
41
72
|
}
|
|
42
73
|
try {
|
|
43
|
-
const
|
|
44
|
-
return text(`Posted #${
|
|
74
|
+
const stored = appendPost(path, post);
|
|
75
|
+
return text(`Posted #${stored.seq}.`);
|
|
45
76
|
} catch (error) {
|
|
46
77
|
return text(`Not posted: ${(error as Error).message}`);
|
|
47
78
|
}
|
|
@@ -51,15 +82,51 @@ export function registerBoardClient(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
|
|
|
51
82
|
pi.registerTool({
|
|
52
83
|
name: "read_board",
|
|
53
84
|
label: "Read the task board",
|
|
54
|
-
description: "Read the task board. By default you see the kinds your role follows, newest last; pass kinds to see others, or after to see only posts newer than one you have read.",
|
|
85
|
+
description: "Read the task board. By default you see the kinds your role follows, newest last; pass kinds to see others, or after to see only posts newer than one you have read. Reading is receipt only: acknowledge what you incorporate with ack_board.",
|
|
55
86
|
parameters: Type.Object({
|
|
56
87
|
kinds: Type.Optional(Type.Array(Type.String(), { description: `Any of: ${POST_KINDS.join(", ")}` })),
|
|
57
88
|
after: Type.Optional(Type.Number({ description: "Only posts after this number" })),
|
|
58
89
|
}),
|
|
59
90
|
async execute(_id, params) {
|
|
60
91
|
const kinds: readonly PostKind[] = params.kinds?.filter(isPostKind).length ? params.kinds.filter(isPostKind) : subscriptionFor(self);
|
|
61
|
-
const
|
|
92
|
+
const matching = readBoard(path)
|
|
93
|
+
.filter((post) => kinds.includes(post.kind) && (!params.after || post.seq > params.after))
|
|
94
|
+
.sort((a, b) => a.seq - b.seq);
|
|
95
|
+
const posts = matching.slice(-50);
|
|
96
|
+
if (posts.length > 0) recordRead(path, self, kinds, posts);
|
|
62
97
|
return text(posts.length > 0 ? formatPosts(posts) : "Nothing on the board for those kinds yet.");
|
|
63
98
|
},
|
|
64
99
|
});
|
|
100
|
+
|
|
101
|
+
pi.registerTool({
|
|
102
|
+
name: "ack_board",
|
|
103
|
+
label: "Acknowledge board events",
|
|
104
|
+
description:
|
|
105
|
+
"Acknowledge the board events you have actually incorporated into your work: the numbers shown as `#3` (or the posts' ids). Receipt is not acknowledgement — reading or being steered a post does not count; only this does. Out-of-order acknowledgements are kept, and events relevant to you that you have not acknowledged yet stay pending.",
|
|
106
|
+
parameters: Type.Object({
|
|
107
|
+
events: Type.Array(Type.Union([Type.String(), Type.Number()]), { description: "The events: `#3`-style numbers, plain numbers, or post ids" }),
|
|
108
|
+
}),
|
|
109
|
+
async execute(_id, params) {
|
|
110
|
+
if (desk) {
|
|
111
|
+
let answer: { ok: boolean; text: string } | undefined;
|
|
112
|
+
try {
|
|
113
|
+
answer = await desk.request({ op: "ack", events: params.events });
|
|
114
|
+
} catch {
|
|
115
|
+
// Fall through to the file: acknowledging is idempotent, so a retry cannot double-record.
|
|
116
|
+
}
|
|
117
|
+
if (answer && (answer.ok || !/^the file desk is unavailable$|^the file desk closed$/.test(answer.text))) return text(answer.text);
|
|
118
|
+
}
|
|
119
|
+
return text(ackText(markHandled(path, self, params.events)));
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** The worker-facing result of one acknowledgement, whether anything was recorded or not. */
|
|
125
|
+
export function ackText(result: { acknowledged: { seq: number }[]; unknown: string[]; refused: string[]; state: { handled?: number[] } }): string {
|
|
126
|
+
if (result.refused.length > 0) return `Not acknowledged: ${result.refused.join(", ")} ${result.refused.length === 1 ? "is" : "are"} outside what your role follows. Nothing was recorded.`;
|
|
127
|
+
const parts = [`Acknowledged ${result.acknowledged.map((post) => `#${post.seq}`).join(", ")}.`];
|
|
128
|
+
if (result.unknown.length > 0) parts.push(`No board event matches: ${result.unknown.join(", ")}.`);
|
|
129
|
+
const pending = result.state.handled ?? [];
|
|
130
|
+
if (pending.length > 0) parts.push(`${pending.length} earlier event${pending.length === 1 ? "" : "s"} you have not acknowledged still hold${pending.length === 1 ? "s" : ""} your acknowledgement cursor.`);
|
|
131
|
+
return parts.join(" ");
|
|
65
132
|
}
|
package/src/desk/board.ts
CHANGED
|
@@ -7,10 +7,15 @@
|
|
|
7
7
|
*
|
|
8
8
|
* It lives in the task's directory as `board.jsonl`, so later steps, the QA
|
|
9
9
|
* gate and the oracle read the same record; a worker without a running desk
|
|
10
|
-
* (a single step) appends to the file directly.
|
|
10
|
+
* (a single step) appends to the file directly. A post may name recipients
|
|
11
|
+
* (`to`) or files: those route its *delivery* — an addressed post is pushed
|
|
12
|
+
* only to its targets, a file-named one to the workers holding those files —
|
|
13
|
+
* while the board itself stays readable to every role.
|
|
11
14
|
*/
|
|
12
|
-
import { appendFileSync, mkdirSync, readFileSync
|
|
15
|
+
import { appendFileSync, mkdirSync, readFileSync } from "node:fs";
|
|
13
16
|
import { dirname, join } from "node:path";
|
|
17
|
+
import { randomUUID } from "node:crypto";
|
|
18
|
+
import { LOCK_BUSY, withFileLock } from "./lock.ts";
|
|
14
19
|
|
|
15
20
|
export const POST_KINDS = ["contract", "decision", "blocker", "question", "done", "note"] as const;
|
|
16
21
|
export type PostKind = (typeof POST_KINDS)[number];
|
|
@@ -22,12 +27,16 @@ export function isPostKind(value: unknown): value is PostKind {
|
|
|
22
27
|
export interface BoardPost {
|
|
23
28
|
/** Order on the board, from 1. */
|
|
24
29
|
seq: number;
|
|
25
|
-
/**
|
|
30
|
+
/** Stable unique id minted at append; posts written before it exists have none, and stay readable. */
|
|
31
|
+
id?: string;
|
|
32
|
+
/** Who posted it: a domain (`backend`, `designer`, `qa`), an assignment id (`backend:9f2c1e`), a child run id, or `oracle`. */
|
|
26
33
|
from: string;
|
|
27
34
|
kind: PostKind;
|
|
28
35
|
text: string;
|
|
29
36
|
/** Repository files the post is about, when it names any. */
|
|
30
37
|
files?: string[];
|
|
38
|
+
/** Recipients the post is addressed to (worker ids or domains): only they are pushed it; everyone can still read it. */
|
|
39
|
+
to?: string[];
|
|
31
40
|
at: string;
|
|
32
41
|
}
|
|
33
42
|
|
|
@@ -35,7 +44,7 @@ export interface BoardPost {
|
|
|
35
44
|
export const BOARD_ENV = "BOT_LOBBY_BOARD";
|
|
36
45
|
|
|
37
46
|
/** Tools every worker gets when its task has a board. */
|
|
38
|
-
export const BOARD_TOOLS: readonly string[] = ["post_board", "read_board"];
|
|
47
|
+
export const BOARD_TOOLS: readonly string[] = ["post_board", "read_board", "ack_board"];
|
|
39
48
|
|
|
40
49
|
/**
|
|
41
50
|
* What each role reads by default. A builder needs the contracts and
|
|
@@ -51,11 +60,30 @@ export const SUBSCRIPTIONS: Readonly<Record<string, readonly PostKind[]>> = {
|
|
|
51
60
|
|
|
52
61
|
const DEFAULT_SUBSCRIPTION: readonly PostKind[] = ["contract", "decision", "blocker"];
|
|
53
62
|
|
|
54
|
-
/** Kinds a running peer is told about the moment they are posted, not only when it next reads. */
|
|
55
|
-
export const PUSHED_KINDS: ReadonlySet<PostKind> = new Set(["contract", "blocker"]);
|
|
63
|
+
/** Kinds a running peer is told about the moment they are posted, not only when it next reads: the consequential ones, never routine notes. */
|
|
64
|
+
export const PUSHED_KINDS: ReadonlySet<PostKind> = new Set(["contract", "decision", "blocker"]);
|
|
56
65
|
|
|
57
66
|
export function subscriptionFor(reader: string): readonly PostKind[] {
|
|
58
|
-
return SUBSCRIPTIONS[reader] ?? DEFAULT_SUBSCRIPTION;
|
|
67
|
+
return SUBSCRIPTIONS[reader] ?? SUBSCRIPTIONS[roleOf(reader) ?? ""] ?? DEFAULT_SUBSCRIPTION;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** A recipient named the way posts address it: DEV/design/backend all mean `backend`; anything else (a child run id, `oracle`) is itself. */
|
|
71
|
+
export function recipientId(name: string): string {
|
|
72
|
+
const key = name.trim().toLowerCase();
|
|
73
|
+
const ids: Record<string, string> = { dev: "backend", backend: "backend", design: "designer", designer: "designer", qa: "qa", oracle: "oracle" };
|
|
74
|
+
return ids[key] ?? name.trim();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The role a recipient answers to: `backend`, `backend:9f2c1e` and `DEV` are backend; an unrelated id (a bare child run id) has none. */
|
|
78
|
+
export function roleOf(name: string): string | undefined {
|
|
79
|
+
const [head = ""] = name.trim().split(":", 1);
|
|
80
|
+
const role = recipientId(head);
|
|
81
|
+
return SUBSCRIPTIONS[role] ? role : undefined;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** One assignment's board identity: unique per worker run (`backend:9f2c1e`), so two same-role workers never share delivery state while posts addressed to their role still reach both. */
|
|
85
|
+
export function assignmentId(domain: string): string {
|
|
86
|
+
return `${recipientId(domain)}:${randomUUID().slice(0, 8)}`;
|
|
59
87
|
}
|
|
60
88
|
|
|
61
89
|
export const MAX_POST_CHARS = 1500;
|
|
@@ -102,61 +130,41 @@ export interface NewPost {
|
|
|
102
130
|
kind: PostKind;
|
|
103
131
|
text: string;
|
|
104
132
|
files?: readonly string[];
|
|
133
|
+
/** Recipients the post is addressed to; an addressed post is pushed only to them. */
|
|
134
|
+
to?: readonly string[];
|
|
105
135
|
}
|
|
106
136
|
|
|
107
137
|
/** Longest a board path in a post may be. */
|
|
108
138
|
const MAX_FILE_CHARS = 300;
|
|
109
|
-
/** How long a writer waits for another process's append, and when a lock is taken to be left behind. */
|
|
110
|
-
const LOCK_WAIT_MS = 2000;
|
|
111
|
-
const STALE_LOCK_MS = 10_000;
|
|
112
139
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
}
|
|
140
|
+
/** Most recipients one post may name. */
|
|
141
|
+
const MAX_TARGETS = 8;
|
|
116
142
|
|
|
117
|
-
/**
|
|
118
|
-
* Run `write` holding the board's lock (a directory beside it, made
|
|
119
|
-
* atomically), so posts from several processes get distinct sequence
|
|
120
|
-
* numbers. A lock left by a crashed writer is broken once stale; past the
|
|
121
|
-
* wait the post goes in anyway rather than being lost.
|
|
122
|
-
*/
|
|
123
|
-
function withLock<T>(path: string, write: () => T): T {
|
|
124
|
-
const lock = `${path}.lock`;
|
|
125
|
-
const until = Date.now() + LOCK_WAIT_MS;
|
|
126
|
-
let held = false;
|
|
127
|
-
while (!held) {
|
|
128
|
-
try {
|
|
129
|
-
mkdirSync(lock);
|
|
130
|
-
held = true;
|
|
131
|
-
} catch {
|
|
132
|
-
try {
|
|
133
|
-
if (Date.now() - statSync(lock).mtimeMs > STALE_LOCK_MS) rmSync(lock, { recursive: true, force: true });
|
|
134
|
-
} catch {
|
|
135
|
-
// Released meanwhile.
|
|
136
|
-
}
|
|
137
|
-
if (Date.now() > until) break;
|
|
138
|
-
sleep(5);
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
try {
|
|
142
|
-
return write();
|
|
143
|
-
} finally {
|
|
144
|
-
if (held) rmSync(lock, { recursive: true, force: true });
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
/** Append a post and return it as stored. Throws on an empty text, so the caller can tell the agent. */
|
|
143
|
+
/** Append a post and return it as stored. Throws on an empty text or a busy board, so the caller can tell the agent. */
|
|
149
144
|
export function appendPost(path: string, post: NewPost, now = new Date()): BoardPost {
|
|
150
145
|
const text = clean(post.text, MAX_POST_CHARS);
|
|
151
146
|
if (!text) throw new Error("a board post needs text");
|
|
152
147
|
const files = (post.files ?? []).map((file) => clean(file, MAX_FILE_CHARS)).filter(Boolean).slice(0, MAX_FILES);
|
|
148
|
+
const to = [...new Set((post.to ?? []).map((target) => clean(target, 80)).filter(Boolean))].slice(0, MAX_TARGETS);
|
|
153
149
|
mkdirSync(dirname(path), { recursive: true });
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
150
|
+
try {
|
|
151
|
+
return withFileLock(path, () => {
|
|
152
|
+
const seq = (readBoard(path).at(-1)?.seq ?? 0) + 1;
|
|
153
|
+
const stored: BoardPost = { seq, id: randomUUID(), from: post.from, kind: post.kind, text, ...(files.length > 0 ? { files } : {}), ...(to.length > 0 ? { to } : {}), at: now.toISOString() };
|
|
154
|
+
appendFileSync(path, `${JSON.stringify(stored)}\n`, "utf8");
|
|
155
|
+
return stored;
|
|
156
|
+
});
|
|
157
|
+
} catch (error) {
|
|
158
|
+
throw new Error((error as Error).message === LOCK_BUSY ? `Not posted: ${LOCK_BUSY}` : (error as Error).message);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** The newest post already on the board with this sender, kind and exact text, if one exists: a retry can adopt it instead of duplicating it. */
|
|
163
|
+
export function recentPost(path: string, from: string, kind: PostKind, text: string): BoardPost | undefined {
|
|
164
|
+
const wanted = clean(text, MAX_POST_CHARS);
|
|
165
|
+
return readBoard(path)
|
|
166
|
+
.filter((post) => post.from === from && post.kind === kind && post.text === wanted)
|
|
167
|
+
.at(-1);
|
|
160
168
|
}
|
|
161
169
|
|
|
162
170
|
export interface BoardFilter {
|
|
@@ -179,13 +187,16 @@ export function filterPosts(posts: readonly BoardPost[], filter: BoardFilter): B
|
|
|
179
187
|
const NAMES: Record<string, string> = { backend: "DEV", designer: "DESIGN", qa: "QA", oracle: "ORACLE" };
|
|
180
188
|
|
|
181
189
|
export function posterName(from: string): string {
|
|
190
|
+
const role = roleOf(from);
|
|
191
|
+
if (role && from !== role) return `${NAMES[role] ?? role.toUpperCase()}(${from.slice(role.length + 1)})`;
|
|
182
192
|
return NAMES[from] ?? (from.length > 12 ? `child ${from.slice(0, 8)}` : from.toUpperCase());
|
|
183
193
|
}
|
|
184
194
|
|
|
185
195
|
/** One post on one line: its text is flattened, so a post can never pass for a line of the engine's own. */
|
|
186
196
|
export function formatPost(post: BoardPost): string {
|
|
187
197
|
const files = post.files?.length ? ` (${post.files.join(", ")})` : "";
|
|
188
|
-
|
|
198
|
+
const to = post.to?.length ? ` → ${post.to.map(posterName).join(", ")}` : "";
|
|
199
|
+
return `#${post.seq} [${post.kind}] ${posterName(post.from)}${to}${files}: ${post.text.replace(/\s+/g, " ")}`;
|
|
189
200
|
}
|
|
190
201
|
|
|
191
202
|
export function formatPosts(posts: readonly BoardPost[]): string {
|