dsh-global-todo 0.12.8 → 0.12.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +45 -0
- package/README.md +6 -3
- package/README.zh.md +1 -1
- package/lib/capture.js +6 -0
- package/lib/client.js +18 -0
- package/lib/errors.js +3 -0
- package/lib/index.js +14 -1
- package/lib/ingest.js +4 -1
- package/lib/model.js +44 -4
- package/lib/routes.js +61 -1
- package/lib/service.js +32 -1
- package/package.json +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,48 @@
|
|
|
1
|
+
## [0.12.10] — 2026-10-21
|
|
2
|
+
|
|
3
|
+
Two follow-ups from #3, plus the notification channel this repository actually has.
|
|
4
|
+
|
|
5
|
+
- **`/health` names what waits for review.** `review.count` said something was waiting;
|
|
6
|
+
`review.items` says which (id, seq, title, doneAt, newest first) — checking the state no longer
|
|
7
|
+
means opening the panel and clicking through the list.
|
|
8
|
+
- **The panel says when the running version is older than the installed one.** `stale` was added to
|
|
9
|
+
`/health` in 0.12.9; the panel now shows a one-time notice naming both versions and saying to
|
|
10
|
+
restart `dsh web`. This is the trap where the client is new, the module graph is old, and the
|
|
11
|
+
panel calls routes that 404.
|
|
12
|
+
- **`tools/notify.mjs`** — pushes release / CI-failed / new-issue messages to WeChat through the
|
|
13
|
+
host's delivery library, which owns the freshness window and the outbox. `--dry-run` composes and
|
|
14
|
+
reports the window without sending; a closed window queues instead of failing.
|
|
15
|
+
|
|
16
|
+
## [0.12.9] — 2026-10-21
|
|
17
|
+
|
|
18
|
+
Everything a Windows tester found in a 41-case sweep, answered (#3).
|
|
19
|
+
|
|
20
|
+
- **`P0`–`P3` are accepted as input.** The panel printed the labels and the API rejected them.
|
|
21
|
+
`priority: "P0"` now means `urgent` (likewise `P1`–`P3`), in create and in patch.
|
|
22
|
+
- **A paraphrase of something finished minutes ago is the same errand.** The 7-day cooldown only
|
|
23
|
+
compared exact titles, so 「T3 逾期项(再办一次)」 slipped past a row completed a minute earlier.
|
|
24
|
+
Similar wording now counts, with its own reason (`similar-recently-done`).
|
|
25
|
+
- **`POST /note` needs text.** An empty note appended a nameless entry to the activity trail; it is
|
|
26
|
+
now `400 invalid-body`.
|
|
27
|
+
- **`POST /diagnostics` needs a scope.** Without it every report collapsed into one nameless bucket
|
|
28
|
+
in `/health`.
|
|
29
|
+
- **A missing `id` is a bad body, not a missing record.** `POST /dispatch {}` answered 404; it is 400
|
|
30
|
+
now, matching the documented error contract.
|
|
31
|
+
- **One bad ingest row no longer aborts the batch.** Each item is isolated (like capture), and the
|
|
32
|
+
ingest title cap is the model's own 200 rather than a second opinion at 500.
|
|
33
|
+
- **Out-of-range settings are reported.** `{idleMinutes: -5}` answered 200 with 5; a provided value
|
|
34
|
+
outside its range is `400 invalid-body` instead of a silent clamp.
|
|
35
|
+
- **`POST /restore` refuses partial records.** `{id, title}` used to restore with an invented
|
|
36
|
+
`createdAt`, empty tags and a reassigned `seq`; a lossy success is worse than a refusal.
|
|
37
|
+
- **`/health` says when the installed package is newer than the running module**
|
|
38
|
+
(`packageVersion`, `stale`), which is the silent trap behind "I upgraded and the panel calls a
|
|
39
|
+
route that 404s".
|
|
40
|
+
- **Smaller ones**: `invalid-evidence` / `invalid-steps` / `invalid-decision` are now listed in
|
|
41
|
+
`ERROR_CODES`; an over-long step is rejected like an over-long list (both used to differ); the
|
|
42
|
+
Markdown section notes that `doing`/`cancelled` come back as `open`.
|
|
43
|
+
- **Reviewed, not changed**: an agent completion already sets `review.pending` in every path
|
|
44
|
+
(`model.js:545/641/685`), so the auto-complete case the report describes is covered since 0.7.
|
|
45
|
+
|
|
1
46
|
## [0.12.8] — 2026-10-21
|
|
2
47
|
|
|
3
48
|
The Windows runner kept finding things after 0.12.7. Four more, all real.
|
package/README.md
CHANGED
|
@@ -94,7 +94,9 @@ description, steps, attachments and progress notes stay.
|
|
|
94
94
|
|
|
95
95
|
### Markdown in and out
|
|
96
96
|
|
|
97
|
-
The JSON export is the round-trip format. Markdown is the one you read, diff and edit by hand
|
|
97
|
+
The JSON export is the round-trip format. Markdown is the one you read, diff and edit by hand
|
|
98
|
+
(note that `doing` and `cancelled` come back as `open`: a hand-edited list has no work-in-progress
|
|
99
|
+
state, and the JSON remains the format that round-trips exactly):
|
|
98
100
|
|
|
99
101
|
```text
|
|
100
102
|
- [ ] 标题
|
|
@@ -288,7 +290,8 @@ Your list is not touched by install or uninstall: it lives outside the plugin, a
|
|
|
288
290
|
wall of rows. A conversation with one item shows that row directly; items with no source
|
|
289
291
|
conversation (added in the panel) sit under 手动添加. `time` is the original
|
|
290
292
|
overdue / today / soon / later / no-due buckets, `level` groups by priority.
|
|
291
|
-
- **Levels P0–P3** — `P0 urgent / P1 high / P2 normal (default) / P3 low
|
|
293
|
+
- **Levels P0–P3** — `P0 urgent / P1 high / P2 normal (default) / P3 low`; the API accepts
|
|
294
|
+
either the canonical names or the labels (`priority: "P0"` works), with a badge on the
|
|
292
295
|
row. Records written before levels existed keep their `high/normal/low`, so nothing needed a
|
|
293
296
|
migration. A conversation group shows the highest level it holds, and a green dot marks a
|
|
294
297
|
group with a conversation still running.
|
|
@@ -378,7 +381,7 @@ fence when that service is present, and by a strict loopback check otherwise.
|
|
|
378
381
|
| POST | `/api/global-todo/capture` | `{ sessionId? }` — ask the current conversation to collect its follow-ups |
|
|
379
382
|
| POST | `/api/global-todo/bulk` | `{ ids, status?, addTags?, removeTags? }` — one change across a selection; a stale id lands in `unmatched` instead of failing the batch |
|
|
380
383
|
| POST | `/api/global-todo/ingest` | `{ items: [{ title, source: { kind }, confidence }], dryRun? }` with `Authorization: Bearer <settings.ingestToken>` — external items; below `ingestConfirmBelow` they wait in the inbox |
|
|
381
|
-
| GET | `/api/global-todo/health` | — (store location, counts, dispatch capabilities, plus **tool registration** and **whether the completion watcher attached** — both fail quietly, so both are reported — and `metrics`: dispatch success rate, agent tool calls, list growth; `autoCollect`: why each waiting conversation was or was not asked, counters, and how the settings file loaded) |
|
|
384
|
+
| GET | `/api/global-todo/health` | — (store location, counts, dispatch capabilities, plus **tool registration** and **whether the completion watcher attached** — both fail quietly, so both are reported — and `metrics`: dispatch success rate, agent tool calls, list growth; `autoCollect`: why each waiting conversation was or was not asked, counters, and how the settings file loaded; `version` is the running module and `packageVersion`/`stale` say whether the installed package is newer than the running one; `review.items` names the finished todos that still wait for a person, so a check does not have to drive the panel) |
|
|
382
385
|
| POST | `/api/global-todo/create` | `{ title, description?, links?, steps?, tags?, priority?, dueAt? }` |
|
|
383
386
|
| POST | `/api/global-todo/update` | `{ id, ...fields, by?: human\|agent }` |
|
|
384
387
|
| POST | `/api/global-todo/status` | `{ id, status, by?: human\|agent }` |
|
package/README.zh.md
CHANGED
|
@@ -217,7 +217,7 @@ dsh plugin --profile web remove dsh-global-todo
|
|
|
217
217
|
`auto` 优先复用这条待办已绑定的会话、其次是当前对话、最后才新开;`new` 总是新开对话;`bound` 只发给已绑定的对话,不在就明确报出原因。
|
|
218
218
|
找不到任何可用的对话时**明确失败**,不会偷偷开一个新的。
|
|
219
219
|
- **分组维度**:工具栏上有 `对话 / 时间 / 级别` 三个维度,**默认按对话**——同一个 agent 对话提交的待办会**合并成一条**(显示「N 条」,点开才展开),这样几次「抓一下待办」不会把清单糊成一片。单条的对话直接显示那一行;没有来源对话的(在面板里手加的)归到「手动添加」。`时间` 是原来的逾期 / 今天 / 接下来 / 以后 / 没有截止时间,`级别` 按高 / 一般 / 低分组。
|
|
220
|
-
- **级别 P0–P3**:每条待办可设 `P0 紧急 / P1 高 / P2 常规 / P3
|
|
220
|
+
- **级别 P0–P3**:每条待办可设 `P0 紧急 / P1 高 / P2 常规 / P3 低`;接口两种写法都收(`priority: "P0"` 或 `"urgent"`),旧记录里的 high/normal/low 原样有效,默认 P2。行上有徽标,对话分组头显示组内**最高级别**,绿色小圆点表示该对话里还有在跑的会话。
|
|
221
221
|
- **从本对话抓取**:面板上一个按钮,把「请把这段对话里需要跟进的事用 `global_todo_capture` 抓一下」注入你正在说话的那个对话——聊着聊着把该跟进的事一次变成待办,不用自己一条条敲。提示词里同样写清了「不要记什么」(会话计划、闲聊、已完成的事),否则抓取只会制造噪音。
|
|
222
222
|
- **自动完成**:派发出去的会话**干净结束**时,宿主自己把这条待办标成完成,并附上**它最后那段回复**作为完成说明、**这一轮改动的文件**作为产出——不用等模型记得调工具。面板上完成的行显示「完成于 …」加产出芯片,展开详情有「完成记录」段(说明 + 产出路径,可点开或复制)。
|
|
223
223
|
两条护栏值得知道:**只对刚派发过的会话生效**(你在那个会话里随便聊两句不会把待办完成掉),**失败 / 中断的回合不标记完成**(宁可留着未完成,也不能让一份干砸的活显示成已完成)。
|
package/lib/capture.js
CHANGED
|
@@ -91,6 +91,11 @@ export function findDuplicate(input) {
|
|
|
91
91
|
if (now - created < windowMs && similarity(title, todo.title) >= threshold) {
|
|
92
92
|
return { duplicate: true, reason: 'similar-active', matchId: todo.id }
|
|
93
93
|
}
|
|
94
|
+
} else if (now - doneAt < cooldownMs && similarity(title, todo.title) >= threshold) {
|
|
95
|
+
// The cooldown was exact-title only, so 「T3 逾期项」 finished two minutes ago and
|
|
96
|
+
// 「T3 逾期项(再办一次)」 was accepted — the same errand, re-registered. Similar
|
|
97
|
+
// wording counts here for the same reason it counts among active rows.
|
|
98
|
+
return { duplicate: true, reason: 'similar-recently-done', matchId: todo.id }
|
|
94
99
|
}
|
|
95
100
|
}
|
|
96
101
|
return { duplicate: false }
|
|
@@ -101,4 +106,5 @@ export const CAPTURE_REASONS = Object.freeze({
|
|
|
101
106
|
'same-active': '清单里已有同名的未完成条目,未重复登记',
|
|
102
107
|
'similar-active': '清单里已有一条很相似的未完成条目,未重复登记',
|
|
103
108
|
'recently-done': '这条刚完成不久(7 天内),没有重复登记',
|
|
109
|
+
'similar-recently-done': '这件事刚完成不久(7 天内),措辞不同也视为同一件,未重复登记',
|
|
104
110
|
})
|
package/lib/client.js
CHANGED
|
@@ -175,6 +175,9 @@ window.__ModuleLoader__.load({
|
|
|
175
175
|
'notice.autoCollectOn': '已开启自动获取:一次人为发言最多自动问一次',
|
|
176
176
|
'notice.autoCollectOff': '已关闭自动获取',
|
|
177
177
|
'notice.externalChange': '清单在别处被改过,已载入最新内容',
|
|
178
|
+
// Installed newer than running: the panel would otherwise call routes the old module
|
|
179
|
+
// graph does not have, and look broken until someone happens to restart the Host.
|
|
180
|
+
'notice.stale': '已安装 {installed},当前运行的是 {running};重启 dsh web 后生效',
|
|
178
181
|
'reminder.title': '到点提醒({n})',
|
|
179
182
|
'reminder.note': '这些是到了设定的提醒时间,和「逾期」不是同一批。',
|
|
180
183
|
'reminder.dismissAll': '全部知道了',
|
|
@@ -472,6 +475,7 @@ window.__ModuleLoader__.load({
|
|
|
472
475
|
'notice.autoCollectOn': 'Auto-collect on: at most one question per message from you',
|
|
473
476
|
'notice.autoCollectOff': 'Auto-collect off',
|
|
474
477
|
'notice.externalChange': 'The list changed somewhere else; the latest content is loaded',
|
|
478
|
+
'notice.stale': 'Installed {installed}, running {running}; restart dsh web to finish the upgrade',
|
|
475
479
|
'reminder.title': 'Due now ({n})',
|
|
476
480
|
'reminder.note': 'These reached their reminder time — a different set from Overdue.',
|
|
477
481
|
'reminder.dismissAll': 'Dismiss all',
|
|
@@ -2715,6 +2719,9 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
2715
2719
|
// The store counts file changes made outside this process (another machine, a sync
|
|
2716
2720
|
// client). A new count means the panel's view was stale a moment ago; say so once.
|
|
2717
2721
|
const externalRef = useRef(null)
|
|
2722
|
+
// Warn once per mount: a version mismatch is a fact about this page load, not an
|
|
2723
|
+
// event worth repeating on every refresh.
|
|
2724
|
+
const staleRef = useRef(false)
|
|
2718
2725
|
// The wide-screen detail card floats over the panel, so its offsets are measured from
|
|
2719
2726
|
// the panel's own column rather than derived from the viewport width.
|
|
2720
2727
|
const innerRef = useRef(null)
|
|
@@ -2782,6 +2789,17 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
2782
2789
|
setTimeout(() => setNotice(null), 8000)
|
|
2783
2790
|
}
|
|
2784
2791
|
externalRef.current = external
|
|
2792
|
+
if (data?.stale === true && !staleRef.current) {
|
|
2793
|
+
staleRef.current = true
|
|
2794
|
+
setNotice({
|
|
2795
|
+
text: translate(lang, 'notice.stale', {
|
|
2796
|
+
installed: String(data.packageVersion ?? '?'),
|
|
2797
|
+
running: String(data.version ?? '?'),
|
|
2798
|
+
}),
|
|
2799
|
+
sessionId: '',
|
|
2800
|
+
})
|
|
2801
|
+
setTimeout(() => setNotice(null), 12000)
|
|
2802
|
+
}
|
|
2785
2803
|
setState((current) => ({ ...current, health: data }))
|
|
2786
2804
|
} catch {
|
|
2787
2805
|
/* the health mirror is not worth an error banner */
|
package/lib/errors.js
CHANGED
package/lib/index.js
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
* @module dsh-global-todo
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
+
import { readFileSync } from 'node:fs'
|
|
23
24
|
import { readFile, writeFile } from 'node:fs/promises'
|
|
24
25
|
import path from 'node:path'
|
|
25
26
|
|
|
@@ -36,7 +37,7 @@ import { createStore, resolveStorePath } from './store.js'
|
|
|
36
37
|
import { createTools } from './tools.js'
|
|
37
38
|
|
|
38
39
|
/** Must equal `package.json`'s version; `npm run check` enforces that. */
|
|
39
|
-
export const VERSION = '0.12.
|
|
40
|
+
export const VERSION = '0.12.10'
|
|
40
41
|
|
|
41
42
|
/** Plugin id. Also the Loader row id in `cordis.patch.yml`. */
|
|
42
43
|
export const name = 'dsh-global-todo'
|
|
@@ -525,6 +526,15 @@ export function apply(ctx, config) {
|
|
|
525
526
|
|
|
526
527
|
ctx.inject(['webServer'], (webCtx) => {
|
|
527
528
|
webCtx.effect(() => {
|
|
529
|
+
// The running module is the truth for behaviour; package.json is the truth for what
|
|
530
|
+
// was installed. When they differ the host is still executing the old graph, and
|
|
531
|
+
// nothing else in the system says so.
|
|
532
|
+
let diskVersion
|
|
533
|
+
try {
|
|
534
|
+
diskVersion = JSON.parse(readFileSync(path.join(import.meta.dirname, '..', 'package.json'), 'utf8')).version
|
|
535
|
+
} catch {
|
|
536
|
+
diskVersion = undefined
|
|
537
|
+
}
|
|
528
538
|
const routes = createRoutes({
|
|
529
539
|
service,
|
|
530
540
|
dispatcher,
|
|
@@ -545,6 +555,9 @@ export function apply(ctx, config) {
|
|
|
545
555
|
autoCollect: autoCollectState,
|
|
546
556
|
reminders: reminderState,
|
|
547
557
|
version: VERSION,
|
|
558
|
+
// What is on disk, which after an upgrade is not what this process is running.
|
|
559
|
+
// The panel talks to a tree that changed under it; /health is where that is visible.
|
|
560
|
+
diskVersion,
|
|
548
561
|
})
|
|
549
562
|
const disposers = routes.map((definition) => webCtx.webServer.register(definition))
|
|
550
563
|
webCtx.logger?.info?.(`dsh-global-todo: 注册了 ${disposers.length} 条路由(${ROUTE_BASE})`)
|
package/lib/ingest.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* @module dsh-global-todo/ingest
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
+
import { LIMITS } from './model.js'
|
|
12
13
|
import { INGEST_SOURCES } from './settings.js'
|
|
13
14
|
|
|
14
15
|
/** Tag that marks a record as waiting for a person. */
|
|
@@ -28,7 +29,9 @@ export function normalizeIngestItem(raw, options = {}) {
|
|
|
28
29
|
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return { error: '条目必须是对象' }
|
|
29
30
|
const title = typeof raw.title === 'string' ? raw.title.trim() : ''
|
|
30
31
|
if (title === '') return { error: '缺 title' }
|
|
31
|
-
|
|
32
|
+
// The model's own limit, not a second opinion: a 500-character title passed here and was
|
|
33
|
+
// then rejected by create(), which aborted the whole batch instead of skipping one row.
|
|
34
|
+
if (title.length > LIMITS.title) return { error: `title 超过 ${LIMITS.title} 字` }
|
|
32
35
|
const source = raw.source !== null && typeof raw.source === 'object' && !Array.isArray(raw.source) ? raw.source : {}
|
|
33
36
|
const kind = typeof source.kind === 'string' ? source.kind.trim().toLowerCase() : ''
|
|
34
37
|
if (!INGEST_SOURCES.includes(kind)) return { error: `source.kind 必须是 ${INGEST_SOURCES.join(' / ')}` }
|
package/lib/model.js
CHANGED
|
@@ -40,6 +40,23 @@ export const PRIORITY_LABELS = Object.freeze({
|
|
|
40
40
|
low: 'P3 低',
|
|
41
41
|
})
|
|
42
42
|
|
|
43
|
+
/**
|
|
44
|
+
* The labels are also accepted on the way in.
|
|
45
|
+
*
|
|
46
|
+
* The panel and the tools show `P0`–`P3` everywhere, so a person (or a model reading the
|
|
47
|
+
* schema description) will eventually send `"P0"`; rejecting it with `invalid-priority`
|
|
48
|
+
* while the UI prints exactly that string is a trap, not a validation.
|
|
49
|
+
*/
|
|
50
|
+
export const PRIORITY_ALIASES = Object.freeze({ p0: 'urgent', p1: 'high', p2: 'normal', p3: 'low' })
|
|
51
|
+
|
|
52
|
+
/** @param {unknown} value @returns {string} a canonical priority, or '' when unusable */
|
|
53
|
+
function canonicalPriority(value) {
|
|
54
|
+
if (typeof value !== 'string') return ''
|
|
55
|
+
const key = value.trim().toLowerCase()
|
|
56
|
+
if (PRIORITIES.includes(key)) return key
|
|
57
|
+
return PRIORITY_ALIASES[key] ?? ''
|
|
58
|
+
}
|
|
59
|
+
|
|
43
60
|
/** Who performed a write. Recorded on every entry of the activity trail. */
|
|
44
61
|
export const ACTORS = Object.freeze(['human', 'agent'])
|
|
45
62
|
|
|
@@ -139,6 +156,21 @@ export function normalizeTags(value) {
|
|
|
139
156
|
* @param {string} fallback
|
|
140
157
|
* @returns {string}
|
|
141
158
|
*/
|
|
159
|
+
/**
|
|
160
|
+
* Priority accepts the canonical names and the P0–P3 labels the panel prints.
|
|
161
|
+
*
|
|
162
|
+
* @param {unknown} value
|
|
163
|
+
* @param {string} fallback
|
|
164
|
+
* @returns {string}
|
|
165
|
+
*/
|
|
166
|
+
function normalizePriorityField(value, fallback) {
|
|
167
|
+
if (value === undefined || value === null || value === '') return fallback
|
|
168
|
+
const canonical = canonicalPriority(value)
|
|
169
|
+
if (canonical === '') {
|
|
170
|
+
throw new TodoError('invalid-priority', `优先级只能是 ${PRIORITIES.join(' / ')}(或 P0–P3 标签),收到 ${String(value)}`)
|
|
171
|
+
}
|
|
172
|
+
return canonical
|
|
173
|
+
}
|
|
142
174
|
function normalizeEnum(value, allowed, code, field, fallback) {
|
|
143
175
|
if (value === undefined || value === null || value === '') return fallback
|
|
144
176
|
if (typeof value !== 'string') throw new TodoError(code, `${field} 必须是字符串`)
|
|
@@ -204,7 +236,7 @@ export function createTodo(input, options = {}) {
|
|
|
204
236
|
title,
|
|
205
237
|
description: normalizeText(raw.description, '描述', LIMITS.description, 'invalid-description'),
|
|
206
238
|
status: normalizeEnum(raw.status, STATUSES, 'invalid-status', '状态', 'open'),
|
|
207
|
-
priority:
|
|
239
|
+
priority: normalizePriorityField(raw.priority, 'normal'),
|
|
208
240
|
dueAt: normalizeDue(raw.dueAt ?? raw.due),
|
|
209
241
|
tags: normalizeTags(raw.tags),
|
|
210
242
|
links,
|
|
@@ -563,7 +595,7 @@ export function applyPatch(todo, patch, options = {}) {
|
|
|
563
595
|
}
|
|
564
596
|
}
|
|
565
597
|
if (patch.priority !== undefined) {
|
|
566
|
-
const priority =
|
|
598
|
+
const priority = normalizePriorityField(patch.priority, todo.priority)
|
|
567
599
|
if (priority !== todo.priority) {
|
|
568
600
|
todo.priority = priority
|
|
569
601
|
changed.push('priority')
|
|
@@ -684,7 +716,12 @@ export function normalizeSteps(value) {
|
|
|
684
716
|
const out = []
|
|
685
717
|
for (const item of value) {
|
|
686
718
|
if (typeof item === 'string') {
|
|
687
|
-
const text = item.trim()
|
|
719
|
+
const text = item.trim()
|
|
720
|
+
// Over-long text used to be truncated silently while an over-long list was rejected;
|
|
721
|
+
// two limits, two behaviours, and the quiet one loses words. Both refuse now.
|
|
722
|
+
if (text.length > LIMITS.step) {
|
|
723
|
+
throw new TodoError('invalid-steps', `每一步最多 ${LIMITS.step} 字,收到 ${text.length} 字`)
|
|
724
|
+
}
|
|
688
725
|
// A blank line in a textarea is a typo, not a step.
|
|
689
726
|
if (text !== '') out.push({ text, done: false })
|
|
690
727
|
continue
|
|
@@ -692,7 +729,10 @@ export function normalizeSteps(value) {
|
|
|
692
729
|
if (item === null || typeof item !== 'object' || Array.isArray(item)) {
|
|
693
730
|
throw new TodoError('invalid-steps', '步骤必须是字符串或 { text, done }')
|
|
694
731
|
}
|
|
695
|
-
const text = typeof item.text === 'string' ? item.text.trim()
|
|
732
|
+
const text = typeof item.text === 'string' ? item.text.trim() : ''
|
|
733
|
+
if (text.length > LIMITS.step) {
|
|
734
|
+
throw new TodoError('invalid-steps', `每一步最多 ${LIMITS.step} 字,收到 ${text.length} 字`)
|
|
735
|
+
}
|
|
696
736
|
if (text === '') continue
|
|
697
737
|
out.push({ text, done: item.done === true })
|
|
698
738
|
}
|
package/lib/routes.js
CHANGED
|
@@ -419,8 +419,16 @@ export function createRoutes(deps) {
|
|
|
419
419
|
return {
|
|
420
420
|
plugin: 'dsh-global-todo',
|
|
421
421
|
version,
|
|
422
|
+
// `version` is what this process runs; `packageVersion` is what is installed. After
|
|
423
|
+
// an upgrade the two differ until the host restarts, and the panel then calls routes
|
|
424
|
+
// the old graph does not have (a 404 that looks like a plugin bug).
|
|
425
|
+
packageVersion: deps.diskVersion ?? version,
|
|
426
|
+
stale: deps.diskVersion !== undefined && deps.diskVersion !== version,
|
|
422
427
|
store: await service.describe(),
|
|
423
428
|
counts,
|
|
429
|
+
// The count says something waits for a person; the items say what, so a check does not
|
|
430
|
+
// have to drive the UI to find out.
|
|
431
|
+
review: { count: counts.review, items: await service.awaitingReview() },
|
|
424
432
|
// Purely additive, in-memory counters: hand-off success rate, agent tool
|
|
425
433
|
// calls and how the list grew. `null` when they were never mounted — an
|
|
426
434
|
// absent metric is not a zero.
|
|
@@ -510,6 +518,11 @@ export function createRoutes(deps) {
|
|
|
510
518
|
|
|
511
519
|
exact('POST', `${ROUTE_BASE}/diagnostics`, async (request) => {
|
|
512
520
|
const body = await readJsonBody(request)
|
|
521
|
+
// `scope` is the grouping key in /health; without it every entry collapses into one
|
|
522
|
+
// nameless bucket, so it is required rather than defaulted.
|
|
523
|
+
if (typeof body.scope !== 'string' || body.scope.trim() === '') {
|
|
524
|
+
throw new TodoError('invalid-body', 'diagnostics 需要 scope(如 health / list / dispatch)')
|
|
525
|
+
}
|
|
513
526
|
// The panel's failures happen in someone else's browser, where neither the Host
|
|
514
527
|
// nor the person wants to be reading consoles. Reporting them here is what makes
|
|
515
528
|
// "click it and tell me what it said" unnecessary.
|
|
@@ -519,6 +532,10 @@ export function createRoutes(deps) {
|
|
|
519
532
|
|
|
520
533
|
exact('POST', `${ROUTE_BASE}/settings`, async (request) => {
|
|
521
534
|
const body = await readJsonBody(request)
|
|
535
|
+
// The settings store clamps silently, which is right for a hand-edited file and wrong
|
|
536
|
+
// for an API: `{idleMinutes: -5}` answering 200-but-5 hides the rejected intent. A
|
|
537
|
+
// provided value outside its range is reported instead.
|
|
538
|
+
assertSettingsRange(body ?? {})
|
|
522
539
|
const next = await deps.settings.update(body ?? {})
|
|
523
540
|
return {
|
|
524
541
|
settings: {
|
|
@@ -605,6 +622,11 @@ export function createRoutes(deps) {
|
|
|
605
622
|
|
|
606
623
|
exact('POST', `${ROUTE_BASE}/note`, async (request) => {
|
|
607
624
|
const body = await readJsonBody(request)
|
|
625
|
+
// A note with no text records an empty entry in the activity trail: the README calls
|
|
626
|
+
// `text` required, so an empty one is a bad body, not a note.
|
|
627
|
+
if (typeof body.text !== 'string' || body.text.trim() === '') {
|
|
628
|
+
throw new TodoError('invalid-body', 'note 需要非空的 text')
|
|
629
|
+
}
|
|
608
630
|
const { todo, note } = await service.note(body.id, body.text, { by: 'human' })
|
|
609
631
|
return { todo, note }
|
|
610
632
|
}),
|
|
@@ -644,7 +666,18 @@ export function createRoutes(deps) {
|
|
|
644
666
|
// The panel's undo: put a removed record back verbatim, timestamps and
|
|
645
667
|
// attachments included, instead of re-creating it as a new todo.
|
|
646
668
|
const body = await readJsonBody(request)
|
|
647
|
-
|
|
669
|
+
// A partial object would be "restored" with invented defaults (a new createdAt, empty
|
|
670
|
+
// tags, a reassigned seq) — a lossy success is worse than a refusal.
|
|
671
|
+
const todo = body.todo
|
|
672
|
+
if (todo === null || typeof todo !== 'object' || Array.isArray(todo)) {
|
|
673
|
+
throw new TodoError('invalid-body', 'restore 需要完整的 todo 对象')
|
|
674
|
+
}
|
|
675
|
+
for (const field of ['id', 'title', 'createdAt', 'status']) {
|
|
676
|
+
if (todo[field] === undefined || todo[field] === null || todo[field] === '') {
|
|
677
|
+
throw new TodoError('invalid-body', `restore 的 todo 缺少 ${field};撤销要原样放回,不能补默认值`)
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
return { todo: await service.restore(todo, { by: 'human' }) }
|
|
648
681
|
}),
|
|
649
682
|
|
|
650
683
|
exact('POST', `${ROUTE_BASE}/link`, async (request) => {
|
|
@@ -681,6 +714,10 @@ export function createRoutes(deps) {
|
|
|
681
714
|
|
|
682
715
|
exact('POST', `${ROUTE_BASE}/dispatch`, async (request) => {
|
|
683
716
|
const body = await readJsonBody(request)
|
|
717
|
+
// A missing id is a malformed request (400), not a record that does not exist (404).
|
|
718
|
+
if (typeof body.id !== 'string' || body.id.trim() === '') {
|
|
719
|
+
throw new TodoError('invalid-body', 'dispatch 需要 id')
|
|
720
|
+
}
|
|
684
721
|
return dispatcher.dispatch(body.id, {
|
|
685
722
|
mode: body.mode,
|
|
686
723
|
sessionId: body.sessionId,
|
|
@@ -698,4 +735,27 @@ export function createRoutes(deps) {
|
|
|
698
735
|
]
|
|
699
736
|
}
|
|
700
737
|
|
|
738
|
+
/**
|
|
739
|
+
* Ranges the settings route refuses, mirroring the clamps in `normalizeSettings`.
|
|
740
|
+
*
|
|
741
|
+
* @param {Record<string, unknown>} body
|
|
742
|
+
*/
|
|
743
|
+
function assertSettingsRange(body) {
|
|
744
|
+
const ranges = {
|
|
745
|
+
idleMinutes: [5, 24 * 60],
|
|
746
|
+
cooldownMinutes: [5, 24 * 60],
|
|
747
|
+
ingestConfirmBelow: [0, 1],
|
|
748
|
+
reminderLeadMinutes: [0, 24 * 60],
|
|
749
|
+
}
|
|
750
|
+
for (const [field, [low, high]] of Object.entries(ranges)) {
|
|
751
|
+
const value = body[field]
|
|
752
|
+
if (value === undefined || value === null) continue
|
|
753
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
754
|
+
throw new TodoError('invalid-body', `${field} 必须是数字(${low}–${high})`)
|
|
755
|
+
}
|
|
756
|
+
if (value < low || value > high) {
|
|
757
|
+
throw new TodoError('invalid-body', `${field} 必须在 ${low}–${high} 之间,收到 ${value}`)
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
}
|
|
701
761
|
export { sendJson, readJsonBody }
|
package/lib/service.js
CHANGED
|
@@ -775,7 +775,19 @@ export function createService(options) {
|
|
|
775
775
|
preview.push({ title: verdict.item.title, tags: verdict.item.tags, inbox: verdict.inbox })
|
|
776
776
|
continue
|
|
777
777
|
}
|
|
778
|
-
|
|
778
|
+
// One bad row must not take the batch down with it: `global_todo_capture` already
|
|
779
|
+
// isolates each create, and the HTTP door should behave the same way.
|
|
780
|
+
let todo
|
|
781
|
+
try {
|
|
782
|
+
todo = await create(verdict.item, { by: 'agent' })
|
|
783
|
+
} catch (error) {
|
|
784
|
+
skipped.push({
|
|
785
|
+
title: verdict.item.title.slice(0, 80),
|
|
786
|
+
reason: 'invalid',
|
|
787
|
+
error: error?.message ?? String(error),
|
|
788
|
+
})
|
|
789
|
+
continue
|
|
790
|
+
}
|
|
779
791
|
created.push(todo)
|
|
780
792
|
existing.push(todo)
|
|
781
793
|
}
|
|
@@ -809,10 +821,29 @@ export function createService(options) {
|
|
|
809
821
|
return counts(todos, now())
|
|
810
822
|
}
|
|
811
823
|
|
|
824
|
+
/**
|
|
825
|
+
* Which finished todos an agent marked done that a person has not confirmed.
|
|
826
|
+
*
|
|
827
|
+
* The count in `/health` says something is waiting; this says what, so a check does not
|
|
828
|
+
* have to open the panel and click through the list to find out.
|
|
829
|
+
*
|
|
830
|
+
* @param {number} [limit] newest first, capped
|
|
831
|
+
* @returns {Promise<{ id: string, seq: number, title: string, doneAt: number }[]>}
|
|
832
|
+
*/
|
|
833
|
+
async function awaitingReview(limit = 20) {
|
|
834
|
+
const todos = await store.all()
|
|
835
|
+
return todos
|
|
836
|
+
.filter((todo) => todo.status === 'done' && todo.review?.pending === true)
|
|
837
|
+
.sort((left, right) => (right.doneAt ?? right.updatedAt ?? 0) - (left.doneAt ?? left.updatedAt ?? 0))
|
|
838
|
+
.slice(0, limit)
|
|
839
|
+
.map((todo) => ({ id: todo.id, seq: todo.seq, title: todo.title, doneAt: todo.doneAt ?? todo.updatedAt ?? 0 }))
|
|
840
|
+
}
|
|
841
|
+
|
|
812
842
|
return {
|
|
813
843
|
list,
|
|
814
844
|
listAll,
|
|
815
845
|
get,
|
|
846
|
+
awaitingReview,
|
|
816
847
|
create,
|
|
817
848
|
update,
|
|
818
849
|
setStatus,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-global-todo",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.10",
|
|
4
4
|
"icon": "./docs/icon.svg",
|
|
5
5
|
"description": "Cross-session todo list for DeepSeek Harness: one JSON file a person and an agent both write to, with step plans that ship as a numbered hand-off, session read-back, and a human confirmation before an agent's \"done\" counts.",
|
|
6
6
|
"type": "module",
|
|
@@ -73,6 +73,7 @@
|
|
|
73
73
|
"check": "node scripts/check.mjs",
|
|
74
74
|
"doctor": "node tools/doctor.mjs",
|
|
75
75
|
"check:windows": "node tools/windows-check.mjs",
|
|
76
|
+
"notify": "node tools/notify.mjs",
|
|
76
77
|
"test": "node --test \"test/**/*.test.mjs\"",
|
|
77
78
|
"verify": "npm run check && npm run check:render && npm test",
|
|
78
79
|
"preview": "node tools/preview.mjs",
|