openvibers/openvibe.vision Commits
Vision network tools, wave 1: alt text, private visual search, receipts, products, camera watch, moments (#1)
Changes
@@@ -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@@@ -181,3 +181,24 @@ export/deletion and the image SSRF guard.## LicenseAGPL-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.nnew file mode 100644@@@ -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);@@@ -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"++ }}}}@@@ -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",nnew file mode 100644@@@ -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; });@@@ -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) ──@@@ -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.@@@ -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',});});@@@ -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',@@@ -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('.');@@@ -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' },@@@ -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 () => {@@@ -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' },@@@ -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.')}nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };@@@ -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}`;nnew file mode 100644@@@ -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();++})();
@@ -69,3 +69,9 @@ VISION_TESSERACT=tesseract
@@ -69,3 +69,9 @@ VISION_TESSERACT=tesseract
# optional: defaults to true in production
# optional: defaults to true in production
# COOKIE_SECURE=true
# 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
@@ -181,3 +181,24 @@ export/deletion and the image SSRF guard.
@@ -181,3 +181,24 @@ export/deletion and the image SSRF guard.
## License
## License
AGPL-3.0-only. See [LICENSE](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.
new file mode 100644
new file mode 100644
@@ -0,0 +1,12 @@
@@ -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);
@@ -9,6 +9,7 @@
@@ -9,6 +9,7 @@
"version": "0.1.0",
"version": "0.1.0",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
+ "@huggingface/transformers": "3.8.1",
"cookie-parser": "^1.4.7",
"cookie-parser": "^1.4.7",
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
@@ -46,6 +47,477 @@
@@ -46,6 +47,477 @@
"tslib": "^2.4.0"
"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": {
"node_modules/@img/sharp-darwin-arm64": {
"version": "0.33.5",
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz",
@@ -146,7 +618,45 @@
@@ -146,7 +618,45 @@
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz",
"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==",
"integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==",
"cpu": [
"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": [
"libc": [
"glibc"
"glibc"
@@ -286,6 +796,56 @@
@@ -286,6 +796,56 @@
"@img/sharp-libvips-linux-arm64": "1.0.4"
"@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": {
"node_modules/@img/sharp-linux-s390x": {
"version": "0.33.5",
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz",
@@ -405,6 +965,25 @@
@@ -405,6 +965,25 @@
"url": "https://opencollective.com/libvips"
"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": {
"node_modules/@img/sharp-win32-ia32": {
"version": "0.33.5",
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz",
@@ -449,6 +1028,84 @@
@@ -449,6 +1028,84 @@
"integrity": "sha512-/B9W4qKSSITDii5nkBCHyPkIkAi+ealUtr1oqBJsLxjSRLka4pxun2VvMNSmcwgAMxgXtQfl0qRv7TE+udPJzg==",
"integrity": "sha512-/B9W4qKSSITDii5nkBCHyPkIkAi+ealUtr1oqBJsLxjSRLka4pxun2VvMNSmcwgAMxgXtQfl0qRv7TE+udPJzg==",
"license": "MIT"
"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": {
"node_modules/accepts": {
"version": "1.3.8",
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
@@ -531,6 +1188,13 @@
@@ -531,6 +1188,13 @@
"npm": "1.2.8000 || >= 1.4.16"
"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": {
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
@@ -586,6 +1250,15 @@
@@ -586,6 +1250,15 @@
"url": "https://github.com/sponsors/ljharb"
"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": {
"node_modules/cluster-key-slot": {
"version": "1.1.2",
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
@@ -694,6 +1367,40 @@
@@ -694,6 +1367,40 @@
"ms": "2.0.0"
"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": {
"node_modules/denque": {
"version": "2.1.0",
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
@@ -731,6 +1438,12 @@
@@ -731,6 +1438,12 @@
"node": ">=8"
"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": {
"node_modules/dotenv": {
"version": "16.6.1",
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
@@ -811,12 +1524,30 @@
@@ -811,12 +1524,30 @@
"node": ">= 0.4"
"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": {
"node_modules/escape-html": {
"version": "1.0.3",
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"license": "MIT"
"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": {
"node_modules/etag": {
"version": "1.8.1",
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
@@ -927,6 +1658,12 @@
@@ -927,6 +1658,12 @@
"node": ">= 0.8"
"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": {
"node_modules/forwarded": {
"version": "0.2.0",
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@@ -991,6 +1728,39 @@
@@ -991,6 +1728,39 @@
"node": ">= 0.4"
"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": {
"node_modules/gopd": {
"version": "1.2.0",
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
@@ -1003,6 +1773,24 @@
@@ -1003,6 +1773,24 @@
"url": "https://github.com/sponsors/ljharb"
"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": {
"node_modules/has-symbols": {
"version": "1.1.0",
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
@@ -1141,6 +1929,12 @@
@@ -1141,6 +1929,12 @@
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"license": "MIT"
"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": {
"node_modules/jsonwebtoken": {
"version": "9.0.3",
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
@@ -1244,6 +2038,24 @@
@@ -1244,6 +2038,24 @@
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
"license": "MIT"
"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": {
"node_modules/math-intrinsics": {
"version": "1.1.0",
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -1313,6 +2125,27 @@
@@ -1313,6 +2125,27 @@
"node": ">= 0.6"
"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": {
"node_modules/ms": {
"version": "2.0.0",
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
@@ -1358,6 +2191,15 @@
@@ -1358,6 +2191,15 @@
"url": "https://github.com/sponsors/ljharb"
"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": {
"node_modules/on-finished": {
"version": "2.4.2",
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.2.tgz",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.2.tgz",
@@ -1374,6 +2216,49 @@
@@ -1374,6 +2216,49 @@
"url": "https://opencollective.com/express"
"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": {
"node_modules/openvibe-contracts": {
"version": "0.235.0",
"version": "0.235.0",
"resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.235.0",
"resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.235.0",
@@ -1534,6 +2419,12 @@
@@ -1534,6 +2419,12 @@
"split2": "^4.1.0"
"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": {
"node_modules/postgres-array": {
"version": "2.0.0",
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
@@ -1573,6 +2464,29 @@
@@ -1573,6 +2464,29 @@
"node": ">=0.10.0"
"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": {
"node_modules/proxy-addr": {
"version": "2.0.8",
"version": "2.0.8",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",
@@ -1660,6 +2574,23 @@
@@ -1660,6 +2574,23 @@
"node": ">=0.10.0"
"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": {
"node_modules/safe-buffer": {
"version": "5.2.1",
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
@@ -1698,6 +2629,12 @@
@@ -1698,6 +2629,12 @@
"node": ">=10"
"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": {
"node_modules/send": {
"version": "0.19.2",
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
"resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
@@ -1728,6 +2665,21 @@
@@ -1728,6 +2665,21 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
"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": {
"node_modules/serve-static": {
"version": "1.16.3",
"version": "1.16.3",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
@@ -1878,6 +2830,12 @@
@@ -1878,6 +2830,12 @@
"node": ">= 10.x"
"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": {
"node_modules/standard-as-callback": {
"version": "2.1.0",
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
"resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
@@ -1901,6 +2859,22 @@
@@ -1901,6 +2859,22 @@
"node": ">=10.0.0"
"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": {
"node_modules/toidentifier": {
"version": "1.0.1",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
@@ -1917,6 +2891,18 @@
@@ -1917,6 +2891,18 @@
"license": "0BSD",
"license": "0BSD",
"optional": true
"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": {
"node_modules/type-is": {
"version": "1.6.18",
"version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
@@ -1930,6 +2916,12 @@
@@ -1930,6 +2916,12 @@
"node": ">= 0.6"
"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": {
"node_modules/unpipe": {
"version": "1.0.0",
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
@@ -1965,6 +2957,15 @@
@@ -1965,6 +2957,15 @@
"engines": {
"engines": {
"node": ">=0.4"
"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"
+ }
}
}
}
}
}
}
@@ -9,7 +9,8 @@
@@ -9,7 +9,8 @@
"dev": "NODE_ENV=development node server/index.js",
"dev": "NODE_ENV=development node server/index.js",
"proof": "node scripts/proof.js",
"proof": "node scripts/proof.js",
"test": "node test/run.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": [
"keywords": [
"openvibe"
"openvibe"
@@ -17,6 +18,7 @@
@@ -17,6 +18,7 @@
"author": "OpenVibers",
"author": "OpenVibers",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
+ "@huggingface/transformers": "3.8.1",
"cookie-parser": "^1.4.7",
"cookie-parser": "^1.4.7",
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
new file mode 100644
new file mode 100644
@@ -0,0 +1,26 @@
@@ -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; });
@@ -62,6 +62,8 @@ async function createApp(opts = {}) {
@@ -62,6 +62,8 @@ async function createApp(opts = {}) {
const ocr = opts.ocr || createOcr({ config, log });
const ocr = opts.ocr || createOcr({ config, log });
const ai = opts.ai || createAi({ config, fetchImpl, log });
const ai = opts.ai || createAi({ config, fetchImpl, log });
const vision = createVision({ config, s, ocr, ai, store, 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
// 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.
// client-credentials token; a test injects a stand-in through opts.accountSend.
@@ -70,7 +72,7 @@ async function createApp(opts = {}) {
@@ -70,7 +72,7 @@ async function createApp(opts = {}) {
? createNetworkSender({ networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl })
? 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'); });
: 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();
const app = express();
app.disable('x-powered-by');
app.disable('x-powered-by');
@@ -113,7 +115,7 @@ async function createApp(opts = {}) {
@@ -113,7 +115,7 @@ async function createApp(opts = {}) {
// ── Machine endpoints ───────────────────────────────────
// ── Machine endpoints ───────────────────────────────────
app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-vision', name: 'OpenVibe.Vision', version: VERSION }));
app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-vision', name: 'OpenVibe.Vision', version: VERSION }));
release.mount(app, { registry: metrics.registry });
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);
app.get('/api/ready', readiness.handler);
// ── Who is asking (verified offline; refreshed when expired) ──
// ── Who is asking (verified offline; refreshed when expired) ──
@@ -83,6 +83,11 @@ function load(env = process.env) {
@@ -83,6 +83,11 @@ function load(env = process.env) {
lang: env.VISION_OCR_LANG || 'eng',
lang: env.VISION_OCR_LANG || 'eng',
timeoutMs: Math.max(2000, int(env.VISION_OCR_TIMEOUT_MS, 20000)),
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
// 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.
// guard. No image bytes are stored in the database or written to a page.
@@ -25,7 +25,7 @@ const { loadImage } = require('../vision/images');
@@ -25,7 +25,7 @@ const { loadImage } = require('../vision/images');
const { VisionError } = require('../vision/service');
const { VisionError } = require('../vision/service');
function createApi(ctx) {
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 r = asyncRouter();
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: config.images.maxMb * 1024 * 1024, files: 1 } });
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: config.images.maxMb * 1024 * 1024, files: 1 } });
@@ -110,6 +110,45 @@ function createApi(ctx) {
@@ -110,6 +110,45 @@ function createApi(ctx) {
r.get('/ping', (_req, res) => res.json({ ok: true, service: config.service }));
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 ─────
// ── OCR: local first, anonymous local calls allowed ─────
r.post('/ocr', attachUpload, principal.optional('vision.ocr', { allowAnonymous: true }), async (req, res) => {
r.post('/ocr', attachUpload, principal.optional('vision.ocr', { allowAnonymous: true }), async (req, res) => {
const b = bodyOf(req);
const b = bodyOf(req);
@@ -224,7 +263,7 @@ function createApi(ctx) {
@@ -224,7 +263,7 @@ function createApi(ctx) {
ai: ai.enabled,
ai: ai.enabled,
configured: config.ai.models,
configured: config.ai.models,
offered: modelCache.offered,
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',
note: 'provider:model hints; OpenVibe.AI\'s router is the authority and its provenance is what an answer reports',
});
});
});
});
@@ -64,6 +64,14 @@ function createPageRoutes(ctx) {
@@ -64,6 +64,14 @@ function createPageRoutes(ctx) {
body: raw(views.home({ signedIn: Boolean(signedIn(req)), ai: ai.enabled })),
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 ──────────────────────────────────────────────────
// ── Ask ──────────────────────────────────────────────────
r.get('/ask', (req, res) => page(req, res, {
r.get('/ask', (req, res) => page(req, res, {
index: true, cache: PUBLIC_CACHE, title: 'Ask a picture',
index: true, cache: PUBLIC_CACHE, title: 'Ask a picture',
@@ -7,7 +7,7 @@
@@ -7,7 +7,7 @@
* a person: their Network user token as a Bearer, or this site's session cookie (vision_at).
* 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 }
* { 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
* 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
* 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.
* until the main session registers the manifests; the main session adds the grants.
* { kind: 'anonymous' }
* { kind: 'anonymous' }
@@ -21,8 +21,8 @@ const { serviceAuth, capabilities, http, ids } = require('openvibe-contracts');
@@ -21,8 +21,8 @@ const { serviceAuth, capabilities, http, ids } = require('openvibe-contracts');
const PRINCIPAL_SUB = /^(svc|app|mod|agent):/;
const PRINCIPAL_SUB = /^(svc|app|mod|agent):/;
const PROJECT_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
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) {
function decodePayload(token) {
const parts = String(token || '').split('.');
const parts = String(token || '').split('.');
@@ -11,6 +11,7 @@
@@ -11,6 +11,7 @@
const { createAccountData, TOPICS } = require('openvibe-sdk/account-data');
const { createAccountData, TOPICS } = require('openvibe-sdk/account-data');
const TABLES = [
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.
// 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_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' },
{ table: 'vision_answers', subject: 'subject', value: (usr) => usr, file: 'answers.json', erase: 'delete' },
@@ -8,7 +8,7 @@
@@ -8,7 +8,7 @@
const { jwksStatus } = require('openvibe-sdk/auth');
const { jwksStatus } = require('openvibe-sdk/auth');
const { createReadiness } = require('openvibe-shared/ready');
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({
return createReadiness({
service: 'vision',
service: 'vision',
release,
release,
@@ -33,6 +33,8 @@ function createServiceReadiness({ s, config, valkey = null, release = null, ai =
@@ -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: '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: '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,
name: 'local_ocr', required: false,
check: async () => {
check: async () => {
@@ -22,6 +22,9 @@ const NAV = [
@@ -22,6 +22,9 @@ const NAV = [
{ label: 'Home', href: '/' },
{ label: 'Home', href: '/' },
{ label: 'Ask a picture', href: '/ask' },
{ label: 'Ask a picture', href: '/ask' },
{ label: 'OCR', href: '/ocr' },
{ label: 'OCR', href: '/ocr' },
+ { label: 'Find', href: '/find' },
+ { label: 'Read', href: '/read' },
+ { label: 'Watch', href: '/watch' },
{ label: 'Compare', href: '/compare' },
{ label: 'Compare', href: '/compare' },
{ label: 'UI check', href: '/ui-check' },
{ label: 'UI check', href: '/ui-check' },
{ label: 'Developers', href: '/developers' },
{ label: 'Developers', href: '/developers' },
@@ -230,6 +230,12 @@ ${ep('POST', '/api/v1/ask (alias /api/v1/describe)', 'One image and a question:
@@ -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/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/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/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/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/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.')}
${ep('GET', '/api/v1/models', 'The AI route\'s offered provider:model hints and the local engine. No subject data.')}
new file mode 100644
new file mode 100644
@@ -0,0 +1,58 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,143 @@
@@ -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 };
@@ -46,7 +46,7 @@ async function boot(opts = {}) {
@@ -46,7 +46,7 @@ async function boot(opts = {}) {
const { createStore } = require('../../server/db');
const { createStore } = require('../../server/db');
const testdb = await require('./db').testDb();
const testdb = await require('./db').testDb();
const store = createStore(testdb.db, { now: opts.now });
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();
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 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}`;
const base = `http://127.0.0.1:${server.address().port}`;
new file mode 100644
new file mode 100644
@@ -0,0 +1,89 @@
@@ -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();
+})();