// ST-BME: LLM 记忆提取管线（写入路径）
// 分析对话 → 提取节点和关系 → 更新图谱
// v2: 融合 Mem0 精确对照 + Graphiti 时序边 + MemoRAG 全局概要

import { embedBatch } from "../vector/embedding.js";
import { debugLog, debugWarn } from "../runtime/debug-logging.js";
import {
  addEdge,
  addNode,
  createEdge,
  createNode,
  findLatestNode,
  getActiveNodes,
  getNode,
  invalidateEdge,
  updateNode,
} from "../graph/graph.js";
import { callLLMForJSON } from "../llm/llm.js";
import { ensureEventTitle } from "../graph/node-labels.js";
import {
  normalizeMemoryScope,
  isObjectiveScope,
} from "../graph/memory-scope.js";
import {
  applyCognitionUpdates,
  applyRegionUpdates,
  resolveKnowledgeOwner,
} from "../graph/knowledge-state.js";
import {
  applyBatchStoryTime,
  createSpanFromStoryTime,
  deriveStoryTimeSpanFromNodes,
  describeNodeStoryTime,
  normalizeStoryTime,
  resolveActiveStoryContext,
  upsertTimelineSegment,
} from "../graph/story-timeline.js";
import { getActiveSummaryEntries } from "../graph/summary-state.js";
import {
  buildTaskExecutionDebugContext,
  buildTaskLlmPayload,
  buildTaskPrompt,
} from "../prompting/prompt-builder.js";
import { RELATION_TYPES } from "../graph/schema.js";
import { applyTaskRegex } from "../prompting/task-regex.js";
import { getSTContextForPrompt, getSTContextSnapshot } from "../host/st-context.js";
import { buildExtractionInputContext } from "./extraction-context.js";
import { buildTaskGraphStats } from "./task-graph-stats.js";
import {
  aliasSetMatchesValue,
  buildUserPovAliasNormalizedSet,
  getHostUserAliasHints,
} from "../runtime/user-alias-utils.js";
import { buildNodeVectorText, isDirectVectorConfig } from "../vector/vector-index.js";

function createAbortError(message = "操作已终止") {
  const error = new Error(message);
  error.name = "AbortError";
  return error;
}

function createTaskLlmDebugContext(promptBuild, regexInput) {
  return typeof buildTaskExecutionDebugContext === "function"
    ? buildTaskExecutionDebugContext(promptBuild, { regexInput })
    : null;
}

function createExtractTaskLlmDebugContext(promptBuild, regexInput, inputContext = null) {
  const debugContext = createTaskLlmDebugContext(promptBuild, regexInput);
  if (!inputContext || typeof inputContext !== "object") {
    return debugContext;
  }
  return {
    ...debugContext,
    inputContext,
  };
}

function resolveTaskPromptPayload(promptBuild, fallbackUserPrompt = "") {
  if (typeof buildTaskLlmPayload === "function") {
    return buildTaskLlmPayload(promptBuild, fallbackUserPrompt);
  }

  return {
    systemPrompt: String(promptBuild?.systemPrompt || ""),
    userPrompt: String(fallbackUserPrompt || ""),
    promptMessages: [],
    additionalMessages: Array.isArray(promptBuild?.privateTaskMessages)
      ? promptBuild.privateTaskMessages
      : [],
  };
}

function resolveTaskLlmSystemPrompt(promptPayload, fallbackSystemPrompt = "") {
  const hasPromptMessages =
    Array.isArray(promptPayload?.promptMessages) &&
    promptPayload.promptMessages.length > 0;
  if (hasPromptMessages) {
    return String(promptPayload?.systemPrompt || "");
  }
  return String(promptPayload?.systemPrompt || fallbackSystemPrompt || "");
}

function buildActiveSummariesText(graph) {
  const entries = getActiveSummaryEntries(graph);
  if (!Array.isArray(entries) || entries.length === 0) return "";
  return entries
    .map((entry, index) => {
      const rangeLabel = Array.isArray(entry.messageRange) && entry.messageRange.length >= 2
          && entry.messageRange[0] >= 0 && entry.messageRange[1] >= 0
        ? `楼${entry.messageRange[0]}~${entry.messageRange[1]}`
        : "";
      const levelLabel = entry.level ? `L${entry.level}` : "";
      const prefix = [rangeLabel, levelLabel].filter(Boolean).join(" ");
      return `[${index + 1}]${prefix ? ` (${prefix})` : ""} ${String(entry.text || entry.summary || "").trim()}`;
    })
    .filter((line) => line.trim())
    .join("\n");
}

function buildStoryTimeContextText(graph) {
  const storyCtx = resolveActiveStoryContext(graph);
  if (!storyCtx?.resolved) return "";
  const parts = [];
  if (storyCtx.activeStoryTimeLabel) {
    parts.push(`当前活跃剧情时间：${storyCtx.activeStoryTimeLabel}`);
  }
  if (storyCtx.source) {
    parts.push(`来源：${storyCtx.source}`);
  }
  const seg = storyCtx.segment;
  if (seg?.tense && seg.tense !== "unknown") {
    parts.push(`时态：${seg.tense}`);
  }
  return parts.join(" | ");
}

function applyRecentMessageCap(messages, cap = 0) {
  if (!Array.isArray(messages) || messages.length === 0) return messages;
  const numericCap = Number(cap);
  if (!Number.isFinite(numericCap) || numericCap <= 0) return messages;
  if (messages.length <= numericCap) return messages;
  return messages.slice(-numericCap);
}

function resolveExtractPromptStructuredMode(settings) {
  const mode = String(settings?.extractPromptStructuredMode || "both").trim().toLowerCase();
  if (["transcript", "structured", "both"].includes(mode)) return mode;
  return "both";
}

function formatExtractRankingMessage(message = {}) {
  const role = String(message?.role || "assistant").trim().toLowerCase() === "user"
    ? "user"
    : "assistant";
  const content = String(message?.content || "").trim();
  if (!content) return "";
  return `[${role}]: ${content}`;
}

function buildExtractRankingQueryText(messages = []) {
  const normalizedMessages = Array.isArray(messages) ? messages : [];
  const targetLines = normalizedMessages
    .filter((message) => message?.isContextOnly !== true)
    .map((message) => formatExtractRankingMessage(message))
    .filter(Boolean);
  if (targetLines.length > 0) {
    return targetLines.join("\n");
  }
  return normalizedMessages
    .map((message) => formatExtractRankingMessage(message))
    .filter(Boolean)
    .join("\n");
}

function buildReflectionRankingQueryText({
  eventSummary = "",
  characterSummary = "",
  threadSummary = "",
  contradictionSummary = "",
} = {}) {
  return [
    eventSummary ? `最近事件:\n${eventSummary}` : "",
    characterSummary ? `近期角色状态:\n${characterSummary}` : "",
    threadSummary ? `当前主线:\n${threadSummary}` : "",
    contradictionSummary ? `已知矛盾:\n${contradictionSummary}` : "",
  ]
    .filter(Boolean)
    .join("\n\n");
}

function isAbortError(error) {
  return error?.name === "AbortError";
}

function throwIfAborted(signal) {
  if (signal?.aborted) {
    throw signal.reason instanceof Error ? signal.reason : createAbortError();
  }
}

const EXTRACTION_RESULT_CONTAINER_KEYS = [
  "operations",
  "nodes",
  "items",
  "entries",
  "memories",
  "results",
  "data",
  "memory_operations",
  "actions",
  "output",
  "extracted",
  "extractions",
  "memory_nodes",
];

const EXTRACTION_OPERATION_META_KEYS = new Set([
  "action",
  "op",
  "operation",
  "type",
  "fields",
  "nodeId",
  "node_id",
  "targetNodeId",
  "target_node_id",
  "sourceNodeId",
  "source_node_id",
  "ref",
  "reference",
  "id",
  "links",
  "relations",
  "edges",
  "importance",
  "clusters",
  "scope",
  "storyTime",
  "seq",
  "temporalStrength",
  "temporal_strength",
]);

function isPlainObject(value) {
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}

/**
 * 判断一个对象是否像一个 extraction 操作
 * (包含 action/op/operation/type 中的至少一个)
 */
function looksLikeSingleOperation(obj) {
  if (!isPlainObject(obj)) return false;
  return (
    typeof obj.action === "string" ||
    typeof obj.op === "string" ||
    typeof obj.operation === "string" ||
    typeof obj.type === "string"
  );
}

function extractOperationsPayload(result) {
  // 直接是数组 → 直接返回
  if (Array.isArray(result)) {
    return result;
  }
  if (!isPlainObject(result)) {
    return null;
  }

  // 1. 优先匹配已知容器键
  for (const key of EXTRACTION_RESULT_CONTAINER_KEYS) {
    if (Array.isArray(result[key])) {
      return result[key];
    }
  }

  // 2. 智能探测：扫描对象中第一个值为非空数组且元素看起来像操作的键
  for (const [key, value] of Object.entries(result)) {
    if (
      Array.isArray(value) &&
      value.length > 0 &&
      value.some((item) => looksLikeSingleOperation(item))
    ) {
      debugLog(
        `[ST-BME] 自动探测到非标准容器键: "${key}" (${value.length} 项)`,
      );
      return value;
    }
  }

  // 3. 单个操作对象兜底：如果整个结果看起来像一条操作，包装成数组
  if (looksLikeSingleOperation(result)) {
    debugLog(
      "[ST-BME] LLM 返回了单个操作对象，自动包装为数组",
    );
    return [result];
  }

  return null;
}

function resolveExtractionAction(rawOp) {
  const explicitAction = rawOp?.action ?? rawOp?.op ?? rawOp?.operation;
  if (typeof explicitAction === "string" && explicitAction.trim()) {
    return explicitAction.trim().toLowerCase();
  }

  if (rawOp?.type) {
    if (rawOp?.nodeId || rawOp?.node_id) {
      return "update";
    }
    return "create";
  }

  return "";
}

function resolveExtractionTypeDef(schema, type) {
  if (!Array.isArray(schema) || !type) {
    return null;
  }
  return schema.find((entry) => entry?.id === type) || null;
}

function resolveExtractionFieldNames(typeDef) {
  return new Set(
    Array.isArray(typeDef?.columns)
      ? typeDef.columns
          .map((column) => String(column?.name || "").trim())
          .filter(Boolean)
      : [],
  );
}

