eve-hub 0.14.5 → 0.14.7

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 CHANGED
@@ -1,5 +1,17 @@
1
1
  # eve-hub
2
2
 
3
+ ## 0.14.7
4
+
5
+ ### Patch Changes
6
+
7
+ - Bots see animated GIF motion as sampled frames.
8
+
9
+ ## 0.14.6
10
+
11
+ ### Patch Changes
12
+
13
+ - New `fetch_upload` tool: a bot copies a file another bot shared as a `hub://upload/<id>` link into its sandbox (for example a patch to `git apply`), with the hub credential its kit tools already send. A shell can't fetch these links: the hosted hub's Deployment Protection sends it to a login page.
14
+
3
15
  ## 0.14.5
4
16
 
5
17
  ### Patch Changes
@@ -0,0 +1,185 @@
1
+ // A model sees only the first frame of an animated image: Anthropic, OpenAI
2
+ // and the rest read a GIF as one still. So every model call a bot makes swaps
3
+ // each inline animated image in its prompt (a message attachment, a read_file
4
+ // or view_image result, an image read_thread returns) for a header and up to
5
+ // MAX_FRAMES labeled stills sampled evenly across it, in time order.
6
+ //
7
+ // The swap happens on the Gateway request itself (installAnimatedFrames wraps
8
+ // global fetch, as model-timeout.mjs does), not by wrapping the model: a model
9
+ // object changes the id eve records for every step, while the request body is
10
+ // the AI SDK prompt verbatim. A body with no animated image goes through
11
+ // byte-identical.
12
+ //
13
+ // A decoder turns one media type into frames; video (mp4/mov) plugs in as a
14
+ // second entry in DECODERS.
15
+ import { createHash } from "node:crypto";
16
+ import { crc32, deflateSync } from "node:zlib";
17
+ import omggif from "omggif";
18
+
19
+ export const MAX_FRAMES = 8;
20
+
21
+ const INSTALLED = Symbol.for("hub.animatedFrames");
22
+ const GATEWAY_HOST = "ai-gateway.vercel.sh";
23
+
24
+ function pngChunk(type, data) {
25
+ const body = Buffer.concat([Buffer.from(type, "latin1"), data]);
26
+ const out = Buffer.alloc(body.length + 8);
27
+ out.writeUInt32BE(data.length, 0);
28
+ body.copy(out, 4);
29
+ out.writeUInt32BE(crc32(body), body.length + 4);
30
+ return out;
31
+ }
32
+
33
+ export function encodePng(width, height, rgba) {
34
+ const ihdr = Buffer.alloc(13);
35
+ ihdr.writeUInt32BE(width, 0);
36
+ ihdr.writeUInt32BE(height, 4);
37
+ ihdr.set([8, 6, 0, 0, 0], 8);
38
+ const stride = width * 4;
39
+ const raw = Buffer.alloc((stride + 1) * height);
40
+ for (let y = 0; y < height; y++) Buffer.from(rgba.buffer, rgba.byteOffset + y * stride, stride).copy(raw, y * (stride + 1) + 1);
41
+ return Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), pngChunk("IHDR", ihdr), pngChunk("IDAT", deflateSync(raw)), pngChunk("IEND", Buffer.alloc(0))]);
42
+ }
43
+
44
+ // `count` indices spread evenly over `total` frames, first and last included.
45
+ export function sampleIndices(total, count = MAX_FRAMES) {
46
+ if (total <= count) return Array.from({ length: total }, (_, i) => i);
47
+ return Array.from({ length: count }, (_, i) => Math.round((i * (total - 1)) / (count - 1)));
48
+ }
49
+
50
+ // Browsers play a delay of 0 or 1 hundredths as 10.
51
+ const delayMs = (centis) => (centis <= 1 ? 10 : centis) * 10;
52
+
53
+ // Anthropic rejects a request with more than 20 images once any is over
54
+ // 2000 px on a side, and the decode below runs inside fetch, synchronously:
55
+ // a GIF bigger than either bound goes to the provider as it is.
56
+ export const MAX_SIDE = 2000;
57
+ export const MAX_DECODED_PIXELS = 32_000_000;
58
+
59
+ // Up to `max` composited frames of an animated GIF, or null for a still or
60
+ // one over the bounds.
61
+ function sampleGif(bytes, max) {
62
+ const gif = new omggif.GifReader(bytes);
63
+ const total = gif.numFrames();
64
+ const { width, height } = gif;
65
+ if (total < 2 || width > MAX_SIDE || height > MAX_SIDE || width * height * total > MAX_DECODED_PIXELS) return null;
66
+ const wanted = new Set(sampleIndices(total, max));
67
+ const canvas = new Uint8Array(width * height * 4);
68
+ const frames = [];
69
+ let timeMs = 0;
70
+ for (let i = 0; i < total; i++) {
71
+ const info = gif.frameInfo(i);
72
+ const previous = info.disposal === 3 ? canvas.slice() : null;
73
+ gif.decodeAndBlitFrameRGBA(i, canvas);
74
+ if (wanted.has(i)) frames.push({ index: i, timeMs, png: encodePng(width, height, canvas) });
75
+ timeMs += delayMs(info.delay);
76
+ if (info.disposal === 2) {
77
+ for (let y = info.y; y < info.y + info.height; y++) canvas.fill(0, (y * width + info.x) * 4, (y * width + info.x + info.width) * 4);
78
+ } else if (previous) canvas.set(previous);
79
+ }
80
+ return { frameCount: total, durationMs: timeMs, frames };
81
+ }
82
+
83
+ // media type -> (bytes, max frames) => { frameCount, durationMs, frames: [{ index, timeMs, png }] } | null
84
+ export const DECODERS = { "image/gif": sampleGif };
85
+
86
+ const seconds = (ms) => `${(ms / 1000).toFixed(2)}s`;
87
+
88
+ // An image stays in a session's history, so every later model call carries it
89
+ // again; decoding a 1 MB GIF takes about 400 ms.
90
+ const recent = new Map();
91
+ const RECENT_MAX = 16;
92
+
93
+ function expandImage(mediaType, base64) {
94
+ if (!DECODERS[mediaType]) return null;
95
+ const key = createHash("sha256").update(mediaType).update("\0").update(base64).digest("hex");
96
+ if (recent.has(key)) return recent.get(key);
97
+ const parts = decodeImage(mediaType, base64);
98
+ recent.set(key, parts);
99
+ if (recent.size > RECENT_MAX) recent.delete(recent.keys().next().value);
100
+ return parts;
101
+ }
102
+
103
+ // The parts that replace one animated image: a header, then a label and a
104
+ // PNG per sampled frame. null when the image is a still or can't be decoded;
105
+ // then the provider gets the original and reads it as it always has.
106
+ function decodeImage(mediaType, base64) {
107
+ let sampled;
108
+ try {
109
+ sampled = DECODERS[mediaType](Buffer.from(base64, "base64"), MAX_FRAMES);
110
+ } catch {
111
+ return null;
112
+ }
113
+ if (!sampled) return null;
114
+ const { frameCount, durationMs, frames } = sampled;
115
+ const header = `Animated image (${mediaType}), ${frameCount} frames over ${seconds(durationMs)}. The ${frames.length} images below are frames sampled from this one animation, in time order; motion between the sampled frames is not shown.`;
116
+ return [
117
+ { type: "text", text: header },
118
+ ...frames.flatMap(({ index, timeMs, png }) => [
119
+ { type: "text", text: `Frame ${index + 1} of ${frameCount}, at ${seconds(timeMs)}` },
120
+ { type: "file", mediaType: "image/png", data: { type: "data", data: png.toString("base64") } },
121
+ ]),
122
+ ];
123
+ }
124
+
125
+ // `prompt` (AI SDK language model v4 messages) with each animated image in
126
+ // a user message or a tool result expanded, and how many were.
127
+ export function expandAnimatedImages(prompt) {
128
+ let expanded = 0;
129
+ const expandParts = (parts) =>
130
+ parts.flatMap((part) => {
131
+ const data = part?.type === "file" && part.data?.type === "data" && typeof part.data.data === "string" ? part.data.data : null;
132
+ const replacement = data === null ? null : expandImage(part.mediaType, data);
133
+ if (!replacement) return [part];
134
+ expanded++;
135
+ return replacement;
136
+ });
137
+ const out = prompt.map((message) => {
138
+ if (!Array.isArray(message?.content)) return message;
139
+ if (message.role === "user") return { ...message, content: expandParts(message.content) };
140
+ if (message.role !== "tool") return message;
141
+ return {
142
+ ...message,
143
+ content: message.content.map((part) =>
144
+ part?.type === "tool-result" && part.output?.type === "content" && Array.isArray(part.output.value)
145
+ ? { ...part, output: { ...part.output, value: expandParts(part.output.value) } }
146
+ : part,
147
+ ),
148
+ };
149
+ });
150
+ return { prompt: out, expanded };
151
+ }
152
+
153
+ // A Gateway language-model request body with its animated images expanded,
154
+ // or the same string when it has none.
155
+ export function expandRequestBody(body) {
156
+ if (!Object.keys(DECODERS).some((type) => body.includes(`"${type}"`))) return body;
157
+ const parsed = JSON.parse(body);
158
+ if (!Array.isArray(parsed.prompt)) return body;
159
+ const { prompt, expanded } = expandAnimatedImages(parsed.prompt);
160
+ return expanded === 0 ? body : JSON.stringify({ ...parsed, prompt });
161
+ }
162
+
163
+ function isLanguageModelCall(input) {
164
+ try {
165
+ const url = new URL(typeof input === "string" || input instanceof URL ? input : input.url);
166
+ return url.hostname === GATEWAY_HOST && url.pathname.endsWith("/language-model");
167
+ } catch {
168
+ return false;
169
+ }
170
+ }
171
+
172
+ export function withAnimatedFrames(baseFetch) {
173
+ return function fetchWithAnimatedFrames(input, init) {
174
+ if (typeof init?.body !== "string" || !isLanguageModelCall(input)) return baseFetch(input, init);
175
+ const body = expandRequestBody(init.body);
176
+ return baseFetch(input, body === init.body ? init : { ...init, body });
177
+ };
178
+ }
179
+
180
+ export function installAnimatedFrames() {
181
+ if (globalThis.fetch[INSTALLED]) return;
182
+ const wrapped = withAnimatedFrames(globalThis.fetch.bind(globalThis));
183
+ wrapped[INSTALLED] = true;
184
+ globalThis.fetch = wrapped;
185
+ }
package/kit-modules.json CHANGED
@@ -23,6 +23,7 @@
23
23
  "tools/create_thread",
