Parcourir la source

feat: 完成长记忆闭环与流程文档重构

lingfengQAQ il y a 3 mois
Parent
commit
8196b175da
47 fichiers modifiés avec 5041 ajouts et 1291 suppressions
  1. 1 1
      .claude-plugin/marketplace.json
  2. 4 3
      README.md
  3. 20 0
      docs/README.md
  4. 24 0
      docs/commands.md
  5. 335 0
      docs/light-memory-system.md
  6. 701 0
      docs/light-retrofit-plan.md
  7. 616 0
      docs/long-term-memory-architecture-v2.md
  8. 510 0
      docs/long-term-memory-research-report.md
  9. 1 1
      webnovel-writer/.claude-plugin/plugin.json
  10. 182 173
      webnovel-writer/agents/context-agent.md
  11. 133 160
      webnovel-writer/agents/data-agent.md
  12. 8 1
      webnovel-writer/scripts/data_modules/__init__.py
  13. 10 0
      webnovel-writer/scripts/data_modules/config.py
  14. 30 0
      webnovel-writer/scripts/data_modules/context_manager.py
  15. 28 0
      webnovel-writer/scripts/data_modules/memory/__init__.py
  16. 182 0
      webnovel-writer/scripts/data_modules/memory/bootstrap.py
  17. 52 0
      webnovel-writer/scripts/data_modules/memory/budget.py
  18. 111 0
      webnovel-writer/scripts/data_modules/memory/compactor.py
  19. 217 0
      webnovel-writer/scripts/data_modules/memory/orchestrator.py
  20. 152 0
      webnovel-writer/scripts/data_modules/memory/schema.py
  21. 248 0
      webnovel-writer/scripts/data_modules/memory/store.py
  22. 223 0
      webnovel-writer/scripts/data_modules/memory/writer.py
  23. 59 0
      webnovel-writer/scripts/data_modules/schemas.py
  24. 15 1
      webnovel-writer/scripts/data_modules/state_manager.py
  25. 1 0
      webnovel-writer/scripts/data_modules/tests/test_config.py
  26. 44 0
      webnovel-writer/scripts/data_modules/tests/test_context_manager.py
  27. 1 0
      webnovel-writer/scripts/data_modules/tests/test_extract_chapter_context.py
  28. 78 0
      webnovel-writer/scripts/data_modules/tests/test_memory_bootstrap.py
  29. 64 0
      webnovel-writer/scripts/data_modules/tests/test_memory_orchestrator.py
  30. 43 0
      webnovel-writer/scripts/data_modules/tests/test_memory_schema.py
  31. 36 0
      webnovel-writer/scripts/data_modules/tests/test_memory_schema_compat.py
  32. 206 0
      webnovel-writer/scripts/data_modules/tests/test_memory_store.py
  33. 51 0
      webnovel-writer/scripts/data_modules/tests/test_memory_writer.py
  34. 7 0
      webnovel-writer/scripts/data_modules/tests/test_state_manager_extra.py
  35. 5 0
      webnovel-writer/scripts/data_modules/webnovel.py
  36. 32 0
      webnovel-writer/scripts/extract_chapter_context.py
  37. 35 0
      webnovel-writer/scripts/status_reporter.py
  38. 21 27
      webnovel-writer/skills/webnovel-dashboard/SKILL.md
  39. 8 8
      webnovel-writer/skills/webnovel-init/SKILL.md
  40. 146 399
      webnovel-writer/skills/webnovel-plan/SKILL.md
  41. 46 150
      webnovel-writer/skills/webnovel-resume/SKILL.md
  42. 38 32
      webnovel-writer/skills/webnovel-resume/references/workflow-resume.md
  43. 113 111
      webnovel-writer/skills/webnovel-review/SKILL.md
  44. 194 216
      webnovel-writer/skills/webnovel-write/SKILL.md
  45. 1 1
      webnovel-writer/skills/webnovel-write/references/step-3-review-gate.md
  46. 8 6
      webnovel-writer/skills/webnovel-write/references/workflow-details.md
  47. 1 1
      webnovel-writer/skills/webnovel-write/references/writing/genre-hook-payoff-library.md

+ 1 - 1
.claude-plugin/marketplace.json