function resolveExtractionNodeId(rawOp) {
  const nodeId =
    rawOp?.nodeId ??
    rawOp?.node_id ??
    rawOp?.targetNodeId ??
    rawOp?.target_node_id ??
    rawOp?.id;
  return nodeId == null || nodeId === "" ? "" : String(nodeId);
}

function resolveExtractionRef(rawOp) {
  const ref = rawOp?.ref ?? rawOp?.reference ?? rawOp?.id;
  return ref == null || ref === "" ? "" : String(ref);
}

function collectNormalizedOperationFields(rawOp, typeDef) {
  const fieldNames = resolveExtractionFieldNames(typeDef);
  const fields = isPlainObject(rawOp?.fields) ? { ...rawOp.fields } : {};

  for (const [key, value] of Object.entries(rawOp || {})) {
    if (key === "fields") {
      continue;
    }

    if (key === "scope") {
      if (!isPlainObject(value) && (fieldNames.has("scope") || !typeDef)) {
        fields.scope = value;
      }
      continue;
    }

    if (EXTRACTION_OPERATION_META_KEYS.has(key)) {
      continue;
    }

    if (!typeDef || fieldNames.has(key)) {
      fields[key] = value;
    }
  }

  return fields;
}

function normalizeExtractionOperation(rawOp, schema) {
  if (!isPlainObject(rawOp)) {
    return rawOp;
  }

  const action = resolveExtractionAction(rawOp);
  const type = rawOp?.type == null ? "" : String(rawOp.type).trim();
  const typeDef = resolveExtractionTypeDef(schema, type);
  const normalized = {
    ...rawOp,
    ...(action ? { action } : {}),
    ...(type ? { type } : {}),
  };

  const nodeId = resolveExtractionNodeId(rawOp);
  const ref = resolveExtractionRef(rawOp);

  if (action === "create") {
    if (ref) {
      normalized.ref = ref;
    }
    delete normalized.nodeId;
  } else if ((action === "update" || action === "delete") && nodeId) {
    normalized.nodeId = nodeId;
  }

  if (Array.isArray(rawOp?.relations) && !Array.isArray(rawOp?.links)) {
    normalized.links = rawOp.relations;
  } else if (Array.isArray(rawOp?.edges) && !Array.isArray(rawOp?.links)) {
    normalized.links = rawOp.edges;
  }

  if (!Array.isArray(normalized.clusters) && normalized.clusters != null) {
    normalized.clusters = [normalized.clusters].filter(Boolean);
  }

  if (isPlainObject(rawOp?.scope)) {
    normalized.scope = rawOp.scope;
  } else if (action === "create" || action === "update") {
    delete normalized.scope;
  }

  if (isPlainObject(rawOp?.storyTime)) {
    normalized.storyTime = normalizeStoryTime(rawOp.storyTime, {
      source: "extract",
    });
  } else if (action === "create" || action === "update") {
    delete normalized.storyTime;
  }

  if (action === "create" || action === "update") {
    const fields = collectNormalizedOperationFields(rawOp, typeDef);
    if (Object.keys(fields).length > 0) {
      normalized.fields = fields;
    }
  }

  delete normalized.op;
  delete normalized.operation;
  delete normalized.node_id;
  delete normalized.target_node_id;
  delete normalized.source_node_id;
  delete normalized.reference;
  delete normalized.relations;
  delete normalized.edges;
  delete normalized.temporal_strength;

  return normalized;
}

function normalizeExtractionResultPayload(result, schema) {
  const operations = extractOperationsPayload(result);
  if (!Array.isArray(operations)) {
    return result;
  }

  const normalizedOperations = operations.map((op) =>
    normalizeExtractionOperation(op, schema),
  );
  const normalizedCognitionUpdates = Array.isArray(result?.cognitionUpdates)
    ? result.cognitionUpdates
        .filter(isPlainObject)
        .map((entry) => ({
          ownerType: String(entry?.ownerType || "").trim(),
          ownerName: String(entry?.ownerName || "").trim(),
          ownerId: String(entry?.ownerId || "").trim(),
          ownerNodeId: String(entry?.ownerNodeId || "").trim(),
          knownRefs: Array.isArray(entry?.knownRefs)
            ? entry.knownRefs
            : entry?.knownRefs != null
              ? [entry.knownRefs]
              : [],
          mistakenRefs: Array.isArray(entry?.mistakenRefs)
            ? entry.mistakenRefs
            : entry?.mistakenRefs != null
              ? [entry.mistakenRefs]
              : [],
          visibility: Array.isArray(entry?.visibility) ? entry.visibility : [],
        }))
    : [];
  const normalizedRegionUpdates = isPlainObject(result?.regionUpdates)
    ? {
        activeRegionHint: String(result.regionUpdates?.activeRegionHint || "").trim(),
        adjacency: Array.isArray(result.regionUpdates?.adjacency)
          ? result.regionUpdates.adjacency
              .filter(isPlainObject)
              .map((entry) => ({
                region: String(entry?.region || "").trim(),
                adjacent: Array.isArray(entry?.adjacent)
                  ? entry.adjacent
                  : entry?.adjacent != null
                    ? [entry.adjacent]
                    : [],
                source: String(entry?.source || "").trim(),
              }))
          : [],
      }
    : null;
  const normalizedBatchStoryTime = isPlainObject(result?.batchStoryTime)
    ? {
        ...normalizeStoryTime(result.batchStoryTime, { source: "extract" }),
        advancesActiveTimeline: result.batchStoryTime?.advancesActiveTimeline === true,
      }
    : null;

  if (Array.isArray(result) || !isPlainObject(result)) {
    return {
      operations: normalizedOperations,
      cognitionUpdates: normalizedCognitionUpdates,
      regionUpdates: normalizedRegionUpdates,
      batchStoryTime: normalizedBatchStoryTime,
    };
  }

  return {
    ...result,
    operations: normalizedOperations,
    cognitionUpdates: normalizedCognitionUpdates,
    regionUpdates: normalizedRegionUpdates,
    batchStoryTime: normalizedBatchStoryTime,
  };
}

function normalizeExtractionOwnerText(value) {
  return String(value || "").trim();
}

function collectExtractorUserAliasHints(scopeRuntime = {}, extraHints = []) {
  const hints = [];
  const pushHint = (value) => {
    const normalized = normalizeExtractionOwnerText(value);
    if (!normalized || hints.includes(normalized)) return;
    hints.push(normalized);
  };
  const ingest = (value) => {
    if (value == null) return;
    if (typeof value === "string") {
      pushHint(value);
      return;
    }
    if (Array.isArray(value)) {
      for (const item of value) ingest(item);
      return;
    }
    if (typeof value === "object") {
      pushHint(value.name1);
      pushHint(value.userName);
      pushHint(value.personaName);
      pushHint(value.name);
      ingest(value.aliases);
    }
  };

  pushHint(scopeRuntime.activeUserOwner);
  ingest(getHostUserAliasHints(extraHints));
  return hints;
}

function buildExtractorUserAliasContext(scopeRuntime = {}, extraHints = []) {
  const aliasHints = collectExtractorUserAliasHints(scopeRuntime, extraHints);
  return {
    aliasHints,
    aliasSet: buildUserPovAliasNormalizedSet(aliasHints),
    preferredName: aliasHints[0] || "",
  };
}

function matchesExtractorUserAlias(ownerName = "", scopeRuntime = {}, extraHints = []) {
  const aliasContext = buildExtractorUserAliasContext(scopeRuntime, extraHints);
  return aliasSetMatchesValue(aliasContext.aliasSet, ownerName);
}

function resolveExtractorUserOwnerName(
  scopeRuntime = {},
  rawOwnerName = "",
  extraHints = [],
) {
  const aliasContext = buildExtractorUserAliasContext(scopeRuntime, [
    rawOwnerName,
    ...(Array.isArray(extraHints) ? extraHints : [extraHints]),
  ]);
  return aliasContext.preferredName || normalizeExtractionOwnerText(rawOwnerName);
}

function resolveCharacterOwnerCandidate(graph, ownerName = "", ownerNodeId = "") {
  const resolved = resolveKnowledgeOwner(graph, {
    ownerType: "character",
    ownerName,
    nodeId: ownerNodeId,
  });
  return resolved?.ownerType === "character" && resolved?.ownerKey ? resolved : null;
}

function deriveExtractionOwnerContext(
  graph,
  normalizedResult = {},
  scopeRuntime = {},
) {
  const ownerMap = new Map();
  const registerCharacterOwner = (ownerName = "", ownerNodeId = "", source = "") => {
    if (
      normalizeExtractionOwnerText(ownerName) &&
      !normalizeExtractionOwnerText(ownerNodeId) &&
      matchesExtractorUserAlias(ownerName, scopeRuntime)
    ) {
      return;
    }
    const resolved = resolveCharacterOwnerCandidate(graph, ownerName, ownerNodeId);
    if (!resolved?.ownerKey) return;
    const existing = ownerMap.get(resolved.ownerKey) || {
      ...resolved,
      sources: [],
    };
    if (source && !existing.sources.includes(source)) {
      existing.sources.push(source);
    }
    ownerMap.set(resolved.ownerKey, existing);
  };

  for (const op of Array.isArray(normalizedResult?.operations)
    ? normalizedResult.operations
    : []) {
    if (String(op?.type || "") === "pov_memory") {
      registerCharacterOwner(
        op?.scope?.ownerName || op?.scope?.ownerId,
        "",
        "pov-memory-scope",
      );
    }
    if (
      String(op?.type || "") === "character" &&
      ["create", "update"].includes(String(op?.action || ""))
    ) {
      registerCharacterOwner(
        op?.fields?.name || "",
        String(op?.nodeId || ""),
        "character-operation",
      );
    }
  }

  for (const entry of Array.isArray(normalizedResult?.cognitionUpdates)
    ? normalizedResult.cognitionUpdates
    : []) {
    if (String(entry?.ownerType || "") !== "character") continue;
    registerCharacterOwner(
      entry?.ownerName || entry?.ownerId,
      entry?.ownerNodeId,
      "cognition-update",
    );
  }

  const runtimeOwner = resolveCharacterOwnerCandidate(
    graph,
    scopeRuntime.activeCharacterOwner,
    "",
  );
  if (runtimeOwner?.ownerKey && runtimeOwner?.nodeId && ownerMap.size <= 1) {
    registerCharacterOwner(runtimeOwner.ownerName, runtimeOwner.nodeId, "runtime-unique");
  }

  const ownerCandidates = [...ownerMap.values()];
  return {
    ownerCandidates,
    soleCharacterOwner: ownerCandidates.length === 1 ? ownerCandidates[0] : null,
  };
}

