eve-hub 0.14.6 → 0.14.8

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.8
4
+
5
+ ### Patch Changes
6
+
7
+ - `packKit` (the tarball the hub's tests and hosted workspaces' staging use) compiles the kit inside `packages/agent-kit/dist`, where a publish compiles it, instead of a temp dir. The compiler now resolves eve and the kit's other dependencies there too, so a declaration only a publish rejected (TS2883 in 0.14.5) fails the tests before merge.
8
+
9
+ ## 0.14.7
10
+
11
+ ### Patch Changes
12
+
13
+ - Bots see animated GIF motion as sampled frames.
14
+
3
15
  ## 0.14.6
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eve-hub",
3
- "version": "0.14.6",
3
+ "version": "0.14.8",
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 }) {