@@ -11,7 +11,7 @@
     {
       "name": "webnovel-writer",
       "description": "长篇网文创作系统(skills + agents + data chain + RAG)",
-      "version": "5.5.4",
+      "version": "5.5.5",
       "author": {
         "name": "lingfengQAQ"
       },

+ 4 - 3
README.md

@@ -118,7 +118,8 @@ model: sonnet
 
 | 版本 | 说明 |
 |------|------|
-| **v5.5.4 (当前)** | 补齐写作链提示词强约束(流程硬约束、中文思维写作约束、Step 职责边界);统一中文化审查/润色/Agent 报告文案;清理文档内部版本号与版本历史,降低与插件发版版本混淆。 |
+| **v5.5.5 (当前)** | 新增长期记忆闭环:写前注入 `long_term_memory`,写后沉淀 `memory_facts` 到 `memory_scratchpad.json`;补齐 `memory` 运维子命令。 |
+| **v5.5.4** | 补齐写作链提示词强约束(流程硬约束、中文思维写作约束、Step 职责边界);统一中文化审查/润色/Agent 报告文案;清理文档内部版本号与版本历史,降低与插件发版版本混淆。 |
 | **v5.5.3** | 新增统一 `preflight` 预检命令;写作链 CLI 示例统一为 UTF-8 运行方式,收口文档中的长 shell 预检片段并降低 Windows 终端乱码风险。 |
 | **v5.5.2** | 支持将详细大纲中的章节名同步到正文文件名;修复 workflow_manager 在无参 find_project_root monkeypatch 下的兼容性问题。 |
 | **v5.5.1** | 修复卷级单文件大纲在上下文快照中的章节提取问题;补齐命令文档中遗漏的 `/webnovel-dashboard` 与 `/webnovel-learn`。 |
@@ -133,11 +134,11 @@ model: sonnet
 
 1. 先在本地同步版本信息:
    ```bash
-   python -X utf8 webnovel-writer/scripts/sync_plugin_version.py --version 5.5.4 --release-notes "本次版本说明"
+   python -X utf8 webnovel-writer/scripts/sync_plugin_version.py --version 5.5.5 --release-notes "本次版本说明"
    ```
 2. 提交并推送版本变更(`README.md`、`plugin.json`、`marketplace.json`)。
 3. 打开仓库的 Actions 页面,选择 `Plugin Release`。
-4. 输入与当前仓库元数据一致的 `version`(例如 `5.5.4`)和用于 GitHub Release 的 `release_notes`。
+4. 输入与当前仓库元数据一致的 `version`(例如 `5.5.5`)和用于 GitHub Release 的 `release_notes`。
 5. 工作流会执行以下动作:
    - 校验 `plugin.json`、`marketplace.json` 与 README 当前版本已经一致
    - 校验当前版本与输入的 `version` 一致

+ 20 - 0
docs/README.md

@@ -3,6 +3,10 @@
 本目录承载 `README.md` 之外的详细说明,按模块拆分:
 
 - [架构与模块](#架构与模块)
+- [LIGHT 长期记忆系统](#light-长期记忆系统)
+- [LIGHT 改造计划](#light-改造计划)
+- [长期记忆研究报告](#长期记忆研究报告)
+- [长期记忆新架构](#长期记忆新架构)
 - [命令详解](#命令详解)
 - [RAG 与配置](#rag-与配置)
 - [题材模板](#题材模板)
@@ -12,6 +16,22 @@
 
 - `architecture.md`:系统架构、核心理念、双 Agent、六维审查
 
+## LIGHT 长期记忆系统
+
+- `light-memory-system.md`:LIGHT 三层记忆架构、数据流、落地实现建议
+
+## LIGHT 改造计划
+
+- `light-retrofit-plan.md`:基于当前项目的 LIGHT 分层记忆改造方案与执行阶段
+
+## 长期记忆研究报告
+
+- `long-term-memory-research-report.md`:长期记忆论文、基准与开源项目调研报告
+
+## 长期记忆新架构
+
+- `long-term-memory-architecture-v2.md`:基于调研结论重规划的长期记忆架构
+
 ## 命令详解
 
 - `commands.md`:`/webnovel-*` 命令详细说明

+ 24 - 0
docs/commands.md

@@ -99,3 +99,27 @@
 产出:
 
 - `.webnovel/project_memory.json`
+
+## 长期记忆运维命令(CLI)
+
+用途:查看、查询、回填和手动更新长期记忆(`memory_scratchpad.json`)。
+
+示例:
+
+```bash
+python -X utf8 "<CLAUDE_PLUGIN_ROOT>/scripts/webnovel.py" --project-root "<PROJECT_ROOT>" memory stats
+python -X utf8 "<CLAUDE_PLUGIN_ROOT>/scripts/webnovel.py" --project-root "<PROJECT_ROOT>" memory query --category character_state --subject xiaoyan
+python -X utf8 "<CLAUDE_PLUGIN_ROOT>/scripts/webnovel.py" --project-root "<PROJECT_ROOT>" memory dump
+python -X utf8 "<CLAUDE_PLUGIN_ROOT>/scripts/webnovel.py" --project-root "<PROJECT_ROOT>" memory conflicts
+python -X utf8 "<CLAUDE_PLUGIN_ROOT>/scripts/webnovel.py" --project-root "<PROJECT_ROOT>" memory bootstrap
+python -X utf8 "<CLAUDE_PLUGIN_ROOT>/scripts/webnovel.py" --project-root "<PROJECT_ROOT>" memory update --chapter 100 --data '{...}'
+```
+
+常用子命令:
+
+- `memory stats`:查看总量、状态分布、分类统计
+- `memory query`:按 `category/subject/status` 过滤查询
+- `memory dump`:导出完整 scratchpad 内容
+- `memory conflicts`:查看同主键 active 冲突项
+- `memory bootstrap`:从 `index.db` 与 `summaries` 回填初始长期记忆
+- `memory update`:对指定章节结果执行一次手动映射写入

+ 335 - 0
docs/light-memory-system.md

@@ -0,0 +1,335 @@
+# LIGHT 长期记忆系统说明
+
+## 文档目标
+
+本文档将论文中的 LIGHT 系统整理成可落地的工程方案,重点说明三层记忆结构、读写链路、核心数据结构,以及在产品化实现时需要重点处理的问题。
+
+## 一句话定义
+
+LIGHT 不是单纯把上下文窗口做大,而是把长期记忆拆成三层:
+
+- 情节记忆 `episodic memory`:负责从历史对话中检索证据
+- 工作记忆 `working memory`:负责保留最近上下文
+- 草稿板 `scratchpad`:负责沉淀长期稳定的高价值事实
+
+回答时由统一的记忆编排器组合这三层记忆,再交给生成模型。
+
+## 设计目标
+
+- 支持超长会话下的稳定问答
+- 降低“上下文很长但还是忘事”的问题
+- 兼顾近期上下文、远期事实和长期偏好
+- 为信息更新、偏好跟随、跨轮总结提供结构化基础
+
+## 总体架构
+
+```text
+┌────────────────────────────────────────────────────┐
+│                    用户对话流                      │
+└────────────────────────────────────────────────────┘
+                         │
+                         ▼
+┌────────────────────────────────────────────────────┐
+│                  回合后处理器                      │
+├────────────────────────────────────────────────────┤
+│ 1. 保存原始 turn 到会话存储                        │
+│ 2. 提取 key-value 和摘要,写入情节记忆             │
+│ 3. 提炼显著事实,增量更新 scratchpad              │
+└────────────────────────────────────────────────────┘
+                         │
+                         ▼
+┌────────────────────────────────────────────────────┐
+│                  用户新问题到达                    │
+└────────────────────────────────────────────────────┘
+                         │
+                         ▼
+┌────────────────────────────────────────────────────┐
+│                  记忆编排器                        │
+├────────────────────────────────────────────────────┤
+│ 1. 检索 episodic top-k                             │
+│ 2. 读取最近 N 轮 working memory                    │
+│ 3. 对 scratchpad 分块并过滤相关内容                │
+│ 4. 组装 prompt,交给回答模型                       │
+└────────────────────────────────────────────────────┘
+```
+
+## 三层记忆拆解
+
+### 1. 情节记忆 `episodic memory`
+
+作用:
+
+- 存储历史对话中的离散事件与事实证据
+- 在远距离问题中召回“当时具体说过什么”
+- 为答案提供可追溯的历史片段
+
+写入方式:
+
+- 每轮 `user-assistant turn pair` 结束后执行一次
+- 提取结构化 `key-value pairs`
+- 生成该轮摘要 `summary`
+- 对结构化结果或摘要做向量化
+- 将原始对话片段与索引共同存入向量库
+
+建议字段:
+
+```json
+{
+  "session_id": "session-001",
+  "turn_id": 1842,
+  "timestamp": "2026-03-19T18:00:00+08:00",
+  "entities": [
+    {"key": "居住城市", "value": "苏州"},
+    {"key": "宠物名字", "value": "团子"}
+  ],
+  "summary": "用户提到已搬到苏州,并养了一只叫团子的猫。",
+  "raw_span": "原始对话片段",
+  "embedding_text": "居住城市=苏州;宠物名字=团子;用户提到已搬到苏州,并养了一只叫团子的猫。"
+}
+```
+
+工程建议:
+
+- 不要只存全文向量,优先存“摘要 + 结构化提取”的索引文本
+- 检索后返回原始片段,避免摘要失真带来的二次误导
+- `top-k` 不要盲目调大,过多召回会引入噪声
+
+### 2. 工作记忆 `working memory`
+
+作用:
+
+- 保留最近若干轮原始对话
+- 维持局部连贯性、代词解析、刚更新的信息
+- 防止系统只看远期记忆,忽略当前上下文
+
+实现方式:
+
+- 直接从会话存储读取最近 `N` 轮
+- 或按 `token budget` 截断,而不是写死固定轮数
+
+工程建议:
+
+- 优先按 token 上限裁剪,而不是按回合数裁剪
+- 最近轮的优先级通常高于远期记忆
+- 回答冲突时,应优先检查工作记忆中是否存在更近更新
+
+### 3. 草稿板 `scratchpad`
+
+作用:
+
+- 沉淀对长期任务真正重要的高层事实
+- 存储偏好、长期约束、时间线、未完成事项等
+- 避免只依赖相似度检索导致关键信息漏召回
+
+写入方式:
+
+- 每轮结束后由 LLM 提炼“显著信息”
+- 将新信息合并到现有 scratchpad
+- 超过阈值后执行压缩
+
+推荐分槽位组织,而不是一整段自由文本:
+
+```json
+{
+  "profile": [
+    "用户当前住在苏州"
+  ],
+  "preferences": [
+    "默认使用简体中文",
+    "倾向简洁回答"
+  ],
+  "instructions": [
+    "代码注释使用中文",
+    "避免未验证结论"
+  ],
+  "timeline": [
+    "2025-12: 从杭州搬到苏州",
+    "2026-04: 计划去东京出差"
+  ],
+  "open_loops": [
+    "简历修改尚未完成"
+  ]
+}
+```
+
+工程建议:
+
+- 将 `profile / preferences / instructions / timeline / open_loops` 分开维护
+- 超过长度阈值后做压缩,但要保留高优先级槽位
+- 回答前不要整块全部注入,应先按问题相关性过滤
+
+## 读写链路
+
+### 写入链路
+
+每轮对话结束后:
+
+1. 保存原始 turn 到会话存储
+2. 用提取模型生成 `key-value + summary`
+3. 写入情节记忆索引
+4. 更新 scratchpad
+5. 超过阈值时压缩 scratchpad
+
+### 读取链路
+
+收到新问题后:
+
+1. 对问题做向量化
+2. 从情节记忆中召回 `top-k`
+3. 从会话存储取最近若干轮 working memory
+4. 将 scratchpad 分块
+5. 对每块做相关性过滤
+6. 将三层记忆拼装为回答上下文
+7. 调用生成模型作答
+
+## 关键数据存储
+
+建议至少拆成四类存储:
+
+- `conversation_store`:保存原始对话 turn
+- `episodic_index`:保存摘要、结构化提取、embedding、原始片段引用
+- `scratchpad_store`:保存长期摘要与槽位化事实
+- `memory_metadata`:保存版本号、状态、更新时间、冲突标记
+
+可选状态字段:
+
+- `active`:当前有效
+- `outdated`:已过期
+- `contradicted`:被冲突信息覆盖
+- `tentative`:不确定信息,需后续确认
+
+## 记忆编排器职责
+
+记忆编排器是系统核心,职责包括:
+
+- 控制每类记忆的 token 预算
+- 决定检索数量与排序策略
+- 在冲突信息出现时做优先级裁决
+- 过滤与问题无关的 scratchpad 块
+- 统一输出给回答模型的上下文格式
+
+一个简单的优先级顺序可以是:
+
+1. 当前轮明确指令
+2. working memory 中的最近事实
+3. scratchpad 中的长期约束与偏好
+4. episodic memory 中召回的历史证据
+
+## 最小可用实现(MVP)
+
+最小版本只需要五个组件:
+
+- `conversation_store`
+- `episodic_indexer`
+- `scratchpad_manager`
+- `memory_orchestrator`
+- `response_generator`
+
+伪代码:
+
+```python
+def on_turn_end(session_id, user_msg, assistant_msg):
+    save_raw_turn(session_id, user_msg, assistant_msg)
+
+    memory_item = extract_kv_and_summary(user_msg, assistant_msg)
+    vector_store.upsert(session_id, memory_item)
+
+    scratchpad = load_scratchpad(session_id)
+    scratchpad = update_scratchpad(scratchpad, user_msg, assistant_msg)
+    if token_len(scratchpad) > 30000:
+        scratchpad = compress_scratchpad(scratchpad, target_tokens=15000)
+    save_scratchpad(session_id, scratchpad)
+
+
+def answer(session_id, question):
+    episodic_docs = retrieve_topk(session_id, question, k=15)
+    recent_turns = load_recent_turns(session_id, token_budget=8000)
+    scratchpad = load_scratchpad(session_id)
+    scratch_chunks = semantic_chunk(scratchpad)
+    filtered_scratch = filter_relevant_chunks(question, scratch_chunks)
+
+    prompt = build_prompt(
+        question=question,
+        episodic_memory=episodic_docs,
+        working_memory=recent_turns,
+        scratchpad=filtered_scratch,
+    )
+    return llm_generate(prompt)
+```
+
+## 与普通 RAG 的区别
+
+普通 RAG 常见流程是:
+
+- 文本切块
+- 向量检索
+- 拼接上下文
+
+LIGHT 的差异在于:
+
+- 有独立的 working memory,保证近期连续性
+- 有 scratchpad,维护高层抽象事实
+- 有持续写入机制,而不是只在回答前做一次检索
+- 更接近“记忆系统”,而不是单次检索增强
+
+## 落地时最难的四个问题
+
+### 1. 信息更新
+
+同一事实会变化,例如“住在杭州”后来变成“搬到苏州”。系统不能只追加,不做状态管理。
+
+### 2. 矛盾消解
+
+不同时间的记忆可能互相冲突。系统需要判断:
+
+- 哪条是最新的
+- 哪条是已过期的
+- 哪条只是暂时猜测
+
+### 3. Scratchpad 膨胀
+
+如果长期只追加不整理,scratchpad 会退化成另一份噪声上下文,导致检索和回答质量持续下降。
+
+### 4. 写入污染
+
+每轮写入依赖 LLM 提取。如果提取错误,错误会长期保留并反复影响后续回答。
+
+## 产品化增强建议
+
+如果从论文原型继续往前做,建议增加以下能力:
+
+- 混合召回:结合 dense、keyword、时间排序和 rerank
+- 冲突检查:回答前先做 memory conflict check
+- 偏好单独建表:用户偏好和长期指令不要完全混在 scratchpad 中
+- 版本化更新:对关键事实保留变更历史和生效时间
+- 可观察性:记录每次回答用了哪些记忆,便于审计和调试
+
+## 适用场景
+
+LIGHT 适合以下系统:
+
+- 长对话助手
+- 需要记住用户偏好的智能体
+- 多轮写作/策划协作系统
+- 长期任务跟踪与提醒系统
+- 小说创作助手中的角色、设定、剧情记忆系统
+
+## 对本项目的启发
+
+如果把 LIGHT 思路引入当前小说写作系统,可以这样映射:
+
+- 情节记忆:存章节事件、角色状态变化、伏笔和回收线索
+- 工作记忆:存当前章节任务书、最近章节摘要、当前写作约束
+- 草稿板:存角色设定、长期世界观规则、主线目标、未回收伏笔
+
+这样可以把“长篇连载的一致性问题”从单次检索,升级成持续维护的记忆闭环。
+
+## 总结
+
+LIGHT 的核心价值不在于更大的上下文窗口,而在于把长期记忆拆成:
+
+- 可检索的历史证据层
+- 保持局部连贯的短期层
+- 稳定沉淀长期事实的抽象层
+
+工程上最值得复用的是这套分层记忆与统一编排思路,而不是某个固定模型或具体实现细节。

+ 701 - 0
docs/light-retrofit-plan.md

@@ -0,0 +1,701 @@
+# LIGHT 改造现有系统计划
+
+## 文档目标
+
+本文档给出一份可直接执行的改造计划,用于在当前 `webnovel-writer` 基础上引入 LIGHT 风格的三层记忆系统,同时尽量复用现有的 `state.json`、`index.db`、`vectors.db`、`ContextManager` 与写作工作流。
+
+目标不是推翻现有架构,而是在低风险前提下补齐:
+
+- 独立的长期摘要记忆层 `scratchpad`
+- 统一的记忆编排层 `memory orchestrator`
+- 面向写作场景的冲突裁决与记忆状态管理
+
+## 当前系统现状摘要
+
+当前项目已经具备以下能力:
+
+- `state.json`:精简运行态与章节元信息
+- `index.db`:实体、别名、状态变化、关系、追读力、审查指标
+- `vectors.db`:章节摘要与场景切片的向量检索
+- `ContextManager`:多源上下文拼装与预算裁剪
+- `extract_chapter_context.py`:写作前统一上下文入口
+- `workflow_manager.py`:写作工作流状态机与中断恢复
+
+当前缺口主要有两项:
+
+- 缺独立维护的长期摘要记忆层
+- 缺统一调度 `working / episodic / scratchpad` 的记忆编排器
+
+## 改造目标
+
+### 总目标
+
+将系统升级为三层记忆架构:
+
+- `working memory`:近期写作上下文
+- `episodic memory`:可检索历史证据
+- `scratchpad memory`:长期高密度摘要记忆
+
+### 子目标
+
+- 不破坏现有 `/webnovel-write` 主流程
+- 不修改已有数据模型的核心职责边界
+- 新增能力尽量通过“增量接入”完成
+- 保持现有 CLI 与测试体系可持续演进
+
+## 目标架构
+
+```text
+Skills / Agents
+   │
+   ▼
+Chapter Context Facade
+   │  extract_chapter_context.py
+   │  ContextManager
+   ▼
+Memory Orchestrator
+   ├── Working Memory
+   ├── Episodic Memory
+   └── Scratchpad Memory
+   ▼
+Final Context Pack
+
+写入侧:
+Data Agent / Step 5
+   ├── state.json
+   ├── index.db
+   ├── vectors.db
+   ├── summaries/
+   └── memory_scratchpad.json
+```
+
+## 设计原则
+
+### 1. 最小侵入
+
+优先复用:
+
+- `ContextManager`
+- `extract_chapter_context.py`
+- `StateManager`
+- `IndexManager`
+- `RAGAdapter`
+
+### 2. 单一职责
+
+- `state.json` 继续保存运行态精简信息
+- `index.db` 继续保存结构化事实
+- `vectors.db` 继续保存语义检索向量
+- `memory_scratchpad.json` 专门保存长期摘要记忆
+
+### 3. 渐进替换
+
+先“旁路接入”,再逐步把逻辑迁入编排器,而不是一次性重写 `ContextManager`
+
+### 4. 可回滚
+
+每个阶段都能独立上线和回退,避免一次改太大导致写作链失稳
+
+## 记忆分层映射
+
+### Working Memory
+
+来源:
+
+- 本章大纲
+- 最近章节摘要
+- 当前 `state.json`
+- `reader_signal`
+- `writing_guidance`
+
+当前主要载体:
+
+- `scripts/extract_chapter_context.py`
+- `scripts/data_modules/context_manager.py`
+
+### Episodic Memory
+
+来源:
+
+- `index.db`
+- `vectors.db`
+- `.webnovel/summaries/chNNNN.md`
+
+当前主要载体:
+
+- `scripts/data_modules/index_manager.py`
+- `scripts/data_modules/rag_adapter.py`
+- `scripts/data_modules/state_manager.py`
+
+### Scratchpad Memory
+
+新增:
+
+- `.webnovel/memory_scratchpad.json`
+
+用途:
+
+- 汇总长期有效事实
+- 维护角色当前稳定状态
+- 沉淀世界规则与长期约束
+- 存储时间线与未回收开放环
+- 服务写作前的高密度摘要注入
+
+## 新增模块规划
+
+建议新增以下文件:
+
+```text
+webnovel-writer/scripts/data_modules/
+├── memory_orchestrator.py
+├── scratchpad_manager.py
+├── scratchpad_schema.py
+└── memory_conflict_resolver.py
+```
+
+### `scratchpad_schema.py`
+
+职责:
+
+- 定义 `memory_scratchpad.json` 结构
+- 提供默认值与校验函数
+
+建议结构:
+
+```json
+{
+  "story_facts": [],
+  "character_facts": [],
+  "world_rules": [],
+  "timeline": [],
+  "open_loops": [],
+  "reader_promises": [],
+  "active_constraints": [],
+  "meta": {
+    "version": 1,
+    "last_updated": ""
+  }
+}
+```
+
+### `scratchpad_manager.py`
+
+职责:
+
+- 读写 `memory_scratchpad.json`
+- 从章节结果增量更新长期摘要
+- 定期压缩与去噪
+- 按主题或章节生成相关片段
+
+建议能力:
+
+- `load()`
+- `save()`
+- `update_from_chapter_result()`
+- `compress_if_needed()`
+- `filter_for_chapter()`
+- `mark_fact_status()`
+
+### `memory_conflict_resolver.py`
+
+职责:
+
+- 处理新旧事实冲突
+- 给事实打状态标签
+
+建议状态:
+
+- `active`
+- `outdated`
+- `contradicted`
+- `tentative`
+
+建议处理对象:
+
+- 角色境界/地点/归属变更
+- 关系变化
+- 世界规则的显式修订
+- 伏笔从 active 到 resolved
+
+### `memory_orchestrator.py`
+
+职责:
+
+- 统一调度三层记忆
+- 控制预算与优先级
+- 输出最终 context pack
+
+建议接口:
+
+- `build_memory_pack(chapter: int) -> dict`
+- `load_working_memory(chapter: int) -> dict`
+- `load_episodic_memory(chapter: int) -> dict`
+- `load_scratchpad_memory(chapter: int) -> dict`
+- `resolve_conflicts(...)`
+- `assemble(...)`
+
+## 现有文件改造点
+
+### 第一优先级
+
+#### `scripts/data_modules/context_manager.py`
+
+改造目标:
+
+- 从“多源上下文拼装器”升级为“编排器调用方”
+- 新增 `scratchpad` section
+- 后续逐步委托给 `MemoryOrchestrator`
+
+建议改动:
+
+- `_build_pack()` 中加入 scratchpad 内容
+- `SECTION_ORDER` 中加入或重新排序 `scratchpad`
+- 新增从 orchestrator 获取结果的兼容入口
+
+#### `scripts/extract_chapter_context.py`
+
+改造目标:
+
+- 接入 `MemoryOrchestrator`
+- 输出更完整的三层记忆摘要
+
+建议改动:
+
+- 在 `build_chapter_context_payload()` 中加载 orchestrator 结果
+- 保留现有 JSON/text 输出格式兼容
+- 新增 `scratchpad_signal` 或 `long_term_memory` 字段
+
+#### `scripts/data_modules/state_manager.py`
+
+改造目标:
+
+- 在 `process_chapter_result()` 完成后触发 scratchpad 更新
+
+建议改动:
+
+- 在章节结果保存成功后调用 `ScratchpadManager.update_from_chapter_result()`
+- 失败时只记录 warning,不影响主流程完成
+
+### 第二优先级
+
+#### `scripts/data_modules/rag_adapter.py`
+
+改造目标:
+
+- 让 episodic memory 检索对“长期事实块”更友好
+
+建议改动:
+
+- 为 scratchpad 相关片段预留可选向量化能力
+- 允许对 `chunk_type=scratchpad` 做检索
+- 不作为第一阶段必做项
+
+#### `scripts/status_reporter.py`
+
+改造目标:
+
+- 利用 scratchpad 生成更高层视角的健康报告
+
+建议改动:
+
+- 报告中增加“长期约束漂移”“未回收开放环”“记忆冲突提醒”
+
+#### Dashboard
+
+改造目标:
+
+- 增加 scratchpad 可视化
+
+建议改动:
+
+- 展示当前长期事实摘要
+- 展示冲突事实与待确认事实
+
+## 新数据文件规划
+
+新增:
+
+```text
+.webnovel/memory_scratchpad.json
+```
+
+建议字段:
+
+- `story_facts`:当前剧情稳定事实
+- `character_facts`:角色稳定状态与重要变化
+- `world_rules`:长期不可违背设定
+- `timeline`:关键事件时间线
+- `open_loops`:未回收伏笔、未完成承诺、待兑现悬念
+- `reader_promises`:已对读者抛出的期待点
+- `active_constraints`:当前章节仍需遵守的重要约束
+- `meta`:版本、更新时间、压缩次数
+
+建议事实项结构:
+
+```json
+{
+  "id": "fact-char-xiaoyan-realm-001",
+  "category": "character_facts",
+  "subject": "xiaoyan",
+  "field": "realm",
+  "value": "筑基三层",
+  "status": "active",
+  "source_chapter": 128,
+  "updated_at": "2026-03-19T20:00:00+08:00",
+  "evidence": [
+    "ch0128_summary",
+    "state_change:xiaoyan:realm:128"
+  ],
+  "confidence": 0.95
+}
+```
+
+## 分阶段执行计划
+
+### Phase 0:设计冻结与接口先行
+
+目标:
+
+- 明确数据结构和接口,不立即改主流程
+
+任务:
+
+1. 定义 `memory_scratchpad.json` schema
+2. 定义 `ScratchpadManager` 最小接口
+3. 定义 `MemoryOrchestrator` 输入输出格式
+4. 补充设计文档与测试样例
+
+交付物:
+
+- `scratchpad_schema.py`
+- 设计说明文档
+- 测试用 fixture 样例
+
+验收标准:
+
+- schema 稳定
+- 接口命名与现有风格一致
+- 不影响现有命令
+
+### Phase 1:落地 Scratchpad 存储层
+
+目标:
+
+- 让项目拥有独立的长期摘要记忆文件
+
+任务:
+
+1. 实现 `scratchpad_schema.py`
+2. 实现 `scratchpad_manager.py`
+3. 支持加载、保存、初始化默认结构
+4. 编写基础单元测试
+
+交付物:
+
+- `memory_scratchpad.json`
+- `ScratchpadManager`
+- 对应 tests
+
+验收标准:
+
+- 文件创建与读写稳定
+- schema 校验通过
+- 空项目可自动初始化
+
+### Phase 2:接入章节写后更新链
+
+目标:
+
+- 在现有 Step 5 后自动维护 scratchpad
+
+任务:
+
+1. 在 `StateManager.process_chapter_result()` 后接入 scratchpad 更新
+2. 从以下来源提取增量事实:
+   - `entities_new`
+   - `state_changes`
+   - `relationships_new`
+   - `chapter_meta`
+   - `plot_threads.foreshadowing`
+3. 增加错误隔离,保证 scratchpad 失败不阻断主流程
+
+交付物:
+
+- 写后更新逻辑
+- 日志与 warning 机制
+
+验收标准:
+
+- 写完一章后 scratchpad 有可见增量
+- 工作流不因 scratchpad 失败而中断
+
+### Phase 3:接入读取链
+
+目标:
+
+- 在写作前上下文中引入 scratchpad
+
+任务:
+
+1. 在 `ContextManager` 中加入 scratchpad section
+2. 在 `extract_chapter_context.py` 中输出长期记忆摘要
+3. 为 scratchpad 分配独立预算
+4. 增加按章节相关性过滤
+
+交付物:
+
+- 上下文新增 `scratchpad` / `long_term_memory`
+- text 输出中新增相关章节说明
+
+验收标准:
+
+- 第 N 章上下文可看到长期约束与关键开放环
+- 上下文长度可控
+
+### Phase 4:引入 Memory Orchestrator
+
+目标:
+
+- 从“多源拼装”升级为“分层记忆编排”
+
+任务:
+
+1. 新建 `memory_orchestrator.py`
+2. 把以下逻辑迁入 orchestrator:
+   - Working memory 读取
+   - Episodic memory 检索
+   - Scratchpad 过滤
+   - 冲突检查
+   - 最终 pack 组装
+3. 让 `ContextManager` 调用 orchestrator,而不是自己做全部组装
+
+交付物:
+
+- `MemoryOrchestrator`
+- `ContextManager` 兼容适配
+
+验收标准:
+
+- 输出结构与旧接口兼容
+- 逻辑可单测
+- 能独立关闭 orchestrator,回退旧路径
+
+### Phase 5:冲突裁决与记忆状态化
+
+目标:
+
+- 解决“旧事实覆盖新事实”的问题
+
+任务:
+
+1. 新建 `memory_conflict_resolver.py`
+2. 对关键事实打状态:
+   - `active`
+   - `outdated`
+   - `contradicted`
+   - `tentative`
+3. 处理常见冲突:
+   - 人物境界变化
+   - 人物位置变化
+   - 势力归属变化
+   - 伏笔状态变化
+   - 关系状态变化
+
+交付物:
+
+- 冲突解析器
+- 冲突样例测试
+
+验收标准:
+
+- 最新事实优先
+- 旧事实保留历史但不进入高优先上下文
+
+### Phase 6:高级能力增强
+
+目标:
+
+- 进一步提升检索与可观察性
+
+任务:
+
+1. 可选支持 scratchpad 向量化
+2. 在 dashboard 中增加 scratchpad 可视化
+3. 增加记忆命中日志
+4. 在健康报告中加入记忆冲突提醒
+
+交付物:
+
+- dashboard 页面扩展
+- 观测指标扩展
+
+验收标准:
+
+- 能追踪每次写作用了哪些记忆
+- 可以人工审计长期记忆质量
+
+## 实施顺序建议
+
+推荐严格按以下顺序做:
+
+1. Phase 0
+2. Phase 1
+3. Phase 2
+4. Phase 3
+5. Phase 4
+6. Phase 5
+7. Phase 6
+
+原因:
+
+- 先落存储层,后接写链
+- 先让数据流起来,再做编排优化
+- 先保证稳定,再做智能化冲突处理
+
+## 测试计划
+
+### 单元测试
+
+新增测试文件建议:
+
+```text
+scripts/data_modules/tests/
+├── test_scratchpad_schema.py
+├── test_scratchpad_manager.py
+├── test_memory_orchestrator.py
+└── test_memory_conflict_resolver.py
+```
+
+重点测试:
+
+- scratchpad 初始化
+- 章节结果增量写入
+- 冲突事实状态变化
+- 上下文过滤与预算裁剪
+- orchestrator 输出兼容性
+
+### 集成测试
+
+建议扩展:
+
+- `test_context_manager.py`
+- `test_extract_chapter_context.py`
+- `test_state_manager_extra.py`
+
+验证场景:
+
+1. 连续两章更新同一角色境界
+2. 伏笔状态从 active 变为 resolved
+3. 写作上下文中出现正确的长期约束
+4. scratchpad 失败时主流程仍能成功
+
+### 回归测试
+
+必须覆盖:
+
+- `/webnovel-write` 主流程不回归
+- `/webnovel-review` 不受影响
+- 旧项目无 `memory_scratchpad.json` 时可兼容
+
+## 风险与控制策略
+
+### 风险 1:上下文变长
+
+问题:
+
+- 引入 scratchpad 后上下文膨胀
+
+控制:
+
+- 单独预算
+- 分槽位裁剪
+- 只注入相关块
+
+### 风险 2:错误事实长期污染
+
+问题:
+
+- scratchpad 写入错误后会长期影响后续章节
+
+控制:
+
+- 先基于结构化数据生成,而不是纯文本自由总结
+- 保留 evidence 字段
+- 增加 `tentative` 状态
+
+### 风险 3:和现有 ContextManager 职责冲突
+
+问题:
+
+- 逻辑迁移过快,导致重复拼装
+
+控制:
+
+- Phase 3 前只做增量 section
+- Phase 4 再逐步收口到 orchestrator
+
+### 风险 4:影响写作主链稳定性
+
+问题:
+
+- 新模块异常阻断主流程
+
+控制:
+
+- scratchpad 写入采用 best-effort
+- 失败只记录 warning
+- 主流程成功判定不依赖 scratchpad
+
+## 里程碑定义
+
+### M1:Scratchpad 可用
+
+完成标志:
+
+- 可以创建并维护 `.webnovel/memory_scratchpad.json`
+
+### M2:写后自动更新
+
+完成标志:
+
+- 每章写作完成后 scratchpad 自动更新
+
+### M3:写前自动读取
+
+完成标志:
+
+- 写作上下文中包含长期摘要记忆
+
+### M4:统一编排上线
+
+完成标志:
+
+- `MemoryOrchestrator` 成为主读取路径
+
+### M5:冲突管理完成
+
+完成标志:
+
+- 新旧事实状态化,长期记忆可持续演进
+
+## 推荐首批实现范围
+
+如果只做一个最小可落地版本,建议范围限定为:
+
+1. 新增 `memory_scratchpad.json`
+2. 实现 `scratchpad_schema.py`
+3. 实现 `scratchpad_manager.py`
+4. 在 `state_manager.py` 写后更新 scratchpad
+5. 在 `context_manager.py` 写前读取 scratchpad
+
+这五项完成后,就已经有一版可运行的 LIGHT MVP。
+
+## 最终结论
+
+当前项目不需要重构重来,而应该走一条“补层”的路线:
+
+- 保留现有 `state/index/rag/workflow`
+- 新增独立 `scratchpad`
+- 再以 `memory orchestrator` 把三层记忆统一起来
+
+这是对当前工程最稳、最符合现状、也最容易逐步验收的改造方案。

+ 616 - 0
docs/long-term-memory-architecture-v2.md

@@ -0,0 +1,616 @@
+# 长期记忆新架构规划(V2)
+
+## 文档目标
+
+基于长期记忆方向的论文与工程调研结果,重新规划 `webnovel-writer` 的目标架构。
+
+这版架构不再把重点放在“补一个 scratchpad 文件”,而是把系统正式升级为:
+
+- `记忆写入层`
+- `记忆存储层`
+- `记忆编排层`
+- `写作消费层`
+
+四层协同的长期记忆系统。
+
+## 一句话结论
+
+新架构应采用:
+
+- `LIGHT` 的三层记忆分层
+- `Mem0` 的独立 memory layer 思路
+- `Zep / Graphiti` 的时态事实与关系建模
+
+也就是说,目标不是“在 ContextManager 上继续堆逻辑”,而是:
+
+**把记忆系统独立出来,让 ContextManager 退化成消费记忆编排结果的适配层。**
+
+## 为什么要重规划
+
+旧方案的问题在于:
+
+- 仍然以 `ContextManager` 为中心
+- 更像“现有系统增强”
+- 不够清晰地区分“写记忆”和“读记忆”
+
+基于调研后,新的核心认识是:
+
+1. 长期记忆的关键不只是检索,而是写入与更新
+2. 长期记忆必须有自己的生命周期管理
+3. 事实、关系、时间线应当进入同一记忆体系
+4. 上下文组装只是消费层,不应继续承担全部核心职责
+
+## 新架构核心原则
+
+### 1. 记忆系统独立化
+
+新增独立 `memory` 子系统,负责:
+
+- 记忆抽取
+- 记忆归档
+- 记忆压缩
+- 记忆检索
+- 冲突裁决
+
+### 2. 记忆分层化
+
+固定分成三层:
+
+- `Working Memory`
+- `Episodic Memory`
+- `Semantic/Scratchpad Memory`
+
+### 3. 时态事实化
+
+角色状态、关系、势力、伏笔等信息都必须支持:
+
+- 当前有效
+- 历史版本
+- 变更时间
+- 来源证据
+
+### 4. 消费与存储解耦
+
+- `ContextManager` 只负责消费记忆编排结果
+- 写作技能/查询技能不直接拼接多源原始数据
+
+## 目标总架构
+
+```text
+┌──────────────────────────────────────────────────────────────┐
+│                    Skills / Agents / Dashboard              │
+│   write / review / query / resume / dashboard              │
+└──────────────────────────┬───────────────────────────────────┘
+                           │
+                           ▼
+┌──────────────────────────────────────────────────────────────┐
+│                    Context Facade Layer                     │
+│  ContextManager / extract_chapter_context / query adapter   │
+│  只负责接收 Memory Orchestrator 输出并适配不同命令           │
+└──────────────────────────┬───────────────────────────────────┘
+                           │
+                           ▼
+┌──────────────────────────────────────────────────────────────┐
+│                    Memory Orchestrator                      │
+│  统一读取 Working / Episodic / Semantic Memory             │
+│  做预算控制、相关性过滤、冲突裁决、输出 context pack         │
+└───────────────┬───────────────────────┬──────────────────────┘
+                │                       │
+                ▼                       ▼
+     ┌───────────────────┐   ┌───────────────────────────────┐
+     │ Working Memory    │   │ Long-Term Memory Layer        │
+     │ 近期写作上下文      │   │ Episodic + Semantic           │
+     └───────────────────┘   └───────────────────────────────┘
+                                          │
+                                          ▼
+                       ┌──────────────────────────────────────┐
+                       │ Memory Write Pipeline                │
+                       │ Chapter result -> extract -> update  │
+                       └──────────────────────────────────────┘
+                                          │
+                                          ▼
+                       ┌──────────────────────────────────────┐
+                       │ Memory Storage Layer                 │
+                       │ state/index/vectors/scratchpad/graph │
+                       └──────────────────────────────────────┘
+```
+
+## 四层模型
+
+## 1. 记忆写入层
+
+这是新架构中最重要的新层。
+
+### 职责
+
+- 从章节产物中提取长期有效信息
+- 判断哪些信息进入哪种记忆层
+- 对已有事实做更新、失效、冲突标记
+- 生成可供后续读取的标准化记忆项
+
+### 写入入口
+
+主入口仍然挂在 Step 5 / Data Agent 完成之后。
+
+输入来源:
+
+- `entities_new`
+- `entities_appeared`
+- `state_changes`
+- `relationships_new`
+- `chapter_meta`
+- `summary`
+- `plot_threads`
+- 后续可扩展:审查报告、用户偏好、人工修订
+
+### 建议新增模块
+
+```text
+scripts/data_modules/memory/
+├── write_pipeline.py
+├── extractors/
+│   ├── fact_extractor.py
+│   ├── relationship_extractor.py
+│   ├── timeline_extractor.py
+│   └── promise_extractor.py
+```
+
+### 设计理由
+
+这部分吸收的是 `Mem0` 的思路:
+
+- 记忆不是原样存对话
+- 而是先做显著信息提炼,再写入 memory layer
+
+## 2. 记忆存储层
+
+新架构不废弃现有存储,而是重新定义职责。
+
+### 2.1 `state.json`
+
+继续保留,但职责进一步收缩为:
+
+- 当前写作运行态
+- 主角快照
+- 进度
+- strand tracker
+- 少量即时风险提示
+
+不再承载长期知识沉淀。
+
+### 2.2 `index.db`
+
+继续作为结构化事实库,但要明确升级为:
+
+- `episodic structured memory`
+
+主要存:
+
+- 实体
+- 别名
+- 关系
+- 关系事件
+- 状态变化
+- chapter/meta
+- 追读力和审查指标
+
+### 2.3 `vectors.db`
+
+继续作为:
+
+- `episodic retrieval memory`
+
+主要存:
+
+- 章节摘要
+- 场景切片
+- 后续可扩展:
+  - 长期摘要块 embedding
+  - 伏笔块 embedding
+  - 角色关系摘要块 embedding
+
+### 2.4 `memory_scratchpad.json`
+
+保留,但重新定义为:
+
+- `semantic memory cache`
+
+它不应该只是一个杂项摘要文件,而应该是一个可压缩、高密度、面向消费的长期摘要缓存。
+
+建议结构:
+
+```json
+{
+  "character_state": [],
+  "story_state": [],
+  "world_rules": [],
+  "timeline": [],
+  "open_loops": [],
+  "reader_promises": [],
+  "active_constraints": [],
+  "meta": {}
+}
+```
+
+### 2.5 `memory_graph.db` 或图表扩展
+
+这是新规划中和旧方案最大的差异之一。
+
+建议新增图记忆层,哪怕初期只是 SQLite 表,也要预留语义:
+
+- 实体节点
+- 关系边
+- 时间有效性
+- 来源章节
+- 版本变化
+
+形式上可以有两种方案:
+
+1. 保守方案:
+   - 先继续放在 `index.db`
+   - 新增 graph-oriented tables
+2. 进阶方案:
+   - 新建 `memory_graph.db`
+
+第一阶段建议用保守方案,避免基础设施过重。
+
+## 3. 记忆编排层
+
+这是新的核心中台。
+
+### 职责
+
+- 按章节或查询意图读取三层记忆
+- 根据任务类型动态分配预算
+- 合并 working / episodic / semantic memory
+- 对冲突事实做优先级裁决
+- 输出标准化 context pack
+
+### 建议新增模块
+
+```text
+scripts/data_modules/memory/
+├── orchestrator.py
+├── budget_manager.py
+├── relevance_filter.py
+├── conflict_resolver.py
+└── memory_pack.py
+```
+
+### 三层记忆定义
+
+#### Working Memory
+
+来源:
+
+- 本章大纲
+- 最近摘要
+- 当前状态
+- 当前 chapter guidance
+- 当前债务与风险
+
+特点:
+
+- 强时效
+- 高优先级
+- 不需要长期存档
+
+#### Episodic Memory
+
+来源:
+
+- `index.db`
+- `vectors.db`
+- `summaries`
+
+特点:
+
+- 保留历史证据
+- 可追溯
+- 适合回答“发生过什么”
+
+#### Semantic Memory
+
+来源:
+
+- `memory_scratchpad.json`
+- 图记忆层生成的高阶摘要
+
+特点:
+
+- 保留稳定抽象事实
+- 适合回答“当前应该认为是真的是什么”
+
+### 编排流程
+
+```text
+写作请求
+   │
+   ▼
+意图分析
+   │
+   ├── chapter_write
+   ├── consistency_check
+   ├── continuity_query
+   └── review
+   ▼
+Memory Orchestrator
+   │
+   ├── load working memory
+   ├── retrieve episodic memory
+   ├── load semantic memory
+   ├── resolve conflicts
+   ├── apply budget
+   └── build final pack
+```
+
+## 4. 写作消费层
+
+这一层包括:
+
+- `ContextManager`
+- `extract_chapter_context.py`
+- `/webnovel-write`
+- `/webnovel-query`
+- `/webnovel-review`
+
+### 新职责
+
+- 不直接拼装底层数据
+- 只根据任务类型请求 memory pack
+- 将 memory pack 渲染成:
+  - text
+  - json
+  - prompt context
+
+### 这意味着什么
+
+`ContextManager` 不再是记忆逻辑中心,而是:
+
+- 一个适配器
+- 一个模板层
+- 一个输出格式层
+
+## 模块目录规划
+
+建议新增独立目录:
+
+```text
+webnovel-writer/scripts/data_modules/
+├── memory/
+│   ├── __init__.py
+│   ├── schema.py
+│   ├── storage.py
+│   ├── write_pipeline.py
+│   ├── orchestrator.py
+│   ├── conflict_resolver.py
+│   ├── relevance_filter.py
+│   ├── budget_manager.py
+│   ├── summary_compactor.py
+│   └── graph_memory.py
+```
+
+### 各模块职责
+
+#### `schema.py`
+
+- 定义记忆项结构
+- 定义状态字段与版本字段
+
+#### `storage.py`
+
+- 封装对 `memory_scratchpad.json`、`index.db`、`vectors.db` 的统一读写
+
+#### `write_pipeline.py`
+
+- 章节完成后写入记忆
+
+#### `orchestrator.py`
+
+- 对外提供统一 `build_memory_pack()`
+
+#### `conflict_resolver.py`
+
+- 处理 active / outdated / contradicted / tentative
+
+#### `relevance_filter.py`
+
+- 按章节目标、大纲关键词、实体中心过滤长期记忆
+
+#### `budget_manager.py`
+
+- 控制不同任务类型的 token 预算
+
+#### `summary_compactor.py`
+
+- 负责 scratchpad 压缩和去噪
+
+#### `graph_memory.py`
+
+- 封装关系图、时间线和事实图语义
+
+## 数据模型重定义
+
+### 统一记忆项
+
+建议引入统一记忆对象结构:
+
+```json
+{
+  "id": "mem-001",
+  "layer": "semantic",
+  "category": "character_state",
+  "subject": "xiaoyan",
+  "field": "realm",
+  "value": "筑基三层",
+  "status": "active",
+  "source": {
+    "chapter": 128,
+    "type": "state_change"
+  },
+  "evidence": [
+    "state_change:xiaoyan:realm:128",
+    "summary:ch0128"
+  ],
+  "updated_at": "2026-03-19T20:00:00+08:00",
+  "confidence": 0.95
+}
+```
+
+### 为什么需要统一记忆项
+
+这样可以统一处理:
+
+- 角色状态
+- 世界规则
+- 伏笔
+- 读者承诺
+- 关系变化
+- 时间线事件
+
+## 新架构的数据流
+
+## 写入流
+
+```text
+正文完成
+   │
+   ▼
+Data Agent
+   │
+   ├── 原有写入:state/index/vectors/summaries
+   └── Memory Write Pipeline
+           │
+           ├── 提取长期事实
+           ├── 合并到 semantic memory
+           ├── 更新 graph memory
+           ├── 标记冲突和过期项
+           └── 刷新 scratchpad cache
+```
+
+## 读取流
+
+```text
+第N章写作 / 查询 / 审查
+   │
+   ▼
+Memory Orchestrator
+   │
+   ├── Working Memory
+   ├── Episodic Memory
+   ├── Semantic Memory
+   ├── Graph Memory
+   └── Conflict Resolution
+   ▼
+Memory Pack
+   ▼
+ContextManager / Query Renderer / Review Renderer
+```
+
+## 对现有模块的角色调整
+
+### `ContextManager`
+
+从:
+
+- 核心上下文拼装器
+
+变为:
+
+- memory pack 渲染器
+- 模板权重应用器
+
+### `StateManager`
+
+从:
+
+- 运行态状态 + 部分知识写入
+
+变为:
+
+- 运行态状态管理器
+- 章节结果写入入口
+- 记忆写入流水线触发器
+
+### `IndexManager`
+
+从:
+
+- 结构化索引库
+
+变为:
+
+- episodic structured memory backend
+
+### `RAGAdapter`
+
+从:
+
+- 场景检索器
+
+变为:
+
+- episodic retrieval backend
+
+## 推荐实施顺序
+
+### Stage 1:搭 memory 子系统骨架
+
+- 新增 `memory/` 目录
+- 定义统一记忆 schema
+- 抽出 scratchpad 存储
+
+### Stage 2:接写入管线
+
+- 章节写后自动更新 semantic memory
+- 建立冲突状态字段
+
+### Stage 3:接编排器
+
+- `ContextManager` 改为消费 orchestrator
+- `extract_chapter_context.py` 改为基于 memory pack 输出
+
+### Stage 4:接图记忆
+
+- 先在 `index.db` 扩展 graph-like tables
+- 后续如有必要再拆独立图库
+
+### Stage 5:扩展 dashboard 和观测
+
+- 查看长期事实
+- 查看冲突项
+- 查看记忆命中来源
+
+## 关键取舍
+
+### 不建议做的事
+
+- 继续把所有逻辑堆进 `ContextManager`
+- 只加一个 `memory_scratchpad.json` 就认为问题解决
+- 第一阶段就引入完整图数据库
+- 直接照搬 Letta/MemGPT 的 agent runtime
+
+### 建议做的事
+
+- 把 memory 作为独立子系统设计
+- 先解决记忆写入问题,再优化读取
+- 用统一记忆项结构降低后续复杂度
+- 让图语义先以轻量方式落在现有 SQLite 中
+
+## 最终目标
+
+最终系统应具备以下能力:
+
+1. 写完一章后,系统能自动沉淀长期有效事实
+2. 多章之后,系统能区分历史事实和当前事实
+3. 写作前,系统能自动提供“近期上下文 + 历史证据 + 长期约束”
+4. 当关系、设定、伏笔变化时,系统能保留版本与证据
+5. 审查和查询模块能共享同一套长期记忆底座
+
+## 总结
+
+基于调研,新的最佳架构不是“在现有系统上补一个功能点”,而是:
+
+**把长期记忆升级为一个独立子系统,并让现有写作系统围绕它重新分层。**
+
+这版架构比旧方案更适合长期演化,也更符合当前长期记忆研究和工程实践的主流方向。

+ 510 - 0
docs/long-term-memory-research-report.md

@@ -0,0 +1,510 @@
+# 长期记忆论文与项目调研报告
+
+## 文档目标
+
+本文档汇总大语言模型长期记忆方向中较有代表性的论文、基准和开源项目,重点回答四个问题:
+
+- 长期记忆问题到底在解决什么
+- 这个领域近两年的主流技术路线是什么
+- 哪些项目已经做到较强的工程落地
+- 对当前 `webnovel-writer` 最值得借鉴的能力是什么
+
+说明:
+
+- 本报告优先使用论文原文、arXiv 页面和项目官方仓库
+- 报告不是穷举式综述,而是面向架构决策的工程调研
+- 截止本次检索时间,最新参考到 2026 年 3 月可见资料
+
+## 一句话结论
+
+当前长期记忆方向已经很明确地形成了三条主线:
+
+1. `外部记忆 + 检索`:把历史信息存到外部存储,再按需召回
+2. `分层记忆 + 编排`:把近期上下文、历史证据、长期摘要分层管理
+3. `图结构 / 时态记忆`:用知识图谱或时态图来处理事实更新、关系变化和时间推理
+
+对我们项目最直接有用的不是“更大上下文”,而是:
+
+- 独立的长期摘要层
+- 统一的记忆编排层
+- 面向事实更新的状态管理
+
+## 为什么长期记忆仍然是独立问题
+
+代表性基准都在说明同一件事:
+
+- 单纯增加上下文窗口,并不能稳定解决长期记忆
+- 模型在多会话、多时间点、事实更新、跨章节整合时仍然容易退化
+- 记忆系统的核心不只是“存”,还包括“写入、压缩、检索、更新、冲突裁决”
+
+这一点在以下基准中都被反复验证:
+
+- `LoCoMo`
+- `LongMemEval`
+- `BEAM`
+
+## 评测基准
+
+### 1. LoCoMo
+
+论文:
+
+- `Evaluating Very Long-Term Conversational Memory of LLM Agents`
+- arXiv:2402.17753
+- 链接:<https://arxiv.org/abs/2402.17753>
+
+关键点:
+
+- 数据集聚焦“超长期对话记忆”
+- 每条对话平均约 `300 turns`
+- 平均约 `9K tokens`
+- 覆盖最多 `35 sessions`
+- 评测问答、事件总结、多模态对话生成
+
+价值:
+
+- 是很多后续 memory 系统对比的常用基准
+- 强调多轮会话与长期连续性,而不只是单文档检索
+
+来源:LoCoMo 论文摘要页 <https://arxiv.org/abs/2402.17753>
+
+### 2. LongMemEval
+
+论文:
+
+- `LongMemEval: Benchmarking Chat Assistants on Long-Term Interactive Memory`
+- arXiv:2410.10813
+- 链接:<https://arxiv.org/abs/2410.10813>
+
+关键点:
+
+- 明确拆出五种长期记忆能力:
+  - 信息抽取
+  - 多会话推理
+  - 时间推理
+  - 知识更新
+  - 拒答
+- 包含 `500` 个精心构造的问题
+- 论文指出,商业助手和长上下文模型在持续交互上的准确率会出现约 `30%` 下滑
+
+价值:
+
+- 比纯“召回率”更接近真实助手场景
+- 很适合衡量“事实更新”和“跨会话推理”
+
+来源:LongMemEval 论文摘要页 <https://arxiv.org/abs/2410.10813>
+
+### 3. BEAM
+
+论文:
+
+- `Beyond a Million Tokens: Benchmarking and Enhancing Long-Term Memory in LLMs`
+- arXiv:2510.27246
+- 链接:<https://arxiv.org/abs/2510.27246>
+
+关键点:
+
+- 生成最长可达 `10M tokens` 的连贯对话
+- 构建 `100` 段对话和 `2000` 个校验问题
+- 同时提出 `LIGHT` 记忆框架
+- 作者报告 LIGHT 相对强基线平均提升 `3.5% - 12.69%`
+
+价值:
+
+- 把“超长上下文”与“长期记忆”区分得更清楚
+- 对我们这种长篇创作系统非常有参考价值
+
+来源:BEAM/LIGHT 论文摘要页 <https://arxiv.org/abs/2510.27246>
+
+## 代表论文
+
+### A. 早期外部记忆路线
+
+#### MemoryBank
+
+- 论文:`MemoryBank: Enhancing Large Language Models with Long-Term Memory`
+- arXiv:2305.10250
+- 链接:<https://arxiv.org/abs/2305.10250>
+
+核心思想:
+
+- 把对话历史转成外部记忆
+- 检索相关记忆参与回答
+- 引入类似“遗忘曲线”的更新机制
+- 强调用户画像和长期陪伴式交互
+
+意义:
+
+- 很早就提出“记忆不是纯存档,而是要更新和遗忘”
+- 适合人格化、陪伴式、个性化场景
+
+局限:
+
+- 更偏对话陪伴
+- 工程结构相对较早,抽象层级不够细
+
+#### LongMem
+
+- 论文:`Augmenting Language Models with Long-Term Memory`
+- arXiv:2306.07174
+- 链接:<https://arxiv.org/abs/2306.07174>
+
+核心思想:
+
+- 冻结 backbone LLM 作为 memory encoder
+- 旁路引入 retriever/reader side-network
+- 把长期上下文缓存为外部记忆
+
+意义:
+
+- 代表“模型架构级长期记忆”的路线
+- 不只是 prompt engineering,而是显式引入 memory 模块
+
+局限:
+
+- 对我们当前项目这种插件式工程系统,可复用性不如外部存储方案高
+
+### B. 分层记忆与虚拟上下文路线
+
+#### MemGPT
+
+- 论文:`MemGPT: Towards LLMs as Operating Systems`
+- arXiv:2310.08560
+- 链接:<https://arxiv.org/abs/2310.08560>
+
+核心思想:
+
+- 把 LLM 看成“应用层”
+- 通过类似操作系统的虚拟内存管理,把不同记忆层在上下文和外部存储间切换
+- 用 memory tier 和 control flow 管理超出窗口的历史
+
+意义:
+
+- 是“分层记忆编排”这条路线的关键代表作
+- 强调 memory paging、分层存储和自管理
+
+局限:
+
+- 偏 agent runtime
+- 对小说写作系统来说,直接照搬成本较高
+
+#### LIGHT
+
+- 论文:`Beyond a Million Tokens: Benchmarking and Enhancing Long-Term Memory in LLMs`
+- arXiv:2510.27246
+- 链接:<https://arxiv.org/abs/2510.27246>
+
+核心思想:
+
+- 明确拆成三层:
+  - `episodic memory`
+  - `working memory`
+  - `scratchpad`
+- 用统一编排器组合三者
+
+意义:
+
+- 和我们项目当前改造方向最接近
+- 对“长期写作上下文”非常适配
+
+局限:
+
+- 目前还是较新的研究工作
+- 更像工程原型,不是现成平台
+
+### C. 生产化记忆层路线
+
+#### Mem0
+
+- 论文:`Mem0: Building Production-Ready AI Agents with Scalable Long-Term Memory`
+- arXiv:2504.19413
+- 链接:<https://arxiv.org/abs/2504.19413>
+- 官方仓库:<https://github.com/mem0ai/mem0>
+
+核心思想:
+
+- 动态提取对话中的显著信息
+- 做记忆 consolidation
+- 检索时优先使用精炼后的 memory,而不是整段历史
+- 还有 graph-based 变体
+
+官方声称:
+
+- 在 LoCoMo 上相对 OpenAI Memory 有 `+26%` 提升
+- 相对 full-context 有更低延迟与 token 成本
+
+工程价值:
+
+- 非常重视生产环境指标:延迟、token 成本、SDK 接入
+- 是“记忆层产品化”的代表项目
+
+局限:
+
+- 更适合通用 agent/assistant
+- 对小说写作的结构化剧情状态,还需要定制
+
+来源:
+
+- 论文摘要页 <https://arxiv.org/abs/2504.19413>
+- 官方仓库 README <https://github.com/mem0ai/mem0>
+
+#### Zep
+
+- 论文:`Zep: A Temporal Knowledge Graph Architecture for Agent Memory`
+- arXiv:2501.13956
+- 链接:<https://arxiv.org/abs/2501.13956>
+
+核心思想:
+
+- 用 `Graphiti` 作为时态知识图谱引擎
+- 将对话和业务数据融合到一个可追踪历史的时态图中
+- 强调动态知识集成、历史关系维护、低延迟检索
+
+论文报告:
+
+- 在 DMR 上优于 MemGPT
+- 在 LongMemEval 上最高可提升 `18.5%`
+- 相对基线延迟降低 `90%`
+
+工程价值:
+
+- 很适合“事实会变、关系会变、时间有强语义”的场景
+- 对角色关系、势力变化、设定修订这类小说问题很有参考性
+
+来源:Zep 论文摘要页 <https://arxiv.org/abs/2501.13956>
+
+#### MIRIX
+
+- 论文:`MIRIX: Multi-Agent Memory System for LLM-Based Agents`
+- arXiv:2507.07957
+- 链接:<https://arxiv.org/abs/2507.07957>
+
+核心思想:
+
+- 设计六类记忆:
+  - Core
+  - Episodic
+  - Semantic
+  - Procedural
+  - Resource Memory
+  - Knowledge Vault
+- 用多 Agent 协同管理更新和检索
+- 扩展到多模态记忆
+
+意义:
+
+- 展示了“记忆分工继续细化”的趋势
+- 证明未来 memory system 不一定只分三层
+
+局限:
+
+- 架构更复杂
+- 对当前项目来说有参考价值,但不适合第一阶段直接引入
+
+来源:MIRIX 论文摘要页 <https://arxiv.org/abs/2507.07957>
+
+## 代表开源项目
+
+### 1. Letta
+
+- 仓库:<https://github.com/letta-ai/letta>
+- 说明:原 MemGPT 项目已经并入 Letta
+
+官方定位:
+
+- `stateful agents`
+- 强调 agent 可跨会话持续存在、学习、自我改进
+
+值得关注的点:
+
+- 长寿命 agent
+- memory blocks
+- 持久化 agent runtime
+
+对我们的启发:
+
+- 适合作为“长期存在的创作 Agent”参考
+- 但它更像完整 agent 平台,不适合直接嵌入当前插件架构
+
+来源:
+
+- Letta 仓库 README <https://github.com/letta-ai/letta>
+- 官方说明 <https://www.letta.com/blog/memgpt-and-letta>
+
+### 2. Mem0
+
+- 仓库:<https://github.com/mem0ai/mem0>
+
+官方定位:
+
+- `Universal memory layer for AI Agents`
+
+值得关注的点:
+
+- SDK 化程度高
+- 支持自托管和托管
+- 强调多层 memory、用户偏好、会话和 agent 状态
+
+对我们的启发:
+
+- 非常适合作为“独立 memory layer”的工程参考
+- 尤其适合看它怎么做 API、接入层和 memory 抽取流程
+
+来源:Mem0 官方仓库 README <https://github.com/mem0ai/mem0>
+
+### 3. Graphiti
+
+- 仓库:<https://github.com/getzep/graphiti>
+
+官方定位:
+
+- `Build Real-Time Knowledge Graphs for AI Agents`
+
+特点:
+
+- 时态知识图谱
+- 支持实体、事实关系、原始 episodes、ontology
+- 适合动态环境中的 agent memory
+
+对我们的启发:
+
+- 非常适合角色关系、势力结构、事件时间线
+- 如果后续想做“剧情知识图谱”,Graphiti/Zep 是最值得研究的一类
+
+来源:Graphiti 官方仓库 README <https://github.com/getzep/graphiti>
+
+## 研究趋势总结
+
+### 趋势 1:从“长上下文”转向“记忆系统”
+
+近两年最明确的结论是:
+
+- 长上下文不等于长期记忆
+- 真正有效的方案都在做外部 memory、结构化抽取、分层编排
+
+### 趋势 2:从“检索片段”转向“记忆写入与更新”
+
+早期系统重点是:
+
+- 怎么检索历史
+
+近年的重点已经变成:
+
+- 记忆写什么
+- 什么时候合并
+- 什么时候标记过期
+- 遇到冲突怎么裁决
+
+### 趋势 3:从“平面向量库”转向“图结构和时态结构”
+
+在纯向量检索之外,越来越多系统开始强调:
+
+- entity / relation
+- temporal validity
+- provenance
+- update / invalidation
+
+这对任何“事实会变化”的系统都非常关键。
+
+### 趋势 4:生产系统开始关注成本和延迟
+
+像 Mem0、Zep 这类项目,不再只讲准确率,而是同时强调:
+
+- p95 延迟
+- token 成本
+- 开发者接入成本
+- 托管与可观测性
+
+## 对当前项目最有用的结论
+
+### 最值得借鉴的不是某一个项目,而是三类能力
+
+#### 1. LIGHT 的分层思想
+
+最适合我们当前项目的核心抽象仍然是:
+
+- `working memory`
+- `episodic memory`
+- `scratchpad`
+
+原因:
+
+- 我们已经有 working 和 episodic 的基础
+- 当前最缺的正是 scratchpad
+
+#### 2. Zep/Graphiti 的时态事实处理
+
+对小说系统特别重要的是:
+
+- 同一角色状态会变
+- 同一关系会演化
+- 同一设定可能被修订
+
+所以光做向量检索不够,必须有事实状态和时间语义。
+
+#### 3. Mem0 的工程落地方式
+
+Mem0 很值得借鉴的是:
+
+- memory layer 独立化
+- SDK/接口清晰
+- 生产环境关注延迟和 token 成本
+
+这对我们把长期记忆做成独立模块非常有帮助。
+
+## 对 `webnovel-writer` 的映射建议
+
+### 可以直接吸收的部分
+
+- LIGHT:三层记忆结构
+- Mem0:独立 memory layer 设计
+- Zep/Graphiti:关系和时间线建模方式
+
+### 暂时不建议直接照搬的部分
+
+- Letta:完整 stateful agent 平台,体量过大
+- MIRIX:分类太细,第一阶段会过重
+- LongMem:更偏模型架构级改造,不适合当前插件工程
+
+## 我的建议排序
+
+如果以“对当前项目收益 / 改造成本”排序,我建议优先关注:
+
+1. `LIGHT`
+2. `Mem0`
+3. `Zep / Graphiti`
+4. `MemGPT / Letta`
+5. `MIRIX`
+6. `MemoryBank`
+7. `LongMem`
+
+## 结论
+
+长期记忆方向现在已经很清楚:
+
+- 上下文窗口只是基础设施,不是最终答案
+- 真正有效的是“分层记忆 + 持续写入 + 智能检索 + 冲突管理”
+
+对当前项目来说,最现实的路线不是做一个通用 agent 平台,而是:
+
+- 保留现有 `state.json / index.db / vectors.db`
+- 新增 `scratchpad` 长期摘要层
+- 再加一个统一的 `memory orchestrator`
+
+这条路线与近期论文结论最一致,也最贴合当前系统基础。
+
+## 参考链接
+
+- MemoryBank: <https://arxiv.org/abs/2305.10250>
+- LongMem: <https://arxiv.org/abs/2306.07174>
+- MemGPT: <https://arxiv.org/abs/2310.08560>
+- LoCoMo: <https://arxiv.org/abs/2402.17753>
+- LongMemEval: <https://arxiv.org/abs/2410.10813>
+- Zep: <https://arxiv.org/abs/2501.13956>
+- Mem0 论文: <https://arxiv.org/abs/2504.19413>
+- Mem0 仓库: <https://github.com/mem0ai/mem0>
+- MIRIX: <https://arxiv.org/abs/2507.07957>
+- Graphiti 仓库: <https://github.com/getzep/graphiti>
+- Letta 仓库: <https://github.com/letta-ai/letta>
+- Letta / MemGPT 迁移说明: <https://www.letta.com/blog/memgpt-and-letta>
+- LIGHT / BEAM: <https://arxiv.org/abs/2510.27246>

+ 1 - 1
webnovel-writer/.claude-plugin/plugin.json

@@ -1,6 +1,6 @@
 {
   "name": "webnovel-writer",
-  "version": "5.5.4",
+  "version": "5.5.5",
   "description": "长篇网文创作系统(skills + agents + data chain + RAG)",
   "author": {
     "name": "lingfengQAQ"

+ 182 - 173
webnovel-writer/agents/context-agent.md

@@ -1,21 +1,22 @@
 ---
 name: context-agent
-description: 上下文搜集Agent,内置 Context Contract,输出可被 Step 2A 直接消费的创作执行包。
+description: 上下文搜集 Agent,内置 Context Contract,输出可被 Step 2A 直接消费的创作执行包。
 tools: Read, Grep, Bash
 model: inherit
 ---
 
-# context-agent (上下文搜集Agent)
+# context-agent(上下文搜集 Agent)
 
-> **Role**: 创作执行包生成器。目标是“能直接开写”,不堆信息
-> **Philosophy**: 按需召回 + 推断补全,确保接住上章、场景清晰、留出钩子。
+> **职责**:生成可直接开写的创作执行包,目标是“信息够用、约束清楚、无需补问”
+> **原则**:按需召回、推断补全、先接住上章、再锁定本章任务与章末钩子。
 
 ## 核心参考
 
-- **Taxonomy**: `${CLAUDE_PLUGIN_ROOT}/references/reading-power-taxonomy.md`
-- **Genre Profile**: `${CLAUDE_PLUGIN_ROOT}/references/genre-profiles.md`
-- **Context Contract**: `${CLAUDE_PLUGIN_ROOT}/skills/webnovel-write/references/step-1.5-contract.md`
-- **Shared References**: `${CLAUDE_PLUGIN_ROOT}/references/shared/` 为单一事实源;如需枚举/扫描参考文件,遇到 `<!-- DEPRECATED:` 的文件一律跳过。
+- **分类参考**:`${CLAUDE_PLUGIN_ROOT}/references/reading-power-taxonomy.md`
+- **题材画像**:`${CLAUDE_PLUGIN_ROOT}/references/genre-profiles.md`
+- **执行合同**:`${CLAUDE_PLUGIN_ROOT}/skills/webnovel-write/references/step-1.5-contract.md`
+- **共享事实源**:`${CLAUDE_PLUGIN_ROOT}/references/shared/`
+  - 若需枚举共享参考文件,遇到 `<!-- DEPRECATED:` 的文件一律跳过。
 
 ## 输入
 
@@ -28,152 +29,163 @@ model: inherit
 }
 ```
 
-## 输出格式:创作执行包(Step 2A 直连)
+## 输出格式:创作执行包
 
-输出必须是单一执行包,包含 3 层:
+输出必须是一个单一执行包,包含以下 3 层内容,且三层信息必须一致。
 
-1. **任务书(8板块)**
-- 本章核心任务(目标/阻力/代价、冲突一句话、必须完成、绝对不能、反派层级)
-- 接住上章(上章钩子、读者期待、开头建议)
-- 出场角色(状态、动机、情绪底色、说话风格、红线)
-- 场景与力量约束(地点、可用能力、禁用能力)
-- **时间约束(新增)**(上章时间锚点、本章时间锚点、允许推进跨度、时间过渡要求、倒计时状态)
-- 风格指导(本章类型、参考样本、最近模式、本章建议)
-- 连续性与伏笔(时间/位置/情绪连贯;必须处理/可选伏笔)
-- 追读力策略(未闭合问题 + 钩子类型/强度、微兑现建议、差异化提示)
+### 1. 任务书(8 个板块)
+
+- 本章核心任务:目标、阻力、代价、核心冲突一句话、必须完成、绝对不能、反派层级
+- 接住上章:上章钩子、读者期待、开头建议
+- 出场角色:状态、动机、情绪底色、说话风格、行为红线
+- 场景与力量约束:地点、可用能力、禁用能力
+- 时间约束:上章时间锚点、本章时间锚点、允许推进跨度、时间过渡要求、倒计时状态
+- 风格指导:本章类型、参考样本、最近模式、本章建议
+- 连续性与伏笔:时间/位置/情绪连贯;必须处理与可选伏笔
+- 追读力策略:未闭合问题、钩子类型/强度、微兑现建议、差异化提示
+
+### 2. Context Contract(内置合同)
 
-2. **Context Contract(内置 Step 1.5)**
 - 目标、阻力、代价、本章变化、未闭合问题、核心冲突一句话
 - 开头类型、情绪节奏、信息密度
-- 是否过渡章(必须按大纲判定,禁止按字数判定)
-- 追读力设计钩子类型/强度、微兑现清单、爽点模式
+- 是否过渡章
+- 追读力设计钩子类型/强度、微兑现清单、爽点模式
 
-3. **Step 2A 直写提示词**
-- 章节节拍(开场触发 → 推进/受阻 → 反转/兑现 → 章末钩子)
-- 不可变事实清单(大纲事实/设定事实/承接事实)
-- 禁止事项(越级能力、无因果跳转、设定冲突、剧情硬拐)
-- 终检清单(本章必须满足项 + fail 条件)
+### 3. Step 2A 直写提示词
 
-要求:
-- 三层信息必须一致;若冲突,以“设定 > 大纲 > 风格偏好”优先。
-- 输出内容必须能直接给 Step 2A 开写,不再依赖额外补问。
+- 章节节拍:开场触发 → 推进/受阻 → 反转/兑现 → 章末钩子
+- 不可变事实清单:大纲事实、设定事实、承接事实、长期记忆事实
+- 禁止事项:越级能力、无因果跳转、设定冲突、剧情硬拐
+- 终检清单:本章必须满足项与 fail 条件
 
----
+硬规则:
+- 若信息冲突,优先级为 `设定 > 大纲 > 长期记忆 > 风格偏好`。
+- 输出内容必须能直接交给 Step 2A 开写,不得依赖额外补问。
 
 ## 读取优先级与默认值
 
 | 字段 | 读取来源 | 缺失时默认值 |
 |------|---------|-------------|
 | 上章钩子 | `chapter_meta[NNNN].hook` 或 `chapter_reading_power` | `{type: "无", content: "上章无明确钩子", strength: "weak"}` |
-| 最近3章模式 | `chapter_meta` 或 `chapter_reading_power` | 空数组,不做重复检查 |
-| 上章结束情绪 | `chapter_meta[NNNN].ending.emotion` | "未知"(提示自行判断) |
-| 角色动机 | 大纲+角色状态推断 | **必须推断,无默认值** |
-| 题材Profile | `state.json → project.genre` | 默认 "shuangwen" |
-| 当前债务 | `index.db → chase_debt` | 0 |
-
-**缺失处理**:
-- 若 `chapter_meta` 不存在(如第1章),跳过“接住上章”
-- 最近3章数据不完整时,只用现有数据做差异化检查
+| 最近 3 章模式 | `chapter_meta` 或 `chapter_reading_power` | 空数组 |
+| 上章结束情绪 | `chapter_meta[NNNN].ending.emotion` | `未知` |
+| 角色动机 | 大纲 + 角色状态推断 | 必须推断,无默认值 |
+| 题材画像 | `state.json -> project.genre` | `shuangwen` |
+| 当前债务 | `index.db -> chase_debt` | `0` |
+
+缺失处理:
+- 若 `chapter_meta` 不存在,跳过“接住上章”
+- 最近 3 章数据不完整时,只用现有数据做差异化检查
 - 若 `plot_threads.foreshadowing` 缺失或非列表:
-  - 视为“当前无结构化伏笔数据”,第 7 板块输出空清单并显式标注“数据缺失,需人工补录”
-  - 禁止静默跳过第 7 板块
-
-**章节编号规则**: 4位数字,如 `0001`, `0099`, `0100`
+  - 第 7 板块仍必须输出;
+  - 显式标注“结构化伏笔数据缺失,需人工补录”;
+  - 禁止静默跳过伏笔板块。
 
----
+章节编号统一使用 4 位数,如 `0001`、`0099`、`0100`。
 
 ## 关键数据来源
 
-- `state.json`: 进度、主角状态、strand_tracker、chapter_meta、project.genre、plot_threads.foreshadowing
-- `index.db`: 实体/别名/关系/状态变化/override_contracts/chase_debt/chapter_reading_power
-- `.webnovel/summaries/ch{NNNN}.md`: 章节摘要(含钩子/结束状态)
-- `.webnovel/context_snapshots/`: 上下文快照(优先复用)
+- `state.json`:进度、主角状态、`strand_tracker`、`chapter_meta`、`project.genre`、`plot_threads.foreshadowing`
+- `index.db`:实体、别名、关系、状态变化、覆盖合同、追读力债务
+- `.webnovel/summaries/ch{NNNN}.md`:章节摘要
+- `.webnovel/context_snapshots/`:上下文快照,优先复用
+- `.webnovel/long_term_memory.json`:长期记忆主存储
+- `.webnovel/memory_scratchpad.json`:长期记忆暂存与待压缩事实
 - `大纲/` 与 `设定集/`
 
-**钩子数据来源说明**:
-- **章纲的"钩子"字段**:本章应设置的章末钩子(规划用)
-- **chapter_meta[N].hook**:本章实际设置的钩子(执行结果)
-- **context-agent 读取**:chapter_meta[N-1].hook 作为"上章钩子"
-- **数据流**:章纲规划 → 写作实现 → 写入 chapter_meta → 下章读取
+钩子数据说明:
+- 章纲中的“钩子”字段:规划中的章末钩子
+- `chapter_meta[N].hook`:实际写入后的章末钩子
+- 本 Agent 读取 `chapter_meta[N-1].hook` 作为“上章钩子”
 
----
-
-## 执行流程(精简版)
+## 执行流程
 
-### Step -1: CLI 入口与脚本目录校验(必做)
+### Step 1:校验脚本入口与项目根目录
 
-为避免 `PYTHONPATH` / `cd` / 参数顺序导致的隐性失败,所有 CLI 调用统一走:
-- `${SCRIPTS_DIR}/webnovel.py`
+所有 CLI 调用统一走 `${SCRIPTS_DIR}/webnovel.py`。
 
 ```bash
-# 仅使用 CLAUDE_PLUGIN_ROOT,避免多路径探测带来的误判
 if [ -z "${CLAUDE_PLUGIN_ROOT}" ] || [ ! -d "${CLAUDE_PLUGIN_ROOT}/scripts" ]; then
   echo "ERROR: 未设置 CLAUDE_PLUGIN_ROOT 或缺少目录: ${CLAUDE_PLUGIN_ROOT}/scripts" >&2
   exit 1
 fi
-SCRIPTS_DIR="${CLAUDE_PLUGIN_ROOT}/scripts"
 
-# 建议先确认解析出的 project_root,避免写到错误目录
+SCRIPTS_DIR="${CLAUDE_PLUGIN_ROOT}/scripts"
 python "${SCRIPTS_DIR}/webnovel.py" --project-root "{project_root}" where
 ```
 
-### Step 0: ContextManager 快照优先
+要求:
+- `project_root` 必须能解析到真实书项目根。
+- 任一校验失败立即中断,不进入后续步骤。
+
+### Step 2:优先读取 ContextManager 快照
+
 ```bash
 python "${SCRIPTS_DIR}/webnovel.py" --project-root "{project_root}" context -- --chapter {NNNN}
 ```
 
-### Step 0.5: Context Contract 上下文包(内置)
+要求:
+- 若已有可用快照,优先复用快照中的稳定事实。
+- 快照与最新大纲冲突时,以最新大纲为准。
+
+### Step 3:读取上下文合同包
+
 ```bash
 python "${SCRIPTS_DIR}/webnovel.py" --project-root "{project_root}" extract-context --chapter {NNNN} --format json
 ```
 
-- 必须读取:`writing_guidance.guidance_items`
-- 推荐读取:`reader_signal` 与 `genre_profile.reference_hints`
-- 条件读取:`rag_assist`(当 `invoked=true` 且 `hits` 非空时,必须提炼成可执行约束,禁止只贴检索命中)
+必须读取:
+- `writing_guidance.guidance_items`
+
+推荐读取:
+- `reader_signal`
+- `genre_profile.reference_hints`
 
-### Step 0.6: 时间线读取(新增,必做)
+条件读取:
+- `rag_assist.invoked=true` 且 `hits` 非空时,必须把命中内容提炼成可执行约束,禁止原样粘贴检索结果。
+
+### Step 4:读取时间线与长期记忆
 
 先确定 `{volume_id}`:
-- 优先读取 `state.json` 中当前卷信息(如有)
-- 若缺失,则从 `大纲/总纲.md` 的章节范围反推 `{NNNN}` 所在卷
+- 优先读取 `state.json` 当前卷信息
+- 若缺失,则从 `大纲/总纲.md` 的章节范围反推
+
+读取本卷时间线:
 
-读取本卷时间线表:
 ```bash
 cat "{project_root}/大纲/第{volume_id}卷-时间线.md"
 ```
 
-从章纲提取本章时间字段:
-- `时间锚点`:本章发生的具体时间
-- `章内时间跨度`:本章覆盖的时间长度
-- `与上章时间差`:与上章的时间间隔
-- `倒计时状态`:若有倒计时事件的推进情况
-
-从上章 chapter_meta 或章纲提取:
-- 上章结束时间锚点
-- 上章倒计时状态
-
-生成时间约束输出(必须包含在任务书第 5 板块):
-```markdown
-## 时间约束
-- 上章时间锚点: {末世第3天 黄昏}
-- 本章时间锚点: {末世第4天 清晨}
-- 与上章时间差: {跨夜}
-- 本章允许推进: 最大 {章内时间跨度}
-- 时间过渡要求: {若跨夜/跨日,需补写的过渡句}
-- 倒计时状态: {物资耗尽 D-5 → D-4 / 无}
+读取长期记忆:
+
+```bash
+cat "{project_root}/.webnovel/long_term_memory.json"
+cat "{project_root}/.webnovel/memory_scratchpad.json"
 ```
 
-**时间约束硬规则**:
-- 若 `与上章时间差` 为"跨夜"或"跨日",必须在任务书中标注"需补写时间过渡"
-- 若存在倒计时事件,必须校验推进是否正确(D-N 只能变为 D-(N-1),不可跳跃)
-- 时间锚点不得回跳(除非明确标注为闪回章节)
+必须提取:
+- 本章时间锚点、章内时间跨度、与上章时间差、倒计时状态
+- 与当前章节直接相关的长期事实:`timeline_events`、`world_rules`、`open_loops`、`reader_promises`
+
+时间硬规则:
+- `跨夜` 或 `跨日` 必须标注“需补写时间过渡”。
+- 倒计时只能按有效步长推进,不得跳跃。
+- 时间锚点不得回跳,除非明确标注闪回。
+
+长期记忆硬规则:
+- 只提炼与本章直接相关的事实,禁止整库搬运。
+- `open_loops` 与 `reader_promises` 命中时,必须进入任务书或终检清单。
+
+### Step 5:读取大纲与运行状态
 
-### Step 1: 读取大纲与状态
 - 大纲:`大纲/卷N/第XXX章.md` 或 `大纲/第{卷}卷-详细大纲.md`
-  - 必须优先提取并写入任务书:目标/阻力/代价/反派层级/本章变化/章末未闭合问题/钩子(若存在)
-- `state.json`:progress / protagonist_state / chapter_meta / project.genre
+- 状态:`state.json`
+
+必须提取并写入任务书:
+- 目标、阻力、代价、反派层级、本章变化、章末未闭合问题、钩子
+
+### Step 6:读取追读力、债务与模式数据
 
-### Step 2: 追读力与债务(按需)
 ```bash
 python "${SCRIPTS_DIR}/webnovel.py" --project-root "{project_root}" index get-recent-reading-power --limit 5
 python "${SCRIPTS_DIR}/webnovel.py" --project-root "{project_root}" index get-pattern-usage-stats --last-n 20
@@ -181,88 +193,85 @@ python "${SCRIPTS_DIR}/webnovel.py" --project-root "{project_root}" index get-ho
 python "${SCRIPTS_DIR}/webnovel.py" --project-root "{project_root}" index get-debt-summary
 ```
 
-### Step 3: 实体与最近出场 + 伏笔读取
+要求:
+- 仅用于差异化建议、追读力设计、债务提醒。
+- 不得让“追读力偏好”覆盖大纲主任务。
+
+### Step 7:读取实体、出场记录与伏笔
+
 ```bash
 python "${SCRIPTS_DIR}/webnovel.py" --project-root "{project_root}" index get-core-entities
 python "${SCRIPTS_DIR}/webnovel.py" --project-root "{project_root}" index recent-appearances --limit 20
 ```
 
-- 从 `state.json` 读取:
-  - `progress.current_chapter`
-  - `plot_threads.foreshadowing`(主路径)
-- 缺失降级:
-  - 若 `plot_threads.foreshadowing` 不存在或类型错误,置为空数组并打标 `foreshadowing_data_missing=true`
-- 对每条伏笔至少提取:
-  - `content`
-  - `planted_chapter`
-  - `target_chapter`
-  - `resolved_chapter`
-  - `status`
-- 回收判定优先级:
-  - 若 `resolved_chapter` 非空,直接视为已回收并排除(即使 `status` 文案异常)
-  - 否则按 `status` 判定是否已回收
-- 生成排序键:
-  - `remaining = target_chapter - current_chapter`(若缺失则记为 `null`)
-  - 二次排序:`planted_chapter` 升序(更早埋设优先)
-  - 三次排序:`content` 字典序(确保稳定)
-- 输出到第 7 板块时,按 `remaining` 升序列出。
-
-### Step 4: 摘要与推断补全
+伏笔处理规则:
+- 主路径:`state.json -> plot_threads.foreshadowing`
+- 缺失时置为空数组,并标记 `foreshadowing_data_missing=true`
+- 每条伏笔至少提取:`content`、`planted_chapter`、`target_chapter`、`resolved_chapter`、`status`
+- 若 `resolved_chapter` 非空,直接视为已回收并排除
+- 排序键:
+  - `remaining = target_chapter - current_chapter`
+  - 再按 `planted_chapter` 升序
+  - 再按 `content` 字典序
+
+第 7 板块输出规则:
+- `必须处理`:`remaining <= 5` 或已超期
+- `可选伏笔`:最多 5 条
+- 若数据缺失,必须显式说明
+
+### Step 8:读取摘要并做推断补全
+
 - 优先读取 `.webnovel/summaries/ch{NNNN-1}.md`
-- 若缺失,降级为章节正文前 300-500 字概述
-- 推断规则:
-  - 动机 = 角色目标 + 当前处境 + 上章钩子压力
-  - 情绪底色 = 上章结束情绪 + 事件走向
-  - 可用能力 = 当前境界 + 近期获得 + 设定禁用项
-
-### Step 5: 组装创作执行包(任务书 + Context Contract + 直写提示词)
-输出可直接供 Step 2A 消费的单一执行包,不拆分独立 Step 1.5。
-
-- 第 7 板块必须包含“伏笔优先级清单”:
-  - `必须处理(本章优先)`:`remaining <= 5` 或已超期(`remaining < 0`),全部列出不截断
-  - `可选伏笔(可延后)`:最多 5 条
-- 第 7 板块生成规则(统一口径):
-  - 仅纳入未回收伏笔(见 Step 3 回收判定)
-  - 主排序按 `remaining` 升序,`remaining=null` 放末尾
-  - 若 `必须处理` 超过 3 条:前 3 条标记“最高优先”,其余标记“本章仍需处理”
-  - 若 `可选伏笔` 超过 5 条:展示前 5 条并标注“其余 N 条可选伏笔已省略”
-  - 若 `foreshadowing_data_missing=true`:明确输出“结构化伏笔数据缺失,当前清单仅供占位”
-
-Context Contract 必须字段(不可缺):
-- `目标` / `阻力` / `代价` / `本章变化` / `未闭合问题`
-- `核心冲突一句话`
-- `开头类型` / `情绪节奏` / `信息密度`
-- `是否过渡章`
-- `追读力设计`
-
-### Step 6: 逻辑红线校验(输出前强制)
-对执行包做一致性自检,任一 fail 则回到 Step 5 重组:
-
-- 红线1:不可变事实冲突(大纲关键事件、设定规则、上章既有结果)
-- 红线2:时空跳跃无承接(地点/时间突变且无过渡)
-- 红线3:能力或信息无因果来源(突然会/突然知道)
-- 红线4:角色动机断裂(行为与近期目标明显冲突且无触发)
-- 红线5:合同与任务书冲突(例如“过渡章=true”却要求高强度高潮兑现)
-- **红线6:时间逻辑错误**(时间回跳、倒计时跳跃、大跨度无过渡)
+- 缺失时退化为上一章正文前 300-500 字概述
 
-通过标准
-- 红线 fail 数 = 0
-- 执行包内包含“不可变事实清单 + 章节节拍 + 终检清单 + 时间约束”
-- Step 2A 在不补问情况下可直接起草正文
+推断规则:
+- 动机 = 角色目标 + 当前处境 + 上章钩子压力
+- 情绪底色 = 上章结束情绪 + 事件走向
+- 可用能力 = 当前境界 + 近期获得 + 设定禁用项
 
----
+要求:
+- 角色动机和情绪不能留空。
+- 推断结果必须可落实到“出场角色”板块与正文终检。
+
+### Step 9:组装创作执行包
+
+输出单一执行包,包含:
+- 任务书
+- Context Contract
+- Step 2A 直写提示词
+
+硬要求:
+- 任务书必须包含 8 个板块,且含“时间约束”。
+- “不可变事实清单”必须纳入长期记忆事实。
+- `open_loops` 和 `reader_promises` 若与本章有关,必须进入“连续性与伏笔”或“终检清单”。
+- 若 `必须处理` 伏笔超过 3 条:前 3 条标记“最高优先”,其余标记“本章仍需处理”。
+
+### Step 10:执行红线校验并输出
+
+输出前必须做一致性自检,任一 fail 都回到 Step 9 重组:
+
+- 红线 1:不可变事实冲突
+- 红线 2:时空跳跃无承接
+- 红线 3:能力或信息无因果来源
+- 红线 4:角色动机断裂
+- 红线 5:合同与任务书冲突
+- 红线 6:时间逻辑错误
+- 红线 7:长期记忆事实被遗漏或写反
+
+通过标准:
+- fail 数 = 0
+- 执行包内包含:不可变事实清单、章节节拍、终检清单、时间约束
+- Step 2A 无需补问即可直接起草正文
 
 ## 成功标准
 
-1. ✅ 创作执行包可直接驱动 Step 2A(无需补问)
-2. ✅ 任务书包含 8 个板块(含时间约束)
-3. ✅ 上章钩子与读者期待明确(若存在)
-4. ✅ 角色动机/情绪为推断结果(非空)
-5. ✅ 最近模式已对比,给出差异化建议
-6. ✅ 章末钩子建议类型明确
-7. ✅ 反派层级已注明(若大纲提供)
-8. ✅ 第 7 板块已基于 `plot_threads.foreshadowing` 按紧急度排序输出
-9. ✅ Context Contract 字段完整且与任务书一致
-10. ✅ 逻辑红线校验通过(fail=0)
-11. ✅ **时间约束板块完整**(上章时间锚点、本章时间锚点、允许推进跨度、过渡要求、倒计时状态)
-12. ✅ **时间逻辑红线通过**(无回跳、无倒计时跳跃、大跨度有过渡要求)
+1. 创作执行包可直接驱动 Step 2A。
+2. 任务书包含 8 个板块,且时间约束完整。
+3. 上章钩子与读者期待明确。
+4. 角色动机与情绪为非空推断结果。
+5. 最近模式已对比,并给出差异化建议。
+6. 第 7 板块已按紧急度输出伏笔清单。
+7. Context Contract 字段完整且与任务书一致。
+8. 长期记忆事实已被读取,并进入不可变事实或终检清单。
+9. 时间逻辑红线通过。
+10. Step 2A 可在不补问的情况下直接开写。

+ 133 - 160
webnovel-writer/agents/data-agent.md

@@ -1,21 +1,22 @@
 ---
 name: data-agent
-description: 数据处理Agent,负责 AI 实体提取、场景切片、索引构建,并记录钩子/模式/结束状态与章节摘要
+description: 数据处理 Agent,负责实体提取、摘要回写、长期记忆提炼、索引构建与观测记录
 tools: Read, Write, Bash
 model: inherit
 ---
 
-# data-agent (数据处理Agent)
+# data-agent(数据处理 Agent)
 
-> **职责**: 智能数据工程师,负责从章节正文中提取结构化信息并写入数据链。
->
-> **原则**: AI驱动提取,智能消歧 - 用语义理解替代正则匹配,用置信度控制质量。
+> **职责**:从章节正文提取结构化信息,写回状态、索引、摘要、长期记忆与观测日志。
+> **原则**:AI 驱动提取、语义消歧、一次处理、多库同步、失败最小隔离。
 
-**命令示例即最终准则**:本文档中的所有 CLI 命令示例已与当前仓库真实接口对齐。脚本调用方式以本文档示例为准;命令失败时查错误日志定位问题,不去大范围翻源码学习调用方式。
+**命令示例即最终准则**:本文档中的 CLI 调用方式已与当前仓库接口对齐。命令失败时优先查日志,不去翻源码猜调用方式。
 
-**当前约定**:
-- 章节摘要不再追加到正文,改为 `.webnovel/summaries/ch{NNNN}.md`
-- 在 state.json 写入 `chapter_meta`(钩子/模式/结束状态)
+## 当前约定
+
+- 章节摘要写入 `.webnovel/summaries/ch{NNNN}.md`
+- `state.json` 写入 `chapter_meta`
+- 长期记忆提取结果写入 `memory_facts`,再交由写入器同步到 `.webnovel/memory_scratchpad.json`
 
 ## 输入
 
@@ -30,13 +31,19 @@ model: inherit
 }
 ```
 
-`chapter_file` 必须传入实际章节文件路径。若详细大纲已有章节名,优先使用带标题文件名;旧的 `正文/第0100章.md` 仍兼容。
+要求:
+- `chapter_file` 必须传入真实章节文件路径。
+- 若详细大纲已有标题,优先使用 `正文/第0100章-章节标题.md`。
+- 旧格式 `正文/第0100章.md` 仍兼容。
+
+## 主要写入位置
 
-**重要**: 所有数据写入 `{project_root}/.webnovel/` 目录:
-- index.db → 实体、别名、状态变化、关系、章节索引 (SQLite)
-- state.json → 进度、配置、节奏追踪 + chapter_meta
-- vectors.db → RAG 向量 (SQLite)
-- summaries/ → 章节摘要文件
+- `.webnovel/index.db`:实体、别名、关系、状态变化、章节索引
+- `.webnovel/state.json`:进度、主角状态、节奏追踪、`chapter_meta`
+- `.webnovel/vectors.db`:RAG 向量索引
+- `.webnovel/summaries/`:章节摘要文件
+- `.webnovel/memory_scratchpad.json`:长期记忆暂存事实
+- `.webnovel/observability/data_agent_timing.jsonl`:分步耗时日志
 
 ## 输出
 
@@ -54,20 +61,23 @@ model: inherit
   "relationships_new": [
     {"from": "xiaoyan", "to": "hongyi_girl", "type": "相识", "description": "初次见面"}
   ],
+  "memory_facts": {
+    "timeline_events": [],
+    "world_rules": [],
+    "open_loops": [],
+    "reader_promises": []
+  },
   "scenes_chunked": 4,
-  "uncertain": [
-    {"mention": "那位前辈", "candidates": [{"type": "角色", "id": "yaolao"}, {"type": "角色", "id": "elder_zhang"}], "confidence": 0.6}
-  ],
-  "warnings": []
+  "uncertain": [],
+  "warnings": [],
+  "timing_ms": {},
+  "bottlenecks_top3": []
 }
 ```
 
 ## 执行流程
 
-### Step -1: CLI 入口与脚本目录校验(必做)
-
-为避免 `PYTHONPATH` / `cd` / 参数顺序导致的隐性失败,所有 CLI 调用统一走:
-- `${SCRIPTS_DIR}/webnovel.py`
+### Step 1:校验脚本入口与项目根目录
 
 ```bash
 export SCRIPTS_DIR="${CLAUDE_PLUGIN_ROOT:?CLAUDE_PLUGIN_ROOT is required}/scripts"
@@ -75,62 +85,65 @@ python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "{project_root}" pref
 python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "{project_root}" where
 ```
 
-### Step A: 加载上下文(SQL 查询)
+要求:
+- `preflight` 必须通过。
+- 无法解析项目根或脚本目录时立即中断。
 
-使用 Read 工具读取章节正文:
-- 章节正文: 实际章节文件路径(优先 `正文/第0100章-章节标题.md`,旧格式 `正文/第0100章.md` 仍兼容)
+### Step 2:加载正文与索引上下文
 
-使用 Bash 工具从 index.db 查询已有实体:
- ```bash
-  python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "{project_root}" index get-core-entities
-  python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "{project_root}" index get-aliases --entity "xiaoyan"
-  python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "{project_root}" index recent-appearances --limit 20
-  python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "{project_root}" index get-by-alias --alias "萧炎"
-  ```
+使用 `Read` 读取章节正文,使用 `Bash` 读取已有实体与最近出场记录。
 
-### Step B: AI 实体提取
+```bash
+python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "{project_root}" index get-core-entities
+python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "{project_root}" index recent-appearances --limit 20
+```
 
-**Data Agent 直接执行** (无需调用外部 LLM)。
+按需读取:
 
-### Step C: 实体消歧处理
+```bash
+python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "{project_root}" index get-aliases --entity "xiaoyan"
+python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "{project_root}" index get-by-alias --alias "萧炎"
+```
 
-**置信度策略**:
+### Step 3:执行实体提取与语义消歧
 
-| 置信度范围 | 处理方式 |
-|-----------|---------|
-| > 0.8 | 自动采用,无需确认 |
-| 0.5 - 0.8 | 采用建议值,记录 warning |
-| < 0.5 | 标记待人工确认,不自动写入 |
+由 Data Agent 在同一轮上下文内直接完成,不额外调用独立 LLM Agent。
 
-### Step D: 写入存储
+置信度规则:
+- `> 0.8`:自动采用
+- `0.5 - 0.8`:采用建议值,并记录 warning
+- `< 0.5`:标记待人工确认,不自动写入
 
- **写入 index.db (实体/别名/状态变化/关系)**:
- ```bash
-  python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "{project_root}" index upsert-entity --data '{...}'
-  python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "{project_root}" index register-alias --alias "红衣女子" --entity "hongyi_girl" --type "角色"
-  python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "{project_root}" index record-state-change --data '{...}'
-  python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "{project_root}" index upsert-relationship --data '{...}'
- ```
+### Step 4:写入实体、状态与关系数据
 
- **更新精简版 state.json**:
- ```bash
-  python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "{project_root}" state process-chapter --chapter 100 --data '{...}'
- ```
+写入 `index.db`:
 
-写入内容:
-- 更新 `progress.current_chapter`
-- 更新 `protagonist_state`
-- 更新 `strand_tracker`
-- 更新 `disambiguation_warnings/pending`
-- **新增 `chapter_meta`**(钩子/模式/结束状态)
+```bash
+python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "{project_root}" index upsert-entity --data '{...}'
+python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "{project_root}" index register-alias --alias "红衣女子" --entity "hongyi_girl" --type "角色"
+python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "{project_root}" index record-state-change --data '{...}'
+python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "{project_root}" index upsert-relationship --data '{...}'
+```
+
+更新 `state.json`:
+
+```bash
+python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "{project_root}" state process-chapter --chapter 100 --data '{...}'
+```
 
-### Step E: 生成章节摘要文件(新增)
+必须写入:
+- `progress.current_chapter`
+- `protagonist_state`
+- `strand_tracker`
+- `disambiguation_warnings/pending`
+- `chapter_meta`
 
-**输出路径**: `.webnovel/summaries/ch{NNNN}.md`
+### Step 5:生成章节摘要文件
 
-**章节编号规则**: 4位数字,如 `0001`, `0099`, `0100`
+输出路径:`.webnovel/summaries/ch{NNNN}.md`
+
+摘要格式:
 
-**摘要文件格式**:
 ```markdown
 ---
 chapter: 0099
@@ -153,12 +166,28 @@ hook_strength: "strong"
 {下章衔接,30字}
 ```
 
-### Step F: AI 场景切片
+### Step 6:提取长期记忆事实
+
+在同一轮 Data Agent 上下文中提取以下结构,并写入 `memory_facts`:
+- `timeline_events`
+- `world_rules`
+- `open_loops`
+- `reader_promises`
+
+约束:
+- 不新增额外 LLM 调用。
+- 不创建独立 extractor Agent。
+- 只提炼“可跨章复用”的长期事实,不混入临时工作记忆。
+- 提取结果必须交由 `memory/writer.py` 写入 `.webnovel/memory_scratchpad.json`。
+
+### Step 7:执行场景切片
 
-- 按地点/时间/视角切分场景
-- 每个场景生成摘要 (50-100字)
+- 按地点、时间、视角切分场景
+- 每个场景生成 50-100 字摘要
 
-### Step G: 向量嵌入
+### Step 8:写入 RAG 向量与风格样本
+
+向量索引:
 
 ```bash
 python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "{project_root}" rag index-chapter \
@@ -167,98 +196,44 @@ python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "{project_root}" rag
   --summary "本章摘要文本"
 ```
 
-**父子索引规则**:
-- 父块: `chunk_type='summary'`, `chunk_id='ch0100_summary'`
-- 子块: `chunk_type='scene'`, `chunk_id='ch0100_s{scene_index}'`, `parent_chunk_id='ch0100_summary'`
-- `source_file`:
-  - summary: `summaries/ch0100.md`
-  - scene: `{chapter_file}#scene_{scene_index}`
-
-### Step H: 风格样本评估
+父子索引规则:
+- 父块:`chunk_type='summary'`,`chunk_id='ch0100_summary'`
+- 子块:`chunk_type='scene'`,`chunk_id='ch0100_s{scene_index}'`
 
-```python
-if review_score >= 80:
-    extract_style_candidates(chapter_content)
-```
+风格样本提取仅在 `review_score >= 80` 时执行:
 
 ```bash
 python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "{project_root}" style extract --chapter 100 --score 85 --scenes '[...]'
 ```
 
-### Step I: 债务利息计算
-
-**默认不自动触发**。仅在“开启债务追踪”或用户明确要求时执行:
- ```bash
- python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "{project_root}" index accrue-interest --current-chapter {chapter}
- ```
-
-此步骤会:
-- 对所有 `status='active'` 的债务计算利息(每章 10%)
-- 将逾期债务标记为 `status='overdue'`
-- 记录利息事件到 `debt_events` 表
-
-### Step J: 生成处理报告(含性能日志)
-
-**必须记录分步耗时**(用于定位慢点):
-- A 加载上下文
-- B AI 实体提取
-- C 实体消歧
-- D 写入 state/index
-- E 写入章节摘要
-- F AI 场景切片
-- G RAG 向量索引
-- H 风格样本评估(若跳过写 0)
-- I 债务利息(若跳过写 0)
-- TOTAL 总耗时
-
-**性能日志落盘(新增,必做)**:
-- 脚本自动写入:`.webnovel/observability/data_agent_timing.jsonl`
-- Data Agent 报告中仍需返回:`timing_ms` + `bottlenecks_top3`
-- 规则:`bottlenecks_top3` 始终按耗时降序返回;当 `TOTAL > 30000ms` 时,需在报告文字部分附加原因说明。
-
-观测日志说明:
-- `call_trace.jsonl`:外层流程调用链(agent 启动、排队、环境探测等系统开销)。
-- `data_agent_timing.jsonl`:Data Agent 内部各子步骤耗时。
-- 当外层总耗时远大于内层 timing 之和时,默认先归因为 agent 启动与环境探测开销,不误判为正文或数据处理慢。
+### Step 9:按需计算债务利息
 
-```json
-{
-  "chapter": 100,
-  "entities_appeared": 5,
-  "entities_new": 1,
-  "state_changes": 1,
-  "relationships_new": 1,
-  "scenes_chunked": 4,
-  "uncertain": [
-    {"mention": "那位前辈", "candidates": [{"type": "角色", "id": "yaolao"}, {"type": "角色", "id": "elder_zhang"}], "adopted": "yaolao", "confidence": 0.6}
-  ],
-  "warnings": [
-    "中置信度匹配: 那位前辈 → yaolao (confidence: 0.6)"
-  ],
-  "errors": [],
-  "timing_ms": {
-    "A_load_context": 120,
-    "B_entity_extract": 18500,
-    "C_disambiguation": 210,
-    "D_state_index_write": 430,
-    "E_summary_write": 90,
-    "F_scene_chunking": 6200,
-    "G_rag_index": 2800,
-    "H_style_sample": 150,
-    "I_debt_interest": 0,
-    "TOTAL": 28500
-  },
-  "bottlenecks_top3": [
-    {"step": "B_entity_extract", "elapsed_ms": 18500, "ratio": 64.9},
-    {"step": "F_scene_chunking", "elapsed_ms": 6200, "ratio": 21.8},
-    {"step": "G_rag_index", "elapsed_ms": 2800, "ratio": 9.8}
-  ]
-}
+默认不自动触发,仅在用户明确要求或已开启债务追踪时执行:
+
+```bash
+python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "{project_root}" index accrue-interest --current-chapter {chapter}
 ```
 
----
+### Step 10:生成处理报告与观测日志
 
-## 接口规范:chapter_meta (state.json)
+必须记录分步耗时:
+- Step 2:加载正文与索引上下文
+- Step 3:实体提取与消歧
+- Step 4:写入实体与状态
+- Step 5:写入章节摘要
+- Step 6:长期记忆提取
+- Step 7:场景切片
+- Step 8:向量与风格样本
+- Step 9:债务利息
+- TOTAL:总耗时
+
+观测规则:
+- 脚本自动写入 `.webnovel/observability/data_agent_timing.jsonl`
+- 返回结果中仍需包含 `timing_ms` 与 `bottlenecks_top3`
+- `bottlenecks_top3` 必须按耗时降序
+- `TOTAL > 30000ms` 时,必须附加原因说明
+
+## 接口规范:chapter_meta
 
 ```json
 {
@@ -285,15 +260,13 @@ python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "{project_root}" styl
 }
 ```
 
----
-
 ## 成功标准
 
-1. ✅ 所有出场实体被正确识别(准确率 > 90%)
-2. ✅ 状态变化被正确捕获(准确率 > 85%)
-3. ✅ 消歧结果合理(高置信度 > 80%)
-4. ✅ 场景切片数量合理(通常 3-6 个/章)
-5. ✅ 向量成功存入数据库
-6. ✅ 章节摘要文件生成成功
-7. ✅ chapter_meta 写入 state.json
-8. ✅ 输出格式为有效 JSON
+1. 出场实体识别完整且消歧结果合理。
+2. 状态变化、关系变化已正确落库。
+3. `state.json` 与 `chapter_meta` 已更新。
+4. `.webnovel/summaries/ch{NNNN}.md` 已生成。
+5. `memory_facts` 已产出并写入 `.webnovel/memory_scratchpad.json`。
+6. 场景切片与向量索引成功写入。
+7. `review_score >= 80` 时已按规则提取风格样本。
+8. 观测日志已写入,输出为有效 JSON。

+ 8 - 1
webnovel-writer/scripts/data_modules/__init__.py

@@ -51,6 +51,10 @@ __all__ = [
     "StyleSampler",
     "StyleSample",
     "SceneType",
+    # Memory
+    "ScratchpadManager",
+    "MemoryWriter",
+    "MemoryOrchestrator",
 ]
 
 
@@ -87,6 +91,10 @@ _LAZY_EXPORTS: dict[str, tuple[str, str]] = {
     "StyleSampler": (".style_sampler", "StyleSampler"),
     "StyleSample": (".style_sampler", "StyleSample"),
     "SceneType": (".style_sampler", "SceneType"),
+    # Memory
+    "ScratchpadManager": (".memory.store", "ScratchpadManager"),
+    "MemoryWriter": (".memory.writer", "MemoryWriter"),
+    "MemoryOrchestrator": (".memory.orchestrator", "MemoryOrchestrator"),
 }
 
 
@@ -103,4 +111,3 @@ def __getattr__(name: str) -> Any:  # pragma: no cover
 
 def __dir__() -> list[str]:  # pragma: no cover
     return sorted(set(list(globals().keys()) + list(_LAZY_EXPORTS.keys())))
-

+ 10 - 0
webnovel-writer/scripts/data_modules/config.py

@@ -102,6 +102,10 @@ class DataModulesConfig:
     def state_file(self) -> Path:
         return self.webnovel_dir / "state.json"
 
+    @property
+    def scratchpad_file(self) -> Path:
+        return self.webnovel_dir / "memory_scratchpad.json"
+
     @property
     def index_db(self) -> Path:
         return self.webnovel_dir / "index.db"
@@ -256,6 +260,12 @@ class DataModulesConfig:
         ",",
         "、",
     )
+    context_use_memory_orchestrator: bool = False
+    memory_orchestrator_max_items: int = 30
+    memory_orchestrator_recent_changes_limit: int = 10
+    memory_orchestrator_source_window: int = 20
+    memory_compactor_enabled: bool = True
+    memory_compactor_threshold: int = 500
 
     export_recent_changes_slice: int = 20
     export_disambiguation_slice: int = 20

+ 30 - 0
webnovel-writer/scripts/data_modules/context_manager.py

@@ -54,6 +54,7 @@ class ContextManager:
     EXTRA_SECTIONS = {
         "story_skeleton",
         "memory",
+        "long_term_memory",
         "preferences",
         "alerts",
         "reader_signal",
@@ -69,6 +70,7 @@ class ContextManager:
         "writing_guidance",
         "story_skeleton",
         "memory",
+        "long_term_memory",
         "preferences",
         "alerts",
     ]
@@ -188,6 +190,17 @@ class ContextManager:
 
     def _build_pack(self, chapter: int) -> Dict[str, Any]:
         state = self._load_state()
+        use_orchestrator = bool(getattr(self.config, "context_use_memory_orchestrator", False))
+
+        orchestrator_pack: Dict[str, Any] = {}
+        try:
+            from .memory.orchestrator import MemoryOrchestrator
+
+            orchestrator = MemoryOrchestrator(self.config)
+            orchestrator_pack = orchestrator.build_memory_pack(chapter)
+        except Exception as exc:
+            logger.warning("memory_orchestrator_failed: %s", exc)
+
         core = {
             "chapter_outline": self._load_outline(chapter),
             "protagonist_snapshot": state.get("protagonist_state", {}),
@@ -201,6 +214,21 @@ class ContextManager:
                 window=self.config.context_recent_meta_window,
             ),
         }
+        if use_orchestrator and orchestrator_pack:
+            working_items = list(orchestrator_pack.get("working_memory") or [])
+            outline_item = next((x for x in working_items if x.get("source") == "outline"), None)
+            state_item = next((x for x in working_items if x.get("source") == "state_export"), None)
+            summary_items = [
+                {"chapter": x.get("chapter"), "summary": x.get("content")}
+                for x in working_items
+                if x.get("source") == "summary"
+            ]
+            core["chapter_outline"] = str(outline_item.get("content", "")) if outline_item else core["chapter_outline"]
+            if isinstance(state_item, dict) and isinstance(state_item.get("content"), dict):
+                state_export = dict(state_item.get("content") or {})
+                core["protagonist_snapshot"] = state_export.get("protagonist_state", core["protagonist_snapshot"])
+            if summary_items:
+                core["recent_summaries"] = summary_items
 
         scene = {
             "location_context": state.get("protagonist_state", {}).get("location", {}),
@@ -220,6 +248,7 @@ class ContextManager:
 
         preferences = self._load_json_optional(self.config.webnovel_dir / "preferences.json")
         memory = self._load_json_optional(self.config.webnovel_dir / "project_memory.json")
+        long_term_memory: Dict[str, Any] = orchestrator_pack if orchestrator_pack else {}
         story_skeleton = self._load_story_skeleton(chapter)
         alert_slice = max(0, int(self.config.context_alerts_slice))
         reader_signal = self._load_reader_signal(chapter)
@@ -237,6 +266,7 @@ class ContextManager:
             "story_skeleton": story_skeleton,
             "preferences": preferences,
             "memory": memory,
+            "long_term_memory": long_term_memory,
             "alerts": {
                 "disambiguation_warnings": (
                     state.get("disambiguation_warnings", [])[-alert_slice:] if alert_slice else []

+ 28 - 0
webnovel-writer/scripts/data_modules/memory/__init__.py

@@ -0,0 +1,28 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+长期记忆子系统。
+"""
+
+from .schema import (
+    BUCKET_TO_CATEGORY,
+    CATEGORY_KEY_RULES,
+    CATEGORY_TO_BUCKET,
+    MemoryItem,
+    ScratchpadData,
+)
+from .store import ScratchpadManager
+from .writer import MemoryWriter
+from .orchestrator import MemoryOrchestrator
+
+__all__ = [
+    "MemoryItem",
+    "ScratchpadData",
+    "CATEGORY_TO_BUCKET",
+    "BUCKET_TO_CATEGORY",
+    "CATEGORY_KEY_RULES",
+    "ScratchpadManager",
+    "MemoryWriter",
+    "MemoryOrchestrator",
+]
+

+ 182 - 0
webnovel-writer/scripts/data_modules/memory/bootstrap.py

@@ -0,0 +1,182 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+从 index.db 回填初始长期记忆。
+"""
+from __future__ import annotations
+
+import re
+from typing import Any, Dict
+
+from ..config import DataModulesConfig, get_config
+from ..index_manager import IndexManager
+from .schema import MemoryItem
+from .store import ScratchpadManager
+
+
+FORESHADOWING_SECTION_RE = re.compile(r"##\s*伏笔\s*\r?\n(.*?)(?=\r?\n##|\Z)", re.DOTALL)
+FORESHADOWING_BULLET_RE = re.compile(r"^\s*[-*]\s+(.+?)\s*$", re.MULTILINE)
+
+
+def _extract_chapter_from_name(name: str) -> int:
+    m = re.search(r"ch(\d{1,6})", name, re.IGNORECASE)
+    if m:
+        return int(m.group(1))
+    m = re.search(r"第\s*(\d+)\s*章", name)
+    if m:
+        return int(m.group(1))
+    return 0
+
+
+def _extract_open_loops(summary_text: str) -> list[str]:
+    if not summary_text:
+        return []
+    section_match = FORESHADOWING_SECTION_RE.search(summary_text)
+    if not section_match:
+        return []
+    block = section_match.group(1).strip()
+    if not block:
+        return []
+    loops = []
+    for m in FORESHADOWING_BULLET_RE.finditer(block):
+        text = str(m.group(1) or "").strip()
+        if text:
+            loops.append(text)
+    return loops
+
+
+def bootstrap_from_index(config: DataModulesConfig | None = None) -> Dict[str, Any]:
+    cfg = config or get_config()
+    idx = IndexManager(cfg)
+    store = ScratchpadManager(cfg)
+
+    created = 0
+    by_category: Dict[str, int] = {}
+
+    for entity in idx.get_entities_by_type("角色", include_archived=True):
+        entity_id = str(entity.get("id", "") or "").strip()
+        current = entity.get("current_json") or {}
+        if not entity_id or not isinstance(current, dict):
+            continue
+        for field, value in current.items():
+            item = MemoryItem(
+                id=f"bootstrap-character_state-{entity_id}-{field}",
+                layer="semantic",
+                category="character_state",
+                subject=entity_id,
+                field=str(field),
+                value=str(value),
+                payload={},
+                source_chapter=int(entity.get("last_appearance") or 0),
+                evidence=["bootstrap:index_entities"],
+            )
+            store.upsert_item(item)
+            created += 1
+            by_category["character_state"] = by_category.get("character_state", 0) + 1
+
+    # 回填状态变化:最新值 active,历史值 outdated。
+    changes = idx.get_recent_state_changes(limit=5000)
+    changes_sorted = sorted(
+        changes,
+        key=lambda x: (int(x.get("chapter") or 0), int(x.get("id") or 0)),
+    )
+    latest_by_key: Dict[tuple[str, str], Dict[str, Any]] = {}
+    history_rows: list[Dict[str, Any]] = []
+    for row in changes_sorted:
+        entity_id = str(row.get("entity_id", "") or "").strip()
+        field = str(row.get("field", "") or "").strip()
+        if not entity_id or not field:
+            continue
+        key = (entity_id, field)
+        if key in latest_by_key:
+            history_rows.append(latest_by_key[key])
+        latest_by_key[key] = row
+
+    for row in history_rows:
+        entity_id = str(row.get("entity_id", "") or "").strip()
+        field = str(row.get("field", "") or "").strip()
+        ch = int(row.get("chapter") or 0)
+        val = str(row.get("new_value", "") or "")
+        if not entity_id or not field or not val:
+            continue
+        item = MemoryItem(
+            id=f"bootstrap-state-{entity_id}-{field}-{ch}",
+            layer="semantic",
+            category="character_state",
+            subject=entity_id,
+            field=field,
+            value=val,
+            payload={"old_value": str(row.get("old_value", "") or ""), "reason": str(row.get("reason", "") or "")},
+            status="outdated",
+            source_chapter=ch,
+            evidence=["bootstrap:index_state_changes"],
+        )
+        store.upsert_item(item)
+        created += 1
+        by_category["character_state"] = by_category.get("character_state", 0) + 1
+
+    for (entity_id, field), row in latest_by_key.items():
+        ch = int(row.get("chapter") or 0)
+        val = str(row.get("new_value", "") or "")
+        if not val:
+            continue
+        item = MemoryItem(
+            id=f"bootstrap-state-latest-{entity_id}-{field}",
+            layer="semantic",
+            category="character_state",
+            subject=entity_id,
+            field=field,
+            value=val,
+            payload={"old_value": str(row.get("old_value", "") or ""), "reason": str(row.get("reason", "") or "")},
+            status="active",
+            source_chapter=ch,
+            evidence=["bootstrap:index_state_changes_latest"],
+        )
+        store.upsert_item(item)
+        created += 1
+        by_category["character_state"] = by_category.get("character_state", 0) + 1
+
+    for rel in idx.get_recent_relationships(limit=500):
+        from_entity = str(rel.get("from_entity", "") or "").strip()
+        to_entity = str(rel.get("to_entity", "") or "").strip()
+        if not from_entity or not to_entity:
+            continue
+        item = MemoryItem(
+            id=f"bootstrap-relationship-{from_entity}-{to_entity}",
+            layer="semantic",
+            category="relationship",
+            subject=from_entity,
+            field=to_entity,
+            value=str(rel.get("type", "") or ""),
+            payload={"description": rel.get("description", "")},
+            source_chapter=int(rel.get("chapter") or 0),
+            evidence=["bootstrap:index_relationships"],
+        )
+        store.upsert_item(item)
+        created += 1
+        by_category["relationship"] = by_category.get("relationship", 0) + 1
+
+    # 从 summaries 中抽取“伏笔”区块回填 open_loop。
+    summaries_dir = cfg.webnovel_dir / "summaries"
+    if summaries_dir.exists():
+        for path in sorted(summaries_dir.glob("*.md")):
+            text = path.read_text(encoding="utf-8")
+            chapter = _extract_chapter_from_name(path.stem)
+            for idx_loop, loop in enumerate(_extract_open_loops(text), start=1):
+                item = MemoryItem(
+                    id=f"bootstrap-open-loop-{chapter}-{idx_loop}",
+                    layer="semantic",
+                    category="open_loop",
+                    subject=loop[:64],
+                    field="status",
+                    value=loop,
+                    payload={"planted_chapter": chapter, "urgency": 50, "status": "active"},
+                    status="active",
+                    source_chapter=chapter,
+                    evidence=["bootstrap:summaries_foreshadowing"],
+                )
+                store.upsert_item(item)
+                created += 1
+                by_category["open_loop"] = by_category.get("open_loop", 0) + 1
+
+    return {"items_created": created, "categories": by_category}

+ 52 - 0
webnovel-writer/scripts/data_modules/memory/budget.py

@@ -0,0 +1,52 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+长期记忆预算分配。
+"""
+from __future__ import annotations
+
+from typing import Dict
+
+
+DEFAULT_BUDGET = {
+    "write": {"max_items": 30, "working_ratio": 0.45, "episodic_ratio": 0.30, "semantic_ratio": 0.25},
+    "review": {"max_items": 40, "working_ratio": 0.35, "episodic_ratio": 0.35, "semantic_ratio": 0.30},
+    "query": {"max_items": 25, "working_ratio": 0.30, "episodic_ratio": 0.45, "semantic_ratio": 0.25},
+}
+
+
+def get_budget(task_type: str = "write") -> Dict[str, float]:
+    key = str(task_type or "write").lower()
+    return dict(DEFAULT_BUDGET.get(key, DEFAULT_BUDGET["write"]))
+
+
+def allocate_limits(max_items: int, task_type: str = "write") -> Dict[str, int]:
+    """按任务类型分配 working/episodic/semantic 的条目预算。"""
+    max_items = max(1, int(max_items or 1))
+    budget = get_budget(task_type)
+    wr = float(budget.get("working_ratio", 0.45))
+    er = float(budget.get("episodic_ratio", 0.30))
+    sr = float(budget.get("semantic_ratio", 0.25))
+
+    total_ratio = wr + er + sr
+    if total_ratio <= 0:
+        wr, er, sr = 0.45, 0.30, 0.25
+        total_ratio = 1.0
+    wr, er, sr = wr / total_ratio, er / total_ratio, sr / total_ratio
+
+    w = int(max_items * wr)
+    e = int(max_items * er)
+    s = int(max_items * sr)
+    used = w + e + s
+
+    # 把余数按语义层优先分配,保证总和等于 max_items。
+    while used < max_items:
+        if s <= w:
+            s += 1
+        elif w <= e:
+            w += 1
+        else:
+            e += 1
+        used += 1
+
+    return {"working": w, "episodic": e, "semantic": s}

+ 111 - 0
webnovel-writer/scripts/data_modules/memory/compactor.py

@@ -0,0 +1,111 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+scratchpad 压缩器。
+"""
+from __future__ import annotations
+
+from typing import Dict, List, Tuple
+
+from .schema import CATEGORY_KEY_RULES, CATEGORY_TO_BUCKET, MemoryItem, ScratchpadData, now_iso
+
+
+def _key_for(item: MemoryItem) -> Tuple:
+    fields = CATEGORY_KEY_RULES.get(item.category)
+    if not fields:
+        return (item.id,)
+    return tuple(getattr(item, f, None) for f in fields)
+
+
+def _is_resolved_open_loop(item: MemoryItem) -> bool:
+    if item.category != "open_loop":
+        return False
+    state = str((item.payload or {}).get("status", "") or "").strip().lower()
+    return state in {"resolved", "closed", "done", "paid_off", "payoff"}
+
+
+def compact_scratchpad(data: ScratchpadData, max_items: int = 500) -> ScratchpadData:
+    if data.count_items() <= max_items:
+        return data
+
+    # 1) 同 key 的 outdated 只保留最新,避免历史膨胀。
+    for bucket in CATEGORY_TO_BUCKET.values():
+        rows: List[MemoryItem] = list(getattr(data, bucket))
+        latest_outdated: Dict[Tuple, MemoryItem] = {}
+        keep: List[MemoryItem] = []
+        for row in rows:
+            if row.status != "outdated":
+                keep.append(row)
+                continue
+            key = _key_for(row)
+            prev = latest_outdated.get(key)
+            if prev is None or (row.updated_at or "") >= (prev.updated_at or ""):
+                latest_outdated[key] = row
+        keep.extend(latest_outdated.values())
+        setattr(data, bucket, keep)
+
+    # 2) 清理已回收伏笔。
+    data.open_loops = [row for row in data.open_loops if not _is_resolved_open_loop(row)]
+
+    # 3) 压缩过旧 timeline(与当前最新章节相距 50 章以上)。
+    timeline = sorted(data.timeline, key=lambda x: x.source_chapter)
+    if timeline:
+        latest_chapter = max(x.source_chapter for x in timeline)
+        old = [x for x in timeline if (latest_chapter - x.source_chapter) > 50]
+        fresh = [x for x in timeline if (latest_chapter - x.source_chapter) <= 50]
+        if len(old) > 1:
+            samples = []
+            for row in old[:8]:
+                label = row.value or row.subject or row.field or row.id
+                if label:
+                    samples.append(str(label))
+            summary_text = ";".join(samples) if samples else "早期关键事件"
+            summary_item = MemoryItem(
+                id=f"timeline-summary-upto-{old[-1].source_chapter}",
+                layer="semantic",
+                category="story_fact",
+                subject="timeline_summary",
+                field=f"<=ch{old[-1].source_chapter}",
+                value=f"早期事件摘要:{summary_text}",
+                payload={
+                    "from_chapter": old[0].source_chapter,
+                    "to_chapter": old[-1].source_chapter,
+                    "items_merged": len(old),
+                },
+                status="active",
+                source_chapter=old[-1].source_chapter,
+                evidence=["compactor:timeline"],
+                updated_at=now_iso(),
+            )
+            replaced = False
+            for i, row in enumerate(list(data.story_facts)):
+                if row.subject == summary_item.subject and row.field == summary_item.field:
+                    data.story_facts[i] = summary_item
+                    replaced = True
+                    break
+            if not replaced:
+                data.story_facts.append(summary_item)
+        data.timeline = fresh
+
+    # 4) 若仍超限,按状态和新鲜度做全局截断,保证总量不超过 max_items。
+    if data.count_items() > max_items:
+        ranked: List[Tuple[str, MemoryItem]] = []
+        for bucket in CATEGORY_TO_BUCKET.values():
+            for row in list(getattr(data, bucket)):
+                ranked.append((bucket, row))
+
+        ranked.sort(
+            key=lambda item: (
+                0 if item[1].status == "active" else 1,
+                -int(item[1].source_chapter or 0),
+                item[1].updated_at or "",
+            )
+        )
+        keep = ranked[:max_items]
+        kept_ids = {item.id for _, item in keep}
+        for bucket in CATEGORY_TO_BUCKET.values():
+            rows = [row for row in list(getattr(data, bucket)) if row.id in kept_ids]
+            setattr(data, bucket, rows)
+
+    data.meta = {**dict(data.meta or {}), "last_updated": now_iso(), "total_items": data.count_items()}
+    return data

+ 217 - 0
webnovel-writer/scripts/data_modules/memory/orchestrator.py

@@ -0,0 +1,217 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+长期记忆编排器。
+"""
+from __future__ import annotations
+
+import json
+from typing import Any, Dict, List
+
+from ..config import DataModulesConfig, get_config
+from ..index_manager import IndexManager
+from .schema import MemoryItem
+from .store import ScratchpadManager
+from .budget import allocate_limits
+
+try:
+    from chapter_outline_loader import load_chapter_outline
+except ImportError:  # pragma: no cover
+    from scripts.chapter_outline_loader import load_chapter_outline
+
+
+class MemoryOrchestrator:
+    PRIORITY = {
+        "world_rule": 0,
+        "character_state": 1,
+        "relationship": 2,
+        "story_fact": 3,
+        "open_loop": 4,
+        "reader_promise": 5,
+        "timeline": 6,
+    }
+
+    def __init__(self, config: DataModulesConfig | None = None):
+        self.config = config or get_config()
+        self.index_manager = IndexManager(self.config)
+        self.store = ScratchpadManager(self.config)
+
+    def build_memory_pack(self, chapter: int, task_type: str = "write") -> Dict[str, Any]:
+        outline = load_chapter_outline(self.config.project_root, chapter, max_chars=1500)
+
+        working = self._build_working_memory(chapter=chapter, outline=outline)
+        episodic = self._build_episodic_memory(chapter=chapter)
+        active_items = self.store.query(status="active")
+        conflicts = self.store.conflicts()
+        filtered = self._filter_relevant(active_items, chapter=chapter, outline=outline)
+
+        max_items = max(1, int(getattr(self.config, "memory_orchestrator_max_items", 30)))
+        limits = allocate_limits(max_items=max_items, task_type=task_type)
+        semantic_items = self._apply_budget(filtered, max_items=limits["semantic"])
+        working_items = working[: limits["working"]]
+        episodic_items = episodic[: limits["episodic"]]
+        semantic_payload = [item.to_dict() for item in semantic_items]
+
+        recent_changes = self.index_manager.get_recent_state_changes(
+            limit=max(1, int(getattr(self.config, "memory_orchestrator_recent_changes_limit", 10)))
+        )
+
+        active_constraints = [
+            item.to_dict()
+            for item in semantic_items
+            if item.category in {"world_rule", "open_loop"}
+        ]
+        warnings = []
+        if conflicts:
+            warnings.append(
+                {
+                    "type": "memory_conflict",
+                    "count": len(conflicts),
+                    "sample": conflicts[:5],
+                }
+            )
+
+        return {
+            "working_memory": working_items,
+            "episodic_memory": episodic_items,
+            "semantic_memory": semantic_payload,
+            # long_term_facts 保持对外 contract:仅包含可直接注入的长期语义事实。
+            "long_term_facts": semantic_payload,
+            "active_constraints": active_constraints,
+            "recent_changes": list(recent_changes),
+            "warnings": warnings,
+            "stats": {
+                "total": len(active_items),
+                "working_total": len(working),
+                "episodic_total": len(episodic),
+                "semantic_total": len(filtered),
+                "injected": len(semantic_payload),
+                "layered_total_injected": len(working_items) + len(episodic_items) + len(semantic_payload),
+                "filtered": max(0, len(active_items) - len(filtered)),
+                "conflicts": len(conflicts),
+            },
+        }
+
+    def _filter_relevant(self, items: List[MemoryItem], chapter: int, outline: str) -> List[MemoryItem]:
+        if not items:
+            return []
+        if not outline:
+            return sorted(items, key=lambda x: (x.source_chapter, x.updated_at), reverse=True)
+
+        keep: List[MemoryItem] = []
+        source_window = max(1, int(getattr(self.config, "memory_orchestrator_source_window", 20)))
+        for item in items:
+            if item.subject and item.subject in outline:
+                keep.append(item)
+                continue
+            if item.field and item.field in outline:
+                keep.append(item)
+                continue
+            if item.value and item.value[:20] in outline:
+                keep.append(item)
+                continue
+            if item.source_chapter > 0 and chapter - item.source_chapter <= source_window:
+                keep.append(item)
+
+        return sorted(keep, key=lambda x: (self.PRIORITY.get(x.category, 99), -x.source_chapter))
+
+    def _apply_budget(self, items: List[MemoryItem], max_items: int) -> List[MemoryItem]:
+        if max_items <= 0:
+            return []
+        if len(items) <= max_items:
+            return list(items)
+        return list(items[:max_items])
+
+    def _load_state(self) -> Dict[str, Any]:
+        path = self.config.state_file
+        if not path.exists():
+            return {}
+        try:
+            return json.loads(path.read_text(encoding="utf-8"))
+        except Exception:
+            return {}
+
+    def _load_recent_summaries(self, chapter: int, window: int) -> List[Dict[str, Any]]:
+        result: List[Dict[str, Any]] = []
+        summary_dir = self.config.webnovel_dir / "summaries"
+        if not summary_dir.exists():
+            return result
+        for ch in range(max(1, chapter - window), chapter):
+            path = summary_dir / f"ch{ch:04d}.md"
+            if not path.exists():
+                continue
+            text = path.read_text(encoding="utf-8")
+            if text:
+                result.append({"layer": "working", "source": "summary", "chapter": ch, "content": text[:800]})
+        return result
+
+    def _build_working_memory(self, chapter: int, outline: str) -> List[Dict[str, Any]]:
+        state = self._load_state()
+        result: List[Dict[str, Any]] = []
+        if outline:
+            result.append({"layer": "working", "source": "outline", "chapter": chapter, "content": outline})
+
+        summary_window = max(1, int(getattr(self.config, "context_recent_summaries_window", 3)))
+        result.extend(self._load_recent_summaries(chapter=chapter, window=summary_window))
+
+        state_export = {
+            "protagonist_state": state.get("protagonist_state", {}),
+            "plot_threads": state.get("plot_threads", {}),
+            "disambiguation_pending": state.get("disambiguation_pending", []),
+        }
+        result.append(
+            {
+                "layer": "working",
+                "source": "state_export",
+                "chapter": chapter,
+                "content": state_export,
+            }
+        )
+        return result
+
+    def _build_episodic_memory(self, chapter: int) -> List[Dict[str, Any]]:
+        _ = chapter
+        changes_limit = max(1, int(getattr(self.config, "memory_orchestrator_recent_changes_limit", 10)))
+        rel_limit = max(1, min(20, changes_limit))
+
+        recent_changes = self.index_manager.get_recent_state_changes(limit=changes_limit)
+        recent_relationships = self.index_manager.get_recent_relationships(limit=rel_limit)
+        recent_appearances = self.index_manager.get_recent_appearances(limit=rel_limit)
+
+        result: List[Dict[str, Any]] = []
+        for row in recent_changes:
+            result.append(
+                {
+                    "layer": "episodic",
+                    "source": "state_change",
+                    "chapter": int(row.get("chapter") or 0),
+                    "entity_id": row.get("entity_id", ""),
+                    "field": row.get("field", ""),
+                    "content": row,
+                }
+            )
+        for row in recent_relationships:
+            result.append(
+                {
+                    "layer": "episodic",
+                    "source": "relationship",
+                    "chapter": int(row.get("chapter") or 0),
+                    "entity_id": row.get("from_entity", ""),
+                    "field": row.get("to_entity", ""),
+                    "content": row,
+                }
+            )
+        for row in recent_appearances:
+            result.append(
+                {
+                    "layer": "episodic",
+                    "source": "appearance",
+                    "chapter": int(row.get("chapter") or 0),
+                    "entity_id": row.get("entity_id", ""),
+                    "field": "appearance",
+                    "content": row,
+                }
+            )
+
+        result.sort(key=lambda x: int(x.get("chapter") or 0), reverse=True)
+        return result

+ 152 - 0
webnovel-writer/scripts/data_modules/memory/schema.py

@@ -0,0 +1,152 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+长期记忆 schema 定义。
+"""
+from __future__ import annotations
+
+from dataclasses import asdict, dataclass, field
+from datetime import datetime
+from typing import Any, Dict, List
+
+
+VALID_LAYERS = {"semantic", "episodic"}
+VALID_STATUSES = {"active", "outdated", "contradicted", "tentative"}
+
+CATEGORY_TO_BUCKET: Dict[str, str] = {
+    "character_state": "character_state",
+    "story_fact": "story_facts",
+    "world_rule": "world_rules",
+    "timeline": "timeline",
+    "open_loop": "open_loops",
+    "reader_promise": "reader_promises",
+    "relationship": "relationships",
+}
+BUCKET_TO_CATEGORY: Dict[str, str] = {v: k for k, v in CATEGORY_TO_BUCKET.items()}
+
+CATEGORY_KEY_RULES: Dict[str, tuple[str, ...]] = {
+    "character_state": ("subject", "field"),
+    "relationship": ("subject", "field"),
+    "world_rule": ("subject", "field"),
+    "story_fact": ("subject", "field"),
+    "timeline": ("subject", "source_chapter"),
+    "open_loop": ("subject",),
+    "reader_promise": ("subject",),
+}
+
+
+def now_iso() -> str:
+    return datetime.now().isoformat(timespec="seconds")
+
+
+@dataclass
+class MemoryItem:
+    id: str
+    layer: str
+    category: str
+    subject: str
+    field: str
+    value: str
+    payload: Dict[str, Any] = field(default_factory=dict)
+    status: str = "active"
+    source_chapter: int = 0
+    evidence: List[str] = field(default_factory=list)
+    updated_at: str = ""
+
+    def normalized(self) -> "MemoryItem":
+        layer = self.layer if self.layer in VALID_LAYERS else "semantic"
+        category = self.category if self.category in CATEGORY_TO_BUCKET else "story_fact"
+        status = self.status if self.status in VALID_STATUSES else "active"
+        updated_at = self.updated_at or now_iso()
+        return MemoryItem(
+            id=str(self.id or ""),
+            layer=layer,
+            category=category,
+            subject=str(self.subject or ""),
+            field=str(self.field or ""),
+            value=str(self.value or ""),
+            payload=dict(self.payload or {}),
+            status=status,
+            source_chapter=int(self.source_chapter or 0),
+            evidence=[str(x) for x in (self.evidence or []) if str(x)],
+            updated_at=updated_at,
+        )
+
+    def to_dict(self) -> Dict[str, Any]:
+        return asdict(self.normalized())
+
+    @classmethod
+    def from_dict(cls, payload: Dict[str, Any]) -> "MemoryItem":
+        return cls(
+            id=str(payload.get("id", "")),
+            layer=str(payload.get("layer", "semantic")),
+            category=str(payload.get("category", "story_fact")),
+            subject=str(payload.get("subject", "")),
+            field=str(payload.get("field", "")),
+            value=str(payload.get("value", "")),
+            payload=dict(payload.get("payload") or {}),
+            status=str(payload.get("status", "active")),
+            source_chapter=int(payload.get("source_chapter", 0) or 0),
+            evidence=[str(x) for x in (payload.get("evidence") or []) if str(x)],
+            updated_at=str(payload.get("updated_at", "")),
+        ).normalized()
+
+
+@dataclass
+class ScratchpadData:
+    character_state: List[MemoryItem] = field(default_factory=list)
+    story_facts: List[MemoryItem] = field(default_factory=list)
+    world_rules: List[MemoryItem] = field(default_factory=list)
+    timeline: List[MemoryItem] = field(default_factory=list)
+    open_loops: List[MemoryItem] = field(default_factory=list)
+    reader_promises: List[MemoryItem] = field(default_factory=list)
+    relationships: List[MemoryItem] = field(default_factory=list)
+    meta: Dict[str, Any] = field(
+        default_factory=lambda: {"version": 1, "last_updated": "", "total_items": 0}
+    )
+
+    @classmethod
+    def empty(cls) -> "ScratchpadData":
+        return cls()
+
+    @classmethod
+    def from_dict(cls, payload: Dict[str, Any]) -> "ScratchpadData":
+        def _items(bucket: str) -> List[MemoryItem]:
+            rows = payload.get(bucket, [])
+            if not isinstance(rows, list):
+                return []
+            return [MemoryItem.from_dict(row) for row in rows if isinstance(row, dict)]
+
+        data = cls(
+            character_state=_items("character_state"),
+            story_facts=_items("story_facts"),
+            world_rules=_items("world_rules"),
+            timeline=_items("timeline"),
+            open_loops=_items("open_loops"),
+            reader_promises=_items("reader_promises"),
+            relationships=_items("relationships"),
+            meta=dict(payload.get("meta") or {}),
+        )
+        data.meta.setdefault("version", 1)
+        data.meta.setdefault("last_updated", "")
+        data.meta.setdefault("total_items", 0)
+        data.meta["total_items"] = data.count_items()
+        return data
+
+    def count_items(self) -> int:
+        return sum(
+            len(getattr(self, bucket))
+            for bucket in BUCKET_TO_CATEGORY
+        )
+
+    def to_dict(self) -> Dict[str, Any]:
+        result: Dict[str, Any] = {}
+        for bucket in BUCKET_TO_CATEGORY:
+            result[bucket] = [item.to_dict() for item in getattr(self, bucket)]
+        meta = dict(self.meta or {})
+        meta["version"] = int(meta.get("version", 1) or 1)
+        meta["last_updated"] = meta.get("last_updated") or now_iso()
+        meta["total_items"] = self.count_items()
+        result["meta"] = meta
+        return result
+

+ 248 - 0
webnovel-writer/scripts/data_modules/memory/store.py

@@ -0,0 +1,248 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+scratchpad 持久化与查询。
+"""
+from __future__ import annotations
+
+from dataclasses import asdict
+from pathlib import Path
+from typing import Any, Dict, List, Optional
+
+from ..config import DataModulesConfig, get_config
+from ..cli_output import print_error, print_success
+from ..cli_args import normalize_global_project_root, load_json_arg
+
+from .schema import (
+    BUCKET_TO_CATEGORY,
+    CATEGORY_KEY_RULES,
+    CATEGORY_TO_BUCKET,
+    MemoryItem,
+    ScratchpadData,
+    now_iso,
+)
+
+try:
+    from security_utils import atomic_write_json, read_json_safe
+except ImportError:  # pragma: no cover
+    from scripts.security_utils import atomic_write_json, read_json_safe
+
+
+class ScratchpadManager:
+    def __init__(self, config: DataModulesConfig | None = None):
+        self.config = config or get_config()
+        self.path = Path(self.config.scratchpad_file)
+
+    def load(self) -> ScratchpadData:
+        if not self.path.exists():
+            return ScratchpadData.empty()
+        payload = read_json_safe(self.path, default={})
+        if not isinstance(payload, dict):
+            return ScratchpadData.empty()
+        return ScratchpadData.from_dict(payload)
+
+    def save(self, data: ScratchpadData) -> None:
+        self.config.ensure_dirs()
+        if bool(getattr(self.config, "memory_compactor_enabled", True)):
+            threshold = max(1, int(getattr(self.config, "memory_compactor_threshold", 500)))
+            if data.count_items() > threshold:
+                from .compactor import compact_scratchpad
+
+                data = compact_scratchpad(data, max_items=threshold)
+        payload = data.to_dict()
+        payload.setdefault("meta", {})
+        payload["meta"]["last_updated"] = now_iso()
+        payload["meta"]["total_items"] = data.count_items()
+        atomic_write_json(self.path, payload, use_lock=True, backup=True)
+
+    def _key_for(self, item: MemoryItem) -> tuple[Any, ...]:
+        rule = CATEGORY_KEY_RULES.get(item.category)
+        if not rule:
+            return (item.id,)
+        values: list[Any] = []
+        for key in rule:
+            values.append(getattr(item, key, None))
+        return tuple(values)
+
+    def upsert_item(self, item: MemoryItem) -> Dict[str, int]:
+        normalized = item.normalized()
+        data = self.load()
+        bucket = CATEGORY_TO_BUCKET[normalized.category]
+        rows: List[MemoryItem] = list(getattr(data, bucket))
+        target_key = self._key_for(normalized)
+
+        outdated = 0
+        replaced_existing = False
+        new_rows: List[MemoryItem] = []
+        for row in rows:
+            row_key = self._key_for(row)
+            if row_key == target_key and row.id != normalized.id:
+                # 同 key 旧值降级为 outdated,保留审计轨迹
+                if row.status != "outdated":
+                    row = MemoryItem(**{**asdict(row), "status": "outdated", "updated_at": now_iso()})
+                    outdated += 1
+                replaced_existing = True
+            elif row.id == normalized.id:
+                replaced_existing = True
+                continue
+            new_rows.append(row)
+
+        normalized.updated_at = normalized.updated_at or now_iso()
+        new_rows.append(normalized)
+        setattr(data, bucket, new_rows)
+        self.save(data)
+
+        return {
+            "added": 0 if replaced_existing else 1,
+            "updated": 1 if replaced_existing else 0,
+            "outdated": outdated,
+        }
+
+    def mark_status(self, item_id: str, status: str) -> bool:
+        if not item_id:
+            return False
+        data = self.load()
+        updated = False
+        for bucket in BUCKET_TO_CATEGORY:
+            rows: List[MemoryItem] = getattr(data, bucket)
+            for i, row in enumerate(rows):
+                if row.id == item_id:
+                    rows[i] = MemoryItem(**{**asdict(row), "status": status, "updated_at": now_iso()})
+                    updated = True
+        if updated:
+            self.save(data)
+        return updated
+
+    def query(
+        self,
+        category: Optional[str] = None,
+        subject: Optional[str] = None,
+        status: Optional[str] = "active",
+    ) -> List[MemoryItem]:
+        data = self.load()
+        categories = [category] if category else list(CATEGORY_TO_BUCKET.keys())
+        result: List[MemoryItem] = []
+        for cat in categories:
+            bucket = CATEGORY_TO_BUCKET.get(cat)
+            if not bucket:
+                continue
+            rows: List[MemoryItem] = getattr(data, bucket)
+            for row in rows:
+                if subject and row.subject != subject:
+                    continue
+                if status and row.status != status:
+                    continue
+                result.append(row)
+        return result
+
+    def stats(self) -> Dict[str, Any]:
+        data = self.load()
+        by_category: Dict[str, int] = {}
+        active = 0
+        outdated = 0
+        contradicted = 0
+        tentative = 0
+        for category, bucket in CATEGORY_TO_BUCKET.items():
+            rows: List[MemoryItem] = getattr(data, bucket)
+            by_category[category] = len(rows)
+            for row in rows:
+                if row.status == "active":
+                    active += 1
+                elif row.status == "outdated":
+                    outdated += 1
+                elif row.status == "contradicted":
+                    contradicted += 1
+                elif row.status == "tentative":
+                    tentative += 1
+        return {
+            "total": data.count_items(),
+            "active": active,
+            "outdated": outdated,
+            "contradicted": contradicted,
+            "tentative": tentative,
+            "by_category": by_category,
+            "path": str(self.path),
+        }
+
+    def dump(self) -> Dict[str, Any]:
+        return self.load().to_dict()
+
+    def conflicts(self) -> List[Dict[str, Any]]:
+        data = self.load()
+        conflicts: List[Dict[str, Any]] = []
+        for category, bucket in CATEGORY_TO_BUCKET.items():
+            key_count: Dict[tuple[Any, ...], int] = {}
+            rows: List[MemoryItem] = getattr(data, bucket)
+            for row in rows:
+                if row.status != "active":
+                    continue
+                key = self._key_for(row)
+                key_count[key] = key_count.get(key, 0) + 1
+            for key, cnt in key_count.items():
+                if cnt > 1:
+                    conflicts.append({"category": category, "key": list(key), "active_items": cnt})
+        return conflicts
+
+
+def main() -> None:
+    import argparse
+    import sys
+
+    parser = argparse.ArgumentParser(description="Memory Scratchpad CLI")
+    parser.add_argument("--project-root", type=str, help="项目根目录")
+    sub = parser.add_subparsers(dest="command", required=True)
+
+    sub.add_parser("stats")
+    p_query = sub.add_parser("query")
+    p_query.add_argument("--category", type=str, default=None)
+    p_query.add_argument("--subject", type=str, default=None)
+    p_query.add_argument("--status", type=str, default="active")
+    sub.add_parser("dump")
+    sub.add_parser("conflicts")
+
+    p_update = sub.add_parser("update")
+    p_update.add_argument("--chapter", type=int, required=True)
+    p_update.add_argument("--data", required=True, help="章节结构化结果 JSON")
+
+    sub.add_parser("bootstrap")
+
+    args = parser.parse_args(normalize_global_project_root(sys.argv[1:]))
+
+    config = None
+    if args.project_root:
+        from project_locator import resolve_project_root
+
+        resolved_root = resolve_project_root(args.project_root)
+        config = DataModulesConfig.from_project_root(resolved_root)
+
+    manager = ScratchpadManager(config)
+
+    if args.command == "stats":
+        print_success(manager.stats(), message="memory_stats")
+        return
+    if args.command == "dump":
+        print_success(manager.dump(), message="memory_dump")
+        return
+    if args.command == "conflicts":
+        print_success(manager.conflicts(), message="memory_conflicts")
+        return
+    if args.command == "query":
+        rows = [row.to_dict() for row in manager.query(args.category, args.subject, args.status)]
+        print_success(rows, message="memory_query")
+        return
+    if args.command == "update":
+        from .writer import MemoryWriter
+
+        payload = load_json_arg(args.data)
+        writer = MemoryWriter(config or get_config())
+        result = writer.update_from_chapter_result(args.chapter, payload)
+        print_success(result, message="memory_updated")
+        return
+    if args.command == "bootstrap":
+        from .bootstrap import bootstrap_from_index
+
+        result = bootstrap_from_index(config or get_config())
+        print_success(result, message="memory_bootstrapped")
+        return
+
+    print_error("UNKNOWN_COMMAND", "未知命令", suggestion="请查看 --help")

+ 223 - 0
webnovel-writer/scripts/data_modules/memory/writer.py

@@ -0,0 +1,223 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+章节结果 -> 长期记忆项映射。
+"""
+from __future__ import annotations
+
+import hashlib
+from typing import Any, Dict, List
+
+from ..config import DataModulesConfig, get_config
+from .schema import MemoryItem
+from .store import ScratchpadManager
+
+
+class MemoryWriter:
+    def __init__(self, config: DataModulesConfig | None = None):
+        self.config = config or get_config()
+        self.store = ScratchpadManager(self.config)
+
+    def _item_id(self, category: str, subject: str, field: str, chapter: int) -> str:
+        raw = f"{category}|{subject}|{field}|{chapter}"
+        digest = hashlib.sha1(raw.encode("utf-8")).hexdigest()[:12]
+        return f"mem-{category}-{digest}"
+
+    def _upsert(self, item: MemoryItem, stats: Dict[str, Any]) -> None:
+        result = self.store.upsert_item(item)
+        stats["items_added"] += int(result.get("added", 0))
+        stats["items_updated"] += int(result.get("updated", 0))
+        stats["items_outdated"] += int(result.get("outdated", 0))
+
+    def update_from_chapter_result(self, chapter: int, result: Dict[str, Any]) -> Dict[str, Any]:
+        stats: Dict[str, Any] = {
+            "chapter": int(chapter),
+            "items_added": 0,
+            "items_updated": 0,
+            "items_outdated": 0,
+            "warnings": [],
+        }
+
+        # Stage 2: 零成本结构化映射
+        for change in result.get("state_changes", []) or []:
+            entity_id = str(change.get("entity_id", "") or "").strip()
+            field = str(change.get("field", "") or "").strip()
+            if not entity_id or not field:
+                continue
+            item = MemoryItem(
+                id=self._item_id("character_state", entity_id, field, chapter),
+                layer="semantic",
+                category="character_state",
+                subject=entity_id,
+                field=field,
+                value=str(change.get("new", "") or ""),
+                payload={"old_value": change.get("old")},
+                source_chapter=int(chapter),
+                evidence=[f"state_change:{entity_id}:{field}:{chapter}"],
+            )
+            self._upsert(item, stats)
+
+        for entity in result.get("entities_new", []) or []:
+            entity_id = str(entity.get("suggested_id") or entity.get("id") or "").strip()
+            name = str(entity.get("name", "") or "").strip()
+            if not entity_id:
+                continue
+            item = MemoryItem(
+                id=self._item_id("character_state", entity_id, "first_seen", chapter),
+                layer="semantic",
+                category="character_state",
+                subject=entity_id,
+                field="first_seen",
+                value=name,
+                payload={"tier": entity.get("tier"), "type": entity.get("type")},
+                source_chapter=int(chapter),
+                evidence=[f"entity_new:{entity_id}:{chapter}"],
+            )
+            self._upsert(item, stats)
+
+        for rel in result.get("relationships_new", []) or []:
+            from_entity = str(rel.get("from") or rel.get("from_entity") or "").strip()
+            to_entity = str(rel.get("to") or rel.get("to_entity") or "").strip()
+            rel_type = str(rel.get("type", "") or "").strip()
+            if not from_entity or not to_entity:
+                continue
+            item = MemoryItem(
+                id=self._item_id("relationship", from_entity, to_entity, chapter),
+                layer="semantic",
+                category="relationship",
+                subject=from_entity,
+                field=to_entity,
+                value=rel_type,
+                payload={"description": rel.get("description", ""), "to_entity": to_entity},
+                source_chapter=int(chapter),
+                evidence=[f"relationship:{from_entity}:{to_entity}:{chapter}"],
+            )
+            self._upsert(item, stats)
+
+        chapter_meta = result.get("chapter_meta") or {}
+        hook = chapter_meta.get("hook")
+        if isinstance(hook, dict):
+            hook_content = str(hook.get("content", "") or "").strip()
+            if hook_content:
+                item = MemoryItem(
+                    id=self._item_id("story_fact", "chapter_hook", str(chapter), chapter),
+                    layer="semantic",
+                    category="story_fact",
+                    subject="chapter_hook",
+                    field=str(chapter),
+                    value=hook_content,
+                    payload={"hook_type": hook.get("type"), "strength": hook.get("strength")},
+                    source_chapter=int(chapter),
+                    evidence=[f"chapter_meta:hook:{chapter}"],
+                )
+                self._upsert(item, stats)
+        elif isinstance(hook, str) and hook.strip():
+            item = MemoryItem(
+                id=self._item_id("story_fact", "chapter_hook", str(chapter), chapter),
+                layer="semantic",
+                category="story_fact",
+                subject="chapter_hook",
+                field=str(chapter),
+                value=hook.strip(),
+                payload={},
+                source_chapter=int(chapter),
+                evidence=[f"chapter_meta:hook:{chapter}"],
+            )
+            self._upsert(item, stats)
+
+        # Stage 4: Data Agent 深度提取扩展
+        memory_facts = result.get("memory_facts") or {}
+        if isinstance(memory_facts, dict):
+            self._apply_memory_facts(chapter, memory_facts, stats)
+
+        return stats
+
+    def _apply_memory_facts(self, chapter: int, memory_facts: Dict[str, Any], stats: Dict[str, Any]) -> None:
+        timeline_events = memory_facts.get("timeline_events") or []
+        for row in timeline_events:
+            if not isinstance(row, dict):
+                continue
+            event = str(row.get("event", "") or "").strip()
+            if not event:
+                continue
+            source_chapter = int(row.get("chapter") or chapter)
+            item = MemoryItem(
+                id=self._item_id("timeline", event, str(source_chapter), chapter),
+                layer="semantic",
+                category="timeline",
+                subject=event,
+                field="event",
+                value=event,
+                payload={"time_hint": row.get("time_hint"), "event_type": row.get("event_type")},
+                source_chapter=source_chapter,
+                evidence=[f"memory_facts:timeline:{chapter}"],
+            )
+            self._upsert(item, stats)
+
+        world_rules = memory_facts.get("world_rules") or []
+        for row in world_rules:
+            if not isinstance(row, dict):
+                continue
+            rule = str(row.get("rule", "") or "").strip()
+            if not rule:
+                continue
+            subject = str(row.get("domain", "") or "").strip() or str(row.get("scope", "") or "").strip() or "global"
+            field = str(row.get("field", "") or "").strip() or rule[:32]
+            item = MemoryItem(
+                id=self._item_id("world_rule", subject, field, chapter),
+                layer="semantic",
+                category="world_rule",
+                subject=subject,
+                field=field,
+                value=rule,
+                payload={"scope": row.get("scope"), "rule_text": rule},
+                source_chapter=int(chapter),
+                evidence=[f"memory_facts:world_rule:{chapter}"],
+            )
+            self._upsert(item, stats)
+
+        open_loops = memory_facts.get("open_loops") or []
+        for row in open_loops:
+            if not isinstance(row, dict):
+                continue
+            content = str(row.get("content", "") or "").strip()
+            if not content:
+                continue
+            item = MemoryItem(
+                id=self._item_id("open_loop", content, "status", chapter),
+                layer="semantic",
+                category="open_loop",
+                subject=content,
+                field="status",
+                value=content,
+                payload={
+                    "urgency": row.get("urgency"),
+                    "planted_chapter": row.get("planted_chapter"),
+                    "expected_payoff": row.get("expected_payoff"),
+                    "status": row.get("status"),
+                },
+                source_chapter=int(chapter),
+                evidence=[f"memory_facts:open_loop:{chapter}"],
+            )
+            self._upsert(item, stats)
+
+        reader_promises = memory_facts.get("reader_promises") or []
+        for row in reader_promises:
+            if not isinstance(row, dict):
+                continue
+            content = str(row.get("content", "") or "").strip()
+            if not content:
+                continue
+            item = MemoryItem(
+                id=self._item_id("reader_promise", content, "promise", chapter),
+                layer="semantic",
+                category="reader_promise",
+                subject=content,
+                field="promise",
+                value=content,
+                payload={"promise_type": row.get("type"), "target": row.get("target")},
+                source_chapter=int(chapter),
+                evidence=[f"memory_facts:reader_promise:{chapter}"],
+            )
+            self._upsert(item, stats)
+

+ 59 - 0
webnovel-writer/scripts/data_modules/schemas.py

@@ -64,6 +64,51 @@ class UncertainMention(BaseModel):
     adopted: Optional[str] = None
 
 
+class TimelineEvent(BaseModel):
+    model_config = ConfigDict(extra="allow")
+
+    event: str
+    chapter: Optional[int] = None
+    time_hint: Optional[str] = None
+    event_type: Optional[str] = None
+
+
+class WorldRule(BaseModel):
+    model_config = ConfigDict(extra="allow")
+
+    rule: str
+    scope: Optional[str] = None
+    domain: Optional[str] = None
+    field: Optional[str] = None
+
+
+class OpenLoop(BaseModel):
+    model_config = ConfigDict(extra="allow")
+
+    content: str
+    status: Optional[str] = None
+    urgency: Optional[float] = None
+    planted_chapter: Optional[int] = None
+    expected_payoff: Optional[str] = None
+
+
+class ReaderPromise(BaseModel):
+    model_config = ConfigDict(extra="allow")
+
+    content: str
+    type: Optional[str] = None
+    target: Optional[str] = None
+
+
+class MemoryFacts(BaseModel):
+    model_config = ConfigDict(extra="allow")
+
+    timeline_events: List[TimelineEvent] = Field(default_factory=list)
+    world_rules: List[WorldRule] = Field(default_factory=list)
+    open_loops: List[OpenLoop] = Field(default_factory=list)
+    reader_promises: List[ReaderPromise] = Field(default_factory=list)
+
+
 class DataAgentOutput(BaseModel):
     model_config = ConfigDict(extra="allow")
 
@@ -74,6 +119,7 @@ class DataAgentOutput(BaseModel):
     scenes_chunked: int = 0
     uncertain: List[UncertainMention] = Field(default_factory=list)
     warnings: List[str] = Field(default_factory=list)
+    memory_facts: Optional[MemoryFacts] = None
 
 
 class ErrorSchema(BaseModel):
@@ -121,5 +167,18 @@ def normalize_data_agent_output(payload: Dict[str, Any]) -> Dict[str, Any]:
     ]:
         _ensure_list(key)
 
+    memory_facts = payload.get("memory_facts")
+    if memory_facts is None:
+        payload["memory_facts"] = {}
+    elif not isinstance(memory_facts, dict):
+        payload["memory_facts"] = {}
+    else:
+        for key in ["timeline_events", "world_rules", "open_loops", "reader_promises"]:
+            value = memory_facts.get(key)
+            if value is None:
+                memory_facts[key] = []
+            elif not isinstance(value, list):
+                memory_facts[key] = [value]
+
     payload.setdefault("scenes_chunked", 0)
     return payload

+ 15 - 1
webnovel-writer/scripts/data_modules/state_manager.py

@@ -1093,6 +1093,16 @@ class StateManager:
         # 同步主角状态(entities_v3 → protagonist_state)
         self.sync_protagonist_from_entity()
 
+        # 长期记忆写入(best-effort,不阻断主流程)
+        try:
+            from .memory.writer import MemoryWriter
+
+            writer = MemoryWriter(self.config)
+            mem_result = writer.update_from_chapter_result(chapter, result)
+            logger.info("memory_write: %s", mem_result)
+        except Exception as exc:
+            logger.warning("memory_write_failed: %s", exc)
+
         return warnings
 
     # ==================== 导出 ====================
@@ -1260,7 +1270,11 @@ def main():
         from project_locator import resolve_project_root
         from .config import DataModulesConfig
 
-        resolved_root = resolve_project_root(args.project_root)
+        try:
+            resolved_root = resolve_project_root(args.project_root)
+        except FileNotFoundError:
+            # 兼容旧行为:显式目录无法被 locator 识别时,直接按传入路径初始化。
+            resolved_root = Path(args.project_root).expanduser().resolve()
         config = DataModulesConfig.from_project_root(resolved_root)
 
     manager = StateManager(config)

+ 1 - 0
webnovel-writer/scripts/data_modules/tests/test_config.py

@@ -15,6 +15,7 @@ def test_config_paths_and_defaults(tmp_path):
     assert cfg.project_root == tmp_path
     assert cfg.webnovel_dir.name == ".webnovel"
     assert cfg.state_file.name == "state.json"
+    assert cfg.scratchpad_file.name == "memory_scratchpad.json"
     assert cfg.index_db.name == "index.db"
     assert cfg.rag_db.name == "rag.db"
     assert cfg.vector_db.name == "vectors.db"

+ 44 - 0
webnovel-writer/scripts/data_modules/tests/test_context_manager.py

@@ -95,6 +95,50 @@ def test_context_manager_build_and_filter(temp_project):
     assert any(c.get("entity_id") == "xiaoyan" for c in characters)
     assert not any(c.get("entity_id") == "bad" for c in characters)
     assert payload["sections"]["preferences"]["content"].get("tone") == "热血"
+    assert "long_term_memory" in payload["sections"]
+
+
+def test_context_manager_uses_memory_orchestrator_for_working_when_enabled(temp_project, monkeypatch):
+    state = {
+        "protagonist_state": {"name": "旧快照"},
+        "chapter_meta": {},
+        "disambiguation_warnings": [],
+        "disambiguation_pending": [],
+    }
+    temp_project.state_file.write_text(json.dumps(state, ensure_ascii=False), encoding="utf-8")
+    temp_project.context_use_memory_orchestrator = True
+
+    def _fake_pack(self, chapter, task_type="write"):
+        return {
+            "working_memory": [
+                {"layer": "working", "source": "outline", "chapter": chapter, "content": "FAKE_OUTLINE"},
+                {
+                    "layer": "working",
+                    "source": "state_export",
+                    "chapter": chapter,
+                    "content": {"protagonist_state": {"name": "新快照"}},
+                },
+                {"layer": "working", "source": "summary", "chapter": chapter - 1, "content": "FAKE_SUMMARY"},
+            ],
+            "episodic_memory": [],
+            "semantic_memory": [],
+            "long_term_facts": [],
+            "active_constraints": [],
+            "recent_changes": [],
+            "warnings": [],
+            "stats": {"total": 0, "injected": 0, "filtered": 0, "conflicts": 0},
+        }
+
+    monkeypatch.setattr("data_modules.memory.orchestrator.MemoryOrchestrator.build_memory_pack", _fake_pack)
+    manager = ContextManager(temp_project)
+    payload = manager.build_context(1, use_snapshot=False, save_snapshot=False)
+    core = payload["sections"]["core"]["content"]
+    long_term_memory = payload["sections"]["long_term_memory"]["content"]
+
+    assert "working_memory" in long_term_memory
+    assert core["chapter_outline"] == "FAKE_OUTLINE"
+    assert core["protagonist_snapshot"] == {"name": "新快照"}
+    assert core["recent_summaries"] == [{"chapter": 0, "summary": "FAKE_SUMMARY"}]
 
 
 def test_context_manager_loads_volume_outline_file(temp_project):

+ 1 - 0
webnovel-writer/scripts/data_modules/tests/test_extract_chapter_context.py

@@ -161,6 +161,7 @@ def test_build_chapter_context_payload_includes_contract_sections(tmp_path):
     assert "rag_assist" in payload
     assert isinstance(payload["rag_assist"], dict)
     assert payload["rag_assist"].get("invoked") is False
+    assert "long_term_memory" in payload
 
 
 def test_render_text_contains_writing_guidance_section(tmp_path):

+ 78 - 0
webnovel-writer/scripts/data_modules/tests/test_memory_bootstrap.py

@@ -0,0 +1,78 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+
+from data_modules.config import DataModulesConfig
+from data_modules.index_manager import EntityMeta, RelationshipMeta, StateChangeMeta, IndexManager
+from data_modules.memory.bootstrap import bootstrap_from_index
+from data_modules.memory.store import ScratchpadManager
+
+
+def _cfg(tmp_path):
+    cfg = DataModulesConfig.from_project_root(tmp_path)
+    cfg.ensure_dirs()
+    return cfg
+
+
+def test_bootstrap_from_index_includes_state_changes_and_open_loops(tmp_path):
+    cfg = _cfg(tmp_path)
+    idx = IndexManager(cfg)
+    idx.upsert_entity(
+        EntityMeta(
+            id="xiaoyan",
+            type="角色",
+            canonical_name="萧炎",
+            current={"realm": "斗者"},
+            first_appearance=1,
+            last_appearance=2,
+        )
+    )
+    idx.record_state_change(
+        StateChangeMeta(
+            entity_id="xiaoyan",
+            field="realm",
+            old_value="斗者",
+            new_value="斗师",
+            reason="突破",
+            chapter=3,
+        )
+    )
+    idx.record_state_change(
+        StateChangeMeta(
+            entity_id="xiaoyan",
+            field="realm",
+            old_value="斗师",
+            new_value="大斗师",
+            reason="再突破",
+            chapter=8,
+        )
+    )
+    idx.upsert_relationship(
+        RelationshipMeta(
+            from_entity="xiaoyan",
+            to_entity="yaolao",
+            type="师徒",
+            description="授艺",
+            chapter=2,
+        )
+    )
+
+    summaries_dir = cfg.webnovel_dir / "summaries"
+    summaries_dir.mkdir(parents=True, exist_ok=True)
+    (summaries_dir / "ch0008.md").write_text(
+        "## 剧情摘要\n内容\n\n## 伏笔\n- 三年之约\n- 神秘玉佩的来历\n",
+        encoding="utf-8",
+    )
+
+    result = bootstrap_from_index(cfg)
+    assert result["items_created"] > 0
+    assert result["categories"].get("character_state", 0) >= 2
+    assert result["categories"].get("open_loop", 0) >= 2
+
+    store = ScratchpadManager(cfg)
+    active_realm = store.query(category="character_state", subject="xiaoyan", status="active")
+    assert any(item.field == "realm" and item.value == "大斗师" for item in active_realm)
+    outdated_realm = store.query(category="character_state", subject="xiaoyan", status="outdated")
+    assert any(item.field == "realm" and item.value == "斗师" for item in outdated_realm)
+    loops = store.query(category="open_loop", status="active")
+    assert any("三年之约" in item.value for item in loops)
+

+ 64 - 0
webnovel-writer/scripts/data_modules/tests/test_memory_orchestrator.py

@@ -0,0 +1,64 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+
+from data_modules.config import DataModulesConfig
+from data_modules.memory.orchestrator import MemoryOrchestrator
+from data_modules.memory.schema import MemoryItem
+from data_modules.memory.store import ScratchpadManager
+
+
+def _cfg(tmp_path):
+    cfg = DataModulesConfig.from_project_root(tmp_path)
+    cfg.ensure_dirs()
+    return cfg
+
+
+def test_build_memory_pack_empty(tmp_path):
+    orchestrator = MemoryOrchestrator(_cfg(tmp_path))
+    pack = orchestrator.build_memory_pack(1)
+    assert pack["stats"]["total"] == 0
+    assert pack["semantic_memory"] == []
+    assert pack["long_term_facts"] == pack["semantic_memory"]
+    assert len(pack["long_term_facts"]) == pack["stats"]["injected"]
+    assert "working_memory" in pack
+    assert "episodic_memory" in pack
+    assert "semantic_memory" in pack
+
+
+def test_build_memory_pack_filter_and_budget(tmp_path):
+    cfg = _cfg(tmp_path)
+    cfg.memory_orchestrator_max_items = 1
+    outline_dir = cfg.project_root / "大纲"
+    outline_dir.mkdir(parents=True, exist_ok=True)
+    (outline_dir / "第1卷 详细大纲.md").write_text("### 第10章:萧炎突破\n", encoding="utf-8")
+
+    store = ScratchpadManager(cfg)
+    store.upsert_item(
+        MemoryItem(
+            id="m1",
+            layer="semantic",
+            category="character_state",
+            subject="萧炎",
+            field="realm",
+            value="斗师",
+            source_chapter=9,
+        )
+    )
+    store.upsert_item(
+        MemoryItem(
+            id="m2",
+            layer="semantic",
+            category="story_fact",
+            subject="chapter_hook",
+            field="9",
+            value="神秘强者出现",
+            source_chapter=9,
+        )
+    )
+
+    orchestrator = MemoryOrchestrator(cfg)
+    pack = orchestrator.build_memory_pack(10)
+    assert pack["stats"]["total"] >= 2
+    assert len(pack["long_term_facts"]) == 1
+    assert pack["stats"]["semantic_total"] >= 1
+    assert pack["long_term_facts"] == pack["semantic_memory"]

+ 43 - 0
webnovel-writer/scripts/data_modules/tests/test_memory_schema.py

@@ -0,0 +1,43 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+
+from data_modules.memory.schema import (
+    BUCKET_TO_CATEGORY,
+    CATEGORY_KEY_RULES,
+    CATEGORY_TO_BUCKET,
+    MemoryItem,
+    ScratchpadData,
+)
+
+
+def test_memory_item_roundtrip_and_payload():
+    item = MemoryItem(
+        id="m1",
+        layer="semantic",
+        category="character_state",
+        subject="xiaoyan",
+        field="realm",
+        value="筑基三层",
+        payload={"old_value": "筑基二层"},
+        source_chapter=12,
+        evidence=["state_change:xiaoyan:realm:12"],
+    )
+    raw = item.to_dict()
+    rebuilt = MemoryItem.from_dict(raw)
+    assert rebuilt.value == "筑基三层"
+    assert rebuilt.payload.get("old_value") == "筑基二层"
+    assert rebuilt.status == "active"
+
+
+def test_scratchpad_data_default_and_count():
+    data = ScratchpadData.empty()
+    assert data.count_items() == 0
+    raw = data.to_dict()
+    assert raw["meta"]["version"] == 1
+    assert raw["meta"]["total_items"] == 0
+
+
+def test_category_bucket_mapping_complete():
+    assert set(CATEGORY_TO_BUCKET.values()) == set(BUCKET_TO_CATEGORY.keys())
+    assert set(CATEGORY_TO_BUCKET.keys()) == set(CATEGORY_KEY_RULES.keys())
+

+ 36 - 0
webnovel-writer/scripts/data_modules/tests/test_memory_schema_compat.py

@@ -0,0 +1,36 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+
+from data_modules.schemas import normalize_data_agent_output, validate_data_agent_output
+
+
+def test_data_agent_output_compatible_without_memory_facts():
+    payload = {
+        "entities_appeared": [],
+        "entities_new": [],
+        "state_changes": [],
+        "relationships_new": [],
+    }
+    normalized = normalize_data_agent_output(payload)
+    validated = validate_data_agent_output(normalized)
+    assert validated.memory_facts is not None
+
+
+def test_data_agent_output_accepts_memory_facts():
+    payload = {
+        "entities_appeared": [],
+        "entities_new": [],
+        "state_changes": [],
+        "relationships_new": [],
+        "memory_facts": {
+            "timeline_events": [{"event": "萧炎离开天云宗", "chapter": 12}],
+            "world_rules": [{"rule": "修炼体系九境", "scope": "global"}],
+            "open_loops": [{"content": "三年之约", "status": "active"}],
+            "reader_promises": [{"content": "纳兰嫣然会出场", "type": "encounter"}],
+        },
+    }
+    normalized = normalize_data_agent_output(payload)
+    validated = validate_data_agent_output(normalized)
+    assert len(validated.memory_facts.timeline_events) == 1
+    assert len(validated.memory_facts.world_rules) == 1
+

+ 206 - 0
webnovel-writer/scripts/data_modules/tests/test_memory_store.py

@@ -0,0 +1,206 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+
+from data_modules.config import DataModulesConfig
+from data_modules.memory.schema import MemoryItem
+from data_modules.memory.store import ScratchpadManager
+
+
+def _cfg(tmp_path):
+    cfg = DataModulesConfig.from_project_root(tmp_path)
+    cfg.ensure_dirs()
+    return cfg
+
+
+def test_load_empty_file(tmp_path):
+    manager = ScratchpadManager(_cfg(tmp_path))
+    data = manager.load()
+    assert data.count_items() == 0
+
+
+def test_upsert_character_state_marks_old_outdated(tmp_path):
+    manager = ScratchpadManager(_cfg(tmp_path))
+    item1 = MemoryItem(
+        id="a1",
+        layer="semantic",
+        category="character_state",
+        subject="xiaoyan",
+        field="realm",
+        value="斗者",
+        source_chapter=1,
+    )
+    item2 = MemoryItem(
+        id="a2",
+        layer="semantic",
+        category="character_state",
+        subject="xiaoyan",
+        field="realm",
+        value="斗师",
+        source_chapter=2,
+    )
+    manager.upsert_item(item1)
+    manager.upsert_item(item2)
+    active = manager.query(category="character_state", subject="xiaoyan", status="active")
+    outdated = manager.query(category="character_state", subject="xiaoyan", status="outdated")
+    assert len(active) == 1
+    assert active[0].value == "斗师"
+    assert len(outdated) == 1
+
+
+def test_upsert_world_rule_with_subject_field_key(tmp_path):
+    manager = ScratchpadManager(_cfg(tmp_path))
+    manager.upsert_item(
+        MemoryItem(
+            id="w1",
+            layer="semantic",
+            category="world_rule",
+            subject="修炼体系",
+            field="境界划分",
+            value="九境",
+            source_chapter=1,
+        )
+    )
+    manager.upsert_item(
+        MemoryItem(
+            id="w2",
+            layer="semantic",
+            category="world_rule",
+            subject="修炼体系",
+            field="突破条件",
+            value="需心境",
+            source_chapter=2,
+        )
+    )
+    rows = manager.query(category="world_rule", status="active")
+    assert len(rows) == 2
+
+
+def test_timeline_same_subject_different_chapter_should_append(tmp_path):
+    manager = ScratchpadManager(_cfg(tmp_path))
+    manager.upsert_item(
+        MemoryItem(
+            id="t1",
+            layer="semantic",
+            category="timeline",
+            subject="离开宗门",
+            field="event",
+            value="离开宗门",
+            source_chapter=10,
+        )
+    )
+    manager.upsert_item(
+        MemoryItem(
+            id="t2",
+            layer="semantic",
+            category="timeline",
+            subject="离开宗门",
+            field="event",
+            value="离开宗门",
+            source_chapter=20,
+        )
+    )
+    rows = manager.query(category="timeline", status="active")
+    assert len(rows) == 2
+
+
+def test_mark_status_and_stats(tmp_path):
+    manager = ScratchpadManager(_cfg(tmp_path))
+    manager.upsert_item(
+        MemoryItem(
+            id="p1",
+            layer="semantic",
+            category="reader_promise",
+            subject="纳兰嫣然出场",
+            field="promise",
+            value="纳兰嫣然会在宗门大比前出场",
+            source_chapter=8,
+        )
+    )
+    assert manager.mark_status("p1", "tentative") is True
+    rows = manager.query(category="reader_promise", status="tentative")
+    assert len(rows) == 1
+    stats = manager.stats()
+    assert stats["total"] >= 1
+
+
+def test_compactor_enforces_global_limit_and_dedupes_timeline_summary(tmp_path):
+    cfg = _cfg(tmp_path)
+    cfg.memory_compactor_enabled = True
+    cfg.memory_compactor_threshold = 3
+    manager = ScratchpadManager(cfg)
+
+    manager.upsert_item(
+        MemoryItem(
+            id="t-old-1",
+            layer="semantic",
+            category="timeline",
+            subject="事件一",
+            field="event",
+            value="事件一",
+            source_chapter=1,
+        )
+    )
+    manager.upsert_item(
+        MemoryItem(
+            id="t-old-2",
+            layer="semantic",
+            category="timeline",
+            subject="事件二",
+            field="event",
+            value="事件二",
+            source_chapter=2,
+        )
+    )
+    manager.upsert_item(
+        MemoryItem(
+            id="t-fresh",
+            layer="semantic",
+            category="timeline",
+            subject="事件三",
+            field="event",
+            value="事件三",
+            source_chapter=60,
+        )
+    )
+    manager.upsert_item(
+        MemoryItem(
+            id="w1",
+            layer="semantic",
+            category="world_rule",
+            subject="修炼体系",
+            field="境界划分",
+            value="九境",
+            source_chapter=60,
+        )
+    )
+
+    dump1 = manager.dump()
+    total1 = sum(len(v) for k, v in dump1.items() if isinstance(v, list))
+    assert total1 <= 3
+    summary_count1 = sum(
+        1
+        for row in dump1.get("story_facts", [])
+        if row.get("subject") == "timeline_summary"
+    )
+    assert summary_count1 <= 1
+
+    manager.upsert_item(
+        MemoryItem(
+            id="w2",
+            layer="semantic",
+            category="world_rule",
+            subject="地理",
+            field="区域",
+            value="中州",
+            source_chapter=61,
+        )
+    )
+    dump2 = manager.dump()
+    total2 = sum(len(v) for k, v in dump2.items() if isinstance(v, list))
+    assert total2 <= 3
+    summary_count2 = sum(
+        1
+        for row in dump2.get("story_facts", [])
+        if row.get("subject") == "timeline_summary"
+    )
+    assert summary_count2 <= 1

+ 51 - 0
webnovel-writer/scripts/data_modules/tests/test_memory_writer.py

@@ -0,0 +1,51 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+
+from data_modules.config import DataModulesConfig
+from data_modules.memory.store import ScratchpadManager
+from data_modules.memory.writer import MemoryWriter
+
+
+def _cfg(tmp_path):
+    cfg = DataModulesConfig.from_project_root(tmp_path)
+    cfg.ensure_dirs()
+    return cfg
+
+
+def test_writer_stage2_mapping(tmp_path):
+    cfg = _cfg(tmp_path)
+    writer = MemoryWriter(cfg)
+    result = {
+        "entities_new": [{"suggested_id": "yaolao", "name": "药老", "type": "角色", "tier": "重要"}],
+        "state_changes": [{"entity_id": "xiaoyan", "field": "realm", "old": "斗者", "new": "斗师"}],
+        "relationships_new": [{"from": "xiaoyan", "to": "yaolao", "type": "师徒", "description": "收徒"}],
+        "chapter_meta": {"hook": {"content": "三年之约将至", "type": "悬念钩", "strength": "strong"}},
+    }
+    summary = writer.update_from_chapter_result(12, result)
+    assert summary["items_added"] >= 4
+    store = ScratchpadManager(cfg)
+    chars = store.query(category="character_state", status="active")
+    assert any(x.subject == "xiaoyan" and x.field == "realm" for x in chars)
+    rels = store.query(category="relationship", status="active")
+    assert any(x.subject == "xiaoyan" and x.field == "yaolao" for x in rels)
+
+
+def test_writer_stage4_memory_facts_mapping(tmp_path):
+    cfg = _cfg(tmp_path)
+    writer = MemoryWriter(cfg)
+    result = {
+        "memory_facts": {
+            "timeline_events": [{"event": "萧炎离开天云宗", "chapter": 100, "time_hint": "三年之约前夕"}],
+            "world_rules": [{"rule": "修炼体系九境", "scope": "global", "domain": "修炼体系", "field": "境界划分"}],
+            "open_loops": [{"content": "三年之约", "status": "active", "urgency": 80}],
+            "reader_promises": [{"content": "纳兰嫣然会出场", "type": "encounter", "target": "纳兰嫣然"}],
+        }
+    }
+    summary = writer.update_from_chapter_result(100, result)
+    assert summary["items_added"] >= 4
+    store = ScratchpadManager(cfg)
+    assert store.query(category="timeline", status="active")
+    assert store.query(category="world_rule", status="active")
+    assert store.query(category="open_loop", status="active")
+    assert store.query(category="reader_promise", status="active")
+

+ 7 - 0
webnovel-writer/scripts/data_modules/tests/test_state_manager_extra.py

@@ -201,6 +201,12 @@ def test_process_chapter_result_and_sqlite_sync(temp_project):
             },
         ],
         "chapter_meta": {"hook": "test", "end": "ok"},
+        "memory_facts": {
+            "timeline_events": [{"event": "萧炎离开天云宗", "chapter": 12, "time_hint": "夜晚"}],
+            "world_rules": [{"rule": "修炼体系九境", "scope": "global", "domain": "修炼体系", "field": "境界划分"}],
+            "open_loops": [{"content": "三年之约", "status": "active", "urgency": 80}],
+            "reader_promises": [{"content": "纳兰嫣然会出场", "type": "encounter"}],
+        },
     }
     warnings = manager.process_chapter_result(12, result)
     assert any("需人工确认" in w for w in warnings)
@@ -217,6 +223,7 @@ def test_process_chapter_result_and_sqlite_sync(temp_project):
     by_tier = manager.get_entities_by_tier("核心")
     assert "xiaoyan" in by_type
     assert "xiaoyan" in by_tier
+    assert temp_project.scratchpad_file.exists()
 
 
 def test_export_context_and_protagonist_alias(temp_project):

+ 5 - 0
webnovel-writer/scripts/data_modules/webnovel.py

@@ -223,6 +223,9 @@ def main() -> None:
     p_context = sub.add_parser("context", help="转发到 context_manager")
     p_context.add_argument("args", nargs=argparse.REMAINDER)
 
+    p_memory = sub.add_parser("memory", help="转发到 memory.store")
+    p_memory.add_argument("args", nargs=argparse.REMAINDER)
+
     p_migrate = sub.add_parser("migrate", help="转发到 migrate_state_to_sqlite")
     p_migrate.add_argument("args", nargs=argparse.REMAINDER)
 
@@ -287,6 +290,8 @@ def main() -> None:
         raise SystemExit(_run_data_module("entity_linker", [*forward_args, *rest]))
     if tool == "context":
         raise SystemExit(_run_data_module("context_manager", [*forward_args, *rest]))
+    if tool == "memory":
+        raise SystemExit(_run_data_module("memory.store", [*forward_args, *rest]))
     if tool == "migrate":
         raise SystemExit(_run_data_module("migrate_state_to_sqlite", [*forward_args, *rest]))
 

+ 32 - 0
webnovel-writer/scripts/extract_chapter_context.py

@@ -314,6 +314,7 @@ def _load_contract_context(project_root: Path, chapter_num: int) -> Dict[str, An
         "reader_signal": (sections.get("reader_signal") or {}).get("content", {}),
         "genre_profile": (sections.get("genre_profile") or {}).get("content", {}),
         "writing_guidance": (sections.get("writing_guidance") or {}).get("content", {}),
+        "long_term_memory": (sections.get("long_term_memory") or {}).get("content", {}),
     }
 
 
@@ -340,6 +341,7 @@ def build_chapter_context_payload(project_root: Path, chapter_num: int) -> Dict[
         "reader_signal": contract_context.get("reader_signal", {}),
         "genre_profile": contract_context.get("genre_profile", {}),
         "writing_guidance": contract_context.get("writing_guidance", {}),
+        "long_term_memory": contract_context.get("long_term_memory", {}),
         "rag_assist": rag_assist,
     }
 
@@ -482,6 +484,36 @@ def _render_text(payload: Dict[str, Any]) -> str:
             lines.append(f"- {row}")
         lines.append("")
 
+    long_term_memory = payload.get("long_term_memory") or {}
+    if long_term_memory:
+        lines.append("## 长期记忆")
+        lines.append("")
+        stats = long_term_memory.get("stats") or {}
+        if stats:
+            lines.append(
+                f"- 注入条目: {stats.get('injected', 0)} / 总条目: {stats.get('total', 0)}"
+            )
+        active_constraints = long_term_memory.get("active_constraints") or []
+        if active_constraints:
+            lines.append("- 活跃约束:")
+            for row in active_constraints[:5]:
+                value = str(row.get("value", "") or "").strip()
+                subject = str(row.get("subject", "") or "").strip()
+                if subject:
+                    lines.append(f"  - [{subject}] {value}")
+                else:
+                    lines.append(f"  - {value}")
+        facts = long_term_memory.get("long_term_facts") or []
+        if facts:
+            lines.append("- 关键长期事实:")
+            for row in facts[:5]:
+                category = str(row.get("category", "") or "").strip()
+                subject = str(row.get("subject", "") or "").strip()
+                field = str(row.get("field", "") or "").strip()
+                value = str(row.get("value", "") or "").strip()
+                lines.append(f"  - ({category}) {subject}.{field} = {value}")
+        lines.append("")
+
     rag_assist = payload.get("rag_assist") or {}
     hits = rag_assist.get("hits") or []
     if rag_assist.get("invoked") and hits:

+ 35 - 0
webnovel-writer/scripts/status_reporter.py

@@ -848,6 +848,10 @@ class StatusReporter:
         if focus in ["all", "basic"]:
             report_lines.extend(self._generate_basic_stats())
 
+        # 记忆统计(长期记忆)
+        if focus in ["all", "basic"]:
+            report_lines.extend(self._generate_memory_section())
+
         # 角色活跃度
         if focus in ["all", "characters"]:
             report_lines.extend(self._generate_character_section())
@@ -874,6 +878,37 @@ class StatusReporter:
 
         return "\n".join(report_lines)
 
+    def _generate_memory_section(self) -> List[str]:
+        """生成长期记忆统计章节(best-effort)。"""
+        try:
+            from data_modules.memory.store import ScratchpadManager
+        except Exception:
+            try:
+                from scripts.data_modules.memory.store import ScratchpadManager
+            except Exception:
+                return []
+
+        try:
+            manager = ScratchpadManager(self.config)
+            stats = manager.stats()
+            conflicts = manager.conflicts()
+        except Exception:
+            return []
+
+        return [
+            "## 🧠 长期记忆统计",
+            "",
+            f"- **总条目**: {stats.get('total', 0)}",
+            f"- **Active**: {stats.get('active', 0)}",
+            f"- **Outdated**: {stats.get('outdated', 0)}",
+            f"- **Contradicted**: {stats.get('contradicted', 0)}",
+            f"- **Tentative**: {stats.get('tentative', 0)}",
+            f"- **冲突键数量**: {len(conflicts)}",
+            "",
+            "---",
+            "",
+        ]
+
     def _generate_basic_stats(self) -> List[str]:
         """生成基本统计"""
         if not self.state:

+ 21 - 27
webnovel-writer/skills/webnovel-dashboard/SKILL.md

@@ -1,6 +1,6 @@
 ---
 name: webnovel-dashboard
-description: 启动可视化小说管理面板(只读 Web Dashboard),实时查看项目状态、实体图谱与章节内容。
+description: 启动只读小说管理面板,查看项目状态、实体图谱与章节内容。
 allowed-tools: Bash Read
 ---
 
@@ -8,18 +8,13 @@ allowed-tools: Bash Read
 
 ## 目标
 
-在本地启动一个 **只读** Web 面板,用于可视化查看当前小说项目的:
-- 创作进度与 Strand 节奏分布
-- 设定词典(角色/地点/势力等实体)
-- 关系图谱
-- 章节与大纲内容浏览
-- 追读力分析数据
+- 在本地启动只读 Web 面板。
+- 实时查看创作进度、设定词典、关系图谱、章节内容与追读力数据。
+- 允许监听 `.webnovel/` 变化,但不得修改项目内容。
 
-面板通过 `watchdog` 监听 `.webnovel/` 目录变更并实时刷新,不对项目做任何修改。
+## 执行流程
 
-## 执行步骤
-
-### Step 0:环境确认
+### Step 1:确认环境与模块目录
 
 ```bash
 export WORKSPACE_ROOT="${CLAUDE_PROJECT_DIR:-$PWD}"
@@ -28,46 +23,45 @@ if [ -z "${CLAUDE_PLUGIN_ROOT}" ] || [ ! -d "${CLAUDE_PLUGIN_ROOT}/dashboard" ];
   echo "ERROR: 未找到 dashboard 模块: ${CLAUDE_PLUGIN_ROOT}/dashboard" >&2
   exit 1
 fi
+
 export DASHBOARD_DIR="${CLAUDE_PLUGIN_ROOT}/dashboard"
 ```
 
-### Step 1:安装依赖(首次)
+### Step 2:安装依赖并解析项目根目录
 
 ```bash
 python -m pip install -r "${DASHBOARD_DIR}/requirements.txt" --quiet
-```
-
-### Step 2:解析项目根目录并准备 Python 模块路径
-
-```bash
 export SCRIPTS_DIR="${CLAUDE_PLUGIN_ROOT}/scripts"
 export PROJECT_ROOT="$(python "${SCRIPTS_DIR}/webnovel.py" --project-root "${WORKSPACE_ROOT}" where)"
 echo "项目路径: ${PROJECT_ROOT}"
+```
+
+补充要求:
+- `PROJECT_ROOT` 必须解析成功
+- 若依赖已安装,可重复执行,不视为错误
+
+### Step 3:准备 Python 模块路径并校验前端产物
 
-# 确保 `python -m dashboard.server` 可在任意工作目录下找到插件模块
+```bash
 if [ -n "${PYTHONPATH:-}" ]; then
   export PYTHONPATH="${CLAUDE_PLUGIN_ROOT}:${PYTHONPATH}"
 else
   export PYTHONPATH="${CLAUDE_PLUGIN_ROOT}"
 fi
 
-# 前端 dist 已随插件发布;若缺失说明安装包异常
 if [ ! -f "${DASHBOARD_DIR}/frontend/dist/index.html" ]; then
   echo "ERROR: 缺少前端构建产物 ${DASHBOARD_DIR}/frontend/dist/index.html" >&2
-  echo "请重新安装插件或联系维护者修复发布包。" >&2
   exit 1
 fi
 ```
 
-### Step 3:启动 Dashboard
+### Step 4:启动 Dashboard
 
 ```bash
 python -m dashboard.server --project-root "${PROJECT_ROOT}"
 ```
 
-启动后会自动打开浏览器访问 `http://127.0.0.1:8765`。
-
-如不需要自动打开浏览器,使用:
+如不需要自动打开浏览器:
 
 ```bash
 python -m dashboard.server --project-root "${PROJECT_ROOT}" --no-browser
@@ -75,6 +69,6 @@ python -m dashboard.server --project-root "${PROJECT_ROOT}" --no-browser
 
 ## 注意事项
 
-- Dashboard 为纯只读面板,所有 API 仅 GET,不提供任何修改接口。
-- 文件读取严格限制在 `PROJECT_ROOT` 范围内,防止路径穿越
-- 如需自定义端口,添加 `--port 9000` 参数
+- Dashboard 为纯只读面板,不提供修改接口。
+- 文件读取必须限制在 `PROJECT_ROOT` 范围内
+- 如需自定义端口,使用 `--port 9000`

+ 8 - 8
webnovel-writer/skills/webnovel-init/SKILL.md

@@ -52,7 +52,7 @@ allowed-tools: Read Write Edit Grep Bash Task AskUserQuestion WebSearch WebFetch
   - 触发:所有项目必读。
 - `references/system-data-flow.md`
   - 用途:初始化产物与后续 `/plan`、`/write` 的数据流一致性检查。
-  - 触发:Step 0 预检必读。
+  - 触发:Step 1 预检必读。
 
 ### worldbuilding
 
@@ -123,7 +123,7 @@ allowed-tools: Read Write Edit Grep Bash Task AskUserQuestion WebSearch WebFetch
 
 ## 交互流程(Deep)
 
-### Step 0:预检与上下文加载
+### Step 1:预检与上下文加载
 
 环境设置(bash 命令执行前):
 ```bash
@@ -151,7 +151,7 @@ export SCRIPTS_DIR="${CLAUDE_PLUGIN_ROOT}/scripts"
 输出:
 - 进入 Deep 采集前的“已知信息清单”和“待收集清单”。
 
-### Step 1:故事核与商业定位
+### Step 2:故事核与商业定位
 
 收集项(必收):
 - 书名(可先给工作名)
@@ -171,7 +171,7 @@ export SCRIPTS_DIR="${CLAUDE_PLUGIN_ROOT}/scripts"
 - 优先让用户自由描述,再二次结构化确认。
 - 若用户卡住,给 2-4 个候选方向供选。
 
-### Step 2:角色骨架与关系冲突
+### Step 3:角色骨架与关系冲突
 
 收集项(必收):
 - 主角姓名
@@ -185,7 +185,7 @@ export SCRIPTS_DIR="${CLAUDE_PLUGIN_ROOT}/scripts"
 - 主角原型标签(成长型/复仇型/天才流等)
 - 多主角分工
 
-### Step 3:金手指与兑现机制
+### Step 4:金手指与兑现机制
 
 收集项(必收):
 - 金手指类型(可为“无金手指”)
@@ -200,7 +200,7 @@ export SCRIPTS_DIR="${CLAUDE_PLUGIN_ROOT}/scripts"
 - 若为重生:重生时间点、记忆完整度
 - 若为传承/器灵:辅助边界与出手限制
 
-### Step 4:世界观与力量规则
+### Step 5:世界观与力量规则
 
 收集项(必收):
 - 世界规模(单城/多域/大陆/多界)
@@ -213,7 +213,7 @@ export SCRIPTS_DIR="${CLAUDE_PLUGIN_ROOT}/scripts"
 - 宗门/组织层级
 - 境界链与小境界
 
-### Step 5:创意约束包(差异化核心)
+### Step 6:创意约束包(差异化核心)
 
 流程:
 1. 基于题材映射加载反套路库(最多 2 个主相关库)。
@@ -234,7 +234,7 @@ export SCRIPTS_DIR="${CLAUDE_PLUGIN_ROOT}/scripts"
 备注:
 - 若用户要求“贴近当下市场”,可触发外部检索并标注时间戳。
 
-### Step 6:一致性复述与最终确认
+### Step 7:一致性复述与最终确认
 
 必须输出“初始化摘要草案”并让用户确认:
 - 故事核(题材/一句话故事/核心冲突)

+ 146 - 399
webnovel-writer/skills/webnovel-plan/SKILL.md

@@ -1,479 +1,226 @@
 ---
 name: webnovel-plan
-description: Builds volume and chapter outlines from the total outline, inherits creative constraints, and prepares writing-ready chapter plans. Use when the user asks for outlining or runs /webnovel-plan.
+description: 基于总纲生成卷纲、时间线和章纲,并把新增设定增量写回现有设定集。
 ---
 
 # Outline Planning
 
-Purpose: refine 总纲 into volume + chapter outlines. Do not redesign the global story.
-Setting policy: 先基于 init 产出的总纲+世界观补齐设定集基线;再在卷纲完成后,直接对现有设定集做增量补充。
+## 目标
 
-## Project Root Guard
-- Claude Code 的“工作区根目录”不一定等于“书项目根目录”。常见结构:工作区为 `D:\wk\xiaoshuo`,书项目为 `D:\wk\xiaoshuo\凡人资本论`。
-- 必须先解析 `PROJECT_ROOT` 为真实书项目根(必须包含 `.webnovel/state.json`),后续所有读写路径都以该目录为准。
+- 基于总纲细化卷纲、时间线与章纲,不重做全局故事。
+- 先补齐设定基线,再产出可直接进入写作的章纲。
+- 卷纲完成后,把新增设定增量写回现有设定集。
+
+## 执行原则
+
+1. 只做增量补齐,不重写整份总纲或设定集。
+2. 先锁定卷级节奏,再批量拆章。
+3. 时间线是硬约束,所有章纲都必须带时间字段。
+4. 若发现总纲与设定冲突,先阻断,再等用户裁决。
+
+## 环境准备
 
-环境设置(bash 命令执行前):
 ```bash
 export WORKSPACE_ROOT="${CLAUDE_PROJECT_DIR:-$PWD}"
-
-if [ -z "${CLAUDE_PLUGIN_ROOT}" ] || [ ! -d "${CLAUDE_PLUGIN_ROOT}/skills/webnovel-plan" ]; then
-  echo "ERROR: 未设置 CLAUDE_PLUGIN_ROOT 或缺少目录: ${CLAUDE_PLUGIN_ROOT}/skills/webnovel-plan" >&2
-  exit 1
-fi
 export SKILL_ROOT="${CLAUDE_PLUGIN_ROOT}/skills/webnovel-plan"
-
-if [ -z "${CLAUDE_PLUGIN_ROOT}" ] || [ ! -d "${CLAUDE_PLUGIN_ROOT}/scripts" ]; then
-  echo "ERROR: 未设置 CLAUDE_PLUGIN_ROOT 或缺少目录: ${CLAUDE_PLUGIN_ROOT}/scripts" >&2
-  exit 1
-fi
 export SCRIPTS_DIR="${CLAUDE_PLUGIN_ROOT}/scripts"
-
 export PROJECT_ROOT="$(python "${SCRIPTS_DIR}/webnovel.py" --project-root "${WORKSPACE_ROOT}" where)"
 ```
 
-## References(按步骤导航)
-
-- Step 3(必读,节拍表模板):[大纲-卷节拍表.md](../../templates/output/大纲-卷节拍表.md)
-- Step 4.5(必读,时间线模板):[大纲-卷时间线.md](../../templates/output/大纲-卷时间线.md)
-- Step 4(必读,题材配置):[genre-profiles.md](../../references/genre-profiles.md)
-- Step 4(必读,Strand 节奏):[strand-weave-pattern.md](../../references/shared/strand-weave-pattern.md)
-- Step 4(可选,爽点结构需要细化):[cool-points-guide.md](../../references/shared/cool-points-guide.md)
-- Step 5/6(可选,冲突强度分层):[conflict-design.md](references/outlining/conflict-design.md)
-- Step 5(可选,需要钩子/节奏细分):[reading-power-taxonomy.md](../../references/reading-power-taxonomy.md)
-- Step 6(可选,章节微结构细化):[chapter-planning.md](references/outlining/chapter-planning.md)
-- Step 4/5(可选,电竞/直播文/克苏鲁):[genre-volume-pacing.md](references/outlining/genre-volume-pacing.md)
-- 归档(不进主流程):`references/outlining/outline-structure.md`、`references/outlining/plot-frameworks.md`
-
-## Reference Loading Levels (strict, lazy)
-
-Use progressive disclosure and load only what current step requires:
-- L0: No references before scope/volume is confirmed.
-- L1: Before each step, load only the "必读" items in **References(按步骤导航)**.
-- L2: Load optional items only when the trigger condition applies.
-
-## Workflow
-1. Load project data.
-2. Build setting baseline from 总纲 + 世界观 (in-place incremental).
-3. Select volume and confirm scope.
-4. Generate volume beat sheet (节拍表).
-4.5. Generate volume timeline (时间线表).
-5. Generate volume skeleton.
-6. Generate chapter outlines in batches.
-7. Enrich existing setting files from volume outline (in-place incremental).
-8. Validate + save + update state.
-
-## 1) Load project data
+## References
+
+- Step 4:`../../templates/output/大纲-卷节拍表.md`
+- Step 5:`../../templates/output/大纲-卷时间线.md`
+- Step 6:`../../references/genre-profiles.md`
+- Step 6:`../../references/shared/strand-weave-pattern.md`
+- Step 6:`../../references/shared/cool-points-guide.md`(按需)
+- Step 6/7:`references/outlining/conflict-design.md`(按需)
+- Step 7:`../../references/reading-power-taxonomy.md`(按需)
+- Step 7:`references/outlining/chapter-planning.md`(按需)
+- Step 6/7:`references/outlining/genre-volume-pacing.md`(特定题材按需)
+
+## 执行流程
+
+### Step 1:加载项目数据并确认前置条件
+
 ```bash
 cat "$PROJECT_ROOT/.webnovel/state.json"
 cat "$PROJECT_ROOT/大纲/总纲.md"
 ```
 
-Optional (only if they exist):
-- `设定集/主角组.md`
-- `设定集/女主卡.md`
-- `设定集/反派设计.md`
+按需读取:
 - `设定集/世界观.md`
 - `设定集/力量体系.md`
 - `设定集/主角卡.md`
-- `.webnovel/idea_bank.json` (inherit constraints)
+- `设定集/反派设计.md`
+- `.webnovel/idea_bank.json`
 
-If 总纲.md lacks volume ranges / core conflict / climax, ask the user to fill those before proceeding.
+阻断条件:
+- 总纲缺少卷名、章节范围、核心冲突或卷末高潮
 
-## 2) Build setting baseline from 总纲 + 世界观
-目标:在不推翻现有内容的前提下,让设定集从“骨架模板”进入“可规划可写作”的基线状态。
+### Step 2:补齐设定基线
 
-输入来源:
-- `大纲/总纲.md`
-- `设定集/世界观.md`
-- `设定集/力量体系.md`
-- `设定集/主角卡.md`
-- `设定集/反派设计.md`
+目标:让设定集从骨架模板进入“可规划、可写作”的状态。
 
-执行规则(必须):
-- 只做增量补齐,不清空、不重写整文件。
-- 优先补齐“可执行字段”:角色定位、势力关系、能力边界、代价规则、反派层级映射。
-- 若总纲与现有设定冲突,先列冲突并阻断,等待用户裁决后再改。
+必须补齐:
+- `设定集/世界观.md`:世界边界、社会结构、关键地点用途
+- `设定集/力量体系.md`:境界链、限制、代价与冷却
+- `设定集/主角卡.md`:欲望、缺陷、初始资源与限制
+- `设定集/反派设计.md`:小/中/大反派层级与镜像关系
 
-基线补齐最小要求:
-- `设定集/世界观.md`:世界规则边界、社会结构、关键地点用途。
-- `设定集/力量体系.md`:境界链/能力限制/代价与冷却。
-- `设定集/主角卡.md`:欲望、缺陷、初始资源与限制。
-- `设定集/反派设计.md`:小/中/大反派层级与主角镜像关系。
+硬规则:
+- 只增量补齐,不清空、不重写整文件
+- 发现冲突时先列出冲突并阻断
 
-## 3) Select volume
-- Offer choices from 总纲.md (卷名 + 章节范围).
-- Confirm any special requirement (tone, POV emphasis, romance, etc.).
-If 总纲缺少卷名/章节范围/核心冲突/卷末高潮,先补问并更新总纲,再继续。
+### Step 3:选择目标卷并确认范围
 
-## 4) Generate volume beat sheet (节拍表)
-目标:先把本卷“承诺→危机递增→中段反转→最低谷→大兑现+新钩子”钉死,避免卷中段漂移。
+必须确认:
+- 卷名
+- 章节范围
+- 核心冲突
+- 是否存在特殊要求,例如视角、情感线、题材偏移
 
-Load template:
-```bash
-cat "${SKILL_ROOT}/../../templates/output/大纲-卷节拍表.md"
-```
+### Step 4:生成卷节拍表
 
-Must satisfy (hard requirements):
-- **中段反转(必填)**:不得留空;若无,写 `无(理由:...)`
-- **危机链**:至少 3 次递增(表格 1-3 行不得空)
-- **卷末新钩子**:必须能落到“最后一章的章末未闭合问题”
+执行前加载模板:
 
-Write output:
 ```bash
-@'
-{beat_sheet_content}
-'@ | Set-Content -Encoding UTF8 "$PROJECT_ROOT/大纲/第{volume_id}卷-节拍表.md"
+cat "${SKILL_ROOT}/../../templates/output/大纲-卷节拍表.md"
 ```
 
-Completion criteria:
-- `大纲/第{volume_id}卷-节拍表.md` 存在且非空
-- Step 4/5 能直接引用 Catalyst / 中段反转 / 最低谷 / 大兑现 / 新钩子来锚定节奏
+硬要求:
+- 必须填写中段反转;若确实没有,写“无(理由:...)”
+- 危机链至少 3 次递增
+- 卷末新钩子必须能落到最后一章的章末未闭合问题
+
+输出文件:`大纲/第{volume_id}卷-节拍表.md`
 
-## 4.5) Generate volume timeline (时间线表)
+### Step 5:生成卷时间线表
 
-目标:为本卷建立时间轴基准,确保章节间时间推进逻辑自洽,避免"第一章灾变第二章火拼"的时间跳跃问题。
+执行前加载模板:
 
-Load template:
 ```bash
 cat "${SKILL_ROOT}/../../templates/output/大纲-卷时间线.md"
 ```
 
-Must satisfy (hard requirements):
-- **时间基准(必填)**:明确本卷使用的时间体系(末世第X天/仙历年月/现代日期)
-- **本卷时间跨度(必填)**:本卷覆盖的时间范围
-- **关键倒计时事件**:若有时限性事件(物资耗尽/大比开始/截止日期),必须列出并标注 D-N
+硬要求:
+- 必须明确时间体系
+- 必须明确本卷时间跨度
+- 有倒计时事件时必须列出并标记 D-N
 
-Write output:
-```bash
-@'
-{timeline_content}
-'@ | Set-Content -Encoding UTF8 "$PROJECT_ROOT/大纲/第{volume_id}卷-时间线.md"
-```
+输出文件:`大纲/第{volume_id}卷-时间线.md`
 
-Completion criteria:
-- `大纲/第{volume_id}卷-时间线.md` 存在且非空
-- 时间基准和本卷跨度已明确
-- 若存在倒计时事件,已在表中列出
+### Step 6:生成卷纲骨架
+
+必须加载:
 
-## 5) Generate volume skeleton
-Load genre profile and apply standards:
 ```bash
 cat "${SKILL_ROOT}/../../references/genre-profiles.md"
 cat "${SKILL_ROOT}/../../references/shared/strand-weave-pattern.md"
 ```
 
-Optional (only if爽点结构需要细化):
-```bash
-cat "${SKILL_ROOT}/../../references/shared/cool-points-guide.md"
-```
+按需加载:
 
-Optional (only if需要补强卷级冲突链与强度分层):
 ```bash
+cat "${SKILL_ROOT}/../../references/shared/cool-points-guide.md"
 cat "${SKILL_ROOT}/references/outlining/conflict-design.md"
-```
-
-Load beat sheet (must exist):
-```bash
-cat "$PROJECT_ROOT/大纲/第{volume_id}卷-节拍表.md"
-```
-
-Extract for current genre:
-- Strand 比例(Quest/Fire/Constellation)
-- 爽点密度标准(每章最低/推荐)
-- 钩子类型偏好
-
-### Strand Weave 规划策略
-Based on genre profile, distribute chapters:
-- **Quest Strand** (主线推进): 55-65% 章节
-  - 目标明确、进展可见、有阶段性成果
-  - 例:突破境界、完成任务、获得宝物
-- **Fire Strand** (情感/关系): 20-30% 章节
-  - 人物关系变化、情感冲突、团队动态
-  - 例:与女主互动、师徒矛盾、兄弟背叛
-- **Constellation Strand** (世界/谜团): 10-20% 章节
-  - 世界观揭示、伏笔埋设、谜团推进
-  - 例:发现古老秘密、揭示反派阴谋、世界真相
-
-**Weaving pattern** (recommended):
-- 每 3-5 章切换主导 Strand
-- 高潮章节可多 Strand 交织
-- 卷末 3-5 章集中 Quest Strand
-
-For 电竞/直播文/克苏鲁, apply dedicated volume pacing template:
-```bash
 cat "${SKILL_ROOT}/references/outlining/genre-volume-pacing.md"
-```
-
-### 爽点密度规划策略
-Based on genre profile:
-- **常规章节**: 1-2 个小爽点(强度 2-3)
-- **关键章节**: 2-3 个爽点,至少 1 个中爽点(强度 4-5)
-- **高潮章节**: 3-4 个爽点,至少 1 个大爽点(强度 6-7)
-
-**Distribution rule**:
-- 每 5-8 章至少 1 个关键章节
-- 每卷至少 1 个高潮章节(通常在卷末)
-
-### 约束触发规划策略
-If idea_bank.json exists:
-```bash
 cat "$PROJECT_ROOT/.webnovel/idea_bank.json"
 ```
 
-Calculate trigger frequency:
-- **反套路规则**: 每 N 章触发 1 次
-  - N = max(5, 总章数 / 10)
-  - 例:50 章卷 → 每 5 章触发
-  - 例:100 章卷 → 每 10 章触发
-- **硬约束**: 贯穿全卷,在章节目标/爽点设计中体现
-- **主角缺陷**: 每卷至少 2 次成为冲突来源
-- **反派镜像**: 反派出场章节必须体现镜像对比
+卷纲必须明确:
+- 卷摘要
+- 关键人物与反派层级
+- Strand 分布
+- 爽点密度规划
+- 伏笔规划
+- 约束触发规划
 
-Use this template and fill from 总纲 + idea_bank:
+### Step 7:批量生成章纲
 
-```markdown
-# 第 {volume_id} 卷:{卷名}
+批次规则:
+- `<=20` 章:1 批
+- `21-40` 章:2 批
+- `41-60` 章:3 批
+- `>60` 章:4 批及以上
 
-> 章节范围: 第 {start} - {end} 章
-> 核心冲突: {conflict}
-> 卷末高潮: {climax}
+按需加载:
 
-## 卷摘要
-{2-3 段落概述}
-
-## 关键人物与反派
-- 主要登场角色:
-- 反派层级:
-
-## Strand Weave 规划
-| 章节范围 | 主导 Strand | 内容概要 |
-|---------|------------|---------|
+```bash
+cat "${SKILL_ROOT}/../../references/reading-power-taxonomy.md"
+cat "${SKILL_ROOT}/references/outlining/chapter-planning.md"
+```
 
-## 爽点密度规划
-| 章节 | 爽点类型 | 具体内容 | 强度 |
-|------|---------|---------|------|
+每章必须包含:
+- 目标
+- 阻力
+- 代价
+- 时间锚点
+- 章内时间跨度
+- 与上章时间差
+- 倒计时状态
+- 爽点
+- Strand
+- 反派层级
+- 视角/主角
+- 关键实体
+- 本章变化
+- 章末未闭合问题
+- 钩子
 
-## 伏笔规划
-| 章节 | 操作 | 伏笔内容 |
-|------|------|---------|
+输出文件:`大纲/第{volume_id}卷-详细大纲.md`
 
-## 约束触发规划(如有)
-- 反套路规则:每 N 章触发一次
-- 硬约束:贯穿全卷
-```
+### Step 8:把新增设定写回现有设定集
 
-## 6) Generate chapter outlines (batched)
-Batching rule:
-- ≤20 章:1 批
-- 21–40 章:2 批
-- 41–60 章:3 批
-- >60 章:4+ 批
+输入来源:
+- 卷节拍表
+- 卷时间线表
+- 卷详细大纲
+- 现有设定集文件
 
-Optional (only if需要钩子/节奏细分):
-```bash
-cat "${SKILL_ROOT}/../../references/reading-power-taxonomy.md"
-```
+写回规则:
+- 只增量补充相关段落
+- 新角色写入角色卡或角色组
+- 新势力、地点、规则写入世界观或力量体系
+- 新反派层级写入反派设计
 
-Optional (only if需要章节微结构/标题策略细化):
-```bash
-cat "${SKILL_ROOT}/references/outlining/chapter-planning.md"
-```
+硬规则:
+- 若发现与总纲或既有设定冲突,标记 `BLOCKER` 并停止后续更新
 
-### Chapter generation strategy
-For each chapter, determine:
-
-**1. Strand assignment** (follow volume skeleton distribution)
-- Quest: 主线任务推进、目标达成、能力提升
-- Fire: 人物关系、情感冲突、团队动态
-- Constellation: 世界揭示、伏笔埋设、谜团推进
-
-**2. 爽点设计** (based on Strand and position)
-- Quest Strand → 成就爽点(打脸、逆袭、突破)
-- Fire Strand → 情感爽点(认可、保护、告白)
-- Constellation Strand → 认知爽点(真相、预言、身份)
-
-**3. 钩子设计** (based on next chapter's Strand)
-- 悬念钩子:提出问题、制造危机
-- 承诺钩子:预告奖励、暗示转折
-- 情感钩子:关系变化、角色危机
-
-**4. 反派层级** (based on volume skeleton)
-- 无:日常章节、修炼章节、关系章节
-- 小:小冲突、小反派、局部对抗
-- 中:中反派出场、重要冲突、阶段性对抗
-- 大:大反派出场、核心冲突、卷级高潮
-
-**5. 关键实体** (new or important)
-- 新角色:姓名 + 一句话定位
-- 新地点:名称 + 一句话描述
-- 新物品:名称 + 功能
-- 新势力:名称 + 立场
-
-**6. 约束检查** (if idea_bank exists)
-- 是否触发反套路规则?
-- 是否体现硬约束?
-- 是否展现主角缺陷?
-- 是否体现反派镜像?
-
-Chapter format (include 反派层级 for context-agent):
-
-```markdown
-### 第 {N} 章:{标题}
-- 目标: {20字以内}
-- 阻力: {20字以内}
-- 代价: {20字以内}
-- 时间锚点: {末世第X天 时段/仙历X年X月X日/具体日期+时段}
-- 章内时间跨度: {如 3小时/半天/1天}
-- 与上章时间差: {如 紧接/6小时/1天/跨夜}
-- 倒计时状态: {事件A D-3 -> D-2 / 无}
-- 爽点: {类型} - {30字以内}
-- Strand: {Quest|Fire|Constellation}
-- 反派层级: {无/小/中/大}
-- 视角/主角: {主角A/主角B/女主/群像}
-- 关键实体: {新增或重要出场}
-- 本章变化: {30字以内,优先可量化变化}
-- 章末未闭合问题: {30字以内}
-- 钩子: {类型} - {30字以内}
-```
+### Step 9:验证、保存并更新状态
 
-**时间字段说明**:
-- **时间锚点**:本章发生的具体时间点,必须与时间线表一致
-- **章内时间跨度**:本章内容覆盖的时间长度
-- **与上章时间差**:与上一章结束时间的间隔
-  - 紧接:无时间间隔,直接承接
-  - 跨夜:过夜但不超过 12 小时
-  - 具体时长:如 6小时、1天、3天
-- **倒计时状态**:若存在倒计时事件,标注推进情况(D-N → D-(N-1))
-
-**字段说明**:
-- **章末未闭合问题**:本章结尾必须保留的“未闭合决策/问题”,用于驱动读者点下一章。
-  - 规则:必须与 **钩子** 的类型/强度一致;不得出现“钩子很强但问题很虚”的错配。
-- **钩子**:本章应设置的章末钩子(规划用)
-  - 例:悬念钩 - 神秘人身份即将揭晓
-  - 意思是:本章结尾要设置这个悬念钩子
-  - 下章 context-agent 会读取 chapter_meta[N].hook(实际实现的钩子),生成"接住上章"指导
-  - 钩子类型参考:悬念钩 | 危机钩 | 承诺钩 | 情绪钩 | 选择钩 | 渴望钩
-
-Save after each batch:
-```bash
-@'
-{batch_content}
-'@ | Add-Content -Encoding UTF8 "$PROJECT_ROOT/大纲/第{volume_id}卷-详细大纲.md"
-```
+必须通过以下检查:
+- 节拍表存在且非空
+- 时间线表存在且非空
+- 详细大纲存在且非空
+- 每章时间字段齐全
+- 时间线单调递增
+- 倒计时推进正确
+- 新设定已回写到现有设定集
+- `BLOCKER=0`
 
-## 7) Enrich existing setting files from volume outline
-目标:卷纲写完后,把本卷新增事实写回“现有设定集文件”,确保后续写作可直接读取。
+更新状态:
 
-输入来源:
-- `大纲/第{volume_id}卷-节拍表.md`
-- `大纲/第{volume_id}卷-详细大纲.md`
-- 现有设定集文件(世界观/力量体系/主角卡/主角组/女主卡/反派设计)
-
-写回策略(必须):
-- 仅增量补充相关段落,不覆盖整文件。
-- 新增角色:写入对应角色卡或角色组条目(含首次出场章、关系、红线)。
-- 新增势力/地点/规则:写入世界观或力量体系对应章节。
-- 新增反派层级信息:写入反派设计并保持小/中/大层级一致。
-
-冲突处理(硬规则):
-- 若卷纲新增信息与总纲或已确认设定冲突,标记 `BLOCKER` 并停止 state 更新。
-- 只有冲突裁决完成后,才允许继续更新设定并进入保存步骤。
-
-## 8) Validate + save
-### Validation checks (must pass all)
-
-**1. 爽点密度检查**
-- 每章 ≥1 小爽点(强度 2-3)
-- 每 5-8 章至少 1 个关键章节(强度 4-5)
-- 每卷至少 1 个高潮章节(强度 6-7)
-
-**2. Strand 比例检查**
-Count chapters by Strand and compare with genre profile:
-- Quest: 应占 55-65%
-- Fire: 应占 20-30%
-- Constellation: 应占 10-20%
-
-If deviation > 15%, adjust chapter assignments.
-
-**3. 总纲一致性检查**
-- 卷核心冲突是否贯穿章节?
-- 卷末高潮是否在最后 3-5 章体现?
-- 关键人物是否按计划登场?
-
-**4. 约束触发频率检查** (if idea_bank exists)
-- 反套路规则触发次数 ≥ 总章数 / N(N = max(5, 总章数/10))
-- 硬约束在至少 50% 章节中体现
-- 主角缺陷至少 2 次成为冲突来源
-- 反派镜像在反派出场章节中体现
-
-**5. 完整性检查**
-Every chapter must have:
-- 目标(20 字以内)
-- 阻力(20 字以内)
-- 代价(20 字以内)
-- 时间锚点(必填)
-- 章内时间跨度(必填)
-- 与上章时间差(必填)
-- 倒计时状态(若有倒计时事件则必填)
-- 爽点(类型 + 30 字描述)
-- Strand(Quest/Fire/Constellation)
-- 反派层级(无/小/中/大)
-- 视角/主角
-- 关键实体(至少 1 个)
-- 本章变化(30 字以内)
-- 章末未闭合问题(30 字以内)
-- 钩子(类型 + 30 字描述)
-
-**6. 时间线一致性检查(新增)**
-- 时间线表文件存在:`大纲/第{volume_id}卷-时间线.md`
-- 所有章节时间锚点已填写
-- 时间单调递增(不得回跳,除非明确标注为闪回)
-- 倒计时推进正确(D-5 → D-4 → D-3,不得跳跃)
-- 大跨度时间跳跃(>3天)必须有过渡章说明或明确标注
-
-**7. 设定补全检查**
-- 本卷涉及的新角色/势力/规则已回写到现有设定集文件
-- 所有新增条目可回溯到本卷章纲章节
-- `BLOCKER` 数量为 0;若 >0,必须先裁决,不得进入 state 更新
-
-Update state (include chapters range):
 ```bash
 python "${SCRIPTS_DIR}/webnovel.py" --project-root "$PROJECT_ROOT" update-state -- \
   --volume-planned {volume_id} \
   --chapters-range "{start}-{end}"
 ```
 
-Final check:
-- 节拍表文件已写入:`大纲/第{volume_id}卷-节拍表.md`
-- 时间线表文件已写入:`大纲/第{volume_id}卷-时间线.md`
-- 章纲文件已写入:`大纲/第{volume_id}卷-详细大纲.md`
-- 设定集已完成基线补齐与本卷增量补充(原文件内可见)
-- 每章包含:目标/阻力/代价/时间锚点/章内时间跨度/与上章时间差/爽点/Strand/反派层级/视角/关键实体/本章变化/章末未闭合问题/钩子
-- 时间线单调递增,倒计时推进正确
-- 与总纲冲突/高潮一致,约束触发频率合理(如有 idea_bank)
-
-### Hard fail conditions (must stop)
-- 节拍表文件不存在或为空
-- 节拍表中段反转缺失(未按“必填/无(理由)”规则填写)
-- **时间线表文件不存在或为空**
-- 章纲文件不存在或为空
-- 任一章节缺少:目标/阻力/代价/时间锚点/章内时间跨度/与上章时间差/爽点/Strand/反派层级/视角/关键实体/本章变化/章末未闭合问题/钩子
-- **任一章节时间字段(时间锚点/章内时间跨度/与上章时间差)缺失**
-- **时间回跳且未标注为闪回**
-- **倒计时算术冲突(如 D-5 直接跳到 D-2)**
-- **重大事件发生时间与前章间隔不足且无合理解释(如末世第1天建帮派)**
+## 硬失败条件
+
+- 节拍表不存在或为空
+- 中段反转缺失且未给出理由
+- 时间线表不存在或为空
+- 详细大纲不存在或为空
+- 任一章节缺少时间字段
+- 时间回跳且未标注闪回
+- 倒计时算术冲突
 - 与总纲核心冲突或卷末高潮明显冲突
-- 设定集基线未补齐,或本卷增量未回写到现有设定集
 - 存在 `BLOCKER` 未裁决
-- 约束触发频率不足(当 idea_bank 启用时)
-
-### Rollback / recovery
-If any hard fail triggers:
-1. Stop and list the failing items.
-2. Re-generate only the failed batch (do not overwrite the whole file).
-3. If the last batch is invalid, remove that batch and rewrite it.
-4. Only update state after Final check passes.
-
-Next steps:
-- 继续规划下一卷 → /webnovel-plan
-- 开始写作 → /webnovel-write
+
+## 恢复规则
+
+1. 只重做失败批次,不覆盖整卷文件。
+2. 最后一个批次无效时,只删除并重写该批次。
+3. 仅在全部验证通过后更新状态。

+ 46 - 150
webnovel-writer/skills/webnovel-resume/SKILL.md

@@ -1,202 +1,98 @@
 ---
 name: webnovel-resume
-description: Recovers interrupted webnovel tasks with precise workflow state tracking. Detects interruption point and provides safe recovery options. Activates when user wants to resume or /webnovel-resume.
+description: 检测中断点并按安全策略恢复小说工作流。
 allowed-tools: Read Bash AskUserQuestion
 ---
 
 # Task Resume Skill
 
-## Project Root Guard(必须先确认)
+## 目标
 
-- Claude Code 的“工作区根目录”不一定等于“书项目根目录”。常见结构:工作区为 `D:\wk\xiaoshuo`,书项目为 `D:\wk\xiaoshuo\凡人资本论`。
-- 必须先解析真实书项目根(必须包含 `.webnovel/state.json`),后续所有读写路径都以该目录为准。
+- 检测真实中断点,禁止凭感觉续写。
+- 让用户基于清晰风险选择恢复策略。
+- 恢复时只做最小清理,不擅自扩写半成品。
+
+## 执行流程
+
+### Step 1:解析项目根目录并加载恢复协议
 
-环境设置(bash 命令执行前):
 ```bash
 export WORKSPACE_ROOT="${CLAUDE_PROJECT_DIR:-$PWD}"
-
-if [ -z "${CLAUDE_PLUGIN_ROOT}" ] || [ ! -d "${CLAUDE_PLUGIN_ROOT}/skills/webnovel-resume" ]; then
-  echo "ERROR: 未设置 CLAUDE_PLUGIN_ROOT 或缺少目录: ${CLAUDE_PLUGIN_ROOT}/skills/webnovel-resume" >&2
-  exit 1
-fi
 export SKILL_ROOT="${CLAUDE_PLUGIN_ROOT}/skills/webnovel-resume"
-
-if [ -z "${CLAUDE_PLUGIN_ROOT}" ] || [ ! -d "${CLAUDE_PLUGIN_ROOT}/scripts" ]; then
-  echo "ERROR: 未设置 CLAUDE_PLUGIN_ROOT 或缺少目录: ${CLAUDE_PLUGIN_ROOT}/scripts" >&2
-  exit 1
-fi
 export SCRIPTS_DIR="${CLAUDE_PLUGIN_ROOT}/scripts"
-
 export PROJECT_ROOT="$(python "${SCRIPTS_DIR}/webnovel.py" --project-root "${WORKSPACE_ROOT}" where)"
-```
-
-## Workflow Checklist
-
-Copy and track progress:
-
-```
-任务恢复进度:
-- [ ] Step 1: 加载恢复协议 (cat "${SKILL_ROOT}/references/workflow-resume.md")
-- [ ] Step 2: 加载数据规范 (cat "${SKILL_ROOT}/references/system-data-flow.md")
-- [ ] Step 3: 确认上下文充足
-- [ ] Step 4: 检测中断状态
-- [ ] Step 5: 展示恢复选项 (AskUserQuestion)
-- [ ] Step 6: 执行恢复
-- [ ] Step 7: 继续任务 (可选)
-```
-
----
-
-## Reference Loading Levels (strict, lazy)
-
-- L0: 不加载任何参考,直到确认存在中断恢复需求。
-- L1: 只加载恢复协议主文件。
-- L2: 仅在数据一致性检查时加载数据规范。
-
-### L1 (minimum)
-- [workflow-resume.md](references/workflow-resume.md)
-
-### L2 (conditional)
-- [system-data-flow.md](references/system-data-flow.md)(仅在需要核对状态字段/恢复策略时)
-
-## Step 1: 加载恢复协议(必须执行)
-
-```bash
 cat "${SKILL_ROOT}/references/workflow-resume.md"
 ```
 
-**核心原则**(读取后应用)
-- **禁止智能续写**: 上下文丢失风险高
-- **必须检测后恢复**: 不猜测中断点
-- **必须用户确认**: 不自动恢复
+核心原则:
+- 禁止智能续写半成品
+- 必须先检测再恢复
+- 必须用户确认后执行
 
-## Step 2: 加载数据规范
+### Step 2:按需加载数据规范
 
 ```bash
 cat "${SKILL_ROOT}/references/system-data-flow.md"
 ```
 
-## Step 3: 确认上下文充足
+要求:
+- 仅在需要核对状态字段、恢复策略或数据一致性时加载
 
-**检查清单**:
-- [ ] 恢复协议已理解
-- [ ] Step 难度分级已知
-- [ ] 状态结构已理解
-- [ ] "删除重来" vs "智能续写" 原则已明确
+### Step 3:确认上下文充足
 
-**如有缺失 → 返回对应 Step**
+必须确认:
+- 已理解恢复协议
+- 已理解状态结构
+- 已明确“删除重来”优先于“智能续写”
 
-## Step 难度分级(来自 workflow-resume.md)
-
-| Step | 难度 | 恢复策略 |
-|------|------|---------|
-| Step 1 | ⭐ | 直接重新执行 |
-| Step 1.5 | ⭐ | 重新设计 |
-| Step 2A | ⭐⭐ | 删除半成品,重新开始 |
-| Step 2B | ⭐⭐ | 继续适配或回到 2A |
-| Step 3 | ⭐⭐⭐ | 用户决定:重审或跳过 |
-| Step 4 | ⭐⭐ | 继续润色或删除重写 |
-| Step 5 | ⭐⭐ | 重新运行(幂等) |
-| Step 6 | ⭐⭐⭐ | 检查暂存区,决定提交/回滚 |
-
-## Step 4: 检测中断状态
+### Step 4:检测中断状态
 
 ```bash
 python "${SCRIPTS_DIR}/webnovel.py" --project-root "$PROJECT_ROOT" workflow detect
 ```
 
-**输出情况**
-- 无中断 → 结束流程,通知用户
-- 检测到中断 → 继续 Step 5
+结果处理:
+- 无中断:直接结束并通知用户
+- 有中断:进入 Step 5
 
-## Step 5: 展示恢复选项(必须执行)
+### Step 5:展示恢复选项并让用户决策
 
-**展示给用户**
-- 任务命令和参数
-- 中断时间已过时长
+必须展示:
+- 原任务命令和参数
+- 中断时间已过时长
 - 已完成步骤
-- 当前中断步骤
+- 当前中断步骤
 - 剩余步骤
-- 恢复选项及风险等级
-
-**示例输出**:
-
-```
-🔴 检测到中断任务:
-
-任务:/webnovel-write 7
-中断位置:Step 2 - 章节内容生成中
+- 恢复选项与风险说明
 
-已完成:
-  ✅ Step 1: 上下文加载
+### Step 6:执行恢复操作
 
-未完成:
-  ⏸️ Step 2: 章节内容(已写1500字)
-  ⏹️ Step 3-7: 未开始
+选项 A:删除半成品并清理工作流状态
 
-恢复选项:
-A) 删除半成品,从Step 1重新开始(推荐)
-B) 回滚到Ch6,放弃Ch7所有进度
-
-请选择(A/B):
-```
-
-## Step 6: 执行恢复
-
-**选项 A - 删除重来**(推荐):
 ```bash
 python "${SCRIPTS_DIR}/webnovel.py" --project-root "$PROJECT_ROOT" workflow cleanup --chapter {N} --confirm
 python "${SCRIPTS_DIR}/webnovel.py" --project-root "$PROJECT_ROOT" workflow clear
 ```
 
-**选项 B - Git 回滚**:
-```bash
-git -C "$PROJECT_ROOT" reset --hard ch{N-1:04d}
-python "${SCRIPTS_DIR}/webnovel.py" --project-root "$PROJECT_ROOT" workflow clear
-```
+选项 B:按既有版本回退,再清理工作流状态
 
-## Step 7: 继续任务(可选)
-
-如用户选择立即继续:
 ```bash
-/{original_command} {original_args}
-```
-
----
-
-## 特殊场景
-
-### Step 6 中断(成本高)
-
-```
-恢复选项:
-A) 重新执行双章审查(成本:~$0.15)⚠️
-B) 跳过审查,继续下一章(可后续补审)
-```
-
-### Step 4 中断(部分状态)
-
-```
-⚠️ state.json 可能部分更新
-
-A) 检查并修复 state.json
-B) 回滚到上一章(安全)
+git -C "$PROJECT_ROOT" rev-parse --verify "ch{N-1:04d}"
+git -C "$PROJECT_ROOT" switch --detach "ch{N-1:04d}"
+python "${SCRIPTS_DIR}/webnovel.py" --project-root "$PROJECT_ROOT" workflow clear
 ```
 
-### 长时间中断(>1小时)
-
-```
-⚠️ 中断已超过1小时
+说明:
+- `workflow cleanup --confirm` 与 Git 回退都属于高风险操作,执行前必须获得用户明确确认。
+- 若用户只是要保留现场排查问题,不应执行上述清理或回退。
 
-上下文丢失风险高
-建议重新开始而非续写
-```
+### Step 7:按用户意愿继续任务
 
----
+若用户要求立即继续,则执行原始命令;若未要求,则仅完成恢复并结束。
 
 ## 禁止事项
 
-- ❌ 智能续写半成品内容
-- ❌ 自动选择恢复策略
-- 跳过中断检测
-- ❌ 不验证就修复 state.json
+- 禁止智能续写半成品
+- 禁止自动替用户选择恢复策略
+- 禁止跳过中断检测
+- 禁止在未验证前修复 `state.json`

+ 38 - 32
webnovel-writer/skills/webnovel-resume/references/workflow-resume.md

@@ -4,7 +4,7 @@ purpose: 任务恢复时加载,指导中断恢复流程
 ---
 
 <context>
-此文件用于中断任务恢复。Claude 已知错误处理流程,这里只补充网文创作工作流特定的 Step 难度分级和恢复策略。
+此文件用于中断任务恢复。Claude 已知通用错误处理流程,这里只补充网文创作工作流的步骤难度分级和恢复策略。
 </context>
 
 <instructions>
@@ -14,60 +14,66 @@ purpose: 任务恢复时加载,指导中断恢复流程
 | Step | 名称 | 影响 | 难度 | 默认策略 |
 |------|------|------|------|----------|
 | Step 1 | Context Agent | 无副作用(仅读取) | ⭐ | 直接重新执行 |
-| Step 1.5 | 章节设计 | 结构未固化 | ⭐ | 重新设计 |
-| Step 2A | 生成粗稿 | 半成品章节文件 | ⭐⭐ | **删除半成品**,从 Step 1 重新开始 |
-| Step 2B | 风格适配 | 部分改写内容 | ⭐⭐ | 继续适配或回到 2A |
+| Step 2A | 生成粗稿 | 半成品章节文件 | ⭐⭐ | 删除半成品,从 Step 1 重新开始 |
+| Step 2B | 风格适配 | 部分改写内容 | ⭐⭐ | 继续适配或回到 Step 2A |
 | Step 3 | 审查 | 审查未完成 | ⭐⭐⭐ | 用户决定:重审或跳过 |
 | Step 4 | 网文化润色 | 部分润色的文件 | ⭐⭐ | 继续润色或删除重写 |
-| Step 5 | Data Agent | 实体未提取完 | ⭐⭐ | 重新运行(幂等) |
-| Step 6 | Git 备份 | 未提交 | ⭐⭐⭐ | 检查暂存区,决定提交/回滚 |
+| Step 5 | Data Agent | 实体、摘要、长期记忆未写完 | ⭐⭐ | 重新运行(幂等) |
+| Step 6 | Git 备份 | 未提交 | ⭐⭐⭐ | 检查暂存区,决定提交或保留现场 |
 
 ## 恢复流程
 
-### Phase 1: 检测中断状态
+### Step 1:检测中断状态
 
 ```bash
 python "${SCRIPTS_DIR}/webnovel.py" --project-root "$PROJECT_ROOT" workflow detect
 ```
 
-### Phase 2: 询问用户
+### Step 2:询问用户
 
-**必须展示**:
+必须展示:
 - 任务命令和参数
 - 中断时间和位置
 - 已完成步骤
 - 恢复选项和风险等级
 
-### Phase 3: 执行恢复
+### Step 3:执行恢复
+
+选项 A:删除半成品后重新开始
 
-**选项 A(推荐)**: 删除半成品重新开始
 ```bash
 python "${SCRIPTS_DIR}/webnovel.py" --project-root "$PROJECT_ROOT" workflow cleanup --chapter {N} --confirm
 python "${SCRIPTS_DIR}/webnovel.py" --project-root "$PROJECT_ROOT" workflow clear
 /webnovel-write {N}
 ```
 
-**选项 B**: 回滚到上一章
+选项 B:切到上一稳定标签,再清理中断状态
+
 ```bash
-git reset --hard ch{N-1:04d}
+git -C "$PROJECT_ROOT" rev-parse --verify "ch{N-1:04d}"
+git -C "$PROJECT_ROOT" switch --detach "ch{N-1:04d}"
 python "${SCRIPTS_DIR}/webnovel.py" --project-root "$PROJECT_ROOT" workflow clear
 ```
 
-## 为什么删除而不续写?
+硬规则:
+- `workflow cleanup --confirm` 与 Git 切换都属于高风险操作,执行前必须获得用户明确确认。
+- 若用户要保留现场排查问题,禁止执行清理或回退。
+
+## 为什么删除而不续写
 
-1. **质量保证**: 半成品可能包含未完成句子、逻辑断裂
-2. **上下文丢失**: 新会话无法记住之前创作思路
-3. **防幻觉**: 续写容易产生前后矛盾
-4. **成本可控**: 重新生成 < 修复半成品 + 审查
+1. 半成品可能包含未完成句子、逻辑断裂
+2. 新会话缺失创作时的隐含上下文。
+3. 直接续写更容易引入前后矛盾。
+4. 重新生成的成本通常低于修半成品加重审。
 
 ## 特殊场景
 
 | 场景 | 策略 |
 |------|------|
-| 超过24小时 | 推荐回滚(上下文完全丢失) |
-| 超过1小时 | 推荐重新开始 |
-| 多个半成品 | 删除所有,从稳定状态重新开始 |
-| workflow_state.json不存在 | 启发式检测 state.json vs 文件 |
+| 超过 24 小时 | 推荐重新开始 |
+| 超过 1 小时 | 默认删除半成品后重来 |
+| 多个半成品 | 删除所有半成品,从稳定状态重新开始 |
+| workflow_state.json 不存在 | 启发式检测 `state.json` 与正文文件 |
 
 </instructions>
 
@@ -76,7 +82,7 @@ python "${SCRIPTS_DIR}/webnovel.py" --project-root "$PROJECT_ROOT" workflow clea
 <example>
 <input>Step 2A 中断(最常见)</input>
 <output>
-🔴 检测到中断任务:
+检测到中断任务:
 
 任务:/webnovel-write 7
 中断位置:Step 2A - 章节内容生成中
@@ -86,11 +92,11 @@ python "${SCRIPTS_DIR}/webnovel.py" --project-root "$PROJECT_ROOT" workflow clea
 
 未完成:
   ⏸️ Step 2A: 生成章节内容(已写1500字)
-  ⏹️ Step 3-6: 未开始
+  ⏹️ Step 2B-6: 未开始
 
 恢复选项:
-A) 删除半成品,从 Step 1 重新开始(推荐)
-B) 回滚到 Ch6,放弃 Ch7 所有进度
+A) 删除半成品,从 Step 1 重新开始(推荐)
+B) 切到 Ch6 稳定标签,放弃 Ch7 当前进度
 
 请选择(A/B):
 </output>
@@ -100,12 +106,12 @@ B) 回滚到 Ch6,放弃 Ch7 所有进度
 <input>Step 3 中断(审查)</input>
 <output>
 恢复选项:
-A) 重新执行审查 ⚠️
-   - 调用6个审查员
+A) 重新执行审查
+   - 调用审查员
    - 生成审查报告
    - 继续 Step 4 润色
 
-B) 跳过审查,直接润色(推荐)
+B) 跳过审查,直接润色
    - 不生成审查报告
    - 可后续用 /webnovel-review 补审
 
@@ -117,7 +123,7 @@ B) 跳过审查,直接润色(推荐)
 <input>Step 4 中断(润色)</input>
 <output>
 恢复选项:
