/**
 * Prompt runner — fills a prompt YAML's user.template with case variables,
 * assembles the full system + user message pair, calls generateText through
 * the active SpecVerse provider, returns the raw text + tokens + duration.
 *
 * Schema-path variables ({{aiSchemaPath}}, {{referenceSchemaPath}},
 * {{referenceExamplePath}}) are auto-filled with absolute paths into
 * @specverse/entities/schema/ — same convention the in-tree MCP prompts use.
 *
 * Originally lived in `specverse-demo-self/harness/lib/prompt-runner.mjs`;
 * promoted into `@specverse/engines/ai` as part of TODO #21 phase 2 so the
 * forthcoming `spv ai analyse` / `spv ai create` commands can call it directly.
 */
import { readFileSync, existsSync } from 'fs';
import { createRequire } from 'module';
import { dirname, join } from 'path';
import { generateText } from 'ai';
import { loadSkillContent, isSkillInstalled } from './skill-loader.js';
import yaml from 'js-yaml';
import { resolveModel } from './model-resolver.js';
const require = createRequire(import.meta.url);
function resolveEntitiesFile(rel) {
    const pkg = require.resolve('@specverse/entities/package.json');
    return join(dirname(pkg), rel);
}
const SCHEMA_VAR_PATHS = {
    aiSchemaPath: resolveEntitiesFile('schema/SPECVERSE-SCHEMA-AI.yaml'),
    referenceSchemaPath: resolveEntitiesFile('schema/MINIMAL-SYNTAX-REFERENCE.specly'),
    referenceExamplePath: resolveEntitiesFile('schema/MINIMAL-SYNTAX-REFERENCE.specly'),
};
/** The active composed-prompt tag — selects which `_composed/<tag>/` bundle to
 *  load, and is recorded in run manifests for A/B traceability across prompt
 *  iterations. Override via the SPECVERSE_PROMPT_TAG env var; default `current`. */
export function promptTag() {
    return process.env.SPECVERSE_PROMPT_TAG || 'current';
}
/** Resolve the canonical prompt YAML for a given operation. */
export function loadPrompt(operation) {
    // Prompts moved from @specverse/engines/assets/prompts to
    // @specverse/assets/prompts on 2026-04-25 (see specverse-self/docs/plans/
    // 2026-04-25-CONTENT-PACKAGE-REFACTOR.md). Try assets first; fall back
    // to engines for older installs.
    // Phase 1 (2026-05-22 prompt-composition): prefer the pre-COMPOSED bundle
    // (`_composed/<tag>/prompts/`) whose partials were inlined at build time by
    // self/scripts/compose-prompts.mjs; fall back to the version-less source dir
    // (`core/standard/default/`), which the runtime `inlinePartials` below
    // expands. The fallback keeps this working against published @specverse/assets
    // that predate the `_composed/` dir. `inlinePartials` is a no-op on an already-
    // composed prompt (no `{{> }}` tokens remain). Tag via SPECVERSE_PROMPT_TAG.
    const tag = promptTag();
    const candidates = [];
    try {
        const assetsPkg = require.resolve('@specverse/assets/package.json');
        candidates.push(join(dirname(assetsPkg), 'prompts/_composed', tag, 'prompts', `${operation}.prompt.yaml`), join(dirname(assetsPkg), 'prompts/core/standard/default', `${operation}.prompt.yaml`));
    }
    catch {
        /* @specverse/assets not installed — fall through */
    }
    try {
        const enginesPkg = require.resolve('@specverse/engines/package.json');
        candidates.push(join(dirname(enginesPkg), 'assets/prompts/_composed', tag, 'prompts', `${operation}.prompt.yaml`), join(dirname(enginesPkg), 'assets/prompts/core/standard/default', `${operation}.prompt.yaml`));
    }
    catch {
        /* engines also not installed — defer to error below */
    }
    for (const p of candidates) {
        if (existsSync(p)) {
            const raw = yaml.load(readFileSync(p, 'utf8'));
            return inlinePartials(raw);
        }
    }
    throw new Error(`prompt file not found for "${operation}". Tried:\n  ${candidates.join('\n  ')}\n` +
        'Ensure @specverse/assets is installed (npm install @specverse/assets).');
}
/** Substitute {{var}} placeholders in `text` from `values` + auto-filled schema paths. */
export function substitute(text, values) {
    return text.replace(/\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}/g, (m, name) => {
        if (name in values && values[name] !== undefined)
            return values[name];
        if (name in SCHEMA_VAR_PATHS)
            return SCHEMA_VAR_PATHS[name];
        return m;
    });
}
/**
 * Resolve `<assets>/prompts/core/standard/default/partials/<name>.md`.
 * Partials are reusable Markdown blocks shared across multiple prompts —
 * `before-starting`, `output-format-dual-block`, etc. Use `{{> partial-name}}`
 * in a prompt template (anywhere — system.role, user.template, etc) to
 * include one.
 *
 * Returns null if the partial file doesn't exist; the caller leaves the
 * `{{> ... }}` token literal in the assembled prompt — better than silent
 * omission (typos become obvious).
 */
