Metadata-Version: 2.4
Name: dingclaw
Version: 0.4.7
Summary: DingTalk digital-avatar runtime: reply as yourself, fail closed
Author: Zhuoyue
License-Expression: GPL-3.0-or-later
Project-URL: Homepage, https://github.com/adongguo/dingclaw
Project-URL: Repository, https://github.com/adongguo/dingclaw
Project-URL: Issues, https://github.com/adongguo/dingclaw/issues
Keywords: dingtalk,dws,ai-agent,digital-avatar,automation
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Console
Classifier: Intended Audience :: End Users/Desktop
Classifier: Operating System :: MacOS
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Topic :: Communications :: Chat
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Provides-Extra: test
Requires-Dist: pytest<10,>=7; extra == "test"
Requires-Dist: pytest-cov<8,>=4; extra == "test"
Provides-Extra: release
Requires-Dist: twine<7,>=6; extra == "release"
Dynamic: license-file

# DingClaw

**钉钉数字分身运行时。** 以本人身份在钉钉里代答——不是机器人账号，是「你」。

### 独立健康巡检

每分钟检查运行状态，异常时给本人发钉钉提醒，恢复后通知；不调用模型，支持重复告警抑制和本机通知兜底。安装和边界见源码包里的 `docs/健康巡检.md`。

### Agent 输出限制

`agent_max_output_bytes` 默认 `1000000`；设置为 `0` 表示不限制回复 Agent 与告警分析的 stdout、stderr、流式事件累计量及最终结果字节数。正整数保留原有上限行为（流式事件最低上限为 16,000,000 字节）。修改后重启实例，确保预热进程使用同一配置。无限模式会增加内存与临时文件用量；模型上下文、执行超时及钉钉平台消息限制仍然有效。

### 不限运行时长

以下配置解除回复与告警分析的总时长、空闲和过程输出限制；修改已有配置时保留其余字段：

```json
{
  "agent_timeout_seconds": 0,
  "agent_timeout_seconds_by_tier": {"restricted": 0, "standard": 0, "privileged": 0},
  "long_task_enabled": true,
  "long_task_hard_timeout_seconds": 0,
  "long_task_idle_timeout_seconds": 0,
  "agent_max_output_bytes": 0,
  "alerts": {"analysis_timeout_seconds": 0}
}
```

`0` 表示对应限制关闭，已有正数配置和默认值仍保持有限行为。取消、服务关闭、授权校验、并发、租约心跳和发送去重继续有效；DWS/模型供应商及工具自身限制不受此配置控制。告警分析通过独立心跳与尝试标识恢复中断任务；可识别的暂态失败最多再试一次，每次计额度。最终结果先持久化再投递，未知投递状态不自动重发，失败通知送达不代表诊断成功。更新程序和配置后重启，并按交付手册核验。

名字取自 `ding`（钉钉）+ `claw`。dingclaw 是**单渠道、单组织、以本人身份、fail-closed** 的窄深实现。

服务命令 `dingclawd`，Python 包 `dingclaw`。这是一个本机常驻的轻量轮询服务。它每三分钟通过 `dws` 拉取钉钉增量消息，只把符合规则的新消息交给 CLI Agent，并通过本机已授权的 DWS 身份发送回复。默认使用当前用户身份，不需要注册钉钉应用；也可显式切换到机器人模式。

切换后端无需改代码：在配置的 `agents` 中填写 `name`、`tier`、`enabled` 与
`binary: "auto"`，每次加载配置时发现当前可用的 CLI 入口，再由程序生成对应模板；
也可填写绝对路径固定版本。自动发现支持 Claude Code、Codex、Qoder CLI 和 pi，
不自动启用后端或授予模板验证标记。Codex 已覆盖回复、识图、摘要和 DING 分类，
配置范例和首次验证见源码包里 `docs/配置参考.md` 的「切换到 Codex」一节。

默认策略：

- 接收全部单聊，但按不可变钉钉 ID 排除黑名单里的人；
- 群聊只处理明确 @ 当前用户的消息；
- 黑名单完全由运行实例的人自己配置，产品不内置任何具体人选；
- 忽略自己和已知机器人发出的消息，避免回复回路；
- 支持 Claude Code、Codex、Qoder CLI、pi 四种 CLI Agent，交付默认 Qoder；pi 固定走 ChatGPT（Codex）订阅。四者都要在**运行它的那台机器上**跑过 `verify-template` 并由人工写入 `agent_templates_verified` 才能发送，只有 `claude:restricted` 是本仓库已回放验证的条目；
- 每一条发出去的消息都带 AI 标识：正文固定文案（覆盖全部发送路径）+ DWS 原生角标（仅纯文本路径有）；
  也可以把标识换成贴在自己那条消息上的文字表情（`ai_disclosure_mode=emotion`），但**只有流式卡片路径**能这么做——
  只有它存在「消息已建、正文还空」的一瞬，能让徽标先于正文落地；
- 可选在对方那条消息上贴「⌛分身回复中 → ✅分身已回复」状态标识（`presence_markers_enabled`）；
- 可选理解消息和引用里的附件（兼容键名仍为 `image_understanding_enabled`）：图片交给独立 `vision` tier，UTF-8 文本文件做有界提取，
  其他附件只保留类型与元数据；回复模型仍然 `--tools ""`，只拿得到不可信文本，不获得路径或文件权限；
- 默认只回复你**还没读过**的消息（`unread_gate_enabled`，默认开启）；关掉就是什么都回，
  此时防「对方改口」的三个装置从可选变成必需；
- 使用 SQLite 保存水位、去重、租约和发送状态；
- 发送结果不确定时标记为 `SEND_UNKNOWN`，不自动重发；
- 配置未完成、DWS 契约未验证时拒绝启用自动发送。

## 安装

