eve-hub 0.16.9 → 0.16.11
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "eve-hub",
|
|
3
|
-
"version": "0.16.
|
|
3
|
+
"version": "0.16.11",
|
|
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",
|
|
@@ -4,10 +4,13 @@
|
|
|
4
4
|
import * as z from "zod";
|
|
5
5
|
|
|
6
6
|
export const VERBS = ["Land", "Answer", "Pick", "Look", "Review", "Read", "Write"];
|
|
7
|
-
const Row = z.object({
|
|
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
|
+
});
|
|
8
11
|
export const PlanBody = z.object({
|
|
9
12
|
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),
|
|
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),
|
|
11
14
|
later: z.object({ line: z.string().min(1).max(240), rows: z.array(Row) }).nullable(),
|
|
12
15
|
moved: z.array(z.object({ ask: z.string().min(1), reason: z.string().min(1).max(240) })),
|
|
13
16
|
});
|
package/schedules/dash-plan.mjs
CHANGED
|
@@ -9,6 +9,10 @@
|
|
|
9
9
|
// or the local day changed. Answering an ask doesn't make one: the answered row drops out in the
|
|
10
10
|
// app. The previous plan goes into the prompt as a fact, so the list stands still between visits.
|
|
11
11
|
//
|
|
12
|
+
// Parking: a row may carry `park`, a local day. The first plan step on or after it defers that
|
|
13
|
+
// thread through the Dash entries, the way the human's own defer does, and stores the plan without
|
|
14
|
+
// the row. The day changing already makes a plan step, so parking needs no timer of its own.
|
|
15
|
+
//
|
|
12
16
|
// Plain JS on purpose: the schedule (TypeScript, bundled by eve) and the tests and replay (plain
|
|
13
17
|
// node) import the same module.
|
|
14
18
|
import { said, clip } from "./thread-brief.mjs";
|
|
@@ -56,13 +60,11 @@ export function waitingThreads(snap) {
|
|
|
56
60
|
}
|
|
57
61
|
|
|
58
62
|
/** 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
|
|
60
|
-
const
|
|
61
|
-
return
|
|
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();
|
|
63
|
+
const threadAskKeys = (snap, t, human = humanOf(snap)) => {
|
|
64
|
+
const m = askingMessage((t.messages ?? []).filter(said), human);
|
|
65
|
+
return [...(m ? [`${t.id}@${m.id}`] : []), ...requestsOf(snap, t.id).map((r) => `${t.id}@${r.id}`)];
|
|
65
66
|
};
|
|
67
|
+
const askKeys = (snap) => waitingThreads(snap).flatMap((t) => threadAskKeys(snap, t)).sort();
|
|
66
68
|
|
|
67
69
|
// --- facts --------------------------------------------------------------------------------
|
|
68
70
|
|
|
@@ -99,6 +101,34 @@ export function planCards(snap, { now = Date.now(), sizes = {} } = {}) {
|
|
|
99
101
|
});
|
|
100
102
|
}
|
|
101
103
|
|
|
104
|
+
/**
|
|
105
|
+
* One fact per track that has a card: when the human last posted anywhere in it, and which of its threads
|
|
106
|
+
* have moved since then, so a chunk's `since` can say what changed there.
|
|
107
|
+
* @param {any} snap @param {{ id: string }[]} cards @param {{ now?: number }} [opts]
|
|
108
|
+
*/
|
|
109
|
+
export function trackFacts(snap, cards, { now = Date.now() } = {}) {
|
|
110
|
+
const human = humanOf(snap);
|
|
111
|
+
const wanted = new Set(cards.map((c) => trackOf(snap?.threads?.[c.id])));
|
|
112
|
+
const by = new Map([...wanted].map((track) => [track, { lastHuman: 0, threads: [] }]));
|
|
113
|
+
for (const t of Object.values(snap?.threads ?? {})) {
|
|
114
|
+
const f = by.get(trackOf(t));
|
|
115
|
+
if (!f || t.archived || (t.kind && t.kind !== "thread")) continue;
|
|
116
|
+
const ms = (t.messages ?? []).filter((m) => said(m) && at(m) < now);
|
|
117
|
+
f.lastHuman = Math.max(f.lastHuman, ...ms.filter((m) => isHuman(m, human)).map(at));
|
|
118
|
+
f.threads.push({ t, ms });
|
|
119
|
+
}
|
|
120
|
+
return [...by].map(([track, f]) => {
|
|
121
|
+
const summary = snap?.tracks?.[track]?.summary?.text ?? snap?.pathSummaries?.[track]?.text;
|
|
122
|
+
const moved = f.threads.map(({ t, ms }) => ({ t, fresh: ms.filter((m) => at(m) > f.lastHuman) })).filter((x) => x.fresh.length);
|
|
123
|
+
return {
|
|
124
|
+
track,
|
|
125
|
+
summary: summary ? clip(String(summary), 1500) : null,
|
|
126
|
+
humanLastPostedHere: f.lastHuman ? ago(now - f.lastHuman) : "never",
|
|
127
|
+
movedSince: moved.map(({ t, fresh }) => ({ id: t.id, title: t.title ?? t.id, newMessages: fresh.length, latest: `[${fresh.at(-1).author}] ${clip(String(fresh.at(-1).text), 300)}` })),
|
|
128
|
+
};
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
102
132
|
/** What the human is like right now, as facts; `decisions` is the text of their standing decisions. */
|
|
103
133
|
export function aboutHuman(snap, { now = Date.now(), decisions = "" } = {}) {
|
|
104
134
|
const human = humanOf(snap);
|
|
@@ -120,6 +150,7 @@ export function aboutHuman(snap, { now = Date.now(), decisions = "" } = {}) {
|
|
|
120
150
|
justAnswered: last ? { id: last.id, title: last.title, minutesAgo: Math.round((now - last.at) / 60_000) } : null,
|
|
121
151
|
shareOfMessagesInTheSameThreadAsThePreviousOne: mine.length > 1 ? Number((same / (mine.length - 1)).toFixed(2)) : null,
|
|
122
152
|
localTime: new Intl.DateTimeFormat("en-US", { timeZone: tz, weekday: "long", hour: "numeric", minute: "2-digit" }).format(now),
|
|
153
|
+
today: localDay(now, tz),
|
|
123
154
|
messagesByLocalHourLast14Days: hist,
|
|
124
155
|
mostRecentThreads: [...recent.values()],
|
|
125
156
|
profile: snap?.dash?.about?.profile ? clip(String(snap.dash.about.profile), 3000) : null,
|
|
@@ -164,22 +195,26 @@ export function replanReason(snap, now = Date.now()) {
|
|
|
164
195
|
|
|
165
196
|
const planned = (p) => [...p.chunks.flatMap((c) => c.rows), ...(p.later?.rows ?? [])];
|
|
166
197
|
|
|
167
|
-
export function planPrompt(cards, about, { human = "me", prev = null, now = Date.now() } = {}) {
|
|
198
|
+
export function planPrompt(cards, about, { human = "me", prev = null, now = Date.now(), tracks = [] } = {}) {
|
|
168
199
|
const open = new Set(cards.map((c) => c.id));
|
|
169
200
|
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
201
|
return [
|
|
171
202
|
`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
203
|
`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
|
|
204
|
+
`Then order every real ask the way a careful person would work through them, as a chief of staff briefing ${human}: one chunk per track, so ${human} switches context as little as possible. Each card's \`context\` is its track and repo. Put first what matters most and what is blocking others; weigh how taxing each one is (how much to read, diff size, how many decisions). The rule: never come back to a track. All asks of one track sit in its one chunk, or all in "later"; 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. Quick yeses that unblock bots go early.`,
|
|
174
205
|
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
206
|
`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)}`,
|
|
207
|
+
`Facts about each track with an ask: its summary, when ${human} last posted anywhere in it, and the threads that moved since then:\n${JSON.stringify(tracks)}`,
|
|
176
208
|
`Rules for the output:`,
|
|
177
209
|
`- 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
|
|
210
|
+
`- Each chunk is one track (code fills in its "track" from the cards). "title" names the track's work in plain words. "why" is one plain sentence saying why it sits at that position.`,
|
|
211
|
+
`- Each chunk's "since": one plain sentence, at most 160 characters, saying what changed in that track since ${human} last posted in it (the track facts), e.g. "The reviewer passed the fix and CI went green."`,
|
|
212
|
+
`- Each row's "why": one plain sentence, at most 160 characters, saying what ${human} has to decide or do and which option the asking bot picked, e.g. "The reviewer passed it and the builder recommends landing now."`,
|
|
213
|
+
`- Each row's "park": for an ask that has waited 5 days or more (its waitingSince), a day after today (YYYY-MM-DD, today is ${about?.today ?? "in the facts"}) when it gets set aside if still unanswered, usually tomorrow; null for every other row.`,
|
|
179
214
|
`- "minutes" is a whole number: an honest estimate of the effort for that row.`,
|
|
180
215
|
`- "verb" is one of ${VERBS.join(", ")}.`,
|
|
181
216
|
`- "consent" is true only when the ask is a pure yes/no approval with nothing to read (already reviewed, green).`,
|
|
182
|
-
`- "line" (top level)
|
|
217
|
+
`- "line" (top level) says plainly what changed overall since ${human} last looked, and "later.line" says plainly why those can wait. Each is one short sentence of at most ${LINE_MAX} characters, with no semicolon and no counts.`,
|
|
183
218
|
`- "moved": each row whose place changed since the previous plan, with a one-sentence reason (empty when there is no previous plan).`,
|
|
184
219
|
`- Every card id appears exactly once across notAsks, chunks and later. Use only ids from the cards. No empty or placeholder entries.`,
|
|
185
220
|
`"later" is null when nothing can wait.`,
|
|
@@ -189,9 +224,10 @@ export function planPrompt(cards, about, { human = "me", prev = null, now = Date
|
|
|
189
224
|
|
|
190
225
|
/**
|
|
191
226
|
* The rules a plan must meet beyond its shape (the schema checks the shape): every card once, only real ids, a
|
|
192
|
-
* track
|
|
227
|
+
* one chunk per track, lines short, park days after today. Throws an Error naming what is wrong; returns the plan
|
|
228
|
+
* (with the planner's `notAsks`), each chunk's `track` set from its cards.
|
|
193
229
|
*/
|
|
194
|
-
export function checkPlan(raw, cards) {
|
|
230
|
+
export function checkPlan(raw, cards, { today = null } = {}) {
|
|
195
231
|
const long = [raw.line, raw.later?.line].find((l) => typeof l === "string" && l.length > LINE_MAX);
|
|
196
232
|
if (long) throw new Error(`a voice line is ${long.length} characters; keep each to one sentence of at most ${LINE_MAX}`);
|
|
197
233
|
const ids = new Set(cards.map((c) => c.id));
|
|
@@ -203,10 +239,17 @@ export function checkPlan(raw, cards) {
|
|
|
203
239
|
const missing = [...ids].filter((id) => !seen.includes(id));
|
|
204
240
|
if (missing.length) throw new Error(`the plan leaves out ${missing.slice(0, 5).join(", ")}`);
|
|
205
241
|
const trackOfCard = new Map(cards.map((c) => [c.id, String(c.context).split(" | ")[0]]));
|
|
242
|
+
const mixed = raw.chunks.find((c) => new Set(c.rows.map((r) => trackOfCard.get(r.id))).size > 1);
|
|
243
|
+
if (mixed) throw new Error(`chunk "${mixed.title}" mixes tracks ${[...new Set(mixed.rows.map((r) => trackOfCard.get(r.id)))].join(", ")}; make one chunk per track`);
|
|
244
|
+
const chunkTracks = raw.chunks.map((c) => trackOfCard.get(c.rows[0].id));
|
|
245
|
+
const split = chunkTracks.find((t, i) => chunkTracks.indexOf(t) !== i) ?? (raw.later?.rows ?? []).map((r) => trackOfCard.get(r.id)).find((t) => chunkTracks.includes(t));
|
|
246
|
+
if (split) throw new Error(`track ${split} is split across chunks or between a chunk and "later"; put all of a track's asks in one chunk or all in "later"`);
|
|
206
247
|
const visits = planned(raw).map((r) => trackOfCard.get(r.id)).filter((t, i, a) => t !== a[i - 1]);
|
|
207
248
|
const back = visits.find((t, i) => visits.indexOf(t) !== i);
|
|
208
249
|
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
|
-
|
|
250
|
+
const early = today && planned(raw).find((r) => r.park && r.park <= today);
|
|
251
|
+
if (early) throw new Error(`${early.id} parks on ${early.park}, which isn't after today (${today}); park on a later day or set park to null`);
|
|
252
|
+
return { ...raw, chunks: raw.chunks.map((c) => ({ ...c, track: trackOfCard.get(c.rows[0].id) })), later: raw.later?.rows.length ? raw.later : null, moved: raw.moved.filter((m) => ids.has(m.ask)) };
|
|
210
253
|
}
|
|
211
254
|
|
|
212
255
|
/**
|
|
@@ -215,27 +258,60 @@ export function checkPlan(raw, cards) {
|
|
|
215
258
|
*/
|
|
216
259
|
export async function makePlan(generate, cards, about, options) {
|
|
217
260
|
const prompt = planPrompt(cards, about, options);
|
|
261
|
+
const check = (raw) => checkPlan(raw, cards, { today: about?.today ?? null });
|
|
218
262
|
const first = await generate(prompt);
|
|
219
263
|
try {
|
|
220
|
-
return
|
|
264
|
+
return check(first);
|
|
221
265
|
} catch (err) {
|
|
222
|
-
return
|
|
266
|
+
return check(await generate(`${prompt}\n\nYour previous answer:\n${JSON.stringify(first)}\n\nIt breaks a rule: ${err.message}. Return the corrected full plan.`));
|
|
223
267
|
}
|
|
224
268
|
}
|
|
225
269
|
|
|
270
|
+
/**
|
|
271
|
+
* Defers each thread of the stored plan whose park day has come and that still waits on the asks that plan saw (a new
|
|
272
|
+
* ask since then is the next plan's to judge), then stores that plan
|
|
273
|
+
* without those rows (same basis, so the defer change still makes a fresh plan). Returns the ids it parked.
|
|
274
|
+
*/
|
|
275
|
+
export async function parkDue(hub, snap, now = Date.now()) {
|
|
276
|
+
const prev = snap?.dash?.plan;
|
|
277
|
+
if (!prev) return [];
|
|
278
|
+
let was;
|
|
279
|
+
try { was = new Set(JSON.parse(prev.basis).asks ?? []); } catch { return []; }
|
|
280
|
+
const today = localDay(now, snap?.dash?.about?.tz);
|
|
281
|
+
const sameAsk = new Set(waitingThreads(snap).filter((t) => threadAskKeys(snap, t).every((k) => was.has(k))).map((t) => t.id));
|
|
282
|
+
const due = new Set(planned(prev).filter((r) => r.park && r.park <= today && sameAsk.has(r.id)).map((r) => r.id));
|
|
283
|
+
if (!due.size) return [];
|
|
284
|
+
await hub.dashEntries([...due].map((threadId) => ({ threadId, deferred: true })));
|
|
285
|
+
const keep = (rows) => rows.filter((r) => !due.has(r.id));
|
|
286
|
+
const laterRows = keep(prev.later?.rows ?? []);
|
|
287
|
+
await hub.dashPlan(prev.basis, {
|
|
288
|
+
line: prev.line,
|
|
289
|
+
chunks: prev.chunks.map((c) => ({ ...c, rows: keep(c.rows) })).filter((c) => c.rows.length),
|
|
290
|
+
later: laterRows.length ? { line: prev.later.line, rows: laterRows } : null,
|
|
291
|
+
moved: prev.moved.filter((m) => !due.has(m.ask)),
|
|
292
|
+
});
|
|
293
|
+
return [...due];
|
|
294
|
+
}
|
|
295
|
+
|
|
226
296
|
/**
|
|
227
297
|
* One plan step. Returns { planned: id|null, reason }.
|
|
228
298
|
* @param {any} hub @param {{ generate?: (prompt: string) => Promise<any>, decisions?: string, sizes?: any, now?: number, log?: any, snap?: any }} [opts]
|
|
229
299
|
*/
|
|
230
300
|
export async function runPlan(hub, { generate, decisions = "", sizes = {}, now = Date.now(), log = console, snap = null } = {}) {
|
|
231
301
|
if (!generate) return { planned: null, reason: "no model" };
|
|
232
|
-
|
|
302
|
+
let s = snap ?? (await hub.snapshot());
|
|
303
|
+
const parked = await parkDue(hub, s, now);
|
|
304
|
+
if (parked.length) {
|
|
305
|
+
log?.log?.(`dash plan: parked ${parked.join(", ")}`);
|
|
306
|
+
s = await hub.snapshot();
|
|
307
|
+
}
|
|
233
308
|
const reason = replanReason(s, now);
|
|
234
309
|
if (!reason) return { planned: null, reason: "plan is current" };
|
|
235
310
|
if (!waitingThreads(s).length) return { planned: null, reason: "nothing waits on the human" };
|
|
236
311
|
let plan;
|
|
237
312
|
try {
|
|
238
|
-
const
|
|
313
|
+
const cards = planCards(s, { now, sizes });
|
|
314
|
+
const { notAsks, ...body } = await makePlan(generate, cards, aboutHuman(s, { now, decisions }), { human: humanOf(s), prev: s?.dash?.plan ?? null, now, tracks: trackFacts(s, cards, { now }) });
|
|
239
315
|
plan = body;
|
|
240
316
|
} catch (err) {
|
|
241
317
|
log?.warn?.(`dash plan: skipped, ${err?.message ?? err}`);
|