import { initStderrTee } from "../../../../lib/mcp-stderr-tee/dist/index.js";
initStderrTee("memory");
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { int } from "neo4j-driver";
import { memorySearch } from "./tools/memory-search.js";
import { memoryRank } from "./tools/memory-rank.js";
import { memoryWrite } from "./tools/memory-write.js";
import { loadSchema } from "./lib/schema-loader.js";
import { memoryReindex } from "./tools/memory-reindex.js";
import { memoryIngestExtract } from "./tools/memory-ingest-extract.js";
import { memoryIngest } from "./tools/memory-ingest.js";
import { memoryIngestWeb } from "./tools/memory-ingest-web.js";
import { memoryUpdate } from "./tools/memory-update.js";
import { memoryDelete } from "./tools/memory-delete.js";
import { memoryListAttachments } from "./tools/memory-list-attachments.js";
import { memoryReadAttachment } from "./tools/memory-read-attachment.js";
import { memoryEditAttachment } from "./tools/memory-edit-attachment.js";
import { memoryRenameAttachment } from "./tools/memory-rename-attachment.js";
import { profileRead } from "./tools/profile-read.js";
import { profileUpdate } from "./tools/profile-update.js";
import { profileDelete } from "./tools/profile-delete.js";
import { getSession, closeDriver } from "./lib/neo4j.js";
import { embed } from "./lib/embeddings.js";
const server = new McpServer({
    name: "maxy-memory",
    version: "0.1.0",
});
const readOnly = process.env.READ_ONLY === "true";
const accountId = process.env.ACCOUNT_ID;
if (!accountId) {
    throw new Error("ACCOUNT_ID environment variable is required");
}
// Load the schema contract from platform/plugins/memory/references/schema-*.md
// once at startup. Every memory-write call validates against this. If loading
// fails we throw — a memory server running with a broken schema is worse than
// one that fails to start loudly.
const schema = loadSchema();
const userId = process.env.USER_ID; // Optional — present for admin sessions with users.json auth
// Scope filtering: comma-separated list of allowed scopes (e.g. "public,shared").
// When set, memory-search only returns nodes whose scope is in this list.
// When absent (admin), no scope filtering is applied.
const allowedScopes = process.env.ALLOWED_SCOPES
    ? process.env.ALLOWED_SCOPES.split(",").map((s) => s.trim()).filter(Boolean)
    : undefined;
// Session scoping for conversation-search: limits results to this conversation.
// When absent (admin), conversation-search returns results across all conversations.
const sessionId = process.env.SESSION_ID || undefined;
// Per-agent knowledge scoping: when set, memory-search only returns nodes whose
// `agents` array contains this slug. For public agents, set via env var (server-level
// enforcement). For admin queries, passed as an optional tool param.
const envAgentSlug = process.env.AGENT_SLUG || undefined;
// Keyword subscriptions: comma-separated list of keywords for BM25-based reactive
// knowledge inclusion. When set, memory-search runs a BM25 query per keyword and
// unions results with directly-tagged nodes. Capped at 5 for Pi performance.
const MAX_KEYWORD_SUBSCRIPTIONS = 5;
const envKeywordSubscriptions = process.env.KNOWLEDGE_KEYWORDS
    ? process.env.KNOWLEDGE_KEYWORDS.split(",").map((s) => s.trim().toLowerCase()).filter(Boolean).slice(0, MAX_KEYWORD_SUBSCRIPTIONS)
    : undefined;