function resolvePartial(name) {
    if (!/^[a-z][a-z0-9-]*$/.test(name))
        return null; // strict whitelist
    try {
        const assetsPkg = require.resolve('@specverse/assets/package.json');
        const path = join(dirname(assetsPkg), 'prompts/core/standard/default/partials', `${name}.md`);
        if (existsSync(path))
            return readFileSync(path, 'utf8').replace(/\n+$/, '');
    }
    catch {
        /* fall through */
    }
    return null;
}
/**
 * Expand `{{> partial-name}}` references inside any string. Recursive
 * (up to 3 levels of nesting) to allow partials to include partials.
 * Variables ({{var}}) are left untouched — separate substitution pass
 * handles those at user-template assembly time. Variables in system.role
 * remain literal per the convention (LLM resolves them from context).
 */
function expandPartials(text) {
    let expanded = text;
    for (let depth = 0; depth < 3; depth++) {
        let changed = false;
        expanded = expanded.replace(/\{\{>\s*([a-z][a-z0-9-]*)\s*\}\}/g, (m, name) => {
            const partial = resolvePartial(name);
            if (partial !== null) {
                changed = true;
                return partial;
            }
            return m;
        });
        if (!changed)
            break;
    }
    return expanded;
}
/**
 * Apply expandPartials() to every string field in a PromptYaml. Partials
 * are inlined at load time so downstream code sees already-flat content.
 */
function inlinePartials(prompt) {
    const result = { ...prompt, system: { ...(prompt.system || {}) }, user: { ...(prompt.user || {}) } };
    if (result.system) {
        if (result.system.role)
            result.system.role = expandPartials(result.system.role);
        if (result.system.minimalContext)
            result.system.minimalContext = expandPartials(result.system.minimalContext);
        if (result.system.fullContext)
            result.system.fullContext = expandPartials(result.system.fullContext);
        if (result.system.context)
            result.system.context = expandPartials(result.system.context);
    }
    if (result.user?.template)
        result.user.template = expandPartials(result.user.template);
    return result;
}
/** Assemble the system message from a prompt YAML. */
export function assembleSystem(prompt) {
    const sys = prompt?.system ?? {};
    const parts = [];
    // `intro` is the leading identity/instructions slot used by the micro-call
    // prompts (analyse-action, manifest, realize-action, attribute). It was
    // previously omitted here — a latent bug that silently withheld those
    // prompts' OUTPUT FORMAT / role guidance from the system message.
    if (sys.intro)
        parts.push(sys.intro.trim());
    if (sys.role)
        parts.push(sys.role.trim());
    if (sys.minimalContext)
        parts.push(sys.minimalContext.trim());
    if (sys.fullContext)
        parts.push(sys.fullContext.trim());
    if (sys.context)
        parts.push(sys.context.trim()); // older shape support
    return parts.filter(Boolean).join('\n\n');
}
/**
 * Provider-aware skill inlining. Used by {@link runPrompt} to combine
 * the assembled base system prompt with the content of named skills
 * for providers that can't auto-load skills from the filesystem.
 *
 *   - `claude-cli` → returns `baseSystem` unchanged. The CLI loads
 *     skills natively from `~/.claude/skills/` based on the
 *     SKILL.md description; inlining would double-pay the tokens.
 *   - any other provider AND skills installed → prepends each skill's
 *     content (SKILL.md body + reference/*.md) to the base system
 *     prompt, separated by `---` rules.
 *   - any other provider AND skills NOT installed → returns
 *     `baseSystem` unchanged.
 *
 * Exported for testing in isolation; `runPrompt` calls this with the
 * resolved model's `provider` field.
 */
