/**
 * src/knowledge/vault.ts
 *
 * Obsidian-compatible vault mirror. Inspired by OpenHuman's Memory Tree
 * + Obsidian Wiki workflow: every KG entity + every episode of the day
 * lands as a `.md` file in `<dataDir>/vault/<tenantId>/`, with YAML
 * frontmatter and wiki-style `[[links]]`. The owner opens the folder in
 * Obsidian (free, cross-platform) and BROWSES + EDITS the AI employee's
 * entire memory in plain Markdown.
 *
 * Why this matters for B2B sales:
 *   - "Show me what your AI thinks about my business" → open the vault.
 *   - "I want to correct something it learned wrong" → edit the file.
 *   - "Can I export everything?" → it's already plain `.md` on disk.
 *
 * Layout:
 *   <dataDir>/vault/<tenantId>/
 *     README.md                       — quick orientation for the owner
 *     entities/
 *       <Type>/<safe-name>.md         — one entity per file
 *     episodes/
 *       YYYY-MM-DD.md                 — daily rollup of conversation excerpts
 *     skills/
 *       <name>.md                     — skill body + accumulated lessons
 *     PROFILE.md                      — (v2.5.0 PROFILE.md feature)
 *
 * Sync strategy:
 *   - Incremental: each upsert/episode write triggers a single-file refresh.
 *   - Full rebuild: once daily during housekeeping (02:30 KSA).
 *   - Owner edits are PRESERVED on next sync (frontmatter-driven merge).
 *
 * Idempotency: writes are skipped when content unchanged. Mtime preserved
 * so Obsidian's "modified date" reflects the agent's last update, not the
 * sync run.
 */
import { promises as fs } from 'node:fs';
import { join } from 'node:path';
import { createHash } from 'node:crypto';
import { createChildLogger } from '../utils/logger.js';
import { getDataDir } from '../utils/data-dir.js';
import { supabase } from '../utils/supabase.js';
const log = createChildLogger('knowledge:vault');
/** True when the vault feature is enabled. Defaults ON; opt out by setting
 * MUASSIS_VAULT_ENABLED=false. */
function isEnabled() {
    return (process.env.MUASSIS_VAULT_ENABLED ?? 'true').toLowerCase() !== 'false';
}
export function getVaultRoot(tenantId) {
    return join(getDataDir(), 'vault', tenantId);
}
/** Slug a free-text name for use as a filename. Conservative: keeps Arabic,
 * Latin, digits, hyphens, underscores. Other chars collapse to '-'. Trims
 * to 80 chars so the path stays well under filesystem limits. */