function normalizeCognitionUpdatesWithOwnerContext(
  graph,
  cognitionUpdates = [],
  scopeRuntime = {},
  ownerContext = {},
  ownershipWarnings = [],
) {
  const normalized = [];

  for (const entry of Array.isArray(cognitionUpdates) ? cognitionUpdates : []) {
    const ownerType = normalizeExtractionOwnerText(entry?.ownerType);
    const rawOwnerName = normalizeExtractionOwnerText(
      entry?.ownerName || entry?.ownerId,
    );
    const rawOwnerNodeId = normalizeExtractionOwnerText(entry?.ownerNodeId);
    if (ownerType === "character") {
      if (
        rawOwnerName &&
        !rawOwnerNodeId &&
        matchesExtractorUserAlias(rawOwnerName, scopeRuntime)
      ) {
        const resolvedUserName = resolveExtractorUserOwnerName(
          scopeRuntime,
          rawOwnerName,
        );
        if (!resolvedUserName) {
          ownershipWarnings.push({
            kind: "invalid-owner-scope",
            source: "cognitionUpdate",
            ownerType: "user",
          });
          continue;
        }
        normalized.push({
          ...entry,
          ownerType: "user",
          ownerName: resolvedUserName,
          ownerId: resolvedUserName,
          ownerNodeId: "",
        });
        continue;
      }
      const resolved =
        resolveCharacterOwnerCandidate(
          graph,
          rawOwnerName,
          rawOwnerNodeId,
        ) || ownerContext?.soleCharacterOwner || null;
      if (!resolved?.ownerKey) {
        ownershipWarnings.push({
          kind: "invalid-owner-scope",
          source: "cognitionUpdate",
          ownerType,
        });
        continue;
      }
      normalized.push({
        ...entry,
        ownerType: "character",
        ownerName: resolved.ownerName,
        ownerId: resolved.ownerName,
        ownerNodeId: resolved.nodeId || normalizeExtractionOwnerText(entry?.ownerNodeId),
      });
      continue;
    }

    if (ownerType === "user") {
      const resolvedUserName = resolveExtractorUserOwnerName(
        scopeRuntime,
        rawOwnerName,
      );
      if (!resolvedUserName) {
        ownershipWarnings.push({
          kind: "invalid-owner-scope",
          source: "cognitionUpdate",
          ownerType,
        });
        continue;
      }
      normalized.push({
        ...entry,
        ownerType: "user",
        ownerName: resolvedUserName,
        ownerId: resolvedUserName,
      });
      continue;
    }

    if (ownerContext?.soleCharacterOwner) {
      normalized.push({
        ...entry,
        ownerType: "character",
        ownerName: ownerContext.soleCharacterOwner.ownerName,
        ownerId: ownerContext.soleCharacterOwner.ownerName,
        ownerNodeId: ownerContext.soleCharacterOwner.nodeId || "",
      });
      continue;
    }

    ownershipWarnings.push({
      kind: "invalid-owner-scope",
      source: "cognitionUpdate",
      ownerType,
    });
  }

  return normalized;
}

function supportsPointStoryTime(type = "") {
  return ["event", "pov_memory"].includes(String(type || ""));
}

function supportsSpanStoryTime(type = "") {
  return ["thread", "synopsis", "reflection"].includes(String(type || ""));
}

function resolveOperationStoryTime(
  graph,
  op = {},
  batchStoryTime = null,
  { source = "extract" } = {},
) {
  const explicitStoryTime = normalizeStoryTime(op?.storyTime, { source });
  const fallbackStoryTime = normalizeStoryTime(batchStoryTime, { source });
  const candidate =
    explicitStoryTime.segmentId || explicitStoryTime.label
      ? explicitStoryTime
      : fallbackStoryTime.segmentId || fallbackStoryTime.label
        ? fallbackStoryTime
        : null;
  if (!candidate) {
    return {
      storyTime: normalizeStoryTime(),
      storyTimeSpan: createSpanFromStoryTime(null, source),
      timelineAdvanceApplied: false,
    };
  }

  const activeReferenceSegmentId = String(
    graph?.historyState?.activeStorySegmentId ||
      graph?.historyState?.lastExtractedStorySegmentId ||
      "",
  ).trim();
  const upserted = upsertTimelineSegment(graph, candidate, {
    referenceSegmentId: activeReferenceSegmentId,
    source,
  });
  return {
    storyTime: upserted.storyTime,
    storyTimeSpan: createSpanFromStoryTime(upserted.storyTime, source),
    timelineAdvanceApplied: false,
  };
}

function applyOperationStoryTimeToNode(
  graph,
  node,
  op = {},
  batchStoryTime = null,
  { source = "extract" } = {},
) {
  if (!node || typeof node !== "object") return;
  const resolved = resolveOperationStoryTime(graph, op, batchStoryTime, { source });
  if (supportsPointStoryTime(node.type)) {
    node.storyTime = resolved.storyTime;
    node.storyTimeSpan = createSpanFromStoryTime(null, source);
    return;
  }
  if (supportsSpanStoryTime(node.type)) {
    node.storyTime = normalizeStoryTime();
    node.storyTimeSpan = resolved.storyTimeSpan;
    return;
  }
  node.storyTime = normalizeStoryTime();
  node.storyTimeSpan = createSpanFromStoryTime(null, source);
}

/**
 * 对未处理的对话楼层执行记忆提取
 *
 * @param {object} params
 * @param {object} params.graph - 当前图状态
 * @param {Array<{seq?: number, role: string, content: string}>} params.messages - 要处理的对话消息
 * @param {number} params.startSeq - 本批处理的首个 assistant 消息 chat 索引
 * @param {number} params.endSeq - 本批处理的末个 assistant 消息 chat 索引
 * @param {number} [params.lastProcessedSeq] - 上次处理到的 chat 索引
 * @param {object[]} params.schema - 节点类型 Schema
 * @param {object} params.embeddingConfig - Embedding API 配置
 * @param {string} [params.extractPrompt] - 自定义提取提示词
 * @param {object} [params.v2Options] - v2 增强选项
 * @returns {Promise<{success: boolean, newNodes: number, updatedNodes: number, newEdges: number, newNodeIds: string[], processedRange: [number, number]}>}
 */
