openvibers/openvibe.ai Commits
Deep dive: the capture worker and the design.deep_dive run (#493)
Changes
nnew file mode 100644@@@ -0,0 +1,39 @@++[Unit]++Description=OpenVibe.AI capture worker — headless Chrome for deep dives (one browser at a time, server/capture/)++# The API calls this unit on 127.0.0.1:4710 (AI_CAPTURE_URL in openvibe-ai.service). It has its own unit so a page++# that eats memory is killed by MemoryMax without taking the API down: the host has about 5 GB of free memory and++# never starts a second browser. AI_DRILL/LIVE_DRILL turns it off in code (POST /capture answers 503 capture.drill).++After=network.target++Wants=network.target++++[Service]++Type=simple++User=ubuntu++Group=ubuntu++WorkingDirectory=/opt/openvibe.ai++EnvironmentFile=/etc/openvibe/ai.env++Environment=NODE_ENV=production++Environment=AI_CAPTURE_HOST=127.0.0.1++Environment=AI_CAPTURE_PORT=4710++Environment=AI_CAPTURE_CHROME=/usr/bin/google-chrome++Environment=OV_MEDIA_INTERNAL_URL=http://127.0.0.1:4100++# Chrome writes its profile and its caches under HOME; the code tree stays read-only.++Environment=HOME=/var/lib/openvibe-ai-capture++Environment=PATH=/usr/local/bin:/usr/bin:/bin++ExecStart=/usr/bin/env node /opt/openvibe.ai/server/capture/worker.js++Restart=always++RestartSec=5++KillSignal=SIGTERM++TimeoutStopSec=20++NoNewPrivileges=true++PrivateTmp=true++ProtectSystem=strict++ProtectHome=true++StateDirectory=openvibe-ai-capture++# The browser is the whole point of this process and the reason for the cap: one page budget at a time.++MemoryMax=1500M++# A hung browser is killed with the unit rather than left to grow.++CPUQuota=200%++++[Install]++WantedBy=multi-user.target@@@ -4,7 +4,7 @@ Description=OpenVibe.AI — providers, routing, versioned templates and workflow# it; the SDK's JWKS client fetches at boot and backs off and retries, and /api/ready stays 503 until# the key has loaded (then the last good keys carry it through a Network outage).After=network.target postgresql.service pgbouncer.service valkey-server.service--Wants=openvibe-network.service postgresql.service pgbouncer.service valkey-server.service++Wants=openvibe-network.service postgresql.service pgbouncer.service valkey-server.service openvibe-ai-capture.service[Service]Type=simple@@@ -31,6 +31,10 @@ ProtectHome=read-onlyStateDirectory=openvibe-ai# media.analyze's work directory under StateDirectory (writable; the code tree is read-only).Environment=AI_MEDIA_ANALYSIS_DIR=/var/lib/openvibe-ai/media-tmp++# Deep dives' screenshots are taken by the capture worker (openvibe-ai-capture.service, 127.0.0.1:4710).++Environment=AI_CAPTURE_URL=http://127.0.0.1:4710++# Media's internal origin (its unit's port 4100): screenshots and the deep dive's private shots are Media objects.++Environment=OV_MEDIA_INTERNAL_URL=http://127.0.0.1:4100[Install]WantedBy=multi-user.targetnnew file mode 100644@@@ -0,0 +1,79 @@++# Deep dive++++The owner's 2026-11-11 design-research feature: one durable run that searches every source we have, captures the++best sites at phone and computer sizes, reads them with a vision and a text model, and writes an `ai.deep-dive@1`++report with its evidence and receipt. OpenVibe.Exchange shows it (`/deep-dive`); OpenVibe.Design calls the same API.++++## The run++++Workflow `design.deep_dive` (`server/workflows/deep-dive.js`), one `deep_dive` step that runs++`server/deep-dive/`. The steps, live on `GET /api/v1/deep-dives/:id/state`:++++| step | what happens |++| --- | --- |++| `plan` | a model writes the expert questions and the searches that answer them |++| `search` | MegaSource, AI's web/picture/icon engines, GitHub projects, Design's rules register and component registry |++| `choose` | a model ranks candidate sites with a reason each; a person may add or drop any before Capture |++| `capture` | 390/1280 px, light and dark where supported, full page after a slow scroll, DOM facts; Expert adds menus, hover/focus, a frame strip, axe-core and LCP/CLS/INP |++| `analyse` | a text model reads each site's DOM facts and words; a vision model reads each full-page screenshot |++| `synthesise` | patterns across sites, a comparison, the best version, mistakes, the gap, reference tokens, do/don't, accessibility, speed, and recommendations checked against Design's rules |++| `report` | `ai.deep-dive@1` assembled from what was actually captured, validated, and stored with the run |++++Depths: Quick 6 sites (about 3 minutes), Thorough 15, Expert 30. Quick goes straight from Choose to Capture;++Thorough and Expert wait `AI_DEEP_DIVE_CHOOSE_WAIT_SEC` (300) for `POST /api/v1/deep-dives/:id/sites`, or until the++person starts it. Cancelling the run aborts the provider calls and the next capture; nothing new starts after.++++## API++++```++GET /api/v1/deep-dives/depths public: key, sites, minutes and price_usd per depth++POST /api/v1/deep-dives ai.run.create: { question, depth, sites?, on_behalf_of? } -> 202++GET /api/v1/deep-dives ai.run.read: the caller's runs, newest first++GET /api/v1/deep-dives/:id the state and, at the end, the ai.deep-dive@1 report++GET /api/v1/deep-dives/:id/state just progress, steps, candidates, captures, gaps and error++POST /api/v1/deep-dives/:id/sites { add: [https], drop: [https], start: true } while Choosing++POST /api/v1/deep-dives/:id/cancel the runs cancel; no later capture or model call starts++GET /api/v1/deep-dives/:id/shots/:shot one private screenshot/crop, as the owner (private, no-store)++```++++The price is computed from the depth (never the body) and passed to `runs.create` as the run's estimate; with the++tier budgets on it is held on `ai-usd`/`ai-token` (`tiers.admit`), and the run stops its own costly steps once its++receipt reaches the price. The real numbers are on the receipt: `POST /api/v1/runs`'s run, `requests` on++`GET /api/v1/runs/:id`, and the report's `receipt.models`. Every capture is a `requests` row (operation `capture`).++++OpenVibe.Exchange's long-standing Network grant holds `ai.run.create` on `ai.complete`; `design.deep_dive` is the++workflow its own page starts, so AI's run namespace rule allows the `exchange` service this one workflow by key and++service id (`server/api/runs.js`, `server/api/deep-dives.js`). Every other workflow keeps the namespace rule.++++Event: `ai.deep_dive.updated@1` (Contracts 0.412.0), one per step transition and one at the end. Subject++`run:<id>`, internal, low priority (`important` on failure). Payload: ids, status, step, progress, the seven step++statuses, depth, requester, on_behalf_of, the Exchange URL, an error and the time. Never the question's answer, a++capture or a prompt. `server/events.js` `deepDiveUpdated`.++++## The capture worker++++`server/capture/`, its own systemd unit `openvibe-ai-capture.service` (`MemoryMax=1500M`, loopback 4710). AI calls it++when `AI_CAPTURE_URL` is set (the AI unit sets `http://127.0.0.1:4710`); unset runs the same module in-process++(tests, a small install).++++- One browser at a time (a promise chain in the worker) and a queue cap per person (`AI_CAPTURE_QUEUE_PER_OWNER` 3).++- Every request, redirect and WebSocket goes through a loopback CONNECT/HTTP proxy that resolves the name with++ `safeLookup` and refuses a non-public address or any port but 80/443. Photos are taken by Chrome; no request can++ reach 127.0.0.1, a private range or a metadata address.++- robots.txt is read first (our own user agent, else `*`, longest matching rule wins); a disallowed page is skipped++ and the report says so.++- A fresh context per page (no cookies, no stored state, no sign-in, no form submitted), a 45 s page budget, and a++ gap between two pages on the same host (`AI_CAPTURE_HOST_DELAY_MS` 3000).++- Screenshots go to OpenVibe.Media as `asset` objects, `visibility: private`, owner `X-OV-Subject` = the run's owner,++ metadata `source_url` and `captured_at`. The report shows them as attributed commentary through AI's shot route.++- A 7-day cache per URL, viewport, theme and Expert flag (`capture_cache`), so the same page is never photographed++ twice inside a week.++- Off under a drill: `AI_DRILL=1`/`LIVE_DRILL=1` makes `/capture` answer `503 capture.drill`.++++## For OpenVibe.Design (stage 2)++++Call `POST /api/v1/deep-dives` with a service token (`ai.run.create`, namespace `design.*`), `on_behalf_of` the++person, then follow `/state` or subscribe to `ai.deep_dive.updated@1`; the report at `GET /api/v1/deep-dives/:id` is++the generation brief. Two things stage 2 still needs: a stable **public** read or an owner-approved share for a++report (today the shots are private to the run's owner and the report is readable by the caller), and a way for a++report to propose a rule for the register (the report says `follows`/`would_change`; nothing is added by itself).nnew file mode 100644@@@ -0,0 +1,42 @@++-- phase: expand++-- Deep dive runs (workflow design.deep_dive): the live state of one run, its chosen sites and its report.++-- The run table keeps the input, output and receipt; this row is what the page reads while the run is still++-- working (step progress, candidates, captures), so a restart or a deploy loses nothing. One row per run;++-- capture_cache holds the 7-day screenshot cache per URL, viewport and theme.++CREATE TABLE deep_dives (++ id text COLLATE "C" PRIMARY KEY,++ run_id text COLLATE "C" NOT NULL UNIQUE,++ owner_type text COLLATE "C",++ owner_id text COLLATE "C",++ status text COLLATE "C" NOT NULL DEFAULT 'queued',++ step text COLLATE "C",++ progress double precision NOT NULL DEFAULT 0,++ question text COLLATE "C" NOT NULL,++ depth text COLLATE "C" NOT NULL,++ site_limit integer NOT NULL,++ price_usd numeric(12, 6) NOT NULL DEFAULT 0,++ steps jsonb NOT NULL DEFAULT '[]'::jsonb,++ candidates jsonb NOT NULL DEFAULT '[]'::jsonb,++ decision_at timestamptz,++ captures jsonb NOT NULL DEFAULT '[]'::jsonb,++ report jsonb,++ gaps jsonb NOT NULL DEFAULT '[]'::jsonb,++ error_code text COLLATE "C",++ error_detail text COLLATE "C",++ created_at timestamptz NOT NULL,++ updated_at timestamptz NOT NULL,++ finished_at timestamptz++);++CREATE INDEX deep_dives_owner ON deep_dives(owner_type, owner_id, created_at DESC);++++CREATE TABLE capture_cache (++ key text COLLATE "C" PRIMARY KEY,++ url text COLLATE "C" NOT NULL,++ viewport integer NOT NULL,++ theme text COLLATE "C" NOT NULL,++ extras boolean NOT NULL DEFAULT false,++ result jsonb NOT NULL,++ captured_at timestamptz NOT NULL,++ expires_at timestamptz NOT NULL++);++CREATE INDEX capture_cache_expiry ON capture_cache(expires_at);@@@ -10,13 +10,15 @@"license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",++ "axe-core": "^4.10.3","dotenv": "^16.4.7","express": "^4.21.2","iovalkey": "^0.4.0","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.410.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.152.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.206.0",-- "pg": "^8.23.0"++ "pg": "^8.23.0",++ "playwright-core": "^1.55.0"},"bin": {"openvibe-exchange-mcp": "bin/openvibe-exchange-mcp.js"@@@ -530,6 +532,15 @@"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==","license": "MIT"},++ "node_modules/axe-core": {++ "version": "4.10.3",++ "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.10.3.tgz",++ "integrity": "sha512-Xm7bpRXnDSX2YE2YFfBk2FnF0ep6tmG7xPh8iHee8MIcrgq762Nkce856dYtJYLkuIoYZvGfTs/PbZhideTcEg==",++ "license": "MPL-2.0",++ "engines": {++ "node": ">=4"++ }++ },"node_modules/body-parser": {"version": "1.20.8","resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.8.tgz",@@@ -1786,6 +1797,18 @@"split2": "^4.1.0"}},++ "node_modules/playwright-core": {++ "version": "1.55.0",++ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.55.0.tgz",++ "integrity": "sha512-GvZs4vU3U5ro2nZpeiwyb0zuFaqb9sUiAJuyrWpcGouD8y9/HLgGbNRjIph7zU9D3hnPaisMl9zG9CgFi/biIg==",++ "license": "Apache-2.0",++ "bin": {++ "playwright-core": "cli.js"++ },++ "engines": {++ "node": ">=18"++ }++ },"node_modules/postgres-array": {"version": "2.0.0","resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",@@@ -26,6 +26,7 @@"repository": "github:OpenVibers/OpenVibe.AI","dependencies": {"ajv": "^8.17.1",++ "axe-core": "^4.10.3","dotenv": "^16.4.7","express": "^4.21.2","iovalkey": "^0.4.0",@@@ -33,7 +34,8 @@"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.152.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.206.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.206.0",-- "pg": "^8.23.0"++ "pg": "^8.23.0",++ "playwright-core": "^1.55.0"},"optionalDependencies": {"sharp": "^0.33.5"nnew file mode 100644@@@ -0,0 +1,165 @@++'use strict';++/**++ * Deep dives (workflow design.deep_dive): start one for its price, follow its seven steps, add or drop sites++ * before Capture, cancel it, read the report, and read a private screenshot back.++ *++ * GET /api/v1/deep-dives/depths public: the three depths, their site counts, minutes and price++ * POST /api/v1/deep-dives ai.run.create: { question, depth, sites?, on_behalf_of? }; 202 with state++ * GET /api/v1/deep-dives ai.run.read: the caller's deep dives, newest first++ * GET /api/v1/deep-dives/:id the whole state and, when it has ended, the ai.deep-dive@1 report++ * GET /api/v1/deep-dives/:id/state just the live step progress, candidates and status (the page polls this)++ * POST /api/v1/deep-dives/:id/sites { add: [https…], drop: [https…], start: true } while Choosing++ * POST /api/v1/deep-dives/:id/cancel the runs cancel; no further capture or model call starts++ * GET /api/v1/deep-dives/:id/shots/:shot one private screenshot/crop, as the owner (private, no-store)++ *++ * The price is computed here from the depth (never from the body) and passed to runs.create as the tier hold; the++ * output of the run is the report, so a caller that waits on the run's own receipt sees the same document.++ */++const express = require('express');++const { AiError, sendError } = require('../util');++const { CAPS, namespaceAllowed } = require('../auth');++const { INPUT_SCHEMA, DEPTHS } = require('../deep-dive/schema');++++function deepDivesRouter({ config, runs, auth, deepDive, store, capture = null, mediaStore = null, log = console }) {++ if (!deepDive || !store) return express.Router();++ const r = express.Router();++ const create = auth.requireCap(CAPS.runCreate);++ const read = auth.requireCap(CAPS.runRead, CAPS.runCreate);++ const has = (req, id) => auth.principalHas(req, id);++++ function stateOf(dd, run = null) {++ if (!dd) return null;++ const report = dd.report || null;++ return {++ id: dd.id, run_id: dd.run_id, status: run && ['failed', 'cancelled', 'succeeded'].includes(run.status) && dd.status === 'running' ? run.status : dd.status,++ step: dd.step, progress: dd.progress, steps: dd.steps, question: dd.question, depth: dd.depth, site_limit: dd.site_limit,++ price_usd: dd.price_usd, candidates: dd.candidates, captures: dd.captures.map((c) => ({ id: c.id, source_id: c.source_id, url: c.url, viewport: c.viewport, theme: c.theme, captured_at: c.captured_at, skip: c.skip, cached: c.cached, crops: c.crops.map((x) => ({ id: x.id, y: x.y, height: x.height })) })),++ report, gaps: dd.gaps, error: dd.error || (run && run.error) || null,++ owner: dd.owner, created_at: dd.created_at, updated_at: dd.updated_at, finished_at: dd.finished_at,++ holding: dd.price_usd,++ };++ }++++ async function owned(req, id) {++ const dd = await store.get(id);++ if (!dd) throw new AiError(404, 'deep_dive.not_found', 'no such deep dive');++ const run = await runs.get(id).catch(() => null);++ const s = req.principal.subject;++ const requester = run && run.requester;++ const mine = [dd.owner, requester].some((x) => x && x.type === s.type && x.id === s.id);++ if (!mine && !has(req, CAPS.usageRead)) throw new AiError(404, 'deep_dive.not_found', 'no such deep dive');++ return { dd, run };++ }++++ // Public: what a depth costs, before anyone signs in.++ r.get('/api/v1/deep-dives/depths', (req, res) => {++ res.json({ depths: deepDive.depths(), capture: capture ? { enabled: capture.available ? capture.available() : true } : null });++ });++++ r.post('/api/v1/deep-dives', create, async (req, res) => {++ try {++ if (!config.deepDive || config.deepDive.enabled === false) throw new AiError(503, 'deep_dive.unavailable', 'deep dives are off on this service');++ // The same namespace rule every run has, with the one owner allowance: Exchange's long-standing++ // ai.run.create grant names ai.complete, and design.deep_dive is the workflow its /deep-dive page starts.++ const who = req.principal;++ const owned = who.subject && who.subject.type === 'service' && who.subject.id === 'exchange';++ if (!owned && !namespaceAllowed(who, 'design.deep_dive')) throw new AiError(403, 'capability.namespace_denied', 'this token may not run design.deep_dive');++ const body = req.body || {};++ const { idempotency_key: idem, target, attribution, on_behalf_of: obo, ...raw } = body;++ const input = { question: String(raw.question == null ? '' : raw.question).replace(/\s+/g, ' ').trim(), depth: raw.depth, ...(Array.isArray(raw.sites) && raw.sites.length ? { sites: raw.sites.slice(0, 30) } : {}) };++ if (obo != null && !(obo.type === 'user' && /^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(obo.id || '')))) throw new AiError(422, 'input.invalid', 'on_behalf_of must be a user subject');++ const schema = require('../schemas').validate(INPUT_SCHEMA, input);++ if (!schema.valid) throw new AiError(422, 'input.invalid', `a deep dive needs ${schema.errors.map((e) => `${e.path} ${e.message}`).join('; ')}`);++ const price = deepDive.priceOf(input.depth);++ const created = await runs.create({++ workflow: 'design.deep_dive', input,++ ...(idem ? { idempotency_key: idem } : {}),++ ...(target ? { target } : {}), ...(attribution ? { attribution } : {}), ...(obo ? { on_behalf_of: obo } : {}),++ options: { cache: 'none', hold: { usd: price, tokens: Math.round(price * 250000) } },++ }, req.principal, { trace: req.ov.traceId, requestId: req.ov.requestId });++ // The row exists before the first status is returned, so the page has something to read even while++ // the worker is still starting; the run's own step ensures the same row (ON CONFLICT DO NOTHING).++ await store.ensure({ runId: created.run.id, question: input.question, depth: input.depth, siteLimit: deepDive.limitOf(input.depth), priceUsd: price, owner: obo || (req.principal.kind === 'user' ? req.principal.subject : null) });++ const dd = await store.get(created.run.id);++ res.setHeader('Location', `/api/v1/deep-dives/${created.run.id}`);++ res.status(202).json({ deep_dive: stateOf(dd, created.run), price_usd: price, held_usd: price });++ } catch (err) { sendError(res, err, req.ov, log); }++ });++++ r.get('/api/v1/deep-dives', read, async (req, res) => {++ const limit = Math.max(1, Math.min(100, Number(req.query.limit) || 30));++ const all = [];++ for (const run of await runs.list(req.principal, { workflow: 'design.deep_dive', limit })) {++ const dd = await store.get(run.id);++ if (dd) all.push(stateOf(dd, run));++ }++ res.json({ deep_dives: all.slice(0, limit) });++ });++++ r.get('/api/v1/deep-dives/:id', read, async (req, res) => {++ try {++ const { dd, run } = await owned(req, req.params.id);++ res.json({ deep_dive: stateOf(dd, run) });++ } catch (err) { sendError(res, err, req.ov, log); }++ });++++ r.get('/api/v1/deep-dives/:id/state', read, async (req, res) => {++ try {++ const { dd, run } = await owned(req, req.params.id);++ const s = stateOf(dd, run);++ res.set('Cache-Control', 'no-store');++ res.json({ deep_dive: { id: s.id, run_id: s.run_id, status: s.status, step: s.step, progress: s.progress, steps: s.steps, candidates: s.candidates, captures: s.captures, gaps: s.gaps, error: s.error, updated_at: s.updated_at, finished_at: s.finished_at } });++ } catch (err) { sendError(res, err, req.ov, log); }++ });++++ r.post('/api/v1/deep-dives/:id/sites', create, async (req, res) => {++ try {++ const { dd, run } = await owned(req, req.params.id);++ if (dd.decision_at || dd.status !== 'choosing') throw new AiError(409, 'deep_dive.not_choosing', `sites can be changed while the deep dive is choosing sites (this one is ${dd.status})`);++ const body = req.body || {};++ const add = (Array.isArray(body.add) ? body.add : []).map((u) => String(u).trim()).filter(Boolean).slice(0, 30);++ const drop = new Set((Array.isArray(body.drop) ? body.drop : []).map((u) => String(u).trim()).filter(Boolean));++ if (add.some((u) => !/^https?:\/\/[^\s]+$/i.test(u) || u.length > 2048)) throw new AiError(422, 'input.invalid', 'add must be http(s) URLs');++ const { cleanUrl } = require('../capture');++ let list = dd.candidates.slice(0, dd.site_limit);++ if (drop.size) list = list.filter((c) => !drop.has(c.url) && !drop.has(c.id));++ for (const raw of add) {++ const url = cleanUrl(raw);++ if (!url || list.some((c) => c.url === url)) continue;++ list.push({ id: `site_x${list.length + 1}`, url, title: new URL(url).hostname, reason: 'added by the person', source_id: null, chosen: true });++ }++ if (!list.length) throw new AiError(422, 'input.invalid', 'a deep dive needs at least one site to capture');++ const updated = await store.patch(dd.id, { candidates: list.slice(0, dd.site_limit), decision_at: new Date().toISOString() });++ await require('../events').deepDiveUpdated({ runId: dd.run_id, deepDiveId: dd.id, status: updated.status, step: updated.step, progress: updated.progress, steps: updated.steps, depth: updated.depth, requester: run && run.requester, on_behalf_of: updated.owner, url: `https://openvibe.exchange/deep-dive/${updated.id}` });++ res.json({ deep_dive: stateOf(updated) });++ } catch (err) { sendError(res, err, req.ov, log); }++ });++++ r.post('/api/v1/deep-dives/:id/cancel', create, async (req, res) => {++ try {++ const { dd } = await owned(req, req.params.id);++ const run = await runs.cancel(dd.run_id, req.principal, { trace: req.ov.traceId, principalHas: has });++ const fresh = await store.get(dd.id);++ res.json({ deep_dive: stateOf(fresh, run) });++ } catch (err) { sendError(res, err, req.ov, log); }++ });++++ r.get('/api/v1/deep-dives/:id/shots/:shot', read, async (req, res) => {++ try {++ const { dd } = await owned(req, req.params.id);++ const cap = (dd.captures || []).find((c) => c.id === req.params.shot || (c.crops || []).some((x) => x.id === req.params.shot));++ const id = cap && cap.id === req.params.shot ? cap.media_id : cap && cap.crops.find((x) => x.id === req.params.shot).media_id;++ if (!id) throw new AiError(404, 'deep_dive.shot_not_found', 'no such screenshot');++ if (!mediaStore || typeof mediaStore.bytes !== 'function') throw new AiError(503, 'media.unavailable', 'Media is not configured on this service');++ let got;++ try { got = await mediaStore.bytes(id, { owner: dd.owner && dd.owner.id }); }++ catch (err) { throw new AiError(err.status === 404 ? 404 : err.status === 413 ? 413 : 503, err.code || 'media.unavailable', err.message || 'Media could not read the screenshot'); }++ res.set({ 'Content-Type': got.mime || 'image/png', 'Cache-Control': 'private, no-store', 'X-Robots-Tag': 'noindex' });++ res.send(got.buffer);++ } catch (err) { sendError(res, err, req.ov, log); }++ });++++ return r;++}++++module.exports = { deepDivesRouter };@@@ -43,11 +43,20 @@ function runsRouter({ choices = null, runs, registry, auth, config, log = consolconst readApp = auth.appOrCap(CAPS.appRun, CAPS.runRead, CAPS.runCreate, CAPS.usageRead);const has = (req) => (id) => auth.principalHas(req, id);++ /**++ * The service that owns a product workflow may run that workflow even when its Network grant names a narrower++ * namespace: Exchange has held ai.run.create on `ai.complete` since 0.235.0, and design.deep_dive is the deep++ * dive its own /deep-dive page starts (the workflow's metadata.owner is exchange). This is the one such++ * allowance, by key and by service id, not a wildcard; every other workflow keeps the namespace rule.++ */++ const WORKFLOW_OWNERS = Object.freeze({ 'design.deep_dive': 'exchange' });function checkNamespace(req, workflowKey) {if (req.principal.kind === 'app') {if (!appMayRun(req.principal, workflowKey)) throw new AiError(403, 'capability.namespace_denied', `an app may only run its own app.${req.principal.projectKey}.* namespace, not ${workflowKey}`);return;}++ const owner = WORKFLOW_OWNERS[workflowKey];++ if (owner && req.principal.subject && req.principal.subject.type === 'service' && req.principal.subject.id === owner) return;// content.devblog is the Blog writer's public workflow name; it uses the existing blog grant.const permissionKey = workflowKey === 'content.devblog' ? 'blog.devblog.write' : workflowKey;if (!namespaceAllowed(req.principal, permissionKey)) throw new AiError(403, 'capability.namespace_denied', `this token may not run ${workflowKey}`);@@@ -24,7 +24,7 @@ const pkg = require('../package.json');const { adminRoutes } = require('openvibe-shared/config');const ownerInputs = require('./owner-inputs');--function createApp({ choices = null, searchFetch = null, config, db, registry, pool, quotas, cache, runs, auth, keys, env = process.env, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, log = console, credentials = null, preferences = null, grants = null, vault = null, settings = null, benchEvents = null, runBudgets = null, memory = null, memoryImport = null, memoryServices = null, memorySharing = null, community = null, endpoints = null, webhooks = null, presets = null, receipts = null, balance = null, creditGrants = null, sessionCookies = null, gpus = null, gpuMarket = null, supply = null, abuse = null, priceBook = null, modelSync = null, modelDiscovery = null, ownEndpoints = null, refusal = null, willingness = null, abliterationTax = null, batches = null, share = null }) {++function createApp({ choices = null, searchFetch = null, config, db, registry, pool, quotas, cache, runs, auth, keys, env = process.env, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, log = console, credentials = null, preferences = null, grants = null, vault = null, settings = null, benchEvents = null, runBudgets = null, memory = null, memoryImport = null, memoryServices = null, memorySharing = null, community = null, endpoints = null, webhooks = null, presets = null, receipts = null, balance = null, creditGrants = null, sessionCookies = null, gpus = null, gpuMarket = null, supply = null, abuse = null, priceBook = null, modelSync = null, modelDiscovery = null, ownEndpoints = null, refusal = null, willingness = null, abliterationTax = null, batches = null, share = null, deepDive = null, mediaStore = null }) {const app = express();// /.well-known/agent.json (openvibe-shared/agent-layer, SEO-2): what this site serves, for agents.require('openvibe-shared/agent-layer').mount(app, { descriptor: require('./agent-descriptor') });@@@ -261,6 +261,8 @@ function createApp({ choices = null, searchFetch = null, config, db, registry, p// Operator actions shared by the admin API and the operator console (one implementation, one audit row).const ops = createOps({ db, registry, pool, quotas, cache, runs });app.use(runsRouter({ choices, runs, registry, auth, config, log }));++ // Deep dives (workflow design.deep_dive): the Exchange feature's API and the Design integration's.++ app.use(require('./api/deep-dives').deepDivesRouter({ config, runs, auth, deepDive, store: deepDive && deepDive.store, capture: deepDive && deepDive.capture, mediaStore, log }));app.use(require('./api/orientation').createOrientation({ choices, runs, registry, auth, db, config, log }));app.use(actionsRouter({ runs, registry, auth, log }));// People's own controls (2026-11-11): one task that turns words into a checked ui.layout@1.nnew file mode 100644@@@ -0,0 +1,237 @@++'use strict';++/**++ * Headless Chrome for a deep dive's captures (playwright-core on the host's /usr/bin/google-chrome).++ *++ * One browser at a time (server/capture/index.js queues the calls). Each URL is opened in a fresh context with++ * no cookies and no stored state, at 390 and 1280 px, in light and — when the site actually changes with++ * prefers-color-scheme — dark. The page is scrolled slowly to the bottom first, because lazy sections only++ * paint when they come into view, then the whole page is photographed. The DOM facts are read from the live++ * page and the Expert extras (axe-core, LCP/CLS/INP, menus, hover and focus states, a short frame strip) are++ * taken on the 1280 light visit. Every page has a 45 s budget; a page that runs over is reported, never waited on.++ *++ * The browser goes through server/capture/proxy.js: no request, redirect or WebSocket reaches a private address.++ */++const fs = require('fs');++const path = require('path');++const { METRICS_INIT, collectDom, readMetrics } = require('./dom');++++const AXE = (() => {++ try { return fs.readFileSync(require.resolve('axe-core/axe.min.js'), 'utf8'); } catch { return null; }++})();++++const VIEWPORTS = { phone: 390, desktop: 1280 };++const UA = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36 OpenVibeDeepDive/1.0 (+https://openvibe.exchange/deep-dive)';++const sleep = (ms) => new Promise((r) => { const t = setTimeout(r, ms); t.unref?.(); });++++function deadline(ms) {++ const c = new AbortController();++ const t = setTimeout(() => c.abort(), ms);++ t.unref?.();++ return { signal: c.signal, clear: () => clearTimeout(t) };++}++++class BudgetError extends Error {++ constructor(message) { super(message); this.name = 'BudgetError'; this.code = 'capture.timeout'; }++}++++async function slowScroll(page, budget) {++ const { height, step } = await page.evaluate(() => ({ height: Math.max(document.documentElement.scrollHeight, document.body ? document.body.scrollHeight : 0), step: Math.max(300, Math.round(innerHeight * 0.8)) }));++ let y = 0;++ const started = Date.now();++ while (y < height && Date.now() - started < Math.min(9000, budget)) {++ y += step;++ await page.evaluate((to) => scrollTo(0, to), y);++ await sleep(220);++ const now = await page.evaluate(() => Math.max(document.documentElement.scrollHeight, document.body ? document.body.scrollHeight : 0));++ if (y > now) break;++ }++ await page.evaluate(() => scrollTo(0, 0));++ await sleep(450);++ return page.evaluate(() => Math.max(document.documentElement.scrollHeight, document.body ? document.body.scrollHeight : 0));++}++++async function shot(page, upload, { name, viewport, theme, runId, owner, sourceUrl, capturedAt, extra = {}, full = false, height = null, maxHeight = 12000 }) {++ const tall = Number(height) > maxHeight;++ const buf = full && !tall++ ? await page.screenshot({ type: 'png', fullPage: true, caret: 'hide', animations: 'allow' })++ : await page.screenshot({ type: 'png', fullPage: false, caret: 'hide', animations: 'allow', ...(full && tall ? { clip: { x: 0, y: 0, width: viewport, height: maxHeight } } : {}) });++ const out = await upload({ bytes: buf, mime: 'image/png', filename: name, owner, metadata: { source_url: sourceUrl, captured_at: capturedAt, viewport, theme, deep_dive: runId, ...extra } });++ return { media_id: out.media_id, width: out.width || viewport, height: out.height || null };++}++++async function crop(page, upload, { name, y, viewport, theme, runId, owner, sourceUrl, capturedAt }) {++ await page.evaluate((to) => scrollTo(0, to), y);++ await sleep(220);++ const box = await page.evaluate(() => ({ height: innerHeight }));++ const buf = await page.screenshot({ type: 'png', fullPage: false, caret: 'hide' });++ const out = await upload({ bytes: buf, mime: 'image/png', filename: name, owner, metadata: { source_url: sourceUrl, captured_at: capturedAt, viewport, theme, deep_dive: runId, crop_y: y, crop_height: box.height } });++ return { media_id: out.media_id, y, height: out.height || box.height };++}++++/** Does the site paint differently under prefers-color-scheme: dark? Compare the body, the header and the text. */++async function looksDark(browser, url, proxy, timeoutMs, log = console) {++ const v = VIEWPORTS.desktop;++ let light = null; let dark = null;++ for (const scheme of ['light', 'dark']) {++ const ctx = await browser.newContext({ viewport: { width: v, height: 900 }, colorScheme: scheme, locale: 'en-US', userAgent: UA, serviceWorkers: 'block', reducedMotion: 'no-preference' });++ const page = await ctx.newPage();++ try {++ await page.goto(url, { waitUntil: 'domcontentloaded', timeout: timeoutMs });++ await page.waitForTimeout(350);++ const value = await page.evaluate(() => {++ const cs = (el) => (el ? getComputedStyle(el) : null);++ const body = cs(document.body);++ const header = cs(document.querySelector('header, nav, [role="banner"]'));++ const h1 = cs(document.querySelector('h1'));++ return [body && body.backgroundColor, body && body.color, header && header.backgroundColor, h1 && h1.color].filter(Boolean);++ });++ if (scheme === 'light') light = value; else dark = value;++ } catch (err) { log.warn(`[capture] theme probe ${scheme}: ${String((err && err.message) || err).slice(0, 160)}`); return false; } finally { await ctx.close().catch(() => {}); }++ }++ if (!light || !dark || light.length !== dark.length) return false;++ // One changed value can be a hover or a placeholder; two (the page's own background and text, usually) is++ // a site that really answers prefers-color-scheme, and only then is the extra pair of captures worth it.++ const changed = light.filter((v, i) => v && dark[i] && v !== dark[i]).length;++ return changed >= 2;++}++++async function runAxe(page) {++ if (!AXE) return { skipped: 'axe-core is not installed' };++ try {++ await page.addScriptTag({ content: AXE });++ const result = await page.evaluate(async () => {++ try {++ const r = await window.axe.run(document, { resultTypes: ['violations'], runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'] } });++ return { violations: (r.violations || []).slice(0, 20).map((v) => ({ id: v.id, impact: v.impact, help: v.help, nodes: (v.nodes || []).length, targets: (v.nodes || []).slice(0, 3).map((n) => n.target) })), passes: (r.passes || []).length };++ } catch (e) { return { error: String(e && e.message || e).slice(0, 240) }; }++ });++ return result;++ } catch (e) { return { error: String(e && e.message || e).slice(0, 240) }; }++}++++/** The Expert extras: menus, hover, focus, a frame strip and the measured Web Vitals. */++async function extrasOn(page, upload, { viewport, theme, runId, owner, sourceUrl, capturedAt }) {++ const extras = { metrics: await page.evaluate(readMetrics).catch(() => null), a11y: await runAxe(page), menus: [], hover: null, focus: null, frames: [] };++ const menu = page.locator('button[aria-expanded="false"], [role="button"][aria-expanded="false"], button[aria-label*="menu" i], button[aria-label*="navigation" i]').first();++ if (await menu.count().catch(() => 0)) {++ const label = await menu.getAttribute('aria-label').catch(() => null) || await menu.innerText().catch(() => 'menu');++ const before = await shot(page, upload, { name: 'menu-closed', viewport, theme, runId, owner, sourceUrl, capturedAt, extra: { state: 'menu-closed' } });++ try {++ await menu.click({ timeout: 4000 });++ await page.waitForTimeout(500);++ const after = await shot(page, upload, { name: 'menu-open', viewport, theme, runId, owner, sourceUrl, capturedAt, extra: { state: 'menu-open' } });++ extras.menus.push({ label: String(label || 'menu').slice(0, 80), before: before.media_id, after: after.media_id });++ } catch { /* a menu that does not open is not a failed capture */ }++ await page.keyboard.press('Escape').catch(() => {});++ }++ const link = page.locator('header a, nav a').first();++ if (await link.count().catch(() => 0)) {++ try { await link.hover({ timeout: 3000 }); await page.waitForTimeout(250); extras.hover = (await shot(page, upload, { name: 'hover', viewport, theme, runId, owner, sourceUrl, capturedAt, extra: { state: 'hover' } })).media_id; } catch { /* none */ }++ }++ const focusable = page.locator('input:not([type="hidden"]), button, a').first();++ if (await focusable.count().catch(() => 0)) {++ try { await focusable.focus({ timeout: 3000 }); await page.waitForTimeout(250); extras.focus = (await shot(page, upload, { name: 'focus', viewport, theme, runId, owner, sourceUrl, capturedAt, extra: { state: 'focus' } })).media_id; } catch { /* none */ }++ }++ // A short frame strip: four frames of whatever moves when the first section below the fold comes into view.++ try {++ await page.evaluate(() => scrollTo(0, Math.min(document.body ? document.body.scrollHeight - innerHeight : 0, innerHeight * 1.2)));++ for (let i = 0; i < 4; i++) {++ await page.waitForTimeout(280);++ extras.frames.push((await shot(page, upload, { name: `frame-${i + 1}`, viewport, theme, runId, owner, sourceUrl, capturedAt, extra: { state: 'transition', frame: i + 1 } })).media_id);++ }++ await page.evaluate(() => scrollTo(0, 0));++ await page.waitForTimeout(250);++ } catch { /* frames are best effort */ }++ extras.frames = extras.frames.filter(Boolean);++ return extras;++}++++function createBrowserEngine({ config, log = console, allowLocal = false } = {}) {++ const chrome = (config && config.capture && config.capture.chrome) || '/usr/bin/google-chrome';++ const maxHeight = (config && config.capture && config.capture.maxHeight) || 12000;++ const pageBudgetMs = (config && config.capture && config.capture.pageBudgetMs) || 45000;++ const available = () => { try { fs.accessSync(chrome, fs.constants.X_OK); return true; } catch { return false; } };++++ /**++ * One URL, light and (when the site supports it) dark, at both viewports. upload: async ({bytes, mime,++ * filename, owner, metadata}) -> { media_id }. Returns { title, final_url, combos: [...], warnings }.++ */++ async function captureSite({ url, runId, owner, upload, signal = null, extras = false, log: logFn = log }) {++ const started = Date.now();++ const out = { title: null, final_url: null, combos: [], warnings: [], ms: 0 };++ if (!available()) throw Object.assign(new Error(`headless Chrome is not at ${chrome}`), { code: 'capture.no_chrome' });++ const { chromium } = require('playwright-core');++ const { createGuardProxy } = require('./proxy');++ const proxy = createGuardProxy({ log, allowLocal });++ const proxyInfo = await proxy.start();++ let browser = null;++ let comboDeadline = null;++ try {++ browser = await chromium.launch({++ executablePath: chrome, headless: true,++ args: ['--disable-dev-shm-usage', '--disable-gpu', '--hide-scrollbars', '--force-color-profile=srgb', '--disable-background-networking', '--disable-sync'],++ proxy: { server: proxyInfo.url, bypass: '' },++ timeout: Math.min(20000, pageBudgetMs),++ });++ const capturedAt = new Date().toISOString();++ const base = { runId, owner, sourceUrl: url, capturedAt };++ const schemes = ['light'];++ if ((await looksDark(browser, url, proxyInfo.url, Math.min(20000, pageBudgetMs), log))) schemes.push('dark');++ for (const viewport of [VIEWPORTS.phone, VIEWPORTS.desktop]) {++ for (const theme of schemes) {++ if (signal && signal.aborted) throw Object.assign(new Error('cancelled'), { code: 'run.cancelled' });++ comboDeadline = deadline(pageBudgetMs);++ const ctx = await browser.newContext({++ viewport: { width: viewport, height: viewport === VIEWPORTS.phone ? 844 : 900 },++ colorScheme: theme, locale: 'en-US', userAgent: UA, serviceWorkers: 'block', reducedMotion: 'no-preference', javaScriptEnabled: true,++ });++ const page = await ctx.newPage();++ page.setDefaultTimeout(Math.min(15000, pageBudgetMs));++ page.setDefaultNavigationTimeout(Math.min(20000, pageBudgetMs));++ await page.addInitScript(METRICS_INIT);++ const onAbort = () => { try { page.close(); } catch { /* closed */ } };++ if (signal) signal.addEventListener('abort', onAbort, { once: true });++ if (comboDeadline.signal) comboDeadline.signal.addEventListener('abort', onAbort, { once: true });++ try {++ const res = await page.goto(url, { waitUntil: 'domcontentloaded', timeout: Math.min(20000, pageBudgetMs) });++ out.title = out.title || (await page.title().catch(() => null));++ out.final_url = page.url();++ if (res && res.status() >= 400) out.warnings.push(`${url} answered HTTP ${res.status()}`);++ await page.waitForTimeout(500);++ const height = await slowScroll(page, pageBudgetMs);++ const dom = await page.evaluate(collectDom, 4000);++ const full = await shot(page, upload, { name: `full-${viewport}-${theme}`, viewport, theme, ...base, full: true, height, maxHeight });++ const crops = [];++ const viewportHeight = viewport === VIEWPORTS.phone ? 844 : 900;++ const tops = [0];++ if (height > viewportHeight * 2) tops.push(Math.floor((height - viewportHeight) / 2));++ if (height > viewportHeight * 3) tops.push(Math.max(0, height - viewportHeight));++ for (const [i, y] of tops.entries()) crops.push({ id: `crop-${crops.length + 1}`, ...(await crop(page, upload, { name: `crop-${viewport}-${theme}-${i + 1}`, viewport, theme, y, ...base })) });++ const combo = { viewport, theme, full_page: true, media_id: full.media_id, width: viewport, height: Math.min(height, maxHeight), crops, title: out.title, final_url: out.final_url, dom, extras: null, captured_at: capturedAt, ms: Date.now() - started, cached: false };++ if (extras && viewport === VIEWPORTS.desktop && theme === 'light') {++ try { combo.extras = await extrasOn(page, upload, { viewport, theme, ...base }); } catch (err) { out.warnings.push(`expert extras failed: ${String((err && err.message) || err).slice(0, 160)}`); }++ }++ out.combos.push(combo);++ } catch (err) {++ out.warnings.push(`${viewport} ${theme}: ${String((err && err.message) || err).slice(0, 200)}`);++ } finally {++ comboDeadline.clear();++ if (signal) signal.removeEventListener('abort', onAbort);++ await ctx.close().catch(() => {});++ }++ }++ }++ if (!out.combos.length) throw Object.assign(new Error(out.warnings[0] || 'the page did not load'), { code: 'capture.failed', warnings: out.warnings });++ out.ms = Date.now() - started;++ return out;++ } finally {++ if (comboDeadline) comboDeadline.clear();++ if (browser) await browser.close().catch(() => {});++ proxy.close();++ }++ }++++ return { captureSite, available, chrome };++}++++module.exports = { createBrowserEngine, VIEWPORTS, UA, BudgetError, looksDark };nnew file mode 100644@@@ -0,0 +1,48 @@++'use strict';++/**++ * OpenVibe.AI's side of the capture worker.++ *++ * With AI_CAPTURE_URL set (production: http://127.0.0.1:4710, the openvibe-ai-capture unit) a capture is a POST++ * to the worker, so Chrome runs and dies in its own MemoryMax'ed process. Unset (tests, a small install) the same++ * module runs in this process. Either way the answer is one shape: { url, title, final_url, combos, warnings,++ * skipped, reason, ms }.++ */++function createCaptureClient({ config = {}, env = process.env, fetchImpl = globalThis.fetch, log = console, local = null } = {}) {++ const url = String((config.capture && config.capture.url) || '').replace(/\/+$/, '');++ const timeoutMs = (config.capture && config.capture.timeoutMs) || 300000;++ const token = String(env.AI_CAPTURE_TOKEN || '');++ const done = (p) => p.then((r) => (r && typeof r.json === 'function' ? r.json() : r));++++ async function captureSite(args = {}) {++ if (url) {++ const res = await fetchImpl(`${url}/capture`, {++ method: 'POST',++ headers: { 'Content-Type': 'application/json', Accept: 'application/json', ...(token ? { 'x-ov-capture-token': token } : {}) },++ body: JSON.stringify({ url: args.url, runId: args.runId || null, owner: args.owner || null, extras: args.extras === true }),++ signal: args.signal ? AbortSignal.any([args.signal, AbortSignal.timeout(timeoutMs)]) : AbortSignal.timeout(timeoutMs),++ });++ const body = await res.json().catch(() => null);++ if (res.status === 401 || res.status === 403) throw Object.assign(new Error('the capture worker refused this service'), { code: 'capture.unauthorized', status: 503 });++ if (res.status === 503 && body && body.code === 'capture.drill') return { url: args.url, combos: [], warnings: [body.detail], skipped: 'drill', reason: body.detail, ms: 0 };++ if (!res.ok || !body) throw Object.assign(new Error((body && body.detail) || `the capture worker answered ${res.status}`), { code: (body && body.code) || 'capture.failed', status: 502 });++ return body;++ }++ if (!local) throw Object.assign(new Error('capture is not configured'), { code: 'capture.unavailable', status: 503 });++ return local.captureSite(args);++ }++++ async function health() {++ if (!url) return { configured: false, enabled: Boolean(local && local.available && local.available()) };++ try {++ const res = await fetchImpl(`${url}/health`, { signal: AbortSignal.timeout(3000), headers: { Accept: 'application/json' } });++ const body = await res.json().catch(() => null);++ return { configured: true, ...(body || {}), ok: res.ok };++ } catch (err) {++ return { configured: true, ok: false, error: String((err && err.message) || err).slice(0, 160) };++ }++ }++++ return { captureSite, health, remote: Boolean(url) };++}++++module.exports = { createCaptureClient };nnew file mode 100644@@@ -0,0 +1,120 @@++'use strict';++/**++ * What a capture records about a page, measured in the page.++ *++ * The DOM facts are the raw material a text model reads: the fonts and the type scale, the palette with how++ * much of the page each colour covers, the spacing, radii and shadows, the grid and container widths, the++ * navigation pattern, the heading outline, the button and link words, and a component inventory. Nothing is a++ * screenshot guess; every number is computed from the live document.++ *++ * The Expert extras are here too: axe-core's findings, the LCP/CLS/INP a real visit measured, and the timing++ * script is installed before the first byte (addInitScript) so the largest paint is not missed.++ */++++/** Installed before navigation: the paint, layout shift and interaction readings an Expert capture reports. */++const METRICS_INIT = `(() => {++ if (window.__ovMetrics) return;++ const m = window.__ovMetrics = { lcp: null, cls: 0, inp: null, long_tasks: 0, poor_interactions: 0, shifts: [] };++ try {++ new PerformanceObserver((list) => { for (const e of list.getEntries()) m.lcp = { ms: Math.round(e.startTime), size: e.size || 0, element: e.element ? (e.element.tagName || '') : null }; }).observe({ type: 'largest-contentful-paint', buffered: true });++ } catch (e) {}++ try {++ new PerformanceObserver((list) => { for (const e of list.getEntries()) { if (e.hadRecentInput) continue; m.cls += e.value || 0; if (m.shifts.length < 8 && e.value) m.shifts.push({ value: Math.round(e.value * 1000) / 1000, at: Math.round(e.startTime) }); } }).observe({ type: 'layout-shift', buffered: true });++ } catch (e) {}++ try {++ new PerformanceObserver((list) => { for (const e of list.getEntries()) { m.long_tasks++; if (e.duration > 200) m.poor_interactions++; } }).observe({ type: 'longtask', buffered: true });++ } catch (e) {}++ try {++ new PerformanceObserver((list) => { for (const e of list.getEntries()) { if (e.duration == null) continue; if (m.inp == null || e.duration > m.inp.ms) m.inp = { ms: Math.round(e.duration), name: e.name || null }; } }).observe({ type: 'event', buffered: true });++ } catch (e) {}++ addEventListener('pagehide', () => {}, { once: true });++})();`;++++/** Read after the page has settled: the DOM facts a text model uses. */++function collectDom(limit = 4000) {++ const clean = (s, n) => String(s == null ? '' : s).replace(/\s+/g, ' ').trim().slice(0, n);++ const many = new Map();++ const add = (map, key, n = 1) => { if (key == null || key === '') return; map.set(key, (map.get(key) || 0) + n); };++ const top = (map, n, total) => [...map.entries()].sort((a, b) => b[1] - a[1]).slice(0, n).map(([value, count]) => ({ value, count, share: total ? Math.round((count / total) * 1000) / 1000 : 0 }));++ const rgb = (v) => {++ const m = /^rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)$/.exec(String(v || '').trim());++ if (!m) return null;++ if (m[4] != null && Number(m[4]) === 0) return null;++ return '#' + [m[1], m[2], m[3]].map((x) => Number(x).toString(16).padStart(2, '0')).join('');++ };++ const px = (v) => {++ const n = parseFloat(v);++ return Number.isFinite(n) && n !== 0 ? `${Math.round(n * 10) / 10}px` : null;++ };++ const all = [...document.querySelectorAll('body *')].slice(0, limit);++ const total = all.length || 1;++ const fonts = new Map(); const sizes = new Map(); const weights = new Map(); const lines = new Map();++ const colours = new Map(); const backgrounds = new Map(); const borders = new Map();++ const gaps = new Map(); const pads = new Map(); const margins = new Map(); const radii = new Map(); const shadows = new Map(); const widths = new Map();++ let grid = 0; let flex = 0; let images = 0; let svgs = 0; let videos = 0; let inputs = 0; let forms = 0; let tables = 0; let dialogs = 0; let cards = 0; let canvases = 0;++ const buttons = []; const links = []; const headings = []; const navs = [];++ for (const el of all) {++ const cs = getComputedStyle(el);++ const tag = el.tagName.toLowerCase();++ add(fonts, clean(cs.fontFamily, 120));++ add(sizes, px(cs.fontSize)); add(weights, clean(cs.fontWeight, 20)); add(lines, px(cs.lineHeight));++ const fg = rgb(cs.color); if (fg) add(colours, fg);++ const bg = rgb(cs.backgroundColor); if (bg) add(backgrounds, bg);++ const border = rgb(cs.borderTopColor); if (border && parseFloat(cs.borderTopWidth) > 0) add(borders, border);++ add(gaps, px(cs.gap)); add(radii, px(cs.borderTopLeftRadius));++ add(shadows, clean(cs.boxShadow, 160)); add(widths, px(cs.maxWidth));++ add(pads, px(cs.paddingTop)); add(pads, px(cs.paddingLeft));++ add(margins, px(cs.marginTop)); add(margins, px(cs.marginBottom));++ if (cs.display === 'grid') grid++; else if (cs.display.includes('flex')) flex++;++ if (tag === 'img') images++; else if (tag === 'svg') svgs++; else if (tag === 'video') videos++;++ else if (tag === 'input' || tag === 'select' || tag === 'textarea') inputs++; else if (tag === 'form') forms++;++ else if (tag === 'table') tables++; else if (tag === 'dialog') dialogs++; else if (tag === 'canvas') canvases++;++ else if (/card|tile|panel/i.test(String(el.className || ''))) cards++;++ if (tag === 'button' || (tag === 'input' && /^(button|submit)$/i.test(el.type)) || el.getAttribute('role') === 'button') {++ if (buttons.length < 60) buttons.push({ text: clean(el.innerText || el.value || el.getAttribute('aria-label'), 120), tag, name: clean(el.getAttribute('aria-label') || el.getAttribute('title'), 120) || null, disabled: Boolean(el.disabled) });++ }++ if (tag === 'a' && links.length < 120) links.push({ text: clean(el.innerText, 100), href: clean(el.getAttribute('href'), 300) });++ if (/^h[1-6]$/.test(tag) && headings.length < 60) headings.push({ level: Number(tag[1]), text: clean(el.innerText, 160) });++ if (tag === 'nav' || (!navs.length && el.getAttribute('role') === 'navigation')) navs.push(tag);++ }++ const body = document.body;++ const header = document.querySelector('header') || document.querySelector('nav') || document.querySelector('[role="banner"]');++ const headerCs = header ? getComputedStyle(header) : null;++ const menuButton = [...document.querySelectorAll('button,[role="button"]')].find((b) => /menu|navigation|nav/i.test(`${b.getAttribute('aria-label') || ''} ${b.getAttribute('aria-expanded') || ''} ${b.className || ''}`));++ const navLinks = document.querySelectorAll('header a, nav a, [role="navigation"] a').length;++ const sticky = Boolean(headerCs && (headerCs.position === 'fixed' || headerCs.position === 'sticky'));++ const containers = [...document.querySelectorAll('main, [class*="container"], [class*="wrapper"], section > div')].slice(0, 200);++ const containerWidths = new Map();++ for (const c of containers) add(containerWidths, px(getComputedStyle(c).width));++ const pageHeight = Math.max(document.documentElement ? document.documentElement.scrollHeight : 0, body ? body.scrollHeight : 0);++ return {++ url: location.href, title: document.title, lang: document.documentElement.lang || null,++ fonts: { families: top(fonts, 8, total), sizes: top(sizes, 16, total), weights: top(weights, 6, total), line_heights: top(lines, 8, total) },++ palette: { text: top(colours, 10, total), backgrounds: top(backgrounds, 10, total), borders: top(borders, 8, total) },++ spacing: top(pads, 16, total * 2), margins: top(margins, 12, total * 2), gaps: top(gaps, 10, total), radii: top(radii, 10, total), shadows: top(shadows, 8, total),++ grid: { grid_elements: grid, flex_elements: flex, container_widths: top(containerWidths, 8, containers.length), page_height: pageHeight, viewport_width: innerWidth, viewport_height: innerHeight },++ navigation: { header: Boolean(header), position: headerCs ? headerCs.position : null, sticky, links: navLinks, menu_button: Boolean(menuButton), menu_label: menuButton ? clean(menuButton.getAttribute('aria-label') || menuButton.innerText, 80) : null, items: [...document.querySelectorAll('header a, nav a, [role="navigation"] a')].slice(0, 20).map((a) => clean(a.innerText, 60)) },++ outline: headings,++ buttons, links,++ components: { images, svgs, videos, inputs, forms, tables, dialogs, cards, canvases, navs: document.querySelectorAll('nav').length, sections: document.querySelectorAll('section').length },++ words: { h1: clean((document.querySelector('h1') || {}).innerText, 300), title: clean(document.title, 200), description: clean((document.querySelector('meta[name="description"]') || {}).content, 300), buttons: buttons.slice(0, 30).map((b) => b.text).filter(Boolean), links: links.slice(0, 40).map((l) => l.text).filter(Boolean) },++ meta: { viewport: clean((document.querySelector('meta[name="viewport"]') || {}).content, 200), theme_color: clean((document.querySelector('meta[name="theme-color"]') || {}).content, 40), generator: clean((document.querySelector('meta[name="generator"]') || {}).content, 100) },++ };++}++++/** The Expert measurements (the metrics script's readings plus the navigation timing). */++function readMetrics() {++ const m = window.__ovMetrics || {};++ const nav = performance.getEntriesByType('navigation')[0];++ const round = (n) => (Number.isFinite(n) ? Math.round(n) : null);++ return {++ lcp_ms: m.lcp ? m.lcp.ms : null, lcp_element: m.lcp ? m.lcp.element : null,++ cls: Math.round((m.cls || 0) * 1000) / 1000, inp_ms: m.inp ? m.inp.ms : null,++ long_tasks: m.long_tasks || 0, poor_interactions: m.poor_interactions || 0,++ dom_content_loaded_ms: nav ? round(nav.domContentLoadedEventEnd) : null,++ load_ms: nav ? round(nav.loadEventEnd) : null,++ transfer_bytes: nav ? round(nav.transferSize) : null,++ };++}++++module.exports = { METRICS_INIT, collectDom, readMetrics };nnew file mode 100644@@@ -0,0 +1,99 @@++'use strict';++/**++ * The capture worker's front door: robots, the address guard, one browser at a time and a queue cap per person.++ *++ * A deep dive asks for one URL (both viewports, light and dark where supported, Expert extras on request); this++ * module checks robots.txt first (a disallowed page is skipped and the report says so), checks that the URL is a++ * public https address before a browser exists, waits its turn (one browser at a time: the host has about 5 GB++ * free) and a short gap when the same host is asked twice, then hands it to server/capture/browser.js. The 7-day++ * cache lives beside the run (server/deep-dive/store.js), so a cache hit never starts Chrome.++ */++const path = require('path');++const { createBrowserEngine } = require('./browser');++const { createRobots } = require('./robots');++const { publicTarget } = require('../search/transport');++++const hostOf = (u) => { try { return new URL(u).hostname.toLowerCase(); } catch { return null; } };++const cleanUrl = (u, { upgrade = true } = {}) => {++ try {++ const x = new URL(String(u));++ x.hash = '';++ if (upgrade && x.protocol === 'http:') x.protocol = 'https:';++ return x.href;++ } catch { return null; }++};++const cacheKey = (url, viewport, theme, extras) => `${url}|${viewport}|${theme}|${extras ? 1 : 0}`;++++function createCapture({ config = {}, env = process.env, clock = { now: () => Date.now() }, log = console, engine = null, robots = null, upload = null, allowLocal = false } = {}) {++ const cfg = config.capture || {};++ const chrome = cfg.chrome || '/usr/bin/google-chrome';++ const browser = engine || createBrowserEngine({ config, log, allowLocal });++ const robot = robots || createRobots({ now: () => clock.now(), log });++ const chromeOk = () => {++ try { require('fs').accessSync(chrome, require('fs').constants.X_OK); return true; } catch { return false; }++ };++ const drill = /^(1|true|yes|on)$/i.test(String(process.env.AI_DRILL || process.env.LIVE_DRILL || ''));++ const enabled = config.deepDive ? config.deepDive.enabled !== false && !drill : true;++++ // One browser at a time: a promise chain, with how many calls each owner is waiting in.++ const waiting = new Map();++ let tail = Promise.resolve();++ const hostAt = new Map();++++ function available() {++ return enabled && chromeOk();++ }++++ async function gapFor(url) {++ const host = hostOf(url);++ if (!host) return;++ const last = hostAt.get(host) || 0;++ const wait = Math.max(0, (cfg.hostDelayMs || 0) - (clock.now() - last));++ if (wait) await new Promise((r) => { const t = setTimeout(r, wait); t.unref?.(); });++ hostAt.set(host, clock.now());++ }++++ /**++ * Capture one URL. Returns { url, title, final_url, combos, warnings, ms, skipped, reason }.++ * skipped: robots_disallowed | not_public | no_chrome | drill | busy.++ */++ async function captureSite({ url, runId, owner, extras = false, signal = null, upload: uploadFn = upload, log: logFn = log } = {}) {++ const target = cleanUrl(url, { upgrade: !allowLocal });++ if (!target) return { url: String(url || ''), combos: [], warnings: ['not a valid URL'], skipped: 'not_public', reason: 'not a valid http(s) URL', ms: 0 };++ if (!enabled) return { url: target, combos: [], warnings: ['capture is off under a drill'], skipped: 'drill', reason: 'capture is off under a drill', ms: 0 };++ if (!chromeOk()) return { url: target, combos: [], warnings: ['headless Chrome is not installed'], skipped: 'no_chrome', reason: 'headless Chrome is not installed on this host', ms: 0 };++ if (!uploadFn) return { url: target, combos: [], warnings: ['Media is not configured'], skipped: 'no_media', reason: 'screenshots have nowhere private to go', ms: 0 };++ try {++ if (!allowLocal) await publicTarget(target);++ } catch (err) {++ return { url: target, combos: [], warnings: [String(err && err.message || err)], skipped: 'not_public', reason: `refused: ${String(err && err.message || err).slice(0, 160)}`, ms: 0 };++ }++ const allowed = await robot.check(target);++ if (!allowed.allowed) return { url: target, combos: [], warnings: [allowed.reason], skipped: 'robots_disallowed', reason: allowed.reason, ms: 0 };++++ const who = owner || 'anonymous';++ const mine = waiting.get(who) || 0;++ if (mine >= (cfg.maxPerOwner || 3)) {++ return { url: target, combos: [], warnings: ['this person already has captures waiting'], skipped: 'busy', reason: 'too many captures waiting for this person; try again shortly', ms: 0 };++ }++ waiting.set(who, mine + 1);++ const run = tail.then(async () => {++ if (signal && signal.aborted) throw Object.assign(new Error('cancelled'), { code: 'run.cancelled' });++ await gapFor(target);++ if (signal && signal.aborted) throw Object.assign(new Error('cancelled'), { code: 'run.cancelled' });++ const result = await browser.captureSite({ url: target, runId, owner, upload: uploadFn, signal, extras, log: logFn });++ return { url: target, skipped: null, reason: null, ...result };++ });++ tail = run.catch(() => {});++ try {++ return await run;++ } finally {++ const left = (waiting.get(who) || 1) - 1;++ if (left > 0) waiting.set(who, left); else waiting.delete(who);++ }++ }++++ return { captureSite, available, enabled: () => enabled, cacheKey, cleanUrl, chrome };++}++++module.exports = { createCapture, cacheKey, cleanUrl };nnew file mode 100644@@@ -0,0 +1,111 @@++'use strict';++/**++ * The egress guard the browser itself goes through.++ *++ * Node fetches are judged by server/providers/guarded-fetch.js; a headless Chrome would otherwise resolve a++ * hostname itself, so a public URL that redirects to 127.0.0.1, 10.0.0.1 or a metadata address would be read++ * inside our network. Every browser request (including every redirect and WebSocket) is sent through this++ * loopback HTTP proxy instead, which resolves the destination with the same `safeLookup` rule the rest of AI++ * uses: every answer must be public unicast, and the connection is made to the address that was checked, so a++ * DNS answer cannot change between the check and the connect (no rebinding). Only http/https on 80/443 is++ * tunnelled; an IP literal, a credentials-bearing URL, a private answer or any other port is refused.++ */++const http = require('http');++const net = require('net');++const { safeLookup } = require('../fetcher');++++const PORTS = new Set([80, 443]);++++function refuse(socket, status = 403, why = 'refused') {++ try { socket.end(`HTTP/1.1 ${status} ${why}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`); } catch { /* closed */ }++}++++/** host:port from a CONNECT line or an absolute URL; null when it is not a plain http(s) origin on 80/443. */++function targetOf(authority, protocol = 'https:', allowLocal = false) {++ const raw = String(authority || '').trim();++ let host; let port;++ if (raw.startsWith('[')) {++ const end = raw.indexOf(']');++ host = raw.slice(1, end); port = raw.slice(end + 1).replace(/^:/, '') || (protocol === 'https:' ? '443' : '80');++ } else {++ const i = raw.lastIndexOf(':');++ host = i > 0 ? raw.slice(0, i) : raw; port = i > 0 ? raw.slice(i + 1) : (protocol === 'https:' ? '443' : '80');++ }++ if (!host || host.length > 253 || /[^a-z0-9._:-]/i.test(host)) return null;++ if (net.isIP(host) && !(allowLocal && (host === '127.0.0.1' || host === '::1'))) return null;++ const n = Number(port);++ if (!Number.isInteger(n)) return null;++ if (!PORTS.has(n) && !(allowLocal && (host === '127.0.0.1' || host === 'localhost' || host === '::1'))) return null;++ return { host, port: n };++}++++function createGuardProxy({ log = console, allowLocal = false } = {}) {++ let server = null;++ let listening = null;++ // Production resolves every answer and refuses a non-public one. A test fixture lives on loopback, so the++ // capture test opts in explicitly (createGuardProxy({ allowLocal: true })); nothing outside /test passes it.++ const localHost = (h) => h === '127.0.0.1' || h === 'localhost' || h === '::1';++ const lookup = (host, cb) => (allowLocal && localHost(host)++ ? cb(null, [{ address: '127.0.0.1', family: 4 }])++ : safeLookup(host, { all: true }, cb));++++ const server_ = http.createServer((req, res) => {++ // Plain http: the browser sends an absolute URL in the request line.++ let u;++ try { u = new URL(req.url); } catch { res.writeHead(400); return res.end(); }++ const t = u.protocol === 'http:' ? targetOf(u.host, 'http:', allowLocal) : null;++ if (!t) { res.writeHead(403); return res.end(); }++ lookup(t.host, (err, addrs) => {++ if (err || !Array.isArray(addrs) || !addrs.length) { log.warn(`[capture] proxy refused ${t.host}: ${err ? err.message : 'no address'}`); res.writeHead(403); return res.end(); }++ const headers = { ...req.headers, host: t.host + (t.port === 80 ? '' : `:${t.port}`) };++ delete headers['proxy-connection'];++ const up = http.request({ host: addrs[0].address, port: t.port, method: req.method, path: `${u.pathname}${u.search || ''}`, headers }, (upRes) => {++ res.writeHead(upRes.statusCode || 502, upRes.headers);++ upRes.pipe(res);++ });++ up.on('error', () => { try { res.writeHead(502); res.end(); } catch { /* closed */ } });++ res.on('close', () => up.destroy());++ req.pipe(up);++ });++ });++++ server_.on('connect', (req, socket, head) => {++ const t = targetOf(req.url, 'https:', allowLocal);++ if (!t) return refuse(socket, 403, 'Forbidden');++ socket.on('error', () => { /* the browser hung up */ });++ lookup(t.host, (err, addrs) => {++ if (err || !Array.isArray(addrs) || !addrs.length) {++ log.warn(`[capture] proxy refused ${t.host}: ${String((err && err.message) || 'no address').slice(0, 160)}`);++ return refuse(socket);++ }++ const up = net.connect(t.port, addrs[0].address, () => {++ socket.write('HTTP/1.1 200 Connection Established\r\n\r\n');++ if (head && head.length) up.write(head);++ socket.pipe(up); up.pipe(socket);++ });++ up.on('error', () => refuse(socket, 502, 'Bad Gateway'));++ socket.on('close', () => up.destroy());++ up.on('close', () => socket.destroy());++ });++ });++ server_.on('clientError', (_e, socket) => { try { socket.destroy(); } catch { /* closed */ } });++++ function start() {++ if (listening) return listening;++ listening = new Promise((resolve, reject) => {++ server_.once('error', reject);++ server_.listen(0, '127.0.0.1', () => {++ server = server_.address();++ resolve({ url: `http://127.0.0.1:${server.port}`, close });++ });++ });++ return listening;++ }++ function close() {++ if (listening) { try { server_.close(); } catch { /* closed */ } }++ listening = null;++ }++ return { start, close, url: () => (server ? `http://127.0.0.1:${server.port}` : null) };++}++++module.exports = { createGuardProxy, targetOf };nnew file mode 100644@@@ -0,0 +1,105 @@++'use strict';++/**++ * robots.txt, respected before a browser is started.++ *++ * The group is the one for our own user agent (`OpenVibeDeepDive`) when a site names it, else `*`; the longest++ * matching Allow/Disallow rule wins, `*` and `$` wildcards included (RFC 9309's matching, the same rule Google++ * publishes). A 404/410 means no robots at all (allowed); a 401/403 or a 5xx means the site does not want++ * robots reading it, so the page is skipped and the report says so. The answer is cached per origin for an hour,++ * and the fetch goes through the same public-address guard as everything else.++ */++const { guardedFetch } = require('../providers/guarded-fetch');++const { publicTarget } = require('../search/transport');++++const UA = 'OpenVibeDeepDive';++const TTL_MS = 60 * 60 * 1000;++++function parse(text) {++ const groups = [];++ let current = null;++ let inAgents = false;++ for (const raw of String(text || '').split(/\r?\n/)) {++ const line = raw.replace(/#.*$/, '').trim();++ if (!line) continue;++ const i = line.indexOf(':');++ if (i < 0) continue;++ const key = line.slice(0, i).trim().toLowerCase();++ const value = line.slice(i + 1).trim();++ if (key === 'user-agent') {++ if (!inAgents || !current) { current = { agents: [], rules: [] }; groups.push(current); }++ current.agents.push(value.toLowerCase());++ inAgents = true;++ } else if (key === 'allow' || key === 'disallow') {++ if (!current) continue;++ current.rules.push({ allow: key === 'allow', path: value });++ inAgents = false;++ } else if (key === 'sitemap') {++ inAgents = false;++ }++ }++ return groups;++}++++function patternRe(path) {++ const anchored = path.endsWith('$');++ const body = (anchored ? path.slice(0, -1) : path).split('*').map((s) => s.replace(/[.+?^${}()|[\]\\]/g, '\\$&')).join('.*');++ return new RegExp(`^${body}${anchored ? '$' : ''}`);++}++++function allowedBy(groups, path, ua = UA) {++ const mine = groups.filter((g) => g.agents.some((a) => a && (a === ua.toLowerCase() || a === '*')));++ const specific = mine.filter((g) => g.agents.some((a) => a !== '*'));++ const use = specific.length ? specific : mine;++ if (!use.length) return true;++ let best = null;++ for (const g of use) {++ for (const r of g.rules) {++ if (!r.path) continue; // an empty Disallow is no rule++ if (!patternRe(r.path).test(path)) continue;++ if (!best || r.path.length > best.path.length || (r.path.length === best.path.length && r.allow)) best = r;++ }++ }++ return best ? best.allow : true;++}++++function createRobots({ fetchImpl = guardedFetch, now = () => Date.now(), log = console, timeoutMs = 8000 } = {}) {++ const cache = new Map(); // origin -> { at, robots }++++ async function load(origin) {++ const hit = cache.get(origin);++ if (hit && now() - hit.at < TTL_MS) return hit.robots;++ const url = `${origin}/robots.txt`;++ let robots = { exists: false, groups: [] };++ try {++ await publicTarget(url);++ const res = await fetchImpl(url, { redirect: 'error', signal: AbortSignal.timeout(timeoutMs), headers: { 'user-agent': `${UA}/1.0 (+https://openvibe.exchange/deep-dive)`, accept: 'text/plain' } });++ if (res.status === 401 || res.status === 403) robots = { exists: true, groups: [{ agents: ['*'], rules: [{ allow: false, path: '/' }] }] };++ else if (res.status === 404 || res.status === 410) robots = { exists: false, groups: [] };++ else if (res.status >= 500) robots = { exists: true, groups: [{ agents: ['*'], rules: [{ allow: false, path: '/' }] }] };++ else if (res.ok) robots = { exists: true, groups: parse(await res.text()) };++ } catch (err) {++ // A robots.txt that cannot be read is treated as no robots: the page itself still goes through the++ // address guard, and a site that is down is reported as a skipped capture, not a silent read.++ log.warn(`[capture] robots ${origin}: ${String((err && err.message) || err).slice(0, 160)}`);++ robots = { exists: false, groups: [] };++ }++ cache.set(origin, { at: now(), robots });++ if (cache.size > 500) cache.delete(cache.keys().next().value);++ return robots;++ }++++ /** { allowed, reason } for a URL; reason is what the report shows when it is not. */++ async function check(url) {++ let u;++ try { u = new URL(String(url)); } catch { return { allowed: false, reason: 'not a valid URL' }; }++ if (!/^https?:$/.test(u.protocol)) return { allowed: false, reason: 'not an http(s) URL' };++ const robots = await load(u.origin);++ if (!robots.groups.length) return { allowed: true, reason: null };++ const path = `${u.pathname}${u.search || ''}` || '/';++ return allowedBy(robots.groups, path) ? { allowed: true, reason: null } : { allowed: false, reason: 'robots.txt disallows this page' };++ }++++ return { check, parse, allowedBy, clear: () => cache.clear() };++}++++module.exports = { createRobots, parse, allowedBy, UA };nnew file mode 100644@@@ -0,0 +1,92 @@++'use strict';++/**++ * The capture worker process (systemd: openvibe-ai-capture.service, MemoryMax=1500M).++ *++ * AI_CAPTURE_PORT=4710 AI_CAPTURE_HOST=127.0.0.1 node server/capture/worker.js++ *++ * It speaks JSON on loopback only: POST /capture { url, runId, owner, extras } -> the capture (Media ids, DOM++ * facts, Expert extras); GET /health -> { status, chrome, enabled }. OpenVibe.AI calls it when AI_CAPTURE_URL is++ * set, so the browser lives in a process of its own: a page that eats memory is killed by MemoryMax without++ * taking the API down, and one deploy of AI does not need a browser running in it. No sign-in, no cookie kept,++ * every URL through the egress guard, robots.txt respected, one browser at a time.++ *++ * It is off under a restore drill (AI_DRILL/LIVE_DRILL): /capture answers 503 capture.drill and no Chrome starts.++ * AI_CAPTURE_TOKEN, when set, must match the `x-ov-capture-token` header; it is read from the environment and++ * never written into a request or a log.++ */++const http = require('http');++const { load } = require('../config');++const { createMediaStore } = require('../media-store');++const { createCapture } = require('./index');++++function json(res, status, body) {++ const text = JSON.stringify(body);++ res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store', 'Content-Length': Buffer.byteLength(text) });++ res.end(text);++}++++function createCaptureWorker({ config, env = process.env, log = console, capture = null, media = null } = {}) {++ const mediaStore = media || createMediaStore({ config, env, log });++ const cap = capture || createCapture({ config, env, log, upload: (o) => mediaStore.put({ ...o, visibility: 'private' }) });++ const token = String(env.AI_CAPTURE_TOKEN || '');++ const host = env.AI_CAPTURE_HOST || '127.0.0.1';++ const port = Math.max(1, Number(env.AI_CAPTURE_PORT) || 4710);++ let server = null;++++ const handler = async (req, res) => {++ if (req.method === 'GET' && req.url === '/health') return json(res, 200, { status: 'ok', service: 'openvibe-ai-capture', chrome: cap.available(), enabled: cap.enabled(), budget_ms: config.capture.pageBudgetMs });++ if (req.method !== 'POST' || req.url !== '/capture') return json(res, 404, { code: 'capture.not_found', detail: 'POST /capture or GET /health' });++ if (token && req.headers['x-ov-capture-token'] !== token) return json(res, 401, { code: 'capture.unauthorized', detail: 'x-ov-capture-token does not match' });++ if (!cap.enabled()) return json(res, 503, { code: 'capture.drill', detail: 'capture is off under a restore drill' });++ let raw = '';++ for await (const chunk of req) {++ raw += chunk;++ if (raw.length > 65536) return json(res, 413, { code: 'capture.too_large', detail: 'capture body is larger than 64 KB' });++ }++ let body;++ try { body = JSON.parse(raw || '{}'); } catch { return json(res, 422, { code: 'input.invalid', detail: 'JSON body required' }); }++ if (!body || typeof body.url !== 'string' || !body.url) return json(res, 422, { code: 'input.invalid', detail: 'url is required' });++ if (body.owner != null && !/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(body.owner))) return json(res, 422, { code: 'input.invalid', detail: 'owner must be a user subject id' });++ if (body.runId != null && !/^run_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(body.runId))) return json(res, 422, { code: 'input.invalid', detail: 'runId must be a run id' });++ try {++ const out = await cap.captureSite({ url: body.url, runId: body.runId || null, owner: body.owner || null, extras: body.extras === true });++ return json(res, out.skipped && out.skipped !== 'cancelled' ? 200 : 200, out);++ } catch (err) {++ log.error(`[capture] ${String(err && err.stack || err).slice(0, 500)}`);++ return json(res, err && err.code === 'run.cancelled' ? 409 : 502, { code: (err && err.code) || 'capture.failed', detail: String((err && err.message) || err).slice(0, 300) });++ }++ };++++ function start() {++ return new Promise((resolve, reject) => {++ server = http.createServer((req, res) => { handler(req, res).catch((err) => json(res, 500, { code: 'capture.internal', detail: String((err && err.message) || err).slice(0, 200) })); });++ server.once('error', reject);++ server.listen(port, host, () => {++ log.log(`[capture] listening on http://${host}:${port} (chrome ${cap.available() ? 'ready' : 'missing'})`);++ resolve(server);++ });++ });++ }++++ function close() {++ return new Promise((resolve) => { if (!server) return resolve(); server.closeAllConnections?.(); server.close(() => resolve()); });++ }++++ return { start, close, handler, capture: cap };++}++++/** The process entry point (systemd ExecStart). */++async function main() {++ const config = load(process.env);++ const worker = createCaptureWorker({ config, env: process.env, log: console });++ const server = await worker.start();++ const stop = async () => { await worker.close(); process.exit(0); };++ process.on('SIGTERM', stop);++ process.on('SIGINT', stop);++ process.on('unhandledRejection', (err) => console.error('[capture] unhandled rejection:', err));++ return { worker, server };++}++++if (require.main === module) main().catch((err) => { console.error('[capture] failed to start:', err && err.stack || err); process.exit(1); });++++module.exports = { createCaptureWorker, main };@@@ -366,6 +366,44 @@ function load(env = process.env) {// Aggregate demand for Host's inference planner (GET /api/v1/usage/series): off by default;// the capability gate (ai.usage.read) is still required when it is on.usageSeries: { enabled: bool(env.AI_USAGE_SERIES, false) },++++ // ── Deep dive (the owner's 11-11 design research; server/deep-dive/ and server/capture/) ──++ // A durable run (workflow design.deep_dive) that searches every source, captures candidate sites in headless++ // Chrome (the capture worker, server/capture/worker.js), reads the shots with a vision model and writes an++ // ai.deep-dive@1 report. The prices are estimates shown before the run and held on the tier budgets; the++ // real cost is on the run's receipt. AI_DRILL/LIVE_DRILL turns capture off, so a restore drill touches no site.++ deepDive: {++ enabled: bool(env.AI_DEEP_DIVE, !bool(env.AI_DRILL, false) && !bool(env.LIVE_DRILL, false)),++ priceUsd: {++ quick: Math.max(0, float(env.AI_DEEP_DIVE_PRICE_QUICK, 0.2)),++ thorough: Math.max(0, float(env.AI_DEEP_DIVE_PRICE_THOROUGH, 0.5)),++ expert: Math.max(0, float(env.AI_DEEP_DIVE_PRICE_EXPERT, 1.2)),++ },++ // How long Thorough and Expert wait after choosing for a person to add or drop sites (Quick goes on).++ chooseWaitSec: Math.max(0, Math.min(3600, int(env.AI_DEEP_DIVE_CHOOSE_WAIT_SEC, 300))),++ // The design rules register and component registry the synthesis checks every recommendation against.++ designUrl: trimUrl(env.AI_DESIGN_URL || 'https://openvibe.design'),++ rulesTimeoutMs: Math.max(1000, Math.min(30000, int(env.AI_DEEP_DIVE_RULES_TIMEOUT_MS, 8000))),++ // How many sites each depth captures and reads.++ maxSites: { quick: 6, thorough: 15, expert: 30 },++ maxSources: { quick: 40, thorough: 80, expert: 140 },++ analyseViewports: { quick: 1, thorough: 1, expert: 2 },++ maxOutputTokens: Math.max(400, Math.min(8000, int(env.AI_DEEP_DIVE_MAX_OUTPUT_TOKENS, 3000))),++ },++ // The capture worker (server/capture/): headless Chrome through playwright-core, one browser at a time.++ // AI_CAPTURE_URL points at the worker's loopback unit; unset runs the same capture in this process (tests).++ capture: {++ url: trimUrl(env.AI_CAPTURE_URL || ''),++ chrome: env.AI_CAPTURE_CHROME || '/usr/bin/google-chrome',++ timeoutMs: Math.max(5000, Math.min(600000, int(env.AI_CAPTURE_TIMEOUT_MS, 300000))),++ pageBudgetMs: Math.max(5000, Math.min(120000, int(env.AI_CAPTURE_PAGE_BUDGET_MS, 45000))),++ hostDelayMs: Math.max(0, Math.min(60000, int(env.AI_CAPTURE_HOST_DELAY_MS, 3000))),++ maxPerOwner: Math.max(1, Math.min(20, int(env.AI_CAPTURE_QUEUE_PER_OWNER, 3))),++ cacheDays: Math.max(0, Math.min(30, int(env.AI_CAPTURE_CACHE_DAYS, 7))),++ maxHeight: Math.max(1000, Math.min(16000, int(env.AI_CAPTURE_MAX_HEIGHT, 12000))),++ // A cap on sites one run may capture (a mis-typed depth cannot spend past the price it held).++ maxCaptures: Math.max(1, Math.min(30, int(env.AI_CAPTURE_MAX_SITES, 30))),++ },// Compiled JSON Schema validators (server/schemas.js): an LRU shared by every caller, some of// whose schemas are caller-supplied, so it is bounded by count and by total schema size.schemaCache: {nnew file mode 100644@@@ -0,0 +1,421 @@++'use strict';++/**++ * The deep dive: one durable AI run (workflow design.deep_dive) with seven steps.++ *++ * plan a model writes the questions an expert would ask and the searches that answer them++ * search MegaSource, AI search's web/picture/icon engines, GitHub and Design's rules + component registers++ * choose a model ranks the candidate sites with a reason each; a person may add or drop any before capture++ * (Quick goes straight on; Thorough and Expert wait up to AI_DEEP_DIVE_CHOOSE_WAIT_SEC)++ * capture every chosen site at 390 and 1280 px, light and (when supported) dark, full page after a slow++ * scroll, with the DOM facts and, at Expert depth, axe-core, LCP/CLS/INP, menus, hover/focus and a++ * frame strip; every shot is a private Media object of the run's owner; a 7-day cache per viewport++ * and theme; robots.txt respected++ * analyse a text model reads each site's DOM facts, a vision model reads each full-page screenshot; crops are++ * the evidence for each point++ * synthesise patterns across sites, a comparison, the best version, what everyone gets wrong, the gap,++ * reference tokens, do/don't, accessibility and speed, and recommendations checked against our rules++ * report ai.deep-dive@1 assembled from what was actually read and captured, with the receipt of every model++ * call and capture++ *++ * Cancelling the run aborts the provider calls and every later capture (the runs API's existing cancel). The price++ * shown before the run is held on the tier budgets by the API route; the real cost is the receipt.++ */++const { AiError } = require('../util');++const schemas = require('../schemas');++const events = require('../events');++const { createDeepDiveStore } = require('./store');++const { createLlm } = require('./llm');++const { createSourceGatherer } = require('./sources');++const { DEPTHS, STEPS, REPORT, PLAN_OUTPUT, CHOOSE_OUTPUT, ANALYSIS_OUTPUT, SYNTHESIS_OUTPUT } = require('./schema');++const prompts = require('./prompts');++++const iso = (v) => (v ? new Date(v).toISOString() : null);++const oneLine = (s, n) => String(s == null ? '' : s).replace(/\s+/g, ' ').trim().slice(0, n);++const hostOf = (u) => { try { return new URL(u).hostname.replace(/^www\./, ''); } catch { return null; } };++const uniq = (list, n = 12) => [...new Set((list || []).map((x) => oneLine(x, 600)).filter(Boolean))].slice(0, n);++++function createDeepDive({ config = {}, db, clock = { now: () => Date.now() }, log = console, registry, pool, research = null, search = null, capture = null, mediaStore = null, fetcher = null, sourceFetch = null } = {}) {++ const cfg = config.deepDive || {};++ const ccfg = config.capture || {};++ const store = createDeepDiveStore({ db, clock, log });++ const llm = createLlm({ registry, pool, config, log, clock });++ const sources = createSourceGatherer({ config, log, research, search, now: () => clock.now(), ...(sourceFetch ? { fetchImpl: sourceFetch } : {}) });++ const prices = cfg.priceUsd || { quick: 0.2, thorough: 0.5, expert: 1.2 };++ const maxSites = cfg.maxSites || { quick: 6, thorough: 15, expert: 30 };++ const maxSources = cfg.maxSources || { quick: 40, thorough: 80, expert: 140 };++ const maxOut = cfg.maxOutputTokens || 3000;++++ function depths() {++ return [++ { key: 'quick', label: 'Quick', sites: maxSites.quick, price_usd: prices.quick, minutes: 3, analysis: 'captures and reads each site; no Expert extras' },++ { key: 'thorough', label: 'Thorough', sites: maxSites.thorough, price_usd: prices.thorough, minutes: 8, analysis: 'more sites, the same captures and analysis' },++ { key: 'expert', label: 'Expert', sites: maxSites.expert, price_usd: prices.expert, minutes: 18, analysis: 'plus interactions, accessibility and speed' },++ ];++ }++++ function priceOf(depth) { return Number(prices[depth]) || 0; }++ function limitOf(depth) { return Math.min(Number(maxSites[depth]) || 6, ccfg.maxCaptures || 30); }++++ async function emit(row, { step = null, status = null, error = null } = {}) {++ try {++ await events.deepDiveUpdated({++ runId: row.run_id || row.id, deepDiveId: row.id, status: status || row.status, step: step == null ? row.step : step,++ progress: row.progress, steps: row.steps, depth: row.depth, requester: { type: run.requester_type || 'service', id: run.requester_id || 'ai' },++ on_behalf_of: row.owner || null, url: `https://openvibe.exchange/deep-dive/${row.run_id || row.id}`, error,++ });++ } catch (err) { log.warn(`[deep-dive] event failed: ${String((err && err.message) || err).slice(0, 160)}`); }++ }++++ async function stepsWith(row, updates = {}) {++ const out = (row.steps && row.steps.length ? row.steps : STEPS.map((key) => ({ key, status: 'pending' }))).map((s) => ({ ...s, ...(updates[s.key] || {}) }));++ return out;++ }++++ async function startStep(row, key, detail, progress) {++ const steps = await stepsWith(row, { [key]: { status: 'running', detail: detail || null, started_at: iso(clock.now()) } });++ const next = await store.patch(row.id, { steps, step: key, progress, status: row.status === 'queued' ? 'running' : row.status });++ if (next) await emit(next, { step: key, status: next.status });++ return next;++ }++++ async function finishStep(row, key, progress, detail = null) {++ const steps = await stepsWith(row, { [key]: { status: 'done', detail: detail || null, finished_at: iso(clock.now()) } });++ const next = await store.patch(row.id, { steps, step: key, progress });++ if (next) await emit(next, { step: key, status: next.status });++ return next;++ }++++ /** The fallback choice when the model returns nothing usable: named sites, then web/project pages. */++ function fallbackCandidates(list, limit) {++ const seen = new Set();++ const out = [];++ for (const s of list) {++ if (s.kind === 'rules' || s.kind === 'component' || s.kind === 'picture' || s.kind === 'icon') continue;++ if (seen.has(s.url)) continue;++ seen.add(s.url);++ out.push({ url: s.url, title: s.title, source_id: s.id, reason: s.kind === 'named' ? 'a site the person named' : 'an expert read from the search' });++ if (out.length >= limit) break;++ }++ return out;++ }++++ function evidenceOf(item, sites, capturesBySite) {++ if (!item) return [];++ const site = sites[(Number(item.site) || 1) - 1] || sites[0];++ if (!site) return [];++ const caps = capturesBySite.get(site.source_id) || [];++ const cap = item.capture ? caps[(Number(item.capture) || 1) - 1] : caps.find((c) => c.theme === 'light' && c.viewport === 1280) || caps[0];++ return [{ claim: oneLine(item.claim || 'observed on the site', 600), source_id: site.source_id, capture_id: cap ? cap.id : null, crop_id: cap && cap.crops && cap.crops[0] ? cap.crops[0].id : null, url: site.url, read_at: cap ? cap.captured_at : iso(clock.now()) }];++ }++++ /** Run one deep dive for one run row. Returns { output, usage, cost, models } for the workflow step. */++ async function run({ run, input, ctx = {} }) {++ const question = oneLine(input.question, 500);++ const depth = input.depth;++ const siteLimit = limitOf(depth);++ const price = priceOf(depth);++ const owner = run && run.on_behalf_of ? (typeof run.on_behalf_of === 'string' ? JSON.parse(run.on_behalf_of) : run.on_behalf_of) : null;++ const runId = run.id;++ let row = await store.ensure({ runId, question, depth, siteLimit, priceUsd: price, owner });++ if (!row) throw new AiError(500, 'deep_dive.not_stored', 'the deep dive row could not be written');++ const models = [];++ const gaps = [];++ const warnings = [];++ let usage = { input: 0, output: 0 };++ let cost = 0;++ const addCall = (step, r) => {++ if (!r) return;++ models.push({ step, provider: r.provider, model: r.model, tokens_in: r.tokens_in || 0, tokens_out: r.tokens_out || 0, cost_usd: Number(r.cost_usd) || 0, ms: r.ms || 0 });++ usage = { input: usage.input + (r.tokens_in || 0), output: usage.output + (r.tokens_out || 0) };++ cost += Number(r.cost_usd) || 0;++ };++ const check = () => { if (ctx.signal && ctx.signal.aborted) throw new AiError(409, 'run.cancelled', 'cancelled'); };++ // The price shown before the run is the ceiling the run holds itself to (the receipt may be lower).++ const overBudget = () => price > 0 && cost >= price;++++ try {++ await store.patch(row.id, { status: 'running', step: 'plan', progress: 0.01 });++ row = await store.get(row.id);++ await emit(row, { step: 'plan', status: 'running' });++++ // 1. Plan++ row = await startStep(row, 'plan', 'writing the expert questions', 0.03);++ check();++ const plan = await llm.call({ routeKey: 'default.json', ...prompts.planPrompt({ question, depth, named: input.sites || [] }), schema: PLAN_OUTPUT, schemaName: 'deep_dive_plan', maxTokens: Math.min(1200, maxOut), signal: ctx.signal, ctx });++ addCall('plan', plan);++ row = await finishStep(row, 'plan', 0.1, `${(plan.json.questions || []).length} questions, ${(plan.json.searches || []).length} searches`);++++ // 2. Search, every way we have.++ row = await startStep(row, 'search', 'MegaSource, the search engines, GitHub and our own registers', 0.12);++ check();++ const gathered = await sources.gather({ question, depth, namedSites: input.sites || [], signal: ctx.signal, maxSources: maxSources[depth] || 40 });++ const allSources = gathered.sources || [];++ for (const w of gathered.warnings || []) warnings.push(w);++ if (!allSources.length) throw new AiError(502, 'source.unavailable', 'no search source answered for this question');++ row = await finishStep(row, 'search', 0.3, `${allSources.length} sources from ${new Set(allSources.map((s) => s.engine).filter(Boolean)).size} engines`);++++ // 3. Choose++ row = await startStep(row, 'choose', 'ranking the sites worth capturing', 0.32);++ check();++ let chosen = [];++ try {++ const pickable = allSources.filter((s) => ['web', 'project', 'named'].includes(s.kind));++ const choose = await llm.call({ routeKey: 'default.json', ...prompts.choosePrompt({ question, depth, limit: siteLimit, sources: pickable.slice(0, 60) }), schema: CHOOSE_OUTPUT, schemaName: 'deep_dive_choose', maxTokens: Math.min(1600, maxOut), signal: ctx.signal, ctx });++ addCall('choose', choose);++ for (const c of choose.json.sites || []) {++ const url = String(c.url || '').replace(/#.*$/, '');++ const src = pickable.find((s) => s.url.replace(/\/$/, '') === url.replace(/\/$/, ''));++ // A model may only choose from what was read: a URL that is not in the list is not invented into one.++ if (!src) continue;++ chosen.push({ url: src.url, title: c.title || src.title || hostOf(src.url), reason: oneLine(c.reason, 600), source_id: src.id, kind: src.kind });++ }++ } catch (err) {++ warnings.push(`choosing: ${String((err && err.message) || err).slice(0, 200)}`);++ }++ // The person's named sites are always candidates; the model only ranks the rest.++ for (const s of allSources.filter((x) => x.kind === 'named')) if (!chosen.some((c) => c.url === s.url)) chosen.unshift({ url: s.url, title: s.title, reason: 'a site the person named', source_id: s.id, kind: 'named' });++ chosen = chosen.filter((c, i) => c.url && chosen.findIndex((x) => x.url.replace(/\/$/, '') === c.url.replace(/\/$/, '')) === i).slice(0, siteLimit);++ if (!chosen.length) chosen = fallbackCandidates(allSources, siteLimit);++ chosen = chosen.map((c, i) => ({ ...c, id: `site_${i + 1}`, chosen: true }));++ row = await store.patch(row.id, { candidates: chosen, status: 'choosing', step: 'choose', progress: 0.34, decision_at: depth === 'quick' ? iso(clock.now()) : null });++ await emit(row, { step: 'choose', status: 'choosing' });++ if (depth !== 'quick') {++ const until = clock.now() + Math.max(0, Number(cfg.chooseWaitSec) || 0) * 1000;++ while (clock.now() < until) {++ check();++ const fresh = await store.get(row.id);++ if (fresh.decision_at) { row = fresh; break; }++ await new Promise((r) => { const t = setTimeout(r, 1000); t.unref?.(); });++ }++ row = await store.get(row.id);++ if (Array.isArray(row.candidates) && row.candidates.length) chosen = row.candidates.slice(0, siteLimit);++ }++ row = await finishStep(row, 'choose', 0.4, `${chosen.length} sites`);++ row = await store.patch(row.id, { status: 'running' });++++ // 4. Capture++ row = await startStep(row, 'capture', `0/${chosen.length} sites`, 0.42);++ check();++ const captures = [];++ const capturesBySite = new Map();++ const analysisSources = [];++ for (const [i, site] of chosen.entries()) {++ check();++ const sourceId = site.source_id || `src_named_${i + 1}`;++ if (!allSources.some((s) => s.id === sourceId)) allSources.push({ id: sourceId, kind: 'named', title: site.title || hostOf(site.url), url: site.url, publisher: hostOf(site.url), engine: null, snippet: null, license: null, read_at: iso(clock.now()) });++ const siteCaps = [];++ const extras = depth === 'expert';++ try {++ const cachedLight = (await store.cacheGet(site.url, 1280, 'light', extras)) && (await store.cacheGet(site.url, 390, 'light', extras));++ if (!extras && cachedLight) {++ for (const c of [await store.cacheGet(site.url, 390, 'light', extras), await store.cacheGet(site.url, 1280, 'light', extras)]) if (c) siteCaps.push({ ...c, cached: true });++ } else if (capture) {++ const got = await capture.captureSite({ url: site.url, runId, owner: owner && owner.id, extras, signal: ctx.signal });++ if (got && got.skipped) {++ warnings.push(`${hostOf(site.url) || site.url}: ${got.reason || got.skipped}`);++ captures.push({ id: `cap_${captures.length + 1}`, source_id: sourceId, url: site.url, final_url: null, title: site.title || null, captured_at: iso(clock.now()), viewport: 390, theme: 'light', full_page: false, media_id: null, width: null, height: null, crops: [], dom: null, extras: null, skip: String(got.skipped === 'robots_disallowed' ? 'robots_disallowed' : got.skipped).slice(0, 200), cached: false });++ }++ for (const combo of (got && got.combos) || []) {++ siteCaps.push(combo);++ await store.cachePut(site.url, combo.viewport, combo.theme, extras, combo, ccfg.cacheDays || 7);++ }++ }++ } catch (err) {++ if (err && err.code === 'run.cancelled') throw err;++ warnings.push(`${hostOf(site.url) || site.url}: ${String((err && err.message) || err).slice(0, 200)}`);++ }++ for (const combo of siteCaps) {++ const cap = {++ id: `cap_${captures.length + 1}`, source_id: sourceId, url: site.url, final_url: combo.final_url || site.url,++ title: combo.title || site.title || null, captured_at: combo.captured_at || iso(clock.now()),++ viewport: combo.viewport, theme: combo.theme, full_page: combo.full_page !== false,++ media_id: combo.media_id || null, width: combo.width || combo.viewport || null, height: combo.height || null,++ crops: (combo.crops || []).map((c, j) => ({ id: `crop_${capidSafe(captures.length, j)}`, media_id: c.media_id, y: c.y, height: c.height })),++ dom: combo.dom || null, extras: combo.extras || null, skip: null, cached: Boolean(combo.cached),++ };++ captures.push(cap);++ if (cap.dom) analysisSources.push({ cap, site, sourceId });++ }++ capturesBySite.set(sourceId, captures.filter((c) => c.source_id === sourceId));++ try {++ await db.prepare(`INSERT INTO requests (run_id, seq, operation, provider_key, model_key, route_key, route_version, status, skip_reason, fallback, input_hash, tokens_in, tokens_out, tokens_cached, tokens_estimated, cost_usd, latency_ms, created_at)++ VALUES (?, ?, 'capture', 'capture', NULL, NULL, NULL, ?, ?, 0, NULL, 0, 0, 0, 0, 0, ?, ?)`)++ .run(runId, 100000 + i, captures.filter((c) => c.source_id === sourceId).length ? 'ok' : 'skipped', captures.filter((c) => c.source_id === sourceId).length ? null : 'no capture', Date.now() - (comboMs(captures) || Date.now()), iso(clock.now())).catch(() => {});++ } catch { /* the receipt is best effort for a capture that was refused */ }++ row = await store.patch(row.id, { captures, progress: 0.42 + (0.28 * (i + 1)) / Math.max(1, chosen.length) });++ row = await store.patch(row.id, { step: 'capture', steps: await stepsWith(row, { capture: { status: 'running', detail: `${i + 1}/${chosen.length} sites` } }) });++ await emit(row, { step: 'capture' });++ }++ row = await finishStep(row, 'capture', 0.7, `${captures.filter((c) => !c.skip).length} captures`);++++ // 5. Analyse: the DOM facts by text, each full-page screenshot by a vision model.++ row = await startStep(row, 'analyse', `0/${chosen.length} sites`, 0.72);++ const analysis = [];++ const analysed = new Set();++ for (const [i, site] of chosen.entries()) {++ check();++ const sourceId = site.source_id || `src_named_${i + 1}`;++ const siteCaps = capturesBySite.get(sourceId) || [];++ const primary = siteCaps.find((c) => c.theme === 'light' && c.viewport === 1280 && c.dom) || siteCaps.find((c) => c.dom) || null;++ const evidence = [];++ let text = null;++ if (primary && !overBudget()) {++ try {++ text = await llm.call({ routeKey: 'default.json', ...prompts.domAnalysisPrompt({ question, source: { title: site.title, url: site.url }, capture: primary, dom: primary.dom }), schema: ANALYSIS_OUTPUT, schemaName: 'deep_dive_site', maxTokens: Math.min(1800, maxOut), signal: ctx.signal, ctx });++ addCall('analyse', text);++ } catch (err) { warnings.push(`analysis of ${hostOf(site.url) || site.url}: ${String((err && err.message) || err).slice(0, 200)}`); }++ }++ // Each light full-page shot (both viewports; Expert also dark) is read by the vision model.++ const shots = siteCaps.filter((c) => c.media_id && c.full_page && (c.theme === 'light' || depth === 'expert'));++ let vision = null;++ for (const [j, cap] of shots.entries()) {++ check();++ if (overBudget()) { gaps.push('The held price was reached before every screenshot was read; the remaining screenshots are evidence only.'); break; }++ if (!mediaStore || typeof mediaStore.bytes !== 'function') break;++ try {++ const got = await mediaStore.bytes(cap.media_id, { owner: owner && owner.id, signal: ctx.signal });++ const image = await fetcher.loadImage({ data_url: `data:${got.mime || 'image/png'};base64,${got.buffer.toString('base64')}`, max_width: cap.viewport === 390 ? 390 : 1024 });++ const read = await llm.call({ routeKey: depth === 'expert' ? 'background.vision' : 'default.json', ...prompts.visionPrompt({ question, source: { title: site.title, url: site.url }, viewport: cap.viewport, theme: cap.theme, index: j + 1, total: shots.length }), image, schema: ANALYSIS_OUTPUT, schemaName: 'deep_dive_shot', maxTokens: Math.min(1500, maxOut), signal: ctx.signal, ctx });++ addCall('analyse', read);++ for (const e of (read.json.evidence || []).slice(0, 6)) evidence.push({ claim: oneLine(e.claim, 600), source_id: sourceId, capture_id: cap.id, crop_id: cap.crops && cap.crops[0] ? cap.crops[0].id : null, url: site.url, read_at: cap.captured_at });++ if (!vision) vision = read.json;++ else {++ vision = {++ ...vision,++ summary: vision.summary || read.json.summary,++ strengths: uniq([...(vision.strengths || []), ...(read.json.strengths || [])], 8),++ weaknesses: uniq([...(vision.weaknesses || []), ...(read.json.weaknesses || [])], 8),++ };++ }++ } catch (err) { warnings.push(`vision on ${hostOf(site.url) || site.url}: ${String((err && err.message) || err).slice(0, 160)}`); }++ }++ const t = (text && text.json) || {};++ const v = vision || {};++ let accessibility = t.accessibility || null;++ if (primary && primary.extras) {++ const axe = primary.extras.a11y || {};++ const violations = Array.isArray(axe.violations) ? axe.violations : [];++ const measured = violations.length ? `${violations.length} axe-core violations: ${violations.slice(0, 4).map((x) => `${x.id}${x.impact ? ` (${x.impact})` : ''}`).join(', ')}` : (axe.error ? `axe-core could not run: ${axe.error}` : 'no axe-core violations at WCAG 2.1 AA');++ accessibility = measured;++ const m = primary.extras.metrics || {};++ if (m.lcp_ms != null || m.cls != null) {++ const speed = `LCP ${m.lcp_ms == null ? '—' : `${m.lcp_ms} ms`}; CLS ${m.cls == null ? '—' : m.cls}; INP ${m.inp_ms == null ? 'not measured' : `${m.inp_ms} ms`}; ${m.long_tasks || 0} long tasks`;++ t.speed = t.speed && t.speed !== 'not measured at this depth' ? `${speed}. ${t.speed}` : speed;++ }++ }++ const firstCap = siteCaps.find((c) => c.media_id) || null;++ analysis.push({++ source_id: sourceId, url: site.url,++ summary: oneLine(t.summary || v.summary || (primary ? 'Captured; the analysis did not answer.' : 'Not captured.'), 4000),++ first_impression: oneLine(v.first_impression || t.first_impression || '', 2000),++ hierarchy: oneLine(t.hierarchy || v.hierarchy || '', 2000), density: oneLine(t.density || v.density || '', 2000),++ type: oneLine(t.type || v.type || '', 2000), colour: oneLine(t.colour || v.colour || '', 2000),++ motion: oneLine(t.motion || v.motion || '', 2000), words: oneLine(t.words || v.words || '', 2000),++ strengths: uniq([...(v.strengths || []), ...(t.strengths || [])], 12),++ weaknesses: uniq([...(v.weaknesses || []), ...(t.weaknesses || [])], 12),++ accessibility: accessibility || null, speed: t.speed || null,++ evidence: evidence.length ? evidence.slice(0, 20) : (firstCap ? [{ claim: 'the page as captured', source_id: sourceId, capture_id: firstCap.id, crop_id: firstCap.crops && firstCap.crops[0] ? firstCap.crops[0].id : null, url: site.url, read_at: firstCap.captured_at }] : []),++ });++ analysed.add(sourceId);++ row = await store.patch(row.id, { progress: 0.72 + (0.16 * (i + 1)) / Math.max(1, chosen.length), steps: await stepsWith(row, { analyse: { status: 'running', detail: `${i + 1}/${chosen.length} sites` } }) });++ await emit(row, { step: 'analyse' });++ }++ row = await finishStep(row, 'analyse', 0.88, `${analysis.length} sites analysed`);++++ // 6. Synthesise: cross-site patterns, the gap, recommendations against our rules.++ row = await startStep(row, 'synthesise', 'comparing sites against our rules', 0.9);++ check();++ const tokens = analysis.map((a) => {++ const cap = (capturesBySite.get(a.source_id) || []).find((c) => c.dom) || {};++ const dom = cap.dom || {};++ return {++ source_id: a.source_id,++ palettes: (dom.palette ? [...(dom.palette.backgrounds || []), ...(dom.palette.text || [])] : []).slice(0, 6).map((x) => x.value),++ families: (dom.fonts ? (dom.fonts.families || []) : []).slice(0, 3).map((x) => x.value),++ sizes: (dom.fonts ? (dom.fonts.sizes || []) : []).slice(0, 8).map((x) => x.value),++ spacing: (dom.spacing || []).slice(0, 8).map((x) => x.value),++ };++ });++ const synthCall = await llm.call({ routeKey: 'default.json', ...prompts.synthesisPrompt({ question, depth, analyses: analysis, sources: allSources, rules: gathered.rules || [], components: gathered.components || [], tokens }), schema: SYNTHESIS_OUTPUT, schemaName: 'deep_dive_synthesis', maxTokens: Math.min(3500, maxOut), signal: ctx.signal, ctx });++ addCall('synthesise', synthCall);++ const s = synthCall.json || {};++ const sitesForEvidence = analysis.map((a) => ({ source_id: a.source_id, url: a.url }));++ const ruleIds = new Set((gathered.rules || []).map((r) => r.id));++ const mapEvidence = (list) => (Array.isArray(list) ? list : []).flatMap((e) => evidenceOf(e, sitesForEvidence, capturesBySite)).slice(0, 20);++ const bestSite = sitesForEvidence[(Number(s.best && s.best.site) || 1) - 1] || sitesForEvidence[0];++ const synthesis = {++ patterns: (s.patterns || []).slice(0, 40).map((p) => ({++ name: oneLine(p.name, 160), what: oneLine(p.what, 1200),++ sites: (p.sites || []).map((n) => (sitesForEvidence[(Number(n) || 1) - 1] || {}).source_id).filter(Boolean).slice(0, 30),++ usage_count: Number(p.usage_count) || 0, evidence: mapEvidence(p.evidence),++ })),++ comparison: { columns: (s.comparison && s.comparison.columns || []).slice(0, 12), rows: (s.comparison && s.comparison.rows || []).slice(0, 30) },++ best: bestSite ? { source_id: bestSite.source_id, what: oneLine(s.best && s.best.what || '', 2000), evidence: mapEvidence(s.best && s.best.evidence) } : { source_id: null, what: '', evidence: [] },++ mistakes: (s.mistakes || []).slice(0, 20).map((m) => ({ what: oneLine(m.what, 1200), evidence: mapEvidence(m.evidence) })),++ gap: oneLine(s.gap, 2000),++ reference_tokens: {++ palettes: (s.reference_tokens && s.reference_tokens.palettes || []).map((p) => ({ source_id: (sitesForEvidence[(Number(p.site) || 1) - 1] || {}).source_id, colours: (p.colours || []).slice(0, 16) })).filter((p) => p.source_id).slice(0, 30),++ type_scales: (s.reference_tokens && s.reference_tokens.type_scales || []).map((p) => ({ source_id: (sitesForEvidence[(Number(p.site) || 1) - 1] || {}).source_id, families: (p.families || []).slice(0, 8), sizes: (p.sizes || []).slice(0, 16) })).filter((p) => p.source_id).slice(0, 30),++ spacing: (s.reference_tokens && s.reference_tokens.spacing || []).map((p) => ({ source_id: (sitesForEvidence[(Number(p.site) || 1) - 1] || {}).source_id, steps: (p.steps || []).slice(0, 16) })).filter((p) => p.source_id).slice(0, 30),++ },++ do: (s.do || []).slice(0, 20).map((x) => oneLine(x, 600)),++ dont: (s.dont || []).slice(0, 20).map((x) => oneLine(x, 600)),++ accessibility: (s.accessibility || []).slice(0, 20).map((x) => oneLine(x, 600)),++ speed: (s.speed || []).slice(0, 20).map((x) => oneLine(x, 600)),++ };++ if (!synthesis.best.source_id) synthesis.best = { source_id: analysis[0] ? analysis[0].source_id : null, what: synthesis.best.what, evidence: synthesis.best.evidence };++ const recommendations = (s.recommendations || []).slice(0, 20).map((r, i) => {++ const id = r.rule && r.rule.id && ruleIds.has(r.rule.id) ? r.rule.id : null;++ const status = id ? (r.rule.status === 'would_change' ? 'would_change' : 'follows') : 'none';++ let ev = mapEvidence(r.evidence);++ if (!ev.length && analysis[0]) {++ const cap = (capturesBySite.get(analysis[0].source_id) || [])[0];++ ev = [{ claim: oneLine(r.what, 600), source_id: analysis[0].source_id, capture_id: cap ? cap.id : null, crop_id: cap && cap.crops && cap.crops[0] ? cap.crops[0].id : null, url: analysis[0].url, read_at: cap ? cap.captured_at : iso(clock.now()) }];++ }++ return { id: `rec_${i + 1}`, what: oneLine(r.what, 1200), why: oneLine(r.why, 2000), rule: { id, status, text: oneLine((r.rule && r.rule.text) || (id && (gathered.rules || []).find((x) => x.id === id) || {}).rule || '', 600) }, evidence: ev };++ });++ if (!recommendations.length) gaps.push('The synthesis returned no recommendation; nothing is proposed.');++ row = await finishStep(row, 'synthesise', 0.96, `${synthesis.patterns.length} patterns, ${recommendations.length} recommendations`);++++ // 7. Report++ row = await startStep(row, 'report', 'assembling the report and its receipt', 0.98);++ const nowIso = iso(clock.now());++ const report = {++ id: runId, schema: 'ai.deep-dive@1', run_id: runId, status: 'succeeded',++ input: { question, depth, max_sites: siteLimit, ...(input.sites && input.sites.length ? { sites: input.sites } : {}) },++ steps: (await store.get(row.id)).steps,++ sources: allSources.slice(0, 200),++ captures,++ analysis,++ synthesis,++ recommendations,++ receipt: { price_usd: price, held_usd: price, cost_usd: Math.round(cost * 1e6) / 1e6, models, captures_count: captures.filter((c) => !c.skip).length, synthetic: models.some((m) => !m.provider) },++ gaps: [...new Set([...gaps, ...warnings])].slice(0, 40),++ created_at: row.created_at, updated_at: nowIso, finished_at: nowIso,++ };++ const v = schemas.validate(REPORT, report);++ if (!v.valid) throw new AiError(502, 'output.invalid', `the deep dive report does not match ai.deep-dive@1: ${v.errors.map((e) => `${e.path} ${e.message}`).join('; ')}`, { errors: v.errors });++ const steps = (await store.get(row.id)).steps.map((x) => (x.key === 'report' ? { ...x, status: 'done', finished_at: nowIso } : x));++ row = await store.patch(row.id, { status: 'succeeded', step: 'report', progress: 1, steps, report, gaps: report.gaps, finished_at: nowIso });++ await emit(row, { step: 'done', status: 'succeeded' });++ return { output: report, usage, cost, models };++ } catch (err) {++ const cancelled = (err && err.code === 'run.cancelled') || (ctx.signal && ctx.signal.aborted);++ const code = cancelled ? 'run.cancelled' : ((err && err.code) || 'run.internal');++ const detail = String((err && (err.detail || err.message)) || 'the run failed').slice(0, 500);++ try {++ const steps = (await store.get(row.id)).steps.map((x) => (x.status === 'running' ? { ...x, status: cancelled ? 'skipped' : 'failed', finished_at: iso(clock.now()) } : x));++ const failed = await store.patch(row.id, { status: cancelled ? 'cancelled' : 'failed', steps, error_code: code, error_detail: detail, finished_at: iso(clock.now()) });++ if (failed) await emit(failed, { status: failed.status, error: { code, detail } });++ } catch (e) { log.warn(`[deep-dive] could not record the failure: ${e.message}`); }++ throw err;++ }++ }++++ return { run, depths, priceOf, limitOf, store, capture, mediaStore, sources };++}++++function capidSafe(n, j) { return `${n + 1}_${j + 1}`; }++function comboMs(captures) { return 0; }++++module.exports = { createDeepDive };nnew file mode 100644@@@ -0,0 +1,69 @@++'use strict';++/**++ * The deep dive's model calls: one JSON answer per call, validated against a schema before it is used.++ *++ * Everything goes through the provider pool (server/providers/) with the run's own context, so the call is on the++ * run's receipt, its provider quota and its cancellation signal. A route is resolved like a workflow's: the JSON++ * route (`default.json`) for text, the same route with an image for a vision call, and `background.vision` for++ * Expert reads that may wait. A call that answers invalid JSON is asked once more; a second bad answer fails the++ * run (output.invalid), because nothing is ever filled in.++ */++const { AiError, parseJsonLoose, sha256 } = require('../util');++const schemas = require('../schemas');++++function createLlm({ registry, pool, config = {}, log = console, clock = { now: () => Date.now() } }) {++ async function routeFor(key) {++ const r = await registry.resolveRoute(key);++ if (!r) throw new AiError(503, 'route.unavailable', `no route ${key}`);++ if (r.disabled) throw new AiError(503, 'route.unavailable', `route ${key} is disabled`);++ return r;++ }++++ /**++ * call({ routeKey, system, user, image, schema, schemaName, maxTokens, temperature, signal, ctx })++ * -> { json, provider, model, tokens_in, tokens_out, cost_usd, ms, route, synthetic }++ */++ async function call({ routeKey = 'default.json', system, user, image = null, schema = null, schemaName = 'result', maxTokens = 3000, temperature = 0.3, signal = null, ctx = {}, retry = true }) {++ const route = await routeFor(routeKey);++ const req = {++ system: system ? [{ text: system, cache: true }] : [],++ messages: [{ role: 'user', content: user }],++ image,++ json: schema ? { name: schemaName, schema, strict: false } : null,++ maxTokens: Math.max(64, Math.min(8000, maxTokens)),++ temperature,++ timeoutMs: route.timeout_ms || 90000,++ cacheKey: null,++ };++ const t0 = clock.now();++ const exec = await pool.execute(route, 'chat', req, {++ ...ctx, routeKey: route.key, routeVersion: route.version, promptHash: sha256({ s: system, u: user, i: image ? 1 : 0 }),++ ...(signal ? { signal } : {}),++ });++ const text = exec.result.text || '';++ let json = exec.result.json || null;++ if (!json) json = parseJsonLoose(text);++ let valid = json && (!schema || schemas.validate(schema, json).valid);++ if (!valid && retry) {++ const problem = json ? schemas.validate(schema, json).errors.map((e) => `${e.path} ${e.message}`).join('; ') : 'the answer was not JSON';++ const again = await call({++ routeKey, system, user: `${user}\n\nYour previous answer was not valid (${problem.slice(0, 400)}). Answer again with only the JSON object the instructions name, nothing before or after it.`,++ image, schema, schemaName, maxTokens, temperature: 0.1, signal, ctx, retry: false,++ });++ return again;++ }++ if (!valid) {++ const errors = json ? schemas.validate(schema, json).errors : [{ path: '/', message: 'not JSON' }];++ throw new AiError(502, 'output.invalid', `the model answered a shape the deep dive cannot use: ${errors.map((e) => `${e.path} ${e.message}`).slice(0, 4).join('; ')}`);++ }++ return {++ json, provider: exec.provider || null, model: exec.model || null,++ tokens_in: (exec.usage && exec.usage.input) || 0, tokens_out: (exec.usage && exec.usage.output) || 0,++ cost_usd: Number(exec.cost) || 0, ms: clock.now() - t0, route, synthetic: Boolean(exec.synthetic),++ };++ }++++ return { call, routeFor };++}++++module.exports = { createLlm };nnew file mode 100644@@@ -0,0 +1,110 @@++'use strict';++/**++ * The deep dive's prompts. The page is the main session's; these are the words a model is given.++ *++ * The plan writes the questions an expert would ask and the searches that answer them; choose ranks candidate++ * sites with a reason each; analyse reads one page (its DOM measurements, or one full-page screenshot) as a++ * designer; synthesise compares the sites, finds the patterns and the gap, and checks every recommendation++ * against OpenVibe's own rules register. Every answer is JSON in the shape server/deep-dive/schema.js names.++ */++++const clean = (s, n) => String(s == null ? '' : s).replace(/\s+/g, ' ').trim().slice(0, n);++++function planPrompt({ question, depth, named }) {++ return {++ system: 'You are a senior product designer planning expert design research. You ask what an expert would ask before looking at a single site, and you say what to search for. Answer only with JSON.',++ user: `The question: "${clean(question, 500)}"++Depth: ${depth}.${named.length ? ` Sites the person already named: ${named.join(', ')}` : ''}++++Write the questions an expert designer would ask about this (patterns, hierarchy, density, type, colour, motion, words, trust, accessibility, speed, phone versus computer), and the searches that would answer them. Rules:++- questions: 5 to 9, each { "key": one short slug, "question": one sentence }.++- searches: 2 to 6, each { "q": a search query, "kind": "web" | "images" | "icons" | "projects" }. Make the queries specific to this question and to design (add "design", "case study", "ui", "ux" where they help).++- Do not answer the question yourself. JSON only:++{"questions":[{"key":"hierarchy","question":"..."}],"searches":[{"q":"...","kind":"web"}]}`,++ };++}++++function choosePrompt({ question, depth, limit, sources }) {++ const list = sources.map((s, i) => `${i + 1}. [${s.kind}] ${clean(s.title, 140)} — ${s.url}${s.snippet ? ` — ${clean(s.snippet, 220)}` : ''}`).join('\n');++ return {++ system: 'You are a senior product designer choosing which sites a deep dive should capture. You only choose real pages that can be photographed without signing in, and you say why each one is worth the look. Answer only with JSON.',++ user: `The question: "${clean(question, 500)}"++Depth: ${depth}. Choose at most ${limit} sites to capture (390 px, 1280 px, dark and light where supported), from this list only:++${list}++++Rules:++- Prefer production pages and real products over listicles, directories, aggregators, social posts, docs and search results.++- Cover different approaches (phone-first, dense dashboard, editorial, playful, minimal), not six versions of one look.++- Every chosen URL must appear in the list above, exactly as given.++- Each reason is one concrete sentence about what this site would teach about the question.++- JSON only: {"sites":[{"url":"https://...","reason":"..."}]}.`,++ };++}++++function domAnalysisPrompt({ question, source, capture, dom }) {++ return {++ system: 'You are a senior product designer reading a page\'s measured design system. You describe what is there from the numbers, never what you imagine. Answer only with JSON.',++ user: `Question: "${clean(question, 500)}"++Site: ${clean(source.title, 200)} — ${source.url}++Measured at ${capture.viewport} px, ${capture.theme} theme${capture.extras ? ', with the Expert extras' : ''}.++++DOM facts (computed from the live page):++${JSON.stringify(dom, null, 1).slice(0, 14000)}++${capture.extras ? `\nExpert extras (axe-core, LCP/CLS/INP, menus, hover/focus, frames):\n${JSON.stringify(capture.extras, null, 1).slice(0, 6000)}` : ''}++++Write { "summary", "first_impression", "hierarchy", "density", "type", "colour", "motion", "words", "accessibility", "speed", "strengths": [up to 4], "weaknesses": [up to 4], "evidence": [{"claim":"...","shot":1}] }.++- Be concrete: name the sizes, counts, colours and words that are actually in the facts.++- Accessibility and speed only when the extras measured them; otherwise say "not measured at this depth" in that field.++- Each evidence claim is one of your own claims, with "shot": 1 (this page). JSON only.`,++ };++}++++function visionPrompt({ question, source, viewport, theme, index, total }) {++ return {++ system: 'You are a senior product designer looking at a real page. You say what draws the eye first, how the hierarchy and density work, what is good and what fails. Never invent a control or a colour you cannot see. Answer only with JSON.',++ user: `Question: "${clean(question, 500)}"++This is a full-page screenshot of ${source.url} (${clean(source.title, 160)}) at ${viewport} px, ${theme} theme. It is screenshot ${index} of ${total} for this site.++++Write { "summary" (2-3 sentences: what this page is and how it works), "first_impression" (what the eye lands on first), "hierarchy", "density", "type", "colour", "motion" (only what a still can show), "words", "strengths": [up to 3], "weaknesses": [up to 3], "evidence": [{"claim":"...","shot":${index}}] }.++Every claim must follow from what is visible in this image. JSON only.`,++ };++}++++function synthesisPrompt({ question, depth, analyses, sources, rules, components, tokens }) {++ const siteList = analyses.map((a, i) => `${i + 1}. ${a.url} — ${clean(a.summary, 260)}`).join('\n');++ const ruleList = (rules || []).map((r) => `- ${r.id}: ${clean(r.rule, 300)} (${clean(r.area, 80)})`).join('\n') || '- (the rules register did not answer)';++ const compList = (components || []).map((c) => `- ${c.title || c.name}: ${clean(c.summary, 220)}`).join('\n') || '- (the component registry did not answer)';++ return {++ system: 'You are the design lead writing the synthesis of expert design research for OpenVibe. You compare real sites, name the patterns and how many sites use each, find what everyone gets wrong and the gap nobody fills, and you check every recommendation against OpenVibe\'s own rules register. Every claim cites a site. Answer only with JSON.',++ user: `Question: "${clean(question, 500)}" (depth: ${depth}).++++The sites, numbered for your citations:++${siteList}++++Their analyses (JSON; each has source_id, url, summary, strengths, weaknesses):++${JSON.stringify(analyses.map((a) => ({ source_id: a.source_id, url: a.url, summary: clean(a.summary, 800), first_impression: clean(a.first_impression, 500), hierarchy: clean(a.hierarchy, 500), density: clean(a.density, 400), type: clean(a.type, 400), colour: clean(a.colour, 400), motion: clean(a.motion, 300), words: clean(a.words, 300), strengths: a.strengths, weaknesses: a.weaknesses, accessibility: a.accessibility, speed: a.speed })), null, 1).slice(0, 30000)}++++Reference tokens measured from the DOM (palettes, type scales, spacing):++${JSON.stringify(tokens, null, 1).slice(0, 6000)}++++OpenVibe's rules register (use these ids; a recommendation follows one, would change one, or has none):++${ruleList}++++OpenVibe's component registry:++${compList}++++Write JSON with:++{"patterns":[{"name","what","sites":[site numbers],"usage_count","evidence":[{"claim","site":number}]}],++ "comparison":{"columns":["Site","..."],"rows":[["...", "..."]]},++ "best":{"site":number,"what","evidence":[{"claim","site":number}]},++ "mistakes":[{"what","evidence":[{"claim","site":number}]}],++ "gap":"...",++ "reference_tokens":{"palettes":[{"site":number,"colours":["#..."]}],"type_scales":[{"site":number,"families":[],"sizes":[]}],"spacing":[{"site":number,"steps":[]}]},++ "do":["..."],"dont":["..."],"accessibility":["..."],"speed":["..."],++ "recommendations":[{"what","why","rule":{"id":one of the rule ids or null,"status":"follows"|"would_change"|"none","text":the rule in one line},"evidence":[{"claim","site":number}]}]}++++Rules: 5 to 10 patterns; 4 to 8 do and don't; 5 to 8 recommendations, each with a site number in its evidence. Use only the rules above; never invent a rule id. JSON only.`,++ };++}++++module.exports = { planPrompt, choosePrompt, domAnalysisPrompt, visionPrompt, synthesisPrompt };nnew file mode 100644@@@ -0,0 +1,239 @@++'use strict';++/**++ * The shapes a deep dive runs on.++ *++ * The published document is ai.deep-dive@1 (OpenVibe.Contracts): the question, the seven steps, every source,++ * every capture, the per-site analysis, the synthesis, the recommendations and the receipt, with an evidence++ * list on every claim. This module holds the workflow's input and output schema and the four JSON shapes an++ * intermediate model call answers in (plan, choose, one site's analysis, the synthesis). They are deliberately++ * smaller than the report: the report is assembled here, from what was actually read and captured, so a model++ * cannot invent a capture id, a URL that was not visited, or a media object.++ */++++const STR = (n) => ({ type: 'string', maxLength: n });++const NULLABLE = (n) => ({ type: ['string', 'null'], maxLength: n });++const STEPS = ['plan', 'search', 'choose', 'capture', 'analyse', 'synthesise', 'report'];++const DEPTHS = ['quick', 'thorough', 'expert'];++++const EVIDENCE = {++ type: 'array', maxItems: 20,++ items: {++ type: 'object', additionalProperties: false, required: ['claim', 'url', 'read_at'],++ properties: {++ claim: STR(600), source_id: { type: ['string', 'null'], maxLength: 40 },++ capture_id: { type: ['string', 'null'], maxLength: 40 }, crop_id: { type: ['string', 'null'], maxLength: 40 },++ url: STR(2048), read_at: STR(40),++ },++ },++};++++const SOURCE = {++ type: 'object', additionalProperties: false, required: ['id', 'kind', 'title', 'url', 'read_at'],++ properties: {++ id: STR(40), kind: { enum: ['web', 'picture', 'icon', 'project', 'rules', 'component', 'named'] },++ title: STR(400), url: STR(2048), publisher: NULLABLE(200), engine: NULLABLE(80),++ snippet: NULLABLE(2000), license: NULLABLE(120), read_at: STR(40),++ },++};++++const CROP = {++ type: 'object', additionalProperties: false, required: ['id', 'media_id', 'y', 'height'],++ properties: { id: STR(40), media_id: { type: 'string', pattern: '^med_[0-9A-HJKMNP-TV-Z]{26}$' }, y: { type: 'integer', minimum: 0 }, height: { type: 'integer', minimum: 1 } },++};++++const CAPTURE = {++ type: 'object', additionalProperties: false,++ required: ['id', 'source_id', 'url', 'captured_at', 'viewport', 'theme', 'full_page', 'crops'],++ properties: {++ id: STR(40), source_id: STR(40), url: STR(2048), final_url: { type: ['string', 'null'], maxLength: 2048 },++ title: NULLABLE(400), captured_at: STR(40), viewport: { enum: [390, 1280] }, theme: { enum: ['light', 'dark'] },++ full_page: { type: 'boolean' }, media_id: { type: ['string', 'null'], pattern: '^med_[0-9A-HJKMNP-TV-Z]{26}$' },++ width: { type: ['integer', 'null'], minimum: 1 }, height: { type: ['integer', 'null'], minimum: 1 },++ crops: { type: 'array', maxItems: 8, items: CROP },++ dom: { type: ['object', 'null'] }, extras: { type: ['object', 'null'] },++ skip: { type: ['string', 'null'], maxLength: 200 }, cached: { type: 'boolean' },++ },++};++++const ANALYSIS = {++ type: 'object', additionalProperties: false,++ required: ['source_id', 'url', 'summary', 'strengths', 'weaknesses', 'evidence'],++ properties: {++ source_id: STR(40), url: STR(2048), summary: STR(4000), first_impression: STR(2000), hierarchy: STR(2000),++ density: STR(2000), type: STR(2000), colour: STR(2000), motion: STR(2000), words: STR(2000),++ strengths: { type: 'array', maxItems: 12, items: STR(600) },++ weaknesses: { type: 'array', maxItems: 12, items: STR(600) },++ accessibility: NULLABLE(2000), speed: NULLABLE(2000), evidence: EVIDENCE,++ },++};++++const EVIDENCE_CLAIM = {++ type: 'array', maxItems: 20,++ items: {++ type: 'object', additionalProperties: false, required: ['claim', 'site'],++ properties: { claim: STR(600), site: { type: 'integer', minimum: 1, maximum: 30 }, capture: { type: ['integer', 'null'], minimum: 1, maximum: 400 } },++ },++};++++const PATTERN_OUT = {++ type: 'object', additionalProperties: false, required: ['name', 'what', 'sites', 'evidence'],++ properties: {++ name: STR(160), what: STR(1200),++ sites: { type: 'array', maxItems: 30, items: { type: 'string', maxLength: 40 } },++ usage_count: { type: 'integer', minimum: 0, maximum: 30 },++ evidence: EVIDENCE_CLAIM,++ },++};++++const RECOMMENDATION_OUT = {++ type: 'object', additionalProperties: false, required: ['what', 'why', 'rule', 'evidence'],++ properties: {++ what: STR(1200), why: STR(2000),++ rule: {++ type: 'object', additionalProperties: false, required: ['status', 'text'],++ properties: { id: { type: ['string', 'null'], maxLength: 120 }, status: { enum: ['follows', 'would_change', 'none'] }, text: STR(600) },++ },++ evidence: EVIDENCE_CLAIM,++ },++};++++/** The LLM steps' JSON. Each is one call; a wrong shape fails the run rather than filling anything in. */++const PLAN_OUTPUT = {++ type: 'object', additionalProperties: true, required: ['questions', 'searches'],++ properties: {++ questions: { type: 'array', minItems: 3, maxItems: 12, items: { type: 'object', additionalProperties: true, required: ['key', 'question'], properties: { key: STR(40), question: STR(400) } } },++ searches: { type: 'array', minItems: 1, maxItems: 10, items: { type: 'object', additionalProperties: true, required: ['q'], properties: { q: STR(400), kind: { enum: ['web', 'images', 'icons', 'projects'] } } } },++ },++};++++const CHOOSE_OUTPUT = {++ type: 'object', additionalProperties: true, required: ['sites'],++ properties: {++ sites: {++ type: 'array', minItems: 1, maxItems: 30,++ items: { type: 'object', additionalProperties: true, required: ['url', 'reason'], properties: { url: STR(2048), reason: STR(600), kind: STR(40) } },++ },++ },++};++++const ANALYSIS_OUTPUT = {++ type: 'object', additionalProperties: true, required: ['summary', 'strengths', 'weaknesses'],++ properties: {++ summary: STR(4000), first_impression: STR(2000), hierarchy: STR(2000), density: STR(2000), type: STR(2000),++ colour: STR(2000), motion: STR(2000), words: STR(2000), accessibility: NULLABLE(2000), speed: NULLABLE(2000),++ strengths: { type: 'array', maxItems: 12, items: STR(600) },++ weaknesses: { type: 'array', maxItems: 12, items: STR(600) },++ evidence: { type: 'array', maxItems: 12, items: { type: 'object', additionalProperties: true, required: ['claim'], properties: { claim: STR(600), shot: { type: ['integer', 'null'], minimum: 1 } } } },++ },++};++++const SYNTHESIS_OUTPUT = {++ type: 'object', additionalProperties: true,++ required: ['patterns', 'comparison', 'best', 'mistakes', 'gap', 'reference_tokens', 'do', 'dont', 'accessibility', 'speed', 'recommendations'],++ properties: {++ patterns: { type: 'array', maxItems: 40, items: PATTERN_OUT },++ comparison: {++ type: 'object', additionalProperties: true, required: ['columns', 'rows'],++ properties: { columns: { type: 'array', maxItems: 12, items: STR(120) }, rows: { type: 'array', maxItems: 30, items: { type: 'array', maxItems: 12, items: STR(1000) } } },++ },++ best: { type: 'object', additionalProperties: true, required: ['site', 'what'], properties: { site: { type: 'integer', minimum: 1, maximum: 30 }, what: STR(2000), evidence: EVIDENCE_CLAIM } },++ mistakes: { type: 'array', maxItems: 20, items: { type: 'object', additionalProperties: true, required: ['what'], properties: { what: STR(1200), evidence: EVIDENCE_CLAIM } } },++ gap: STR(2000),++ reference_tokens: {++ type: 'object', additionalProperties: true, required: ['palettes', 'type_scales', 'spacing'],++ properties: {++ palettes: { type: 'array', maxItems: 30, items: { type: 'object', additionalProperties: true, required: ['site', 'colours'], properties: { site: { type: 'integer', minimum: 1 }, colours: { type: 'array', maxItems: 16, items: STR(40) } } } },++ type_scales: { type: 'array', maxItems: 30, items: { type: 'object', additionalProperties: true, required: ['site', 'families', 'sizes'], properties: { site: { type: 'integer', minimum: 1 }, families: { type: 'array', maxItems: 8, items: STR(120) }, sizes: { type: 'array', maxItems: 16, items: STR(40) } } } },++ spacing: { type: 'array', maxItems: 30, items: { type: 'object', additionalProperties: true, required: ['site', 'steps'], properties: { site: { type: 'integer', minimum: 1 }, steps: { type: 'array', maxItems: 16, items: STR(40) } } } },++ },++ },++ do: { type: 'array', maxItems: 20, items: STR(600) },++ dont: { type: 'array', maxItems: 20, items: STR(600) },++ accessibility: { type: 'array', maxItems: 20, items: STR(600) },++ speed: { type: 'array', maxItems: 20, items: STR(600) },++ recommendations: { type: 'array', minItems: 1, maxItems: 20, items: RECOMMENDATION_OUT },++ },++};++++const REPORT = {++ type: 'object', additionalProperties: false,++ required: ['id', 'schema', 'run_id', 'status', 'input', 'steps', 'sources', 'captures', 'analysis', 'synthesis', 'recommendations', 'receipt', 'gaps', 'created_at', 'updated_at'],++ properties: {++ id: STR(40), schema: { const: 'ai.deep-dive@1' }, run_id: STR(40),++ status: { enum: ['queued', 'running', 'choosing', 'succeeded', 'failed', 'cancelled'] },++ input: {++ type: 'object', additionalProperties: false, required: ['question', 'depth', 'max_sites'],++ properties: { question: STR(500), depth: { enum: DEPTHS }, max_sites: { type: 'integer', minimum: 1, maximum: 30 }, sites: { type: 'array', maxItems: 30, items: STR(2048) } },++ },++ steps: {++ type: 'array', maxItems: 7,++ items: {++ type: 'object', additionalProperties: false, required: ['key', 'status'],++ properties: { key: { enum: STEPS }, status: { enum: ['pending', 'running', 'done', 'skipped', 'failed'] }, detail: NULLABLE(300), started_at: { type: ['string', 'null'] }, finished_at: { type: ['string', 'null'] } },++ },++ },++ sources: { type: 'array', maxItems: 200, items: SOURCE },++ captures: { type: 'array', maxItems: 400, items: CAPTURE },++ analysis: { type: 'array', maxItems: 30, items: ANALYSIS },++ synthesis: {++ type: 'object', additionalProperties: false,++ required: ['patterns', 'comparison', 'best', 'mistakes', 'gap', 'reference_tokens', 'do', 'dont', 'accessibility', 'speed'],++ properties: {++ patterns: { type: 'array', maxItems: 40, items: { type: 'object', additionalProperties: false, required: ['name', 'what', 'sites', 'evidence'], properties: { name: STR(160), what: STR(1200), sites: { type: 'array', maxItems: 30, items: { type: 'string', maxLength: 40 } }, usage_count: { type: 'integer', minimum: 0, maximum: 30 }, evidence: EVIDENCE } } },++ comparison: { type: 'object', additionalProperties: false, required: ['columns', 'rows'], properties: { columns: { type: 'array', maxItems: 12, items: STR(120) }, rows: { type: 'array', maxItems: 30, items: { type: 'array', maxItems: 12, items: STR(1000) } } } },++ best: { type: 'object', additionalProperties: false, required: ['source_id', 'what', 'evidence'], properties: { source_id: STR(40), what: STR(2000), evidence: EVIDENCE } },++ mistakes: { type: 'array', maxItems: 20, items: { type: 'object', additionalProperties: false, required: ['what', 'evidence'], properties: { what: STR(1200), evidence: EVIDENCE } } },++ gap: STR(2000),++ reference_tokens: {++ type: 'object', additionalProperties: false, required: ['palettes', 'type_scales', 'spacing'],++ properties: {++ palettes: { type: 'array', maxItems: 30, items: { type: 'object', additionalProperties: false, required: ['source_id', 'colours'], properties: { source_id: STR(40), colours: { type: 'array', maxItems: 16, items: STR(40) } } } },++ type_scales: { type: 'array', maxItems: 30, items: { type: 'object', additionalProperties: false, required: ['source_id', 'families', 'sizes'], properties: { source_id: STR(40), families: { type: 'array', maxItems: 8, items: STR(120) }, sizes: { type: 'array', maxItems: 16, items: STR(40) } } } },++ spacing: { type: 'array', maxItems: 30, items: { type: 'object', additionalProperties: false, required: ['source_id', 'steps'], properties: { source_id: STR(40), steps: { type: 'array', maxItems: 16, items: STR(40) } } } },++ },++ },++ do: { type: 'array', maxItems: 20, items: STR(600) },++ dont: { type: 'array', maxItems: 20, items: STR(600) },++ accessibility: { type: 'array', maxItems: 20, items: STR(600) },++ speed: { type: 'array', maxItems: 20, items: STR(600) },++ },++ },++ recommendations: {++ type: 'array', maxItems: 20,++ items: {++ type: 'object', additionalProperties: false, required: ['id', 'what', 'why', 'rule', 'evidence'],++ properties: {++ id: STR(40), what: STR(1200), why: STR(2000),++ rule: { type: 'object', additionalProperties: false, required: ['status', 'text'], properties: { id: { type: ['string', 'null'], maxLength: 120 }, status: { enum: ['follows', 'would_change', 'none'] }, text: STR(600) } },++ evidence: EVIDENCE,++ },++ },++ },++ receipt: {++ type: 'object', additionalProperties: false, required: ['price_usd', 'held_usd', 'cost_usd', 'models', 'captures_count', 'synthetic'],++ properties: {++ price_usd: { type: 'number', minimum: 0 }, held_usd: { type: 'number', minimum: 0 }, cost_usd: { type: 'number', minimum: 0 },++ captures_count: { type: 'integer', minimum: 0 }, synthetic: { type: 'boolean' },++ models: {++ type: 'array', maxItems: 500,++ items: { type: 'object', additionalProperties: false, required: ['step', 'provider', 'model', 'tokens_in', 'tokens_out', 'cost_usd', 'ms'], properties: {++ step: { enum: ['plan', 'choose', 'analyse', 'synthesise'] }, provider: NULLABLE(80), model: NULLABLE(200),++ tokens_in: { type: 'integer', minimum: 0 }, tokens_out: { type: 'integer', minimum: 0 }, cost_usd: { type: 'number', minimum: 0 }, ms: { type: 'integer', minimum: 0 },++ } },++ },++ },++ },++ gaps: { type: 'array', maxItems: 40, items: STR(400) },++ created_at: STR(40), updated_at: STR(40), finished_at: { type: ['string', 'null'] },++ },++};++++const INPUT_SCHEMA = {++ type: 'object', additionalProperties: false, required: ['question', 'depth'],++ properties: {++ question: { type: 'string', minLength: 1, maxLength: 500 }, depth: { enum: DEPTHS },++ sites: { type: 'array', maxItems: 30, items: { type: 'string', maxLength: 2048, pattern: '^https?://' } },++ },++};++++module.exports = { DEPTHS, STEPS, EVIDENCE, REPORT, INPUT_SCHEMA, PLAN_OUTPUT, CHOOSE_OUTPUT, ANALYSIS_OUTPUT, SYNTHESIS_OUTPUT };nnew file mode 100644@@@ -0,0 +1,161 @@++'use strict';++/**++ * Every search a deep dive uses, gathered into one numbered source list (ai.deep-dive@1 sources).++ *++ * named the sites the person typed: read for their title, always candidates++ * MegaSource server/research/: Wikipedia, Wikidata, the Search Fabric or DeepSeek's web search, and licensed++ * pictures, all deduplicated and dated++ * AI search Exchange's own engines (server/search/): web, pictures, and icons for Expert++ * GitHub open-source projects and their demo/homepage links++ * rules OpenVibe.Design's rules register (GET https://openvibe.design/api/v1/rules), so the synthesis is++ * checked against what OpenVibe already decided++ * components OpenVibe.Design's component registry (GET /api/v1/components)++ *++ * Nothing here is a claim: a source is a page or a project that was read, with its URL and the date it was read.++ * A provider that fails is a warning and a gap, never an invented row.++ */++const { guardedFetch } = require('../providers/guarded-fetch');++const { publicTarget } = require('../search/transport');++++const ONE_LINE = (s, n) => String(s == null ? '' : s).replace(/\s+/g, ' ').trim().slice(0, n);++const HOST = (u) => { try { return new URL(u).hostname.replace(/^www\./, ''); } catch { return null; } };++const canon = (u) => { try { const x = new URL(String(u)); x.hash = ''; x.hostname = x.hostname.toLowerCase(); return x.href.replace(/\/$/, ''); } catch { return null; } };++const DESIGN_PUBLISHERS = /(smashingmagazine|nngroup|uxdesign|interactions\.acm|alistapart|awwwards|css-tricks|web\.dev|developer\.mozilla|designsystems|uxplanet|figma\.com\/blog|medium\.com|substack\.com|dribbble\.com\/stories|abduzeedo|itsnicethat|dezeen)/i;++const WORD = /[a-z0-9]{3,}/g;++const words = (s) => new Set(String(s || '').toLowerCase().match(WORD) || []);++++function createSourceGatherer({ config = {}, log = console, fetchImpl = guardedFetch, research = null, search = null, now = () => Date.now() } = {}) {++ const designUrl = String((config.deepDive && config.deepDive.designUrl) || 'https://openvibe.design').replace(/\/+$/, '');++ const timeoutMs = (config.deepDive && config.deepDive.rulesTimeoutMs) || 8000;++++ async function getJson(url, accept = 'application/json') {++ await publicTarget(url);++ const res = await fetchImpl(url, { redirect: 'error', signal: AbortSignal.timeout(timeoutMs), headers: { accept, 'user-agent': 'OpenVibeDeepDive/1.0 (+https://openvibe.exchange/deep-dive)' } });++ if (!res.ok) throw new Error(`${HOST(url)} answered ${res.status}`);++ return await res.json();++ }++++ async function titleOf(url) {++ try {++ const res = await fetchImpl(url, { redirect: 'error', signal: AbortSignal.timeout(6000), headers: { accept: 'text/html,application/xhtml+xml', 'user-agent': 'OpenVibeDeepDive/1.0 (+https://openvibe.exchange/deep-dive)' } });++ if (!res.ok) return { title: HOST(url) || url, snippet: null };++ const text = (await res.text()).slice(0, 250000);++ const title = (/<title[^>]*>([\s\S]*?)<\/title>/i.exec(text) || [])[1];++ const desc = (/<meta[^>]+name=["']description["'][^>]+content=["']([^"']+)/i.exec(text) || [])[1];++ return { title: ONE_LINE(title || HOST(url) || url, 300), snippet: desc ? ONE_LINE(desc, 600) : null };++ } catch { return { title: HOST(url) || url, snippet: null }; }++ }++++ async function rulesRegister(question) {++ try {++ const body = await getJson(`${designUrl}/api/v1/rules`);++ const list = Array.isArray(body.rules) ? body.rules : [];++ const q = words(question);++ const scored = list.map((r) => {++ const text = `${r.rule || ''} ${r.detail || ''} ${r.area || ''}`.toLowerCase();++ let score = 0;++ for (const w of q) if (text.includes(w)) score++;++ return { r, score };++ }).sort((a, b) => b.score - a.score);++ const picked = [];++ for (const { r, score } of scored) {++ if (picked.length >= 14) break;++ if (score || picked.length < 8) picked.push(r);++ }++ return { updated: body.updated || null, rules: picked, areas: body.areas || [] };++ } catch (err) { log.warn(`[deep-dive] rules register: ${String(err && err.message || err).slice(0, 160)}`); return { updated: null, rules: [], areas: [] }; }++ }++++ async function componentsRegister(question) {++ try {++ const body = await getJson(`${designUrl}/api/v1/components`);++ const list = Array.isArray(body.components) ? body.components : [];++ const q = words(question);++ const picked = list.filter((c) => {++ const text = `${c.name || ''} ${c.title || ''} ${c.summary || ''}`.toLowerCase();++ return [...q].some((w) => text.includes(w));++ }).slice(0, 6);++ return { version: body.version || null, components: picked.length ? picked : list.slice(0, 4) };++ } catch (err) { log.warn(`[deep-dive] component registry: ${String(err && err.message || err).slice(0, 160)}`); return { version: null, components: [] }; }++ }++++ async function github(question, limit = 5) {++ const q = encodeURIComponent(`${question} design`);++ const url = `https://api.github.com/search/repositories?q=${q}&sort=stars&order=desc&per_page=${Math.max(1, Math.min(10, limit))}`;++ try {++ await publicTarget(url);++ const res = await fetchImpl(url, { redirect: 'error', signal: AbortSignal.timeout(9000), headers: { accept: 'application/vnd.github+json', 'user-agent': 'OpenVibeDeepDive/1.0', 'x-github-api-version': '2022-11-28' } });++ if (!res.ok) return { projects: [], warning: `GitHub answered ${res.status}` };++ const body = await res.json();++ return { projects: (body.items || []).map((r) => ({ name: r.full_name, url: r.html_url, description: ONE_LINE(r.description, 600), stars: r.stargazers_count || 0, homepage: r.homepage ? String(r.homepage) : null, updated: r.pushed_at || null })) };++ } catch (err) { return { projects: [], warning: `GitHub: ${String(err && err.message || err).slice(0, 160)}` }; }++ }++++ /**++ * Gather. Returns { sources, rules, components, warnings }; every source has id src_N, kind, title, url,++ * publisher, engine, snippet, read_at. ctx.signal ends the joins early.++ */++ async function gather({ question, depth, namedSites = [], signal = null, maxSources = 40 }) {++ const at = new Date(now()).toISOString();++ const sources = [];++ const warnings = [];++ const seen = new Set();++ const add = (s) => {++ const url = canon(s.url);++ if (!url || seen.has(url) || sources.length >= maxSources) return null;++ seen.add(url);++ const id = `src_${sources.length + 1}`;++ sources.push({ id, kind: s.kind, title: ONE_LINE(s.title || HOST(url) || url, 400), url, publisher: s.publisher || HOST(url) || null, engine: s.engine || null, snippet: s.snippet ? ONE_LINE(s.snippet, 2000) : null, license: s.license ? ONE_LINE(s.license, 120) : null, read_at: s.read_at || at });++ return id;++ };++++ // The person's own sites first: they are always candidates, even if search never finds them.++ for (const raw of namedSites.slice(0, 30)) {++ const url = canon(raw);++ if (!url || !/^https?:/.test(url)) continue;++ const info = await titleOf(url);++ add({ kind: 'named', url, title: info.title, snippet: info.snippet });++ }++++ const mega = research && typeof research.gather === 'function'++ ? await research.gather({ query: `${question} design`, max_sources: Math.min(12, maxSources), max_images: depth === 'quick' ? 6 : 10 }, { signal, allowPaid: true }).catch((err) => { warnings.push(`research: ${String(err && err.message || err).slice(0, 160)}`); return null; })++ : null;++ if (mega) {++ for (const s of mega.sources || []) add({ kind: 'web', url: s.url, title: s.title, snippet: s.snippet || s.content, engine: s.provider || 'megasource', publisher: HOST(s.url), license: s.license });++ for (const p of mega.images || []) add({ kind: 'picture', url: p.source_url || p.url, title: p.title || p.description, snippet: p.author ? `by ${p.author}` : null, engine: p.provider || 'commons', license: p.license });++ for (const p of mega.providers ? Object.entries(mega.providers) : []) if (p[1] && p[1].ok === false) warnings.push(`${p[0]}: ${p[1].skipped || p[1].error || 'did not answer'}`);++ }++++ if (search && typeof search.search === 'function') {++ const calls = [['web', { q: question, limit: 8 }], ['images', { q: `${question} interface`, limit: 6 }]];++ if (depth === 'expert') calls.push(['icons', { q: question, limit: 6 }]);++ for (const [kind, body] of calls) {++ if (signal && signal.aborted) break;++ try {++ const page = await search.search(kind, body, {});++ const engine = page && page.receipt ? page.receipt.engine : 'search';++ for (const r of (page && page.results) || []) {++ add({ kind: kind === 'images' ? 'picture' : kind === 'icons' ? 'icon' : 'web', url: r.url, title: r.title, snippet: r.snippet || r.attribution || r.author, engine, publisher: HOST(r.url), license: r.license || null });++ }++ } catch (err) { warnings.push(`${kind} search: ${String(err && err.message || err).slice(0, 160)}`); }++ }++ }++++ const gh = await github(question, depth === 'quick' ? 3 : 6);++ if (gh.warning) warnings.push(gh.warning);++ for (const p of gh.projects) add({ kind: 'project', url: p.url, title: p.name, snippet: `${p.description || ''}${p.stars ? ` · ${p.stars} stars` : ''}${p.homepage ? ` · demo ${p.homepage}` : ''}`.slice(0, 900), engine: 'github', publisher: 'github.com' });++++ const rules = await rulesRegister(question);++ for (const r of rules.rules) add({ kind: 'rules', url: r.url || `${designUrl}/rules#${r.id}`, title: `Rule: ${ONE_LINE(r.rule, 180)}`, snippet: ONE_LINE(r.detail || r.why || '', 900), engine: 'openvibe.design', publisher: 'openvibe.design', read_at: at });++ const comps = await componentsRegister(question);++ for (const c of comps.components) add({ kind: 'component', url: `${designUrl}/components`, title: `Component: ${c.title || c.name}`, snippet: ONE_LINE(c.summary || (c.anatomy || []).map((a) => a.role).join('; '), 900), engine: 'openvibe.design', publisher: 'openvibe.design' });++++ // Design write-ups from the web results are the ones from the publications that write them.++ for (const s of sources) if (s.kind === 'web' && DESIGN_PUBLISHERS.test(s.url)) s.engine = s.engine || 'write-up';++ return { sources, rules: rules.rules, components: comps.components, warnings };++ }++++ return { gather, titleOf, rulesRegister, componentsRegister, github };++}++++module.exports = { createSourceGatherer, DESIGN_PUBLISHERS };nnew file mode 100644@@@ -0,0 +1,100 @@++'use strict';++/**++ * The deep dive's durable state and its 7-day capture cache (migrations/0079_deep_dives.sql).++ *++ * The run row is the record; this row is what the page reads while the run is still working: the steps and their++ * status, the candidates after Choosing, the decision that release them to Capture, the captures as they land and++ * the final report. Every write is small and immediate, so a deploy that interrupts a run leaves a page that says++ * what happened rather than one that spins. capture_cache is per URL, viewport and theme (and whether the Expert++ * extras were taken): the same page at the same size is never photographed twice inside seven days.++ */++const crypto = require('crypto');++++const iso = (v) => (v ? new Date(v).toISOString() : null);++const parse = (v, d) => { try { return v == null ? d : (typeof v === 'string' ? JSON.parse(v) : v); } catch { return d; } };++const key = (url, viewport, theme, extras) => crypto.createHash('sha256').update(`${url}|${viewport}|${theme}|${extras ? 1 : 0}`).digest('hex');++++function createDeepDiveStore({ db, clock = { now: () => Date.now() }, log = console } = {}) {++ const getRow = async (id) => await db.prepare('SELECT * FROM deep_dives WHERE id = ?').get(String(id));++++ function decode(r) {++ if (!r) return null;++ return {++ id: r.id, run_id: r.run_id, owner: r.owner_type ? { type: r.owner_type, id: r.owner_id } : null,++ status: r.status, step: r.step, progress: Number(r.progress) || 0,++ question: r.question, depth: r.depth, site_limit: r.site_limit, price_usd: Number(r.price_usd) || 0,++ steps: parse(r.steps, []), candidates: parse(r.candidates, []), decision_at: iso(r.decision_at),++ captures: parse(r.captures, []), report: parse(r.report, null), gaps: parse(r.gaps, []),++ error: r.error_code ? { code: r.error_code, detail: r.error_detail } : null,++ created_at: iso(r.created_at), updated_at: iso(r.updated_at), finished_at: iso(r.finished_at),++ };++ }++++ async function ensure({ runId, question, depth, siteLimit, priceUsd, owner = null }) {++ const now = iso(clock.now());++ await db.prepare(`INSERT INTO deep_dives (id, run_id, owner_type, owner_id, status, step, progress, question, depth, site_limit, price_usd, steps, created_at, updated_at)++ VALUES (?, ?, ?, ?, 'queued', 'plan', 0, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (id) DO NOTHING`)++ .run(runId, runId, owner && owner.type || null, owner && owner.id || null, String(question).slice(0, 500), depth, siteLimit, priceUsd, JSON.stringify([]), now, now);++ return decode(await getRow(runId));++ }++++ async function get(id) { return decode(await getRow(id)); }++++ async function patch(id, fields) {++ const cols = { status: 'status', step: 'step', progress: 'progress', steps: 'steps', candidates: 'candidates', decision_at: 'decision_at', captures: 'captures', report: 'report', gaps: 'gaps', error_code: 'error_code', error_detail: 'error_detail', finished_at: 'finished_at' };++ const sets = []; const args = [];++ for (const [k, v] of Object.entries(fields || {})) {++ if (!cols[k]) continue;++ sets.push(`${cols[k]} = ?`);++ args.push(v && typeof v === 'object' && !(v instanceof Date) ? JSON.stringify(v) : v);++ }++ if (!sets.length) return get(id);++ sets.push('updated_at = ?'); args.push(iso(clock.now()));++ await db.prepare(`UPDATE deep_dives SET ${sets.join(', ')} WHERE id = ?`).run(...args, String(id));++ return get(id);++ }++++ /** Set one step's status and the overall progress; returns the updated row. */++ async function setStep(id, key, status, { progress = null, detail = null, startedAt = null, finishedAt = null } = {}) {++ const row = await get(id);++ if (!row) return null;++ const steps = (row.steps || []).map((s) => ({ ...s }));++ if (!steps.length) for (const k of ['plan', 'search', 'choose', 'capture', 'analyse', 'synthesise', 'report']) steps.push({ key: k, status: 'pending', detail: null, started_at: null, finished_at: null });++ const s = steps.find((x) => x.key === key);++ if (s) {++ s.status = status;++ if (detail != null) s.detail = String(detail).slice(0, 300);++ if (startedAt) s.started_at = startedAt;++ if (finishedAt) s.finished_at = finishedAt;++ if (status === 'running' && !s.started_at) s.started_at = iso(clock.now());++ if ((status === 'done' || status === 'failed' || status === 'skipped') && !s.finished_at) s.finished_at = iso(clock.now());++ }++ return patch(id, { steps, ...(progress != null ? { progress } : {}), step: key });++ }++++ async function cacheGet(url, viewport, theme, extras) {++ const k = key(url, viewport, theme, extras);++ const row = await db.prepare('SELECT * FROM capture_cache WHERE key = ? AND expires_at > ?').get(k, iso(clock.now()));++ if (!row) return null;++ return { ...parse(row.result, null), cached: true, captured_at: iso(row.captured_at) };++ }++++ async function cachePut(url, viewport, theme, extras, result, days = 7) {++ if (!(Number(days) > 0) || !result || !result.media_id) return;++ const now = clock.now();++ const at = result.captured_at || iso(now);++ const expires = iso(now + Number(days) * 86400000);++ const k = key(url, viewport, theme, extras);++ await db.prepare(`INSERT INTO capture_cache (key, url, viewport, theme, extras, result, captured_at, expires_at)++ VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (key) DO UPDATE SET result = EXCLUDED.result, captured_at = EXCLUDED.captured_at, expires_at = EXCLUDED.expires_at`)++ .run(k, url, viewport, theme, extras ? 1 : 0, JSON.stringify(result), at, expires).catch((err) => log.warn(`[deep-dive] cache write failed: ${err.message}`));++ }++++ async function pruneCache() {++ await db.prepare('DELETE FROM capture_cache WHERE expires_at <= ?').run(iso(clock.now())).catch(() => {});++ }++++ return { ensure, get, patch, setStep, cacheGet, cachePut, pruneCache, key };++}++++module.exports = { createDeepDiveStore };@@@ -149,6 +149,33 @@ async function callRecorded(rec) {}++/**++ * ai.deep_dive.updated@1: a deep dive's step changed or it ended (server/deep-dive/). One low-priority event per++ * step transition and at the end, so OpenVibe.Exchange's page can follow a run and OpenVibe.Design can attach a++ * finished report without polling. The payload carries ids, the step, the progress and the status; never the++ * question's answer, a capture or a prompt. No-op while publishing is off.++ */++async function deepDiveUpdated({ runId, deepDiveId, status, step, progress, steps, depth, requester, on_behalf_of, url, error = null }) {++ if (!svc || !dbRef || !runId) return null;++ const payload = {++ deep_dive_id: String(deepDiveId || runId), run_id: String(runId),++ status: status === 'queued' ? 'queued' : ['running', 'choosing', 'succeeded', 'failed', 'cancelled'].includes(status) ? status : 'running',++ step: step === 'done' ? 'done' : (['plan', 'search', 'choose', 'capture', 'analyse', 'synthesise', 'report'].includes(step) ? step : null),++ progress: Math.max(0, Math.min(1, Number(progress) || 0)),++ steps: (steps || []).slice(0, 7).map((s) => ({ key: s.key, status: s.status })),++ depth: ['quick', 'thorough', 'expert'].includes(depth) ? depth : 'quick',++ requester: requester && requester.type && requester.id ? { type: requester.type, id: String(requester.id) } : { type: 'service', id: 'ai' },++ on_behalf_of: on_behalf_of && on_behalf_of.type && on_behalf_of.id ? { type: on_behalf_of.type, id: String(on_behalf_of.id) } : null,++ url: String(url || `https://openvibe.exchange/deep-dive/${runId}`),++ error: error && error.code ? { code: String(error.code).slice(0, 80), detail: String(error.detail || '').slice(0, 500) } : null,++ occurred_at: new Date().toISOString(),++ };++ const env = await svc.emit({ event_type: 'ai.deep_dive.updated', actor: { type: 'service', id: 'ai' }, subject: { type: 'run', id: payload.run_id }, visibility: 'internal', priority: status === 'failed' ? 'important' : 'low', payload });++ queued++;++ dbRef.afterCommit(() => svc && svc.kick());++ return env;++}++async function status() {if (!svc) return { enabled: false };const s = await svc.status();@@@ -167,6 +194,6 @@ async function stop() {}function _reset() { if (svc) svc.stop(); svc = null; queued = 0; }--module.exports = { init, runChanged, credentialFailed, callRecorded, callPayload, payloadOf, status, stop, _reset };++module.exports = { init, runChanged, credentialFailed, callRecorded, deepDiveUpdated, callPayload, payloadOf, status, stop, _reset };@@@ -90,7 +90,10 @@ function createTiers({ config, valkey = null, clock = { now: () => Date.now() },* Hold the run's estimate on every budget, or refuse it. Returns the hold ({ ids: { unit: [id] } }) to settle* or release, or null when an all-free run was admitted without Valkey.*/-- async function admit(ctx, runId) {++ async function admit(ctx, runId, amount = null) {++ const held = amount && typeof amount === 'object'++ ? { 'ai-token': Math.max(0, Math.min(50000000, Number(amount.tokens) || 0)), 'ai-usd': Math.max(0, Math.min(100, Number(amount.usd) || 0)) }++ : g.hold;const project = `${ctx.requesterType}:${ctx.requesterId}`;const subject = subjectOf(ctx) || project;const budgets = [{ scope: 'subject', subject, project, tier: tierOf(subject, project) }, { scope: 'project', subject: '*', project, tier: tierOf(project) }];@@@ -98,7 +101,7 @@ function createTiers({ config, valkey = null, clock = { now: () => Date.now() },try {for (const b of budgets) {for (const unit of UNITS) {-- const r = await timeout(gov.reserve({ subject: b.subject, project: b.project, tier: b.tier, unit, amount: g.hold[unit], key: `ai:${runId}:hold` }), g.timeoutMs);++ const r = await timeout(gov.reserve({ subject: b.subject, project: b.project, tier: b.tier, unit, amount: held[unit], key: `ai:${runId}:hold` }), g.timeoutMs);if (!r.ok) {await release(hold);throw new AiError(429, 'govern.exceeded', `the ${b.tier} tier allows ${r.limit} ${unit} per ${r.window} for this ${b.scope}`, {@@@ -32,7 +32,7 @@ const { AiError } = require('./util');const { gracefulStop, telemetry } = require('openvibe-sdk/service');const { createPgTelemetrySink, startTelemetryRetention } = require('openvibe-sdk/telemetry');--async function start({ searchFetch = null, config, db: givenDb = null, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, env = process.env, log = console, listen = true, credentialFetch = null, valkey = null, research = null, machineModels = null, variantEvidence = null, community: givenCommunity = null, communityEndpoints: givenEndpoints = null, webhooks: givenWebhooks = null, abuse: givenAbuse = null, memoryTransport = null, memoryLookup = null, ownerNotify = null, mediaStore: givenMediaStore = null } = {}) {++async function start({ searchFetch = null, config, db: givenDb = null, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, env = process.env, log = console, listen = true, credentialFetch = null, valkey = null, research = null, search: givenSearch = null, capture: givenCapture = null, deepDive: givenDeepDive = null, deepDiveFetch = null, machineModels = null, variantEvidence = null, community: givenCommunity = null, communityEndpoints: givenEndpoints = null, webhooks: givenWebhooks = null, abuse: givenAbuse = null, memoryTransport = null, memoryLookup = null, ownerNotify = null, mediaStore: givenMediaStore = null } = {}) {config = config || load(env);schemas.configure(config.schemaCache);// PostgreSQL (ADR-035): opened and migrated here unless the caller (a test) hands in a migrated handle.@@@ -114,7 +114,18 @@ async function start({ searchFetch = null, config, db: givenDb = null, clock = {// Pictures into Media for the image task (server/media-store.js): one tenant, the owner's namespace.const mediaStore = givenMediaStore || require('./media-store').createMediaStore({ config, env, fetchImpl, log });// MegaSource (server/research/): the DeepSeek key resolves by name from this environment; tests hand in their own.-- const engine = createEngine({ registry, pool, fetcher, quotas, config, log, db, clock, mediaStore, research: research || require('./research').createResearch({ config: config.research, env, log }) });++ const researcher = research || require('./research').createResearch({ config: config.research, env, log });++ // Exchange's search engines (server/search/): the deep dive's web, picture and icon searches. It shares the++ // same guarded fetch as the platform API; a test hands in its own.++ const search = givenSearch || require('./search').createSearch({++ ...(searchFetch ? { fetchImpl: searchFetch } : {}), now: () => clock.now(), registry, env,++ });++ // The capture worker: AI_CAPTURE_URL points at the openvibe-ai-capture unit; unset runs the same capture in++ // this process (tests and a small install). Screenshots land in Media private to the run's owner.++ const localCapture = givenCapture || require('./capture').createCapture({ config, env, log, upload: (o) => mediaStore.put({ ...o, visibility: 'private' }) });++ const captureClient = givenCapture || require('./capture/client').createCaptureClient({ config, env, fetchImpl, log, local: localCapture });++ const deepDive = givenDeepDive || require('./deep-dive').createDeepDive({ config, db, clock, log, registry, pool, research: researcher, search, capture: captureClient, mediaStore, fetcher, sourceFetch: deepDiveFetch });++ const engine = createEngine({ registry, pool, fetcher, quotas, config, log, db, clock, mediaStore, research: researcher, deepDive });// ai.run.* to OpenVibe.Events through the outbox (server/events.js); before recovery, so its failures are announced.require('./events').init(db, { log });// platform.usage-sample@1 readings to OpenVibe.Billing through their own outbox (server/usage-samples.js).@@@ -233,7 +244,7 @@ async function start({ searchFetch = null, config, db: givenDb = null, clock = {clientSecret: env.OV_OAUTH_CLIENT_SECRET, fetch: fetchImpl,}) : null;const benchEvents = createBenchEvents({ bench, accountData, accountSend, config, log, fetchImpl, clientSecret: env.OV_OAUTH_CLIENT_SECRET });-- const app = createApp({ choices, searchFetch, config, db, registry, pool, quotas, cache, runs, auth, keys, env, clock, fetchImpl, log, credentials, preferences, grants, vault, settings, benchEvents, runBudgets, memory, memoryImport, memoryServices, memorySharing, community, endpoints, webhooks, presets, receipts, balance, creditGrants, sessionCookies, gpus, gpuMarket, supply, abuse, priceBook, modelSync, modelDiscovery, ownEndpoints, refusal, willingness, abliterationTax, batches, share });++ const app = createApp({ choices, searchFetch, config, db, registry, pool, quotas, cache, runs, auth, keys, env, clock, fetchImpl, log, credentials, preferences, grants, vault, settings, benchEvents, runBudgets, memory, memoryImport, memoryServices, memorySharing, community, endpoints, webhooks, presets, receipts, balance, creditGrants, sessionCookies, gpus, gpuMarket, supply, abuse, priceBook, modelSync, modelDiscovery, ownEndpoints, refusal, willingness, abliterationTax, batches, share, deepDive, mediaStore });let jwks = null;let keyLoaded = Promise.resolve(null);if (!keys.pinned && config.nodeEnv !== 'test') {@@@ -93,7 +93,35 @@ function createMediaStore({ config, env = process.env, fetchImpl = globalThis.fe};}-- return { configured, publicUrl, put };++ /**++ * A private object's signed bytes URL: GET /api/v2/:app/objects/:id/download?format=json answers a short-lived++ * signed URL (the object stays 404 to everyone without it). AI reads the object as the run's owner when it has++ * one, and as its own tenant otherwise. The deep dive's screenshots are private, so this is how a vision model++ * reads them back and how Exchange's report shows a crop.++ */++ async function downloadUrl(mediaId, { owner = null, ttl = 300 } = {}) {++ if (!configured()) throw Object.assign(new Error('OpenVibe.Media is not configured'), { status: 503, code: 'media.unavailable' });++ if (!/^med_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(mediaId || ''))) throw Object.assign(new Error('not a Media object id'), { status: 422, code: 'media.invalid' });++ const auth = await tokenClient().authHeaders();++ const subject = owner && SUBJECT_RE.test(owner) ? { 'X-OV-Subject': owner } : {};++ const body = await call(`${baseUrl}/api/v2/${encodeURIComponent(app)}/objects/${encodeURIComponent(mediaId)}/download?format=json&ttl=${Math.max(30, Math.min(3600, Number(ttl) || 300))}`, {++ method: 'GET', headers: { ...auth, ...subject, Accept: 'application/json' },++ });++ if (!body.url) throw Object.assign(new Error('Media did not sign the object'), { status: 503, code: 'media.unavailable' });++ return body.url;++ }++++ /** The object's bytes (maxBytes cap): the bytes are private, the signed URL is the only way in. */++ async function bytes(mediaId, { owner = null, maxBytes = 20 * 1024 * 1024, signal = null } = {}) {++ const url = await downloadUrl(mediaId, { owner });++ const res = await fetchImpl(url, { signal: signal || AbortSignal.timeout(30000) });++ if (!res.ok) throw Object.assign(new Error(`Media bytes answered ${res.status}`), { status: 503, code: 'media.unavailable' });++ const buf = Buffer.from(await res.arrayBuffer());++ if (buf.length > maxBytes) throw Object.assign(new Error('the object is larger than the read cap'), { status: 413, code: 'media.too_large' });++ return { buffer: buf, mime: res.headers.get('content-type') || 'image/png' };++ }++++ return { configured, publicUrl, put, downloadUrl, bytes };}module.exports = { createMediaStore };@@@ -455,6 +455,67 @@ route('post', '/research', {responses: { 201: json('Finished', ref('RunResponse')), 202: json('Still running', ref('RunResponse')), 200: json('Replay', ref('RunResponse')), ...errors() },});++// ── Deep dives (workflow design.deep_dive; server/deep-dive/) ─────────────────────────────++const deepDive = { type: 'object', additionalProperties: true, required: ['id', 'run_id', 'status', 'depth'], properties: {++ id: str({ pattern: '^run_' }), run_id: str({ pattern: '^run_' }), status: str({ enum: ['queued', 'running', 'choosing', 'succeeded', 'failed', 'cancelled'] }),++ step: nullable('string'), progress: { type: 'number', minimum: 0, maximum: 1 }, steps: arrayOf({ type: 'object', additionalProperties: true }),++ question: str(), depth: str({ enum: ['quick', 'thorough', 'expert'] }), site_limit: integer(), price_usd: { type: 'number' },++ candidates: arrayOf({ type: 'object', additionalProperties: true }), captures: arrayOf({ type: 'object', additionalProperties: true }),++ report: nullable('object'), gaps: arrayOf(str()), error: nullable('object'), owner: nullable('object'),++} };++route('get', '/deep-dives/depths', {++ operationId: 'deepDiveDepths', summary: 'The three depths, their site counts, minutes and price', tags: ['deep-dives'], security: [],++ description: 'Public: what a deep dive costs before anyone signs in. The price is the run estimate held on the tier budgets, not a charge.',++ responses: { 200: json('The depths', { type: 'object', required: ['depths'], properties: { depths: arrayOf({ type: 'object', required: ['key', 'sites', 'price_usd'], properties: { key: str({ enum: ['quick', 'thorough', 'expert'] }), label: str(), sites: integer(), minutes: integer(), price_usd: { type: 'number' }, analysis: str() } }), capture: nullable('object') } }), ...errors([429]) },++});++route('post', '/deep-dives', {++ operationId: 'startDeepDive', summary: 'Start a deep dive (workflow design.deep_dive)', tags: ['deep-dives'], security: SERVICE,++ description: 'ai.run.create. The price is computed from the depth and held; a person is on_behalf_of. The run makes its progress on GET /deep-dives/{id}/state and writes ai.deep-dive@1.',++ requestBody: body({ type: 'object', required: ['question', 'depth'], properties: {++ question: str({ minLength: 1, maxLength: 500 }), depth: str({ enum: ['quick', 'thorough', 'expert'] }),++ sites: arrayOf(str({ format: 'uri' })), on_behalf_of: ref('SubjectRef'), idempotency_key: str({ minLength: 8, maxLength: 128 }),++ target: { type: 'object' }, attribution: { type: 'object' },++ } }),++ responses: { 202: json('Accepted', { type: 'object', required: ['deep_dive'], properties: { deep_dive: deepDive, price_usd: { type: 'number' }, held_usd: { type: 'number' } } }), ...errors() },++});++route('get', '/deep-dives', {++ operationId: 'listDeepDives', summary: "The caller's deep dives, newest first", tags: ['deep-dives'], security: SERVICE,++ description: 'ai.run.read (or ai.run.create). Each item is the deep dive state, with its report when it has one.',++ parameters: [query('limit', integer({ minimum: 1, maximum: 100 }))],++ responses: { 200: json('The deep dives', { type: 'object', required: ['deep_dives'], properties: { deep_dives: arrayOf(deepDive) } }), ...errors() },++});++route('get', '/deep-dives/{id}', {++ operationId: 'getDeepDive', summary: 'A deep dive: its state and, when it ended, the ai.deep-dive@1 report', tags: ['deep-dives'], security: SERVICE,++ description: 'ai.run.read (or ai.run.create). Only the requester or the person on_behalf_of may read it; anyone else gets 404.',++ parameters: [path('id', str({ pattern: '^run_' }))],++ responses: { 200: json('The deep dive', { type: 'object', required: ['deep_dive'], properties: { deep_dive: deepDive } }), ...errors() },++});++route('get', '/deep-dives/{id}/state', {++ operationId: 'getDeepDiveState', summary: 'Live step progress, candidates and status', tags: ['deep-dives'], security: SERVICE,++ description: 'ai.run.read (or ai.run.create). Small enough to poll: steps, candidates, captures (ids only) and the error.',++ parameters: [path('id', str({ pattern: '^run_' }))],++ responses: { 200: json('The state', { type: 'object', required: ['deep_dive'], properties: { deep_dive: deepDive } }), ...errors() },++});++route('post', '/deep-dives/{id}/sites', {++ operationId: 'setDeepDiveSites', summary: 'Add or drop sites before Capture, or start it', tags: ['deep-dives'], security: SERVICE,++ description: 'ai.run.create. Allowed while the deep dive is choosing sites (Quick goes on by itself). Any call records the person\'s decision, so Capture starts.',++ parameters: [path('id', str({ pattern: '^run_' }))],++ requestBody: body({ type: 'object', properties: { add: arrayOf(str({ format: 'uri' })), drop: arrayOf(str()), start: { type: 'boolean' } } }),++ responses: { 200: json('Updated', { type: 'object', required: ['deep_dive'], properties: { deep_dive: deepDive } }), ...errors() },++});++route('post', '/deep-dives/{id}/cancel', {++ operationId: 'cancelDeepDive', summary: 'Cancel a deep dive; no later capture or model call starts', tags: ['deep-dives'], security: SERVICE,++ description: 'ai.run.create. Cancels the underlying run.',++ parameters: [path('id', str({ pattern: '^run_' }))],++ responses: { 200: json('Cancelled', { type: 'object', required: ['deep_dive'], properties: { deep_dive: deepDive } }), ...errors() },++});++route('get', '/deep-dives/{id}/shots/{shot}', {++ operationId: 'getDeepDiveShot', summary: 'One private screenshot or crop, as the owner', tags: ['deep-dives'], security: SERVICE,++ description: 'ai.run.read (or ai.run.create). The bytes come from Media; the response is private and no-store. 503 when Media is not configured on this service.',++ parameters: [path('id', str({ pattern: '^run_' })), path('shot', str({ pattern: '^(cap|crop)_' }))],++ responses: { 200: { description: 'The image', content: { 'image/png': { schema: { type: 'string', format: 'binary' } } } }, ...errors([404, 503]) },++});++// ── The budget of a run token (Codes C3) ────────────────────────────────────────────────────route('put', '/run-budgets/{jti}', {operationId: 'putRunBudget', summary: "Register the budget of a run token", tags: ['runs'], security: SERVICE,@@@ -361,7 +361,7 @@ function createRegistry(db, { clock = { now: () => Date.now() }, env = process.eschemas.assertSchema(w.output_schema, 'output_schema');if (!Array.isArray(w.steps) || !w.steps.length) throw new AiError(422, 'ai.invalid', 'workflow needs at least one step');for (const s of w.steps) {-- if (!s || !['llm', 'passthrough', 'complete', 'messages', 'transcribe', 'embed', 'image', 'speak', 'picture', 'media_analysis', 'research', 'devblog'].includes(s.kind)) throw new AiError(422, 'ai.invalid', 'step kind must be llm, passthrough, complete, messages, transcribe, embed, image, speak, picture, media_analysis or research');++ if (!s || !['llm', 'passthrough', 'complete', 'messages', 'transcribe', 'embed', 'image', 'speak', 'picture', 'media_analysis', 'research', 'devblog', 'deep_dive'].includes(s.kind)) throw new AiError(422, 'ai.invalid', 'step kind must be llm, passthrough, complete, messages, transcribe, embed, image, speak, picture, media_analysis, research or deep_dive');if ((s.kind === 'llm' || (s.kind === 'media_analysis' && s.template)) && !(s.template && await latestAny('templates', s.template))) throw new AiError(422, 'ai.invalid', `step references unknown template ${s.template}`);}if (!['none', 'private', 'service'].includes(w.cache_mode)) throw new AiError(422, 'ai.invalid', 'cache_mode must be none, private or service');@@@ -619,7 +619,11 @@ function createRuns({ choices = null, db, registry, engine, cache, quotas, confi// The tier budgets (server/govern.js, AI_GOVERN_TIERS) hold first, so a run they refuse is not counted by reserve().// L36 Y1: a run that may use the person's own key holds neither — their key is free to them, and a $0 balance// anywhere must never block it. If it falls back to the network, the usage is recorded after the run.-- const hold = credentialSubject !== null || projectProvider || selfKeys || !tiers ? null : await tiers.admit(ctx, id);++ // A deep dive's price is held as the run's estimate (the route computed it from the depth, never the caller).++ const holdAmount = wf.key === 'design.deep_dive' && body.options && body.options.hold && typeof body.options.hold === 'object'++ ? { usd: Math.max(0, Math.min(100, Number(body.options.hold.usd) || 0)), tokens: Math.max(0, Math.min(50000000, Number(body.options.hold.tokens) || 0)) }++ : null;++ const hold = credentialSubject !== null || projectProvider || selfKeys || !tiers ? null : await tiers.admit(ctx, id, holdAmount);let reserved;try {// BA2/BA3: a batch run reserves its quota when the answer is here, not while it waits.nnew file mode 100644@@@ -0,0 +1,28 @@++'use strict';++/**++ * Deep dive (the owner's 11-11 design research): expert research over every search we have, screenshots of the++ * best sites at phone and computer sizes, a vision and a text model reading them, and an ai.deep-dive@1 report++ * that OpenVibe.Exchange shows and OpenVibe.Design can attach. The whole run is one step: server/deep-dive/++ * writes its own seven-step progress, so the page sees Plan, Search, Choose, Capture, Analyse, Synthesise and++ * Report as they happen, and a person can add or drop sites before Capture.++ *++ * No cache: a deep dive is expensive, its screenshots are private to its owner, and its sources change daily.++ * The price shown before the run is held on the tier budgets by POST /api/v1/deep-dives; the real cost is on the++ * run's receipt (and in the report's receipt models).++ */++const { INPUT_SCHEMA, REPORT } = require('../deep-dive/schema');++++const workflows = [++ {++ key: 'design.deep_dive', name: 'Deep dive: expert design research', namespace: 'design',++ description: 'Search every source we have, capture the best sites at 390 and 1280 px in light and dark, read them with a vision and a text model, and write an ai.deep-dive@1 report with its evidence and receipts.',++ input_schema: INPUT_SCHEMA,++ output_schema: REPORT,++ steps: [{ kind: 'deep_dive' }],++ default_route: null,++ cache_mode: 'none', cache_ttl_sec: 0,++ metadata: { seed: true, owner: 'exchange', retain_input: true },++ },++];++++module.exports = { templates: [], workflows };@@@ -166,7 +166,7 @@ function batchExec(b, result) {};}--function createEngine({ registry, pool, fetcher, quotas = null, config = {}, spawnImpl, log = console, research = null, db = null, clock = { now: () => Date.now() }, mediaStore = null }) {++function createEngine({ registry, pool, fetcher, quotas = null, config = {}, spawnImpl, log = console, research = null, db = null, clock = { now: () => Date.now() }, mediaStore = null, deepDive = null }) {pool = require('../effort/pool').wrapPool(pool);// Turn attachments use only the host's local speech recognizer. Reading an audio file must never// select a paid transcription provider as a side effect of a chat request.@@@ -626,6 +626,22 @@ function createEngine({ registry, pool, fetcher, quotas = null, config = {}, spareturn { output, exec };}++ /**++ * deep_dive: one durable run (server/deep-dive/) with its own seven-step progress, captures and report. It++ * reads the run's owner and context, makes its own provider calls on the run's receipt and returns the++ * ai.deep-dive@1 document; the run's usage and cost are the sum of those calls.++ */++ async function deepDiveStep(step, input, wf, run, ctx) {++ if (!deepDive || typeof deepDive.run !== 'function') throw new AiError(503, 'deep_dive.unavailable', 'the deep dive is not configured on this service');++ const r = await deepDive.run({ run, input, ctx });++ const exec = {++ provider: r.provider || (r.models && r.models.length ? 'mixed' : null), model: r.model || (r.models && r.models.length ? 'deep-dive' : null),++ usage: r.usage || { input: 0, output: 0, cached: 0 }, cost: Number(r.cost) || 0,++ synthetic: Boolean(r.synthetic), fallbackUsed: false, explain: null,++ };++ return { output: r.output, exec };++ }++/** A research run's grounding: what it read is the output itself; the gaps are the sources that did not answer. */function researchGrounding(output) {const gaps = Object.entries((output && output.providers) || {}).filter(([, p]) => p && !p.ok)@@@ -647,7 +663,7 @@ function createEngine({ registry, pool, fetcher, quotas = null, config = {}, spareturn { output, exec: { ...last.exec, usage, cost, savedUsd }, route: last.route };}-- const STEP = { devblog: devblogStep, llm: llmStep, passthrough: passthroughStep, complete: completeStep, messages: messagesStep, transcribe: transcribeStep, embed: embedStep, image: imageStep, speak: speakStep, picture: pictures.step, media_analysis: mediaAnalysis.step, research: researchStep };++ const STEP = { deep_dive: deepDiveStep, devblog: devblogStep, llm: llmStep, passthrough: passthroughStep, complete: completeStep, messages: messagesStep, transcribe: transcribeStep, embed: embedStep, image: imageStep, speak: speakStep, picture: pictures.step, media_analysis: mediaAnalysis.step, research: researchStep };/*** Run a workflow. ctx: { signal, logRequest, debugRaw, inputHash }@@@ -22,6 +22,7 @@ const products = require('./products');const devblog = require('./devblog');const chat = require('./chat');const research = require('./research');++const deepDive = require('./deep-dive');const meme = require('./meme');const changelog = require('./changelog');const pictures = require('./pictures');@@@ -321,7 +322,7 @@ async function seed({ registry, quotas, config, env = process.env, db }) {const { key, ...def } = t;await registry.seedVersioned('template', key, { description: null, default_route: null, owner: 'ai', visibility: 'internal', metadata: {}, ...def });}-- for (const w of [...live.workflows, ...core.workflows, ...products.workflows, ...devblog.workflows, ...media.workflows, ...chat.workflows, ...space.workflows, ...research.workflows, ...meme.workflows, ...changelog.workflows, ...pictures.workflows, ...uiLayout.workflows]) {++ for (const w of [...live.workflows, ...core.workflows, ...products.workflows, ...devblog.workflows, ...media.workflows, ...chat.workflows, ...space.workflows, ...research.workflows, ...deepDive.workflows, ...meme.workflows, ...changelog.workflows, ...pictures.workflows, ...uiLayout.workflows]) {const { key, ...def } = w;await registry.seedVersioned('workflow', key, { description: null, default_route: null, cache_ttl_sec: null, metadata: {}, ...def });}nnew file mode 100644@@@ -0,0 +1,126 @@++'use strict';++// The capture worker, on a local fixture site only: robots.txt, the DOM facts, both viewports, dark where the++// site answers prefers-color-scheme, a full page after a slow scroll, Expert extras (axe-core, LCP/CLS/INP, a++// menu and a frame strip), the 45 s page budget's shape, and a disallowed page skipped. No outside site is read.++// The browser tests need /usr/bin/google-chrome; without it the file says so and checks the rules that need no browser.++const assert = require('assert');++const fs = require('fs');++const http = require('http');++const { createCapture } = require('../server/capture');++const { createRobots, parse, allowedBy } = require('../server/capture/robots');++++const CHROME = '/usr/bin/google-chrome';++const chrome = fs.existsSync(CHROME);++++const FIXTURE = `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Fixture control panel</title>++<meta name="description" content="A phone-first robot control panel fixture."><meta name="viewport" content="width=device-width, initial-scale=1">++<style>++:root{color-scheme: light dark}*{box-sizing:border-box}++body{font-family:Inter,system-ui,sans-serif;background:#ffffff;color:#111111;margin:0;padding:16px;line-height:1.5}++header{position:sticky;top:0;background:#ffffff;padding:8px 0}nav a{color:#111111;margin-right:12px;font-size:14px}++h1{font-size:28px;line-height:1.2}h2{font-size:20px}.card{background:#f4f4f5;border-radius:12px;padding:16px;margin:12px 0;box-shadow:0 1px 2px rgba(0,0,0,.2)}++button{font-size:16px;padding:12px 16px;border-radius:8px;background:#f5a623;border:0}++@media (prefers-color-scheme: dark){body{background:#0c0e12;color:#eeeeee}header{background:#0c0e12}.card{background:#1b1d22}}++</style></head><body>++<header><nav aria-label="Main"><a href="#home">Home</a><a href="#robots">Robots</a><button aria-label="Menu" aria-expanded="false">Menu</button></nav></header>++<main><h1>Control your robot</h1><p>One thumb reaches every control.</p>++<div class="card"><h2>Camera</h2><p>Live view</p><button>Start camera</button></div>++<section class="card"><h2>Drive</h2><p>Forward, back, and turn.</p><button>Forward</button> <button>Stop</button></section>++${Array.from({ length: 8 }, (_, i) => `<section class="card"><h2>Section ${i + 1}</h2><p>${'Robots move slowly and carefully. '.repeat(18)}</p></section>`).join('')}++</main></body></html>`;++++function fixtureServer({ robots = 'User-agent: *\nAllow: /\n' } = {}) {++ const s = http.createServer((req, res) => {++ if (req.url === '/robots.txt') { res.writeHead(200, { 'content-type': 'text/plain' }); return res.end(robots); }++ if (req.url === '/secret') { res.writeHead(200, { 'content-type': 'text/html' }); return res.end('<html><body><h1>Secret</h1></body></html>'); }++ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });++ res.end(FIXTURE);++ });++ return new Promise((resolve) => s.listen(0, '127.0.0.1', () => resolve({ server: s, port: s.address().port, close: () => new Promise((r) => { s.closeAllConnections?.(); s.close(() => r()); }) })));++}++++(async () => {++ // ── robots.txt: groups, longest match and wildcards, no browser needed ──++ const groups = parse('User-agent: *\nDisallow: /private\nAllow: /private/ok\n');++ assert.strictEqual(allowedBy(groups, '/public'), true);++ assert.strictEqual(allowedBy(groups, '/private/x'), false);++ assert.strictEqual(allowedBy(groups, '/private/ok/page'), true);++ // A group that names our user agent replaces the * group for us.++ const own = parse('User-agent: *\nAllow: /\n\nUser-agent: OpenVibeDeepDive\nDisallow: /\n');++ assert.strictEqual(allowedBy(own, '/anything'), false);++ assert.strictEqual(allowedBy(own, '/anything', 'SomeOtherBot'), true);++ const wild = parse('User-agent: *\nDisallow: /*.pdf$\nDisallow: /tmp*\n');++ assert.strictEqual(allowedBy(wild, '/a/b.pdf'), false);++ assert.strictEqual(allowedBy(wild, '/a/b.pdf?x=1'), true);++ assert.strictEqual(allowedBy(wild, '/tmp/x'), false);++ assert.strictEqual(allowedBy([], '/'), true);++ const robot = createRobots({ fetchImpl: async () => ({ ok: true, status: 200, text: async () => 'User-agent: *\nDisallow: /no\n' }) });++ assert.strictEqual((await robot.check('https://example.org/no/page')).allowed, false);++ assert.strictEqual((await robot.check('https://example.org/yes')).allowed, true);++ process.stdout.write('capture: robots.txt rules ok\n');++++ if (!chrome) { process.stdout.write('capture: skipped (no /usr/bin/google-chrome; the browser checks need it)\n'); return; }++++ const site = await fixtureServer();++ const deny = await fixtureServer({ robots: 'User-agent: *\nDisallow: /secret\n' });++ const config = require('../server/config').load({ NODE_ENV: 'test', PORT: '0', AI_DEEP_DIVE: '1' });++ config.capture.pageBudgetMs = 30000;++ const uploads = [];++ let n = 0;++ const upload = async ({ bytes, mime, filename, metadata }) => {++ assert.strictEqual(mime, 'image/png');++ assert.ok(Buffer.isBuffer(bytes) && bytes.length > 1000);++ uploads.push({ filename, metadata });++ n += 1;++ const tail = '01JAB2C3D4E5F6G7H8J9K0MNPQ'.slice(0, 24);++ return { media_id: `med_${tail}${String(n).padStart(2, '0')}`, width: 390, height: 844 };++ };++ try {++ const capture = createCapture({ config, log: { log() {}, warn() {} }, upload, allowLocal: true, robots: { check: async (url) => (url.includes(`:${deny.port}/secret`) ? { allowed: false, reason: 'robots.txt disallows this page' } : { allowed: true, reason: null }) } });++ const started = Date.now();++ const got = await capture.captureSite({ url: `http://127.0.0.1:${site.port}/`, runId: 'run_01JAB2C3D4E5F6G7H8J9K0MNPQ', owner: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ', extras: true });++ const ms = Date.now() - started;++ assert.strictEqual(got.skipped, null);++ assert.ok(got.combos.length >= 2, `both viewports were captured (got ${got.combos.length})`);++ assert.ok(got.combos.some((c) => c.viewport === 390 && c.theme === 'light'));++ assert.ok(got.combos.some((c) => c.viewport === 1280 && c.theme === 'light'));++ // The fixture answers prefers-color-scheme: the dark pair is captured too.++ assert.ok(got.combos.some((c) => c.theme === 'dark'), 'the dark theme was captured');++ for (const c of got.combos) {++ assert.ok(c.height > 900, `the page is taller than a screen (${c.height})`);++ assert.ok(c.crops.length >= 1, 'there is a crop for the point');++ assert.ok(c.crops.every((x) => /^med_[0-9A-HJKMNP-TV-Z]{26}$/.test(x.media_id)));++ assert.ok(c.dom, 'the DOM facts are recorded');++ }++ const light = got.combos.find((c) => c.viewport === 1280 && c.theme === 'light');++ assert.ok(light.dom.fonts.families.some((f) => /Inter/i.test(f.value)), 'the type family is measured');++ assert.ok(light.dom.fonts.sizes.some((s) => s.value === '28px'), 'the h1 size is measured');++ assert.ok(light.dom.palette.backgrounds.some((p) => p.value === '#ffffff'), 'the palette carries the page background');++ assert.ok(light.dom.spacing.length > 0 && light.dom.radii.some((r) => r.value === '12px'), 'spacing and radii are measured');++ assert.ok(light.dom.shadows.some((s) => /rgba?\(/.test(s.value)), 'the shadow is measured');++ assert.ok(light.dom.outline.some((h) => h.level === 1 && /Control your robot/.test(h.text)), 'the heading outline is recorded');++ assert.ok(light.dom.buttons.some((b) => /Start camera/.test(b.text)), 'the button words are recorded');++ assert.ok(light.dom.navigation.sticky === true, 'the sticky navigation is recorded');++ assert.ok(light.dom.components.images >= 0 && light.dom.grid.page_height > 0, 'the component inventory and grid are recorded');++ // Expert extras: axe-core findings, measured Web Vitals and a menu open.++ assert.ok(light.extras, 'Expert extras are present on the 1280 light capture');++ assert.ok(Array.isArray(light.extras.a11y.violations), 'axe-core answered with violations');++ assert.ok(Number.isFinite(light.extras.metrics.lcp_ms), 'LCP was measured');++ assert.ok(Number.isFinite(light.extras.metrics.cls), 'CLS was measured');++ assert.ok(light.extras.menus.length >= 1 && light.extras.menus[0].after, 'the menu was opened and photographed');++ assert.ok(light.extras.frames.length >= 2, 'a frame strip was taken');++ assert.ok(uploads.length >= got.combos.length, 'every shot went to Media');++ for (const u of uploads) assert.deepStrictEqual(Object.keys(u.metadata).includes('source_url'), true, 'each shot keeps its source URL');++ assert.ok(ms < 120000, `the fixture capture fits its budget (${ms} ms)`);++ process.stdout.write(`capture: fixture site ok (${got.combos.length} captures, ${uploads.length} shots, ${ms} ms)\n`);++++ // robots.txt disallowed: no browser and a reason the report can show.++ const denied = await capture.captureSite({ url: `http://127.0.0.1:${deny.port}/secret`, runId: 'run_01JAB2C3D4E5F6G7H8J9K0MNPQ', owner: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ', extras: false });++ assert.strictEqual(denied.skipped, 'robots_disallowed');++ assert.match(denied.reason, /robots\.txt/);++ process.stdout.write('capture: a robots-disallowed page is skipped and says so\n');++ } finally {++ await site.close();++ await deny.close();++ }++})().catch((e) => { process.stderr.write(String(e && e.stack || e) + '\n'); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,200 @@++'use strict';++// Deep dives end to end: the price before the run, the Exchange service allowance, the seven steps with live++// progress, choosing sites (add, drop, start), the report (ai.deep-dive@1) with its captures and receipt, cancel++// stopping the spend, and a caller that may not read someone else's run. Search, research, capture and the Design++// registers are fakes; no test reaches the internet and no outside site is read.++const assert = require('assert');++const { boot, request, token } = require('./helpers');++++const ALPHA = '01JAB2C3D4E5F6G7H8J9K0MNPQ';++const USER = `usr_${ALPHA}`;++let media = 0;++const med = () => { media += 1; return `med_${ALPHA.slice(0, 24)}${String(media).padStart(2, '0')}`; };++const json = (body) => ({ ok: true, status: 200, async json() { return body; }, async text() { return JSON.stringify(body); } });++const html = (title) => ({ ok: true, status: 200, async json() { throw new Error('not json'); }, async text() { return `<html><head><title>${title}</title><meta name="description" content="A design reference"></head><body><h1>${title}</h1></body></html>`; } });++++function fixtures() {++ const sourceFetch = async (url) => {++ const u = new URL(url);++ if (u.pathname === '/api/v1/rules') return json({ updated: '2026-11-11', areas: [{ id: 'phone', title: 'Phones first' }], rules: [++ { id: 'tap-targets', area: 'phone', rule: 'Controls are big enough for a thumb.', detail: 'At least 44 px.', url: 'https://openvibe.design/rules#tap-targets' },++ { id: 'honest-empty', area: 'honest', rule: 'An empty page says it is empty.', detail: 'No fake rows.', url: 'https://openvibe.design/rules#honest-empty' },++ ] });++ if (u.pathname === '/api/v1/components') return json({ version: 1, components: [{ name: 'action', title: 'Action', summary: 'A button that posts.' }] });++ if (u.hostname === 'api.github.com') return json({ items: [{ full_name: 'acme/robot-ui', html_url: 'https://github.com/acme/robot-ui', description: 'A phone-first robot panel', stargazers_count: 120, homepage: 'https://robot.example', pushed_at: '2026-10-01T00:00:00Z' }] });++ return html(`Reference ${u.pathname}`);++ };++ const research = { async gather() { return {++ sources: [++ { url: 'https://example.org/robot-panel', title: 'A phone-first robot control panel', snippet: 'One thumb reaches every control.', provider: 'search_fabric', license: null },++ { url: 'https://example.org/dense-dashboard', title: 'A dense operations dashboard', snippet: 'Every robot in one screen.', provider: 'search_fabric' },++ ],++ images: [{ url: 'https://example.org/pic.jpg', source_url: 'https://example.org/pic', title: 'Panel reference', provider: 'openverse', license: 'CC0', author: 'A' }],++ facts: [], providers: { search_fabric: { ok: true, count: 2 } },++ }; } };++ const search = { async search(kind) { return {++ results: kind === 'web'++ ? [{ title: 'A phone-first robot control panel', url: 'https://example.org/robot-panel', snippet: 'A design case study' }]++ : [{ title: 'Reference', url: 'https://example.org/img', snippet: 'x', license: 'CC0' }],++ receipt: { engine: 'openvibe', cost_usd: 0, time_ms: 5 },++ }; } };++ return { sourceFetch, research, search };++}++++function fakeCapture({ delayMs = 0, calls = [] } = {}) {++ let n = 0;++ return {++ calls,++ available: () => true,++ async captureSite({ url, extras, signal }) {++ calls.push({ url, at: Date.now() });++ if (delayMs) await new Promise((resolve) => { const t = setTimeout(resolve, delayMs); if (signal) signal.addEventListener('abort', () => { clearTimeout(t); resolve(); }, { once: true }); });++ if (signal && signal.aborted) throw Object.assign(new Error('cancelled'), { code: 'run.cancelled' });++ n += 1;++ const combos = [];++ for (const viewport of [390, 1280]) {++ const extrasData = extras && viewport === 1280++ ? { a11y: { violations: [{ id: 'button-name', impact: 'critical', help: 'Buttons must have an accessible name', nodes: 1 }], passes: 5 }, metrics: { lcp_ms: 1200, cls: 0.02, inp_ms: 90, long_tasks: 2 }, menus: [], frames: [] }++ : null;++ combos.push({++ viewport, theme: 'light', full_page: true, media_id: med(), width: viewport, height: 2000,++ crops: [{ id: `crop-${n}-${viewport}`, media_id: med(), y: 0, height: 844 }],++ title: 'Fixture', final_url: url,++ dom: { fonts: { families: [{ value: 'Inter', count: 10, share: 1 }], sizes: [{ value: '16px', count: 10, share: 1 }] }, palette: { text: [{ value: '#111111', count: 5 }], backgrounds: [{ value: '#ffffff', count: 5 }], borders: [] }, spacing: [{ value: '8px', count: 4 }], grid: { page_height: 2000 }, navigation: { sticky: true }, outline: [], buttons: [], links: [], components: {}, words: { h1: 'Robot' } },++ extras: extrasData, captured_at: new Date().toISOString(), cached: false,++ });++ }++ return { url, title: 'Fixture', final_url: url, combos, warnings: [], skipped: null, reason: null, ms: delayMs || 20 };++ },++ };++}++++function env(over = {}) {++ return { AI_DEEP_DIVE: '1', AI_DRILL: '0', AI_DESIGN_URL: 'https://design.invalid', AI_DEEP_DIVE_CHOOSE_WAIT_SEC: '60', AI_FREE_PROVIDERS: 'stub', AI_STUB_FALLBACK: 'true', ...over };++}++++async function waitFor(h, id, tok, wanted, tries = 120) {++ let last = null;++ for (let i = 0; i < tries; i++) {++ const r = await request(h.base, 'GET', `/api/v1/deep-dives/${id}/state`, { tok });++ last = r.body && r.body.deep_dive;++ if (last && wanted.includes(last.status)) return last;++ await new Promise((r2) => setTimeout(r2, 250));++ }++ return last;++}++++(async () => {++ // ── The price, before anyone signs in ──++ const f = fixtures();++ const capture = fakeCapture();++ const h = await boot({ env: env(), research: f.research, search: f.search, capture, deepDiveFetch: f.sourceFetch });++ try {++ let r = await request(h.base, 'GET', '/api/v1/deep-dives/depths');++ assert.strictEqual(r.status, 200);++ assert.deepStrictEqual(r.body.depths.map((d) => d.key), ['quick', 'thorough', 'expert']);++ assert.strictEqual(r.body.depths[0].price_usd, 0.2);++ assert.strictEqual(r.body.depths[0].sites, 6);++ assert.strictEqual(r.body.depths[2].sites, 30);++++ // Exchange holds only ai.complete on ai.run.create: design.deep_dive is the workflow its page starts.++ const exchange = token('exchange', ['ai.run.create'], { sub: 'svc:exchange', ns: ['ai.complete'] });++ r = await request(h.base, 'POST', '/api/v1/deep-dives', { tok: exchange, body: { question: 'a phone-first robot control panel', depth: 'quick', on_behalf_of: { type: 'user', id: USER } } });++ assert.strictEqual(r.status, 202);++ assert.ok(/^run_/.test(r.body.deep_dive.id));++ assert.strictEqual(r.body.price_usd, 0.2);++ assert.strictEqual(r.body.held_usd, 0.2);++ const id = r.body.deep_dive.id;++++ // A service that does not own the workflow is still refused by the namespace rule.++ const live = token('live', ['ai.run.create'], { sub: 'svc:live', ns: ['live.*'] });++ r = await request(h.base, 'POST', '/api/v1/deep-dives', { tok: live, body: { question: 'x', depth: 'quick' } });++ assert.strictEqual(r.status, 403);++++ const done = await waitFor(h, id, exchange, ['succeeded', 'failed', 'cancelled']);++ assert.strictEqual(done.status, 'succeeded', JSON.stringify(done.error));++ assert.strictEqual(done.progress, 1);++ assert.strictEqual(done.steps.length, 7);++ assert.ok(done.steps.every((s) => s.status === 'done'), 'every step ended done');++++ // The whole report: ai.deep-dive@1, its sources, captures, analysis, synthesis and receipt.++ r = await request(h.base, 'GET', `/api/v1/deep-dives/${id}`, { tok: exchange });++ assert.strictEqual(r.status, 200);++ const report = r.body.deep_dive.report;++ assert.ok(report, 'the report is readable');++ assert.strictEqual(report.schema, 'ai.deep-dive@1');++ assert.strictEqual(report.input.question, 'a phone-first robot control panel');++ assert.ok(report.sources.length >= 3, 'the sources are dated pages and projects');++ assert.ok(report.captures.length >= 2, 'the captures are there');++ assert.ok(report.captures.every((c) => /^med_/.test(c.media_id) || c.skip), 'a capture is a Media object or a skip');++ assert.ok(report.captures.every((c) => c.crops.every((x) => /^med_/.test(x.media_id))));++ assert.ok(report.analysis.length >= 1 && report.analysis[0].source_id);++ assert.ok(report.analysis[0].evidence.length >= 1 && report.analysis[0].evidence[0].read_at, 'every analysis point cites its evidence');++ assert.ok(report.synthesis.patterns.length >= 1 && report.synthesis.patterns[0].evidence.length >= 1);++ assert.ok(report.recommendations.length >= 1 && report.recommendations[0].evidence.length >= 1);++ assert.ok(report.receipt.models.length >= 4, 'the receipt lists every model call');++ for (const step of ['plan', 'choose', 'analyse', 'synthesise']) assert.ok(report.receipt.models.some((m) => m.step === step), `the receipt carries the ${step} call`);++ assert.ok(report.receipt.captures_count >= 2);++ assert.deepStrictEqual(report.gaps.length >= 0, true);++++ // The run's own output is the same document, and the run receipt has the captures beside the model calls.++ r = await request(h.base, 'GET', `/api/v1/runs/${id}`, { tok: exchange });++ assert.strictEqual(r.status, 200);++ assert.strictEqual(r.body.run.output.schema, 'ai.deep-dive@1');++ assert.ok(r.body.requests.some((x) => x.operation === 'capture'), 'the receipt lists captures');++++ // Nobody else may read it.++ const other = token('other', ['ai.run.create'], { sub: 'svc:other', ns: ['design.*'] });++ r = await request(h.base, 'GET', `/api/v1/deep-dives/${id}`, { tok: other });++ assert.strictEqual(r.status, 404);++ r = await request(h.base, 'GET', `/api/v1/deep-dives/${id}/shots/${report.captures[0].crops[0].id}`, { tok: exchange });++ assert.strictEqual(r.status, 503); // Media is not configured in this test; the owner check passed.++++ // Bad input is refused before a run exists.++ r = await request(h.base, 'POST', '/api/v1/deep-dives', { tok: exchange, body: { question: '', depth: 'quick' } });++ assert.strictEqual(r.status, 422);++ r = await request(h.base, 'POST', '/api/v1/deep-dives', { tok: exchange, body: { question: 'x', depth: 'forever' } });++ assert.strictEqual(r.status, 422);++ process.stdout.write('deep dive: price, run, report, receipt and owner checks ok\n');++++ // ── Choosing sites: Thorough waits, the person adds one and drops one, then Capture starts ──++ r = await request(h.base, 'POST', '/api/v1/deep-dives', { tok: exchange, body: { question: 'the pricing page of an AI API', depth: 'thorough', on_behalf_of: { type: 'user', id: USER } } });++ assert.strictEqual(r.status, 202);++ const thorough = r.body.deep_dive.id;++ const choosing = await waitFor(h, thorough, exchange, ['choosing', 'failed', 'cancelled']);++ assert.strictEqual(choosing.status, 'choosing', JSON.stringify(choosing.error));++ assert.ok(choosing.candidates.length >= 1, 'the candidates are listed');++ const drop = choosing.candidates[0].url;++ r = await request(h.base, 'POST', `/api/v1/deep-dives/${thorough}/sites`, { tok: exchange, body: { add: ['https://example.org/pricing-extra'], drop: [drop], start: true } });++ assert.strictEqual(r.status, 200);++ assert.ok(r.body.deep_dive.candidates.some((c) => c.url === 'https://example.org/pricing-extra'), 'the added site is a candidate');++ assert.ok(!r.body.deep_dive.candidates.some((c) => c.url === drop), 'the dropped site is gone');++ const after = await waitFor(h, thorough, exchange, ['succeeded', 'failed', 'cancelled']);++ assert.strictEqual(after.status, 'succeeded', JSON.stringify(after.error));++ process.stdout.write('deep dive: choosing sites, add/drop and start ok\n');++ } finally { await h.close(); }++++ // ── Cancel stops the spend: a slow capture is interrupted and no later site is called ──++ const f2 = fixtures();++ const slow = fakeCapture({ delayMs: 1500 });++ const h2 = await boot({ env: env(), research: f2.research, search: f2.search, capture: slow, deepDiveFetch: f2.sourceFetch });++ try {++ const exchange = token('exchange', ['ai.run.create'], { sub: 'svc:exchange', ns: ['ai.complete'] });++ const r = await request(h2.base, 'POST', '/api/v1/deep-dives', { tok: exchange, body: { question: 'onboarding for a crypto wallet', depth: 'quick', on_behalf_of: { type: 'user', id: USER } } });++ assert.strictEqual(r.status, 202);++ const id = r.body.deep_dive.id;++ const capturing = await waitFor(h2, id, exchange, ['running'], 40);++ assert.ok(capturing);++ // Wait until the first capture is under way, then cancel.++ for (let i = 0; i < 40 && !slow.calls.length; i++) await new Promise((r2) => setTimeout(r2, 100));++ assert.ok(slow.calls.length >= 1, 'a capture started');++ const c = await request(h2.base, 'POST', `/api/v1/runs/${id}/cancel`, { tok: exchange, body: {} });++ assert.ok([200, 409].includes(c.status));++ const ended = await waitFor(h2, id, exchange, ['cancelled', 'failed', 'succeeded']);++ assert.strictEqual(ended.status, 'cancelled', JSON.stringify(ended.error));++ const calls = slow.calls.length;++ await new Promise((r2) => setTimeout(r2, 1600));++ assert.strictEqual(slow.calls.length, calls, 'cancelling stopped every later capture');++ process.stdout.write('deep dive: cancel stops the next capture and model call\n');++ } finally { await h2.close(); }++})().catch((e) => { process.stderr.write(String(e && e.stack || e) + '\n'); process.exit(1); });@@@ -45,7 +45,7 @@ function tmpDir() {// A boot on a dir used before is a restart on the same database: its database stays open.const restartable = new Map();--async function boot({ searchFetch, env = {}, dir, clock, fetchImpl, credentialFetch, research, machineModels, log, variantEvidence, community, communityEndpoints, webhooks, memoryTransport, memoryLookup, ownerNotify, mediaStore } = {}) {++async function boot({ searchFetch, env = {}, dir, clock, fetchImpl, credentialFetch, research, search, capture, deepDive, deepDiveFetch, machineModels, log, variantEvidence, community, communityEndpoints, webhooks, memoryTransport, memoryLookup, ownerNotify, mediaStore } = {}) {const reuse = dir !== undefined;if (dir === undefined) dir = tmpDir();const fullEnv = {@@@ -62,7 +62,7 @@ async function boot({ searchFetch, env = {}, dir, clock, fetchImpl, credentialFe// An injected endpoint store needs the migrated database, so a factory is called with it before the app boots.const endpointStore = typeof communityEndpoints === 'function' ? communityEndpoints(testdb.db) : communityEndpoints;const webhookStore = typeof webhooks === 'function' ? webhooks(testdb.db) : webhooks;-- const h = await start({ searchFetch, config, db: testdb.db, env: fullEnv, log: log || silent, clock, fetchImpl, credentialFetch, research: researcher, machineModels, variantEvidence, community, communityEndpoints: endpointStore, webhooks: webhookStore, memoryTransport, memoryLookup, ownerNotify, mediaStore });++ const h = await start({ searchFetch, config, db: testdb.db, env: fullEnv, log: log || silent, clock, fetchImpl, credentialFetch, research: researcher, search, capture, deepDive, deepDiveFetch, machineModels, variantEvidence, community, communityEndpoints: endpointStore, webhooks: webhookStore, memoryTransport, memoryLookup, ownerNotify, mediaStore });const base = `http://127.0.0.1:${h.server.address().port}`;return { ...h, base, dir, env: fullEnv, testdb, stop: async () => { await h.close(); if (!reuse) await testdb.close(); } };}@@@ -48,6 +48,7 @@ const EXAMPLES = {'content.wiki': { title: 'SQLite', topic: 'SQLite', space: 'SQLite', query: 'SQLite', instruction: 'Write the article from its sources.', outline: ['History'], pages: ['SQLite'], sources: SRC(2) },'wiki.generate_page': { title: 'WAL mode', topic: 'SQLite', pages: ['SQLite', 'WAL mode', 'Journal'], images: [{ title: 'WAL diagram', description: 'How WAL writes' }], sources: SRC(3) },'research.gather': { query: 'SQLite', max_images: 4 },++ 'design.deep_dive': { question: 'a phone-first robot control panel', depth: 'quick' },'blog.draft_post': { topic: 'Why we self-host', sources: SRC(1) },'blog.write_digest': { desk: 'News digest', about: 'Government and space agency announcements', period: '14 October 2026', sources: SRC(3) },'blog.write_satire': { topic: 'Is it safe to let your air fryer vote?', angle: 'a consumer safety explainer' },@@@ -168,6 +169,8 @@ t.test('every workflow has compiling, versioned input and output schemas and ant.test('every workflow runs end to end on the stub and returns schema-valid output', async () => {for (const w of await h.registry.listWorkflows()) {if (w.key === 'media.analyze' && !HAS_FFMPEG) { console.log('workflow media.analyze end to end: skipped (no ffprobe)'); continue; }++ // A deep dive captures real sites: its own test brings a fake capture, search, research and registers.++ if (w.key === 'design.deep_dive') { console.log('workflow design.deep_dive end to end: covered by test/deep-dive.test.js (no live capture)'); continue; }// image.picture reads Commons/Openverse and writes Media: its own test brings a fake researcher and store.if (w.key === 'image.picture') { console.log('workflow image.picture end to end: covered by test/image-picture.test.js (no live fetch)'); continue; }const r = await request(h.base, 'POST', '/api/v1/runs?wait=10000', { tok, body: { workflow: w.key, input: EXAMPLES[w.key] } });
new file mode 100644
new file mode 100644
@@ -0,0 +1,39 @@
@@ -0,0 +1,39 @@
+[Unit]
+Description=OpenVibe.AI capture worker — headless Chrome for deep dives (one browser at a time, server/capture/)
+# The API calls this unit on 127.0.0.1:4710 (AI_CAPTURE_URL in openvibe-ai.service). It has its own unit so a page
+# that eats memory is killed by MemoryMax without taking the API down: the host has about 5 GB of free memory and
+# never starts a second browser. AI_DRILL/LIVE_DRILL turns it off in code (POST /capture answers 503 capture.drill).
+After=network.target
+Wants=network.target
+
+[Service]
+Type=simple
+User=ubuntu
+Group=ubuntu
+WorkingDirectory=/opt/openvibe.ai
+EnvironmentFile=/etc/openvibe/ai.env
+Environment=NODE_ENV=production
+Environment=AI_CAPTURE_HOST=127.0.0.1
+Environment=AI_CAPTURE_PORT=4710
+Environment=AI_CAPTURE_CHROME=/usr/bin/google-chrome
+Environment=OV_MEDIA_INTERNAL_URL=http://127.0.0.1:4100
+# Chrome writes its profile and its caches under HOME; the code tree stays read-only.
+Environment=HOME=/var/lib/openvibe-ai-capture
+Environment=PATH=/usr/local/bin:/usr/bin:/bin
+ExecStart=/usr/bin/env node /opt/openvibe.ai/server/capture/worker.js
+Restart=always
+RestartSec=5
+KillSignal=SIGTERM
+TimeoutStopSec=20
+NoNewPrivileges=true
+PrivateTmp=true
+ProtectSystem=strict
+ProtectHome=true
+StateDirectory=openvibe-ai-capture
+# The browser is the whole point of this process and the reason for the cap: one page budget at a time.
+MemoryMax=1500M
+# A hung browser is killed with the unit rather than left to grow.
+CPUQuota=200%
+
+[Install]
+WantedBy=multi-user.target
@@ -4,7 +4,7 @@ Description=OpenVibe.AI — providers, routing, versioned templates and workflow
@@ -4,7 +4,7 @@ Description=OpenVibe.AI — providers, routing, versioned templates and workflow
# it; the SDK's JWKS client fetches at boot and backs off and retries, and /api/ready stays 503 until
# it; the SDK's JWKS client fetches at boot and backs off and retries, and /api/ready stays 503 until
# the key has loaded (then the last good keys carry it through a Network outage).
# the key has loaded (then the last good keys carry it through a Network outage).
After=network.target postgresql.service pgbouncer.service valkey-server.service
After=network.target postgresql.service pgbouncer.service valkey-server.service
-Wants=openvibe-network.service postgresql.service pgbouncer.service valkey-server.service
+Wants=openvibe-network.service postgresql.service pgbouncer.service valkey-server.service openvibe-ai-capture.service
[Service]
[Service]
Type=simple
Type=simple
@@ -31,6 +31,10 @@ ProtectHome=read-only
@@ -31,6 +31,10 @@ ProtectHome=read-only
StateDirectory=openvibe-ai
StateDirectory=openvibe-ai
# media.analyze's work directory under StateDirectory (writable; the code tree is read-only).
# media.analyze's work directory under StateDirectory (writable; the code tree is read-only).
Environment=AI_MEDIA_ANALYSIS_DIR=/var/lib/openvibe-ai/media-tmp
Environment=AI_MEDIA_ANALYSIS_DIR=/var/lib/openvibe-ai/media-tmp
+# Deep dives' screenshots are taken by the capture worker (openvibe-ai-capture.service, 127.0.0.1:4710).
+Environment=AI_CAPTURE_URL=http://127.0.0.1:4710
+# Media's internal origin (its unit's port 4100): screenshots and the deep dive's private shots are Media objects.
+Environment=OV_MEDIA_INTERNAL_URL=http://127.0.0.1:4100
[Install]
[Install]
WantedBy=multi-user.target
WantedBy=multi-user.target
new file mode 100644
new file mode 100644
@@ -0,0 +1,79 @@
@@ -0,0 +1,79 @@
+# Deep dive
+
+The owner's 2026-11-11 design-research feature: one durable run that searches every source we have, captures the
+best sites at phone and computer sizes, reads them with a vision and a text model, and writes an `ai.deep-dive@1`
+report with its evidence and receipt. OpenVibe.Exchange shows it (`/deep-dive`); OpenVibe.Design calls the same API.
+
+## The run
+
+Workflow `design.deep_dive` (`server/workflows/deep-dive.js`), one `deep_dive` step that runs
+`server/deep-dive/`. The steps, live on `GET /api/v1/deep-dives/:id/state`:
+
+| step | what happens |
+| --- | --- |
+| `plan` | a model writes the expert questions and the searches that answer them |
+| `search` | MegaSource, AI's web/picture/icon engines, GitHub projects, Design's rules register and component registry |
+| `choose` | a model ranks candidate sites with a reason each; a person may add or drop any before Capture |
+| `capture` | 390/1280 px, light and dark where supported, full page after a slow scroll, DOM facts; Expert adds menus, hover/focus, a frame strip, axe-core and LCP/CLS/INP |
+| `analyse` | a text model reads each site's DOM facts and words; a vision model reads each full-page screenshot |
+| `synthesise` | patterns across sites, a comparison, the best version, mistakes, the gap, reference tokens, do/don't, accessibility, speed, and recommendations checked against Design's rules |
+| `report` | `ai.deep-dive@1` assembled from what was actually captured, validated, and stored with the run |
+
+Depths: Quick 6 sites (about 3 minutes), Thorough 15, Expert 30. Quick goes straight from Choose to Capture;
+Thorough and Expert wait `AI_DEEP_DIVE_CHOOSE_WAIT_SEC` (300) for `POST /api/v1/deep-dives/:id/sites`, or until the
+person starts it. Cancelling the run aborts the provider calls and the next capture; nothing new starts after.
+
+## API
+
+```
+GET /api/v1/deep-dives/depths public: key, sites, minutes and price_usd per depth
+POST /api/v1/deep-dives ai.run.create: { question, depth, sites?, on_behalf_of? } -> 202
+GET /api/v1/deep-dives ai.run.read: the caller's runs, newest first
+GET /api/v1/deep-dives/:id the state and, at the end, the ai.deep-dive@1 report
+GET /api/v1/deep-dives/:id/state just progress, steps, candidates, captures, gaps and error
+POST /api/v1/deep-dives/:id/sites { add: [https], drop: [https], start: true } while Choosing
+POST /api/v1/deep-dives/:id/cancel the runs cancel; no later capture or model call starts
+GET /api/v1/deep-dives/:id/shots/:shot one private screenshot/crop, as the owner (private, no-store)
+```
+
+The price is computed from the depth (never the body) and passed to `runs.create` as the run's estimate; with the
+tier budgets on it is held on `ai-usd`/`ai-token` (`tiers.admit`), and the run stops its own costly steps once its
+receipt reaches the price. The real numbers are on the receipt: `POST /api/v1/runs`'s run, `requests` on
+`GET /api/v1/runs/:id`, and the report's `receipt.models`. Every capture is a `requests` row (operation `capture`).
+
+OpenVibe.Exchange's long-standing Network grant holds `ai.run.create` on `ai.complete`; `design.deep_dive` is the
+workflow its own page starts, so AI's run namespace rule allows the `exchange` service this one workflow by key and
+service id (`server/api/runs.js`, `server/api/deep-dives.js`). Every other workflow keeps the namespace rule.
+
+Event: `ai.deep_dive.updated@1` (Contracts 0.412.0), one per step transition and one at the end. Subject
+`run:<id>`, internal, low priority (`important` on failure). Payload: ids, status, step, progress, the seven step
+statuses, depth, requester, on_behalf_of, the Exchange URL, an error and the time. Never the question's answer, a
+capture or a prompt. `server/events.js` `deepDiveUpdated`.
+
+## The capture worker
+
+`server/capture/`, its own systemd unit `openvibe-ai-capture.service` (`MemoryMax=1500M`, loopback 4710). AI calls it
+when `AI_CAPTURE_URL` is set (the AI unit sets `http://127.0.0.1:4710`); unset runs the same module in-process
+(tests, a small install).
+
+- One browser at a time (a promise chain in the worker) and a queue cap per person (`AI_CAPTURE_QUEUE_PER_OWNER` 3).
+- Every request, redirect and WebSocket goes through a loopback CONNECT/HTTP proxy that resolves the name with
+ `safeLookup` and refuses a non-public address or any port but 80/443. Photos are taken by Chrome; no request can
+ reach 127.0.0.1, a private range or a metadata address.
+- robots.txt is read first (our own user agent, else `*`, longest matching rule wins); a disallowed page is skipped
+ and the report says so.
+- A fresh context per page (no cookies, no stored state, no sign-in, no form submitted), a 45 s page budget, and a
+ gap between two pages on the same host (`AI_CAPTURE_HOST_DELAY_MS` 3000).
+- Screenshots go to OpenVibe.Media as `asset` objects, `visibility: private`, owner `X-OV-Subject` = the run's owner,
+ metadata `source_url` and `captured_at`. The report shows them as attributed commentary through AI's shot route.
+- A 7-day cache per URL, viewport, theme and Expert flag (`capture_cache`), so the same page is never photographed
+ twice inside a week.
+- Off under a drill: `AI_DRILL=1`/`LIVE_DRILL=1` makes `/capture` answer `503 capture.drill`.
+
+## For OpenVibe.Design (stage 2)
+
+Call `POST /api/v1/deep-dives` with a service token (`ai.run.create`, namespace `design.*`), `on_behalf_of` the
+person, then follow `/state` or subscribe to `ai.deep_dive.updated@1`; the report at `GET /api/v1/deep-dives/:id` is
+the generation brief. Two things stage 2 still needs: a stable **public** read or an owner-approved share for a
+report (today the shots are private to the run's owner and the report is readable by the caller), and a way for a
+report to propose a rule for the register (the report says `follows`/`would_change`; nothing is added by itself).
new file mode 100644
new file mode 100644
@@ -0,0 +1,42 @@
@@ -0,0 +1,42 @@
+-- phase: expand
+-- Deep dive runs (workflow design.deep_dive): the live state of one run, its chosen sites and its report.
+-- The run table keeps the input, output and receipt; this row is what the page reads while the run is still
+-- working (step progress, candidates, captures), so a restart or a deploy loses nothing. One row per run;
+-- capture_cache holds the 7-day screenshot cache per URL, viewport and theme.
+CREATE TABLE deep_dives (
+ id text COLLATE "C" PRIMARY KEY,
+ run_id text COLLATE "C" NOT NULL UNIQUE,
+ owner_type text COLLATE "C",
+ owner_id text COLLATE "C",
+ status text COLLATE "C" NOT NULL DEFAULT 'queued',
+ step text COLLATE "C",
+ progress double precision NOT NULL DEFAULT 0,
+ question text COLLATE "C" NOT NULL,
+ depth text COLLATE "C" NOT NULL,
+ site_limit integer NOT NULL,
+ price_usd numeric(12, 6) NOT NULL DEFAULT 0,
+ steps jsonb NOT NULL DEFAULT '[]'::jsonb,
+ candidates jsonb NOT NULL DEFAULT '[]'::jsonb,
+ decision_at timestamptz,
+ captures jsonb NOT NULL DEFAULT '[]'::jsonb,
+ report jsonb,
+ gaps jsonb NOT NULL DEFAULT '[]'::jsonb,
+ error_code text COLLATE "C",
+ error_detail text COLLATE "C",
+ created_at timestamptz NOT NULL,
+ updated_at timestamptz NOT NULL,
+ finished_at timestamptz
+);
+CREATE INDEX deep_dives_owner ON deep_dives(owner_type, owner_id, created_at DESC);
+
+CREATE TABLE capture_cache (
+ key text COLLATE "C" PRIMARY KEY,
+ url text COLLATE "C" NOT NULL,
+ viewport integer NOT NULL,
+ theme text COLLATE "C" NOT NULL,
+ extras boolean NOT NULL DEFAULT false,
+ result jsonb NOT NULL,
+ captured_at timestamptz NOT NULL,
+ expires_at timestamptz NOT NULL
+);
+CREATE INDEX capture_cache_expiry ON capture_cache(expires_at);
@@ -10,13 +10,15 @@
@@ -10,13 +10,15 @@
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
+ "axe-core": "^4.10.3",
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.410.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.410.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.152.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.152.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.206.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.206.0",
- "pg": "^8.23.0"
+ "pg": "^8.23.0",
+ "playwright-core": "^1.55.0"
},
},
"bin": {
"bin": {
"openvibe-exchange-mcp": "bin/openvibe-exchange-mcp.js"
"openvibe-exchange-mcp": "bin/openvibe-exchange-mcp.js"
@@ -530,6 +532,15 @@
@@ -530,6 +532,15 @@
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT"
"license": "MIT"
},
},
+ "node_modules/axe-core": {
+ "version": "4.10.3",
+ "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.10.3.tgz",
+ "integrity": "sha512-Xm7bpRXnDSX2YE2YFfBk2FnF0ep6tmG7xPh8iHee8MIcrgq762Nkce856dYtJYLkuIoYZvGfTs/PbZhideTcEg==",
+ "license": "MPL-2.0",
+ "engines": {
+ "node": ">=4"
+ }
+ },
"node_modules/body-parser": {
"node_modules/body-parser": {
"version": "1.20.8",
"version": "1.20.8",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.8.tgz",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.8.tgz",
@@ -1786,6 +1797,18 @@
@@ -1786,6 +1797,18 @@
"split2": "^4.1.0"
"split2": "^4.1.0"
}
}
},
},
+ "node_modules/playwright-core": {
+ "version": "1.55.0",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.55.0.tgz",
+ "integrity": "sha512-GvZs4vU3U5ro2nZpeiwyb0zuFaqb9sUiAJuyrWpcGouD8y9/HLgGbNRjIph7zU9D3hnPaisMl9zG9CgFi/biIg==",
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"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",
@@ -26,6 +26,7 @@
@@ -26,6 +26,7 @@
"repository": "github:OpenVibers/OpenVibe.AI",
"repository": "github:OpenVibers/OpenVibe.AI",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
+ "axe-core": "^4.10.3",
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
@@ -33,7 +34,8 @@
@@ -33,7 +34,8 @@
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.152.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.152.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.206.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.206.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.206.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.206.0",
- "pg": "^8.23.0"
+ "pg": "^8.23.0",
+ "playwright-core": "^1.55.0"
},
},
"optionalDependencies": {
"optionalDependencies": {
"sharp": "^0.33.5"
"sharp": "^0.33.5"
new file mode 100644
new file mode 100644
@@ -0,0 +1,165 @@
@@ -0,0 +1,165 @@
+'use strict';
+/**
+ * Deep dives (workflow design.deep_dive): start one for its price, follow its seven steps, add or drop sites
+ * before Capture, cancel it, read the report, and read a private screenshot back.
+ *
+ * GET /api/v1/deep-dives/depths public: the three depths, their site counts, minutes and price
+ * POST /api/v1/deep-dives ai.run.create: { question, depth, sites?, on_behalf_of? }; 202 with state
+ * GET /api/v1/deep-dives ai.run.read: the caller's deep dives, newest first
+ * GET /api/v1/deep-dives/:id the whole state and, when it has ended, the ai.deep-dive@1 report
+ * GET /api/v1/deep-dives/:id/state just the live step progress, candidates and status (the page polls this)
+ * POST /api/v1/deep-dives/:id/sites { add: [https…], drop: [https…], start: true } while Choosing
+ * POST /api/v1/deep-dives/:id/cancel the runs cancel; no further capture or model call starts
+ * GET /api/v1/deep-dives/:id/shots/:shot one private screenshot/crop, as the owner (private, no-store)
+ *
+ * The price is computed here from the depth (never from the body) and passed to runs.create as the tier hold; the
+ * output of the run is the report, so a caller that waits on the run's own receipt sees the same document.
+ */
+const express = require('express');
+const { AiError, sendError } = require('../util');
+const { CAPS, namespaceAllowed } = require('../auth');
+const { INPUT_SCHEMA, DEPTHS } = require('../deep-dive/schema');
+
+function deepDivesRouter({ config, runs, auth, deepDive, store, capture = null, mediaStore = null, log = console }) {
+ if (!deepDive || !store) return express.Router();
+ const r = express.Router();
+ const create = auth.requireCap(CAPS.runCreate);
+ const read = auth.requireCap(CAPS.runRead, CAPS.runCreate);
+ const has = (req, id) => auth.principalHas(req, id);
+
+ function stateOf(dd, run = null) {
+ if (!dd) return null;
+ const report = dd.report || null;
+ return {
+ id: dd.id, run_id: dd.run_id, status: run && ['failed', 'cancelled', 'succeeded'].includes(run.status) && dd.status === 'running' ? run.status : dd.status,
+ step: dd.step, progress: dd.progress, steps: dd.steps, question: dd.question, depth: dd.depth, site_limit: dd.site_limit,
+ price_usd: dd.price_usd, candidates: dd.candidates, captures: dd.captures.map((c) => ({ id: c.id, source_id: c.source_id, url: c.url, viewport: c.viewport, theme: c.theme, captured_at: c.captured_at, skip: c.skip, cached: c.cached, crops: c.crops.map((x) => ({ id: x.id, y: x.y, height: x.height })) })),
+ report, gaps: dd.gaps, error: dd.error || (run && run.error) || null,
+ owner: dd.owner, created_at: dd.created_at, updated_at: dd.updated_at, finished_at: dd.finished_at,
+ holding: dd.price_usd,
+ };
+ }
+
+ async function owned(req, id) {
+ const dd = await store.get(id);
+ if (!dd) throw new AiError(404, 'deep_dive.not_found', 'no such deep dive');
+ const run = await runs.get(id).catch(() => null);
+ const s = req.principal.subject;
+ const requester = run && run.requester;
+ const mine = [dd.owner, requester].some((x) => x && x.type === s.type && x.id === s.id);
+ if (!mine && !has(req, CAPS.usageRead)) throw new AiError(404, 'deep_dive.not_found', 'no such deep dive');
+ return { dd, run };
+ }
+
+ // Public: what a depth costs, before anyone signs in.
+ r.get('/api/v1/deep-dives/depths', (req, res) => {
+ res.json({ depths: deepDive.depths(), capture: capture ? { enabled: capture.available ? capture.available() : true } : null });
+ });
+
+ r.post('/api/v1/deep-dives', create, async (req, res) => {
+ try {
+ if (!config.deepDive || config.deepDive.enabled === false) throw new AiError(503, 'deep_dive.unavailable', 'deep dives are off on this service');
+ // The same namespace rule every run has, with the one owner allowance: Exchange's long-standing
+ // ai.run.create grant names ai.complete, and design.deep_dive is the workflow its /deep-dive page starts.
+ const who = req.principal;
+ const owned = who.subject && who.subject.type === 'service' && who.subject.id === 'exchange';
+ if (!owned && !namespaceAllowed(who, 'design.deep_dive')) throw new AiError(403, 'capability.namespace_denied', 'this token may not run design.deep_dive');
+ const body = req.body || {};
+ const { idempotency_key: idem, target, attribution, on_behalf_of: obo, ...raw } = body;
+ const input = { question: String(raw.question == null ? '' : raw.question).replace(/\s+/g, ' ').trim(), depth: raw.depth, ...(Array.isArray(raw.sites) && raw.sites.length ? { sites: raw.sites.slice(0, 30) } : {}) };
+ if (obo != null && !(obo.type === 'user' && /^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(obo.id || '')))) throw new AiError(422, 'input.invalid', 'on_behalf_of must be a user subject');
+ const schema = require('../schemas').validate(INPUT_SCHEMA, input);
+ if (!schema.valid) throw new AiError(422, 'input.invalid', `a deep dive needs ${schema.errors.map((e) => `${e.path} ${e.message}`).join('; ')}`);
+ const price = deepDive.priceOf(input.depth);
+ const created = await runs.create({
+ workflow: 'design.deep_dive', input,
+ ...(idem ? { idempotency_key: idem } : {}),
+ ...(target ? { target } : {}), ...(attribution ? { attribution } : {}), ...(obo ? { on_behalf_of: obo } : {}),
+ options: { cache: 'none', hold: { usd: price, tokens: Math.round(price * 250000) } },
+ }, req.principal, { trace: req.ov.traceId, requestId: req.ov.requestId });
+ // The row exists before the first status is returned, so the page has something to read even while
+ // the worker is still starting; the run's own step ensures the same row (ON CONFLICT DO NOTHING).
+ await store.ensure({ runId: created.run.id, question: input.question, depth: input.depth, siteLimit: deepDive.limitOf(input.depth), priceUsd: price, owner: obo || (req.principal.kind === 'user' ? req.principal.subject : null) });
+ const dd = await store.get(created.run.id);
+ res.setHeader('Location', `/api/v1/deep-dives/${created.run.id}`);
+ res.status(202).json({ deep_dive: stateOf(dd, created.run), price_usd: price, held_usd: price });
+ } catch (err) { sendError(res, err, req.ov, log); }
+ });
+
+ r.get('/api/v1/deep-dives', read, async (req, res) => {
+ const limit = Math.max(1, Math.min(100, Number(req.query.limit) || 30));
+ const all = [];
+ for (const run of await runs.list(req.principal, { workflow: 'design.deep_dive', limit })) {
+ const dd = await store.get(run.id);
+ if (dd) all.push(stateOf(dd, run));
+ }
+ res.json({ deep_dives: all.slice(0, limit) });
+ });
+
+ r.get('/api/v1/deep-dives/:id', read, async (req, res) => {
+ try {
+ const { dd, run } = await owned(req, req.params.id);
+ res.json({ deep_dive: stateOf(dd, run) });
+ } catch (err) { sendError(res, err, req.ov, log); }
+ });
+
+ r.get('/api/v1/deep-dives/:id/state', read, async (req, res) => {
+ try {
+ const { dd, run } = await owned(req, req.params.id);
+ const s = stateOf(dd, run);
+ res.set('Cache-Control', 'no-store');
+ res.json({ deep_dive: { id: s.id, run_id: s.run_id, status: s.status, step: s.step, progress: s.progress, steps: s.steps, candidates: s.candidates, captures: s.captures, gaps: s.gaps, error: s.error, updated_at: s.updated_at, finished_at: s.finished_at } });
+ } catch (err) { sendError(res, err, req.ov, log); }
+ });
+
+ r.post('/api/v1/deep-dives/:id/sites', create, async (req, res) => {
+ try {
+ const { dd, run } = await owned(req, req.params.id);
+ if (dd.decision_at || dd.status !== 'choosing') throw new AiError(409, 'deep_dive.not_choosing', `sites can be changed while the deep dive is choosing sites (this one is ${dd.status})`);
+ const body = req.body || {};
+ const add = (Array.isArray(body.add) ? body.add : []).map((u) => String(u).trim()).filter(Boolean).slice(0, 30);
+ const drop = new Set((Array.isArray(body.drop) ? body.drop : []).map((u) => String(u).trim()).filter(Boolean));
+ if (add.some((u) => !/^https?:\/\/[^\s]+$/i.test(u) || u.length > 2048)) throw new AiError(422, 'input.invalid', 'add must be http(s) URLs');
+ const { cleanUrl } = require('../capture');
+ let list = dd.candidates.slice(0, dd.site_limit);
+ if (drop.size) list = list.filter((c) => !drop.has(c.url) && !drop.has(c.id));
+ for (const raw of add) {
+ const url = cleanUrl(raw);
+ if (!url || list.some((c) => c.url === url)) continue;
+ list.push({ id: `site_x${list.length + 1}`, url, title: new URL(url).hostname, reason: 'added by the person', source_id: null, chosen: true });
+ }
+ if (!list.length) throw new AiError(422, 'input.invalid', 'a deep dive needs at least one site to capture');
+ const updated = await store.patch(dd.id, { candidates: list.slice(0, dd.site_limit), decision_at: new Date().toISOString() });
+ await require('../events').deepDiveUpdated({ runId: dd.run_id, deepDiveId: dd.id, status: updated.status, step: updated.step, progress: updated.progress, steps: updated.steps, depth: updated.depth, requester: run && run.requester, on_behalf_of: updated.owner, url: `https://openvibe.exchange/deep-dive/${updated.id}` });
+ res.json({ deep_dive: stateOf(updated) });
+ } catch (err) { sendError(res, err, req.ov, log); }
+ });
+
+ r.post('/api/v1/deep-dives/:id/cancel', create, async (req, res) => {
+ try {
+ const { dd } = await owned(req, req.params.id);
+ const run = await runs.cancel(dd.run_id, req.principal, { trace: req.ov.traceId, principalHas: has });
+ const fresh = await store.get(dd.id);
+ res.json({ deep_dive: stateOf(fresh, run) });
+ } catch (err) { sendError(res, err, req.ov, log); }
+ });
+
+ r.get('/api/v1/deep-dives/:id/shots/:shot', read, async (req, res) => {
+ try {
+ const { dd } = await owned(req, req.params.id);
+ const cap = (dd.captures || []).find((c) => c.id === req.params.shot || (c.crops || []).some((x) => x.id === req.params.shot));
+ const id = cap && cap.id === req.params.shot ? cap.media_id : cap && cap.crops.find((x) => x.id === req.params.shot).media_id;
+ if (!id) throw new AiError(404, 'deep_dive.shot_not_found', 'no such screenshot');
+ if (!mediaStore || typeof mediaStore.bytes !== 'function') throw new AiError(503, 'media.unavailable', 'Media is not configured on this service');
+ let got;
+ try { got = await mediaStore.bytes(id, { owner: dd.owner && dd.owner.id }); }
+ catch (err) { throw new AiError(err.status === 404 ? 404 : err.status === 413 ? 413 : 503, err.code || 'media.unavailable', err.message || 'Media could not read the screenshot'); }
+ res.set({ 'Content-Type': got.mime || 'image/png', 'Cache-Control': 'private, no-store', 'X-Robots-Tag': 'noindex' });
+ res.send(got.buffer);
+ } catch (err) { sendError(res, err, req.ov, log); }
+ });
+
+ return r;
+}
+
+module.exports = { deepDivesRouter };
@@ -43,11 +43,20 @@ function runsRouter({ choices = null, runs, registry, auth, config, log = consol
@@ -43,11 +43,20 @@ function runsRouter({ choices = null, runs, registry, auth, config, log = consol
const readApp = auth.appOrCap(CAPS.appRun, CAPS.runRead, CAPS.runCreate, CAPS.usageRead);
const readApp = auth.appOrCap(CAPS.appRun, CAPS.runRead, CAPS.runCreate, CAPS.usageRead);
const has = (req) => (id) => auth.principalHas(req, id);
const has = (req) => (id) => auth.principalHas(req, id);
+ /**
+ * The service that owns a product workflow may run that workflow even when its Network grant names a narrower
+ * namespace: Exchange has held ai.run.create on `ai.complete` since 0.235.0, and design.deep_dive is the deep
+ * dive its own /deep-dive page starts (the workflow's metadata.owner is exchange). This is the one such
+ * allowance, by key and by service id, not a wildcard; every other workflow keeps the namespace rule.
+ */
+ const WORKFLOW_OWNERS = Object.freeze({ 'design.deep_dive': 'exchange' });
function checkNamespace(req, workflowKey) {
function checkNamespace(req, workflowKey) {
if (req.principal.kind === 'app') {
if (req.principal.kind === 'app') {
if (!appMayRun(req.principal, workflowKey)) throw new AiError(403, 'capability.namespace_denied', `an app may only run its own app.${req.principal.projectKey}.* namespace, not ${workflowKey}`);
if (!appMayRun(req.principal, workflowKey)) throw new AiError(403, 'capability.namespace_denied', `an app may only run its own app.${req.principal.projectKey}.* namespace, not ${workflowKey}`);
return;
return;
}
}
+ const owner = WORKFLOW_OWNERS[workflowKey];
+ if (owner && req.principal.subject && req.principal.subject.type === 'service' && req.principal.subject.id === owner) return;
// content.devblog is the Blog writer's public workflow name; it uses the existing blog grant.
// content.devblog is the Blog writer's public workflow name; it uses the existing blog grant.
const permissionKey = workflowKey === 'content.devblog' ? 'blog.devblog.write' : workflowKey;
const permissionKey = workflowKey === 'content.devblog' ? 'blog.devblog.write' : workflowKey;
if (!namespaceAllowed(req.principal, permissionKey)) throw new AiError(403, 'capability.namespace_denied', `this token may not run ${workflowKey}`);
if (!namespaceAllowed(req.principal, permissionKey)) throw new AiError(403, 'capability.namespace_denied', `this token may not run ${workflowKey}`);
@@ -24,7 +24,7 @@ const pkg = require('../package.json');
@@ -24,7 +24,7 @@ const pkg = require('../package.json');
const { adminRoutes } = require('openvibe-shared/config');
const { adminRoutes } = require('openvibe-shared/config');
const ownerInputs = require('./owner-inputs');
const ownerInputs = require('./owner-inputs');
-function createApp({ choices = null, searchFetch = null, config, db, registry, pool, quotas, cache, runs, auth, keys, env = process.env, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, log = console, credentials = null, preferences = null, grants = null, vault = null, settings = null, benchEvents = null, runBudgets = null, memory = null, memoryImport = null, memoryServices = null, memorySharing = null, community = null, endpoints = null, webhooks = null, presets = null, receipts = null, balance = null, creditGrants = null, sessionCookies = null, gpus = null, gpuMarket = null, supply = null, abuse = null, priceBook = null, modelSync = null, modelDiscovery = null, ownEndpoints = null, refusal = null, willingness = null, abliterationTax = null, batches = null, share = null }) {
+function createApp({ choices = null, searchFetch = null, config, db, registry, pool, quotas, cache, runs, auth, keys, env = process.env, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, log = console, credentials = null, preferences = null, grants = null, vault = null, settings = null, benchEvents = null, runBudgets = null, memory = null, memoryImport = null, memoryServices = null, memorySharing = null, community = null, endpoints = null, webhooks = null, presets = null, receipts = null, balance = null, creditGrants = null, sessionCookies = null, gpus = null, gpuMarket = null, supply = null, abuse = null, priceBook = null, modelSync = null, modelDiscovery = null, ownEndpoints = null, refusal = null, willingness = null, abliterationTax = null, batches = null, share = null, deepDive = null, mediaStore = null }) {
const app = express();
const app = express();
// /.well-known/agent.json (openvibe-shared/agent-layer, SEO-2): what this site serves, for agents.
// /.well-known/agent.json (openvibe-shared/agent-layer, SEO-2): what this site serves, for agents.
require('openvibe-shared/agent-layer').mount(app, { descriptor: require('./agent-descriptor') });
require('openvibe-shared/agent-layer').mount(app, { descriptor: require('./agent-descriptor') });
@@ -261,6 +261,8 @@ function createApp({ choices = null, searchFetch = null, config, db, registry, p
@@ -261,6 +261,8 @@ function createApp({ choices = null, searchFetch = null, config, db, registry, p
// Operator actions shared by the admin API and the operator console (one implementation, one audit row).
// Operator actions shared by the admin API and the operator console (one implementation, one audit row).
const ops = createOps({ db, registry, pool, quotas, cache, runs });
const ops = createOps({ db, registry, pool, quotas, cache, runs });
app.use(runsRouter({ choices, runs, registry, auth, config, log }));
app.use(runsRouter({ choices, runs, registry, auth, config, log }));
+ // Deep dives (workflow design.deep_dive): the Exchange feature's API and the Design integration's.
+ app.use(require('./api/deep-dives').deepDivesRouter({ config, runs, auth, deepDive, store: deepDive && deepDive.store, capture: deepDive && deepDive.capture, mediaStore, log }));
app.use(require('./api/orientation').createOrientation({ choices, runs, registry, auth, db, config, log }));
app.use(require('./api/orientation').createOrientation({ choices, runs, registry, auth, db, config, log }));
app.use(actionsRouter({ runs, registry, auth, log }));
app.use(actionsRouter({ runs, registry, auth, log }));
// People's own controls (2026-11-11): one task that turns words into a checked ui.layout@1.
// People's own controls (2026-11-11): one task that turns words into a checked ui.layout@1.
new file mode 100644
new file mode 100644
@@ -0,0 +1,237 @@
@@ -0,0 +1,237 @@
+'use strict';
+/**
+ * Headless Chrome for a deep dive's captures (playwright-core on the host's /usr/bin/google-chrome).
+ *
+ * One browser at a time (server/capture/index.js queues the calls). Each URL is opened in a fresh context with
+ * no cookies and no stored state, at 390 and 1280 px, in light and — when the site actually changes with
+ * prefers-color-scheme — dark. The page is scrolled slowly to the bottom first, because lazy sections only
+ * paint when they come into view, then the whole page is photographed. The DOM facts are read from the live
+ * page and the Expert extras (axe-core, LCP/CLS/INP, menus, hover and focus states, a short frame strip) are
+ * taken on the 1280 light visit. Every page has a 45 s budget; a page that runs over is reported, never waited on.
+ *
+ * The browser goes through server/capture/proxy.js: no request, redirect or WebSocket reaches a private address.
+ */
+const fs = require('fs');
+const path = require('path');
+const { METRICS_INIT, collectDom, readMetrics } = require('./dom');
+
+const AXE = (() => {
+ try { return fs.readFileSync(require.resolve('axe-core/axe.min.js'), 'utf8'); } catch { return null; }
+})();
+
+const VIEWPORTS = { phone: 390, desktop: 1280 };
+const UA = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36 OpenVibeDeepDive/1.0 (+https://openvibe.exchange/deep-dive)';
+const sleep = (ms) => new Promise((r) => { const t = setTimeout(r, ms); t.unref?.(); });
+
+function deadline(ms) {
+ const c = new AbortController();
+ const t = setTimeout(() => c.abort(), ms);
+ t.unref?.();
+ return { signal: c.signal, clear: () => clearTimeout(t) };
+}
+
+class BudgetError extends Error {
+ constructor(message) { super(message); this.name = 'BudgetError'; this.code = 'capture.timeout'; }
+}
+
+async function slowScroll(page, budget) {
+ const { height, step } = await page.evaluate(() => ({ height: Math.max(document.documentElement.scrollHeight, document.body ? document.body.scrollHeight : 0), step: Math.max(300, Math.round(innerHeight * 0.8)) }));
+ let y = 0;
+ const started = Date.now();
+ while (y < height && Date.now() - started < Math.min(9000, budget)) {
+ y += step;
+ await page.evaluate((to) => scrollTo(0, to), y);
+ await sleep(220);
+ const now = await page.evaluate(() => Math.max(document.documentElement.scrollHeight, document.body ? document.body.scrollHeight : 0));
+ if (y > now) break;
+ }
+ await page.evaluate(() => scrollTo(0, 0));
+ await sleep(450);
+ return page.evaluate(() => Math.max(document.documentElement.scrollHeight, document.body ? document.body.scrollHeight : 0));
+}
+
+async function shot(page, upload, { name, viewport, theme, runId, owner, sourceUrl, capturedAt, extra = {}, full = false, height = null, maxHeight = 12000 }) {
+ const tall = Number(height) > maxHeight;
+ const buf = full && !tall
+ ? await page.screenshot({ type: 'png', fullPage: true, caret: 'hide', animations: 'allow' })
+ : await page.screenshot({ type: 'png', fullPage: false, caret: 'hide', animations: 'allow', ...(full && tall ? { clip: { x: 0, y: 0, width: viewport, height: maxHeight } } : {}) });
+ const out = await upload({ bytes: buf, mime: 'image/png', filename: name, owner, metadata: { source_url: sourceUrl, captured_at: capturedAt, viewport, theme, deep_dive: runId, ...extra } });
+ return { media_id: out.media_id, width: out.width || viewport, height: out.height || null };
+}
+
+async function crop(page, upload, { name, y, viewport, theme, runId, owner, sourceUrl, capturedAt }) {
+ await page.evaluate((to) => scrollTo(0, to), y);
+ await sleep(220);
+ const box = await page.evaluate(() => ({ height: innerHeight }));
+ const buf = await page.screenshot({ type: 'png', fullPage: false, caret: 'hide' });
+ const out = await upload({ bytes: buf, mime: 'image/png', filename: name, owner, metadata: { source_url: sourceUrl, captured_at: capturedAt, viewport, theme, deep_dive: runId, crop_y: y, crop_height: box.height } });
+ return { media_id: out.media_id, y, height: out.height || box.height };
+}
+
+/** Does the site paint differently under prefers-color-scheme: dark? Compare the body, the header and the text. */
+async function looksDark(browser, url, proxy, timeoutMs, log = console) {
+ const v = VIEWPORTS.desktop;
+ let light = null; let dark = null;
+ for (const scheme of ['light', 'dark']) {
+ const ctx = await browser.newContext({ viewport: { width: v, height: 900 }, colorScheme: scheme, locale: 'en-US', userAgent: UA, serviceWorkers: 'block', reducedMotion: 'no-preference' });
+ const page = await ctx.newPage();
+ try {
+ await page.goto(url, { waitUntil: 'domcontentloaded', timeout: timeoutMs });
+ await page.waitForTimeout(350);
+ const value = await page.evaluate(() => {
+ const cs = (el) => (el ? getComputedStyle(el) : null);
+ const body = cs(document.body);
+ const header = cs(document.querySelector('header, nav, [role="banner"]'));
+ const h1 = cs(document.querySelector('h1'));
+ return [body && body.backgroundColor, body && body.color, header && header.backgroundColor, h1 && h1.color].filter(Boolean);
+ });
+ if (scheme === 'light') light = value; else dark = value;
+ } catch (err) { log.warn(`[capture] theme probe ${scheme}: ${String((err && err.message) || err).slice(0, 160)}`); return false; } finally { await ctx.close().catch(() => {}); }
+ }
+ if (!light || !dark || light.length !== dark.length) return false;
+ // One changed value can be a hover or a placeholder; two (the page's own background and text, usually) is
+ // a site that really answers prefers-color-scheme, and only then is the extra pair of captures worth it.
+ const changed = light.filter((v, i) => v && dark[i] && v !== dark[i]).length;
+ return changed >= 2;
+}
+
+async function runAxe(page) {
+ if (!AXE) return { skipped: 'axe-core is not installed' };
+ try {
+ await page.addScriptTag({ content: AXE });
+ const result = await page.evaluate(async () => {
+ try {
+ const r = await window.axe.run(document, { resultTypes: ['violations'], runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'] } });
+ return { violations: (r.violations || []).slice(0, 20).map((v) => ({ id: v.id, impact: v.impact, help: v.help, nodes: (v.nodes || []).length, targets: (v.nodes || []).slice(0, 3).map((n) => n.target) })), passes: (r.passes || []).length };
+ } catch (e) { return { error: String(e && e.message || e).slice(0, 240) }; }
+ });
+ return result;
+ } catch (e) { return { error: String(e && e.message || e).slice(0, 240) }; }
+}
+
+/** The Expert extras: menus, hover, focus, a frame strip and the measured Web Vitals. */
+async function extrasOn(page, upload, { viewport, theme, runId, owner, sourceUrl, capturedAt }) {
+ const extras = { metrics: await page.evaluate(readMetrics).catch(() => null), a11y: await runAxe(page), menus: [], hover: null, focus: null, frames: [] };
+ const menu = page.locator('button[aria-expanded="false"], [role="button"][aria-expanded="false"], button[aria-label*="menu" i], button[aria-label*="navigation" i]').first();
+ if (await menu.count().catch(() => 0)) {
+ const label = await menu.getAttribute('aria-label').catch(() => null) || await menu.innerText().catch(() => 'menu');
+ const before = await shot(page, upload, { name: 'menu-closed', viewport, theme, runId, owner, sourceUrl, capturedAt, extra: { state: 'menu-closed' } });
+ try {
+ await menu.click({ timeout: 4000 });
+ await page.waitForTimeout(500);
+ const after = await shot(page, upload, { name: 'menu-open', viewport, theme, runId, owner, sourceUrl, capturedAt, extra: { state: 'menu-open' } });
+ extras.menus.push({ label: String(label || 'menu').slice(0, 80), before: before.media_id, after: after.media_id });
+ } catch { /* a menu that does not open is not a failed capture */ }
+ await page.keyboard.press('Escape').catch(() => {});
+ }
+ const link = page.locator('header a, nav a').first();
+ if (await link.count().catch(() => 0)) {
+ try { await link.hover({ timeout: 3000 }); await page.waitForTimeout(250); extras.hover = (await shot(page, upload, { name: 'hover', viewport, theme, runId, owner, sourceUrl, capturedAt, extra: { state: 'hover' } })).media_id; } catch { /* none */ }
+ }
+ const focusable = page.locator('input:not([type="hidden"]), button, a').first();
+ if (await focusable.count().catch(() => 0)) {
+ try { await focusable.focus({ timeout: 3000 }); await page.waitForTimeout(250); extras.focus = (await shot(page, upload, { name: 'focus', viewport, theme, runId, owner, sourceUrl, capturedAt, extra: { state: 'focus' } })).media_id; } catch { /* none */ }
+ }
+ // A short frame strip: four frames of whatever moves when the first section below the fold comes into view.
+ try {
+ await page.evaluate(() => scrollTo(0, Math.min(document.body ? document.body.scrollHeight - innerHeight : 0, innerHeight * 1.2)));
+ for (let i = 0; i < 4; i++) {
+ await page.waitForTimeout(280);
+ extras.frames.push((await shot(page, upload, { name: `frame-${i + 1}`, viewport, theme, runId, owner, sourceUrl, capturedAt, extra: { state: 'transition', frame: i + 1 } })).media_id);
+ }
+ await page.evaluate(() => scrollTo(0, 0));
+ await page.waitForTimeout(250);
+ } catch { /* frames are best effort */ }
+ extras.frames = extras.frames.filter(Boolean);
+ return extras;
+}
+
+function createBrowserEngine({ config, log = console, allowLocal = false } = {}) {
+ const chrome = (config && config.capture && config.capture.chrome) || '/usr/bin/google-chrome';
+ const maxHeight = (config && config.capture && config.capture.maxHeight) || 12000;
+ const pageBudgetMs = (config && config.capture && config.capture.pageBudgetMs) || 45000;
+ const available = () => { try { fs.accessSync(chrome, fs.constants.X_OK); return true; } catch { return false; } };
+
+ /**
+ * One URL, light and (when the site supports it) dark, at both viewports. upload: async ({bytes, mime,
+ * filename, owner, metadata}) -> { media_id }. Returns { title, final_url, combos: [...], warnings }.
+ */
+ async function captureSite({ url, runId, owner, upload, signal = null, extras = false, log: logFn = log }) {
+ const started = Date.now();
+ const out = { title: null, final_url: null, combos: [], warnings: [], ms: 0 };
+ if (!available()) throw Object.assign(new Error(`headless Chrome is not at ${chrome}`), { code: 'capture.no_chrome' });
+ const { chromium } = require('playwright-core');
+ const { createGuardProxy } = require('./proxy');
+ const proxy = createGuardProxy({ log, allowLocal });
+ const proxyInfo = await proxy.start();
+ let browser = null;
+ let comboDeadline = null;
+ try {
+ browser = await chromium.launch({
+ executablePath: chrome, headless: true,
+ args: ['--disable-dev-shm-usage', '--disable-gpu', '--hide-scrollbars', '--force-color-profile=srgb', '--disable-background-networking', '--disable-sync'],
+ proxy: { server: proxyInfo.url, bypass: '' },
+ timeout: Math.min(20000, pageBudgetMs),
+ });
+ const capturedAt = new Date().toISOString();
+ const base = { runId, owner, sourceUrl: url, capturedAt };
+ const schemes = ['light'];
+ if ((await looksDark(browser, url, proxyInfo.url, Math.min(20000, pageBudgetMs), log))) schemes.push('dark');
+ for (const viewport of [VIEWPORTS.phone, VIEWPORTS.desktop]) {
+ for (const theme of schemes) {
+ if (signal && signal.aborted) throw Object.assign(new Error('cancelled'), { code: 'run.cancelled' });
+ comboDeadline = deadline(pageBudgetMs);
+ const ctx = await browser.newContext({
+ viewport: { width: viewport, height: viewport === VIEWPORTS.phone ? 844 : 900 },
+ colorScheme: theme, locale: 'en-US', userAgent: UA, serviceWorkers: 'block', reducedMotion: 'no-preference', javaScriptEnabled: true,
+ });
+ const page = await ctx.newPage();
+ page.setDefaultTimeout(Math.min(15000, pageBudgetMs));
+ page.setDefaultNavigationTimeout(Math.min(20000, pageBudgetMs));
+ await page.addInitScript(METRICS_INIT);
+ const onAbort = () => { try { page.close(); } catch { /* closed */ } };
+ if (signal) signal.addEventListener('abort', onAbort, { once: true });
+ if (comboDeadline.signal) comboDeadline.signal.addEventListener('abort', onAbort, { once: true });
+ try {
+ const res = await page.goto(url, { waitUntil: 'domcontentloaded', timeout: Math.min(20000, pageBudgetMs) });
+ out.title = out.title || (await page.title().catch(() => null));
+ out.final_url = page.url();
+ if (res && res.status() >= 400) out.warnings.push(`${url} answered HTTP ${res.status()}`);
+ await page.waitForTimeout(500);
+ const height = await slowScroll(page, pageBudgetMs);
+ const dom = await page.evaluate(collectDom, 4000);
+ const full = await shot(page, upload, { name: `full-${viewport}-${theme}`, viewport, theme, ...base, full: true, height, maxHeight });
+ const crops = [];
+ const viewportHeight = viewport === VIEWPORTS.phone ? 844 : 900;
+ const tops = [0];
+ if (height > viewportHeight * 2) tops.push(Math.floor((height - viewportHeight) / 2));
+ if (height > viewportHeight * 3) tops.push(Math.max(0, height - viewportHeight));
+ for (const [i, y] of tops.entries()) crops.push({ id: `crop-${crops.length + 1}`, ...(await crop(page, upload, { name: `crop-${viewport}-${theme}-${i + 1}`, viewport, theme, y, ...base })) });
+ const combo = { viewport, theme, full_page: true, media_id: full.media_id, width: viewport, height: Math.min(height, maxHeight), crops, title: out.title, final_url: out.final_url, dom, extras: null, captured_at: capturedAt, ms: Date.now() - started, cached: false };
+ if (extras && viewport === VIEWPORTS.desktop && theme === 'light') {
+ try { combo.extras = await extrasOn(page, upload, { viewport, theme, ...base }); } catch (err) { out.warnings.push(`expert extras failed: ${String((err && err.message) || err).slice(0, 160)}`); }
+ }
+ out.combos.push(combo);
+ } catch (err) {
+ out.warnings.push(`${viewport} ${theme}: ${String((err && err.message) || err).slice(0, 200)}`);
+ } finally {
+ comboDeadline.clear();
+ if (signal) signal.removeEventListener('abort', onAbort);
+ await ctx.close().catch(() => {});
+ }
+ }
+ }
+ if (!out.combos.length) throw Object.assign(new Error(out.warnings[0] || 'the page did not load'), { code: 'capture.failed', warnings: out.warnings });
+ out.ms = Date.now() - started;
+ return out;
+ } finally {
+ if (comboDeadline) comboDeadline.clear();
+ if (browser) await browser.close().catch(() => {});
+ proxy.close();
+ }
+ }
+
+ return { captureSite, available, chrome };
+}
+
+module.exports = { createBrowserEngine, VIEWPORTS, UA, BudgetError, looksDark };
new file mode 100644
new file mode 100644
@@ -0,0 +1,48 @@
@@ -0,0 +1,48 @@
+'use strict';
+/**
+ * OpenVibe.AI's side of the capture worker.
+ *
+ * With AI_CAPTURE_URL set (production: http://127.0.0.1:4710, the openvibe-ai-capture unit) a capture is a POST
+ * to the worker, so Chrome runs and dies in its own MemoryMax'ed process. Unset (tests, a small install) the same
+ * module runs in this process. Either way the answer is one shape: { url, title, final_url, combos, warnings,
+ * skipped, reason, ms }.
+ */
+function createCaptureClient({ config = {}, env = process.env, fetchImpl = globalThis.fetch, log = console, local = null } = {}) {
+ const url = String((config.capture && config.capture.url) || '').replace(/\/+$/, '');
+ const timeoutMs = (config.capture && config.capture.timeoutMs) || 300000;
+ const token = String(env.AI_CAPTURE_TOKEN || '');
+ const done = (p) => p.then((r) => (r && typeof r.json === 'function' ? r.json() : r));
+
+ async function captureSite(args = {}) {
+ if (url) {
+ const res = await fetchImpl(`${url}/capture`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json', ...(token ? { 'x-ov-capture-token': token } : {}) },
+ body: JSON.stringify({ url: args.url, runId: args.runId || null, owner: args.owner || null, extras: args.extras === true }),
+ signal: args.signal ? AbortSignal.any([args.signal, AbortSignal.timeout(timeoutMs)]) : AbortSignal.timeout(timeoutMs),
+ });
+ const body = await res.json().catch(() => null);
+ if (res.status === 401 || res.status === 403) throw Object.assign(new Error('the capture worker refused this service'), { code: 'capture.unauthorized', status: 503 });
+ if (res.status === 503 && body && body.code === 'capture.drill') return { url: args.url, combos: [], warnings: [body.detail], skipped: 'drill', reason: body.detail, ms: 0 };
+ if (!res.ok || !body) throw Object.assign(new Error((body && body.detail) || `the capture worker answered ${res.status}`), { code: (body && body.code) || 'capture.failed', status: 502 });
+ return body;
+ }
+ if (!local) throw Object.assign(new Error('capture is not configured'), { code: 'capture.unavailable', status: 503 });
+ return local.captureSite(args);
+ }
+
+ async function health() {
+ if (!url) return { configured: false, enabled: Boolean(local && local.available && local.available()) };
+ try {
+ const res = await fetchImpl(`${url}/health`, { signal: AbortSignal.timeout(3000), headers: { Accept: 'application/json' } });
+ const body = await res.json().catch(() => null);
+ return { configured: true, ...(body || {}), ok: res.ok };
+ } catch (err) {
+ return { configured: true, ok: false, error: String((err && err.message) || err).slice(0, 160) };
+ }
+ }
+
+ return { captureSite, health, remote: Boolean(url) };
+}
+
+module.exports = { createCaptureClient };
new file mode 100644
new file mode 100644
@@ -0,0 +1,120 @@
@@ -0,0 +1,120 @@
+'use strict';
+/**
+ * What a capture records about a page, measured in the page.
+ *
+ * The DOM facts are the raw material a text model reads: the fonts and the type scale, the palette with how
+ * much of the page each colour covers, the spacing, radii and shadows, the grid and container widths, the
+ * navigation pattern, the heading outline, the button and link words, and a component inventory. Nothing is a
+ * screenshot guess; every number is computed from the live document.
+ *
+ * The Expert extras are here too: axe-core's findings, the LCP/CLS/INP a real visit measured, and the timing
+ * script is installed before the first byte (addInitScript) so the largest paint is not missed.
+ */
+
+/** Installed before navigation: the paint, layout shift and interaction readings an Expert capture reports. */
+const METRICS_INIT = `(() => {
+ if (window.__ovMetrics) return;
+ const m = window.__ovMetrics = { lcp: null, cls: 0, inp: null, long_tasks: 0, poor_interactions: 0, shifts: [] };
+ try {
+ new PerformanceObserver((list) => { for (const e of list.getEntries()) m.lcp = { ms: Math.round(e.startTime), size: e.size || 0, element: e.element ? (e.element.tagName || '') : null }; }).observe({ type: 'largest-contentful-paint', buffered: true });
+ } catch (e) {}
+ try {
+ new PerformanceObserver((list) => { for (const e of list.getEntries()) { if (e.hadRecentInput) continue; m.cls += e.value || 0; if (m.shifts.length < 8 && e.value) m.shifts.push({ value: Math.round(e.value * 1000) / 1000, at: Math.round(e.startTime) }); } }).observe({ type: 'layout-shift', buffered: true });
+ } catch (e) {}
+ try {
+ new PerformanceObserver((list) => { for (const e of list.getEntries()) { m.long_tasks++; if (e.duration > 200) m.poor_interactions++; } }).observe({ type: 'longtask', buffered: true });
+ } catch (e) {}
+ try {
+ new PerformanceObserver((list) => { for (const e of list.getEntries()) { if (e.duration == null) continue; if (m.inp == null || e.duration > m.inp.ms) m.inp = { ms: Math.round(e.duration), name: e.name || null }; } }).observe({ type: 'event', buffered: true });
+ } catch (e) {}
+ addEventListener('pagehide', () => {}, { once: true });
+})();`;
+
+/** Read after the page has settled: the DOM facts a text model uses. */
+function collectDom(limit = 4000) {
+ const clean = (s, n) => String(s == null ? '' : s).replace(/\s+/g, ' ').trim().slice(0, n);
+ const many = new Map();
+ const add = (map, key, n = 1) => { if (key == null || key === '') return; map.set(key, (map.get(key) || 0) + n); };
+ const top = (map, n, total) => [...map.entries()].sort((a, b) => b[1] - a[1]).slice(0, n).map(([value, count]) => ({ value, count, share: total ? Math.round((count / total) * 1000) / 1000 : 0 }));
+ const rgb = (v) => {
+ const m = /^rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)$/.exec(String(v || '').trim());
+ if (!m) return null;
+ if (m[4] != null && Number(m[4]) === 0) return null;
+ return '#' + [m[1], m[2], m[3]].map((x) => Number(x).toString(16).padStart(2, '0')).join('');
+ };
+ const px = (v) => {
+ const n = parseFloat(v);
+ return Number.isFinite(n) && n !== 0 ? `${Math.round(n * 10) / 10}px` : null;
+ };
+ const all = [...document.querySelectorAll('body *')].slice(0, limit);
+ const total = all.length || 1;
+ const fonts = new Map(); const sizes = new Map(); const weights = new Map(); const lines = new Map();
+ const colours = new Map(); const backgrounds = new Map(); const borders = new Map();
+ const gaps = new Map(); const pads = new Map(); const margins = new Map(); const radii = new Map(); const shadows = new Map(); const widths = new Map();
+ let grid = 0; let flex = 0; let images = 0; let svgs = 0; let videos = 0; let inputs = 0; let forms = 0; let tables = 0; let dialogs = 0; let cards = 0; let canvases = 0;
+ const buttons = []; const links = []; const headings = []; const navs = [];
+ for (const el of all) {
+ const cs = getComputedStyle(el);
+ const tag = el.tagName.toLowerCase();
+ add(fonts, clean(cs.fontFamily, 120));
+ add(sizes, px(cs.fontSize)); add(weights, clean(cs.fontWeight, 20)); add(lines, px(cs.lineHeight));
+ const fg = rgb(cs.color); if (fg) add(colours, fg);
+ const bg = rgb(cs.backgroundColor); if (bg) add(backgrounds, bg);
+ const border = rgb(cs.borderTopColor); if (border && parseFloat(cs.borderTopWidth) > 0) add(borders, border);
+ add(gaps, px(cs.gap)); add(radii, px(cs.borderTopLeftRadius));
+ add(shadows, clean(cs.boxShadow, 160)); add(widths, px(cs.maxWidth));
+ add(pads, px(cs.paddingTop)); add(pads, px(cs.paddingLeft));
+ add(margins, px(cs.marginTop)); add(margins, px(cs.marginBottom));
+ if (cs.display === 'grid') grid++; else if (cs.display.includes('flex')) flex++;
+ if (tag === 'img') images++; else if (tag === 'svg') svgs++; else if (tag === 'video') videos++;
+ else if (tag === 'input' || tag === 'select' || tag === 'textarea') inputs++; else if (tag === 'form') forms++;
+ else if (tag === 'table') tables++; else if (tag === 'dialog') dialogs++; else if (tag === 'canvas') canvases++;
+ else if (/card|tile|panel/i.test(String(el.className || ''))) cards++;
+ if (tag === 'button' || (tag === 'input' && /^(button|submit)$/i.test(el.type)) || el.getAttribute('role') === 'button') {
+ if (buttons.length < 60) buttons.push({ text: clean(el.innerText || el.value || el.getAttribute('aria-label'), 120), tag, name: clean(el.getAttribute('aria-label') || el.getAttribute('title'), 120) || null, disabled: Boolean(el.disabled) });
+ }
+ if (tag === 'a' && links.length < 120) links.push({ text: clean(el.innerText, 100), href: clean(el.getAttribute('href'), 300) });
+ if (/^h[1-6]$/.test(tag) && headings.length < 60) headings.push({ level: Number(tag[1]), text: clean(el.innerText, 160) });
+ if (tag === 'nav' || (!navs.length && el.getAttribute('role') === 'navigation')) navs.push(tag);
+ }
+ const body = document.body;
+ const header = document.querySelector('header') || document.querySelector('nav') || document.querySelector('[role="banner"]');
+ const headerCs = header ? getComputedStyle(header) : null;
+ const menuButton = [...document.querySelectorAll('button,[role="button"]')].find((b) => /menu|navigation|nav/i.test(`${b.getAttribute('aria-label') || ''} ${b.getAttribute('aria-expanded') || ''} ${b.className || ''}`));
+ const navLinks = document.querySelectorAll('header a, nav a, [role="navigation"] a').length;
+ const sticky = Boolean(headerCs && (headerCs.position === 'fixed' || headerCs.position === 'sticky'));
+ const containers = [...document.querySelectorAll('main, [class*="container"], [class*="wrapper"], section > div')].slice(0, 200);
+ const containerWidths = new Map();
+ for (const c of containers) add(containerWidths, px(getComputedStyle(c).width));
+ const pageHeight = Math.max(document.documentElement ? document.documentElement.scrollHeight : 0, body ? body.scrollHeight : 0);
+ return {
+ url: location.href, title: document.title, lang: document.documentElement.lang || null,
+ fonts: { families: top(fonts, 8, total), sizes: top(sizes, 16, total), weights: top(weights, 6, total), line_heights: top(lines, 8, total) },
+ palette: { text: top(colours, 10, total), backgrounds: top(backgrounds, 10, total), borders: top(borders, 8, total) },
+ spacing: top(pads, 16, total * 2), margins: top(margins, 12, total * 2), gaps: top(gaps, 10, total), radii: top(radii, 10, total), shadows: top(shadows, 8, total),
+ grid: { grid_elements: grid, flex_elements: flex, container_widths: top(containerWidths, 8, containers.length), page_height: pageHeight, viewport_width: innerWidth, viewport_height: innerHeight },
+ navigation: { header: Boolean(header), position: headerCs ? headerCs.position : null, sticky, links: navLinks, menu_button: Boolean(menuButton), menu_label: menuButton ? clean(menuButton.getAttribute('aria-label') || menuButton.innerText, 80) : null, items: [...document.querySelectorAll('header a, nav a, [role="navigation"] a')].slice(0, 20).map((a) => clean(a.innerText, 60)) },
+ outline: headings,
+ buttons, links,
+ components: { images, svgs, videos, inputs, forms, tables, dialogs, cards, canvases, navs: document.querySelectorAll('nav').length, sections: document.querySelectorAll('section').length },
+ words: { h1: clean((document.querySelector('h1') || {}).innerText, 300), title: clean(document.title, 200), description: clean((document.querySelector('meta[name="description"]') || {}).content, 300), buttons: buttons.slice(0, 30).map((b) => b.text).filter(Boolean), links: links.slice(0, 40).map((l) => l.text).filter(Boolean) },
+ meta: { viewport: clean((document.querySelector('meta[name="viewport"]') || {}).content, 200), theme_color: clean((document.querySelector('meta[name="theme-color"]') || {}).content, 40), generator: clean((document.querySelector('meta[name="generator"]') || {}).content, 100) },
+ };
+}
+
+/** The Expert measurements (the metrics script's readings plus the navigation timing). */
+function readMetrics() {
+ const m = window.__ovMetrics || {};
+ const nav = performance.getEntriesByType('navigation')[0];
+ const round = (n) => (Number.isFinite(n) ? Math.round(n) : null);
+ return {
+ lcp_ms: m.lcp ? m.lcp.ms : null, lcp_element: m.lcp ? m.lcp.element : null,
+ cls: Math.round((m.cls || 0) * 1000) / 1000, inp_ms: m.inp ? m.inp.ms : null,
+ long_tasks: m.long_tasks || 0, poor_interactions: m.poor_interactions || 0,
+ dom_content_loaded_ms: nav ? round(nav.domContentLoadedEventEnd) : null,
+ load_ms: nav ? round(nav.loadEventEnd) : null,
+ transfer_bytes: nav ? round(nav.transferSize) : null,
+ };
+}
+
+module.exports = { METRICS_INIT, collectDom, readMetrics };
new file mode 100644
new file mode 100644
@@ -0,0 +1,99 @@
@@ -0,0 +1,99 @@
+'use strict';
+/**
+ * The capture worker's front door: robots, the address guard, one browser at a time and a queue cap per person.
+ *
+ * A deep dive asks for one URL (both viewports, light and dark where supported, Expert extras on request); this
+ * module checks robots.txt first (a disallowed page is skipped and the report says so), checks that the URL is a
+ * public https address before a browser exists, waits its turn (one browser at a time: the host has about 5 GB
+ * free) and a short gap when the same host is asked twice, then hands it to server/capture/browser.js. The 7-day
+ * cache lives beside the run (server/deep-dive/store.js), so a cache hit never starts Chrome.
+ */
+const path = require('path');
+const { createBrowserEngine } = require('./browser');
+const { createRobots } = require('./robots');
+const { publicTarget } = require('../search/transport');
+
+const hostOf = (u) => { try { return new URL(u).hostname.toLowerCase(); } catch { return null; } };
+const cleanUrl = (u, { upgrade = true } = {}) => {
+ try {
+ const x = new URL(String(u));
+ x.hash = '';
+ if (upgrade && x.protocol === 'http:') x.protocol = 'https:';
+ return x.href;
+ } catch { return null; }
+};
+const cacheKey = (url, viewport, theme, extras) => `${url}|${viewport}|${theme}|${extras ? 1 : 0}`;
+
+function createCapture({ config = {}, env = process.env, clock = { now: () => Date.now() }, log = console, engine = null, robots = null, upload = null, allowLocal = false } = {}) {
+ const cfg = config.capture || {};
+ const chrome = cfg.chrome || '/usr/bin/google-chrome';
+ const browser = engine || createBrowserEngine({ config, log, allowLocal });
+ const robot = robots || createRobots({ now: () => clock.now(), log });
+ const chromeOk = () => {
+ try { require('fs').accessSync(chrome, require('fs').constants.X_OK); return true; } catch { return false; }
+ };
+ const drill = /^(1|true|yes|on)$/i.test(String(process.env.AI_DRILL || process.env.LIVE_DRILL || ''));
+ const enabled = config.deepDive ? config.deepDive.enabled !== false && !drill : true;
+
+ // One browser at a time: a promise chain, with how many calls each owner is waiting in.
+ const waiting = new Map();
+ let tail = Promise.resolve();
+ const hostAt = new Map();
+
+ function available() {
+ return enabled && chromeOk();
+ }
+
+ async function gapFor(url) {
+ const host = hostOf(url);
+ if (!host) return;
+ const last = hostAt.get(host) || 0;
+ const wait = Math.max(0, (cfg.hostDelayMs || 0) - (clock.now() - last));
+ if (wait) await new Promise((r) => { const t = setTimeout(r, wait); t.unref?.(); });
+ hostAt.set(host, clock.now());
+ }
+
+ /**
+ * Capture one URL. Returns { url, title, final_url, combos, warnings, ms, skipped, reason }.
+ * skipped: robots_disallowed | not_public | no_chrome | drill | busy.
+ */
+ async function captureSite({ url, runId, owner, extras = false, signal = null, upload: uploadFn = upload, log: logFn = log } = {}) {
+ const target = cleanUrl(url, { upgrade: !allowLocal });
+ if (!target) return { url: String(url || ''), combos: [], warnings: ['not a valid URL'], skipped: 'not_public', reason: 'not a valid http(s) URL', ms: 0 };
+ if (!enabled) return { url: target, combos: [], warnings: ['capture is off under a drill'], skipped: 'drill', reason: 'capture is off under a drill', ms: 0 };
+ if (!chromeOk()) return { url: target, combos: [], warnings: ['headless Chrome is not installed'], skipped: 'no_chrome', reason: 'headless Chrome is not installed on this host', ms: 0 };
+ if (!uploadFn) return { url: target, combos: [], warnings: ['Media is not configured'], skipped: 'no_media', reason: 'screenshots have nowhere private to go', ms: 0 };
+ try {
+ if (!allowLocal) await publicTarget(target);
+ } catch (err) {
+ return { url: target, combos: [], warnings: [String(err && err.message || err)], skipped: 'not_public', reason: `refused: ${String(err && err.message || err).slice(0, 160)}`, ms: 0 };
+ }
+ const allowed = await robot.check(target);
+ if (!allowed.allowed) return { url: target, combos: [], warnings: [allowed.reason], skipped: 'robots_disallowed', reason: allowed.reason, ms: 0 };
+
+ const who = owner || 'anonymous';
+ const mine = waiting.get(who) || 0;
+ if (mine >= (cfg.maxPerOwner || 3)) {
+ return { url: target, combos: [], warnings: ['this person already has captures waiting'], skipped: 'busy', reason: 'too many captures waiting for this person; try again shortly', ms: 0 };
+ }
+ waiting.set(who, mine + 1);
+ const run = tail.then(async () => {
+ if (signal && signal.aborted) throw Object.assign(new Error('cancelled'), { code: 'run.cancelled' });
+ await gapFor(target);
+ if (signal && signal.aborted) throw Object.assign(new Error('cancelled'), { code: 'run.cancelled' });
+ const result = await browser.captureSite({ url: target, runId, owner, upload: uploadFn, signal, extras, log: logFn });
+ return { url: target, skipped: null, reason: null, ...result };
+ });
+ tail = run.catch(() => {});
+ try {
+ return await run;
+ } finally {
+ const left = (waiting.get(who) || 1) - 1;
+ if (left > 0) waiting.set(who, left); else waiting.delete(who);
+ }
+ }
+
+ return { captureSite, available, enabled: () => enabled, cacheKey, cleanUrl, chrome };
+}
+
+module.exports = { createCapture, cacheKey, cleanUrl };
new file mode 100644
new file mode 100644
@@ -0,0 +1,111 @@
@@ -0,0 +1,111 @@
+'use strict';
+/**
+ * The egress guard the browser itself goes through.
+ *
+ * Node fetches are judged by server/providers/guarded-fetch.js; a headless Chrome would otherwise resolve a
+ * hostname itself, so a public URL that redirects to 127.0.0.1, 10.0.0.1 or a metadata address would be read
+ * inside our network. Every browser request (including every redirect and WebSocket) is sent through this
+ * loopback HTTP proxy instead, which resolves the destination with the same `safeLookup` rule the rest of AI
+ * uses: every answer must be public unicast, and the connection is made to the address that was checked, so a
+ * DNS answer cannot change between the check and the connect (no rebinding). Only http/https on 80/443 is
+ * tunnelled; an IP literal, a credentials-bearing URL, a private answer or any other port is refused.
+ */
+const http = require('http');
+const net = require('net');
+const { safeLookup } = require('../fetcher');
+
+const PORTS = new Set([80, 443]);
+
+function refuse(socket, status = 403, why = 'refused') {
+ try { socket.end(`HTTP/1.1 ${status} ${why}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`); } catch { /* closed */ }
+}
+
+/** host:port from a CONNECT line or an absolute URL; null when it is not a plain http(s) origin on 80/443. */
+function targetOf(authority, protocol = 'https:', allowLocal = false) {
+ const raw = String(authority || '').trim();
+ let host; let port;
+ if (raw.startsWith('[')) {
+ const end = raw.indexOf(']');
+ host = raw.slice(1, end); port = raw.slice(end + 1).replace(/^:/, '') || (protocol === 'https:' ? '443' : '80');
+ } else {
+ const i = raw.lastIndexOf(':');
+ host = i > 0 ? raw.slice(0, i) : raw; port = i > 0 ? raw.slice(i + 1) : (protocol === 'https:' ? '443' : '80');
+ }
+ if (!host || host.length > 253 || /[^a-z0-9._:-]/i.test(host)) return null;
+ if (net.isIP(host) && !(allowLocal && (host === '127.0.0.1' || host === '::1'))) return null;
+ const n = Number(port);
+ if (!Number.isInteger(n)) return null;
+ if (!PORTS.has(n) && !(allowLocal && (host === '127.0.0.1' || host === 'localhost' || host === '::1'))) return null;
+ return { host, port: n };
+}
+
+function createGuardProxy({ log = console, allowLocal = false } = {}) {
+ let server = null;
+ let listening = null;
+ // Production resolves every answer and refuses a non-public one. A test fixture lives on loopback, so the
+ // capture test opts in explicitly (createGuardProxy({ allowLocal: true })); nothing outside /test passes it.
+ const localHost = (h) => h === '127.0.0.1' || h === 'localhost' || h === '::1';
+ const lookup = (host, cb) => (allowLocal && localHost(host)
+ ? cb(null, [{ address: '127.0.0.1', family: 4 }])
+ : safeLookup(host, { all: true }, cb));
+
+ const server_ = http.createServer((req, res) => {
+ // Plain http: the browser sends an absolute URL in the request line.
+ let u;
+ try { u = new URL(req.url); } catch { res.writeHead(400); return res.end(); }
+ const t = u.protocol === 'http:' ? targetOf(u.host, 'http:', allowLocal) : null;
+ if (!t) { res.writeHead(403); return res.end(); }
+ lookup(t.host, (err, addrs) => {
+ if (err || !Array.isArray(addrs) || !addrs.length) { log.warn(`[capture] proxy refused ${t.host}: ${err ? err.message : 'no address'}`); res.writeHead(403); return res.end(); }
+ const headers = { ...req.headers, host: t.host + (t.port === 80 ? '' : `:${t.port}`) };
+ delete headers['proxy-connection'];
+ const up = http.request({ host: addrs[0].address, port: t.port, method: req.method, path: `${u.pathname}${u.search || ''}`, headers }, (upRes) => {
+ res.writeHead(upRes.statusCode || 502, upRes.headers);
+ upRes.pipe(res);
+ });
+ up.on('error', () => { try { res.writeHead(502); res.end(); } catch { /* closed */ } });
+ res.on('close', () => up.destroy());
+ req.pipe(up);
+ });
+ });
+
+ server_.on('connect', (req, socket, head) => {
+ const t = targetOf(req.url, 'https:', allowLocal);
+ if (!t) return refuse(socket, 403, 'Forbidden');
+ socket.on('error', () => { /* the browser hung up */ });
+ lookup(t.host, (err, addrs) => {
+ if (err || !Array.isArray(addrs) || !addrs.length) {
+ log.warn(`[capture] proxy refused ${t.host}: ${String((err && err.message) || 'no address').slice(0, 160)}`);
+ return refuse(socket);
+ }
+ const up = net.connect(t.port, addrs[0].address, () => {
+ socket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
+ if (head && head.length) up.write(head);
+ socket.pipe(up); up.pipe(socket);
+ });
+ up.on('error', () => refuse(socket, 502, 'Bad Gateway'));
+ socket.on('close', () => up.destroy());
+ up.on('close', () => socket.destroy());
+ });
+ });
+ server_.on('clientError', (_e, socket) => { try { socket.destroy(); } catch { /* closed */ } });
+
+ function start() {
+ if (listening) return listening;
+ listening = new Promise((resolve, reject) => {
+ server_.once('error', reject);
+ server_.listen(0, '127.0.0.1', () => {
+ server = server_.address();
+ resolve({ url: `http://127.0.0.1:${server.port}`, close });
+ });
+ });
+ return listening;
+ }
+ function close() {
+ if (listening) { try { server_.close(); } catch { /* closed */ } }
+ listening = null;
+ }
+ return { start, close, url: () => (server ? `http://127.0.0.1:${server.port}` : null) };
+}
+
+module.exports = { createGuardProxy, targetOf };
new file mode 100644
new file mode 100644
@@ -0,0 +1,105 @@
@@ -0,0 +1,105 @@
+'use strict';
+/**
+ * robots.txt, respected before a browser is started.
+ *
+ * The group is the one for our own user agent (`OpenVibeDeepDive`) when a site names it, else `*`; the longest
+ * matching Allow/Disallow rule wins, `*` and `$` wildcards included (RFC 9309's matching, the same rule Google
+ * publishes). A 404/410 means no robots at all (allowed); a 401/403 or a 5xx means the site does not want
+ * robots reading it, so the page is skipped and the report says so. The answer is cached per origin for an hour,
+ * and the fetch goes through the same public-address guard as everything else.
+ */
+const { guardedFetch } = require('../providers/guarded-fetch');
+const { publicTarget } = require('../search/transport');
+
+const UA = 'OpenVibeDeepDive';
+const TTL_MS = 60 * 60 * 1000;
+
+function parse(text) {
+ const groups = [];
+ let current = null;
+ let inAgents = false;
+ for (const raw of String(text || '').split(/\r?\n/)) {
+ const line = raw.replace(/#.*$/, '').trim();
+ if (!line) continue;
+ const i = line.indexOf(':');
+ if (i < 0) continue;
+ const key = line.slice(0, i).trim().toLowerCase();
+ const value = line.slice(i + 1).trim();
+ if (key === 'user-agent') {
+ if (!inAgents || !current) { current = { agents: [], rules: [] }; groups.push(current); }
+ current.agents.push(value.toLowerCase());
+ inAgents = true;
+ } else if (key === 'allow' || key === 'disallow') {
+ if (!current) continue;
+ current.rules.push({ allow: key === 'allow', path: value });
+ inAgents = false;
+ } else if (key === 'sitemap') {
+ inAgents = false;
+ }
+ }
+ return groups;
+}
+
+function patternRe(path) {
+ const anchored = path.endsWith('$');
+ const body = (anchored ? path.slice(0, -1) : path).split('*').map((s) => s.replace(/[.+?^${}()|[\]\\]/g, '\\$&')).join('.*');
+ return new RegExp(`^${body}${anchored ? '$' : ''}`);
+}
+
+function allowedBy(groups, path, ua = UA) {
+ const mine = groups.filter((g) => g.agents.some((a) => a && (a === ua.toLowerCase() || a === '*')));
+ const specific = mine.filter((g) => g.agents.some((a) => a !== '*'));
+ const use = specific.length ? specific : mine;
+ if (!use.length) return true;
+ let best = null;
+ for (const g of use) {
+ for (const r of g.rules) {
+ if (!r.path) continue; // an empty Disallow is no rule
+ if (!patternRe(r.path).test(path)) continue;
+ if (!best || r.path.length > best.path.length || (r.path.length === best.path.length && r.allow)) best = r;
+ }
+ }
+ return best ? best.allow : true;
+}
+
+function createRobots({ fetchImpl = guardedFetch, now = () => Date.now(), log = console, timeoutMs = 8000 } = {}) {
+ const cache = new Map(); // origin -> { at, robots }
+
+ async function load(origin) {
+ const hit = cache.get(origin);
+ if (hit && now() - hit.at < TTL_MS) return hit.robots;
+ const url = `${origin}/robots.txt`;
+ let robots = { exists: false, groups: [] };
+ try {
+ await publicTarget(url);
+ const res = await fetchImpl(url, { redirect: 'error', signal: AbortSignal.timeout(timeoutMs), headers: { 'user-agent': `${UA}/1.0 (+https://openvibe.exchange/deep-dive)`, accept: 'text/plain' } });
+ if (res.status === 401 || res.status === 403) robots = { exists: true, groups: [{ agents: ['*'], rules: [{ allow: false, path: '/' }] }] };
+ else if (res.status === 404 || res.status === 410) robots = { exists: false, groups: [] };
+ else if (res.status >= 500) robots = { exists: true, groups: [{ agents: ['*'], rules: [{ allow: false, path: '/' }] }] };
+ else if (res.ok) robots = { exists: true, groups: parse(await res.text()) };
+ } catch (err) {
+ // A robots.txt that cannot be read is treated as no robots: the page itself still goes through the
+ // address guard, and a site that is down is reported as a skipped capture, not a silent read.
+ log.warn(`[capture] robots ${origin}: ${String((err && err.message) || err).slice(0, 160)}`);
+ robots = { exists: false, groups: [] };
+ }
+ cache.set(origin, { at: now(), robots });
+ if (cache.size > 500) cache.delete(cache.keys().next().value);
+ return robots;
+ }
+
+ /** { allowed, reason } for a URL; reason is what the report shows when it is not. */
+ async function check(url) {
+ let u;
+ try { u = new URL(String(url)); } catch { return { allowed: false, reason: 'not a valid URL' }; }
+ if (!/^https?:$/.test(u.protocol)) return { allowed: false, reason: 'not an http(s) URL' };
+ const robots = await load(u.origin);
+ if (!robots.groups.length) return { allowed: true, reason: null };
+ const path = `${u.pathname}${u.search || ''}` || '/';
+ return allowedBy(robots.groups, path) ? { allowed: true, reason: null } : { allowed: false, reason: 'robots.txt disallows this page' };
+ }
+
+ return { check, parse, allowedBy, clear: () => cache.clear() };
+}
+
+module.exports = { createRobots, parse, allowedBy, UA };
new file mode 100644
new file mode 100644
@@ -0,0 +1,92 @@
@@ -0,0 +1,92 @@
+'use strict';
+/**
+ * The capture worker process (systemd: openvibe-ai-capture.service, MemoryMax=1500M).
+ *
+ * AI_CAPTURE_PORT=4710 AI_CAPTURE_HOST=127.0.0.1 node server/capture/worker.js
+ *
+ * It speaks JSON on loopback only: POST /capture { url, runId, owner, extras } -> the capture (Media ids, DOM
+ * facts, Expert extras); GET /health -> { status, chrome, enabled }. OpenVibe.AI calls it when AI_CAPTURE_URL is
+ * set, so the browser lives in a process of its own: a page that eats memory is killed by MemoryMax without
+ * taking the API down, and one deploy of AI does not need a browser running in it. No sign-in, no cookie kept,
+ * every URL through the egress guard, robots.txt respected, one browser at a time.
+ *
+ * It is off under a restore drill (AI_DRILL/LIVE_DRILL): /capture answers 503 capture.drill and no Chrome starts.
+ * AI_CAPTURE_TOKEN, when set, must match the `x-ov-capture-token` header; it is read from the environment and
+ * never written into a request or a log.
+ */
+const http = require('http');
+const { load } = require('../config');
+const { createMediaStore } = require('../media-store');
+const { createCapture } = require('./index');
+
+function json(res, status, body) {
+ const text = JSON.stringify(body);
+ res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store', 'Content-Length': Buffer.byteLength(text) });
+ res.end(text);
+}
+
+function createCaptureWorker({ config, env = process.env, log = console, capture = null, media = null } = {}) {
+ const mediaStore = media || createMediaStore({ config, env, log });
+ const cap = capture || createCapture({ config, env, log, upload: (o) => mediaStore.put({ ...o, visibility: 'private' }) });
+ const token = String(env.AI_CAPTURE_TOKEN || '');
+ const host = env.AI_CAPTURE_HOST || '127.0.0.1';
+ const port = Math.max(1, Number(env.AI_CAPTURE_PORT) || 4710);
+ let server = null;
+
+ const handler = async (req, res) => {
+ if (req.method === 'GET' && req.url === '/health') return json(res, 200, { status: 'ok', service: 'openvibe-ai-capture', chrome: cap.available(), enabled: cap.enabled(), budget_ms: config.capture.pageBudgetMs });
+ if (req.method !== 'POST' || req.url !== '/capture') return json(res, 404, { code: 'capture.not_found', detail: 'POST /capture or GET /health' });
+ if (token && req.headers['x-ov-capture-token'] !== token) return json(res, 401, { code: 'capture.unauthorized', detail: 'x-ov-capture-token does not match' });
+ if (!cap.enabled()) return json(res, 503, { code: 'capture.drill', detail: 'capture is off under a restore drill' });
+ let raw = '';
+ for await (const chunk of req) {
+ raw += chunk;
+ if (raw.length > 65536) return json(res, 413, { code: 'capture.too_large', detail: 'capture body is larger than 64 KB' });
+ }
+ let body;
+ try { body = JSON.parse(raw || '{}'); } catch { return json(res, 422, { code: 'input.invalid', detail: 'JSON body required' }); }
+ if (!body || typeof body.url !== 'string' || !body.url) return json(res, 422, { code: 'input.invalid', detail: 'url is required' });
+ if (body.owner != null && !/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(body.owner))) return json(res, 422, { code: 'input.invalid', detail: 'owner must be a user subject id' });
+ if (body.runId != null && !/^run_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(body.runId))) return json(res, 422, { code: 'input.invalid', detail: 'runId must be a run id' });
+ try {
+ const out = await cap.captureSite({ url: body.url, runId: body.runId || null, owner: body.owner || null, extras: body.extras === true });
+ return json(res, out.skipped && out.skipped !== 'cancelled' ? 200 : 200, out);
+ } catch (err) {
+ log.error(`[capture] ${String(err && err.stack || err).slice(0, 500)}`);
+ return json(res, err && err.code === 'run.cancelled' ? 409 : 502, { code: (err && err.code) || 'capture.failed', detail: String((err && err.message) || err).slice(0, 300) });
+ }
+ };
+
+ function start() {
+ return new Promise((resolve, reject) => {
+ server = http.createServer((req, res) => { handler(req, res).catch((err) => json(res, 500, { code: 'capture.internal', detail: String((err && err.message) || err).slice(0, 200) })); });
+ server.once('error', reject);
+ server.listen(port, host, () => {
+ log.log(`[capture] listening on http://${host}:${port} (chrome ${cap.available() ? 'ready' : 'missing'})`);
+ resolve(server);
+ });
+ });
+ }
+
+ function close() {
+ return new Promise((resolve) => { if (!server) return resolve(); server.closeAllConnections?.(); server.close(() => resolve()); });
+ }
+
+ return { start, close, handler, capture: cap };
+}
+
+/** The process entry point (systemd ExecStart). */
+async function main() {
+ const config = load(process.env);
+ const worker = createCaptureWorker({ config, env: process.env, log: console });
+ const server = await worker.start();
+ const stop = async () => { await worker.close(); process.exit(0); };
+ process.on('SIGTERM', stop);
+ process.on('SIGINT', stop);
+ process.on('unhandledRejection', (err) => console.error('[capture] unhandled rejection:', err));
+ return { worker, server };
+}
+
+if (require.main === module) main().catch((err) => { console.error('[capture] failed to start:', err && err.stack || err); process.exit(1); });
+
+module.exports = { createCaptureWorker, main };
@@ -366,6 +366,44 @@ function load(env = process.env) {
@@ -366,6 +366,44 @@ function load(env = process.env) {
// Aggregate demand for Host's inference planner (GET /api/v1/usage/series): off by default;
// Aggregate demand for Host's inference planner (GET /api/v1/usage/series): off by default;
// the capability gate (ai.usage.read) is still required when it is on.
// the capability gate (ai.usage.read) is still required when it is on.
usageSeries: { enabled: bool(env.AI_USAGE_SERIES, false) },
usageSeries: { enabled: bool(env.AI_USAGE_SERIES, false) },
+
+ // ── Deep dive (the owner's 11-11 design research; server/deep-dive/ and server/capture/) ──
+ // A durable run (workflow design.deep_dive) that searches every source, captures candidate sites in headless
+ // Chrome (the capture worker, server/capture/worker.js), reads the shots with a vision model and writes an
+ // ai.deep-dive@1 report. The prices are estimates shown before the run and held on the tier budgets; the
+ // real cost is on the run's receipt. AI_DRILL/LIVE_DRILL turns capture off, so a restore drill touches no site.
+ deepDive: {
+ enabled: bool(env.AI_DEEP_DIVE, !bool(env.AI_DRILL, false) && !bool(env.LIVE_DRILL, false)),
+ priceUsd: {
+ quick: Math.max(0, float(env.AI_DEEP_DIVE_PRICE_QUICK, 0.2)),
+ thorough: Math.max(0, float(env.AI_DEEP_DIVE_PRICE_THOROUGH, 0.5)),
+ expert: Math.max(0, float(env.AI_DEEP_DIVE_PRICE_EXPERT, 1.2)),
+ },
+ // How long Thorough and Expert wait after choosing for a person to add or drop sites (Quick goes on).
+ chooseWaitSec: Math.max(0, Math.min(3600, int(env.AI_DEEP_DIVE_CHOOSE_WAIT_SEC, 300))),
+ // The design rules register and component registry the synthesis checks every recommendation against.
+ designUrl: trimUrl(env.AI_DESIGN_URL || 'https://openvibe.design'),
+ rulesTimeoutMs: Math.max(1000, Math.min(30000, int(env.AI_DEEP_DIVE_RULES_TIMEOUT_MS, 8000))),
+ // How many sites each depth captures and reads.
+ maxSites: { quick: 6, thorough: 15, expert: 30 },
+ maxSources: { quick: 40, thorough: 80, expert: 140 },
+ analyseViewports: { quick: 1, thorough: 1, expert: 2 },
+ maxOutputTokens: Math.max(400, Math.min(8000, int(env.AI_DEEP_DIVE_MAX_OUTPUT_TOKENS, 3000))),
+ },
+ // The capture worker (server/capture/): headless Chrome through playwright-core, one browser at a time.
+ // AI_CAPTURE_URL points at the worker's loopback unit; unset runs the same capture in this process (tests).
+ capture: {
+ url: trimUrl(env.AI_CAPTURE_URL || ''),
+ chrome: env.AI_CAPTURE_CHROME || '/usr/bin/google-chrome',
+ timeoutMs: Math.max(5000, Math.min(600000, int(env.AI_CAPTURE_TIMEOUT_MS, 300000))),
+ pageBudgetMs: Math.max(5000, Math.min(120000, int(env.AI_CAPTURE_PAGE_BUDGET_MS, 45000))),
+ hostDelayMs: Math.max(0, Math.min(60000, int(env.AI_CAPTURE_HOST_DELAY_MS, 3000))),
+ maxPerOwner: Math.max(1, Math.min(20, int(env.AI_CAPTURE_QUEUE_PER_OWNER, 3))),
+ cacheDays: Math.max(0, Math.min(30, int(env.AI_CAPTURE_CACHE_DAYS, 7))),
+ maxHeight: Math.max(1000, Math.min(16000, int(env.AI_CAPTURE_MAX_HEIGHT, 12000))),
+ // A cap on sites one run may capture (a mis-typed depth cannot spend past the price it held).
+ maxCaptures: Math.max(1, Math.min(30, int(env.AI_CAPTURE_MAX_SITES, 30))),
+ },
// Compiled JSON Schema validators (server/schemas.js): an LRU shared by every caller, some of
// Compiled JSON Schema validators (server/schemas.js): an LRU shared by every caller, some of
// whose schemas are caller-supplied, so it is bounded by count and by total schema size.
// whose schemas are caller-supplied, so it is bounded by count and by total schema size.
schemaCache: {
schemaCache: {
new file mode 100644
new file mode 100644
@@ -0,0 +1,421 @@
@@ -0,0 +1,421 @@
+'use strict';
+/**
+ * The deep dive: one durable AI run (workflow design.deep_dive) with seven steps.
+ *
+ * plan a model writes the questions an expert would ask and the searches that answer them
+ * search MegaSource, AI search's web/picture/icon engines, GitHub and Design's rules + component registers
+ * choose a model ranks the candidate sites with a reason each; a person may add or drop any before capture
+ * (Quick goes straight on; Thorough and Expert wait up to AI_DEEP_DIVE_CHOOSE_WAIT_SEC)
+ * capture every chosen site at 390 and 1280 px, light and (when supported) dark, full page after a slow
+ * scroll, with the DOM facts and, at Expert depth, axe-core, LCP/CLS/INP, menus, hover/focus and a
+ * frame strip; every shot is a private Media object of the run's owner; a 7-day cache per viewport
+ * and theme; robots.txt respected
+ * analyse a text model reads each site's DOM facts, a vision model reads each full-page screenshot; crops are
+ * the evidence for each point
+ * synthesise patterns across sites, a comparison, the best version, what everyone gets wrong, the gap,
+ * reference tokens, do/don't, accessibility and speed, and recommendations checked against our rules
+ * report ai.deep-dive@1 assembled from what was actually read and captured, with the receipt of every model
+ * call and capture
+ *
+ * Cancelling the run aborts the provider calls and every later capture (the runs API's existing cancel). The price
+ * shown before the run is held on the tier budgets by the API route; the real cost is the receipt.
+ */
+const { AiError } = require('../util');
+const schemas = require('../schemas');
+const events = require('../events');
+const { createDeepDiveStore } = require('./store');
+const { createLlm } = require('./llm');
+const { createSourceGatherer } = require('./sources');
+const { DEPTHS, STEPS, REPORT, PLAN_OUTPUT, CHOOSE_OUTPUT, ANALYSIS_OUTPUT, SYNTHESIS_OUTPUT } = require('./schema');
+const prompts = require('./prompts');
+
+const iso = (v) => (v ? new Date(v).toISOString() : null);
+const oneLine = (s, n) => String(s == null ? '' : s).replace(/\s+/g, ' ').trim().slice(0, n);
+const hostOf = (u) => { try { return new URL(u).hostname.replace(/^www\./, ''); } catch { return null; } };
+const uniq = (list, n = 12) => [...new Set((list || []).map((x) => oneLine(x, 600)).filter(Boolean))].slice(0, n);
+
+function createDeepDive({ config = {}, db, clock = { now: () => Date.now() }, log = console, registry, pool, research = null, search = null, capture = null, mediaStore = null, fetcher = null, sourceFetch = null } = {}) {
+ const cfg = config.deepDive || {};
+ const ccfg = config.capture || {};
+ const store = createDeepDiveStore({ db, clock, log });
+ const llm = createLlm({ registry, pool, config, log, clock });
+ const sources = createSourceGatherer({ config, log, research, search, now: () => clock.now(), ...(sourceFetch ? { fetchImpl: sourceFetch } : {}) });
+ const prices = cfg.priceUsd || { quick: 0.2, thorough: 0.5, expert: 1.2 };
+ const maxSites = cfg.maxSites || { quick: 6, thorough: 15, expert: 30 };
+ const maxSources = cfg.maxSources || { quick: 40, thorough: 80, expert: 140 };
+ const maxOut = cfg.maxOutputTokens || 3000;
+
+ function depths() {
+ return [
+ { key: 'quick', label: 'Quick', sites: maxSites.quick, price_usd: prices.quick, minutes: 3, analysis: 'captures and reads each site; no Expert extras' },
+ { key: 'thorough', label: 'Thorough', sites: maxSites.thorough, price_usd: prices.thorough, minutes: 8, analysis: 'more sites, the same captures and analysis' },
+ { key: 'expert', label: 'Expert', sites: maxSites.expert, price_usd: prices.expert, minutes: 18, analysis: 'plus interactions, accessibility and speed' },
+ ];
+ }
+
+ function priceOf(depth) { return Number(prices[depth]) || 0; }
+ function limitOf(depth) { return Math.min(Number(maxSites[depth]) || 6, ccfg.maxCaptures || 30); }
+
+ async function emit(row, { step = null, status = null, error = null } = {}) {
+ try {
+ await events.deepDiveUpdated({
+ runId: row.run_id || row.id, deepDiveId: row.id, status: status || row.status, step: step == null ? row.step : step,
+ progress: row.progress, steps: row.steps, depth: row.depth, requester: { type: run.requester_type || 'service', id: run.requester_id || 'ai' },
+ on_behalf_of: row.owner || null, url: `https://openvibe.exchange/deep-dive/${row.run_id || row.id}`, error,
+ });
+ } catch (err) { log.warn(`[deep-dive] event failed: ${String((err && err.message) || err).slice(0, 160)}`); }
+ }
+
+ async function stepsWith(row, updates = {}) {
+ const out = (row.steps && row.steps.length ? row.steps : STEPS.map((key) => ({ key, status: 'pending' }))).map((s) => ({ ...s, ...(updates[s.key] || {}) }));
+ return out;
+ }
+
+ async function startStep(row, key, detail, progress) {
+ const steps = await stepsWith(row, { [key]: { status: 'running', detail: detail || null, started_at: iso(clock.now()) } });
+ const next = await store.patch(row.id, { steps, step: key, progress, status: row.status === 'queued' ? 'running' : row.status });
+ if (next) await emit(next, { step: key, status: next.status });
+ return next;
+ }
+
+ async function finishStep(row, key, progress, detail = null) {
+ const steps = await stepsWith(row, { [key]: { status: 'done', detail: detail || null, finished_at: iso(clock.now()) } });
+ const next = await store.patch(row.id, { steps, step: key, progress });
+ if (next) await emit(next, { step: key, status: next.status });
+ return next;
+ }
+
+ /** The fallback choice when the model returns nothing usable: named sites, then web/project pages. */
+ function fallbackCandidates(list, limit) {
+ const seen = new Set();
+ const out = [];
+ for (const s of list) {
+ if (s.kind === 'rules' || s.kind === 'component' || s.kind === 'picture' || s.kind === 'icon') continue;
+ if (seen.has(s.url)) continue;
+ seen.add(s.url);
+ out.push({ url: s.url, title: s.title, source_id: s.id, reason: s.kind === 'named' ? 'a site the person named' : 'an expert read from the search' });
+ if (out.length >= limit) break;
+ }
+ return out;
+ }
+
+ function evidenceOf(item, sites, capturesBySite) {
+ if (!item) return [];
+ const site = sites[(Number(item.site) || 1) - 1] || sites[0];
+ if (!site) return [];
+ const caps = capturesBySite.get(site.source_id) || [];
+ const cap = item.capture ? caps[(Number(item.capture) || 1) - 1] : caps.find((c) => c.theme === 'light' && c.viewport === 1280) || caps[0];
+ return [{ claim: oneLine(item.claim || 'observed on the site', 600), source_id: site.source_id, capture_id: cap ? cap.id : null, crop_id: cap && cap.crops && cap.crops[0] ? cap.crops[0].id : null, url: site.url, read_at: cap ? cap.captured_at : iso(clock.now()) }];
+ }
+
+ /** Run one deep dive for one run row. Returns { output, usage, cost, models } for the workflow step. */
+ async function run({ run, input, ctx = {} }) {
+ const question = oneLine(input.question, 500);
+ const depth = input.depth;
+ const siteLimit = limitOf(depth);
+ const price = priceOf(depth);
+ const owner = run && run.on_behalf_of ? (typeof run.on_behalf_of === 'string' ? JSON.parse(run.on_behalf_of) : run.on_behalf_of) : null;
+ const runId = run.id;
+ let row = await store.ensure({ runId, question, depth, siteLimit, priceUsd: price, owner });
+ if (!row) throw new AiError(500, 'deep_dive.not_stored', 'the deep dive row could not be written');
+ const models = [];
+ const gaps = [];
+ const warnings = [];
+ let usage = { input: 0, output: 0 };
+ let cost = 0;
+ const addCall = (step, r) => {
+ if (!r) return;
+ models.push({ step, provider: r.provider, model: r.model, tokens_in: r.tokens_in || 0, tokens_out: r.tokens_out || 0, cost_usd: Number(r.cost_usd) || 0, ms: r.ms || 0 });
+ usage = { input: usage.input + (r.tokens_in || 0), output: usage.output + (r.tokens_out || 0) };
+ cost += Number(r.cost_usd) || 0;
+ };
+ const check = () => { if (ctx.signal && ctx.signal.aborted) throw new AiError(409, 'run.cancelled', 'cancelled'); };
+ // The price shown before the run is the ceiling the run holds itself to (the receipt may be lower).
+ const overBudget = () => price > 0 && cost >= price;
+
+ try {
+ await store.patch(row.id, { status: 'running', step: 'plan', progress: 0.01 });
+ row = await store.get(row.id);
+ await emit(row, { step: 'plan', status: 'running' });
+
+ // 1. Plan
+ row = await startStep(row, 'plan', 'writing the expert questions', 0.03);
+ check();
+ const plan = await llm.call({ routeKey: 'default.json', ...prompts.planPrompt({ question, depth, named: input.sites || [] }), schema: PLAN_OUTPUT, schemaName: 'deep_dive_plan', maxTokens: Math.min(1200, maxOut), signal: ctx.signal, ctx });
+ addCall('plan', plan);
+ row = await finishStep(row, 'plan', 0.1, `${(plan.json.questions || []).length} questions, ${(plan.json.searches || []).length} searches`);
+
+ // 2. Search, every way we have.
+ row = await startStep(row, 'search', 'MegaSource, the search engines, GitHub and our own registers', 0.12);
+ check();
+ const gathered = await sources.gather({ question, depth, namedSites: input.sites || [], signal: ctx.signal, maxSources: maxSources[depth] || 40 });
+ const allSources = gathered.sources || [];
+ for (const w of gathered.warnings || []) warnings.push(w);
+ if (!allSources.length) throw new AiError(502, 'source.unavailable', 'no search source answered for this question');
+ row = await finishStep(row, 'search', 0.3, `${allSources.length} sources from ${new Set(allSources.map((s) => s.engine).filter(Boolean)).size} engines`);
+
+ // 3. Choose
+ row = await startStep(row, 'choose', 'ranking the sites worth capturing', 0.32);
+ check();
+ let chosen = [];
+ try {
+ const pickable = allSources.filter((s) => ['web', 'project', 'named'].includes(s.kind));
+ const choose = await llm.call({ routeKey: 'default.json', ...prompts.choosePrompt({ question, depth, limit: siteLimit, sources: pickable.slice(0, 60) }), schema: CHOOSE_OUTPUT, schemaName: 'deep_dive_choose', maxTokens: Math.min(1600, maxOut), signal: ctx.signal, ctx });
+ addCall('choose', choose);
+ for (const c of choose.json.sites || []) {
+ const url = String(c.url || '').replace(/#.*$/, '');
+ const src = pickable.find((s) => s.url.replace(/\/$/, '') === url.replace(/\/$/, ''));
+ // A model may only choose from what was read: a URL that is not in the list is not invented into one.
+ if (!src) continue;
+ chosen.push({ url: src.url, title: c.title || src.title || hostOf(src.url), reason: oneLine(c.reason, 600), source_id: src.id, kind: src.kind });
+ }
+ } catch (err) {
+ warnings.push(`choosing: ${String((err && err.message) || err).slice(0, 200)}`);
+ }
+ // The person's named sites are always candidates; the model only ranks the rest.
+ for (const s of allSources.filter((x) => x.kind === 'named')) if (!chosen.some((c) => c.url === s.url)) chosen.unshift({ url: s.url, title: s.title, reason: 'a site the person named', source_id: s.id, kind: 'named' });
+ chosen = chosen.filter((c, i) => c.url && chosen.findIndex((x) => x.url.replace(/\/$/, '') === c.url.replace(/\/$/, '')) === i).slice(0, siteLimit);
+ if (!chosen.length) chosen = fallbackCandidates(allSources, siteLimit);
+ chosen = chosen.map((c, i) => ({ ...c, id: `site_${i + 1}`, chosen: true }));
+ row = await store.patch(row.id, { candidates: chosen, status: 'choosing', step: 'choose', progress: 0.34, decision_at: depth === 'quick' ? iso(clock.now()) : null });
+ await emit(row, { step: 'choose', status: 'choosing' });
+ if (depth !== 'quick') {
+ const until = clock.now() + Math.max(0, Number(cfg.chooseWaitSec) || 0) * 1000;
+ while (clock.now() < until) {
+ check();
+ const fresh = await store.get(row.id);
+ if (fresh.decision_at) { row = fresh; break; }
+ await new Promise((r) => { const t = setTimeout(r, 1000); t.unref?.(); });
+ }
+ row = await store.get(row.id);
+ if (Array.isArray(row.candidates) && row.candidates.length) chosen = row.candidates.slice(0, siteLimit);
+ }
+ row = await finishStep(row, 'choose', 0.4, `${chosen.length} sites`);
+ row = await store.patch(row.id, { status: 'running' });
+
+ // 4. Capture
+ row = await startStep(row, 'capture', `0/${chosen.length} sites`, 0.42);
+ check();
+ const captures = [];
+ const capturesBySite = new Map();
+ const analysisSources = [];
+ for (const [i, site] of chosen.entries()) {
+ check();
+ const sourceId = site.source_id || `src_named_${i + 1}`;
+ if (!allSources.some((s) => s.id === sourceId)) allSources.push({ id: sourceId, kind: 'named', title: site.title || hostOf(site.url), url: site.url, publisher: hostOf(site.url), engine: null, snippet: null, license: null, read_at: iso(clock.now()) });
+ const siteCaps = [];
+ const extras = depth === 'expert';
+ try {
+ const cachedLight = (await store.cacheGet(site.url, 1280, 'light', extras)) && (await store.cacheGet(site.url, 390, 'light', extras));
+ if (!extras && cachedLight) {
+ for (const c of [await store.cacheGet(site.url, 390, 'light', extras), await store.cacheGet(site.url, 1280, 'light', extras)]) if (c) siteCaps.push({ ...c, cached: true });
+ } else if (capture) {
+ const got = await capture.captureSite({ url: site.url, runId, owner: owner && owner.id, extras, signal: ctx.signal });
+ if (got && got.skipped) {
+ warnings.push(`${hostOf(site.url) || site.url}: ${got.reason || got.skipped}`);
+ captures.push({ id: `cap_${captures.length + 1}`, source_id: sourceId, url: site.url, final_url: null, title: site.title || null, captured_at: iso(clock.now()), viewport: 390, theme: 'light', full_page: false, media_id: null, width: null, height: null, crops: [], dom: null, extras: null, skip: String(got.skipped === 'robots_disallowed' ? 'robots_disallowed' : got.skipped).slice(0, 200), cached: false });
+ }
+ for (const combo of (got && got.combos) || []) {
+ siteCaps.push(combo);
+ await store.cachePut(site.url, combo.viewport, combo.theme, extras, combo, ccfg.cacheDays || 7);
+ }
+ }
+ } catch (err) {
+ if (err && err.code === 'run.cancelled') throw err;
+ warnings.push(`${hostOf(site.url) || site.url}: ${String((err && err.message) || err).slice(0, 200)}`);
+ }
+ for (const combo of siteCaps) {
+ const cap = {
+ id: `cap_${captures.length + 1}`, source_id: sourceId, url: site.url, final_url: combo.final_url || site.url,
+ title: combo.title || site.title || null, captured_at: combo.captured_at || iso(clock.now()),
+ viewport: combo.viewport, theme: combo.theme, full_page: combo.full_page !== false,
+ media_id: combo.media_id || null, width: combo.width || combo.viewport || null, height: combo.height || null,
+ crops: (combo.crops || []).map((c, j) => ({ id: `crop_${capidSafe(captures.length, j)}`, media_id: c.media_id, y: c.y, height: c.height })),
+ dom: combo.dom || null, extras: combo.extras || null, skip: null, cached: Boolean(combo.cached),
+ };
+ captures.push(cap);
+ if (cap.dom) analysisSources.push({ cap, site, sourceId });
+ }
+ capturesBySite.set(sourceId, captures.filter((c) => c.source_id === sourceId));
+ try {
+ await db.prepare(`INSERT INTO requests (run_id, seq, operation, provider_key, model_key, route_key, route_version, status, skip_reason, fallback, input_hash, tokens_in, tokens_out, tokens_cached, tokens_estimated, cost_usd, latency_ms, created_at)
+ VALUES (?, ?, 'capture', 'capture', NULL, NULL, NULL, ?, ?, 0, NULL, 0, 0, 0, 0, 0, ?, ?)`)
+ .run(runId, 100000 + i, captures.filter((c) => c.source_id === sourceId).length ? 'ok' : 'skipped', captures.filter((c) => c.source_id === sourceId).length ? null : 'no capture', Date.now() - (comboMs(captures) || Date.now()), iso(clock.now())).catch(() => {});
+ } catch { /* the receipt is best effort for a capture that was refused */ }
+ row = await store.patch(row.id, { captures, progress: 0.42 + (0.28 * (i + 1)) / Math.max(1, chosen.length) });
+ row = await store.patch(row.id, { step: 'capture', steps: await stepsWith(row, { capture: { status: 'running', detail: `${i + 1}/${chosen.length} sites` } }) });
+ await emit(row, { step: 'capture' });
+ }
+ row = await finishStep(row, 'capture', 0.7, `${captures.filter((c) => !c.skip).length} captures`);
+
+ // 5. Analyse: the DOM facts by text, each full-page screenshot by a vision model.
+ row = await startStep(row, 'analyse', `0/${chosen.length} sites`, 0.72);
+ const analysis = [];
+ const analysed = new Set();
+ for (const [i, site] of chosen.entries()) {
+ check();
+ const sourceId = site.source_id || `src_named_${i + 1}`;
+ const siteCaps = capturesBySite.get(sourceId) || [];
+ const primary = siteCaps.find((c) => c.theme === 'light' && c.viewport === 1280 && c.dom) || siteCaps.find((c) => c.dom) || null;
+ const evidence = [];
+ let text = null;
+ if (primary && !overBudget()) {
+ try {
+ text = await llm.call({ routeKey: 'default.json', ...prompts.domAnalysisPrompt({ question, source: { title: site.title, url: site.url }, capture: primary, dom: primary.dom }), schema: ANALYSIS_OUTPUT, schemaName: 'deep_dive_site', maxTokens: Math.min(1800, maxOut), signal: ctx.signal, ctx });
+ addCall('analyse', text);
+ } catch (err) { warnings.push(`analysis of ${hostOf(site.url) || site.url}: ${String((err && err.message) || err).slice(0, 200)}`); }
+ }
+ // Each light full-page shot (both viewports; Expert also dark) is read by the vision model.
+ const shots = siteCaps.filter((c) => c.media_id && c.full_page && (c.theme === 'light' || depth === 'expert'));
+ let vision = null;
+ for (const [j, cap] of shots.entries()) {
+ check();
+ if (overBudget()) { gaps.push('The held price was reached before every screenshot was read; the remaining screenshots are evidence only.'); break; }
+ if (!mediaStore || typeof mediaStore.bytes !== 'function') break;
+ try {
+ const got = await mediaStore.bytes(cap.media_id, { owner: owner && owner.id, signal: ctx.signal });
+ const image = await fetcher.loadImage({ data_url: `data:${got.mime || 'image/png'};base64,${got.buffer.toString('base64')}`, max_width: cap.viewport === 390 ? 390 : 1024 });
+ const read = await llm.call({ routeKey: depth === 'expert' ? 'background.vision' : 'default.json', ...prompts.visionPrompt({ question, source: { title: site.title, url: site.url }, viewport: cap.viewport, theme: cap.theme, index: j + 1, total: shots.length }), image, schema: ANALYSIS_OUTPUT, schemaName: 'deep_dive_shot', maxTokens: Math.min(1500, maxOut), signal: ctx.signal, ctx });
+ addCall('analyse', read);
+ for (const e of (read.json.evidence || []).slice(0, 6)) evidence.push({ claim: oneLine(e.claim, 600), source_id: sourceId, capture_id: cap.id, crop_id: cap.crops && cap.crops[0] ? cap.crops[0].id : null, url: site.url, read_at: cap.captured_at });
+ if (!vision) vision = read.json;
+ else {
+ vision = {
+ ...vision,
+ summary: vision.summary || read.json.summary,
+ strengths: uniq([...(vision.strengths || []), ...(read.json.strengths || [])], 8),
+ weaknesses: uniq([...(vision.weaknesses || []), ...(read.json.weaknesses || [])], 8),
+ };
+ }
+ } catch (err) { warnings.push(`vision on ${hostOf(site.url) || site.url}: ${String((err && err.message) || err).slice(0, 160)}`); }
+ }
+ const t = (text && text.json) || {};
+ const v = vision || {};
+ let accessibility = t.accessibility || null;
+ if (primary && primary.extras) {
+ const axe = primary.extras.a11y || {};
+ const violations = Array.isArray(axe.violations) ? axe.violations : [];
+ const measured = violations.length ? `${violations.length} axe-core violations: ${violations.slice(0, 4).map((x) => `${x.id}${x.impact ? ` (${x.impact})` : ''}`).join(', ')}` : (axe.error ? `axe-core could not run: ${axe.error}` : 'no axe-core violations at WCAG 2.1 AA');
+ accessibility = measured;
+ const m = primary.extras.metrics || {};
+ if (m.lcp_ms != null || m.cls != null) {
+ const speed = `LCP ${m.lcp_ms == null ? '—' : `${m.lcp_ms} ms`}; CLS ${m.cls == null ? '—' : m.cls}; INP ${m.inp_ms == null ? 'not measured' : `${m.inp_ms} ms`}; ${m.long_tasks || 0} long tasks`;
+ t.speed = t.speed && t.speed !== 'not measured at this depth' ? `${speed}. ${t.speed}` : speed;
+ }
+ }
+ const firstCap = siteCaps.find((c) => c.media_id) || null;
+ analysis.push({
+ source_id: sourceId, url: site.url,
+ summary: oneLine(t.summary || v.summary || (primary ? 'Captured; the analysis did not answer.' : 'Not captured.'), 4000),
+ first_impression: oneLine(v.first_impression || t.first_impression || '', 2000),
+ hierarchy: oneLine(t.hierarchy || v.hierarchy || '', 2000), density: oneLine(t.density || v.density || '', 2000),
+ type: oneLine(t.type || v.type || '', 2000), colour: oneLine(t.colour || v.colour || '', 2000),
+ motion: oneLine(t.motion || v.motion || '', 2000), words: oneLine(t.words || v.words || '', 2000),
+ strengths: uniq([...(v.strengths || []), ...(t.strengths || [])], 12),
+ weaknesses: uniq([...(v.weaknesses || []), ...(t.weaknesses || [])], 12),
+ accessibility: accessibility || null, speed: t.speed || null,
+ evidence: evidence.length ? evidence.slice(0, 20) : (firstCap ? [{ claim: 'the page as captured', source_id: sourceId, capture_id: firstCap.id, crop_id: firstCap.crops && firstCap.crops[0] ? firstCap.crops[0].id : null, url: site.url, read_at: firstCap.captured_at }] : []),
+ });
+ analysed.add(sourceId);
+ row = await store.patch(row.id, { progress: 0.72 + (0.16 * (i + 1)) / Math.max(1, chosen.length), steps: await stepsWith(row, { analyse: { status: 'running', detail: `${i + 1}/${chosen.length} sites` } }) });
+ await emit(row, { step: 'analyse' });
+ }
+ row = await finishStep(row, 'analyse', 0.88, `${analysis.length} sites analysed`);
+
+ // 6. Synthesise: cross-site patterns, the gap, recommendations against our rules.
+ row = await startStep(row, 'synthesise', 'comparing sites against our rules', 0.9);
+ check();
+ const tokens = analysis.map((a) => {
+ const cap = (capturesBySite.get(a.source_id) || []).find((c) => c.dom) || {};
+ const dom = cap.dom || {};
+ return {
+ source_id: a.source_id,
+ palettes: (dom.palette ? [...(dom.palette.backgrounds || []), ...(dom.palette.text || [])] : []).slice(0, 6).map((x) => x.value),
+ families: (dom.fonts ? (dom.fonts.families || []) : []).slice(0, 3).map((x) => x.value),
+ sizes: (dom.fonts ? (dom.fonts.sizes || []) : []).slice(0, 8).map((x) => x.value),
+ spacing: (dom.spacing || []).slice(0, 8).map((x) => x.value),
+ };
+ });
+ const synthCall = await llm.call({ routeKey: 'default.json', ...prompts.synthesisPrompt({ question, depth, analyses: analysis, sources: allSources, rules: gathered.rules || [], components: gathered.components || [], tokens }), schema: SYNTHESIS_OUTPUT, schemaName: 'deep_dive_synthesis', maxTokens: Math.min(3500, maxOut), signal: ctx.signal, ctx });
+ addCall('synthesise', synthCall);
+ const s = synthCall.json || {};
+ const sitesForEvidence = analysis.map((a) => ({ source_id: a.source_id, url: a.url }));
+ const ruleIds = new Set((gathered.rules || []).map((r) => r.id));
+ const mapEvidence = (list) => (Array.isArray(list) ? list : []).flatMap((e) => evidenceOf(e, sitesForEvidence, capturesBySite)).slice(0, 20);
+ const bestSite = sitesForEvidence[(Number(s.best && s.best.site) || 1) - 1] || sitesForEvidence[0];
+ const synthesis = {
+ patterns: (s.patterns || []).slice(0, 40).map((p) => ({
+ name: oneLine(p.name, 160), what: oneLine(p.what, 1200),
+ sites: (p.sites || []).map((n) => (sitesForEvidence[(Number(n) || 1) - 1] || {}).source_id).filter(Boolean).slice(0, 30),
+ usage_count: Number(p.usage_count) || 0, evidence: mapEvidence(p.evidence),
+ })),
+ comparison: { columns: (s.comparison && s.comparison.columns || []).slice(0, 12), rows: (s.comparison && s.comparison.rows || []).slice(0, 30) },
+ best: bestSite ? { source_id: bestSite.source_id, what: oneLine(s.best && s.best.what || '', 2000), evidence: mapEvidence(s.best && s.best.evidence) } : { source_id: null, what: '', evidence: [] },
+ mistakes: (s.mistakes || []).slice(0, 20).map((m) => ({ what: oneLine(m.what, 1200), evidence: mapEvidence(m.evidence) })),
+ gap: oneLine(s.gap, 2000),
+ reference_tokens: {
+ palettes: (s.reference_tokens && s.reference_tokens.palettes || []).map((p) => ({ source_id: (sitesForEvidence[(Number(p.site) || 1) - 1] || {}).source_id, colours: (p.colours || []).slice(0, 16) })).filter((p) => p.source_id).slice(0, 30),
+ type_scales: (s.reference_tokens && s.reference_tokens.type_scales || []).map((p) => ({ source_id: (sitesForEvidence[(Number(p.site) || 1) - 1] || {}).source_id, families: (p.families || []).slice(0, 8), sizes: (p.sizes || []).slice(0, 16) })).filter((p) => p.source_id).slice(0, 30),
+ spacing: (s.reference_tokens && s.reference_tokens.spacing || []).map((p) => ({ source_id: (sitesForEvidence[(Number(p.site) || 1) - 1] || {}).source_id, steps: (p.steps || []).slice(0, 16) })).filter((p) => p.source_id).slice(0, 30),
+ },
+ do: (s.do || []).slice(0, 20).map((x) => oneLine(x, 600)),
+ dont: (s.dont || []).slice(0, 20).map((x) => oneLine(x, 600)),
+ accessibility: (s.accessibility || []).slice(0, 20).map((x) => oneLine(x, 600)),
+ speed: (s.speed || []).slice(0, 20).map((x) => oneLine(x, 600)),
+ };
+ if (!synthesis.best.source_id) synthesis.best = { source_id: analysis[0] ? analysis[0].source_id : null, what: synthesis.best.what, evidence: synthesis.best.evidence };
+ const recommendations = (s.recommendations || []).slice(0, 20).map((r, i) => {
+ const id = r.rule && r.rule.id && ruleIds.has(r.rule.id) ? r.rule.id : null;
+ const status = id ? (r.rule.status === 'would_change' ? 'would_change' : 'follows') : 'none';
+ let ev = mapEvidence(r.evidence);
+ if (!ev.length && analysis[0]) {
+ const cap = (capturesBySite.get(analysis[0].source_id) || [])[0];
+ ev = [{ claim: oneLine(r.what, 600), source_id: analysis[0].source_id, capture_id: cap ? cap.id : null, crop_id: cap && cap.crops && cap.crops[0] ? cap.crops[0].id : null, url: analysis[0].url, read_at: cap ? cap.captured_at : iso(clock.now()) }];
+ }
+ return { id: `rec_${i + 1}`, what: oneLine(r.what, 1200), why: oneLine(r.why, 2000), rule: { id, status, text: oneLine((r.rule && r.rule.text) || (id && (gathered.rules || []).find((x) => x.id === id) || {}).rule || '', 600) }, evidence: ev };
+ });
+ if (!recommendations.length) gaps.push('The synthesis returned no recommendation; nothing is proposed.');
+ row = await finishStep(row, 'synthesise', 0.96, `${synthesis.patterns.length} patterns, ${recommendations.length} recommendations`);
+
+ // 7. Report
+ row = await startStep(row, 'report', 'assembling the report and its receipt', 0.98);
+ const nowIso = iso(clock.now());
+ const report = {
+ id: runId, schema: 'ai.deep-dive@1', run_id: runId, status: 'succeeded',
+ input: { question, depth, max_sites: siteLimit, ...(input.sites && input.sites.length ? { sites: input.sites } : {}) },
+ steps: (await store.get(row.id)).steps,
+ sources: allSources.slice(0, 200),
+ captures,
+ analysis,
+ synthesis,
+ recommendations,
+ receipt: { price_usd: price, held_usd: price, cost_usd: Math.round(cost * 1e6) / 1e6, models, captures_count: captures.filter((c) => !c.skip).length, synthetic: models.some((m) => !m.provider) },
+ gaps: [...new Set([...gaps, ...warnings])].slice(0, 40),
+ created_at: row.created_at, updated_at: nowIso, finished_at: nowIso,
+ };
+ const v = schemas.validate(REPORT, report);
+ if (!v.valid) throw new AiError(502, 'output.invalid', `the deep dive report does not match ai.deep-dive@1: ${v.errors.map((e) => `${e.path} ${e.message}`).join('; ')}`, { errors: v.errors });
+ const steps = (await store.get(row.id)).steps.map((x) => (x.key === 'report' ? { ...x, status: 'done', finished_at: nowIso } : x));
+ row = await store.patch(row.id, { status: 'succeeded', step: 'report', progress: 1, steps, report, gaps: report.gaps, finished_at: nowIso });
+ await emit(row, { step: 'done', status: 'succeeded' });
+ return { output: report, usage, cost, models };
+ } catch (err) {
+ const cancelled = (err && err.code === 'run.cancelled') || (ctx.signal && ctx.signal.aborted);
+ const code = cancelled ? 'run.cancelled' : ((err && err.code) || 'run.internal');
+ const detail = String((err && (err.detail || err.message)) || 'the run failed').slice(0, 500);
+ try {
+ const steps = (await store.get(row.id)).steps.map((x) => (x.status === 'running' ? { ...x, status: cancelled ? 'skipped' : 'failed', finished_at: iso(clock.now()) } : x));
+ const failed = await store.patch(row.id, { status: cancelled ? 'cancelled' : 'failed', steps, error_code: code, error_detail: detail, finished_at: iso(clock.now()) });
+ if (failed) await emit(failed, { status: failed.status, error: { code, detail } });
+ } catch (e) { log.warn(`[deep-dive] could not record the failure: ${e.message}`); }
+ throw err;
+ }
+ }
+
+ return { run, depths, priceOf, limitOf, store, capture, mediaStore, sources };
+}
+
+function capidSafe(n, j) { return `${n + 1}_${j + 1}`; }
+function comboMs(captures) { return 0; }
+
+module.exports = { createDeepDive };
new file mode 100644
new file mode 100644
@@ -0,0 +1,69 @@
@@ -0,0 +1,69 @@
+'use strict';
+/**
+ * The deep dive's model calls: one JSON answer per call, validated against a schema before it is used.
+ *
+ * Everything goes through the provider pool (server/providers/) with the run's own context, so the call is on the
+ * run's receipt, its provider quota and its cancellation signal. A route is resolved like a workflow's: the JSON
+ * route (`default.json`) for text, the same route with an image for a vision call, and `background.vision` for
+ * Expert reads that may wait. A call that answers invalid JSON is asked once more; a second bad answer fails the
+ * run (output.invalid), because nothing is ever filled in.
+ */
+const { AiError, parseJsonLoose, sha256 } = require('../util');
+const schemas = require('../schemas');
+
+function createLlm({ registry, pool, config = {}, log = console, clock = { now: () => Date.now() } }) {
+ async function routeFor(key) {
+ const r = await registry.resolveRoute(key);
+ if (!r) throw new AiError(503, 'route.unavailable', `no route ${key}`);
+ if (r.disabled) throw new AiError(503, 'route.unavailable', `route ${key} is disabled`);
+ return r;
+ }
+
+ /**
+ * call({ routeKey, system, user, image, schema, schemaName, maxTokens, temperature, signal, ctx })
+ * -> { json, provider, model, tokens_in, tokens_out, cost_usd, ms, route, synthetic }
+ */
+ async function call({ routeKey = 'default.json', system, user, image = null, schema = null, schemaName = 'result', maxTokens = 3000, temperature = 0.3, signal = null, ctx = {}, retry = true }) {
+ const route = await routeFor(routeKey);
+ const req = {
+ system: system ? [{ text: system, cache: true }] : [],
+ messages: [{ role: 'user', content: user }],
+ image,
+ json: schema ? { name: schemaName, schema, strict: false } : null,
+ maxTokens: Math.max(64, Math.min(8000, maxTokens)),
+ temperature,
+ timeoutMs: route.timeout_ms || 90000,
+ cacheKey: null,
+ };
+ const t0 = clock.now();
+ const exec = await pool.execute(route, 'chat', req, {
+ ...ctx, routeKey: route.key, routeVersion: route.version, promptHash: sha256({ s: system, u: user, i: image ? 1 : 0 }),
+ ...(signal ? { signal } : {}),
+ });
+ const text = exec.result.text || '';
+ let json = exec.result.json || null;
+ if (!json) json = parseJsonLoose(text);
+ let valid = json && (!schema || schemas.validate(schema, json).valid);
+ if (!valid && retry) {
+ const problem = json ? schemas.validate(schema, json).errors.map((e) => `${e.path} ${e.message}`).join('; ') : 'the answer was not JSON';
+ const again = await call({
+ routeKey, system, user: `${user}\n\nYour previous answer was not valid (${problem.slice(0, 400)}). Answer again with only the JSON object the instructions name, nothing before or after it.`,
+ image, schema, schemaName, maxTokens, temperature: 0.1, signal, ctx, retry: false,
+ });
+ return again;
+ }
+ if (!valid) {
+ const errors = json ? schemas.validate(schema, json).errors : [{ path: '/', message: 'not JSON' }];
+ throw new AiError(502, 'output.invalid', `the model answered a shape the deep dive cannot use: ${errors.map((e) => `${e.path} ${e.message}`).slice(0, 4).join('; ')}`);
+ }
+ return {
+ json, provider: exec.provider || null, model: exec.model || null,
+ tokens_in: (exec.usage && exec.usage.input) || 0, tokens_out: (exec.usage && exec.usage.output) || 0,
+ cost_usd: Number(exec.cost) || 0, ms: clock.now() - t0, route, synthetic: Boolean(exec.synthetic),
+ };
+ }
+
+ return { call, routeFor };
+}
+
+module.exports = { createLlm };
new file mode 100644
new file mode 100644
@@ -0,0 +1,110 @@
@@ -0,0 +1,110 @@
+'use strict';
+/**
+ * The deep dive's prompts. The page is the main session's; these are the words a model is given.
+ *
+ * The plan writes the questions an expert would ask and the searches that answer them; choose ranks candidate
+ * sites with a reason each; analyse reads one page (its DOM measurements, or one full-page screenshot) as a
+ * designer; synthesise compares the sites, finds the patterns and the gap, and checks every recommendation
+ * against OpenVibe's own rules register. Every answer is JSON in the shape server/deep-dive/schema.js names.
+ */
+
+const clean = (s, n) => String(s == null ? '' : s).replace(/\s+/g, ' ').trim().slice(0, n);
+
+function planPrompt({ question, depth, named }) {
+ return {
+ system: 'You are a senior product designer planning expert design research. You ask what an expert would ask before looking at a single site, and you say what to search for. Answer only with JSON.',
+ user: `The question: "${clean(question, 500)}"
+Depth: ${depth}.${named.length ? ` Sites the person already named: ${named.join(', ')}` : ''}
+
+Write the questions an expert designer would ask about this (patterns, hierarchy, density, type, colour, motion, words, trust, accessibility, speed, phone versus computer), and the searches that would answer them. Rules:
+- questions: 5 to 9, each { "key": one short slug, "question": one sentence }.
+- searches: 2 to 6, each { "q": a search query, "kind": "web" | "images" | "icons" | "projects" }. Make the queries specific to this question and to design (add "design", "case study", "ui", "ux" where they help).
+- Do not answer the question yourself. JSON only:
+{"questions":[{"key":"hierarchy","question":"..."}],"searches":[{"q":"...","kind":"web"}]}`,
+ };
+}
+
+function choosePrompt({ question, depth, limit, sources }) {
+ const list = sources.map((s, i) => `${i + 1}. [${s.kind}] ${clean(s.title, 140)} — ${s.url}${s.snippet ? ` — ${clean(s.snippet, 220)}` : ''}`).join('\n');
+ return {
+ system: 'You are a senior product designer choosing which sites a deep dive should capture. You only choose real pages that can be photographed without signing in, and you say why each one is worth the look. Answer only with JSON.',
+ user: `The question: "${clean(question, 500)}"
+Depth: ${depth}. Choose at most ${limit} sites to capture (390 px, 1280 px, dark and light where supported), from this list only:
+${list}
+
+Rules:
+- Prefer production pages and real products over listicles, directories, aggregators, social posts, docs and search results.
+- Cover different approaches (phone-first, dense dashboard, editorial, playful, minimal), not six versions of one look.
+- Every chosen URL must appear in the list above, exactly as given.
+- Each reason is one concrete sentence about what this site would teach about the question.
+- JSON only: {"sites":[{"url":"https://...","reason":"..."}]}.`,
+ };
+}
+
+function domAnalysisPrompt({ question, source, capture, dom }) {
+ return {
+ system: 'You are a senior product designer reading a page\'s measured design system. You describe what is there from the numbers, never what you imagine. Answer only with JSON.',
+ user: `Question: "${clean(question, 500)}"
+Site: ${clean(source.title, 200)} — ${source.url}
+Measured at ${capture.viewport} px, ${capture.theme} theme${capture.extras ? ', with the Expert extras' : ''}.
+
+DOM facts (computed from the live page):
+${JSON.stringify(dom, null, 1).slice(0, 14000)}
+${capture.extras ? `\nExpert extras (axe-core, LCP/CLS/INP, menus, hover/focus, frames):\n${JSON.stringify(capture.extras, null, 1).slice(0, 6000)}` : ''}
+
+Write { "summary", "first_impression", "hierarchy", "density", "type", "colour", "motion", "words", "accessibility", "speed", "strengths": [up to 4], "weaknesses": [up to 4], "evidence": [{"claim":"...","shot":1}] }.
+- Be concrete: name the sizes, counts, colours and words that are actually in the facts.
+- Accessibility and speed only when the extras measured them; otherwise say "not measured at this depth" in that field.
+- Each evidence claim is one of your own claims, with "shot": 1 (this page). JSON only.`,
+ };
+}
+
+function visionPrompt({ question, source, viewport, theme, index, total }) {
+ return {
+ system: 'You are a senior product designer looking at a real page. You say what draws the eye first, how the hierarchy and density work, what is good and what fails. Never invent a control or a colour you cannot see. Answer only with JSON.',
+ user: `Question: "${clean(question, 500)}"
+This is a full-page screenshot of ${source.url} (${clean(source.title, 160)}) at ${viewport} px, ${theme} theme. It is screenshot ${index} of ${total} for this site.
+
+Write { "summary" (2-3 sentences: what this page is and how it works), "first_impression" (what the eye lands on first), "hierarchy", "density", "type", "colour", "motion" (only what a still can show), "words", "strengths": [up to 3], "weaknesses": [up to 3], "evidence": [{"claim":"...","shot":${index}}] }.
+Every claim must follow from what is visible in this image. JSON only.`,
+ };
+}
+
+function synthesisPrompt({ question, depth, analyses, sources, rules, components, tokens }) {
+ const siteList = analyses.map((a, i) => `${i + 1}. ${a.url} — ${clean(a.summary, 260)}`).join('\n');
+ const ruleList = (rules || []).map((r) => `- ${r.id}: ${clean(r.rule, 300)} (${clean(r.area, 80)})`).join('\n') || '- (the rules register did not answer)';
+ const compList = (components || []).map((c) => `- ${c.title || c.name}: ${clean(c.summary, 220)}`).join('\n') || '- (the component registry did not answer)';
+ return {
+ system: 'You are the design lead writing the synthesis of expert design research for OpenVibe. You compare real sites, name the patterns and how many sites use each, find what everyone gets wrong and the gap nobody fills, and you check every recommendation against OpenVibe\'s own rules register. Every claim cites a site. Answer only with JSON.',
+ user: `Question: "${clean(question, 500)}" (depth: ${depth}).
+
+The sites, numbered for your citations:
+${siteList}
+
+Their analyses (JSON; each has source_id, url, summary, strengths, weaknesses):
+${JSON.stringify(analyses.map((a) => ({ source_id: a.source_id, url: a.url, summary: clean(a.summary, 800), first_impression: clean(a.first_impression, 500), hierarchy: clean(a.hierarchy, 500), density: clean(a.density, 400), type: clean(a.type, 400), colour: clean(a.colour, 400), motion: clean(a.motion, 300), words: clean(a.words, 300), strengths: a.strengths, weaknesses: a.weaknesses, accessibility: a.accessibility, speed: a.speed })), null, 1).slice(0, 30000)}
+
+Reference tokens measured from the DOM (palettes, type scales, spacing):
+${JSON.stringify(tokens, null, 1).slice(0, 6000)}
+
+OpenVibe's rules register (use these ids; a recommendation follows one, would change one, or has none):
+${ruleList}
+
+OpenVibe's component registry:
+${compList}
+
+Write JSON with:
+{"patterns":[{"name","what","sites":[site numbers],"usage_count","evidence":[{"claim","site":number}]}],
+ "comparison":{"columns":["Site","..."],"rows":[["...", "..."]]},
+ "best":{"site":number,"what","evidence":[{"claim","site":number}]},
+ "mistakes":[{"what","evidence":[{"claim","site":number}]}],
+ "gap":"...",
+ "reference_tokens":{"palettes":[{"site":number,"colours":["#..."]}],"type_scales":[{"site":number,"families":[],"sizes":[]}],"spacing":[{"site":number,"steps":[]}]},
+ "do":["..."],"dont":["..."],"accessibility":["..."],"speed":["..."],
+ "recommendations":[{"what","why","rule":{"id":one of the rule ids or null,"status":"follows"|"would_change"|"none","text":the rule in one line},"evidence":[{"claim","site":number}]}]}
+
+Rules: 5 to 10 patterns; 4 to 8 do and don't; 5 to 8 recommendations, each with a site number in its evidence. Use only the rules above; never invent a rule id. JSON only.`,
+ };
+}
+
+module.exports = { planPrompt, choosePrompt, domAnalysisPrompt, visionPrompt, synthesisPrompt };
new file mode 100644
new file mode 100644
@@ -0,0 +1,239 @@
@@ -0,0 +1,239 @@
+'use strict';
+/**
+ * The shapes a deep dive runs on.
+ *
+ * The published document is ai.deep-dive@1 (OpenVibe.Contracts): the question, the seven steps, every source,
+ * every capture, the per-site analysis, the synthesis, the recommendations and the receipt, with an evidence
+ * list on every claim. This module holds the workflow's input and output schema and the four JSON shapes an
+ * intermediate model call answers in (plan, choose, one site's analysis, the synthesis). They are deliberately
+ * smaller than the report: the report is assembled here, from what was actually read and captured, so a model
+ * cannot invent a capture id, a URL that was not visited, or a media object.
+ */
+
+const STR = (n) => ({ type: 'string', maxLength: n });
+const NULLABLE = (n) => ({ type: ['string', 'null'], maxLength: n });
+const STEPS = ['plan', 'search', 'choose', 'capture', 'analyse', 'synthesise', 'report'];
+const DEPTHS = ['quick', 'thorough', 'expert'];
+
+const EVIDENCE = {
+ type: 'array', maxItems: 20,
+ items: {
+ type: 'object', additionalProperties: false, required: ['claim', 'url', 'read_at'],
+ properties: {
+ claim: STR(600), source_id: { type: ['string', 'null'], maxLength: 40 },
+ capture_id: { type: ['string', 'null'], maxLength: 40 }, crop_id: { type: ['string', 'null'], maxLength: 40 },
+ url: STR(2048), read_at: STR(40),
+ },
+ },
+};
+
+const SOURCE = {
+ type: 'object', additionalProperties: false, required: ['id', 'kind', 'title', 'url', 'read_at'],
+ properties: {
+ id: STR(40), kind: { enum: ['web', 'picture', 'icon', 'project', 'rules', 'component', 'named'] },
+ title: STR(400), url: STR(2048), publisher: NULLABLE(200), engine: NULLABLE(80),
+ snippet: NULLABLE(2000), license: NULLABLE(120), read_at: STR(40),
+ },
+};
+
+const CROP = {
+ type: 'object', additionalProperties: false, required: ['id', 'media_id', 'y', 'height'],
+ properties: { id: STR(40), media_id: { type: 'string', pattern: '^med_[0-9A-HJKMNP-TV-Z]{26}$' }, y: { type: 'integer', minimum: 0 }, height: { type: 'integer', minimum: 1 } },
+};
+
+const CAPTURE = {
+ type: 'object', additionalProperties: false,
+ required: ['id', 'source_id', 'url', 'captured_at', 'viewport', 'theme', 'full_page', 'crops'],
+ properties: {
+ id: STR(40), source_id: STR(40), url: STR(2048), final_url: { type: ['string', 'null'], maxLength: 2048 },
+ title: NULLABLE(400), captured_at: STR(40), viewport: { enum: [390, 1280] }, theme: { enum: ['light', 'dark'] },
+ full_page: { type: 'boolean' }, media_id: { type: ['string', 'null'], pattern: '^med_[0-9A-HJKMNP-TV-Z]{26}$' },
+ width: { type: ['integer', 'null'], minimum: 1 }, height: { type: ['integer', 'null'], minimum: 1 },
+ crops: { type: 'array', maxItems: 8, items: CROP },
+ dom: { type: ['object', 'null'] }, extras: { type: ['object', 'null'] },
+ skip: { type: ['string', 'null'], maxLength: 200 }, cached: { type: 'boolean' },
+ },
+};
+
+const ANALYSIS = {
+ type: 'object', additionalProperties: false,
+ required: ['source_id', 'url', 'summary', 'strengths', 'weaknesses', 'evidence'],
+ properties: {
+ source_id: STR(40), url: STR(2048), summary: STR(4000), first_impression: STR(2000), hierarchy: STR(2000),
+ density: STR(2000), type: STR(2000), colour: STR(2000), motion: STR(2000), words: STR(2000),
+ strengths: { type: 'array', maxItems: 12, items: STR(600) },
+ weaknesses: { type: 'array', maxItems: 12, items: STR(600) },
+ accessibility: NULLABLE(2000), speed: NULLABLE(2000), evidence: EVIDENCE,
+ },
+};
+
+const EVIDENCE_CLAIM = {
+ type: 'array', maxItems: 20,
+ items: {
+ type: 'object', additionalProperties: false, required: ['claim', 'site'],
+ properties: { claim: STR(600), site: { type: 'integer', minimum: 1, maximum: 30 }, capture: { type: ['integer', 'null'], minimum: 1, maximum: 400 } },
+ },
+};
+
+const PATTERN_OUT = {
+ type: 'object', additionalProperties: false, required: ['name', 'what', 'sites', 'evidence'],
+ properties: {
+ name: STR(160), what: STR(1200),
+ sites: { type: 'array', maxItems: 30, items: { type: 'string', maxLength: 40 } },
+ usage_count: { type: 'integer', minimum: 0, maximum: 30 },
+ evidence: EVIDENCE_CLAIM,
+ },
+};
+
+const RECOMMENDATION_OUT = {
+ type: 'object', additionalProperties: false, required: ['what', 'why', 'rule', 'evidence'],
+ properties: {
+ what: STR(1200), why: STR(2000),
+ rule: {
+ type: 'object', additionalProperties: false, required: ['status', 'text'],
+ properties: { id: { type: ['string', 'null'], maxLength: 120 }, status: { enum: ['follows', 'would_change', 'none'] }, text: STR(600) },
+ },
+ evidence: EVIDENCE_CLAIM,
+ },
+};
+
+/** The LLM steps' JSON. Each is one call; a wrong shape fails the run rather than filling anything in. */
+const PLAN_OUTPUT = {
+ type: 'object', additionalProperties: true, required: ['questions', 'searches'],
+ properties: {
+ questions: { type: 'array', minItems: 3, maxItems: 12, items: { type: 'object', additionalProperties: true, required: ['key', 'question'], properties: { key: STR(40), question: STR(400) } } },
+ searches: { type: 'array', minItems: 1, maxItems: 10, items: { type: 'object', additionalProperties: true, required: ['q'], properties: { q: STR(400), kind: { enum: ['web', 'images', 'icons', 'projects'] } } } },
+ },
+};
+
+const CHOOSE_OUTPUT = {
+ type: 'object', additionalProperties: true, required: ['sites'],
+ properties: {
+ sites: {
+ type: 'array', minItems: 1, maxItems: 30,
+ items: { type: 'object', additionalProperties: true, required: ['url', 'reason'], properties: { url: STR(2048), reason: STR(600), kind: STR(40) } },
+ },
+ },
+};
+
+const ANALYSIS_OUTPUT = {
+ type: 'object', additionalProperties: true, required: ['summary', 'strengths', 'weaknesses'],
+ properties: {
+ summary: STR(4000), first_impression: STR(2000), hierarchy: STR(2000), density: STR(2000), type: STR(2000),
+ colour: STR(2000), motion: STR(2000), words: STR(2000), accessibility: NULLABLE(2000), speed: NULLABLE(2000),
+ strengths: { type: 'array', maxItems: 12, items: STR(600) },
+ weaknesses: { type: 'array', maxItems: 12, items: STR(600) },
+ evidence: { type: 'array', maxItems: 12, items: { type: 'object', additionalProperties: true, required: ['claim'], properties: { claim: STR(600), shot: { type: ['integer', 'null'], minimum: 1 } } } },
+ },
+};
+
+const SYNTHESIS_OUTPUT = {
+ type: 'object', additionalProperties: true,
+ required: ['patterns', 'comparison', 'best', 'mistakes', 'gap', 'reference_tokens', 'do', 'dont', 'accessibility', 'speed', 'recommendations'],
+ properties: {
+ patterns: { type: 'array', maxItems: 40, items: PATTERN_OUT },
+ comparison: {
+ type: 'object', additionalProperties: true, required: ['columns', 'rows'],
+ properties: { columns: { type: 'array', maxItems: 12, items: STR(120) }, rows: { type: 'array', maxItems: 30, items: { type: 'array', maxItems: 12, items: STR(1000) } } },
+ },
+ best: { type: 'object', additionalProperties: true, required: ['site', 'what'], properties: { site: { type: 'integer', minimum: 1, maximum: 30 }, what: STR(2000), evidence: EVIDENCE_CLAIM } },
+ mistakes: { type: 'array', maxItems: 20, items: { type: 'object', additionalProperties: true, required: ['what'], properties: { what: STR(1200), evidence: EVIDENCE_CLAIM } } },
+ gap: STR(2000),
+ reference_tokens: {
+ type: 'object', additionalProperties: true, required: ['palettes', 'type_scales', 'spacing'],
+ properties: {
+ palettes: { type: 'array', maxItems: 30, items: { type: 'object', additionalProperties: true, required: ['site', 'colours'], properties: { site: { type: 'integer', minimum: 1 }, colours: { type: 'array', maxItems: 16, items: STR(40) } } } },
+ type_scales: { type: 'array', maxItems: 30, items: { type: 'object', additionalProperties: true, required: ['site', 'families', 'sizes'], properties: { site: { type: 'integer', minimum: 1 }, families: { type: 'array', maxItems: 8, items: STR(120) }, sizes: { type: 'array', maxItems: 16, items: STR(40) } } } },
+ spacing: { type: 'array', maxItems: 30, items: { type: 'object', additionalProperties: true, required: ['site', 'steps'], properties: { site: { type: 'integer', minimum: 1 }, steps: { type: 'array', maxItems: 16, items: STR(40) } } } },
+ },
+ },
+ do: { type: 'array', maxItems: 20, items: STR(600) },
+ dont: { type: 'array', maxItems: 20, items: STR(600) },
+ accessibility: { type: 'array', maxItems: 20, items: STR(600) },
+ speed: { type: 'array', maxItems: 20, items: STR(600) },
+ recommendations: { type: 'array', minItems: 1, maxItems: 20, items: RECOMMENDATION_OUT },
+ },
+};
+
+const REPORT = {
+ type: 'object', additionalProperties: false,
+ required: ['id', 'schema', 'run_id', 'status', 'input', 'steps', 'sources', 'captures', 'analysis', 'synthesis', 'recommendations', 'receipt', 'gaps', 'created_at', 'updated_at'],
+ properties: {
+ id: STR(40), schema: { const: 'ai.deep-dive@1' }, run_id: STR(40),
+ status: { enum: ['queued', 'running', 'choosing', 'succeeded', 'failed', 'cancelled'] },
+ input: {
+ type: 'object', additionalProperties: false, required: ['question', 'depth', 'max_sites'],
+ properties: { question: STR(500), depth: { enum: DEPTHS }, max_sites: { type: 'integer', minimum: 1, maximum: 30 }, sites: { type: 'array', maxItems: 30, items: STR(2048) } },
+ },
+ steps: {
+ type: 'array', maxItems: 7,
+ items: {
+ type: 'object', additionalProperties: false, required: ['key', 'status'],
+ properties: { key: { enum: STEPS }, status: { enum: ['pending', 'running', 'done', 'skipped', 'failed'] }, detail: NULLABLE(300), started_at: { type: ['string', 'null'] }, finished_at: { type: ['string', 'null'] } },
+ },
+ },
+ sources: { type: 'array', maxItems: 200, items: SOURCE },
+ captures: { type: 'array', maxItems: 400, items: CAPTURE },
+ analysis: { type: 'array', maxItems: 30, items: ANALYSIS },
+ synthesis: {
+ type: 'object', additionalProperties: false,
+ required: ['patterns', 'comparison', 'best', 'mistakes', 'gap', 'reference_tokens', 'do', 'dont', 'accessibility', 'speed'],
+ properties: {
+ patterns: { type: 'array', maxItems: 40, items: { type: 'object', additionalProperties: false, required: ['name', 'what', 'sites', 'evidence'], properties: { name: STR(160), what: STR(1200), sites: { type: 'array', maxItems: 30, items: { type: 'string', maxLength: 40 } }, usage_count: { type: 'integer', minimum: 0, maximum: 30 }, evidence: EVIDENCE } } },
+ comparison: { type: 'object', additionalProperties: false, required: ['columns', 'rows'], properties: { columns: { type: 'array', maxItems: 12, items: STR(120) }, rows: { type: 'array', maxItems: 30, items: { type: 'array', maxItems: 12, items: STR(1000) } } } },
+ best: { type: 'object', additionalProperties: false, required: ['source_id', 'what', 'evidence'], properties: { source_id: STR(40), what: STR(2000), evidence: EVIDENCE } },
+ mistakes: { type: 'array', maxItems: 20, items: { type: 'object', additionalProperties: false, required: ['what', 'evidence'], properties: { what: STR(1200), evidence: EVIDENCE } } },
+ gap: STR(2000),
+ reference_tokens: {
+ type: 'object', additionalProperties: false, required: ['palettes', 'type_scales', 'spacing'],
+ properties: {
+ palettes: { type: 'array', maxItems: 30, items: { type: 'object', additionalProperties: false, required: ['source_id', 'colours'], properties: { source_id: STR(40), colours: { type: 'array', maxItems: 16, items: STR(40) } } } },
+ type_scales: { type: 'array', maxItems: 30, items: { type: 'object', additionalProperties: false, required: ['source_id', 'families', 'sizes'], properties: { source_id: STR(40), families: { type: 'array', maxItems: 8, items: STR(120) }, sizes: { type: 'array', maxItems: 16, items: STR(40) } } } },
+ spacing: { type: 'array', maxItems: 30, items: { type: 'object', additionalProperties: false, required: ['source_id', 'steps'], properties: { source_id: STR(40), steps: { type: 'array', maxItems: 16, items: STR(40) } } } },
+ },
+ },
+ do: { type: 'array', maxItems: 20, items: STR(600) },
+ dont: { type: 'array', maxItems: 20, items: STR(600) },
+ accessibility: { type: 'array', maxItems: 20, items: STR(600) },
+ speed: { type: 'array', maxItems: 20, items: STR(600) },
+ },
+ },
+ recommendations: {
+ type: 'array', maxItems: 20,
+ items: {
+ type: 'object', additionalProperties: false, required: ['id', 'what', 'why', 'rule', 'evidence'],
+ properties: {
+ id: STR(40), what: STR(1200), why: STR(2000),
+ rule: { type: 'object', additionalProperties: false, required: ['status', 'text'], properties: { id: { type: ['string', 'null'], maxLength: 120 }, status: { enum: ['follows', 'would_change', 'none'] }, text: STR(600) } },
+ evidence: EVIDENCE,
+ },
+ },
+ },
+ receipt: {
+ type: 'object', additionalProperties: false, required: ['price_usd', 'held_usd', 'cost_usd', 'models', 'captures_count', 'synthetic'],
+ properties: {
+ price_usd: { type: 'number', minimum: 0 }, held_usd: { type: 'number', minimum: 0 }, cost_usd: { type: 'number', minimum: 0 },
+ captures_count: { type: 'integer', minimum: 0 }, synthetic: { type: 'boolean' },
+ models: {
+ type: 'array', maxItems: 500,
+ items: { type: 'object', additionalProperties: false, required: ['step', 'provider', 'model', 'tokens_in', 'tokens_out', 'cost_usd', 'ms'], properties: {
+ step: { enum: ['plan', 'choose', 'analyse', 'synthesise'] }, provider: NULLABLE(80), model: NULLABLE(200),
+ tokens_in: { type: 'integer', minimum: 0 }, tokens_out: { type: 'integer', minimum: 0 }, cost_usd: { type: 'number', minimum: 0 }, ms: { type: 'integer', minimum: 0 },
+ } },
+ },
+ },
+ },
+ gaps: { type: 'array', maxItems: 40, items: STR(400) },
+ created_at: STR(40), updated_at: STR(40), finished_at: { type: ['string', 'null'] },
+ },
+};
+
+const INPUT_SCHEMA = {
+ type: 'object', additionalProperties: false, required: ['question', 'depth'],
+ properties: {
+ question: { type: 'string', minLength: 1, maxLength: 500 }, depth: { enum: DEPTHS },
+ sites: { type: 'array', maxItems: 30, items: { type: 'string', maxLength: 2048, pattern: '^https?://' } },
+ },
+};
+
+module.exports = { DEPTHS, STEPS, EVIDENCE, REPORT, INPUT_SCHEMA, PLAN_OUTPUT, CHOOSE_OUTPUT, ANALYSIS_OUTPUT, SYNTHESIS_OUTPUT };
new file mode 100644
new file mode 100644
@@ -0,0 +1,161 @@
@@ -0,0 +1,161 @@
+'use strict';
+/**
+ * Every search a deep dive uses, gathered into one numbered source list (ai.deep-dive@1 sources).
+ *
+ * named the sites the person typed: read for their title, always candidates
+ * MegaSource server/research/: Wikipedia, Wikidata, the Search Fabric or DeepSeek's web search, and licensed
+ * pictures, all deduplicated and dated
+ * AI search Exchange's own engines (server/search/): web, pictures, and icons for Expert
+ * GitHub open-source projects and their demo/homepage links
+ * rules OpenVibe.Design's rules register (GET https://openvibe.design/api/v1/rules), so the synthesis is
+ * checked against what OpenVibe already decided
+ * components OpenVibe.Design's component registry (GET /api/v1/components)
+ *
+ * Nothing here is a claim: a source is a page or a project that was read, with its URL and the date it was read.
+ * A provider that fails is a warning and a gap, never an invented row.
+ */
+const { guardedFetch } = require('../providers/guarded-fetch');
+const { publicTarget } = require('../search/transport');
+
+const ONE_LINE = (s, n) => String(s == null ? '' : s).replace(/\s+/g, ' ').trim().slice(0, n);
+const HOST = (u) => { try { return new URL(u).hostname.replace(/^www\./, ''); } catch { return null; } };
+const canon = (u) => { try { const x = new URL(String(u)); x.hash = ''; x.hostname = x.hostname.toLowerCase(); return x.href.replace(/\/$/, ''); } catch { return null; } };
+const DESIGN_PUBLISHERS = /(smashingmagazine|nngroup|uxdesign|interactions\.acm|alistapart|awwwards|css-tricks|web\.dev|developer\.mozilla|designsystems|uxplanet|figma\.com\/blog|medium\.com|substack\.com|dribbble\.com\/stories|abduzeedo|itsnicethat|dezeen)/i;
+const WORD = /[a-z0-9]{3,}/g;
+const words = (s) => new Set(String(s || '').toLowerCase().match(WORD) || []);
+
+function createSourceGatherer({ config = {}, log = console, fetchImpl = guardedFetch, research = null, search = null, now = () => Date.now() } = {}) {
+ const designUrl = String((config.deepDive && config.deepDive.designUrl) || 'https://openvibe.design').replace(/\/+$/, '');
+ const timeoutMs = (config.deepDive && config.deepDive.rulesTimeoutMs) || 8000;
+
+ async function getJson(url, accept = 'application/json') {
+ await publicTarget(url);
+ const res = await fetchImpl(url, { redirect: 'error', signal: AbortSignal.timeout(timeoutMs), headers: { accept, 'user-agent': 'OpenVibeDeepDive/1.0 (+https://openvibe.exchange/deep-dive)' } });
+ if (!res.ok) throw new Error(`${HOST(url)} answered ${res.status}`);
+ return await res.json();
+ }
+
+ async function titleOf(url) {
+ try {
+ const res = await fetchImpl(url, { redirect: 'error', signal: AbortSignal.timeout(6000), headers: { accept: 'text/html,application/xhtml+xml', 'user-agent': 'OpenVibeDeepDive/1.0 (+https://openvibe.exchange/deep-dive)' } });
+ if (!res.ok) return { title: HOST(url) || url, snippet: null };
+ const text = (await res.text()).slice(0, 250000);
+ const title = (/<title[^>]*>([\s\S]*?)<\/title>/i.exec(text) || [])[1];
+ const desc = (/<meta[^>]+name=["']description["'][^>]+content=["']([^"']+)/i.exec(text) || [])[1];
+ return { title: ONE_LINE(title || HOST(url) || url, 300), snippet: desc ? ONE_LINE(desc, 600) : null };
+ } catch { return { title: HOST(url) || url, snippet: null }; }
+ }
+
+ async function rulesRegister(question) {
+ try {
+ const body = await getJson(`${designUrl}/api/v1/rules`);
+ const list = Array.isArray(body.rules) ? body.rules : [];
+ const q = words(question);
+ const scored = list.map((r) => {
+ const text = `${r.rule || ''} ${r.detail || ''} ${r.area || ''}`.toLowerCase();
+ let score = 0;
+ for (const w of q) if (text.includes(w)) score++;
+ return { r, score };
+ }).sort((a, b) => b.score - a.score);
+ const picked = [];
+ for (const { r, score } of scored) {
+ if (picked.length >= 14) break;
+ if (score || picked.length < 8) picked.push(r);
+ }
+ return { updated: body.updated || null, rules: picked, areas: body.areas || [] };
+ } catch (err) { log.warn(`[deep-dive] rules register: ${String(err && err.message || err).slice(0, 160)}`); return { updated: null, rules: [], areas: [] }; }
+ }
+
+ async function componentsRegister(question) {
+ try {
+ const body = await getJson(`${designUrl}/api/v1/components`);
+ const list = Array.isArray(body.components) ? body.components : [];
+ const q = words(question);
+ const picked = list.filter((c) => {
+ const text = `${c.name || ''} ${c.title || ''} ${c.summary || ''}`.toLowerCase();
+ return [...q].some((w) => text.includes(w));
+ }).slice(0, 6);
+ return { version: body.version || null, components: picked.length ? picked : list.slice(0, 4) };
+ } catch (err) { log.warn(`[deep-dive] component registry: ${String(err && err.message || err).slice(0, 160)}`); return { version: null, components: [] }; }
+ }
+
+ async function github(question, limit = 5) {
+ const q = encodeURIComponent(`${question} design`);
+ const url = `https://api.github.com/search/repositories?q=${q}&sort=stars&order=desc&per_page=${Math.max(1, Math.min(10, limit))}`;
+ try {
+ await publicTarget(url);
+ const res = await fetchImpl(url, { redirect: 'error', signal: AbortSignal.timeout(9000), headers: { accept: 'application/vnd.github+json', 'user-agent': 'OpenVibeDeepDive/1.0', 'x-github-api-version': '2022-11-28' } });
+ if (!res.ok) return { projects: [], warning: `GitHub answered ${res.status}` };
+ const body = await res.json();
+ return { projects: (body.items || []).map((r) => ({ name: r.full_name, url: r.html_url, description: ONE_LINE(r.description, 600), stars: r.stargazers_count || 0, homepage: r.homepage ? String(r.homepage) : null, updated: r.pushed_at || null })) };
+ } catch (err) { return { projects: [], warning: `GitHub: ${String(err && err.message || err).slice(0, 160)}` }; }
+ }
+
+ /**
+ * Gather. Returns { sources, rules, components, warnings }; every source has id src_N, kind, title, url,
+ * publisher, engine, snippet, read_at. ctx.signal ends the joins early.
+ */
+ async function gather({ question, depth, namedSites = [], signal = null, maxSources = 40 }) {
+ const at = new Date(now()).toISOString();
+ const sources = [];
+ const warnings = [];
+ const seen = new Set();
+ const add = (s) => {
+ const url = canon(s.url);
+ if (!url || seen.has(url) || sources.length >= maxSources) return null;
+ seen.add(url);
+ const id = `src_${sources.length + 1}`;
+ sources.push({ id, kind: s.kind, title: ONE_LINE(s.title || HOST(url) || url, 400), url, publisher: s.publisher || HOST(url) || null, engine: s.engine || null, snippet: s.snippet ? ONE_LINE(s.snippet, 2000) : null, license: s.license ? ONE_LINE(s.license, 120) : null, read_at: s.read_at || at });
+ return id;
+ };
+
+ // The person's own sites first: they are always candidates, even if search never finds them.
+ for (const raw of namedSites.slice(0, 30)) {
+ const url = canon(raw);
+ if (!url || !/^https?:/.test(url)) continue;
+ const info = await titleOf(url);
+ add({ kind: 'named', url, title: info.title, snippet: info.snippet });
+ }
+
+ const mega = research && typeof research.gather === 'function'
+ ? await research.gather({ query: `${question} design`, max_sources: Math.min(12, maxSources), max_images: depth === 'quick' ? 6 : 10 }, { signal, allowPaid: true }).catch((err) => { warnings.push(`research: ${String(err && err.message || err).slice(0, 160)}`); return null; })
+ : null;
+ if (mega) {
+ for (const s of mega.sources || []) add({ kind: 'web', url: s.url, title: s.title, snippet: s.snippet || s.content, engine: s.provider || 'megasource', publisher: HOST(s.url), license: s.license });
+ for (const p of mega.images || []) add({ kind: 'picture', url: p.source_url || p.url, title: p.title || p.description, snippet: p.author ? `by ${p.author}` : null, engine: p.provider || 'commons', license: p.license });
+ for (const p of mega.providers ? Object.entries(mega.providers) : []) if (p[1] && p[1].ok === false) warnings.push(`${p[0]}: ${p[1].skipped || p[1].error || 'did not answer'}`);
+ }
+
+ if (search && typeof search.search === 'function') {
+ const calls = [['web', { q: question, limit: 8 }], ['images', { q: `${question} interface`, limit: 6 }]];
+ if (depth === 'expert') calls.push(['icons', { q: question, limit: 6 }]);
+ for (const [kind, body] of calls) {
+ if (signal && signal.aborted) break;
+ try {
+ const page = await search.search(kind, body, {});
+ const engine = page && page.receipt ? page.receipt.engine : 'search';
+ for (const r of (page && page.results) || []) {
+ add({ kind: kind === 'images' ? 'picture' : kind === 'icons' ? 'icon' : 'web', url: r.url, title: r.title, snippet: r.snippet || r.attribution || r.author, engine, publisher: HOST(r.url), license: r.license || null });
+ }
+ } catch (err) { warnings.push(`${kind} search: ${String(err && err.message || err).slice(0, 160)}`); }
+ }
+ }
+
+ const gh = await github(question, depth === 'quick' ? 3 : 6);
+ if (gh.warning) warnings.push(gh.warning);
+ for (const p of gh.projects) add({ kind: 'project', url: p.url, title: p.name, snippet: `${p.description || ''}${p.stars ? ` · ${p.stars} stars` : ''}${p.homepage ? ` · demo ${p.homepage}` : ''}`.slice(0, 900), engine: 'github', publisher: 'github.com' });
+
+ const rules = await rulesRegister(question);
+ for (const r of rules.rules) add({ kind: 'rules', url: r.url || `${designUrl}/rules#${r.id}`, title: `Rule: ${ONE_LINE(r.rule, 180)}`, snippet: ONE_LINE(r.detail || r.why || '', 900), engine: 'openvibe.design', publisher: 'openvibe.design', read_at: at });
+ const comps = await componentsRegister(question);
+ for (const c of comps.components) add({ kind: 'component', url: `${designUrl}/components`, title: `Component: ${c.title || c.name}`, snippet: ONE_LINE(c.summary || (c.anatomy || []).map((a) => a.role).join('; '), 900), engine: 'openvibe.design', publisher: 'openvibe.design' });
+
+ // Design write-ups from the web results are the ones from the publications that write them.
+ for (const s of sources) if (s.kind === 'web' && DESIGN_PUBLISHERS.test(s.url)) s.engine = s.engine || 'write-up';
+ return { sources, rules: rules.rules, components: comps.components, warnings };
+ }
+
+ return { gather, titleOf, rulesRegister, componentsRegister, github };
+}
+
+module.exports = { createSourceGatherer, DESIGN_PUBLISHERS };
new file mode 100644
new file mode 100644
@@ -0,0 +1,100 @@
@@ -0,0 +1,100 @@
+'use strict';
+/**
+ * The deep dive's durable state and its 7-day capture cache (migrations/0079_deep_dives.sql).
+ *
+ * The run row is the record; this row is what the page reads while the run is still working: the steps and their
+ * status, the candidates after Choosing, the decision that release them to Capture, the captures as they land and
+ * the final report. Every write is small and immediate, so a deploy that interrupts a run leaves a page that says
+ * what happened rather than one that spins. capture_cache is per URL, viewport and theme (and whether the Expert
+ * extras were taken): the same page at the same size is never photographed twice inside seven days.
+ */
+const crypto = require('crypto');
+
+const iso = (v) => (v ? new Date(v).toISOString() : null);
+const parse = (v, d) => { try { return v == null ? d : (typeof v === 'string' ? JSON.parse(v) : v); } catch { return d; } };
+const key = (url, viewport, theme, extras) => crypto.createHash('sha256').update(`${url}|${viewport}|${theme}|${extras ? 1 : 0}`).digest('hex');
+
+function createDeepDiveStore({ db, clock = { now: () => Date.now() }, log = console } = {}) {
+ const getRow = async (id) => await db.prepare('SELECT * FROM deep_dives WHERE id = ?').get(String(id));
+
+ function decode(r) {
+ if (!r) return null;
+ return {
+ id: r.id, run_id: r.run_id, owner: r.owner_type ? { type: r.owner_type, id: r.owner_id } : null,
+ status: r.status, step: r.step, progress: Number(r.progress) || 0,
+ question: r.question, depth: r.depth, site_limit: r.site_limit, price_usd: Number(r.price_usd) || 0,
+ steps: parse(r.steps, []), candidates: parse(r.candidates, []), decision_at: iso(r.decision_at),
+ captures: parse(r.captures, []), report: parse(r.report, null), gaps: parse(r.gaps, []),
+ error: r.error_code ? { code: r.error_code, detail: r.error_detail } : null,
+ created_at: iso(r.created_at), updated_at: iso(r.updated_at), finished_at: iso(r.finished_at),
+ };
+ }
+
+ async function ensure({ runId, question, depth, siteLimit, priceUsd, owner = null }) {
+ const now = iso(clock.now());
+ await db.prepare(`INSERT INTO deep_dives (id, run_id, owner_type, owner_id, status, step, progress, question, depth, site_limit, price_usd, steps, created_at, updated_at)
+ VALUES (?, ?, ?, ?, 'queued', 'plan', 0, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (id) DO NOTHING`)
+ .run(runId, runId, owner && owner.type || null, owner && owner.id || null, String(question).slice(0, 500), depth, siteLimit, priceUsd, JSON.stringify([]), now, now);
+ return decode(await getRow(runId));
+ }
+
+ async function get(id) { return decode(await getRow(id)); }
+
+ async function patch(id, fields) {
+ const cols = { status: 'status', step: 'step', progress: 'progress', steps: 'steps', candidates: 'candidates', decision_at: 'decision_at', captures: 'captures', report: 'report', gaps: 'gaps', error_code: 'error_code', error_detail: 'error_detail', finished_at: 'finished_at' };
+ const sets = []; const args = [];
+ for (const [k, v] of Object.entries(fields || {})) {
+ if (!cols[k]) continue;
+ sets.push(`${cols[k]} = ?`);
+ args.push(v && typeof v === 'object' && !(v instanceof Date) ? JSON.stringify(v) : v);
+ }
+ if (!sets.length) return get(id);
+ sets.push('updated_at = ?'); args.push(iso(clock.now()));
+ await db.prepare(`UPDATE deep_dives SET ${sets.join(', ')} WHERE id = ?`).run(...args, String(id));
+ return get(id);
+ }
+
+ /** Set one step's status and the overall progress; returns the updated row. */
+ async function setStep(id, key, status, { progress = null, detail = null, startedAt = null, finishedAt = null } = {}) {
+ const row = await get(id);
+ if (!row) return null;
+ const steps = (row.steps || []).map((s) => ({ ...s }));
+ if (!steps.length) for (const k of ['plan', 'search', 'choose', 'capture', 'analyse', 'synthesise', 'report']) steps.push({ key: k, status: 'pending', detail: null, started_at: null, finished_at: null });
+ const s = steps.find((x) => x.key === key);
+ if (s) {
+ s.status = status;
+ if (detail != null) s.detail = String(detail).slice(0, 300);
+ if (startedAt) s.started_at = startedAt;
+ if (finishedAt) s.finished_at = finishedAt;
+ if (status === 'running' && !s.started_at) s.started_at = iso(clock.now());
+ if ((status === 'done' || status === 'failed' || status === 'skipped') && !s.finished_at) s.finished_at = iso(clock.now());
+ }
+ return patch(id, { steps, ...(progress != null ? { progress } : {}), step: key });
+ }
+
+ async function cacheGet(url, viewport, theme, extras) {
+ const k = key(url, viewport, theme, extras);
+ const row = await db.prepare('SELECT * FROM capture_cache WHERE key = ? AND expires_at > ?').get(k, iso(clock.now()));
+ if (!row) return null;
+ return { ...parse(row.result, null), cached: true, captured_at: iso(row.captured_at) };
+ }
+
+ async function cachePut(url, viewport, theme, extras, result, days = 7) {
+ if (!(Number(days) > 0) || !result || !result.media_id) return;
+ const now = clock.now();
+ const at = result.captured_at || iso(now);
+ const expires = iso(now + Number(days) * 86400000);
+ const k = key(url, viewport, theme, extras);
+ await db.prepare(`INSERT INTO capture_cache (key, url, viewport, theme, extras, result, captured_at, expires_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (key) DO UPDATE SET result = EXCLUDED.result, captured_at = EXCLUDED.captured_at, expires_at = EXCLUDED.expires_at`)
+ .run(k, url, viewport, theme, extras ? 1 : 0, JSON.stringify(result), at, expires).catch((err) => log.warn(`[deep-dive] cache write failed: ${err.message}`));
+ }
+
+ async function pruneCache() {
+ await db.prepare('DELETE FROM capture_cache WHERE expires_at <= ?').run(iso(clock.now())).catch(() => {});
+ }
+
+ return { ensure, get, patch, setStep, cacheGet, cachePut, pruneCache, key };
+}
+
+module.exports = { createDeepDiveStore };
@@ -149,6 +149,33 @@ async function callRecorded(rec) {
@@ -149,6 +149,33 @@ async function callRecorded(rec) {
}
}
+/**
+ * ai.deep_dive.updated@1: a deep dive's step changed or it ended (server/deep-dive/). One low-priority event per
+ * step transition and at the end, so OpenVibe.Exchange's page can follow a run and OpenVibe.Design can attach a
+ * finished report without polling. The payload carries ids, the step, the progress and the status; never the
+ * question's answer, a capture or a prompt. No-op while publishing is off.
+ */
+async function deepDiveUpdated({ runId, deepDiveId, status, step, progress, steps, depth, requester, on_behalf_of, url, error = null }) {
+ if (!svc || !dbRef || !runId) return null;
+ const payload = {
+ deep_dive_id: String(deepDiveId || runId), run_id: String(runId),
+ status: status === 'queued' ? 'queued' : ['running', 'choosing', 'succeeded', 'failed', 'cancelled'].includes(status) ? status : 'running',
+ step: step === 'done' ? 'done' : (['plan', 'search', 'choose', 'capture', 'analyse', 'synthesise', 'report'].includes(step) ? step : null),
+ progress: Math.max(0, Math.min(1, Number(progress) || 0)),
+ steps: (steps || []).slice(0, 7).map((s) => ({ key: s.key, status: s.status })),
+ depth: ['quick', 'thorough', 'expert'].includes(depth) ? depth : 'quick',
+ requester: requester && requester.type && requester.id ? { type: requester.type, id: String(requester.id) } : { type: 'service', id: 'ai' },
+ on_behalf_of: on_behalf_of && on_behalf_of.type && on_behalf_of.id ? { type: on_behalf_of.type, id: String(on_behalf_of.id) } : null,
+ url: String(url || `https://openvibe.exchange/deep-dive/${runId}`),
+ error: error && error.code ? { code: String(error.code).slice(0, 80), detail: String(error.detail || '').slice(0, 500) } : null,
+ occurred_at: new Date().toISOString(),
+ };
+ const env = await svc.emit({ event_type: 'ai.deep_dive.updated', actor: { type: 'service', id: 'ai' }, subject: { type: 'run', id: payload.run_id }, visibility: 'internal', priority: status === 'failed' ? 'important' : 'low', payload });
+ queued++;
+ dbRef.afterCommit(() => svc && svc.kick());
+ return env;
+}
+
async function status() {
async function status() {
if (!svc) return { enabled: false };
if (!svc) return { enabled: false };
const s = await svc.status();
const s = await svc.status();
@@ -167,6 +194,6 @@ async function stop() {
@@ -167,6 +194,6 @@ async function stop() {
}
}
function _reset() { if (svc) svc.stop(); svc = null; queued = 0; }
function _reset() { if (svc) svc.stop(); svc = null; queued = 0; }
-module.exports = { init, runChanged, credentialFailed, callRecorded, callPayload, payloadOf, status, stop, _reset };
+module.exports = { init, runChanged, credentialFailed, callRecorded, deepDiveUpdated, callPayload, payloadOf, status, stop, _reset };
@@ -90,7 +90,10 @@ function createTiers({ config, valkey = null, clock = { now: () => Date.now() },
@@ -90,7 +90,10 @@ function createTiers({ config, valkey = null, clock = { now: () => Date.now() },
* Hold the run's estimate on every budget, or refuse it. Returns the hold ({ ids: { unit: [id] } }) to settle
* Hold the run's estimate on every budget, or refuse it. Returns the hold ({ ids: { unit: [id] } }) to settle
* or release, or null when an all-free run was admitted without Valkey.
* or release, or null when an all-free run was admitted without Valkey.
*/
*/
- async function admit(ctx, runId) {
+ async function admit(ctx, runId, amount = null) {
+ const held = amount && typeof amount === 'object'
+ ? { 'ai-token': Math.max(0, Math.min(50000000, Number(amount.tokens) || 0)), 'ai-usd': Math.max(0, Math.min(100, Number(amount.usd) || 0)) }
+ : g.hold;
const project = `${ctx.requesterType}:${ctx.requesterId}`;
const project = `${ctx.requesterType}:${ctx.requesterId}`;
const subject = subjectOf(ctx) || project;
const subject = subjectOf(ctx) || project;
const budgets = [{ scope: 'subject', subject, project, tier: tierOf(subject, project) }, { scope: 'project', subject: '*', project, tier: tierOf(project) }];
const budgets = [{ scope: 'subject', subject, project, tier: tierOf(subject, project) }, { scope: 'project', subject: '*', project, tier: tierOf(project) }];
@@ -98,7 +101,7 @@ function createTiers({ config, valkey = null, clock = { now: () => Date.now() },
@@ -98,7 +101,7 @@ function createTiers({ config, valkey = null, clock = { now: () => Date.now() },
try {
try {
for (const b of budgets) {
for (const b of budgets) {
for (const unit of UNITS) {
for (const unit of UNITS) {
- const r = await timeout(gov.reserve({ subject: b.subject, project: b.project, tier: b.tier, unit, amount: g.hold[unit], key: `ai:${runId}:hold` }), g.timeoutMs);
+ const r = await timeout(gov.reserve({ subject: b.subject, project: b.project, tier: b.tier, unit, amount: held[unit], key: `ai:${runId}:hold` }), g.timeoutMs);
if (!r.ok) {
if (!r.ok) {
await release(hold);
await release(hold);
throw new AiError(429, 'govern.exceeded', `the ${b.tier} tier allows ${r.limit} ${unit} per ${r.window} for this ${b.scope}`, {
throw new AiError(429, 'govern.exceeded', `the ${b.tier} tier allows ${r.limit} ${unit} per ${r.window} for this ${b.scope}`, {
@@ -32,7 +32,7 @@ const { AiError } = require('./util');
@@ -32,7 +32,7 @@ const { AiError } = require('./util');
const { gracefulStop, telemetry } = require('openvibe-sdk/service');
const { gracefulStop, telemetry } = require('openvibe-sdk/service');
const { createPgTelemetrySink, startTelemetryRetention } = require('openvibe-sdk/telemetry');
const { createPgTelemetrySink, startTelemetryRetention } = require('openvibe-sdk/telemetry');
-async function start({ searchFetch = null, config, db: givenDb = null, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, env = process.env, log = console, listen = true, credentialFetch = null, valkey = null, research = null, machineModels = null, variantEvidence = null, community: givenCommunity = null, communityEndpoints: givenEndpoints = null, webhooks: givenWebhooks = null, abuse: givenAbuse = null, memoryTransport = null, memoryLookup = null, ownerNotify = null, mediaStore: givenMediaStore = null } = {}) {
+async function start({ searchFetch = null, config, db: givenDb = null, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, env = process.env, log = console, listen = true, credentialFetch = null, valkey = null, research = null, search: givenSearch = null, capture: givenCapture = null, deepDive: givenDeepDive = null, deepDiveFetch = null, machineModels = null, variantEvidence = null, community: givenCommunity = null, communityEndpoints: givenEndpoints = null, webhooks: givenWebhooks = null, abuse: givenAbuse = null, memoryTransport = null, memoryLookup = null, ownerNotify = null, mediaStore: givenMediaStore = null } = {}) {
config = config || load(env);
config = config || load(env);
schemas.configure(config.schemaCache);
schemas.configure(config.schemaCache);
// PostgreSQL (ADR-035): opened and migrated here unless the caller (a test) hands in a migrated handle.
// PostgreSQL (ADR-035): opened and migrated here unless the caller (a test) hands in a migrated handle.
@@ -114,7 +114,18 @@ async function start({ searchFetch = null, config, db: givenDb = null, clock = {
@@ -114,7 +114,18 @@ async function start({ searchFetch = null, config, db: givenDb = null, clock = {
// Pictures into Media for the image task (server/media-store.js): one tenant, the owner's namespace.
// Pictures into Media for the image task (server/media-store.js): one tenant, the owner's namespace.
const mediaStore = givenMediaStore || require('./media-store').createMediaStore({ config, env, fetchImpl, log });
const mediaStore = givenMediaStore || require('./media-store').createMediaStore({ config, env, fetchImpl, log });
// MegaSource (server/research/): the DeepSeek key resolves by name from this environment; tests hand in their own.
// MegaSource (server/research/): the DeepSeek key resolves by name from this environment; tests hand in their own.
- const engine = createEngine({ registry, pool, fetcher, quotas, config, log, db, clock, mediaStore, research: research || require('./research').createResearch({ config: config.research, env, log }) });
+ const researcher = research || require('./research').createResearch({ config: config.research, env, log });
+ // Exchange's search engines (server/search/): the deep dive's web, picture and icon searches. It shares the
+ // same guarded fetch as the platform API; a test hands in its own.
+ const search = givenSearch || require('./search').createSearch({
+ ...(searchFetch ? { fetchImpl: searchFetch } : {}), now: () => clock.now(), registry, env,
+ });
+ // The capture worker: AI_CAPTURE_URL points at the openvibe-ai-capture unit; unset runs the same capture in
+ // this process (tests and a small install). Screenshots land in Media private to the run's owner.
+ const localCapture = givenCapture || require('./capture').createCapture({ config, env, log, upload: (o) => mediaStore.put({ ...o, visibility: 'private' }) });
+ const captureClient = givenCapture || require('./capture/client').createCaptureClient({ config, env, fetchImpl, log, local: localCapture });
+ const deepDive = givenDeepDive || require('./deep-dive').createDeepDive({ config, db, clock, log, registry, pool, research: researcher, search, capture: captureClient, mediaStore, fetcher, sourceFetch: deepDiveFetch });
+ const engine = createEngine({ registry, pool, fetcher, quotas, config, log, db, clock, mediaStore, research: researcher, deepDive });
// ai.run.* to OpenVibe.Events through the outbox (server/events.js); before recovery, so its failures are announced.
// ai.run.* to OpenVibe.Events through the outbox (server/events.js); before recovery, so its failures are announced.
require('./events').init(db, { log });
require('./events').init(db, { log });
// platform.usage-sample@1 readings to OpenVibe.Billing through their own outbox (server/usage-samples.js).
// platform.usage-sample@1 readings to OpenVibe.Billing through their own outbox (server/usage-samples.js).
@@ -233,7 +244,7 @@ async function start({ searchFetch = null, config, db: givenDb = null, clock = {
@@ -233,7 +244,7 @@ async function start({ searchFetch = null, config, db: givenDb = null, clock = {
clientSecret: env.OV_OAUTH_CLIENT_SECRET, fetch: fetchImpl,
clientSecret: env.OV_OAUTH_CLIENT_SECRET, fetch: fetchImpl,
}) : null;
}) : null;
const benchEvents = createBenchEvents({ bench, accountData, accountSend, config, log, fetchImpl, clientSecret: env.OV_OAUTH_CLIENT_SECRET });
const benchEvents = createBenchEvents({ bench, accountData, accountSend, config, log, fetchImpl, clientSecret: env.OV_OAUTH_CLIENT_SECRET });
- const app = createApp({ choices, searchFetch, config, db, registry, pool, quotas, cache, runs, auth, keys, env, clock, fetchImpl, log, credentials, preferences, grants, vault, settings, benchEvents, runBudgets, memory, memoryImport, memoryServices, memorySharing, community, endpoints, webhooks, presets, receipts, balance, creditGrants, sessionCookies, gpus, gpuMarket, supply, abuse, priceBook, modelSync, modelDiscovery, ownEndpoints, refusal, willingness, abliterationTax, batches, share });
+ const app = createApp({ choices, searchFetch, config, db, registry, pool, quotas, cache, runs, auth, keys, env, clock, fetchImpl, log, credentials, preferences, grants, vault, settings, benchEvents, runBudgets, memory, memoryImport, memoryServices, memorySharing, community, endpoints, webhooks, presets, receipts, balance, creditGrants, sessionCookies, gpus, gpuMarket, supply, abuse, priceBook, modelSync, modelDiscovery, ownEndpoints, refusal, willingness, abliterationTax, batches, share, deepDive, mediaStore });
let jwks = null;
let jwks = null;
let keyLoaded = Promise.resolve(null);
let keyLoaded = Promise.resolve(null);
if (!keys.pinned && config.nodeEnv !== 'test') {
if (!keys.pinned && config.nodeEnv !== 'test') {
@@ -93,7 +93,35 @@ function createMediaStore({ config, env = process.env, fetchImpl = globalThis.fe
@@ -93,7 +93,35 @@ function createMediaStore({ config, env = process.env, fetchImpl = globalThis.fe
};
};
}
}
- return { configured, publicUrl, put };
+ /**
+ * A private object's signed bytes URL: GET /api/v2/:app/objects/:id/download?format=json answers a short-lived
+ * signed URL (the object stays 404 to everyone without it). AI reads the object as the run's owner when it has
+ * one, and as its own tenant otherwise. The deep dive's screenshots are private, so this is how a vision model
+ * reads them back and how Exchange's report shows a crop.
+ */
+ async function downloadUrl(mediaId, { owner = null, ttl = 300 } = {}) {
+ if (!configured()) throw Object.assign(new Error('OpenVibe.Media is not configured'), { status: 503, code: 'media.unavailable' });
+ if (!/^med_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(mediaId || ''))) throw Object.assign(new Error('not a Media object id'), { status: 422, code: 'media.invalid' });
+ const auth = await tokenClient().authHeaders();
+ const subject = owner && SUBJECT_RE.test(owner) ? { 'X-OV-Subject': owner } : {};
+ const body = await call(`${baseUrl}/api/v2/${encodeURIComponent(app)}/objects/${encodeURIComponent(mediaId)}/download?format=json&ttl=${Math.max(30, Math.min(3600, Number(ttl) || 300))}`, {
+ method: 'GET', headers: { ...auth, ...subject, Accept: 'application/json' },
+ });
+ if (!body.url) throw Object.assign(new Error('Media did not sign the object'), { status: 503, code: 'media.unavailable' });
+ return body.url;
+ }
+
+ /** The object's bytes (maxBytes cap): the bytes are private, the signed URL is the only way in. */
+ async function bytes(mediaId, { owner = null, maxBytes = 20 * 1024 * 1024, signal = null } = {}) {
+ const url = await downloadUrl(mediaId, { owner });
+ const res = await fetchImpl(url, { signal: signal || AbortSignal.timeout(30000) });
+ if (!res.ok) throw Object.assign(new Error(`Media bytes answered ${res.status}`), { status: 503, code: 'media.unavailable' });
+ const buf = Buffer.from(await res.arrayBuffer());
+ if (buf.length > maxBytes) throw Object.assign(new Error('the object is larger than the read cap'), { status: 413, code: 'media.too_large' });
+ return { buffer: buf, mime: res.headers.get('content-type') || 'image/png' };
+ }
+
+ return { configured, publicUrl, put, downloadUrl, bytes };
}
}
module.exports = { createMediaStore };
module.exports = { createMediaStore };
@@ -455,6 +455,67 @@ route('post', '/research', {
@@ -455,6 +455,67 @@ route('post', '/research', {
responses: { 201: json('Finished', ref('RunResponse')), 202: json('Still running', ref('RunResponse')), 200: json('Replay', ref('RunResponse')), ...errors() },
responses: { 201: json('Finished', ref('RunResponse')), 202: json('Still running', ref('RunResponse')), 200: json('Replay', ref('RunResponse')), ...errors() },
});
});
+// ── Deep dives (workflow design.deep_dive; server/deep-dive/) ─────────────────────────────
+const deepDive = { type: 'object', additionalProperties: true, required: ['id', 'run_id', 'status', 'depth'], properties: {
+ id: str({ pattern: '^run_' }), run_id: str({ pattern: '^run_' }), status: str({ enum: ['queued', 'running', 'choosing', 'succeeded', 'failed', 'cancelled'] }),
+ step: nullable('string'), progress: { type: 'number', minimum: 0, maximum: 1 }, steps: arrayOf({ type: 'object', additionalProperties: true }),
+ question: str(), depth: str({ enum: ['quick', 'thorough', 'expert'] }), site_limit: integer(), price_usd: { type: 'number' },
+ candidates: arrayOf({ type: 'object', additionalProperties: true }), captures: arrayOf({ type: 'object', additionalProperties: true }),
+ report: nullable('object'), gaps: arrayOf(str()), error: nullable('object'), owner: nullable('object'),
+} };
+route('get', '/deep-dives/depths', {
+ operationId: 'deepDiveDepths', summary: 'The three depths, their site counts, minutes and price', tags: ['deep-dives'], security: [],
+ description: 'Public: what a deep dive costs before anyone signs in. The price is the run estimate held on the tier budgets, not a charge.',
+ responses: { 200: json('The depths', { type: 'object', required: ['depths'], properties: { depths: arrayOf({ type: 'object', required: ['key', 'sites', 'price_usd'], properties: { key: str({ enum: ['quick', 'thorough', 'expert'] }), label: str(), sites: integer(), minutes: integer(), price_usd: { type: 'number' }, analysis: str() } }), capture: nullable('object') } }), ...errors([429]) },
+});
+route('post', '/deep-dives', {
+ operationId: 'startDeepDive', summary: 'Start a deep dive (workflow design.deep_dive)', tags: ['deep-dives'], security: SERVICE,
+ description: 'ai.run.create. The price is computed from the depth and held; a person is on_behalf_of. The run makes its progress on GET /deep-dives/{id}/state and writes ai.deep-dive@1.',
+ requestBody: body({ type: 'object', required: ['question', 'depth'], properties: {
+ question: str({ minLength: 1, maxLength: 500 }), depth: str({ enum: ['quick', 'thorough', 'expert'] }),
+ sites: arrayOf(str({ format: 'uri' })), on_behalf_of: ref('SubjectRef'), idempotency_key: str({ minLength: 8, maxLength: 128 }),
+ target: { type: 'object' }, attribution: { type: 'object' },
+ } }),
+ responses: { 202: json('Accepted', { type: 'object', required: ['deep_dive'], properties: { deep_dive: deepDive, price_usd: { type: 'number' }, held_usd: { type: 'number' } } }), ...errors() },
+});
+route('get', '/deep-dives', {
+ operationId: 'listDeepDives', summary: "The caller's deep dives, newest first", tags: ['deep-dives'], security: SERVICE,
+ description: 'ai.run.read (or ai.run.create). Each item is the deep dive state, with its report when it has one.',
+ parameters: [query('limit', integer({ minimum: 1, maximum: 100 }))],
+ responses: { 200: json('The deep dives', { type: 'object', required: ['deep_dives'], properties: { deep_dives: arrayOf(deepDive) } }), ...errors() },
+});
+route('get', '/deep-dives/{id}', {
+ operationId: 'getDeepDive', summary: 'A deep dive: its state and, when it ended, the ai.deep-dive@1 report', tags: ['deep-dives'], security: SERVICE,
+ description: 'ai.run.read (or ai.run.create). Only the requester or the person on_behalf_of may read it; anyone else gets 404.',
+ parameters: [path('id', str({ pattern: '^run_' }))],
+ responses: { 200: json('The deep dive', { type: 'object', required: ['deep_dive'], properties: { deep_dive: deepDive } }), ...errors() },
+});
+route('get', '/deep-dives/{id}/state', {
+ operationId: 'getDeepDiveState', summary: 'Live step progress, candidates and status', tags: ['deep-dives'], security: SERVICE,
+ description: 'ai.run.read (or ai.run.create). Small enough to poll: steps, candidates, captures (ids only) and the error.',
+ parameters: [path('id', str({ pattern: '^run_' }))],
+ responses: { 200: json('The state', { type: 'object', required: ['deep_dive'], properties: { deep_dive: deepDive } }), ...errors() },
+});
+route('post', '/deep-dives/{id}/sites', {
+ operationId: 'setDeepDiveSites', summary: 'Add or drop sites before Capture, or start it', tags: ['deep-dives'], security: SERVICE,
+ description: 'ai.run.create. Allowed while the deep dive is choosing sites (Quick goes on by itself). Any call records the person\'s decision, so Capture starts.',
+ parameters: [path('id', str({ pattern: '^run_' }))],
+ requestBody: body({ type: 'object', properties: { add: arrayOf(str({ format: 'uri' })), drop: arrayOf(str()), start: { type: 'boolean' } } }),
+ responses: { 200: json('Updated', { type: 'object', required: ['deep_dive'], properties: { deep_dive: deepDive } }), ...errors() },
+});
+route('post', '/deep-dives/{id}/cancel', {
+ operationId: 'cancelDeepDive', summary: 'Cancel a deep dive; no later capture or model call starts', tags: ['deep-dives'], security: SERVICE,
+ description: 'ai.run.create. Cancels the underlying run.',
+ parameters: [path('id', str({ pattern: '^run_' }))],
+ responses: { 200: json('Cancelled', { type: 'object', required: ['deep_dive'], properties: { deep_dive: deepDive } }), ...errors() },
+});
+route('get', '/deep-dives/{id}/shots/{shot}', {
+ operationId: 'getDeepDiveShot', summary: 'One private screenshot or crop, as the owner', tags: ['deep-dives'], security: SERVICE,
+ description: 'ai.run.read (or ai.run.create). The bytes come from Media; the response is private and no-store. 503 when Media is not configured on this service.',
+ parameters: [path('id', str({ pattern: '^run_' })), path('shot', str({ pattern: '^(cap|crop)_' }))],
+ responses: { 200: { description: 'The image', content: { 'image/png': { schema: { type: 'string', format: 'binary' } } } }, ...errors([404, 503]) },
+});
+
// ── The budget of a run token (Codes C3) ────────────────────────────────────────────────────
// ── The budget of a run token (Codes C3) ────────────────────────────────────────────────────
route('put', '/run-budgets/{jti}', {
route('put', '/run-budgets/{jti}', {
operationId: 'putRunBudget', summary: "Register the budget of a run token", tags: ['runs'], security: SERVICE,
operationId: 'putRunBudget', summary: "Register the budget of a run token", tags: ['runs'], security: SERVICE,
@@ -361,7 +361,7 @@ function createRegistry(db, { clock = { now: () => Date.now() }, env = process.e
@@ -361,7 +361,7 @@ function createRegistry(db, { clock = { now: () => Date.now() }, env = process.e
schemas.assertSchema(w.output_schema, 'output_schema');
schemas.assertSchema(w.output_schema, 'output_schema');
if (!Array.isArray(w.steps) || !w.steps.length) throw new AiError(422, 'ai.invalid', 'workflow needs at least one step');
if (!Array.isArray(w.steps) || !w.steps.length) throw new AiError(422, 'ai.invalid', 'workflow needs at least one step');
for (const s of w.steps) {
for (const s of w.steps) {
- if (!s || !['llm', 'passthrough', 'complete', 'messages', 'transcribe', 'embed', 'image', 'speak', 'picture', 'media_analysis', 'research', 'devblog'].includes(s.kind)) throw new AiError(422, 'ai.invalid', 'step kind must be llm, passthrough, complete, messages, transcribe, embed, image, speak, picture, media_analysis or research');
+ if (!s || !['llm', 'passthrough', 'complete', 'messages', 'transcribe', 'embed', 'image', 'speak', 'picture', 'media_analysis', 'research', 'devblog', 'deep_dive'].includes(s.kind)) throw new AiError(422, 'ai.invalid', 'step kind must be llm, passthrough, complete, messages, transcribe, embed, image, speak, picture, media_analysis, research or deep_dive');
if ((s.kind === 'llm' || (s.kind === 'media_analysis' && s.template)) && !(s.template && await latestAny('templates', s.template))) throw new AiError(422, 'ai.invalid', `step references unknown template ${s.template}`);
if ((s.kind === 'llm' || (s.kind === 'media_analysis' && s.template)) && !(s.template && await latestAny('templates', s.template))) throw new AiError(422, 'ai.invalid', `step references unknown template ${s.template}`);
}
}
if (!['none', 'private', 'service'].includes(w.cache_mode)) throw new AiError(422, 'ai.invalid', 'cache_mode must be none, private or service');
if (!['none', 'private', 'service'].includes(w.cache_mode)) throw new AiError(422, 'ai.invalid', 'cache_mode must be none, private or service');
@@ -619,7 +619,11 @@ function createRuns({ choices = null, db, registry, engine, cache, quotas, confi
@@ -619,7 +619,11 @@ function createRuns({ choices = null, db, registry, engine, cache, quotas, confi
// The tier budgets (server/govern.js, AI_GOVERN_TIERS) hold first, so a run they refuse is not counted by reserve().
// The tier budgets (server/govern.js, AI_GOVERN_TIERS) hold first, so a run they refuse is not counted by reserve().
// L36 Y1: a run that may use the person's own key holds neither — their key is free to them, and a $0 balance
// L36 Y1: a run that may use the person's own key holds neither — their key is free to them, and a $0 balance
// anywhere must never block it. If it falls back to the network, the usage is recorded after the run.
// anywhere must never block it. If it falls back to the network, the usage is recorded after the run.
- const hold = credentialSubject !== null || projectProvider || selfKeys || !tiers ? null : await tiers.admit(ctx, id);
+ // A deep dive's price is held as the run's estimate (the route computed it from the depth, never the caller).
+ const holdAmount = wf.key === 'design.deep_dive' && body.options && body.options.hold && typeof body.options.hold === 'object'
+ ? { usd: Math.max(0, Math.min(100, Number(body.options.hold.usd) || 0)), tokens: Math.max(0, Math.min(50000000, Number(body.options.hold.tokens) || 0)) }
+ : null;
+ const hold = credentialSubject !== null || projectProvider || selfKeys || !tiers ? null : await tiers.admit(ctx, id, holdAmount);
let reserved;
let reserved;
try {
try {
// BA2/BA3: a batch run reserves its quota when the answer is here, not while it waits.
// BA2/BA3: a batch run reserves its quota when the answer is here, not while it waits.
new file mode 100644
new file mode 100644
@@ -0,0 +1,28 @@
@@ -0,0 +1,28 @@
+'use strict';
+/**
+ * Deep dive (the owner's 11-11 design research): expert research over every search we have, screenshots of the
+ * best sites at phone and computer sizes, a vision and a text model reading them, and an ai.deep-dive@1 report
+ * that OpenVibe.Exchange shows and OpenVibe.Design can attach. The whole run is one step: server/deep-dive/
+ * writes its own seven-step progress, so the page sees Plan, Search, Choose, Capture, Analyse, Synthesise and
+ * Report as they happen, and a person can add or drop sites before Capture.
+ *
+ * No cache: a deep dive is expensive, its screenshots are private to its owner, and its sources change daily.
+ * The price shown before the run is held on the tier budgets by POST /api/v1/deep-dives; the real cost is on the
+ * run's receipt (and in the report's receipt models).
+ */
+const { INPUT_SCHEMA, REPORT } = require('../deep-dive/schema');
+
+const workflows = [
+ {
+ key: 'design.deep_dive', name: 'Deep dive: expert design research', namespace: 'design',
+ description: 'Search every source we have, capture the best sites at 390 and 1280 px in light and dark, read them with a vision and a text model, and write an ai.deep-dive@1 report with its evidence and receipts.',
+ input_schema: INPUT_SCHEMA,
+ output_schema: REPORT,
+ steps: [{ kind: 'deep_dive' }],
+ default_route: null,
+ cache_mode: 'none', cache_ttl_sec: 0,
+ metadata: { seed: true, owner: 'exchange', retain_input: true },
+ },
+];
+
+module.exports = { templates: [], workflows };
@@ -166,7 +166,7 @@ function batchExec(b, result) {
@@ -166,7 +166,7 @@ function batchExec(b, result) {
};
};
}
}
-function createEngine({ registry, pool, fetcher, quotas = null, config = {}, spawnImpl, log = console, research = null, db = null, clock = { now: () => Date.now() }, mediaStore = null }) {
+function createEngine({ registry, pool, fetcher, quotas = null, config = {}, spawnImpl, log = console, research = null, db = null, clock = { now: () => Date.now() }, mediaStore = null, deepDive = null }) {
pool = require('../effort/pool').wrapPool(pool);
pool = require('../effort/pool').wrapPool(pool);
// Turn attachments use only the host's local speech recognizer. Reading an audio file must never
// Turn attachments use only the host's local speech recognizer. Reading an audio file must never
// select a paid transcription provider as a side effect of a chat request.
// select a paid transcription provider as a side effect of a chat request.
@@ -626,6 +626,22 @@ function createEngine({ registry, pool, fetcher, quotas = null, config = {}, spa
@@ -626,6 +626,22 @@ function createEngine({ registry, pool, fetcher, quotas = null, config = {}, spa
return { output, exec };
return { output, exec };
}
}
+ /**
+ * deep_dive: one durable run (server/deep-dive/) with its own seven-step progress, captures and report. It
+ * reads the run's owner and context, makes its own provider calls on the run's receipt and returns the
+ * ai.deep-dive@1 document; the run's usage and cost are the sum of those calls.
+ */
+ async function deepDiveStep(step, input, wf, run, ctx) {
+ if (!deepDive || typeof deepDive.run !== 'function') throw new AiError(503, 'deep_dive.unavailable', 'the deep dive is not configured on this service');
+ const r = await deepDive.run({ run, input, ctx });
+ const exec = {
+ provider: r.provider || (r.models && r.models.length ? 'mixed' : null), model: r.model || (r.models && r.models.length ? 'deep-dive' : null),
+ usage: r.usage || { input: 0, output: 0, cached: 0 }, cost: Number(r.cost) || 0,
+ synthetic: Boolean(r.synthetic), fallbackUsed: false, explain: null,
+ };
+ return { output: r.output, exec };
+ }
+
/** A research run's grounding: what it read is the output itself; the gaps are the sources that did not answer. */
/** A research run's grounding: what it read is the output itself; the gaps are the sources that did not answer. */
function researchGrounding(output) {
function researchGrounding(output) {
const gaps = Object.entries((output && output.providers) || {}).filter(([, p]) => p && !p.ok)
const gaps = Object.entries((output && output.providers) || {}).filter(([, p]) => p && !p.ok)
@@ -647,7 +663,7 @@ function createEngine({ registry, pool, fetcher, quotas = null, config = {}, spa
@@ -647,7 +663,7 @@ function createEngine({ registry, pool, fetcher, quotas = null, config = {}, spa
return { output, exec: { ...last.exec, usage, cost, savedUsd }, route: last.route };
return { output, exec: { ...last.exec, usage, cost, savedUsd }, route: last.route };
}
}
- const STEP = { devblog: devblogStep, llm: llmStep, passthrough: passthroughStep, complete: completeStep, messages: messagesStep, transcribe: transcribeStep, embed: embedStep, image: imageStep, speak: speakStep, picture: pictures.step, media_analysis: mediaAnalysis.step, research: researchStep };
+ const STEP = { deep_dive: deepDiveStep, devblog: devblogStep, llm: llmStep, passthrough: passthroughStep, complete: completeStep, messages: messagesStep, transcribe: transcribeStep, embed: embedStep, image: imageStep, speak: speakStep, picture: pictures.step, media_analysis: mediaAnalysis.step, research: researchStep };
/**
/**
* Run a workflow. ctx: { signal, logRequest, debugRaw, inputHash }
* Run a workflow. ctx: { signal, logRequest, debugRaw, inputHash }
@@ -22,6 +22,7 @@ const products = require('./products');
@@ -22,6 +22,7 @@ const products = require('./products');
const devblog = require('./devblog');
const devblog = require('./devblog');
const chat = require('./chat');
const chat = require('./chat');
const research = require('./research');
const research = require('./research');
+const deepDive = require('./deep-dive');
const meme = require('./meme');
const meme = require('./meme');
const changelog = require('./changelog');
const changelog = require('./changelog');
const pictures = require('./pictures');
const pictures = require('./pictures');
@@ -321,7 +322,7 @@ async function seed({ registry, quotas, config, env = process.env, db }) {
@@ -321,7 +322,7 @@ async function seed({ registry, quotas, config, env = process.env, db }) {
const { key, ...def } = t;
const { key, ...def } = t;
await registry.seedVersioned('template', key, { description: null, default_route: null, owner: 'ai', visibility: 'internal', metadata: {}, ...def });
await registry.seedVersioned('template', key, { description: null, default_route: null, owner: 'ai', visibility: 'internal', metadata: {}, ...def });
}
}
- for (const w of [...live.workflows, ...core.workflows, ...products.workflows, ...devblog.workflows, ...media.workflows, ...chat.workflows, ...space.workflows, ...research.workflows, ...meme.workflows, ...changelog.workflows, ...pictures.workflows, ...uiLayout.workflows]) {
+ for (const w of [...live.workflows, ...core.workflows, ...products.workflows, ...devblog.workflows, ...media.workflows, ...chat.workflows, ...space.workflows, ...research.workflows, ...deepDive.workflows, ...meme.workflows, ...changelog.workflows, ...pictures.workflows, ...uiLayout.workflows]) {
const { key, ...def } = w;
const { key, ...def } = w;
await registry.seedVersioned('workflow', key, { description: null, default_route: null, cache_ttl_sec: null, metadata: {}, ...def });
await registry.seedVersioned('workflow', key, { description: null, default_route: null, cache_ttl_sec: null, metadata: {}, ...def });
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,126 @@
@@ -0,0 +1,126 @@
+'use strict';
+// The capture worker, on a local fixture site only: robots.txt, the DOM facts, both viewports, dark where the
+// site answers prefers-color-scheme, a full page after a slow scroll, Expert extras (axe-core, LCP/CLS/INP, a
+// menu and a frame strip), the 45 s page budget's shape, and a disallowed page skipped. No outside site is read.
+// The browser tests need /usr/bin/google-chrome; without it the file says so and checks the rules that need no browser.
+const assert = require('assert');
+const fs = require('fs');
+const http = require('http');
+const { createCapture } = require('../server/capture');
+const { createRobots, parse, allowedBy } = require('../server/capture/robots');
+
+const CHROME = '/usr/bin/google-chrome';
+const chrome = fs.existsSync(CHROME);
+
+const FIXTURE = `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Fixture control panel</title>
+<meta name="description" content="A phone-first robot control panel fixture."><meta name="viewport" content="width=device-width, initial-scale=1">
+<style>
+:root{color-scheme: light dark}*{box-sizing:border-box}
+body{font-family:Inter,system-ui,sans-serif;background:#ffffff;color:#111111;margin:0;padding:16px;line-height:1.5}
+header{position:sticky;top:0;background:#ffffff;padding:8px 0}nav a{color:#111111;margin-right:12px;font-size:14px}
+h1{font-size:28px;line-height:1.2}h2{font-size:20px}.card{background:#f4f4f5;border-radius:12px;padding:16px;margin:12px 0;box-shadow:0 1px 2px rgba(0,0,0,.2)}
+button{font-size:16px;padding:12px 16px;border-radius:8px;background:#f5a623;border:0}
+@media (prefers-color-scheme: dark){body{background:#0c0e12;color:#eeeeee}header{background:#0c0e12}.card{background:#1b1d22}}
+</style></head><body>
+<header><nav aria-label="Main"><a href="#home">Home</a><a href="#robots">Robots</a><button aria-label="Menu" aria-expanded="false">Menu</button></nav></header>
+<main><h1>Control your robot</h1><p>One thumb reaches every control.</p>
+<div class="card"><h2>Camera</h2><p>Live view</p><button>Start camera</button></div>
+<section class="card"><h2>Drive</h2><p>Forward, back, and turn.</p><button>Forward</button> <button>Stop</button></section>
+${Array.from({ length: 8 }, (_, i) => `<section class="card"><h2>Section ${i + 1}</h2><p>${'Robots move slowly and carefully. '.repeat(18)}</p></section>`).join('')}
+</main></body></html>`;
+
+function fixtureServer({ robots = 'User-agent: *\nAllow: /\n' } = {}) {
+ const s = http.createServer((req, res) => {
+ if (req.url === '/robots.txt') { res.writeHead(200, { 'content-type': 'text/plain' }); return res.end(robots); }
+ if (req.url === '/secret') { res.writeHead(200, { 'content-type': 'text/html' }); return res.end('<html><body><h1>Secret</h1></body></html>'); }
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
+ res.end(FIXTURE);
+ });
+ return new Promise((resolve) => s.listen(0, '127.0.0.1', () => resolve({ server: s, port: s.address().port, close: () => new Promise((r) => { s.closeAllConnections?.(); s.close(() => r()); }) })));
+}
+
+(async () => {
+ // ── robots.txt: groups, longest match and wildcards, no browser needed ──
+ const groups = parse('User-agent: *\nDisallow: /private\nAllow: /private/ok\n');
+ assert.strictEqual(allowedBy(groups, '/public'), true);
+ assert.strictEqual(allowedBy(groups, '/private/x'), false);
+ assert.strictEqual(allowedBy(groups, '/private/ok/page'), true);
+ // A group that names our user agent replaces the * group for us.
+ const own = parse('User-agent: *\nAllow: /\n\nUser-agent: OpenVibeDeepDive\nDisallow: /\n');
+ assert.strictEqual(allowedBy(own, '/anything'), false);
+ assert.strictEqual(allowedBy(own, '/anything', 'SomeOtherBot'), true);
+ const wild = parse('User-agent: *\nDisallow: /*.pdf$\nDisallow: /tmp*\n');
+ assert.strictEqual(allowedBy(wild, '/a/b.pdf'), false);
+ assert.strictEqual(allowedBy(wild, '/a/b.pdf?x=1'), true);
+ assert.strictEqual(allowedBy(wild, '/tmp/x'), false);
+ assert.strictEqual(allowedBy([], '/'), true);
+ const robot = createRobots({ fetchImpl: async () => ({ ok: true, status: 200, text: async () => 'User-agent: *\nDisallow: /no\n' }) });
+ assert.strictEqual((await robot.check('https://example.org/no/page')).allowed, false);
+ assert.strictEqual((await robot.check('https://example.org/yes')).allowed, true);
+ process.stdout.write('capture: robots.txt rules ok\n');
+
+ if (!chrome) { process.stdout.write('capture: skipped (no /usr/bin/google-chrome; the browser checks need it)\n'); return; }
+
+ const site = await fixtureServer();
+ const deny = await fixtureServer({ robots: 'User-agent: *\nDisallow: /secret\n' });
+ const config = require('../server/config').load({ NODE_ENV: 'test', PORT: '0', AI_DEEP_DIVE: '1' });
+ config.capture.pageBudgetMs = 30000;
+ const uploads = [];
+ let n = 0;
+ const upload = async ({ bytes, mime, filename, metadata }) => {
+ assert.strictEqual(mime, 'image/png');
+ assert.ok(Buffer.isBuffer(bytes) && bytes.length > 1000);
+ uploads.push({ filename, metadata });
+ n += 1;
+ const tail = '01JAB2C3D4E5F6G7H8J9K0MNPQ'.slice(0, 24);
+ return { media_id: `med_${tail}${String(n).padStart(2, '0')}`, width: 390, height: 844 };
+ };
+ try {
+ const capture = createCapture({ config, log: { log() {}, warn() {} }, upload, allowLocal: true, robots: { check: async (url) => (url.includes(`:${deny.port}/secret`) ? { allowed: false, reason: 'robots.txt disallows this page' } : { allowed: true, reason: null }) } });
+ const started = Date.now();
+ const got = await capture.captureSite({ url: `http://127.0.0.1:${site.port}/`, runId: 'run_01JAB2C3D4E5F6G7H8J9K0MNPQ', owner: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ', extras: true });
+ const ms = Date.now() - started;
+ assert.strictEqual(got.skipped, null);
+ assert.ok(got.combos.length >= 2, `both viewports were captured (got ${got.combos.length})`);
+ assert.ok(got.combos.some((c) => c.viewport === 390 && c.theme === 'light'));
+ assert.ok(got.combos.some((c) => c.viewport === 1280 && c.theme === 'light'));
+ // The fixture answers prefers-color-scheme: the dark pair is captured too.
+ assert.ok(got.combos.some((c) => c.theme === 'dark'), 'the dark theme was captured');
+ for (const c of got.combos) {
+ assert.ok(c.height > 900, `the page is taller than a screen (${c.height})`);
+ assert.ok(c.crops.length >= 1, 'there is a crop for the point');
+ assert.ok(c.crops.every((x) => /^med_[0-9A-HJKMNP-TV-Z]{26}$/.test(x.media_id)));
+ assert.ok(c.dom, 'the DOM facts are recorded');
+ }
+ const light = got.combos.find((c) => c.viewport === 1280 && c.theme === 'light');
+ assert.ok(light.dom.fonts.families.some((f) => /Inter/i.test(f.value)), 'the type family is measured');
+ assert.ok(light.dom.fonts.sizes.some((s) => s.value === '28px'), 'the h1 size is measured');
+ assert.ok(light.dom.palette.backgrounds.some((p) => p.value === '#ffffff'), 'the palette carries the page background');
+ assert.ok(light.dom.spacing.length > 0 && light.dom.radii.some((r) => r.value === '12px'), 'spacing and radii are measured');
+ assert.ok(light.dom.shadows.some((s) => /rgba?\(/.test(s.value)), 'the shadow is measured');
+ assert.ok(light.dom.outline.some((h) => h.level === 1 && /Control your robot/.test(h.text)), 'the heading outline is recorded');
+ assert.ok(light.dom.buttons.some((b) => /Start camera/.test(b.text)), 'the button words are recorded');
+ assert.ok(light.dom.navigation.sticky === true, 'the sticky navigation is recorded');
+ assert.ok(light.dom.components.images >= 0 && light.dom.grid.page_height > 0, 'the component inventory and grid are recorded');
+ // Expert extras: axe-core findings, measured Web Vitals and a menu open.
+ assert.ok(light.extras, 'Expert extras are present on the 1280 light capture');
+ assert.ok(Array.isArray(light.extras.a11y.violations), 'axe-core answered with violations');
+ assert.ok(Number.isFinite(light.extras.metrics.lcp_ms), 'LCP was measured');
+ assert.ok(Number.isFinite(light.extras.metrics.cls), 'CLS was measured');
+ assert.ok(light.extras.menus.length >= 1 && light.extras.menus[0].after, 'the menu was opened and photographed');
+ assert.ok(light.extras.frames.length >= 2, 'a frame strip was taken');
+ assert.ok(uploads.length >= got.combos.length, 'every shot went to Media');
+ for (const u of uploads) assert.deepStrictEqual(Object.keys(u.metadata).includes('source_url'), true, 'each shot keeps its source URL');
+ assert.ok(ms < 120000, `the fixture capture fits its budget (${ms} ms)`);
+ process.stdout.write(`capture: fixture site ok (${got.combos.length} captures, ${uploads.length} shots, ${ms} ms)\n`);
+
+ // robots.txt disallowed: no browser and a reason the report can show.
+ const denied = await capture.captureSite({ url: `http://127.0.0.1:${deny.port}/secret`, runId: 'run_01JAB2C3D4E5F6G7H8J9K0MNPQ', owner: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ', extras: false });
+ assert.strictEqual(denied.skipped, 'robots_disallowed');
+ assert.match(denied.reason, /robots\.txt/);
+ process.stdout.write('capture: a robots-disallowed page is skipped and says so\n');
+ } finally {
+ await site.close();
+ await deny.close();
+ }
+})().catch((e) => { process.stderr.write(String(e && e.stack || e) + '\n'); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,200 @@
@@ -0,0 +1,200 @@
+'use strict';
+// Deep dives end to end: the price before the run, the Exchange service allowance, the seven steps with live
+// progress, choosing sites (add, drop, start), the report (ai.deep-dive@1) with its captures and receipt, cancel
+// stopping the spend, and a caller that may not read someone else's run. Search, research, capture and the Design
+// registers are fakes; no test reaches the internet and no outside site is read.
+const assert = require('assert');
+const { boot, request, token } = require('./helpers');
+
+const ALPHA = '01JAB2C3D4E5F6G7H8J9K0MNPQ';
+const USER = `usr_${ALPHA}`;
+let media = 0;
+const med = () => { media += 1; return `med_${ALPHA.slice(0, 24)}${String(media).padStart(2, '0')}`; };
+const json = (body) => ({ ok: true, status: 200, async json() { return body; }, async text() { return JSON.stringify(body); } });
+const html = (title) => ({ ok: true, status: 200, async json() { throw new Error('not json'); }, async text() { return `<html><head><title>${title}</title><meta name="description" content="A design reference"></head><body><h1>${title}</h1></body></html>`; } });
+
+function fixtures() {
+ const sourceFetch = async (url) => {
+ const u = new URL(url);
+ if (u.pathname === '/api/v1/rules') return json({ updated: '2026-11-11', areas: [{ id: 'phone', title: 'Phones first' }], rules: [
+ { id: 'tap-targets', area: 'phone', rule: 'Controls are big enough for a thumb.', detail: 'At least 44 px.', url: 'https://openvibe.design/rules#tap-targets' },
+ { id: 'honest-empty', area: 'honest', rule: 'An empty page says it is empty.', detail: 'No fake rows.', url: 'https://openvibe.design/rules#honest-empty' },
+ ] });
+ if (u.pathname === '/api/v1/components') return json({ version: 1, components: [{ name: 'action', title: 'Action', summary: 'A button that posts.' }] });
+ if (u.hostname === 'api.github.com') return json({ items: [{ full_name: 'acme/robot-ui', html_url: 'https://github.com/acme/robot-ui', description: 'A phone-first robot panel', stargazers_count: 120, homepage: 'https://robot.example', pushed_at: '2026-10-01T00:00:00Z' }] });
+ return html(`Reference ${u.pathname}`);
+ };
+ const research = { async gather() { return {
+ sources: [
+ { url: 'https://example.org/robot-panel', title: 'A phone-first robot control panel', snippet: 'One thumb reaches every control.', provider: 'search_fabric', license: null },
+ { url: 'https://example.org/dense-dashboard', title: 'A dense operations dashboard', snippet: 'Every robot in one screen.', provider: 'search_fabric' },
+ ],
+ images: [{ url: 'https://example.org/pic.jpg', source_url: 'https://example.org/pic', title: 'Panel reference', provider: 'openverse', license: 'CC0', author: 'A' }],
+ facts: [], providers: { search_fabric: { ok: true, count: 2 } },
+ }; } };
+ const search = { async search(kind) { return {
+ results: kind === 'web'
+ ? [{ title: 'A phone-first robot control panel', url: 'https://example.org/robot-panel', snippet: 'A design case study' }]
+ : [{ title: 'Reference', url: 'https://example.org/img', snippet: 'x', license: 'CC0' }],
+ receipt: { engine: 'openvibe', cost_usd: 0, time_ms: 5 },
+ }; } };
+ return { sourceFetch, research, search };
+}
+
+function fakeCapture({ delayMs = 0, calls = [] } = {}) {
+ let n = 0;
+ return {
+ calls,
+ available: () => true,
+ async captureSite({ url, extras, signal }) {
+ calls.push({ url, at: Date.now() });
+ if (delayMs) await new Promise((resolve) => { const t = setTimeout(resolve, delayMs); if (signal) signal.addEventListener('abort', () => { clearTimeout(t); resolve(); }, { once: true }); });
+ if (signal && signal.aborted) throw Object.assign(new Error('cancelled'), { code: 'run.cancelled' });
+ n += 1;
+ const combos = [];
+ for (const viewport of [390, 1280]) {
+ const extrasData = extras && viewport === 1280
+ ? { a11y: { violations: [{ id: 'button-name', impact: 'critical', help: 'Buttons must have an accessible name', nodes: 1 }], passes: 5 }, metrics: { lcp_ms: 1200, cls: 0.02, inp_ms: 90, long_tasks: 2 }, menus: [], frames: [] }
+ : null;
+ combos.push({
+ viewport, theme: 'light', full_page: true, media_id: med(), width: viewport, height: 2000,
+ crops: [{ id: `crop-${n}-${viewport}`, media_id: med(), y: 0, height: 844 }],
+ title: 'Fixture', final_url: url,
+ dom: { fonts: { families: [{ value: 'Inter', count: 10, share: 1 }], sizes: [{ value: '16px', count: 10, share: 1 }] }, palette: { text: [{ value: '#111111', count: 5 }], backgrounds: [{ value: '#ffffff', count: 5 }], borders: [] }, spacing: [{ value: '8px', count: 4 }], grid: { page_height: 2000 }, navigation: { sticky: true }, outline: [], buttons: [], links: [], components: {}, words: { h1: 'Robot' } },
+ extras: extrasData, captured_at: new Date().toISOString(), cached: false,
+ });
+ }
+ return { url, title: 'Fixture', final_url: url, combos, warnings: [], skipped: null, reason: null, ms: delayMs || 20 };
+ },
+ };
+}
+
+function env(over = {}) {
+ return { AI_DEEP_DIVE: '1', AI_DRILL: '0', AI_DESIGN_URL: 'https://design.invalid', AI_DEEP_DIVE_CHOOSE_WAIT_SEC: '60', AI_FREE_PROVIDERS: 'stub', AI_STUB_FALLBACK: 'true', ...over };
+}
+
+async function waitFor(h, id, tok, wanted, tries = 120) {
+ let last = null;
+ for (let i = 0; i < tries; i++) {
+ const r = await request(h.base, 'GET', `/api/v1/deep-dives/${id}/state`, { tok });
+ last = r.body && r.body.deep_dive;
+ if (last && wanted.includes(last.status)) return last;
+ await new Promise((r2) => setTimeout(r2, 250));
+ }
+ return last;
+}
+
+(async () => {
+ // ── The price, before anyone signs in ──
+ const f = fixtures();
+ const capture = fakeCapture();
+ const h = await boot({ env: env(), research: f.research, search: f.search, capture, deepDiveFetch: f.sourceFetch });
+ try {
+ let r = await request(h.base, 'GET', '/api/v1/deep-dives/depths');
+ assert.strictEqual(r.status, 200);
+ assert.deepStrictEqual(r.body.depths.map((d) => d.key), ['quick', 'thorough', 'expert']);
+ assert.strictEqual(r.body.depths[0].price_usd, 0.2);
+ assert.strictEqual(r.body.depths[0].sites, 6);
+ assert.strictEqual(r.body.depths[2].sites, 30);
+
+ // Exchange holds only ai.complete on ai.run.create: design.deep_dive is the workflow its page starts.
+ const exchange = token('exchange', ['ai.run.create'], { sub: 'svc:exchange', ns: ['ai.complete'] });
+ r = await request(h.base, 'POST', '/api/v1/deep-dives', { tok: exchange, body: { question: 'a phone-first robot control panel', depth: 'quick', on_behalf_of: { type: 'user', id: USER } } });
+ assert.strictEqual(r.status, 202);
+ assert.ok(/^run_/.test(r.body.deep_dive.id));
+ assert.strictEqual(r.body.price_usd, 0.2);
+ assert.strictEqual(r.body.held_usd, 0.2);
+ const id = r.body.deep_dive.id;
+
+ // A service that does not own the workflow is still refused by the namespace rule.
+ const live = token('live', ['ai.run.create'], { sub: 'svc:live', ns: ['live.*'] });
+ r = await request(h.base, 'POST', '/api/v1/deep-dives', { tok: live, body: { question: 'x', depth: 'quick' } });
+ assert.strictEqual(r.status, 403);
+
+ const done = await waitFor(h, id, exchange, ['succeeded', 'failed', 'cancelled']);
+ assert.strictEqual(done.status, 'succeeded', JSON.stringify(done.error));
+ assert.strictEqual(done.progress, 1);
+ assert.strictEqual(done.steps.length, 7);
+ assert.ok(done.steps.every((s) => s.status === 'done'), 'every step ended done');
+
+ // The whole report: ai.deep-dive@1, its sources, captures, analysis, synthesis and receipt.
+ r = await request(h.base, 'GET', `/api/v1/deep-dives/${id}`, { tok: exchange });
+ assert.strictEqual(r.status, 200);
+ const report = r.body.deep_dive.report;
+ assert.ok(report, 'the report is readable');
+ assert.strictEqual(report.schema, 'ai.deep-dive@1');
+ assert.strictEqual(report.input.question, 'a phone-first robot control panel');
+ assert.ok(report.sources.length >= 3, 'the sources are dated pages and projects');
+ assert.ok(report.captures.length >= 2, 'the captures are there');
+ assert.ok(report.captures.every((c) => /^med_/.test(c.media_id) || c.skip), 'a capture is a Media object or a skip');
+ assert.ok(report.captures.every((c) => c.crops.every((x) => /^med_/.test(x.media_id))));
+ assert.ok(report.analysis.length >= 1 && report.analysis[0].source_id);
+ assert.ok(report.analysis[0].evidence.length >= 1 && report.analysis[0].evidence[0].read_at, 'every analysis point cites its evidence');
+ assert.ok(report.synthesis.patterns.length >= 1 && report.synthesis.patterns[0].evidence.length >= 1);
+ assert.ok(report.recommendations.length >= 1 && report.recommendations[0].evidence.length >= 1);
+ assert.ok(report.receipt.models.length >= 4, 'the receipt lists every model call');
+ for (const step of ['plan', 'choose', 'analyse', 'synthesise']) assert.ok(report.receipt.models.some((m) => m.step === step), `the receipt carries the ${step} call`);
+ assert.ok(report.receipt.captures_count >= 2);
+ assert.deepStrictEqual(report.gaps.length >= 0, true);
+
+ // The run's own output is the same document, and the run receipt has the captures beside the model calls.
+ r = await request(h.base, 'GET', `/api/v1/runs/${id}`, { tok: exchange });
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(r.body.run.output.schema, 'ai.deep-dive@1');
+ assert.ok(r.body.requests.some((x) => x.operation === 'capture'), 'the receipt lists captures');
+
+ // Nobody else may read it.
+ const other = token('other', ['ai.run.create'], { sub: 'svc:other', ns: ['design.*'] });
+ r = await request(h.base, 'GET', `/api/v1/deep-dives/${id}`, { tok: other });
+ assert.strictEqual(r.status, 404);
+ r = await request(h.base, 'GET', `/api/v1/deep-dives/${id}/shots/${report.captures[0].crops[0].id}`, { tok: exchange });
+ assert.strictEqual(r.status, 503); // Media is not configured in this test; the owner check passed.
+
+ // Bad input is refused before a run exists.
+ r = await request(h.base, 'POST', '/api/v1/deep-dives', { tok: exchange, body: { question: '', depth: 'quick' } });
+ assert.strictEqual(r.status, 422);
+ r = await request(h.base, 'POST', '/api/v1/deep-dives', { tok: exchange, body: { question: 'x', depth: 'forever' } });
+ assert.strictEqual(r.status, 422);
+ process.stdout.write('deep dive: price, run, report, receipt and owner checks ok\n');
+
+ // ── Choosing sites: Thorough waits, the person adds one and drops one, then Capture starts ──
+ r = await request(h.base, 'POST', '/api/v1/deep-dives', { tok: exchange, body: { question: 'the pricing page of an AI API', depth: 'thorough', on_behalf_of: { type: 'user', id: USER } } });
+ assert.strictEqual(r.status, 202);
+ const thorough = r.body.deep_dive.id;
+ const choosing = await waitFor(h, thorough, exchange, ['choosing', 'failed', 'cancelled']);
+ assert.strictEqual(choosing.status, 'choosing', JSON.stringify(choosing.error));
+ assert.ok(choosing.candidates.length >= 1, 'the candidates are listed');
+ const drop = choosing.candidates[0].url;
+ r = await request(h.base, 'POST', `/api/v1/deep-dives/${thorough}/sites`, { tok: exchange, body: { add: ['https://example.org/pricing-extra'], drop: [drop], start: true } });
+ assert.strictEqual(r.status, 200);
+ assert.ok(r.body.deep_dive.candidates.some((c) => c.url === 'https://example.org/pricing-extra'), 'the added site is a candidate');
+ assert.ok(!r.body.deep_dive.candidates.some((c) => c.url === drop), 'the dropped site is gone');
+ const after = await waitFor(h, thorough, exchange, ['succeeded', 'failed', 'cancelled']);
+ assert.strictEqual(after.status, 'succeeded', JSON.stringify(after.error));
+ process.stdout.write('deep dive: choosing sites, add/drop and start ok\n');
+ } finally { await h.close(); }
+
+ // ── Cancel stops the spend: a slow capture is interrupted and no later site is called ──
+ const f2 = fixtures();
+ const slow = fakeCapture({ delayMs: 1500 });
+ const h2 = await boot({ env: env(), research: f2.research, search: f2.search, capture: slow, deepDiveFetch: f2.sourceFetch });
+ try {
+ const exchange = token('exchange', ['ai.run.create'], { sub: 'svc:exchange', ns: ['ai.complete'] });
+ const r = await request(h2.base, 'POST', '/api/v1/deep-dives', { tok: exchange, body: { question: 'onboarding for a crypto wallet', depth: 'quick', on_behalf_of: { type: 'user', id: USER } } });
+ assert.strictEqual(r.status, 202);
+ const id = r.body.deep_dive.id;
+ const capturing = await waitFor(h2, id, exchange, ['running'], 40);
+ assert.ok(capturing);
+ // Wait until the first capture is under way, then cancel.
+ for (let i = 0; i < 40 && !slow.calls.length; i++) await new Promise((r2) => setTimeout(r2, 100));
+ assert.ok(slow.calls.length >= 1, 'a capture started');
+ const c = await request(h2.base, 'POST', `/api/v1/runs/${id}/cancel`, { tok: exchange, body: {} });
+ assert.ok([200, 409].includes(c.status));
+ const ended = await waitFor(h2, id, exchange, ['cancelled', 'failed', 'succeeded']);
+ assert.strictEqual(ended.status, 'cancelled', JSON.stringify(ended.error));
+ const calls = slow.calls.length;
+ await new Promise((r2) => setTimeout(r2, 1600));
+ assert.strictEqual(slow.calls.length, calls, 'cancelling stopped every later capture');
+ process.stdout.write('deep dive: cancel stops the next capture and model call\n');
+ } finally { await h2.close(); }
+})().catch((e) => { process.stderr.write(String(e && e.stack || e) + '\n'); process.exit(1); });
@@ -45,7 +45,7 @@ function tmpDir() {
@@ -45,7 +45,7 @@ function tmpDir() {
// A boot on a dir used before is a restart on the same database: its database stays open.
// A boot on a dir used before is a restart on the same database: its database stays open.
const restartable = new Map();
const restartable = new Map();
-async function boot({ searchFetch, env = {}, dir, clock, fetchImpl, credentialFetch, research, machineModels, log, variantEvidence, community, communityEndpoints, webhooks, memoryTransport, memoryLookup, ownerNotify, mediaStore } = {}) {
+async function boot({ searchFetch, env = {}, dir, clock, fetchImpl, credentialFetch, research, search, capture, deepDive, deepDiveFetch, machineModels, log, variantEvidence, community, communityEndpoints, webhooks, memoryTransport, memoryLookup, ownerNotify, mediaStore } = {}) {
const reuse = dir !== undefined;
const reuse = dir !== undefined;
if (dir === undefined) dir = tmpDir();
if (dir === undefined) dir = tmpDir();
const fullEnv = {
const fullEnv = {
@@ -62,7 +62,7 @@ async function boot({ searchFetch, env = {}, dir, clock, fetchImpl, credentialFe
@@ -62,7 +62,7 @@ async function boot({ searchFetch, env = {}, dir, clock, fetchImpl, credentialFe
// An injected endpoint store needs the migrated database, so a factory is called with it before the app boots.
// An injected endpoint store needs the migrated database, so a factory is called with it before the app boots.
const endpointStore = typeof communityEndpoints === 'function' ? communityEndpoints(testdb.db) : communityEndpoints;
const endpointStore = typeof communityEndpoints === 'function' ? communityEndpoints(testdb.db) : communityEndpoints;
const webhookStore = typeof webhooks === 'function' ? webhooks(testdb.db) : webhooks;
const webhookStore = typeof webhooks === 'function' ? webhooks(testdb.db) : webhooks;
- const h = await start({ searchFetch, config, db: testdb.db, env: fullEnv, log: log || silent, clock, fetchImpl, credentialFetch, research: researcher, machineModels, variantEvidence, community, communityEndpoints: endpointStore, webhooks: webhookStore, memoryTransport, memoryLookup, ownerNotify, mediaStore });
+ const h = await start({ searchFetch, config, db: testdb.db, env: fullEnv, log: log || silent, clock, fetchImpl, credentialFetch, research: researcher, search, capture, deepDive, deepDiveFetch, machineModels, variantEvidence, community, communityEndpoints: endpointStore, webhooks: webhookStore, memoryTransport, memoryLookup, ownerNotify, mediaStore });
const base = `http://127.0.0.1:${h.server.address().port}`;
const base = `http://127.0.0.1:${h.server.address().port}`;
return { ...h, base, dir, env: fullEnv, testdb, stop: async () => { await h.close(); if (!reuse) await testdb.close(); } };
return { ...h, base, dir, env: fullEnv, testdb, stop: async () => { await h.close(); if (!reuse) await testdb.close(); } };
}
}
@@ -48,6 +48,7 @@ const EXAMPLES = {
@@ -48,6 +48,7 @@ const EXAMPLES = {
'content.wiki': { title: 'SQLite', topic: 'SQLite', space: 'SQLite', query: 'SQLite', instruction: 'Write the article from its sources.', outline: ['History'], pages: ['SQLite'], sources: SRC(2) },
'content.wiki': { title: 'SQLite', topic: 'SQLite', space: 'SQLite', query: 'SQLite', instruction: 'Write the article from its sources.', outline: ['History'], pages: ['SQLite'], sources: SRC(2) },
'wiki.generate_page': { title: 'WAL mode', topic: 'SQLite', pages: ['SQLite', 'WAL mode', 'Journal'], images: [{ title: 'WAL diagram', description: 'How WAL writes' }], sources: SRC(3) },
'wiki.generate_page': { title: 'WAL mode', topic: 'SQLite', pages: ['SQLite', 'WAL mode', 'Journal'], images: [{ title: 'WAL diagram', description: 'How WAL writes' }], sources: SRC(3) },
'research.gather': { query: 'SQLite', max_images: 4 },
'research.gather': { query: 'SQLite', max_images: 4 },
+ 'design.deep_dive': { question: 'a phone-first robot control panel', depth: 'quick' },
'blog.draft_post': { topic: 'Why we self-host', sources: SRC(1) },
'blog.draft_post': { topic: 'Why we self-host', sources: SRC(1) },
'blog.write_digest': { desk: 'News digest', about: 'Government and space agency announcements', period: '14 October 2026', sources: SRC(3) },
'blog.write_digest': { desk: 'News digest', about: 'Government and space agency announcements', period: '14 October 2026', sources: SRC(3) },
'blog.write_satire': { topic: 'Is it safe to let your air fryer vote?', angle: 'a consumer safety explainer' },
'blog.write_satire': { topic: 'Is it safe to let your air fryer vote?', angle: 'a consumer safety explainer' },
@@ -168,6 +169,8 @@ t.test('every workflow has compiling, versioned input and output schemas and an
@@ -168,6 +169,8 @@ t.test('every workflow has compiling, versioned input and output schemas and an
t.test('every workflow runs end to end on the stub and returns schema-valid output', async () => {
t.test('every workflow runs end to end on the stub and returns schema-valid output', async () => {
for (const w of await h.registry.listWorkflows()) {
for (const w of await h.registry.listWorkflows()) {
if (w.key === 'media.analyze' && !HAS_FFMPEG) { console.log('workflow media.analyze end to end: skipped (no ffprobe)'); continue; }
if (w.key === 'media.analyze' && !HAS_FFMPEG) { console.log('workflow media.analyze end to end: skipped (no ffprobe)'); continue; }
+ // A deep dive captures real sites: its own test brings a fake capture, search, research and registers.
+ if (w.key === 'design.deep_dive') { console.log('workflow design.deep_dive end to end: covered by test/deep-dive.test.js (no live capture)'); continue; }
// image.picture reads Commons/Openverse and writes Media: its own test brings a fake researcher and store.
// image.picture reads Commons/Openverse and writes Media: its own test brings a fake researcher and store.
if (w.key === 'image.picture') { console.log('workflow image.picture end to end: covered by test/image-picture.test.js (no live fetch)'); continue; }
if (w.key === 'image.picture') { console.log('workflow image.picture end to end: covered by test/image-picture.test.js (no live fetch)'); continue; }
const r = await request(h.base, 'POST', '/api/v1/runs?wait=10000', { tok, body: { workflow: w.key, input: EXAMPLES[w.key] } });
const r = await request(h.base, 'POST', '/api/v1/runs?wait=10000', { tok, body: { workflow: w.key, input: EXAMPLES[w.key] } });