eve-hub 0.14.8 → 0.14.10

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.10
4
+
5
+ ### Patch Changes
6
+
7
+ - Image sizes: `displaySize` gives an image's shown size, after a JPEG's EXIF orientation.
8
+
9
+ ## 0.14.9
10
+
11
+ ### Patch Changes
12
+
13
+ - `post_message` finds links with a markdown parser instead of a regex. A sandbox path in code (a fence or an inline span) stays as written, a label with brackets (nested or escaped) keeps its link, a `#…` fragment stays on the rewritten link, a `%20` in the path is decoded, and a reference definition's target is rewritten too. The 700-character cap and the `![]()` refusal read the same links, so an image written inside code is no longer refused.
14
+
3
15
  ## 0.14.8
4
16
 
5
17
  ### Patch Changes
package/hub-upload.d.ts CHANGED
@@ -11,7 +11,7 @@ export type Uploaded = {
11
11
  };
12
12
  export declare const hostedHub: () => boolean;
13
13
  export declare const IMAGE_FILE: RegExp;
14
- /** A markdown link target that names a sandbox file: an absolute path, or one under the home directory. */
14
+ /** A markdown link target that names a sandbox file: an absolute path (not a `//host` one), or one under the home directory. */
15
15
  export declare const isSandboxPath: (target: string) => boolean;
16
16
  export declare function readSandboxFile(sandbox: SandboxFiles, path: string): Promise<Uint8Array>;
17
17
  export declare function uploadBytes(bytes: Uint8Array, name: string, what: string): Promise<Uploaded>;
package/hub-upload.js CHANGED
@@ -8,8 +8,8 @@ import { pathToFileURL } from "node:url";
8
8
  import { registryFetch } from "./registry-fetch.mjs";
9
9
  export const hostedHub = () => Boolean(process.env.VERCEL || process.env.HUB_TOKEN);
10
10
  export const IMAGE_FILE = /\.(png|jpe?g|gif|webp|heic)$/i;
11
- /** A markdown link target that names a sandbox file: an absolute path, or one under the home directory. */
12
- export const isSandboxPath = (target) => /^(\/|\$HOME\/|~\/)/.test(target);
11
+ /** A markdown link target that names a sandbox file: an absolute path (not a `//host` one), or one under the home directory. */
12
+ export const isSandboxPath = (target) => /^(\/(?!\/)|\$HOME\/|~\/)/.test(target);
13
13
  // The sandbox resolves `~/`; `$HOME/` is the same place spelled as the shell does.
