Book 第十章:Notebook 为什么必须结构化编辑
第三部分:本地执行面

第十章:Notebook 为什么必须结构化编辑

看 Claude Code 为什么明确不把 notebook 当普通 JSON 文本去改,而是按 cell 结构操作。

1. 为什么 notebook 必须单独看

如果把 .ipynb 当普通文本文件看,最直觉的做法其实是:

  • Read 到 JSON
  • Edit 某几段字符串
  • 再写回去

但 Claude Code 明确没有这么干。

它专门做了一个 NotebookEditTool,这背后其实是一个很重要的设计判断:

notebook 不是普通文本文件,而是结构化文档对象。

这件事很关键,因为它意味着 Claude Code 在工具层已经承认:

  • 有些文件虽然落盘是文本
  • 但运行时不该把它们按文本处理

对你后面做 C# 版来说,这几乎就是对象模型分层的直接证据。

2. 先说结论

我对这份实现的判断是:

NotebookEditTool 不是 FileEditTool 的变种,而是“面向 cell 的 notebook 结构编辑协议”。

它的核心特点是:

  • 明确只服务 .ipynb
  • 编辑目标是 cell,不是原始 JSON 片段
  • 支持 replace / insert / delete
  • 仍然要求先 Read
  • 写入时会保留 notebook 文件的编码和换行
  • 修改 code cell 时会清空 execution_countoutputs

这套设计很能说明 Claude Code 的一条原则:

不要让模型直接改序列化格式,尽量让模型改领域对象。

3. 源码锚点

建议先看这些位置:

  • src/tools/NotebookEditTool/NotebookEditTool.ts:90
  • src/tools/NotebookEditTool/NotebookEditTool.ts:125
  • src/tools/NotebookEditTool/NotebookEditTool.ts:133
  • src/tools/NotebookEditTool/NotebookEditTool.ts:176
  • src/tools/NotebookEditTool/NotebookEditTool.ts:219
  • src/tools/NotebookEditTool/NotebookEditTool.ts:252
  • src/tools/NotebookEditTool/NotebookEditTool.ts:295
  • src/tools/NotebookEditTool/NotebookEditTool.ts:331
  • src/tools/NotebookEditTool/NotebookEditTool.ts:372
  • src/tools/NotebookEditTool/NotebookEditTool.ts:392
  • src/tools/NotebookEditTool/NotebookEditTool.ts:431
  • src/tools/NotebookEditTool/prompt.ts:2
  • src/tools/NotebookEditTool/UI.tsx:72

4. 整体结构图

flowchart TD
    A["模型发起 NotebookEdit(notebook_path, cell_id, new_source, cell_type, edit_mode)"] --> B["checkPermissions()<br/>write 权限"]
    B --> C["validateInput()"]

    C --> C1["必须是 .ipynb"]
    C --> C2["edit_mode 合法"]
    C --> C3["insert 时必须给 cell_type"]
    C --> C4["必须先 Read"]
    C --> C5["stale check"]
    C --> C6["notebook JSON 合法"]
    C --> C7["cell_id 或 cell index 可定位"]

    C7 --> D["call()"]
    D --> E["fileHistoryTrackEdit()"]
    E --> F["readFileSyncWithMetadata()"]
    F --> G["jsonParse() 非缓存版本"]
    G --> H{"edit_mode"}
    H --> I["delete cell"]
    H --> J["insert cell"]
    H --> K["replace cell source"]
    K --> L["code cell 清 execution_count / outputs"]
    I --> M["jsonStringify(notebook)"]
    J --> M
    L --> M
    M --> N["writeTextContent()"]
    N --> O["更新 readFileState"]
    O --> P["tool_result"]

这里最重要的一点是:

Claude Code 对 notebook 的编辑粒度是 cell,而不是文本 diff。

5. Prompt 已经暴露了这不是文本编辑

prompt.ts 里的描述很直接:

  • 编辑对象是 notebook cell
  • edit_mode=insert 表示插入 cell
  • edit_mode=delete 表示删除 cell

