一次完整的开源扩展开发复盘:从发现需求空白到发布 npm、进官方包目录,外加四个值得记住的坑。
背景我在用 pi,但它读不了 PDF
pi 是 earendil-works 开源的 coding agent(GitHub 117k+ stars),设计哲学非常克制------核心只内置 8 个工具:bash / edit / find / grep / ls / powershell / read / write。子代理、plan mode、扩展生态全部外置,官方的态度是"需要什么,装什么"。
克制带来一个问题:read 工具只认文本和图片。你给 AI 助手一个 .pdf 需求文档、一个 .xlsx 数据表,它会直接把二进制字节流当文本读出来,输出一堆乱码。
Claude Code 官方的解法是给模型一个 skill,教它现场写 Python 脚本 调 pdfplumber------每次都要生成代码、依赖 Python 环境。而我在 GitHub 上搜了一圈:pi 的生态里居然没有一个文档读取扩展。
pi-doc-reader 是什么
一行命令安装:
bash
pi install npm:pi-doc-reader
装完之后,你的 pi 直接获得:
- PDF :逐页提取文本层;没有文本层的扫描页自动渲染成 PNG 图片,交给 vision 模型"看"。用
pages="1-10"参数翻页,默认读前 20 页 - DOCX:标题、列表、表格、粗斜体,完整转 markdown
- XLSX:每个 sheet 一张 markdown 表格,公式取缓存结果,超长表格自动截断并提示
- PPTX:每页标题 + 正文要点,演讲者备注作为附注输出
- 旧格式兜底 :
.doc/.xls这类 OLE2 老格式会提示用textutil/libreoffice转换后再读
使用上零学习成本 ------不需要任何新的提示词或命令,你只要跟平时一样说"读一下 report.pdf",模型就会调 read。

