# @zhangj1164/dsh-memo

[English](README.md) | 中文

DSH 本地按周组织的个人备忘服务，支持 AI 分析、报告导出、遥测记录、GitHub issue 生成和已归档会话总结。

## 插件

`MemoService extends TypertRemoteService`，`static inject = ['storageDomain', 'telemetry', 'githubIssue']`。服务将条目存储在以 ISO-8601 周标识（`YYYY-Www`，周一起）为键的 KV 表中，使用 LLM 进行分析和报告导出。

| 配置 | 默认值 | 含义 |
|---|---|---|
| `repoUrl` | `https://github.com/zhangj1164/dsh-mega-plugins` | 传递给 github-issue 服务用于报告生成的 GitHub 仓库 URL。 |
| `provider` | 未设置 | AI 调用使用的已注册 DSH provider 路由。未设置时跟随本部署的 `agentDefaultModel` 选择。 |
| `model` | 未设置 | AI 调用的模型 id。未设置时跟随本部署的 `agentDefaultModel` 选择。 |
| `logAnalysisPlugins` | `['memo', 'github-issue']` | `analyzeLogs` 覆盖的插件 id，按读取顺序。遥测没有枚举插件的接口，因此由部署方给出集合；默认值是这些 bundle 一起安装的套件。 |
| `storageWriteRetries` | `4` | 持久写入因 `EPERM` / `EBUSY`（Windows 上 rename 覆盖被占用文件的竞态）失败后的额外尝试次数。`0` 表示关闭重试。 |
| `storageWriteRetryDelayMs` | `50` | 首次退避的毫秒数，每次重试翻倍，上限 5000 毫秒。只有瞬态失败才会等待。 |
| `sessionSummaryMaxSessions` | `5` | 单次 `summarizeSession` 最多接受的已归档会话数（1–20）。 |
| `sessionSummaryTranscriptMaxChars` | `24000` | 单个会话转写的字符预算（1000–200000）。保留首尾，中间以省略标记收束。 |
| `sessionSummaryLanguage` | `auto` | 输出语言。`auto` 跟随客户端传来的语言，缺省用中文；`zh` / `en` 强制使用一种语言。 |
| `sessionSummaryMaxChars` | `300` | 选择器默认提供的总结字数（100–4000）。 |
| `sessionSummaryMaxCharsLimit` | `2000` | 客户端可请求的字数上限（100–20000），宿主按它截断。 |
| `sessionSummarySourceTemplate` | `\n\n来源会话：{sessionId}` | 追加到每条生成总结末尾的文本。支持 `{sessionId}` 与 `{sessionTitle}` 占位符。 |
| `sessionSummaryPrompt` | 内置 | 会话总结使用的系统提示词。支持 `{language}` 与 `{maxChars}` 占位符。 |

每个字段都是 `.volatile()`，因此设置面板可以在服务持续运行时就地改写：`cordis-plugin-loader` 刷新易变引用而不是重新挂载插件，每次调用都通过该引用读取当前值。看板用户会改的字段——`repoUrl`、`provider`、`model` 以及六个 `sessionSummary*` 文案开关——可以在 DSH Web 里通过 [`@zhangj1164/dsh-client-ui-memo-settings`](../../client/ui-memo-settings/README.zh.md) 编辑，它写的就是本表描述的那个设置服务；部署级开关（`logAnalysisPlugins`、`storageWriteRetries`、`storageWriteRetryDelayMs`、`sessionSummaryMaxCharsLimit`）仍留在 `cordis.yml`。那个页面以这些字段真的带 volatile 为前提：从别的分支残留的宿主 `lib/` 一个表单都投影不出来，页面因此静默消失而不是报错，只有重建本包并重启 DSH Web 才会回来——构建与排查顺序见设置页 README 的「本地开发与排查」一节。

## 持久写入重试

`dsh-storage-json` 以「写临时文件 → rename 覆盖目标」发布单元。Windows 上，只要杀软、索引器或另一个读句柄仍持有目标文件，这次 rename 就会以 `EPERM` 失败，而 JSON 后端会直接报错、不重试。memo 对 `EPERM` / `EBUSY` 按指数退避重放同一次 `put` / `delete`，最多重试 `storageWriteRetries` 次，因此一次短暂的扫描窗口不会把保存变成误报；其他错误立即抛出。预算耗尽后错误仍会抛回调用方，真正的介质故障不会被报告成成功。