14
14
  const sandboxPath = (path) => path.replace(/^\$HOME\//, "~/");
15
15
  export async function readSandboxFile(sandbox, path) {
@@ -2,4 +2,5 @@ export const MAX_BYTES: number;
2
2
  export const MODEL_MAX_EDGE: number;
3
3
  export function sniffImage(bytes: Uint8Array): string | null;
4
4
  export function imageSize(bytes: Uint8Array, mediaType: string): { width: number; height: number } | null;
5
+ export function displaySize(bytes: Uint8Array, mediaType: string): { width: number; height: number } | null;
5
6
  export function modelUnsafe(bytes: Uint8Array): string | null;
package/image-limits.mjs CHANGED
@@ -45,6 +45,41 @@ export function imageSize(bytes, mediaType) {
45
45
  return null;
46
46
  }
47
47
 
48
+ // The size the image is shown at: a JPEG whose EXIF orientation turns it a quarter (5 to 8) is drawn with its sides swapped, as
49
+ // AppKit and browsers draw it. imageSize is the stored frame, which is what a model provider's limits apply to.
50
+ export function displaySize(bytes, mediaType) {
51
+ const size = imageSize(bytes, mediaType);
52
+ if (!size || mediaType !== "image/jpeg") return size;
53
+ const o = jpegOrientation(bytes);
54
+ return o >= 5 && o <= 8 ? { width: size.height, height: size.width } : size;
55
+ }
56
+
57
+ // The EXIF orientation (1 to 8) in a JPEG's APP1 segment; 1 when it has none.
58
+ function jpegOrientation(bytes) {
59
+ const v = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
60
+ let i = 2;
61
+ while (i + 4 <= bytes.length && bytes[i] === 0xff) {
62
+ const marker = bytes[i + 1];
63
+ if (marker === 0xda || marker === 0xd9) return 1;
64
+ const len = v.getUint16(i + 2);
65
+ const start = i + 4, end = Math.min(i + 2 + len, bytes.length);
66
+ if (marker === 0xe1 && end - start >= 14 && String.fromCharCode(...bytes.subarray(start, start + 4)) === "Exif" && bytes[start + 4] === 0 && bytes[start + 5] === 0) {
67
+ const tiff = start + 6, little = bytes[tiff] === 0x49;
68
+ const u16 = (o) => v.getUint16(o, little), u32 = (o) => v.getUint32(o, little);
69
+ const ifd = tiff + u32(tiff + 4);
70
+ if (ifd + 2 > end) return 1;
71
+ const n = u16(ifd);
72
+ for (let e = 0; e < n && ifd + 2 + e * 12 + 12 <= end; e++) {
73
+ const at = ifd + 2 + e * 12;
74
+ if (u16(at) === 0x0112) return u16(at + 8);
75
+ }
76
+ return 1;
77
+ }
78
+ i += 2 + len;
79
+ }
80
+ return 1;
81
+ }
82
+
48
83
  // Why a model provider would reject these bytes as an image part, or null if
49
84
  // it takes them: an unknown format, a size we can't read, a side over 8000px,
50
85
  // or more than MAX_BYTES. Checked before any file part is emitted.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eve-hub",
3
- "version": "0.14.8",
3
+ "version": "0.14.10",
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",
@@ -41,6 +41,7 @@
41
41
  "ai": "^7.0.137",
42
42
  "jose": "^6.2.3",
43
43
  "jsonc-parser": "3.3.1",
44
+ "mdast-util-from-markdown": "^2.1.0",
44
45
  "omggif": "1.0.10",
45
46
  "smol-toml": "^1.9.1",
46
47
  "tree-sitter-bash": "0.25.1",
@@ -3,7 +3,7 @@ export declare const MAX_CHARS = 700;
3
3
  export declare const postLength: (text: string) => number;
4
4
  /** The error for a text the thread shouldn't carry, or null. */
5
5
  export declare function textError(text: string): string | null;
6
- /** The text with each sandbox-path link target replaced by where the human's app opens it. */
6
+ /** The text with each sandbox-path link target replaced by where the human's app opens it; a `#…` fragment stays on it. Each file is opened once. */
7
7
  export declare function rewriteDocLinks(text: string, open: (path: string) => Promise<string>): Promise<string>;
8
8
  declare const _default: import("eve/tools").ToolDefinition<{
9
9
  threadId?: string | undefined;
@@ -1,4 +1,5 @@
1
1
  import { defineTool } from "eve/tools";
2
+ import { fromMarkdown } from "mdast-util-from-markdown";
2
3
  import { z } from "zod";
3
4
  import { documentUrl, hostedHub, isSandboxPath, uploadBytes, uploadFile } from "../hub-upload.js";
4
5
  import { registryFetch } from "../registry-fetch.mjs";
@@ -23,30 +24,64 @@ import { registryFetch } from "../registry-fetch.mjs";
23
24
  // move), the text is its summary, `metadata` its PR and sha. The hub keeps the
24
25
  // latest one per bot and thread for the workspace state and the Dash.
25
26
  export const MAX_CHARS = 700;
26
- const LINK = /\[([^\]]*)\]\(([^)\s]+)\)/g;
27
- const IMAGE_SYNTAX = /!\[[^\]]*\]\([^)]*\)/;
28
27
  const SCREENSHOT = "screenshot";
