/**
 * src/knowledge/graphiti.ts
 * Conversation knowledge graph — SQLite-backed.
 *
 * Replaces the original Neo4j-backed implementation with a SQLite version
 * that ships out-of-the-box on the device. Same exported class shape so
 * unified.ts and downstream consumers (agent/brain.ts, channels/whatsapp/handler.ts)
 * don't change.
 *
 * Storage model (kg_entities + kg_relationships + kg_episodes):
 * - Customer-as-Person rows: kind='conversation', type='Person', customer_id=<the id>
 * - Other entities (Products, Locations, Companies, etc.): kind='conversation', customer_id=NULL
 * - Relationships: source_id → target_id with a type ('mentioned', 'interested_in', etc.)
 * - Episodes: raw conversation chunks with extracted-counts for telemetry
 */
import { randomUUID } from 'node:crypto';
import { createChildLogger } from '../utils/logger.js';
import { supabase } from '../utils/supabase.js';
import { extractEntities } from './extraction.js';
const log = createChildLogger('knowledge:graphiti');
/**
 * Normalize a name for dedup lookups.
 *
 * (Round-7 fix) Apply Unicode NFC then collapse Arabic letter variants that
 * are visually identical but encode differently:
 *   - All diacritics stripped (fatha/damma/kasra/sukun/shadda…).
 *   - Hamza-bearing alefs (إ أ آ) → bare alef (ا).
 *   - Alef maksura (ى) → ya (ي).
 *   - Ta marbuta (ة) → ha (ه).
 *   - Decomposed lam+alef (ل + ا) and the ligature (ﻻ) → "لا".
 *   - Tatweel (ـ) stripped.
 *   - Non-NFC sequences normalized.
 *
 * Without this, "Ahmed" entered as "احمد" once and "أحمد" the next turn would
 * dedupe to two separate KG rows. Same for "Phone XS" vs "Phone XS" with a
 * sukun on a letter. Critical for Arabic-first dedup.
 */
const AR_DIACRITICS = /[ً-ٰٟۖ-ۭ]/g;
const AR_TATWEEL = /ـ/g;
const norm = (s) => {
    if (!s)
        return '';
    return s
        .normalize('NFC')
        .replace(AR_DIACRITICS, '')
        .replace(AR_TATWEEL, '')
        .replace(/[إأآا]/g, 'ا')
        .replace(/ى/g, 'ي')
        .replace(/ة/g, 'ه')
        .replace(/ﻻ|ﻼ|ﻷ|ﻸ|ﻹ|ﻺ/g, 'لا') // lam-alef ligature forms
        .toLowerCase()
        .trim();
};
/** Validate that a properties value is a plain object — defensive against
 * malformed DB rows where properties got stored as a string or null. */
const safeProps = (v) => {
    if (v && typeof v === 'object' && !Array.isArray(v)) {
        return v;
    }
    return {};
};
/** (v2.5.0) Best-effort vault mirror. Failures are non-fatal — vault is
 * a convenience layer, not the source of truth. */
