Retour aux skills
zenstory-ai/oh-story-claudecodeVérifier avant exécution

SKILL DETAIL

story

zenstory-ai/oh-story-claudecode/story

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

Installations · 91Voir la source

Installation

npx skills add https://github.com/zenstory-ai/oh-story-claudecode --skill story

Fichiers du skill

SKILL.md

Dernière synchronisation · 30 août 2026

assets/app.js
const state = {
  workspace: null,
  activeView: "libraries",
  activeFile: null,
  originalContent: "",
  dirty: false,
  mode: "edit",
  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"),
  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.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");
  elements.cursorPosition.textContent = `第 ${lines.length} 行,第 ${[...lines.at(-1)].length + 1} 列`;
}

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);
    setMode("edit");
    updateDocumentMeta();
    updateCursorPosition();
    elements.editorEmpty.hidden = true;
    elements.editorWorkspace.hidden = false;
    document.body.classList.add("document-open");
    syncActiveRow();
    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 listType = null;

  const closeList = () => {
    if (listType) output.push(`</${listType}>`);
    listType = null;
  };

  for (const line of lines) {
    if (line.trim().startsWith("```")) {
      closeList();
      if (inCode) {
        output.push(`<pre><code>${codeLines.join("\n")}</code></pre>`);
        codeLines = [];
      }
      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}>${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>${inlineMarkdown((unordered || ordered)[1])}</li>`);
    } else if (/^\s*([-*_])(?:\s*\1){2,}\s*$/.test(line)) {
      closeList();
      output.push("<hr>");
    } else if (line.startsWith("&gt; ")) {
      closeList();
      output.push(`<blockquote>${inlineMarkdown(line.slice(5))}</blockquote>`);
    } else if (line.trim()) {
      closeList();
      output.push(`<p>${inlineMarkdown(line)}</p>`);
    } else {
      closeList();
    }
  }
  if (inCode) output.push(`<pre><code>${codeLines.join("\n")}</code></pre>`);
  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.editorInput.hidden = previewing;
  elements.previewPane.hidden = !previewing;
  if (previewing) {
    elements.previewPane.innerHTML = markdownToSafeHtml(elements.editorInput.value);
  } else {
    window.requestAnimationFrame(() => elements.editorInput.focus());
  }
}

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);
    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();
  }
}

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();
});

["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();
assets/index.html
<!doctype html>
<html lang="zh-CN">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <meta name="color-scheme" content="light">
    <meta name="theme-color" content="#f7f8fa">
    <title>OH STORY · 本地写作工作台</title>
    <link rel="stylesheet" href="/styles.css">
  </head>
  <body>
    <a class="skip-link" href="#editorPanel">跳到文稿台</a>

    <div class="app-shell">
      <header class="masthead">
        <div class="brand-lockup" aria-label="OH STORY 本地写作工作台">
          <span class="brand-seal" aria-hidden="true">OH</span>
          <span class="brand-copy">
            <strong>OH STORY</strong>
            <small>WRITING DESK</small>
          </span>
        </div>

        <div class="workspace-identity">
          <svg viewBox="0 0 24 24" aria-hidden="true">
            <path d="M3.5 6.5h6l2 2h9v10h-17z"></path>
          </svg>
          <span class="workspace-copy">
            <strong id="workspaceName">正在辨认书架…</strong>
            <span id="workspacePath" class="workspace-path"></span>
          </span>
        </div>

        <div class="masthead-actions">
          <div class="workspace-summary" aria-label="工作区统计">
            <span><b id="libraryCount">—</b> 拆文库</span>
            <span><b id="projectCount">—</b> 项目</span>
            <span><b id="fileCount">—</b> 文稿</span>
          </div>
          <div id="connectionStatus" class="connection-status" role="status">
            <span class="status-dot" aria-hidden="true"></span>
            <span>连接中</span>
          </div>
          <button id="refreshButton" class="icon-button" type="button" title="刷新工作区" aria-label="刷新工作区">
            <svg viewBox="0 0 24 24" aria-hidden="true">
              <path d="M20 6v5h-5"></path>
              <path d="M18.2 15.7A7 7 0 1 1 19 9.3L20 11"></path>
            </svg>
          </button>
        </div>
      </header>

      <main class="desk">
        <aside class="archive-panel" aria-labelledby="archiveTitle">
          <h1 id="archiveTitle" class="sr-only">工作区文件</h1>

          <div class="archive-tabs" role="tablist" aria-label="工作区内容">
            <button id="librariesTab" type="button" role="tab" aria-selected="true" aria-controls="treePanel" data-view="libraries">
              <svg viewBox="0 0 24 24" aria-hidden="true">
                <path d="M4 5.5h6l2 2h8v11H4z"></path>
              </svg>
              <span>拆文库</span>
              <b id="librariesBadge">0</b>
            </button>
            <button id="projectsTab" type="button" role="tab" aria-selected="false" aria-controls="treePanel" data-view="projects">
              <svg viewBox="0 0 24 24" aria-hidden="true">
                <path d="M5 4.5h14v15H5z"></path>
                <path d="M8 8h8M8 12h8M8 16h5"></path>
              </svg>
              <span>写作项目</span>
              <b id="projectsBadge">0</b>
            </button>
          </div>

          <label class="search-field" for="treeSearch">
            <svg viewBox="0 0 24 24" aria-hidden="true">
              <circle cx="11" cy="11" r="6.5"></circle>
              <path d="m16 16 4 4"></path>
            </svg>
            <span class="sr-only">搜索文件名</span>
            <input id="treeSearch" type="search" autocomplete="off" placeholder="搜索文件、角色或章节">
            <kbd>⌘ K</kbd>
          </label>

          <div id="treePanel" class="tree-panel" role="tabpanel" aria-labelledby="librariesTab">
            <div id="treeLoading" class="tree-message">
              <span class="loading-mark" aria-hidden="true"></span>
              <p>正在整理案卷…</p>
            </div>
            <div id="fileTree" class="file-tree" aria-label="文件树"></div>
          </div>

          <footer class="archive-note">
            <span class="status-dot" aria-hidden="true"></span>
            <p>本地工作区 · 文稿不会上传</p>
          </footer>
        </aside>

        <section id="editorPanel" class="editor-panel" aria-labelledby="editorTitle">
          <div id="editorEmpty" class="editor-empty">
            <div class="empty-folio" aria-hidden="true">
              <svg viewBox="0 0 48 48">
                <path d="M12 7h19l6 6v28H12z"></path>
                <path d="M31 7v7h6M18 21h13M18 27h13M18 33h9"></path>
              </svg>
            </div>
            <h2>选择一份文稿开始工作</h2>
            <p>从左侧打开 Markdown、TXT 或配置文本,直接阅读、校订并保存。</p>
            <div class="empty-shortcuts">
              <span><kbd>⌘ K</kbd> 搜索</span>
              <span><kbd>⌘ S</kbd> 保存</span>
            </div>
          </div>

          <div id="editorWorkspace" class="editor-workspace" hidden>
            <header class="editor-toolbar">
              <button id="mobileBackButton" class="mobile-back-button" type="button" aria-label="返回文件列表">
                <svg viewBox="0 0 24 24" aria-hidden="true">
                  <path d="m15 5-7 7 7 7"></path>
                </svg>
                <span>文件</span>
              </button>
              <div class="document-identity">
                <nav id="breadcrumbs" class="breadcrumbs" aria-label="文件路径"></nav>
                <div class="document-title-row">
                  <h2 id="editorTitle">未命名文稿</h2>
                </div>
              </div>

              <div class="editor-actions">
                <button
                  id="deleteButton"
                  class="icon-button document-delete-button"
                  type="button"
                  title="删除当前文件"
                  aria-label="删除当前文件"
                >
                  <svg viewBox="0 0 24 24" aria-hidden="true">
                    <path d="M4.5 7h15M9 7V4.5h6V7M7 7l.8 13h8.4L17 7M10 10.5v6M14 10.5v6"></path>
                  </svg>
                </button>
                <div class="mode-switch" role="group" aria-label="文稿模式">
                  <button type="button" data-mode="edit" aria-pressed="true">编辑</button>
                  <button type="button" data-mode="preview" aria-pressed="false">预览</button>
                </div>
              </div>
            </header>

            <div id="editorBody" class="editor-body">
              <label class="sr-only" for="editorInput">文件内容</label>
              <textarea id="editorInput" spellcheck="true"></textarea>
              <article id="previewPane" class="preview-pane" aria-label="Markdown 预览" hidden></article>
            </div>

            <footer class="editor-footer">
              <div class="document-status">
                <span id="cursorPosition">第 1 行,第 1 列</span>
                <span id="documentMeta" class="document-meta"></span>
                <span id="encodingLabel">UTF-8 · LF</span>
              </div>
              <div class="save-controls">
                <span id="dirtyStatus" class="save-state" data-state="saved">
                  <span aria-hidden="true"></span>
                  <span>已保存</span>
                </span>
                <button id="saveButton" class="save-button" type="button" disabled>
                  <svg viewBox="0 0 24 24" aria-hidden="true">
                    <path d="M5 4h12l2 2v14H5z"></path>
                    <path d="M8 4v6h8V4M8 20v-6h8v6"></path>
                  </svg>
                  <span>保存</span>
                  <kbd>⌘ S</kbd>
                </button>
              </div>
            </footer>
          </div>
        </section>
      </main>
    </div>

    <div id="toastRegion" class="toast-region" role="status" aria-live="polite" aria-atomic="true"></div>

    <dialog id="conflictDialog" class="conflict-dialog" aria-labelledby="conflictTitle">
      <form method="dialog">
        <span class="dialog-seal" aria-hidden="true">校</span>
        <span class="eyebrow">检测到外部修改</span>
        <h2 id="conflictTitle">磁盘上的文稿比当前版本更新</h2>
        <p>为避免覆盖其他程序写入的内容,Dashboard 已暂停保存。你可以保留当前编辑继续比对,或重新载入磁盘版本。</p>
        <div class="dialog-actions">
          <button value="cancel" class="secondary-button">保留当前编辑</button>
          <button id="reloadConflictButton" value="reload" class="danger-button">重新载入磁盘版本</button>
        </div>
      </form>
    </dialog>

    <script type="module" src="/app.js"></script>
  </body>
</html>
assets/styles.css
:root {
  color-scheme: light;
  --ink-950: #17202a;
  --ink-900: #202936;
  --ink-800: #334155;
  --ink-700: #475569;
  --ink-600: #64748b;
  --ink-500: #8290a3;
  --surface: #ffffff;
  --surface-subtle: #f7f8fa;
  --surface-muted: #f1f4f7;
  --surface-active: #eaf0f6;
  --line: #e1e6ec;
  --line-strong: #d2d9e2;
  --blue-700: #295f9e;
  --blue-600: #3777c3;
  --blue-100: #e8f1fc;
  --green-700: #24704a;
  --green-100: #e8f5ed;
  --amber-700: #9a6515;
  --amber-100: #fff5dc;
  --red-700: #a43b42;
  --red-100: #fcebed;
  --sans: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB",
    "Microsoft YaHei", sans-serif;
  --mono: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
  --focus: 0 0 0 3px rgba(55, 119, 195, 0.18);
}

* {
  box-sizing: border-box;
}

html {
  min-width: 320px;
  height: 100%;
  color: var(--ink-900);
  background: var(--surface-subtle);
  font-family: var(--sans);
  font-size: 16px;
}

body {
  min-height: 100%;
  margin: 0;
  overflow: hidden;
  background: var(--surface-subtle);
}

button,
input,
textarea {
  font: inherit;
}

button {
  color: inherit;
}

button,
summary {
  -webkit-tap-highlight-color: transparent;
}

button:focus-visible,
input:focus-visible,
textarea:focus-visible,
summary:focus-visible {
  z-index: 1;
  outline: none;
  box-shadow: var(--focus);
}

[hidden] {
  display: none !important;
}

.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

.skip-link {
  position: fixed;
  z-index: 100;
  top: 8px;
  left: 8px;
  padding: 8px 12px;
  color: #fff;
  background: var(--blue-700);
  border-radius: 5px;
  transform: translateY(-160%);
}

.skip-link:focus {
  transform: translateY(0);
}

kbd {
  padding: 1px 5px;
  color: var(--ink-600);
  background: var(--surface-muted);
  border: 1px solid var(--line-strong);
  border-radius: 4px;
  font-family: var(--mono);
  font-size: 10px;
  line-height: 16px;
}

.app-shell {
  width: 100vw;
  height: 100dvh;
  display: grid;
  grid-template-rows: 48px minmax(0, 1fr);
  overflow: hidden;
  background: var(--surface);
}

/* Global workbench bar */
.masthead {
  min-width: 0;
  display: grid;
  grid-template-columns: 320px minmax(240px, 1fr) auto;
  align-items: center;
  color: var(--ink-800);
  background: var(--surface-subtle);
  border-bottom: 1px solid var(--line-strong);
}

.brand-lockup,
.workspace-identity,
.masthead-actions,
.connection-status,
.workspace-summary,
.document-title-row,
.editor-actions,
.empty-shortcuts,
.document-status,
.save-controls {
  display: flex;
  align-items: center;
}

.brand-lockup {
  align-self: stretch;
  gap: 9px;
  padding: 0 16px;
  border-right: 1px solid var(--line);
}

.brand-seal {
  width: 25px;
  height: 25px;
  display: grid;
  place-items: center;
  color: #fff;
  background: var(--blue-600);
  border-radius: 7px 7px 7px 2px;
  font-size: 9px;
  font-weight: 750;
  line-height: 1;
  box-shadow: 0 1px 2px rgba(26, 54, 85, 0.18);
}

.brand-copy {
  min-width: 0;
  display: flex;
  align-items: baseline;
  gap: 7px;
}

.brand-copy strong {
  color: var(--ink-950);
  font-size: 14px;
  font-weight: 700;
  letter-spacing: 0.02em;
  white-space: nowrap;
}

.brand-copy small {
  color: var(--ink-500);
  font-family: var(--mono);
  font-size: 8px;
  letter-spacing: 0.1em;
}

.workspace-identity {
  min-width: 0;
  gap: 9px;
  padding: 0 18px;
}

.workspace-identity > svg {
  width: 17px;
  flex: 0 0 auto;
  fill: none;
  stroke: var(--blue-700);
  stroke-linejoin: round;
  stroke-width: 1.55;
}

.workspace-copy {
  min-width: 0;
  display: flex;
  align-items: baseline;
  gap: 10px;
}

.workspace-identity strong,
.workspace-path {
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.workspace-identity strong {
  flex: 0 0 auto;
  max-width: 220px;
  color: var(--ink-900);
  font-size: 13px;
  font-weight: 650;
}

.workspace-path {
  color: var(--ink-500);
  font-family: var(--mono);
  font-size: 10px;
}

.masthead-actions {
  align-self: stretch;
  gap: 14px;
  padding: 0 12px 0 18px;
}

.workspace-summary {
  gap: 14px;
  color: var(--ink-500);
  font-size: 11px;
  white-space: nowrap;
}

.workspace-summary b {
  color: var(--ink-700);
  font-variant-numeric: tabular-nums;
  font-weight: 650;
}

.connection-status {
  gap: 6px;
  color: var(--ink-600);
  font-size: 11px;
  white-space: nowrap;
}

.status-dot {
  width: 7px;
  height: 7px;
  flex: 0 0 auto;
  background: var(--ink-500);
  border-radius: 50%;
  box-shadow: 0 0 0 3px rgba(130, 144, 163, 0.12);
}

.connection-status[data-state="online"] .status-dot,
.archive-note .status-dot {
  background: #43a56f;
  box-shadow: 0 0 0 3px rgba(67, 165, 111, 0.12);
}

.connection-status[data-state="offline"] {
  color: var(--red-700);
}

.connection-status[data-state="offline"] .status-dot {
  background: var(--red-700);
  box-shadow: 0 0 0 3px rgba(164, 59, 66, 0.12);
}

.icon-button {
  width: 30px;
  height: 30px;
  display: grid;
  flex: 0 0 auto;
  place-items: center;
  padding: 0;
  color: var(--ink-600);
  background: transparent;
  border: 1px solid transparent;
  border-radius: 5px;
  cursor: pointer;
}

.icon-button:hover {
  color: var(--ink-900);
  background: var(--surface-muted);
  border-color: var(--line);
}

.icon-button svg {
  width: 16px;
  fill: none;
  stroke: currentColor;
  stroke-linecap: round;
  stroke-linejoin: round;
  stroke-width: 1.6;
}

/* Two-pane writing desk */
.desk {
  min-width: 0;
  min-height: 0;
  display: grid;
  grid-template-columns: 320px minmax(0, 1fr);
  overflow: hidden;
}

.archive-panel {
  min-width: 0;
  min-height: 0;
  display: grid;
  grid-template-rows: 55px 59px minmax(0, 1fr) 36px;
  background: var(--surface-subtle);
  border-right: 1px solid var(--line-strong);
}

.archive-tabs {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  padding: 0 10px;
  background: var(--surface);
  border-bottom: 1px solid var(--line);
}

.archive-tabs button {
  position: relative;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 7px;
  min-width: 0;
  padding: 0 7px;
  color: var(--ink-600);
  background: transparent;
  border: 0;
  cursor: pointer;
  font-size: 12px;
}

.archive-tabs button::after {
  content: "";
  position: absolute;
  right: 10px;
  bottom: -1px;
  left: 10px;
  height: 2px;
  background: transparent;
}

.archive-tabs button:hover {
  color: var(--ink-900);
}

.archive-tabs button[aria-selected="true"] {
  color: var(--blue-600);
  font-weight: 650;
}

.archive-tabs button[aria-selected="true"]::after {
  background: var(--blue-600);
}

.archive-tabs svg {
  width: 17px;
  flex: 0 0 auto;
  fill: none;
  stroke: currentColor;
  stroke-linecap: round;
  stroke-linejoin: round;
  stroke-width: 1.45;
}

.archive-tabs b {
  min-width: 17px;
  padding: 1px 5px;
  color: var(--ink-600);
  background: var(--surface-muted);
  border-radius: 9px;
  font-size: 9px;
  font-variant-numeric: tabular-nums;
  font-weight: 650;
}

.archive-tabs button[aria-selected="true"] b {
  color: var(--blue-700);
  background: var(--blue-100);
}

.search-field {
  align-self: center;
  height: 36px;
  display: flex;
  align-items: center;
  gap: 8px;
  margin: 11px 16px 12px;
  padding: 0 9px 0 11px;
  color: var(--ink-600);
  background: var(--surface);
  border: 1px solid var(--line-strong);
  border-radius: 7px;
}

.search-field:focus-within {
  border-color: var(--blue-600);
  box-shadow: var(--focus);
}

.search-field svg {
  width: 16px;
  flex: 0 0 auto;
  fill: none;
  stroke: var(--ink-500);
  stroke-linecap: round;
  stroke-width: 1.65;
}

.search-field input {
  min-width: 0;
  flex: 1;
  color: var(--ink-900);
  background: transparent;
  border: 0;
  outline: 0;
  font-size: 12px;
}

.search-field input::placeholder {
  color: var(--ink-500);
}

.tree-panel {
  min-height: 0;
  overflow: auto;
  padding: 4px 8px 18px;
  scrollbar-color: var(--line-strong) transparent;
  scrollbar-width: thin;
}

.tree-message {
  min-height: 150px;
  display: grid;
  place-items: center;
  align-content: center;
  gap: 10px;
  padding: 28px;
  color: var(--ink-500);
  text-align: center;
}

.tree-message p {
  max-width: 220px;
  margin: 0;
  font-size: 12px;
  line-height: 1.65;
}

.loading-mark {
  width: 18px;
  height: 18px;
  border: 2px solid var(--line-strong);
  border-top-color: var(--blue-600);
  border-radius: 50%;
  animation: spin 0.8s linear infinite;
}

@keyframes spin {
  to {
    transform: rotate(360deg);
  }
}

.file-tree,
.file-tree ul {
  margin: 0;
  padding: 0;
  list-style: none;
}

.file-tree > ul {
  display: grid;
  gap: 1px;
}

.file-tree details {
  min-width: 0;
}

.file-tree summary {
  position: relative;
  min-width: 0;
  height: 34px;
  display: flex;
  align-items: center;
  gap: 7px;
  padding: 0 8px 0 24px;
  color: var(--ink-800);
  border-radius: 5px;
  cursor: pointer;
  font-size: 12.5px;
  user-select: none;
}

.file-tree summary::-webkit-details-marker {
  display: none;
}

.file-tree summary::before {
  content: "";
  position: absolute;
  left: 9px;
  width: 5px;
  height: 5px;
  border-right: 1.4px solid var(--ink-500);
  border-bottom: 1.4px solid var(--ink-500);
  transform: rotate(-45deg);
  transition: transform 120ms ease;
}

.file-tree details[open] > summary::before {
  transform: rotate(45deg) translate(-1px, -1px);
}

.file-tree summary:hover {
  background: var(--surface-muted);
}

.tree-label {
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.tree-icon {
  width: 15px;
  flex: 0 0 auto;
  fill: none;
  stroke: var(--ink-500);
  stroke-linecap: round;
  stroke-linejoin: round;
  stroke-width: 1.45;
}

.folder-icon {
  stroke: #758ca6;
}

.file-tree ul ul {
  margin-left: 17px;
  padding-left: 5px;
  border-left: 1px solid var(--line);
}

.tree-inline-status {
  min-height: 30px;
  display: flex;
  align-items: center;
  padding: 4px 10px;
  color: var(--ink-500);
  font-size: 11px;
}

.tree-inline-status button {
  padding: 4px 0;
  color: var(--blue-600);
  background: transparent;
  border: 0;
  cursor: pointer;
  font: inherit;
}

.tree-inline-status button:hover {
  text-decoration: underline;
}

.file-row {
  width: 100%;
  height: 34px;
  display: flex;
  align-items: center;
  gap: 7px;
  min-width: 0;
  padding: 0 8px 0 11px;
  color: var(--ink-700);
  background: transparent;
  border: 0;
  border-radius: 5px;
  cursor: pointer;
  font-size: 12.5px;
  text-align: left;
}

.file-row:hover:not(:disabled) {
  color: var(--ink-900);
  background: var(--surface-muted);
}

.file-row[data-active="true"] {
  color: var(--ink-900);
  background: var(--surface-active);
  box-shadow: inset 2px 0 var(--blue-600);
  font-weight: 550;
}

.file-row[data-active="true"] .tree-icon {
  stroke: var(--blue-600);
}

.file-row:disabled {
  cursor: not-allowed;
  opacity: 0.48;
}

.file-row .tree-label {
  flex: 1;
}

.file-ext {
  flex: 0 0 auto;
  color: var(--ink-500);
  font-family: var(--mono);
  font-size: 8px;
  text-transform: uppercase;
}

.archive-note {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 0 16px;
  color: var(--ink-500);
  background: var(--surface-subtle);
  border-top: 1px solid var(--line);
}

.archive-note p {
  margin: 0;
  overflow: hidden;
  font-size: 10px;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* Editor */
.editor-panel {
  min-width: 0;
  min-height: 0;
  position: relative;
  overflow: hidden;
  background: var(--surface);
}

.editor-empty {
  height: 100%;
  display: grid;
  place-items: center;
  align-content: center;
  gap: 10px;
  padding: 40px;
  color: var(--ink-500);
  text-align: center;
}

.empty-folio {
  width: 52px;
  height: 52px;
  display: grid;
  place-items: center;
  margin-bottom: 3px;
  color: #9aa8b8;
  background: var(--surface-subtle);
  border: 1px solid var(--line);
  border-radius: 14px;
}

.empty-folio svg {
  width: 30px;
  fill: none;
  stroke: currentColor;
  stroke-linecap: round;
  stroke-linejoin: round;
  stroke-width: 1.45;
}

.editor-empty h2 {
  margin: 2px 0 0;
  color: var(--ink-800);
  font-size: 17px;
  font-weight: 650;
}

.editor-empty > p {
  max-width: 450px;
  margin: 0;
  color: var(--ink-500);
  font-size: 12px;
  line-height: 1.7;
}

.empty-shortcuts {
  gap: 15px;
  margin-top: 9px;
  font-size: 10px;
}

.empty-shortcuts span {
  display: flex;
  align-items: center;
  gap: 5px;
}

.editor-workspace {
  height: 100%;
  min-height: 0;
  display: grid;
  grid-template-rows: 73px minmax(0, 1fr) 43px;
}

.editor-toolbar {
  min-width: 0;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 24px;
  padding: 9px 22px 10px 24px;
  background: var(--surface);
  border-bottom: 1px solid var(--line);
}

.document-identity {
  min-width: 0;
  display: grid;
  align-content: center;
  gap: 5px;
}

.breadcrumbs {
  min-width: 0;
  display: flex;
  gap: 5px;
  overflow: hidden;
  color: var(--ink-500);
  font-size: 10px;
  white-space: nowrap;
}

.breadcrumbs span {
  max-width: 170px;
  overflow: hidden;
  text-overflow: ellipsis;
}

.breadcrumbs i {
  color: var(--line-strong);
  font-style: normal;
}

.document-title-row {
  min-width: 0;
}

.document-title-row h2 {
  min-width: 0;
  margin: 0;
  overflow: hidden;
  color: var(--ink-950);
  font-size: 17px;
  font-weight: 680;
  letter-spacing: 0.01em;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.editor-actions {
  flex: 0 0 auto;
  gap: 6px;
}

.document-delete-button:hover:not(:disabled),
.document-delete-button:focus-visible {
  color: var(--red-700);
  background: var(--red-100);
  border-color: #f0cdd1;
}

.document-delete-button:disabled {
  opacity: 0.45;
  cursor: not-allowed;
}

.mobile-back-button {
  display: none;
}

.mode-switch {
  display: flex;
  padding: 2px;
  background: var(--surface-muted);
  border: 1px solid var(--line);
  border-radius: 6px;
}

.mode-switch button {
  height: 27px;
  padding: 0 12px;
  color: var(--ink-600);
  background: transparent;
  border: 0;
  border-radius: 4px;
  cursor: pointer;
  font-size: 11px;
}

.mode-switch button[aria-pressed="true"] {
  color: var(--ink-900);
  background: var(--surface);
  box-shadow: 0 1px 2px rgba(31, 47, 65, 0.1);
  font-weight: 600;
}

.editor-body {
  min-height: 0;
  position: relative;
  overflow: hidden;
  background: var(--surface);
}

#editorInput,
.preview-pane {
  width: 100%;
  height: 100%;
  margin: 0;
  padding: 25px clamp(24px, 4vw, 62px) 80px;
  overflow: auto;
  color: var(--ink-900);
  background: var(--surface);
  border: 0;
  font-family: var(--sans);
  font-size: 16px;
  font-weight: 400;
  line-height: 1.82;
  letter-spacing: 0.012em;
  scrollbar-color: var(--line-strong) transparent;
  scrollbar-width: thin;
}

#editorInput {
  resize: none;
  outline: none;
  caret-color: var(--blue-600);
}

#editorInput::selection,
.preview-pane ::selection {
  background: #dbeafe;
}

.preview-pane {
  max-width: none;
}

.preview-pane > :first-child {
  margin-top: 0;
}

.preview-pane > :last-child {
  margin-bottom: 0;
}

.preview-pane h1,
.preview-pane h2,
.preview-pane h3,
.preview-pane h4 {
  margin: 1.7em 0 0.65em;
  color: var(--ink-950);
  font-weight: 680;
  line-height: 1.35;
}

.preview-pane h1 {
  padding-bottom: 0.35em;
  border-bottom: 1px solid var(--line);
  font-size: 1.6em;
}

.preview-pane h2 {
  font-size: 1.35em;
}

.preview-pane h3 {
  font-size: 1.15em;
}

.preview-pane p,
.preview-pane ul,
.preview-pane ol,
.preview-pane blockquote,
.preview-pane pre {
  max-width: 880px;
  margin: 0 0 1.1em;
}

.preview-pane blockquote {
  padding: 4px 0 4px 16px;
  color: var(--ink-600);
  border-left: 3px solid var(--line-strong);
}

.preview-pane code {
  padding: 0.12em 0.34em;
  background: var(--surface-muted);
  border-radius: 4px;
  font-family: var(--mono);
  font-size: 0.88em;
}

.preview-pane pre {
  padding: 16px 18px;
  overflow: auto;
  background: #f5f7fa;
  border: 1px solid var(--line);
  border-radius: 7px;
}

.preview-pane pre code {
  padding: 0;
  background: transparent;
}

.preview-pane hr {
  margin: 2em 0;
  border: 0;
  border-top: 1px solid var(--line);
}

.editor-footer {
  min-width: 0;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 20px;
  padding: 0 12px 0 18px;
  color: var(--ink-500);
  background: var(--surface-subtle);
  border-top: 1px solid var(--line);
  font-size: 10px;
}

.document-status {
  min-width: 0;
  gap: 14px;
  overflow: hidden;
  white-space: nowrap;
}

.document-status > span + span::before {
  content: "";
  display: inline-block;
  width: 1px;
  height: 10px;
  margin-right: 14px;
  vertical-align: -1px;
  background: var(--line-strong);
}

.document-meta {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.save-controls {
  flex: 0 0 auto;
  gap: 11px;
}

.save-state {
  display: flex;
  align-items: center;
  gap: 6px;
  color: var(--green-700);
  white-space: nowrap;
}

.save-state > span:first-child {
  width: 6px;
  height: 6px;
  background: currentColor;
  border-radius: 50%;
}

.save-state[data-state="dirty"] {
  color: var(--amber-700);
}

.save-state[data-state="saving"] > span:first-child {
  background: transparent;
  border: 1px solid currentColor;
  border-top-color: transparent;
  animation: spin 0.7s linear infinite;
}

.save-button {
  height: 31px;
  display: flex;
  align-items: center;
  gap: 6px;
  padding: 0 9px 0 10px;
  color: #fff;
  background: var(--blue-600);
  border: 1px solid var(--blue-600);
  border-radius: 5px;
  cursor: pointer;
  font-size: 11px;
  font-weight: 600;
  box-shadow: 0 1px 2px rgba(31, 67, 110, 0.14);
}

.save-button:hover:not(:disabled) {
  background: var(--blue-700);
  border-color: var(--blue-700);
}

.save-button:disabled {
  color: #9aa5b3;
  background: #e6eaf0;
  border-color: #e0e5eb;
  cursor: not-allowed;
  box-shadow: none;
}

.save-button svg {
  width: 14px;
  fill: none;
  stroke: currentColor;
  stroke-linecap: round;
  stroke-linejoin: round;
  stroke-width: 1.5;
}

.save-button kbd {
  height: 18px;
  color: inherit;
  background: rgba(255, 255, 255, 0.12);
  border-color: rgba(255, 255, 255, 0.28);
  line-height: 14px;
}

.save-button:disabled kbd {
  background: rgba(0, 0, 0, 0.03);
  border-color: rgba(0, 0, 0, 0.08);
}

/* Feedback and conflict protection */
.toast-region {
  position: fixed;
  z-index: 80;
  right: 18px;
  bottom: 54px;
  display: grid;
  gap: 8px;
  pointer-events: none;
}

.toast {
  min-width: 240px;
  max-width: min(360px, calc(100vw - 32px));
  display: flex;
  overflow: hidden;
  color: var(--ink-800);
  background: var(--surface);
  border: 1px solid var(--line-strong);
  border-radius: 7px;
  box-shadow: 0 12px 32px rgba(34, 49, 67, 0.14);
}

.toast::before {
  content: "";
  width: 4px;
  flex: 0 0 auto;
  background: var(--green-700);
}

.toast[data-kind="error"]::before {
  background: var(--red-700);
}

.toast p {
  margin: 0;
  padding: 11px 13px;
  font-size: 12px;
  line-height: 1.5;
}

.conflict-dialog {
  width: min(500px, calc(100vw - 28px));
  padding: 0;
  color: var(--ink-900);
  background: var(--surface);
  border: 1px solid var(--line-strong);
  border-radius: 10px;
  box-shadow: 0 24px 70px rgba(23, 32, 42, 0.2);
}

.conflict-dialog::backdrop {
  background: rgba(25, 34, 45, 0.32);
}

.conflict-dialog form {
  position: relative;
  padding: 28px;
}

.dialog-seal {
  width: 34px;
  height: 34px;
  display: grid;
  place-items: center;
  margin-bottom: 17px;
  color: var(--red-700);
  background: var(--red-100);
  border-radius: 8px;
  font-weight: 700;
}

.eyebrow {
  color: var(--ink-500);
  font-size: 10px;
  font-weight: 700;
  letter-spacing: 0.13em;
}

.conflict-dialog h2 {
  margin: 7px 0 10px;
  color: var(--ink-950);
  font-size: 19px;
  line-height: 1.45;
}

.conflict-dialog p {
  margin: 0;
  color: var(--ink-600);
  font-size: 13px;
  line-height: 1.75;
}

.dialog-actions {
  display: flex;
  justify-content: flex-end;
  gap: 9px;
  margin-top: 24px;
}

.secondary-button,
.danger-button {
  height: 34px;
  padding: 0 13px;
  background: var(--surface);
  border: 1px solid var(--line-strong);
  border-radius: 6px;
  cursor: pointer;
  font-size: 12px;
}

.secondary-button:hover {
  background: var(--surface-subtle);
}

.danger-button {
  color: #fff;
  background: var(--red-700);
  border-color: var(--red-700);
}

@media (max-width: 1040px) {
  .masthead {
    grid-template-columns: 270px minmax(180px, 1fr) auto;
  }

  .desk {
    grid-template-columns: 270px minmax(0, 1fr);
  }

  .workspace-summary {
    display: none;
  }

  .archive-panel {
    grid-template-rows: 52px 57px minmax(0, 1fr) 36px;
  }

  #editorInput,
  .preview-pane {
    padding-right: 30px;
    padding-left: 30px;
  }
}

@media (max-width: 720px) {
  body {
    overflow: hidden;
  }

  .app-shell {
    width: 100%;
    height: 100dvh;
    min-height: 0;
    grid-template-rows: 88px minmax(0, 1fr);
    overflow: hidden;
  }

  .masthead {
    position: sticky;
    z-index: 20;
    top: 0;
    grid-template-columns: minmax(0, 1fr) auto;
    grid-template-rows: 48px 40px;
    background: rgba(247, 248, 250, 0.97);
  }

  .brand-lockup {
    border-right: 0;
  }

  .brand-copy small {
    display: none;
  }

  .workspace-identity {
    grid-column: 1 / -1;
    grid-row: 2;
    align-self: stretch;
    padding: 0 16px;
    border-top: 1px solid var(--line);
  }

  .workspace-path {
    display: none;
  }

  .masthead-actions {
    grid-column: 2;
    grid-row: 1;
    padding-left: 4px;
  }

  .connection-status span:last-child {
    display: none;
  }

  .desk {
    min-height: 0;
    display: block;
    overflow: hidden;
  }

  .archive-panel {
    height: 100%;
    min-height: 0;
    grid-template-rows: 52px 57px minmax(0, 1fr) 36px;
    border-right: 0;
  }

  .editor-panel {
    display: none;
    height: 100%;
    min-height: 0;
    overflow: hidden;
  }

  body.document-open .archive-panel {
    display: none;
  }

  body.document-open .editor-panel {
    display: block;
  }

  .editor-workspace {
    min-height: 0;
    grid-template-rows: auto minmax(0, 1fr) auto;
  }

  .editor-toolbar {
    min-height: 76px;
    gap: 12px;
    padding: 10px 14px 11px 16px;
  }

  .mobile-back-button {
    height: 31px;
    display: flex;
    flex: 0 0 auto;
    align-items: center;
    gap: 2px;
    padding: 0 7px 0 4px;
    color: var(--blue-600);
    background: transparent;
    border: 0;
    border-radius: 5px;
    cursor: pointer;
    font-size: 11px;
  }

  .mobile-back-button:hover {
    background: var(--blue-100);
  }

  .mobile-back-button svg {
    width: 16px;
    fill: none;
    stroke: currentColor;
    stroke-linecap: round;
    stroke-linejoin: round;
    stroke-width: 1.7;
  }

  .breadcrumbs span {
    max-width: 82px;
  }

  .document-title-row h2 {
    max-width: 58vw;
    font-size: 16px;
  }

  .mode-switch button {
    padding: 0 9px;
  }

  #editorInput,
  .preview-pane {
    min-height: 0;
    padding: 23px 18px 70px;
    font-size: 15px;
    line-height: 1.78;
  }

  .editor-footer {
    min-height: 48px;
    padding: 7px 10px 7px 13px;
  }

  .document-status {
    gap: 0;
  }

  .document-status > span + span::before {
    margin-right: 9px;
  }

  .document-status > span {
    margin-right: 9px;
  }

  #encodingLabel,
  .document-meta {
    display: none;
  }

  .save-button kbd {
    display: none;
  }

  .toast-region {
    right: 12px;
    bottom: 60px;
    left: 12px;
  }

  .toast {
    width: 100%;
    max-width: none;
  }
}

@media (max-width: 400px) {
  .brand-lockup {
    padding-left: 12px;
  }

  .masthead-actions {
    gap: 5px;
    padding-right: 8px;
  }

  .archive-tabs {
    padding: 0 5px;
  }

  .search-field {
    margin-right: 12px;
    margin-left: 12px;
  }

  .save-state {
    display: none;
  }
}

@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    scroll-behavior: auto !important;
    transition-duration: 0.01ms !important;
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
  }
}
references/author-memory.md
# 作者记忆协议

作者记忆用于保存跨会话复用的创作偏好,不保存小说世界里的事实。它借鉴“原始证据 → 候选 → 已确认画像 → 变更记录”的记忆管道,但把决定权留给作者。

## 边界与优先级

加载优先级从高到低:

1. 安全、平台、字数、文件协议等硬性门禁;
2. 用户在当前请求中的明确要求;
3. 当前书的 `设定/文风.md`、题材定位、细纲和其他项目设定;
4. 作者记忆中的本书偏好;
5. 作者记忆中的题材、流程和全局偏好;
6. 对标素材、通用方法和默认值。

作者记忆不能把本书事实写进 `.story/作者记忆/`,不能覆盖当前请求,不能降低审稿 rubric,也不能让去 AI 味改动剧情意图。小说事实继续由各书的 `追踪/` 和 `设定/` 管理。

## 文件与所有权

工作区级目录:

```text
{工作区}/.story/作者记忆/
├── _author-memory-state.json  # 唯一结构化权威
├── 作者画像.md               # 仅 active,供作者查看与管理
├── 待确认.md                 # pending / conflict,不参与约束
└── 变更记录.md               # 最近 100 次、最新在前的事务记录
```

三个 Markdown 文件都从 state 确定性生成,禁止手改;完整历史保留在 state,变更记录只展示最近 100 次。`作者画像.md` 是人类管理视图,普通写作 agent 不整份注入,而是调用 `query` 取得本次相关的紧凑上下文。作者记忆不存在时,普通写作、审稿和去味任务直接继续,不自动初始化空目录;首次 `record` 会随事务创建。

工作区必须显式传给脚本。优先使用已经包含 `.story/作者记忆/` 的最近祖先;首次初始化时使用承载多本书、`.active-book`、`长篇/`、`短篇/` 或 `拆文库/` 的创作工作区根。不要把用户主目录当默认工作区。

## 什么时候读取

长篇、短篇、去 AI 味开始前,如果 state 已存在,用 `query` 按本书、题材、流程和类型筛选 active 条目。查询输出固定不超过 2048 字节。不要先查询全部再让 agent 自行筛选,按任务直接选择 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`;没有回执就不得声称“已经记住”。
- 普通创作只做一次本地 `query`,没有 state 时返回空结果且不创建文件;有记忆时也只返回相关 active 条目,硬上限 2048 字节。完整画像、证据、候选和 journal 不进入正文 prompt。
- 查询项是低优先级倾向,不是逐条打卡清单。自然吸收即可,不复述画像、不刻意提高词面命中率,也不得为命中偏好牺牲正文连贯、节奏、字数或本书既定笔调。
- 不安装会记录全部用户消息的 prompt hook。自然语言是否属于长期习惯仍需 agent 判断;这样不能承诺隐式偏好 100% 捕获,但避免把一次性要求、私人对话和小说事实静默写入长期记忆。需要确定写入时,用户可明确说“记住:……”,并以回执验收。

## 捕获判定

| 输入证据 | 处理 |
|---|---|
| “以后都这样”“我一直习惯……”等直接、稳定、范围清楚的原话 | `active`,`source=explicit_user` |
| 用户明确接受助手提出的长期做法 | `active`,`source=accepted_suggestion` |
| 同类修改反复出现,但用户没说这是长期规则 | `pending`,`source=repeated_correction` |
| 从成稿或操作轨迹推断出的模式 | `pending`,`source=inferred_pattern` |
| “这一章别……”“这次给我……”等一次性要求 | 只执行,不记录 |
| 角色、时间线、伏笔、世界观、当前剧情走向 | 写项目设定/追踪,不写作者记忆 |
| 助手自己生成的文字、默认模板、工具告警、rubric 结论 | 不自我学习 |

保留用户的否定词、限定词和适用范围,`quote` 写原话,`assertion` 只做不改变语义的紧凑归纳。范围规则:

- “本书 / 这个角色 / 这次连载” → `book`;
- “都市文 / 这类题材” → `genre`;
- 交稿、检查、确认节奏等操作习惯 → `workflow`;
- “以后 / 一贯 / 我习惯”且无更窄限定 → `global`;
- 范围含糊但可能稳定 → 取当前最窄合理范围并置 `pending`。

类型可选:`prose_style`、`story_design`、`workflow`、`delivery`、`interaction`。置信度与重要度均为 `low | medium | high`。

## 冲突、撤回与强化

- 同一类型、范围、归纳文本再次出现时,脚本强化原条目,累加证据和确认次数,不重复建条目。
- 新偏好与 active 条目矛盾时,先以 `conflict` 记候选,并在 `conflicts_with` 列出冲突 ID;当前任务仍按本轮明确要求执行。
- 作者选定新规则时用 `replace`,一次性启用新条目并把旧条目标成 `superseded`。
- pending 可以用 `decide=activate|reject`;冲突候选不能绕过旧规则直接 activate。
- 作者说“忘掉 / 这不再是我的习惯”时用 `forget`,保留历史证据但不再加载。
- active 条目的语义不可原地偷改;语义变化必须 replace,历史才可审计。

## 运行工具

先依次尝试 `python3`、`python`、`py -3` 找到 Python 3,再从当前 skill 根运行本地副本:

```text
{PYTHON} {当前 skill 根}/scripts/author_memory_commit.py init   --workspace {工作区}
{PYTHON} {当前 skill 根}/scripts/author_memory_commit.py record --workspace {工作区} --input {单事件.json}
{PYTHON} {当前 skill 根}/scripts/author_memory_commit.py query  --workspace {工作区} [--kind prose_style] [--book {书名}] [--genre {题材}] [--workflow {流程}]
{PYTHON} {当前 skill 根}/scripts/author_memory_commit.py commit --workspace {工作区} --input {事务.json}
{PYTHON} {当前 skill 根}/scripts/author_memory_commit.py check  --workspace {工作区}
```

- `record`:常用单事件入口,自动读取当前修订、首次自动初始化;`event_id` 相同且内容相同会幂等返回原回执,内容不同会失败。
- `query`:只读相关 active 条目;`--kind` 可重复,不存在 state 时返回空结果且零写入。返回的 `omitted > 0` 时收窄 kind / book / genre / workflow 后重查,不得改读完整画像规避预算。
- `commit`:高级批量入口;先在内存完成 schema、引用、容量和所有视图校验,最后原子替换 state。事务文件在成功前必须保留;过期修订会在任何写入前失败。
- `check`:从 state 重建并逐字核验所有派生视图。

## 事务格式

常用单事件新增或强化:

```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`。待确认项的 `status` 用 `pending`;冲突候选用 `conflict` 并填写 active ID。确认或拒绝候选时,把下列对象作为新事件的 `operation`:

```json
{"action":"decide","item_id":"AP002","decision":"activate","quote":"对,这就是我的长期习惯。","reason":"作者明确确认"}
```

用新规则替代一个或多个旧条目时,`replace.preference` 与上例字段相同,但不传 `status`、`conflicts_with`,新条目直接 active;下列对象同样作为 `operation`:

```json
{
  "action": "replace",
  "old_ids": ["AP001", "AP002"],
  "preference": {
    "kind": "prose_style",
    "scope": {"level": "book", "value": "雾港来信"},
    "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
#!/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 is workspace-level and deliberately
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

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")
SOURCES = (
    "explicit_user",
    "accepted_suggestion",
    "repeated_correction",
    "inferred_pattern",
    "manual",
)
RANK = {"low": 0, "medium": 1, "high": 2}


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 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(item_id.startswith("AP") and item_id[2:].isdigit() and int(item_id[2:]) >= 1, 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)


def memory_root(workspace: Path) -> Path:
    return workspace.resolve() / ".story" / "作者记忆"


def state_path(workspace: Path) -> Path:
    return memory_root(workspace) / "_author-memory-state.json"


def empty_state() -> dict[str, Any]:
    return {
        "schema_version": STATE_SCHEMA_VERSION,
        "state_revision": 0,
        "next_item_number": 1,
        "items": {},
        "journal": [],
        "applied_transactions": {},
    }


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(item_id.startswith("AP") and item_id[2:].isdigit() and int(item_id[2:]) >= 1, 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(superseded_by.startswith("AP") and superseded_by[2:].isdigit(), 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=768),
        "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 validate_state(value: object) -> dict[str, Any]:
    state = as_mapping(value, "state")
    allowed = {"schema_version", "state_revision", "next_item_number", "items", "journal", "applied_transactions"}
    require_known_keys(state, allowed, "state")
    require(state.get("schema_version") == STATE_SCHEMA_VERSION, f"state.schema_version must be {STATE_SCHEMA_VERSION}")
    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")
        max_number = max(max_number, int(raw_id[2:]))
        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")
    return {
        "schema_version": STATE_SCHEMA_VERSION,
        "state_revision": revision,
        "next_item_number": next_number,
        "items": items,
        "journal": copy.deepcopy(journal),
        "applied_transactions": copy.deepcopy(transactions),
    }


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)
    source = choice(preference.get("source"), 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")
        require(source not in {"repeated_correction", "inferred_pattern"}, f"{label} inferred evidence must remain pending")
    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": clean_text(preference.get("assertion"), f"{label}.assertion", max_bytes=768),
        "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]}


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:
    value = {
        "kind": preference["kind"],
        "scope": preference["scope"],
        "assertion": preference["assertion"].casefold(),
    }
    return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))


def allocate_item(state: dict[str, Any], preference: dict[str, Any], revision: int) -> dict[str, Any]:
    item_id = f"AP{state['next_item_number']:03d}"
    state["next_item_number"] += 1
    return {
        "id": item_id,
        "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:
    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 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
    old_ids = {item["id"] for item in old_items}
    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 old_ids]
        if retained == candidate["conflicts_with"]:
            continue
        candidate["conflicts_with"] = retained
        candidate["updated_revision"] = revision
        if not retained:
            candidate["status"] = "pending"
            released += 1
    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 = 0
    for candidate in state["items"].values():
        if candidate["status"] != "conflict" or item["id"] not in candidate["conflicts_with"]:
            continue
        candidate["conflicts_with"] = [conflict_id for conflict_id in candidate["conflicts_with"] if conflict_id != item["id"]]
        candidate["updated_revision"] = revision
        if not candidate["conflicts_with"]:
            candidate["status"] = "pending"
            released += 1
    suffix = f";{released} 个冲突候选退回待确认" if released else ""
    return f"忘记 {item['id']}:{item['assertion']}{suffix}"


def apply_transaction(state: dict[str, Any], transaction: dict[str, Any], digest: str) -> 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))
    committed_at = datetime.now(timezone.utc).replace(microsecond=0).isoformat()
    updated["state_revision"] = revision
    updated["journal"].append({
        "revision": revision,
        "transaction_id": transaction["transaction_id"],
        "committed_at": committed_at,
        "summaries": summaries,
    })
    item_ids = sorted(
        (item_id for item_id, item in updated["items"].items() if item["updated_revision"] == revision),
        key=lambda item_id: int(item_id[2:]),
    )
    require(bool(item_ids), "transaction did not update any author-memory item")
    updated["applied_transactions"][transaction["transaction_id"]] = {
        "revision": revision,
        "digest": digest,
        "item_ids": item_ids,
    }
    return validate_state(updated), summaries


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 render_profile(state: dict[str, Any]) -> str:
    lines = [
        "# 作者画像",
        "",
        "<!-- 由 author_memory_commit.py 生成,请勿手改;修改请提交事务。 -->",
        "",
        f"> 状态修订:{state['state_revision']}。仅列出已确认偏好;当前明确要求、本书设定与硬性门禁优先。",
        "",
    ]
    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: int(item["id"][2:]))
        if not items:
            lines.extend(["- 暂无", ""])
            continue
        for item in items:
            lines.append(f"- **{item['id']}**〔{scope_label(item['scope'])}|{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']}。待确认项不参与创作约束,也不应打断当前任务。",
        "",
    ]
    items = sorted((item for item in state["items"].values() if item["status"] in {"pending", "conflict"}), key=lambda item: int(item["id"][2:]))
    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"- 原话:\u201c{item['evidence'][-1]['quote']}\u201d",
            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 生成,请勿手改;最近记录在前。 -->",
        "",
    ]
    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(workspace: Path, state: dict[str, Any]) -> None:
    root = memory_root(workspace)
    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(root / name, payload)
    # State is the authority and therefore the last commit point.
    write_if_changed(state_path(workspace), state_payload)


def command_init(workspace: Path) -> dict[str, Any]:
    require(workspace.exists() and workspace.is_dir(), f"workspace does not exist: {workspace}")
    path = state_path(workspace)
    if path.exists():
        state = validate_state(read_json(path))
    else:
        state = empty_state()
    write_snapshot(workspace, state)
    return {"ok": True, "command": "init", "revision": state["state_revision"], "root": str(memory_root(workspace))}


def command_commit(workspace: Path, input_path: Path) -> dict[str, Any]:
    require(state_path(workspace).exists(), "author memory is not initialized; run init first")
    state = validate_state(read_json(state_path(workspace)))
    transaction = normalize_transaction(read_json(input_path))
    digest = transaction_digest(transaction)
    updated, summaries = apply_transaction(state, transaction, digest)
    replayed = updated is state
    if not replayed:
        write_snapshot(workspace, updated)
    else:
        # Repair missing or stale views during an idempotent retry.
        write_snapshot(workspace, state)
    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,
    }


def command_record(workspace: Path, input_path: Path) -> dict[str, Any]:
    require(workspace.exists() and workspace.is_dir(), f"workspace does not exist: {workspace}")
    event = normalize_record_event(read_json(input_path))
    path = state_path(workspace)
    state = validate_state(read_json(path)) if path.exists() else empty_state()
    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 state["state_revision"]
    transaction = {
        "schema_version": INPUT_SCHEMA_VERSION,
        "transaction_id": transaction_id,
        "expected_state_revision": expected_revision,
        "operations": [event["operation"]],
    }
    digest = transaction_digest(transaction)
    updated, summaries = apply_transaction(state, transaction, digest)
    replayed = updated is state
    write_snapshot(workspace, updated)
    record = updated["applied_transactions"][transaction_id]
    item_ids = record["item_ids"]
    receipt = f"Author Memory Receipt: r{record['revision']} · {', '.join(item_ids)}"
    return {
        "ok": True,
        "command": "record",
        "revision": updated["state_revision"],
        "applied_revision": record["revision"],
        "event_id": event["event_id"],
        "replayed": replayed,
        "item_ids": item_ids,
        "receipt": receipt,
        "summaries": summaries,
    }


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 command_query(
    workspace: Path,
    kinds: list[str] | None,
    book: str | None,
    genre: str | None,
    workflow: str | None,
) -> dict[str, Any]:
    require(workspace.exists() and workspace.is_dir(), f"workspace does not exist: {workspace}")
    path = state_path(workspace)
    if not path.exists():
        return {"ok": True, "command": "query", "initialized": False, "revision": 0, "items": [], "omitted": 0}
    state = validate_state(read_json(path))
    requested_kinds = set(kinds or KINDS)
    requested_scopes = {
        "book": optional_text(book, "query.book", max_bytes=180),
        "genre": optional_text(genre, "query.genre", max_bytes=180),
        "workflow": optional_text(workflow, "query.workflow", max_bytes=180),
    }

    def relevant(item: dict[str, Any]) -> bool:
        if item["status"] != "active" or item["kind"] not in requested_kinds:
            return False
        level = item["scope"]["level"]
        return level == "global" or same_scope_value(item["scope"]["value"], requested_scopes[level])

    scope_rank = {"book": 0, "genre": 1, "workflow": 2, "global": 3}
    candidates = sorted(
        (item for item in state["items"].values() if relevant(item)),
        key=lambda item: (
            scope_rank[item["scope"]["level"]],
            -RANK[item["importance"]],
            -item["confirmation_count"],
            int(item["id"][2:]),
        ),
    )
    result: dict[str, Any] = {
        "ok": True,
        "command": "query",
        "initialized": True,
        "revision": state["state_revision"],
        "items": [],
        "omitted": len(candidates),
    }
    for item in candidates:
        compact = {
            "id": item["id"],
            "kind": item["kind"],
            "scope": item["scope"],
            "assertion": item["assertion"],
        }
        result["items"].append(compact)
        result["omitted"] = len(candidates) - len(result["items"])
        payload = json.dumps(result, ensure_ascii=False, sort_keys=True) + "\n"
        if len(payload.encode("utf-8")) > QUERY_MAX_BYTES:
            result["items"].pop()
            result["omitted"] += 1
            break
    require(len((json.dumps(result, ensure_ascii=False, sort_keys=True) + "\n").encode("utf-8")) <= QUERY_MAX_BYTES, "query result exceeds its fixed byte budget")
    return result


def command_check(workspace: Path) -> dict[str, Any]:
    path = state_path(workspace)
    require(path.exists(), "author memory is not initialized")
    state = validate_state(read_json(path))
    views = render_views(state)
    root = memory_root(workspace)
    for name, expected in views.items():
        view_path = root / name
        require(view_path.exists(), f"missing derived view: {view_path}")
        require(view_path.read_text(encoding="utf-8") == expected, f"derived view is stale or edited: {view_path}")
    counts = {status: sum(1 for item in state["items"].values() if item["status"] == status) for status in STATUSES}
    return {"ok": True, "command": "check", "revision": state["state_revision"], "counts": counts}


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(description=__doc__)
    subparsers = parser.add_subparsers(dest="command", required=True)
    for command in ("init", "check"):
        child = subparsers.add_parser(command)
        child.add_argument("--workspace", required=True, type=Path)
    commit = subparsers.add_parser("commit")
    commit.add_argument("--workspace", required=True, type=Path)
    commit.add_argument("--input", required=True, type=Path)
    record = subparsers.add_parser("record")
    record.add_argument("--workspace", required=True, type=Path)
    record.add_argument("--input", required=True, type=Path)
    query = subparsers.add_parser("query")
    query.add_argument("--workspace", required=True, type=Path)
    query.add_argument("--kind", action="append", choices=KINDS)
    query.add_argument("--book")
    query.add_argument("--genre")
    query.add_argument("--workflow")
    return parser


def main() -> int:
    args = build_parser().parse_args()
    try:
        if args.command == "init":
            result = command_init(args.workspace)
        elif args.command == "commit":
            result = command_commit(args.workspace, args.input)
        elif args.command == "record":
            result = command_record(args.workspace, args.input)
        elif args.command == "query":
            result = command_query(args.workspace, args.kind, args.book, args.genre, args.workflow)
        else:
            result = command_check(args.workspace)
        emit(result)
        return 0
    except AuthorMemoryError as exc:
        emit({"ok": False, "error": str(exc)}, error=True)
        return 2


if __name__ == "__main__":
    raise SystemExit(main())
scripts/dashboard-server.mjs
#!/usr/bin/env node

import { spawn } from "node:child_process";
import { realpathSync } from "node:fs";
import { createServer } from "node:http";
import {
  chmod,
  copyFile,
  lstat,
  readFile,
  readdir,
  realpath,
  rename,
  stat,
  unlink,
  writeFile,
} from "node:fs/promises";
import { extname, basename, dirname, isAbsolute, relative, resolve, sep } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { createHash, randomUUID } from "node:crypto";

const MODULE_PATH = fileURLToPath(import.meta.url);
const ASSET_DIR = fileURLToPath(new URL("../assets/", import.meta.url));
const EDITABLE_EXTENSIONS = new Set([".md", ".txt", ".json", ".yaml", ".yml", ".toml"]);
const LONG_PROJECT_DIRECTORY_MARKERS = new Set(["正文", "大纲", "设定", "追踪"]);
const SHORT_PROJECT_BODY_FILE = "正文.md";
const SHORT_PROJECT_COMPANION_FILES = new Set(["小节大纲.md", "设定.md"]);
const IGNORED_DIRECTORIES = new Set([
  ".git",
  ".omc",
  ".omx",
  ".claude",
  ".codex",
  ".opencode",
  ".zcode",
  ".agents",
  "node_modules",
  "test-results",
  "playwright-report",
  "__pycache__",
]);
const MAX_FILE_BYTES = 2 * 1024 * 1024;
const MAX_REQUEST_BYTES = MAX_FILE_BYTES + 64 * 1024;
const DIRECTORY_PAGE_SIZE = 200;
const MAX_SEARCH_RESULTS = 100;
const MAX_SEARCH_NODES = 5000;
const MAX_SEARCH_DEPTH = 20;
const LOOPBACK_HOSTS = new Set(["127.0.0.1", "::1", "localhost"]);
const FILE_MUTATION_TAILS = new Map();

const CONTENT_TYPES = {
  ".css": "text/css; charset=utf-8",
  ".html": "text/html; charset=utf-8",
  ".js": "text/javascript; charset=utf-8",
  ".json": "application/json; charset=utf-8",
  ".svg": "image/svg+xml; charset=utf-8",
};

export class DashboardError extends Error {
  constructor(status, code, message) {
    super(message);
    this.name = "DashboardError";
    this.status = status;
    this.code = code;
  }
}

function isPathInside(candidate, root) {
  const relation = relative(root, candidate);
  return relation === "" || (!relation.startsWith(`..${sep}`) && relation !== ".." && !isAbsolute(relation));
}

function toPosixPath(value) {
  return value.split(sep).join("/");
}

function isEditableFile(name) {
  return EDITABLE_EXTENSIONS.has(extname(name).toLowerCase());
}

function fileVersion(content) {
  return createHash("sha256").update(content, "utf8").digest("hex");
}

async function withSerializedFileMutation(absolutePath, operation) {
  const previous = FILE_MUTATION_TAILS.get(absolutePath) || Promise.resolve();
  let release;
  const gate = new Promise((accept) => {
    release = accept;
  });
  const tail = previous.catch(() => {}).then(() => gate);
  FILE_MUTATION_TAILS.set(absolutePath, tail);
  await previous.catch(() => {});
  try {
    return await operation();
  } finally {
    release();
    if (FILE_MUTATION_TAILS.get(absolutePath) === tail) {
      FILE_MUTATION_TAILS.delete(absolutePath);
    }
  }
}

function recordScanError(scanErrors, root, absolutePath, error) {
  const errorPath = toPosixPath(relative(root, absolutePath)) || ".";
  if (scanErrors.some((entry) => entry.path === errorPath)) {
    return;
  }
  scanErrors.push({
    path: errorPath,
    code: typeof error?.code === "string" ? error.code : "READ_ERROR",
    message: `目录无法读取,请检查访问权限或挂载状态:${errorPath}`,
  });
}

function shouldIgnoreDirectory(name) {
  return IGNORED_DIRECTORIES.has(name) || (name.startsWith(".") && name !== ".story");
}

function compareTreeEntries(left, right) {
  if (left.type !== right.type) {
    return left.type === "directory" ? -1 : 1;
  }
  return left.name.localeCompare(right.name, "zh-CN", { numeric: true, sensitivity: "base" });
}

async function existingRealRoot(root) {
  const absolute = resolve(root);
  const info = await stat(absolute).catch(() => null);
  if (!info?.isDirectory()) {
    throw new DashboardError(400, "invalid_workspace", `工作区不存在或不是目录:${absolute}`);
  }
  return realpath(absolute);
}

export async function resolveWorkspacePath(root, requestedPath, options = {}) {
  const { editableOnly = false } = options;
  if (typeof requestedPath !== "string" || requestedPath.length === 0) {
    throw new DashboardError(400, "invalid_path", "文件路径不能为空");
  }
  if (
    requestedPath.includes("\0") ||
    isAbsolute(requestedPath) ||
    /^[A-Za-z]:[\\/]/.test(requestedPath)
  ) {
    throw new DashboardError(403, "path_outside_workspace", "只允许访问工作区内的相对路径");
  }

  const realRoot = await existingRealRoot(root);
  const candidate = resolve(realRoot, requestedPath);
  if (!isPathInside(candidate, realRoot)) {
    throw new DashboardError(403, "path_outside_workspace", "路径超出工作区");
  }

  const info = await lstat(candidate).catch((error) => {
    if (error?.code === "ENOENT") {
      throw new DashboardError(404, "file_not_found", "文件不存在");
    }
    throw error;
  });
  if (info.isSymbolicLink()) {
    throw new DashboardError(403, "symlink_not_editable", "Dashboard 不读写符号链接文件");
  }
  if (!info.isFile()) {
    throw new DashboardError(400, "not_a_file", "目标不是普通文件");
  }

  const resolvedFile = await realpath(candidate);
  if (!isPathInside(resolvedFile, realRoot)) {
    throw new DashboardError(403, "path_outside_workspace", "符号链接指向工作区外部");
  }
  if (editableOnly && !isEditableFile(candidate)) {
    throw new DashboardError(415, "unsupported_file_type", "该文件类型不支持在线编辑");
  }

  return { absolutePath: candidate, realRoot, info };
}

function directoryNode(absolutePath, relativePath) {
  return {
    name: basename(absolutePath),
    path: relativePath ? toPosixPath(relativePath) : ".",
    type: "directory",
    children: [],
    loaded: false,
  };
}

function assertRelativeWorkspacePath(requestedPath, label = "路径") {
  if (typeof requestedPath !== "string" || requestedPath.length === 0) {
    throw new DashboardError(400, "invalid_path", `${label}不能为空`);
  }
  if (
    requestedPath.includes("\0") ||
    isAbsolute(requestedPath) ||
    /^[A-Za-z]:[\\/]/.test(requestedPath)
  ) {
    throw new DashboardError(403, "path_outside_workspace", "只允许访问工作区内的相对路径");
  }
}

export async function resolveWorkspaceDirectory(root, requestedPath) {
  assertRelativeWorkspacePath(requestedPath, "目录路径");
  const realRoot = await existingRealRoot(root);
  const candidate = resolve(realRoot, requestedPath);
  if (!isPathInside(candidate, realRoot)) {
    throw new DashboardError(403, "path_outside_workspace", "路径超出工作区");
  }
  if (
    requestedPath !== "." &&
    requestedPath.split(/[\\/]+/).some((segment) => shouldIgnoreDirectory(segment))
  ) {
    throw new DashboardError(403, "directory_hidden", "该目录不会显示在 Dashboard 中");
  }

  const info = await lstat(candidate).catch((error) => {
    if (error?.code === "ENOENT") {
      throw new DashboardError(404, "directory_not_found", "目录不存在");
    }
    throw error;
  });
  if (info.isSymbolicLink()) {
    throw new DashboardError(403, "symlink_not_readable", "Dashboard 不读取符号链接目录");
  }
  if (!info.isDirectory()) {
    throw new DashboardError(400, "not_a_directory", "目标不是目录");
  }

  const resolvedDirectory = await realpath(candidate);
  if (!isPathInside(resolvedDirectory, realRoot)) {
    throw new DashboardError(403, "path_outside_workspace", "符号链接指向工作区外部");
  }
  return { absolutePath: candidate, realRoot };
}

function parseDirectoryCursor(value) {
  if (value === null || value === "") return 0;
  if (!/^\d+$/.test(value)) {
    throw new DashboardError(400, "invalid_cursor", "目录游标无效");
  }
  const cursor = Number(value);
  if (!Number.isSafeInteger(cursor)) {
    throw new DashboardError(400, "invalid_cursor", "目录游标无效");
  }
  return cursor;
}

function visibleDirectoryEntries(entries) {
  return entries
    .filter(
      (entry) =>
        !entry.isSymbolicLink() &&
        (!entry.isDirectory() || !shouldIgnoreDirectory(entry.name)),
    )
    .sort((left, right) =>
      compareTreeEntries(
        { name: left.name, type: left.isDirectory() ? "directory" : "file" },
        { name: right.name, type: right.isDirectory() ? "directory" : "file" },
      ),
    );
}

export async function listWorkspaceDirectory(root, requestedPath, cursorValue = null) {
  const { absolutePath, realRoot } = await resolveWorkspaceDirectory(root, requestedPath);
  const cursor = parseDirectoryCursor(cursorValue);
  const entries = await readdir(absolutePath, { withFileTypes: true }).catch(() => {
    throw new DashboardError(
      403,
      "directory_unreadable",
      `目录无法读取,请检查访问权限或挂载状态:${toPosixPath(requestedPath)}`,
    );
  });
  const visibleEntries = visibleDirectoryEntries(entries);
  const page = visibleEntries.slice(cursor, cursor + DIRECTORY_PAGE_SIZE);
  const nodes = (
    await Promise.all(
      page.map(async (entry) => {
        const childAbsolute = resolve(absolutePath, entry.name);
        const childRelative = relative(realRoot, childAbsolute);
        if (entry.isDirectory()) {
          return directoryNode(childAbsolute, childRelative);
        }
        const info = await lstat(childAbsolute).catch(() => null);
        if (!info?.isFile() || info.isSymbolicLink()) return null;
        return {
          name: entry.name,
          path: toPosixPath(childRelative),
          type: "file",
          editable: isEditableFile(childAbsolute) && info.size <= MAX_FILE_BYTES,
          size: info.size,
        };
      }),
    )
  ).filter(Boolean);
  const nextOffset = cursor + page.length;
  return {
    path: toPosixPath(relative(realRoot, absolutePath)) || ".",
    entries: nodes,
    nextCursor: nextOffset < visibleEntries.length ? String(nextOffset) : null,
  };
}

async function listLibraryRoots(root, scanErrors) {
  const roots = [];
  const standardRoot = resolve(root, "拆文库");
  const standardInfo = await lstat(standardRoot).catch(() => null);
  if (standardInfo?.isDirectory() && !standardInfo.isSymbolicLink()) {
    // 单个拆文库读不动时保留其他项目,但把残缺扫描显式带回前端;空数组只能表达“确实为空”,
    // 不能再同时承担权限错误/外挂盘掉线,否则作者会把不可见文稿误当成不存在。
    const entries = await readdir(standardRoot, { withFileTypes: true }).catch((error) => {
      recordScanError(scanErrors, root, standardRoot, error);
      return [];
    });
    for (const entry of entries) {
      if (entry.isDirectory() && !entry.isSymbolicLink()) {
        roots.push({ absolutePath: resolve(standardRoot, entry.name), relativePath: `拆文库${sep}${entry.name}` });
      }
    }
  }

  // 工作区根目录读不动就没有任何可展示的树,直接给出可执行的报错,而不是静默返回空树。
  const rootEntries = await readdir(root, { withFileTypes: true }).catch(() => {
    throw new DashboardError(
      403,
      "workspace_unreadable",
      `工作区目录无法读取,请检查访问权限:${root}`,
    );
  });
  for (const entry of rootEntries) {
    if (
      entry.name.startsWith("拆文库-") &&
      entry.isDirectory() &&
      !entry.isSymbolicLink()
    ) {
      roots.push({ absolutePath: resolve(root, entry.name), relativePath: entry.name });
    }
  }

  return roots.sort((left, right) =>
    left.relativePath.localeCompare(right.relativePath, "zh-CN", { numeric: true }),
  );
}

function isUnderAnyPath(candidate, blockedPaths) {
  return blockedPaths.some((blocked) => isPathInside(candidate, blocked));
}

async function findProjectRoots(
  root,
  libraryPaths,
  scanErrors,
  currentPath = root,
  depth = 0,
  projects = [],
) {
  if (depth > 3 || isUnderAnyPath(currentPath, libraryPaths)) {
    return projects;
  }

  const entries = await readdir(currentPath, { withFileTypes: true }).catch((error) => {
    recordScanError(scanErrors, root, currentPath, error);
    return [];
  });
  const childDirectoryNames = new Set(
    entries.filter((entry) => entry.isDirectory() && !entry.isSymbolicLink()).map((entry) => entry.name),
  );
  const childFileNames = new Set(
    entries.filter((entry) => entry.isFile() && !entry.isSymbolicLink()).map((entry) => entry.name),
  );
  const isLongProject = [...LONG_PROJECT_DIRECTORY_MARKERS].some((marker) =>
    childDirectoryNames.has(marker),
  );
  const isShortProject =
    childFileNames.has(SHORT_PROJECT_BODY_FILE) &&
    [...SHORT_PROJECT_COMPANION_FILES].some((marker) => childFileNames.has(marker));
  if (isLongProject || isShortProject) {
    projects.push({
      absolutePath: currentPath,
      relativePath: relative(root, currentPath),
    });
    return projects;
  }

  for (const entry of entries) {
    if (!entry.isDirectory() || entry.isSymbolicLink() || shouldIgnoreDirectory(entry.name)) {
      continue;
    }
    await findProjectRoots(
      root,
      libraryPaths,
      scanErrors,
      resolve(currentPath, entry.name),
      depth + 1,
      projects,
    );
  }
  return projects;
}

async function discoverWorkspaceRoots(realRoot) {
  const scanErrors = [];
  const libraryRoots = await listLibraryRoots(realRoot, scanErrors);
  const libraryPaths = libraryRoots.map((entry) => entry.absolutePath);
  const projectRoots = await findProjectRoots(realRoot, libraryPaths, scanErrors);
  return { libraryRoots, projectRoots, scanErrors };
}

export async function scanWorkspace(root) {
  const realRoot = await existingRealRoot(root);
  const { libraryRoots, projectRoots, scanErrors } = await discoverWorkspaceRoots(realRoot);
  const libraries = libraryRoots.map((entry) =>
    directoryNode(entry.absolutePath, entry.relativePath),
  );
  const projects = projectRoots.map((entry) =>
    directoryNode(entry.absolutePath, entry.relativePath),
  );
  libraries.sort(compareTreeEntries);
  projects.sort(compareTreeEntries);

  return {
    workspace: {
      name: basename(realRoot),
      path: realRoot,
    },
    libraries,
    projects,
    scanErrors,
    stats: {
      libraries: libraries.length,
      projects: projects.length,
      files: null,
      editableFiles: null,
      onDemand: true,
    },
    limits: {
      maxFileBytes: MAX_FILE_BYTES,
      editableExtensions: [...EDITABLE_EXTENSIONS],
      directoryPageSize: DIRECTORY_PAGE_SIZE,
      maxSearchResults: MAX_SEARCH_RESULTS,
      truncated: scanErrors.length > 0,
      truncatedByReadError: scanErrors.length > 0,
    },
  };
}

export async function searchWorkspace(root, queryValue, scopeValue) {
  const query = typeof queryValue === "string" ? queryValue.trim() : "";
  if (!query || query.length > 100) {
    throw new DashboardError(400, "invalid_query", "搜索词长度必须在 1–100 个字符之间");
  }
  if (!["libraries", "projects"].includes(scopeValue)) {
    throw new DashboardError(400, "invalid_scope", "搜索范围必须是拆文库或写作项目");
  }

  const realRoot = await existingRealRoot(root);
  const { libraryRoots, projectRoots, scanErrors } = await discoverWorkspaceRoots(realRoot);
  const roots = scopeValue === "libraries" ? libraryRoots : projectRoots;
  const normalizedQuery = query.toLocaleLowerCase("zh-CN");
  const state = {
    nodes: 0,
    truncatedByResults: false,
    truncatedByNodes: false,
    truncatedByDepth: false,
    results: [],
    scanErrors,
  };

  async function visit(absolutePath, relativePath, depth) {
    if (state.results.length >= MAX_SEARCH_RESULTS) {
      state.truncatedByResults = true;
      return;
    }
    if (state.nodes >= MAX_SEARCH_NODES) {
      state.truncatedByNodes = true;
      return;
    }
    if (depth > MAX_SEARCH_DEPTH) {
      state.truncatedByDepth = true;
      return;
    }
    state.nodes += 1;

    const info = await lstat(absolutePath).catch((error) => {
      recordScanError(state.scanErrors, realRoot, absolutePath, error);
      return null;
    });
    if (!info || info.isSymbolicLink()) return;
    if (info.isFile()) {
      const path = toPosixPath(relativePath);
      if (basename(absolutePath).toLocaleLowerCase("zh-CN").includes(normalizedQuery)) {
        state.results.push({
          name: basename(absolutePath),
          path,
          type: "file",
          editable: isEditableFile(absolutePath) && info.size <= MAX_FILE_BYTES,
          size: info.size,
        });
      }
      return;
    }
    if (!info.isDirectory()) return;

    const entries = await readdir(absolutePath, { withFileTypes: true }).catch((error) => {
      recordScanError(state.scanErrors, realRoot, absolutePath, error);
      return [];
    });
    for (const entry of visibleDirectoryEntries(entries)) {
      if (state.results.length >= MAX_SEARCH_RESULTS) {
        state.truncatedByResults = true;
        break;
      }
      if (state.nodes >= MAX_SEARCH_NODES) {
        state.truncatedByNodes = true;
        break;
      }
      await visit(
        resolve(absolutePath, entry.name),
        relativePath ? `${relativePath}${sep}${entry.name}` : entry.name,
        depth + 1,
      );
    }
  }

  for (const entry of roots) {
    await visit(entry.absolutePath, entry.relativePath, 0);
    if (state.truncatedByResults || state.truncatedByNodes) break;
  }
  state.results.sort(compareTreeEntries);
  const truncated =
    state.truncatedByResults ||
    state.truncatedByNodes ||
    state.truncatedByDepth ||
    state.scanErrors.length > 0;
  return {
    query,
    scope: scopeValue,
    results: state.results,
    truncated,
    truncation: {
      byResults: state.truncatedByResults,
      byNodes: state.truncatedByNodes,
      byDepth: state.truncatedByDepth,
      byReadError: state.scanErrors.length > 0,
    },
    scanErrors,
    limits: {
      maxResults: MAX_SEARCH_RESULTS,
      maxNodes: MAX_SEARCH_NODES,
      maxDepth: MAX_SEARCH_DEPTH,
    },
  };
}

async function readJsonBody(request) {
  const chunks = [];
  let size = 0;
  for await (const chunk of request) {
    size += chunk.length;
    if (size > MAX_REQUEST_BYTES) {
      throw new DashboardError(413, "request_too_large", "保存内容超过 2 MiB 限制");
    }
    chunks.push(chunk);
  }

  try {
    return JSON.parse(Buffer.concat(chunks).toString("utf8"));
  } catch {
    throw new DashboardError(400, "invalid_json", "请求正文不是有效 JSON");
  }
}

function responseHeaders(contentType) {
  return {
    "Cache-Control": "no-store",
    "Content-Security-Policy":
      "default-src 'self'; base-uri 'none'; connect-src 'self'; img-src 'self' data:; object-src 'none'; script-src 'self'; style-src 'self'",
    "Content-Type": contentType,
    "Cross-Origin-Resource-Policy": "same-origin",
    "Referrer-Policy": "no-referrer",
    "X-Content-Type-Options": "nosniff",
    "X-Frame-Options": "DENY",
  };
}

function sendJson(response, status, payload) {
  response.writeHead(status, responseHeaders("application/json; charset=utf-8"));
  // Keep JSON safe even if a response is ever embedded in an HTML context.
  // The endpoint already sends application/json with nosniff and a strict CSP;
  // escaping HTML-significant characters adds defense in depth for user input.
  const body = JSON.stringify(payload).replace(/[<>&\u2028\u2029]/g, (character) => {
    switch (character) {
      case "<":
        return "\\u003c";
      case ">":
        return "\\u003e";
      case "&":
        return "\\u0026";
      case "\u2028":
        return "\\u2028";
      default:
        return "\\u2029";
    }
  });
  response.end(body);
}

async function readWorkspaceFile(root, requestedPath) {
  const { absolutePath, info } = await resolveWorkspacePath(root, requestedPath, {
    editableOnly: true,
  });
  if (info.size > MAX_FILE_BYTES) {
    throw new DashboardError(413, "file_too_large", "文件超过 2 MiB,无法在 Dashboard 中打开");
  }
  const content = await readFile(absolutePath, "utf8");
  return {
    path: toPosixPath(relative(await existingRealRoot(root), absolutePath)),
    name: basename(absolutePath),
    content,
    size: Buffer.byteLength(content),
    mtimeMs: info.mtimeMs,
    version: fileVersion(content),
  };
}

async function replaceFileAtomically(target, content, mode) {
  const temporary = resolve(dirname(target), `.${basename(target)}.story-dashboard-${randomUUID()}.tmp`);
  await writeFile(temporary, content, { encoding: "utf8", mode });
  // open(2) 的 mode 会被进程 umask 削掉,光靠 writeFile 保不住原文件权限,
  // 所以改名前显式补一次;个别文件系统不支持权限位,失败就按原样落盘。
  await chmod(temporary, mode & 0o7777).catch(() => {});
  try {
    await rename(temporary, target);
  } catch (error) {
    if (process.platform !== "win32" || !["EACCES", "EPERM", "EEXIST"].includes(error?.code)) {
      await unlink(temporary).catch(() => {});
      throw error;
    }
    await copyFile(temporary, target);
    await unlink(temporary).catch(() => {});
  }
}

async function saveWorkspaceFile(root, payload) {
  if (!payload || typeof payload !== "object") {
    throw new DashboardError(400, "invalid_payload", "缺少保存参数");
  }
  if (typeof payload.content !== "string") {
    throw new DashboardError(400, "invalid_content", "文件内容必须是文本");
  }
  if (Buffer.byteLength(payload.content) > MAX_FILE_BYTES) {
    throw new DashboardError(413, "file_too_large", "文件超过 2 MiB,无法保存");
  }
  if (!/^[a-f0-9]{64}$/.test(payload.expectedVersion || "")) {
    throw new DashboardError(400, "missing_file_version", "保存请求缺少文件版本,请重新载入后再试");
  }

  const initial = await resolveWorkspacePath(root, payload.path, {
    editableOnly: true,
  });
  return withSerializedFileMutation(initial.absolutePath, async () => {
    let current;
    try {
      current = await resolveWorkspacePath(root, payload.path, { editableOnly: true });
    } catch (error) {
      if (error instanceof DashboardError && error.code === "file_not_found") {
        throw new DashboardError(409, "file_changed", "文件已被其他程序删除。请刷新目录后再保存。");
      }
      throw error;
    }
    const currentContent = await readFile(current.absolutePath, "utf8");
    if (fileVersion(currentContent) !== payload.expectedVersion) {
      throw new DashboardError(
        409,
        "file_changed",
        "文件已被其他程序修改。请重新载入后再保存,避免覆盖新内容。",
      );
    }

    await replaceFileAtomically(current.absolutePath, payload.content, current.info.mode);
    const updated = await stat(current.absolutePath);
    return {
      ok: true,
      path: toPosixPath(relative(current.realRoot, current.absolutePath)),
      size: updated.size,
      mtimeMs: updated.mtimeMs,
      version: fileVersion(payload.content),
    };
  });
}

async function deleteWorkspaceFile(root, payload) {
  if (!payload || typeof payload !== "object") {
    throw new DashboardError(400, "invalid_payload", "缺少删除参数");
  }
  if (!/^[a-f0-9]{64}$/.test(payload.expectedVersion || "")) {
    throw new DashboardError(400, "missing_file_version", "删除请求缺少文件版本,请重新载入后再试");
  }

  const initial = await resolveWorkspacePath(root, payload.path, {
    editableOnly: true,
  });
  return withSerializedFileMutation(initial.absolutePath, async () => {
    let current;
    try {
      current = await resolveWorkspacePath(root, payload.path, { editableOnly: true });
    } catch (error) {
      if (error instanceof DashboardError && error.code === "file_not_found") {
        throw new DashboardError(409, "file_changed", "文件已被其他程序删除。请刷新目录后再操作。");
      }
      throw error;
    }
    const currentContent = await readFile(current.absolutePath, "utf8");
    if (fileVersion(currentContent) !== payload.expectedVersion) {
      throw new DashboardError(
        409,
        "file_changed",
        "文件已被其他程序修改。请重新载入后再删除,避免误删新版本。",
      );
    }

    await unlink(current.absolutePath);
    return {
      ok: true,
      path: toPosixPath(relative(current.realRoot, current.absolutePath)),
    };
  });
}

async function serveStaticFile(requestPath, response) {
  const assetName = requestPath === "/" ? "index.html" : requestPath.slice(1);
  if (!["index.html", "styles.css", "app.js"].includes(assetName)) {
    sendJson(response, 404, { error: { code: "not_found", message: "页面不存在" } });
    return;
  }
  const assetPath = resolve(ASSET_DIR, assetName);
  const body = await readFile(assetPath);
  response.writeHead(200, responseHeaders(CONTENT_TYPES[extname(assetName)] || "application/octet-stream"));
  response.end(body);
}

function normalizedHostname(hostHeader) {
  if (!hostHeader) return "";
  try {
    return new URL(`http://${hostHeader}`).hostname.replace(/^\[|\]$/g, "").toLowerCase();
  } catch {
    return "";
  }
}

