openvibers/openvibe.design Commits

Research first: deep dive synthesis on /create, component pages and the rules register (#284)

OP OpenVibers committed 1 days ago · 56785fbbf1 · 25 files, +2077 −38

* Research first: a deep dive's synthesis in the generation brief

/create gets a Research first toggle: start a priced deep dive for the
design's brief or attach one already run. Its report's numbered points go
into every take's brief and each take records the points it follows. A
component page gains 'How others do it' from the viewer's own finished
dives, with crops, attribution and the day each was read. The owner alone
sees a report's proposed rules on /rules; an approval opens a pull request
against server/rules/register.js, and nothing is added by itself.

The dive is an OpenVibe.AI run (workflow design.deep_dive) on the person's
behalf; the client reads both the run API and the feature's own route so
the page does not depend on which one the deployment carries.

* Account export for deep dives and the proposed rules; read AI's live dive state

The two new tables name a person, so account export and deletion cover them:
a deep dive's report and points, and the rule proposals it made. The AI
client now reads GET /api/v1/deep-dives/:id first (the live steps and the
report beside the run) and falls back to the run API. The prices fall back
to AI's 0.2/0.5/1.2 when its depths endpoint has not answered.

* Merge origin/main into deepdive-design-1111

Resolve the config conflict: keep main's Exchange icon-search client and
the deep dive configuration beside it.

Changes

ddiff --git a/migrations/0022_deep_dive.sql b/migrations/0022_deep_dive.sql
nnew file mode 100644
iindex 0000000..776211f
---- /dev/null
++++ b/migrations/0022_deep_dive.sql
@@@ -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";
ddiff --git a/public/css/deepdive.css b/public/css/deepdive.css
nnew file mode 100644
iindex 0000000..d656b02
---- /dev/null
++++ b/public/css/deepdive.css
@@@ -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; }
++}
ddiff --git a/public/js/design.js b/public/js/design.js
iindex d2033a9..bce4b92 100644
---- a/public/js/design.js
++++ b/public/js/design.js
@@@ -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();
}
ddiff --git a/server/app.js b/server/app.js
iindex 9337774..eb8d1a5 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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', {
ddiff --git a/server/config.js b/server/config.js
iindex ae2db19..009617c 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -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: {
ddiff --git a/server/create/api.js b/server/create/api.js
iindex c121e50..97b0aeb 100644
---- a/server/create/api.js
++++ b/server/create/api.js
@@@ -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) {
ddiff --git a/server/create/plan.js b/server/create/plan.js
iindex 7c3d000..3c6ccb3 100644
---- a/server/create/plan.js
++++ b/server/create/plan.js
@@@ -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),
};
}
ddiff --git a/server/deepdive/ai.js b/server/deepdive/ai.js
nnew file mode 100644
iindex 0000000..580e97a
---- /dev/null
++++ b/server/deepdive/ai.js
@@@ -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 };
ddiff --git a/server/deepdive/api.js b/server/deepdive/api.js
nnew file mode 100644
iindex 0000000..1baa746
---- /dev/null
++++ b/server/deepdive/api.js
@@@ -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 };
ddiff --git a/server/deepdive/github.js b/server/deepdive/github.js
nnew file mode 100644
iindex 0000000..730f21e
---- /dev/null
++++ b/server/deepdive/github.js
@@@ -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 };
ddiff --git a/server/deepdive/proposals.js b/server/deepdive/proposals.js
nnew file mode 100644
iindex 0000000..d23f56b
---- /dev/null
++++ b/server/deepdive/proposals.js
@@@ -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 };
ddiff --git a/server/deepdive/report.js b/server/deepdive/report.js
nnew file mode 100644
iindex 0000000..6d5bbc8
---- /dev/null
++++ b/server/deepdive/report.js
@@@ -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 };
ddiff --git a/server/deepdive/service.js b/server/deepdive/service.js
nnew file mode 100644
iindex 0000000..9ea5a5d
---- /dev/null
++++ b/server/deepdive/service.js
@@@ -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 };
ddiff --git a/server/deepdive/store.js b/server/deepdive/store.js
nnew file mode 100644
iindex 0000000..7be1cd1
---- /dev/null
++++ b/server/deepdive/store.js
@@@ -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 };
ddiff --git a/server/designs.js b/server/designs.js
iindex 97b821d..45dadd0 100644
---- a/server/designs.js
++++ b/server/designs.js
@@@ -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 = consol
id: 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 = consol
return 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 = consol
source: 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 = consol
await 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 = consol
const 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 = consol
async 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 }) {
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 40dc6e6..946e789 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -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);
ddiff --git a/server/identity/account-data.js b/server/identity/account-data.js
iindex e1a3b35..fb2e129 100644
---- a/server/identity/account-data.js
++++ b/server/identity/account-data.js
@@@ -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).
ddiff --git a/server/index.js b/server/index.js
iindex 68929e3..f5d9536 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -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 });
ddiff --git a/server/views/components-list.js b/server/views/components-list.js
iindex dc39f5a..0ac88ec 100644
---- a/server/views/components-list.js
++++ b/server/views/components-list.js
@@@ -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,
ddiff --git a/server/views/deepdive.js b/server/views/deepdive.js
nnew file mode 100644
iindex 0000000..431af5d
---- /dev/null
++++ b/server/views/deepdive.js
@@@ -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/&lt;id&gt;</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 };
ddiff --git a/server/views/design.js b/server/views/design.js
iindex 7423e43..4bc1589 100644
---- a/server/views/design.js
++++ b/server/views/design.js
@@@ -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 };
ddiff --git a/server/views/rules.js b/server/views/rules.js
iindex c4094de..1bf7cc6 100644
---- a/server/views/rules.js
++++ b/server/views/rules.js
@@@ -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>`,
});
}
ddiff --git a/test/deepdive.test.js b/test/deepdive.test.js
nnew file mode 100644
iindex 0000000..0820a89
---- /dev/null
++++ b/test/deepdive.test.js
@@@ -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; });
ddiff --git a/test/helpers/ai.js b/test/helpers/ai.js
iindex 418646b..1b10e44 100644
---- a/test/helpers/ai.js
++++ b/test/helpers/ai.js
@@@ -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.00
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 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.00
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);
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 });
}
ddiff --git a/test/helpers/boot.js b/test/helpers/boot.js
iindex 0c04c7b..1c75ddd 100644
---- a/test/helpers/boot.js
++++ b/test/helpers/boot.js
@@@ -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}`;