## 模型路由解析

AI 调用按以下顺序解析路由，没有任何硬编码：

1. 请求上显式的 `provider`/`model`（供测试和需要临时改路的调用方使用）。
2. 本服务的 `Config.provider` / `Config.model`。
3. 本部署 `agentDefaultModel` 服务的当前选择——"这个部署用哪个模型"已有的唯一事实来源。

解析不出路由时，调用以 `llm-failure` 且 `failureCode: 'NO_MODEL_ROUTE'` 失败，而不是静默地什么都不产出。

`listModels` 把第 2、3 层解析结果作为*当前*路由上报，随后给出**每一个已注册** provider 及其公布的模型，使调用方既能显示「本次会用哪个模型」，也能切到部署真实拥有的其他路由。插件仅声明、尚未激活的 dormant 路由不会出现：它们承载不了调用，列出来等于提供一个必然失败的选择。所有异常都在**成功**结果里上报：某个 provider 自己的目录抛错时只带它自己的 `error`，其余照常可用；只有整个注册表都读不出来时才设 `catalogError`——选择器失效不等于看板失效。注册表与目录都来自 `llm` 服务，因为 DSH 只把 `listProviders` 与 `listModels` 暴露给宿主；浏览器两者都无法枚举，也绝不能被塞一份硬编码清单。两者在 DSH 里都是建议性的：成员资格从不参与请求校验，所以未列出的模型 id 不等于被拒绝。

## 失败上报

`analyze`、`exportReport` 以及其它基于模型的方法会保留 DSH 的失败事实，而不是把它们统统折叠成一条消息。失败时的 `llm-failure` 携带：

| 字段 | 含义 |
|---|---|
| `failureCode` | DSH 的 provider 中立机器路由码：`NO_ADAPTER`、`MISSING_CREDENTIAL`、`AUTH`、`RATE_LIMIT`、`EMPTY_RESPONSE` 等。 |
| `message` | DSH 原始消息，前缀本次尝试的 provider 与 model。 |
| `provider` / `model` | 失败调用实际发往的路由。 |
| `status` | provider 返回的 HTTP 状态码（DSH 提供时）。 |

`EMPTY_RESPONSE` 表示模型确实没有产出文本；`NO_ADAPTER` 表示配置的 provider 在本部署中未注册。这两者以前无法区分。

失败背后的遥测事件把同一份路由写进 metadata，**成功**调用的事件同样如此：`analyze`、`exportReport`、`listModels`、`analyzeLogs` 成功时都会记录 `provider` 与 `model`。解析结果为空的路由记为空字符串，这样「早于本改动、根本没有 `provider` 键」的事件仍与「有键但解析为空」区分得开。只记失败能回答「哪条路由坏了」，却回答不了「成功那些调用走的是哪条路由、之后它变了吗」——而成功时路由本来就在作用域里。

`readExternalPath` 用同样的方式保留读取失败的原因，每个失败都带上尝试过的 `path`：`path-not-found`、`path-not-text`（目标不是可读文本文件）、`path-too-large`（被后端的大小上限拒绝）、`approval-denied`（权限或沙箱拒绝）、`fs-error`（其它一切），以及部署未挂 `fs` 服务时的 `fs-unavailable`。遥测记录的码与返回给调用方的码是同一个。此前它们全都是同一个 `approval-denied`：文件不存在、文件不是文本、与真正的拒绝，不解析消息就分不开。

## Remote 方法

每个方法都声明且只声明一个名为 `request` 的形参，即使它本身不携带任何输入。协议按**名字**绑定参数——客户端发送 `{ args: { request } }`——因此一个完全不声明形参的方法，其调用会在进入方法体之前就被拒绝。这不是外观差异：`listArchivedQuarters` 曾经不声明形参，宿主成功归档了季度，而客户端读回的结果始终为空，于是归档看起来什么也没做。现在 `tests/remote-signatures.spec.ts` 会让任何违反该规则的方法失败。

