eve-hub 0.5.1-pre.1 → 0.5.2

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.
@@ -0,0 +1,17 @@
1
+ import type { CapAgent, Clamped, SubagentCap } from './subagent-cap.mjs';
2
+ export type RosterAgent = CapAgent & { model: string };
3
+ export type Selection = { model: string; reasoning?: string };
4
+ export type SubagentModels = {
5
+ subagentCap: string;
6
+ subagents: Array<{ name: string; model: string; clamped?: string[] }>;
7
+ agentCopies: { model: string; clamped?: string[] };
8
+ };
9
+ export const CONTEXT_WINDOW: number;
10
+ export function parseModel(label?: string): Selection;
11
+ export function formatModel(selection: Selection): string;
12
+ export function ownModel(agents: readonly RosterAgent[], name: string): Selection;
13
+ export function copyModel(agents: readonly RosterAgent[], cap: SubagentCap, name: string): Clamped;
14
+ export function subagentModels(agents: readonly RosterAgent[], cap: SubagentCap, name: string): SubagentModels;
15
+ export function isAgentCopy(ctx: unknown): boolean;
16
+ export function modelOptionsFor(model: string): { providerOptions: Record<string, unknown> } | undefined;
17
+ export function selectModel(agents: readonly RosterAgent[], cap: SubagentCap, name: string, ctx: unknown): Selection & { modelContextWindowTokens: number; modelOptions?: unknown; copy: boolean };
package/bot-model.mjs CHANGED
@@ -6,7 +6,7 @@
6
6
  // Built-in `agent` copies of a bot (eve's root-copy tool) run under the same
7
7
  // subagent cap as declared subagents: the bot's own model and reasoning,
8
8
  // clamped (never rejected) to the cap.
9
- import { capFor, clampSubagent } from "./subagent-cap.mjs";
9
+ import { capFor, clampSubagent, resolveSubagents } from "./subagent-cap.mjs";
10
10
 
11
11
  export const CONTEXT_WINDOW = 200_000;
12
12
 
@@ -31,6 +31,18 @@ export function copyModel(agents, cap, name) {
31
31
  return clampSubagent(ownModel(agents, name), capFor(agents, cap, name));
32
32
  }
33
33
 
34
+ // What list_agents reports `name`'s cap, declared subagents and `agent` copies
35
+ // run on, on the local and hosted hubs alike.
36
+ export function subagentModels(agents, cap, name) {
37
+ const notes = ({ clamped }) => (clamped.length ? { clamped } : {});
38
+ const copy = copyModel(agents, cap, name);
39
+ return {
40
+ subagentCap: formatModel(capFor(agents, cap, name)),
41
+ subagents: resolveSubagents(agents, cap, name).map((s) => ({ name: s.name, model: formatModel(s), ...notes(s) })),
42
+ agentCopies: { model: formatModel(copy), ...notes(copy) },
43
+ };
44
+ }
45
+
34
46
  // eve runs a root copy as a session on the "subagent" channel; a bot's own
35
47
  // sessions come in over its eve/http channel. (ctx.session.parent is not
36
48
  // populated in dynamic-model resolvers, so the channel is the signal.)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eve-hub",
3
- "version": "0.5.1-pre.1",
3
+ "version": "0.5.2",
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",
@@ -18,7 +18,9 @@
18
18
  // (plain node) import the same module.
19
19
 
20
20
  // Read per call: a test (or the daemon) can point it at another registry.
21
- export const registryBase = () => process.env.ROSTER_REGISTRY_URL ?? "http://127.0.0.1:3100";
21
+ // Without its trailing slash: every path starts with one, and `//api/...`
22
+ // is a different route (a 404 on the hosted hub).
23
+ export const registryBase = () => (process.env.ROSTER_REGISTRY_URL ?? "http://127.0.0.1:3100").replace(/\/+$/, "");
22
24
 
23
25
  // Also used by the sandbox's node:http transport, which distinguishes
24
26
  // requests that never connected from ones whose result may have been lost.
@@ -1,91 +1,226 @@
1
1
  // main's tracks schedule (schedules/tracks.ts): main leads every track (a