function normalizedOriginHostname(origin) {
  try {
    return new URL(origin).hostname.replace(/^\[|\]$/g, "").toLowerCase();
  } catch {
    return "";
  }
}

function assertLocalRequest(request, allowNetwork) {
  if (allowNetwork) return;
  const hostname = normalizedHostname(request.headers.host);
  if (!LOOPBACK_HOSTS.has(hostname)) {
    throw new DashboardError(403, "invalid_host", "Dashboard 只接受本机回环地址请求");
  }
  if (["PUT", "DELETE"].includes(request.method) && request.headers.origin) {
    const originHostname = normalizedOriginHostname(request.headers.origin);
    if (!LOOPBACK_HOSTS.has(originHostname)) {
      throw new DashboardError(403, "invalid_origin", "拒绝来自非本机页面的写入请求");
    }
  }
}

export function createDashboardServer({ root, allowNetwork = false }) {
  const workspaceRoot = resolve(root);
  return createServer(async (request, response) => {
    try {
      assertLocalRequest(request, allowNetwork);
      const url = new URL(request.url || "/", "http://127.0.0.1");
      if (request.method === "GET" && url.pathname === "/health") {
        sendJson(response, 200, { ok: true });
        return;
      }
      if (request.method === "GET" && url.pathname === "/api/workspace") {
        sendJson(response, 200, await scanWorkspace(workspaceRoot));
        return;
      }
      if (request.method === "GET" && url.pathname === "/api/tree") {
        sendJson(
          response,
          200,
          await listWorkspaceDirectory(
            workspaceRoot,
            url.searchParams.get("path") || "",
            url.searchParams.get("cursor"),
          ),
        );
        return;
      }
      if (request.method === "GET" && url.pathname === "/api/search") {
        sendJson(
          response,
          200,
          await searchWorkspace(
            workspaceRoot,
            url.searchParams.get("q") || "",
            url.searchParams.get("scope") || "",
          ),
        );
        return;
      }
      if (request.method === "GET" && url.pathname === "/api/file") {
        sendJson(response, 200, await readWorkspaceFile(workspaceRoot, url.searchParams.get("path") || ""));
        return;
      }
      if (request.method === "PUT" && url.pathname === "/api/file") {
        sendJson(response, 200, await saveWorkspaceFile(workspaceRoot, await readJsonBody(request)));
        return;
      }
      if (request.method === "DELETE" && url.pathname === "/api/file") {
        sendJson(response, 200, await deleteWorkspaceFile(workspaceRoot, await readJsonBody(request)));
        return;
      }
      if (request.method === "GET") {
        await serveStaticFile(url.pathname, response);
        return;
      }
      sendJson(response, 405, {
        error: { code: "method_not_allowed", message: "请求方法不支持" },
      });
    } catch (error) {
      const known = error instanceof DashboardError;
      if (!known) {
        console.error("[story-dashboard]", error);
      }
      sendJson(response, known ? error.status : 500, {
        error: {
          code: known ? error.code : "internal_error",
          message: known ? error.message : "Dashboard 处理请求时发生错误",
        },
      });
    }
  });
}

