@a-t-h-i/bot-lobby 0.6.7 → 0.6.9

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 (60) hide show
  1. package/README.md +182 -16
  2. package/package.json +5 -1
  3. package/prompts/master.md +15 -0
  4. package/prompts/panel.md +4 -0
  5. package/prompts/planner.md +5 -0
  6. package/prompts/pr-review.md +62 -0
  7. package/prompts/splitter.md +58 -0
  8. package/src/ask/dialog.ts +19 -5
  9. package/src/ask/state.ts +8 -2
  10. package/src/ask/tool.ts +3 -1
  11. package/src/ask/view.ts +6 -3
  12. package/src/classifier/instance.ts +6 -0
  13. package/src/classifier/knowledge.ts +158 -0
  14. package/src/classifier/review.ts +146 -0
  15. package/src/excalidraw/check.ts +31 -0
  16. package/src/excalidraw/client.ts +266 -0
  17. package/src/excalidraw/room.ts +118 -0
  18. package/src/excalidraw/scene.ts +746 -0
  19. package/src/excalidraw/sessions.ts +302 -0
  20. package/src/excalidraw/tools.ts +223 -0
  21. package/src/execution/agent-runner.ts +2 -0
  22. package/src/execution/pi-runner.ts +9 -3
  23. package/src/execution/workspace.ts +185 -0
  24. package/src/index.ts +3 -0
  25. package/src/knowledge/edit.ts +109 -0
  26. package/src/knowledge/notes.ts +143 -0
  27. package/src/knowledge/selector.ts +1 -1
  28. package/src/knowledge/store.ts +16 -2
  29. package/src/lobby/ask.ts +42 -21
  30. package/src/lobby/issues.ts +1 -1
  31. package/src/lobby/knowledge.ts +179 -0
  32. package/src/lobby/layout.ts +95 -18
  33. package/src/lobby/planner.ts +205 -8
  34. package/src/lobby/pr-review.ts +360 -0
  35. package/src/lobby/pulls.ts +250 -0
  36. package/src/lobby/quickfix.ts +2 -0
  37. package/src/lobby/runtime.ts +121 -13
  38. package/src/lobby/split.ts +230 -0
  39. package/src/lobby/tabs/excalidraw.ts +95 -0
  40. package/src/lobby/tabs/git.ts +162 -0
  41. package/src/lobby/tabs/knowledge.ts +135 -0
  42. package/src/lobby/tabs/plan.ts +3 -1
  43. package/src/lobby/tabs/tasks.ts +11 -2
  44. package/src/lobby/view.ts +611 -32
  45. package/src/master/master.ts +39 -13
  46. package/src/pi/commands.ts +12 -7
  47. package/src/pi/model-support.ts +9 -0
  48. package/src/pi/plan-checklist.ts +42 -0
  49. package/src/pi/route.ts +2 -1
  50. package/src/pi/settings-ui.ts +41 -1
  51. package/src/pi/start-flags.ts +14 -0
  52. package/src/pi/start-task.ts +56 -5
  53. package/src/pi/tools.ts +4 -2
  54. package/src/schemas/configuration.ts +32 -3
  55. package/src/schemas/task.ts +15 -0
  56. package/src/state/backlog.ts +27 -4
  57. package/src/state/metrics.ts +2 -2
  58. package/src/state/persistence.ts +5 -5
  59. package/src/text.ts +38 -0
  60. package/src/workflow/workflow.ts +11 -0
@@ -11,6 +11,7 @@ import { isSubagentProcess } from "../pi/quiet.ts";
11
11
  import { Classifier } from "./classifier.ts";
12
12
  import { registerJevProvider, type KeySource, type KeyStatus } from "./hosts.ts";
13
13
  import { fileHinter, type FileHinter, type FileScope } from "./files.ts";
14
+ import { knowledgePicker, type KnowledgePicker } from "./knowledge.ts";
14
15
  import { lobbyFeed } from "../lobby/feed.ts";
15
16
  import { triageLine, triageWithContext } from "./triage.ts";
16
17
  import type { TaskTriage } from "../schemas/task.ts";
@@ -45,6 +46,11 @@ export function hintsFor(scope: FileScope): FileHinter {
45
46
  return fileHinter(classifier(), scope, (text) => lobbyFeed.log("CLASSIFIER", text, "info"));
46
47
  }
47
48
 
49
+ /** Relevant knowledge for agents, with what Jev kept of each long file logged to the lobby's activity feed. */
50
+ export function knowledgeFor(): KnowledgePicker {
51
+ return knowledgePicker(classifier(), (text) => lobbyFeed.log("CLASSIFIER", text, "info"));
52
+ }
53
+
48
54
  /** Triage a request for a task in a tree, logged to the lobby's activity feed; undefined when triage is off or fails. */
