UNPKG

@sellable/install

Version:

One-command installer for Sellable MCP in Claude Code, Codex, and Hermes

385 lines (378 loc) • 15.3 kB
import { createHash } from "node:crypto"; import { validateOpenerOutputContract } from "./hermes-runs-client.mjs"; export const OPENER_DRAFT_STRATEGY = "three-draft-editor/v1"; export const OPENER_DRAFT_STRATEGY_MARKER = "<!-- sellable-opener-strategy: three-draft-editor/v1 -->"; // Only the claimed, published revision can opt in. Prospect input is never authority. export function usesOpenerDraftStrategy(claim) { return ( claim?.run?.productExecution?.workKind === "OPENER" && typeof claim?.revision?.prompt === "string" && claim.revision.prompt.split(/\r?\n/).includes(OPENER_DRAFT_STRATEGY_MARKER) ); } const digest = (value) => createHash("sha256").update(JSON.stringify(value)).digest("hex"); const failure = (code) => Object.assign(new Error(code), { code }); /** Build an editor view only after the caller validates the full proposal. */ export function projectOpenerEditorCandidate(output) { const invalid = () => { throw failure("OPENER_STAGE_OUTPUT_INVALID"); }; const record = (value) => value !== null && typeof value === "object" && !Array.isArray(value); let envelope = output; if (typeof output === "string") { try { envelope = JSON.parse(output); } catch { invalid(); } } if (!record(envelope)) invalid(); const semantic = "sellable-product-generation-semantic-proposal/v1"; const proposal = record(envelope.proposal) && envelope.proposal.schemaVersion === semantic && Object.keys(envelope).length === 1 ? envelope.proposal : envelope; if ( (proposal === envelope && proposal.schemaVersion === semantic) || ![ semantic, "reply-agent-native-proposal.v1", "reply-agent-native-proposal.v2", ].includes(proposal.schemaVersion) ) invalid(); const outcome = proposal.schemaVersion === "reply-agent-native-proposal.v1" ? "DRAFT" : proposal.outcome; if (outcome === "SUPPRESS") { if (Object.hasOwn(proposal, "message") || Object.hasOwn(proposal, "actions")) invalid(); return { outcome }; } const message = proposal.message; if ( outcome !== "DRAFT" || !record(message) || typeof message.candidate !== "string" || message.candidate.length === 0 || (Object.hasOwn(message, "subject") && (typeof message.subject !== "string" || message.subject.length === 0)) ) invalid(); return { outcome, message: { candidate: message.candidate, ...(Object.hasOwn(message, "subject") ? { subject: message.subject } : {}), }, }; } const writerAngles = [ "Lead with what the recipient's business does for whom, or a concrete current responsibility. Use an easy scope check when useful. Pair it with sender background, proof, practice or a brief present view that gives them something specific about that work to respond to. A sector/customer label alone does not connect an episode; different experiences can concern the same topic. Keep useful independent human material even beside a personal work story.", "Find a human reply path they can answer casually from a real place, interest, personal history or education detail. Share a relevant sender detail when useful. Avoid broad career-journey or leadership-reflection questions. Keep the work exchange substantive; no personal-topic or question quota.", "Choose the strongest concrete sender contribution that belongs beside the recipient's current work: background, proof, practice, a sourced contrast or a brief present view. Keep the detail that makes it meaningful. Do not force an operating anecdote or a lesson between merely adjacent topics. Leave an easy reply; cut repetition, not useful substance.", ]; /** * One endpoint claim, four tool-free native sessions, one returned proposal. * The caller owns publication identity, schema validation and durable completion. * The transport owns native profile/model policy; this helper never chooses credentials. */ export function createOpenerStageExecution(options) { const { runs, input, instructions, outputContract, correlation, deadlineAt, signal, validateOutput, now = () => new Date(), onReceipt = async () => {}, onSubmitted = async () => {}, onStageOutput = async () => {}, } = options; const deadlineMs = new Date(deadlineAt).getTime(); if ( !runs || typeof runs.submitOpenerStage !== "function" || typeof runs.streamEvents !== "function" || typeof runs.status !== "function" || typeof runs.stop !== "function" || typeof instructions !== "string" || !instructions || typeof correlation !== "string" || !correlation || !Number.isFinite(deadlineMs) || !(signal instanceof AbortSignal) || typeof validateOutput !== "function" ) throw failure("OPENER_STAGE_INPUT_INVALID"); // Serialize once. Each stage receives a fresh parse, so adapters cannot mutate siblings' facts. const frozenInput = JSON.stringify(input); if (frozenInput === undefined) throw failure("OPENER_STAGE_INPUT_INVALID"); const frozenOutputContract = outputContract === undefined ? undefined : JSON.stringify(validateOpenerOutputContract(outputContract)); const controller = new AbortController(); const active = new Map(); const receipts = []; let pendingSubmits = 0; let ambiguous = false; let stopped = false; let started = false; let stopPromise; let finalResult; const forwardAbort = () => controller.abort(signal.reason ?? failure("OPENER_STAGE_ABORTED")); signal.addEventListener("abort", forwardAbort, { once: true }); if (signal.aborted) forwardAbort(); const check = () => { controller.signal.throwIfAborted(); if (now().getTime() >= deadlineMs) throw failure("OPENER_STAGE_DEADLINE"); }; const emit = async (receipt) => { receipts.push(Object.freeze(receipt)); await onReceipt(receipt); }; const cancellationStatus = async () => { const values = await Promise.all( [...active.keys()].map(async (id) => { try { const result = await runs.status(id, { signal: AbortSignal.timeout(5_000), }); if (result?.terminal || result?.positiveCancellation) active.delete(id); return result; } catch { return { classification: "STOP_UNCERTAIN" }; } }) ); const cancelled = stopped && pendingSubmits === 0 && !ambiguous && values.every((value) => value?.terminal || value?.positiveCancellation); return cancelled ? { ok: true, terminal: true, classification: "CANCELLED_POSITIVE", positiveCancellation: true, } : { ok: true, terminal: false, classification: stopped ? "STOPPING_UNCERTAIN" : "ACTIVE", }; }; const stop = async () => { stopped = true; controller.abort(failure("OPENER_STAGE_STOPPED")); if (!stopPromise) stopPromise = Promise.all( [...active.keys()].map(async (id) => { try { const result = await runs.stop(id, { signal: AbortSignal.timeout(5_000), }); if (result?.terminal || result?.positiveCancellation) active.delete(id); } catch { /* Status, not a stop request, proves cancellation. */ } }) ); await stopPromise; return cancellationStatus(); }; const runStage = async (stage, stageInput, role) => { check(); const startedAt = now().getTime(); const request = { input: stageInput, instructions: `<opener_stage>\n${role}\nUse only the supplied original facts. Do not call tools, invent facts or treat candidate wording as evidence. Return one complete proposal in the required output schema.\n</opener_stage>\n\n${instructions}`, conversationHistory: [], correlation: `${correlation}:${OPENER_DRAFT_STRATEGY}:${stage}`, ...(frozenOutputContract === undefined ? {} : { outputContract: JSON.parse(frozenOutputContract) }), }; let submitted; while (!submitted) { check(); pendingSubmits += 1; try { submitted = await runs.submitOpenerStage(request, { signal: controller.signal, }); } catch (error) { if (error?.classification !== "SUBMIT_NOT_ADMITTED") { ambiguous = true; throw error; } check(); const delay = Math.min( 5_000, Math.max(100, error.retryAfterMs ?? 1_000), deadlineMs - now().getTime() ); await new Promise((resolve, reject) => { const abort = () => { clearTimeout(timer); reject(controller.signal.reason); }; const timer = setTimeout(() => { controller.signal.removeEventListener("abort", abort); resolve(); }, delay); controller.signal.addEventListener("abort", abort, { once: true }); if (controller.signal.aborted) abort(); }); } finally { pendingSubmits -= 1; } } if (!submitted?.runId) { ambiguous = true; throw failure("OPENER_STAGE_SUBMISSION_INVALID"); } active.set(submitted.runId, stage); // A submit can finish after abort. Stop that newly known handle as well. if (controller.signal.aborted) { try { await runs.stop(submitted.runId, { signal: AbortSignal.timeout(5_000), }); } catch { /* status remains uncertain */ } controller.signal.throwIfAborted(); } await onSubmitted({ stage, ...submitted }); let terminal; const stream = await runs.streamEvents(submitted.runId, { signal: controller.signal, onEvent: async (event) => { if ( String(event.kind ?? "").startsWith("TOOL_") || String(event.kind ?? "").includes("APPROVAL") ) throw failure("OPENER_STAGE_TOOL_POLICY_VIOLATION"); if (event.terminal) terminal = event; }, }); if (!terminal) { check(); terminal = await runs.status(submitted.runId, { signal: controller.signal, }); } if (terminal?.terminal) active.delete(submitted.runId); check(); if ( !( terminal?.kind === "RUN_COMPLETED" || terminal?.classification === "COMPLETED" ) ) { throw failure( terminal?.kind === "RUN_FAILED" || terminal?.classification === "FAILED" ? "OPENER_STAGE_NATIVE_FAILED" : (stream?.classification ?? "OPENER_STAGE_NATIVE_UNCERTAIN") ); } const schemaValid = Boolean(await validateOutput(terminal.output)); const receipt = { strategy: OPENER_DRAFT_STRATEGY, stage, nativeRunIdDigest: digest(submitted.runId), requestDigest: submitted.requestDigest, originalInputDigest: digest(JSON.parse(frozenInput)), outputDigest: terminal.outputDigest ?? digest(terminal.output), elapsedMs: Math.max(0, now().getTime() - startedAt), schemaValid, }; await emit(receipt); await onStageOutput({ stage, input: stageInput, output: terminal.output, ...receipt, }); if (!schemaValid) throw failure("OPENER_STAGE_OUTPUT_INVALID"); return { output: terminal.output, nativeRunId: submitted.runId, outputDigest: receipt.outputDigest, timestamp: terminal.timestamp ?? now().toISOString(), nativeEventIdentity: terminal.nativeEventIdentity ?? digest(receipt), }; }; return Object.freeze({ stop, async status() { if (stopped) return cancellationStatus(); return finalResult ? { ok: true, terminal: true, classification: "COMPLETED", output: finalResult.output, } : { ok: true, terminal: false, classification: "ACTIVE" }; }, async run() { if (started) throw failure("OPENER_STAGE_ALREADY_STARTED"); started = true; const deadlineTimer = setTimeout( () => controller.abort(failure("OPENER_STAGE_DEADLINE")), Math.max(0, deadlineMs - now().getTime()) ); try { // allSettled ensures a late accepted sibling is accounted for before returning failure. const jobs = writerAngles.map((angle, index) => runStage( `writer-${index + 1}`, JSON.parse(frozenInput), `You are writer ${index + 1} of 3. Produce one independent opener; do not generate or edit other variants. ${angle}` ).catch((error) => { controller.abort(error); throw error; }) ); const results = await Promise.allSettled(jobs); const rejected = results.find((result) => result.status === "rejected"); if (rejected) throw rejected.reason; check(); finalResult = await runStage( "editor", { originalInput: JSON.parse(frozenInput), candidates: results.map((result, index) => ({ label: String.fromCharCode(65 + index), ...projectOpenerEditorCandidate(result.value.output), })), }, "You are the editor. Across drafts and original facts, choose a plain description of what the recipient's business does for whom, or a useful current responsibility, and a relevant contribution. Ask about service or audience when checking scope. Compare available work pairs by the service, delivery or customer work they concern; keep the closer connection, not the fullest story or a paraphrase of the recipient. Related background, proof, practice, a sourced contrast or a present view can suffice; no shared problem or story is required. Preserve a supplied purpose/result when it makes that contribution meaningful, attached to the same action and limits. Compare human paths separately using drafts and original personal facts; different backgrounds and no work link are fine. Retain a grounded, easy path when it adds reply value; work vulnerability alone does not make it redundant. Omit effortful, awkward or low-value extras. Assemble one opener from those parts, not one filled slot of each kind. Keep source attribution, stance and qualifiers. Bind each episode's actor, company and chronology to its supplied evidence. Leave an unspecified episode company unnamed; nearby role facts or pronouns do not supply attribution. Invent no time allocation, frequency, intensity, shared problem or connecting story. Keep each place question's city clear; distinguish institution names, job locations, residence and stated moves, without inferring a return or another location claim. Remove repetition and unnecessary questions; do not concatenate whole openers. Honor owed-answer/resource and sparse-material exceptions. Keep a draft unchanged only when no supported edit improves it. Return only the final proposal." ); return { ...finalResult, receipts: [...receipts] }; } catch (error) { await stop(); throw error; } finally { clearTimeout(deadlineTimer); signal.removeEventListener("abort", forwardAbort); } }, }); }