dingclaw 依赖钉钉官方的 DWS CLI。安装入口以
[钉钉开放平台](https://open.dingtalk.com/)为准；国内环境可以直接使用官网给出的 npm
镜像安装方式：

```bash
npm install -g dingtalk-workspace-cli --registry=https://registry.npmmirror.com
dws --version
```

DWS 是独立项目，源码和更多安装方式见
[DingTalk-Real-AI/dingtalk-workspace-cli](https://gitee.com/DingTalk-Real-AI/dingtalk-workspace-cli)。
官网也提供 macOS / Linux 安装脚本；采用脚本方式时应先下载并审阅内容，再在本机执行。
它访问企业数据前仍需企业管理员授权；dingclaw 不附带、转存或代发 DWS 凭据。

公开版通过 PyPI 安装。使用独立虚拟环境可避免改动系统 Python：

```bash
python3 -m venv ~/.local/share/dingclaw/venv
~/.local/share/dingclaw/venv/bin/pip install 'dingclaw==0.4.7'
~/.local/share/dingclaw/venv/bin/dingclawd --version
```

也可以执行 `pipx install 'dingclaw==0.4.7'`。企业如需内部分发，可把同一组 wheel / sdist
同步到云效 Packages 等私有 PyPI；云效仓库仍要求仓库成员凭据，不是互联网匿名下载入口。

安装后跑一次 `init`：

```bash
dingclawd whoami                                      # 只读，认领你的两个不可变 ID
dingclawd init --owner-name <你的花名> --agent qoder  # 配置 + 工作域 + launchd（不加载）
dingclawd doctor                                      # 列出你还欠的每一件事
```

`init` 生成 `~/.config/dingclaw/config.json`（0600）、`~/.dingclaw/` 工作域骨架、
`~/Library/LaunchAgents/com.dingclaw.<你>.plist`，**所有门禁一律关闭**。
它不会、也无法打开任何门禁。

分身关掉的时候，用面板接管：

```bash
dingclawd tui                                       # 分诊列表 + 会话预览 + 分身模式切换
```

面板是 TypeScript + React + Ink 写的，随 wheel 分发成一个单文件 bundle，不需要 `npm install`。
它自己不做任何判断：门禁开没开、这行能不能触发、这条消息是不是分身发的，全部由 Python 内核回答，
面板只渲染答案。**把面板整个删掉，这个实例的安全属性一点不变。**

五个主视图均可按 `r`（或 `Ctrl+L`）主动拉取最新数据；可见会话的消息也一起刷新。刷新保留草稿、筛选和阅读位置，不触发回复或标记已读。输入框中的 `r` 仍是正文，退出输入后再刷新。

要默认打开时间线，在配置文件的 `panel_ui` 中设置 `"view": "timeline"`，重开面板生效。`single_lens` 保持 `false` 时仍可用数字键切换其他视图。

选中时间线视图时每 15 秒自动刷新最新消息，保留当前时间范围和选中消息；进入会话后仍继续刷新列表，切换到其他视图即停止。慢请求期间不叠加请求，失败保留旧列表并在后续周期重试。全屏面板逐行覆盖变化，不先擦空列表；最后成功刷新时间按当前视图记录，时间线更新不会继续显示分诊列表的旧时间。实时连接状态与读取结果分别判断，读取成功代表取得 DWS 当前可查询的数据，仍可能有上游索引延迟。

未读列表会合并独立未读来源与普通、置顶会话信息，每 15 秒自动刷新，收到实时消息后也会合并触发刷新；当前会话每 30 秒补读消息。后台更新保留当前选择和输入，不会清除未读。接口达到 100 条上限或读取失败时会持续提示覆盖限制；顶部连接标记与数据更新时间分别展示，连接正常不代表列表一定新鲜。

分诊和顶部待办数字只统计仍待处理的条目：DING 读取服务端 `UNREAD`，已经看过或确认的不再保留为历史待办；@我按具体消息核对当前未读范围，已读的旧 @ 不会因为会话又有新消息而重新出现；普通未读要求未读数大于 0 且未开启免打扰。面板本地已处理的条目也不再显示。各视图的顶部数字共用这份筛选结果，并独立补读，较慢的统计查询不会拖住时间线更新。已读状态读取失败、达到快照上限或三页历史仍不足时保留候选并明确提示未确认；读取面板不会替你执行已读或 DING 确认操作。

支持内联图片的终端中，点击图片可交给系统默认查看器打开。键盘操作是在非输入状态选中消息后按 `v`；多个附件先选择，再按 `Enter` 或 `o`。退出会话、滚动和调整窗口时会清理旧图片位置。图片可点击时启用终端鼠标报告，拖动不会打开图片；需要选择终端原始文字时使用终端提供的选择修饰键（Ghostty 为 `Shift`）。

在群聊输入框键入 `@` 会就地打开当前群成员选择器；继续输入可按群昵称、花名或姓名筛选，
`↑` / `↓` 选择、`Enter` 插入，`Esc` 则保留为普通文字。只有从选择器确认的青色 `@花名`
才携带钉钉原生通知，手打同名文本和 AI 草稿里的 `@名字` 都不会猜测身份或误通知；单聊中的
`@` 始终是普通文字。发送前身份行会持续显示本条将通知的人数。

撰写框里超过一行宽度的内容会自动折行并全部显示，不再截断；`Ctrl+J` 仍可手动换行。草稿高于编辑区上限（终端高度的三分之一）时只显示光标附近的行，框内首行会写明上面或下面还有几行。

撰写区按 `Ctrl+X Ctrl+E` 可把整段草稿交给外部 Vim/Neovim；保存退出只回填，不会自动发送。编辑器按 `DINGCLAW_EDITOR`、`VISUAL`、`EDITOR`、`nvim`、`vim`、`vi` 依次选择。保存空内容，或先按 `Esc` 收起再按 `d`，都会丢弃整段草稿；正文里的普通 `d` 不受影响。AI 草稿经外部编辑后仍保留 AI 标识，只有完整丢弃后重新输入才转为本人文本。

需要 `node >= 20`；没有或版本不够时自动回落到旧的 curses 待办面板，并说明原因（`--legacy` 可强制回落）。

源码包和完整交付包中还附带 `docs/交付手册.md` 与 `docs/配置参考.md`；本文保留公开安装、
核心安全边界和日常使用所需的信息，避免 PyPI 页面依赖无法解析的仓库相对链接。

**也可以让 agent 代劳**：交付包里带一份 `SKILL.md`（仓库内是 `install_skill/SKILL.md`），交给 Claude Code / Qoder CLI / Codex 执行即可。它会跑完全部步骤，并在四个安全门禁处停下来要你本人确认——`verify-contracts --attest --yes`、`verify-template --attest --yes`、`enable-sending --yes`、以及 `launchctl bootstrap`。这四个命令没有 `--yes` 一律拒绝执行，agent 绕不过去。

手工配置也可以：`config.example.json` 是全量键 + 默认值，由代码生成、有测试钉住。

## 轻量路由与持续任务

可选 `fast_routing` 默认关闭，默认 `shadow` 模式只记录判断。独立 `codex:fast` 无工具探针验证通过并开启持续任务后，可切到 `enforce`：纯问候使用可信模板，召唤帮助使用程序帮助原文，复杂问题、附件与信息不完整的任务继续使用原有强模型。轻量模型默认为 `gpt-5.6-luna`、`low`，单次超时 8 秒、并发 2、每 UTC 日独立额度 200 次；失败、超时、忙碌或额度耗尽自动回退。

路由使用独立空白 Codex app-server 上下文，不继承 AGENTS、MCP 或 Skills，不开放工具。关联输入先持久化再分类；纯催进度不取消或重跑任务，混合补充保留全部要求。快答沿用持久最终投递，文本选择与卡片创建原子互斥，不把已开始或未知的卡片再发成另一条文本。发送仍保留 AI 标识与原有权限校验。

路由连接常驻并预建下一轮 thread：上一轮退订后立刻开好下一轮要用的 thread，让 Codex 的会话初始化与本轮回复投递重叠；守护进程启动时预热连接，把进程拉起、`initialize` 与首个 thread 的冷启动移出首条消息的路径。任意时刻只保留一个活跃 thread，每个 thread 仍只承载一个请求，路由请求之间不共享上下文。本机实测单轮 4.3 秒降至 2.8 秒，其中会话初始化段 2.38 秒降至 0.90 秒。

本机装有托管 Agent Hook 时，守护进程只读跟随它的摘要日志，判断同步 hook 是否因本地引擎不应答而阻塞。只解析 `mode=sync` 记录的判定与耗时，命中 `allow_timeout` / `allow_circuit_open` / `allow_server_dead` 或单次 5 秒以上即置劣化，状态跃迁时记 `agent_hook_degraded` 与 `agent_hook_recovered`（带 `degraded_seconds`）。日志缺失即静默停用，读不了只报一次 `agent_hook_watch_failed`；日志中的 prompt 与命令原文既不解析也不回显。劣化期间，命中问候与召唤帮助白名单的新召唤直接用本地可信模板作答并标记 `engine_degraded_direct`——该路径下模型生成的文本本就会被同样的模板替换，等下去只是多付一次同步 hook 超时；引擎健康时行为不变，追问分类仍走模型。本机实测托管 hook 引擎不应答时单轮路由 19.6 秒，健康时 4.4 秒。

验证入口为 `dingclawd verify-fast-router --config ~/.config/dingclaw/config.json`；完整参数和灰度验收见源码包里 `docs/配置参考.md` 的「轻量路由」与 `docs/交付手册.md` 的「轻量路由交付验收」。不通过探针时仅停用优化，不降低强模型配置。

## 群告警统计与分级响应

显式配置 `alerts.groups` 数组后，可独立采集指定群中指定机器人发送的告警，按来源、程度和分类统计。
默认关闭，群名只用于显示，授权依据是群 `conversation_id` 和来源 `sender_open_ids`，不要把采集名单写进聊天的 `robot_user_ids` 排除项。

日报默认每天北京时间 21:00，统计前一天 21:00 至当天 21:00；周报默认周日 21:00，统计前一周同一时刻至本周同一时刻。
两种报告都由程序生成，不调用 AI。独立计时与工作线程不依赖聊天模型就绪，也不因群消息已读、未 @ 或 summon 模式跳过告警。
报告分别显示消息数、触发/恢复数、同类聚合、来源/级别/分类和采集缺口；同类聚合不能直接当作独立故障数。

卡片把级别仅放在图片里时，可通过 `alerts.card_templates` 配置人工核实过的图片 `media_id` 与级别、来源（含 ARMS）的精确映射。默认空列表，不调用 AI/OCR 识图，也不按颜色或未识别图片猜测级别。

严重程度先按当前告警字段和来源规则匹配：恢复不排障，显式 P0 优先；未命中显式业务规则的 P1 绕过默认降噪，其他普通 OOM、ACK Warn/资源修改、巡检及 CPU/带宽告警默认抑制自动分析，保留已知上游级别（未知级别才回退 P2）。CMS 聚合级别字段中的完整 `严重(critical)` 及带正整数次数的 `严重(critical)1次` 映射为 P1，不从链接或说明片段猜级别。`noise_categories` 可选择这些类别，设为空数组可禁用默认抑制；自定义规则支持上游级别、标题全部/任一关键词联合匹配，`severity=inherit` 配合 `response=record` 可保留 P1 统计而不触发模型。
常规告警仅统计；严重告警默认全实例每日最多调用 Codex 3 次，同类冷却 6 小时、并发 1。切到 `analysis_mode=rules` 后只生成规则排查建议。
Codex 默认使用 `analysis_permission=read-only`，只读取程序准备的脱敏证据目录，不开放其他目录、网络或 MCP。需要对严重告警查询真实日志和源码时，可显式设置 `analysis_permission=root`：沿用 privileged Codex 的 `danger-full-access` 及 `agent_config_home` 中的 MCP、Skills；这里的 root 是应用高权限，不是 sudo 或切换操作系统 UID。诊断职责仍限定只读，禁止自动修复、重启、发布和写业务数据；root 下这是提示约束，不是操作系统强制隔离。`analysis_evidence_paths` 是最多 8 个优先参考文件的绝对路径，read-only 只看脱敏快照，root 可继续查询其他已授权证据。单次超时默认 180 秒，可配置 0 或 1–1800 秒；0 关闭分析总时长限制；日额度、冷却和并发限制不变。证据不足时明确列出缺口，不能把候选原因写成已确认根因。

```bash
dingclawd alerts status
dingclawd alerts report --group <群ID> --kind daily
dingclawd alerts report --group <群ID> --kind weekly --at 2026-09-13T21:00:00+08:00
# 明确发送预览所对应的报告；同窗口与定时任务共享幂等记录。
dingclawd alerts report --group <群ID> --kind daily --send
```

首次回填历史只参与统计，旧严重告警不会自动补做分析；错过发送时刻后仅补最新一期。
发送结果未知不自动重发，消息历史可见才是实际送达证据。
完整字段及规则示例见源码包里 `docs/配置参考.md` 的「群告警统计与分级响应」，部署验收见交付手册。

## 本机准备

个人身份模式必须分别填写本人的 `self_user_ids` 和 `self_open_dingtalk_ids`，再按需配置 `deny_user_ids`，设置 `sender_mode=personal`，并用脱敏真实响应验证 `list`、`list-all`、`list-mentions`、`list-unread-conversations`、`send`、`query-send-status` 六项契约后再打开门禁。`deny_user_ids` 可包含 userId 或 openDingTalkId；禁止使用姓名代替不可变 ID 作为生产拒绝规则。两类自身 ID 都会进入回复回路保护与状态库发送身份绑定。

启动前运行 `doctor`。它会验证 DWS 是否安装，并通过 `dws auth status` 检查指定组织的 OAuth、Access Token 和 Refresh Token；未授权时会输出可直接执行的设备流登录与复核命令。也可以手动执行：

```bash
dws auth login --device --profile <corpId> --format json
dws auth status --profile <corpId> --format json
```

Apple Silicon 上运行时会在 DWS 子进程出口使用 `/usr/bin/arch -arm64`，避免 Rosetta 的执行偏好让同一个分发脚本选中旧 Intel CLI。组织参数和身份校验仍然生效，无需修改 `dws_profile`。若手工执行提示 `unknown flag: --profile` 或找不到 `event consume`，先对比 `dws --version` 与 `arch -arm64 dws --version`，再用 `arch -arm64 dws auth status --profile <corpId> --format json` 核验；不要删除组织参数或反复重新授权来绕过版本差异。Intel Mac 与 Linux 不添加架构前缀。


若选择 `sender_mode=bot`，机器人必须属于同一个 `dws_profile`，并额外填写 `robot_code`、`robot_user_ids`，验证 `send-by-bot` 契约。机器人列表为空时才需要由该组织的开放平台开发者创建或复用企业内部应用并启用机器人能力；禁止借用其他组织或其他业务应用的机器人 Code。

### 给分身一个自己的 CLI 配置目录（`agent_config_home`）

不配这一项，分身用的就是**你本人**那份 CLI 配置：你的模型、你的思考档位、你装的每一个
skill 和 MCP server、以及你 cwd 之上的每一条 `CLAUDE.md`。这不只是慢——`claude:privileged`
带 `--permission-mode bypassPermissions`，等于每一轮代答都在你的全部工具面上跑。

`claude:privileged` 模板本来就声明了 `isolation="config_home"`，只是在此之前没有任何东西
去设那个环境变量。现在有了：

```bash
mkdir -p ~/.dingclaw/claude-home && chmod 700 ~/.dingclaw/claude-home
# 在里面放一份只服务于分身的 settings.json，然后一次性登录：
CLAUDE_CONFIG_DIR=~/.dingclaw/claude-home claude /login
```

再把绝对路径写进 `agent_config_home`。**只接受字面绝对路径，不支持 `~`**——与 `dws_binary`
同规则，猜不如拒。`agent_path` 同理，用来补齐 launchd 那份极窄的 `PATH`（没有它，
privileged 那一轮里 agent 想调 `node`、`uv` 会直接找不到，失败重试白烧时间）。

凭据按**目录**分账存在 Keychain 里，所以新目录必须自己登录一次；本仓库不提供任何
搬运凭据的代码路径。配好之后 `doctor` 会**常驻一条** login 引导 warning——config 层无法
判断登录态，这是设计如此，不是故障。判定这个目录确实可用的唯一凭据是探针通过：

```bash
dingclawd verify-template --agent claude --tier privileged --config ~/.config/dingclaw/config.json
```

探针与真实回复路径用的是**同一份**环境覆盖，所以探针过了就是运行时能跑。反过来，
不配 `agent_config_home` 时 `doctor` 会说出来自己正在继承（`agent_config_home is empty`），
升级到本版本的现存部署都会看到这条，配上才消失。

本机实测（同一条 prompt、真实 privileged 模板命令、各 6 次）：继承本人配置时墙钟中位
**23.0s**（20.0–29.5s），换成隔离目录（关 thinking、effort medium）后中位 **12.5s**
（10.4–14.2s），两个分布不重叠。收益来自模型档位，不是来自少读了几个 skill。

### 一个账号用尽时换下一个（`account_home`）

`agent_config_home` 让分身有自己的配置，但仍是**一个**账号。每条 `agents` 条目可以再填
`account_home`，把它钉在某个已登录的配置目录上；同一 tier 放两条，就是主备两个账号。

主账号报出额度耗尽或掉登录（`usage limit reached`、`429`、`Not logged in` 等）时，
这条消息立刻交给下一条，主账号进入冷却：有 CLI 打印的重置时间就用它，没有就用
`agent_account_cooldown_seconds`（默认 900 秒）。冷却期内不再先撞一次主账号；全部冷却时
仍试最早到期的那个，成功即恢复。识别不了的失败维持原样——换下一个，但不记冷却。
`doctor` 的 `agent_accounts` / `agent_account_cooldowns` 说明现在用的是哪个账号、谁在冷却。
细节见源码包里 `docs/配置参考.md` 的「多账号与额度感知切换」。

## 黑名单

黑名单使用不可变钉钉用户 ID，修改配置时采用进程锁、同目录原子替换并保持 `0600` 权限。添加、查看和移除命令如下：

```bash
dingclawd blacklist add --user-id <userId> --config ~/.config/dingclaw/config.json
dingclawd blacklist list --config ~/.config/dingclaw/config.json
dingclawd blacklist remove --user-id <userId> --config ~/.config/dingclaw/config.json
```

黑名单没有任何内置成员，加谁减谁完全由你决定。**入站消息的 `sender_id` 恒为空**，所以只写工号的条目永远匹配不到发送者——`doctor` 会把这类条目列进 `warnings`。发送阶段会持有黑名单读锁；`blacklist add` 返回后，不会再产生发给该用户的新发送调用。姓名黑名单仅为兼容字段，不建议作为生产拒绝依据。

### 时间线黑名单

另一份名单，**按会话隐藏时间线里的行**，走同一把锁、同样的原子替换和 `0600`：

```bash
dingclawd timeline-blacklist add --conversation-id <openConversationId> --config ~/.config/dingclaw/config.json
dingclawd timeline-blacklist list --config ~/.config/dingclaw/config.json
dingclawd timeline-blacklist remove --conversation-id <openConversationId> --config ~/.config/dingclaw/config.json
dingclawd timeline-blacklist mode --value exclude|allow-only --config ~/.config/dingclaw/config.json
```

它和上面那份不是一回事，**别混**：上面那份拒绝的是人，分身不再回复他；这份只是让刷屏的群不出现在「我错过了什么」这条混流里，**分身在那个群里的行为一点不变**，该回还是回，未读红点也照旧。

过滤分两层，取并集：

| | 配置层 `timeline_deny_conversation_ids` | 面板层（状态库里的表） |
| --- | --- | --- |
| 谁写 | `timeline-blacklist add/remove`、手改配置 | 面板里按 `b` |
| 何时生效 | 进程启动时读，**要重开面板/重启服务** | 每页现读，**立即生效** |
| 记不记群名 | 只有 id | 面板知道标题，会一并存下来 |
| 面板能不能取消 | **不能**，`b` 取消不掉配置层 | 能 |

`list` 会同时打印两层（`conversation_ids` 是配置层，`panel` 是面板层），所以「这个群为什么还是不显示」只用问一次。状态库不存在时 `panel.status` 是 `absent`，它不会为了打印而建库。

`mode` 改的是面板层：`exclude` 是黑名单（列出来的不显示），`allow-only` 是白名单（**只**显示放行的，名单空着时间线就整个是空的，面板页脚会直说）。配置层在两种模式下都隐藏。

按 `openConversationId` 而不是群名，因为群名随时被人改，认名字的过滤会在改名那天悄悄失效。id 从时间线、会话列表或收件箱的返回值里抄，别手打；它是 base64（形如 `cidAbCdEfGhIjKlMn+OpQrSt/w==`），**大小写敏感**——这点恰好和认人的黑名单相反。

只挡 `timeline`，**不挡 `search`**：搜索是你主动打了个词，隐藏它等于丢信息。过滤在整页取回之后做，所以一页可能只剩几条甚至空，而 `hasMore` 仍是 `true`——`hasMore` 和 `cursor` 描述的是钉钉那一页，改它们会让翻页开始漏消息。面板页脚会写「黑名单隐藏 N 条」，CLI 的返回值里是 `filtered` 和 `filterMode`，所以读时间线的 agent 不会把「被隐藏」说成「没消息」。

面板里在任意视图选中会话后按 `b`：黑名单模式下是加入/移出隐藏名单，白名单模式下管的是放行名单，状态行会写清楚这次到底改了什么——包括「面板名单去掉了但配置里还锁着」这种只改了一半的情况。切模式在命令面板（`:` 或 `^K`）里。

## 使用

```bash
dingclawd doctor --config ~/.config/dingclaw/config.json
dingclawd poll --config ~/.config/dingclaw/config.json
dingclawd pull-now --config ~/.config/dingclaw/config.json
dingclawd run --config ~/.config/dingclaw/config.json
```

`poll` 只执行一轮，适合 `launchd` 每 180 秒调度；`pull-now` 是语义明确的人工立即触发入口，仍复用相同租约、去重、黑名单、Agent 预算和发送状态机。修复水位后需要回放近期遗漏消息时可传 `--lookback-seconds 900`，其值不能超过 `max_message_age_seconds`。`run` 是内置循环模式，每轮都会重新加载配置、黑名单和 DWS OAuth 状态。`doctor` 不会发送消息或修改钉钉业务数据，但 `dws auth status` 可能刷新本地 OAuth token slot。门禁或 DWS OAuth 未满足时服务以禁用状态运行，不拉取消息、不调用 Agent、不发送，并输出授权引导。

`pull-now` 可能调用模型并真实回复消息，不应作为只读诊断命令使用。另一个轮询进程持有租约时，它会返回结构化 `busy`，不会并行执行第二条发送链。

已生成正文但尚未送达的最终回复，可通过手动补发入口检查和恢复，无需重新调用模型：

```bash
dingclawd delivery list
dingclawd delivery show --job-id <job_id>
dingclawd delivery retry --job-id <job_id>                 # 仅预览
dingclawd delivery retry --job-id <job_id> --yes           # 确认排队
dingclawd delivery retry --job-id <job_id> --yes --ack-unknown
```

各命令均支持 `--config`。先核对预览中的目标会话和正文；未知发送必须核对会话历史，并通过 `--ack-unknown` 明确接受可能重复的风险。已有卡片继续更新原卡片，无卡片身份的未知结果改用带 AI 角标的普通消息。成功、已受理待查询、正在发送、已停止、过期修订和已废弃的记录不能强制重发；没有已保存最终正文的消息不在此入口范围内。`PENDING` 已在自动重试队列，重复操作不创建新投递。补发操作保留审计记录。

`retry --yes` 只排入持久队列，需运行中的 `dingclawd run` 或既有定时轮次继续投递，且需保持 `card_delivery_enabled=true`。排队成功不代表已送达，使用 `delivery show` 回读状态；`ACCEPTED` 仍需查询确认，`DONE` 才表示最终结果投递成功。原有权限和发送就绪校验继续生效。

## v2 六项能力（多数默认关闭）

v2 在同一条轮询链上叠加六项能力，全部有独立配置开关；未读门禁与轮次聚合默认开启，其余默认关闭，关闭开关即回到 v1 行为。

### 未读门禁（unread_gate_enabled）

开启后每轮 poll 先取一份 `list-unread-conversations` 快照，普通消息只自动回复"我还没读过"的消息；已读消息记 `skip_reason=already_read` 跳过。名次判定基于状态库中该会话全量非本人消息（含已处理消息），比快照更新的消息乐观放行。快照溢出（达到 `unread_snapshot_count`）时按 `unread_gate_on_overflow` 处理：默认 `skip_round` 暂停普通新消息，保留对账、发送恢复、已有任务的补充/停止及已识别同事明确召唤的处理。跨轮滞留的普通回复任务在发送前用当轮快照复核：未读清零若可归因于我方出站发送则照发，否则以 `read_after_generation` 取消。开启前必须运行契约断言并人工确认：

```bash
dingclawd verify-unread-contract --config ~/.config/dingclaw/config.json
```

`ranked` 模式零额外 DWS 调用；`exact` 模式对每会话再走一次历史分页取真实未读后缀。

### 召唤通道：@ 你自己（self_command_enabled）

召唤排障时，分身先沿已有线索查询业务证据。错误关键词未命中时继续检查日志原文、响应体和下游结果；必要时逐步扩大时间窗，并用真实责任仓库源码核对成功判定。只找到相似异常时会说明它是候选，缺少客户事件关联信息时仍会询问，不能保证每次都能唯一定位。简短回复不意味着只做一次查询。

只有携带本人不可变 ID 的消息才可能是**指令**；他人打出一模一样的字、甚至打出你的名字，永远不会变成你的指令。身份检查在文本之前，且只认不可变 ID——**名字从来不是授权信号**。有意放宽的通道只有下文两条：「同事在单聊里 @ 你」和「群里同事 @ 你 + 分身」。它们各是独立的解析方法、独立的开关，授权来自开关和黑名单，仍然不来自名字。

**一个动词，没有第二级关键词**，而且这个动词是钉钉里本来就有的手势：

| 你打的 | 效果 |
|---|---|
| `@你的真名(花名)` | 处理这个会话里等着的事 |
| `@你的真名(花名) 帮我回一下他` | 同上，并把这句话作为指令交给 privileged 模板 |
| `@me` | 同上；单聊里没有 @ 选择器，用这个 |

在群里直接从 @ 列表里选自己就行，钉钉会把它渲染成 `@真名(花名)` 进正文，两半哪一半匹配上都算。全角括号也认。`@me` 大小写不敏感（手机键盘会自动大写首字母）。

`@me <任务>` 的回复**发回你打召唤的那个会话**：自聊回给你自己，同事单聊回进那个单聊（对方看得到），群聊发进群。会话的对端从历史消息里解析；一个对方从没说过话的单聊解析不到人，回复退回你的自聊——答案宁可回错地方也不消失。回执类输出（指令无法识别、静音确认这些）**始终只发给你本人**：那是你自己的记账，不该出现在同事的窗口里。

召唤可以在**消息任意位置**——开头、中间、最后一行都认。你习惯先贴一段告警再在末尾补一句
`@自己 结合日志定位一下`，分身拿到的任务就是「去掉那个 @ 之后的整条消息」，贴进来的正文原样保留；
只有 @ 而没有别的内容才是裸召唤。边界规则不变：`@me` 后面要么空白要么结束（`@members`、`@me的想法`、
`@me.example` 不触发），`@名字` 必须整个对上（`@张三版` 不触发）；`@` 前面**不要求**空白，因为钉钉把选出来的
@ 渲染成紧贴前一个词的 `请@真名(花名) 看下`。引用别人一条 @ 了你的消息不会触发——引用走的是单独字段，
解析器不读它。代价是：转发或复述同事一句 @ 了你的话、在群里点名一串人时把自己也点进去，都会被当成召唤。

单聊、群聊、和自己的会话三种形态都生效，且**群聊里被召唤的会话会豁免「只处理 @我 的消息」这条策略**——群里真正需要处理的那条，通常恰恰是没有 @ 你的那条。豁免严格限定在这一条规则上：黑名单、自己发的、机器人发的照旧拦截。

**裸 `@me` 不发任何回执**。它通常打在别人的聊天窗口里，一句写给你自己看的「已登记 reprocess，窗口 900 秒」对同事毫无意义。分身产出的那条回复就是回执；没产出时去 `dingclawd tui` 里看原因。

#### 同事在单聊里 @ 你（peer_summon_enabled，默认开）

同事在**和你的单聊**里写 `@你的名字 <任务>`（`@张三 帮我查一下今天的构建`、`@李四(张三) 这个告警看下`），分身把它当成一次召唤：去掉那个 @ 之后的整条消息作为任务交给 **privileged 模板**（和你自己的 `@me <任务>` 走同一条链），回复发回这个单聊、发给这位同事。`summon` 模式下同样生效——这是它存在的理由：在 summon 模式里同事的消息本来一概不答，而一句点名的 `@你` 就是明确的呼叫。

边界（有意为之）：

- **只认单聊**。群里 @ 你走原有的 mention 路径和普通档，不因为这条变化；群里要召唤分身，得在 @ 后面跟上「分身」二字并打开下一节的开关。
- **只认你的名字**（`owner_display_name` / `self_mention_names`），同事打的 `@me` 指的是同事自己，不触发。名字没配则永远不触发。
- **@ 后面必须有内容**。裸 `@张三` 是叫人，不是给分身的任务，按普通消息处理；「先发一条 `@张三`、再发一条问题」这种两条消息的形态也不支持，和本人的 `@me` 一致。
- **不要求召唤词，但会替你吃掉**。单聊里一句 `@你` 就够了，同事若把群里学来的「分身」也打上（`@张三 分身 帮我查下`），召唤词和多打的那遍名字都不会进任务，交给模板的仍是 `帮我查下`——两条通道给模型的任务文本一致。单独一句 `@张三 分身` 仍然没有任务，按普通消息处理。
- **门禁照旧**：黑名单、机器人、会话静音仍对同事生效；已识别的明确召唤不受已读状态影响，未读快照失败或溢出也照常处理；`privileged` 模板未验证或未启用时同事的消息退回普通档，**不会**收到「模板未验证」那条本该给你自己看的回执；思考摘要按作者判定，同事看不到。
- 依赖 `self_command_enabled=true`（召唤通道总开关），通道关着时这个开关没有作用。

要说清楚的代价：这是**默认开着**的放权开关（召唤总开关 `self_command_enabled` 打开后才生效），把「谁能启动一个 privileged 任务」从只有你，放宽到了任何不在黑名单里、能和你单聊的人；任务文本来自同事。不想要就 `"peer_summon_enabled": false`，守护进程每轮重读配置，不用重启。转述一句同事 @ 了你的话同样会触发，代价与本人召唤相同。

#### 群里同事 @ 你 + 「分身」（group_avatar_summon_enabled，默认关）

群里被 @ 是日常手势，多数时候找的是你这个人，所以群里不能像单聊那样一句 `@你` 就算召唤。打开这个开关后，同事在群里**从 @ 列表里选中你**、紧跟着写「分身」二字并给出任务（`@李四(张三) 分身 帮我查一下今天的构建`、`@李四(张三)分身看下这个`），分身把它当成一次召唤：去掉「@你 分身」之后的整条消息交给 **privileged 模板**（与 `@me <任务>`、同事单聊召唤同一条链），回复发进这个群。`summon` 模式下同样生效——在 summon 模式里群 @ 本来一概留着不答，而点名「分身」就是明确的呼叫。

边界（有意为之）：

- **必须是真实 @**（从列表里选人，钉钉记为 @ 了你）。手打的 `@张三分身` 只是文字，不触发——它连普通群 @ 都不算，在 summon 模式下照旧留着等你自己的 `@me`。
- **召唤词必须紧跟在 @ 后面**，中间可以有空格（钉钉选人后自带一个），后面不要求空格（`分身帮我查下` 也认）；`@张三 帮我查下` 没有召唤词，仍是普通群 @，走普通档。认哪些词由 `avatar_summon_words` 决定，默认只有「分身」，见下。
- **唯一的例外是再打一遍你的名字**：选完 @ 又手打一遍名字是输入习惯不是另一句话，所以 `@张三 张三 分身 帮我查下` 照样算召唤。只有你的名字享受这个待遇，`@张三 老王 分身在吗` 是在问老王，不触发；打两遍也不行。
- **打了召唤词但没打对位置，会收到一句提示**。真实 @ 了你、正文里有召唤词、手势又没匹配上时（`@张三 老王 分身 …`、`@张三` 换行再写「分身」、`@张三 分身` 没给任务），分身在群里回一句怎么叫才对，然后把这条消息就地结掉，不跑模型。没有召唤词的普通群 @ 不会收到任何提示——那是找你这个人；只打了「分身」却没真 @ 的也不会，那是在聊分身而不是叫分身。
- **召唤词后面必须有内容**。裸 `@张三 分身` 按普通群 @ 处理。
- 名字规则与门禁和上一节相同：只认你的名字（同事的 `@me` 不算）；黑名单、机器人、会话静音照旧；已识别的明确召唤不受已读状态及未读快照失败、溢出影响；privileged 模板未验证时退回普通群 @，不发回执；思考摘要同事看不到；依赖 `self_command_enabled=true`。
- 回复发进群，**不会**额外 @ 提问的人（普通群 @ 的回复也不会，卡片路径也不支持）。

换一个召唤词：`avatar_summon_words` 是词表，默认 `["分身"]`——产品对自己的称呼，也是所有标识文案用的词。想教同事一个更短、或者不用切输入法的口令，把它加进去即可，例如 `["分身", "替身", "claw"]`：列表里**任何一个**词紧跟在真实 @ 后面都算召唤，其余门槛一个不减。英文词大小写不敏感（`claw`、`Claw`、`CLAW` 都认）；两个词互为前缀时长的优先（`["分身", "分身版"]` 下 `@你 分身版 x` 的任务是 `x` 而不是 `版 x`）。选词的硬约束来自「词后不要求边界」这一条：`帮我` 这种日常开头一旦进了词表，`@你 帮我看下` 天天都会被当成召唤，所以挑一个没人会对着同事说的词。写成 `[]` 等于把这个手势关掉，开关还开着时 `doctor` 会提示；含空白、`@` 或括号的词配置校验直接拒绝——那种词永远匹配不上。分身的自称文案不跟着变，仍然是「分身」：召唤词是给同事的口令，自称是对外的身份说明，两者本就不必一致。

代价：开着时，任何和你同群、不在黑名单里的人都能启动一个 privileged 任务，而且回复公开在群里。所以它**默认关**，只在你想要的实例上打开；守护进程每轮重读配置，即时回执引擎每分钟重读，关掉不用重启。会误触发的形态：正文恰好以「分身」开头（`@你 分身乏术…`）；同事重新从列表里选中你来转述一句「@你 分身 …」；群里有人花名与你相同、被选中后紧跟「分身」（名字只是触发条件，`@真名(花名)` 两半哪一半对上都算）。代价都是一次模型调用和一条群回复。

#### 你在群里 @ 机器人（robot_summon_names，默认空）

群里从 @ 列表里选自己，钉钉会给你自己推一条通知，同事也看见你在「@自己」。把群里某个机器人的名字写进 `robot_summon_names`，**你自己**发出的消息里 `@机器人名` 就是 `@me` 的别名：`@云监控告警 结合日志定位一下` 等于 `@me 结合日志定位一下`——去掉那个 @ 的整条消息交给 **privileged 模板**，回复发进本群；裸 `@云监控告警` 等于裸 `@me`（打开本会话的召唤窗、回看积压）。这不是放权：能触发的仍然只有你。

- **只认你自己发的消息**。dws 的消息回包里没有「@ 了谁」这个字段，被 @ 的机器人只能靠正文里的名字认——和 `@me` 一样是一段文本，授权仍然只来自你的不可变 ID。同事发 `@机器人名 <任务>` 什么也不会发生，上面两条同事通道也不读这些名字。
- **名字必须整个对上**：`@` 后紧跟名字，名字到下一个空白、括号或 `@` 为止（从列表里选出来的机器人钉钉自带一个空格，`@SLS看下` 这种黏连不认）；`@真名(花名)` 形态两半哪一半对上都算；大小写不敏感。配置时名字里不能有空白、`@` 和括号，不能与 `owner_display_name` / `self_mention_names` 重复。
- **名字和 openDingTalkId 去哪找**：`dws chat group bots --group <群 openConversationId>` 列出群里的机器人（`name`、`botOpenDingTalkId`、`robotCode`）。会自己回话的机器人（小钉这类 AI 助理）把 `botOpenDingTalkId` 写进 `robot_user_ids`，免得分身去答它的回复；只发告警的 webhook 机器人**不要**写——`robot_user_ids` 里的发送者不入上下文，告警正文就进不了分身的记忆。
- **两个开关**：`robot_summon_bare_reprocess`（默认 `true`）决定裸 `@机器人名` 是否等同裸 `@me`，`false` 时裸 @机器人 只是一条普通消息；`robot_summon_group_only`（默认 `false`）决定是否只在群里认，默认与 `@me` 一样在单聊、自聊里也认——轮次、即时回执、自聊探针三处口径一致。
- **时延**：@机器人 不会给你推 `at` 事件，只靠 `list-all` 轮询发现（空闲 `poll_interval_seconds`，活跃窗内 `active_poll_interval_seconds`）。想要秒级唤醒，给那个群加一条 `event_subscriptions` 项 `{"event_key": "user_im_message_receive_group", "target": "<群 openConversationId>"}`，代价是该群每条闲聊都会唤醒一轮。
- 出站护栏一并认识这些名字：分身回复、卡片思考摘要、回执里出现的 `@机器人名` 会换成全角 `＠`，不会自己召唤自己。

代价：你真心想问那个机器人本身（比如 `@小钉 帮我查下`）时分身也会答，一群两条回复；只填专门为分身准备的、不会回话的机器人最省心。名单每轮重读，改了不用重启；即时回执引擎里的名字在下一次启动守护进程时更新。

静音、状态、重置上下文都搬到了 `dingclawd tui`。旧的 `self_command_prefix` 通道仍然解析（`#前缀 mute 30m` / `unmute` / `status` / `reset` / `reprocess [15m]` / `do <任务>`），**只为一个场景保留**：人不在电脑前、TUI 没开，要能立刻让某个会话闭嘴。它不再是文档路径；把 `self_command_prefix` 设成 `@me` 即可彻底关掉它。

指令解析是确定性的。分身自己发出去的每一条消息都带着你的身份，下一轮读回来就是「你的消息」，所以出站全部过护栏：
模型输出**任意位置**出现召唤形态（`@me`、`@你的名字`、`@机器人名`）时，`@` 会被换成全角 `＠`（读起来一样、不通知任何人、
解析不到）；以配置前缀开头的回复仍整条替换为安全话术；卡片上的思考摘要、回执文案同样处理。这道护栏在指令通道关闭时也保持武装。

### 分身模式（avatar_mode）

| 值 | 行为 |
|---|---|
| `auto`（默认） | 按策略回全部命中的消息 |
| `summon` | **只回你 `@me` 过的会话**，其余消息一律不处理 |

`summon` 不是更弱的 `auto`，是另一个产品：分身从「你不在时替你说话的进程」变成「你拿起来用的工具」。

默认是 `auto` 而不是更窄的那个，理由和门禁相反：门禁回答「这个实例能不能发」，一律默认关；`avatar_mode` 回答「能发的那些里你想发哪些」，改它的默认值会让一个正在运行的实例在升级后**静默改变行为**，而这正是本配置在别处明确拒绝的事。

没有 `off` 值。彻底关掉分身是 `send_enabled`——一道需要人工背书的门禁；再加一个入口会让「分身到底开没开」有两个真值来源。

`summon` 模式下未被召唤的消息**不会**被标成终态跳过，而是留在待处理状态：`@me` 的意思是「处理这里的事」，其中当然包括召唤**之前**就到的那些。

### 聚合与忙时合并（round_merge_enabled / burst_quiet_seconds / max_supersede_per_job)

一轮 poll 内同一（会话, 发送者）至多一条回复：跨间隔的多段消息合并成一个 Round，从第二段起加 `[+Δs]` 相对时间标注（单段原文透传，保留问候拦截）。对方最后一条消息距今不足 `burst_quiet_seconds`（默认 30，0=关闭）时先按兵不动等下一轮，首条等待不超过 `burst_hold_max_seconds` 防饿死。上一轮未发出的旧回复遇到同会话更新消息时废弃并合并重生成（`superseded_by_newer_messages`），防饿死计数落在消息上，达到 `max_supersede_per_job` 后照发旧回复。

### 会话上下文与记忆（context_enabled）

每会话持久 transcript（SQLite，与消息状态同库）；提示词注入结构化 `context`（长期记忆、滚动摘要、近期轮次、未回复层），全部按 UTF-8 字节预算裁剪，当前批文本永不截断。未压缩尺寸超过 `compress_trigger_bytes` 后由维护任务用同构无工具模板压缩成滚动摘要并顺带沉淀长期事实；压缩失败只降级不阻塞回复。空闲超过 `session_reset_idle_hours` 或在 TUI 里重置时先冲刷记忆再换代。摘要与记忆始终位于不可信数据段，不具备指令权限。

#### 入账范围（context_ingest_scope）

transcript 记什么由 `context_ingest_scope` 决定：

| 取值 | 语义 |
|---|---|
| `replied`（默认） | 只有真正生成了回复的消息入账 |
| `participating` | 单聊全量；群聊仅记录分身已参与的会话（回过话、召唤过，或本条消息 @ 了自己） |
| `all` | 抓到的一切都入账，仅供压测排查 |

四种角色：`peer`（对方）、`self_human`（本人在钉钉手打）、`self_agent`（分身生成并发出）、`control`（自指令事件）。`self_human` 与 `self_agent` 必须分开——否则分身会把自己上轮的输出当成主人的指示。

入账复用发送门禁的同一套身份判定：黑名单发送者和机器人**一律不入账**，因为上下文会被拿去回复会话里的其他人，还会被压进滚动摘要和长期记忆。分身自己发出的回复会在下一轮被 `list-all` 抓回来，按内容在 `context_echo_claim_window_seconds` 窗口内认领已有的 `self_agent` 轮，不会重复入账；打字机分段会认领同一条。

turn 的时间取消息自身的产生时间（而非入账时刻），所以 `--lookback-seconds`、reprocess 复活、补偿回放带进来的旧消息在上下文里按真实时间线排序；会话的 `last_activity_on` 仍取入账时刻，旧消息不会把活跃时间往回拨。

#### 未回复层（context_since_last_reply_turns）

大于 0 时，transcript 在"分身自己最后一次发言"处切成两段：之前的进 `recentTurns`，之后的进 `sinceLastReply`——即"我上次说完到现在，会话里发生了什么、我还没回”。两段互斥，拼起来是完整时间线。提示词会显式声明这一层尚未被回复，避免模型以为已经答过。正在回答的这一批消息永远不会出现在自己的上下文里。

预算独立（默认 10KB，从近期轮次匀出），总量超限时的降级顺序是：skill 尾部 → recentTurns → sinceLastReply → memory → summary。

#### 引用透传

钉钉的引用回复（`quotedMessage`）会随消息一起解析、持久化并注入提示词与 transcript，只取一层、上限 1500 字、解析失败降级为无引用。一句「是的」带上它引用的原文，往往就足以消除歧义。引用图片保留自己的 `mediaId` 与源消息锚点；DWS 对引用文件不返回 `resources/fileId`，服务会按引用的源消息 ID 做一次有界回查后再解析，回查失败仍保留引用文字并降级为附件元数据。

### 权限分层与多 Agent（tier_standard_user_ids / tier_skill_packs / agent_templates_verified）

三档 tier 决定普通用户和自己各自能用到的 skills 与模板：

| tier | 触发者 | 内联 skill 包 | 模板 |
|---|---|---|---|
| `restricted`（默认） | 任何非白名单发送者 | `tier_skill_packs.restricted`（默认仅 证据与权限门禁、能力画像） | 现有无工具安全模板 |
| `standard` | `tier_standard_user_ids` 白名单同事 | 默认 `*` 全部包 | 同 restricted 模板 |
| `privileged` | 本人经 `@me <任务>` 或 `@机器人名 <任务>`（`robot_summon_names`，默认空）；单聊里同事经 `@你的名字 <任务>`（`peer_summon_enabled`，默认开）；群里同事经真实 @ 你 + `分身 <任务>`（`group_avatar_summon_enabled`，默认关；召唤词见 `avatar_summon_words`） | `*` | 高权限模板（独立验证位） |

"给普通用户少数 skills"通过内联技能包白名单实现；restricted 的外部工具能力由各 provider 模板限制，Codex 另使用 read-only 沙箱。模板注册表把逐字白名单从单模板推广为 (agent, tier) 模板表：codex restricted 使用 `read-only` 沙箱并关闭 shell、MCP、浏览器等外部工具，可通过 `agent_config_home` 指定 `CODEX_HOME`，qoder 逐字固定 `--model auto`，并以 `--tools "" --strict-mcp-config` 注册 restricted 条目，claude privileged 使用 `--permission-mode bypassPermissions --add-dir {agent_workspace}`；pi 两档逐字固定 `--provider openai-codex --model {pi_model}`（由 `pi_model` 展开，默认 `gpt-5.6-sol`），restricted 另以 `--no-tools --no-extensions --no-skills --no-prompt-templates --no-context-files` 关闭工具与继承资源，配置目录使用 `<agent_config_home>/pi`，与 qoder 一样没有 schema 参数、回复结构由解析器校验；opencode 仍为 blocked：它的工具关闭、沙箱与会话持久化只能经环境变量携带的配置设置，逐字比对无法背书。**qoder 没有 `--safe-mode` 也没有 `--json-schema`**，所以它的 restricted 档买到的是「无工具 + 无 MCP」的隔离，回复结构没有 CLI 层强制，只能由输出解析器事后拒绝——这是它与 claude 的真实能力差。启用任何模板都要求 `agent_templates_verified` 含对应 `agent:tier` 键，新模板先跑探针：

```bash
dingclawd verify-template --agent claude --tier privileged --config ~/.config/dingclaw/config.json
```

探针通过后由人工把键写入配置；命令本身不会修改配置。各 tier 有独立每日额度（`daily_agent_call_limit_by_tier`）。

### 打字机输出（typewriter_mode）

个人身份消息不可原地编辑，打字机以语义分段渐进发送实现：段落>句号>逗号切段，每段 ≤`chunk_max_chars`，段间 `chunk_interval_ms`；逐段确认，任一段不确定或失败即中止并抑制后续段（宁可少发不重发），job 状态由段状态推导。每段幂等 uuid 含段序号，同文本两段不会被 DWS 幂等吞并。默认 `off`——对同事场景多段即多次推送打扰，建议仅按 tier 对自聊开启（`typewriter_mode_by_tier.privileged=chunked`）。机器人 AI 卡片流式为预留项，契约未验证不实现。

### 常驻模式（run）：事件为主，轮询为兜底

`run` 的轮次有两个来源：DWS 长连接推来的**事件**把睡眠掐短（`event_listener_enabled` +
`event_subscriptions`），**间隔轮询**在没有事件时按点跑。事件覆盖到的会话，消息在
一两秒内就会催出一轮；覆盖不到的，等下一次轮询。这就是"事件为主、轮询兜底"的全部
含义——**事件永远只是加速器**：它不解析任何消息内容，门禁全部跑在轮次从 DWS 确认
读回的数据上；监听器死掉的唯一后果是回到轮询节奏，不会丢消息（游标安全由
`overlap_seconds` 保证，与轮询频率无关）。

**覆盖面的真相**（dws 只有三把事件钥匙，没有"全量消息"的全局键）：

| 流量 | 事件覆盖 | 没覆盖时 |
|---|---|---|
| 群里 @ 你 | `at` 键，一条订阅全局生效 | — |
| 自聊（`@me` 自测在内） | `o2o` 键订**你自己的 userId**，实测约 1.3 秒推达 | — |
| 白名单同事的单聊 | `event_subscribe_standard_tier=true` 自动为 `tier_standard_user_ids` 里每个 userId 派生一条 `o2o` 订阅（工号形如 `100001` 或 `WB00000001` 都算；openDingTalkId 是 `--user` 唯一拒收的形状，会被跳过并在 doctor 里说明） | 兜底轮询 |
| 名单外的人、新联系人的第一条消息 | **订不了** | 兜底轮询 |

每条订阅是一个常驻 consumer 进程（实测约 21MB），上限 32 条，超出时先丢派生、
永不丢显式配置。派生只在 listener 开启时发生；`event_listener_enabled requires
event_subscriptions` 这条门禁只认**显式**列表——名册的一次编辑不该能让实例起不来。

兜底间隔的取值取决于你对"承诺"的定义：只开事件唤醒时 300s 是合理姿态（事件负责快、
兜底负责不漏）；**开了下面的即时回执后建议 60s**——⌛ 是贴在对方消息上的显性承诺，
兜底轮是这个承诺在一切退化路径（事件未覆盖会话、事件通道故障、秒贴误判后的真实
回复）下的兑现上界，300s 的上界配不上秒级的承诺。代价同样要知情：名单外单聊的
最坏发现延迟等于这个间隔；未读快照持续溢出且全网静默超过 15 分钟时复判间隔同它。
活跃窗不受影响——近 `active_window_minutes` 内有新消息时仍以
`active_poll_interval_seconds`（默认 30s）快跑，quiet-hold 的"等对方打完字"靠的
正是它。兜底轮**救不回**中断到超龄的生成任务（遗弃判定窗大于消息年龄上限，被弃
即过期），别指望它做恢复。

**持续任务（`continuous_tasks_enabled`，默认关闭）**：开启后，一次明确召唤建立一项任务和一张进度卡。
同一会话、同一发起人的补充和纠正先保存，再取消旧执行并用合并后的输入继续；不会为每句话另发一份答案。
直接发送“停止”可停止当前任务。普通补充默认在最后一次输入后 120 秒内归入任务，跨会话、跨发起人和卡片回声不合并。
一旦最终回复开始发送，新输入建立下一项任务。停止只能拦截尚未开始的操作，已经发生的外部操作不会自动撤销。
开启要求 `self_command_enabled`、`card_delivery_enabled`、`card_open_before_generation` 同时为真。
持续任务卡片只展示公开进度和工具状态，最终答案确认投递后才标记“已回复”。卡片创建明确返回 `RATE_LIMIT_ERROR` 时，自动改用带 AI 角标的普通消息发送最终正文；降级选择和正文持久保存，续作或重启后不再反复建卡，投递重试不重新调用模型。
普通消息明确失败会退避重试；发送结果未知时仅在首次可能已发送的尝试起的 23 小时安全窗口内使用同一 UUID 重试，超过窗口保留记录待核对，不换 UUID 重发。取得发送回执后查询状态。卡片创建结果未知且无句柄时降级文本，已有卡片的更新失败只重试同一卡片。
明确召唤后，即使模型判断无需回复，或任务准备、执行失败、模型重试耗尽，也会保存简短结束或失败反馈并通过同一最终投递流程发送。普通非任务消息仍可静默跳过。持续重试受权限、发送就绪、停止和最新任务修订约束；外部服务持续不可用时保留待处理状态，不能据此认定已经送达。
配置参数见源码包里 `docs/配置参考.md` 的「持续任务与出站调度」。

**面板 `@`：让分身直接回复（`panel_reply_tier`，默认 `privileged`）**：在 `dingclawd tui` 里选中对方的一条消息（列表里则是该会话对方的最新一条）按 `@`，确认框写明档位和回复对象，按 `y` 后分身不起草，直接以你的名义回复（带 AI 标识）。默认 root 档，与 `@me 任务` 同一条任务通道，能执行命令、查内部系统后再回；命令面板可切到 `standard` / `restricted`。这类任务只接受你本人引用续接，对方后续消息不会接着驱动它；root 档回复里的凭据值会被打码。依赖 `continuous_tasks_enabled=true`。

**即时回执（`presence_instant_ack`，缺省关）**：事件到达的那几秒内就把
⌛「回复中」贴到对方消息上，不等轮次跑到生成阶段——消息→⌛ 实测 3-7 秒（下限是
钉钉推送延迟，毫秒级不存在）。开启要求 `presence_markers_enabled` 与
`event_listener_enabled` 同时为真（缺一 ConfigError）。它是 presence 层的受控
白名单，不是第二条摄取路径：事件载荷只决定"要不要贴表情"，门禁、游标、生成、
投递全部照旧。判定门全部 fail-closed：无文本（表情包/语音）不贴、陈旧事件不贴、
身份不明不贴、deny/robot/mute 不贴（deny 名单、机器人召唤名和即时回执开关在事件处理前按配置文件变化热读）；SUMMON 模式下只有开着召唤窗的会话才贴，例外是 o2o 键上同事的
`@你 <任务>`（`peer_summon_enabled`）和 `at` 键上同事的 `@你 分身 <任务>`
（`group_avatar_summon_enabled`），轮次会把它们当 root 档任务跑；自己发的消息只有
`@me <任务>` 形态贴（回复恰好锚在召唤行上），裸 `@me` 不贴（它的回复锚在积压
消息上）。

Stream 是最快路径但不是可靠队列。若把
`presence_owner_summon_probe_interval_seconds` 设为 2–60，守护进程会只对主人自聊
增加一条只读重叠探针：每个间隔读取一次最近窗口，把主人自己发出的单聊
`@me <任务>` 去重后交给**同一个**即时回执队列。活动持续任务中的普通补充和停止也会
请求优先读取，由权威轮次重新读取并通过全部业务门禁；这些提示不会直接贴回执或接纳输入。
探针本身不创建任务、不移动游标、不发送正文。0 为关闭，也是升级
默认值。开启还要求 `self_command_enabled=true`、恰好一个 `self_user_ids`、至少一个
`self_open_dingtalk_ids`，并为该 userId 显式配置 `o2o` 订阅。这样正常时仍由 Stream
抢先；健康 DWS 下，单条 owner-o2o 漏推会多出一个探针间隔以及读取、贴表情耗时。探针
把每次 CLI 读取限制在 20 秒，给高负载下的本机启动留出余量；失败时不做通用的
`--verbose` 原地重试，等待时间按配置间隔逐次翻倍至 30 秒（配置间隔更长时保持原间隔），
读取成功后恢复配置间隔。读取窗口至少覆盖最近 120 秒，避免短暂退避漏掉召唤；更长故障
仍依靠正常轮询及其时效门禁。停止服务可中断等待和在途读取。轮次若确实发现 owner 自聊任务
只能自己首贴，会立即只重建这一条 o2o consumer，不再被其他订阅的流量掩盖或连带重启
全部 consumer。

每次秒贴都写前登记进 `presence_acks` 表（行上带 `landed` 位：引擎的
dws 调用落地前为 0，轮次遇到 0 不收养而是自己贴——同一描述符重复 add 实测幂等，两枚
合一——并**接管**该行，计入 `presence_acks_taken_over`；引擎调用失败时只有服务端明确
拒绝（`declined…`）才释放行，超时/进程死亡说不清贴纸有没有先落地，行保留为 0 交给
轮次接管，若此时行已被撤回/清扫（`row=gone`）则顺手撤一次以防贴纸其实落了地；
成功后发现行已被撤回/清扫则把刚贴的贴纸再撤掉，记一行
`presence_ack_taken_down`，`reason=row_gone`，带 `removed`；撤不掉时把行按已落地
态写回，交给 TTL 清扫兜底），轮次要么**收养**这张贴纸
（任务锚点命中：不重复贴，交给既有 ⌛→✅ 生命周期）、要么**撤回**（锚点右移、
或 TTL=`max_message_age_seconds` 到期清扫；引擎登记过却没落地的行不再同步调 dws
撤回——那多半是根本没贴上的贴纸，负载下每枚都要再吃一个超时，还正好堵在 ⌛ 的
关键路径上——而是删行后交给修复队列异步撤，每轮限量，同步撤回失败也入同一队列）
——崩溃在任何一步留下的都是登记行而不是无主贴纸，关掉开关后上一代残留行也照样
被清扫。日志：每次秒贴一行
`presence_ack`（带 `source=event|owner_probe`、`event_id`、`conversation_id`、
`message_id`；`latency_ms` 是消息时间→贴纸落地的总耗时，另带三段拆分：`push_ms`
消息时间→事件行离开 consumer 管道，即守护进程之外的部分；`queue_ms` 到达→引擎开始
处理；`add_ms` add-text-emotion 子进程本身。`push_ms` 大说明问题在事件链路上游或
进程调度档，不在引擎。每行还带 `attempts`（dws 调用次数，引擎固定为 1）；dws 调用
失败的 `presence_ack_failed` 行带同样三段外加 `reason`——`timeout` / `unavailable` /
`cancelled` / `exit:<退出码>:<输出摘要>` / `error:…` / `declined[:服务端错误码]`——
`row`（登记行的下场：`released` 已释放 / `retained` 保留给轮次 / `taken_over`
轮次已接管 / `gone` 已被撤回或清扫）和 `taken_over`（`row` 是否为 `taken_over` 的
布尔缩写）；引擎自身异常（sqlite、解析边角）是另一种形状：`reason=engine_error`，
只带 `error` 文本，没有三段与 `row`，每分钟至多一行并带累计 `total`（已写下的
登记行留在未落地态，由轮次接管或 TTL 清扫收敛）；成功行的 `taken_over=true`
表示轮次在调用期间先贴了，两枚合一。子进程预算由 `presence_ack_timeout_seconds`
决定，缺省 30s，引擎不重试——单工人线程上再试一次等于让后面所有事件多等一整个
超时；轮次收养/接管/撤回进 `presence_acks_adopted` / `presence_acks_taken_over` /
`presence_acks_withdrawn` 计数；引擎闸门拦下的事件按原因计数并限频上报
`presence_ack_skipped`（同一 reason 每分钟至多一行，带累计 total）——"引擎根本
没收到事件"和"收到了但被闸门拦下"从此在日志里可区分。轮次对引擎从未登记过的
消息自己首贴的 ⌛ 进 `presence_marks_fresh` 计数，它同时是下文投递活性规则的证据
来源；接管引擎未落地行的那次贴不算（事件明明到了，只是秒贴输了竞速）。
已知食言率来源（贴上后静默撤回）：ranked
unread 已读跳过、coalesce 锚点右移、SUMMON 消费轮后到达的尾消息、以及事件带出了
文本但轮次判定内容形态不支持的消息（超长文本引擎已同口径拒贴）——这是"先秒回
表情再干活"形态的固有属性。另外与开关无关：轮次侧的 ⌛ 现在在**请求构建之前**
就贴上（带图消息不再等图片理解付费），任务确定要跑的那一刻就是贴上的那一刻。

每一轮的日志都带 `wake` 字段说明自己为什么醒（`event` / `interval` / `immediate` /
`startup`）和 `events_seen` 累计数——事件通道死没死，从此是日志里的事实而不是推断。
一次性 `poll` 命令的输出没有这两个键，键的有无就是两种模式的区分。launchd 定时
`poll` 模式不受本节影响。

**订阅武装（arming）与自动重弹**：实测（2026-08-24）钉钉个人事件的服务端只把
"**长连接已建立时**发出的订阅 create"绑定到当前连接上推送；贴着 socket 出生前后
那几秒发出的 create 会返回成功、sublist 里 `status=1`，却永远不投递。daemon 冷启动
恰好就是这个形状——第一个 consumer 还在拉起 bus，所有订阅已经创建完了。而 dws 的
consumer 无论是否带 `--ephemeral`，退出时都会按"归属清理"注销自己创建的订阅（只有
`--subscribe-id` 复用的订阅才保留），所以每次重启都是一轮"注销 + 贴着新 socket 重建"，
事件通道时好时坏的全部来源就在这里，与订阅条数无关（14 条订阅在温 socket 上照常
投递）。对策是常驻的 rearm monitor：探测 bus 的出生时间，发现某个 consumer 的当前
代际是贴着 socket 出生 spawn 的，就在 socket 温热（约 25s）后把它弹跳一次——退出
注销、重生重建，这一次 create 落在温 socket 上，稳定武装。bus 的 `bus.log` 出现
`personal source reconnecting`（原地换连接、本地无进程死亡）时同样重弹。第三条
触发是**投递活性**：前两条规则在 2026-08-24 的一次线上失聪里双双失手——温 socket
上重建的订阅登记在案、consumer 健在，却 18 分钟一条不投；这种形状只有结果可观测。
于是轮询本身充当探针：某一轮不得不为一条引擎连登记行都没写过的消息自己首贴 ⌛
（`presence_marks_fresh`，说明秒回引擎没等到事件；接管引擎未落地行的贴不算）而
listener 的累计投递数又纹丝不动，即记一次证据；证据跨静默轮
保持、任何一条投递即清零，攒满两次就把全部 consumer 弹跳一次（冷却 15 分钟，冷却
内攒出的证据作废——那多半是重建订阅还在服务端传播时攒的）。该规则只在秒回引擎
武装时生效（引擎不在场时轮次首贴是常态而非证据），重试复活的补贴纸也不计证据。
三条规则共享一份状态：daemon 启动、socket 换代、任何一次重弹之后的温热+传播期
（约 45 秒）内活性证据一律作废，纪元重弹同时清证据并计入冷却——活性规则不会抢在
cold_socket 前面开火把 consumer 弹出纪元规则的覆盖范围，也不会在传播期里重复弹跳。
每次重弹在日志里是一行 `event_listener_rearm`（带
`reason=cold_socket|source_reconnect|delivery_liveness`）；监视器任何一环失效的
后果只是"没有这次助推"，轮询兜底不受影响。冷启动后事件通道达到可投递的时间约为
一分钟，这一分钟内照常由轮询覆盖。

从带 `--ephemeral` 订阅的旧版本（0.3.0 及更早）升级时不再需要手工 `dws event stop`
清墓碑：所谓"墓碑"其实就是上面的武装缺失，rearm monitor 起来后第一次重弹就会修好。

## 未读历史消息一次性补偿

补偿采用不可变计划和显式执行两阶段，不能用扩大 `pull-now` 时间窗或清空状态库替代：

```bash
dingclawd compensate-unread plan --count 100 --config ~/.config/dingclaw/config.json
dingclawd compensate-unread execute --run-id <run_id> --yes --config ~/.config/dingclaw/config.json
dingclawd compensate-unread status --run-id <run_id> --config ~/.config/dingclaw/config.json
```

`plan` 会读取未读会话和历史消息并把不可变计划写入本地状态库，但不会调用 Agent 或发送消息。它按 `unreadPoint` 从每个会话最后一条消息向前恢复未读后缀；单聊保留全部符合身份策略的消息，群聊仍只保留未读后缀中明确 @ 当前用户的消息。如果 DWS 返回数量达到 `--count` 上限、单聊对方身份无法解析、历史分页无法完整恢复未读边界，计划状态为 `INCOMPLETE` 或直接失败，禁止执行。

首次 `execute` 会重新读取未读会话并严格比较快照；快照、个人 Skill 或发送身份有变化时要求重新生成计划。执行必须显式传 `--yes`，复用正常服务的租约、动态黑名单、每日 Agent 预算、发送幂等和状态对账。单次最多创建 `max_jobs_per_poll` 个回复任务；若 `remaining_messages` 非零，可对同一 `run_id` 再次执行，仍属于同一次幂等补偿。任务进入 `ACCEPTED` 不代表补偿成功，只有状态对账为 `SEND_SUCCEEDED` 才是成功投递；`SEND_UNKNOWN` 不自动重发。

## Agent Skill

一共三份 Skill，**分开的是授权范围，不是章节**：

| Skill | 在哪 | 能做什么 |
|---|---|---|
| `dingclaw-install` | 交付包根目录（仓库内 `install_skill/SKILL.md`） | 装机。装的时候还没有 `dingclawd`，所以它不随包分发 |
| `dingclawd` | 随 Python 包 | 运维：`doctor`、`status`、`pull-now`、`compensate-unread`、`blacklist`。**能触发调模型并真实发消息的轮次** |
| `dingclaw-inbox` | 随 Python 包 | 只读：`inbox`、`conversations`、`messages`、`timeline`、`search`。不发消息、不起草、不清红点、不静音、不切模式、不下附件 |

后两份可安装到 Codex、Claude Code、Qoder CLI：

```bash
dingclawd skill path                                    # 两份都打印
dingclawd skill install --agent all                     # 两份都装
dingclawd skill install --agent claude --skill dingclaw-inbox   # 只给读的那一份
```

**只装 `dingclaw-inbox` 是一个有意义的选择**：想让 agent 回答「我错过了什么」的人，不必同时把「以我的身份说话」交出去。反过来，运维 skill 不包含读收件箱的命令。

已存在不同内容时默认拒绝覆盖；确认替换时显式增加 `--force`。三类 Agent 安装的内容来自同一份 package data，Skill 只编排本程序 CLI，不绕过程序直接操作 DWS 或 SQLite。

安装以 (Agent, Skill) 为单位原子且可回滚，不承诺跨目录的整体事务；中途失败时 CLI 输出 `atomicity=per_agent_skill`、已完成的 `results`、`failed_agent`、`failed_skill`、完全没动过的 `pending_agents` 和逐条列出的 `pending`，调用方可修复后幂等重试。

### 只读命令

面板的四个视图各有一条一次性 CLI，输出单行 JSON，**都不会清红点**：

```bash
dingclawd inbox --hours 24            # 未读 / 钉我 / @我 / 提醒，一个会话一行
dingclawd conversations --limit 50    # 会话列表
dingclawd messages --conversation-id <cid> --limit 20
dingclawd timeline --hours 24         # 跨会话按时间，最新在前
dingclawd search --query "发布" --days 14
```

面板这一侧的默认其实一样，**打开一个会话同样不清红点**——「未读保持」出厂就开着，只有显式按 `R` 才调 `clear-red-point`；按 `^R` 关掉保持之后，打开会话才会顺手清掉，代价是分身的未读门禁跟着失效。`unread_gate_enabled` 读的是服务端未读状态，清红点会让分身以后不再处理那个会话——这个副作用该由看着屏幕的人决定，所以 CLI 这一侧连一条显式清的子命令都没有。写的那一半（发送、起草、清红点、静音、切模式、下载、触发）**不是加了 `--yes` 门禁，是压根没有子命令**。

## 安全边界

配置和运行日志不得提交到仓库。子进程调用不经过 shell，聊天正文通过标准输入交给 Agent。自动发送必须同时满足 `send_enabled=true`、真实契约验证、身份拒绝列表完整和 DWS OAuth 有效；机器人模式还要求机器人编码存在。服务会在每一次真实发送前重新加载门禁配置，并复核 DWS 组织和本人 ID，生成期间关闭门禁或切换授权都会阻止发送。DWS 接受任务不等于真实收件；服务会继续查询任务状态，也不会把未知发送结果当作成功。

自动发送使用逐字比对的 (agent, tier) 命令模板表和经安全校验的可执行路径，禁止追加工具、权限绕过或未知参数。四个 CLI Agent 各有条目（opencode 另列为封禁），但列在表里只是「允许尝试」，不是「允许发送」——每一条都还要在运行它的那台机器上跑过 `verify-template` 并由人工写入 `agent_templates_verified`。状态库首次初始化时同时绑定 `dws_profile` 和发送身份指纹（实际授权用户、发送模式、机器人 Code、DWS 路径等）；之后若切换组织或发送身份但复用同一 SQLite 文件，服务会在处理旧回复前直接拒绝启动。

从未包含 `self_open_dingtalk_ids` 的旧版本升级时，发送身份指纹会变化，禁止原地复用旧状态库。升级步骤必须按顺序执行：

1. 先卸载旧 launchd 任务，确认没有轮询进程。
2. 只读核验旧库不存在 `GENERATING`、`GENERATED`、`SENDING`、`ACCEPTED` 或 `RETRY_PENDING` 等非终态业务记录；存在时停止升级并人工处置，不能轮换。
3. 将同一状态库的 `state.db`、`state.db-wal`、`state.db-shm`（存在才处理）一起移动到同目录的同一时间戳备份名，保留恢复能力；不得只移动主文件。
4. 补齐并以 `0600` 保存配置中的 `self_open_dingtalk_ids`，运行 `doctor`，再用新程序创建全新的状态库。
5. 使用不超过 `max_message_age_seconds` 的 `pull-now --lookback-seconds` 做有界回放，核对结果后再 bootstrap launchd，并读回 launchd 状态和下一次 180 秒定时输出。

本仓库不自动迁移旧发送身份指纹：自动放宽或改写指纹可能让旧身份生成的回复被新身份发送。上述轮换只适用于已确认没有非终态业务记录的状态库。

launchd 任务由 `dingclawd init` 生成到 `~/Library/LaunchAgents/com.dingclaw.<你>.plist`，
但**不会自动加载**。确认 `doctor` 无错、`poll` 跑通一轮之后再手工加载：

```bash
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.dingclaw.<你>.plist
```

label 按 owner 生成，所以同一台机器上的多个实例不会互相顶掉。plist 跑的是 `run`
（常驻循环）而不是 `poll`：两者是完全独立的代码路径，只有 `run` 里有自适应调度和事件监听。

## AI 标识

**每一条发出去的消息都必须让收件人知道不是人手打的**，这条不可关闭。

只有纯文本的 `chat message send` 与引用回复 `chat message reply` 有 DWS 原生 `--ai-tag` 角标，`send-card`（流式卡片）
和 `send-by-bot`（机器人）都没有。所以正文里的固定文案（`ai_disclosure_text`，
默认 `[AI 分身代发]`）才是覆盖全部三条路径的那道保障，**置空是就绪错误**。

`--ai-tag` 现在每次发送都显式声明（`ai_tag_enabled`，默认 true），不再继承 DWS CLI
的默认值——上游翻了默认值会让角标无声消失，且没有任何信号。

标识只加一次：注入点在投递收口且在打字机分段之前，所以一条回复标一次、标在第一段；实时流的中途帧各自整体覆写、各自带标识，读者仍只看到一次。

## 许可证

dingclaw 的后续新版本以 [GNU GPL v3.0 or later](https://spdx.org/licenses/GPL-3.0-or-later.html) 发布；已经发布的历史版本继续适用其随版本提供的许可证。TUI 单文件内嵌依赖的
许可文本与版权声明随 wheel 保存在 `dingclaw/tui_app/THIRD_PARTY_LICENSES.txt`。DWS CLI 是
独立的上游项目，使用其自己的许可证。

即时回执引擎在启动时创建；启动时关闭 `presence_instant_ack` 的实例首次开启需重启。已创建的引擎可以通过热配置关闭及恢复。
