/**
 * src/autonomy/profile.ts
 *
 * PROFILE.md self-learning. Inspired by OpenHuman's user_profile_facets +
 * PROFILE.md pipeline (docs/AGENT_SELF_LEARNING.md).
 *
 * Six fact classes, each emitted from observable signal:
 *   - style/*        — verbosity, format, language, emoji use, formality (formal/Hijazi/Najdi)
 *   - identity/*     — owner_name, business_name, timezone, role, employer
 *   - tooling/*      — accounting (Zoho/QuickBooks/Foodics), POS, CRM, payment gateway
 *   - veto/*         — banned tones / banned actions
 *   - goal/*         — free-form goal sentences
 *   - channel/preference — primary delivery channel (WhatsApp/Telegram/email/dashboard)
 *
 * Storage: `customer_memories` table (already exists), namespace='profile',
 * key=`<class>/<slug>`, value=`<value>`. This piggybacks on the existing
 * journaled-memory pipeline so we don't add a new table or schema.
 *
 * Materialization: `<dataDir>/vault/<tenantId>/PROFILE.md`. The owner
 * opens this file in Obsidian and edits values directly. The next time
 * we run `loadProfileForPrompt()`, we re-read the file and surface its
 * `<!-- USER:BEGIN -->` block as overrides (file beats DB on conflict).
 *
 * Extraction: a daily cron (`__autonomy:profile-extract__`) walks the last
 * 24h of journaled messages + tool calls and emits LearningCandidate
 * objects to the memories table. No extra LLM calls — pattern-driven.
 */
