@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.
Files changed (109) hide show
  1. package/THIRD_PARTY_NOTICES.md +16 -3
  2. package/package.json +12 -7
  3. package/prompts/global.md +2 -1
  4. package/prompts/worker.md +2 -9
  5. package/src/classifier/files.ts +18 -6
  6. package/src/classifier/instance.ts +2 -2
  7. package/src/classifier/knowledge.ts +124 -1
  8. package/src/desk/board-client.ts +79 -12
  9. package/src/desk/board.ts +63 -52
  10. package/src/desk/delivery.ts +243 -0
  11. package/src/desk/ipc.ts +34 -1
  12. package/src/desk/lock.ts +81 -0
  13. package/src/desk/session.ts +85 -18
  14. package/src/execution/agent-runner.ts +10 -2
  15. package/src/execution/delegation.ts +160 -7
  16. package/src/execution/output-artifacts.ts +176 -0
  17. package/src/index.ts +3 -0
  18. package/src/knowledge/bookmarks.ts +452 -0
  19. package/src/knowledge/catalog-tools.ts +145 -0
  20. package/src/knowledge/catalogue.ts +550 -0
  21. package/src/knowledge/file-notes.ts +16 -6
  22. package/src/knowledge/repo-map.ts +67 -2
  23. package/src/knowledge/retrieval.ts +431 -0
  24. package/src/lobby/feed.ts +10 -0
  25. package/src/lobby/keys.ts +1 -0
  26. package/src/lobby/runtime.ts +8 -0
  27. package/src/master/master.ts +132 -21
  28. package/src/pi/events.ts +31 -2
  29. package/src/pi/owner.ts +35 -4
  30. package/src/pi/tools.ts +12 -1
  31. package/src/prompts/compiler.ts +24 -7
  32. package/src/schemas/configuration.ts +4 -0
  33. package/src/schemas/task.ts +32 -0
  34. package/src/state/checkpoint.ts +199 -0
  35. package/src/state/persistence.ts +138 -3
  36. package/src/webui/api/index.ts +12 -0
  37. package/src/webui/code.ts +202 -0
  38. package/src/webui/dev/fake-service.ts +16 -2
  39. package/src/webui/protocol.ts +10 -0
  40. package/src/webui/static.ts +5 -2
  41. package/src/workflow/workflow.ts +62 -11
  42. package/webui/dist/assets/CodeSurface-BtzJOVyH.js +448 -0
  43. package/webui/dist/assets/{TerminalView-D1GcmM8g.js → TerminalView-Cz-KNWsq.js} +2 -2
  44. package/webui/dist/assets/astro-p42oEArs.js +1 -0
  45. package/webui/dist/assets/c-DaO8xguW.js +1 -0
  46. package/webui/dist/assets/ccount-DQxSH8e6.js +1 -0
  47. package/webui/dist/assets/csharp-dmMxLVLW.js +1 -0
  48. package/webui/dist/assets/css-C8fQr_0Q.js +1 -0
  49. package/webui/dist/assets/diff-woXpYk--.js +1 -0
  50. package/webui/dist/assets/dockerfile-IyjqRm3v.js +1 -0
  51. package/webui/dist/assets/dotenv-CuH5my32.js +1 -0
  52. package/webui/dist/assets/fish-BJitypiv.js +1 -0
  53. package/webui/dist/assets/go-rLFTqkRN.js +1 -0
  54. package/webui/dist/assets/graphql-AVu4aKQX.js +1 -0
  55. package/webui/dist/assets/html-wSbaAAK3.js +1 -0
  56. package/webui/dist/assets/index-D5fvY8f8.js +112 -0
  57. package/webui/dist/assets/index-DFvk8ADr.css +2 -0
  58. package/webui/dist/assets/ini-B5eOa1yu.js +1 -0
  59. package/webui/dist/assets/java-DdHNQ9hk.js +1 -0
  60. package/webui/dist/assets/javascript-BgS3c2Ky.js +1 -0
  61. package/webui/dist/assets/json-qhed-kSA.js +1 -0
  62. package/webui/dist/assets/json5-BR5RXkoi.js +1 -0
  63. package/webui/dist/assets/jsonc-CYpm1nAK.js +1 -0
  64. package/webui/dist/assets/jsonl-CmCQp5Yx.js +1 -0
  65. package/webui/dist/assets/jsx-gTgmArGx.js +1 -0
  66. package/webui/dist/assets/kotlin-DhhofPvG.js +1 -0
  67. package/webui/dist/assets/kts-DFAlwc6n.js +1 -0
  68. package/webui/dist/assets/less-DVTAwKKz.js +1 -0
  69. package/webui/dist/assets/lua-xZcbXUE9.js +1 -0
  70. package/webui/dist/assets/makefile-Dixweg8N.js +1 -0
  71. package/webui/dist/assets/markdown-BYOwaDjH.js +1 -0
  72. package/webui/dist/assets/php-C6PDnbTe.js +1 -0
  73. package/webui/dist/assets/pierre-dark-CpLgRqie.js +1 -0
  74. package/webui/dist/assets/pierre-dark-protanopia-deuteranopia-B35FxJx-.js +1 -0
  75. package/webui/dist/assets/pierre-dark-soft-kZQmAZld.js +1 -0
  76. package/webui/dist/assets/pierre-dark-tritanopia-CpjhbsIL.js +1 -0
  77. package/webui/dist/assets/pierre-dark-vibrant-CpQYzh95.js +1 -0
  78. package/webui/dist/assets/pierre-light-CoaEpmwp.js +1 -0
  79. package/webui/dist/assets/pierre-light-protanopia-deuteranopia-0fSaH845.js +1 -0
  80. package/webui/dist/assets/pierre-light-soft-lWLdNTOI.js +1 -0
  81. package/webui/dist/assets/pierre-light-tritanopia-CEbqgOJL.js +1 -0
  82. package/webui/dist/assets/pierre-light-vibrant-D80Fkn33.js +1 -0
  83. package/webui/dist/assets/postcss-D_Wz94rJ.js +1 -0
  84. package/webui/dist/assets/powershell-QtSnmXcu.js +1 -0
  85. package/webui/dist/assets/prisma-DMgNhnYS.js +1 -0
  86. package/webui/dist/assets/properties-CxAuzHS9.js +1 -0
  87. package/webui/dist/assets/python-gzcpVVnB.js +1 -0
  88. package/webui/dist/assets/rolldown-runtime-hePW80VL.js +1 -0
  89. package/webui/dist/assets/ruby-CPL7DL0w.js +1 -0
  90. package/webui/dist/assets/rust-DpiIf5JO.js +1 -0
  91. package/webui/dist/assets/sass-DXrisJhu.js +1 -0
  92. package/webui/dist/assets/scss-CHVQqdPx.js +1 -0
  93. package/webui/dist/assets/shellscript-CLZ0U2zV.js +1 -0
  94. package/webui/dist/assets/sql-CEbI0hMM.js +1 -0
  95. package/webui/dist/assets/svelte-DKhxH1PN.js +1 -0
  96. package/webui/dist/assets/swift-CyEgAFGc.js +1 -0
  97. package/webui/dist/assets/toml-CcmNWLt0.js +1 -0
  98. package/webui/dist/assets/tsx-CUOciP0b.js +1 -0
  99. package/webui/dist/assets/typescript-BydbNFcO.js +1 -0
  100. package/webui/dist/assets/vue-kBwcnTZb.js +1 -0
  101. package/webui/dist/assets/wasm-BnjxR4X6.js +1 -0
  102. package/webui/dist/assets/xml-CeXJrFlG.js +1 -0
  103. package/webui/dist/assets/yaml-rwi0_p6S.js +1 -0
  104. package/webui/dist/assets/yml-s9iVJXCw.js +1 -0
  105. package/webui/dist/assets/zsh-B7CDPTg5.js +1 -0
  106. package/webui/dist/build.json +44 -16
  107. package/webui/dist/index.html +4 -2
  108. package/webui/dist/assets/index-Dnz2q4t0.js +0 -112
  109. package/webui/dist/assets/index-nzFYIE8J.css +0 -2
