@sylphx/image-reader-mcp 0.1.2 → 0.1.5

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/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  ### Image evidence for agents
6
6
 
7
- **Iris** (transitional package `@sylphx/image-reader-mcp`) — local-first image facts, not default VLM guesses.
7
+ **Iris** (transitional package `@sylphx/image-reader-mcp`) — **Rust-first** local image facts, not default VLM guesses. `sharp`/`exifr` are optional, not required for MCP.
8
8
 
9
9
  <p align="center">
10
10
  <img src="https://mark.sylphx.com/api/v1/banner?type=holo&theme=tokyonight&text=image+reader+mcp&desc=Evidence-first+image+reading+for+AI+agents+%E2%80%94+metadata%2C+OCR+text%2C+regions%2C+and+ci&height=200&animation=rise&credit=0" alt="image-reader-mcp — Sylphx Mark banner" width="100%" />
@@ -43,6 +43,8 @@ Iris is **local-first**: geometry + OCR + **layout blocks** + **agent_map** so a
43
43
 
44
44
  Spec: [docs/specs/agent-image-read-contract.md](docs/specs/agent-image-read-contract.md)
45
45
 
46
+ **Local-first frontier:** Rust decode, Tesseract native layout (no npm ML), optional Ollama VLM; cloud URL optional. Zero API key.
47
+
46
48
  ## Product docs
47
49
 
48
50
  | Doc | Purpose |
@@ -76,7 +78,7 @@ an image, not a creative caption.**
76
78
  | Paraphrased OCR | Optional Tesseract lines with bounding boxes and confidence |
77
79
  | GPS and EXIF leak into context | GPS redacted; trust warnings for suspicious metadata |
78
80
  | No provenance | Agent Media Twin JSON with measurable, citeable fields |
79
- | Cloud API by default | **Local-first** — sharp + exifr on your machine |
81
+ | Cloud API by default | **Local-first** — Rust decode + optional Tesseract OCR; sharp/exifr only optional fallbacks |
80
82
  | Ship and pray | **23** unit tests on schema, metadata, OCR hooks, safety limits, doctor, and release gate |
81
83
 
82
84
  ## See it work
@@ -139,7 +141,7 @@ Abbreviated shape — optional OCR skips gracefully when Tesseract is not instal
139
141
  | --- | --- |
140
142
  | `read_image` | Read a local image and return dimensions, mime, metadata, optional OCR, and trust warnings. |
141
143
 
142
- Supported formats: PNG, JPEG, GIF, WebP, TIFF, and other formats sharp can decode.
144
+ Supported formats: PNG, JPEG, GIF, WebP, TIFF, and other formats the **Rust decode engine** supports (optional sharp covers additional formats when installed).
143
145
 
144
146
  ## Quick Start
145
147
 
@@ -1,25 +1,96 @@
1
1
  #!/usr/bin/env bash
2
- # Image Reader MCP launcher — Rust rmcp only (fail-closed, no TS stdio fallback).
2
+ # Iris MCP launcher — Rust rmcp only (fail-closed).
3
+ # Resolves native via optionalDependencies, staged bin/native, or cargo target.
3
4
  set -euo pipefail
4
5
 
