/**
 * Chat / Agent SSE routes
 *
 * POST /api/chat       — send a message, await full response
 * POST /api/chat/stream — SSE stream of agent events with session persistence
 *
 * Single agent instance per server process (single-user v1).
 */
import { validateArtifactBundle } from '../../core/artifact-bundle.js';
import { parseFollowups } from '../../agent/followups.js';
import * as access from '../../services/access-control.js';
import { reqGitUser } from './graph-api-access.js';
import { hashArtifactBody } from '../../agent/tools/artifact-tools.js';
import { log } from '../../utils/log.js';
import { addInferenceUsage, createCloudInferenceBillingClient, createInferenceUsageSummary, isStudiographCreditsMode, } from '../../services/cloud-inference-billing.js';
import { reserveCloudInference, settleCloudInference, releaseCloudInference, } from '../cloud-billing-flow.js';
import { prepareCreatePayload, bodyRevisionHash } from '../../services/entity-mutations.js';
import { StaleBaseRevisionError } from '../../core/graph.js';
import { resolveImageRefs, resolveDocContext, isValidAttachmentTurn, composeAttachmentMessageText, attachmentPlaceholder, normalizeAttachmentRef, } from '../../services/scratch-attachment-service.js';
/** Valid per-request reasoning levels — a curated subset of pi-agent-core's
 *  ThinkingLevel (we don't expose 'minimal'). Kept in sync with the client's
 *  THINKING_LEVEL_OPTIONS (repos.ts). Used to validate the user's stored
 *  `thinking_level` before it's applied — an unknown value falls back to the
 *  default. */
const VALID_THINKING_LEVELS = [
    'off', 'low', 'medium', 'high', 'xhigh',
];
const VALID_THINKING_LEVEL_SET = new Set(VALID_THINKING_LEVELS);
/** Resolve a user's preferred thinking level from their per-user settings,
 *  defaulting to 'medium' (the orchestrator's hardcoded fallback) when unset,
 *  invalid, or unreadable. Applied per message so a change takes effect on the
 *  user's next turn. */
function resolveUserThinkingLevel(authService, userId) {
    if (userId == null)
        return 'medium';
    try {
        const raw = authService.getUserSettings(userId)?.thinking_level;
        if (typeof raw === 'string' && VALID_THINKING_LEVEL_SET.has(raw)) {
            return raw;
        }
    }
    catch { /* fall through to default */ }
    return 'medium';
}
/**
 * A per-turn "today is …" context line so the agent always knows the current
 * date and stops misfiling entries to the wrong day. Prefers the client's local
 * date (correct timezone) and falls back to the server's UTC date for callers
 * that don't send one. The label is sanitized (single line, length-capped)
 * since it's free text from the request.
 */
export function buildDatePrefix(clientDate, clientDateLabel) {
    const iso = clientDate && /^\d{4}-\d{2}-\d{2}$/.test(clientDate)
        ? clientDate
        : new Date().toISOString().slice(0, 10);
    const label = clientDateLabel
        ? clientDateLabel.replace(/[\r\n]+/g, ' ').trim().slice(0, 64) || iso
        : iso;
    return (`[Today is ${label} (${iso}).]\n\n` +
        `Interpret relative dates ("today", "tomorrow", "yesterday", weekday names) ` +
        `against this. When setting a date field, default to ${iso} (YYYY-MM-DD) ` +
        `unless the user gives another date.\n\n`);
}
/** Cap on how many unresolved comments we fold into an entry's chat context.
 *  Keeps the prefix bounded on heavily-commented entries; the agent can still
 *  call list_entities/get_related for the full set. */
const MAX_CONTEXT_COMMENTS = 25;
function slugifyArtifactId(value) {
    const slug = value
        .toLowerCase()
        .replace(/[^a-z0-9]+/g, '-')
        .replace(/^-+|-+$/g, '')
        .slice(0, 80);
    return slug || 'artifact';
}
function parseEntityContext(context) {
    if (!context || context.startsWith('folder:'))
        return null;
    const [repo, entityType, entityId] = context.split('/');
    if (!repo || !entityType || !entityId)
        return null;
    return { repo, entityType, entityId };
}
function parseArtifactSelectionContext(parts) {
    const marker = parts.slice(3).find((part) => part.startsWith('artifact-selection:'));
    if (!marker)
        return null;
    const raw = marker.slice('artifact-selection:'.length);
    try {
        return decodeURIComponent(raw).slice(0, 1600);
    }
    catch {
        return raw.slice(0, 1600);
    }
}
function repoFromContext(context, user, agent, authService) {
    const workspaceManager = agent.getWorkspaceManager();
    if (context?.startsWith('folder:')) {
        const repo = context.slice('folder:'.length);
        return access.canWriteRepo(user, repo, workspaceManager, authService) ? repo : null;
    }
    const entityContext = parseEntityContext(context);
    if (entityContext && access.canWriteRepo(user, entityContext.repo, workspaceManager, authService)) {
        return entityContext.repo;
    }
    if (user) {
        const personal = workspaceManager.getUserPersonalRepo(user.id);
        if (personal && access.canWriteRepo(user, personal.name, workspaceManager, authService)) {
            return personal.name;
        }
    }
    return access.getAccessibleRepos(user, workspaceManager, authService)
        .find((repo) => access.canWriteRepo(user, repo.name, workspaceManager, authService))
        ?.name ?? null;
}
function resolveArtifactToolTarget(target, user, agent, authService) {
    if (!target?.entityId)
        return null;
    const workspaceManager = agent.getWorkspaceManager();
    if (target.repo) {
        if (!access.canWriteRepo(user, target.repo, workspaceManager, authService)) {
            throw new Error(`No write access to artifact folder: ${target.repo}`);
        }
        const graph = workspaceManager.getGraph(target.repo);
        if (!graph)
            throw new Error(`Folder not found: ${target.repo}`);
        try {
            graph.get('artifact', target.entityId);
        }
        catch {
            throw new Error(`Artifact target not found: ${target.entityId} in "${target.repo}". target updates an EXISTING artifact — to create a new one, drop target.entityId and pass createNew: true instead.`);
        }
        return { repo: target.repo, entityId: target.entityId };
    }
    const matches = [];
    for (const repo of access.getAccessibleRepos(user, workspaceManager, authService)) {
        if (!access.canWriteRepo(user, repo.name, workspaceManager, authService))
            continue;
        const graph = workspaceManager.getGraph(repo.name);
        if (!graph)
            continue;
        try {
            graph.get('artifact', target.entityId);
            matches.push({ repo: repo.name, entityId: target.entityId });
        }
        catch {
            // Not in this folder.
        }
    }
    if (matches.length === 1)
        return matches[0];
    if (matches.length > 1) {
        throw new Error(`Artifact target ${target.entityId} exists in multiple folders. Pass target.repo.`);
    }
    throw new Error(`Artifact target not found: ${target.entityId}. target updates an EXISTING artifact — to create a new one, drop target.entityId and pass createNew: true instead (with target.repo to pick the folder).`);
}
/** True when a bundle's entry document has no real content (the new-entry
 *  template ships a blank entrypoint). Mirrors the canvas's empty-artifact check. */
