# 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 命中。