export async function extractMemories({
  graph,
  messages,
  startSeq,
  endSeq,
  lastProcessedSeq = -1,
  schema,
  embeddingConfig,
  extractPrompt,
  signal = undefined,
  settings = {},
  onStreamProgress = null,
}) {
  throwIfAborted(signal);
  if (!messages || messages.length === 0) {
    return {
      success: true,
      newNodes: 0,
      updatedNodes: 0,
      newEdges: 0,
      newNodeIds: [],
      processedRange: [lastProcessedSeq, lastProcessedSeq],
    };
  }

  const effectiveStartSeq = Number.isFinite(startSeq)
    ? startSeq
    : (messages.find((m) => Number.isFinite(m.seq))?.seq ??
      lastProcessedSeq + 1);
  const effectiveEndSeq = Number.isFinite(endSeq)
    ? endSeq
    : ([...messages].reverse().find((m) => Number.isFinite(m.seq))?.seq ??
      effectiveStartSeq);
  const currentSeq = effectiveEndSeq;
  const stContext = getSTContextSnapshot();
  const scopeRuntime = {
    activeCharacterOwner: stContext?.prompt?.charName || "",
    activeUserOwner: stContext?.prompt?.userName || "",
  };

  debugLog(
    `[ST-BME] 提取开始: chat[${effectiveStartSeq}..${effectiveEndSeq}], ${messages.length} 条消息`,
  );

  const extractionInput = buildExtractionInputContext(messages, {
    settings,
    userName: stContext?.prompt?.userName || "",
    charName: stContext?.prompt?.charName || "",
  });
  const allStructuredMessages = Array.isArray(extractionInput?.filteredMessages)
    ? extractionInput.filteredMessages.map((message) => ({
        seq: message?.seq,
        role: message?.role,
        content: message?.content,
        speaker: message?.speaker,
        name: message?.name,
        hideSpeakerLabel: message?.hideSpeakerLabel === true,
        isContextOnly: message?.isContextOnly === true,
      }))
    : [];

  // Phase 3: apply recent message cap
  const structuredMessages = applyRecentMessageCap(
    allStructuredMessages,
    settings?.extractRecentMessageCap,
  );
  const cappedMessageCount = allStructuredMessages.length - structuredMessages.length;
  if (cappedMessageCount > 0) {
    debugLog(
      `[ST-BME][extract-p3] extractRecentMessageCap=${settings?.extractRecentMessageCap}, ` +
        `capped ${cappedMessageCount} messages (${allStructuredMessages.length} -> ${structuredMessages.length})`,
    );
  }

  // Phase 3: structured mode determines what goes into recentMessages/dialogueText
  const structuredMode = resolveExtractPromptStructuredMode(settings);
  const dialogueText = structuredMode === "structured"
    ? ""
    : String(extractionInput?.filteredTranscript || "");
  const promptRecentMessages = structuredMode === "transcript"
    ? dialogueText
    : structuredMessages;

  const extractGraphRankingQuery = buildExtractRankingQueryText(structuredMessages);
  const extractGraphStats = await buildTaskGraphStats({
    graph,
    schema,
    userMessage: extractGraphRankingQuery,
    recentMessages: [],
    embeddingConfig,
    signal,
    settings,
    rankingOptions: {
      topK: 12,
      diffusionTopK: 48,
      enableContextQueryBlend: false,
      enableMultiIntent: true,
      maxTextLength: 1200,
    },
    relevantHeading: "与当前提取片段最相关的既有节点",
  });
  const extractGraphRanking = extractGraphStats.ranking;
  const extractGraphRelevantNodes = extractGraphStats.relevantReferenceMap;
  const graphOverview = extractGraphStats.graphStats;

  // 构建 Schema 描述
  const schemaDescription = buildSchemaDescription(schema);
  const currentRange =
    messages.length > 0
      ? `${messages[0]?.seq ?? "?"} ~ ${messages[messages.length - 1]?.seq ?? "?"}`
      : "";

  // Phase 3: layered context — active summaries and story time
  const activeSummaries = settings?.extractIncludeSummaries !== false
    ? buildActiveSummariesText(graph)
    : "";
  const storyTimeContext = settings?.extractIncludeStoryTime !== false
    ? buildStoryTimeContextText(graph)
    : "";

  debugLog(
    `[ST-BME][extract-p3] structuredMode=${structuredMode}, ` +
      `activeSummaries=${activeSummaries ? activeSummaries.split("\n").length + " entries" : "none"}, ` +
      `storyTimeContext=${storyTimeContext ? "present" : "none"}, ` +
      `worldbookMode=${String(settings?.extractWorldbookMode || "active")}`,
  );
  if (extractGraphRanking) {
    debugLog(
      `[ST-BME][extract-graph] relevantNodes=${extractGraphRelevantNodes.references.length}, ` +
        `vectorMergedHits=${Number(extractGraphRanking?.diagnostics?.vectorMergedHits || 0)}, ` +
        `diffusionHits=${Number(extractGraphRanking?.diagnostics?.diffusionHits || 0)}, ` +
        `lexicalBoostedNodes=${Number(extractGraphRanking?.diagnostics?.lexicalBoostedNodes || 0)}`,
    );
  }

  const extractWorldbookMode = String(settings?.extractWorldbookMode || "active").trim().toLowerCase();
  const promptBuild = await buildTaskPrompt(settings, "extract", {
    taskName: "extract",
    schema: schemaDescription,
    schemaDescription,
    recentMessages: promptRecentMessages,
    chatMessages: structuredMessages,
    dialogueText,
    graphStats: graphOverview,
    graphOverview,
    currentRange,
    activeSummaries,
    storyTimeContext,
    taskInputDebug: extractionInput?.debug || null,
    __skipWorldInfo: extractWorldbookMode === "none",
    ...getSTContextForPrompt(),
  });

  // 系统提示词
  const extractRegexInput = { entries: [] };
  const systemPrompt = applyTaskRegex(
    settings,
    "extract",
    "finalPrompt",
    promptBuild.systemPrompt ||
      extractPrompt ||
      buildDefaultExtractPrompt(schema),
    extractRegexInput,
    "system",
  );

  // 用户提示词 — Phase 3 分层信息结构
  const userPromptSections = [];

  // Layer 1: 当前对话切片（区分上下文回顾 vs 提取目标）
  {
    const hasContextMessages = structuredMessages.some((m) => m?.isContextOnly === true);
    const hasTargetMessages = structuredMessages.some((m) => m?.isContextOnly !== true);
    if (dialogueText) {
      if (hasContextMessages && hasTargetMessages) {
        userPromptSections.push(
          "## 对话内容",
          "以下对话包含两部分：已提取过的上下文回顾（仅供理解前情）和本次需要提取记忆的新内容。" +
            "请**只从新内容中提取记忆**，不要重复提取上下文回顾中已有的信息。",
          dialogueText,
          "",
        );
      } else {
        userPromptSections.push("## 当前对话内容（需提取记忆）", dialogueText, "");
      }
    } else if (structuredMode === "structured" && structuredMessages.length > 0) {
      if (hasContextMessages && hasTargetMessages) {
        userPromptSections.push(
          "## 对话内容（结构化消息）",
          "以下结构化消息包含两部分：标记为 isContextOnly 的是已提取过的上下文回顾（仅供理解前情），" +
            "其余是本次需要提取记忆的新内容。请**只从 isContextOnly 为 false 的消息中提取记忆**。" +
            "(结构化消息已通过 profile blocks 注入，请参考上方 recentMessages 块。)",
          "",
        );
      } else {
        userPromptSections.push(
          "## 当前对话内容（结构化消息，需提取记忆）",
          "(结构化消息已通过 profile blocks 注入，请参考上方 recentMessages 块。)",
          "",
        );
      }
    }
  }

  // Layer 2: 当前图谱状态
  userPromptSections.push(
    "## 当前图谱状态",
    graphOverview || "(空图谱，尚无节点)",
    "",
  );

  // Layer 3: 已有总结快照（帮助避免重复提取）
  if (activeSummaries) {
    userPromptSections.push(
      "## 近期局面总结（已有覆盖，避免重复）",
      activeSummaries,
      "",
    );
  }

  // Layer 4: 故事时间线位置
  if (storyTimeContext) {
    userPromptSections.push(
      "## 当前故事时间",
      storyTimeContext,
      "",
    );
  }

  // Layer 5: 节点类型定义
  userPromptSections.push("## 节点类型定义", schemaDescription, "");

  userPromptSections.push("请分析对话，按 JSON 格式输出操作列表。");
  const userPrompt = userPromptSections.join("\n");
  const promptPayload = resolveTaskPromptPayload(promptBuild, userPrompt);
  const extractionAugmentPrompt = buildCognitiveExtractAugmentPrompt();
  const promptPayloadAdditionalMessages = Array.isArray(
    promptPayload.additionalMessages,
  )
    ? [
        ...promptPayload.additionalMessages,
        {
          role: "system",
          content: extractionAugmentPrompt,
        },
      ]
    : [
        {
          role: "system",
          content: extractionAugmentPrompt,
        },
      ];
  const llmSystemPrompt = resolveTaskLlmSystemPrompt(
    promptPayload,
    systemPrompt,
  );

  // 诊断：追踪 promptPayload
  {
    const pm = Array.isArray(promptPayload.promptMessages) ? promptPayload.promptMessages : [];
    const pmUser = pm.filter((m) => m?.role === "user");
    const am = Array.isArray(promptPayload.additionalMessages) ? promptPayload.additionalMessages : [];
    debugLog(
      `[ST-BME][prompt-diag] resolveTaskPromptPayload: ` +
        `promptMessages=${pm.length} (user=${pmUser.length}), ` +
        `additionalMessages=${am.length}, ` +
        `userPrompt length=${String(promptPayload.userPrompt || "").length}, ` +
        `systemPrompt length=${String(promptPayload.systemPrompt || "").length}, ` +
        `llmSystemPrompt length=${String(llmSystemPrompt || "").length}`,
    );
    if (pmUser.length > 0) {
      for (const m of pmUser) {
        debugLog(
          `[ST-BME][prompt-diag]   user msg: contentLen=${String(m.content || "").length}, ` +
            `blockName="${m.blockName || ""}", preview="${String(m.content || "").slice(0, 60)}..."`,
        );
      }
    } else {
      debugWarn(
        `[ST-BME][prompt-diag]   NO user messages in promptMessages! Fallback userPrompt will be used.`,
      );
    }
    if (extractionInput?.debug) {
      debugLog(
        `[ST-BME][extract-input] raw=${Number(extractionInput.debug.rawMessageCount || 0)}, ` +
          `filtered=${Number(extractionInput.debug.filteredMessageCount || 0)}, ` +
          `assistantChanged=${Number(extractionInput.debug.changedAssistantMessageCount || 0)}, ` +
          `assistantDropped=${Number(extractionInput.debug.droppedAssistantMessageCount || 0)}, ` +
          `extractRules=${Number(extractionInput.debug.assistantBoundaryConfig?.extractRuleCount || 0)}, ` +
          `excludeRules=${Number(extractionInput.debug.assistantBoundaryConfig?.excludeRuleCount || 0)}`,
      );
    }
  }

  // 调用 LLM
  const llmResult = await callLLMForJSON({
    systemPrompt: llmSystemPrompt,
    userPrompt: promptPayload.userPrompt,
    maxRetries: 2,
    signal,
    taskType: "extract",
    debugContext: createExtractTaskLlmDebugContext(
      promptBuild,
      extractRegexInput,
      extractionInput?.debug || null,
    ),
    promptMessages: promptPayload.promptMessages,
    additionalMessages: promptPayloadAdditionalMessages,
    onStreamProgress,
    returnFailureDetails: true,
  });
  throwIfAborted(signal);
  const llmFailure =
    llmResult && typeof llmResult === "object" && "ok" in llmResult
      ? llmResult
      : null;
  const result = llmFailure
    ? llmFailure.ok
      ? llmFailure.data
      : null
    : llmResult;
  const normalizedResult = normalizeExtractionResultPayload(result, schema);
  const ownershipWarnings = [];
  const extractionOwnerContext = deriveExtractionOwnerContext(
    graph,
    normalizedResult,
    scopeRuntime,
  );
  const normalizedCognitionUpdates = normalizeCognitionUpdatesWithOwnerContext(
    graph,
    normalizedResult?.cognitionUpdates,
    scopeRuntime,
    extractionOwnerContext,
    ownershipWarnings,
  );

  if (!normalizedResult || !Array.isArray(normalizedResult.operations)) {
    const diagType = result === null
      ? "null"
      : Array.isArray(result)
        ? `array(len=${result.length})`
        : typeof result;
    const diagKeys = isPlainObject(result)
      ? Object.keys(result).slice(0, 10).join(", ")
      : "";
    const diagPreview = typeof result === "string"
      ? result.slice(0, 120)
      : "";
    console.warn(
      `[ST-BME] 提取 LLM 未返回有效操作 ` +
        `[type=${diagType}]` +
        (diagKeys ? ` [keys=${diagKeys}]` : "") +
        (diagPreview ? ` [preview=${diagPreview}]` : "") +
        (llmFailure?.ok === false && llmFailure?.errorType
          ? ` [failureType=${String(llmFailure.errorType)}]`
          : "") +
        (llmFailure?.ok === false && llmFailure?.failureReason
          ? ` [failureReason=${String(llmFailure.failureReason).slice(0, 200)}]`
          : ""),
    );
    const failureReason =
      llmFailure?.ok === false
        ? String(llmFailure.failureReason || "").trim()
        : "";
    return {
      success: false,
      error: failureReason
        ? `提取 LLM 未返回有效操作: ${failureReason}`
        : "提取 LLM 未返回有效操作",
      newNodes: 0,
      updatedNodes: 0,
      newEdges: 0,
      newNodeIds: [],
      processedRange: [lastProcessedSeq, lastProcessedSeq],
    };
  }

  // 执行操作
  const stats = { newNodes: 0, updatedNodes: 0, newEdges: 0 };
  const newNodeIds = []; // v2: 收集新建节点 ID（用于进化引擎）
  const updatedNodeIds = [];
  const refMap = new Map();
  const pendingLinkJobs = [];
  const suppressedDefaultPairKeys = new Set();
  const operationErrors = [];
  const normalizedBatchStoryTime = normalizedResult?.batchStoryTime || null;

  for (const op of normalizedResult.operations) {
    try {
      switch (op.action) {
        case "create": {
          const createResult = handleCreate(
            graph,
            op,
            currentSeq,
            schema,
            refMap,
            stats,
            scopeRuntime,
            extractionOwnerContext,
            ownershipWarnings,
            normalizedBatchStoryTime,
          );
          if (createResult?.nodeId) {
            queueOperationLinks(pendingLinkJobs, createResult.nodeId, op.links);
          }
          if (createResult?.created === true && createResult.nodeId) {
            newNodeIds.push(createResult.nodeId);
          }
          if (createResult?.updated === true && createResult.nodeId) {
            updatedNodeIds.push(createResult.nodeId);
          }
          break;
        }
        case "update":
          {
            const updatedNodeId = handleUpdate(
              graph,
              op,
              currentSeq,
              stats,
              scopeRuntime,
              extractionOwnerContext,
              ownershipWarnings,
              normalizedBatchStoryTime,
            );
            if (updatedNodeId) {
              updatedNodeIds.push(updatedNodeId);
              queueOperationLinks(pendingLinkJobs, updatedNodeId, op.links);
            }
          }
          break;
        case "delete":
          handleDelete(graph, op, stats);
          break;
        case "_skip":
          // Mem0 对照判定为重复，跳过
          break;
        default: {
          const message = `[ST-BME] 未知操作类型: ${op?.action ?? "<missing>"}`;
          console.warn(message, op);
          operationErrors.push(message);
          break;
        }
      }
    } catch (e) {
      console.error(`[ST-BME] 操作执行失败:`, op, e);
      operationErrors.push(e?.message || String(e));
    }
  }

  if (operationErrors.length > 0) {
    return {
      success: false,
      error: operationErrors.join(" | "),
      ...stats,
      newNodeIds,
      processedRange: [effectiveStartSeq, effectiveEndSeq],
    };
  }

  applyPendingLinks(graph, pendingLinkJobs, refMap, stats, {
    suppressedDefaultPairKeys,
  });
  applyDefaultBatchEdges(
    graph,
    [...new Set([...newNodeIds, ...updatedNodeIds])],
    stats,
    settings,
    {
      suppressedDefaultPairKeys,
    },
  );

  // 为新建节点生成 embedding。失败不应回滚整批图谱写入。
  try {
    await generateNodeEmbeddings(graph, embeddingConfig, signal);
  } catch (error) {
    if (isAbortError(error)) {
      throw error;
    }
    console.error("[ST-BME] 节点 embedding 生成失败，保留图谱写入:", error);
  }

  // 更新处理进度：统一记录为已处理到的末个 chat 索引
  graph.lastProcessedSeq = Math.max(
    graph.lastProcessedSeq ?? -1,
    effectiveEndSeq,
  );
  const changedNodeIds = [...new Set([...newNodeIds, ...updatedNodeIds])];
  if (ownershipWarnings.length > 0) {
    debugWarn(
      `[ST-BME] 已跳过 ${ownershipWarnings.length} 条缺少具体人物 owner 的主观记忆或认知更新`,
    );
  }
  applyCognitionUpdates(graph, normalizedCognitionUpdates, {
    refMap,
    changedNodeIds,
    scopeRuntime,
    source: "extract",
  });
  applyRegionUpdates(graph, normalizedResult.regionUpdates, {
    changedNodeIds,
    source: "extract",
  });
  const batchStoryTimeResult = applyBatchStoryTime(
    graph,
    normalizedBatchStoryTime,
    "extract",
  );
  updateRuntimeScopeState(graph, newNodeIds, scopeRuntime, extractionOwnerContext);

  debugLog(
    `[ST-BME] 提取完成: 新建 ${stats.newNodes}, 更新 ${stats.updatedNodes}, 新边 ${stats.newEdges}, lastProcessedSeq=${graph.lastProcessedSeq}`,
  );

  return {
    success: true,
    error: "",
    ...stats,
    newNodeIds,
    changedNodeIds,
    ownerWarnings: ownershipWarnings,
    batchStoryTime: normalizedBatchStoryTime,
    batchStoryTimeResult,
    processedRange: [effectiveStartSeq, effectiveEndSeq],
  };
}