2
- // path below its project, hub/native; registry: snap.tracks keyed by path,
3
- // thread.trackId = its path) by keeping
4
- // its free-form summary current with set_summary. Every 10 minutes, with no
5
- // model call, this looks at the threads filed in open tracks; only when one
6
- // of them has a new message since the last pass does it post one notice in
7
- // sch-main-tracks tagging main, listing the tracks that moved. What the
8
- // summary says, and whether it changes at all, is main's call.
2
+ // path below its project, hub/native; registry: snap.tracks keyed by path)
3
+ // by keeping its free-form summary current with set_summary. A main turn is
4
+ // expensive, so every 10 minutes this decides, per thread, whether there is
5
+ // anything to summarize before waking main:
9
6
  //
10
- // State: { seen: { [threadId]: { m: its newest said message, p: its path } } }.
11
- // A thread never seen counts as moved, so the first pass asks for every
12
- // track's first summary.
7
+ // facts code only gathers facts, from the one snapshot read: the latest
8
+ // human message, the bot messages since it, the latest bot
9
+ // message, the latest bot event of any kind (registry session
10
+ // lastEventAt, nested subagent runs included), who's working, and
11
+ // when the thread's track (or the thread) was last summarized.
12
+ // ask a thread is asked about when something happened since that
13
+ // summary and its basis (newest message, summary time, who's
14
+ // working) differs from the one it was last asked on. Each
15
+ // goes to the model (DECISION_MODEL) in its own evaluation, all
16
+ // in parallel, with its track's summary and the messages since
17
+ // it: does the summary already state them (missing, stated,
18
+ // chatter), and what changed. No thresholds in code. Each verdict is kept against its basis (state.seen):
19
+ // the model is close to even on borderline threads, so asking
20
+ // again on the same facts would wake main by chance, not news.
21
+ // wake only threads whose verdict is `missing` reach main, in one post
22
+ // in sch-main-tracks with the answers and facts.
23
+ //
24
+ // A failed or invalid evaluation posts and saves nothing: the next tick asks
25
+ // again. Without `evaluate` (tests) nothing is asked and nobody is woken.
13
26
  //
14
27
  // Plain JS on purpose: the schedule (TypeScript, bundled by eve) and the
15
28
  // tests (plain node) import the same module.
16
29
  import { runOnce } from "./hub.mjs";
30
+ import { DECISION_MODEL } from "./dash-classify.mjs";
31
+ import { clip, flat, said } from "./thread-brief.mjs";
17
32
 
18
- // What counts as a new message: something a bot or the human said, not a
19
- // hub event (join/leave, schedule notes) or a system post.
20
- const said = (m) => !!m && m.authorKind !== "event" && !m.event && m.author !== "system";
21
33
  // A track chat is main's own conversation with the human; it doesn't move the track.
22
34
  const counted = (t) => !!t && t.kind === "thread" && !t.chatFor;
23
35
  const pathOf = (t) => t.tags?.path ?? t.trackId ?? null;
24
- // Every open track a path is on: itself and each ancestor that's a track, so
25
- // a message on hub/native/composer moves hub/native too.
26
- const tracksOn = (p, tracks) => (p ? p.split("/").map((_, i, a) => a.slice(0, i + 1).join("/")).filter((k) => tracks[k] && tracks[k].open !== false) : []);
36
+ const newest = (...ats) => ats.filter(Boolean).sort().at(-1) ?? null;
37
+ const isHuman = (m, humans) => m.authorKind === "human" || humans.has(m.author);
38
+
39
+ // The messages since the summary the model reads, newest last: news (a
40
+ // merge) is often followed by acks, so the latest message alone misses it.
41
+ export const RECENT = 6;
42
+
43
+ // The facts for one thread on an open track.
44
+ export function threadFacts(t, snap, track) {
45
+ const humans = new Set((snap?.humans ?? []).map((h) => h.name));
46
+ const ms = (t.messages ?? []).filter(said);
47
+ let h = ms.length - 1;
48
+ while (h >= 0 && !isHuman(ms[h], humans)) h--;
49
+ const human = h >= 0 ? ms[h] : null;
50
+ const bot = ms.findLast((m) => !isHuman(m, humans)) ?? null;
51
+ const sessions = Object.values(snap?.sessions ?? {}).filter((s) => s?.threadId === t.id);
52
+ const summaryAt = newest(track.summary?.at, t.summary?.at);
53
+ const since = ms.filter((m) => !summaryAt || m.at > summaryAt);
54
+ return {
55
+ id: t.id,
56
+ title: t.title ?? t.id,
57
+ track: track.id,
58
+ trackTitle: track.title ?? track.id,
59
+ archived: !!t.archived,
60
+ phase: t.work?.phase ?? null,
61
+ working: Object.entries(t.status ?? {}).filter(([, s]) => s === "working").map(([b]) => b).sort(),
62
+ human: human && { id: human.id, author: human.author, at: human.at, text: human.text },
63
+ botsSinceHuman: ms.length - 1 - h,
64
+ bot: bot && { id: bot.id, author: bot.author, at: bot.at, text: bot.text },
65
+ lastId: ms.at(-1)?.id ?? null,
66
+ lastAt: ms.at(-1)?.at ?? null,
67
+ eventAt: newest(...sessions.map((s) => s.lastEventAt), ...Object.values(t.busySince ?? {})),
68
+ summaryAt,
69
+ sinceSummary: since.length,
70
+ recent: since.slice(-RECENT).map((m) => ({ id: m.id, author: m.author, at: m.at, text: m.text })),
71
+ summary: track.summary?.text ? { text: track.summary.text, by: track.summary.by ?? null } : null,
72
+ };
73
+ }
74
+
75
+ // What a verdict was made on: a thread is asked again once this differs.
76
+ // The bots' event time isn't in it: it moves every tick while a bot works.
77
+ export const basisOf = (f) => [f.lastId, f.summaryAt ?? "", f.working.join(",")].join("|");
27
78
 