-A) 继续润色(推荐)⭐
+A) 继续润色
    - 打开并继续润色实际章节文件(优先 `正文/第0007章-章节标题.md`)
    - 保存文件
    - 继续 Step 5(Data Agent)
@@ -135,6 +141,6 @@ B) 删除润色稿,从 Step 2A 重写
 <errors>
 ❌ 智能续写半成品 → ✅ 删除后重新生成
 ❌ 自动决定恢复策略 → ✅ 必须用户确认
-❌ 跳过中断检测 → ✅ 先运行 workflow_manager.py detect
-❌ 修复 state.json 不验证 → ✅ 逐字段检查一致性
+❌ 跳过中断检测 → ✅ 先运行 workflow detect
+❌ 不确认就做高风险清理 → ✅ 先获得用户明确确认
 </errors>

+ 113 - 111
webnovel-writer/skills/webnovel-review/SKILL.md

@@ -1,194 +1,196 @@
 ---
 name: webnovel-review
-description: Reviews chapter quality with checker agents and generates reports. Use when the user asks for a chapter review or runs /webnovel-review.
+description: 使用审查 Agent 评估章节质量,生成报告并写回审查指标。
 allowed-tools: Read Grep Write Edit Bash Task AskUserQuestion
 ---
 
 # Quality Review Skill
 
