eve-hub 0.5.7 → 0.6.1

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,9 +1,22 @@
1
1
  # eve-hub
2
2
 
3
+ ## 0.6.1
4
+
5
+ ### Patch Changes
6
+
7
+ - Every bot gets an execution-context prompt (`agents/<bot>/agent/instructions/execution-context.ts`), chosen by its definition in `hub.workspace.json`: `runsOn` on the agent, else a workspace-wide `"runsOn"` (a local hub's workspace declares `"local"`; a deployable repo declares `"hosted"`), else hosted. A local bot's comes from the kit's templates; the Vercel build writes a hosted bot's from what it produced: the eve-hub version, the roster and which bots run on the Mac, the deployment, team and repo commit (or that the hub uploaded the files), the kit tools it removed, and the dashboard links to its logs. The shared prompts keep only what is true in both contexts: main's restart, team, memory and Dash-schedule paragraphs, and the image and local-file link rules, moved into the per-context pages. `hub workspace sync` declares `"runsOn": "local"` in a workspace file that declares nothing.
8
+
9
+ ## 0.6.0
10
+
11
+ ### Minor Changes
12
+
13
+ - A workspace repo carries its bots, memory and workspace file; the Vercel build prepares it (eve-hub/vercel), including bots that run on the Mac (`runsOn: "local"`). The Mac runner downloads the deployed commit through the hub instead of reading a folder.
14
+
3
15
  ## 0.5.7
4
16
 
5
17
  ### Patch Changes
6
18
 
19
+ - One `dash` schedule (every 8 minutes, and when a bot's turn ends) replaces `dash`, `classify` and `tracks`: one GitHub query, a model call per thread whose facts changed and a model call per track with news. The `classify.ts` and `tracks.ts` stubs are retired; the track-writer bot is no longer woken.
7
20
  - `post_message` takes a reviewer's `verdict` (ship, fix-first, rethink) and `decisions`; a ship without a change-explainer for the PR shows "no explainer" on the Dash.
8
21
 
9
22
  ## 0.5.6
@@ -0,0 +1,79 @@
1
+ // A bot's execution-context prompt. A bot is defined in hub.workspace.json
2
+ // (`runsOn` on the agent, else the file's own default, else hosted:
3
+ // workspace.mjs agentRunsOn), and the page it gets follows from that field
4
+ // alone: the local page from the kit's templates (every bot's
5
+ // instructions/execution-context.ts stub calls `executionContext`), or, for a
6
+ // bot the Vercel build deploys, a hosted page the build writes over that stub
7
+ // (prepare.mjs), because only the build knows the deployment. The shared
8
+ // prompts (templates/main-instructions.md, workspace-protocol.md) hold only
9
+ // what is true in both contexts.
10
+ import { readFileSync } from "node:fs";
11
+ import { join } from "node:path";
12
+ import { templatesDir } from "./main-instructions.mjs";
13
+ import { agentRunsOn, parseWorkspace } from "./workspace.mjs";
14
+
15
+ const page = (name, options) => readFileSync(join(templatesDir(options), `execution-context-${name}.md`), "utf8").trimEnd();
16
+
17
+ // The build's Vercel facts. The team and project name come from the build's
18
+ // OIDC token (its `owner` and `project` claims); anything the build doesn't
19
+ // carry stays undefined and the page leaves it out. A malformed token fails
20
+ // the build.
21
+ const claims = (jwt) => JSON.parse(Buffer.from(jwt.split(".")[1], "base64url").toString());
22
+
23
+ export function deploymentFacts(env = process.env) {
24
+ const oidc = env.VERCEL_OIDC_TOKEN ? claims(env.VERCEL_OIDC_TOKEN) : {};
25
+ const repo = env.VERCEL_GIT_REPO_OWNER && env.VERCEL_GIT_REPO_SLUG ? `${env.VERCEL_GIT_REPO_OWNER}/${env.VERCEL_GIT_REPO_SLUG}` : undefined;
26
+ return {
27
+ team: oidc.owner,
28
+ project: oidc.project,
29
+ projectId: env.VERCEL_PROJECT_ID,
30
+ deployment: env.VERCEL_DEPLOYMENT_ID,
31
+ url: env.VERCEL_URL,
32
+ environment: env.VERCEL_ENV,
33
+ repo,
34
+ branch: env.VERCEL_GIT_COMMIT_REF,
35
+ sha: env.VERCEL_GIT_COMMIT_SHA,
36
+ };
37
+ }
38
+
39
+ const list = (items) => items.map((item) => `\`${item}\``).join(", ");
40
+
41
+ export function hostedContext({ bot, roster, kit, removed, deployment: d }, options) {
42
+ const mac = roster.filter((agent) => agent.runsOn === "local").map((agent) => agent.name);
43
+ const cloud = roster.filter((agent) => agent.runsOn !== "local").map((agent) => agent.name);
44
+ const dashboard = d.team && d.project ? `https://vercel.com/${d.team}/${d.project}` : undefined;
45
+ const lines = [
46
+ "## Execution context",
47
+ `You are \`${bot}\`, an eve agent deployed on Vercel, not on a laptop: \`hub.workspace.json\` defines you as hosted.`,
48
+ "",
49
+ `- **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.`,
50
+ `- **Package:** your tools, hooks, channel and instructions come from the \`eve-hub\` package, version ${kit}.`,
51
+ `- **Deployment:** ${[
52
+ (d.project ?? d.projectId) && `project \`${d.project ?? d.projectId}\``,
53
+ d.team && `team \`${d.team}\``,
54
+ d.deployment && `deployment \`${d.deployment}\``,
55
+ d.environment && `${d.environment} environment`,
56
+ d.url && `at \`https://${d.url}\``,
57
+ ].filter(Boolean).join(", ") || "no Vercel facts were available to this build"}. Each bot is its own service at \`/eve/<name>\` in that one project.`,
58
+ `- **Source:** ${d.repo ? `built from \`${d.repo}\`${d.branch ? ` on \`${d.branch}\`` : ""}${d.sha ? ` at commit \`${d.sha}\`` : ""}` : "no git repository: the hub uploaded these files"}. You are made of files in it: \`agents/${bot}/\`, \`memory/\` and \`hub.workspace.json\`.`,
59
+ d.repo
60
+ ? "- **Changing it:** commit to that repository; Vercel builds and deploys the commit."
61
+ : "- **Changing it:** there is no repository to commit to. The hub replaces these files on its next deploy, so an edit made only in this deployment does not last.",
62
+ "- **Limits:** there is no `hub` CLI, you cannot restart yourself or any bot, and there are no host paths: your shell is a Vercel Sandbox with its own filesystem, not the owner's machine.",
63
+ `- **Kit tools this build removed:** ${removed.length ? list(removed) : "none"}. They need the hub's machine, or change the roster, which a deployment does by commit.`,
64
+ `- **Logs:** ${dashboard ? `[runtime logs](${dashboard}/logs)${d.deployment ? ` and [this deployment](${dashboard}/${d.deployment.replace(/^dpl_/, "")})` : ""} in the Vercel dashboard` : "the Vercel project's runtime logs in the dashboard"}.`,
65
+ ...(bot === "main" ? [page("hosted-main", options)] : []),
66
+ ];
67
+ return `${lines.join("\n")}\n`;
68
+ }
69
+
70
+ // What a bot's instructions/execution-context.ts stub renders. Hosted is the
71
+ // Vercel build's to write (hostedContext), never this process's guess.
72
+ export function executionContext(bot, workspace, options) {
73
+ const parsed = parseWorkspace(workspace);
74
+ const runsOn = agentRunsOn(parsed, bot);
75
+ if (runsOn !== "local") {
76
+ throw new Error(`${bot} is defined as hosted in hub.workspace.json, and a hosted bot's execution context is written by its Vercel build (eve-hub/vercel). A bot on this machine needs "runsOn": "local", on the agent or at the top of hub.workspace.json.`);
77
+ }
78
+ return ["## Execution context", `You are \`${bot}\`: \`hub.workspace.json\` defines you as local.`, "", page("local", options), ...(bot === "main" ? [page("local-main", options)] : [])].join("\n") + "\n";
79
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eve-hub",
3
- "version": "0.5.7",
3
+ "version": "0.6.1",
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",
@@ -23,13 +23,20 @@
23
23
  "./subagent-cap": "./subagent-cap.mjs",
24
24
  "./session-limits": "./session-limits.mjs",
25
25
  "./main-instructions": "./main-instructions.mjs",
26
+ "./execution-context": "./execution-context.mjs",
26
27
  "./connections/*": "./connections/*.js",
27
- "./bot-token": "./bot-token.mjs"
28
+ "./bot-token": "./bot-token.mjs",
29
+ "./prepare": "./prepare.mjs",
30
+ "./vercel": "./vercel.mjs",
31
+ "./world": "./world.mjs"
28
32
  },