49
55
  export async function triageFor(scope: FileScope, request: string, signal?: AbortSignal): Promise<TaskTriage | undefined> {
50
56
  const triage = await triageWithContext(classifier(), scope, request, signal);
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Relevant knowledge. An agent's knowledge, standards and decisions are put in
3
+ * its prompt, not looked up, so a gate ("does this step need the knowledge
4
+ * base?") would have to guess before the agent knows what it needs, and a wrong
5
+ * "no" is silent. What Jev does safely instead is the ranking the keyword
6
+ * selector does badly: when a file is longer than the prompt has room for, Jev
7
+ * judges each of its sections against the step (one yes/no per section) and the
8
+ * ones that bear on it stay. A file that fits goes in whole and costs nothing.
9
+ *
10
+ * Whatever is left out is said so, with the file's path, so an agent that does
11
+ * need it reads the rest itself. Any failure, timeout or switch-off keeps the
12
+ * keyword selection, which is what agents got before.
13
+ */
14
+ import { basename } from "node:path";
15
+ import { score, selectRelevant, splitSections, tokenize, type KnowledgeInput, type KnowledgeSelection } from "../knowledge/selector.ts";
16
+ import type { Classifier } from "./classifier.ts";
17
+ import { noul, yesOf, type SystemOneRequest } from "./client.ts";
18
+ import { clip } from "./limits.ts";
19
+
20
+ /** How many characters of each knowledge file a prompt has room for. */
21
+ export const KNOWLEDGE_BUDGET_CHARS = 4000;
22
+ /** A section is judged on its first characters; the whole section is kept when it stays. */
23
+ export const SECTION_EXCERPT_CHARS = 700;
24
+ /** Sections judged per file; a longer file is narrowed by keywords first. */
25
+ export const MAX_SECTIONS = 60;
26
+ /** The step waits this long for Jev, then goes with keywords. */
27
+ export const KNOWLEDGE_BUDGET_MS = 1500;
28
+
29
+ /** The three files a prompt carries: `knowledge`, `standards` and `decisions`. */
30
+ export type KnowledgeSlice = keyof KnowledgeSelection;
31
+ export type KnowledgePaths = Partial<Record<KnowledgeSlice, string>>;
32
+
33
+ export interface SectionCall {
34
+ request: SystemOneRequest;
35
+ /** The section each question key stands for, by index into the file's sections. */
36
+ keys: Map<string, number>;
37
+ }
38
+
39
+ /** One request that asks about each candidate section of a file. */
40
+ export function sectionRequest(task: string, sections: readonly string[], candidates: readonly number[]): SectionCall {
41
+ const entries: Record<string, string> = {};
42
+ const questions: SystemOneRequest["questions"] = {};
43
+ const keys = new Map<string, number>();
44
+ candidates.forEach((index, position) => {
45
+ const key = `s${position + 1}`;
46
+ entries[key] = clip(sections[index]!, SECTION_EXCERPT_CHARS);
47
+ questions[key] = noul(`Does \`sections.${key}\` hold a fact, rule or decision that an agent doing \`task\` should have in front of it? Yes when it bears on the code, files, conventions or choices the task touches; no when it is about something else or only shares words with it.`);
48
+ keys.set(key, index);
49
+ });
50
+ return { request: { state: { task: clip(task, 3000), sections: entries }, questions }, keys };
51
+ }
52
+
53
+ /** The sections to judge: all of them, or the `MAX_SECTIONS` that share the most words with the task. */
54
+ export function candidateSections(task: string, sections: readonly string[]): number[] {
55
+ const all = sections.map((_, index) => index);
56
+ if (sections.length <= MAX_SECTIONS) return all;
57
+ const tokens = tokenize(task);
58
+ const scored = all.map((index) => ({ index, hits: score(sections[index]!, tokens) }));
59
+ scored.sort((a, b) => b.hits - a.hits || b.index - a.index);
60
+ return scored.slice(0, MAX_SECTIONS).map((entry) => entry.index).sort((a, b) => a - b);
61
+ }
62
+
63
+ /** What was kept of a file: its text and how many of its sections that was. */
64
+ export interface Kept {
65
+ text: string;
66
+ kept: number;
67
+ total: number;
68
+ }
69
+
70
+ /**
71
+ * The most relevant sections that fit `budget`, in the order of the file. A
72
+ * section too long to fit on its own is cut to fit when nothing else is kept.
73
+ */
74
+ export function keepSections(sections: readonly string[], relevance: ReadonlyMap<number, number>, floor: number, budget: number): Kept {
75
+ const wanted = [...relevance].filter(([, value]) => value >= floor).sort((a, b) => b[1] - a[1] || a[0] - b[0]);
76
+ const chosen: number[] = [];
77
+ const texts = new Map<number, string>();
78
+ let used = 0;
79
+ for (const [index] of wanted) {
80
+ const text = sections[index]!;
81
+ if (used + text.length <= budget) {
82
+ chosen.push(index);
83
+ texts.set(index, text);
84
+ used += text.length + 2;
85
+ } else if (chosen.length === 0) {
86
+ chosen.push(index);
87
+ texts.set(index, clip(text, budget));
88
+ used = budget;
89
+ }
90
+ }
91
+ chosen.sort((a, b) => a - b);
92
+ return { text: chosen.map((index) => texts.get(index)!).join("\n\n").trim(), kept: chosen.length, total: sections.length };
93
+ }
94
+
95
+ /** The line that tells an agent what it was not given, and where all of it is. */
96
+ export function leftOutNote(kept: Kept, name: string, path?: string): string {
97
+ const where = path ? ` The whole file is ${path}.` : "";
98
+ return kept.kept === 0
99
+ ? `_No section of ${name} bears on this step (${kept.total} sections).${where}_`
100
+ : `_Kept ${kept.kept} of ${kept.total} sections of ${name} for this step.${where}_`;
101
+ }
102
+
103
+ /** Picks what an agent's prompt carries of its knowledge files. */
104
+ export interface KnowledgePicker {
105
+ select(task: string, files: KnowledgeInput, options?: { signal?: AbortSignal; paths?: KnowledgePaths }): Promise<KnowledgeSelection>;
106
+ }
107
+
108
+ const SLICES: readonly KnowledgeSlice[] = ["knowledge", "standards", "decisions"];
109
+
110
+ /**
111
+ * Jev's picks for one file over the budget, or undefined when it cannot judge
112
+ * (off, failed, out of time, no key): the caller then uses keywords.
113
+ */
114
+ async function rank(classifier: Classifier, task: string, content: string, budget: number, options: { signal?: AbortSignal }): Promise<{ kept: Kept; sections: string[]; ms: number } | undefined> {
115
+ const sections = splitSections(content.trim());
116
+ if (sections.length < 2) return undefined;
117
+ const call = sectionRequest(task, sections, candidateSections(task, sections));
118
+ const result = await classifier.ask("knowledge", call.request, { ...(options.signal ? { signal: options.signal } : {}), timeoutMs: Math.min(classifier.config.timeoutMs, KNOWLEDGE_BUDGET_MS) });
119
+ if (!result) return undefined;
120
+ const relevance = new Map<number, number>();
121
+ for (const [key, index] of call.keys) {
122
+ const value = yesOf(result.answers, key);
123
+ if (value !== undefined) relevance.set(index, value);
124
+ }
125
+ // An answer to none of the questions says nothing: keywords decide.
126
+ if (relevance.size === 0) return undefined;
127
+ return { kept: keepSections(sections, relevance, classifier.config.thresholds.knowledgeRelevantAt, budget), sections, ms: result.ms };
128
+ }
129
+
130
+ /**
131
+ * A picker on Jev. Files that fit the prompt are untouched; a longer one keeps
132
+ * the sections Jev judges to bear on the step, with a note of what was left
133
+ * out. Standards are the exception to an empty result: they are rules for all
134
+ * work, so when Jev sees none that applies the keyword selection stands.
135
+ */
136
+ export function knowledgePicker(classifier: Classifier, log?: (text: string) => void, budget = KNOWLEDGE_BUDGET_CHARS): KnowledgePicker {
137
+ return {
138
+ async select(task, files, options = {}) {
139
+ const plain = (kind: KnowledgeSlice) => selectRelevant(task, files[kind] ?? "", budget);
140
+ const picked = await Promise.all(SLICES.map(async (kind): Promise<string> => {
141
+ const content = files[kind] ?? "";
142
+ if (content.trim().length <= budget || !task.trim() || !classifier.enabled("knowledge")) return plain(kind);
143
+ try {
144
+ const ranked = await rank(classifier, task, content, budget, options.signal ? { signal: options.signal } : {});
145
+ if (!ranked) return plain(kind);
146
+ const path = options.paths?.[kind];
147
+ const name = path ? basename(path) : kind;
148
+ if (ranked.kept.kept === 0 && kind === "standards") return plain(kind);
149
+ log?.(`knowledge: kept ${ranked.kept.kept} of ${ranked.kept.total} sections of ${name} · ${ranked.ms} ms`);
150
+ return [ranked.kept.text, leftOutNote(ranked.kept, name, path)].filter(Boolean).join("\n\n");
151
+ } catch {
152
+ return plain(kind);
153
+ }
154
+ }));
155
+ return { knowledge: picked[0]!, standards: picked[1]!, decisions: picked[2]! };
156
+ },
157
+ };
158
+ }
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Jev's quick read of a pull request, for the Git tab: how big the change is,
3
+ * how likely it is risky, to break callers, to touch security-sensitive code
4
+ * or to change behaviour without tests, and what kind of change it is — in
5
+ * one call, in a moment, without writing a word. It is a triage, not a review:
6
+ * it says whether a full review by an agent is worth its tokens.
7
+ */
8
+ import type { Classifier } from "./classifier.ts";
9
+ import { choice, choiceOf, noul, score, scoreOf, yesOf, type SystemOneRequest } from "./client.ts";
10
+ import { clip } from "./limits.ts";
11
+ import { TRIAGE_SIZES } from "./triage.ts";
12
+ import type { TriageSize } from "../schemas/task.ts";
13
+
14
+ const SIZE_LEVELS = [
15
+ "trivial: a one-line or mechanical change (a rename, a typo, a version bump)",
16
+ "small: a few files in one area, following an existing pattern",
17
+ "medium: several files or two areas, with some design decisions",
18
+ "large: a cross-cutting change, a new subsystem, a migration or an architecture change",
19
+ ];
20
+
21
+ const KINDS: Record<string, string> = {
22
+ feature: "New capability or behaviour",
23
+ bugfix: "Existing behaviour was wrong and is fixed",
24
+ refactor: "Restructures code without changing behaviour",
25
+ tests: "Adds or fixes tests only",
26
+ docs: "Documentation only",
27
+ dependency: "Upgrades or changes dependencies",
28
+ chore: "Configuration, build, tooling or cleanup",
29
+ };
30
+
31
+ /** The pull request as Jev reads it. */
32
+ export interface PullInput {
33
+ title: string;
34
+ body: string;
35
+ /** Changed files with their line counts, e.g. `src/a.ts (+12 −3)`. */
36
+ files: readonly string[];
37
+ diff: string;
38
+ }
39
+
40
+ /** What Jev made of a pull request. */
41
+ export interface PullRead {
42
+ size: TriageSize;
43
+ sizeConfidence: number;
44
+ /** Probabilities of yes, 0 to 1. */
45
+ risky: number;
46
+ breaking: number;
47
+ security: number;
48
+ testsMissing: number;
49
+ kind?: string;
50
+ kindProbability?: number;
51
+ model: string;
52
+ ms: number;
53
+ }
54
+
55
+ /** Characters of diff sent: the rest is what the agent's review is for. */
56
+ export const READ_DIFF_CHARS = 80_000;
57
+
58
+ export function pullRequest(input: PullInput): SystemOneRequest {
59
+ return {
60
+ state: {
61
+ title: clip(input.title, 300),
62
+ description: clip(input.body, 3000),
63
+ changed_files: clip(input.files.join("\n"), 4000),
64
+ diff: clip(input.diff, READ_DIFF_CHARS),
65
+ },
66
+ questions: {
67
+ size: score("How big is the change in `diff`, judged by what it would take to review and verify?", SIZE_LEVELS),
68
+ risky: noul(
69
+ "Could merging `diff` break existing behaviour or cause harm: data loss, corrupted state, wrong results, concurrency bugs, performance cliffs, or an outage?",
70
+ "Yes: there is a plausible way this breaks something that works today.",
71
+ "No: it is low-risk, well contained, or purely additive.",
72
+ ),
73
+ breaking: noul(
74
+ "Does `diff` change a public API, a config or file format, a database schema or a command-line interface in a way that breaks existing callers or users?",
75
+ "Yes: something outside this change has to adapt.",
76
+ "No: existing callers and data keep working as they are.",
77
+ ),
78
+ security: noul(
79
+ "Does `diff` touch authentication, authorisation, secrets, input validation, cryptography, or other security-sensitive code?",
80
+ "Yes: a mistake here could be a vulnerability.",
81
+ "No: nothing in it is security-relevant.",
82
+ ),
83
+ tests_missing: noul(
84
+ "Does `diff` change behaviour without adding or updating tests that cover the change?",
85
+ "Yes: the behaviour changes and no test in the diff covers it.",
86
+ "No: it adds or updates tests for what it changes, or it changes no behaviour.",
87
+ ),
88
+ kind: choice("What kind of change is `diff`?", KINDS),
89
+ },
90
+ };
91
+ }
92
+
93
+ /** Jev's read of a pull request, or undefined when the classifier is off or fails. */
94
+ export async function readPull(classifier: Classifier, input: PullInput, signal?: AbortSignal): Promise<PullRead | undefined> {
95
+ if (!classifier.enabled("review")) return undefined;
96
+ const result = await classifier.ask("review", pullRequest(input), signal ? { signal } : {});
97
+ if (!result) return undefined;
98
+ const size = scoreOf(result.answers, "size");
99
+ if (!size) return undefined;
100
+ const kind = choiceOf(result.answers, "kind");
101
+ return {
102
+ size: TRIAGE_SIZES[Math.max(0, Math.min(TRIAGE_SIZES.length - 1, size.level))]!,
103
+ sizeConfidence: size.confidence,
104
+ risky: yesOf(result.answers, "risky") ?? 0,
105
+ breaking: yesOf(result.answers, "breaking") ?? 0,
106
+ security: yesOf(result.answers, "security") ?? 0,
107
+ testsMissing: yesOf(result.answers, "tests_missing") ?? 0,
108
+ ...(kind ? { kind: kind.choice, kindProbability: kind.probability } : {}),
109
+ model: result.model,
110
+ ms: result.ms,
111
+ };
112
+ }
113
+
114
+ const CONCERN = 0.5;
115
+
116
+ /** The concerns worth a look, most likely first, as words. */
117
+ export function concerns(read: PullRead): string[] {
118
+ const found: Array<[number, string]> = [
119
+ [read.risky, "risky"],
120
+ [read.security, "touches security"],
121
+ [read.breaking, "may break callers"],
122
+ [read.testsMissing, "no tests for the change"],
123
+ ];
124
+ return found.filter(([probability]) => probability >= CONCERN).sort((a, b) => b[0] - a[0]).map(([, words]) => words);
125
+ }
126
+
127
+ /** Whether a full review by an agent is worth its tokens: a concern, or a change too big to skim. */
128
+ export function worthReview(read: PullRead): boolean {
129
+ return concerns(read).length > 0 || read.size === "large";
130
+ }
131
+
132
+ /** One line: `medium · bugfix · risky 0.71, no tests for the change 0.64 → worth a full review`. */
133
+ export function readLine(read: PullRead): string {
134
+ const kind = read.kind ? ` · ${read.kind}` : "";
135
+ const flagged = concerns(read);
136
+ const figures = [
137
+ ["risky", read.risky],
138
+ ["security", read.security],
139
+ ["breaking", read.breaking],
140
+ ["tests missing", read.testsMissing],
141
+ ] as const;
142
+ const detail = flagged.length > 0
143
+ ? figures.filter(([, probability]) => probability >= CONCERN).sort((a, b) => b[1] - a[1]).map(([name, probability]) => `${name} ${probability.toFixed(2)}`).join(", ")
144
+ : "nothing stands out";
145
+ return `${read.size}${kind} · ${detail} → ${worthReview(read) ? "worth a full review" : "looks routine"}`;
146
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * "Does this session work?": join the room for a moment, as a seat named
3
+ * after the lobby, and say what is there — the server reached, who is in the
4
+ * room, how much is on the board, whether the link's key fits. The seat leaves
5
+ * again at once, so a check never leaves an extra collaborator in the room.
6
+ */
7
+ import { ExcalidrawRoom, type RoomOptions } from "./client.ts";
8
+ import { parseRoomLink } from "./room.ts";
9
+
10
+ export interface CheckResult {
11
+ ok: boolean;
12
+ text: string;
13
+ }
14
+
15
+ export async function checkSession(link: string, name: string, options: Pick<RoomOptions, "server" | "sceneWaitMs" | "connectTimeoutMs" | "connect"> = {}): Promise<CheckResult> {
16
+ const parsed = parseRoomLink(link);
17
+ if (!parsed) return { ok: false, text: "not a valid room link" };
18
+ const room = new ExcalidrawRoom({ link: parsed, username: "bot-lobby · check", name, ...options });
19
+ try {
20
+ const status = await room.ready();
21
+ // Give an answer to the newcomer's arrival a moment more when someone is there but the board has not come yet.
22
+ if (status.undecryptable > 0 && status.elements === 0) return { ok: false, text: "reached the room, but its messages would not open: the link's key does not match, so it may have been copied incompletely" };
23
+ if (status.peers === 0) return { ok: true, text: "reached the server; nobody has this session open yet — open the link in Excalidraw, and agents can read and draw" };
24
+ const who = status.people.length > 0 ? status.people.join(", ") : `${status.peers} other${status.peers === 1 ? "" : "s"}`;
25
+ return { ok: true, text: `reached the room with ${who}; ${status.synced ? `${status.elements} element${status.elements === 1 ? "" : "s"} on the board` : "the board has not been sent yet (it comes when someone in the room answers)"}` };
26
+ } catch (error) {
27
+ return { ok: false, text: (error as Error).message };
28
+ } finally {
29
+ room.close();
30
+ }
31
+ }
@@ -0,0 +1,266 @@
1
+ /**
2
+ * One agent's seat in an Excalidraw shared session. It joins the room the way
3
+ * a second browser tab would: over Excalidraw's collaboration socket, sending
4
+ * and receiving the encrypted scene messages every client in the room sends.
5
+ * Others in the room answer a newcomer with the whole scene, so a seat learns
6
+ * the board from whoever is there; what the agent draws goes out as an update
7
+ * that every browser merges in place, under the agent's name.
8
+ *
9
+ * A seat never answers a newcomer with a scene it does not have: a browser
10
+ * that took an empty scene from an agent would skip loading the board it saved
11
+ * itself. And it does not draw while nobody else is in the room, because
12
+ * nothing keeps a scene for a room but the browsers in it.
13
+ */
14
+ import { io, type Socket } from "socket.io-client";
15
+ import { DEFAULT_SERVER, seal, unseal, type RoomLink } from "./room.ts";
16
+ import { describeScene, mergeElements, plainText, planDraw, sceneBounds, visible, type DrawOutcome, type DrawRequest, type Scene, type SceneElement } from "./scene.ts";
17
+
18
+ /** The part of a socket.io client a seat uses; tests and other transports can stand in for it. */
19
+ export type SocketLike = Pick<Socket, "on" | "emit" | "close" | "connected"> & { id?: string | undefined };
20
+
21
+ export interface RoomOptions {
22
+ link: Pick<RoomLink, "roomId" | "roomKey">;
23
+ /** The name other people see next to this seat's cursor. */
24
+ username: string;
25
+ /** The session's name in the lobby, for readings and messages. */
26
+ name: string;
27
+ server?: string;
28
+ connect?: (server: string) => SocketLike;
29
+ /** Give up on reaching the server after this long. */
30
+ connectTimeoutMs?: number;
31
+ /** How long to wait for a collaborator to send the scene (Excalidraw's own client waits five seconds). */
32
+ sceneWaitMs?: number;
33
+ }
34
+
35
+ export interface RoomStatus {
36
+ connected: boolean;
37
+ /** Others in the room, people and agents, not counting this seat. */
38
+ peers: number;
39
+ /** A collaborator has sent the whole scene. */
40
+ synced: boolean;
41
+ elements: number;
42
+ /** Messages that would not open: the link's key does not match the room's. */
43
+ undecryptable: number;
44
+ /** Names the collaborators have given themselves. */
45
+ people: string[];
46
+ }
47
+
48
+ const CONNECT_TIMEOUT_MS = 10_000;
49
+ const SCENE_WAIT_MS = 5000;
50
+ /** The collaboration server relays at most a megabyte a message; a scene near that is not sent whole. */
51
+ const MAX_MESSAGE_CHARS = 900_000;
52
+
53
+ function defaultConnect(server: string): SocketLike {
54
+ return io(server, { transports: ["websocket", "polling"], autoUnref: true, timeout: 8000 });
55
+ }
56
+
57
+ export function collaborationServer(env: NodeJS.ProcessEnv = process.env): string {
58
+ return env.BOT_LOBBY_EXCALIDRAW_SERVER?.trim() || DEFAULT_SERVER;
59
+ }
60
+
61
+ export class ExcalidrawRoom {
62
+ readonly scene: Scene = new Map();
63
+ private readonly options: RoomOptions;
64
+ private socket: SocketLike | undefined;
65
+ private opening: Promise<RoomStatus> | undefined;
66
+ private peerIds: string[] = [];
67
+ private readonly names = new Map<string, string>();
68
+ private synced = false;
69
+ private undecryptable = 0;
70
+ private closed = false;
71
+ private timers: Array<ReturnType<typeof setTimeout>> = [];
72
+
73
+ constructor(options: RoomOptions) {
74
+ this.options = options;
75
+ }
76
+
77
+ get name(): string {
78
+ return this.options.name;
79
+ }
80
+
81
+ /** Which room this seat is in; a seat is replaced when its session's link changes. */
82
+ get linkKey(): string {
83
+ return `${this.options.link.roomId},${this.options.link.roomKey}`;
84
+ }
85
+
86
+ status(): RoomStatus {
87
+ return {
88
+ connected: this.socket?.connected ?? false,
89
+ peers: this.peerIds.length,
90
+ synced: this.synced,
91
+ elements: visible(this.scene).length,
92
+ undecryptable: this.undecryptable,
93
+ people: [...new Set(this.peerIds.map((id) => this.names.get(id)).filter((name): name is string => Boolean(name)))],
94
+ };
95
+ }
96
+
97
+ /** Join the room (once) and wait until a collaborator has sent the scene, or the wait for it is over. */
98
+ ready(): Promise<RoomStatus> {
99
+ this.opening ??= this.open();
100
+ return this.opening;
101
+ }
102
+
103
+ private open(): Promise<RoomStatus> {
104
+ return new Promise((resolve, reject) => {
105
+ // A seat that failed to connect once may try again.
106
+ this.closed = false;
107
+ const { roomId } = this.options.link;
108
+ const server = this.options.server ?? collaborationServer();
109
+ const socket = (this.options.connect ?? defaultConnect)(server);
110
+ this.socket = socket;
111
+ let done = false;
112
+ let lastError = "";
113
+ const settle = () => {
114
+ if (done) return;
115
+ done = true;
116
+ resolve(this.status());
117
+ this.announce();
118
+ };
119
+ this.timers.push(
120
+ setTimeout(() => {
121
+ if (done) return;
122
+ done = true;
123
+ this.opening = undefined;
124
+ this.close();
125
+ reject(new Error(`could not reach the Excalidraw collaboration server ${server}${lastError ? ` (${lastError})` : ""}`));
126
+ }, this.options.connectTimeoutMs ?? CONNECT_TIMEOUT_MS),
127
+ );
128
+ socket.on("connect_error", (error: Error) => {
129
+ lastError = error.message;
130
+ });
131
+ // Every (re)connection is greeted with init-room; the seat answers by joining.
132
+ socket.on("init-room", () => {
133
+ socket.emit("join-room", roomId);
134
+ this.timers.push(setTimeout(settle, this.options.sceneWaitMs ?? SCENE_WAIT_MS));
135
+ });
136
+ socket.on("first-in-room", () => settle());
137
+ socket.on("room-user-change", (clients: string[]) => {
138
+ this.peerIds = clients.filter((id) => id !== socket.id);
139
+ for (const id of [...this.names.keys()]) if (!clients.includes(id)) this.names.delete(id);
140
+ });
141
+ socket.on("new-user", () => {
142
+ // What this seat does not know, it does not claim to.
143
+ if (this.synced || visible(this.scene).length > 0) void this.send("SCENE_INIT", [...this.scene.values()]);
144
+ });
145
+ socket.on("client-broadcast", (data: unknown, iv: unknown) => {
146
+ void this.receive(data, iv).then((initial) => {
147
+ if (initial) settle();
148
+ });
149
+ });
150
+ });
151
+ }
152
+
153
+ /** A received message; true when it was the scene a newcomer is waiting for. */
154
+ private async receive(data: unknown, iv: unknown): Promise<boolean> {
155
+ const message = (await unseal(this.options.link.roomKey, data, iv)) as { type?: string; payload?: Record<string, unknown> } | undefined;
156
+ if (!message || typeof message !== "object") {
157
+ this.undecryptable += 1;
158
+ return false;
159
+ }
160
+ const payload = message.payload ?? {};
161
+ switch (message.type) {
162
+ case "SCENE_INIT":
163
+ case "SCENE_UPDATE":
164
+ if (Array.isArray(payload.elements)) mergeElements(this.scene, payload.elements);
165
+ if (message.type === "SCENE_INIT") this.synced = true;
166
+ return message.type === "SCENE_INIT";
167
+ case "MOUSE_LOCATION":
168
+ case "IDLE_STATUS":
169
+ if (typeof payload.socketId === "string" && typeof payload.username === "string" && plainText(payload.username)) this.names.set(payload.socketId, plainText(payload.username).slice(0, 40));
170
+ return false;
171
+ default:
172
+ return false;
173
+ }
174
+ }
175
+
176
+ /** Send a scene message to the room; false when the seat is not connected. */
177
+ private async send(type: "SCENE_INIT" | "SCENE_UPDATE" | "IDLE_STATUS" | "MOUSE_LOCATION", body: unknown): Promise<boolean> {
178
+ const socket = this.socket;
179
+ if (!socket?.connected || this.closed) return false;
180
+ const payload = type === "SCENE_INIT" || type === "SCENE_UPDATE" ? { elements: body } : body;
181
+ if (JSON.stringify(payload).length > MAX_MESSAGE_CHARS) return false;
182
+ const sealed = await seal(this.options.link.roomKey, { type, payload });
183
+ socket.emit("server-broadcast", this.options.link.roomId, sealed.data, sealed.iv);
184
+ return true;
185
+ }
186
+
187
+ /**
188
+ * Tell the room who this seat is (its collaborator name), and where it just
189
+ * drew. Excalidraw sends these as volatile messages because it sends them
190
+ * constantly; a seat says it once, so it does not let the server drop it.
191
+ */
192
+ private announce(near?: { x: number; y: number }): void {
193
+ const socketId = this.socket?.id;
194
+ if (!socketId) return;
195
+ void this.send("IDLE_STATUS", { socketId, userState: "active", username: this.options.username });
196
+ if (near) void this.send("MOUSE_LOCATION", { socketId, pointer: { x: near.x, y: near.y, tool: "pointer" }, button: "up", selectedElementIds: {}, username: this.options.username });
197
+ }
198
+
199
+ /** Why nothing may be drawn right now, or undefined when it may. */
200
+ cannotDraw(): string | undefined {
201
+ if (!this.socket?.connected) return "this seat is not connected to the room";
202
+ if (this.peerIds.length === 0) return "nobody else is in the room, and only a browser keeps a room's board; ask the user to open the session's link in Excalidraw, then try again";
203
+ return undefined;
204
+ }
205
+
206
+ /** Draw: the elements go out to the room and into this seat's own scene. */
207
+ async draw(request: DrawRequest): Promise<DrawOutcome> {
208
+ const outcome = planDraw(this.scene, request);
209
+ if (outcome.changed.length === 0) return outcome;
210
+ const reason = this.cannotDraw();
211
+ if (reason) return { ...outcome, changed: [], drawn: [], removed: [], problems: [...outcome.problems, reason] };
212
+ for (const element of outcome.changed) this.scene.set(element.id, element);
213
+ await this.send("SCENE_UPDATE", outcome.changed);
214
+ const around = sceneBounds(outcome.changed.filter((element) => !element.isDeleted));
215
+ this.announce(around ? { x: around.x1, y: around.y1 } : undefined);
216
+ return outcome;
217
+ }
218
+
219
+ /** The board in words, for a model, with what is known about the room around it. */
220
+ read(): string {
221
+ const status = this.status();
222
+ const notes: string[] = [];
223
+ if (!status.connected) notes.push("Not connected to the room right now; this is the board as last seen.");
224
+ else if (status.undecryptable > 0 && status.elements === 0) notes.push("Messages in this room would not open: the session link's key does not match the room's, so it may have been copied incompletely.");
225
+ else if (status.peers === 0) notes.push("Nobody else is in the room, so the board above is not known: ask the user to open the session's link in Excalidraw, then read again.");
226
+ else if (!status.synced) notes.push("No collaborator has sent the whole board yet, so this may be incomplete; read again in a few seconds.");
227
+ if (status.peers > 0) notes.push(`In the room with you: ${status.people.length > 0 ? status.people.join(", ") : `${status.peers} other${status.peers === 1 ? "" : "s"}`}.`);
228
+ return [describeScene(this.scene, this.options.name), ...notes].join("\n");
229
+ }
230
+
231
+ elements(): SceneElement[] {
232
+ return visible(this.scene);
233
+ }
234
+
235
+ close(): void {
236
+ this.closed = true;
237
+ for (const timer of this.timers) clearTimeout(timer);
238
+ this.timers = [];
239
+ this.socket?.close();
240
+ }
241
+ }
242
+
243
+ /** The seats an agent has open, one per session, kept for the length of its run. */
244
+ export class RoomPool {
245
+ private readonly rooms = new Map<string, ExcalidrawRoom>();
246
+ private readonly make: (options: RoomOptions) => ExcalidrawRoom;
247
+
248
+ constructor(make: (options: RoomOptions) => ExcalidrawRoom = (options) => new ExcalidrawRoom(options)) {
249
+ this.make = make;
250
+ }
251
+
252
+ /** The seat for a session, opened on first use; a session whose link changed gets a new seat. */
253
+ room(key: string, options: RoomOptions): ExcalidrawRoom {
254
+ const existing = this.rooms.get(key);
255
+ if (existing && existing.linkKey === `${options.link.roomId},${options.link.roomKey}`) return existing;
256
+ existing?.close();
257
+ const room = this.make(options);
258
+ this.rooms.set(key, room);
259
+ return room;
260
+ }
261
+
262
+ closeAll(): void {
263
+ for (const room of this.rooms.values()) room.close();
264
+ this.rooms.clear();
265
+ }
266
+ }