eve-hub 0.5.4 → 0.5.6
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/README.md +6 -6
- package/channels/eve-roster-user.js +4 -3
- package/package.json +1 -1
- package/schedules/dash-classify.mjs +1 -1
- package/schedules/tracks-lead.mjs +47 -166
- package/schedules/tracks.d.ts +6 -0
- package/schedules/tracks.js +15 -13
- package/templates/main-instructions.md +5 -5
- package/templates/workspace-protocol.md +9 -4
- package/tools/agent_send.js +1 -1
- package/tools/apply_tags.js +3 -3
- package/tools/artifact_publish.js +1 -1
- package/tools/create_thread.js +7 -7
- package/tools/list_threads.js +2 -2
- package/tools/move_path.js +4 -4
- package/tools/post_message.js +1 -1
- package/tools/read_thread.js +1 -1
- package/tools/restart_agent.js +1 -1
- package/tools/set_summary.js +1 -1
- package/tools/tag_threads.js +2 -2
- package/tools/{rename_thread.d.ts → update_thread.d.ts} +6 -6
- package/tools/update_thread.js +31 -0
- package/tools/rename_thread.js +0 -30
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# eve-hub
|
|
2
2
|
|
|
3
|
+
## 0.5.6
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 1418c75: Release: a PR bumps the version itself (`pnpm changeset version`); the release workflow only publishes.
|
|
8
|
+
|
|
9
|
+
## 0.5.5
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- 2d21596: Publish the `update_thread` tool (replaces `rename_thread`). The published 0.5.4 predates it, so a workspace whose agents import `eve-hub/tools/update_thread` fails to build.
|
|
14
|
+
|
|
3
15
|
## 0.5.4
|
|
4
16
|
|
|
5
17
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -4,14 +4,14 @@ The bot-side code every hub agent shares (milestone M3 in [`docs/architecture`](
|
|
|
4
4
|
|
|
5
5
|
| Module | What it is | Bots use it as |
|
|
6
6
|
|---|---|---|
|
|
7
|
-
| `tools/
|
|
7
|
+
| `tools/update_thread.ts`, `list_threads.ts`, `read_thread.ts`, `react.ts`, `show.ts`, `attach_image.ts`, `browser.ts` | Registry and workspace tools (`defineTool`) | `agent/tools/<same name>.ts` |
|
|
8
8
|
| `tools/create_thread.ts`, `post_message.ts`, `archive_thread.ts`, `unarchive_thread.ts`, `list_agents.ts`, `create_agent.ts`, `remove_agent.ts`, `restart_agent.ts`, `agent_send.ts`, `tag_threads.ts`, `apply_tags.ts` | The orchestrator's tools | `main`'s `agent/tools/<same name>.ts` |
|
|
9
9
|
| `hooks/sandbox.ts` | Migrates legacy Docker sessions to the host sandbox (`defineHook`) | `agent/hooks/sandbox.ts` |
|
|
10
10
|
| `instructions/main.ts`, `main-instructions.mjs` | main's instructions (`defineInstructions`), rendered at build time from the kit's `templates/main-instructions.md` + `workspace-protocol.md`, so no workspace carries a copy | `main`'s `agent/instructions.ts`; workspace-only additions in `agent/instructions/*.md` |
|
|
11
11
|
| `instructions/protocol.ts` | The shared protocol alone, rendered from `templates/workspace-protocol.md` | every other bot's `agent/instructions/protocol.ts`, appended after its own `instructions.md` (who the bot is) |
|
|
12
12
|
| `memory/workspace.ts`, `memory.mjs` | an eve memory slot (default export, constant `workspace` scope) whose provider recalls the workspace's `memory/<id>.md` entries, one message each, at every turn start and after compaction, only the ones whose text changed since the session last saw them, as new messages after the history; `validateMemory(root)` for a workspace's tests | every bot's (main too) `agent/memory/workspace.ts`: `export { default } from "eve-hub/memory/workspace"` |
|
|
13
13
|
| `memory/bot.ts` | the same for the bot's own `agents/<bot>/memory/<id>.md` entries; the bot is found from its directory (`findBotDir`), scope is its name | every bot's `agent/memory/bot.ts`: `export { default } from "eve-hub/memory/bot"` |
|
|
14
|
-
| `channels/eve-roster-user.ts` | `eveChannel({ auth: [rosterUser, localDev()]
|
|
14
|
+
| `channels/eve-roster-user.ts` | `eveChannel({ auth: [rosterUser, localDev()] })`: eve's default steer policy, and the registry's `x-roster-user` human as a user principal | every bot's `agent/channels/eve.ts` |
|
|
15
15
|
| `channels/eve.ts` | Re-exports `eve-roster-user.ts`, for stubs written before every bot shared one channel | older `agent/channels/eve.ts` stubs |
|
|
16
16
|
| `tools/disabled.ts` | `disableTool()`: removes an eve default tool | the read-only preset's `agent/tools/write_file.ts` |
|
|
17
17
|
| `host-sandbox.ts`, `host-browser.ts` | `HostSandbox` (the host machine is the sandbox) and each session's headless Chromium | `agent/sandbox.ts` imports `HostSandbox` |
|
|
@@ -28,8 +28,8 @@ The `hubChannel` from D0 isn't here yet. Turn lifecycle and replies are owned by
|
|
|
28
28
|
eve discovers tools, hooks, and channels by file under `agent/tools`, `agent/hooks`, and `agent/channels`. A tool's name is its file name, so the file has to exist in the bot. The stub just re-exports the kit's default:
|
|
29
29
|
|
|
30
30
|
```ts
|
|
31
|
-
// agents/<bot>/agent/tools/
|
|
32
|
-
export { default } from "eve-hub/tools/
|
|
31
|
+
// agents/<bot>/agent/tools/update_thread.ts
|
|
32
|
+
export { default } from "eve-hub/tools/update_thread";
|
|
33
33
|
```
|
|
34
34
|
|
|
35
35
|
`sandbox.ts` stays a small module of its own, because each agent and subagent declares its own `environment`:
|
|
@@ -52,13 +52,13 @@ A bot's role is enforced by its files as well as its prose. `create_agent`'s `pr
|
|
|
52
52
|
3. The stubs use the specifiers the `exports` map serves (`eve-hub/tools/x`, `eve-hub/host-sandbox`, `eve-hub/roster-model`, …).
|
|
53
53
|
4. `eve` and `zod` are peer dependencies. eve's bundler resolves the kit to its realpath, so the kit's own `eve` imports come from hub's install. When the workspace's eve is linked to hub's, that's one eve (verified with eve 0.69).
|
|
54
54
|
|
|
55
|
-
Add a changeset (`pnpm changeset`, pick `eve-hub`) when a tool's name, schema, or description, the hook's events, or the channel config changes; the
|
|
55
|
+
Add a changeset (`pnpm changeset`, pick `eve-hub`) when a tool's name, schema, or description, the hook's events, or the channel config changes; run `pnpm changeset version` in the same PR so it bumps `version` and writes CHANGELOG.md (a test fails while a changeset file is pending). Hub records each agent's kit version and accepts N and N−1 (D0/D12).
|
|
56
56
|
|
|
57
57
|
## Published package (deployed workspaces)
|
|
58
58
|
|
|
59
59
|
A deployed workspace (`node scripts/workspace-template.mjs <dir>`, lib/workspace-template.mjs) has no hub checkout to link, so it installs the kit from npm, published as [`eve-hub`](https://www.npmjs.com/package/eve-hub): `"eve-hub": "<version>"`. `node packages/agent-kit/scripts/pack.mjs <outDir>` builds the package: every `.ts` module compiled to `.js` + `.d.ts` in place (node won't strip types under `node_modules`), the `exports` map pointed at them, and `templates/` included.
|
|
60
60
|
|
|
61
|
-
Releases go through Changesets, as in eve. A PR that changes the kit adds a changeset (`pnpm changeset`); when it merges, `.github/workflows/release.yml`
|
|
61
|
+
Releases go through Changesets, as in eve. A PR that changes the kit adds a changeset (`pnpm changeset`) and applies it (`pnpm changeset version`); when it merges, `.github/workflows/release.yml` runs `changeset publish` (no bot commits to main: its ruleset allows only PR merges): the kit's `prepack` stages the package into `dist/npm` (its `publishConfig.directory`), npm gets it, and the `eve-hub@<version>` GitHub release is created. npm auth is the repo's `NPM_TOKEN` secret (an npm automation token). The template pins the version this checkout carries.
|
|
62
62
|
|
|
63
63
|
The template's main gets a Vercel Sandbox and only the kit stubs that run without the hub's machine (lib/workspace-template.mjs `DEPLOYED_KIT_FILES`; eve's own bash replaces the kit's). The published tarball (`scripts/pack.mjs`) leaves out the kit__worker extension, which mounts only on the hub's machine. The hub reaches each bot's eve channel with `Authorization: Bearer $BOT_TOKEN` (`bot-token.mjs`); the kit's calls to the hub (`registry-fetch.mjs`, at `ROSTER_REGISTRY_URL`) carry `Authorization: Bearer $HUB_TOKEN` and, since the hosted hub keeps Vercel Authentication on, `x-vercel-protection-bypass: $HUB_PROTECTION_BYPASS` (the hub project's automation bypass secret), each when set.
|
|
64
64
|
|
|
@@ -16,6 +16,7 @@ async function rosterUser(req) {
|
|
|
16
16
|
return null;
|
|
17
17
|
return { authenticator: "roster", principalId: `roster:${user}`, principalType: "user", attributes: {} };
|
|
18
18
|
}
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
|
|
19
|
+
// No turnPolicy: eve's default steers a message into the running turn at its
|
|
20
|
+
// next step. The registry sends turnPolicy "queue" for a bot's message, so it
|
|
21
|
+
// waits for the turn to end.
|
|
22
|
+
export default eveChannel({ auth: [rosterUser, dev] });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "eve-hub",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.6",
|
|
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",
|
|
@@ -184,7 +184,7 @@ export function newPathNotice(threads, snap) {
|
|
|
184
184
|
for (const t of threads) byProject.set(t.tags?.project ?? "", [...(byProject.get(t.tags?.project ?? "") ?? []), t]);
|
|
185
185
|
const brief = (t) => [`- ${t.id}`, ...briefText(threadBrief(t, snap)).split("\n").map((l) => ` ${l}`)];
|
|
186
186
|
return [
|
|
187
|
-
`@main, 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
|
|
187
|
+
`@main, 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.`,
|
|
188
188
|
...[...byProject].flatMap(([p, ts]) => {
|
|
189
189
|
const paths = pathsInProject(ts[0], snap);
|
|
190
190
|
return [
|
|
@@ -1,85 +1,52 @@
|
|
|
1
1
|
// main's tracks schedule (schedules/tracks.ts): main leads every track (a
|
|
2
|
-
// path below its project,
|
|
3
|
-
// by keeping its free-form summary current with set_summary.
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
// lastEventAt, nested subagent runs included), who's working, and
|
|
11
|
-
// when the thread's track (or the thread) was last summarized.
|
|
12
|
-
// ask a thread is asked about when something happened since that
|
|
13
|
-
// summary and its basis (newest message, summary time, who's
|
|
14
|
-
// working) differs from the one it was last asked on. Each
|
|
15
|
-
// goes to the model (DECISION_MODEL) in its own evaluation, all
|
|
16
|
-
// in parallel, with its track's summary and the messages since
|
|
17
|
-
// it: does the summary already state them (missing, stated,
|
|
18
|
-
// chatter), and what changed. No thresholds in code. Each verdict is kept against its basis (state.seen):
|
|
19
|
-
// the model is close to even on borderline threads, so asking
|
|
20
|
-
// again on the same facts would wake main by chance, not news.
|
|
21
|
-
// wake only threads whose verdict is `missing` reach main, in one post
|
|
22
|
-
// in sch-main-tracks with the answers and facts.
|
|
23
|
-
//
|
|
24
|
-
// A failed or invalid evaluation posts and saves nothing: the next tick asks
|
|
25
|
-
// again. Without `evaluate` (tests) nothing is asked and nobody is woken.
|
|
2
|
+
// path below its project, web/checkout; registry: snap.tracks keyed by path)
|
|
3
|
+
// by keeping its free-form summary current with set_summary. Every 10
|
|
4
|
+
// minutes this wakes main, in one post in sch-main-tracks, with each thread
|
|
5
|
+
// on an open track that has messages newer than the last notice that listed
|
|
6
|
+
// it (state.marks: thread id -> newest message that notice listed). Main
|
|
7
|
+
// decides in its own turn what each summary needs; code
|
|
8
|
+
// only gathers facts. No model call here: a cheap model couldn't tell
|
|
9
|
+
// whether a summary already states a thread's news (it missed merges).
|
|
26
10
|
//
|
|
27
11
|
// Plain JS on purpose: the schedule (TypeScript, bundled by eve) and the
|
|
28
12
|
// tests (plain node) import the same module.
|
|
29
13
|
import { runOnce } from "./hub.mjs";
|
|
30
|
-
import {
|
|
31
|
-
import { clip, flat, said } from "./thread-brief.mjs";
|
|
14
|
+
import { said } from "./thread-brief.mjs";
|
|
32
15
|
|
|
33
16
|
// A track chat is main's own conversation with the human; it doesn't move the track.
|
|
34
17
|
const counted = (t) => !!t && t.kind === "thread" && !t.chatFor;
|
|
35
18
|
const pathOf = (t) => t.tags?.path ?? t.trackId ?? null;
|
|
36
|
-
const newest = (...ats) => ats.filter(Boolean).sort().at(-1) ?? null;
|
|
37
19
|
const isHuman = (m, humans) => m.authorKind === "human" || humans.has(m.author);
|
|
38
20
|
|
|
39
|
-
// The messages since the summary the model reads, newest last: news (a
|
|
40
|
-
// merge) is often followed by acks, so the latest message alone misses it.
|
|
41
|
-
export const RECENT = 6;
|
|
42
|
-
|
|
43
21
|
// The facts for one thread on an open track.
|
|
44
22
|
export function threadFacts(t, snap, track) {
|
|
45
23
|
const humans = new Set((snap?.humans ?? []).map((h) => h.name));
|
|
46
24
|
const ms = (t.messages ?? []).filter(said);
|
|
47
25
|
let h = ms.length - 1;
|
|
48
26
|
while (h >= 0 && !isHuman(ms[h], humans)) h--;
|
|
49
|
-
const
|
|
50
|
-
const bot = ms.findLast((m) => !isHuman(m, humans)) ?? null;
|
|
51
|
-
const sessions = Object.values(snap?.sessions ?? {}).filter((s) => s?.threadId === t.id);
|
|
52
|
-
const summaryAt = newest(track.summary?.at, t.summary?.at);
|
|
53
|
-
const since = ms.filter((m) => !summaryAt || m.at > summaryAt);
|
|
27
|
+
const summaryAt = track.summary?.at ?? null;
|
|
54
28
|
return {
|
|
55
29
|
id: t.id,
|
|
56
30
|
title: t.title ?? t.id,
|
|
57
31
|
track: track.id,
|
|
58
32
|
trackTitle: track.title ?? track.id,
|
|
59
33
|
archived: !!t.archived,
|
|
60
|
-
phase: t.work?.phase ?? null,
|
|
61
34
|
working: Object.entries(t.status ?? {}).filter(([, s]) => s === "working").map(([b]) => b).sort(),
|
|
62
|
-
|
|
35
|
+
humanAt: h >= 0 ? ms[h].at : null,
|
|
63
36
|
botsSinceHuman: ms.length - 1 - h,
|
|
64
|
-
bot: bot && { id: bot.id, author: bot.author, at: bot.at, text: bot.text },
|
|
65
|
-
lastId: ms.at(-1)?.id ?? null,
|
|
66
37
|
lastAt: ms.at(-1)?.at ?? null,
|
|
67
|
-
|
|
38
|
+
summarized: !!track.summary?.text,
|
|
68
39
|
summaryAt,
|
|
69
|
-
sinceSummary:
|
|
70
|
-
recent: since.slice(-RECENT).map((m) => ({ id: m.id, author: m.author, at: m.at, text: m.text })),
|
|
71
|
-
summary: track.summary?.text ? { text: track.summary.text, by: track.summary.by ?? null } : null,
|
|
40
|
+
sinceSummary: ms.filter((m) => !summaryAt || m.at > summaryAt).length,
|
|
72
41
|
};
|
|
73
42
|
}
|
|
74
43
|
|
|
75
|
-
// What a verdict was made on: a thread is asked again once this differs.
|
|
76
|
-
// The bots' event time isn't in it: it moves every tick while a bot works.
|
|
77
|
-
export const basisOf = (f) => [f.lastId, f.summaryAt ?? "", f.working.join(",")].join("|");
|
|
78
|
-
|
|
79
44
|
// Every filed thread on an open track, as facts; and of those, the ones to
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
|
|
45
|
+
// wake main with: a message newer than its mark. A thread without a mark
|
|
46
|
+
// (the first run, or filed onto an open track since) has never been listed,
|
|
47
|
+
// so all of it is news. A summary's write time isn't a mark: it doesn't say
|
|
48
|
+
// which threads' messages the summary covered.
|
|
49
|
+
export function tracksFacts(snap, marks = {}) {
|
|
83
50
|
const tracks = snap?.tracks ?? {};
|
|
84
51
|
const all = [];
|
|
85
52
|
for (const t of Object.values(snap?.threads ?? {})) {
|
|
@@ -87,10 +54,9 @@ export function tracksFacts(snap, seen = {}) {
|
|
|
87
54
|
const track = tracks[pathOf(t)];
|
|
88
55
|
if (track && track.open !== false) all.push(threadFacts(t, snap, track));
|
|
89
56
|
}
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
return { all, ask };
|
|
57
|
+
const wake = all.filter((f) => f.lastAt && f.lastAt > (marks[f.id] ?? ""));
|
|
58
|
+
wake.sort((a, b) => a.track.localeCompare(b.track) || String(b.lastAt).localeCompare(String(a.lastAt)));
|
|
59
|
+
return { all, wake };
|
|
94
60
|
}
|
|
95
61
|
|
|
96
62
|
const ago = (at, now) => {
|
|
@@ -99,128 +65,43 @@ const ago = (at, now) => {
|
|
|
99
65
|
return min < 60 ? `${min}m ago` : min < 48 * 60 ? `${Math.round(min / 60)}h ago` : `${Math.round(min / 1440)}d ago`;
|
|
100
66
|
};
|
|
101
67
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
export function trackText(f, now) {
|
|
106
|
-
return `## Track ${f.track} ("${f.trackTitle}")\nTrack summary: ${f.summary ? `written ${ago(f.summaryAt, now)} by ${f.summary.by ?? "?"}:\n${clip(String(f.summary.text).trim(), FACT_CHARS.summary)}` : "none yet"}`;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
// One thread's facts as the model reads them.
|
|
110
|
-
export function factsText(f, now) {
|
|
111
|
-
const msg = (m, n) => (m ? `${m.author}, ${ago(m.at, now)}: ${clip(flat(m.text), n)}` : "none");
|
|
112
|
-
return [
|
|
113
|
-
`### Thread ${f.id}: "${f.title}"${f.archived ? " (archived)" : ""}`,
|
|
114
|
-
`Latest human message: ${msg(f.human, FACT_CHARS.human)}`,
|
|
115
|
-
`Bot messages since the latest human message: ${f.botsSinceHuman}`,
|
|
116
|
-
`Latest bot message: ${msg(f.bot, FACT_CHARS.bot)}`,
|
|
117
|
-
`Messages since the track summary was written: ${f.sinceSummary}${f.sinceSummary > f.recent.length ? ` (the last ${f.recent.length} below)` : ""}`,
|
|
118
|
-
...f.recent.map((m) => `- ${msg(m, FACT_CHARS.recent)}`),
|
|
119
|
-
`Latest bot activity of any kind (tool calls, subagents included): ${ago(f.eventAt, now)}`,
|
|
120
|
-
`Bots working now: ${f.working.join(", ") || "none"}${f.phase ? ` · phase the bots reported: ${f.phase}` : ""}`,
|
|
121
|
-
].join("\n");
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
// The verdict: one factual question, does the track summary already state
|
|
125
|
-
// what this thread's latest messages say? Only `missing` wakes main.
|
|
126
|
-
export const VERDICT = {
|
|
127
|
-
missing: "The thread's latest messages state where the work stands (a decision, a plan, a PR opened, pushed, reviewed or merged, work finished, a problem, a question for the human) and the track summary doesn't state it, or states an older status (e.g. the summary calls a PR a draft or awaiting review and the thread says it merged). Also when the track has no summary yet.",
|
|
128
|
-
stated: "The track summary already states it, including the current status of every PR and decision the messages mention. A summary that names the PR but gives an older status doesn't count.",
|
|
129
|
-
chatter: "The messages don't state anything about where the work stands: acks, progress narration, a bot handing off to another bot mid-task with no outcome yet.",
|
|
130
|
-
};
|
|
131
|
-
export const wakes = (d) => d.verdict === "missing";
|
|
132
|
-
export const CHANGE = {
|
|
133
|
-
nothing: "Nothing that matters changed.",
|
|
134
|
-
progress: "Work moved forward; nothing settled yet.",
|
|
135
|
-
plan: "A plan or approach was posted or changed.",
|
|
136
|
-
needs_human: "A bot now waits on the human: a question, a decision or a sign-off.",
|
|
137
|
-
decided: "The human decided, approved or redirected something.",
|
|
138
|
-
pr: "A PR was opened, pushed, reviewed or its CI changed.",
|
|
139
|
-
landed: "Work was merged, shipped or finished.",
|
|
140
|
-
problem: "Something broke, failed or got stuck.",
|
|
141
|
-
};
|
|
142
|
-
// The evaluation for one thread: its track's summary, then its facts.
|
|
143
|
-
// -> { state, questions }
|
|
144
|
-
export function threadQuestions(f, now) {
|
|
145
|
-
const questions = {
|
|
146
|
-
verdict: { type: "choice", instructions: "Does the track summary already state what this thread's messages since it say, with each PR's and decision's current status?", criteria: VERDICT },
|
|
147
|
-
change: { type: "choice", instructions: "What changed in this thread since the track summary was written?", criteria: CHANGE },
|
|
148
|
-
};
|
|
149
|
-
const state = [
|
|
150
|
-
"A chat thread where AI bots work for a human. It sits on a track (a feature); main keeps a short summary per track so the human sees what's up without reading the threads. Compare the thread's messages with the track summary's text: answer from what the summary says, not from how long ago it was written. Times are relative to now.",
|
|
151
|
-
trackText(f, now),
|
|
152
|
-
factsText(f, now),
|
|
153
|
-
].join("\n\n");
|
|
154
|
-
return { state, questions };
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
// Ask about each thread in `ask`, one evaluation per thread, all at once:
|
|
158
|
-
// given every thread in one evaluation, the model answered for the wrong
|
|
159
|
-
// thread (merges in a thread's latest message read as already stated). -> { decided: [{ ...facts,
|
|
160
|
-
// verdict, change }], usage, ms }, or null when a call failed or an answer
|
|
161
|
-
// is invalid.
|
|
162
|
-
export async function decideTracks(ask, { evaluate, model = DECISION_MODEL, now = Date.now(), timeoutMs = 30_000, log = console }) {
|
|
163
|
-
const started = Date.now();
|
|
164
|
-
let rs;
|
|
165
|
-
try {
|
|
166
|
-
rs = await Promise.all(ask.map((f) => evaluate({ model, ...threadQuestions(f, now), maxRetries: 1, abortSignal: AbortSignal.timeout(timeoutMs) })));
|
|
167
|
-
} catch (err) {
|
|
168
|
-
log?.warn?.(`tracks: evaluation failed, tick skipped: ${err?.message ?? err}`);
|
|
169
|
-
return null;
|
|
170
|
-
}
|
|
171
|
-
const ms = Date.now() - started;
|
|
172
|
-
const pick = (r, q, criteria) => {
|
|
173
|
-
const a = r?.answers?.[q];
|
|
174
|
-
return a?.type === "choice" && Object.hasOwn(criteria, a.choice) ? a.choice : null;
|
|
175
|
-
};
|
|
176
|
-
const decided = ask.map((f, i) => ({ ...f, verdict: pick(rs[i], "verdict", VERDICT), change: pick(rs[i], "change", CHANGE) }));
|
|
177
|
-
if (decided.some((d) => !d.verdict || !d.change)) {
|
|
178
|
-
log?.warn?.("tracks: invalid answer, tick skipped");
|
|
179
|
-
return null;
|
|
180
|
-
}
|
|
181
|
-
const usage = { inputTokens: rs.reduce((n, r) => n + (r?.usage?.inputTokens ?? 0), 0) };
|
|
182
|
-
log?.log?.(`tracks: asked about ${ask.length} threads in ${ms} ms (${usage.inputTokens} input tokens), ${decided.filter(wakes).length} need an update`);
|
|
183
|
-
return { decided, usage, ms };
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
// The post that wakes main: only the threads that need an update, by track,
|
|
187
|
-
// each with the model's answers and its facts.
|
|
188
|
-
export function tracksNotice(decided, now = Date.now()) {
|
|
68
|
+
// The post that wakes the summary writer (main, unless the workspace's
|
|
69
|
+
// schedule names another bot): the threads with new messages, by track.
|
|
70
|
+
export function tracksNotice(wake, now = Date.now(), writer = "main") {
|
|
189
71
|
const lines = [
|
|
190
|
-
"
|
|
72
|
+
`@${writer} ` + "These threads have new messages since you last saw them. Read each (`read_thread`) and, where its track's summary no longer states where the work stands, rewrite it with `set_summary` (scope `path:<path>`), a short markdown note of what's up, written for the human. Leave a summary that's still right as it is. Reply here with one line; mention nobody.",
|
|
191
73
|
];
|
|
192
74
|
let track = null;
|
|
193
|
-
for (const d of
|
|
75
|
+
for (const d of wake) {
|
|
194
76
|
if (d.track !== track) {
|
|
195
77
|
track = d.track;
|
|
196
|
-
lines.push("", `- **${d.trackTitle}** (\`${d.track}\`): ${d.
|
|
78
|
+
lines.push("", `- **${d.trackTitle}** (\`${d.track}\`): ${d.summarized ? `summary written ${ago(d.summaryAt, now)}` : "no summary yet"}`);
|
|
197
79
|
}
|
|
198
|
-
const human = d.
|
|
199
|
-
lines.push(` - [${d.id}](${d.id}) ${d.title}: ${d.
|
|
80
|
+
const human = d.humanAt ? `human ${ago(d.humanAt, now)}, ${d.botsSinceHuman} bot messages since` : `no human message, ${d.botsSinceHuman} bot messages`;
|
|
81
|
+
lines.push(` - [${d.id}](${d.id}) ${d.title}: ${d.sinceSummary} messages since the summary; ${human}; latest message ${ago(d.lastAt, now)}${d.working.length ? `; ${d.working.join(", ")} working` : ""}${d.archived ? " (archived)" : ""}`);
|
|
200
82
|
}
|
|
201
83
|
return lines.join("\n");
|
|
202
84
|
}
|
|
203
85
|
|
|
204
|
-
// One tick. State: {
|
|
205
|
-
//
|
|
206
|
-
//
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
86
|
+
// One tick. State: { marks }, per thread on an open track, the newest
|
|
87
|
+
// message main has been handed (a woken thread's is the one just listed).
|
|
88
|
+
// Saved only with a post, or on the first run. `writer`: the bot the notice
|
|
89
|
+
// tags. One that isn't on the roster gets main a notice saying so, and no
|
|
90
|
+
// marks move, so nothing is skipped once it's fixed.
|
|
91
|
+
// -> { posts, state, wake }
|
|
92
|
+
/** @param {any} hub @param {{ now?: number, writer?: string }} [opts] */
|
|
93
|
+
export async function runTracks(hub, { now = Date.now(), writer = "main" } = {}) {
|
|
94
|
+
let wake = [];
|
|
210
95
|
const result = await runOnce(hub, "tracks", async (snap, saved) => {
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
if (ask.length && evaluate) {
|
|
214
|
-
const out = await decideTracks(ask, { evaluate, model, now, log });
|
|
215
|
-
if (!out) return { posts: [], state: saved, changed: false };
|
|
216
|
-
tick = out;
|
|
96
|
+
if (!(snap?.agents ?? []).some((a) => a.name === writer)) {
|
|
97
|
+
return { posts: [`@main The tracks schedule is set to tag \`${writer}\`, which isn't a bot in this workspace, so no track summaries are being updated. Fix the writer in agents/main/agent/schedules/tracks.ts.`], state: saved, changed: false };
|
|
217
98
|
}
|
|
218
|
-
const
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
const
|
|
223
|
-
return { posts: wake.length ? [tracksNotice(wake, now)] : [], state
|
|
99
|
+
const marks = saved?.marks ?? null;
|
|
100
|
+
let all;
|
|
101
|
+
({ all, wake } = tracksFacts(snap, marks ?? {}));
|
|
102
|
+
if (marks && !wake.length) return { posts: [], state: saved, changed: false };
|
|
103
|
+
const state = { marks: Object.fromEntries(all.map((f) => [f.id, f.lastAt ?? ""])) };
|
|
104
|
+
return { posts: wake.length ? [tracksNotice(wake, now, writer)] : [], state, changed: true };
|
|
224
105
|
}, { now });
|
|
225
|
-
return { ...result,
|
|
106
|
+
return { ...result, wake };
|
|
226
107
|
}
|
package/schedules/tracks.d.ts
CHANGED
package/schedules/tracks.js
CHANGED
|
@@ -1,16 +1,18 @@
|
|
|
1
|
-
// main's tracks schedule: every 10 minutes,
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
1
|
+
// main's tracks schedule: every 10 minutes, tags main in sch-main-tracks
|
|
2
|
+
// with the threads on open tracks that have new messages since a notice last
|
|
3
|
+
// listed them; main then rewrites the summaries that need it with
|
|
4
|
+
// set_summary. No model call. The pass: runTracks in ./tracks-lead.mjs. A
|
|
5
|
+
// workspace that has another bot write summaries exports
|
|
6
|
+
// tracksSchedule({ writer: "<bot>" }) from its agents/main/agent/schedules/tracks.ts.
|
|
7
7
|
import { defineSchedule } from "eve/schedules";
|
|
8
|
-
import { evaluate } from "eve/ai";
|
|
9
8
|
import { createHub } from "./hub.mjs";
|
|
10
9
|
import { runTracks } from "./tracks-lead.mjs";
|
|
11
|
-
export
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
}
|
|
10
|
+
export function tracksSchedule({ writer } = {}) {
|
|
11
|
+
return defineSchedule({
|
|
12
|
+
cron: "*/10 * * * *",
|
|
13
|
+
async run() {
|
|
14
|
+
await runTracks(createHub(), { writer });
|
|
15
|
+
},
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
export default tracksSchedule();
|
|
@@ -12,9 +12,9 @@ You don't know in advance which bots exist or what they're good at, and the team
|
|
|
12
12
|
- **Cross-thread chores (merge, rebase, close out, restart) fan out to the threads that own the work.** Post in each owning thread and @-mention the bot that built it. That bot lands its own branch, rebases onto the latest target and fast-forwards (retrying if the target moved), checks the build, and reports the sha in its own thread. Never collect several threads' work into one bot or into your own session. Track which threads have landed and report back where the human asked.
|
|
13
13
|
|
|
14
14
|
## Opening work
|
|
15
|
-
**Paths.** A thread is one piece of work: one task, usually one PR. Every thread sits on one **path**, `project/track`, e.g. `
|
|
15
|
+
**Paths.** A thread is one piece of work: one task, usually one PR. Every thread sits on one **path**, `project/track`, e.g. `web/checkout/cart`: the first segment is the project, the rest is the feature or effort inside it ("checkout redesign", "billing migration"), which spans many threads and PRs. Put each thread you open on the path for its area if one exists, even read broadly (`list_threads` shows each thread's path; a bug or follow-up in an area belongs to it). A new path only for work that clearly starts a new effort, named for the feature, never the task. Near-duplicates (`web/checkout` and `web/checkout-ui`) get merged with `move_path`. Never move a thread whose path the human set (it's locked).
|
|
16
16
|
|
|
17
|
-
**Summaries.** You lead every track: keep each one's summary current with `set_summary` (scope `path:<path>`, e.g. `path:
|
|
17
|
+
**Summaries.** You lead every track: keep each one's summary current with `set_summary` (scope `path:<path>`, e.g. `path:web/checkout`), a short free-form markdown note for the human of what's up in that feature. You decide what's worth saying and when; there's no fixed format. Every 10 minutes your `tracks` schedule tags the summary writer in `sch-main-tracks` with each thread that has new messages since a notice last listed it, by track, with its facts. The writer is you unless the schedule names another bot; then leave those notices to it. When it's you: read each listed thread (`read_thread`) and rewrite its track's summary where it no longer states where the work stands; leave a summary that's still right. Reply there with one line and mention nobody. A thread can carry a summary too (scope `thread:<id>`: what it's about and where it stands); write one when a thread's opening ask no longer describes it. `list_threads` returns both, and the classifier files threads by them.
|
|
18
18
|
|
|
19
19
|
**Track chats.** Each path can have one track chat: an ordinary thread on that path, opened from the track page's composer, where the human asks you about the track. Answer from the track's threads, PRs and summary. When a question turns into work, post it in the thread that owns it, or open a new thread on the path and hand it to a builder, and link that thread in your reply. A track chat isn't counted among the path's threads and doesn't trigger your `tracks` schedule.
|
|
20
20
|
|
|
@@ -22,7 +22,7 @@ Call `create_thread` per piece of work, with its `path` and a `domain` tag (reus
|
|
|
22
22
|
|
|
23
23
|
Then reply to the human in 1–3 lines: what you opened and who's on it. In a DM, @-mentions never wake anyone; they're links for the human, so tag every bot you refer to there.
|
|
24
24
|
|
|
25
|
-
When the human starts a thread themselves (from the new-thread view), it comes to you untitled and already is the piece of work: don't `create_thread` another.
|
|
25
|
+
When the human starts a thread themselves (from the new-thread view), it comes to you untitled and already is the piece of work: don't `create_thread` another. File it first with one `update_thread` call (a few words for what's asked as the `title`, a `path` (project/track), a `domain` and a short `summary`; the delivery lists the projects and domains in use and every track with its summary), then route the work inside it by @-mentioning the bot who should start, or answer if it's a quick question.
|
|
26
26
|
|
|
27
27
|
## Memory
|
|
28
28
|
When the human states a durable preference (one that should hold beyond the current task), add it as a memory entry in the workspace repo: `memory/<id>.md` if it holds for every bot (naming no bot; a rule for one role says the role), or `agents/<bot>/memory/<id>.md` if it holds for one bot only (it may name that bot, no other). Front matter `id` (the file name, not already used in the other layer), `status: active`, `since` (today), `evidence` (`[thr_…/msg_…]`), then the rule in at most 3 lines. If it replaces an entry, set that one's `status: superseded` and `superseded_by: <new id>` rather than rewriting it. It 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.
|
|
@@ -32,13 +32,13 @@ When the human states a durable preference (one that should hold beyond the curr
|
|
|
32
32
|
- `remove_agent` removes a bot from the team and shuts it down (name). Use it when the human asks. It can't remove main or humans.
|
|
33
33
|
- `show` moves the human's screen to a thread or a DM, optionally scrolled to one message. `create_thread` already takes them to the new thread.
|
|
34
34
|
- `post_message` posts as you into an existing thread (`threadId`) or a bot's DM (`agent`), from any session: e.g. telling several stalled threads to retry. Its @-mention wakes that bot just like a reply (one mention, never yourself); archived threads refuse it. Every bot has it, not just you. Without a target it posts into the thread you're in, which is how you talk there.
|
|
35
|
-
- **Thread tags** (`project`, `domain`) group the human's sidebar. There's no fixed list: the tags are whatever threads use. Keeping them tidy is your job, ongoing: retag a thread whose tag no longer fits what it's doing, merge near-duplicates and one-offs into bigger tags, rename a tag. Do it as a model pass: `tag_threads` `vocabulary` for what's in use and what's untagged, `list_threads` / `read_thread` to read what the threads actually did (not just their titles), then `apply_tags` with `assignments` (tags per thread), `merges` (domains only: `from` into `to`; a rename is a one-tag merge; projects and paths merge or rename with `move_path`) and/or `labels` (a tag's display name, which heads its sidebar group: `
|
|
35
|
+
- **Thread tags** (`project`, `domain`) group the human's sidebar. There's no fixed list: the tags are whatever threads use. Keeping them tidy is your job, ongoing: retag a thread whose tag no longer fits what it's doing, merge near-duplicates and one-offs into bigger tags, rename a tag. Do it as a model pass: `tag_threads` `vocabulary` for what's in use and what's untagged, `list_threads` / `read_thread` to read what the threads actually did (not just their titles), then `apply_tags` with `assignments` (tags per thread), `merges` (domains only: `from` into `to`; a rename is a one-tag merge; projects and paths merge or rename with `move_path`) and/or `labels` (a tag's display name, which heads its sidebar group: `payments-api` → "Payments API"; a tag without one shows title-cased, so name any that read wrong that way). It applies right away, no approval needed; say in a line what you changed. For a big pass the human asked to review first, stage it with `tag_threads` `propose`, post the table, and `apply_tags` its `proposalId` once they say yes. Tags and labels the human set by hand stay put, merges included; change one only when the human asks you to.
|
|
36
36
|
- `archive_thread` takes a thread off the active list once the human has signed off on it or dropped it; history is kept. Archive only when the human says so or the thread's work is clearly finished, and give a short `reason`. `unarchive_thread` brings one back.
|
|
37
37
|
|
|
38
38
|
## The Dash
|
|
39
39
|
The Dash is the human's view of open threads that need them, grouped by project, then by delay. Each thread can carry one **entry**: `importance` (`high` | `normal` | `low`: how much it matters given the human's role), `delay` (`blocking`: a bot can't go on until the human acts; `land`: finished work waits on a merge or sign-off and goes stale; `fyi`: nothing waits on it), an optional `reason`, and `deferred` (on the backburner: hidden, and not classified again until un-deferred). The sidebar badge counts `blocking` entries. Threads where a bot is working are hidden until they settle.
|
|
40
40
|
- **Classifier.** Your `dash` schedule classifies every idle, non-deferred thread that moved on since its entry was written, every 10 minutes, and writes the entries itself. You don't rewrite the Dash on each tick.
|
|
41
41
|
- **Ticks.** The same schedule also scans PRs, threads where a bot asks the human, and Dash threads that moved on, and posts in `sch-main-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): 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.
|
|
42
|
-
- **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 `
|
|
42
|
+
- **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.
|
|
43
43
|
- **Deferred.** The human can defer or bring back a thread from the Dash themselves; don't undo their call.
|
|
44
44
|
- **Report only.** The Dash never nudges bots, merges, or posts into the threads it lists.
|
|
@@ -16,7 +16,7 @@ You are one named bot in a small workspace of long-lived bots. Work happens in t
|
|
|
16
16
|
- You can `show` the human something: it moves their screen to a thread or DM, optionally scrolled to one message. Use it sparingly, when they need to look now.
|
|
17
17
|
- You can `react` to any message by its `[msg_…]` id. React when a reply would be noise: 👍 to acknowledge, 👀 when you're picking something up that takes a while, ✅ when you've done what was asked, 🎉 for good news. Reactions are yours to choose; don't react to everything.
|
|
18
18
|
- Images people post come with the message (shown as `[image: name]` in the log), and you see them. To post one yourself, call `attach_image` with a file from your sandbox or `screenshot: true` for your browser; it goes out with your next message. To look at an image file yourself (a screenshot you took, a capture), call `view_image` with its path; `read_file` can't read images and `attach_image` doesn't show them to you. To see an image posted in another thread, call `read_thread` with that message's `messageId`.
|
|
19
|
-
- If a thread's scope has clearly changed, `
|
|
19
|
+
- If a thread's scope has clearly changed, `update_thread` retitles it, and refreshes its track, domain and summary (everyone sees an inline "renamed this thread: old → new" line on a retitle). Do it rarely, and only for threads you're in.
|
|
20
20
|
- Your session only sees the thread it lives in. To find or read any other thread (by title, who's busy there, archived or not), use `list_threads` and `read_thread`; don't guess from memory.
|
|
21
21
|
- Never `sleep` or poll for more than 60s in one bash call, except `gh pr checks <n> --watch` while landing an approved PR (set timeoutSeconds). For other CI or a deploy, check once, report, and say what you're waiting on.
|
|
22
22
|
- If you have `kit__worker`, split the work by cost. Exploration (reading code, tracing call sites) goes to an `agent` copy or researcher. An edit you can spec fully (files, the change, how to verify) goes to `kit__worker`. You keep the design, the debugging, reading every diff it makes, and running the tests.
|
|
@@ -44,9 +44,15 @@ Anything you post outside this workspace (GitHub comments and reviews, PR descri
|
|
|
44
44
|
|
|
45
45
|
## Style
|
|
46
46
|
|
|
47
|
-
|
|
47
|
+
Signal first: say only the essence, what the reader must know or decide. Precise and technical, for an engineer.
|
|
48
48
|
|
|
49
|
-
|
|
49
|
+
- Delta only. Post only what's new since the last message in the thread. Never restate what another bot already posted (PR number, sha, file, verdict): link the message that posted it. If you have nothing new, react instead of posting.
|
|
50
|
+
- Crux first. When you need something from the reader, the first sentence is the one question, answerable in a word: "Land #422 now, or hold it until Kit works?" Otherwise the first sentence is the verdict or what changed. Then only the 1–3 facts that decide it.
|
|
51
|
+
- Every sentence must change what the reader does next. If losing it changes nothing, cut it. No filler, no disclaimers, no "great question", no recap of the plan back to its author, no bold labels or code chips on ordinary nouns; code formatting is for what the reader will paste or search.
|
|
52
|
+
- Name things at the reader's level. Say "the worker fix" or "GitHub writes", not "fix 1" or `resolveSubagent`. Identifiers belong behind the link.
|
|
53
|
+
- Depth is one step away, and it is shown, not written. Plans, briefs and reviews keep only the essence inline; the full body lives in a file you link, with the file:line, test or trace next to each claim. When asked to go deeper, don't add prose. Give a concrete scenario in numbered steps (who does what, then what happens: "1. agent A calls a subagent 2. it runs on the hub's model cap"), then the code, diff, trace or command output that proves it, trimmed to the lines that make the point, then a link pinned to a commit.
|
|
54
|
+
|
|
55
|
+
Link what you reference, so the human can click it, using the repo you actually worked in. PRs and issues get full URLs, `[#4234](https://github.com/<owner>/<repo>/pull/4234)`; commits too, ``[`684725be`](https://github.com/<owner>/<repo>/commit/684725be)``. Code on GitHub gets a permalink pinned to a commit sha, ``[`path:267`](https://github.com/<owner>/<repo>/blob/<sha>/path#L267)``, never a branch URL that drifts. Local files get their absolute path and line, ``[`docs/setup.md:42`](/Users/<you>/wrk/<repo>/docs/setup.md#L42)``; clicking opens the file in the human's editor. Bare `#123`, shas and paths stay plain text. Never show a raw URL, in prose or in a table cell: every link is `[label](url)`, and the label names the target (`#68`, a short sha, `file.swift:731`, a page title), never the URL itself. The one exception is when the human asks for the literal URL, e.g. to copy it.
|
|
50
56
|
|
|
51
57
|
## Client surface
|
|
52
58
|
|
|
@@ -83,5 +89,4 @@ renders as:
|
|
|
83
89
|
- `attach_image` shows an image tile. `![]()` shows only alt text; raw HTML shows literally.
|
|
84
90
|
- Nothing collapses, and the Mentions inbox previews only the first plain-text line of each message. So each message's first bubble is its point, in at most 2 sentences; detail follows after `---`.
|
|
85
91
|
- Separate updates (started, found, done) are separate posts; `---` splits one message into bubbles.
|
|
86
|
-
- Optional detail doesn't go in the message. If it's worth having, end with one line, `More: <topic> · <topic>`, and expand a topic when asked.
|
|
87
92
|
- Tables only for 2+ rows of short, comparable values: at most 4 columns sharing the width equally, a few words per cell. A single item, or anything that needs a reason, goes in a sentence or bullets. Shorten shas to 8 characters; keep URLs and paths in link text, not cells.
|
package/tools/agent_send.js
CHANGED
|
@@ -10,7 +10,7 @@ export default defineTool({
|
|
|
10
10
|
'Returns { result: "queued" | "steered" | "aborted" | "not busy there", ... }. On "not busy there" from steer, send with queue instead.',
|
|
11
11
|
].join("\n"),
|
|
12
12
|
inputSchema: z.object({
|
|
13
|
-
agent: z.string().describe("The bot,
|
|
13
|
+
agent: z.string().describe("The bot, a bot's name, as the workspace state lists it."),
|
|
14
14
|
threadId: z.string().optional().describe("The thread, e.g. thr_006b. Needed for queue and steer, and for abort with input."),
|
|
15
15
|
input: z.string().optional().describe("What to tell the bot, written to it. Needed for queue and steer; optional for abort."),
|
|
16
16
|
policy: z.enum(["queue", "steer", "abort"]).optional().describe("queue (default), steer, or abort."),
|
package/tools/apply_tags.js
CHANGED
|
@@ -4,14 +4,14 @@
|
|
|
4
4
|
import { defineTool } from "eve/tools";
|
|
5
5
|
import { z } from "zod";
|
|
6
6
|
import { registryFetch } from "../registry-fetch.mjs";
|
|
7
|
-
const tag = z.string().describe("Lowercase slug, e.g. '
|
|
7
|
+
const tag = z.string().describe("Lowercase slug, e.g. 'checkout'.");
|
|
8
8
|
export default defineTool({
|
|
9
|
-
description: "Retag threads, merge / rename tags, or set their display names, workspace-wide. Pass `assignments` (tags per thread), `merges` (domains only: fold the `from` domains into `to`; later uses land on `to`; a rename is a one-tag merge. Projects and paths merge or rename with move_path), and/or `labels` (a tag's display name, the sidebar group header, e.g.
|
|
9
|
+
description: "Retag threads, merge / rename tags, or set their display names, workspace-wide. Pass `assignments` (tags per thread), `merges` (domains only: fold the `from` domains into `to`; later uses land on `to`; a rename is a one-tag merge. Projects and paths merge or rename with move_path), and/or `labels` (a tag's display name, the sidebar group header, e.g. payments-api -> 'Payments API'; no threads change), or the `proposalId` of a batch staged with tag_threads. Writes immediately, no approval. Tags and labels the human set by hand are kept, merges included. Read the threads first (list_threads, read_thread) so tags follow what they did, not their titles.",
|
|
10
10
|
inputSchema: z.object({
|
|
11
11
|
proposalId: z.string().optional().describe("A batch staged with tag_threads propose, e.g. tgp_02ab."),
|
|
12
12
|
note: z.string().optional().describe("One line on what this pass changes and why."),
|
|
13
13
|
assignments: z
|
|
14
|
-
.array(z.object({ threadId: z.string(), path: z.string().optional().describe("project/track, e.g. '
|
|
14
|
+
.array(z.object({ threadId: z.string(), path: z.string().optional().describe("project/track, e.g. 'web/checkout'; sets the project too"), project: tag.optional(), domain: tag.optional() }))
|
|
15
15
|
.optional()
|
|
16
16
|
.describe("Tags per thread."),
|
|
17
17
|
merges: z
|
|
@@ -9,7 +9,7 @@ export default defineTool({
|
|
|
9
9
|
description: "Publish the next version of an artifact: a versioned static site you own and keep current, such as a change explainer for a PR or stack. Builds it from `source` in your sandbox, deploys it, moves its stable URL to the new version, and shows a card in this thread (the same card updates on every later version). Takes a few minutes. Types: change-explainer (source: { fixture: <fixture dir>, repo: <git checkout>, scip?: <index.scip>, branch?: <ref> }).",
|
|
10
10
|
inputSchema: z.object({
|
|
11
11
|
type: z.enum(Object.keys(TYPES)).describe("Artifact type."),
|
|
12
|
-
key: z.string().regex(/^[\w.-]{1,40}$/).describe("Stable key within the type, e.g. the fixture id `thr_05a3` or `
|
|
12
|
+
key: z.string().regex(/^[\w.-]{1,40}$/).describe("Stable key within the type, e.g. the fixture id `thr_05a3` or `api-pr-123`. Same key = same artifact, next version."),
|
|
13
13
|
source: z.record(z.string(), z.unknown()).describe("What the type builds from (see the description)."),
|
|
14
14
|
}),
|
|
15
15
|
async execute({ type, key, source }, ctx) {
|
package/tools/create_thread.js
CHANGED
|
@@ -4,14 +4,14 @@ import { registryFetch } from "../registry-fetch.mjs";
|
|
|
4
4
|
export default defineTool({
|
|
5
5
|
description: "Open a new workspace thread for one piece of work. The first message is posted as you; @-mention the bot who should start. File it under a path (project/track) and tag a domain when you can (reuse tags in use; list_threads shows each thread's tags); a thread left untagged asks its first bot to tag it.",
|
|
6
6
|
inputSchema: z.object({
|
|
7
|
-
title: z.string().describe("Short thread title, e.g. '
|
|
7
|
+
title: z.string().describe("Short thread title, e.g. 'Checkout: retry failed payments'."),
|
|
8
8
|
message: z.string().describe("First message. Must @-mention who should act first."),
|
|
9
|
-
project: z.string().optional().describe("Project tag: the codebase or effort, e.g. '
|
|
10
|
-
domain: z.string().optional().describe("Domain tag: the kind of work, e.g. '
|
|
11
|
-
projectLabel: z.string().optional().describe("Display name for a new project tag, e.g. '
|
|
12
|
-
domainLabel: z.string().optional().describe("Display name for a new domain tag, e.g. '
|
|
13
|
-
path: z.string().optional().describe("Path: project, then the track inside it, e.g. '
|
|
14
|
-
pathLabel: z.string().optional().describe("Display name for a new path's last segment, e.g. '
|
|
9
|
+
project: z.string().optional().describe("Project tag: the codebase or effort, e.g. 'web', 'api'."),
|
|
10
|
+
domain: z.string().optional().describe("Domain tag: the kind of work, e.g. 'feature', 'bugfix', 'docs'."),
|
|
11
|
+
projectLabel: z.string().optional().describe("Display name for a new project tag, e.g. 'Payments API'. Only names a tag that has none yet."),
|
|
12
|
+
domainLabel: z.string().optional().describe("Display name for a new domain tag, e.g. 'Bug fixes'. Only names a tag that has none yet."),
|
|
13
|
+
path: z.string().optional().describe("Path: project, then the track inside it, e.g. 'web/checkout/cart' or 'api/billing'. Reuse a path in use (list_threads shows each thread's); the first segment is the project."),
|
|
14
|
+
pathLabel: z.string().optional().describe("Display name for a new path's last segment, e.g. 'Cart'. Only names a path that has none yet."),
|
|
15
15
|
focus: z.boolean().optional().describe("Move the human's screen to the new thread (default true)."),
|
|
16
16
|
}),
|
|
17
17
|
async execute({ title, message, project, domain, projectLabel, domainLabel, path, pathLabel, focus = true }, ctx) {
|
package/tools/list_threads.js
CHANGED
|
@@ -5,7 +5,7 @@ import { defineTool } from "eve/tools";
|
|
|
5
5
|
import { z } from "zod";
|
|
6
6
|
import { registryFetch } from "../registry-fetch.mjs";
|
|
7
7
|
export default defineTool({
|
|
8
|
-
description: "List workspace threads, workspace-wide (not just the one you're in): id, title, participants, status (active/archived), who's busy there (since when, doing what), last activity, tags (path: project/track, e.g. '
|
|
8
|
+
description: "List workspace threads, workspace-wide (not just the one you're in): id, title, participants, status (active/archived), who's busy there (since when, doing what), last activity, tags (path: project/track, e.g. 'web/checkout'; project; domain), and the thread's stored summary when main wrote one. Newest activity first. `paths` holds the listed threads' paths with each one's current summary (what's up in that feature). Use it to find a thread by title before asking someone, and read_thread to read one.",
|
|
9
9
|
inputSchema: z.object({
|
|
10
10
|
query: z.string().optional().describe("Match titles containing this text (or an exact id)."),
|
|
11
11
|
archived: z.enum(["exclude", "include", "only"]).optional().describe("Archived threads: exclude (default), include, or only."),
|
|
@@ -13,7 +13,7 @@ export default defineTool({
|
|
|
13
13
|
participant: z.string().optional().describe("Only threads this bot or human is in."),
|
|
14
14
|
busy: z.string().optional().describe("'true' for threads with someone working, 'false' for none, or a bot name."),
|
|
15
15
|
project: z.string().optional().describe("Only threads with this project tag ('none' for untagged)."),
|
|
16
|
-
path: z.string().optional().describe("Only threads on this path or under it, e.g. '
|
|
16
|
+
path: z.string().optional().describe("Only threads on this path or under it, e.g. 'web' or 'web/checkout'."),
|
|
17
17
|
domain: z.string().optional().describe("Only threads with this domain tag ('none' for untagged)."),
|
|
18
18
|
limit: z.number().int().min(1).max(500).optional().describe("Max threads (default 50)."),
|
|
19
19
|
}),
|
package/tools/move_path.js
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
// The `move_path` tool (main's): rename or merge a path (project/track, e.g.
|
|
2
|
-
// `
|
|
2
|
+
// `web/checkout`). Every thread on it or under it moves, keeping the part
|
|
3
3
|
// below; its label and summary carry over; later uses of the old path land
|
|
4
4
|
// on the new one. Threads whose path a human set by hand stay (lib/paths.mjs).
|
|
5
5
|
import { defineTool } from "eve/tools";
|
|
6
6
|
import { z } from "zod";
|
|
7
7
|
import { registryFetch } from "../registry-fetch.mjs";
|
|
8
8
|
export default defineTool({
|
|
9
|
-
description: "Rename or merge a path (project/track). Every thread on `from` or under it moves to `to`, keeping the part below (from '
|
|
9
|
+
description: "Rename or merge a path (project/track). Every thread on `from` or under it moves to `to`, keeping the part below (from 'checkout', to 'web/checkout': 'checkout/cart' becomes 'web/checkout/cart'). Use it to fold near-duplicate paths into one or to move a project under another. Threads whose path the human set by hand stay; the reply lists them.",
|
|
10
10
|
inputSchema: z.object({
|
|
11
|
-
from: z.string().describe("The path to move, e.g. '
|
|
12
|
-
to: z.string().describe("Where it goes, e.g. '
|
|
11
|
+
from: z.string().describe("The path to move, e.g. 'checkout' or 'web/checkout-ui'."),
|
|
12
|
+
to: z.string().describe("Where it goes, e.g. 'web/checkout' or 'web/payments' (an existing path merges)."),
|
|
13
13
|
}),
|
|
14
14
|
async execute({ from, to }, ctx) {
|
|
15
15
|
const res = await registryFetch(`/api/paths/move`, {
|
package/tools/post_message.js
CHANGED
|
@@ -16,7 +16,7 @@ import { registryFetch } from "../registry-fetch.mjs";
|
|
|
16
16
|
export default defineTool({
|
|
17
17
|
description: "Post a message as you. Leave out threadId and agent to post into the thread you're in, while you work: posting doesn't end your turn, and when a post is your turn's last act your end-of-turn text isn't posted. Or post into another workspace thread or a bot's DM. @-mentions wake bots the same way your replies do (at most one, never yourself); `@all` wakes every participant; each bot's gate decides whether it replies. Posting into a bot's DM wakes that bot. Archived threads are refused. A status post sets the thread's phase: planning, working, reviewing or done; allowed moves are start → planning or working, planning → working, working → reviewing, reviewing → working (fix first), planning (rethink) or done. Any other move is refused with the allowed next phases. The text is the one-line summary; metadata carries the PR and sha.",
|
|
18
18
|
inputSchema: z.object({
|
|
19
|
-
threadId: z.string().optional().describe("Another thread's id, e.g. thr_0002, or a DM id
|
|
19
|
+
threadId: z.string().optional().describe("Another thread's id, e.g. thr_0002, or a DM id, dm-<bot>. Omit it to post into the thread you're in."),
|
|
20
20
|
agent: z.string().optional().describe("Post into this bot's DM instead of a thread."),
|
|
21
21
|
text: z.string().describe("The message. @-mention the one bot who should act. On a status post: the one-line summary."),
|
|
22
22
|
status: z.enum(["planning", "working", "reviewing", "done"]).optional().describe("The thread's phase after this post. Makes it a status post."),
|
package/tools/read_thread.js
CHANGED
|
@@ -38,7 +38,7 @@ export async function withImages(output) {
|
|
|
38
38
|
return toolOutput.content(parts);
|
|
39
39
|
}
|
|
40
40
|
export default defineTool({
|
|
41
|
-
description: "Read a workspace thread or DM by id (e.g. thr_006o, dm
|
|
41
|
+
description: "Read a workspace thread or DM by id (e.g. thr_006o, or dm-<bot>) or exact title: its summary (participants, who's busy, archived or not) and its latest messages, oldest first. Works for any thread, not just the one you're in. Pass messageId to get just that message with its images, so you can see a screenshot posted in another thread.",
|
|
42
42
|
inputSchema: z.object({
|
|
43
43
|
threadId: z.string().describe("Thread id (thr_…, dm-<bot>) or exact title."),
|
|
44
44
|
limit: z.number().int().min(1).max(200).optional().describe("How many of the latest messages (default 20)."),
|
package/tools/restart_agent.js
CHANGED
|
@@ -4,7 +4,7 @@ import { registryFetch } from "../registry-fetch.mjs";
|
|
|
4
4
|
export default defineTool({
|
|
5
5
|
description: "Restart ONE bot's server so hub.workspace.json edits (model, reasoning, role, subagents) and code changes take effect, without touching any other bot. By default it waits until the bot is idle in every thread, then restarts; with whenIdle false it interrupts now, taking over a restart still waiting for idle. Any turn the restart cuts off is re-delivered to a fresh session afterwards, and messages sent meanwhile wait for the bot. Returns right away; check list_agents (its `restart` field) for progress. main can't restart itself (a human can: `hub restart --bot main`).",
|
|
6
6
|
inputSchema: z.object({
|
|
7
|
-
name: z.string().describe("The bot to restart,
|
|
7
|
+
name: z.string().describe("The bot to restart, a bot's name, as the workspace state lists it."),
|
|
8
8
|
whenIdle: z.boolean().optional().describe("Wait until it's idle everywhere first (default true)."),
|
|
9
9
|
timeoutMinutes: z.number().min(1).max(24 * 60).optional().describe("Give up waiting for idle after this long (default 60). Nothing is restarted on timeout."),
|
|
10
10
|
}),
|
package/tools/set_summary.js
CHANGED
|
@@ -9,7 +9,7 @@ import { registryFetch } from "../registry-fetch.mjs";
|
|
|
9
9
|
export default defineTool({
|
|
10
10
|
description: "Write the summary of a path (project/track) or a thread: a short free-form markdown note, kept current. A path's: what's up in that feature, for the human (what's moving, what's blocked or waiting on them, what just landed). A thread's: what it's about and where it stands. You decide what's worth saying. It replaces the previous summary; empty text clears it. list_threads returns both.",
|
|
11
11
|
inputSchema: z.object({
|
|
12
|
-
scope: z.string().describe("What it summarizes: 'path:<path>' (e.g. 'path:
|
|
12
|
+
scope: z.string().describe("What it summarizes: 'path:<path>' (e.g. 'path:web/checkout') or 'thread:<id>' (e.g. 'thread:thr_0d00')."),
|
|
13
13
|
text: z.string().describe("The whole new summary, markdown. Empty clears it."),
|
|
14
14
|
}),
|
|
15
15
|
async execute({ scope, text }, ctx) {
|
package/tools/tag_threads.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import { defineTool } from "eve/tools";
|
|
5
5
|
import { z } from "zod";
|
|
6
6
|
import { registryFetch } from "../registry-fetch.mjs";
|
|
7
|
-
const tag = z.string().describe("Lowercase slug, e.g. '
|
|
7
|
+
const tag = z.string().describe("Lowercase slug, e.g. 'checkout'.");
|
|
8
8
|
export default defineTool({
|
|
9
9
|
description: "Thread tags (project, domain) workspace-wide. action 'vocabulary': the tags in use with counts, saved merge aliases, untagged threads, and pending proposals. action 'propose': stage a backfill (tags per thread) or a domain merge (fold near-duplicate or one-off domains into others; projects and paths merge with move_path) for the human to review; nothing is written, and the reply has a review table to post. action 'dismiss': drop a pending proposal. For everyday retags and merges skip this and call apply_tags directly.",
|
|
10
10
|
inputSchema: z.object({
|
|
@@ -12,7 +12,7 @@ export default defineTool({
|
|
|
12
12
|
kind: z.enum(["backfill", "merge", "labels"]).optional().describe("For propose."),
|
|
13
13
|
note: z.string().optional().describe("For propose: one line on what this pass did."),
|
|
14
14
|
assignments: z
|
|
15
|
-
.array(z.object({ threadId: z.string(), path: z.string().optional().describe("project/track, e.g. '
|
|
15
|
+
.array(z.object({ threadId: z.string(), path: z.string().optional().describe("project/track, e.g. 'web/checkout'; sets the project too"), project: tag.optional(), domain: tag.optional() }))
|
|
16
16
|
.optional()
|
|
17
17
|
.describe("For propose: tags per thread."),
|
|
18
18
|
merges: z
|
|
@@ -1,22 +1,22 @@
|
|
|
1
1
|
declare const _default: import("eve/tools").ToolDefinition<{
|
|
2
2
|
threadId: string;
|
|
3
3
|
title?: string | undefined;
|
|
4
|
-
|
|
4
|
+
path?: string | null | undefined;
|
|
5
5
|
domain?: string | undefined;
|
|
6
|
+
summary?: string | undefined;
|
|
7
|
+
pathLabel?: string | undefined;
|
|
6
8
|
projectLabel?: string | undefined;
|
|
7
9
|
domainLabel?: string | undefined;
|
|
8
|
-
path?: string | null | undefined;
|
|
9
|
-
pathLabel?: string | undefined;
|
|
10
10
|
}, any> & {
|
|
11
11
|
execute(input: {
|
|
12
12
|
threadId: string;
|
|
13
13
|
title?: string | undefined;
|
|
14
|
-
|
|
14
|
+
path?: string | null | undefined;
|
|
15
15
|
domain?: string | undefined;
|
|
16
|
+
summary?: string | undefined;
|
|
17
|
+
pathLabel?: string | undefined;
|
|
16
18
|
projectLabel?: string | undefined;
|
|
17
19
|
domainLabel?: string | undefined;
|
|
18
|
-
path?: string | null | undefined;
|
|
19
|
-
pathLabel?: string | undefined;
|
|
20
20
|
}, ctx: import("eve/tools").ToolContext): Promise<any>;
|
|
21
21
|
};
|
|
22
22
|
export default _default;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// The `update_thread` tool, shared by every bot: say what a thread is, in one
|
|
2
|
+
// call. Title, path (project/track), domain and the thread's summary. The
|
|
3
|
+
// registry keeps the rename history and posts an inline "<bot> renamed this
|
|
4
|
+
// thread: old → new" marker; tags go on thread.tags (lib/tags.mjs) and the
|
|
5
|
+
// summary is the one `set_summary` writes (lib/paths.mjs setSummary).
|
|
6
|
+
import { defineTool } from "eve/tools";
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
import { registryFetch } from "../registry-fetch.mjs";
|
|
9
|
+
export default defineTool({
|
|
10
|
+
description: "Update a workspace thread you're in: its title, track, domain and summary, in one call. Retitle when the work's scope has changed and the old title no longer fits: everyone sees a short 'renamed this thread: old → new' line; the history is kept. Keep titles short (at most 80 characters). File it on a path (project/track, e.g. 'web/checkout/cart': the first segment is the project, the rest the track) and tag a domain (the kind of work, e.g. 'feature', 'bugfix', 'infra'); reuse a path and domain already in use when they fit; for a new one, also give its display name (pathLabel / domainLabel, e.g. 'Payments API'). The summary is a short note on what the thread is about and where it stands; it replaces the previous one, and empty text clears it. The reply says what each tag was filed under and, for a tag no other thread uses, lists the ones in use so you can switch. Tags a human set by hand stay. DMs and archived threads can't be updated.",
|
|
11
|
+
inputSchema: z.object({
|
|
12
|
+
threadId: z.string().describe("Thread id, e.g. thr_0002 (shown in the [thread: …] header)."),
|
|
13
|
+
title: z.string().optional().describe("New short title, e.g. 'Checkout: retry failed payments'. Omit to leave it."),
|
|
14
|
+
path: z.string().nullable().optional().describe("Path: project, then the track inside it, e.g. 'web/checkout/cart' or 'api/billing'. Reuse a path in use (the naming prompt and list_threads show them); the first segment is the project. null: back to the project alone. A path a human set by hand stays."),
|
|
15
|
+
domain: z.string().optional().describe("Domain tag: the kind of work, e.g. 'feature', 'bugfix', 'infra', 'docs', 'review', 'research'. Lowercase slug."),
|
|
16
|
+
summary: z.string().optional().describe("The thread's whole new summary, markdown: what it's about and where it stands. Empty clears it. Omit to leave it."),
|
|
17
|
+
pathLabel: z.string().optional().describe("Display name for a new path's last segment, e.g. 'Cart'. Only names a path that has none yet."),
|
|
18
|
+
projectLabel: z.string().optional().describe("Display name for a new project (a path's first segment), shown as its sidebar group, e.g. 'Payments API' for payments-api. Only names a project that has none yet."),
|
|
19
|
+
domainLabel: z.string().optional().describe("Display name for a new domain tag, e.g. 'Bug fixes' for bugfix. Only names a tag that has none yet."),
|
|
20
|
+
}),
|
|
21
|
+
async execute({ threadId, ...fields }, ctx) {
|
|
22
|
+
const res = await registryFetch(`/api/threads/${encodeURIComponent(threadId)}/rename`, {
|
|
23
|
+
method: "POST",
|
|
24
|
+
headers: { "content-type": "application/json" },
|
|
25
|
+
body: JSON.stringify({ sessionId: ctx.session.id, ...fields }),
|
|
26
|
+
});
|
|
27
|
+
if (!res.ok)
|
|
28
|
+
throw new Error(`update_thread failed: ${res.status} ${await res.text()}`);
|
|
29
|
+
return await res.json();
|
|
30
|
+
},
|
|
31
|
+
});
|
package/tools/rename_thread.js
DELETED
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
// The `rename_thread` tool, shared by every bot: retitle a thread you're in as
|
|
2
|
-
// its scope changes, and tag it (path, project, domain). The registry keeps the
|
|
3
|
-
// rename history and posts an inline "<bot> renamed this thread: old → new"
|
|
4
|
-
// marker; tags go on thread.tags (lib/tags.mjs).
|
|
5
|
-
import { defineTool } from "eve/tools";
|
|
6
|
-
import { z } from "zod";
|
|
7
|
-
import { registryFetch } from "../registry-fetch.mjs";
|
|
8
|
-
export default defineTool({
|
|
9
|
-
description: "Rename and/or tag a workspace thread you're in. Rename when the work's scope has changed and the old title no longer fits: everyone sees a short 'renamed this thread: old → new' line; the history is kept. Keep titles short (at most 80 characters). File it under a path (project/track, e.g. 'hub/native/composer': the first segment is the project, the rest the feature inside it) and tag a domain (the kind of work, e.g. 'ui', 'infra'); reuse a tag already in use when it fits; for a new one, also give its display name (pathLabel / domainLabel, e.g. 'MCP Channel'). The reply says what each tag was filed under and, for a tag no other thread uses, lists the ones in use so you can switch. Tags a human set by hand stay. DMs and archived threads can't be renamed.",
|
|
10
|
-
inputSchema: z.object({
|
|
11
|
-
threadId: z.string().describe("Thread id, e.g. thr_0002 (shown in the [thread: …] header)."),
|
|
12
|
-
title: z.string().optional().describe("New short title, e.g. 'DeepSWE hill-climb: retry policy'. Omit to only retag."),
|
|
13
|
-
project: z.string().optional().describe("Project tag: the codebase or effort, e.g. 'native', 'hub', 'eve'. Lowercase slug."),
|
|
14
|
-
domain: z.string().optional().describe("Domain tag: the kind of work, e.g. 'ui', 'infra', 'agents', 'review', 'planning', 'research'. Lowercase slug."),
|
|
15
|
-
projectLabel: z.string().optional().describe("Display name for a new project tag, shown as its sidebar group, e.g. 'MCP Channel' for mcpchannel. Only names a tag that has none yet."),
|
|
16
|
-
domainLabel: z.string().optional().describe("Display name for a new domain tag, e.g. 'UI' for ui. Only names a tag that has none yet."),
|
|
17
|
-
path: z.string().nullable().optional().describe("Path: project, then the track inside it, e.g. 'hub/native/composer' or 'eve/code-mode'. Reuse a path in use (list_threads shows each thread's); the first segment is the project. null: back to the project alone. A path a human set by hand stays."),
|
|
18
|
-
pathLabel: z.string().optional().describe("Display name for a new path's last segment, e.g. 'Composer'. Only names a path that has none yet."),
|
|
19
|
-
}),
|
|
20
|
-
async execute({ threadId, title, project, domain, projectLabel, domainLabel, path, pathLabel }, ctx) {
|
|
21
|
-
const res = await registryFetch(`/api/threads/${encodeURIComponent(threadId)}/rename`, {
|
|
22
|
-
method: "POST",
|
|
23
|
-
headers: { "content-type": "application/json" },
|
|
24
|
-
body: JSON.stringify({ sessionId: ctx.session.id, title, project, domain, projectLabel, domainLabel, path, pathLabel }),
|
|
25
|
-
});
|
|
26
|
-
if (!res.ok)
|
|
27
|
-
throw new Error(`rename_thread failed: ${res.status} ${await res.text()}`);
|
|
28
|
-
return await res.json();
|
|
29
|
-
},
|
|
30
|
-
});
|