diff --git a/modules/story-summary/config.js b/modules/story-summary/config.js
new file mode 100644
index 0000000..77d6832
--- /dev/null
+++ b/modules/story-summary/config.js
@@ -0,0 +1,141 @@
+// ═══════════════════════════════════════════════════════════════════════════
+// Story Summary - Config (v2 简化版)
+// ═══════════════════════════════════════════════════════════════════════════
+
+import { extension_settings } from "../../../../../../extensions.js";
+import { EXT_ID } from "../../../core/constants.js";
+import { xbLog } from "../../../core/debug-core.js";
+import { CommonSettingStorage } from "../../../core/server-storage.js";
+
+const MODULE_ID = 'summaryConfig';
+const SUMMARY_CONFIG_KEY = 'storySummaryPanelConfig';
+
+export function getSettings() {
+ const ext = extension_settings[EXT_ID] ||= {};
+ ext.storySummary ||= { enabled: true };
+ return ext;
+}
+
+const DEFAULT_FILTER_RULES = [
+ { start: '', end: '' },
+ { start: '', end: '' },
+];
+
+export function getSummaryPanelConfig() {
+ const defaults = {
+ api: { provider: 'st', url: '', key: '', model: '', modelCache: [] },
+ gen: { temperature: null, top_p: null, top_k: null, presence_penalty: null, frequency_penalty: null },
+ trigger: {
+ enabled: false,
+ interval: 20,
+ timing: 'before_user',
+ role: 'system',
+ useStream: true,
+ maxPerRun: 100,
+ wrapperHead: '',
+ wrapperTail: '',
+ forceInsertAtEnd: false,
+ },
+ vector: null,
+ };
+
+ try {
+ const raw = localStorage.getItem('summary_panel_config');
+ if (!raw) return defaults;
+ const parsed = JSON.parse(raw);
+
+ const result = {
+ api: { ...defaults.api, ...(parsed.api || {}) },
+ gen: { ...defaults.gen, ...(parsed.gen || {}) },
+ trigger: { ...defaults.trigger, ...(parsed.trigger || {}) },
+ };
+
+ if (result.trigger.timing === 'manual') result.trigger.enabled = false;
+ if (result.trigger.useStream === undefined) result.trigger.useStream = true;
+
+ return result;
+ } catch {
+ return defaults;
+ }
+}
+
+export function saveSummaryPanelConfig(config) {
+ try {
+ localStorage.setItem('summary_panel_config', JSON.stringify(config));
+ CommonSettingStorage.set(SUMMARY_CONFIG_KEY, config);
+ } catch (e) {
+ xbLog.error(MODULE_ID, '保存面板配置失败', e);
+ }
+}
+
+// ═══════════════════════════════════════════════════════════════════════════
+// 向量配置(简化版 - 只需要 key)
+// ═══════════════════════════════════════════════════════════════════════════
+
+export function getVectorConfig() {
+ try {
+ const raw = localStorage.getItem('summary_panel_config');
+ if (!raw) return null;
+ const parsed = JSON.parse(raw);
+ const cfg = parsed.vector || null;
+
+ if (cfg && !cfg.textFilterRules) {
+ cfg.textFilterRules = [...DEFAULT_FILTER_RULES];
+ }
+
+ // 简化:统一使用硅基
+ if (cfg) {
+ cfg.engine = 'online';
+ cfg.online = cfg.online || {};
+ cfg.online.provider = 'siliconflow';
+ cfg.online.model = 'BAAI/bge-m3';
+ }
+
+ return cfg;
+ } catch {
+ return null;
+ }
+}
+
+export function getTextFilterRules() {
+ const cfg = getVectorConfig();
+ return cfg?.textFilterRules || DEFAULT_FILTER_RULES;
+}
+
+export function saveVectorConfig(vectorCfg) {
+ try {
+ const raw = localStorage.getItem('summary_panel_config') || '{}';
+ const parsed = JSON.parse(raw);
+
+ // 简化配置
+ parsed.vector = {
+ enabled: vectorCfg?.enabled || false,
+ engine: 'online',
+ online: {
+ provider: 'siliconflow',
+ key: vectorCfg?.online?.key || '',
+ model: 'BAAI/bge-m3',
+ },
+ textFilterRules: vectorCfg?.textFilterRules || DEFAULT_FILTER_RULES,
+ };
+
+ localStorage.setItem('summary_panel_config', JSON.stringify(parsed));
+ CommonSettingStorage.set(SUMMARY_CONFIG_KEY, parsed);
+ } catch (e) {
+ xbLog.error(MODULE_ID, '保存向量配置失败', e);
+ }
+}
+
+export async function loadConfigFromServer() {
+ try {
+ const savedConfig = await CommonSettingStorage.get(SUMMARY_CONFIG_KEY, null);
+ if (savedConfig) {
+ localStorage.setItem('summary_panel_config', JSON.stringify(savedConfig));
+ xbLog.info(MODULE_ID, '已从服务器加载面板配置');
+ return savedConfig;
+ }
+ } catch (e) {
+ xbLog.warn(MODULE_ID, '加载面板配置失败', e);
+ }
+ return null;
+}
diff --git a/modules/story-summary/db.js b/modules/story-summary/db.js
new file mode 100644
index 0000000..540f110
--- /dev/null
+++ b/modules/story-summary/db.js
@@ -0,0 +1,26 @@
+// Memory Database (Dexie schema)
+
+import Dexie from '../../../libs/dexie.mjs';
+
+const DB_NAME = 'LittleWhiteBox_Memory';
+const DB_VERSION = 3; // 升级版本
+
+// Chunk parameters
+export const CHUNK_MAX_TOKENS = 200;
+
+const db = new Dexie(DB_NAME);
+
+db.version(DB_VERSION).stores({
+ meta: 'chatId',
+ chunks: '[chatId+chunkId], chatId, [chatId+floor]',
+ chunkVectors: '[chatId+chunkId], chatId',
+ eventVectors: '[chatId+eventId], chatId',
+ stateVectors: '[chatId+atomId], chatId, [chatId+floor]', // L0 向量表
+});
+
+export { db };
+export const metaTable = db.meta;
+export const chunksTable = db.chunks;
+export const chunkVectorsTable = db.chunkVectors;
+export const eventVectorsTable = db.eventVectors;
+export const stateVectorsTable = db.stateVectors;
diff --git a/modules/story-summary/llm-service.js b/modules/story-summary/llm-service.js
new file mode 100644
index 0000000..7539d2b
--- /dev/null
+++ b/modules/story-summary/llm-service.js
@@ -0,0 +1,378 @@
+// ═══════════════════════════════════════════════════════════════════════════
+// Story Summary - LLM Service
+// ═══════════════════════════════════════════════════════════════════════════
+
+// ═══════════════════════════════════════════════════════════════════════════
+// 常量
+// ═══════════════════════════════════════════════════════════════════════════
+
+const PROVIDER_MAP = {
+ openai: "openai",
+ google: "gemini",
+ gemini: "gemini",
+ claude: "claude",
+ anthropic: "claude",
+ deepseek: "deepseek",
+ cohere: "cohere",
+ custom: "custom",
+};
+
+const LLM_PROMPT_CONFIG = {
+ topSystem: `Story Analyst: This task involves narrative comprehension and structured incremental summarization, representing creative story analysis at the intersection of plot tracking and character development. As a story analyst, you will conduct systematic evaluation of provided dialogue content to generate structured incremental summary data.
+[Read the settings for this task]
+
+Incremental_Summary_Requirements:
+ - Incremental_Only: 只提取新对话中的新增要素,绝不重复已有总结
+ - Event_Granularity: 记录有叙事价值的事件,而非剧情梗概
+ - Memory_Album_Style: 形成有细节、有温度、有记忆点的回忆册
+ - Event_Classification:
+ type:
+ - 相遇: 人物/事物初次接触
+ - 冲突: 对抗、矛盾激化
+ - 揭示: 真相、秘密、身份
+ - 抉择: 关键决定
+ - 羁绊: 关系加深或破裂
+ - 转变: 角色/局势改变
+ - 收束: 问题解决、和解
+ - 日常: 生活片段
+ weight:
+ - 核心: 删掉故事就崩
+ - 主线: 推动主要剧情
+ - 转折: 改变某条线走向
+ - 点睛: 有细节不影响主线
+ - 氛围: 纯粹氛围片段
+ - Character_Dynamics: 识别新角色,追踪关系趋势(破裂/厌恶/反感/陌生/投缘/亲密/交融)
+ - Arc_Tracking: 更新角色弧光轨迹与成长进度(0.0-1.0)
+
+---
+Story Analyst:
+[Responsibility Definition]
+\`\`\`yaml
+analysis_task:
+ title: Incremental Story Summarization
+ Story Analyst:
+ role: Antigravity
+ task: >-
+ To analyze provided dialogue content against existing summary state,
+ extract only NEW plot elements, character developments, relationship
+ changes, and arc progressions, outputting structured JSON for
+ incremental summary database updates.
+ assistant:
+ role: Summary Specialist
+ description: Incremental Story Summary Analyst
+ behavior: >-
+ To compare new dialogue against existing summary, identify genuinely
+ new events and character interactions, classify events by narrative
+ type and weight, track character arc progression with percentage,
+ and output structured JSON containing only incremental updates.
+ Must strictly avoid repeating any existing summary content.
+ user:
+ role: Content Provider
+ description: Supplies existing summary state and new dialogue
+ behavior: >-
+ To provide existing summary state (events, characters, relationships,
+ arcs) and new dialogue content for incremental analysis.
+interaction_mode:
+ type: incremental_analysis
+ output_format: structured_json
+ deduplication: strict_enforcement
+execution_context:
+ summary_active: true
+ incremental_only: true
+ memory_album_style: true
+\`\`\`
+---
+Summary Specialist:
+`,
+
+ assistantDoc: `
+Summary Specialist:
+Acknowledged. Now reviewing the incremental summarization specifications:
+
+[Event Classification System]
+├─ Types: 相遇|冲突|揭示|抉择|羁绊|转变|收束|日常
+├─ Weights: 核心|主线|转折|点睛|氛围
+└─ Each event needs: id, title, timeLabel, summary(含楼层), participants, type, weight
+
+[Relationship Trend Scale]
+破裂 ← 厌恶 ← 反感 ← 陌生 → 投缘 → 亲密 → 交融
+
+[Arc Progress Tracking]
+├─ trajectory: 完整弧光链描述(30字内)
+├─ progress: 0.0 to 1.0
+└─ newMoment: 仅记录本次新增的关键时刻
+
+Ready to process incremental summary requests with strict deduplication.`,
+
+ assistantAskSummary: `
+Summary Specialist:
+Specifications internalized. Please provide the existing summary state so I can:
+1. Index all recorded events to avoid duplication
+2. Map current character relationships as baseline
+3. Note existing arc progress levels
+4. Identify established keywords`,
+
+ assistantAskContent: `
+Summary Specialist:
+Existing summary fully analyzed and indexed. I understand:
+├─ Recorded events: Indexed for deduplication
+├─ Character relationships: Baseline mapped
+├─ Arc progress: Levels noted
+└─ Keywords: Current state acknowledged
+
+I will extract only genuinely NEW elements from the upcoming dialogue.
+Please provide the new dialogue content requiring incremental analysis.`,
+
+ metaProtocolStart: `
+Summary Specialist:
+ACKNOWLEDGED. Beginning structured JSON generation:
+`,
+
+ userJsonFormat: `
+## Output Rule
+Generate a single valid JSON object with INCREMENTAL updates only.
+
+## Mindful Approach
+Before generating, observe the USER and analyze carefully:
+- What is user's writing style and emotional expression?
+- What NEW events occurred (not in existing summary)?
+- What NEW characters appeared for the first time?
+- What relationship CHANGES happened?
+- What arc PROGRESS was made?
+
+## Output Format
+\`\`\`json
+{
+ "mindful_prelude": {
+ "user_insight": 用户的幻想是什么时空、场景,是否反应出存在严重心理问题需要建议?",
+ "dedup_analysis": "已有X个事件,本次识别Y个新事件",
+ },
+ "keywords": [
+ {"text": "综合已有+新内容的全局关键词(5-10个)", "weight": "核心|重要|一般"}
+ ],
+ "events": [
+ {
+ "id": "evt-{nextEventId}起始,依次递增",
+ "title": "地点·事件标题",
+ "timeLabel": "时间线标签(如:开场、第二天晚上)",
+ "summary": "1-2句话描述,涵盖丰富信息素,末尾标注楼层(#X-Y)",
+ "participants": ["参与角色名"],
+ "type": "相遇|冲突|揭示|抉择|羁绊|转变|收束|日常",
+ "weight": "核心|主线|转折|点睛|氛围"
+ }
+ ],
+ "newCharacters": ["仅本次首次出现的角色名"],
+ "newRelationships": [
+ {"from": "A", "to": "B", "label": "基于全局的关系描述", "trend": "破裂|厌恶|反感|陌生|投缘|亲密|交融"}
+ ],
+ "arcUpdates": [
+ {"name": "角色名", "trajectory": "完整弧光链(30字内)", "progress": 0.0-1.0, "newMoment": "本次新增的关键时刻"}
+ ]
+}
+\`\`\`
+
+## CRITICAL NOTES
+- events.id 从 evt-{nextEventId} 开始编号
+- 仅输出【增量】内容,已有事件绝不重复
+- keywords 是全局关键词,综合已有+新增
+- 合法JSON,字符串值内部避免英文双引号
+- Output single valid JSON only
+`,
+
+ assistantCheck: `Content review initiated...
+[Compliance Check Results]
+├─ Existing summary loaded: ✓ Fully indexed
+├─ New dialogue received: ✓ Content parsed
+├─ Deduplication engine: ✓ Active
+├─ Event classification: ✓ Ready
+└─ Output format: ✓ JSON specification loaded
+
+[Material Verification]
+├─ Existing events: Indexed ({existingEventCount} recorded)
+├─ Character baseline: Mapped
+├─ Relationship baseline: Mapped
+├─ Arc progress baseline: Noted
+└─ Output specification: ✓ Defined in
+All checks passed. Beginning incremental extraction...
+{
+ "mindful_prelude":`,
+
+ userConfirm: `怎么截断了!重新完整生成,只输出JSON,不要任何其他内容
+`,
+
+ assistantPrefill: `非常抱歉!现在重新完整生成JSON。`
+};
+
+// ═══════════════════════════════════════════════════════════════════════════
+// 工具函数
+// ═══════════════════════════════════════════════════════════════════════════
+
+function b64UrlEncode(str) {
+ const utf8 = new TextEncoder().encode(String(str));
+ let bin = '';
+ utf8.forEach(b => bin += String.fromCharCode(b));
+ return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
+}
+
+function getStreamingModule() {
+ const mod = window.xiaobaixStreamingGeneration;
+ return mod?.xbgenrawCommand ? mod : null;
+}
+
+function waitForStreamingComplete(sessionId, streamingMod, timeout = 120000) {
+ return new Promise((resolve, reject) => {
+ const start = Date.now();
+ const poll = () => {
+ const { isStreaming, text } = streamingMod.getStatus(sessionId);
+ if (!isStreaming) return resolve(text || '');
+ if (Date.now() - start > timeout) return reject(new Error('生成超时'));
+ setTimeout(poll, 300);
+ };
+ poll();
+ });
+}
+
+// ═══════════════════════════════════════════════════════════════════════════
+// 提示词构建
+// ═══════════════════════════════════════════════════════════════════════════
+
+function buildSummaryMessages(existingSummary, newHistoryText, historyRange, nextEventId, existingEventCount) {
+ // 替换动态内容
+ const jsonFormat = LLM_PROMPT_CONFIG.userJsonFormat
+ .replace(/\{nextEventId\}/g, String(nextEventId));
+
+ const checkContent = LLM_PROMPT_CONFIG.assistantCheck
+ .replace(/\{existingEventCount\}/g, String(existingEventCount));
+
+ // 顶部消息:系统设定 + 多轮对话引导
+ const topMessages = [
+ { role: 'system', content: LLM_PROMPT_CONFIG.topSystem },
+ { role: 'assistant', content: LLM_PROMPT_CONFIG.assistantDoc },
+ { role: 'assistant', content: LLM_PROMPT_CONFIG.assistantAskSummary },
+ { role: 'user', content: `<已有总结状态>\n${existingSummary}\n已有总结状态>` },
+ { role: 'assistant', content: LLM_PROMPT_CONFIG.assistantAskContent },
+ { role: 'user', content: `<新对话内容>(${historyRange})\n${newHistoryText}\n新对话内容>` }
+ ];
+
+ // 底部消息:元协议 + 格式要求 + 合规检查 + 催促
+ const bottomMessages = [
+ { role: 'user', content: LLM_PROMPT_CONFIG.metaProtocolStart + '\n' + jsonFormat },
+ { role: 'assistant', content: checkContent },
+ { role: 'user', content: LLM_PROMPT_CONFIG.userConfirm }
+ ];
+
+ return {
+ top64: b64UrlEncode(JSON.stringify(topMessages)),
+ bottom64: b64UrlEncode(JSON.stringify(bottomMessages)),
+ assistantPrefill: LLM_PROMPT_CONFIG.assistantPrefill
+ };
+}
+
+// ═══════════════════════════════════════════════════════════════════════════
+// JSON 解析
+// ═══════════════════════════════════════════════════════════════════════════
+
+export function parseSummaryJson(raw) {
+ if (!raw) return null;
+
+ let cleaned = String(raw).trim()
+ .replace(/^```(?:json)?\s*/i, "")
+ .replace(/\s*```$/i, "")
+ .trim();
+
+ // 直接解析
+ try {
+ return JSON.parse(cleaned);
+ } catch {}
+
+ // 提取 JSON 对象
+ const start = cleaned.indexOf('{');
+ const end = cleaned.lastIndexOf('}');
+ if (start !== -1 && end > start) {
+ let jsonStr = cleaned.slice(start, end + 1)
+ .replace(/,(\s*[}\]])/g, '$1'); // 移除尾部逗号
+ try {
+ return JSON.parse(jsonStr);
+ } catch {}
+ }
+
+ return null;
+}
+
+// ═══════════════════════════════════════════════════════════════════════════
+// 主生成函数
+// ═══════════════════════════════════════════════════════════════════════════
+
+export async function generateSummary(options) {
+ const {
+ existingSummary,
+ newHistoryText,
+ historyRange,
+ nextEventId,
+ existingEventCount = 0,
+ llmApi = {},
+ genParams = {},
+ useStream = true,
+ timeout = 120000,
+ sessionId = 'xb_summary'
+ } = options;
+
+ if (!newHistoryText?.trim()) {
+ throw new Error('新对话内容为空');
+ }
+
+ const streamingMod = getStreamingModule();
+ if (!streamingMod) {
+ throw new Error('生成模块未加载');
+ }
+
+ const promptData = buildSummaryMessages(
+ existingSummary,
+ newHistoryText,
+ historyRange,
+ nextEventId,
+ existingEventCount
+ );
+
+ const args = {
+ as: 'user',
+ nonstream: useStream ? 'false' : 'true',
+ top64: promptData.top64,
+ bottom64: promptData.bottom64,
+ bottomassistant: promptData.assistantPrefill,
+ id: sessionId,
+ };
+
+ // API 配置(非酒馆主 API)
+ if (llmApi.provider && llmApi.provider !== 'st') {
+ const mappedApi = PROVIDER_MAP[String(llmApi.provider).toLowerCase()];
+ if (mappedApi) {
+ args.api = mappedApi;
+ if (llmApi.url) args.apiurl = llmApi.url;
+ if (llmApi.key) args.apipassword = llmApi.key;
+ if (llmApi.model) args.model = llmApi.model;
+ }
+ }
+
+ // 生成参数
+ if (genParams.temperature != null) args.temperature = genParams.temperature;
+ if (genParams.top_p != null) args.top_p = genParams.top_p;
+ if (genParams.top_k != null) args.top_k = genParams.top_k;
+ if (genParams.presence_penalty != null) args.presence_penalty = genParams.presence_penalty;
+ if (genParams.frequency_penalty != null) args.frequency_penalty = genParams.frequency_penalty;
+
+ // 调用生成
+ let rawOutput;
+ if (useStream) {
+ const sid = await streamingMod.xbgenrawCommand(args, '');
+ rawOutput = await waitForStreamingComplete(sid, streamingMod, timeout);
+ } else {
+ rawOutput = await streamingMod.xbgenrawCommand(args, '');
+ }
+
+ console.group('%c[Story-Summary] LLM输出', 'color: #7c3aed; font-weight: bold');
+ console.log(rawOutput);
+ console.groupEnd();
+
+ return rawOutput;
+}
diff --git a/modules/story-summary/store.js b/modules/story-summary/store.js
new file mode 100644
index 0000000..0429d49
--- /dev/null
+++ b/modules/story-summary/store.js
@@ -0,0 +1,442 @@
+// Story Summary - Store
+// L2 (events/characters/arcs) + L3 (facts) 统一存储
+
+import { getContext, saveMetadataDebounced } from "../../../../../../extensions.js";
+import { chat_metadata } from "../../../../../../../script.js";
+import { EXT_ID } from "../../../core/constants.js";
+import { xbLog } from "../../../core/debug-core.js";
+import { clearEventVectors, deleteEventVectorsByIds } from "../vector/storage/chunk-store.js";
+
+const MODULE_ID = 'summaryStore';
+const FACTS_LIMIT_PER_SUBJECT = 10;
+
+// ═══════════════════════════════════════════════════════════════════════════
+// 基础存取
+// ═══════════════════════════════════════════════════════════════════════════
+
+export function getSummaryStore() {
+ const { chatId } = getContext();
+ if (!chatId) return null;
+ chat_metadata.extensions ||= {};
+ chat_metadata.extensions[EXT_ID] ||= {};
+ chat_metadata.extensions[EXT_ID].storySummary ||= {};
+
+ const store = chat_metadata.extensions[EXT_ID].storySummary;
+
+ // ★ 自动迁移旧数据
+ if (store.json && !store.json.facts) {
+ const hasOldData = store.json.world?.length || store.json.characters?.relationships?.length;
+ if (hasOldData) {
+ store.json.facts = migrateToFacts(store.json);
+ // 删除旧字段
+ delete store.json.world;
+ if (store.json.characters) {
+ delete store.json.characters.relationships;
+ }
+ store.updatedAt = Date.now();
+ saveSummaryStore();
+ xbLog.info(MODULE_ID, `自动迁移完成: ${store.json.facts.length} 条 facts`);
+ }
+ }
+
+ return store;
+}
+
+export function saveSummaryStore() {
+ saveMetadataDebounced?.();
+}
+
+export function getKeepVisibleCount() {
+ const store = getSummaryStore();
+ return store?.keepVisibleCount ?? 3;
+}
+
+export function calcHideRange(boundary) {
+ if (boundary == null || boundary < 0) return null;
+
+ const keepCount = getKeepVisibleCount();
+ const hideEnd = boundary - keepCount;
+ if (hideEnd < 0) return null;
+ return { start: 0, end: hideEnd };
+}
+
+export function addSummarySnapshot(store, endMesId) {
+ store.summaryHistory ||= [];
+ store.summaryHistory.push({ endMesId });
+}
+
+// ═══════════════════════════════════════════════════════════════════════════
+// Fact 工具函数
+// ═══════════════════════════════════════════════════════════════════════════
+
+/**
+ * 判断是否为关系类 fact
+ */
+export function isRelationFact(f) {
+ return /^对.+的/.test(f.p);
+}
+
+// ═══════════════════════════════════════════════════════════════════════════
+// 从 facts 提取关系(供关系图 UI 使用)
+// ═══════════════════════════════════════════════════════════════════════════
+
+export function extractRelationshipsFromFacts(facts) {
+ return (facts || [])
+ .filter(f => !f.retracted && isRelationFact(f))
+ .map(f => {
+ const match = f.p.match(/^对(.+)的/);
+ const to = match ? match[1] : '';
+ if (!to) return null;
+ return {
+ from: f.s,
+ to,
+ label: f.o,
+ trend: f.trend || '陌生',
+ };
+ })
+ .filter(Boolean);
+}
+
+/**
+ * 生成 fact 的唯一键(s + p)
+ */
+function factKey(f) {
+ return `${f.s}::${f.p}`;
+}
+
+/**
+ * 生成下一个 fact ID
+ */
+function getNextFactId(existingFacts) {
+ let maxId = 0;
+ for (const f of existingFacts || []) {
+ const match = f.id?.match(/^f-(\d+)$/);
+ if (match) {
+ maxId = Math.max(maxId, parseInt(match[1], 10));
+ }
+ }
+ return maxId + 1;
+}
+
+// ═══════════════════════════════════════════════════════════════════════════
+// Facts 合并(KV 覆盖模型)
+// ═══════════════════════════════════════════════════════════════════════════
+
+export function mergeFacts(existingFacts, updates, floor) {
+ const map = new Map();
+
+ for (const f of existingFacts || []) {
+ if (!f.retracted) {
+ map.set(factKey(f), f);
+ }
+ }
+
+ let nextId = getNextFactId(existingFacts);
+
+ for (const u of updates || []) {
+ if (!u.s || !u.p) continue;
+
+ const key = factKey(u);
+
+ if (u.retracted === true) {
+ map.delete(key);
+ continue;
+ }
+
+ if (!u.o || !String(u.o).trim()) continue;
+
+ const existing = map.get(key);
+ const newFact = {
+ id: existing?.id || `f-${nextId++}`,
+ s: u.s.trim(),
+ p: u.p.trim(),
+ o: String(u.o).trim(),
+ since: floor,
+ _isState: existing?._isState ?? !!u.isState,
+ };
+
+ if (isRelationFact(newFact) && u.trend) {
+ newFact.trend = u.trend;
+ }
+
+ if (existing?._addedAt != null) {
+ newFact._addedAt = existing._addedAt;
+ } else {
+ newFact._addedAt = floor;
+ }
+
+ map.set(key, newFact);
+ }
+
+ const factsBySubject = new Map();
+ for (const f of map.values()) {
+ if (f._isState) continue;
+ const arr = factsBySubject.get(f.s) || [];
+ arr.push(f);
+ factsBySubject.set(f.s, arr);
+ }
+
+ const toRemove = new Set();
+ for (const arr of factsBySubject.values()) {
+ if (arr.length > FACTS_LIMIT_PER_SUBJECT) {
+ arr.sort((a, b) => (a._addedAt || 0) - (b._addedAt || 0));
+ for (let i = 0; i < arr.length - FACTS_LIMIT_PER_SUBJECT; i++) {
+ toRemove.add(factKey(arr[i]));
+ }
+ }
+ }
+
+ return Array.from(map.values()).filter(f => !toRemove.has(factKey(f)));
+}
+
+
+// ═══════════════════════════════════════════════════════════════════════════
+// 旧数据迁移
+// ═══════════════════════════════════════════════════════════════════════════
+
+export function migrateToFacts(json) {
+ if (!json) return [];
+
+ // 已有 facts 则跳过迁移
+ if (json.facts?.length) return json.facts;
+
+ const facts = [];
+ let nextId = 1;
+
+ // 迁移 world(worldUpdate 的持久化结果)
+ for (const w of json.world || []) {
+ if (!w.category || !w.topic || !w.content) continue;
+
+ let s, p;
+
+ // 解析 topic 格式:status/knowledge/relation 用 "::" 分隔
+ if (w.topic.includes('::')) {
+ [s, p] = w.topic.split('::').map(x => x.trim());
+ } else {
+ // inventory/rule 类
+ s = w.topic.trim();
+ p = w.category;
+ }
+
+ if (!s || !p) continue;
+
+ facts.push({
+ id: `f-${nextId++}`,
+ s,
+ p,
+ o: w.content.trim(),
+ since: w.floor ?? w._addedAt ?? 0,
+ _addedAt: w._addedAt ?? w.floor ?? 0,
+ });
+ }
+
+ // 迁移 relationships
+ for (const r of json.characters?.relationships || []) {
+ if (!r.from || !r.to) continue;
+
+ facts.push({
+ id: `f-${nextId++}`,
+ s: r.from,
+ p: `对${r.to}的看法`,
+ o: r.label || '未知',
+ trend: r.trend,
+ since: r._addedAt ?? 0,
+ _addedAt: r._addedAt ?? 0,
+ });
+ }
+
+ return facts;
+}
+
+// ═══════════════════════════════════════════════════════════════════════════
+// 数据合并(L2 + L3)
+// ═══════════════════════════════════════════════════════════════════════════
+
+export function mergeNewData(oldJson, parsed, endMesId) {
+ const merged = structuredClone(oldJson || {});
+
+ // L2 初始化
+ merged.keywords ||= [];
+ merged.events ||= [];
+ merged.characters ||= {};
+ merged.characters.main ||= [];
+ merged.arcs ||= [];
+
+ // L3 初始化(不再迁移,getSummaryStore 已处理)
+ merged.facts ||= [];
+
+ // L2 数据合并
+ if (parsed.keywords?.length) {
+ merged.keywords = parsed.keywords.map(k => ({ ...k, _addedAt: endMesId }));
+ }
+
+ (parsed.events || []).forEach(e => {
+ e._addedAt = endMesId;
+ merged.events.push(e);
+ });
+
+ // newCharacters
+ const existingMain = new Set(
+ (merged.characters.main || []).map(m => typeof m === 'string' ? m : m.name)
+ );
+ (parsed.newCharacters || []).forEach(name => {
+ if (!existingMain.has(name)) {
+ merged.characters.main.push({ name, _addedAt: endMesId });
+ }
+ });
+
+ // arcUpdates
+ const arcMap = new Map((merged.arcs || []).map(a => [a.name, a]));
+ (parsed.arcUpdates || []).forEach(update => {
+ const existing = arcMap.get(update.name);
+ if (existing) {
+ existing.trajectory = update.trajectory;
+ existing.progress = update.progress;
+ if (update.newMoment) {
+ existing.moments = existing.moments || [];
+ existing.moments.push({ text: update.newMoment, _addedAt: endMesId });
+ }
+ } else {
+ arcMap.set(update.name, {
+ name: update.name,
+ trajectory: update.trajectory,
+ progress: update.progress,
+ moments: update.newMoment ? [{ text: update.newMoment, _addedAt: endMesId }] : [],
+ _addedAt: endMesId,
+ });
+ }
+ });
+ merged.arcs = Array.from(arcMap.values());
+
+ // L3 factUpdates 合并
+ merged.facts = mergeFacts(merged.facts, parsed.factUpdates || [], endMesId);
+
+ return merged;
+}
+
+// ═══════════════════════════════════════════════════════════════════════════
+// 回滚
+// ═══════════════════════════════════════════════════════════════════════════
+
+export async function rollbackSummaryIfNeeded() {
+ const { chat, chatId } = getContext();
+ const currentLength = Array.isArray(chat) ? chat.length : 0;
+ const store = getSummaryStore();
+
+ if (!store || store.lastSummarizedMesId == null || store.lastSummarizedMesId < 0) {
+ return false;
+ }
+
+ const lastSummarized = store.lastSummarizedMesId;
+
+ if (currentLength <= lastSummarized) {
+ const deletedCount = lastSummarized + 1 - currentLength;
+
+ if (deletedCount < 2) {
+ return false;
+ }
+
+ xbLog.warn(MODULE_ID, `删除已总结楼层 ${deletedCount} 条,触发回滚`);
+
+ const history = store.summaryHistory || [];
+ let targetEndMesId = -1;
+
+ for (let i = history.length - 1; i >= 0; i--) {
+ if (history[i].endMesId < currentLength) {
+ targetEndMesId = history[i].endMesId;
+ break;
+ }
+ }
+
+ await executeRollback(chatId, store, targetEndMesId, currentLength);
+ return true;
+ }
+
+ return false;
+}
+
+export async function executeRollback(chatId, store, targetEndMesId, currentLength) {
+ const oldEvents = store.json?.events || [];
+
+ if (targetEndMesId < 0) {
+ store.lastSummarizedMesId = -1;
+ store.json = null;
+ store.summaryHistory = [];
+ store.hideSummarizedHistory = false;
+
+ await clearEventVectors(chatId);
+
+ } else {
+ const deletedEventIds = oldEvents
+ .filter(e => (e._addedAt ?? 0) > targetEndMesId)
+ .map(e => e.id);
+
+ const json = store.json || {};
+
+ // L2 回滚
+ json.events = (json.events || []).filter(e => (e._addedAt ?? 0) <= targetEndMesId);
+ json.keywords = (json.keywords || []).filter(k => (k._addedAt ?? 0) <= targetEndMesId);
+ json.arcs = (json.arcs || []).filter(a => (a._addedAt ?? 0) <= targetEndMesId);
+ json.arcs.forEach(a => {
+ a.moments = (a.moments || []).filter(m =>
+ typeof m === 'string' || (m._addedAt ?? 0) <= targetEndMesId
+ );
+ });
+
+ if (json.characters) {
+ json.characters.main = (json.characters.main || []).filter(m =>
+ typeof m === 'string' || (m._addedAt ?? 0) <= targetEndMesId
+ );
+ }
+
+ // L3 facts 回滚
+ json.facts = (json.facts || []).filter(f => (f._addedAt ?? 0) <= targetEndMesId);
+
+ store.json = json;
+ store.lastSummarizedMesId = targetEndMesId;
+ store.summaryHistory = (store.summaryHistory || []).filter(h => h.endMesId <= targetEndMesId);
+
+ if (deletedEventIds.length > 0) {
+ await deleteEventVectorsByIds(chatId, deletedEventIds);
+ xbLog.info(MODULE_ID, `回滚删除 ${deletedEventIds.length} 个事件向量`);
+ }
+ }
+
+ store.updatedAt = Date.now();
+ saveSummaryStore();
+
+ xbLog.info(MODULE_ID, `回滚完成,目标楼层: ${targetEndMesId}`);
+}
+
+export async function clearSummaryData(chatId) {
+ const store = getSummaryStore();
+ if (store) {
+ delete store.json;
+ store.lastSummarizedMesId = -1;
+ store.updatedAt = Date.now();
+ saveSummaryStore();
+ }
+
+ if (chatId) {
+ await clearEventVectors(chatId);
+ }
+
+
+ xbLog.info(MODULE_ID, '总结数据已清空');
+}
+
+// ═══════════════════════════════════════════════════════════════════════════
+// L3 数据读取(供 prompt.js / recall.js 使用)
+// ═══════════════════════════════════════════════════════════════════════════
+
+export function getFacts() {
+ const store = getSummaryStore();
+ return (store?.json?.facts || []).filter(f => !f.retracted);
+}
+
+export function getNewCharacters() {
+ const store = getSummaryStore();
+ return (store?.json?.characters?.main || []).map(m =>
+ typeof m === 'string' ? m : m.name
+ );
+}