function parseCliArguments(argv) {
  const options = {
    root: process.cwd(),
    host: process.env.STORY_DASHBOARD_HOST || "127.0.0.1",
    port: Number(process.env.STORY_DASHBOARD_PORT || 43110),
    open: false,
    allowNetwork: false,
  };

  for (let index = 0; index < argv.length; index += 1) {
    const value = argv[index];
    if (value === "--root") {
      options.root = argv[++index];
    } else if (value === "--host") {
      options.host = argv[++index];
    } else if (value === "--port") {
      options.port = Number(argv[++index]);
    } else if (value === "--open") {
      options.open = true;
    } else if (value === "--allow-network") {
      options.allowNetwork = true;
    } else if (value === "--help" || value === "-h") {
      options.help = true;
    } else {
      throw new DashboardError(400, "unknown_argument", `未知参数:${value}`);
    }
  }

  if (!options.root) {
    throw new DashboardError(400, "missing_root", "--root 需要目录参数");
  }
  if (!Number.isInteger(options.port) || options.port < 0 || options.port > 65535) {
    throw new DashboardError(400, "invalid_port", "端口必须是 0–65535 的整数");
  }
  if (!LOOPBACK_HOSTS.has(options.host) && !options.allowNetwork) {
    throw new DashboardError(
      400,
      "network_binding_requires_opt_in",
      "非本机地址需要显式增加 --allow-network;通常不应把写作文件暴露到局域网",
    );
  }
  return options;
}

