eve-hub 0.6.42 → 0.6.44

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/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # eve-hub
2
2
 
3
+ ## 0.6.44
4
+
5
+ ### Patch Changes
6
+
7
+ - The mention rule (mentionTokens and codeRanges) lives in mention-tokens.mjs with no imports, so the composer's fuzz harness loads the server's own source; mention-gate re-exports codeRanges.
8
+
9
+ ## 0.6.43
10
+
11
+ ### Patch Changes
12
+
13
+ - Every bot is given the team model (who is on the team, where each runs, each bot's real tools from its eve runtime, who hands work to whom) as a memory slot that re-sends only when it changes, hosted bots included; `list_agents` is on every bot and reports each bot's kind, place and tools.
14
+
3
15
  ## 0.6.42
4
16
 
5
17
  ### Patch Changes
@@ -39,15 +39,13 @@ export function deploymentFacts(env = process.env) {
39
39
 
40
40
  const list = (items) => items.map((item) => `\`${item}\``).join(", ");
41
41
 
42
- export function hostedContext({ bot, roster, kit, removed, deployment: d }, options) {
43
- const mac = roster.filter((agent) => agent.runsOn === "local").map((agent) => agent.name);
44
- const cloud = roster.filter((agent) => agent.runsOn !== "local").map((agent) => agent.name);
42
+ export function hostedContext({ bot, kit, removed, deployment: d }, options) {
45
43
  const dashboard = d.team && d.project ? `https://vercel.com/${d.team}/${d.project}` : undefined;
46
44
  const lines = [
47
45
  "## Execution context",
48
46
  `You are \`${bot}\`, an eve agent deployed on Vercel, not on a laptop: \`hub.workspace.json\` defines you as hosted.`,
49
47
  "",
50
- `- **Workspace:** ${cloud.length ? `${list(cloud)} run on Vercel` : "no bot runs on Vercel"}${mac.length ? `; ${list(mac)} run on the owner's Mac through the hub runner` : ""}. One hosted hub holds the threads and routes messages to each.`,
48
+ "- **Workspace:** one hosted hub holds the threads and routes messages to each bot. Who is on the team, where each bot runs and what each can do come live from the hub: the team model message at the top of your turn (when it changes) and `list_agents`.",
51
49
  `- **Package:** your tools, hooks, channel and instructions come from the \`eve-hub\` package, version ${kit}.`,
52
50
  `- **Deployment:** ${[
53
51
  (d.project ?? d.projectId) && `project \`${d.project ?? d.projectId}\``,
package/kit-modules.json CHANGED
@@ -8,6 +8,7 @@
8
8
  "instructions/main",
9
9
  "instructions/protocol",
10
10
  "memory/bot",
11
+ "memory/team",
11
12
  "memory/workspace",
12
13
  "schedules/compact",
13
14
  "schedules/dash",
@@ -0,0 +1,20 @@
1
+ declare const recall: ({ messages }: {
2
+ messages: readonly unknown[];
3
+ }) => Promise<{
4
+ messages: {
5
+ id: string;
6
+ content: string;
7
+ }[];
8
+ }>;
9
+ declare const _default: import("eve/memory").DefinedMemory<{
10
+ readonly description: "The team: who is on it, what each bot can do, and who hands work to whom.";
11
+ readonly namespace: "hub-team-memory";
12
+ readonly scope: "workspace";
13
+ readonly provider: {
14
+ readonly recall: {
15
+ "turn.started": typeof recall;
16
+ "compaction.completed": typeof recall;
17
+ };
18
+ };
19
+ }>;
20
+ export default _default;
package/memory/team.js ADDED
@@ -0,0 +1,38 @@
1
+ import { defineMemory, defineMemoryProvider } from "eve/memory";
2
+ import { registryFetch } from "../registry-fetch.mjs";
3
+ import { teamRecall } from "../team.mjs";
4
+ // The team model (team.mjs) as an eve memory slot: roster, each bot's tools
5
+ // and the handoff rules, from the hub's GET /api/team. Recall runs at the start
6
+ // of every turn and after compaction, and adds one message only when the team
7
+ // differs from what this session was last given, so a turn where nothing
8
+ // changed carries nothing. A hub that can't answer within 5 s fails the recall, not the turn.
9
+ const warned = new Set();
10
+ async function fetchTeam(since) {
11
+ const res = await registryFetch(`/api/team${since ? `?since=${since}` : ""}`, { signal: AbortSignal.timeout(5_000) });
12
+ if (res.status === 304)
13
+ return null;
14
+ if (!res.ok)
15
+ throw new Error(`GET /api/team: HTTP ${res.status}: ${(await res.text()).slice(0, 200)}`);
16
+ return (await res.json());
17
+ }
18
+ const recall = async ({ messages }) => {
19
+ try {
20
+ return await teamRecall(messages, fetchTeam);
21
+ }
22
+ catch (error) {
23
+ const note = error.message;
24
+ if (!warned.has(note)) {
25
+ warned.add(note);
26
+ console.warn(`team: ${note}`);
27
+ }
28
+ return { messages: [] };
29
+ }
30
+ };
31
+ export default defineMemory({
32
+ description: "The team: who is on it, what each bot can do, and who hands work to whom.",
33
+ namespace: "hub-team-memory",
34
+ scope: "workspace",
35
+ provider: defineMemoryProvider({
36
+ recall: { "turn.started": recall, "compaction.completed": recall },
37
+ }),
38
+ });
package/mention-gate.mjs CHANGED
@@ -20,11 +20,14 @@
20
20
  // overridden by HUB_MENTION_GATE (on|off), HUB_MENTION_GATE_MODEL,
21
21
  // HUB_MENTION_GATE_REASONING, HUB_MENTION_GATE_TIMEOUT_MS and
22
22
  // HUB_MENTION_GATE_URL (tests).
23
+ import { codeRanges } from "./mention-tokens.mjs";
23
24
  import { registryHeaders } from "./registry-fetch.mjs";
24
25
  import { existsSync, readFileSync } from "node:fs";
25
26
  import { join } from "node:path";
26
27
  import { pathToFileURL } from "node:url";
27
28
 
29
+ export { codeRanges };
30
+
28
31
  export const GATE_DEFAULTS = {
29
32
  enabled: true,
30
33
  model: "openai/gpt-6-luna-decisions",
@@ -194,69 +197,6 @@ export function triggerText(envelope) {
194
197
  return next < 0 ? rest : rest.slice(0, next);
195
198
  }
196
199
 
197
- // The character ranges of a message that are code, by CommonMark's rules:
198
- // fenced blocks (``` or ~~~, closed by the same character, at least as
199
- // long), and code spans (a run of n backticks closed by the next run of
200
- // exactly n on the same line; an opener can't be backslash-escaped).
201
- // Anything ambiguous stays text: an unclosed fence or an unmatched run is
202
- // literal, so a handle next to it counts as live.
203
- export function codeRanges(text) {
204
- const s = String(text ?? "");
205
- const ranges = [];
206
- const lines = [];
207
- for (let at = 0; at <= s.length; ) {
208
- const nl = s.indexOf("\n", at);
209
- const end = nl < 0 ? s.length : nl;
210
- lines.push({ start: at, end, text: s.slice(at, end) });
211
- if (nl < 0) break;
212
- at = nl + 1;
213
- }
214
- // Fences first: only closed ones are code.
215
- const fenced = new Set();
216
- for (let i = 0; i < lines.length; i++) {
217
- const open = lines[i].text.match(/^ {0,3}(`{3,}|~{3,})(.*)$/);
218
- if (!open || (open[1][0] === "`" && open[2].includes("`"))) continue;
219
- const close = new RegExp(`^ {0,3}${open[1][0] === "`" ? "`" : "~"}{${open[1].length},}\\s*$`);
220
- const j = lines.findIndex((l, k) => k > i && close.test(l.text));
221
- if (j < 0) continue;
222
- ranges.push([lines[i].start, lines[j].end]);
223
- for (let k = i; k <= j; k++) fenced.add(k);
224
- i = j;
225
- }
226
- // Code spans.
227
- let para = [];
228
- const flush = () => {
229
- if (!para.length) return;
230
- const from = para[0].start;
231
- const t = s.slice(from, para.at(-1).end);
232
- const runs = [...t.matchAll(/`+/g)].map((m) => ({ at: m.index, n: m[0].length }));
233
- for (let i = 0; i < runs.length; i++) {
234
- let { at, n } = runs[i];
235
- let slash = 0;
236
- for (let b = at - 1; b >= 0 && t[b] === "\\"; b--) slash++;
237
- if (slash % 2 === 1) {
238
- // An escaped backtick is literal; the rest of the run can still open.
239
- at++;
240
- n--;
241
- if (!n) continue;
242
- }
243
- const j = runs.findIndex((r, k) => k > i && r.n === n);
244
- if (j < 0) continue;
245
- ranges.push([from + at, from + runs[j].at + n]);
246
- i = j;
247
- }
248
- para = [];
249
- };
250
- // One line at a time: a span that would cross lines might cross a block
251
- // boundary (a heading or a list item interrupts a paragraph without a
252
- // blank line), so it stays text.
253
- lines.forEach((l, i) => {
254
- if (!fenced.has(i) && l.text.trim()) para.push(l);
255
- flush();
256
- });
257
- return ranges;
258
- }
259
-
260
200
  // Where the bot's @handle sits in a message, by parsing its markdown: in
261
201
  // code (codeRanges), on a > quote line, or live (anywhere else).
262
202
  export function mentionSites(text, agent) {
@@ -1,4 +1,67 @@
1
- import { codeRanges } from "./mention-gate.mjs";
1
+ // The mention rule: what counts as `@handle` in a message. No imports, so the
2
+ // native editor checks load this one file as is.
3
+ // The character ranges of a message that are code, by CommonMark's rules:
4
+ // fenced blocks (``` or ~~~, closed by the same character, at least as
5
+ // long), and code spans (a run of n backticks closed by the next run of
6
+ // exactly n on the same line; an opener can't be backslash-escaped).
7
+ // Anything ambiguous stays text: an unclosed fence or an unmatched run is
8
+ // literal, so a handle next to it counts as live.
9
+ export function codeRanges(text) {
10
+ const s = String(text ?? "");
11
+ const ranges = [];
12
+ const lines = [];
13
+ for (let at = 0; at <= s.length; ) {
14
+ const nl = s.indexOf("\n", at);
15
+ const end = nl < 0 ? s.length : nl;
16
+ lines.push({ start: at, end, text: s.slice(at, end) });
17
+ if (nl < 0) break;
18
+ at = nl + 1;
19
+ }
20
+ // Fences first: only closed ones are code.
21
+ const fenced = new Set();
22
+ for (let i = 0; i < lines.length; i++) {
23
+ const open = lines[i].text.match(/^ {0,3}(`{3,}|~{3,})(.*)$/);
24
+ if (!open || (open[1][0] === "`" && open[2].includes("`"))) continue;
25
+ const close = new RegExp(`^ {0,3}${open[1][0] === "`" ? "`" : "~"}{${open[1].length},}\\s*$`);
26
+ const j = lines.findIndex((l, k) => k > i && close.test(l.text));
27
+ if (j < 0) continue;
28
+ ranges.push([lines[i].start, lines[j].end]);
29
+ for (let k = i; k <= j; k++) fenced.add(k);
30
+ i = j;
31
+ }
32
+ // Code spans.
33
+ let para = [];
34
+ const flush = () => {
35
+ if (!para.length) return;
36
+ const from = para[0].start;
37
+ const t = s.slice(from, para.at(-1).end);
38
+ const runs = [...t.matchAll(/`+/g)].map((m) => ({ at: m.index, n: m[0].length }));
39
+ for (let i = 0; i < runs.length; i++) {
40
+ let { at, n } = runs[i];
41
+ let slash = 0;
42
+ for (let b = at - 1; b >= 0 && t[b] === "\\"; b--) slash++;
43
+ if (slash % 2 === 1) {
44
+ // An escaped backtick is literal; the rest of the run can still open.
45
+ at++;
46
+ n--;
47
+ if (!n) continue;
48
+ }
49
+ const j = runs.findIndex((r, k) => k > i && r.n === n);
50
+ if (j < 0) continue;
51
+ ranges.push([from + at, from + runs[j].at + n]);
52
+ i = j;
53
+ }
54
+ para = [];
55
+ };
56
+ // One line at a time: a span that would cross lines might cross a block
57
+ // boundary (a heading or a list item interrupts a paragraph without a
58
+ // blank line), so it stays text.
59
+ lines.forEach((l, i) => {
60
+ if (!fenced.has(i) && l.text.trim()) para.push(l);
61
+ flush();
62
+ });
63
+ return ranges;
64
+ }
2
65
 
3
66
  // The one definition of an actual mention: `@handle` at a word boundary (not
4
67
  // foo@bar, @@x, \@x or a URL's /@x) and outside code (codeRanges). Every reader of
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eve-hub",
3
- "version": "0.6.42",
3
+ "version": "0.6.44",
4
4
  "license": "Apache-2.0",
5
5
  "description": "Bot-side code shared by every hub agent: registry tools, the roster hook, the eve channel, the host sandbox and browser, and model helpers.",
6
6
  "type": "module",
package/prepare.mjs CHANGED
@@ -101,10 +101,10 @@ export default defineMemory({ namespace: ${JSON.stringify(`hub-${slot}-memory`)}
101
101
  // Replaces the kit's execution-context stub (the local page) with the hosted
102
102
  // page, for a bot the definition says is hosted. Its removed-tools list is read
103
103
  // from the bot's folder as pruned.
104
- function writeExecutionContext(bot, { bot: name, roster, deployment }) {
104
+ function writeExecutionContext(bot, { bot: name, deployment }) {
105
105
  const kitTools = Object.keys(name === "main" ? MAIN_KIT_FILES : KIT_FILES).filter((file) => file.startsWith("tools/") && file !== "tools/bash.ts").map((file) => basename(file, ".ts"));
106
106
  const removed = kitTools.filter((tool) => !existsSync(join(bot, "tools", `${tool}.ts`)));
107
- const section = hostedContext({ bot: name, roster, kit: KIT_VERSION, removed, deployment });
107
+ const section = hostedContext({ bot: name, kit: KIT_VERSION, removed, deployment });
108
108
  mkdirSync(join(bot, "instructions"), { recursive: true });
109
109
  writeFileSync(join(bot, "instructions", "execution-context.ts"), `import { defineInstructions } from "eve/instructions";\n\nexport default defineInstructions({ content: ${JSON.stringify(section)} });\n`);
110
110
  }
@@ -141,11 +141,10 @@ export function prepareDeployment(dir) {
141
141
  writeFileSync(join(bot, stub), content);
142
142
  }
143
143
  }
144
- const roster = resolved.agents;
145
144
  const deployment = deploymentFacts();
146
145
  for (const name of readdirSync(join(dir, "agents"))) {
147
146
  const bot = join(dir, "agents", name, "agent");
148
- writeExecutionContext(bot, { bot: name, roster, deployment });
147
+ writeExecutionContext(bot, { bot: name, deployment });
149
148
  // The kit's extension (kit__worker) runs host-only workers and is excluded from the kit tarball.
150
149
  // Other extensions, such as eve-code, stay.
151
150
  for (const item of readdirSync(bot, { recursive: true })) {
package/stubs.d.mts CHANGED
@@ -1,6 +1,7 @@
1
1
  export const KIT_FILES: Record<string, string[]>;
2
2
  export const MAIN_KIT_FILES: Record<string, string[]>;
3
3
  export const DEPLOYED_KIT_FILES: Set<string>;
4
+ export const MEMORY_STUBS: Record<string, string>;
4
5
  export const PROTOCOL_STUB_FILE: string;
5
6
  export const PROTOCOL_STUB: string;
6
7
  export function kitStub(file: string, options?: { files?: Record<string, string[]> }): string;
package/stubs.mjs CHANGED
@@ -14,6 +14,7 @@ export const KIT_FILES = {
14
14
  "tools/update_thread.ts": ["Update a thread this bot is in: title, track, domain and summary."],
15
15
  "tools/list_threads.ts": ["Every workspace thread, from any session."],
16
16
  "tools/read_thread.ts": ["Read any workspace thread."],
17
+ "tools/list_agents.ts": ["The team right now: kind, place, tools, models, who's busy."],
17
18
  "tools/post_message.ts": ["Post into an existing thread or a bot's DM; with `status`, report the thread's phase."],
18
19
  };
19
20
 
@@ -28,7 +29,6 @@ export const MAIN_KIT_FILES = {
28
29
  "tools/move_path.ts": ["Rename or merge a path (project/track): every thread under it moves."],
29
30
  "tools/archive_thread.ts": ["Archive a thread whose work is done."],
30
31
  "tools/unarchive_thread.ts": ["Bring an archived thread back."],
31
- "tools/list_agents.ts": ["The team right now: roles, models, who's busy."],
32
32
  "tools/create_agent.ts": ["Add a bot to the workspace."],
33
33
  "tools/remove_agent.ts": ["Remove a bot from the workspace."],
34
34
  "tools/restart_agent.ts": ["Restart one bot's server."],
@@ -61,6 +61,7 @@ export const DEPLOYED_KIT_FILES = new Set([
61
61
  "skills/onboarding-profile.ts",
62
62
  "tools/onboarding_scripts.ts",
63
63
  ...["react", "show", "update_thread", "list_threads", "read_thread", "post_message"].map((t) => `tools/${t}.ts`),
64
+ "memory/team.ts",
64
65
  ...["create_thread", "set_summary", "move_path", "archive_thread", "unarchive_thread", "list_agents", "agent_send", "create_agent", "remove_agent", "restart_agent", "tag_threads", "dash_update", "apply_tags"].map((t) => `tools/${t}.ts`),
65
66
  ]);
66
67
 
@@ -97,9 +98,10 @@ import workspace from "../../../../hub.workspace.json" with { type: "json" };
97
98
  export default defineInstructions({ content: executionContext(${JSON.stringify(name)}, workspace) });
98
99
  `;
99
100
 
100
- // Every bot (main too) has two memory slots: memory/workspace.ts recalls the
101
- // workspace's memory/ entries and memory/bot.ts the bot's own
102
- // agents/<bot>/memory/ entries, every turn (packages/agent-kit/memory/).
101
+ // Every bot (main too) has three memory slots: memory/workspace.ts recalls the
102
+ // workspace's memory/ entries, memory/bot.ts the bot's own agents/<bot>/memory/
103
+ // entries, and memory/team.ts the team model from the hub, every turn
104
+ // (packages/agent-kit/memory/).
103
105
  const memoryStub = (slot, what) => `// ${what}, recalled every turn. From eve-hub
104
106
  // (packages/agent-kit/memory/${slot}.ts); add or supersede entries there, not here.
105
107
  export { default } from "eve-hub/memory/${slot}";
@@ -107,6 +109,7 @@ export { default } from "eve-hub/memory/${slot}";
107
109
  export const MEMORY_STUBS = {
108
110
  "memory/workspace.ts": memoryStub("workspace", "The workspace's memory/ entries"),
109
111
  "memory/bot.ts": memoryStub("bot", "This bot's own agents/<bot>/memory/ entries"),
112
+ "memory/team.ts": memoryStub("team", "The team model (roster, tools, handoffs) from the hub"),
110
113
  };
111
114
 
112
115
  // main's instructions aren't copied into a workspace: agent/instructions.ts
package/sync-kit.mjs CHANGED
@@ -65,8 +65,8 @@ export function syncKit({ bots, modules }) {
65
65
  const wanted = new Set(Object.keys(stubs).filter((file) => local || DEPLOYED_KIT_FILES.has(file)));
66
66
  if (local) {
67
67
  wanted.add(EXECUTION_CONTEXT_STUB_FILE);
68
- for (const file of Object.keys(MEMORY_STUBS)) wanted.add(file);
69
68
  }
69
+ for (const file of Object.keys(MEMORY_STUBS)) if (local || DEPLOYED_KIT_FILES.has(file)) wanted.add(file);
70
70
  const content = (file) => (file === EXECUTION_CONTEXT_STUB_FILE ? executionContextStub(bot) : MEMORY_STUBS[file] ?? kitStub(file, { files: stubs }));
71
71
  for (const file of Object.keys(files).sort()) {
72
72
  const rel = file.replace(SUBAGENT, "");
package/team.d.mts ADDED
@@ -0,0 +1,24 @@
1
+ export type TeamNode = {
2
+ id: string;
3
+ kind: "human" | "coordinator" | "planner" | "builder" | "reviewer" | "git-ops" | "demo" | "other";
4
+ label: string;
5
+ color?: string;
6
+ role?: string;
7
+ model?: string;
8
+ runsOn?: "hosted" | "local";
9
+ tools?: string[] | null;
10
+ subagents?: string[];
11
+ };
12
+ export type TeamEdge = { from: string; to: string; kind: string };
13
+ export type TeamModel = { version: 1; hash: string; nodes: TeamNode[]; edges: TeamEdge[] };
14
+ export type TeamAgent = { name: string; role: string; model: string; color?: string; kind?: string; runsOn?: "hosted" | "local"; subagents?: Record<string, unknown> };
15
+ export const KINDS: readonly string[];
16
+ export const MODEL_VERSION: 1;
17
+ export const NODE_KINDS: readonly [string, ...string[]];
18
+ export const EDGE_KINDS: readonly [string, ...string[]];
19
+ export const SECTIONS: readonly { id: string; edge: { kind: string; from: unknown; to: unknown }; text: string }[];
20
+ export const SECTIONS_HASH: string;
21
+ export function teamEdges(nodes: readonly TeamNode[]): TeamEdge[];
22
+ export function teamModel(input: { agents: readonly TeamAgent[]; humans?: readonly { name: string; color?: string }[]; tools?: Record<string, readonly string[] | null | undefined> }): TeamModel;
23
+ export function teamText(model: TeamModel): string;
24
+ export function teamRecall(history: readonly unknown[], fetchTeam: (since?: string) => Promise<TeamModel | null>): Promise<{ messages: { id: string; content: string }[] }>;
package/team.mjs ADDED
@@ -0,0 +1,113 @@
1
+ // The team model: who is on the team, what each bot can do, and who hands work
2
+ // to whom. One derivation feeds every reader: the hub's GET /api/team (the
3
+ // native Team window, list_agents) and the prompt's team slot (memory/team.ts),
4
+ // which delivers it to a session once and again only when it changes.
5
+ //
6
+ // A bot's `kind` is its place in the team (hub.workspace.json). Its tools are
7
+ // what its running eve reports (/v1/info), never read from files. The edges
8
+ // come from SECTIONS: each protocol section names the edge it draws between
9
+ // kinds (or a tool), and the same entry renders the sentence the bots read.
10
+ import { createHash } from "node:crypto";
11
+ import { KINDS } from "./workspace.mjs";
12
+
13
+ export { KINDS };
14
+ export const MODEL_VERSION = 1;
15
+ export const NODE_KINDS = [...KINDS, "other", "human"];
16
+
17
+ const sha = (text, length) => createHash("sha256").update(text).digest("hex").slice(0, length);
18
+
19
+ // Selectors: "human", "bots" (every bot), { kind: [...] } or { tool: "agent_send" };
20
+ // all but the first pick bots. `text` is the sentence the team prompt carries when the section
21
+ // has at least one edge; `{from}` is the names behind the edge's from side.
22
+ export const SECTIONS = [
23
+ { id: "partner", edge: { kind: "partner", from: "human", to: { kind: ["coordinator"] } }, text: "The coordinator works with the human directly." },
24
+ { id: "route", edge: { kind: "route", from: { kind: ["coordinator"] }, to: { kind: ["planner", "builder"] } }, text: "The coordinator routes work to planners and builders." },
25
+ { id: "plan", edge: { kind: "plan", from: { kind: ["planner"] }, to: { kind: ["builder"] } }, text: "Planners hand plans to builders." },
26
+ { id: "build", edge: { kind: "build", from: { kind: ["builder"] }, to: { kind: ["reviewer"] } }, text: "Builders hand finished work to a reviewer." },
27
+ { id: "fixfirst", edge: { kind: "fixfirst", from: { kind: ["reviewer"] }, to: { kind: ["builder"] } }, text: "Reviewers send fixes back to the builder that posted the build." },
28
+ { id: "ship", edge: { kind: "ship", from: { kind: ["reviewer"] }, to: "human" }, text: "A reviewer's ship verdict goes to the human." },
29
+ { id: "escalate", edge: { kind: "escalate", from: { kind: ["git-ops"] }, to: { kind: ["builder"] } }, text: "Git-ops bots report what they can't fix to a builder." },
30
+ { id: "steer", edge: { kind: "steer", from: { tool: "agent_send" }, to: "bots" }, text: "Only {from} can queue work for or steer another bot (`agent_send`)." },
31
+ ];
32
+ export const EDGE_KINDS = SECTIONS.map((section) => section.edge.kind);
33
+ export const SECTIONS_HASH = sha(JSON.stringify(SECTIONS), 8);
34
+
35
+ const pick = (selector, nodes) =>
36
+ nodes.filter((n) => {
37
+ if (selector === "human") return n.kind === "human";
38
+ if (n.kind === "human") return false;
39
+ return selector === "bots" || (selector.kind ? selector.kind.includes(n.kind) : Boolean(n.tools?.includes(selector.tool)));
40
+ });
41
+
42
+ export function teamEdges(nodes) {
43
+ const edges = [];
44
+ for (const { edge } of SECTIONS) {
45
+ const from = pick(edge.from, nodes);
46
+ const to = pick(edge.to, nodes);
47
+ for (const f of from) for (const t of to) if (f.id !== t.id) edges.push({ from: f.id, to: t.id, kind: edge.kind });
48
+ }
49
+ return edges;
50
+ }
51
+
52
+ // `agents`: hub.workspace.json's agents plus `runsOn` resolved; `tools`: bot
53
+ // name -> the tool names its eve reports, absent or null when not reported.
54
+ export function teamModel({ agents, humans = [], tools = {} }) {
55
+ const nodes = [
56
+ ...humans.map((h) => ({ id: h.name, kind: "human", label: h.name, ...(h.color ? { color: h.color } : {}) })),
57
+ ...agents.map((a) => ({
58
+ id: a.name,
59
+ kind: a.kind ?? "other",
60
+ label: a.name,
61
+ ...(a.color ? { color: a.color } : {}),
62
+ role: a.role,
63
+ model: a.model,
64
+ runsOn: a.runsOn ?? "hosted",
65
+ tools: tools[a.name] ? [...new Set(tools[a.name])].sort() : null,
66
+ subagents: Object.keys(a.subagents ?? {}),
67
+ })),
68
+ ];
69
+ const edges = teamEdges(nodes);
70
+ return { version: MODEL_VERSION, hash: sha(JSON.stringify({ nodes, edges }), 12), nodes, edges };
71
+ }
72
+
73
+ const list = (items) => items.join(", ");
74
+
75
+ // What a session reads: the roster with each bot's kind, place and tools, then
76
+ // the handoff sentences. Identical for every bot, so one slot text serves the
77
+ // whole team. The header carries both hashes: the model's and the sections'.
78
+ export function teamText(model) {
79
+ const bots = model.nodes.filter((n) => n.kind !== "human");
80
+ const reported = bots.filter((b) => b.tools);
81
+ const shared = reported.length ? reported[0].tools.filter((t) => reported.every((b) => b.tools.includes(t))) : [];
82
+ const lines = [`Team model ${model.hash}/${SECTIONS_HASH}: the team right now, from the hub. It replaces any earlier team model above.`, "", "Bots:"];
83
+ for (const b of bots) {
84
+ const own = b.tools?.filter((t) => !shared.includes(t));
85
+ const where = b.runsOn === "local" ? "on the owner's Mac" : "on Vercel";
86
+ lines.push(`- ${b.id} (${b.kind}, ${where}): ${b.role}${own?.length ? ` Also: ${list(own)}.` : ""}${b.tools ? "" : " Tools not reported."}${b.subagents.length ? ` Subagents: ${list(b.subagents)}.` : ""}`);
87
+ }
88
+ if (shared.length) lines.push("", `Every bot with reported tools has: ${list(shared)}.`);
89
+ const rules = SECTIONS.flatMap(({ id, edge, text }) => {
90
+ const from = model.edges.filter((e) => e.kind === edge.kind).map((e) => e.from);
91
+ return from.length ? [`- ${text.replace("{from}", list([...new Set(from)]))}`] : [];
92
+ });
93
+ if (rules.length) lines.push("", "Handoffs:", ...rules);
94
+ return lines.join("\n");
95
+ }
96
+
97
+ const textOf = (m) => (typeof m.content === "string" ? m.content : Array.isArray(m.content) ? m.content.map((p) => (p?.type === "text" ? p.text : "")).join("") : "");
98
+ const HEADER = /^Team model ([0-9a-f]{12})\/([0-9a-f]{8}):/;
99
+
100
+ // The team slot's recall, given the session's history: the team model as one
101
+ // message when it differs from the last one this session was given, nothing
102
+ // otherwise. `fetchTeam(since)` asks the hub, with the model hash the session
103
+ // holds (only while these sections are the ones that rendered it), and answers
104
+ // the model, or null when the hub says nothing changed.
105
+ export async function teamRecall(history, fetchTeam) {
106
+ const given = history.filter((m) => m?.role === "user" && m.kind === "memory.load" && HEADER.test(textOf(m))).map(textOf);
107
+ const last = HEADER.exec(given.at(-1) ?? "");
108
+ const model = await fetchTeam(last && last[2] === SECTIONS_HASH ? last[1] : undefined);
109
+ if (!model) return { messages: [] };
110
+ const text = teamText(model);
111
+ if (given.at(-1) === text) return { messages: [] };
112
+ return { messages: [{ id: `team#${given.length + 1}@${sha(text, 8)}`, content: text }] };
113
+ }
@@ -2,6 +2,9 @@ declare const _default: import("eve/tools").ToolDefinition<Record<string, never>
2
2
  name: string;
3
3
  role: string;
4
4
  model: string;
5
+ kind?: string;
6
+ runsOn?: "hosted" | "local";
7
+ tools?: string[] | null;
5
8
  subagentCap: string;
6
9
  subagents: Array<{
7
10
  name: string;
@@ -31,6 +34,9 @@ declare const _default: import("eve/tools").ToolDefinition<Record<string, never>
31
34
  name: string;
32
35
  role: string;
33
36
  model: string;
37
+ kind?: string;
38
+ runsOn?: "hosted" | "local";
39
+ tools?: string[] | null;
34
40
  subagentCap: string;
35
41
  subagents: Array<{
36
42
  name: string;
@@ -2,7 +2,7 @@ import { defineTool } from "eve/tools";
2
2
  import { z } from "zod";
3
3
  import { registryFetch } from "../registry-fetch.mjs";
4
4
  export default defineTool({
5
- description: "List the workspace's bots right now: each one's role, model, the model and reasoning each of its declared subagents and its built-in `agent` copies resolve to under the workspace subagent cap (clamped from hub.workspace.json, never rejected), whether it's idle or busy (and where; a bot's threads run in parallel, so busy elsewhere doesn't mean it can't start work in a new thread), its server's memory (process.rssMb) and uptime (process.uptimeS; only on a local hub, since a hosted hub runs no bot process to measure), and any restart in progress (restart_agent, or the registry recycling a bot that grew too big or ran too long). The team changes; check it before routing work.",
5
+ description: "List the workspace's bots right now: each one's role, kind (its place in the team: coordinator, planner, builder, reviewer, git-ops, demo, or other), where it runs (hosted or local), the tools its runtime reports (null when not yet reported), its model, the model and reasoning each of its declared subagents and its built-in `agent` copies resolve to under the workspace subagent cap (clamped from hub.workspace.json, never rejected), whether it's idle or busy (and where; a bot's threads run in parallel, so busy elsewhere doesn't mean it can't start work in a new thread), its server's memory (process.rssMb) and uptime (process.uptimeS; only on a local hub, since a hosted hub runs no bot process to measure), and any restart in progress (restart_agent, or the registry recycling a bot that grew too big or ran too long). The team changes; check it before routing work.",
6
6
  inputSchema: z.object({}),
7
7
  async execute() {
8
8
  // Anything but a 2xx list of bots is an error naming the status and the
package/workspace.mjs CHANGED
@@ -47,9 +47,11 @@ export const DOMAIN = /^[a-z][a-z0-9-]*(\/[a-z][a-z0-9-]*)*$/;
47
47
  export const NAME = /^[a-z][a-z0-9-]{1,30}$/;
48
48
  export const HUMAN_NAME = /^[a-z][a-z0-9-]{0,30}$/;
49
49
  const COLOR = /^#[0-9a-f]{3}([0-9a-f]{3})?$/i;
50
- const AGENT_KEYS = new Set(["name", "model", "role", "color", "subagents", "subagentCap", "limits", "port", "runsOn", "domains"]);
50
+ const AGENT_KEYS = new Set(["name", "model", "role", "color", "subagents", "subagentCap", "limits", "port", "runsOn", "domains", "kind"]);
51
51
 
52
52
  export const RUNS_ON = ["hosted", "local"];
53
+ // The bot's place in the team (team.mjs draws the handoffs between kinds).
54
+ export const KINDS = ["coordinator", "planner", "builder", "reviewer", "git-ops", "demo"];
53
55
 
54
56
  export class WorkspaceError extends Error {}
55
57
 
@@ -102,6 +104,7 @@ export function parseWorkspace(data, file = WORKSPACE_FILE) {
102
104
  // Where the bot runs: on the hosted bots project (default), or on the human's Mac, started by
103
105
  // the app's local runner. Fixed at definition.
104
106
  if (agent.runsOn !== undefined && !RUNS_ON.includes(agent.runsOn)) fail(file, `${label}.runsOn must be "hosted" or "local"`);
107
+ if (agent.kind !== undefined && !KINDS.includes(agent.kind)) fail(file, `${label}.kind must be one of ${KINDS.join(", ")}`);
105
108
  if (agent.domains !== undefined && (!Array.isArray(agent.domains) || agent.domains.some((d) => typeof d !== "string" || !DOMAIN.test(d)))) fail(file, `${label}.domains must be an array of memory folders like "ui" or "ui/motion"`);
106
109
  const unknown = Object.keys(agent).filter((k) => !AGENT_KEYS.has(k));
107
110
  if (unknown.length) fail(file, `${label}: unknown field(s) ${unknown.join(", ")}`);