29
33
  "peerDependencies": {
30
34
  "eve": "*",
31
35
  "zod": "4.5.4",
32
36
  "@vercel/connect": "^2.4.1",
33
37
  "@vercel/oidc": "4.0.0"
38
+ },
39
+ "dependencies": {
40
+ "ai": "^7.0.122"
34
41
  }
35
42
  }
package/prepare.mjs ADDED
@@ -0,0 +1,219 @@
1
+ // What a Vercel build does to a workspace checkout before eve builds its bots:
2
+ // the deployed bots differ from the Mac's in what they run on, not in what they
3
+ // are (vercel.mjs calls this from the repo's vercel.ts, after install and
4
+ // before withEve assembles the services):
5
+ // - bots marked `runsOn: "local"` are the Mac runner's: their folders and
6
+ // roster entries leave the hosted build, after hub.manifest.json records the
7
+ // whole roster for the hub;
8
+ // - each bot runs in a Vercel Sandbox (the project's OIDC token opens it and
9
+ // pays for model calls) with only the kit stubs that run off the hub's
10
+ // machine (DEPLOYED_KIT_FILES);
11
+ // - the kit extension, host-only workers and kit stubs are dropped, the hub's
12
+ // workflow world and the Vercel Kit connection are wired in, and the
13
+ // workspace's memory is embedded in the bots' memory providers (a function
14
+ // has no workspace cwd);
15
+ // - /hub/manifest: the roster and the commit sha the deployment was built
16
+ // from, for the hub to sync from: a custom channel on main, routed in
17
+ // vercel.mjs. Bearer BOT_TOKEN, as on each bot's eve channel (bot-token.mjs).
18
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, rmdirSync, unlinkSync, writeFileSync } from "node:fs";
19
+ import { basename, dirname, join } from "node:path";
20
+ import { KIT_FILES, MAIN_KIT_FILES } from "./stubs.mjs";
21
+ import { deploymentFacts, hostedContext } from "./execution-context.mjs";
22
+ import { botDomains, domainFolders, readBotMemory, readMemory, validateMemory } from "./memory.mjs";
23
+ import { agentRunsOn, readWorkspaceFile, removeWorkspaceAgent } from "./workspace.mjs";
24
+
25
+ // The kit stubs a deployed main keeps: ones whose whole import closure in the
26
+ // published kit runs in a Vercel function and talks to the hub over HTTP alone
27
+ // (test/workspace-template.test.mjs walks it). Every other kit stub needs the
28
+ // hub's machine: the sandbox hook (it migrates host sessions, and deletes any
29
+ // sandbox that resolves /workspace as itself, which a Vercel Sandbox does),
30
+ // the kit's bash (HostSandbox's root, the daemon's env; eve's own bash runs
31
+ // instead), the browser and the screenshot paths of attach_image/view_image,
32
+ // the mention gate (the
33
+ // daemon's per-process HUB_AGENT and patched model credential), the tools that
34
+ // scaffold, remove or restart bots on the hub's disk (a deployment changes its
35
+ // roster by commit), memory (the workspace's files), and main's schedules.
36
+ export const DEPLOYED_KIT_FILES = new Set([
37
+ "channels/eve.ts",
38
+ "skills/change-explainer.ts",
39
+ ...["react", "show", "update_thread", "list_threads", "read_thread", "post_message"].map((t) => `tools/${t}.ts`),
40
+ ...["create_thread", "set_summary", "move_path", "archive_thread", "unarchive_thread", "list_agents", "agent_send", "tag_threads", "dash_update", "apply_tags", "artifact_publish", "artifact_status", "artifact_list"].map((t) => `tools/${t}.ts`),
41
+ ]);
42
+
43
+
44
+ const vercelSandbox = (prepare) => `import { defineSandbox } from "eve/sandbox";
45
+ import { VercelSandbox } from "eve/sandbox/vercel";
46
+ ${prepare ? 'import { installCodeTooling } from "eve/extensions/code/sandbox";\n' : ""}
47
+ // TEMPORARY (until the Vercel Connect GitHub connector replaces it): the
48
+ // project's GITHUB_TOKEN is added to GitHub requests by the sandbox firewall,
49
+ // so the token never enters the sandbox. api.github.com (gh, REST) takes
50
+ // Bearer; github.com (git over HTTPS) takes Basic. Unset: no policy.
51
+ const token = process.env.GITHUB_TOKEN;
52
+ const networkPolicy = token ? { allow: {
53
+ "api.github.com": [{ transform: [{ headers: { authorization: "Bearer " + token } }] }],
54
+ "github.com": [{ transform: [{ headers: { authorization: "Basic " + Buffer.from("x-access-token:" + token).toString("base64") } }] }],
55
+ "*": [],
56
+ } } : undefined;
57
+
58
+ // A Vercel Sandbox, opened with the project's OIDC token.
59
+ export const environment = VercelSandbox.environment({ networkPolicy, prepare: async (sandbox) => {${prepare}
60
+ const run = async (command, what) => {
61
+ if ((await sandbox.run({ command })).exitCode !== 0) throw new Error(what + " failed");
62
+ };
63
+ if (!token) return;
64
+ // eve's sandbox image is Ubuntu with git only; gh and ssh-keygen come from apt.
65
+ await run("sudo apt-get update -qq && sudo apt-get install -y -qq gh openssh-client", "gh and ssh installation");
66
+ // gh refuses to run without a login; this one is a placeholder the firewall overwrites.
67
+ await run("mkdir -p ~/.config/gh && printf 'github.com:\\n user: hub\\n oauth_token: placeholder\\n git_protocol: https\\n' > ~/.config/gh/hosts.yml", "gh login");
68
+ // The org requires signed commits. Eve reuses a named template sandbox after a failed prepare, so
69
+ // this step starts by clearing an earlier key. A key made here, registered as a signing key on
70
+ // the token's user, never leaves the sandbox; commits carry that user's noreply email.
71
+ await run([
72
+ "mkdir -p ~/.ssh && rm -f ~/.ssh/signing ~/.ssh/signing.pub && ssh-keygen -q -t ed25519 -N '' -C hub-bot -f ~/.ssh/signing",
73
+ "gh api user/ssh_signing_keys -f title=\\\"hub bot sandbox $(date -u +%FT%TZ)\\\" -f key=\\\"$(cat ~/.ssh/signing.pub)\\\" --silent",
74
+ "login=$(gh api user --jq .login) && id=$(gh api user --jq .id)",
75
+ "git config --global user.name \\\"$login\\\" && git config --global user.email \\\"$id+$login@users.noreply.github.com\\\"",
76
+ "git config --global gpg.format ssh && git config --global user.signingkey ~/.ssh/signing && git config --global commit.gpgsign true",
77
+ ].join(" && "), "git signing setup");
78
+ } });
79
+ export default defineSandbox(() => environment.open());
80
+ `;
81
+
82
+ export const VERCEL_SANDBOX = vercelSandbox("");
83
+
84
+ // The builder's sandbox also carries eve-code's tooling (agents/builder/agent/extensions/code/).
85
+ export const CODE_SANDBOX = vercelSandbox("\n await installCodeTooling(sandbox);");
86
+
87
+ // Bots that edit code: eve-code (apply_patch, grep, the review skills and its
88
+ // read-only worker) plus eve's git extension, both with no `github` option. The
89
+ // git extension then points the bot at `gh` and `git` in bash, whose GitHub
90
+ // credential is the sandbox firewall's (VERCEL_SANDBOX above), and its `pr` skill
91
+ // is the plain commit, push and draft-PR one. The worker runs on the roster's
92
+ // subagent cap like every other subagent, not on eve's built-in default.
93
+ export const CODE_BOTS = ["builder"];
94
+ export const codeExtensionFiles = (bot) => ({
95
+ "extensions/code/extension.ts": `import code from "eve/extensions/code";
96
+ import { bindRoster } from "eve-hub/roster-model";
97
+ import { resolveSubagent } from "eve-hub/subagent-cap";
98
+ import workspace from "../../../../../hub.workspace.json" with { type: "json" };
99
+
100
+ const { agents, subagentCap } = bindRoster(workspace);
101
+ const { model, reasoning } = resolveSubagent(agents, subagentCap, ${JSON.stringify(bot)}, "code__worker");
102
+
103
+ export default code({ worker: { model, reasoning } });
104
+ `,
105
+ "extensions/git/extension.ts": `import git from "eve/extensions/git";\n\nexport default git({});\n`,
106
+ });
107
+
108
+ const KIT_VERSION = JSON.parse(readFileSync(new URL("./package.json", import.meta.url), "utf8")).version;
109
+ const MANIFEST_CHANNEL = `import { defineChannel, GET } from "eve/channels";
110
+ import { isBotToken } from "eve-hub/bot-token";
111
+ import { parseWorkspace } from "eve-hub/workspace";
112
+ import workspace from "../../../../hub.manifest.json" with { type: "json" };
113
+
114
+ // The roster and subagent cap as deployed, Mac bots included, and the eve-hub version that built it, for the hub to sync from:
115
+ // hub.manifest.json is bundled at build, so a push whose deploy fails never
116
+ // changes it. \`sha\` is the commit the deployment was built from, which the hub
117
+ // syncs by; a deployment with none (\`vercel deploy\` uploads no git metadata)
118
+ // has no manifest to give.
119
+ const sha = process.env.VERCEL_GIT_COMMIT_SHA;
120
+ const { agents, subagentCap } = parseWorkspace(workspace);
121
+ const manifest = { sha, kit: ${JSON.stringify(KIT_VERSION)}, agents, subagentCap };
122
+
123
+ export default defineChannel({
124
+ routes: [
125
+ GET("/hub/manifest", (request) => {
126
+ if (!isBotToken(request.headers.get("authorization"))) return new Response("unauthorized", { status: 401 });
127
+ if (!sha) return new Response("no commit sha: this deployment wasn't built from git", { status: 500 });
128
+ return Response.json(manifest);
129
+ }),
130
+ ],
131
+ });
132
+ `;
133
+
134
+ export const MANIFEST_FILE = "hub.manifest.json";
135
+
136
+ export function injectWorkflowWorld(agent, file) {
137
+ const count = agent.split("defineAgent({").length - 1;
138
+ if (count !== 1) throw new Error(`${file}: expected exactly one defineAgent({, found ${count}`);
139
+ return agent.replace("defineAgent({", `defineAgent({\n experimental: { workflow: { world: "eve-hub/world" } },`);
140
+ }
141
+
142
+ // Removes `file` under `bot`, then any directory it leaves empty.
143
+ function removeStub(bot, file) {
144
+ unlinkSync(join(bot, file));
145
+ for (let dir = dirname(file); dir !== "." && !readdirSync(join(bot, dir)).length; dir = dirname(dir)) rmdirSync(join(bot, dir));
146
+ }
147
+
148
+ const memoryProvider = (slot, entries, prefix, scope) => `import { defineMemory, defineMemoryProvider } from "eve/memory";
149
+ import { memoryRecall } from "eve-hub/memory";
150
+ const entries = ${JSON.stringify(entries)};
151
+ const recall = ({ messages }) => ({ messages: memoryRecall(entries, messages, { prefix: ${JSON.stringify(prefix)} }) });
152
+ export default defineMemory({ namespace: ${JSON.stringify(`hub-${slot}-memory`)}, scope: ${JSON.stringify(scope)}, provider: defineMemoryProvider({ recall: { "turn.started": recall, "compaction.completed": recall } }) });
153
+ `;
154
+
155
+ // Replaces the kit's execution-context stub (the local page) with the hosted
156
+ // page, for a bot the definition says is hosted. Its removed-tools list is read
157
+ // from the bot's folder as pruned.
158
+ function writeExecutionContext(bot, { bot: name, roster, deployment }) {
159
+ 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"));
160
+ const removed = kitTools.filter((tool) => !existsSync(join(bot, "tools", `${tool}.ts`)));
161
+ const section = hostedContext({ bot: name, roster, kit: KIT_VERSION, removed, deployment });
162
+ mkdirSync(join(bot, "instructions"), { recursive: true });
163
+ writeFileSync(join(bot, "instructions", "execution-context.ts"), `import { defineInstructions } from "eve/instructions";\n\nexport default defineInstructions({ content: ${JSON.stringify(section)} });\n`);
164
+ }
165
+
166
+ // Applies to a checkout the build owns: it deletes and rewrites files in place.
167
+ // A tree that already has hub.manifest.json was prepared, and a second pass
168
+ // would record the stripped roster.
169
+ export function prepareDeployment(dir) {
170
+ if (existsSync(join(dir, MANIFEST_FILE))) return;
171
+ validateMemory(dir);
172
+ const definition = readWorkspaceFile(dir);
173
+ const resolved = { ...definition, agents: definition.agents.map((agent) => ({ ...agent, runsOn: agentRunsOn(definition, agent.name) })) };
174
+ writeFileSync(join(dir, MANIFEST_FILE), `${JSON.stringify(resolved, null, 2)}\n`);
175
+ for (const { name } of definition.agents) {
176
+ if (agentRunsOn(definition, name) !== "local") continue;
177
+ removeWorkspaceAgent(dir, name);
178
+ rmSync(join(dir, "agents", name), { recursive: true, force: true });
179
+ }
180
+ mkdirSync(join(dir, "agents", "main", "agent", "channels"), { recursive: true });
181
+ writeFileSync(join(dir, "agents", "main", "agent", "channels", "manifest.ts"), MANIFEST_CHANNEL);
182
+ for (const file of readdirSync(join(dir, "agents"), { recursive: true })) {
183
+ if (!file.endsWith("/agent.ts")) continue;
184
+ const bot = dirname(join(dir, "agents", file));
185
+ for (const stub of Object.keys({ ...MAIN_KIT_FILES, "memory/workspace.ts": 0, "memory/bot.ts": 0 })) if (!DEPLOYED_KIT_FILES.has(stub) && existsSync(join(bot, stub))) removeStub(bot, stub);
186
+ // learning-review is not in MAIN_KIT_FILES, but also requires the host.
187
+ if (existsSync(join(bot, "schedules/learning-review.ts"))) removeStub(bot, "schedules/learning-review.ts");
188
+ const name = basename(dirname(bot));
189
+ const code = CODE_BOTS.includes(name);
190
+ writeFileSync(join(bot, "sandbox.ts"), code ? CODE_SANDBOX : VERCEL_SANDBOX);
191
+ if (code) {
192
+ for (const [stub, content] of Object.entries(codeExtensionFiles(name))) {
193
+ mkdirSync(dirname(join(bot, stub)), { recursive: true });
194
+ writeFileSync(join(bot, stub), content);
195
+ }
196
+ }
197
+ }
198
+ const roster = resolved.agents;
199
+ const deployment = deploymentFacts();
200
+ for (const name of readdirSync(join(dir, "agents"))) {
201
+ const bot = join(dir, "agents", name, "agent");
202
+ writeExecutionContext(bot, { bot: name, roster, deployment });
203
+ // The kit's extension (kit__worker) runs host-only workers and is excluded from the kit tarball.
204
+ // Other extensions, such as eve-code, stay.
205
+ for (const item of readdirSync(bot, { recursive: true })) {
206
+ if (item.endsWith(join("extensions", "kit.ts"))) removeStub(bot, item);
207
+ }
208
+ const agentFile = join(bot, "agent.ts");
209
+ writeFileSync(agentFile, injectWorkflowWorld(readFileSync(agentFile, "utf8"), agentFile));
210
+ // Hosted bots reach the human's connected services (GitHub) through Vercel Kit,
211
+ // with the project's KIT_CONNECTOR; there is no host credential to fall back on.
212
+ mkdirSync(join(bot, "connections"), { recursive: true });
213
+ writeFileSync(join(bot, "connections", "vercel-kit.ts"), 'export { default } from "eve-hub/connections/vercel-kit";\n');
214
+ mkdirSync(join(bot, "memory"), { recursive: true });
215
+ const botDir = join(dir, "agents", name);
216
+ writeFileSync(join(bot, "memory", "workspace.ts"), memoryProvider("workspace", readMemory(dir, { folders: domainFolders(botDomains(dir, botDir)) }).entries, "Standing decision", "workspace"));
217
+ writeFileSync(join(bot, "memory", "bot.ts"), memoryProvider("bot", readBotMemory(dir, botDir).entries, "Your standing decision", name));
218
+ }
219
+ }
@@ -1,14 +1,19 @@
1
- // main's classify schedule (schedules/classify.ts): gives
2
- // every idle thread a Dash entry (registry/dash.mjs: importance, delay) and
3
- // writes them back through POST /api/dash/entries.
1
+ // The threads part of main's dash pass (schedules/dash-pass.mjs): gives
2
+ // every open thread, busy or idle, a Dash entry (registry/dash.mjs: importance,
3
+ // lane, reason) and writes them back through POST /api/dash/entries.
4
4
  //
