diff --git a/AGENTS.md b/AGENTS.md index 4e901c4..918cac5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,6 +36,7 @@ | `test/markdown.test.js` | 4 个单测:导出→读回往返、手写 Markdown 读回、id 命中即更新。 | | `test/bbdc.test.js` | 13 个单测:prefs 解析、按月求和(累计 293 词就是 48 个月的 `learnNum` 相加)、`sid` 编码变体、时长两边取新、本地刷盘滞后标记等。 | | `test/mini-dock.test.js` | 8 个单测:迷你条松手不贴边、仍会被收回视口、吸附实现已移除、声明了 `no-drag`、不许停进宿主标题栏那条拖拽带,以及计划表悬浮件的同款坑位守卫(no-drag / pointer-events / z-index / 默认位不重叠 / 钳制函数不许另起炉灶)。 | +| `test/message-source.test.js` | 5 个单测(会话消息 source 准入):kind 必须是 `plugin:dsh-study-desk`、工厂出的 source 能过宿主 v4 准入、`at` 默认取当前时间且不共享同一对象,外加**静态扫**把裸 `kind: 'plugin'` 挡在提交前(先 `maskComments()` 抹掉注释——注释里讲旧写法不算违规),以及一条「扫描器不空转」自证。 | | `test/style.test.js` | 6 个单测(视觉层守卫):CSS 里 hex 必须为 0、饼图 8 色不许退回语义色、不许有零点几像素字号、标题不用衬线、字阶挂在各根上、图表配色归属。 | | `tools/client-loader.mjs` | 把 `client.js` 这个 loader 格式的 bundle 在纯 Node 里载进来、取 `exports.__test`。两个测试文件共用一份,免得各写一套桩。放在 `tools/` 是因为 `node --test` 会把 `test/` 下每个文件都当测试跑。 | | `apply-probe.mjs` | 离线冒烟:用 stub ctx 把两侧 `apply()` 真跑一遍,不启动 DSH 就能抓加载期错误。 | @@ -94,7 +95,11 @@ - **提醒发送**:`withDesk` 结账时把走完的段收进 sink,`emitTimerEnd` 过滤(开关关了 / 不是 AI 链 / 段走完超过 10 分钟才被结账的都跳过)后调钩子:`ctx.get('agents').get(sessionId).followup(消息)`。 这是官方 `dsh-goal-round-driver` 的同款姿势(app.asar 里有源码可对照);`followup` 空闲就开新一轮、 - 跑着就排下一轮。消息 `role:'user'`、`source.kind:'plugin'`,正文自带「这是系统自动消息,不是用户发言」。 + 跑着就排下一轮。消息 `role:'user'`、`source.kind:'plugin:dsh-study-desk'`(**必须带 `plugin:` 前缀**—— + 裸 `'plugin'` 是宿主会话格式 v4 已退役的写法,落盘会被整条拒绝,报 + `format v4 message requires a producer-owned source kind`,整个会话当场跑不起来; + kind 由 `MESSAGE_SOURCE_KIND` / `timerEndSource()` 统一产出,别手写), + 正文自带「这是系统自动消息,不是用户发言」。 宿主侧另有 10 秒看门狗:面板没开没人轮询 desk 时由它兜底结账。 - **开关**:`settings.notifySessionOnTimerEnd`(默认开,`!== false` 即开,语义同 `injectPrompt`), 设置页有勾选项。**手动停钟(客户端按钮 / 工具 `off=true`)永不提醒**;会话已关闭(agents 里没有) @@ -205,7 +210,7 @@ ctx.set('studyDesk', { ... }) // 抛 Error: cannot set property "studyDesk ## 测试 ```bash -node --test # 73 个单测:数据层 + 番茄钟状态机 + AI 起钟会话标记 + 复习排期 + 遗忘曲线/预报/建议 + Markdown 往返 + 不背单词 prefs 解析与时长取新 + 迷你条/计划表悬浮件落点与拖拽带 + 每日计划表 + 视觉层守卫(hex / 字阶 / 配色归属) +node --test # 78 个单测:数据层 + 番茄钟状态机 + AI 起钟会话标记 + 复习排期 + 遗忘曲线/预报/建议 + Markdown 往返 + 不背单词 prefs 解析与时长取新 + 迷你条/计划表悬浮件落点与拖拽带 + 每日计划表 + 视觉层守卫(hex / 字阶 / 配色归属)+ 消息 source kind 准入(含静态扫防回潮) node apply-probe.mjs # 离线冒烟:stub ctx 跑两侧 apply(),检查工具 / 路由 / 槽位注册 / 新 op / 快照新字段 / AI 起钟门禁与会话提醒 / 每日计划表校验 node install.mjs --check # 体检:本目录与某个 profile 的接线是否完整 ``` diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c8d65b..456c895 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,22 @@ 格式随版本号走;`package.json` 的 version 是唯一真相,tag 用 `v<版本>`。 +## v0.8.1 + +番茄钟走完的自动提醒会把整个会话打成跑不起来 —— 修掉那条写入。 + +- **会话消息的 source kind 带上生产者前缀**:番茄钟走完发回起钟会话的那条提醒,`source` 以前写成 + `{ kind: 'plugin', plugin: 'dsh-study-desk', … }`。宿主会话格式 v4 把这种「kind 一律 `plugin`、 + 只靠 `plugin` 字段认来源」的写法判为退役语法,落盘时整条拒绝,报 + `format v4 message requires a producer-owned source kind` —— 表现就是会话在番茄钟停止的那一刻 + 忽然**任何回复都发不出去**,历史还在、但每一轮都撞同一堵墙。现在 kind 写成 + `plugin:dsh-study-desk`(宿主只要求「非空且不是裸 `plugin`」,前缀正是来源标记, + `clearai` / `dsh-mneme` 等插件在现有会话里早已这么写),由 `MESSAGE_SOURCE_KIND` + 与 `timerEndSource()` 统一产出,不再手写。 +- **回归测试 + 静态扫**:新增 `test/message-source.test.js`(4 条)。除了断言工厂产出的 source + 能过宿主同款准入,还静态扫源码禁止裸 `kind: 'plugin'` 回潮;`apply-probe.mjs` 里那条端到端断言 + 同步改成新 kind。单测 73 → 78。 + ## v0.8.0 AI 把督促链路接上了:AI 起番茄钟必须先落卡、走完自动提醒开钟的那个会话;新增每日计划表,模型从系统提示里就知道「现在该做什么」。看板补上过卡模式、卡片截止日期与一批顺手的升级。 diff --git a/apply-probe.mjs b/apply-probe.mjs index fef36c7..471d958 100644 --- a/apply-probe.mjs +++ b/apply-probe.mjs @@ -268,7 +268,9 @@ check('专注走完 → 提醒以 user 消息送进起钟会话', () => { assert.equal(msg.role, 'user') assert.match(msg.content[0].text, /番茄钟·自动提醒/) assert.match(msg.content[0].text, /25 分钟/) - assert.equal(msg.source.kind, 'plugin') + // 必须是生产者自己命名的 kind:裸 'plugin' 是宿主会话格式 v4 已退役的写法, + // 落盘时会被整条拒绝(format v4 message requires a producer-owned source kind) + assert.equal(msg.source.kind, 'plugin:dsh-study-desk') }) check('自动摆的休息继承会话标记(链条不断)', () => { const t = JSON.parse(nudgeRes.body).state.timer diff --git a/index.js b/index.js index 021d753..f2153b1 100644 --- a/index.js +++ b/index.js @@ -182,6 +182,18 @@ const pluginVersion = (() => { } catch { return '' } })() +// 会话消息的 source kind 必须是「生产者自己命名」的:宿主会话格式 v4 把旧的共享包装 +// (kind 一律写 plugin、只靠 plugin 字段认来源的那种)判为退役语法,落盘即整条拒绝,报 +// format v4 message requires a producer-owned source kind +// (app.asar: lib/types/message-sources.js 的 source())。所以插件写给会话的消息一律用 +// `plugin:<包名>` 这种带前缀的 kind —— 宿主只要求「非空且不是 plugin」,前缀正是来源标记。 +export const MESSAGE_SOURCE_KIND = `plugin:${name}` + +/** 番茄钟走完那条自动提醒的 source(导出仅为单测能直接断言形状)。 */ +export function timerEndSource(at = Date.now()) { + return { kind: MESSAGE_SOURCE_KIND, plugin: name, event: 'timer-end', at } +} + // --------------------------------------------------------------------------- // 串行化的状态访问:客户端会 POST,模型会调工具,两边可能同时改同一份 JSON。 // 用一条 promise 链把「读-改-写」串起来,避免后写的把先写的覆盖掉。 @@ -853,7 +865,10 @@ export function apply(ctx, config) { id: randomUUID(), role: 'user', content: [{ type: 'text', text: timerEndText(info) }], - source: { kind: 'plugin', plugin: name, event: 'timer-end', at: Date.now() }, + // kind 必须是生产者自己命名的(plugin:<包名>):裸 'plugin' 是退役写法, + // 宿主会话格式 v4 会整条拒绝这个 splice,会话当场报 + // format v4 message requires a producer-owned source kind + source: timerEndSource(), }) } diff --git a/package.json b/package.json index 1c586b5..6b4ef69 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dsh-study-desk", - "version": "0.8.0", + "version": "0.8.1", "icon": "./assets/icon.svg", "description": "学习工作台 DSH 插件:Notion 式待办墙与标签过滤 + 间隔重复复习队列(简化 SM-2,带记忆曲线、未来 7 天到期预报与今日建议)+ 按实际复习时长点亮的热力图与周报 + 每日复盘 + Markdown 导出读回 + 跨面板存活的番茄钟 + 独立的不背单词打卡面板(只读本机 App 数据)。只消费主题 token,融入现有主题而不改主题。", "type": "module", diff --git a/test/message-source.test.js b/test/message-source.test.js new file mode 100644 index 0000000..9ab8e6c --- /dev/null +++ b/test/message-source.test.js @@ -0,0 +1,118 @@ +/** + * 消息 source 形状守卫(回归:session-26939e9f 的 `format v4 message requires a producer-owned source kind`)。 + * + * 背景:宿主会话格式 v4 把旧的共享包装 `{ kind: 'plugin', plugin: … }` 判为退役语法。 + * app.asar 里 lib/types/message-sources.js 的 source() 只要看到 kind === 'plugin' 就抛, + * 而插件塞进 `agent.followup(...)` 的消息会被原样写进 `agent/inbox/spliced.data.inserted[]`, + * 于是整条 splice 落盘失败,那个会话从此每一轮都报这行错。 + * + * 所以这里锁两件事: + * 1. 番茄钟提醒的 source 是 `plugin:<包名>` 这种生产者自己命名的 kind; + * 2. 源码里不许再出现裸 `kind: 'plugin'`(静态扫,防回潮)。 + */ + +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +import { MESSAGE_SOURCE_KIND, name, timerEndSource } from '../index.js' + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..') + +/** 复刻宿主的 v4 准入判据(app.asar: lib/types/message-sources.js 的 source())。 */ +function assertProducerOwnedSource(message) { + const value = message && message.source + assert.ok(value && typeof value === 'object' && !Array.isArray(value), 'source 必须是对象') + assert.equal(typeof value.kind, 'string', 'source.kind 必须是字符串') + assert.ok(value.kind.length > 0, 'source.kind 不能是空串') + assert.notEqual(value.kind, 'plugin', 'source.kind 不许是退役写法 plugin') +} + +test('MESSAGE_SOURCE_KIND 是生产者自己命名的 kind', () => { + assert.equal(MESSAGE_SOURCE_KIND, 'plugin:dsh-study-desk') + assert.equal(MESSAGE_SOURCE_KIND, `plugin:${name}`) +}) + +test('番茄钟提醒的 source 能过宿主 v4 准入', () => { + const at = 1770000000000 + const source = timerEndSource(at) + assertProducerOwnedSource({ source }) + assert.deepEqual(source, { + kind: 'plugin:dsh-study-desk', + plugin: 'dsh-study-desk', + event: 'timer-end', + at, + }) +}) + +test('timerEndSource 的 at 默认取当前时间,且不共享同一个对象', () => { + const before = Date.now() + const a = timerEndSource() + const b = timerEndSource() + assert.ok(a.at >= before) + assert.notEqual(a, b, '每次都要新对象:消息 source 会被宿主深冻结') +}) + +/** + * 把注释抹成空格(保留长度与换行),字符串 / 模板原样留着。 + * 静态扫只该抓「代码里写了旧写法」;解释旧写法的注释必须放过 —— + * index.js 里那段退役说明离踩线只差一个冒号。 + */ +function maskComments(text) { + const out = text.split('') + let i = 0 + let quote = null + while (i < text.length) { + const ch = text[i] + if (quote) { + if (ch === '\\') { i += 2; continue } + if (ch === quote) quote = null + i += 1 + continue + } + if (ch === "'" || ch === '"' || ch === '`') { quote = ch; i += 1; continue } + if (ch === '/' && text[i + 1] === '/') { + while (i < text.length && text[i] !== '\n') { out[i] = ' '; i += 1 } + continue + } + if (ch === '/' && text[i + 1] === '*') { + while (i < text.length && !(text[i] === '*' && text[i + 1] === '/')) { + if (text[i] !== '\n') out[i] = ' ' + i += 1 + } + if (i < text.length) { out[i] = ' '; out[i + 1] = ' '; i += 2 } + continue + } + i += 1 + } + return out.join('') +} + +const BANNED_KIND = /kind\s*:\s*(['"`])plugin\1/ + +test("源码里不许出现裸 kind: 'plugin'(静态扫,防回潮)", () => { + // 只看宿主半边的源码;client.js 是浏览器 bundle,它的 kind 全是诊断/计时器, + // 但为免以后有人往里面塞会话消息,也一并扫。 + const files = ['index.js', 'desk.js', 'timer.js', 'markdown.js', 'bbdc.js', 'client.js'] + for (const file of files) { + const code = maskComments(readFileSync(join(ROOT, file), 'utf8')) + const hit = BANNED_KIND.exec(code) + assert.equal(hit, null, `${file} 里还有裸 kind 'plugin':${hit ? hit[0] : ''}`) + } +}) + +test('扫描器不空转:注释里讲旧写法不算违规,代码里写了才算', () => { + // 注释形态必须放过 + assert.equal(BANNED_KIND.test(maskComments("// 旧写法是 kind: 'plugin'")), false) + assert.equal(BANNED_KIND.test(maskComments('/* 也别写成 kind: "plugin" */')), false) + assert.equal(BANNED_KIND.test(maskComments('const a = 1 // kind: `plugin`')), false) + // 代码形态必须抓到 + assert.equal(BANNED_KIND.test(maskComments("const s = { kind: 'plugin' }")), true) + assert.equal(BANNED_KIND.test(maskComments('const s = { kind:"plugin" }')), true) + assert.equal(BANNED_KIND.test(maskComments('const s = { kind : `plugin` }')), true) + // 新写法与相似词不许误伤 + assert.equal(BANNED_KIND.test(maskComments("const s = { kind: 'plugin:dsh-study-desk' }")), false) + assert.equal(BANNED_KIND.test(maskComments("const s = { kind: 'pluginish' }")), false) +})