虽然 prompt 里还残留了 cell_number 这种旧说法,但真正的实现已经是:

  • cell_id
  • 或可解析成索引形式的 cell-N

这点反而挺有意思,它说明这里很可能经历过一版从“按索引编辑”向“按 cell ID 编辑”演化的过程。

6. 协议模型本身就是结构化的

6.1 输入不是“旧串 / 新串”

NotebookEditTool 的输入是:

  • notebook_path
  • cell_id
  • new_source
  • cell_type
  • edit_mode

FileEditTool 相比,这已经完全不是一个层级的问题了。

FileEditTool 让模型描述:

  • 哪段字符串要替换成什么

NotebookEditTool 让模型描述:

  • 哪个 cell 要进行什么操作

这说明 Claude Code 不想让模型理解 notebook 的底层 JSON 细节,而是直接给它一个更稳定的领域接口。

6.2 输出也围绕 notebook 语义组织

输出里会包含:

  • cell_id
  • cell_type
  • language
  • edit_mode
  • original_file
  • updated_file

也就是说,它不是单纯给一个“编辑成功”的字符串,而是保留了 notebook 级别的前后状态。

7. 校验阶段最重要的是三条边界

7.1 只能处理 .ipynb

如果扩展名不是 .ipynb,直接拒绝,并明确告诉模型:

  • 其他文件请用 FileEditTool

这个边界很清楚:

  • text file -> FileEditTool
  • notebook -> NotebookEditTool

7.2 insert 必须显式指定 cell_type

这条要求非常合理。

因为插入一个新 cell 时,系统没法从现有对象里推断它到底应该是:

  • code
  • markdown

所以这里不让模型偷懒,而是要求把意图说完整。

7.3 一样要求先 Read

源码注释写得很直接:

  • 如果不要求先读,模型就可能编辑一个从没看过的 notebook
  • 或者基于过时内容修改,导致 silent data loss

这再次证明“先读后写”不是 text tool 的特殊要求,而是 Claude Code 本地修改工具的统一原则。

8. notebook 校验不是只看路径,还要看对象内容

8.1 必须是合法 JSON

它会先把文件内容做 safeParseJSON(...)

如果根本不是合法 JSON,直接拒绝。

8.2 cell_id 支持两种定位方式

这里实现得挺细:

先尝试:

  • 按 notebook 里真实的 cell.id 查找

如果找不到,再尝试:

  • cell_id 解析成索引形式

比如:

  • cell-3

这种双通道定位挺合理,因为 notebook 在不同版本、不同生成器里,对 cell 标识的稳定性并不完全一样。

8.3 没给 cell_id 也不是一概不行

如果是 insert,不提供 cell_id 也可以,默认从开头插。

但如果不是 insert,那就会直接拒绝。

这说明这里对“缺省行为”也很克制:

  • 只在插入场景提供合理默认
  • 替换和删除必须明确定位

9. call() 里的几个实现细节很有代表性

9.1 它用了非缓存版 jsonParse()

源码注释特别说明:

  • safeParseJSON() 有缓存
  • call() 里会原地修改 notebook 对象
  • 如果继续复用缓存对象,会污染后续调用

这很值得记一下,因为它说明作者不仅考虑“能不能 parse”,还考虑:

  • 共享对象引用
  • 缓存污染
  • validate 和 call 之间的对象生命周期

这是很典型的 runtime 细节意识。

9.2 replace 有一个“越界自动转 insert”的语义

如果模型想 replace 的位置刚好是:

  • cellIndex === notebook.cells.length

实现会自动把它转成 insert

而且如果没指定 cell_type,还会默认成 code

这能看出 Claude Code 在结构化对象上更愿意做“温和纠偏”,而不是像 FileEditTool 那样在文本歧义上坚决拒绝。

9.3 新插入的 code cell 会自动带 notebook 语义字段

如果插入的是 code cell,会显式补:

  • execution_count: null
  • outputs: []