@@ -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 and the
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; the styles and markup below were ported into this repository and
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.58",
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.3.0 <1.4.0",
51
- "@earendil-works/pi-coding-agent": ">=1.3.0 <1.4.0",
52
- "@earendil-works/pi-tui": ">=1.3.0 <1.4.0",
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.3.0",
58
- "@earendil-works/pi-coding-agent": "1.3.0",
59
- "@earendil-works/pi-tui": "1.3.0",
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
- - Open the files and `path:line` anchors your brief and the scout findings
13
- name first, then the **Repository map** and any **Likely files** section;
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.
@@ -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
- return join(dataRoot(scope.root, scope.configDir), "cache", "files.json");
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
- mkdirSync(dirname(path), { recursive: true });
259
- writeFileSync(`${path}.tmp`, JSON.stringify({ version: 1, cwd: scope.cwd, files } satisfies CacheFile));
260
- renameSync(`${path}.tmp`, path);
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
+ }
@@ -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) and `read_board`
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, filterPosts, formatPosts, isPostKind, POST_KINDS, readBoard, subscriptionFor, type PostKind } from "./board.ts";
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`. Contracts and blockers reach the other running workers at once. Post facts, not progress chatter.",
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
- const answer = await desk.request({ op: "post", from: self, kind: params.kind, text: params.text, files: params.files ?? [] });
39
- // Only a desk without the board, or one never reached, leaves the post to the file below; any other answer may follow a write.
40
- if (answer.ok || !/^This task has no board\.$|^the file desk is unavailable$/.test(answer.text)) return text(answer.text);
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 post = appendPost(path, { from: self, kind: params.kind, text: params.text, ...(params.files ? { files: params.files } : {}) });
44
- return text(`Posted #${post.seq}.`);
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 posts = filterPosts(readBoard(path), { kinds, ...(params.after ? { after: params.after } : {}), limit: 50 });
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
  }