5
- ROOT="$(cd "$(dirname "$0")/.." && pwd)"
6
+ PACKAGE_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
7
+
8
+ host_platform_key() {
9
+ local os arch
10
+ os="$(uname -s 2>/dev/null || echo unknown)"
11
+ arch="$(uname -m 2>/dev/null || echo unknown)"
12
+ case "$os" in
13
+ Darwin) os="darwin" ;;
14
+ Linux) os="linux" ;;
15
+ *) os="$(printf '%s' "$os" | tr '[:upper:]' '[:lower:]')" ;;
16
+ esac
17
+ case "$arch" in
18
+ x86_64|amd64) arch="x64" ;;
19
+ aarch64|arm64) arch="arm64" ;;
20
+ esac
21
+ printf '%s-%s\n' "$os" "$arch"
22
+ }
23
+
24
+ platform_package_name() {
25
+ case "$(host_platform_key)" in
26
+ darwin-arm64) printf '%s\n' '@sylphx/image-reader-mcp-darwin-arm64' ;;
27
+ darwin-x64) printf '%s\n' '@sylphx/image-reader-mcp-darwin-x64' ;;
28
+ linux-x64) printf '%s\n' '@sylphx/image-reader-mcp-linux-x64-gnu' ;;
29
+ linux-arm64) printf '%s\n' '@sylphx/image-reader-mcp-linux-arm64-gnu' ;;
30
+ *) return 1 ;;
31
+ esac
32
+ }
33
+
34
+ is_runnable_native() {
35
+ local candidate="$1"
36
+ [[ -n "$candidate" && -f "$candidate" && -x "$candidate" ]] || return 1
37
+ return 0
38
+ }
39
+
40
+ resolve_from_optional_dep() {
41
+ local pkg_name candidate
42
+ pkg_name="$(platform_package_name 2>/dev/null || true)"
43
+ [[ -n "$pkg_name" ]] || return 1
44
+ local search_roots=(
45
+ "$PACKAGE_ROOT/node_modules"
46
+ "$PACKAGE_ROOT/../node_modules"
47
+ "$PACKAGE_ROOT/../../node_modules"
48
+ )
49
+ for root in "${search_roots[@]}"; do
50
+ candidate="$root/$pkg_name/image-reader-mcp-server"
51
+ if is_runnable_native "$candidate"; then
52
+ printf '%s\n' "$candidate"
53
+ return 0
54
+ fi
55
+ done
56
+ if command -v node >/dev/null 2>&1; then
57
+ candidate="$(
58
+ node -e "
59
+ try {
60
+ const p = require('node:path');
61
+ const { createRequire } = require('node:module');
62
+ const r = createRequire(p.join(process.argv[1], 'package.json'));
63
+ const pkg = r.resolve(process.argv[2] + '/package.json');
64
+ process.stdout.write(p.join(p.dirname(pkg), 'image-reader-mcp-server'));
65
+ } catch { process.exit(1); }
66
+ " "$PACKAGE_ROOT" "$pkg_name" 2>/dev/null || true
67
+ )"
68
+ if is_runnable_native "${candidate:-}"; then
69
+ printf '%s\n' "$candidate"
70
+ return 0
71
+ fi
72
+ fi
73
+ return 1
74
+ }
6
75
 
7
76
  resolve_rust_bin() {
8
77
  if [[ -n "${IMAGE_READER_MCP_RUST_BIN:-}" && -x "${IMAGE_READER_MCP_RUST_BIN}" ]]; then
9
78
  printf '%s\n' "${IMAGE_READER_MCP_RUST_BIN}"
10
79
  return 0
11
80
  fi
12
-
81
+ if bin="$(resolve_from_optional_dep)"; then
82
+ printf '%s\n' "$bin"
83
+ return 0
84
+ fi
13
85
  for candidate in \
14
- "$ROOT/bin/native/image-reader-mcp-server" \
15
- "$ROOT/target/release/image-reader-mcp-server" \
16
- "$ROOT/target/debug/image-reader-mcp-server"; do
17
- if [[ -x "$candidate" ]]; then
86
+ "$PACKAGE_ROOT/bin/native/image-reader-mcp-server" \
87
+ "$PACKAGE_ROOT/target/release/image-reader-mcp-server" \
88
+ "$PACKAGE_ROOT/target/debug/image-reader-mcp-server"; do
89
+ if is_runnable_native "$candidate"; then
18
90
  printf '%s\n' "$candidate"
19
91
  return 0
20
92
  fi
21
93
  done
22
-
23
94
  return 1
24
95
  }
25
96
 
@@ -44,5 +115,5 @@ if bin="$(resolve_rust_bin)"; then
44
115
  exec "$bin" "$@"
45
116
  fi
46
117
 
47
- echo "[image-reader-mcp] Rust MCP server (rmcp) is not built. Run: bun run build:rust" >&2
118
+ echo "[iris] Rust MCP server not found. Install matching optional native package (@sylphx/image-reader-mcp-<platform>) or run: bun run build:rust" >&2
48
119
  exit 1