async function listen(server, host, preferredPort) {
  const attempts = preferredPort === 0 ? [0] : Array.from({ length: 11 }, (_, index) => preferredPort + index);
  for (const port of attempts) {
    try {
      await new Promise((accept, reject) => {
        const onError = (error) => {
          server.off("listening", onListening);
          reject(error);
        };
        const onListening = () => {
          server.off("error", onError);
          accept();
        };
        server.once("error", onError);
        server.once("listening", onListening);
        server.listen(port, host);
      });
      return server.address().port;
    } catch (error) {
      if (error?.code !== "EADDRINUSE" || port === attempts.at(-1)) {
        throw error;
      }
    }
  }
  throw new Error("No available port");
}

function openBrowser(url) {
  const { command, args } = browserLaunchCommand(url);
  const child = spawn(command, args, { detached: true, stdio: "ignore" });
  child.on("error", () => {});
  child.unref();
}

export function browserLaunchCommand(url, platform = process.platform) {
  if (platform === "darwin") {
    return { command: "open", args: [url] };
  }
  if (platform === "win32") {
    return { command: "cmd", args: ["/c", "start", "", url] };
  }
  return { command: "xdg-open", args: [url] };
}

export function pathsReferToSameFile(left, right) {
  if (!left || !right) return false;
  try {
    return realpathSync(left) === realpathSync(right);
  } catch {
    return false;
  }
}