这说明它创建的不是“看起来像 cell 的 JSON”,而是符合 notebook 语义的完整 cell 对象。

10. 修改 code cell 时,会主动清掉执行结果

这是这份实现里最值得学的一点之一。

replace 命中一个 code cell 时,它不仅会改 source,还会:

  • execution_count = null
  • outputs = []

这个决策很对,因为一旦代码变了:

  • 原有执行编号可能已经失真
  • 原有输出大概率也失真

如果不清理,notebook 看起来就会处于一种“代码和结果不一致”的假状态。

这说明 Claude Code 对 notebook 的理解不是“文本容器”,而是“带执行语义的文档对象”。

11. 写回文件时,它保留了 notebook 文件本身的外壳风格

11.1 保留编码和换行

readFileSyncWithMetadata() 读出来的不只是内容,还有:

  • encoding
  • lineEndings

最后写回时会用:

  • writeTextContent(fullPath, updatedContent, encoding, lineEndings)

这说明 notebook 虽然是结构化对象,但落盘外壳仍然被认真保留。

11.2 写回 JSON 时固定缩进

它用的是:

  • jsonStringify(notebook, null, 1)

也就是说,Claude Code 在 notebook 序列化层有自己稳定的格式选择。

这和 FileEditTool 的“尽量保留原文件文本风格”不一样,说明 notebook 在它这里更像数据结构,而不是纯文本文档。

12. readFileState 的更新细节也很讲究

写完以后它会更新:

  • content
  • timestamp
  • offset: undefined
  • limit: undefined

源码注释特别点出一件事:

  • 这样做是为了打断 FileReadTool 的 dedup 命中
  • 否则 Read -> NotebookEdit -> Read 在同一毫秒里可能会错误返回 file_unchanged

这说明不同工具之间并不是松散并列关系,而是共享同一套读取状态机。

也就是说,Claude Code 的工具层是一个相互感知的 runtime 网络

13. 它和 FileEditTool / FileWriteTool 的差异,也很能说明设计思路

NotebookEditTool 有几件“没做”的事,反而很值得注意:

  • 没有 LSP 通知
  • 没有 VS Code diff 通知
  • 没有 skill discovery
  • 没有文本级 quote normalization

这不是缺功能,而是边界不同。

这说明 Claude Code 已经默认接受:

  • notebook 不是 LSP 主战场
  • notebook 编辑不是文本风格问题
  • notebook 的关键是对象语义,而不是 patch 语义

这是一种很成熟的“按对象类型分 runtime 责任”的做法。

14. 对 C# 版的翻译建议

如果你后面做 C# 版,我建议不要让 notebook 复用 FileEditTool 的请求模型。

更稳的拆法是:

14.1 协议层

  • NotebookEditToolDefinition
  • NotebookEditRequest
  • NotebookEditResult
  • NotebookEditMode

14.2 notebook 对象层

  • NotebookDocument
  • NotebookCell
  • CodeCell
  • MarkdownCell

14.3 校验层

  • INotebookPathPolicy
  • INotebookReadStateGuard
  • INotebookCellLocator
  • INotebookSchemaValidator

14.4 编辑层

  • INotebookEditCoordinator
  • INotebookCellFactory
  • INotebookExecutionStateCleaner
  • INotebookSerializer

这里我最建议你保留两件事:

  1. notebook 单独协议
  2. code cell 修改后清 outputs / execution_count

这两条非常关键,因为它们直接决定 notebook 编辑结果是不是语义一致。

15. 我对这套设计的评价

NotebookEditTool 最值得学的,不是它支持 insert / replace / delete,而是它把 notebook 从“文本文件”提升成了“领域对象”。

这件事看起来小,实际上非常值钱。

因为一旦你承认 notebook 是对象,不是文本:

  • 你就会给它单独协议
  • 你就会在 code cell 修改后清理执行结果
  • 你就不会让模型去改一大坨 JSON

这正是 Claude Code 这套工具设计最有参考价值的地方:

不是所有东西都该走同一把锤子。