async function mirrorToVault(tenantId, entity) {
    try {
        const { syncEntityToVault } = await import('./vault.js');
        await syncEntityToVault(tenantId, entity);
    }
    catch { /* non-fatal */ }
}
async function mirrorEpisodeToVault(tenantId, ep) {
    try {
        const { appendEpisodeToVault } = await import('./vault.js');
        await appendEpisodeToVault(tenantId, ep);
    }
    catch { /* non-fatal */ }
}
export class GraphitiClient {
    /** Connect — no-op for SQLite (the shim is initialized lazily by utils/supabase.ts). */
    async connect() {
        log.info('graphiti: connected (sqlite-backed, no remote driver needed)');
    }
    /** Close — no-op. The underlying SQLite handle is managed by the shim. */
    async close() {
        log.debug('graphiti: close called (sqlite-backed, no-op)');
    }
    // ─── Internals ─────────────────────────────────────────────────────
    async upsertEntity(row) {
        const name_normalized = norm(row.name);
        // Look up existing
        let existing = null;
        if (row.customer_id) {
            const r = await supabase
                .from('kg_entities')
                .select('*')
                .eq('tenant_id', row.tenant_id)
                .eq('kind', 'conversation')
                .eq('customer_id', row.customer_id)
                .eq('type', row.type)
                .eq('name_normalized', name_normalized)
                .maybeSingle();
            existing = r.data ?? null;
        }
        else {
            const r = await supabase
                .from('kg_entities')
                .select('*')
                .eq('tenant_id', row.tenant_id)
                .eq('kind', 'conversation')
                .is('customer_id', null)
                .eq('type', row.type)
                .eq('name_normalized', name_normalized)
                .maybeSingle();
            existing = r.data ?? null;
        }
        const now = new Date().toISOString();
        if (existing) {
            const merged = { ...safeProps(existing.properties), ...(row.properties ?? {}) };
            await supabase
                .from('kg_entities')
                .update({ properties: merged, episode_count: existing.episode_count + 1, updated_at: now })
                .eq('id', existing.id);
            // (v2.5.0) Mirror to Obsidian vault. Best-effort, async.
            void mirrorToVault(row.tenant_id, { ...existing, properties: merged, episode_count: existing.episode_count + 1, updated_at: now });
            return { ...existing, properties: merged, episode_count: existing.episode_count + 1, updated_at: now };
        }
        const id = randomUUID();
        const fresh = {
            id,
            tenant_id: row.tenant_id,
            kind: 'conversation',
            customer_id: row.customer_id,
            type: row.type,
            name: row.name,
            name_normalized,
            properties: row.properties ?? {},
            source_files: [],
            episode_count: 1,
            created_at: now,
            updated_at: now,
        };
        // (v2.5.0) Mirror to vault on successful insert below.
        const ins = await supabase.from('kg_entities').insert(fresh);
        if (!ins.error)
            void mirrorToVault(row.tenant_id, fresh);
        if (ins.error) {
            // (C4 fix) Concurrent-write race: two parallel turns mentioning the same
            // entity both pass the read-existing check, both try to insert, the
            // second hits unique constraint idx_kg_entities_uniq. Previously this
            // was silently swallowed and the second turn's "I learned X" was lost.
            // Now: re-fetch the row that won the race and increment episode_count
            // so both calls' work is reflected.
            const isDupe = /UNIQUE|constraint|duplicate/i.test(ins.error.message);
            if (!isDupe) {
                throw new Error(`kg_entities insert failed: ${ins.error.message}`);
            }
            log.debug({ tenant_id: row.tenant_id, name: row.name }, 'kg upsert race detected, re-fetching winner');
            const reFetch = await supabase
                .from('kg_entities')
                .select('*')
                .eq('tenant_id', row.tenant_id)
                .eq('kind', 'conversation')
                .eq('name_normalized', name_normalized);
            const winner = (reFetch.data ?? []).find((r) => r.customer_id === row.customer_id && r.type === row.type);
            if (winner) {
                const merged = { ...safeProps(winner.properties), ...(row.properties ?? {}) };
                await supabase
                    .from('kg_entities')
                    .update({ properties: merged, episode_count: winner.episode_count + 1, updated_at: now })
                    .eq('id', winner.id);
                return { ...winner, properties: merged, episode_count: winner.episode_count + 1, updated_at: now };
            }
            // Fallback: re-fetch failed (very rare) — return the fresh row even
            // though it's not in the DB. Calling code at least sees our intent.
            log.warn({ row }, 'kg upsert race re-fetch failed — returning synthetic row');
            return fresh;
        }
        return fresh;
    }
    async upsertRelationship(row) {
        const existing = await supabase
            .from('kg_relationships')
            .select('id, properties')
            .eq('tenant_id', row.tenant_id)
            .eq('source_id', row.source_id)
            .eq('target_id', row.target_id)
            .eq('type', row.type)
            .maybeSingle();
        const now = new Date().toISOString();
        if (existing.data) {
            const merged = { ...safeProps(existing.data.properties), ...(row.properties ?? {}) };
            await supabase
                .from('kg_relationships')
                .update({ properties: merged, updated_at: now })
                .eq('id', existing.data.id);
            return;
        }
        const ins = await supabase.from('kg_relationships').insert({
            id: randomUUID(),
            tenant_id: row.tenant_id,
            source_id: row.source_id,
            target_id: row.target_id,
            type: row.type,
            properties: row.properties ?? {},
            created_at: now,
            updated_at: now,
        });
        if (ins.error) {
            // If it's a unique-conflict that the maybeSingle above missed (race), swallow
            log.debug({ error: ins.error.message }, 'kg_relationships insert skipped');
        }
    }
    // ─── Public API ────────────────────────────────────────────────────
    /**
     * Synchronous raw-episode write + naive entity stub extraction. (C5 fix.)
     *
     * Runs on the brain's hot path (awaited before next turn). Does NOT call
     * the LLM — uses a regex-based stub extractor to grab obvious entities
     * (proper nouns, numbers with units, URLs). The richer LLM-based
     * extraction follows in `addConversationEpisode` async.
     *
     * Why both: this guarantees that turn N+1's queryBusinessBrain sees at
     * least the ROUGH entities from turn N (e.g., a customer name, a product
     * SKU, a price). The previous design had a 2-10s gap where N's content
     * existed but had no extracted entities — agent forgot what was just said.
     */
    async recordRawEpisode(tenantId, customerId, customerName, conversationText, timestamp = new Date()) {
        try {
            const text = conversationText.slice(0, 8000);
            if (text.length < conversationText.length) {
                log.debug({ orig: conversationText.length, kept: text.length }, 'episode text truncated to 8000 chars');
            }
            await supabase.from('kg_episodes').insert({
                id: randomUUID(),
                tenant_id: tenantId,
                kind: 'conversation',
                customer_id: customerId,
                content: text,
                entities_extracted: 0,
                relationships_extracted: 0,
                created_at: timestamp.toISOString(),
            });
            // (v2.5.0) Mirror to Obsidian vault.
            void mirrorEpisodeToVault(tenantId, {
                customerId, customerName, content: text, createdAt: timestamp,
            });
            // Always register the customer as a Person entity.
            await this.upsertEntity({
                tenant_id: tenantId,
                customer_id: customerId,
                type: 'Person',
                name: customerName,
            });
            // (C5) Stub extraction — naive, fast, no LLM. Catches:
            //   • Capitalized multi-word phrases (likely names/places/products)
            //   • Currency amounts (SAR, USD, ر.س, $, ريال)
            //   • Hashtag-style topic markers
            // These show up in the next turn's queryBusinessBrain even before the
            // async LLM enrichment lands.
            const stubs = extractRawEntityStubs(text);
            for (const stub of stubs.slice(0, 8)) {
                try {
                    await this.upsertEntity({
                        tenant_id: tenantId,
                        customer_id: stub.shared ? null : customerId,
                        type: stub.type,
                        name: stub.name,
                        properties: { source: 'raw_stub', confidence: 'low' },
                    });
                }
                catch { /* non-fatal */ }
            }
        }
        catch (err) {
            log.debug({ err: err.message }, 'raw episode write failed');
        }
    }
    /**
     * Add a conversation episode — extract entities/relationships and store.
     * Returns the count of entities + relationships persisted (caller telemetry).
     */
    async addConversationEpisode(tenantId, customerId, customerName, conversationText, timestamp = new Date()) {
        const extraction = await extractEntities(conversationText, tenantId);
        // (C6) If extraction failed at the LLM/parse layer, journal it so the
        // owner sees a pattern instead of silently piling up zero-entity
        // episodes forever.
        if (extraction.failed) {
            try {
                const { record } = await import('../autonomy/journal.js');
                await record({
                    tenantId,
                    kind: 'kg_extraction_failed',
                    action: 'extract_entities',
                    target: customerId,
                    outcome: 'failure',
                    reason: extraction.reason ?? 'unknown extraction error',
                });
            }
            catch { /* journal failure is non-fatal */ }
            log.warn({ tenantId, customerId, reason: extraction.reason }, 'KG extraction failed — episode kept, no entities');
            return { entitiesCreated: 0, relationshipsCreated: 0, failed: true, reason: extraction.reason };
        }
        if (extraction.entities.length === 0) {
            log.debug({ tenantId, customerId }, 'No entities extracted from conversation');
            return { entitiesCreated: 0, relationshipsCreated: 0 };
        }
        try {
            // 1. Episode log row (telemetry — also fuels the dashboard graph view)
            await supabase.from('kg_episodes').insert({
                id: randomUUID(),
                tenant_id: tenantId,
                kind: 'conversation',
                customer_id: customerId,
                content: conversationText.slice(0, 8000), // cap to avoid pathological rows
                entities_extracted: extraction.entities.length,
                relationships_extracted: extraction.relationships.length,
                created_at: timestamp.toISOString(),
            });
            // 2. Customer-as-Person entity (always exists for the speaker)
            const customerEntity = await this.upsertEntity({
                tenant_id: tenantId,
                customer_id: customerId,
                type: 'Person',
                name: customerName,
                properties: { isCustomer: true },
            });
            // 3. Each extracted entity (shared, customer_id=null)
            const nameToId = new Map();
            nameToId.set(norm(customerName), customerEntity.id);
            let entitiesCreated = 0;
            for (const e of extraction.entities) {
                if (norm(e.name) === norm(customerName))
                    continue;
                const ent = await this.upsertEntity({
                    tenant_id: tenantId,
                    customer_id: null,
                    type: e.type,
                    name: e.name,
                    properties: e.properties ?? {},
                });
                nameToId.set(norm(e.name), ent.id);
                entitiesCreated++;
            }
            // 4. Extracted relationships
            let relationshipsCreated = 0;
            for (const r of extraction.relationships) {
                const sourceId = nameToId.get(norm(r.from));
                const targetId = nameToId.get(norm(r.to));
                if (!sourceId || !targetId)
                    continue;
                await this.upsertRelationship({
                    tenant_id: tenantId,
                    source_id: sourceId,
                    target_id: targetId,
                    type: r.type,
                    properties: r.properties ?? {},
                });
                relationshipsCreated++;
            }
            // 5. "mentioned" link from customer to each non-self entity
            for (const e of extraction.entities) {
                if (norm(e.name) === norm(customerName))
                    continue;
                const targetId = nameToId.get(norm(e.name));
                if (!targetId)
                    continue;
                await this.upsertRelationship({
                    tenant_id: tenantId,
                    source_id: customerEntity.id,
                    target_id: targetId,
                    type: 'mentioned',
                });
            }
            log.info({ tenantId, customerId, entitiesCreated, relationshipsCreated }, 'Conversation episode stored in knowledge graph');
            return { entitiesCreated, relationshipsCreated };
        }
        catch (err) {
            log.error({ tenantId, customerId, error: err.message }, 'Failed to store episode');
            throw err;
        }
    }
    /**
     * Everything we know about a specific customer — their entity row plus
     * every entity they're connected to.
     */
    async queryCustomerKnowledge(tenantId, customerId) {
        const customer = await supabase
            .from('kg_entities')
            .select('*')
            .eq('tenant_id', tenantId)
            .eq('kind', 'conversation')
            .eq('customer_id', customerId)
            .maybeSingle();
        if (!customer.data) {
            return { entities: [], relationships: [] };
        }
        const customerRow = customer.data;
        const rels = await supabase
            .from('kg_relationships')
            .select('*')
            .eq('tenant_id', tenantId)
            .eq('source_id', customerRow.id);
        const relRows = (rels.data ?? []);
        if (relRows.length === 0) {
            return {
                entities: [{ name: customerRow.name, entityType: 'Person', properties: safeProps(customerRow.properties) }],
                relationships: [],
            };
        }
        const targetIds = relRows.map(r => r.target_id);
        const targets = await supabase
            .from('kg_entities')
            .select('*')
            .in('id', targetIds);
        const byId = new Map();
        for (const t of (targets.data ?? []))
            byId.set(t.id, t);
        const entities = [
            { name: customerRow.name, entityType: 'Person', properties: safeProps(customerRow.properties) },
        ];
        const relationships = [];
        for (const r of relRows) {
            const t = byId.get(r.target_id);
            if (!t)
                continue;
            entities.push({ name: t.name, entityType: t.type, properties: safeProps(t.properties) });
            relationships.push({ from: customerRow.name, to: t.name, type: r.type, confidence: 0.8 });
        }
        // (Round-7 fix, was H6 cap-at-15) Include recent shared entities
        // (customer_id IS NULL). These are products / locations / companies
        // extracted from conversations that aren't bound to one customer —
        // without this, the brain misses them when answering "what was that
        // product I mentioned?" follow-ups.
        //
        // Cap increased 15 → 50 + ordered by `episode_count DESC, updated_at
        // DESC` so frequently-discussed entities surface even when not the
        // most-recent. The brain prompt can handle ~50 names; the previous 15
        // missed entities #16+ for tenants with active conversations.
        const recentShared = await supabase
            .from('kg_entities')
            .select('name, type, properties, episode_count, updated_at')
            .eq('tenant_id', tenantId)
            .eq('kind', 'conversation')
            .is('customer_id', null)
            .order('episode_count', { ascending: false })
            .order('updated_at', { ascending: false })
            .limit(50);
        for (const t of (recentShared.data ?? [])) {
            // Skip if this entity is already linked through the customer's relationships
            if (entities.some((e) => e.name === t.name))
                continue;
            entities.push({ name: t.name, entityType: t.type, properties: safeProps(t.properties), scope: 'shared' });
        }
        return { entities, relationships };
    }
    /** Keyword search across all conversation entities for a tenant. */
    async searchKnowledge(tenantId, query) {
        const result = await supabase
            .from('kg_entities')
            .select('id, name, type, properties, episode_count')
            .eq('tenant_id', tenantId)
            .eq('kind', 'conversation')
            .ilike('name_normalized', `%${norm(query)}%`)
            .order('episode_count', { ascending: false })
            .limit(20);
        return (result.data ?? []).map(r => ({
            ...r,
            properties: safeProps(r.properties),
            score: 1.0,
        }));
    }
    /**
     * Find other customers who mentioned the same entities — "shared interest"
     * recommendations. JS-side join: get the customer's mentioned entities,
     * then find other customers who have a relationship to those same entities.
     */
    async getCustomerRelationships(tenantId, customerId) {
        const customer = await supabase
            .from('kg_entities')
            .select('id, name')
            .eq('tenant_id', tenantId)
            .eq('kind', 'conversation')
            .eq('customer_id', customerId)
            .maybeSingle();
        if (!customer.data)
            return [];
        const customerRow = customer.data;
        const myRels = await supabase
            .from('kg_relationships')
            .select('target_id, type')
            .eq('tenant_id', tenantId)
            .eq('source_id', customerRow.id);
        const myTargets = (myRels.data ?? []);
        if (myTargets.length === 0)
            return [];
        const targetIds = myTargets.map(r => r.target_id);
        const otherRels = await supabase
            .from('kg_relationships')
            .select('source_id, target_id, type')
            .eq('tenant_id', tenantId)
            .in('target_id', targetIds)
            .neq('source_id', customerRow.id);
        const otherSourceIds = Array.from(new Set((otherRels.data ?? []).map(r => r.source_id)));
        if (otherSourceIds.length === 0)
            return [];
        const otherCustomers = await supabase
            .from('kg_entities')
            .select('id, name, customer_id')
            .in('id', otherSourceIds)
            .eq('kind', 'conversation')
            .eq('type', 'Person')
            .neq('customer_id', null);
        const customersById = new Map();
        for (const c of (otherCustomers.data ?? [])) {
            customersById.set(c.id, { name: c.name, customer_id: c.customer_id });
        }
        const sharedEntities = await supabase
            .from('kg_entities')
            .select('id, name, type')
            .in('id', targetIds);
        const entitiesById = new Map();
        for (const e of (sharedEntities.data ?? [])) {
            entitiesById.set(e.id, { name: e.name, type: e.type });
        }
        const myRelByTarget = new Map();
        for (const r of myTargets)
            myRelByTarget.set(r.target_id, r.type);
        const out = [];
        for (const r of (otherRels.data ?? [])) {
            const other = customersById.get(r.source_id);
            const ent = entitiesById.get(r.target_id);
            if (!other || !ent)
                continue;
            out.push({
                otherCustomer: other.name,
                otherCustomerId: other.customer_id,
                sharedEntity: ent.name,
                entityType: ent.type,
                relType1: myRelByTarget.get(r.target_id) ?? 'mentioned',
                relType2: r.type,
            });
        }
        return out.slice(0, 50);
    }
    /**
     * Product insights — for a specific product (or all top products), list the
     * customers who mentioned/asked about it and how often.
     */
    async getProductInsights(tenantId, productId) {
        const productsQuery = supabase
            .from('kg_entities')
            .select('id, name, properties, episode_count')
            .eq('tenant_id', tenantId)
            .eq('kind', 'conversation')
            .eq('type', 'Product');
        const products = productId
            ? await productsQuery.ilike('name_normalized', norm(productId))
            : await productsQuery.order('episode_count', { ascending: false }).limit(30);
        const productRows = (products.data ?? []);
        if (productRows.length === 0)
            return [];
        const productIds = productRows.map(p => p.id);
        const rels = await supabase
            .from('kg_relationships')
            .select('source_id, target_id, type')
            .eq('tenant_id', tenantId)
            .in('target_id', productIds);
        const sourceIds = Array.from(new Set((rels.data ?? []).map(r => r.source_id)));
        const customers = sourceIds.length > 0
            ? await supabase
                .from('kg_entities')
                .select('id, name, customer_id')
                .in('id', sourceIds)
                .eq('kind', 'conversation')
                .eq('type', 'Person')
            : { data: [], error: null };
        const customersById = new Map();
        for (const c of (customers.data ?? [])) {
            customersById.set(c.id, { name: c.name, customer_id: c.customer_id });
        }
        const relsByProduct = new Map();
        for (const r of (rels.data ?? [])) {
            const c = customersById.get(r.source_id);
            if (!c || !c.customer_id)
                continue;
            const list = relsByProduct.get(r.target_id) ?? [];
            list.push({ customer: c.name, customerId: c.customer_id, relType: r.type });
            relsByProduct.set(r.target_id, list);
        }
        return productRows.map(p => ({
            product: p.name,
            productProps: safeProps(p.properties),
            mentions: p.episode_count,
            customers: relsByProduct.get(p.id) ?? [],
        }));
    }
}
export const graphiti = new GraphitiClient();
/**
 * (C5) Naive regex-based entity extraction. Runs on the hot path so the
 * next turn sees SOMETHING. Lower-confidence than the LLM extractor; the
 * async addConversationEpisode call refines and links these later.
 */
