Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 就能抓加载期错误。 |
Expand Down Expand Up @@ -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 里没有)
Expand Down Expand Up @@ -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 的接线是否完整
```
Expand Down
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 起番茄钟必须先落卡、走完自动提醒开钟的那个会话;新增每日计划表,模型从系统提示里就知道「现在该做什么」。看板补上过卡模式、卡片截止日期与一批顺手的升级。
Expand Down
4 changes: 3 additions & 1 deletion apply-probe.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 16 additions & 1 deletion index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 链把「读-改-写」串起来,避免后写的把先写的覆盖掉。
Expand Down Expand Up @@ -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(),
})
}

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
118 changes: 118 additions & 0 deletions test/message-source.test.js
Original file line number Diff line number Diff line change
@@ -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)
})
Loading