/**
 * 处理 create 操作
 */
function handleCreate(
  graph,
  op,
  seq,
  schema,
  refMap,
  stats,
  scopeRuntime = {},
  ownerContext = {},
  ownershipWarnings = [],
  batchStoryTime = null,
) {
  const normalizedFields =
    op.type === "event" ? ensureEventTitle(op.fields || {}) : op.fields || {};
  const typeDef = schema.find((s) => s.id === op.type);
  if (!typeDef) {
    console.warn(`[ST-BME] 未知节点类型: ${op.type}`);
    return null;
  }
  const scopeDecision = resolveOperationScope(
    graph,
    op,
    scopeRuntime,
    ownerContext,
  );
  if (scopeDecision.invalidReason) {
    ownershipWarnings.push({
      kind: scopeDecision.invalidReason,
      source: "operation",
      action: String(op?.action || ""),
      type: String(op?.type || ""),
    });
    return null;
  }
  const nodeScope = scopeDecision.scope;

  // latestOnly 类型：检查是否已存在同名节点
  if (typeDef.latestOnly && op.fields?.name) {
    const existing = findLatestNode(
      graph,
      op.type,
      op.fields.name,
      "name",
      nodeScope,
    );
    if (existing) {
      // 转为更新操作
      updateNode(graph, existing.id, { fields: op.fields, seq, scope: nodeScope });
      applyOperationStoryTimeToNode(graph, existing, op, batchStoryTime);
      stats.updatedNodes++;

      if (op.ref) refMap.set(op.ref, existing.id);

      return { nodeId: existing.id, created: false, updated: true };
    }
  }

  // 创建新节点
  const node = createNode({
    type: op.type,
    fields: normalizedFields,
    seq,
    importance: op.importance ?? 5.0,
    clusters: op.clusters || [],
    scope: nodeScope,
  });
  applyOperationStoryTimeToNode(graph, node, op, batchStoryTime);

  addNode(graph, node);
  stats.newNodes++;

  // 保存 ref 用于同批次引用
  if (op.ref) {
    refMap.set(op.ref, node.id);
  }

  return { nodeId: node.id, created: true, updated: false };
}

/**
 * 处理 update 操作
 */
function handleUpdate(
  graph,
  op,
  currentSeq,
  stats,
  scopeRuntime = {},
  ownerContext = {},
  ownershipWarnings = [],
  batchStoryTime = null,
) {
  if (!op.nodeId) {
    console.warn("[ST-BME] update 操作缺少 nodeId");
    return "";
  }

  const previousNode = getNode(graph, op.nodeId);
  if (!previousNode) {
    console.warn(`[ST-BME] update 目标节点不存在: ${op.nodeId}`);
    return "";
  }

  const previousFields = { ...(previousNode.fields || {}) };
  const nextFields =
    previousNode.type === "event"
      ? ensureEventTitle({ ...previousFields, ...(op.fields || {}) })
      : { ...previousFields, ...(op.fields || {}) };
  const changeSummary = buildFieldChangeSummary(previousFields, nextFields);
  const scopeDecision = resolveOperationScope(
    graph,
    op,
    scopeRuntime,
    ownerContext,
    { existingScope: previousNode.scope },
  );
  if (scopeDecision.invalidReason && previousNode.type === "pov_memory") {
    ownershipWarnings.push({
      kind: scopeDecision.invalidReason,
      source: "operation",
      action: String(op?.action || ""),
      type: String(op?.type || ""),
      nodeId: previousNode.id,
    });
  }
  const resolvedScope = scopeDecision.scope;

  const updateSeq = Number.isFinite(op.seq) ? op.seq : currentSeq;
  const updated = updateNode(graph, op.nodeId, {
    fields: op.fields || {},
    seq: Math.max(previousNode.seq || 0, updateSeq),
    scope: resolvedScope,
  });

  if (updated) {
    stats.updatedNodes++;
    const node = getNode(graph, op.nodeId);
    if (node) {
      applyOperationStoryTimeToNode(graph, node, op, batchStoryTime);
      node.embedding = null;
      node.seq = Math.max(node.seq || 0, updateSeq);
      node.seqRange = [
        Math.min(node.seqRange?.[0] ?? node.seq, updateSeq),
        Math.max(node.seqRange?.[1] ?? node.seq, updateSeq),
      ];
    }

    // v2 Graphiti: 标记旧的 updates/temporal_update 边为失效
    const oldEdges = graph.edges.filter(
      (e) =>
        !e.invalidAt &&
        ((e.relation === "updates" && e.toId === op.nodeId) ||
          (e.relation === "temporal_update" &&
            e.toId === op.nodeId &&
            op.sourceNodeId &&
            e.fromId === op.sourceNodeId)),
    );
    for (const e of oldEdges) {
      invalidateEdge(e, graph);
    }

    if (op.sourceNodeId && op.sourceNodeId !== op.nodeId) {
      const temporalEdge = createEdge({
        fromId: op.sourceNodeId,
        toId: op.nodeId,
        relation: "temporal_update",
        strength: op.temporalStrength ?? 0.95,
        edgeType: 0,
      });
      addEdgeWithStats(graph, temporalEdge, stats);
    }

    if (changeSummary) {
      const updateEventNode = createNode({
        type: "event",
        fields: {
          title: `${previousNode.fields?.name || previousNode.fields?.title || previousNode.type} 状态更新`,
          summary: `${previousNode.type} 状态更新：${changeSummary}`,
          participants:
            previousNode.fields?.name ||
            previousNode.fields?.title ||
            previousNode.id,
          status: "resolved",
        },
        seq: updateSeq,
        importance: Math.max(
          4,
          Math.min(8, op.importance ?? previousNode.importance ?? 5),
        ),
        scope: isObjectiveScope(previousNode.scope)
          ? normalizeMemoryScope(previousNode.scope)
          : normalizeMemoryScope({
              layer: "objective",
              regionPrimary: resolvedScope.regionPrimary,
              regionPath: resolvedScope.regionPath,
              regionSecondary: resolvedScope.regionSecondary,
            }),
      });
      addNode(graph, updateEventNode);
      stats.newNodes++;

      const updateEdge = createEdge({
        fromId: updateEventNode.id,
        toId: op.nodeId,
        relation: "updates",
        strength: 0.9,
        edgeType: 0,
        scope: updateEventNode.scope,
      });
      addEdgeWithStats(graph, updateEdge, stats);
    }
  }
  return updated ? op.nodeId : "";
}