// Output character budget for memory-search results. Claude Code's tool result token
// limit is roughly 100K chars; 80K leaves headroom for MCP framing and avoids the
// dump-to-file cascade that forces multi-step Bash parsing.
const OUTPUT_CHAR_BUDGET = 80_000;
/** Format a single search result. When includeRelated is false, related nodes are omitted. */
function formatResult(r, includeRelated) {
    let text = `[${r.labels.join(", ")}] (id: ${r.nodeId}) (score: ${r.score.toFixed(3)})\n`;
    for (const [k, v] of Object.entries(r.properties)) {
        text += `  ${k}: ${String(v)}\n`;
    }
    if (includeRelated && r.related.length > 0) {
        text += "  Related:\n";
        for (const rel of r.related) {
            const arrow = rel.direction === "outgoing" ? `--[${rel.relationship}]-->` : `<--[${rel.relationship}]--`;
            text += `    ${arrow} [${rel.labels.join(", ")}]`;
            const relProps = Object.entries(rel.properties)
                .filter(([k]) => k !== "embedding" && k !== "accountId")
                .map(([k, v]) => `${k}: ${String(v)}`)
                .join(", ");
            if (relProps)
                text += ` {${relProps}}`;
            text += "\n";
        }
    }
    return text;
}
server.tool("memory-search", "Search the knowledge graph using semantic vector search with graph traversal. Returns relevant nodes and their relationships.", {
    query: z.string().describe("Natural language search query"),
    labels: z
        .array(z.string())
        .optional()
        .describe("Filter by node labels (e.g. Question, Service, DefinedTerm, Review, Person)"),
    limit: z
        .number()
        .optional()
        .describe("Maximum number of results (default 10)"),
    expandHops: z
        .number()
        .optional()
        .describe("Graph traversal depth for related nodes (default 1). Use 0 for listing/inventory queries (node properties only, compact output). Use 1 for deep context queries (includes related nodes — can produce large output for nodes with many relationships)."),
    keywords: z
        .array(z.string())
        .optional()
        .describe("Filter KnowledgeDocument results to those containing these topic keywords"),
    keywordMatch: z
        .enum(["any", "all"])
        .optional()
        .describe("Keyword match mode: 'any' (default, OR — matches docs with any listed keyword) or 'all' (AND — must have every keyword)"),
    agentSlug: z
        .string()
        .optional()
        .describe("Filter to nodes tagged for this agent slug (nodes where slug is in the agents array). For admin use — public agents use server-level env var."),
}, async ({ query, labels, limit, expandHops, keywords, keywordMatch, agentSlug: toolAgentSlug }) => {
    // Env var takes precedence over tool param (security: public agent server-level enforcement)
    const effectiveAgentSlug = envAgentSlug ?? toolAgentSlug;
    try {
        const results = await memorySearch({
            query,
            labels,
            accountId,
            limit,
            expandHops,
            allowedScopes,
            keywords,
            keywordMatch,
            agentSlug: effectiveAgentSlug,
            keywordSubscriptions: envKeywordSubscriptions,
        });
        if (results.length === 0) {
            return {
                content: [
                    {
                        type: "text",
                        text: "No results found for this query.",
                    },
                ],
            };
        }
        const formatted = results.map((r) => formatResult(r, true));
        const separator = "\n---\n";
        let output = formatted.join(separator);
        let droppedRelated = 0;
        let droppedResults = 0;
        if (output.length > OUTPUT_CHAR_BUDGET) {
            const originalChars = output.length;
            // Pass 1: strip related nodes from lowest-scored results first.
            // Results are already sorted by score descending, so we iterate
            // from the tail (lowest score) toward the head.
            for (let i = formatted.length - 1; i >= 0 && output.length > OUTPUT_CHAR_BUDGET; i--) {
                if (results[i].related.length > 0) {
                    droppedRelated += results[i].related.length;
                    formatted[i] = formatResult(results[i], false);
                    output = formatted.join(separator);
                }
            }
            // Pass 2: if still over budget, drop entire results from the tail.
            while (formatted.length > 0 && output.length > OUTPUT_CHAR_BUDGET) {
                formatted.pop();
                droppedResults++;
                output = formatted.join(separator);
            }
            const notice = `\n\n[Truncated: ${droppedRelated} related nodes removed` +
                (droppedResults > 0 ? `, ${droppedResults} results omitted` : "") +
                `. Use expandHops: 0 for compact listing, or reduce limit.]`;
            output += notice;
            process.stderr.write(`[memory-search] [${accountId}] output truncated: ${originalChars} → ${output.length}, ` +
                `${droppedRelated} related nodes dropped, ${droppedResults} results dropped\n`);
        }
        return {
            content: [{ type: "text", text: output }],
        };
    }
    catch (err) {
        return {
            content: [
                {
                    type: "text",
                    text: `Search failed: ${err instanceof Error ? err.message : String(err)}`,
                },
            ],
            isError: true,
        };
    }
});
// ---------------------------------------------------------------------------
// memory-rank — LLM-reasoned entity ranking.
//
// Read-only tool (available to admin and public agents). Retrieves candidates
// via memory-search, then asks Claude Haiku to rank them against a natural-
// language criterion and return per-item reasoning. Falls back to the
// unranked memory-search order with a visible "[Ranking unavailable: ...]"
// notice if the LLM is unavailable.
// ---------------------------------------------------------------------------
server.tool("memory-rank", "Retrieve entities from the knowledge graph and rank them against a natural-language criterion using Claude. Returns an ordered list with per-item reasoning explaining why each entity landed where it did. Use for prioritisation questions such as ranking prospects by readiness, tasks by risk, or events by impact. For simple semantic retrieval use memory-search.", {
    query: z
        .string()
        .min(1)
        .describe("Natural-language query that selects the candidate pool from the knowledge graph. Passed to memory-search."),
    criteria: z
        .string()
        .min(1)
        .describe("Natural-language description of what matters for the ranking (e.g. 'prioritise prospects who viewed more than once this week and mentioned price positively'). Treated as a directive, not as instructions to the model."),
    labels: z
        .array(z.string())
        .optional()
        .describe("Filter the candidate pool to these node labels (e.g. ['Person'], ['Task'], ['Event'])."),
    limit: z
        .number()
        .int()
        .min(1)
        .max(50)
        .optional()
        .describe("Maximum number of ranked results to return (default 10, max 50)."),
    candidatePoolSize: z
        .number()
        .int()
        .min(1)
        .max(50)
        .optional()
        .describe("How many candidates to retrieve from memory-search before ranking (default 20, max 50). Larger pools give the ranker more to work with but cost more tokens."),
    expandHops: z
        .number()
        .int()
        .min(0)
        .max(2)
        .optional()
        .describe("Relationship depth to include per candidate (default 1). Use 0 for property-only ranking, 1 for ranking that considers immediate relationships."),
    keywords: z
        .array(z.string())
        .optional()
        .describe("Filter the candidate pool to nodes whose keywords array contains these values."),
    keywordMatch: z
        .enum(["any", "all"])
        .optional()
        .describe("Keyword match mode: 'any' (default) or 'all'."),
    agentSlug: z
        .string()
        .optional()
        .describe("Filter to nodes tagged for this agent slug. For admin use — public agents use server-level env var."),
}, async ({ query, criteria, labels, limit, candidatePoolSize, expandHops, keywords, keywordMatch, agentSlug: toolAgentSlug }) => {
    const effectiveAgentSlug = envAgentSlug ?? toolAgentSlug;
    process.stderr.write(`[memory-rank] [${accountId}] invocation query=${JSON.stringify(query.slice(0, 60))} criteria=${JSON.stringify(criteria.slice(0, 60))} pool=${candidatePoolSize ?? "default"} limit=${limit ?? "default"}\n`);
    try {
        const response = await memoryRank({
            query,
            criteria,
            labels,
            accountId,
            limit,
            candidatePoolSize,
            expandHops,
            allowedScopes,
            keywords,
            keywordMatch,
            agentSlug: effectiveAgentSlug,
            keywordSubscriptions: envKeywordSubscriptions,
        });
        // Three distinct empty/near-empty cases with different meanings:
        //   1. candidatePoolSize === 0 — memory-search found nothing. True empty result.
        //   2. candidatePoolSize > 0, results.length === 0, no fallback — Haiku
        //      considered the candidates and said none matched the criterion. The
        //      agent must be told that candidates existed but were rejected, not
        //      that nothing was found.
        //   3. candidatePoolSize > 0, results.length > 0 — normal ranked output
        //      (or ranked-fallback unranked output when fallbackReason is set).
        if (response.candidatePoolSize === 0) {
            return {
                content: [
                    {
                        type: "text",
                        text: "No candidates found for this query.",
                    },
                ],
            };
        }
        if (response.results.length === 0 && !response.fallbackReason) {
            // Haiku ran successfully and returned rankings: []. Candidates existed
            // but none matched the criterion. Report this honestly so the agent
            // can relay it to the user instead of claiming nothing was found.
            return {
                content: [
                    {
                        type: "text",
                        text: `Considered ${response.candidatePoolSize} candidate${response.candidatePoolSize === 1 ? "" : "s"} but none matched the criterion.`,
                    },
                ],
            };
        }
        const headerLines = [];
        if (response.fallbackReason) {
            headerLines.push(`[Ranking unavailable: ${response.fallbackReason}] — showing ${response.results.length} candidates in their original memory-search score order.`);
        }
        else {
            headerLines.push(`Ranked ${response.results.length} of ${response.candidatePoolSize} candidates.`);
            if (response.hallucinated > 0) {
                headerLines.push(`(${response.hallucinated} ranker response${response.hallucinated === 1 ? "" : "s"} referenced candidates not in the pool and were discarded.)`);
            }
        }
        const body = response.results
            .map((r) => {
            const rankLine = r.rank !== undefined ? `#${r.rank} ` : "";
            const reasoningLine = r.reasoning ? `\n  reasoning: ${r.reasoning}` : "";
            let text = `${rankLine}[${r.labels.join(", ")}] (id: ${r.nodeId}) (score: ${r.score.toFixed(3)})${reasoningLine}\n`;
            for (const [k, v] of Object.entries(r.properties)) {
                text += `  ${k}: ${String(v)}\n`;
            }
            if (r.related.length > 0) {
                text += "  Related:\n";
                for (const rel of r.related) {
                    const arrow = rel.direction === "outgoing"
                        ? `--[${rel.relationship}]-->`
                        : `<--[${rel.relationship}]--`;
                    text += `    ${arrow} [${rel.labels.join(", ")}]`;
                    const relProps = Object.entries(rel.properties)
                        .filter(([k]) => k !== "embedding" && k !== "accountId")
                        .map(([k, v]) => `${k}: ${String(v)}`)
                        .join(", ");
                    if (relProps)
                        text += ` {${relProps}}`;
                    text += "\n";
                }
            }
            return text;
        })
            .join("\n---\n");
        const output = `${headerLines.join("\n")}\n\n${body}`;
        return {
            content: [{ type: "text", text: output }],
        };
    }
    catch (err) {
        const msg = err instanceof Error ? err.message : String(err);
        process.stderr.write(`[memory-rank] [${accountId}] unhandled error: ${msg}\n`);
        return {
            content: [
                {
                    type: "text",
                    text: `Rank failed: ${msg}`,
                },
            ],
            isError: true,
        };
    }
});
if (!readOnly) {
    server.tool("memory-write", "Create a new node in the knowledge graph with automatic embedding computation.", {
        labels: z
            .array(z.string())
            .describe("Node labels (e.g. ['Person'], ['Question'], ['DefinedTerm'])"),
        properties: z
            .record(z.string(), z.unknown())
            .describe("Node properties as key-value pairs"),
        scope: z
            .string()
            .describe("Visibility scope — required. Valid values: 'public', 'shared', 'admin', or 'user:{identifier}'."),
        relationships: z
            .array(z.object({
            type: z.string().describe("Relationship type (e.g. HAS_PRICING, OFFERS)"),
            direction: z.enum(["outgoing", "incoming"]),
            targetNodeId: z.string().describe("Element ID of the target node"),
        }))
            .optional()
            .describe("Relationships to create with existing nodes"),
    }, async ({ labels, properties, scope, relationships }) => {
        try {
            const result = await memoryWrite({
                labels,
                properties: properties,
                accountId,
                scope,
                relationships,
                schema,
            });
            return {
                content: [
                    {
                        type: "text",
                        text: `Node created: [${result.labels.join(", ")}] (ID: ${result.nodeId})`,
                    },
                ],
            };
        }
        catch (err) {
            return {
                content: [
                    {
                        type: "text",
                        text: `Write failed: ${err instanceof Error ? err.message : String(err)}`,
                    },
                ],
                isError: true,
            };
        }
    });
    server.tool("memory-update", "Update properties on an existing node in the knowledge graph. Finds the node by element ID, verifies account ownership, sets the provided properties, and recomputes the embedding.", {
        nodeId: z.string().describe("Element ID of the node to update (from memory-search results)"),
        properties: z
            .record(z.string(), z.unknown())
            .describe("Properties to set or overwrite. Restricted keys (embedding, accountId, createdAt) are ignored."),
    }, async ({ nodeId, properties }) => {
        try {
            const result = await memoryUpdate({
                nodeId,
                properties: properties,
                accountId,
            });
            return {
                content: [
                    {
                        type: "text",
                        text: `Node updated: [${result.labels.join(", ")}] (ID: ${result.nodeId})`,
                    },
                ],
            };
        }
        catch (err) {
            return {
                content: [
                    {
                        type: "text",
                        text: `Update failed: ${err instanceof Error ? err.message : String(err)}`,
                    },
                ],
                isError: true,
            };
        }
    });
    server.tool("session-compact-status", "Check whether a session compaction summary exists in the graph. Returns the most recent summary title, date, and briefing text. Use this at session start to determine whether a prior session was compacted and its context should be restored.", {}, async () => {
        const session = getSession();
        try {
            const result = await session.run(`MATCH (n:CreativeWork {accountId: $accountId})
           WHERE n.title STARTS WITH 'Session Summary'
           RETURN n.title AS title, n.createdAt AS createdAt, n.abstract AS abstract
           ORDER BY n.createdAt DESC LIMIT 1`, { accountId });
            if (result.records.length === 0) {
                return { content: [{ type: "text", text: "No session summaries found." }] };
            }
            const r = result.records[0];
            const text = `Title: ${r.get("title")}\nDate: ${r.get("createdAt")}\n\n${r.get("abstract")}`;
            return { content: [{ type: "text", text }] };
        }
        catch (err) {
            return {
                content: [{ type: "text", text: `session-compact-status failed: ${err instanceof Error ? err.message : String(err)}` }],
                isError: true,
            };
        }
        finally {
            await session.close();
        }
    });
    server.tool("session-compact", "Write a session summary to the knowledge graph. Call this when compacting context — provide the full briefing and the tool handles labelling, structure, and embedding.", {
        content: z.string().describe("The session briefing — what was discussed, decisions made, work in progress, open questions, and anything learned about the user or their business"),
    }, async ({ content }) => {
        try {
            const title = `Session Summary ${new Date().toISOString().slice(0, 10)}`;
            const result = await memoryWrite({
                labels: ["CreativeWork"],
                properties: { title, abstract: content },
                accountId,
                scope: "admin",
                schema,
            });
            return {
                content: [{ type: "text", text: `Session summary saved (ID: ${result.nodeId})` }],
            };
        }
        catch (err) {
            return {
                content: [{ type: "text", text: `session-compact failed: ${err instanceof Error ? err.message : String(err)}` }],
                isError: true,
            };
        }
    });
    server.tool("memory-reindex", "Rebuild vector embeddings for all nodes missing embeddings. Run after seeding or bulk updates.", {}, async () => {
        try {
            const result = await memoryReindex(accountId);
            return {
                content: [
                    {
                        type: "text",
                        text: `Reindex complete: ${result.nodesProcessed} processed, ${result.nodesSkipped} skipped, ${result.scopeBackfilled} scope backfilled.${result.errors.length > 0 ? `\nErrors: ${result.errors.join("; ")}` : ""}`,
                    },
                ],
            };
        }
        catch (err) {
            console.error("[memory-reindex] failed:", err instanceof Error ? err.message : String(err));
            return {
                content: [
                    {
                        type: "text",
                        text: `Reindex failed: ${err instanceof Error ? err.message : String(err)}`,
                    },
                ],
                isError: true,
            };
        }
    });
    server.tool("memory-ingest-extract", "Extract text from an uploaded file and chunk it server-side. Supports PDF (via pdftotext), plain text, and markdown. " +
        "Rejects CSV (structured data — use memory-write instead) and images (metadata-only). " +
        "Returns section titles and chunk previews. Full chunk content is cached server-side — " +
        "the calling agent generates summaries from the previews, then calls memory-ingest with only summaries (no raw content needed).", {
        storagePath: z.string().describe("Absolute path to the stored file on disk"),
        filename: z.string().describe("Original filename as uploaded"),
        mimeType: z.string().describe("MIME type of the file"),
        attachmentId: z.string().describe("UUID of the file attachment — used as cache key for memory-ingest"),
    }, async ({ storagePath, filename, mimeType, attachmentId }) => {
        try {
            const result = await memoryIngestExtract({ storagePath, filename, mimeType, attachmentId });
            return {
                content: [{
                        type: "text",
                        text: JSON.stringify(result),
                    }],
            };
        }
        catch (err) {
            return {
                content: [{
                        type: "text",
                        text: `memory-ingest-extract failed for "${filename}": ${err instanceof Error ? err.message : String(err)}`,
                    }],
                isError: true,
            };
        }
    });
    server.tool("memory-ingest", "Ingest a knowledge document into the graph as a three-level hierarchy: KnowledgeDocument → Section → Chunk. " +
        "Requires a prior call to memory-ingest-extract (which caches the raw chunk content server-side). " +
        "The calling agent provides only summaries — no raw content needed. The tool retrieves cached content, " +
        "pairs it with summaries, embeds everything, and writes to the graph. " +
        "Re-ingesting with the same attachmentId replaces the existing hierarchy — safe to call on already-ingested documents.", {
        attachmentId: z.string().describe("UUID of the file attachment — must match a prior memory-ingest-extract call"),
        documentSummary: z.string().describe("LLM-generated summary of the entire document (1-3 sentences)"),
        sections: z.array(z.object({
            title: z.string().describe("Section title (must match the title from memory-ingest-extract)"),
            summary: z.string().describe("LLM-generated summary of this section (1 sentence)"),
            chunkSummaries: z.array(z.string().describe("LLM-generated summary of one chunk (1 sentence)"))
                .describe("One summary per chunk in this section, in order"),
        })).describe("One entry per section from memory-ingest-extract, in order"),
        scope: z.string().describe("Visibility scope for all created nodes — required. Valid values: 'public', 'shared', 'admin', or 'user:{identifier}'."),
        entities: z.array(z.object({
            name: z.string().describe("Entity name (for logging)"),
            nodeId: z.string().describe("Neo4j element ID of the entity node"),
        })).optional().describe("Entities referenced in the document to link via REFERENCES relationships"),
        sourceUrl: z.string().optional().describe("Original URL for web-sourced documents. Omit for file uploads."),
        sourceType: z.string().optional().describe("Provenance: 'upload' or 'web'. Omit for file uploads (defaults to no sourceType)."),
        keywords: z.array(z.string()).optional().describe("LLM-extracted topic keywords for the document. Normalized (lowercase, trimmed, deduplicated) before storage."),
        userKeywords: z.array(z.string()).optional().describe("Keywords explicitly provided by the user. Always included verbatim (after normalization). " +
            "Use for user-defined collections and classification (e.g. 'file this under quarterly reports')."),
    }, async ({ attachmentId, documentSummary, sections, scope, entities, sourceUrl, sourceType, keywords, userKeywords }) => {
        try {
            const result = await memoryIngest({
                accountId,
                attachmentId,
                documentSummary,
                sections,
                scope,
                entities,
                sourceUrl,
                sourceType,
                keywords,
                userKeywords,
            });
            return {
                content: [{
                        type: "text",
                        text: JSON.stringify({
                            documentNodeId: result.documentNodeId,
                            sectionCount: result.sectionCount,
                            chunkCount: result.chunkCount,
                            entityLinks: result.entityLinks,
                            documentSummary: result.documentSummary,
                            keywords: result.keywords,
                        }),
                    }],
            };
        }
        catch (err) {
            return {
                content: [{
                        type: "text",
                        text: `memory-ingest failed: ${err instanceof Error ? err.message : String(err)}`,
                    }],
                isError: true,
            };
        }
    });
    server.tool("memory-ingest-web", "Ingest web content into the knowledge graph. Accepts a URL and its pre-fetched readable content " +
        "(the agent calls WebFetch first, then passes the text here). " +
        "Extracts a title, writes content to a temp file, and delegates to the existing extraction + chunking pipeline. " +
        "Returns section/chunk previews for the agent to generate summaries, then call memory-ingest with sourceUrl and sourceType. " +
        "If the URL was previously ingested, returns the existing document info so the agent can inform the user.", {
        url: z.string().describe("The web page URL being ingested"),
        content: z.string().describe("The readable text content of the web page (pre-fetched by the agent via WebFetch)"),
        scope: z.string().optional().describe("Intended visibility scope (for logging). The actual scope is set when the agent subsequently calls memory-ingest."),
    }, async ({ url, content, scope }) => {
        try {
            const result = await memoryIngestWeb({
                accountId,
                url,
                content,
                scope,
            });
            const response = {
                attachmentId: result.attachmentId,
                title: result.title,
                sourceUrl: url,
                sourceType: "web",
                ...result.extract,
            };
            if (result.existingDocument) {
                response.existingDocument = result.existingDocument;
            }
            return {
                content: [{
                        type: "text",
                        text: JSON.stringify(response),
                    }],
            };
        }
        catch (err) {
            return {
                content: [{
                        type: "text",
                        text: `memory-ingest-web failed: ${err instanceof Error ? err.message : String(err)}`,
                    }],
                isError: true,
            };
        }
    });
    server.tool("memory-delete", "Delete a knowledge document from the graph. Default: soft delete (sets a deletedAt timestamp, " +
        "excludes from search, preservable for 7 days). With force=true: permanently deletes the " +
        "KnowledgeDocument and its entire hierarchy (Sections, Chunks, REFERENCES edges) from the graph, " +
        "plus removes the attachment directory from disk.", {
        attachmentId: z.string().describe("UUID of the document to delete (the attachmentId from memory-search or memory-list-attachments)"),
        force: z.boolean().optional().describe("When true, permanently delete (hard delete). Default false = soft delete with 7-day grace period."),
    }, async ({ attachmentId, force }) => {
        try {
            const result = await memoryDelete({ accountId, attachmentId, force });
            const modeLabel = result.mode === "soft" ? "Soft-deleted" : "Permanently deleted";
            let text = `${modeLabel}: ${result.attachmentId.slice(0, 8)}…`;
            if (result.mode === "soft") {
                text += `\nDocument and ${result.sections} sections, ${result.chunks} chunks excluded from search.`;
                text += `\nGrace period: 7 days. Use force=true to permanently delete.`;
                if (result.deletedAt)
                    text += `\nDeleted at: ${result.deletedAt}`;
                if (result.purgeAfter)
                    text += `\nPurge after: ${result.purgeAfter}`;
            }
            else {
                text += `\nRemoved: 1 document, ${result.sections} sections, ${result.chunks} chunks, ${result.references} references.`;
                text += result.diskRemoved ? `\nDisk files removed.` : `\nNo files on disk (web-ingested document).`;
            }
            return { content: [{ type: "text", text }] };
        }
        catch (err) {
            return {
                content: [{ type: "text", text: `memory-delete failed: ${err instanceof Error ? err.message : String(err)}` }],
                isError: true,
            };
        }
    });
    server.tool("memory-list-attachments", "List stored file attachments for this account with their ingestion status. " +
        "Returns original filenames, sizes, upload dates, and whether each file has been ingested " +
        "into the knowledge graph. Does not include web-ingested documents (those have no file on disk).", {}, async () => {
        try {
            const result = await memoryListAttachments(accountId);
            if (result.entries.length === 0) {
                return { content: [{ type: "text", text: "No stored attachments found." }] };
            }
            const lines = result.entries.map((e) => {
                const status = e.softDeleted
                    ? "deleted (pending purge)"
                    : e.ingested
                        ? "ingested"
                        : "not ingested";
                const size = e.sizeBytes < 1024
                    ? `${e.sizeBytes} B`
                    : e.sizeBytes < 1024 * 1024
                        ? `${(e.sizeBytes / 1024).toFixed(1)} KB`
                        : `${(e.sizeBytes / (1024 * 1024)).toFixed(1)} MB`;
                return `${e.filename}  (${e.mimeType}, ${size})  [${status}]\n  ID: ${e.attachmentId}  Stored: ${e.storedAt}`;
            });
            const summary = `${result.totalFiles} files (${result.ingestedCount} ingested${result.skippedCount > 0 ? `, ${result.skippedCount} skipped` : ""})`;
            return {
                content: [{ type: "text", text: `${summary}\n\n${lines.join("\n\n")}` }],
            };
        }
        catch (err) {
            return {
                content: [{ type: "text", text: `memory-list-attachments failed: ${err instanceof Error ? err.message : String(err)}` }],
                isError: true,
            };
        }
    });
    server.tool("memory-read-attachment", "Read the content of a stored attachment from disk. Returns the full text for text/markdown files, " +
        "extracted text for PDFs (via pdftotext), and metadata + HTTP URL for images. " +
        "Use this to show the user what's in a file or to prepare content for editing.", {
        attachmentId: z.string().describe("UUID of the attachment to read"),
    }, async ({ attachmentId }) => {
        try {
            const result = await memoryReadAttachment(accountId, attachmentId);
            let text = `**${result.filename}** (${result.mimeType}, ${result.sizeBytes} bytes)\nStored: ${result.storedAt}\n`;
            if (result.contentNote)
                text += `\n_${result.contentNote}_\n`;
            if (result.httpUrl)
                text += `\nURL: ${result.httpUrl}\n`;
            if (result.content)
                text += `\n---\n\n${result.content}`;
            return { content: [{ type: "text", text }] };
        }
        catch (err) {
            return {
                content: [{ type: "text", text: `memory-read-attachment failed: ${err instanceof Error ? err.message : String(err)}` }],
                isError: true,
            };
        }
    });
    server.tool("memory-edit-attachment", "Edit a stored text/markdown attachment in place. Replaces the file content on disk, updates the " +
        "metadata timestamp, and re-extracts the file for chunking (populating the extract cache). " +
        "After this call, use memory-ingest with updated summaries to sync the knowledge graph. " +
        "Only text/plain and text/markdown files are editable — PDFs and images are rejected.", {
        attachmentId: z.string().describe("UUID of the attachment to edit"),
        content: z.string().describe("The full replacement text content"),
    }, async ({ attachmentId, content }) => {
        try {
            const result = await memoryEditAttachment({ accountId, attachmentId, content });
            let text = `Edited: ${result.filename} (${result.oldSizeBytes} → ${result.newSizeBytes} bytes)`;
            if (result.cachePopulated) {
                text += `\nExtract cache populated: ${result.extractSections} sections, ${result.extractChunks} chunks.`;
                text += `\nCall memory-ingest with updated summaries to sync the knowledge graph.`;
            }
            else {
                text += `\nWARNING: File updated on disk but extract cache could not be populated.`;
                text += `\nCall memory-ingest-extract manually, then memory-ingest.`;
            }
            return { content: [{ type: "text", text }] };
        }
        catch (err) {
            return {
                content: [{ type: "text", text: `memory-edit-attachment failed: ${err instanceof Error ? err.message : String(err)}` }],
                isError: true,
            };
        }
    });
    server.tool("memory-rename-attachment", "Rename a stored attachment. Updates the filename in the .meta.json sidecar and the " +
        "KnowledgeDocument name in Neo4j (if the file has been ingested). The file on disk " +
        "is not renamed — it uses UUID-based naming. Only the human-readable metadata changes.", {
        attachmentId: z.string().describe("UUID of the attachment to rename"),
        newFilename: z.string().describe("The new filename (e.g. 'quarterly-report-q1.pdf')"),
    }, async ({ attachmentId, newFilename }) => {
        try {
            const result = await memoryRenameAttachment({ accountId, attachmentId, newFilename });
            if (result.oldFilename === result.newFilename) {
                return { content: [{ type: "text", text: `Filename unchanged: "${result.newFilename}"` }] };
            }
            let text = `Renamed: "${result.oldFilename}" → "${result.newFilename}"`;
            text += result.graphUpdated
                ? `\nKnowledge graph updated.`
                : `\nNo KnowledgeDocument in graph (file not yet ingested — metadata only).`;
            return { content: [{ type: "text", text }] };
        }
        catch (err) {
            return {
                content: [{ type: "text", text: `memory-rename-attachment failed: ${err instanceof Error ? err.message : String(err)}` }],
                isError: true,
            };
        }
    });
    // --- conversation-list formatting helpers ---
    /** Strip Neo4j DateTime timezone ID bracket (e.g. "[UTC]", "[Europe/London]") that Date.parse rejects. */
    function cleanNeo4jIso(isoStr) {
        return isoStr.replace(/\[.*\]$/, "");
    }
    /** Format an ISO timestamp to short human-readable form: "YYYY-MM-DD HH:MM UTC". Empty string if invalid. */
    function shortIso(isoStr) {
        if (!isoStr)
            return "";
        const ms = Date.parse(cleanNeo4jIso(isoStr));
        if (!Number.isFinite(ms))
            return "";
        return new Date(ms).toISOString().replace("T", " ").slice(0, 16) + " UTC";
    }
    /** Compute relative time string from an ISO timestamp. Returns null for >= 7 days or invalid input. */
    function relativeTime(isoStr) {
        if (!isoStr)
            return null;
        const ms = Date.parse(cleanNeo4jIso(isoStr));
        if (!Number.isFinite(ms))
            return null;
        const diffMs = Date.now() - ms;
        if (diffMs < 60_000)
            return "just now"; // includes future timestamps (clock drift)
        const mins = Math.floor(diffMs / 60_000);
        if (mins < 60)
            return `${mins}m ago`;
        const hours = Math.floor(mins / 60);
        if (hours < 24)
            return `${hours}h ago`;
        const days = Math.floor(hours / 24);
        if (days < 7)
            return `${days}d ago`;
        return null;
    }
    /** Format conversation header line with activity timestamps. Shared across metadata and inline modes. */
    function formatConversationHeader(opts) {
        const label = opts.name ? `"${opts.name}"` : opts.conversationId.slice(0, 8) + "…";
        const agentInfo = opts.agentSlug ? `${opts.agentType}/${opts.agentSlug}` : opts.agentType;
        const createdStr = shortIso(opts.createdAt) || "(no timestamp)";
        const updatedStr = shortIso(opts.updatedAt);
        if (!updatedStr) {
            return `${label} [${agentInfo}] · created ${createdStr} · (activity unknown) — ${opts.messageCount} messages`;
        }
        const createdMs = Date.parse(cleanNeo4jIso(opts.createdAt));
        const updatedMs = Date.parse(cleanNeo4jIso(opts.updatedAt));
        if (Number.isFinite(createdMs) && Number.isFinite(updatedMs) && Math.abs(updatedMs - createdMs) < 1000) {
            const ago = relativeTime(opts.updatedAt);
            const agoStr = ago ? ` (${ago})` : "";
            return `${label} [${agentInfo}] · created ${createdStr}${agoStr} — ${opts.messageCount} messages`;
        }
        const ago = relativeTime(opts.updatedAt);
        const agoStr = ago ? ` (${ago})` : "";
        return `${label} [${agentInfo}] · created ${createdStr} · active ${updatedStr}${agoStr} — ${opts.messageCount} messages`;
    }
    server.tool("conversation-list", "List conversations or retrieve messages from a specific conversation. " +
        "Without conversationId: lists all conversations with message counts, ordered by most recent activity (updatedAt DESC). " +
        "Headers show both creation time and last activity time; when they differ, the conversation was resumed. " +
        "With conversationId: returns all messages in that conversation chronologically with per-message timestamps. " +
        "With includeMessages: returns conversations with their timestamped messages inline (useful for batch review). " +
        "The since filter matches against activity time (updatedAt), not creation time.", {
        conversationId: z
            .string()
            .optional()
            .describe("If provided, returns messages for this conversation. If omitted, lists all conversations."),
        agentType: z
            .string()
            .optional()
            .describe("Filter conversations by agent type ('admin' or 'public'). Only applies when listing conversations."),
        agentSlug: z
            .string()
            .optional()
            .describe("Filter conversations by public agent slug (e.g. 'maxy-assistant'). Only applies when listing conversations."),
        since: z
            .string()
            .optional()
            .describe("ISO 8601 timestamp. Only return conversations with activity (updatedAt) at or after this time. Only applies when listing conversations."),
        includeMessages: z
            .boolean()
            .optional()
            .describe("When true, include message content inline with each conversation (max 50 messages per conversation). Only applies when listing conversations."),
        limit: z
            .number()
            .optional()
            .describe("Maximum results (default 50 for conversations, 200 for messages)"),
    }, async ({ conversationId, agentType, agentSlug, since, includeMessages, limit }) => {
        const startTime = Date.now();
        const dbSession = getSession();
        try {
            if (conversationId) {
                // Messages mode — chronological listing for a specific conversation
                const messageLimit = limit ?? 200;
                const result = await dbSession.run(`MATCH (m:Message)-[:PART_OF]->(c:Conversation {conversationId: $conversationId, accountId: $accountId})
             RETURN m.role AS role, m.content AS content, m.createdAt AS createdAt
             ORDER BY m.createdAt ASC
             LIMIT $limit`, { conversationId, accountId, limit: int(messageLimit) });
                if (result.records.length === 0) {
                    return {
                        content: [{ type: "text", text: "No messages found for this conversation." }],
                    };
                }
                const formatted = result.records.map((r) => {
                    const role = String(r.get("role") ?? "unknown");
                    const content = String(r.get("content") ?? "");
                    const ts = shortIso(String(r.get("createdAt") ?? ""));
                    return ts ? `[${role} ${ts}]\n  ${content}` : `[${role}]\n  ${content}`;
                });
                process.stderr.write(`[conversation-list] [${accountId}] mode=messages conversationId=${conversationId.slice(0, 8)}… count=${result.records.length} (${Date.now() - startTime}ms)\n`);
                return {
                    content: [{ type: "text", text: formatted.join("\n---\n") }],
                };
            }
            // List mode — all conversations with message counts (or inline messages)
            const convLimit = limit ?? (includeMessages ? 20 : 50);
            const whereClauses = [];
            if (agentType)
                whereClauses.push("c.agentType = $agentType");
            if (agentSlug)
                whereClauses.push("c.agentSlug = $agentSlug");
            if (since)
                whereClauses.push("c.updatedAt >= datetime($since)");
            const whereStr = whereClauses.length > 0 ? `WHERE ${whereClauses.join(" AND ")}` : "";
            const params = {
                accountId,
                limit: int(convLimit),
                ...(agentType ? { agentType } : {}),
                ...(agentSlug ? { agentSlug } : {}),
                ...(since ? { since } : {}),
            };
            if (includeMessages) {
                // Inline messages mode — conversations with message content
                const msgLimit = 50;
                const result = await dbSession.run(`MATCH (c:Conversation {accountId: $accountId})
             ${whereStr}
             WITH c ORDER BY c.updatedAt DESC LIMIT $limit
             OPTIONAL MATCH (m:Message)-[:PART_OF]->(c)
             WITH c, m ORDER BY m.createdAt ASC
             WITH c, collect({role: m.role, content: m.content, createdAt: m.createdAt})[0..${msgLimit}] AS messages
             RETURN c.conversationId AS conversationId,
                    c.agentType AS agentType,
                    c.agentSlug AS agentSlug,
                    c.name AS name,
                    c.createdAt AS createdAt,
                    c.updatedAt AS updatedAt,
                    size(messages) AS messageCount,
                    messages
             ORDER BY c.updatedAt DESC`, params);
                if (result.records.length === 0) {
                    return {
                        content: [{ type: "text", text: "No conversations found." }],
                    };
                }
                const formatted = result.records.map((r) => {
                    const id = String(r.get("conversationId") ?? "");
                    const msgCount = Number(r.get("messageCount") ?? 0);
                    const header = formatConversationHeader({
                        name: r.get("name"),
                        conversationId: id,
                        agentType: String(r.get("agentType") ?? ""),
                        agentSlug: r.get("agentSlug"),
                        createdAt: String(r.get("createdAt") ?? ""),
                        updatedAt: String(r.get("updatedAt") ?? ""),
                        messageCount: msgCount,
                    });
                    const messages = r.get("messages");
                    const validMessages = (messages || []).filter((m) => m.role != null);
                    if (validMessages.length === 0) {
                        return `### ${header}\n  (no messages)`;
                    }
                    let missingTs = 0;
                    const msgLines = validMessages.map((m) => {
                        const role = String(m.role);
                        const content = String(m.content ?? "");
                        const ts = shortIso(String(m.createdAt ?? ""));
                        if (!ts)
                            missingTs++;
                        return ts ? `[${role} ${ts}] ${content}` : `[${role}] ${content}`;
                    });
                    const footer = missingTs > 0 ? `\n(${missingTs} message${missingTs > 1 ? "s" : ""} had no timestamp)` : "";
                    return `### ${header}\n${msgLines.join("\n")}${footer}`;
                });
                process.stderr.write(`[conversation-list] [${accountId}] mode=inline agentType=${agentType ?? "all"} agentSlug=${agentSlug ?? "all"} since=${since ?? "none"} count=${result.records.length} (${Date.now() - startTime}ms)\n`);
                return {
                    content: [{ type: "text", text: formatted.join("\n\n") }],
                };
            }
            // Metadata mode — conversations with message counts only
            const result = await dbSession.run(`MATCH (c:Conversation {accountId: $accountId})
           ${whereStr}
           WITH c ORDER BY c.updatedAt DESC LIMIT $limit
           OPTIONAL MATCH (m:Message)-[:PART_OF]->(c)
           RETURN c.conversationId AS conversationId,
                  c.agentType AS agentType,
                  c.agentSlug AS agentSlug,
                  c.name AS name,
                  c.createdAt AS createdAt,
                  c.updatedAt AS updatedAt,
                  count(m) AS messageCount
           ORDER BY c.updatedAt DESC`, params);
            if (result.records.length === 0) {
                return {
                    content: [{ type: "text", text: "No conversations found." }],
                };
            }
            const formatted = result.records.map((r) => {
                const id = String(r.get("conversationId") ?? "");
                const msgCount = typeof r.get("messageCount") === "number"
                    ? r.get("messageCount")
                    : Number(r.get("messageCount"));
                return formatConversationHeader({
                    name: r.get("name"),
                    conversationId: id,
                    agentType: String(r.get("agentType") ?? ""),
                    agentSlug: r.get("agentSlug"),
                    createdAt: String(r.get("createdAt") ?? ""),
                    updatedAt: String(r.get("updatedAt") ?? ""),
                    messageCount: msgCount,
                });
            });
            process.stderr.write(`[conversation-list] [${accountId}] mode=list agentType=${agentType ?? "all"} agentSlug=${agentSlug ?? "all"} since=${since ?? "none"} count=${result.records.length} (${Date.now() - startTime}ms)\n`);
            return {
                content: [{ type: "text", text: formatted.join("\n") }],
            };
        }
        catch (err) {
            return {
                content: [
                    {
                        type: "text",
                        text: `conversation-list failed: ${err instanceof Error ? err.message : String(err)}`,
                    },
                ],
                isError: true,
            };
        }
        finally {
            await dbSession.close();
        }
    });
    server.tool("profile-update", "Create, update, or reinforce a user preference, and set top-level UserProfile fields (timezone, locale, givenName, role, expertise) via the profileFields parameter. Supports modes: reinforce (re-observed, boost confidence), update (value changed), contradict (value contradicts prior), merge (combine overlapping preferences).", {
        category: z.enum(["communication", "scheduling", "decision", "workflow", "content", "interaction"])
            .describe("Preference category"),
        key: z.string().describe("Preference identifier (e.g. 'response_length', 'meeting_time')"),
        value: z.string().describe("The preference value (e.g. 'terse', 'never before 10am')"),
        source: z.enum(["explicit", "behavioural"]).optional()
            .describe("How the preference was observed. Defaults to 'behavioural'."),
        mode: z.enum(["reinforce", "update", "contradict", "merge"]).optional()
            .describe("Update mode. Defaults to 'reinforce'."),
        conversationId: z.string().optional()
            .describe("Conversation ID for evidence linking"),
        profileFields: z.record(z.string(), z.unknown()).optional()
            .describe("Top-level UserProfile fields to update (e.g. timezone, locale, role)"),
        mergeSourceIds: z.array(z.string()).optional()
            .describe("For mode 'merge': preferenceIds of sources to combine into this preference"),
    }, async ({ category, key, value, source, mode, conversationId, profileFields, mergeSourceIds }) => {
        try {
            if (!userId) {
                return { content: [{ type: "text", text: "profile-update requires an authenticated admin session with userId" }], isError: true };
            }
            const result = await profileUpdate({
                accountId,
                userId,
                category,
                key,
                value,
                source,
                mode,
                conversationId,
                profileFields: profileFields,
                mergeSourceIds,
            });
            return {
                content: [{
                        type: "text",
                        text: `Preference ${result.action}: ${category}/${key} (confidence: ${result.confidence.toFixed(2)}${result.mergedCount ? `, merged ${result.mergedCount} sources` : ""})`,
                    }],
            };
        }
        catch (err) {
            return {
                content: [{
                        type: "text",
                        text: `profile-update failed: ${err instanceof Error ? err.message : String(err)}`,
                    }],
                isError: true,
            };
        }
    });
    server.tool("profile-delete", "Delete a specific user preference. Use when the owner says 'forget that' or 'don't remember X'. Idempotent — succeeds even if the preference doesn't exist.", {
        category: z.enum(["communication", "scheduling", "decision", "workflow", "content", "interaction"])
            .describe("Preference category"),
        key: z.string().describe("Preference key to delete"),
    }, async ({ category, key }) => {
        try {
            if (!userId) {
                return { content: [{ type: "text", text: "profile-delete requires an authenticated admin session with userId" }], isError: true };
            }
            const result = await profileDelete({ accountId, userId, category, key });
            return {
                content: [{
                        type: "text",
                        text: result.deleted
                            ? `Deleted preference: ${category}/${key}`
                            : `No preference found for ${category}/${key} — nothing to delete.`,
                    }],
            };
        }
        catch (err) {
            return {
                content: [{
                        type: "text",
                        text: `profile-delete failed: ${err instanceof Error ? err.message : String(err)}`,
                    }],
                isError: true,
            };
        }
    });
    // profile-read contains owner PII (name, role, timezone, preferences) —
    // admin-only, not exposed to the public agent.
    server.tool("profile-read", "Read the business owner's profile and preferences. Returns preferences grouped by category (communication, scheduling, decisions, workflow, content, interaction). " +
        "With detail=true: includes per-preference confidence scores, evidence trail (which conversations the preference was observed in), source (explicit vs behavioural), and summary statistics. " +
        "Use detail=true when the owner asks 'what do you know about me?' or when reviewing memory accuracy.", {
        detail: z.boolean().optional().describe("When true, returns per-preference confidence, source, evidence trail, and memory statistics. " +
            "Default false returns a compact summary for system prompt injection."),
    }, async ({ detail }) => {
        try {
            if (!userId) {
                return { content: [{ type: "text", text: "profile-read requires an authenticated admin session with userId" }], isError: true };
            }
            const result = await profileRead({ accountId, userId, detail: detail ?? false });
            return {
                content: [{ type: "text", text: result.summary }],
            };
        }
        catch (err) {
            return {
                content: [
                    {
                        type: "text",
                        text: `profile-read failed: ${err instanceof Error ? err.message : String(err)}`,
                    },
                ],
                isError: true,
            };
        }
    });
}
// conversation-search is read-only — available regardless of readOnly mode.
// When SESSION_ID is set (public agent), results are scoped to that conversation only.
server.tool("conversation-search", "Search conversation history using semantic vector search. Returns relevant past messages, ordered by relevance. Use this to recall specific past interactions.", {
    query: z.string().describe("Natural language search query"),
    limit: z
        .number()
        .optional()
        .describe("Maximum number of results (default 10)"),
}, async ({ query, limit: maxResults }) => {
    try {
        const queryEmbedding = await embed(query);
        const resultLimit = maxResults ?? 10;
        const dbSession = getSession();
        try {
            const sessionClause = sessionId
                ? "AND node.conversationId = $sessionId"
                : "";
            const result = await dbSession.run(`CALL db.index.vector.queryNodes('message_embedding', $limit, $embedding)
           YIELD node, score
           WHERE node.accountId = $accountId
           ${sessionClause}
           RETURN node.messageId AS messageId,
                  node.role AS role,
                  node.content AS content,
                  node.conversationId AS conversationId,
                  node.createdAt AS createdAt,
                  score
           ORDER BY score DESC
           LIMIT $limit`, {
                embedding: queryEmbedding,
                limit: int(resultLimit),
                accountId,
                ...(sessionId ? { sessionId } : {}),
            });
            if (result.records.length === 0) {
                return {
                    content: [{ type: "text", text: "No conversation history found for this query." }],
                };
            }
            const formatted = result.records.map((r) => {
                const role = r.get("role");
                const content = r.get("content");
                const score = typeof r.get("score") === "number" ? r.get("score") : Number(r.get("score"));
                const createdAt = String(r.get("createdAt"));
                return `[${role}] (score: ${score.toFixed(3)}, ${createdAt})\n  ${content}`;
            });
            return {
                content: [{ type: "text", text: formatted.join("\n---\n") }],
            };
        }
        finally {
            await dbSession.close();
        }
    }
    catch (err) {
        return {
            content: [
                {
                    type: "text",
                    text: `Conversation search failed: ${err instanceof Error ? err.message : String(err)}`,
                },
            ],
            isError: true,
        };
    }
});
// Cleanup on exit
process.on("SIGINT", async () => {
    await closeDriver();
    process.exit(0);
});
const transport = new StdioServerTransport();
await server.connect(transport);
//# sourceMappingURL=index.js.map