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 +13 -0
- package/execution-context.mjs +79 -0
- package/package.json +9 -2
- package/prepare.mjs +219 -0
- package/schedules/dash-classify.mjs +84 -37
- package/schedules/dash-pass.mjs +51 -0
- package/schedules/dash-rules.mjs +4 -1
- package/schedules/dash-run.mjs +11 -4
- package/schedules/dash.js +14 -16
- package/schedules/hub.d.mts +2 -1
- package/schedules/hub.mjs +4 -1
- package/schedules/lane-facts.mjs +134 -0
- package/schedules/thread-brief.mjs +3 -1
- package/schedules/track-summaries.mjs +108 -0
- package/stubs.mjs +46 -0
- package/templates/execution-context-hosted-main.md +4 -0
- package/templates/execution-context-local-main.md +7 -0
- package/templates/execution-context-local.md +4 -0
- package/templates/main-instructions.md +5 -11
- package/templates/workspace-protocol.md +5 -5
- package/tools/dash_update.d.ts +4 -4
- package/tools/dash_update.js +7 -7
- package/tools/post_message.d.ts +2 -0
- package/tools/post_message.js +6 -5
- package/vendor/world-turso.mjs +6416 -0
- package/vercel.mjs +15 -0
- package/workspace.mjs +33 -2
- package/world.mjs +27232 -0
- package/schedules/classify.d.ts +0 -5
- package/schedules/classify.js +0 -20
- package/schedules/tracks-lead.mjs +0 -107
- package/schedules/tracks.d.ts +0 -11
- package/schedules/tracks.js +0 -18
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.
|
|
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
|
|
2
|
-
// every
|
|
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)
|
|
6
|
-
//
|
|
7
|
-
// since
|
|
8
|
-
//
|
|
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
|
|
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:
|
|
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)
|
|
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
|
|
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
|
-
|
|
90
|
-
|
|
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
|
-
|
|
144
|
+
state: {
|
|
107
145
|
type: "choice",
|
|
108
|
-
instructions: "
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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 {
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
|
202
|
-
//
|
|
203
|
-
//
|
|
204
|
-
//
|
|
205
|
-
const
|
|
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?.
|
|
212
|
-
if (was === e.
|
|
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 =
|
|
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.
|
|
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
|
|
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 }),
|
|
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]));
|