function addEdgeWithStats(graph, edge, stats) {
  const addedEdge = addEdge(graph, edge);
  if (addedEdge === edge) {
    stats.newEdges++;
  }
  return addedEdge;
}

function buildUndirectedPairKey(leftId, rightId) {
  const normalizedLeft = String(leftId || "").trim();
  const normalizedRight = String(rightId || "").trim();
  if (!normalizedLeft || !normalizedRight || normalizedLeft === normalizedRight) {
    return "";
  }
  return [normalizedLeft, normalizedRight].sort().join("::");
}

function queueOperationLinks(pendingLinkJobs, sourceId, links) {
  if (!sourceId || !Array.isArray(links) || links.length === 0) return;
  pendingLinkJobs.push({
    sourceId: String(sourceId || ""),
    links,
  });
}

function resolveLinkTargetId(link = {}, refMap = new Map()) {
  let targetId = link.targetNodeId || null;
  if (!targetId && link.targetRef) {
    targetId = refMap.get(link.targetRef);
  }
  return targetId ? String(targetId) : "";
}

function shouldInvalidateLink(link = {}) {
  const action = String(link?.action || "").trim().toLowerCase();
  return (
    link?.remove === true ||
    link?.delete === true ||
    link?.unlink === true ||
    link?.invalidate === true ||
    action === "remove" ||
    action === "delete" ||
    action === "unlink" ||
    action === "invalidate"
  );
}

function isEdgeCurrentlyActive(edge) {
  if (!edge) return false;
  if (edge.invalidAt) return false;
  if (edge.expiredAt) return false;
  return true;
}

function resolveLinkRelation(link = {}, { fallback = "related" } = {}) {
  const relation = String(link?.relation || "").trim();
  if (RELATION_TYPES.includes(relation)) {
    return relation;
  }
  return fallback;
}

function invalidateLinksBetween(graph, sourceId, targetId, relation = "related") {
  if (!sourceId || !targetId) return 0;
  let changed = 0;
  for (const edge of Array.isArray(graph?.edges) ? graph.edges : []) {
    if (!isEdgeCurrentlyActive(edge)) continue;
    if (edge.relation !== relation) continue;
    const sameDirection = edge.fromId === sourceId && edge.toId === targetId;
    const reverseDirection = edge.fromId === targetId && edge.toId === sourceId;
    if (!sameDirection && !reverseDirection) continue;
    invalidateEdge(edge, graph);
    changed += 1;
  }
  return changed;
}

function handleLinks(graph, sourceId, links, refMap, stats, options = {}) {
  const suppressedDefaultPairKeys =
    options?.suppressedDefaultPairKeys instanceof Set
      ? options.suppressedDefaultPairKeys
      : null;
  const sourceNode = getNode(graph, sourceId);
  const sourceScope = normalizeMemoryScope(sourceNode?.scope);
  for (const link of links) {
    const targetId = resolveLinkTargetId(link, refMap);
    if (!targetId) continue;

    if (shouldInvalidateLink(link)) {
      const relation = resolveLinkRelation(link, { fallback: "related" });
      if (suppressedDefaultPairKeys && relation === "related") {
        const pairKey = buildUndirectedPairKey(sourceId, targetId);
        if (pairKey) {
          suppressedDefaultPairKeys.add(pairKey);
        }
      }
      invalidateLinksBetween(graph, sourceId, targetId, relation);
      continue;
    }

    const relation = resolveLinkRelation(link, { fallback: "related" });
    const edgeType = relation === "contradicts" ? 255 : 0;
    const edge = createEdge({
      fromId: sourceId,
      toId: targetId,
      relation,
      strength: link.strength ?? 0.8,
      edgeType,
      scope: link.scope || sourceScope,
    });

    addEdgeWithStats(graph, edge, stats);
  }
}

function applyPendingLinks(
  graph,
  pendingLinkJobs,
  refMap,
  stats,
  { suppressedDefaultPairKeys = null } = {},
) {
  for (const job of Array.isArray(pendingLinkJobs) ? pendingLinkJobs : []) {
    if (!job?.sourceId || !Array.isArray(job?.links) || job.links.length === 0) {
      continue;
    }
    handleLinks(graph, job.sourceId, job.links, refMap, stats, {
      suppressedDefaultPairKeys,
    });
  }
}

function hasActiveEdgeBetween(graph, leftId, rightId) {
  if (!leftId || !rightId) return false;
  return (Array.isArray(graph?.edges) ? graph.edges : []).some((edge) => {
    if (!isEdgeCurrentlyActive(edge)) return false;
    return (
      (edge.fromId === leftId && edge.toId === rightId) ||
      (edge.fromId === rightId && edge.toId === leftId)
    );
  });
}

function applyDefaultBatchEdges(
  graph,
  nodeIds,
  stats,
  settings = {},
  { suppressedDefaultPairKeys = null } = {},
) {
  if (settings?.extractDefaultBatchRelatedEdges === false) {
    return 0;
  }
  const strength = Math.max(
    0,
    Math.min(1, Number(settings?.extractDefaultBatchRelatedEdgeStrength) || 0.25),
  );
  if (strength <= 0) return 0;

  const orderedIds = [...new Set(
    (Array.isArray(nodeIds) ? nodeIds : [])
      .map((nodeId) => String(nodeId || "").trim())
      .filter(Boolean),
  )].filter((nodeId) => {
    const node = getNode(graph, nodeId);
    return Boolean(node) && node.archived !== true;
  });

  let createdCount = 0;
  for (let leftIndex = 0; leftIndex < orderedIds.length; leftIndex += 1) {
    for (let rightIndex = leftIndex + 1; rightIndex < orderedIds.length; rightIndex += 1) {
      const sourceId = orderedIds[leftIndex];
      const targetId = orderedIds[rightIndex];
      const pairKey = buildUndirectedPairKey(sourceId, targetId);
      if (suppressedDefaultPairKeys?.has(pairKey)) {
        continue;
      }
      if (hasActiveEdgeBetween(graph, sourceId, targetId)) {
        continue;
      }
      const sourceNode = getNode(graph, sourceId);
      const edge = createEdge({
        fromId: sourceId,
        toId: targetId,
        relation: "related",
        strength,
        edgeType: 0,
        scope: normalizeMemoryScope(sourceNode?.scope),
      });
      if (addEdgeWithStats(graph, edge, stats) === edge) {
        createdCount += 1;
      }
    }
  }
  return createdCount;
}

function buildFieldChangeSummary(previousFields = {}, nextFields = {}) {
  const changes = [];
  const keys = new Set([
    ...Object.keys(previousFields),
    ...Object.keys(nextFields),
  ]);

  for (const key of keys) {
    const before = previousFields[key];
    const after = nextFields[key];
    if (before === after) continue;

    const beforeText = before == null || before === "" ? "空" : String(before);
    const afterText = after == null || after === "" ? "空" : String(after);
    changes.push(`${key}: ${beforeText} -> ${afterText}`);
  }

  return changes.slice(0, 3).join("；");
}

/**
 * 处理 delete 操作
 */
function handleDelete(graph, op, stats) {
  if (!op.nodeId) return;
  updateNode(graph, op.nodeId, { archived: true });
}

function resolveOperationScope(
  graph,
  op,
  scopeRuntime = {},
  ownerContext = {},
  { existingScope = null } = {},
) {
  const fallbackScope = normalizeMemoryScope(
    existingScope || { layer: op?.type === "pov_memory" ? "pov" : "objective" },
  );

  if (op?.type !== "pov_memory") {
    return {
      scope: op?.scope
        ? normalizeMemoryScope(op.scope, existingScope || {})
        : fallbackScope.layer === "objective"
          ? fallbackScope
          : normalizeMemoryScope({ layer: "objective" }),
      invalidReason: "",
    };
  }

  if (!op?.scope && existingScope) {
    return {
      scope: normalizeMemoryScope(existingScope),
      invalidReason: "",
    };
  }

  const rawScope = op?.scope ? normalizeMemoryScope(op.scope) : null;
  const ownerType = String(rawScope?.ownerType || "").trim();
  const explicitOwnerName = normalizeExtractionOwnerText(
    rawScope?.ownerName || rawScope?.ownerId,
  );
  const explicitOwnerNodeId = normalizeExtractionOwnerText(
    op?.scope?.ownerNodeId || op?.scope?.owner_node_id,
  );

  if (ownerType === "user") {
    const userName = resolveExtractorUserOwnerName(
      scopeRuntime,
      explicitOwnerName,
    );
    if (!userName) {
      return {
        scope: fallbackScope,
        invalidReason: "invalid-owner-scope",
      };
    }
    return {
      scope: normalizeMemoryScope({
        ...(rawScope || {}),
        layer: "pov",
        ownerType: "user",
        ownerId: userName,
        ownerName: userName,
      }),
      invalidReason: "",
    };
  }

  if (
    ownerType === "character" &&
    explicitOwnerName &&
    !explicitOwnerNodeId &&
    matchesExtractorUserAlias(explicitOwnerName, scopeRuntime)
  ) {
    const userName = resolveExtractorUserOwnerName(
      scopeRuntime,
      explicitOwnerName,
    );
    if (!userName) {
      return {
        scope: fallbackScope,
        invalidReason: "invalid-owner-scope",
      };
    }
    return {
      scope: normalizeMemoryScope({
        ...(rawScope || {}),
        layer: "pov",
        ownerType: "user",
        ownerId: userName,
        ownerName: userName,
      }),
      invalidReason: "",
    };
  }

  const resolvedCharacterOwner =
    resolveCharacterOwnerCandidate(graph, explicitOwnerName, explicitOwnerNodeId) ||
    ownerContext?.soleCharacterOwner ||
    null;
  if (!resolvedCharacterOwner?.ownerKey) {
    return {
      scope: fallbackScope,
      invalidReason: "invalid-owner-scope",
    };
  }

  return {
    scope: normalizeMemoryScope({
      ...(rawScope || {}),
      layer: "pov",
      ownerType: "character",
      ownerId: resolvedCharacterOwner.ownerName,
      ownerName: resolvedCharacterOwner.ownerName,
    }),
    invalidReason: "",
  };
}