-## Project Root Guard(必须先确认)
+## 目标
 
-- Claude Code 的“工作区根目录”不一定等于“书项目根目录”。常见结构:工作区为 `D:\wk\xiaoshuo`,书项目为 `D:\wk\xiaoshuo\凡人资本论`。
-- 必须先解析真实书项目根(必须包含 `.webnovel/state.json`),后续所有读写路径都以该目录为准。
+- 解析真实书项目根目录,按统一流程完成章节审查。
+- 调用审查 Agent 生成结构化问题列表、综合评分与审查报告。
+- 把审查指标写入 `index.db`,并把审查记录写回 `state.json`。
+- 若存在关键问题,明确交给用户决定是否立即返工。
+
+## 执行流程
+
+### Step 1:解析项目根目录并建立环境变量
 
-环境设置(bash 命令执行前):
 ```bash
 export WORKSPACE_ROOT="${CLAUDE_PROJECT_DIR:-$PWD}"
-
-if [ -z "${CLAUDE_PLUGIN_ROOT}" ] || [ ! -d "${CLAUDE_PLUGIN_ROOT}/skills/webnovel-review" ]; then
-  echo "ERROR: 未设置 CLAUDE_PLUGIN_ROOT 或缺少目录: ${CLAUDE_PLUGIN_ROOT}/skills/webnovel-review" >&2
-  exit 1
-fi
 export SKILL_ROOT="${CLAUDE_PLUGIN_ROOT}/skills/webnovel-review"
-
-if [ -z "${CLAUDE_PLUGIN_ROOT}" ] || [ ! -d "${CLAUDE_PLUGIN_ROOT}/scripts" ]; then
-  echo "ERROR: 未设置 CLAUDE_PLUGIN_ROOT 或缺少目录: ${CLAUDE_PLUGIN_ROOT}/scripts" >&2
-  exit 1
-fi
 export SCRIPTS_DIR="${CLAUDE_PLUGIN_ROOT}/scripts"
-
 export PROJECT_ROOT="$(python "${SCRIPTS_DIR}/webnovel.py" --project-root "${WORKSPACE_ROOT}" where)"
 ```
 