28
+ // The markdown parser finds the links and where each destination starts, so a path in code is never read as a
29
+ // link and brackets in a label (nested or escaped) never cut it.
30
+ function destinations(text) {
31
+ const spans = new Map();
32
+ function enter(token) {
33
+ spans.set(this.stack.at(-1), token);
34
+ }
35
+ const tree = fromMarkdown(text, { mdastExtensions: [{ enter: { resource: enter, definitionDestination: enter } }] });
36
+ const found = [];
37
+ const visit = (node) => {
38
+ const span = spans.get(node);
39
+ if (span && node.url !== undefined && (node.type === "link" || node.type === "image" || node.type === "definition")) {
40
+ const to = node.type === "definition" ? node.position.end.offset : span.end.offset;
41
+ found.push({ kind: node.type, from: span.start.offset, to, url: node.url, title: node.title });
42
+ }
43
+ node.children?.forEach(visit);
44
+ };
45
+ visit(tree);
46
+ return found;
47
+ }
29
48
  /** The characters the cap counts: the text without its links' targets. */
30
- export const postLength = (text) => text.replace(LINK, (_, label) => `[${label}]()`).length;
49
+ export const postLength = (text) => destinations(text).reduce((n, d) => n - (d.to - d.from) + (d.kind === "definition" ? 0 : "()".length), text.length);
31
50
  /** The error for a text the thread shouldn't carry, or null. */
32
51
  export function textError(text) {
33
- if (IMAGE_SYNTAX.test(text))
52
+ if (destinations(text).some((d) => d.kind === "image"))
34
53
  return "Images don't go in the text: pass the file's sandbox path in `images` and it shows inline";
35
54
  if (postLength(text) > MAX_CHARS)
36
55
  return "Too long: move the detail into a doc and link it";
37
56
  return null;
38
57
  }
39
- /** The text with each sandbox-path link target replaced by where the human's app opens it. */
58
+ const PATH_AND_FRAGMENT = /^([^?#]*)(?:\?[^#]*)?(#.*)?$/;
59
+ /** The text with each sandbox-path link target replaced by where the human's app opens it; a `#…` fragment stays on it. Each file is opened once. */
40
60
  export async function rewriteDocLinks(text, open) {
41
- const links = [...text.matchAll(LINK)].filter((m) => isSandboxPath(m[2]));
42
- const urls = await Promise.all(links.map((m) => open(m[2])));
43
- let out = "", at = 0;
44
- links.forEach((m, i) => {
45
- const start = m.index + m[0].length - m[2].length - 1;
46
- out += text.slice(at, start) + urls[i];
47
- at = start + m[2].length;
61
+ const links = destinations(text).flatMap((d) => {
62
+ const [, path, fragment = ""] = PATH_AND_FRAGMENT.exec(d.url);
63
+ return d.kind !== "image" && isSandboxPath(path) ? [{ ...d, file: decode(path), fragment }] : [];
48
64
  });
49
- return out + text.slice(at);
65
+ if (!links.length)
66
+ return text;
67
+ const files = [...new Set(links.map((l) => l.file))];
68
+ const opened = new Map(await Promise.all(files.map(async (file) => [file, await open(file)])));
69
+ let out = text;
70
+ for (const l of links.sort((a, b) => b.from - a.from)) {
71
+ const url = `${opened.get(l.file)}${l.fragment}`;
72
+ const target = /[\s()<>]/.test(url) ? `<${url}>` : url;
73
+ const title = l.title == null ? "" : ` "${l.title.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
74
+ out = `${out.slice(0, l.from)}${l.kind === "definition" ? `${target}${title}` : `(${target}${title})`}${out.slice(l.to)}`;
75
+ }
76
+ return out;
77
+ }
78
+ function decode(path) {
79
+ try {
80
+ return decodeURIComponent(path);
81
+ }
82
+ catch {
83
+ return path;
84
+ }
50
85
  }
51
86
  async function screenshot(sessionId) {
52
87
  if (hostedHub())