function updateRuntimeScopeState(
  graph,
  newNodeIds = [],
  scopeRuntime = {},
  ownerContext = {},
) {
  if (!graph?.historyState || typeof graph.historyState !== "object") {
    return;
  }

  graph.historyState.activeCharacterPovOwner =
    String(ownerContext?.soleCharacterOwner?.ownerName || "");
  graph.historyState.activeUserPovOwner =
    String(scopeRuntime.activeUserOwner || "");

  const objectiveCandidates = (Array.isArray(newNodeIds) ? newNodeIds : [])
    .map((nodeId) => getNode(graph, nodeId))
    .filter((node) => node && !node.archived && isObjectiveScope(node.scope))
    .sort((a, b) => (b.seqRange?.[1] ?? b.seq ?? 0) - (a.seqRange?.[1] ?? a.seq ?? 0));

  const regionNode =
    objectiveCandidates.find((node) => node.scope?.regionPrimary) ||
    getActiveNodes(graph)
      .filter((node) => !node.archived && isObjectiveScope(node.scope))
      .sort((a, b) => (b.seqRange?.[1] ?? b.seq ?? 0) - (a.seqRange?.[1] ?? a.seq ?? 0))
      .find((node) => node.scope?.regionPrimary);

  if (regionNode?.scope?.regionPrimary) {
    graph.historyState.lastExtractedRegion = String(
      regionNode.scope.regionPrimary || "",
    );
    if (!String(graph?.regionState?.manualActiveRegion || "").trim()) {
      graph.historyState.activeRegion = String(regionNode.scope.regionPrimary || "");
      graph.historyState.activeRegionSource = "extract";
    }
  }
}

/**
 * 为缺少 embedding 的节点生成向量
 */
async function generateNodeEmbeddings(graph, embeddingConfig, signal) {
  if (!isDirectVectorConfig(embeddingConfig)) return;
  throwIfAborted(signal);

  const needsEmbedding = graph.nodes.filter(
    (n) =>
      !n.archived && (!Array.isArray(n.embedding) || n.embedding.length === 0),
  );

  if (needsEmbedding.length === 0) return;

  const texts = needsEmbedding.map(
    (node) => buildNodeVectorText(node) || node.type,
  );

  debugLog(`[ST-BME] 为 ${texts.length} 个节点生成 embedding`);

  const embeddings = await embedBatch(texts, embeddingConfig, { signal });

  for (let i = 0; i < needsEmbedding.length; i++) {
    if (embeddings[i]) {
      needsEmbedding[i].embedding = Array.from(embeddings[i]);
    }
  }
}

/**
 * 构建 Schema 描述文本
 */
function buildSchemaDescription(schema) {
  return schema
    .map((t) => {
      const cols = t.columns
        .map((c) => `${c.name}${c.required ? "(必填)" : ""}: ${c.hint}`)
        .join("\n    ");
      return `类型 "${t.id}" (${t.label}):\n    ${cols}`;
    })
    .join("\n\n");
}

/**
 * 构建默认提取提示词
 */
function buildDefaultExtractPrompt(schema) {
  const typeNames = schema.map((s) => `${s.id}(${s.label})`).join(", ");

  return [
    "你是一个记忆提取分析器。从对话中提取结构化记忆节点并存入知识图谱。",
    "",
    `支持的节点类型：${typeNames}`,
    "",
    "这轮必须同时考虑三层信息：",
    "- 客观事实：继续写入 event / character / location / thread / rule / synopsis / reflection",
    '- 主观记忆：统一写入 pov_memory，使用 scope.layer = "pov"',
    "- 地区归属：能判断时写入 scope.regionPrimary / regionPath / regionSecondary，判断不出来就留空",
    "",
    "输出格式为严格 JSON：",
    "{",
    '  "thought": "你对本段对话的分析（事件/角色变化/新信息/谁如何理解）",',
    '  "batchStoryTime": {"label": "第二天清晨", "tense": "ongoing", "relation": "after", "anchorLabel": "昨夜冲突之后", "confidence": "high", "advancesActiveTimeline": true},',
    '  "operations": [',
    "    {",
      '      "action": "create",',
      '      "type": "event",',
      '      "fields": {"title": "简短事件名", "summary": "...", "participants": "...", "status": "ongoing"},',
      '      "scope": {"layer": "objective", "regionPrimary": "主地区", "regionPath": ["上级地区", "主地区"], "regionSecondary": ["次级地区"]},',
      '      "storyTime": {"label": "第二天清晨", "tense": "ongoing", "relation": "same", "confidence": "high"},',
      '      "importance": 6,',
      '      "ref": "evt1",',
    '      "links": [',
    '        {"targetNodeId": "existing-id", "relation": "involved_in", "strength": 0.9},',
    '        {"targetRef": "char1", "relation": "occurred_at", "strength": 0.8}',
    "      ]",
    "    },",
    "    {",
    '      "action": "create",',
    '      "type": "pov_memory",',
    '      "fields": {"summary": "角色怎么记住这件事", "belief": "她认为发生了什么", "emotion": "情绪", "attitude": "态度", "certainty": "unsure", "about": "evt1"},',
    '      "scope": {"layer": "pov", "ownerType": "character", "ownerId": "角色名", "ownerName": "角色名", "regionPrimary": "主地区", "regionPath": ["上级地区", "主地区"]}',
    "    },",
    "    {",
    '      "action": "create",',
    '      "type": "pov_memory",',
    '      "fields": {"summary": "用户怎么记住这件事", "belief": "用户视角判断", "emotion": "情绪", "attitude": "态度", "certainty": "certain", "about": "evt1"},',
    '      "scope": {"layer": "pov", "ownerType": "user", "ownerId": "用户名", "ownerName": "用户名"}',
    "    }",
    "  ],",
    '  "cognitionUpdates": [',
    "    {",
    '      "ownerType": "character",',
    '      "ownerName": "艾琳",',
    '      "ownerNodeId": "char-1",',
    '      "knownRefs": ["evt1", "char2"],',
    '      "mistakenRefs": ["evt2"],',
    '      "visibility": [',
    '        {"ref": "evt1", "score": 1.0, "reason": "direct witness"},',
    '        {"ref": "thread-1", "score": 0.55, "reason": "heard nearby"}',
    "      ]",
    "    }",
    "  ],",
    '  "regionUpdates": {',
    '    "activeRegionHint": "钟楼",',
    '    "adjacency": [',
    '      {"region": "钟楼", "adjacent": ["旧城区", "内廷"]}',
    "    ]",
    "  }",
    "}",
    "",
    "规则：",
    "- 每批对话最多创建 1 个事件节点，多个子事件合并为一条",
    "- batchStoryTime 表示这批对话主叙事所处的剧情时间；普通当前场景尽量填写，推不出来就留空",
    "- operations[].storyTime 用于节点自己的剧情时间；不写时系统会继承 batchStoryTime",
    "- 必须区分聊天顺序和剧情顺序，不要把“后说到”误当成“后发生”",
    "- flashback / hypothetical / future 可以写 storyTime，但通常不要把 advancesActiveTimeline 设为 true",
    "- 涉及到的角色都尽量尝试生成对应 POV 记忆和 cognitionUpdates；不必强行覆盖全图所有角色",
    "- cognitionUpdates 用来表达谁确定知道、谁误解了什么、谁只是模糊可见",
    "- 多角色场景里，pov_memory 和 cognitionUpdates 必须写清具体人物；不要把角色卡名当作 POV owner",
    "- 只有在这一批明显只涉及一个具体角色实体时，才允许省略 character POV 的 owner 并让系统安全归属",
    "- knownRefs / mistakenRefs 优先引用同批 ref；没有 ref 再引用现有 nodeId",
    "- regionUpdates 只有在对话里明确出现地区线索时才写；不确定就留空",
    "- 角色/地点节点：如果图中已有同名同作用域节点，用 update 而非 create",
    `- 关系类型限定：${RELATION_TYPES.join(", ")}`,
    "- contradicts 关系用于矛盾/冲突信息",
    "- evolves 关系用于新信息揭示旧记忆需修正的情况",
    "- temporal_update 关系用于实体状态的时序变化",
    "- 不要虚构内容，只提取对话中有证据支持的信息",
    "- 用户 POV 不等于角色已知事实，不要把用户想法伪装成客观事实",
    "- pov_memory 只能用于主观记忆，不要拿 character/location/event 去伪装第一视角记忆",
    "- 地区不确定就留空，不要硬编",
    "- importance 范围 1-10，普通事件 5，关键转折 8+",
    "- event.fields.title 需要是简短事件名，建议 6-10 字，只用于图谱和列表显示",
    "- summary 应该是摘要抽象，不要复制原文",
  ].join("\n");
}

function buildCognitiveExtractAugmentPrompt() {
  return [
    "增强要求：这一轮提取除了 operations，还要尽量补 cognitionUpdates 与 regionUpdates。",
    "- cognitionUpdates 表达谁明确知道哪些客观节点、谁产生了误解、谁只是低置信可见。",
    "- 本批涉及到的角色都尽量尝试生成 POV 和记忆认知更新，不必覆盖全图全部角色。",
    "- ownerType 只能是 character 或 user；ownerName 必须写清楚角色名或用户名。",
    "- 不要把角色卡名、旁白身份或群像统称当成 POV owner；多角色时一定写具体人物。",
    "- knownRefs / mistakenRefs 优先引用同批 ref，没有 ref 再用现有 nodeId。",
    "- visibility.score 取 0..1，1 表示亲历或明确得知，0.5 左右表示间接听闻。",
    "- regionUpdates.activeRegionHint 只在这批对话明确落到某个地区时填写。",
    "- regionUpdates.adjacency 只在文本里明确出现邻接关系时填写，不要猜。",
    "- batchStoryTime.label 尽量写成可复用的剧情时间标签，例如“第二天清晨”“昨夜之后”“回忆里的童年时期”。",
    "- advancesActiveTimeline 只有在这批确实推动当前主叙事时间线时才写 true。",
    "- 若没有认知或空间变化，可返回空数组或空对象，但不要返回无效结构。",
  ].join("\n");
}