function slugifyForFile(name) {
    if (!name)
        return 'unnamed';
    const cleaned = name
        .normalize('NFC')
        .replace(/[/\\:*?"<>|\0]/g, '-') // forbidden FS chars
        .replace(/[^\p{L}\p{N}\-_ .]+/gu, '-') // anything not letter/digit/safe punctuation
        .replace(/\s+/g, ' ')
        .trim()
        .slice(0, 80);
    return cleaned || 'unnamed';
}
async function ensureDir(path) {
    await fs.mkdir(path, { recursive: true });
}
/** Write a file only if content has changed. Returns true if a write happened. */
async function writeIfChanged(filePath, content) {
    try {
        const existing = await fs.readFile(filePath, 'utf-8');
        if (existing === content)
            return false;
    }
    catch { /* missing — proceed to write */ }
    await fs.writeFile(filePath, content, 'utf-8');
    return true;
}
/** Render an object as YAML frontmatter. Conservative — strings only, no
 * nested objects (kept on purpose; the body holds the rich content). */
function renderFrontmatter(meta) {
    const lines = ['---'];
    for (const [k, v] of Object.entries(meta)) {
        if (v === null || v === undefined)
            continue;
        if (Array.isArray(v)) {
            if (v.length === 0)
                continue;
            lines.push(`${k}:`);
            for (const item of v)
                lines.push(`  - ${String(item).replace(/\n/g, ' ')}`);
        }
        else if (typeof v === 'object') {
            // Inline JSON for nested objects — keeps frontmatter scannable.
            lines.push(`${k}: ${JSON.stringify(v)}`);
        }
        else {
            const s = String(v).replace(/\n/g, ' ');
            // Quote strings containing colons or special chars
            lines.push(/[:#&*!|>'"%@`,]/.test(s) ? `${k}: "${s.replace(/"/g, '\\"')}"` : `${k}: ${s}`);
        }
    }
    lines.push('---', '');
    return lines.join('\n');
}
/** Ensure the vault tree exists + drop a README so the owner knows what
 * they're looking at. Idempotent. */
export async function ensureVaultForTenant(tenantId) {
    if (!isEnabled())
        return;
    const root = getVaultRoot(tenantId);
    await ensureDir(root);
    await ensureDir(join(root, 'entities'));
    await ensureDir(join(root, 'episodes'));
    await ensureDir(join(root, 'skills'));
    await writeIfChanged(join(root, 'README.md'), [
        '# Muassis Knowledge Vault',
        '',
        'This folder is your AI employee\'s memory, mirrored as plain Markdown files.',
        'You can open it in [Obsidian](https://obsidian.md) (free) and read or edit anything.',
        '',
        '## Layout',
        '',
        '- `entities/` — every person, product, location, etc. Muassis has learned about, one file per entity.',
        '- `episodes/` — daily rollups of conversation excerpts.',
        '- `skills/` — Muassis\'s skill catalog plus accumulated lessons.',
        '- `PROFILE.md` — your style + identity preferences (Muassis auto-learns these; you can edit them).',
        '',
        '## Editing',
        '',
        'Edits you make here are **preserved** on the next sync. Muassis will not overwrite content',
        'between the `<!-- USER:BEGIN -->` and `<!-- USER:END -->` markers.',
        '',
        'To re-sync: the daily housekeeping job (02:30 KSA) refreshes everything, or restart the',
        'gateway and a fresh sync runs at boot.',
        '',
        '## Privacy',
        '',
        'These files live on YOUR machine. Muassis never uploads them anywhere.',
        '',
    ].join('\n'));
}
/**
 * Mirror one KG entity to its `.md` file. Called on every upsertEntity.
 * Cheap: a short write to local disk.
 */
export async function syncEntityToVault(tenantId, entity, options) {
    if (!isEnabled())
        return;
    try {
        await ensureVaultForTenant(tenantId);
        const root = getVaultRoot(tenantId);
        const typeDir = join(root, 'entities', slugifyForFile(entity.type || 'Unknown'));
        await ensureDir(typeDir);
        const filePath = join(typeDir, `${slugifyForFile(entity.name)}.md`);
        const props = entity.properties || {};
        const frontmatter = renderFrontmatter({
            name: entity.name,
            type: entity.type,
            kind: entity.kind,
            scope: entity.customer_id ? 'customer' : 'shared',
            customer_id: entity.customer_id,
            episode_count: entity.episode_count ?? 0,
            updated_at: entity.updated_at ?? new Date().toISOString(),
            // Surface a few common props inline for fast scanning + Obsidian search.
            ...pickInline(props),
            // Tags help Obsidian's tag filter — `#skill`, `#cron`, etc.
            tags: entity.kind ? `${entity.kind}/${slugifyForFile(entity.type)}` : entity.type,
        });
        const propLines = Object.entries(props)
            .filter(([k]) => !INLINE_PROP_KEYS.has(k))
            .map(([k, v]) => `- **${k}**: ${formatPropValue(v)}`);
        // ── Wiki-links: this is what powers Obsidian's graph view ──
        // Each [[Other Entity]] becomes an edge in Obsidian's graph. Without
        // these, the graph shows isolated nodes — a forest of unconnected
        // markdown files. With them, the graph becomes the visual KG the
        // owner can navigate.
        const relatedSection = (options?.relatedEntityNames?.length ?? 0) > 0
            ? [
                '## Related',
                '',
                ...(options.relatedEntityNames).map((n) => `- [[${n}]]`),
                '',
            ].join('\n')
            : '';
        const body = [
            frontmatter,
            `# ${entity.name}`,
            '',
            `Type: **${entity.type}**${entity.kind ? ` · Kind: \`${entity.kind}\`` : ''}`,
            entity.customer_id ? `Scope: bound to customer \`${entity.customer_id}\`` : 'Scope: shared across customers',
            `Mentioned in ${entity.episode_count ?? 0} episode(s).`,
            '',
            '## Properties',
            propLines.length > 0 ? propLines.join('\n') : '_(no extra properties)_',
            '',
            relatedSection,
            '<!-- USER:BEGIN — your notes below are preserved between syncs -->',
            '',
            '<!-- USER:END -->',
            '',
        ].join('\n');
        const merged = await mergeUserSection(filePath, body);
        await writeIfChanged(filePath, merged);
    }
    catch (err) {
        log.debug({ err: err.message, name: entity.name }, 'vault entity sync failed (non-fatal)');
    }
}
/**
 * Full vault rebuild — exports EVERY kg_entity for the tenant as a markdown
 * file with proper wiki-links from kg_relationships. After this runs, the
 * vault directory can be opened directly in Obsidian and the graph view
 * shows the complete knowledge graph with all connections.
 *
 * Used by:
 *   POST /api/knowledge/vault/rebuild (owner-triggered from Memory tab)
 *
 * Returns counts so the UI can show "synced 200 entities + 47 relationships".
 */
export async function rebuildVaultForTenant(tenantId) {
    if (!isEnabled())
        return { entities: 0, relationships: 0, path: '(vault disabled)' };
    const { supabase } = await import('../utils/supabase.js');
    const [{ data: entities }, { data: rels }] = await Promise.all([
        supabase.from('kg_entities').select('id, name, type, kind, customer_id, properties, episode_count, updated_at').eq('tenant_id', tenantId).limit(5000),
        supabase.from('kg_relationships').select('source_id, target_id, type').eq('tenant_id', tenantId).limit(10000),
    ]);
    // Build a name lookup so relationship target_id → name for wiki-links
    const idToName = new Map();
    for (const e of (entities ?? [])) {
        idToName.set(e.id, e.name);
    }
    // Build adjacency: for each entity id → set of related entity names
    const adj = new Map();
    for (const r of (rels ?? [])) {
        const sn = idToName.get(r.source_id);
        const tn = idToName.get(r.target_id);
        if (sn && tn) {
            if (!adj.has(r.source_id))
                adj.set(r.source_id, new Set());
            if (!adj.has(r.target_id))
                adj.set(r.target_id, new Set());
            adj.get(r.source_id).add(tn);
            adj.get(r.target_id).add(sn);
        }
    }
    // Sync each entity with its relationships
    let synced = 0;
    for (const e of (entities ?? [])) {
        const related = Array.from(adj.get(e.id) || []);
        await syncEntityToVault(tenantId, e, { relatedEntityNames: related });
        synced++;
    }
    return {
        entities: synced,
        relationships: rels?.length ?? 0,
        path: getVaultRoot(tenantId),
    };
}
const INLINE_PROP_KEYS = new Set(['source', 'confidence', 'isCustomer']);
function pickInline(props) {
    const out = {};
    for (const k of INLINE_PROP_KEYS)
        if (k in props)
            out[k] = props[k];
    return out;
}
function formatPropValue(v) {
    if (v === null || v === undefined)
        return '_null_';
    if (typeof v === 'string')
        return v.length > 200 ? v.slice(0, 200) + '…' : v;
    if (Array.isArray(v))
        return v.map(x => formatPropValue(x)).join(', ');
    if (typeof v === 'object')
        return '`' + JSON.stringify(v).slice(0, 200) + '`';
    return String(v);
}
/**
 * If the user has added content between USER:BEGIN / USER:END markers in
 * the existing file, preserve it in the rewritten file.
 */
async function mergeUserSection(filePath, freshBody) {
    try {
        const existing = await fs.readFile(filePath, 'utf-8');
        const userMatch = existing.match(/<!-- USER:BEGIN[^>]*-->([\s\S]*?)<!-- USER:END -->/);
        if (userMatch && userMatch[1].trim()) {
            return freshBody.replace(/<!-- USER:BEGIN[^>]*-->[\s\S]*?<!-- USER:END -->/, `<!-- USER:BEGIN — your notes below are preserved between syncs -->${userMatch[1]}<!-- USER:END -->`);
        }
    }
    catch { /* file doesn't exist yet, no merge needed */ }
    return freshBody;
}
/**
 * Append today's conversation excerpts to `episodes/YYYY-MM-DD.md`.
 * Coalesces multiple appends per day into one file.
 */
export async function appendEpisodeToVault(tenantId, episode) {
    if (!isEnabled())
        return;
    try {
        await ensureVaultForTenant(tenantId);
        const root = getVaultRoot(tenantId);
        const when = episode.createdAt instanceof Date ? episode.createdAt :
            typeof episode.createdAt === 'string' ? new Date(episode.createdAt) : new Date();
        const dayKey = when.toISOString().slice(0, 10);
        const filePath = join(root, 'episodes', `${dayKey}.md`);
        const timeHHMM = when.toISOString().slice(11, 16);
        const excerpt = episode.content.length > 1500 ? episode.content.slice(0, 1500) + '…' : episode.content;
        let existing = '';
        try {
            existing = await fs.readFile(filePath, 'utf-8');
        }
        catch { /* new file */ }
        if (!existing) {
            const header = [
                renderFrontmatter({ date: dayKey, kind: 'episodes' }),
                `# Episodes — ${dayKey}`,
                '',
            ].join('\n');
            existing = header;
        }
        const entry = [
            `## ${timeHHMM} — ${episode.customerName || 'Customer'} (\`${episode.customerId.slice(0, 8)}\`)`,
            '',
            excerpt,
            '',
        ].join('\n');
        // Don't append the same excerpt twice (re-runs of recordRawEpisode).
        const fingerprint = createHash('sha256').update(`${dayKey}|${episode.customerId}|${excerpt}`).digest('hex').slice(0, 12);
        if (existing.includes(`<!-- fp:${fingerprint} -->`))
            return;
        const next = existing.trimEnd() + '\n\n' + entry + `<!-- fp:${fingerprint} -->\n`;
        await fs.writeFile(filePath, next, 'utf-8');
    }
    catch (err) {
        log.debug({ err: err.message }, 'vault episode append failed (non-fatal)');
    }
}
/** Full vault rebuild — used by housekeeping or first-boot. Reads all KG
 * rows for the tenant and rewrites the vault from scratch. Preserves
 * user-edited content via mergeUserSection. */
export async function rebuildVault(tenantId) {
    if (!isEnabled())
        return { entities: 0, episodes: 0, skills: 0 };
    await ensureVaultForTenant(tenantId);
    const root = getVaultRoot(tenantId);
    // Entities
    const { data: entitiesData } = await supabase
        .from('kg_entities')
        .select('name, type, customer_id, properties, episode_count, updated_at')
        .eq('tenant_id', tenantId)
        .eq('kind', 'conversation')
        .order('updated_at', { ascending: false })
        .limit(2000);
    let entityCount = 0;
    for (const e of (entitiesData ?? [])) {
        await syncEntityToVault(tenantId, e);
        entityCount++;
    }
    // Skills
    const { data: skillsData } = await supabase
        .from('kg_entities')
        .select('name, properties, updated_at')
        .eq('tenant_id', tenantId)
        .eq('kind', 'skill')
        .order('updated_at', { ascending: false })
        .limit(500);
    await ensureDir(join(root, 'skills'));
    let skillCount = 0;
    for (const s of (skillsData ?? [])) {
        const props = (s.properties || {});
        const filePath = join(root, 'skills', `${slugifyForFile(s.name)}.md`);
        const lessons = Array.isArray(props.lessons_learned) ? props.lessons_learned : [];
        const liveLessons = lessons.filter(l => !l.superseded_at);
        const frontmatter = renderFrontmatter({
            name: s.name,
            kind: 'skill',
            lessons_count: liveLessons.length,
            updated_at: s.updated_at,
        });
        const lessonsBlock = liveLessons.length > 0
            ? liveLessons.map((l, i) => {
                const text = typeof l.text === 'string' ? l.text : '';
                const sev = typeof l.severity === 'string' ? l.severity : 'medium';
                const k = typeof l.kind === 'string' ? l.kind : 'avoid';
                return `${i + 1}. [${k}/${sev}] ${text}`;
            }).join('\n')
            : '_(no lessons yet)_';
        const body = [
            frontmatter,
            `# ${s.name}`,
            '',
            typeof props.when_to_use === 'string' ? `**When**: ${props.when_to_use}` : '',
            '',
            '## Instructions',
            typeof props.instructions === 'string' ? props.instructions : '_(none)_',
            '',
            '## Lessons learned',
            lessonsBlock,
            '',
            '<!-- USER:BEGIN — your notes below are preserved between syncs -->',
            '',
            '<!-- USER:END -->',
            '',
        ].join('\n');
        await writeIfChanged(filePath, await mergeUserSection(filePath, body));
        skillCount++;
    }
    // Episodes (last 14 days only — full history is in the SQLite DB)
    const cutoff = new Date(Date.now() - 14 * 24 * 60 * 60 * 1000).toISOString();
    const { data: episodesData } = await supabase
        .from('kg_episodes')
        .select('customer_id, content, created_at')
        .eq('tenant_id', tenantId)
        .eq('kind', 'conversation')
        .gte('created_at', cutoff)
        .order('created_at', { ascending: true })
        .limit(5000);
    let episodeCount = 0;
    for (const ep of (episodesData ?? [])) {
        await appendEpisodeToVault(tenantId, {
            customerId: ep.customer_id,
            customerName: '',
            content: ep.content,
            createdAt: ep.created_at,
        });
        episodeCount++;
    }
    log.info({ tenantId, entities: entityCount, episodes: episodeCount, skills: skillCount }, 'vault rebuild complete');
    return { entities: entityCount, episodes: episodeCount, skills: skillCount };
}
//# sourceMappingURL=vault.js.map