/**
 * src/autonomy/skill-discovery.ts
 *
 * Autonomous GitHub skill discovery. Runs daily (4am KSA so it doesn't
 * compete with morning-brief at 8am). Searches GitHub for repos that could
 * become Muassis skills, vets safety, ranks by relevance to the tenant's
 * business + identity facts, and stores top candidates in the knowledge
 * graph as `kind=skill` entities.
 *
 * Why: Muassis should grow on its own. The 46 default skills it ships with
 * are a starting point — over months, the agent should curate a personalized
 * skill library shaped by the business's actual domain. A salla-running
 * Saudi e-commerce shop should accumulate Salla-specific skills; a real-estate
 * shop should accumulate Wathiq + Ejar + RGA skills.
 *
 * The agent doesn't blindly install — it stores candidates as KG entities
 * with `install_status=discovered`. The morning brief surfaces the top 1-2
 * to the owner: "found 3 candidate skills overnight, recommend installing
 * X because [reason]." Owner approves → install → status flips to `installed`.
 */
import { randomUUID } from 'node:crypto';
import { createChildLogger } from '../utils/logger.js';
import { supabase } from '../utils/supabase.js';
import { record as journalRecord } from './journal.js';
import { listFacts } from './identity.js';
const log = createChildLogger('autonomy:skill-discovery');
/**
 * Build search queries shaped by what we know about the tenant's business.
 * Each query is a focused noun-phrase that GitHub's search rewards. We
 * generate 3-5 per cycle and rotate them.
 */
async function buildQueries(tenantId) {
    const facts = await listFacts(tenantId);
    const factMap = new Map(facts.map(f => [f.fact_key, f.fact_value]));
    const story = factMap.get('business.story') ?? '';
    const products = facts.filter(f => f.fact_key.startsWith('product.'));
    const competitors = facts.filter(f => f.fact_key.startsWith('competitor.'));
    const baseQueries = [
        'claude code skills agent',
        'claude code skills saudi',
        'ai agent skills business automation',
        'whatsapp baileys business automation',
        'small business agent tools',
    ];
    // Domain queries from identity
    const domainQueries = [];
    if (story.includes('عقار') || /real[- ]?estate/i.test(story)) {
        domainQueries.push('real estate api saudi', 'wathiq ejar saudi', 'property listing automation');
    }
    if (story.includes('متجر') || /e[- ]?commerce|shopify|salla|zid/i.test(story)) {
        domainQueries.push('salla api skill', 'zid api skill', 'shopify mcp server');
    }
    if (story.includes('مطعم') || /restaurant|food/i.test(story)) {
        domainQueries.push('foodics api skill', 'pos integration restaurant');
    }
    if (story.includes('عيادة') || /clinic|health|medical/i.test(story)) {
        domainQueries.push('saudi health api', 'tabeebak api');
    }
    // Competitor-driven
    for (const c of competitors.slice(0, 2)) {
        try {
            const parsed = JSON.parse(c.fact_value);
            if (parsed.name)
                domainQueries.push(`${parsed.name} api integration`);
        }
        catch { /* fact_value might be plain text */ }
    }
    // Product-driven
    for (const p of products.slice(0, 2)) {
        domainQueries.push(`${p.fact_value} api integration`);
    }
    // Rotate which 3 base queries fire each day so we don't keep re-fetching the same
    const dayOfYear = Math.floor((Date.now() - new Date(new Date().getFullYear(), 0, 0).getTime()) / 86_400_000);
    const rotated = [...baseQueries].slice(dayOfYear % baseQueries.length).concat(baseQueries.slice(0, dayOfYear % baseQueries.length));
    return [...rotated.slice(0, 3), ...domainQueries.slice(0, 2)];
}
async function searchGitHub(query) {
    // GitHub Search API — no auth needed for low-rate read access (10 req/min anon).
    // We respect rate limits by limiting to 3 queries/day and 5 results each.
    const url = `https://api.github.com/search/repositories?q=${encodeURIComponent(query)}&sort=stars&order=desc&per_page=10`;
    try {
        const r = await fetch(url, {
            headers: {
                'Accept': 'application/vnd.github+json',
                'User-Agent': 'Muassis-Skill-Discovery/1.1',
            },
            signal: AbortSignal.timeout(15_000),
        });
        if (!r.ok) {
            log.warn({ status: r.status, query }, 'github search failed');
            return [];
        }
        const j = await r.json();
        return j.items ?? [];
    }
    catch (err) {
        log.warn({ err: err.message, query }, 'github search threw');
        return [];
    }
}
/** Safety vetting — drop archived, very-low-star, and known-malicious patterns. */
function passesSafetyCheck(item) {
    if (item.archived)
        return { ok: false, reason: 'archived' };
    if (item.stargazers_count < 30)
        return { ok: false, reason: 'low-stars' };
    // Updated within 18 months
    if (Date.now() - new Date(item.updated_at).getTime() > 18 * 30 * 86_400_000) {
        return { ok: false, reason: 'stale' };
    }
    // Description sniff for obvious red flags
    const desc = (item.description ?? '').toLowerCase();
    if (/\b(steal|harvest|exfiltrate|password.{0,10}stealer|crypto.?miner)\b/.test(desc)) {
        return { ok: false, reason: 'description-suspicious' };
    }
    return { ok: true };
}
function scoreRelevance(item, query, allFacts) {
    let score = 0;
    const reasons = [];
    const desc = (item.description ?? '').toLowerCase();
    const topics = item.topics ?? [];
    const queryWords = query.toLowerCase().split(/\s+/).filter(w => w.length > 3);
    for (const w of queryWords) {
        if (desc.includes(w)) {
            score += 2;
            reasons.push(`mentions "${w}"`);
        }
        if (topics.includes(w)) {
            score += 3;
            reasons.push(`topic "${w}"`);
        }
        if (item.name.toLowerCase().includes(w)) {
            score += 4;
            reasons.push(`name "${w}"`);
        }
    }
    // Saudi/Arabic bonus
    if (/saudi|arabic|ksa|riyadh|jeddah|عربي|سعودي/.test(desc + topics.join(' '))) {
        score += 5;
        reasons.push('saudi-relevant');
    }
    // Star bonus (log scale)
    score += Math.min(10, Math.log10(item.stargazers_count + 1) * 2);
    // Identity-fact match — if the description mentions any of the tenant's known
    // products/competitors, score higher
    for (const fact of allFacts) {
        if (fact.length > 4 && desc.includes(fact.toLowerCase())) {
            score += 3;
            reasons.push(`matches business fact "${fact.slice(0, 30)}"`);
        }
    }
    return { score, reasons };
}
/**
 * Persist a candidate as a knowledge-graph entity. The KG is the agent's
 * memory of "what skills exist in the world that I could use." When the
 * brain searches knowledge, these surface alongside actual customer/business
 * facts — so suggesting a skill becomes a natural recall, not a separate
 * lookup path.
 */
