dsh-email-todo
Verifieddsh-email-todo · v1.0.12 · MIT · Web UI
邮件待办:读取 dsh-email 的邮件内容,用 LLM 把邮件里的工作事项提取成可管理的待办清单,并在 Web 客户端右侧 Sidebar 提供一个「待办」tab。
Install
dsh plugin add dsh-email-todo Confirm the layer applied with dsh --profile default --dump-config — see the install guide.
Source
Published to npm without a public repository. Inspect the package contents before installing.
Tags
Creators
Readme
dsh-email-todo
邮件待办:读取 dsh-email 的邮件内容,把邮件里的工作事项提取成一份可管理的待办清单, 并在 DSH Web 客户端右侧 Sidebar 里提供一个「待办」tab。
组成
| 文件 | 作用 |
|---|---|
index.js |
Host 半边:调用 dsh-email 的 email_list / email_read 工具读邮件、提取待办、去重、持久化,并暴露同源 JSON 路由 POST /_dsh/dsh-email-todo/api。 |
client.js |
Client 半边:注册一种右侧 Sidebar 的 tab 类型(ctx.sidebarRightTabs.register,id/kind dsh-email-todo / email-todo),并挂上它的正文席位 sidebar.right.pane.tab#dsh-email-todo 与引导页入口。 |
cordis.patch.yml |
插入 email-todo 这一行,并给出默认配置。 |
Host 与 Client 之间走同源 HTTP 路由:这是已安装 bundle 的常规桥接方式
(客户端 ctx.remote.<ns> 需要 @Remote 生成的声明,纯 JS Host 半边没有)。
前置条件(很重要)
必须先装并启用 dsh-email。
本插件的邮件全部来自它提供的工具(email_list / email_read / email_watch / email_health),
但它不是本插件的包依赖——装 dsh-email-todo 不会连带装上它。正确顺序:
- 在「插件」面板安装并启用
dsh-email,在它自己的设置页配好邮箱账号并确认能收信; - 再安装并启用
dsh-email-todo; - 重启 DSH(安装/替换 bundle 需要重启才会加载新的模块代)。
缺 dsh-email 时的表现是明确的,不会让你猜:
- 启用
dsh-email-todo后,插件详情页会出现一张引导卡(plugins.bundle.activation席位); - 待办面板顶部会显示「未检测到 dsh-email」横幅(Host 通过
state.deps.email自检); - 点同步会直接报「未检测到 dsh-email:…请到「插件」面板安装并启用 dsh-email…」。
另外,本插件 1.0.2 起注册了设置页,因此要求 DSH 提供 @deepseek-ai/dsh-client-ui-settings
(dsh.client.inject 里声明了它);过老的 DSH 构建会因为缺这个客户端包而整体不加载。
使用
两个入口,同一个面板、同一份清单:
| 场景 | 入口 | 席位 |
|---|---|---|
| 有会话(聊天中) | 会话 header 右上角的「待办」图标 | conversation.session.header.utilities(session 作用域) |
| 没有会话(起始页等) | 窗口右上角的同一个「待办」按钮(浮层) | shell.overlay(root 作用域) |
两者互斥显示(同一位置只会有一个),角标 / 悬浮列表 / 点击打开面板的行为完全一致。 平台把"右上角工具区"定义为会话的一部分,所以没有会话时那个席位整块不渲染, 浮层入口就是补上这一段(判断依据是"会话 header 入口有没有挂载",不依赖运行时钩子)。
入口一:会话 header 右上角的「待办」图标(
conversation.session.header.utilities,order 5, 和「在应用中打开」「导出会话日志」同在一排;该席位是 session 作用域,所以要开着会话才可见)。- 角标 = 未完成待办条数(0 时不显示,>99 显示
99+); - 悬浮弹出下拉列表:前
POP_ITEM_LIMIT(5)条未完成待办,每条带来源(发件人 · 主题), 超出部分显示「还有 N 条…」,底部提示可点开面板;鼠标离开或按 Esc 关闭; - 点击直接打开右侧 Sidebar 的待办页(复用右栏自己的
commandTarget+openTabFromTarget: 起始页占着格子时让它让位,该分栏则落在当前活跃分栏,并自动展开右栏)。 - 待办页本身显示在屏幕上时这个图标不占位(读的是本 tab 自己的
useTabInfo().tab.visible), 所以「收起状态下它在那儿,打开后它让位」。
- 角标 = 未完成待办条数(0 时不显示,>99 显示
入口二:右侧 Sidebar 的起始页胶囊——从 tab 条的添加控件进入起始页,点「待办」。
点「同步邮件」:一封一封地读窗口内的邮件,每处理完一封就写入清单并显示出来 (面板副标题实时显示
第 3/10 封 · 正在处理「主题」 · 已新增 2 条),不是等全部跑完才一次性输出。面板里可以手动添加、内联改名(点标题)、勾选完成、忽略、忽略已完成、清空全部。 每条待办是两行,为的是「一眼看重点,细看有出处」:
左 右 第一行 来源图标 + 一句话说清要做什么(点一下可就地改名) 待办时间:模型给的截止日期( 9月30日/Sep 30);已过期标红;信里没写时间就显示**「时间未定」**第二行 来自谁发的哪封信: 来自 <发件人> · <邮件主题>附件数量:📎N,悬浮列出附件文件名 来源图标区分待办是怎么来的,加新来源只在这张表里加一行:
图标 含义 📧 邮件同步生成(按钮「📧 同步邮件」同一个图标) ✍️ 手动添加(按钮「✍️ 添加」同一个图标) 📌 其他来源(预留;未知来源不会空着) 主题兜底生成的待办不再重复显示主题(标题就是主题);手动添加的条目第二行不再写「手动添加」 ——标题前的 ✍️ 已经说清了,重复的文字反而占地方。
手动添加会调一次模型做语义解析(见下文「手动添加」一节):把「下周三下午跟张三对一下方案」 解析成标题「与张三对齐方案」+ 时间
2026-10-08;解析失败也绝不丢输入,原样存成标题。只是被抄送(知会)的邮件原则上不生成待办;万一模型从里面挑出了"点名要求你本人办"的事, 第二行会带一个
抄送标记,说明这条待办的出处。仅抄送(知会)的处理:怎么算"我自己"——先看配置
selfAddresses, 留空就用email_health里账号自带的地址自动探测(provider / 账号地址 / IMAP …), 两者都拿不到就按「未知」处理(不会因为找不到自己就把邮件当抄送,宁可多生成也不漏)。 判定为"自己在cc、不在to"时:规则引擎直接不生成(这类邮件是噪音的主要来源), 模型收到的卡片里带收件方式:仅抄送(收件人只是被知会),并被要求只在信中明确点名要求本人办理时才生成。 报告里会写其中 N 封仅抄送,未生成待办,逐封摘要里这些邮件显示 0 项——判断依据可见。同步完会多一条可折叠的摘要条:默认收起,只显示
同步摘要:读取邮件 N 封,生成待办 M 项(点一下展开),右侧是这次同步的时间。 展开后是两列表格——邮件标题(下面一行灰色的主题/要求/时间,即模型给的结构化摘要)与待办数量; 表头吸顶、列表可滚动。
角标的数据来自同一条 POST /_dsh/dsh-email-todo/api(action: "state"):挂载时读一次,
之后每 30 秒(页面可见时)、窗口重新获得焦点时、以及每次悬浮时各刷新一次。
同步是客户端的一条模块级任务,不挂在面板实例上:一次同步会跑很久(每封邮件一次模型调用),
而面板随时会被关掉、切走、重开。所以「正在同步」「本次报告」「失败原因」都存在一个模块级
syncJob 里——关掉面板再打开,按钮依然是禁用态、进度继续显示(同步中… 已 Ns,每秒走一格),
请求本身也不会因为组件卸载而被丢弃;另一个会话的面板看到的是同一次同步,
重复点击不会发出第二个请求(Host 端串行执行,客户端提前 return)。
同步期间客户端还会每秒拉一次 state,把 Host 逐封落盘的结果实时并进共享快照——
所以清单是"一封一封长出来"的,角标和进程也不会等最后那一下。
tab 的 chip、关闭、分栏、全屏、保活全部沿用右侧 Sidebar 原本的逻辑(keepMounted: true,
切回本 tab 或重新展开右栏时自动刷新一次清单),没有另起一套面板实现。
未配置邮箱时同步会直接报出 dsh-email 的提示(例如「未配置邮箱账号」),面板里显示错误并提供重试。
同步引擎:LLM 语义处理(默认)+ 规则兜底
同步的核心是语义判断:一封邮件要不要行动、要做什么、什么时候前做完。这部分交给部署的默认模型
(ctx.llm + agentDefaultModel),规则引擎保留为预处理与兜底。
为什么是直接调 ctx.llm,而不是起一个子 Agent:这是一次「读文本 + 按固定格式作答」的分类抽取,
不需要模型调用工具或多轮行动;子 Agent 会带一整套自己的系统提示与工具、并作为一个子会话出现在界面里,
成本、延迟、副作用都更高。需要模型动手做事时子 Agent 才划算——这里的模型只需要"读和判断"。
两个版本化契约(稳定性的来源)
① 邮件卡片 v1(llm.js:mailCard)——一封邮件唯一进入提示词的东西:
| 字段 | 来源 | 说明 |
|---|---|---|
v |
常量 | 契约版本,模型回复里必须回带同一个值 |
uid / subject / from / date |
邮件自身 | 身份与归属 |
body |
email_read 的正文 |
先剥引用回复/签名/URL、折叠空行,再按 llmBodyChars 截断 |
truncated |
计算 | 是否被截断 |
卡片不含运行时间,字段顺序固定,所以同一封邮件永远序列化成同样的字节 → 同样的哈希。 当前日期写在提示词的抬头里(用于推断截止日期),不进入卡片。
② 回复 v1(llm.js:readReply)——模型必须给出的结构:
{"v":1,"mails":[{
"uid":47,
"category":"request|notice|report|receipt|promotion|system|other",
"actionable":true,
"summary":{"topic":"…","ask":"…","when":"…","text":"…"},
"items":[{"key":"stable-slug","title":"…","due":"YYYY-MM-DD","dueText":"…",
"priority":"high|normal|low","duplicateOf":"…","quote":"…"}]
}]}
读取是逐字段修复的,不是"全对才收":枚举越界退回默认值、日期不合格式清空、标题过短或 slug 重复的条目丢弃、
未知 uid 丢弃、模型漏答的邮件标记为 omitted、schema 版本不符或完全不是 JSON 则这封判失败。
一次调用的回复不可用时只重试一次(附上一句"上次回复不可用"的提醒),再失败就按 engine 的语义处理:
auto 让这封改用规则引擎(记进报告),llm 直接报错。
被 maxTokens 截断时不走重试,而是把输出上限翻倍再试一次(上限 16000,MAX_TOKEN_ESCALATIONS = 1)——
对"边想边写"的模型,继续加预算只会买来更多思考;还不行就这一封改用规则引擎。
翻倍重试计入 llm.retries,不算错误:只有真的退回了规则引擎(llm.fallbacks > 0)才在面板显示错误行,
否则只显示一行 输出过长已加倍重试 N 次。截断的错误信息会带上"其中思考内容 N 字"。
一次调用只读一封邮件,处理完一封就落盘
这是整个流程的骨架,也是"慢"与"体验"两个问题的答案:
- 不限制一次同步读多少封:
limit默认 100(邮件工具自己的上限),窗口内的邮件一封一封过; - 不合并:每次模型调用只带 一封 邮件。提示词(以及模型的思考)因此与单封邮件成正比, 慢模型不会因为"一次要写 10 封的 JSON"而超长、超时;
- 边处理边落盘:每处理完一封就
saveState,并更新state.progress(running/done/total/uid/subject/created); - 客户端每秒读一次 state:同步期间列表是"一封一封长出来"的,面板副标题实时显示
同步中… 已 Ns · 第 3/10 封 · 正在处理「主题」 · 已新增 2 条,不需要等最后一次性输出; - 进度不会残留:同步正常结束或抛错都会清掉
progress;进程若在同步中途退出, 重新加载状态时也会被清空(emptyState里就没有它)。
因为一封就是一封,缓存与幂等也按封算:某封失败只影响那一封,其余照常写入、照常显示。
刷新页面 / 关掉标签页会怎样
Host 的同步不会中断。 同步的循环不看 HTTP 连接,页面刷新只是让浏览器掐断了回包; 已经处理完的邮件照常落盘,剩下的继续一封一封处理。
- 刷新后新的页面会自己接上:
state.progress.running是"正在同步"的权威依据, 面板据此保持按钮禁用、每秒轮询并继续显示进度(用时从 Host 记的progress.startedAt算); 同步结束后还能把lastSync还原成「本次同步」的报告行——即使这次同步是本页打开之前发起的。 这条链路必须走接口:同步把progress写进状态文件,而每次state请求都会经过normalizeState重新装载一次——它必须把仍在跑的progress带过去,否则接口永远返回progress: null,刷新后的页面就无从知道同步还在跑(这正是 2026-09-29 那次"重启+刷新 按钮仍不禁用"的原因)。同时它不能无条件继承:进程若崩在同步中途,磁盘上会留下一条running: true,所以只有startedAt晚于本次加载时刻的进度才算数。 - 并发保护:Host 侧有一个进程内标志,同步进行中再来的
sync会被拒绝 ({ok:false, error:{code:'sync-running'}}),避免两份 state 互相覆盖。 客户端也用同一依据禁用按钮,所以两个窗口/双击都不会发出第二次同步。 - 死连接不会牵连进程:响应写入包在 try/catch 里。长同步结束时页面可能早就关了, 往断掉的 socket 写会抛错,而异步 HTTP 处理器里逃出去的错是 unhandled rejection, Node 默认会直接结束进程——"丢一个响应"可以接受,"把 dsh 一起带走"不可以。
思维链的处理(本地模型即使 reasoningEffort: off 也会吐思考内容)
两种形态都要能活:
| 形态 | 表现 | 处理 | 报告字段 |
|---|---|---|---|
| 独立通道 | 提供方把思考放在 reasoning 通道(适配器映射成 reasoning-delta) |
根本不进正文,只计数 | llm.reasoningChars |
| 混进正文 | 思考跟着正文一起输出(本次部署的情况) | 先剥掉闭合的思维标签( thinking/<thinking>/◁think▷ 等),再做 JSON 提取 |
llm.thinkingChars |
正文解析不依赖"第一个 { 到最后一个 }",而是枚举所有可解析的 JSON 对象候选(字符串与转义感知,
跳过 {模板} 这类散文括号),再挑 mails 条目最多的那个、同分取最后一个——于是思考里"把示例 JSON 念一遍"
不会被误当成答案;散文里漏写一个 { 也不会把真答案藏起来。截断时的错误信息会带上"其中思考内容 N 字"。
思维链救不回来,只能预算:它照样占 maxTokens。看到 llm.thinkingChars 大、llm.fallbacks 上升时,
按顺序调:llmBodyChars 降到 900(正文短,思考就短)→ llmMaxTokens 提到 6000。更彻底的路子是让
提供方/适配器把思考放到 reasoning 通道(thinkingFormat 那一项的对齐),那时 llm.reasoningChars 会有值、
thinkingChars 归零。
同步很慢怎么看
一次同步 = 每封邮件一次模型调用,所以慢是线性的、可见的,不是卡住。看报告:
| 现象 | 含义 | 处置 |
|---|---|---|
| 每封之间的间隔很长 | 单次调用本身慢:模型在正文里长篇思考 | 降 llmBodyChars(最有效);llmMaxTokens 别盲目加大 |
llm.fallbacks 上升 |
有些邮件反复超长,最后用了规则引擎 | 同上;这些邮件下次同步会再交给模型 |
| 每封都很快、整体仍久 | 邮件多(limit 默认 100) |
降 limit 或 days,按窗口收敛 |
| 进度条长时间不动 | 当前这一封在思考 | 正常;llmTimeoutMs 是单次上限,超了才会中断 |
给慢的本地模型的一组起点配置(写进 profile 的 cordis.patch.yml,config 层热生效):
- id: email-todo
config:
llmBodyChars: 900 # 正文喂短一点,思考也短(速度的主要旋钮)
llmMaxTokens: 3000 # 单封输出上限
llmTimeoutMs: 300000 # 单次超时放宽:慢没关系,别中断
limit: 30 # 一次看最近 30 封(默认 100)
待办的状态与词汇表(一个概念一个词)
清单上的每条待办只有两种显示状态,加两种记忆状态;是否重新生成,由这张表决定—— 而且要分同步模式(见下):
| 状态 | 怎么进入 | 手动同步(完整) | 自动轮询(增量) | 数据 |
|---|---|---|---|---|
| 待处理 | 首次同步生成 | 判定重复 → 不生成 | 跳过这封邮件 | todo.done === false |
| 已完成 | 在待处理列表勾选复选框 | 判定重复 → 不生成 | 跳过这封邮件 | todo.done === true |
| 已忽略 | 在待处理/已完成列表点该行的 ✕,或点「忽略已完成」 | 判定重复 → 不生成 | 跳过这封邮件 | ignored[] 一条记录 |
| 清除忽略记录后 | 点「清除忽略记录」 | 重读并重建 | 不重建(等手动) | 记录被清空 |
| 清空全部待办后 | 点「清空全部待办」 | 重读并重建 | 重建(缓存也清空了) | 全部归零 |
动作只有三类,词汇一一对应:
- 忽略(行尾 ✕ /「忽略已完成」):把待办从清单移除,并记住"别再生成"。 同一动作的两个入口——单条用 ✕,整批已完成用「忽略已完成」——所以共用"忽略"这个词。
- 清除忽略记录:忘掉这些"别再生成"。下一次手动同步会把对应邮件重新读一遍并重建待办 (id 与首次一致);自动轮询不会替你重建——重建是人的决定。
- 清空全部待办:清单 + 忽略记录 + 邮件缓存一起归零,等于回到初始状态;之后自动轮询也会重建。
为什么"已忽略"必须让整封信不重读:邮件一旦重读,模型会换一种说法把同一件事再写一遍 (实测:忽略掉「关注决赛通知并准备决赛」,重读后变成「准备企业文化宣讲决赛并关注通知」), 而忽略记录里存的是旧措辞,拦不住新措辞。所以规则定在邮件层: 只要这封信还有一条删除记忆在,它就不重读——直接从源头杜绝"换个说法又冒出来"。
两种同步模式:自动只捡新邮件,重建必须手动
| 手动「同步邮件」 | 自动轮询 | |
|---|---|---|
mode |
full |
incremental |
| 处理范围 | 按状态表完整重算窗口内邮件 | 只处理没见过的邮件,或列表指纹变过的邮件 |
| 清除忽略记录后的待办 | 重建 | 不重建 |
| 换模型/换引擎后 | 不重算(已有条目的邮件连正文都不重读;只有"当初什么都没产出"的邮件会再看一眼) | 不重算(见过就是见过,不让无人值守的轮询批量喂模型) |
| 清空全部待办后 | 重建 | 重建(mailLog 已清空 ⇒ 全部属于"没见过") |
增量模式的判定只看一件事:mailLog[uid].list 与这次列表行是否一致。故意不看引擎指纹、
也不看状态表——所以它永远不会去动"你已经决定过的事"。要重算,就点一次「同步邮件」。
自动轮询:调度在 Host,每 30 秒增量探测一次
调度属于核心 SOP,所以它放在 Host:客户端起定时器只在页面开着时有效,还会被浏览器降频、 冻结、丢弃。放在 Host 之后,关掉浏览器期间产生的待办,下次打开页面就在列表里。
一轮的流程(installAutoSync):
dsh 启动
└─ ① 先跑一次**增量**同步 ← 只捞停机期间没见过的新邮件;同时给 email_watch 建基线
(必需:email_watch 的游标只在 Host 进程内存里,重启后第一次调用只建基线、报 0 封)
└─ ② 之后每 autoSyncSeconds 秒:只调用 email_watch 做增量探测
├─ newCount = 0 → 本轮结束(不列全表、不下正文、不调模型)
└─ newCount > 0 → 跑一次**增量**同步(列表 + 只读新邮件正文 + 只对新邮件调模型)
| 情况 | 一轮的开销 |
|---|---|
| 没有新邮件 | 1 次 email_watch(增量探测,只查比游标更新的未读信封) |
| 来了 1 封新邮件 | 该次探测 + 一次增量同步:1 次 email_list + 只读那一封的正文 + 那一封的模型调用 |
| 某封信正文变了 | 列表指纹变化 → 重读该封(其余仍跳过) |
| 你清除忽略记录之后 | 轮询什么都不做——重建要等你手动点「同步邮件」 |
- 只有一个开关,而且不在面板上:自动轮询恒开,间隔取配置
autoSyncSeconds(默认 30 秒, 写成0即关闭);是否只看未读取配置unreadOnly(默认false= 全看)。 面板上因此只剩一个「📧 同步邮件」按钮——选项收进配置,界面不再堆勾选框。 - 失败指数退避:探测或同步失败时按 2 倍退避(最长 10 分钟),成功即恢复。
- 与手动同步共用一个锁:Host 的
sync-running保证自动与手动不会并发。
email_watch 是工具,不是事件。 它是拉取式的函数调用(游标式增量:首次建基线,之后只报告
比游标新的邮件);dsh-email 不对外发事件、也没有可订阅的服务,所以这里本质仍是轮询,
只是探测这一步足够便宜。要真正的事件驱动,得让 dsh-email 在新邮件到达时发事件(上游改动)。
幂等性:同一封邮件不会变成两份待办
- 一封邮件只在它第一次产出条目时贡献清单内容。 后续同步(换引擎、缓存失效等)重新处理它时,
抽出的条目一律不再追加——判据是邮件 uid,与文案无关。
为什么需要这条:
sourceKey里的 slug 由模型给出,退到规则兜底时会换一个 slug,于是"这封信 处理过"对 sourceKey 不可见;靠标题相似度兜底又拦不住同一封信的两种说法(实测 「参加9月24日AI+专项周会及AI知识库分享会」vs「本周周会改至周四(9/24)14:00—15:00」 相似度为 0.000——数字不同会被similarity直接判 0),结果同一封信在清单上留下 2~3 条。 首次处理仍然最多产出maxItemsPerMail条(一封邮件本来就可以有多件事),这条只封"后续追加"。 代价:换更好的模型后,已经产出过条目的邮件不会自动重算——想让新模型重来一遍,用 「清空全部待办」(它同时清掉邮件缓存)再手动同步。报告里的repeatSkipped会告诉你 这一次有多少条因此被丢弃。 - 内容哈希缓存:每封邮件处理完记一条
mailLog[uid] = {hash, engine, at, created, ignoredKeys}。hash是卡片哈希(正文一改就变),engine是引擎指纹(rules:v1:<每封上限>或llm:v1:<provider>/<model>:<每封上限>)。 引擎指纹只对"当初什么都没产出"的邮件有意义:换模型/换引擎时,那些邮件会被重新看一眼 (本来就没抓到东西,新模型值得再试);而已经产出过条目的邮件不重算——连正文都不重读, 也不会替换或追加条目(见下面第 0 条)。 缓存判定按上表逐条对应:- 它创建过待办:待办还在清单上(待处理/已完成)→ 跳过;待办被忽略且那条记忆还在 → 跳过; 待办没了、记忆也没了(清除忽略记录)→ 重读重建;
- 它当初没生成是因为被忽略记录挡下:
ignoredKeys里那些记录还在 → 跳过;已被清空 → 重读; - 它本来就没有待办(日报之类):邮件级记忆写着"没什么可做" → 跳过,不白花模型调用。
- 待办 id 由指纹派生:
t_<sha1(sourceKey) 前 16 位>。清空清单后用同样的邮件重跑, 得到的是同一批 id(含标题、来源、截止日期)——可复现,也方便以后接外部系统。 - 三道去重网(都在写入前):
sourceKey = email:<账号>:<文件夹>:<uid>:<item key>撞上已有/忽略记录 → 跳过;titleKey完全相同或相似度 ≥minSimilarity→ 跳过;模型自己给出的duplicateOf命中已有待办 → 跳过。 提示词里会带上当前清单的全部标题(含已完成的)——按状态表,已完成同样"不再生成", 只报未完成的会让模型重新生成已经做完的事。
规则引擎(engine: rules,或 LLM 不可用时的兜底)
只有明确要求收件人做事的句子才会成为待办,例如「请/务必/麻烦/尽快…」「于 X 日前反馈/提交」、 「截止…」「任务:/待办:」「改期/调整到…」「deadline/due/please/submit…」,并且必须出现具体的动作动词 (回复/确认/提交/反馈/参加/准备/更新…)——所以光有「截止时间:9月30日」不构成待办。
以下内容一律不算:日报周报的进度百分比、请查收 / 详见附件 之类的知会、
联系人:… / 电话这类落款、如需请假请联系 这类可选事项、系统通知与退订信息。
细节规则:
- 长句挑出义务最强、带截止时间的那个分句作为标题(例如「为了…,请大家…填写各自的简介PPT,于9月20日前反馈至…」 → 「于9月20日(本周日)中午12:00前反馈至集团机关团支部邮箱」);分句太短不足以独立成事时保留整句。
- 以冒号结尾的行是下文的标题(「另有一项待办,请各场景…配合完成:」),不会单独成为待办。
截止时间:9月30日这样的纯时间行会并到上一条待办上:按附件指引准备本场景需求的知识库资料(截止 9月30日)。- 会议改期(「本周周会改至周四(9/24)14:00—15:00」)会被识别成待办。
- 正文没有可执行内容时,只有主题本身像一件事(通知/邀请/报名…)才会用主题兜底,日报周报类主题会被拒绝。
规则引擎同样会写 mailLog(引擎指纹不同,所以两个引擎互不污染缓存)。
配置
两条路都可以改配置,改的是同一份数据(profile 的配置层):
- 设置面板:设置 → 邮件待办。表单不写死字段——由 Host 下发
CONFIG_FIELDS渲染 (与 schema、默认值同源),保存走settings.update(ns, patch, revision)带并发保护; 模型那一项是从 DSH 可选模型列表里选的下拉(provider + 模型 + 「用部署默认」)。 页面只读时(profile 没有 settings 服务)会说明改用下面的 patch。 - profile 的
cordis.patch.yml:覆盖同一行 id 即可(后面的层覆盖前面的层):
- id: email-todo
config:
limit: 100 # 每次同步看最近多少封(1-100,默认就是工具上限)
days: 7 # 只扫描最近多少天;0 表示不按日期过滤
unreadOnly: false # 只扫描未读
maxItemsPerMail: 3 # 单封邮件最多提取几条
maxNewPerSync: 100 # 一次同步最多新建几条(读多少封不受此限制)
minSimilarity: 0.86 # 重复判定阈值(0.5-1)
engine: auto # auto | llm | rules
selfAddresses: "" # 本邮箱地址;空 = 从 email_health 自动探测(用于识别"仅抄送")
llmProvider: "" # 覆盖 provider;空 = 用部署默认
llmModel: "" # 覆盖模型 id;空 = 用部署默认模型
llmBodyChars: 1800 # 单封正文截断长度(速度的主要旋钮)
llmMaxTokens: 4000 # 单封、单次调用输出上限
llmTimeoutMs: 300000 # 单次模型调用超时
stateWriteMs: 1000 # 同步期间状态文件最多多久写一次(0 = 每封都写)
stateScope: dsh-home # dsh-home(各 profile 共用一份清单)|profile(按 profile 隔离)
autoSyncSeconds: 30 # Host 自动轮询的探测间隔(0 = 关闭)
autoSyncOnStart: true # 启动时先补一轮
probeBackoffMaxSeconds: 600 # 探测失败的退避上限(秒)
recordProbe: false # 把每次探测写进状态文件(lastProbe),用于确认轮询真的在跑
skipCourtesyCopy: true # 知会类(仅抄送)是否一律不生成待办
subjectFallback: true # 正文抽不出时是否用主题兜底
manualParse: true # 手动添加是否调模型解析(关掉 = 原文即标题)
promptExtra: "" # 追加到邮件提示词末尾的额外要求(策略从这里配,不写死在代码里)
llmEscalations: 1 # 被输出上限截断后最多加倍重试几次
llmMaxTokensCeiling: 16000 # 加倍的上限
manualMaxTokens: 0 # 手动解析的输出上限(0 = 跟随 llmMaxTokens,最多 800)
maxTodos: 500 # 清单容量上限
maxIgnored: 1000 # 忽略记录上限
maxMailLog: 2000 # 邮件缓存上限
popupItemLimit: 5 # 会话 header 角标悬浮列出几条
配置项共 30 个,与包内 cordis.patch.yml 一一对应(那份是"默认值 + 注释"的完整清单,
照抄改值即可)。三条与"策略"直接相关的:
promptExtra:把提取策略挪到配置里。上次那封转发微信聊天记录漏检就是策略问题, 现在可以这样配而不用改代码:promptExtra: "转发聊天记录里若涉及本人行程或承诺,也按本人事务生成待办,并在 quote 里写出依据句。"stateScope:dsh-home时同一 DSH_HOME 下的所有 profile 共用一份清单(在 A profile 装的、 B profile 打开就能看到);要各自一份就写profile。recordProbe:打开后每次探测都写lastProbe: {at, newCount, ok}——把"30 秒的探测到底有没有 在跑"从推断变成可查(autoSyncSeconds探测不等于同步,没有新邮件时不会产生任何同步记录)。
状态是进程内单一持有者:所有请求改的是同一个对象,落盘是整文件原子重写(POSIX 上按 0600; Windows 由目录 ACL 决定权限),并用「mtime + 大小」作为廉价签名,外部改动会在下一次请求被读入。 这样"同步期间的界面操作"不会被同步的写回滚(曾经会:同步持有自己的副本,每处理一封全量覆盖写)。
engine 的三种语义:auto = 有模型路由就用 LLM,某一封失败时那一封退回规则引擎(其余继续用模型,
报告里记下原因);llm = 必须用模型,不可用或回复不合约定就报错(不做静默降级);rules = 一次模型都不调。
模型路由取部署的默认选择(设置 → 模型);llmProvider / llmModel 只在想给待办单独换一个模型时填写。
模型调用次数 = 需要处理的邮件封数(缓存命中的封不调用),所以 limit 直接决定"最坏情况下要等多久"。
手动添加:一句话交给模型解析
点「✍️ 添加」时,Host 会调一次模型把这句话解析成结构化字段(独立的契约,与邮件那套互不影响):
输入:下周三下午跟张三对一下方案
↓ 一次模型调用(严格 JSON:title / due / dueText / priority)
标题:与张三对齐方案 时间:2026-10-08(原文「下周三下午」留在 dueText)
备注:下周三下午跟张三对一下方案(原文)
来源:{ kind: "manual", raw: 原文, parsed: true, engine: <模型> }
三条硬规则:
- 绝不丢输入:模型答非所问、被截断、provider 报错、没有模型路由——一律退回"原文即标题"
(
parsed: false),照样进清单,只差一个解析出来的时间。 - 不受
engine影响:即使配成rules(一次模型都不调)或llm(严格模式),手动添加也照常工作 ——严格模式约束的是"读邮件",不是"记待办"。 - 去重按解析后的标题算:所以先说「下周三跟张三对方案」再输「跟张三对方案(下周三)」会被判重复。
按钮在解析期间显示「解析中…」并禁用,回车与点击是同一个入口。
状态文件
${DSH_HOME:-~/.dsh}/dsh-email-todo.json(0600 权限,原子写入)。内容:
todos[](含 titleKey / sourceKey / due / dueText / priority / source)、
ignored[](删除即忽略的记忆:一次删除一条记录 {titleKey, sourceKey},所以面板左下角的
"已忽略 N 条"就等于你删过的条数;旧文件的裸字符串数组会在加载时自动配对迁移)、
mailLog{}(内容哈希缓存,上限 2000 条)、syncedAt / lastSync(含本次逐封摘要与模型用量)、
progress(仅同步期间存在:{running, done, total, uid, subject, created, startedAt};
done 是"已完成几封"(从 0 起),界面显示"第 done+1/total 封")。
删除该文件即可把清单恢复到初始状态;只删 todos 则下一次同步会重新生成同一批(id 也相同)。
接口
POST /_dsh/dsh-email-todo/api,仅限本机回环地址,JSON 请求体 { action, ... }:
| action | 参数 | 说明 |
|---|---|---|
state |
– | 取清单与配置 |
models |
– | 枚举 DSH 可选模型:providers[] + models[](provider/id/name)+ 部署默认 current + 本插件覆盖值 configured(供设置页做模型下拉、也方便脚本核对) |
settings |
– | 设置页要的全部内容:groups + fields(同 config-schema.js 的表)+ values + revision + editable/reason + 模型目录 |
setConfig |
patch, revision? |
把已知字段写回 profile 的配置层(带 revision 并发保护;未知字段忽略) |
sync |
options?: {limit, days, unreadOnly} |
读邮件并生成待办 |
add |
title, note? |
手动添加:title 是原文,Host 调模型解析出标题/时间后落库;重复则返回 duplicate: true,两种情况都带 parsed 表示解析是否成功 |
update |
id, title?, note? |
改名/备注 |
toggle |
id, done? |
勾选完成 |
remove |
id |
删除并记入忽略 |
clearDone |
– | 忽略已完成(批量) |
clearIgnored |
– | 清除忽略记录 |
响应统一为 { ok: true, value } 或 { ok: false, error: { code, message } }。
注意
Host 半边是普通 ESM 模块,DSH 的模块热重载默认不监听 profile 外的链接包,
改动 index.js / llm.js 或 package.json(dsh.client.inject)后需要重启 profile 才会生效;
Client 半边(client.js)改动后刷新页面即可(浏览器可能缓存带 rev 的产物,硬刷新更稳)。
已验证
五套脚本,全部零依赖、不需要邮箱或模型即可复现,约 265 条断言:
cd <本包目录>
npm test # 等价于 node test/run-all.mjs,逐套跑并汇总
node test/check-host.mjs # 也可以单独跑某一套
test/check-host.mjs(真实index.js+ 假 webServer / 假 tools / 假 llm):路由与请求形状校验 (跨站 / text/plain 一律拒绝)、同步全流程、抄送判定、缓存判定真值表(14 行,覆盖两种模式 × 待办存活 × 删除记忆 × 引擎/哈希变化)、幂等(重建得到同一批 id)、忽略语义、 同步期间的界面操作不会被回滚(并附一条"旧行为确实会丢"的反证)、逐封进度与落盘、 写入节流(不丢结果)、并发锁、死连接不炸进程、刷新后接口仍报sync-running、 轮询成本(没新邮件时 0 次正文下载 / 0 次模型调用)、Host 调度器(真实等待 1s 而非 mock 时钟)。test/check-llm.mjs:契约层——卡片是邮件的纯函数(同邮件同哈希)、提示词可复现、 回复读取的逐字段修复与拒绝、思维链两种形态、单封单调用、截断重试语义、用量统计、 手动解析契约(一句话进、结构化字段出,短标题放行、坏回复一次修复重试)。test/check-client.mjs:客户端状态机(浏览器 shim 加载真实client.js,不引 jsdom、不渲染组件)——todoFeed广播与派生计数、syncJob并发守卫(本页在跑 / Host 在跑都不许再发请求)、 Host 侧同步的进度看门狗(开始轮询 / 结束停止)、自动与手动同步的差别(静默吞掉 sync-running)、 报告行在"同步响应"与"刷新后读到的 lastSync"两种来源下一致、 来源图标映射(📧 / ✍️ / 📌 与"未知来源不空着"、按钮图标与列表行图标同源)。test/check-extract.mjs:规则引擎的提取/去重用例(原句全部来自真实同步)。test/check-locale.mjs:中英字典键一致性 + 引用检查(同时报出"定义了但没人用"的死键—— 修掉假阳性之后这份清单是空的,任何新增死键都会立刻现形)。- 真实链路:
email_list/email_read经过 Host 工具注册表读取真实邮箱成功。 - 客户端席位:右侧 Sidebar tab 正文(
sidebar.right.pane.tab#dsh-email-todo)、引导页入口、 会话 header 右上角入口(conversation.session.header.utilities#dsh-email-todo); 左栏没有本插件的任何入口。
打包与发布
包里没有构建步骤(Host 是普通 ESM,Client 是 loader 直接加载的产物),所以打包就是 npm pack:
npm test # 发布前先全绿(约 265 条断言,不需要邮箱或模型)
npm pack --dry-run # 看清单:package.json / index.js / llm.js / config-schema.js / client.js /
# cordis.patch.yml / icon.svg / README.md / CHANGELOG.md / test/**
npm pack # 产出 dsh-email-todo-<版本>.tgz(离线分发用;安装 spec 就是它的绝对路径)
npm publish # 或 npm publish --registry <私有源>;scoped 公开包加 --access public
发布前请确认三件事:
- 名字一致:
package.json的name必须等于cordis.patch.yml里那一行的name:(当前都是dsh-email-todo)。改成 scoped 包名时要三处同改——package.json、 patch 的name:、以及 profile 的bundles列表条目,否则插件行解析不到包。 - 版本递增:同一版本不能重复发布,改
version并补CHANGELOG.md。 - 示例数据:README 里举的例子来自真实收到的邮件(已去标识)。如果公开发布,建议换成通用例子。
装到另一个 profile(或另一台机器):
plugin_manager action: install_bundle target: [email protected]
替换已安装的包需要重启才会加载新的 JS 模块代;新增 bundle 可以热生效。
使用方还需要装 dsh-email(邮件工具的来源),并在设置里配置邮箱账号与模型。
发布被拒 E403 / 提示需要 2FA
npm error code E403
npm error 403 Forbidden - PUT https://registry.npmjs.org/<包名> - Two-factor authentication
or granular access token with bypass 2fa enabled is required to publish packages.
账号开了 2FA,而 .npmrc 里的 token 没有"绕过 2FA"的授权——publishing 是写操作,npm 要求
「当场给 OTP」或「一个被授权可绕过 2FA 的 token」。三选一:
- 重新生成 Granular Access Token(推荐,之后发布无感):npmjs.com → Access Tokens →
Generate New Token → Granular Access Token,勾 Bypass 2FA、权限 Read and write、
Packages 选 All packages(未 scoped 的新包必须如此;想限定 scope 就要改成 scoped 包名),
然后替换
~/.npmrc里//registry.npmjs.org/:_authToken=的值(保留 cache/store 那几行)。 - 临时用 OTP:
npm login --auth-type=web后npm publish --otp=<6位验证码>(每次都要输; 注意npm login会改写.npmrc的认证行,先备份)。 - 不经过 npm:
npm pack出来的dsh-email-todo-<版本>.tgz直接分发, 用install_bundle指向该文件安装;或发布到公司私有源,通常不要求 2FA。