| 方法 | 行为 |
|---|---|
| `getOrCreateCurrentWeek(request)` | 创建或返回当前周。`provider`/`model` 为可选覆盖项。 |
| `getWeek(request)` | 按 id 返回一个周，不存在时返回 `null`。 |
| `listWeeks(request)` | 按范围列出周，最新的在前。 |
| `addEntry(request)` | 向一周添加条目。若该周不存在则创建。 |
| `updateEntry(request)` | 更新条目内容。编辑过去的周需要 `force: true`。 |
| `deleteEntry(request)` | 从一周删除条目。过去的周需要 `force: true`。 |
| `analyze(request)` | 对一段时间的条目运行 AI 分析（梳理/总结/分析）。该时段无条目时返回 `no-entries`，模型调用失败时返回 `llm-failure`。 |
| `exportReport(request)` | 使用模型导出一段时间的 Markdown 工作报告。 |
| `analyzeLogs(request)` | 读取所有已配置插件的遥测失败记录，通过 github-issue 服务生成**一份**覆盖全部插件的 GitHub issue 报告。会一并传递分析时间窗——各插件时间窗的并集——以及每组的路由、最近一次失败时间和之后的尝试次数，使报告能说明失败是否仍在发生。 |
| `listPeriods(request)` | 列出某一维度（周/月/季/年）可导航的周期，最新的在前，并给出每个周期包含的周 id。 |
| `listMemory(request)` | 列出长期记忆条目——每次成功的 `analyze` 追加到账本的结论——最新的在前。可选的 `period` 过滤与 `limit`（默认 50）；账本读不出来时返回 `ledger-error`。 |
| `archiveQuarter(request)` | 按标签归档一个季度。任何不符合 `YYYY-Qn` 的标签都会以 `invalid-quarter-label` 被拒绝。 |
| `unarchiveQuarter(request)` | 将某季度移出归档，并报告是否确实删除了记录。 |
| `listArchivedQuarters(request)` | 列出已归档的季度，最早的在前，每条附带宿主解析出的周 id。 |
| `listArchivedSessions(request)` | 列出 DSH 官方归档中的会话，最新的在前，附带标题和已总结过的周。子代理会话会被排除。 |
| `summarizeSession(request)` | 把请求的每个已归档会话分别总结并写入一个备忘周，输出语言与字数由请求指定。每个会话一条条目；`force: true` 会替换已有总结而不是重复添加。 |
| `listModels(request)` | 上报 AI 调用将使用的路由，随后给出每个已注册 provider 及其公布的模型。不会失败：某个 provider 读不出来时只带它自己的 `error`，整个注册表读不出来时返回空列表并附 `catalogError`。 |

上表列出调用方可以通过 Remote 面触及的方法。`readExternalPath` 故意不在其中：方法仍存在、仍返回文件文本，但没有 `@Remote` 注册就没有线上名字，任何请求都寻址不到它。

### turn 之外没有审批

本表的每个方法都在 turn 之间到达：面板是在点击之后调用它，而不是在某个 agent turn 开着的时候。`ctx.approval.request()` 要求 open turn 与 `agent`，它的 `approval/asked` + `approval/decided` 审计对是 turn 提交与重放的单位——因此 UI 发起的调用弹不出确认，那里也不存在真人确认。`readExternalPath` 留在宿主内部，既因为它没有传输层，也因为这个原因：敏感操作不能向一个没有 turn 的表面借审批。

不要用自建的一次性令牌或客户端对话框顶替审批。两者与被保护的方法处在同一个可寻址面上，都绕开部署已配置的 `ask` / `never` 策略，也都不产生审批审计；只有被保护代码自己认得的确认，什么都确认不了。确实需要用户同意的能力只有两种合规形态：留在宿主内部、不注册 `@Remote`，或包装成 turn 内的 tool、走 `ctx.approval`。正确的修法是收口暴露面，而不是在客户端重建同意。

