/**
 * Agent Orchestrator
 *
 * Manages the Pi-Mono agent instance and coordinates with workspace managers.
 * Loads skills, handles system prompts, and manages conversation flow.
 */
import { readFileSync, existsSync } from 'fs';
import { join, dirname } from 'path';
import { homedir } from 'os';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
import { getModel, completeSimple } from '@mariozechner/pi-ai';
import { Agent } from '@mariozechner/pi-agent-core';
import { WorkspaceManager } from '../core/workspace-manager.js';
import { findPersonRefForUser } from '../services/user-person-bridge.js';
import { createGraphTools } from './tools/graph-tools.js';
import { createFolderTools } from './tools/folder-tools.js';
import { createHistoryTools } from './tools/history-tools.js';
import { loadSkillIndex, buildSkillIndexPrompt, loadEagerSkills } from './skill-loader.js';
import { createLoadSkillTool } from './tools/load-skill.js';
import { loadCustomTools } from './tools/tool-loader.js';
import { ConnectorManager } from '../mcp/connector-manager.js';
import { buildConnectorRegistry, createConnectorTools } from './tools/connector-tools.js';
import { createFsTools } from './tools/fs-tools.js';
import { createWebTools } from './tools/web-tools.js';
import { createOpsTools } from './tools/ops-tools.js';
import { createPermissionTools } from './tools/permission-tools.js';
import { createCaptureTools } from './tools/capture-tools.js';
import { createArtifactTools, hashArtifactBody } from './tools/artifact-tools.js';
import { validateArtifactBundle } from '../core/artifact-bundle.js';
import { renderEntityTypesSection } from './prompts/entity-types-section.js';
import { ENTITY_SCHEMAS } from '../core/validation.js';
import { AuthService } from '../services/auth-service.js';
import * as access from '../services/access-control.js';
import { loadUserConfig, resolveApiKey, resolveProvider, resolveModelId } from '../core/user-config.js';
import { supportsVision } from '../services/model-capabilities.js';
import { onSecretMutation } from '../core/secrets/index.js';
import { AgentPool } from './agent-pool.js';
import { addInferenceUsage, createCloudInferenceStreamFn, createInferenceUsageSummary, isStudiographCreditsMode, studiographCreditsModel, } from '../services/cloud-inference-billing.js';
export class AgentOrchestrator {
    workspacePath;
    workspaceManager;
    pool;
    config;
    gitUser;
    skillsDirs;
    toolsDirs;
    registryRef = { registry: null };
    authService;
    commentService;
    chatSessionService;
    recentMemory;
    sessionsDir;
    // Per-session context — used by tool closures via the pool's contextMap
    // Legacy fields kept for backward compat with callers that set them before chat()
    _currentUser = null;
    _highlightCallback = null;
    _mutationCallback = null;
    currentSessionId = null;
    _sessionUserId;
    constructor(config) {
        this.workspacePath = config.workspacePath;
        this.config = config.workspaceConfig;
        this.gitUser = config.gitUser;
        this.recentMemory = config.recentMemory;
        // Prefer the route-level AuthService so we share the master-key
        // crypto wiring + the OAuth-mutation pubsub. Fallback construction
        // only fires in tests / CLI scenarios that don't supply one — those
        // paths don't read encrypted oauth_tokens, so the missing crypto is
        // harmless there.
        this.authService = config.authService ?? new AuthService(config.workspacePath);
        this.commentService = config.commentService ?? null;
        this.chatSessionService = config.chatSessionService ?? null;
        this.sessionsDir = join(homedir(), '.studiograph', 'sessions');
        // Skills: filesystem dirs are now bundled-only. User/workspace authoring
        // moves to graph `skill` entities (enumerated per-user in createAgentInstance).
        const userToolsDir = join(homedir(), '.studiograph', 'tools');
        const workspaceToolsDir = join(this.workspacePath, '.studiograph', 'tools');
        const bundledSkillsDir = join(__dirname, 'skills');
        this.skillsDirs = [bundledSkillsDir, ...(config.skillsDirs ?? [])];
        this.toolsDirs = [userToolsDir, workspaceToolsDir, ...(config.toolsDirs ?? [])];
        this.workspaceManager = config.workspaceManager ?? new WorkspaceManager(this.workspacePath, this.config, this.gitUser);
        // Initialize agent pool — each session gets its own Agent instance
        this.pool = new AgentPool({
            createAgent: (userId) => this.createAgentInstance(userId),
            loadCustomTools: (agent, userId) => this.loadCustomToolsAsync(agent, userId),
            sessionsDir: this.sessionsDir,
        });
        // Evict cached agents when an operator changes a model.* key via
        // Settings UI / CLI. Without this, the running agent keeps using
        // the apiKey/provider/modelId it captured at createAgentInstance
        // time, and the operator's rotation appears to have no effect
        // until the next process restart. Other keys (r2.*, search.*) are
        // read fresh on each tool call, so eviction isn't needed for them.
        onSecretMutation((key) => {
            if (key.startsWith('model.')) {
                this.pool.invalidateAll();
            }
        });
        // Evict the user's pooled agent when their OAuth state changes.
        // ConnectorManager.buildTools resolves per-user OAuth at agent-
        // construction time only; mid-session connect/disconnect would
        // otherwise leave the agent with a stale tool list (the connector
        // would stay invisible after Connect, or stay visible after
        // Disconnect). Scoped per-user — other users' sessions are
        // untouched.
        this.authService.onOAuthMutation((userId) => {
            this.pool.invalidateUser(userId);
        });
    }
    /**
     * Create Pi-Mono agent with tools and system prompt.
     * Custom tools and skills are loaded asynchronously via initAsync().
     */
    createAgentInstance(userId) {
        const userConfig = loadUserConfig();
        const useCreditsGateway = isStudiographCreditsMode();
        const provider = useCreditsGateway ? 'studiograph-cloud' : resolveProvider(userConfig, this.config);
        const modelId = useCreditsGateway ? studiographCreditsModel().id : resolveModelId(userConfig, this.config);
        const apiKey = useCreditsGateway ? 'studiograph-cloud-gateway' : resolveApiKey(userConfig, this.config);
        if (!apiKey) {
            throw new Error('API key not found in config or environment');
        }
        const model = useCreditsGateway
            ? studiographCreditsModel()
            : getModel(provider, modelId);
        // Build skill index — bundled filesystem skills + graph `skill` entries
        // from folders the user can access. Graph skills take priority over
        // bundled on name collision (user-authored overrides can customise
        // built-in skills).
        const promptRepos = this.getPromptRepoScope(userId);
        const graphSkills = this.enumerateGraphSkills(promptRepos, userId);
        const bundledSkills = loadSkillIndex(this.skillsDirs);
        const seen = new Set(graphSkills.map((s) => s.name));
        const skillIndex = [...graphSkills, ...bundledSkills.filter(s => !seen.has(s.name))];
        const graphResolver = (repo, id) => this.readGraphSkillBody(repo, id);
        // Build tools — closures read per-session context from the pool
        // The pool sets context before each withAgent call, so these resolve correctly
        // Closures that resolve per-session context at call time.
        // The pool sets context before each withAgent callback; the closure
        // finds which session owns this agent and reads from that context.
        const getUser = () => {
            const sid = this.pool.findSessionForAgent(agent);
            return sid ? (this.pool.getContext(sid).user ?? null) : this._currentUser;
        };
        // The raw client session id for the active turn (scratch images are keyed by
        // it). Resolved per call from the pool context so session-scoped tools target
        // the current conversation. Null for non-web contexts that set no context.
        const getActiveSessionId = () => {
            const sid = this.pool.findSessionForAgent(agent);
            return sid ? (this.pool.getContext(sid).clientSessionId ?? null) : null;
        };
        const onHighlight = (ids) => {
            const sid = this.pool.findSessionForAgent(agent);
            if (sid)
                this.pool.getContext(sid).onHighlight?.(ids);
            else
                this._highlightCallback?.(ids);
        };
        const onMutation = (m) => {
            const sid = this.pool.findSessionForAgent(agent);
            if (sid)
                this.pool.getContext(sid).onMutation?.(m);
            else
                this._mutationCallback?.(m);
        };
        const onArtifact = (bundle, target, options) => {
            const sid = this.pool.findSessionForAgent(agent);
            if (sid)
                return this.pool.getContext(sid).onArtifact?.(bundle, target, options);
        };
        const readArtifact = (target) => {
            const sid = this.pool.findSessionForAgent(agent);
            const ctx = sid ? this.pool.getContext(sid) : { user: this._currentUser };
            const effectiveTarget = target?.entityId ? target : ctx.currentArtifactTarget;
            if (!effectiveTarget?.entityId) {
                throw new Error('No artifact target is available. Pass target.entityId or open an artifact canvas first.');
            }
            if (!effectiveTarget.repo) {
                throw new Error('Artifact target repo is required when inspecting by target.');
            }
            if (!access.hasRepoAccess(ctx.user ?? undefined, effectiveTarget.repo, this.workspaceManager, this.authService)) {
                throw new Error(`Folder "${effectiveTarget.repo}" not found`);
            }
            const graph = this.workspaceManager.getGraph(effectiveTarget.repo);
            const entity = graph.get('artifact', effectiveTarget.entityId);
            // Prefer the live editor Y.Doc body over disk (matches get_entity /
            // patch_entity), so the agent edits what the user currently sees and the
            // hash matches the write-time guard's view of "current".
            const body = graph.getLiveBody('artifact', effectiveTarget.entityId) ?? entity.document.content;
            let rawBundle;
            try {
                rawBundle = JSON.parse(body);
            }
            catch {
                throw new Error(`Artifact ${effectiveTarget.repo}/${effectiveTarget.entityId} body is not a valid JSON bundle.`);
            }
            const validation = validateArtifactBundle(rawBundle);
            if (!validation.ok || !validation.bundle) {
                throw new Error(`Invalid artifact bundle: ${validation.errors.join('; ')}`);
            }
            return {
                bundle: validation.bundle,
                hash: hashArtifactBody(body),
                updatedAt: typeof entity.data?.updated_at === 'string' ? entity.data.updated_at : undefined,
            };
        };
        const onAgentActivity = (action, repo) => {
            const sid = this.pool.findSessionForAgent(agent);
            if (sid)
                this.pool.getContext(sid).onAgentActivity?.(action, repo);
        };
        /** Resolves the per-session capture callback at tool-call time. The
         *  capture flow installs it via AgentContext; other flows leave it
         *  undefined and the tool becomes a no-op acknowledgement. */
        const getNothingToCaptureCallback = () => {
            const sid = this.pool.findSessionForAgent(agent);
            if (!sid)
                return undefined;
            return this.pool.getContext(sid).onNothingToCapture ?? undefined;
        };
        const tools = [
            ...createGraphTools(this.workspaceManager, this.gitUser, this.workspacePath, onHighlight, onMutation, getUser, this.authService, onAgentActivity, this.commentService, this.chatSessionService, getActiveSessionId),
            ...createFolderTools(this.workspaceManager, this.gitUser, onMutation, getUser, this.authService, onAgentActivity),
            ...createHistoryTools(this.workspaceManager, this.gitUser, onMutation, getUser, this.authService, onAgentActivity),
            ...createOpsTools(this.workspacePath, this.config),
            ...createPermissionTools(this.authService, getUser, this.workspaceManager),
            ...createFsTools(getUser),
            ...createWebTools(this.config.search),
            ...createCaptureTools(getNothingToCaptureCallback),
            ...createArtifactTools(onArtifact, { readArtifact }),
            createLoadSkillTool(skillIndex, graphResolver),
        ];
        // Build system prompt — base + auto-generated tool reference + memory + eager skills + skill index
        const systemPrompt = this.buildSystemPrompt(skillIndex, tools, this.recentMemory, graphResolver, promptRepos, userId);
        // Log registered tools at startup (verbose only)
        if (process.env.STUDIOGRAPH_VERBOSE === '1') {
            console.log(`🔧 ${tools.length} tools registered: ${tools.map((t) => t.name).join(', ')}`);
        }
        // Dedup the bundled tools array — guards against any future
        // createXxxTools() collision so we never hand a duplicate-name list
        // to the Agent constructor (Anthropic 400s on `tools: Tool names must
        // be unique.`).
        const dedupedTools = [];
        const bundledSeen = new Set();
        for (const t of tools) {
            const name = t.name;
            if (!name || bundledSeen.has(name))
                continue;
            bundledSeen.add(name);
            dedupedTools.push(t);
        }
        const agent = new Agent({
            getApiKey: () => apiKey,
            // `thinkingLevel: 'medium'` turns on extended thinking so the runtime
            // emits thinking_delta events. The chat stream relays those as a
            // single-line "what the agent is thinking" status (replacing the pulsing
            // dot). 'medium' is the lowest level that produces readable, followable
            // reasoning on adaptive-thinking models (e.g. Opus 4.7, where 'low' is too
            // brief — it flashes by). Forwarded as `reasoning` on both the direct and
            // credits-gateway stream paths; a no-op for providers without reasoning.
            initialState: { model, systemPrompt, tools: dedupedTools, thinkingLevel: 'medium' },
            streamFn: useCreditsGateway ? createCloudInferenceStreamFn() : undefined,
        });
        // Custom tools are loaded asynchronously by the pool wrapper
        // (config.loadCustomTools, wired to loadCustomToolsAsync). Do NOT call
        // it here — calling it twice fires two concurrent Promise.alls, both
        // racing to mutate agent.state.tools, and the inter-microtask dedup
        // can't always prevent Anthropic seeing a duplicated list on the very
        // first prompt. Single fire site = no race.
        return agent;
    }
    /**
     * Load custom JS tools from app/workspace tool dirs and append to the agent.
     * Runs async after construction — custom tools may not be available for the
     * very first message if initialisation is extremely fast, but in practice
     * the first user message arrives well after startup.
     *
     * `userId` is forwarded into `ConnectorManager.buildTools` so per-user MCP
     * OAuth tokens are resolved at tool-build time. Without it the runtime
     * silently falls back to workspace-shared static headers (the deferred
     * gap before 2026-05-13). The agent pool keys agents per-session and
     * supplies the session's userId on creation; CLI / non-interactive
     * spawns pass `undefined` and intentionally land on the workspace
     * fallback.
     */
    loadCustomToolsAsync(agent, userId) {
        const context = { workspaceManager: this.workspaceManager, gitUser: this.gitUser };
        return Promise.all([
            loadCustomTools(this.toolsDirs, context),
            ConnectorManager.buildTools(ConnectorManager.loadConfigs(this.workspacePath), { userId, authService: this.authService }),
        ]).then(([customTools, connectorTools]) => {
            const extra = [...customTools];
            if (connectorTools.length > 0) {
                const registry = buildConnectorRegistry(connectorTools);
                extra.push(...createConnectorTools(registry));
                this.registryRef.registry = registry;
            }
            if (extra.length > 0) {
                // Dedup by name — this method can be invoked more than once per agent
                // (createAgentInstance schedules it, and the pool also calls it via
                // its loadCustomTools callback). Without dedup we end up with
                // duplicate tool registrations, which Anthropic rejects on the next
                // turn with: 400 "tools: Tool names must be unique."
                const existing = agent.state.tools;
                const seen = new Set(existing.map((t) => t.name));
                const merged = [...existing];
                for (const t of extra) {
                    if (seen.has(t.name))
                        continue;
                    seen.add(t.name);
                    merged.push(t);
                }
                if (merged.length !== existing.length) {
                    agent.state.tools = merged;
                }
            }
            if (process.env.STUDIOGRAPH_VERBOSE === '1') {
                // eslint-disable-next-line no-console
                console.log(`[orchestrator] loadCustomToolsAsync done: customTools=${customTools.length}, connectorTools=${connectorTools.length}, total agent.state.tools=${agent.state.tools.length}`);
            }
        }).catch(err => {
            console.warn('Failed to load custom/connector tools:', err);
        });
    }
    /**
     * Build the full system prompt: base prompt + eager skill content + skill index.
     */
    buildSystemPrompt(skillIndex, tools, recentMemory, graphResolver, promptRepos = this.workspaceManager.getContentRepoConfigs(), userId) {
        const promptPath = join(__dirname, 'prompts', 'system.md');
        let base = existsSync(promptPath)
            ? readFileSync(promptPath, 'utf-8')
            : this.getDefaultSystemPrompt(promptRepos);
        // Auto-generate tool reference from registered tools, grouped by category
        const categories = {
            'Entity Management': [],
            'Workspace Git': [],
            'External Connectors': [],
            'Operations': [],
            'Skills': [],
            'Other': [],
        };
        for (const t of tools) {
            if (t.description?.startsWith('DEPRECATED'))
                continue;
            const name = t.name ?? '';
            if (name.startsWith('workspace_'))
                categories['Workspace Git'].push(t);
            else if (name.startsWith('connector_'))
                categories['External Connectors'].push(t);
            else if (name.startsWith('app_') || name.startsWith('deploy_'))
                categories['Operations'].push(t);
            else if (name === 'load_skill')
                categories['Skills'].push(t);
            else if (['create_artifact', 'inspect_artifact'].includes(name))
                (categories['Artifacts'] ??= []).push(t);
            else if (['create_entity', 'get_entity', 'update_entity', 'delete_entity', 'search_entities', 'list_entities', 'get_related', 'get_backlinks', 'validate_wikilinks', 'get_workspace_summary', 'list_repos', 'query_dataset', 'append_rows', 'get_entity_schema', 'lint_entities'].includes(name))
                categories['Entity Management'].push(t);
            else if (['create_folder', 'create_subfolder', 'rename_folder', 'delete_folder', 'list_folders', 'move_entity', 'transfer_entity'].includes(name))
                (categories['Folders'] ??= []).push(t);
            else if (['list_file_history', 'list_folder_history', 'list_recent_deletions', 'read_file_at_commit', 'get_entity_diff', 'restore_file'].includes(name))
                (categories['Git History'] ??= []).push(t);
            else
                categories['Other'].push(t);
        }
        const toolRef = Object.entries(categories)
            .filter(([, items]) => items.length > 0)
            .map(([cat, items]) => `**${cat}:**\n${items.map((t) => `- \`${t.name}\` — ${t.description}`).join('\n')}`)
            .join('\n\n');
        base = base.replace('{{TOOL_REFERENCE}}', toolRef);
        // Render the agent-visible entity type list from the launch allowlist +
        // any workspace-defined custom types.
        const registry = this.workspaceManager.getSchemaRegistry();
        const builtinTypes = new Set(Object.keys(ENTITY_SCHEMAS));
        const customTypes = registry.getAllTypes().filter(t => !builtinTypes.has(t));
        base = base.replace('{{ENTITY_TYPES}}', renderEntityTypesSection({ customTypes }));
        const parts = [base.trim()];
        // Inject workspace identity from graph entities (org, team, capabilities, guidelines)
        // Falls back to ABOUT.md if the graph has no organization entries
        const identitySection = this.buildWorkspaceIdentity(promptRepos);
        if (identitySection) {
            parts.push(identitySection);
        }
        else {
            const aboutPath = join(this.workspacePath, '.studiograph', 'ABOUT.md');
            if (existsSync(aboutPath)) {
                const aboutContent = readFileSync(aboutPath, 'utf-8');
                if (aboutContent.trim()) {
                    parts.push(`## Workspace Identity\n\n${aboutContent}`);
                }
            }
        }
        // Append workspace schema extensions (org-specific fields and custom types)
        const schemaSection = this.workspaceManager.getSchemaRegistry().toPromptSection();
        if (schemaSection) {
            parts.push(schemaSection);
        }
        // Inject per-repo subfolder inventory so the agent knows the existing
        // folder layout without calling list_folders for every routing decision.
        // Capped per-repo to keep system prompt size bounded.
        const folderInventory = this.buildFolderInventory(promptRepos);
        if (folderInventory) {
            parts.push(folderInventory);
        }
        // Inject recent memory context (today + yesterday daily memory)
        if (recentMemory) {
            parts.push(`## Memory Context\n\n${recentMemory}`);
        }
        // Inject current-user identity + bridge so the agent can read its own
        // handle and the linked person entity from the prompt instead of
        // having to call list_users on every turn.
        if (userId) {
            const u = this.authService.findUserById(userId);
            if (u) {
                const ref = findPersonRefForUser(this.workspaceManager, u.handle);
                const lines = [
                    `## Current User`,
                    ``,
                    `- Display name: ${u.displayName}`,
                    `- Handle: \`${u.handle}\` (use this for created_by/updated_by audit, @mentions, and link_person_to_user)`,
                    `- Email: ${u.email}`,
                    `- Role: ${u.role}`,
                    ref
                        ? `- Linked person entity: \`[[${ref.entityId}]]\` in \`${ref.repo}\` — when the user refers to themselves, this is their record. Write enrichments here via update_entity; never call link_person_to_user for the current user.`
                        : `- Linked person entity: **none.** This account has no person record yet (new/legacy account, or a previously-linked record was deleted). The link is optional but powers their "My Tasks", @mentions, and lets teammates assign tasks to them. If they'd like to link, offer to either (a) find an existing person record that is them and link it (search_entities → link_person_to_user), or (b) create a person entity for them and link it (create_entity → link_person_to_user). You can also point them to Settings → Profile → "Create & link" for a one-click repair. Don't push workspace-wide config changes for this.`,
                ];
                parts.push(lines.join('\n'));
            }
        }
        // Inline eager skills directly into the system prompt
        const eagerContent = loadEagerSkills(skillIndex, graphResolver);
        if (eagerContent) {
            parts.push(eagerContent);
        }
        // Append skill index for on-demand skills
        const index = buildSkillIndexPrompt(skillIndex);
        if (index) {
            parts.push(index);
        }
        return parts.join('\n\n');
    }
    /**
     * Enumerate skill entities across all folders the user can access. Each
     * skill becomes a SkillMeta with a `graph://<repo>/<id>` sentinel
     * entrypoint. Priority order: user's private folder first, then shared
     * folders alphabetically. First occurrence of a given name wins.
     */
    getPromptRepoScope(userId) {
        if (!userId)
            return this.workspaceManager.getAllRepoConfigs();
        const user = this.authService.findUserById(userId);
        if (!user)
            return [];
        return access.getAccessibleRepos(user, this.workspaceManager, this.authService);
    }
    enumerateGraphSkills(accessibleRepos, userId) {
        const skills = [];
        const seen = new Set();
        const personalRepo = userId ? this.workspaceManager.getUserPersonalRepo(userId) : undefined;
        const sortedRepos = [...accessibleRepos]
            .sort((a, b) => {
            if (personalRepo && a.name === personalRepo.name)
                return -1;
            if (personalRepo && b.name === personalRepo.name)
                return 1;
            return a.name.localeCompare(b.name);
        });
        for (const repo of sortedRepos) {
            try {
                const graph = this.workspaceManager.getGraph(repo.name);
                const entries = graph.list('skill');
                for (const entry of entries) {
                    if (seen.has(entry.id))
                        continue;
                    seen.add(entry.id);
                    const data = entry.data;
                    skills.push({
                        name: entry.id,
                        description: typeof data.description === 'string' ? data.description : '',
                        loading: data.loading === 'eager' ? 'eager' : 'on-demand',
                        dir: `graph://${repo.name}`,
                        entrypoint: `graph://${repo.name}/${entry.id}`,
                        isDirectory: false,
                    });
                }
            }
            catch {
                // skip repos that fail to load
            }
        }
        return skills;
    }
    /**
     * Load the markdown body of a skill entity by (repo, id).
     */
    readGraphSkillBody(repo, id) {
        try {
            const graph = this.workspaceManager.getGraph(repo);
            const entries = graph.list('skill');
            const entry = entries.find(e => e.id === id);
            if (!entry)
                return `Error: skill "${id}" not found in folder "${repo}".`;
            return entry.document?.content ?? '';
        }
        catch (e) {
            return `Error loading skill "${id}" from "${repo}": ${e?.message ?? e}`;
        }
    }
    /**
     * Build a workspace identity section from graph entities.
     * Reads organization, person, practice-area, and guideline entries
     * to compose a concise identity summary for the system prompt.
     * Returns null if no organization entries exist.
     */
    buildWorkspaceIdentity(repos) {
        try {
            const listScoped = (entityType) => repos.flatMap(repo => {
                try {
                    return this.workspaceManager.getGraph(repo.name).list(entityType);
                }
                catch {
                    return [];
                }
            });
            const orgs = listScoped('organization');
            if (orgs.length === 0)
                return null;
            const lines = ['## Workspace Identity'];
            // Organization(s)
            for (const org of orgs.slice(0, 3)) {
                const name = org.data.name || org.id;
                const industry = org.data.industry ? ` (${org.data.industry})` : '';
                lines.push(`\n### ${name}${industry}`);
                if (org.document.content.trim()) {
                    // Include first ~500 chars of body for context
                    const body = org.document.content.trim();
                    lines.push(body.length > 500 ? body.slice(0, 500) + '...' : body);
                }
            }
            // Team
            const people = listScoped('person');
            if (people.length > 0) {
                lines.push('\n### Team');
                for (const person of people.slice(0, 15)) {
                    const name = person.data.name || person.id;
                    const role = person.data.role ? ` — ${person.data.role}` : '';
                    const location = person.data.location ? ` (${person.data.location})` : '';
                    lines.push(`- ${name}${role}${location}`);
                }
            }
            // Services (capabilities + methodologies — replaces former practice areas)
            const services = listScoped('service');
            if (services.length > 0) {
                lines.push('\n### Capabilities');
                // Top-level services (no `sprint` parent) read as the firm's overall
                // capabilities; nested services are sprint-level details. Surface
                // top-level only here to keep the system prompt compact.
                const topLevel = services.filter(s => !s.data.sprint);
                const list = topLevel.length > 0 ? topLevel : services;
                lines.push(list.map(s => s.data.name || s.id).join(', '));
            }
            // Guidelines (communication style, preferences) — now document.category=guideline
            const guidelines = listScoped('document').filter(d => d.data.category === 'guideline');
            if (guidelines.length > 0) {
                lines.push('\n### Guidelines');
                for (const g of guidelines.slice(0, 5)) {
                    const name = g.data.name || g.id;
                    lines.push(`\n**${name}**`);
                    if (g.document.content.trim()) {
                        const body = g.document.content.trim();
                        lines.push(body.length > 300 ? body.slice(0, 300) + '...' : body);
                    }
                }
            }
            return lines.join('\n');
        }
        catch {
            return null;
        }
    }
    /**
     * Per-repo subfolder summary for the system prompt. Lists each accessible
     * repo with up to MAX_PER_REPO subfolder paths so the agent can route new
     * entries into existing folders without calling list_folders for every
     * decision. Refresh cadence is per-session (system prompt is built once).
     */
    buildFolderInventory(repos) {
        const MAX_PER_REPO = 8;
        // System folders (the reserved asset store) are never a place the agent
        // should create entries — keep them out of the folder inventory.
        repos = repos.filter(r => !r.system);
        if (repos.length === 0)
            return null;
        const lines = [];
        for (const repo of repos) {
            let folders = [];
            try {
                folders = this.workspaceManager.getGraph(repo.name).listFolders();
            }
            catch {
                continue;
            }
            const display = repo.display_name || repo.name;
            if (folders.length === 0) {
                lines.push(`- **${display}** (\`${repo.name}\`): no subfolders`);
            }
            else {
                // Render `path` plus an explicit display_name override in quotes when one
                // exists — so the agent can refer to folders by their human label (e.g. in
                // follow-up suggestions) instead of the kebab path. Folders without an
                // override title-case the last segment for display.
                const shown = folders
                    .slice(0, MAX_PER_REPO)
                    .map(f => (f.display_name ? `\`${f.path}\` ("${f.display_name}")` : `\`${f.path}\``))
                    .join(', ');
                const more = folders.length > MAX_PER_REPO ? ` (+${folders.length - MAX_PER_REPO} more)` : '';
                lines.push(`- **${display}** (\`${repo.name}\`): ${shown}${more}`);
            }
        }
        if (lines.length === 0)
            return null;
        return `## Folder Inventory\n\nExisting subfolders per repo. Prefer placing new entries in an existing folder when one fits. Folder paths are repo-relative POSIX; a quoted "Display Name" after a path is that folder's human label.\n\nThe **bold** name is the repo's display name — use it in any text the user sees (e.g. "My Folder"), never the \`slug\` in parentheses. The slug is only for tool calls and URL/wikilink paths.\n\n${lines.join('\n')}`;
    }
    /**
     * Fallback system prompt when prompts/system.md is not found.
     */
    getDefaultSystemPrompt(promptRepos = this.workspaceManager.getContentRepoConfigs()) {
        const totalEntities = promptRepos.reduce((count, repo) => {
            try {
                return count + this.workspaceManager.getGraph(repo.name).search({}).length;
            }
            catch {
                return count;
            }
        }, 0);
        const repoList = promptRepos
            .map(r => `  - ${r.name} (${r.type}): ${r.description}`)
            .join('\n');
        return `# Studiograph AI Assistant

You are Studiograph, an AI assistant helping creative teams manage their knowledge graphs.

## Workspace

**Team:** ${this.config.team_name}
**Repositories:** ${promptRepos.length}
**Total Entities:** ${totalEntities}

**Available Repositories:**
${repoList}`;
    }
    /**
     * Send a message to the agent
     */
    /**
     * Send a message and await the full agent response.
     * Uses the current session (set via switchSession) or a default session.
     */
    async chat(userMessage) {
        const sessionId = this.currentSessionId ?? 'default';
        const ctx = {
            user: this._currentUser,
            onHighlight: this._highlightCallback,
            onMutation: this._mutationCallback,
        };
        return this.pool.withAgent(sessionId, ctx, async (agent) => {
            return new Promise((resolve, reject) => {
                let content = '';
                const toolCalls = [];
                const usage = createInferenceUsageSummary();
                const unsubscribe = agent.subscribe((event) => {
                    if (event.type === 'message_end') {
                        const msg = event.message;
                        if (msg?.role === 'assistant') {
                            addInferenceUsage(usage, msg.usage);
                            content = msg.content
                                .filter((b) => b.type === 'text')
                                .map((b) => b.text)
                                .join('');
                            for (const block of msg.content) {
                                if (block.type === 'toolCall') {
                                    toolCalls.push({ tool: block.name, input: block.input, output: null });
                                }
                            }
                        }
                    }
                    else if (event.type === 'agent_end') {
                        unsubscribe();
                        resolve({ content, toolCalls, usage });
                    }
                });
                agent.prompt(userMessage).catch((error) => {
                    unsubscribe();
                    reject(error);
                });
            });
        }, { userId: this._sessionUserId });
    }
    /**
     * Send a message with an explicit session ID and context.
     * Preferred over the legacy setCurrentUser + switchSession + chat pattern.
     */
    async chatInSession(sessionId, userMessage, opts) {
        const ctx = { user: opts?.user ?? null };
        return this.pool.withAgent(sessionId, ctx, async (agent) => {
            return new Promise((resolve, reject) => {
                let content = '';
                const toolCalls = [];
                const usage = createInferenceUsageSummary();
                const unsubscribe = agent.subscribe((event) => {
                    if (event.type === 'message_end') {
                        const msg = event.message;
                        if (msg?.role === 'assistant') {
                            addInferenceUsage(usage, msg.usage);
                            content = msg.content
                                .filter((b) => b.type === 'text')
                                .map((b) => b.text)
                                .join('');
                            for (const block of msg.content) {
                                if (block.type === 'toolCall') {
                                    toolCalls.push({ tool: block.name, input: block.input, output: null });
                                }
                            }
                        }
                    }
                    else if (event.type === 'agent_end') {
                        unsubscribe();
                        resolve({ content, toolCalls, usage });
                    }
                });
                agent.prompt(userMessage).catch((error) => {
                    unsubscribe();
                    reject(error);
                });
            });
        }, { userId: opts?.userId, fallbackHistory: opts?.fallbackHistory });
    }
    /**
     * Get workspace manager (for tools)
     */
    getWorkspaceManager() {
        return this.workspaceManager;
    }
    getModelSelection() {
        const userConfig = loadUserConfig();
        return {
            provider: resolveProvider(userConfig, this.config),
            modelId: resolveModelId(userConfig, this.config),
        };
    }
    /** Can the active chat model accept image input? Drives the composer's
     *  vision-attach affordance (disable + tooltip when false). The Studiograph
     *  Credits gateway is vision-capable (A6); otherwise resolve from the model. */
    supportsVision() {
        if (isStudiographCreditsMode())
            return true;
        const { provider, modelId } = this.getModelSelection();
        return supportsVision(provider, modelId);
    }
    /**
     * Delete the agent-pool's persisted state for a chat session — evicts the warm
     * agent + unlinks its on-disk JSON, which holds base64 copies of any vision
     * images the agent saw. Called when a chat is deleted so those copies don't
     * outlive the conversation (composer-vision cleanup). `rawSessionId` is the
     * client session id; the pool keys by the resolved `user:{id}:{raw}` form.
     */
    deleteChatSessionState(userId, rawSessionId) {
        this.pool.deleteSession(userId, `user:${userId}:${rawSessionId}`);
    }
    /** Delete agent-pool state for ALL of a user's chats (reset / delete-all). */
    deleteAllChatSessionState(userId) {
        this.pool.deleteAllSessions(userId);
    }
    /**
     * One-shot, tool-free model call that names a conversation from its opening
     * exchange (the ChatGPT pattern). Used by POST /api/chat/title after the
     * first assistant reply. Deliberately lightweight: no agent pool, no tools,
     * no system context — just the configured model and a tight instruction.
     *
     * Returns null (caller keeps the truncated first-message title) when there's
     * no usable title, no API key, or we're in Studiograph Credits gateway mode —
     * the gateway routes inference through its own client (cloud-inference-billing),
     * not pi-ai's complete(), so a direct completeSimple() can't reach it. Titles
     * via the gateway are a follow-up if we want them.
     */
    async generateConversationTitle(firstUserMessage, assistantReply) {
        if (isStudiographCreditsMode())
            return null;
        const userConfig = loadUserConfig();
        const provider = resolveProvider(userConfig, this.config);
        const modelId = resolveModelId(userConfig, this.config);
        const apiKey = resolveApiKey(userConfig, this.config);
        if (!apiKey)
            return null;
        const model = getModel(provider, modelId);
        const systemPrompt = 'You name a conversation from its opening exchange. Reply with ONLY the ' +
            'title: 3 to 6 words, Title Case, no surrounding quotes, no trailing ' +
            'punctuation. Describe the topic, not the speaker (e.g. "Cooper Hewitt ' +
            'DEM Sync Notes", not "User Talks About a Meeting").';
        const userText = `First message:\n${firstUserMessage}\n\nAssistant reply:\n${assistantReply}`.slice(0, 2000);
        // No `temperature` — newer models (e.g. claude-opus-4-7) reject it with a
        // 400 "temperature is deprecated for this model". maxTokens is plenty for a
        // few-word title.
        const result = await completeSimple(model, { systemPrompt, messages: [{ role: 'user', content: userText, timestamp: Date.now() }] }, { apiKey, maxTokens: 32 });
        // Surface provider errors (e.g. a rejected option) so the route logs them
        // instead of silently returning null and the client keeping its fallback.
        if (result.stopReason === 'error') {
            throw new Error(result.errorMessage || 'title model call failed');
        }
        const raw = result.content
            .filter((c) => c.type === 'text')
            .map((c) => c.text)
            .join('')
            .trim();
        // Strip wrapping quotes / trailing punctuation the model may add anyway.
        const title = raw.replace(/^["'`]+|["'`]+$/g, '').replace(/[.!?]+$/, '').trim().slice(0, 60);
        if (!title)
            return null;
        const usage = createInferenceUsageSummary();
        addInferenceUsage(usage, result.usage);
        return { title, usage };
    }
    /**
     * Get workspace summary
     */
    getSummary() {
        return this.workspaceManager.getSummary();
    }
    /**
     * Get repository names
     */
    getRepoNames() {
        return this.workspaceManager.getRepoNames();
    }
    /**
     * Set the current authenticated user for per-folder access filtering.
     * Called per-request by the chat endpoint. Null = full access (API key / open mode).
     */
    setCurrentUser(user) {
        this._currentUser = user;
    }
    /**
     * Set a callback for highlight_entities tool events (used by SSE streaming).
     */
    setHighlightCallback(cb) {
        this._highlightCallback = cb;
    }
    /**
     * Emit a highlight event (called by graph-tools highlight_entities).
     */
    emitHighlight(ids) {
        this._highlightCallback?.(ids);
    }
    /**
     * Set a callback for entity mutation events (used by SSE streaming).
     */
    setMutationCallback(cb) {
        this._mutationCallback = cb;
    }
    /**
     * Emit a mutation event (called by graph-tools create/update/delete).
     */
    emitMutation(m) {
        this._mutationCallback?.(m);
    }
    /**
     * Expose the raw pi-agent-core Agent instance for a session (for SSE streaming).
     * Returns the default session agent if no sessionId specified.
     */
    getAgent(sessionId) {
        return this.pool.getAgent(sessionId ?? this.currentSessionId ?? 'default');
    }
    /** Get the agent pool (for advanced callers that need direct pool access). */
    getPool() {
        return this.pool;
    }
    /** Abort the agent for a session. */
    abort(sessionId) {
        this.pool.abort(sessionId ?? this.currentSessionId ?? 'default');
    }
    /**
     * Get the current session ID (null if none active).
     */
    getCurrentSessionId() {
        return this.currentSessionId;
    }
    /**
     * Switch session (backward compat — sets state for next chat() call).
     * With the pool, actual session loading happens lazily in withAgent().
     */
    async switchSession(sessionId, fallbackHistory, userId) {
        this._sessionUserId = userId;
        this.currentSessionId = sessionId;
        // Pre-create the agent in the pool if fallback history is provided
        // (so the pool has the context ready when chat() runs)
        if (fallbackHistory && fallbackHistory.length > 0) {
            const ctx = { user: this._currentUser };
            await this.pool.withAgent(sessionId, ctx, async () => {
                // Agent is created and loaded — nothing else to do
            }, { userId, fallbackHistory });
        }
    }
    /**
     * Save session (backward compat — pool auto-saves after each withAgent call).
     */
    saveSession() {
        // Pool handles session persistence automatically
    }
    /**
     * Clear all saved sessions from disk.
     */
    clearSessions() {
        return this.pool.clearSessions(this._sessionUserId);
    }
    /** Shut down the agent pool (save all sessions, stop idle reaper). */
    async shutdown() {
        await this.pool.shutdown();
    }
}
//# sourceMappingURL=orchestrator.js.map