Skip to content

dsh-email-todo

Verified

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

  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 构建会因为缺这个客户端包而整体不加载。

使用

两个入口,同一个面板、同一份清单:

场景 入口 席位
有会话(聊天中) 会话 header 右上角的「待办」图标 conversation.session.header.utilities(session 作用域)
没有会话(起始页等) 窗口右上角的同一个「待办」按钮(浮层) shell.overlay(root 作用域)

两者互斥显示(同一位置只会有一个),角标 / 悬浮列表 / 点击打开面板的行为完全一致。 平台把"右上角工具区"定义为会话的一部分,所以没有会话时那个席位整块不渲染, 浮层入口就是补上这一段(判断依据是"会话 header 入口有没有挂载",不依赖运行时钩子)。

  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。