// ==================== v2 增强功能 ====================

/**
 * 全局故事概要生成（MemoRAG 启发）
 * 基于图中事件/角色/主线自动生成/更新 synopsis 节点
 *
 * @param {object} params
 * @param {object} params.graph
 * @param {object[]} params.schema
 * @param {number} params.currentSeq
 * @returns {Promise<void>}
 */
export async function generateSynopsis({
  graph,
  schema,
  currentSeq,
  customPrompt,
  signal,
  settings = {},
}) {
  const eventNodes = getActiveNodes(graph, "event").sort(
    (a, b) => a.seq - b.seq,
  );

  if (eventNodes.length < 3) return;

  const eventSummaries = eventNodes
    .map((n) => {
      const storyLabel = describeNodeStoryTime(n);
      return `[楼${n.seq}]${storyLabel ? ` [${storyLabel}]` : ""} ${n.fields.summary || "(无)"}`;
    })
    .join("\n");

  const characterNodes = getActiveNodes(graph, "character");
  const charSummary = characterNodes
    .map((n) => `${n.fields.name}: ${n.fields.state || "(无状态)"}`)
    .join("; ");

  const threadNodes = getActiveNodes(graph, "thread");
  const threadSummary = threadNodes
    .map((n) => {
      const storyLabel = describeNodeStoryTime(n);
      return `${n.fields.title}: ${n.fields.status || "active"}${storyLabel ? `（${storyLabel}）` : ""}`;
    })
    .join("; ");
  const synopsisStoryTimeSpan = deriveStoryTimeSpanFromNodes(
    graph,
    [...eventNodes, ...threadNodes],
    "derived",
  );

  const synopsisPromptBuild = await buildTaskPrompt(settings, "synopsis", {
    taskName: "synopsis",
    eventSummary: eventSummaries,
    characterSummary: charSummary || "(无)",
    threadSummary: threadSummary || "(无)",
    graphStats: `event=${eventNodes.length}, character=${characterNodes.length}, thread=${threadNodes.length}`,
    ...getSTContextForPrompt(),
  });
  const synopsisRegexInput = { entries: [] };
  const synopsisSystemPrompt = applyTaskRegex(
    settings,
    "synopsis",
    "finalPrompt",
    synopsisPromptBuild.systemPrompt ||
      customPrompt ||
      [
        "你是故事概要生成器。根据事件线、角色和主线生成简洁的前情提要。",
        '输出 JSON：{"summary": "前情提要文本（200字以内）"}',
        "要求：涵盖核心冲突、关键转折、主要角色当前状态。",
      ].join("\n"),
    synopsisRegexInput,
    "system",
  );

  const synopsisUserPrompt = [
      "## 事件时间线",
      eventSummaries,
      "",
      "## 角色状态",
      charSummary || "(无)",
      "",
      "## 活跃主线",
      threadSummary || "(无)",
    ].join("\n");
  const synopsisPromptPayload = resolveTaskPromptPayload(
    synopsisPromptBuild,
    synopsisUserPrompt,
  );

  const result = await callLLMForJSON({
    systemPrompt: resolveTaskLlmSystemPrompt(
      synopsisPromptPayload,
      synopsisSystemPrompt,
    ),
    userPrompt: synopsisPromptPayload.userPrompt,
    maxRetries: 1,
    signal,
    taskType: "synopsis",
    debugContext: createTaskLlmDebugContext(
      synopsisPromptBuild,
      synopsisRegexInput,
    ),
    promptMessages: synopsisPromptPayload.promptMessages,
    additionalMessages: synopsisPromptPayload.additionalMessages,
  });

  if (!result?.summary) return;

  const existingSynopsis = graph.nodes.find(
    (n) => n.type === "synopsis" && !n.archived,
  );

  if (existingSynopsis) {
    updateNode(graph, existingSynopsis.id, {
      fields: { summary: result.summary, scope: `楼 1 ~ ${currentSeq}` },
      seq: Math.max(existingSynopsis.seq || 0, currentSeq),
      storyTimeSpan: synopsisStoryTimeSpan,
    });
    existingSynopsis.seqRange = [
      Math.min(existingSynopsis.seqRange?.[0] ?? currentSeq, currentSeq),
      Math.max(existingSynopsis.seqRange?.[1] ?? currentSeq, currentSeq),
    ];
    existingSynopsis.embedding = null;
    debugLog("[ST-BME] 全局概要已更新");
  } else {
    const node = createNode({
      type: "synopsis",
      fields: { summary: result.summary, scope: `楼 1 ~ ${currentSeq}` },
      seq: currentSeq,
      importance: 9.0,
    });
    node.storyTimeSpan = synopsisStoryTimeSpan;
    addNode(graph, node);
    debugLog("[ST-BME] 全局概要已创建");
  }
}

export async function generateReflection({
  graph,
  currentSeq,
  schema = [],
  embeddingConfig,
  customPrompt,
  signal,
  settings = {},
}) {
  const recentEvents = getActiveNodes(graph, "event")
    .sort((a, b) => b.seq - a.seq)
    .slice(0, 6)
    .reverse();

  if (recentEvents.length < 2) return null;

  const recentCharacters = getActiveNodes(graph, "character")
    .sort((a, b) => b.seq - a.seq)
    .slice(0, 5);

  const recentThreads = getActiveNodes(graph, "thread")
    .sort((a, b) => b.seq - a.seq)
    .slice(0, 4);

  const contradictEdges = graph.edges
    .filter((e) => e.relation === "contradicts" && !e.invalidAt)
    .slice(-5);

  const eventSummary = recentEvents
    .map((n) => {
      const storyLabel = describeNodeStoryTime(n);
      return `[楼${n.seq}]${storyLabel ? ` [${storyLabel}]` : ""} ${n.fields.summary || "(无)"}`;
    })
    .join("\n");
  const characterSummary = recentCharacters
    .map(
      (n) =>
        `${n.fields.name || n.fields.title || n.id}: ${n.fields.state || n.fields.summary || "(无)"}`,
    )
    .join("\n");
  const threadSummary = recentThreads
    .map(
      (n) =>
        `${n.fields.title || n.fields.name || n.id}: ${n.fields.status || n.fields.summary || "active"}${describeNodeStoryTime(n) ? `（${describeNodeStoryTime(n)}）` : ""}`,
    )
    .join("\n");
  const reflectionStoryTimeSpan = deriveStoryTimeSpanFromNodes(
    graph,
    [...recentEvents, ...recentThreads],
    "derived",
  );
  const contradictionSummary = contradictEdges
    .map((e) => `${e.fromId} -> ${e.toId} (${e.relation})`)
    .join("\n");
  const reflectionGraphStats = await buildTaskGraphStats({
    graph,
    schema,
    userMessage: buildReflectionRankingQueryText({
      eventSummary,
      characterSummary,
      threadSummary,
      contradictionSummary,
    }),
    recentMessages: [],
    embeddingConfig,
    signal,
    settings,
    rankingOptions: {
      topK: 12,
      diffusionTopK: 48,
      enableContextQueryBlend: false,
      enableMultiIntent: true,
      maxTextLength: 1200,
    },
    relevantHeading: "与当前反思最相关的既有节点",
  });

  const reflectionPromptBuild = await buildTaskPrompt(settings, "reflection", {
    taskName: "reflection",
    eventSummary,
    characterSummary: characterSummary || "(无)",
    threadSummary: threadSummary || "(无)",
    contradictionSummary: contradictionSummary || "(无)",
    graphStats: reflectionGraphStats.graphStats,
    ...getSTContextForPrompt(),
  });
  const reflectionRegexInput = { entries: [] };
  const reflectionSystemPrompt = applyTaskRegex(
    settings,
    "reflection",
    "finalPrompt",
    reflectionPromptBuild.systemPrompt ||
      customPrompt ||
      [
        "你是 RP 长期记忆系统的反思生成器。",
        '输出严格 JSON：{"insight":"...","trigger":"...","suggestion":"...","importance":1-10}',
        "insight 应总结最近情节中最值得长期保留的变化、关系趋势或潜在线索。",
        "trigger 说明触发这条反思的关键事件或矛盾。",
        "suggestion 给出后续检索或叙事上值得关注的提示。",
        "不要复述全部事件，要提炼高层结论。",
      ].join("\n"),
    reflectionRegexInput,
    "system",
  );

  const reflectionUserPrompt = [
      "## 最近事件",
      eventSummary,
      "",
      "## 近期角色状态",
      characterSummary || "(无)",
      "",
      "## 当前主线",
      threadSummary || "(无)",
      "",
      "## 已知矛盾",
      contradictionSummary || "(无)",
    ].join("\n");
  const reflectionPromptPayload = resolveTaskPromptPayload(
    reflectionPromptBuild,
    reflectionUserPrompt,
  );

  const result = await callLLMForJSON({
    systemPrompt: resolveTaskLlmSystemPrompt(
      reflectionPromptPayload,
      reflectionSystemPrompt,
    ),
    userPrompt: reflectionPromptPayload.userPrompt,
    maxRetries: 1,
    signal,
    taskType: "reflection",
    debugContext: createTaskLlmDebugContext(
      reflectionPromptBuild,
      reflectionRegexInput,
    ),
    promptMessages: reflectionPromptPayload.promptMessages,
    additionalMessages: reflectionPromptPayload.additionalMessages,
  });

  if (!result?.insight) return null;

  const reflectionNode = createNode({
    type: "reflection",
    fields: {
      insight: result.insight,
      trigger:
        result.trigger ||
        recentEvents[recentEvents.length - 1]?.fields?.summary ||
        "",
      suggestion: result.suggestion || "",
    },
    seq: currentSeq,
    importance: Math.max(5, Math.min(10, result.importance ?? 7)),
  });
  reflectionNode.storyTimeSpan = reflectionStoryTimeSpan;
  addNode(graph, reflectionNode);

  for (const eventNode of recentEvents.slice(-3)) {
    const edge = createEdge({
      fromId: reflectionNode.id,
      toId: eventNode.id,
      relation: "evolves",
      strength: 0.75,
      edgeType: 0,
    });
    addEdge(graph, edge);
  }

  debugLog("[ST-BME] 反思条目已生成");
  return reflectionNode.id;
}