该行为已对照 DSH `0.2.1-rc.2` 与 `0.2.1-rc.4` 核验；上游讨论与本仓库的跟踪单见 [#103](https://github.com/zhangj1164/dsh-mega-plugins/issues/103)。

### 一次日志分析覆盖什么

出一份报告，而不是每个插件一份：套件里的包是一起安装、一起坏、一起修的，而只覆盖 `memo` 的报告无法提到弄坏本面板 issue 编辑器的那个 `github-issue` 失败——那恰恰是没被看见的失败。总数跨插件相加，时间窗取各插件时间窗的并集，失败组合并后按失败次数排序；每组在 `featureCodeRef` 里已经带着自己的插件前缀，所以不同插件的组不会相撞。请求可以自带 `pluginIds`；部署方用 `Config.logAnalysisPlugins` 裁剪默认值。空集合会以 `no-plugins-configured` 被拒绝，而不是被当成「套件没有记录到任何东西」——「什么都没读」和「什么都没发生」不是同一个断言。

## 季度归档

归档一个季度是打一个持久标记，而不是搬移：备忘所在的周原地不动，一个已归档季度就是在它自己的存储 domain（`memo_archive`，表 `quarters`）里的一行 `{ label, archivedAt }`。没有任何内容被复制，因此取消归档不会丢内容，归档状态也不可能与备忘表不一致。

`listArchivedQuarters` 返回每个季度的周 id，由宿主用「决定一个季度拥有哪些周」的那同一套周期日历解析。客户端只需据此建一个 `Set` 就能知道哪些卡片只读——这正是归档能在**四个维度**同时生效、而客户端又永远不需要跨年把周映射到季度的原因。

这些周 id **刻意不落存储**。它们由标签确定性地推导而来，存下来就等于存一份可推导的副本：一旦日历规则被修正，这份副本就会出错，并在此刻静默地把卡片错误地解除归档或过度归档。

**请求只命名季度，绝不命名周期。** 「你正在看的周期所属的季度」没有唯一答案：一年包含四个季度；而某一周的周一可能落在上一个季度，这一周本身却属于包含其周四的那个季度。因此按周期的起始时间戳反推季度，会在周维度与年维度上都归档到错误的季度——所以归档动作只在季度标签无歧义处提供，其他任何地方都不做猜测。

该 domain 与 `memo` 分离，而不是在 `weeks` 旁加一张表，这样归档打不开也绝不会导致备忘表打不开。名字是 `memo_archive` 而非 `memo-archive`：存储 domain 名必须匹配 `/^[a-z][a-z0-9_]*$/`。

**已归档季度在宿主侧就是只读的。** `addEntry`、`updateEntry`、`deleteEntry` 对落在已归档季度内的周一律以失败码 `quarter-archived` 拒绝，且在任何表操作之前就拒绝，因此被拒的调用什么都不改变。只读之所以落实在这里而不只是浏览器里，是因为同一套 Remote 方法从任何客户端都可达，也因为归档可能在编辑弹窗已经打开之后才落地。

守卫询问的是 `weekIdBelongsToPeriod(weekId, 'quarter', label)`——也就是当初解析该季度周 id 时用的同一条周四规则，因此它的判断不可能与产出这些周 id 的日历发生漂移。代价是在一次用户发起的写入上遍历一遍归档行，而归档行至多只有几个季度。

一个值得说明的后果：归档**当前**季度会连今天一起关掉，因为「归档本季度」对进行中的季度是一个正当操作。客户端在该状态下禁用输入区并指出取消归档动作，因此出路始终一次点击可达。

## 会话总结

总结的数据来源是 DSH 自己的会话归档：`workspaceRegistry.archivedSessionIds` 决定哪些会话可选，`sessionQuery` 提供标题、头部信息和模型可见的会话面。两者都通过 `ctx.get()` 可选读取，因此没有这些服务的部署仍能启动 memo；此时选择器会返回 `session-service-unavailable` 并指出缺失的服务名，而不是显示一个空归档。

**每个会话一次模型调用、一条备忘条目。** 选中的会话先被转写成「用户 / 助手」文本（系统提示词、工具结果和工具合成的用户轮次都会被跳过），保留首尾并以省略标记收束到 `sessionSummaryTranscriptMaxChars`，再各自调用一次模型。结果作为一条文本条目追加到目标周，因此周视图读起来就是「每段对话各自产出了什么」，一个超长会话也不会挤掉其他会话。子代理会话不会出现在选择器中：它们是模型自己的委派，不是用户的工作。

**语言和字数跟随读者。** 浏览器随请求发送当前界面语言（`zh` 或 `en`）与选择器里的字数；宿主按 `sessionSummaryLanguage` 与 `[100, sessionSummaryMaxCharsLimit]` 窗口解析，再把 `{language}` 与 `{maxChars}` 代入 `sessionSummaryPrompt`。默认提示词据此要求简体中文或英文，并把篇幅控制在目标字数附近，因此中文界面不会再收到英文总结。部署强制 `zh` / `en` 时覆盖浏览器；自定义提示词省略某个占位符，就等于放弃该维度的控制。

**来源是记录下来的，不是猜出来的。** 每条生成的条目都带 `originSessionId`，`updateEntry` 会保留它；同一会话在同一周已有总结时，宿主以 `already-summarized` 拒绝第二次写入，除非传 `force: true`——那会替换该条目而不是新增一条。来源注记由宿主在模型返回之后按 `sessionSummarySourceTemplate` 渲染（支持 `{sessionId}` 与 `{sessionTitle}` 占位符）；模型看不到它，也无法伪造；如果放在浏览器里拼，其他客户端就看不到这段来源。

**目标周与失败处理。** 请求上的 `weekId` 优先，否则用宿主的当前 ISO 周；客户端发送的是输入区使用的同一个目标周，因此「我正在看的那一周」在看板上只有一个含义。单个会话的失败（日志读不出、转写为空、模型失败）只留在该会话自己的结果里，一个坏会话不会丢弃其他已经成功的总结。目标周落在已归档季度时，会在任何写入之前以 `quarter-archived` 拒绝。

## 四维度周期

备忘 UI 在同一批卡片上按周、月、季、年导航。这套日历计算由 `@zhangj1164/dsh-memo/period` 统一持有，宿主与 UI 因此不可能对"哪张卡片属于哪里"产生分歧。

周期标签是固定的：`2026-W36`、`2026-09`、`2026-Q3`、`2026`。

归属只有一条规则：**一周属于包含其周四的那个周期**——这同时也是"哪一年拥有这一周"的既定约定，所以 `2025-12-29` 是 `2026-W01`。由于每周恰有一个周四，各周期的周列表恰好平铺整条时间线：一年的十二个月与四个季度各自不重不漏地覆盖该年的所有周。`weekIdsInPeriod` 就是 `weekIdBelongsToPeriod` 的枚举，因此一张卡片不可能"在一个周期里被列出、在另一个周期里被高亮"。

旧的前缀判定里藏着两个缺陷，现已由测试覆盖：

- 季度归属沿用了与月份标签共享的 `YYYY-` 前缀，导致一年中每个季度都匹配到 `Q1`。
- 周标签取的是该周周一的日历年，导致跨年那一周整体错一年。

## 共享 LLM 文本助手

`@zhangj1164/dsh-memo/llm-text` 导出 `streamLlmText(llm, route, system, userText)`，这是把 DSH 流转换为"收集到的文本"或"保留的失败事实"的唯一位置。其它向模型索取单块文本的宿主插件应当使用它，而不是各自重写流循环：手写的副本正是丢掉 `chunk.reason.failure`、把所有失败报成一模一样的原因。

## 历史周强制门控

当前周始终可编辑。在过去的周中编辑或删除条目需要 `force: true`，以确认 AI 分析结果可能发生变化。这防止了在未经明确同意的情况下意外修改历史记录。

## Bundle 层与安装

此包声明 `dsh: { bundle: { patch: "./cordis.patch.yml" } }`。补丁只插入两行——`memo` 与 `ui-memo`——不插入其他任何行。本套件的每个组合条目 id 都只有一个归属 bundle：`telemetry` 属于 `@zhangj1164/dsh-telemetry`，`github-issue` 属于 `@zhangj1164/dsh-github-issue`，`memo` 与 `ui-memo` 属于本 bundle。由于没有两个 bundle 插入同一个 id，套件的任意子集都能组合，整套则用最自然的命令即可安装：

```sh
dsh plugin --profile web add @zhangj1164/dsh-telemetry @zhangj1164/dsh-github-issue @zhangj1164/dsh-memo @zhangj1164/dsh-client-ui-memo
```

四个包都必须点名。profile 设置了 `autoInstallPeers: false`（`${DSH_HOME}/profiles/<name>/pnpm-workspace.yaml`），因此安装只会引入命令中点名的包，永远不会自动带入 peer 依赖。其中三个正是本 bundle 各行得以解析所必需的：memo 服务通过 `inject` 依赖 `telemetry` 与 `githubIssue`，而补丁插入的 `ui-memo` 行指向 `@zhangj1164/dsh-client-ui-memo`。只装 `@zhangj1164/dsh-memo` 会让它们全部无法解析，加载器随之让启动失败——先是 `Cannot find package '@zhangj1164/dsh-github-issue' imported from …`，接着是同样的 `@zhangj1164/dsh-client-ui-memo`。把 `@zhangj1164/dsh-telemetry`、`@zhangj1164/dsh-github-issue`、`@zhangj1164/dsh-client-ui-memo` 与 `@zhangj1164/dsh-memo` 一起点名，套件才能加载。

本包此前自己插入 `github-issue` 行。这确实让套件可以用一条命令安装，但也让同一个条目 id 有了两个归属方，于是「把所有包都点名」——安装整套最显然的方式——变成了启动失败（`duplicate loader entry id: github-issue`），而不是合并。一个 id 只有一个归属方，正是子集可自由组合的前提，`pnpm run verify-bundle-entries` 会强制这一点。

### 从本仓库安装（不经过注册表）

当没有可用的注册表发布、或不想走注册表时，同样四个包可以直接从 monorepo 安装。pnpm 用属于 committish 的 `path:` 参数选中仓库内的包，也就是写在 `#` 之后：

```sh
dsh plugin --profile web add \
  "github:zhangj1164/dsh-mega-plugins#path:packages/telemetry/dsh-telemetry" \
  "github:zhangj1164/dsh-mega-plugins#path:packages/github-issue/dsh-github-issue" \
  "github:zhangj1164/dsh-mega-plugins#path:packages/memo/dsh-memo" \
  "github:zhangj1164/dsh-mega-plugins#path:packages/client/ui-memo"
```

git 安装取的是源码，因此本包的 `prepare` 脚本会在安装过程中构建出 `lib/`。pnpm ≥10 在 profile 授权之前拒绝执行它：首次 `add` 会以 `ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED` 失败；仓库根 README 同时记录了修复所需的、与 commit 无关的 `allowBuilds` 键，以及授权构建所伴随的锁定 commit 注意事项。

## 需求映射

- **需求 1** — `addEntry` 支持 `text`、`image` 和 `file` 条目类型；UI 面板记录文本条目。
- **需求 2** — `getOrCreateCurrentWeek` 自动创建或加载当前 ISO 周（周一开始，周日结束）作为存储点。
- **需求 3** — `updateEntry` 和 `deleteEntry` 强制 `past-week-requires-force`：编辑非本周的备忘需要 `force: true`，以确认 AI 分析结果可能变化。
- **需求 4** — `analyze` 和 `exportReport` 接受周期参数（`week` / `month` / `quarter` / `year`），并收集匹配周中的条目。
- **需求 5** — `readExternalPath` 通过 `fs` 服务读取本地文件路径并返回文本。它是宿主内部方法：没有 `@Remote` 注册，因此没有端点，仓库里也没有调用点，不需要构建同意对话框。方法留在服务上，作为后续传输层可以接的宿主侧实现；在它出现之前，没有任何东西能调用这个方法。
- **需求 6** — `exportReport` 生成标准工作报告（Markdown），可直接审查并导出为 `.md`。
- **需求 7** — 每个关键操作都调用 `ctx.telemetry.track()` / `ctx.telemetry.trackError()`，携带 `featureCodeRef` 锚点。
- **需求 8** — `analyzeLogs` 通过 `telemetry` 服务读取遥测失败记录，通过 `githubIssue` 生成 GitHub issue 报告，并返回报告 + 预填 URL。
- **需求 12** — 依赖 `@zhangj1164/dsh-telemetry` 和 `@zhangj1164/dsh-github-issue` 作为独立的通用插件，两者各自带有自己的 bundle。三个包必须一起安装：注入的服务来自这些 bundle，而 profile 不会自动安装 peer 依赖。

## 已知限制

- **仅按周组织** — 条目按 ISO 周组织；没有自由格式的日期或标签系统。
- **模型路由属于部署配置** — 解析不出路由、或配置的路由未注册时，`analyze` 和 `exportReport` 以 `llm-failure` 加 `failureCode` 失败。请在此处设置 `provider`/`model`，或依赖 `agentDefaultModel`。
- **遥测和 github-issue 为注入服务** — memo 服务通过 `inject` 依赖它们的可用性。
- **面板无法导入文件** — `readExternalPath` 是宿主内部方法且没有调用点，所以现在的看板还导入不了文件。