-## 0.5 工作流断点(best-effort,不得阻断主流程)
+要求:
+- `PROJECT_ROOT` 必须包含 `.webnovel/state.json`
+- 任一关键目录不存在时立即阻断
 
-> 目标:让 `/webnovel-resume` 能基于真实断点恢复。即使 workflow_manager 出错,也**只记录警告**,审查继续。
+### Step 2:记录工作流断点(best-effort)
 
-推荐(bash):
 ```bash
 python "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" workflow start-task --command webnovel-review --chapter {end} || true
+python "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" workflow start-step --step-id "Step 1" --step-name "解析项目根目录" || true
+python "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" workflow complete-step --step-id "Step 1" --artifacts '{"project_root_ready":true}' || true
 ```
 
-Step 映射(必须与 `workflow_manager.py get_pending_steps("webnovel-review")` 对齐):
-- Step 1:加载参考
-- Step 2:加载项目状态
-- Step 3:并行调用检查员
-- Step 4:生成审查报告
-- Step 5:保存审查指标到 index.db
-- Step 6:写回审查记录到 state.json
-- Step 7:处理关键问题(AskUserQuestion)
-- Step 8:收尾(完成任务)
+要求:
+- 记录失败只记警告,不阻断主流程
+
+### Step 3:按需加载参考资料
 