5
- // targets open threads (not DMs, schedule threads or archived) where no
6
- // bot is working, that aren't deferred, and whose basis changed
7
- // since their entry was written. An unchanged hub costs nothing.
8
- // classify (target, snap) -> { threadId, importance, delay } or null, all
5
+ // targets open threads (not DMs, schedule threads or archived) that aren't
6
+ // deferred and are due. A thread is due when its basis changed
7
+ // since its entry was written; a busy thread's basis ends with
8
+ // its facts' key (./lane-facts.mjs), which moves when it goes
9
+ // quiet for another pass, not while its bot keeps working. An
10
+ // unchanged hub costs nothing.
11
+ // classify (target, snap) -> { threadId, importance, lane, reason } or null, all
9
12
  // targets at once, each written as soon as it's decided.
10
13
  // modelClassify: one evaluation per thread on the AI Gateway
11
- // (openai/gpt-6-luna-decisions), two choice questions. Its
14
+ // (openai/gpt-6-luna-decisions), two choice questions: the
15
+ // thread's state (each state is one lane) and its importance. The
16
+ // reason line is quoted from facts by ./lane-facts.mjs. Its
12
17
  // answer is the classification. No model, a failed call or an
13
18
  // invalid answer writes nothing for that thread, so it's tried
14
19
  // again next tick, and the pass doesn't stamp classifiedAt.