function bundleEntrypointBlank(bundle) {
    const entry = bundle.files?.[bundle.entrypoint];
    return typeof entry === 'string' && entry.trim().length === 0;
}
/** True when a stored artifact body parses to a bundle with a blank entrypoint.
 *  Unparseable bodies are treated as non-blank (conservative — the stale guard
 *  still applies rather than letting an unread overwrite through). */
function artifactBodyIsBlank(body) {
    try {
        const parsed = validateArtifactBundle(JSON.parse(body));
        return parsed.ok && !!parsed.bundle && bundleEntrypointBlank(parsed.bundle);
    }
    catch {
        return false;
    }
}
export async function persistArtifactFromChat(params) {
    const { bundle, context, target, user, agent, authService, gitUser, options } = params;
    const validation = validateArtifactBundle(bundle);
    if (!validation.ok || !validation.bundle) {
        throw new Error(`Invalid artifact bundle: ${validation.errors.join('; ')}`);
    }
    const validBundle = validation.bundle;
    const workspaceManager = agent.getWorkspaceManager();
    const body = `${JSON.stringify(validBundle, null, 2)}\n`;
    const entityContext = parseEntityContext(context);
    const actor = gitUser ?? { id: 'agent', name: 'Studiograph Agent', email: 'agent@studiograph.local' };
    const createData = {
        name: validBundle.title,
        kind: validBundle.kind,
        status: 'draft',
        viewport_width: validBundle.viewport.width,
        viewport_height: validBundle.viewport.height,
        viewport_mode: validBundle.viewport.mode,
    };
    const updateData = {
        name: validBundle.title,
        kind: validBundle.kind,
        viewport_width: validBundle.viewport.width,
        viewport_height: validBundle.viewport.height,
        viewport_mode: validBundle.viewport.mode,
    };
    // createNew is the explicit escape hatch from the scoped-canvas default: a
    // scoped chat otherwise has no path to a SECOND artifact (omitting target
    // updates the scoped canvas, and resolveArtifactToolTarget requires the
    // target to already EXIST — treating not-found as an implicit create would
    // mask typos by silently minting duplicates). It must not combine with
    // edit-shaped arguments.
    if (options?.createNew) {
        if (target?.entityId) {
            throw new Error('createNew creates a fresh artifact, but target.entityId points at an existing one — drop createNew to update that artifact, or drop target.entityId to create a new one (target.repo alone picks the destination folder).');
        }
        if (options.baseHash) {
            throw new Error('createNew creates a fresh artifact, but baseHash asserts an edit of one you read — drop baseHash (or drop createNew to update the artifact you read).');
        }
    }
    const explicitTarget = options?.createNew ? null : resolveArtifactToolTarget(target, user, agent, authService);
    const updateTarget = explicitTarget
        ?? (!options?.createNew && entityContext?.entityType === 'artifact'
            ? { repo: entityContext.repo, entityId: entityContext.entityId }
            : null);
    if (updateTarget) {
        if (!access.canWriteRepo(user, updateTarget.repo, workspaceManager, authService)) {
            throw new Error(`No write access to artifact folder: ${updateTarget.repo}`);
        }
        const graph = workspaceManager.getGraph(updateTarget.repo);
        if (!graph)
            throw new Error(`Folder not found: ${updateTarget.repo}`);
        // Stale-write + destructive-shrink guard. Only protects an artifact that
        // already has real content — a fresh/empty canvas (new-entry template) can
        // be filled freely without a prior read. Prefer the live editor body, so
        // the hash matches what read_artifact returned.
        let currentBody = null;
        // The on-disk body (null when the artifact doesn't exist yet). Kept separate
        // from `currentBody` (which prefers the live ytext) because the coordinator's
        // baseRevision precondition is checked against DISK — see the graph.update call.
        let diskBody = null;
        try {
            const existing = graph.get('artifact', updateTarget.entityId);
            diskBody = existing.document.content;
            currentBody = graph.getLiveBody('artifact', updateTarget.entityId) ?? diskBody;
        }
        catch {
            currentBody = null; // artifact doesn't exist yet → nothing to protect
            diskBody = null;
        }
        if (currentBody && !artifactBodyIsBlank(currentBody)) {
            if (!options?.baseHash) {
                throw new Error('This artifact already has content — call read_artifact first and pass its hash as baseHash before updating, so you do not overwrite the current source.');
            }
            if (options.baseHash !== hashArtifactBody(currentBody)) {
                throw new Error('Artifact changed since you read it (baseHash mismatch). Call read_artifact again, re-apply your change to the current source, then retry.');
            }
            if (!options.replace && bundleEntrypointBlank(validBundle)) {
                throw new Error('Refusing to blank a non-empty artifact. Include the full content, or pass replace: true to intentionally clear it.');
            }
        }
        try {
            await graph.update({
                entityType: 'artifact',
                entityId: updateTarget.entityId,
                data: updateData,
                content: body,
                commitMessage: `Update artifact/${updateTarget.entityId} from agent`,
                user: actor,
                source: 'server-write',
                // Re-check the precondition INSIDE the per-doc lock so two artifact
                // writes racing in one turn can't both pass the pre-lock baseHash check
                // above and then silently clobber each other — the loser throws.
                // Checked against DISK inside the lock, so this must be the DISK hash, not
                // `currentBody` (which prefers the live ytext and would always mismatch the
                // disk check when the editor has unsaved edits → spurious conflict). `body`
                // is the full new bundle, so no live content is lost. (Same rule as
                // graph.appendDatasetRows / patch_entity.) Use `!== null` not truthiness: an
                // empty ('') disk body (a blank stub being filled) still needs the in-lock
                // precondition, or two concurrent fills both pass the pre-lock blank check
                // and the loser silently clobbers the winner. hash('') is a valid base.
                baseRevision: diskBody !== null ? bodyRevisionHash(diskBody) : undefined,
            });
        }
        catch (err) {
            if (err instanceof StaleBaseRevisionError) {
                throw new Error('Artifact changed since you read it (a concurrent update landed). Call read_artifact again, re-apply your change to the current source, then retry.');
            }
            throw err;
        }
        return { repo: updateTarget.repo, entityType: 'artifact', entityId: updateTarget.entityId, action: 'updated' };
    }
    // Destination folder for the create: an explicit target.repo wins
    // (validated here — the update path above validates its own), else the chat
    // context decides (scoped entity's folder → folder scope → personal repo →
    // first writable folder).
    let repo;
    if (target?.repo) {
        if (!access.canWriteRepo(user, target.repo, workspaceManager, authService)) {
            throw new Error(`No write access to artifact folder: ${target.repo}`);
        }
        repo = target.repo;
    }
    else {
        repo = repoFromContext(context, user, agent, authService);
    }
    if (!repo)
        throw new Error('No writable folder is available for the artifact.');
    const graph = workspaceManager.getGraph(repo);
    if (!graph)
        throw new Error(`Folder not found: ${repo}`);
    const baseId = slugifyArtifactId(validBundle.artifactId || validBundle.title);
    // If an artifact already exists at the base id and is still a blank stub (an
    // earlier attempt in this same flow created the entry but never filled the
    // bundle), fill it in place instead of minting `baseId-2`. Otherwise the
    // agent creates a duplicate, consolidates the real bundle into the original,
    // deletes the duplicate, and the chat's artifact chip is left pointing at the
    // deleted `-2` id → "This entry couldn't be loaded". Only a BLANK stub is
    // adopted; a real artifact at the same slug still gets a fresh `-N` id so we
    // never silently overwrite existing content.
    //
    // Deliberately NOT skipped under createNew: a createNew retry after a failed
    // first fill must heal its own stub the same way (skipping would reintroduce
    // the dangling-chip duplicate), and adoption only ever fills EMPTY content —
    // createNew's contract (don't touch the scoped canvas or any existing
    // artifact's content) holds either way; only the reported `action` differs.
    try {
        const stub = graph.get('artifact', baseId);
        const stubBody = graph.getLiveBody('artifact', baseId) ?? stub.document.content;
        if (artifactBodyIsBlank(stubBody)) {
            await graph.update({
                entityType: 'artifact',
                entityId: baseId,
                data: updateData,
                content: body,
                commitMessage: `Fill artifact/${baseId} from agent`,
                user: actor,
                source: 'server-write',
            });
            return { repo, entityType: 'artifact', entityId: baseId, action: 'updated' };
        }
    }
    catch {
        // No existing artifact at baseId → fall through to a fresh create.
    }
    let entityId = baseId;
    let foundAvailableId = false;
    for (let i = 2; i < 1000; i++) {
        try {
            graph.get('artifact', entityId);
            entityId = `${baseId}-${i}`;
        }
        catch {
            foundAvailableId = true;
            break;
        }
    }
    if (!foundAvailableId) {
        entityId = `${baseId}-${Date.now().toString(36)}`;
    }
    await graph.create(prepareCreatePayload({
        entityType: 'artifact',
        entityId,
        data: createData,
        content: body,
        commitMessage: `Create artifact/${entityId} from agent`,
    }, actor));
    return { repo, entityType: 'artifact', entityId, action: 'created' };
}
/**
 * Render an entry's open (unresolved, non-deleted) comments as a compact,
 * newest-first block so the agent sees user feedback on the entry it's chatting
 * about without having to call a tool. Each line carries the author, the text
 * the comment is pinned to (anchor_quote), and the body. Returns '' when there
 * are no open comments (or no service), so the caller can append unconditionally.
 */