-Step 记录模板(bash,失败不阻断):
 ```bash
-python "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" workflow start-step --step-id "Step 1" --step-name "加载参考" || true
-python "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" workflow complete-step --step-id "Step 1" --artifacts '{"ok":true}' || true
+python "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" workflow start-step --step-id "Step 2" --step-name "加载参考" || true
 ```
 
-## Review depth
+必读:
 
-- **Core (default)**: consistency / continuity / ooc / reader-pull
-- **Full (关键章/用户要求)**: core + high-point + pacing
+```bash
+cat "${SKILL_ROOT}/../../references/shared/core-constraints.md"
+```
 
-## Step 1: 加载参考(按需)
+按需加载:
 
-## References(按步骤导航)
+```bash
+cat "${SKILL_ROOT}/../../references/shared/cool-points-guide.md"
+cat "${SKILL_ROOT}/../../references/shared/strand-weave-pattern.md"
+cat "${SKILL_ROOT}/references/common-mistakes.md"
+cat "${SKILL_ROOT}/references/pacing-control.md"
+```
 
-- Step 1(必读,硬约束):[core-constraints.md](../../references/shared/core-constraints.md)
-- Step 1(可选,Full 或节奏/爽点相关问题):[cool-points-guide.md](../../references/shared/cool-points-guide.md)
-- Step 1(可选,Full 或节奏/爽点相关问题):[strand-weave-pattern.md](../../references/shared/strand-weave-pattern.md)
-- Step 1(可选,仅在返工建议需要时):[common-mistakes.md](references/common-mistakes.md)
-- Step 1(可选,仅在返工建议需要时):[pacing-control.md](references/pacing-control.md)
+规则:
+- 先判定 Core 或 Full 审查深度,再加载对应参考
+- 不得在未触发时一次性读完全部资料
 
