import { readFile, unlink } from "node:fs/promises";
import { relative, resolve, sep } from "node:path";
import { getSession } from "../lib/neo4j.js";
import { withWriteEvent } from "../lib/log-ingest.js";
import { embed, embedBatch } from "../lib/embeddings.js";
import { extractCache } from "./memory-ingest-extract.js";
import { deleteDocumentChildren } from "../lib/document-hierarchy.js";
import { restoreNode } from "../../../../../lib/graph-trash/dist/index.js";
import { IDENTITY_SECTION_KINDS, STRUCTURAL_SECTION_KINDS, CONTRACT_SECTION_KINDS, STANDALONE_NODE_KINDS, SECTION_KIND_OTHER, } from "../lib/section-types.js";
import { isConversationSource } from "../lib/conversation-normalisers/types.js";
import { turnTextCachePath } from "../lib/conversation-pipeline/turn-text-cache-path.js";
import { isAllowedTypedEdge, isTypedEntityEdgeType } from "../lib/typed-edge-schema.js";
// ---------------------------------------------------------------------------
// Single-Section document ingestion (replaces the typed-vs-UNMAPPED fork).
//
// CACHE LOOKUP --> DERIVE BODIES --> EMBED --> MERGE/REVIVE DOC --> CLEANUP --> WRITE
//   (attachmentId)  cachedText.slice  batch     KnowledgeDocument   prior     Section nodes
//                   (sourceStart,     Ollama    (idempotent)        children  + secondary labels
//                    sourceEnd)                                               + NEXT chain
//                                                                             + anchor edges
//                                                                             + related entities
//                                                                             + KD-level edges
//
// Body authority: the server slices :Section.body from a non-LLM source of
// text using the classifier-supplied (sourceStart, sourceEnd) offsets. On the
// document path the source is `extractCache` (an in-process Map keyed by
// `attachmentId`, populated by `memory-ingest-extract`). On the
// conversation-archive path the source is a per-session tempfile written by
// `conversation-archive-ingest.mjs` at `turnTextCachePath(conversationIdentity,
// archiveSha256, sessionIndex)` — the bin and writer run in separate
// processes so the cache is a deterministic disk path instead of a shared
// Map. Either way, any `body` field the classifier supplies is logged and
// ignored — the classifier is a chunker, not the authoritative source of
// stored text. (Task 465 closed this on the document path; Task 468 closed
// it on the conversation-archive path.)
//
// Every classified section produces ONE `:Section` node. When the classifier
// recognises the kind (Position/Education/Chapter/Parties/etc.), the same
// node carries a secondary label (`:Section:Position`) and any structured
// properties; identity-kind anchor edges go to the multi-labeled node
// directly, killing the parallel Section-vs-typed-node concept.
//
// Special-case writers fire for two contract-clause kinds:
//   * Parties:     `(:KnowledgeDocument)-[:PARTY]->(:Person|:Organization)` from documentEdges.
//   * Definitions: `(:Section:Definitions)-[:DEFINES]->(:DefinedTerm)` from related entries.
//
// `:Chunk` is gone. Sections carry their body directly. If a body legitimately
// exceeds Neo4j's property limit, that is a classifier-split-the-section
// concern, not a writer problem.
//
// Provenance properties (createdByAgent, createdBySession, source,
// sourceDocumentId) stamp every node and edge so re-ingest cleanup finds
// them deterministically.
// ---------------------------------------------------------------------------
const PREVIEW_LENGTH = 150;
const PROVENANCE_AGENT = "document-ingest";
const PROVENANCE_SOURCE = "document";
// ---------------------------------------------------------------------------
// Section-body derivation. Shared by both ingest paths (Task 465 doc path,
// Task 468 conversation-archive path).
//
// The classifier emits (sourceStart, sourceEnd) offsets into a non-LLM
// source of text — `cachedText`. The server slices that text to produce
// :Section.body so the LLM cannot smuggle a paraphrase, summary, or
// hallucination into stored content. The classifier's own `body` field, if
// present, is logged and ignored — never written.
//
// `sourceKey` is the identity the caller uses for log attribution
// (`attachmentId` on the document path, `conversationIdentity` on the chat
// path); it is grep-able in the structured log lines.
//
// Throws on invalid offsets (negative, reversed, out-of-bounds) so a
// classifier defect produces a loud, attributable failure instead of
// silently-truncated section nodes.
// ---------------------------------------------------------------------------
export function deriveSectionBodies(sections, cachedText, sourceKey = "unknown") {
    const textLength = cachedText.length;
    return sections.map((section, i) => {
        const { sourceStart, sourceEnd, body: agentBody } = section;
        if (!Number.isInteger(sourceStart) || sourceStart < 0) {
            throw new Error(`[memory-ingest:derive] invalid sourceStart: sectionIndex=${i} ` +
                `sourceStart=${sourceStart} sourceKey=${sourceKey}`);
        }
        if (!Number.isInteger(sourceEnd) || sourceEnd < 0) {
            throw new Error(`[memory-ingest:derive] invalid sourceEnd: sectionIndex=${i} ` +
                `sourceEnd=${sourceEnd} sourceKey=${sourceKey}`);
        }
        if (sourceStart > sourceEnd) {
            throw new Error(`[memory-ingest:derive] reversed offsets: sectionIndex=${i} ` +
                `sourceStart=${sourceStart} sourceEnd=${sourceEnd} sourceKey=${sourceKey}`);
        }
        if (sourceEnd > textLength) {
            throw new Error(`[memory-ingest:derive] out-of-bounds sourceEnd: sectionIndex=${i} ` +
                `sourceEnd=${sourceEnd} textLength=${textLength} sourceKey=${sourceKey}`);
        }
        if (agentBody !== undefined) {
            const agentBodyPreview = JSON.stringify(agentBody.slice(0, 120));
            console.error(`[memory-ingest:derive] agent body suppressed: sectionIndex=${i} ` +
                `agentBodyLength=${agentBody.length} ` +
                `agentBodyPreview=${agentBodyPreview} sourceKey=${sourceKey}`);
        }
        if (sourceStart === sourceEnd) {
            console.error(`[memory-ingest:derive] zero-width section: sectionIndex=${i} ` +
                `offset=${sourceStart} sourceKey=${sourceKey}`);
            return "";
        }
        return cachedText.slice(sourceStart, sourceEnd);
    });
}
/** Identity-kind anchor edge writer set (UserProfile → Section:Kind). */
const IDENTITY_KINDS_SET = new Set(IDENTITY_SECTION_KINDS);
/** Standalone (non-Section) node kinds (currently just Project). */
const STANDALONE_KINDS_SET = new Set(STANDALONE_NODE_KINDS);
/** All section-shaped kinds — anything that becomes a `:Section` node. */
const SECTION_LABEL_KINDS = new Set([
    ...IDENTITY_SECTION_KINDS,
    ...STRUCTURAL_SECTION_KINDS,
    ...CONTRACT_SECTION_KINDS,
    SECTION_KIND_OTHER,
]);
// ---------------------------------------------------------------------------
// Section-properties reserved-key strip (Task 469).
//
// `section.properties` is an unconstrained Zod record. The writer spreads it
// onto every node it creates, with `baseProps` stamped on top. That spread
// order means any writer-owned key the agent puts into `properties` lands
// verbatim on the node UNLESS `baseProps` also carries that key — which would
// be the next paraphrase channel after Task 465 closed `body` via server-side
// slicing. The most attractive one is `summary`, because `:Section.summary` is
// the operator-visible compressed view of the section's content.
//
// Strip every key the writer also writes as an authoritative field at the
// spread boundary. The acceptance is "top-level `section.summary` is the
// source of truth for `:Section.summary` on both paths" — so `summary` lives
// in `baseProps` and is reserved against the properties bag.
// ---------------------------------------------------------------------------
export const RESERVED_SECTION_PROPERTY_KEYS = new Set([
    "accountId",
    "title",
    "body",
    "bodyPreview",
    "summary",
    "embedding",
    "position",
    "scope",
    "createdAt",
    "updatedAt",
    "createdByAgent",
    "createdBySource",
    "createdBySession",
    "source",
    "sourceDocumentId",
    "archiveSha256",
    "archiveSourceFile",
]);
/**
 * Drop reserved writer-owned keys from an agent-supplied `section.properties`
 * bag. When any keys are stripped, emit one grep-able log line naming the
 * keys and the context (attachmentId or conversationIdentity:archiveSha256)
 * so the operator can attribute the smuggle attempt to a specific ingest.
 *
 * Mirrors the Task 465 `agent body suppressed` log shape — a quiet strip
 * with a loud trail. Returns a new object; never mutates the input.
 */
