/**
 * Background runner for the daily skill-proposal scan.
 *
 * Spawns a forked agent that reads recent session JSONL logs and drafts/edits
 * proposals under `~/.shortcut/agent/skill-proposals/`. Lifecycle is managed
 * via session_start / session_switch (see extension.ts): each session reset
 * aborts any in-flight scan and may start a new one if the daily cooldown has
 * elapsed. The shared singleton ensures only one scan runs across the process.
 */
import { accessSync, constants, existsSync, lstatSync } from 'node:fs';
import { SHORTCUT_LLM_PROXY_URL } from '../../constants.js';
import { deriveModePermissionPolicy } from '../permissions/policy.js';
import { createShortcutAuthRecovery } from '../providers/shortcut-auth-recovery.js';
import { resolveForkTraceDir } from '../session/fork-trace-paths.js';
import { runForkedAgent } from '../session/forked-agent.js';
import { createSkillProposalTools, SKILL_PROPOSAL_TOOL_NAMES } from './agent-tools.js';
import { countSkillProposals, listSkillProposals, tryCountSkillProposals } from './catalog.js';
import { buildSkillProposalPrompt } from './prompt.js';
import { getSkillProposalRoots, initializeSkillProposalRoots } from './proposal-fs.js';
import { readSkillProposalScanState, shouldRunDailySkillProposalScan, writeSkillProposalScanState } from './scan-state.js';
import { emitSkillProposalStatus } from './status.js';
const SKILL_PROPOSAL_FORK_KIND = 'skill-proposals';
const SKILL_PROPOSAL_INSTRUCTION = 'Run the skill proposal scan. Inspect recent session JSONL logs after the last scan time, then create or update skill proposals only if a reusable workflow, correction, or durable procedure is clearly worth proposing. Otherwise make no filesystem changes.';
const SKILL_PROPOSAL_MAX_TURNS = 50;
let sharedSkillProposalBackground;
/** Return the process-wide singleton skill-proposal background runner, creating it on first use. */
export function getSharedSkillProposalBackground() {
    sharedSkillProposalBackground ??= new SkillProposalBackground();
    return sharedSkillProposalBackground;
}
export class SkillProposalBackground {
    running = false;
    abortController = new AbortController();
    scanToken = 0;
    roots;
    runForkedAgent;
    /** Construct a runner with optional overridden roots and forked-agent runner (for tests). */
    constructor(options = {}) {
        this.roots = options.roots ?? getSkillProposalRoots();
        this.runForkedAgent = options.runForkedAgent ?? runForkedAgent;
    }
    /** Ensure proposal roots exist on disk and emit the initial status count. */
    initialize() {
        initializeSkillProposalRoots(this.roots);
        emitProposalCountStatus(this.roots, 'checked');
    }
    /** Abort any in-flight scan and reset the runner so a new session can start fresh. */
    reset() {
        // Aborts the current scan if one is running; the in-flight Promise rejects
        // and unwinds via runScan's finally block. A fresh controller takes over
        // for the next scan started after reset.
        this.abortController.abort();
        this.abortController = new AbortController();
        this.scanToken++;
        this.running = false;
        this.initialize();
    }
    /** Kick off a scan if the daily cooldown has elapsed and none is currently running. */
    startDailyScanIfDue(ctx) {
        if (this.running)
            return;
        if (!shouldRunDailySkillProposalScan(readSkillProposalScanState(this.roots)))
            return;
        void this.runScan(ctx);
    }
    /** Begin a user-initiated scan; returns false if a scan is already running or no model is available. */
    startManualScan(ctx, manualRequest) {
        if (this.running)
            return false;
        if (!ctx.model)
            return false;
        void this.runScan(ctx, manualRequest);
        return true;
    }
    /** Mark a scan running and delegate to executeScan; clears the running flag in `finally`. */
    async runScan(ctx, manualRequest) {
        if (this.running)
            return;
        const model = ctx.model;
        if (!model)
            return;
        const scanToken = ++this.scanToken;
        const abortSignal = this.abortController.signal;
        this.running = true;
        try {
            await this.executeScan(ctx, model, manualRequest, abortSignal);
        }
        finally {
            if (this.scanToken === scanToken) {
                this.running = false;
            }
        }
    }
    /** Run the forked skill-proposal agent against recent session JSONLs and emit status events. */
    async executeScan(ctx, model, manualRequest, signal) {
        const baseUrl = model.baseUrl ?? SHORTCUT_LLM_PROXY_URL;
        const modelRegistry = ctx.modelRegistry;
        const apiKey = await modelRegistry.getApiKey(model);
        if (!apiKey)
            return;
        const beforeCountResult = tryCountSkillProposals(this.roots);
        if ('message' in beforeCountResult) {
            emitSkillProposalStatus({
                phase: 'error',
                count: beforeCountResult.count,
                message: beforeCountResult.message
            });
            return;
        }
        const beforeCount = beforeCountResult.count;
        emitSkillProposalStatus({ phase: 'running' });
        const sessionId = ctx.sessionManager.getSessionId();
        const sessionDir = ctx.sessionManager.getSessionDir();
        const rawSessionFile = ctx.sessionManager.getSessionFile?.();
        // A fresh session has a sessionFile path but no file on disk until the
        // first assistant message lands. Treat it as absent.
        const sessionFile = rawSessionFile !== undefined && existsSync(rawSessionFile) ? rawSessionFile : undefined;
        const lastScanAt = readSkillProposalScanState(this.roots).lastScanAt;
        try {
            const scanStartedAt = new Date().toISOString();
            const sessionReadRoots = validatedSessionJsonlReadRoots(sessionDir, sessionFile);
            const tools = createSkillProposalTools(this.roots, { readRoots: sessionReadRoots });
            const permissionPolicy = deriveModePermissionPolicy([...SKILL_PROPOSAL_TOOL_NAMES], undefined, [this.roots.proposalsRoot, this.roots.liveSkillsRoot, ...sessionReadRoots]);
            const messages = [
                {
                    role: 'user',
                    timestamp: Date.now(),
                    content: [
                        {
                            type: 'text',
                            text: manualRequest
                                ? `Run the skill proposal scan from the session JSONL directory. User request: ${manualRequest}`
                                : 'Run the daily skill proposal scan from the session JSONL directory.'
                        }
                    ]
                }
            ];
            const result = await this.runForkedAgent({
                systemPrompt: buildSkillProposalPrompt({
                    proposalsRoot: this.roots.proposalsRoot,
                    liveSkillsRoot: this.roots.liveSkillsRoot,
                    sessionDir,
                    sessionFile,
                    lastScanAt,
                    manualRequest,
                    proposals: listSkillProposals(this.roots)
                }),
                messages,
                tools,
                model,
                baseUrl,
                apiKey,
                getExtraHeaders: () => ({ 'X-Session-Id': sessionId }),
                recoverAuth: createShortcutAuthRecovery(modelRegistry.authStorage),
                getToolContext: () => ({ permissionPolicy })
            }, {
                kind: SKILL_PROPOSAL_FORK_KIND,
                instruction: SKILL_PROPOSAL_INSTRUCTION,
                traceDir: resolveForkTraceDir(sessionDir, sessionFile),
                signal,
                effortLevel: 'low',
                maxTurns: SKILL_PROPOSAL_MAX_TURNS,
                onToolCall: emitRunningToolCall
            });
            const afterCount = countSkillProposals(this.roots);
            writeSkillProposalScanState(this.roots, { lastScanAt: scanStartedAt });
            const message = normalizeAgentResponse(result.response);
            emitSkillProposalStatus({
                phase: afterCount === beforeCount && !message ? 'checked' : 'updated',
                count: afterCount,
                message
            });
        }
        catch (error) {
            const message = error instanceof Error ? error.message : String(error);
            const count = tryCountSkillProposals(this.roots).count;
            emitSkillProposalStatus({ phase: 'error', count, message });
        }
    }
}
/** Emit a running-status update tagged with the in-progress tool call name and arguments. */
function emitRunningToolCall(toolCall) {
    emitSkillProposalStatus({
        phase: 'running',
        toolCall: { name: toolCall.name, arguments: toolCall.arguments }
    });
}
/** Collapse whitespace in the forked agent's reply and return undefined when empty. */
function normalizeAgentResponse(response) {
    const normalized = response.replace(/\s+/g, ' ').trim();
    return normalized.length > 0 ? normalized : undefined;
}
/** Return the dedup'd, readability-checked session paths the fork is allowed to read. */
function validatedSessionJsonlReadRoots(sessionDir, sessionFile) {
    if (!isReadableDirectory(sessionDir))
        return [];
    return [
        ...new Set([sessionDir, ...(sessionFile && isReadableFile(sessionFile) ? [sessionFile] : [])])
    ];
}
/** True if `path` is a directory the current process can read. */
function isReadableDirectory(path) {
    try {
        const stats = lstatSync(path);
        if (!stats.isDirectory())
            return false;
        accessSync(path, constants.R_OK);
        return true;
    }
    catch {
        return false;
    }
}
/** True if `path` is a regular file the current process can read. */
function isReadableFile(path) {
    try {
        const stats = lstatSync(path);
        if (!stats.isFile())
            return false;
        accessSync(path, constants.R_OK);
        return true;
    }
    catch {
        return false;
    }
}
/** Emit a status update with the current proposal count, downgrading to error on read failure. */
function emitProposalCountStatus(roots, phase) {
    const result = tryCountSkillProposals(roots);
    emitSkillProposalStatus('message' in result
        ? { phase: 'error', count: result.count, message: result.message }
        : { phase, count: result.count });
}
//# sourceMappingURL=background.js.map