-## Reference Loading Levels (strict, lazy)
+```bash
+python "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" workflow complete-step --step-id "Step 2" --artifacts '{"references_loaded":true}' || true
+```
 
-- L0: 先确定审查深度(Core / Full),再加载参考。
-- L1: 只加载 References 区的“必读”条目。
-- L2: 仅在问题定位需要时加载 References 区的“可选”条目。
+### Step 4:加载项目状态与待审正文
 
-**必读**:
 ```bash
-cat "${SKILL_ROOT}/../../references/shared/core-constraints.md"
+python "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" workflow start-step --step-id "Step 3" --step-name "加载项目状态" || true
 ```
 
-**建议(Full 或需要时)**:
 ```bash
-cat "${SKILL_ROOT}/../../references/shared/cool-points-guide.md"
-cat "${SKILL_ROOT}/../../references/shared/strand-weave-pattern.md"
+cat "${PROJECT_ROOT}/.webnovel/state.json"
 ```
 
-**可选**:
+要求:
+- 明确当前章节范围与对应正文文件
+- 若缺少正文或状态文件,立即阻断
+
 ```bash
-cat "${SKILL_ROOT}/references/common-mistakes.md"
-cat "${SKILL_ROOT}/references/pacing-control.md"
+python "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" workflow complete-step --step-id "Step 3" --artifacts '{"review_input_ready":true}' || true
 ```
 
-## Step 2: 加载项目状态(若存在)
+### Step 5:并行调用检查员并汇总结果
 
 ```bash
-cat "$PROJECT_ROOT/.webnovel/state.json"
+python "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" workflow start-step --step-id "Step 4" --step-name "并行调用检查员" || true
 ```
 
-## Step 3: 并行调用检查员(Task)
-
-**调用约束**:
-- 必须通过 `Task` 工具调用审查 subagent,禁止主流程直接内联审查结论。
-- 各 subagent 结果全部返回后再生成总评与优先级。
+必须通过 `Task` 调用审查子代理,禁止主流程伪造结论。
 
-**Core**:
+Core:
 - `consistency-checker`
 - `continuity-checker`
 - `ooc-checker`
 - `reader-pull-checker`
 
-**Full 追加**:
+Full 追加:
 - `high-point-checker`
 - `pacing-checker`
 
-## Step 4: 生成审查报告
+要求:
+- 所有子代理结果返回后,统一汇总 `issues`、`severity`、`overall_score`
 
-保存到:`审查报告/第{start}-{end}章审查报告.md`
+```bash
+python "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" workflow complete-step --step-id "Step 4" --artifacts '{"review_completed":true}' || true
+```
 
