Claude Skill

story

网络小说工具箱主入口。根据用户需求自动路由到对应 skill,并可管理作者习惯、启动本地 Dashboard。触发方式:/story、$story、/story dashboard、/网文、「我想写小说」「记住我的写作习惯」「打开工作台」「检查更新」。

LLM Mart · 0 points · 17 views 43 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download zenstory-ai-oh-story-claudecode-skills_story-0ffe7db.zip · 61 KB
Part of zenstory-ai/oh-story-claudecode — 26 skills

Install

skills CLI npx skills add https://github.com/zenstory-ai/oh-story-claudecode/tree/main/skills/story
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install zenstory-ai-oh-story-claudecode@llmmart
Git git clone https://github.com/zenstory-ai/oh-story-claudecode.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole zenstory-ai/oh-story-claudecode collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

story:网文工具箱路由

你是网文工具箱的路由入口。用户的请求模糊时由你分发到具体 skill。

路由表

Codex CLI 中优先使用 $story-* 或 /skills 触发;Claude Code / OpenCode 继续使用 /story-*;Antigravity 可在 /skills 中选择或用自然语言点名;OpenClaw 可用 /skill story-* 或自然语言点名 skill。下表以 slash command 展示,Codex 可将 /story-long-write 等价替换为 $story-long-write,OpenClaw 可将其等价替换为 /skill story-long-write。

用户意图 关键词示例 路由到
长篇规划/写作 讨论长篇结构、规划剧情、开书、写大纲、补细纲、长篇、连载 /story-long-write
写短篇 短篇、盐言、一万字 /story-short-write
长篇拆文 拆文、分析这本书、黄金三章 /story-long-analyze
短篇拆文 拆短篇、分析这个故事 /story-short-analyze
长篇扫榜 长篇排行、什么火、起点/番茄/晋江 /story-long-scan
选题决策 写什么能爆、帮我选题、选题方向 /story-long-scan
短篇扫榜 短篇排行、知乎盐言排行 /story-short-scan
去 AI 味 去 AI 味、太 AI、去味 /story-deslop
审查稿件 审查、审稿、帮我审一下、一致性检查、看看有没有问题 /story-review
封面 封面、封面图 /story-cover
环境部署 准备写书、搭环境、初始化 /story-setup
浏览器操控 浏览器、抓取、登录态 /browser-cdp
导入小说 导入、反向解析、导入小说、把我的书导进来 /story-import
工作台 dashboard、工作台、看拆文库、浏览项目文件、打开项目面板 见下方「Dashboard 工作台」
检查/更新版本 检查更新、有新版本吗、升级、更新工具箱 见下方「版本更新检查」
切换/列出书目 切书、换书、列出我的书、我在写哪几本、切换项目 见下方「多书切换」
管理作者习惯 记住我的写作习惯、作者画像、待确认偏好、忘掉这个偏好 见下方「作者记忆」
查故事资料 查角色、查伏笔、查进度、查设定、什么状态、写到哪了 spawn story-explorer agent(结构化 prompt:项目目录:{dir}\n查询类型:{根据意图选择}\n查询参数:{用户查询});agent 不可用时见下方「查询降级」
查资料 查资料、帮我查资料、调研、搜索一下、搜一下 spawn story-researcher agent;agent 不可用时见下方「查询降级」

导入续写顺序

用户问"导入续写先 setup 还是 import"时,直接回答:推荐先 /story-setup,新开/刷新会话后 /story-import,最后 /story-long-write 日更 或 /story-long-write 写第N章。如果用户已经直接触发 /story-import,按 story-import 自带环境检测继续:未 setup 时让用户选择先去 setup 或继续串行导入。

作者记忆

用户要求记住、查看、确认、替换或忘掉作者习惯时,加载 references/author-memory.md,并只用本 skill 的 scripts/author_memory_commit.py 管理两级 .story/作者记忆/:全局、题材、流程条目在工作区(AP),本书条目在书目录(BP,传 --book-root)。常用变更走单事件 record;工具未返回 ok: true 和 Author Memory Receipt 前,不得声称已记住。显示画像或待确认项是只读操作;不存在时直接说明尚未建立。

新增习惯必须保留用户原话和适用范围。一次性要求只执行不记录;小说事实写入本书设定/追踪;不从反复修改或成稿推断偏好,只记作者明确说的,原话范围含糊才进待确认;与已生效习惯冲突时显式 replace,不原地改写历史。项目级画像里还有「本书:」条目时,建议对该书运行 migrate --book-root。用户没有指定工作区时,按协议定位已有作者记忆的最近祖先或当前创作工作区,禁止默认写到用户主目录。

Dashboard 工作台

用户执行 /story dashboard(Codex 为 $story dashboard),或明确说“打开工作台 / 看项目 文件”时,直接启动随本 skill 分发的本地 Dashboard,不再转发到其他 skill:

  1. 把当前工作目录作为默认工作区;用户明确给出目录时改用该目录。目录必须存在。

  2. 从当前已加载的 story skill 目录定位 scripts/dashboard-server.mjs,不要硬编码仓库路径、 全局 skill 路径或用户主目录。

  3. 检查 node 可用后,以长运行进程执行:

    node "<story-skill-dir>/scripts/dashboard-server.mjs" --root "<workspace>" --open
    
  4. 等待输出出现“本机地址”,把完整 URL 回给用户。工具支持后台进程/PTY 时让服务保持运行; 无法自动拉起浏览器不算失败,仍返回可点击 URL。

  5. Dashboard 默认只监听 127.0.0.1。不要主动增加 --allow-network,不要把工作区暴露到 局域网或公网。

工作台会识别标准 拆文库/{书名}/,兼容存量 拆文库-{书名}/。写作项目识别同时支持:

  • 长篇目录结构:目录内含 正文/、大纲/、设定/ 或 追踪/ 任一普通子目录。
  • 短篇单文件结构:目录内含普通文件 正文.md,并同时含 小节大纲.md 或 设定.md。

符号链接不作为项目标记,只有单个 正文.md 的普通资料目录也不会被误认。浏览器可编辑 .md、.txt、.json、.yaml、.yml、.toml,保存或确认删除前用修改时间防止 误操作外部更新。

停止服务时终止对应的 Node 长运行进程即可。若用户只问用法,不要替他启动;给出 /story dashboard / $story dashboard 两种平台对应入口。

路由流程

  1. 分析用户请求,提取意图关键词
  2. 匹配上表,找到对应的 skill
  3. 如果能明确匹配,直接调用对应 skill(Claude/OpenCode 可用 Skill("skill-name") 或 slash command;Codex 用 $skill-name / /skills;Antigravity 用 /skills 或自然语言点名;OpenClaw 用 /skill skill-name 或自然语言点名)
  4. 如果无法匹配,询问用户想做什么(从上表中选择)
  5. 如果用户说"我想写小说"但未指定长篇/短篇,询问篇幅类型后再路由

查询降级

Spawn 版本提示(不阻断 spawn):先读取项目根 .story-deployed 的 agents_version。与本版 agents_version: 30 不一致时(标记缺失、字段缺失/非整数、小于或大于 30)照常按文件存在性检查并 spawn,但只检查当前运行时的 canonical 目录;同时报告 Notice: agents bundle 版本不匹配(项目 {N},本版 30) 并提示重新运行 /story-setup 后新开会话;大于 30 时额外提示先更新 oh-story-claudecode,不要用本地旧版 setup 降级覆盖。只有 agent 文件缺失、或运行时不暴露 custom agent 时才降级 solo/direct,报告 Fallback: ... -> solo。

