/**
 * Model Discovery Service
 *
 * Discovers ALL models installed on the machine from all vendors/formats:
 *   - Ollama  (via `ollama list`)
 *   - Local GGUF  (~/.avis/models/gguf/)
 *   - HuggingFace  (~/.cache/huggingface/hub/ or ~/Library/Caches/huggingface/)
 *   - Cloud APIs  (Anthropic, OpenAI, Google, etc.) – detected via auth.json / env vars
 *
 * Organizes discovered models in ~/.models/ with per-provider manifests and a
 * unified index.json, then syncs them into ~/.avis/agent/models.json so they
 * appear automatically in the model picker.
 */
import { spawnSync } from "node:child_process";
import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
import { homedir } from "node:os";
import { basename, join } from "node:path";
// ---------------------------------------------------------------------------
// Paths
// ---------------------------------------------------------------------------
const HOME = homedir();
const MODELS_DIR = join(HOME, ".models");
const AVIS_MODELS_DIR = join(HOME, ".avis", "models");
const AGENT_DIR = join(HOME, ".avis", "agent");
const AGENT_MODELS_JSON = join(AGENT_DIR, "models.json");
// Known HuggingFace cache locations across platforms
const HF_CACHE_DIRS = [
    join(HOME, ".cache", "huggingface", "hub"),
    join(HOME, "Library", "Caches", "huggingface", "hub"),
    join(HOME, ".huggingface", "hub"),
];
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function safeReadJson(path, fallback) {
    if (!existsSync(path))
        return fallback;
    try {
        return JSON.parse(readFileSync(path, "utf-8"));
    }
    catch {
        return fallback;
    }
}
function writeJson(path, data) {
    const dir = path.substring(0, path.lastIndexOf("/"));
    mkdirSync(dir, { recursive: true });
    writeFileSync(path, JSON.stringify(data, null, 2), "utf-8");
}
function fileSizeBytes(path) {
    try {
        return statSync(path).size;
    }
    catch {
        return 0;
    }
}
function isSymlink(path) {
    try {
        return lstatSync(path).isSymbolicLink();
    }
    catch {
        return false;
    }
}
// ---------------------------------------------------------------------------
// ~/.models directory scaffold
// ---------------------------------------------------------------------------
/** Remove old symlinks / legacy dirs and create the proper structure. */
export function scaffoldModelsDir() {
    mkdirSync(MODELS_DIR, { recursive: true });
    const entries = existsSync(MODELS_DIR) ? readdirSync(MODELS_DIR) : [];
    for (const entry of entries) {
        const fullPath = join(MODELS_DIR, entry);
        if (isSymlink(fullPath)) {
            // Remove legacy symlinks (avis-gguf, avis-hf, avis-ollama, …)
            unlinkSync(fullPath);
        }
    }
    // Create canonical provider subdirectories
    for (const dir of ["ollama", "gguf", "hf", "cloud", "openai", "anthropic", "google", "huggingface"]) {
        mkdirSync(join(MODELS_DIR, dir), { recursive: true });
    }
}
function discoverOllamaModels() {
    try {
        const result = spawnSync("ollama", ["list", "--json"], { encoding: "utf-8", timeout: 5000 });
        if (result.status !== 0 || !result.stdout) {
            // Fallback: try plain text parsing
            return discoverOllamaModelsText();
        }
        // Parse JSON output (newer Ollama versions)
        let json;
        try {
            json = JSON.parse(result.stdout);
        }
        catch {
            return discoverOllamaModelsText();
        }
        return json.map((m) => ({
            id: m.name,
            name: formatOllamaName(m.name),
            provider: "ollama",
            format: "ollama",
            sizeBytes: m.size,
            contextWindow: 32768,
            maxTokens: 8192,
            supportsVision: m.name.toLowerCase().includes("vision") || m.name.toLowerCase().includes("llava"),
            supportsReasoning: m.name.toLowerCase().includes("think") || m.name.toLowerCase().includes("reason"),
            source: m.name,
            quant: m.quantization_level || (m.name.includes(":") ? m.name.split(":")[1] : undefined),
            status: "available",
        }));
    }
    catch {
        return discoverOllamaModelsText();
    }
}
function discoverOllamaModelsText() {
    try {
        const result = spawnSync("ollama", ["list"], { encoding: "utf-8", timeout: 5000 });
        if (result.status !== 0 || !result.stdout)
            return [];
        const lines = result.stdout.trim().split("\n").slice(1); // skip header
        return lines
            .filter((l) => l.trim())
            .map((line) => {
            const parts = line.trim().split(/\s+/);
            if (!parts[0])
                return null;
            const name = parts[0];
            const sizeStr = parts[2]; // e.g. "9.0 GB"
            const sizeBytes = parseSizeStr(sizeStr, parts[3]);
            return {
                id: name,
                name: formatOllamaName(name),
                provider: "ollama",
                format: "ollama",
                sizeBytes,
                contextWindow: 32768,
                maxTokens: 8192,
                supportsVision: name.toLowerCase().includes("vision") || name.toLowerCase().includes("llava"),
                supportsReasoning: name.toLowerCase().includes("think") ||
                    name.toLowerCase().includes("reason") ||
                    name.toLowerCase().includes("qwen3"),
                source: name,
                quant: name.includes(":") ? name.split(":")[1] : "latest",
                status: "available",
            };
        })
            .filter(Boolean);
    }
    catch {
        return [];
    }
}
function parseSizeStr(value, unit) {
    if (!value)
        return undefined;
    const num = parseFloat(value);
    if (Number.isNaN(num))
        return undefined;
    const u = (unit || "").toUpperCase();
    if (u === "GB")
        return Math.round(num * 1024 ** 3);
    if (u === "MB")
        return Math.round(num * 1024 ** 2);
    return undefined;
}
function formatOllamaName(id) {
    // "hf.co/unsloth/Qwen3-Coder-30B-A3B-Instruct-GGUF:UD-Q4_K_XL" → "Qwen3-Coder 30B A3B"
    // "dotkaio/avis:latest" → "Avis (local)"
    // "avis:latest" → "Avis"
    const base = id.includes("/") ? id.split("/").pop() : id;
    const [modelPart, tag] = base.split(":");
    if (!modelPart)
        return id;
    const cleaned = modelPart
        .replace(/-GGUF$/i, "")
        .replace(/-Instruct$/i, " Instruct")
        .replace(/-it$/i, " Instruct")
        .replace(/-/g, " ")
        .trim();
    const tagSuffix = tag && tag !== "latest" ? ` (${tag})` : "";
    return cleaned + tagSuffix;
}
// ---------------------------------------------------------------------------
// Local GGUF discovery
// ---------------------------------------------------------------------------
function discoverGgufModels() {
    const ggufDir = join(AVIS_MODELS_DIR, "gguf");
    if (!existsSync(ggufDir))
        return [];
    // Read existing manifest for metadata
    const manifestPath = join(AVIS_MODELS_DIR, "models.json");
    const manifest = safeReadJson(manifestPath, []);
    const ggufFiles = readdirSync(ggufDir)
        .filter((f) => f.endsWith(".gguf") && !f.includes("mmproj") && !f.includes("projector"))
        .map((f) => join(ggufDir, f));
    return ggufFiles.map((filePath) => {
        const fileName = basename(filePath, ".gguf");
        // Try to find metadata from manifest
        const meta = manifest.find((m) => m.modelPath === filePath || m.id === fileName.split("-Q")[0].split("-q")[0]);
        const sizeBytes = fileSizeBytes(filePath);
        // Infer quant from filename
        const quantMatch = fileName.match(/[_-](Q\d+[_A-Z]*|q\d+[_a-z]*|F16|BF16|F32|IQ\d+[A-Z_]*)/i);
        const quant = meta?.quant || (quantMatch ? quantMatch[1].toUpperCase() : undefined);
        // Infer a readable name
        const rawName = fileName
            .replace(/[-_](Q\d+[A-Z_]*|F16|BF16|F32|IQ\d+[A-Z_]*)$/i, "")
            .replace(/[-_]it$/i, "")
            .replace(/[-_]Instruct$/i, "")
            .replace(/-/g, " ")
            .replace(/_/g, " ")
            .trim();
        const mmProjExists = existsSync(join(ggufDir, `${fileName.replace(/[-_](Q\d+[A-Z_]*|F16|BF16|F32)/i, "")}-mmproj-BF16.gguf`));
        return {
            id: meta?.id ||
                fileName
                    .split(/[-_](Q\d|F16|BF16|IQ)/i)[0]
                    .toLowerCase()
                    .replace(/\s+/g, "-"),
            name: meta?.name || rawName || fileName,
            provider: "local",
            format: "gguf",
            sizeBytes,
            contextWindow: meta?.contextSize || 128000,
            maxTokens: 32768,
            supportsVision: meta?.supportsVision ?? (mmProjExists || fileName.toLowerCase().includes("vision")),
            supportsReasoning: fileName.toLowerCase().includes("qwen3") || fileName.toLowerCase().includes("think"),
            filePath,
            quant,
            status: "available",
        };
    });
}
// ---------------------------------------------------------------------------
// HuggingFace cache discovery
// ---------------------------------------------------------------------------
function discoverHfModels() {
    const models = [];
    for (const cacheDir of HF_CACHE_DIRS) {
        if (!existsSync(cacheDir))
            continue;
        let entries;
        try {
            entries = readdirSync(cacheDir);
        }
        catch {
            continue;
        }
        // HF hub cache: dirs named "models--<org>--<name>"
        for (const entry of entries) {
            if (!entry.startsWith("models--"))
                continue;
            const repoId = entry.replace(/^models--/, "").replace(/--/g, "/");
            const repoDir = join(cacheDir, entry);
            const snapshotsDir = join(repoDir, "snapshots");
            if (!existsSync(snapshotsDir))
                continue;
            // Find GGUF files inside
            let ggufFound = false;
            try {
                const snapshots = readdirSync(snapshotsDir);
                for (const snap of snapshots) {
                    const snapDir = join(snapshotsDir, snap);
                    try {
                        const files = readdirSync(snapDir);
                        const ggufFiles = files.filter((f) => f.endsWith(".gguf") && !f.includes("mmproj"));
                        if (ggufFiles.length > 0) {
                            ggufFound = true;
                            for (const f of ggufFiles) {
                                const quantMatch = f.match(/[_-](Q\d+[_A-Z]*|F16|BF16|F32|IQ\d+[A-Z_]*)/i);
                                const quant = quantMatch ? quantMatch[1].toUpperCase() : undefined;
                                models.push({
                                    id: `${repoId.replace(/\//g, "-").toLowerCase()}-${quant || "latest"}`,
                                    name: formatHfName(repoId, quant),
                                    provider: "huggingface",
                                    format: "hf",
                                    sizeBytes: fileSizeBytes(join(snapDir, f)),
                                    contextWindow: 32768,
                                    maxTokens: 8192,
                                    supportsVision: f.toLowerCase().includes("vision"),
                                    supportsReasoning: repoId.toLowerCase().includes("qwen3") || repoId.toLowerCase().includes("think"),
                                    source: repoId,
                                    quant,
                                    filePath: join(snapDir, f),
                                    status: "available",
                                });
                            }
                        }
                    }
                    catch {
                        // skip
                    }
                }
            }
            catch {
                // skip
            }
            if (!ggufFound) {
                // Register HF model as a cached (non-GGUF) model
                models.push({
                    id: repoId.replace(/\//g, "-").toLowerCase(),
                    name: formatHfName(repoId),
                    provider: "huggingface",
                    format: "hf",
                    contextWindow: 32768,
                    maxTokens: 8192,
                    supportsVision: repoId.toLowerCase().includes("vision"),
                    supportsReasoning: repoId.toLowerCase().includes("qwen3"),
                    source: repoId,
                    status: "not-running",
                });
            }
        }
    }
    return models;
}
function formatHfName(repoId, quant) {
    const parts = repoId.split("/");
    const modelName = (parts[1] || parts[0])
        .replace(/-GGUF$/i, "")
        .replace(/-it$/i, "")
        .replace(/-Instruct$/i, " Instruct")
        .replace(/-/g, " ")
        .trim();
    return quant ? `${modelName} (${quant})` : modelName;
}
const CLOUD_PROVIDERS = [
    { id: "anthropic", name: "Anthropic (Claude)", envKey: "ANTHROPIC_API_KEY", authJsonKey: "anthropic" },
    { id: "openai", name: "OpenAI (GPT)", envKey: "OPENAI_API_KEY", authJsonKey: "openai" },
    { id: "google", name: "Google (Gemini)", envKey: "GOOGLE_GENERATIVE_AI_API_KEY", authJsonKey: "google" },
    { id: "xai", name: "xAI (Grok)", envKey: "XAI_API_KEY", authJsonKey: "xai" },
    { id: "groq", name: "Groq", envKey: "GROQ_API_KEY", authJsonKey: "groq" },
    { id: "mistral", name: "Mistral", envKey: "MISTRAL_API_KEY", authJsonKey: "mistral" },
    { id: "github-copilot", name: "GitHub Copilot", authJsonKey: "github-copilot" },
    { id: "openrouter", name: "OpenRouter", envKey: "OPENROUTER_API_KEY", authJsonKey: "openrouter" },
    {
        id: "huggingface",
        name: "HuggingFace (Inference API)",
        envKey: "HUGGINGFACE_API_KEY",
        authJsonKey: "huggingface",
    },
];
function readAuthJson() {
    const authPath = join(AGENT_DIR, "auth.json");
    return safeReadJson(authPath, {});
}
function discoverCloudModels() {
    const auth = readAuthJson();
    const models = [];
    for (const provider of CLOUD_PROVIDERS) {
        const hasEnvKey = provider.envKey ? !!process.env[provider.envKey] : false;
        const authEntry = provider.authJsonKey ? auth[provider.authJsonKey] : undefined;
        const hasApiKey = hasEnvKey || (authEntry?.type === "api_key" && !!authEntry.key);
        const hasOAuth = authEntry?.type === "oauth";
        const status = hasApiKey || hasOAuth ? "available" : "no-auth";
        models.push({
            id: provider.id,
            name: provider.name,
            provider: provider.id,
            format: "cloud",
            contextWindow: 200000,
            maxTokens: 32768,
            supportsVision: ["anthropic", "openai", "google"].includes(provider.id),
            supportsReasoning: ["anthropic", "openai", "google", "xai"].includes(provider.id),
            isApiKey: !!provider.envKey,
            isOAuth: provider.id === "github-copilot",
            status,
        });
    }
    return models;
}
// ---------------------------------------------------------------------------
// models.json sync
// ---------------------------------------------------------------------------
/**
 * Build a models.json-compatible entry for an Ollama model so it appears
 * in the Avis model registry without manual configuration.
 */
function buildOllamaModelsJsonEntry(models) {
    return {
        baseUrl: "http://localhost:11434/v1",
        apiKey: "ollama",
        api: "openai-completions",
        models: models.map((m) => ({
            id: m.id,
            name: m.name,
            reasoning: m.supportsReasoning,
            input: m.supportsVision ? ["text", "image"] : ["text"],
            cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
            contextWindow: m.contextWindow,
            maxTokens: m.maxTokens,
            compat: {
                supportsDeveloperRole: false,
                supportsStore: false,
                supportsReasoningEffort: false,
                maxTokensField: "max_tokens",
                requiresThinkingAsText: m.supportsReasoning,
            },
        })),
    };
}
/**
 * Build a models.json-compatible entry for local GGUF models.
 */
function buildLocalModelsJsonEntry(models) {
    return {
        baseUrl: "http://localhost:8787/v1",
        apiKey: "local",
        api: "openai-completions",
        models: models.map((m) => ({
            id: m.id,
            name: m.name,
            reasoning: m.supportsReasoning,
            input: m.supportsVision ? ["text", "image"] : ["text"],
            cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
            contextWindow: m.contextWindow,
            maxTokens: m.maxTokens,
            compat: {
                supportsDeveloperRole: false,
                supportsStore: false,
                supportsReasoningEffort: false,
                maxTokensField: "max_tokens",
                requiresThinkingAsText: false,
            },
        })),
    };
}
/**
 * Merge discovered models into ~/.avis/agent/models.json.
 * Existing entries for non-auto-discovered providers are preserved.
 */
function syncToModelsJson(ollamaModels, ggufModels) {
    mkdirSync(AGENT_DIR, { recursive: true });
    const existing = safeReadJson(AGENT_MODELS_JSON, {
        providers: {},
    });
    const providers = { ...existing.providers };
    // Upsert ollama provider
    if (ollamaModels.length > 0) {
        providers.ollama = buildOllamaModelsJsonEntry(ollamaModels);
    }
    // Upsert local (GGUF) provider
    if (ggufModels.length > 0) {
        providers.local = buildLocalModelsJsonEntry(ggufModels);
    }
    writeJson(AGENT_MODELS_JSON, { providers });
}
/**
 * Run full model discovery across all providers/formats.
 * Writes manifests to ~/.models/ and syncs ~/.avis/agent/models.json.
 */
export async function discoverAllModels() {
    // 1. Ensure ~/.models/ has the right structure
    scaffoldModelsDir();
    // 2. Discover models from all sources (in parallel where possible)
    const [ollamaModels, ggufModels, hfModels] = await Promise.all([
        Promise.resolve(discoverOllamaModels()),
        Promise.resolve(discoverGgufModels()),
        Promise.resolve(discoverHfModels()),
    ]);
    const cloudModels = discoverCloudModels();
    // 3. Write per-provider manifests to ~/.models/
    const now = Date.now();
    const writeManifest = (dir, provider, models) => {
        const manifest = { provider, discoveredAt: now, models };
        writeJson(join(MODELS_DIR, dir, "manifest.json"), manifest);
    };
    writeManifest("ollama", "ollama", ollamaModels);
    writeManifest("gguf", "gguf", ggufModels);
    writeManifest("hf", "huggingface", hfModels);
    writeManifest("cloud", "cloud", cloudModels);
    // Write per-cloud-provider manifests
    for (const cloudProvider of ["anthropic", "openai", "google", "huggingface"]) {
        const subset = cloudModels.filter((m) => m.provider === cloudProvider);
        if (subset.length > 0) {
            writeManifest(cloudProvider, cloudProvider, subset);
        }
    }
    // 4. Write unified index
    const index = {
        updatedAt: now,
        providers: {
            ollama: { provider: "ollama", discoveredAt: now, models: ollamaModels },
            gguf: { provider: "gguf", discoveredAt: now, models: ggufModels },
            huggingface: { provider: "huggingface", discoveredAt: now, models: hfModels },
            cloud: { provider: "cloud", discoveredAt: now, models: cloudModels },
        },
    };
    writeJson(join(MODELS_DIR, "index.json"), index);
    // 5. Sync to ~/.avis/agent/models.json
    syncToModelsJson(ollamaModels, ggufModels);
    const all = [...ollamaModels, ...ggufModels, ...hfModels, ...cloudModels];
    return { ollama: ollamaModels, gguf: ggufModels, hf: hfModels, cloud: cloudModels, all, index };
}
/**
 * Read the last discovery result from ~/.models/index.json without running discovery.
 */
export function readCachedDiscovery() {
    const indexPath = join(MODELS_DIR, "index.json");
    if (!existsSync(indexPath))
        return null;
    try {
        const index = JSON.parse(readFileSync(indexPath, "utf-8"));
        const ollama = index.providers.ollama?.models ?? [];
        const gguf = index.providers.gguf?.models ?? [];
        const hf = index.providers.huggingface?.models ?? [];
        const cloud = index.providers.cloud?.models ?? [];
        return { ollama, gguf, hf, cloud, all: [...ollama, ...gguf, ...hf, ...cloud], index };
    }
    catch {
        return null;
    }
}
/**
 * Get the count of all available (status === "available") models.
 */
export function countAvailableModels(result) {
    return result.all.filter((m) => m.status === "available").length;
}
/**
 * Get a quick summary string for display in the UI.
 */
export function getDiscoverySummary(result) {
    const parts = [];
    if (result.ollama.length)
        parts.push(`${result.ollama.length} Ollama`);
    if (result.gguf.length)
        parts.push(`${result.gguf.length} GGUF`);
    if (result.hf.length)
        parts.push(`${result.hf.length} HuggingFace`);
    const cloudAvailable = result.cloud.filter((m) => m.status === "available").length;
    if (cloudAvailable)
        parts.push(`${cloudAvailable} cloud API`);
    return parts.join(", ") || "no models found";
}
//# sourceMappingURL=model-discovery.js.map