@@ -23,7 +28,8 @@
23
28
  // tests (plain node) import the same module.
24
29
 
25
30
  import { createHash } from "node:crypto";
26
- import { lastSaid, threadBrief, briefText } from "./thread-brief.mjs";
31
+ import { lastSaid, said, threadBrief, briefText } from "./thread-brief.mjs";
32
+ import { busyBasis, factsText, laneFacts, factsKey, paragraphs, reasonLine } from "./lane-facts.mjs";
27
33
 
28
34
  export { threadBrief, briefText } from "./thread-brief.mjs";
29
35
 
@@ -39,7 +45,7 @@ const working = (t) => Object.values(t?.status ?? {}).includes("working");
39
45
  // The input version (registry/dash.mjs BASIS_VERSION): bumped when what the
40
46
  // model reads changes, so every entry made from the older input is
41
47
  // classified once more, then stays quiet.
42
- export const BASIS_VERSION = "v:brief1";
48
+ export const BASIS_VERSION = "v:lanes1";
43
49
  export function classifyBasis(t) {
44
50
  const st = Object.values(t?.status ?? {});
45
51
  const live = st.includes("waiting") ? "waiting" : st.includes("working") ? "working" : "idle";
@@ -67,6 +73,24 @@ export function pathQuestion(paths) {
67
73
  return { type: "choice", instructions: "Which path covers this thread's work? Paths group threads by feature or effort at any depth. A bug, polish or follow-up belongs to its area's existing path. Prefer an existing path where it fits; choose new only when none fits.", criteria };
68
74
  }
69
75
 
76
+ // Which paragraph holds the ask the human would answer: the row quotes the one
77
+ // the model points at, word for word, so code never guesses where it is. The
78
+ // candidates are the paragraphs of the bot messages in the brief that @-mention
79
+ // the human; the model is asked only when there are two or more (the API wants
80
+ // two choices). The choices are labelled p0, p1, …: the gateway fails on
81
+ // labels like `thr_0m7l#3`, not on how many there are.
82
+ export function askCandidates(t, brief) {
83
+ const ids = new Set([...(brief?.earlier ?? []), ...(brief?.recent ?? [])].map((l) => l.id));
84
+ return (t?.messages ?? [])
85
+ .filter((m) => ids.has(m.id) && said(m) && m.authorKind !== "human" && m.author !== brief.human && ((m.mentions ?? []).includes(brief.human) || String(m.text).includes(`@${brief.human}`)))
86
+ .flatMap((m) => paragraphs(m.text).map((text) => ({ author: m.author, text })))
87
+ .map((c, i) => ({ id: `p${i}`, ...c }));
88
+ }
89
+ export function askQuestion(candidates) {
90
+ const criteria = Object.fromEntries(candidates.map((c) => [c.id, `${c.author}: ${c.text.slice(0, 160)}`]));
91
+ return { type: "choice", instructions: "Which of these paragraphs holds what the human is asked to answer, decide or do? Pick the paragraph a person reading the thread would point to as the ask. If none asks them anything, pick the newest.", criteria };
92
+ }
93
+
70
94
  // The threads to (re)classify this pass, at most `max`: those the Dash needs
71
95
  // (no entry, or the thread moved on) first, newest change first, so the
72
96
  // thread whose turn just ended (what fired this pass) is selected under the
@@ -74,21 +98,24 @@ export function pathQuestion(paths) {
74
98
  // fresh ones instead of holding the front; later passes drain the rest. Then those
75
99
  // asked only for a path, least recently classified first, so threads still
76
100
  // waiting on main's filing take turns and never crowd out the Dash.
77
- export function classifyTargets(snap, { max = CLASSIFY_MAX } = {}) {
101
+ export function classifyTargets(snap, { max = CLASSIFY_MAX, now = Date.now() } = {}) {
78
102
  const entries = snap?.dash?.entries ?? {};
79
103
  const out = [];
80
104
  const pathOnly = [];
81
105
  for (const t of Object.values(snap?.threads ?? {})) {
82
- if (!open(t) || working(t)) continue;
106
+ if (!open(t)) continue;
83
107
  const e = entries[t.id];
84
108
  if (e?.deferred) continue;
85
109
  const last = lastSaid(t);
86
110
  if (!last) continue;
87
- const basis = classifyBasis(t);
111
+ const facts = laneFacts(t, snap, now);
112
+ const base = classifyBasis(t);
113
+ const busy = working(t);
114
+ const basis = busy ? busyBasis(base, factsKey(facts)) : base;
115
+ const look = { due: !e || e.basis !== basis, basis };
88
116
  const path = askPath(t);
89
- const due = !e || e.basis !== basis;
90
- if (!due && !path) continue;
91
- (due ? out : pathOnly).push({ id: t.id, title: t.title, tags: t.tags ?? {}, status: t.status ?? {}, last, brief: threadBrief(t, snap), basis, entry: e ?? null, paths: path ? pathsInProject(t, snap) : null });
117
+ if (!look.due && !path) continue;
118
+ (look.due ? out : pathOnly).push({ id: t.id, title: t.title, tags: t.tags ?? {}, status: t.status ?? {}, last, brief: threadBrief(t, snap), facts, basis: look.basis, entry: e ?? null, paths: path ? pathsInProject(t, snap) : null });
92
119
  }
93
120
  const at = (x) => String(x ?? "");
94
121
  out.sort((a, b) => at(b.last.at).localeCompare(at(a.last.at)));
@@ -102,14 +129,27 @@ export function classifyTargets(snap, { max = CLASSIFY_MAX } = {}) {
102
129
  // (eve/ai); tests pass a mock.
103
130
  export const DECISION_MODEL = "openai/gpt-6-luna-decisions";
104
131
 
132
+ // What the thread is doing for the human, as one pick. Each state is one lane
133
+ // (the entry's `lane`); `ready` isn't here, the hub derives it from the reviewer's
134
+ // ship verdict on the PR's current head (entriesOf, registry/dash.mjs).
135
+ export const STATES = {
136
+ "asks-you": "needs-you",
137
+ stuck: "running", // a stall is a bot's to steer; it shows on the thread's own row, not as the human's task
138
+ dropped: "needs-you",
139
+ running: "running",
140
+ settled: "fyi",
141
+ };
142
+
105
143
  export const QUESTIONS = {
106
- delay: {
144
+ state: {
107
145
  type: "choice",
108
- instructions: "Who has to act next in this thread, and what does it cost the human to leave it alone right now? Read the whole brief, not just the last message: something asked of the human stays open until they settle it, and a reply from them that asks back or comments without deciding doesn't settle it.",
146
+ instructions: "What is this thread doing for the human right now? Read the whole brief and the live facts. Be strict: the human's attention is scarce, so a thread belongs in front of them only when something specific is theirs to do right now, and every other thread is running or settled. Read from the newest message back to the latest ask to the human; that is the only candidate. A bot that mentions the human, reports progress, says work is done or ready, or asks another bot for something is not asking the human; a request to confirm, approve or land something is.",
109
147
  criteria: {
110
- blocking: "A bot can't continue until the human answers a question, makes a decision or approves something, and they haven't settled it yet.",
111
- land: "The work is finished and waits on the human to try, validate, merge or sign it off, and they haven't yet.",
112
- fyi: "Nothing waits on the human: whatever was asked of them is settled, or a bot acts next.",
148
+ "asks-you": "The thread's last substantive message to or about the human is a bot putting an explicit question, a choice between options, a decision, or a request to confirm, approve or land something to the human, including an approval they give outside the thread such as a GitHub review (a proposal the thread says waits on their confirmation counts), and it is still open: the human's reply closes it only when that reply answers or decides it (a question back keeps it open), and a later bot message closes it only when it carries on that same work or answers its own question. Bot-to-bot messages about anything else (status, cleanup, handoffs) don't close it, however many follow. If you can't point at the exact ask and the human is its only possible answerer, it's not this.",
149
+ stuck: "A bot's turn is live but going nowhere that more waiting won't fix: no tool activity for far longer than that kind of work normally pauses (a bot thinking or writing between calls for a few minutes is normal), the same call or file repeated over and over (once or twice is normal work), a call far longer than its kind normally takes, or a restart that left the turn silent. A CI watch, a build, a test run, subagents out or a task_wait that is long but making sense is not stuck.",
150
+ dropped: "No bot is working, nothing is open with the human, and work was left hanging: a bot said it would do or report something next and didn't (the turn ended without finishing what it announced), or a bot was handed work by another bot and nobody picked it up.",
151
+ running: "A bot's turn is live, working or waiting on something that will end by itself (CI, a build, subagents, another bot). The human has nothing to do. Never pick this when every bot is idle.",
152
+ settled: "Nothing specific waits on the human and nobody is working: whatever was asked of them is settled, or the work is done, reported or landed.",
113
153
  },
114
154
  },
115
155
  importance: {
@@ -131,31 +171,35 @@ export function threadState(t, snap) {
131
171
  return [
132
172
  `A chat thread between a human (${brief.human}) and AI bots that work for them. A message addresses whoever it @-mentions; a bot addressing another bot asks nothing of ${brief.human}.`,
133
173
  `Bot status now: ${status}`,
174
+ ...(t.facts ? [`Live facts:`, factsText(t.facts)] : []),
134
175
  briefText(brief),
135
176
  ].join("\n");
136
177
  }
137
178
 
138
- // One evaluation; returns { delay, importance } when both answers are valid
179
+ // One evaluation; returns { state, importance } when both answers are valid
139
180
  // choices, else null, plus `path` (a path id or `new`) for an unfiled
140
181
  // thread: `new` without a call when the project has no open paths, and left
141
182
  // out when that answer alone is invalid. Throws when the call fails.
142
183
  export async function decideThread(t, snap, { evaluate, model = DECISION_MODEL, timeoutMs = 20000 }) {
143
184
  const uq = t.paths ? pathQuestion(t.paths) : null;
144
- const questions = uq ? { ...QUESTIONS, path: uq } : QUESTIONS;
185
+ const asks = askCandidates(snap?.threads?.[t.id], t.brief ?? threadBrief(t, snap));
186
+ const questions = { ...QUESTIONS, ...(asks.length > 1 ? { ask: askQuestion(asks) } : {}), ...(uq ? { path: uq } : {}) };
145
187
  const r = await evaluate({ model, state: threadState(t, snap), questions, maxRetries: 1, abortSignal: AbortSignal.timeout(timeoutMs) });
146
188
  const out = {};
147
189
  for (const [name, q] of Object.entries(questions)) {
148
190
  const a = r?.answers?.[name];
149
191
  const ok = a?.type === "choice" && Object.hasOwn(q.criteria, a.choice);
150
192
  if (ok) out[name] = a.choice;
151
- else if (name !== "path") return null;
193
+ else if (name !== "path" && name !== "ask") return null;
152
194
  }
153
195
  if (t.paths && !uq) out.path = NEW_PATH;
196
+ if (asks.length === 1) out.ask = asks[0].text;
197
+ else if (out.ask) out.ask = asks.find((c) => c.id === out.ask).text;
154
198
  return out;
155
199
  }
156
200
 
157
201
  // The schedule's classifier, one thread: the model's answer as is
158
- // ({ threadId, importance, delay, path? }), or null when there's no
202
+ // ({ threadId, importance, lane, reason, path? }), or null when there's no
159
203
  // `evaluate`, the call failed or the answer is invalid (no entry for it).
160
204
  /** @param {{ evaluate?: (options: any) => Promise<any>, log?: any, model?: string, timeoutMs?: number }} [options] */
161
205
  export function modelClassify({ evaluate, log = console, ...opts } = {}) {
@@ -163,7 +207,9 @@ export function modelClassify({ evaluate, log = console, ...opts } = {}) {
163
207
  if (!evaluate) return null;
164
208
  const a = await decideThread(t, snap, { evaluate, ...opts }).catch((err) => (log?.warn?.(`dash: decide ${t.id} failed, skipped: ${err?.message ?? err}`), undefined));
165
209
  if (a === null) log?.warn?.(`dash: decide ${t.id}: invalid answer, skipped`);
166
- return a ? { threadId: t.id, ...a } : null;
210
+ if (!a) return null;
211
+ const { state, ask, ...rest } = a;
212
+ return { threadId: t.id, ...rest, lane: STATES[state], reason: reasonLine(state, t.brief, t.facts, ask) };
167
213
  };
168
214
  }
169
215
 
@@ -198,22 +244,22 @@ export function newPathNotice(threads, snap) {
198
244
  ].join("\n");
199
245
  }
200
246
 
201
- // The "asks you" notice: a thread whose verdict changed to waiting on the
202
- // human (blocking: their decision; land: their sign-off), or from that to
203
- // not. The model's verdict on the brief decides; nothing here reads the
204
- // messages. (A pending request is posted by the rule scan, dash-rules.mjs.)
205
- const WAITS = { blocking: "waiting on your decision", land: "waiting on your sign-off" };
247
+ // The "asks you" notice: a thread whose lane changed to needs-you (its reason
248
+ // says why), or from needs-you or ready (waiting on the human's sign-off) to
249
+ // anything else. The model's pick decides; nothing here reads the messages. (A
250
+ // pending request is posted by the rule scan, dash-rules.mjs.)
251
+ const asking = (lane) => lane === "needs-you" || lane === "ready";
206
252
  // The notice for `e`, the new entry of target `t`, or null: committed with
207
253
  // it in one write (hub.dashEntries), so a verdict and its notice are kept
208
254
  // together or not at all, and a failed write is judged afresh next pass
209
255
  // against the entry as it still is.
210
256
  export function asksNotice(e, t) {
211
- const was = t.entry?.delay;
212
- if (was === e.delay) return null;
257
+ const was = t.entry?.lane;
258
+ if (was === e.lane || (asking(was) && asking(e.lane))) return null;
213
259
  const link = `[${String(t.title ?? t.id).replace(/[[\]]/g, "")}](${t.id})`;
214
- const line = WAITS[e.delay] ? `asks you: ${link}, ${WAITS[e.delay]}` : WAITS[was] ? `no longer asking you: ${link}` : null;
260
+ const line = asking(e.lane) ? `needs you: ${link}, ${e.reason}` : asking(was) ? `no longer asking you: ${link}` : null;
215
261
  if (!line) return null;
216
- const key = `asks-${createHash("sha256").update(`${e.threadId}:${e.delay}:${e.basis}`).digest("hex").slice(0, 32)}`;
262
+ const key = `asks-${createHash("sha256").update(`${e.threadId}:${e.lane}:${e.basis}`).digest("hex").slice(0, 32)}`;
217
263
  return { schedule: "dash", text: `@main dash: ${line}`, clientId: key };
218
264
  }
219
265
 
@@ -247,7 +293,8 @@ async function noticeNewPaths(hub, fresh, log) {
247
293
  // thread whose turn just ended shows within its own call. Path picks are
248
294
  // filed after the pass (`filing`, see filePaths), outside it. Each entry
249
295
  // carries the basis its target was classified from, so a thread that moved
250
- // on meanwhile is looked at again next pass, and clears any old reason line.
296
+ // on meanwhile is looked at again next pass. The entry's reason is the lane's
297
+ // one-line why, replaced with every classification.
251
298
  // Importance a human set (entry.importanceBy) is never sent. classifiedAt is
252
299
  // stamped when there was nothing to do or every target was written; a
253
300
  // skipped target or a failed write leaves it, so "never classified" and
@@ -265,7 +312,7 @@ export async function runClassify(hub, { classify = async () => null, max = CLAS
265
312
  if (!d) return 0;
266
313
  if (d.path) picks.push([t, d.path]);
267
314
  const own = humans.has(t.entry?.importanceBy);
268
- const entry = { threadId: t.id, ...(own || d.importance === undefined ? {} : { importance: d.importance }), delay: d.delay, reason: null, basis: t.basis };
315
+ const entry = { threadId: t.id, ...(own || d.importance === undefined ? {} : { importance: d.importance }), lane: d.lane, reason: d.reason, basis: t.basis };
269
316
  const notice = asksNotice(entry, t);
270
317
  try {
271
318
  await (notice ? hub.dashEntries([entry], [], notice) : hub.dashEntries([entry]));