openvibers/openvibe.design Commits
Research first: deep dive synthesis on /create, component pages and the rules register (#284)
Changes
nnew file mode 100644@@@ -0,0 +1,52 @@++-- phase: expand++-- "Research first" (owner ask, 2026-11-11): a design can be drawn from a deep dive. The dive is a durable++-- OpenVibe.AI run (workflow design.deep_dive); this table keeps the run's link, its report's numbered points,++-- the brief the generator reads, and the person's own view of it. The design points at it with a plain text id;++-- no foreign key, so a report kept here never blocks a design or a run.++CREATE TABLE IF NOT EXISTS design_research (++ id text COLLATE "C" PRIMARY KEY,++ owner text COLLATE "C" NOT NULL,++ question text NOT NULL,++ depth text COLLATE "C" NOT NULL DEFAULT 'thorough' CHECK (depth IN ('quick','thorough','expert')),++ sites jsonb NOT NULL DEFAULT '[]'::jsonb,++ run_id text COLLATE "C",++ status text COLLATE "C" NOT NULL DEFAULT 'starting'++ CHECK (status IN ('starting','running','done','failed','cancelled')),++ progress jsonb NOT NULL DEFAULT '{}'::jsonb,++ report jsonb,++ points jsonb NOT NULL DEFAULT '[]'::jsonb,++ brief text,++ report_url text,++ estimate jsonb,++ error text,++ created_at timestamptz NOT NULL,++ updated_at timestamptz NOT NULL++);++CREATE INDEX IF NOT EXISTS design_research_owner_recent ON design_research (owner, created_at DESC, id);++CREATE INDEX IF NOT EXISTS design_research_run ON design_research (run_id) WHERE run_id IS NOT NULL;++++-- A rule a report proposes. Nothing enters server/rules/register.js by itself: the owner reads the evidence on++-- /rules and approves or dismisses. Approval opens a pull request against the register; dismissal keeps the row++-- so the same proposal is not offered again.++CREATE TABLE IF NOT EXISTS rule_proposals (++ id text COLLATE "C" PRIMARY KEY,++ research_id text COLLATE "C" NOT NULL,++ owner text COLLATE "C" NOT NULL,++ rule text NOT NULL,++ detail text NOT NULL DEFAULT '',++ why text NOT NULL DEFAULT '',++ area text COLLATE "C" NOT NULL DEFAULT 'products',++ evidence jsonb NOT NULL DEFAULT '[]'::jsonb,++ state text COLLATE "C" NOT NULL DEFAULT 'proposed' CHECK (state IN ('proposed','approved','dismissed')),++ pr_url text,++ pr_branch text,++ pr_error text,++ decided_by text COLLATE "C",++ decided_at timestamptz,++ created_at timestamptz NOT NULL,++ updated_at timestamptz NOT NULL++);++CREATE INDEX IF NOT EXISTS rule_proposals_state ON rule_proposals (state, created_at DESC);++CREATE INDEX IF NOT EXISTS rule_proposals_research ON rule_proposals (research_id);++++ALTER TABLE designs ADD COLUMN IF NOT EXISTS research_id text COLLATE "C";nnew file mode 100644@@@ -0,0 +1,44 @@++/* Research first (2026-11-11): the plain, usable parts of a deep dive. The look is the main session's; this++ only makes the composer's module, one report and a component's findings readable at 390 and 1280. */++.dd-page { max-width: 74rem; margin: 0 auto; padding: 1.5rem 1rem 4rem; }++.dd-status { color: var(--text-secondary); font-size: .9rem; margin: .4rem 0 1rem; }++.dd-notice { padding: .6rem .8rem; border: 1px solid var(--border); border-radius: 10px; background: color-mix(in srgb, var(--accent) 8%, transparent); }++.dd-notice.is-error { border-color: color-mix(in srgb, #e5484d 50%, var(--border)); }++.dd-steps { display: flex; flex-wrap: wrap; gap: .4rem; list-style: none; padding: 0; margin: .8rem 0 1.2rem; }++.dd-step { padding: .3rem .65rem; border: 1px solid var(--border); border-radius: 999px; font-size: .82rem; color: var(--text-secondary); }++.dd-step.is-done { border-color: color-mix(in srgb, var(--accent) 55%, var(--border)); color: var(--text-primary); }++.dd-step.is-running { border-color: var(--accent); color: var(--text-primary); font-weight: 650; }++.dd-step.is-failed { border-color: #e5484d; }++.dd-sec { margin: 2rem 0; }++.dd-points { list-style: none; padding: 0; margin: 0; display: grid; gap: .5rem; }++.dd-points--page > li { display: flex; gap: .6rem; border-top: 1px solid var(--border); padding-top: .55rem; }++.dd-points--page p { margin: 0 0 .3rem; }++.dd-n { flex: none; display: inline-flex; align-items: center; justify-content: center; min-width: 1.55rem; height: 1.55rem; border-radius: 50%; background: color-mix(in srgb, var(--accent) 18%, transparent); font-size: .76rem; font-weight: 700; }++.dd-evidence { list-style: none; padding: 0; margin: .3rem 0; display: grid; gap: .3rem; }++.dd-ev { font-size: .82rem; color: var(--text-secondary); }++.dd-crop { display: block; max-width: min(100%, 22rem); height: auto; border: 1px solid var(--border); border-radius: 8px; margin: .3rem 0; }++.dd-find-list { list-style: none; padding: 0; margin: 1rem 0 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr)); gap: 1rem; }++.dd-find { display: flex; flex-direction: column; gap: .35rem; }++.dd-find .dd-crop { max-width: 100%; }++.dd-attrib { font-size: .8rem; color: var(--text-secondary); }++.dd-inline { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .85em; }++.dd-props { display: grid; gap: 1rem; }++.dd-prop { border: 1px solid var(--border); border-radius: 12px; padding: .9rem; }++.dd-prop h3 { margin: .2rem 0 .4rem; font-size: 1rem; }++.dd-prop-state { font-size: .72rem; font-weight: 700; text-transform: uppercase; letter-spacing: .04em; color: var(--text-secondary); }++.dd-prop-state.is-approved { color: #2f9e44; }++.dd-prop-acts { display: flex; flex-wrap: wrap; gap: .5rem; margin-top: .6rem; }++.dd-why span { font-weight: 700; color: var(--text-secondary); }++.dd-proposed { border-top: 1px solid var(--border); }++.dv-deep { display: grid; gap: .5rem; margin: .5rem 0; }++.dv-research { margin: 1.2rem 0; padding: .9rem; border: 1px solid var(--border); border-radius: 14px; }++.dv-research h2 { font-size: 1rem; margin: 0 0 .3rem; }++.dv-research-q { color: var(--text-secondary); font-size: .86rem; margin: 0 0 .5rem; }++.dv-take-research { font-size: .8rem; color: var(--text-secondary); margin: .3rem 0 0; }++.dv-take-research a { color: inherit; text-decoration: underline; }++@media (max-width: 480px) { .dd-find-list { grid-template-columns: 1fr; } .dd-page { padding-left: .8rem; padding-right: .8rem; } }++/* The composer hides the module's word on a phone; Research first keeps it, because "Off" alone says nothing. */++@media (max-width: 640px) {++ .dv-mod[data-mod="research"] > summary .dv-mod-k { display: inline; }++ .dv-mod[data-mod="research"] > summary b { min-width: 0; }++}@@@ -7,6 +7,22 @@const picked = (name) => (q(`input[name="${name}"]:checked`) || {}).value || '';const chosenModels = () => all('input[name="models"]:checked').map((x) => x.value);const takesEl = q('input[name="takes"]'), cost = q('[data-dv-cost]'), goLabel = q('[data-go-label]'), go = q('.dv-go');++ const researchOn = () => { const tick = q('input[name="research"]'); const id = q('select[name="research_id"]'); const ref = q('input[name="research_ref"]'); return Boolean((tick && tick.checked) || (id && id.value) || (ref && ref.value.trim())); };++ const researchValue = () => {++ const id = q('select[name="research_id"]'), ref = q('input[name="research_ref"]');++ if (ref && ref.value.trim()) return { ref: ref.value.trim() };++ if (id && id.value) return { id: id.value };++ const d = q('input[name="research_depth"]:checked');++ return { depth: d ? d.value : 'thorough' };++ };++ const fitResearch = () => {++ const on = researchOn(); const tick = q('input[name="research"]'); const id = q('select[name="research_id"]'), ref = q('input[name="research_ref"]');++ const fresh = q('[data-research-new]');++ if (fresh) fresh.hidden = !(tick && tick.checked) || Boolean((id && id.value) || (ref && ref.value.trim()));++ const attached = Boolean((id && id.value) || (ref && ref.value.trim()));++ const depth = q('input[name="research_depth"]:checked');++ if (tick) setText('research', attached ? 'Attached dive' : on ? (depth && depth.dataset.label ? `Research: ${depth.dataset.label}` : 'Research first') : 'Off');++ };const setText = (mod, text) => { const v = q(`.dv-mod[data-mod="${mod}"] [data-mod-v]`); if (v) v.textContent = text; };// What a choice in the tray shows: the name of what is chosen (and, for the look, its colours).const show = (input) => {@@@ -47,9 +63,10 @@if (picked('look')) b.look = picked('look');const m = chosenModels(); if (m.length) { b.models = m; b.takes = takesNow(); }if (startUrl()) b.start = { url: startUrl() };++ if (researchOn()) b.research = researchValue();return b;};-- const save = () => { try { localStorage.setItem(KEY, JSON.stringify({ kind: picked('kind'), who: picked('who'), look: picked('look'), frame: picked('frame'), models: chosenModels(), takes: takesNow() })); } catch { /* a private window */ } };++ const save = () => { try { localStorage.setItem(KEY, JSON.stringify({ kind: picked('kind'), who: picked('who'), look: picked('look'), frame: picked('frame'), models: chosenModels(), takes: takesNow(), research: researchOn() ? researchValue() : null })); } catch { /* a private window */ } };// What the run will cost, asked from the server as the choices settle (the same plan the run uses).const usd = (n) => (n >= 0.995 ? `$${n.toFixed(2)}` : n >= 0.01 ? `${Math.round(n * 100)}¢` : n > 0 ? 'under 1¢' : 'nothing');let costTimer = 0, costSeq = 0;@@@ -78,8 +95,14 @@['who', 'look', 'frame'].forEach((name) => { const el = last[name] && q(`input[name="${name}"][value="${CSS.escape(last[name])}"]`); if (el && !(el.closest('.dv-opt') && el.closest('.dv-opt').hidden)) { el.checked = true; show(el); } });(last.models || []).forEach((id) => { const el = q(`input[name="models"][value="${CSS.escape(id)}"]`); if (el) el.checked = true; });if (takesEl && last.takes) takesEl.value = String(last.takes);++ if (last.research) {++ if (last.research.depth) { const d = q(`input[name="research_depth"][value="${CSS.escape(last.research.depth)}"]`); if (d) d.checked = true; }++ if (last.research.id) { const sel = q('select[name="research_id"]'); if (sel) sel.value = last.research.id; }++ if (last.research.ref) { const r = q('input[name="research_ref"]'); if (r) r.value = last.research.ref; }++ const tick = q('input[name="research"]'); if (tick) tick.checked = !last.research.id && !last.research.ref;++ }} catch { fitFrames(); fitExamples(); }-- fitModels();++ fitModels(); fitResearch();const mods = all('.dv-mod');const closeMods = (except) => mods.forEach((m) => { if (m !== except) m.open = false; });form.addEventListener('change', (event) => {@@@ -88,6 +111,7 @@else if (input.name === 'models' || input.name === 'takes') fitModels();else if (input.name === 'start_url') setText('start', startUrl() ? (() => { try { return new URL(startUrl()).hostname; } catch { return 'A page'; } })() : 'Blank');else if (input.name === 'who') { all('input[name="models"]').forEach((x) => { x.checked = false; }); fitModels(); show(input); setTimeout(() => closeMods(null), 160); }++ else if (input.name === 'research' || input.name === 'research_depth' || input.name === 'research_id' || input.name === 'research_ref') { fitResearch(); }else if (input.closest('.dv-mod')) { show(input); setTimeout(() => closeMods(null), 160); }save(); askCost();});@@@ -96,6 +120,8 @@takesEl.value = String(takesNow() + Number(step.dataset.step)); fitModels(); save(); askCost();});prompt.addEventListener('blur', askCost);++ const researchRef = q('input[name="research_ref"]');++ if (researchRef) researchRef.addEventListener('input', () => { fitResearch(); save(); askCost(); });mods.forEach((m) => m.addEventListener('toggle', () => { if (m.open) closeMods(m); }));document.addEventListener('click', (event) => { const open = mods.find((m) => m.open); if (open && (!open.contains(event.target) || event.target === open)) closeMods(null); });document.addEventListener('keydown', (event) => { if (event.key === 'Escape') { const open = mods.find((m) => m.open); if (open) { closeMods(null); open.querySelector('summary').focus(); } } });@@@ -307,12 +333,18 @@if ([...canvas.querySelectorAll('.dv-card')].every(card => ['done', 'failed'].includes(card.dataset.status))) return;const url = `/api/v1/designs/${encodeURIComponent(designId)}${designKey ? `?key=${encodeURIComponent(designKey)}` : ''}`;const initial = [...canvas.querySelectorAll('.dv-card')].map(card => card.dataset.status).join(',');++ const researchState = () => {++ const box = canvas.querySelector('[data-dd-state]');++ return box ? box.dataset.ddState : '';++ };++ const stateOf = (research) => research ? `${research.status}|${((research.progress && research.progress.steps) || []).map((s) => s.state).join(',')}` : '';++ const initialResearch = researchState();const timer = setInterval(async () => {try {const response = await fetch(url, { cache: 'no-store' });if (!response.ok) return;const data = await response.json();-- if (data.variants.map(v => v.status).join(',') !== initial) {++ if (data.variants.map(v => v.status).join(',') !== initial || stateOf(data.design && data.design.research) !== initialResearch) {clearInterval(timer);location.reload();}@@@ -40,6 +40,12 @@ const { createMcpRoutes } = require('./http/mcp');const { createArenaApi } = require('./http/arena-api');const { createDesignApi } = require('./http/design-api');const { createDesigns } = require('./designs');++const { createDeepDiveAi } = require('./deepdive/ai');++const { createDeepDiveStore } = require('./deepdive/store');++const { createDeepDiveService } = require('./deepdive/service');++const { createDeepDiveApi } = require('./deepdive/api');++const { createRuleProposals } = require('./deepdive/proposals');++const { createRulePr } = require('./deepdive/github');const { createCreateOptions } = require('./create/options');const { createCreateApi } = require('./create/api');const { createBalanceReader } = require('./create/billing');@@@ -102,7 +108,14 @@ async function createApp(opts = {}) {// Sharing (openvibe-sdk/share): the shares row next to each signed-in maker's design, the one access// check every read path asks, and the uniform API below. Guest designs keep their secret edit key.const sharing = opts.sharing || createSharing({ db: s.db, config, log, now });-- const designs = opts.designs || createDesigns({ s, ai, bench, catalog, config, log, now, sharing });++ // "Research first": the dives Design follows, their reports' points, and the rules they propose. This is++ // created before the designs because a design's takes wait for its dive's brief.++ const deepDiveStore = opts.deepDiveStore || createDeepDiveStore({ s, log });++ const deepDiveAi = opts.deepDiveAi || createDeepDiveAi({ config, fetchImpl, log });++ const deepDive = opts.deepDive || createDeepDiveService({ s, config, fetchImpl, log, store: deepDiveStore, ai: deepDiveAi, now });++ const rulePr = opts.rulePr || createRulePr({ config, fetchImpl, log });++ const proposals = opts.proposals || createRuleProposals({ store: deepDiveStore, rulePr, config, log });++ const designs = opts.designs || createDesigns({ s, ai, bench, catalog, config, log, now, sharing, deepDive });// The create page's options, its estimate and its person's Balance, read from the services that own them// (AI's Exchange catalogue and choices, Network's themes, Billing's Balance), never a second hand-kept list.const balance = opts.balance || createBalanceReader({ config, fetchImpl, log });@@@ -122,7 +135,7 @@ async function createApp(opts = {}) {: async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Design cannot answer account events'); });const ctx = {-- config, s, store: s, keys, sso, settings, catalog, searchIndex, arena, bench, ai, jobs, designs, sharing, publisher, accountData, accountSend, log, picks, fonts, aiStore, maker, registry, createOptions, balance,++ config, s, store: s, keys, sso, settings, catalog, searchIndex, arena, bench, ai, jobs, designs, sharing, publisher, accountData, accountSend, log, picks, fonts, aiStore, maker, registry, createOptions, balance, deepDive, deepDiveStore, deepDiveAi, proposals, rulePr,random: opts.random || Math.random,};@@@ -295,6 +308,7 @@ async function createApp(opts = {}) {// ── API: the Arena, the glyphs (with their pages), then the theme proxy ──app.use('/api/v1/designs', express.json({ limit: '256kb' }), createDesignApi(ctx));app.use('/api', express.json({ limit: '64kb' }));++ app.use('/api', createDeepDiveApi(ctx));app.use('/api/create', createCreateApi(ctx));app.use('/api/v1/arena', createArenaApi(ctx));app.use('/api/v1/brand', require('./http/brand-api').createBrandApi(ctx));@@@ -334,7 +348,7 @@ async function createApp(opts = {}) {app.get('/openapi.json', require('./http/openapi-routes').openapiRoute(app, {title: `${d.name} API`, description: d.purpose, version: require('../package.json').version, baseUrl: site,docs: `${site}/developers`,-- files: ['http/api.js', 'http/arena-api.js', 'http/brand-api.js', 'http/design-api.js', 'http/fonts.js'].map((f) => require('path').join(__dirname, f)),++ files: ['http/api.js', 'http/arena-api.js', 'http/brand-api.js', 'http/design-api.js', 'http/fonts.js', 'deepdive/api.js'].map((f) => require('path').join(__dirname, f)),}));}app.use('/api', (req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', {@@@ -64,6 +64,10 @@ function load(env = process.env) {maxHtmlBytes: 200 * 1024,},++ // Who alone may decide a rule a report proposes (the Network owner account). A username, as Network's own++ // owner guard uses, with the owner role as the other accepted claim.++ ownerUsername: String(env.DESIGN_OWNER_USERNAME || env.OWNER_USERNAME || 'goosely'),++// OpenVibe.Exchange's icon reference search (the Exchange platform API at api.openvibe.exchange, served by// OpenVibe.AI): Design mints its own client-credentials token (audience openvibe.ai, scope// exchange.search.icons) and needs no Exchange key. EXCHANGE_SEARCH_KEY is the owner's override.@@@ -75,6 +79,35 @@ function load(env = process.env) {timeoutMs: Math.max(1000, int(env.DESIGN_EXCHANGE_TIMEOUT_MS, 5000)),},++ // A deep dive (contract ai.deep-dive@1): an AI run on the person's behalf, priced per depth before it starts.++ // The workflow and the prices are AI's; DESIGN_DEEP_DIVE_PRICES is only the fallback for a deployment whose++ // options endpoint does not answer yet (quick=0.2,thorough=0.5,expert=1.2).++ deepDive: {++ url: trim(env.DESIGN_DEEP_DIVE_URL || env.OV_AI_INTERNAL_URL || aiUrl),++ workflow: env.DESIGN_DEEP_DIVE_WORKFLOW || 'design.deep_dive',++ optionsPath: env.DESIGN_DEEP_DIVE_OPTIONS_PATH || '',++ startPath: env.DESIGN_DEEP_DIVE_START_PATH || '',++ deepDivesPath: env.DESIGN_DEEP_DIVE_START_PATH ? '' : '',++ prices: env.DESIGN_DEEP_DIVE_PRICES || 'quick=0.2,thorough=0.5,expert=1.2',++ reportPath: env.DESIGN_DEEP_DIVE_REPORT_PATH || '/deep-dive/:id',++ cropPath: env.DESIGN_DEEP_DIVE_CROP_PATH || '/api/v1/deep-dives/:runId/shots/:cropId',++ publicReportBase: trim(env.OV_EXCHANGE_URL || 'https://openvibe.exchange'),++ exchangeUrl: trim(env.OV_EXCHANGE_INTERNAL_URL || 'http://127.0.0.1:5150'),++ timeoutMs: Math.max(1000, int(env.DESIGN_DEEP_DIVE_TIMEOUT_MS, 30_000)),++ pollMs: Math.max(200, int(env.DESIGN_DEEP_DIVE_POLL_MS, 4000)),++ waitMs: Math.max(0, int(env.DESIGN_DEEP_DIVE_WAIT_MS, 0)),++ maxWaitMs: Math.max(30_000, int(env.DESIGN_DEEP_DIVE_MAX_WAIT_MS, 15 * 60_000)),++ // The owner approves a proposed rule on /rules; the approval opens a pull request on the register with++ // this token. Without one the approval is recorded and the page says a PR needs the token: nothing is++ // added to the register by itself.++ githubToken: env.DESIGN_RULES_GITHUB_TOKEN || '',++ repo: env.DESIGN_RULES_REPO || 'OpenVibers/OpenVibe.Design',++ registerPath: env.DESIGN_RULES_PATH || 'server/rules/register.js',++ branchPrefix: env.DESIGN_RULES_BRANCH_PREFIX || 'rules/research',++ ownerUsername: String(env.DESIGN_OWNER_USERNAME || env.OWNER_USERNAME || 'goosely'),++++ },++// OpenVibe.Media: a starting picture is uploaded there by the page and the create request carries its id;// Design names its public file address in the prompt and never takes raw image bytes.media: {@@@ -65,7 +65,7 @@ function createCreateApi(ctx) {routes.post('/estimate', sameOrigin, safe(async (req, res) => {const all = await ctx.createOptions.options({ token: token(req) });const plan = await resolvePlan(req.body || {}, {-- options: all, designs: ctx.designs, ai: ctx.ai, owner: owner(req), key: requestKey(req), viewer: viewer(req), config: ctx.config,++ options: all, designs: ctx.designs, ai: ctx.ai, deepDive: ctx.deepDive, owner: owner(req), key: requestKey(req), viewer: viewer(req), config: ctx.config,});const estimate = estimatePlan(plan);const signedIn = Boolean(owner(req));@@@ -81,7 +81,7 @@ function createCreateApi(ctx) {routes.post('/', sameOrigin, safe(async (req, res) => {const all = await ctx.createOptions.options({ token: token(req) });const plan = await resolvePlan(req.body || {}, {-- options: all, designs: ctx.designs, ai: ctx.ai, owner: owner(req), key: requestKey(req), viewer: viewer(req), config: ctx.config,++ options: all, designs: ctx.designs, ai: ctx.ai, deepDive: ctx.deepDive, owner: owner(req), key: requestKey(req), viewer: viewer(req), config: ctx.config,});const estimate = estimatePlan(plan);const out = await ctx.designs.create({@@@ -97,6 +97,7 @@ function createCreateApi(ctx) {sourceDesignId: plan.start.source_design_id || null,estimate,who: (req.body || {}).who,++ research: plan.research,owner: owner(req), ip: req.ip,});if (out.editKey) {@@@ -89,6 +89,43 @@ async function resolvePlan(body = {}, ctx = {}) {}if (!prompt && !containsStart(start)) throw fail(422, 'create.prompt', 'Describe the design first.');++ // "Research first": a deep dive's report becomes part of the generation brief. Attaching an existing dive++ // adds nothing to the price; starting one is priced from AI's own depths before the plan is returned.++ let research = null;++ const rawResearch = raw.research;++ if (rawResearch != null && rawResearch !== false && rawResearch !== '' && rawResearch !== 'off') {++ if (!ctx.deepDive) throw fail(503, 'create.research', 'Deep dives are not available here.');++ const value = typeof rawResearch === 'string' ? { ref: rawResearch } : (typeof rawResearch === 'object' ? rawResearch : {});++ const ref = value.id || value.ref || value.run_id || null;++ if (ref) {++ // A dive Design already follows, or one the person ran on OpenVibe Exchange and pasted here. Attaching++ // is a read: it starts no run and adds nothing to the price.++ let row;++ try {++ const attached = await ctx.deepDive.attach({ owner, token: ctx.viewer && ctx.viewer.token, ref: String(ref) });++ row = attached.research;++ } catch (err) {++ if (err.status === 401 || err.status === 403) throw fail(err.status, 'create.research', err.detail || 'That deep dive is not yours to attach.');++ throw fail(err.status || 404, 'create.research', err.detail || 'That deep dive was not found.');++ }++ if (!row) throw fail(404, 'create.research', 'That deep dive was not found.');++ if (['failed', 'cancelled'].includes(row.status)) throw fail(409, 'create.research', 'That deep dive did not finish; attach one with a report.');++ research = { mode: 'attach', id: row.id, runId: row.runId || null, depth: row.depth, question: row.question,++ status: row.status, price_usd: 0, currency: 'USD', points: row.pointsCount || 0 };++ } else {++ const wanted = String(value.depth || value.mode || 'thorough').toLowerCase();++ if (!['quick', 'thorough', 'expert'].includes(wanted)) throw fail(422, 'create.research', 'Choose Quick, Thorough or Expert for the deep dive.');++ let depths = [];++ try { depths = (await ctx.deepDive.options()).depths || []; } catch { depths = []; }++ const chosen = depths.find((d) => d.id === wanted) || null;++ const sites = Array.isArray(value.sites) ? value.sites : String(value.sites || '').split(/\r?\n|,/).map((x) => x.trim()).filter(Boolean);++ research = { mode: 'new', depth: wanted, question: String(value.question || prompt || start.title || 'A design').slice(0, 500),++ sites: sites.filter((x) => /^https?:\/\//i.test(x)).slice(0, 30),++ price_usd: chosen && chosen.price_usd != null ? Number(chosen.price_usd) : null,++ currency: (chosen && chosen.currency) || 'USD', sites_count: chosen && chosen.sites || null, points: 0 };++ }++ }++const catalogue = Array.isArray(options.models) ? options.models : [];const wanted = Array.isArray(raw.models) ? raw.models.map((id) => String(id == null ? '' : id).trim()).filter(Boolean) : [];const ids = [...new Set(wanted)];@@@ -150,7 +187,7 @@ async function resolvePlan(body = {}, ctx = {}) {look: look.id, lookName: look.name, lookSource: look.source, lookVariables: look.variables || {},themeId: look.themeId || (look.source === 'theme' ? look.id : null),start, startText,-- models, takes, slots, legacy,++ models, takes, slots, legacy, research,input_tokens, output_tokens,};}@@@ -185,18 +222,28 @@ function estimatePlan(plan) {entry.priced = entry.priced || isPriced;}const perModel = [...per.values()];-- const total = micro(perModel.reduce((sum, entry) => sum + entry.cost_usd, 0));-- const cost = { currency: 'USD', per_model: perModel, total_usd: total, total };++ const modelTotal = micro(perModel.reduce((sum, entry) => sum + entry.cost_usd, 0));++ const research = plan.research ? {++ mode: plan.research.mode, depth: plan.research.depth, question: plan.research.question,++ id: plan.research.id || null, status: plan.research.status || null,++ price_usd: plan.research.price_usd == null ? null : micro(Number(plan.research.price_usd)),++ currency: plan.research.currency || 'USD', priced: plan.research.price_usd != null,++ sites: plan.research.sites_count || null,++ } : null;++ const researchCost = research && research.price_usd != null ? Number(research.price_usd) : 0;++ const total = micro(modelTotal + researchCost);++ const cost = { currency: 'USD', per_model: perModel, research, total_usd: total, total };return {currency: 'USD',per_model: perModel,++ research,total_usd: total,cost,takes: (plan.slots || []).length,models: perModel.length,input_tokens: plan.input_tokens,output_tokens: plan.output_tokens,-- priced,++ priced: priced || (research ? research.priced : false),};}nnew file mode 100644@@@ -0,0 +1,313 @@++'use strict';++++/**++ * OpenVibe.Design's way to a deep dive (contract ai.deep-dive@1, OpenVibe.AI's workflow design.deep_dive).++ *++ * A dive is an ordinary durable AI run. Design starts it as the first-party service `design`, on the person's++ * behalf (so AI holds their Balance for the depth's price), and reads the run while it works. When the run++ * finishes its output is the report. The run id, not a second copy of the pipeline, is the source of truth.++ *++ * The paths are read from configuration because the AI and Exchange deployments of the feature ship together:++ * `ai.runsPath` is /api/v1/runs, `ai.deepDivesPath` is the feature's own front door when a deployment has one,++ * and the options path gives the depths and their price. A 404 falls through to the next path.++ */++++const { serviceAuth } = require('openvibe-contracts');++const { normalizeReport, progressOf } = require('./report');++const { STEP_NAMES } = require('./report');++++class DeepDiveError extends Error {++ constructor(status, code, detail) {++ super(detail || code);++ this.name = 'DeepDiveError';++ this.status = status;++ this.code = code;++ this.detail = detail || code;++ }++}++++const DEPTH_NAMES = { quick: 'Quick', thorough: 'Thorough', expert: 'Expert' };++++function parsePrices(raw) {++ const out = {};++ for (const part of String(raw || '').split(',')) {++ const [key, value] = part.split('=').map((x) => x && x.trim());++ const n = Number(value);++ if (key && Number.isFinite(n) && n >= 0) out[key] = n;++ }++ return out;++}++++function createDeepDiveAi({ config, fetchImpl = globalThis.fetch, log = console }) {++ const ai = config.ai || {};++ const dd = config.deepDive || {};++ const base = String(dd.url || ai.url || '').replace(/\/+$/, '');++ const tokenClient = config.oauth && config.oauth.clientSecret && base++ ? serviceAuth.createTokenClient({++ tokenUrl: `${config.networkInternalUrl || config.networkUrl}/oauth/token`,++ clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,++ audience: ai.audience || 'openvibe.ai', scope: ai.scope || 'ai.run.create', fetchImpl,++ })++ : null;++ const enabled = Boolean(base && tokenClient);++ const timeoutMs = Math.max(1000, Number(dd.timeoutMs) || 30_000);++ const pollMs = Math.max(200, Number(dd.pollMs) || 4000);++ const waitMs = Math.max(0, Number(dd.waitMs) || 0);++ const fallbackPrices = parsePrices(dd.prices);++ const publicBase = String(dd.publicReportBase || 'https://openvibe.exchange').replace(/\/+$/, '');++ const reportPath = String(dd.reportPath || '/deep-dive/:id');++ const exchangeBase = String(dd.exchangeUrl || '').replace(/\/+$/, '');++++ async function headers({ token = null, retried = false } = {}) {++ if (token) return { Accept: 'application/json', Authorization: `Bearer ${token}` };++ if (!tokenClient) throw new DeepDiveError(503, 'deep_dive.not_configured', 'OpenVibe.AI is not configured for deep dives.');++ try { return { Accept: 'application/json', ...(await tokenClient.authHeaders()) }; } catch {++ if (!retried) return headers({ token, retried: true });++ throw new DeepDiveError(502, 'deep_dive.token_failed', 'OpenVibe.Network did not issue an AI token for a deep dive.');++ }++ }++++ async function call(method, path, body, { token = null, retried = false, baseUrl = base } = {}) {++ if (!baseUrl) throw new DeepDiveError(503, 'deep_dive.not_configured', 'OpenVibe.AI is not configured for deep dives.');++ let h;++ try { h = await headers({ token }); } catch (err) { throw err; }++ let res;++ try {++ res = await fetchImpl(`${baseUrl}${path}`, {++ method, headers: { ...h, ...(body ? { 'Content-Type': 'application/json' } : {}) },++ body: body ? JSON.stringify(body) : undefined,++ signal: AbortSignal.timeout(timeoutMs),++ });++ } catch (err) {++ throw new DeepDiveError(502, 'deep_dive.unreachable', err && err.name === 'TimeoutError'++ ? 'OpenVibe.AI did not answer in time.' : 'OpenVibe.AI could not be reached.');++ }++ if (res.status === 401 && !token && !retried) {++ tokenClient.invalidate();++ return call(method, path, body, { token, retried: true, baseUrl });++ }++ const json = await res.json().catch(() => null);++ if (!res.ok) {++ const code = (json && (json.code || json.error)) || `deep_dive.http_${res.status}`;++ const detail = (json && (json.detail || json.message)) || `OpenVibe.AI refused the deep dive (${res.status}).`;++ throw new DeepDiveError(res.status >= 500 ? 502 : res.status, String(code).slice(0, 80), String(detail).slice(0, 500));++ }++ return json;++ }++++ /** The depths and what each costs, before a run starts. AI's own answer when it has one. */++ async function options({ token = null } = {}) {++ const seen = new Set();++ const candidates = [];++ if (dd.optionsPath) candidates.push({ baseUrl: base, path: dd.optionsPath });++ candidates.push({ baseUrl: base, path: '/api/v1/deep-dives/depths' }, { baseUrl: base, path: '/api/v1/deep-dives/options' },++ { baseUrl: base, path: '/api/v1/deep-dive/options' }, { baseUrl: base, path: '/api/v1/deep-dives/prices' });++ if (exchangeBase) candidates.push({ baseUrl: exchangeBase, path: '/api/v1/deep-dives/options' }, { baseUrl: exchangeBase, path: '/api/v1/exchange/deep-dives/options' });++ for (const c of candidates) {++ const key = `${c.baseUrl}${c.path}`;++ if (seen.has(key)) continue;++ seen.add(key);++ try {++ const body = await call('GET', c.path, null, { token, baseUrl: c.baseUrl });++ const out = normalizeOptions(body);++ if (out) return { ...out, source: 'ai' };++ } catch (err) {++ if (err.status !== 404 && err.status !== 405) log.warn('[OpenVibe.Design] deep dive options:', err.message);++ }++ }++ return fallbackOptions();++ }++++ function fallbackOptions() {++ const depths = ['quick', 'thorough', 'expert'].map((id) => ({++ id, name: DEPTH_NAMES[id], sites: id === 'quick' ? 6 : id === 'thorough' ? 15 : 30,++ minutes: id === 'quick' ? 3 : null, price_usd: fallbackPrices[id] == null ? null : Number(fallbackPrices[id]),++ currency: 'USD',++ line: id === 'quick' ? 'Six sites, about three minutes' : id === 'thorough' ? 'Fifteen sites' : 'Thirty sites, plus interactions, accessibility and speed',++ }));++ return { depths, priced: depths.some((d) => d.price_usd != null), source: 'config', note: depths.some((d) => d.price_usd != null) ? null : 'The price is shown on OpenVibe Exchange before the dive starts.' };++ }++++ function normalizeOptions(body) {++ const root = Array.isArray(body) ? { depths: body } : (body && (body.deep_dive || body.deepDive) ? (body.deep_dive || body.deepDive) : body);++ const raw = root && (root.depths || root.options || root.depth_options || root.levels);++ if (!Array.isArray(raw) || !raw.length) {++ // A prices-only answer: { prices: { quick: 0.25, … } } or { quick: { price_usd } }.++ const prices = root && (root.prices || root.depth_prices);++ if (prices && typeof prices === 'object' && !Array.isArray(prices)) {++ const depths = ['quick', 'thorough', 'expert'].map((id) => {++ const v = prices[id];++ if (v == null) return null;++ const price = typeof v === 'object' ? (v.price_usd != null ? v.price_usd : v.price) : v;++ const extra = typeof v === 'object' ? v : {};++ return { id, name: extra.name || DEPTH_NAMES[id], sites: Number(extra.sites || extra.max_sites) || null,++ minutes: Number(extra.minutes) || null, price_usd: Number(price) || 0, currency: String(extra.currency || 'USD'), line: extra.line || null };++ }).filter(Boolean);++ if (depths.length) return { depths, priced: true, source: 'ai', note: root.note || null };++ }++ return null;++ }++ const depths = raw.map((d) => {++ const id = String(d.id || d.key || d.depth || d.name || '').toLowerCase();++ const known = ['quick', 'thorough', 'expert'].includes(id) ? id : null;++ if (!known) return null;++ return {++ id: known, name: String(d.name || d.label || DEPTH_NAMES[known]),++ sites: Number(d.sites || d.site_count || d.max_sites) || null,++ minutes: Number(d.minutes || d.minutes_about) || null,++ price_usd: Number(d.price_usd != null ? d.price_usd : d.price) || 0,++ currency: String(d.currency || 'USD'),++ line: String(d.line || d.description || d.analysis || '') || null,++ };++ }).filter(Boolean);++ if (!depths.length) return null;++ return { depths, priced: depths.every((d) => d.price_usd != null), source: 'ai', note: root.note || null };++ }++++ /** The run body both doors accept; the report is the run's output. */++ function runBody({ question, sites = [], depth = 'thorough', person = null, target = null }) {++ // ai.deep-dive@1's input: the question, the depth, the depth's site budget and the sites the person named.++ const wanted = ['quick', 'thorough', 'expert'].includes(String(depth)) ? String(depth) : 'thorough';++ const maxSites = wanted === 'quick' ? 6 : wanted === 'thorough' ? 15 : 30;++ const input = { question: String(question || '').slice(0, 500), depth: wanted, max_sites: maxSites,++ sites: sites.map((s) => String(s).slice(0, 2000)).filter((s) => /^https?:\/\//i.test(s)).slice(0, 30) };++ return {++ workflow: dd.workflow || 'design.deep_dive',++ input,++ options: { cache: false },++ ...(person ? { on_behalf_of: { type: 'user', id: String(person) } } : {}),++ ...(target ? { target } : {}),++ };++ }++++ /** Start a dive on the person's behalf. The id is kept before any report exists, so a failed run is still a record. */++ async function start({ question, sites = [], depth = 'thorough', person = null, target = null, idempotencyKey = null } = {}) {++ const body = runBody({ question, sites, depth, person, target });++ if (idempotencyKey) body.idempotency_key = String(idempotencyKey).slice(0, 200);++ const paths = [];++ if (dd.startPath) paths.push({ path: dd.startPath, body });++ // The feature's own door (OpenVibe.AI's /api/v1/deep-dives) takes the dive's input and the person it is for.++ // It is preferred over the generic run API: it computes the depth's price and hold itself, and its route is++ // what the Exchange page and Design share.++ paths.push({ path: '/api/v1/deep-dives', body: { ...body.input, ...(body.idempotency_key ? { idempotency_key: body.idempotency_key } : {}),++ ...(body.on_behalf_of ? { on_behalf_of: body.on_behalf_of } : {}), ...(body.target ? { target: body.target } : {}) } });++ paths.push({ path: '/api/v1/deep-dives', body });++ paths.push({ path: '/api/v1/runs?wait=0', body });++ if (dd.deepDivesPath) paths.push({ path: dd.deepDivesPath, body: body.input });++ paths.push({ path: '/api/v1/deep-dives', body: body.input });++ let last = null;++ for (const p of paths) {++ try {++ const out = await call('POST', p.path, p.body);++ const run = out && (out.run || out.deep_dive || out.deepDive || out);++ const id = run && (run.id || run.run_id || run.deep_dive_id);++ if (!id) throw new DeepDiveError(502, 'deep_dive.bad_answer', 'OpenVibe.AI started a deep dive without an id.');++ return { runId: String(id), run: run.run || run, raw: out };++ } catch (err) {++ last = err;++ if (![403, 404, 405, 422].includes(err.status)) throw err;++ }++ }++ throw last || new DeepDiveError(502, 'deep_dive.start_failed', 'The deep dive could not be started.');++ }++++ /** A dive's own document, from the feature's route when its run output is not the report itself. */++ async function deepDiveDoc(runId, { token = null } = {}) {++ const id = String(runId || '');++ const out = await call('GET', `/api/v1/deep-dives/${encodeURIComponent(id)}`, null, { token });++ const doc = out && (out.deep_dive || out.deepDive || out.report || out.run || out);++ if (!doc || typeof doc !== 'object') throw new DeepDiveError(502, 'deep_dive.bad_answer', 'OpenVibe.AI answered without a deep dive.');++ const status = doc.status || (out && out.run && out.run.status) || 'succeeded';++ const report = doc.report && typeof doc.report === 'object' ? doc.report : null;++ return { run: { id, status, output: doc.report || doc.output || doc, error: doc.error || null,++ input: doc.input || (report && report.input) || { question: doc.question, depth: doc.depth, max_sites: doc.site_limit, sites: [] },++ usage: doc.receipt || null, explain: { deep_dive: { steps: doc.steps || [], progress: doc.progress, current: doc.step || doc.current || null } } }, citations: [] };++ }++++ /** One run, with the person's own token when attaching a dive they ran on Exchange. */++ async function getRun(runId, { token = null } = {}) {++ const id = String(runId || '');++ if (!/^[A-Za-z0-9._~-]{1,160}$/.test(id)) throw new DeepDiveError(422, 'deep_dive.invalid_id', 'That is not a deep dive id.');++ // The feature's own state is the live one: it has the seven steps and the report beside the run. A++ // deployment without it falls back to the run API, whose output is the same report.++ try {++ return await deepDiveDoc(id, { token });++ } catch (err) {++ if (![403, 404, 405].includes(err.status)) throw err;++ }++ let out;++ try {++ out = await call('GET', `/api/v1/runs/${encodeURIComponent(id)}`, null, { token });++ } catch (err) {++ if (err.status === 404) throw new DeepDiveError(404, 'deep_dive.not_found', 'That deep dive was not found.');++ throw err;++ }++ const run = out && (out.run || out);++ if (!run || typeof run !== 'object') throw new DeepDiveError(502, 'deep_dive.bad_answer', 'OpenVibe.AI answered without a run.');++ return { run, citations: out && out.citations || [] };++ }++++ async function cancel(runId) {++ const out = await call('POST', `/api/v1/runs/${encodeURIComponent(runId)}/cancel`, {});++ return out && (out.run || out);++ }++++ /**++ * One crop of a capture as bytes: AI serves the private Media object to the service that ran the dive.++ * The path is configuration because the capture endpoint ships with the feature.++ */++ async function crop(runId, cropId) {++ const id = String(runId || '');++ const crop = String(cropId || '');++ if (!/^[A-Za-z0-9._~-]{1,160}$/.test(id) || !/^[A-Za-z0-9._~-]{1,120}$/.test(crop)) throw new DeepDiveError(422, 'deep_dive.invalid_crop', 'That is not a crop of this deep dive.');++ const candidates = [dd.cropPath, '/api/v1/deep-dives/:runId/shots/:cropId', '/api/v1/deep-dives/:runId/crops/:cropId',++ '/api/v1/deep-dives/:runId/capture/:cropId', '/api/v1/deep-dives/:runId/media/:cropId'].filter(Boolean);++ let last = null;++ for (const template of [...new Set(candidates)]) {++ const path = template.replace(':runId', encodeURIComponent(id)).replace(':cropId', encodeURIComponent(crop));++ let h;++ try { h = await headers(); } catch (err) { throw err; }++ let res;++ try {++ res = await fetchImpl(`${base}${path}`, { headers: { ...h, Accept: 'image/*' }, signal: AbortSignal.timeout(timeoutMs) });++ } catch (err) {++ last = new DeepDiveError(502, 'deep_dive.unreachable', 'OpenVibe.AI could not be reached for the crop.');++ continue;++ }++ if (res.status === 404 || res.status === 405) { last = new DeepDiveError(404, 'deep_dive.no_crop', 'That crop is not available.'); continue; }++ if (!res.ok) {++ const body = await res.text().catch(() => '');++ last = new DeepDiveError(res.status >= 500 ? 502 : res.status, `deep_dive.crop_${res.status}`, body.slice(0, 300) || 'The crop could not be read.');++ continue;++ }++ const type = String(res.headers.get('content-type') || 'image/png');++ if (!/^image\//i.test(type)) { last = new DeepDiveError(502, 'deep_dive.crop_type', 'OpenVibe.AI did not answer with an image.'); continue; }++ return { contentType: type, body: Buffer.from(await res.arrayBuffer()) };++ }++ throw last || new DeepDiveError(404, 'deep_dive.no_crop', 'That crop is not available.');++ }++++ /** The report once a run has succeeded; the run is the evidence, the report is its output. */++ function reportOf(run, { question = '', depth = 'thorough' } = {}) {++ let output = run && run.output;++ if (output && typeof output === 'object' && output.deep_dive) output = output.deep_dive;++ if (output && typeof output === 'object' && output.report) output = output.report;++ if (!output) return null;++ const normalized = normalizeReport(output, { question, depth });++ const url = (output && (output.report_url || output.url)) || (run && (run.report_url || run.url)) || null;++ return { ...normalized, url: url || `${publicBase}${reportPath.replace(':id', String(run && run.id || ''))}` };++ }++++ function statusOf(run) {++ const s = String((run && (run.status || run.state)) || 'running').toLowerCase();++ if (['succeeded', 'success', 'done', 'completed', 'ok', 'cached'].includes(s)) return 'done';++ if (['failed', 'error', 'errored'].includes(s)) return 'failed';++ if (['cancelled', 'canceled', 'stopped'].includes(s)) return 'cancelled';++ if (['queued', 'pending', 'starting'].includes(s)) return 'starting';++ return 'running';++ }++++ return { enabled, start, getRun, cancel, crop, options, reportOf, statusOf, progressOf, publicReportUrl: (id) => `${publicBase}${reportPath.replace(':id', String(id || ''))}`, STEP_NAMES, pollMs, waitMs, DEPTH_NAMES };++}++++module.exports = { createDeepDiveAi, DeepDiveError, parsePrices, DEPTH_NAMES };nnew file mode 100644@@@ -0,0 +1,90 @@++'use strict';++++/**++ * The deep dive API used by /create, the canvas and the owner's rules page:++ *++ * GET /api/deep-dive/options the depths and their price, plus the person's finished dives to attach++ * GET /api/deep-dive/mine the person's dives, newest first++ * GET /api/deep-dive/:id one dive (the owner only), its progress, points and proposals++ * POST /api/deep-dive/attach attach a dive the person already ran (a report link or a run id)++ * POST /api/deep-dive/:id/cancel stop a dive that is still running++ * GET /api/v1/rules/proposals the proposed rules (owner only)++ * POST /api/v1/rules/proposals/:id/approve|dismiss the owner's decision++ *++ * The start of a dive for /create is not a separate call: the create plan carries the chosen depth and the++ * design's background run starts it (server/designs.js), so a price and a run are planned from the same choices.++ */++const { http } = require('openvibe-contracts');++const { asyncRouter } = require('../http/router');++const { requireSameOrigin } = require('../http/same-origin');++++const owner = (req) => (req.viewer && req.viewer.kind === 'user' ? req.viewer.subject : null);++const tokenOf = (req) => (req.viewer && req.viewer.kind === 'user' ? req.viewer.token : null);++const viewer = (req) => (req.viewer && req.viewer.kind === 'user' ? req.viewer : null);++++function createDeepDiveApi(ctx) {++ const routes = asyncRouter();++ const sameOrigin = requireSameOrigin(ctx.config);++ const privateJson = (res, status, body) => res.status(status).set('Cache-Control', 'private, no-store').json(body);++ const safe = (fn) => async (req, res) => {++ try { return await fn(req, res); } catch (err) {++ ctx.log.warn('[OpenVibe.Design] deep dive:', (err && err.message) || err);++ if (res.headersSent) return;++ return http.sendProblem(res, err.status || 500, err.code || 'deep_dive.error', { detail: err.status ? err.detail : 'The request could not be completed.', ctx: req.ov });++ }++ };++++ routes.get('/deep-dive/options', safe(async (req, res) => {++ const options = await ctx.deepDive.options();++ const recent = owner(req) ? await ctx.deepDive.list(owner(req), { limit: 6 }) : [];++ return privateJson(res, 200, {++ ...options,++ recent: recent.filter((r) => r.status === 'done' || r.status === 'running'),++ signed_in: Boolean(owner(req)),++ note: owner(req) ? options.note : 'Sign in to start a deep dive; its price is held on your Balance before it runs.',++ design_href: '/create',++ });++ }));++++ routes.get('/deep-dive/mine', safe(async (req, res) => {++ if (!owner(req)) return privateJson(res, 401, { error: 'auth.required', detail: 'Sign in to see your deep dives.' });++ return privateJson(res, 200, { dives: await ctx.deepDive.list(owner(req), { limit: Number(req.query.limit) || 8 }) });++ }));++++ routes.get('/deep-dive/:id', safe(async (req, res) => {++ if (!owner(req)) return privateJson(res, 401, { error: 'auth.required', detail: 'Sign in to see this deep dive.' });++ const research = await ctx.deepDive.get(req.params.id, owner(req));++ return privateJson(res, 200, { deep_dive: research });++ }));++++ routes.post('/deep-dive/attach', sameOrigin, safe(async (req, res) => {++ if (!owner(req)) return privateJson(res, 401, { error: 'auth.required', detail: 'Sign in to attach a deep dive.' });++ const body = req.body || {};++ const out = await ctx.deepDive.attach({ owner: owner(req), token: tokenOf(req), ref: body.ref || body.id || body.url || '' });++ return privateJson(res, out.existing ? 200 : 201, out);++ }));++++ routes.post('/deep-dive/:id/cancel', sameOrigin, safe(async (req, res) => {++ if (!owner(req)) return privateJson(res, 401, { error: 'auth.required', detail: 'Sign in to stop a deep dive.' });++ const research = await ctx.deepDive.get(req.params.id, owner(req));++ if (!research || !research.runId) return privateJson(res, 409, { error: 'deep_dive.no_run', detail: 'That deep dive has no run to stop.' });++ if (research.status === 'done' || research.status === 'failed') return privateJson(res, 200, { deep_dive: research });++ await ctx.deepDive.client.cancel(research.runId);++ const updated = await ctx.deepDive.get(req.params.id, owner(req));++ return privateJson(res, 200, { deep_dive: updated });++ }));++++ routes.get('/v1/rules/proposals', safe(async (req, res) => {++ if (!ctx.proposals.isOwner(viewer(req))) return privateJson(res, 403, { error: 'rules.owner_only', detail: 'Only the owner decides a proposed rule.' });++ return privateJson(res, 200, { proposals: await ctx.proposals.list({ state: req.query.state || 'proposed' }) });++ }));++++ routes.post('/v1/rules/proposals/:id/:action', sameOrigin, safe(async (req, res) => {++ const out = await ctx.proposals.decide(req.params.id, req.params.action, viewer(req));++ return privateJson(res, 200, out);++ }));++++ return routes;++}++++module.exports = { createDeepDiveApi };nnew file mode 100644@@@ -0,0 +1,116 @@++'use strict';++++/**++ * The one write a rule approval makes: a pull request against server/rules/register.js, never a direct change.++ *++ * A proposed rule is appended to the register's `RULES` array with its id, area, sentence, detail, the day it was++ * approved, why, and a link to the deep dive that proposed it. The change is made on a branch and opened as a PR;++ * the register on main is never written by this service. Without a GitHub token the approval is recorded and the++ * page says a pull request still needs the token.++ */++++const fail = (status, code, detail) => Object.assign(new Error(detail), { status, code, detail });++const API = 'https://api.github.com';++++function jsq(value) {++ return `'${String(value == null ? '' : value)++ .replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/\r?\n/g, '\\n')}'`;++}++function slug(value) {++ return String(value || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 60) || 'proposed-rule';++}++function today() { return new Date().toISOString().slice(0, 10); }++++/** The source with one rule appended before the `RULES` array's closing bracket. Idempotent by rule id. */++function addRule(source, proposal, { id, area, decided, reportUrl = null }) {++ const text = String(source || '');++ const marker = 'const RULES = Object.freeze([';++ const start = text.indexOf(marker);++ if (start === -1) throw fail(500, 'rules.register_shape', 'The rules register could not be read: its RULES array was not found.');++ let close = text.indexOf('\n]);', start);++ if (close === -1) close = text.indexOf(']);', start);++ if (close === -1) throw fail(500, 'rules.register_shape', 'The rules register could not be read: the RULES array has no closing bracket.');++ if (text.includes(`R(${jsq(id)},`)) throw fail(409, 'rules.duplicate', `Rule ${id} is already in the register.`);++ const see = reportUrl ? `, [{ label: 'Read the deep dive', href: ${jsq(reportUrl)} }]` : '';++ const line = ` R(${jsq(id)}, ${jsq(area)}, ${jsq(proposal.rule)},++ ${jsq(proposal.detail || '')},++ ${jsq(decided)}, ${jsq(proposal.why || '')}${see}),++`;++ const gap = text[close - 1] === '\n' ? '' : '\n';++ return `${text.slice(0, close)}${gap}${line}${text.slice(close + 1)}`;++}++++async function gh(fetchImpl, token, method, path, body = null) {++ let res;++ try {++ res = await fetchImpl(`${API}${path}`, {++ method,++ headers: {++ Accept: 'application/vnd.github+json', 'User-Agent': 'OpenVibe.Design rules proposals',++ Authorization: `Bearer ${token}`, ...(body ? { 'Content-Type': 'application/json' } : {}),++ },++ body: body ? JSON.stringify(body) : undefined,++ signal: AbortSignal.timeout(20_000),++ });++ } catch (err) {++ throw fail(502, 'rules.github_unreachable', err && err.name === 'TimeoutError' ? 'GitHub did not answer in time.' : 'GitHub could not be reached.');++ }++ const json = await res.json().catch(() => null);++ if (!res.ok) {++ const detail = (json && json.message) || `GitHub answered ${res.status}.`;++ throw fail(res.status === 401 || res.status === 403 ? 502 : 502, `rules.github_${res.status}`, String(detail).slice(0, 500));++ }++ return json;++}++++function createRulePr({ config, fetchImpl = globalThis.fetch, log = console, now = () => new Date() }) {++ const dd = config.deepDive || {};++ async function open({ proposal, reportUrl = null, decidedBy = null }) {++ const token = dd.githubToken;++ if (!token) throw fail(503, 'rules.github_token', 'Approving a rule records it, but opening the pull request needs DESIGN_RULES_GITHUB_TOKEN on this service.');++ const repo = String(dd.repo || 'OpenVibers/OpenVibe.Design');++ if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo)) throw fail(500, 'rules.repo', 'The rules repository is not configured.');++ const path = String(dd.registerPath || 'server/rules/register.js').replace(/^\/+/, '');++ if (!/^[A-Za-z0-9._/-]+$/.test(path) || path.includes('..')) throw fail(500, 'rules.path', 'The register path is not valid.');++ const meta = await gh(fetchImpl, token, 'GET', `/repos/${repo}`);++ const base = String((meta && meta.default_branch) || 'main');++ const ref = await gh(fetchImpl, token, 'GET', `/repos/${repo}/git/ref/heads/${encodeURIComponent(base)}`);++ const baseSha = ref && ref.object && ref.object.sha;++ if (!baseSha) throw fail(502, 'rules.github_answer', 'GitHub did not say which commit main is at.');++ const file = await gh(fetchImpl, token, 'GET', `/repos/${repo}/contents/${path.split('/').map(encodeURIComponent).join('/')}?ref=${encodeURIComponent(base)}`);++ const source = Buffer.from(String(file && file.content || ''), 'base64').toString('utf8');++ const id = `research-${slug(proposal.id || proposal.rule)}`;++ const date = now().toISOString().slice(0, 10);++ const updated = addRule(source, proposal, { id, area: proposal.area || 'products', decided: date, reportUrl });++ const branch = `${String(dd.branchPrefix || 'rules/research').replace(/\/+$/, '')}-${id.replace(/^research-/, '')}`.slice(0, 200);++ try {++ await gh(fetchImpl, token, 'POST', `/repos/${repo}/git/refs`, { ref: `refs/heads/${branch}`, sha: baseSha });++ } catch (err) {++ // A retry after a failed pull request reuses its branch instead of failing on the second create.++ if (!/already exists/i.test(String(err.detail || err.message))) throw err;++ }++ await gh(fetchImpl, token, 'PUT', `/repos/${repo}/contents/${path.split('/').map(encodeURIComponent).join('/')}`, {++ message: `Rules: ${proposal.rule}`.slice(0, 200),++ content: Buffer.from(updated, 'utf8').toString('base64'),++ sha: file && file.sha,++ branch,++ });++ const pull = await gh(fetchImpl, token, 'POST', `/repos/${repo}/pulls`, {++ title: `Rules: ${String(proposal.rule).slice(0, 160)}`,++ head: branch, base,++ body: [++ '## What this adds', '',++ `Rule \`${id}\` in \`${path}\`: **${proposal.rule}**`, '',++ proposal.detail || '',++ proposal.why ? `Why: ${proposal.why}` : '',++ reportUrl ? `Proposed by a deep dive: ${reportUrl}` : '',++ `Approved by @${decidedBy || 'the owner'} on ${date}.`, '',++ 'Nothing is added to the register by itself: merging this pull request is the decision.',++ ].filter(Boolean).join('\n'),++ });++ return { url: pull && pull.html_url, number: pull && pull.number, branch, id };++ }++ return { open };++}++++module.exports = { createRulePr, addRule, jsq, slug };nnew file mode 100644@@@ -0,0 +1,49 @@++'use strict';++++/**++ * "Proposed from research" (owner only): the rules a deep dive proposes, with their evidence, and the one++ * decision the owner makes about each. Approval opens a pull request on the register and marks the proposal++ * approved; dismissal keeps it out of the way. Nothing is written to server/rules/register.js here.++ */++++const fail = (status, code, detail) => Object.assign(new Error(detail), { status, code, detail });++++function createRuleProposals({ store, rulePr, config = {}, log = console }) {++ const ownerName = String((config.deepDive && config.deepDive.ownerUsername) || config.ownerUsername || 'goosely').toLowerCase();++ const isOwner = (viewer) => Boolean(viewer && viewer.kind === 'user' && (++ viewer.role === 'owner' || String(viewer.username || '').toLowerCase() === ownerName));++++ async function list({ state = 'proposed' } = {}) {++ const rows = await store.listProposals({ state: state || null });++ return await Promise.all(rows.map((r) => store.publicProposal(r, { research: true })));++ }++++ async function decide(id, action, viewer) {++ if (!isOwner(viewer)) throw fail(403, 'rules.owner_only', 'Only the owner decides a proposed rule.');++ const proposal = await store.proposal(id);++ if (!proposal) throw fail(404, 'rules.not_found', 'That proposed rule was not found.');++ if (action === 'dismiss') {++ const row = await store.decideProposal(id, { state: 'dismissed', by: viewer.username });++ return { proposal: await store.publicProposal(row), action: 'dismissed' };++ }++ if (action !== 'approve') throw fail(422, 'rules.action', 'Approve or dismiss a proposed rule.');++ if (proposal.state === 'approved' && proposal.pr_url) {++ return { proposal: await store.publicProposal(proposal), action: 'approved', already: true };++ }++ if (proposal.state === 'dismissed') throw fail(409, 'rules.dismissed', 'That proposed rule was dismissed already.');++ const research = await store.get(proposal.research_id);++ let opened;++ try {++ opened = await rulePr.open({ proposal, reportUrl: research && research.report_url, decidedBy: viewer.username });++ } catch (err) {++ await store.decideProposal(id, { state: proposal.state === 'approved' ? 'approved' : 'proposed', by: null, prError: String(err.detail || err.message).slice(0, 500) }).catch(() => {});++ throw err;++ }++ const row = await store.decideProposal(id, { state: 'approved', by: viewer.username, prUrl: opened.url, prBranch: opened.branch, prError: null });++ return { proposal: await store.publicProposal(row), action: 'approved', pull: opened };++ }++++ return { isOwner, list, decide, ownerName };++}++++module.exports = { createRuleProposals };nnew file mode 100644@@@ -0,0 +1,237 @@++'use strict';++++/**++ * A deep dive's report, read for Design (contract ai.deep-dive@1 on OpenVibe.AI).++ *++ * The report is evidence, not content: Design takes the numbered points (patterns, do and don't, reference++ * tokens, the rules a report follows), a compact brief for the generator, the crops a component's page shows++ * with their attribution and dates, and the rules a report proposes. No site's HTML, CSS or assets are ever++ * stored in a design or copied into a prompt: a crop is shown as commentary with its source, and a reference++ * token is a direction, never a palette to copy.++ *++ * The shape of a report is read leniently (the dive is written by another service and may grow): the usual++ * names are used when present and a missing part is simply empty.++ */++++const str = (v, max = 4000) => (typeof v === 'string' ? v : v == null ? '' : String(v)).trim().slice(0, max);++const list = (v) => (Array.isArray(v) ? v : v == null ? [] : [v]);++const object = (v) => (v && typeof v === 'object' && !Array.isArray(v) ? v : {});++++/** Text of a pattern, a do, a rule or a recommendation, whichever shape the report used. */++function textOf(item) {++ if (typeof item === 'string') return str(item, 600);++ if (!item || typeof item !== 'object') return '';++ return str(item.text || item.title || item.rule || item.name || item.pattern || item.detail || item.description || item.advice, 600);++}++++/** The evidence behind one item, flattened to what a page can show. */++function evidenceOf(item) {++ if (!item || typeof item !== 'object') return [];++ const raw = [++ ...list(item.evidence), ...list(item.citations), ...list(item.sources), ...list(item.sites),++ ...(item.site ? [item] : []), ...(item.url ? [item] : []),++ ];++ const out = [];++ for (const e of raw) {++ if (typeof e === 'string') { if (/^https?:\/\//.test(e)) out.push({ url: e, site: hostOf(e) }); continue; }++ if (!e || typeof e !== 'object') continue;++ const url = str(e.url || e.source_url || e.href || e.link, 2000);++ const site = str(e.site || e.name || e.publisher || e.title || e.source || e.domain, 200) || (url ? hostOf(url) : '');++ const crop = str(e.crop_url || e.crop || e.image_url || e.screenshot_url || e.shot_url || e.image, 2000);++ const captured = str(e.read_at || e.captured_at || e.retrieved_at || e.date || e.capture_date, 40);++ const note = str(e.claim || e.note || e.quote || e.detail || e.text, 400);++ if (url || site || crop || e.crop_id || e.capture_id) out.push({ site, url: url || null, crop: crop || null,++ cropId: str(e.crop_id, 80) || null, captureId: str(e.capture_id, 80) || null,++ capturedAt: captured || null, note });++ }++ return out.slice(0, 8);++}++function hostOf(url) { try { return new URL(String(url)).hostname.replace(/^www\./, ''); } catch { return ''; } }++++/** Turn the report into a numbered list a take can follow and a page can link to. */++const STEP_LABEL = { plan: 'Planning', search: 'Searching', choose: 'Choosing sites', capture: 'Capturing', analyse: 'Analysing', synthesise: 'Writing it up', report: 'Writing it up' };++function pointsOf(report) {++ const source = object(report);++ const synth = object(source.synthesis);++ const explicit = list(source.points).length ? list(source.points) : list(synth.points);++ const points = [];++ const add = (group, text, item, extra = {}) => {++ const t = textOf(text);++ if (!t) return;++ points.push({ n: points.length + 1, group, text: t, evidence: evidenceOf(item), ...extra });++ };++ if (explicit.length) {++ for (const p of explicit) {++ if (typeof p === 'string') add('point', p);++ else add(str(p.group || p.kind || 'point', 40), textOf(p), p, { rule: str(p.rule || p.rule_id, 80) || null });++ }++ } else {++ // ai.deep-dive@1: patterns say what and how many sites; do/don't are sentences; the reference tokens are++ // per source and are inspiration only; a recommendation states whether it follows or would change a rule.++ for (const item of list(synth.patterns || source.patterns)) {++ const name = str(item && (item.name || item.title), 160);++ const what = str(item && (item.what || item.text || item.description), 600);++ const count = item && Number.isFinite(Number(item.usage_count)) ? Number(item.usage_count) : (Array.isArray(item && item.sites) ? item.sites.length : 0);++ add('pattern', count ? `${count} of the sites: ${name}${what ? ` — ${what}` : ''}` : (name || what), item);++ }++ for (const item of list(synth.dos || synth.do || source.dos || source.do)) add('do', item, item);++ for (const item of list(synth.donts || synth.dont || source.donts || source.dont)) add("don't", item, item);++ const tokens = object(synth.reference_tokens || synth.tokens || source.reference_tokens || source.tokens);++ for (const p of list(tokens.palettes)) add('reference token', `Palette, as direction only: ${list(p && p.colours).join(', ')}`, p);++ for (const p of list(tokens.type_scales)) add('reference token', `Type scale, as direction only: ${list(p && p.families).join(', ')}${list(p && p.sizes).length ? ` at ${list(p.sizes).join(', ')}` : ''}`, p);++ for (const p of list(tokens.spacing)) add('reference token', `Spacing steps, as direction only: ${list(p && p.steps).join(', ')}`, p);++ for (const item of list(synth.best ? [synth.best] : [])) add('pattern', item.what, item);++ for (const item of list(synth.mistakes || source.mistakes)) add("don't", item.what || item.text, item);++ for (const item of list(synth.rules || source.rules_followed || source.rules)) add('rule', item, item);++ for (const item of list(source.recommendations || synth.recommendations)) {++ const rule = item && item.rule && typeof item.rule === 'object' ? item.rule : null;++ if (rule && rule.text && rule.status !== 'none') add('rule', rule.text, item, { rule: rule.id || null });++ }++ for (const item of list(source.recommendations || synth.recommendations)) add('recommendation', item && (item.what || item.text), item);++ }++ if (!points.length && (source.summary || source.verdict)) add('summary', source.summary || source.verdict);++ return points.slice(0, 60);++}++/** What the generator reads: the points, plainly, with the copying rule stated once. */++function briefOf(report, points, fallbackQuestion = '') {++ const r = object(report);++ const synth = object(r.synthesis);++ const input = object(r.input);++ const question = str(r.question || input.question || r.query || synth.question || fallbackQuestion, 300);++ const sites = list(r.sites || r.sources || r.captures).length;++ const by = (group) => points.filter((p) => p.group === group);++ const lines = [`Research first: ${question || 'expert design research'}${sites ? `, from ${sites} sites read at phone and computer sizes` : ''}.`];++ if (by('pattern').length) lines.push('What the better sites do:', ...by('pattern').map((p) => `- ${p.text}`));++ if (by('do').length) lines.push('Do:', ...by('do').map((p) => `- ${p.text}`));++ if (by("don't").length) lines.push('Do not:', ...by("don't").map((p) => `- ${p.text}`));++ if (by('reference token').length) lines.push('Reference tokens, as direction only (never copy a palette, typeface, spacing scale or asset):', ...by('reference token').map((p) => `- ${p.text}`));++ if (by('rule').length) lines.push('OpenVibe rules this follows:', ...by('rule').map((p) => `- ${p.text}`));++ lines.push('Use the research as direction for this design. Never copy a site\'s assets, code, markup, CSS, words or an exact palette. Do not reproduce or load any reference image.');++ return lines.join('\n');++}++++/** The rules a report proposes for the register. Only an explicit "would change" becomes one; nothing is inferred. */++function proposedRulesOf(report) {++ const r = object(report);++ const synth = object(r.synthesis);++ const out = [];++ for (const item of [...list(r.recommendations), ...list(synth.recommendations), ...list(r.proposed_rules)]) {++ if (!item) continue;++ const rule = object(item.rule);++ if (!(rule.status === 'would_change' || item.proposed_rule || item.proposes_rule)) continue;++ const text = str(item.proposed_rule || item.rule_text || item.what || (typeof item === 'string' ? item : ''), 300);++ if (!text) continue;++ out.push({++ rule: text,++ detail: str(rule.text || item.detail || item.description || item.what, 1200),++ why: str(item.why || item.reason || item.evidence_note, 1200),++ area: str(item.area || item.group, 40),++ evidence: evidenceOf(item),++ ruleId: str(rule.id, 120) || null,++ });++ }++ return out.slice(0, 12);++}++/** The component pages' "How others do it": the points and per-site analysis whose words name a component. */++function findsFor(report, component, limit = 6) {++ const name = str(component, 80).toLowerCase();++ if (!name) return [];++ const wordy = new RegExp(`(^|[^a-z0-9])${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}([^a-z0-9]|$)`, 'i');++ const out = [];++ const push = (text, evidence, extra = {}) => {++ if (!text || !wordy.test(text)) return;++ const ev = Array.isArray(evidence) ? evidence : [];++ out.push({++ title: str(text, 400), detail: '', evidence: ev,++ crop: (ev.find((e) => e.crop) || {}).crop || null,++ cropId: (ev.find((e) => e.cropId) || {}).cropId || null,++ captureId: (ev.find((e) => e.captureId) || {}).captureId || null,++ site: (ev.find((e) => e.site) || {}).site || null,++ capturedAt: (ev.find((e) => e.capturedAt) || {}).capturedAt || null,++ url: (ev.find((e) => e.url) || {}).url || null,++ ...extra,++ });++ };++ // The capture worker's component inventory, when the DOM facts carry one: the same component seen on a site,++ // with the crop that shows it. The capitalised name is what the inventory uses; the check is case-insensitive.++ const sources = new Map(list(object(report).sources).map((x) => [x && x.id, x || {}]));++ for (const cap of list(object(report).captures)) {++ if (!cap || typeof cap !== 'object') continue;++ const dom = object(cap.dom);++ const inventory = list(dom.components || dom.component_inventory || dom.inventory || dom.ui_components);++ const hit = inventory.find((c) => {++ const label = typeof c === 'string' ? c : (c && (c.name || c.component || c.kind));++ return label && wordy.test(String(label));++ });++ if (!hit) continue;++ const src = sources.get(cap.source_id) || {};++ const crop = list(cap.crops)[0] || null;++ const label = typeof hit === 'string' ? hit : (hit.name || hit.component || hit.kind);++ push(`A ${label} appears on ${src.title || host(cap.url)}.`, [{ site: host(src.url || cap.url), url: src.url || cap.url || null,++ cropId: crop && crop.id || null, captureId: cap.id || null, capturedAt: cap.captured_at || null,++ note: hit && typeof hit === 'object' ? str(hit.note || hit.usage || '', 300) : '' }]);++ }++ // The contract's per-site analysis: a summary, strengths and weaknesses, each with evidence.++ for (const a of list(object(report).analysis)) {++ if (!a || typeof a !== 'object') continue;++ const ev = evidenceOf(a);++ for (const text of [a.summary, a.first_impression, a.hierarchy, a.density, a.type, a.colour, a.motion, a.words, ...list(a.strengths), ...list(a.weaknesses)]) {++ if (text && wordy.test(String(text))) push(text, ev, { sourceUrl: a.url || null });++ }++ }++ for (const p of pointsOf(report)) {++ if (wordy.test(p.text) || p.evidence.some((e) => wordy.test(e.note || '') || wordy.test(e.site || ''))) {++ const ev = p.evidence.length ? p.evidence : [];++ out.push({ n: p.n, title: p.text, detail: '', evidence: ev,++ crop: (ev.find((e) => e.crop) || {}).crop || null,++ cropId: (ev.find((e) => e.cropId) || {}).cropId || null,++ captureId: (ev.find((e) => e.captureId) || {}).captureId || null,++ site: (ev.find((e) => e.site) || {}).site || null,++ capturedAt: (ev.find((e) => e.capturedAt) || {}).capturedAt || null,++ url: (ev.find((e) => e.url) || {}).url || null });++ }++ }++ const seen = new Set();++ return out.filter((x) => { const k = `${x.title}|${x.cropId || x.crop || ''}`; if (seen.has(k)) return false; seen.add(k); return true; }).slice(0, limit);++}++/** The live step list for a running dive: the contract's seven steps, in order, as the words the brief names. */++const CONTRACT_STEPS = ['plan', 'search', 'choose', 'capture', 'analyse', 'synthesise', 'report'];++const STEP_NAMES = CONTRACT_STEPS.map((k) => STEP_LABEL[k]);++function progressOf(run) {++ const r = object(run);++ const out = object(r.explain).deep_dive || object(r.explain).deepDive || r.progress || {};++ const report = object(r.output);++ const raw = list(out.steps || r.steps || report.steps);++ let steps;++ if (raw.length && raw[0] && (raw[0].key || raw[0].name || raw[0].step)) {++ steps = raw.map((s, i) => {++ const key = str(s.key || s.name || s.step, 40);++ const state = String(s.status || s.state || '').toLowerCase();++ return { key, name: STEP_LABEL[key] || str(s.title, 80) || STEP_NAMES[i] || `Step ${i + 1}`,++ state: /done|complete|succeed/i.test(state) ? 'done' : /fail|error/i.test(state) ? 'failed' : /run|active|work/i.test(state) ? 'running' : /skip/i.test(state) ? 'skipped' : 'waiting' };++ });++ } else {++ const index = Number(out.step_index || out.step || 0);++ steps = STEP_NAMES.map((name, i) => ({ key: CONTRACT_STEPS[i], name, state: i < index ? 'done' : i === index ? 'running' : 'waiting' }));++ }++ return { steps, current: str(out.current || out.label || out.step_name, 120) || null, percent: Number(out.percent || out.progress) || null };++}++function normalizeReport(input, { question = '', depth = 'thorough' } = {}) {++ let report = input;++ if (typeof report === 'string') { try { report = JSON.parse(report); } catch { report = { summary: report }; } }++ if (report && typeof report === 'object' && report.deep_dive) report = report.deep_dive;++ else if (report && typeof report === 'object' && report.report) report = report.report;++ report = object(report);++ const points = pointsOf(report);++ return {++ question: str(report.question || object(report.input).question || report.query || question, 300),++ depth: str(report.depth || depth, 20) || 'thorough',++ summary: str(report.summary || report.verdict || object(report.synthesis).summary, 2000),++ points,++ brief: briefOf(report, points, question),++ proposedRules: proposedRulesOf(report),++ report,++ };++}++++module.exports = { normalizeReport, pointsOf, briefOf, proposedRulesOf, findsFor, progressOf, evidenceOf, textOf, STEP_NAMES };nnew file mode 100644@@@ -0,0 +1,246 @@++'use strict';++++/**++ * "Research first": one place that starts a deep dive, follows it, and hands its evidence to a design.++ *++ * A dive starts as an AI run on the person's behalf and is followed by the service in the background, never by++ * the request that created it (a dive takes minutes). While it runs, a design's takes wait; when it finishes,++ * the report's numbered points become the generation brief and each take records the points it follows. A failed++ * dive does not lose its design: the takes are drawn without it and the canvas says what happened.++ */++++const { createDeepDiveAi, DeepDiveError } = require('./ai');++const { findsFor, normalizeReport, proposedRulesOf, progressOf } = require('./report');++++const sleep = (ms) => new Promise((r) => setTimeout(r, ms));++const REF = /(?:runs?|deep[-_ ]?dives?|research)\/([A-Za-z0-9._~-]{1,160})/i;++++function createDeepDiveService({ s, config, fetchImpl = globalThis.fetch, log = console, store, ai, now = () => Date.now() }) {++ const db = store;++ const client = ai || createDeepDiveAi({ config, fetchImpl, log });++ const running = new Map(); // research id → the promise following it++ const maxWaitMs = Math.max(30_000, Number(config.deepDive && config.deepDive.maxWaitMs) || 15 * 60_000);++ const pollMs = Math.max(200, Number(config.deepDive && config.deepDive.pollMs) || 4000);++++ let optionsCache = { at: 0, value: null };++ async function options() {++ // The depths and their prices change rarely; a page and its estimate ask once, not once per keystroke.++ if (optionsCache.value && Date.now() - optionsCache.at < 60_000) return optionsCache.value;++ const out = await client.options().catch((err) => {++ log.warn('[OpenVibe.Design] deep dive options:', err.message);++ return { depths: [], priced: false, source: 'none', note: 'The depths could not be read; try again in a moment.' };++ });++ optionsCache = { at: Date.now(), value: out };++ return out;++ }++++ function depthOf(optionsOut, wanted) {++ const list = (optionsOut && optionsOut.depths) || [];++ return list.find((d) => d.id === wanted) || list.find((d) => d.id === 'thorough') || list[0] || null;++ }++++ async function create({ owner, question, depth = 'thorough', sites = [], targetId = null, token = null }) {++ if (!owner) throw Object.assign(new Error('Sign in to start a deep dive.'), { status: 401, code: 'deep_dive.sign_in', detail: 'Sign in to start a deep dive.' });++ const opts = await options();++ const chosen = depthOf(opts, depth);++ if (!chosen) throw Object.assign(new Error('The deep dive depths are not available right now.'), { status: 503, code: 'deep_dive.depths', detail: 'The deep dive depths are not available right now.' });++ const estimate = { depth: chosen.id, price_usd: chosen.price_usd, currency: chosen.currency || 'USD', priced: chosen.price_usd != null, sites: chosen.sites || null };++ const research = await db.create({ owner, question: String(question || '').slice(0, 500), depth: chosen.id, sites, estimate, status: 'starting' });++ let run;++ try {++ run = await client.start({++ question, sites, depth: chosen.id, person: owner, idempotencyKey: research.id,++ target: { service: 'design', type: 'design_research', id: research.id },++ });++ } catch (err) {++ await db.patch(research.id, { status: 'failed', error: err.detail || err.message });++ throw err;++ }++ const status = client.statusOf(run.run) || 'running';++ const updated = await db.patch(research.id, { runId: run.runId, status, progress: progressOf(run.run) });++ follow(research.id);++ return { research: db.publicRow(updated || research), options: opts, estimate, tokenUsed: Boolean(token) };++ }++++ /** Follow a running dive to its end and keep what it says. One follower per dive in this process. */++ function follow(id) {++ if (running.has(id)) return running.get(id);++ const promise = (async () => {++ let warned = false;++ while (true) {++ const row = await db.get(id);++ if (!row || ['done', 'failed', 'cancelled'].includes(row.status)) return row;++ if (!row.run_id) { await db.patch(id, { status: 'failed', error: 'The deep dive did not start.' }); return await db.get(id); }++ if (Date.now() - new Date(row.created_at).getTime() > maxWaitMs) {++ await db.patch(id, { status: 'failed', error: 'The deep dive did not finish in time. Its report may still appear on OpenVibe Exchange.' });++ return await db.get(id);++ }++ let got;++ try { got = await client.getRun(row.run_id); } catch (err) {++ // A transient read error is retried; an authorisation failure is final.++ if ([401, 403, 404].includes(err.status)) {++ await db.patch(id, { status: 'failed', error: err.detail || err.message });++ return await db.get(id);++ }++ if (!warned) { log.warn('[OpenVibe.Design] deep dive read:', err.message); warned = true; }++ await sleep(pollMs);++ continue;++ }++ const status = client.statusOf(got.run);++ if (status === 'done') {++ const report = client.reportOf(got.run, { question: row.question, depth: row.depth });++ if (!report) {++ await db.patch(id, { status: 'failed', progress: progressOf(got.run), error: 'The deep dive finished without a report.' });++ return await db.get(id);++ }++ const saved = await db.patch(id, { status: 'done', progress: progressOf(got.run), report: report.report,++ points: report.points, brief: report.brief, reportUrl: report.url, error: null });++ try { await db.addProposals(saved, proposedRulesOf(report.report)); } catch (err) { log.warn('[OpenVibe.Design] rule proposals:', err.message); }++ return saved;++ }++ if (status === 'failed' || status === 'cancelled') {++ const err = got.run && (got.run.error || got.run.detail);++ await db.patch(id, { status, progress: progressOf(got.run), error: String((err && (err.detail || err.message || err.code)) || (status === 'cancelled' ? 'The deep dive was cancelled.' : 'The deep dive did not finish.')).slice(0, 500) });++ return await db.get(id);++ }++ await db.patch(id, { status, progress: progressOf(got.run) });++ await sleep(pollMs);++ }++ })().catch(async (err) => {++ log.warn('[OpenVibe.Design] deep dive follower:', err.message);++ await db.patch(id, { status: 'failed', error: String(err.message || err).slice(0, 500) }).catch(() => {});++ return await db.get(id).catch(() => null);++ }).finally(() => { running.delete(id); });++ running.set(id, promise);++ return promise;++ }++++ /** Wait for a dive's report, or its failure, for a design's takes (bounded by the design's own run). */++ async function wait(id, { timeoutMs = maxWaitMs } = {}) {++ const started = Date.now();++ const follower = follow(id);++ let row = await Promise.race([++ follower,++ (async () => { while (Date.now() - started < timeoutMs) { const r = await db.get(id); if (r && ['done', 'failed', 'cancelled'].includes(r.status)) return r; await sleep(Math.min(pollMs, 1000)); } return await db.get(id); })(),++ ]).catch(() => null);++ if (!row) row = await db.get(id);++ return row;++ }++++ async function get(id, owner) {++ const row = owner ? await db.owned(id, owner) : await db.get(id);++ if (!row) return null;++ const data = db.publicRow(row, { points: true });++ if (row.status === 'done') {++ const proposals = await db.proposalsFor(id).catch(() => []);++ data.proposals = (await Promise.all(proposals.map((p) => db.publicProposal(p)))).filter(Boolean);++ }++ return data;++ }++++ /** The part of a dive a design's canvas may show to whoever can open the design: no report, no proposals. */++ async function summary(id) {++ const row = await db.get(id);++ return row ? db.publicRow(row, { points: true }) : null;++ }++++ async function list(owner, { limit = 8 } = {}) {++ const rows = await db.list(owner, { limit });++ return rows.map((r) => db.publicRow(r));++ }++++ /** Attach a dive the person already ran: paste its report address or its run id. */++ async function attach({ owner, token, ref }) {++ if (!owner) throw Object.assign(new Error('Sign in to attach a deep dive.'), { status: 401, code: 'deep_dive.sign_in', detail: 'Sign in to attach a deep dive.' });++ const value = String(ref || '').trim();++ if (!value) throw Object.assign(new Error('Paste the deep dive report link or its id.'), { status: 422, code: 'deep_dive.ref', detail: 'Paste the deep dive report link or its id.' });++ const m = REF.exec(value);++ const runId = (m ? m[1] : value).replace(/[?#].*$/, '');++ if (!/^[A-Za-z0-9._~-]{1,160}$/.test(runId)) throw Object.assign(new Error('That is not a deep dive link or id.'), { status: 422, code: 'deep_dive.ref', detail: 'That is not a deep dive link or id.' });++ // A dive Design already follows is named by its own id (rsr_...), not an AI run id: attach is a read.++ const own = await db.get(runId);++ if (own) {++ if (own.owner !== owner) throw Object.assign(new Error('That deep dive belongs to someone else.'), { status: 403, code: 'deep_dive.forbidden', detail: 'That deep dive belongs to someone else.' });++ if (own.status === 'failed' || own.status === 'cancelled') throw Object.assign(new Error('That deep dive did not finish; attach one with a report.'), { status: 409, code: 'deep_dive.failed', detail: 'That deep dive did not finish; attach one with a report.' });++ return { research: db.publicRow(own, { points: true }), existing: true };++ }++ const existing = await db.byRun(runId);++ if (existing && existing.owner === owner) return { research: db.publicRow(existing, { points: true }), existing: true };++ if (existing && existing.owner !== owner) throw Object.assign(new Error('That deep dive belongs to someone else.'), { status: 403, code: 'deep_dive.forbidden', detail: 'That deep dive belongs to someone else.' });++ if (!token) throw Object.assign(new Error('Sign in to attach a deep dive you ran on OpenVibe Exchange.'), { status: 401, code: 'deep_dive.sign_in', detail: 'Sign in to attach a deep dive you ran on OpenVibe Exchange.' });++ const got = await client.getRun(runId, { token });++ const run = got.run || {};++ const output = run.output && typeof run.output === 'object' ? run.output : {};++ const input = run.input && typeof run.input === 'object' ? run.input : (output.input && typeof output.input === 'object' ? output.input : {});++ const question = input.question || input.query || output.question || output.query || 'A deep dive';++ const depth = input.depth || output.depth || 'thorough';++ const status = client.statusOf(run);++ const report = status === 'done' ? client.reportOf(run, { question, depth }) : null;++ const row = await db.create({ owner, question, depth, sites: Array.isArray(input.sites) ? input.sites : [], runId, status: status === 'done' ? 'done' : status,++ estimate: { depth, price_usd: null, currency: 'USD', priced: false, attached: true } });++ if (report) {++ const saved = await db.patch(row.id, { status: 'done', progress: progressOf(run), report: report.report, points: report.points,++ brief: report.brief, reportUrl: report.url, error: null });++ await db.addProposals(saved, proposedRulesOf(report.report)).catch((err) => log.warn('[OpenVibe.Design] rule proposals:', err.message));++ return { research: (await get(row.id, owner)), existing: false };++ }++ if (status === 'running' || status === 'starting') {++ const updated = await db.patch(row.id, { progress: progressOf(run) });++ follow(row.id);++ return { research: db.publicRow(updated || row, { points: true }), existing: false };++ }++ const failed = await db.patch(row.id, { status: status === 'failed' ? 'failed' : 'failed', error: 'That deep dive did not finish.' });++ return { research: db.publicRow(failed || row), existing: false };++ }++++ /** Read one point list for a design's take: patterns, do, don't and tokens rotate per take. */++ function pointsForTake(points, index, total = 1) {++ const list = Array.isArray(points) ? points : [];++ if (!list.length) return [];++ const groups = ['pattern', 'do', "don't", 'reference token', 'rule', 'recommendation', 'point'];++ const out = [];++ const count = Math.max(2, Math.min(5, Math.ceil(list.length / Math.max(1, total)) + 1));++ for (let i = 0; i < count; i++) {++ const pool = list.filter((p) => p.group === groups[(i + Number(index || 0)) % groups.length]);++ const source = pool.length ? pool : list;++ const pick = source[(Number(index || 0) + i) % source.length];++ if (pick && !out.some((p) => p.n === pick.n)) out.push({ n: pick.n, group: pick.group, text: String(pick.text || '').slice(0, 300) });++ }++ return out.slice(0, 5);++ }++++ function pointLine(points, research) {++ if (!Array.isArray(points) || !points.length) return '';++ const links = points.map((p) => `point ${p.n}`).join(', ');++ return `\nThis take follows research ${links}${research && research.reportUrl ? ` (${research.reportUrl})` : ''}. Use them as direction; never copy a site's assets, code or exact palette.`;++ }++++ /** "How others do it": crops and points across every finished report this service holds. */++ async function finds(component, { limit = 6, owner = null } = {}) {++ // A deep dive is the person's. A component page shows the findings of the person looking at it, never++ // another person's research or crops.++ if (!owner) return { component, count: 0, finds: [], reports: 0 };++ const reports = await db.allDone({ limit: 60, owner });++ const out = [];++ for (const row of reports) {++ const found = findsFor(row.report, component, limit);++ for (const find of found) {++ const key = `${find.n || ''}|${find.title}|${find.crop || find.url || ''}`;++ if (out.some((x) => x.key === key)) continue;++ out.push({ ...find, key, researchId: row.id, runId: row.run_id || null, researchUrl: `/research/${encodeURIComponent(row.id)}`,++ reportUrl: row.report_url || null, depth: row.depth, readAt: row.updated_at });++ }++ }++ return { component, count: out.length, finds: out.slice(0, limit), reports: reports.length };++ }++++ async function resume() {++ const rows = await s.db.many("SELECT id FROM design_research WHERE status IN ('starting','running') AND run_id IS NOT NULL ORDER BY created_at LIMIT 20");++ for (const r of rows) follow(r.id);++ return rows.length;++ }++++ return { options, create, follow, wait, get, summary, list, attach, pointsForTake, pointLine, finds, resume, client };++}++++module.exports = { createDeepDiveService, REF };nnew file mode 100644@@@ -0,0 +1,145 @@++'use strict';++++/**++ * The deep dives Design knows: the runs it started for a person, the report's numbered points and brief, and++ * the rules those reports propose. A dive is kept even when its run fails, because the person paid for a dive++ * and a failed one still says why; a report template is never stored, only the run's own answer.++ */++++const fail = (status, code, detail) => Object.assign(new Error(detail), { status, code, detail });++const PUBLIC = new Set(['quick', 'thorough', 'expert']);++++function createDeepDiveStore({ s, log = console }) {++ const db = s.db;++++ function publicRow(row, { points = false } = {}) {++ if (!row) return null;++ const out = {++ id: row.id, question: row.question, depth: row.depth, sites: Array.isArray(row.sites) ? row.sites : [],++ status: row.status, runId: row.run_id || null, url: `/research/${encodeURIComponent(row.id)}`,++ priceUsd: (row.report && row.report.receipt && row.report.receipt.price_usd != null)++ ? Number(row.report.receipt.price_usd) : (row.estimate && row.estimate.price_usd != null ? Number(row.estimate.price_usd) : null),++ reportUrl: row.report_url || null, progress: row.progress && typeof row.progress === 'object' ? row.progress : {},++ estimate: row.estimate || null, error: row.error || null,++ createdAt: row.created_at, updatedAt: row.updated_at,++ pointsCount: Array.isArray(row.points) ? row.points.length : 0,++ };++ if (points) out.points = Array.isArray(row.points) ? row.points : [];++ return out;++ }++++ async function create({ owner, question, depth = 'thorough', sites = [], estimate = null, runId = null, status = 'starting' }) {++ const id = s.newId('rsr');++ const at = s.iso();++ await db.exec(`INSERT INTO design_research(id,owner,question,depth,sites,run_id,status,progress,estimate,created_at,updated_at)++ VALUES($1,$2,$3,$4,$5::jsonb,$6,$7,'{}'::jsonb,$8::jsonb,$9::timestamptz,$9::timestamptz)`,++ [id, owner, String(question || '').slice(0, 2000), PUBLIC.has(depth) ? depth : 'thorough', JSON.stringify(Array.isArray(sites) ? sites.slice(0, 30) : []),++ runId, status, estimate ? JSON.stringify(estimate) : null, at]);++ return await get(id);++ }++++ async function get(id) { return await db.maybe('SELECT * FROM design_research WHERE id=$1', [id]); }++++ async function owned(id, owner) {++ const row = await get(id);++ if (!row) throw fail(404, 'deep_dive.not_found', 'Deep dive not found.');++ if (row.owner !== owner) throw fail(403, 'deep_dive.forbidden', 'That deep dive belongs to someone else.');++ return row;++ }++++ async function patch(id, fields = {}) {++ const columns = [];++ const values = [id];++ const put = (name, value) => { values.push(value); columns.push(`${name}=$${values.length}`); };++ if (fields.status != null) put('status', fields.status);++ if (fields.runId != null) put('run_id', fields.runId);++ if (fields.progress != null) put('progress', JSON.stringify(fields.progress));++ if (fields.report != null) put('report', JSON.stringify(fields.report));++ if (fields.points != null) put('points', JSON.stringify(fields.points));++ if (fields.brief != null) put('brief', String(fields.brief).slice(0, 20000));++ if (fields.reportUrl != null) put('report_url', fields.reportUrl);++ if (fields.error != null) put('error', String(fields.error).slice(0, 500));++ if (fields.estimate != null) put('estimate', JSON.stringify(fields.estimate));++ if (!columns.length) return await get(id);++ values.push(s.iso());++ columns.push(`updated_at=$${values.length}::timestamptz`);++ return await db.maybe(`UPDATE design_research SET ${columns.join(', ')} WHERE id=$1 RETURNING *`, values);++ }++++ async function list(owner, { limit = 8 } = {}) {++ const size = Math.max(1, Math.min(30, Number(limit) || 8));++ return await db.many(`SELECT * FROM design_research WHERE owner=$1 ORDER BY created_at DESC, id DESC LIMIT $2`, [owner, size]);++ }++++ async function recentDone(owner, { limit = 6 } = {}) {++ return await db.many(`SELECT * FROM design_research WHERE owner=$1 AND status='done' ORDER BY created_at DESC, id DESC LIMIT $2`,++ [owner, Math.max(1, Math.min(20, Number(limit) || 6))]);++ }++++ async function byRun(runId) { return await db.maybe('SELECT * FROM design_research WHERE run_id=$1 ORDER BY created_at DESC LIMIT 1', [runId]); }++++ async function allDone({ limit = 50, owner = null } = {}) {++ const size = Math.max(1, Math.min(200, Number(limit) || 50));++ if (owner) return await db.many("SELECT * FROM design_research WHERE status='done' AND report IS NOT NULL AND owner=$1 ORDER BY created_at DESC, id DESC LIMIT $2", [owner, size]);++ return await db.many("SELECT * FROM design_research WHERE status='done' AND report IS NOT NULL ORDER BY created_at DESC, id DESC LIMIT $1", [size]);++ }++++ // ── Rules a report proposes ─────────────────────────────++ async function proposalsFor(researchId) {++ return await db.many('SELECT * FROM rule_proposals WHERE research_id=$1 ORDER BY created_at', [researchId]);++ }++++ async function addProposals(research, items = []) {++ const out = [];++ for (const item of items) {++ const rule = String(item && item.rule || '').trim().slice(0, 300);++ if (!rule) continue;++ const existing = await db.maybe("SELECT * FROM rule_proposals WHERE research_id=$1 AND lower(rule)=lower($2) AND state IN ('proposed','approved') LIMIT 1",++ [research.id, rule]);++ if (existing) { out.push(existing); continue; }++ const id = s.newId('rpp');++ const at = s.iso();++ const row = await db.maybe(`INSERT INTO rule_proposals(id,research_id,owner,rule,detail,why,area,evidence,state,created_at,updated_at)++ VALUES($1,$2,$3,$4,$5,$6,$7,$8::jsonb,'proposed',$9::timestamptz,$9::timestamptz) RETURNING *`,++ [id, research.id, research.owner, rule, String(item.detail || '').slice(0, 1500), String(item.why || '').slice(0, 1500),++ String(item.area || 'products').slice(0, 40),++ JSON.stringify(Array.isArray(item.evidence) ? item.evidence.slice(0, 8) : []), at]);++ if (row) out.push(row);++ }++ return out;++ }++++ async function listProposals({ state = null, limit = 100 } = {}) {++ const size = Math.max(1, Math.min(300, Number(limit) || 100));++ if (state) return await db.many('SELECT * FROM rule_proposals WHERE state=$1 ORDER BY created_at DESC LIMIT $2', [state, size]);++ return await db.many('SELECT * FROM rule_proposals ORDER BY CASE state WHEN \'proposed\' THEN 0 ELSE 1 END, created_at DESC LIMIT $1', [size]);++ }++++ async function proposal(id) { return await db.maybe('SELECT * FROM rule_proposals WHERE id=$1', [id]); }++++ async function decideProposal(id, { state, by = null, prUrl = null, prBranch = null, prError = null }) {++ const at = s.iso();++ return await db.maybe(`UPDATE rule_proposals SET state=$2, decided_by=$3, decided_at=$4::timestamptz, pr_url=COALESCE($5,pr_url),++ pr_branch=COALESCE($6,pr_branch), pr_error=$7, updated_at=$4::timestamptz WHERE id=$1 RETURNING *`,++ [id, state, by, at, prUrl, prBranch, prError]);++ }++++ async function publicProposal(row, { research = false } = {}) {++ if (!row) return null;++ const out = {++ id: row.id, researchId: row.research_id, owner: row.owner, rule: row.rule, detail: row.detail, why: row.why,++ area: row.area, evidence: Array.isArray(row.evidence) ? row.evidence : [], state: row.state,++ prUrl: row.pr_url || null, prBranch: row.pr_branch || null, prError: row.pr_error || null,++ decidedAt: row.decided_at || null, createdAt: row.created_at,++ };++ if (research) {++ const r = await get(row.research_id);++ out.research = r ? publicRow(r) : null;++ }++ return out;++ }++++ return { create, get, owned, patch, list, recentDone, byRun, allDone, publicRow, proposalsFor, addProposals, listProposals, proposal, decideProposal, publicProposal };++}++++module.exports = { createDeepDiveStore };@@@ -96,7 +96,7 @@ function editPrompt(instruction, html, names) {].join('\n\n');}--function createDesigns({ s, ai, bench = null, catalog, config = {}, log = console, now = () => Date.now(), sharing = null }) {++function createDesigns({ s, ai, bench = null, catalog, config = {}, log = console, now = () => Date.now(), sharing = null, deepDive = null }) {const db = s.db;const chatEditLimit = Math.max(1, Number(config.studio && config.studio.chatEditsPerDesign) || CHAT_EDITS_DEFAULT);const publicRow = d => {@@@ -105,7 +105,7 @@ function createDesigns({ s, ai, bench = null, catalog, config = {}, log = consolid: d.id, prompt: d.prompt, kind: d.kind, themeId: d.theme_id,frame: d.frame || null, look: d.look || d.theme_id || null,model: names[0] || null, models: names,-- start: publicStart(d.start || {}), sourceDesignId: d.source_design_id || null,++ start: publicStart(d.start || {}), sourceDesignId: d.source_design_id || null, researchId: d.research_id || null,estimate: d.estimate || null,status: d.variants.every(v => v.status === 'done' || v.status === 'failed')? d.variants.some(v => v.status === 'done') ? 'done' : 'failed' : 'running',@@@ -157,7 +157,7 @@ function createDesigns({ s, ai, bench = null, catalog, config = {}, log = consolreturn await theme(d && d.theme_id);}async function create({ prompt, kind = 'page', themeId, look = null, lookVariables = null, frame: frameId = null,-- models = null, takes = null, start = null, sourceDesignId = null, estimate = null,++ models = null, takes = null, start = null, sourceDesignId = null, estimate = null, research = null,owner = null, ip = '', who = 'claude' }) {prompt = String(prompt || '').trim();const kindEntry = kindOf(kind);@@@ -219,21 +219,36 @@ function createDesigns({ s, ai, bench = null, catalog, config = {}, log = consolsource: slot.source || null, status: 'pending', html: '', ms: 0, costUsd: 0, error: null,paid_by: null, prefer: slot.prefer || null, pin: slot.pin === true }));const id = s.newId('dsn'), editKey = owner ? null : random();++ // "Research first": start the dive now, on the person's behalf, and point the design at it. The takes++ // wait for the report in run(); an attached dive is not run or charged again.++ let researchId = null;++ if (research) {++ if (!deepDive) throw fail(503, 'design.research_unavailable', 'Deep dives are not available here.');++ if (research.mode === 'attach' && research.id) {++ researchId = String(research.id);++ } else if (owner) {++ const started = await deepDive.create({ owner, question: research.question || prompt, depth: research.depth,++ sites: research.sites || [], targetId: id });++ researchId = started.research.id;++ } else {++ throw fail(401, 'design.research_sign_in', 'Sign in to start a deep dive for this design.');++ }++ }const startRecord = start && typeof start === 'object' && !Array.isArray(start) ? start : {};const thumbnail = makeThumbnail({ kind, kindName: kindEntry.name, prompt,model: variants[0] ? (variants[0].name || variants[0].model) : null, variables: selected.variables || {} });await db.exec(`INSERT INTO designs(id,prompt,kind,theme_id,variants,owner,guest_hash,guest_ip,created_at,updated_at,-- frame,look,look_variables,models,start,source_design_id,estimate,thumbnail_svg)-- VALUES($1,$2,$3,$4,$5::jsonb,$6,$7,$8,$9::timestamptz,$9::timestamptz,$10,$11,$12::jsonb,$13::jsonb,$14::jsonb,$15,$16::jsonb,$17)`,++ frame,look,look_variables,models,start,source_design_id,estimate,thumbnail_svg,research_id)++ VALUES($1,$2,$3,$4,$5::jsonb,$6,$7,$8,$9::timestamptz,$9::timestamptz,$10,$11,$12::jsonb,$13::jsonb,$14::jsonb,$15,$16::jsonb,$17,$18)`,[id, prompt, kind, selected.id, JSON.stringify(variants), owner, editKey ? hash(editKey).toString('hex') : null,owner ? null : ip, s.iso(), frame, lookId || selected.id, JSON.stringify(selected.variables || {}),requested ? JSON.stringify(requested) : null, JSON.stringify(startRecord), sourceDesignId || null,-- estimate ? JSON.stringify(estimate) : null, thumbnail]);++ estimate ? JSON.stringify(estimate) : null, thumbnail, researchId]);// A signed-in maker's new design is Only me until its Share sheet says otherwise (a guest's is its key).if (sharing && owner) await sharing.register(id, owner).catch((err) => log.warn('[OpenVibe.Design] share row not registered:', err.message));run(id).catch(err => log.warn('[OpenVibe.Design] generation:', err.message));return { design: publicRow({ id, prompt, kind, theme_id: selected.id, frame, look: lookId || selected.id,-- look_variables: selected.variables || {}, models: requested, start: startRecord, source_design_id: sourceDesignId || null,++ look_variables: selected.variables || {}, models: requested, start: startRecord, source_design_id: sourceDesignId || null, research_id: researchId || null,estimate, variants, created_at: s.iso(), updated_at: s.iso() }),takes: variants.map(v => ({ id: v.id, slot: v.slot, provider: v.provider, model: v.model, modelId: v.model_id, name: v.name, status: v.status })),editKey, editUrl: editKey ? `/d/${id}?key=${encodeURIComponent(editKey)}` : null };@@@ -251,7 +266,21 @@ function createDesigns({ s, ai, bench = null, catalog, config = {}, log = consolawait updateVariant(id, index, running);try {const t = await lookTheme(d);-- const prompt = [themePrompt(d.prompt, Object.keys(t.variables || {})),++ // The dive's report is part of the brief for every take. A dive that is still running or failed++ // does not hold the design forever: the takes are drawn without it and the canvas says so.++ let report = null;++ if (d.research_id && deepDive) {++ try {++ const research = await deepDive.wait(d.research_id, { timeoutMs: Number(config.deepDive && config.deepDive.maxWaitMs) || 15 * 60_000 });++ if (research && research.status === 'done') {++ report = { brief: String(research.brief || '').slice(0, 12000), points: Array.isArray(research.points) ? research.points : [], url: research.report_url || null };++ }++ } catch (err) { log.warn('[OpenVibe.Design] design research:', err.message); }++ }++ const points = report && deepDive ? deepDive.pointsForTake(report.points, index, d.variants.length) : [];++ const withResearch = report && report.brief ? `${d.prompt}\n\n${report.brief}` : d.prompt;++ const prompt = [themePrompt(withResearch, Object.keys(t.variables || {})),++ report && deepDive ? deepDive.pointLine(points, report) : '',instructionFor(d.kind, d.frame), startPrompt(d.start || {})].filter(Boolean).join('\n\n');const out = await ai.generate({ prompt, category: categoryFor(d.kind),slot: { provider: v.provider, model: v.model }, roundId: id, index,@@@ -260,7 +289,7 @@ function createDesigns({ s, ai, bench = null, catalog, config = {}, log = consolconst currentTheme = await lookTheme(current);await updateVariant(id, index, { ...running, status: 'done', html: wrap(out.html, currentTheme.variables || {}),provider: out.provider, model: out.model, ms: out.ms, costUsd: out.cost_usd, error: null,-- paid_by: out.paid_by || null, prefer: out.prefer || v.prefer || null });++ research_points: points, paid_by: out.paid_by || null, prefer: out.prefer || v.prefer || null });} catch (err) {await updateVariant(id, index, { ...running, status: 'failed', error: String(err.message).slice(0, 300) });}@@@ -400,6 +429,9 @@ function createDesigns({ s, ai, bench = null, catalog, config = {}, log = consolasync function read(id, owner, key, viewer = null) {const d = await row(id, owner, key, viewer), selected = await lookTheme(d);++ const research = d.research_id && deepDive++ ? await (owner && d.owner === owner ? deepDive.get(d.research_id, owner) : deepDive.summary(d.research_id)).catch(() => null)++ : null;const p = await pickRow(id);const versions = p ? await db.many('SELECT * FROM design_versions WHERE design_id=$1 ORDER BY seq', [id]) : [];const current = p ? versions.find(v => Number(v.seq) === Number(p.current_seq)) : null;@@@ -412,7 +444,7 @@ function createDesigns({ s, ai, bench = null, catalog, config = {}, log = consol}return v.status === 'done' ? { ...v, html: wrap(v.html, selected.variables || {}) } : v;});-- return { design: publicRow(d), variants, pick: pickPublic(p, current), versions: versions.map(v => versionPublic(v, p ? p.current_seq : 1)) };++ return { design: { ...publicRow(d), research }, variants, pick: pickPublic(p, current), versions: versions.map(v => versionPublic(v, p ? p.current_seq : 1)) };}async function pick({ id, variantIndex, variantId, owner, key, subject = null, token = null, viewer = null }) {@@@ -121,7 +121,32 @@ function createPageRoutes(ctx) {// What can be made, in which sizes and looks, by which models at what price (the same answer /api/create/options// gives). Without it the composer shows the themes and the four ways, and posts the old form.const options = ctx.createOptions ? await ctx.createOptions.options({ token: tokenOf(req) }).then((all) => ctx.createOptions.publicOptions(all), () => null) : null;-- return page(res, 200, designView.create({ recent, themes, options }), req.viewer, { cacheControl: 'private, no-store' });++ // "Research first": the depths and their price, and the person's finished dives to attach. A failure++ // leaves the module showing the link to OpenVibe Exchange rather than failing the page.++ const signedIn = Boolean(subjectOf(req));++ const diveOptions = ctx.deepDive ? await ctx.deepDive.options().catch(() => null) : null;++ const dives = signedIn && ctx.deepDive ? await ctx.deepDive.list(subjectOf(req), { limit: 8 }).catch(() => []) : [];++ return page(res, 200, designView.create({ recent, themes, options,++ research: { options: diveOptions, recent: dives.filter((x) => x.status === 'done' || x.status === 'running'), signedIn } }), req.viewer, { cacheControl: 'private, no-store' });++ });++ // One deep dive's report, read on Design: the numbered points with their evidence and the rules it proposes.++ // It is the person's own dive, so only the person who started or attached it can open it.++ r.get('/research/:id/crop/:cropId', async (req, res) => {++ if (!subjectOf(req)) return res.status(401).type('text/plain').send('Sign in to see this deep dive.');++ const research = await ctx.deepDive.get(req.params.id, subjectOf(req)).catch(() => null);++ if (!research || !research.runId) return res.status(404).type('text/plain').send('Not found.');++ try {++ const out = await ctx.deepDiveAi.crop(research.runId, req.params.cropId);++ return res.set({ 'Content-Type': out.contentType, 'Cache-Control': 'private, max-age=86400', 'X-Content-Type-Options': 'nosniff' }).send(out.body);++ } catch (err) {++ return res.status(err.status || 502).type('text/plain').send(err.detail || 'The crop could not be read.');++ }++ });++ r.get('/research/:id', async (req, res) => {++ if (!subjectOf(req)) return res.redirect(303, `/auth/login?next=${encodeURIComponent(req.originalUrl)}`);++ const research = await ctx.deepDive.get(req.params.id, subjectOf(req)).catch(() => null);++ if (!research) return page(res, 404, views.notFound(), req.viewer);++ return page(res, 200, require('../views/deepdive').researchPage({ research, viewer: req.viewer }), req.viewer, { cacheControl: 'private, no-store' });});r.post('/create', express.urlencoded({ extended: false, limit: '8kb' }), async (req, res) => {if (!writeAllowed(req, config)) return res.status(403).send('Open this form on openvibe.design.');@@@ -241,7 +266,30 @@ function createPageRoutes(ctx) {// The decision register and the system map.const rulesView = require('../views/rules');-- r.get('/rules', (req, res) => page(res, 200, rulesView.rulesPage(), req.viewer));++ // "Proposed from research": only the owner sees the rules a deep dive proposes, and only the owner decides.++ // An approval opens a pull request on the register; the register itself is never written by this page.++ const ruleNotice = (req) => ({ notice: req.query.notice ? String(req.query.notice).slice(0, 300) : null,++ error: req.query.error ? String(req.query.error).slice(0, 300) : null });++ r.get('/rules', async (req, res) => {++ const owner = Boolean(ctx.proposals && ctx.proposals.isOwner(req.viewer));++ const proposals = owner ? await ctx.proposals.list({ state: null }).catch((err) => { log.warn('[OpenVibe.Design] rule proposals:', err.message); return []; }) : [];++ const { notice, error } = ruleNotice(req);++ return page(res, 200, rulesView.rulesPage({ proposals, owner, notice, error }), req.viewer);++ });++ for (const action of ['approve', 'dismiss']) {++ r.post(`/rules/proposals/:id/${action}`, express.urlencoded({ extended: false, limit: '4kb' }), async (req, res) => {++ if (!writeAllowed(req, config)) return res.status(403).send('Approve a rule from openvibe.design itself.');++ try {++ const out = await ctx.proposals.decide(req.params.id, action, req.viewer);++ const q = action === 'approve' && out.pull && out.pull.url++ ? `notice=${encodeURIComponent(`Approved: the pull request is open at ${out.pull.url}`)}`++ : `notice=${encodeURIComponent(action === 'dismiss' ? 'Dismissed.' : 'Approved.')}`;++ return res.redirect(303, `/rules?${q}#proposal-${encodeURIComponent(req.params.id)}`);++ } catch (err) {++ return res.redirect(303, `/rules?error=${encodeURIComponent(String(err.detail || err.message || 'That decision could not be made.').slice(0, 300))}#proposal-${encodeURIComponent(req.params.id)}`);++ }++ });++ }// Every openvibe-shared release, read from the pinned package (server/changes.js).r.get('/changes/:version', (req, res) => {const html = changesView.releasePage(req.params.version);@@@ -311,7 +359,8 @@ function createPageRoutes(ctx) {if (note) return page(res, 200, note, req.viewer);try {const entry = await registry.get(req.params.name);-- return entry ? page(res, 200, componentsListView.componentEntryPage(entry), req.viewer)++ const findings = entry && ctx.deepDive && subjectOf(req) ? await ctx.deepDive.finds(entry.name, { limit: 6, owner: subjectOf(req) }).catch(() => null) : null;++ return entry ? page(res, 200, componentsListView.componentEntryPage(entry, { findings }), req.viewer): page(res, 404, views.notFound(), req.viewer);} catch (err) {log.error('[OpenVibe.Design] component registry entry failed:', (err && err.message) || err);@@@ -14,7 +14,10 @@ const TABLES = [{ table: 'arena_rounds', subject: 'created_by', file: 'arena_rounds.json', erase: 'delete' },{ table: 'arena_votes', subject: 'voter_subject', file: 'arena_votes.json', erase: 'delete' },// The designs a signed-in person asked for; a guest's design has no owner and matches nobody. Versions cascade.-- { table: 'designs', subject: 'owner', file: 'designs.json', order: 'created_at', columns: ['id', 'kind', 'prompt', 'theme_id', 'variants', 'frame', 'look', 'look_variables', 'models', 'start', 'source_design_id', 'estimate', 'thumbnail_svg', 'space_url', 'published_at', 'created_at', 'updated_at'] },++ { table: 'designs', subject: 'owner', file: 'designs.json', order: 'created_at', columns: ['id', 'kind', 'prompt', 'theme_id', 'variants', 'frame', 'look', 'look_variables', 'models', 'start', 'source_design_id', 'research_id', 'estimate', 'thumbnail_svg', 'space_url', 'published_at', 'created_at', 'updated_at'] },++ // The deep dives a person ran and the rules they proposed: their research, its report, and their decisions.++ { table: 'design_research', subject: 'owner', file: 'deep_dives.json', order: 'created_at', columns: ['id', 'question', 'depth', 'sites', 'run_id', 'status', 'progress', 'report', 'points', 'brief', 'report_url', 'estimate', 'error', 'created_at', 'updated_at'], erase: 'delete' },++ { table: 'rule_proposals', subject: 'owner', file: 'rule_proposals.json', order: 'created_at', columns: ['id', 'research_id', 'rule', 'detail', 'why', 'area', 'evidence', 'state', 'pr_url', 'pr_branch', 'decided_at', 'created_at', 'updated_at'], erase: 'delete' },// The icons a person asked OpenVibe.AI to draw (/icons). Who reviewed one is not theirs to export. An approved// drawing already taken into openvibe-shared stays there: it is a drawing, it names nobody.// The marks a signed-in person picked on /brand/explore (a guest's picks name nobody).@@@ -46,6 +46,7 @@ async function start() {// The icon and theme pages in OpenVibe.Search: a sweep a minute after boot and every six hours.if (!config.drill) ctx.searchIndex.start();ctx.designs.resume().catch((err) => console.warn('[OpenVibe.Design] resume designs:', err.message));++ if (ctx.deepDive) ctx.deepDive.resume().catch((err) => console.warn('[OpenVibe.Design] resume deep dives:', err.message));});server.keepAliveTimeout = 65_000;createLifecycle({ server, ctx });@@@ -11,6 +11,7 @@const kit = require('openvibe-shared/kit');const { layout, esc } = require('../render/layout');const { RULES } = require('../rules/register');++const deepDiveView = require('./deepdive');const FLOAT_RULE = RULES.find((r) => r.id === 'pip-goes-anywhere');/** What each component is for. A name not listed here lands in Other, so a new kit part is never dropped. */@@@ -118,7 +119,7 @@ function floatDemo() {}/** /components/<name>: one entry, laid out like the designed interface pages. */--function componentEntryPage(entry) {++function componentEntryPage(entry, { findings = null } = {}) {const c = entry.definition || {};const group = GROUPS.find(([id]) => id === GROUP_OF.get(entry.name));const p = c.platforms || {}, web = p.web || {}, android = p.android || {}, ios = p.ios || {};@@@ -128,6 +129,7 @@ function componentEntryPage(entry) {<header class="ds-page-h"><p class="ds-kicker"><a href="/components${group ? `#crl-${group[0]}` : ''}">${esc(group ? group[1] : 'Component registry')}</a></p><h1>${esc(c.title || entry.name)}</h1><p class="ds-lede">${esc(c.summary || '')}</p><p class="crl-where">${web.module ? `<code>${esc(web.module)}</code>` : ''}<a href="/api/v1/components/${encodeURIComponent(entry.name)}">JSON</a></p></header>${entry.name === 'float' ? floatDemo() : ''}++${findings && findings.finds && findings.finds.length ? deepDiveView.componentFinds({ component: entry.name, finds: findings.finds, reports: findings.reports }) : ''}${web.markup ? `<section class="crl-sec crl-build" aria-labelledby="crl-build-h"><h2 id="crl-build-h">Build it</h2>${kit.snippet({ code: String(web.markup), id: 'crl-build' })}</section>` : ''}<div class="cr-layout"><section class="crl-sec" aria-labelledby="crl-props-h"><h2 id="crl-props-h">Props</h2>${props.length ? `<dl class="cr-props">${props.map(([k, t]) => `<div><dt><code>${esc(k)}</code></dt><dd>${esc(t)}</dd></div>`).join('')}</dl>` : '<p class="crl-none">No props.</p>'}@@@ -148,7 +150,7 @@ ${examples(entry)}description: c.summary || 'One design.component@1 entry.',path: `/components/${entry.name}`,active: '/components',-- styles: ['css/component-registry.css'],++ styles: ['css/component-registry.css', 'css/deepdive.css'],shared: entry.name === 'float' ? ['ov-float.js'] : [],scripts: entry.name === 'float' ? ['js/ov-float-demo.js'] : [],body,nnew file mode 100644@@@ -0,0 +1,159 @@++'use strict';++++/**++ * The plain pages of "Research first": the composer's module, the canvas's report panel, one report read on++ * Design, "How others do it" on a component page, and the owner's "Proposed from research" list. The look is++ * the main session's; this is kit markup and the words the brief names, nothing restyled.++ *++ * A crop is commentary: it is shown small with its site, its address and the day it was read, and it is never++ * pasted into a design. A reference token is a direction, never a palette to copy.++ */++const kit = require('openvibe-shared/kit');++const { layout, esc } = require('../render/layout');++++const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];++const day = (iso) => { const t = Date.parse(iso); if (!isFinite(t)) return ''; const d = new Date(t); return `${d.getUTCDate()} ${MONTHS[d.getUTCMonth()]} ${d.getUTCFullYear()}`; };++const usd = (n) => (n == null || !Number.isFinite(Number(n)) ? '' : Number(n) >= 1 ? `$${Number(n).toFixed(2)}` : Number(n) > 0 ? `${Math.max(1, Math.round(Number(n) * 100))}¢` : 'free');++const publicUrl = (url) => { const s = String(url || ''); return /^\//.test(s) && !/^\/research\//.test(s) ? `https://openvibe.exchange${s}` : s; };++// A report is written by another service: no link or image in it may carry a scheme we did not choose.++const safeHref = (url) => { const s = String(url || ''); return /^(https?:\/\/|\/)/i.test(s) ? publicUrl(s) : null; };++const safeImage = (url) => { const s = String(url || ''); return /^(https?:\/\/|\/|data:image\/)/i.test(s) ? (s.startsWith('data:') ? s : publicUrl(s)) : null; };++const host = (url) => { try { return new URL(String(url)).hostname.replace(/^www\./, ''); } catch { return ''; } };++const DEPTH_LINE = { quick: 'Six sites, about three minutes', thorough: 'Fifteen sites, a deeper read', expert: 'Thirty sites, plus interactions, accessibility and speed' };++++function cropSrc(e, research) {++ if (e && e.crop) return publicUrl(e.crop);++ if (e && e.cropId && research && research.id) return `/research/${encodeURIComponent(research.id)}/crop/${encodeURIComponent(e.cropId)}`;++ return null;++}++function evidenceList(evidence, { crops = false, research = null } = {}) {++ const list = Array.isArray(evidence) ? evidence : [];++ if (!list.length) return '';++ return `<ul class="dd-evidence">${list.map((e) => {++ const site = e.site || host(e.url);++ const label = site || (e.url ? 'Source' : 'Evidence');++ const href = safeHref(e.url);++ const link = href ? `<a href="${esc(href)}" rel="noopener">${esc(label)}</a>` : `<span>${esc(label)}</span>`;++ const when = e.capturedAt ? ` · read ${esc(day(e.capturedAt) || String(e.capturedAt).slice(0, 10))}` : '';++ const src = crops ? safeImage(cropSrc(e, research)) : null;++ const crop = src ? `<img class="dd-crop" src="${esc(src)}" alt="A crop from ${esc(label)}" loading="lazy" decoding="async">` : '';++ return `<li>${crop}<span class="dd-ev">${link}${when}${e.note ? ` · ${esc(e.note)}` : ''}</span></li>`;++ }).join('')}</ul>`;++}++++/** The composer's "Research first" module. `options` is GET /api/deep-dive/options. */++function researchModule({ options = null, recent = [], signedIn = false } = {}) {++ const o = options || {};++ const depths = Array.isArray(o.depths) ? o.depths : [];++ const priced = depths.some((d) => d.price_usd != null);++ if (!signedIn) {++ return `<details class="dv-mod" data-mod="research"><summary><span class="dv-mod-k">Research first</span><b>Deep dive</b></summary>++<div class="dv-mod-sheet" role="group" aria-label="Research first"><header><b>Research first</b><small>An expert design research run: we search everywhere, capture the best sites at phone and computer sizes, and tell you what works and why. It goes into the brief for every take.</small></header>++<p>${kit.button({ href: '/auth/login?next=%2Fcreate', label: 'Sign in to start a deep dive', kind: 'quiet', icon: 'spark' })}</p>++<p class="dv-fine">A deep dive is priced on OpenVibe Exchange and held on your Balance before it runs. <a href="https://openvibe.exchange/deep-dive">Read about deep dives</a>.</p></div></details>`;++ }++ const depthRows = depths.length ? depths.map((d, i) => `<label class="dv-opt"><input type="radio" name="research_depth" value="${esc(d.id)}" data-label="${esc(d.name || d.id)}"${d.id === 'thorough' || (!i && !depths.some((x) => x.id === 'thorough')) ? ' checked' : ''}><span><b>${esc(d.name || d.id)}</b><small>${esc(d.line || DEPTH_LINE[d.id] || '')}${d.price_usd != null ? ` · ${usd(d.price_usd)}` : ''}${d.sites ? ` · ${Number(d.sites)} sites` : ''}</small></span></label>`).join('')++ : `<p class="dv-fine">The depths are not readable right now. <a href="https://openvibe.exchange/deep-dive">Start a deep dive on OpenVibe Exchange</a>, then attach it here.</p>`;++ const done = (recent || []).filter((r) => r && r.id);++ const recentRows = done.length ? `<label class="dv-start"><span>Or attach a dive you already ran</span><select name="research_id"><option value="">None</option>${done.map((r) => `<option value="${esc(r.id)}">${esc(String(r.question || 'Deep dive').slice(0, 80))}${r.status === 'running' ? ' (still running)' : ''} · ${esc(day(r.createdAt))}</option>`).join('')}</select></label>` : '';++ return `<details class="dv-mod" data-mod="research"><summary><svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3.5l1.9 5.1 5.1 1.9-5.1 1.9L12 17.5l-1.9-5.1L5 10.5l5.1-1.9z"/></svg><span class="dv-mod-k">Research first</span><b data-mod-v>${priced ? 'Off' : 'Deep dive'}</b></summary>++<div class="dv-mod-sheet" role="group" aria-label="Research first"><header><b>Research first</b><small>A deep dive's synthesis (patterns, do and don't, reference tokens as inspiration, the rules it cites) becomes part of the generation brief. Each take says which research points it follows. A site's assets and code are never copied.</small></header>++<label class="dv-opt dv-opt--check"><input type="checkbox" name="research" value="new" data-research="on"><span><b>Start a deep dive for this design</b><small>The takes wait for its report, then are drawn from it.</small></span></label>++<div class="dv-deep" data-research-new hidden>++${depthRows}++${priced ? '' : '<p class="dv-fine">The price is shown before the dive starts on <a href="https://openvibe.exchange/deep-dive">OpenVibe Exchange</a>.</p>'}++</div>++<div class="dv-deep" data-research-attach>++${recentRows}++<label class="dv-start"><span>Or paste a report link or run id</span><input type="text" name="research_ref" autocomplete="off" maxlength="2000" placeholder="https://openvibe.exchange/deep-dive/…"></label>++</div>++<p class="dv-fine">A dive is a separate priced run on OpenVibe.AI; its report stays readable on OpenVibe Exchange and at <span class="dd-inline">/research/<id></span> here. Research informs; it never copies a site's assets or code.</p></div></details>`;++}++++/** The canvas's research panel: the live steps, then the numbered points the takes follow. */++function researchPanel(research) {++ if (!research || !research.id) return '';++ const steps = (research.progress && Array.isArray(research.progress.steps)) ? research.progress.steps : [];++ const points = Array.isArray(research.points) ? research.points : [];++ const running = research.status === 'starting' || research.status === 'running';++ const stepList = steps.length ? `<ol class="dd-steps">${steps.map((s) => `<li class="dd-step is-${esc(s.state || 'waiting')}"><span>${esc(s.name)}</span></li>`).join('')}</ol>` : '';++ const shown = points.slice(0, 6);++ const pointsList = shown.length ? `<ol class="dd-points">${shown.map((p) => `<li id="p${esc(String(p.n))}"><a href="${esc(research.url)}#p${esc(String(p.n))}"><span class="dd-n">${esc(String(p.n))}</span>${esc(p.text)}</a></li>`).join('')}</ol>${points.length > shown.length ? `<p class="dv-fine"><a href="${esc(research.url)}">All ${points.length} research points on the report</a></p>` : ''}` : '';++ const stateKey = `${research.status}|${steps.map((s) => s.state).join(',')}`;++ return `<section class="dv-research" data-dv-research="${esc(research.id)}" data-dd-state="${esc(stateKey)}" aria-labelledby="dv-research-h">++<h2 id="dv-research-h">Research first${running ? ' · working' : research.status === 'done' ? ' · its report' : ''}</h2>++<p class="dv-research-q">${esc(research.question || 'A deep dive')} · ${esc(research.depth || 'thorough')}${research.priceUsd != null ? ` · ${esc(usd(research.priceUsd))}` : ''}${research.status === 'done' ? ` · ${points.length} points` : ''}</p>++${stepList}++${research.status === 'failed' ? `<p class="dv-fine is-error">The deep dive did not finish${research.error ? `: ${esc(research.error)}` : ''}. The takes are drawn without it.</p>` : ''}++${running ? `<p class="dv-fine">The takes wait for the report. <a href="${esc(research.url)}">Follow it</a>.</p>` : research.reportUrl && safeHref(research.reportUrl) ? `<p class="dv-links"><a href="${esc(research.url)}">Read the report on Design</a> · <a href="${esc(safeHref(research.reportUrl))}" rel="noopener">On OpenVibe Exchange</a></p>` : `<p class="dv-links"><a href="${esc(research.url)}">Read the report</a></p>`}++${pointsList}</section>`;++}++++/** The chips on one take: the points it was asked to follow, linked to the report. */++function takePoints(points, research) {++ if (!Array.isArray(points) || !points.length) return '';++ const base = research && research.url ? research.url : '#';++ return `<p class="dv-take-research">Follows ${points.map((p) => `<a href="${esc(base)}#p${esc(String(p.n))}" title="${esc(String(p.text || '').slice(0, 240))}">research point ${esc(String(p.n))}</a>`).join(', ')}</p>`;++}++++/** One report, read on Design: the numbered points with their evidence, and the rules it proposes. */++function researchPage({ research, viewer = null, notice = null, error = null } = {}) {++ const r = research || {};++ const points = Array.isArray(r.points) ? r.points : [];++ const groups = [...new Set(points.map((p) => p.group))];++ const steps = (r.progress && Array.isArray(r.progress.steps)) ? r.progress.steps : [];++ const proposals = Array.isArray(r.proposals) ? r.proposals : [];++ const running = r.status === 'starting' || r.status === 'running';++ const body = `<div class="ds-wrap tk dd-page">++<header class="ds-page-h"><p class="ds-kicker">Research first</p><h1>${esc(r.question || 'A deep dive')}</h1>++<p class="ds-lede">A ${esc(r.depth || 'thorough')} deep dive${r.priceUsd != null ? ` (${esc(usd(r.priceUsd))})` : ''}${r.createdAt ? `, started ${esc(day(r.createdAt))}` : ''}; the takes on its design follow the numbered points below. ${safeHref(r.reportUrl) ? `<a href="${esc(safeHref(r.reportUrl))}" rel="noopener">The run on OpenVibe Exchange</a>.` : ''}</p>++<p class="dd-status">${running ? 'Working' : r.status === 'done' ? `Done · ${points.length} research points` : r.status === 'failed' ? 'Did not finish' : esc(r.status || '')}${r.error ? ` · ${esc(r.error)}` : ''}</p>++${notice ? `<p class="dd-notice">${esc(notice)}</p>` : ''}${error ? `<p class="dd-notice is-error">${esc(error)}</p>` : ''}++${steps.length ? `<ol class="dd-steps">${steps.map((s) => `<li class="dd-step is-${esc(s.state || 'waiting')}"><span>${esc(s.name)}</span></li>`).join('')}</ol>` : ''}++</header>++${points.length ? `<section class="dd-sec" aria-labelledby="dd-points-h"><h2 id="dd-points-h">The research points</h2>++${groups.map((g) => `<h3>${esc(g === "don't" ? 'Do not' : g === 'do' ? 'Do' : g === 'reference token' ? 'Reference tokens, as inspiration' : g)}</h3><ol class="dd-points dd-points--page">${points.filter((p) => p.group === g).map((p) => `<li id="p${esc(String(p.n))}"><span class="dd-n">${esc(String(p.n))}</span><div><p>${esc(p.text)}</p>${evidenceList(p.evidence, { crops: true, research: r })}</div></li>`).join('')}</ol>`).join('')}++</section>` : running ? kit.empty({ icon: 'spark', title: 'Still researching', text: 'The points appear here as the dive writes them up.' }) : kit.empty({ icon: 'search', title: 'No report yet', text: 'This deep dive has not produced a report.' })}++${proposals.length ? `<section class="dd-sec" aria-labelledby="dd-props-h"><h2 id="dd-props-h">Proposed from research</h2>${proposalsList(proposals, { compact: true })}</section>` : ''}++<p class="dd-back"><a href="${r.designId ? `/d/${encodeURIComponent(r.designId)}` : '/create'}">${r.designId ? 'Back to the design' : 'Create a design'}</a></p>++</div>`;++ return layout({ title: `${String(r.question || 'A deep dive').slice(0, 70)} · Research`, description: 'A deep dive report on OpenVibe.Design: patterns, do and don\'t, reference tokens and the rules it cites, with the evidence behind each point.', path: `/research/${r.id || ''}`, robots: 'noindex, nofollow', styles: ['css/deepdive.css'], body });++}++++/** "How others do it" on a component page. Empty and absent until a finished dive has a point for it. */++function componentFinds({ component, finds = [], reports = 0 } = {}) {++ if (!Array.isArray(finds) || !finds.length) return '';++ return `<section class="crl-sec dd-finds" id="how-others" aria-labelledby="dd-finds-h"><h2 id="dd-finds-h">How others do it</h2>++<p>From ${reports === 1 ? 'a deep dive' : `${reports} deep dives`} on this component. Each crop is commentary: it is shown with its site and the day it was read, and is never copied into a design.</p>++<ul class="dd-find-list">${finds.map((f) => `<li class="dd-find">++${safeImage(cropSrc({ crop: f.crop, cropId: f.cropId }, { id: f.researchId })) ? `<a href="${esc(safeHref(f.url) || safeImage(cropSrc({ crop: f.crop, cropId: f.cropId }, { id: f.researchId })))}" rel="noopener"><img class="dd-crop" src="${esc(safeImage(cropSrc({ crop: f.crop, cropId: f.cropId }, { id: f.researchId })))}" alt="A crop from ${esc(f.site || host(f.url) || 'a reference site')}" loading="lazy" decoding="async"></a>` : ''}++<div><b>${esc(f.title || 'A finding')}</b>++${f.site || f.capturedAt || f.url ? `<p class="dd-attrib">${esc(f.site || host(f.url) || 'Reference')}${f.capturedAt ? ` · read ${esc(day(f.capturedAt) || String(f.capturedAt).slice(0, 10))}` : ''}${safeHref(f.url) ? ` · <a href="${esc(safeHref(f.url))}" rel="noopener">the site</a>` : ''}${f.n ? ` · <a href="${esc(f.researchUrl)}#p${esc(String(f.n))}">research point ${esc(String(f.n))}</a>` : f.researchUrl ? ` · <a href="${esc(f.researchUrl)}">the report</a>` : ''}</p>` : ''}++</div></li>`).join('')}</ul></section>`;++}++++function proposalsList(proposals, { owner = false, compact = false } = {}) {++ const list = Array.isArray(proposals) ? proposals : [];++ if (!list.length) return compact ? '' : kit.empty({ icon: 'spark', title: 'No proposed rules', text: 'A deep dive that proposes a rule for the register shows it here. Nothing is added by itself.' });++ return `<div class="dd-props">${list.map((p) => `<article class="dd-prop" id="proposal-${esc(p.id)}">++<header><span class="dd-prop-state is-${esc(p.state)}">${p.state === 'approved' ? 'Approved' : p.state === 'dismissed' ? 'Dismissed' : 'Proposed'}</span><h3>${esc(p.rule)}</h3></header>++${p.detail ? `<p>${esc(p.detail)}</p>` : ''}++${p.why ? `<p class="dd-why"><span>Why</span> ${esc(p.why)}</p>` : ''}++${p.evidence && p.evidence.length ? evidenceList(p.evidence, { crops: true, research: p.research }) : ''}++<p class="dd-attrib">${p.research ? `From <a href="${esc(p.research.url)}">${esc(p.research.question || 'a deep dive')}</a>` : 'From a deep dive'} · area: ${esc(p.area || 'products')}</p>++${safeHref(p.prUrl) ? `<p class="dd-pr"><a href="${esc(safeHref(p.prUrl))}" rel="noopener">The pull request against the register</a></p>` : ''}++${p.prError ? `<p class="dd-notice is-error">${esc(p.prError)}</p>` : ''}++${owner && p.state === 'proposed' ? `<div class="dd-prop-acts">++<form method="post" action="/rules/proposals/${esc(p.id)}/approve"><button type="submit" class="ovk-btn ovk-btn--primary">Approve and open a pull request</button></form>++<form method="post" action="/rules/proposals/${esc(p.id)}/dismiss"><button type="submit" class="ovk-btn ovk-btn--quiet">Dismiss</button></form>++</div>` : ''}++</article>`).join('')}</div>`;++}++++/** "Proposed from research" on /rules, for the owner only. */++function proposalsSection(proposals) {++ return `<section class="rl-sec dd-proposed" aria-labelledby="rl-proposed-h"><div class="rl-sec-head"><h2 id="rl-proposed-h">Proposed from research</h2><p>A deep dive can propose a rule for this register, with the evidence behind it. Only the owner decides; approval opens a pull request against <code>server/rules/register.js</code>. Nothing is added by itself.</p></div>++${proposalsList(proposals, { owner: true })}</section>`;++}++++module.exports = { researchModule, researchPanel, takePoints, researchPage, componentFinds, proposalsSection, proposalsList, evidenceList, day, usd, DEPTH_LINE };@@@ -4,6 +4,7 @@ const { layout, esc } = require('../render/layout');const ovServe = require('openvibe-shared/serve');const { attrEscape } = require('../arena/sanitize');const { themeCss } = require('../designs');++const deepDiveView = require('./deepdive');// The composer's choices. The server takes kind, themeId and who (designs.create); each is a choice a person can see.const G = (d) => `<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">${d}</svg>`;@@@ -79,7 +80,7 @@ const maker = (p) => { const k = String(p || '').toLowerCase(); return MAKER[k]* designer, and the form posts as it always did. With a script, the form is sent to POST /api/create and the cost is* asked from POST /api/create/estimate as the choices change.*/--function create({ recent = [], themes = [], options = null } = {}) {++function create({ recent = [], themes = [], options = null, research = null } = {}) {const o = options || {};const kinds = (o.kinds && o.kinds.length ? o.kinds : MAKES.slice(0, 3).map((m) => ({ id: m.id, name: m.name, frames: [] }))).map((k) => ({ ...k, ...(MAKE[k.id] || { glyph: MAKES[0].glyph, placeholder: 'Describe what you need…', examples: [] }), name: k.name || (MAKE[k.id] || {}).name || k.id }));@@@ -108,6 +109,7 @@ ${models.length ? `<p class="dv-group-h">Or choose the models yourself<small>Up<div class="dv-models" data-max="${Number(limits.models) || 4}">${modelRows}</div><div class="dv-takes"><span><b>Takes from each</b><small>${Number(limits.takes_total) || 8} takes at most in one run</small></span><span class="dv-step"><button type="button" data-step="-1" aria-label="One take fewer">−</button><input type="number" name="takes" inputmode="numeric" min="${Number(takes.min) || 1}" max="${Number(takes.max) || 4}" value="${Number(takes.default) || 3}" data-total="${Number(limits.takes_total) || 8}" aria-label="Takes from each model"><button type="button" data-step="1" aria-label="One take more">+</button></span></div>` : ''}<a class="dv-mod-more" href="https://openvibe.exchange/models">Compare these models on OpenVibe Exchange</a></div></details>`;++ const researchMod = deepDiveView.researchModule({ options: research && research.options, recent: research && research.recent, signedIn: Boolean(research && research.signedIn) });const startMod = options ? `<details class="dv-mod" data-mod="start"><summary>${LINK_ICON}<span class="dv-mod-k">Start</span><b data-mod-v>Blank</b></summary><div class="dv-mod-sheet" role="group" aria-label="Start from"><header><b>Start from a page</b><small>Paste a public address and the takes begin from what that page says and how it is laid out. Leave it empty to start blank.</small></header><label class="dv-start"><span class="dv-visually-hidden">The page's address</span><input type="url" name="start_url" inputmode="url" autocomplete="off" placeholder="https://example.com/page" maxlength="2000"></label>@@@ -119,12 +121,12 @@ ${models.length ? `<p class="dv-group-h">Or choose the models yourself<small>Up${kindRow}<label class="dv-visually-hidden" for="dv-prompt">Describe your design</label><textarea id="dv-prompt" name="prompt" required maxlength="${Number(limits.prompt) || 4000}" placeholder="${esc((kinds[0] || MAKES[0]).placeholder)}"></textarea>--<div class="dv-tray">${frameMod}${lookMod}${whoMod}${startMod}<button type="submit" class="dv-go"><span data-go-label>Create three takes</span>${ARROW}</button></div>++<div class="dv-tray">${frameMod}${lookMod}${whoMod}${startMod}${researchMod}<button type="submit" class="dv-go"><span data-go-label>Create three takes</span>${ARROW}</button></div><p class="dv-cost" data-dv-cost role="status" hidden></p></form><div class="dv-examples" aria-label="Try an example">${kinds.map((k) => (k.examples || []).map((x) => `<button type="button" data-example="${esc(x)}" data-kind="${esc(k.id)}">${k.glyph}<span>${esc(x)}</span></button>`).join('')).join('')}</div>${recent.length ? `<section class="dv-recent"><h2>Your recent designs</h2><ul>${recent.map((d) => { const k = kindName[d.kind] || MAKE[d.kind] || MAKES[0]; const bits = [k.name, lookName[d.look] || lookName[d.themeId], d.model, d.status === 'running' ? 'Drawing now' : d.status === 'failed' ? 'Did not finish' : ago(d.createdAt)].filter(Boolean);return `<li><a class="dv-rc" href="${esc(d.url)}"><span class="dv-rc-k" aria-hidden="true">${d.thumbnail ? `<img src="${esc(d.thumbnail)}" alt="" loading="lazy" decoding="async">` : k.glyph}</span><span class="dv-rc-t"><b>${esc(d.prompt)}</b><small>${esc(bits.join(' · '))}</small></span></a></li>`; }).join('')}</ul></section>` : ''}</section>`;-- return layout({ title: 'Create a design', path: '/create', body, active: '/create', scripts: ['js/design.js'] });++ return layout({ title: 'Create a design', path: '/create', body, active: '/create', styles: ['css/deepdive.css'], scripts: ['js/design.js'] });}const themeListener = `<script data-dv-theme="1">(function(){addEventListener('message',function(event){if(event.source!==parent||!event.data||event.data.type!=='design-theme')return;var style=document.getElementById('design-tokens');if(style&&typeof event.data.css==='string')style.textContent=event.data.css;});})();</script>`;@@@ -213,6 +215,7 @@ function canvas({ data, themes = [], editKey = '', viewer = null, publishing = {`<div class="dv-skeleton" role="status"><span>${esc(v.model || 'Designer')}</span><small>${esc(v.status === 'failed' ? 'Could not generate' : 'Creating…')}</small></div>`}</div><div class="dv-label"><span class="dv-model">${esc(v.model || 'Designer')}</span>${done ?`<span>${(Number(v.ms || 0) / 1000).toFixed(1)} s · $${Number(v.costUsd || 0).toFixed(4)}</span>` : ''}</div>++${deepDiveView.takePoints(v.research_points || v.researchPoints, design.research)}<div class="dv-card-actions">${picked? `<span class="dv-picked">Working frame</span><span class="dv-fine">Click any element to edit it</span>`: done ? (pick ? '<span class="dv-fine">Not the working frame</span>'@@@ -249,6 +252,7 @@ ${versionDetail(v)}</span></li>`).join('');<p id="dv-status" role="status">${esc(statusText)}</p><p class="dv-share"><button type="button" data-ov-share-sheet data-kind="design" data-id="${esc(design.id)}"data-title="${esc(design.prompt)}" data-url="${esc(`/d/${encodeURIComponent(design.id)}`)}">Share</button></p></header>++${deepDiveView.researchPanel(design.research)}<form class="dv-chat" id="dv-chat-form" method="post" action="/d/${encodeURIComponent(design.id)}/edit">${keyInput}<label for="dv-instruction">Edit with AI</label>@@@ -284,7 +288,7 @@ ${versions.length ? `<ol class="dv-vlist" id="dv-vlist">${versionRows}</ol>` : '</aside><script type="application/json" id="dv-theme-css">${cssJson}</script>`;return layout({ title: 'Design canvas', path: `/d/${encodeURIComponent(design.id)}`, robots: 'noindex, nofollow',-- body, scripts: ['js/design.js'] });++ body, styles: ['css/deepdive.css'], scripts: ['js/design.js'] });}module.exports = { create, canvas, gate, frame, overlayScript };@@@ -10,6 +10,7 @@const kit = require('openvibe-shared/kit');const { layout, esc, baseUrl } = require('../render/layout');const { AREAS, RULES, byArea, latest } = require('../rules/register');++const deepDiveView = require('./deepdive');const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];const day = (iso) => { const [y, m, d] = String(iso || '').split('-').map(Number); return y ? `${d} ${MONTHS[m - 1]} ${y}` : ''; };@@@ -26,7 +27,7 @@ function rule(r) {</article>`;}--function rulesPage() {++function rulesPage({ proposals = [], owner = false, notice = null, error = null } = {}) {const areas = byArea();const newest = latest();const recent = [...RULES].sort((a, b) => (a.decided < b.decided ? 1 : -1)).slice(0, 5);@@@ -48,6 +49,8 @@ function rulesPage() {</section>`).join('');const data = `<section class="rl-sec rl-data" aria-labelledby="rl-data-h"><div class="rl-sec-head"><h2 id="rl-data-h">Use the rules</h2><p>The register is data too: read it from code, hand it to an AI assistant before it designs for OpenVibe, or link one rule by its address.</p></div><p class="rl-links">${kit.button({ href: '/api/v1/rules', label: 'As JSON', kind: 'quiet', icon: 'code' })}${kit.button({ href: '/rules.md', label: 'As Markdown', kind: 'quiet', icon: 'file' })}${kit.button({ href: '/patterns', label: 'The patterns', kind: 'quiet', icon: 'forward' })}</p></section>`;++ const proposed = owner ? deepDiveView.proposalsSection(proposals) : '';++ const noticeBar = notice || error ? `<p class="dd-notice${error ? ' is-error' : ''}" role="status">${esc(error || notice)}</p>` : '';const jsonLd = [{ '@context': 'https://schema.org', '@type': 'CollectionPage', name: 'How OpenVibe decides', url: `${baseUrl()}/rules`, dateModified: newest, hasPart: RULES.map((r) => ({ '@type': 'CreativeWork', name: r.rule, url: `${baseUrl()}/rules#${r.id}`, dateCreated: r.decided })) }];return layout({title: 'Rules: how OpenVibe decides',@@@ -57,7 +60,7 @@ function rulesPage() {styles: ['css/rules.css'],scripts: ['js/rules.js'],jsonLd,-- body: `<article class="rl">${hero}${door}${index}${latestBlock}${sections}${data}</article>`,++ body: `<article class="rl">${hero}${noticeBar}${door}${proposed}${index}${latestBlock}${sections}${data}</article>`,});}nnew file mode 100644@@@ -0,0 +1,323 @@++'use strict';++++/**++ * Research first (owner ask, 2026-11-11): a deep dive's synthesis goes into the generation brief, each take++ * records the numbered points it follows, a component page gains "How others do it" only when a finished dive++ * has a finding for it, and the owner alone decides a proposed rule (an approval opens a pull request; nothing++ * is added to the register by itself).++ */++++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++const { startAi } = require('./helpers/ai');++const kinds = require('../server/create/kinds');++++const html = '<!doctype html><html><head><title>A design</title></head><body><h1>A robot panel</h1><button>Stop</button></body></html>';++const ev = (url, site, extra = {}) => ({ claim: 'A captured control panel', url, read_at: '2026-11-11T10:00:00.000Z', source_id: 'src_acme', capture_id: 'cap_acme', ...extra });++const report = {++ id: 'run_01JKLMNPQRSTVWXYZ0123456789',++ schema: 'ai.deep-dive@1',++ status: 'succeeded',++ input: { question: 'A phone-first robot control panel', depth: 'quick', max_sites: 6, sites: [] },++ steps: [++ { key: 'plan', status: 'done' }, { key: 'search', status: 'done' }, { key: 'choose', status: 'skipped' },++ { key: 'capture', status: 'done' }, { key: 'analyse', status: 'done' }, { key: 'synthesise', status: 'done' }, { key: 'report', status: 'done' },++ ],++ sources: [++ { id: 'src_acme', kind: 'web', title: 'Acme controls', url: 'https://acme.example/controls', read_at: '2026-11-11T10:00:00.000Z' },++ ],++ captures: [++ { id: 'cap_acme', source_id: 'src_acme', url: 'https://acme.example/controls', captured_at: '2026-11-11T10:00:00.000Z', viewport: 390, theme: 'dark', full_page: true, crops: [{ id: 'crop_acme', media_id: 'med_01JKLMNPQRSTVWXYZ0123456789', y: 0, height: 640 }] },++ ],++ analysis: [++ { source_id: 'src_acme', url: 'https://acme.example/controls', summary: 'A phone-first panel whose main button sits in the thumb arc and keeps Stop one press away.', strengths: ['The Stop button never moves.'], weaknesses: ['The deck is denser than it needs to be.'], evidence: [ev('https://acme.example/controls', 'acme.example')] },++ ],++ synthesis: {++ patterns: [++ { name: 'One primary button in the thumb arc', what: 'The primary button sits in the thumb arc, with Stop always one press away.', sites: ['src_acme'], usage_count: 2, evidence: [ev('https://acme.example/controls', 'acme.example')] },++ { name: 'State stays on the control', what: 'A second button reads its state in the same place the first one acts.', sites: ['src_acme'], usage_count: 2, evidence: [ev('https://acme.example/controls', 'acme.example')] },++ ],++ comparison: { columns: ['Site', 'Density'], rows: [['acme.example', 'dense']] },++ best: { source_id: 'src_acme', what: 'The Stop disc stays in the same place at every size.', evidence: [ev('https://acme.example/controls', 'acme.example')] },++ mistakes: [{ what: 'A menu hides the emergency stop.', evidence: [ev('https://acme.example/controls', 'acme.example')] }],++ gap: 'No one shows the robot’s state beside the controls.',++ reference_tokens: {++ palettes: [{ source_id: 'src_acme', colours: ['#0b0f19', '#e6edf7', '#e5484d'] }],++ type_scales: [{ source_id: 'src_acme', families: ['Inter'], sizes: ['16/24', '28/32'] }],++ spacing: [{ source_id: 'src_acme', steps: ['4', '8', '12', '16'] }],++ },++ do: ['Name every control with one verb the person already knows.'],++ dont: ['Do not hide Stop in a menu, under a hold or behind a slide.'],++ accessibility: ['The Stop disc has a 48 px target.'],++ speed: ['The panel is under 60 kB.'],++ },++ recommendations: [++ { id: 'rec_stop', what: 'A robot control surface keeps Stop visible at every size.', why: 'Every captured control panel that hid Stop made an emergency cost an extra press.', rule: { id: 'controls-one-family', status: 'follows', text: 'Rule 128: a device’s controls are one family, arranged per person.' }, evidence: [ev('https://acme.example/controls', 'acme.example', { crop_id: 'crop_acme' })] },++ { id: 'rec_pressed', what: 'A control’s pressed state is visible in the same place it acts.', why: 'Two of the three best panels put feedback on the control; the rest made people look away.', rule: { id: null, status: 'would_change', text: 'A control’s pressed state is visible on the control itself.' }, evidence: [ev('https://acme.example/controls', 'acme.example')] },++ { id: 'rec_targets', what: 'A control target is at least 48 px at every size.', why: 'The captured panels that shrink their targets on a phone were harder to hit.', rule: { id: null, status: 'would_change', text: 'A control’s target is at least 48 px.' }, evidence: [ev('https://acme.example/controls', 'acme.example')] },++ ],++ receipt: { price_usd: 0.25, held_usd: 0.25, cost_usd: 0.18, models: [], captures_count: 1, synthetic: false },++ gaps: [],++ created_at: '2026-11-11T09:57:00.000Z', updated_at: '2026-11-11T10:00:00.000Z', finished_at: '2026-11-11T10:00:00.000Z',++};++++const slots = [{ provider: 'alpha', model: 'one' }, { provider: 'beta', model: 'two' }, { provider: 'gamma', model: 'three' }];++++function fakeAi(state) {++ return {++ designPlan: async () => ({ slots }),++ exchangeCatalog: async () => ({}),++ choices: async () => ({}),++ offeredModels: async () => [],++ generate: async ({ prompt, slot }) => {++ state.prompts.push(prompt);++ return { html, provider: slot.provider, model: slot.model, cost_usd: 0.001, ms: 5 };++ },++ };++}++++function createOptions() {++ const allKinds = kinds.KINDS.map((k) => ({ id: k.id, name: k.name, frames: k.frames }));++ return {++ options: async () => ({++ kinds: allKinds,++ frames: kinds.FRAMES,++ looks: [{ id: 'vibe', name: 'Vibe', source: 'theme', variables: { '--bg-primary': '#0b0f19', '--text-primary': '#e6edf7', '--accent': '#3b82f6' }, swatch: [] }],++ models: [], takes: { min: 1, max: 4, default: 3, total: 8 },++ limits: { prompt: 4000, models: 4, takes_per_model: 4, takes_total: 8 },++ currency: 'USD',++ }),++ publicOptions: (all) => all,++ };++}++++const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));++++(async () => {++ const dive = await startAi({ deepDive: {++ options: { depths: [++ { id: 'quick', name: 'Quick', price_usd: 0.25, sites: 6, minutes: 3, line: 'Six sites, about three minutes' },++ { id: 'thorough', name: 'Thorough', price_usd: 0.65, sites: 15, line: 'Fifteen sites' },++ { id: 'expert', name: 'Expert', price_usd: 1.5, sites: 30, line: 'Thirty sites' },++ ] },++ report,++ } });++ const state = { prompts: [] };++ const rulePr = { calls: [], open: async ({ proposal, reportUrl }) => { rulePr.calls.push({ rule: proposal.rule, reportUrl }); return { url: `https://github.com/OpenVibers/OpenVibe.Design/pull/${rulePr.calls.length}`, number: rulePr.calls.length, branch: 'rules/research-test' }; } };++ const t = await boot({ ai: fakeAi(state), createOptions: createOptions(), env: { DESIGN_DEEP_DIVE_URL: dive.url, DESIGN_DEEP_DIVE_POLL_MS: '5' }, rulePr });++ try {++ const owner = t.network.addUser('goosely', { role: 'owner' });++ const kim = t.network.addUser('kim');++ let researchId;++++ await check('the contract report becomes numbered points, a brief and proposed rules with no copied code', async () => {++ const { normalizeReport, findsFor } = require('../server/deepdive/report');++ const n = normalizeReport(report, { question: 'A phone-first robot control panel', depth: 'quick' });++ assert.ok(n.points.length >= 9, `points ${n.points.length}`);++ assert.ok(n.points.every((x) => x.n >= 1 && x.text), 'every point has a number and a sentence');++ assert.match(n.brief, /Research first: A phone-first robot control panel/);++ assert.match(n.brief, /What the better sites do/);++ assert.match(n.brief, /Reference tokens, as direction only/);++ assert.match(n.brief, /OpenVibe rules this follows/);++ assert.match(n.brief, /never copy a site/i);++ assert.ok(!/<style|<html|class=/.test(n.brief), 'the brief is words, never a site\'s markup');++ assert.equal(n.proposedRules.length, 2);++ assert.match(n.proposedRules[0].rule, /pressed state|48 px/);++ assert.match(findsFor(report, 'button')[0].title, /button/i);++ assert.equal(findsFor(report, 'carousel').length, 0);++ });++++ await check('the composer offers Research first with its depth and price; a guest is told to sign in', async () => {++ const guest = await t.get('/create');++ assert.equal(guest.status, 200);++ assert.match(guest.text, /Research first/);++ assert.match(guest.text, /Sign in to start a deep dive/);++ const page = await t.get('/create', { as: owner });++ assert.equal(page.status, 200);++ assert.match(page.text, /Research first/);++ assert.match(page.text, /Thorough/);++ assert.match(page.text, /\$0\.65|65¢/);++ assert.match(page.text, /name="research"/);++ });++++ await check('the estimate prices the dive exactly as the run will use it', async () => {++ const r = await t.get('/api/create/estimate', { as: owner, origin: 'https://openvibe.design', json: { prompt: 'A phone-first robot control panel', kind: 'page', research: { depth: 'quick' } } });++ assert.equal(r.status, 200, r.text);++ const body = r.json();++ assert.equal(body.research.depth, 'quick');++ assert.equal(body.research.price_usd, 0.25);++ assert.equal(body.total_usd, 0.25);++ assert.equal(body.priced, true);++ });++++ await check('a create starts one dive on the person\'s behalf, and its report becomes the brief', async () => {++ const r = await t.get('/api/create', { as: owner, origin: 'https://openvibe.design', json: { prompt: 'A phone-first robot control panel', kind: 'page', research: { depth: 'quick' } } });++ assert.equal(r.status, 201, r.text);++ researchId = r.json().design.researchId;++ assert.ok(researchId, 'the design records its dive');++ assert.equal(dive.state.deepDives.size, 1);++ const [run] = [...dive.state.deepDives.values()];++ assert.deepEqual(run.run.on_behalf_of, { type: 'user', id: owner.subject });++ assert.equal(run.run.input.depth, 'quick');++ assert.equal(run.run.input.question, 'A phone-first robot control panel');++++ let data;++ for (let i = 0; i < 120; i++) {++ const got = await t.get(`/api/v1/designs/${r.json().design.id}`, { as: owner });++ data = got.json();++ if (data.design.status === 'done' && data.design.research && data.design.research.status === 'done') break;++ await pause(20);++ }++ assert.equal(data.design.research.status, 'done', JSON.stringify(data.design.research));++ assert.ok(data.design.research.pointsCount >= 9, `points ${data.design.research.pointsCount}`);++ assert.equal(data.variants.filter((v) => v.status === 'done').length, 3);++ for (const v of data.variants) {++ assert.ok(Array.isArray(v.research_points) && v.research_points.length, 'each take says which points it follows');++ }++ assert.ok(state.prompts.length >= 3);++ assert.match(state.prompts[0], /Research first: A phone-first robot control panel/);++ assert.match(state.prompts[0], /This take follows research point/);++ assert.match(state.prompts[0], /Never copy a site/);++ const row = await t.store.db.maybe('SELECT * FROM design_research WHERE id=$1', [researchId]);++ assert.equal(row.status, 'done');++ assert.ok(row.report_url.startsWith('https://openvibe.exchange/deep-dive/'));++ assert.equal((await t.store.db.many('SELECT * FROM rule_proposals WHERE research_id=$1', [researchId])).length, 2);++ });++++ await check('the canvas names the dive and links every take to its points', async () => {++ const design = (await t.store.db.many('SELECT id FROM designs WHERE research_id=$1', [researchId]))[0];++ const page = await t.get(`/d/${design.id}`, { as: owner });++ assert.equal(page.status, 200);++ assert.match(page.text, /Research first · its report/);++ assert.match(page.text, /A phone-first robot control panel/);++ assert.match(page.text, /Follows .*research point/);++ assert.match(page.text, new RegExp(`/research/${researchId}`));++ });++++ await check('the report page is the owner\'s, with numbered evidence', async () => {++ const page = await t.get(`/research/${researchId}`, { as: owner });++ assert.equal(page.status, 200);++ assert.match(page.text, /The research points/);++ assert.match(page.text, /Stop always one press away/);++ assert.match(page.text, /acme\.example/);++ assert.match(page.text, /read 11 Nov 2026/);++ assert.match(page.text, /Proposed from research/);++ assert.equal((await t.get(`/research/${researchId}`, { as: kim })).status, 404);++ assert.equal((await t.get(`/research/${researchId}`)).status, 303);++ });++++ await check('a crop is proxied from the dive, owner only, never a public Media address', async () => {++ const png = await t.get(`/research/${researchId}/crop/crop_acme`, { as: owner });++ assert.equal(png.status, 200, png.text);++ assert.equal(png.headers.get('content-type'), 'image/png');++ assert.equal(png.headers.get('x-content-type-options'), 'nosniff');++ assert.equal((await t.get(`/research/${researchId}/crop/crop_acme`, { as: kim })).status, 404);++ assert.equal((await t.get(`/research/${researchId}/crop/crop_acme`)).status, 401);++ });++++ await check('a component page gains How others do it only when a finished dive has a finding', async () => {++ const before = await t.get('/components/button');++ assert.equal(before.status, 200);++ // The report is already finished by now, so the section is there; the empty case is shown by a component++ // no point names (the rule is that the section is absent, not an empty heading).++ const page = await t.get('/components/button', { as: owner });++ assert.equal(page.status, 200);++ assert.match(page.text, /How others do it/);++ assert.match(page.text, /acme\.example/);++ assert.match(page.text, /read 11 Nov 2026/);++ const other = await t.get('/components/table', { as: owner });++ assert.doesNotMatch(other.text, /How others do it/);++ const someoneElse = await t.get('/components/button', { as: kim });++ assert.doesNotMatch(someoneElse.text, /How others do it/);++ });++++ await check('Proposed from research is the owner\'s alone', async () => {++ const ownerPage = await t.get('/rules', { as: owner });++ assert.equal(ownerPage.status, 200);++ assert.match(ownerPage.text, /Proposed from research/);++ assert.match(ownerPage.text, /pressed state|48 px/);++ const other = await t.get('/rules', { as: kim });++ assert.equal(other.status, 200);++ assert.doesNotMatch(other.text, /Proposed from research/);++ });++++ await check('only the owner approves, and an approval opens a pull request, never a register write', async () => {++ const proposals = (await t.store.db.many('SELECT * FROM rule_proposals WHERE research_id=$1 ORDER BY created_at', [researchId]));++ assert.equal(proposals.length, 2);++ const forbidden = await t.get(`/api/v1/rules/proposals/${proposals[0].id}/approve`, { as: kim, origin: 'https://openvibe.design', json: {} });++ assert.equal(forbidden.status, 403);++ const approved = await t.get(`/api/v1/rules/proposals/${proposals[0].id}/approve`, { as: owner, origin: 'https://openvibe.design', json: {} });++ assert.equal(approved.status, 200, approved.text);++ assert.equal(approved.json().proposal.state, 'approved');++ assert.match(approved.json().proposal.prUrl, /\/pull\/1$/);++ assert.equal(rulePr.calls.length, 1);++ const dismissed = await t.get(`/api/v1/rules/proposals/${proposals[1].id}/dismiss`, { as: owner, origin: 'https://openvibe.design', json: {} });++ assert.equal(dismissed.status, 200);++ assert.equal(dismissed.json().proposal.state, 'dismissed');++ const register = require('fs').readFileSync(require('path').join(__dirname, '..', 'server', 'rules', 'register.js'), 'utf8');++ assert.doesNotMatch(register, /pressed state is visible|48 px/);++ });++++ await check('an approval builds the register change on a branch and opens a pull request, never main', async () => {++ const fs = require('fs'), path = require('path');++ const { createRulePr, addRule } = require('../server/deepdive/github');++ const source = fs.readFileSync(path.join(__dirname, '..', 'server', 'rules', 'register.js'), 'utf8');++ const calls = [];++ const fakeFetch = async (url, opts = {}) => {++ calls.push({ url, method: opts.method || 'GET', body: opts.body ? JSON.parse(opts.body) : null });++ if (url.endsWith('/repos/OpenVibers/OpenVibe.Design')) return { ok: true, status: 200, json: async () => ({ default_branch: 'main' }) };++ if (url.includes('/git/ref/heads/main')) return { ok: true, status: 200, json: async () => ({ object: { sha: 'a'.repeat(40) } }) };++ if (url.includes('/contents/server/rules/register.js')) return { ok: true, status: 200, json: async () => ({ sha: 'file-sha', content: Buffer.from(source, 'utf8').toString('base64') }) };++ if (url.endsWith('/git/refs')) return { ok: true, status: 201, json: async () => ({ ref: 'refs/heads/rules/research-rpp-test' }) };++ if (url.includes('/contents/server/rules/register.js')) return { ok: true, status: 200, json: async () => ({ content: { sha: 'new-sha' } }) };++ if (url.endsWith('/pulls')) return { ok: true, status: 201, json: async () => ({ number: 9, html_url: 'https://github.com/OpenVibers/OpenVibe.Design/pull/9' }) };++ return { ok: false, status: 404, json: async () => ({ message: 'no' }) };++ };++ const rulePr = createRulePr({ config: { deepDive: { githubToken: 'test-token', repo: 'OpenVibers/OpenVibe.Design', registerPath: 'server/rules/register.js', branchPrefix: 'rules/research' } }, fetchImpl: fakeFetch });++ const out = await rulePr.open({ proposal: { id: 'rpp_test', rule: 'A test rule stays one sentence.', detail: 'The detail.', why: 'Because.', area: 'data' }, reportUrl: 'https://openvibe.exchange/deep-dive/run_x', decidedBy: 'goosely' });++ assert.equal(out.url, 'https://github.com/OpenVibers/OpenVibe.Design/pull/9');++ const put = calls.find((c) => c.method === 'PUT');++ assert.ok(put, 'the file is updated on the branch');++ assert.equal(put.body.branch, 'rules/research-rpp-test');++ const written = Buffer.from(put.body.content, 'base64').toString('utf8');++ assert.match(written, /R\('research-rpp-test', 'data', 'A test rule stays one sentence\.'/);++ assert.match(written, /read the deep dive|Read the deep dive/);++ const pull = calls.find((c) => c.url.endsWith('/pulls'));++ assert.equal(pull.body.head, 'rules/research-rpp-test');++ assert.equal(pull.body.base, 'main');++ assert.match(pull.body.body, /https:\/\/openvibe\.exchange\/deep-dive\/run_x/);++ assert.doesNotMatch(pull.body.body, /Generated with|Co-Authored|Claude-Session/);++ const twice = addRule(written, { rule: 'A second rule.', detail: '', why: '' }, { id: 'research-second', area: 'data', decided: '2026-11-11' });++ assert.match(twice, /R\('research-second'/);++ assert.throws(() => addRule(written, { rule: 'x' }, { id: 'research-rpp-test', area: 'data', decided: '2026-11-11' }), /already in the register/);++ });++++ await check('a dive the person already ran attaches without a second run or charge', async () => {++ const before = dive.state.deepDives.size;++ const estimate = await t.get('/api/create/estimate', { as: owner, origin: 'https://openvibe.design', json: { prompt: 'Another panel', kind: 'page', research: { id: researchId } } });++ assert.equal(estimate.status, 200, estimate.text);++ assert.equal(estimate.json().research.price_usd, 0);++ const r = await t.get('/api/create', { as: owner, origin: 'https://openvibe.design', json: { prompt: 'Another panel', kind: 'page', research: { id: researchId } } });++ assert.equal(r.status, 201, r.text);++ assert.equal(r.json().design.researchId, researchId);++ assert.equal(dive.state.deepDives.size, before);++ assert.equal((await t.store.db.many('SELECT * FROM design_research WHERE owner=$1', [owner.subject])).length, 1);++ });++++ await check('the JSON API offers the depths, the recent dives and a running dive', async () => {++ const options = await t.get('/api/deep-dive/options', { as: owner });++ assert.equal(options.status, 200);++ assert.equal(options.json().depths.length, 3);++ assert.ok(options.json().recent.some((x) => x.id === researchId));++ const one = await t.get(`/api/deep-dive/${researchId}`, { as: owner });++ assert.equal(one.status, 200);++ assert.equal(one.json().deep_dive.status, 'done');++ assert.equal((await t.get(`/api/deep-dive/${researchId}`, { as: kim })).status, 403);++ });++++ done();++ } finally {++ await t.close();++ await dive.close();++ }++})().catch((err) => { console.error(err); process.exitCode = 1; });@@@ -45,13 +45,15 @@ function listen(handler) {});}--async function startAi({ models = DEFAULT_MODELS, htmlFor = null, costUsd = 0.0012, ms = 321, tokensIn = 120, tokensOut = 480, failModels = [], asyncMode = false, routesStatus = 200 } = {}) {++async function startAi({ models = DEFAULT_MODELS, htmlFor = null, costUsd = 0.0012, ms = 321, tokensIn = 120, tokensOut = 480, failModels = [], asyncMode = false, routesStatus = 200, deepDive = null } = {}) {const st = {models: models.map((m) => ({ ...m })),requests: [], routeRequests: 0, lastAuth: null, tokenClaims: null,costUsd, ms, tokensIn, tokensOut,failModels: new Set(failModels), asyncMode, routesStatus,delayMs: 0, runCount: 0, runs: new Map(), htmlFor: null,++ deepDiveReports: [], deepDives: new Map(), deepDiveOptions: deepDive && deepDive.options ? deepDive.options : null, deepDiveReport: deepDive && deepDive.report ? deepDive.report : null,++ deepDiveStatus: deepDive && deepDive.status ? deepDive.status : 'succeeded', deepDiveReads: 0,};function routeFor(body) {@@@ -67,7 +69,7 @@ async function startAi({ models = DEFAULT_MODELS, htmlFor = null, costUsd = 0.00return st.models[i] || { provider: 'stub', model: 'stub-1' };}-- const srv = await listen(async (req, raw, json) => {++ const srv = await listen(async (req, raw, json, res) => {const url = new URL(req.url, 'http://x');const text = raw.toString('utf8');let body = {};@@@ -81,6 +83,24 @@ async function startAi({ models = DEFAULT_MODELS, htmlFor = null, costUsd = 0.00}if (st.delayMs) await new Promise((r) => setTimeout(r, st.delayMs));++ // A deep dive (workflow design.deep_dive): the run API when the client asks there, and the feature's own++ // door as a second path. The report is the run's output; a test can make the first read still running.++ if ((url.pathname === '/api/v1/runs' || url.pathname === '/api/v1/deep-dives') && req.method === 'POST' &&++ (body.workflow === 'design.deep_dive' || url.pathname === '/api/v1/deep-dives')) {++ st.requests.push({ method: 'POST', path: url.pathname, query: Object.fromEntries(url.searchParams), body, claims });++ const id = `run_dd_${crypto.randomBytes(6).toString('hex')}`;++ const input = body.input || body;++ const report = typeof st.deepDiveOptions === 'function' ? st.deepDiveOptions(input) : st.deepDiveReport;++ const run = { id, status: 'running', workflow: { key: 'design.deep_dive', version: 1 }, input,++ on_behalf_of: body.on_behalf_of || null, target: body.target || null, output: null, created_at: new Date().toISOString() };++ st.deepDives.set(id, { run, report });++ st.deepDiveReports.push(report);++ return json(202, { run });++ }++ if (url.pathname === '/api/v1/deep-dives/options' && req.method === 'GET') {++ const options = st.deepDiveOptions && st.deepDiveOptions.depths ? st.deepDiveOptions : { depths: [] };++ return json(200, options);++ }if (url.pathname === '/api/v1/routes' && req.method === 'GET') {st.routeRequests++;if (st.routesStatus !== 200) return json(st.routesStatus, { code: 'route.unavailable', detail: 'routes are down' });@@@ -126,10 +146,31 @@ async function startAi({ models = DEFAULT_MODELS, htmlFor = null, costUsd = 0.00return json(201, { run });}++ const shot = /^\/api\/v1\/deep-dives\/([^/]+)\/shots\/([^/]+)$/.exec(url.pathname);++ if (shot && req.method === 'GET') {++ st.requests.push({ method: 'GET', path: url.pathname, claims });++ const png = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9Zl1sAAAAASUVORK5CYII=', 'base64');++ st.lastShot = { path: url.pathname, status: 200 };++ res.statusCode = 200;++ res.setHeader('Content-Type', 'image/png');++ res.setHeader('Cache-Control', 'private, no-store');++ res.end(png);++ return;++ }const one = /^\/api\/v1\/runs\/([^/]+)$/.exec(url.pathname);if (one && req.method === 'GET') {st.requests.push({ method: 'GET', path: url.pathname, claims });-- const run = st.runs.get(decodeURIComponent(one[1]));++ const id = decodeURIComponent(one[1]);++ const dive = st.deepDives.get(id);++ if (dive) {++ st.deepDiveReads++;++ const done = st.deepDiveStatus === 'succeeded' || st.deepDiveReads > 1;++ const run = done++ ? { ...dive.run, status: st.deepDiveStatus, output: dive.report, usage: { cost_usd: 0.25, tokens_in: 100, tokens_out: 200 } }++ : dive.run;++ return json(200, { run });++ }++ const run = st.runs.get(id);if (!run) return json(404, { code: 'run.not_found' });return json(200, { run });}@@@ -29,6 +29,8 @@ async function boot(opts = {}) {DESIGN_AI_POLL_MS: '10',DESIGN_AI_WAIT_MS: '5000',DESIGN_ARENA_JOB_INTERVAL_MS: '100000',++ DESIGN_DEEP_DIVE_POLL_MS: '10',++ ...(opts.ai && opts.ai.url ? { DESIGN_DEEP_DIVE_URL: opts.ai.url } : {}),...(opts.env || {}),};for (const [k, v] of Object.entries(opts.env || {})) if (v === null) delete env[k];@@@ -47,7 +49,8 @@ async function boot(opts = {}) {const config = configLib.load(env);const testdb = opts.store ? null : await require('./db').testDb({ store: opts.testStore });const store = opts.store || createStore(testdb.db, { now: opts.now });-- const built = await createApp({ config, store, log, now: opts.now, accountSend: opts.accountSend, random: opts.random, ai: opts.ai, publisher: opts.publisher, balance: opts.balance, createOptions: opts.createOptions });++ const built = await createApp({ config, store, log, now: opts.now, accountSend: opts.accountSend, random: opts.random, ai: opts.ai, publisher: opts.publisher, balance: opts.balance, createOptions: opts.createOptions,++ deepDive: opts.deepDive, deepDiveStore: opts.deepDiveStore, deepDiveAi: opts.deepDiveAi, proposals: opts.proposals, rulePr: opts.rulePr });await built.ctx.keys.ensure();const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });const base = `http://127.0.0.1:${server.address().port}`;
new file mode 100644
new file mode 100644
@@ -0,0 +1,52 @@
@@ -0,0 +1,52 @@
+-- phase: expand
+-- "Research first" (owner ask, 2026-11-11): a design can be drawn from a deep dive. The dive is a durable
+-- OpenVibe.AI run (workflow design.deep_dive); this table keeps the run's link, its report's numbered points,
+-- the brief the generator reads, and the person's own view of it. The design points at it with a plain text id;
+-- no foreign key, so a report kept here never blocks a design or a run.
+CREATE TABLE IF NOT EXISTS design_research (
+ id text COLLATE "C" PRIMARY KEY,
+ owner text COLLATE "C" NOT NULL,
+ question text NOT NULL,
+ depth text COLLATE "C" NOT NULL DEFAULT 'thorough' CHECK (depth IN ('quick','thorough','expert')),
+ sites jsonb NOT NULL DEFAULT '[]'::jsonb,
+ run_id text COLLATE "C",
+ status text COLLATE "C" NOT NULL DEFAULT 'starting'
+ CHECK (status IN ('starting','running','done','failed','cancelled')),
+ progress jsonb NOT NULL DEFAULT '{}'::jsonb,
+ report jsonb,
+ points jsonb NOT NULL DEFAULT '[]'::jsonb,
+ brief text,
+ report_url text,
+ estimate jsonb,
+ error text,
+ created_at timestamptz NOT NULL,
+ updated_at timestamptz NOT NULL
+);
+CREATE INDEX IF NOT EXISTS design_research_owner_recent ON design_research (owner, created_at DESC, id);
+CREATE INDEX IF NOT EXISTS design_research_run ON design_research (run_id) WHERE run_id IS NOT NULL;
+
+-- A rule a report proposes. Nothing enters server/rules/register.js by itself: the owner reads the evidence on
+-- /rules and approves or dismisses. Approval opens a pull request against the register; dismissal keeps the row
+-- so the same proposal is not offered again.
+CREATE TABLE IF NOT EXISTS rule_proposals (
+ id text COLLATE "C" PRIMARY KEY,
+ research_id text COLLATE "C" NOT NULL,
+ owner text COLLATE "C" NOT NULL,
+ rule text NOT NULL,
+ detail text NOT NULL DEFAULT '',
+ why text NOT NULL DEFAULT '',
+ area text COLLATE "C" NOT NULL DEFAULT 'products',
+ evidence jsonb NOT NULL DEFAULT '[]'::jsonb,
+ state text COLLATE "C" NOT NULL DEFAULT 'proposed' CHECK (state IN ('proposed','approved','dismissed')),
+ pr_url text,
+ pr_branch text,
+ pr_error text,
+ decided_by text COLLATE "C",
+ decided_at timestamptz,
+ created_at timestamptz NOT NULL,
+ updated_at timestamptz NOT NULL
+);
+CREATE INDEX IF NOT EXISTS rule_proposals_state ON rule_proposals (state, created_at DESC);
+CREATE INDEX IF NOT EXISTS rule_proposals_research ON rule_proposals (research_id);
+
+ALTER TABLE designs ADD COLUMN IF NOT EXISTS research_id text COLLATE "C";
new file mode 100644
new file mode 100644
@@ -0,0 +1,44 @@
@@ -0,0 +1,44 @@
+/* Research first (2026-11-11): the plain, usable parts of a deep dive. The look is the main session's; this
+ only makes the composer's module, one report and a component's findings readable at 390 and 1280. */
+.dd-page { max-width: 74rem; margin: 0 auto; padding: 1.5rem 1rem 4rem; }
+.dd-status { color: var(--text-secondary); font-size: .9rem; margin: .4rem 0 1rem; }
+.dd-notice { padding: .6rem .8rem; border: 1px solid var(--border); border-radius: 10px; background: color-mix(in srgb, var(--accent) 8%, transparent); }
+.dd-notice.is-error { border-color: color-mix(in srgb, #e5484d 50%, var(--border)); }
+.dd-steps { display: flex; flex-wrap: wrap; gap: .4rem; list-style: none; padding: 0; margin: .8rem 0 1.2rem; }
+.dd-step { padding: .3rem .65rem; border: 1px solid var(--border); border-radius: 999px; font-size: .82rem; color: var(--text-secondary); }
+.dd-step.is-done { border-color: color-mix(in srgb, var(--accent) 55%, var(--border)); color: var(--text-primary); }
+.dd-step.is-running { border-color: var(--accent); color: var(--text-primary); font-weight: 650; }
+.dd-step.is-failed { border-color: #e5484d; }
+.dd-sec { margin: 2rem 0; }
+.dd-points { list-style: none; padding: 0; margin: 0; display: grid; gap: .5rem; }
+.dd-points--page > li { display: flex; gap: .6rem; border-top: 1px solid var(--border); padding-top: .55rem; }
+.dd-points--page p { margin: 0 0 .3rem; }
+.dd-n { flex: none; display: inline-flex; align-items: center; justify-content: center; min-width: 1.55rem; height: 1.55rem; border-radius: 50%; background: color-mix(in srgb, var(--accent) 18%, transparent); font-size: .76rem; font-weight: 700; }
+.dd-evidence { list-style: none; padding: 0; margin: .3rem 0; display: grid; gap: .3rem; }
+.dd-ev { font-size: .82rem; color: var(--text-secondary); }
+.dd-crop { display: block; max-width: min(100%, 22rem); height: auto; border: 1px solid var(--border); border-radius: 8px; margin: .3rem 0; }
+.dd-find-list { list-style: none; padding: 0; margin: 1rem 0 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr)); gap: 1rem; }
+.dd-find { display: flex; flex-direction: column; gap: .35rem; }
+.dd-find .dd-crop { max-width: 100%; }
+.dd-attrib { font-size: .8rem; color: var(--text-secondary); }
+.dd-inline { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .85em; }
+.dd-props { display: grid; gap: 1rem; }
+.dd-prop { border: 1px solid var(--border); border-radius: 12px; padding: .9rem; }
+.dd-prop h3 { margin: .2rem 0 .4rem; font-size: 1rem; }
+.dd-prop-state { font-size: .72rem; font-weight: 700; text-transform: uppercase; letter-spacing: .04em; color: var(--text-secondary); }
+.dd-prop-state.is-approved { color: #2f9e44; }
+.dd-prop-acts { display: flex; flex-wrap: wrap; gap: .5rem; margin-top: .6rem; }
+.dd-why span { font-weight: 700; color: var(--text-secondary); }
+.dd-proposed { border-top: 1px solid var(--border); }
+.dv-deep { display: grid; gap: .5rem; margin: .5rem 0; }
+.dv-research { margin: 1.2rem 0; padding: .9rem; border: 1px solid var(--border); border-radius: 14px; }
+.dv-research h2 { font-size: 1rem; margin: 0 0 .3rem; }
+.dv-research-q { color: var(--text-secondary); font-size: .86rem; margin: 0 0 .5rem; }
+.dv-take-research { font-size: .8rem; color: var(--text-secondary); margin: .3rem 0 0; }
+.dv-take-research a { color: inherit; text-decoration: underline; }
+@media (max-width: 480px) { .dd-find-list { grid-template-columns: 1fr; } .dd-page { padding-left: .8rem; padding-right: .8rem; } }
+/* The composer hides the module's word on a phone; Research first keeps it, because "Off" alone says nothing. */
+@media (max-width: 640px) {
+ .dv-mod[data-mod="research"] > summary .dv-mod-k { display: inline; }
+ .dv-mod[data-mod="research"] > summary b { min-width: 0; }
+}
@@ -7,6 +7,22 @@
@@ -7,6 +7,22 @@
const picked = (name) => (q(`input[name="${name}"]:checked`) || {}).value || '';
const picked = (name) => (q(`input[name="${name}"]:checked`) || {}).value || '';
const chosenModels = () => all('input[name="models"]:checked').map((x) => x.value);
const chosenModels = () => all('input[name="models"]:checked').map((x) => x.value);
const takesEl = q('input[name="takes"]'), cost = q('[data-dv-cost]'), goLabel = q('[data-go-label]'), go = q('.dv-go');
const takesEl = q('input[name="takes"]'), cost = q('[data-dv-cost]'), goLabel = q('[data-go-label]'), go = q('.dv-go');
+ const researchOn = () => { const tick = q('input[name="research"]'); const id = q('select[name="research_id"]'); const ref = q('input[name="research_ref"]'); return Boolean((tick && tick.checked) || (id && id.value) || (ref && ref.value.trim())); };
+ const researchValue = () => {
+ const id = q('select[name="research_id"]'), ref = q('input[name="research_ref"]');
+ if (ref && ref.value.trim()) return { ref: ref.value.trim() };
+ if (id && id.value) return { id: id.value };
+ const d = q('input[name="research_depth"]:checked');
+ return { depth: d ? d.value : 'thorough' };
+ };
+ const fitResearch = () => {
+ const on = researchOn(); const tick = q('input[name="research"]'); const id = q('select[name="research_id"]'), ref = q('input[name="research_ref"]');
+ const fresh = q('[data-research-new]');
+ if (fresh) fresh.hidden = !(tick && tick.checked) || Boolean((id && id.value) || (ref && ref.value.trim()));
+ const attached = Boolean((id && id.value) || (ref && ref.value.trim()));
+ const depth = q('input[name="research_depth"]:checked');
+ if (tick) setText('research', attached ? 'Attached dive' : on ? (depth && depth.dataset.label ? `Research: ${depth.dataset.label}` : 'Research first') : 'Off');
+ };
const setText = (mod, text) => { const v = q(`.dv-mod[data-mod="${mod}"] [data-mod-v]`); if (v) v.textContent = text; };
const setText = (mod, text) => { const v = q(`.dv-mod[data-mod="${mod}"] [data-mod-v]`); if (v) v.textContent = text; };
// What a choice in the tray shows: the name of what is chosen (and, for the look, its colours).
// What a choice in the tray shows: the name of what is chosen (and, for the look, its colours).
const show = (input) => {
const show = (input) => {
@@ -47,9 +63,10 @@
@@ -47,9 +63,10 @@
if (picked('look')) b.look = picked('look');
if (picked('look')) b.look = picked('look');
const m = chosenModels(); if (m.length) { b.models = m; b.takes = takesNow(); }
const m = chosenModels(); if (m.length) { b.models = m; b.takes = takesNow(); }
if (startUrl()) b.start = { url: startUrl() };
if (startUrl()) b.start = { url: startUrl() };
+ if (researchOn()) b.research = researchValue();
return b;
return b;
};
};
- const save = () => { try { localStorage.setItem(KEY, JSON.stringify({ kind: picked('kind'), who: picked('who'), look: picked('look'), frame: picked('frame'), models: chosenModels(), takes: takesNow() })); } catch { /* a private window */ } };
+ const save = () => { try { localStorage.setItem(KEY, JSON.stringify({ kind: picked('kind'), who: picked('who'), look: picked('look'), frame: picked('frame'), models: chosenModels(), takes: takesNow(), research: researchOn() ? researchValue() : null })); } catch { /* a private window */ } };
// What the run will cost, asked from the server as the choices settle (the same plan the run uses).
// What the run will cost, asked from the server as the choices settle (the same plan the run uses).
const usd = (n) => (n >= 0.995 ? `$${n.toFixed(2)}` : n >= 0.01 ? `${Math.round(n * 100)}¢` : n > 0 ? 'under 1¢' : 'nothing');
const usd = (n) => (n >= 0.995 ? `$${n.toFixed(2)}` : n >= 0.01 ? `${Math.round(n * 100)}¢` : n > 0 ? 'under 1¢' : 'nothing');
let costTimer = 0, costSeq = 0;
let costTimer = 0, costSeq = 0;
@@ -78,8 +95,14 @@
@@ -78,8 +95,14 @@
['who', 'look', 'frame'].forEach((name) => { const el = last[name] && q(`input[name="${name}"][value="${CSS.escape(last[name])}"]`); if (el && !(el.closest('.dv-opt') && el.closest('.dv-opt').hidden)) { el.checked = true; show(el); } });
['who', 'look', 'frame'].forEach((name) => { const el = last[name] && q(`input[name="${name}"][value="${CSS.escape(last[name])}"]`); if (el && !(el.closest('.dv-opt') && el.closest('.dv-opt').hidden)) { el.checked = true; show(el); } });
(last.models || []).forEach((id) => { const el = q(`input[name="models"][value="${CSS.escape(id)}"]`); if (el) el.checked = true; });
(last.models || []).forEach((id) => { const el = q(`input[name="models"][value="${CSS.escape(id)}"]`); if (el) el.checked = true; });
if (takesEl && last.takes) takesEl.value = String(last.takes);
if (takesEl && last.takes) takesEl.value = String(last.takes);
+ if (last.research) {
+ if (last.research.depth) { const d = q(`input[name="research_depth"][value="${CSS.escape(last.research.depth)}"]`); if (d) d.checked = true; }
+ if (last.research.id) { const sel = q('select[name="research_id"]'); if (sel) sel.value = last.research.id; }
+ if (last.research.ref) { const r = q('input[name="research_ref"]'); if (r) r.value = last.research.ref; }
+ const tick = q('input[name="research"]'); if (tick) tick.checked = !last.research.id && !last.research.ref;
+ }
} catch { fitFrames(); fitExamples(); }
} catch { fitFrames(); fitExamples(); }
- fitModels();
+ fitModels(); fitResearch();
const mods = all('.dv-mod');
const mods = all('.dv-mod');
const closeMods = (except) => mods.forEach((m) => { if (m !== except) m.open = false; });
const closeMods = (except) => mods.forEach((m) => { if (m !== except) m.open = false; });
form.addEventListener('change', (event) => {
form.addEventListener('change', (event) => {
@@ -88,6 +111,7 @@
@@ -88,6 +111,7 @@
else if (input.name === 'models' || input.name === 'takes') fitModels();
else if (input.name === 'models' || input.name === 'takes') fitModels();
else if (input.name === 'start_url') setText('start', startUrl() ? (() => { try { return new URL(startUrl()).hostname; } catch { return 'A page'; } })() : 'Blank');
else if (input.name === 'start_url') setText('start', startUrl() ? (() => { try { return new URL(startUrl()).hostname; } catch { return 'A page'; } })() : 'Blank');
else if (input.name === 'who') { all('input[name="models"]').forEach((x) => { x.checked = false; }); fitModels(); show(input); setTimeout(() => closeMods(null), 160); }
else if (input.name === 'who') { all('input[name="models"]').forEach((x) => { x.checked = false; }); fitModels(); show(input); setTimeout(() => closeMods(null), 160); }
+ else if (input.name === 'research' || input.name === 'research_depth' || input.name === 'research_id' || input.name === 'research_ref') { fitResearch(); }
else if (input.closest('.dv-mod')) { show(input); setTimeout(() => closeMods(null), 160); }
else if (input.closest('.dv-mod')) { show(input); setTimeout(() => closeMods(null), 160); }
save(); askCost();
save(); askCost();
});
});
@@ -96,6 +120,8 @@
@@ -96,6 +120,8 @@
takesEl.value = String(takesNow() + Number(step.dataset.step)); fitModels(); save(); askCost();
takesEl.value = String(takesNow() + Number(step.dataset.step)); fitModels(); save(); askCost();
});
});
prompt.addEventListener('blur', askCost);
prompt.addEventListener('blur', askCost);
+ const researchRef = q('input[name="research_ref"]');
+ if (researchRef) researchRef.addEventListener('input', () => { fitResearch(); save(); askCost(); });
mods.forEach((m) => m.addEventListener('toggle', () => { if (m.open) closeMods(m); }));
mods.forEach((m) => m.addEventListener('toggle', () => { if (m.open) closeMods(m); }));
document.addEventListener('click', (event) => { const open = mods.find((m) => m.open); if (open && (!open.contains(event.target) || event.target === open)) closeMods(null); });
document.addEventListener('click', (event) => { const open = mods.find((m) => m.open); if (open && (!open.contains(event.target) || event.target === open)) closeMods(null); });
document.addEventListener('keydown', (event) => { if (event.key === 'Escape') { const open = mods.find((m) => m.open); if (open) { closeMods(null); open.querySelector('summary').focus(); } } });
document.addEventListener('keydown', (event) => { if (event.key === 'Escape') { const open = mods.find((m) => m.open); if (open) { closeMods(null); open.querySelector('summary').focus(); } } });
@@ -307,12 +333,18 @@
@@ -307,12 +333,18 @@
if ([...canvas.querySelectorAll('.dv-card')].every(card => ['done', 'failed'].includes(card.dataset.status))) return;
if ([...canvas.querySelectorAll('.dv-card')].every(card => ['done', 'failed'].includes(card.dataset.status))) return;
const url = `/api/v1/designs/${encodeURIComponent(designId)}${designKey ? `?key=${encodeURIComponent(designKey)}` : ''}`;
const url = `/api/v1/designs/${encodeURIComponent(designId)}${designKey ? `?key=${encodeURIComponent(designKey)}` : ''}`;
const initial = [...canvas.querySelectorAll('.dv-card')].map(card => card.dataset.status).join(',');
const initial = [...canvas.querySelectorAll('.dv-card')].map(card => card.dataset.status).join(',');
+ const researchState = () => {
+ const box = canvas.querySelector('[data-dd-state]');
+ return box ? box.dataset.ddState : '';
+ };
+ const stateOf = (research) => research ? `${research.status}|${((research.progress && research.progress.steps) || []).map((s) => s.state).join(',')}` : '';
+ const initialResearch = researchState();
const timer = setInterval(async () => {
const timer = setInterval(async () => {
try {
try {
const response = await fetch(url, { cache: 'no-store' });
const response = await fetch(url, { cache: 'no-store' });
if (!response.ok) return;
if (!response.ok) return;
const data = await response.json();
const data = await response.json();
- if (data.variants.map(v => v.status).join(',') !== initial) {
+ if (data.variants.map(v => v.status).join(',') !== initial || stateOf(data.design && data.design.research) !== initialResearch) {
clearInterval(timer);
clearInterval(timer);
location.reload();
location.reload();
}
}
@@ -40,6 +40,12 @@ const { createMcpRoutes } = require('./http/mcp');
@@ -40,6 +40,12 @@ const { createMcpRoutes } = require('./http/mcp');
const { createArenaApi } = require('./http/arena-api');
const { createArenaApi } = require('./http/arena-api');
const { createDesignApi } = require('./http/design-api');
const { createDesignApi } = require('./http/design-api');
const { createDesigns } = require('./designs');
const { createDesigns } = require('./designs');
+const { createDeepDiveAi } = require('./deepdive/ai');
+const { createDeepDiveStore } = require('./deepdive/store');
+const { createDeepDiveService } = require('./deepdive/service');
+const { createDeepDiveApi } = require('./deepdive/api');
+const { createRuleProposals } = require('./deepdive/proposals');
+const { createRulePr } = require('./deepdive/github');
const { createCreateOptions } = require('./create/options');
const { createCreateOptions } = require('./create/options');
const { createCreateApi } = require('./create/api');
const { createCreateApi } = require('./create/api');
const { createBalanceReader } = require('./create/billing');
const { createBalanceReader } = require('./create/billing');
@@ -102,7 +108,14 @@ async function createApp(opts = {}) {
@@ -102,7 +108,14 @@ async function createApp(opts = {}) {
// Sharing (openvibe-sdk/share): the shares row next to each signed-in maker's design, the one access
// Sharing (openvibe-sdk/share): the shares row next to each signed-in maker's design, the one access
// check every read path asks, and the uniform API below. Guest designs keep their secret edit key.
// check every read path asks, and the uniform API below. Guest designs keep their secret edit key.
const sharing = opts.sharing || createSharing({ db: s.db, config, log, now });
const sharing = opts.sharing || createSharing({ db: s.db, config, log, now });
- const designs = opts.designs || createDesigns({ s, ai, bench, catalog, config, log, now, sharing });
+ // "Research first": the dives Design follows, their reports' points, and the rules they propose. This is
+ // created before the designs because a design's takes wait for its dive's brief.
+ const deepDiveStore = opts.deepDiveStore || createDeepDiveStore({ s, log });
+ const deepDiveAi = opts.deepDiveAi || createDeepDiveAi({ config, fetchImpl, log });
+ const deepDive = opts.deepDive || createDeepDiveService({ s, config, fetchImpl, log, store: deepDiveStore, ai: deepDiveAi, now });
+ const rulePr = opts.rulePr || createRulePr({ config, fetchImpl, log });
+ const proposals = opts.proposals || createRuleProposals({ store: deepDiveStore, rulePr, config, log });
+ const designs = opts.designs || createDesigns({ s, ai, bench, catalog, config, log, now, sharing, deepDive });
// The create page's options, its estimate and its person's Balance, read from the services that own them
// The create page's options, its estimate and its person's Balance, read from the services that own them
// (AI's Exchange catalogue and choices, Network's themes, Billing's Balance), never a second hand-kept list.
// (AI's Exchange catalogue and choices, Network's themes, Billing's Balance), never a second hand-kept list.
const balance = opts.balance || createBalanceReader({ config, fetchImpl, log });
const balance = opts.balance || createBalanceReader({ config, fetchImpl, log });
@@ -122,7 +135,7 @@ async function createApp(opts = {}) {
@@ -122,7 +135,7 @@ async function createApp(opts = {}) {
: async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Design cannot answer account events'); });
: async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Design cannot answer account events'); });
const ctx = {
const ctx = {
- config, s, store: s, keys, sso, settings, catalog, searchIndex, arena, bench, ai, jobs, designs, sharing, publisher, accountData, accountSend, log, picks, fonts, aiStore, maker, registry, createOptions, balance,
+ config, s, store: s, keys, sso, settings, catalog, searchIndex, arena, bench, ai, jobs, designs, sharing, publisher, accountData, accountSend, log, picks, fonts, aiStore, maker, registry, createOptions, balance, deepDive, deepDiveStore, deepDiveAi, proposals, rulePr,
random: opts.random || Math.random,
random: opts.random || Math.random,
};
};
@@ -295,6 +308,7 @@ async function createApp(opts = {}) {
@@ -295,6 +308,7 @@ async function createApp(opts = {}) {
// ── API: the Arena, the glyphs (with their pages), then the theme proxy ──
// ── API: the Arena, the glyphs (with their pages), then the theme proxy ──
app.use('/api/v1/designs', express.json({ limit: '256kb' }), createDesignApi(ctx));
app.use('/api/v1/designs', express.json({ limit: '256kb' }), createDesignApi(ctx));
app.use('/api', express.json({ limit: '64kb' }));
app.use('/api', express.json({ limit: '64kb' }));
+ app.use('/api', createDeepDiveApi(ctx));
app.use('/api/create', createCreateApi(ctx));
app.use('/api/create', createCreateApi(ctx));
app.use('/api/v1/arena', createArenaApi(ctx));
app.use('/api/v1/arena', createArenaApi(ctx));
app.use('/api/v1/brand', require('./http/brand-api').createBrandApi(ctx));
app.use('/api/v1/brand', require('./http/brand-api').createBrandApi(ctx));
@@ -334,7 +348,7 @@ async function createApp(opts = {}) {
@@ -334,7 +348,7 @@ async function createApp(opts = {}) {
app.get('/openapi.json', require('./http/openapi-routes').openapiRoute(app, {
app.get('/openapi.json', require('./http/openapi-routes').openapiRoute(app, {
title: `${d.name} API`, description: d.purpose, version: require('../package.json').version, baseUrl: site,
title: `${d.name} API`, description: d.purpose, version: require('../package.json').version, baseUrl: site,
docs: `${site}/developers`,
docs: `${site}/developers`,
- files: ['http/api.js', 'http/arena-api.js', 'http/brand-api.js', 'http/design-api.js', 'http/fonts.js'].map((f) => require('path').join(__dirname, f)),
+ files: ['http/api.js', 'http/arena-api.js', 'http/brand-api.js', 'http/design-api.js', 'http/fonts.js', 'deepdive/api.js'].map((f) => require('path').join(__dirname, f)),
}));
}));
}
}
app.use('/api', (req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', {
app.use('/api', (req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', {
@@ -64,6 +64,10 @@ function load(env = process.env) {
@@ -64,6 +64,10 @@ function load(env = process.env) {
maxHtmlBytes: 200 * 1024,
maxHtmlBytes: 200 * 1024,
},
},
+ // Who alone may decide a rule a report proposes (the Network owner account). A username, as Network's own
+ // owner guard uses, with the owner role as the other accepted claim.
+ ownerUsername: String(env.DESIGN_OWNER_USERNAME || env.OWNER_USERNAME || 'goosely'),
+
// OpenVibe.Exchange's icon reference search (the Exchange platform API at api.openvibe.exchange, served by
// OpenVibe.Exchange's icon reference search (the Exchange platform API at api.openvibe.exchange, served by
// OpenVibe.AI): Design mints its own client-credentials token (audience openvibe.ai, scope
// OpenVibe.AI): Design mints its own client-credentials token (audience openvibe.ai, scope
// exchange.search.icons) and needs no Exchange key. EXCHANGE_SEARCH_KEY is the owner's override.
// exchange.search.icons) and needs no Exchange key. EXCHANGE_SEARCH_KEY is the owner's override.
@@ -75,6 +79,35 @@ function load(env = process.env) {
@@ -75,6 +79,35 @@ function load(env = process.env) {
timeoutMs: Math.max(1000, int(env.DESIGN_EXCHANGE_TIMEOUT_MS, 5000)),
timeoutMs: Math.max(1000, int(env.DESIGN_EXCHANGE_TIMEOUT_MS, 5000)),
},
},
+ // A deep dive (contract ai.deep-dive@1): an AI run on the person's behalf, priced per depth before it starts.
+ // The workflow and the prices are AI's; DESIGN_DEEP_DIVE_PRICES is only the fallback for a deployment whose
+ // options endpoint does not answer yet (quick=0.2,thorough=0.5,expert=1.2).
+ deepDive: {
+ url: trim(env.DESIGN_DEEP_DIVE_URL || env.OV_AI_INTERNAL_URL || aiUrl),
+ workflow: env.DESIGN_DEEP_DIVE_WORKFLOW || 'design.deep_dive',
+ optionsPath: env.DESIGN_DEEP_DIVE_OPTIONS_PATH || '',
+ startPath: env.DESIGN_DEEP_DIVE_START_PATH || '',
+ deepDivesPath: env.DESIGN_DEEP_DIVE_START_PATH ? '' : '',
+ prices: env.DESIGN_DEEP_DIVE_PRICES || 'quick=0.2,thorough=0.5,expert=1.2',
+ reportPath: env.DESIGN_DEEP_DIVE_REPORT_PATH || '/deep-dive/:id',
+ cropPath: env.DESIGN_DEEP_DIVE_CROP_PATH || '/api/v1/deep-dives/:runId/shots/:cropId',
+ publicReportBase: trim(env.OV_EXCHANGE_URL || 'https://openvibe.exchange'),
+ exchangeUrl: trim(env.OV_EXCHANGE_INTERNAL_URL || 'http://127.0.0.1:5150'),
+ timeoutMs: Math.max(1000, int(env.DESIGN_DEEP_DIVE_TIMEOUT_MS, 30_000)),
+ pollMs: Math.max(200, int(env.DESIGN_DEEP_DIVE_POLL_MS, 4000)),
+ waitMs: Math.max(0, int(env.DESIGN_DEEP_DIVE_WAIT_MS, 0)),
+ maxWaitMs: Math.max(30_000, int(env.DESIGN_DEEP_DIVE_MAX_WAIT_MS, 15 * 60_000)),
+ // The owner approves a proposed rule on /rules; the approval opens a pull request on the register with
+ // this token. Without one the approval is recorded and the page says a PR needs the token: nothing is
+ // added to the register by itself.
+ githubToken: env.DESIGN_RULES_GITHUB_TOKEN || '',
+ repo: env.DESIGN_RULES_REPO || 'OpenVibers/OpenVibe.Design',
+ registerPath: env.DESIGN_RULES_PATH || 'server/rules/register.js',
+ branchPrefix: env.DESIGN_RULES_BRANCH_PREFIX || 'rules/research',
+ ownerUsername: String(env.DESIGN_OWNER_USERNAME || env.OWNER_USERNAME || 'goosely'),
+
+ },
+
// OpenVibe.Media: a starting picture is uploaded there by the page and the create request carries its id;
// OpenVibe.Media: a starting picture is uploaded there by the page and the create request carries its id;
// Design names its public file address in the prompt and never takes raw image bytes.
// Design names its public file address in the prompt and never takes raw image bytes.
media: {
media: {
@@ -65,7 +65,7 @@ function createCreateApi(ctx) {
@@ -65,7 +65,7 @@ function createCreateApi(ctx) {
routes.post('/estimate', sameOrigin, safe(async (req, res) => {
routes.post('/estimate', sameOrigin, safe(async (req, res) => {
const all = await ctx.createOptions.options({ token: token(req) });
const all = await ctx.createOptions.options({ token: token(req) });
const plan = await resolvePlan(req.body || {}, {
const plan = await resolvePlan(req.body || {}, {
- options: all, designs: ctx.designs, ai: ctx.ai, owner: owner(req), key: requestKey(req), viewer: viewer(req), config: ctx.config,
+ options: all, designs: ctx.designs, ai: ctx.ai, deepDive: ctx.deepDive, owner: owner(req), key: requestKey(req), viewer: viewer(req), config: ctx.config,
});
});
const estimate = estimatePlan(plan);
const estimate = estimatePlan(plan);
const signedIn = Boolean(owner(req));
const signedIn = Boolean(owner(req));
@@ -81,7 +81,7 @@ function createCreateApi(ctx) {
@@ -81,7 +81,7 @@ function createCreateApi(ctx) {
routes.post('/', sameOrigin, safe(async (req, res) => {
routes.post('/', sameOrigin, safe(async (req, res) => {
const all = await ctx.createOptions.options({ token: token(req) });
const all = await ctx.createOptions.options({ token: token(req) });
const plan = await resolvePlan(req.body || {}, {
const plan = await resolvePlan(req.body || {}, {
- options: all, designs: ctx.designs, ai: ctx.ai, owner: owner(req), key: requestKey(req), viewer: viewer(req), config: ctx.config,
+ options: all, designs: ctx.designs, ai: ctx.ai, deepDive: ctx.deepDive, owner: owner(req), key: requestKey(req), viewer: viewer(req), config: ctx.config,
});
});
const estimate = estimatePlan(plan);
const estimate = estimatePlan(plan);
const out = await ctx.designs.create({
const out = await ctx.designs.create({
@@ -97,6 +97,7 @@ function createCreateApi(ctx) {
@@ -97,6 +97,7 @@ function createCreateApi(ctx) {
sourceDesignId: plan.start.source_design_id || null,
sourceDesignId: plan.start.source_design_id || null,
estimate,
estimate,
who: (req.body || {}).who,
who: (req.body || {}).who,
+ research: plan.research,
owner: owner(req), ip: req.ip,
owner: owner(req), ip: req.ip,
});
});
if (out.editKey) {
if (out.editKey) {
@@ -89,6 +89,43 @@ async function resolvePlan(body = {}, ctx = {}) {
@@ -89,6 +89,43 @@ async function resolvePlan(body = {}, ctx = {}) {
}
}
if (!prompt && !containsStart(start)) throw fail(422, 'create.prompt', 'Describe the design first.');
if (!prompt && !containsStart(start)) throw fail(422, 'create.prompt', 'Describe the design first.');
+ // "Research first": a deep dive's report becomes part of the generation brief. Attaching an existing dive
+ // adds nothing to the price; starting one is priced from AI's own depths before the plan is returned.
+ let research = null;
+ const rawResearch = raw.research;
+ if (rawResearch != null && rawResearch !== false && rawResearch !== '' && rawResearch !== 'off') {
+ if (!ctx.deepDive) throw fail(503, 'create.research', 'Deep dives are not available here.');
+ const value = typeof rawResearch === 'string' ? { ref: rawResearch } : (typeof rawResearch === 'object' ? rawResearch : {});
+ const ref = value.id || value.ref || value.run_id || null;
+ if (ref) {
+ // A dive Design already follows, or one the person ran on OpenVibe Exchange and pasted here. Attaching
+ // is a read: it starts no run and adds nothing to the price.
+ let row;
+ try {
+ const attached = await ctx.deepDive.attach({ owner, token: ctx.viewer && ctx.viewer.token, ref: String(ref) });
+ row = attached.research;
+ } catch (err) {
+ if (err.status === 401 || err.status === 403) throw fail(err.status, 'create.research', err.detail || 'That deep dive is not yours to attach.');
+ throw fail(err.status || 404, 'create.research', err.detail || 'That deep dive was not found.');
+ }
+ if (!row) throw fail(404, 'create.research', 'That deep dive was not found.');
+ if (['failed', 'cancelled'].includes(row.status)) throw fail(409, 'create.research', 'That deep dive did not finish; attach one with a report.');
+ research = { mode: 'attach', id: row.id, runId: row.runId || null, depth: row.depth, question: row.question,
+ status: row.status, price_usd: 0, currency: 'USD', points: row.pointsCount || 0 };
+ } else {
+ const wanted = String(value.depth || value.mode || 'thorough').toLowerCase();
+ if (!['quick', 'thorough', 'expert'].includes(wanted)) throw fail(422, 'create.research', 'Choose Quick, Thorough or Expert for the deep dive.');
+ let depths = [];
+ try { depths = (await ctx.deepDive.options()).depths || []; } catch { depths = []; }
+ const chosen = depths.find((d) => d.id === wanted) || null;
+ const sites = Array.isArray(value.sites) ? value.sites : String(value.sites || '').split(/\r?\n|,/).map((x) => x.trim()).filter(Boolean);
+ research = { mode: 'new', depth: wanted, question: String(value.question || prompt || start.title || 'A design').slice(0, 500),
+ sites: sites.filter((x) => /^https?:\/\//i.test(x)).slice(0, 30),
+ price_usd: chosen && chosen.price_usd != null ? Number(chosen.price_usd) : null,
+ currency: (chosen && chosen.currency) || 'USD', sites_count: chosen && chosen.sites || null, points: 0 };
+ }
+ }
+
const catalogue = Array.isArray(options.models) ? options.models : [];
const catalogue = Array.isArray(options.models) ? options.models : [];
const wanted = Array.isArray(raw.models) ? raw.models.map((id) => String(id == null ? '' : id).trim()).filter(Boolean) : [];
const wanted = Array.isArray(raw.models) ? raw.models.map((id) => String(id == null ? '' : id).trim()).filter(Boolean) : [];
const ids = [...new Set(wanted)];
const ids = [...new Set(wanted)];
@@ -150,7 +187,7 @@ async function resolvePlan(body = {}, ctx = {}) {
@@ -150,7 +187,7 @@ async function resolvePlan(body = {}, ctx = {}) {
look: look.id, lookName: look.name, lookSource: look.source, lookVariables: look.variables || {},
look: look.id, lookName: look.name, lookSource: look.source, lookVariables: look.variables || {},
themeId: look.themeId || (look.source === 'theme' ? look.id : null),
themeId: look.themeId || (look.source === 'theme' ? look.id : null),
start, startText,
start, startText,
- models, takes, slots, legacy,
+ models, takes, slots, legacy, research,
input_tokens, output_tokens,
input_tokens, output_tokens,
};
};
}
}
@@ -185,18 +222,28 @@ function estimatePlan(plan) {
@@ -185,18 +222,28 @@ function estimatePlan(plan) {
entry.priced = entry.priced || isPriced;
entry.priced = entry.priced || isPriced;
}
}
const perModel = [...per.values()];
const perModel = [...per.values()];
- const total = micro(perModel.reduce((sum, entry) => sum + entry.cost_usd, 0));
- const cost = { currency: 'USD', per_model: perModel, total_usd: total, total };
+ const modelTotal = micro(perModel.reduce((sum, entry) => sum + entry.cost_usd, 0));
+ const research = plan.research ? {
+ mode: plan.research.mode, depth: plan.research.depth, question: plan.research.question,
+ id: plan.research.id || null, status: plan.research.status || null,
+ price_usd: plan.research.price_usd == null ? null : micro(Number(plan.research.price_usd)),
+ currency: plan.research.currency || 'USD', priced: plan.research.price_usd != null,
+ sites: plan.research.sites_count || null,
+ } : null;
+ const researchCost = research && research.price_usd != null ? Number(research.price_usd) : 0;
+ const total = micro(modelTotal + researchCost);
+ const cost = { currency: 'USD', per_model: perModel, research, total_usd: total, total };
return {
return {
currency: 'USD',
currency: 'USD',
per_model: perModel,
per_model: perModel,
+ research,
total_usd: total,
total_usd: total,
cost,
cost,
takes: (plan.slots || []).length,
takes: (plan.slots || []).length,
models: perModel.length,
models: perModel.length,
input_tokens: plan.input_tokens,
input_tokens: plan.input_tokens,
output_tokens: plan.output_tokens,
output_tokens: plan.output_tokens,
- priced,
+ priced: priced || (research ? research.priced : false),
};
};
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,313 @@
@@ -0,0 +1,313 @@
+'use strict';
+
+/**
+ * OpenVibe.Design's way to a deep dive (contract ai.deep-dive@1, OpenVibe.AI's workflow design.deep_dive).
+ *
+ * A dive is an ordinary durable AI run. Design starts it as the first-party service `design`, on the person's
+ * behalf (so AI holds their Balance for the depth's price), and reads the run while it works. When the run
+ * finishes its output is the report. The run id, not a second copy of the pipeline, is the source of truth.
+ *
+ * The paths are read from configuration because the AI and Exchange deployments of the feature ship together:
+ * `ai.runsPath` is /api/v1/runs, `ai.deepDivesPath` is the feature's own front door when a deployment has one,
+ * and the options path gives the depths and their price. A 404 falls through to the next path.
+ */
+
+const { serviceAuth } = require('openvibe-contracts');
+const { normalizeReport, progressOf } = require('./report');
+const { STEP_NAMES } = require('./report');
+
+class DeepDiveError extends Error {
+ constructor(status, code, detail) {
+ super(detail || code);
+ this.name = 'DeepDiveError';
+ this.status = status;
+ this.code = code;
+ this.detail = detail || code;
+ }
+}
+
+const DEPTH_NAMES = { quick: 'Quick', thorough: 'Thorough', expert: 'Expert' };
+
+function parsePrices(raw) {
+ const out = {};
+ for (const part of String(raw || '').split(',')) {
+ const [key, value] = part.split('=').map((x) => x && x.trim());
+ const n = Number(value);
+ if (key && Number.isFinite(n) && n >= 0) out[key] = n;
+ }
+ return out;
+}
+
+function createDeepDiveAi({ config, fetchImpl = globalThis.fetch, log = console }) {
+ const ai = config.ai || {};
+ const dd = config.deepDive || {};
+ const base = String(dd.url || ai.url || '').replace(/\/+$/, '');
+ const tokenClient = config.oauth && config.oauth.clientSecret && base
+ ? serviceAuth.createTokenClient({
+ tokenUrl: `${config.networkInternalUrl || config.networkUrl}/oauth/token`,
+ clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
+ audience: ai.audience || 'openvibe.ai', scope: ai.scope || 'ai.run.create', fetchImpl,
+ })
+ : null;
+ const enabled = Boolean(base && tokenClient);
+ const timeoutMs = Math.max(1000, Number(dd.timeoutMs) || 30_000);
+ const pollMs = Math.max(200, Number(dd.pollMs) || 4000);
+ const waitMs = Math.max(0, Number(dd.waitMs) || 0);
+ const fallbackPrices = parsePrices(dd.prices);
+ const publicBase = String(dd.publicReportBase || 'https://openvibe.exchange').replace(/\/+$/, '');
+ const reportPath = String(dd.reportPath || '/deep-dive/:id');
+ const exchangeBase = String(dd.exchangeUrl || '').replace(/\/+$/, '');
+
+ async function headers({ token = null, retried = false } = {}) {
+ if (token) return { Accept: 'application/json', Authorization: `Bearer ${token}` };
+ if (!tokenClient) throw new DeepDiveError(503, 'deep_dive.not_configured', 'OpenVibe.AI is not configured for deep dives.');
+ try { return { Accept: 'application/json', ...(await tokenClient.authHeaders()) }; } catch {
+ if (!retried) return headers({ token, retried: true });
+ throw new DeepDiveError(502, 'deep_dive.token_failed', 'OpenVibe.Network did not issue an AI token for a deep dive.');
+ }
+ }
+
+ async function call(method, path, body, { token = null, retried = false, baseUrl = base } = {}) {
+ if (!baseUrl) throw new DeepDiveError(503, 'deep_dive.not_configured', 'OpenVibe.AI is not configured for deep dives.');
+ let h;
+ try { h = await headers({ token }); } catch (err) { throw err; }
+ let res;
+ try {
+ res = await fetchImpl(`${baseUrl}${path}`, {
+ method, headers: { ...h, ...(body ? { 'Content-Type': 'application/json' } : {}) },
+ body: body ? JSON.stringify(body) : undefined,
+ signal: AbortSignal.timeout(timeoutMs),
+ });
+ } catch (err) {
+ throw new DeepDiveError(502, 'deep_dive.unreachable', err && err.name === 'TimeoutError'
+ ? 'OpenVibe.AI did not answer in time.' : 'OpenVibe.AI could not be reached.');
+ }
+ if (res.status === 401 && !token && !retried) {
+ tokenClient.invalidate();
+ return call(method, path, body, { token, retried: true, baseUrl });
+ }
+ const json = await res.json().catch(() => null);
+ if (!res.ok) {
+ const code = (json && (json.code || json.error)) || `deep_dive.http_${res.status}`;
+ const detail = (json && (json.detail || json.message)) || `OpenVibe.AI refused the deep dive (${res.status}).`;
+ throw new DeepDiveError(res.status >= 500 ? 502 : res.status, String(code).slice(0, 80), String(detail).slice(0, 500));
+ }
+ return json;
+ }
+
+ /** The depths and what each costs, before a run starts. AI's own answer when it has one. */
+ async function options({ token = null } = {}) {
+ const seen = new Set();
+ const candidates = [];
+ if (dd.optionsPath) candidates.push({ baseUrl: base, path: dd.optionsPath });
+ candidates.push({ baseUrl: base, path: '/api/v1/deep-dives/depths' }, { baseUrl: base, path: '/api/v1/deep-dives/options' },
+ { baseUrl: base, path: '/api/v1/deep-dive/options' }, { baseUrl: base, path: '/api/v1/deep-dives/prices' });
+ if (exchangeBase) candidates.push({ baseUrl: exchangeBase, path: '/api/v1/deep-dives/options' }, { baseUrl: exchangeBase, path: '/api/v1/exchange/deep-dives/options' });
+ for (const c of candidates) {
+ const key = `${c.baseUrl}${c.path}`;
+ if (seen.has(key)) continue;
+ seen.add(key);
+ try {
+ const body = await call('GET', c.path, null, { token, baseUrl: c.baseUrl });
+ const out = normalizeOptions(body);
+ if (out) return { ...out, source: 'ai' };
+ } catch (err) {
+ if (err.status !== 404 && err.status !== 405) log.warn('[OpenVibe.Design] deep dive options:', err.message);
+ }
+ }
+ return fallbackOptions();
+ }
+
+ function fallbackOptions() {
+ const depths = ['quick', 'thorough', 'expert'].map((id) => ({
+ id, name: DEPTH_NAMES[id], sites: id === 'quick' ? 6 : id === 'thorough' ? 15 : 30,
+ minutes: id === 'quick' ? 3 : null, price_usd: fallbackPrices[id] == null ? null : Number(fallbackPrices[id]),
+ currency: 'USD',
+ line: id === 'quick' ? 'Six sites, about three minutes' : id === 'thorough' ? 'Fifteen sites' : 'Thirty sites, plus interactions, accessibility and speed',
+ }));
+ return { depths, priced: depths.some((d) => d.price_usd != null), source: 'config', note: depths.some((d) => d.price_usd != null) ? null : 'The price is shown on OpenVibe Exchange before the dive starts.' };
+ }
+
+ function normalizeOptions(body) {
+ const root = Array.isArray(body) ? { depths: body } : (body && (body.deep_dive || body.deepDive) ? (body.deep_dive || body.deepDive) : body);
+ const raw = root && (root.depths || root.options || root.depth_options || root.levels);
+ if (!Array.isArray(raw) || !raw.length) {
+ // A prices-only answer: { prices: { quick: 0.25, … } } or { quick: { price_usd } }.
+ const prices = root && (root.prices || root.depth_prices);
+ if (prices && typeof prices === 'object' && !Array.isArray(prices)) {
+ const depths = ['quick', 'thorough', 'expert'].map((id) => {
+ const v = prices[id];
+ if (v == null) return null;
+ const price = typeof v === 'object' ? (v.price_usd != null ? v.price_usd : v.price) : v;
+ const extra = typeof v === 'object' ? v : {};
+ return { id, name: extra.name || DEPTH_NAMES[id], sites: Number(extra.sites || extra.max_sites) || null,
+ minutes: Number(extra.minutes) || null, price_usd: Number(price) || 0, currency: String(extra.currency || 'USD'), line: extra.line || null };
+ }).filter(Boolean);
+ if (depths.length) return { depths, priced: true, source: 'ai', note: root.note || null };
+ }
+ return null;
+ }
+ const depths = raw.map((d) => {
+ const id = String(d.id || d.key || d.depth || d.name || '').toLowerCase();
+ const known = ['quick', 'thorough', 'expert'].includes(id) ? id : null;
+ if (!known) return null;
+ return {
+ id: known, name: String(d.name || d.label || DEPTH_NAMES[known]),
+ sites: Number(d.sites || d.site_count || d.max_sites) || null,
+ minutes: Number(d.minutes || d.minutes_about) || null,
+ price_usd: Number(d.price_usd != null ? d.price_usd : d.price) || 0,
+ currency: String(d.currency || 'USD'),
+ line: String(d.line || d.description || d.analysis || '') || null,
+ };
+ }).filter(Boolean);
+ if (!depths.length) return null;
+ return { depths, priced: depths.every((d) => d.price_usd != null), source: 'ai', note: root.note || null };
+ }
+
+ /** The run body both doors accept; the report is the run's output. */
+ function runBody({ question, sites = [], depth = 'thorough', person = null, target = null }) {
+ // ai.deep-dive@1's input: the question, the depth, the depth's site budget and the sites the person named.
+ const wanted = ['quick', 'thorough', 'expert'].includes(String(depth)) ? String(depth) : 'thorough';
+ const maxSites = wanted === 'quick' ? 6 : wanted === 'thorough' ? 15 : 30;
+ const input = { question: String(question || '').slice(0, 500), depth: wanted, max_sites: maxSites,
+ sites: sites.map((s) => String(s).slice(0, 2000)).filter((s) => /^https?:\/\//i.test(s)).slice(0, 30) };
+ return {
+ workflow: dd.workflow || 'design.deep_dive',
+ input,
+ options: { cache: false },
+ ...(person ? { on_behalf_of: { type: 'user', id: String(person) } } : {}),
+ ...(target ? { target } : {}),
+ };
+ }
+
+ /** Start a dive on the person's behalf. The id is kept before any report exists, so a failed run is still a record. */
+ async function start({ question, sites = [], depth = 'thorough', person = null, target = null, idempotencyKey = null } = {}) {
+ const body = runBody({ question, sites, depth, person, target });
+ if (idempotencyKey) body.idempotency_key = String(idempotencyKey).slice(0, 200);
+ const paths = [];
+ if (dd.startPath) paths.push({ path: dd.startPath, body });
+ // The feature's own door (OpenVibe.AI's /api/v1/deep-dives) takes the dive's input and the person it is for.
+ // It is preferred over the generic run API: it computes the depth's price and hold itself, and its route is
+ // what the Exchange page and Design share.
+ paths.push({ path: '/api/v1/deep-dives', body: { ...body.input, ...(body.idempotency_key ? { idempotency_key: body.idempotency_key } : {}),
+ ...(body.on_behalf_of ? { on_behalf_of: body.on_behalf_of } : {}), ...(body.target ? { target: body.target } : {}) } });
+ paths.push({ path: '/api/v1/deep-dives', body });
+ paths.push({ path: '/api/v1/runs?wait=0', body });
+ if (dd.deepDivesPath) paths.push({ path: dd.deepDivesPath, body: body.input });
+ paths.push({ path: '/api/v1/deep-dives', body: body.input });
+ let last = null;
+ for (const p of paths) {
+ try {
+ const out = await call('POST', p.path, p.body);
+ const run = out && (out.run || out.deep_dive || out.deepDive || out);
+ const id = run && (run.id || run.run_id || run.deep_dive_id);
+ if (!id) throw new DeepDiveError(502, 'deep_dive.bad_answer', 'OpenVibe.AI started a deep dive without an id.');
+ return { runId: String(id), run: run.run || run, raw: out };
+ } catch (err) {
+ last = err;
+ if (![403, 404, 405, 422].includes(err.status)) throw err;
+ }
+ }
+ throw last || new DeepDiveError(502, 'deep_dive.start_failed', 'The deep dive could not be started.');
+ }
+
+ /** A dive's own document, from the feature's route when its run output is not the report itself. */
+ async function deepDiveDoc(runId, { token = null } = {}) {
+ const id = String(runId || '');
+ const out = await call('GET', `/api/v1/deep-dives/${encodeURIComponent(id)}`, null, { token });
+ const doc = out && (out.deep_dive || out.deepDive || out.report || out.run || out);
+ if (!doc || typeof doc !== 'object') throw new DeepDiveError(502, 'deep_dive.bad_answer', 'OpenVibe.AI answered without a deep dive.');
+ const status = doc.status || (out && out.run && out.run.status) || 'succeeded';
+ const report = doc.report && typeof doc.report === 'object' ? doc.report : null;
+ return { run: { id, status, output: doc.report || doc.output || doc, error: doc.error || null,
+ input: doc.input || (report && report.input) || { question: doc.question, depth: doc.depth, max_sites: doc.site_limit, sites: [] },
+ usage: doc.receipt || null, explain: { deep_dive: { steps: doc.steps || [], progress: doc.progress, current: doc.step || doc.current || null } } }, citations: [] };
+ }
+
+ /** One run, with the person's own token when attaching a dive they ran on Exchange. */
+ async function getRun(runId, { token = null } = {}) {
+ const id = String(runId || '');
+ if (!/^[A-Za-z0-9._~-]{1,160}$/.test(id)) throw new DeepDiveError(422, 'deep_dive.invalid_id', 'That is not a deep dive id.');
+ // The feature's own state is the live one: it has the seven steps and the report beside the run. A
+ // deployment without it falls back to the run API, whose output is the same report.
+ try {
+ return await deepDiveDoc(id, { token });
+ } catch (err) {
+ if (![403, 404, 405].includes(err.status)) throw err;
+ }
+ let out;
+ try {
+ out = await call('GET', `/api/v1/runs/${encodeURIComponent(id)}`, null, { token });
+ } catch (err) {
+ if (err.status === 404) throw new DeepDiveError(404, 'deep_dive.not_found', 'That deep dive was not found.');
+ throw err;
+ }
+ const run = out && (out.run || out);
+ if (!run || typeof run !== 'object') throw new DeepDiveError(502, 'deep_dive.bad_answer', 'OpenVibe.AI answered without a run.');
+ return { run, citations: out && out.citations || [] };
+ }
+
+ async function cancel(runId) {
+ const out = await call('POST', `/api/v1/runs/${encodeURIComponent(runId)}/cancel`, {});
+ return out && (out.run || out);
+ }
+
+ /**
+ * One crop of a capture as bytes: AI serves the private Media object to the service that ran the dive.
+ * The path is configuration because the capture endpoint ships with the feature.
+ */
+ async function crop(runId, cropId) {
+ const id = String(runId || '');
+ const crop = String(cropId || '');
+ if (!/^[A-Za-z0-9._~-]{1,160}$/.test(id) || !/^[A-Za-z0-9._~-]{1,120}$/.test(crop)) throw new DeepDiveError(422, 'deep_dive.invalid_crop', 'That is not a crop of this deep dive.');
+ const candidates = [dd.cropPath, '/api/v1/deep-dives/:runId/shots/:cropId', '/api/v1/deep-dives/:runId/crops/:cropId',
+ '/api/v1/deep-dives/:runId/capture/:cropId', '/api/v1/deep-dives/:runId/media/:cropId'].filter(Boolean);
+ let last = null;
+ for (const template of [...new Set(candidates)]) {
+ const path = template.replace(':runId', encodeURIComponent(id)).replace(':cropId', encodeURIComponent(crop));
+ let h;
+ try { h = await headers(); } catch (err) { throw err; }
+ let res;
+ try {
+ res = await fetchImpl(`${base}${path}`, { headers: { ...h, Accept: 'image/*' }, signal: AbortSignal.timeout(timeoutMs) });
+ } catch (err) {
+ last = new DeepDiveError(502, 'deep_dive.unreachable', 'OpenVibe.AI could not be reached for the crop.');
+ continue;
+ }
+ if (res.status === 404 || res.status === 405) { last = new DeepDiveError(404, 'deep_dive.no_crop', 'That crop is not available.'); continue; }
+ if (!res.ok) {
+ const body = await res.text().catch(() => '');
+ last = new DeepDiveError(res.status >= 500 ? 502 : res.status, `deep_dive.crop_${res.status}`, body.slice(0, 300) || 'The crop could not be read.');
+ continue;
+ }
+ const type = String(res.headers.get('content-type') || 'image/png');
+ if (!/^image\//i.test(type)) { last = new DeepDiveError(502, 'deep_dive.crop_type', 'OpenVibe.AI did not answer with an image.'); continue; }
+ return { contentType: type, body: Buffer.from(await res.arrayBuffer()) };
+ }
+ throw last || new DeepDiveError(404, 'deep_dive.no_crop', 'That crop is not available.');
+ }
+
+ /** The report once a run has succeeded; the run is the evidence, the report is its output. */
+ function reportOf(run, { question = '', depth = 'thorough' } = {}) {
+ let output = run && run.output;
+ if (output && typeof output === 'object' && output.deep_dive) output = output.deep_dive;
+ if (output && typeof output === 'object' && output.report) output = output.report;
+ if (!output) return null;
+ const normalized = normalizeReport(output, { question, depth });
+ const url = (output && (output.report_url || output.url)) || (run && (run.report_url || run.url)) || null;
+ return { ...normalized, url: url || `${publicBase}${reportPath.replace(':id', String(run && run.id || ''))}` };
+ }
+
+ function statusOf(run) {
+ const s = String((run && (run.status || run.state)) || 'running').toLowerCase();
+ if (['succeeded', 'success', 'done', 'completed', 'ok', 'cached'].includes(s)) return 'done';
+ if (['failed', 'error', 'errored'].includes(s)) return 'failed';
+ if (['cancelled', 'canceled', 'stopped'].includes(s)) return 'cancelled';
+ if (['queued', 'pending', 'starting'].includes(s)) return 'starting';
+ return 'running';
+ }
+
+ return { enabled, start, getRun, cancel, crop, options, reportOf, statusOf, progressOf, publicReportUrl: (id) => `${publicBase}${reportPath.replace(':id', String(id || ''))}`, STEP_NAMES, pollMs, waitMs, DEPTH_NAMES };
+}
+
+module.exports = { createDeepDiveAi, DeepDiveError, parsePrices, DEPTH_NAMES };
new file mode 100644
new file mode 100644
@@ -0,0 +1,90 @@
@@ -0,0 +1,90 @@
+'use strict';
+
+/**
+ * The deep dive API used by /create, the canvas and the owner's rules page:
+ *
+ * GET /api/deep-dive/options the depths and their price, plus the person's finished dives to attach
+ * GET /api/deep-dive/mine the person's dives, newest first
+ * GET /api/deep-dive/:id one dive (the owner only), its progress, points and proposals
+ * POST /api/deep-dive/attach attach a dive the person already ran (a report link or a run id)
+ * POST /api/deep-dive/:id/cancel stop a dive that is still running
+ * GET /api/v1/rules/proposals the proposed rules (owner only)
+ * POST /api/v1/rules/proposals/:id/approve|dismiss the owner's decision
+ *
+ * The start of a dive for /create is not a separate call: the create plan carries the chosen depth and the
+ * design's background run starts it (server/designs.js), so a price and a run are planned from the same choices.
+ */
+const { http } = require('openvibe-contracts');
+const { asyncRouter } = require('../http/router');
+const { requireSameOrigin } = require('../http/same-origin');
+
+const owner = (req) => (req.viewer && req.viewer.kind === 'user' ? req.viewer.subject : null);
+const tokenOf = (req) => (req.viewer && req.viewer.kind === 'user' ? req.viewer.token : null);
+const viewer = (req) => (req.viewer && req.viewer.kind === 'user' ? req.viewer : null);
+
+function createDeepDiveApi(ctx) {
+ const routes = asyncRouter();
+ const sameOrigin = requireSameOrigin(ctx.config);
+ const privateJson = (res, status, body) => res.status(status).set('Cache-Control', 'private, no-store').json(body);
+ const safe = (fn) => async (req, res) => {
+ try { return await fn(req, res); } catch (err) {
+ ctx.log.warn('[OpenVibe.Design] deep dive:', (err && err.message) || err);
+ if (res.headersSent) return;
+ return http.sendProblem(res, err.status || 500, err.code || 'deep_dive.error', { detail: err.status ? err.detail : 'The request could not be completed.', ctx: req.ov });
+ }
+ };
+
+ routes.get('/deep-dive/options', safe(async (req, res) => {
+ const options = await ctx.deepDive.options();
+ const recent = owner(req) ? await ctx.deepDive.list(owner(req), { limit: 6 }) : [];
+ return privateJson(res, 200, {
+ ...options,
+ recent: recent.filter((r) => r.status === 'done' || r.status === 'running'),
+ signed_in: Boolean(owner(req)),
+ note: owner(req) ? options.note : 'Sign in to start a deep dive; its price is held on your Balance before it runs.',
+ design_href: '/create',
+ });
+ }));
+
+ routes.get('/deep-dive/mine', safe(async (req, res) => {
+ if (!owner(req)) return privateJson(res, 401, { error: 'auth.required', detail: 'Sign in to see your deep dives.' });
+ return privateJson(res, 200, { dives: await ctx.deepDive.list(owner(req), { limit: Number(req.query.limit) || 8 }) });
+ }));
+
+ routes.get('/deep-dive/:id', safe(async (req, res) => {
+ if (!owner(req)) return privateJson(res, 401, { error: 'auth.required', detail: 'Sign in to see this deep dive.' });
+ const research = await ctx.deepDive.get(req.params.id, owner(req));
+ return privateJson(res, 200, { deep_dive: research });
+ }));
+
+ routes.post('/deep-dive/attach', sameOrigin, safe(async (req, res) => {
+ if (!owner(req)) return privateJson(res, 401, { error: 'auth.required', detail: 'Sign in to attach a deep dive.' });
+ const body = req.body || {};
+ const out = await ctx.deepDive.attach({ owner: owner(req), token: tokenOf(req), ref: body.ref || body.id || body.url || '' });
+ return privateJson(res, out.existing ? 200 : 201, out);
+ }));
+
+ routes.post('/deep-dive/:id/cancel', sameOrigin, safe(async (req, res) => {
+ if (!owner(req)) return privateJson(res, 401, { error: 'auth.required', detail: 'Sign in to stop a deep dive.' });
+ const research = await ctx.deepDive.get(req.params.id, owner(req));
+ if (!research || !research.runId) return privateJson(res, 409, { error: 'deep_dive.no_run', detail: 'That deep dive has no run to stop.' });
+ if (research.status === 'done' || research.status === 'failed') return privateJson(res, 200, { deep_dive: research });
+ await ctx.deepDive.client.cancel(research.runId);
+ const updated = await ctx.deepDive.get(req.params.id, owner(req));
+ return privateJson(res, 200, { deep_dive: updated });
+ }));
+
+ routes.get('/v1/rules/proposals', safe(async (req, res) => {
+ if (!ctx.proposals.isOwner(viewer(req))) return privateJson(res, 403, { error: 'rules.owner_only', detail: 'Only the owner decides a proposed rule.' });
+ return privateJson(res, 200, { proposals: await ctx.proposals.list({ state: req.query.state || 'proposed' }) });
+ }));
+
+ routes.post('/v1/rules/proposals/:id/:action', sameOrigin, safe(async (req, res) => {
+ const out = await ctx.proposals.decide(req.params.id, req.params.action, viewer(req));
+ return privateJson(res, 200, out);
+ }));
+
+ return routes;
+}
+
+module.exports = { createDeepDiveApi };
new file mode 100644
new file mode 100644
@@ -0,0 +1,116 @@
@@ -0,0 +1,116 @@
+'use strict';
+
+/**
+ * The one write a rule approval makes: a pull request against server/rules/register.js, never a direct change.
+ *
+ * A proposed rule is appended to the register's `RULES` array with its id, area, sentence, detail, the day it was
+ * approved, why, and a link to the deep dive that proposed it. The change is made on a branch and opened as a PR;
+ * the register on main is never written by this service. Without a GitHub token the approval is recorded and the
+ * page says a pull request still needs the token.
+ */
+
+const fail = (status, code, detail) => Object.assign(new Error(detail), { status, code, detail });
+const API = 'https://api.github.com';
+
+function jsq(value) {
+ return `'${String(value == null ? '' : value)
+ .replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/\r?\n/g, '\\n')}'`;
+}
+function slug(value) {
+ return String(value || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 60) || 'proposed-rule';
+}
+function today() { return new Date().toISOString().slice(0, 10); }
+
+/** The source with one rule appended before the `RULES` array's closing bracket. Idempotent by rule id. */
+function addRule(source, proposal, { id, area, decided, reportUrl = null }) {
+ const text = String(source || '');
+ const marker = 'const RULES = Object.freeze([';
+ const start = text.indexOf(marker);
+ if (start === -1) throw fail(500, 'rules.register_shape', 'The rules register could not be read: its RULES array was not found.');
+ let close = text.indexOf('\n]);', start);
+ if (close === -1) close = text.indexOf(']);', start);
+ if (close === -1) throw fail(500, 'rules.register_shape', 'The rules register could not be read: the RULES array has no closing bracket.');
+ if (text.includes(`R(${jsq(id)},`)) throw fail(409, 'rules.duplicate', `Rule ${id} is already in the register.`);
+ const see = reportUrl ? `, [{ label: 'Read the deep dive', href: ${jsq(reportUrl)} }]` : '';
+ const line = ` R(${jsq(id)}, ${jsq(area)}, ${jsq(proposal.rule)},
+ ${jsq(proposal.detail || '')},
+ ${jsq(decided)}, ${jsq(proposal.why || '')}${see}),
+`;
+ const gap = text[close - 1] === '\n' ? '' : '\n';
+ return `${text.slice(0, close)}${gap}${line}${text.slice(close + 1)}`;
+}
+
+async function gh(fetchImpl, token, method, path, body = null) {
+ let res;
+ try {
+ res = await fetchImpl(`${API}${path}`, {
+ method,
+ headers: {
+ Accept: 'application/vnd.github+json', 'User-Agent': 'OpenVibe.Design rules proposals',
+ Authorization: `Bearer ${token}`, ...(body ? { 'Content-Type': 'application/json' } : {}),
+ },
+ body: body ? JSON.stringify(body) : undefined,
+ signal: AbortSignal.timeout(20_000),
+ });
+ } catch (err) {
+ throw fail(502, 'rules.github_unreachable', err && err.name === 'TimeoutError' ? 'GitHub did not answer in time.' : 'GitHub could not be reached.');
+ }
+ const json = await res.json().catch(() => null);
+ if (!res.ok) {
+ const detail = (json && json.message) || `GitHub answered ${res.status}.`;
+ throw fail(res.status === 401 || res.status === 403 ? 502 : 502, `rules.github_${res.status}`, String(detail).slice(0, 500));
+ }
+ return json;
+}
+
+function createRulePr({ config, fetchImpl = globalThis.fetch, log = console, now = () => new Date() }) {
+ const dd = config.deepDive || {};
+ async function open({ proposal, reportUrl = null, decidedBy = null }) {
+ const token = dd.githubToken;
+ if (!token) throw fail(503, 'rules.github_token', 'Approving a rule records it, but opening the pull request needs DESIGN_RULES_GITHUB_TOKEN on this service.');
+ const repo = String(dd.repo || 'OpenVibers/OpenVibe.Design');
+ if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo)) throw fail(500, 'rules.repo', 'The rules repository is not configured.');
+ const path = String(dd.registerPath || 'server/rules/register.js').replace(/^\/+/, '');
+ if (!/^[A-Za-z0-9._/-]+$/.test(path) || path.includes('..')) throw fail(500, 'rules.path', 'The register path is not valid.');
+ const meta = await gh(fetchImpl, token, 'GET', `/repos/${repo}`);
+ const base = String((meta && meta.default_branch) || 'main');
+ const ref = await gh(fetchImpl, token, 'GET', `/repos/${repo}/git/ref/heads/${encodeURIComponent(base)}`);
+ const baseSha = ref && ref.object && ref.object.sha;
+ if (!baseSha) throw fail(502, 'rules.github_answer', 'GitHub did not say which commit main is at.');
+ const file = await gh(fetchImpl, token, 'GET', `/repos/${repo}/contents/${path.split('/').map(encodeURIComponent).join('/')}?ref=${encodeURIComponent(base)}`);
+ const source = Buffer.from(String(file && file.content || ''), 'base64').toString('utf8');
+ const id = `research-${slug(proposal.id || proposal.rule)}`;
+ const date = now().toISOString().slice(0, 10);
+ const updated = addRule(source, proposal, { id, area: proposal.area || 'products', decided: date, reportUrl });
+ const branch = `${String(dd.branchPrefix || 'rules/research').replace(/\/+$/, '')}-${id.replace(/^research-/, '')}`.slice(0, 200);
+ try {
+ await gh(fetchImpl, token, 'POST', `/repos/${repo}/git/refs`, { ref: `refs/heads/${branch}`, sha: baseSha });
+ } catch (err) {
+ // A retry after a failed pull request reuses its branch instead of failing on the second create.
+ if (!/already exists/i.test(String(err.detail || err.message))) throw err;
+ }
+ await gh(fetchImpl, token, 'PUT', `/repos/${repo}/contents/${path.split('/').map(encodeURIComponent).join('/')}`, {
+ message: `Rules: ${proposal.rule}`.slice(0, 200),
+ content: Buffer.from(updated, 'utf8').toString('base64'),
+ sha: file && file.sha,
+ branch,
+ });
+ const pull = await gh(fetchImpl, token, 'POST', `/repos/${repo}/pulls`, {
+ title: `Rules: ${String(proposal.rule).slice(0, 160)}`,
+ head: branch, base,
+ body: [
+ '## What this adds', '',
+ `Rule \`${id}\` in \`${path}\`: **${proposal.rule}**`, '',
+ proposal.detail || '',
+ proposal.why ? `Why: ${proposal.why}` : '',
+ reportUrl ? `Proposed by a deep dive: ${reportUrl}` : '',
+ `Approved by @${decidedBy || 'the owner'} on ${date}.`, '',
+ 'Nothing is added to the register by itself: merging this pull request is the decision.',
+ ].filter(Boolean).join('\n'),
+ });
+ return { url: pull && pull.html_url, number: pull && pull.number, branch, id };
+ }
+ return { open };
+}
+
+module.exports = { createRulePr, addRule, jsq, slug };
new file mode 100644
new file mode 100644
@@ -0,0 +1,49 @@
@@ -0,0 +1,49 @@
+'use strict';
+
+/**
+ * "Proposed from research" (owner only): the rules a deep dive proposes, with their evidence, and the one
+ * decision the owner makes about each. Approval opens a pull request on the register and marks the proposal
+ * approved; dismissal keeps it out of the way. Nothing is written to server/rules/register.js here.
+ */
+
+const fail = (status, code, detail) => Object.assign(new Error(detail), { status, code, detail });
+
+function createRuleProposals({ store, rulePr, config = {}, log = console }) {
+ const ownerName = String((config.deepDive && config.deepDive.ownerUsername) || config.ownerUsername || 'goosely').toLowerCase();
+ const isOwner = (viewer) => Boolean(viewer && viewer.kind === 'user' && (
+ viewer.role === 'owner' || String(viewer.username || '').toLowerCase() === ownerName));
+
+ async function list({ state = 'proposed' } = {}) {
+ const rows = await store.listProposals({ state: state || null });
+ return await Promise.all(rows.map((r) => store.publicProposal(r, { research: true })));
+ }
+
+ async function decide(id, action, viewer) {
+ if (!isOwner(viewer)) throw fail(403, 'rules.owner_only', 'Only the owner decides a proposed rule.');
+ const proposal = await store.proposal(id);
+ if (!proposal) throw fail(404, 'rules.not_found', 'That proposed rule was not found.');
+ if (action === 'dismiss') {
+ const row = await store.decideProposal(id, { state: 'dismissed', by: viewer.username });
+ return { proposal: await store.publicProposal(row), action: 'dismissed' };
+ }
+ if (action !== 'approve') throw fail(422, 'rules.action', 'Approve or dismiss a proposed rule.');
+ if (proposal.state === 'approved' && proposal.pr_url) {
+ return { proposal: await store.publicProposal(proposal), action: 'approved', already: true };
+ }
+ if (proposal.state === 'dismissed') throw fail(409, 'rules.dismissed', 'That proposed rule was dismissed already.');
+ const research = await store.get(proposal.research_id);
+ let opened;
+ try {
+ opened = await rulePr.open({ proposal, reportUrl: research && research.report_url, decidedBy: viewer.username });
+ } catch (err) {
+ await store.decideProposal(id, { state: proposal.state === 'approved' ? 'approved' : 'proposed', by: null, prError: String(err.detail || err.message).slice(0, 500) }).catch(() => {});
+ throw err;
+ }
+ const row = await store.decideProposal(id, { state: 'approved', by: viewer.username, prUrl: opened.url, prBranch: opened.branch, prError: null });
+ return { proposal: await store.publicProposal(row), action: 'approved', pull: opened };
+ }
+
+ return { isOwner, list, decide, ownerName };
+}
+
+module.exports = { createRuleProposals };
new file mode 100644
new file mode 100644
@@ -0,0 +1,237 @@
@@ -0,0 +1,237 @@
+'use strict';
+
+/**
+ * A deep dive's report, read for Design (contract ai.deep-dive@1 on OpenVibe.AI).
+ *
+ * The report is evidence, not content: Design takes the numbered points (patterns, do and don't, reference
+ * tokens, the rules a report follows), a compact brief for the generator, the crops a component's page shows
+ * with their attribution and dates, and the rules a report proposes. No site's HTML, CSS or assets are ever
+ * stored in a design or copied into a prompt: a crop is shown as commentary with its source, and a reference
+ * token is a direction, never a palette to copy.
+ *
+ * The shape of a report is read leniently (the dive is written by another service and may grow): the usual
+ * names are used when present and a missing part is simply empty.
+ */
+
+const str = (v, max = 4000) => (typeof v === 'string' ? v : v == null ? '' : String(v)).trim().slice(0, max);
+const list = (v) => (Array.isArray(v) ? v : v == null ? [] : [v]);
+const object = (v) => (v && typeof v === 'object' && !Array.isArray(v) ? v : {});
+
+/** Text of a pattern, a do, a rule or a recommendation, whichever shape the report used. */
+function textOf(item) {
+ if (typeof item === 'string') return str(item, 600);
+ if (!item || typeof item !== 'object') return '';
+ return str(item.text || item.title || item.rule || item.name || item.pattern || item.detail || item.description || item.advice, 600);
+}
+
+/** The evidence behind one item, flattened to what a page can show. */
+function evidenceOf(item) {
+ if (!item || typeof item !== 'object') return [];
+ const raw = [
+ ...list(item.evidence), ...list(item.citations), ...list(item.sources), ...list(item.sites),
+ ...(item.site ? [item] : []), ...(item.url ? [item] : []),
+ ];
+ const out = [];
+ for (const e of raw) {
+ if (typeof e === 'string') { if (/^https?:\/\//.test(e)) out.push({ url: e, site: hostOf(e) }); continue; }
+ if (!e || typeof e !== 'object') continue;
+ const url = str(e.url || e.source_url || e.href || e.link, 2000);
+ const site = str(e.site || e.name || e.publisher || e.title || e.source || e.domain, 200) || (url ? hostOf(url) : '');
+ const crop = str(e.crop_url || e.crop || e.image_url || e.screenshot_url || e.shot_url || e.image, 2000);
+ const captured = str(e.read_at || e.captured_at || e.retrieved_at || e.date || e.capture_date, 40);
+ const note = str(e.claim || e.note || e.quote || e.detail || e.text, 400);
+ if (url || site || crop || e.crop_id || e.capture_id) out.push({ site, url: url || null, crop: crop || null,
+ cropId: str(e.crop_id, 80) || null, captureId: str(e.capture_id, 80) || null,
+ capturedAt: captured || null, note });
+ }
+ return out.slice(0, 8);
+}
+function hostOf(url) { try { return new URL(String(url)).hostname.replace(/^www\./, ''); } catch { return ''; } }
+
+/** Turn the report into a numbered list a take can follow and a page can link to. */
+const STEP_LABEL = { plan: 'Planning', search: 'Searching', choose: 'Choosing sites', capture: 'Capturing', analyse: 'Analysing', synthesise: 'Writing it up', report: 'Writing it up' };
+function pointsOf(report) {
+ const source = object(report);
+ const synth = object(source.synthesis);
+ const explicit = list(source.points).length ? list(source.points) : list(synth.points);
+ const points = [];
+ const add = (group, text, item, extra = {}) => {
+ const t = textOf(text);
+ if (!t) return;
+ points.push({ n: points.length + 1, group, text: t, evidence: evidenceOf(item), ...extra });
+ };
+ if (explicit.length) {
+ for (const p of explicit) {
+ if (typeof p === 'string') add('point', p);
+ else add(str(p.group || p.kind || 'point', 40), textOf(p), p, { rule: str(p.rule || p.rule_id, 80) || null });
+ }
+ } else {
+ // ai.deep-dive@1: patterns say what and how many sites; do/don't are sentences; the reference tokens are
+ // per source and are inspiration only; a recommendation states whether it follows or would change a rule.
+ for (const item of list(synth.patterns || source.patterns)) {
+ const name = str(item && (item.name || item.title), 160);
+ const what = str(item && (item.what || item.text || item.description), 600);
+ const count = item && Number.isFinite(Number(item.usage_count)) ? Number(item.usage_count) : (Array.isArray(item && item.sites) ? item.sites.length : 0);
+ add('pattern', count ? `${count} of the sites: ${name}${what ? ` — ${what}` : ''}` : (name || what), item);
+ }
+ for (const item of list(synth.dos || synth.do || source.dos || source.do)) add('do', item, item);
+ for (const item of list(synth.donts || synth.dont || source.donts || source.dont)) add("don't", item, item);
+ const tokens = object(synth.reference_tokens || synth.tokens || source.reference_tokens || source.tokens);
+ for (const p of list(tokens.palettes)) add('reference token', `Palette, as direction only: ${list(p && p.colours).join(', ')}`, p);
+ for (const p of list(tokens.type_scales)) add('reference token', `Type scale, as direction only: ${list(p && p.families).join(', ')}${list(p && p.sizes).length ? ` at ${list(p.sizes).join(', ')}` : ''}`, p);
+ for (const p of list(tokens.spacing)) add('reference token', `Spacing steps, as direction only: ${list(p && p.steps).join(', ')}`, p);
+ for (const item of list(synth.best ? [synth.best] : [])) add('pattern', item.what, item);
+ for (const item of list(synth.mistakes || source.mistakes)) add("don't", item.what || item.text, item);
+ for (const item of list(synth.rules || source.rules_followed || source.rules)) add('rule', item, item);
+ for (const item of list(source.recommendations || synth.recommendations)) {
+ const rule = item && item.rule && typeof item.rule === 'object' ? item.rule : null;
+ if (rule && rule.text && rule.status !== 'none') add('rule', rule.text, item, { rule: rule.id || null });
+ }
+ for (const item of list(source.recommendations || synth.recommendations)) add('recommendation', item && (item.what || item.text), item);
+ }
+ if (!points.length && (source.summary || source.verdict)) add('summary', source.summary || source.verdict);
+ return points.slice(0, 60);
+}
+/** What the generator reads: the points, plainly, with the copying rule stated once. */
+function briefOf(report, points, fallbackQuestion = '') {
+ const r = object(report);
+ const synth = object(r.synthesis);
+ const input = object(r.input);
+ const question = str(r.question || input.question || r.query || synth.question || fallbackQuestion, 300);
+ const sites = list(r.sites || r.sources || r.captures).length;
+ const by = (group) => points.filter((p) => p.group === group);
+ const lines = [`Research first: ${question || 'expert design research'}${sites ? `, from ${sites} sites read at phone and computer sizes` : ''}.`];
+ if (by('pattern').length) lines.push('What the better sites do:', ...by('pattern').map((p) => `- ${p.text}`));
+ if (by('do').length) lines.push('Do:', ...by('do').map((p) => `- ${p.text}`));
+ if (by("don't").length) lines.push('Do not:', ...by("don't").map((p) => `- ${p.text}`));
+ if (by('reference token').length) lines.push('Reference tokens, as direction only (never copy a palette, typeface, spacing scale or asset):', ...by('reference token').map((p) => `- ${p.text}`));
+ if (by('rule').length) lines.push('OpenVibe rules this follows:', ...by('rule').map((p) => `- ${p.text}`));
+ lines.push('Use the research as direction for this design. Never copy a site\'s assets, code, markup, CSS, words or an exact palette. Do not reproduce or load any reference image.');
+ return lines.join('\n');
+}
+
+/** The rules a report proposes for the register. Only an explicit "would change" becomes one; nothing is inferred. */
+function proposedRulesOf(report) {
+ const r = object(report);
+ const synth = object(r.synthesis);
+ const out = [];
+ for (const item of [...list(r.recommendations), ...list(synth.recommendations), ...list(r.proposed_rules)]) {
+ if (!item) continue;
+ const rule = object(item.rule);
+ if (!(rule.status === 'would_change' || item.proposed_rule || item.proposes_rule)) continue;
+ const text = str(item.proposed_rule || item.rule_text || item.what || (typeof item === 'string' ? item : ''), 300);
+ if (!text) continue;
+ out.push({
+ rule: text,
+ detail: str(rule.text || item.detail || item.description || item.what, 1200),
+ why: str(item.why || item.reason || item.evidence_note, 1200),
+ area: str(item.area || item.group, 40),
+ evidence: evidenceOf(item),
+ ruleId: str(rule.id, 120) || null,
+ });
+ }
+ return out.slice(0, 12);
+}
+/** The component pages' "How others do it": the points and per-site analysis whose words name a component. */
+function findsFor(report, component, limit = 6) {
+ const name = str(component, 80).toLowerCase();
+ if (!name) return [];
+ const wordy = new RegExp(`(^|[^a-z0-9])${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}([^a-z0-9]|$)`, 'i');
+ const out = [];
+ const push = (text, evidence, extra = {}) => {
+ if (!text || !wordy.test(text)) return;
+ const ev = Array.isArray(evidence) ? evidence : [];
+ out.push({
+ title: str(text, 400), detail: '', evidence: ev,
+ crop: (ev.find((e) => e.crop) || {}).crop || null,
+ cropId: (ev.find((e) => e.cropId) || {}).cropId || null,
+ captureId: (ev.find((e) => e.captureId) || {}).captureId || null,
+ site: (ev.find((e) => e.site) || {}).site || null,
+ capturedAt: (ev.find((e) => e.capturedAt) || {}).capturedAt || null,
+ url: (ev.find((e) => e.url) || {}).url || null,
+ ...extra,
+ });
+ };
+ // The capture worker's component inventory, when the DOM facts carry one: the same component seen on a site,
+ // with the crop that shows it. The capitalised name is what the inventory uses; the check is case-insensitive.
+ const sources = new Map(list(object(report).sources).map((x) => [x && x.id, x || {}]));
+ for (const cap of list(object(report).captures)) {
+ if (!cap || typeof cap !== 'object') continue;
+ const dom = object(cap.dom);
+ const inventory = list(dom.components || dom.component_inventory || dom.inventory || dom.ui_components);
+ const hit = inventory.find((c) => {
+ const label = typeof c === 'string' ? c : (c && (c.name || c.component || c.kind));
+ return label && wordy.test(String(label));
+ });
+ if (!hit) continue;
+ const src = sources.get(cap.source_id) || {};
+ const crop = list(cap.crops)[0] || null;
+ const label = typeof hit === 'string' ? hit : (hit.name || hit.component || hit.kind);
+ push(`A ${label} appears on ${src.title || host(cap.url)}.`, [{ site: host(src.url || cap.url), url: src.url || cap.url || null,
+ cropId: crop && crop.id || null, captureId: cap.id || null, capturedAt: cap.captured_at || null,
+ note: hit && typeof hit === 'object' ? str(hit.note || hit.usage || '', 300) : '' }]);
+ }
+ // The contract's per-site analysis: a summary, strengths and weaknesses, each with evidence.
+ for (const a of list(object(report).analysis)) {
+ if (!a || typeof a !== 'object') continue;
+ const ev = evidenceOf(a);
+ for (const text of [a.summary, a.first_impression, a.hierarchy, a.density, a.type, a.colour, a.motion, a.words, ...list(a.strengths), ...list(a.weaknesses)]) {
+ if (text && wordy.test(String(text))) push(text, ev, { sourceUrl: a.url || null });
+ }
+ }
+ for (const p of pointsOf(report)) {
+ if (wordy.test(p.text) || p.evidence.some((e) => wordy.test(e.note || '') || wordy.test(e.site || ''))) {
+ const ev = p.evidence.length ? p.evidence : [];
+ out.push({ n: p.n, title: p.text, detail: '', evidence: ev,
+ crop: (ev.find((e) => e.crop) || {}).crop || null,
+ cropId: (ev.find((e) => e.cropId) || {}).cropId || null,
+ captureId: (ev.find((e) => e.captureId) || {}).captureId || null,
+ site: (ev.find((e) => e.site) || {}).site || null,
+ capturedAt: (ev.find((e) => e.capturedAt) || {}).capturedAt || null,
+ url: (ev.find((e) => e.url) || {}).url || null });
+ }
+ }
+ const seen = new Set();
+ return out.filter((x) => { const k = `${x.title}|${x.cropId || x.crop || ''}`; if (seen.has(k)) return false; seen.add(k); return true; }).slice(0, limit);
+}
+/** The live step list for a running dive: the contract's seven steps, in order, as the words the brief names. */
+const CONTRACT_STEPS = ['plan', 'search', 'choose', 'capture', 'analyse', 'synthesise', 'report'];
+const STEP_NAMES = CONTRACT_STEPS.map((k) => STEP_LABEL[k]);
+function progressOf(run) {
+ const r = object(run);
+ const out = object(r.explain).deep_dive || object(r.explain).deepDive || r.progress || {};
+ const report = object(r.output);
+ const raw = list(out.steps || r.steps || report.steps);
+ let steps;
+ if (raw.length && raw[0] && (raw[0].key || raw[0].name || raw[0].step)) {
+ steps = raw.map((s, i) => {
+ const key = str(s.key || s.name || s.step, 40);
+ const state = String(s.status || s.state || '').toLowerCase();
+ return { key, name: STEP_LABEL[key] || str(s.title, 80) || STEP_NAMES[i] || `Step ${i + 1}`,
+ state: /done|complete|succeed/i.test(state) ? 'done' : /fail|error/i.test(state) ? 'failed' : /run|active|work/i.test(state) ? 'running' : /skip/i.test(state) ? 'skipped' : 'waiting' };
+ });
+ } else {
+ const index = Number(out.step_index || out.step || 0);
+ steps = STEP_NAMES.map((name, i) => ({ key: CONTRACT_STEPS[i], name, state: i < index ? 'done' : i === index ? 'running' : 'waiting' }));
+ }
+ return { steps, current: str(out.current || out.label || out.step_name, 120) || null, percent: Number(out.percent || out.progress) || null };
+}
+function normalizeReport(input, { question = '', depth = 'thorough' } = {}) {
+ let report = input;
+ if (typeof report === 'string') { try { report = JSON.parse(report); } catch { report = { summary: report }; } }
+ if (report && typeof report === 'object' && report.deep_dive) report = report.deep_dive;
+ else if (report && typeof report === 'object' && report.report) report = report.report;
+ report = object(report);
+ const points = pointsOf(report);
+ return {
+ question: str(report.question || object(report.input).question || report.query || question, 300),
+ depth: str(report.depth || depth, 20) || 'thorough',
+ summary: str(report.summary || report.verdict || object(report.synthesis).summary, 2000),
+ points,
+ brief: briefOf(report, points, question),
+ proposedRules: proposedRulesOf(report),
+ report,
+ };
+}
+
+module.exports = { normalizeReport, pointsOf, briefOf, proposedRulesOf, findsFor, progressOf, evidenceOf, textOf, STEP_NAMES };
new file mode 100644
new file mode 100644
@@ -0,0 +1,246 @@
@@ -0,0 +1,246 @@
+'use strict';
+
+/**
+ * "Research first": one place that starts a deep dive, follows it, and hands its evidence to a design.
+ *
+ * A dive starts as an AI run on the person's behalf and is followed by the service in the background, never by
+ * the request that created it (a dive takes minutes). While it runs, a design's takes wait; when it finishes,
+ * the report's numbered points become the generation brief and each take records the points it follows. A failed
+ * dive does not lose its design: the takes are drawn without it and the canvas says what happened.
+ */
+
+const { createDeepDiveAi, DeepDiveError } = require('./ai');
+const { findsFor, normalizeReport, proposedRulesOf, progressOf } = require('./report');
+
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+const REF = /(?:runs?|deep[-_ ]?dives?|research)\/([A-Za-z0-9._~-]{1,160})/i;
+
+function createDeepDiveService({ s, config, fetchImpl = globalThis.fetch, log = console, store, ai, now = () => Date.now() }) {
+ const db = store;
+ const client = ai || createDeepDiveAi({ config, fetchImpl, log });
+ const running = new Map(); // research id → the promise following it
+ const maxWaitMs = Math.max(30_000, Number(config.deepDive && config.deepDive.maxWaitMs) || 15 * 60_000);
+ const pollMs = Math.max(200, Number(config.deepDive && config.deepDive.pollMs) || 4000);
+
+ let optionsCache = { at: 0, value: null };
+ async function options() {
+ // The depths and their prices change rarely; a page and its estimate ask once, not once per keystroke.
+ if (optionsCache.value && Date.now() - optionsCache.at < 60_000) return optionsCache.value;
+ const out = await client.options().catch((err) => {
+ log.warn('[OpenVibe.Design] deep dive options:', err.message);
+ return { depths: [], priced: false, source: 'none', note: 'The depths could not be read; try again in a moment.' };
+ });
+ optionsCache = { at: Date.now(), value: out };
+ return out;
+ }
+
+ function depthOf(optionsOut, wanted) {
+ const list = (optionsOut && optionsOut.depths) || [];
+ return list.find((d) => d.id === wanted) || list.find((d) => d.id === 'thorough') || list[0] || null;
+ }
+
+ async function create({ owner, question, depth = 'thorough', sites = [], targetId = null, token = null }) {
+ if (!owner) throw Object.assign(new Error('Sign in to start a deep dive.'), { status: 401, code: 'deep_dive.sign_in', detail: 'Sign in to start a deep dive.' });
+ const opts = await options();
+ const chosen = depthOf(opts, depth);
+ if (!chosen) throw Object.assign(new Error('The deep dive depths are not available right now.'), { status: 503, code: 'deep_dive.depths', detail: 'The deep dive depths are not available right now.' });
+ const estimate = { depth: chosen.id, price_usd: chosen.price_usd, currency: chosen.currency || 'USD', priced: chosen.price_usd != null, sites: chosen.sites || null };
+ const research = await db.create({ owner, question: String(question || '').slice(0, 500), depth: chosen.id, sites, estimate, status: 'starting' });
+ let run;
+ try {
+ run = await client.start({
+ question, sites, depth: chosen.id, person: owner, idempotencyKey: research.id,
+ target: { service: 'design', type: 'design_research', id: research.id },
+ });
+ } catch (err) {
+ await db.patch(research.id, { status: 'failed', error: err.detail || err.message });
+ throw err;
+ }
+ const status = client.statusOf(run.run) || 'running';
+ const updated = await db.patch(research.id, { runId: run.runId, status, progress: progressOf(run.run) });
+ follow(research.id);
+ return { research: db.publicRow(updated || research), options: opts, estimate, tokenUsed: Boolean(token) };
+ }
+
+ /** Follow a running dive to its end and keep what it says. One follower per dive in this process. */
+ function follow(id) {
+ if (running.has(id)) return running.get(id);
+ const promise = (async () => {
+ let warned = false;
+ while (true) {
+ const row = await db.get(id);
+ if (!row || ['done', 'failed', 'cancelled'].includes(row.status)) return row;
+ if (!row.run_id) { await db.patch(id, { status: 'failed', error: 'The deep dive did not start.' }); return await db.get(id); }
+ if (Date.now() - new Date(row.created_at).getTime() > maxWaitMs) {
+ await db.patch(id, { status: 'failed', error: 'The deep dive did not finish in time. Its report may still appear on OpenVibe Exchange.' });
+ return await db.get(id);
+ }
+ let got;
+ try { got = await client.getRun(row.run_id); } catch (err) {
+ // A transient read error is retried; an authorisation failure is final.
+ if ([401, 403, 404].includes(err.status)) {
+ await db.patch(id, { status: 'failed', error: err.detail || err.message });
+ return await db.get(id);
+ }
+ if (!warned) { log.warn('[OpenVibe.Design] deep dive read:', err.message); warned = true; }
+ await sleep(pollMs);
+ continue;
+ }
+ const status = client.statusOf(got.run);
+ if (status === 'done') {
+ const report = client.reportOf(got.run, { question: row.question, depth: row.depth });
+ if (!report) {
+ await db.patch(id, { status: 'failed', progress: progressOf(got.run), error: 'The deep dive finished without a report.' });
+ return await db.get(id);
+ }
+ const saved = await db.patch(id, { status: 'done', progress: progressOf(got.run), report: report.report,
+ points: report.points, brief: report.brief, reportUrl: report.url, error: null });
+ try { await db.addProposals(saved, proposedRulesOf(report.report)); } catch (err) { log.warn('[OpenVibe.Design] rule proposals:', err.message); }
+ return saved;
+ }
+ if (status === 'failed' || status === 'cancelled') {
+ const err = got.run && (got.run.error || got.run.detail);
+ await db.patch(id, { status, progress: progressOf(got.run), error: String((err && (err.detail || err.message || err.code)) || (status === 'cancelled' ? 'The deep dive was cancelled.' : 'The deep dive did not finish.')).slice(0, 500) });
+ return await db.get(id);
+ }
+ await db.patch(id, { status, progress: progressOf(got.run) });
+ await sleep(pollMs);
+ }
+ })().catch(async (err) => {
+ log.warn('[OpenVibe.Design] deep dive follower:', err.message);
+ await db.patch(id, { status: 'failed', error: String(err.message || err).slice(0, 500) }).catch(() => {});
+ return await db.get(id).catch(() => null);
+ }).finally(() => { running.delete(id); });
+ running.set(id, promise);
+ return promise;
+ }
+
+ /** Wait for a dive's report, or its failure, for a design's takes (bounded by the design's own run). */
+ async function wait(id, { timeoutMs = maxWaitMs } = {}) {
+ const started = Date.now();
+ const follower = follow(id);
+ let row = await Promise.race([
+ follower,
+ (async () => { while (Date.now() - started < timeoutMs) { const r = await db.get(id); if (r && ['done', 'failed', 'cancelled'].includes(r.status)) return r; await sleep(Math.min(pollMs, 1000)); } return await db.get(id); })(),
+ ]).catch(() => null);
+ if (!row) row = await db.get(id);
+ return row;
+ }
+
+ async function get(id, owner) {
+ const row = owner ? await db.owned(id, owner) : await db.get(id);
+ if (!row) return null;
+ const data = db.publicRow(row, { points: true });
+ if (row.status === 'done') {
+ const proposals = await db.proposalsFor(id).catch(() => []);
+ data.proposals = (await Promise.all(proposals.map((p) => db.publicProposal(p)))).filter(Boolean);
+ }
+ return data;
+ }
+
+ /** The part of a dive a design's canvas may show to whoever can open the design: no report, no proposals. */
+ async function summary(id) {
+ const row = await db.get(id);
+ return row ? db.publicRow(row, { points: true }) : null;
+ }
+
+ async function list(owner, { limit = 8 } = {}) {
+ const rows = await db.list(owner, { limit });
+ return rows.map((r) => db.publicRow(r));
+ }
+
+ /** Attach a dive the person already ran: paste its report address or its run id. */
+ async function attach({ owner, token, ref }) {
+ if (!owner) throw Object.assign(new Error('Sign in to attach a deep dive.'), { status: 401, code: 'deep_dive.sign_in', detail: 'Sign in to attach a deep dive.' });
+ const value = String(ref || '').trim();
+ if (!value) throw Object.assign(new Error('Paste the deep dive report link or its id.'), { status: 422, code: 'deep_dive.ref', detail: 'Paste the deep dive report link or its id.' });
+ const m = REF.exec(value);
+ const runId = (m ? m[1] : value).replace(/[?#].*$/, '');
+ if (!/^[A-Za-z0-9._~-]{1,160}$/.test(runId)) throw Object.assign(new Error('That is not a deep dive link or id.'), { status: 422, code: 'deep_dive.ref', detail: 'That is not a deep dive link or id.' });
+ // A dive Design already follows is named by its own id (rsr_...), not an AI run id: attach is a read.
+ const own = await db.get(runId);
+ if (own) {
+ if (own.owner !== owner) throw Object.assign(new Error('That deep dive belongs to someone else.'), { status: 403, code: 'deep_dive.forbidden', detail: 'That deep dive belongs to someone else.' });
+ if (own.status === 'failed' || own.status === 'cancelled') throw Object.assign(new Error('That deep dive did not finish; attach one with a report.'), { status: 409, code: 'deep_dive.failed', detail: 'That deep dive did not finish; attach one with a report.' });
+ return { research: db.publicRow(own, { points: true }), existing: true };
+ }
+ const existing = await db.byRun(runId);
+ if (existing && existing.owner === owner) return { research: db.publicRow(existing, { points: true }), existing: true };
+ if (existing && existing.owner !== owner) throw Object.assign(new Error('That deep dive belongs to someone else.'), { status: 403, code: 'deep_dive.forbidden', detail: 'That deep dive belongs to someone else.' });
+ if (!token) throw Object.assign(new Error('Sign in to attach a deep dive you ran on OpenVibe Exchange.'), { status: 401, code: 'deep_dive.sign_in', detail: 'Sign in to attach a deep dive you ran on OpenVibe Exchange.' });
+ const got = await client.getRun(runId, { token });
+ const run = got.run || {};
+ const output = run.output && typeof run.output === 'object' ? run.output : {};
+ const input = run.input && typeof run.input === 'object' ? run.input : (output.input && typeof output.input === 'object' ? output.input : {});
+ const question = input.question || input.query || output.question || output.query || 'A deep dive';
+ const depth = input.depth || output.depth || 'thorough';
+ const status = client.statusOf(run);
+ const report = status === 'done' ? client.reportOf(run, { question, depth }) : null;
+ const row = await db.create({ owner, question, depth, sites: Array.isArray(input.sites) ? input.sites : [], runId, status: status === 'done' ? 'done' : status,
+ estimate: { depth, price_usd: null, currency: 'USD', priced: false, attached: true } });
+ if (report) {
+ const saved = await db.patch(row.id, { status: 'done', progress: progressOf(run), report: report.report, points: report.points,
+ brief: report.brief, reportUrl: report.url, error: null });
+ await db.addProposals(saved, proposedRulesOf(report.report)).catch((err) => log.warn('[OpenVibe.Design] rule proposals:', err.message));
+ return { research: (await get(row.id, owner)), existing: false };
+ }
+ if (status === 'running' || status === 'starting') {
+ const updated = await db.patch(row.id, { progress: progressOf(run) });
+ follow(row.id);
+ return { research: db.publicRow(updated || row, { points: true }), existing: false };
+ }
+ const failed = await db.patch(row.id, { status: status === 'failed' ? 'failed' : 'failed', error: 'That deep dive did not finish.' });
+ return { research: db.publicRow(failed || row), existing: false };
+ }
+
+ /** Read one point list for a design's take: patterns, do, don't and tokens rotate per take. */
+ function pointsForTake(points, index, total = 1) {
+ const list = Array.isArray(points) ? points : [];
+ if (!list.length) return [];
+ const groups = ['pattern', 'do', "don't", 'reference token', 'rule', 'recommendation', 'point'];
+ const out = [];
+ const count = Math.max(2, Math.min(5, Math.ceil(list.length / Math.max(1, total)) + 1));
+ for (let i = 0; i < count; i++) {
+ const pool = list.filter((p) => p.group === groups[(i + Number(index || 0)) % groups.length]);
+ const source = pool.length ? pool : list;
+ const pick = source[(Number(index || 0) + i) % source.length];
+ if (pick && !out.some((p) => p.n === pick.n)) out.push({ n: pick.n, group: pick.group, text: String(pick.text || '').slice(0, 300) });
+ }
+ return out.slice(0, 5);
+ }
+
+ function pointLine(points, research) {
+ if (!Array.isArray(points) || !points.length) return '';
+ const links = points.map((p) => `point ${p.n}`).join(', ');
+ return `\nThis take follows research ${links}${research && research.reportUrl ? ` (${research.reportUrl})` : ''}. Use them as direction; never copy a site's assets, code or exact palette.`;
+ }
+
+ /** "How others do it": crops and points across every finished report this service holds. */
+ async function finds(component, { limit = 6, owner = null } = {}) {
+ // A deep dive is the person's. A component page shows the findings of the person looking at it, never
+ // another person's research or crops.
+ if (!owner) return { component, count: 0, finds: [], reports: 0 };
+ const reports = await db.allDone({ limit: 60, owner });
+ const out = [];
+ for (const row of reports) {
+ const found = findsFor(row.report, component, limit);
+ for (const find of found) {
+ const key = `${find.n || ''}|${find.title}|${find.crop || find.url || ''}`;
+ if (out.some((x) => x.key === key)) continue;
+ out.push({ ...find, key, researchId: row.id, runId: row.run_id || null, researchUrl: `/research/${encodeURIComponent(row.id)}`,
+ reportUrl: row.report_url || null, depth: row.depth, readAt: row.updated_at });
+ }
+ }
+ return { component, count: out.length, finds: out.slice(0, limit), reports: reports.length };
+ }
+
+ async function resume() {
+ const rows = await s.db.many("SELECT id FROM design_research WHERE status IN ('starting','running') AND run_id IS NOT NULL ORDER BY created_at LIMIT 20");
+ for (const r of rows) follow(r.id);
+ return rows.length;
+ }
+
+ return { options, create, follow, wait, get, summary, list, attach, pointsForTake, pointLine, finds, resume, client };
+}
+
+module.exports = { createDeepDiveService, REF };
new file mode 100644
new file mode 100644
@@ -0,0 +1,145 @@
@@ -0,0 +1,145 @@
+'use strict';
+
+/**
+ * The deep dives Design knows: the runs it started for a person, the report's numbered points and brief, and
+ * the rules those reports propose. A dive is kept even when its run fails, because the person paid for a dive
+ * and a failed one still says why; a report template is never stored, only the run's own answer.
+ */
+
+const fail = (status, code, detail) => Object.assign(new Error(detail), { status, code, detail });
+const PUBLIC = new Set(['quick', 'thorough', 'expert']);
+
+function createDeepDiveStore({ s, log = console }) {
+ const db = s.db;
+
+ function publicRow(row, { points = false } = {}) {
+ if (!row) return null;
+ const out = {
+ id: row.id, question: row.question, depth: row.depth, sites: Array.isArray(row.sites) ? row.sites : [],
+ status: row.status, runId: row.run_id || null, url: `/research/${encodeURIComponent(row.id)}`,
+ priceUsd: (row.report && row.report.receipt && row.report.receipt.price_usd != null)
+ ? Number(row.report.receipt.price_usd) : (row.estimate && row.estimate.price_usd != null ? Number(row.estimate.price_usd) : null),
+ reportUrl: row.report_url || null, progress: row.progress && typeof row.progress === 'object' ? row.progress : {},
+ estimate: row.estimate || null, error: row.error || null,
+ createdAt: row.created_at, updatedAt: row.updated_at,
+ pointsCount: Array.isArray(row.points) ? row.points.length : 0,
+ };
+ if (points) out.points = Array.isArray(row.points) ? row.points : [];
+ return out;
+ }
+
+ async function create({ owner, question, depth = 'thorough', sites = [], estimate = null, runId = null, status = 'starting' }) {
+ const id = s.newId('rsr');
+ const at = s.iso();
+ await db.exec(`INSERT INTO design_research(id,owner,question,depth,sites,run_id,status,progress,estimate,created_at,updated_at)
+ VALUES($1,$2,$3,$4,$5::jsonb,$6,$7,'{}'::jsonb,$8::jsonb,$9::timestamptz,$9::timestamptz)`,
+ [id, owner, String(question || '').slice(0, 2000), PUBLIC.has(depth) ? depth : 'thorough', JSON.stringify(Array.isArray(sites) ? sites.slice(0, 30) : []),
+ runId, status, estimate ? JSON.stringify(estimate) : null, at]);
+ return await get(id);
+ }
+
+ async function get(id) { return await db.maybe('SELECT * FROM design_research WHERE id=$1', [id]); }
+
+ async function owned(id, owner) {
+ const row = await get(id);
+ if (!row) throw fail(404, 'deep_dive.not_found', 'Deep dive not found.');
+ if (row.owner !== owner) throw fail(403, 'deep_dive.forbidden', 'That deep dive belongs to someone else.');
+ return row;
+ }
+
+ async function patch(id, fields = {}) {
+ const columns = [];
+ const values = [id];
+ const put = (name, value) => { values.push(value); columns.push(`${name}=$${values.length}`); };
+ if (fields.status != null) put('status', fields.status);
+ if (fields.runId != null) put('run_id', fields.runId);
+ if (fields.progress != null) put('progress', JSON.stringify(fields.progress));
+ if (fields.report != null) put('report', JSON.stringify(fields.report));
+ if (fields.points != null) put('points', JSON.stringify(fields.points));
+ if (fields.brief != null) put('brief', String(fields.brief).slice(0, 20000));
+ if (fields.reportUrl != null) put('report_url', fields.reportUrl);
+ if (fields.error != null) put('error', String(fields.error).slice(0, 500));
+ if (fields.estimate != null) put('estimate', JSON.stringify(fields.estimate));
+ if (!columns.length) return await get(id);
+ values.push(s.iso());
+ columns.push(`updated_at=$${values.length}::timestamptz`);
+ return await db.maybe(`UPDATE design_research SET ${columns.join(', ')} WHERE id=$1 RETURNING *`, values);
+ }
+
+ async function list(owner, { limit = 8 } = {}) {
+ const size = Math.max(1, Math.min(30, Number(limit) || 8));
+ return await db.many(`SELECT * FROM design_research WHERE owner=$1 ORDER BY created_at DESC, id DESC LIMIT $2`, [owner, size]);
+ }
+
+ async function recentDone(owner, { limit = 6 } = {}) {
+ return await db.many(`SELECT * FROM design_research WHERE owner=$1 AND status='done' ORDER BY created_at DESC, id DESC LIMIT $2`,
+ [owner, Math.max(1, Math.min(20, Number(limit) || 6))]);
+ }
+
+ async function byRun(runId) { return await db.maybe('SELECT * FROM design_research WHERE run_id=$1 ORDER BY created_at DESC LIMIT 1', [runId]); }
+
+ async function allDone({ limit = 50, owner = null } = {}) {
+ const size = Math.max(1, Math.min(200, Number(limit) || 50));
+ if (owner) return await db.many("SELECT * FROM design_research WHERE status='done' AND report IS NOT NULL AND owner=$1 ORDER BY created_at DESC, id DESC LIMIT $2", [owner, size]);
+ return await db.many("SELECT * FROM design_research WHERE status='done' AND report IS NOT NULL ORDER BY created_at DESC, id DESC LIMIT $1", [size]);
+ }
+
+ // ── Rules a report proposes ─────────────────────────────
+ async function proposalsFor(researchId) {
+ return await db.many('SELECT * FROM rule_proposals WHERE research_id=$1 ORDER BY created_at', [researchId]);
+ }
+
+ async function addProposals(research, items = []) {
+ const out = [];
+ for (const item of items) {
+ const rule = String(item && item.rule || '').trim().slice(0, 300);
+ if (!rule) continue;
+ const existing = await db.maybe("SELECT * FROM rule_proposals WHERE research_id=$1 AND lower(rule)=lower($2) AND state IN ('proposed','approved') LIMIT 1",
+ [research.id, rule]);
+ if (existing) { out.push(existing); continue; }
+ const id = s.newId('rpp');
+ const at = s.iso();
+ const row = await db.maybe(`INSERT INTO rule_proposals(id,research_id,owner,rule,detail,why,area,evidence,state,created_at,updated_at)
+ VALUES($1,$2,$3,$4,$5,$6,$7,$8::jsonb,'proposed',$9::timestamptz,$9::timestamptz) RETURNING *`,
+ [id, research.id, research.owner, rule, String(item.detail || '').slice(0, 1500), String(item.why || '').slice(0, 1500),
+ String(item.area || 'products').slice(0, 40),
+ JSON.stringify(Array.isArray(item.evidence) ? item.evidence.slice(0, 8) : []), at]);
+ if (row) out.push(row);
+ }
+ return out;
+ }
+
+ async function listProposals({ state = null, limit = 100 } = {}) {
+ const size = Math.max(1, Math.min(300, Number(limit) || 100));
+ if (state) return await db.many('SELECT * FROM rule_proposals WHERE state=$1 ORDER BY created_at DESC LIMIT $2', [state, size]);
+ return await db.many('SELECT * FROM rule_proposals ORDER BY CASE state WHEN \'proposed\' THEN 0 ELSE 1 END, created_at DESC LIMIT $1', [size]);
+ }
+
+ async function proposal(id) { return await db.maybe('SELECT * FROM rule_proposals WHERE id=$1', [id]); }
+
+ async function decideProposal(id, { state, by = null, prUrl = null, prBranch = null, prError = null }) {
+ const at = s.iso();
+ return await db.maybe(`UPDATE rule_proposals SET state=$2, decided_by=$3, decided_at=$4::timestamptz, pr_url=COALESCE($5,pr_url),
+ pr_branch=COALESCE($6,pr_branch), pr_error=$7, updated_at=$4::timestamptz WHERE id=$1 RETURNING *`,
+ [id, state, by, at, prUrl, prBranch, prError]);
+ }
+
+ async function publicProposal(row, { research = false } = {}) {
+ if (!row) return null;
+ const out = {
+ id: row.id, researchId: row.research_id, owner: row.owner, rule: row.rule, detail: row.detail, why: row.why,
+ area: row.area, evidence: Array.isArray(row.evidence) ? row.evidence : [], state: row.state,
+ prUrl: row.pr_url || null, prBranch: row.pr_branch || null, prError: row.pr_error || null,
+ decidedAt: row.decided_at || null, createdAt: row.created_at,
+ };
+ if (research) {
+ const r = await get(row.research_id);
+ out.research = r ? publicRow(r) : null;
+ }
+ return out;
+ }
+
+ return { create, get, owned, patch, list, recentDone, byRun, allDone, publicRow, proposalsFor, addProposals, listProposals, proposal, decideProposal, publicProposal };
+}
+
+module.exports = { createDeepDiveStore };
@@ -96,7 +96,7 @@ function editPrompt(instruction, html, names) {
@@ -96,7 +96,7 @@ function editPrompt(instruction, html, names) {
].join('\n\n');
].join('\n\n');
}
}
-function createDesigns({ s, ai, bench = null, catalog, config = {}, log = console, now = () => Date.now(), sharing = null }) {
+function createDesigns({ s, ai, bench = null, catalog, config = {}, log = console, now = () => Date.now(), sharing = null, deepDive = null }) {
const db = s.db;
const db = s.db;
const chatEditLimit = Math.max(1, Number(config.studio && config.studio.chatEditsPerDesign) || CHAT_EDITS_DEFAULT);
const chatEditLimit = Math.max(1, Number(config.studio && config.studio.chatEditsPerDesign) || CHAT_EDITS_DEFAULT);
const publicRow = d => {
const publicRow = d => {
@@ -105,7 +105,7 @@ function createDesigns({ s, ai, bench = null, catalog, config = {}, log = consol
@@ -105,7 +105,7 @@ function createDesigns({ s, ai, bench = null, catalog, config = {}, log = consol
id: d.id, prompt: d.prompt, kind: d.kind, themeId: d.theme_id,
id: d.id, prompt: d.prompt, kind: d.kind, themeId: d.theme_id,
frame: d.frame || null, look: d.look || d.theme_id || null,
frame: d.frame || null, look: d.look || d.theme_id || null,
model: names[0] || null, models: names,
model: names[0] || null, models: names,
- start: publicStart(d.start || {}), sourceDesignId: d.source_design_id || null,
+ start: publicStart(d.start || {}), sourceDesignId: d.source_design_id || null, researchId: d.research_id || null,
estimate: d.estimate || null,
estimate: d.estimate || null,
status: d.variants.every(v => v.status === 'done' || v.status === 'failed')
status: d.variants.every(v => v.status === 'done' || v.status === 'failed')
? d.variants.some(v => v.status === 'done') ? 'done' : 'failed' : 'running',
? d.variants.some(v => v.status === 'done') ? 'done' : 'failed' : 'running',
@@ -157,7 +157,7 @@ function createDesigns({ s, ai, bench = null, catalog, config = {}, log = consol
@@ -157,7 +157,7 @@ function createDesigns({ s, ai, bench = null, catalog, config = {}, log = consol
return await theme(d && d.theme_id);
return await theme(d && d.theme_id);
}
}
async function create({ prompt, kind = 'page', themeId, look = null, lookVariables = null, frame: frameId = null,
async function create({ prompt, kind = 'page', themeId, look = null, lookVariables = null, frame: frameId = null,
- models = null, takes = null, start = null, sourceDesignId = null, estimate = null,
+ models = null, takes = null, start = null, sourceDesignId = null, estimate = null, research = null,
owner = null, ip = '', who = 'claude' }) {
owner = null, ip = '', who = 'claude' }) {
prompt = String(prompt || '').trim();
prompt = String(prompt || '').trim();
const kindEntry = kindOf(kind);
const kindEntry = kindOf(kind);
@@ -219,21 +219,36 @@ function createDesigns({ s, ai, bench = null, catalog, config = {}, log = consol
@@ -219,21 +219,36 @@ function createDesigns({ s, ai, bench = null, catalog, config = {}, log = consol
source: slot.source || null, status: 'pending', html: '', ms: 0, costUsd: 0, error: null,
source: slot.source || null, status: 'pending', html: '', ms: 0, costUsd: 0, error: null,
paid_by: null, prefer: slot.prefer || null, pin: slot.pin === true }));
paid_by: null, prefer: slot.prefer || null, pin: slot.pin === true }));
const id = s.newId('dsn'), editKey = owner ? null : random();
const id = s.newId('dsn'), editKey = owner ? null : random();
+ // "Research first": start the dive now, on the person's behalf, and point the design at it. The takes
+ // wait for the report in run(); an attached dive is not run or charged again.
+ let researchId = null;
+ if (research) {
+ if (!deepDive) throw fail(503, 'design.research_unavailable', 'Deep dives are not available here.');
+ if (research.mode === 'attach' && research.id) {
+ researchId = String(research.id);
+ } else if (owner) {
+ const started = await deepDive.create({ owner, question: research.question || prompt, depth: research.depth,
+ sites: research.sites || [], targetId: id });
+ researchId = started.research.id;
+ } else {
+ throw fail(401, 'design.research_sign_in', 'Sign in to start a deep dive for this design.');
+ }
+ }
const startRecord = start && typeof start === 'object' && !Array.isArray(start) ? start : {};
const startRecord = start && typeof start === 'object' && !Array.isArray(start) ? start : {};
const thumbnail = makeThumbnail({ kind, kindName: kindEntry.name, prompt,
const thumbnail = makeThumbnail({ kind, kindName: kindEntry.name, prompt,
model: variants[0] ? (variants[0].name || variants[0].model) : null, variables: selected.variables || {} });
model: variants[0] ? (variants[0].name || variants[0].model) : null, variables: selected.variables || {} });
await db.exec(`INSERT INTO designs(id,prompt,kind,theme_id,variants,owner,guest_hash,guest_ip,created_at,updated_at,
await db.exec(`INSERT INTO designs(id,prompt,kind,theme_id,variants,owner,guest_hash,guest_ip,created_at,updated_at,
- frame,look,look_variables,models,start,source_design_id,estimate,thumbnail_svg)
- VALUES($1,$2,$3,$4,$5::jsonb,$6,$7,$8,$9::timestamptz,$9::timestamptz,$10,$11,$12::jsonb,$13::jsonb,$14::jsonb,$15,$16::jsonb,$17)`,
+ frame,look,look_variables,models,start,source_design_id,estimate,thumbnail_svg,research_id)
+ VALUES($1,$2,$3,$4,$5::jsonb,$6,$7,$8,$9::timestamptz,$9::timestamptz,$10,$11,$12::jsonb,$13::jsonb,$14::jsonb,$15,$16::jsonb,$17,$18)`,
[id, prompt, kind, selected.id, JSON.stringify(variants), owner, editKey ? hash(editKey).toString('hex') : null,
[id, prompt, kind, selected.id, JSON.stringify(variants), owner, editKey ? hash(editKey).toString('hex') : null,
owner ? null : ip, s.iso(), frame, lookId || selected.id, JSON.stringify(selected.variables || {}),
owner ? null : ip, s.iso(), frame, lookId || selected.id, JSON.stringify(selected.variables || {}),
requested ? JSON.stringify(requested) : null, JSON.stringify(startRecord), sourceDesignId || null,
requested ? JSON.stringify(requested) : null, JSON.stringify(startRecord), sourceDesignId || null,
- estimate ? JSON.stringify(estimate) : null, thumbnail]);
+ estimate ? JSON.stringify(estimate) : null, thumbnail, researchId]);
// A signed-in maker's new design is Only me until its Share sheet says otherwise (a guest's is its key).
// A signed-in maker's new design is Only me until its Share sheet says otherwise (a guest's is its key).
if (sharing && owner) await sharing.register(id, owner).catch((err) => log.warn('[OpenVibe.Design] share row not registered:', err.message));
if (sharing && owner) await sharing.register(id, owner).catch((err) => log.warn('[OpenVibe.Design] share row not registered:', err.message));
run(id).catch(err => log.warn('[OpenVibe.Design] generation:', err.message));
run(id).catch(err => log.warn('[OpenVibe.Design] generation:', err.message));
return { design: publicRow({ id, prompt, kind, theme_id: selected.id, frame, look: lookId || selected.id,
return { design: publicRow({ id, prompt, kind, theme_id: selected.id, frame, look: lookId || selected.id,
- look_variables: selected.variables || {}, models: requested, start: startRecord, source_design_id: sourceDesignId || null,
+ look_variables: selected.variables || {}, models: requested, start: startRecord, source_design_id: sourceDesignId || null, research_id: researchId || null,
estimate, variants, created_at: s.iso(), updated_at: s.iso() }),
estimate, variants, created_at: s.iso(), updated_at: s.iso() }),
takes: variants.map(v => ({ id: v.id, slot: v.slot, provider: v.provider, model: v.model, modelId: v.model_id, name: v.name, status: v.status })),
takes: variants.map(v => ({ id: v.id, slot: v.slot, provider: v.provider, model: v.model, modelId: v.model_id, name: v.name, status: v.status })),
editKey, editUrl: editKey ? `/d/${id}?key=${encodeURIComponent(editKey)}` : null };
editKey, editUrl: editKey ? `/d/${id}?key=${encodeURIComponent(editKey)}` : null };
@@ -251,7 +266,21 @@ function createDesigns({ s, ai, bench = null, catalog, config = {}, log = consol
@@ -251,7 +266,21 @@ function createDesigns({ s, ai, bench = null, catalog, config = {}, log = consol
await updateVariant(id, index, running);
await updateVariant(id, index, running);
try {
try {
const t = await lookTheme(d);
const t = await lookTheme(d);
- const prompt = [themePrompt(d.prompt, Object.keys(t.variables || {})),
+ // The dive's report is part of the brief for every take. A dive that is still running or failed
+ // does not hold the design forever: the takes are drawn without it and the canvas says so.
+ let report = null;
+ if (d.research_id && deepDive) {
+ try {
+ const research = await deepDive.wait(d.research_id, { timeoutMs: Number(config.deepDive && config.deepDive.maxWaitMs) || 15 * 60_000 });
+ if (research && research.status === 'done') {
+ report = { brief: String(research.brief || '').slice(0, 12000), points: Array.isArray(research.points) ? research.points : [], url: research.report_url || null };
+ }
+ } catch (err) { log.warn('[OpenVibe.Design] design research:', err.message); }
+ }
+ const points = report && deepDive ? deepDive.pointsForTake(report.points, index, d.variants.length) : [];
+ const withResearch = report && report.brief ? `${d.prompt}\n\n${report.brief}` : d.prompt;
+ const prompt = [themePrompt(withResearch, Object.keys(t.variables || {})),
+ report && deepDive ? deepDive.pointLine(points, report) : '',
instructionFor(d.kind, d.frame), startPrompt(d.start || {})].filter(Boolean).join('\n\n');
instructionFor(d.kind, d.frame), startPrompt(d.start || {})].filter(Boolean).join('\n\n');
const out = await ai.generate({ prompt, category: categoryFor(d.kind),
const out = await ai.generate({ prompt, category: categoryFor(d.kind),
slot: { provider: v.provider, model: v.model }, roundId: id, index,
slot: { provider: v.provider, model: v.model }, roundId: id, index,
@@ -260,7 +289,7 @@ function createDesigns({ s, ai, bench = null, catalog, config = {}, log = consol
@@ -260,7 +289,7 @@ function createDesigns({ s, ai, bench = null, catalog, config = {}, log = consol
const currentTheme = await lookTheme(current);
const currentTheme = await lookTheme(current);
await updateVariant(id, index, { ...running, status: 'done', html: wrap(out.html, currentTheme.variables || {}),
await updateVariant(id, index, { ...running, status: 'done', html: wrap(out.html, currentTheme.variables || {}),
provider: out.provider, model: out.model, ms: out.ms, costUsd: out.cost_usd, error: null,
provider: out.provider, model: out.model, ms: out.ms, costUsd: out.cost_usd, error: null,
- paid_by: out.paid_by || null, prefer: out.prefer || v.prefer || null });
+ research_points: points, paid_by: out.paid_by || null, prefer: out.prefer || v.prefer || null });
} catch (err) {
} catch (err) {
await updateVariant(id, index, { ...running, status: 'failed', error: String(err.message).slice(0, 300) });
await updateVariant(id, index, { ...running, status: 'failed', error: String(err.message).slice(0, 300) });
}
}
@@ -400,6 +429,9 @@ function createDesigns({ s, ai, bench = null, catalog, config = {}, log = consol
@@ -400,6 +429,9 @@ function createDesigns({ s, ai, bench = null, catalog, config = {}, log = consol
async function read(id, owner, key, viewer = null) {
async function read(id, owner, key, viewer = null) {
const d = await row(id, owner, key, viewer), selected = await lookTheme(d);
const d = await row(id, owner, key, viewer), selected = await lookTheme(d);
+ const research = d.research_id && deepDive
+ ? await (owner && d.owner === owner ? deepDive.get(d.research_id, owner) : deepDive.summary(d.research_id)).catch(() => null)
+ : null;
const p = await pickRow(id);
const p = await pickRow(id);
const versions = p ? await db.many('SELECT * FROM design_versions WHERE design_id=$1 ORDER BY seq', [id]) : [];
const versions = p ? await db.many('SELECT * FROM design_versions WHERE design_id=$1 ORDER BY seq', [id]) : [];
const current = p ? versions.find(v => Number(v.seq) === Number(p.current_seq)) : null;
const current = p ? versions.find(v => Number(v.seq) === Number(p.current_seq)) : null;
@@ -412,7 +444,7 @@ function createDesigns({ s, ai, bench = null, catalog, config = {}, log = consol
@@ -412,7 +444,7 @@ function createDesigns({ s, ai, bench = null, catalog, config = {}, log = consol
}
}
return v.status === 'done' ? { ...v, html: wrap(v.html, selected.variables || {}) } : v;
return v.status === 'done' ? { ...v, html: wrap(v.html, selected.variables || {}) } : v;
});
});
- return { design: publicRow(d), variants, pick: pickPublic(p, current), versions: versions.map(v => versionPublic(v, p ? p.current_seq : 1)) };
+ return { design: { ...publicRow(d), research }, variants, pick: pickPublic(p, current), versions: versions.map(v => versionPublic(v, p ? p.current_seq : 1)) };
}
}
async function pick({ id, variantIndex, variantId, owner, key, subject = null, token = null, viewer = null }) {
async function pick({ id, variantIndex, variantId, owner, key, subject = null, token = null, viewer = null }) {
@@ -121,7 +121,32 @@ function createPageRoutes(ctx) {
@@ -121,7 +121,32 @@ function createPageRoutes(ctx) {
// What can be made, in which sizes and looks, by which models at what price (the same answer /api/create/options
// What can be made, in which sizes and looks, by which models at what price (the same answer /api/create/options
// gives). Without it the composer shows the themes and the four ways, and posts the old form.
// gives). Without it the composer shows the themes and the four ways, and posts the old form.
const options = ctx.createOptions ? await ctx.createOptions.options({ token: tokenOf(req) }).then((all) => ctx.createOptions.publicOptions(all), () => null) : null;
const options = ctx.createOptions ? await ctx.createOptions.options({ token: tokenOf(req) }).then((all) => ctx.createOptions.publicOptions(all), () => null) : null;
- return page(res, 200, designView.create({ recent, themes, options }), req.viewer, { cacheControl: 'private, no-store' });
+ // "Research first": the depths and their price, and the person's finished dives to attach. A failure
+ // leaves the module showing the link to OpenVibe Exchange rather than failing the page.
+ const signedIn = Boolean(subjectOf(req));
+ const diveOptions = ctx.deepDive ? await ctx.deepDive.options().catch(() => null) : null;
+ const dives = signedIn && ctx.deepDive ? await ctx.deepDive.list(subjectOf(req), { limit: 8 }).catch(() => []) : [];
+ return page(res, 200, designView.create({ recent, themes, options,
+ research: { options: diveOptions, recent: dives.filter((x) => x.status === 'done' || x.status === 'running'), signedIn } }), req.viewer, { cacheControl: 'private, no-store' });
+ });
+ // One deep dive's report, read on Design: the numbered points with their evidence and the rules it proposes.
+ // It is the person's own dive, so only the person who started or attached it can open it.
+ r.get('/research/:id/crop/:cropId', async (req, res) => {
+ if (!subjectOf(req)) return res.status(401).type('text/plain').send('Sign in to see this deep dive.');
+ const research = await ctx.deepDive.get(req.params.id, subjectOf(req)).catch(() => null);
+ if (!research || !research.runId) return res.status(404).type('text/plain').send('Not found.');
+ try {
+ const out = await ctx.deepDiveAi.crop(research.runId, req.params.cropId);
+ return res.set({ 'Content-Type': out.contentType, 'Cache-Control': 'private, max-age=86400', 'X-Content-Type-Options': 'nosniff' }).send(out.body);
+ } catch (err) {
+ return res.status(err.status || 502).type('text/plain').send(err.detail || 'The crop could not be read.');
+ }
+ });
+ r.get('/research/:id', async (req, res) => {
+ if (!subjectOf(req)) return res.redirect(303, `/auth/login?next=${encodeURIComponent(req.originalUrl)}`);
+ const research = await ctx.deepDive.get(req.params.id, subjectOf(req)).catch(() => null);
+ if (!research) return page(res, 404, views.notFound(), req.viewer);
+ return page(res, 200, require('../views/deepdive').researchPage({ research, viewer: req.viewer }), req.viewer, { cacheControl: 'private, no-store' });
});
});
r.post('/create', express.urlencoded({ extended: false, limit: '8kb' }), async (req, res) => {
r.post('/create', express.urlencoded({ extended: false, limit: '8kb' }), async (req, res) => {
if (!writeAllowed(req, config)) return res.status(403).send('Open this form on openvibe.design.');
if (!writeAllowed(req, config)) return res.status(403).send('Open this form on openvibe.design.');
@@ -241,7 +266,30 @@ function createPageRoutes(ctx) {
@@ -241,7 +266,30 @@ function createPageRoutes(ctx) {
// The decision register and the system map.
// The decision register and the system map.
const rulesView = require('../views/rules');
const rulesView = require('../views/rules');
- r.get('/rules', (req, res) => page(res, 200, rulesView.rulesPage(), req.viewer));
+ // "Proposed from research": only the owner sees the rules a deep dive proposes, and only the owner decides.
+ // An approval opens a pull request on the register; the register itself is never written by this page.
+ const ruleNotice = (req) => ({ notice: req.query.notice ? String(req.query.notice).slice(0, 300) : null,
+ error: req.query.error ? String(req.query.error).slice(0, 300) : null });
+ r.get('/rules', async (req, res) => {
+ const owner = Boolean(ctx.proposals && ctx.proposals.isOwner(req.viewer));
+ const proposals = owner ? await ctx.proposals.list({ state: null }).catch((err) => { log.warn('[OpenVibe.Design] rule proposals:', err.message); return []; }) : [];
+ const { notice, error } = ruleNotice(req);
+ return page(res, 200, rulesView.rulesPage({ proposals, owner, notice, error }), req.viewer);
+ });
+ for (const action of ['approve', 'dismiss']) {
+ r.post(`/rules/proposals/:id/${action}`, express.urlencoded({ extended: false, limit: '4kb' }), async (req, res) => {
+ if (!writeAllowed(req, config)) return res.status(403).send('Approve a rule from openvibe.design itself.');
+ try {
+ const out = await ctx.proposals.decide(req.params.id, action, req.viewer);
+ const q = action === 'approve' && out.pull && out.pull.url
+ ? `notice=${encodeURIComponent(`Approved: the pull request is open at ${out.pull.url}`)}`
+ : `notice=${encodeURIComponent(action === 'dismiss' ? 'Dismissed.' : 'Approved.')}`;
+ return res.redirect(303, `/rules?${q}#proposal-${encodeURIComponent(req.params.id)}`);
+ } catch (err) {
+ return res.redirect(303, `/rules?error=${encodeURIComponent(String(err.detail || err.message || 'That decision could not be made.').slice(0, 300))}#proposal-${encodeURIComponent(req.params.id)}`);
+ }
+ });
+ }
// Every openvibe-shared release, read from the pinned package (server/changes.js).
// Every openvibe-shared release, read from the pinned package (server/changes.js).
r.get('/changes/:version', (req, res) => {
r.get('/changes/:version', (req, res) => {
const html = changesView.releasePage(req.params.version);
const html = changesView.releasePage(req.params.version);
@@ -311,7 +359,8 @@ function createPageRoutes(ctx) {
@@ -311,7 +359,8 @@ function createPageRoutes(ctx) {
if (note) return page(res, 200, note, req.viewer);
if (note) return page(res, 200, note, req.viewer);
try {
try {
const entry = await registry.get(req.params.name);
const entry = await registry.get(req.params.name);
- return entry ? page(res, 200, componentsListView.componentEntryPage(entry), req.viewer)
+ const findings = entry && ctx.deepDive && subjectOf(req) ? await ctx.deepDive.finds(entry.name, { limit: 6, owner: subjectOf(req) }).catch(() => null) : null;
+ return entry ? page(res, 200, componentsListView.componentEntryPage(entry, { findings }), req.viewer)
: page(res, 404, views.notFound(), req.viewer);
: page(res, 404, views.notFound(), req.viewer);
} catch (err) {
} catch (err) {
log.error('[OpenVibe.Design] component registry entry failed:', (err && err.message) || err);
log.error('[OpenVibe.Design] component registry entry failed:', (err && err.message) || err);
@@ -14,7 +14,10 @@ const TABLES = [
@@ -14,7 +14,10 @@ const TABLES = [
{ table: 'arena_rounds', subject: 'created_by', file: 'arena_rounds.json', erase: 'delete' },
{ table: 'arena_rounds', subject: 'created_by', file: 'arena_rounds.json', erase: 'delete' },
{ table: 'arena_votes', subject: 'voter_subject', file: 'arena_votes.json', erase: 'delete' },
{ table: 'arena_votes', subject: 'voter_subject', file: 'arena_votes.json', erase: 'delete' },
// The designs a signed-in person asked for; a guest's design has no owner and matches nobody. Versions cascade.
// The designs a signed-in person asked for; a guest's design has no owner and matches nobody. Versions cascade.
- { table: 'designs', subject: 'owner', file: 'designs.json', order: 'created_at', columns: ['id', 'kind', 'prompt', 'theme_id', 'variants', 'frame', 'look', 'look_variables', 'models', 'start', 'source_design_id', 'estimate', 'thumbnail_svg', 'space_url', 'published_at', 'created_at', 'updated_at'] },
+ { table: 'designs', subject: 'owner', file: 'designs.json', order: 'created_at', columns: ['id', 'kind', 'prompt', 'theme_id', 'variants', 'frame', 'look', 'look_variables', 'models', 'start', 'source_design_id', 'research_id', 'estimate', 'thumbnail_svg', 'space_url', 'published_at', 'created_at', 'updated_at'] },
+ // The deep dives a person ran and the rules they proposed: their research, its report, and their decisions.
+ { table: 'design_research', subject: 'owner', file: 'deep_dives.json', order: 'created_at', columns: ['id', 'question', 'depth', 'sites', 'run_id', 'status', 'progress', 'report', 'points', 'brief', 'report_url', 'estimate', 'error', 'created_at', 'updated_at'], erase: 'delete' },
+ { table: 'rule_proposals', subject: 'owner', file: 'rule_proposals.json', order: 'created_at', columns: ['id', 'research_id', 'rule', 'detail', 'why', 'area', 'evidence', 'state', 'pr_url', 'pr_branch', 'decided_at', 'created_at', 'updated_at'], erase: 'delete' },
// The icons a person asked OpenVibe.AI to draw (/icons). Who reviewed one is not theirs to export. An approved
// The icons a person asked OpenVibe.AI to draw (/icons). Who reviewed one is not theirs to export. An approved
// drawing already taken into openvibe-shared stays there: it is a drawing, it names nobody.
// drawing already taken into openvibe-shared stays there: it is a drawing, it names nobody.
// The marks a signed-in person picked on /brand/explore (a guest's picks name nobody).
// The marks a signed-in person picked on /brand/explore (a guest's picks name nobody).
@@ -46,6 +46,7 @@ async function start() {
@@ -46,6 +46,7 @@ async function start() {
// The icon and theme pages in OpenVibe.Search: a sweep a minute after boot and every six hours.
// The icon and theme pages in OpenVibe.Search: a sweep a minute after boot and every six hours.
if (!config.drill) ctx.searchIndex.start();
if (!config.drill) ctx.searchIndex.start();
ctx.designs.resume().catch((err) => console.warn('[OpenVibe.Design] resume designs:', err.message));
ctx.designs.resume().catch((err) => console.warn('[OpenVibe.Design] resume designs:', err.message));
+ if (ctx.deepDive) ctx.deepDive.resume().catch((err) => console.warn('[OpenVibe.Design] resume deep dives:', err.message));
});
});
server.keepAliveTimeout = 65_000;
server.keepAliveTimeout = 65_000;
createLifecycle({ server, ctx });
createLifecycle({ server, ctx });
@@ -11,6 +11,7 @@
@@ -11,6 +11,7 @@
const kit = require('openvibe-shared/kit');
const kit = require('openvibe-shared/kit');
const { layout, esc } = require('../render/layout');
const { layout, esc } = require('../render/layout');
const { RULES } = require('../rules/register');
const { RULES } = require('../rules/register');
+const deepDiveView = require('./deepdive');
const FLOAT_RULE = RULES.find((r) => r.id === 'pip-goes-anywhere');
const FLOAT_RULE = RULES.find((r) => r.id === 'pip-goes-anywhere');
/** What each component is for. A name not listed here lands in Other, so a new kit part is never dropped. */
/** What each component is for. A name not listed here lands in Other, so a new kit part is never dropped. */
@@ -118,7 +119,7 @@ function floatDemo() {
@@ -118,7 +119,7 @@ function floatDemo() {
}
}
/** /components/<name>: one entry, laid out like the designed interface pages. */
/** /components/<name>: one entry, laid out like the designed interface pages. */
-function componentEntryPage(entry) {
+function componentEntryPage(entry, { findings = null } = {}) {
const c = entry.definition || {};
const c = entry.definition || {};
const group = GROUPS.find(([id]) => id === GROUP_OF.get(entry.name));
const group = GROUPS.find(([id]) => id === GROUP_OF.get(entry.name));
const p = c.platforms || {}, web = p.web || {}, android = p.android || {}, ios = p.ios || {};
const p = c.platforms || {}, web = p.web || {}, android = p.android || {}, ios = p.ios || {};
@@ -128,6 +129,7 @@ function componentEntryPage(entry) {
@@ -128,6 +129,7 @@ function componentEntryPage(entry) {
<header class="ds-page-h"><p class="ds-kicker"><a href="/components${group ? `#crl-${group[0]}` : ''}">${esc(group ? group[1] : 'Component registry')}</a></p><h1>${esc(c.title || entry.name)}</h1><p class="ds-lede">${esc(c.summary || '')}</p>
<header class="ds-page-h"><p class="ds-kicker"><a href="/components${group ? `#crl-${group[0]}` : ''}">${esc(group ? group[1] : 'Component registry')}</a></p><h1>${esc(c.title || entry.name)}</h1><p class="ds-lede">${esc(c.summary || '')}</p>
<p class="crl-where">${web.module ? `<code>${esc(web.module)}</code>` : ''}<a href="/api/v1/components/${encodeURIComponent(entry.name)}">JSON</a></p></header>
<p class="crl-where">${web.module ? `<code>${esc(web.module)}</code>` : ''}<a href="/api/v1/components/${encodeURIComponent(entry.name)}">JSON</a></p></header>
${entry.name === 'float' ? floatDemo() : ''}
${entry.name === 'float' ? floatDemo() : ''}
+${findings && findings.finds && findings.finds.length ? deepDiveView.componentFinds({ component: entry.name, finds: findings.finds, reports: findings.reports }) : ''}
${web.markup ? `<section class="crl-sec crl-build" aria-labelledby="crl-build-h"><h2 id="crl-build-h">Build it</h2>${kit.snippet({ code: String(web.markup), id: 'crl-build' })}</section>` : ''}
${web.markup ? `<section class="crl-sec crl-build" aria-labelledby="crl-build-h"><h2 id="crl-build-h">Build it</h2>${kit.snippet({ code: String(web.markup), id: 'crl-build' })}</section>` : ''}
<div class="cr-layout">
<div class="cr-layout">
<section class="crl-sec" aria-labelledby="crl-props-h"><h2 id="crl-props-h">Props</h2>${props.length ? `<dl class="cr-props">${props.map(([k, t]) => `<div><dt><code>${esc(k)}</code></dt><dd>${esc(t)}</dd></div>`).join('')}</dl>` : '<p class="crl-none">No props.</p>'}
<section class="crl-sec" aria-labelledby="crl-props-h"><h2 id="crl-props-h">Props</h2>${props.length ? `<dl class="cr-props">${props.map(([k, t]) => `<div><dt><code>${esc(k)}</code></dt><dd>${esc(t)}</dd></div>`).join('')}</dl>` : '<p class="crl-none">No props.</p>'}
@@ -148,7 +150,7 @@ ${examples(entry)}
@@ -148,7 +150,7 @@ ${examples(entry)}
description: c.summary || 'One design.component@1 entry.',
description: c.summary || 'One design.component@1 entry.',
path: `/components/${entry.name}`,
path: `/components/${entry.name}`,
active: '/components',
active: '/components',
- styles: ['css/component-registry.css'],
+ styles: ['css/component-registry.css', 'css/deepdive.css'],
shared: entry.name === 'float' ? ['ov-float.js'] : [],
shared: entry.name === 'float' ? ['ov-float.js'] : [],
scripts: entry.name === 'float' ? ['js/ov-float-demo.js'] : [],
scripts: entry.name === 'float' ? ['js/ov-float-demo.js'] : [],
body,
body,
new file mode 100644
new file mode 100644
@@ -0,0 +1,159 @@
@@ -0,0 +1,159 @@
+'use strict';
+
+/**
+ * The plain pages of "Research first": the composer's module, the canvas's report panel, one report read on
+ * Design, "How others do it" on a component page, and the owner's "Proposed from research" list. The look is
+ * the main session's; this is kit markup and the words the brief names, nothing restyled.
+ *
+ * A crop is commentary: it is shown small with its site, its address and the day it was read, and it is never
+ * pasted into a design. A reference token is a direction, never a palette to copy.
+ */
+const kit = require('openvibe-shared/kit');
+const { layout, esc } = require('../render/layout');
+
+const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
+const day = (iso) => { const t = Date.parse(iso); if (!isFinite(t)) return ''; const d = new Date(t); return `${d.getUTCDate()} ${MONTHS[d.getUTCMonth()]} ${d.getUTCFullYear()}`; };
+const usd = (n) => (n == null || !Number.isFinite(Number(n)) ? '' : Number(n) >= 1 ? `$${Number(n).toFixed(2)}` : Number(n) > 0 ? `${Math.max(1, Math.round(Number(n) * 100))}¢` : 'free');
+const publicUrl = (url) => { const s = String(url || ''); return /^\//.test(s) && !/^\/research\//.test(s) ? `https://openvibe.exchange${s}` : s; };
+// A report is written by another service: no link or image in it may carry a scheme we did not choose.
+const safeHref = (url) => { const s = String(url || ''); return /^(https?:\/\/|\/)/i.test(s) ? publicUrl(s) : null; };
+const safeImage = (url) => { const s = String(url || ''); return /^(https?:\/\/|\/|data:image\/)/i.test(s) ? (s.startsWith('data:') ? s : publicUrl(s)) : null; };
+const host = (url) => { try { return new URL(String(url)).hostname.replace(/^www\./, ''); } catch { return ''; } };
+const DEPTH_LINE = { quick: 'Six sites, about three minutes', thorough: 'Fifteen sites, a deeper read', expert: 'Thirty sites, plus interactions, accessibility and speed' };
+
+function cropSrc(e, research) {
+ if (e && e.crop) return publicUrl(e.crop);
+ if (e && e.cropId && research && research.id) return `/research/${encodeURIComponent(research.id)}/crop/${encodeURIComponent(e.cropId)}`;
+ return null;
+}
+function evidenceList(evidence, { crops = false, research = null } = {}) {
+ const list = Array.isArray(evidence) ? evidence : [];
+ if (!list.length) return '';
+ return `<ul class="dd-evidence">${list.map((e) => {
+ const site = e.site || host(e.url);
+ const label = site || (e.url ? 'Source' : 'Evidence');
+ const href = safeHref(e.url);
+ const link = href ? `<a href="${esc(href)}" rel="noopener">${esc(label)}</a>` : `<span>${esc(label)}</span>`;
+ const when = e.capturedAt ? ` · read ${esc(day(e.capturedAt) || String(e.capturedAt).slice(0, 10))}` : '';
+ const src = crops ? safeImage(cropSrc(e, research)) : null;
+ const crop = src ? `<img class="dd-crop" src="${esc(src)}" alt="A crop from ${esc(label)}" loading="lazy" decoding="async">` : '';
+ return `<li>${crop}<span class="dd-ev">${link}${when}${e.note ? ` · ${esc(e.note)}` : ''}</span></li>`;
+ }).join('')}</ul>`;
+}
+
+/** The composer's "Research first" module. `options` is GET /api/deep-dive/options. */
+function researchModule({ options = null, recent = [], signedIn = false } = {}) {
+ const o = options || {};
+ const depths = Array.isArray(o.depths) ? o.depths : [];
+ const priced = depths.some((d) => d.price_usd != null);
+ if (!signedIn) {
+ return `<details class="dv-mod" data-mod="research"><summary><span class="dv-mod-k">Research first</span><b>Deep dive</b></summary>
+<div class="dv-mod-sheet" role="group" aria-label="Research first"><header><b>Research first</b><small>An expert design research run: we search everywhere, capture the best sites at phone and computer sizes, and tell you what works and why. It goes into the brief for every take.</small></header>
+<p>${kit.button({ href: '/auth/login?next=%2Fcreate', label: 'Sign in to start a deep dive', kind: 'quiet', icon: 'spark' })}</p>
+<p class="dv-fine">A deep dive is priced on OpenVibe Exchange and held on your Balance before it runs. <a href="https://openvibe.exchange/deep-dive">Read about deep dives</a>.</p></div></details>`;
+ }
+ const depthRows = depths.length ? depths.map((d, i) => `<label class="dv-opt"><input type="radio" name="research_depth" value="${esc(d.id)}" data-label="${esc(d.name || d.id)}"${d.id === 'thorough' || (!i && !depths.some((x) => x.id === 'thorough')) ? ' checked' : ''}><span><b>${esc(d.name || d.id)}</b><small>${esc(d.line || DEPTH_LINE[d.id] || '')}${d.price_usd != null ? ` · ${usd(d.price_usd)}` : ''}${d.sites ? ` · ${Number(d.sites)} sites` : ''}</small></span></label>`).join('')
+ : `<p class="dv-fine">The depths are not readable right now. <a href="https://openvibe.exchange/deep-dive">Start a deep dive on OpenVibe Exchange</a>, then attach it here.</p>`;
+ const done = (recent || []).filter((r) => r && r.id);
+ const recentRows = done.length ? `<label class="dv-start"><span>Or attach a dive you already ran</span><select name="research_id"><option value="">None</option>${done.map((r) => `<option value="${esc(r.id)}">${esc(String(r.question || 'Deep dive').slice(0, 80))}${r.status === 'running' ? ' (still running)' : ''} · ${esc(day(r.createdAt))}</option>`).join('')}</select></label>` : '';
+ return `<details class="dv-mod" data-mod="research"><summary><svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3.5l1.9 5.1 5.1 1.9-5.1 1.9L12 17.5l-1.9-5.1L5 10.5l5.1-1.9z"/></svg><span class="dv-mod-k">Research first</span><b data-mod-v>${priced ? 'Off' : 'Deep dive'}</b></summary>
+<div class="dv-mod-sheet" role="group" aria-label="Research first"><header><b>Research first</b><small>A deep dive's synthesis (patterns, do and don't, reference tokens as inspiration, the rules it cites) becomes part of the generation brief. Each take says which research points it follows. A site's assets and code are never copied.</small></header>
+<label class="dv-opt dv-opt--check"><input type="checkbox" name="research" value="new" data-research="on"><span><b>Start a deep dive for this design</b><small>The takes wait for its report, then are drawn from it.</small></span></label>
+<div class="dv-deep" data-research-new hidden>
+${depthRows}
+${priced ? '' : '<p class="dv-fine">The price is shown before the dive starts on <a href="https://openvibe.exchange/deep-dive">OpenVibe Exchange</a>.</p>'}
+</div>
+<div class="dv-deep" data-research-attach>
+${recentRows}
+<label class="dv-start"><span>Or paste a report link or run id</span><input type="text" name="research_ref" autocomplete="off" maxlength="2000" placeholder="https://openvibe.exchange/deep-dive/…"></label>
+</div>
+<p class="dv-fine">A dive is a separate priced run on OpenVibe.AI; its report stays readable on OpenVibe Exchange and at <span class="dd-inline">/research/<id></span> here. Research informs; it never copies a site's assets or code.</p></div></details>`;
+}
+
+/** The canvas's research panel: the live steps, then the numbered points the takes follow. */
+function researchPanel(research) {
+ if (!research || !research.id) return '';
+ const steps = (research.progress && Array.isArray(research.progress.steps)) ? research.progress.steps : [];
+ const points = Array.isArray(research.points) ? research.points : [];
+ const running = research.status === 'starting' || research.status === 'running';
+ const stepList = steps.length ? `<ol class="dd-steps">${steps.map((s) => `<li class="dd-step is-${esc(s.state || 'waiting')}"><span>${esc(s.name)}</span></li>`).join('')}</ol>` : '';
+ const shown = points.slice(0, 6);
+ const pointsList = shown.length ? `<ol class="dd-points">${shown.map((p) => `<li id="p${esc(String(p.n))}"><a href="${esc(research.url)}#p${esc(String(p.n))}"><span class="dd-n">${esc(String(p.n))}</span>${esc(p.text)}</a></li>`).join('')}</ol>${points.length > shown.length ? `<p class="dv-fine"><a href="${esc(research.url)}">All ${points.length} research points on the report</a></p>` : ''}` : '';
+ const stateKey = `${research.status}|${steps.map((s) => s.state).join(',')}`;
+ return `<section class="dv-research" data-dv-research="${esc(research.id)}" data-dd-state="${esc(stateKey)}" aria-labelledby="dv-research-h">
+<h2 id="dv-research-h">Research first${running ? ' · working' : research.status === 'done' ? ' · its report' : ''}</h2>
+<p class="dv-research-q">${esc(research.question || 'A deep dive')} · ${esc(research.depth || 'thorough')}${research.priceUsd != null ? ` · ${esc(usd(research.priceUsd))}` : ''}${research.status === 'done' ? ` · ${points.length} points` : ''}</p>
+${stepList}
+${research.status === 'failed' ? `<p class="dv-fine is-error">The deep dive did not finish${research.error ? `: ${esc(research.error)}` : ''}. The takes are drawn without it.</p>` : ''}
+${running ? `<p class="dv-fine">The takes wait for the report. <a href="${esc(research.url)}">Follow it</a>.</p>` : research.reportUrl && safeHref(research.reportUrl) ? `<p class="dv-links"><a href="${esc(research.url)}">Read the report on Design</a> · <a href="${esc(safeHref(research.reportUrl))}" rel="noopener">On OpenVibe Exchange</a></p>` : `<p class="dv-links"><a href="${esc(research.url)}">Read the report</a></p>`}
+${pointsList}</section>`;
+}
+
+/** The chips on one take: the points it was asked to follow, linked to the report. */
+function takePoints(points, research) {
+ if (!Array.isArray(points) || !points.length) return '';
+ const base = research && research.url ? research.url : '#';
+ return `<p class="dv-take-research">Follows ${points.map((p) => `<a href="${esc(base)}#p${esc(String(p.n))}" title="${esc(String(p.text || '').slice(0, 240))}">research point ${esc(String(p.n))}</a>`).join(', ')}</p>`;
+}
+
+/** One report, read on Design: the numbered points with their evidence, and the rules it proposes. */
+function researchPage({ research, viewer = null, notice = null, error = null } = {}) {
+ const r = research || {};
+ const points = Array.isArray(r.points) ? r.points : [];
+ const groups = [...new Set(points.map((p) => p.group))];
+ const steps = (r.progress && Array.isArray(r.progress.steps)) ? r.progress.steps : [];
+ const proposals = Array.isArray(r.proposals) ? r.proposals : [];
+ const running = r.status === 'starting' || r.status === 'running';
+ const body = `<div class="ds-wrap tk dd-page">
+<header class="ds-page-h"><p class="ds-kicker">Research first</p><h1>${esc(r.question || 'A deep dive')}</h1>
+<p class="ds-lede">A ${esc(r.depth || 'thorough')} deep dive${r.priceUsd != null ? ` (${esc(usd(r.priceUsd))})` : ''}${r.createdAt ? `, started ${esc(day(r.createdAt))}` : ''}; the takes on its design follow the numbered points below. ${safeHref(r.reportUrl) ? `<a href="${esc(safeHref(r.reportUrl))}" rel="noopener">The run on OpenVibe Exchange</a>.` : ''}</p>
+<p class="dd-status">${running ? 'Working' : r.status === 'done' ? `Done · ${points.length} research points` : r.status === 'failed' ? 'Did not finish' : esc(r.status || '')}${r.error ? ` · ${esc(r.error)}` : ''}</p>
+${notice ? `<p class="dd-notice">${esc(notice)}</p>` : ''}${error ? `<p class="dd-notice is-error">${esc(error)}</p>` : ''}
+${steps.length ? `<ol class="dd-steps">${steps.map((s) => `<li class="dd-step is-${esc(s.state || 'waiting')}"><span>${esc(s.name)}</span></li>`).join('')}</ol>` : ''}
+</header>
+${points.length ? `<section class="dd-sec" aria-labelledby="dd-points-h"><h2 id="dd-points-h">The research points</h2>
+${groups.map((g) => `<h3>${esc(g === "don't" ? 'Do not' : g === 'do' ? 'Do' : g === 'reference token' ? 'Reference tokens, as inspiration' : g)}</h3><ol class="dd-points dd-points--page">${points.filter((p) => p.group === g).map((p) => `<li id="p${esc(String(p.n))}"><span class="dd-n">${esc(String(p.n))}</span><div><p>${esc(p.text)}</p>${evidenceList(p.evidence, { crops: true, research: r })}</div></li>`).join('')}</ol>`).join('')}
+</section>` : running ? kit.empty({ icon: 'spark', title: 'Still researching', text: 'The points appear here as the dive writes them up.' }) : kit.empty({ icon: 'search', title: 'No report yet', text: 'This deep dive has not produced a report.' })}
+${proposals.length ? `<section class="dd-sec" aria-labelledby="dd-props-h"><h2 id="dd-props-h">Proposed from research</h2>${proposalsList(proposals, { compact: true })}</section>` : ''}
+<p class="dd-back"><a href="${r.designId ? `/d/${encodeURIComponent(r.designId)}` : '/create'}">${r.designId ? 'Back to the design' : 'Create a design'}</a></p>
+</div>`;
+ return layout({ title: `${String(r.question || 'A deep dive').slice(0, 70)} · Research`, description: 'A deep dive report on OpenVibe.Design: patterns, do and don\'t, reference tokens and the rules it cites, with the evidence behind each point.', path: `/research/${r.id || ''}`, robots: 'noindex, nofollow', styles: ['css/deepdive.css'], body });
+}
+
+/** "How others do it" on a component page. Empty and absent until a finished dive has a point for it. */
+function componentFinds({ component, finds = [], reports = 0 } = {}) {
+ if (!Array.isArray(finds) || !finds.length) return '';
+ return `<section class="crl-sec dd-finds" id="how-others" aria-labelledby="dd-finds-h"><h2 id="dd-finds-h">How others do it</h2>
+<p>From ${reports === 1 ? 'a deep dive' : `${reports} deep dives`} on this component. Each crop is commentary: it is shown with its site and the day it was read, and is never copied into a design.</p>
+<ul class="dd-find-list">${finds.map((f) => `<li class="dd-find">
+${safeImage(cropSrc({ crop: f.crop, cropId: f.cropId }, { id: f.researchId })) ? `<a href="${esc(safeHref(f.url) || safeImage(cropSrc({ crop: f.crop, cropId: f.cropId }, { id: f.researchId })))}" rel="noopener"><img class="dd-crop" src="${esc(safeImage(cropSrc({ crop: f.crop, cropId: f.cropId }, { id: f.researchId })))}" alt="A crop from ${esc(f.site || host(f.url) || 'a reference site')}" loading="lazy" decoding="async"></a>` : ''}
+<div><b>${esc(f.title || 'A finding')}</b>
+${f.site || f.capturedAt || f.url ? `<p class="dd-attrib">${esc(f.site || host(f.url) || 'Reference')}${f.capturedAt ? ` · read ${esc(day(f.capturedAt) || String(f.capturedAt).slice(0, 10))}` : ''}${safeHref(f.url) ? ` · <a href="${esc(safeHref(f.url))}" rel="noopener">the site</a>` : ''}${f.n ? ` · <a href="${esc(f.researchUrl)}#p${esc(String(f.n))}">research point ${esc(String(f.n))}</a>` : f.researchUrl ? ` · <a href="${esc(f.researchUrl)}">the report</a>` : ''}</p>` : ''}
+</div></li>`).join('')}</ul></section>`;
+}
+
+function proposalsList(proposals, { owner = false, compact = false } = {}) {
+ const list = Array.isArray(proposals) ? proposals : [];
+ if (!list.length) return compact ? '' : kit.empty({ icon: 'spark', title: 'No proposed rules', text: 'A deep dive that proposes a rule for the register shows it here. Nothing is added by itself.' });
+ return `<div class="dd-props">${list.map((p) => `<article class="dd-prop" id="proposal-${esc(p.id)}">
+<header><span class="dd-prop-state is-${esc(p.state)}">${p.state === 'approved' ? 'Approved' : p.state === 'dismissed' ? 'Dismissed' : 'Proposed'}</span><h3>${esc(p.rule)}</h3></header>
+${p.detail ? `<p>${esc(p.detail)}</p>` : ''}
+${p.why ? `<p class="dd-why"><span>Why</span> ${esc(p.why)}</p>` : ''}
+${p.evidence && p.evidence.length ? evidenceList(p.evidence, { crops: true, research: p.research }) : ''}
+<p class="dd-attrib">${p.research ? `From <a href="${esc(p.research.url)}">${esc(p.research.question || 'a deep dive')}</a>` : 'From a deep dive'} · area: ${esc(p.area || 'products')}</p>
+${safeHref(p.prUrl) ? `<p class="dd-pr"><a href="${esc(safeHref(p.prUrl))}" rel="noopener">The pull request against the register</a></p>` : ''}
+${p.prError ? `<p class="dd-notice is-error">${esc(p.prError)}</p>` : ''}
+${owner && p.state === 'proposed' ? `<div class="dd-prop-acts">
+<form method="post" action="/rules/proposals/${esc(p.id)}/approve"><button type="submit" class="ovk-btn ovk-btn--primary">Approve and open a pull request</button></form>
+<form method="post" action="/rules/proposals/${esc(p.id)}/dismiss"><button type="submit" class="ovk-btn ovk-btn--quiet">Dismiss</button></form>
+</div>` : ''}
+</article>`).join('')}</div>`;
+}
+
+/** "Proposed from research" on /rules, for the owner only. */
+function proposalsSection(proposals) {
+ return `<section class="rl-sec dd-proposed" aria-labelledby="rl-proposed-h"><div class="rl-sec-head"><h2 id="rl-proposed-h">Proposed from research</h2><p>A deep dive can propose a rule for this register, with the evidence behind it. Only the owner decides; approval opens a pull request against <code>server/rules/register.js</code>. Nothing is added by itself.</p></div>
+${proposalsList(proposals, { owner: true })}</section>`;
+}
+
+module.exports = { researchModule, researchPanel, takePoints, researchPage, componentFinds, proposalsSection, proposalsList, evidenceList, day, usd, DEPTH_LINE };
@@ -4,6 +4,7 @@ const { layout, esc } = require('../render/layout');
@@ -4,6 +4,7 @@ const { layout, esc } = require('../render/layout');
const ovServe = require('openvibe-shared/serve');
const ovServe = require('openvibe-shared/serve');
const { attrEscape } = require('../arena/sanitize');
const { attrEscape } = require('../arena/sanitize');
const { themeCss } = require('../designs');
const { themeCss } = require('../designs');
+const deepDiveView = require('./deepdive');
// The composer's choices. The server takes kind, themeId and who (designs.create); each is a choice a person can see.
// The composer's choices. The server takes kind, themeId and who (designs.create); each is a choice a person can see.
const G = (d) => `<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">${d}</svg>`;
const G = (d) => `<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">${d}</svg>`;
@@ -79,7 +80,7 @@ const maker = (p) => { const k = String(p || '').toLowerCase(); return MAKER[k]
@@ -79,7 +80,7 @@ const maker = (p) => { const k = String(p || '').toLowerCase(); return MAKER[k]
* designer, and the form posts as it always did. With a script, the form is sent to POST /api/create and the cost is
* designer, and the form posts as it always did. With a script, the form is sent to POST /api/create and the cost is
* asked from POST /api/create/estimate as the choices change.
* asked from POST /api/create/estimate as the choices change.
*/
*/
-function create({ recent = [], themes = [], options = null } = {}) {
+function create({ recent = [], themes = [], options = null, research = null } = {}) {
const o = options || {};
const o = options || {};
const kinds = (o.kinds && o.kinds.length ? o.kinds : MAKES.slice(0, 3).map((m) => ({ id: m.id, name: m.name, frames: [] })))
const kinds = (o.kinds && o.kinds.length ? o.kinds : MAKES.slice(0, 3).map((m) => ({ id: m.id, name: m.name, frames: [] })))
.map((k) => ({ ...k, ...(MAKE[k.id] || { glyph: MAKES[0].glyph, placeholder: 'Describe what you need…', examples: [] }), name: k.name || (MAKE[k.id] || {}).name || k.id }));
.map((k) => ({ ...k, ...(MAKE[k.id] || { glyph: MAKES[0].glyph, placeholder: 'Describe what you need…', examples: [] }), name: k.name || (MAKE[k.id] || {}).name || k.id }));
@@ -108,6 +109,7 @@ ${models.length ? `<p class="dv-group-h">Or choose the models yourself<small>Up
@@ -108,6 +109,7 @@ ${models.length ? `<p class="dv-group-h">Or choose the models yourself<small>Up
<div class="dv-models" data-max="${Number(limits.models) || 4}">${modelRows}</div>
<div class="dv-models" data-max="${Number(limits.models) || 4}">${modelRows}</div>
<div class="dv-takes"><span><b>Takes from each</b><small>${Number(limits.takes_total) || 8} takes at most in one run</small></span><span class="dv-step"><button type="button" data-step="-1" aria-label="One take fewer">−</button><input type="number" name="takes" inputmode="numeric" min="${Number(takes.min) || 1}" max="${Number(takes.max) || 4}" value="${Number(takes.default) || 3}" data-total="${Number(limits.takes_total) || 8}" aria-label="Takes from each model"><button type="button" data-step="1" aria-label="One take more">+</button></span></div>` : ''}
<div class="dv-takes"><span><b>Takes from each</b><small>${Number(limits.takes_total) || 8} takes at most in one run</small></span><span class="dv-step"><button type="button" data-step="-1" aria-label="One take fewer">−</button><input type="number" name="takes" inputmode="numeric" min="${Number(takes.min) || 1}" max="${Number(takes.max) || 4}" value="${Number(takes.default) || 3}" data-total="${Number(limits.takes_total) || 8}" aria-label="Takes from each model"><button type="button" data-step="1" aria-label="One take more">+</button></span></div>` : ''}
<a class="dv-mod-more" href="https://openvibe.exchange/models">Compare these models on OpenVibe Exchange</a></div></details>`;
<a class="dv-mod-more" href="https://openvibe.exchange/models">Compare these models on OpenVibe Exchange</a></div></details>`;
+ const researchMod = deepDiveView.researchModule({ options: research && research.options, recent: research && research.recent, signedIn: Boolean(research && research.signedIn) });
const startMod = options ? `<details class="dv-mod" data-mod="start"><summary>${LINK_ICON}<span class="dv-mod-k">Start</span><b data-mod-v>Blank</b></summary>
const startMod = options ? `<details class="dv-mod" data-mod="start"><summary>${LINK_ICON}<span class="dv-mod-k">Start</span><b data-mod-v>Blank</b></summary>
<div class="dv-mod-sheet" role="group" aria-label="Start from"><header><b>Start from a page</b><small>Paste a public address and the takes begin from what that page says and how it is laid out. Leave it empty to start blank.</small></header>
<div class="dv-mod-sheet" role="group" aria-label="Start from"><header><b>Start from a page</b><small>Paste a public address and the takes begin from what that page says and how it is laid out. Leave it empty to start blank.</small></header>
<label class="dv-start"><span class="dv-visually-hidden">The page's address</span><input type="url" name="start_url" inputmode="url" autocomplete="off" placeholder="https://example.com/page" maxlength="2000"></label>
<label class="dv-start"><span class="dv-visually-hidden">The page's address</span><input type="url" name="start_url" inputmode="url" autocomplete="off" placeholder="https://example.com/page" maxlength="2000"></label>
@@ -119,12 +121,12 @@ ${models.length ? `<p class="dv-group-h">Or choose the models yourself<small>Up
@@ -119,12 +121,12 @@ ${models.length ? `<p class="dv-group-h">Or choose the models yourself<small>Up
${kindRow}
${kindRow}
<label class="dv-visually-hidden" for="dv-prompt">Describe your design</label>
<label class="dv-visually-hidden" for="dv-prompt">Describe your design</label>
<textarea id="dv-prompt" name="prompt" required maxlength="${Number(limits.prompt) || 4000}" placeholder="${esc((kinds[0] || MAKES[0]).placeholder)}"></textarea>
<textarea id="dv-prompt" name="prompt" required maxlength="${Number(limits.prompt) || 4000}" placeholder="${esc((kinds[0] || MAKES[0]).placeholder)}"></textarea>
-<div class="dv-tray">${frameMod}${lookMod}${whoMod}${startMod}<button type="submit" class="dv-go"><span data-go-label>Create three takes</span>${ARROW}</button></div>
+<div class="dv-tray">${frameMod}${lookMod}${whoMod}${startMod}${researchMod}<button type="submit" class="dv-go"><span data-go-label>Create three takes</span>${ARROW}</button></div>
<p class="dv-cost" data-dv-cost role="status" hidden></p></form>
<p class="dv-cost" data-dv-cost role="status" hidden></p></form>
<div class="dv-examples" aria-label="Try an example">${kinds.map((k) => (k.examples || []).map((x) => `<button type="button" data-example="${esc(x)}" data-kind="${esc(k.id)}">${k.glyph}<span>${esc(x)}</span></button>`).join('')).join('')}</div>
<div class="dv-examples" aria-label="Try an example">${kinds.map((k) => (k.examples || []).map((x) => `<button type="button" data-example="${esc(x)}" data-kind="${esc(k.id)}">${k.glyph}<span>${esc(x)}</span></button>`).join('')).join('')}</div>
${recent.length ? `<section class="dv-recent"><h2>Your recent designs</h2><ul>${recent.map((d) => { const k = kindName[d.kind] || MAKE[d.kind] || MAKES[0]; const bits = [k.name, lookName[d.look] || lookName[d.themeId], d.model, d.status === 'running' ? 'Drawing now' : d.status === 'failed' ? 'Did not finish' : ago(d.createdAt)].filter(Boolean);
${recent.length ? `<section class="dv-recent"><h2>Your recent designs</h2><ul>${recent.map((d) => { const k = kindName[d.kind] || MAKE[d.kind] || MAKES[0]; const bits = [k.name, lookName[d.look] || lookName[d.themeId], d.model, d.status === 'running' ? 'Drawing now' : d.status === 'failed' ? 'Did not finish' : ago(d.createdAt)].filter(Boolean);
return `<li><a class="dv-rc" href="${esc(d.url)}"><span class="dv-rc-k" aria-hidden="true">${d.thumbnail ? `<img src="${esc(d.thumbnail)}" alt="" loading="lazy" decoding="async">` : k.glyph}</span><span class="dv-rc-t"><b>${esc(d.prompt)}</b><small>${esc(bits.join(' · '))}</small></span></a></li>`; }).join('')}</ul></section>` : ''}</section>`;
return `<li><a class="dv-rc" href="${esc(d.url)}"><span class="dv-rc-k" aria-hidden="true">${d.thumbnail ? `<img src="${esc(d.thumbnail)}" alt="" loading="lazy" decoding="async">` : k.glyph}</span><span class="dv-rc-t"><b>${esc(d.prompt)}</b><small>${esc(bits.join(' · '))}</small></span></a></li>`; }).join('')}</ul></section>` : ''}</section>`;
- return layout({ title: 'Create a design', path: '/create', body, active: '/create', scripts: ['js/design.js'] });
+ return layout({ title: 'Create a design', path: '/create', body, active: '/create', styles: ['css/deepdive.css'], scripts: ['js/design.js'] });
}
}
const themeListener = `<script data-dv-theme="1">(function(){addEventListener('message',function(event){if(event.source!==parent||!event.data||event.data.type!=='design-theme')return;var style=document.getElementById('design-tokens');if(style&&typeof event.data.css==='string')style.textContent=event.data.css;});})();</script>`;
const themeListener = `<script data-dv-theme="1">(function(){addEventListener('message',function(event){if(event.source!==parent||!event.data||event.data.type!=='design-theme')return;var style=document.getElementById('design-tokens');if(style&&typeof event.data.css==='string')style.textContent=event.data.css;});})();</script>`;
@@ -213,6 +215,7 @@ function canvas({ data, themes = [], editKey = '', viewer = null, publishing = {
@@ -213,6 +215,7 @@ function canvas({ data, themes = [], editKey = '', viewer = null, publishing = {
`<div class="dv-skeleton" role="status"><span>${esc(v.model || 'Designer')}</span><small>${esc(v.status === 'failed' ? 'Could not generate' : 'Creating…')}</small></div>`}</div>
`<div class="dv-skeleton" role="status"><span>${esc(v.model || 'Designer')}</span><small>${esc(v.status === 'failed' ? 'Could not generate' : 'Creating…')}</small></div>`}</div>
<div class="dv-label"><span class="dv-model">${esc(v.model || 'Designer')}</span>${done ?
<div class="dv-label"><span class="dv-model">${esc(v.model || 'Designer')}</span>${done ?
`<span>${(Number(v.ms || 0) / 1000).toFixed(1)} s · $${Number(v.costUsd || 0).toFixed(4)}</span>` : ''}</div>
`<span>${(Number(v.ms || 0) / 1000).toFixed(1)} s · $${Number(v.costUsd || 0).toFixed(4)}</span>` : ''}</div>
+${deepDiveView.takePoints(v.research_points || v.researchPoints, design.research)}
<div class="dv-card-actions">${picked
<div class="dv-card-actions">${picked
? `<span class="dv-picked">Working frame</span><span class="dv-fine">Click any element to edit it</span>`
? `<span class="dv-picked">Working frame</span><span class="dv-fine">Click any element to edit it</span>`
: done ? (pick ? '<span class="dv-fine">Not the working frame</span>'
: done ? (pick ? '<span class="dv-fine">Not the working frame</span>'
@@ -249,6 +252,7 @@ ${versionDetail(v)}</span></li>`).join('');
@@ -249,6 +252,7 @@ ${versionDetail(v)}</span></li>`).join('');
<p id="dv-status" role="status">${esc(statusText)}</p>
<p id="dv-status" role="status">${esc(statusText)}</p>
<p class="dv-share"><button type="button" data-ov-share-sheet data-kind="design" data-id="${esc(design.id)}"
<p class="dv-share"><button type="button" data-ov-share-sheet data-kind="design" data-id="${esc(design.id)}"
data-title="${esc(design.prompt)}" data-url="${esc(`/d/${encodeURIComponent(design.id)}`)}">Share</button></p></header>
data-title="${esc(design.prompt)}" data-url="${esc(`/d/${encodeURIComponent(design.id)}`)}">Share</button></p></header>
+${deepDiveView.researchPanel(design.research)}
<form class="dv-chat" id="dv-chat-form" method="post" action="/d/${encodeURIComponent(design.id)}/edit">
<form class="dv-chat" id="dv-chat-form" method="post" action="/d/${encodeURIComponent(design.id)}/edit">
${keyInput}
${keyInput}
<label for="dv-instruction">Edit with AI</label>
<label for="dv-instruction">Edit with AI</label>
@@ -284,7 +288,7 @@ ${versions.length ? `<ol class="dv-vlist" id="dv-vlist">${versionRows}</ol>` : '
@@ -284,7 +288,7 @@ ${versions.length ? `<ol class="dv-vlist" id="dv-vlist">${versionRows}</ol>` : '
</aside>
</aside>
<script type="application/json" id="dv-theme-css">${cssJson}</script>`;
<script type="application/json" id="dv-theme-css">${cssJson}</script>`;
return layout({ title: 'Design canvas', path: `/d/${encodeURIComponent(design.id)}`, robots: 'noindex, nofollow',
return layout({ title: 'Design canvas', path: `/d/${encodeURIComponent(design.id)}`, robots: 'noindex, nofollow',
- body, scripts: ['js/design.js'] });
+ body, styles: ['css/deepdive.css'], scripts: ['js/design.js'] });
}
}
module.exports = { create, canvas, gate, frame, overlayScript };
module.exports = { create, canvas, gate, frame, overlayScript };
@@ -10,6 +10,7 @@
@@ -10,6 +10,7 @@
const kit = require('openvibe-shared/kit');
const kit = require('openvibe-shared/kit');
const { layout, esc, baseUrl } = require('../render/layout');
const { layout, esc, baseUrl } = require('../render/layout');
const { AREAS, RULES, byArea, latest } = require('../rules/register');
const { AREAS, RULES, byArea, latest } = require('../rules/register');
+const deepDiveView = require('./deepdive');
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
const day = (iso) => { const [y, m, d] = String(iso || '').split('-').map(Number); return y ? `${d} ${MONTHS[m - 1]} ${y}` : ''; };
const day = (iso) => { const [y, m, d] = String(iso || '').split('-').map(Number); return y ? `${d} ${MONTHS[m - 1]} ${y}` : ''; };
@@ -26,7 +27,7 @@ function rule(r) {
@@ -26,7 +27,7 @@ function rule(r) {
</article>`;
</article>`;
}
}
-function rulesPage() {
+function rulesPage({ proposals = [], owner = false, notice = null, error = null } = {}) {
const areas = byArea();
const areas = byArea();
const newest = latest();
const newest = latest();
const recent = [...RULES].sort((a, b) => (a.decided < b.decided ? 1 : -1)).slice(0, 5);
const recent = [...RULES].sort((a, b) => (a.decided < b.decided ? 1 : -1)).slice(0, 5);
@@ -48,6 +49,8 @@ function rulesPage() {
@@ -48,6 +49,8 @@ function rulesPage() {
</section>`).join('');
</section>`).join('');
const data = `<section class="rl-sec rl-data" aria-labelledby="rl-data-h"><div class="rl-sec-head"><h2 id="rl-data-h">Use the rules</h2><p>The register is data too: read it from code, hand it to an AI assistant before it designs for OpenVibe, or link one rule by its address.</p></div>
const data = `<section class="rl-sec rl-data" aria-labelledby="rl-data-h"><div class="rl-sec-head"><h2 id="rl-data-h">Use the rules</h2><p>The register is data too: read it from code, hand it to an AI assistant before it designs for OpenVibe, or link one rule by its address.</p></div>
<p class="rl-links">${kit.button({ href: '/api/v1/rules', label: 'As JSON', kind: 'quiet', icon: 'code' })}${kit.button({ href: '/rules.md', label: 'As Markdown', kind: 'quiet', icon: 'file' })}${kit.button({ href: '/patterns', label: 'The patterns', kind: 'quiet', icon: 'forward' })}</p></section>`;
<p class="rl-links">${kit.button({ href: '/api/v1/rules', label: 'As JSON', kind: 'quiet', icon: 'code' })}${kit.button({ href: '/rules.md', label: 'As Markdown', kind: 'quiet', icon: 'file' })}${kit.button({ href: '/patterns', label: 'The patterns', kind: 'quiet', icon: 'forward' })}</p></section>`;
+ const proposed = owner ? deepDiveView.proposalsSection(proposals) : '';
+ const noticeBar = notice || error ? `<p class="dd-notice${error ? ' is-error' : ''}" role="status">${esc(error || notice)}</p>` : '';
const jsonLd = [{ '@context': 'https://schema.org', '@type': 'CollectionPage', name: 'How OpenVibe decides', url: `${baseUrl()}/rules`, dateModified: newest, hasPart: RULES.map((r) => ({ '@type': 'CreativeWork', name: r.rule, url: `${baseUrl()}/rules#${r.id}`, dateCreated: r.decided })) }];
const jsonLd = [{ '@context': 'https://schema.org', '@type': 'CollectionPage', name: 'How OpenVibe decides', url: `${baseUrl()}/rules`, dateModified: newest, hasPart: RULES.map((r) => ({ '@type': 'CreativeWork', name: r.rule, url: `${baseUrl()}/rules#${r.id}`, dateCreated: r.decided })) }];
return layout({
return layout({
title: 'Rules: how OpenVibe decides',
title: 'Rules: how OpenVibe decides',
@@ -57,7 +60,7 @@ function rulesPage() {
@@ -57,7 +60,7 @@ function rulesPage() {
styles: ['css/rules.css'],
styles: ['css/rules.css'],
scripts: ['js/rules.js'],
scripts: ['js/rules.js'],
jsonLd,
jsonLd,
- body: `<article class="rl">${hero}${door}${index}${latestBlock}${sections}${data}</article>`,
+ body: `<article class="rl">${hero}${noticeBar}${door}${proposed}${index}${latestBlock}${sections}${data}</article>`,
});
});
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,323 @@
@@ -0,0 +1,323 @@
+'use strict';
+
+/**
+ * Research first (owner ask, 2026-11-11): a deep dive's synthesis goes into the generation brief, each take
+ * records the numbered points it follows, a component page gains "How others do it" only when a finished dive
+ * has a finding for it, and the owner alone decides a proposed rule (an approval opens a pull request; nothing
+ * is added to the register by itself).
+ */
+
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+const { startAi } = require('./helpers/ai');
+const kinds = require('../server/create/kinds');
+
+const html = '<!doctype html><html><head><title>A design</title></head><body><h1>A robot panel</h1><button>Stop</button></body></html>';
+const ev = (url, site, extra = {}) => ({ claim: 'A captured control panel', url, read_at: '2026-11-11T10:00:00.000Z', source_id: 'src_acme', capture_id: 'cap_acme', ...extra });
+const report = {
+ id: 'run_01JKLMNPQRSTVWXYZ0123456789',
+ schema: 'ai.deep-dive@1',
+ status: 'succeeded',
+ input: { question: 'A phone-first robot control panel', depth: 'quick', max_sites: 6, sites: [] },
+ steps: [
+ { key: 'plan', status: 'done' }, { key: 'search', status: 'done' }, { key: 'choose', status: 'skipped' },
+ { key: 'capture', status: 'done' }, { key: 'analyse', status: 'done' }, { key: 'synthesise', status: 'done' }, { key: 'report', status: 'done' },
+ ],
+ sources: [
+ { id: 'src_acme', kind: 'web', title: 'Acme controls', url: 'https://acme.example/controls', read_at: '2026-11-11T10:00:00.000Z' },
+ ],
+ captures: [
+ { id: 'cap_acme', source_id: 'src_acme', url: 'https://acme.example/controls', captured_at: '2026-11-11T10:00:00.000Z', viewport: 390, theme: 'dark', full_page: true, crops: [{ id: 'crop_acme', media_id: 'med_01JKLMNPQRSTVWXYZ0123456789', y: 0, height: 640 }] },
+ ],
+ analysis: [
+ { source_id: 'src_acme', url: 'https://acme.example/controls', summary: 'A phone-first panel whose main button sits in the thumb arc and keeps Stop one press away.', strengths: ['The Stop button never moves.'], weaknesses: ['The deck is denser than it needs to be.'], evidence: [ev('https://acme.example/controls', 'acme.example')] },
+ ],
+ synthesis: {
+ patterns: [
+ { name: 'One primary button in the thumb arc', what: 'The primary button sits in the thumb arc, with Stop always one press away.', sites: ['src_acme'], usage_count: 2, evidence: [ev('https://acme.example/controls', 'acme.example')] },
+ { name: 'State stays on the control', what: 'A second button reads its state in the same place the first one acts.', sites: ['src_acme'], usage_count: 2, evidence: [ev('https://acme.example/controls', 'acme.example')] },
+ ],
+ comparison: { columns: ['Site', 'Density'], rows: [['acme.example', 'dense']] },
+ best: { source_id: 'src_acme', what: 'The Stop disc stays in the same place at every size.', evidence: [ev('https://acme.example/controls', 'acme.example')] },
+ mistakes: [{ what: 'A menu hides the emergency stop.', evidence: [ev('https://acme.example/controls', 'acme.example')] }],
+ gap: 'No one shows the robot’s state beside the controls.',
+ reference_tokens: {
+ palettes: [{ source_id: 'src_acme', colours: ['#0b0f19', '#e6edf7', '#e5484d'] }],
+ type_scales: [{ source_id: 'src_acme', families: ['Inter'], sizes: ['16/24', '28/32'] }],
+ spacing: [{ source_id: 'src_acme', steps: ['4', '8', '12', '16'] }],
+ },
+ do: ['Name every control with one verb the person already knows.'],
+ dont: ['Do not hide Stop in a menu, under a hold or behind a slide.'],
+ accessibility: ['The Stop disc has a 48 px target.'],
+ speed: ['The panel is under 60 kB.'],
+ },
+ recommendations: [
+ { id: 'rec_stop', what: 'A robot control surface keeps Stop visible at every size.', why: 'Every captured control panel that hid Stop made an emergency cost an extra press.', rule: { id: 'controls-one-family', status: 'follows', text: 'Rule 128: a device’s controls are one family, arranged per person.' }, evidence: [ev('https://acme.example/controls', 'acme.example', { crop_id: 'crop_acme' })] },
+ { id: 'rec_pressed', what: 'A control’s pressed state is visible in the same place it acts.', why: 'Two of the three best panels put feedback on the control; the rest made people look away.', rule: { id: null, status: 'would_change', text: 'A control’s pressed state is visible on the control itself.' }, evidence: [ev('https://acme.example/controls', 'acme.example')] },
+ { id: 'rec_targets', what: 'A control target is at least 48 px at every size.', why: 'The captured panels that shrink their targets on a phone were harder to hit.', rule: { id: null, status: 'would_change', text: 'A control’s target is at least 48 px.' }, evidence: [ev('https://acme.example/controls', 'acme.example')] },
+ ],
+ receipt: { price_usd: 0.25, held_usd: 0.25, cost_usd: 0.18, models: [], captures_count: 1, synthetic: false },
+ gaps: [],
+ created_at: '2026-11-11T09:57:00.000Z', updated_at: '2026-11-11T10:00:00.000Z', finished_at: '2026-11-11T10:00:00.000Z',
+};
+
+const slots = [{ provider: 'alpha', model: 'one' }, { provider: 'beta', model: 'two' }, { provider: 'gamma', model: 'three' }];
+
+function fakeAi(state) {
+ return {
+ designPlan: async () => ({ slots }),
+ exchangeCatalog: async () => ({}),
+ choices: async () => ({}),
+ offeredModels: async () => [],
+ generate: async ({ prompt, slot }) => {
+ state.prompts.push(prompt);
+ return { html, provider: slot.provider, model: slot.model, cost_usd: 0.001, ms: 5 };
+ },
+ };
+}
+
+function createOptions() {
+ const allKinds = kinds.KINDS.map((k) => ({ id: k.id, name: k.name, frames: k.frames }));
+ return {
+ options: async () => ({
+ kinds: allKinds,
+ frames: kinds.FRAMES,
+ looks: [{ id: 'vibe', name: 'Vibe', source: 'theme', variables: { '--bg-primary': '#0b0f19', '--text-primary': '#e6edf7', '--accent': '#3b82f6' }, swatch: [] }],
+ models: [], takes: { min: 1, max: 4, default: 3, total: 8 },
+ limits: { prompt: 4000, models: 4, takes_per_model: 4, takes_total: 8 },
+ currency: 'USD',
+ }),
+ publicOptions: (all) => all,
+ };
+}
+
+const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
+
+(async () => {
+ const dive = await startAi({ deepDive: {
+ options: { depths: [
+ { id: 'quick', name: 'Quick', price_usd: 0.25, sites: 6, minutes: 3, line: 'Six sites, about three minutes' },
+ { id: 'thorough', name: 'Thorough', price_usd: 0.65, sites: 15, line: 'Fifteen sites' },
+ { id: 'expert', name: 'Expert', price_usd: 1.5, sites: 30, line: 'Thirty sites' },
+ ] },
+ report,
+ } });
+ const state = { prompts: [] };
+ const rulePr = { calls: [], open: async ({ proposal, reportUrl }) => { rulePr.calls.push({ rule: proposal.rule, reportUrl }); return { url: `https://github.com/OpenVibers/OpenVibe.Design/pull/${rulePr.calls.length}`, number: rulePr.calls.length, branch: 'rules/research-test' }; } };
+ const t = await boot({ ai: fakeAi(state), createOptions: createOptions(), env: { DESIGN_DEEP_DIVE_URL: dive.url, DESIGN_DEEP_DIVE_POLL_MS: '5' }, rulePr });
+ try {
+ const owner = t.network.addUser('goosely', { role: 'owner' });
+ const kim = t.network.addUser('kim');
+ let researchId;
+
+ await check('the contract report becomes numbered points, a brief and proposed rules with no copied code', async () => {
+ const { normalizeReport, findsFor } = require('../server/deepdive/report');
+ const n = normalizeReport(report, { question: 'A phone-first robot control panel', depth: 'quick' });
+ assert.ok(n.points.length >= 9, `points ${n.points.length}`);
+ assert.ok(n.points.every((x) => x.n >= 1 && x.text), 'every point has a number and a sentence');
+ assert.match(n.brief, /Research first: A phone-first robot control panel/);
+ assert.match(n.brief, /What the better sites do/);
+ assert.match(n.brief, /Reference tokens, as direction only/);
+ assert.match(n.brief, /OpenVibe rules this follows/);
+ assert.match(n.brief, /never copy a site/i);
+ assert.ok(!/<style|<html|class=/.test(n.brief), 'the brief is words, never a site\'s markup');
+ assert.equal(n.proposedRules.length, 2);
+ assert.match(n.proposedRules[0].rule, /pressed state|48 px/);
+ assert.match(findsFor(report, 'button')[0].title, /button/i);
+ assert.equal(findsFor(report, 'carousel').length, 0);
+ });
+
+ await check('the composer offers Research first with its depth and price; a guest is told to sign in', async () => {
+ const guest = await t.get('/create');
+ assert.equal(guest.status, 200);
+ assert.match(guest.text, /Research first/);
+ assert.match(guest.text, /Sign in to start a deep dive/);
+ const page = await t.get('/create', { as: owner });
+ assert.equal(page.status, 200);
+ assert.match(page.text, /Research first/);
+ assert.match(page.text, /Thorough/);
+ assert.match(page.text, /\$0\.65|65¢/);
+ assert.match(page.text, /name="research"/);
+ });
+
+ await check('the estimate prices the dive exactly as the run will use it', async () => {
+ const r = await t.get('/api/create/estimate', { as: owner, origin: 'https://openvibe.design', json: { prompt: 'A phone-first robot control panel', kind: 'page', research: { depth: 'quick' } } });
+ assert.equal(r.status, 200, r.text);
+ const body = r.json();
+ assert.equal(body.research.depth, 'quick');
+ assert.equal(body.research.price_usd, 0.25);
+ assert.equal(body.total_usd, 0.25);
+ assert.equal(body.priced, true);
+ });
+
+ await check('a create starts one dive on the person\'s behalf, and its report becomes the brief', async () => {
+ const r = await t.get('/api/create', { as: owner, origin: 'https://openvibe.design', json: { prompt: 'A phone-first robot control panel', kind: 'page', research: { depth: 'quick' } } });
+ assert.equal(r.status, 201, r.text);
+ researchId = r.json().design.researchId;
+ assert.ok(researchId, 'the design records its dive');
+ assert.equal(dive.state.deepDives.size, 1);
+ const [run] = [...dive.state.deepDives.values()];
+ assert.deepEqual(run.run.on_behalf_of, { type: 'user', id: owner.subject });
+ assert.equal(run.run.input.depth, 'quick');
+ assert.equal(run.run.input.question, 'A phone-first robot control panel');
+
+ let data;
+ for (let i = 0; i < 120; i++) {
+ const got = await t.get(`/api/v1/designs/${r.json().design.id}`, { as: owner });
+ data = got.json();
+ if (data.design.status === 'done' && data.design.research && data.design.research.status === 'done') break;
+ await pause(20);
+ }
+ assert.equal(data.design.research.status, 'done', JSON.stringify(data.design.research));
+ assert.ok(data.design.research.pointsCount >= 9, `points ${data.design.research.pointsCount}`);
+ assert.equal(data.variants.filter((v) => v.status === 'done').length, 3);
+ for (const v of data.variants) {
+ assert.ok(Array.isArray(v.research_points) && v.research_points.length, 'each take says which points it follows');
+ }
+ assert.ok(state.prompts.length >= 3);
+ assert.match(state.prompts[0], /Research first: A phone-first robot control panel/);
+ assert.match(state.prompts[0], /This take follows research point/);
+ assert.match(state.prompts[0], /Never copy a site/);
+ const row = await t.store.db.maybe('SELECT * FROM design_research WHERE id=$1', [researchId]);
+ assert.equal(row.status, 'done');
+ assert.ok(row.report_url.startsWith('https://openvibe.exchange/deep-dive/'));
+ assert.equal((await t.store.db.many('SELECT * FROM rule_proposals WHERE research_id=$1', [researchId])).length, 2);
+ });
+
+ await check('the canvas names the dive and links every take to its points', async () => {
+ const design = (await t.store.db.many('SELECT id FROM designs WHERE research_id=$1', [researchId]))[0];
+ const page = await t.get(`/d/${design.id}`, { as: owner });
+ assert.equal(page.status, 200);
+ assert.match(page.text, /Research first · its report/);
+ assert.match(page.text, /A phone-first robot control panel/);
+ assert.match(page.text, /Follows .*research point/);
+ assert.match(page.text, new RegExp(`/research/${researchId}`));
+ });
+
+ await check('the report page is the owner\'s, with numbered evidence', async () => {
+ const page = await t.get(`/research/${researchId}`, { as: owner });
+ assert.equal(page.status, 200);
+ assert.match(page.text, /The research points/);
+ assert.match(page.text, /Stop always one press away/);
+ assert.match(page.text, /acme\.example/);
+ assert.match(page.text, /read 11 Nov 2026/);
+ assert.match(page.text, /Proposed from research/);
+ assert.equal((await t.get(`/research/${researchId}`, { as: kim })).status, 404);
+ assert.equal((await t.get(`/research/${researchId}`)).status, 303);
+ });
+
+ await check('a crop is proxied from the dive, owner only, never a public Media address', async () => {
+ const png = await t.get(`/research/${researchId}/crop/crop_acme`, { as: owner });
+ assert.equal(png.status, 200, png.text);
+ assert.equal(png.headers.get('content-type'), 'image/png');
+ assert.equal(png.headers.get('x-content-type-options'), 'nosniff');
+ assert.equal((await t.get(`/research/${researchId}/crop/crop_acme`, { as: kim })).status, 404);
+ assert.equal((await t.get(`/research/${researchId}/crop/crop_acme`)).status, 401);
+ });
+
+ await check('a component page gains How others do it only when a finished dive has a finding', async () => {
+ const before = await t.get('/components/button');
+ assert.equal(before.status, 200);
+ // The report is already finished by now, so the section is there; the empty case is shown by a component
+ // no point names (the rule is that the section is absent, not an empty heading).
+ const page = await t.get('/components/button', { as: owner });
+ assert.equal(page.status, 200);
+ assert.match(page.text, /How others do it/);
+ assert.match(page.text, /acme\.example/);
+ assert.match(page.text, /read 11 Nov 2026/);
+ const other = await t.get('/components/table', { as: owner });
+ assert.doesNotMatch(other.text, /How others do it/);
+ const someoneElse = await t.get('/components/button', { as: kim });
+ assert.doesNotMatch(someoneElse.text, /How others do it/);
+ });
+
+ await check('Proposed from research is the owner\'s alone', async () => {
+ const ownerPage = await t.get('/rules', { as: owner });
+ assert.equal(ownerPage.status, 200);
+ assert.match(ownerPage.text, /Proposed from research/);
+ assert.match(ownerPage.text, /pressed state|48 px/);
+ const other = await t.get('/rules', { as: kim });
+ assert.equal(other.status, 200);
+ assert.doesNotMatch(other.text, /Proposed from research/);
+ });
+
+ await check('only the owner approves, and an approval opens a pull request, never a register write', async () => {
+ const proposals = (await t.store.db.many('SELECT * FROM rule_proposals WHERE research_id=$1 ORDER BY created_at', [researchId]));
+ assert.equal(proposals.length, 2);
+ const forbidden = await t.get(`/api/v1/rules/proposals/${proposals[0].id}/approve`, { as: kim, origin: 'https://openvibe.design', json: {} });
+ assert.equal(forbidden.status, 403);
+ const approved = await t.get(`/api/v1/rules/proposals/${proposals[0].id}/approve`, { as: owner, origin: 'https://openvibe.design', json: {} });
+ assert.equal(approved.status, 200, approved.text);
+ assert.equal(approved.json().proposal.state, 'approved');
+ assert.match(approved.json().proposal.prUrl, /\/pull\/1$/);
+ assert.equal(rulePr.calls.length, 1);
+ const dismissed = await t.get(`/api/v1/rules/proposals/${proposals[1].id}/dismiss`, { as: owner, origin: 'https://openvibe.design', json: {} });
+ assert.equal(dismissed.status, 200);
+ assert.equal(dismissed.json().proposal.state, 'dismissed');
+ const register = require('fs').readFileSync(require('path').join(__dirname, '..', 'server', 'rules', 'register.js'), 'utf8');
+ assert.doesNotMatch(register, /pressed state is visible|48 px/);
+ });
+
+ await check('an approval builds the register change on a branch and opens a pull request, never main', async () => {
+ const fs = require('fs'), path = require('path');
+ const { createRulePr, addRule } = require('../server/deepdive/github');
+ const source = fs.readFileSync(path.join(__dirname, '..', 'server', 'rules', 'register.js'), 'utf8');
+ const calls = [];
+ const fakeFetch = async (url, opts = {}) => {
+ calls.push({ url, method: opts.method || 'GET', body: opts.body ? JSON.parse(opts.body) : null });
+ if (url.endsWith('/repos/OpenVibers/OpenVibe.Design')) return { ok: true, status: 200, json: async () => ({ default_branch: 'main' }) };
+ if (url.includes('/git/ref/heads/main')) return { ok: true, status: 200, json: async () => ({ object: { sha: 'a'.repeat(40) } }) };
+ if (url.includes('/contents/server/rules/register.js')) return { ok: true, status: 200, json: async () => ({ sha: 'file-sha', content: Buffer.from(source, 'utf8').toString('base64') }) };
+ if (url.endsWith('/git/refs')) return { ok: true, status: 201, json: async () => ({ ref: 'refs/heads/rules/research-rpp-test' }) };
+ if (url.includes('/contents/server/rules/register.js')) return { ok: true, status: 200, json: async () => ({ content: { sha: 'new-sha' } }) };
+ if (url.endsWith('/pulls')) return { ok: true, status: 201, json: async () => ({ number: 9, html_url: 'https://github.com/OpenVibers/OpenVibe.Design/pull/9' }) };
+ return { ok: false, status: 404, json: async () => ({ message: 'no' }) };
+ };
+ const rulePr = createRulePr({ config: { deepDive: { githubToken: 'test-token', repo: 'OpenVibers/OpenVibe.Design', registerPath: 'server/rules/register.js', branchPrefix: 'rules/research' } }, fetchImpl: fakeFetch });
+ const out = await rulePr.open({ proposal: { id: 'rpp_test', rule: 'A test rule stays one sentence.', detail: 'The detail.', why: 'Because.', area: 'data' }, reportUrl: 'https://openvibe.exchange/deep-dive/run_x', decidedBy: 'goosely' });
+ assert.equal(out.url, 'https://github.com/OpenVibers/OpenVibe.Design/pull/9');
+ const put = calls.find((c) => c.method === 'PUT');
+ assert.ok(put, 'the file is updated on the branch');
+ assert.equal(put.body.branch, 'rules/research-rpp-test');
+ const written = Buffer.from(put.body.content, 'base64').toString('utf8');
+ assert.match(written, /R\('research-rpp-test', 'data', 'A test rule stays one sentence\.'/);
+ assert.match(written, /read the deep dive|Read the deep dive/);
+ const pull = calls.find((c) => c.url.endsWith('/pulls'));
+ assert.equal(pull.body.head, 'rules/research-rpp-test');
+ assert.equal(pull.body.base, 'main');
+ assert.match(pull.body.body, /https:\/\/openvibe\.exchange\/deep-dive\/run_x/);
+ assert.doesNotMatch(pull.body.body, /Generated with|Co-Authored|Claude-Session/);
+ const twice = addRule(written, { rule: 'A second rule.', detail: '', why: '' }, { id: 'research-second', area: 'data', decided: '2026-11-11' });
+ assert.match(twice, /R\('research-second'/);
+ assert.throws(() => addRule(written, { rule: 'x' }, { id: 'research-rpp-test', area: 'data', decided: '2026-11-11' }), /already in the register/);
+ });
+
+ await check('a dive the person already ran attaches without a second run or charge', async () => {
+ const before = dive.state.deepDives.size;
+ const estimate = await t.get('/api/create/estimate', { as: owner, origin: 'https://openvibe.design', json: { prompt: 'Another panel', kind: 'page', research: { id: researchId } } });
+ assert.equal(estimate.status, 200, estimate.text);
+ assert.equal(estimate.json().research.price_usd, 0);
+ const r = await t.get('/api/create', { as: owner, origin: 'https://openvibe.design', json: { prompt: 'Another panel', kind: 'page', research: { id: researchId } } });
+ assert.equal(r.status, 201, r.text);
+ assert.equal(r.json().design.researchId, researchId);
+ assert.equal(dive.state.deepDives.size, before);
+ assert.equal((await t.store.db.many('SELECT * FROM design_research WHERE owner=$1', [owner.subject])).length, 1);
+ });
+
+ await check('the JSON API offers the depths, the recent dives and a running dive', async () => {
+ const options = await t.get('/api/deep-dive/options', { as: owner });
+ assert.equal(options.status, 200);
+ assert.equal(options.json().depths.length, 3);
+ assert.ok(options.json().recent.some((x) => x.id === researchId));
+ const one = await t.get(`/api/deep-dive/${researchId}`, { as: owner });
+ assert.equal(one.status, 200);
+ assert.equal(one.json().deep_dive.status, 'done');
+ assert.equal((await t.get(`/api/deep-dive/${researchId}`, { as: kim })).status, 403);
+ });
+
+ done();
+ } finally {
+ await t.close();
+ await dive.close();
+ }
+})().catch((err) => { console.error(err); process.exitCode = 1; });
@@ -45,13 +45,15 @@ function listen(handler) {
@@ -45,13 +45,15 @@ function listen(handler) {
});
});
}
}
-async function startAi({ models = DEFAULT_MODELS, htmlFor = null, costUsd = 0.0012, ms = 321, tokensIn = 120, tokensOut = 480, failModels = [], asyncMode = false, routesStatus = 200 } = {}) {
+async function startAi({ models = DEFAULT_MODELS, htmlFor = null, costUsd = 0.0012, ms = 321, tokensIn = 120, tokensOut = 480, failModels = [], asyncMode = false, routesStatus = 200, deepDive = null } = {}) {
const st = {
const st = {
models: models.map((m) => ({ ...m })),
models: models.map((m) => ({ ...m })),
requests: [], routeRequests: 0, lastAuth: null, tokenClaims: null,
requests: [], routeRequests: 0, lastAuth: null, tokenClaims: null,
costUsd, ms, tokensIn, tokensOut,
costUsd, ms, tokensIn, tokensOut,
failModels: new Set(failModels), asyncMode, routesStatus,
failModels: new Set(failModels), asyncMode, routesStatus,
delayMs: 0, runCount: 0, runs: new Map(), htmlFor: null,
delayMs: 0, runCount: 0, runs: new Map(), htmlFor: null,
+ deepDiveReports: [], deepDives: new Map(), deepDiveOptions: deepDive && deepDive.options ? deepDive.options : null, deepDiveReport: deepDive && deepDive.report ? deepDive.report : null,
+ deepDiveStatus: deepDive && deepDive.status ? deepDive.status : 'succeeded', deepDiveReads: 0,
};
};
function routeFor(body) {
function routeFor(body) {
@@ -67,7 +69,7 @@ async function startAi({ models = DEFAULT_MODELS, htmlFor = null, costUsd = 0.00
@@ -67,7 +69,7 @@ async function startAi({ models = DEFAULT_MODELS, htmlFor = null, costUsd = 0.00
return st.models[i] || { provider: 'stub', model: 'stub-1' };
return st.models[i] || { provider: 'stub', model: 'stub-1' };
}
}
- const srv = await listen(async (req, raw, json) => {
+ const srv = await listen(async (req, raw, json, res) => {
const url = new URL(req.url, 'http://x');
const url = new URL(req.url, 'http://x');
const text = raw.toString('utf8');
const text = raw.toString('utf8');
let body = {};
let body = {};
@@ -81,6 +83,24 @@ async function startAi({ models = DEFAULT_MODELS, htmlFor = null, costUsd = 0.00
@@ -81,6 +83,24 @@ async function startAi({ models = DEFAULT_MODELS, htmlFor = null, costUsd = 0.00
}
}
if (st.delayMs) await new Promise((r) => setTimeout(r, st.delayMs));
if (st.delayMs) await new Promise((r) => setTimeout(r, st.delayMs));
+ // A deep dive (workflow design.deep_dive): the run API when the client asks there, and the feature's own
+ // door as a second path. The report is the run's output; a test can make the first read still running.
+ if ((url.pathname === '/api/v1/runs' || url.pathname === '/api/v1/deep-dives') && req.method === 'POST' &&
+ (body.workflow === 'design.deep_dive' || url.pathname === '/api/v1/deep-dives')) {
+ st.requests.push({ method: 'POST', path: url.pathname, query: Object.fromEntries(url.searchParams), body, claims });
+ const id = `run_dd_${crypto.randomBytes(6).toString('hex')}`;
+ const input = body.input || body;
+ const report = typeof st.deepDiveOptions === 'function' ? st.deepDiveOptions(input) : st.deepDiveReport;
+ const run = { id, status: 'running', workflow: { key: 'design.deep_dive', version: 1 }, input,
+ on_behalf_of: body.on_behalf_of || null, target: body.target || null, output: null, created_at: new Date().toISOString() };
+ st.deepDives.set(id, { run, report });
+ st.deepDiveReports.push(report);
+ return json(202, { run });
+ }
+ if (url.pathname === '/api/v1/deep-dives/options' && req.method === 'GET') {
+ const options = st.deepDiveOptions && st.deepDiveOptions.depths ? st.deepDiveOptions : { depths: [] };
+ return json(200, options);
+ }
if (url.pathname === '/api/v1/routes' && req.method === 'GET') {
if (url.pathname === '/api/v1/routes' && req.method === 'GET') {
st.routeRequests++;
st.routeRequests++;
if (st.routesStatus !== 200) return json(st.routesStatus, { code: 'route.unavailable', detail: 'routes are down' });
if (st.routesStatus !== 200) return json(st.routesStatus, { code: 'route.unavailable', detail: 'routes are down' });
@@ -126,10 +146,31 @@ async function startAi({ models = DEFAULT_MODELS, htmlFor = null, costUsd = 0.00
@@ -126,10 +146,31 @@ async function startAi({ models = DEFAULT_MODELS, htmlFor = null, costUsd = 0.00
return json(201, { run });
return json(201, { run });
}
}
+ const shot = /^\/api\/v1\/deep-dives\/([^/]+)\/shots\/([^/]+)$/.exec(url.pathname);
+ if (shot && req.method === 'GET') {
+ st.requests.push({ method: 'GET', path: url.pathname, claims });
+ const png = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9Zl1sAAAAASUVORK5CYII=', 'base64');
+ st.lastShot = { path: url.pathname, status: 200 };
+ res.statusCode = 200;
+ res.setHeader('Content-Type', 'image/png');
+ res.setHeader('Cache-Control', 'private, no-store');
+ res.end(png);
+ return;
+ }
const one = /^\/api\/v1\/runs\/([^/]+)$/.exec(url.pathname);
const one = /^\/api\/v1\/runs\/([^/]+)$/.exec(url.pathname);
if (one && req.method === 'GET') {
if (one && req.method === 'GET') {
st.requests.push({ method: 'GET', path: url.pathname, claims });
st.requests.push({ method: 'GET', path: url.pathname, claims });
- const run = st.runs.get(decodeURIComponent(one[1]));
+ const id = decodeURIComponent(one[1]);
+ const dive = st.deepDives.get(id);
+ if (dive) {
+ st.deepDiveReads++;
+ const done = st.deepDiveStatus === 'succeeded' || st.deepDiveReads > 1;
+ const run = done
+ ? { ...dive.run, status: st.deepDiveStatus, output: dive.report, usage: { cost_usd: 0.25, tokens_in: 100, tokens_out: 200 } }
+ : dive.run;
+ return json(200, { run });
+ }
+ const run = st.runs.get(id);
if (!run) return json(404, { code: 'run.not_found' });
if (!run) return json(404, { code: 'run.not_found' });
return json(200, { run });
return json(200, { run });
}
}
@@ -29,6 +29,8 @@ async function boot(opts = {}) {
@@ -29,6 +29,8 @@ async function boot(opts = {}) {
DESIGN_AI_POLL_MS: '10',
DESIGN_AI_POLL_MS: '10',
DESIGN_AI_WAIT_MS: '5000',
DESIGN_AI_WAIT_MS: '5000',
DESIGN_ARENA_JOB_INTERVAL_MS: '100000',
DESIGN_ARENA_JOB_INTERVAL_MS: '100000',
+ DESIGN_DEEP_DIVE_POLL_MS: '10',
+ ...(opts.ai && opts.ai.url ? { DESIGN_DEEP_DIVE_URL: opts.ai.url } : {}),
...(opts.env || {}),
...(opts.env || {}),
};
};
for (const [k, v] of Object.entries(opts.env || {})) if (v === null) delete env[k];
for (const [k, v] of Object.entries(opts.env || {})) if (v === null) delete env[k];
@@ -47,7 +49,8 @@ async function boot(opts = {}) {
@@ -47,7 +49,8 @@ async function boot(opts = {}) {
const config = configLib.load(env);
const config = configLib.load(env);
const testdb = opts.store ? null : await require('./db').testDb({ store: opts.testStore });
const testdb = opts.store ? null : await require('./db').testDb({ store: opts.testStore });
const store = opts.store || createStore(testdb.db, { now: opts.now });
const store = opts.store || createStore(testdb.db, { now: opts.now });
- const built = await createApp({ config, store, log, now: opts.now, accountSend: opts.accountSend, random: opts.random, ai: opts.ai, publisher: opts.publisher, balance: opts.balance, createOptions: opts.createOptions });
+ const built = await createApp({ config, store, log, now: opts.now, accountSend: opts.accountSend, random: opts.random, ai: opts.ai, publisher: opts.publisher, balance: opts.balance, createOptions: opts.createOptions,
+ deepDive: opts.deepDive, deepDiveStore: opts.deepDiveStore, deepDiveAi: opts.deepDiveAi, proposals: opts.proposals, rulePr: opts.rulePr });
await built.ctx.keys.ensure();
await built.ctx.keys.ensure();
const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
const base = `http://127.0.0.1:${server.address().port}`;
const base = `http://127.0.0.1:${server.address().port}`;