function printHelp() {
  console.log(`Story Dashboard

Usage:
  node dashboard-server.mjs [--root <dir>] [--host 127.0.0.1] [--port 43110] [--open]

Options:
  --root <dir>       写作工作区,默认当前目录
  --host <host>      监听地址,默认 127.0.0.1
  --port <port>      首选端口,默认 43110;0 表示随机端口
  --open             启动后用系统默认浏览器打开
  --allow-network    显式允许绑定非回环地址(不推荐)
`);
}

async function main() {
  const options = parseCliArguments(process.argv.slice(2));
  if (options.help) {
    printHelp();
    return;
  }

  const workspace = await existingRealRoot(options.root);
  const server = createDashboardServer({ root: workspace, allowNetwork: options.allowNetwork });
  const port = await listen(server, options.host, options.port);
  const displayHost = options.host === "::1" ? "[::1]" : options.host;
  const url = `http://${displayHost}:${port}`;

  console.log("Story Dashboard 已启动");
  console.log(`工作区:${workspace}`);
  console.log(`本机地址:${url}`);
  if (options.open) {
    openBrowser(url);
  }

  const shutdown = () => {
    server.close(() => process.exit(0));
  };
  process.once("SIGINT", shutdown);
  process.once("SIGTERM", shutdown);
}

