Chuyển đến nội dung chính

dsh-email-todo

Đã xác minh

dsh-email-todo · v1.0.18 · MIT · Giao diện web

邮件待办:读取 dsh-email 的邮件内容,用 LLM 把邮件里的工作事项提取成可管理的待办清单,并在 Web 客户端右侧 Sidebar 提供一个「待办」tab。

Cài đặt

dsh plugin add dsh-email-todo

Xác nhận layer đã áp bằng dsh --profile default --dump-config — xem hướng dẫn cài plugin.

Mã nguồn

Phát hành lên npm mà không có repository công khai. Hãy kiểm tra nội dung package trước khi cài.

Thẻ

Tác giả

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 不会连带装上它。正确顺序:

  1. 在「插件」面板安装并启用 dsh-email,在它自己的设置页配好邮箱账号并确认能收信;
  2. 再安装并启用 dsh-email-todo;
  3. 重启 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 构建会因为缺这个客户端包而整体不加载。

使用

三个入口,同一份清单:

方式 位置 适合
斜杠命令 /todo-add 主对话框里直接输入 手边正在聊天,顺手记一条
全局入口按钮 常驻标题栏(conversation.header.leading,点击开关右栏待办页) 看清单、勾选、清空
面板输入框 右栏待办页顶部「添加一条待办…」 已经在面板里

斜杠命令 /todo-add

/todo-add 周五前把季度报告发给张总
→ 已加入待办:把季度报告发给张总(10月9日)
  • 走 Host 的 commands 服务注册:纯上下文注册 = 全局命令,每个会话的 / 菜单里都有;
  • 执行不经过模型(平台文档原话:parse and execute a known command without sending it to the model) ——确定性动作,只额外调一次"语义解析"(识别时间/优先级),不占一个对话回合;
  • 与面板输入框共用同一段逻辑:判重、解析失败不丢原文、重复时提示"已存在相似的待办,未重复添加"。

全局入口按钮

  • 席位 conversation.header.leading(root 作用域、"available without a Session")—— 与右侧 Sidebar 的收起/展开按钮在同一个常驻容器里,所以有没有会话都在, 且只有一个入口(不重复、不跳位置);
  • 点击是开关:收起时点开右栏待办页;展开时再点收起右栏;
  • 悬浮出下拉列表(前 popupItemLimit 条未完成,向右展开,不压左栏);
  • 角标 = 未完成条数;待办页正在屏幕上时按钮显示为激活态(再点就是收起)。
  1. 入口一:会话 header 右上角的「待办」图标(conversation.session.header.utilities,order 5, 和「在应用中打开」「导出会话日志」同在一排;该席位是 session 作用域,所以要开着会话才可见)。

    • 角标 = 未完成待办条数(0 时不显示,>99 显示 99+);
    • 悬浮弹出下拉列表:前 POP_ITEM_LIMIT(5)条未完成待办,每条带来源(发件人 · 主题), 超出部分显示「还有 N 条…」,底部提示可点开面板;鼠标离开或按 Esc 关闭;
    • 点击直接打开右侧 Sidebar 的待办页(复用右栏自己的 commandTarget + openTabFromTarget: 起始页占着格子时让它让位,该分栏则落在当前活跃分栏,并自动展开右栏)。
    • 待办页本身显示在屏幕上时这个图标不占位(读的是本 tab 自己的 useTabInfo().tab.visible), 所以「收起状态下它在那儿,打开后它让位」。
  2. 入口二:右侧 Sidebar 的起始页胶囊——从 tab 条的添加控件进入起始页,点「待办」。

  3. 点「同步邮件」:一封一封地读窗口内的邮件,每处理完一封就写入清单并显示出来 (面板副标题实时显示 第 3/10 封 · 正在处理「主题」 · 已新增 2 条),不是等全部跑完才一次性输出。

  4. 面板里可以手动添加、内联改名(点标题)、勾选完成、忽略、忽略已完成、清空全部。 每条待办是两行,为的是「一眼看重点,细看有出处」:

    左 右
    第一行 来源图标 + 一句话说清要做什么(点一下可就地改名) 待办时间:模型给的截止日期(9月30日 / Sep 30);已过期标红;信里没写时间就显示**「时间未定」**
    第二行 来自谁发的哪封信:来自 <发件人> · <邮件主题> 附件数量:📎N,悬浮列出附件文件名

    来源图标区分待办是怎么来的,加新来源只在这张表里加一行:

    图标 含义
    📧 邮件同步生成(按钮「📧 同步邮件」同一个图标)
    ✍️ 手动添加(按钮「✍️ 添加」同一个图标)
    📌 其他来源(预留;未知来源不会空着)

    主题兜底生成的待办不再重复显示主题(标题就是主题);手动添加的条目第二行不再写「手动添加」 ——标题前的 ✍️ 已经说清了,重复的文字反而占地方。

    手动添加会调一次模型做语义解析(见下文「手动添加」一节):把「下周三下午跟张三对一下方案」 解析成标题「与张三对齐方案」+ 时间 2026-10-08;解析失败也绝不丢输入,原样存成标题。

    只是被抄送(知会)的邮件原则上不生成待办;万一模型从里面挑出了"点名要求你本人办"的事, 第二行会带一个 抄送 标记,说明这条待办的出处。

    仅抄送(知会)的处理:怎么算"我自己"——先看配置 selfAddresses, 留空就用 email_health 里账号自带的地址自动探测(provider / 账号地址 / IMAP …), 两者都拿不到就按「未知」处理(不会因为找不到自己就把邮件当抄送,宁可多生成也不漏)。 判定为"自己在 cc、不在 to"时:规则引擎直接不生成(这类邮件是噪音的主要来源), 模型收到的卡片里带 收件方式:仅抄送(收件人只是被知会),并被要求只在信中明确点名要求本人办理时才生成。 报告里会写 其中 N 封仅抄送,未生成待办,逐封摘要里这些邮件显示 0 项——判断依据可见。

  5. 同步完会多一条可折叠的摘要条:默认收起,只显示 同步摘要:读取邮件 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 在新邮件到达时发事件(上游改动)。

