From c995e710aad1d23268a6bf26219a64633f2b2d12 Mon Sep 17 00:00:00 2001 From: Shubhojyoti Ganguly Date: Sun, 30 Aug 2026 20:36:10 +0530 Subject: [PATCH] feat: add meridian community plugin Webcmd commands for Meridian (getmeridian.tech), the founder copilot with the Astra agent: browser-based account authorization (login/whoami), turn-based Astra ideation chat up to the Approve & Start stage, project creation, ideal-user persona building from behaviours or interview notes, prioritized competitor research, and Astra background-agent (market intelligence) controls. Commands ride the app's own API contract from the logged-in page context (httpOnly session cookie, cross-origin app->api fetch), kick off long Astra calls in-page and poll to stay under the CDP evaluate cap, and poll job-based endpoints (project setup, competitor scans) to completion. Co-Authored-By: Claude Fable 5 --- plugins/meridian/README.md | 109 +++++++++ plugins/meridian/agent-pause.js | 32 +++ plugins/meridian/agent-start.js | 45 ++++ plugins/meridian/agent-status.js | 50 ++++ plugins/meridian/approve.js | 108 +++++++++ plugins/meridian/auth.js | 58 +++++ plugins/meridian/competitor-add.js | 53 ++++ plugins/meridian/competitor-scan.js | 38 +++ plugins/meridian/competitors.js | 54 +++++ plugins/meridian/ideate.js | 121 ++++++++++ plugins/meridian/package.json | 9 + plugins/meridian/persona.js | 135 +++++++++++ plugins/meridian/personas.js | 47 ++++ plugins/meridian/projects.js | 44 ++++ plugins/meridian/test/commands.test.js | 322 +++++++++++++++++++++++++ plugins/meridian/test/helpers.js | 55 +++++ plugins/meridian/test/utils.test.js | 168 +++++++++++++ plugins/meridian/utils.js | 293 ++++++++++++++++++++++ plugins/meridian/webcmd-plugin.json | 10 + 19 files changed, 1751 insertions(+) create mode 100644 plugins/meridian/README.md create mode 100644 plugins/meridian/agent-pause.js create mode 100644 plugins/meridian/agent-start.js create mode 100644 plugins/meridian/agent-status.js create mode 100644 plugins/meridian/approve.js create mode 100644 plugins/meridian/auth.js create mode 100644 plugins/meridian/competitor-add.js create mode 100644 plugins/meridian/competitor-scan.js create mode 100644 plugins/meridian/competitors.js create mode 100644 plugins/meridian/ideate.js create mode 100644 plugins/meridian/package.json create mode 100644 plugins/meridian/persona.js create mode 100644 plugins/meridian/personas.js create mode 100644 plugins/meridian/projects.js create mode 100644 plugins/meridian/test/commands.test.js create mode 100644 plugins/meridian/test/helpers.js create mode 100644 plugins/meridian/test/utils.test.js create mode 100644 plugins/meridian/utils.js create mode 100644 plugins/meridian/webcmd-plugin.json diff --git a/plugins/meridian/README.md b/plugins/meridian/README.md new file mode 100644 index 00000000..a26b93bb --- /dev/null +++ b/plugins/meridian/README.md @@ -0,0 +1,109 @@ +# webcmd-plugin-meridian + +Webcmd commands for [Meridian](https://app.getmeridian.tech) (getmeridian.tech), the founder copilot whose Astra agent takes an idea from ideation to validated market intelligence. The plugin drives the same APIs the Meridian app uses, through a logged-in Webcmd browser profile. + +## Install + +```bash +webcmd plugin install github:agentrhq/webcmd/meridian +``` + +## Authorize your Meridian account + +Every command runs against your Meridian account, so authorize the browser profile first: + +```bash +webcmd meridian login +``` + +This opens Meridian in the Webcmd browser. Sign in — or sign up if you have no account yet (email/password or Google; new email accounts must verify their address before login works). Then confirm: + +```bash +webcmd meridian whoami +``` + +Commands raise a typed auth error (instead of empty results) whenever the session is missing or expired. + +## Commands + +| Command | Description | +| --- | --- | +| `webcmd meridian login` | Open Meridian sign-in/sign-up to authorize this browser profile | +| `webcmd meridian whoami` | Show the signed-in Meridian account (email, org, credits) | +| `webcmd meridian projects` | List the projects (initiatives) in your account | +| `webcmd meridian ideate` | One turn of the Astra ideation chat; repeat until stage=ready_to_start | +| `webcmd meridian approve` | Approve the ready draft and start the project | +| `webcmd meridian persona` | Build and save an ideal-user persona from behaviours or interview notes | +| `webcmd meridian personas` | List the personas saved on a project | +| `webcmd meridian competitors` | List a project's competitor board, prioritized by relevance | +| `webcmd meridian competitor-add` | Add a competitor by name or website | +| `webcmd meridian competitor-scan` | Rebuild the prioritized competitor landscape | +| `webcmd meridian agent-status` | Show the Astra background agent and its branch states | +| `webcmd meridian agent-start` | Start/resume the Astra market-intelligence agent (`--scan` for an immediate tick) | +| `webcmd meridian agent-pause` | Pause the Astra background agent | + +## Workflows + +### Ideation → Approve & Start + +Astra interviews you about the idea and web-researches the market. Keep answering until the readiness gate opens (it needs `problem_statement`, `solution`, and `market_opportunity` confidence, and at least three founder turns): + +```bash +webcmd meridian ideate "AI agents that QA mobile apps before release" -f json +webcmd meridian ideate "Mobile teams at seed-stage startups; they ship weekly" -f json +webcmd meridian ideate "They find UI regressions only after users complain" --research -f json +``` + +Each turn returns the running summary — problem statement, solution, market opportunity, differentiation — plus readiness scores, tap-to-answer `suggestions`, and the web-research `research_sources` behind Astra's context. When `stage` is `ready_to_start`, the row also carries the three-stage plan. Then: + +```bash +webcmd meridian approve -f json +``` + +Astra creates the project and sets it up (starter persona, competitor scan, first signals). The conversation state lives in the persistent browser session; `--reset` starts a fresh draft. + +### Ideal-user personas + +From observed behaviour, or from uploaded user-interview notes: + +```bash +webcmd meridian persona "Churned users all mention onboarding friction" -f json +webcmd meridian persona --file ./interviews/batch-3.md -f json +webcmd meridian personas -f json +``` + +When Astra marks the draft ready, the persona is saved to your Meridian account automatically (OCEAN traits included). + +To mine a Reddit community for persona signals, compose with the `reddit` plugin: pull the community and its top discussions (`webcmd reddit subreddit-info r/mobiledev`, `webcmd reddit subreddit r/mobiledev --limit 25`), then feed the recurring behaviours and complaints into `webcmd meridian persona`. + +### Competitor research + +Meridian's own scan builds and prioritizes the board for the project context: + +```bash +webcmd meridian competitor-scan +webcmd meridian competitors -f json +``` + +To widen the net, source candidates with the research plugins first — `webcmd ycombinator companies "mobile testing"`, `webcmd hackernews search "mobile app QA"`, `webcmd producthunt search "app testing"` — then add the credible ones so the next scan positions them: + +```bash +webcmd meridian competitor-add acme.ai +webcmd meridian competitor-scan +``` + +### Market intelligence (Astra background agent) + +```bash +webcmd meridian agent-start --scan +webcmd meridian agent-status -f json +webcmd meridian agent-pause +``` + +The agent runs 24×7 server-side across parallel branches (market research, synthesis, planning, execution); `agent-status` shows each branch's state and any checkpoints awaiting a human decision. + +## Notes + +- Meridian meters some actions in account credits (new project, competitor scan/add, research turns). Commands surface the API's insufficient-credit errors as actionable messages. +- Astra turns can take a while (LLM + web research); every long-running command takes `--timeout`. +- Chat drafts (ideation, persona) keep their conversation state in the persistent Webcmd browser session; ideation drafts are also autosaved to your account so they stay resumable in the Meridian app. diff --git a/plugins/meridian/agent-pause.js b/plugins/meridian/agent-pause.js new file mode 100644 index 00000000..cb8d9238 --- /dev/null +++ b/plugins/meridian/agent-pause.js @@ -0,0 +1,32 @@ +import { cli, Strategy } from '@agentrhq/webcmd/registry'; +import { MERIDIAN_DOMAIN, apiFetch, normalizeText, requireProjectId } from './utils.js'; + +export const agentPauseCommand = cli({ + site: 'meridian', + name: 'agent-pause', + access: 'write', + description: 'Pause the Astra background agent on a Meridian project', + example: 'webcmd meridian agent-pause ', + domain: MERIDIAN_DOMAIN, + strategy: Strategy.COOKIE, + browser: true, + navigateBefore: false, + siteSession: 'persistent', + args: [ + { name: 'project', positional: true, required: true, help: 'Meridian project id (see: webcmd meridian projects)' }, + ], + columns: ['status', 'detail', 'next'], + func: async (page, kwargs) => { + const projectId = requireProjectId(kwargs.project); + const instance = await apiFetch(page, `/astra/initiatives/${encodeURIComponent(projectId)}/pause`, { + method: 'POST', + body: {}, + label: 'agent pause', + }); + return [{ + status: normalizeText(instance?.status) || 'PAUSED', + detail: 'Astra background agent paused; resume any time to continue market intelligence', + next: `webcmd meridian agent-start ${projectId}`, + }]; + }, +}); diff --git a/plugins/meridian/agent-start.js b/plugins/meridian/agent-start.js new file mode 100644 index 00000000..831b1908 --- /dev/null +++ b/plugins/meridian/agent-start.js @@ -0,0 +1,45 @@ +import { cli, Strategy } from '@agentrhq/webcmd/registry'; +import { MERIDIAN_DOMAIN, apiFetch, normalizeText, parseBoolFlag, requireBoundedInt, requireProjectId } from './utils.js'; + +export const agentStartCommand = cli({ + site: 'meridian', + name: 'agent-start', + access: 'write', + description: 'Start (or resume) the Astra market-intelligence background agent on a Meridian project', + example: 'webcmd meridian agent-start --scan', + domain: MERIDIAN_DOMAIN, + strategy: Strategy.COOKIE, + browser: true, + navigateBefore: false, + siteSession: 'persistent', + args: [ + { name: 'project', positional: true, required: true, help: 'Meridian project id (see: webcmd meridian projects)' }, + { name: 'scan', type: 'boolean', default: false, help: 'Also run one blocking market-research scan tick right now' }, + { name: 'timeout', type: 'int', default: 240, help: 'Max seconds to wait for the --scan tick (30-600)' }, + ], + columns: ['status', 'detail', 'next'], + func: async (page, kwargs) => { + const projectId = requireProjectId(kwargs.project); + const timeoutSeconds = requireBoundedInt(kwargs.timeout, 240, 30, 600, 'meridian agent-start --timeout'); + const instance = await apiFetch(page, `/astra/initiatives/${encodeURIComponent(projectId)}/resume`, { + method: 'POST', + body: {}, + label: 'agent resume', + }); + let detail = `Astra agent is ${normalizeText(instance?.status) || 'ACTIVE'}; it keeps researching, validating, and positioning in the background`; + if (parseBoolFlag(kwargs.scan)) { + const scanned = await apiFetch(page, `/astra/initiatives/${encodeURIComponent(projectId)}/scan-now`, { + method: 'POST', + body: {}, + timeoutSeconds, + label: 'agent scan', + }); + detail += ` | scan tick: ${normalizeText(scanned?.status) || 'done'}`; + } + return [{ + status: normalizeText(instance?.status) || 'ACTIVE', + detail, + next: `webcmd meridian agent-status ${projectId}`, + }]; + }, +}); diff --git a/plugins/meridian/agent-status.js b/plugins/meridian/agent-status.js new file mode 100644 index 00000000..1d37874a --- /dev/null +++ b/plugins/meridian/agent-status.js @@ -0,0 +1,50 @@ +import { cli, Strategy } from '@agentrhq/webcmd/registry'; +import { CommandExecutionError } from '@agentrhq/webcmd/errors'; +import { MERIDIAN_DOMAIN, apiFetch, normalizeText, requireProjectId } from './utils.js'; + +export const agentStatusCommand = cli({ + site: 'meridian', + name: 'agent-status', + access: 'read', + description: 'Show the Astra background agent status and branch states for a Meridian project', + example: 'webcmd meridian agent-status -f json', + domain: MERIDIAN_DOMAIN, + strategy: Strategy.COOKIE, + browser: true, + navigateBefore: false, + siteSession: 'persistent', + args: [ + { name: 'project', positional: true, required: true, help: 'Meridian project id (see: webcmd meridian projects)' }, + ], + columns: ['branch', 'state', 'stage', 'summary'], + func: async (page, kwargs) => { + const projectId = requireProjectId(kwargs.project); + const overview = await apiFetch(page, `/astra/initiatives/${encodeURIComponent(projectId)}/overview`, { + label: 'agent status', + }); + if (!overview || typeof overview !== 'object') { + throw new CommandExecutionError('Meridian agent status returned an unreadable response'); + } + const awaiting = Number(overview.awaiting_human); + const rootSummary = [ + normalizeText(overview.initiative_name), + Number.isFinite(awaiting) && awaiting > 0 ? `${awaiting} checkpoint(s) awaiting you` : '', + ].filter(Boolean).join(' — '); + const rows = [{ + branch: 'root', + state: normalizeText(overview.status) || 'UNKNOWN', + stage: normalizeText(overview.root_state) || null, + summary: rootSummary || null, + }]; + const branches = overview.branches && typeof overview.branches === 'object' ? overview.branches : {}; + for (const [branch, info] of Object.entries(branches)) { + rows.push({ + branch, + state: normalizeText(info?.state) || 'UNKNOWN', + stage: normalizeText(info?.stage) || null, + summary: normalizeText(info?.summary) || null, + }); + } + return rows; + }, +}); diff --git a/plugins/meridian/approve.js b/plugins/meridian/approve.js new file mode 100644 index 00000000..f13f44ab --- /dev/null +++ b/plugins/meridian/approve.js @@ -0,0 +1,108 @@ +import { cli, Strategy } from '@agentrhq/webcmd/registry'; +import { ArgumentError, CommandExecutionError } from '@agentrhq/webcmd/errors'; +import { + IDEATION_STATE_KEY, MERIDIAN_APP_ORIGIN, MERIDIAN_DOMAIN, + apiFetch, clearPageState, loadPageState, normalizeText, parseBoolFlag, pollJob, requireBoundedInt, +} from './utils.js'; + +// Same payload mapping as the app's buildInitiativeDraft — the backend expects +// this exact initiative shape on start-initiative-job. +function buildInitiativePayload(summary) { + return { + name: normalizeText(summary.initiative_name) + || normalizeText(summary.solution || summary.problem_statement || 'New Project').split(/[.!?]/)[0].slice(0, 60), + problem_statement: summary.problem_statement || '', + value_proposition: summary.solution || '', + product_definition: summary.solution || '', + market_definition: summary.market_opportunity || '', + current_stage: summary.current_stage || 'IDEATION', + goal_stage: summary.goal_stage || 'PMF', + burn_rate_monthly: summary.burn_rate_monthly ?? null, + operating_capital: summary.operating_capital ?? null, + goal_eta: summary.goal_eta || '', + repo_ids: [], + }; +} + +export const approveCommand = cli({ + site: 'meridian', + name: 'approve', + access: 'write', + description: 'Approve the ready ideation draft and start the project in your Meridian account (costs Meridian credits)', + example: 'webcmd meridian approve -f json', + domain: MERIDIAN_DOMAIN, + strategy: Strategy.COOKIE, + browser: true, + navigateBefore: false, + siteSession: 'persistent', + args: [ + { name: 'force', type: 'boolean', default: false, help: 'Start even if Astra has not marked the draft ready' }, + { name: 'timeout', type: 'int', default: 360, help: 'Max seconds to wait for Astra to set the project up (30-600)' }, + ], + columns: ['status', 'project_id', 'project_name', 'url', 'detail'], + func: async (page, kwargs) => { + const timeoutSeconds = requireBoundedInt(kwargs.timeout, 360, 30, 600, 'meridian approve --timeout'); + const state = await loadPageState(page, IDEATION_STATE_KEY, { messages: [] }); + if (!Array.isArray(state.messages) || !state.messages.length || !state.assessment) { + throw new ArgumentError( + 'no ideation draft found in this browser session', + 'Draft one first: webcmd meridian ideate ""', + ); + } + if (!state.ready && !parseBoolFlag(kwargs.force)) { + throw new ArgumentError( + 'the ideation draft has not reached the Approve & Start stage yet', + 'Keep answering Astra via `webcmd meridian ideate "..."` until stage=ready_to_start, or pass --force.', + ); + } + + const summary = state.assessment.summary && typeof state.assessment.summary === 'object' + ? state.assessment.summary + : {}; + const started = await apiFetch(page, '/astra/onboarding/start-initiative-job', { + method: 'POST', + body: { + initiative: buildInitiativePayload(summary), + messages: state.messages.map((entry) => ({ role: entry.role, content: entry.content })), + links: Array.isArray(state.links) ? state.links : [], + documents: Array.isArray(state.documents) ? state.documents : [], + assessment: state.assessment, + }, + timeoutSeconds: 90, + label: 'project start', + }); + const job = await pollJob(page, started?.job_id, { timeoutSeconds, label: 'project setup' }); + + const initiative = job?.result?.initiative; + const projectId = String(initiative?.id ?? ''); + if (!projectId) { + throw new CommandExecutionError( + `Meridian project setup finished without a project id: ${JSON.stringify(job?.result ?? {}).slice(0, 300)}`, + ); + } + // The draft became a real project — retire the saved draft card. Best + // effort: the project already exists even if this cleanup fails. + if (state.session_id) { + try { + await apiFetch(page, `/astra/onboarding/draft-sessions/${encodeURIComponent(state.session_id)}/discard`, { + method: 'POST', + body: {}, + timeoutSeconds: 30, + label: 'draft discard', + }); + } catch { + // Non-fatal cleanup. + } + } + await clearPageState(page, IDEATION_STATE_KEY); + + const redirect = normalizeText(job?.result?.redirect); + return [{ + status: 'started', + project_id: projectId, + project_name: normalizeText(initiative?.name) || null, + url: `${MERIDIAN_APP_ORIGIN}${redirect || `/app/initiatives/${projectId}/dashboard`}`, + detail: 'Astra set the project up (starter persona, competitor scan, first signals). Next: webcmd meridian agent-status ' + projectId, + }]; + }, +}); diff --git a/plugins/meridian/auth.js b/plugins/meridian/auth.js new file mode 100644 index 00000000..08c3bd1c --- /dev/null +++ b/plugins/meridian/auth.js @@ -0,0 +1,58 @@ +import { AuthRequiredError, CommandExecutionError } from '@agentrhq/webcmd/errors'; +import { registerSiteAuthCommands } from '@agentrhq/webcmd/plugin-runtime'; +import { + MERIDIAN_API_ORIGIN, MERIDIAN_APP_ORIGIN, MERIDIAN_DOMAIN, + ensureOnMeridianApp, hasMeridianSessionCookie, normalizeText, +} from './utils.js'; + +async function verifyMeridianIdentity(page) { + await ensureOnMeridianApp(page); + if (!await hasMeridianSessionCookie(page)) { + throw new AuthRequiredError( + MERIDIAN_DOMAIN, + 'Meridian session_token cookie missing — sign in (or sign up) at app.getmeridian.tech first.', + ); + } + const result = await page.evaluate(`(async () => { + try { + var res = await fetch(${JSON.stringify(`${MERIDIAN_API_ORIGIN}/api/auth/me`)}, { + credentials: 'include', + headers: { 'Accept': 'application/json' }, + }); + if (res.status === 401 || res.status === 403) { + return { kind: 'auth', detail: 'Meridian /auth/me returned HTTP ' + res.status + ' — session expired or not signed in' }; + } + if (!res.ok) return { kind: 'http', httpStatus: res.status }; + var me = await res.json(); + if (!me || !me.email) { + return { kind: 'auth', detail: 'Meridian /auth/me returned no identity — anonymous session' }; + } + return { ok: true, identity: me }; + } catch (e) { + return { kind: 'exception', detail: String((e && e.message) || e) }; + } + })()`); + if (result?.kind === 'auth') throw new AuthRequiredError(MERIDIAN_DOMAIN, result.detail); + if (result?.kind === 'http') throw new CommandExecutionError(`HTTP ${result.httpStatus} from Meridian /auth/me`); + if (result?.kind === 'exception') throw new CommandExecutionError(`Meridian whoami failed: ${result.detail}`); + if (!result?.ok) throw new CommandExecutionError(`Unexpected Meridian identity probe: ${JSON.stringify(result)}`); + const me = result.identity; + return { + email: normalizeText(me.email), + name: normalizeText(me.name) || null, + org: normalizeText(me.org_name) || null, + credits: Number.isFinite(Number(me.credits_balance)) ? Number(me.credits_balance) : null, + }; +} + +registerSiteAuthCommands({ + site: 'meridian', + domain: MERIDIAN_DOMAIN, + // Sign-in is a modal on the app landing page (there is no /login route); + // signed-in profiles are redirected straight into /app/initiatives. + loginUrl: `${MERIDIAN_APP_ORIGIN}/`, + loginDescription: 'Open Meridian so you can sign in or sign up (email/password or Google) and authorize this browser profile', + columns: ['email', 'name', 'org', 'credits'], + quickCheck: hasMeridianSessionCookie, + verify: verifyMeridianIdentity, +}); diff --git a/plugins/meridian/competitor-add.js b/plugins/meridian/competitor-add.js new file mode 100644 index 00000000..b097ed33 --- /dev/null +++ b/plugins/meridian/competitor-add.js @@ -0,0 +1,53 @@ +import { cli, Strategy } from '@agentrhq/webcmd/registry'; +import { CommandExecutionError } from '@agentrhq/webcmd/errors'; +import { + MERIDIAN_DOMAIN, apiFetch, normalizeText, requireBoundedInt, requireNonEmptyText, requireProjectId, +} from './utils.js'; + +export const competitorAddCommand = cli({ + site: 'meridian', + name: 'competitor-add', + access: 'write', + description: 'Add a competitor to a Meridian project by name or website (e.g. one found via the ycombinator/hackernews/producthunt plugins)', + example: 'webcmd meridian competitor-add acme.ai', + domain: MERIDIAN_DOMAIN, + strategy: Strategy.COOKIE, + browser: true, + navigateBefore: false, + siteSession: 'persistent', + args: [ + { name: 'project', positional: true, required: true, help: 'Meridian project id (see: webcmd meridian projects)' }, + { + name: 'competitor', + positional: true, + required: true, + help: 'Competitor website URL or bare company name (Meridian resolves the official site)', + }, + { name: 'timeout', type: 'int', default: 120, help: 'Max seconds to wait for enrichment (10-600)' }, + ], + columns: ['status', 'competitor', 'detail', 'next'], + func: async (page, kwargs) => { + const projectId = requireProjectId(kwargs.project); + const competitor = requireNonEmptyText( + kwargs.competitor, + 'competitor name or URL', + 'Example: webcmd meridian competitor-add acme.ai', + ); + const timeoutSeconds = requireBoundedInt(kwargs.timeout, 120, 10, 600, 'meridian competitor-add --timeout'); + const result = await apiFetch(page, `/initiatives/${encodeURIComponent(projectId)}/competitors/add-manual`, { + method: 'POST', + body: { website: competitor }, + timeoutSeconds, + label: 'competitor add', + }); + if (!result || typeof result !== 'object') { + throw new CommandExecutionError('Meridian competitor add returned an unreadable response'); + } + return [{ + status: 'added', + competitor, + detail: normalizeText(result.message ?? result.status) || `Competitor queued on project ${projectId}`, + next: `webcmd meridian competitors ${projectId}`, + }]; + }, +}); diff --git a/plugins/meridian/competitor-scan.js b/plugins/meridian/competitor-scan.js new file mode 100644 index 00000000..21bdb3a5 --- /dev/null +++ b/plugins/meridian/competitor-scan.js @@ -0,0 +1,38 @@ +import { cli, Strategy } from '@agentrhq/webcmd/registry'; +import { MERIDIAN_DOMAIN, apiFetch, normalizeText, pollJob, requireBoundedInt, requireProjectId } from './utils.js'; + +export const competitorScanCommand = cli({ + site: 'meridian', + name: 'competitor-scan', + access: 'write', + description: 'Run Meridian\'s competitor landscape scan so the board is rebuilt and prioritized for the project context (costs Meridian credits)', + example: 'webcmd meridian competitor-scan ', + domain: MERIDIAN_DOMAIN, + strategy: Strategy.COOKIE, + browser: true, + navigateBefore: false, + siteSession: 'persistent', + args: [ + { name: 'project', positional: true, required: true, help: 'Meridian project id (see: webcmd meridian projects)' }, + { name: 'timeout', type: 'int', default: 300, help: 'Max seconds to wait for the scan job (30-600)' }, + ], + columns: ['status', 'competitor_count', 'detail', 'next'], + func: async (page, kwargs) => { + const projectId = requireProjectId(kwargs.project); + const timeoutSeconds = requireBoundedInt(kwargs.timeout, 300, 30, 600, 'meridian competitor-scan --timeout'); + const started = await apiFetch(page, `/initiatives/${encodeURIComponent(projectId)}/competitors/generate`, { + method: 'POST', + body: {}, + timeoutSeconds: 90, + label: 'competitor scan', + }); + const job = await pollJob(page, started?.job_id, { timeoutSeconds, label: 'competitor scan' }); + const competitors = Array.isArray(job?.result?.competitors) ? job.result.competitors.length : null; + return [{ + status: 'completed', + competitor_count: competitors, + detail: normalizeText(job?.stage) || 'Competitor landscape rebuilt for the project context', + next: `webcmd meridian competitors ${projectId}`, + }]; + }, +}); diff --git a/plugins/meridian/competitors.js b/plugins/meridian/competitors.js new file mode 100644 index 00000000..2369c4cf --- /dev/null +++ b/plugins/meridian/competitors.js @@ -0,0 +1,54 @@ +import { cli, Strategy } from '@agentrhq/webcmd/registry'; +import { CommandExecutionError, EmptyResultError } from '@agentrhq/webcmd/errors'; +import { MERIDIAN_DOMAIN, apiFetch, normalizeText, requireProjectId, toHttpsUrlOrNull } from './utils.js'; + +export const competitorsCommand = cli({ + site: 'meridian', + name: 'competitors', + access: 'read', + description: 'List the competitor board of a Meridian project, prioritized by relevance to its context', + example: 'webcmd meridian competitors -f json', + domain: MERIDIAN_DOMAIN, + strategy: Strategy.COOKIE, + browser: true, + navigateBefore: false, + siteSession: 'persistent', + args: [ + { name: 'project', positional: true, required: true, help: 'Meridian project id (see: webcmd meridian projects)' }, + ], + columns: ['rank', 'name', 'category', 'relevance', 'description', 'top_threat', 'website'], + func: async (page, kwargs) => { + const projectId = requireProjectId(kwargs.project); + const analysis = await apiFetch(page, `/initiatives/${encodeURIComponent(projectId)}/competitors`, { + label: 'competitors list', + }); + const items = Array.isArray(analysis?.competitors) ? analysis.competitors : null; + if (!items) { + throw new CommandExecutionError('Meridian competitors list returned an unexpected payload shape'); + } + if (!items.length) { + throw new EmptyResultError( + 'meridian competitors', + 'No competitors on this project yet. Run `webcmd meridian competitor-scan ` or add one with competitor-add.', + ); + } + const scored = items.map((item) => ({ + item, + relevance: Number.isFinite(Number(item?.relevance_score)) ? Number(item.relevance_score) : 0, + })); + scored.sort((a, b) => b.relevance - a.relevance); + return scored.map(({ item, relevance }, index) => { + const threats = Array.isArray(item?.unique_features) ? item.unique_features : []; + const topThreat = threats.find((feature) => feature?.threat_level === 'HIGH') ?? threats[0]; + return { + rank: index + 1, + name: normalizeText(item?.name) || null, + category: normalizeText(item?.category) || null, + relevance, + description: normalizeText(item?.description) || null, + top_threat: normalizeText(topThreat?.name) || null, + website: toHttpsUrlOrNull(item?.website), + }; + }); + }, +}); diff --git a/plugins/meridian/ideate.js b/plugins/meridian/ideate.js new file mode 100644 index 00000000..84d934a3 --- /dev/null +++ b/plugins/meridian/ideate.js @@ -0,0 +1,121 @@ +import { cli, Strategy } from '@agentrhq/webcmd/registry'; +import { CommandExecutionError } from '@agentrhq/webcmd/errors'; +import { + IDEATION_STATE_KEY, MERIDIAN_DOMAIN, + apiFetch, clearPageState, collectTurnSources, formatConfidence, formatSuggestions, + loadPageState, newDraftSessionId, normalizeText, parseBoolFlag, requireBoundedInt, + requireNonEmptyText, savePageState, +} from './utils.js'; + +const CONFIDENCE_FIELDS = ['problem_statement', 'solution', 'market_opportunity']; +const EMPTY_IDEATION_STATE = { messages: [], links: [], documents: [] }; + +function formatStagePlan(stagePlan) { + const stages = Array.isArray(stagePlan?.stages) ? stagePlan.stages : []; + return stages + .map((stage) => { + const key = normalizeText(stage?.key ?? stage?.title); + const description = normalizeText(stage?.description); + return description ? `${key}: ${description}` : key; + }) + .filter(Boolean) + .join(' | '); +} + +export const ideateCommand = cli({ + site: 'meridian', + name: 'ideate', + access: 'write', + description: 'Send one turn of the Astra ideation chat; repeat until the draft reaches the Approve & Start stage', + example: 'webcmd meridian ideate "An AI copilot that validates startup ideas" -f json', + domain: MERIDIAN_DOMAIN, + strategy: Strategy.COOKIE, + browser: true, + navigateBefore: false, + siteSession: 'persistent', + args: [ + { name: 'message', positional: true, required: true, help: 'Your idea, or the answer to Astra\'s last question' }, + { name: 'research', type: 'boolean', default: false, help: 'Ask Astra to run web research on this turn' }, + { name: 'reset', type: 'boolean', default: false, help: 'Discard the current draft conversation and start over' }, + { name: 'timeout', type: 'int', default: 240, help: 'Max seconds to wait for the Astra turn (10-600)' }, + ], + columns: [ + 'stage', 'reply', 'problem_statement', 'solution', 'market_opportunity', 'differentiation', + 'readiness', 'missing_context', 'suggestions', 'plan', 'research_sources', + ], + func: async (page, kwargs) => { + const message = requireNonEmptyText( + kwargs.message, + 'meridian ideate message', + 'Example: webcmd meridian ideate "AI agents that test mobile apps"', + ); + const timeoutSeconds = requireBoundedInt(kwargs.timeout, 240, 10, 600, 'meridian ideate --timeout'); + if (parseBoolFlag(kwargs.reset)) await clearPageState(page, IDEATION_STATE_KEY); + + const state = await loadPageState(page, IDEATION_STATE_KEY, EMPTY_IDEATION_STATE); + const sessionId = state.session_id || newDraftSessionId(); + const messages = [...state.messages, { role: 'user', content: message }]; + + const turn = await apiFetch(page, '/astra/onboarding/draft-message', { + method: 'POST', + body: { + messages, + documents: state.documents, + previous_confidence: state.confidence ?? Object.fromEntries(CONFIDENCE_FIELDS.map((field) => [field, 0])), + session_id: sessionId, + web_search: parseBoolFlag(kwargs.research), + }, + timeoutSeconds, + label: 'ideation turn', + }); + if (!turn || typeof turn !== 'object') { + throw new CommandExecutionError('Meridian ideation turn returned an unreadable response'); + } + if (turn.blocked === true || turn.phase === 'blocked') { + throw new CommandExecutionError( + 'Astra declined this idea (content-safety guardrail). Rephrase the idea and re-run with --reset.', + ); + } + + const reply = normalizeText(turn.reply); + const summary = turn.summary && typeof turn.summary === 'object' ? turn.summary : {}; + const ready = turn.ready_to_start === true; + const nextMessages = [...messages, { role: 'assistant', content: reply }]; + + await savePageState(page, IDEATION_STATE_KEY, { + session_id: sessionId, + messages: nextMessages, + documents: state.documents, + links: state.links, + confidence: turn.confidence ?? {}, + assessment: turn, + ready, + }); + // Mirror the app's autosave so the draft shows up (and stays resumable) + // in the user's Meridian account; a failure here must not eat the turn. + try { + await apiFetch(page, '/astra/onboarding/autosave-session', { + method: 'POST', + body: { session_id: sessionId, messages: nextMessages, assessment: turn, documents: state.documents, links: state.links }, + timeoutSeconds: 30, + label: 'draft autosave', + }); + } catch { + // Draft autosave is a convenience mirror; the turn already succeeded. + } + + return [{ + stage: ready ? 'ready_to_start' : 'in_progress', + reply, + problem_statement: normalizeText(summary.problem_statement) || null, + solution: normalizeText(summary.solution) || null, + market_opportunity: normalizeText(summary.market_opportunity) || null, + differentiation: normalizeText(turn.differentiation) || null, + readiness: formatConfidence(turn.confidence, CONFIDENCE_FIELDS), + missing_context: Array.isArray(turn.missing_context) ? turn.missing_context.join(', ') || null : null, + suggestions: formatSuggestions(turn.suggestions) || null, + plan: formatStagePlan(turn.stage_plan) || null, + research_sources: collectTurnSources(turn) || null, + }]; + }, +}); diff --git a/plugins/meridian/package.json b/plugins/meridian/package.json new file mode 100644 index 00000000..7d7d41ab --- /dev/null +++ b/plugins/meridian/package.json @@ -0,0 +1,9 @@ +{ + "name": "webcmd-plugin-meridian", + "version": "0.1.0", + "type": "module", + "description": "Meridian (getmeridian.tech) founder workflows: Astra ideation chat, ideal-user personas, competitor research, and the Astra market-intelligence agent", + "peerDependencies": { + "@agentrhq/webcmd": ">=0.5.3" + } +} diff --git a/plugins/meridian/persona.js b/plugins/meridian/persona.js new file mode 100644 index 00000000..c5a29ab3 --- /dev/null +++ b/plugins/meridian/persona.js @@ -0,0 +1,135 @@ +import { cli, Strategy } from '@agentrhq/webcmd/registry'; +import { ArgumentError, CommandExecutionError } from '@agentrhq/webcmd/errors'; +import { + MERIDIAN_DOMAIN, + apiFetch, clearPageState, formatSuggestions, loadPageState, normalizeText, parseBoolFlag, + personaStateKey, requireBoundedInt, requireProjectId, savePageState, +} from './utils.js'; + +const MAX_INTERVIEW_BYTES = 262_144; +const EMPTY_PERSONA_STATE = { messages: [] }; + +async function readInterviewFile(filePath) { + const fs = await import('node:fs'); + const path = await import('node:path'); + const absPath = path.default.resolve(String(filePath)); + if (!fs.default.existsSync(absPath)) { + throw new ArgumentError(`interview file not found: ${absPath}`); + } + const stats = fs.default.statSync(absPath); + if (stats.size > MAX_INTERVIEW_BYTES) { + throw new ArgumentError( + `interview file is ${(stats.size / 1024).toFixed(0)} KB; max is ${MAX_INTERVIEW_BYTES / 1024} KB of plain text`, + ); + } + return { + filename: path.default.basename(absPath), + text: fs.default.readFileSync(absPath, 'utf8'), + }; +} + +export const personaCommand = cli({ + site: 'meridian', + name: 'persona', + access: 'write', + description: 'Build and save an ideal-user persona from observed behaviours or user-interview notes', + example: 'webcmd meridian persona "Power users batch tasks on Sunday nights" -f json', + domain: MERIDIAN_DOMAIN, + strategy: Strategy.COOKIE, + browser: true, + navigateBefore: false, + siteSession: 'persistent', + args: [ + { name: 'project', positional: true, required: true, help: 'Meridian project id (see: webcmd meridian projects)' }, + { + name: 'message', + positional: true, + required: false, + help: 'Observed user behaviour, or the answer to Astra\'s last persona question', + }, + { + name: 'file', + help: 'Plain-text user-interview notes to fold into the persona (txt/md, max 256 KB)', + file: { + direction: 'input', + pathKind: 'file', + multiple: false, + contentTypes: ['text/plain', 'text/markdown'], + maxBytes: MAX_INTERVIEW_BYTES, + }, + }, + { name: 'reset', type: 'boolean', default: false, help: 'Discard the in-progress persona draft for this project' }, + { name: 'timeout', type: 'int', default: 180, help: 'Max seconds to wait for the persona turn (10-600)' }, + ], + columns: ['status', 'reply', 'persona_id', 'persona_name', 'tagline', 'missing_fields', 'suggestions'], + func: async (page, kwargs) => { + const projectId = requireProjectId(kwargs.project); + const timeoutSeconds = requireBoundedInt(kwargs.timeout, 180, 10, 600, 'meridian persona --timeout'); + const stateKey = personaStateKey(projectId); + if (parseBoolFlag(kwargs.reset)) await clearPageState(page, stateKey); + + const message = normalizeText(kwargs.message); + const documents = []; + if (kwargs.file) documents.push(await readInterviewFile(kwargs.file)); + if (!message && !documents.length) { + throw new ArgumentError( + 'meridian persona needs observed behaviour text or --file interview notes', + 'Example: webcmd meridian persona "Churned users all mention onboarding friction"', + ); + } + + const state = await loadPageState(page, stateKey, EMPTY_PERSONA_STATE); + const messages = [ + ...state.messages, + { role: 'user', content: message || 'Build the persona from the attached user-interview notes.' }, + ]; + const turn = await apiFetch(page, `/initiatives/${encodeURIComponent(projectId)}/personas/chat`, { + method: 'POST', + body: { messages, documents, summary: state.summary ?? {} }, + timeoutSeconds, + label: 'persona turn', + }); + if (!turn || typeof turn !== 'object') { + throw new CommandExecutionError('Meridian persona turn returned an unreadable response'); + } + + const reply = normalizeText(turn.reply); + const summary = turn.summary && typeof turn.summary === 'object' ? turn.summary : {}; + const missingFields = Array.isArray(turn.missing_fields) ? turn.missing_fields.join(', ') : ''; + + if (turn.ready_to_save !== true) { + await savePageState(page, stateKey, { + messages: [...messages, { role: 'assistant', content: reply }], + summary, + }); + return [{ + status: 'in_progress', + reply, + persona_id: null, + persona_name: normalizeText(summary.name) || null, + tagline: normalizeText(summary.tagline) || null, + missing_fields: missingFields || null, + suggestions: formatSuggestions(turn.suggestions) || null, + }]; + } + + // Astra committed a full draft — save it to the account, like the app's + // chat drawer does on ready_to_save. + const saved = await apiFetch(page, `/initiatives/${encodeURIComponent(projectId)}/personas`, { + method: 'POST', + body: { summary, source: 'astra' }, + timeoutSeconds: 60, + label: 'persona save', + }); + await clearPageState(page, stateKey); + return [{ + status: 'saved', + reply, + persona_id: String(saved?.id ?? '') || null, + persona_name: normalizeText(saved?.name ?? summary.name) || null, + tagline: normalizeText(saved?.tagline ?? summary.tagline) || null, + missing_fields: null, + suggestions: null, + }]; + }, +}); diff --git a/plugins/meridian/personas.js b/plugins/meridian/personas.js new file mode 100644 index 00000000..d82d5870 --- /dev/null +++ b/plugins/meridian/personas.js @@ -0,0 +1,47 @@ +import { cli, Strategy } from '@agentrhq/webcmd/registry'; +import { CommandExecutionError, EmptyResultError } from '@agentrhq/webcmd/errors'; +import { MERIDIAN_DOMAIN, apiFetch, normalizeText, requireProjectId } from './utils.js'; + +export const personasCommand = cli({ + site: 'meridian', + name: 'personas', + access: 'read', + description: 'List the ideal-user personas saved on a Meridian project', + example: 'webcmd meridian personas -f json', + domain: MERIDIAN_DOMAIN, + strategy: Strategy.COOKIE, + browser: true, + navigateBefore: false, + siteSession: 'persistent', + args: [ + { name: 'project', positional: true, required: true, help: 'Meridian project id (see: webcmd meridian projects)' }, + ], + columns: ['id', 'name', 'tagline', 'identity', 'pains', 'completeness', 'source'], + func: async (page, kwargs) => { + const projectId = requireProjectId(kwargs.project); + const items = await apiFetch(page, `/initiatives/${encodeURIComponent(projectId)}/personas`, { + label: 'personas list', + }); + if (!Array.isArray(items)) { + throw new CommandExecutionError('Meridian personas list returned an unexpected payload shape'); + } + if (!items.length) { + throw new EmptyResultError( + 'meridian personas', + 'No personas on this project yet. Build one with `webcmd meridian persona ""`.', + ); + } + return items.map((item) => { + const completeness = Number(item?.completeness); + return { + id: String(item?.id ?? ''), + name: normalizeText(item?.name) || null, + tagline: normalizeText(item?.tagline) || null, + identity: normalizeText(item?.identity) || null, + pains: Array.isArray(item?.pains) ? item.pains.map(normalizeText).filter(Boolean).join('; ') || null : null, + completeness: Number.isFinite(completeness) ? completeness : null, + source: normalizeText(item?.source) || null, + }; + }); + }, +}); diff --git a/plugins/meridian/projects.js b/plugins/meridian/projects.js new file mode 100644 index 00000000..6abc57ba --- /dev/null +++ b/plugins/meridian/projects.js @@ -0,0 +1,44 @@ +import { cli, Strategy } from '@agentrhq/webcmd/registry'; +import { CommandExecutionError, EmptyResultError } from '@agentrhq/webcmd/errors'; +import { MERIDIAN_APP_ORIGIN, MERIDIAN_DOMAIN, apiFetch, normalizeText } from './utils.js'; + +export const projectsCommand = cli({ + site: 'meridian', + name: 'projects', + access: 'read', + description: 'List the projects (initiatives) in your Meridian account', + example: 'webcmd meridian projects -f json', + domain: MERIDIAN_DOMAIN, + strategy: Strategy.COOKIE, + browser: true, + navigateBefore: false, + siteSession: 'persistent', + args: [], + columns: ['id', 'name', 'stage', 'workflow', 'health', 'signals_validated', 'url'], + func: async (page) => { + const items = await apiFetch(page, '/initiatives', { label: 'projects list' }); + if (!Array.isArray(items)) { + throw new CommandExecutionError('Meridian projects list returned an unexpected payload shape'); + } + if (!items.length) { + throw new EmptyResultError( + 'meridian projects', + 'No projects yet. Draft one with `webcmd meridian ideate ""`, then `webcmd meridian approve`.', + ); + } + return items.map((item) => { + const id = String(item?.id ?? ''); + const health = Number(item?.pdlc_health); + const validated = Number(item?.signals_validated); + return { + id, + name: normalizeText(item?.name) || null, + stage: normalizeText(item?.current_stage) || null, + workflow: normalizeText(item?.workflow_stage) || null, + health: Number.isFinite(health) ? health : null, + signals_validated: Number.isFinite(validated) ? validated : null, + url: id ? `${MERIDIAN_APP_ORIGIN}/app/initiatives/${id}/dashboard` : null, + }; + }); + }, +}); diff --git a/plugins/meridian/test/commands.test.js b/plugins/meridian/test/commands.test.js new file mode 100644 index 00000000..baef2e73 --- /dev/null +++ b/plugins/meridian/test/commands.test.js @@ -0,0 +1,322 @@ +import { describe, expect, it } from 'vitest'; +import { getRegistry } from '@agentrhq/webcmd/registry'; +import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@agentrhq/webcmd/errors'; +import { IDEATION_STATE_KEY } from '../utils.js'; +import { ideateCommand } from '../ideate.js'; +import { approveCommand } from '../approve.js'; +import { projectsCommand } from '../projects.js'; +import { personaCommand } from '../persona.js'; +import { personasCommand } from '../personas.js'; +import { competitorsCommand } from '../competitors.js'; +import { competitorAddCommand } from '../competitor-add.js'; +import { competitorScanCommand } from '../competitor-scan.js'; +import { agentStatusCommand } from '../agent-status.js'; +import { agentStartCommand } from '../agent-start.js'; +import { agentPauseCommand } from '../agent-pause.js'; +import '../auth.js'; +import { makeMeridianPage, okFetch } from './helpers.js'; + +describe('meridian command registration', () => { + it('registers login and whoami through the shared auth runtime', () => { + const registry = getRegistry(); + const login = registry.get('meridian/login'); + const whoami = registry.get('meridian/whoami'); + expect(login).toBeDefined(); + expect(whoami).toBeDefined(); + expect(login.access).toBe('write'); + expect(whoami.access).toBe('read'); + expect(whoami.columns).toEqual(['logged_in', 'site', 'email', 'name', 'org', 'credits']); + }); + + it('declares read/write access to match each command\'s side effects', () => { + for (const command of [projectsCommand, personasCommand, competitorsCommand, agentStatusCommand]) { + expect(command.access).toBe('read'); + } + for (const command of [ + ideateCommand, approveCommand, personaCommand, + competitorAddCommand, competitorScanCommand, agentStartCommand, agentPauseCommand, + ]) { + expect(command.access).toBe('write'); + } + }); + + it('keeps every command on the persistent Meridian site session', () => { + for (const command of [ + ideateCommand, approveCommand, projectsCommand, personaCommand, personasCommand, + competitorsCommand, competitorAddCommand, competitorScanCommand, + agentStatusCommand, agentStartCommand, agentPauseCommand, + ]) { + expect(command.siteSession).toBe('persistent'); + expect(command.domain).toBe('getmeridian.tech'); + } + }); +}); + +describe('meridian ideate', () => { + it('rejects an empty message with ArgumentError', async () => { + const page = makeMeridianPage(); + await expect(ideateCommand.func(page, { message: ' ' })).rejects.toBeInstanceOf(ArgumentError); + }); + + it('surfaces the content-safety guardrail as CommandExecutionError', async () => { + const page = makeMeridianPage({ fetchResults: [okFetch({ blocked: true, phase: 'blocked' })] }); + await expect(ideateCommand.func(page, { message: 'idea' })).rejects.toThrow(/guardrail/); + }); + + it('returns in_progress rows while Astra keeps the Approve & Start gate closed', async () => { + const turn = { + reply: 'Tell me who has this problem.', + phase: 'problem_statement', + summary: { problem_statement: 'Founders lack validation' }, + confidence: { problem_statement: 0.4, solution: 0.1, market_opportunity: 0.0 }, + ready_to_start: false, + missing_context: ['solution', 'market_opportunity'], + suggestions: [{ label: 'Indie hackers', value: 'Indie hackers building B2B tools' }], + }; + const page = makeMeridianPage({ fetchResults: [okFetch(turn), okFetch({ status: 'saved' })] }); + const rows = await ideateCommand.func(page, { message: 'An idea-validation copilot' }); + expect(rows).toHaveLength(1); + expect(rows[0].stage).toBe('in_progress'); + expect(rows[0].problem_statement).toBe('Founders lack validation'); + expect(rows[0].missing_context).toBe('solution, market_opportunity'); + expect(rows[0].suggestions).toContain('Indie hackers'); + expect(Object.keys(rows[0]).sort()).toEqual([...ideateCommand.columns].sort()); + }); + + it('reports ready_to_start with the stage plan and research sources on the ready turn', async () => { + const turn = { + reply: 'Got everything needed. Please approve the plan and start the project.', + phase: 'ready', + summary: { problem_statement: 'P', solution: 'S', market_opportunity: 'M' }, + confidence: { problem_statement: 0.9, solution: 0.88, market_opportunity: 0.86 }, + ready_to_start: true, + missing_context: [], + differentiation: 'Only player with live validation data', + stage_plan: { stages: [ + { key: 'research', title: 'Research', description: 'Validate the problem' }, + { key: 'build', title: 'Build', description: 'Ship the MVP' }, + { key: 'release', title: 'Release', description: 'Launch and learn' }, + ] }, + agents: [{ agent: 'web_browsing', findings: [{ title: 'Competitor teardown', url: 'https://example.com/a' }] }], + visual: { items: [{ name: 'Acme', url: 'https://acme.ai' }] }, + }; + const page = makeMeridianPage({ fetchResults: [okFetch(turn), okFetch({ status: 'saved' })] }); + const rows = await ideateCommand.func(page, { message: 'final answer' }); + expect(rows[0].stage).toBe('ready_to_start'); + expect(rows[0].differentiation).toBe('Only player with live validation data'); + expect(rows[0].plan).toBe('research: Validate the problem | build: Ship the MVP | release: Launch and learn'); + expect(rows[0].research_sources).toBe('Competitor teardown — https://example.com/a | Acme — https://acme.ai'); + }); + + it('accumulates conversation history across turns in the page session', async () => { + const page = makeMeridianPage({ + fetchResults: [ + okFetch({ reply: 'first reply', summary: {}, confidence: {}, ready_to_start: false }), + okFetch({ status: 'saved' }), + okFetch({ reply: 'second reply', summary: {}, confidence: {}, ready_to_start: false }), + okFetch({ status: 'saved' }), + ], + }); + await ideateCommand.func(page, { message: 'turn one' }); + await ideateCommand.func(page, { message: 'turn two' }); + const saved = JSON.parse(page.state.storage[IDEATION_STATE_KEY]); + expect(saved.messages.map((entry) => entry.content)).toEqual([ + 'turn one', 'first reply', 'turn two', 'second reply', + ]); + expect(saved.session_id).toMatch(/^draft_/); + }); + + it('still returns the turn when the draft autosave mirror fails', async () => { + const page = makeMeridianPage({ + fetchResults: [ + okFetch({ reply: 'ok', summary: {}, confidence: {}, ready_to_start: false }), + { pending: false, error: 'NetworkError' }, + ], + }); + const rows = await ideateCommand.func(page, { message: 'hello' }); + expect(rows[0].stage).toBe('in_progress'); + }); +}); + +describe('meridian approve', () => { + const readyState = JSON.stringify({ + session_id: 'draft_test1', + messages: [{ role: 'user', content: 'x' }, { role: 'assistant', content: 'y' }], + assessment: { summary: { initiative_name: 'Validation Copilot', problem_statement: 'P', solution: 'S' } }, + links: [], + documents: [], + ready: true, + }); + + it('requires an existing ideation draft', async () => { + const page = makeMeridianPage(); + await expect(approveCommand.func(page, {})).rejects.toBeInstanceOf(ArgumentError); + }); + + it('refuses to start before the ready stage unless --force', async () => { + const page = makeMeridianPage({ + pageState: { [IDEATION_STATE_KEY]: JSON.stringify({ messages: [{ role: 'user', content: 'x' }], assessment: { summary: {} }, ready: false }) }, + }); + await expect(approveCommand.func(page, {})).rejects.toBeInstanceOf(ArgumentError); + }); + + it('runs the setup job, discards the draft, and returns the new project', async () => { + const page = makeMeridianPage({ + pageState: { [IDEATION_STATE_KEY]: readyState }, + fetchResults: [ + okFetch({ job_id: 'job_9', status: 'pending' }), + okFetch({ status: 'completed', result: { initiative: { id: 'init_abc123', name: 'Validation Copilot' }, redirect: '/app/initiatives/init_abc123/ideal-customer' } }), + okFetch({ status: 'discarded' }), + ], + }); + const rows = await approveCommand.func(page, {}); + expect(rows[0].status).toBe('started'); + expect(rows[0].project_id).toBe('init_abc123'); + expect(rows[0].url).toBe('https://app.getmeridian.tech/app/initiatives/init_abc123/ideal-customer'); + expect(page.state.storage[IDEATION_STATE_KEY]).toBeUndefined(); + }); + + it('throws CommandExecutionError when the job finishes without a project id', async () => { + const page = makeMeridianPage({ + pageState: { [IDEATION_STATE_KEY]: readyState }, + fetchResults: [okFetch({ job_id: 'job_9' }), okFetch({ status: 'completed', result: {} })], + }); + await expect(approveCommand.func(page, {})).rejects.toBeInstanceOf(CommandExecutionError); + }); +}); + +describe('meridian project-scoped reads', () => { + it('projects maps the bare initiative array onto stable rows', async () => { + const page = makeMeridianPage({ + fetchResults: [okFetch([{ + id: 'init_1', name: 'Alpha', current_stage: 'IDEATION', workflow_stage: 'RESEARCH', + pdlc_health: 62, signals_validated: 3, + }])], + }); + const rows = await projectsCommand.func(page, {}); + expect(rows).toEqual([{ + id: 'init_1', name: 'Alpha', stage: 'IDEATION', workflow: 'RESEARCH', + health: 62, signals_validated: 3, + url: 'https://app.getmeridian.tech/app/initiatives/init_1/dashboard', + }]); + }); + + it('projects throws EmptyResultError with a next step when the account has none', async () => { + const page = makeMeridianPage({ fetchResults: [okFetch([])] }); + await expect(projectsCommand.func(page, {})).rejects.toBeInstanceOf(EmptyResultError); + }); + + it('personas requires a project id and rejects unexpected payloads', async () => { + await expect(personasCommand.func(makeMeridianPage(), { project: '' })).rejects.toBeInstanceOf(ArgumentError); + const page = makeMeridianPage({ fetchResults: [okFetch({ nope: true })] }); + await expect(personasCommand.func(page, { project: 'init_1' })).rejects.toBeInstanceOf(CommandExecutionError); + }); + + it('competitors ranks the board by relevance score', async () => { + const page = makeMeridianPage({ + fetchResults: [okFetch({ + competitors: [ + { name: 'Beta', relevance_score: 55, website: 'https://beta.io', category: 'Indirect' }, + { + name: 'Acme', relevance_score: 91, website: 'acme.ai', category: 'Direct', + unique_features: [{ name: 'Realtime sync', threat_level: 'HIGH' }], + }, + ], + swot: {}, + })], + }); + const rows = await competitorsCommand.func(page, { project: 'init_1' }); + expect(rows.map((row) => row.name)).toEqual(['Acme', 'Beta']); + expect(rows[0].rank).toBe(1); + expect(rows[0].top_threat).toBe('Realtime sync'); + expect(rows[0].website).toBe('https://acme.ai/'); + }); +}); + +describe('meridian persona building', () => { + it('needs behaviour text or an interview file', async () => { + const page = makeMeridianPage(); + await expect(personaCommand.func(page, { project: 'init_1' })).rejects.toBeInstanceOf(ArgumentError); + }); + + it('keeps the draft in progress while Astra still needs fields', async () => { + const page = makeMeridianPage({ + fetchResults: [okFetch({ + reply: 'Who is this persona, in one line?', + summary: { name: '' }, + ready_to_save: false, + missing_fields: ['name', 'pains'], + suggestions: ['A time-starved ops lead'], + })], + }); + const rows = await personaCommand.func(page, { project: 'init_1', message: 'users batch tasks at night' }); + expect(rows[0].status).toBe('in_progress'); + expect(rows[0].missing_fields).toBe('name, pains'); + }); + + it('saves the persona to the account when Astra marks it ready', async () => { + const page = makeMeridianPage({ + fetchResults: [ + okFetch({ + reply: 'Persona ready.', + summary: { name: 'Ops Olivia', tagline: 'Automates busywork' }, + ready_to_save: true, + }), + okFetch({ id: 'persona_1', name: 'Ops Olivia', tagline: 'Automates busywork' }), + ], + }); + const rows = await personaCommand.func(page, { project: 'init_1', message: 'users batch tasks at night' }); + expect(rows[0].status).toBe('saved'); + expect(rows[0].persona_id).toBe('persona_1'); + expect(page.state.kickoffs).toHaveLength(2); + }); +}); + +describe('meridian astra agent controls', () => { + it('agent-status flattens the root state and branch map into rows', async () => { + const page = makeMeridianPage({ + fetchResults: [okFetch({ + status: 'ACTIVE', + root_state: 'WORKING', + initiative_name: 'Alpha', + awaiting_human: 1, + branches: { + market_research: { state: 'WORKING', stage: 'SIGNAL', summary: 'Scanning competitors' }, + synthesis: { state: 'IDLE', stage: 'SYNTHESIS' }, + }, + })], + }); + const rows = await agentStatusCommand.func(page, { project: 'init_1' }); + expect(rows[0]).toEqual({ branch: 'root', state: 'ACTIVE', stage: 'WORKING', summary: 'Alpha — 1 checkpoint(s) awaiting you' }); + expect(rows).toHaveLength(3); + expect(rows[1]).toEqual({ branch: 'market_research', state: 'WORKING', stage: 'SIGNAL', summary: 'Scanning competitors' }); + }); + + it('agent-start resumes the agent and only scans with --scan', async () => { + const page = makeMeridianPage({ + fetchResults: [okFetch({ status: 'ACTIVE' }), okFetch({ status: 'scanned' })], + }); + const rows = await agentStartCommand.func(page, { project: 'init_1', scan: true }); + expect(page.state.kickoffs).toHaveLength(2); + expect(rows[0].status).toBe('ACTIVE'); + expect(rows[0].detail).toContain('scan tick: scanned'); + }); + + it('agent commands surface a missing session as AuthRequiredError', async () => { + const page = makeMeridianPage({ cookies: [] }); + await expect(agentPauseCommand.func(page, { project: 'init_1' })).rejects.toBeInstanceOf(AuthRequiredError); + }); + + it('competitor-scan drives the generate job to completion', async () => { + const page = makeMeridianPage({ + fetchResults: [ + okFetch({ job_id: 'job_c1' }), + okFetch({ status: 'completed', stage: 'Scored competitors', result: { competitors: [{}, {}, {}] } }), + ], + }); + const rows = await competitorScanCommand.func(page, { project: 'init_1' }); + expect(rows[0].status).toBe('completed'); + expect(rows[0].competitor_count).toBe(3); + expect(rows[0].next).toBe('webcmd meridian competitors init_1'); + }); +}); diff --git a/plugins/meridian/test/helpers.js b/plugins/meridian/test/helpers.js new file mode 100644 index 00000000..a6370307 --- /dev/null +++ b/plugins/meridian/test/helpers.js @@ -0,0 +1,55 @@ +import { vi } from 'vitest'; + +const SESSION_COOKIE = { name: 'session_token', value: 'sess_test' }; + +// Interprets the page scripts utils.js emits (location probe, cookie check, +// fetch kickoff/poll slots, sessionStorage chat state) so command funcs can run +// against the real utils flow without a browser. +export function makeMeridianPage({ + href = 'https://app.getmeridian.tech/app/initiatives', + cookies = [SESSION_COOKIE], + fetchResults = [], + pageState = {}, +} = {}) { + const remaining = [...fetchResults]; + const storageKey = (script) => { + const match = script.match(/(?:getItem|setItem|removeItem)\((".*?")/); + return match ? JSON.parse(match[1]) : null; + }; + const page = { + state: { storage: { ...pageState }, kickoffs: [] }, + goto: vi.fn(async () => {}), + wait: vi.fn(async () => {}), + getCookies: vi.fn(async () => cookies), + evaluate: vi.fn(async (script) => { + if (script.includes('window.location.href')) return href; + if (script.includes('sessionStorage.getItem')) return page.state.storage[storageKey(script)] ?? ''; + if (script.includes('sessionStorage.setItem')) { + const match = script.match(/setItem\([^,]+,\s*(".*")\)/s); + if (match) page.state.storage[storageKey(script)] = JSON.parse(match[1]); + return true; + } + if (script.includes('sessionStorage.removeItem')) { + delete page.state.storage[storageKey(script)]; + return true; + } + if (script.includes('__webcmdMeridianFetch') && script.includes('fetch(')) { + page.state.kickoffs.push(script); + return true; + } + if (script.includes('__webcmdMeridianFetch')) { + return remaining.length ? remaining.shift() : { pending: true }; + } + throw new Error(`makeMeridianPage: unhandled script: ${script.slice(0, 120)}`); + }), + }; + return page; +} + +export function okFetch(data, status = 200) { + return { pending: false, status, ok: status >= 200 && status < 300, data }; +} + +export function failFetch(status, data = null) { + return { pending: false, status, ok: false, data }; +} diff --git a/plugins/meridian/test/utils.test.js b/plugins/meridian/test/utils.test.js new file mode 100644 index 00000000..e646e67c --- /dev/null +++ b/plugins/meridian/test/utils.test.js @@ -0,0 +1,168 @@ +import { describe, expect, it } from 'vitest'; +import { ArgumentError, AuthRequiredError, CommandExecutionError } from '@agentrhq/webcmd/errors'; +import { + IDEATION_STATE_KEY, + apiFetch, collectTurnSources, formatConfidence, formatSuggestions, loadPageState, normalizeText, + parseBoolFlag, pollJob, requireBoundedInt, requireNonEmptyText, requireProjectId, + savePageState, toHttpsUrlOrNull, +} from '../utils.js'; +import { failFetch, makeMeridianPage, okFetch } from './helpers.js'; + +describe('meridian input validation helpers', () => { + it('normalizeText collapses whitespace', () => { + expect(normalizeText(' a \n b\t c ')).toBe('a b c'); + expect(normalizeText(null)).toBe(''); + }); + + it('requireNonEmptyText throws ArgumentError on blank input', () => { + expect(() => requireNonEmptyText(' ', 'message')).toThrow(ArgumentError); + expect(requireNonEmptyText(' hi ', 'message')).toBe('hi'); + }); + + it('requireProjectId throws ArgumentError with a discovery hint', () => { + expect(() => requireProjectId('')).toThrow(ArgumentError); + expect(requireProjectId(' init_abc123 ')).toBe('init_abc123'); + }); + + it('requireBoundedInt enforces bounds and integer-ness', () => { + expect(requireBoundedInt(undefined, 180, 10, 600, '--timeout')).toBe(180); + expect(requireBoundedInt(60, 180, 10, 600, '--timeout')).toBe(60); + expect(() => requireBoundedInt(5, 180, 10, 600, '--timeout')).toThrow(ArgumentError); + expect(() => requireBoundedInt('abc', 180, 10, 600, '--timeout')).toThrow(ArgumentError); + }); + + it('parseBoolFlag accepts booleans and "true" strings only', () => { + expect(parseBoolFlag(true)).toBe(true); + expect(parseBoolFlag('true')).toBe(true); + expect(parseBoolFlag('false')).toBe(false); + expect(parseBoolFlag(undefined)).toBe(false); + }); + + it('toHttpsUrlOrNull upgrades bare domains and rejects non-http schemes', () => { + expect(toHttpsUrlOrNull('acme.ai')).toBe('https://acme.ai/'); + expect(toHttpsUrlOrNull('https://acme.ai/x')).toBe('https://acme.ai/x'); + expect(toHttpsUrlOrNull('javascript:alert(1)')).toBeNull(); + expect(toHttpsUrlOrNull('')).toBeNull(); + }); + + it('formatSuggestions handles strings, label/value pairs, and duplicate labels', () => { + expect(formatSuggestions(['a', { label: 'B', value: 'b-value' }, { label: 'same', value: 'same' }])) + .toBe('a | B: b-value | same'); + expect(formatSuggestions(undefined)).toBe(''); + }); + + it('formatConfidence renders every requested dimension with a numeric score', () => { + expect(formatConfidence({ problem_statement: 0.9 }, ['problem_statement', 'solution'])) + .toBe('problem_statement=0.90 solution=0.00'); + }); + + it('collectTurnSources mirrors the app: agent findings + visual items, deduped by URL', () => { + const turn = { + agents: [ + { agent: 'web_browsing', findings: [{ title: 'A', url: 'https://a.com' }] }, + { agent: 'web_browsing', findings: [{ title: 'A again', url: 'https://a.com' }] }, + ], + visual: { items: [{ name: 'Acme', url: 'https://acme.ai' }, { name: 'No URL' }] }, + }; + expect(collectTurnSources(turn)).toBe('A — https://a.com | Acme — https://acme.ai'); + expect(collectTurnSources({})).toBe(''); + }); +}); + +describe('meridian apiFetch', () => { + it('returns parsed data on success', async () => { + const page = makeMeridianPage({ fetchResults: [okFetch({ hello: 'world' })] }); + await expect(apiFetch(page, '/initiatives', { label: 'projects list' })).resolves.toEqual({ hello: 'world' }); + }); + + it('throws AuthRequiredError when the session cookie is missing, before any request starts', async () => { + const page = makeMeridianPage({ cookies: [] }); + await expect(apiFetch(page, '/initiatives')).rejects.toBeInstanceOf(AuthRequiredError); + expect(page.state.kickoffs).toHaveLength(0); + }); + + it('throws AuthRequiredError on HTTP 401', async () => { + const page = makeMeridianPage({ fetchResults: [failFetch(401, { detail: 'Not authenticated' })] }); + await expect(apiFetch(page, '/initiatives')).rejects.toBeInstanceOf(AuthRequiredError); + }); + + it('maps HTTP 402 to an actionable credits error', async () => { + const page = makeMeridianPage({ fetchResults: [failFetch(402, { detail: 'Not enough credits' })] }); + await expect(apiFetch(page, '/initiatives')).rejects.toThrow(/credits/); + }); + + it('maps the structured subscription_required 403 to CommandExecutionError', async () => { + const page = makeMeridianPage({ + fetchResults: [failFetch(403, { detail: { code: 'subscription_required', message: 'Subscribe to continue' } })], + }); + await expect(apiFetch(page, '/initiatives')).rejects.toThrow(/subscription/); + }); + + it('treats a plain 403 as an auth failure', async () => { + const page = makeMeridianPage({ fetchResults: [failFetch(403, { detail: 'Forbidden' })] }); + await expect(apiFetch(page, '/initiatives')).rejects.toBeInstanceOf(AuthRequiredError); + }); + + it('throws CommandExecutionError with the status on other HTTP failures', async () => { + const page = makeMeridianPage({ fetchResults: [failFetch(500, { detail: 'boom' })] }); + await expect(apiFetch(page, '/initiatives')).rejects.toThrow(/HTTP 500/); + }); + + it('throws CommandExecutionError when the in-page fetch itself failed', async () => { + const page = makeMeridianPage({ fetchResults: [{ pending: false, error: 'NetworkError' }] }); + await expect(apiFetch(page, '/initiatives')).rejects.toThrow(/NetworkError/); + }); + + it('keeps polling past pending entries until the request settles', async () => { + const page = makeMeridianPage({ fetchResults: [{ pending: true }, { pending: true }, okFetch([1, 2])] }); + await expect(apiFetch(page, '/x')).resolves.toEqual([1, 2]); + }); + + it('throws a timeout CommandExecutionError when the deadline elapses', async () => { + const page = makeMeridianPage(); + await expect(apiFetch(page, '/slow', { timeoutSeconds: 0, label: 'slow call' })) + .rejects.toBeInstanceOf(CommandExecutionError); + }); + + it('navigates to the Meridian app first when the tab is elsewhere', async () => { + const page = makeMeridianPage({ href: 'https://example.com/', fetchResults: [okFetch({})] }); + await apiFetch(page, '/initiatives'); + expect(page.goto).toHaveBeenCalledWith('https://app.getmeridian.tech/', expect.anything()); + }); +}); + +describe('meridian pollJob', () => { + it('polls until the job completes and returns the final job', async () => { + const page = makeMeridianPage({ + fetchResults: [okFetch({ status: 'running', progress: 40 }), okFetch({ status: 'completed', result: { ok: 1 } })], + }); + const job = await pollJob(page, 'job_1', { label: 'setup' }); + expect(job.result).toEqual({ ok: 1 }); + }); + + it('surfaces a failed job as CommandExecutionError with its error', async () => { + const page = makeMeridianPage({ fetchResults: [okFetch({ status: 'failed', error: 'setup exploded' })] }); + await expect(pollJob(page, 'job_1', { label: 'setup' })).rejects.toThrow(/setup exploded/); + }); + + it('rejects a missing job id up front', async () => { + const page = makeMeridianPage(); + await expect(pollJob(page, '', { label: 'setup' })).rejects.toBeInstanceOf(CommandExecutionError); + expect(page.state.kickoffs).toHaveLength(0); + }); +}); + +describe('meridian chat state round-trip', () => { + it('persists and reloads state through the page session storage', async () => { + const page = makeMeridianPage(); + await savePageState(page, IDEATION_STATE_KEY, { messages: [{ role: 'user', content: 'hi' }], ready: false }); + const state = await loadPageState(page, IDEATION_STATE_KEY, { messages: [] }); + expect(state.messages).toEqual([{ role: 'user', content: 'hi' }]); + expect(state.ready).toBe(false); + }); + + it('falls back to the provided default on corrupted state', async () => { + const page = makeMeridianPage({ pageState: { [IDEATION_STATE_KEY]: '{not json' } }); + await expect(loadPageState(page, IDEATION_STATE_KEY, { messages: [] })).resolves.toEqual({ messages: [] }); + }); +}); diff --git a/plugins/meridian/utils.js b/plugins/meridian/utils.js new file mode 100644 index 00000000..7d87d198 --- /dev/null +++ b/plugins/meridian/utils.js @@ -0,0 +1,293 @@ +import { ArgumentError, AuthRequiredError, CommandExecutionError } from '@agentrhq/webcmd/errors'; + +export const MERIDIAN_DOMAIN = 'getmeridian.tech'; +// The React app lives on the app subdomain (the apex serves a static marketing +// site) and calls the API cross-origin, authenticated by an httpOnly +// `session_token` cookie (SameSite=None) issued by the API host. +export const MERIDIAN_APP_ORIGIN = 'https://app.getmeridian.tech'; +export const MERIDIAN_API_ORIGIN = 'https://api.getmeridian.tech'; +export const SESSION_COOKIE_NAME = 'session_token'; + +export function normalizeText(value) { + return String(value ?? '').replace(/\s+/g, ' ').trim(); +} + +export function requireNonEmptyText(value, label, example) { + const text = String(value ?? '').trim(); + if (!text) throw new ArgumentError(`${label} cannot be empty`, example); + return text; +} + +export function requireProjectId(value) { + const id = String(value ?? '').trim(); + if (!id) { + throw new ArgumentError( + 'a Meridian project id is required', + 'List projects with: webcmd meridian projects', + ); + } + return id; +} + +export function requireBoundedInt(value, fallback, min, max, label) { + const raw = value ?? fallback; + const parsed = typeof raw === 'number' ? raw : Number(raw); + if (!Number.isInteger(parsed) || parsed < min || parsed > max) { + throw new ArgumentError(`${label} must be an integer between ${min} and ${max}, got ${JSON.stringify(value)}`); + } + return parsed; +} + +export function parseBoolFlag(value) { + if (typeof value === 'boolean') return value; + return String(value ?? '').trim().toLowerCase() === 'true'; +} + +export function toHttpsUrlOrNull(value) { + const raw = String(value ?? '').trim(); + if (!raw) return null; + try { + const url = new URL(raw.includes('://') ? raw : `https://${raw}`); + return url.protocol === 'http:' || url.protocol === 'https:' ? url.href : null; + } catch { + return null; + } +} + +export async function isOnMeridianApp(page) { + const url = await page.evaluate('window.location.href').catch(() => ''); + if (typeof url !== 'string' || !url) return false; + try { + const host = new URL(url).hostname; + // Vibe preview origins are excluded from the platform CORS allowlist, + // so they cannot serve as a fetch origin for API calls. + return (host === MERIDIAN_DOMAIN || host.endsWith(`.${MERIDIAN_DOMAIN}`)) && !host.includes('preview.'); + } catch { + return false; + } +} + +export async function ensureOnMeridianApp(page) { + if (await isOnMeridianApp(page)) return false; + await page.goto(`${MERIDIAN_APP_ORIGIN}/`, { waitUntil: 'load', settleMs: 1000 }); + return true; +} + +export async function hasMeridianSessionCookie(page) { + const cookies = await page.getCookies({ url: `${MERIDIAN_API_ORIGIN}/` }); + return cookies.some((cookie) => cookie.name === SESSION_COOKIE_NAME && cookie.value); +} + +export async function ensureMeridianSession(page) { + await ensureOnMeridianApp(page); + if (!await hasMeridianSessionCookie(page)) { + throw new AuthRequiredError( + MERIDIAN_DOMAIN, + 'No Meridian session in this browser profile. Run `webcmd meridian login`, sign in (or sign up) in the opened browser, then retry.', + ); + } +} + +// CDP evaluate calls have a hard 30s cap, while Meridian's Astra endpoints can +// legitimately run for minutes (LLM turns + web research). Kick the fetch off +// inside the page, park the outcome on a window slot, and poll it with short +// evaluates until it settles. The page origin (app.getmeridian.tech) is on the +// platform CORS allowlist, and `credentials: 'include'` rides the same +// httpOnly session cookie the app itself uses. +function buildFetchKickoffScript(slot, path, method, body) { + return `(() => { + var store = window.__webcmdMeridianFetch = window.__webcmdMeridianFetch || {}; + store[${JSON.stringify(slot)}] = { pending: true }; + var init = { method: ${JSON.stringify(method)}, credentials: 'include', headers: { 'Accept': 'application/json' } }; + var body = ${JSON.stringify(body ?? null)}; + if (body !== null) { + init.headers['Content-Type'] = 'application/json'; + init.body = JSON.stringify(body); + } + fetch(${JSON.stringify(`${MERIDIAN_API_ORIGIN}/api${path}`)}, init) + .then(function(res) { + return res.text().then(function(text) { + var data = null; + try { data = text ? JSON.parse(text) : null; } catch { data = { raw: text.slice(0, 2000) }; } + store[${JSON.stringify(slot)}] = { pending: false, status: res.status, ok: res.ok, data: data }; + }); + }) + .catch(function(err) { + store[${JSON.stringify(slot)}] = { pending: false, error: String((err && err.message) || err) }; + }); + return true; + })()`; +} + +function buildFetchPollScript(slot) { + return `(() => { + var store = window.__webcmdMeridianFetch || {}; + var entry = store[${JSON.stringify(slot)}]; + if (!entry) return { pending: false, error: 'request slot lost (page navigated mid-request)' }; + if (!entry.pending) delete store[${JSON.stringify(slot)}]; + return entry; + })()`; +} + +function extractErrorDetail(data) { + const detail = data && typeof data === 'object' ? data.detail : null; + if (typeof detail === 'string') return { message: detail, code: '' }; + if (detail && typeof detail === 'object') { + return { message: String(detail.message ?? ''), code: String(detail.code ?? '') }; + } + return { message: '', code: '' }; +} + +export async function apiFetch(page, path, { method = 'GET', body = null, timeoutSeconds = 120, label = path } = {}) { + await ensureMeridianSession(page); + const slot = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; + const kicked = await page.evaluate(buildFetchKickoffScript(slot, path, method, body)); + if (kicked !== true) { + throw new CommandExecutionError(`Meridian ${label} request could not start in the browser page`); + } + + const deadline = Date.now() + timeoutSeconds * 1000; + let entry = null; + while (Date.now() < deadline) { + entry = await page.evaluate(buildFetchPollScript(slot)); + if (entry && entry.pending === false) break; + await page.wait(1); + } + + if (!entry || entry.pending !== false) { + throw new CommandExecutionError( + `Meridian ${label} did not finish within ${timeoutSeconds}s. Re-run with a higher --timeout if Astra is still working.`, + ); + } + if (entry.error) { + throw new CommandExecutionError(`Meridian ${label} failed: ${entry.error}`); + } + if (entry.ok) return entry.data; + + const { message, code } = extractErrorDetail(entry.data); + if (entry.status === 401) { + throw new AuthRequiredError( + MERIDIAN_DOMAIN, + `Meridian ${label} returned HTTP 401${message ? ` (${message})` : ''}. Run \`webcmd meridian login\` and sign in, then retry.`, + ); + } + if (entry.status === 402) { + throw new CommandExecutionError( + `Meridian ${label} needs more credits${message ? `: ${message}` : ''}. Top up in the Meridian app, then retry.`, + ); + } + if (entry.status === 403 && code === 'subscription_required') { + throw new CommandExecutionError(`Meridian ${label} requires an active subscription: ${message}`); + } + if (entry.status === 403) { + throw new AuthRequiredError(MERIDIAN_DOMAIN, `Meridian ${label} returned HTTP 403${message ? ` (${message})` : ''}.`); + } + throw new CommandExecutionError( + `Meridian ${label} returned HTTP ${entry.status}${message ? `: ${message}` : ''}`, + ); +} + +// Long jobs (project creation, competitor scans) return {job_id}; poll the +// jobs endpoint until the job settles. +export async function pollJob(page, jobId, { timeoutSeconds = 300, label = 'job' } = {}) { + const id = String(jobId ?? '').trim(); + if (!id) throw new CommandExecutionError(`Meridian ${label} did not return a job id`); + const deadline = Date.now() + timeoutSeconds * 1000; + let job = null; + while (Date.now() < deadline) { + job = await apiFetch(page, `/jobs/${encodeURIComponent(id)}`, { timeoutSeconds: 30, label: `${label} status` }); + const status = String(job?.status ?? ''); + if (status === 'completed') return job; + if (status === 'failed') { + throw new CommandExecutionError(`Meridian ${label} failed: ${normalizeText(job?.error) || 'unknown job error'}`); + } + await page.wait(2); + } + const stage = normalizeText(job?.stage); + throw new CommandExecutionError( + `Meridian ${label} is still running after ${timeoutSeconds}s${stage ? ` (${stage})` : ''}. Re-run with a higher --timeout.`, + ); +} + +// Chat drafts are stateless on the server side of a turn — the client owns the +// running messages/assessment. Park that state in sessionStorage of the +// persistent site tab so consecutive calls continue one conversation. +export const IDEATION_STATE_KEY = 'webcmd-meridian-ideation'; + +export function personaStateKey(projectId) { + return `webcmd-meridian-persona-${projectId}`; +} + +export async function loadPageState(page, key, fallback) { + const raw = await page.evaluate( + `(() => { try { return window.sessionStorage.getItem(${JSON.stringify(key)}) || ''; } catch { return ''; } })()`, + ); + if (typeof raw !== 'string' || !raw) return { ...fallback }; + try { + const state = JSON.parse(raw); + return state && typeof state === 'object' ? { ...fallback, ...state } : { ...fallback }; + } catch { + return { ...fallback }; + } +} + +export async function savePageState(page, key, state) { + await page.evaluate( + `(() => { try { window.sessionStorage.setItem(${JSON.stringify(key)}, ${JSON.stringify(JSON.stringify(state))}); return true; } catch { return false; } })()`, + ); +} + +export async function clearPageState(page, key) { + await page.evaluate( + `(() => { try { window.sessionStorage.removeItem(${JSON.stringify(key)}); return true; } catch { return false; } })()`, + ); +} + +export function newDraftSessionId() { + return `draft_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`; +} + +// Mirrors the frontend's collectTurnSources: web-research findings from the +// sub-agent trail plus competitive-landscape visual items, deduped by URL. +export function collectTurnSources(turn) { + const sources = []; + const seen = new Set(); + const push = (title, url) => { + const link = normalizeText(url); + if (!link || seen.has(link)) return; + seen.add(link); + const name = normalizeText(title); + sources.push(name && name !== link ? `${name} — ${link}` : link); + }; + for (const agent of Array.isArray(turn?.agents) ? turn.agents : []) { + for (const finding of Array.isArray(agent?.findings) ? agent.findings : []) { + push(finding?.title, finding?.url); + } + } + for (const item of Array.isArray(turn?.visual?.items) ? turn.visual.items : []) { + push(item?.name, item?.url); + } + return sources.join(' | '); +} + +export function formatSuggestions(suggestions) { + if (!Array.isArray(suggestions)) return ''; + return suggestions + .map((item) => { + if (typeof item === 'string') return normalizeText(item); + const label = normalizeText(item?.label); + const value = normalizeText(item?.value); + return label && value && label !== value ? `${label}: ${value}` : (value || label); + }) + .filter(Boolean) + .join(' | '); +} + +export function formatConfidence(confidence, fields) { + return fields + .map((field) => { + const value = Number(confidence?.[field]); + return `${field}=${(Number.isFinite(value) ? value : 0).toFixed(2)}`; + }) + .join(' '); +} diff --git a/plugins/meridian/webcmd-plugin.json b/plugins/meridian/webcmd-plugin.json new file mode 100644 index 00000000..99ffca3a --- /dev/null +++ b/plugins/meridian/webcmd-plugin.json @@ -0,0 +1,10 @@ +{ + "name": "meridian", + "version": "0.1.0", + "description": "Meridian (getmeridian.tech) founder workflows: Astra ideation chat, ideal-user personas, competitor research, and the Astra market-intelligence agent", + "webcmd": ">=0.5.3", + "author": { + "name": "Shubhojyoti Ganguly", + "handle": "sgang007" + } +}