GitHub:github.com/skyler-deve...
npm:www.npmjs.com/package/pi-...
pi 官方包目录:pi.dev/packages/pi...
一个核心设计决策:覆写 read,而不是新增工具
拿到需求时第一个纠结点:做一个新工具 read_doc,还是覆写内置的 read?
pi 官方示例 tool-override.ts 确认了同名注册即可覆写。我选择了覆写,理由是模型的调用习惯 :系统提示词已经训练模型"读文件就用 read",给它一个 .pdf 路径时它天然会调 read。如果做成新工具,模型大概率先用 read 失败一次(读出一堆乱码),再转投新工具------浪费一轮上下文。
但覆写有个必须解决的问题:不能破坏原有行为 。pi 的 read 对文本文件的截断策略、对图片的 resize 处理、对远程路径的解析都在持续演进,如果我复制一份实现,上游一改我就落后。
最终的架构是覆写 + 委托:
bash
execute(path)
→ 读文件头 8 字节 + zip 中央目录(不 decompress)判格式
├─ txt/md/图片等 → 原样委托给 createReadToolDefinition 的原实现
├─ pdf/docx/xlsx/pptx → 文档转换管线
└─ 损坏文件 → 报错 + 可操作的 fallback 建议
非文档路径逐字节透传给原实现(把 ctx 通过包装层的隐藏第五参数传回去,保证图片 resize、cwd 解析和内置行为完全一致),我的代码只接管文档分流。测试里专门有一条断言:覆写前后读 txt 的输出逐字节一致。
格式判定也不用扩展名,而是读 magic bytes:%PDF 头 → PDF;PK zip 头再解析中央目录看内部路径是 word/ 还是 xl/;D0 CF 11 E0 是老 OLE 格式。这样 .pdf 后缀的纯文本文件能正常读,没有后缀的真 PDF 也不会漏。
PDF 是最有意思的部分:双通道混合
PDF 分两种:有文本层的(程序生成的)和纯图片的(扫描件)。一套管线吃不下两种,所以做了混合:
- unpdf(pdfjs 的 serverless 封装)逐页提取文本
- 单页提取字符数 < 32 判定为扫描页
- 扫描页用 pdfjs +
@napi-rs/canvas渲染成 PNG(最长边压到 1570px,对齐主流 vision 模型的输入预算),作为ImageContent内容块和文本一起返回
pi 的工具协议原生支持混合内容块(内置 read 读图片就是这个形态),所以扫描页图片可以直接进入模型上下文。实测 deepseek 这类多模态模型能从渲染图里读出发票号和金额------甚至能发现我测试样例里文字被页边裁切的真实瑕疵。
几个体验层面的细节:
pages参数 :"1-10"、"5-"、"3,7"都行,默认前 20 页,输出超限时提示模型用 pages 续读- 非 vision 模型降级 :检测到模型不支持图像时省略图片块并说明,整本无文本层时直接给
ocrmypdf的 fallback 命令 - 内容哈希缓存:markdown 和渲染的页图都按文件内容 SHA-256 缓存(页图单独落盘),百页 PDF 只转换一次;LRU 上限 500MB
- 损坏/加密 PDF:不抛裸异常,而是返回"无法解析 + 这是可执行的 pdftotext 命令",让模型自己接手兜底
踩坑记录(这部分可能比扩展本身有用)
坑 1:pi 宿主包必须声明在 peerDependencies。 我最初把 typebox 写在 dependencies 里,功能完全正常,但 pi 启动时报 warning。深挖源码发现 pi 的扩展加载器对 typebox 和 @earendil-works/pi-* 做 import 别名重定向------前提是这些包声明在 peerDependencies 且范围用 *。写在 dependencies 里扩展就会带着自己的 typebox 副本跑,双实例会导致类型注册表、instanceof 校验的诡异不一致。这种 bug 平时看不出来,属于埋雷型。
坑 2:npm optionalDependencies 的老 bug 还在。 @napi-rs/canvas 的平台二进制(darwin-arm64 等)是 optionalDependencies,npm 的 npm/cli#4828 会导致装完主包但平台二进制缺失,运行时报错。解法:删掉 node_modules 和 package-lock.json 重装,或显式安装对应平台包。
坑 3:JavaScript 正则的双重可选组。 解析 PDF 页码参数 "5-" 时,(\d*)? 这种"捕获组本身可选、内部量词也可匹配空"的写法,正则引擎会直接跳过捕获组,导致 match[2] 是 undefined 而不是空串------"5-" 被解析成了单页。去掉内层 ? 写成 (\d*) 就好了。
坑 4:npm 发布后的 packument 缓存。 发布成功(registry 返回 202)后立刻 npm install,拿到的还是旧版本元数据------registry 的 packument 有分钟级的 CDN/本地缓存延迟。急用就装精确版本号 npm install pkg@x.y.z,或等几分钟。
另外一个非技术但真实的坑:npm 账号开了通行密钥(passkey)2FA 的话,CLI 发布不存在输入验证码这回事------passkey 只在浏览器弹确认。解法是在网页上签一个 bypass-2FA 的 granular token 给 CLI 用。
官方包目录收录是自动的
发 npm 前我以为进 pi.dev/packages 需要提 issue 等维护者批准(pi 源码仓库对新贡献者确实有这个门禁)。查了文档才发现:包的 keywords 里带 pi-package 就会被官方 gallery 自动发现,没有人工审核环节。我的包发布当天就出现在目录里了。
总结
pi-doc-reader 现已支持 PDF / DOCX / XLSX / PPTX 四种格式,三平台 CI(ubuntu/macos/windows)全绿,MIT 协议,零 Python 依赖。
如果你在用 pi,一条命令就能让它读懂办公室里最常见的四种文件:
bash
pi install npm:pi-doc-reader
欢迎试用、提 issue、点 star: github.com/skyler-deve...
接下来计划的方向:PPTX 的 bullet 层级还原、真实扫描件的 OCR 精度调优。有需求欢迎在 issue 里提。