eve-hub 0.16.20 → 0.16.22

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.
@@ -7,6 +7,8 @@ import { isHubRequest } from "../hub-oidc.mjs";
7
7
  // a local-dev request, or one from the hub (a deployed bot, see hub-oidc.mjs). Resolving it as a user lets user-scoped
8
8
  // connections (open_pull_request's OAuth) key tokens to the configured human;
9
9
  // `localDev()` alone is not a user, so eve fails them with principal_required.
10
+ // eve serves `GET /eve/v1/info` only to operator principals; the hub reads it as this user to learn the bot's
11
+ // schedules and tools (hosted-hub readDeployment), so a request that passed the hub check may read it too.
10
12
  const dev = localDev();
11
13
  async function rosterUser(req) {
12
14
  const user = req.headers.get("x-roster-user");
@@ -14,7 +16,7 @@ async function rosterUser(req) {
14
16
  return null;
15
17
  if (!(await dev(req)) && !(await isHubRequest(req, getVercelOidcToken)))
16
18
  return null;
17
- return { authenticator: "roster", principalId: `roster:${user}`, principalType: "user", attributes: {} };
19
+ return { authenticator: "roster", principalId: `roster:${user}`, principalType: "user", attributes: {}, allowAgentInfo: true };
18
20
  }
19
21
  // No turnPolicy: eve's default steers a message into the running turn at its
20
22
  // next step. The registry sends turnPolicy "queue" for a bot's message, so it
@@ -1,9 +1,3 @@
1
1
  // The Vercel Connect connector that holds the workspace human's GitHub token (the eve-hub-github
2
- // App). The bots' `git__gh` (prepare.mjs) and main's dash pass (schedules/dash-run.mjs) ask it for
3
- // the same subject, so both act as the human. Its own module so a schedule can import it without
4
- // prepare.mjs's build-time imports.
2
+ // App). The bots' `git__gh` (prepare.mjs) asks it for the human's token, so they act as the human.
5
3
  export const GITHUB_CONNECTOR = "github/eve-hub-github";
6
-
7
- // The subject eve's connections resolve for a hub turn (channels/eve-roster-user.ts: principal
8
- // `roster:<human>`, authenticator `roster`, no issuer of its own, so eve uses the authenticator).
9
- export const rosterSubject = (human) => ({ type: "user", id: `roster:${human}`, issuer: "roster" });
package/kit-modules.json CHANGED
@@ -9,7 +9,6 @@
9
9
  "memory/bot",
10
10
  "memory/team",
11
11
  "memory/workspace",
12
- "schedules/dash",
13
12
  "schedules/prune",
14
13
  "schedules/wake",
15
14
  "skills/fanout",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eve-hub",
3
- "version": "0.16.20",
3
+ "version": "0.16.22",
4
4
  "license": "Apache-2.0",
5
5
  "description": "Bot-side code shared by every hub agent: registry tools, the roster hook, the eve channel, the host sandbox and browser, and model helpers.",
6
6
  "type": "module",
package/prepare.mjs CHANGED
@@ -11,8 +11,7 @@
11
11
  // is the host's fails the build, and the build never writes a sandbox;
12
12
  // - the kit extension, host-only workers and kit stubs are dropped, the
13
13
  // Vercel Kit connection is wired in, every bot of a build from a repo gets eve's git extension, and the
14
- // workspace's memory and each bot's team architecture are embedded in the bots' memory providers, and main's
15
- // standing decisions in its dash schedule (a function has no workspace cwd);
14
+ // workspace's memory and each bot's team architecture are embedded in the bots' memory providers, (a function has no workspace cwd);
16
15
  // - /hub/manifest: the roster and the commit sha the deployment was built
17
16
  // from, for the hub to sync from: a custom channel on main, routed in
18
17
  // vercel.mjs. From the hub, as on each bot's eve channel (hub-oidc.mjs).
@@ -24,7 +23,7 @@ import KIT_MODULES from "./kit-modules.json" with { type: "json" };
24
23
  import { applyKit, readBotFiles, syncKit } from "./sync-kit.mjs";
25
24
  export { DEPLOYED_KIT_FILES };
26
25
  import { deploymentFacts, hostedContext } from "./execution-context.mjs";
27
- import { activeEntries, botDomains, domainFolders, entryText, readBotMemory, readMemory, validateMemory } from "./memory.mjs";
26
+ import { botDomains, domainFolders, readBotMemory, readMemory, validateMemory } from "./memory.mjs";
28
27
  import { agentRunsOn, coordinatorOf, readWorkspaceFile, removeWorkspaceAgent } from "./workspace.mjs";
29
28
  import { architectureText } from "./team.mjs";
30
29
  import { sandboxReady } from "./sandbox-migrate.mjs";
@@ -165,16 +164,6 @@ const recall = () => teamRecall(text);
165
164
  export default defineMemory({ namespace: "hub-team-memory", scope: "workspace", provider: defineMemoryProvider({ recall: { "turn.started": recall } }) });
166
165
  `;
167
166
 
168
- // A hosted main's dash schedule: the standing decisions main's turns recall, embedded at build
169
- // time for the Dash plan, since a function has no memory/ to read. It replaces only the kit's
170
- // re-export; a workspace's own dash schedule (.ts or another source for the slot) stays.
171
- const DASH_SCHEDULE_FILE = join("schedules", "dash.ts");
172
- const isKitDashSchedule = (bot) => existsSync(join(bot, DASH_SCHEDULE_FILE)) && isKitReexport(readFileSync(join(bot, DASH_SCHEDULE_FILE), "utf8"), "schedules/dash");
173
- export const dashScheduleStub = (entries) => `import { dashSchedule } from "eve-hub/schedules/dash";
174
- const decisions = ${JSON.stringify(activeEntries(entries).map((e) => entryText(e)).join("\n"))};
175
- export default dashSchedule({ decisions: () => decisions });
176
- `;
177
-
178
167
  // Replaces the kit's execution-context stub (the local page) with the hosted
179
168
  // page, for a bot the definition says is hosted. Its removed-tools list is read
180
169
  // from the bot's folder as pruned.
@@ -245,7 +234,6 @@ export function prepareDeployment(dir) {
245
234
  const botDir = join(dir, "agents", name);
246
235
  const { entries } = readMemory(dir, { folders: domainFolders(botDomains(dir, botDir)) });
247
236
  writeFileSync(join(bot, "memory", "workspace.ts"), memoryProvider("workspace", entries, "Standing decision", "workspace"));
248
- if (name === coordinator && isKitDashSchedule(bot)) writeFileSync(join(bot, DASH_SCHEDULE_FILE), dashScheduleStub(entries));
249
237
  // A folder the roster doesn't list has no place in the team, and a function can't read the file the stub would.
250
238
  if (definition.agents.some((a) => a.name === name)) writeFileSync(join(bot, "memory", "team.ts"), teamProvider(architectureText(definition.data, name)));
251
239
  else rmSync(join(bot, "memory", "team.ts"), { force: true });
package/stubs.mjs CHANGED
@@ -42,7 +42,6 @@ export const MAIN_KIT_FILES = {
42
42
  "tools/dash_update.ts": ["Set Dash entries: importance, lane, reason and deferred per thread."],
43
43
  "tools/apply_tags.ts": ["Retag threads, merge or rename tags: applies a staged proposal or a new batch directly."],
44
44
  // Handler schedules: the hub fires them from its minute clock (registry/schedules.mjs).
45
- "schedules/dash.ts": ["When a bot's turn ends (fired by the hub), and every 8 minutes: the human's PRs, a Dash entry for each thread that changed, and a new summary for each track with news."],
46
45
  "schedules/prune.ts": ["Every 10 minutes: one prune pass over what archived or deleted threads leave on disk: worktrees, scratch, their-only uploads, idle bots' traces and workflow runs; unsaved work kept as refs/bundles."],
47
46
  };
48
47
 
@@ -54,10 +53,8 @@ export const MAIN_KIT_FILES = {
54
53
  // the kit's bash (HostSandbox's root, the daemon's env; eve's own bash runs
55
54
  // instead), the browser and the screenshot path of view_image,
56
55
  // restart_hub (the hub's own code), memory (the workspace's files), and main's prune schedule (it cleans the hub machine's disk).
57
- // main's dash schedule stays: off the hub's machine it has no PR data (its lookup runs `gh`).
58
56
  export const DEPLOYED_KIT_FILES = new Set([
59
57
  "channels/eve.ts",
60
- "schedules/dash.ts",
61
58
  "skills/onboarding-profile.ts",
62
59
  "tools/onboarding_scripts.ts",
63
60
  ...["react", "show", "fetch_upload", "update_thread", "list_threads", "read_thread", "post_message"].map((t) => `tools/${t}.ts`),
@@ -1,3 +1,2 @@
1
1
  - **Team.** `create_agent` takes `runsOn` (`hosted` or `local`) and commits the new bot to the workspace repository; it joins with the next deployment, and a `local` bot then runs on the owner's Mac. `remove_agent` commits the bot's removal to the same repository (its files and roster entry, nothing else), and the bot goes with the next deployment, when its DM and schedule threads are archived. If nobody on the team covers the work well, ask the human before going on.
2
2
  - **Memory.** Add an entry as your Memory section says, by committing the file to the workspace repository (see Changing it above). With no repository, write nothing and tell the human to link one first.
3
- - **Schedules.** Your `dash` schedule runs every 8 minutes: it classifies the threads that changed into the Dash and rewrites track summaries that have news, without PR data (a function has no `gh`).
@@ -1,6 +1,3 @@
1
1
  - **Team.** `create_agent` adds a bot to the workspace; if nobody on the team covers the work well, create a specialist (name, model, role, instructions), then route to it. Give planners and reviewers `preset: "read-only"` so they can't edit files. `remove_agent` removes a bot from the team and shuts it down (name); use it when the human asks. It can't remove you (the coordinator) or humans.
2
2
  - **Memory.** A new memory entry ships in the next workspace sync PR; each bot recalls it on its next turn, as a `Standing decision <id>:` or `Your standing decision <id>:` message.
3
- - **Summaries.** Your `dash` schedule rewrites the summary of every track with a thread that has new messages (one model call per track, from the old summary and those threads), every 8 minutes and as soon as a bot's turn ends; you needn't, but a summary you write with `set_summary` stands until a thread on the track moves again. `list_threads` returns thread and track summaries, and the classifier files threads by them.
4
- - **Classifier.** Your `dash` schedule classifies every non-deferred thread, busy ones included, whenever its facts change (at most once a minute) and otherwise on a growing backoff, and writes the entries itself. It reads the thread; whether it waits on the human is the model's call. You don't rewrite the Dash on each tick.
5
- - **Ticks.** The same schedule also scans PRs, threads where a bot asks the human, and Dash threads that moved on, and posts in your `dash` schedule thread (`sch-<your name>-dash`) only when something changed. On a tick, fix only what the classifier got wrong with `dash_update` (fields you leave out keep their value): change a `lane` (with its `reason`) the classifier got wrong, raise `importance` for what matters more given the human's role, set `deferred: true` for work the human parked, `remove` an entry that no longer applies. Often nothing needs changing. Don't reply in the tick thread beyond a one-line note, and mention nobody.
6
- - **Unfiled paths.** The classifier files unfiled threads onto an existing path in their project. When none fits, one notice gives every unfiled thread in that project alongside every path in use. Read their briefs and put each on a listed path where one fits, or a new `project/feature` path, with `update_thread` (`threadId` and `path` only, including threads you aren't in). Merge near-duplicates with `move_path`. Never move a human-locked path.
3
+ - **Summaries.** A track summary you write with `set_summary` stands until you write another. `list_threads` returns thread and track summaries.
@@ -3,8 +3,7 @@ import { z } from "zod";
3
3
  import { registryFetch } from "../registry-fetch.mjs";
4
4
  // main only (the registry refuses other bots). Writes Dash entries
5
5
  // (hosted-hub/src/domain/dash.mjs): importance / lane / reason / deferred on existing,
6
- // non-archived threads, merged field by field (POST /api/dash/entries). The
7
- // dash schedule classifies idle threads on its own; this is for corrections.
6
+ // non-archived threads, merged field by field (POST /api/dash/entries).
8
7
  const entry = z.object({
9
8
  threadId: z.string().describe("The thread this entry is about (an open thread, not a DM)."),
10
9
  importance: z.enum(["high", "normal", "low"]).optional().describe("How much this matters given the human's role. Required for a thread with no entry yet."),
@@ -16,7 +15,7 @@ const entry = z.object({
16
15
  deferred: z.boolean().optional().describe("true: on the backburner, hidden from the Dash and not classified again until set back to false."),
17
16
  });
18
17
  export default defineTool({
19
- description: "Set Dash entries: per thread, its importance (high|normal|low), lane (needs-you|running|fyi) with its reason, and deferred (backburner). Fields you leave out keep their value. The dash schedule classifies idle threads by itself; use this to correct an entry, defer a thread, or add one it hasn't reached. `remove` drops entries. ",
18
+ description: "Set Dash entries: per thread, its importance (high|normal|low), lane (needs-you|running|fyi) with its reason, and deferred (backburner). Fields you leave out keep their value. Use it to set or correct an entry, or to defer a thread. `remove` drops entries. ",
20
19
  inputSchema: z.object({ entries: z.array(entry).max(200).default([]), remove: z.array(z.string()).optional() }),
21
20
  async execute({ entries, remove }, ctx) {
22
21
  const res = await registryFetch(`/api/dash/entries`, {
@@ -1,367 +0,0 @@
1
- // The threads part of main's dash pass (schedules/dash-pass.mjs): gives
2
- // every open thread, busy or idle, a Dash entry (hosted-hub/src/domain/dash.mjs: importance,
3
- // lane, reason) and writes them back through POST /api/dash/entries.
4
- //
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
12
- // targets at once, each written as soon as it's decided.
13
- // modelClassify: one evaluation per thread on the AI Gateway
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
17
- // answer is the classification. No model, a failed call or an
18
- // invalid answer writes nothing for that thread, so it's tried
19
- // again next tick, and the pass doesn't stamp classifiedAt.
20
- // file a thread with an empty or project-only tags.path is a
21
- // target even when its basis hasn't changed, and its evaluation
22
- // carries a third choice: any path in use in its project, or
23
- // `new`. A path pick files the thread (hub.fileThread); `new`
24
- // goes to main in sch-main-dash, since `evaluate` can't write a
25
- // path title. A filed thread is never asked again.
26
- //
27
- // Plain JS on purpose: the schedule (TypeScript, bundled by eve) and the
28
- // tests (plain node) import the same module.
29
-
30
- import { createHash } from "node:crypto";
31
- import { coordinatorOf } from "../workspace.mjs";
32
- import { lastSaid, said, threadBrief, briefText } from "./thread-brief.mjs";
33
- import { busyBasis, factsText, laneFacts, factsKey, paragraphs, reasonLine } from "./lane-facts.mjs";
34
-
35
- export { threadBrief, briefText } from "./thread-brief.mjs";
36
-
37
- export const CLASSIFY_MAX = 30;
38
-
39
- const open = (t) => !!t && t.kind === "thread" && !t.schedule && !String(t.id).startsWith("sch-") && !t.archived;
40
- const working = (t) => Object.values(t?.status ?? {}).includes("working");
41
-
42
- // What a classification was made from: the last message, the live status
43
- // class (waiting / working / idle) and the project and issue type tags. The entry
44
- // keeps it (entry.basis); a thread is classified again once it differs. Kept
45
- // in step with hosted-hub/src/domain/dash.mjs classifyBasis (test/dash-entries.test.mjs).
46
- // The input version (hosted-hub/src/domain/dash.mjs BASIS_VERSION): bumped when what the
47
- // model reads changes, so every entry made from the older input is
48
- // classified once more, then stays quiet.
49
- export const BASIS_VERSION = "v:lanes1";
50
- export function classifyBasis(t) {
51
- const st = Object.values(t?.status ?? {});
52
- const live = st.includes("waiting") ? "waiting" : st.includes("working") ? "working" : "idle";
53
- // The newest status post (t.work, see the hub's lib/work-status.mjs), only
54
- // once a bot has posted one, so bases from before statuses existed stay valid.
55
- const reported = Object.values(t?.work?.bots ?? {}).map((b) => b?.messageId ?? "").sort().at(-1);
56
- return [lastSaid(t)?.id ?? "", live, t?.tags?.project ?? "", (t?.tags?.issueType ?? t?.tags?.domain) ?? "", ...(reported ? [reported] : []), BASIS_VERSION].join("|");
57
- }
58
-
59
- // Paths come from the snapshot's legacy view; all depths in use are choices.
60
- const filed = (t) => (t?.tags?.path ?? "").includes("/");
61
- export const askPath = (t) => open(t) && !filed(t) && !t?.tags?.locked?.includes("path");
62
- export const NEW_PATH = "new";
63
- export function pathsInProject(t, snap) {
64
- const project = t.tags?.project ?? t.tags?.path?.split("/")[0] ?? "";
65
- return Object.values(snap?.tracks ?? {})
66
- .filter((u) => u.projectId === project)
67
- .map((u) => ({ id: u.id, title: u.title, summary: u.summary?.text ?? snap?.pathSummaries?.[u.id]?.text ?? "", count: Object.values(snap?.threads ?? {}).filter((x) => open(x) && (x.tags?.path === u.id || x.tags?.path?.startsWith(u.id + "/"))).length }))
68
- .sort((a, b) => a.id.localeCompare(b.id));
69
- }
70
- export function pathQuestion(paths) {
71
- if (!paths.length) return null;
72
- const criteria = Object.fromEntries(paths.map((u) => [u.id, `Path ${u.id}: "${u.title}". ${u.summary} Open threads: ${u.count}.`]));
73
- criteria[NEW_PATH] = "No listed path fits this work, even read broadly; the coordinator must name a new path.";
74
- 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 };
75
- }
76
-
77
- // Which paragraph holds the ask the human would answer: the row quotes the one
78
- // the model points at, word for word, so code never guesses where it is. The
79
- // candidates are the paragraphs of the bot messages in the brief that @-mention
80
- // the human; the model is asked only when there are two or more (the API wants
81
- // two choices). The choices are labelled p0, p1, …: the gateway fails on
82
- // labels like `thr_0m7l#3`, not on how many there are.
83
- export function askCandidates(t, brief) {
84
- const ids = new Set([...(brief?.earlier ?? []), ...(brief?.recent ?? [])].map((l) => l.id));
85
- return (t?.messages ?? [])
86
- .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}`)))
87
- .flatMap((m) => paragraphs(m.text).map((text) => ({ author: m.author, text })))
88
- .map((c, i) => ({ id: `p${i}`, ...c }));
89
- }
90
- export function askQuestion(candidates) {
91
- const criteria = Object.fromEntries(candidates.map((c) => [c.id, `${c.author}: ${c.text.slice(0, 160)}`]));
92
- 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 };
93
- }
94
-
95
- // The threads to (re)classify this pass, at most `max`: those the Dash needs
96
- // (no entry, or the thread moved on) first, newest change first, so the
97
- // thread whose turn just ended (what fired this pass) is selected under the
98
- // cap whatever the backlog, and a thread whose answers keep failing sinks behind
99
- // fresh ones instead of holding the front; later passes drain the rest. Then those
100
- // asked only for a path, least recently classified first, so threads still
101
- // waiting on main's filing take turns and never crowd out the Dash.
102
- export function classifyTargets(snap, { max = CLASSIFY_MAX, now = Date.now() } = {}) {
103
- const entries = snap?.dash?.entries ?? {};
104
- const out = [];
105
- const pathOnly = [];
106
- for (const t of Object.values(snap?.threads ?? {})) {
107
- if (!open(t)) continue;
108
- const e = entries[t.id];
109
- if (e?.deferred) continue;
110
- const last = lastSaid(t);
111
- if (!last) continue;
112
- const facts = laneFacts(t, snap, now);
113
- const base = classifyBasis(t);
114
- const busy = working(t);
115
- const basis = busy ? busyBasis(base, factsKey(facts)) : base;
116
- const look = { due: !e || e.basis !== basis, basis };
117
- const path = askPath(t);
118
- if (!look.due && !path) continue;
119
- (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 });
120
- }
121
- const at = (x) => String(x ?? "");
122
- out.sort((a, b) => at(b.last.at).localeCompare(at(a.last.at)));
123
- pathOnly.sort((a, b) => at(a.entry?.updatedAt).localeCompare(at(b.entry?.updatedAt)) || at(a.last.at).localeCompare(at(b.last.at)));
124
- return [...out, ...pathOnly].slice(0, max);
125
- }
126
-
127
- // --- the model -------------------------------------------------------------
128
- // openai/gpt-6-luna-decisions on the AI Gateway, through an AI SDK
129
- // `evaluate` (eve/ai's `decide`): the schedule passes eve's; tests pass a
130
- // mock.
131
- export const DECISION_MODEL = "openai/gpt-6-luna-decisions";
132
-
133
- // What the thread is doing for the human, as one pick. Each state is one lane
134
- // (the entry's `lane`); `ready` isn't here, the hub derives it from the reviewer's
135
- // ship verdict on the PR's current head (entriesOf, hosted-hub/src/domain/dash.mjs).
136
- export const STATES = {
137
- "asks-you": "needs-you",
138
- stuck: "running", // a stall is a bot's to steer; it shows on the thread's own row, not as the human's task
139
- dropped: "needs-you",
140
- running: "running",
141
- settled: "fyi",
142
- };
143
-
144
- export const QUESTIONS = {
145
- state: {
146
- type: "choice",
147
- 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.",
148
- criteria: {
149
- "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.",
150
- 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.",
151
- 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.",
152
- 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.",
153
- 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.",
154
- },
155
- },
156
- importance: {
157
- type: "choice",
158
- instructions: "How much does this thread matter compared with the human's other work?",
159
- criteria: {
160
- high: "Core product work, a release, or something many other tasks depend on",
161
- normal: "Ordinary feature or fix work",
162
- low: "A side experiment, polish, chores or a nice-to-have",
163
- },
164
- },
165
- };
166
-
167
- // The evaluation's `state`: what the thread is, then its brief
168
- // (./thread-brief.mjs), the same block main's new-path notice carries.
169
- export function threadState(t, snap) {
170
- const brief = t.brief ?? threadBrief(t, snap);
171
- const status = Object.entries(t.status ?? {}).map(([b, s]) => `${b}: ${s}`).join(", ") || "all idle";
172
- return [
173
- `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}.`,
174
- `Bot status now: ${status}`,
175
- ...(t.facts ? [`Live facts:`, factsText(t.facts)] : []),
176
- briefText(brief),
177
- ].join("\n");
178
- }
179
-
180
- // One evaluation; returns { state, importance } when both answers are valid
181
- // choices, else null, plus `path` (a path id or `new`) for an unfiled
182
- // thread: `new` without a call when the project has no open paths, and left
183
- // out when that answer alone is invalid. Throws when the call fails.
184
- export async function decideThread(t, snap, { evaluate, model = DECISION_MODEL, timeoutMs = 20000 }) {
185
- const uq = t.paths ? pathQuestion(t.paths) : null;
186
- const asks = askCandidates(snap?.threads?.[t.id], t.brief ?? threadBrief(t, snap));
187
- const questions = { ...QUESTIONS, ...(asks.length > 1 ? { ask: askQuestion(asks) } : {}), ...(uq ? { path: uq } : {}) };
188
- const r = await evaluate({ model, state: threadState(t, snap), questions, maxRetries: 1, signal: AbortSignal.timeout(timeoutMs) });
189
- const out = {};
190
- for (const [name, q] of Object.entries(questions)) {
191
- const a = r?.answers?.[name];
192
- const ok = a?.type === "choice" && Object.hasOwn(q.criteria, a.choice);
193
- if (ok) out[name] = a.choice;
194
- else if (name !== "path" && name !== "ask") return null;
195
- }
196
- if (t.paths && !uq) out.path = NEW_PATH;
197
- if (asks.length === 1) out.ask = asks[0].text;
198
- else if (out.ask) out.ask = asks.find((c) => c.id === out.ask).text;
199
- return out;
200
- }
201
-
202
- // The schedule's classifier, one thread: the model's answer as is
203
- // ({ threadId, importance, lane, reason, path? }), or null when there's no
204
- // `evaluate`, the call failed or the answer is invalid (no entry for it).
205
- /** @param {{ evaluate?: (options: any) => Promise<any>, log?: any, model?: string, timeoutMs?: number }} [options] */
206
- export function modelClassify({ evaluate, log = console, ...opts } = {}) {
207
- return async (t, snap) => {
208
- if (!evaluate) return null;
209
- const a = await decideThread(t, snap, { evaluate, ...opts }).catch((err) => (log?.warn?.(`dash: decide ${t.id} failed, skipped: ${err?.message ?? err}`), undefined));
210
- if (a === null) log?.warn?.(`dash: decide ${t.id}: invalid answer, skipped`);
211
- if (!a) return null;
212
- const { state, ask, ...rest } = a;
213
- return { threadId: t.id, ...rest, lane: STATES[state], reason: reasonLine(state, t.brief, t.facts, ask) };
214
- };
215
- }
216
-
217
- // Every unfiled thread main should file, in the given projects: open, a
218
- // project, no path, not filed or unfiled by hand. Working threads too: the
219
- // grouping is done once, over the whole project.
220
- export function unfiledIn(snap, projects) {
221
- return Object.values(snap?.threads ?? {})
222
- .filter((t) => open(t) && askPath(t) && projects.has(t.tags?.project ?? ""))
223
- .sort((a, b) => String(a.createdAt ?? "").localeCompare(String(b.createdAt ?? "")));
224
- }
225
-
226
- // The notice main gets when a thread needs a new path: every unfiled thread
227
- // in that thread's project (unfiledIn), each with its brief, and the project's open paths with their threads. main
228
- // groups them all at once, creates a path per feature and files them.
229
- export function newPathNotice(threads, snap) {
230
- const byProject = new Map();
231
- for (const t of threads) byProject.set(t.tags?.project ?? "", [...(byProject.get(t.tags?.project ?? "") ?? []), t]);
232
- const brief = (t) => [`- ${t.id}`, ...briefText(threadBrief(t, snap)).split("\n").map((l) => ` ${l}`)];
233
- return [
234
- `@${coordinatorOf(snap?.agents)}, these are every unfiled thread in each project. Group each project's work in one go from its briefs, using read_thread when needed. Put each thread on a listed path where one fits, or on a new project/feature path, with update_thread (threadId and path only, including threads you aren't in). Merge near-duplicate paths with move_path. Never move a human-locked path.`,
235
- ...[...byProject].flatMap(([p, ts]) => {
236
- const paths = pathsInProject(ts[0], snap);
237
- return [
238
- "",
239
- `## ${p}`,
240
- ...(paths.length ? ["Paths in use:", ...paths.map((u) => `- ${u.id} "${u.title}" (${u.count} open threads)${u.summary ? `: ${u.summary}` : ""}`)] : ["No paths in use yet."]),
241
- `Unfiled (${ts.length}):`,
242
- ...ts.flatMap(brief),
243
- ];
244
- }),
245
- ].join("\n");
246
- }
247
-
248
- // The "asks you" notice: a thread whose lane changed to needs-you (its reason
249
- // says why), or from needs-you or ready (waiting on the human's sign-off) to
250
- // anything else. The model's pick decides; nothing here reads the messages. (A
251
- // pending request is posted by the rule scan, dash-rules.mjs.)
252
- const asking = (lane) => lane === "needs-you" || lane === "ready";
253
- // The notice for `e`, the new entry of target `t`, or null: committed with
254
- // it in one write (hub.dashEntries), so a verdict and its notice are kept
255
- // together or not at all, and a failed write is judged afresh next pass
256
- // against the entry as it still is.
257
- export function asksNotice(e, t, coordinator) {
258
- const was = t.entry?.lane;
259
- if (was === e.lane || (asking(was) && asking(e.lane))) return null;
260
- const link = `[${String(t.title ?? t.id).replace(/[[\]]/g, "")}](${t.id})`;
261
- const line = asking(e.lane) ? `needs you: ${link}, ${e.reason}` : asking(was) ? `no longer asking you: ${link}` : null;
262
- if (!line) return null;
263
- const key = `asks-${createHash("sha256").update(`${e.threadId}:${e.lane}:${e.basis}`).digest("hex").slice(0, 32)}`;
264
- return { schedule: "dash", text: `@${coordinator} dash: ${line}`, clientId: key };
265
- }
266
-
267
- // The `new` answers of a pass go to main in one notice per project (deduped
268
- // by the set of threads): not just these, every unfiled thread in their
269
- // projects, so main groups a project once instead of 30 at a time. Which
270
- // threads are unfiled is read from the hub now, after this pass's filing,
271
- // not from the pass's snapshot: another pass may have filed some meanwhile.
272
- // A failed read or post is logged; the threads are still unfiled, so they're
273
- // asked again.
274
- async function noticeNewPaths(hub, fresh, log) {
275
- if (!fresh.length || !hub.post) return;
276
- let snap;
277
- try {
278
- snap = await hub.snapshot();
279
- } catch (err) {
280
- return log?.warn?.(`dash: new-path notice skipped, no snapshot: ${err?.message ?? err}`);
281
- }
282
- const all = unfiledIn(snap, new Set(fresh.map((t) => t.tags?.project ?? "")));
283
- for (const project of new Set(all.map((t) => t.tags?.project ?? ""))) {
284
- const threads = all.filter((t) => (t.tags?.project ?? "") === project);
285
- const key = `paths-new-${createHash("sha256").update(threads.map((t) => t.id).sort().join(",")).digest("hex").slice(0, 32)}`;
286
- await hub.post("dash", newPathNotice(threads, snap), key).catch((err) => log?.warn?.(`dash: new-path notice failed: ${err?.message ?? err}`));
287
- }
288
- }
289
-
290
- // One classifier pass. Every target is decided at once, and each is written
291
- // (with its "asks you" notice) the moment its answer is in, so a thread's
292
- // entry never waits on another thread's call: the pass takes as long as its
293
- // slowest call (decideThread's timeoutMs), whatever the backlog, and the
294
- // thread whose turn just ended shows within its own call. Path picks are
295
- // filed after the pass (`filing`, see filePaths), outside it. Each entry
296
- // carries the basis its target was classified from, so a thread that moved
297
- // on meanwhile is looked at again next pass. The entry's reason is the lane's
298
- // one-line why, replaced with every classification.
299
- // Importance a human set (entry.importanceBy) is never sent. classifiedAt is
300
- // stamped when there was nothing to do or every target was written; a
301
- // skipped target or a failed write leaves it, so "never classified" and
302
- // "checked" stay distinct. Returns { written, filing }: `filing` settles when
303
- // the pass's path maintenance has (the schedule doesn't wait for it).
304
- /** @param {any} hub @param {{ classify?: (target: any, snap: any) => Promise<any>, max?: number, snap?: any, log?: any }} [opts] */
305
- export async function runClassify(hub, { classify = async () => null, max = CLASSIFY_MAX, snap = null, log = console } = {}) {
306
- const s = snap ?? (await hub.snapshot());
307
- const targets = classifyTargets(s, { max });
308
- const humans = new Set((s?.humans ?? []).map((h) => h.name));
309
- if (targets.length) log?.log?.(`dash: classifying ${targets.length} thread${targets.length === 1 ? "" : "s"}: ${targets.map((t) => t.id).join(", ")}`);
310
- const picks = [];
311
- const one = async (t) => {
312
- const d = await classify(t, s);
313
- if (!d) return 0;
314
- if (d.path) picks.push([t, d.path]);
315
- const own = humans.has(t.entry?.importanceBy);
316
- const entry = { threadId: t.id, ...(own || d.importance === undefined ? {} : { importance: d.importance }), lane: d.lane, reason: d.reason, basis: t.basis };
317
- const notice = asksNotice(entry, t, coordinatorOf(s?.agents));
318
- try {
319
- await (notice ? hub.dashEntries([entry], [], notice) : hub.dashEntries([entry]));
320
- return 1;
321
- } catch (err) {
322
- log?.warn?.(`dash: classify write for ${t.id} failed: ${err?.message ?? err}`);
323
- return 0;
324
- }
325
- };
326
- const written = (await Promise.all(targets.map(one))).reduce((a, b) => a + b, 0);
327
- if (written === targets.length) await hub.dashClassified?.().catch((err) => log?.warn?.(`dash: classifiedAt not stamped: ${err?.message ?? err}`));
328
- return { written, filing: filePaths(hub, picks, log) };
329
- }
330
-
331
- // Path maintenance after a pass, never part of it: the pass is over when its
332
- // entries are written and classifiedAt stamped, so the next one (a fresh
333
- // question) never waits on a filing request. Files each path pick, then sends the pass's `new` answers.
334
- // Never rejects; a failure is logged and the thread, still unfiled, is
335
- // asked again. A pass that starts meanwhile may ask the same thread again:
336
- // a second filing is refused (already filed), and the notice reads the hub's
337
- // state when it's built, so it never names a thread filed meanwhile.
338
- async function filePaths(hub, picks, log) {
339
- await Promise.all(picks.filter(([, path]) => path !== NEW_PATH).map(([t, path]) => hub.fileThread?.(t.id, path).catch((err) => log?.warn?.(`dash: file ${t.id} into ${path} failed: ${err?.message ?? err}`))));
340
- await noticeNewPaths(hub, picks.filter(([, path]) => path === NEW_PATH).map(([t]) => t), log);
341
- }
342
-
343
- // One pass at a time in this bot process, and none lost: the schedule runs
344
- // its pass through this. The hub can't know when a pass ends (a dispatch it
345
- // gave up on keeps running in eve; aborting the request doesn't stop it),
346
- // so the process that runs passes is the one that serializes them. A
347
- // dispatch that arrives during a pass returns at once and asks that pass for
348
- // one more round when it ends, which reads a fresh snapshot. The flag lives
349
- // on globalThis, which eve keeps across dispatches, and goes with the process.
350
- const RUNNING = Symbol.for("hub.dash.classify.running");
351
- /** @param {() => Promise<unknown>} pass */
352
- export async function classifyPass(pass) {
353
- const g = /** @type {any} */ (globalThis);
354
- if (g[RUNNING]) {
355
- g[RUNNING].again = true;
356
- return;
357
- }
358
- const run = (g[RUNNING] = { again: true });
359
- try {
360
- while (run.again) {
361
- run.again = false;
362
- await pass();
363
- }
364
- } finally {
365
- g[RUNNING] = undefined;
366
- }
367
- }
@@ -1,21 +0,0 @@
1
- // The human's standing decisions (the workspace's memory/*.md), as one text for the plan's facts
2
- // about them. Read where the schedule runs; a workspace that isn't there is logged and the plan
3
- // goes without, so it never fails the pass.
4
- import { existsSync } from "node:fs";
5
- import { join } from "node:path";
6
- import { activeEntries, entryText, MEMORY_DIR, readMemory } from "../memory.mjs";
7
- import { findWorkspaceRoot } from "../workspace.mjs";
8
-
9
- export function standingDecisions({ root = null, log = console } = {}) {
10
- try {
11
- const dir = root ?? findWorkspaceRoot();
12
- if (!dir) throw new Error("no workspace root");
13
- if (!existsSync(join(dir, MEMORY_DIR))) throw new Error(`no ${MEMORY_DIR}/ in ${dir}`);
14
- const { entries, errors } = readMemory(dir);
15
- for (const e of errors) log?.warn?.(`dash plan: memory: ${e}`);
16
- return activeEntries(entries).map((e) => entryText(e)).join("\n");
17
- } catch (err) {
18
- log?.warn?.(`dash plan: no standing decisions: ${err?.message ?? err}`);
19
- return "";
20
- }
21
- }
@@ -1,57 +0,0 @@
1
- // The one check behind main's Dash (schedules/dash.ts), every 8 minutes and
2
- // again as soon as a bot's turn ends (registry/server.mjs, schedules.runSoon):
3
- //
4
- // threads runClassify (./dash-classify.mjs): one model call per thread whose
5
- // facts changed since its entry was written, busy or idle; none for a
6
- // thread that hasn't changed. Every run.
7
- // PRs runDash (./dash-run.mjs): one chunked GitHub query for the human's
8
- // PRs, what Dash items wait on and the PRs reviews are about. No model.
9
- // tracks runTracks (./track-summaries.mjs): one model call per track with a
10
- // thread that has news; none for a track whose threads haven't moved.
11
- //
12
- // The PRs and the tracks run once per cron slot (*/8: the minute rounded down
13
- // to a multiple of 8): when the pass's last full attempt (pass.fullAt in the
14
- // schedule's state, written before the GitHub call, so an outage doesn't
15
- // reopen the gate) is older than the current slot. A turn end inside a slot
16
- // whose full check has run classifies threads and nothing else; a cron fire
17
- // is always the first run of its slot. If the stamp can't be written, the
18
- // full check is skipped. A pass over an unchanged hub makes no model call.
19
- // The plan (runPlan, ./dash-plan.mjs) comes last, once per slot: one model call that reads every
20
- // waiting thread when the plan is missing or what it was made from changed (a new ask, a pin, a
21
- // defer, a new day); none otherwise. It runs last because it is the slow call.
22
- // The parts run one after another (they share the schedule's state); a
23
- // failure in one never stops the others.
24
- import { classifyPass, runClassify } from "./dash-classify.mjs";
25
- import { runDash } from "./dash-run.mjs";
26
- import { runPlan } from "./dash-plan.mjs";
27
- import { runTracks } from "./track-summaries.mjs";
28
-
29
- const SLOT_MIN = 8;
30
- // */8 restarts every hour: :56 is followed by :00.
31
- const slotOf = (ms) => {
32
- const min = Math.floor(ms / 60_000);
33
- return (min - ((min % 60) % SLOT_MIN)) * 60_000;
34
- };
35
-
36
- /** @param {any} hub @param {{ classify?: (target: any, snap: any) => Promise<any>, summarize?: (prompt: string) => Promise<string>, plan?: (prompt: string) => Promise<string>, decisions?: string, probe?: any, log?: any, now?: number }} [opts] */
37
- export async function runPass(hub, { classify, summarize, plan, decisions, probe, log = console, now = Date.now() } = {}) {
38
- const part = (name, run) => run().catch((err) => log?.warn?.(`dash pass: ${name} failed: ${err?.message ?? err}`));
39
- const { state } = await hub.loadVersioned("dash");
40
- const full = (state?.pass?.fullAt ?? 0) < slotOf(now);
41
- await part("threads", () => runClassify(hub, { classify, log }));
42
- if (!full) return;
43
- try {
44
- await hub.commit("dash", { state: { ...state, pass: { ...state?.pass, fullAt: now } } }, []);
45
- } catch (err) {
46
- log?.warn?.(`dash pass: full check skipped, its stamp wasn't saved: ${err?.message ?? err}`);
47
- return;
48
- }
49
- let sizes = {};
50
- await part("PRs", async () => { sizes = (await runDash(hub, { ...(probe ? { probe } : {}), log })).sizes ?? {}; });
51
- await part("tracks", () => runTracks(hub, { summarize, log }));
52
- await part("plan", () => runPlan(hub, { generate: plan, decisions, sizes, now, log }));
53
- }
54
-
55
- // One pass at a time in this process (classifyPass), a dispatch that comes
56
- // in meanwhile asks for one more round when it ends.
57
- export const dashPass = (hub, options) => classifyPass(() => runPass(hub, options));
@@ -1,17 +0,0 @@
1
- // The shape of a Dash plan as the planner must return it (generateText `output`). The hub's check is
2
- // the wire's DashPlanIn (packages/wire/src/routes.ts); test/dash-plan.test.mjs fails if the two differ.
3
- // `notAsks` is the planner's own: threads it judged not to wait on the human, never sent to the hub.
4
- import * as z from "zod";
5
-
6
- export const VERBS = ["Land", "Answer", "Pick", "Look", "Review", "Read", "Write"];
7
- const Row = z.object({
8
- id: z.string().min(1), title: z.string().min(1).max(120), verb: z.enum(VERBS), minutes: z.int().min(0).max(600), consent: z.boolean(),
9
- why: z.string().min(1).max(160).optional(), park: z.iso.date().nullable().optional(),
10
- });
11
- export const PlanBody = z.object({
12
- line: z.string().min(1).max(240),
13
- chunks: z.array(z.object({ title: z.string().min(1).max(120), why: z.string().min(1).max(240), track: z.string().min(1).max(160).optional(), since: z.string().min(1).max(160).optional(), rows: z.array(Row).min(1) })).max(40),
14
- later: z.object({ line: z.string().min(1).max(240), rows: z.array(Row) }).nullable(),
15
- moved: z.array(z.object({ ask: z.string().min(1), reason: z.string().min(1).max(240) })),
16
- });
17
- export const PlanOut = PlanBody.extend({ notAsks: z.array(z.object({ id: z.string().min(1), why: z.string() })) });