# AGENTS.md — 给后续 agent 的开发手册 > 这份文件是本插件的**第一入口**。先读它,再动代码。 > 里面每一条都是**踩过的坑**,不是理论。 ## 0. 这是什么 `dsh-paperforge`:把 DSH 变成一个「论文阅读工作台」。用户在工作区里打开 PDF 后,可以: | 能力 | 入口 | 实现要点 | | ----------------- | ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 论文库 + 阅读模式 | 左侧栏「论文」 | `sidebar.panellist` 图标 + `main` 全局面板;点论文 → `uiWorkspace.openWorkspace` → `sidebarRight.openResource` → `toggleFullscreen(commandTarget())` | | 笔记旁栏 | PDF 页头「笔记」 | `/api/paper.note` → `openResource(笔记地址, { paneId: 另一栏 \| preferNewPane })`;DSH 原生 Markdown 预览 + 文件监听自动刷新 | | 划词高亮(六色) | 拖选 → 高亮 → 选色 | 逐行合并矩形 + 界面叠加;Node 写笔记(`lib/notes.js`),再记 `highlights.json` | | 论文专属对话 | PDF 页头「AI」/ 划词问 AI | 右侧栏标签`paperchat` 嵌入普通会话;`sessions.create({ workspaceId })`;`insertReference` 预置 `@论文` | | 框选截图 | 页头「截图」→ 拖矩形 | 从已渲染画布裁像素,落盘到`assets/`,写入笔记 | | 高亮持久化 | 自动 | `highlights.json` + `MutationObserver` 在页面渲染时补画 | ## 1. 格式:**不是纯 Node**,是三段式 ``` dsh-paperforge/ ← 本仓库根目录(standalone,可 clone 到任何位置) ├─ package.json # name=dsh-paperforge, dsh.client.platform=web, dsh.bundle.patch, exports["./client"] ├─ pixi.toml # 插件专属开发环境(python + pymupdf)+ 任务 ├─ lib/index.js # ① 宿主半:Node ESM(Cordis 插件),注册 HTTP 路由 ├─ lib/notes.js # 笔记格式 / 按章节插入 / 旧格式迁移(纯 Node,单元测试覆盖) ├─ lib/library.js # 论文库扫描(纯 Node,单元测试覆盖) ├─ lib/merge.js # 行级三方合并(Markdown 编辑器保存用,单元测试覆盖) ├─ lib/markdown-file.js # 编辑器保存:路径守卫 + 直写 / 合并 / 冲突;新建 Summary md(单元测试覆盖) ├─ lib/assets.js # 清理笔记不再引用的图片(宽限期 2 分钟,单元测试覆盖) ├─ docs/images/{zh,en}/ # README 截图(不随包分发) ├─ test/ # node --test 单元测试 ├─ lib/client.js # ② 客户端半:浏览器 bundle(DSH 自己拼接,非打包器产物) ├─ py/paper_clip.py # ③ Python 边车:真正写笔记/截图/高亮(**随包分发,自包含**) ├─ cordis.patch.yml # bundle 补丁层:dsh plugin add 后自动插入 paperforge 行 ├─ tools/doctor.py # ④ 只读环境自检(Python/PyMuPDF、边车、profile 是否启用) ├─ verify.mjs # ⑤ 契约与冒烟测试(真实 import、真跑 handler、校验客户端 bundle 契约、脱敏自检) ├─ AGENTS.md # ⑥ 本文件 └─ README.md / LICENSE / .gitignore ``` 四者通过 HTTP(宿主路由)和 CLI(宿主 → Python)连接。 ### ① 宿主半 `lib/index.js`(Node ESM) - 必须是真 ESM:`import`/`export`,`export const inject = ["connection"]`(需要 HTTP 路由注册表)。 - `apply(ctx, config)` 里用 `ctx.connection.fetch.register({ path, methods:["POST"], requestBody:"buffered", fetch })` 注册路由。 - 当前 10 条路由:`paper.note` / `paper.md.save` / `paper.summary.create` / `paper.clip` / `paper.highlights` / `paper.library` / `paper.session` / `paper.log` / `paper.diag` / `paper.health`。 - 可选依赖:Cordis 的 `inject` 里写的都是**必需**服务。要「有就用」,用 `ctx.inject(["workspaceController"], (sub) => …)` 起子插件(服务不存在时不运行)。编辑器保存就是这样订阅 DSH 工作区列表的。 - **改动这一半必须重启 `dsh web`**(`dsh-hmr` 在本 profile 是关闭的)。 ### ② 客户端半 `lib/client.js`(浏览器 bundle) 硬约束(写错就不加载): - 必须 `window.__ModuleLoader__.load({ id: "dsh-paperforge", factory: (require) => {...} })`,**id 必须精确等于包名**; - 必须是**可拼接的普通脚本**:**不能有顶层 `import`/`export`**; - `require()` **只能**用 9 个基线键:`react`、`react/jsx-runtime`、`react-dom`、`react-dom/client`、`@deepseek-ai/cordis`、`@deepseek-ai/dsh-client-store`、`@deepseek-ai/dsh-client-ui-slots`、`@deepseek-ai/dsh-client-ui-primitives`、`@deepseek-ai/dsh-client-ui-dockkit`; - 导出 `exports.apply` / `exports.inject`。 - 槽位注册:`ctx.slots.inject("sidebar.right.tab.document.actions", () => ctx.slots.register({ name: SLOT, id: "paper" }, HeaderActions))` —— **`id` 不能漏**,否则 list 槽位不渲染。 - ⚠️ **客户端改动需要重启 `dsh web` 才生效**:服务器会发编译缓存,热替换不总生效。 ## 2. 🔴 六个致命坑(都真实发生过) 1. **`node --check` 抓不到 import 错误**。 曾写成 `import { appendFileSync, execFile } from "node:child_process"`(`appendFileSync` 属于 `node:fs`)→ ESM 链接期报错 → **整个宿主半不加载** → 全部路由消失。 ✅ **必须**用真实导入测试(`verify.mjs` 第一项就是它)。 2. **引用了未导入的标识符** → 运行时 `ReferenceError`,`node --check` 同样抓不到。 ✅ `verify.mjs` 的 smoke 测试会**真跑每个 handler**,这类错误会被抓住。 3. **访问未注入的 Cordis 服务会直接抛错**(不是返回 undefined)。 曾访问 `ctx.remote`(不在 `inject` 里)→ 抛错;而**异步函数里的同步抛错会变成 rejected promise 被静默吞掉**,界面无任何反应。 ✅ 服务访问一律包 `try/catch` 并记录;`inject` 里必须有该服务。 4. **`Object.keys(ctx)` 不能用来列举服务**(服务是原型/不可枚举属性,实测只返回 `["fiber"]`)。 5. **模块级变量名不要和 handler 里的局部变量重名**。把 `CLIP_SCRIPT` 改成模块级 `paths` 后,`handleClip` 里的局部 `const paths = paperPaths(...)` **遮蔽**了它 → `paths.clipScript` 变 `undefined` → Python 命令静默失效。已改名 `runtime` 规避。`verify.mjs` 的 smoke 测试当场抓住了 `paths is not defined`。 6. **不要用 shell 的行号切片改这两个文件**。曾用 PowerShell `$L[0..649-1]` 把 client.js 切成 188 行碎片(数组减法报错导致写入残缺)。 ✅ 用编辑器的**唯一文本匹配**替换;改之前先 commit,出错可随时回退。 ## 3. 关键 API(客户端,均在 DSH 0.1.7-rc.2 源码与安装包中核实) ```js // 论文专属对话:先例 ui-subagent/src/client/sidebar-chat/index.tsx ctx.resources.register({ protocol: "paperchat", async *open(address, { signal }) { const reference = ctx.sessions.retain(sessionId, { source: "paperforgeChat", signal }); // 标签打开期间持有会话 yield { ok: true, value: { sessionId, reference } }; /* 等 abort 后 */ reference.release(); } }); ctx.sidebarRightTabs.register({ id, kind: "paperchat", patterns: ["dsh-resource://paperchat/session/**"], canOpen, title }); // 标签体:{renderSlot("paperforge.chat.conversation")} // 子槽位:renderFactorySlot("conversation.content", { variant: "embedded", phase, hero }, { slots: { views } }) const id = await ctx.sessions.create({ workspaceId }); // 归入工作区(只传 cwd 会落到「未分组」;二者不能同传) ctx.sidebarRight.openResource(`dsh-resource://paperchat/session/${id}`, { preferNewPane: true, revealIfOpened: true }); const input = ctx.conversation.input.for(ctx.sessions.scope(id)); input.insertReference({ source: "reference", ref: '@"论文.pdf"', label, appearance: "file", clipboardText }, { start: 0, end: 0, draftRev }); input.focus(); // 否则用户容易点到标签本身(点标签会打开该文件) await ctx.sessions.binding(id).session.prompt([{ type: "text", text }], "queue"); ``` 补充事实: - `session.open()` **不负责显示**(只加载历史);主区显示会话用 `uiWorkspace.openSession / openWorkspace`。 - `remote.session.selectModel` 会把选择**存为全局默认模型**(`agentDefaultModel.saveSelection`),所以插件只在 config 显式配置 `model` 时调用。 - 会话在首轮对话开始前 `blank=true`,DSH「新建会话」会复用工作区里的空白会话;论文对话因此在创建时就命名为「AI · 论文名」,未提问就关闭标签时解除绑定。 - 文件地址:`dsh-resource://file/session//<逐段 encodeURIComponent 的路径>`(绝对路径保留开头 `/`,即 `//home/...`)。 - `sidebar.right.tab.document.actions` 挂在**所有**文档预览上:页头按钮只对 `.pdf` 渲染。 - DSH 的 Markdown 预览里本地文件链接**不可点击**(没有文件委托);笔记用「原文:文件名」这种读起来自然的写法。 ### 阅读布局与会话(原生多栏,2026-09-29 起) ```js ctx.uiWorkspace.openSession(sessionId); // 主区显示本篇论文的会话(左侧会话列表保持可见) // 右侧栏布局按会话保存:等 ctx.sidebarRight.mounted 变成该会话,再 openResource(PDF)、openBeside(笔记) const ref = ctx.sessions.retain(sessionId, { source: "paperforgeChat" }); const { session } = await ref.ready; // 新建的会话必须先 retain,否则 binding 拿不到 await session.rename(title); await session.prompt(content, "queue"); ref.release(); ``` - 阅读模式不再用 `toggleFullscreen`:全屏会盖住左侧会话栏和主区。右侧栏**写死最多两栏**(DSH 限制),多的窗口只能浮动。 - 新会话的右侧栏里 DSH 会先放一个「开始」引导标签(`layout.tabs[id].kind === "guide"`);第一次打开 PDF 时用 `openResource(address, { replaceTab })` 顶替它。 - **坑**:刚 `sessions.create` 的会话没有任何界面持有时,`sessions.binding(id).session` 取不到,直接改名会被静默跳过,DSH 随后用自动标题覆盖。一律先 `retain` 再操作(`withSession`)。 - 客户端会话摘要(`sessions.list.byId`)的 id 字段是 **`id`**,不是 `sessionId`;标题用 `title`,缺省时用 `displayTitle`;`blank` 为真时 DSH 列表显示「新会话」。工作区与会话的归属用 `workspaces.list`(`items[].sessionIds`、`archivedSessionIds`)。 - `@会话` 引用写成 `@[标题](dsh-session:)`,标题里的 `\` 与 `]` 要转义;每条消息**最多 3 个**(`dsh-session-reference` 的硬上限),所以 Summary 按每 3 个一批发送。 - 旧的「右侧栏对话标签」(`paperchat`)不再主动打开;标签类型仍注册,已保存的布局里的旧标签照常显示。 ### Markdown 块级编辑(DSH 的文档预览只读,插件自己注册渲染器) - **图片清理**(`lib/assets.js`):打开笔记、编辑器保存笔记目录里的文件时,扫 `/*/assets/`。引用按笔记根目录下**所有** `.md`(不含 `.md.bak`)计算,AI 写的图解笔记、Summary 里用到的图不会误删。没人引用的图先记时刻,超过 2 分钟仍没人引用才删(防「剪切 → 粘贴」中间那次自动保存)。 ```js ctx.documentPreviews.register({ id: "paperforge.md-editor", extensions: ["md", "markdown"], priority: "extension", title: () => t("editorTitle"), loading: "renderer", wrap: false }); // extension 级排在 DSH 自带的只读预览前 → .md 默认用它 ctx.slots.inject("sidebar.right.tab.document", () => ctx.slots.register({ name: "sidebar.right.tab.document", key: "paperforge.md-editor", locale: NS }, MarkdownEditor)); // body 收到 { content: { kind: "renderer", revision, loaded(version), failed(), reload() }, resourceAddress, … } const page = await ctx.remote.workspaceFiles.read(sessionId, path, { offset }, signal); // 分页;offset 从 1 起,下一页 = offset + lines ``` - **必须上报 DSH 自己的版本号**:DSH 拿文件元数据的 `version` 与 `content.loaded(version)` 比较决定自动刷新。报别的(比如自己算的哈希)会让刷新无限循环。所以读取走 `remote.workspaceFiles.read`(需显式注入 `remote.workspaceFiles`),写入走宿主 `/api/paper.md.save`。 - 自己保存也会触发一次刷新:读到的若是自己更早一次写入的旧版本要忽略(`recent` 列表),否则界面会闪回。 - 每块单独用 `MarkdownText` 渲染,块间距要按 DSH 的节奏自己补(段落 16px、h1–h3 上方 32px);容器要设 `white-space: normal`(预览容器是面向源码的,会继承 `pre`)。 - 保存带共同祖先 `base`:磁盘没变就直写;变了就三方合并(互不重叠的改动都保留,同位置插入两边都留);只有同一处被两边改才返回 409,界面给「保留我的 / 用文件里的」。 - dockkit 的 `findTabPane(layout, tabId)` 返回**栏对象**(取 `.id`);`active()` 的标签记录没有 `paneId`,全屏要用 `commandTarget()`。 - 推理等级合法值**只有** `off | low | high | max`。 ## 4. 调试工作流(先打通观察通道,再改代码) **这一步是本插件开发中最大的教训**:早期靠"改了—让用户试—截图"循环,浪费了大量往返。 | 通道 | 位置 | 用途 | | -------- | -------------------------------------- | ------------------------------------------------------------- | | 操作日志 | `notes/.paper-log`(JSONL) | 结构化事件流 | | 自检端点 | `POST /api/paper.health` | 一次请求看全:版本、路由、模型配置、Python 是否存在、最近日志 | | 会话落盘 | `~/.dsh/sessions/----/` | 文件时间 = 客户端 RPC 是否真的落地 | | 验证套件 | `pixi run test`、`pixi run verify` | 单元测试 + 契约/冒烟测试(工作区默认落临时目录,零污染) | **改客户端的标准流程**:改代码 → `pixi run test && pixi run verify` → 重启 `dsh web` + 硬刷新 → 在沙箱 `DSH_HOME` 里端到端验证。 ## 5. 配置 插件以 bundle 身份安装后,`paperforge` 这一行已由本包的 `cordis.patch.yml` 插入。 改配置就在 `$DSH_HOME/profiles/web/cordis.patch.yml` 里**按 id 覆盖**(实测带不带 `name` 都行): ```yaml - id: paperforge config: workspaceRoot: <你的工作区绝对路径> # 可选,缺省=启动 dsh 的目录 notesDir: paperforge_notes # 可选,笔记根目录名(2026-09-29 起默认 paperforge_notes,旧版是 notes) python: # 可选,只有「把高亮写进 PDF」(writePdf)用到 model: # 可选;不写则不调用 selectModel,沿用用户当前模型 provider: deepseek-official name: deepseek-flash reasoningEffort: high # off | low | high | max ``` - ⚠️ **不要再写 `- insert:`**:会插入第二个同 id 的行(`dsh --dump-config` 实测),插件被加载两次。 - ⚠️ `selectModel` 会在后台把选择存为全局默认模型(`dsh-api-session-controller` 的 `agentDefaultModel.saveSelection`),所以 `model` 必须保持可选。 宿主 `apply(ctx, config)` 读取后经 `/api/paper.session` 下发给客户端。 `writePdf` 用的 Python 解释器按顺序取**第一个存在的**:`config.python` → `$DSH_PAPER_PYTHON` → `<插件目录>/.pixi/envs/default` → `<工作区>/.pixi/envs/default` → `python3` → `python`。被选中的解释器必须装有 PyMuPDF(不会跳过缺库的);`pixi run doctor` 查看实际会用哪个。 改 YAML 前先备份,改完用 `dsh --profile web --dump-config` 确认组合结果。 ## 6. 安装 / 卸载 只用 DSH 原生命令(本包声明了 `dsh.bundle.patch`,装入即启用;未声明时 `dsh plugin add` 只会装成普通依赖、不激活): ```bash pixi install # 建插件专属环境(python + pymupdf) pixi run doctor # 环境自检 dsh plugin --profile web add . # 开发:以 link: 软链装入 web profile,改代码重启 dsh web 即生效 dsh plugin --profile web remove dsh-paperforge ``` 用户安装用 `dsh plugin --profile web add github:PhysicalAI-0/dsh-paperforge`(见 README)。 不想动真实 profile 时,可设 `DSH_HOME=<临时目录>` 在沙箱 profile 里试装。 ## 7. 已知未完成 / 计划 ### 7.1 统一为 JavaScript:用 `mupdf` 替换 Python 边车(**先实测通过,再改代码**) **现状(2026-09-29 实测)** - 界面(`client.js`)与宿主(`index.js` / `notes.js` / `library.js`)都是 JS,也只能是 JS:DSH 只加载 JS 前端插件,宿主是跑在 DSH Node 进程里的 Cordis 插件;DSH 本身没有 Rust。 - Python 只剩「把高亮写进 PDF」(`writePdf`,界面无入口)在用:`py/paper_clip.py` 425 行里被调用的只有 `add_pdf_highlight` 约 30 行,其余是早期 Zotero 工作流的遗留。 - 代价:插件专属 pixi 环境约 1 GB / 8000+ 文件;每次调用新起进程约 0.3–0.4 s;两种语言、两套依赖。 - 不考虑 Rust:插件没有 CPU 瓶颈(写一条笔记 < 1 ms;PDF 渲染在浏览器;耗时在模型);原生二进制要按平台分别编译,会破坏 `dsh plugin add github:…` 一条命令安装。 **决定**:PDF 引擎用 npm 包 `mupdf`(Artifex 官方 WASM 版,与现用 PyMuPDF 同一引擎、同为 1.28.x,无需原生编译)。许可证 AGPL-3.0-or-later,与现状相同,README 需注明。后续「按页读论文 / 读图表」工具(按页抽文本、区域渲染为 PNG)也用它。 **验证门槛**:以下全部实测通过才改代码;任一项不过,保留 Python 边车,另议方案(`pdfjs-dist` 抽文本 + 浏览器画布出图)。 1. **安装**:作为宿主依赖,在全新 `DSH_HOME` 里经 `dsh plugin --profile web add`(GitHub 与本地 link 两种方式)装上,无需编译;DSH 所用 Node 版本下能 `import`。 2. **在 DSH 进程里可用**:宿主 `apply()` 中加载 WASM 不报错、不影响其他插件;记录首次加载耗时与内存增量。 3. **写高亮与现状等价**:多行选区按逐行矩形写成一条高亮注释;首次修改前备份 `<名>.dsh-orig.pdf`;写后分别用 `mupdf` 与 PyMuPDF 读回,注释数量、颜色、矩形一致;DSH 预览里可见。 4. **为后续功能铺路**:按页抽取文本(带页码);指定页与区域按 DPI 渲染为 PNG,尺寸与 PyMuPDF 输出一致、内容肉眼一致。 5. **性能**:记录单次写高亮、单页抽文本、单个区域渲染的耗时;超过约 50 ms 的操作放进 `worker_threads`,不阻塞 DSH 事件循环。 6. **测试不依赖第三方 PDF**:单元测试用 `mupdf` 现场生成 PDF,仓库里不放论文文件。 **通过后的迁移步骤**(在一个分支内完成,E2E 全部通过再合并) 1. 新增宿主依赖 `mupdf` 与 `lib/pdf.js`(写高亮 / 抽文本 / 渲染),配单元测试。 2. `handleClip` 的 `writePdf` 改走 `lib/pdf.js`。 3. 删除 `py/`、`tools/doctor.py` 中的 Python 检查、`resolvePython` / `runPython`、`config.python`、`DSH_PAPER_PYTHON` / `DSH_PAPER_CLIP_SCRIPT`、`pixi.toml` 里的 python / pymupdf,以及 README「把高亮写进 PDF(可选)」中的 Python 说明。 4. pixi 只剩任务入口后二选一:保留 pixi 并改为锁定 Node 版本,或全部改用 `npm test` / `npm run verify`(届时再定)。 ### 7.2 论文库扫描改为异步(不阻塞 DSH) - 现状:`scanLibrary` 用同步 `readdirSync`。在一个含大型源码仓库(深度 4 内约 700 个目录)、位于网络文件系统的工作区里实测 **411 ms**,期间 DSH 服务端的所有请求与流式输出都停住。与语言无关,换 Rust 也要读同样多的目录。 - 做法:改用 `fs.promises.opendir` 异步遍历;按根目录缓存结果,论文库的「刷新」按钮强制重扫。 - 验收:扫描期间 DSH 其他请求照常响应;结果与现在一致(沿用 `test/library.test.mjs`)。 ### 7.3 Markdown 编辑体验升级(二期,初版块级编辑已上线) 初版的问题(用户验收反馈,2026-09-29): 1. **不够实时**:编辑时整块换成一个显示原始 Markdown 的文本框,不如 Vditor「即时渲染」顺手。目标是光标所在处才露出语法,其余内容边打边成形。 2. **图片**:点击图片应放大查看(灯箱),而不是进入源码。编辑图片改走单独入口,例如悬停时出现「编辑」按钮。 3. **拖拽排序**:像 Vditor / Notion 一样,块左侧有拖柄,拖动调整位置。 4. **富渲染**:用 Vditor `method.min.js`(51 KB,MIT)的渲染方法,按代码块语言渲染 mermaid(流程图 / 时序图 / 甘特图)、echarts 图表、脑图、graphviz、abc 五线谱。数学公式与代码高亮 DSH 的 `MarkdownText` 已支持。入口已留在 `BlockView`。 路线:第 1 条基本要求引入真正的编辑器(Vditor 的即时渲染模式最接近)。做之前先实测: - 插件要加打包流程; - 依赖资源必须本地托管(Vditor 默认运行时从 unpkg 下载); - 保存时是否整份改写格式:拿本插件的笔记格式做往返测试(remark 往返实测会改列表符号、表格对齐、转义方括号); - 与 PaperForge 并发追加的合并是否仍然成立; - 明暗主题。 第 2、3 条在现有块级编辑上就能做(图片灯箱、块拖柄只重排块的原文,不改写内容)。第 4 条的门槛是:库本地托管,PlantUML 默认关闭(它会把图的内容发到 plantuml.com),体积与首屏耗时实测后再合入。 ### 7.4 其他 - **读图 skill(计划中)**:写一个 skill 引导 Agent 高效读取论文页面与图表——只在问题涉及图 / 表 / 公式时才读图;渲染**局部高分辨率**截图后用 `read_image`;不下载源码、不安装依赖。前提:所用模型在 DSH 中声明了图片输入(DSH 默认的 deepseek-flash 已声明)。 - 问答要点自动回写笔记(目前问答只在专属对话里)。 - 界面上开关「写进 PDF 文件」(宿主已支持 `writePdf`;建议在 7.1 完成后再做)。 - 划词弹窗快捷键;多篇论文的会话总览视图。 - 同名 PDF(不同目录)目前共用一个笔记目录:笔记身份应改为基于相对路径。 ## 8. 提交前必做 ```bash pixi run test # 单元测试,必须全部通过 pixi run verify # 契约与冒烟测试,必须全部通过(等价于 node verify.mjs) node --check lib/client.js node -e "await import(require('node:url').pathToFileURL('lib/index.js').href)" # 宿主半真实导入 ``` 改完在 `Memory.md` 追加一条记录(本项目的共享记忆约定)。 ## 9. 脱敏规范(本插件要求可原样分发) **代码里不允许出现任何用户名、盘符、机器专属路径。** 全部在运行时推导: | 用途 | 优先级 | | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 工作区根 | `config.workspaceRoot` → `$DSH_PAPER_WORKSPACE` → `process.cwd()` | | Python 解释器 | `config.python` → `$DSH_PAPER_PYTHON` → **`/.pixi/envs/default` 里的 python**(`pixi install` 就地建)→ `/.pixi/...` → `python3` → `python` | | 截图/笔记脚本 | `config.clipScript` → `$DSH_PAPER_CLIP_SCRIPT` → **`/py/paper_clip.py`** → `/tools/paper_clip.py` | | 笔记根目录 | `config.notesDir` → 默认 `notes` | - 宿主把这些解析结果放在模块级 `runtime` 对象里(**不要叫 `paths`**,见坑 #5)。 - 客户端**不持有任何绝对路径**:`cwd` 由 `/api/paper.session` 下发的 `config.workspaceRoot` 提供; 诊断走 `/api/paper.diag`(服务端自己拼路径)。 - `verify.mjs` 从 `import.meta.url` 推导目录,不写死路径。 - 提交前自检:在本仓库下搜 `<你的用户名>` / `C:\Users` / 盘符,应为 0 命中。