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
Empty file removed ).join(
Empty file.
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,6 @@ pack.log

# 视觉验证的临时产物(harness + 截图),不进版本库
.shots/

# video2code 会话缓存,不进版本库
.v2c/
35 changes: 28 additions & 7 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@
- **待办墙**:Notion 式三列(待办 / 进行中 / 已完成),卡片带科目、预计时长、累计投入,可拖拽换列;
- **学习热力图**:点亮条件是**当天实际复习了多少分钟**,不是「当天有没有消耗 token / 有没有开过会话」;
- **番茄钟**:专注 / 短休 / 长休自动轮换,换面板、刷新、关掉浏览器再打开都按绝对截止时间恢复;
AI 起钟走门禁(必须挂「进行中」的卡),走完自动提醒开钟的那个会话(可开关);
- **每日计划表**:`HH:MM~HH:MM 事情` 的每日固定模板,开了之后主面板显示、模型从系统提示里
知道「现在该做什么」,还可以像迷你条一样悬浮在桌面上(功能与悬浮各自独立开关);
- **常驻迷你条**:右下角小胶囊,在对话页也能看见剩余时间。

它**不属于 DSH 官方**,也不依赖任何第三方包(运行时依赖为零)。宿主半边只额外用到 DSH 自己的
Expand All @@ -25,14 +28,14 @@
| `markdown.js` | Markdown 导出 / 读回(纯函数):`toMarkdown` 生成 Obsidian 友好的 `desk.md`,`fromMarkdown` + `mergeMarkdown` 按 id 合并读回。机器字段放在行尾 `<!-- desk:… -->` 注释里。 |
| `bbdc.js` | **不背单词打卡桥**(纯数据层,零依赖):从腾讯 Androws 虚拟磁盘里的 App 数据读打卡/时长/词书,再用 App WebView 缓存里挖到的 `sid` 打 `learnywhere.cn` 的统计接口补累计词数。**只读**,不回写 App。`bbdcView()` 把采集结果裁成面板要看的紧凑形状(挡住 sid 摘要与临时目录);`readBbdc()` 带 10 分钟缓存 + 并发去重。 |
| `timer.js` | 番茄钟状态机:`start/pause/resume/reset/complete/reconcile`。真相是绝对时间戳,不用「还剩多少毫秒」。 |
| `index.js` | **宿主半边**:`study_desk` 工具(11 个 action)+ `/api/dsh-study-desk` 路由 + 往 system prompt 注入今日摘要。派生数据(复习队列(每张带 `curve`)/统计/`forecast` 七日预报/`plan` 今日建议/周报/标签/单词打卡)一律由 `desk.js` / `bbdc.js` 算好再随快照发出。 |
| `client.js` | **客户端半边**:整页看板(复习队列 / 过滤栏 / 热力图 / 周报 / 复盘)+ 独立的「单词打卡」面板 + 设置页 + 迷你条,注册 6 个槽位(2 个侧栏入口 + 2 个 main + 1 个迷你条 + 1 个设置节)。 |
| `index.js` | **宿主半边**:`study_desk` 工具(11 个 action)+ `/api/dsh-study-desk` 路由 + 往 system prompt 注入今日摘要(含每日计划表的「现在该做什么」)。派生数据(复习队列(每张带 `curve`)/统计/`forecast` 七日预报/`plan` 今日建议/`dayPlan` 计划表当前时段/周报/标签/单词打卡)一律由 `desk.js` / `bbdc.js` 算好再随快照发出。 |
| `client.js` | **客户端半边**:整页看板(复习队列 / 过滤栏 / 热力图 / 周报 / 复盘 / 每日计划表)+ 独立的「单词打卡」面板 + 设置页 + 迷你条与计划表两个悬浮件,注册 7 个槽位(2 个侧栏入口 + 2 个 main + 2 个 shell.overlay + 1 个设置节)。 |
| `cordis.patch.yml` | 让宿主把本插件 insert 进 loader 树。 |
| `package.json` | `dsh.bundle.patch` 指向上面那个 patch;`dsh.client` 声明客户端半边与注入的运行时包。 |
| `test/desk.test.js` | 30 个单测,`node --test`。 |
| `test/desk.test.js` | 数据层主套件(含番茄钟状态机、AI 起钟会话标记、每日计划表归一化与区间判定),`node --test`。 |
| `test/markdown.test.js` | 4 个单测:导出→读回往返、手写 Markdown 读回、id 命中即更新。 |
| `test/bbdc.test.js` | 13 个单测:prefs 解析、按月求和(累计 293 词就是 48 个月的 `learnNum` 相加)、`sid` 编码变体、时长两边取新、本地刷盘滞后标记等。 |
| `test/mini-dock.test.js` | 5 个单测:迷你条松手不贴边、仍会被收回视口、吸附实现已移除、声明了 `no-drag`、不许停进宿主标题栏那条拖拽带。 |
| `test/mini-dock.test.js` | 8 个单测:迷你条松手不贴边、仍会被收回视口、吸附实现已移除、声明了 `no-drag`、不许停进宿主标题栏那条拖拽带,以及计划表悬浮件的同款坑位守卫(no-drag / pointer-events / z-index / 默认位不重叠 / 钳制函数不许另起炉灶)。 |
| `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 @@ -62,6 +65,9 @@
或 Windows 用户目录那种绝对路径(`C:` 下 Users 里带真名的那种)—— CI 会 grep 直接红。
7. **复习排期、队列排序、周报、标签聚合只在 `desk.js` 一处算。** 宿主把 `review / due / tags / weekly / journal`
随快照发出,`client.js` 只画不算。浏览器里再实现一遍 SM-2 迟早和宿主对不上。
每日计划表的「现在在哪个区间」(`dailyPlanNow`)同理只在 desk.js:注入 system prompt 与快照
`dayPlan` 都从它取。客户端那份 `pmin()` 只做钟表换算(当前分钟落在哪个区间、剩几分钟),
和画倒计时是一档的事,不是领域算法——别把排序、校验再写一遍进浏览器。
8. **Markdown 读回是「合并」不是「覆盖」**:`desk.md` 里没出现的卡不动;靠行尾 `<!-- desk:id:… -->` 对回原卡,
注释被删就当新卡。导出的 Markdown 里不许出现本机绝对路径。
9. **不背单词那边只读,且凭据不出门。** `bbdc.js` 只读 App 数据与官方 H5 的公开统计接口,
Expand All @@ -78,6 +84,21 @@
规则只写在 `bbdcView()` 一处(客户端是独立 bundle、只能 `require react`,两边各写一遍必散)。
另有 `durationAsOf` / `durationMightBeStale`:只在「本地落后且云端没兜住」时提醒,
判定今天用 `desk.js` 的 `dayKey`(本地时区),**不能用 `toISOString()`** —— UTC+8 的早上会认错天。
10. **AI 起番茄钟走门禁,走完的提醒发回起钟的会话。** 这是用户点名的督促链路:
- **门禁**:工具 `focus` 起钟必须 `task=` 一张**「进行中」**的卡,不挂卡 / 卡不存在 / 卡不在 doing
都直接拒绝,报错文案教完整流程(`add` 建卡 → `move` 到 `doing` → `focus` 挂卡)。目的是逼 AI 把它
安排的任务真的落到待办墙上,周报和近 30 天统计才对得上。**只限工具路径**——客户端 POST
`timer.start`(手动起钟)不受限制。
- **会话标记**:AI 起钟时把 `exec.agent.id`(= 会话 id)存进 `state.timer`(`by:'ai'` + `sessionId`);
专注走完自动摆的休息**继承**这两个字段(链条到休息结束为止),手动提前结束摆的休息也继承。
- **提醒发送**:`withDesk` 结账时把走完的段收进 sink,`emitTimerEnd` 过滤(开关关了 / 不是 AI 链 /
段走完超过 10 分钟才被结账的都跳过)后调钩子:`ctx.get('agents').get(sessionId).followup(消息)`。
这是官方 `dsh-goal-round-driver` 的同款姿势(app.asar 里有源码可对照);`followup` 空闲就开新一轮、
跑着就排下一轮。消息 `role:'user'`、`source.kind:'plugin'`,正文自带「这是系统自动消息,不是用户发言」。
宿主侧另有 10 秒看门狗:面板没开没人轮询 desk 时由它兜底结账。
- **开关**:`settings.notifySessionOnTimerEnd`(默认开,`!== false` 即开,语义同 `injectPrompt`),
设置页有勾选项。**手动停钟(客户端按钮 / 工具 `off=true`)永不提醒**;会话已关闭(agents 里没有)
就静默放弃,不排队不补发。

## 三个必须知道的坑

Expand Down Expand Up @@ -184,8 +205,8 @@ ctx.set('studyDesk', { ... }) // 抛 Error: cannot set property "studyDesk
## 测试

```bash
node --test # 58 个单测:数据层 + 番茄钟状态机 + 复习排期 + 遗忘曲线/预报/建议 + Markdown 往返 + 不背单词 prefs 解析与时长取新 + 迷你条落点与拖拽带 + 视觉层守卫(hex / 字阶 / 配色归属)
node apply-probe.mjs # 离线冒烟:stub ctx 跑两侧 apply(),检查工具 / 路由 / 槽位注册 / 新 op / 快照新字段
node --test # 73 个单测:数据层 + 番茄钟状态机 + AI 起钟会话标记 + 复习排期 + 遗忘曲线/预报/建议 + Markdown 往返 + 不背单词 prefs 解析与时长取新 + 迷你条/计划表悬浮件落点与拖拽带 + 每日计划表 + 视觉层守卫(hex / 字阶 / 配色归属)
node apply-probe.mjs # 离线冒烟:stub ctx 跑两侧 apply(),检查工具 / 路由 / 槽位注册 / 新 op / 快照新字段 / AI 起钟门禁与会话提醒 / 每日计划表校验
node install.mjs --check # 体检:本目录与某个 profile 的接线是否完整
```

Expand All @@ -199,7 +220,7 @@ node install.mjs --check # 体检:本目录与某个 profile 的接线是否
拿一份 React 18 的 UMD 构建(本机任意 `node_modules/react{,-dom}/umd/*.production.min.js`),
写一个 HTML 复刻宿主外壳(`frame > centerCol(flex, overflow:hidden) + overlayLayer(z-index:20, pointer-events:none)`),
塞进宿主的 `body{--dsw-alias-*}` 主题 token,替换 `window.fetch` 成一个小假宿主,
然后用 `def.factory(require)` 拿到模块、`ReactDOM.createRoot` 把四个槽位挂出来,
然后用 `def.factory(require)` 拿到模块、`ReactDOM.createRoot` 把七个槽位挂出来,
再用真 `MouseEvent('click')` 点一遍按钮、断言 POST 出去的 op。
暗色只要给 `<body>` 加 `data-ds-dark-theme`(注意:暗色标记在 **body** 上,不是 html)。
无头 Edge 出图与结果:`msedge --headless=new --dump-dom`(把断言写进一个屏外 `<pre>`)+ `--screenshot`。
Expand Down
113 changes: 107 additions & 6 deletions apply-probe.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* 用法:node apply-probe.mjs (cwd = 本目录,含 index.js/desk.js/timer.js/client.js)
*/
import assert from 'node:assert/strict'
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'

Expand Down Expand Up @@ -223,6 +223,101 @@ check('未知 op 带 error 文案', () => assert.ok(String(JSON.parse(p5.body).e
const p6 = await post({ op: 'task.add' })
check('缺 title 返回 400', () => assert.equal(p6.code, 400))

// ---- AI 起钟门禁 + 番茄钟走完的会话提醒(督促链路端到端)----
// stub 一个 agents 服务:宿主提醒走 ctx.get('agents').get(sessionId).followup(消息)
const agentStub = { id: 'sess-A', followups: [], followup(msg) { this.followups.push(msg) } }
registered.services.agents = { get(id) { return id === 'sess-A' ? agentStub : undefined } }

const blockedNoTask = await run('focus', { minutes: 25 })
check('AI 起钟不挂卡被拒,且报错里教了完整流程', () => {
assert.equal(blockedNoTask.ok, false)
assert.match(blockedNoTask.message, /挂卡/)
assert.match(blockedNoTask.message, /action=add/)
assert.match(blockedNoTask.message, /status=doing/)
})

const gateCard = await run('add', { title: '门禁冒烟卡', subject: '英语' })
check('门禁卡建好', () => assert.equal(gateCard.ok, true))
const blockedTodo = await run('focus', { minutes: 25, task: '门禁冒烟卡' })
check('AI 起钟挂待办卡被拒', () => {
assert.equal(blockedTodo.ok, false)
assert.match(blockedTodo.message, /进行中/)
})

await run('move', { title: '门禁冒烟卡', status: 'doing' })
const aiFocus = await tool.execute({ action: 'focus', minutes: 25, task: '门禁冒烟卡' }, { agent: { id: 'sess-A' } })
check('挂「进行中」的卡可以起钟', () => assert.equal(aiFocus.ok, true))
check('起钟回执预告了自动提醒', () => assert.match(aiFocus.message, /自动往本会话发一条提醒/))
check('计时器记下了起钟会话', () => {
const t = JSON.parse(readFileSync(join(PROBE_HOME, 'study-desk', 'desk.json'), 'utf8')).timer
assert.equal(t.by, 'ai')
assert.equal(t.sessionId, 'sess-A')
})

// 时间快进:把 endsAt 改到过去再 GET,宿主结账 → 提醒送进 stub 会话
const deskPath = join(PROBE_HOME, 'study-desk', 'desk.json')
const rawDesk = JSON.parse(readFileSync(deskPath, 'utf8'))
rawDesk.timer.endsAt = Date.now() - 1000
rawDesk.timer.startedAt = rawDesk.timer.endsAt - 25 * 60000
writeFileSync(deskPath, JSON.stringify(rawDesk))
const nudgeRes = fakeRes()
await route.handler({ method: 'GET', url: '/api/dsh-study-desk?op=state', headers: {}, on() {} }, nudgeRes)
check('专注走完 → 提醒以 user 消息送进起钟会话', () => {
assert.equal(agentStub.followups.length, 1)
const msg = agentStub.followups[0]
assert.equal(msg.role, 'user')
assert.match(msg.content[0].text, /番茄钟·自动提醒/)
assert.match(msg.content[0].text, /25 分钟/)
assert.equal(msg.source.kind, 'plugin')
})
check('自动摆的休息继承会话标记(链条不断)', () => {
const t = JSON.parse(nudgeRes.body).state.timer
assert.equal(t.kind, 'break')
assert.equal(t.sessionId, 'sess-A')
})

// 关掉开关:休息走完不再提醒
await post({ op: 'settings.update', patch: { notifySessionOnTimerEnd: false } })
const rawDesk2 = JSON.parse(readFileSync(deskPath, 'utf8'))
rawDesk2.timer.endsAt = Date.now() - 1000
writeFileSync(deskPath, JSON.stringify(rawDesk2))
await route.handler({ method: 'GET', url: '/api/dsh-study-desk?op=state', headers: {}, on() {} }, fakeRes())
check('开关关闭后不再提醒', () => assert.equal(agentStub.followups.length, 1))

// POST timer.start(客户端手动路径)不受门禁限制,也不带会话标记
const pUser = await post({ op: 'timer.start', minutes: 5, name: '手动起钟' })
check('手动起钟不需要挂卡', () => {
const body = JSON.parse(pUser.body)
assert.equal(body.ok, true)
assert.equal(body.state.timer.by, null)
})
await post({ op: 'timer.reset' })
await run('remove', { title: '门禁冒烟卡' })

// ---- 每日计划表:数据校验在 desk.js 一处,快照带宿主算好的 dayPlan
const dp1 = await post({ op: 'dailyPlan.update', patch: {
enabled: true,
items: [
'这行不是对象,会被丢掉',
{ start: '08:00', end: '09:30', label: '背单词', subject: '英语' },
{ start: '09:30', end: '08:00', label: '起终写反的', subject: '' },
{ start: '25:00', end: '26:00', label: '非法时刻的' },
],
} })
check('POST dailyPlan.update 200', () => assert.equal(dp1.code, 200))
check('计划表坏行被丢、好行保留', () => {
const dp = JSON.parse(dp1.body).state.dailyPlan
assert.equal(dp.enabled, true)
assert.equal(dp.items.length, 1)
assert.equal(dp.items[0].label, '背单词')
})
check('快照带 dayPlan(current/next 形状)', () => {
const j = JSON.parse(dp1.body)
assert.ok(Array.isArray(j.dayPlan.items) && j.dayPlan.items.length === 1)
assert.ok(j.dayPlan.current === null || j.dayPlan.current.label === '背单词')
})
await post({ op: 'dailyPlan.update', patch: { enabled: false } })

// ---- 复习 / 复盘 / Markdown
const p7 = await post({ op: 'review.grade', title: 'POST 加的卡', grade: 'easy' })
check('POST review.grade 200', () => assert.equal(p7.code, 200))
Expand Down Expand Up @@ -342,6 +437,7 @@ globalThis.fetch = async () => ({
journal: {},
timer: null,
settings: { focusMin: 25, shortBreakMin: 5, longBreakMin: 15, roundsBeforeLong: 4, dailyGoalMin: 180, streakMin: 10, injectPrompt: true, subjects: [], heatmapWeeks: 26, reviewQueueSize: 10 },
dailyPlan: { enabled: true, float: true, items: [{ id: 'a', start: '08:00', end: '09:30', label: '背单词', subject: '英语' }] },
createdAt: 0,
updatedAt: 0,
},
Expand All @@ -350,6 +446,7 @@ globalThis.fetch = async () => ({
streak: 0,
focusRoundsToday: 0,
review: { scheduled: 0, due: 0, dueToday: 0, overdue: 0, doneToday: 0, weekDone: 0 },
dayPlan: { current: null, next: null, items: [{ id: 'a', start: '08:00', end: '09:30', label: '背单词', subject: '英语' }] },
due: [],
tags: [],
weekly: { weeks: [], goalWeekly: 1260 },
Expand Down Expand Up @@ -400,12 +497,12 @@ const clientCtx = {

check('client apply() 不抛', () => clientMod.apply(clientCtx))
// 两个面板:学习工作台 + 单词打卡,各自一对 sidebar.panellist / main
check('注册了 6 个槽位(两个面板 + 迷你条 + 设置页)', () => {
check('注册了 7 个槽位(两个面板 + 两个悬浮件 + 设置页)', () => {
const names = clientRegistered.slots.map((s) => s.slotName).sort()
assert.deepEqual(names, ['main', 'main', 'settings.section', 'shell.overlay', 'sidebar.panellist', 'sidebar.panellist'])
assert.deepEqual(names, ['main', 'main', 'settings.section', 'shell.overlay', 'shell.overlay', 'sidebar.panellist', 'sidebar.panellist'])
})
check('inject 的槽位名与注册的一致', () => {
assert.deepEqual(clientRegistered.injects.slice().sort(), ['main', 'main', 'settings.section', 'shell.overlay', 'sidebar.panellist', 'sidebar.panellist'])
assert.deepEqual(clientRegistered.injects.slice().sort(), ['main', 'main', 'settings.section', 'shell.overlay', 'shell.overlay', 'sidebar.panellist', 'sidebar.panellist'])
})
check('两个侧栏面板的 id 与两个 main 的 key 逐一对齐', () => {
const ids = clientRegistered.slots.filter((x) => x.slotName === 'sidebar.panellist').map((x) => x.id).sort()
Expand Down Expand Up @@ -443,9 +540,13 @@ check('每个槽位的 factory 都用 Boundary 包了一层', () => {
assert.equal(node.__el.name, 'Boundary', `${s.slotName} 没被 Boundary 包住,实际是 ${node.__el && node.__el.name}`)
}
})
check('Boundary 的 where 标签能区分两个面板', () => {
check('Boundary 的 where 标签能区分两个面板与两个悬浮件', () => {
const wheres = clientRegistered.slots.map((s) => s.factory({ size: 18 }).props.where).sort()
assert.deepEqual(wheres, ['main', 'main.bbdc', 'settings.section', 'shell.overlay', 'sidebar.panellist', 'sidebar.panellist.bbdc'])
assert.deepEqual(wheres, ['main', 'main.bbdc', 'settings.section', 'shell.overlay', 'shell.overlay.plandock', 'sidebar.panellist', 'sidebar.panellist.bbdc'])
})
check('两个 shell.overlay 的 id 不冲突(宿主按 id 挂槽位)', () => {
const ids = clientRegistered.slots.filter((x) => x.slotName === 'shell.overlay').map((x) => x.id).sort()
assert.deepEqual(ids, ['study-desk-mini', 'study-desk-plan'])
})
check('注入了一份 style', () => assert.ok(clientRegistered.effects.length > 0))

Expand Down
Loading
Loading