import { promises as fs } from 'node:fs';
import { join } from 'node:path';
import { createChildLogger } from '../utils/logger.js';
import { supabase } from '../utils/supabase.js';
import { getVaultRoot, ensureVaultForTenant } from '../knowledge/vault.js';
const log = createChildLogger('autonomy:profile');
const RULES = [
    // ── style/verbosity ──
    { class: 'style', key: 'verbosity', pattern: /(?:كن\s+مختصر|خلي\s+ردك?\s+قصير|قصير\s+من\s+فضلك|terse|be\s+brief|short(?:er)?\s+responses?|keep\s+it\s+short)/iu,
        extract: () => 'terse', confidence: 0.85 },
    { class: 'style', key: 'verbosity', pattern: /(?:اشرح\s+أكثر|تفاصيل\s+أكثر|explain\s+more|more\s+detail)/iu,
        extract: () => 'verbose', confidence: 0.75 },
    // ── style/format ──
    { class: 'style', key: 'format', pattern: /(?:قوائم\s+نقطية|bullet(?:s|\s+points)?|use\s+lists)/iu,
        extract: () => 'bullets', confidence: 0.8 },
    { class: 'style', key: 'format', pattern: /(?:فقرات|paragraphs?|prose)/iu,
        extract: () => 'paragraphs', confidence: 0.7 },
    // ── style/language ──
    { class: 'style', key: 'language', pattern: /(?:بالعربي|عربي\s+فقط|in\s+arabic\s+only|arabic\s+please)/iu,
        extract: () => 'arabic', confidence: 0.9 },
    { class: 'style', key: 'language', pattern: /(?:in\s+english\s+(?:only|please)|بالإنجليزية)/iu,
        extract: () => 'english', confidence: 0.9 },
    // ── style/emoji ──
    { class: 'style', key: 'emoji', pattern: /(?:بدون\s+إيموجي|no\s+emoji|skip\s+emoji|less\s+emoji)/iu,
        extract: () => 'skip', confidence: 0.85 },
    // ── style/formality (Hijazi/Najdi/Formal) — heuristic via dialect tokens
    // in the user's own messages, not in commands. Handled separately. ──
    // ── identity/name ──
    { class: 'identity', key: 'owner_name', pattern: /(?:اسمي|أنا)\s+([\p{L}]{2,30})\b/u,
        extract: (m) => m[1].trim(), confidence: 0.8 },
    { class: 'identity', key: 'owner_name', pattern: /(?:my\s+name\s+is|i\s+am)\s+([A-Za-z][A-Za-z؀-ۿ\s'-]{1,40})\b/i,
        extract: (m) => m[1].trim(), confidence: 0.8 },
    // ── identity/business_name ──
    { class: 'identity', key: 'business_name', pattern: /(?:شركتي|متجري|محلي|مطعمي|مكتبي)\s+(?:اسمها\s+|اسمه\s+)?["«]?([\p{L}\s]{3,40})["»]?/u,
        extract: (m) => m[1].trim(), confidence: 0.75 },
    { class: 'identity', key: 'business_name', pattern: /(?:my\s+business|my\s+company|my\s+shop|my\s+store)\s+(?:is\s+(?:called\s+)?)?["«]?([A-Za-z0-9\s'-]{3,40})["»]?/i,
        extract: (m) => m[1].trim(), confidence: 0.75 },
    // ── identity/timezone ──
    { class: 'identity', key: 'timezone', pattern: /(?:my\s+timezone\s+is|i\s+am\s+in)\s+(asia\/[a-z_]+|[a-z]+\/[a-z_]+)/i,
        extract: (m) => m[1], confidence: 0.85 },
    // ── tooling — Saudi B2B stack ──
    { class: 'tooling', key: 'accounting', pattern: /\b(zoho|quickbooks|odoo|wafeq|sage|xero|qoyod)\b/i,
        extract: (m) => m[1].toLowerCase(), confidence: 0.7 },
    { class: 'tooling', key: 'ecommerce', pattern: /\b(salla|zid|shopify|woocommerce|magento)\b/i,
        extract: (m) => m[1].toLowerCase(), confidence: 0.8 },
    { class: 'tooling', key: 'pos', pattern: /\b(foodics|loyverse|posera|marn|esquare)\b/i,
        extract: (m) => m[1].toLowerCase(), confidence: 0.8 },
    { class: 'tooling', key: 'payments', pattern: /\b(mada|hyperpay|tap|stripe|paytabs|moyasar|stcpay|tabby|tamara)\b/i,
        extract: (m) => m[1].toLowerCase(), confidence: 0.75 },
    // ── veto ──
    { class: 'veto', key: 'tone', pattern: /(?:لا\s+تستخدم\s+["«]?([^"»\n]{2,30})["»]?|never\s+use\s+["«]?([^"»\n]{2,30})["»]?)/iu,
        extract: (m) => m[1] || m[2] || '', confidence: 0.8 },
    // ── goal ──
    { class: 'goal', key: 'pinned', pattern: /(?:هدفي|goal:)\s*([^.\n]{6,160})/iu,
        extract: (m) => m[1].trim(), confidence: 0.7 },
    // ── channel/preference ──
    { class: 'channel', key: 'preference', pattern: /(?:ابعتلي|أرسل\s+لي|أرسللي|send\s+(?:me|to\s+me))\s+(?:كل\s+شي\s+)?(?:عبر\s+|بـ?|on\s+|via\s+)?(?:whatsapp|واتساب|telegram|تلجرام|email|إيميل|dashboard|لوحة)/iu,
        extract: (m) => {
            const t = m[0].toLowerCase();
            if (/whatsapp|واتساب/.test(t))
                return 'whatsapp';
            if (/telegram|تلجرام/.test(t))
                return 'telegram';
            if (/email|إيميل/.test(t))
                return 'email';
            if (/dashboard|لوحة/.test(t))
                return 'dashboard';
            return null;
        },
        confidence: 0.8 },
];
/** Try every rule against a message; return all distinct facets that fire. */
export function extractFacetsFromMessage(message) {
    if (!message || message.length < 3)
        return [];
    const out = [];
    const seen = new Set();
    for (const rule of RULES) {
        const m = rule.pattern.exec(message);
        if (!m)
            continue;
        const value = rule.extract(m, { message });
        if (!value)
            continue;
        const dedup = `${rule.class}/${rule.key}|${value.toLowerCase()}`;
        if (seen.has(dedup))
            continue;
        seen.add(dedup);
        out.push({ class: rule.class, key: rule.key, value, confidence: rule.confidence });
    }
    return out;
}
/**
 * Persist a single observed facet. Uses customer_memories with namespace
 * 'profile' so the data layer doesn't need a new table. Repeated
 * observations strengthen confidence (capped at 0.98).
 */
async function upsertFacet(tenantId, facet) {
    const namespace = 'profile';
    const memKey = `${facet.class}/${facet.key}`;
    // Read existing
    const { data: existing } = await supabase
        .from('customer_memories')
        .select('id, value, metadata')
        .eq('tenant_id', tenantId)
        .is('customer_id', null) // tenant-wide
        .eq('namespace', namespace)
        .eq('key', memKey)
        .maybeSingle();
    const now = new Date().toISOString();
    if (existing?.id) {
        const prevMeta = existing.metadata || {};
        const prevValue = String(existing.value ?? '');
        const sameValue = prevValue === facet.value;
        const prevConfidence = typeof prevMeta.confidence === 'number' ? prevMeta.confidence : 0.5;
        const prevEvidence = typeof prevMeta.evidence_count === 'number' ? prevMeta.evidence_count : 1;
        const newConfidence = sameValue
            ? Math.min(0.98, prevConfidence + 0.05)
            : Math.max(0.3, facet.confidence); // conflicting value resets
        await supabase
            .from('customer_memories')
            .update({
            value: facet.value,
            metadata: { ...prevMeta, class: facet.class, key: facet.key, confidence: newConfidence, evidence_count: prevEvidence + 1, last_seen_at: now },
            updated_at: now,
        })
            .eq('id', existing.id);
    }
    else {
        await supabase.from('customer_memories').insert({
            tenant_id: tenantId,
            customer_id: null,
            namespace,
            key: memKey,
            value: facet.value,
            metadata: { class: facet.class, key: facet.key, confidence: facet.confidence, evidence_count: 1, last_seen_at: now },
            created_at: now,
            updated_at: now,
        });
    }
}
/** Public entry: observe a message and persist any facets it implies.
 * Cheap — pattern-driven, no LLM call. */
export async function observeMessageForProfile(tenantId, message) {
    const facets = extractFacetsFromMessage(message);
    for (const f of facets) {
        try {
            await upsertFacet(tenantId, f);
        }
        catch (err) {
            log.debug({ err: err.message }, 'profile facet upsert failed');
        }
    }
    if (facets.length > 0) {
        log.debug({ tenantId, count: facets.length, facets: facets.map(f => `${f.class}/${f.key}=${f.value}`) }, 'profile facets learned');
    }
    return facets.length;
}
/** Load all profile facets for a tenant. Sorted by class then key. */
export async function loadProfile(tenantId) {
    const { data } = await supabase
        .from('customer_memories')
        .select('key, value, metadata')
        .eq('tenant_id', tenantId)
        .is('customer_id', null)
        .eq('namespace', 'profile')
        .limit(200);
    const out = [];
    for (const row of (data ?? [])) {
        const m = row.metadata || {};
        const className = typeof m.class === 'string' ? m.class : row.key.split('/')[0];
        const k = typeof m.key === 'string' ? m.key : row.key.split('/').slice(1).join('/');
        out.push({
            class: className,
            key: k,
            value: row.value,
            confidence: typeof m.confidence === 'number' ? m.confidence : 0.5,
            evidenceCount: typeof m.evidence_count === 'number' ? m.evidence_count : 1,
            lastSeenAt: typeof m.last_seen_at === 'string' ? m.last_seen_at : '',
        });
    }
    out.sort((a, b) => (a.class + a.key).localeCompare(b.class + b.key));
    return out;
}
/** Render profile facets as a compact prompt block. Inject into every
 * system prompt. Returns empty string when there are no high-confidence
 * facts (no value in noisy injection). */
export async function buildProfilePromptBlock(tenantId, opts = {}) {
    const minConf = opts.minConfidence ?? 0.5;
    const maxFacets = opts.maxFacets ?? 25;
    // First check vault PROFILE.md for user overrides
    const userOverrides = await readUserOverridesFromVault(tenantId);
    const dbFacets = (await loadProfile(tenantId)).filter(f => f.confidence >= minConf);
    // Merge — user overrides win on conflict
    const merged = new Map();
    for (const f of dbFacets)
        merged.set(`${f.class}/${f.key}`, f);
    for (const [k, v] of userOverrides) {
        const [cls, key] = k.split('/');
        merged.set(k, { class: cls, key, value: v, confidence: 1, evidenceCount: 99, lastSeenAt: '(user-edit)' });
    }
    if (merged.size === 0)
        return '';
    const facets = [...merged.values()].slice(0, maxFacets);
    const lines = ['## تفضيلات المالك (Owner profile — learned + edited)'];
    for (const f of facets)
        lines.push(`- **${f.class}/${f.key}**: ${f.value}${f.lastSeenAt === '(user-edit)' ? ' *(user-edited)*' : ''}`);
    return lines.join('\n');
}
/** Materialize PROFILE.md in the vault. Preserves the USER:BEGIN/END
 * block so owner edits survive each rebuild. */
export async function materializeProfileMd(tenantId) {
    await ensureVaultForTenant(tenantId);
    const facets = await loadProfile(tenantId);
    const root = getVaultRoot(tenantId);
    const filePath = join(root, 'PROFILE.md');
    const byClass = new Map();
    for (const f of facets) {
        const arr = byClass.get(f.class) ?? [];
        arr.push(f);
        byClass.set(f.class, arr);
    }
    const sections = [];
    for (const cls of ['style', 'identity', 'tooling', 'veto', 'goal', 'channel']) {
        const items = byClass.get(cls) ?? [];
        if (items.length === 0)
            continue;
        sections.push(`## ${cls}`);
        for (const f of items) {
            sections.push(`- **${f.key}**: ${f.value}  *(confidence ${(f.confidence * 100).toFixed(0)}%, ${f.evidenceCount} observations)*`);
        }
        sections.push('');
    }
    const fresh = [
        '---',
        `tenant: ${tenantId}`,
        `updated_at: ${new Date().toISOString()}`,
        'kind: profile',
        '---',
        '',
        '# Owner Profile',
        '',
        'These are preferences Muassis has learned from observing your conversations.',
        'You can edit values in the **User overrides** block below — they\'ll be respected on',
        'every turn and survive re-syncs.',
        '',
        sections.join('\n') || '_(no learned facets yet)_',
        '',
        '## User overrides',
        '',
        'Add lines in this format (one per line) to tune Muassis\'s behavior:',
        '',
        '```',
        'style/verbosity: terse',
        'style/language: arabic',
        'channel/preference: whatsapp',
        '```',
        '',
        '<!-- USER:BEGIN — edits between these markers are preserved -->',
        '',
        '<!-- USER:END -->',
        '',
    ].join('\n');
    // Merge USER block from existing file
    let merged = fresh;
    try {
        const existing = await fs.readFile(filePath, 'utf-8');
        const m = existing.match(/<!-- USER:BEGIN[^>]*-->([\s\S]*?)<!-- USER:END -->/);
        if (m && m[1].trim()) {
            merged = fresh.replace(/<!-- USER:BEGIN[^>]*-->[\s\S]*?<!-- USER:END -->/, `<!-- USER:BEGIN — edits between these markers are preserved -->${m[1]}<!-- USER:END -->`);
        }
    }
    catch { /* no existing file */ }
    try {
        const existing = await fs.readFile(filePath, 'utf-8');
        if (existing === merged)
            return;
    }
    catch { /* write below */ }
    await fs.writeFile(filePath, merged, 'utf-8');
    log.info({ tenantId, facets: facets.length }, 'PROFILE.md materialized');
}
/** Read the USER:BEGIN/END block from vault PROFILE.md and parse
 * `<class>/<key>: <value>` lines. */
async function readUserOverridesFromVault(tenantId) {
    const overrides = new Map();
    try {
        const filePath = join(getVaultRoot(tenantId), 'PROFILE.md');
        const content = await fs.readFile(filePath, 'utf-8');
        const m = content.match(/<!-- USER:BEGIN[^>]*-->([\s\S]*?)<!-- USER:END -->/);
        if (!m)
            return overrides;
        for (const line of m[1].split('\n')) {
            const t = line.trim();
            if (!t || t.startsWith('#') || t.startsWith('<!--'))
                continue;
            const kv = t.match(/^([a-z]+\/[a-z_]+)\s*[:=]\s*(.+)$/i);
            if (kv)
                overrides.set(kv[1].toLowerCase(), kv[2].trim());
        }
    }
    catch { /* file missing */ }
    return overrides;
}
/** Daily cron entry — runs in housekeeping window (02:30 KSA). Walks the
 * last 24h of inbound user messages and re-extracts facets. Cheap because
 * extraction is pattern-only. Materializes PROFILE.md when done. */
export async function runProfileExtraction(tenantId) {
    const since = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
    const { data: msgs } = await supabase
        .from('messages')
        .select('content, direction, created_at')
        .eq('tenant_id', tenantId)
        .eq('direction', 'inbound')
        .gte('created_at', since)
        .order('created_at', { ascending: true })
        .limit(500);
    let scanned = 0;
    let learned = 0;
    for (const m of (msgs ?? [])) {
        scanned++;
        const c = await observeMessageForProfile(tenantId, m.content ?? '');
        learned += c;
    }
    await materializeProfileMd(tenantId);
    log.info({ tenantId, messagesScanned: scanned, facetsLearned: learned }, 'profile extraction complete');
    return { messagesScanned: scanned, facetsLearned: learned };
}
//# sourceMappingURL=profile.js.map