export function assembleSystemWithSkills(baseSystem, providerId, skillNames, cwd) {
    if (skillNames.length === 0)
        return baseSystem;
    if (providerId === 'claude-cli')
        return baseSystem;
    // Provider needs skills inlined from disk.
    const skillTexts = [];
    for (const skillName of skillNames) {
        const loaded = loadSkillContent(skillName, cwd);
        if (loaded)
            skillTexts.push(loaded.text);
    }
    if (skillTexts.length === 0)
        return baseSystem;
    return skillTexts.join('\n\n---\n\n') + '\n\n---\n\n' + baseSystem;
}
/**
 * The THIN system for a skill-backed op on the claude-cli path: the always-sent
 * `role` (identity) plus an explicit pointer telling the CLI to read the backing
 * skill's per-operation reference. The grounding itself is NOT inlined — the CLI
 * auto-loads the installed skill, so inlining would double-pay the tokens (the
 * whole point of the skill path). The reference body is byte-identical to the
 * grounding the full path inlines (compose-prompts parity gate), so output is
 * equivalent. Used only when {@link runPrompt} has confirmed the skill installed.
 *
 * Exported for testing in isolation.
 */
export function assembleSystemThin(prompt, skillName, operation) {
    const parts = [];
    const role = prompt?.system?.role?.trim();
    if (role)
        parts.push(role);
    parts.push(`The shared grounding (contract + conventions) for this operation is provided by the ` +
        `installed \`${skillName}\` SpecVerse skill. Read its \`reference/${operation}.md\` before ` +
        `emitting — it is the authoritative contract for this step.`);
    return parts.join('\n\n');
}
/**
 * The single delivery decision point (Phase 2b/3): given a loaded prompt + the
 * resolved provider, return the system message to send.
 *
 *   - skill-backed op (`prompt.skill` stamped by compose) + claude-cli + that
 *     skill installed → THIN (role + a read-the-skill pointer); the CLI auto-loads
 *     the grounding, so it isn't inlined — no double-send.
 *   - everything else → FULL grounding: the composed prompt's system as-is, plus
 *     any `skills` inlined for non-claude (the grounding-is-skill-only realize-owner
 *     path passes `skills:['realize-emit']`; that skill's content lives only in the
 *     skill files, so non-claude providers need it inlined here).
 *
 * Used by {@link runPrompt} and by BehaviorAIService so both share one code path
 * (the "fold behavior into runPrompt's delivery" cleanup).
 */
export function assembleDeliverySystem(prompt, providerId, operation, opts = {}) {
    // SPECVERSE_PROMPT_FORCE_FULL_GROUNDING=1 bypasses the thin-skill path so the
    // FULL composed prompt (e.g. a non-default SPECVERSE_PROMPT_TAG candidate) is
    // delivered to claude-cli too — otherwise claude-cli reads the globally-installed
    // skill, which is tag-agnostic, and a tagged prompt never reaches it. Used for
    // prompt A/B experiments (set on BOTH control + candidate so the delivery channel
    // is identical and only prompt content differs). Off by default — production
    // claude-cli keeps the thin-skill token optimization.
    const forceFull = process.env.SPECVERSE_PROMPT_FORCE_FULL_GROUNDING === '1';
    if (!forceFull && prompt.skill && providerId === 'claude-cli' && isSkillInstalled(prompt.skill, opts.cwd)) {
        return assembleSystemThin(prompt, prompt.skill, operation);
    }
    return assembleSystemWithSkills(assembleSystem(prompt), providerId, opts.skills ?? [], opts.cwd);
}
/** Assemble the user message — substitute case variables into user.template. */
export function assembleUser(prompt, values) {
    const tmpl = prompt?.user?.template ?? '';
    return substitute(tmpl, values);
}
/**
 * Build a single model instance for a whole run. Pass the returned model to
 * every runPrompt call in the run so the claude-cli provider's session-resume
 * (--session-id then --resume) actually fires across cases — first case pays
 * the system-prompt cost, subsequent cases hit cache. ~75% input-token
 * reduction on a typical 8-case run.
 */