「查故事资料」「查资料」走 agent 前先做轻量可用性检查(路由只做这一层,不承担全局部署策略):当前不在子代理上下文、当前运行时的 Agent/Task 或 invoke_subagent 工具可用,且对应部署文件存在(Claude .claude/agents/*.md、OpenCode .opencode/agents/*.md、Codex .codex/agents/*.toml、Antigravity .agents/agents/agent-name/agent.md,其中 agent-name 为目标 agent 名)→ 可尝试 spawn。Antigravity 用 invoke_subagent + 同名 TypeName,不得因其他端文件存在而误判。任一不满足,或运行时返回 unknown agent / 未暴露 custom-agent registry,则降级,不硬失败:

  • story-explorer 不可用 → 主线程直接用 Read/Grep 从项目文件检索(角色状态/伏笔/进度/设定),回答前标注 Fallback: agent unavailable -> direct lookup;项目尚未部署时提示先 /story-setup(Codex 中用 $story-setup)。
  • story-researcher 不可用 → 主线程用现有检索/回答能力完成,或提示用户改用 /browser-cdp 采集,同样标注 Fallback: agent unavailable -> direct lookup。

项目状态感知

路由前先检查当前项目状态:

  • 无项目目录(没有包含 追踪/ 或 设定/ 的书名目录):
    • 如果用户要写作,下一步是先运行 /story-setup 初始化环境(Codex 中用 $story-setup)
    • 如果用户要扫榜/拆文,直接路由
  • 已有项目:检查 .story-deployed 标记,如未部署则先运行 /story-setup(Codex 中用 $story-setup)

多书切换

用户想切换或查看在写的书时(一个项目可同时有多本):

  1. 在项目根查找所有书目录:包含 追踪/ 或 设定/ 子目录的目录(含 长篇/、短篇/ 下的子目录)。
  2. 列出书名,并标出当前 .active-book 指向的那本。
  3. 让用户选择,把所选书的相对路径写入项目根 .active-book(覆盖原内容)。
  4. 只发现一本时直接确认为活跃书,无需询问。

版本更新检查

用户问"有没有新版本""检查更新""升级"时执行。只通知,更不更新由用户定,不自动安装。

  1. 当前版本:读本 skill 同目录的 VERSION 文件;缺失则视为未知。
  2. 最新版本:优先 gh release view --json tagName,name,url -R zenstory-ai/oh-story-claudecode 取 tagName;无 gh 用 curl -fsS --max-time 5 https://api.github.com/repos/zenstory-ai/oh-story-claudecode/releases/latest 取 .tag_name(jq 或 grep)。查不到 → 告知"暂时拉不到最新版本,可手动看 Releases",不报错。
  3. 比较:去掉 v 前缀按语义版本比(major.minor.patch)。gh release 默认取 latest 稳定版,不含 pre-release。
  4. 告知:
    • 已最新 → 「已是最新版 vX.Y.Z」。
    • 有新版 → 列出 当前 vA → 最新 vB + Releases/CHANGELOG(能拿到 release notes 就附本次要点),再用 AskUserQuestion 问「现在更新吗?」:
      • 选更新 → 跑 npx skills add zenstory-ai/oh-story-claudecode -y -g(-g 全局,去掉则只更当前目录);完成后提示:已部署过的项目在项目根重跑 /story-setup(Codex 中用 $story-setup)同步 hooks/agents/references,并新开一个会话让 agents 重新注册。
      • 选先不 → 不动,告知随时可再来。
Files (oh-story-claudecode)
  • assets
    • app.js 36.8 KB
      const state = {
        workspace: null,
        activeView: "libraries",
        activeFile: null,
        originalContent: "",
        dirty: false,
        mode: "edit",
        restored: false,
        showLineNumbers: true,
        filter: "",
        loadingFile: false,
        saving: false,
        deleting: false,
        searching: false,
        searchResults: [],
        searchTruncation: null,
        searchSequence: 0,
        searchTimer: null,
        // 记住作者手动展开/收起过的目录,重绘文件树时不要把人正在翻的章节文件夹关掉
        expandedDirs: new Set(),
        collapsedDirs: new Set(),
      };
      
      const elements = {
        workspaceName: document.querySelector("#workspaceName"),
        workspacePath: document.querySelector("#workspacePath"),
        connectionStatus: document.querySelector("#connectionStatus"),
        treeSearch: document.querySelector("#treeSearch"),
        libraryCount: document.querySelector("#libraryCount"),
        projectCount: document.querySelector("#projectCount"),
        fileCount: document.querySelector("#fileCount"),
        librariesBadge: document.querySelector("#librariesBadge"),
        projectsBadge: document.querySelector("#projectsBadge"),
        archiveTabs: [...document.querySelectorAll(".archive-tabs [role='tab']")],
        treePanel: document.querySelector("#treePanel"),
        treeLoading: document.querySelector("#treeLoading"),
        fileTree: document.querySelector("#fileTree"),
        refreshButton: document.querySelector("#refreshButton"),
        mobileBackButton: document.querySelector("#mobileBackButton"),
        editorEmpty: document.querySelector("#editorEmpty"),
        editorWorkspace: document.querySelector("#editorWorkspace"),
        editorBody: document.querySelector("#editorBody"),
        editorContainer: document.querySelector("#editorContainer"),
        lineNumbersGutter: document.querySelector("#lineNumbersGutter"),
        lineNumbersButton: document.querySelector("#lineNumbersButton"),
        editorTitle: document.querySelector("#editorTitle"),
        breadcrumbs: document.querySelector("#breadcrumbs"),
        dirtyStatus: document.querySelector("#dirtyStatus"),
        documentMeta: document.querySelector("#documentMeta"),
        editorInput: document.querySelector("#editorInput"),
        previewPane: document.querySelector("#previewPane"),
        modeButtons: [...document.querySelectorAll(".mode-switch button")],
        deleteButton: document.querySelector("#deleteButton"),
        saveButton: document.querySelector("#saveButton"),
        cursorPosition: document.querySelector("#cursorPosition"),
        encodingLabel: document.querySelector("#encodingLabel"),
        toastRegion: document.querySelector("#toastRegion"),
        conflictDialog: document.querySelector("#conflictDialog"),
        reloadConflictButton: document.querySelector("#reloadConflictButton"),
        truncationNotice: null,
      };
      
      class ApiError extends Error {
        constructor(status, code, message) {
          super(message);
          this.name = "ApiError";
          this.status = status;
          this.code = code;
        }
      }
      
      async function requestJson(url, options) {
        let response;
        try {
          response = await fetch(url, options);
        } catch {
          setConnection("offline", "连接中断");
          throw new ApiError(0, "network_error", "无法连接本地 Dashboard 服务");
        }
      
        let payload;
        try {
          payload = await response.json();
        } catch {
          payload = null;
        }
      
        if (!response.ok) {
          throw new ApiError(
            response.status,
            payload?.error?.code || "request_failed",
            payload?.error?.message || `请求失败(${response.status})`,
          );
        }
        setConnection("online", "仅本机");
        return payload;
      }
      
      function setConnection(status, label) {
        elements.connectionStatus.dataset.state = status;
        elements.connectionStatus.querySelector("span:last-child").textContent = label;
      }
      
      function showToast(message, kind = "success") {
        const toast = document.createElement("div");
        toast.className = "toast";
        toast.dataset.kind = kind;
        const text = document.createElement("p");
        text.textContent = message;
        toast.append(text);
        elements.toastRegion.append(toast);
        window.setTimeout(() => toast.remove(), 4200);
      }
      
      function formatNumber(value) {
        return new Intl.NumberFormat("zh-CN").format(value || 0);
      }
      
      function formatBytes(bytes) {
        if (bytes < 1024) return `${bytes} B`;
        if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KiB`;
        return `${(bytes / (1024 * 1024)).toFixed(1)} MiB`;
      }
      
      function countCharacters(content) {
        return [...content.replace(/\s/g, "")].length;
      }
      
      // textarea 的 value 永远是 LF:读盘时先归一化,写盘时再换回原文件的换行符,
      // 否则 CRLF 稿件会被一次改动整篇重写,而且脏标记永远对不上、清不掉。
      function detectEol(content) {
        let crlf = 0;
        let lf = 0;
        let cr = 0;
        for (let index = 0; index < content.length; index += 1) {
          if (content[index] === "\r") {
            if (content[index + 1] === "\n") {
              crlf += 1;
              index += 1;
            } else {
              cr += 1;
            }
          } else if (content[index] === "\n") {
            lf += 1;
          }
        }
        // 按 LF/CRLF 的主流风格回写;只有纯 CR 文件才保留 CR。一个粘贴进来的孤立 CR
        // 不能把每个 LF 都扩散成 CR,反过来也不能让 CRLF 稿件整篇变成 LF。
        if (crlf > lf) return "\r\n";
        if (lf > 0) return "\n";
        if (cr > 0) return "\r";
        return "\n";
      }
      
      function normalizeEol(content) {
        return content.replaceAll("\r\n", "\n").replaceAll("\r", "\n");
      }
      
      function applyEol(content, eol) {
        return !eol || eol === "\n" ? content : content.replaceAll("\n", eol);
      }
      
      function activeEol() {
        return state.activeFile?.eol || "\n";
      }
      
      function currentByteSize() {
        return new TextEncoder().encode(applyEol(elements.editorInput.value, activeEol())).length;
      }
      
      function fileExtension(name) {
        const index = name.lastIndexOf(".");
        return index >= 0 ? name.slice(index + 1) : "";
      }
      
      function iconSvg(kind) {
        if (kind === "folder") {
          return `<svg class="tree-icon folder-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M3.5 6.5h6l2 2h9v10h-17z"></path></svg>`;
        }
        return `<svg class="tree-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M6 3.5h8l4 4v13H6z"></path><path d="M14 3.5v4h4M9 12h6M9 16h5"></path></svg>`;
      }
      
      function createTreeEntry(node, depth = 0) {
        const item = document.createElement("li");
        if (node.type === "directory") {
          const details = document.createElement("details");
          details.dataset.path = node.path;
          const shouldOpen =
            state.expandedDirs.has(node.path) ||
            (depth === 0 && !state.collapsedDirs.has(node.path));
          details.open = shouldOpen;
          // 只记录作者亲手的展开/收起;首层程序化展开不算偏好。
          let recorded = shouldOpen;
          details.addEventListener("toggle", () => {
            if (details.open === recorded) return;
            recorded = details.open;
            if (details.open) {
              state.expandedDirs.add(node.path);
              state.collapsedDirs.delete(node.path);
              if (!node.loaded && !node.loading) loadDirectory(node);
            } else {
              state.expandedDirs.delete(node.path);
              state.collapsedDirs.add(node.path);
            }
          });
          const summary = document.createElement("summary");
          summary.innerHTML = iconSvg("folder");
          const label = document.createElement("span");
          label.className = "tree-label";
          label.textContent = node.name;
          summary.append(label);
          details.append(summary);
      
          const list = document.createElement("ul");
          node.children.forEach((child) => {
            const childItem = createTreeEntry(child, depth + 1);
            if (childItem) list.append(childItem);
          });
          if (node.loading) {
            const loading = document.createElement("li");
            loading.className = "tree-inline-status";
            loading.textContent = "正在读取目录…";
            list.append(loading);
          } else if (node.loadError) {
            const retry = document.createElement("li");
            retry.className = "tree-inline-status";
            const button = document.createElement("button");
            button.type = "button";
            button.textContent = "目录加载失败,点击重试";
            button.addEventListener("click", () => loadDirectory(node));
            retry.append(button);
            list.append(retry);
          } else if (node.loaded && node.children.length === 0) {
            const empty = document.createElement("li");
            empty.className = "tree-inline-status";
            empty.textContent = "空目录";
            list.append(empty);
          }
          if (node.nextCursor && !node.loading) {
            const more = document.createElement("li");
            more.className = "tree-inline-status";
            const button = document.createElement("button");
            button.type = "button";
            button.textContent = "加载更多";
            button.addEventListener("click", () => loadDirectory(node, { append: true }));
            more.append(button);
            list.append(more);
          }
          details.append(list);
          item.append(details);
          if (shouldOpen && !node.loaded && !node.loading && !node.loadError && !node.loadQueued) {
            node.loadQueued = true;
            window.queueMicrotask(() => {
              node.loadQueued = false;
              if (!node.loaded && !node.loading && !node.loadError) loadDirectory(node);
            });
          }
          return item;
        }
      
        const button = document.createElement("button");
        button.type = "button";
        button.className = "file-row";
        button.dataset.path = node.path;
        button.dataset.active = String(state.activeFile?.path === node.path);
        button.disabled = !node.editable;
        button.title = node.editable ? node.path : `${node.path}(此文件类型只展示,不可编辑)`;
        button.innerHTML = iconSvg("file");
      
        const label = document.createElement("span");
        label.className = "tree-label";
        label.textContent = node.name;
        button.append(label);
      
        const extension = document.createElement("span");
        extension.className = "file-ext";
        extension.textContent = fileExtension(node.name);
        button.append(extension);
        if (node.editable) {
          button.addEventListener("click", () => openFile(node.path));
        }
        item.append(button);
        return item;
      }
      
      function mergeDirectoryEntries(node, entries, append) {
        if (!append) {
          node.children = entries;
          return;
        }
        const existingPaths = new Set(node.children.map((entry) => entry.path));
        node.children.push(...entries.filter((entry) => !existingPaths.has(entry.path)));
      }
      
      async function loadDirectory(node, { append = false } = {}) {
        if (node.loading) return;
        node.loading = true;
        node.loadError = "";
        renderTree();
        try {
          const cursor = append && node.nextCursor ? `&cursor=${encodeURIComponent(node.nextCursor)}` : "";
          const page = await requestJson(`/api/tree?path=${encodeURIComponent(node.path)}${cursor}`);
          mergeDirectoryEntries(node, page.entries, append);
          node.nextCursor = page.nextCursor;
          node.loaded = true;
        } catch (error) {
          node.loadError = error.message;
          showToast(error.message, "error");
        } finally {
          node.loading = false;
          renderLoadedFileCount();
          renderTree();
        }
      }
      
      function loadedFileCount() {
        const paths = new Set();
        function visit(node) {
          if (node.type === "file") {
            paths.add(node.path);
            return;
          }
          node.children.forEach(visit);
        }
        state.workspace?.libraries.forEach(visit);
        state.workspace?.projects.forEach(visit);
        return paths.size;
      }
      
      function renderLoadedFileCount() {
        if (!state.workspace) return;
        const count = loadedFileCount();
        elements.fileCount.textContent = count ? `${formatNumber(count)}+` : "按需";
        elements.fileCount.title = "文稿随目录展开按需加载,不预先遍历整个工作区";
      }
      
      // 只改当前高亮行,不重建整棵树——重建会把作者正在翻的目录全部收起
      function syncActiveRow() {
        const activePath = state.activeFile?.path;
        elements.fileTree.querySelectorAll(".file-row").forEach((row) => {
          row.dataset.active = String(row.dataset.path === activePath);
        });
      }
      
      function searchTruncationMessage() {
        const status = state.searchTruncation;
        if (!status) return "";
        const messages = [];
        if (status.byResults) {
          messages.push(
            `匹配结果超过 ${formatNumber(status.limits.maxResults)} 条,仅显示最先找到的部分,请输入更精确的文件名`,
          );
        }
        if (status.byNodes) {
          messages.push(
            `搜索达到 ${formatNumber(status.limits.maxNodes)} 个节点的扫描上限,后续目录尚未检查,请直接展开目标目录查找`,
          );
        }
        if (status.byDepth) {
          messages.push(
            `部分目录超过 ${formatNumber(status.limits.maxDepth)} 层,更深处未搜索;其他项目已继续搜索`,
          );
        }
        if (status.byReadError) {
          const paths = status.scanErrors.map((entry) => entry.path).filter(Boolean);
          const shown = paths.slice(0, 3).join("、") || "部分目录";
          const more = paths.length > 3 ? `等 ${formatNumber(paths.length)} 处` : "";
          messages.push(
            `${shown}${more}无法读取,搜索结果可能不完整。请检查目录访问权限或外挂盘挂载状态`,
          );
        }
        return messages.join(";");
      }
      
      function renderTree() {
        elements.fileTree.replaceChildren();
        elements.treeLoading.hidden = true;
        const query = state.filter.trim();
        const collection = query
          ? state.searchResults
          : state.workspace?.[state.activeView] || [];
      
        if (query && state.searching) {
          const message = document.createElement("div");
          message.className = "tree-message";
          const text = document.createElement("p");
          text.textContent = `正在搜索“${query}”…`;
          message.append(text);
          elements.fileTree.append(message);
          return;
        }
      
        if (!collection.length) {
          const message = document.createElement("div");
          message.className = "tree-message";
          const text = document.createElement("p");
          text.textContent = query
            ? state.searchTruncation
              ? `搜索未完成,暂时无法确认是否存在“${query}”`
              : `没有找到“${query}”`
            : state.activeView === "libraries"
              ? "工作区里还没有拆文库。运行拆文 skill 后,档案会出现在这里。"
              : "还没有识别到写作项目。长篇需包含正文、大纲、设定或追踪目录;短篇需包含正文.md,并同时包含小节大纲.md或设定.md。";
          message.append(text);
          elements.fileTree.append(message);
          const truncation = searchTruncationMessage();
          if (query && truncation) {
            const status = document.createElement("div");
            status.className = "tree-message";
            status.setAttribute("role", "status");
            const statusText = document.createElement("p");
            statusText.textContent = truncation;
            status.append(statusText);
            elements.fileTree.append(status);
          }
          return;
        }
      
        const list = document.createElement("ul");
        collection.forEach((node) => {
          const item = createTreeEntry(node);
          if (item) list.append(item);
        });
        const truncation = searchTruncationMessage();
        if (query && truncation) {
          const status = document.createElement("li");
          status.className = "tree-inline-status";
          status.setAttribute("role", "status");
          status.textContent = truncation;
          list.append(status);
        }
        elements.fileTree.append(list);
      }
      
      function truncationMessage(scanErrors = []) {
        const paths = scanErrors.map((entry) => entry.path).filter(Boolean);
        const shown = paths.slice(0, 3).join("、") || "部分目录";
        const more = paths.length > 3 ? `等 ${formatNumber(paths.length)} 处` : "";
        return `${shown}${more}无法读取,其中的文稿没有列出。请检查这些目录的访问权限和外挂盘挂载状态,恢复后刷新目录。`;
      }
      
      function renderTruncationNotice(limits, scanErrors) {
        if (!limits?.truncated) {
          elements.truncationNotice?.remove();
          elements.truncationNotice = null;
          return;
        }
        if (!elements.truncationNotice) {
          const notice = document.createElement("div");
          notice.id = "treeTruncationNotice";
          notice.className = "tree-message";
          notice.setAttribute("role", "status");
          notice.append(document.createElement("p"));
          elements.treePanel.insertBefore(notice, elements.fileTree);
          elements.truncationNotice = notice;
        }
        elements.truncationNotice.querySelector("p").textContent = truncationMessage(scanErrors);
      }
      
      function renderWorkspace() {
        const { workspace, stats, libraries, projects, limits, scanErrors } = state.workspace;
        elements.workspaceName.textContent = workspace.name;
        elements.workspacePath.textContent = workspace.path;
        elements.workspacePath.title = workspace.path;
        elements.libraryCount.textContent = formatNumber(stats.libraries);
        elements.projectCount.textContent = formatNumber(stats.projects);
        renderLoadedFileCount();
        elements.librariesBadge.textContent = formatNumber(libraries.length);
        elements.projectsBadge.textContent = formatNumber(projects.length);
        renderTruncationNotice(limits, scanErrors);
        renderTree();
      }
      
      async function loadWorkspace({ announce = false } = {}) {
        window.clearTimeout(state.searchTimer);
        state.searchSequence += 1;
        elements.treeLoading.hidden = false;
        elements.fileTree.replaceChildren();
        setConnection("", "连接中");
        try {
          state.workspace = await requestJson("/api/workspace");
          state.searchResults = [];
          state.searchTruncation = null;
          state.searching = Boolean(state.filter.trim());
          renderWorkspace();
          if (!state.restored) await restoreViewState();
          if (state.filter.trim()) scheduleSearch();
          if (announce) showToast("工作区目录已刷新");
        } catch (error) {
          elements.treeLoading.hidden = true;
          const message = document.createElement("div");
          message.className = "tree-message";
          const text = document.createElement("p");
          text.textContent = error.message;
          message.append(text);
          elements.fileTree.replaceChildren(message);
          showToast(error.message, "error");
        }
      }
      
      function confirmDiscard() {
        return !state.dirty || window.confirm("当前文稿还有未保存的修改。确定放弃并打开另一份文件吗?");
      }
      
      function setDirty(dirty) {
        state.dirty = dirty;
        elements.dirtyStatus.dataset.state = dirty ? "dirty" : "saved";
        elements.dirtyStatus.querySelector("span:last-child").textContent = dirty ? "待保存" : "已保存";
        syncActionAvailability();
      }
      
      function syncActionAvailability() {
        const busy = state.loadingFile || state.saving || state.deleting;
        elements.saveButton.disabled = busy || !state.dirty;
        elements.deleteButton.disabled = busy || !state.activeFile;
      }
      
      function setSaving(saving) {
        state.saving = saving;
        elements.dirtyStatus.dataset.state = saving ? "saving" : state.dirty ? "dirty" : "saved";
        elements.dirtyStatus.querySelector("span:last-child").textContent = saving
          ? "保存中"
          : state.dirty
            ? "待保存"
            : "已保存";
        syncActionAvailability();
      }
      
      function renderBreadcrumbs(path) {
        elements.breadcrumbs.replaceChildren();
        path.split("/").forEach((part, index, parts) => {
          const label = document.createElement("span");
          label.textContent = part;
          elements.breadcrumbs.append(label);
          if (index < parts.length - 1) {
            const divider = document.createElement("i");
            divider.textContent = "/";
            elements.breadcrumbs.append(divider);
          }
        });
      }
      
      function updateDocumentMeta() {
        if (!state.activeFile) return;
        const content = elements.editorInput.value;
        elements.documentMeta.textContent = [
          formatBytes(currentByteSize()),
          `${formatNumber(countCharacters(content))} 字符`,
          fileExtension(state.activeFile.name).toUpperCase(),
        ].join("  ·  ");
      }
      
      function updateCursorPosition() {
        const content = elements.editorInput.value;
        const caret = elements.editorInput.selectionStart;
        const before = content.slice(0, caret);
        const lines = before.split("\n");
        updateActiveGutterLine(lines.length);
        elements.cursorPosition.textContent = `第 ${lines.length} 行,第 ${[...lines.at(-1)].length + 1} 列`;
      }
      
      function updateActiveGutterLine(currentLine) {
        const prevActive = elements.lineNumbersGutter.querySelector(".gutter-line.active");
        if (prevActive && prevActive.dataset.line === String(currentLine)) return;
        if (prevActive) prevActive.classList.remove("active");
        const target = elements.lineNumbersGutter.querySelector(`.gutter-line[data-line='${currentLine}']`);
        if (target) target.classList.add("active");
      }
      
      let lineMirror = null;
      function getLineMirror() {
        if (!lineMirror) {
          lineMirror = document.createElement("div");
          lineMirror.className = "editor-line-mirror";
          document.body.appendChild(lineMirror);
        }
        return lineMirror;
      }
      
      function updateLineNumbers() {
        if (!state.showLineNumbers || elements.editorContainer.hidden) return;
        const input = elements.editorInput;
        const text = input.value;
        const lines = text.split("\n");
        const count = lines.length;
      
        const style = window.getComputedStyle(input);
        const mirror = getLineMirror();
        mirror.style.fontFamily = style.fontFamily;
        mirror.style.fontSize = style.fontSize;
        mirror.style.fontWeight = style.fontWeight;
        mirror.style.fontStyle = style.fontStyle;
        // 与 textarea 一样使用无单位行高,避免像素小数取整在长文中累积偏移。
        mirror.style.lineHeight = String(parseFloat(style.lineHeight) / parseFloat(style.fontSize));
        mirror.style.letterSpacing = style.letterSpacing;
        mirror.style.wordSpacing = style.wordSpacing;
        mirror.style.textTransform = style.textTransform;
        mirror.style.textIndent = style.textIndent;
        mirror.style.whiteSpace = "pre-wrap";
        mirror.style.wordBreak = style.wordBreak;
        mirror.style.overflowWrap = "break-word";
        mirror.style.tabSize = style.tabSize || "4";
      
        const padLeft = parseFloat(style.paddingLeft) || 0;
        const padRight = parseFloat(style.paddingRight) || 0;
        const innerWidth = input.clientWidth - padLeft - padRight;
        if (innerWidth <= 0) return;
        mirror.style.width = `${innerWidth}px`;
      
        mirror.innerHTML = lines.map((line) => `<div>${escapeHtml(line) || "&#8203;"}</div>`).join("");
      
        const caret = input.selectionStart || 0;
        const beforeCaret = text.slice(0, caret);
        const activeLineIndex = beforeCaret.split("\n").length - 1;
      
        elements.lineNumbersGutter.style.paddingTop = style.paddingTop;
        elements.lineNumbersGutter.style.paddingBottom = style.paddingBottom;
        elements.lineNumbersGutter.style.lineHeight = style.lineHeight;
        const children = mirror.children;
        const rows = document.createDocumentFragment();
        for (let i = 0; i < count; i++) {
          const isActive = i === activeLineIndex ? " active" : "";
          const row = document.createElement("div");
          row.className = `gutter-line${isActive}`;
          row.dataset.line = String(i + 1);
          row.textContent = String(i + 1);
          row.style.height = `${children[i].getBoundingClientRect().height}px`;
          rows.append(row);
        }
        elements.lineNumbersGutter.replaceChildren(rows);
        elements.lineNumbersGutter.scrollTop = input.scrollTop;
      }
      
      let lineNumbersFrame = null;
      function scheduleLineNumbers() {
        if (lineNumbersFrame !== null) return;
        lineNumbersFrame = window.requestAnimationFrame(() => {
          lineNumbersFrame = null;
          updateLineNumbers();
        });
      }
      
      function setLineNumbersVisible(visible) {
        state.showLineNumbers = visible;
        elements.editorBody.classList.toggle("show-line-numbers", visible);
        elements.lineNumbersButton.setAttribute("aria-pressed", String(visible));
        scheduleLineNumbers();
        syncViewState();
      }
      
      function viewStateKey() {
        return `story_dashboard_view:${state.workspace.workspace.path}`;
      }
      
      function syncViewState() {
        if (!state.restored) return;
        const saved = {
          view: state.activeView,
          file: state.activeFile?.path || null,
          mode: state.mode,
          showLineNumbers: state.showLineNumbers,
        };
        try {
          localStorage.setItem(viewStateKey(), JSON.stringify(saved));
        } catch {}
        const url = new URL(window.location.href);
        url.searchParams.set("view", saved.view);
        if (saved.file) {
          url.searchParams.set("file", saved.file);
          url.searchParams.set("mode", saved.mode);
        } else {
          url.searchParams.delete("file");
          url.searchParams.delete("mode");
        }
        window.history.replaceState(null, "", url);
      }
      
      async function restoreViewState() {
        let saved = {};
        try {
          saved = JSON.parse(localStorage.getItem(viewStateKey())) || {};
        } catch {}
        const params = new URLSearchParams(window.location.search);
        // 显式链接优先于这个工作区上次打开的文稿。
        const linked = ["view", "file", "mode"].some((key) => params.has(key));
        const file = linked ? params.get("file") : saved.file;
        const mode = (linked ? params.get("mode") : saved.mode) === "preview" ? "preview" : "edit";
        const requestedView = linked ? params.get("view") : saved.view;
        const defaultView = state.workspace.libraries.length ? "libraries" : "projects";
        const view = ["libraries", "projects"].includes(requestedView)
          ? requestedView
          : typeof file === "string" ? deduceViewForPath(file) : defaultView;
        setLineNumbersVisible(saved.showLineNumbers !== false);
        setMode(mode);
        if (typeof file === "string" && file) await openFile(file, { force: true });
        setActiveView(view);
        state.restored = true;
        syncViewState();
      }
      
      function expandParentDirs(filePath) {
        if (!filePath) return;
        const parts = filePath.split("/");
        let current = "";
        for (let i = 0; i < parts.length - 1; i++) {
          current = current ? `${current}/${parts[i]}` : parts[i];
          state.expandedDirs.add(current);
          state.collapsedDirs.delete(current);
        }
      }
      
      function deduceViewForPath(filePath) {
        if (!state.workspace || !filePath) return state.activeView;
        const containsFile = ({ path }) => path === "." || filePath.startsWith(`${path}/`);
        if (state.workspace.libraries.some(containsFile)) {
          return "libraries";
        }
        if (state.workspace.projects.some(containsFile)) {
          return "projects";
        }
        return state.activeView;
      }
      
      async function openFile(path, { force = false } = {}) {
        if (state.loadingFile || (!force && !confirmDiscard())) return;
        state.loadingFile = true;
        syncActionAvailability();
        elements.fileTree.setAttribute("aria-busy", "true");
        try {
          const file = await requestJson(`/api/file?path=${encodeURIComponent(path)}`);
          const normalized = normalizeEol(file.content);
          file.eol = detectEol(file.content);
          file.content = normalized;
          state.activeFile = file;
          state.originalContent = normalized;
          elements.editorInput.value = normalized;
          elements.editorTitle.textContent = file.name;
          renderBreadcrumbs(file.path);
          setDirty(false);
          expandParentDirs(file.path);
          setActiveView(deduceViewForPath(file.path));
          setMode(state.mode);
          updateDocumentMeta();
          updateCursorPosition();
          elements.editorEmpty.hidden = true;
          elements.editorWorkspace.hidden = false;
          document.body.classList.add("document-open");
          syncActiveRow();
          syncViewState();
          scheduleLineNumbers();
          if (state.mode === "edit") window.requestAnimationFrame(() => elements.editorInput.focus());
        } catch (error) {
          showToast(error.message, "error");
        } finally {
          state.loadingFile = false;
          syncActionAvailability();
          elements.fileTree.removeAttribute("aria-busy");
        }
      }
      
      function escapeHtml(value) {
        return value
          .replaceAll("&", "&amp;")
          .replaceAll("<", "&lt;")
          .replaceAll(">", "&gt;")
          .replaceAll('"', "&quot;")
          .replaceAll("'", "&#039;");
      }
      
      function inlineMarkdown(value) {
        return value
          .replace(/`([^`]+)`/g, "<code>$1</code>")
          .replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
          .replace(/__([^_]+)__/g, "<strong>$1</strong>")
          .replace(/(?<!\*)\*([^*\n]+)\*(?!\*)/g, "<em>$1</em>");
      }
      
      function markdownToSafeHtml(markdown) {
        const lines = escapeHtml(markdown).replaceAll("\r\n", "\n").split("\n");
        const output = [];
        let inCode = false;
        let codeLines = [];
        let codeStartLine = 1;
        let listType = null;
      
        const closeList = () => {
          if (listType) output.push(`</${listType}>`);
          listType = null;
        };
      
        for (let i = 0; i < lines.length; i++) {
          const line = lines[i];
          const lineNum = i + 1;
          if (line.trim().startsWith("```")) {
            closeList();
            if (inCode) {
              output.push(`<div class="preview-code-block" data-line="${codeStartLine}"><pre><code>${codeLines.join("\n")}</code></pre></div>`);
              codeLines = [];
            } else {
              codeStartLine = lineNum;
            }
            inCode = !inCode;
            continue;
          }
          if (inCode) {
            codeLines.push(line);
            continue;
          }
      
          const heading = line.match(/^(#{1,4})\s+(.+)$/);
          const unordered = line.match(/^\s*[-*+]\s+(.+)$/);
          const ordered = line.match(/^\s*\d+[.)]\s+(.+)$/);
          if (heading) {
            closeList();
            const level = heading[1].length;
            output.push(`<h${level} data-line="${lineNum}">${inlineMarkdown(heading[2])}</h${level}>`);
          } else if (unordered || ordered) {
            const nextType = unordered ? "ul" : "ol";
            if (listType !== nextType) {
              closeList();
              listType = nextType;
              output.push(`<${listType}>`);
            }
            output.push(`<li data-line="${lineNum}">${inlineMarkdown((unordered || ordered)[1])}</li>`);
          } else if (/^\s*([-*_])(?:\s*\1){2,}\s*$/.test(line)) {
            closeList();
            output.push(`<hr data-line="${lineNum}">`);
          } else if (line.startsWith("&gt; ")) {
            closeList();
            output.push(`<blockquote data-line="${lineNum}">${inlineMarkdown(line.slice(5))}</blockquote>`);
          } else if (line.trim()) {
            closeList();
            output.push(`<p data-line="${lineNum}">${inlineMarkdown(line)}</p>`);
          } else {
            closeList();
          }
        }
        if (inCode) output.push(`<div class="preview-code-block" data-line="${codeStartLine}"><pre><code>${codeLines.join("\n")}</code></pre></div>`);
        closeList();
        return output.join("");
      }
      
      function setMode(mode) {
        state.mode = mode;
        elements.modeButtons.forEach((button) => {
          button.setAttribute("aria-pressed", String(button.dataset.mode === mode));
        });
        const previewing = mode === "preview";
        elements.editorContainer.hidden = previewing;
        elements.previewPane.hidden = !previewing;
        if (previewing) {
          elements.previewPane.innerHTML = markdownToSafeHtml(elements.editorInput.value);
        } else {
          window.requestAnimationFrame(() => elements.editorInput.focus());
        }
        scheduleLineNumbers();
        syncViewState();
      }
      
      async function saveFile() {
        if (!state.activeFile || !state.dirty || state.saving || state.deleting) return;
        // 请求发出前就把身份和正文快照下来:保存期间作者可能换文件、也可能接着敲字,
        // 收尾只允许写回这次真正送出去的那份,绝不能落到别的文稿头上。
        const file = state.activeFile;
        const sent = elements.editorInput.value;
        setSaving(true);
        try {
          const saved = await requestJson("/api/file", {
            method: "PUT",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify({
              path: file.path,
              content: applyEol(sent, file.eol),
              expectedVersion: file.version,
            }),
          });
          file.mtimeMs = saved.mtimeMs;
          file.version = saved.version;
          file.size = saved.size;
          showToast(`已保存《${file.name}》`);
          if (state.activeFile !== file) return;
          state.originalContent = sent;
          // 保存途中敲进来的字仍是未保存修改,不能被这次结果抹平成「已保存」
          setDirty(elements.editorInput.value !== sent);
          updateDocumentMeta();
        } catch (error) {
          if (state.activeFile !== file) {
            showToast(`《${file.name}》保存失败:${error.message}`, "error");
            return;
          }
          setDirty(true);
          if (error instanceof ApiError && error.status === 409) {
            elements.conflictDialog.showModal();
          } else {
            showToast(error.message, "error");
          }
        } finally {
          setSaving(false);
        }
      }
      
      async function deleteFile() {
        if (!state.activeFile || state.saving || state.deleting) return;
        const file = state.activeFile;
        const warning = state.dirty
          ? `《${file.name}》还有未保存修改。删除会永久移除磁盘文件并丢弃这些修改,且无法撤销。确定删除吗?`
          : `确定永久删除《${file.name}》吗?此操作无法撤销。`;
        if (!window.confirm(warning)) return;
      
        state.deleting = true;
        syncActionAvailability();
        try {
          await requestJson("/api/file", {
            method: "DELETE",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify({
              path: file.path,
              expectedVersion: file.version,
            }),
          });
          state.activeFile = null;
          state.originalContent = "";
          elements.editorInput.value = "";
          elements.editorWorkspace.hidden = true;
          elements.editorEmpty.hidden = false;
          document.body.classList.remove("document-open");
          setDirty(false);
          syncViewState();
          await loadWorkspace();
          showToast(`已删除《${file.name}》`);
        } catch (error) {
          showToast(error.message, "error");
        } finally {
          state.deleting = false;
          syncActionAvailability();
        }
      }
      
      async function searchWorkspace(query, sequence) {
        state.searching = true;
        renderTree();
        try {
          const result = await requestJson(
            `/api/search?q=${encodeURIComponent(query)}&scope=${encodeURIComponent(state.activeView)}`,
          );
          if (sequence !== state.searchSequence) return;
          state.searchResults = result.results;
          state.searchTruncation = result.truncated
            ? {
                ...(result.truncation || {
                  byResults: true,
                  byNodes: false,
                  byDepth: false,
                  byReadError: false,
                }),
                scanErrors: result.scanErrors || [],
                limits: result.limits,
              }
            : null;
        } catch (error) {
          if (sequence !== state.searchSequence) return;
          state.searchResults = [];
          state.searchTruncation = null;
          showToast(error.message, "error");
        } finally {
          if (sequence === state.searchSequence) {
            state.searching = false;
            renderTree();
          }
        }
      }
      
      function scheduleSearch() {
        window.clearTimeout(state.searchTimer);
        const query = state.filter.trim();
        state.searchSequence += 1;
        const sequence = state.searchSequence;
        if (!query) {
          state.searching = false;
          state.searchResults = [];
          state.searchTruncation = null;
          renderTree();
          return;
        }
        state.searching = true;
        renderTree();
        state.searchTimer = window.setTimeout(() => searchWorkspace(query, sequence), 180);
      }
      
      function setActiveView(view) {
        state.activeView = view;
        elements.archiveTabs.forEach((tab) => {
          const selected = tab.dataset.view === view;
          tab.setAttribute("aria-selected", String(selected));
          tab.tabIndex = selected ? 0 : -1;
        });
        elements.treePanel.setAttribute(
          "aria-labelledby",
          view === "libraries" ? "librariesTab" : "projectsTab",
        );
        if (state.filter.trim()) {
          scheduleSearch();
        } else {
          renderTree();
        }
        syncViewState();
      }
      
      elements.archiveTabs.forEach((tab) => {
        tab.addEventListener("click", () => setActiveView(tab.dataset.view));
        tab.addEventListener("keydown", (event) => {
          if (!["ArrowLeft", "ArrowRight"].includes(event.key)) return;
          event.preventDefault();
          const direction = event.key === "ArrowRight" ? 1 : -1;
          const current = elements.archiveTabs.indexOf(event.currentTarget);
          const next = elements.archiveTabs.at(
            (current + direction + elements.archiveTabs.length) % elements.archiveTabs.length,
          );
          setActiveView(next.dataset.view);
          next.focus();
        });
      });
      
      elements.treeSearch.addEventListener("input", (event) => {
        state.filter = event.currentTarget.value;
        scheduleSearch();
      });
      
      elements.treeSearch.addEventListener("keydown", (event) => {
        if (event.key === "Escape") {
          event.currentTarget.value = "";
          state.filter = "";
          scheduleSearch();
        }
      });
      
      elements.refreshButton.addEventListener("click", () => loadWorkspace({ announce: true }));
      elements.mobileBackButton.addEventListener("click", () => {
        document.body.classList.remove("document-open");
        window.requestAnimationFrame(() => elements.treeSearch.focus());
      });
      elements.saveButton.addEventListener("click", saveFile);
      elements.deleteButton.addEventListener("click", deleteFile);
      
      elements.editorInput.addEventListener("input", () => {
        setDirty(elements.editorInput.value !== state.originalContent);
        updateDocumentMeta();
        updateCursorPosition();
        scheduleLineNumbers();
      });
      
      elements.editorInput.addEventListener("scroll", () => {
        elements.lineNumbersGutter.scrollTop = elements.editorInput.scrollTop;
      }, { passive: true });
      
      elements.lineNumbersButton.addEventListener("click", () => {
        setLineNumbersVisible(!state.showLineNumbers);
      });
      
      new ResizeObserver(scheduleLineNumbers).observe(elements.editorInput);
      document.fonts.ready.then(scheduleLineNumbers);
      
      ["click", "keyup", "select"].forEach((eventName) => {
        elements.editorInput.addEventListener(eventName, updateCursorPosition);
      });
      
      elements.modeButtons.forEach((button) => {
        button.addEventListener("click", () => setMode(button.dataset.mode));
      });
      
      elements.conflictDialog.addEventListener("close", () => {
        if (elements.conflictDialog.returnValue === "reload" && state.activeFile) {
          openFile(state.activeFile.path, { force: true });
        }
      });
      
      document.addEventListener("keydown", (event) => {
        const modifier = event.metaKey || event.ctrlKey;
        if (modifier && event.key.toLocaleLowerCase() === "s") {
          event.preventDefault();
          saveFile();
        }
        if (modifier && event.key.toLocaleLowerCase() === "k") {
          event.preventDefault();
          elements.treeSearch.focus();
          elements.treeSearch.select();
        }
      });
      
      window.addEventListener("beforeunload", (event) => {
        if (state.dirty) {
          event.preventDefault();
          event.returnValue = "";
        }
      });
      
      loadWorkspace();
      
    • index.html 9.2 KB · in bundle
    • styles.css 25.9 KB · in bundle
  • references
    • author-memory.md 17.1 KB
      # 作者记忆协议
      
      作者记忆用于保存跨会话复用的创作偏好,不保存小说世界里的事实。它借鉴“原始证据 → 候选 → 已确认画像 → 变更记录”的记忆管道,但把决定权留给作者。
      
      ## 边界与优先级
      
      加载优先级从高到低:
      
      1. 安全、用户授权范围、明确的平台交付要求、字数与文件协议;句长、视角、修辞和标点偏好不属于不可覆盖的硬门禁;
      2. 用户在当前请求中的明确要求;
      3. 当前书的 `设定/文风.md`、题材定位、细纲和其他项目设定;
      4. 作者记忆中的本书偏好;
      5. 作者记忆中的题材、流程和全局偏好;
      6. 对标素材、通用方法和默认值。
      
      按表达维度取最窄适用要求:低优先级只补缺项,不与高优先级要求并列执行。通用 references 自称“必须/禁用”不改变此顺序;审稿不因作者有意采用的表达本身扣分,真实可读性与因果问题仍照常评价。
      
      作者记忆不能把本书事实写进 `.story/作者记忆/`,不能覆盖当前请求,不能降低审稿 rubric,也不能让去 AI 味改动剧情意图。小说事实继续由各书的 `追踪/` 和 `设定/` 管理。
      
      ## 文件与所有权
      
      作者记忆分两级存放,记忆随书走:
      
      ```text
      {工作区}/.story/作者记忆/          # 项目级 store:global / genre / workflow 条目,编号 AP
      ├── _author-memory-state.json  # 唯一结构化权威
      ├── 作者画像.md               # 仅 active,供作者查看与管理
      ├── 待确认.md                 # pending / conflict,不参与约束
      └── 变更记录.md               # 最近 100 次、最新在前的事务记录
      {书}/.story/作者记忆/            # 书级 store:只存这本书的 book 条目,编号 BP,同样四个文件
      ```
      
      三个 Markdown 文件都从 state 确定性生成,禁止手改;完整历史保留在 state,变更记录只展示最近 100 次。`作者画像.md` 是人类管理视图,普通写作 agent 不整份注入,而是调用 `query` 取得本次相关的紧凑上下文。作者记忆不存在时,普通写作、审稿和去味任务直接继续,不自动初始化空目录;首次 `record` 会随事务创建。
      
      **定位规则**:`--workspace` 必须显式传给脚本——优先使用已经包含 `.story/作者记忆/` 的最近祖先;首次初始化时使用承载多本书、`.active-book`、`长篇/`、`短篇/` 或 `拆文库/` 的创作工作区根;不要把用户主目录当默认工作区。`--book-root` 是当前书的项目目录(`.active-book` 指向、或含 `设定/`、`正文/` 的那一层,如 `{工作区}/长篇/{书名}/`);书名默认取书级 state 记录的名字,首次建立时取目录名,`--book` 可覆盖。
      
      **路由规则**:ID 前缀就是 store——`decide` / `forget` 看 `item_id`(`AP` 进项目级,`BP` 进书级),`remember` / `replace` 看 `scope.level`(`book` 进书级,其余进项目级)。书级操作必须传 `--book-root`,没传直接报错,不会退而写进项目级。一份 `commit` 事务只能写一个 store;`replace` 与 `conflicts_with` 不能跨 store——本书例外按优先级覆盖全局规则,不算冲突,直接 `remember` 为 book 条目即可;要把全局规则改成本书规则,拆成 `forget` + `remember` 两个事件。
      
      **存量迁移,不做双读**:升级前写进项目级 store 的 book 条目不再参与查询与预算估算,也不再接受新的 book 写入;它们仍在 `作者画像.md` 里可见、可 `decide` / `forget`。对每本书运行一次 `migrate --book-root {书目录}` 即可整批搬回来:断言、证据、确认次数、重要度原样保留,换成 `BP` 编号,原 `AP` 条目标 `superseded` 并注明去向;与全局条目的冲突关系在迁移后不再成立,这类候选退回 `pending`。书级每个源条目一笔事务,重跑只补没做完的一半。「整理作者记忆」看到项目级画像里还有「本书:」条目时,把迁移列为默认提案项。
      
      ## 什么时候读取
      
      长篇、短篇、去 AI 味开始前,如果 state 已存在,用 `query` 按本书、题材、流程和类型筛选 active 条目:结果是项目级 store 与 `--book-root` 所指书级 store 的合并;不传 `--book-root` 就拿不到任何本书条目。查询输出固定不超过 2048 字节。`--kind` 必传——不传直接报错,不再默认返回全部类型;按任务直接选择 kind:
      
      | 任务 | query kinds | 注入位置 |
      |---|---|---|
      | 正文初稿 / 续写 | `prose_style` + `story_design` | 主会话与实际正文 agent |
      | 去 AI 味 / 改写 | `prose_style` | 主会话与实际改写 agent |
      | 设定 / 大纲 | `story_design` + `workflow` + `interaction` | 主会话,不传正文 agent |
      | 审稿 | `delivery` + `interaction` + 必要的 `prose_style` | 主会话,不降低 rubric |
      
      审稿匹配项只用于交付格式、协作方式和“作者有意采用的表达选择”说明;问题严重度和 PASS/FAIL 仍由 rubric 决定。
      
      待确认项不进入 prompt 约束,也不应为了确认它们中断当前任务。只有用户主动查看作者画像、候选积累到适合回顾的节点,或新偏好与 active 条目冲突时,才集中呈现。
      
      ## 可靠性与负荷边界
      
      - 明确“记住 / 确认 / 替换 / 忘掉”的请求走单事件 `record`,不要求 agent 手工读取修订号或拼多操作事务。成功响应会给出 `Author Memory Receipt: rN · APxxx`;没有回执就不得声称“已经记住”。回执带 `warnings` 时把提醒原样转告作者。
      - 普通创作只做一次本地 `query`,没有 state 时返回空结果且不创建文件;有记忆时也只返回相关 active 条目,硬上限 2048 字节。完整画像、证据、候选和 journal 不进入正文 prompt。
      - **写入不因注入预算失败**:`record` / `commit` 照常成功、给回执;工具按上表四类任务组合估算最坏查询情形(全局条目+各 scope 维度最重的单一切片,切片按大小写无关归并、轻重按写作时真正读到的字段算,与真实查询同一把尺),装不进 2048 字节的组合在返回的 `warnings` 里点名将被略过的条目及其断言首句。收到提醒就转告作者并建议「整理作者记忆」。写书级条目时「本书+全局」按实际条目精确计算;写项目级条目时只看得到项目级 store,顺手传 `--book-root` 就把当前这本书也算进提醒。查询按 **重要度 → 本书例外 → 最近更新** 排序装填(跨 store 的「最近」按提交时刻比),先丢的恒是重要度较低的条目——`importance` 决定超编时谁留在 prompt 里,写入时按偏好的实际分量填,不要一律 `high`。
      - 注入预算之外还有一道硬上限:`作者画像.md` 超过 12288 字节时写入会直接失败并要求先整理。active 条目攒到几十上百条才会碰到(远在注入预算之后),碰到就走「整理作者记忆」;`forget` 这类减量操作在满编时照常可用。
      - 查询项是低优先级倾向,不是逐条打卡清单。自然吸收即可,不复述画像、不刻意提高词面命中率,也不得为命中偏好牺牲正文连贯、节奏、字数或本书既定笔调。
      - 不安装会记录全部用户消息的 prompt hook,也不在作者没开口时观察他:不从反复修改、成稿或操作轨迹推断偏好写入。只记作者明确表达的偏好;自然语言是否属于长期习惯仍需 agent 判断,拿不准就只执行不记录。需要确定写入时,用户可明确说“记住:……”,并以回执验收。
      
      ## 捕获判定
      
      | 输入证据 | 处理 |
      |---|---|
      | “以后都这样”“我一直习惯……”等直接、稳定、范围清楚的原话 | `active`,`source=explicit_user` |
      | 用户明确接受助手提出的长期做法 | `active`,`source=accepted_suggestion` |
      | 作者原话像长期偏好但范围或稳定性含糊 | `pending`,取当前最窄合理范围;待确认只来自作者自己的话 |
      | 同类修改反复出现、从成稿或操作轨迹看出的模式 | 不记录、不推断;作者没开口的偏好不进记忆 |
      | “这一章别……”“这次给我……”等一次性要求 | 只执行,不记录 |
      | 角色、时间线、伏笔、世界观、当前剧情走向 | 写项目设定/追踪,不写作者记忆 |
      | 助手自己生成的文字、默认模板、工具告警、rubric 结论 | 不自我学习 |
      
      保留用户的否定词、限定词和适用范围,`quote` 写原话,`assertion` 只做不改变语义的紧凑归纳,**新建条目限一句话(≤120 字节,约 40 个字)**;需要解释的背景写进 `reason`(不进 prompt 载荷),不另开字段。
      
      **一条偏好就是一条记录,例外和限定不许拆出去单列。** 「以后少用破折号,对话里也别用,除非表示打断」是一条带例外的偏好,必须整条写成一个 `assertion`(「破折号少用、对话里也不用,只在表示打断时保留」)。超编时条目是逐条被丢的,把「除非表示打断」拆成独立一条,就可能只丢掉例外、把作者明说过的限定变成送进正文的绝对禁令。只有原话确实塞了**几条互不依赖**的偏好(如「多用短句」+「章末留钩子」)才拆成几条。整句写不进 120 字节时压缩措辞,不要切掉限定词。
      
      升级前写下的长断言不受新上限约束:原样重申它会**强化**原条目(确认次数 +1),不会因超长被拒;只有真正新建条目才校验 120 字节。
      
      范围规则:
      
      - “本书 / 这个角色 / 这次连载” → `book`;
      - “都市文 / 这类题材” → `genre`;
      - 交稿、检查、确认节奏等操作习惯 → `workflow`;
      - “以后 / 一贯 / 我习惯”且无更窄限定 → `global`;
      - 范围含糊但可能稳定 → 取当前最窄合理范围并置 `pending`。
      
      类型可选:`prose_style`、`story_design`、`workflow`、`delivery`、`interaction`。置信度与重要度均为 `low | medium | high`。`source` 只接受 `explicit_user`、`accepted_suggestion`、`manual`;工具会拒绝推断类来源,存量 state 里的旧来源条目照常可读、可确认、可退役。
      
      ## 冲突、撤回与强化
      
      - 同一类型、范围、归纳文本再次出现时,脚本强化原条目,累加证据和确认次数,不重复建条目。
      - 新偏好与同一 store 里的 active 条目矛盾时,先以 `conflict` 记候选,并在 `conflicts_with` 列出冲突 ID;当前任务仍按本轮明确要求执行。本书例外与全局规则不算冲突。
      - 同一范围的规则改版用 `replace`,启用新条目并将旧条目标成 `superseded`。本书例外用 `remember` 新建 book 条目(传 `--book-root`),保留 global 习惯;只有作者明确撤销或改变旧规则范围时才跨范围替换,跨 store 时拆成 `forget` + `remember`。
      - pending 可以用 `decide=activate|reject`;冲突候选不能绕过旧规则直接 activate。
      - 作者说“忘掉 / 这不再是我的习惯”时用 `forget`,保留历史证据但不再加载。
      - active 条目的语义不可原地偷改;语义变化必须 replace,历史才可审计。
      
      ## 整理作者记忆
      
      作者说「整理作者记忆」,或回执 `warnings`/查询 `omitted_ids` 提示超编时:读项目级与当前书的 `作者画像.md`(每条都标了范围、重要度、把握和确认次数,重要度就是超编时的去留依据),提出合并同义条(`replace` 多合一)、退役过时条(`forget`)、给错标成 `high` 的条目下调重要度、把超长断言压缩成一句话的提案;项目级画像里还有「本书:」条目时,「对该书运行 `migrate --book-root`」列为默认提案项。清单让作者逐条确认,确认后按 store 各汇成一份 `commit` 事务提交(一份事务只写一个 store)。合并时保住每条的否定词、限定词和适用范围——合不动就退役其中一条,不要靠删限定词把两条凑成一条。整理只由作者发起或确认,不自动执行。
      
      ## 运行工具
      
      先依次尝试 `python3`、`python`、`py -3` 找到 Python 3,再从当前 skill 根运行本地副本:
      
      ```text
      {PYTHON} {当前 skill 根}/scripts/author_memory_commit.py init    --workspace {工作区} [--book-root {书目录}]
      {PYTHON} {当前 skill 根}/scripts/author_memory_commit.py record  --workspace {工作区} [--book-root {书目录}] --input {单事件.json}
      {PYTHON} {当前 skill 根}/scripts/author_memory_commit.py query   --workspace {工作区} --book-root {书目录} --kind {类型}(必传,可重复) [--genre {题材}] [--workflow {流程}]
      {PYTHON} {当前 skill 根}/scripts/author_memory_commit.py commit  --workspace {工作区} [--book-root {书目录}] --input {事务.json}
      {PYTHON} {当前 skill 根}/scripts/author_memory_commit.py migrate --workspace {工作区} --book-root {书目录}
      {PYTHON} {当前 skill 根}/scripts/author_memory_commit.py check   --workspace {工作区} [--book-root {书目录}]
      ```
      
      所有子命令都可加 `--book {书名}` 覆盖书名;正在写某本书时一律带上 `--book-root`,书级操作没有它会直接报错。
      
      - `record`:常用单事件入口,按路由规则落到项目级或书级 store,自动读取该 store 的当前修订、首次自动初始化;`event_id` 相同且内容相同会幂等返回原回执,内容不同会失败。原样重申已有条目的 `assertion` 会强化该条(不受 120 字节新建上限约束)。返回里的 `store` / `book` 说明写到了哪一级,`warnings` 是预算提醒,要原样转告作者。
      - `query`:只读相关 active 条目,项目级与书级合并返回;`--kind` 必传、可重复,两级 state 都不存在时返回空结果且零写入。输出按 重要度 → 本书例外 → 最近更新 排序装填;装不下的条目跳过而不中断(一条长的不挡后面的短条),漏下的 ID 按同一优先级报进 `omitted_ids`(最多列 20 条,`omitted` 是真实总数)。**`omitted_ids` 非空=记忆超编**——转告作者并建议「整理作者记忆」,它不是「没有更多了」,也不得改读完整画像规避预算。
      - `commit`:高级批量入口;一份事务只写一个 store,先在内存完成 schema、引用、容量和所有视图校验,最后原子替换 state。事务文件在成功前必须保留;过期修订会在任何写入前失败。
      - `migrate`:把项目级 store 里某本书的存量 book 条目整批搬进 `--book-root` 的书级 store,幂等,中途失败直接重跑;返回 `migrated`(源→新编号),没有存量时为空。
      - `check`:从 state 重建并逐字核验所有派生视图;传 `--book-root` 时两级一起核验。
      
      ## 事务格式
      
      常用单事件新增或强化:
      
      ```json
      {
        "schema_version": 1,
        "event_id": "conversation-2026-08-25-message-42",
        "operation": {
          "action": "remember",
          "preference": {
            "kind": "prose_style",
            "scope": {"level": "global", "value": null},
            "assertion": "对话尽量短,用动作承接情绪,不用大段解释",
              "quote": "以后对话都短一点,情绪放动作里,别让角色长篇解释。",
              "source_ref": "conversation:2026-08-25",
              "source": "explicit_user",
              "confidence": "high",
              "importance": "high",
            "status": "active",
            "reason": "用户以“以后”明确声明长期偏好",
            "conflicts_with": []
          }
        }
      }
      ```
      
      把文件交给 `record`(book 范围的事件同时传 `--book-root`)。待确认项的 `status` 用 `pending`;冲突候选用 `conflict` 并填写同一 store 里的 active ID。确认或拒绝候选时,把下列对象作为新事件的 `operation`(`BP` 编号的事件传 `--book-root`):
      
      ```json
      {"action":"decide","item_id":"AP002","decision":"activate","quote":"对,这就是我的长期习惯。","reason":"作者明确确认"}
      ```
      
      用新规则替代一个或多个旧条目时,`replace.preference` 与上例字段相同,但不传 `status`、`conflicts_with`,新条目直接 active;下列对象同样作为 `operation`:
      
      ```json
      {
        "action": "replace",
        "old_ids": ["AP001"],
        "preference": {
          "kind": "prose_style",
          "scope": {"level": "global", "value": null},
          "assertion": "以后对话允许更长的试探,但避免解释设定",
          "quote": "把以前对话都要短的习惯替换掉:以后可以让对话慢一点,多试探,但还是别拿台词讲设定。",
          "source_ref": "conversation:2026-08-25",
          "source": "explicit_user",
          "confidence": "high",
          "importance": "high",
          "reason": "作者明确替换原有全局规则,不是新增本书例外"
        }
      }
      ```
      
      撤回条目的 `operation`:
      
      ```json
      {"action":"forget","item_id":"AP003","quote":"忘掉这个偏好。","reason":"作者明确撤回"}
      ```
      
      需要把多个动作绑定成一次原子提交时才用高级 `commit`:顶层传 `schema_version`、唯一 `transaction_id`、当前 `expected_state_revision` 和含 1–32 项的 `operations`。操作按数组顺序应用,任一步失败则整份事务零写入。成功后删除临时输入文件;显式记忆请求还要把工具返回的回执原样告诉用户。
      
  • scripts
    • author_memory_commit.py 70.7 KB
      #!/usr/bin/env python3
      """Maintain evidence-backed author preferences and deterministic Markdown views.
      
      The language model supplies compact semantic transactions. This tool validates
      and applies them in memory, renders every derived view, and writes the JSON state
      last as the commit point. Author memory lives in two kinds of store: the
      project-level store under the workspace holds global / genre / workflow items
      (`AP` ids); each book keeps its own book-level store under the book directory
      (`BP` ids) so memory travels with the book. Both stay separate from each book's
      story-continuity tracking.
      """
      
      from __future__ import annotations
      
      import argparse
      import copy
      import hashlib
      import json
      import os
      import stat
      import sys
      import tempfile
      from datetime import datetime, timezone
      from pathlib import Path
      from typing import Any
      
      
      INPUT_SCHEMA_VERSION = 1
      STATE_SCHEMA_VERSION = 1
      STATE_MAX_BYTES = 2 * 1024 * 1024
      PROFILE_MAX_BYTES = 12288
      PENDING_MAX_BYTES = 12288
      JOURNAL_MAX_BYTES = 24576
      QUERY_MAX_BYTES = 2048
      ASSERTION_MAX_BYTES = 120  # 新建条目的断言限一句话;解释进 reason(不进 query 载荷)。
      OMITTED_IDS_MAX = 20  # omitted_ids 封顶,omitted 保留真实总数——漏项列表不许把载荷本身挤炸。
      LEGACY_ASSERTION_MAX_BYTES = 768  # 存量条目的读取上限;强化老条目不受新上限约束。
      
      # query 的输出是要原样贴进执行 agent prompt 的注入载荷,QUERY_MAX_BYTES
      # 是它在 prompt 里的注意力预算,不该放大。防「作者以为载入了、实际被静默
      # 截断挤掉」靠三层:①新建条目的断言限 ASSERTION_MAX_BYTES,从源头短(强化
      # 已有条目不受限,否则存量长断言再也无法被确认,只会派生重复条目);②写入
      # 端按下列任务组合(与 references/author-memory.md 的映射表同包跟版)估算
      # 最坏查询情形——全局条目+各 scope 维度上最重的单一切片(一次查询只带一
      # 个 book/genre/workflow,不同书的条目不会同现;切片按 casefold 归并,与
      # same_scope_value 同一口径,轻重按 compact 字节+列表分隔符算,与真实载荷
      # 同一把尺),装不下时在返回的 warnings 里点名将被略过的条目、指向「整理作
      # 者记忆」,写入本身永不因注入预算失败;③查询按 重要度→本书例外→最近更新
      # 排序装填,被略过的恒是重要度较低的条目,漏下的 ID 按同一优先级顺序报进
      # omitted_ids。
      QUERY_COMBOS: dict[str, tuple[str, ...]] = {
          "正文初稿/续写": ("prose_style", "story_design"),
          "去AI味/改写": ("prose_style",),
          "设定/大纲": ("story_design", "workflow", "interaction"),
          "审稿": ("delivery", "interaction", "prose_style"),
      }
      
      KINDS = ("prose_style", "story_design", "workflow", "delivery", "interaction")
      KIND_TITLES = {
          "prose_style": "文风与表达",
          "story_design": "故事设计",
          "workflow": "创作流程",
          "delivery": "交付格式",
          "interaction": "协作方式",
      }
      SCOPE_LEVELS = ("global", "genre", "book", "workflow")
      STATUSES = ("active", "pending", "conflict", "rejected", "superseded")
      CONFIDENCE_LEVELS = ("low", "medium", "high")
      IMPORTANCE_LEVELS = ("low", "medium", "high")
      # 作者记忆只记作者明确表达的偏好。repeated_correction / inferred_pattern 两条
      # 由 agent 主动推断写入的管道已经移除(#436):它们只在攒待确认清单的审阅负
      # 担,不是记忆质量;文档也明说不装全量消息 hook,隐式捕获本就承诺不了完整
      # 性。SOURCES 里保留这两个值只为存量 state 仍能通过校验、仍能 decide/forget,
      # 新写入一律按 WRITE_SOURCES 校验。
      SOURCES = (
          "explicit_user",
          "accepted_suggestion",
          "repeated_correction",
          "inferred_pattern",
          "manual",
      )
      WRITE_SOURCES = ("explicit_user", "accepted_suggestion", "manual")
      RANK = {"low": 0, "medium": 1, "high": 2}
      
      # 两级 store(#435):项目级存 global/genre/workflow,ID 前缀 AP,位于
      # {工作区}/.story/作者记忆/;书级只存该书的 book 条目,ID 前缀 BP,位于
      # {书}/.story/作者记忆/,书归档、迁移时记忆随书走。ID 前缀就是路由键——
      # decide/forget 看 item_id 前缀,remember/replace 看 scope.level,一份事务
      # 只写一个 store。项目级 store 里升级前写入的存量 book 条目不再参与查询与估算,
      # 用 migrate 搬进书目录后才回来——不做双读,双读会让迁移永远没人做。
      STORE_PREFIX = {"project": "AP", "book": "BP"}
      ID_PREFIXES = tuple(STORE_PREFIX.values())
      
      
      class AuthorMemoryError(ValueError):
          """Expected validation or state error."""
      
      
      def require(condition: bool, message: str) -> None:
          if not condition:
              raise AuthorMemoryError(message)
      
      
      def as_mapping(value: object, label: str) -> dict[str, Any]:
          require(isinstance(value, dict), f"{label} must be a JSON object")
          return value
      
      
      def as_list(value: object, label: str) -> list[Any]:
          require(isinstance(value, list), f"{label} must be a JSON array")
          return value
      
      
      def as_int(value: object, label: str, *, minimum: int = 0) -> int:
          require(isinstance(value, int) and not isinstance(value, bool), f"{label} must be an integer")
          require(value >= minimum, f"{label} must be >= {minimum}")
          return value
      
      
      def require_known_keys(mapping: dict[str, Any], allowed: set[str], label: str) -> None:
          unknown = set(mapping) - allowed
          require(not unknown, f"{label} contains unsupported fields: {', '.join(sorted(unknown))}")
      
      
      def clean_text(value: object, label: str, *, max_bytes: int = 768) -> str:
          require(isinstance(value, str), f"{label} must be a string")
          cleaned = " ".join(value.replace("|", "|").split())
          require(bool(cleaned), f"{label} must not be empty")
          require(len(cleaned.encode("utf-8")) <= max_bytes, f"{label} exceeds {max_bytes} bytes")
          return cleaned
      
      
      def optional_text(value: object, label: str, *, max_bytes: int = 768) -> str | None:
          if value is None:
              return None
          return clean_text(value, label, max_bytes=max_bytes)
      
      
      def choice(value: object, allowed: tuple[str, ...], label: str) -> str:
          require(isinstance(value, str) and value in allowed, f"{label} must be one of: {', '.join(allowed)}")
          return value
      
      
      def is_item_id(value: object) -> bool:
          return (
              isinstance(value, str)
              and len(value) >= 3
              and value[:2] in ID_PREFIXES
              and value[2:].isdigit()
              and int(value[2:]) >= 1
          )
      
      
      def id_number(item_id: str) -> int:
          return int(item_id[2:])
      
      
      def id_store(item_id: str) -> str:
          return "book" if item_id.startswith(STORE_PREFIX["book"]) else "project"
      
      
      def clean_id_list(value: object, label: str, *, maximum: int = 32) -> list[str]:
          raw = as_list(value, label)
          require(len(raw) <= maximum, f"{label} may contain at most {maximum} items")
          result: list[str] = []
          for index, item in enumerate(raw):
              item_id = clean_text(item, f"{label}[{index}]", max_bytes=32)
              require(is_item_id(item_id), f"{label}[{index}] is not an author-memory id")
              if item_id not in result:
                  result.append(item_id)
          return result
      
      
      def emit(document: object, *, error: bool = False) -> None:
          payload = json.dumps(document, ensure_ascii=False, sort_keys=True)
          stream = sys.stderr if error else sys.stdout
          stream.flush()
          stream.buffer.write((payload + "\n").encode("utf-8"))
          stream.buffer.flush()
      
      
      def json_payload(document: object) -> str:
          return json.dumps(document, ensure_ascii=False, indent=2, sort_keys=True) + "\n"
      
      
      def read_json(path: Path) -> object:
          try:
              require(path.stat().st_size <= STATE_MAX_BYTES, f"{path} exceeds {STATE_MAX_BYTES} bytes")
              return json.loads(path.read_text(encoding="utf-8"))
          except (OSError, json.JSONDecodeError) as exc:
              raise AuthorMemoryError(f"unable to read JSON {path}: {exc}") from exc
      
      
      def atomic_write_text(path: Path, payload: str) -> None:
          path.parent.mkdir(parents=True, exist_ok=True)
          mode = stat.S_IMODE(path.stat().st_mode) if path.exists() else 0o644
          fd, temporary_name = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=path.parent)
          temporary = Path(temporary_name)
          try:
              with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as handle:
                  handle.write(payload)
                  handle.flush()
                  os.fsync(handle.fileno())
              os.chmod(temporary, mode)
              os.replace(temporary, path)
          finally:
              temporary.unlink(missing_ok=True)
      
      
      def write_if_changed(path: Path, payload: str) -> None:
          try:
              if path.read_text(encoding="utf-8") == payload:
                  return
          except FileNotFoundError:
              pass
          atomic_write_text(path, payload)
      
      
      # ---------------------------------------------------------------------------
      # Stores
      # ---------------------------------------------------------------------------
      
      
      class Store:
          """一个 state 文件的落点:项目级(工作区)或书级(书目录)。"""
      
          __slots__ = ("kind", "root", "book")
      
          def __init__(self, kind: str, root: Path, book: str | None) -> None:
              self.kind = kind
              self.root = root
              self.book = book
      
          @property
          def prefix(self) -> str:
              return STORE_PREFIX[self.kind]
      
          @property
          def state_path(self) -> Path:
              return self.root / "_author-memory-state.json"
      
          @property
          def label(self) -> str:
              return "项目级" if self.kind == "project" else f"书级({self.book})"
      
      
      def project_store(workspace: Path) -> Store:
          return Store("project", workspace.resolve() / ".story" / "作者记忆", None)
      
      
      def peek_book_name(state_path: Path) -> str | None:
          """不校验整份 state,只取书名——书级 store 一旦建立,书名以 state 为准,
          书目录改名不影响。"""
          if not state_path.exists():
              return None
          document = read_json(state_path)
          name = document.get("book") if isinstance(document, dict) else None
          return name if isinstance(name, str) and name.strip() else None
      
      
      def book_store(book_root: Path, book: str | None) -> Store:
          require(book_root.exists() and book_root.is_dir(), f"book root does not exist: {book_root}")
          resolved = book_root.resolve()
          root = resolved / ".story" / "作者记忆"
          name = optional_text(book, "book", max_bytes=180)
          stored = peek_book_name(root / "_author-memory-state.json")
          if name is None:
              name = stored if stored is not None else clean_text(resolved.name, "book root name", max_bytes=180)
          elif stored is not None:
              require(
                  name.casefold() == stored.casefold(),
                  f"--book「{name}」与 {root} 里记录的书「{stored}」不一致",
              )
          return Store("book", root, name)
      
      
      def resolve_target_store(kind: str, workspace: Path, book_root: Path | None, book: str | None) -> Store:
          if kind == "project":
              return project_store(workspace)
          require(
              book_root is not None,
              "book 级条目须传 --book-root {书目录}——记忆随书存放在 {书}/.story/作者记忆/,不再写进工作区的项目级 store",
          )
          return book_store(book_root, book)
      
      
      def empty_state(book: str | None = None) -> dict[str, Any]:
          state: dict[str, Any] = {
              "schema_version": STATE_SCHEMA_VERSION,
              "state_revision": 0,
              "next_item_number": 1,
              "items": {},
              "journal": [],
              "applied_transactions": {},
          }
          if book is not None:
              state["book"] = book
          return state
      
      
      # ---------------------------------------------------------------------------
      # Validation
      # ---------------------------------------------------------------------------
      
      
      def normalize_scope(value: object, label: str) -> dict[str, str | None]:
          scope = as_mapping(value, label)
          require_known_keys(scope, {"level", "value"}, label)
          level = choice(scope.get("level"), SCOPE_LEVELS, f"{label}.level")
          raw_value = scope.get("value")
          if level == "global":
              require(raw_value is None, f"{label}.value must be null for global scope")
              normalized_value = None
          else:
              normalized_value = clean_text(raw_value, f"{label}.value", max_bytes=180)
          return {"level": level, "value": normalized_value}
      
      
      def normalize_evidence(value: object, label: str) -> dict[str, str | None]:
          evidence = as_mapping(value, label)
          require_known_keys(evidence, {"quote", "source_ref"}, label)
          return {
              "quote": clean_text(evidence.get("quote"), f"{label}.quote", max_bytes=768),
              "source_ref": optional_text(evidence.get("source_ref"), f"{label}.source_ref", max_bytes=240),
          }
      
      
      def normalize_item(value: object, label: str) -> dict[str, Any]:
          item = as_mapping(value, label)
          allowed = {
              "id", "kind", "scope", "assertion", "confidence", "importance", "status", "source",
              "reason", "conflicts_with", "confirmation_count", "evidence", "created_revision",
              "updated_revision", "superseded_by",
          }
          require_known_keys(item, allowed, label)
          item_id = clean_text(item.get("id"), f"{label}.id", max_bytes=32)
          require(is_item_id(item_id), f"{label}.id is invalid")
          evidence = [normalize_evidence(entry, f"{label}.evidence[{index}]") for index, entry in enumerate(as_list(item.get("evidence"), f"{label}.evidence"))]
          require(bool(evidence), f"{label}.evidence must not be empty")
          status = choice(item.get("status"), STATUSES, f"{label}.status")
          conflicts = clean_id_list(item.get("conflicts_with"), f"{label}.conflicts_with")
          superseded_by = optional_text(item.get("superseded_by"), f"{label}.superseded_by", max_bytes=32)
          if superseded_by is not None:
              require(is_item_id(superseded_by), f"{label}.superseded_by is invalid")
          return {
              "id": item_id,
              "kind": choice(item.get("kind"), KINDS, f"{label}.kind"),
              "scope": normalize_scope(item.get("scope"), f"{label}.scope"),
              "assertion": clean_text(item.get("assertion"), f"{label}.assertion", max_bytes=LEGACY_ASSERTION_MAX_BYTES),
              "confidence": choice(item.get("confidence"), CONFIDENCE_LEVELS, f"{label}.confidence"),
              "importance": choice(item.get("importance"), IMPORTANCE_LEVELS, f"{label}.importance"),
              "status": status,
              "source": choice(item.get("source"), SOURCES, f"{label}.source"),
              "reason": clean_text(item.get("reason"), f"{label}.reason", max_bytes=480),
              "conflicts_with": conflicts,
              "confirmation_count": as_int(item.get("confirmation_count"), f"{label}.confirmation_count", minimum=1),
              "evidence": evidence,
              "created_revision": as_int(item.get("created_revision"), f"{label}.created_revision", minimum=1),
              "updated_revision": as_int(item.get("updated_revision"), f"{label}.updated_revision", minimum=1),
              "superseded_by": superseded_by,
          }
      
      
      def scope_fits_store(scope: dict[str, str | None], book: str | None) -> bool:
          if book is None:
              return True  # 项目级:存量 book 条目仍合法,只为老库能通过校验并被 migrate 搬走
          return scope["level"] == "book" and (scope["value"] or "").casefold() == book.casefold()
      
      
      def validate_state(value: object, *, store: Store) -> dict[str, Any]:
          state = as_mapping(value, "state")
          allowed = {"schema_version", "state_revision", "next_item_number", "items", "journal", "applied_transactions", "book"}
          require_known_keys(state, allowed, "state")
          require(state.get("schema_version") == STATE_SCHEMA_VERSION, f"state.schema_version must be {STATE_SCHEMA_VERSION}")
          if store.kind == "book":
              book = clean_text(state.get("book"), "state.book", max_bytes=180)
              require(store.book is not None and book.casefold() == store.book.casefold(), f"state.book「{book}」与目标书「{store.book}」不一致")
          else:
              require("book" not in state, "project-level state must not carry state.book")
              book = None
          revision = as_int(state.get("state_revision"), "state.state_revision")
          next_number = as_int(state.get("next_item_number"), "state.next_item_number", minimum=1)
          raw_items = as_mapping(state.get("items"), "state.items")
          items: dict[str, Any] = {}
          max_number = 0
          for raw_id, raw_item in raw_items.items():
              normalized = normalize_item(raw_item, f"state.items.{raw_id}")
              require(raw_id == normalized["id"], f"state.items key {raw_id} does not match item id")
              require(raw_id.startswith(store.prefix), f"state.items.{raw_id} does not belong to the {store.label} store(前缀应为 {store.prefix})")
              require(scope_fits_store(normalized["scope"], book), f"state.items.{raw_id} scope does not belong to book「{book}」")
              max_number = max(max_number, id_number(raw_id))
              require(normalized["created_revision"] <= normalized["updated_revision"] <= revision, f"state.items.{raw_id} revision is ahead of state")
              items[raw_id] = normalized
          require(next_number > max_number, "state.next_item_number must be greater than every allocated item id")
          for item_id, item in items.items():
              for conflict_id in item["conflicts_with"]:
                  require(conflict_id in items and conflict_id != item_id, f"state.items.{item_id} has an invalid conflict id")
              if item["superseded_by"] is not None:
                  require(item["superseded_by"] in items and item["superseded_by"] != item_id, f"state.items.{item_id} has an invalid superseded_by id")
              if item["status"] == "active":
                  require(not item["conflicts_with"], f"active item {item_id} cannot retain conflicts")
              if item["status"] == "pending":
                  require(not item["conflicts_with"], f"pending item {item_id} cannot retain conflicts")
              if item["status"] == "conflict":
                  require(bool(item["conflicts_with"]), f"conflict item {item_id} must reference an active item")
                  require(all(items[conflict_id]["status"] == "active" for conflict_id in item["conflicts_with"]), f"conflict item {item_id} must reference only active items")
              if item["status"] != "superseded":
                  require(item["superseded_by"] is None, f"only superseded item {item_id} may set superseded_by")
          journal = as_list(state.get("journal"), "state.journal")
          require(len(journal) == revision, "state.journal length must equal state.state_revision")
          journal_revisions: dict[str, int] = {}
          for index, entry in enumerate(journal):
              mapping = as_mapping(entry, f"state.journal[{index}]")
              require_known_keys(mapping, {"revision", "transaction_id", "committed_at", "summaries"}, f"state.journal[{index}]")
              entry_revision = as_int(mapping.get("revision"), f"state.journal[{index}].revision", minimum=1)
              require(entry_revision == index + 1, f"state.journal[{index}].revision must be {index + 1}")
              transaction_id = clean_text(mapping.get("transaction_id"), f"state.journal[{index}].transaction_id", max_bytes=128)
              require(transaction_id not in journal_revisions, f"state.journal repeats transaction_id {transaction_id}")
              journal_revisions[transaction_id] = entry_revision
              clean_text(mapping.get("committed_at"), f"state.journal[{index}].committed_at", max_bytes=64)
              summaries = as_list(mapping.get("summaries"), f"state.journal[{index}].summaries")
              require(bool(summaries), f"state.journal[{index}].summaries must not be empty")
              for summary_index, summary in enumerate(summaries):
                  clean_text(summary, f"state.journal[{index}].summaries[{summary_index}]", max_bytes=768)
          transactions = as_mapping(state.get("applied_transactions"), "state.applied_transactions")
          require(set(transactions) == set(journal_revisions), "state.applied_transactions must match state.journal transaction ids")
          for transaction_id, record in transactions.items():
              clean_text(transaction_id, "state.applied_transactions key", max_bytes=128)
              mapping = as_mapping(record, f"state.applied_transactions.{transaction_id}")
              require_known_keys(mapping, {"revision", "digest", "item_ids"}, f"state.applied_transactions.{transaction_id}")
              transaction_revision = as_int(mapping.get("revision"), f"state.applied_transactions.{transaction_id}.revision", minimum=1)
              require(transaction_revision == journal_revisions[transaction_id], f"state.applied_transactions.{transaction_id}.revision does not match journal")
              digest = clean_text(mapping.get("digest"), f"state.applied_transactions.{transaction_id}.digest", max_bytes=64)
              require(len(digest) == 64 and all(char in "0123456789abcdef" for char in digest), f"state.applied_transactions.{transaction_id}.digest is invalid")
              item_ids = clean_id_list(mapping.get("item_ids"), f"state.applied_transactions.{transaction_id}.item_ids")
              require(bool(item_ids), f"state.applied_transactions.{transaction_id}.item_ids must not be empty")
              require(all(item_id in items for item_id in item_ids), f"state.applied_transactions.{transaction_id}.item_ids references an unknown item")
          result = {
              "schema_version": STATE_SCHEMA_VERSION,
              "state_revision": revision,
              "next_item_number": next_number,
              "items": items,
              "journal": copy.deepcopy(journal),
              "applied_transactions": copy.deepcopy(transactions),
          }
          if book is not None:
              result["book"] = book
          return result
      
      
      def load_state(store: Store) -> dict[str, Any] | None:
          if not store.state_path.exists():
              return None
          return validate_state(read_json(store.state_path), store=store)
      
      
      def normalize_preference(value: object, label: str, *, allow_status: bool) -> dict[str, Any]:
          preference = as_mapping(value, label)
          allowed = {"kind", "scope", "assertion", "quote", "source_ref", "source", "confidence", "importance", "reason"}
          if allow_status:
              allowed |= {"status", "conflicts_with"}
          require_known_keys(preference, allowed, label)
          raw_source = preference.get("source")
          require(
              raw_source not in {"repeated_correction", "inferred_pattern"},
              f"{label}.source「{raw_source}」已不再写入:作者记忆只记作者明确表达的偏好,"
              f"不从重复修改或成稿轨迹推断;范围含糊的原话用 explicit_user 并置 status=pending",
          )
          source = choice(raw_source, WRITE_SOURCES, f"{label}.source")
          status = choice(preference.get("status"), ("active", "pending", "conflict"), f"{label}.status") if allow_status else "active"
          conflicts = clean_id_list(preference.get("conflicts_with", []), f"{label}.conflicts_with") if allow_status else []
          if status == "active":
              require(not conflicts, f"{label}.conflicts_with must be empty for active status")
          elif status == "conflict":
              require(bool(conflicts), f"{label}.conflicts_with is required for conflict status")
          else:
              require(not conflicts, f"{label}.conflicts_with is only valid for conflict status")
          return {
              "kind": choice(preference.get("kind"), KINDS, f"{label}.kind"),
              "scope": normalize_scope(preference.get("scope"), f"{label}.scope"),
              # 这里按存量上限收;ASSERTION_MAX_BYTES 只在真正新建条目时校验
              # (require_new_item_assertion),好让存量长断言仍能被强化。
              "assertion": clean_text(preference.get("assertion"), f"{label}.assertion", max_bytes=LEGACY_ASSERTION_MAX_BYTES),
              "quote": clean_text(preference.get("quote"), f"{label}.quote", max_bytes=768),
              "source_ref": optional_text(preference.get("source_ref"), f"{label}.source_ref", max_bytes=240),
              "source": source,
              "confidence": choice(preference.get("confidence"), CONFIDENCE_LEVELS, f"{label}.confidence"),
              "importance": choice(preference.get("importance"), IMPORTANCE_LEVELS, f"{label}.importance"),
              "status": status,
              "reason": clean_text(preference.get("reason"), f"{label}.reason", max_bytes=480),
              "conflicts_with": conflicts,
          }
      
      
      def normalize_transaction(value: object) -> dict[str, Any]:
          transaction = as_mapping(value, "transaction")
          require_known_keys(transaction, {"schema_version", "transaction_id", "expected_state_revision", "operations"}, "transaction")
          require(transaction.get("schema_version") == INPUT_SCHEMA_VERSION, f"transaction.schema_version must be {INPUT_SCHEMA_VERSION}")
          transaction_id = clean_text(transaction.get("transaction_id"), "transaction.transaction_id", max_bytes=128)
          operations = as_list(transaction.get("operations"), "transaction.operations")
          require(1 <= len(operations) <= 32, "transaction.operations must contain 1-32 operations")
          normalized_operations: list[dict[str, Any]] = []
          for index, raw_operation in enumerate(operations):
              label = f"transaction.operations[{index}]"
              operation = as_mapping(raw_operation, label)
              action = operation.get("action")
              if action == "remember":
                  require_known_keys(operation, {"action", "preference"}, label)
                  normalized_operations.append({"action": action, "preference": normalize_preference(operation.get("preference"), f"{label}.preference", allow_status=True)})
              elif action == "decide":
                  require_known_keys(operation, {"action", "item_id", "decision", "quote", "reason"}, label)
                  normalized_operations.append({
                      "action": action,
                      "item_id": clean_id_list([operation.get("item_id")], f"{label}.item_id", maximum=1)[0],
                      "decision": choice(operation.get("decision"), ("activate", "reject"), f"{label}.decision"),
                      "quote": clean_text(operation.get("quote"), f"{label}.quote", max_bytes=768),
                      "reason": clean_text(operation.get("reason"), f"{label}.reason", max_bytes=480),
                  })
              elif action == "replace":
                  require_known_keys(operation, {"action", "old_ids", "preference"}, label)
                  old_ids = clean_id_list(operation.get("old_ids"), f"{label}.old_ids")
                  require(bool(old_ids), f"{label}.old_ids must not be empty")
                  normalized_operations.append({"action": action, "old_ids": old_ids, "preference": normalize_preference(operation.get("preference"), f"{label}.preference", allow_status=False)})
              elif action == "forget":
                  require_known_keys(operation, {"action", "item_id", "quote", "reason"}, label)
                  normalized_operations.append({
                      "action": action,
                      "item_id": clean_id_list([operation.get("item_id")], f"{label}.item_id", maximum=1)[0],
                      "quote": clean_text(operation.get("quote"), f"{label}.quote", max_bytes=768),
                      "reason": clean_text(operation.get("reason"), f"{label}.reason", max_bytes=480),
                  })
              else:
                  raise AuthorMemoryError(f"{label}.action must be one of: remember, decide, replace, forget")
          return {
              "schema_version": INPUT_SCHEMA_VERSION,
              "transaction_id": transaction_id,
              "expected_state_revision": as_int(transaction.get("expected_state_revision"), "transaction.expected_state_revision"),
              "operations": normalized_operations,
          }
      
      
      def normalize_record_event(value: object) -> dict[str, Any]:
          event = as_mapping(value, "event")
          require_known_keys(event, {"schema_version", "event_id", "operation"}, "event")
          require(event.get("schema_version") == INPUT_SCHEMA_VERSION, f"event.schema_version must be {INPUT_SCHEMA_VERSION}")
          event_id = clean_text(event.get("event_id"), "event.event_id", max_bytes=120)
          normalized = normalize_transaction({
              "schema_version": INPUT_SCHEMA_VERSION,
              "transaction_id": f"record:{event_id}",
              "expected_state_revision": 0,
              "operations": [event.get("operation")],
          })
          return {"event_id": event_id, "operation": normalized["operations"][0]}
      
      
      # ---------------------------------------------------------------------------
      # Routing: which store does an operation belong to?
      # ---------------------------------------------------------------------------
      
      
      def scope_store_kind(scope: dict[str, str | None]) -> str:
          return "book" if scope["level"] == "book" else "project"
      
      
      def store_kind_label(kind: str) -> str:
          return "书级(BP)" if kind == "book" else "项目级(AP)"
      
      
      def operation_store_kind(operation: dict[str, Any], label: str) -> str:
          action = operation["action"]
          if action in {"decide", "forget"}:
              return id_store(operation["item_id"])
          preference = operation["preference"]
          kind = scope_store_kind(preference["scope"])
          if action == "remember":
              for conflict_id in preference["conflicts_with"]:
                  require(
                      id_store(conflict_id) == kind,
                      f"{label}.conflicts_with 只能引用同一 store 的条目({conflict_id} 在{store_kind_label(id_store(conflict_id))}):"
                      f"本书例外不算与全局规则冲突,直接 remember 为 book 条目即可",
                  )
              return kind
          for old_id in operation["old_ids"]:
              require(
                  id_store(old_id) == kind,
                  f"{label} replace 不能跨 store:{old_id} 在{store_kind_label(id_store(old_id))},新条目范围属于{store_kind_label(kind)};"
                  f"跨 store 改版拆成 forget+remember,存量 book 条目先 migrate",
              )
          return kind
      
      
      def transaction_store_kind(transaction: dict[str, Any]) -> str:
          kinds = {
              operation_store_kind(operation, f"transaction.operations[{index}]")
              for index, operation in enumerate(transaction["operations"])
          }
          require(
              len(kinds) == 1,
              "一份事务只能写一个 store:项目级(global/genre/workflow 范围或 AP 编号)与书级(book 范围或 BP 编号)的操作要分开提交",
          )
          return kinds.pop()
      
      
      # ---------------------------------------------------------------------------
      # Transactions
      # ---------------------------------------------------------------------------
      
      
      def transaction_digest(transaction: dict[str, Any]) -> str:
          canonical = json.dumps(transaction, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
          return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
      
      
      def fingerprint(preference: dict[str, Any]) -> str:
          """同一条偏好的身份:kind+scope+断言,scope.value 与 same_scope_value 同样按
          casefold 比——否则「Urban」「urban」会各建一条,两条同断言一起挤进 prompt。"""
          scope = preference["scope"]
          value = {
              "kind": preference["kind"],
              "scope": {"level": scope["level"], "value": None if scope["value"] is None else scope["value"].casefold()},
              "assertion": preference["assertion"].casefold(),
          }
          return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
      
      
      def require_new_item_assertion(preference: dict[str, Any]) -> None:
          """新建条目的断言限一句话。强化已有条目走不到这里——存量长断言必须还能
          被确认,否则作者重申老偏好只会派生一条重复条目,库反而更挤。"""
          size = len(preference["assertion"].encode("utf-8"))
          require(
              size <= ASSERTION_MAX_BYTES,
              f"新条目的 assertion {size} 字节,超出 {ASSERTION_MAX_BYTES} 字节上限——"
              f"断言限一句话,需要解释的背景写进 reason。若这是对已有条目的重申,"
              f"原样使用该条目的 assertion 即可强化(不受本上限约束);"
              f"若确实是几条互不依赖的偏好,才拆成几条分别记录。",
          )
      
      
      def require_scope_fits_store(state: dict[str, Any], scope: dict[str, str | None]) -> None:
          book = state.get("book")
          if book is None:
              require(
                  scope["level"] != "book",
                  "book 级条目须传 --book-root {书目录}——记忆随书存放在 {书}/.story/作者记忆/,不再写进工作区的项目级 store",
              )
          else:
              require(
                  scope_fits_store(scope, book),
                  f"book 条目的范围「{scope['value']}」与本书 store「{book}」不一致——只有这本书的条目才住在这个书目录",
              )
      
      
      def next_item_id(state: dict[str, Any]) -> str:
          prefix = STORE_PREFIX["book"] if state.get("book") is not None else STORE_PREFIX["project"]
          item_id = f"{prefix}{state['next_item_number']:03d}"
          state["next_item_number"] += 1
          return item_id
      
      
      def allocate_item(state: dict[str, Any], preference: dict[str, Any], revision: int) -> dict[str, Any]:
          require_new_item_assertion(preference)
          require_scope_fits_store(state, preference["scope"])
          return {
              "id": next_item_id(state),
              "kind": preference["kind"],
              "scope": copy.deepcopy(preference["scope"]),
              "assertion": preference["assertion"],
              "confidence": preference["confidence"],
              "importance": preference["importance"],
              "status": preference["status"],
              "source": preference["source"],
              "reason": preference["reason"],
              "conflicts_with": list(preference["conflicts_with"]),
              "confirmation_count": 1,
              "evidence": [{"quote": preference["quote"], "source_ref": preference["source_ref"]}],
              "created_revision": revision,
              "updated_revision": revision,
              "superseded_by": None,
          }
      
      
      def best_level(first: str, second: str) -> str:
          return first if RANK[first] >= RANK[second] else second
      
      
      def add_evidence(item: dict[str, Any], quote: str, source_ref: str | None) -> None:
          evidence = {"quote": quote, "source_ref": source_ref}
          if evidence not in item["evidence"]:
              item["evidence"].append(evidence)
      
      
      def require_item(state: dict[str, Any], item_id: str, label: str) -> dict[str, Any]:
          require(item_id in state["items"], f"{label} references unknown item {item_id}")
          return state["items"][item_id]
      
      
      def apply_remember(state: dict[str, Any], preference: dict[str, Any], revision: int) -> str:
          require_scope_fits_store(state, preference["scope"])
          for conflict_id in preference["conflicts_with"]:
              conflict = require_item(state, conflict_id, "remember")
              require(conflict["status"] == "active", f"remember conflict {conflict_id} must be active")
          preference_fingerprint = fingerprint(preference)
          for item in state["items"].values():
              if item["status"] not in {"active", "pending", "conflict"} or fingerprint(item) != preference_fingerprint:
                  continue
              require(not (item["status"] == "conflict" and preference["status"] == "active"), f"conflict item {item['id']} must be resolved with replace or rejected")
              require(not (item["status"] == "active" and preference["status"] == "conflict"), f"active item {item['id']} cannot be recategorized as its own conflict")
              add_evidence(item, preference["quote"], preference["source_ref"])
              item["confirmation_count"] += 1
              item["confidence"] = best_level(item["confidence"], preference["confidence"])
              item["importance"] = best_level(item["importance"], preference["importance"])
              item["updated_revision"] = revision
              item["reason"] = preference["reason"]
              if item["status"] == "pending" and preference["status"] == "active":
                  item["status"] = "active"
              elif item["status"] == "pending" and preference["status"] == "conflict":
                  item["status"] = "conflict"
                  item["conflicts_with"] = list(preference["conflicts_with"])
              elif item["status"] == "conflict" and preference["status"] == "conflict":
                  item["conflicts_with"] = sorted(set(item["conflicts_with"]) | set(preference["conflicts_with"]))
              return f"强化 {item['id']}:{item['assertion']}"
          item = allocate_item(state, preference, revision)
          state["items"][item["id"]] = item
          return f"新增 {item['id']}({item['status']}):{item['assertion']}"
      
      
      def apply_decide(state: dict[str, Any], operation: dict[str, Any], revision: int) -> str:
          item = require_item(state, operation["item_id"], "decide")
          require(item["status"] in {"pending", "conflict"}, f"decide requires pending/conflict item, got {item['status']}")
          if operation["decision"] == "activate":
              require(item["status"] == "pending" and not item["conflicts_with"], "conflict candidates must be activated with replace")
              item["status"] = "active"
              verb = "确认"
          else:
              item["status"] = "rejected"
              verb = "拒绝"
          add_evidence(item, operation["quote"], None)
          item["reason"] = operation["reason"]
          item["updated_revision"] = revision
          return f"{verb} {item['id']}:{item['assertion']}"
      
      
      def release_conflicts(state: dict[str, Any], removed_ids: set[str], revision: int) -> int:
          """被撤下的 active 条目不再是任何候选的冲突对象;冲突对象清空的候选退回 pending。"""
          released = 0
          for candidate in state["items"].values():
              if candidate["status"] != "conflict":
                  continue
              retained = [item_id for item_id in candidate["conflicts_with"] if item_id not in removed_ids]
              if retained == candidate["conflicts_with"]:
                  continue
              candidate["conflicts_with"] = retained
              candidate["updated_revision"] = revision
              if not retained:
                  candidate["status"] = "pending"
                  released += 1
          return released
      
      
      def apply_replace(state: dict[str, Any], operation: dict[str, Any], revision: int) -> str:
          old_items = [require_item(state, item_id, "replace") for item_id in operation["old_ids"]]
          for item in old_items:
              require(item["status"] in {"active", "conflict", "pending"}, f"replace target {item['id']} is already {item['status']}")
          replacement = allocate_item(state, operation["preference"], revision)
          replacement["status"] = "active"
          replacement["conflicts_with"] = []
          state["items"][replacement["id"]] = replacement
          for item in old_items:
              item["status"] = "superseded"
              item["superseded_by"] = replacement["id"]
              item["updated_revision"] = revision
          released = release_conflicts(state, {item["id"] for item in old_items}, revision)
          replaced = ", ".join(item["id"] for item in old_items)
          suffix = f";{released} 个其他冲突候选退回待确认" if released else ""
          return f"用 {replacement['id']} 替代 {replaced}:{replacement['assertion']}{suffix}"
      
      
      def apply_forget(state: dict[str, Any], operation: dict[str, Any], revision: int) -> str:
          item = require_item(state, operation["item_id"], "forget")
          require(item["status"] in {"active", "pending", "conflict"}, f"forget target {item['id']} is already {item['status']}")
          item["status"] = "superseded"
          item["superseded_by"] = None
          item["reason"] = operation["reason"]
          item["updated_revision"] = revision
          add_evidence(item, operation["quote"], None)
          released = release_conflicts(state, {item["id"]}, revision)
          suffix = f";{released} 个冲突候选退回待确认" if released else ""
          return f"忘记 {item['id']}:{item['assertion']}{suffix}"
      
      
      def committed_now() -> str:
          return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
      
      
      def seal_transaction(
          updated: dict[str, Any],
          transaction_id: str,
          digest: str,
          summaries: list[str],
          *,
          store: Store,
      ) -> dict[str, Any]:
          """把本次修订写进 journal / applied_transactions,并整份校验。"""
          revision = updated["state_revision"] + 1
          updated["state_revision"] = revision
          updated["journal"].append({
              "revision": revision,
              "transaction_id": transaction_id,
              "committed_at": committed_now(),
              "summaries": summaries,
          })
          item_ids = sorted(
              (item_id for item_id, item in updated["items"].items() if item["updated_revision"] == revision),
              key=id_number,
          )
          require(bool(item_ids), "transaction did not update any author-memory item")
          updated["applied_transactions"][transaction_id] = {
              "revision": revision,
              "digest": digest,
              "item_ids": item_ids,
          }
          return validate_state(updated, store=store)
      
      
      def apply_transaction(state: dict[str, Any], transaction: dict[str, Any], digest: str, *, store: Store) -> tuple[dict[str, Any], list[str]]:
          applied = state["applied_transactions"].get(transaction["transaction_id"])
          if applied is not None:
              require(applied["digest"] == digest, "transaction_id was already used with different content")
              return state, [f"事务已应用于修订 {applied['revision']},本次为幂等重放"]
          require(transaction["expected_state_revision"] == state["state_revision"], f"stale state revision: expected {transaction['expected_state_revision']}, current {state['state_revision']}")
          updated = copy.deepcopy(state)
          revision = updated["state_revision"] + 1
          summaries: list[str] = []
          for operation in transaction["operations"]:
              if operation["action"] == "remember":
                  summaries.append(apply_remember(updated, operation["preference"], revision))
              elif operation["action"] == "decide":
                  summaries.append(apply_decide(updated, operation, revision))
              elif operation["action"] == "replace":
                  summaries.append(apply_replace(updated, operation, revision))
              else:
                  summaries.append(apply_forget(updated, operation, revision))
          return seal_transaction(updated, transaction["transaction_id"], digest, summaries, store=store), summaries
      
      
      # ---------------------------------------------------------------------------
      # Derived views
      # ---------------------------------------------------------------------------
      
      
      def scope_label(scope: dict[str, str | None]) -> str:
          if scope["level"] == "global":
              return "全局"
          labels = {"genre": "题材", "book": "本书", "workflow": "流程"}
          return f"{labels[scope['level']]}:{scope['value']}"
      
      
      def store_caption(state: dict[str, Any]) -> str:
          book = state.get("book")
          if book is None:
              return "项目级记忆(全局、题材、流程);各书的书级记忆住在各自书目录的 .story/作者记忆/。"
          return f"本书「{book}」的书级记忆;全局、题材、流程记忆住在工作区的 .story/作者记忆/。"
      
      
      def render_profile(state: dict[str, Any]) -> str:
          lines = [
              "# 作者画像",
              "",
              "<!-- 由 author_memory_commit.py 生成,请勿手改;修改请提交事务。 -->",
              "",
              f"> 状态修订:{state['state_revision']}。{store_caption(state)}仅列出已确认偏好;当前明确要求、本书设定与硬性门禁优先。",
              "",
          ]
          active = [item for item in state["items"].values() if item["status"] == "active"]
          for kind in KINDS:
              lines.extend([f"## {KIND_TITLES[kind]}", ""])
              items = sorted((item for item in active if item["kind"] == kind), key=lambda item: id_number(item["id"]))
              if not items:
                  lines.extend(["- 暂无", ""])
                  continue
              for item in items:
                  # 必须显示 importance:它决定超编时谁留在 prompt 里,而「整理作者
                  # 记忆」只以本文件为输入——不显示就无从判断该退役哪条。
                  lines.append(
                      f"- **{item['id']}**〔{scope_label(item['scope'])}|重要 {item['importance']}"
                      f"|把握 {item['confidence']}|确认 {item['confirmation_count']} 次〕{item['assertion']}"
                  )
              lines.append("")
          return "\n".join(lines).rstrip() + "\n"
      
      
      def render_pending(state: dict[str, Any]) -> str:
          lines = [
              "# 待确认的作者习惯",
              "",
              "<!-- 由 author_memory_commit.py 生成,请勿手改;修改请提交事务。 -->",
              "",
              f"> 状态修订:{state['state_revision']}。{store_caption(state)}待确认项不参与创作约束,也不应打断当前任务。",
              "",
          ]
          items = sorted((item for item in state["items"].values() if item["status"] in {"pending", "conflict"}), key=lambda item: id_number(item["id"]))
          if not items:
              lines.extend(["暂无待确认项。", ""])
          for item in items:
              lines.extend([
                  f"## {item['id']} · {'冲突' if item['status'] == 'conflict' else '待确认'}",
                  "",
                  f"- 候选习惯:{item['assertion']}",
                  f"- 范围:{scope_label(item['scope'])}",
                  f"- 原话:“{item['evidence'][-1]['quote']}”",
                  f"- 依据:{item['reason']}",
                  f"- 置信度 / 重要度:{item['confidence']} / {item['importance']}",
              ])
              if item["conflicts_with"]:
                  lines.append(f"- 冲突对象:{', '.join(item['conflicts_with'])}")
              lines.append("")
          return "\n".join(lines).rstrip() + "\n"
      
      
      def render_journal(state: dict[str, Any]) -> str:
          lines = [
              "# 作者记忆变更记录",
              "",
              "<!-- 由 author_memory_commit.py 生成,请勿手改;最近记录在前。 -->",
              "",
              f"> {store_caption(state)}",
              "",
          ]
          if not state["journal"]:
              lines.extend(["暂无变更。", ""])
          for entry in reversed(state["journal"][-100:]):
              lines.extend([f"## r{entry['revision']} · {entry['committed_at']}", "", f"- 事务:`{entry['transaction_id']}`"])
              lines.extend(f"- {summary}" for summary in entry["summaries"])
              lines.append("")
          return "\n".join(lines).rstrip() + "\n"
      
      
      def render_views(state: dict[str, Any]) -> dict[str, str]:
          views = {
              "作者画像.md": render_profile(state),
              "待确认.md": render_pending(state),
              "变更记录.md": render_journal(state),
          }
          limits = {"作者画像.md": PROFILE_MAX_BYTES, "待确认.md": PENDING_MAX_BYTES, "变更记录.md": JOURNAL_MAX_BYTES}
          for name, payload in views.items():
              require(len(payload.encode("utf-8")) <= limits[name], f"{name} exceeds {limits[name]} bytes; consolidate old memory first")
          return views
      
      
      def write_snapshot(store: Store, state: dict[str, Any]) -> None:
          views = render_views(state)
          state_payload = json_payload(state)
          require(len(state_payload.encode("utf-8")) <= STATE_MAX_BYTES, f"_author-memory-state.json exceeds {STATE_MAX_BYTES} bytes")
          for name, payload in views.items():
              write_if_changed(store.root / name, payload)
          # State is the authority and therefore the last commit point.
          write_if_changed(store.state_path, state_payload)
      
      
      # ---------------------------------------------------------------------------
      # Query & budget
      # ---------------------------------------------------------------------------
      
      
      def same_scope_value(item_value: str | None, requested: str | None) -> bool:
          return requested is not None and item_value is not None and item_value.casefold() == requested.casefold()
      
      
      def summarize_assertion(assertion: str, *, limit: int = 14) -> str:
          return assertion if len(assertion) <= limit else assertion[:limit] + "…"
      
      
      def compact_item(item: dict[str, Any]) -> dict[str, Any]:
          """query 载荷只带这四个字段——估算与真实输出必须同一把尺。"""
          return {"id": item["id"], "kind": item["kind"], "scope": item["scope"], "assertion": item["assertion"]}
      
      
      def compact_bytes(item: dict[str, Any]) -> int:
          return len(json.dumps(compact_item(item), ensure_ascii=False).encode("utf-8"))
      
      
      SCOPE_RANK = {"book": 0, "genre": 1, "workflow": 2, "global": 3}
      
      
      def query_sort_key(item: dict[str, Any]) -> tuple[int, int, int, int, int]:
          """重要度→本书例外→最近更新→确认次数→编号。
      
          重要度必须排在 scope 之前:超编时先丢的应当是不重要的条目,而不是「凡
          全局一律先丢」。scope 在前会让任意数量的 low 本书琐事挤掉 high 的全局
          铁律——那恰恰是作者最不愿意丢的那一类。同重要度之内才按本书例外优先。
          两个 store 的修订号互不可比,但 book 条目只来自书级 store、其余只来自项目
          级,同 scope 必同 store,所以按修订号比「最近更新」在合并后仍成立。
          """
          return (
              -RANK[item["importance"]],
              SCOPE_RANK[item["scope"]["level"]],
              -item["updated_revision"],
              -item["confirmation_count"],
              id_number(item["id"]),
          )
      
      
      def fit_items(
          sorted_items: list[dict[str, Any]],
          revision: int,
          *,
          extra: dict[str, Any] | None = None,
      ) -> tuple[dict[str, Any], list[str]]:
          """按 query 输出信封把条目装进 QUERY_MAX_BYTES:装不下的跳过而不中断
          (一条长的不挡后面的短条),漏下的 ID 报进 omitted_ids(封顶
          OMITTED_IDS_MAX 条,omitted 保留真实总数)。返回 (结果文档, 全部漏下 ID)。
      
          漏项恒按候选优先级排序,不按被丢弃的先后:收尾回吐的条目优先级高于循环
          里跳过的,若按追加顺序排,omitted_ids 的封顶正好会把最该报的那条切掉。
          `extra` 是同样计入信封的附加字段(book_revision 等)。
          """
          result: dict[str, Any] = {
              "ok": True,
              "command": "query",
              "initialized": True,
              "revision": revision,
              "items": [],
              "omitted": 0,
              "omitted_ids": [],
          }
          result.update(extra or {})
          order = {item["id"]: index for index, item in enumerate(sorted_items)}
          kept: list[dict[str, Any]] = []
          dropped: set[str] = set()
      
          def ordered_omitted() -> list[str]:
              return sorted(dropped, key=order.__getitem__)
      
          def envelope_bytes() -> int:
              omitted = ordered_omitted()
              result["items"] = [compact_item(item) for item in kept]
              result["omitted"] = len(omitted)
              result["omitted_ids"] = omitted[:OMITTED_IDS_MAX]
              return len((json.dumps(result, ensure_ascii=False, sort_keys=True) + "\n").encode("utf-8"))
      
          for item in sorted_items:
              kept.append(item)
              if envelope_bytes() > QUERY_MAX_BYTES:
                  kept.pop()
                  dropped.add(item["id"])
          # omitted 计数落定后包可能恰好贴边超出一两个字节,回吐条目直到装下。
          while kept and envelope_bytes() > QUERY_MAX_BYTES:
              dropped.add(kept.pop()["id"])
          envelope_bytes()
          return result, ordered_omitted()
      
      
      def build_query_result(
          candidates: list[dict[str, Any]],
          project_state: dict[str, Any] | None,
          book_state: dict[str, Any] | None,
      ) -> tuple[dict[str, Any], list[str]]:
          """真实查询与写入端估算共用的唯一信封构造:候选已按优先级排好,这里补上
          book_revision 等附加字段再装填。两处信封差一个字段就是一次假阴性——回执
          说没事、query 照样丢条。"""
          extra: dict[str, Any] = {}
          if book_state is not None:
              extra["book_revision"] = book_state["state_revision"]
          revision = project_state["state_revision"] if project_state is not None else 0
          return fit_items(candidates, revision, extra=extra)
      
      
      def merged_query(
          project_state: dict[str, Any] | None,
          book_state: dict[str, Any] | None,
          kinds: set[str],
          requested_scopes: dict[str, str | None],
      ) -> tuple[dict[str, Any], list[str]]:
          """真实查询的纯函数部分:项目级按 kind/scope 过滤(存量 book 条目不参与,
          migrate 后才回来),书级整个 store 就是这本书的,只按 status/kind 过滤;
          合并后按 重要度→本书例外→最近更新 装填。"""
      
          def relevant(item: dict[str, Any]) -> bool:
              if item["status"] != "active" or item["kind"] not in kinds:
                  return False
              level = item["scope"]["level"]
              if level == "global":
                  return True
              return level != "book" and same_scope_value(item["scope"]["value"], requested_scopes[level])
      
          candidates: list[dict[str, Any]] = []
          if project_state is not None:
              candidates.extend(item for item in project_state["items"].values() if relevant(item))
          if book_state is not None:
              candidates.extend(active_of_kinds(book_state, tuple(kinds)))
          candidates.sort(key=query_sort_key)
          return build_query_result(candidates, project_state, book_state)
      
      
      def slice_weight(items: list[dict[str, Any]]) -> int:
          """切片在真实载荷里的占位:compact 字节+每条在 JSON 数组里的分隔符。
      
          只比 compact 字节会挑错切片——条目多、单条短的切片字节和更小,实际占位
          却更大,于是估算判「装得下」而真实查询溢出(warnings 假阴性)。
          """
          return sum(compact_bytes(item) + 2 for item in items)
      
      
      def active_of_kinds(state: dict[str, Any] | None, kinds: tuple[str, ...]) -> list[dict[str, Any]]:
          if state is None:
              return []
          return [item for item in state["items"].values() if item["status"] == "active" and item["kind"] in kinds]
      
      
      def worst_case_items(
          project_state: dict[str, Any] | None,
          book_state: dict[str, Any] | None,
          kinds: tuple[str, ...],
      ) -> list[dict[str, Any]]:
          """某个任务组合的最坏查询候选:全局条目+各 scope 维度上最重的单一切片。
      
          一次查询只带一个 book/genre/workflow,不同书的条目不会同现,所以按切片
          取最重而不是全加起来,多书工作区才不会被粗算误伤。切片按 casefold 归并,
          与 same_scope_value 同一口径——否则只差大小写的同名书在这里算两个切片、
          在真实查询里却合成一个,估算就成了下界。
      
          book 维度就是当前可见的书级 store(整个 store 是这本书的一片),所以写书级
          条目时「本书+全局」是精确的;写项目级条目而没传 --book-root 时看不到任何
          书级 store,估算只覆盖项目级。项目级里的存量 book 条目不参与查询,也不参与
          估算。
          """
          pool = [item for item in active_of_kinds(project_state, kinds) if item["scope"]["level"] != "book"]
          worst = [item for item in pool if item["scope"]["level"] == "global"]
          slices: dict[str, dict[str, list[dict[str, Any]]]] = {"genre": {}, "workflow": {}}
          for item in pool:
              level = item["scope"]["level"]
              if level != "global":
                  slices[level].setdefault((item["scope"]["value"] or "").casefold(), []).append(item)
          for level in ("genre", "workflow"):
              if slices[level]:
                  worst.extend(max(slices[level].values(), key=slice_weight))
          worst.extend(active_of_kinds(book_state, kinds))
          worst.sort(key=query_sort_key)
          return worst
      
      
      def legacy_book_items(project_state: dict[str, Any] | None) -> list[dict[str, Any]]:
          """项目级 store 里升级前写入、还活着的 book 条目——只有 migrate 会碰它们。"""
          if project_state is None:
              return []
          return [
              item for item in project_state["items"].values()
              if item["scope"]["level"] == "book" and item["status"] in {"active", "pending", "conflict"}
          ]
      
      
      def query_budget_warnings(project_state: dict[str, Any] | None, book_state: dict[str, Any] | None) -> list[str]:
          """写入回执的预算提醒:每个任务组合按最坏查询情形试装,装不下的点名。
      
          写入永不因注入预算失败;提醒指向「整理作者记忆」。点名带断言首句——只给
          编号的话,作者不打开 作者画像.md 就无从判断丢的是什么。
          """
          warnings: list[str] = []
          for task, kinds in QUERY_COMBOS.items():
              worst = worst_case_items(project_state, book_state, kinds)
              _, omitted = build_query_result(worst, project_state, book_state)
              if not omitted:
                  continue
              assertions = {item["id"]: item["assertion"] for item in worst}
              shown = ";".join(f"{item_id}「{summarize_assertion(assertions[item_id])}」" for item_id in omitted[:3])
              more = f" 等 {len(omitted)} 条" if len(omitted) > 3 else ""
              warnings.append(
                  f"「{task}」最坏查询装不下 {len(omitted)} 条,它们不会进入 prompt:"
                  f"{shown}{more}——超出 {QUERY_MAX_BYTES} 字节注入预算。"
                  f"说「整理作者记忆」可合并同义条、退役过时条。"
              )
          return warnings
      
      
      def visible_states(
          store: Store,
          updated: dict[str, Any],
          workspace: Path,
          book_root: Path | None,
          book: str | None,
      ) -> tuple[dict[str, Any] | None, dict[str, Any] | None]:
          """写入后可见的两级状态:刚写的那份用内存里的,另一份只读加载(没有就 None)。"""
          if store.kind == "project":
              book_state = load_state(book_store(book_root, book)) if book_root is not None else None
              return updated, book_state
          return load_state(project_store(workspace)), updated
      
      
      # ---------------------------------------------------------------------------
      # Commands
      # ---------------------------------------------------------------------------
      
      
      def require_workspace(workspace: Path) -> None:
          require(workspace.exists() and workspace.is_dir(), f"workspace does not exist: {workspace}")
      
      
      def store_fields(store: Store) -> dict[str, Any]:
          fields: dict[str, Any] = {"store": store.kind, "root": str(store.root)}
          if store.book is not None:
              fields["book"] = store.book
          return fields
      
      
      def command_init(workspace: Path, book_root: Path | None, book: str | None) -> dict[str, Any]:
          require_workspace(workspace)
          store = book_store(book_root, book) if book_root is not None else project_store(workspace)
          state = load_state(store)
          if state is None:
              state = empty_state(store.book)
          write_snapshot(store, state)
          return {"ok": True, "command": "init", "revision": state["state_revision"], **store_fields(store)}
      
      
      def command_commit(workspace: Path, book_root: Path | None, book: str | None, input_path: Path) -> dict[str, Any]:
          require_workspace(workspace)
          transaction = normalize_transaction(read_json(input_path))
          store = resolve_target_store(transaction_store_kind(transaction), workspace, book_root, book)
          require(store.state_path.exists(), f"{store.label} author memory is not initialized; run init first")
          state = load_state(store)
          digest = transaction_digest(transaction)
          updated, summaries = apply_transaction(state, transaction, digest, store=store)
          replayed = updated is state
          # 幂等重放时也重写快照,修复缺失或过期的派生视图。
          write_snapshot(store, updated)
          project_state, book_state = visible_states(store, updated, workspace, book_root, book)
          return {
              "ok": True,
              "command": "commit",
              "revision": updated["state_revision"],
              "transaction_id": transaction["transaction_id"],
              "replayed": replayed,
              "item_ids": updated["applied_transactions"][transaction["transaction_id"]]["item_ids"],
              "summaries": summaries,
              "warnings": query_budget_warnings(project_state, book_state),
              **store_fields(store),
          }
      
      
      def command_record(workspace: Path, book_root: Path | None, book: str | None, input_path: Path) -> dict[str, Any]:
          require_workspace(workspace)
          event = normalize_record_event(read_json(input_path))
          store = resolve_target_store(operation_store_kind(event["operation"], "event.operation"), workspace, book_root, book)
          state = load_state(store)
          if state is None:
              state = empty_state(store.book)
          transaction_id = f"record:{event['event_id']}"
          applied = state["applied_transactions"].get(transaction_id)
          expected_revision = applied["revision"] - 1 if applied is not None else stat
    • dashboard-server.mjs 32.2 KB · in bundle
  • SKILL.md 10.9 KB
    ---
    name: story
    description: "网络小说工具箱主入口。根据用户需求自动路由到对应 skill,并可管理作者习惯、启动本地 Dashboard。触发方式:/story、$story、/story dashboard、/网文、「我想写小说」「记住我的写作习惯」「打开工作台」「检查更新」。"
    metadata: {"openclaw":{"source":"https://github.com/zenstory-ai/oh-story-claudecode"}}
    ---
    # story:网文工具箱路由
    
    你是网文工具箱的路由入口。用户的请求模糊时由你分发到具体 skill。
    
    ## 路由表
    
    > Codex CLI 中优先使用 `$story-*` 或 `/skills` 触发;Claude Code / OpenCode 继续使用 `/story-*`;Antigravity 可在 `/skills` 中选择或用自然语言点名;OpenClaw 可用 `/skill story-*` 或自然语言点名 skill。下表以 slash command 展示,Codex 可将 `/story-long-write` 等价替换为 `$story-long-write`,OpenClaw 可将其等价替换为 `/skill story-long-write`。
    
    | 用户意图 | 关键词示例 | 路由到 |
    |---|---|---|
    | 长篇规划/写作 | 讨论长篇结构、规划剧情、开书、写大纲、补细纲、长篇、连载 | `/story-long-write` |
    | 写短篇 | 短篇、盐言、一万字 | `/story-short-write` |
    | 长篇拆文 | 拆文、分析这本书、黄金三章 | `/story-long-analyze` |
    | 短篇拆文 | 拆短篇、分析这个故事 | `/story-short-analyze` |
    | 长篇扫榜 | 长篇排行、什么火、起点/番茄/晋江 | `/story-long-scan` |
    | 选题决策 | 写什么能爆、帮我选题、选题方向 | `/story-long-scan` |
    | 短篇扫榜 | 短篇排行、知乎盐言排行 | `/story-short-scan` |
    | 去 AI 味 | 去 AI 味、太 AI、去味 | `/story-deslop` |
    | 审查稿件 | 审查、审稿、帮我审一下、一致性检查、看看有没有问题 | `/story-review` |
    | 封面 | 封面、封面图 | `/story-cover` |
    | 环境部署 | 准备写书、搭环境、初始化 | `/story-setup` |
    | 浏览器操控 | 浏览器、抓取、登录态 | `/browser-cdp` |
    | 导入小说 | 导入、反向解析、导入小说、把我的书导进来 | `/story-import` |
    | 工作台 | dashboard、工作台、看拆文库、浏览项目文件、打开项目面板 | 见下方「Dashboard 工作台」 |
    | 检查/更新版本 | 检查更新、有新版本吗、升级、更新工具箱 | 见下方「版本更新检查」 |
    | 切换/列出书目 | 切书、换书、列出我的书、我在写哪几本、切换项目 | 见下方「多书切换」 |
    | 管理作者习惯 | 记住我的写作习惯、作者画像、待确认偏好、忘掉这个偏好 | 见下方「作者记忆」 |
    | 查故事资料 | 查角色、查伏笔、查进度、查设定、什么状态、写到哪了 | spawn `story-explorer` agent(结构化 prompt:`项目目录:{dir}\n查询类型:{根据意图选择}\n查询参数:{用户查询}`);agent 不可用时见下方「查询降级」 |
    | 查资料 | 查资料、帮我查资料、调研、搜索一下、搜一下 | spawn `story-researcher` agent;agent 不可用时见下方「查询降级」 |
    
    ### 导入续写顺序
    
    用户问"导入续写先 setup 还是 import"时,直接回答:**推荐先 `/story-setup`,新开/刷新会话后 `/story-import`,最后 `/story-long-write 日更` 或 `/story-long-write 写第N章`**。如果用户已经直接触发 `/story-import`,按 story-import 自带环境检测继续:未 setup 时让用户选择先去 setup 或继续串行导入。
    
    ## 作者记忆
    
    用户要求记住、查看、确认、替换或忘掉作者习惯时,加载 [references/author-memory.md](references/author-memory.md),并只用本 skill 的 `scripts/author_memory_commit.py` 管理两级 `.story/作者记忆/`:全局、题材、流程条目在工作区(`AP`),本书条目在书目录(`BP`,传 `--book-root`)。常用变更走单事件 `record`;工具未返回 `ok: true` 和 `Author Memory Receipt` 前,不得声称已记住。显示画像或待确认项是只读操作;不存在时直接说明尚未建立。
    
    新增习惯必须保留用户原话和适用范围。一次性要求只执行不记录;小说事实写入本书设定/追踪;不从反复修改或成稿推断偏好,只记作者明确说的,原话范围含糊才进待确认;与已生效习惯冲突时显式 replace,不原地改写历史。项目级画像里还有「本书:」条目时,建议对该书运行 `migrate --book-root`。用户没有指定工作区时,按协议定位已有作者记忆的最近祖先或当前创作工作区,禁止默认写到用户主目录。
    
    ## Dashboard 工作台
    
    用户执行 `/story dashboard`(Codex 为 `$story dashboard`),或明确说“打开工作台 / 看项目
    文件”时,直接启动随本 skill 分发的本地 Dashboard,不再转发到其他 skill:
    
    1. 把**当前工作目录**作为默认工作区;用户明确给出目录时改用该目录。目录必须存在。
    2. 从当前已加载的 `story` skill 目录定位 `scripts/dashboard-server.mjs`,不要硬编码仓库路径、
       全局 skill 路径或用户主目录。
    3. 检查 `node` 可用后,以长运行进程执行:
    
       ```bash
       node "<story-skill-dir>/scripts/dashboard-server.mjs" --root "<workspace>" --open
       ```
    
    4. 等待输出出现“本机地址”,把完整 URL 回给用户。工具支持后台进程/PTY 时让服务保持运行;
       无法自动拉起浏览器不算失败,仍返回可点击 URL。
    5. Dashboard 默认只监听 `127.0.0.1`。不要主动增加 `--allow-network`,不要把工作区暴露到
       局域网或公网。
    
    工作台会识别标准 `拆文库/{书名}/`,兼容存量 `拆文库-{书名}/`。写作项目识别同时支持:
    
    - 长篇目录结构:目录内含 `正文/`、`大纲/`、`设定/` 或 `追踪/` 任一普通子目录。
    - 短篇单文件结构:目录内含普通文件 `正文.md`,并同时含 `小节大纲.md` 或 `设定.md`。
    
    符号链接不作为项目标记,只有单个 `正文.md` 的普通资料目录也不会被误认。浏览器可编辑
    `.md`、`.txt`、`.json`、`.yaml`、`.yml`、`.toml`,保存或确认删除前用修改时间防止
    误操作外部更新。
    
    停止服务时终止对应的 Node 长运行进程即可。若用户只问用法,不要替他启动;给出
    `/story dashboard` / `$story dashboard` 两种平台对应入口。
    
    ## 路由流程
    
    1. 分析用户请求,提取意图关键词
    2. 匹配上表,找到对应的 skill
    3. 如果能明确匹配,直接调用对应 skill(Claude/OpenCode 可用 `Skill("skill-name")` 或 slash command;Codex 用 `$skill-name` / `/skills`;Antigravity 用 `/skills` 或自然语言点名;OpenClaw 用 `/skill skill-name` 或自然语言点名)
    4. 如果无法匹配,询问用户想做什么(从上表中选择)
    5. 如果用户说"我想写小说"但未指定长篇/短篇,询问篇幅类型后再路由
    
    ## 查询降级
    
    > Spawn 版本提示(不阻断 spawn):先读取项目根 `.story-deployed` 的 `agents_version`。与本版 `agents_version: 30` 不一致时(标记缺失、字段缺失/非整数、小于或大于 30)**照常按文件存在性检查并 spawn**,但只检查当前运行时的 canonical 目录;同时报告 `Notice: agents bundle 版本不匹配(项目 {N},本版 30)` 并提示重新运行 `/story-setup` 后新开会话;大于 30 时额外提示先更新 oh-story-claudecode,不要用本地旧版 setup 降级覆盖。只有 agent 文件缺失、或运行时不暴露 custom agent 时才降级 solo/direct,报告 `Fallback: ... -> solo`。
    
    「查故事资料」「查资料」走 agent 前先做轻量可用性检查(路由只做这一层,不承担全局部署策略):当前不在子代理上下文、当前运行时的 Agent/Task 或 `invoke_subagent` 工具可用,且对应部署文件存在(Claude `.claude/agents/*.md`、OpenCode `.opencode/agents/*.md`、Codex `.codex/agents/*.toml`、Antigravity `.agents/agents/agent-name/agent.md`,其中 `agent-name` 为目标 agent 名)→ 可尝试 spawn。Antigravity 用 `invoke_subagent` + 同名 `TypeName`,不得因其他端文件存在而误判。任一不满足,或运行时返回 unknown agent / 未暴露 custom-agent registry,则降级,不硬失败:
    
    - `story-explorer` 不可用 → 主线程直接用 Read/Grep 从项目文件检索(角色状态/伏笔/进度/设定),回答前标注 `Fallback: agent unavailable -> direct lookup`;项目尚未部署时提示先 `/story-setup`(Codex 中用 `$story-setup`)。
    - `story-researcher` 不可用 → 主线程用现有检索/回答能力完成,或提示用户改用 `/browser-cdp` 采集,同样标注 `Fallback: agent unavailable -> direct lookup`。
    
    ## 项目状态感知
    
    路由前先检查当前项目状态:
    
    - **无项目目录**(没有包含 `追踪/` 或 `设定/` 的书名目录):
      - 如果用户要写作,下一步是先运行 `/story-setup` 初始化环境(Codex 中用 `$story-setup`)
      - 如果用户要扫榜/拆文,直接路由
    - **已有项目**:检查 `.story-deployed` 标记,如未部署则先运行 `/story-setup`(Codex 中用 `$story-setup`)
    
    ## 多书切换
    
    用户想切换或查看在写的书时(一个项目可同时有多本):
    
    1. 在项目根查找所有书目录:包含 `追踪/` 或 `设定/` 子目录的目录(含 `长篇/`、`短篇/` 下的子目录)。
    2. 列出书名,并标出当前 `.active-book` 指向的那本。
    3. 让用户选择,把所选书的相对路径写入项目根 `.active-book`(覆盖原内容)。
    4. 只发现一本时直接确认为活跃书,无需询问。
    
    ## 版本更新检查
    
    用户问"有没有新版本""检查更新""升级"时执行。**只通知,更不更新由用户定,不自动安装。**
    
    1. **当前版本**:读本 skill 同目录的 `VERSION` 文件;缺失则视为未知。
    2. **最新版本**:优先 `gh release view --json tagName,name,url -R zenstory-ai/oh-story-claudecode` 取 `tagName`;无 gh 用 `curl -fsS --max-time 5 https://api.github.com/repos/zenstory-ai/oh-story-claudecode/releases/latest` 取 `.tag_name`(jq 或 grep)。查不到 → 告知"暂时拉不到最新版本,可手动看 [Releases](https://github.com/zenstory-ai/oh-story-claudecode/releases)",不报错。
    3. **比较**:去掉 `v` 前缀按语义版本比(major.minor.patch)。`gh release` 默认取 latest 稳定版,不含 pre-release。
    4. **告知**:
       - 已最新 → 「已是最新版 vX.Y.Z」。
       - 有新版 → 列出 当前 vA → 最新 vB + [Releases](https://github.com/zenstory-ai/oh-story-claudecode/releases)/[CHANGELOG](https://github.com/zenstory-ai/oh-story-claudecode/blob/main/CHANGELOG.md)(能拿到 release notes 就附本次要点),再用 AskUserQuestion 问「现在更新吗?」:
         - 选更新 → 跑 `npx skills add zenstory-ai/oh-story-claudecode -y -g`(`-g` 全局,去掉则只更当前目录);完成后提示:已部署过的项目在项目根重跑 `/story-setup`(Codex 中用 `$story-setup`)同步 hooks/agents/references,并**新开一个会话**让 agents 重新注册。
         - 选先不 → 不动,告知随时可再来。
    
  • VERSION 7 B · in bundle

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related