export function stripReservedSectionProperties(properties, ctx) {
    const stripped = [];
    const cleaned = {};
    for (const [key, value] of Object.entries(properties)) {
        if (RESERVED_SECTION_PROPERTY_KEYS.has(key)) {
            stripped.push(key);
        }
        else {
            cleaned[key] = value;
        }
    }
    if (stripped.length > 0) {
        console.error(`[memory-ingest] reserved-property-keys-stripped ` +
            `kind=${ctx.kind} keys=${stripped.join(",")} ${ctx.identity}`);
    }
    return cleaned;
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function normaliseKeywords(arr) {
    if (!arr || arr.length === 0)
        return [];
    return arr.map((k) => k.toLowerCase().trim()).filter(Boolean);
}
/**
 * Identifying property for MERGE on a related node by kind.
 * Choose a stable, human-recognisable key per label so the same real-world
 * entity collapses to one node across documents.
 */
function mergeKeyFor(kind, properties) {
    switch (kind) {
        case "Organization":
            return typeof properties.name === "string" && properties.name.trim()
                ? { key: "name", value: properties.name.trim() }
                : null;
        case "Person":
            if (typeof properties.email === "string" && properties.email.trim()) {
                return { key: "email", value: properties.email.trim() };
            }
            if (typeof properties.telephone === "string" && properties.telephone.trim()) {
                return { key: "telephone", value: properties.telephone.trim() };
            }
            return null;
        case "DefinedTerm":
            return typeof properties.name === "string" && properties.name.trim()
                ? { key: "name", value: properties.name.trim() }
                : null;
        case "Credential":
            return typeof properties.name === "string" && properties.name.trim()
                ? { key: "name", value: properties.name.trim() }
                : null;
        default:
            return null;
    }
}
function bumpKind(breakdown, kind) {
    breakdown[kind] = (breakdown[kind] ?? 0) + 1;
}
function bumpEdge(breakdown, edgeType) {
    breakdown[edgeType] = (breakdown[edgeType] ?? 0) + 1;
}
// ---------------------------------------------------------------------------
// Main entry point
// ---------------------------------------------------------------------------
export async function memoryIngest(params) {
    // Exactly one of `attachmentId` or `conversationIdentity` is set; the
    // dispatcher signal is *which one*. The document path MERGEs under
    // `:KnowledgeDocument`; the conversation path MERGEs under
    // `:ConversationArchive` (Task 397).
    if (!params.attachmentId && !params.conversationIdentity) {
        throw new Error("memory-ingest: exactly one of `attachmentId` (document path) or `conversationIdentity` (conversation-archive path) must be set");
    }
    if (params.attachmentId && params.conversationIdentity) {
        throw new Error("memory-ingest: `attachmentId` and `conversationIdentity` are mutually exclusive — set exactly one");
    }
    const mergeKey = (params.attachmentId ?? params.conversationIdentity);
    const parentLabel = params.conversationIdentity ? "ConversationArchive" : "KnowledgeDocument";
    return withWriteEvent({ tool: "memory-ingest", accountId: params.accountId, labels: [parentLabel], name: mergeKey }, () => memoryIngestImpl(params));
}
async function memoryIngestImpl(params) {
    // Conversation-archive branch. Diverges enough from the document path
    // (no extract cache, no anchor edges, no related entities, delta-append
    // semantics) that it has its own end-to-end implementation in
    // `ingestConversationDocument()`. The conversation writer MERGEs under
    // `:ConversationArchive`; the document writer below MERGEs under
    // `:KnowledgeDocument`.
    if (params.conversationIdentity) {
        return ingestConversationDocument(params);
    }
    const { accountId, documentSummary, anchorNodeId, anchorLabel, sections, documentEdges = [], orphanCandidates = [], scope, sourceUrl, sourceType, documentKeywords: rawDocKeywords, userKeywords: rawUserKeywords, sessionId, } = params;
    // The dispatcher already verified exactly one of attachmentId /
    // conversationIdentity is set. On this branch attachmentId is the one.
    const attachmentId = params.attachmentId;
    if (!scope) {
        throw new Error("scope is required — valid values: 'public', 'shared', 'admin', 'user:{identifier}'");
    }
    if (!anchorNodeId) {
        throw new Error("anchorNodeId is required — the document subject's element ID (UserProfile/LocalBusiness/Person/Organization)");
    }
    if (!anchorLabel) {
        throw new Error("anchorLabel is required — the anchor node's primary label");
    }
    const keywords = (() => {
        const u = normaliseKeywords(rawUserKeywords);
        const d = normaliseKeywords(rawDocKeywords);
        if (u.length === 0 && d.length === 0)
            return undefined;
        return [...new Set([...u, ...d])];
    })();
    const t0 = Date.now();
    const log = (stage, detail) => console.error(`[memory-ingest] [${attachmentId.slice(0, 8)}] ${stage}${detail ? ` — ${detail}` : ""} (${Date.now() - t0}ms)`);
    log("start", `${sections.length} sections, scope=${scope}, anchor=${anchorLabel}`);
    const cached = extractCache.get(attachmentId);
    if (!cached) {
        throw new Error(`No cached extract found for attachment "${attachmentId}". ` +
            `Call memory-ingest-extract first.`);
    }
    log("cache-hit", cached.filename);
    const { filename, mimeType, text: cachedText } = cached;
    const now = new Date().toISOString();
    // 1. Derive every section's body from the cached document text. The
    //    classifier's offsets are the only trusted input; any `body` field
    //    it supplied is logged and ignored. Invalid offsets throw.
    const sectionBodies = deriveSectionBodies(sections, cachedText, attachmentId);
    // 2. Embed document summary + every server-derived section body in one batch.
    const textsToEmbed = [documentSummary, ...sectionBodies];
    log("embedding", `${textsToEmbed.length} texts`);
    const embeddings = await embedBatch(textsToEmbed);
    log("embedded", `${embeddings.length} vectors`);
    const docEmbedding = embeddings[0];
    const dbSession = getSession();
    let documentNodeId = "";
    const kindBreakdown = {};
    const edgeBreakdown = {};
    let relatedCount = 0;
    let standaloneCount = 0;
    try {
        // 2. Trash-revival — restore a soft-deleted KnowledgeDocument
        //    with this attachmentId so MERGE finds the existing node.
        const trashedDoc = await dbSession.run(`MATCH (d:KnowledgeDocument:Trashed)
       WHERE d.accountId = $accountId
         AND d._trashedKeys IS NOT NULL
         AND d._trashedKeys CONTAINS $attachmentId
       RETURN elementId(d) AS eid LIMIT 1`, { accountId, attachmentId });
        if (trashedDoc.records.length > 0) {
            const eid = trashedDoc.records[0].get("eid");
            await restoreNode({ session: dbSession, accountId, elementId: eid });
            log("revived", `restored trashed KnowledgeDocument elementId=${eid}`);
        }
        // 3. MERGE the KnowledgeDocument parent. Optional fields (sourceUrl,
        //    sourceType, keywords) are SET only when present so re-ingest of a
        //    file upload doesn't null out a previously-set web-source URL.
        const optionalSets = [];
        const optionalParams = {};
        if (sourceUrl !== undefined) {
            optionalSets.push("d.sourceUrl = $sourceUrl");
            optionalParams.sourceUrl = sourceUrl;
        }
        if (sourceType !== undefined) {
            optionalSets.push("d.sourceType = $sourceType");
            optionalParams.sourceType = sourceType;
        }
        if (keywords !== undefined) {
            optionalSets.push("d.keywords = $keywords");
            optionalParams.keywords = keywords;
        }
        // Task 524 — persist the real on-disk source path so the sidebar download
        // resolver can serve the file directly (via=stored) instead of guessing a
        // location class. Skipped for web ingests: `memory-ingest-web` extracts
        // from a tmpfile it unlinks immediately after, so the cached storagePath
        // would be a dead pointer. Web docs stay non-downloadable by design.
        if (sourceType !== "web" && cached.storagePath) {
            optionalSets.push("d.sourcePath = $sourcePath");
            optionalParams.sourcePath = cached.storagePath;
        }
        const optionalSetClause = optionalSets.length > 0 ? ", " + optionalSets.join(", ") : "";
        const docResult = await dbSession.run(`MERGE (d:KnowledgeDocument { attachmentId: $attachmentId })
       SET d.accountId      = $accountId,
           d.name           = $filename,
           d.encodingFormat = $mimeType,
           d.summary        = $documentSummary,
           d.sectionCount   = $sectionCount,
           d.scope          = $scope,
           d.embedding      = $embedding,
           d.createdAt      = coalesce(d.createdAt, $createdAt),
           d.updatedAt      = $updatedAt
           ${optionalSetClause}
       REMOVE d.deletedAt
       RETURN elementId(d) AS nodeId`, {
            attachmentId,
            accountId,
            filename,
            mimeType,
            documentSummary,
            sectionCount: sections.length,
            scope,
            embedding: docEmbedding,
            createdAt: now,
            updatedAt: now,
            ...optionalParams,
        });
        documentNodeId = docResult.records[0].get("nodeId");
        console.error(`[memory-ingest] KnowledgeDocument.name=${JSON.stringify(filename)} attachmentId=${attachmentId.slice(0, 8)} docId=${documentNodeId.slice(0, 12)}`);
        // Task 518 — reverse-MERGE the INGESTED_AS edge from a pre-existing
        // :FileArtifact (Task 514) at the same physical file. Closes the race
        // window where a file is uploaded (FileArtifact upsert) BEFORE this
        // document is ingested; the file-index walker would otherwise only link
        // on the next reconcile pass. Only fires when sourcePath was persisted
        // (file-backed ingests, not web), resolves under DATA_ROOT (claude-uploads
        // KDs stay unlinked by design), and the data root env is set. A missing
        // env is silent — module init must never throw on per-account env, and a
        // missing link self-heals on the next file-index reconcile.
        if (sourceType !== "web" && cached.storagePath) {
            const dataRoot = process.env.MAXY_DATA_ROOT ??
                process.env.DATA_ROOT ??
                process.env.PLATFORM_DATA_ROOT ??
                null;
            if (dataRoot) {
                const sourcePath = cached.storagePath;
                const rootReal = resolve(dataRoot);
                const sourceReal = resolve(sourcePath);
                const rel = relative(rootReal, sourceReal);
                const underRoot = rel.length > 0 && !rel.startsWith("..") && !rel.startsWith(`..${sep}`);
                if (underRoot) {
                    const relativePath = rel.split(sep).join("/");
                    const linkRes = await dbSession.run(`MATCH (d:KnowledgeDocument) WHERE elementId(d) = $docId
             OPTIONAL MATCH (f:FileArtifact {accountId: $accountId, relativePath: $relativePath})
             FOREACH (_ IN CASE WHEN f IS NULL THEN [] ELSE [1] END |
               MERGE (f)-[:INGESTED_AS]->(d))
             RETURN elementId(f) AS fileId`, { docId: documentNodeId, accountId, relativePath });
                    const fileId = linkRes.records[0]?.get("fileId");
                    if (fileId) {
                        console.error(`[memory-ingest] linked-file-artifact account=${accountId} relativePath="${relativePath}" fileId=${fileId.slice(0, 12)}`);
                    }
                }
            }
        }
        // 4. Cleanup prior children (idempotent re-ingest). Removes Section nodes
        //    (any secondary label) and any standalone nodes stamped with this
        //    attachmentId. MERGEd related entities (Organizations, Persons) are spared.
        const cleanup = await deleteDocumentChildren(attachmentId, dbSession);
        if (cleanup.sections > 0 || cleanup.chunks > 0 || cleanup.typed > 0 || cleanup.references > 0) {
            log("cleanup", `deleted ${cleanup.sections} sections, ${cleanup.chunks} chunks, ${cleanup.typed} typed, ${cleanup.references} references`);
        }
        // 5. Per-section writes. Track the previous section's elementId so we can
        //    chain (:Section)-[:NEXT]->(:Section) in reading order.
        let previousSectionId = null;
        for (let i = 0; i < sections.length; i++) {
            const section = sections[i];
            const bodyEmbedding = embeddings[i + 1]; // +1 because index 0 is docEmbedding
            const sectionBody = sectionBodies[i];
            console.error(`[memory-ingest] section-body-derived sectionIndex=${i} ` +
                `kind=${section.kind} sourceStart=${section.sourceStart} ` +
                `sourceEnd=${section.sourceEnd} bodyLength=${sectionBody.length} ` +
                `attachmentId=${attachmentId}`);
            const baseProps = {
                accountId,
                title: section.title,
                body: sectionBody,
                bodyPreview: sectionBody.slice(0, PREVIEW_LENGTH),
                summary: section.summary,
                position: i,
                scope,
                embedding: bodyEmbedding,
                createdAt: now,
                updatedAt: now,
                createdByAgent: PROVENANCE_AGENT,
                createdBySource: PROVENANCE_AGENT,
                createdBySession: sessionId ?? "",
                source: PROVENANCE_SOURCE,
                sourceDocumentId: attachmentId,
            };
            // 5a. Standalone node kind (currently just Project) — no `:Section` label.
            if (STANDALONE_KINDS_SET.has(section.kind)) {
                const standaloneId = await writeStandaloneNode(dbSession, section, baseProps, anchorNodeId, anchorLabel, attachmentId, sessionId ?? "", now);
                standaloneCount += 1;
                bumpKind(kindBreakdown, section.kind);
                if (section.anchorEdge)
                    bumpEdge(edgeBreakdown, section.anchorEdge.type);
                previousSectionId = null; // standalone breaks the section chain
                // related entities for standalone (e.g. Project UNDER Organization)
                if (section.related && section.related.length > 0) {
                    for (const related of section.related) {
                        await writeRelatedAndEdge(dbSession, standaloneId, section.kind, related, accountId, now, sessionId ?? "", attachmentId, anchorNodeId, anchorLabel);
                        relatedCount += 1;
                        bumpEdge(edgeBreakdown, related.edge.type);
                    }
                }
                continue;
            }
            // 5b. Section-shaped kind (everything else, including Other). One `:Section`
            //     node with optional secondary label.
            const isKnownSectionKind = SECTION_LABEL_KINDS.has(section.kind);
            const sectionLabels = isKnownSectionKind && section.kind !== SECTION_KIND_OTHER
                ? [`Section`, section.kind]
                : section.kind === SECTION_KIND_OTHER
                    ? [`Section`, `Other`]
                    : [`Section`]; // unrecognised kind — should never happen post-classifier
            // Compose properties: classifier-supplied properties overlaid on system
            // fields, system fields winning. For Section:Other, also stamp
            // classifierReason so the ontology-growth query can surface it.
            // Reserved writer-owned keys are stripped before the spread (Task 469)
            // so an agent cannot smuggle a paraphrase via properties.summary or
            // any other authoritative field.
            const sectionProps = {
                ...stripReservedSectionProperties(section.properties, {
                    kind: section.kind,
                    identity: `attachmentId=${attachmentId}`,
                }),
                ...baseProps,
                ...(section.kind === SECTION_KIND_OTHER && section.classifierReason
                    ? { classifierReason: section.classifierReason }
                    : {}),
            };
            const labelClause = sectionLabels.map((l) => `\`${l}\``).join(":");
            const sectionResult = await dbSession.run(`CREATE (s:${labelClause})
         SET s = $props
         WITH s
         MATCH (d:KnowledgeDocument) WHERE elementId(d) = $docId
         CREATE (d)-[hs:HAS_SECTION]->(s)
         SET hs.createdByAgent   = $createdByAgent,
             hs.createdBySession = $createdBySession,
             hs.source           = $source,
             hs.sourceDocumentId = $sourceDocumentId,
             hs.createdAt        = $createdAt
         RETURN elementId(s) AS nodeId`, {
                props: sectionProps,
                docId: documentNodeId,
                createdByAgent: PROVENANCE_AGENT,
                createdBySession: sessionId ?? "",
                source: PROVENANCE_SOURCE,
                sourceDocumentId: attachmentId,
                createdAt: now,
            });
            const sectionId = sectionResult.records[0].get("nodeId");
            bumpKind(kindBreakdown, section.kind);
            bumpEdge(edgeBreakdown, "HAS_SECTION");
            // 5c. NEXT chain in reading order.
            if (previousSectionId) {
                await dbSession.run(`MATCH (a:Section), (b:Section)
           WHERE elementId(a) = $prev AND elementId(b) = $cur
           CREATE (a)-[n:NEXT]->(b)
           SET n.createdByAgent   = $createdByAgent,
               n.createdBySession = $createdBySession,
               n.source           = $source,
               n.sourceDocumentId = $sourceDocumentId,
               n.createdAt        = $createdAt`, {
                    prev: previousSectionId,
                    cur: sectionId,
                    createdByAgent: PROVENANCE_AGENT,
                    createdBySession: sessionId ?? "",
                    source: PROVENANCE_SOURCE,
                    sourceDocumentId: attachmentId,
                    createdAt: now,
                });
                bumpEdge(edgeBreakdown, "NEXT");
            }
            previousSectionId = sectionId;
            // 5d. Anchor edge (identity kinds only). Writer applies the edge the
            //     classifier proposed; never invents.
            if (section.anchorEdge && IDENTITY_KINDS_SET.has(section.kind)) {
                const direction = section.anchorEdge.direction;
                const edgeType = section.anchorEdge.type;
                const edgeProps = {
                    ...(section.anchorEdge.properties ?? {}),
                    createdByAgent: PROVENANCE_AGENT,
                    createdBySession: sessionId ?? "",
                    source: PROVENANCE_SOURCE,
                    sourceDocumentId: attachmentId,
                    createdAt: now,
                };
                const cypher = direction === "from-anchor"
                    ? `MATCH (a:\`${anchorLabel}\`) WHERE elementId(a) = $anchorId AND a.accountId = $accountId
             MATCH (s:Section) WHERE elementId(s) = $sectionId
             CREATE (a)-[edge:\`${edgeType}\`]->(s)
             SET edge += $edgeProps`
                    : `MATCH (a:\`${anchorLabel}\`) WHERE elementId(a) = $anchorId AND a.accountId = $accountId
             MATCH (s:Section) WHERE elementId(s) = $sectionId
             CREATE (s)-[edge:\`${edgeType}\`]->(a)
             SET edge += $edgeProps`;
                await dbSession.run(cypher, { anchorId: anchorNodeId, sectionId, accountId, edgeProps });
                bumpEdge(edgeBreakdown, edgeType);
            }
            // 5e. Related entities (Organization for AT, DefinedTerm for DEFINES on
            //     :Section:Definitions, etc.). Apply each as the classifier emitted.
            //     Typed-entity edges (FOUNDED, WORKS_AT, INVESTED_IN, ADVISES) opt
            //     into anchor-routing via `edge.targetMode = "anchor"` so the edge
            //     lands on the document anchor (e.g. the company's `:Organization`)
            //     instead of the section node — Task 482.
            if (section.related && section.related.length > 0) {
                for (const related of section.related) {
                    await writeRelatedAndEdge(dbSession, sectionId, "Section", related, accountId, now, sessionId ?? "", attachmentId, anchorNodeId, anchorLabel);
                    relatedCount += 1;
                    bumpEdge(edgeBreakdown, related.edge.type);
                }
            }
        }
        // 6. Document-level edges (PARTY for contracts, PARTICIPANT for meetings/calls,
        //    FROM/TO/CC for emails, SPEAKER for voice-notes). Applied off the
        //    KnowledgeDocument; classifier proposes, writer applies.
        if (documentEdges && documentEdges.length > 0) {
            const docEdgeTypes = Array.from(new Set(documentEdges.map((e) => e.type))).sort().join(",");
            console.error(`[memory-ingest] documentEdges count=${documentEdges.length} types=${docEdgeTypes}`);
            for (const docEdge of documentEdges) {
                await writeDocumentEdge(dbSession, documentNodeId, docEdge, accountId, now, sessionId ?? "", attachmentId);
                relatedCount += 1;
                bumpEdge(edgeBreakdown, docEdge.type);
            }
        }
        // Reclassify-gate stamp (Task 401). Records what body length the
        // sections that now hang off this KD were classified against, so a
        // future kd-classify can decide whether the KD has grown enough to
        // warrant another pass. Only stamped when the KD carries a `body`
        // (thread-shaped KDs like email today; bodyless attachment KDs are
        // left alone — the gate has no meaning there).
        await dbSession.run(`MATCH (d:KnowledgeDocument) WHERE elementId(d) = $docId
       WITH d
       WHERE d.body IS NOT NULL
       SET d.lastClassifiedAt = datetime(),
           d.lastClassifiedBodyLength = size(d.body)`, { docId: documentNodeId });
        log("neo4j-done", `doc=${documentNodeId.slice(0, 12)} sections=${sections.length} kinds=${JSON.stringify(kindBreakdown)} edges=${JSON.stringify(edgeBreakdown)} related=${relatedCount} orphans=${orphanCandidates.length}`);
        console.error(`[memory-ingest] sections=${sections.length} chain=${Math.max(0, sections.length - 1)} typed=${formatBreakdown(kindBreakdown)} edges=${formatBreakdown(edgeBreakdown)} orphans=${orphanCandidates.length} docId=${documentNodeId}`);
        for (const orphan of orphanCandidates) {
            console.error(`[document-ingest] orphan-candidate node=${orphan.kind} label=${JSON.stringify(orphan.label)} reason=${JSON.stringify(orphan.reason)}`);
        }
        // Inline compiled-truth reconciliation gate (opt-in — Task 595).
        // Only runs on the document path; conversation-archive path has no
        // MENTIONS edges at ingest time (those come from the enrich phase).
        let inlineRewriteCandidates;
        let inlineRewriteDeferred;
        if (params.inlineRewrite) {
            const threshold = params.inlineRewriteThreshold ?? 2;
            const rows = await queryInlineRewriteCandidates(dbSession, documentNodeId, accountId, threshold);
            inlineRewriteCandidates = [];
            inlineRewriteDeferred = [];
            const cooldownMs = 7 * 24 * 60 * 60 * 1000;
            const cutoffIso = new Date(Date.now() - cooldownMs).toISOString();
            for (const row of rows) {
                const inCooldown = !params.forceRewrite &&
                    row.compiledTruthUpdatedAt !== null &&
                    row.compiledTruthUpdatedAt > cutoffIso;
                if (inCooldown) {
                    inlineRewriteDeferred.push({
                        elementId: row.elementId,
                        label: row.label,
                        name: row.name,
                        edgeCount: row.edgeCount,
                        reason: "cooldown",
                        compiledTruthUpdatedAt: row.compiledTruthUpdatedAt,
                    });
                }
                else {
                    inlineRewriteCandidates.push({
                        elementId: row.elementId,
                        label: row.label,
                        name: row.name,
                        edgeCount: row.edgeCount,
                    });
                }
            }
            const logReason = rows.length === 0
                ? "threshold-not-met"
                : inlineRewriteCandidates.length === 0
                    ? "cooldown"
                    : params.forceRewrite
                        ? "force"
                        : "";
            console.error(`[memory-ingest] op=compiled-truth-inline accountId=${accountId} kdId=${documentNodeId} ` +
                `entitiesChecked=${rows.length} entitiesRecompiled=${inlineRewriteCandidates.length} ` +
                `entitiesDeferred=${inlineRewriteDeferred.length}` +
                (logReason ? ` reason=${logReason}` : ""));
        }
        return {
            documentNodeId,
            sectionCount: sections.length,
            kindBreakdown,
            edgeBreakdown,
            relatedCount,
            standaloneCount,
            orphanCandidates,
            documentSummary,
            keywords,
            ...(inlineRewriteCandidates !== undefined ? { inlineRewriteCandidates } : {}),
            ...(inlineRewriteDeferred !== undefined ? { inlineRewriteDeferred } : {}),
        };
    }
    finally {
        extractCache.delete(attachmentId);
        await dbSession.close();
        log("complete");
    }
}
// ---------------------------------------------------------------------------
// Helpers — standalone-node writer (Project), related-edge writer, document-
// edge writer.
// ---------------------------------------------------------------------------
async function writeStandaloneNode(dbSession, section, baseProps, anchorNodeId, anchorLabel, attachmentId, sessionId, now) {
    // Standalone (e.g. :Project) doesn't take `summary` — the section's
    // summary is meaningful for the :Section node, not for a typed entity
    // derived from it. Drop summary from baseProps before the spread.
    const { summary: _droppedSummary, ...standaloneBaseProps } = baseProps;
    void _droppedSummary;
    const props = {
        ...stripReservedSectionProperties(section.properties, {
            kind: section.kind,
            identity: `attachmentId=${attachmentId}`,
        }),
        ...standaloneBaseProps,
    };
    const r = await dbSession.run(`CREATE (n:\`${section.kind}\`)
     SET n = $props
     RETURN elementId(n) AS nodeId`, { props });
    const nodeId = r.records[0].get("nodeId");
    if (section.anchorEdge) {
        const direction = section.anchorEdge.direction;
        const edgeType = section.anchorEdge.type;
        const edgeProps = {
            ...(section.anchorEdge.properties ?? {}),
            createdByAgent: PROVENANCE_AGENT,
            createdBySession: sessionId,
            source: PROVENANCE_SOURCE,
            sourceDocumentId: attachmentId,
            createdAt: now,
        };
        const cypher = direction === "from-anchor"
            ? `MATCH (a:\`${anchorLabel}\`) WHERE elementId(a) = $anchorId AND a.accountId = $accountId
         MATCH (n) WHERE elementId(n) = $nodeId
         CREATE (a)-[edge:\`${edgeType}\`]->(n)
         SET edge += $edgeProps`
            : `MATCH (a:\`${anchorLabel}\`) WHERE elementId(a) = $anchorId AND a.accountId = $accountId
         MATCH (n) WHERE elementId(n) = $nodeId
         CREATE (n)-[edge:\`${edgeType}\`]->(a)
         SET edge += $edgeProps`;
        await dbSession.run(cypher, {
            anchorId: anchorNodeId,
            nodeId,
            accountId: baseProps.accountId,
            edgeProps,
        });
    }
    return nodeId;
}
async function writeRelatedAndEdge(dbSession, fromNodeId, fromLabel, related, accountId, now, sessionId, attachmentId, anchorNodeId, anchorLabel) {
    // Task 482 — typed-entity edges (FOUNDED, WORKS_AT, INVESTED_IN,
    // ADVISES, etc.) must NEVER land on a `:Section` node. The classifier
    // can opt into anchor-routing via `edge.targetMode === "anchor"`; the
    // writer then connects the related entity to the document anchor
    // instead of the section. Default `"section"` preserves prior
    // behaviour for non-typed-entity edges (DEFINES, AT, …).
    const targetMode = related.edge.targetMode ?? "section";
    const fromEndpointId = targetMode === "anchor" ? anchorNodeId : fromNodeId;
    const fromEndpointLabel = targetMode === "anchor" ? anchorLabel : fromLabel;
    const relatedNodeId = await writeRelatedNode({
        session: dbSession,
        accountId,
        related,
        now,
        sessionId,
    });
    const sourceLabel = related.edge.direction === "outgoing" ? fromEndpointLabel : related.kind;
    const targetLabel = related.edge.direction === "outgoing" ? related.kind : fromEndpointLabel;
    rejectIfDisallowedTypedEdge({
        sourceLabel,
        edgeType: related.edge.type,
        targetLabel,
        context: `related (kind=${related.kind} direction=${related.edge.direction} targetMode=${targetMode} attachmentId=${attachmentId})`,
    });
    await dbSession.run(`MATCH (a) WHERE elementId(a) = $a
     MATCH (b) WHERE elementId(b) = $b
     CREATE (a)-[edge:\`${related.edge.type}\`]->(b)
     SET edge += $edgeProps`, {
        a: related.edge.direction === "outgoing" ? fromEndpointId : relatedNodeId,
        b: related.edge.direction === "outgoing" ? relatedNodeId : fromEndpointId,
        edgeProps: {
            ...(related.edge.properties ?? {}),
            createdByAgent: PROVENANCE_AGENT,
            createdBySession: sessionId,
            source: PROVENANCE_SOURCE,
            sourceDocumentId: attachmentId,
            createdAt: now,
        },
    });
}
/**
 * Loud-rejects a classifier-emitted edge whose type is governed by the
 * typed-edge allowlist (FOUNDED, WORKS_AT, INVESTED_IN, ADVISES,
 * MENTIONS, AUTHORED, ATTACHED_TO, REFERENCES, ATTENDED) but whose
 * (sourceLabel, targetLabel) pair is not in the allowlist. Task 482 —
 * closes the writer defect that produced `(:Person)-[:FOUNDED]->(:Section)`
 * on the Joel Smalley BioSymm website ingest.
 *
 * Edge types absent from the allowlist (HAS_SECTION, HAS_POSITION, AT,
 * PARTY, PARTICIPANT, FROM/TO/CC, SPEAKER, DEFINES, …) are not governed
 * here and pass through.
 */
function rejectIfDisallowedTypedEdge(params) {
    const { sourceLabel, edgeType, targetLabel, context } = params;
    if (!isTypedEntityEdgeType(edgeType))
        return;
    if (isAllowedTypedEdge(sourceLabel, edgeType, targetLabel))
        return;
    throw new Error(`[memory-ingest] disallowed typed-entity edge ` +
        `(:${sourceLabel})-[:${edgeType}]->(:${targetLabel}) — ` +
        `not in TYPED_EDGE_ALLOWLIST. ${context}. ` +
        `Classifier must emit typed-entity edges with allowlist-shaped ` +
        `source/target labels (use \`edge.targetMode: "anchor"\` to land ` +
        `the edge on the document anchor instead of the section).`);
}
async function writeDocumentEdge(dbSession, documentNodeId, docEdge, accountId, now, sessionId, attachmentId) {
    // Task 482 — typed-entity allowlist guard. KD-rooted edges whose type
    // is governed by `TYPED_EDGE_ALLOWLIST` (MENTIONS, AUTHORED, …) must
    // honour the source/target label shapes declared there. PARTY,
    // PARTICIPANT, FROM/TO/CC, SPEAKER are not in the allowlist and pass
    // through unchecked.
    const sourceLabel = docEdge.direction === "outgoing" ? "KnowledgeDocument" : docEdge.targetKind;
    const targetLabel = docEdge.direction === "outgoing" ? docEdge.targetKind : "KnowledgeDocument";
    rejectIfDisallowedTypedEdge({
        sourceLabel,
        edgeType: docEdge.type,
        targetLabel,
        context: `documentEdge (targetKind=${docEdge.targetKind} direction=${docEdge.direction} attachmentId=${attachmentId})`,
    });
    // Build a synthetic ClassifiedRelated so we can reuse writeRelatedNode for
    // the target. The MERGE-vs-CREATE decision is the same; the edge is off
    // the KnowledgeDocument rather than off a Section.
    const synthetic = {
        kind: docEdge.targetKind,
        properties: docEdge.targetProperties,
        edge: { type: docEdge.type, direction: docEdge.direction },
        merge: docEdge.merge !== false,
    };
    const targetNodeId = await writeRelatedNode({
        session: dbSession,
        accountId,
        related: synthetic,
        now,
        sessionId,
    });
    await dbSession.run(`MATCH (d:KnowledgeDocument) WHERE elementId(d) = $docId
     MATCH (n) WHERE elementId(n) = $targetId
     CREATE (d)-[edge:\`${docEdge.type}\`]->(n)
     SET edge.createdByAgent   = $createdByAgent,
         edge.createdBySession = $createdBySession,
         edge.source           = $source,
         edge.sourceDocumentId = $sourceDocumentId,
         edge.createdAt        = $createdAt`, {
        docId: documentNodeId,
        targetId: targetNodeId,
        createdByAgent: PROVENANCE_AGENT,
        createdBySession: sessionId,
        source: PROVENANCE_SOURCE,
        sourceDocumentId: attachmentId,
        createdAt: now,
    });
}
function formatBreakdown(b) {
    const entries = Object.entries(b);
    if (entries.length === 0)
        return "{}";
    return entries.map(([k, v]) => `${k}:${v}`).join(",");
}
async function writeRelatedNode(opts) {
    const { session, accountId, related, now, sessionId } = opts;
    // Compute embedding from a representative property string so the related
    // node is searchable. Falls back to label+JSON if no `name` field exists.
    const embedSource = typeof related.properties.name === "string"
        ? related.properties.name
        : `${related.kind} ${JSON.stringify(related.properties)}`;
    const relatedEmbedding = await embed(embedSource);
    const mergeKey = related.merge !== false ? mergeKeyFor(related.kind, related.properties) : null;
    if (mergeKey) {
        // MERGE on the identifying property + accountId. ON CREATE stamps
        // provenance; ON MATCH leaves provenance intact (so re-ingest of the
        // same Organization across docs doesn't overwrite the original
        // provenance — first-write wins for shared entities).
        const result = await session.run(`MERGE (r:\`${related.kind}\` { accountId: $accountId, \`${mergeKey.key}\`: $mergeValue })
       ON CREATE SET r += $createProps,
                     r.embedding        = $embedding,
                     r.createdAt        = $createdAt,
                     r.updatedAt        = $createdAt,
                     r.createdByAgent   = $createdByAgent,
                     r.createdBySource  = $createdByAgent,
                     r.createdBySession = $createdBySession,
                     r.source           = $source
       ON MATCH  SET r.updatedAt = $createdAt
       RETURN elementId(r) AS nodeId`, {
            accountId,
            mergeValue: mergeKey.value,
            createProps: { ...related.properties, accountId, scope: "shared" },
            embedding: relatedEmbedding,
            createdAt: now,
            createdByAgent: PROVENANCE_AGENT,
            createdBySession: sessionId ?? "",
            source: PROVENANCE_SOURCE,
        });
        return result.records[0].get("nodeId");
    }
    // CREATE — no stable identifying property, treat as one-of-a-kind for
    // this document. Track sourceDocumentId so re-ingest cleanup catches it.
    const result = await session.run(`CREATE (r:\`${related.kind}\`)
     SET r = $props
     RETURN elementId(r) AS nodeId`, {
        props: {
            ...related.properties,
            accountId,
            scope: "shared",
            embedding: relatedEmbedding,
            createdAt: now,
            updatedAt: now,
            createdByAgent: PROVENANCE_AGENT,
            createdBySource: PROVENANCE_AGENT,
            createdBySession: sessionId ?? "",
            source: PROVENANCE_SOURCE,
        },
    });
    return result.records[0].get("nodeId");
}
async function queryInlineRewriteCandidates(session, docNodeId, accountId, threshold) {
    const result = await session.run(`MATCH (doc) WHERE elementId(doc) = $docId
     MATCH (doc)-[:HAS_SECTION*0..1]->(intermediary)
     MATCH (intermediary)-[r:MENTIONS|REFERENCES]->(target)
     WHERE target.accountId = $accountId
       AND NOT target:Trashed
       AND target.compiledTruth IS NOT NULL
     WITH elementId(target) AS eid, target, count(r) AS edgeCount
     WHERE edgeCount >= $threshold
     RETURN eid AS elementId,
            [l IN labels(target) WHERE l IN ['Person', 'Organization', 'Concept', 'Project', 'LocalBusiness']][0] AS label,
            coalesce(target.name, target.title, '<unnamed>') AS name,
            toString(target.compiledTruthUpdatedAt) AS compiledTruthUpdatedAt,
            edgeCount
     ORDER BY edgeCount DESC`, { docId: docNodeId, accountId, threshold });
    return result.records.map((rec) => ({
        elementId: String(rec.get("elementId")),
        label: String(rec.get("label") ?? "Unknown"),
        name: String(rec.get("name")),
        compiledTruthUpdatedAt: rec.get("compiledTruthUpdatedAt"),
        edgeCount: Number(rec.get("edgeCount")),
    }));
}
// ---------------------------------------------------------------------------
// Conversation-archive ingest — chat-mode branch (Task 397).
//
// MERGE :ConversationArchive on conversationIdentity → cleanup THIS export's
// chunks by archiveSha256 (delta discipline: prior exports' chunks survive)
// → CREATE new chunks → extend NEXT chain from existing tail → MERGE
// :PARTICIPANT_IN edges → update lastIngestedMessageHash + lastIngestedMessageAt.
//
// No anchor edges, no related entities, no documentEdges, no orphan candidates.
// Sections in this branch are :Section chunks (no secondary label — source
// is the parent's discriminator); properties pass through verbatim (summary,
// keywords, firstMessageAt, lastMessageAt, participantNames, messageCount).
// The parent label `:ConversationArchive` is what distinguishes a transcript
// from a narrative document; both use the same `:Section`+`:NEXT` chain shape.
// ---------------------------------------------------------------------------
// Source-agnostic provenance. The orchestrator is one bash entry
// regardless of source, so the agent stamp is the same across every ingest;
// the source format flows into `source` instead.
const CHAT_PROVENANCE_AGENT = "conversation-archive";
async function ingestConversationDocument(params) {
    const { accountId, conversationIdentity: rawConversationIdentity, documentSummary, sections, scope, documentKeywords: rawDocKeywords, userKeywords: rawUserKeywords, sessionId, archiveSha256, archiveSourceFile, lastIngestedMessageHash, lastIngestedMessageAt, participantElementIds = [], source, 
    // sub-scope B — default `false` is the non-destructive contract.
    // The bin previously defaulted this to `true` (cleanup-on-every-
    // run-by-archiveSha256), which combined with `--session-gap-hours` to
    // silently destroy 138 chunks during the Adam Mackay re-gap. Default
    // `false` means cleanup never fires unless the caller explicitly opts in;
    // the bin only opts in under `--rebuild` AND on the first session.
    cleanupPriorChunks = false, archiveTitle, sessionIndex, } = params;
    // The dispatcher verified conversationIdentity is set on this branch.
    const conversationIdentity = rawConversationIdentity;
    if (!scope) {
        throw new Error("scope is required");
    }
    if (!source || !isConversationSource(source)) {
        throw new Error(`source is required on the conversation-document path and must be one of the closed enum (got ${JSON.stringify(source)}).`);
    }
    if (!archiveSha256 || !archiveSha256.trim()) {
        throw new Error("archiveSha256 is required on the conversation-document path");
    }
    if (!archiveSourceFile || !archiveSourceFile.trim()) {
        throw new Error("archiveSourceFile is required on the conversation-document path");
    }
    if (!lastIngestedMessageHash || !lastIngestedMessageHash.trim()) {
        throw new Error("lastIngestedMessageHash is required on the conversation-document path");
    }
    if (!lastIngestedMessageAt || !lastIngestedMessageAt.trim()) {
        throw new Error("lastIngestedMessageAt is required on the conversation-document path");
    }
    // Task 468 — sessionIndex is the per-session key into the bin's turnText
    // tempfile cache. Without it the writer cannot locate the bytes to slice
    // `:Section.body` from a non-LLM source.
    if (!Number.isInteger(sessionIndex) || sessionIndex < 1) {
        throw new Error(`sessionIndex is required on the conversation-document path and must be integer >= 1 (got ${JSON.stringify(sessionIndex)})`);
    }
    const cachePath = turnTextCachePath({
        conversationIdentity,
        archiveSha256,
        sessionIndex: sessionIndex,
    });
    const keywords = (() => {
        const u = (rawUserKeywords ?? []).map((k) => k.toLowerCase().trim()).filter(Boolean);
        const d = (rawDocKeywords ?? []).map((k) => k.toLowerCase().trim()).filter(Boolean);
        if (u.length === 0 && d.length === 0)
            return undefined;
        return [...new Set([...u, ...d])];
    })();
    const t0 = Date.now();
    const log = (stage, detail) => console.error(`[memory-ingest] [CD:${conversationIdentity.slice(0, 8)}] ${stage}${detail ? ` — ${detail}` : ""} (${Date.now() - t0}ms)`);
    log("start", `${sections.length} chunks, archiveSha256=${archiveSha256.slice(0, 12)}, scope=${scope}`);
    const now = new Date().toISOString();
    const dbSession = getSession();
    let archiveNodeId = "";
    const kindBreakdown = {};
    const edgeBreakdown = {};
    try {
        // 1. Trash-revival. `:ConversationArchive` is in graph-trash's
        //    UNIQUE_KEYS_BY_LABEL with `conversationIdentity`, so a trashed
        //    conversation-archive parent has `conversationIdentity` snapshotted
        //    into `_trashedKeys` and nulled on the live property. Look it up
        //    via the snapshot — direct property lookup would miss a
        //    trashed-then-revived MERGE target. Without this, re-ingesting into
        //    a previously-trashed parent (operator manually drag-trashed, then
        //    re-ran) MERGEs into a fresh node and leaves the trashed twin behind.
        const trashedArchive = await dbSession.run(`MATCH (a:ConversationArchive:Trashed)
       WHERE a.accountId = $accountId
         AND (a.conversationIdentity = $conversationIdentity
              OR a._trashedKeys CONTAINS $conversationIdentity)
       RETURN elementId(a) AS eid LIMIT 1`, { accountId, conversationIdentity });
        if (trashedArchive.records.length > 0) {
            const eid = trashedArchive.records[0].get("eid");
            await restoreNode({ session: dbSession, accountId, elementId: eid });
            log("revived", `restored trashed ConversationArchive elementId=${eid}`);
        }
        // 2. Read this session's turnText from the bin's tempfile cache and slice
        //    every section's body server-side. Mirrors the document path's
        //    `extractCache` → `deriveSectionBodies` flow; the bin runs in a
        //    separate process so the cache is a deterministic disk path
        //    (`turnTextCachePath`) instead of an in-process Map. Any `body` field
        //    the specialist still emits is logged and ignored (Task 468).
        let turnText;
        try {
            turnText = await readFile(cachePath, "utf8");
        }
        catch (err) {
            const detail = err instanceof Error ? err.message : String(err);
            throw new Error(`[memory-ingest] failed to read turnText cache for chat path: ` +
                `path=${cachePath} conversationIdentity=${conversationIdentity} ` +
                `archiveSha256=${archiveSha256.slice(0, 12)} sessionIndex=${sessionIndex} ` +
                `reason=${detail}`);
        }
        log("turntext-loaded", `bytes=${turnText.length} sessionIndex=${sessionIndex}`);
        const sectionBodies = deriveSectionBodies(sections, turnText, conversationIdentity);
        for (let i = 0; i < sections.length; i++) {
            console.error(`[memory-ingest] chat-section-body-derived sectionIndex=${i} ` +
                `sourceStart=${sections[i].sourceStart} ` +
                `sourceEnd=${sections[i].sourceEnd} ` +
                `bodyLength=${sectionBodies[i].length} ` +
                `conversationIdentity=${conversationIdentity} ` +
                `sessionIndex=${sessionIndex}`);
        }
        const textsToEmbed = [documentSummary, ...sectionBodies];
        log("embedding", `${textsToEmbed.length} texts`);
        const embeddings = await embedBatch(textsToEmbed);
        const archiveEmbedding = embeddings[0];
        // 3. MERGE :ConversationArchive on conversationIdentity. ON CREATE stamps
        //    immutable identity props; ON MATCH bumps the cursor + last-ingest
        //    timestamps so the next delta-append run finds the right point.
        //
        //    sub-scope A — `a.title` is the stable label for the parent.
        //    ON CREATE sets it; ON MATCH uses COALESCE so an existing title is
        //    NEVER overwritten (re-ingests must produce the same title anyway,
        //    but the COALESCE makes the contract explicit). UI prefers `title`
        //    via display-helpers' conversation-document branch.
        const archiveResult = await dbSession.run(`MERGE (a:ConversationArchive { conversationIdentity: $conversationIdentity })
       ON CREATE SET a.accountId               = $accountId,
                     a.scope                   = $scope,
                     a.title                   = $archiveTitle,
                     a.summary                 = $documentSummary,
                     a.keywords                = $keywords,
                     a.embedding               = $archiveEmbedding,
                     a.archiveSourceFile       = $archiveSourceFile,
                     a.createdAt               = $createdAt,
                     a.createdBySession        = $sessionId,
                     a.createdByAgent          = $createdByAgent,
                     a.source                  = $source
       SET a.updatedAt                = $updatedAt,
           a.title                    = COALESCE(a.title, $archiveTitle),
           a.lastIngestedMessageHash  = $lastIngestedMessageHash,
           a.lastIngestedMessageAt    = $lastIngestedMessageAt,
           a.lastIngestedBySession    = $sessionId,
           a.lastIngestedArchiveSha256 = $archiveSha256
       RETURN elementId(a) AS nodeId, count(a) AS affected`, {
            conversationIdentity,
            accountId,
            scope,
            archiveTitle: archiveTitle ?? null,
            documentSummary,
            keywords: keywords ?? [],
            archiveEmbedding,
            archiveSourceFile,
            createdAt: now,
            updatedAt: now,
            sessionId: sessionId ?? "",
            createdByAgent: CHAT_PROVENANCE_AGENT,
            source: source,
            lastIngestedMessageHash,
            lastIngestedMessageAt,
            archiveSha256,
        });
        if (archiveResult.records[0]?.get("affected")?.toNumber?.() !== 1) {
            throw new Error("memory-ingest: conversation-document MERGE returned no row — Cypher silent no-op");
        }
        archiveNodeId = archiveResult.records[0].get("nodeId");
        log("archive-merged", `nodeId=${archiveNodeId.slice(0, 12)}`);
        // 3a. Drain pending :HAS_ENCLOSURE edges parked on :DigitalDocument by
        //     upstream writers (Task 402: email-ingest tags attachments before
        //     the :ConversationArchive exists). Any :DigitalDocument whose
        //     pendingArchiveEdges list contains the current conversationIdentity
        //     gets a :HAS_ENCLOSURE edge from this archive node; the entry is
        //     removed from the list so re-runs are no-ops.
        const enclosureResult = await dbSession.run(`MATCH (a:ConversationArchive { conversationIdentity: $conversationIdentity })
       MATCH (d:DigitalDocument { accountId: $accountId })
       WHERE d.pendingArchiveEdges IS NOT NULL
         AND $conversationIdentity IN d.pendingArchiveEdges
       MERGE (a)-[:HAS_ENCLOSURE]->(d)
       SET d.pendingArchiveEdges = [x IN d.pendingArchiveEdges WHERE x <> $conversationIdentity]
       RETURN count(d) AS attached`, { conversationIdentity, accountId });
        const enclosuresAttached = enclosureResult.records[0]?.get("attached")?.toNumber?.() ?? 0;
        if (enclosuresAttached > 0) {
            log("enclosures-attached", `count=${enclosuresAttached}`);
        }
        // 4. Cleanup chunks of THIS export (idempotency for re-running the same
        //    export bytes). Delta-append re-imports of NEWER exports leave prior
        //    chunks alone because their archiveSha256 differs. sub-scope
        //    E: skipped on per-session calls after the first, or this would
        //    delete the chunks that earlier sessions in the same run just wrote
        //    (matching conversationIdentity + matching archiveSha256).
        let cleanedPriorChunks = 0;
        if (cleanupPriorChunks) {
            const cleanup = await dbSession.run(`MATCH (a:ConversationArchive { conversationIdentity: $conversationIdentity })
               -[hs:HAS_SECTION]->(c:Section { archiveSha256: $archiveSha256 })
         DETACH DELETE c
         RETURN count(c) AS removed`, { conversationIdentity, archiveSha256 });
            cleanedPriorChunks = cleanup.records[0]?.get("removed")?.toNumber?.() ?? 0;
            if (cleanedPriorChunks > 0) {
                log("cleanup", `dropped ${cleanedPriorChunks} prior chunks for this archiveSha256`);
            }
        }
        else {
            log("cleanup-skipped", `cleanupPriorChunks=false (default non-destructive contract — only --rebuild's first session sets true)`);
        }
        // 5. Find existing tail of NEXT chain (if any). Delta-append wires the
        //    new first chunk after this tail; first-ever ingest finds nothing.
        //    Restrict the NEXT chain match to this conversation-document's
        //    sections by going through HAS_SECTION rather than by section label;
        //    a plain `:Section` would also match document-path siblings.
        const tailResult = await dbSession.run(`MATCH (a:ConversationArchive { conversationIdentity: $conversationIdentity })
             -[:HAS_SECTION]->(c:Section)
       WHERE NOT EXISTS {
         MATCH (c)-[:NEXT]->(:Section)<-[:HAS_SECTION]-(a)
       }
       RETURN elementId(c) AS tailId LIMIT 1`, { conversationIdentity });
        let previousChunkId = tailResult.records[0]?.get("tailId") ?? null;
        if (previousChunkId) {
            log("tail-found", `extending NEXT chain from elementId=${previousChunkId.slice(0, 12)}`);
        }
        // 6. CREATE chunk nodes + HAS_SECTION + NEXT chain.
        let chunksWritten = 0;
        let nextEdgesCreated = 0;
        for (let i = 0; i < sections.length; i++) {
            const section = sections[i];
            // Server-sliced from turnText (Task 468) — never the LLM-supplied
            // `section.body`, which `deriveSectionBodies` logs+ignores upstream.
            const sectionBody = sectionBodies[i];
            const bodyEmbedding = embeddings[i + 1];
            const baseProps = {
                accountId,
                title: section.title,
                body: sectionBody,
                bodyPreview: sectionBody.slice(0, PREVIEW_LENGTH),
                summary: section.summary,
                position: i,
                scope,
                embedding: bodyEmbedding,
                createdAt: now,
                updatedAt: now,
                createdByAgent: CHAT_PROVENANCE_AGENT,
                createdBySource: CHAT_PROVENANCE_AGENT,
                createdBySession: sessionId ?? "",
                source: source,
                archiveSha256,
                archiveSourceFile,
            };
            const chunkProps = {
                ...stripReservedSectionProperties(section.properties, {
                    kind: section.kind,
                    identity: `conversationIdentity=${conversationIdentity} archiveSha256=${archiveSha256}`,
                }),
                ...baseProps,
            };
            const r = await dbSession.run(`CREATE (c:Section)
         SET c = $props
         WITH c
         MATCH (a:ConversationArchive { conversationIdentity: $conversationIdentity })
         CREATE (a)-[hs:HAS_SECTION]->(c)
         SET hs.createdByAgent   = $createdByAgent,
             hs.createdBySession = $createdBySession,
             hs.source           = $source,
             hs.archiveSha256    = $archiveSha256,
             hs.createdAt        = $createdAt
         RETURN elementId(c) AS nodeId`, {
                props: chunkProps,
                conversationIdentity,
                createdByAgent: CHAT_PROVENANCE_AGENT,
                createdBySession: sessionId ?? "",
                source: source,
                archiveSha256,
                createdAt: now,
            });
            const chunkId = r.records[0].get("nodeId");
            chunksWritten++;
            bumpKind(kindBreakdown, "Conversation");
            bumpEdge(edgeBreakdown, "HAS_SECTION");
            if (previousChunkId) {
                const nextRes = await dbSession.run(`MATCH (prev:Section), (cur:Section)
           WHERE elementId(prev) = $prev AND elementId(cur) = $cur
           CREATE (prev)-[n:NEXT]->(cur)
           SET n.createdByAgent   = $createdByAgent,
               n.createdBySession = $createdBySession,
               n.source           = $source,
               n.archiveSha256    = $archiveSha256,
               n.createdAt        = $createdAt
           RETURN count(n) AS created`, {
                    prev: previousChunkId,
                    cur: chunkId,
                    createdByAgent: CHAT_PROVENANCE_AGENT,
                    createdBySession: sessionId ?? "",
                    source: source,
                    archiveSha256,
                    createdAt: now,
                });
                if (nextRes.records[0]?.get("created")?.toNumber?.() !== 1) {
                    throw new Error("memory-ingest: NEXT edge CREATE returned no row — silent no-op");
                }
                nextEdgesCreated++;
                bumpEdge(edgeBreakdown, "NEXT");
            }
            previousChunkId = chunkId;
        }
        // 7. MERGE :PARTICIPANT_IN edges from each operator-confirmed participant
        //    to the conversation-document parent. Idempotent — re-runs are no-ops
        //    on the edge set.
        let participantEdgesMerged = 0;
        for (const elemId of participantElementIds) {
            const pres = await dbSession.run(`MATCH (a:ConversationArchive { conversationIdentity: $conversationIdentity })
         MATCH (p) WHERE elementId(p) = $elemId AND (p:Person OR p:AdminUser)
         MERGE (p)-[r:PARTICIPANT_IN]->(a)
         ON CREATE SET r.createdByAgent   = $createdByAgent,
                       r.createdBySession = $createdBySession,
                       r.source           = $source,
                       r.createdAt        = $createdAt
         RETURN count(r) AS merged`, {
                conversationIdentity,
                elemId,
                createdByAgent: CHAT_PROVENANCE_AGENT,
                createdBySession: sessionId ?? "",
                source: source,
                createdAt: now,
            });
            const merged = pres.records[0]?.get("merged")?.toNumber?.() ?? 0;
            if (merged !== 1) {
                throw new Error(`memory-ingest: PARTICIPANT_IN MERGE for elementId=${elemId} returned no row — Person/AdminUser may not exist or label mismatch`);
            }
            participantEdgesMerged++;
            bumpEdge(edgeBreakdown, "PARTICIPANT_IN");
        }
        log("neo4j-done", `archive=${archiveNodeId.slice(0, 12)} chunks=${chunksWritten} next-edges=${nextEdgesCreated} participants=${participantEdgesMerged}`);
        return {
            documentNodeId: archiveNodeId,
            sectionCount: chunksWritten,
            kindBreakdown,
            edgeBreakdown,
            relatedCount: 0,
            standaloneCount: 0,
            orphanCandidates: [],
            documentSummary,
            keywords,
            cleanedPriorChunks,
        };
    }
    finally {
        await dbSession.close();
        // Best-effort cleanup of the per-session turnText tempfile. Stale files
        // from aborted runs are benign (deterministic path; re-run overwrites);
        // we still try to remove on success to keep /tmp tidy. Logged on
        // failure so an operator debugging tmpdir pressure can see it.
        try {
            await unlink(cachePath);
        }
        catch (err) {
            const detail = err instanceof Error ? err.message : String(err);
            console.error(`[memory-ingest] turntext-cache-unlink-failed path=${cachePath} ` +
                `reason=${detail}`);
        }
        log("complete");
    }
}
//# sourceMappingURL=memory-ingest.js.map