import { randomUUID } from "node:crypto";
import os from "node:os";
import path from "node:path";
import { isTextAttachment, AiMode, } from "@superblocksteam/library-shared/types";
import { addTracingToMethods, TracedEventEmitter, } from "@superblocksteam/shared";
import { generateObject, hasToolCall } from "ai";
import { z } from "zod";
import { getErrorMeta, getLogger, setDefaultLogger } from "../util/logger.js";
import { getToolCallArguments, getToolErrorCode, getToolOutput, } from "./agent/tool-message-utils.js";
import { hasPendingEscalations, flushPendingEscalations, } from "./agent/tools/apis/api-validation-orchestrator.js";
import { safeSampleJson } from "./agent/tools/apis/sample-json.js";
import { readFile } from "./agent/tools/build-read-file.js";
import { debugCache } from "./agent/tools/debug-cache.js";
import { buildReadFileToolFactory } from "./agent/tools/index.js";
import { SessionEntityPermissionStore, } from "./agent/tools2/entity-permissions.js";
import { ToolRegistry } from "./agent/tools2/registry.js";
import { explainCodeFinalizeToolFactory } from "./agent/tools2/tools/explain-code-finalize.js";
import { FileSystemInterface } from "./app-interface/file-system-interface.js";
import { createDefaultVirtualFileSystem, createPathValidator, } from "./app-interface/filesystem/index.js";
import { AppShell } from "./app-interface/shell.js";
import { AppSkillsManager } from "./app-skills/manager.js";
import { BoltArtifactProcessor } from "./artifacts/bolt.js";
import { ChatSessionStore } from "./chat/chat-session-store.js";
import { AppContextStore } from "./context/app-context.js";
import { restoreContextFromCheckpoint } from "./context-download.js";
import { KnowledgeManager } from "./facts/knowledge-manager.js";
import { IntegrationStore } from "./integrations/store.js";
import { JudgeService } from "./judge/judge-service.js";
import { CsvJudgeStorage } from "./judge/storage/csv-storage.js";
import { createChaosFetch } from "./llm/chaos-fetch.js";
import { LLMClient } from "./llm/client.js";
import { ContextManagerV2 } from "./llm/context-v2/manager.js";
import { LocalContextStorageV2 } from "./llm/context-v2/storage/local.js";
import { getActiveContext } from "./llm/context-v2/utils/get-loaded-context.js";
import { createLLMProvider } from "./llm/provider.js";
import { traceLLM } from "./llmobs/helpers.js";
import { getJwtTraceTags } from "./llmobs/helpers.js";
import llmobs from "./llmobs/index.js";
import { PlaywrightMcpServerManager } from "./mcp/playwright-server.js";
import { ClarkProfiler } from "./profiler/clark-profiler.js";
import { explainEventHandlerCodePrompt } from "./prompts/explain-code.js";
import { RecordingManager, createRecordingFetch } from "./recording/index.js";
import { ClarkStateNames, Clark, SERVICE_STARTED_WITH_DRAFT, USER_ACCEPTED_DRAFT, USER_REJECTED_DRAFT, USER_SENT_PROMPT, USER_CANCELED, AGENT_CANCELED, } from "./state-machine/clark-fsm.js";
import { doAgentPlanning } from "./state-machine/handlers/agent-planning.js";
import { doAwaitingUser } from "./state-machine/handlers/awaiting-user.js";
import { doIdle } from "./state-machine/handlers/idle.js";
import { doLLMGenerating } from "./state-machine/handlers/llm-generating.js";
import { playDead } from "./state-machine/handlers/play-dead.js";
import { doPostProcessing } from "./state-machine/handlers/post-processing.js";
import { doRuntimeReviewing } from "./state-machine/handlers/runtime-reviewing.js";
import { classificationHelper } from "./state-machine/helpers/classification.js";
import { getContextId } from "./state-machine/helpers/context-id.js";
import { sendUserMessageChannel, clearPendingToolPermissionRequest, } from "./state-machine/helpers/peer.js";
import { transitionFrom } from "./state-machine/helpers/transition.js";
import { TemplateRenderer } from "./template-renderer.js";
import { createJsonStreamParser } from "./util/json-stream-parser.js";
import { processLLMConfig } from "./util/llm-config-utils.js";
import { getToolCallSignature } from "./util/tool-signature.js";
export { AiServiceFeatureFlags } from "./features.js";
export { SnapshotManager } from "./recording/index.js";
export { isSdkApiTemplate } from "./sdk-api-templates.js";
// Helper functions for console log and runtime error buffer management
export function pruneByTTL(items, ttlMs) {
    const now = Date.now();
    return items.filter((item) => {
        // Drop items without timestamp (defensive: should not happen in practice)
        if (!item.timestamp) {
            return false;
        }
        return now - item.timestamp < ttlMs;
    });
}
export function estimateSize(obj) {
    try {
        const str = JSON.stringify(obj);
        // Rough approximation: UTF-16 character = 2 bytes on average
        return str.length * 2;
    }
    catch {
        return 0;
    }
}
export function truncateString(str, maxBytes) {
    // Rough approximation: assume 2 bytes per character
    const maxChars = Math.floor(maxBytes / 2);
    if (str.length <= maxChars)
        return str;
    return str.slice(0, maxChars) + "... [truncated]";
}
export function pruneBySize(items, maxBytes) {
    let totalSize = 0;
    const result = [];
    // Keep newest items that fit within size limit (iterate from end)
    for (let i = items.length - 1; i >= 0; i--) {
        const item = items[i]; // Non-null assertion: index is valid from loop
        // Use cached size if available, otherwise calculate
        const itemSize = item._size ?? estimateSize(item);
        if (totalSize + itemSize <= maxBytes) {
            totalSize += itemSize;
            result.unshift(item); // Add at beginning to maintain chronological order
        }
        else {
            break;
        }
    }
    return result;
}
const mergePlanContexts = (existing, incoming) => {
    if (!existing && !incoming) {
        return undefined;
    }
    return {
        plan: incoming?.plan ?? existing?.plan,
        approved: incoming?.approved !== undefined ? incoming.approved : existing?.approved,
        integrations: incoming?.integrations ?? existing?.integrations,
        entities: incoming?.entities ?? existing?.entities,
        title: incoming?.title ?? existing?.title,
        attachments: incoming?.attachments ?? existing?.attachments,
        enableTesting: incoming?.enableTesting ?? existing?.enableTesting,
    };
};
export class AiService extends TracedEventEmitter {
    config;
    artifactProcessor;
    templateRenderer;
    appShell;
    fileSystemInterface;
    integrationStore;
    knowledgeManager;
    _appContext;
    llmProviderSettings;
    _logger;
    clark;
    clarkProfiler;
    chatSessionStore;
    contextManagerV2;
    llmClient;
    toolRegistry = new ToolRegistry();
    entityPermissionStore = new SessionEntityPermissionStore();
    _onGenerationCompleteCallback;
    tokenManagerJwt;
    viteServer;
    mcpServerManager;
    recordingManager;
    sessionId;
    get appContext() {
        return this._appContext;
    }
    get clarkState() {
        return this.clark.state;
    }
    get appRootDirPath() {
        return this.config.appRootDirPath;
    }
    get organizationId() {
        return this.config.organizationId;
    }
    get superblocksBaseUrl() {
        return this.config.superblocksBaseUrl;
    }
    /**
     * Gets the contextId for the current conversation context.
     * Returns null if JWT is missing or has no userId.
     */
    getContextId() {
        try {
            return getContextId(this.clark, this.config);
        }
        catch {
            return null;
        }
    }
    /**
     * Gets the JWT from Clark context
     */
    getClarkJwt() {
        return this.clark?.context?.jwt;
    }
    /**
     * Updates the JWT in Clark context
     */
    updateClarkJwt(jwt) {
        if (jwt) {
            this.clark.updateContext({ jwt });
        }
    }
    // ===== Recording API =====
    /**
     * Check if recording is currently enabled.
     */
    isRecording() {
        return this.recordingManager.isEnabled();
    }
    /**
     * Get the current recording session ID.
     */
    getRecordingSessionId() {
        return this.recordingManager.getCurrentSessionId();
    }
    /**
     * Clear all recordings.
     *
     * @param sessionId - Optional session ID to clear only that session
     */
    async clearRecordings(sessionId) {
        await this.recordingManager.clear(sessionId);
    }
    /**
     * Get the RecordingManager instance for advanced usage.
     * This allows external code to add custom recording middleware.
     */
    getRecordingManager() {
        return this.recordingManager;
    }
    /**
     * Returns the set of integration IDs available in the current environment.
     */
    async getAvailableIntegrationIds() {
        const headers = await this.integrationStore.availableIntegrations();
        return new Set(headers.map((h) => h.id));
    }
    get isSessionManagementEnabled() {
        return this.config.features.sessionManagementEnabled === true;
    }
    /**
     * Checks if there is a connected peer (user is connected via socket)
     */
    hasConnectedPeer() {
        return !!this.clark?.context?.peer;
    }
    constructor(config) {
        super(config.tracer);
        this.config = config;
        setDefaultLogger(config.logger);
        // Store the wrapped logger so getLogger() can return it
        // The wrapped logger forwards logs to the browser console when Clark logs are enabled
        this._logger = getLogger();
        // Prevent traced functions from creating traces when called outside of a Clark context
        llmobs.setDefaultRootPolicy("drop-if-no-parent");
        // todo: move to state handlers
        this.artifactProcessor = new BoltArtifactProcessor();
        this.llmProviderSettings = {
            anthropic: {
                baseURL: config.superblocksBaseUrl,
                apiKey: "placeholder",
                path: "/v1/ai/proxy/anthropic/messages",
                ...config.providerSettings?.anthropic,
            },
            clark: {
                baseURL: config.superblocksBaseUrl,
                path: "/api/v1/ai/inference",
                upstreamProvider: "unified",
                ...config.providerSettings?.clark,
            },
        };
        this.templateRenderer =
            config.templateRenderer ??
                new TemplateRenderer({
                    prefetchNodeModules: true,
                    workDirSeed: config.appRootDirPath,
                });
        // Create KnowledgeManager before AppShell so it can be passed to VirtualFileSystem
        this.knowledgeManager = new KnowledgeManager();
        this.appShell = new AppShell({
            appRootDirPath: config.appRootDirPath,
            fsOperationQueue: config.fsOperationQueue,
            draftInterface: config.draftInterface,
            templateRenderer: this.templateRenderer,
            virtualFileSystem: createDefaultVirtualFileSystem({
                useSystemSkills: config.features.useSystemSkills,
                knowledgeManager: this.knowledgeManager,
                sdkApiEnabled: config.features.sdkApiEnabled,
            }),
            pathValidator: createPathValidator(config.features.sdkApiEnabled ?? false),
            messageSender: async (message) => {
                // Send checklist messages through the peer connection
                if (this.clark.context.peer &&
                    (message.type === "checklist_item_added" ||
                        message.type === "checklist_item_updated")) {
                    const sendUserMessage = sendUserMessageChannel(this.clark, this.chatSessionStore);
                    await sendUserMessage({
                        type: "text",
                        text: "",
                        group: "checklist",
                        status: "ready",
                        checklistData: message,
                    });
                }
            },
        });
        this.fileSystemInterface = new FileSystemInterface();
        this.integrationStore = new IntegrationStore(undefined, config.appRootDirPath, config.features.metadataCacheMaxTotalSizeMB, config.features.metadataCacheMaxSingleFileSizeMB, config.pluginExecutionVersions, config.superblocksBaseUrl, config.applicationId, () => this.clark?.context?.jwt);
        this._appContext = new AppContextStore(this.fileSystemInterface);
        this.sessionId = `session_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
        this.chatSessionStore = new ChatSessionStore({
            applicationId: config.applicationId,
            rpcClient: config.rpcClient,
            logger: config.logger,
        });
        if (this.config.tokenManager) {
            this.config.tokenManager.on("tokenUpdated", (event) => {
                this.tokenManagerJwt = event.token;
                this.getLogger().info(`[ai-service] token updated via tokenManager`);
            });
        }
        this.clarkProfiler = new ClarkProfiler(this.sessionId);
        this.clarkProfiler.initialize(this.appShell);
        this.contextManagerV2 = new ContextManagerV2({
            storage: new LocalContextStorageV2(config.appRootDirPath),
            rootDir: config.appRootDirPath,
            getModeState: () => {
                const mode = this.clark?.context?.currentMode;
                if (mode === undefined)
                    return undefined;
                return {
                    mode,
                    planApproved: this.clark?.context?.planContext?.approved,
                };
            },
        });
        this.llmClient = new LLMClient({
            contextManagerV2: this.contextManagerV2,
            appShell: this.appShell,
            retryOptions: config.retryOptions,
            unifiedProviderEnabled: config.features.unifiedProviderEnabled ?? false,
            organizationId: config.organizationId,
            applicationId: config.applicationId,
            getJwt: () => this.getJwt(),
            onUsageRecorded: (usage) => {
                // Accumulate workflow-level metrics
                this.clark.updateContext((context) => {
                    const metrics = context.workflowMetrics;
                    if (!metrics)
                        return context;
                    return {
                        ...context,
                        workflowMetrics: {
                            ...metrics,
                            toolCallCount: metrics.toolCallCount + usage.toolCalls,
                            stepCount: metrics.stepCount + usage.stepCount,
                            linesAdded: metrics.linesAdded + usage.linesAdded,
                            linesEdited: metrics.linesEdited + usage.linesEdited,
                            linesRemoved: metrics.linesRemoved + usage.linesRemoved,
                        },
                    };
                });
            },
        });
        this.clark = new Clark({
            initialContext: {
                abortController: new AbortController(),
            },
            createTransitionHandler: this.createClarkTransitionHandler(),
            createTagProvider: this.createClarkTagProviderFactory(),
            sessionId: this.sessionId,
        });
        // Initialize judge service
        this.mcpServerManager = new PlaywrightMcpServerManager(this.clark.logger);
        // Initialize recording manager for capturing AI interactions
        this.recordingManager = new RecordingManager({
            recordingsDir: path.join(config.appRootDirPath, ".superblocks", "recordings"),
            appRootDirPath: config.appRootDirPath,
        });
        this.config.draftInterface
            .hasLocalDraftChanges()
            .then((hasDraft) => {
            if (hasDraft) {
                this.clark.updateContext({
                    hasSuggestions: true,
                });
                const transitionTo = transitionFrom(this.clark);
                void transitionTo({
                    type: SERVICE_STARTED_WITH_DRAFT,
                });
            }
        })
            .catch((error) => {
            this.getLogger().error(`[ai-service] Error checking for local draft changes: ${error}`, getErrorMeta(error));
        });
        const handlerMethods = [
            "handleAiGenerate",
            "handleAiRespondToToolPermission",
            "handleUserAcceptedDraft",
            "handleUserRejectedDraft",
            "handleUserCanceled",
            "handleSkipTestCase",
            "handleBuildSystemError",
            "handleRuntimeError",
            "handleClearRuntimeError",
            "handleConsoleLog",
            "handleApiRunRecord",
            "handlePromptInterpretation",
            "handleJudgeEvaluation",
        ];
        addTracingToMethods(this, handlerMethods, config.tracer);
        // TODO apply isocket error handling
    }
    /**
     * Initialize the AI service. Must be called after construction.
     * Sets up storage, cleanup tasks, and starts recording.
     */
    async initialize() {
        await this.integrationStore.initialize();
        // Start recording with initial commit ID from chat history (if enabled)
        if (this.config.features.sessionRecordingEnabled === true) {
            const initialCommitId = await this.chatSessionStore.getLatestCommitId();
            this.recordingManager.start(initialCommitId);
        }
        this.getLogger().info("[ai-service] Initialized");
    }
    /**
     * Remove the integration metadata cache for this session.
     * Cleans up the session cache directory.
     */
    async removeIntegrationCache() {
        await this.integrationStore.removeIntegrationCache();
        this.getLogger().info("[ai-service] Integration cache removed");
    }
    getLogger() {
        // Return the wrapped logger that forwards logs to the browser console
        // when Clark logs are enabled (via ?clark_logs=true or SUPERBLOCKS_CLI_ENV=local)
        return this._logger;
    }
    createClarkTransitionHandler() {
        const clarkStateHandlerFactories = {
            Idle: doIdle,
            AgentPlanning: doAgentPlanning,
            LLMGenerating: doLLMGenerating,
            PostProcessing: doPostProcessing,
            RuntimeReviewing: doRuntimeReviewing,
            AwaitingUser: doAwaitingUser,
            Dead: playDead,
        };
        return (fsm) => {
            const clark = fsm;
            return async (transition) => {
                const params = {
                    ...this.config,
                    logger: this.getLogger(),
                    templateRenderer: this.templateRenderer,
                    artifactProcessor: this.artifactProcessor,
                    appShell: this.appShell,
                    signals: this,
                    fileSystemInterface: this.fileSystemInterface,
                    integrationStore: this.integrationStore,
                    knowledgeManager: this.knowledgeManager,
                    appContextStore: this.appContext,
                    llmProvider: createLLMProvider(this.getLLMProviderSettings(clark.context.llmConfig), () => this.getJwt(), clark.context.llmConfig, () => ({
                        prompt_id: clark.context.promptId,
                        chatmessage_id: clark.context.chatmessageId,
                        application_id: this.config.applicationId,
                        session_id: this.sessionId,
                        // Note: retry_count for LLM retries is added by orchestrator via providerOptions
                        mode: clark.context.currentMode === AiMode.PLAN ? "plan" : "build",
                        plan_size: clark.context.planSize,
                    })),
                    chatSessionStore: this.chatSessionStore,
                    clarkProfiler: this.clarkProfiler,
                    contextManagerV2: this.contextManagerV2,
                    llmClient: this.llmClient,
                    toolRegistry: this.toolRegistry,
                    entityPermissionStore: this.entityPermissionStore,
                    mcpServerManager: this.mcpServerManager,
                    viteServer: this.viteServer,
                    recordingManager: this.recordingManager,
                };
                const clarkStateHandlers = Object.fromEntries(Object.entries(clarkStateHandlerFactories).map(([state, handlerFactory]) => [state, handlerFactory(clark, params)]));
                const { from, to, event } = transition;
                this.clarkProfiler.onStateTransition(from, to, event);
                if (clark.context.runTimestamp) {
                    this.clarkProfiler.initialize(this.appShell, clark.context.runTimestamp);
                }
                const handler = clarkStateHandlers[to];
                if (!handler) {
                    this.getLogger().error(`[ai-service] No handler for state ${to}`);
                    throw new Error(`No handler for state ${to}`);
                }
                this.getLogger().info(`[ai-service] Transitioned from ${from} to ${to} with event ${event.type}`);
                try {
                    await handler(transition);
                    // Trigger callback when Clark enters idle states (generation complete)
                    if (to === ClarkStateNames.Idle ||
                        to === ClarkStateNames.AwaitingUser) {
                        this.triggerGenerationComplete();
                    }
                }
                catch (error) {
                    console.log(error);
                    this.getLogger().error(`[ai-service] Error in handler for state ${to} with event ${event.type} ${error}`, getErrorMeta(error));
                    if (clark.context.abortController?.signal.aborted &&
                        event.type !== USER_CANCELED &&
                        event.type !== AGENT_CANCELED) {
                        return clark.send({ type: USER_CANCELED });
                    }
                    if (event.type !== AGENT_CANCELED) {
                        return clark.send({
                            type: AGENT_CANCELED,
                            advice: `An unexpected error occurred: ${error}. Please try again.`,
                        });
                    }
                    throw error;
                }
            };
        };
    }
    getJwt() {
        if (this.tokenManagerJwt) {
            return this.tokenManagerJwt;
        }
        // fallback to clark context if no token manager is provided
        return this.clark?.context?.jwt;
    }
    createClarkTagProviderFactory() {
        return (fsm) => {
            const clark = fsm;
            return () => ({
                "app.id": this.config.applicationId,
                "org.id": this.config.organizationId,
                "ai.prompt_id": clark.context.promptId,
                ...getJwtTraceTags(clark.context.jwt),
            });
        };
    }
    async handleAiGenerate(request, peer, peerId) {
        this.getLogger().info(`[ai-service] handleAiGenerate: peerId=${peerId}`);
        if (this.clark.state === ClarkStateNames.Dead) {
            throw new Error("Service is unavailable");
        }
        if (this.isBusy()) {
            this.getLogger().warn("[ai-service] Service is busy. State:", this.clark.state);
            throw new Error("Service is busy");
        }
        this.clarkProfiler.startSession();
        getLogger().info("AI Service received request with llmConfig:", request.llmConfig || {});
        getLogger().info("[ai-service] handleAiGenerate received request", {
            requestMode: request.mode,
            currentContextMode: this.clark.context.currentMode,
            requestPlanContextApproved: request.planContext?.approved,
            existingPlanContextApproved: this.clark.context.planContext?.approved,
            responseMetadataType: request.responseMetadata?.type,
        });
        // Generate prompt_id for this user interaction
        const promptId = randomUUID();
        getLogger().info("[ai-service] Generated prompt_id for user interaction", {
            promptId,
            applicationId: this.config.applicationId,
        });
        this.clark.updateContext((context) => {
            return {
                ...context,
                promptId,
                useMockGeneration: request.mockGeneration ?? false,
                planContext: mergePlanContexts(context.planContext, request.planContext),
                browserContext: request.browserContext,
                // Initialize workflow metrics tracking
                workflowMetrics: {
                    toolCallCount: 0,
                    stepCount: 0,
                    linesAdded: 0,
                    linesEdited: 0,
                    linesRemoved: 0,
                    workflowStartedAt: new Date().toISOString(),
                },
                llmConfig: request.llmConfig,
                partialResponse: undefined,
            };
        });
        const transitionTo = transitionFrom(this.clark);
        void transitionTo({
            type: USER_SENT_PROMPT,
            request,
            peer,
            peerId,
        });
    }
    async handleAiExplainCode(request, peer) {
        if (this.clark.state === ClarkStateNames.Dead) {
            throw new Error("Service is unavailable");
        }
        if (this.isBusy()) {
            this.getLogger().warn("[ai-service] Service is busy. State:", this.clark.state);
            throw new Error("Service is busy");
        }
        this.clarkProfiler.startSession();
        getLogger().info(`[ai-service] Explaining code (${request.codeType}): ${request.itemId}.${request.propertyName}`);
        try {
            const fsm = this.fileSystemInterface.getFileSystemManager();
            if (!fsm) {
                throw new Error("FileSystemManager not initialized");
            }
            const code = await fsm.getPropertyCode(request.itemId, request.propertyName);
            if (!code || code.trim().length === 0) {
                await peer.call.aiPushCodeExplanation({
                    itemId: request.itemId,
                    propertyName: request.propertyName,
                    error: "No code found",
                });
                return;
            }
            const absoluteFilePath = this.fileSystemInterface.getElementToFilePath(request.itemId);
            const relativeFilePath = path.relative(this.appShell.appRootDirPath, absoluteFilePath);
            const build_readFile = buildReadFileToolFactory.create({
                services: { appShell: this.appShell },
            });
            const explainCodeFinalize = explainCodeFinalizeToolFactory.create({});
            const pageContent = await readFile(relativeFilePath, {
                appShell: this.appShell,
            });
            getLogger().info(`[ai-service] Page file path: ${relativeFilePath}, content length: ${pageContent?.length ?? 0}`);
            let prompt;
            if (request.codeType === "eventHandler") {
                prompt = explainEventHandlerCodePrompt({
                    code,
                    pageContent,
                    finalizeToolName: explainCodeFinalize.name,
                    readFileToolName: build_readFile.name,
                });
            }
            else {
                throw new Error(`Unsupported code type: ${request.codeType}`);
            }
            const llmConfig = request.llmConfig
                ? {
                    ...request.llmConfig,
                    interleavedThinking: false,
                    thinkingEnabled: false,
                }
                : undefined;
            const { providerOptions, headers } = processLLMConfig(llmConfig, 5000, `AI Explain Code (${request.codeType})`);
            const llmProviderSettings = this.getLLMProviderSettings(llmConfig);
            // Generate prompt_id for this explain code request (user-initiated action)
            const promptId = randomUUID();
            getLogger().info("[ai-service] Generated prompt_id for explain code", {
                promptId,
                applicationId: this.config.applicationId,
            });
            const llmProvider = createLLMProvider(llmProviderSettings, () => request.jwt, llmConfig, () => ({
                prompt_id: promptId,
                application_id: this.config.applicationId,
                session_id: this.sessionId,
            }));
            const model = llmProvider.modelForTask("explain");
            getLogger().info(`[ai-service] Creating LLM provider with settings:`, JSON.stringify({
                providerType: llmConfig?.provider,
                llmProviderSettings,
            }));
            getLogger().info(`[ai-service] Using model for explain task:`, JSON.stringify({
                modelId: model.modelId,
                provider: model.provider,
            }));
            await traceLLM("handleAiExplainCode", model.modelId, model.provider, async () => {
                getLogger().info(`[ai-service] Calling streamText with prompt length: ${prompt.length}`);
                try {
                    const streamResult = await this.llmClient.streamText({
                        model,
                        stopWhen: hasToolCall(explainCodeFinalize.name),
                        tools: {
                            build_readFile,
                            explainCodeFinalize,
                        },
                        user: { role: "user", content: prompt },
                        providerOptions,
                        headers,
                        providerConfig: {
                            provider: llmConfig?.provider ?? "vertex",
                            model: llmConfig?.coreModel ?? "claude-sonnet-4-5",
                        },
                        enableFallback: llmConfig?.unifiedProviderEnabled ?? false,
                    });
                    let isExplainingStep = false;
                    const parseJsonResult = createJsonStreamParser("result");
                    for await (const chunk of streamResult.fullStream) {
                        // Only stream the final result from explainCodeFinalize tool call
                        if (chunk.type === "tool-input-start" &&
                            chunk.toolName === explainCodeFinalize.name) {
                            isExplainingStep = true;
                        }
                        if (chunk.type === "tool-input-delta" && isExplainingStep) {
                            const input = chunk.delta;
                            if (!input)
                                continue;
                            for (const contentChunk of parseJsonResult(input)) {
                                await peer.call.aiPushCodeExplanation({
                                    itemId: request.itemId,
                                    propertyName: request.propertyName,
                                    delta: contentChunk,
                                    isComplete: false,
                                });
                            }
                        }
                    }
                    // Send completion signal
                    await peer.call.aiPushCodeExplanation({
                        itemId: request.itemId,
                        propertyName: request.propertyName,
                        isComplete: true,
                    });
                }
                catch (error) {
                    const errorMsg = error instanceof Error ? error.message : String(error);
                    const errorStack = error instanceof Error && error.stack ? error.stack : "";
                    getLogger().error(`[ai-service] streamText error: ${errorMsg}\nStack: ${errorStack}`);
                    throw error;
                }
            }, { codeType: request.codeType }, llmobs);
            getLogger().info(`[ai-service] Successfully sent summary for ${request.itemId}.${request.propertyName}`);
        }
        catch (error) {
            const errorMessage = error instanceof Error ? error.message : String(error);
            getLogger().error(`[ai-service] Error explaining code: ${errorMessage}`, getErrorMeta(error));
            await peer.call.aiPushCodeExplanation({
                itemId: request.itemId,
                propertyName: request.propertyName,
                error: `Failed to generate summary`,
            });
        }
    }
    async handleAiLabelAttachment(request) {
        const logger = getLogger();
        const startTime = Date.now();
        const timeout = 30_000;
        const attachment = request.attachment;
        logger.debug("[ai-service] Labeling attachment", {
            fileName: attachment.fileName ?? "unnamed",
            type: attachment.type,
        });
        const llmConfig = request.llmConfig
            ? {
                ...request.llmConfig,
                interleavedThinking: false,
                thinkingEnabled: false,
            }
            : undefined;
        const llmProviderSettings = this.getLLMProviderSettings(llmConfig);
        // Generate prompt_id for this label attachment request (user-initiated action)
        const promptId = randomUUID();
        getLogger().info("[ai-service] Generated prompt_id for label attachment", {
            promptId,
            applicationId: this.config.applicationId,
        });
        const llmProvider = createLLMProvider(llmProviderSettings, () => request.jwt, llmConfig, () => ({
            prompt_id: promptId,
            application_id: this.config.applicationId,
            session_id: this.sessionId,
        }));
        const model = llmProvider.modelForTask("fast");
        const LabelAttachmentSchema = z.object({
            label: z
                .string()
                .min(1)
                .describe('A brief 2-3 word description (e.g., "sales dashboard", "user profile", "data table", "customer data")'),
            insight: z
                .string()
                .min(1)
                .describe('A key observation about the content (e.g., "chart displays monthly revenue", "CSV contains customer records", "JSON config for API settings")'),
        });
        const timeoutController = new AbortController();
        const timeoutId = setTimeout(() => timeoutController.abort(), timeout);
        try {
            // Build the appropriate content part based on attachment type
            const contentPart = this.attachmentToContentPart(attachment);
            const { object: result } = await generateObject({
                model,
                schema: LabelAttachmentSchema,
                messages: [
                    {
                        role: "user",
                        content: [
                            {
                                type: "text",
                                text: "Analyze this attachment and provide a brief label and key insight about its content.",
                            },
                            contentPart,
                        ],
                    },
                ],
                abortSignal: timeoutController.signal,
            });
            const durationMs = Date.now() - startTime;
            logger.debug("[ai-service] Attachment labeling complete", {
                fileName: attachment.fileName,
                type: attachment.type,
                label: result.label,
                insight: result.insight,
                durationMs,
            });
            return result;
        }
        finally {
            clearTimeout(timeoutId);
        }
    }
    /**
     * Converts an attachment to the appropriate LLM content part.
     */
    attachmentToContentPart(attachment) {
        if (attachment.type === "image") {
            return {
                type: "image",
                image: attachment.image,
            };
        }
        else if (attachment.type === "pdf") {
            // Parse data URL to extract base64 data
            const match = attachment.data.match(/^data:([^;]+);base64,(.+)$/);
            const data = match?.[2] ?? attachment.data;
            const mediaType = match?.[1] ?? "application/pdf";
            return {
                type: "file",
                data,
                mediaType,
                filename: attachment.fileName,
            };
        }
        else if (isTextAttachment(attachment)) {
            // Wrap text content in XML tags for structured presentation
            const sanitizedFileName = attachment.fileName
                .replace(/&/g, "&amp;")
                .replace(/"/g, "&quot;")
                .replace(/'/g, "&apos;")
                .replace(/</g, "&lt;")
                .replace(/>/g, "&gt;");
            return {
                type: "text",
                text: `<${attachment.type}_file name="${sanitizedFileName}">\n${attachment.content}\n</${attachment.type}_file>`,
            };
        }
        else {
            throw new Error(`Unsupported attachment type: ${attachment.type}`);
        }
    }
    submitAcceptanceEvaluation(wasAccepted) {
        if (!this.clark.rootSpan) {
            this.getLogger().warn("[ai-service] No root span available for submitting feedback");
            return;
        }
        try {
            this.clark.tracer.submitEvaluation(this.clark.rootSpan, {
                label: "user_feedback",
                metricType: "categorical",
                value: wasAccepted ? "accepted" : "rejected",
            });
        }
        catch (error) {
            this.getLogger().warn(`[ai-service] Failed to submit feedback to Datadog: ${error}`, JSON.stringify(getErrorMeta(error), null, 2));
        }
    }
    async handleUserAcceptedDraft(peer, silently) {
        this.getLogger().info(`[ai-service] handleUserAcceptedDraft`);
        this.submitAcceptanceEvaluation(true);
        if (this.clark.state !== ClarkStateNames.AwaitingUser) {
            throw new Error("Service is not awaiting user input");
        }
        if (!this.clark.context.peer) {
            // peer can be null if service was started with a draft
            this.getLogger().info(`[ai-service] Setting peer from RPC caller`);
            this.clark.updateContext({
                peer,
            });
        }
        const transitionTo = transitionFrom(this.clark);
        void transitionTo({ type: USER_ACCEPTED_DRAFT, silently });
    }
    async handleUserRejectedDraft(peer, silently) {
        this.getLogger().info(`[ai-service] handleUserRejectedDraft`);
        this.submitAcceptanceEvaluation(false);
        if (this.clark.state !== ClarkStateNames.AwaitingUser) {
            throw new Error("Service is not awaiting user input");
        }
        if (!this.clark.context.peer) {
            // peer can be null if service was started with a draft
            this.getLogger().info(`[ai-service] Setting peer from RPC caller`);
            this.clark.updateContext({
                peer,
            });
        }
        const transitionTo = transitionFrom(this.clark);
        void transitionTo({
            type: USER_REJECTED_DRAFT,
            silently,
        });
    }
    async handleUserCanceled() {
        if (!this.isBusy()) {
            return;
        }
        this.clark.context.abortController?.abort();
        // Clear the prompt_id since this prompt was abandoned
        this.clark.updateContext((context) => ({
            ...context,
            promptId: undefined,
        }));
        void this.clark.send({ type: USER_CANCELED });
    }
    /**
     * Submit a prompt with interrupt behavior.
     * If not busy, starts immediately. Otherwise interrupts and continues.
     */
    async handleAiGenerateWithQueue(request, peer, peerId, mode) {
        this.getLogger().info(`[ai-service] handleAiGenerateWithQueue: peerId=${peerId}, mode=${mode}`);
        if (this.clark.state === ClarkStateNames.Dead) {
            throw new Error("Service is unavailable");
        }
        if (!this.isBusy()) {
            await this.handleAiGenerate(request, peer, peerId);
            return { status: "started" };
        }
        await this.interruptAndContinue(request, peer, peerId);
        return { status: "interrupted" };
    }
    /**
     * Interrupt current generation and continue with new prompt.
     * The partial response is preserved in chat history for context.
     */
    async interruptAndContinue(request, peer, peerId) {
        const partialResponse = this.clark.context.partialResponse?.text ?? "";
        const preservedContext = {
            jwt: this.clark.context.jwt,
            llmConfig: this.clark.context.llmConfig,
            availableIntegrations: this.clark.context.availableIntegrations,
        };
        this.getLogger().info(`[ai-service] Interrupting current generation. Partial response length: ${partialResponse.length}`);
        this.clark.updateContext({ isInterruptAbort: true });
        this.clark.context.abortController?.abort();
        await this.waitForAbortComplete();
        this.clark.updateContext({
            peer,
            peerId,
            jwt: preservedContext.jwt,
            llmConfig: preservedContext.llmConfig,
            availableIntegrations: preservedContext.availableIntegrations,
            abortController: new AbortController(),
            partialResponse: undefined,
            interruptedPreviousGeneration: true,
            interruptOriginalPrompt: request.prompt,
        });
        if (partialResponse.trim().length > 10) {
            await this.chatSessionStore.recordAssistant({
                text: partialResponse,
                type: "text",
            });
        }
        const enhancedPrompt = `[The user interrupted the previous generation to say this. It may be an update/correction to what you were doing, or a new direction entirely - use your judgment based on the message:]\n\n${request.prompt}`;
        const enhancedRequest = {
            ...request,
            prompt: enhancedPrompt,
        };
        await this.handleAiGenerate(enhancedRequest, peer, peerId);
    }
    /**
     * Wait for the FSM to transition out of busy state after abort.
     *
     * Resolves when idle, rejects after 20s. The caller must NOT replace
     * the AbortController before this settles — the running generation
     * re-reads the signal on each streamText / tool call and would
     * "un-abort" itself if given a fresh controller.
     */
    async waitForAbortComplete() {
        const WARN_AFTER_MS = 5_000;
        const MAX_WAIT_MS = 20_000;
        const CHECK_INTERVAL_MS = 50;
        const startTime = Date.now();
        let warned = false;
        return new Promise((resolve, reject) => {
            const checkState = () => {
                if (!this.isBusy()) {
                    const elapsed = Date.now() - startTime;
                    if (elapsed > WARN_AFTER_MS) {
                        this.getLogger().info(`[ai-service] Abort completed after ${elapsed}ms`);
                    }
                    resolve();
                    return;
                }
                const elapsed = Date.now() - startTime;
                if (elapsed > MAX_WAIT_MS) {
                    this.getLogger().error(`[ai-service] Abort did not complete after ${MAX_WAIT_MS}ms. ` +
                        `FSM state: ${this.clark.state}`);
                    reject(new Error("Abort timeout"));
                    return;
                }
                if (!warned && elapsed > WARN_AFTER_MS) {
                    this.getLogger().warn(`[ai-service] Still waiting for abort after ${WARN_AFTER_MS}ms. ` +
                        `FSM state: ${this.clark.state}`);
                    warned = true;
                }
                setTimeout(checkState, CHECK_INTERVAL_MS);
            };
            setTimeout(checkState, CHECK_INTERVAL_MS);
        });
    }
    async handleSkipTestCase(testRunId, testCaseId) {
        const logger = this.getLogger();
        logger.info(`[ai-service] Skip test case requested: testRunId=${testRunId}, testCaseId=${testCaseId}`);
        // Verify this is the active test run
        if (this.clark.context.currentTestRunId !== testRunId) {
            logger.warn(`[ai-service] Skip request for testRunId=${testRunId} but current is ${this.clark.context.currentTestRunId}`);
            return;
        }
        // Update context with skip request
        this.clark.updateContext({
            skipRequestedForTestCase: testCaseId,
        });
        // Update the test case status to skipped
        const testCaseStatuses = this.clark.context.testCaseStatuses ??
            new Map();
        testCaseStatuses.set(testCaseId, "skipped");
        this.clark.updateContext({
            testCaseStatuses,
        });
        // Send test_case_update message to UI
        const peer = this.clark.context.peer;
        if (peer) {
            void peer.call.aiPushMessage({
                type: "test_case_update",
                testRunId,
                testCaseId,
                testCaseStatus: "skipped",
                role: "assistant",
                content: "",
            });
        }
        logger.info(`[ai-service] Test case ${testCaseId} marked as skipped, flag set for subagent`);
    }
    /**
     * Handles the user's response to a tool permission request.
     *
     * If approved, immediately executes the tool with the original input,
     * injects the tool call and result into the conversation context,
     * and restarts the LLM with the updated context.
     *
     * If denied, injects a tool error into the context and restarts the LLM.
     */
    async handleAiRespondToToolPermission(request, peer, peerId) {
        const logger = this.getLogger();
        logger.info(`[ai-service] handleAiRespondToToolPermission: messageId=${request.messageId}, approved=${request.approved}`);
        const pendingRequest = this.clark.context.pendingToolPermissionRequest;
        logger.info(`[ai-service] Pending request: ${JSON.stringify(pendingRequest)}`);
        if (!pendingRequest) {
            logger.warn(`[ai-service] No pending tool permission request found for messageId=${request.messageId}`);
            throw new Error("No pending tool permission request");
        }
        if (pendingRequest.messageId !== request.messageId) {
            logger.warn(`[ai-service] Message ID mismatch: expected=${pendingRequest.messageId}, got=${request.messageId}`);
            throw new Error("Message ID mismatch");
        }
        logger.info(`[ai-service] Clearing pending request`);
        // Clear the pending request and notify UI
        clearPendingToolPermissionRequest(this.clark);
        const toolCallId = pendingRequest.messageId;
        const toolName = pendingRequest.toolName;
        const rawInput = pendingRequest.rawInput;
        const contextId = getContextId(this.clark, this.config);
        const llmConfig = this.clark.context.llmConfig;
        logger.info(`[ai-service] Getting context toolCallId=${toolCallId})`);
        const sendUserMessage = sendUserMessageChannel(this.clark, this.chatSessionStore);
        // Build the assistant message with the NEW tool call ID
        const assistantMessage = {
            role: "assistant",
            content: [
                {
                    type: "tool-call",
                    toolCallId,
                    toolName,
                    input: rawInput,
                },
            ],
        };
        // Build the tool result message based on approval/execution
        let toolResultMessage;
        if (request.approved) {
            // Approve in entity permission store
            this.entityPermissionStore.approveToolCall(toolName, rawInput);
            const signature = getToolCallSignature(toolName, rawInput);
            const formattedArgs = await getToolCallArguments(toolName, rawInput, this.clark);
            logger.info(`[ai-service] Sending tool call message: tool=${toolName}, toolCallId=${toolCallId}, signature=${signature}`);
            void sendUserMessage({
                type: "tool",
                tool: toolName,
                toolCallId,
                args: formattedArgs,
                signature,
            });
            // Store always-allow permissions
            request.alwaysAllowPermissions?.forEach((permission) => {
                void this.entityPermissionStore.approve(permission.entityType, permission.entityId, permission.permissionType);
            });
            // Execute the tool with proper access control context from Clark
            const accessControlContext = this.clark.getAccessControl(this.config.features);
            logger.info(`[ai-service] Executing approved tool: ${toolName}`);
            logger.info(`[ai-service] Access control context: customerDataAccessEnabled=${accessControlContext.customerDataAccessEnabled}, mode=${accessControlContext.mode}`);
            try {
                const result = await this.toolRegistry.execute(toolName, rawInput, accessControlContext, { toolCallId, messages: [] });
                if (result.type === "success") {
                    toolResultMessage = {
                        role: "tool",
                        content: [
                            {
                                type: "tool-result",
                                toolCallId,
                                toolName,
                                output: { type: "json", value: result.data },
                            },
                        ],
                    };
                    // Use getToolOutput for consistent formatting with processStreamChunk
                    const output = getToolOutput(toolName, result.data);
                    void sendUserMessage({
                        type: "tool-result",
                        toolCallId,
                        toolName,
                        output,
                    });
                }
                else {
                    logger.warn(`[ai-service] Tool ${toolName} execution failed: ${result.error.message}`);
                    toolResultMessage = {
                        role: "tool",
                        content: [
                            {
                                type: "tool-result",
                                toolCallId,
                                toolName,
                                output: {
                                    type: "error-json",
                                    value: { error: result.error.message },
                                },
                                isError: true,
                            },
                        ],
                    };
                    // Use getToolErrorCode for consistent formatting with processStreamChunk
                    const errorCode = getToolErrorCode(result.error);
                    void sendUserMessage({
                        type: "tool-error",
                        toolCallId,
                        toolName,
                        error: result.error.message,
                        errorCode,
                    });
                }
            }
            catch (error) {
                const errorMessage = error instanceof Error ? error.message : String(error);
                // create a tool-error message with the error
                toolResultMessage = {
                    role: "tool",
                    content: [
                        {
                            type: "tool-error",
                            toolCallId,
                            toolName,
                            output: {
                                type: "error-json",
                                value: { error: errorMessage },
                            },
                            isError: true,
                        },
                    ],
                };
                // Use getToolErrorCode for consistent formatting with processStreamChunk
                const errorCode = getToolErrorCode(error);
                void sendUserMessage({
                    type: "tool-error",
                    toolCallId,
                    toolName,
                    error: typeof errorMessage === "string"
                        ? errorMessage
                        : JSON.stringify(errorMessage),
                    errorCode,
                });
            }
        }
        else {
            const denyReason = request.denyMessage || "User denied permission for this tool call";
            toolResultMessage = {
                role: "tool",
                content: [
                    {
                        type: "tool-result",
                        toolCallId,
                        toolName,
                        output: {
                            type: "error-json",
                            value: { error: `Permission denied: ${denyReason}` },
                        },
                        isError: true,
                    },
                ],
            };
            logger.info(`[ai-service] Tool ${toolName} permission denied, error injected into context`);
        }
        const llmProviderSettings = this.getLLMProviderSettings(llmConfig);
        const llmProvider = createLLMProvider(llmProviderSettings, () => this.clark.context.jwt || "", llmConfig);
        const summarizationModel = llmProvider.modelForTask("summarizeMessages");
        const context = await this.contextManagerV2.getOrCreateContext(contextId, summarizationModel, llmConfig?.contextOptionsV2);
        const placeholderUsage = {
            inputTokens: 0,
            outputTokens: 0,
            totalTokens: 0,
        };
        await context.addResponse(assistantMessage, placeholderUsage);
        await context.addResponse(toolResultMessage, placeholderUsage);
        // Check for pending escalations that were deferred during tool execution
        // (because Clark was in AwaitingUser state for the permission request)
        if (hasPendingEscalations()) {
            logger.info(`[ai-service] Found pending escalation(s) after tool execution, flushing...`);
            const escalationSent = await flushPendingEscalations({
                clark: this.clark,
                services: { chatSessionStore: this.chatSessionStore },
                logger,
                force: true, // Bypass the AwaitingUser check since we're handling it here
                skipStateTransition: true, // Clark is already in AwaitingUser
            });
            if (escalationSent) {
                logger.info(`[ai-service] Sent escalation multi_choice to user, staying in AwaitingUser`);
                // Stay in AwaitingUser state - don't transition to AgentPlanning
                // The user needs to respond to the escalation
                return;
            }
        }
        // Restart the LLM with updated context
        // Create a minimal request to continue the conversation
        // llmConfig should always be available from the original request
        if (!this.clark.context.llmConfig) {
            throw new Error("Cannot continue after tool permission: llmConfig is missing from context");
        }
        const continueRequest = {
            prompt: request.approved
                ? ``
                : `Continue with the task using alternative approaches. The tool ${toolName} was denied permission.`,
            promptContext: {
                entities: [],
                integrations: [],
            },
            jwt: this.clark.context.jwt || "",
            llmConfig: this.clark.context.llmConfig,
            mode: this.clark.context.currentMode,
            browserContext: this.clark.context.browserContext,
            // Mark as tool_permission_response to skip user message recording in agent-planning
            responseMetadata: {
                type: "tool_permission_response",
                responseToMessageId: request.messageId,
                approved: request.approved,
                alwaysAllowPermissions: request.alwaysAllowPermissions,
            },
        };
        const transitionTo = transitionFrom(this.clark);
        void transitionTo({
            type: USER_SENT_PROMPT,
            request: continueRequest,
            peer,
            peerId,
        });
    }
    handleUserConnected(peer, peerId) {
        const existingPeerId = this.clark.context.peerId;
        this.getLogger().info(`[ai-service] handleUserConnected: peerId=${peerId}`);
        if (this.clark.context.peer) {
            if (existingPeerId === peerId) {
                this.getLogger().info(`[ai-service] Existing peer ${peerId} reconnected; refreshing reference`);
            }
            else {
                this.getLogger().warn(`[ai-service] Replacing stale peer ${existingPeerId ?? "unknown"} with ${peerId}`);
            }
        }
        else {
            this.getLogger().info(`[ai-service] peer connecting: ${peerId}`);
        }
        this.clark.updateContext((context) => ({
            ...context,
            peer,
            peerId,
        }));
        this.getLogger().info(`[ai-service] Peer set: ${peerId}`);
        // 1. Sync generation state if generation is in progress
        if (this.clark.context.lastGenerationState) {
            this.getLogger().info(`[ai-service] Syncing generation state on reconnect: ${this.clark.context.lastGenerationState.state}`);
            void peer.call.aiSetGenerationState(this.clark.context.lastGenerationState);
        }
        // 2. Sync tool permission state with the UI
        if (this.clark.context.pendingToolPermissionRequest) {
            // Resend pending tool permission request so user can respond after browser refresh
            const request = this.clark.context.pendingToolPermissionRequest;
            this.getLogger().info(`[ai-service] Resending pending tool permission request on reconnect: ${request.toolName}`);
            // Note: The promise from the original RPC call will have timed out,
            // but resending allows the user to see and respond to the request.
            // The tool execution will fail, but the agent can retry it naturally.
            void peer.call
                .aiRequestToolPermission({
                messageId: request.messageId,
                toolName: request.toolName,
                actionName: request.actionName ?? undefined,
                formattedInput: request.formattedInput ?? undefined,
                rawInput: request.rawInput,
                permissions: request.permissions ?? [],
            })
                .catch((error) => {
                this.getLogger().error(`[ai-service] Failed to resend tool permission request: ${error}`);
            });
        }
        else {
            // No pending request on the server — clear any stale permission the UI
            // may still be holding from a previous session or before a server restart.
            void peer.call.aiClearToolPermission().catch((error) => {
                this.getLogger().warn(`[ai-service] Failed to clear stale tool permission on reconnect`, error);
            });
        }
    }
    handleUserDisconnected(peerId) {
        const currentPeerId = this.clark.context.peerId;
        this.getLogger().info(`[ai-service] handleUserDisconnected: peerId=${peerId}`);
        if (!currentPeerId) {
            return;
        }
        if (currentPeerId !== peerId) {
            this.getLogger().info(`[ai-service] Stale disconnect ignored: peer ${peerId} disconnecting but current peer is ${currentPeerId}`);
            return; // Don't clear the current peer on stale disconnect
        }
        this.getLogger().info(`[ai-service] peer disconnecting: ${peerId}`);
        const contextId = this.getContextId();
        if (contextId) {
            this.contextManagerV2.clearContext(contextId);
        }
        this.clearPeerConnection();
    }
    clearPeerConnection() {
        this.getLogger().info(`[ai-service] Clearing peer connection`);
        this.clark.updateContext((context) => ({
            ...context,
            peer: undefined,
            peerId: undefined,
        }));
    }
    handleBuildSystemError(error) {
        // Explicitly copy error properties since they're not enumerable
        const errorWithTimestamp = {
            ...error, // Copy any custom enumerable properties (e.g., 'code', 'importPath')
            message: error.message,
            name: error.name,
            stack: error.stack,
            timestamp: Date.now(),
        };
        this.getLogger().info(`[ai-service] Received build system error: ${error.message}`);
        this.clark.updateContext((context) => ({
            ...context,
            buildSystemErrors: [
                ...(context.buildSystemErrors ?? []),
                errorWithTimestamp,
            ],
        }));
        // Invalidate context errors cache since build system errors changed
        debugCache.invalidateContextErrors();
    }
    handleRuntimeError(errorData) {
        const MAX_BUFFER_SIZE = 128 * 1024; // 128KB
        const TTL_MS = 10 * 60 * 1000; // 10 minutes
        const trimmedErrorData = {
            ...errorData,
            timestamp: errorData.timestamp ?? Date.now(),
            generationTimestamp: this.clark.context.runTimestamp,
            errorInfo: {
                ...errorData.errorInfo,
                componentStack: errorData.errorInfo?.componentStack
                    ?.split("\n")
                    .slice(0, 5)
                    .join("\n"),
            },
        };
        // Cache the size to avoid repeated calculations during pruning
        const errorWithSize = {
            ...trimmedErrorData,
            _size: estimateSize(trimmedErrorData),
        };
        this.clark.updateContext((context) => {
            // Add the new error
            const updatedErrors = [
                ...(context.runtimeErrors ?? []),
                errorWithSize,
            ];
            // Prune by TTL first (10 minutes)
            const ttlPruned = pruneByTTL(updatedErrors, TTL_MS);
            // Then prune by size (128KB buffer)
            const sizePruned = pruneBySize(ttlPruned, MAX_BUFFER_SIZE);
            return {
                ...context,
                runtimeErrors: sizePruned,
            };
        });
        // Invalidate context errors cache since runtime errors changed
        debugCache.invalidateContextErrors();
    }
    handleClearRuntimeError(id) {
        this.clark.updateContext((context) => ({
            ...context,
            runtimeErrors: (context.runtimeErrors ?? []).filter((error) => error.id !== id),
        }));
        // Invalidate context errors cache since runtime errors changed
        debugCache.invalidateContextErrors();
    }
    handleConsoleLog(logEntry) {
        const MAX_BUFFER_SIZE = 128 * 1024; // 128KB
        const TTL_MS = 10 * 60 * 1000; // 10 minutes
        // Store full messages - truncation happens at retrieval time (lazy truncation)
        const fullEntry = {
            ...logEntry,
            timestamp: logEntry.timestamp ?? Date.now(),
            generationTimestamp: this.clark.context.runTimestamp,
        };
        // Cache the size to avoid repeated calculations during pruning
        const entryWithSize = {
            ...fullEntry,
            _size: estimateSize(fullEntry),
        };
        this.clark.updateContext((context) => {
            // Add the new log
            const updatedLogs = [
                ...(context.consoleLogs ?? []),
                entryWithSize,
            ];
            // Prune by TTL first (10 minutes)
            const ttlPruned = pruneByTTL(updatedLogs, TTL_MS);
            // Then prune by size (128KB buffer)
            const sizePruned = pruneBySize(ttlPruned, MAX_BUFFER_SIZE);
            return {
                ...context,
                consoleLogs: sizePruned,
            };
        });
    }
    handleApiRunRecord(apiId, runRecord) {
        const MAX_BUFFER_SIZE = 128 * 1024; // 128KB
        const TTL_MS = 10 * 60 * 1000; // 10 minutes
        // Trim large response/error/stepLogs fields to prevent buffer overflow
        const trimmedStepLogs = runRecord.stepLogs?.map((log) => ({
            ...log,
            output: log.output !== undefined ? safeSampleJson(log.output) : undefined,
        }));
        const trimmedRecord = {
            ...runRecord,
            response: safeSampleJson(runRecord.response),
            stepLogs: trimmedStepLogs,
        };
        const recordWithMetadata = {
            ...trimmedRecord,
            apiId,
            timestamp: runRecord.timestamp ?? Date.now(),
            generationTimestamp: this.clark.context.runTimestamp,
        };
        // Cache the size to avoid repeated calculations during pruning
        const recordWithSize = {
            ...recordWithMetadata,
            _size: estimateSize(recordWithMetadata),
        };
        this.clark.updateContext((context) => {
            // Add the new record
            const updatedRecords = [...(context.apiRunRecords ?? []), recordWithSize];
            // Prune by TTL first (10 minutes)
            const ttlPruned = pruneByTTL(updatedRecords, TTL_MS);
            // Then prune by size (128KB buffer)
            const sizePruned = pruneBySize(ttlPruned, MAX_BUFFER_SIZE);
            return {
                ...context,
                apiRunRecords: sizePruned,
            };
        });
    }
    handleClearRuntimeErrors(before) {
        this.clark.updateContext((context) => {
            let filteredErrors = context.runtimeErrors ?? [];
            if (before != null) {
                filteredErrors = filteredErrors.filter((error) => error.timestamp != null && error.timestamp >= before);
            }
            else {
                filteredErrors = [];
            }
            return {
                ...context,
                runtimeErrors: filteredErrors,
            };
        });
        // Invalidate context errors cache since runtime errors changed
        debugCache.invalidateContextErrors();
    }
    handleClearBuildSystemErrors() {
        this.clark.updateContext((context) => ({
            ...context,
            buildSystemErrors: [],
        }));
        this.getLogger().info(`[ai-service] Cleared all build system errors`);
        // Invalidate context errors cache since build system errors changed
        debugCache.invalidateContextErrors();
    }
    async handlePromptInterpretation(request, peer) {
        void peer.call.aiPushPromptInterpretation({
            prompt: request.prompt,
            classification: "full_app_gen",
            entities: [], // this is not used yet. It should send any additional inferred entities.
        });
    }
    /**
     * Handles judge evaluation requests.
     *
     * Evaluates an application against a prompt using the judge system.
     * The evaluation is automatically stored to CSV for later analysis.
     *
     * @param request - Judge evaluation request
     * @returns Evaluation result with scores and feedback
     */
    async handleJudgeEvaluation(request) {
        this.getLogger().info(`[ai-service] Judge evaluation requested for promptId=${request.promptId}, appId=${request.appId}`);
        // Temporarily set the JWT for this evaluation
        const previousJwt = this.tokenManagerJwt;
        this.tokenManagerJwt = request.jwt;
        this.getLogger().info(`[ai-service] Set tokenManagerJwt for judge evaluation. hasJwt=${!!request.jwt}, jwtLength=${request.jwt?.length || 0}`);
        // Judge storage location is configurable via JUDGE_STORAGE_PATH env var
        // Supports both directory paths (writes to evaluations.csv) and full file paths
        // Default: <appRoot>/.superblocks/judge-evaluations/evaluations.csv
        const judgeStoragePath = process.env.JUDGE_STORAGE_PATH;
        let storageDir;
        let storageFilename;
        if (judgeStoragePath) {
            // Expand ~ to home directory
            const expandedPath = judgeStoragePath.startsWith("~/")
                ? path.join(os.homedir(), judgeStoragePath.slice(2))
                : judgeStoragePath;
            // Check if path ends with .csv (file path) or is a directory path
            if (expandedPath.endsWith(".csv")) {
                storageDir = path.dirname(expandedPath);
                storageFilename = path.basename(expandedPath);
            }
            else {
                storageDir = expandedPath;
                storageFilename = "evaluations.csv";
            }
        }
        else {
            // Default location
            storageDir = path.join(this.config.appRootDirPath, ".superblocks/judge-evaluations");
            storageFilename = "evaluations.csv";
        }
        const judgeStorage = new CsvJudgeStorage(storageDir, storageFilename);
        this.config.logger.info(`[ai-service] Judge storage configured: ${path.join(storageDir, storageFilename)}`);
        // Create LLM provider for judge with JWT getter
        // Use getLLMProviderSettings to ensure proper configuration (including upstreamProvider)
        const judgeLlmProvider = createLLMProvider(this.getLLMProviderSettings(request.llmConfig), () => this.getJwt(), request.llmConfig, undefined);
        const judgeService = new JudgeService({
            llmClient: this.llmClient,
            llmProvider: judgeLlmProvider,
            storage: judgeStorage,
            mcpServerManager: this.mcpServerManager,
            logger: this.config.logger,
        });
        // Construct storage state path for Playwright authentication
        const storageStateData = request.storageStateData ?? request.options?.storageStateData;
        const sessionStorageData = request.options?.sessionStorageData;
        const storageStatePath = storageStateData !== undefined
            ? undefined
            : request.options?.storageStatePath ||
                (!request.options?.connectWsEndpoint
                    ? process.env.PLAYWRIGHT_STORAGE_STATE_PATH ||
                        process.env.JUDGE_STORAGE_STATE_PATH ||
                        undefined
                    : undefined);
        this.getLogger().info(`[ai-service] Using storage state path: ${storageStatePath ?? "none (jwt or login expected)"}, connectWsEndpoint=${request.options?.connectWsEndpoint ?? "none"}, hasStorageStateData=${storageStateData ? "yes" : "no"}`);
        if (request.options) {
            this.getLogger().info("[ai-service] Judge options received:", request.options);
        }
        try {
            const result = await judgeService.evaluateApplication(request.promptId, request.prompt, request.appId, {
                appUrl: request.appUrl,
                jwt: request.jwt,
                llmConfig: request.llmConfig,
                storageStatePath,
                storageStateData,
                sessionStorageData,
                playwrightServerUrl: request.options?.playwrightServerUrl,
                ...request.options,
            });
            this.getLogger().info(`[ai-service] Judge evaluation completed: promptId=${request.promptId}, appId=${request.appId}, score=${result.overallScore}, passed=${result.passed}`);
            return result;
        }
        catch (error) {
            this.getLogger().error(`[ai-service] Judge evaluation failed: ${String(error)}`, getErrorMeta(error));
            throw error;
        }
        finally {
            // Restore previous JWT
            this.tokenManagerJwt = previousJwt;
        }
    }
    handleUserChange(change) {
        this.getLogger().info(`[ai-service] User changed: ${change.type}`, change);
        this.clark.updateContext((context) => ({
            ...context,
            userChanges: [...(context.userChanges ?? []), change],
        }));
    }
    async handleNewChat() {
        void this.chatSessionStore.newChat();
        try {
            await this.contextManagerV2.clearAllContexts();
        }
        catch (error) {
            this.getLogger().error("[ai-service] Failed to clear contexts on new chat", getErrorMeta(error));
        }
    }
    /**
     * Returns all saved app skill facts for the current application.
     */
    async getAppFacts() {
        const manager = new AppSkillsManager(this.config.appRootDirPath);
        const facts = await manager.loadFacts();
        return AppSkillsManager.toAiAppFacts(facts);
    }
    /**
     * Deletes an app skill fact by its virtual path.
     */
    async deleteAppFact(virtualPath) {
        const manager = new AppSkillsManager(this.config.appRootDirPath);
        await manager.deleteFact(virtualPath);
    }
    /**
     * Toggles the isEnabled field of an app skill fact and returns the updated fact.
     */
    async toggleAppFact(virtualPath, isEnabled) {
        const manager = new AppSkillsManager(this.config.appRootDirPath);
        return manager.toggleFact(virtualPath, isEnabled);
    }
    async handleRestoreToCommit(sinceTimestamp, commitId, applicationId, branchName) {
        // TODO: change this to truncate after commit once commit info is supported here, so we can truncate messages accurately.
        // 1. Truncate chat messages on server (always do this first)
        await this.chatSessionStore.truncateAfterTimestamp(sinceTimestamp);
        // Check JWT once at the beginning - needed for both restoration and truncation
        // Note: clark.context.jwt should be set by the socket handler (socket-manager.ts) before
        // this method is called. The socket handler updates it from peerAuthorization if missing.
        const jwt = this.clark?.context?.jwt;
        if (!jwt) {
            this.getLogger().warn(`[handleRestoreToCommit] No JWT available, cannot restore or truncate context`);
            return;
        }
        // 2. Try to restore context from checkpoint
        try {
            const contextId = getContextId(this.clark, this.config);
            const result = await restoreContextFromCheckpoint({
                applicationId,
                commitId,
                branchName,
                contextId,
                appRootDirPath: this.config.appRootDirPath,
                superblocksBaseUrl: this.config.superblocksBaseUrl,
                logger: this.getLogger(),
                jwt,
                contextManagerV2: this.contextManagerV2,
            });
            if (result.success) {
                this.getLogger().info(`[handleRestoreToCommit] Successfully restored main.json from checkpoint commitId=${commitId}, skipping truncation`);
                return; // Success - no need to truncate
            }
            else {
                // main.json not found or failed - fall through to truncation
                const reason = result.savedFiles.length > 0
                    ? "main.json not found in checkpoint"
                    : "no file saved from checkpoint";
                this.getLogger().info(`[handleRestoreToCommit] Context restore failed: ${reason} for commitId=${commitId}, using truncation fallback`);
            }
        }
        catch (error) {
            // Error downloading - log and fall through to truncation
            this.getLogger().warn(`[handleRestoreToCommit] Error restoring context from checkpoint, using truncation fallback`, getErrorMeta(error));
        }
        // 3. Fallback: Truncate LLM context (restore failed)
        const context = getActiveContext(this.clark, {
            contextManagerV2: this.contextManagerV2,
            applicationId: this.config.applicationId,
        });
        if (context) {
            context.truncateAfterTimestamp(sinceTimestamp);
            this.getLogger().info(`[handleRestoreToCommit] Context truncated after timestamp ${sinceTimestamp}`);
        }
        else {
            this.getLogger().info(`[handleRestoreToCommit] No active context loaded, skipping truncation`);
        }
    }
    async handleApiUpdate(update) {
        void this.appContext.handleApiUpdate(update.payload);
        if (update.author === "clark") {
            return;
        }
        if (!this.isBusy()) {
            void this.handleUserChange({
                type: "apiUpdate",
                apiName: update.payload.apiPb.metadata.name,
            });
        }
        // Note: We no longer pre-cache the YAML→TS transformation here.
        // ApiFileHandler.read() handles stale scratch correctly by checking
        // timestamps and regenerating from YAML on-demand when YAML is newer.
    }
    isBusy() {
        return (this.clark.state !== ClarkStateNames.Idle &&
            this.clark.state !== ClarkStateNames.AwaitingUser &&
            this.clark.state !== ClarkStateNames.Dead);
    }
    isAwaitingUser() {
        return this.clark.state === ClarkStateNames.AwaitingUser;
    }
    /**
     * Check if Clark is currently generating code.
     * Used by lock-service to determine if lock should be held during generation.
     * @returns true if Clark is in a generating state (AgentPlanning, LLMGenerating, PostProcessing, RuntimeReviewing)
     */
    isGenerating() {
        return (this.clark.state === ClarkStateNames.AgentPlanning ||
            this.clark.state === ClarkStateNames.LLMGenerating ||
            this.clark.state === ClarkStateNames.PostProcessing ||
            this.clark.state === ClarkStateNames.RuntimeReviewing);
    }
    /**
     * Wait for Clark generation to complete.
     * Generation means that Clark is either in Plan Mode or Build Mode
     * If Clark is not currently generating, resolves immediately.
     * Used by lock-service to wait for generation before releasing lock.
     * @returns Promise that resolves when generation completes (or immediately if not generating)
     */
    async waitUntilGenerationComplete() {
        if (!this.isGenerating()) {
            // Not generating, return immediately
            return;
        }
        // Wait for generation to complete
        return new Promise((resolve) => {
            this._onGenerationCompleteCallback = resolve;
        });
    }
    /**
     * Manually trigger the generation complete callback.
     * Should be called when Clark transitions to Idle or AwaitingUser state.
     */
    triggerGenerationComplete() {
        if (this._onGenerationCompleteCallback) {
            const callback = this._onGenerationCompleteCallback;
            this._onGenerationCompleteCallback = undefined;
            callback();
        }
    }
    async isGeneratingFullAppOrBroadUpdate() {
        if (!this.isBusy()) {
            return false;
        }
        const { isFullAppOrBroadUpdate } = classificationHelper(this.clark);
        return ((isFullAppOrBroadUpdate() || this.clark.context.usedDebugging) ?? false);
    }
    setSourceTracker(sourceTracker) {
        if (!sourceTracker) {
            throw new Error("Source tracker cannot be undefined");
        }
        this.fileSystemInterface.setSourceTracker(sourceTracker);
    }
    setFileSystemManager(fileSystemManager) {
        this.fileSystemInterface.setFileSystemManager(fileSystemManager);
        // Also set the template renderer for YAML→TS sync
        fileSystemManager.setTemplateRenderer(this.templateRenderer);
    }
    setViteServer(server) {
        this.viteServer = server;
    }
    getViteServer() {
        return this.viteServer;
    }
    getLLMProviderSettings(llconfig) {
        const providerFetchOverride = (provider) => {
            // Start with the base fetch (or provider-specific fetch if configured)
            let baseFetch = this.llmProviderSettings[provider].fetch ?? fetch;
            // Apply recording fetch wrapper if recording is enabled
            // This wraps the base fetch to capture LLM requests/responses
            if (this.recordingManager.isEnabled()) {
                baseFetch = createRecordingFetch(this.recordingManager, baseFetch);
            }
            const chaosOptions = {
                errorRate: 0,
                ...llconfig?.chaosOptions,
            };
            // Apply chaos fetch if configured (wraps recording fetch)
            if (chaosOptions.errorRate > 0) {
                return createChaosFetch(chaosOptions, baseFetch);
            }
            return baseFetch;
        };
        // When unified provider is enabled, use the /unified endpoint which handles fallback
        // Otherwise, use the specific provider endpoint directly
        const upstreamProvider = llconfig?.unifiedProviderEnabled
            ? "unified"
            : llconfig?.provider === "vertex"
                ? "vertex"
                : "bedrock";
        return {
            anthropic: {
                ...this.llmProviderSettings.anthropic,
                fetch: providerFetchOverride("anthropic"),
            },
            clark: {
                ...this.llmProviderSettings.clark,
                fetch: providerFetchOverride("clark"),
                upstreamProvider,
            },
        };
    }
}
//# sourceMappingURL=index.js.map