eve-hub 0.10.0 → 0.10.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/package.json +1 -1
- package/schedules/dash-decisions.mjs +21 -0
- package/schedules/dash-pass.mjs +9 -3
- package/schedules/dash-plan-schema.mjs +14 -0
- package/schedules/dash-plan.mjs +248 -0
- package/schedules/dash-rules.mjs +2 -1
- package/schedules/dash-run.mjs +5 -2
- package/schedules/dash.js +6 -2
- package/schedules/hub.d.mts +1 -0
- package/schedules/hub.mjs +2 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# eve-hub
|
|
2
2
|
|
|
3
|
+
## 0.10.2
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Dash plan: main's pass makes one model call that reads every thread waiting on the human (an @-mention or a pending request) and orders the real asks into chunks, the way a person triaging by hand would; the hub stores each plan as a new row.
|
|
8
|
+
|
|
9
|
+
## 0.10.1
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- The kit's hub client can store a Dash plan (`dashPlan`); the hub checks it and keeps it as the newest.
|
|
14
|
+
|
|
3
15
|
## 0.10.0
|
|
4
16
|
|
|
5
17
|
### Minor Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "eve-hub",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.2",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"description": "Bot-side code shared by every hub agent: registry tools, the roster hook, the eve channel, the host sandbox and browser, and model helpers.",
|
|
6
6
|
"type": "module",
|
|
@@ -0,0 +1,21 @@
|
|
|
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
|
+
}
|
package/schedules/dash-pass.mjs
CHANGED
|
@@ -16,10 +16,14 @@
|
|
|
16
16
|
// whose full check has run classifies threads and nothing else; a cron fire
|
|
17
17
|
// is always the first run of its slot. If the stamp can't be written, the
|
|
18
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.
|
|
19
22
|
// The parts run one after another (they share the schedule's state); a
|
|
20
23
|
// failure in one never stops the others.
|
|
21
24
|
import { classifyPass, runClassify } from "./dash-classify.mjs";
|
|
22
25
|
import { runDash } from "./dash-run.mjs";
|
|
26
|
+
import { runPlan } from "./dash-plan.mjs";
|
|
23
27
|
import { runTracks } from "./track-summaries.mjs";
|
|
24
28
|
|
|
25
29
|
const SLOT_MIN = 8;
|
|
@@ -29,8 +33,8 @@ const slotOf = (ms) => {
|
|
|
29
33
|
return (min - ((min % 60) % SLOT_MIN)) * 60_000;
|
|
30
34
|
};
|
|
31
35
|
|
|
32
|
-
/** @param {any} hub @param {{ classify?: (target: any, snap: any) => Promise<any>, summarize?: (prompt: string) => Promise<string>, probe?: any, log?: any, now?: number }} [opts] */
|
|
33
|
-
export async function runPass(hub, { classify, summarize, probe, log = console, now = Date.now() } = {}) {
|
|
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() } = {}) {
|
|
34
38
|
const part = (name, run) => run().catch((err) => log?.warn?.(`dash pass: ${name} failed: ${err?.message ?? err}`));
|
|
35
39
|
const { state } = await hub.loadVersioned("dash");
|
|
36
40
|
const full = (state?.pass?.fullAt ?? 0) < slotOf(now);
|
|
@@ -42,8 +46,10 @@ export async function runPass(hub, { classify, summarize, probe, log = console,
|
|
|
42
46
|
log?.warn?.(`dash pass: full check skipped, its stamp wasn't saved: ${err?.message ?? err}`);
|
|
43
47
|
return;
|
|
44
48
|
}
|
|
45
|
-
|
|
49
|
+
let sizes = {};
|
|
50
|
+
await part("PRs", async () => { sizes = (await runDash(hub, { ...(probe ? { probe } : {}), log })).sizes ?? {}; });
|
|
46
51
|
await part("tracks", () => runTracks(hub, { summarize, log }));
|
|
52
|
+
await part("plan", () => runPlan(hub, { generate: plan, decisions, sizes, now, log }));
|
|
47
53
|
}
|
|
48
54
|
|
|
49
55
|
// One pass at a time in this process (classifyPass), a dispatch that comes
|
|
@@ -0,0 +1,14 @@
|
|
|
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({ 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() });
|
|
8
|
+
export const PlanBody = z.object({
|
|
9
|
+
line: z.string().min(1).max(240),
|
|
10
|
+
chunks: z.array(z.object({ title: z.string().min(1).max(120), why: z.string().min(1).max(240), rows: z.array(Row).min(1) })).max(40),
|
|
11
|
+
later: z.object({ line: z.string().min(1).max(240), rows: z.array(Row) }).nullable(),
|
|
12
|
+
moved: z.array(z.object({ ask: z.string().min(1), reason: z.string().min(1).max(240) })),
|
|
13
|
+
});
|
|
14
|
+
export const PlanOut = PlanBody.extend({ notAsks: z.array(z.object({ id: z.string().min(1), why: z.string() })) });
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
// The Dash plan of main's dash pass (dash.ts): one model call reads every thread that waits on the
|
|
2
|
+
// human, in full, and orders the real asks the way a person triaging by hand would. Code only
|
|
3
|
+
// gathers facts (what the bots said, how big the PRs are, what is stacked on what, what the human
|
|
4
|
+
// answered last, standing decisions); the model judges what is an ask, the chunks, the order and the
|
|
5
|
+
// voice. The hub stores the result (POST /api/dash/plan) as a new row; it is never overwritten.
|
|
6
|
+
// Design: docs/plans/dash-triage-brief.md.
|
|
7
|
+
//
|
|
8
|
+
// A plan is made when it is missing, a new ask appeared, the human pinned or deferred something,
|
|
9
|
+
// or the local day changed. Answering an ask doesn't make one: the answered row drops out in the
|
|
10
|
+
// app. The previous plan goes into the prompt as a fact, so the list stands still between visits.
|
|
11
|
+
//
|
|
12
|
+
// Plain JS on purpose: the schedule (TypeScript, bundled by eve) and the tests and replay (plain
|
|
13
|
+
// node) import the same module.
|
|
14
|
+
import { said, clip } from "./thread-brief.mjs";
|
|
15
|
+
import { VERBS } from "./dash-plan-schema.mjs";
|
|
16
|
+
|
|
17
|
+
export const PLAN_MODEL = "anthropic/claude-opus-5.5";
|
|
18
|
+
export const PLAN_TIMEOUT_MS = 240_000;
|
|
19
|
+
const MS_DAY = 86_400_000;
|
|
20
|
+
const PRS_PER_CARD = 6;
|
|
21
|
+
export const LINE_MAX = 90;
|
|
22
|
+
|
|
23
|
+
const humanOf = (snap) => snap?.humans?.[0]?.name ?? "me";
|
|
24
|
+
const isHuman = (m, human) => m.author === human || m.authorKind === "human";
|
|
25
|
+
const at = (m) => Date.parse(m?.at ?? "") || 0;
|
|
26
|
+
export const trackOf = (t) => t?.tags?.path ?? t?.trackId ?? t?.tags?.project ?? "unfiled";
|
|
27
|
+
export const repoOf = (t) => {
|
|
28
|
+
const pr = Object.values(t?.work?.bots ?? {}).map((b) => b?.pr).find(Boolean);
|
|
29
|
+
const m = typeof pr === "string" ? pr.match(/^([\w.-]+\/[\w.-]+)#\d+$/) : null;
|
|
30
|
+
return m ? m[1] : null;
|
|
31
|
+
};
|
|
32
|
+
const words = (s) => (String(s).match(/\S+/g) ?? []).length;
|
|
33
|
+
const ago = (ms) => {
|
|
34
|
+
const m = Math.max(0, ms) / 60_000;
|
|
35
|
+
return m < 2 ? "now" : m < 60 ? `${Math.round(m)}m` : m < 2880 ? `${Math.round(m / 60)}h` : `${Math.round(m / 1440)}d`;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** A verdict post's open questions, each with its options and who picked which: what the human still has to answer. */
|
|
39
|
+
const decisionsOf = (m) => (m.decisions ?? []).map((d) => `\n decision: ${d.question} Options: ${(d.options ?? []).map((o) => o.label + (o.pickedBy ? ` (picked by ${o.pickedBy})` : "")).join(" | ")}`).join("");
|
|
40
|
+
|
|
41
|
+
const requestsOf = (snap, id) => Object.values(snap?.requests ?? {}).filter((r) => r.threadId === id && (r.status ?? "pending") === "pending");
|
|
42
|
+
|
|
43
|
+
/** The first message of the ask a thread has now: a bot @-mentioned the human after their last message. */
|
|
44
|
+
const askingMessage = (ms, human) => {
|
|
45
|
+
const after = ms.slice(ms.findLastIndex((m) => isHuman(m, human)) + 1);
|
|
46
|
+
return after.find((m) => !isHuman(m, human) && (m.mentions ?? []).includes(human)) ?? null;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/** The threads that wait on the human: the plan's candidates. A pending request (a tool approval, a question) is an ask. */
|
|
50
|
+
export function waitingThreads(snap) {
|
|
51
|
+
const human = humanOf(snap);
|
|
52
|
+
return Object.values(snap?.threads ?? {}).filter((t) => {
|
|
53
|
+
if (t.archived || t.schedule || (t.kind && t.kind !== "thread") || snap?.dash?.entries?.[t.id]?.deferred) return false;
|
|
54
|
+
return !!askingMessage((t.messages ?? []).filter(said), human) || requestsOf(snap, t.id).length > 0;
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** One key per ask: the asking message, or the request. A new ask in a thread that was planned is a new key. */
|
|
59
|
+
const askKeys = (snap) => {
|
|
60
|
+
const human = humanOf(snap);
|
|
61
|
+
return waitingThreads(snap).flatMap((t) => {
|
|
62
|
+
const m = askingMessage((t.messages ?? []).filter(said), human);
|
|
63
|
+
return [...(m ? [`${t.id}@${m.id}`] : []), ...requestsOf(snap, t.id).map((r) => `${t.id}@${r.id}`)];
|
|
64
|
+
}).sort();
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
// --- facts --------------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
const PR_URL = /github\.com\/([\w.-]+\/[\w.-]+)\/pull\/(\d+)/g;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* One card per waiting thread, facts only. `sizes` is each PR's title and diff size as GitHub last said (dash-run.mjs).
|
|
73
|
+
* @param {any} snap @param {{ now?: number, sizes?: Record<string, { title: string, add: number, del: number, files: number }> }} [opts]
|
|
74
|
+
*/
|
|
75
|
+
export function planCards(snap, { now = Date.now(), sizes = {} } = {}) {
|
|
76
|
+
const human = humanOf(snap);
|
|
77
|
+
const threads = waitingThreads(snap);
|
|
78
|
+
const all = Object.values(snap?.threads ?? {});
|
|
79
|
+
return threads.map((t) => {
|
|
80
|
+
const ms = (t.messages ?? []).filter(said);
|
|
81
|
+
const after = ms.slice(ms.findLastIndex((m) => isHuman(m, human)) + 1).filter((m) => !isHuman(m, human));
|
|
82
|
+
const text = [...after.map((m) => `[${m.author}] ${m.text}${decisionsOf(m)}`), ...requestsOf(snap, t.id).map((r) => `[${r.agent}, waiting on a request] ${r.prompt}`)].join("\n\n");
|
|
83
|
+
const refs = new Map();
|
|
84
|
+
for (const m of ms.slice(-10)) for (const x of String(m.text).matchAll(PR_URL)) refs.set(`${x[1]}#${x[2]}`, [x[1], x[2]]);
|
|
85
|
+
const prs = [...refs.values()].slice(0, PRS_PER_CARD).map(([r, n]) => ({ number: Number(n), ...sizes[`${r}#${n}`] })).filter((p) => p.files != null);
|
|
86
|
+
const nums = prs.map((p) => p.number);
|
|
87
|
+
const mentioned = all.filter((o) => o.id !== t.id && (o.messages ?? []).some((m) => now - at(m) < 48 * 3_600_000 && at(m) < now && (String(m.text).includes(t.id) || nums.some((n) => new RegExp(`(?:pull/|#)${n}\\b`).test(String(m.text)))))).map((o) => o.id);
|
|
88
|
+
return {
|
|
89
|
+
id: t.id,
|
|
90
|
+
title: t.title ?? t.id,
|
|
91
|
+
context: `${trackOf(t)} | ${repoOf(t) ?? "no repo"}`,
|
|
92
|
+
askText: text.slice(-2500),
|
|
93
|
+
askWords: words(text),
|
|
94
|
+
prs,
|
|
95
|
+
waitingSince: after[0]?.at ?? null,
|
|
96
|
+
waitingAge: after[0] ? ago(now - at(after[0])) : "",
|
|
97
|
+
threadsMentioningThis: mentioned,
|
|
98
|
+
};
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** What the human is like right now, as facts; `decisions` is the text of their standing decisions. */
|
|
103
|
+
export function aboutHuman(snap, { now = Date.now(), decisions = "" } = {}) {
|
|
104
|
+
const human = humanOf(snap);
|
|
105
|
+
const tz = snap?.dash?.about?.tz || "UTC";
|
|
106
|
+
const hour = new Intl.DateTimeFormat("en-US", { hour: "numeric", hourCycle: "h23", timeZone: tz });
|
|
107
|
+
const hist = Array(24).fill(0);
|
|
108
|
+
const mine = [];
|
|
109
|
+
for (const t of Object.values(snap?.threads ?? {})) for (const m of t.messages ?? []) {
|
|
110
|
+
if (!said(m) || !isHuman(m, human) || at(m) >= now) continue;
|
|
111
|
+
mine.push({ id: t.id, title: t.title ?? t.id, at: at(m) });
|
|
112
|
+
if (now - at(m) < 14 * MS_DAY) hist[Number(hour.format(at(m)))]++;
|
|
113
|
+
}
|
|
114
|
+
mine.sort((a, b) => a.at - b.at);
|
|
115
|
+
const last = mine.at(-1);
|
|
116
|
+
const same = mine.filter((x, i) => i && mine[i - 1].id === x.id).length;
|
|
117
|
+
const recent = new Map();
|
|
118
|
+
for (const x of [...mine].reverse()) if (!recent.has(x.id) && recent.size < 5) recent.set(x.id, { id: x.id, title: x.title, minutesAgo: Math.round((now - x.at) / 60_000) });
|
|
119
|
+
return {
|
|
120
|
+
justAnswered: last ? { id: last.id, title: last.title, minutesAgo: Math.round((now - last.at) / 60_000) } : null,
|
|
121
|
+
shareOfMessagesInTheSameThreadAsThePreviousOne: mine.length > 1 ? Number((same / (mine.length - 1)).toFixed(2)) : null,
|
|
122
|
+
localTime: new Intl.DateTimeFormat("en-US", { timeZone: tz, weekday: "long", hour: "numeric", minute: "2-digit" }).format(now),
|
|
123
|
+
messagesByLocalHourLast14Days: hist,
|
|
124
|
+
mostRecentThreads: [...recent.values()],
|
|
125
|
+
profile: snap?.dash?.about?.profile ? clip(String(snap.dash.about.profile), 3000) : null,
|
|
126
|
+
standingDecisions: clip(decisions, 40_000),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// --- re-plan ------------------------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
const localDay = (now, tz) => {
|
|
133
|
+
try { return new Intl.DateTimeFormat("en-CA", { timeZone: tz || "UTC" }).format(now); } catch { return new Date(now).toISOString().slice(0, 10); }
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
/** What a plan was made from: the asks (the asking message or request of each waiting thread), pins, defers and the local day (the next pass compares it). */
|
|
137
|
+
export function planBasis(snap, now = Date.now()) {
|
|
138
|
+
const human = humanOf(snap);
|
|
139
|
+
const deferred = Object.entries(snap?.dash?.entries ?? {}).filter(([, e]) => e.deferred).map(([id]) => id).sort();
|
|
140
|
+
return JSON.stringify({
|
|
141
|
+
asks: askKeys(snap),
|
|
142
|
+
pins: [...(snap?.pins?.[human] ?? [])].sort(),
|
|
143
|
+
deferred,
|
|
144
|
+
day: localDay(now, snap?.dash?.about?.tz),
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Why a new plan is needed, or null. An ask that was answered is not a reason. */
|
|
149
|
+
export function replanReason(snap, now = Date.now()) {
|
|
150
|
+
const prev = snap?.dash?.plan;
|
|
151
|
+
if (!prev) return "no plan yet";
|
|
152
|
+
let was;
|
|
153
|
+
try { was = JSON.parse(prev.basis); } catch { return "the last plan's basis is unreadable"; }
|
|
154
|
+
const next = JSON.parse(planBasis(snap, now));
|
|
155
|
+
if (was.day !== next.day) return "a new day";
|
|
156
|
+
const fresh = next.asks.find((id) => !(was.asks ?? []).includes(id));
|
|
157
|
+
if (fresh) return `a new ask: ${fresh}`;
|
|
158
|
+
if (JSON.stringify(was.pins) !== JSON.stringify(next.pins)) return "a pin changed";
|
|
159
|
+
if (JSON.stringify(was.deferred) !== JSON.stringify(next.deferred)) return "a defer changed";
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// --- the call -----------------------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
const planned = (p) => [...p.chunks.flatMap((c) => c.rows), ...(p.later?.rows ?? [])];
|
|
166
|
+
|
|
167
|
+
export function planPrompt(cards, about, { human = "me", prev = null, now = Date.now() } = {}) {
|
|
168
|
+
const open = new Set(cards.map((c) => c.id));
|
|
169
|
+
const was = prev ? [...prev.chunks.flatMap((c) => c.rows.map((r) => ({ id: r.id, chunk: c.title }))), ...(prev.later?.rows ?? []).map((r) => ({ id: r.id, chunk: "later" }))].filter((r) => open.has(r.id)) : null;
|
|
170
|
+
return [
|
|
171
|
+
`You are main, ${human}'s chief of staff, triaging ${human}'s open threads by hand, exactly as ${human} would if they read every one in full. The cards below are threads where a bot said something after ${human}'s last message.`,
|
|
172
|
+
`For each card decide: is something actually waiting on ${human} (a question, a decision, an approval, a look, a sign-off only they can give), or not (a status report, a bot-to-bot handoff, already settled, or waiting on someone else)? Judge from the text of the ask, not from tags.`,
|
|
173
|
+
`Then order every real ask into chunks the way a careful person would work through them: what matters most and what is blocking others first; keep the same track and repo together so ${human} switches context as little as possible; weigh how taxing each one is (how much to read, diff size, how many decisions). Each card's \`context\` is its track and repo: neighbours with different contexts are a context switch. The rule: never come back to a track. All asks of one track sit next to each other in the order (chunks first, then later), in one chunk or neighbouring ones; within a track, put quick yeses first and deep reads last. The track of the thread they answered last (justAnswered) goes first, and that thread, if something waits there, is its first row: the share of their messages that stay in the previous message's thread is in the facts. A track whose asks can wait goes to "later" as a whole, not a piece of it. The thread ${human} answered last (justAnswered) is the context they already have loaded. Quick yeses that unblock bots go early; deep reads get their own chunk. Put asks that can genuinely wait into "later".`,
|
|
174
|
+
was ? [`The previous plan, in order (move a row only when a fact changed, and say why in "moved"):`, ...was.map((r) => `- ${r.id} (${r.chunk})`)].join("\n") : "There is no previous plan: \"moved\" is empty.",
|
|
175
|
+
`Facts about ${human}, including their standing decisions (what they have already settled and what they care about); now is ${new Date(now).toISOString()}:\n${JSON.stringify(about)}`,
|
|
176
|
+
`Rules for the output:`,
|
|
177
|
+
`- Row titles are plain words a person would say ("Land the hub-check segfault fix"), never leading with PR numbers or jargon, at most 60 characters.`,
|
|
178
|
+
`- Each chunk has "title" and "why": one plain sentence saying why it sits at that position.`,
|
|
179
|
+
`- "minutes" is a whole number: an honest estimate of the effort for that row.`,
|
|
180
|
+
`- "verb" is one of ${VERBS.join(", ")}.`,
|
|
181
|
+
`- "consent" is true only when the ask is a pure yes/no approval with nothing to read (already reviewed, green).`,
|
|
182
|
+
`- "line" (top level) and "later.line": one short line each in main's voice, dry and cheeky on routine work, never on anything risky. You may tease the oldest waiting ask with its real age. Each is one short sentence of at most ${LINE_MAX} characters, with no semicolon and no counts. Titles and "why" are never jokes.`,
|
|
183
|
+
`- "moved": each row whose place changed since the previous plan, with a one-sentence reason (empty when there is no previous plan).`,
|
|
184
|
+
`- Every card id appears exactly once across notAsks, chunks and later. Use only ids from the cards. No empty or placeholder entries.`,
|
|
185
|
+
`"later" is null when nothing can wait.`,
|
|
186
|
+
`Cards:\n${JSON.stringify(cards)}`,
|
|
187
|
+
].join("\n\n");
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* The rules a plan must meet beyond its shape (the schema checks the shape): every card once, only real ids, a
|
|
192
|
+
* track's asks together, voice lines short. Throws an Error naming what is wrong; returns the plan (with the planner's `notAsks`).
|
|
193
|
+
*/
|
|
194
|
+
export function checkPlan(raw, cards) {
|
|
195
|
+
const long = [raw.line, raw.later?.line].find((l) => typeof l === "string" && l.length > LINE_MAX);
|
|
196
|
+
if (long) throw new Error(`a voice line is ${long.length} characters; keep each to one sentence of at most ${LINE_MAX}`);
|
|
197
|
+
const ids = new Set(cards.map((c) => c.id));
|
|
198
|
+
const seen = [...raw.notAsks.map((x) => x.id), ...planned(raw).map((r) => r.id)];
|
|
199
|
+
const unknown = seen.filter((id) => !ids.has(id));
|
|
200
|
+
if (unknown.length) throw new Error(`the plan names threads that aren't cards: ${unknown.slice(0, 5).join(", ")}`);
|
|
201
|
+
const twice = seen.filter((id, i) => seen.indexOf(id) !== i);
|
|
202
|
+
if (twice.length) throw new Error(`the plan places ${twice.slice(0, 5).join(", ")} twice`);
|
|
203
|
+
const missing = [...ids].filter((id) => !seen.includes(id));
|
|
204
|
+
if (missing.length) throw new Error(`the plan leaves out ${missing.slice(0, 5).join(", ")}`);
|
|
205
|
+
const trackOfCard = new Map(cards.map((c) => [c.id, String(c.context).split(" | ")[0]]));
|
|
206
|
+
const visits = planned(raw).map((r) => trackOfCard.get(r.id)).filter((t, i, a) => t !== a[i - 1]);
|
|
207
|
+
const back = visits.find((t, i) => visits.indexOf(t) !== i);
|
|
208
|
+
if (back) throw new Error(`the plan comes back to track ${back} after another track's asks; put all of a track's asks together`);
|
|
209
|
+
return { ...raw, later: raw.later?.rows.length ? raw.later : null, moved: raw.moved.filter((m) => ids.has(m.ask)) };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* The model's plan for these cards, with one repair round when its answer breaks a rule.
|
|
214
|
+
* `generate(prompt)` is the model's answer as an object of the plan's schema (dash-plan-schema.mjs PlanOut).
|
|
215
|
+
*/
|
|
216
|
+
export async function makePlan(generate, cards, about, options) {
|
|
217
|
+
const prompt = planPrompt(cards, about, options);
|
|
218
|
+
const first = await generate(prompt);
|
|
219
|
+
try {
|
|
220
|
+
return checkPlan(first, cards);
|
|
221
|
+
} catch (err) {
|
|
222
|
+
return checkPlan(await generate(`${prompt}\n\nYour previous answer:\n${JSON.stringify(first)}\n\nIt breaks a rule: ${err.message}. Return the corrected full plan.`), cards);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* One plan step. Returns { planned: id|null, reason }.
|
|
228
|
+
* @param {any} hub @param {{ generate?: (prompt: string) => Promise<any>, decisions?: string, sizes?: any, now?: number, log?: any, snap?: any }} [opts]
|
|
229
|
+
*/
|
|
230
|
+
export async function runPlan(hub, { generate, decisions = "", sizes = {}, now = Date.now(), log = console, snap = null } = {}) {
|
|
231
|
+
if (!generate) return { planned: null, reason: "no model" };
|
|
232
|
+
const s = snap ?? (await hub.snapshot());
|
|
233
|
+
const reason = replanReason(s, now);
|
|
234
|
+
if (!reason) return { planned: null, reason: "plan is current" };
|
|
235
|
+
if (!waitingThreads(s).length) return { planned: null, reason: "nothing waits on the human" };
|
|
236
|
+
let plan;
|
|
237
|
+
try {
|
|
238
|
+
const { notAsks, ...body } = await makePlan(generate, planCards(s, { now, sizes }), aboutHuman(s, { now, decisions }), { human: humanOf(s), prev: s?.dash?.plan ?? null, now });
|
|
239
|
+
plan = body;
|
|
240
|
+
} catch (err) {
|
|
241
|
+
log?.warn?.(`dash plan: skipped, ${err?.message ?? err}`);
|
|
242
|
+
return { planned: null, reason: String(err?.message ?? err) };
|
|
243
|
+
}
|
|
244
|
+
if (!plan.chunks.length && !plan.later) return { planned: null, reason: "no asks" };
|
|
245
|
+
const { id } = await hub.dashPlan(planBasis(s, now), plan);
|
|
246
|
+
log?.log?.(`dash plan: ${id} (${reason}): ${plan.chunks.length} chunks, ${planned(plan).length} rows`);
|
|
247
|
+
return { planned: id, reason };
|
|
248
|
+
}
|
package/schedules/dash-rules.mjs
CHANGED
|
@@ -285,6 +285,7 @@ const prOf = (n) => ({
|
|
|
285
285
|
title: n.title,
|
|
286
286
|
url: n.url,
|
|
287
287
|
head: n.headRefOid ?? null,
|
|
288
|
+
...(Number.isInteger(n.changedFiles) ? { size: { add: n.additions, del: n.deletions, files: n.changedFiles } } : {}),
|
|
288
289
|
draft: !!n.isDraft,
|
|
289
290
|
ci: String(n.commits?.nodes?.[0]?.commit?.statusCheckRollup?.state ?? "none").toLowerCase(),
|
|
290
291
|
review: String(n.reviewDecision ?? "none").toLowerCase(),
|
|
@@ -325,7 +326,7 @@ export function prsFromSearch(data) {
|
|
|
325
326
|
}
|
|
326
327
|
|
|
327
328
|
// The probe's query: the human's open PRs, plus `refs` ("owner/repo#12") by name.
|
|
328
|
-
const PR_FIELDS = "number title url headRefOid isDraft state merged mergeable reviewDecision repository { nameWithOwner } commits(last: 1) { nodes { commit { statusCheckRollup { state } } } }";
|
|
329
|
+
const PR_FIELDS = "number title url headRefOid additions deletions changedFiles isDraft state merged mergeable reviewDecision repository { nameWithOwner } commits(last: 1) { nodes { commit { statusCheckRollup { state } } } }";
|
|
329
330
|
const REF = /^([\w.-]+)\/([\w.-]+)#(\d+)$/;
|
|
330
331
|
// GitHub gives up on a GraphQL request after ~10 s (502/504); a chunk of 40
|
|
331
332
|
// names takes ~4 s.
|
package/schedules/dash-run.mjs
CHANGED
|
@@ -67,10 +67,12 @@ const hashOf = (text) => createHash("sha256").update(text).digest("base64url").s
|
|
|
67
67
|
const plural = (n) => `${n} PR${n === 1 ? "" : "s"}`;
|
|
68
68
|
|
|
69
69
|
// One tick. `probe(query)` -> the GraphQL answer (rejects on failure).
|
|
70
|
-
// Returns { ok }: false when the PRs couldn't be fetched at all
|
|
70
|
+
// Returns { ok, sizes }: ok is false when the PRs couldn't be fetched at all; sizes is each PR's title and
|
|
71
|
+
// diff size ("owner/repo#12" -> { title, add, del, files }) as GitHub said this tick, for the plan's cards.
|
|
71
72
|
export async function runDash(hub, { probe = (q) => withRetry(() => ghGraphql(q)), now = Date.now(), log = console } = {}) {
|
|
72
73
|
let ok = true;
|
|
73
74
|
let prFacts = {};
|
|
75
|
+
const sizes = {};
|
|
74
76
|
await runOnce(hub, "dash", async (snap, saved, at) => {
|
|
75
77
|
const { refs, reviewed, keep: waited } = dashWatchList(snap, saved);
|
|
76
78
|
const tracked = new Set(Object.keys(dashPrs(saved)));
|
|
@@ -85,6 +87,7 @@ export async function runDash(hub, { probe = (q) => withRetry(() => ghGraphql(q)
|
|
|
85
87
|
const found = answers.flatMap(prsFromSearch);
|
|
86
88
|
const searched = new Set(answers.flatMap((a) => prsFromSearch({ data: { search: a?.data?.search } })).map((p) => p.key));
|
|
87
89
|
prs = found.filter((p) => !reviewed.has(p.key) || searched.has(p.key) || tracked.has(p.key) || waited.has(p.key));
|
|
90
|
+
for (const p of found) if (p.size) sizes[p.key] = { title: p.title, ...p.size };
|
|
88
91
|
for (const p of found) if (reviewed.has(p.key)) facts[p.key] = { head: p.head ?? null, ci: p.ci };
|
|
89
92
|
skip = new Set(answers.flatMap((a, i) => [...failedRefs(a, chunks[i])]));
|
|
90
93
|
if (skip.size) note = `${plural(skip.size)} skipped: GitHub couldn't answer for ${[...skip].slice(0, 3).join(", ")}${skip.size > 3 ? ", …" : ""}`;
|
|
@@ -103,5 +106,5 @@ export async function runDash(hub, { probe = (q) => withRetry(() => ghGraphql(q)
|
|
|
103
106
|
return { posts: text ? [text] : [], state: changed ? { ...state, ...(saved?.pass ? { pass: saved.pass } : {}) } : saved, changed };
|
|
104
107
|
}, { now });
|
|
105
108
|
if (ok) await hub.dashChecked(prFacts);
|
|
106
|
-
return { ok };
|
|
109
|
+
return { ok, sizes };
|
|
107
110
|
}
|
package/schedules/dash.js
CHANGED
|
@@ -7,10 +7,13 @@
|
|
|
7
7
|
// ./dash-pass.mjs.
|
|
8
8
|
import { defineSchedule } from "eve/schedules";
|
|
9
9
|
import * as eveAi from "eve/ai";
|
|
10
|
-
import { generateText } from "ai";
|
|
10
|
+
import { generateText, Output } from "ai";
|
|
11
11
|
import { createHub } from "./hub.mjs";
|
|
12
12
|
import { modelClassify } from "./dash-classify.mjs";
|
|
13
13
|
import { dashPass } from "./dash-pass.mjs";
|
|
14
|
+
import { PLAN_MODEL, PLAN_TIMEOUT_MS } from "./dash-plan.mjs";
|
|
15
|
+
import { PlanOut } from "./dash-plan-schema.mjs";
|
|
16
|
+
import { standingDecisions } from "./dash-decisions.mjs";
|
|
14
17
|
import { modelSummarize } from "./track-summaries.mjs";
|
|
15
18
|
// eve 0.80 renamed `evaluate` to `decide`. The Mac bots run 0.81; the hosted bots' eve build is a
|
|
16
19
|
// 0.69-based branch with `evaluate` only, and a named import of the missing one fails that build.
|
|
@@ -19,6 +22,7 @@ export default defineSchedule({
|
|
|
19
22
|
cron: "*/8 * * * *",
|
|
20
23
|
async run() {
|
|
21
24
|
const hub = createHub();
|
|
22
|
-
|
|
25
|
+
const plan = async (prompt) => (await generateText({ model: PLAN_MODEL, prompt, output: Output.object({ schema: PlanOut }), maxRetries: 1, abortSignal: AbortSignal.timeout(PLAN_TIMEOUT_MS) })).output;
|
|
26
|
+
await hub.track("dash", () => dashPass(hub, { classify: modelClassify({ evaluate }), summarize: modelSummarize({ generateText }), plan, decisions: standingDecisions() }));
|
|
23
27
|
},
|
|
24
28
|
});
|
package/schedules/hub.d.mts
CHANGED
|
@@ -18,6 +18,7 @@ export type Hub = {
|
|
|
18
18
|
setSummary(path: string, text: string): Promise<unknown>;
|
|
19
19
|
dashClassified(): Promise<Ok & { classifiedAt: string }>;
|
|
20
20
|
fileThread(threadId: string, path: string): Promise<unknown>;
|
|
21
|
+
dashPlan(basis: string, plan: object): Promise<Ok & { id: string }>;
|
|
21
22
|
dashEntries(entries: object[], remove?: string[], notice?: Notice | null): Promise<Ok & { entries: DashEntries }>;
|
|
22
23
|
};
|
|
23
24
|
export function createHub(options?: { base?: string; agent?: string; fetchImpl?: typeof fetch }): Hub;
|
package/schedules/hub.mjs
CHANGED
|
@@ -72,6 +72,8 @@ export function createHub({ base = registryBase(), agent = "main", fetchImpl = f
|
|
|
72
72
|
// File an unfiled thread into a track (the classifier's pick), as a bot:
|
|
73
73
|
// the hub refuses a thread that's already filed and doesn't lock it.
|
|
74
74
|
fileThread: (threadId, path) => must(`/api/threads/${encodeURIComponent(threadId)}/track`, { method: "POST", body: JSON.stringify({ by: agent, path }) }),
|
|
75
|
+
// The plan main's pass made (wire DashPlanIn, checked in hosted-hub/src/domain/dash.mjs normalizePlan); the hub keeps it as the newest.
|
|
76
|
+
dashPlan: (basis, plan) => must("/api/dash/plan", { method: "POST", body: JSON.stringify({ by: agent, basis, plan }) }),
|
|
75
77
|
// `notice` ({ schedule, text, clientId? }): posted in that schedule's
|
|
76
78
|
// thread in the same write as the entries, or neither is kept.
|
|
77
79
|
dashEntries: (entries, remove = [], notice = null) => must("/api/dash/entries", { method: "POST", body: JSON.stringify({ by: agent, entries, remove, ...(notice ? { notice } : {}) }) }),
|