export function buildRunModel({ timeoutMs = 15 * 60_000, ...modelOptions } = {}) {
    return resolveModel({ ...modelOptions, timeout: timeoutMs });
}
/**
 * Run a prompt with given values. Returns the LLM's text + telemetry.
 */
export async function runPrompt({ operation, values, model, modelOptions = {}, timeoutMs = 300_000, onAssembled, userPrefix = '', skills = [], }) {
    const prompt = loadPrompt(operation);
    const baseUser = assembleUser(prompt, values);
    // userPrefix is an optional preamble (used for structural-prepass facts
    // injection — kept separate from the canonical prompt template so the
    // experiment is reversible by simply not passing it).
    const user = userPrefix ? userPrefix + baseUser : baseUser;
    const llm = model ?? resolveModel({ ...modelOptions, timeout: timeoutMs });
    // Delivery branch — the single decision point, shared with BehaviorAIService.
    const providerId = llm.provider;
    const system = assembleDeliverySystem(prompt, providerId, operation, { skills });
    // Hook for saving prompts before the LLM call (so we have artifacts on timeout).
    if (typeof onAssembled === 'function')
        onAssembled({ system, user });
    const t0 = Date.now();
    const result = await generateText({
        model: llm,
        system,
        prompt: user,
        abortSignal: AbortSignal.timeout(timeoutMs),
        providerOptions: {
            anthropic: { cacheControl: { type: 'ephemeral' } },
        },
    });
    const duration_ms = Date.now() - t0;
    return {
        text: result.text,
        tokens: {
            input: result.usage?.inputTokens?.total ?? null,
            output: result.usage?.outputTokens?.total ?? null,
        },
        duration_ms,
        system,
        user,
        providerId: llm.provider,
        modelId: llm.modelId,
    };
}
/** Strip a markdown ```yaml/```specly fence + leading prose from a model output.
 *  Returns the FIRST fenced block's content. */
export function extractSpec(text) {
    const fence = text.match(/```(?:yaml|specly|yml)?\n([\s\S]*?)```/);
    if (fence)
        return fence[1].trim();
    return text.trim();
}
/**
 * Extract two consecutive fenced blocks from a model output — used by the
 * analyse pipeline (TODO #36) where the LLM emits a `specly` block followed
 * by a `yaml` manifest block.
 *
 * Returns:
 *   { spec: <first block>, manifest: <second block> | null }
 *
 * If only one block is present, `manifest` is null and the caller should
 * fall back (legacy single-block specs treated as spec-only). If a top-
 * level `manifest:` key is detected inside the spec block, the caller may
 * additionally choose to extract that as a backup — but new analyse
 * outputs should not include it (see analyse.prompt.yaml v9.6+).
 */
export function extractSpecAndManifest(text) {
    const re = /```(?:yaml|specly|yml)?\n([\s\S]*?)```/g;
    const blocks = [];
    let m;
    while ((m = re.exec(text)) !== null) {
        blocks.push(m[1].trim());
        if (blocks.length >= 2)
            break;
    }
    if (blocks.length === 0)
        return { spec: text.trim(), manifest: null };
    if (blocks.length === 1)
        return { spec: blocks[0], manifest: null };
    return { spec: blocks[0], manifest: blocks[1] };
}
//# sourceMappingURL=prompt-runner.js.map