export function formatEntryComments(commentService, repo, entryType, entryId) {
    if (!commentService)
        return '';
    let comments;
    try {
        comments = commentService.list({ repo, entryType, entryId, resolved: false });
    }
    catch {
        return '';
    }
    if (comments.length === 0)
        return '';
    // list() returns oldest-first (ORDER BY thread_id, created_at); show the most
    // recent feedback first and cap the count.
    const ordered = [...comments]
        .sort((a, b) => b.createdAt.localeCompare(a.createdAt))
        .slice(0, MAX_CONTEXT_COMMENTS);
    const lines = ordered.map(c => {
        const anchor = c.anchorQuote ? ` (on "${c.anchorQuote}")` : '';
        const body = c.body.replace(/\s+/g, ' ').trim();
        return `- ${c.authorDisplayName}${anchor}: ${body}`;
    });
    const more = comments.length > ordered.length
        ? `\n(${comments.length - ordered.length} more unresolved — use get_related to see all)`
        : '';
    return (`\nUnresolved comments on this entry (newest first) — treat these as user ` +
        `feedback to address:\n${lines.join('\n')}${more}\n`);
}
export function buildChatContextPrefix(context, user, agent, authService, commentService) {
    if (!context)
        return '';
    const workspaceManager = agent.getWorkspaceManager();
    if (context.startsWith('folder:')) {
        const repo = context.slice('folder:'.length);
        if (!access.hasRepoAccess(user, repo, workspaceManager, authService))
            return '';
        return `[Currently viewing folder: ${repo}]\n\nScope your help to this folder unless the user asks otherwise. Use list_entities or search_entities with repoNames: ["${repo}"] to explore its contents.\n\n`;
    }
    const parts = context.split('/');
    if (parts.length < 3) {
        return `[Currently viewing: ${context}]\n\n`;
    }
    const [repo, type, id] = parts;
    const selectedArtifactTarget = parseArtifactSelectionContext(parts);
    if (!access.hasRepoAccess(user, repo, workspaceManager, authService))
        return '';
    try {
        const graph = workspaceManager.getGraph(repo);
        if (graph) {
            const entity = graph.get(type, id);
            const fm = entity.data ?? {};
            const entityContent = entity.document?.content ?? '';
            const fmLines = Object.entries(fm)
                .filter(([k]) => !['entity_type', 'entity_id', 'created_at', 'updated_at', 'created_by', 'updated_by'].includes(k))
                .map(([k, v]) => `${k}: ${typeof v === 'object' ? JSON.stringify(v) : v}`)
                .join('\n');
            const commentsBlock = formatEntryComments(commentService, repo, type, id);
            const isArtifact = type === 'artifact';
            // For artifacts, the body IS the full ArtifactBundle JSON (all HTML/CSS/JS
            // inlined). Re-sending it every turn overflows the model on larger
            // artifacts, so inject a compact header instead and have the agent pull
            // the source on demand via read_artifact. Use the live editor body so the
            // hash matches what read_artifact / the write guard see.
            let contentSection;
            if (isArtifact) {
                const liveBody = graph.getLiveBody('artifact', id) ?? entityContent;
                let fileCount = 0;
                try {
                    const parsed = JSON.parse(liveBody);
                    if (parsed?.files && typeof parsed.files === 'object')
                        fileCount = Object.keys(parsed.files).length;
                }
                catch { /* malformed body — header still useful */ }
                contentSection = `Artifact source: ${fileCount} file(s), ${liveBody.length} bytes, hash ${hashArtifactBody(liveBody)}. The full source is omitted here to keep context small — call read_artifact to get the current ArtifactBundle before editing.`;
            }
            else {
                contentSection = `Entity content:\n${entityContent}`;
            }
            const artifactInstruction = isArtifact
                ? `\nThis entry is an Artifact Canvas. To revise it, call read_artifact to get the current bundle and its hash, modify the full bundle, then call create_artifact with that bundle and the hash as baseHash (the bundle replaces the canvas). To fill an empty canvas from scratch, call create_artifact directly.\n`
                : '';
            const selectionInstruction = selectedArtifactTarget
                ? `\nSelected artifact target: ${selectedArtifactTarget}\nIf the user refers to "this", "the selected element", "this slide", or a similar scoped target, treat this selected artifact target as the intended area to modify.\n`
                : '';
            return `[Currently viewing entry: ${repo}/${type}/${id}]\n\nEntity frontmatter:\n${fmLines}\n\n${contentSection}\n${commentsBlock}${artifactInstruction}${selectionInstruction}\nAnswer questions about THIS entity. Use the information above.\n\n`;
        }
    }
    catch {
        return `[Currently viewing entry: ${repo}/${type}/${id}]\n\n`;
    }
    return '';
}
export async function registerChatRoutes(fastify, agent, wsHub, authService, memoryService, commentService, chatSessionService) {
    const cloudBilling = createCloudInferenceBillingClient();
    const creditsGatewayMode = isStudiographCreditsMode();
    // POST /api/chat — send message, get full response
    fastify.post('/api/chat', async (req, reply) => {
        const { message } = req.body;
        if (!message || typeof message !== 'string' || !message.trim()) {
            return reply.status(400).send({ error: 'message is required' });
        }
        try {
            const user = req.user ?? null;
            const sessionId = user ? `user:${user.id}:default` : 'default';
            let billingReservation = null;
            try {
                billingReservation = creditsGatewayMode
                    ? null
                    : await reserveCloudInference(cloudBilling, agent, 'Hosted Studiograph chat inference.');
                const response = await agent.chatInSession(sessionId, message, { user, userId: user?.id });
                await settleCloudInference(cloudBilling, billingReservation, response.usage);
                billingReservation = null;
                return reply.send({ content: response.content, toolCalls: response.toolCalls });
            }
            catch (err) {
                await releaseCloudInference(cloudBilling, billingReservation, 'Inference request failed before billing settlement.');
                throw err;
            }
        }
        catch (err) {
            fastify.log.error(err, 'Agent chat error');
            return reply.status(500).send({ error: err.message || 'Agent error' });
        }
    });
    // POST /api/chat/title — name a conversation from its opening exchange.
    // Called by the client after the first assistant reply lands; the returned
    // title replaces the truncated first-message title shown on the launcher.
    // Best-effort: returns { title: null } when titling isn't possible (gateway
    // mode, no key, empty result) so the client just keeps its fallback.
    fastify.post('/api/chat/title', async (req, reply) => {
        const message = req.body?.message;
        const assistantReply = req.body?.reply ?? '';
        if (!message || typeof message !== 'string' || !message.trim()) {
            return reply.status(400).send({ error: 'message is required' });
        }
        try {
            let billingReservation = null;
            try {
                billingReservation = creditsGatewayMode
                    ? null
                    : await reserveCloudInference(cloudBilling, agent, 'Studiograph conversation title.');
                const result = await agent.generateConversationTitle(message, assistantReply);
                await settleCloudInference(cloudBilling, billingReservation, result?.usage);
                billingReservation = null;
                return reply.send({ title: result?.title ?? null });
            }
            catch (err) {
                await releaseCloudInference(cloudBilling, billingReservation, 'Title request failed before billing settlement.');
                throw err;
            }
        }
        catch (err) {
            fastify.log.error(err, 'Title generation error');
            // Non-fatal — the client keeps its fallback title.
            return reply.send({ title: null });
        }
    });
    // POST /api/chat/stream — SSE stream of agent events
    fastify.post('/api/chat/stream', async (req, reply) => {
        const { message, context, sessionId, attachments, images, history, clientDate, clientDateLabel } = req.body;
        // Unify the generic `attachments` field with the legacy `images` field
        // (D10) so old clients keep working. The current turn's refs cover both
        // images (→ vision) and docs (→ injected text).
        const currentRefs = [
            ...(Array.isArray(attachments) ? attachments : []),
            ...(Array.isArray(images) ? images : []),
        ];
        // #5: a turn is valid with text OR at least one attachment (image/doc-only
        // sends are allowed); empty-everything is still rejected.
        if (!isValidAttachmentTurn(message, currentRefs.length)) {
            return reply.status(400).send({ error: 'message or attachments required' });
        }
        const messageText = typeof message === 'string' ? message : '';
        const user = req.user ?? null;
        const resolvedSessionId = sessionId
            ? (user ? `user:${user.id}:${sessionId}` : sessionId)
            // No sessionId: still scope per-user so two authenticated callers that
            // omit it don't collide on one shared 'default' pool entry (warm agent +
            // transcript + state). Mirrors the non-stream /api/chat route above.
            : (user ? `user:${user.id}:default` : 'default');
        // Set SSE headers
        reply.raw.setHeader('Content-Type', 'text/event-stream');
        reply.raw.setHeader('Cache-Control', 'no-cache');
        reply.raw.setHeader('Connection', 'keep-alive');
        reply.raw.flushHeaders();
        let streamDead = false;
        const sendEvent = (data) => {
            if (streamDead || !reply.raw.writable) {
                streamDead = true;
                return;
            }
            try {
                reply.raw.write(`data: ${JSON.stringify(data)}\n\n`);
            }
            catch {
                streamDead = true;
            }
        };
        // Detect client disconnect
        reply.raw.on('close', () => { streamDead = true; });
        reply.raw.on('error', () => { streamDead = true; });
        // Internal-agent identity for activity + entity_change tagging. The web
        // agent runs inside a user session, so the label reads as "<user>'s
        // agent" — distinguishable in the sidebar from external MCP integrations
        // which carry their token name (e.g. "Christian's Cursor").
        const agentLabel = user?.displayName ? `${user.displayName}'s agent` : 'Agent';
        const agentIdentity = {
            kind: 'internal',
            label: agentLabel,
            userId: user?.id != null ? String(user.id) : undefined,
        };
        // Turn-scoped activity tracking. A single chat turn can fan out across
        // multiple folders and intersperse tool calls with LLM "thinking" gaps.
        // We want one continuous chip per touched folder for the whole turn —
        // not a flicker per tool call. So:
        //   1. The first `enter` for a repo broadcasts and adds the repo to a
        //      turn-local set; subsequent enters refresh it (server-side
        //      throttle drops sub-second repeats).
        //   2. Per-tool `leave` events are suppressed — the chip is "pinned"
        //      until the turn ends.
        //   3. A 2s heartbeat keeps the chip alive across multi-second LLM
        //      thinking gaps that would otherwise time out the client's
        //      MIN_VISIBLE floor.
        //   4. The finally block below fires the actual `leave` for every
        //      touched folder when the turn completes (success, error, or
        //      client disconnect).
        const activeRepos = new Set();
        let activityHeartbeat = null;
        function ensureActivityHeartbeat() {
            if (activityHeartbeat)
                return;
            activityHeartbeat = setInterval(() => {
                for (const repo of activeRepos) {
                    wsHub.handleAgentActivity('heartbeat', repo, agentIdentity);
                }
            }, 2_000);
        }
        function stopActivityHeartbeat() {
            if (activityHeartbeat) {
                clearInterval(activityHeartbeat);
                activityHeartbeat = null;
            }
        }
        let billingReservation = null;
        try {
            billingReservation = creditsGatewayMode
                ? null
                : await reserveCloudInference(cloudBilling, agent, 'Hosted Studiograph streaming chat inference.');
            let artifactCount = 0;
            const gitUser = reqGitUser(req);
            const entityContext = parseEntityContext(context);
            const currentArtifactTarget = entityContext?.entityType === 'artifact'
                ? { repo: entityContext.repo, entityId: entityContext.entityId }
                : null;
            // Resolve composer attachments. The client sends durable REFS, never
            // bytes; we resolve from the asset/scratch ROW (A1) and re-authorize
            // every call. Two transports, by type:
            //  - images → vision blocks (current turn only — prior images are held by
            //    the warm agent-pool, or re-attached on demand by the user, D7).
            //  - docs   → injected TEXT, replayed EVERY turn from the session's prior
            //    user messages + the current turn (D7), so they survive reload and
            //    stay in context late in long chats. Doc text is already extracted +
            //    stored, so replay is just a row read (no re-parse).
            // Denied/deleted/over-cap refs degrade to `[file: name]` notes, not errors.
            let imageBlocks = [];
            let imagePlaceholders = [];
            let docContext = '';
            let docPlaceholders = [];
            // Doc refs replayed from history (prior user messages) — re-authorized in
            // the resolver, so a revoked/deleted file silently drops.
            const historyRefs = (user && sessionId && chatSessionService)
                ? chatSessionService.getSessionAttachmentRefs(user.id, sessionId)
                    .map(normalizeAttachmentRef)
                    .filter((r) => r !== null)
                : [];
            if (currentRefs.length > 0 || historyRefs.length > 0) {
                const attWsm = agent.getWorkspaceManager();
                const attIndex = attWsm.getAssetIndex?.();
                // The scratch blob store is workspace-global — reach it via any graph.
                const scratchGraph = (() => {
                    for (const cfg of attWsm.getAllRepoConfigs()) {
                        try {
                            const g = attWsm.getGraph(cfg.name);
                            if (g.getAssetService?.())
                                return g;
                        }
                        catch { /* next */ }
                    }
                    return null;
                })();
                const deps = {
                    getAssetRow: (id) => attIndex?.get(id) ?? null,
                    canAccessRepo: (repo) => access.hasRepoAccess(user ?? undefined, repo, attWsm, authService),
                    downloadAsset: (repo, id, filename) => attWsm.getGraph(repo).downloadAssetById(id, filename),
                    getScratchRow: (sid, fid) => chatSessionService?.getScratchFile(sid, fid) ?? null,
                    downloadScratch: (blobKey) => {
                        const svc = scratchGraph?.getAssetService?.();
                        if (!svc)
                            throw new Error('scratch backend unavailable');
                        return svc.download(blobKey);
                    },
                    currentUserId: user?.id,
                };
                // Images — current turn only. Gated on vision: a text-only model can't
                // see pixels, so degrade every image ref to a text note.
                if (currentRefs.length > 0) {
                    if (agent.supportsVision()) {
                        ({ blocks: imageBlocks, placeholders: imagePlaceholders } = await resolveImageRefs(currentRefs, deps));
                    }
                    else {
                        // Only IMAGE refs are "can't show" here — docs still go in as text
                        // via docContext below, so they must not also get a dropped note.
                        // Library `asset` refs are always images; a `scratch` ref's kind
                        // lives on its row, so look it up (cheap, rare text-only path).
                        imagePlaceholders = currentRefs
                            .filter((r) => r.kind === 'asset' || deps.getScratchRow(r.sessionId, r.fileId)?.kind === 'image')
                            .map((r) => attachmentPlaceholder(r.filename));
                    }
                }
                // Docs — current turn FIRST so a freshly-attached doc wins the file/char
                // budget over older replayed history; deduped in the resolver. Not
                // vision-gated (text works on any model). Synchronous (stored text).
                ({ docContext, placeholders: docPlaceholders } = resolveDocContext([...currentRefs, ...historyRefs], deps));
            }
            const attachmentPlaceholders = [...imagePlaceholders, ...docPlaceholders];
            const response = await agent.getPool().withAgent(resolvedSessionId, {
                user,
                // Raw client session id (what scratch images are keyed by) so
                // session-scoped tools target THIS conversation, not other chats.
                clientSessionId: sessionId,
                currentArtifactTarget,
                onHighlight: (ids) => sendEvent({ type: 'highlight', entityIds: ids }),
                onArtifact: async (bundle, target, writeOptions) => {
                    const location = await persistArtifactFromChat({
                        bundle,
                        context,
                        target,
                        user,
                        agent,
                        authService,
                        gitUser,
                        options: writeOptions,
                    });
                    artifactCount++;
                    sendEvent({ type: 'artifact', artifact: bundle, ...location });
                    sendEvent({
                        type: 'mutation',
                        action: location.action,
                        repo: location.repo,
                        entityType: location.entityType,
                        entityId: location.entityId,
                    });
                    wsHub.broadcast({
                        type: 'entity_change',
                        action: location.action,
                        repo: location.repo,
                        entityType: location.entityType,
                        entityId: location.entityId,
                        actor: user?.displayName ?? 'Agent',
                        source: 'api',
                        agent: agentIdentity,
                        timestamp: new Date().toISOString(),
                    });
                    return location;
                },
                onMutation: (m) => {
                    // Stream the event to the requesting user's chat (existing behavior).
                    sendEvent({ type: 'mutation', action: m.action, repo: m.repo, entityType: m.entityType, entityId: m.entityId });
                    // Broadcast to all connected workspace clients so OTHER users
                    // see live updates from this user's agent. Without this, the
                    // web agent's mutations are invisible to peers until they
                    // reload — MCP mutations already broadcast via src/mcp/tools.ts.
                    wsHub.broadcast({
                        type: 'entity_change',
                        action: m.action,
                        repo: m.repo,
                        entityType: m.entityType,
                        entityId: m.entityId,
                        actor: user?.displayName ?? 'Agent',
                        source: 'api',
                        agent: agentIdentity,
                        timestamp: new Date().toISOString(),
                    });
                    // Asset-only change (e.g. saving a chat image to the library):
                    // also emit repo_sync so asset views refresh — they don't track
                    // entity_change (free-standing assets aren't entities).
                    if (m.repoSync) {
                        wsHub.broadcast({
                            type: 'repo_sync',
                            repo: m.repo,
                            actor: user?.displayName ?? 'Agent',
                            source: 'api',
                            agent: agentIdentity,
                            timestamp: new Date().toISOString(),
                        });
                    }
                },
                onAgentActivity: (action, repo) => {
                    if (action === 'enter') {
                        activeRepos.add(repo);
                        wsHub.handleAgentActivity('enter', repo, agentIdentity);
                        ensureActivityHeartbeat();
                    }
                    // Per-tool `leave` is intentionally suppressed — the finally
                    // block at turn end fires a single `leave` per repo. `heartbeat`
                    // doesn't currently fire from tool wrappers, but if it did,
                    // pass through.
                    if (action === 'heartbeat' && activeRepos.has(repo)) {
                        wsHub.handleAgentActivity('heartbeat', repo, agentIdentity);
                    }
                },
            }, async (rawAgent) => {
                // Per-user thinking level (Agents settings tab). Set fresh each
                // turn — the agent reads state.thinkingLevel in createLoopConfig at
                // prompt time, so a per-message set takes effect this message. The
                // orchestrator's 'medium' default stands in for users who haven't
                // chosen a level (and for the capture/comments agents).
                rawAgent.state.thinkingLevel = resolveUserThinkingLevel(authService, user?.id);
                // Subscribe to agent events for streaming
                let hasContent = false;
                let needsTurnSep = false;
                const unsubscribe = rawAgent.subscribe((event) => {
                    if (event.type === 'message_update') {
                        const msgEvent = event.assistantMessageEvent;
                        if (msgEvent?.type === 'text_delta') {
                            if (needsTurnSep && hasContent) {
                                sendEvent({ type: 'token', text: '\n\n' });
                            }
                            needsTurnSep = false;
                            hasContent = true;
                            sendEvent({ type: 'token', text: msgEvent.delta });
                        }
                        else if (msgEvent?.type === 'thinking_delta') {
                            // Extended-thinking reasoning — relayed as an ephemeral
                            // single-line status the client shows in place of the dot.
                            // Not part of the message body; never persisted.
                            sendEvent({ type: 'thinking', text: msgEvent.delta });
                        }
                        else if (msgEvent?.type === 'thinking_end') {
                            // Reasoning block done → clear the status so the dot returns
                            // for tool execution / answer streaming.
                            sendEvent({ type: 'thinking_end' });
                        }
                    }
                    else if (event.type === 'message_end') {
                        if (event.message?.role === 'assistant') {
                            needsTurnSep = true;
                            for (const block of event.message.content ?? []) {
                                if (block.type === 'toolCall' && block.name) {
                                    sendEvent({ type: 'tool_start', name: block.name });
                                }
                            }
                        }
                    }
                });
                // Build scope-aware context prefix only after folder access passes.
                const contextPrefix = buildChatContextPrefix(context, user ?? undefined, agent, authService, commentService);
                const userPrefix = user ? `[Current user: ${user.displayName} (${user.email}, ${user.role})]\n\n` : '';
                const datePrefix = buildDatePrefix(clientDate, clientDateLabel);
                // #5: image/doc-only send → a minimal prompt so the agent looks at
                // the attachment. Dropped-file notes (placeholders) ride along as text.
                const hasAttachmentContent = imageBlocks.length > 0 || docContext.length > 0;
                const userText = composeAttachmentMessageText(messageText, hasAttachmentContent, attachmentPlaceholders);
                // Doc context (D12-wrapped, capped) goes BEFORE the user's text so the
                // question can reference "the attached file"; the wrapper marks it as
                // data, not instructions.
                const docBlock = docContext ? `${docContext}\n\n` : '';
                const fullMessage = userPrefix + datePrefix + contextPrefix + docBlock + userText;
                // Send message and await response
                const result = await new Promise((resolve, reject) => {
                    let content = '';
                    let assistantMessageCount = 0;
                    let toolCallCount = 0;
                    const blockTypeCounts = {};
                    let lastStopReason;
                    let lastErrorMessage;
                    const usage = createInferenceUsageSummary();
                    const endSub = rawAgent.subscribe((event) => {
                        if (event.type === 'message_end' && event.message?.role === 'assistant') {
                            addInferenceUsage(usage, event.message.usage);
                            assistantMessageCount++;
                            const blocks = event.message.content ?? [];
                            for (const b of blocks) {
                                blockTypeCounts[b.type] = (blockTypeCounts[b.type] ?? 0) + 1;
                            }
                            const textBlocks = blocks.filter((b) => b.type === 'text');
                            const toolBlocks = blocks.filter((b) => b.type === 'toolCall');
                            toolCallCount += toolBlocks.length;
                            if (event.message.stopReason)
                                lastStopReason = event.message.stopReason;
                            if (event.message.errorMessage)
                                lastErrorMessage = event.message.errorMessage;
                            // Keep the LAST non-empty text seen, so a follow-up assistant
                            // turn that has only tool calls doesn't wipe out the text that
                            // was produced earlier in the same prompt run.
                            const turnText = textBlocks.map((b) => b.text).join('');
                            if (turnText.length > 0)
                                content = turnText;
                        }
                        else if (event.type === 'agent_end') {
                            endSub();
                            if (!content) {
                                log.warn(`[chat/stream] agent produced no text ` +
                                    `(assistantMessages=${assistantMessageCount}, toolCalls=${toolCallCount}, ` +
                                    `blockTypes=${JSON.stringify(blockTypeCounts)}, ` +
                                    `stopReason=${lastStopReason ?? 'n/a'}, ` +
                                    `errorMessage=${JSON.stringify(lastErrorMessage ?? null)}, ` +
                                    `sessionId=${resolvedSessionId}) prompt="${messageText.slice(0, 80)}"`);
                            }
                            resolve({ content, usage });
                        }
                    });
                    rawAgent.prompt(fullMessage, imageBlocks.length ? imageBlocks : undefined)
                        .catch((err) => { endSub(); reject(err); });
                });
                unsubscribe();
                return result;
            }, { userId: user?.id, fallbackHistory: history });
            await settleCloudInference(cloudBilling, billingReservation, response.usage);
            billingReservation = null;
            // Write brief entry to today's daily memory
            if (memoryService && response.content) {
                try {
                    const snippet = `User: ${messageText.slice(0, 100)}${messageText.length > 100 ? '…' : ''}\nAgent: ${response.content.slice(0, 200)}${response.content.length > 200 ? '…' : ''}`;
                    memoryService.saveSessionSummary(snippet, { source: 'web-chat', sessionId }, user?.id);
                }
                catch { /* non-fatal */ }
            }
            // Parse follow-up suggestions from response (shared with CLI transport)
            const { content: parsedContent, suggestions } = parseFollowups(response.content);
            let cleanContent = parsedContent;
            // If the agent finished with no text AND no tool call, surface that
            // explicitly so the UI doesn't look frozen. Users reported "silent
            // failures" that were actually legitimately-empty assistant turns.
            // The `fallback: true` flag lets the client decide what to do \u2014
            // typed prompts render the message, system-initiated silent prompts
            // (auto-greet) drop the placeholder so users don't see a confusing
            // "Could you try rephrasing?" they didn't trigger.
            let fallback = false;
            if (!cleanContent.trim() && artifactCount === 0 && !streamDead) {
                cleanContent = '_I didn\u2019t have a response for that. Could you try rephrasing?_';
                fallback = true;
            }
            sendEvent({ type: 'done', message: cleanContent, suggestions, fallback });
        }
        catch (err) {
            await releaseCloudInference(cloudBilling, billingReservation, 'Inference request failed before billing settlement.');
            // Log to stderr so silent agent failures show up in server logs —
            // the SSE error event goes to the client but was invisible to ops.
            log.error('[chat/stream] agent error:', err?.stack || err?.message || err);
            sendEvent({ type: 'error', message: err.message || 'Agent error' });
        }
        finally {
            // End-of-turn agent activity cleanup. Fires for every folder the
            // agent touched during this turn — including paths through the
            // catch branch above, so a crashed/aborted turn still releases
            // its chips instead of relying on the 90s server TTL.
            stopActivityHeartbeat();
            for (const repo of activeRepos) {
                wsHub.handleAgentActivity('leave', repo, agentIdentity);
            }
            activeRepos.clear();
        }
        reply.raw.end();
    });
    // GET /api/chat/capabilities — composer feature gating (vision attach).
    fastify.get('/api/chat/capabilities', async (_req, reply) => {
        try {
            return { vision: agent.supportsVision() };
        }
        catch {
            // Conservative default — hide/disable the affordance rather than offer a
            // vision attach the model can't honor.
            return reply.send({ vision: false });
        }
    });
    // POST /api/chat/abort — abort the current agent run
    fastify.post('/api/chat/abort', async (_req, reply) => {
        try {
            agent.abort();
            return { success: true };
        }
        catch (err) {
            return reply.status(500).send({ error: err.message });
        }
    });
}
//# sourceMappingURL=chat.js.map