-**报告结构(精简版)**:
-```markdown
-# 第 {start}-{end} 章质量审查报告
+### Step 6:生成审查报告与审查指标 JSON
 
-## 综合评分
-- 爽点密度 / 设定一致性 / 节奏控制 / 人物塑造 / 连贯性 / 追读力
-- 总评与等级
+```bash
+python "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" workflow start-step --step-id "Step 5" --step-name "生成审查报告" || true
+```
 
-## 修改优先级
-- 🔴 高优先级(必须修改)
-- 🟠 中优先级(建议修改)
-- 🟡 低优先级(可选优化)
+报告保存到:`审查报告/第{start}-{end}章审查报告.md`
 
-## 改进建议
-- 可执行的修复建议
-```
+报告结构:
+- 综合评分
+- 修改优先级
+- 改进建议
+
+审查指标 JSON 必须包含:
+- `start_chapter`
+- `end_chapter`
+- `overall_score`
+- `dimension_scores`
+- `severity_counts`
+- `critical_issues`
+- `report_file`
+- `notes`
 
-**审查指标 JSON(用于趋势统计)**:
-```json
-{
-  "start_chapter": {start},
-  "end_chapter": {end},
-  "overall_score": 48,
-  "dimension_scores": {
-    "爽点密度": 8,
-    "设定一致性": 7,
-    "节奏控制": 7,
-    "人物塑造": 8,
-    "连贯性": 9,
-    "追读力": 9
-  },
-  "severity_counts": {"critical": 1, "high": 2, "medium": 3, "low": 1},
-  "critical_issues": ["设定自相矛盾"],
-  "report_file": "审查报告/第{start}-{end}章审查报告.md",
-  "notes": ""
-}
+```bash
+python "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" workflow complete-step --step-id "Step 5" --artifacts '{"report_generated":true}' || true
 ```
 
-注意:此处只生成审查指标 JSON;落库见 Step 5。
+### Step 7:写入 index.db 与 state.json
 
-## Step 5: 保存审查指标到 index.db(必做)
+```bash
+python "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" workflow start-step --step-id "Step 6" --step-name "写入审查指标" || true
+```
+
+保存审查指标:
 
 ```bash
 python "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" index save-review-metrics --data '@review_metrics.json'
 ```
 
-## Step 6: 写回审查记录到 state.json(必做)
+写回审查记录:
 
-将审查报告记录写回 `state.json.review_checkpoints`,用于后续追踪与回溯(依赖 `update_state.py --add-review`):
 ```bash
 python "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" update-state -- --add-review "{start}-{end}" "审查报告/第{start}-{end}章审查报告.md"
 ```
 
-## Step 7: 处理关键问题
+```bash
+python "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" workflow complete-step --step-id "Step 6" --artifacts '{"review_metrics_saved":true}' || true
+python "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" workflow start-step --step-id "Step 7" --step-name "写回审查记录" || true
+python "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" workflow complete-step --step-id "Step 7" --artifacts '{"review_checkpoint_saved":true}' || true
+```
+
+### Step 8:处理关键问题并收尾
 
-如发现 critical 问题(`severity_counts.critical > 0` 或 `critical_issues` 非空),**必须使用 AskUserQuestion** 询问用户:
-- A) 立即修复(推荐)
-- B) 仅保存报告,稍后处理
+```bash
+python "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" workflow start-step --step-id "Step 8" --step-name "处理关键问题并收尾" || true
+```
+
+如存在 `critical` 问题,必须使用 `AskUserQuestion` 询问用户:
+- 立即修复
+- 仅保存报告,稍后处理
 
-若用户选择 A:
-- 输出“返工清单”(逐条 critical 问题 → 定位 → 最小修复动作 → 注意事项)
-- 如用户明确授权可直接修改正文文件,则用 `Edit` 对对应章节文件做最小修复,并建议重新运行一次 `/webnovel-review` 验证
+若用户选择立即修复
+- 输出返工清单
+- 在用户明确授权下做最小修改
 
-若用户选择 B
-- 不做正文修改,仅保留审查报告与指标记录,结束本次审查
+若用户选择稍后处理
+- 保留报告与指标记录,结束流程
 
-## Step 8: 收尾(完成任务)
+收尾:
 
 ```bash
-python "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" workflow start-step --step-id "Step 8" --step-name "收尾" || true
 python "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" workflow complete-step --step-id "Step 8" --artifacts '{"ok":true}' || true
 python "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" workflow complete-task --artifacts '{"ok":true}' || true
 ```
+
+## 成功标准
+
+1. 已解析真实书项目根目录。
+2. 已完成至少 Core 审查深度。
+3. 审查报告已生成。
+4. `review_metrics` 已写入 `index.db`。
+5. 审查记录已写回 `state.json`。
+6. 如存在关键问题,用户已明确选择处理策略。

+ 194 - 216
webnovel-writer/skills/webnovel-write/SKILL.md

@@ -1,127 +1,99 @@
 ---
 name: webnovel-write
-description: Writes webnovel chapters (default 2000-2500 words). Use when the user asks to write a chapter or runs /webnovel-write. Runs context, drafting, review, polish, and data extraction.
+description: 产出可发布章节,完整执行上下文、起草、审查、润色、数据回写与备份。
 allowed-tools: Read Write Edit Grep Bash Task
 ---
 
-# Chapter Writing (Structured Workflow)
+# Chapter Writing(结构化写作流程)
 
 ## 目标
 
-- 以稳定流程产出可发布章节:优先使用 `正文/第{NNNN}章-{title_safe}.md`,无标题时回退 `正文/第{NNNN}章.md`。
-- 默认章节字数目标:2000-2500(用户或大纲明确覆盖时从其约定)
-- 保证审查、润色、数据回写完整闭环,避免“写完即丢上下文”
-- 输出直接可被后续章节消费的结构化数据:`review_metrics`、`summaries`、`chapter_meta`
+- 产出可发布章节,优先写入 `正文/第{NNNN}章-{title_safe}.md`,无标题时回退 `正文/第{NNNN}章.md`。
+- 默认目标字数 2000-2500;若用户或大纲另有要求,以用户和大纲为准
+- 保证审查、润色、数据回写、长期记忆提取全部闭环
+- 输出内容必须能被下一章直接消费。
 
 ## 执行原则
 
-1. 先校验输入完整性,再进入写作流程;缺关键输入时立即阻断
-2. 审查与数据回写是硬步骤,`--fast`/`--minimal` 只允许降级可选环节。
-3. 参考资料严格按步骤按需加载,不一次性灌入全部文档。
-4. Step 2B 与 Step 4 职责分离:2B 只做风格转译,4 只做问题修复与质控
-5. 任一步失败优先做最小回滚,不重跑全流程
+1. 先校验输入,再进入写作主链
+2. 审查与数据回写是硬步骤,`--fast` 与 `--minimal` 只允许裁剪可选环节。
+3. 参考资料按步骤按需加载,不一次性灌入全部文档。
+4. Step 4 只做问题修复与终检,不回写结构化数据
+5. 任一步失败优先最小补跑,不重跑整条链路
 
 ## 模式定义
 
-- `/webnovel-write`:Step 1 → 2A → 2B → 3 → 4 → 5 → 6
-- `/webnovel-write --fast`:Step 1 → 2A → 3 → 4 → 5 → 6(跳过 2B)
-- `/webnovel-write --minimal`:Step 1 → 2A → 3(仅3个基础审查)→ 4 → 5 → 6
+- `/webnovel-write`:Step 1 → Step 2A → Step 2B → Step 3 → Step 4 → Step 5 → Step 6
+- `/webnovel-write --fast`:Step 1 → Step 2A → Step 3 → Step 4 → Step 5 → Step 6
+- `/webnovel-write --minimal`:Step 1 → Step 2A → Step 2B → Step 3(仅核心 3 个审查)→ Step 4 → Step 5 → Step 6
 
-最小产物(所有模式)
-- `正文/第{NNNN}章-{title_safe}.md` 或 `正文/第{NNNN}章.md`
-- `index.db.review_metrics` 新纪录(含 `overall_score`)
+最小产物:
+- 章节正文文件
+- `index.db.review_metrics` 新记录
 - `.webnovel/summaries/ch{NNNN}.md`
-- `.webnovel/state.json` 的进度与 `chapter_meta` 更新
+- `.webnovel/state.json` 的进度与 `chapter_meta`
+- `.webnovel/memory_scratchpad.json` 的长期记忆事实
 
-### 流程硬约束(禁止事项)
+## 流程硬约束
 
-- **禁止并步**:不得将两个 Step 合并为一个动作执行(如同时做 2A 和 3)
-- **禁止跳步**:不得跳过未被模式定义标记为可跳过的 Step。
-- **禁止临时改名**:不得将 Step 的输出产物改写为非标准文件名或格式
-- **禁止自创模式**:`--fast` / `--minimal` 只允许按上方定义裁剪步骤,不允许自创混合模式、"半步"或"简化版"
-- **禁止自审替代**:Step 3 审查必须由 Task 子代理执行,主流程不得内联伪造审查结论
-- **禁止源码探测**:脚本调用方式以本文档与 data-agent 文档中的命令示例为准,命令失败时查日志定位问题,不去翻源码学习调用方式
+- 禁止并步:不得把两个 Step 合并执行
+- 禁止跳步:除模式定义明确允许外,不得跳过任何 Step。
+- 禁止改名:标准产物文件名和格式不得私自改写
+- 禁止伪造审查:Step 3 必须由 Task 子代理执行
+- 禁止源码探测:CLI 调用方式以本文档和 agent 文档为准,命令失败优先查日志
+- Workflow step-id 必须使用实现侧真实编号:`Step 1`、`Step 2A`、`Step 2B`、`Step 3`、`Step 4`、`Step 5`、`Step 6`
 
-## 引用加载等级(strict, lazy)
+## 引用加载等级
 
-- L0:未进入对应步骤前,不加载任何参考文件
-- L1:每步仅加载该步“必读”文件。
-- L2:仅在触发条件满足时加载“条件必读/可选”文件
+- L0:未进入对应步骤前,不加载参考资料
+- L1:只加载当前步骤必读文件。
+- L2:仅在触发条件满足时加载条件参考
 
-路径约定:
-- `references/...` 相对当前 skill 目录。
-- `../../references/...` 指向全局共享参考。
-
-## References(逐文件引用清单)
-
-### 根目录
+## References
 
 - `references/step-3-review-gate.md`
-  - 用途:Step 3 审查调用模板、汇总格式、落库 JSON 规范。
-  - 触发:Step 3 必读。
+  - 用途:Step 3 审查调用模板与落库规范。
 - `references/step-5-debt-switch.md`
-  - 用途:Step 5 债务利息开关规则(默认关闭)。
-  - 触发:Step 5 必读。
+  - 用途:Step 5 债务利息开关规则。
 - `../../references/shared/core-constraints.md`
-  - 用途:Step 2A 写作硬约束(大纲即法律 / 设定即物理 / 发明需识别)。
-  - 触发:Step 2A 必读。
+  - 用途:Step 2A 起草硬约束。
 - `references/polish-guide.md`
-  - 用途:Step 4 问题修复、Anti-AI 与 No-Poison 规则。
-  - 触发:Step 4 必读。
+  - 用途:Step 4 润色与终检规则。
 - `references/writing/typesetting.md`
-  - 用途:Step 4 移动端阅读排版与发布前速查。
-  - 触发:Step 4 必读。
+  - 用途:Step 4 排版检查。
 - `references/style-adapter.md`
-  - 用途:Step 2B 风格转译规则,不改剧情事实。
-  - 触发:Step 2B 执行时必读(`--fast`/`--minimal` 跳过)。
+  - 用途:Step 2B 风格适配规则。
 - `references/style-variants.md`
-  - 用途:Step 1(内置 Contract)开头/钩子/节奏变体与重复风险控制。
-  - 触发:Step 1 当需要做差异化设计时加载。
+  - 用途:Step 1 差异化设计。
 - `../../references/reading-power-taxonomy.md`
-  - 用途:Step 1(内置 Contract)钩子、爽点、微兑现 taxonomy。
-  - 触发:Step 1 当需要追读力设计时加载。
+  - 用途:Step 1 追读力设计。
 - `../../references/genre-profiles.md`
-  - 用途:Step 1(内置 Contract)按题材配置节奏阈值与钩子偏好。
-  - 触发:Step 1 当 `state.project.genre` 已知时加载。
+  - 用途:Step 1 题材节奏与钩子偏好。
 - `references/writing/genre-hook-payoff-library.md`
-  - 用途:电竞/直播文/克苏鲁的钩子与微兑现快速库。
-  - 触发:Step 1 题材命中 `esports/livestream/cosmic-horror` 时必读。
-
-### writing(问题定向加读)
+  - 用途:Step 1 特定题材快速库。
 
+问题定向参考:
 - `references/writing/combat-scenes.md`
-  - 触发:战斗章或审查命中“战斗可读性/镜头混乱”。
 - `references/writing/dialogue-writing.md`
-  - 触发:审查命中 OOC、对话说明书化、对白辨识差。
 - `references/writing/emotion-psychology.md`
-  - 触发:情绪转折生硬、动机断层、共情弱。
 - `references/writing/scene-description.md`
-  - 触发:场景空泛、空间方位不清、切场突兀。
 - `references/writing/desire-description.md`
-  - 触发:主角目标弱、欲望驱动力不足。
 
-## 工具策略(按需)
+## 工具策略
 
-- `Read/Grep`:读取 `state.json`、大纲、章节正文与参考文件
-- `Bash`:运行 `extract_chapter_context.py`、`index_manager`、`workflow_manager`
-- `Task`:调用 `context-agent`、审查 subagent、`data-agent` 并行执行
+- `Read/Grep`:读取大纲、状态、正文与参考资料
+- `Bash`:运行 `webnovel.py` 与相关脚本
+- `Task`:调用 `context-agent`、审查器与 `data-agent`。
 
-## 交互流程
+## 执行流程
 
-### Step 0:预检与上下文最小加载
+### 准备阶段:预检与环境准备
 
-必须做:
-- 解析真实书项目根(book project_root):必须包含 `.webnovel/state.json`。
-- 校验核心输入:`大纲/总纲.md`、`${CLAUDE_PLUGIN_ROOT}/scripts/extract_chapter_context.py` 存在。
-- 规范化变量:
-  - `WORKSPACE_ROOT`:Claude Code 打开的工作区根目录(可能是书项目的父目录,例如 `D:\wk\xiaoshuo`)
-  - `PROJECT_ROOT`:真实书项目根目录(必须包含 `.webnovel/state.json`,例如 `D:\wk\xiaoshuo\凡人资本论`)
-  - `SKILL_ROOT`:skill 所在目录(固定 `${CLAUDE_PLUGIN_ROOT}/skills/webnovel-write`)
-  - `SCRIPTS_DIR`:脚本目录(固定 `${CLAUDE_PLUGIN_ROOT}/scripts`)
-  - `chapter_num`:当前章号(整数)
-  - `chapter_padded`:四位章号(如 `0007`)
+必须完成:
+- 解析真实书项目根,必须包含 `.webnovel/state.json`
+- 校验核心输入:`大纲/总纲.md`、`${CLAUDE_PLUGIN_ROOT}/scripts/extract_chapter_context.py`
+- 规范化变量:`WORKSPACE_ROOT`、`PROJECT_ROOT`、`SKILL_ROOT`、`SCRIPTS_DIR`、`chapter_num`、`chapter_padded`
 
-环境设置(bash 命令执行前):
 ```bash
 export WORKSPACE_ROOT="${CLAUDE_PROJECT_DIR:-$PWD}"
 export SCRIPTS_DIR="${CLAUDE_PLUGIN_ROOT:?CLAUDE_PLUGIN_ROOT is required}/scripts"
@@ -131,250 +103,256 @@ python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "${WORKSPACE_ROOT}" p
 export PROJECT_ROOT="$(python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "${WORKSPACE_ROOT}" where)"
 ```
 
-**硬门槛**:`preflight` 必须成功。它统一校验 `CLAUDE_PLUGIN_ROOT` 派生出的 `SKILL_ROOT` / `SCRIPTS_DIR`、`webnovel.py`、`extract_chapter_context.py` 和解析出的 `PROJECT_ROOT`。任一失败都立即阻断。
+硬门槛:
+- `preflight` 必须成功。
+- 任一核心输入缺失立即阻断。
 
-输出:
-- “已就绪输入”与“缺失输入”清单;缺失则阻断并提示先补齐。
-
-### Step 0.5:工作流断点记录(best-effort,不阻断)
+任务记录:
 
 ```bash
 python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" workflow start-task --command webnovel-write --chapter {chapter_num} || true
-python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" workflow start-step --step-id "Step 1" --step-name "Context Agent" || true
-python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" workflow complete-step --step-id "Step 1" --artifacts '{"ok":true}' || true
-python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" workflow complete-task --artifacts '{"ok":true}' || true
 ```
 
-要求:
-- `--step-id` 仅允许:`Step 1` / `Step 2A` / `Step 2B` / `Step 3` / `Step 4` / `Step 5` / `Step 6`。
-- 任何记录失败只记警告,不阻断写作。
-- 每个 Step 执行结束后,同样需要 `complete-step`(失败不阻断)。
+### Step 1:调用 Context Agent 生成执行包
 
-### Step 1:Context Agent(内置 Context Contract,生成直写执行包)
+记录开始:
 
-使用 Task 调用 `context-agent`,参数:
+```bash
+python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" workflow start-step --step-id "Step 1" --step-name "Context Agent" || true
+```
+
+使用 Task 调用 `context-agent`,输入:
 - `chapter`
 - `project_root`
 - `storage_path=.webnovel/`
 - `state_file=.webnovel/state.json`
 
 硬要求:
-- 若 `state` 或大纲不可用,立即阻断并返回缺失项。
-- 输出必须同时包含:
-  - 7 板块任务书(目标/冲突/承接/角色/场景约束/伏笔/追读力);
-  - Context Contract 全字段(目标/阻力/代价/本章变化/未闭合问题/开头类型/情绪节奏/信息密度/过渡章判定/追读力设计);
-  - Step 2A 可直接消费的“写作执行包”(章节节拍、不可变事实清单、禁止事项、终检清单)。
-- 合同与任务书出现冲突时,以“大纲与设定约束更严格者”为准。
+- 输出必须包含任务书、Context Contract、Step 2A 直写提示词。
+- 执行包中必须纳入长期记忆约束与时间约束。
+
+记录完成:
 
-输出:
-- 单一“创作执行包”(任务书 + Context Contract + 直写提示词),供 Step 2A 直接消费,不再拆分独立 Step 1.5。
+```bash
+python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" workflow complete-step --step-id "Step 1" --artifacts '{"context_package":true}' || true
+```
 
-### Step 2A:正文起草
+### Step 2A:起草正文
 
 执行前必须加载:
+
 ```bash
 cat "${SKILL_ROOT}/../../references/shared/core-constraints.md"
+python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" workflow start-step --step-id "Step 2A" --step-name "正文起草" || true
 ```
 
 硬要求:
-- 只输出纯正文到章节正文文件;若详细大纲已有章节名,优先使用 `正文/第{chapter_padded}章-{title_safe}.md`,否则回退为 `正文/第{chapter_padded}章.md`
-- 默认按 2000-2500 字执行;若大纲为关键战斗章/高潮章/卷末章或用户明确指定,则按大纲/用户优先
-- 禁止占位符正文(如 `[TODO]`、`[待补充]`)
-- 保留承接关系:若上章有明确钩子,本章必须回应(可部分兑现)
+- 只输出纯正文到章节文件。
+- 不得出现 `[TODO]`、`[待补充]` 等占位符
+- 若上章存在明确钩子,本章必须回应
+- 中文思维写作,不使用英文框架骨架驱动正文
 
-中文思维写作约束(硬规则):
-- **禁止"先英后中"**:不得先用英文工程化骨架(如 ABCDE 分段、Summary/Conclusion 框架)组织内容,再翻译成中文。
-- **中文叙事单元优先**:以"动作、反应、代价、情绪、场景、关系位移"为基本叙事单元,不使用英文结构标签驱动正文生成。
-- **禁止英文结论话术**:正文、审查说明、润色说明、变更摘要、最终报告中不得出现 Overall / PASS / FAIL / Summary / Conclusion 等英文结论标题。
-- **英文仅限机器标识**:CLI flag(`--fast`)、checker id(`consistency-checker`)、DB 字段名(`anti_ai_force_check`)、JSON 键名等不可改的接口名保持英文,其余一律使用简体中文。
+完成后记录:
 
-输出:
-- 章节草稿(可进入 Step 2B 或 Step 3)。
+```bash
+python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" workflow complete-step --step-id "Step 2A" --artifacts '{"chapter_draft":true}' || true
+```
 
-### Step 2B:风格适配(`--fast` / `--minimal` 跳过)
+### Step 2B:风格适配(`--fast` 跳过)
 
 执行前加载:
+
 ```bash
 cat "${SKILL_ROOT}/references/style-adapter.md"
+python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" workflow start-step --step-id "Step 2B" --step-name "风格适配" || true
 ```
 
 硬要求:
-- 只做表达层转译,不改剧情事实、事件顺序、角色行为结果、设定规则。
-- 对“模板腔、说明腔、机械腔”做定向改写,为 Step 4 留出问题修复空间。
+- 只改表达,不改事实、事件顺序、人物行为结果、设定规则。
+- 重点消除模板腔、说明腔、机械腔。
+
+完成后记录:
 
-输出:
-- 风格化正文(覆盖原章节文件)。
+```bash
+python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" workflow complete-step --step-id "Step 2B" --artifacts '{"style_adapted":true}' || true
+```
 
-### Step 3:审查(auto 路由,必须由 Task 子代理执行)
+### Step 3:执行审查并落库
 
 执行前加载:
+
 ```bash
 cat "${SKILL_ROOT}/references/step-3-review-gate.md"
+python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" workflow start-step --step-id "Step 3" --step-name "审查" || true
 ```
 
 调用约束:
-- 必须用 `Task` 调用审查 subagent,禁止主流程伪造审查结论。
-- 可并行发起审查,统一汇总 `issues/severity/overall_score`。
-- 默认使用 `auto` 路由:根据“本章执行合同 + 正文信号 + 大纲标签”动态选择审查器。
+- 必须用 Task 调用审查子代理。
+- 默认使用 `auto` 路由动态选择检查器。
 
-核心审查器(始终执行)
+核心审查器:
 - `consistency-checker`
 - `continuity-checker`
 - `ooc-checker`
 
-条件审查器(`auto` 命中时执行)
+条件审查器:
 - `reader-pull-checker`
 - `high-point-checker`
 - `pacing-checker`
 
-模式说明:
-- 标准/`--fast`:核心 3 个 + auto 命中的条件审查器
-- `--minimal`:只跑核心 3 个(忽略条件审查器)
+模式规则:
+- 标准模式与 `--fast`:核心 3 个 + auto 命中的条件审查器
+- `--minimal`:只跑核心 3 个
+
+审查指标落库:
 
-审查指标落库(必做):
 ```bash
 python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" index save-review-metrics --data "@${PROJECT_ROOT}/.webnovel/tmp/review_metrics.json"
 ```
 
-review_metrics 字段约束(当前工作流约定只传以下字段):
-```json
-{
-  "start_chapter": 100,
-  "end_chapter": 100,
-  "overall_score": 85.0,
-  "dimension_scores": {"爽点密度": 8.5, "设定一致性": 8.0, "节奏控制": 7.8, "人物塑造": 8.2, "连贯性": 9.0, "追读力": 8.7},
-  "severity_counts": {"critical": 0, "high": 1, "medium": 2, "low": 0},
-  "critical_issues": ["问题描述"],
-  "report_file": "审查报告/第100-100章审查报告.md",
-  "notes": "单个字符串;selected_checkers / timeline_gate / anti_ai_force_check 等扩展信息压成单行文本写入此字段"
-}
-```
-- `notes` 在当前执行契约中必须是单个字符串,不得传入对象或数组。
-- 当前工作流不额外传入其它顶层字段;脚本侧未在此处做新增硬校验。
-
 硬要求:
-- `--minimal` 也必须产出 `overall_score`。
-- 未落库 `review_metrics` 不得进入 Step 5。
+- 必须产出 `overall_score`。
+- `notes` 必须是单个字符串。
+- 未落库 `review_metrics` 不得进入 Step 4。
 
-### Step 4:润色(问题修复优先)
+完成后记录:
+
+```bash
+python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" workflow complete-step --step-id "Step 3" --artifacts '{"review_completed":true}' || true
+```
+
+### Step 4:润色与全文终检
 
 执行前必须加载:
+
 ```bash
 cat "${SKILL_ROOT}/references/polish-guide.md"
 cat "${SKILL_ROOT}/references/writing/typesetting.md"
+python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" workflow start-step --step-id "Step 4" --step-name "润色" || true
 ```
 
 执行顺序:
-1. 修复 `critical`(必须)
-2. 修复 `high`(不能修复则记录 deviation)
-3. 处理 `medium/low`(按收益择优)
-4. 执行 Anti-AI 与 No-Poison 全文终检(必须输出 `anti_ai_force_check: pass/fail`)
+1. 修复已知严重问题
+2. 统一段落、节奏、排版
+3. 执行 Anti-AI 与 No-Poison 全文终检
+
+硬要求:
+- 必须输出 `anti_ai_force_check=pass/fail`
+- `fail` 时不得进入 Step 5
+
+完成后记录:
+
+```bash
+python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" workflow complete-step --step-id "Step 4" --artifacts '{"anti_ai_force_check":"pass"}' || true
+```
 
-输出:
-- 润色后正文(覆盖章节文件)
-- 变更摘要(至少含:修复项、保留项、deviation、`anti_ai_force_check`)
+### Step 5:调用 Data Agent 回写结构化数据
 
-### Step 5:Data Agent(状态与索引回写)
+执行前记录:
+
+```bash
+python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" workflow start-step --step-id "Step 5" --step-name "Data Agent" || true
+```
 
 使用 Task 调用 `data-agent`,参数:
 - `chapter`
-- `chapter_file` 必须传入实际章节文件路径;若详细大纲已有章节名,优先传 `正文/第{chapter_padded}章-{title_safe}.md`,否则传 `正文/第{chapter_padded}章.md`
+- `chapter_file`
 - `review_score=Step 3 overall_score`
 - `project_root`
 - `storage_path=.webnovel/`
 - `state_file=.webnovel/state.json`
 
-Data Agent 默认子步骤(全部执行):
-- A. 加载上下文
-- B. AI 实体提取
-- C. 实体消歧
-- D. 写入 state/index
-- E. 写入章节摘要
-- F. AI 场景切片
-- G. RAG 向量索引(`rag index-chapter --scenes ...`)
-- H. 风格样本评估(`style extract --scenes ...`,仅 `review_score >= 80` 时)
-- I. 债务利息(默认跳过)
-
-`--scenes` 来源优先级(G/H 步骤共用):
-1. 优先从 `index.db` 的 scenes 记录获取(Step F 写入的结果)
-2. 其次按 `start_line` / `end_line` 从正文切片构造
-3. 最后允许单场景退化(整章作为一个 scene)
-
-Step 5 失败隔离规则:
-- 若 G/H 失败原因是 `--scenes` 缺失、scene 为空、scene JSON 格式错误:只补跑 G/H 子步骤,不回滚或重跑 Step 1-4。
-- 若 A-E 失败(state/index/summary 写入失败):仅重跑 Step 5,不回滚已通过的 Step 1-4。
-- 禁止因 RAG/style 子步骤失败而重跑整个写作链。
-
-执行后检查(最小白名单):
+Data Agent 默认子步骤全部执行:
+- 加载上下文
+- 实体提取与消歧
+- 写入 state/index
+- 写入章节摘要
+- 提取长期记忆 `memory_facts`
+- 场景切片
+- RAG 向量索引
+- 风格样本评估(仅 `review_score >= 80`)
+- 债务利息(默认关闭)
+
+失败隔离规则:
+- state/index/summary/memory 写入失败:只重跑 Step 5
+- `--scenes` 缺失导致的向量或风格样本失败:只补跑对应子步骤
+- 禁止因 Step 5 子步骤失败而回滚 Step 1-4
+
+执行后最小检查白名单:
 - `.webnovel/state.json`
 - `.webnovel/index.db`
 - `.webnovel/summaries/ch{chapter_padded}.md`
-- `.webnovel/observability/data_agent_timing.jsonl`(观测日志)
+- `.webnovel/memory_scratchpad.json`
+- `.webnovel/observability/data_agent_timing.jsonl`
 
 性能要求:
-- 读取 timing 日志最近一条;
-- 当 `TOTAL > 30000ms` 时,输出最慢 2-3 个环节与原因说明。
+- 读取最新 timing 记录
+- `TOTAL > 30000ms` 时,输出最慢 2-3 个环节与原因说明
+
+完成后记录:
 
-观测日志说明:
-- `call_trace.jsonl`:外层流程调用链(agent 启动、排队、环境探测等系统开销)。
-- `data_agent_timing.jsonl`:Data Agent 内部各子步骤耗时。
-- 当外层总耗时远大于内层 timing 之和时,默认先归因为 agent 启动与环境探测开销,不误判为正文或数据处理慢。
+```bash
+python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" workflow complete-step --step-id "Step 5" --artifacts '{"state_json_modified":true,"entities_appeared":true}' || true
+```
 
-债务利息:
-- 默认关闭,仅在用户明确要求或开启追踪时执行(见 `step-5-debt-switch.md`)。
+### Step 6:Git 备份
 
-### Step 6:Git 备份(可失败但需说明)
+执行前记录:
 
 ```bash
+python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" workflow start-step --step-id "Step 6" --step-name "Git 备份" || true
 git add .
 git -c i18n.commitEncoding=UTF-8 commit -m "第{chapter_num}章: {title}"
 ```
 
 规则:
-- 提交时机:验证、回写、清理全部完成后最后执行。
-- 提交信息默认中文,格式:`第{chapter_num}章: {title}`。
-- 若 commit 失败,必须给出失败原因与未提交文件范围。
+- 所有验证和回写完成后最后执行。
+- commit 失败时,必须说明失败原因与未提交文件范围。
 
-## 充分性闸门(必须通过)
+完成后记录:
+
+```bash
+python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" workflow complete-step --step-id "Step 6" --artifacts '{"git_status":{"committed":true}}' || true
+python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" workflow complete-task --artifacts '{"ok":true}' || true
+```
+
+## 充分性闸门
 
 未满足以下条件前,不得结束流程:
 
-1. 章节正文文件存在且非空:`正文/第{chapter_padded}章-{title_safe}.md` 或 `正文/第{chapter_padded}章.md`
-2. Step 3 已产出 `overall_score` 且 `review_metrics` 成功落库
-3. Step 4 已处理全部 `critical`,`high` 未修项有 deviation 记录
-4. Step 4 的 `anti_ai_force_check=pass`(基于全文检查;fail 时不得进入 Step 5)
-5. Step 5 已回写 `state.json`、`index.db`、`summaries/ch{chapter_padded}.md`
-6. 若开启性能观测,已读取最新 timing 记录并输出结论
+1. 章节正文文件存在且非空
+2. Step 3 已产出 `overall_score`,且 `review_metrics` 已落库。
+3. Step 4 的 `anti_ai_force_check=pass`。
+4. Step 5 已更新 `state.json`、`index.db`、`summaries/ch{chapter_padded}.md`。
+5. Step 5 已写入 `.webnovel/memory_scratchpad.json`。
+6. 若启用观测,已读取最新 timing 记录并给出结论。
 
 ## 验证与交付
 
-执行检查:
-
 ```bash
 test -f "${PROJECT_ROOT}/.webnovel/state.json"
 test -f "${PROJECT_ROOT}/正文/第${chapter_padded}章.md"
 test -f "${PROJECT_ROOT}/.webnovel/summaries/ch${chapter_padded}.md"
+test -f "${PROJECT_ROOT}/.webnovel/memory_scratchpad.json"
 python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" index get-recent-review-metrics --limit 1
 tail -n 1 "${PROJECT_ROOT}/.webnovel/observability/data_agent_timing.jsonl" || true
 ```
 
 成功标准:
-- 章节文件、摘要文件、状态文件齐全且内容可读。
+- 章节文件、摘要文件、状态文件、长期记忆暂存文件齐全且内容可读。
 - 审查分数可追溯,`overall_score` 与 Step 5 输入一致。
-- 润色后未破坏大纲与设定约束。
+- 润色后未破坏大纲、设定与长期记忆约束。
 
-## 失败处理(最小回滚)
+## 失败处理
 
 触发条件:
-- 章节文件缺失或空文件;
-- 审查结果未落库;
-- Data Agent 关键产物缺失;
-- 润色引入设定冲突。
-
-恢复流程:
-1. 仅重跑失败步骤,不回滚已通过步骤。
-2. 常见最小修复:
-   - 审查缺失:只重跑 Step 3 并落库;
-   - 润色失真:恢复 Step 2A 输出并重做 Step 4;
-   - 摘要/状态缺失:只重跑 Step 5;
-3. 重新执行“验证与交付”全部检查,通过后结束。
+- 章节文件缺失或为空
+- 审查结果未落库
+- Data Agent 关键产物缺失
+- 润色引入设定冲突
+
+恢复规则:
+1. 只补跑失败步骤,不回滚已通过步骤。
+2. 审查缺失:只重跑 Step 3。
+3. 摘要、状态、长期记忆缺失:只重跑 Step 5。
+4. 润色失真:回到 Step 4 修复后重新执行 Step 5。

+ 1 - 1
webnovel-writer/skills/webnovel-write/references/step-3-review-gate.md

@@ -25,7 +25,7 @@
 ## Auto 路由判定信号
 
 输入信号来源:
-1. Step 1.5 合同(是否过渡章、追读力设计、核心冲突)。
+1. Step 1 内置合同(历史文件名仍为 `step-1.5-contract.md`,内容包括是否过渡章、追读力设计、核心冲突)。
 2. 本章正文(战斗/反转/高光/章末未闭合问题等信号)。
 3. 大纲标签(关键章/高潮章/卷末章/转场章)。
 4. 最近章节节奏(连续主线、情感线断档、世界观线断档)。

+ 8 - 6
webnovel-writer/skills/webnovel-write/references/workflow-details.md

@@ -1,13 +1,15 @@
-# Workflow Details (Deprecated)
+# Workflow Details(Deprecated)
 
-本文件已拆分为按步骤单文件,主流程不再直接引用
+本文件已废弃,主流程请以 `SKILL.md` 为准,不再直接引用本文件
 
-请改为按 Step 加载以下文件
+若需要按步骤查参考资料,请使用以下映射
 
-- Step 1.5:`references/step-1.5-contract.md`
+- Step 1:`references/step-1.5-contract.md`
+- Step 2B:`references/style-adapter.md`
 - Step 3:`references/step-3-review-gate.md`
+- Step 4:`references/polish-guide.md`
+- Step 4:`references/writing/typesetting.md`
 - Step 5:`references/step-5-debt-switch.md`
 
 说明:
-- Step 2B 请读取 `references/style-adapter.md`
-- Step 4 请读取 `references/polish-guide.md` 与 `references/writing/typesetting.md`
+- `step-1.5-contract.md` 是历史文件名,当前用于 Step 1 的内置合同,不单独作为 workflow step-id 使用。

+ 1 - 1
webnovel-writer/skills/webnovel-write/references/writing/genre-hook-payoff-library.md

@@ -1,6 +1,6 @@
 # 新题材快速库:章末钩子 + 微兑现(电竞 / 直播文 / 克苏鲁)
 
-用于 `webnovel-write` Step 1.5:快速选一条章末钩子 + 一条微兑现,避免同质化。
+用于 `webnovel-write` Step 1 内置合同:快速选一条章末钩子 + 一条微兑现,避免同质化。
 
 ---