28
- // Tracks (open ones) whose threads have a message newer than `seen`, or that
29
- // a thread was filed into or moved out of since then.
30
- // -> { moved: [{ track, threads: [{ id, title, archived, fresh, lastBy, from?, to? }] }], seen }
31
- // seen: { [threadId]: { m: newest said message id, p: its path } } (a bare
32
- // message id, from before paths moved, reads as "on its current path").
33
- export function tracksMoved(snap, seen = {}) {
79
+ // Every filed thread on an open track, as facts; and of those, the ones to
80
+ // ask about: activity since the last summary, on a basis not yet asked
81
+ // about (seen: { [threadId]: basis }).
82
+ export function tracksFacts(snap, seen = {}) {
34
83
  const tracks = snap?.tracks ?? {};
35
- const next = {};
36
- const byTrack = new Map();
84
+ const all = [];
37
85
  for (const t of Object.values(snap?.threads ?? {})) {
38
86
  if (!counted(t)) continue;
39
- const p = pathOf(t);
40
- const was = typeof seen[t.id] === "string" ? { m: seen[t.id], p } : seen[t.id];
41
- const ms = (t.messages ?? []).filter(said);
42
- const last = ms.at(-1);
43
- const on = tracksOn(p, tracks);
44
- if (on.length && last) next[t.id] = { m: last.id, p };
45
- const fresh = !last || was?.m === last.id ? 0 : was?.m ? Math.max(0, ms.length - 1 - ms.findIndex((m) => m.id === was.m)) : ms.length;
46
- const refiled = !!was && was.p !== p;
47
- if (!fresh && !refiled) continue;
48
- const affected = new Set([...on, ...(refiled ? tracksOn(was.p, tracks) : [])]);
49
- if (!affected.size) continue;
50
- const entry = { id: t.id, title: t.title ?? t.id, archived: !!t.archived, fresh, lastBy: last?.author ?? null, lastAt: last?.at ?? null, ...(refiled ? { from: was.p ?? null, to: p } : {}) };
51
- for (const k of affected) {
52
- if (!byTrack.has(k)) byTrack.set(k, []);
53
- byTrack.get(k).push(entry);
54
- }
87
+ const track = tracks[pathOf(t)];
88
+ if (track && track.open !== false) all.push(threadFacts(t, snap, track));
55
89
  }
56
- const moved = [...byTrack].map(([id, threads]) => ({ track: tracks[id], threads: threads.sort((a, b) => String(b.lastAt).localeCompare(String(a.lastAt))) }));
57
- moved.sort((a, b) => String(a.track.projectId).localeCompare(String(b.track.projectId)) || String(a.track.id).localeCompare(String(b.track.id)));
58
- return { moved, seen: next };
90
+ const since = (f) => !f.summaryAt || [f.lastAt, f.eventAt].some((at) => at && at > f.summaryAt);
91
+ const ask = all.filter((f) => f.lastId && since(f) && seen[f.id] !== basisOf(f));
92
+ ask.sort((a, b) => a.track.localeCompare(b.track) || String(b.lastAt).localeCompare(String(a.lastAt)));
93
+ return { all, ask };
59
94
  }
60
95
 
61
96
  const ago = (at, now) => {
97
+ if (!at) return "never";
62
98
  const min = Math.max(0, Math.round((now - Date.parse(at)) / 60_000));
63
99
  return min < 60 ? `${min}m ago` : min < 48 * 60 ? `${Math.round(min / 60)}h ago` : `${Math.round(min / 1440)}d ago`;
64
100
  };
65
101
 
66
- export function tracksNotice(moved, now = Date.now()) {
102
+ export const FACT_CHARS = { summary: 2000, human: 400, bot: 600, recent: 500 };
103
+
104
+ // One track's summary as the model reads it, once for all its threads.
105
+ export function trackText(f, now) {
106
+ return `## Track ${f.track} ("${f.trackTitle}")\nTrack summary: ${f.summary ? `written ${ago(f.summaryAt, now)} by ${f.summary.by ?? "?"}:\n${clip(String(f.summary.text).trim(), FACT_CHARS.summary)}` : "none yet"}`;
107
+ }
108
+
109
+ // One thread's facts as the model reads them.
110
+ export function factsText(f, now) {
111
+ const msg = (m, n) => (m ? `${m.author}, ${ago(m.at, now)}: ${clip(flat(m.text), n)}` : "none");
112
+ return [
113
+ `### Thread ${f.id}: "${f.title}"${f.archived ? " (archived)" : ""}`,
114
+ `Latest human message: ${msg(f.human, FACT_CHARS.human)}`,
115
+ `Bot messages since the latest human message: ${f.botsSinceHuman}`,
116
+ `Latest bot message: ${msg(f.bot, FACT_CHARS.bot)}`,
117
+ `Messages since the track summary was written: ${f.sinceSummary}${f.sinceSummary > f.recent.length ? ` (the last ${f.recent.length} below)` : ""}`,
118
+ ...f.recent.map((m) => `- ${msg(m, FACT_CHARS.recent)}`),
119
+ `Latest bot activity of any kind (tool calls, subagents included): ${ago(f.eventAt, now)}`,
120
+ `Bots working now: ${f.working.join(", ") || "none"}${f.phase ? ` · phase the bots reported: ${f.phase}` : ""}`,
121
+ ].join("\n");
122
+ }
123
+
124
+ // The verdict: one factual question, does the track summary already state
125
+ // what this thread's latest messages say? Only `missing` wakes main.
126
+ export const VERDICT = {
127
+ missing: "The thread's latest messages state where the work stands (a decision, a plan, a PR opened, pushed, reviewed or merged, work finished, a problem, a question for the human) and the track summary doesn't state it, or states an older status (e.g. the summary calls a PR a draft or awaiting review and the thread says it merged). Also when the track has no summary yet.",
128
+ stated: "The track summary already states it, including the current status of every PR and decision the messages mention. A summary that names the PR but gives an older status doesn't count.",
129
+ chatter: "The messages don't state anything about where the work stands: acks, progress narration, a bot handing off to another bot mid-task with no outcome yet.",
130
+ };
131
+ export const wakes = (d) => d.verdict === "missing";
132
+ export const CHANGE = {
133
+ nothing: "Nothing that matters changed.",
134
+ progress: "Work moved forward; nothing settled yet.",
135
+ plan: "A plan or approach was posted or changed.",
136
+ needs_human: "A bot now waits on the human: a question, a decision or a sign-off.",
137
+ decided: "The human decided, approved or redirected something.",
138
+ pr: "A PR was opened, pushed, reviewed or its CI changed.",
139
+ landed: "Work was merged, shipped or finished.",
140
+ problem: "Something broke, failed or got stuck.",
141
+ };
142
+ // The evaluation for one thread: its track's summary, then its facts.
143
+ // -> { state, questions }
144
+ export function threadQuestions(f, now) {
145
+ const questions = {
146
+ verdict: { type: "choice", instructions: "Does the track summary already state what this thread's messages since it say, with each PR's and decision's current status?", criteria: VERDICT },
147
+ change: { type: "choice", instructions: "What changed in this thread since the track summary was written?", criteria: CHANGE },
148
+ };
149
+ const state = [
150
+ "A chat thread where AI bots work for a human. It sits on a track (a feature); main keeps a short summary per track so the human sees what's up without reading the threads. Compare the thread's messages with the track summary's text: answer from what the summary says, not from how long ago it was written. Times are relative to now.",
151
+ trackText(f, now),
152
+ factsText(f, now),
153
+ ].join("\n\n");
154
+ return { state, questions };
155
+ }
156
+
157
+ // Ask about each thread in `ask`, one evaluation per thread, all at once:
158
+ // given every thread in one evaluation, the model answered for the wrong
159
+ // thread (merges in a thread's latest message read as already stated). -> { decided: [{ ...facts,
160
+ // verdict, change }], usage, ms }, or null when a call failed or an answer
161
+ // is invalid.
162
+ export async function decideTracks(ask, { evaluate, model = DECISION_MODEL, now = Date.now(), timeoutMs = 30_000, log = console }) {
163
+ const started = Date.now();
164
+ let rs;
165
+ try {
166
+ rs = await Promise.all(ask.map((f) => evaluate({ model, ...threadQuestions(f, now), maxRetries: 1, abortSignal: AbortSignal.timeout(timeoutMs) })));
167
+ } catch (err) {
168
+ log?.warn?.(`tracks: evaluation failed, tick skipped: ${err?.message ?? err}`);
169
+ return null;
170
+ }
171
+ const ms = Date.now() - started;
172
+ const pick = (r, q, criteria) => {
173
+ const a = r?.answers?.[q];
174
+ return a?.type === "choice" && Object.hasOwn(criteria, a.choice) ? a.choice : null;
175
+ };
176
+ const decided = ask.map((f, i) => ({ ...f, verdict: pick(rs[i], "verdict", VERDICT), change: pick(rs[i], "change", CHANGE) }));
177
+ if (decided.some((d) => !d.verdict || !d.change)) {
178
+ log?.warn?.("tracks: invalid answer, tick skipped");
179
+ return null;
180
+ }
181
+ const usage = { inputTokens: rs.reduce((n, r) => n + (r?.usage?.inputTokens ?? 0), 0) };
182
+ log?.log?.(`tracks: asked about ${ask.length} threads in ${ms} ms (${usage.inputTokens} input tokens), ${decided.filter(wakes).length} need an update`);
183
+ return { decided, usage, ms };
184
+ }
185
+
186
+ // The post that wakes main: only the threads that need an update, by track,
187
+ // each with the model's answers and its facts.
188
+ export function tracksNotice(decided, now = Date.now()) {
67
189
  const lines = [
68
- "@main These tracks have new messages since the last pass. You lead every track: keep its summary current with `set_summary` (scope `path:<path>`), a short markdown note of what's up, written for the human. Read what moved (`read_thread`), decide what's worth saying, and leave a summary alone when nothing that matters changed. Reply here with one line; mention nobody.",
69
- "",
190
+ "@main These threads need their track summary updated; the tracks classifier skipped the rest. Keep each track's summary current with `set_summary` (scope `path:<path>`), a short markdown note of what's up, written for the human. Read the thread (`read_thread`) where the facts below aren't enough. Reply here with one line; mention nobody.",
70
191
  ];
71
- for (const { track, threads } of moved) {
72
- const s = track.summary;
73
- lines.push(`- **${track.title}** (\`${track.id}\`, project ${track.projectId}): ${s?.at ? `summary written ${ago(s.at, now)} by ${s.by}` : "no summary yet"}`);
74
- for (const t of threads) {
75
- const filed = t.from !== undefined ? `, ${!(t.to === track.id || t.to?.startsWith(`${track.id}/`)) ? `moved out to ${t.to ?? "no path"}` : `filed here${t.from ? ` from ${t.from}` : ""}`}` : "";
76
- lines.push(` - [${t.id}](${t.id}) ${t.title}: ${t.fresh} new${t.lastBy ? `, latest by ${t.lastBy}` : ""}${filed}${t.archived ? " (archived)" : ""}`);
192
+ let track = null;
193
+ for (const d of decided) {
194
+ if (d.track !== track) {
195
+ track = d.track;
196
+ lines.push("", `- **${d.trackTitle}** (\`${d.track}\`): ${d.summary ? `summary written ${ago(d.summaryAt, now)}` : "no summary yet"}`);
77
197
  }
198
+ const human = d.human ? `human ${ago(d.human.at, now)}, ${d.botsSinceHuman} bot messages since` : `no human message, ${d.botsSinceHuman} bot messages`;
199
+ lines.push(` - [${d.id}](${d.id}) ${d.title}: ${d.verdict}, changed: ${d.change}; ${human}; latest message ${ago(d.lastAt, now)}${d.working.length ? `; ${d.working.join(", ")} working` : ""}${d.archived ? " (archived)" : ""}`);
78
200
  }
79
201
  return lines.join("\n");
80
202
  }
81
203
 
82
- // One pass. Saves the new `seen` whenever it differs (a thread unfiled or
83
- // gone drops out), posts only when a track moved.
84
- export async function runTracks(hub, { now = Date.now() } = {}) {
85
- return runOnce(hub, "tracks", (snap, saved) => {
204
+ // One tick. State: { seen: { [threadId]: basis of its last verdict } }, kept
205
+ // only for threads still on an open track.
206
+ // -> { posts, state, decided, usage, ms }
207
+ /** @param {any} hub @param {{ evaluate?: (options: any) => Promise<any>, now?: number, log?: any, model?: string }} [opts] */
208
+ export async function runTracks(hub, { evaluate, now = Date.now(), log = console, model } = {}) {
209
+ let tick = { decided: [], usage: null, ms: 0 };
210
+ const result = await runOnce(hub, "tracks", async (snap, saved) => {
86
211
  const prev = saved?.seen ?? {};
87
- const { moved, seen } = tracksMoved(snap, prev);
212
+ const { all, ask } = tracksFacts(snap, prev);
213
+ if (ask.length && evaluate) {
214
+ const out = await decideTracks(ask, { evaluate, model, now, log });
215
+ if (!out) return { posts: [], state: saved, changed: false };
216
+ tick = out;
217
+ }
218
+ const live = new Set(all.map((f) => f.id));
219
+ const seen = Object.fromEntries(Object.entries(prev).filter(([id]) => live.has(id)));
220
+ for (const d of tick.decided) seen[d.id] = basisOf(d);
221
+ const wake = tick.decided.filter(wakes);
88
222
  const changed = JSON.stringify(Object.entries(seen).sort()) !== JSON.stringify(Object.entries(prev).sort());
89
- return { posts: moved.length ? [tracksNotice(moved, now)] : [], state: { seen }, changed };
223
+ return { posts: wake.length ? [tracksNotice(wake, now)] : [], state: { seen }, changed };
90
224
  }, { now });
225
+ return { ...result, ...tick };
91
226
  }
@@ -1,14 +1,16 @@
1
- // main's tracks schedule: every 10 minutes, with no model call, checks the
2
- // threads filed in open tracks and, only when one has a new message since the
3
- // last pass, tags main in sch-main-tracks with the tracks that moved. main
4
- // then rewrites those tracks' summaries with set_summary, deciding itself
5
- // what's worth saying. The pass: runTracks in ./tracks-lead.mjs.
1
+ // main's tracks schedule: every 10 minutes, gathers facts on the threads filed
2
+ // in open tracks and asks openai/gpt-6-luna-decisions, one `evaluate` per
3
+ // thread (eve's), which of them need their track summary updated.
4
+ // Only those tag main in sch-main-tracks, with the model's answers; main then
5
+ // rewrites the summaries with set_summary. The pass: runTracks in
6
+ // ./tracks-lead.mjs.
6
7
  import { defineSchedule } from "eve/schedules";
8
+ import { evaluate } from "eve/ai";
7
9
  import { createHub } from "./hub.mjs";
8
10
  import { runTracks } from "./tracks-lead.mjs";
9
11
  export default defineSchedule({
10
12
  cron: "*/10 * * * *",
11
13
  async run() {
12
- await runTracks(createHub());
14
+ await runTracks(createHub(), { evaluate });
13
15
  },
14
16
  });
@@ -0,0 +1,10 @@
1
+ export type Requested = { model?: string; reasoning?: string };
2
+ export type SubagentCap = Requested & { models?: readonly string[] };
3
+ export type CapAgent = { name: string; subagents?: Record<string, Requested>; subagentCap?: SubagentCap };
4
+ export type Clamped = { model: string; reasoning: string; clamped: string[] };
5
+ export const REASONING_LEVELS: readonly string[];
6
+ export const DEFAULT_SUBAGENT_CAP: Readonly<{ model: string; reasoning: string; models: readonly string[] }>;
7
+ export function clampSubagent(requested?: Requested, cap?: SubagentCap): Clamped;
8
+ export function capFor(agents: readonly CapAgent[], workspaceCap: SubagentCap, botName: string): SubagentCap;
9
+ export function resolveSubagent(agents: readonly CapAgent[], workspaceCap: SubagentCap, botName: string, name: string): Clamped;
10
+ export function resolveSubagents(agents: readonly CapAgent[], workspaceCap: SubagentCap, botName: string): Array<Clamped & { name: string }>;
@@ -14,7 +14,7 @@ You don't know in advance which bots exist or what they're good at, and the team
14
14
  ## Opening work
15
15
  **Paths.** A thread is one piece of work: one task, usually one PR. Every thread sits on one **path**, `project/track`, e.g. `hub/native/composer`: the first segment is the project, the rest is the feature or effort inside it ("world-hub", "AppKit feed port"), which spans many threads and PRs. Put each thread you open on the path for its area if one exists, even read broadly (`list_threads` shows each thread's path; a bug or follow-up in an area belongs to it). A new path only for work that clearly starts a new effort, named for the feature, never the task. Near-duplicates (`hub/tracks` and `hub/tracks-ui`) get merged with `move_path`. Never move a thread whose path the human set (it's locked).
16
16
 
17
- **Summaries.** You lead every track: keep each one's summary current with `set_summary` (scope `path:<path>`, e.g. `path:hub/native`), a short free-form markdown note for the human of what's up in that feature. You decide what's worth saying and when; there's no fixed format. Your `tracks` schedule tags you in `sch-main-tracks` when a track's threads have new messages; read what moved, update the summaries that need it, reply there with one line and mention nobody. A thread can carry a summary too (scope `thread:<id>`: what it's about and where it stands); write one when a thread's opening ask no longer describes it. `list_threads` returns both, and the classifier files threads by them.
17
+ **Summaries.** You lead every track: keep each one's summary current with `set_summary` (scope `path:<path>`, e.g. `path:hub/native`), a short free-form markdown note for the human of what's up in that feature. You decide what's worth saying and when; there's no fixed format. Your `tracks` schedule tags you in `sch-main-tracks` only for threads a classifier judged to need a summary update, each with its verdict, what changed and its facts; update the summaries that need it (reading a thread when the facts aren't enough), reply there with one line and mention nobody. A thread can carry a summary too (scope `thread:<id>`: what it's about and where it stands); write one when a thread's opening ask no longer describes it. `list_threads` returns both, and the classifier files threads by them.
18
18
 
19
19
  **Track chats.** Each path can have one track chat: an ordinary thread on that path, opened from the track page's composer, where the human asks you about the track. Answer from the track's threads, PRs and summary. When a question turns into work, post it in the thread that owns it, or open a new thread on the path and hand it to a builder, and link that thread in your reply. A track chat isn't counted among the path's threads and doesn't trigger your `tracks` schedule.
20
20
 
@@ -67,7 +67,7 @@ npm test -- test/port.test.mjs
67
67
  renders as:
68
68
 
69
69
  ```
70
- (o) archer 3:12 PM
70
+ (o) bot 3:12 PM
71
71
  ( Port race; fixed in [#412]. ) <- answer bubble
72
72
  ( Two runs bound 4100. Each ) <- detail bubble
73
73
  ( now takes a free port. )
@@ -2,10 +2,25 @@ 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), 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, 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
- const res = await registryFetch(`/api/roster`);
9
- return (await res.json());
8
+ // Anything but a 2xx list of bots is an error naming the status and the
9
+ // body's start: an error page must never read as the team.
10
+ const res = await registryFetch("/api/roster");
11
+ const text = await res.text();
12
+ const fail = (why) => new Error(`GET /api/roster: HTTP ${res.status}${why}: ${text.slice(0, 300)}`);
13
+ if (!res.ok)
14
+ throw fail("");
15
+ let bots;
16
+ try {
17
+ bots = JSON.parse(text);
18
+ }
19
+ catch {
20
+ throw fail(", not JSON");
21
+ }
22
+ if (!Array.isArray(bots))
23
+ throw fail(", not a list of bots");
24
+ return bots;
10
25
  },
11
26
  });