@a-t-h-i/bot-lobby 0.6.58 → 0.6.60
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/THIRD_PARTY_NOTICES.md +16 -3
- package/package.json +12 -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/lobby/feed.ts +10 -0
- package/src/lobby/keys.ts +1 -0
- package/src/lobby/runtime.ts +8 -0
- package/src/master/master.ts +132 -21
- package/src/pi/events.ts +31 -2
- package/src/pi/owner.ts +35 -4
- 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/webui/api/index.ts +12 -0
- package/src/webui/code.ts +202 -0
- package/src/webui/dev/fake-service.ts +16 -2
- package/src/webui/protocol.ts +10 -0
- package/src/webui/static.ts +5 -2
- package/src/workflow/workflow.ts +62 -11
- package/webui/dist/assets/CodeSurface-BtzJOVyH.js +448 -0
- package/webui/dist/assets/{TerminalView-D1GcmM8g.js → TerminalView-Cz-KNWsq.js} +2 -2
- package/webui/dist/assets/astro-p42oEArs.js +1 -0
- package/webui/dist/assets/c-DaO8xguW.js +1 -0
- package/webui/dist/assets/ccount-DQxSH8e6.js +1 -0
- package/webui/dist/assets/csharp-dmMxLVLW.js +1 -0
- package/webui/dist/assets/css-C8fQr_0Q.js +1 -0
- package/webui/dist/assets/diff-woXpYk--.js +1 -0
- package/webui/dist/assets/dockerfile-IyjqRm3v.js +1 -0
- package/webui/dist/assets/dotenv-CuH5my32.js +1 -0
- package/webui/dist/assets/fish-BJitypiv.js +1 -0
- package/webui/dist/assets/go-rLFTqkRN.js +1 -0
- package/webui/dist/assets/graphql-AVu4aKQX.js +1 -0
- package/webui/dist/assets/html-wSbaAAK3.js +1 -0
- package/webui/dist/assets/index-D5fvY8f8.js +112 -0
- package/webui/dist/assets/index-DFvk8ADr.css +2 -0
- package/webui/dist/assets/ini-B5eOa1yu.js +1 -0
- package/webui/dist/assets/java-DdHNQ9hk.js +1 -0
- package/webui/dist/assets/javascript-BgS3c2Ky.js +1 -0
- package/webui/dist/assets/json-qhed-kSA.js +1 -0
- package/webui/dist/assets/json5-BR5RXkoi.js +1 -0
- package/webui/dist/assets/jsonc-CYpm1nAK.js +1 -0
- package/webui/dist/assets/jsonl-CmCQp5Yx.js +1 -0
- package/webui/dist/assets/jsx-gTgmArGx.js +1 -0
- package/webui/dist/assets/kotlin-DhhofPvG.js +1 -0
- package/webui/dist/assets/kts-DFAlwc6n.js +1 -0
- package/webui/dist/assets/less-DVTAwKKz.js +1 -0
- package/webui/dist/assets/lua-xZcbXUE9.js +1 -0
- package/webui/dist/assets/makefile-Dixweg8N.js +1 -0
- package/webui/dist/assets/markdown-BYOwaDjH.js +1 -0
- package/webui/dist/assets/php-C6PDnbTe.js +1 -0
- package/webui/dist/assets/pierre-dark-CpLgRqie.js +1 -0
- package/webui/dist/assets/pierre-dark-protanopia-deuteranopia-B35FxJx-.js +1 -0
- package/webui/dist/assets/pierre-dark-soft-kZQmAZld.js +1 -0
- package/webui/dist/assets/pierre-dark-tritanopia-CpjhbsIL.js +1 -0
- package/webui/dist/assets/pierre-dark-vibrant-CpQYzh95.js +1 -0
- package/webui/dist/assets/pierre-light-CoaEpmwp.js +1 -0
- package/webui/dist/assets/pierre-light-protanopia-deuteranopia-0fSaH845.js +1 -0
- package/webui/dist/assets/pierre-light-soft-lWLdNTOI.js +1 -0
- package/webui/dist/assets/pierre-light-tritanopia-CEbqgOJL.js +1 -0
- package/webui/dist/assets/pierre-light-vibrant-D80Fkn33.js +1 -0
- package/webui/dist/assets/postcss-D_Wz94rJ.js +1 -0
- package/webui/dist/assets/powershell-QtSnmXcu.js +1 -0
- package/webui/dist/assets/prisma-DMgNhnYS.js +1 -0
- package/webui/dist/assets/properties-CxAuzHS9.js +1 -0
- package/webui/dist/assets/python-gzcpVVnB.js +1 -0
- package/webui/dist/assets/rolldown-runtime-hePW80VL.js +1 -0
- package/webui/dist/assets/ruby-CPL7DL0w.js +1 -0
- package/webui/dist/assets/rust-DpiIf5JO.js +1 -0
- package/webui/dist/assets/sass-DXrisJhu.js +1 -0
- package/webui/dist/assets/scss-CHVQqdPx.js +1 -0
- package/webui/dist/assets/shellscript-CLZ0U2zV.js +1 -0
- package/webui/dist/assets/sql-CEbI0hMM.js +1 -0
- package/webui/dist/assets/svelte-DKhxH1PN.js +1 -0
- package/webui/dist/assets/swift-CyEgAFGc.js +1 -0
- package/webui/dist/assets/toml-CcmNWLt0.js +1 -0
- package/webui/dist/assets/tsx-CUOciP0b.js +1 -0
- package/webui/dist/assets/typescript-BydbNFcO.js +1 -0
- package/webui/dist/assets/vue-kBwcnTZb.js +1 -0
- package/webui/dist/assets/wasm-BnjxR4X6.js +1 -0
- package/webui/dist/assets/xml-CeXJrFlG.js +1 -0
- package/webui/dist/assets/yaml-rwi0_p6S.js +1 -0
- package/webui/dist/assets/yml-s9iVJXCw.js +1 -0
- package/webui/dist/assets/zsh-B7CDPTg5.js +1 -0
- package/webui/dist/build.json +44 -16
- package/webui/dist/index.html +4 -2
- package/webui/dist/assets/index-Dnz2q4t0.js +0 -112
- package/webui/dist/assets/index-nzFYIE8J.css +0 -2
package/THIRD_PARTY_NOTICES.md
CHANGED
|
@@ -4,12 +4,13 @@
|
|
|
4
4
|
|
|
5
5
|
The web UI's look (colours, radii, shadows, control and dialog styles, the
|
|
6
6
|
sidebar rail, the chat rows, the work log, the composer (its surface, footer controls and send button), the select
|
|
7
|
-
fields and drop-down lists, the loading and status animations
|
|
8
|
-
built-in colour themes), and the third-party harness support (the model
|
|
7
|
+
fields and drop-down lists, the loading and status animations, the code
|
|
8
|
+
viewer with its diff mode and the built-in colour themes), and the third-party harness support (the model
|
|
9
9
|
manifest, CLI flags, prompt injection and ACP handling behind Settings >
|
|
10
10
|
Harnesses), is adapted from T3 Code,
|
|
11
11
|
https://github.com/pingdotgg/t3code. No T3 Code package is a
|
|
12
|
-
dependency
|
|
12
|
+
dependency (the code viewer uses the same diff and highlighting library T3
|
|
13
|
+
Code does, `@pierre/diffs`, as an ordinary npm dependency); the styles and markup below were ported into this repository and
|
|
13
14
|
rewired to bot-lobby's own data layer. Each adapted file starts with a comment
|
|
14
15
|
naming the file it came from.
|
|
15
16
|
|
|
@@ -38,6 +39,9 @@ naming the file it came from.
|
|
|
38
39
|
| `webui/src/app/Composer.tsx` | `apps/web/src/components/chat/ComposerSurface.tsx`, `ChatComposer.tsx`, `ComposerPrimaryActions.tsx`, `ComposerControl.tsx` |
|
|
39
40
|
| `webui/src/app/composer/ComposerCommandMenu.tsx` | `apps/web/src/components/chat/ComposerCommandMenu.tsx`, `ComposerBanner.tsx` (the attached drawer surface), `composer-logic.ts` (the `/` and `@` trigger) |
|
|
40
41
|
| `webui/src/app/TerminalDrawer.tsx` | `apps/web/src/components/ThreadTerminalDrawer.tsx` (the drawer, its floating toolbar and terminal list) |
|
|
42
|
+
| `webui/src/app/code/CodeViewer.tsx` | `apps/web/src/components/DiffPanel.tsx`, `DiffPanelShell.tsx` (diff layout and wrap toggles), `components/diffs/DiffFileTree.tsx` (changed files list), `components/files/FilePreviewPanel.tsx` (file header) |
|
|
43
|
+
| `webui/src/app/code/CodeSurface.tsx` | `apps/web/src/components/files/FilePreviewPanel.tsx` (`EditableFileSurface`), `components/files/ReadOnlySourcePreview.tsx`, `components/DiffPanel.tsx` |
|
|
44
|
+
| `webui/src/app/code/codeRendering.ts` | `apps/web/src/lib/diffRendering.ts` (`DIFF_SURFACE_THEME_UNSAFE_CSS`, `resolveDiffThemeName`), `lib/syntaxHighlighting.ts` (`PREFERRED_HIGHLIGHTER`), `components/diffs/StyledDiffCodeView.tsx`, `components/files/fileSurfaceChrome.tsx` |
|
|
41
45
|
| `webui/src/tabs/lobby/InlineActivity.tsx` | `apps/web/src/components/chat/WorkLog.tsx` |
|
|
42
46
|
| `src/webui/palette-core.ts` (the built-in themes) | `packages/shared/src/themePalettes.ts` (Blossom is their T3 Chat theme, renamed) |
|
|
43
47
|
| `src/harness/model-manifest.json` (copied as is) | `apps/server/src/provider/model-manifest.json` |
|
|
@@ -88,3 +92,12 @@ Jakarta Sans Project Authors), Figtree (The Figtree Project Authors), Outfit
|
|
|
88
92
|
Grotesk (The Space Grotesk Project Authors), Source Sans 3 and Source Code Pro
|
|
89
93
|
(Adobe), Fira Code (The Fira Code Project Authors) and Roboto Mono (The Roboto
|
|
90
94
|
Mono Project Authors).
|
|
95
|
+
|
|
96
|
+
## Code viewer libraries
|
|
97
|
+
|
|
98
|
+
The code viewer's chunks in `webui/dist/assets` bundle `@pierre/diffs`,
|
|
99
|
+
`@pierre/theming` and `@pierre/theme` (The Pierre Computer Company, Apache
|
|
100
|
+
License 2.0, https://www.apache.org/licenses/LICENSE-2.0), Shiki and its
|
|
101
|
+
grammars (`shiki`, `@shikijs/*`, Pine Wu and Anthony Fu, MIT; each grammar
|
|
102
|
+
keeps its own licence, listed in `@shikijs/langs`) and `diff` (Kevin Decker,
|
|
103
|
+
BSD 3-Clause).
|
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.60",
|
|
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",
|
|
@@ -73,7 +73,12 @@
|
|
|
73
73
|
"@fontsource-variable/source-code-pro": "^5.3.1",
|
|
74
74
|
"@fontsource-variable/source-sans-3": "^5.3.1",
|
|
75
75
|
"@fontsource-variable/space-grotesk": "^5.3.0",
|
|
76
|
+
"@pierre/diffs": "^1.5.2",
|
|
76
77
|
"@playwright/test": "1.62.0",
|
|
78
|
+
"@shikijs/core": "^4.5.0",
|
|
79
|
+
"@shikijs/engine-javascript": "^4.5.0",
|
|
80
|
+
"@shikijs/engine-oniguruma": "^4.5.0",
|
|
81
|
+
"@shikijs/langs": "^4.5.0",
|
|
77
82
|
"@tailwindcss/vite": "4.3.3",
|
|
78
83
|
"@types/node": "^22.10.0",
|
|
79
84
|
"@types/react": "19.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
|
}
|