openvibers/openvibe.vision Commits

Vision network tools, wave 1: alt text, private visual search, receipts, products, camera watch, moments (#1)

OP OpenVibers committed 16 days ago · fb59bf0799 · 19 files, +1435 −11

Changes

ddiff --git a/.env.example b/.env.example
iindex 74e7df1..fdbecec 100644
---- a/.env.example
++++ b/.env.example
@@@ -69,3 +69,9 @@ VISION_TESSERACT=tesseract
# optional: defaults to true in production
# COOKIE_SECURE=true
++
++# Network tool models; npm run models:fetch downloads pinned ONNX files here/models.
++# DATA_DIR=/var/lib/openvibe/vision
++# Structured candidate offer search APIs (not yet provided by Deals/open.shoes).
++# VISION_DEALS_SEARCH_URL=https://openvibe.deals/api/v1/product-search
++# VISION_SHOES_SEARCH_URL=https://open.shoes/api/v1/search
ddiff --git a/README.md b/README.md
iindex e270181..3edce19 100644
---- a/README.md
++++ b/README.md
@@@ -181,3 +181,24 @@ export/deletion and the image SSRF guard.
## License
AGPL-3.0-only. See [LICENSE](LICENSE).
++
++## Network tools, wave 1
++
++All six tools are JSON APIs. Successful `answer` objects carry a `receipt` with engine, model, `cost_usd`, `ms`, confidence and retention. An image is multipart `image` or JSON `image` data URL unless a route says otherwise. Vision does not retain image bytes.
++
++| Room call | Capability | Notes |
++|---|---|---|
++| Pics, Media, Chat → `POST /api/v1/alt` | `vision.alt` | Routed VLM through OpenVibe.AI; short editable `alt_text`; noLooks in the prompt and output. |
++| Media, Search → `POST /api/v1/index`, `GET /api/v1/find?q=`, `POST /api/v1/find` | `vision.find` | Owner's verified user token; index `{item_id,image,result_url?}`; search by text or image. Owner is in every index read predicate. |
++| Food → `POST /api/v1/receipt` | `vision.receipt` | Tesseract first; `fallback=true` permits routed VLM if local extraction is incomplete. |
++| Deals → `POST /api/v1/product` | `vision.product` | CLIP vector and routed product description; `allow_rerank=true` permits another routed VLM call to rerank candidate offers. |
++| Cam → `POST /api/v1/watch` | `vision.watch` | Owner's verified user token; upload still and `classes` array; optional `previous_image` data URL for scene change. |
++| Live, Media → `POST /api/v1/moments` | `vision.moments` | Owner's verified user token; `{frames:[{timestamp_ms,image|image_url}],prompts?}`. Scene histogram and CLIP prompt matching. |
++
++For private index, watch and moments, a service capability alone does not assert a source owner's grant. The room should call with the owner's delegated Network user token. Private index and watch reject `image_url`; the owner must supply bytes or a data URL. Moments accepts public frame URLs under Vision's existing guarded image fetch, or frame data URLs. The room that opens a returned `result_url` must check its own source grant again.
++
++`npm run models:fetch` is an explicit, revision-pinned download into `${DATA_DIR:-data}/models`. It fetches quantized ONNX CLIP ViT-B/32 (`Xenova/clip-vit-base-patch32`, derived from OpenAI CLIP) and DETR ResNet-50 (`Xenova/detr-resnet-50`, derived from Facebook DETR). The local model loader never downloads weights during a request. Missing weights return 503 for model-dependent operations; histogram-only scene change remains available. CLIP code and model are listed as MIT, but the original checkpoint's weight licence has an [open clarification request](https://huggingface.co/openai/clip-vit-base-patch32/discussions/72); confirm this before commercial distribution. DETR model and weights are [Apache 2.0](https://huggingface.co/facebook/detr-resnet-50). We use DETR rather than Ultralytics (AGPL) or RF-DETR Nano because a supported local Transformers.js ONNX export exists for DETR. `@huggingface/transformers` is Apache 2.0. Tesseract is Apache 2.0.
++
++Private embeddings are JSON vectors in PostgreSQL, scored in JavaScript after a mandatory `owner` filter. Vision has no pgvector migration or extension requirement. The v1 search cap is 5,000 items per owner.
++
++Offer discovery is configured by `VISION_DEALS_SEARCH_URL` and `VISION_SHOES_SEARCH_URL`, both fixed operator URLs returning JSON `{offers:[{title,url,price?}]}` or `{results:[...]}`. Deals currently exposes public `GET /api/v1/offers` without a query filter, and its `/search?q=` is an HTML page; open.shoes has no implemented API in this workspace yet. Until those rooms expose a structured search endpoint, `/product` returns the description and embedding with an empty `offers` list and a warning. It does not fabricate matches.
ddiff --git a/migrations/0002_private_index.sql b/migrations/0002_private_index.sql
nnew file mode 100644
iindex 0000000..b097b12
---- /dev/null
++++ b/migrations/0002_private_index.sql
@@@ -0,0 +1,12 @@
++-- phase: expand
++-- pgvector is not required by the current Vision database; score owner-scoped JSON vectors in JS for v1.
++CREATE TABLE vision_private_index (
++ owner text COLLATE "C" NOT NULL,
++ item_id text COLLATE "C" NOT NULL,
++ result_url text,
++ image_sha256 text COLLATE "C" NOT NULL,
++ embedding jsonb NOT NULL,
++ created_at timestamptz NOT NULL DEFAULT now(),
++ PRIMARY KEY (owner, item_id)
++);
++CREATE INDEX vision_private_index_owner ON vision_private_index (owner, created_at DESC);
ddiff --git a/package-lock.json b/package-lock.json
iindex e4b9588..58b7bb7 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -9,6 +9,7 @@
"version": "0.1.0",
"license": "AGPL-3.0-only",
"dependencies": {
++ "@huggingface/transformers": "3.8.1",
"cookie-parser": "^1.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
@@@ -46,6 +47,477 @@
"tslib": "^2.4.0"
}
},
++ "node_modules/@huggingface/jinja": {
++ "version": "0.5.11",
++ "resolved": "https://registry.npmjs.org/@huggingface/jinja/-/jinja-0.5.11.tgz",
++ "integrity": "sha512-YzEzwM5oQf4r0vs7ifWQ+yuVR1YkAHrjERQ+a8WRsStHHp4NRBg+NesjiFc+X2AFUpRvvrIeHDS1xFy9jY/IjQ==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=18"
++ }
++ },
++ "node_modules/@huggingface/transformers": {
++ "version": "3.8.1",
++ "resolved": "https://registry.npmjs.org/@huggingface/transformers/-/transformers-3.8.1.tgz",
++ "integrity": "sha512-tsTk4zVjImqdqjS8/AOZg2yNLd1z9S5v+7oUPpXaasDRwEDhB+xnglK1k5cad26lL5/ZIaeREgWWy0bs9y9pPA==",
++ "license": "Apache-2.0",
++ "dependencies": {
++ "@huggingface/jinja": "^0.5.3",
++ "onnxruntime-node": "1.21.0",
++ "onnxruntime-web": "1.22.0-dev.20250409-89f8206ba4",
++ "sharp": "^0.34.1"
++ }
++ },
++ "node_modules/@huggingface/transformers/node_modules/@img/sharp-darwin-arm64": {
++ "version": "0.34.5",
++ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
++ "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
++ "cpu": [
++ "arm64"
++ ],
++ "license": "Apache-2.0",
++ "optional": true,
++ "os": [
++ "darwin"
++ ],
++ "engines": {
++ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
++ },
++ "funding": {
++ "url": "https://opencollective.com/libvips"
++ },
++ "optionalDependencies": {
++ "@img/sharp-libvips-darwin-arm64": "1.2.4"
++ }
++ },
++ "node_modules/@huggingface/transformers/node_modules/@img/sharp-darwin-x64": {
++ "version": "0.34.5",
++ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
++ "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
++ "cpu": [
++ "x64"
++ ],
++ "license": "Apache-2.0",
++ "optional": true,
++ "os": [
++ "darwin"
++ ],
++ "engines": {
++ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
++ },
++ "funding": {
++ "url": "https://opencollective.com/libvips"
++ },
++ "optionalDependencies": {
++ "@img/sharp-libvips-darwin-x64": "1.2.4"
++ }
++ },
++ "node_modules/@huggingface/transformers/node_modules/@img/sharp-libvips-darwin-arm64": {
++ "version": "1.2.4",
++ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
++ "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
++ "cpu": [
++ "arm64"
++ ],
++ "license": "LGPL-3.0-or-later",
++ "optional": true,
++ "os": [
++ "darwin"
++ ],
++ "funding": {
++ "url": "https://opencollective.com/libvips"
++ }
++ },
++ "node_modules/@huggingface/transformers/node_modules/@img/sharp-libvips-darwin-x64": {
++ "version": "1.2.4",
++ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
++ "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
++ "cpu": [
++ "x64"
++ ],
++ "license": "LGPL-3.0-or-later",
++ "optional": true,
++ "os": [
++ "darwin"
++ ],
++ "funding": {
++ "url": "https://opencollective.com/libvips"
++ }
++ },
++ "node_modules/@huggingface/transformers/node_modules/@img/sharp-libvips-linux-arm": {
++ "version": "1.2.4",
++ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
++ "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
++ "cpu": [
++ "arm"
++ ],
++ "libc": [
++ "glibc"
++ ],
++ "license": "LGPL-3.0-or-later",
++ "optional": true,
++ "os": [
++ "linux"
++ ],
++ "funding": {
++ "url": "https://opencollective.com/libvips"
++ }
++ },
++ "node_modules/@huggingface/transformers/node_modules/@img/sharp-libvips-linux-arm64": {
++ "version": "1.2.4",
++ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
++ "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
++ "cpu": [
++ "arm64"
++ ],
++ "libc": [
++ "glibc"
++ ],
++ "license": "LGPL-3.0-or-later",
++ "optional": true,
++ "os": [
++ "linux"
++ ],
++ "funding": {
++ "url": "https://opencollective.com/libvips"
++ }
++ },
++ "node_modules/@huggingface/transformers/node_modules/@img/sharp-libvips-linux-s390x": {
++ "version": "1.2.4",
++ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
++ "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
++ "cpu": [
++ "s390x"
++ ],
++ "libc": [
++ "glibc"
++ ],
++ "license": "LGPL-3.0-or-later",
++ "optional": true,
++ "os": [
++ "linux"
++ ],
++ "funding": {
++ "url": "https://opencollective.com/libvips"
++ }
++ },
++ "node_modules/@huggingface/transformers/node_modules/@img/sharp-libvips-linux-x64": {
++ "version": "1.2.4",
++ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
++ "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
++ "cpu": [
++ "x64"
++ ],
++ "libc": [
++ "glibc"
++ ],
++ "license": "LGPL-3.0-or-later",
++ "optional": true,
++ "os": [
++ "linux"
++ ],
++ "funding": {
++ "url": "https://opencollective.com/libvips"
++ }
++ },
++ "node_modules/@huggingface/transformers/node_modules/@img/sharp-libvips-linuxmusl-arm64": {
++ "version": "1.2.4",
++ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
++ "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
++ "cpu": [
++ "arm64"
++ ],
++ "libc": [
++ "musl"
++ ],
++ "license": "LGPL-3.0-or-later",
++ "optional": true,
++ "os": [
++ "linux"
++ ],
++ "funding": {
++ "url": "https://opencollective.com/libvips"
++ }
++ },
++ "node_modules/@huggingface/transformers/node_modules/@img/sharp-libvips-linuxmusl-x64": {
++ "version": "1.2.4",
++ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
++ "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
++ "cpu": [
++ "x64"
++ ],
++ "libc": [
++ "musl"
++ ],
++ "license": "LGPL-3.0-or-later",
++ "optional": true,
++ "os": [
++ "linux"
++ ],
++ "funding": {
++ "url": "https://opencollective.com/libvips"
++ }
++ },
++ "node_modules/@huggingface/transformers/node_modules/@img/sharp-linux-arm": {
++ "version": "0.34.5",
++ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
++ "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
++ "cpu": [
++ "arm"
++ ],
++ "libc": [
++ "glibc"
++ ],
++ "license": "Apache-2.0",
++ "optional": true,
++ "os": [
++ "linux"
++ ],
++ "engines": {
++ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
++ },
++ "funding": {
++ "url": "https://opencollective.com/libvips"
++ },
++ "optionalDependencies": {
++ "@img/sharp-libvips-linux-arm": "1.2.4"
++ }
++ },
++ "node_modules/@huggingface/transformers/node_modules/@img/sharp-linux-arm64": {
++ "version": "0.34.5",
++ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
++ "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
++ "cpu": [
++ "arm64"
++ ],
++ "libc": [
++ "glibc"
++ ],
++ "license": "Apache-2.0",
++ "optional": true,
++ "os": [
++ "linux"
++ ],
++ "engines": {
++ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
++ },
++ "funding": {
++ "url": "https://opencollective.com/libvips"
++ },
++ "optionalDependencies": {
++ "@img/sharp-libvips-linux-arm64": "1.2.4"
++ }
++ },
++ "node_modules/@huggingface/transformers/node_modules/@img/sharp-linux-s390x": {
++ "version": "0.34.5",
++ "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
++ "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
++ "cpu": [
++ "s390x"
++ ],
++ "libc": [
++ "glibc"
++ ],
++ "license": "Apache-2.0",
++ "optional": true,
++ "os": [
++ "linux"
++ ],
++ "engines": {
++ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
++ },
++ "funding": {
++ "url": "https://opencollective.com/libvips"
++ },
++ "optionalDependencies": {
++ "@img/sharp-libvips-linux-s390x": "1.2.4"
++ }
++ },
++ "node_modules/@huggingface/transformers/node_modules/@img/sharp-linux-x64": {
++ "version": "0.34.5",
++ "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
++ "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
++ "cpu": [
++ "x64"
++ ],
++ "libc": [
++ "glibc"
++ ],
++ "license": "Apache-2.0",
++ "optional": true,
++ "os": [
++ "linux"
++ ],
++ "engines": {
++ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
++ },
++ "funding": {
++ "url": "https://opencollective.com/libvips"
++ },
++ "optionalDependencies": {
++ "@img/sharp-libvips-linux-x64": "1.2.4"
++ }
++ },
++ "node_modules/@huggingface/transformers/node_modules/@img/sharp-linuxmusl-arm64": {
++ "version": "0.34.5",
++ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
++ "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
++ "cpu": [
++ "arm64"
++ ],
++ "libc": [
++ "musl"
++ ],
++ "license": "Apache-2.0",
++ "optional": true,
++ "os": [
++ "linux"
++ ],
++ "engines": {
++ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
++ },
++ "funding": {
++ "url": "https://opencollective.com/libvips"
++ },
++ "optionalDependencies": {
++ "@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
++ }
++ },
++ "node_modules/@huggingface/transformers/node_modules/@img/sharp-linuxmusl-x64": {
++ "version": "0.34.5",
++ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
++ "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
++ "cpu": [
++ "x64"
++ ],
++ "libc": [
++ "musl"
++ ],
++ "license": "Apache-2.0",
++ "optional": true,
++ "os": [
++ "linux"
++ ],
++ "engines": {
++ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
++ },
++ "funding": {
++ "url": "https://opencollective.com/libvips"
++ },
++ "optionalDependencies": {
++ "@img/sharp-libvips-linuxmusl-x64": "1.2.4"
++ }
++ },
++ "node_modules/@huggingface/transformers/node_modules/@img/sharp-wasm32": {
++ "version": "0.34.5",
++ "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
++ "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
++ "cpu": [
++ "wasm32"
++ ],
++ "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
++ "optional": true,
++ "dependencies": {
++ "@emnapi/runtime": "^1.7.0"
++ },
++ "engines": {
++ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
++ },
++ "funding": {
++ "url": "https://opencollective.com/libvips"
++ }
++ },
++ "node_modules/@huggingface/transformers/node_modules/@img/sharp-win32-ia32": {
++ "version": "0.34.5",
++ "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
++ "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
++ "cpu": [
++ "ia32"
++ ],
++ "license": "Apache-2.0 AND LGPL-3.0-or-later",
++ "optional": true,
++ "os": [
++ "win32"
++ ],
++ "engines": {
++ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
++ },
++ "funding": {
++ "url": "https://opencollective.com/libvips"
++ }
++ },
++ "node_modules/@huggingface/transformers/node_modules/@img/sharp-win32-x64": {
++ "version": "0.34.5",
++ "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
++ "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
++ "cpu": [
++ "x64"
++ ],
++ "license": "Apache-2.0 AND LGPL-3.0-or-later",
++ "optional": true,
++ "os": [
++ "win32"
++ ],
++ "engines": {
++ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
++ },
++ "funding": {
++ "url": "https://opencollective.com/libvips"
++ }
++ },
++ "node_modules/@huggingface/transformers/node_modules/sharp": {
++ "version": "0.34.5",
++ "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
++ "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
++ "hasInstallScript": true,
++ "license": "Apache-2.0",
++ "dependencies": {
++ "@img/colour": "^1.0.0",
++ "detect-libc": "^2.1.2",
++ "semver": "^7.7.3"
++ },
++ "engines": {
++ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
++ },
++ "funding": {
++ "url": "https://opencollective.com/libvips"
++ },
++ "optionalDependencies": {
++ "@img/sharp-darwin-arm64": "0.34.5",
++ "@img/sharp-darwin-x64": "0.34.5",
++ "@img/sharp-libvips-darwin-arm64": "1.2.4",
++ "@img/sharp-libvips-darwin-x64": "1.2.4",
++ "@img/sharp-libvips-linux-arm": "1.2.4",
++ "@img/sharp-libvips-linux-arm64": "1.2.4",
++ "@img/sharp-libvips-linux-ppc64": "1.2.4",
++ "@img/sharp-libvips-linux-riscv64": "1.2.4",
++ "@img/sharp-libvips-linux-s390x": "1.2.4",
++ "@img/sharp-libvips-linux-x64": "1.2.4",
++ "@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
++ "@img/sharp-libvips-linuxmusl-x64": "1.2.4",
++ "@img/sharp-linux-arm": "0.34.5",
++ "@img/sharp-linux-arm64": "0.34.5",
++ "@img/sharp-linux-ppc64": "0.34.5",
++ "@img/sharp-linux-riscv64": "0.34.5",
++ "@img/sharp-linux-s390x": "0.34.5",
++ "@img/sharp-linux-x64": "0.34.5",
++ "@img/sharp-linuxmusl-arm64": "0.34.5",
++ "@img/sharp-linuxmusl-x64": "0.34.5",
++ "@img/sharp-wasm32": "0.34.5",
++ "@img/sharp-win32-arm64": "0.34.5",
++ "@img/sharp-win32-ia32": "0.34.5",
++ "@img/sharp-win32-x64": "0.34.5"
++ }
++ },
++ "node_modules/@img/colour": {
++ "version": "1.1.0",
++ "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
++ "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=18"
++ }
++ },
"node_modules/@img/sharp-darwin-arm64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz",
@@@ -146,7 +618,45 @@
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz",
"integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==",
"cpu": [
-- "arm64"
++ "arm64"
++ ],
++ "libc": [
++ "glibc"
++ ],
++ "license": "LGPL-3.0-or-later",
++ "optional": true,
++ "os": [
++ "linux"
++ ],
++ "funding": {
++ "url": "https://opencollective.com/libvips"
++ }
++ },
++ "node_modules/@img/sharp-libvips-linux-ppc64": {
++ "version": "1.2.4",
++ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
++ "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
++ "cpu": [
++ "ppc64"
++ ],
++ "libc": [
++ "glibc"
++ ],
++ "license": "LGPL-3.0-or-later",
++ "optional": true,
++ "os": [
++ "linux"
++ ],
++ "funding": {
++ "url": "https://opencollective.com/libvips"
++ }
++ },
++ "node_modules/@img/sharp-libvips-linux-riscv64": {
++ "version": "1.2.4",
++ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
++ "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
++ "cpu": [
++ "riscv64"
],
"libc": [
"glibc"
@@@ -286,6 +796,56 @@
"@img/sharp-libvips-linux-arm64": "1.0.4"
}
},
++ "node_modules/@img/sharp-linux-ppc64": {
++ "version": "0.34.5",
++ "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
++ "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
++ "cpu": [
++ "ppc64"
++ ],
++ "libc": [
++ "glibc"
++ ],
++ "license": "Apache-2.0",
++ "optional": true,
++ "os": [
++ "linux"
++ ],
++ "engines": {
++ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
++ },
++ "funding": {
++ "url": "https://opencollective.com/libvips"
++ },
++ "optionalDependencies": {
++ "@img/sharp-libvips-linux-ppc64": "1.2.4"
++ }
++ },
++ "node_modules/@img/sharp-linux-riscv64": {
++ "version": "0.34.5",
++ "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
++ "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
++ "cpu": [
++ "riscv64"
++ ],
++ "libc": [
++ "glibc"
++ ],
++ "license": "Apache-2.0",
++ "optional": true,
++ "os": [
++ "linux"
++ ],
++ "engines": {
++ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
++ },
++ "funding": {
++ "url": "https://opencollective.com/libvips"
++ },
++ "optionalDependencies": {
++ "@img/sharp-libvips-linux-riscv64": "1.2.4"
++ }
++ },
"node_modules/@img/sharp-linux-s390x": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz",
@@@ -405,6 +965,25 @@
"url": "https://opencollective.com/libvips"
}
},
++ "node_modules/@img/sharp-win32-arm64": {
++ "version": "0.34.5",
++ "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
++ "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
++ "cpu": [
++ "arm64"
++ ],
++ "license": "Apache-2.0 AND LGPL-3.0-or-later",
++ "optional": true,
++ "os": [
++ "win32"
++ ],
++ "engines": {
++ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
++ },
++ "funding": {
++ "url": "https://opencollective.com/libvips"
++ }
++ },
"node_modules/@img/sharp-win32-ia32": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz",
@@@ -449,6 +1028,84 @@
"integrity": "sha512-/B9W4qKSSITDii5nkBCHyPkIkAi+ealUtr1oqBJsLxjSRLka4pxun2VvMNSmcwgAMxgXtQfl0qRv7TE+udPJzg==",
"license": "MIT"
},
++ "node_modules/@isaacs/fs-minipass": {
++ "version": "4.0.1",
++ "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
++ "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==",
++ "license": "ISC",
++ "dependencies": {
++ "minipass": "^7.0.4"
++ },
++ "engines": {
++ "node": ">=18.0.0"
++ }
++ },
++ "node_modules/@protobufjs/aspromise": {
++ "version": "1.1.2",
++ "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
++ "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==",
++ "license": "BSD-3-Clause"
++ },
++ "node_modules/@protobufjs/base64": {
++ "version": "1.1.2",
++ "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz",
++ "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==",
++ "license": "BSD-3-Clause"
++ },
++ "node_modules/@protobufjs/codegen": {
++ "version": "2.0.5",
++ "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz",
++ "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==",
++ "license": "BSD-3-Clause"
++ },
++ "node_modules/@protobufjs/eventemitter": {
++ "version": "1.1.1",
++ "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz",
++ "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==",
++ "license": "BSD-3-Clause"
++ },
++ "node_modules/@protobufjs/fetch": {
++ "version": "1.1.1",
++ "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz",
++ "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==",
++ "license": "BSD-3-Clause",
++ "dependencies": {
++ "@protobufjs/aspromise": "^1.1.1"
++ }
++ },
++ "node_modules/@protobufjs/float": {
++ "version": "1.0.2",
++ "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz",
++ "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==",
++ "license": "BSD-3-Clause"
++ },
++ "node_modules/@protobufjs/path": {
++ "version": "1.1.2",
++ "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz",
++ "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==",
++ "license": "BSD-3-Clause"
++ },
++ "node_modules/@protobufjs/pool": {
++ "version": "1.1.0",
++ "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz",
++ "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==",
++ "license": "BSD-3-Clause"
++ },
++ "node_modules/@protobufjs/utf8": {
++ "version": "1.1.2",
++ "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz",
++ "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==",
++ "license": "BSD-3-Clause"
++ },
++ "node_modules/@types/node": {
++ "version": "26.6.6",
++ "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.6.tgz",
++ "integrity": "sha512-MzQj+8Kx3XI40OmUAE6Qoh58ZMNstNNMwn2nfd3qWsfe7nDNSqJNzes4njhz6AHwrSHOffSLwXU6yb474Q4aog==",
++ "license": "MIT",
++ "dependencies": {
++ "undici-types": "~8.9.0"
++ }
++ },
"node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
@@@ -531,6 +1188,13 @@
"npm": "1.2.8000 || >= 1.4.16"
}
},
++ "node_modules/boolean": {
++ "version": "3.2.0",
++ "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz",
++ "integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==",
++ "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
++ "license": "MIT"
++ },
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
@@@ -586,6 +1250,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
++ "node_modules/chownr": {
++ "version": "3.0.0",
++ "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
++ "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==",
++ "license": "BlueOak-1.0.0",
++ "engines": {
++ "node": ">=18"
++ }
++ },
"node_modules/cluster-key-slot": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
@@@ -694,6 +1367,40 @@
"ms": "2.0.0"
}
},
++ "node_modules/define-data-property": {
++ "version": "1.1.4",
++ "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
++ "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
++ "license": "MIT",
++ "dependencies": {
++ "es-define-property": "^1.0.0",
++ "es-errors": "^1.3.0",
++ "gopd": "^1.0.1"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/define-properties": {
++ "version": "1.2.1",
++ "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz",
++ "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==",
++ "license": "MIT",
++ "dependencies": {
++ "define-data-property": "^1.0.1",
++ "has-property-descriptors": "^1.0.0",
++ "object-keys": "^1.1.1"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
"node_modules/denque": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
@@@ -731,6 +1438,12 @@
"node": ">=8"
}
},
++ "node_modules/detect-node": {
++ "version": "2.1.0",
++ "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz",
++ "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==",
++ "license": "MIT"
++ },
"node_modules/dotenv": {
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
@@@ -811,12 +1524,30 @@
"node": ">= 0.4"
}
},
++ "node_modules/es6-error": {
++ "version": "4.1.1",
++ "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz",
++ "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==",
++ "license": "MIT"
++ },
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"license": "MIT"
},
++ "node_modules/escape-string-regexp": {
++ "version": "4.0.0",
++ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
++ "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=10"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/sindresorhus"
++ }
++ },
"node_modules/etag": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
@@@ -927,6 +1658,12 @@
"node": ">= 0.8"
}
},
++ "node_modules/flatbuffers": {
++ "version": "25.9.23",
++ "resolved": "https://registry.npmjs.org/flatbuffers/-/flatbuffers-25.9.23.tgz",
++ "integrity": "sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ==",
++ "license": "Apache-2.0"
++ },
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@@@ -991,6 +1728,39 @@
"node": ">= 0.4"
}
},
++ "node_modules/global-agent": {
++ "version": "3.0.0",
++ "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz",
++ "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==",
++ "license": "BSD-3-Clause",
++ "dependencies": {
++ "boolean": "^3.0.1",
++ "es6-error": "^4.1.1",
++ "matcher": "^3.0.0",
++ "roarr": "^2.15.3",
++ "semver": "^7.3.2",
++ "serialize-error": "^7.0.1"
++ },
++ "engines": {
++ "node": ">=10.0"
++ }
++ },
++ "node_modules/globalthis": {
++ "version": "1.0.4",
++ "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz",
++ "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==",
++ "license": "MIT",
++ "dependencies": {
++ "define-properties": "^1.2.1",
++ "gopd": "^1.0.1"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
@@@ -1003,6 +1773,24 @@
"url": "https://github.com/sponsors/ljharb"
}
},
++ "node_modules/guid-typescript": {
++ "version": "1.0.9",
++ "resolved": "https://registry.npmjs.org/guid-typescript/-/guid-typescript-1.0.9.tgz",
++ "integrity": "sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ==",
++ "license": "ISC"
++ },
++ "node_modules/has-property-descriptors": {
++ "version": "1.0.2",
++ "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
++ "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
++ "license": "MIT",
++ "dependencies": {
++ "es-define-property": "^1.0.0"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
@@@ -1141,6 +1929,12 @@
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"license": "MIT"
},
++ "node_modules/json-stringify-safe": {
++ "version": "5.0.1",
++ "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz",
++ "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==",
++ "license": "ISC"
++ },
"node_modules/jsonwebtoken": {
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
@@@ -1244,6 +2038,24 @@
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
"license": "MIT"
},
++ "node_modules/long": {
++ "version": "5.3.2",
++ "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
++ "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
++ "license": "Apache-2.0"
++ },
++ "node_modules/matcher": {
++ "version": "3.0.0",
++ "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz",
++ "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==",
++ "license": "MIT",
++ "dependencies": {
++ "escape-string-regexp": "^4.0.0"
++ },
++ "engines": {
++ "node": ">=10"
++ }
++ },
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@@ -1313,6 +2125,27 @@
"node": ">= 0.6"
}
},
++ "node_modules/minipass": {
++ "version": "7.1.3",
++ "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
++ "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
++ "license": "BlueOak-1.0.0",
++ "engines": {
++ "node": ">=16 || 14 >=14.17"
++ }
++ },
++ "node_modules/minizlib": {
++ "version": "3.1.0",
++ "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz",
++ "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==",
++ "license": "MIT",
++ "dependencies": {
++ "minipass": "^7.1.2"
++ },
++ "engines": {
++ "node": ">= 18"
++ }
++ },
"node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
@@@ -1358,6 +2191,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
++ "node_modules/object-keys": {
++ "version": "1.1.1",
++ "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
++ "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
"node_modules/on-finished": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.2.tgz",
@@@ -1374,6 +2216,49 @@
"url": "https://opencollective.com/express"
}
},
++ "node_modules/onnxruntime-common": {
++ "version": "1.21.0",
++ "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.21.0.tgz",
++ "integrity": "sha512-Q632iLLrtCAVOTO65dh2+mNbQir/QNTVBG3h/QdZBpns7mZ0RYbLRBgGABPbpU9351AgYy7SJf1WaeVwMrBFPQ==",
++ "license": "MIT"
++ },
++ "node_modules/onnxruntime-node": {
++ "version": "1.21.0",
++ "resolved": "https://registry.npmjs.org/onnxruntime-node/-/onnxruntime-node-1.21.0.tgz",
++ "integrity": "sha512-NeaCX6WW2L8cRCSqy3bInlo5ojjQqu2fD3D+9W5qb5irwxhEyWKXeH2vZ8W9r6VxaMPUan+4/7NDwZMtouZxEw==",
++ "hasInstallScript": true,
++ "license": "MIT",
++ "os": [
++ "win32",
++ "darwin",
++ "linux"
++ ],
++ "dependencies": {
++ "global-agent": "^3.0.0",
++ "onnxruntime-common": "1.21.0",
++ "tar": "^7.0.1"
++ }
++ },
++ "node_modules/onnxruntime-web": {
++ "version": "1.22.0-dev.20250409-89f8206ba4",
++ "resolved": "https://registry.npmjs.org/onnxruntime-web/-/onnxruntime-web-1.22.0-dev.20250409-89f8206ba4.tgz",
++ "integrity": "sha512-0uS76OPgH0hWCPrFKlL8kYVV7ckM7t/36HfbgoFw6Nd0CZVVbQC4PkrR8mBX8LtNUFZO25IQBqV2Hx2ho3FlbQ==",
++ "license": "MIT",
++ "dependencies": {
++ "flatbuffers": "^25.1.24",
++ "guid-typescript": "^1.0.9",
++ "long": "^5.2.3",
++ "onnxruntime-common": "1.22.0-dev.20250409-89f8206ba4",
++ "platform": "^1.3.6",
++ "protobufjs": "^7.2.4"
++ }
++ },
++ "node_modules/onnxruntime-web/node_modules/onnxruntime-common": {
++ "version": "1.22.0-dev.20250409-89f8206ba4",
++ "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.22.0-dev.20250409-89f8206ba4.tgz",
++ "integrity": "sha512-vDJMkfCfb0b1A836rgHj+ORuZf4B4+cc2bASQtpeoJLueuFc5DuYwjIZUBrSvx/fO5IrLjLz+oTrB3pcGlhovQ==",
++ "license": "MIT"
++ },
"node_modules/openvibe-contracts": {
"version": "0.235.0",
"resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.235.0",
@@@ -1534,6 +2419,12 @@
"split2": "^4.1.0"
}
},
++ "node_modules/platform": {
++ "version": "1.3.6",
++ "resolved": "https://registry.npmjs.org/platform/-/platform-1.3.6.tgz",
++ "integrity": "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==",
++ "license": "MIT"
++ },
"node_modules/postgres-array": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
@@@ -1573,6 +2464,29 @@
"node": ">=0.10.0"
}
},
++ "node_modules/protobufjs": {
++ "version": "7.6.6",
++ "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.6.tgz",
++ "integrity": "sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg==",
++ "hasInstallScript": true,
++ "license": "BSD-3-Clause",
++ "dependencies": {
++ "@protobufjs/aspromise": "^1.1.2",
++ "@protobufjs/base64": "^1.1.2",
++ "@protobufjs/codegen": "^2.0.5",
++ "@protobufjs/eventemitter": "^1.1.1",
++ "@protobufjs/fetch": "^1.1.1",
++ "@protobufjs/float": "^1.0.2",
++ "@protobufjs/path": "^1.1.2",
++ "@protobufjs/pool": "^1.1.0",
++ "@protobufjs/utf8": "^1.1.1",
++ "@types/node": ">=13.7.0",
++ "long": "^5.3.2"
++ },
++ "engines": {
++ "node": ">=12.0.0"
++ }
++ },
"node_modules/proxy-addr": {
"version": "2.0.8",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",
@@@ -1660,6 +2574,23 @@
"node": ">=0.10.0"
}
},
++ "node_modules/roarr": {
++ "version": "2.15.4",
++ "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz",
++ "integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==",
++ "license": "BSD-3-Clause",
++ "dependencies": {
++ "boolean": "^3.0.1",
++ "detect-node": "^2.0.4",
++ "globalthis": "^1.0.1",
++ "json-stringify-safe": "^5.0.1",
++ "semver-compare": "^1.0.0",
++ "sprintf-js": "^1.1.2"
++ },
++ "engines": {
++ "node": ">=8.0"
++ }
++ },
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
@@@ -1698,6 +2629,12 @@
"node": ">=10"
}
},
++ "node_modules/semver-compare": {
++ "version": "1.0.0",
++ "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz",
++ "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==",
++ "license": "MIT"
++ },
"node_modules/send": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
@@@ -1728,6 +2665,21 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
++ "node_modules/serialize-error": {
++ "version": "7.0.1",
++ "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz",
++ "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==",
++ "license": "MIT",
++ "dependencies": {
++ "type-fest": "^0.13.1"
++ },
++ "engines": {
++ "node": ">=10"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/sindresorhus"
++ }
++ },
"node_modules/serve-static": {
"version": "1.16.3",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
@@@ -1878,6 +2830,12 @@
"node": ">= 10.x"
}
},
++ "node_modules/sprintf-js": {
++ "version": "1.1.3",
++ "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz",
++ "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==",
++ "license": "BSD-3-Clause"
++ },
"node_modules/standard-as-callback": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
@@@ -1901,6 +2859,22 @@
"node": ">=10.0.0"
}
},
++ "node_modules/tar": {
++ "version": "7.5.22",
++ "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz",
++ "integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==",
++ "license": "BlueOak-1.0.0",
++ "dependencies": {
++ "@isaacs/fs-minipass": "^4.0.0",
++ "chownr": "^3.0.0",
++ "minipass": "^7.1.2",
++ "minizlib": "^3.1.0",
++ "yallist": "^5.0.0"
++ },
++ "engines": {
++ "node": ">=18"
++ }
++ },
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
@@@ -1917,6 +2891,18 @@
"license": "0BSD",
"optional": true
},
++ "node_modules/type-fest": {
++ "version": "0.13.1",
++ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz",
++ "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==",
++ "license": "(MIT OR CC0-1.0)",
++ "engines": {
++ "node": ">=10"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/sindresorhus"
++ }
++ },
"node_modules/type-is": {
"version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
@@@ -1930,6 +2916,12 @@
"node": ">= 0.6"
}
},
++ "node_modules/undici-types": {
++ "version": "8.9.0",
++ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz",
++ "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==",
++ "license": "MIT"
++ },
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
@@@ -1965,6 +2957,15 @@
"engines": {
"node": ">=0.4"
}
++ },
++ "node_modules/yallist": {
++ "version": "5.0.0",
++ "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",
++ "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==",
++ "license": "BlueOak-1.0.0",
++ "engines": {
++ "node": ">=18"
++ }
}
}
}
ddiff --git a/package.json b/package.json
iindex e926c58..58e13b1 100644
---- a/package.json
++++ b/package.json
@@@ -9,7 +9,8 @@
"dev": "NODE_ENV=development node server/index.js",
"proof": "node scripts/proof.js",
"test": "node test/run.js",
-- "test:pg": "VISION_TEST_STORE=pg node test/run.js"
++ "test:pg": "VISION_TEST_STORE=pg node test/run.js",
++ "models:fetch": "node scripts/fetch-models.js"
},
"keywords": [
"openvibe"
@@@ -17,6 +18,7 @@
"author": "OpenVibers",
"license": "AGPL-3.0-only",
"dependencies": {
++ "@huggingface/transformers": "3.8.1",
"cookie-parser": "^1.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
ddiff --git a/scripts/fetch-models.js b/scripts/fetch-models.js
nnew file mode 100644
iindex 0000000..272ce29
---- /dev/null
++++ b/scripts/fetch-models.js
@@@ -0,0 +1,26 @@
++#!/usr/bin/env node
++'use strict';
++// Explicit operator action: no model downloads occur in requests or tests.
++const fs = require('fs/promises');
++const path = require('path');
++const { Readable } = require('stream');
++const { pipeline } = require('stream/promises');
++const root = path.resolve(process.env.DATA_DIR || path.join(__dirname, '..', 'data'), 'models');
++const models = [
++ { dir: 'clip', repo: 'Xenova/clip-vit-base-patch32', revision: '6b7c523703ae26e39be4b03774966f8571c7f523', files: ['config.json', 'preprocessor_config.json', 'tokenizer.json', 'tokenizer_config.json', 'vocab.json', 'merges.txt', 'onnx/text_model_quantized.onnx', 'onnx/vision_model_quantized.onnx'] },
++ { dir: 'detr', repo: 'Xenova/detr-resnet-50', revision: '8be7ab59ff663484ee9ba2e8d8f267330d5ad03e', files: ['config.json', 'preprocessor_config.json', 'onnx/model_quantized.onnx'] },
++];
++(async () => {
++ for (const model of models) for (const file of model.files) {
++ const target = path.join(root, model.dir, file);
++ await fs.mkdir(path.dirname(target), { recursive: true });
++ try { if ((await fs.stat(target)).size > 0) continue; } catch { /* fetch */ }
++ const url = `https://huggingface.co/${model.repo}/resolve/${model.revision}/${file}`;
++ const response = await fetch(url);
++ if (!response.ok || !response.body) throw new Error(`model fetch failed: ${model.repo}/${file} (${response.status})`);
++ const temp = `${target}.partial`;
++ try { await pipeline(Readable.fromWeb(response.body), require('fs').createWriteStream(temp)); await fs.rename(temp, target); }
++ finally { await fs.rm(temp, { force: true }); }
++ process.stdout.write(`fetched ${model.dir}/${file}\n`);
++ }
++})().catch((e) => { console.error(e.message); process.exitCode = 1; });
ddiff --git a/server/app.js b/server/app.js
iindex 03b2686..8416ba3 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -62,6 +62,8 @@ async function createApp(opts = {}) {
const ocr = opts.ocr || createOcr({ config, log });
const ai = opts.ai || createAi({ config, fetchImpl, log });
const vision = createVision({ config, s, ocr, ai, store, log });
++ const models = require('./vision/local-models').createLocalModels({ config, clip: opts.clip, detector: opts.detector });
++ const networkTools = require('./vision/network-tools').createNetworkTools({ s, ai, ocr, models, config, fetchImpl });
// Account export and deletion (ADR-033). The sender posts to Network's internal routes with this service's own
// client-credentials token; a test injects a stand-in through opts.accountSend.
@@@ -70,7 +72,7 @@ async function createApp(opts = {}) {
? createNetworkSender({ networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl })
: async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Vision cannot answer account events'); });
-- const ctx = { config, s, keys, sso, principal, store, ocr, ai, vision, accountData, accountSend, log, fetchImpl };
++ const ctx = { config, s, keys, sso, principal, store, ocr, ai, vision, models, networkTools, accountData, accountSend, log, fetchImpl };
const app = express();
app.disable('x-powered-by');
@@@ -113,7 +115,7 @@ async function createApp(opts = {}) {
// ── Machine endpoints ───────────────────────────────────
app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-vision', name: 'OpenVibe.Vision', version: VERSION }));
release.mount(app, { registry: metrics.registry });
-- const readiness = createServiceReadiness({ s, config, valkey, ai, ocr });
++ const readiness = createServiceReadiness({ s, config, valkey, ai, ocr, models });
app.get('/api/ready', readiness.handler);
// ── Who is asking (verified offline; refreshed when expired) ──
ddiff --git a/server/config.js b/server/config.js
iindex bd3b6e6..b147f1e 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -83,6 +83,11 @@ function load(env = process.env) {
lang: env.VISION_OCR_LANG || 'eng',
timeoutMs: Math.max(2000, int(env.VISION_OCR_TIMEOUT_MS, 20000)),
},
++ models: { dir: require('path').resolve(env.DATA_DIR || require('path').join(__dirname, '..', 'data'), 'models') },
++ offers: {
++ dealsUrl: env.VISION_DEALS_SEARCH_URL || '',
++ shoesUrl: env.VISION_SHOES_SEARCH_URL || '',
++ },
// Image inputs: upload (multipart), a data: URL, or a public https URL fetched with the private-address
// guard. No image bytes are stored in the database or written to a page.
ddiff --git a/server/http/api.js b/server/http/api.js
iindex 10ff058..f1d2973 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -25,7 +25,7 @@ const { loadImage } = require('../vision/images');
const { VisionError } = require('../vision/service');
function createApi(ctx) {
-- const { config, s, principal, store, vision, ai, ocr, fetchImpl } = ctx;
++ const { config, s, principal, store, vision, ai, ocr, fetchImpl, networkTools, models: localModels } = ctx;
const r = asyncRouter();
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: config.images.maxMb * 1024 * 1024, files: 1 } });
@@@ -110,6 +110,45 @@ function createApi(ctx) {
r.get('/ping', (_req, res) => res.json({ ok: true, service: config.service }));
++ // Private tools use the verified owner user token as the caller. Rooms should forward a delegated user token;
++ // a bare service capability cannot assert ownership of private media.
++ const ownerOnly = (req, res, next) => isPerson(req) ? next() : problem(res, 403, 'vision.owner_required', 'a verified owner user token is required');
++ const owner = (req) => req.principal.subject;
++ const noRemoteSource = (req, res, next) => bodyOf(req).image_url ? invalid(res, 'private tools accept uploaded bytes or a data URL, not an unverified source URL') : next();
++
++ r.post('/alt', principal.requireCapability('vision.alt'), attachUpload, (req, res) => run(res, async () => ({ answer: await networkTools.alt(await imageOf(req)) }), 201));
++ r.post('/index', principal.requireCapability('vision.find'), ownerOnly, attachUpload, noRemoteSource, (req, res) => run(res, async () => {
++ const b = bodyOf(req);
++ return { answer: await networkTools.index(owner(req), b.item_id, await imageOf(req), b.result_url) };
++ }, 201));
++ r.get('/find', principal.requireCapability('vision.find'), ownerOnly, (req, res) => run(res, async () => {
++ const q = String(req.query.q || '').trim();
++ if (!q || q.length > 500) throw new VisionError(400, 'vision.invalid_query', 'q must contain 1–500 characters');
++ return { answer: await networkTools.find(owner(req), q) };
++ }));
++ r.post('/find', principal.requireCapability('vision.find'), ownerOnly, attachUpload, noRemoteSource, (req, res) => run(res, async () => ({ answer: await networkTools.find(owner(req), await imageOf(req)) })));
++ r.post('/receipt', principal.requireCapability('vision.receipt'), attachUpload, (req, res) => run(res, async () => ({ answer: await networkTools.readReceipt(await imageOf(req), bodyOf(req).fallback === true || bodyOf(req).fallback === 'true') }), 201));
++ r.post('/product', principal.requireCapability('vision.product'), attachUpload, (req, res) => run(res, async () => ({ answer: await networkTools.product(await imageOf(req), bodyOf(req).allow_rerank === true || bodyOf(req).allow_rerank === 'true') }), 201));
++ r.post('/watch', principal.requireCapability('vision.watch'), ownerOnly, attachUpload, noRemoteSource, (req, res) => run(res, async () => {
++ const b = bodyOf(req);
++ const classes = Array.isArray(b.classes) ? b.classes : String(b.classes || '').split(',').map((x) => x.trim()).filter(Boolean);
++ if (!classes.length || classes.length > 20) throw new VisionError(400, 'vision.invalid_classes', 'send 1–20 watched classes');
++ const previous = b.previous_image ? await loadImage({ image: b.previous_image, config, fetchImpl }) : null;
++ return { answer: await networkTools.watch(await imageOf(req), classes, previous) };
++ }, 201));
++ r.post('/moments', principal.requireCapability('vision.moments'), ownerOnly, (req, res) => run(res, async () => {
++ const b = bodyOf(req);
++ if (!Array.isArray(b.frames) || b.frames.length < 1 || b.frames.length > 30) throw new VisionError(400, 'vision.invalid_frames', 'send 1–30 timestamped frames');
++ const frames = [];
++ for (const f of b.frames) {
++ if (!Number.isFinite(Number(f.timestamp_ms)) || Number(f.timestamp_ms) < 0) throw new VisionError(400, 'vision.invalid_timestamp', 'each frame needs timestamp_ms');
++ if (!f.image && !f.image_url) throw new VisionError(400, 'vision.invalid_frames', 'each frame needs image or image_url');
++ frames.push({ timestamp_ms: Number(f.timestamp_ms), image: await loadImage({ image: f.image, imageUrl: f.image_url, config, fetchImpl }) });
++ }
++ const prompts = Array.isArray(b.prompts) ? b.prompts.map(String).filter(Boolean).slice(0, 10) : ['a goal', 'a funny fall'];
++ return { answer: await networkTools.moments(frames, prompts) };
++ }, 201));
++
// ── OCR: local first, anonymous local calls allowed ─────
r.post('/ocr', attachUpload, principal.optional('vision.ocr', { allowAnonymous: true }), async (req, res) => {
const b = bodyOf(req);
@@@ -224,7 +263,7 @@ function createApi(ctx) {
ai: ai.enabled,
configured: config.ai.models,
offered: modelCache.offered,
-- local: ['tesseract'],
++ local: ['tesseract', ...(localModels.clipReady() ? ['CLIP ViT-B/32 q8'] : []), ...(localModels.detectorReady() ? ['DETR ResNet-50 q8'] : []), 'histogram'],
note: 'provider:model hints; OpenVibe.AI\'s router is the authority and its provenance is what an answer reports',
});
});
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 8eb4d7f..051aa90 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -64,6 +64,14 @@ function createPageRoutes(ctx) {
body: raw(views.home({ signedIn: Boolean(signedIn(req)), ai: ai.enabled })),
}));
++ const toolPage = (req, res, title, intro, fields) => page(req, res, {
++ title, cache: PUBLIC_CACHE,
++ body: html`<main class="vi-wrap vi-page-pad vi-narrow"><header class="vi-section-head"><h1>${title}</h1><p class="vi-lede">${intro}</p></header><div class="vi-panel"><p class="vi-fine">These tools are available through Vision's JSON API. Sign in and send an image as multipart <code>image</code> or a data URL.</p>${fields}</div></main>`,
++ });
++ r.get('/find', (req, res) => toolPage(req, res, 'Find in your images', 'Private visual search uses local CLIP embeddings and checks the owner on each read.', html`<form method="get" action="/find"><label class="vi-label" for="vi-find-q">Search words</label><input id="vi-find-q" name="q" value="${String(req.query.q || '').slice(0, 500)}"><button type="submit">Search</button></form><p class="vi-fine">Room call: GET /api/v1/find?q= with the owner token; POST /api/v1/index to add an image.</p>`));
++ r.get('/read', (req, res) => toolPage(req, res, 'Read a receipt', 'Local Tesseract extracts store, line items and total; a routed fallback is optional.', html`<p class="vi-fine">Food calls POST /api/v1/receipt with the image and optional fallback=true. For plain text, use POST /api/v1/ocr.</p>`));
++ r.get('/watch', (req, res) => toolPage(req, res, 'Watch a still', 'Detect selected objects or a scene change in an owner-supplied camera still.', html`<p class="vi-fine">Cam calls POST /api/v1/watch with the owner token, image and classes. Send previous_image for scene change.</p>`));
++
// ── Ask ──────────────────────────────────────────────────
r.get('/ask', (req, res) => page(req, res, {
index: true, cache: PUBLIC_CACHE, title: 'Ask a picture',
ddiff --git a/server/http/principal.js b/server/http/principal.js
iindex b58edea..c43f8cd 100644
---- a/server/http/principal.js
++++ b/server/http/principal.js
@@@ -7,7 +7,7 @@
* a person: their Network user token as a Bearer, or this site's session cookie (vision_at).
* { kind: 'service', sub: 'svc:design', cap: [...], project, claims }
* a Network service, app or agent token for audience openvibe.vision. Each machine route names exactly one
-- * capability (vision.ui.check for Design's Arena, vision.ask, vision.ocr, vision.compare) and checks the
++ * capability (including the six network tools) and checks the
* token's grant list with openvibe-contracts capabilities.grants. Unknown capability ids are accepted here
* until the main session registers the manifests; the main session adds the grants.
* { kind: 'anonymous' }
@@@ -21,8 +21,8 @@ const { serviceAuth, capabilities, http, ids } = require('openvibe-contracts');
const PRINCIPAL_SUB = /^(svc|app|mod|agent):/;
const PROJECT_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
--/** Vision's four machine capabilities (the main session registers them; v1 accepts the grant directly). */
--const CAPABILITIES = Object.freeze(['vision.ask', 'vision.ocr', 'vision.compare', 'vision.ui.check']);
++/** Vision's machine capabilities (the main session registers them; Vision accepts the grant directly). */
++const CAPABILITIES = Object.freeze(['vision.ask', 'vision.ocr', 'vision.compare', 'vision.ui.check', 'vision.alt', 'vision.find', 'vision.receipt', 'vision.product', 'vision.watch', 'vision.moments']);
function decodePayload(token) {
const parts = String(token || '').split('.');
ddiff --git a/server/identity/account-data.js b/server/identity/account-data.js
iindex bc949d9..a1d1de1 100644
---- a/server/identity/account-data.js
++++ b/server/identity/account-data.js
@@@ -11,6 +11,7 @@
const { createAccountData, TOPICS } = require('openvibe-sdk/account-data');
const TABLES = [
++ { table: 'vision_private_index', subject: 'owner', value: (usr) => usr, file: 'private-index.json', erase: 'delete' },
// Compare samples before their answer, so the deletion counts them itself rather than through the FK cascade.
{ table: 'vision_compare_samples', subject: 'subject', value: (usr) => usr, file: 'compare.json', erase: 'delete' },
{ table: 'vision_answers', subject: 'subject', value: (usr) => usr, file: 'answers.json', erase: 'delete' },
ddiff --git a/server/observability.js b/server/observability.js
iindex b9483de..0d13cee 100644
---- a/server/observability.js
++++ b/server/observability.js
@@@ -8,7 +8,7 @@
const { jwksStatus } = require('openvibe-sdk/auth');
const { createReadiness } = require('openvibe-shared/ready');
--function createServiceReadiness({ s, config, valkey = null, release = null, ai = null, ocr = null }) {
++function createServiceReadiness({ s, config, valkey = null, release = null, ai = null, ocr = null, models = null }) {
return createReadiness({
service: 'vision',
release,
@@@ -33,6 +33,8 @@ function createServiceReadiness({ s, config, valkey = null, release = null, ai =
},
{ name: 'oauth_client', required: false, check: () => (config.oauth.clientSecret ? true : 'OV_OAUTH_CLIENT_SECRET unset: sign-in and AI calls cannot complete') },
{ name: 'ai', required: false, check: () => (ai && ai.enabled ? { ok: true, detail: { mode: 'on demand: a run is made only when a local engine cannot answer' } } : 'OpenVibe.AI is not configured: local OCR and the UI check still serve') },
++ { name: 'local_clip', required: false, check: () => models && models.clipReady() ? { ok: true, detail: 'quantized CLIP weights present' } : 'CLIP weights missing: private search, product embedding and moments return 503' },
++ { name: 'local_detector', required: false, check: () => models && models.detectorReady() ? { ok: true, detail: 'quantized DETR weights present' } : 'DETR weights missing: watched object classes return 503; scene change still works' },
{
name: 'local_ocr', required: false,
check: async () => {
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex c3a6078..a5b0fe9 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -22,6 +22,9 @@ const NAV = [
{ label: 'Home', href: '/' },
{ label: 'Ask a picture', href: '/ask' },
{ label: 'OCR', href: '/ocr' },
++ { label: 'Find', href: '/find' },
++ { label: 'Read', href: '/read' },
++ { label: 'Watch', href: '/watch' },
{ label: 'Compare', href: '/compare' },
{ label: 'UI check', href: '/ui-check' },
{ label: 'Developers', href: '/developers' },
ddiff --git a/server/views/index.js b/server/views/index.js
iindex f7f6cc5..cf0743e 100644
---- a/server/views/index.js
++++ b/server/views/index.js
@@@ -230,6 +230,12 @@ ${ep('POST', '/api/v1/ask (alias /api/v1/describe)', 'One image and a question:
${ep('POST', '/api/v1/ocr', 'The local Tesseract reader. <code>fallback=1</code> asks AI only when no text is found and the caller is signed in or holds <code>vision.ocr</code>.')}
${ep('POST', '/api/v1/compare', 'The same image through local OCR and one to three AI hints (<code>models</code>). Returns each engine\'s answer, provider, model, cost and latency. Capability: <code>vision.compare</code>.')}
${ep('POST', '/api/v1/ui-check', 'HTML, URL or screenshot with <code>viewport</code>. Returns <code>findings</code>: overflow, cut_off, contrast, overlap and layering. Capability: <code>vision.ui.check</code>.')}
++${ep('POST', '/api/v1/alt', 'Pics, Media and Chat send an image for short editable alt text. Capability: <code>vision.alt</code>.')}
++${ep('POST', '/api/v1/index; GET or POST /api/v1/find', 'Media and Search index an owner image and find it by words or image. Requires the owner user token. Capability: <code>vision.find</code>.')}
++${ep('POST', '/api/v1/receipt', 'Food sends a receipt image; Tesseract reads store, items and total first. Capability: <code>vision.receipt</code>.')}
++${ep('POST', '/api/v1/product', 'Deals sends a product image for description, CLIP vector and configured offer candidates. Capability: <code>vision.product</code>.')}
++${ep('POST', '/api/v1/watch', 'Cam sends an owner-provided still and watched classes. Requires the owner user token. Capability: <code>vision.watch</code>.')}
++${ep('POST', '/api/v1/moments', 'Live or Media sends timestamped frames for scenes and candidate moments. Requires the owner user token. Capability: <code>vision.moments</code>.')}
${ep('GET', '/api/v1/answers/:id', 'One saved answer, for its owner or a service holding the route capability.')}
${ep('GET', '/api/v1/ui-checks/:id', 'One saved UI check, for its owner or a service holding <code>vision.ui.check</code>.')}
${ep('GET', '/api/v1/models', 'The AI route\'s offered provider:model hints and the local engine. No subject data.')}
ddiff --git a/server/vision/local-models.js b/server/vision/local-models.js
nnew file mode 100644
iindex 0000000..dedb67a
---- /dev/null
++++ b/server/vision/local-models.js
@@@ -0,0 +1,58 @@
++'use strict';
++const fs = require('fs');
++const path = require('path');
++const sharp = require('sharp');
++const { VisionError } = require('./service');
++
++function unit(data) {
++ const v = Array.from(data, Number);
++ const norm = Math.hypot(...v);
++ if (!norm || !v.every(Number.isFinite)) throw new VisionError(503, 'vision.model_failed', 'the local model returned an invalid embedding');
++ return v.map((x) => x / norm);
++}
++function cosine(a, b) { return a.length === b.length ? a.reduce((n, x, i) => n + x * b[i], 0) : -1; }
++function createLocalModels({ config, clip: fakeClip, detector: fakeDetector } = {}) {
++ const root = path.resolve(config.models.dir);
++ const clipDir = path.join(root, 'clip');
++ const detectorDir = path.join(root, 'detr');
++ let clipPromise; let detectorPromise;
++ const clipReady = () => Boolean(fakeClip || (fs.existsSync(path.join(clipDir, 'onnx/text_model_quantized.onnx')) && fs.existsSync(path.join(clipDir, 'onnx/vision_model_quantized.onnx'))));
++ const detectorReady = () => Boolean(fakeDetector || fs.existsSync(path.join(detectorDir, 'onnx/model_quantized.onnx')));
++ async function clip() {
++ if (fakeClip) return fakeClip;
++ if (!clipReady()) throw new VisionError(503, 'vision.clip_unavailable', 'local CLIP weights are missing; run npm run models:fetch');
++ if (!clipPromise) clipPromise = (async () => {
++ const { env, AutoTokenizer, AutoProcessor, CLIPTextModelWithProjection, CLIPVisionModelWithProjection, RawImage } = await import('@huggingface/transformers');
++ env.allowRemoteModels = false;
++ const opts = { local_files_only: true, dtype: 'q8' };
++ const [tokenizer, processor, textModel, visionModel] = await Promise.all([
++ AutoTokenizer.from_pretrained(clipDir, opts), AutoProcessor.from_pretrained(clipDir, opts),
++ CLIPTextModelWithProjection.from_pretrained(clipDir, opts), CLIPVisionModelWithProjection.from_pretrained(clipDir, opts),
++ ]);
++ return {
++ async text(q) { return unit((await textModel(await tokenizer(q, { padding: true, truncation: true }))).text_embeds.data); },
++ async image(buffer) { const img = await RawImage.read(new Blob([buffer])); return unit((await visionModel(await processor(img))).image_embeds.data); },
++ };
++ })().catch((e) => { clipPromise = null; throw new VisionError(503, 'vision.clip_unavailable', `local CLIP could not load: ${e.message}`); });
++ return clipPromise;
++ }
++ async function detect(buffer) {
++ if (fakeDetector) return fakeDetector.detect(buffer);
++ if (!detectorReady()) throw new VisionError(503, 'vision.detector_unavailable', 'local detector weights are missing; run npm run models:fetch');
++ if (!detectorPromise) detectorPromise = (async () => {
++ const { env, pipeline } = await import('@huggingface/transformers');
++ env.allowRemoteModels = false;
++ return pipeline('object-detection', detectorDir, { local_files_only: true, dtype: 'q8' });
++ })().catch((e) => { detectorPromise = null; throw new VisionError(503, 'vision.detector_unavailable', `local detector could not load: ${e.message}`); });
++ return (await detectorPromise)(new Blob([buffer]), { threshold: 0.3 });
++ }
++ async function histogram(buffer) {
++ const { data } = await sharp(buffer).rotate().resize(32, 32, { fit: 'fill' }).removeAlpha().raw().toBuffer({ resolveWithObject: true });
++ const h = new Array(48).fill(0);
++ for (let i = 0; i < data.length; i += 3) for (let c = 0; c < 3; c++) h[c * 16 + Math.min(15, Math.floor(data[i + c] / 16))]++;
++ return h.map((n) => n / 3072);
++ }
++ function histogramDistance(a, b) { return a.reduce((n, x, i) => n + Math.abs(x - b[i]), 0) / 6; }
++ return { clip, detect, clipReady, detectorReady, histogram, histogramDistance, cosine };
++}
++module.exports = { createLocalModels, cosine, unit };
ddiff --git a/server/vision/network-tools.js b/server/vision/network-tools.js
nnew file mode 100644
iindex 0000000..9482e95
---- /dev/null
++++ b/server/vision/network-tools.js
@@@ -0,0 +1,143 @@
++'use strict';
++const { VisionError } = require('./service');
++const { forModel } = require('./images');
++const { cleanAnswer } = require('../text/no-looks');
++
++const receipt = (engine, model, ms, confidence, retention = 'not stored', cost = 0) => ({ engine, model, cost_usd: cost, ms, confidence, retention });
++function parseReceipt(text) {
++ const lines = String(text || '').split(/\r?\n/).map((x) => x.trim()).filter(Boolean);
++ const money = /(?:[$€£]\s*)?(\d+[.,]\d{2})\s*$/;
++ const totalLine = [...lines].reverse().find((x) => /\b(total|amount due|balance due)\b/i.test(x) && money.test(x));
++ const total = totalLine ? Number(totalLine.match(money)[1].replace(',', '.')) : null;
++ const items = lines.filter((x) => money.test(x) && !/\b(total|tax|subtotal|balance|change|cash|card|payment|discount)\b/i.test(x)).map((x) => {
++ const price = Number(x.match(money)[1].replace(',', '.'));
++ const name = x.replace(money, '').trim();
++ const qtyMatch = name.match(/^(\d+(?:\.\d+)?)\s*[xX×]\s+(.+)$/);
++ return { name: qtyMatch ? qtyMatch[2] : name, qty: qtyMatch ? Number(qtyMatch[1]) : 1, price };
++ }).filter((x) => x.name).slice(0, 100);
++ const store = lines.find((x) => !money.test(x) && /[a-z]{3}/i.test(x)) || null;
++ return { store, items, total };
++}
++function createNetworkTools({ s, ai, ocr, models, config, fetchImpl = fetch }) {
++ async function routed(image, task, question) {
++ if (!ai.enabled) throw new VisionError(503, 'vision.ai_unavailable', 'OpenVibe.AI is not configured');
++ const normal = await forModel(image.buffer);
++ try { return await ai.answer({ dataUrl: normal.dataUrl, task, question }); }
++ catch (e) { throw new VisionError(e.status || 502, e.code || 'vision.ai_failed', e.detail || 'OpenVibe.AI could not answer'); }
++ }
++ async function alt(image) {
++ const a = await routed(image, 'describe', 'Write one short, editable alt text sentence about the scene, objects and action. Never name a person or describe any person’s appearance.');
++ const text = cleanAnswer(a.text).replace(/\s+/g, ' ').slice(0, 240);
++ return { alt_text: text, editable: true, receipt: receipt('routed', a.model, a.ms, null, 'not stored', a.costUsd) };
++ }
++ async function index(owner, itemId, image, url) {
++ const start = Date.now();
++ if (!/^[\w:.-]{1,200}$/.test(String(itemId || ''))) throw new VisionError(400, 'vision.invalid_item', 'item_id must be 1–200 safe characters');
++ if (url && (!/^https:\/\//.test(url) || url.length > 2000)) throw new VisionError(400, 'vision.invalid_url', 'result_url must be an HTTPS URL');
++ const vector = await (await models.clip()).image(image.buffer);
++ await s.db.query(`INSERT INTO vision_private_index (owner, item_id, result_url, image_sha256, embedding, created_at)
++ VALUES ($1,$2,$3,$4,$5::jsonb,now()) ON CONFLICT (owner,item_id) DO UPDATE SET result_url=$3,image_sha256=$4,embedding=$5::jsonb,created_at=now()`,
++ [owner, itemId, url || null, image.sha256, JSON.stringify(vector)]);
++ return { item_id: itemId, receipt: receipt('local', 'CLIP ViT-B/32 q8', Date.now() - start, null, 'embedding stored; image not stored') };
++ }
++ async function find(owner, query) {
++ const start = Date.now();
++ const vector = typeof query === 'string' ? await (await models.clip()).text(query) : await (await models.clip()).image(query.buffer);
++ // The owner predicate is mandatory on every DB read. JS cosine is the v1 pgvector-free scoring path.
++ const rows = await s.db.many('SELECT item_id,result_url,embedding FROM vision_private_index WHERE owner=$1 ORDER BY created_at DESC LIMIT 5000', [owner]);
++ const results = rows.map((r) => ({ item_id: r.item_id, result_url: r.result_url, score: models.cosine(vector, r.embedding) }))
++ .filter((r) => Number.isFinite(r.score)).sort((a, b) => b.score - a.score).slice(0, 30);
++ return { results, receipt: receipt('local', 'CLIP ViT-B/32 q8', Date.now() - start, results[0]?.score ?? null, 'query not stored; indexed embeddings stored') };
++ }
++ async function readReceipt(image, allowFallback) {
++ const start = Date.now();
++ const r = await ocr.recognize(image.buffer);
++ if (!r.ok && !allowFallback) throw new VisionError(503, r.code || 'vision.ocr_unavailable', r.detail || 'local OCR is unavailable');
++ if (r.ok && String(r.text || '').trim()) {
++ const parsed = parseReceipt(r.text);
++ if (parsed.items.length && parsed.total !== null) return { ...parsed, raw_text: r.text, receipt: receipt('local', 'Tesseract 5', Date.now() - start, r.confidence / 100) };
++ }
++ if (!allowFallback) throw new VisionError(422, 'vision.receipt_uncertain', 'local OCR could not extract line items and total; set fallback=true to use routed AI');
++ const a = await routed(image, 'ocr', 'Read this receipt. Return JSON with store, items [{name,qty,price}], and total. Use null for missing values.');
++ let parsed;
++ try { parsed = JSON.parse(a.text.replace(/^```(?:json)?\s*|\s*```$/g, '')); } catch { throw new VisionError(502, 'vision.receipt_invalid', 'routed model did not return receipt JSON'); }
++ if (!Array.isArray(parsed.items)) throw new VisionError(502, 'vision.receipt_invalid', 'routed model omitted receipt items');
++ return { store: String(parsed.store || '').slice(0, 150) || null, items: parsed.items.slice(0, 100).map((x) => ({ name: String(x.name || '').slice(0, 200), qty: Number(x.qty) || 1, price: Number(x.price) || 0 })), total: Number(parsed.total) || null, receipt: receipt('routed', a.model, Date.now() - start, null, 'not stored', a.costUsd) };
++ }
++ async function product(image, allowRerank) {
++ const start = Date.now();
++ const vector = await (await models.clip()).image(image.buffer);
++ const a = await routed(image, 'identify', 'Describe this product only: visible type, brand if legible, colour and identifying features. Do not describe people. One short sentence.');
++ const description = cleanAnswer(a.text).slice(0, 300);
++ const offers = []; const warnings = [];
++ for (const [name, base] of [['deals', config.offers.dealsUrl], ['open.shoes', config.offers.shoesUrl]]) {
++ if (!base) { warnings.push(`${name} search API not configured`); continue; }
++ try {
++ const u = new URL(base); u.searchParams.set('q', description);
++ const res = await fetchImpl(u, { signal: AbortSignal.timeout(5000), headers: { Accept: 'application/json' } });
++ if (!res.ok) throw new Error('HTTP error');
++ const data = await res.json();
++ const candidates = Array.isArray(data.offers) ? data.offers : Array.isArray(data.results) ? data.results : [];
++ for (const c of candidates.slice(0, 20)) if (c && typeof (c.title || c.name) === 'string' && /^https:\/\//.test(c.url || '')) offers.push({ source: name, title: String(c.title || c.name).slice(0, 200), url: c.url, price: c.price ?? null });
++ } catch { warnings.push(`${name} search unavailable`); }
++ }
++ const clip = await models.clip();
++ for (const offer of offers) offer.score = models.cosine(vector, await clip.text(offer.title));
++ offers.sort((x, y) => y.score - x.score);
++ let rerankCost = 0;
++ if (allowRerank && offers.length) {
++ const ranked = await routed(image, 'identify', `Given this product photo, rank these candidate offer titles by visual match. Return a comma-separated list of candidate numbers only: ${offers.slice(0, 10).map((o, i) => `${i + 1}: ${o.title}`).join('; ')}`);
++ rerankCost = ranked.costUsd;
++ const order = ranked.text.match(/\d+/g)?.map(Number) || [];
++ if (order.length) {
++ const original = offers.slice();
++ const unique = [...new Set(order.filter((n) => n >= 1 && n <= original.length))];
++ offers.splice(0, offers.length, ...unique.map((n) => original[n - 1]), ...original.filter((_, i) => !unique.includes(i + 1)));
++ }
++ }
++ return { description, embedding: vector, offers: offers.slice(0, 10), warnings, receipt: receipt(allowRerank && offers.length ? 'local+routed' : 'local+routed description', a.model, Date.now() - start, offers[0]?.score ?? null, 'not stored', a.costUsd + rerankCost) };
++ }
++ async function watch(image, classes, previous) {
++ const start = Date.now();
++ const wanted = classes.map((x) => String(x).toLowerCase());
++ if (wanted.includes('scene change') && !previous) throw new VisionError(400, 'vision.previous_required', 'scene change needs previous_image');
++ const detections = [];
++ if (wanted.some((x) => x !== 'scene change')) {
++ const found = await models.detect(image.buffer);
++ for (const d of found) if (wanted.includes(String(d.label).toLowerCase())) detections.push({ class: d.label, confidence: d.score, box: d.box });
++ const sceneClasses = wanted.filter((x) => ['snow', 'delivery', 'package'].includes(x));
++ if (sceneClasses.length) {
++ const clip = await models.clip();
++ const imageVector = await clip.image(image.buffer);
++ for (const label of sceneClasses) {
++ const prompt = label === 'snow' ? 'a snowy outdoor scene' : 'a delivery package at a door';
++ const score = models.cosine(imageVector, await clip.text(prompt));
++ if (score >= 0.25) detections.push({ class: label, confidence: score, box: null });
++ }
++ }
++ }
++ if (wanted.includes('scene change') && previous) {
++ const diff = models.histogramDistance(await models.histogram(previous.buffer), await models.histogram(image.buffer));
++ if (diff >= 0.22) detections.push({ class: 'scene change', confidence: Math.min(1, diff) });
++ }
++ return { detections, receipt: receipt('local', wanted.some((x) => x !== 'scene change') ? 'DETR ResNet-50 q8 + histogram' : 'histogram', Date.now() - start, detections[0]?.confidence ?? null) };
++ }
++ async function moments(frames, prompts) {
++ const start = Date.now();
++ const clip = await models.clip();
++ const vectors = await Promise.all(prompts.map((p) => clip.text(p)));
++ const scenes = []; const candidates = [];
++ let previous = null;
++ for (const frame of frames) {
++ const hist = await models.histogram(frame.image.buffer);
++ if (previous && models.histogramDistance(previous, hist) >= 0.22) scenes.push({ timestamp_ms: frame.timestamp_ms, kind: 'scene_change' });
++ previous = hist;
++ const embedding = await clip.image(frame.image.buffer);
++ const scored = prompts.map((prompt, i) => ({ prompt, score: models.cosine(embedding, vectors[i]) })).sort((a, b) => b.score - a.score);
++ if (scored[0] && scored[0].score >= 0.2) candidates.push({ timestamp_ms: frame.timestamp_ms, ...scored[0] });
++ }
++ return { scene_changes: scenes, candidate_moments: candidates.sort((a, b) => b.score - a.score).slice(0, 30), receipt: receipt('local', 'CLIP ViT-B/32 q8 + histogram', Date.now() - start, candidates[0]?.score ?? null) };
++ }
++ return { alt, index, find, readReceipt, product, watch, moments };
++}
++module.exports = { createNetworkTools, parseReceipt, receipt };
ddiff --git a/test/helpers/boot.js b/test/helpers/boot.js
iindex e0eeeac..5094de5 100644
---- a/test/helpers/boot.js
++++ b/test/helpers/boot.js
@@@ -46,7 +46,7 @@ async function boot(opts = {}) {
const { createStore } = require('../../server/db');
const testdb = await require('./db').testDb();
const store = createStore(testdb.db, { now: opts.now });
-- const built = await createApp({ config, store, log, telemetrySink: opts.telemetrySink, accountSend: opts.accountSend, ocr: opts.ocr, ai: opts.aiClient });
++ const built = await createApp({ config, store, log, telemetrySink: opts.telemetrySink, accountSend: opts.accountSend, ocr: opts.ocr, ai: opts.aiClient, clip: opts.clip, detector: opts.detector });
await built.ctx.keys.ensure();
const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
const base = `http://127.0.0.1:${server.address().port}`;
ddiff --git a/test/network-tools.test.js b/test/network-tools.test.js
nnew file mode 100644
iindex 0000000..2b50bd2
---- /dev/null
++++ b/test/network-tools.test.js
@@@ -0,0 +1,89 @@
++'use strict';
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++const { parseReceipt } = require('../server/vision/network-tools');
++(async () => {
++ const clip = { async image() { return [1, 0]; }, async text(q) { return /bike|goal/.test(q) ? [1, 0] : [0, 1]; } };
++ const detector = { async detect() { return [{ label: 'cat', score: 0.92, box: { xmin: 0, ymin: 0, xmax: 10, ymax: 10 } }]; } };
++ const ocr = { async recognize() { return { ok: true, text: 'MARKET\nOat milk 3.40\nBread 2.10\nTOTAL 5.50', confidence: 91 }; }, async available() { return 'fake-tesseract'; } };
++ const t = await boot({ clip, detector, ocr });
++ const owner = t.network.addUser('owner');
++ const other = t.network.addUser('other');
++ const png = await t.textImage('MARKET\nOat milk 3.40\nBread 2.10\nTOTAL 5.50');
++ const image = `data:image/png;base64,${png.toString('base64')}`;
++ try {
++ await check('owner-scoped CLIP index and every find read', async () => {
++ const indexed = await t.get('/api/v1/index', { as: owner, json: { item_id: 'photo-1', image } });
++ assert.strictEqual(indexed.status, 201, indexed.text);
++ assert.strictEqual(indexed.json().answer.receipt.retention.includes('stored'), true);
++ const mine = await t.get('/api/v1/find?q=bike', { as: owner });
++ assert.strictEqual(mine.status, 200, mine.text);
++ assert.strictEqual(mine.json().answer.results[0].item_id, 'photo-1');
++ assert.ok(mine.json().answer.receipt);
++ const theirs = await t.get('/api/v1/find?q=bike', { as: other });
++ assert.strictEqual(theirs.status, 200);
++ assert.deepStrictEqual(theirs.json().answer.results, []);
++ const svc = t.network.serviceToken({ sub: 'svc:media', cap: ['vision.find'] });
++ assert.strictEqual((await t.get('/api/v1/find?q=bike', { bearer: svc })).status, 403);
++ const byImage = await t.get('/api/v1/find', { as: owner, json: { image } });
++ assert.strictEqual(byImage.json().answer.results[0].item_id, 'photo-1');
++ });
++ await check('alt text sends noLooks and cleans the answer', async () => {
++ t.aiServer.setText('A shirtless man holds a bicycle.');
++ const r = await t.get('/api/v1/alt', { as: owner, json: { image } });
++ assert.strictEqual(r.status, 201, r.text);
++ assert.ok(!/shirtless/.test(r.json().answer.alt_text));
++ assert.ok(r.json().answer.editable);
++ assert.strictEqual(r.json().answer.receipt.engine, 'routed');
++ const sent = t.aiServer.requests.filter((x) => x.path === '/v1/chat/completions').at(-1);
++ assert.match(JSON.stringify(sent.body), /Never describe a person's body/);
++ });
++ await check('receipt parser and local route return structured lines and receipt', async () => {
++ assert.deepStrictEqual(parseReceipt('MARKET\n2 x Apples 3.40\nTOTAL 3.40').items[0], { name: 'Apples', qty: 2, price: 3.4 });
++ const r = await t.get('/api/v1/receipt', { as: owner, json: { image } });
++ assert.strictEqual(r.status, 201, r.text);
++ assert.strictEqual(r.json().answer.receipt.engine, 'local');
++ assert.ok(r.json().answer.items.length);
++ });
++ await check('watch and moments include detections and receipts; service without owner fails', async () => {
++ const w = await t.get('/api/v1/watch', { as: owner, json: { image, classes: ['cat'] } });
++ assert.strictEqual(w.status, 201, w.text);
++ assert.strictEqual(w.json().answer.detections[0].class, 'cat');
++ assert.ok(w.json().answer.receipt);
++ const m = await t.get('/api/v1/moments', { as: owner, json: { frames: [{ timestamp_ms: 1000, image }], prompts: ['a goal'] } });
++ assert.strictEqual(m.status, 201, m.text);
++ assert.ok(m.json().answer.receipt);
++ assert.strictEqual(m.json().answer.candidate_moments[0].timestamp_ms, 1000);
++ const svc = t.network.serviceToken({ sub: 'svc:cam', cap: ['vision.watch'] });
++ assert.strictEqual((await t.get('/api/v1/watch', { bearer: svc, json: { image, classes: ['cat'] } })).status, 403);
++ });
++ await check('product has embedding and receipt even if offer APIs are not configured', async () => {
++ t.aiServer.setText('Blue bicycle');
++ const r = await t.get('/api/v1/product', { as: owner, json: { image } });
++ assert.strictEqual(r.status, 201, r.text);
++ assert.deepStrictEqual(r.json().answer.embedding, [1, 0]);
++ assert.ok(r.json().answer.receipt);
++ });
++ await check('missing weights degrade without a model download', async () => {
++ const { createLocalModels } = require('../server/vision/local-models');
++ const absent = createLocalModels({ config: { models: { dir: '/tmp/vision-models-absent' } } });
++ assert.strictEqual(absent.clipReady(), false);
++ await assert.rejects(absent.clip(), (e) => e.status === 503 && e.code === 'vision.clip_unavailable');
++ const original = t.ctx.models.clip;
++ t.ctx.models.clip = absent.clip;
++ try {
++ const r = await t.get('/api/v1/index', { as: owner, json: { item_id: 'unavailable', image } });
++ assert.strictEqual(r.status, 503);
++ assert.strictEqual(r.json().code, 'vision.clip_unavailable');
++ } finally { t.ctx.models.clip = original; }
++ });
++ await check('plain tool pages render with vi classes', async () => {
++ for (const path of ['/find', '/read', '/watch']) {
++ const r = await t.get(path);
++ assert.strictEqual(r.status, 200, `${path}: ${r.text}`);
++ assert.match(r.text, /class="vi-/);
++ }
++ });
++ } finally { await t.close(); }
++ done();
++})();