function extractRawEntityStubs(text) {
    const stubs = [];
    const seen = new Set();
    const push = (name, type, shared) => {
        const key = `${type}:${name.toLowerCase()}`;
        if (!seen.has(key)) {
            seen.add(key);
            stubs.push({ name, type, shared });
        }
    };
    // Currency / price amounts → Money entities
    const moneyRe = /(?:[\d,]+(?:\.\d+)?\s*(?:SAR|USD|EUR|GBP|AED|KWD|ر\.?س|ريال|درهم|دولار|يورو))|(?:\$\s*[\d,]+(?:\.\d+)?)/gi;
    for (const m of text.matchAll(moneyRe)) {
        push(m[0].trim(), 'Money', true);
        if (stubs.length >= 8)
            return stubs;
    }
    // Capitalized multi-word phrases (English) — likely product/company/place names
    const properRe = /\b([A-Z][a-z]+(?:\s+[A-Z][a-z]+){1,3})\b/g;
    for (const m of text.matchAll(properRe)) {
        if (m[1].length > 3 && !/^(The|And|For|With|From|This|That|These|Those|When|Where|What|How|Why|Who|Will|Have|Has|Had|Was|Were|Are|Been|Being|Does|Did|May|Can|Would|Should|Could)\b/i.test(m[1])) {
            push(m[1], 'Entity', true);
            if (stubs.length >= 8)
                return stubs;
        }
    }
    // Arabic proper nouns — multi-word phrases starting with definite article ال
    // or known name markers. Conservative — only catches obvious cases.
    const arProperRe = /(?:^|\s)((?:شركة|مؤسسة|مكتب|متجر|محل|مطعم|فندق|بنك|مدينة|حي|شارع|منطقة)\s+[؀-ۿ]+(?:\s+[؀-ۿ]+){0,2})/g;
    for (const m of text.matchAll(arProperRe)) {
        push(m[1].trim(), 'Entity', true);
        if (stubs.length >= 8)
            return stubs;
    }
    // Phone numbers
    const phoneRe = /(?:\+?9665\d{8}|\b05\d{8}\b)/g;
    for (const m of text.matchAll(phoneRe)) {
        push(m[0], 'Phone', true);
        if (stubs.length >= 8)
            return stubs;
    }
    // URLs / domains
    const urlRe = /https?:\/\/[a-z0-9.-]+(?:\/[\w/.\-?=&%]*)?/gi;
    for (const m of text.matchAll(urlRe)) {
        push(m[0], 'URL', true);
        if (stubs.length >= 8)
            return stubs;
    }
    return stubs;
}
//# sourceMappingURL=graphiti.js.map