async function persistCandidate(tenantId, candidate) {
    // Store as kg_entities with kind=skill. We pre-compute name_normalized for
    // FTS matching in graphiti.searchKnowledge.
    const name = candidate.full_name; // owner/repo as canonical key
    const id = randomUUID();
    const now = new Date().toISOString();
    // Check if already discovered (idempotent)
    const { data: existing } = await supabase
        .from('kg_entities')
        .select('id, properties, episode_count')
        .eq('tenant_id', tenantId)
        .eq('kind', 'skill')
        .eq('name_normalized', name.toLowerCase())
        .maybeSingle();
    if (existing) {
        // Touch updated_at and bump episode_count to reflect re-discovery
        const props = existing.properties ?? {};
        await supabase
            .from('kg_entities')
            .update({
            properties: { ...props, last_seen: now, relevance_score: candidate.relevance_score },
            episode_count: (existing.episode_count ?? 0) + 1,
            updated_at: now,
        })
            .eq('id', existing.id);
        return;
    }
    await supabase.from('kg_entities').insert({
        id,
        tenant_id: tenantId,
        customer_id: null,
        kind: 'skill',
        type: 'Skill',
        name,
        name_normalized: name.toLowerCase(),
        properties: {
            description: candidate.description,
            url: candidate.url,
            stars: candidate.stars,
            language: candidate.language,
            topics: candidate.topics,
            relevance_score: candidate.relevance_score,
            reasons: candidate.reasons,
            install_status: 'discovered',
            discovered_at: now,
            last_seen: now,
        },
        source_files: [],
        episode_count: 1,
        created_at: now,
        updated_at: now,
    });
}
export async function runSkillDiscovery(tenantId) {
    log.info({ tenantId }, 'starting skill discovery cycle');
    const queries = await buildQueries(tenantId);
    const facts = (await listFacts(tenantId)).map(f => f.fact_value).filter(v => v.length > 4);
    const allCandidates = [];
    for (const q of queries) {
        const items = await searchGitHub(q);
        for (const it of items) {
            const safety = passesSafetyCheck(it);
            if (!safety.ok)
                continue;
            const { score, reasons } = scoreRelevance(it, q, facts);
            if (score < 5)
                continue; // not relevant enough
            allCandidates.push({
                name: it.name,
                full_name: it.full_name,
                description: it.description ?? '',
                url: it.html_url,
                stars: it.stargazers_count,
                language: it.language,
                topics: it.topics ?? [],
                updated_at: it.updated_at,
                relevance_score: score,
                reasons,
            });
        }
    }
    // Dedup by full_name and keep highest score
    const dedup = new Map();
    for (const c of allCandidates) {
        const existing = dedup.get(c.full_name);
        if (!existing || c.relevance_score > existing.relevance_score) {
            dedup.set(c.full_name, c);
        }
    }
    const ranked = [...dedup.values()].sort((a, b) => b.relevance_score - a.relevance_score).slice(0, 8);
    let newlyStored = 0;
    for (const c of ranked) {
        try {
            await persistCandidate(tenantId, c);
            newlyStored++;
        }
        catch (err) {
            log.warn({ err: err.message, name: c.full_name }, 'persist candidate failed');
        }
    }
    // (2026-05-31) AUTONOMOUS INSTALL — owner wanted the agent to actually get
    // smarter on its own, not just bookmark repos for manual approval. Auto-fetch
    // + install the top 2 ranked candidates that aren't already installed. The
    // installer (installSkillFromUrl) enforces the SAME safety: trusted hosts
    // only, content vetting, size cap, frontmatter — so this is approval-free but
    // not unsafe. Installed skills land in <dataDir>/skills/*.md and become
    // match-eligible for the brain immediately.
    const installed = [];
    try {
        const { installSkillFromUrl } = await import('./skill-installer.js');
        for (const c of ranked.slice(0, 2)) {
            // Skip if we already installed this one in a prior cycle (idempotent).
            const { data: prior } = await supabase
                .from('kg_entities')
                .select('properties')
                .eq('tenant_id', tenantId)
                .eq('kind', 'skill')
                .eq('name_normalized', c.full_name.toLowerCase())
                .maybeSingle();
            if (prior?.properties?.install_status === 'installed') {
                continue;
            }
            try {
                const res = await installSkillFromUrl(tenantId, c.url, { name: c.name, description: c.description });
                if (res?.ok) {
                    installed.push(c.full_name);
                    log.info({ tenantId, skill: c.full_name }, '🧠 auto-installed skill');
                    // Flip THIS candidate's row (keyed by owner/repo) to installed so
                    // the guard above skips it next cycle — the installer names its own
                    // row from the markdown title, which differs from full_name.
                    try {
                        const priorProps = prior?.properties ?? {};
                        await supabase
                            .from('kg_entities')
                            .update({ properties: { ...priorProps, install_status: 'installed', installed_at: new Date().toISOString(), installed_path: res.installedPath }, updated_at: new Date().toISOString() })
                            .eq('tenant_id', tenantId)
                            .eq('kind', 'skill')
                            .eq('name_normalized', c.full_name.toLowerCase());
                    }
                    catch { /* non-fatal */ }
                }
                else {
                    log.info({ tenantId, skill: c.full_name, reason: res?.message }, 'auto-install skipped (vet/fetch)');
                }
            }
            catch (err) {
                log.warn({ tenantId, skill: c.full_name, err: err.message }, 'auto-install threw');
            }
        }
    }
    catch (err) {
        log.warn({ tenantId, err: err.message }, 'skill auto-install unavailable');
    }
    // Journal the discovery cycle so the morning brief can pick it up
    await journalRecord({
        tenantId,
        kind: 'skill_discovery',
        action: installed.length > 0
            ? `installed ${installed.length} new skill(s); discovered ${ranked.length} candidates`
            : `discovered ${ranked.length} skill candidates`,
        target: installed[0] ?? ranked[0]?.full_name ?? null,
        outcome: ranked.length > 0 ? 'success' : 'failure',
        reason: installed.length > 0
            ? `🧠 Installed: ${installed.join(', ')}`
            : ranked.length > 0
                ? `Top: ${ranked[0].full_name} (score ${ranked[0].relevance_score.toFixed(1)}) — ${ranked[0].reasons.slice(0, 2).join(', ')}`
                : 'No relevant skills found this cycle',
        metadata: {
            queries,
            installed,
            top_candidates: ranked.slice(0, 3).map(c => ({
                name: c.full_name,
                score: c.relevance_score,
                url: c.url,
            })),
        },
    });
    return {
        queries,
        candidates: ranked.length,
        newlyStored,
        installed,
        topCandidate: ranked[0] ?? null,
    };
}
//# sourceMappingURL=skill-discovery.js.map