24
24
  "tools/dash_update",
25
25
  "tools/disabled",
26
+ "tools/fetch_upload",
26
27
  "tools/list_agents",
27
28
  "tools/list_threads",
28
29
  "tools/move_path",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eve-hub",
3
- "version": "0.14.5",
3
+ "version": "0.14.7",
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",
@@ -32,15 +32,16 @@
32
32
  "./vercel": "./vercel.mjs"
33
33
  },
34
34
  "peerDependencies": {
35
- "eve": "*",
36
- "zod": "4.5.4",
37
35
  "@vercel/connect": "^2.4.1",
38
- "@vercel/oidc": "4.0.0"
36
+ "@vercel/oidc": "4.0.0",
37
+ "eve": "*",
38
+ "zod": "4.5.4"
39
39
  },
40
40
  "dependencies": {
41
41
  "ai": "^7.0.137",
42
42
  "jose": "^6.2.3",
43
43
  "jsonc-parser": "3.3.1",
44
+ "omggif": "1.0.10",
44
45
  "smol-toml": "^1.9.1",
45
46
  "tree-sitter-bash": "0.25.1",
46
47
  "web-tree-sitter": "0.27.1"
package/roster-model.mjs CHANGED
@@ -18,8 +18,10 @@ import { CONTEXT_WINDOW, selectModel } from "./bot-model.mjs";
18
18
  import { DEFAULT_SUBAGENT_CAP } from "./subagent-cap.mjs";
19
19
  import { sessionLimits } from "./session-limits.mjs";
20
20
  import { installModelCallTimeout } from "./model-timeout.mjs";
21
+ import { installAnimatedFrames } from "./animated-images.mjs";
21
22
  import { findWorkspaceRoot, loadWorkspace, parseWorkspace } from "./workspace.mjs";
22
23
  const real = process.env.ROSTER_MODELS !== "mock";
24
+ installAnimatedFrames();
23
25
  installModelCallTimeout();
24
26
 
25
27
  export function createRosterModel({ agents, subagentCap }) {
package/stubs.mjs CHANGED
@@ -10,6 +10,7 @@ export const KIT_FILES = {
10
10
  "tools/react.ts": ["React to a message with an emoji."],
11
11
  "tools/show.ts": ["Move the human's screen to a thread, DM, or message, or open a page or file beside the thread."],
12
12
  "tools/browser.ts": ["Its own headless browser, watched live in the Computer view."],
13
+ "tools/fetch_upload.ts": ["Copies a file another bot shared (hub://upload/<id>) into the sandbox."],
13
14
  "tools/view_image.ts": ["Shows the bot an image (a sandbox file or a browser screenshot) so it can see it."],
14
15
  "tools/update_thread.ts": ["Update a thread this bot is in: title, track, domain and summary."],
15
16
  "tools/list_threads.ts": ["Every workspace thread, from any session."],
@@ -57,7 +58,7 @@ export const DEPLOYED_KIT_FILES = new Set([
57
58
  "schedules/dash.ts",
58
59
  "skills/onboarding-profile.ts",
59
60
  "tools/onboarding_scripts.ts",
60
- ...["react", "show", "update_thread", "list_threads", "read_thread", "post_message"].map((t) => `tools/${t}.ts`),
61
+ ...["react", "show", "fetch_upload", "update_thread", "list_threads", "read_thread", "post_message"].map((t) => `tools/${t}.ts`),
61
62
  "memory/team.ts",
62
63
  "skills/forms.ts",
63
64
  ...["create_thread", "set_summary", "move_path", "archive_thread", "unarchive_thread", "list_agents", "create_agent", "remove_agent", "restart_agent", "tag_threads", "dash_update", "apply_tags"].map((t) => `tools/${t}.ts`),
@@ -0,0 +1,18 @@
1
+ declare const _default: import("eve/tools").ToolDefinition<{
2
+ uri: string;
3
+ path?: string | undefined;
4
+ }, {
5
+ path: string;
6
+ size: number;
7
+ mediaType: string | null;
8
+ }> & {
9
+ execute(input: {
10
+ uri: string;
11
+ path?: string | undefined;
12
+ }, ctx: import("eve/tools").ToolContext): Promise<{
13
+ path: string;
14
+ size: number;
15
+ mediaType: string | null;
16
+ }>;
17
+ };
18
+ export default _default;
@@ -0,0 +1,40 @@
1
+ // The `fetch_upload` tool: copies a file another bot shared (`hub://upload/<id>`, what
2
+ // `show(path)` returns on a hosted hub) into this bot's sandbox, e.g. a patch to `git apply`.
3
+ // A shell can't fetch it: the hosted hub's Deployment Protection and its auth take only the bot
4
+ // credential that registryFetch sends (registry-fetch.mjs).
5
+ import { defineTool } from "eve/tools";
6
+ import { z } from "zod";
7
+ import { registryFetch } from "../registry-fetch.mjs";
8
+ const UPLOAD = /^hub:\/\/upload\/([0-9a-f]{64}\.[a-z0-9]{1,10})$/;
9
+ export default defineTool({
10
+ description: "Fetch a file another bot shared as a hub://upload/<id> link (e.g. a patch, a log, a brief) into your sandbox, then read or apply it there. Your shell can't fetch these links; this tool sends your hub credential. Returns the path it wrote, the size in bytes and the media type.",
11
+ inputSchema: z.object({
12
+ uri: z.string().describe("The hub://upload/<id> link."),
13
+ path: z.string().optional().describe("Where to write it in your sandbox. Default /tmp/uploads/<id>."),
14
+ }),
15
+ async execute({ uri, path }, ctx) {
16
+ const id = UPLOAD.exec(uri.trim())?.[1];
17
+ if (!id)
18
+ throw new Error(`not a hub://upload/<id> link: ${uri}`);
19
+ // A redirect is Deployment Protection sending the request to a login page: an error, not the file.
20
+ const res = await registryFetch(`/api/uploads/${id}`, { redirect: "manual", signal: AbortSignal.timeout(30_000) });
21
+ if (!res.ok) {
22
+ const text = await res.text();
23
+ // Deployment Protection answers with a whole HTML page: name the redirect, or keep the body short.
24
+ const error = (() => { try {
25
+ return JSON.parse(text).error;
26
+ }
27
+ catch {
28
+ return undefined;
29
+ } })();
30
+ const location = res.headers.get("location");
31
+ const why = error ?? (location ? `redirected to ${location}` : text.slice(0, 200));
32
+ throw new Error(`fetch_upload failed: ${res.status} ${why}`.trim());
33
+ }
34
+ const bytes = new Uint8Array(await res.arrayBuffer());
35
+ const sandbox = await ctx.getSandbox();
36
+ const target = path ?? `/tmp/uploads/${id}`;
37
+ await sandbox.writeBinaryFile({ path: target, content: bytes });
38
+ return { path: sandbox.resolvePath(target), size: bytes.length, mediaType: res.headers.get("content-type") };
39
+ },
40
+ });