eve-hub 0.16.21 → 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.
- package/github-connector.mjs +1 -7
- package/kit-modules.json +0 -1
- package/package.json +1 -1
- package/prepare.mjs +2 -14
- package/stubs.mjs +0 -3
- package/templates/execution-context-hosted-main.md +0 -1
- package/templates/execution-context-local-main.md +1 -4
- package/tools/dash_update.js +2 -3
- package/schedules/dash-classify.mjs +0 -367
- package/schedules/dash-decisions.mjs +0 -21
- package/schedules/dash-pass.mjs +0 -57
- package/schedules/dash-plan-schema.mjs +0 -17
- package/schedules/dash-plan.mjs +0 -332
- package/schedules/dash-rules.mjs +0 -347
- package/schedules/dash-run.mjs +0 -167
- package/schedules/dash.d.ts +0 -11
- package/schedules/dash.js +0 -30
- package/schedules/hub.d.mts +0 -28
- package/schedules/hub.mjs +0 -164
- package/schedules/lane-facts.mjs +0 -136
- package/schedules/thread-brief.mjs +0 -110
- package/schedules/track-summaries.mjs +0 -108
package/schedules/hub.mjs
DELETED
|
@@ -1,164 +0,0 @@
|
|
|
1
|
-
// The coordinator's schedules talk to the registry over its HTTP API
|
|
2
|
-
// (ROSTER_REGISTRY_URL): a handler's own output (`to()`) never reaches the
|
|
3
|
-
// hub, so they read /api/state and post into their schedule's thread
|
|
4
|
-
// (sch-<coordinator>-<id>) as "system", which wakes whoever the text @-mentions.
|
|
5
|
-
//
|
|
6
|
-
// Plain JS on purpose: the schedules (TypeScript, bundled by eve) and the
|
|
7
|
-
// tests (plain node) import the same module.
|
|
8
|
-
import { createHash } from "node:crypto";
|
|
9
|
-
import { registryBase, registryFetch } from "../registry-fetch.mjs";
|
|
10
|
-
import { coordinatorOf } from "../workspace.mjs";
|
|
11
|
-
|
|
12
|
-
export function createHub({ base = registryBase(), agent, fetchImpl = fetch, log = console } = {}) {
|
|
13
|
-
const call = async (path, init = {}) => {
|
|
14
|
-
const res = await registryFetch(path, { ...init, headers: { "content-type": "application/json", ...(init.headers ?? {}) } }, { base, fetchImpl, deadlineMs: 10_000 });
|
|
15
|
-
const text = await res.text();
|
|
16
|
-
let body = null;
|
|
17
|
-
try {
|
|
18
|
-
body = text ? JSON.parse(text) : null;
|
|
19
|
-
} catch {}
|
|
20
|
-
return { status: res.status, ok: res.ok, body: body ?? text };
|
|
21
|
-
};
|
|
22
|
-
const must = async (path, init) => {
|
|
23
|
-
const r = await call(path, init);
|
|
24
|
-
if (!r.ok) throw new Error(`${init?.method ?? "GET"} ${path}: ${r.status} ${typeof r.body === "string" ? r.body : JSON.stringify(r.body)}`.slice(0, 400));
|
|
25
|
-
return r.body;
|
|
26
|
-
};
|
|
27
|
-
// The run the hub is following for this process's handler (`track`), so a commit says which run it belongs to.
|
|
28
|
-
let run = null;
|
|
29
|
-
// The schedules run as the coordinator; with no `agent` given, its name is read once from the hub's roster.
|
|
30
|
-
let named = agent ? Promise.resolve(agent) : null;
|
|
31
|
-
const who = () => (named ??= must("/api/state").then((s) => coordinatorOf(s?.agents)));
|
|
32
|
-
const threadOf = async (id) => `sch-${await who()}-${id.replaceAll("/", ".")}`;
|
|
33
|
-
const statePath = async (id) => `/api/schedules/${encodeURIComponent(await who())}/${id.split("/").map(encodeURIComponent).join("/")}/state`;
|
|
34
|
-
return {
|
|
35
|
-
snapshot: () => must("/api/state"),
|
|
36
|
-
roster: () => must("/api/roster"),
|
|
37
|
-
load: async (id) => (await must(await statePath(id)))?.state ?? null,
|
|
38
|
-
// The state and when it was saved (the version a run decided on).
|
|
39
|
-
loadVersioned: async (id) => {
|
|
40
|
-
const body = await must(await statePath(id));
|
|
41
|
-
return { state: body?.state ?? null, savedAt: body?.savedAt ?? null, paused: body?.paused === true };
|
|
42
|
-
},
|
|
43
|
-
save: async (id, state) => must(await statePath(id), { method: "PUT", body: JSON.stringify({ state }) }),
|
|
44
|
-
// Save `state` (if given: { state }) and post `posts` in one step.
|
|
45
|
-
commit: async (id, withState, posts) => must(await statePath(id), { method: "PUT", body: JSON.stringify({ ...withState, posts, ...(run?.id === id ? { run: run.run } : {}) }) }),
|
|
46
|
-
// One dispatch of the schedule `id`: the hub is told it started (it answers with the run's id and whether the
|
|
47
|
-
// schedule is paused, which skips `fn`) and how it ended, so the hub's run history is what the handler did. A hub
|
|
48
|
-
// that doesn't keep run history (the local one) answers 404 to the start, and `fn` runs untracked.
|
|
49
|
-
track: async (id, fn) => {
|
|
50
|
-
const runs = `${(await statePath(id)).slice(0, -"/state".length)}/runs`;
|
|
51
|
-
const started = await call(runs, { method: "POST", body: "{}" }).catch(() => null);
|
|
52
|
-
if (!started?.ok) return fn();
|
|
53
|
-
if (started.body.paused) {
|
|
54
|
-
log.log(`schedule ${await who()}/${id} skipped: paused`);
|
|
55
|
-
return;
|
|
56
|
-
}
|
|
57
|
-
run = { id, run: started.body.run };
|
|
58
|
-
const end = (error) => call(`${runs}/${encodeURIComponent(run.run)}/end`, { method: "POST", body: JSON.stringify(error ? { error: error.slice(0, 2000) } : {}) }).catch(() => null);
|
|
59
|
-
try {
|
|
60
|
-
const result = await fn();
|
|
61
|
-
await end();
|
|
62
|
-
return result;
|
|
63
|
-
} catch (error) {
|
|
64
|
-
await end(String(error?.message ?? error) || "the run failed");
|
|
65
|
-
throw error;
|
|
66
|
-
} finally {
|
|
67
|
-
run = null;
|
|
68
|
-
}
|
|
69
|
-
},
|
|
70
|
-
// A one-off post outside a run (runOnce commits through `commit`).
|
|
71
|
-
post: async (id, text, key) => must(`/api/threads/${encodeURIComponent(await threadOf(id))}/messages`, { method: "POST", body: JSON.stringify({ author: "system", text, ...(key ? { clientId: key } : {}) }) }),
|
|
72
|
-
// The Dash was scanned now (hosted-hub/src/domain/dash.mjs checkedAt), changed or not.
|
|
73
|
-
// `prFacts` ({ "owner/repo#12": { head, ci } }): what the scan's GitHub query said of the PRs reviews are about.
|
|
74
|
-
dashChecked: async (prFacts = {}) => must("/api/dash/checked", { method: "POST", body: JSON.stringify({ by: await who(), prFacts }) }),
|
|
75
|
-
// A track's summary (set_summary's endpoint, as main).
|
|
76
|
-
setSummary: async (path, text) => must("/api/summaries", { method: "POST", body: JSON.stringify({ by: await who(), scope: `path:${path}`, text }) }),
|
|
77
|
-
// A classifier pass finished (hosted-hub/src/domain/dash.mjs classifiedAt).
|
|
78
|
-
dashClassified: async () => must("/api/dash/classified", { method: "POST", body: JSON.stringify({ by: await who() }) }),
|
|
79
|
-
// Dash entries from the classifier (hosted-hub/src/domain/dash.mjs), merged per field.
|
|
80
|
-
// File an unfiled thread into a track (the classifier's pick), as a bot:
|
|
81
|
-
// the hub refuses a thread that's already filed and doesn't lock it.
|
|
82
|
-
fileThread: async (threadId, path) => must(`/api/threads/${encodeURIComponent(threadId)}/track`, { method: "POST", body: JSON.stringify({ by: await who(), path }) }),
|
|
83
|
-
// The plan main's pass made (wire DashPlanIn, checked in hosted-hub/src/domain/dash.mjs normalizePlan); the hub keeps it as the newest.
|
|
84
|
-
dashPlan: async (basis, plan) => must("/api/dash/plan", { method: "POST", body: JSON.stringify({ by: await who(), basis, plan }) }),
|
|
85
|
-
// `notice` ({ schedule, text, clientId? }): posted in that schedule's
|
|
86
|
-
// thread in the same write as the entries, or neither is kept.
|
|
87
|
-
dashEntries: async (entries, remove = [], notice = null) => must("/api/dash/entries", { method: "POST", body: JSON.stringify({ by: await who(), entries, remove, ...(notice ? { notice } : {}) }) }),
|
|
88
|
-
};
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
// What the hub takes in one commit: up to POSTS_PER_COMMIT posts of up to
|
|
92
|
-
// POST_MAX_BYTES each (server.mjs SCHEDULE_POSTS_MAX / SCHEDULE_POST_MAX).
|
|
93
|
-
export const POST_MAX_BYTES = 16 * 1024;
|
|
94
|
-
export const POSTS_PER_COMMIT = 20;
|
|
95
|
-
|
|
96
|
-
const size = (text) => Buffer.byteLength(text);
|
|
97
|
-
|
|
98
|
-
// Split a text into pieces of at most `max` bytes, at line ends where it
|
|
99
|
-
// can, never inside a character.
|
|
100
|
-
function split(text, max) {
|
|
101
|
-
const out = [];
|
|
102
|
-
let rest = text;
|
|
103
|
-
while (size(rest) > max) {
|
|
104
|
-
let cut = rest.slice(0, max); // at most max chars, maybe more bytes
|
|
105
|
-
while (size(cut) > max) cut = cut.slice(0, Math.max(1, Math.floor(cut.length * 0.9)));
|
|
106
|
-
if (/[\uD800-\uDBFF]$/.test(cut)) cut = cut.slice(0, -1); // not between a surrogate pair
|
|
107
|
-
const nl = cut.lastIndexOf("\n");
|
|
108
|
-
if (nl > cut.length / 2) cut = cut.slice(0, nl + 1);
|
|
109
|
-
out.push(cut);
|
|
110
|
-
rest = rest.slice(cut.length);
|
|
111
|
-
}
|
|
112
|
-
if (rest) out.push(rest);
|
|
113
|
-
return out;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
// The run's notices as posts the hub takes, nothing dropped: a notice too
|
|
117
|
-
// long for one post goes out in labelled parts; small ones are packed
|
|
118
|
-
// together only when there are more than fit in one commit.
|
|
119
|
-
export function toPosts(notices) {
|
|
120
|
-
const parts = [];
|
|
121
|
-
for (const text of notices) {
|
|
122
|
-
if (size(text) <= POST_MAX_BYTES) {
|
|
123
|
-
parts.push(text);
|
|
124
|
-
continue;
|
|
125
|
-
}
|
|
126
|
-
const pieces = split(text, POST_MAX_BYTES - 32);
|
|
127
|
-
pieces.forEach((piece, i) => parts.push(`${piece}\n(part ${i + 1}/${pieces.length})`));
|
|
128
|
-
}
|
|
129
|
-
if (parts.length <= POSTS_PER_COMMIT) return parts;
|
|
130
|
-
const packed = [];
|
|
131
|
-
for (const part of parts) {
|
|
132
|
-
const last = packed.length - 1;
|
|
133
|
-
if (last >= 0 && size(packed[last]) + 2 + size(part) <= POST_MAX_BYTES) packed[last] += `\n\n${part}`;
|
|
134
|
-
else packed.push(part);
|
|
135
|
-
}
|
|
136
|
-
return packed;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
// One run of a schedule: load its state, decide, then commit. A commit is
|
|
140
|
-
// one request the hub applies whole or not at all (PUT .../state
|
|
141
|
-
// { state, posts }: the state and the posts land together in one durable
|
|
142
|
-
// write, or neither does). More posts than one commit takes go first, in
|
|
143
|
-
// commits of their own; the state rides on the last. Each post carries a
|
|
144
|
-
// key made from the state it was decided on and its text, so if a run
|
|
145
|
-
// fails part-way the next one, deciding again from that same state, sends
|
|
146
|
-
// the same keys and the hub skips the posts it already has. Nothing is
|
|
147
|
-
// lost and nothing posted twice. `decide(snap, saved, now)` ->
|
|
148
|
-
// { posts: [text], state, changed }; `snapshot: false` skips reading
|
|
149
|
-
// /api/state (decide gets null). A paused schedule (the state's `paused`)
|
|
150
|
-
// decides and commits nothing.
|
|
151
|
-
export async function runOnce(hub, id, decide, { now = Date.now(), snapshot = true } = {}) {
|
|
152
|
-
const [snap, loaded] = await Promise.all([snapshot ? hub.snapshot() : null, hub.loadVersioned(id)]);
|
|
153
|
-
const saved = loaded.state;
|
|
154
|
-
if (loaded.paused) return { posts: [], state: saved };
|
|
155
|
-
const { posts = [], state, changed } = await decide(snap, saved, now);
|
|
156
|
-
const texts = toPosts(posts.filter((t) => typeof t === "string" && t.trim()));
|
|
157
|
-
if (!changed && !texts.length) return { posts: [], state: saved };
|
|
158
|
-
const base = loaded.savedAt ?? "-";
|
|
159
|
-
const keyed = texts.map((text, i) => ({ text, clientId: `sch:${id}:${createHash("sha256").update(`${base}\0${i}\0${text}`).digest("base64url").slice(0, 32)}` }));
|
|
160
|
-
for (let at = 0; at + POSTS_PER_COMMIT < keyed.length; at += POSTS_PER_COMMIT) await hub.commit(id, {}, keyed.slice(at, at + POSTS_PER_COMMIT));
|
|
161
|
-
const tail = keyed.slice(Math.max(0, Math.ceil(keyed.length / POSTS_PER_COMMIT) - 1) * POSTS_PER_COMMIT);
|
|
162
|
-
await hub.commit(id, changed ? { state } : {}, tail);
|
|
163
|
-
return { posts: texts, state: changed ? state : saved };
|
|
164
|
-
}
|
package/schedules/lane-facts.mjs
DELETED
|
@@ -1,136 +0,0 @@
|
|
|
1
|
-
// The facts the lane classifier reads about a thread, and the one-line reason
|
|
2
|
-
// each answer is shown with. Code only gathers facts (counts, durations, the
|
|
3
|
-
// exact call a turn is in); whether a thread is stuck, waiting on the human or
|
|
4
|
-
// fine is the model's call (dash-classify.mjs), never a threshold here.
|
|
5
|
-
//
|
|
6
|
-
// Plain JS on purpose: the schedule (TypeScript, bundled by eve) and the tests
|
|
7
|
-
// (plain node) import the same module.
|
|
8
|
-
|
|
9
|
-
import { createHash } from "node:crypto";
|
|
10
|
-
import { said, flat, clip } from "./thread-brief.mjs";
|
|
11
|
-
|
|
12
|
-
const MIN = 60_000;
|
|
13
|
-
const minutes = (from, now) => Math.max(0, Math.floor((now - from) / MIN));
|
|
14
|
-
const at = (m) => Date.parse(m?.at ?? "") || 0;
|
|
15
|
-
// eve 0.85 names it eve__task_wait; turns recorded before the move say task_wait.
|
|
16
|
-
const isWait = (x) => x.name === "eve__task_wait" || x.name === "task_wait";
|
|
17
|
-
const isHuman = (m, human) => m.author === human || m.authorKind === "human";
|
|
18
|
-
|
|
19
|
-
// A busy thread's quiet time is read in steps of one pass (the schedule's
|
|
20
|
-
// cron, 8 minutes): a thread quiet for another pass is a new fact to judge,
|
|
21
|
-
// a bot that keeps working is not.
|
|
22
|
-
export const PASS_MIN = 8;
|
|
23
|
-
|
|
24
|
-
// The facts of one thread at `now`. `snap.live` holds each bot's live turn
|
|
25
|
-
// (key `threadId:agent`); `t.suspect` is the hub's mark on a turn that went
|
|
26
|
-
// silent when its bot's server restarted.
|
|
27
|
-
export function laneFacts(t, snap, now = Date.now()) {
|
|
28
|
-
const human = snap?.humans?.[0]?.name ?? "me";
|
|
29
|
-
const ms = (t?.messages ?? []).filter(said);
|
|
30
|
-
const lastBot = [...ms].reverse().find((m) => !isHuman(m, human));
|
|
31
|
-
const lastHuman = [...ms].reverse().find((m) => isHuman(m, human));
|
|
32
|
-
const turns = Object.entries(t?.status ?? {})
|
|
33
|
-
.filter(([, s]) => s === "working")
|
|
34
|
-
.map(([agent]) => turnFacts(agent, snap?.live?.[`${t.id}:${agent}`], now));
|
|
35
|
-
const askAt = ms.findLastIndex((m) => !isHuman(m, human) && (m.mentions ?? []).includes(human));
|
|
36
|
-
return {
|
|
37
|
-
// The newest bot message that @-mentions the human, in messages back from the newest, and whether the human posted after it.
|
|
38
|
-
askBack: askAt < 0 ? null : ms.length - 1 - askAt,
|
|
39
|
-
humanPostedSinceAsk: askAt >= 0 && ms.slice(askAt + 1).some((m) => isHuman(m, human)),
|
|
40
|
-
// Minutes since the newest message, when it is a bot's and @-mentions a bot: a handoff.
|
|
41
|
-
handoffMin: ms.at(-1) && !isHuman(ms.at(-1), human) && (ms.at(-1).mentions ?? []).some((n) => n !== human) ? minutes(at(ms.at(-1)), now) : null,
|
|
42
|
-
botSilentMin: lastBot ? minutes(at(lastBot), now) : null,
|
|
43
|
-
sinceHuman: lastHuman ? ms.length - 1 - ms.lastIndexOf(lastHuman) : ms.length,
|
|
44
|
-
rounds: (t?.messages ?? []).filter((m) => m.statusFrom === "reviewing" && (m.status === "working" || m.status === "planning")).length,
|
|
45
|
-
lastBy: ms.at(-1) ? { author: ms.at(-1).author, human: isHuman(ms.at(-1), human), toHuman: (ms.at(-1).mentions ?? []).includes(human) } : null,
|
|
46
|
-
restarted: !!t?.suspect && turns.length > 0,
|
|
47
|
-
turns,
|
|
48
|
-
};
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
function turnFacts(agent, turn, now) {
|
|
52
|
-
const tools = turn?.tools ?? [];
|
|
53
|
-
const swarm = Object.values(turn?.swarm ?? {});
|
|
54
|
-
const running = [...tools].reverse().find((x) => x.status === "running" && !isWait(x) && x.name !== "researcher");
|
|
55
|
-
const wait = tools.find((x) => isWait(x) && x.status === "running");
|
|
56
|
-
const counts = new Map();
|
|
57
|
-
for (const x of tools) if (!isWait(x)) counts.set(`${x.name} ${flat(x.input)}`, (counts.get(`${x.name} ${flat(x.input)}`) ?? 0) + 1);
|
|
58
|
-
return {
|
|
59
|
-
agent,
|
|
60
|
-
turnMin: turn?.startedAt ? minutes(turn.startedAt, now) : null,
|
|
61
|
-
quietMin: turn?.startedAt ? minutes(Math.max(turn.startedAt, ...tools.map((x) => x.endedAt ?? x.startedAt ?? 0)), now) : null,
|
|
62
|
-
toolCalls: tools.filter((x) => !isWait(x)).length,
|
|
63
|
-
current: running ? { call: clip(`${running.name === "bash" ? "$" : running.name} ${flat(running.input)}`.trim(), 90), min: running.startedAt ? minutes(running.startedAt, now) : null } : null,
|
|
64
|
-
waitingMin: wait?.startedAt ? minutes(wait.startedAt, now) : wait ? 0 : null,
|
|
65
|
-
subagentsOut: swarm.filter((d) => d.status === "working").length,
|
|
66
|
-
subagents: swarm.length,
|
|
67
|
-
repeats: [...counts].filter(([, n]) => n > 1).sort((a, b) => b[1] - a[1]).slice(0, 3).map(([call, n]) => ({ call: clip(call, 90), n })),
|
|
68
|
-
};
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
// The facts as lines for the model. Minutes since a bot posted (botSilentMin)
|
|
72
|
-
// is shown only on a dropped row; handoffMin only when nobody is working and the
|
|
73
|
-
// newest message hands work to a bot: how long a handoff sat is the whole
|
|
74
|
-
// question there. Anywhere else a minutes figure only drifted, and a drifting number flipped close
|
|
75
|
-
// calls (thr_0r2r read asks-you at 12 min and 2000, settled at 1127).
|
|
76
|
-
export function factsText(f) {
|
|
77
|
-
const lines = [
|
|
78
|
-
`Messages since the human's last post: ${f.sinceHuman}`,
|
|
79
|
-
...(f.lastBy ? [`Newest message: from ${f.lastBy.author}${f.lastBy.human ? " (the human)" : ""}${f.lastBy.toHuman ? ", @-mentions the human" : ""}`] : []),
|
|
80
|
-
...(f.askBack == null ? [] : [`Newest bot message that @-mentions the human: ${f.askBack === 0 ? "the newest message" : `${f.askBack} messages back`}; the human ${f.humanPostedSinceAsk ? "has posted since" : "has not posted since"}`]),
|
|
81
|
-
...(f.turns?.length || f.handoffMin == null ? [] : [`Nobody is working here, and the newest message went to a bot ${f.handoffMin} minutes ago.`]),
|
|
82
|
-
`Fix/review rounds so far (reviews sent back to work): ${f.rounds}`,
|
|
83
|
-
...(f.restarted ? ["A bot's server restarted and its turn went silent."] : []),
|
|
84
|
-
];
|
|
85
|
-
for (const x of f.turns) {
|
|
86
|
-
lines.push(`${x.agent}'s turn: running ${x.turnMin ?? "?"} min, ${x.toolCalls} tool calls, no tool activity for ${x.quietMin ?? "?"} min`);
|
|
87
|
-
if (x.current) lines.push(` current call: ${x.current.call}${x.current.min == null ? "" : ` (${x.current.min} min in)`}`);
|
|
88
|
-
if (x.waitingMin != null) lines.push(` parked in task_wait for ${x.waitingMin} min`);
|
|
89
|
-
if (x.subagents) lines.push(` subagents: ${x.subagentsOut} of ${x.subagents} out`);
|
|
90
|
-
for (const r of x.repeats) lines.push(` repeated ${r.n} times of ${x.toolCalls} calls this turn: ${r.call}`);
|
|
91
|
-
}
|
|
92
|
-
return lines.join("\n");
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
// What changing means for a busy thread: the turns working in it, the
|
|
96
|
-
// subagents out, a parked wait, the worst repeat count, rounds, a restart, and
|
|
97
|
-
// how many passes it has been quiet. A bot moving from one call to the next
|
|
98
|
-
// changes none of these, so a thread that is making progress costs nothing;
|
|
99
|
-
// one that has gone quiet is looked at again every pass.
|
|
100
|
-
export const factsKey = (f) =>
|
|
101
|
-
createHash("sha256")
|
|
102
|
-
.update(JSON.stringify([f.rounds, f.restarted, f.turns.map((x) => [x.agent, x.subagentsOut, x.waitingMin != null, x.repeats[0]?.n ?? 0, x.quietMin == null ? null : Math.floor(x.quietMin / PASS_MIN)])]))
|
|
103
|
-
.digest("hex")
|
|
104
|
-
.slice(0, 8);
|
|
105
|
-
|
|
106
|
-
// A busy thread's basis: what classifyBasis reads, then its facts' key. Due
|
|
107
|
-
// when it differs from the one its entry was made from.
|
|
108
|
-
export const busyBasis = (base, key) => `${base}|f:${key}`;
|
|
109
|
-
|
|
110
|
-
// --- the reason line -----------------------------------------------------
|
|
111
|
-
// Each state the model can pick (dash-classify.mjs STATES) has a template over
|
|
112
|
-
// facts and the thread's own words; the model picks the state, code quotes.
|
|
113
|
-
|
|
114
|
-
const REASON_MAX = 400;
|
|
115
|
-
// A message's paragraphs as written (links as their labels), without the ones
|
|
116
|
-
// that are only a rule or markers.
|
|
117
|
-
export const paragraphs = (text) => String(text ?? "").split(/\n\s*\n/).map(flat).filter((p) => /[\p{L}\p{N}]/u.test(p));
|
|
118
|
-
// `label: “text”`, or just the label when the thread has no such message yet.
|
|
119
|
-
const labelled = (label, text) => (text ? `${label}: “${text}”` : label);
|
|
120
|
-
|
|
121
|
-
// `brief` is the thread's brief (thread-brief.mjs): its last messages, each
|
|
122
|
-
// { id, author, human, text }; `ask` is the paragraph the model picked as the
|
|
123
|
-
// ask (askQuestion), else the newest bot message.
|
|
124
|
-
export function reasonLine(state, brief, f, ask = null) {
|
|
125
|
-
const lastBot = [...(brief?.recent ?? [])].reverse().find((l) => !l.human);
|
|
126
|
-
const x = f.turns[0];
|
|
127
|
-
const doing = x?.current ? `${x.current.call}${x.current.min == null ? "" : `, ${x.current.min} min in`}` : x?.subagentsOut ? `${x.subagentsOut} of ${x.subagents} subagents out` : x?.waitingMin != null ? `waiting on its tasks ${x.waitingMin} min` : x ? "thinking" : null;
|
|
128
|
-
const line = {
|
|
129
|
-
"asks-you": () => labelled("asks you", ask ?? lastBot?.text),
|
|
130
|
-
stuck: () => `possibly stuck: ${doing ?? "silent"}${!x?.current && x?.quietMin ? `, no tool activity ${x.quietMin} min` : ""}${x?.repeats?.length ? `, repeated ${x.repeats[0].n}×` : ""}`,
|
|
131
|
-
dropped: () => `${labelled("stopped without finishing", lastBot?.text)}${f.botSilentMin == null ? "" : ` (${f.botSilentMin} min ago)`}`,
|
|
132
|
-
running: () => (x ? `${x.agent}: ${doing}` : "waiting on something that ends by itself"),
|
|
133
|
-
settled: () => labelled("nothing waiting on you", lastBot?.text),
|
|
134
|
-
}[state];
|
|
135
|
-
return clip(line ? line() : "", REASON_MAX);
|
|
136
|
-
}
|
|
@@ -1,110 +0,0 @@
|
|
|
1
|
-
// One factual brief of a thread, built once and read by every part of main's
|
|
2
|
-
// dash schedule that judges a thread: the Dash model call (its delay, which
|
|
3
|
-
// also drives the "asks you" notice, and its track pick) and main's
|
|
4
|
-
// new-track notice (dash-classify.mjs). It states what was said and posted;
|
|
5
|
-
// it never decides what's answered or open. The model reading it does.
|
|
6
|
-
//
|
|
7
|
-
// The brief: title and tags, the opening message, the phase and each bot's
|
|
8
|
-
// latest status post, open requests to the human, the earlier messages to
|
|
9
|
-
// or from the human (every message that @-mentions them and every one they
|
|
10
|
-
// wrote, oldest first, the newest kept when over budget), and the last
|
|
11
|
-
// messages, whole: an ask often sits at the end of a long handoff. The
|
|
12
|
-
// earlier part is capped by length, not by count.
|
|
13
|
-
//
|
|
14
|
-
// Plain JS on purpose: the schedule (TypeScript, bundled by eve) and the
|
|
15
|
-
// tests (plain node) import the same module.
|
|
16
|
-
|
|
17
|
-
// A message someone said: not a hub event or a system line.
|
|
18
|
-
export const said = (m) => m && m.authorKind !== "event" && !m.event && m.author !== "system";
|
|
19
|
-
export const lastSaid = (t) => {
|
|
20
|
-
const ms = t?.messages ?? [];
|
|
21
|
-
for (let i = ms.length - 1; i >= 0; i--) if (said(ms[i])) return ms[i];
|
|
22
|
-
return null;
|
|
23
|
-
};
|
|
24
|
-
|
|
25
|
-
export const plain = (text) => String(text ?? "").replace(/```[\s\S]*?```/g, " ").replace(/\[([^\]]*)\]\([^)]*\)/g, "$1").replace(/[`*_#>|]/g, "").replace(/\s+/g, " ").trim();
|
|
26
|
-
// Message text for the brief: one line, link labels without their targets,
|
|
27
|
-
// otherwise as written (code and markdown kept: "#256" is a PR).
|
|
28
|
-
export const flat = (text) => String(text ?? "").replace(/\[([^\]]*)\]\([^)]*\)/g, "$1").replace(/\s+/g, " ").trim();
|
|
29
|
-
export const clip = (text, n) => (text.length > n ? `${text.slice(0, n - 1)}…` : text);
|
|
30
|
-
// Head and tail: a long message's ask usually sits at its start or its end.
|
|
31
|
-
export const squeeze = (text, n) => {
|
|
32
|
-
if (text.length <= n) return text;
|
|
33
|
-
const head = Math.ceil((n - 3) / 2);
|
|
34
|
-
return `${text.slice(0, head)} … ${text.slice(text.length - (n - 3 - head))}`;
|
|
35
|
-
};
|
|
36
|
-
|
|
37
|
-
export const BRIEF = {
|
|
38
|
-
opening: 600, // the opening message, when it isn't among the last
|
|
39
|
-
message: 700, // each earlier message
|
|
40
|
-
recent: 5, // the last messages, always in, whole
|
|
41
|
-
earlier: 2400, // the earlier messages to or from the human, in total
|
|
42
|
-
report: 200, // each bot's status summary
|
|
43
|
-
};
|
|
44
|
-
|
|
45
|
-
const humanOf = (snap) => snap?.humans?.[0]?.name ?? "me";
|
|
46
|
-
const isHuman = (m, human) => m.author === human || m.authorKind === "human";
|
|
47
|
-
const line = (m, human, n = Infinity) => ({ id: m.id, author: m.author, human: isHuman(m, human), text: squeeze(flat(m.text), n) });
|
|
48
|
-
|
|
49
|
-
// The brief as data. `snap` gives the human's name and pending requests.
|
|
50
|
-
export function threadBrief(t, snap, limits = BRIEF) {
|
|
51
|
-
const human = humanOf(snap);
|
|
52
|
-
const ms = (t?.messages ?? []).filter(said);
|
|
53
|
-
const recent = ms.slice(-limits.recent);
|
|
54
|
-
const before = ms.slice(1, ms.length - recent.length);
|
|
55
|
-
const earlier = [];
|
|
56
|
-
let used = 0;
|
|
57
|
-
for (let i = before.length - 1; i >= 0; i--) {
|
|
58
|
-
const m = before[i];
|
|
59
|
-
if (!isHuman(m, human) && !(m.mentions ?? []).includes(human)) continue;
|
|
60
|
-
const l = line(m, human, limits.message);
|
|
61
|
-
used += l.text.length;
|
|
62
|
-
if (used > limits.earlier) break;
|
|
63
|
-
earlier.unshift(l);
|
|
64
|
-
}
|
|
65
|
-
return {
|
|
66
|
-
human,
|
|
67
|
-
title: t?.title ?? t?.id,
|
|
68
|
-
project: t?.tags?.project ?? null,
|
|
69
|
-
path: t?.tags?.path ?? null,
|
|
70
|
-
issueType: t?.tags?.issueType ?? t?.tags?.domain ?? null,
|
|
71
|
-
// The thread's stored summary (set_summary), when main wrote one.
|
|
72
|
-
summary: t?.summary?.text ? { text: clip(flat(t.summary.text), limits.summary ?? 600), by: t.summary.by ?? null } : null,
|
|
73
|
-
opening: ms.length > limits.recent ? line(ms[0], human, limits.opening) : null,
|
|
74
|
-
phase: t?.work?.phase ?? null,
|
|
75
|
-
reports: Object.entries(t?.work?.bots ?? {}).map(([bot, s]) => ({ bot, summary: clip(flat(s?.summary), limits.report), pr: s?.pr ?? null, sha: s?.sha ? String(s.sha).slice(0, 8) : null })),
|
|
76
|
-
requests: Object.values(snap?.requests ?? {})
|
|
77
|
-
.filter((r) => r?.threadId === t?.id && r.status === "pending")
|
|
78
|
-
.map((r) => ({ agent: r.agent ?? "a bot", kind: r.kind ?? "request" })),
|
|
79
|
-
earlier,
|
|
80
|
-
omitted: before.length - earlier.length,
|
|
81
|
-
recent: recent.map((m) => line(m, human)),
|
|
82
|
-
};
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
const who = (l) => `${l.author}${l.human ? " (human)" : ""}`;
|
|
86
|
-
|
|
87
|
-
// The brief as text: the same block for every reader.
|
|
88
|
-
// "domain" is the label the classifier was measured with (scripts/lane-replay.mjs: "issue type" for the
|
|
89
|
-
// same value cost it two asks and the stall); keep it until a replay says otherwise.
|
|
90
|
-
export function briefText(b) {
|
|
91
|
-
return [
|
|
92
|
-
`Title: ${b.title}`,
|
|
93
|
-
`Project: ${b.project ?? "none"}${b.path && b.path !== b.project ? ` · path: ${b.path}` : ""} · domain: ${b.issueType ?? "none"}`,
|
|
94
|
-
...(b.summary ? [`Summary${b.summary.by ? ` (by ${b.summary.by})` : ""}: ${b.summary.text}`] : []),
|
|
95
|
-
...(b.opening ? [`Opening message, ${who(b.opening)}: ${b.opening.text}`] : []),
|
|
96
|
-
...(b.phase ? [`Phase the bots reported: ${b.phase}`, ...b.reports.map((r) => `- ${r.bot}: ${r.summary}${r.pr ? ` (PR ${r.pr}${r.sha ? ` @ ${r.sha}` : ""})` : ""}`)] : []),
|
|
97
|
-
`Open requests to ${b.human}: ${b.requests.map((r) => `${r.agent} (${r.kind})`).join(", ") || "none"}`,
|
|
98
|
-
...(b.earlier.length ? [`Earlier messages that @-mention ${b.human} or are by ${b.human}, oldest first:`, ...b.earlier.map((l) => `- ${who(l)}: ${l.text}`)] : []),
|
|
99
|
-
...(b.omitted ? [`(${b.omitted} other earlier messages not shown)`] : []),
|
|
100
|
-
...(b.recent.length ? ["Last messages, oldest first:", ...b.recent.map((l) => `- ${who(l)}: ${l.text}`)] : []),
|
|
101
|
-
].join("\n");
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
// A thread already in a track, as one line of that track's description
|
|
105
|
-
// (the track question's criteria, main's notice): its title and opening.
|
|
106
|
-
export const MEMBER_CHARS = 200;
|
|
107
|
-
export const memberLine = (t) => {
|
|
108
|
-
const first = (t?.messages ?? []).find(said);
|
|
109
|
-
return `"${t?.title ?? t?.id}"${first ? `: ${clip(plain(first.text), MEMBER_CHARS)}` : ""}`;
|
|
110
|
-
};
|
|
@@ -1,108 +0,0 @@
|
|
|
1
|
-
// The track summaries of main's dash pass (schedules/dash.ts): every open
|
|
2
|
-
// track (a path below its project, web/checkout; registry: snap.tracks keyed
|
|
3
|
-
// by path) has a free-form summary the human reads above its threads. A pass
|
|
4
|
-
// gives each track with a thread that has messages newer than the last pass
|
|
5
|
-
// that covered it (pass.trackMarks in the dash schedule's state: thread id -> newest message covered) one
|
|
6
|
-
// model call: the track's old summary and those threads' briefs in, the new
|
|
7
|
-
// summary out. Code only gathers facts; the model decides what the summary
|
|
8
|
-
// needs. A call that fails keeps its marks, so the track is asked again next
|
|
9
|
-
// pass.
|
|
10
|
-
//
|
|
11
|
-
// Plain JS on purpose: the schedule (TypeScript, bundled by eve) and the
|
|
12
|
-
// tests (plain node) import the same module.
|
|
13
|
-
import { runOnce } from "./hub.mjs";
|
|
14
|
-
import { said, briefText, threadBrief } from "./thread-brief.mjs";
|
|
15
|
-
|
|
16
|
-
// A track chat is main's own conversation with the human; it doesn't move the track.
|
|
17
|
-
const counted = (t) => !!t && t.kind === "thread" && !t.chatFor;
|
|
18
|
-
const pathOf = (t) => t.tags?.path ?? t.trackId ?? null;
|
|
19
|
-
|
|
20
|
-
// Every filed thread on an open track, as facts; and of those, the ones with
|
|
21
|
-
// a message newer than their mark. A thread without a mark (a first run, or
|
|
22
|
-
// filed onto an open track since) has never been covered, so all of it is
|
|
23
|
-
// news. A summary's write time isn't a mark: it doesn't say which threads'
|
|
24
|
-
// messages the summary covered. Newest first within a track.
|
|
25
|
-
export function tracksFacts(snap, marks = {}) {
|
|
26
|
-
const tracks = snap?.tracks ?? {};
|
|
27
|
-
const all = [];
|
|
28
|
-
for (const t of Object.values(snap?.threads ?? {})) {
|
|
29
|
-
if (!counted(t)) continue;
|
|
30
|
-
const track = tracks[pathOf(t)];
|
|
31
|
-
if (track && track.open !== false) all.push({ id: t.id, track: track.id, archived: !!t.archived, lastAt: (t.messages ?? []).filter(said).at(-1)?.at ?? null });
|
|
32
|
-
}
|
|
33
|
-
const wake = all.filter((f) => f.lastAt && f.lastAt > (marks[f.id] ?? ""));
|
|
34
|
-
wake.sort((a, b) => a.track.localeCompare(b.track) || String(b.lastAt).localeCompare(String(a.lastAt)));
|
|
35
|
-
return { all, wake };
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export const TRACK_MODEL = "anthropic/claude-sonnet-5.5";
|
|
39
|
-
// Tracks asked per pass and threads shown per track; the rest wait for the next pass.
|
|
40
|
-
export const TRACKS_PER_PASS = 8;
|
|
41
|
-
export const THREADS_PER_TRACK = 6;
|
|
42
|
-
export const SUMMARY_MAX = 1500;
|
|
43
|
-
|
|
44
|
-
// The call for one track: its old summary, then each changed thread's brief
|
|
45
|
-
// and where the Dash put it.
|
|
46
|
-
export function trackPrompt(track, wake, snap) {
|
|
47
|
-
const entries = snap?.dash?.entries ?? {};
|
|
48
|
-
const oldSummary = track.summary?.text ?? snap?.pathSummaries?.[track.id]?.text;
|
|
49
|
-
const threads = wake.slice(0, THREADS_PER_TRACK).map((f) => {
|
|
50
|
-
const t = snap.threads[f.id];
|
|
51
|
-
const e = entries[f.id];
|
|
52
|
-
return [`### ${f.id}${f.archived ? " (archived)" : ""}`, ...(e?.lane ? [`Dash: ${e.lane}${e.reason ? `, ${e.reason}` : ""}`] : []), briefText(threadBrief(t, snap))].join("\n");
|
|
53
|
-
});
|
|
54
|
-
return [
|
|
55
|
-
`You keep the summary of one track of work current. A track groups the threads (chats between ${snap?.humans?.[0]?.name ?? "the human"} and AI bots) of one feature. The human reads the summary above the track's threads: a short markdown note of what's up in that feature (what's moving, what's blocked or waiting on them, what just landed), written for them.`,
|
|
56
|
-
`Track: ${track.title ?? track.id} (${track.id})`,
|
|
57
|
-
oldSummary ? `Current summary:\n${oldSummary}` : "There is no summary yet.",
|
|
58
|
-
`Threads with news since the summary was last written:`,
|
|
59
|
-
...threads,
|
|
60
|
-
`Write the whole new summary: keep what is still right, fold in the news, drop what is stale. Answer with the summary only, no preamble, at most ${SUMMARY_MAX} characters.`,
|
|
61
|
-
].join("\n\n");
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
// One pass. State: the dash schedule's own, pass.trackMarks in it: per thread on an open track, the newest message
|
|
65
|
-
// a summary was written for. Saved only when a mark moved. `summarize(prompt)`
|
|
66
|
-
// -> the summary text; `hub.setSummary(path, text)` stores it. A track with no
|
|
67
|
-
// summarize, a failed call or an empty answer keeps its marks.
|
|
68
|
-
// -> { written, wake }
|
|
69
|
-
/** @param {any} hub @param {{ summarize?: (prompt: string) => Promise<string>, log?: any }} [opts] */
|
|
70
|
-
export async function runTracks(hub, { summarize, log = console } = {}) {
|
|
71
|
-
let written = 0;
|
|
72
|
-
let wake = [];
|
|
73
|
-
await runOnce(hub, "dash", async (snap, saved) => {
|
|
74
|
-
const had = saved?.pass?.trackMarks ?? {};
|
|
75
|
-
const facts = tracksFacts(snap, had);
|
|
76
|
-
wake = facts.wake;
|
|
77
|
-
const marks = { ...had, ...Object.fromEntries(facts.all.filter((f) => !f.lastAt).map((f) => [f.id, ""])) };
|
|
78
|
-
const byTrack = Map.groupBy(wake, (f) => f.track);
|
|
79
|
-
const todo = [...byTrack].slice(0, TRACKS_PER_PASS);
|
|
80
|
-
await Promise.all(
|
|
81
|
-
todo.map(async ([id, ws]) => {
|
|
82
|
-
try {
|
|
83
|
-
const text = (await summarize?.(trackPrompt(snap.tracks[id], ws, snap)))?.trim();
|
|
84
|
-
if (!text) return;
|
|
85
|
-
await hub.setSummary(id, text.slice(0, SUMMARY_MAX));
|
|
86
|
-
for (const f of ws.slice(0, THREADS_PER_TRACK)) marks[f.id] = f.lastAt;
|
|
87
|
-
written++;
|
|
88
|
-
} catch (err) {
|
|
89
|
-
log?.warn?.(`tracks: summary for ${id} failed, asked again next pass: ${err?.message ?? err}`);
|
|
90
|
-
}
|
|
91
|
-
}),
|
|
92
|
-
);
|
|
93
|
-
const changed = JSON.stringify(marks) !== JSON.stringify(had);
|
|
94
|
-
return { posts: [], state: { ...saved, pass: { ...saved?.pass, trackMarks: marks } }, changed };
|
|
95
|
-
});
|
|
96
|
-
return { written, wake };
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
// The schedule's summarizer: one text generation per track. Throws on a
|
|
100
|
-
// failed call (runTracks keeps the marks).
|
|
101
|
-
/** @param {{ generateText?: (options: any) => Promise<{ text: string }>, model?: string, timeoutMs?: number }} [options] */
|
|
102
|
-
export function modelSummarize({ generateText, model = TRACK_MODEL, timeoutMs = 60_000 } = {}) {
|
|
103
|
-
return async (prompt) => {
|
|
104
|
-
if (!generateText) return "";
|
|
105
|
-
const r = await generateText({ model, prompt, maxRetries: 1, abortSignal: AbortSignal.timeout(timeoutMs) });
|
|
106
|
-
return r.text;
|
|
107
|
-
};
|
|
108
|
-
}
|