docs(project-resources): document local_directory resource type

Add user-facing guidance for the local_directory project resource introduced
in #3283 — when to pick it over github_repo, the Desktop / CLI attach flow,
path validation rules, the daemon-scoped one-per-(project, daemon) limit,
serial task execution + waiting_local_directory status, what the daemon will
and won't touch in the user's folder, and the v1 limits to call out
(no auto branch switch / commit / PR; dirty tree carried through).

Also ship the missing Chinese counterpart of project-resources and wire it
into meta.zh.json.

MUL-2618

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
J
2026-05-27 14:04:58 +08:00
parent d15da372a9
commit e5684eec09
3 changed files with 359 additions and 6 deletions

View File

@@ -11,6 +11,7 @@
"issues",
"projects",
"comments",
"project-resources",
"---智能体---",
"agents",
"agents-create",

View File

@@ -1,25 +1,27 @@
---
title: Project Resources
description: Attach typed pointers (Git repos today, more later) to a project so agents can pick them up as scoped context.
description: Attach typed pointers (Git repos, local directories, more later) to a project so agents can pick them up as scoped context.
---
A **Project Resource** is a typed pointer — a Git repo URL today, a Notion page or document link tomorrow — attached to a [project](/workspaces). When an [agent](/agents) runs against an issue inside that project, the daemon automatically writes the project's resource list into the agent's working directory and into its [meta-skill](/skills) prompt.
A **Project Resource** is a typed pointer — a Git repo URL, a path on your own machine, a Notion page tomorrow — attached to a [project](/workspaces). When an [agent](/agents) runs against an issue inside that project, the daemon automatically writes the project's resource list into the agent's working directory and into its [meta-skill](/skills) prompt.
The result: the agent knows which repo to check out, which docs are the "primary references" for this project, without anyone copy-pasting context into the issue body.
The result: the agent knows which repo to check out (or which local directory to work in), and which docs are the "primary references" for this project, without anyone copy-pasting context into the issue body.
## Mental model
A project is no longer just a label. It is a small **resource container**:
- A project has 0..N **resources**.
- A resource has a `resource_type` (e.g. `github_repo`) and a `resource_ref` (a JSON payload typed by `resource_type`).
- A resource has a `resource_type` (e.g. `github_repo`, `local_directory`) and a `resource_ref` (a JSON payload typed by `resource_type`).
- New resource types add a string + a handler. **No schema migration. No frontend rewrite.**
This shape is intentional — it's the same pattern Multica already uses for agent providers: a `type` discriminator and a typed payload. It keeps the schema stable so adding "Notion page", "Google Doc", "uploaded file", or "external URL" later is a small, additive change.
## Today: `github_repo`
Today two resource types ship: [`github_repo`](#resource-type-github_repo) (clone-per-task into an isolated worktree) and [`local_directory`](#resource-type-local_directory) (run directly inside a folder on a specific daemon's machine).
The first resource type ships ready to use:
## Resource type: `github_repo`
The default resource type — checked out per task into an isolated worktree:
```json
{
@@ -33,6 +35,108 @@ The first resource type ships ready to use:
`default_branch_hint` is optional — if present, the daemon surfaces it in the meta-skill so the agent knows which branch to base its work on.
## Resource type: `local_directory`
For repos that can't reasonably be re-cloned per task — multi-gigabyte game checkouts, large monorepos, or any project where the worktree-per-task model is painful — a project can instead point at an **existing directory on a specific [daemon](/daemon-runtimes)'s machine**. The agent runs **directly inside that folder**, with no clone, no copy, and no worktree.
```json
{
"resource_type": "local_directory",
"resource_ref": {
"local_path": "/Users/me/code/big-game",
"daemon_id": "0001234e-…",
"label": "main checkout"
}
}
```
The trade-off vs. `github_repo` is intentional: only the bound daemon can pick up tasks against the directory, and tasks on the same directory run **serially** instead of in parallel. In exchange you keep your existing checkout, your existing branch, your existing dirty state — Multica never re-clones it.
### When to pick `local_directory` over `github_repo`
| Concern | `github_repo` (worktree) | `local_directory` |
| --- | --- | --- |
| Checkout cost per task | Fresh clone + worktree | None — agent runs in place |
| Concurrency on the same repo | Many tasks in parallel | One at a time per directory |
| Branch / dirty state | Each task gets a fresh branch from the default | Whatever the directory currently has |
| Where it can run | Any daemon | Exactly one daemon (the one bound) |
| Disk footprint | One worktree per task | Zero overhead — your existing folder |
Pick `local_directory` when re-cloning is the expensive part. Stay on `github_repo` when you want multiple agents working in parallel.
### Attaching a local directory
The folder picker lives in the **Desktop app** only — the web app has no way to read OS paths, so the "Add local directory" button is hidden there. On Desktop:
1. Open the project → **Resources** panel.
2. Click **Add local directory**. A native folder picker opens.
3. Pick the folder. The path is bound to **the daemon currently registered by this Desktop install** — the resource record stores both the path and that daemon's ID.
The button is also hidden (with a hint) when the daemon on this machine is offline, or when the project already has a `local_directory` bound to this daemon. To bind another machine's directory, install Desktop on that machine and add the resource from there.
From the CLI (works on web-only environments too, as long as you supply the daemon ID yourself):
```bash
multica project resource add <project-id> \
--type local_directory \
--local-path /Users/me/code/big-game \
--daemon-id <daemon-uuid> \
--ref-label "main checkout" # optional
multica project resource update <project-id> <resource-id> \
--local-path /Users/me/code/big-game-new
```
`--daemon-id` comes from `multica daemon list`. The CLI also accepts the generic `--ref '<json>'` escape hatch if you'd rather pass the payload directly.
### Path rules
The path you attach must clear both an attach-time and a per-task validation. Both are enforced by the daemon that owns the resource — the server only stores the JSON. A path that breaks any rule fails the task with a typed error and leaves your directory untouched:
- Must be **absolute**.
- Must **exist** and be a **directory** (not a file, symlink to a file, or device node).
- Must be **readable and writable** by the daemon process.
- Cannot be a system root or an entire user profile — `/`, `/Users`, `/home`, `/root`, `/etc`, `/tmp`, `/var`, `/usr`, `/opt`, `/Users/Shared`, your own `$HOME`, any Windows drive root (`C:\`, `D:\`, …), or `C:\Users` / `C:\ProgramData` / `C:\Program Files` / `C:\Program Files (x86)` / `C:\Windows`.
- A symlink that resolves to any of the above is rejected, and so is the canonical form of an OS-aliased path (e.g. on macOS, typing `/private/tmp` is rejected the same way as `/tmp`).
The blacklist is intentionally aggressive — picking your home directory would put Multica's runtime files at the root of your account, which is never what you want. Pick a sub-folder (typically your actual project checkout) instead.
### One per (project, daemon)
A project may hold **at most one `local_directory` per daemon**. Attempting to add a second one on the same daemon returns a `409` from the API; the Desktop button hides itself when the limit is already reached and surfaces a tooltip explaining why.
Different daemons are independent — a shared project can have one `local_directory` per teammate's machine, each binding the same project to a different folder on a different host. When the daemon claims a task, it picks the row that matches its own ID and ignores the rest.
### Running tasks against a local directory
When a task is dispatched on an issue whose project has a `local_directory` bound to the receiving daemon, the daemon:
1. Re-validates the path (rules above).
2. Acquires a per-directory lock keyed on the symlink-resolved real path — so two routes to the same folder (one via a symlink, one direct) still serialise.
3. Writes the agent's `CLAUDE.md` / `AGENTS.md` (and `.multica/project/resources.json`) **into the user's directory**. The agent works there, just as if you'd opened the folder yourself.
4. Keeps Multica's runtime artefacts (`output/`, `logs/`, `.gc_meta.json`) in a separate envRoot **outside** the user's directory.
If a second task for the same directory arrives while the first is running, it parks with status **Waiting for local directory** (等待本地目录释放). The status is visible everywhere the task is — the chat task pill, the agent banner, the execution log, and the activity indicator — and the parked task counts toward the agent's "queued" presence. Cancelling the parked task releases its slot immediately; cancelling the running task lets the next one promote.
The wait is not a timeout — a parked task stays parked until either the lock releases or the user / agent cancels it.
### What Multica will and won't touch in your directory
- **Will write** `CLAUDE.md` / `AGENTS.md` (or the equivalent for your agent's provider) and `.multica/project/resources.json` at the directory root, so the agent has its meta-skill and resource list. Add these to your `.gitignore` if you don't want them committed.
- **Will write** whatever code edits the agent decides to make — exactly the same way as if you'd run the agent locally yourself.
- **Will never physically delete** the directory or anything inside it. Garbage collection is path-aware: for `local_directory` envRoots it cleans only its own `output/` and `logs/` under `workspacesRoot`, and treats the user's directory as off-limits.
### v1 limits (will tighten in follow-ups)
The first release deliberately ships with sharper edges than `github_repo`. Expect this list to shrink over time — what's documented here is what's true today:
- **No automatic branch switching.** The agent runs in whatever branch you have checked out. Switch branches before dispatching if it matters.
- **No dirty-tree protection or auto-commit.** Uncommitted changes are visible to the agent, may be modified in place, and won't be stashed. Treat the directory as a real working tree and commit before risky runs.
- **No automatic PR.** When the task ends, the changes sit on whatever branch they were made on — nothing is pushed and no PR is opened. Push and open the PR yourself when you're ready.
- **`waiting_local_directory` shows status, not the holder.** The badge tells you the task is parked; it doesn't surface which task or which file path is currently holding the directory.
These are tracked as the agent-task-lifecycle follow-up to the local-directory work; until that ships, treat `local_directory` as "the agent runs in your folder, the same way you would."
## Attaching repos at project creation
In the **Web** or **Desktop** app, opening *New project* now shows a **Repos** pill alongside Status / Priority / Lead. Selecting workspace-bound repos (or pasting an ad-hoc URL) attaches them as `github_repo` resources the moment the project is created.

View File

@@ -0,0 +1,248 @@
---
title: 项目资源
description: 给项目挂上有类型的指针GitHub 仓库、本地目录,未来更多),让智能体执行任务时自动拿到对应上下文。
---
**项目资源Project Resource** 是挂在 [项目](/workspaces) 上的有类型的指针 —— 今天可以挂 GitHub 仓库 URL 或者本机的一个目录,未来还会有 Notion 页面、文档链接等等。当 [智能体](/agents) 在这个项目的 issue 上跑任务时,守护进程会自动把项目的资源列表写进智能体的工作目录,并塞进它的 [元 skill](/skills) prompt 里。
带来的结果:智能体知道应该 checkout 哪个仓库(或者在哪个本地目录里工作),知道项目"主要参考资料"是哪些 —— 没人需要把上下文复制粘贴进 issue 描述里。
## 心智模型
项目不再只是一个标签,而是一个小的 **资源容器**
- 一个项目可以挂 0..N 个 **资源**。
- 每个资源有一个 `resource_type`(比如 `github_repo`、`local_directory`),以及一个 `resource_ref`(按 `resource_type` 定型的 JSON 负载)。
- 加新资源类型只需要加一个字符串 + 一个 handler。**不需要 schema 迁移,不需要重写前端。**
这个形状是有意的 —— 跟 Multica 里 agent provider 的设计完全一致:一个 `type` 判别字段 + 一个有类型的 payload。schema 保持稳定,未来加"Notion 页面"、"Google Doc"、"上传文件"、"外部 URL"都是小型的、增量的改动。
目前内置两种资源类型:[`github_repo`](#resource-type-github_repo)(每次任务克隆出独立 worktree和 [`local_directory`](#resource-type-local_directory)(直接在某台守护进程所在机器的目录里运行)。
## 资源类型:`github_repo`
默认的资源类型 —— 每次任务都在隔离的 worktree 里 checkout
```json
{
"resource_type": "github_repo",
"resource_ref": {
"url": "https://github.com/owner/repo",
"default_branch_hint": "main"
}
}
```
`default_branch_hint` 可选 —— 填了之后,守护进程会把它写进元 skill告诉智能体该基于哪个分支干活。
## 资源类型:`local_directory`
对于那些不适合每次任务重新 clone 的仓库 —— 几十 GB 的游戏项目、超大 monorepo、或者本身就不想被多次复制的目录 —— 项目可以改为指向 **某台 [守护进程](/daemon-runtimes) 机器上的一个已有目录**。智能体会 **直接在这个目录里运行**,不 clone、不复制、不创建 worktree。
```json
{
"resource_type": "local_directory",
"resource_ref": {
"local_path": "/Users/me/code/big-game",
"daemon_id": "0001234e-…",
"label": "主开发目录"
}
}
```
跟 `github_repo` 相比的取舍是有意的:只有绑定的那台守护进程能领同一个项目下的任务,而且 **同一个目录上的任务串行执行**,不再并行。换来的是:你的现有 checkout、当前分支、未提交的脏改动一切照旧 —— Multica 不会重新 clone。
### 什么时候选 `local_directory`、什么时候继续用 `github_repo`
| 关注点 | `github_repo`worktree 模式) | `local_directory` |
| --- | --- | --- |
| 每次任务的 checkout 成本 | 重新 clone + worktree | 0 —— 智能体原地干活 |
| 同一仓库的并发 | 任意条任务并行 | 同一目录每次一条 |
| 分支 / 脏改动 | 每条任务从默认分支拉一个干净分支 | 用目录当前的状态 |
| 可在哪台机器跑 | 任意守护进程 | 仅绑定的那一台 |
| 磁盘占用 | 每条任务一个 worktree | 0 —— 用你已有的目录 |
如果"重新 clone"是最贵的步骤,选 `local_directory`;如果你需要同一仓库上多条任务并行,继续用 `github_repo`。
### 添加本地目录
文件夹选择器 **只在桌面端** 提供 —— 浏览器没法读 OS 路径,所以网页端不显示"添加本地目录"按钮。桌面端的流程是:
1. 打开项目 → **Resources资源** 面板。
2. 点击 **Add local directory添加本地目录**,系统会弹出原生文件夹选择器。
3. 选一个文件夹。这个路径会被绑定到 **当前这台桌面端注册的守护进程** —— 资源记录里同时保存路径和该守护进程的 ID。
当本机的守护进程离线,或者这个项目已经在本机绑定了一个 `local_directory` 时,按钮会自动隐藏并给出说明。要把另一台机器上的目录绑过来,需要在那台机器装上桌面端、并在那边添加资源。
也可以用 CLI即便环境是纯网页端也行只要你自己提供守护进程 ID
```bash
multica project resource add <project-id> \
--type local_directory \
--local-path /Users/me/code/big-game \
--daemon-id <daemon-uuid> \
--ref-label "主开发目录" # 可选
multica project resource update <project-id> <resource-id> \
--local-path /Users/me/code/big-game-new
```
`--daemon-id` 可以通过 `multica daemon list` 拿到。CLI 也接受 `--ref '<json>'` 这种通用形式,直接传 payload。
### 路径规则
挂资源时和每次任务启动时,守护进程都会校验一次路径(服务器只负责存 JSON不做校验。任何一条不满足的任务会以一个有类型的错误失败**不会动你的目录**
- 必须是 **绝对路径**。
- 必须 **存在**,并且是 **目录**(不能是普通文件、设备节点、或者指向文件的 symlink
- 守护进程进程必须能 **读 + 写**。
- 不能是系统根目录或整个用户配置区 —— `/`、`/Users`、`/home`、`/root`、`/etc`、`/tmp`、`/var`、`/usr`、`/opt`、`/Users/Shared`、你自己的 `$HOME`、任意 Windows 盘根(`C:\`、`D:\`…),以及 `C:\Users` / `C:\ProgramData` / `C:\Program Files` / `C:\Program Files (x86)` / `C:\Windows`。
- 指向上述任何路径的 symlink 也会被拒macOS 的 canonical 别名(比如手动输入 `/private/tmp`)和 `/tmp` 受同样的限制。
黑名单故意做得激进 —— 选你自己的 home 目录就意味着 Multica 的运行时文件会出现在你账号的根下,这不会是任何人想要的结果。请选一个子目录(一般就是你具体的项目 checkout
### 每台守护进程每个项目最多一条
同一项目上 **每台守护进程最多只能绑一个 `local_directory`**。在同一台机器上想再加一条会被 API 用 `409` 拒绝;桌面端的按钮在达到上限时会自动隐藏,并在 tooltip 里说明原因。
不同的守护进程互不影响 —— 一个共享的项目可以为每个队友的机器各绑一个 `local_directory`,把同一个项目挂到不同主机上的不同目录。守护进程领任务时会挑跟自己 ID 匹配的那一行,其它的忽略。
### 任务在本地目录里如何运行
当一条任务被分发到某个 issue、并且该项目在领任务的守护进程上绑了 `local_directory` 时,守护进程会:
1. 再次校验路径(按上面那套规则)。
2. 以 symlink 解析后的真实路径为 key 取一把"目录锁" —— 即便两条路径走不同路由(一条经过 symlink一条直接指向同一个文件夹也会被串行化。
3. 把智能体的 `CLAUDE.md` / `AGENTS.md`(以及 `.multica/project/resources.json`**写进你的目录**。智能体就在那里工作,跟你自己打开这个文件夹后启动它是一样的体验。
4. Multica 自己的运行时产物(`output/`、`logs/`、`.gc_meta.json`)放在 **你目录之外** 的一个独立 envRoot 里。
如果第一条任务还在跑、第二条针对同一目录的任务又来了,第二条会停在 **Waiting for local directory等待本地目录释放** 状态。这个状态在所有相关 UI 都看得见 —— 聊天里的任务状态条、智能体横幅、执行记录、活动指示器;等待中的任务会被计入该智能体的"排队"占用。取消等待中的任务会立即释放它的槽位;取消正在跑的那条会让下一条立即顶上。
等待没有超时 —— 一条等待中的任务会一直等到锁释放、或者被用户/智能体取消为止。
### Multica 会写、不会动什么
- **会写入** `CLAUDE.md` / `AGENTS.md`(或对应 provider 的等价文件)以及 `.multica/project/resources.json` 到目录根 —— 这样智能体才有自己的元 skill 和资源清单。如果不想把这些提交到 git请加进 `.gitignore`。
- **会写入** 智能体决定做出的所有代码改动 —— 跟你自己在本地跑这个智能体没有区别。
- **永远不会物理删除** 你的目录或里面任何内容。垃圾回收对路径是有判断的:对 `local_directory` 的 envRoot它只会清自己挂在 `workspacesRoot` 下的 `output/` 与 `logs/`**完全不碰** 你的目录。
### v1 阶段的限制(后续会逐步收敛)
第一版有意比 `github_repo` 留了更多锋利的边。下面这份清单会随着后续工作逐步缩短;这里列出的是 **今天**的真实行为:
- **不会自动切分支。** 智能体跑在你当前 checkout 的分支上。如果分支选择重要,请在分发任务前自己切换好。
- **不会保护脏工作区,也不会自动 commit。** 未提交的改动智能体看得到、可能就地修改,不会被 stash。请把这个目录当作真实的工作区来对待重要的运行前自己先 commit。
- **不会自动开 PR。** 任务结束后,改动就停在它实际做出来的分支上 —— Multica 不会 push、不会开 PR。需要 push、需要 PR 的话请自己来。
- **`waiting_local_directory` 只显示状态,不显示是谁占着。** 这个徽标只告诉你"任务在等",不会告诉你具体是哪条任务、哪个路径正在持有目录。
这些都列在 local-directory 工作的"智能体任务生命周期"后续项里;在那之前,请把 `local_directory` 当作"智能体在你的目录里跑,跟你自己跑没区别"来用。
## 在创建项目时挂仓库
在 **网页端** 或 **桌面端**,打开 *新建项目* 时Status / Priority / Lead 旁边多了一个 **Repos** 标签。选已绑到工作区的仓库(或者粘贴一个临时 URL项目创建的瞬间它们就以 `github_repo` 资源的形式挂上去。
通过 **CLI**
```bash
# 创建 + 挂资源一步到位。资源是在同一个事务里挂上的 ——
# 任何一个资源不合法都会让整个 create 回滚,绝不会出现
# "项目建好了但资源只挂了一半"的状态。
multica project create \
--title "Agent UX 2026" \
--repo https://github.com/multica-ai/multica
# 后续管理资源
multica project resource list <project-id>
multica project resource add <project-id> --type github_repo --url <url>
multica project resource remove <project-id> <resource-id>
# 任何服务器认识的 resource_type 都可以用这个通用入口 ——
# 加新类型时不需要改 CLI
multica project resource add <project-id> \
--type notion_page \
--ref '{"page_id":"…","title":"…"}'
```
`--repo` 可以重复指定;每个值会被当作一条单独的 `github_repo` 资源挂上去。
## 运行时智能体看到什么
当守护进程为一个项目内的 issue 启动智能体时,会发生两件事:
### 1. `.multica/project/resources.json`
API 响应的结构化透传,写进智能体的工作目录里:
```json
{
"project_id": "…",
"project_title": "Agent UX 2026",
"resources": [
{
"id": "…",
"resource_type": "github_repo",
"resource_ref": {
"url": "https://github.com/multica-ai/multica",
"default_branch_hint": "main"
}
}
]
}
```
Skill、辅助脚本、或者智能体自己想拿到本次运行**精确的**资源列表时,解析这个文件即可。
### 2. 元 skill prompt 里的 "Project Context" 段
智能体的 `CLAUDE.md` / `AGENTS.md`(按 provider 不同)里多出一段人类可读的摘要:
```
## Project Context
This issue belongs to **Agent UX 2026**.
Project resources (also written to `.multica/project/resources.json`):
- **GitHub repo**: https://github.com/multica-ai/multica (default branch: `main`)
Resources are pointers — open them only when relevant to the task. For
`github_repo` resources, use `multica repo checkout <url>` to fetch the code.
```
这段文字故意做得很精简:完整 payload 已经写到磁盘上了prompt 只是给智能体一个定位 —— 让它知道项目存在、知道挂了什么。
### 失败时
资源拉取是 **best-effort**。如果 API 调失败了prompt 里的项目段会省掉、文件也不写,但任务照常启动 —— 智能体永远不会因为缺少项目上下文而卡住。
## 加一种新资源类型
抽象的全部目的就是让加新类型变便宜。完整流程:
1. **服务端校验器**`server/internal/handler/project_resource.go`)—— 在 `validateAndNormalizeResourceRef` 里加一个 case解析并标准化新的 payload。
2. **守护进程元 skill 格式化器**`server/internal/daemon/execenv/runtime_config.go`)—— 在 `formatProjectResource` 里加一个 case让智能体 prompt 把新类型渲染成一条可读的列表项。
3. **TypeScript 类型**`packages/core/types/project.ts`)—— 给 `ProjectResourceType` 加值,并加上对应的 payload 接口。
4. **UI 渲染**`packages/views/projects/components/project-resources-section.tsx`)—— 在 `ResourceRow` 里加一个 case 渲染新类型。
**不需要 schema 迁移、不需要新的 sqlc query、不需要新的 endpoint、也不需要改 CLI** —— CLI 的通用 `--ref '<json>'` 选项接受校验器认识的任何 payload第 0 天就能用。(之后如果想加按类型的 CLI 快捷参数,可以加,但不是必须。)
同一张 `project_resource` 表、同一套 CRUD 调用,处理所有类型。
## 工作区仓库 vs. 项目仓库
智能体看到的仓库列表(`CLAUDE.md` / `AGENTS.md` 里的 `## Repositories` 段)由守护进程的领任务逻辑按以下优先级决定:
- **项目挂了至少一条 `github_repo` 资源** → 仅显示项目挂的仓库。工作区绑定的仓库会被隐藏,避免智能体猜哪一个属于这个 issue。
- **项目没挂 `github_repo` 资源(或 issue 根本不在项目里)** → 回落到工作区的仓库列表,跟以前一样。
这样可以把智能体的工作集压紧:项目把仓库说清楚了,那就是权威答案。而 `.multica/project/resources.json` 里始终带着完整列表,需要查看全部资源的 skill 仍然能拿到。
守护进程在 checkout 这一侧也是配套的:当任务带着项目级别的 `github_repo` URL 进来时,这些 URL 会被合并进每工作区的白名单,并在智能体启动前同步进本地仓库缓存。所以即便某个项目仓库 URL 没被绑到工作区,`multica repo checkout` 也能正常处理它,不会以"未配置"为由拒绝。白名单的划分只是内部实现:工作区绑定的 URL 和任务级别的 URL 分开记录,工作区仓库列表的刷新不会顺手吊销某条项目 URL。
## 当前**不**做的事
- **跨项目共享**。每条资源现在只能挂在一个项目上。
- **按 skill 划定资源可见性**。所有资源对智能体本次运行里的每个 skill 都可见;按类型过滤是后续工作。
- **缓存 / 同步**。`github_repo` 目前只是元数据 —— checkout 仍然按需走 `multica repo checkout`。Notion / Google Docs 之类的文档文本缓存会随对应类型一起到位。
这些是有意的留白 —— 第一版的目标是用最小的活动部件去验证这个抽象。