幂等性:同一封邮件不会变成两份待办

  1. 一封邮件只在它第一次产出条目时贡献清单内容。 后续同步(换引擎、缓存失效等)重新处理它时, 抽出的条目一律不再追加——判据是邮件 uid,与文案无关。 为什么需要这条:sourceKey 里的 slug 由模型给出,退到规则兜底时会换一个 slug,于是"这封信 处理过"对 sourceKey 不可见;靠标题相似度兜底又拦不住同一封信的两种说法(实测 「参加9月24日AI+专项周会及AI知识库分享会」vs「本周周会改至周四(9/24)14:00—15:00」 相似度为 0.000——数字不同会被 similarity 直接判 0),结果同一封信在清单上留下 2~3 条。 首次处理仍然最多产出 maxItemsPerMail 条(一封邮件本来就可以有多件事),这条只封"后续追加"。 代价:换更好的模型后,已经产出过条目的邮件不会自动重算——想让新模型重来一遍,用 「清空全部待办」(它同时清掉邮件缓存)再手动同步。报告里的 repeatSkipped 会告诉你 这一次有多少条因此被丢弃。
  2. 内容哈希缓存:每封邮件处理完记一条 mailLog[uid] = {hash, engine, at, created, ignoredKeys}。 hash 是卡片哈希(正文一改就变),engine 是引擎指纹(rules:v1:<每封上限> 或 llm:v1:<provider>/<model>:<每封上限>)。 引擎指纹只对"当初什么都没产出"的邮件有意义:换模型/换引擎时,那些邮件会被重新看一眼 (本来就没抓到东西,新模型值得再试);而已经产出过条目的邮件不重算——连正文都不重读, 也不会替换或追加条目(见下面第 0 条)。 缓存判定按上表逐条对应:
    • 它创建过待办:待办还在清单上(待处理/已完成)→ 跳过;待办被忽略且那条记忆还在 → 跳过; 待办没了、记忆也没了(清除忽略记录)→ 重读重建;
    • 它当初没生成是因为被忽略记录挡下:ignoredKeys 里那些记录还在 → 跳过;已被清空 → 重读;
    • 它本来就没有待办(日报之类):邮件级记忆写着"没什么可做" → 跳过,不白花模型调用。
  3. 待办 id 由指纹派生:t_<sha1(sourceKey) 前 16 位>。清空清单后用同样的邮件重跑, 得到的是同一批 id(含标题、来源、截止日期)——可复现,也方便以后接外部系统。
  4. 三道去重网(都在写入前):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 的配置层):

  1. 设置面板:设置 → 邮件待办。表单不写死字段——由 Host 下发 CONFIG_FIELDS 渲染 (与 schema、默认值同源),保存走 settings.update(ns, patch, revision) 带并发保护; 模型那一项是从 DSH 可选模型列表里选的下拉(provider + 模型 + 「用部署默认」)。 页面只读时(profile 没有 settings 服务)会说明改用下面的 patch。
  2. 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

发布前请确认三件事:

  1. 名字一致:package.json 的 name 必须等于 cordis.patch.yml 里那一行的 name: (当前都是 dsh-email-todo)。改成 scoped 包名时要三处同改——package.json、 patch 的 name:、以及 profile 的 bundles 列表条目,否则插件行解析不到包。
  2. 版本递增:同一版本不能重复发布,改 version 并补 CHANGELOG.md。
  3. 示例数据: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」。三选一:

  1. 重新生成 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 那几行)。
  2. 临时用 OTP:npm login --auth-type=web 后 npm publish --otp=<6位验证码>(每次都要输; 注意 npm login 会改写 .npmrc 的认证行,先备份)。
  3. 不经过 npm:npm pack 出来的 dsh-email-todo-<版本>.tgz 直接分发, 用 install_bundle 指向该文件安装;或发布到公司私有源,通常不要求 2FA。