eve-hub 0.4.2 → 0.5.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eve-hub",
3
- "version": "0.4.2",
3
+ "version": "0.5.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",
@@ -28,8 +28,6 @@
28
28
  },
29
29
  "peerDependencies": {
30
30
  "eve": "*",
31
- "ai": "^7.0.105",
32
- "@ai-sdk/gateway": "^4.0.100",
33
31
  "zod": "4.5.4",
34
32
  "@vercel/connect": "^2.4.1",
35
33
  "@vercel/oidc": "4.0.0"
package/roster-model.mjs CHANGED
@@ -17,15 +17,6 @@ import { CONTEXT_WINDOW, selectModel } from "./bot-model.mjs";
17
17
  import { DEFAULT_SUBAGENT_CAP } from "./subagent-cap.mjs";
18
18
  import { sessionLimits } from "./session-limits.mjs";
19
19
  import { findWorkspaceRoot, loadWorkspace, parseWorkspace } from "./workspace.mjs";
20
- import { createGateway } from "ai";
21
- import { hubGatewayFetch } from "./model-credential.mjs";
22
- import { registryBase } from "./registry-fetch.mjs";
23
-
24
- // Hosted, a bot's model ids resolve through the owner's sign-in from the hub
25
- // (model-credential.mjs); elsewhere through the default gateway.
26
- if (process.env.HUB_TOKEN)
27
- globalThis.AI_SDK_DEFAULT_PROVIDER = createGateway({ apiKey: "hub-owner-sign-in", fetch: hubGatewayFetch(`${registryBase().replace(/\/$/, "")}/api/model-credential`) });
28
-
29
20
  const real = process.env.ROSTER_MODELS !== "mock";
30
21
 
31
22
  export function createRosterModel({ agents, subagentCap }) {
@@ -0,0 +1,2 @@
1
+ declare const _default: any;
2
+ export default _default;
@@ -0,0 +1,20 @@
1
+ // main's classify schedule: gives each idle, non-deferred thread that moved
2
+ // on a Dash entry (importance, delay) with openai/gpt-6-luna-decisions
3
+ // through eve's `evaluate` (eve's gateway credentials). The pass: runClassify
4
+ // in ./dash-classify.mjs; a thread whose basis hasn't changed costs nothing.
5
+ // The hub fires it off-cron as soon as a bot's turn ends (registry/server.mjs,
6
+ // schedules.runSoon), so a question shows on the sidebar within seconds; the
7
+ // cron catches what that missed (a failed call, a hub restart). classifyPass
8
+ // keeps one pass at a time in this process, whatever the hub's dispatches;
9
+ // the pass's path filing (`filing`) runs on after it, unawaited, so neither
10
+ // this dispatch nor the next pass waits on it.
11
+ import { defineSchedule } from "eve/schedules";
12
+ import { evaluate } from "eve/ai";
13
+ import { createHub } from "./hub.mjs";
14
+ import { classifyPass, modelClassify, runClassify } from "./dash-classify.mjs";
15
+ export default defineSchedule({
16
+ cron: "*/10 * * * *",
17
+ async run() {
18
+ await classifyPass(() => runClassify(createHub(), { classify: modelClassify({ evaluate }) }));
19
+ },
20
+ });
@@ -1,11 +1,12 @@
1
- // The classifier step of main's dash schedule (schedules/dash.ts): gives
1
+ // main's classify schedule (schedules/classify.ts): gives
2
2
  // every idle thread a Dash entry (registry/dash.mjs: importance, delay) and
3
3
  // writes them back through POST /api/dash/entries.
4
4
  //
5
5
  // targets open threads (not DMs, schedule threads or archived) where no
6
6
  // bot is working, that aren't deferred, and whose basis changed
7
7
  // since their entry was written. An unchanged hub costs nothing.
8
- // classify (targets, snap) -> [{ threadId, importance, delay }].
8
+ // classify (target, snap) -> { threadId, importance, delay } or null, all
9
+ // targets at once, each written as soon as it's decided.
9
10
  // modelClassify: one evaluation per thread on the AI Gateway
10
11
  // (openai/gpt-6-luna-decisions), two choice questions. Its
11
12
  // answer is the classification. No model, a failed call or an
@@ -66,8 +67,11 @@ export function pathQuestion(paths) {
66
67
  return { type: "choice", instructions: "Which path covers this thread's work? Paths group threads by feature or effort at any depth. A bug, polish or follow-up belongs to its area's existing path. Prefer an existing path where it fits; choose new only when none fits.", criteria };
67
68
  }
68
69
 
69
- // The threads to (re)classify this tick, at most `max`: those the Dash needs
70
- // (no entry, or the thread moved on) first, oldest change first; then those
70
+ // The threads to (re)classify this pass, at most `max`: those the Dash needs
71
+ // (no entry, or the thread moved on) first, newest change first, so the
72
+ // thread whose turn just ended (what fired this pass) is selected under the
73
+ // cap whatever the backlog, and a thread whose answers keep failing sinks behind
74
+ // fresh ones instead of holding the front; later passes drain the rest. Then those
71
75
  // asked only for a path, least recently classified first, so threads still
72
76
  // waiting on main's filing take turns and never crowd out the Dash.
73
77
  export function classifyTargets(snap, { max = CLASSIFY_MAX } = {}) {
@@ -87,7 +91,7 @@ export function classifyTargets(snap, { max = CLASSIFY_MAX } = {}) {
87
91
  (due ? out : pathOnly).push({ id: t.id, title: t.title, tags: t.tags ?? {}, status: t.status ?? {}, last, brief: threadBrief(t, snap), basis, entry: e ?? null, paths: path ? pathsInProject(t, snap) : null });
88
92
  }
89
93
  const at = (x) => String(x ?? "");
90
- out.sort((a, b) => at(a.last.at).localeCompare(at(b.last.at)));
94
+ out.sort((a, b) => at(b.last.at).localeCompare(at(a.last.at)));
91
95
  pathOnly.sort((a, b) => at(a.entry?.updatedAt).localeCompare(at(b.entry?.updatedAt)) || at(a.last.at).localeCompare(at(b.last.at)));
92
96
  return [...out, ...pathOnly].slice(0, max);
93
97
  }
@@ -150,20 +154,16 @@ export async function decideThread(t, snap, { evaluate, model = DECISION_MODEL,
150
154
  return out;
151
155
  }
152
156
 
153
- // The schedule's classifier: per thread, the model's answer as is. No
154
- // `evaluate`, a failed call or an invalid answer: no entry for that thread.
155
- /** @param {{ evaluate?: (options: any) => Promise<any>, concurrency?: number, log?: any, model?: string, timeoutMs?: number }} [options] */
156
- export function modelClassify({ evaluate, concurrency = 4, log = console, ...opts } = {}) {
157
- return async (targets, snap) => {
158
- if (!evaluate) return [];
159
- const one = async (t) => {
160
- const a = await decideThread(t, snap, { evaluate, ...opts }).catch((err) => (log?.warn?.(`dash: decide ${t.id} failed, skipped: ${err?.message ?? err}`), undefined));
161
- if (a === null) log?.warn?.(`dash: decide ${t.id}: invalid answer, skipped`);
162
- return a ? { threadId: t.id, ...a } : null;
163
- };
164
- const out = [];
165
- for (let i = 0; i < targets.length; i += concurrency) out.push(...(await Promise.all(targets.slice(i, i + concurrency).map(one))));
166
- return out.filter(Boolean);
157
+ // The schedule's classifier, one thread: the model's answer as is
158
+ // ({ threadId, importance, delay, path? }), or null when there's no
159
+ // `evaluate`, the call failed or the answer is invalid (no entry for it).
160
+ /** @param {{ evaluate?: (options: any) => Promise<any>, log?: any, model?: string, timeoutMs?: number }} [options] */
161
+ export function modelClassify({ evaluate, log = console, ...opts } = {}) {
162
+ return async (t, snap) => {
163
+ if (!evaluate) return null;
164
+ const a = await decideThread(t, snap, { evaluate, ...opts }).catch((err) => (log?.warn?.(`dash: decide ${t.id} failed, skipped: ${err?.message ?? err}`), undefined));
165
+ if (a === null) log?.warn?.(`dash: decide ${t.id}: invalid answer, skipped`);
166
+ return a ? { threadId: t.id, ...a } : null;
167
167
  };
168
168
  }
169
169
 
@@ -203,82 +203,117 @@ export function newPathNotice(threads, snap) {
203
203
  // not. The model's verdict on the brief decides; nothing here reads the
204
204
  // messages. (A pending request is posted by the rule scan, dash-rules.mjs.)
205
205
  const WAITS = { blocking: "waiting on your decision", land: "waiting on your sign-off" };
206
- export function askLines(entries, byId) {
207
- const out = [];
208
- for (const e of entries) {
209
- const t = byId.get(e.threadId);
210
- const was = t.entry?.delay;
211
- if (was === e.delay) continue;
212
- const link = `[${String(t.title ?? t.id).replace(/[[\]]/g, "")}](${t.id})`;
213
- if (WAITS[e.delay]) out.push({ entry: e, line: `asks you: ${link}, ${WAITS[e.delay]}` });
214
- else if (WAITS[was]) out.push({ entry: e, line: `no longer asking you: ${link}` });
215
- }
216
- return out;
217
- }
218
- // The notice for `entries`, or null: committed with them in one write
219
- // (hub.dashEntries), so a verdict and its notice are kept together or not
220
- // at all, and a failed write is judged afresh next pass against the entry
221
- // as it still is.
222
- export function asksNotice(entries, byId) {
223
- const asks = askLines(entries, byId);
224
- if (!asks.length) return null;
225
- const lines = asks.map((a) => a.line);
226
- const key = `asks-${createHash("sha256").update(asks.map(({ entry: e }) => `${e.threadId}:${e.delay}:${e.basis}`).sort().join(",")).digest("hex").slice(0, 32)}`;
227
- return { schedule: "dash", text: `@main dash: ${lines.length === 1 ? lines[0] : `${lines.length} changes\n${lines.map((l) => `- ${l}`).join("\n")}`}`, clientId: key };
206
+ // The notice for `e`, the new entry of target `t`, or null: committed with
207
+ // it in one write (hub.dashEntries), so a verdict and its notice are kept
208
+ // together or not at all, and a failed write is judged afresh next pass
209
+ // against the entry as it still is.
210
+ export function asksNotice(e, t) {
211
+ const was = t.entry?.delay;
212
+ if (was === e.delay) return null;
213
+ const link = `[${String(t.title ?? t.id).replace(/[[\]]/g, "")}](${t.id})`;
214
+ const line = WAITS[e.delay] ? `asks you: ${link}, ${WAITS[e.delay]}` : WAITS[was] ? `no longer asking you: ${link}` : null;
215
+ if (!line) return null;
216
+ const key = `asks-${createHash("sha256").update(`${e.threadId}:${e.delay}:${e.basis}`).digest("hex").slice(0, 32)}`;
217
+ return { schedule: "dash", text: `@main dash: ${line}`, clientId: key };
228
218
  }
229
219
 
230
- // File what the model filed: a path pick goes straight to the hub, `new`
231
- // goes to main in one notice (deduped by the set of threads). A failed
232
- // write is logged and retried next tick, as the thread is still unfiled.
233
- async function filePaths(hub, decided, byId, snap, log) {
234
- const fresh = [];
235
- const filedNow = new Set();
236
- for (const d of decided) {
237
- if (!d?.path) continue;
238
- if (d.path === NEW_PATH) fresh.push(byId.get(d.threadId));
239
- else await hub.fileThread?.(d.threadId, d.path).then(() => filedNow.add(d.threadId), (err) => log?.warn?.(`dash: file ${d.threadId} into ${d.path} failed: ${err?.message ?? err}`));
220
+ // The `new` answers of a pass go to main in one notice per project (deduped
221
+ // by the set of threads): not just these, every unfiled thread in their
222
+ // projects, so main groups a project once instead of 30 at a time. Which
223
+ // threads are unfiled is read from the hub now, after this pass's filing,
224
+ // not from the pass's snapshot: another pass may have filed some meanwhile.
225
+ // A failed read or post is logged; the threads are still unfiled, so they're
226
+ // asked again.
227
+ async function noticeNewPaths(hub, fresh, log) {
228
+ if (!fresh.length || !hub.post) return;
229
+ let snap;
230
+ try {
231
+ snap = await hub.snapshot();
232
+ } catch (err) {
233
+ return log?.warn?.(`dash: new-path notice skipped, no snapshot: ${err?.message ?? err}`);
240
234
  }
241
- if (fresh.length && hub.post) {
242
- // Not just this tick's `new` answers: every unfiled thread in their
243
- // projects, so main groups a project once instead of 30 at a time.
244
- const all = unfiledIn(snap, new Set(fresh.map((t) => t.tags?.project ?? ""))).filter((t) => !filedNow.has(t.id));
245
- for (const project of new Set(all.map((t) => t.tags?.project ?? ""))) {
246
- const threads = all.filter((t) => (t.tags?.project ?? "") === project);
247
- const key = `paths-new-${createHash("sha256").update(threads.map((t) => t.id).sort().join(",")).digest("hex").slice(0, 32)}`;
248
- await hub.post("dash", newPathNotice(threads, snap), key).catch((err) => log?.warn?.(`dash: new-path notice failed: ${err?.message ?? err}`));
249
- }
235
+ const all = unfiledIn(snap, new Set(fresh.map((t) => t.tags?.project ?? "")));
236
+ for (const project of new Set(all.map((t) => t.tags?.project ?? ""))) {
237
+ const threads = all.filter((t) => (t.tags?.project ?? "") === project);
238
+ const key = `paths-new-${createHash("sha256").update(threads.map((t) => t.id).sort().join(",")).digest("hex").slice(0, 32)}`;
239
+ await hub.post("dash", newPathNotice(threads, snap), key).catch((err) => log?.warn?.(`dash: new-path notice failed: ${err?.message ?? err}`));
250
240
  }
251
- return fresh.length;
252
241
  }
253
242
 
254
- // One classifier pass. Returns { written }. Each entry carries the basis its
255
- // target was classified from, so a thread that moved on meanwhile is looked
256
- // at again next tick, and clears any old reason line. Importance a human set
257
- // (entry.importanceBy) is never sent. classifiedAt is stamped when there was
258
- // nothing to do or every target was written; a skipped target or a failed
259
- // write leaves it, so "never classified" and "checked" stay distinct.
260
- /** @param {any} hub @param {{ classify?: (targets: any[], snap: any) => Promise<any[]>, max?: number, snap?: any, log?: any }} [opts] */
261
- export async function runClassify(hub, { classify = async () => [], max = CLASSIFY_MAX, snap = null, log = console } = {}) {
243
+ // One classifier pass. Every target is decided at once, and each is written
244
+ // (with its "asks you" notice) the moment its answer is in, so a thread's
245
+ // entry never waits on another thread's call: the pass takes as long as its
246
+ // slowest call (decideThread's timeoutMs), whatever the backlog, and the
247
+ // thread whose turn just ended shows within its own call. Path picks are
248
+ // filed after the pass (`filing`, see filePaths), outside it. Each entry
249
+ // carries the basis its target was classified from, so a thread that moved
250
+ // on meanwhile is looked at again next pass, and clears any old reason line.
251
+ // Importance a human set (entry.importanceBy) is never sent. classifiedAt is
252
+ // stamped when there was nothing to do or every target was written; a
253
+ // skipped target or a failed write leaves it, so "never classified" and
254
+ // "checked" stay distinct. Returns { written, filing }: `filing` settles when
255
+ // the pass's path maintenance has (the schedule doesn't wait for it).
256
+ /** @param {any} hub @param {{ classify?: (target: any, snap: any) => Promise<any>, max?: number, snap?: any, log?: any }} [opts] */
257
+ export async function runClassify(hub, { classify = async () => null, max = CLASSIFY_MAX, snap = null, log = console } = {}) {
262
258
  const s = snap ?? (await hub.snapshot());
263
259
  const targets = classifyTargets(s, { max });
264
- const byId = new Map(targets.map((t) => [t.id, t]));
265
260
  const humans = new Set((s?.humans ?? []).map((h) => h.name));
266
- const decided = targets.length ? (await classify(targets, s)).filter((e) => e && byId.has(e.threadId)) : [];
267
- await filePaths(hub, decided, byId, s, log);
268
- const entries = decided.map(({ threadId, importance, delay }) => {
269
- const own = humans.has(byId.get(threadId).entry?.importanceBy);
270
- return { threadId, ...(own || importance === undefined ? {} : { importance }), delay, reason: null, basis: byId.get(threadId).basis };
271
- });
272
- const complete = new Set(entries.map((e) => e.threadId)).size === targets.length;
273
- if (entries.length) {
274
- const notice = asksNotice(entries, byId);
261
+ if (targets.length) log?.log?.(`dash: classifying ${targets.length} thread${targets.length === 1 ? "" : "s"}: ${targets.map((t) => t.id).join(", ")}`);
262
+ const picks = [];
263
+ const one = async (t) => {
264
+ const d = await classify(t, s);
265
+ if (!d) return 0;
266
+ if (d.path) picks.push([t, d.path]);
267
+ const own = humans.has(t.entry?.importanceBy);
268
+ const entry = { threadId: t.id, ...(own || d.importance === undefined ? {} : { importance: d.importance }), delay: d.delay, reason: null, basis: t.basis };
269
+ const notice = asksNotice(entry, t);
275
270
  try {
276
- await (notice ? hub.dashEntries(entries, [], notice) : hub.dashEntries(entries));
271
+ await (notice ? hub.dashEntries([entry], [], notice) : hub.dashEntries([entry]));
272
+ return 1;
277
273
  } catch (err) {
278
- log?.warn?.(`dash: classify write failed: ${err?.message ?? err}`);
279
- return { written: 0 };
274
+ log?.warn?.(`dash: classify write for ${t.id} failed: ${err?.message ?? err}`);
275
+ return 0;
276
+ }
277
+ };
278
+ const written = (await Promise.all(targets.map(one))).reduce((a, b) => a + b, 0);
279
+ if (written === targets.length) await hub.dashClassified?.().catch((err) => log?.warn?.(`dash: classifiedAt not stamped: ${err?.message ?? err}`));
280
+ return { written, filing: filePaths(hub, picks, log) };
281
+ }
282
+
283
+ // Path maintenance after a pass, never part of it: the pass is over when its
284
+ // entries are written and classifiedAt stamped, so the next one (a fresh
285
+ // question) never waits on a filing request. Files each path pick, then sends the pass's `new` answers.
286
+ // Never rejects; a failure is logged and the thread, still unfiled, is
287
+ // asked again. A pass that starts meanwhile may ask the same thread again:
288
+ // a second filing is refused (already filed), and the notice reads the hub's
289
+ // state when it's built, so it never names a thread filed meanwhile.
290
+ async function filePaths(hub, picks, log) {
291
+ await Promise.all(picks.filter(([, path]) => path !== NEW_PATH).map(([t, path]) => hub.fileThread?.(t.id, path).catch((err) => log?.warn?.(`dash: file ${t.id} into ${path} failed: ${err?.message ?? err}`))));
292
+ await noticeNewPaths(hub, picks.filter(([, path]) => path === NEW_PATH).map(([t]) => t), log);
293
+ }
294
+
295
+ // One pass at a time in this bot process, and none lost: the schedule runs
296
+ // its pass through this. The hub can't know when a pass ends (a dispatch it
297
+ // gave up on keeps running in eve; aborting the request doesn't stop it),
298
+ // so the process that runs passes is the one that serializes them. A
299
+ // dispatch that arrives during a pass returns at once and asks that pass for
300
+ // one more round when it ends, which reads a fresh snapshot. The flag lives
301
+ // on globalThis, which eve keeps across dispatches, and goes with the process.
302
+ const RUNNING = Symbol.for("hub.dash.classify.running");
303
+ /** @param {() => Promise<unknown>} pass */
304
+ export async function classifyPass(pass) {
305
+ const g = /** @type {any} */ (globalThis);
306
+ if (g[RUNNING]) {
307
+ g[RUNNING].again = true;
308
+ return;
309
+ }
310
+ const run = (g[RUNNING] = { again: true });
311
+ try {
312
+ while (run.again) {
313
+ run.again = false;
314
+ await pass();
280
315
  }
316
+ } finally {
317
+ g[RUNNING] = undefined;
281
318
  }
282
- if (complete) await hub.dashClassified?.().catch((err) => log?.warn?.(`dash: classifiedAt not stamped: ${err?.message ?? err}`));
283
- return { written: entries.length };
284
319
  }
@@ -24,7 +24,7 @@ import { said, lastSaid } from "./thread-brief.mjs";
24
24
  // approval a bot asked for). { threadId: "w:" + the pending request
25
25
  // ids }. A bot's `waiting` status alone isn't one: it also means
26
26
  // waiting on its own tasks. A thread that waits on the human in what was said is
27
- // the classifier's call (the Dash model's verdict, see askLines in
27
+ // the classifier's call (the Dash model's verdict, see asksNotice in
28
28
  // ./dash-classify.mjs), not this scan's. While a bot works there the
29
29
  // previous entry is kept, so a request made meanwhile posts once the
30
30
  // thread settles.
package/schedules/dash.js CHANGED
@@ -9,24 +9,14 @@
9
9
  // with a note of what was skipped. Every scan whose PRs came back stamps the
10
10
  // Dash's checkedAt, so the sidebar shows "stale" while GitHub is unreachable
11
11
  // (gh logged out, offline). The tick: runDash in ./dash-run.mjs; the
12
- // decisions: dashScan in ./dash-rules.mjs. After the scan, the classifier
13
- // (./dash-classify.mjs) gives each idle, non-deferred thread that moved on a
14
- // Dash entry (importance, delay) with openai/gpt-6-luna-decisions through
15
- // eve's `evaluate` (eve's gateway credentials); its failure never fails the tick.
12
+ // decisions: dashScan in ./dash-rules.mjs. The threads' Dash entries are
13
+ // main's classify schedule (./classify.ts).
16
14
  import { defineSchedule } from "eve/schedules";
17
15
  import { createHub } from "./hub.mjs";
18
16
  import { runDash } from "./dash-run.mjs";
19
- import { evaluate } from "eve/ai";
20
- import { modelClassify, runClassify } from "./dash-classify.mjs";
21
17
  export default defineSchedule({
22
18
  cron: "*/10 * * * *",
23
19
  async run() {
24
- const hub = createHub();
25
- try {
26
- await runDash(hub);
27
- }
28
- finally {
29
- await runClassify(hub, { classify: modelClassify({ evaluate }) }).catch((err) => console.warn(`dash: classify skipped: ${err?.message ?? err}`));
30
- }
20
+ await runDash(createHub());
31
21
  },
32
22
  });
@@ -1,19 +1,17 @@
1
- // The `artifact_list` tool: the artifacts this session owns (its state), or,
2
- // with all: true, every artifact the registry knows and who owns each.
1
+ // The `artifact_list` tool: the artifacts this session published, or, with
2
+ // all: true, every artifact the hub knows and who owns each.
3
3
  import { defineTool } from "eve/tools";
4
4
  import { z } from "zod";
5
5
  import { registryFetch } from "../registry-fetch.mjs";
6
- import { artifacts } from "../artifacts/state.js";
7
6
  const brief = (a) => ({ id: a.id, version: a.version, status: a.status, title: a.card?.title, alias: a.alias, updatedAt: a.updatedAt, ...(a.owner ? { owner: a.owner } : {}) });
8
7
  export default defineTool({
9
8
  description: "List the artifacts you own (id, version, status, stable URL). all: true lists every artifact in the workspace with its owner.",
10
9
  inputSchema: z.object({ all: z.boolean().optional().describe("Every artifact the hub knows, not just yours.") }),
11
- async execute({ all }) {
12
- if (!all)
13
- return { artifacts: Object.values(artifacts.get()).map(brief) };
14
- const res = await registryFetch(`/api/artifacts`).catch(() => null);
15
- if (!res?.ok)
16
- throw new Error(`the registry didn't answer (${res?.status ?? "unreachable"})`);
17
- return { artifacts: ((await res.json()).artifacts ?? []).map(brief) };
10
+ async execute({ all }, ctx) {
11
+ const res = await registryFetch(`/api/artifacts`);
12
+ if (!res.ok)
13
+ throw new Error(`GET /api/artifacts: ${(await res.json().catch(() => ({}))).error ?? res.status}`);
14
+ const every = ((await res.json()).artifacts ?? []);
15
+ return { artifacts: (all ? every : every.filter((a) => a.owner?.sessionId === ctx.session.id)).map(brief) };
18
16
  },
19
17
  });
@@ -1,23 +1,19 @@
1
- // The `artifact_publish` tool: builds and deploys the next version of an
2
- // artifact (artifacts/core.mjs) and moves its stable alias to it. The session
3
- // that publishes owns the artifact; the result carries the record, which the
4
- // registry folds into the thread's card and its artifact index.
1
+ // The `artifact_publish` tool: builds the next version of an artifact in this
2
+ // session's sandbox and has the hub deploy it and move its stable link
3
+ // (artifacts/core.mjs). The result carries the hub's record; the hub puts its
4
+ // card on this turn's reply.
5
5
  import { defineTool } from "eve/tools";
6
6
  import { z } from "zod";
7
- import { artifactId, latestRecord, publish, TYPES } from "../artifacts/core.mjs";
8
- import { artifacts } from "../artifacts/state.js";
7
+ import { publish, TYPES } from "../artifacts/core.mjs";
9
8
  export default defineTool({
10
- 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`, 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> }).",
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> }).",
11
10
  inputSchema: z.object({
12
11
  type: z.enum(Object.keys(TYPES)).describe("Artifact type."),
13
12
  key: z.string().regex(/^[\w.-]{1,40}$/).describe("Stable key within the type, e.g. the fixture id `thr_05a3` or `eve-pr-4342`. Same key = same artifact, next version."),
14
13
  source: z.record(z.string(), z.unknown()).describe("What the type builds from (see the description)."),
15
14
  }),
16
- async execute({ type, key, source }) {
17
- const id = artifactId(type, key);
18
- const prior = await latestRecord(id, artifacts.get());
19
- const record = await publish({ type, key, source }, prior);
20
- artifacts.update((all) => ({ ...all, [id]: record }));
21
- return { artifact: record, note: `published ${id} v${record.version}: ${record.alias}` };
15
+ async execute({ type, key, source }, ctx) {
16
+ const record = await publish({ type, key, source }, { sandbox: await ctx.getSandbox(), sessionId: ctx.session.id });
17
+ return { artifact: record, note: `published ${record.id} v${record.version}: ${record.alias}` };
22
18
  },
23
19
  });
@@ -1,22 +1,20 @@
1
1
  // The `artifact_status` tool: whether a published artifact still describes its
2
- // source (the type decides). Updates the record's status, so the card and the
3
- // registry's index show it too.
2
+ // source (the type decides, reading the repo in this session's sandbox). The
3
+ // hub applies the result to the version it checked, so the card shows it too.
4
4
  import { defineTool } from "eve/tools";
5
5
  import { z } from "zod";
6
- import { latestRecord, status } from "../artifacts/core.mjs";
7
- import { artifacts } from "../artifacts/state.js";
6
+ import { registryRecord, status } from "../artifacts/core.mjs";
8
7
  export default defineTool({
9
8
  description: "Check whether an artifact you published is still current: after you push, rebase or force-push, when its base or a stacked parent moves, and before asking for review. Returns fresh or stale with reasons; when stale, publish again with artifact_publish.",
10
9
  inputSchema: z.object({
11
10
  id: z.string().describe("Artifact id, `<type>:<key>`, e.g. change-explainer:thr_05a3 (artifact_list shows yours)."),
12
11
  }),
13
- async execute({ id }) {
14
- const record = await latestRecord(id, artifacts.get());
12
+ async execute({ id }, ctx) {
13
+ const record = await registryRecord(id);
15
14
  if (!record)
16
15
  throw new Error(`no artifact ${id}; artifact_list shows the ones this session owns`);
17
- const result = await status(record);
18
- const next = { ...record, status: result.status, checkedAt: new Date().toISOString() };
19
- artifacts.update((all) => ({ ...all, [id]: next }));
20
- return { artifact: next, status: result.status, reasons: result.reasons };
16
+ const result = await status(record, { sandbox: await ctx.getSandbox() });
17
+ const checked = { id: record.id, version: record.version, status: result.status, checkedAt: new Date().toISOString() };
18
+ return { artifact: checked, status: result.status, reasons: result.reasons };
21
19
  },
22
20
  });
@@ -1 +0,0 @@
1
- export declare const artifacts: any;
@@ -1,5 +0,0 @@
1
- // The owning session's artifact records, id -> latest record. Durable eve
2
- // session state: it survives restarts like the rest of the session.
3
- import { defineState } from "eve/context";
4
- import { STATE_NAME } from "./core.mjs";
5
- export const artifacts = defineState(STATE_NAME, () => ({}));
@@ -1,44 +0,0 @@
1
- // Hosted bots resolve model ids through the owner's refreshable Vercel sign-in.
2
- // HUB_TOKEN identifies a hosted bot; the credential endpoint is on its registry.
3
- // The hub owns expiration and refresh; Gateway rejection invalidates our cache.
4
- import { createHash } from "node:crypto";
5
- import { GatewayAuthenticationError } from "@ai-sdk/gateway";
6
- import { registryHeaders } from "./registry-fetch.mjs";
7
-
8
- /**
9
- * The fetch the gateway sends through: the hub's current token and team on
10
- * each request, and on a 401, one more try with the token the hub hands out
11
- * after it is told (by sha256) which token Gateway refused.
12
- */
13
- export function hubGatewayFetch(credentialUrl, fetchFn = (...a) => globalThis.fetch(...a), now = Date.now) {
14
- let current;
15
- const credential = async (rejected) => {
16
- if (!rejected && current && now() < current.expiresAt) return current;
17
- const headers = registryHeaders();
18
- if (rejected) headers["x-rejected-token-sha256"] = createHash("sha256").update(rejected).digest("hex");
19
- const response = await fetchFn(credentialUrl, { headers });
20
- if (response.status === 401) {
21
- const { error } = await response.json();
22
- throw new GatewayAuthenticationError({ message: error });
23
- }
24
- if (!response.ok) throw new Error(`model credential from the hub: HTTP ${response.status} ${await response.text()}`);
25
- return (current = await response.json());
26
- };
27
- return async (input, init) => {
28
- const headers = new Headers(init?.headers);
29
- const send = async (c) => {
30
- headers.set("authorization", `Bearer ${c.token}`);
31
- headers.set("x-vercel-ai-gateway-team", c.teamId);
32
- const response = await fetchFn(input, { ...init, headers });
33
- if (response.status === 401) current = undefined;
34
- return response;
35
- };
36
- const first = await credential();
37
- const response = await send(first);
38
- if (response.status !== 401) return response;
39
- // A streamed body can't be sent twice, but its rejected token is dropped.
40
- if (init?.body != null && typeof init.body !== "string") return response;
41
- await response.body?.cancel();
42
- return send(await credential(first.token));
43
- };
44
- }