@@ -118,15 +118,15 @@ var require_filesystem = __commonJS((exports, module) => {
118
118
  var LDD_PATH = "/usr/bin/ldd";
119
119
  var SELF_PATH = "/proc/self/exe";
120
120
  var MAX_LENGTH = 2048;
121
- var readFileSync = (path) => {
122
- const fd = fs.openSync(path, "r");
121
+ var readFileSync = (path2) => {
122
+ const fd = fs.openSync(path2, "r");
123
123
  const buffer = Buffer.alloc(MAX_LENGTH);
124
124
  const bytesRead = fs.readSync(fd, buffer, 0, MAX_LENGTH, 0);
125
125
  fs.close(fd, () => {});
126
126
  return buffer.subarray(0, bytesRead);
127
127
  };
128
- var readFile = (path) => new Promise((resolve, reject) => {
129
- fs.open(path, "r", (err, fd) => {
128
+ var readFile = (path2) => new Promise((resolve, reject) => {
129
+ fs.open(path2, "r", (err, fd) => {
130
130
  if (err) {
131
131
  reject(err);
132
132
  } else {
@@ -238,11 +238,11 @@ var require_detect_libc = __commonJS((exports, module) => {
238
238
  }
239
239
  return null;
240
240
  };
241
- var familyFromInterpreterPath = (path) => {
242
- if (path) {
243
- if (path.includes("/ld-musl-")) {
241
+ var familyFromInterpreterPath = (path2) => {
242
+ if (path2) {
243
+ if (path2.includes("/ld-musl-")) {
244
244
  return MUSL;
245
- } else if (path.includes("/ld-linux-")) {
245
+ } else if (path2.includes("/ld-linux-")) {
246
246
  return GLIBC;
247
247
  }
248
248
  }
@@ -287,8 +287,8 @@ var require_detect_libc = __commonJS((exports, module) => {
287
287
  cachedFamilyInterpreter = null;
288
288
  try {
289
289
  const selfContent = await readFile(SELF_PATH);
290
- const path = interpreterPath(selfContent);
291
- cachedFamilyInterpreter = familyFromInterpreterPath(path);
290
+ const path2 = interpreterPath(selfContent);
291
+ cachedFamilyInterpreter = familyFromInterpreterPath(path2);
292
292
  } catch (e) {}
293
293
  return cachedFamilyInterpreter;
294
294
  };
@@ -299,8 +299,8 @@ var require_detect_libc = __commonJS((exports, module) => {
299
299
  cachedFamilyInterpreter = null;
300
300
  try {
301
301
  const selfContent = readFileSync(SELF_PATH);
302
- const path = interpreterPath(selfContent);
303
- cachedFamilyInterpreter = familyFromInterpreterPath(path);
302
+ const path2 = interpreterPath(selfContent);
303
+ cachedFamilyInterpreter = familyFromInterpreterPath(path2);
304
304
  } catch (e) {}
305
305
  return cachedFamilyInterpreter;
306
306
  };
@@ -1777,7 +1777,7 @@ var require_libvips = __commonJS((exports, module) => {
1777
1777
  Copyright 2013 Lovell Fuller and others.
1778
1778
  SPDX-License-Identifier: Apache-2.0
1779
1779
  */
1780
- var { spawnSync } = __require("node:child_process");
1780
+ var { spawnSync: spawnSync2 } = __require("node:child_process");
1781
1781
  var { createHash } = __require("node:crypto");
1782
1782
  var semverCoerce = require_coerce();
1783
1783
  var semverGreaterThanOrEqualTo = require_gte();
@@ -1861,7 +1861,7 @@ var require_libvips = __commonJS((exports, module) => {
1861
1861
  };
1862
1862
  var isRosetta = () => {
1863
1863
  if (process.platform === "darwin" && process.arch === "x64") {
1864
- const translated = spawnSync("sysctl sysctl.proc_translated", spawnSyncOptions).stdout;
1864
+ const translated = spawnSync2("sysctl sysctl.proc_translated", spawnSyncOptions).stdout;
1865
1865
  return (translated || "").trim() === "sysctl.proc_translated: 1";
1866
1866
  }
1867
1867
  return false;
@@ -1877,13 +1877,13 @@ var require_libvips = __commonJS((exports, module) => {
1877
1877
  } catch {}
1878
1878
  return "";
1879
1879
  };
1880
- var spawnRebuild = () => spawnSync(`node-gyp rebuild --directory=src ${isEmscripten() ? "--nodedir=emscripten" : ""}`, {
1880
+ var spawnRebuild = () => spawnSync2(`node-gyp rebuild --directory=src ${isEmscripten() ? "--nodedir=emscripten" : ""}`, {
1881
1881
  ...spawnSyncOptions,
1882
1882
  stdio: "inherit"
1883
1883
  }).status;
1884
1884
  var globalLibvipsVersion = () => {
1885
1885
  if (process.platform !== "win32") {
1886
- const globalLibvipsVersion2 = spawnSync("pkg-config --modversion vips-cpp", {
1886
+ const globalLibvipsVersion2 = spawnSync2("pkg-config --modversion vips-cpp", {
1887
1887
  ...spawnSyncOptions,
1888
1888
  env: {
1889
1889
  ...process.env,
@@ -1897,7 +1897,7 @@ var require_libvips = __commonJS((exports, module) => {
1897
1897
  };
1898
1898
  var pkgConfigPath = () => {
1899
1899
  if (process.platform !== "win32") {
1900
- const brewPkgConfigPath = spawnSync('which brew >/dev/null 2>&1 && brew environment --plain | grep PKG_CONFIG_LIBDIR | cut -d" " -f2', spawnSyncOptions).stdout || "";
1900
+ const brewPkgConfigPath = spawnSync2('which brew >/dev/null 2>&1 && brew environment --plain | grep PKG_CONFIG_LIBDIR | cut -d" " -f2', spawnSyncOptions).stdout || "";
1901
1901
  return [
1902
1902
  brewPkgConfigPath.trim(),
1903
1903
  process.env.PKG_CONFIG_PATH,
@@ -1962,18 +1962,18 @@ var require_sharp = __commonJS((exports, module) => {
1962
1962
  `@img/sharp-${runtimePlatform}/sharp.node`,
1963
1963
  "@img/sharp-wasm32/sharp.node"
1964
1964
  ];
1965
- var path;
1965
+ var path2;
1966
1966
  var sharp;
1967
1967
  var errors = [];
1968
- for (path of paths) {
1968
+ for (path2 of paths) {
1969
1969
  try {
1970
- sharp = __require(path);
1970
+ sharp = __require(path2);
1971
1971
  break;
1972
1972
  } catch (err) {
1973
1973
  errors.push(err);
1974
1974
  }
1975
1975
  }
1976
- if (sharp && path.startsWith("@img/sharp-linux-x64") && !sharp._isUsingX64V2()) {
1976
+ if (sharp && path2.startsWith("@img/sharp-linux-x64") && !sharp._isUsingX64V2()) {
1977
1977
  const err = new Error("Prebuilt binaries for linux-x64 require v2 microarchitecture");
1978
1978
  err.code = "Unsupported CPU";
1979
1979
  errors.push(err);
@@ -4835,15 +4835,15 @@ var require_color = __commonJS((exports, module) => {
4835
4835
  };
4836
4836
  }
4837
4837
  function wrapConversion(toModel, graph) {
4838
- const path = [graph[toModel].parent, toModel];
4838
+ const path2 = [graph[toModel].parent, toModel];
4839
4839
  let fn = conversions_default[graph[toModel].parent][toModel];
4840
4840
  let cur = graph[toModel].parent;
4841
4841
  while (graph[cur].parent) {
4842
- path.unshift(graph[cur].parent);
4842
+ path2.unshift(graph[cur].parent);
4843
4843
  fn = link(conversions_default[graph[cur].parent][cur], fn);
4844
4844
  cur = graph[cur].parent;
4845
4845
  }
4846
- fn.conversion = path;
4846
+ fn.conversion = path2;
4847
4847
  return fn;
4848
4848
  }
4849
4849
  function route(fromModel) {
@@ -5448,7 +5448,7 @@ var require_output = __commonJS((exports, module) => {
5448
5448
  Copyright 2013 Lovell Fuller and others.
5449
5449
  SPDX-License-Identifier: Apache-2.0
5450
5450
  */
5451
- var path = __require("node:path");
5451
+ var path2 = __require("node:path");
5452
5452
  var is = require_is();
5453
5453
  var sharp = require_sharp();
5454
5454
  var formats = new Map([
@@ -5479,9 +5479,9 @@ var require_output = __commonJS((exports, module) => {
5479
5479
  let err;
5480
5480
  if (!is.string(fileOut)) {
5481
5481
  err = new Error("Missing output file path");
5482
- } else if (is.string(this.options.input.file) && path.resolve(this.options.input.file) === path.resolve(fileOut)) {
5482
+ } else if (is.string(this.options.input.file) && path2.resolve(this.options.input.file) === path2.resolve(fileOut)) {
5483
5483
  err = new Error("Cannot use same file for input and output");
5484
- } else if (jp2Regex.test(path.extname(fileOut)) && !this.constructor.format.jp2k.output.file) {
5484
+ } else if (jp2Regex.test(path2.extname(fileOut)) && !this.constructor.format.jp2k.output.file) {
5485
5485
  err = errJp2Save();
5486
5486
  }
5487
5487
  if (err) {
@@ -6453,7 +6453,6 @@ var require_lib = __commonJS((exports, module) => {
6453
6453
  import { createRequire as createRequire2 } from "node:module";
6454
6454
 
6455
6455
  // src/doctor.ts
6456
- var import_sharp = __toESM(require_lib(), 1);
6457
6456
  import { spawnSync as spawnSync2 } from "node:child_process";
6458
6457
  import { existsSync as existsSync2 } from "node:fs";
6459
6458
  import path2 from "node:path";
@@ -6501,9 +6500,11 @@ var listTesseractLanguages = () => {
6501
6500
  }
6502
6501
  const result = spawnSync("tesseract", ["--list-langs"], {
6503
6502
  encoding: "utf8",
6504
- timeout: 5000,
6503
+ timeout: OCR_HEALTHCHECK_TIMEOUT_MS,
6505
6504
  windowsHide: true
6506
6505
  });
6506
+ const stdout = typeof result.stdout === "string" ? result.stdout : "";
6507
+ const languages = stdout.split(/\r?\n/).map((l) => l.trim()).filter((l) => l && !l.toLowerCase().includes("list of available"));
6507
6508
  if (result.status !== 0) {
6508
6509
  const stderr = typeof result.stderr === "string" ? result.stderr.trim() : "";
6509
6510
  return {
@@ -6512,9 +6513,6 @@ var listTesseractLanguages = () => {
6512
6513
  warning: stderr || "tesseract --list-langs failed"
6513
6514
  };
6514
6515
  }
6515
- const out = `${result.stdout ?? ""}
6516
- ${result.stderr ?? ""}`;
6517
- const languages = out.split(/\r?\n/).map((l) => l.trim()).filter((l) => l && !l.toLowerCase().includes("list of available languages") && !l.startsWith("Error"));
6518
6516
  return { available: true, languages };
6519
6517
  };
6520
6518
 
@@ -6550,7 +6548,18 @@ var probeTesseract = () => {
6550
6548
  };
6551
6549
  var probeSharp = async () => {
6552
6550
  try {
6553
- const buffer = await import_sharp.default({
6551
+ let sharpMod;
6552
+ try {
6553
+ sharpMod = (await Promise.resolve().then(() => __toESM(require_lib(), 1))).default ?? await Promise.resolve().then(() => __toESM(require_lib(), 1));
6554
+ } catch {
6555
+ return {
6556
+ id: "sharp",
6557
+ status: "warn",
6558
+ message: "sharp not installed (optional). Rust decode path is preferred for MCP."
6559
+ };
6560
+ }
6561
+ const sharp = sharpMod;
6562
+ const buffer = await sharp({
6554
6563
  create: {
6555
6564
  width: 1,
6556
6565
  height: 1,
@@ -6568,7 +6577,7 @@ var probeSharp = async () => {
6568
6577
  return {
6569
6578
  id: "sharp",
6570
6579
  status: "ok",
6571
- message: `sharp decode pipeline is available (v${import_sharp.default.versions.sharp}).`
6580
+ message: `sharp decode pipeline is available (v${sharp.versions?.sharp ?? "unknown"}).`
6572
6581
  };
6573
6582
  } catch (error) {
6574
6583
  const message = error instanceof Error ? error.message : String(error);
@@ -6643,7 +6652,7 @@ var probeRustDecodeFlag = () => {
6643
6652
  return {
6644
6653
  id: "rust_decode_flag",
6645
6654
  status: "warn",
6646
- message: "Rust decode CLI is not built. sharp remains the default probe path until `cargo build --release`."
6655
+ message: "Rust decode CLI is not built. Install/stage image-reader-cli for native decode, or optional `sharp` for TS fallback."
6647
6656
  };
6648
6657
  };
6649
6658
  var probeNode = () => {
package/package.json CHANGED
@@ -1,15 +1,15 @@
1
1
  {
2
2
  "name": "@sylphx/image-reader-mcp",
3
- "version": "0.1.2",
3
+ "version": "0.1.5",
4
4
  "mcpName": "io.github.SylphxAI/image-reader-mcp",
5
- "description": "Iris \u2014 Evidence-first image reading for AI agents \u2014 metadata, OCR text, regions, and citeable evidence without generative LLM.",
5
+ "description": "Iris Evidence-first image reading for AI agents metadata, OCR text, regions, and citeable evidence without generative LLM.",
6
6
  "type": "module",
7
7
  "bin": {
8
8
  "image-reader-mcp": "./bin/image-reader-mcp",
9
9
  "iris": "./bin/image-reader-mcp"
10
10
  },
11
11
  "files": [
12
- "bin/",
12
+ "bin/image-reader-mcp",
13
13
  "dist/",
14
14
  "README.md",
15
15
  "LICENSE",
@@ -69,8 +69,6 @@
69
69
  },
70
70
  "dependencies": {
71
71
  "@modelcontextprotocol/sdk": "^1.29.0",
72
- "exifr": "^7.1.3",
73
- "sharp": "^0.34.3",
74
72
  "zod": "^4.4.3"
75
73
  },
76
74
  "devDependencies": {
@@ -82,5 +80,13 @@
82
80
  "typescript": "^7.0.2"
83
81
  },
84
82
  "packageManager": "bun@1.3.12",
85
- "private": false
83
+ "private": false,
84
+ "optionalDependencies": {
85
+ "sharp": "^0.34.3",
86
+ "exifr": "^7.1.3",
87
+ "@sylphx/image-reader-mcp-darwin-arm64": "0.1.5",
88
+ "@sylphx/image-reader-mcp-darwin-x64": "0.1.5",
89
+ "@sylphx/image-reader-mcp-linux-x64-gnu": "0.1.5",
90
+ "@sylphx/image-reader-mcp-linux-arm64-gnu": "0.1.5"
91
+ }
86
92
  }
package/src/doctor.ts CHANGED
@@ -2,7 +2,6 @@ import { spawnSync } from 'node:child_process';
2
2
  import { existsSync } from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
- import sharp from 'sharp';
6
5
  import { resolveRustCliBinary } from './engine/rust-decode.js';
7
6
  import { listTesseractLanguages } from './utils/ocr.js';
8
7
  import { IMAGE_SAFETY_LIMITS } from './utils/safety.js';
@@ -53,6 +52,26 @@ const probeTesseract = (): DoctorCheck => {
53
52
 
54
53
  const probeSharp = async (): Promise<DoctorCheck> => {
55
54
  try {
55
+ let sharpMod: unknown;
56
+ try {
57
+ sharpMod =
58
+ ((await import('sharp')) as { default?: unknown }).default ?? (await import('sharp'));
59
+ } catch {
60
+ return {
61
+ id: 'sharp',
62
+ status: 'warn',
63
+ message: 'sharp not installed (optional). Rust decode path is preferred for MCP.',
64
+ };
65
+ }
66
+ const sharp = sharpMod as {
67
+ (
68
+ input: unknown,
69
+ opts?: unknown
70
+ ): {
71
+ png: () => { toBuffer: () => Promise<Buffer> };
72
+ };
73
+ versions?: { sharp?: string };
74
+ };
56
75
  const buffer = await sharp({
57
76
  create: {
58
77
  width: 1,
@@ -75,7 +94,7 @@ const probeSharp = async (): Promise<DoctorCheck> => {
75
94
  return {
76
95
  id: 'sharp',
77
96
  status: 'ok',
78
- message: `sharp decode pipeline is available (v${sharp.versions.sharp}).`,
97
+ message: `sharp decode pipeline is available (v${sharp.versions?.sharp ?? 'unknown'}).`,
79
98
  };
80
99
  } catch (error: unknown) {
81
100
  const message = error instanceof Error ? error.message : String(error);
@@ -162,7 +181,7 @@ const probeRustDecodeFlag = (): DoctorCheck => {
162
181
  id: 'rust_decode_flag',
163
182
  status: 'warn',
164
183
  message:
165
- 'Rust decode CLI is not built. sharp remains the default probe path until `cargo build --release`.',
184
+ 'Rust decode CLI is not built. Install/stage image-reader-cli for native decode, or optional `sharp` for TS fallback.',
166
185
  };
167
186
  };
168
187
 
@@ -1,7 +1,5 @@
1
1
  import fs, { stat } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
- import exifr from 'exifr';
4
- import sharp from 'sharp';
5
3
  import {
6
4
  cropRegionViaRustEngine,
7
5
  probeImageViaRustEngine,
@@ -46,6 +44,23 @@ const readMetadata = async (
46
44
  }
47
45
 
48
46
  try {
47
+ let exifr: {
48
+ parse: (
49
+ path: string,
50
+ opts: Record<string, unknown>
51
+ ) => Promise<Record<string, unknown> | null>;
52
+ };
53
+ try {
54
+ exifr =
55
+ ((await import('exifr')) as { default?: typeof exifr }).default ??
56
+ ((await import('exifr')) as unknown as typeof exifr);
57
+ } catch {
58
+ return {
59
+ trustWarnings: [
60
+ 'Optional dependency `exifr` is not installed; EXIF/XMP/IPTC metadata skipped (geometry/OCR still available).',
61
+ ],
62
+ };
63
+ }
49
64
  const parsed = await exifr.parse(filePath, {
50
65
  tiff: true,
51
66
  xmp: true,
@@ -140,6 +155,28 @@ export const readImage = tool()
140
155
  twin.metadata = extractedMetadata;
141
156
  }
142
157
  } else {
158
+ type SharpMeta = {
159
+ width?: number;
160
+ height?: number;
161
+ format?: string;
162
+ orientation?: number;
163
+ space?: string;
164
+ hasAlpha?: boolean;
165
+ };
166
+ type SharpFn = (
167
+ path: string,
168
+ opts?: { failOn?: string }
169
+ ) => { metadata: () => Promise<SharpMeta> };
170
+ let sharp: SharpFn;
171
+ try {
172
+ const mod = await import('sharp');
173
+ sharp = (mod as { default?: SharpFn }).default ?? (mod as unknown as SharpFn);
174
+ } catch {
175
+ throw new ImageError(
176
+ ErrorCode.InvalidRequest,
177
+ 'Rust decode engine is unavailable and optional `sharp` is not installed. Build/stage image-reader-cli or install sharp for the TS fallback.'
178
+ );
179
+ }
143
180
  const image = sharp(resolvedPath, { failOn: 'none' });
144
181
  const metadata = await image.metadata();
145
182
  validateImageSafety({
@@ -202,6 +239,7 @@ export const readImage = tool()
202
239
  ? { languages_warning: ocr.languages_warning }
203
240
  : {}),
204
241
  ...(ocr.words !== undefined ? { words: ocr.words } : {}),
242
+ ...(ocr.native_blocks !== undefined ? { native_blocks: ocr.native_blocks } : {}),
205
243
  };
206
244
  }
207
245
 
@@ -74,12 +74,14 @@ export const readImageArgsSchema = z.object({
74
74
  include_palette: z
75
75
  .boolean()
76
76
  .optional()
77
- .describe('Sample an approximate local color palette via sharp (not ML). Defaults to false.'),
77
+ .describe(
78
+ 'Sample an approximate local color palette via optional sharp when installed (not ML). Defaults to false.'
79
+ ),
78
80
  include_optional_llm: z
79
81
  .boolean()
80
82
  .optional()
81
83
  .describe(
82
- 'Optional caption via IRIS_OPTIONAL_LLM_URL. Off by default; never authority over OCR/layout evidence.'
84
+ 'Optional local frontier caption via Ollama vision models or IRIS_OPTIONAL_LLM_URL. Off by default; never authority over OCR/layout evidence.'
83
85
  ),
84
86
  });
85
87
 
@@ -110,6 +112,17 @@ export const agentMediaTwinSchema = z.object({
110
112
  })
111
113
  )
112
114
  .optional(),
115
+ native_blocks: z
116
+ .array(
117
+ z.object({
118
+ id: z.string(),
119
+ kind: z.enum(['block', 'paragraph']),
120
+ text: z.string(),
121
+ bbox: boundingBoxSchema,
122
+ confidence: z.number().optional(),
123
+ })
124
+ )
125
+ .optional(),
113
126
  })
114
127
  .optional(),
115
128
  region_evidence: z
@@ -157,6 +170,7 @@ export const agentMediaTwinSchema = z.object({
157
170
  skipped_reason: z.string().optional(),
158
171
  route: z.string().optional(),
159
172
  caption: z.string().optional(),
173
+ model: z.string().optional(),
160
174
  })
161
175
  .optional(),
162
176
  })
@@ -20,6 +20,7 @@ export type AgentImageMap = {
20
20
  skipped_reason?: string;
21
21
  route?: string;
22
22
  caption?: string;
23
+ model?: string;
23
24
  };
24
25
  };
25
26
 
@@ -1,6 +1,6 @@
1
1
  import type { AgentMediaTwin, ReadImageArgs } from '../schemas/readImage.js';
2
2
  import { buildAgentImageMap } from './agentMap.js';
3
- import { buildLayoutFromOcrLines } from './layout.js';
3
+ import { buildBestEffortLayout } from './layout.js';
4
4
  import { maybeOptionalImageCaption } from './optionalLlm.js';
5
5
  import { samplePalette } from './palette.js';
6
6
 
@@ -18,14 +18,30 @@ export async function applyImageIntelligence(
18
18
 
19
19
  const includeLayout = input.include_layout ?? includeOcr;
20
20
  if (includeLayout && next.ocr?.lines && next.ocr.lines.length > 0) {
21
- const layout = buildLayoutFromOcrLines(
22
- next.ocr.lines.map((line) => ({
21
+ const ocrAny = next.ocr as {
22
+ lines: Array<{
23
+ text: string;
24
+ bbox: { x: number; y: number; width: number; height: number };
25
+ confidence?: number;
26
+ }>;
27
+ native_blocks?: Array<{
28
+ id: string;
29
+ kind: 'block' | 'paragraph';
30
+ text: string;
31
+ bbox: { x: number; y: number; width: number; height: number };
32
+ confidence?: number;
33
+ }>;
34
+ };
35
+ const layout = buildBestEffortLayout({
36
+ lines: ocrAny.lines.map((line) => ({
23
37
  text: line.text,
24
38
  bbox: line.bbox,
25
39
  ...(line.confidence !== undefined ? { confidence: line.confidence } : {}),
26
- }))
27
- );
40
+ })),
41
+ ...(ocrAny.native_blocks ? { nativeBlocks: ocrAny.native_blocks } : {}),
42
+ });
28
43
  next.layout = layout;
44
+ next.trust_warnings.push(`layout_policy: ${layout.policy}`);
29
45
  for (const warning of layout.warnings) {
30
46
  next.trust_warnings.push(`layout: ${warning}`);
31
47
  }