eve-hub 0.15.1 → 0.16.0
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/bash-run.mjs +6 -0
- package/connections/vercel-kit.d.ts +1 -1
- package/connections/vercel-kit.js +14 -17
- package/execution-context.mjs +1 -1
- package/host-sandbox.js +1 -1
- package/memory/bot.d.ts +1 -4
- package/memory/bot.js +3 -3
- package/memory/team.d.ts +1 -4
- package/memory/team.js +5 -5
- package/memory/workspace.d.ts +1 -4
- package/memory/workspace.js +6 -7
- package/memory.mjs +16 -61
- package/onboarding/SKILL.md +1 -1
- package/package.json +2 -2
- package/prepare.mjs +4 -4
- package/registry-fetch.mjs +5 -0
- package/roster-model.mjs +6 -20
- package/schedules/dash-classify.mjs +3 -3
- package/schedules/dash.js +2 -5
- package/schedules/lane-facts.mjs +6 -4
- package/schedules/wake.d.ts +2 -2
- package/schedules/wake.js +4 -5
- package/skills/fanout.gen.mjs +1 -1
- package/skills/forms.gen.mjs +1 -1
- package/skills/local-onboarding.gen.mjs +1 -1
- package/skills/local-onboarding.js +1 -1
- package/skills/onboarding-profile.gen.mjs +1 -1
- package/team.d.mts +1 -1
- package/team.mjs +4 -8
- package/templates/execution.md +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# eve-hub
|
|
2
2
|
|
|
3
|
+
## 0.16.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- Move the kit to eve 0.88.0 (v27 session events). The host sandbox takes eve's `signal` for aborts, bot models are `defineDynamic`, session limits use `sessionUsage`, and memory recall gives each entry one message under its own id on `turn.started`. Needs eve 0.88.0 or later.
|
|
8
|
+
|
|
9
|
+
## 0.15.2
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- A wake-up posts at every firing. The hub no longer skips one because the bot had a turn in the thread since setting it, so a turn that only reacted can't cancel a promised check. Only deleting it, or `until` passing, stops it. The tool description and the shared instructions say so.
|
|
14
|
+
|
|
3
15
|
## 0.15.1
|
|
4
16
|
|
|
5
17
|
### Patch Changes
|
package/bash-run.mjs
CHANGED
|
@@ -47,6 +47,7 @@ const asList = (code) => (code == null ? [] : Array.isArray(code) ? code : [code
|
|
|
47
47
|
// that thread's own worktree, not hostRoot. The description is fixed when the
|
|
48
48
|
// tool is defined, so it can't name the path; it says how to find it and keeps
|
|
49
49
|
// the bot out of the shared checkouts.
|
|
50
|
+
/** @param {{ hostRoot?: string, code?: any, sessionWorktrees?: boolean }} [options] */
|
|
50
51
|
export function bashDescription({ hostRoot, code = workspaceCode(), sessionWorktrees = process.env.HUB_SESSION_WORKTREES === "1" } = {}) {
|
|
51
52
|
const dirs = asList(code);
|
|
52
53
|
if (sessionWorktrees) {
|
|
@@ -238,6 +239,11 @@ export function selfStopRefusal(command, env = process.env) {
|
|
|
238
239
|
return verb ? `[${selfStopMessage(verb, env.HUB_AGENT)}]` : null;
|
|
239
240
|
}
|
|
240
241
|
|
|
242
|
+
/**
|
|
243
|
+
* @param {any} sandbox
|
|
244
|
+
* @param {{ command: string, timeoutSeconds?: number }} input
|
|
245
|
+
* @param {{ hostRoot?: string, code?: any, graceMs?: number, env?: NodeJS.ProcessEnv }} [options]
|
|
246
|
+
*/
|
|
241
247
|
export async function runBash(sandbox, { command, timeoutSeconds }, { hostRoot, code = workspaceCode(), graceMs = KILL_GRACE_MS, env = process.env } = {}) {
|
|
242
248
|
const selfStop = selfStopRefusal(command, env);
|
|
243
249
|
if (selfStop) return { exitCode: 1, stdout: "", stderr: selfStop, truncated: false };
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
declare const _default: import("eve").DynamicSentinel<import("eve/connections").DynamicConnectionResult>;
|
|
1
|
+
declare const _default: import("eve").DynamicSentinel<import("eve/connections").DynamicConnectionResult, import("eve").DynamicContext>;
|
|
2
2
|
export default _default;
|
|
@@ -32,21 +32,18 @@ async function refreshProjectToken() {
|
|
|
32
32
|
}
|
|
33
33
|
}
|
|
34
34
|
// One connector per Hub app; Connect resolves credentials for the active user.
|
|
35
|
-
//
|
|
36
|
-
// update the instance identity used when resuming
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
});
|
|
50
|
-
},
|
|
51
|
-
},
|
|
35
|
+
// eve resolves this before each model call, so unconfigured apps run without
|
|
36
|
+
// Kit and connector changes update the instance identity used when resuming
|
|
37
|
+
// an approval.
|
|
38
|
+
export default defineDynamic(async () => {
|
|
39
|
+
const connector = process.env.KIT_CONNECTOR?.trim();
|
|
40
|
+
if (!connector)
|
|
41
|
+
return null;
|
|
42
|
+
await refreshProjectToken();
|
|
43
|
+
return defineMcpClientConnection({
|
|
44
|
+
url: "https://kit.vercel.sh/api/mcp",
|
|
45
|
+
instanceKey: connector,
|
|
46
|
+
description: "The requesting user's connected services and saved skills through Vercel Kit. Search Kit's tools, then execute within the user's approved permissions.",
|
|
47
|
+
auth: connect(connector),
|
|
48
|
+
});
|
|
52
49
|
});
|
package/execution-context.mjs
CHANGED
|
@@ -59,7 +59,7 @@ export function hostedContext({ bot, kit, removed, deployment: d }, options) {
|
|
|
59
59
|
: "- **Changing it:** there is no repository to commit to. The hub replaces these files on its next deploy, so an edit made only in this deployment does not last.",
|
|
60
60
|
d.githubToken
|
|
61
61
|
? "- **GitHub:** `git` and `gh` in your shell are already signed in and set to sign commits as the owner; do not set your own author or identity, or GitHub rejects the push."
|
|
62
|
-
: "- **GitHub:** your shell has no GitHub credential: `git` cannot authenticate, so a private repository won't clone and nothing can be pushed. When a `vercel-kit` connection is listed, reach GitHub through it (
|
|
62
|
+
: "- **GitHub:** your shell has no GitHub credential: `git` cannot authenticate, so a private repository won't clone and nothing can be pushed. When a `vercel-kit` connection is listed, reach GitHub through it (eve__search, then eve__tool); otherwise tell the human this deployment has no GitHub access.",
|
|
63
63
|
"- **Memory:** the standing decisions you recall are the `memory/` and `agents/<bot>/memory/` files, built into each deployment. An entry takes effect when the deployment that contains it is live, not on your next turn; with no repository it cannot last.",
|
|
64
64
|
"- **Limits:** there is no `hub` CLI, you cannot restart yourself or any bot, and there are no host paths: your shell is a Vercel Sandbox with its own filesystem, not the owner's machine.",
|
|
65
65
|
`- **Kit tools this build removed:** ${removed.length ? list(removed) : "none"}. They need the hub's machine, or change the roster, which a deployment does by commit.`,
|
package/host-sandbox.js
CHANGED
|
@@ -657,7 +657,7 @@ export function hostSession(baseRoot, children, sessionId, { worktree, rootSessi
|
|
|
657
657
|
return join(homedir(), path.slice(home.length));
|
|
658
658
|
return path;
|
|
659
659
|
};
|
|
660
|
-
const spawn = async ({ command, workingDirectory, env, abortSignal }) => {
|
|
660
|
+
const spawn = async ({ command, workingDirectory, env, signal: abortSignal }) => {
|
|
661
661
|
await ready();
|
|
662
662
|
const cwd = hostPath(workingDirectory);
|
|
663
663
|
// Paused first: nothing of the user's runs until the ticket is granted.
|
package/memory/bot.d.ts
CHANGED
|
@@ -1,6 +1,4 @@
|
|
|
1
|
-
declare const recall: (
|
|
2
|
-
messages: readonly unknown[];
|
|
3
|
-
}) => {
|
|
1
|
+
declare const recall: () => {
|
|
4
2
|
messages: any;
|
|
5
3
|
};
|
|
6
4
|
declare const _default: import("eve/memory").DefinedMemory<{
|
|
@@ -10,7 +8,6 @@ declare const _default: import("eve/memory").DefinedMemory<{
|
|
|
10
8
|
readonly provider: {
|
|
11
9
|
readonly recall: {
|
|
12
10
|
"turn.started": typeof recall;
|
|
13
|
-
"compaction.completed": typeof recall;
|
|
14
11
|
};
|
|
15
12
|
};
|
|
16
13
|
}>;
|
package/memory/bot.js
CHANGED
|
@@ -8,9 +8,9 @@ import { findWorkspaceRoot } from "../workspace.mjs";
|
|
|
8
8
|
// renamed bot keeps its memory. Scope is that directory's name; with no bot
|
|
9
9
|
// directory the slot is off.
|
|
10
10
|
const botDir = () => findBotDir();
|
|
11
|
-
const recall = (
|
|
11
|
+
const recall = () => {
|
|
12
12
|
const dir = botDir();
|
|
13
|
-
return { messages: dir ? loadBotMemoryRecall(findWorkspaceRoot(), dir
|
|
13
|
+
return { messages: dir ? loadBotMemoryRecall(findWorkspaceRoot(), dir) : [] };
|
|
14
14
|
};
|
|
15
15
|
export default defineMemory({
|
|
16
16
|
description: "Standing decisions for this bot only, from its agents/<bot>/memory/ directory.",
|
|
@@ -20,6 +20,6 @@ export default defineMemory({
|
|
|
20
20
|
return dir ? dir.split(/[\\/]/).at(-1) : null;
|
|
21
21
|
},
|
|
22
22
|
provider: defineMemoryProvider({
|
|
23
|
-
recall: { "turn.started": recall
|
|
23
|
+
recall: { "turn.started": recall },
|
|
24
24
|
}),
|
|
25
25
|
});
|
package/memory/team.d.ts
CHANGED
|
@@ -1,6 +1,4 @@
|
|
|
1
|
-
declare const recall: (
|
|
2
|
-
messages: readonly unknown[];
|
|
3
|
-
}) => {
|
|
1
|
+
declare const recall: () => {
|
|
4
2
|
messages: {
|
|
5
3
|
id: string;
|
|
6
4
|
content: string;
|
|
@@ -13,7 +11,6 @@ declare const _default: import("eve/memory").DefinedMemory<{
|
|
|
13
11
|
readonly provider: {
|
|
14
12
|
readonly recall: {
|
|
15
13
|
"turn.started": typeof recall;
|
|
16
|
-
"compaction.completed": typeof recall;
|
|
17
14
|
};
|
|
18
15
|
};
|
|
19
16
|
}>;
|
package/memory/team.js
CHANGED
|
@@ -6,15 +6,15 @@ import { findWorkspaceRoot, readWorkspaceFile } from "../workspace.mjs";
|
|
|
6
6
|
// The team architecture (team.mjs) as an eve memory slot: how work flows between roles, what each
|
|
7
7
|
// role owns, where bots run and this bot's own place, built from the workspace's hub.workspace.json
|
|
8
8
|
// (a hosted bot's is embedded by its Vercel build, prepare.mjs). Recall runs at the start of every
|
|
9
|
-
// turn
|
|
10
|
-
//
|
|
9
|
+
// turn; eve replaces the session's copy only when the text changed. The bot is the agents/<bot>
|
|
10
|
+
// directory this process runs from (findBotDir).
|
|
11
11
|
const warned = new Set();
|
|
12
|
-
const recall = (
|
|
12
|
+
const recall = () => {
|
|
13
13
|
try {
|
|
14
14
|
const dir = findBotDir();
|
|
15
15
|
if (!dir)
|
|
16
16
|
return { messages: [] };
|
|
17
|
-
return teamRecall(
|
|
17
|
+
return teamRecall(architectureText(readWorkspaceFile(findWorkspaceRoot()).data, basename(dir)));
|
|
18
18
|
}
|
|
19
19
|
catch (error) {
|
|
20
20
|
const note = error.message;
|
|
@@ -30,6 +30,6 @@ export default defineMemory({
|
|
|
30
30
|
namespace: "hub-team-memory",
|
|
31
31
|
scope: "workspace",
|
|
32
32
|
provider: defineMemoryProvider({
|
|
33
|
-
recall: { "turn.started": recall
|
|
33
|
+
recall: { "turn.started": recall },
|
|
34
34
|
}),
|
|
35
35
|
});
|
package/memory/workspace.d.ts
CHANGED
|
@@ -1,6 +1,4 @@
|
|
|
1
|
-
declare const recall: (
|
|
2
|
-
messages: readonly unknown[];
|
|
3
|
-
}) => {
|
|
1
|
+
declare const recall: () => {
|
|
4
2
|
messages: any;
|
|
5
3
|
};
|
|
6
4
|
declare const _default: import("eve/memory").DefinedMemory<{
|
|
@@ -10,7 +8,6 @@ declare const _default: import("eve/memory").DefinedMemory<{
|
|
|
10
8
|
readonly provider: {
|
|
11
9
|
readonly recall: {
|
|
12
10
|
"turn.started": typeof recall;
|
|
13
|
-
"compaction.completed": typeof recall;
|
|
14
11
|
};
|
|
15
12
|
};
|
|
16
13
|
}>;
|
package/memory/workspace.js
CHANGED
|
@@ -2,22 +2,21 @@ import { defineMemory, defineMemoryProvider } from "eve/memory";
|
|
|
2
2
|
import { botDomains, findBotDir, loadMemoryRecall } from "../memory.mjs";
|
|
3
3
|
import { findWorkspaceRoot } from "../workspace.mjs";
|
|
4
4
|
// The workspace's memory/ entries as an eve memory slot (see ../memory.mjs).
|
|
5
|
-
// Recall re-reads memory/ at the start of every turn and
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
// system prompt and the cached prefix stay put when an entry changes.
|
|
5
|
+
// Recall re-reads memory/ at the start of every turn and returns every entry
|
|
6
|
+
// under its id; eve keeps recalled messages outside the history and replaces
|
|
7
|
+
// one only when its text changed.
|
|
9
8
|
// One constant scope and namespace. The entries are memory/ (every bot) plus
|
|
10
9
|
// the folders for the domains this bot lists in hub.workspace.json.
|
|
11
|
-
const recall = (
|
|
10
|
+
const recall = () => {
|
|
12
11
|
const root = findWorkspaceRoot();
|
|
13
12
|
const dir = findBotDir();
|
|
14
|
-
return { messages: loadMemoryRecall(root,
|
|
13
|
+
return { messages: loadMemoryRecall(root, { domains: dir ? botDomains(root, dir) : [] }) };
|
|
15
14
|
};
|
|
16
15
|
export default defineMemory({
|
|
17
16
|
description: "The human's standing decisions for this workspace, from its memory/ directory.",
|
|
18
17
|
namespace: "hub-workspace-memory",
|
|
19
18
|
scope: "workspace",
|
|
20
19
|
provider: defineMemoryProvider({
|
|
21
|
-
recall: { "turn.started": recall
|
|
20
|
+
recall: { "turn.started": recall },
|
|
22
21
|
}),
|
|
23
22
|
});
|
package/memory.mjs
CHANGED
|
@@ -27,7 +27,6 @@
|
|
|
27
27
|
// The front matter is a small YAML subset: `key: value` or `key: [a, b]`,
|
|
28
28
|
// with `# comments`. An evidence item is `thr_…/msg_…`, or `msg_…` for
|
|
29
29
|
// another message in the thread cited just before it.
|
|
30
|
-
import { createHash } from "node:crypto";
|
|
31
30
|
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
32
31
|
import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
|
33
32
|
import { isWorkspace, readWorkspaceFile } from "./workspace.mjs";
|
|
@@ -155,31 +154,18 @@ export function activeEntries(entries) {
|
|
|
155
154
|
return entries.filter((e) => e.status === "active");
|
|
156
155
|
}
|
|
157
156
|
|
|
158
|
-
// What a bot's memory slot (memory/workspace.ts) recalls
|
|
159
|
-
//
|
|
160
|
-
//
|
|
161
|
-
// a
|
|
162
|
-
//
|
|
163
|
-
//
|
|
164
|
-
//
|
|
165
|
-
//
|
|
166
|
-
//
|
|
167
|
-
//
|
|
168
|
-
// hide the earlier message in place, rewriting the prefix. The id is a pure
|
|
169
|
-
// function of the history and the files, so a replayed recall gives the same
|
|
170
|
-
// result.
|
|
171
|
-
//
|
|
172
|
-
// eve can't retract a recalled message, so a superseded or deleted entry this
|
|
173
|
-
// session has seen gets a retirement note; one it never saw gets nothing. A
|
|
174
|
-
// file that can't be read isn't treated as deleted, so a bad edit never
|
|
175
|
-
// retires a rule (it's reported, and the workspace's tests reject it).
|
|
176
|
-
const hash = (text) => createHash("sha256").update(text).digest("hex").slice(0, 8);
|
|
177
|
-
// Each layer's messages start with its own prefix, so a slot reading the
|
|
178
|
-
// shared history only counts its own: the workspace's (memory/) and the
|
|
179
|
-
// bot's own (agents/<bot>/memory/).
|
|
157
|
+
// What a bot's memory slot (memory/workspace.ts) recalls: every entry, each
|
|
158
|
+
// as one message whose id is the entry's id. eve keeps recalled messages
|
|
159
|
+
// outside the history and replaces an earlier recall with the same id, so an
|
|
160
|
+
// unchanged entry is a no-op and an edited one replaces what the session saw.
|
|
161
|
+
// A superseded or retired entry is recalled as a retirement note under its
|
|
162
|
+
// id, which replaces its old rule. An entry whose file is deleted is simply
|
|
163
|
+
// not recalled: sessions that saw it keep its last text, so retire an entry
|
|
164
|
+
// rather than deleting it.
|
|
165
|
+
// Each layer's messages start with its own prefix: the workspace's (memory/)
|
|
166
|
+
// and the bot's own (agents/<bot>/memory/).
|
|
180
167
|
export const SHARED = "Standing decision";
|
|
181
168
|
export const OWN = "Your standing decision";
|
|
182
|
-
const recalledPattern = (prefix) => new RegExp(`^${prefix} ([a-z][a-z0-9-]{1,60})(?::| is retired)`);
|
|
183
169
|
|
|
184
170
|
const retired = (id, by, prefix) => `${prefix} ${id} is retired${by ? `; ${by} replaces it` : ""}. Ignore any earlier text under ${id}.`;
|
|
185
171
|
|
|
@@ -188,37 +174,8 @@ export function entryText(e, prefix = SHARED) {
|
|
|
188
174
|
return e.status === "active" ? `${prefix} ${e.id}: ${e.rule} (${e.evidence.join(", ")})` : retired(e.id, e.supersededBy, prefix);
|
|
189
175
|
}
|
|
190
176
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
// Per entry id, every text this session has recalled for it, oldest first:
|
|
194
|
-
// the `memory.load` messages in the (projected) history.
|
|
195
|
-
export function recalledVersions(history = [], prefix = SHARED) {
|
|
196
|
-
const RECALLED = recalledPattern(prefix);
|
|
197
|
-
const seen = new Map();
|
|
198
|
-
for (const m of history) {
|
|
199
|
-
if (m?.role !== "user" || m.kind !== "memory.load") continue;
|
|
200
|
-
const text = textOf(m);
|
|
201
|
-
const id = RECALLED.exec(text)?.[1];
|
|
202
|
-
if (!id) continue;
|
|
203
|
-
if (!seen.has(id)) seen.set(id, []);
|
|
204
|
-
seen.get(id).push(text);
|
|
205
|
-
}
|
|
206
|
-
return seen;
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
export function memoryRecall(entries, history = [], { unreadable = new Set(), prefix = SHARED } = {}) {
|
|
210
|
-
const seen = recalledVersions(history, prefix);
|
|
211
|
-
const now = new Map();
|
|
212
|
-
for (const e of entries) if (e.status === "active" || seen.has(e.id)) now.set(e.id, entryText(e, prefix));
|
|
213
|
-
for (const id of seen.keys()) if (!now.has(id) && !unreadable.has(id)) now.set(id, retired(id, undefined, prefix));
|
|
214
|
-
const messages = [];
|
|
215
|
-
for (const id of [...now.keys()].sort()) {
|
|
216
|
-
const text = now.get(id);
|
|
217
|
-
const versions = seen.get(id) ?? [];
|
|
218
|
-
if (versions.at(-1) === text) continue;
|
|
219
|
-
messages.push({ id: `${id}#${versions.length + 1}@${hash(text)}`, content: text });
|
|
220
|
-
}
|
|
221
|
-
return messages;
|
|
177
|
+
export function memoryRecall(entries, { prefix = SHARED } = {}) {
|
|
178
|
+
return entries.map((e) => ({ id: e.id, content: entryText(e, prefix) }));
|
|
222
179
|
}
|
|
223
180
|
|
|
224
181
|
// The recall for the workspace at `root`. A file that isn't a valid entry is
|
|
@@ -232,13 +189,11 @@ const warnOnce = (msg) => {
|
|
|
232
189
|
console.warn(`memory: ${msg}`);
|
|
233
190
|
};
|
|
234
191
|
|
|
235
|
-
const unreadableIds = (errors) => new Set(errors.map((e) => /(?:^|\/)memory\/(?:[^/:]+\/)*([^/:]+)\.md:/.exec(e)?.[1]).filter(Boolean));
|
|
236
|
-
|
|
237
192
|
// `domains`: the bot's domains (hub.workspace.json), see the header.
|
|
238
|
-
export function loadMemoryRecall(root,
|
|
193
|
+
export function loadMemoryRecall(root, { warn = warnOnce, domains = /** @type {string[]} */ ([]) } = {}) {
|
|
239
194
|
const { entries, errors } = readMemory(root, { folders: domainFolders(domains) });
|
|
240
195
|
for (const error of errors) warn(error);
|
|
241
|
-
return memoryRecall(entries
|
|
196
|
+
return memoryRecall(entries);
|
|
242
197
|
}
|
|
243
198
|
|
|
244
199
|
// A bot's own entries, agents/<bot>/memory/<id>.md: same schema. An id can't
|
|
@@ -253,10 +208,10 @@ export function readBotMemory(root, botDir) {
|
|
|
253
208
|
return { entries: entries.filter((e) => !shared.has(e.id)), errors };
|
|
254
209
|
}
|
|
255
210
|
|
|
256
|
-
export function loadBotMemoryRecall(root, botDir,
|
|
211
|
+
export function loadBotMemoryRecall(root, botDir, { warn = warnOnce } = {}) {
|
|
257
212
|
const { entries, errors } = readBotMemory(root, botDir);
|
|
258
213
|
for (const error of errors) warn(error);
|
|
259
|
-
return memoryRecall(entries,
|
|
214
|
+
return memoryRecall(entries, { prefix: OWN });
|
|
260
215
|
}
|
|
261
216
|
|
|
262
217
|
// The bot this process runs: the agents/<bot> directory of a workspace that
|
package/onboarding/SKILL.md
CHANGED
|
@@ -16,7 +16,7 @@ With no bot that runs `local`, skip this step and say nothing about a Mac.
|
|
|
16
16
|
|
|
17
17
|
## 1. Ask for Kit access, once
|
|
18
18
|
|
|
19
|
-
Kit's tools are `request_access`, `request_status` and `execute`. Hosted, you reach them through the `vercel-kit` connection: `
|
|
19
|
+
Kit's tools are `request_access`, `request_status` and `execute`. Hosted, you reach them through the `vercel-kit` connection: `eve__tool({ name: "vercel-kit__request_access", input: { ... } })`, and the same shape for the others. Everywhere below, "call `request_access`" means that.
|
|
20
20
|
|
|
21
21
|
Slack goes through the user's Kit connection. Before any fetch, ask for everything the run reads in one request:
|
|
22
22
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "eve-hub",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.0",
|
|
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",
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
"peerDependencies": {
|
|
36
36
|
"@vercel/connect": "^2.4.1",
|
|
37
37
|
"@vercel/oidc": "4.0.0",
|
|
38
|
-
"eve": "
|
|
38
|
+
"eve": ">=0.88.0",
|
|
39
39
|
"zod": "4.5.4"
|
|
40
40
|
},
|
|
41
41
|
"dependencies": {
|
package/prepare.mjs
CHANGED
|
@@ -90,8 +90,8 @@ function removeStub(bot, file) {
|
|
|
90
90
|
const memoryProvider = (slot, entries, prefix, scope) => `import { defineMemory, defineMemoryProvider } from "eve/memory";
|
|
91
91
|
import { memoryRecall } from "eve-hub/memory";
|
|
92
92
|
const entries = ${JSON.stringify(entries)};
|
|
93
|
-
const recall = (
|
|
94
|
-
export default defineMemory({ namespace: ${JSON.stringify(`hub-${slot}-memory`)}, scope: ${JSON.stringify(scope)}, provider: defineMemoryProvider({ recall: { "turn.started": recall
|
|
93
|
+
const recall = () => ({ messages: memoryRecall(entries, { prefix: ${JSON.stringify(prefix)} }) });
|
|
94
|
+
export default defineMemory({ namespace: ${JSON.stringify(`hub-${slot}-memory`)}, scope: ${JSON.stringify(scope)}, provider: defineMemoryProvider({ recall: { "turn.started": recall } }) });
|
|
95
95
|
`;
|
|
96
96
|
|
|
97
97
|
// The team slot of a hosted bot: its architecture text, built from the whole roster (Mac bots
|
|
@@ -99,8 +99,8 @@ export default defineMemory({ namespace: ${JSON.stringify(`hub-${slot}-memory`)}
|
|
|
99
99
|
const teamProvider = (text) => `import { defineMemory, defineMemoryProvider } from "eve/memory";
|
|
100
100
|
import { teamRecall } from "eve-hub/team";
|
|
101
101
|
const text = ${JSON.stringify(text)};
|
|
102
|
-
const recall = (
|
|
103
|
-
export default defineMemory({ namespace: "hub-team-memory", scope: "workspace", provider: defineMemoryProvider({ recall: { "turn.started": recall
|
|
102
|
+
const recall = () => teamRecall(text);
|
|
103
|
+
export default defineMemory({ namespace: "hub-team-memory", scope: "workspace", provider: defineMemoryProvider({ recall: { "turn.started": recall } }) });
|
|
104
104
|
`;
|
|
105
105
|
|
|
106
106
|
// Replaces the kit's execution-context stub (the local page) with the hosted
|
package/registry-fetch.mjs
CHANGED
|
@@ -74,6 +74,11 @@ export async function hubHeaders(init, env = process.env) {
|
|
|
74
74
|
return registryHeaders(merged, env);
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
/**
|
|
78
|
+
* @param {string} path
|
|
79
|
+
* @param {RequestInit} [init]
|
|
80
|
+
* @param {{ base?: string, deadlineMs?: number, attemptTimeoutMs?: number, fetchImpl?: typeof fetch }} [options]
|
|
81
|
+
*/
|
|
77
82
|
export async function registryFetch(path, init = {}, { base = registryBase(), deadlineMs = num(process.env.ROSTER_REGISTRY_RETRY_MS, 30_000), attemptTimeoutMs, fetchImpl = fetch } = {}) {
|
|
78
83
|
init = { ...init, headers: await hubHeaders(init.headers) };
|
|
79
84
|
const input = `${base}${path}`;
|
package/roster-model.mjs
CHANGED
|
@@ -30,26 +30,12 @@ export function createRosterModel({ agents, subagentCap }) {
|
|
|
30
30
|
return function rosterModel(name, { mock, copyMock } = {}) {
|
|
31
31
|
// Resolve once up front so a bad config fails at compile, not mid-turn.
|
|
32
32
|
selectModel(agents, subagentCap, name, null);
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
// mock model is a live object, so mock mode has none.
|
|
40
|
-
const own = () => {
|
|
41
|
-
const { copy: _copy, ...selection } = selectModel(agents, subagentCap, name, null);
|
|
42
|
-
return selection;
|
|
43
|
-
};
|
|
44
|
-
return defineDynamic({
|
|
45
|
-
events: {
|
|
46
|
-
...(real ? { "session.started": own, "turn.started": own } : {}),
|
|
47
|
-
"step.started": (_event, ctx) => {
|
|
48
|
-
const { copy, ...selection } = selectModel(agents, subagentCap, name, ctx);
|
|
49
|
-
if (real) return selection;
|
|
50
|
-
return { model: copy && copyMock ? copyMock : mock, modelContextWindowTokens: CONTEXT_WINDOW };
|
|
51
|
-
},
|
|
52
|
-
},
|
|
33
|
+
// eve evaluates this before every model call, compaction between turns
|
|
34
|
+
// included, so `agent` copies (subagent channel) are clamped each time.
|
|
35
|
+
return defineDynamic((ctx) => {
|
|
36
|
+
const { copy, ...selection } = selectModel(agents, subagentCap, name, ctx);
|
|
37
|
+
if (real) return selection;
|
|
38
|
+
return { model: copy && copyMock ? copyMock : mock, modelContextWindowTokens: CONTEXT_WINDOW };
|
|
53
39
|
});
|
|
54
40
|
};
|
|
55
41
|
}
|
|
@@ -125,8 +125,8 @@ export function classifyTargets(snap, { max = CLASSIFY_MAX, now = Date.now() } =
|
|
|
125
125
|
|
|
126
126
|
// --- the model -------------------------------------------------------------
|
|
127
127
|
// openai/gpt-6-luna-decisions on the AI Gateway, through an AI SDK
|
|
128
|
-
// `evaluate` (
|
|
129
|
-
//
|
|
128
|
+
// `evaluate` (eve/ai's `decide`): the schedule passes eve's; tests pass a
|
|
129
|
+
// mock.
|
|
130
130
|
export const DECISION_MODEL = "openai/gpt-6-luna-decisions";
|
|
131
131
|
|
|
132
132
|
// What the thread is doing for the human, as one pick. Each state is one lane
|
|
@@ -184,7 +184,7 @@ export async function decideThread(t, snap, { evaluate, model = DECISION_MODEL,
|
|
|
184
184
|
const uq = t.paths ? pathQuestion(t.paths) : null;
|
|
185
185
|
const asks = askCandidates(snap?.threads?.[t.id], t.brief ?? threadBrief(t, snap));
|
|
186
186
|
const questions = { ...QUESTIONS, ...(asks.length > 1 ? { ask: askQuestion(asks) } : {}), ...(uq ? { path: uq } : {}) };
|
|
187
|
-
const r = await evaluate({ model, state: threadState(t, snap), questions, maxRetries: 1,
|
|
187
|
+
const r = await evaluate({ model, state: threadState(t, snap), questions, maxRetries: 1, signal: AbortSignal.timeout(timeoutMs) });
|
|
188
188
|
const out = {};
|
|
189
189
|
for (const [name, q] of Object.entries(questions)) {
|
|
190
190
|
const a = r?.answers?.[name];
|
package/schedules/dash.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// (anthropic/claude-sonnet-5.5 through the AI SDK). The pass: runPass in
|
|
7
7
|
// ./dash-pass.mjs.
|
|
8
8
|
import { defineSchedule } from "eve/schedules";
|
|
9
|
-
import
|
|
9
|
+
import { decide } from "eve/ai";
|
|
10
10
|
import { generateText, Output } from "ai";
|
|
11
11
|
import { createHub } from "./hub.mjs";
|
|
12
12
|
import { modelClassify } from "./dash-classify.mjs";
|
|
@@ -15,14 +15,11 @@ import { PLAN_MODEL, PLAN_TIMEOUT_MS } from "./dash-plan.mjs";
|
|
|
15
15
|
import { PlanOut } from "./dash-plan-schema.mjs";
|
|
16
16
|
import { standingDecisions } from "./dash-decisions.mjs";
|
|
17
17
|
import { modelSummarize } from "./track-summaries.mjs";
|
|
18
|
-
// eve 0.80 renamed `evaluate` to `decide`. The Mac bots run 0.81; the hosted bots' eve build is a
|
|
19
|
-
// 0.69-based branch with `evaluate` only, and a named import of the missing one fails that build.
|
|
20
|
-
const evaluate = eveAi.decide ?? eveAi.evaluate;
|
|
21
18
|
export default defineSchedule({
|
|
22
19
|
cron: "*/8 * * * *",
|
|
23
20
|
async run() {
|
|
24
21
|
const hub = createHub();
|
|
25
22
|
const plan = async (prompt) => (await generateText({ model: PLAN_MODEL, prompt, output: Output.object({ schema: PlanOut }), maxRetries: 1, abortSignal: AbortSignal.timeout(PLAN_TIMEOUT_MS) })).output;
|
|
26
|
-
await hub.track("dash", () => dashPass(hub, { classify: modelClassify({ evaluate }), summarize: modelSummarize({ generateText }), plan, decisions: standingDecisions() }));
|
|
23
|
+
await hub.track("dash", () => dashPass(hub, { classify: modelClassify({ evaluate: decide }), summarize: modelSummarize({ generateText }), plan, decisions: standingDecisions() }));
|
|
27
24
|
},
|
|
28
25
|
});
|
package/schedules/lane-facts.mjs
CHANGED
|
@@ -12,6 +12,8 @@ import { said, flat, clip } from "./thread-brief.mjs";
|
|
|
12
12
|
const MIN = 60_000;
|
|
13
13
|
const minutes = (from, now) => Math.max(0, Math.floor((now - from) / MIN));
|
|
14
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";
|
|
15
17
|
const isHuman = (m, human) => m.author === human || m.authorKind === "human";
|
|
16
18
|
|
|
17
19
|
// A busy thread's quiet time is read in steps of one pass (the schedule's
|
|
@@ -49,15 +51,15 @@ export function laneFacts(t, snap, now = Date.now()) {
|
|
|
49
51
|
function turnFacts(agent, turn, now) {
|
|
50
52
|
const tools = turn?.tools ?? [];
|
|
51
53
|
const swarm = Object.values(turn?.swarm ?? {});
|
|
52
|
-
const running = [...tools].reverse().find((x) => x.status === "running" && x
|
|
53
|
-
const wait = tools.find((x) => x
|
|
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");
|
|
54
56
|
const counts = new Map();
|
|
55
|
-
for (const x of tools) if (x
|
|
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);
|
|
56
58
|
return {
|
|
57
59
|
agent,
|
|
58
60
|
turnMin: turn?.startedAt ? minutes(turn.startedAt, now) : null,
|
|
59
61
|
quietMin: turn?.startedAt ? minutes(Math.max(turn.startedAt, ...tools.map((x) => x.endedAt ?? x.startedAt ?? 0)), now) : null,
|
|
60
|
-
toolCalls: tools.filter((x) => x
|
|
62
|
+
toolCalls: tools.filter((x) => !isWait(x)).length,
|
|
61
63
|
current: running ? { call: clip(`${running.name === "bash" ? "$" : running.name} ${flat(running.input)}`.trim(), 90), min: running.startedAt ? minutes(running.startedAt, now) : null } : null,
|
|
62
64
|
waitingMin: wait?.startedAt ? minutes(wait.startedAt, now) : wait ? 0 : null,
|
|
63
65
|
subagentsOut: swarm.filter((d) => d.status === "working").length,
|
package/schedules/wake.d.ts
CHANGED
|
@@ -31,13 +31,13 @@ export declare function wakeAuth(principal: Principal): {
|
|
|
31
31
|
issuer?: string | undefined;
|
|
32
32
|
subject?: string | undefined;
|
|
33
33
|
} | null;
|
|
34
|
-
/** The one call a firing makes: the hub
|
|
34
|
+
/** The one call a firing makes: the hub posts it, keyed on the occurrence so a retry posts once.
|
|
35
35
|
* A definitive refusal (a 4xx: the session is unknown, the thread archived) is returned, not thrown: retrying it can't change it. */
|
|
36
36
|
export declare function fireWake(payload: ArmedWake, occurrence: Occurrence, { fetchImpl, base }?: {
|
|
37
37
|
fetchImpl?: typeof fetch;
|
|
38
38
|
base?: string;
|
|
39
39
|
}): Promise<{
|
|
40
|
-
status: "posted"
|
|
40
|
+
status: "posted";
|
|
41
41
|
} | {
|
|
42
42
|
status: "refused";
|
|
43
43
|
error: string;
|
package/schedules/wake.js
CHANGED
|
@@ -10,9 +10,8 @@
|
|
|
10
10
|
// before approval and again before writing, and refuses the write if the two differ.
|
|
11
11
|
// When it was armed is the hub's: it records the schedule record the tool call returns.
|
|
12
12
|
// When it fires, run() tells the hub (POST /api/wakes, hosted-hub/src/api/wakes.ts)
|
|
13
|
-
// as the bot's deployment; the hub
|
|
14
|
-
//
|
|
15
|
-
// behind any running turn. An occurrence scheduled past `until` deletes the schedule
|
|
13
|
+
// as the bot's deployment; the hub posts a system @-mention for every occurrence, which
|
|
14
|
+
// queues behind any running turn. An occurrence scheduled past `until` deletes the schedule
|
|
16
15
|
// instead. run() never starts a session itself: a session eve starts here is one the
|
|
17
16
|
// hub cannot see.
|
|
18
17
|
import { byPrincipal, defineDynamicSchedules, schedules } from "eve/experimental/schedules";
|
|
@@ -28,7 +27,7 @@ export function wakeAuth(principal) {
|
|
|
28
27
|
return null;
|
|
29
28
|
return { principalType: principal.type, authenticator: principal.authenticator, principalId: principal.principalId, attributes: {}, ...(principal.issuer === undefined ? {} : { issuer: principal.issuer }), ...(principal.subject === undefined ? {} : { subject: principal.subject }) };
|
|
30
29
|
}
|
|
31
|
-
/** The one call a firing makes: the hub
|
|
30
|
+
/** The one call a firing makes: the hub posts it, keyed on the occurrence so a retry posts once.
|
|
32
31
|
* A definitive refusal (a 4xx: the session is unknown, the thread archived) is returned, not thrown: retrying it can't change it. */
|
|
33
32
|
export async function fireWake(payload, occurrence, { fetchImpl = fetch, base } = {}) {
|
|
34
33
|
const res = await registryFetch("/api/wakes", {
|
|
@@ -52,7 +51,7 @@ export async function expireOrFire(payload, occurrence, { remove, fire }) {
|
|
|
52
51
|
return fire(payload, occurrence);
|
|
53
52
|
}
|
|
54
53
|
const definition = defineDynamicSchedules({
|
|
55
|
-
description: "A wake-up for this thread: arm it when you end a turn with your own work unfinished and nothing sure to wake you. One-shot: a delay set to the expected wait. Recurring: a cron expression. Always set `until`, the last time it may fire.
|
|
54
|
+
description: "A wake-up for this thread: arm it when you end a turn with your own work unfinished and nothing sure to wake you. One-shot: a delay set to the expected wait. Recurring: a cron expression. Always set `until`, the last time it may fire. Every firing posts into this thread until you delete it or `until` passes.",
|
|
56
55
|
inputSchema: WakeInput,
|
|
57
56
|
scope: byPrincipal,
|
|
58
57
|
tool: true,
|
package/skills/fanout.gen.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Generated by scripts/build-onboarding.mjs from fanout/SKILL.md. Do not edit.
|
|
2
|
-
export default {"description":"How to hand work to your reader, editor and verifier subagents. Load before you send any task to one, so each gets an unambiguous, non-overlapping, checkable task and you can check what comes back.","
|
|
2
|
+
export default {"description":"How to hand work to your reader, editor and verifier subagents. Load before you send any task to one, so each gets an unambiguous, non-overlapping, checkable task and you can check what comes back.","content":"# Fanning out work\n\nYour `reader`, `editor` and `verifier` run cheaper models than you and never see your conversation. Each one gets only the task you write, so the task has to stand on its own.\n\n## 1. The contract\n\nEvery task you send has all of these fields. If you can't fill one in, don't send the task: do it yourself or split it further.\n\n- `goal`: one sentence describing the result.\n- `owns`: the files the worker may change. It is empty for a reader and a verifier.\n- `facts`: what is already decided, so the worker doesn't reopen it.\n- `pointers`: where to look, as `file:line` or a URL. Never paste file contents.\n- `check`: the command that proves the task is done.\n- `budget`: a turn count. Allow 2 to 3 for setup, 1 to 2 for reading, 2 to 4 for implementing and 2 to 3 for verifying, 12 at most.\n- `stop`: when to give up and report instead of pushing on.\n\n```\ngoal: Make listThreads skip archived threads unless asked.\nowns: /workspace/wt-a/src/threads.ts, /workspace/wt-a/test/threads.test.ts\nfacts: The flag is `archived: \"include\"`; the default excludes them.\npointers: src/threads.ts:88 (the query), test/threads.test.ts:40\ncheck: cd /workspace/wt-a && node --test test/threads.test.ts\nbudget: 5 turns\nstop: if the query is built anywhere else, report where and stop.\n```\n\n## 2. Disjoint ownership\n\n- No two tasks running at the same time share a file in `owns`.\n- Your workers share your sandbox. Give each editor running in parallel its own worktree (`git worktree add /workspace/wt-a`) and name that directory in its task.\n- You apply their diffs to your branch one at a time.\n- A change that cuts across files several workers own runs serially, or goes to an `agent` copy (rung 2 below).\n\n## 3. What comes back\n\nEach tier replies with exactly this report, inside its turn budget:\n\n- `reader`, 8 turns:\n\n ```\n answer: <1-2 sentences>\n evidence: <file:line | command + output | URL>, one per line\n open: <what it couldn't settle, or \"none\">\n ```\n\n- `editor`, 12 turns:\n\n ```\n diff: <git diff of owns>\n check: <command> -> exit <n>, last 20 lines\n outside: <files that also need a change but aren't in owns, or \"none\">\n notes: <=5 lines\n ```\n\n- `verifier`, 10 turns:\n\n ```\n verdict: holds | fails | couldn't check\n runs: <command> -> exit <n>, the deciding output lines\n seen: <what is on screen, for a UI>\n other: <anything wrong outside the claim>\n ```\n\nReview the diff together with the module around it, not just the changed lines. Rerun every `check` yourself before you take the change.\n\nAfter a reader or verifier returns, run `git status --porcelain` in its worktree. If anything changed, discard its result and count the attempt as a failure on the ladder below: `bash` can still write files, so read-only is a prompt rule, not a guarantee.\n\n## 4. When it fails\n\n1. Retry once on the same tier with a fresh worker. Give it the original task, the failed diff and the output of the check.\n2. Then give the same task to an `agent` copy of yourself.\n3. Then take the task back and split it differently.\n\nNever try a rung a second time. If two workers given the same task return substantively different diffs, the task is under-specified: rewrite it before you send it again.\n\n## 5. The cap\n\nRun at most 3 workers in parallel.\n"};
|
package/skills/forms.gen.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Generated by scripts/build-onboarding.mjs from forms/SKILL.md. Do not edit.
|
|
2
|
-
export default {"description":"How to show structure in a message. Load before you explain anything with parts that connect, events in order, numbers to compare, a before and after, or a choice the human must make; and before you post a table, diagram or chart.","
|
|
2
|
+
export default {"description":"How to show structure in a message. Load before you explain anything with parts that connect, events in order, numbers to compare, a before and after, or a choice the human must make; and before you post a table, diagram or chart.","content":"# Showing structure\n\n## Examples\n\nThe prompt's show-don't-tell rule says when to draw. These are past misses.\n\n<example>\nThe human asks \"how do we detect an existing workspace? is it stored in the hub db?\" Miss: one sentence and numbered steps. Instead: the one-line answer, then a flow of today's sign-in check and, beside it, the flow with a \"create new\" option. The question never says \"diagram\"; it asks for a path through parts.\n</example>\n<example>\nBuild times by step. Miss: a schedule chart, then a paragraph repeating every step's seconds. Instead: the chart's title is the claim (\"Linking is 70% of the build\"), and the prose says only what to do about it.\n</example>\n<example>\nThree candidate looks for a theme. Miss: a prose list of concepts. Instead: a rendered image of each, labeled in a few words.\n</example>\n\n## Picking the shape\n- A path through three or more parts: `flow`.\n- An order of events across two or more actors: `sequence`.\n- Numbers to compare: `chart`. Time split into phases or hops (a latency breakdown, a turn's timeline) is a `schedule` chart, one row per request and one segment per phase, never one `bar` per total. A `bar` compares one number across items.\n- A choice you need from the human: `ask`. Choosing an option sends its label back as their reply, so write labels you can act on.\n- Two or more rows of short, comparable values: a table, at most four columns, a few words per cell. A single item, or anything that needs a reason, is a sentence or bullets.\n- Anchor the picture in something the reader knows: a labeled before form, then an after form, in one message.\n- Every part of one diagram is the same kind of thing (all hosts, all processes or all functions); say which in the sentence before it. A group is the level that contains them.\n- At most one or two forms per message.\n- Never draw with characters: no boxes, arrows, timelines or tables in a code fence.\n\n## How the app shows it\n- Each top-level code fence is a card with a copy button; a table is a table card; a link is a chip showing its label; an attached image is its own tile.\n- A fenced form becomes an interactive figure the human can step through.\n\n## Form syntax\n\n- Claim first: a sentence that says what the form shows comes before it. A chart's `title` is its claim.\n- Link a word in your prose to a part with `[word](#id)`. Ids are lowercase slugs (`a-z`, `0-9`, `-`, up to 32 characters), unique in the whole message.\n- Put the explanation of a part in `note` (up to 240 characters), which the human sees when they point at or step to it. Keep labels short.\n- `hi: true` marks the one part that matters, at most one per form. Unknown keys are rejected.\n- Send a form with `post_message`, never in your end-of-turn reply. The hub checks a posted form and rejects a bad one (400) with what's wrong: fix it and post again. Nothing checks a final reply, so a bad form there shows as raw JSON.\n\nA request leaves the [app](#app), the [hub](#hub) routes it, and a [bot](#bot) answers. `flow`: nodes 2–12 (`role`, `group`, `ref` as file:line), edges 1–24 (`from`, `to`, `label` up to 16, `fail` for a failure path), optional `groups`. Steps follow node order.\n\n```flow\n{\"nodes\": [\n {\"id\": \"app\", \"label\": \"App\", \"role\": \"on your Mac\"},\n {\"id\": \"hub\", \"label\": \"Hub\", \"role\": \"threads, routing\", \"hi\": true, \"ref\": \"server.mjs:5323\", \"note\": \"Stores the message, then wakes the bot.\"},\n {\"id\": \"bot\", \"label\": \"Bot\", \"role\": \"one turn\"}],\n \"edges\": [{\"from\": \"app\", \"to\": \"hub\", \"label\": \"send\"}, {\"from\": \"hub\", \"to\": \"bot\", \"label\": \"wake\"}, {\"from\": \"bot\", \"to\": \"hub\", \"label\": \"post\"}]}\n```\n\nThe stream [drops](#drop) before the client [retries](#retry). `sequence`: actors 2–6, messages 1–16 (`from`, `to`, `label` up to 32, `fail` where it breaks, `ref` as file:line; `from` = `to` for a wait or retry inside one actor). Optional `unit` (up to 48 characters) is printed above the actors, e.g. \"each line = one process\"; optional `groups` (up to 3, `id` and `label`) with `group` on an actor draws a box around actors that share a host; grouped actors must sit next to each other. Steps follow message order.\n\n```sequence\n{\"unit\": \"each line = one process\", \"groups\": [{\"id\": \"mac\", \"label\": \"Mac\"}, {\"id\": \"hub-proj\", \"label\": \"Hub project\"}],\n \"actors\": [{\"id\": \"client\", \"label\": \"Client\", \"group\": \"mac\"}, {\"id\": \"hub\", \"label\": \"Hub\", \"group\": \"hub-proj\"}],\n \"messages\": [\n {\"id\": \"drop\", \"from\": \"hub\", \"to\": \"client\", \"label\": \"stream drops\", \"fail\": true, \"ref\": \"Client.swift:257\"},\n {\"id\": \"retry\", \"from\": \"client\", \"to\": \"hub\", \"label\": \"reconnects\", \"note\": \"Resumes from the last event id.\"}]}\n```\n\nFirst token is under 2 s everywhere but [Sydney](#syd). `chart`: `title` (the claim) and `points` 1–24, each with an id and `label`. `type` is one of four:\n- `bar`: `value`; optional `unit`, `target`.\n- `scatter`: axis titles `x`, `y`; points have numeric `x`, `y`.\n- `budget`: `value` > 0; optional `total`; `spare: true` for unused room.\n- `schedule`: `row` (up to 8 rows), `start` < `end`; optional `unit`.\n\n```chart\n{\"type\": \"bar\", \"title\": \"First token is under 2 s everywhere but Sydney\", \"unit\": \"s\", \"target\": 2,\n \"points\": [{\"id\": \"sfo\", \"label\": \"San Francisco\", \"value\": 0.9}, {\"id\": \"syd\", \"label\": \"Sydney\", \"value\": 2.6, \"hi\": true, \"note\": \"No region near it.\"}]}\n```\n\nYou need a decision, so I'm asking you to pick one. `ask`: 2–5 `options`, each with `id`, `label` (up to 40) and an optional one-line `line` (up to 160). Choosing one sends its label back as the human's reply, so write labels you can act on.\n\n```ask\n{\"options\": [\n {\"id\": \"heron\", \"label\": \"Heron Lodge\", \"line\": \"Lake house 90 minutes north. Rooms for all 14, $5,400.\"},\n {\"id\": \"pines\", \"label\": \"The Pines\", \"line\": \"In town, walkable. Sleeps 10, so 4 need a hotel.\"}]}\n```\n"};
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Generated by scripts/build-onboarding.mjs from local-onboarding/SKILL.md. Do not edit.
|
|
2
|
-
export default {"description":"The Mac half of onboarding, when main hands it to you: find the owner's live coding-agent work (Claude Code, Codex, OpenCode, Cursor) to port into threads, and propose the sandbox setup their hosted bots need. Reads only; the threads and the sandbox PR wait for the owner's yes.","
|
|
2
|
+
export default {"description":"The Mac half of onboarding, when main hands it to you: find the owner's live coding-agent work (Claude Code, Codex, OpenCode, Cursor) to port into threads, and propose the sandbox setup their hosted bots need. Reads only; the threads and the sandbox PR wait for the owner's yes.","content":"# The Mac half of onboarding\n\nMain runs onboarding on Vercel, where it can't see this Mac, so it hands you this half and posts its own without waiting. You post two short messages in that thread, the live work first, then the sandbox setup. Follow your writing rules: each first bubble is at most two sentences, with tables below a `---` line.\n\nTwo scripts gather the facts. They live in `{{scripts}}`, read only, and print metadata: repos, branches, counts, times, the agents' own session titles and program names. Work only from what they print. Don't open the agents' session files yourself: they hold the owner's prompts and code. Delete the JSON files when you are done.\n\n## 1. Live work to port\n\n```sh\nnode {{scripts}}/sessions-facts.mjs > \"$TMPDIR/sessions.json\"\n```\n\nThe script covers the last 14 days of sessions, grouped into work-streams by repo and branch, newest first. Each stream has:\n- `repo`, `branch` and `pr` (the open PR);\n- `checkouts`: one per clone or worktree it was worked in. Each has `worktree` (true for a linked git worktree), `uncommitted` (changed files) and `unpushed` (commits ahead of `aheadOf`);\n- `lastActive`;\n- `sessions`: each with its agent, title, time and `checkout` (its index in `checkouts`).\n\nA stream with no remote is keyed `local-N`. A stream marked `gone` is a folder that no longer exists. `unreadable` lists the sources that exist but couldn't be read. An `uncommitted` or `unpushed` of `null` means the count couldn't be determined (git timed out on a huge untracked tree, or there is no upstream or default branch to compare against): it is unknown, not zero.\n\nPost the streams that have work in flight: any checkout with uncommitted files or unpushed commits, or an open PR. A checkout with an unknown count gets a row too, with \"unknown\" in that cell. A stream with more than one checkout gets one row per checkout that has work. Put the rest in one line with their count.\n\n```md\nYou have 3 work-streams in flight on this Mac. Say which to port, and main will open a thread for each so the team can pick it up.\n\n---\n\n| Stream | Agent and session | Last active | Uncommitted | Unpushed | PR |\n|---|---|---|---|---|---|\n| `vercel/app` on `fix-login` (worktree) | Claude Code, \"Fix login redirect\" | 2h ago | 4 files | 2 | [#812](...) |\n\nAlso 5 streams with nothing in flight.\n```\n\nNever post a local path, a file name, a prompt, or anything else from the sessions.\n\nWhen the owner says which streams to port, you still write nothing on the Mac. Post one message that mentions main and asks it to open one thread per stream. For each stream, give a short title, the repo and branch, its state (worktree, uncommitted and unpushed counts, PR), and which agent had it. You don't open threads yourself.\n\n## 2. Sandbox setup\n\n```sh\nnode {{scripts}}/agent-usage-facts.mjs > \"$TMPDIR/agent-usage.json\"\n```\n\nIt takes about 30 seconds on 16 GB of history. It prints names and counts, never a command's arguments, a prompt or a value:\n- `scanned`: records, commands and unparsable commands per agent;\n- `unreadable`;\n- `programs`, `mcpServers`, `repos`, `languages` (file extensions) and `envNames`.\n\nMapping each fact to a setup is your judgment, not a lookup table. Decide, for every program in the top 30:\n\n- **Hosted or Mac.** These can't run in a Linux sandbox, so they stay on the Mac with you:\n - `brew`, `osascript` and `open`;\n - Xcode, `xcodebuild` and Apple-platform `swift`;\n - anything that drives the owner's browser or apps;\n - `localhost` services and the keychain.\n\n Everything else can be hosted.\n- **Already there, or add it.** A hosted bot's sandbox is eve's Ubuntu image with git only, plus what its `prepare` in `agents/<bot>/agent/sandbox.ts` installs. Read `prepare` in the workspace repo's sandbox files. A program it installs is already provided; any other hosted program needs a `prepare` step. Use `apt-get` where Ubuntu has the package, and the project's official installer otherwise.\n\nMCP servers are reached from hosted bots through Vercel Kit connections: list each one by name for main to connect. Env var names are listed for the owner only. Never print a value (you don't have any). Ignore shell and system names (`PATH`, `HOME`, `CI`, ...). A name with a high `shellAssigned` relative to `count` is probably a shell variable, not part of the environment.\n\nPost the proposal:\n- a first bubble on what the hosted bots would gain;\n- a table of the steps to add, with the programs each installs and how often the owner used them;\n- one line for what stays on the Mac;\n- one line for the MCP servers to connect through Kit.\n\nAsk whether to open the PR.\n\n## 3. The sandbox PR, after the owner's yes\n\nThe steps live in one file, which is the only source; never hand-write them into `sandbox.ts`. Write `$TMPDIR/prepare.json` as `{\"commands\": [{\"command\": \"<shell>\", \"what\": \"<short name for the error>\"}]}`.\n\nMain's handoff names the workspace repo. Clone it into a scratch folder, branch, and add the steps to each hosted bot that runs code. That is the builder, unless the owner said otherwise.\n\n```sh\nnode {{scripts}}/prepare-edit.mjs \"$TMPDIR/prepare.json\" agents/builder/agent/sandbox.ts\n```\n\nCommit, push and open the PR with `gh pr create`. The body lists the programs, why each one is there, and the usage counts behind them. Never name the owner's other repos.\n\nThe PR's Vercel build runs `prepare`, so a step that fails, fails the build. Wait for its result with `gh pr checks --watch --fail-fast`. If it fails, fix the step in `prepare.json`, restore the file from the branch you cut from (`git checkout origin/HEAD -- <file>`), rerun the script and push. Post the PR link and its build result in the thread, then delete the scratch clone.\n"};
|
|
@@ -6,4 +6,4 @@ import skill from "./local-onboarding.gen.mjs";
|
|
|
6
6
|
// (scripts/build-onboarding.mjs). Its scripts run from the installed kit, beside
|
|
7
7
|
// the dependencies agent-usage-facts.mjs imports.
|
|
8
8
|
const scripts = join(dirname(templatesDir()), "local-onboarding");
|
|
9
|
-
export default defineSkill({ ...skill,
|
|
9
|
+
export default defineSkill({ ...skill, content: skill.content.replaceAll("{{scripts}}", scripts) });
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Generated by scripts/build-onboarding.mjs from onboarding/SKILL.md. Do not edit.
|
|
2
|
-
export default {"description":"Right after sign-in: ask for the Kit access you need, then work out who the user is and what to focus on from their recent Slack and GitHub, and draft schedules that keep them on track. Reads only; the one write is a pull request the user approves by merging.","markdown":"# Profile the user from Slack and GitHub\n\nTwo scripts gather facts (GitHub in your sandbox, Slack inside Kit); you write the profile and the schedule drafts from those facts alone. Nothing is posted to Slack and nothing is committed; the only write is the pull request in step 6, which the user approves by merging. The user sees one short message from you at the end (step 7), not one per step. If a step cannot finish, that message says which one and what the user must do; never write the profile as if every source had been read.\n\n## 0. Hand the Mac half to the bot on the Mac\n\nYou run on Vercel and can't see the user's Mac. If `list_agents` shows a bot that runs `local`, post one message in this thread before step 1 that mentions it (the first one, if there are several) and asks it to run its local-onboarding skill. Name the workspace repo for its sandbox PR, or say there is none. Then go on with step 1 without waiting: its half arrives in this thread as its own posts.\n\nWith no bot that runs `local`, skip this step and say nothing about a Mac.\n\n## 1. Ask for Kit access, once\n\nKit's tools are `request_access`, `request_status` and `execute`. Hosted, you reach them through the `vercel-kit` connection: `connection_execute({ connection: \"vercel-kit\", tool: \"request_access\", input: { ... } })`, and the same shape for the others. Everywhere below, \"call `request_access`\" means that.\n\nSlack goes through the user's Kit connection. Before any fetch, ask for everything the run reads in one request:\n\n```\nrequest_access({ service: \"slack\", reads: true, reason: \"Read your recent channel messages, mentions and threads to write your onboarding profile\" })\n```\n\n- `approved`: go on.\n- `pending` (or `connection_required`): tell the user, in one sentence, that Slack access is waiting, with the `approveUrl`; then `request_status({ request })` until they decide. Do not start step 3 before it is approved. If they decline, the final message says the Slack half was not read.\n\nGitHub does not use Kit: it uses `gh` in your sandbox (step 2). If `gh auth status` fails, the final message says GitHub was not read.\n\n## 2. GitHub facts\n\nCall the `onboarding_scripts` tool once: it writes the skill's scripts into your sandbox at `/tmp/onboarding-skill/` and returns that directory.\n\n```sh\nnode /tmp/onboarding-skill/github-facts.mjs > /tmp/onboarding-skill/github-facts.json\n```\n\nIt prints one JSON document: `counts` (merged PRs, open PRs, review requests, assigned issues, mentions never replied to, PRs reviewed), `repos` (merged, reviewed, open and lines changed per repo), item lists (`merged`, `open`, `reviewRequested`, `assignedIssues`, `unansweredMentions`; `total` is the full count, `items` the newest 150), `teams`, and `codeowners` lines naming the user or their teams. Open PRs carry `draft`, `reviewDecision`, `mergeable` and `checks`. `author:@me` includes PRs the user's bots open under their login. `unreadable` lists a source that could not be read.\n\n## 3. Slack facts\n\nRun the script in \"The Slack script\" below with Kit's `execute`: pass it as the script, unchanged. It runs inside Kit, so no raw message reaches you; it returns one JSON document in about 10 seconds.\n\n- A `ratelimited` error means Slack's search limit (about 20 a minute; the script makes at most 19). Wait 70 seconds (`sleep 70`) and run it again unchanged, up to five times.\n- A `suspended` result whose output says `consent_required` is a pause, not an error: tell the user in one sentence that the Slack read is waiting, with its `approveUrl`; `request_status({ request })` until they decide; then `execute({ run })` continues the same run (settled steps are not repeated).\n- An `output` with `refused` means Slack answered `missing_scope` (or another refusal) to a call, so no facts were read: `refused.needed` names the scope the user's Slack connection lacks (`refused.provided` what it has). Do not continue. Call `request_access({ service: \"slack\", capability: \"search_messages\", reason })`: if it returns an `approveUrl`, give it to the user; otherwise tell them to reconnect Slack in Kit allowing `refused.needed`. The final message says the Slack half was not read and names the scope.\n- Any other error goes to the user in the final message. Never continue with fewer facts, and never retry around a refusal.\n- Save the run's `output` as `/tmp/onboarding-skill/slack-facts.json` (the checker reads it).\n- `window`: the last 30 days. Slack search allows 15 pages of 100 here, so a very active account is covered only back to `own.oldest` (`newestOnly: true`); state that window in the profile.\n- `own`: messages per channel, thread replies, recent snippets. `mentions`: `replied` means the user posted later in the same thread; `unanswered` means they did not. A mention older than what was read with no reply among it (`notJudged`) is unknown, not unanswered. `mentions.capped` means the search found more mentions (`searchTotal`) than the three pages read: say so in \"Could not tell\". Reactions are not read (the Slack grant has no `reactions:read`): say so in \"Could not tell\". `memberships`: channels the user is in.\n- DMs and group DMs are never searched. Every list item carries a permalink (`url`); a snippet that looked like a secret is `[withheld]`.\n\n## 4. Write the profile and drafts\n\nWrite the profile to `/tmp/onboarding-skill/profile.md`. It names the user's repos and colleagues, so it is posted only in this thread and never committed.\n\nAnswer, in the user's terms:\n\n- What they have shipped, what they own, what they are responsible for.\n- What people tag them for, and what they write about.\n- What they have missed: unanswered mentions, requested reviews, stale or conflicted PRs.\n- Which open GitHub work-streams to close (stale, conflicted, superseded) and which to focus on.\n\nRules:\n\n- Judge; the scripts only counted. No scoring formula and no thresholds from code: say why in a sentence.\n- Every list item cites at least one permalink that appears in the facts, and no URL appears that isn't in the facts. State counts as the facts give them, with the window.\n- Don't quote messages beyond the snippets, and don't name people beyond what a claim needs.\n- Add a \"Could not tell\" section: every `unreadable` source, `notJudged` mentions, that reactions were not read, the Slack window, a Kit grant the user declined.\n\nFor each recurring need you found (a morning pass over unanswered mentions, a weekly stale-PR pass), write one draft to `/tmp/onboarding-skill/schedules/<bot>/<id>.md` for an existing bot of your workspace:\n\n```md\n---\ncron: \"0 9 * * 1-5\"\n---\n\nThe prompt the bot runs.\n```\n\n`cron` is five fields in UTC on a hosted deploy. Each draft says in its prompt which facts it reads and what it reports.\n\n## 5. Check\n\n```sh\nnode /tmp/onboarding-skill/check-report.mjs --profile /tmp/onboarding-skill/profile.md --facts /tmp/onboarding-skill/github-facts.json /tmp/onboarding-skill/slack-facts.json\n```\n\nFix every line it prints and rerun until it prints nothing. Then delete the two facts files.\n\n## 6. Open the pull request, if the workspace has a repo\n\nWhen the workspace is backed by a GitHub repo, open one PR on it that adds only the schedule drafts, as `agents/<bot>/agent/schedules/<id>.md`, and nothing from the profile (`gh pr create`). The user merging it is their approval; the deploy that follows is what makes the schedules run. The PR's checks include the deploy's build, which rejects a bad cron or a bot that does not exist: watch them (`gh pr checks`), fix the draft and push again until they pass, and say in the message if they do not. Without a repo the drafts stay in the thread and the message says so.\n\n## 7. Post one short message to the user\n\nThe user reads it cold, often only its first line (your writing rules). Write it to `/tmp/onboarding-skill/post.md` in this shape, load the forms skill for the tables, and check it:\n\n```md\nOne line on who they are and what they own, then the one thing to do first.\n\n---\n\n| Act on first | Why | Link |\n|---|---|---|\n(three rows, each linking a permalink from the facts)\n\n| Schedule | Bot | When | What it reports |\n|---|---|---|---|\n(one row per draft)\n\nThe drafts are in [this PR](...): merging it turns them on.\n(one line per source not read or grant declined: what was not read and what to do)\n```\n\n```sh\nnode /tmp/onboarding-skill/check-report.mjs --post /tmp/onboarding-skill/post.md\n```\n\nFix every line it prints and rerun until it prints nothing, then post the file's text in this thread.\n\n- The first bubble, everything above `---`, is at most two sentences.\n- Below it, the two tables, then the lines under them and nothing else:\n - The PR line. Without a repo, it says the drafts stay in the thread. If the PR's checks still fail after step 6, it says so and what failed, in place of \"merging it turns them on\".\n - One line per source you could not read or Kit grant the user declined: what was not read and what they must do.\n - If you handed the Mac half over in step 0 and that bot hasn't posted yet, one line: its half hasn't reported yet, and if the Hub app is closed on their Mac, it runs once they open it.\n- Nothing else goes in: not the profile, the \"Could not tell\" section, counts, windows, or how the run went. Keep `profile.md`, and post it, or the part asked for, only when the user asks.\n\nOnly the schedule files ever leave the thread.\n\n## The Slack script\n\n```js\n// Facts about the signed-in user's recent Slack channel activity. Counts, permalinks and short snippets only.\n// Searches exclude DMs and group DMs (-is:dm) so none is ever read. Slack allows about 20 searches a minute: this\n// script makes at most 19, so on a ratelimited error wait a minute and run it again unchanged.\n// The .slice(0, n) before each .map is not redundant: callscript counts worst-case calls from it (200 max).\nconst DAYS = 30;\nconst after = new Date(Date.now() - (DAYS + 1) * 86400000).slice(0, 10);\nconst auth = await slack.auth_test();\nconst me = auth.user_id;\nconst ownPages = await Promise.all([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15].slice(0, 15).map(page => slack.search_messages({ query: `from:<@${me}> after:${after} -is:dm`, count: 100, page, sort: \"timestamp\", sort_dir: \"desc\" })));\nconst mentionPages = await Promise.all([1, 2, 3].slice(0, 3).map(page => slack.search_messages({ query: `<@${me}> after:${after} -is:dm`, count: 100, page, sort: \"timestamp\", sort_dir: \"desc\" })));\nconst member = await slack.users_conversations({ types: \"public_channel,private_channel\", limit: 200, exclude_archived: true });\nconst refused = [auth, ...ownPages, ...mentionPages, member].filter(r => r.ok === false);\nif (refused.length > 0) {\n return { refused: { error: refused[0].error, needed: refused[0].needed ?? null, provided: refused[0].provided ?? null, calls: refused.length } };\n}\nconst channels = Object.entries(Object.groupBy(ownPages.flatMap(r => r.messages.matches).filter(m => m.user === me && !m.channel.is_im && !m.channel.is_mpim), m => m.channel.id)).map(e => ({ id: e[0], channel: e[1][0].channel.name, count: e[1].length, threadReplies: e[1].filter(m => m.permalink.includes(\"thread_ts=\") && !m.permalink.includes(`thread_ts=${m.ts}`)).length, oldest: e[1][e[1].length - 1].ts, recent: e[1].slice(0, 2).map(m => ({ url: m.permalink, ts: m.ts, text: m.text.split(\"\\n\").join(\" \").split(\" \").some(t => t.length >= 20 && [\"xoxb-\", \"xoxp-\", \"xoxa-\", \"xoxs-\", \"xoxe\", \"sk-\", \"ghp_\", \"gho_\", \"ghu_\", \"ghs_\", \"ghr_\", \"github_pat_\", \"vck_\", \"AKIA\", \"eyJ\"].some(p => t.includes(p))) ? \"[withheld]\" : m.text.split(\"\\n\").join(\" \").slice(0, 160) })) })).sort((a, b) => b.count - a.count);\nconst total = channels.reduce((n, c) => n + c.count, 0);\nconst threadReplies = channels.reduce((n, c) => n + c.threadReplies, 0);\nconst mentioned = mentionPages.flatMap(r => r.messages.matches).filter(m => m.user !== me && !m.channel.is_im && !m.channel.is_mpim).map(m => ({ url: m.permalink, ts: m.ts, author: m.username, channel: m.channel.id, name: m.channel.name, thread: m.permalink.includes(\"thread_ts=\") ? m.permalink.split(\"thread_ts=\")[1].split(\"&\")[0] : m.ts, text: m.text.split(\"\\n\").join(\" \").split(\" \").some(t => t.length >= 20 && [\"xoxb-\", \"xoxp-\", \"xoxa-\", \"xoxs-\", \"xoxe\", \"sk-\", \"ghp_\", \"gho_\", \"ghu_\", \"ghs_\", \"ghr_\", \"github_pat_\", \"vck_\", \"AKIA\", \"eyJ\"].some(p => t.includes(p))) ? \"[withheld]\" : m.text.split(\"\\n\").join(\" \").slice(0, 160) }));\nconst ownMessages = ownPages.flatMap(r => r.messages.matches).filter(m => m.user === me && !m.channel.is_im && !m.channel.is_mpim);\nconst lastOwn = Object.fromEntries(Object.entries(Object.groupBy(ownMessages, m => `${m.channel.id}:${m.permalink.includes(\"thread_ts=\") ? m.permalink.split(\"thread_ts=\")[1].split(\"&\")[0] : m.ts}`)).map(e => [e[0], e[1].reduce((t, m) => Math.max(t, Number(m.ts)), 0)]));\nconst oldestOwn = channels.length ? channels.reduce((t, c) => Math.min(t, Number(c.oldest)), Number(channels[0].oldest)) : 0;\nconst newestOnly = ownPages[0].messages.paging.pages > ownPages.length;\nconst known = mentioned.map(m => ({ ...m, replied: (lastOwn[`${m.channel}:${m.thread}`] ?? 0) > Number(m.ts) }));\nconst judged = known.filter(m => m.replied || !newestOnly || Number(m.ts) >= oldestOwn);\nconst open = judged.filter(m => !m.replied);\nreturn {\n me: { id: me, name: auth.user },\n window: { days: DAYS, after, searchTotal: ownPages[0].messages.paging.total, pagesTotal: ownPages[0].messages.paging.pages, pagesRead: ownPages.length, newestOnly },\n scope: \"public and private channels; DMs and group DMs are dropped before counting\",\n own: {\n total,\n threadReplies,\n topLevel: total - threadReplies,\n oldest: channels.length ? oldestOwn : null,\n channelCount: channels.length,\n channels: channels.slice(0, 25).map(c => ({ id: c.id, channel: c.channel, count: c.count, threadReplies: c.threadReplies, recent: c.recent }))\n },\n mentions: {\n searchTotal: mentionPages[0].messages.paging.total,\n pagesTotal: mentionPages[0].messages.paging.pages,\n pagesRead: mentionPages.length,\n capped: mentionPages[0].messages.paging.pages > mentionPages.length,\n found: mentioned.length,\n judged: judged.length,\n notJudged: mentioned.length - judged.length,\n replied: judged.length - open.length,\n unanswered: open.length,\n unansweredItems: open.slice(0, 20).map(m => ({ url: m.url, channel: m.name, author: m.author, ts: m.ts, text: m.text })),\n basis: \"replied = the user posted later in the same thread; reactions are not read (the token lacks reactions:read); a mention not yet answered in a thread may have been answered elsewhere\"\n },\n memberships: { count: member.channels.length, names: member.channels.map(c => c.name).sort(), more: Boolean(member.response_metadata?.next_cursor) }\n};\n```\n"};
|
|
2
|
+
export default {"description":"Right after sign-in: ask for the Kit access you need, then work out who the user is and what to focus on from their recent Slack and GitHub, and draft schedules that keep them on track. Reads only; the one write is a pull request the user approves by merging.","content":"# Profile the user from Slack and GitHub\n\nTwo scripts gather facts (GitHub in your sandbox, Slack inside Kit); you write the profile and the schedule drafts from those facts alone. Nothing is posted to Slack and nothing is committed; the only write is the pull request in step 6, which the user approves by merging. The user sees one short message from you at the end (step 7), not one per step. If a step cannot finish, that message says which one and what the user must do; never write the profile as if every source had been read.\n\n## 0. Hand the Mac half to the bot on the Mac\n\nYou run on Vercel and can't see the user's Mac. If `list_agents` shows a bot that runs `local`, post one message in this thread before step 1 that mentions it (the first one, if there are several) and asks it to run its local-onboarding skill. Name the workspace repo for its sandbox PR, or say there is none. Then go on with step 1 without waiting: its half arrives in this thread as its own posts.\n\nWith no bot that runs `local`, skip this step and say nothing about a Mac.\n\n## 1. Ask for Kit access, once\n\nKit's tools are `request_access`, `request_status` and `execute`. Hosted, you reach them through the `vercel-kit` connection: `eve__tool({ name: \"vercel-kit__request_access\", input: { ... } })`, and the same shape for the others. Everywhere below, \"call `request_access`\" means that.\n\nSlack goes through the user's Kit connection. Before any fetch, ask for everything the run reads in one request:\n\n```\nrequest_access({ service: \"slack\", reads: true, reason: \"Read your recent channel messages, mentions and threads to write your onboarding profile\" })\n```\n\n- `approved`: go on.\n- `pending` (or `connection_required`): tell the user, in one sentence, that Slack access is waiting, with the `approveUrl`; then `request_status({ request })` until they decide. Do not start step 3 before it is approved. If they decline, the final message says the Slack half was not read.\n\nGitHub does not use Kit: it uses `gh` in your sandbox (step 2). If `gh auth status` fails, the final message says GitHub was not read.\n\n## 2. GitHub facts\n\nCall the `onboarding_scripts` tool once: it writes the skill's scripts into your sandbox at `/tmp/onboarding-skill/` and returns that directory.\n\n```sh\nnode /tmp/onboarding-skill/github-facts.mjs > /tmp/onboarding-skill/github-facts.json\n```\n\nIt prints one JSON document: `counts` (merged PRs, open PRs, review requests, assigned issues, mentions never replied to, PRs reviewed), `repos` (merged, reviewed, open and lines changed per repo), item lists (`merged`, `open`, `reviewRequested`, `assignedIssues`, `unansweredMentions`; `total` is the full count, `items` the newest 150), `teams`, and `codeowners` lines naming the user or their teams. Open PRs carry `draft`, `reviewDecision`, `mergeable` and `checks`. `author:@me` includes PRs the user's bots open under their login. `unreadable` lists a source that could not be read.\n\n## 3. Slack facts\n\nRun the script in \"The Slack script\" below with Kit's `execute`: pass it as the script, unchanged. It runs inside Kit, so no raw message reaches you; it returns one JSON document in about 10 seconds.\n\n- A `ratelimited` error means Slack's search limit (about 20 a minute; the script makes at most 19). Wait 70 seconds (`sleep 70`) and run it again unchanged, up to five times.\n- A `suspended` result whose output says `consent_required` is a pause, not an error: tell the user in one sentence that the Slack read is waiting, with its `approveUrl`; `request_status({ request })` until they decide; then `execute({ run })` continues the same run (settled steps are not repeated).\n- An `output` with `refused` means Slack answered `missing_scope` (or another refusal) to a call, so no facts were read: `refused.needed` names the scope the user's Slack connection lacks (`refused.provided` what it has). Do not continue. Call `request_access({ service: \"slack\", capability: \"search_messages\", reason })`: if it returns an `approveUrl`, give it to the user; otherwise tell them to reconnect Slack in Kit allowing `refused.needed`. The final message says the Slack half was not read and names the scope.\n- Any other error goes to the user in the final message. Never continue with fewer facts, and never retry around a refusal.\n- Save the run's `output` as `/tmp/onboarding-skill/slack-facts.json` (the checker reads it).\n- `window`: the last 30 days. Slack search allows 15 pages of 100 here, so a very active account is covered only back to `own.oldest` (`newestOnly: true`); state that window in the profile.\n- `own`: messages per channel, thread replies, recent snippets. `mentions`: `replied` means the user posted later in the same thread; `unanswered` means they did not. A mention older than what was read with no reply among it (`notJudged`) is unknown, not unanswered. `mentions.capped` means the search found more mentions (`searchTotal`) than the three pages read: say so in \"Could not tell\". Reactions are not read (the Slack grant has no `reactions:read`): say so in \"Could not tell\". `memberships`: channels the user is in.\n- DMs and group DMs are never searched. Every list item carries a permalink (`url`); a snippet that looked like a secret is `[withheld]`.\n\n## 4. Write the profile and drafts\n\nWrite the profile to `/tmp/onboarding-skill/profile.md`. It names the user's repos and colleagues, so it is posted only in this thread and never committed.\n\nAnswer, in the user's terms:\n\n- What they have shipped, what they own, what they are responsible for.\n- What people tag them for, and what they write about.\n- What they have missed: unanswered mentions, requested reviews, stale or conflicted PRs.\n- Which open GitHub work-streams to close (stale, conflicted, superseded) and which to focus on.\n\nRules:\n\n- Judge; the scripts only counted. No scoring formula and no thresholds from code: say why in a sentence.\n- Every list item cites at least one permalink that appears in the facts, and no URL appears that isn't in the facts. State counts as the facts give them, with the window.\n- Don't quote messages beyond the snippets, and don't name people beyond what a claim needs.\n- Add a \"Could not tell\" section: every `unreadable` source, `notJudged` mentions, that reactions were not read, the Slack window, a Kit grant the user declined.\n\nFor each recurring need you found (a morning pass over unanswered mentions, a weekly stale-PR pass), write one draft to `/tmp/onboarding-skill/schedules/<bot>/<id>.md` for an existing bot of your workspace:\n\n```md\n---\ncron: \"0 9 * * 1-5\"\n---\n\nThe prompt the bot runs.\n```\n\n`cron` is five fields in UTC on a hosted deploy. Each draft says in its prompt which facts it reads and what it reports.\n\n## 5. Check\n\n```sh\nnode /tmp/onboarding-skill/check-report.mjs --profile /tmp/onboarding-skill/profile.md --facts /tmp/onboarding-skill/github-facts.json /tmp/onboarding-skill/slack-facts.json\n```\n\nFix every line it prints and rerun until it prints nothing. Then delete the two facts files.\n\n## 6. Open the pull request, if the workspace has a repo\n\nWhen the workspace is backed by a GitHub repo, open one PR on it that adds only the schedule drafts, as `agents/<bot>/agent/schedules/<id>.md`, and nothing from the profile (`gh pr create`). The user merging it is their approval; the deploy that follows is what makes the schedules run. The PR's checks include the deploy's build, which rejects a bad cron or a bot that does not exist: watch them (`gh pr checks`), fix the draft and push again until they pass, and say in the message if they do not. Without a repo the drafts stay in the thread and the message says so.\n\n## 7. Post one short message to the user\n\nThe user reads it cold, often only its first line (your writing rules). Write it to `/tmp/onboarding-skill/post.md` in this shape, load the forms skill for the tables, and check it:\n\n```md\nOne line on who they are and what they own, then the one thing to do first.\n\n---\n\n| Act on first | Why | Link |\n|---|---|---|\n(three rows, each linking a permalink from the facts)\n\n| Schedule | Bot | When | What it reports |\n|---|---|---|---|\n(one row per draft)\n\nThe drafts are in [this PR](...): merging it turns them on.\n(one line per source not read or grant declined: what was not read and what to do)\n```\n\n```sh\nnode /tmp/onboarding-skill/check-report.mjs --post /tmp/onboarding-skill/post.md\n```\n\nFix every line it prints and rerun until it prints nothing, then post the file's text in this thread.\n\n- The first bubble, everything above `---`, is at most two sentences.\n- Below it, the two tables, then the lines under them and nothing else:\n - The PR line. Without a repo, it says the drafts stay in the thread. If the PR's checks still fail after step 6, it says so and what failed, in place of \"merging it turns them on\".\n - One line per source you could not read or Kit grant the user declined: what was not read and what they must do.\n - If you handed the Mac half over in step 0 and that bot hasn't posted yet, one line: its half hasn't reported yet, and if the Hub app is closed on their Mac, it runs once they open it.\n- Nothing else goes in: not the profile, the \"Could not tell\" section, counts, windows, or how the run went. Keep `profile.md`, and post it, or the part asked for, only when the user asks.\n\nOnly the schedule files ever leave the thread.\n\n## The Slack script\n\n```js\n// Facts about the signed-in user's recent Slack channel activity. Counts, permalinks and short snippets only.\n// Searches exclude DMs and group DMs (-is:dm) so none is ever read. Slack allows about 20 searches a minute: this\n// script makes at most 19, so on a ratelimited error wait a minute and run it again unchanged.\n// The .slice(0, n) before each .map is not redundant: callscript counts worst-case calls from it (200 max).\nconst DAYS = 30;\nconst after = new Date(Date.now() - (DAYS + 1) * 86400000).slice(0, 10);\nconst auth = await slack.auth_test();\nconst me = auth.user_id;\nconst ownPages = await Promise.all([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15].slice(0, 15).map(page => slack.search_messages({ query: `from:<@${me}> after:${after} -is:dm`, count: 100, page, sort: \"timestamp\", sort_dir: \"desc\" })));\nconst mentionPages = await Promise.all([1, 2, 3].slice(0, 3).map(page => slack.search_messages({ query: `<@${me}> after:${after} -is:dm`, count: 100, page, sort: \"timestamp\", sort_dir: \"desc\" })));\nconst member = await slack.users_conversations({ types: \"public_channel,private_channel\", limit: 200, exclude_archived: true });\nconst refused = [auth, ...ownPages, ...mentionPages, member].filter(r => r.ok === false);\nif (refused.length > 0) {\n return { refused: { error: refused[0].error, needed: refused[0].needed ?? null, provided: refused[0].provided ?? null, calls: refused.length } };\n}\nconst channels = Object.entries(Object.groupBy(ownPages.flatMap(r => r.messages.matches).filter(m => m.user === me && !m.channel.is_im && !m.channel.is_mpim), m => m.channel.id)).map(e => ({ id: e[0], channel: e[1][0].channel.name, count: e[1].length, threadReplies: e[1].filter(m => m.permalink.includes(\"thread_ts=\") && !m.permalink.includes(`thread_ts=${m.ts}`)).length, oldest: e[1][e[1].length - 1].ts, recent: e[1].slice(0, 2).map(m => ({ url: m.permalink, ts: m.ts, text: m.text.split(\"\\n\").join(\" \").split(\" \").some(t => t.length >= 20 && [\"xoxb-\", \"xoxp-\", \"xoxa-\", \"xoxs-\", \"xoxe\", \"sk-\", \"ghp_\", \"gho_\", \"ghu_\", \"ghs_\", \"ghr_\", \"github_pat_\", \"vck_\", \"AKIA\", \"eyJ\"].some(p => t.includes(p))) ? \"[withheld]\" : m.text.split(\"\\n\").join(\" \").slice(0, 160) })) })).sort((a, b) => b.count - a.count);\nconst total = channels.reduce((n, c) => n + c.count, 0);\nconst threadReplies = channels.reduce((n, c) => n + c.threadReplies, 0);\nconst mentioned = mentionPages.flatMap(r => r.messages.matches).filter(m => m.user !== me && !m.channel.is_im && !m.channel.is_mpim).map(m => ({ url: m.permalink, ts: m.ts, author: m.username, channel: m.channel.id, name: m.channel.name, thread: m.permalink.includes(\"thread_ts=\") ? m.permalink.split(\"thread_ts=\")[1].split(\"&\")[0] : m.ts, text: m.text.split(\"\\n\").join(\" \").split(\" \").some(t => t.length >= 20 && [\"xoxb-\", \"xoxp-\", \"xoxa-\", \"xoxs-\", \"xoxe\", \"sk-\", \"ghp_\", \"gho_\", \"ghu_\", \"ghs_\", \"ghr_\", \"github_pat_\", \"vck_\", \"AKIA\", \"eyJ\"].some(p => t.includes(p))) ? \"[withheld]\" : m.text.split(\"\\n\").join(\" \").slice(0, 160) }));\nconst ownMessages = ownPages.flatMap(r => r.messages.matches).filter(m => m.user === me && !m.channel.is_im && !m.channel.is_mpim);\nconst lastOwn = Object.fromEntries(Object.entries(Object.groupBy(ownMessages, m => `${m.channel.id}:${m.permalink.includes(\"thread_ts=\") ? m.permalink.split(\"thread_ts=\")[1].split(\"&\")[0] : m.ts}`)).map(e => [e[0], e[1].reduce((t, m) => Math.max(t, Number(m.ts)), 0)]));\nconst oldestOwn = channels.length ? channels.reduce((t, c) => Math.min(t, Number(c.oldest)), Number(channels[0].oldest)) : 0;\nconst newestOnly = ownPages[0].messages.paging.pages > ownPages.length;\nconst known = mentioned.map(m => ({ ...m, replied: (lastOwn[`${m.channel}:${m.thread}`] ?? 0) > Number(m.ts) }));\nconst judged = known.filter(m => m.replied || !newestOnly || Number(m.ts) >= oldestOwn);\nconst open = judged.filter(m => !m.replied);\nreturn {\n me: { id: me, name: auth.user },\n window: { days: DAYS, after, searchTotal: ownPages[0].messages.paging.total, pagesTotal: ownPages[0].messages.paging.pages, pagesRead: ownPages.length, newestOnly },\n scope: \"public and private channels; DMs and group DMs are dropped before counting\",\n own: {\n total,\n threadReplies,\n topLevel: total - threadReplies,\n oldest: channels.length ? oldestOwn : null,\n channelCount: channels.length,\n channels: channels.slice(0, 25).map(c => ({ id: c.id, channel: c.channel, count: c.count, threadReplies: c.threadReplies, recent: c.recent }))\n },\n mentions: {\n searchTotal: mentionPages[0].messages.paging.total,\n pagesTotal: mentionPages[0].messages.paging.pages,\n pagesRead: mentionPages.length,\n capped: mentionPages[0].messages.paging.pages > mentionPages.length,\n found: mentioned.length,\n judged: judged.length,\n notJudged: mentioned.length - judged.length,\n replied: judged.length - open.length,\n unanswered: open.length,\n unansweredItems: open.slice(0, 20).map(m => ({ url: m.url, channel: m.name, author: m.author, ts: m.ts, text: m.text })),\n basis: \"replied = the user posted later in the same thread; reactions are not read (the token lacks reactions:read); a mention not yet answered in a thread may have been answered elsewhere\"\n },\n memberships: { count: member.channels.length, names: member.channels.map(c => c.name).sort(), more: Boolean(member.response_metadata?.next_cursor) }\n};\n```\n"};
|
package/team.d.mts
CHANGED
|
@@ -20,4 +20,4 @@ export const FLOW: readonly { from: string; to: string; kind?: string; text: str
|
|
|
20
20
|
export function teamEdges(nodes: readonly TeamNode[]): TeamEdge[];
|
|
21
21
|
export function teamModel(input: { agents: readonly TeamAgent[]; humans?: readonly { name: string; label?: string; color?: string }[]; tools?: Record<string, readonly string[] | null | undefined> }): TeamModel;
|
|
22
22
|
export function architectureText(data: unknown, reader: string): string;
|
|
23
|
-
export function teamRecall(
|
|
23
|
+
export function teamRecall(text: string): { messages: { id: string; content: string }[] };
|
package/team.mjs
CHANGED
|
@@ -113,13 +113,9 @@ export function architectureText(data, reader) {
|
|
|
113
113
|
return `<team_architecture>\n${architecture({ agents, reader })}\n</team_architecture>`;
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
export function teamRecall(history, text) {
|
|
121
|
-
const given = history.filter((m) => m?.role === "user" && m.kind === "memory.load" && textOf(m).startsWith("<team_architecture>")).map(textOf);
|
|
122
|
-
if (given.at(-1) === text) return { messages: [] };
|
|
123
|
-
return { messages: [{ id: `team#${given.length + 1}@${sha(text, 8)}`, content: text }] };
|
|
116
|
+
// The team slot's recall: the architecture as one message under one id, so eve replaces what the
|
|
117
|
+
// session was given when the text changes and keeps it when it doesn't.
|
|
118
|
+
export function teamRecall(text) {
|
|
119
|
+
return { messages: [{ id: "team", content: text }] };
|
|
124
120
|
}
|
|
125
121
|
|
package/templates/execution.md
CHANGED
|
@@ -21,8 +21,8 @@
|
|
|
21
21
|
- **Waiting**
|
|
22
22
|
- Never sleep or poll more than 60 seconds in one command. Check CI or a deploy once, report, and say what you're waiting on.
|
|
23
23
|
- While work runs, post at least every 30 minutes: a result, or the one thing blocking you.
|
|
24
|
-
- Ending a turn with your own work unfinished and nothing sure to wake you (CI, a deploy, a background task, stopping partway): arm a wake-up with `schedule__wake__create`, the delay set to the expected wait, the note saying what to pick up and `until` a little after it. It posts into this thread
|
|
25
|
-
- To check back repeatedly (a long CI run, a slow rollout), arm it with a cron expression, `until` the last time it may fire. It posts
|
|
24
|
+
- Ending a turn with your own work unfinished and nothing sure to wake you (CI, a deploy, a background task, stopping partway): arm a wake-up with `schedule__wake__create`, the delay set to the expected wait, the note saying what to pick up and `until` a little after it. It posts into this thread at that time, whatever happens meanwhile; delete it if the wait is over.
|
|
25
|
+
- To check back repeatedly (a long CI run, a slow rollout), arm it with a cron expression, `until` the last time it may fire. It posts at every firing; delete it once you're done waiting.
|
|
26
26
|
- Don't arm one while waiting on the human or on a bot you just mentioned: their reply wakes you.
|
|
27
27
|
- **Ending a turn**
|
|
28
28
|
- End only with results, a blocker you can't clear, or a question for the human; never "I'll do it next".
|