const isMain = pathsReferToSameFile(process.argv[1], MODULE_PATH);
if (isMain) {
  main().catch((error) => {
    const message = error instanceof DashboardError ? error.message : error?.stack || String(error);
    console.error(`Story Dashboard 启动失败:${message}`);
    process.exitCode = 1;
  });
}
SKILL.md
---
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/作者记忆/`。常用变更走单事件 `record`;工具未返回 `ok: true` 和 `Author Memory Receipt` 前,不得声称已记住。显示画像或待确认项是只读操作;不存在时直接说明尚未建立。

新增习惯必须保留用户原话和适用范围。一次性要求只执行不记录;小说事实写入本书设定/追踪;推断和重复修正先进入待确认;与已生效习惯冲突时显式 replace,不原地改写历史。用户没有指定工作区时,按协议定位已有作者记忆的最近祖先或当前创作工作区,禁止默认写到用户主目录。

## 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: 28` 不一致时(标记缺失、字段缺失/非整数、小于或大于 28)**照常按文件存在性检查并 spawn**,但只检查当前运行时的 canonical 目录;同时报告 `Notice: agents bundle 版本不匹配(项目 {N},本版 28)` 并提示重新运行 `/story-setup` 后新开会话;大于 28 时额外提示先更新 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
0.7.8