dsh-global-todo 0.4.0 → 0.4.2
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 +32 -0
- package/README.md +8 -1
- package/README.zh.md +2 -1
- package/docs/DESIGN.md +8 -1
- package/docs/ROADMAP.md +9 -0
- package/docs/screenshots/panel-detail.png +0 -0
- package/docs/screenshots/panel-select.png +0 -0
- package/docs/screenshots/panel.png +0 -0
- package/lib/capture.js +104 -0
- package/lib/client.js +100 -2
- package/lib/index.js +1 -1
- package/lib/model.js +4 -4
- package/lib/routes.js +9 -0
- package/lib/service.js +50 -1
- package/lib/tools.js +19 -12
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,38 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.4.2] — 2026-10-19
|
|
11
|
+
|
|
12
|
+
Noise is the failure mode that matters for a list other people are asked to trust.
|
|
13
|
+
|
|
14
|
+
- **Capture decides with a gate, not a title comparison.** The old check compared normalized
|
|
15
|
+
titles exactly against active rows, which missed the ordinary case — same errand, different
|
|
16
|
+
wording — and happily re-admitted something finished yesterday. `lib/capture.js` now refuses an
|
|
17
|
+
item when its normalized title matches a live row, when a close paraphrase (Dice over character
|
|
18
|
+
bigrams, 0.8) appears within 72 hours, or when the same errand was finished in the last 7 days.
|
|
19
|
+
Archived and cancelled rows are set-aside history and do not block a new one; every refusal
|
|
20
|
+
carries its reason in `skipped[]`.
|
|
21
|
+
## [0.4.1] — 2026-10-19
|
|
22
|
+
|
|
23
|
+
The half-finished half: the selection could only be assigned, and the only way to get a record out
|
|
24
|
+
of the list was to delete it. Both are now actions of their own.
|
|
25
|
+
|
|
26
|
+
- **A selection can be completed, archived and tagged.** `POST /bulk` applies one change across the
|
|
27
|
+
picked rows, and the selection bar gained 完成 / 归档 / a tag field. Each record still goes through
|
|
28
|
+
the same validation and activity trail as a single edit; an id that no longer exists is reported
|
|
29
|
+
in `unmatched` rather than failing the whole batch, because a selection is a human gesture and one
|
|
30
|
+
stale row must not discard the other nine.
|
|
31
|
+
|
|
32
|
+
- **Archive instead of delete.** `archived` joined the statuses, so a finished-or-abandoned record can
|
|
33
|
+
leave the active view without losing its history. The panel offers 归档 / 取消归档 in the row menu,
|
|
34
|
+
the filter chip appears only once something is archived, and `global_todo_update` now accepts
|
|
35
|
+
`status` at all — before this, marking something in progress required the tool named
|
|
36
|
+
`global_todo_done`, which is exactly the kind of name/capability mismatch that makes a model misuse
|
|
37
|
+
a tool.
|
|
38
|
+
|
|
39
|
+
Still deliberately out of scope for this release: the wide two-column detail layout, and the
|
|
40
|
+
cross-machine conflict check — both deserve their own pass rather than riding along with a data-model
|
|
41
|
+
change.
|
|
10
42
|
## [0.4.0] — 2026-10-19
|
|
11
43
|
|
|
12
44
|
The first release after the plugin was listed in a community catalog. It is a reliability
|
package/README.md
CHANGED
|
@@ -194,7 +194,7 @@ Your list is not touched by install or uninstall: it lives outside the plugin, a
|
|
|
194
194
|
| `global_todo_capture` | **Record a batch**: for 「抓一下待办」 or a turn that produced several follow-ups; each entry is validated on its own |
|
|
195
195
|
| `global_todo_list` | Query by state, tag or text; every row carries its creation time |
|
|
196
196
|
| `global_todo_get` | Read one item in full, including attachments and the last hand-off |
|
|
197
|
-
| `global_todo_update` | Change any field of one item; `""` clears, `[]` empties |
|
|
197
|
+
| `global_todo_update` | Change any field of one item, including `status` (`archived` archives without deleting); `""` clears, `[]` empties |
|
|
198
198
|
| `global_todo_link` | Attach or detach documents and addresses |
|
|
199
199
|
| `global_todo_done` | Mark done, back to open, or in progress |
|
|
200
200
|
| `global_todo_reopen` | Put a **wrongly finished** item back (`done` / `cancelled` → `open`); refuses anything else, and the trail records it as the agent |
|
|
@@ -227,6 +227,12 @@ produces noise, and a noisy list is worse than an empty one:
|
|
|
227
227
|
The boundary: it **records, it never starts work**. The list belongs to the human, and
|
|
228
228
|
starting a task stays a human decision (see the non-goals in [ROADMAP](docs/ROADMAP.md)).
|
|
229
229
|
|
|
230
|
+
|
|
231
|
+
Capture is not allowed to become noise. Before an item is written, the same normalized title
|
|
232
|
+
(full-width, case, spacing and punctuation folded away), a close paraphrase within the last 72
|
|
233
|
+
hours (Dice similarity over character bigrams, 0.8), and the same errand finished in the last 7
|
|
234
|
+
days are all refused with a reason on the record; archived and cancelled rows do not block a
|
|
235
|
+
new one. The rules live in `lib/capture.js` and are tested on their own.
|
|
230
236
|
### HTTP API
|
|
231
237
|
|
|
232
238
|
The panel talks to the host over these routes. They are fenced by the host connection
|
|
@@ -240,6 +246,7 @@ fence when that service is present, and by a strict loopback check otherwise.
|
|
|
240
246
|
| GET | `/api/global-todo/export` | — (the store's own JSON document, for backup or migration) |
|
|
241
247
|
| POST | `/api/global-todo/import` | `{ doc, mode?: replace\|merge }` — `replace` is an exact round trip, `merge` updates by id and appends (what the panel sends) |
|
|
242
248
|
| POST | `/api/global-todo/capture` | `{ sessionId? }` — ask the current conversation to collect its follow-ups |
|
|
249
|
+
| 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 |
|
|
243
250
|
| 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) |
|
|
244
251
|
| POST | `/api/global-todo/create` | `{ title, description?, links?, tags?, priority?, dueAt? }` |
|
|
245
252
|
| POST | `/api/global-todo/update` | `{ id, ...fields, by?: human\|agent }` |
|
package/README.zh.md
CHANGED
|
@@ -120,7 +120,7 @@ dsh plugin --profile web remove dsh-global-todo
|
|
|
120
120
|
| `global_todo_capture` | **一次登记多条**(批量抓取):用户说「抓一下待办」,或你一轮里发现好几件要跟进的;逐条校验,坏的那条只跳过它自己 |
|
|
121
121
|
| `global_todo_list` | 按状态/标签/关键词查询,每条都带上加入时间 |
|
|
122
122
|
| `global_todo_get` | 读一条的全文,含附件与最近一次派发 |
|
|
123
|
-
| `global_todo_update` |
|
|
123
|
+
| `global_todo_update` | 改任意字段,包括 `status`(`archived` 归档而不删除);`""` 清空,`[]` 清空列表 |
|
|
124
124
|
| `global_todo_link` | 挂上或摘掉文档与地址 |
|
|
125
125
|
| `global_todo_done` | 标完成 / 标回未完成 / 标进行中 |
|
|
126
126
|
| `global_todo_reopen` | 把**被误标完成**的一条标回未完成(只做 `done` / `cancelled` → `open`);本来就未完成的会被拒绝,动作按 agent 记进活动轨迹 |
|
|
@@ -143,6 +143,7 @@ dsh plugin --profile web remove dsh-global-todo
|
|
|
143
143
|
| POST | `/api/global-todo/import` | `{ doc, mode?: replace\|merge }`——`replace` 是无损往返,`merge` 同 id 覆盖、新 id 追加(面板用的是这个) |
|
|
144
144
|
| POST | `/api/global-todo/note` | `{ id, text }`(追加进展记录) |
|
|
145
145
|
| POST | `/api/global-todo/capture` | `{ sessionId? }`(请当前对话把要跟进的事抓进清单) |
|
|
146
|
+
| POST | `/api/global-todo/bulk` | `{ ids, status?, addTags?, removeTags? }`——一次改一整批;失效的 id 进 `unmatched`,不会让整批失败 |
|
|
146
147
|
| GET | `/api/global-todo/health` | —(数据位置、统计、派发能力,以及**工具注册状态**与**自动收尾观察者是否挂上**——这两种失败是静默的,所以必须能查;还有 `metrics`:派发成功率、工具调用次数、清单增长;`autoCollect`:每个候选对话**为什么被问/没被问**、计次,以及设置文件是怎么加载进来的) |
|
|
147
148
|
| POST | `/api/global-todo/create` | `{ title, description?, links?, tags?, priority?, dueAt? }` |
|
|
148
149
|
| POST | `/api/global-todo/update` | `{ id, ...字段, by?: human\|agent }` |
|
package/docs/DESIGN.md
CHANGED
|
@@ -14,7 +14,7 @@ so the file stays readable, hand-editable and diffable.
|
|
|
14
14
|
| `id` | string | `t-` + 8 hex; unique within the file |
|
|
15
15
|
| `title` | string | 1–200 characters, trimmed |
|
|
16
16
|
| `description` | string | up to 10 000 characters, Markdown-ish plain text |
|
|
17
|
-
| `status` | `open` \| `doing` \| `done` \| `cancelled` | `doing` means someone is on it |
|
|
17
|
+
| `status` | `open` \| `doing` \| `done` \| `cancelled` \| `archived` | `doing` means someone is on it; `archived` keeps the record out of the active view without deleting it |
|
|
18
18
|
| `priority` | `low` \| `normal` \| `high` \| `urgent` | `urgent` was added in front of the first three; `normal` stays the default |
|
|
19
19
|
| `dueAt` | ISO-8601 string \| `null` | canonicalised to UTC on write |
|
|
20
20
|
| `tags` | string[] | ≤ 20, each ≤ 32, de-duplicated case-insensitively |
|
|
@@ -157,6 +157,13 @@ rest of the batch — a partial success is reported as a partial success, never
|
|
|
157
157
|
The ceiling: **capture ends at the list.** Nothing the plugin does starts work by itself;
|
|
158
158
|
"hand it to an agent" is always a human click.
|
|
159
159
|
|
|
160
|
+
|
|
161
|
+
The gate has to be narrower than "remember things": a capture run that re-registers what the list
|
|
162
|
+
already holds is how a trusted list becomes noise. So an item is refused, with the reason on the
|
|
163
|
+
record, when its normalized title matches a live row, when a close paraphrase (Dice over character
|
|
164
|
+
bigrams, 0.8) appears in the last 72 hours, or when the same errand was finished within 7 days.
|
|
165
|
+
Archived and cancelled rows are history a person set aside and must not block a new one; the rules
|
|
166
|
+
are pure and live in `lib/capture.js` so they can be tested without a store.
|
|
160
167
|
## A fallback must never quietly outrank the real rule
|
|
161
168
|
|
|
162
169
|
The routes are fenced: the Host's connection service decides (`isTrustedApiRequest` against
|
package/docs/ROADMAP.md
CHANGED
|
@@ -97,6 +97,15 @@ asked / succeeded / failed / dropped (`explainAutoCollect` in `lib/settings.js`,
|
|
|
97
97
|
in `lib/index.js`). A failed question releases its booking and retries after 1 / 5 / 25 minutes
|
|
98
98
|
(`retryDelayMs`), and an ask that never settles is bounded by a ten-second timeout. An unknown
|
|
99
99
|
working directory is never asked, because the exemption cannot be checked against it.
|
|
100
|
+
**Batch completion and tagging, and archive** — `/bulk` applies a status or tag change across a
|
|
101
|
+
selection in one request; a stale id is reported in `unmatched` instead of discarding the batch
|
|
102
|
+
(`bulk()` in `lib/service.js`, `runBulk` in `lib/client.js`). `archived` joined the statuses, so a
|
|
103
|
+
record can leave the active view without being deleted; the filter chip only appears once something
|
|
104
|
+
is archived, and `global_todo_update` finally accepts `status`.
|
|
105
|
+
**A capture gate, not a title comparison** — before `global_todo_capture` writes an item it asks
|
|
106
|
+
`findDuplicate` (`lib/capture.js`): normalized title against live rows, a close paraphrase inside
|
|
107
|
+
72 hours, and the same errand finished in the last 7 days. The decision is pure, the reason lands
|
|
108
|
+
in `skipped[]`, and archived/cancelled rows deliberately do not block a new one.
|
|
100
109
|
### Partly landed
|
|
101
110
|
|
|
102
111
|
- **Batch and multi-select** — the selection mode, its per-row markers and the batch
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/lib/capture.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Capture quality gate.
|
|
3
|
+
*
|
|
4
|
+
* `global_todo_capture` is the one path that can add several records at once, and a capture run
|
|
5
|
+
* that keeps re-registering what the list already has is how the list turns into noise. The
|
|
6
|
+
* first version compared normalised titles exactly against the active rows; that misses the
|
|
7
|
+
* common case — same errand, different wording — and it re-admits something that was finished
|
|
8
|
+
* yesterday. Both are pure decisions, so they live here and are tested without a store.
|
|
9
|
+
*
|
|
10
|
+
* @module dsh-global-todo/capture
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** Titles closer than this are treated as the same errand. */
|
|
14
|
+
const SIMILARITY = 0.8
|
|
15
|
+
/** Only compare against rows from the last few days; older ones are history, not duplicates. */
|
|
16
|
+
const DEDUPE_WINDOW_HOURS = 72
|
|
17
|
+
/** A finished errand stays done this long before the same title may come back. */
|
|
18
|
+
const DONE_COOLDOWN_DAYS = 7
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Fold a title to what it means: width, case, spacing and punctuation are noise.
|
|
22
|
+
*
|
|
23
|
+
* Numbers and dates are kept on purpose — "10 月 20 日交" and "10 月 21 日交" are different
|
|
24
|
+
* errands even though they differ by one character.
|
|
25
|
+
*
|
|
26
|
+
* @param {unknown} title
|
|
27
|
+
* @returns {string}
|
|
28
|
+
*/
|
|
29
|
+
export function normalizeTitle(title) {
|
|
30
|
+
return String(title ?? '').normalize('NFKC').toLowerCase().replace(/[\s\p{P}\p{S}]+/gu, '')
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** @param {string} text @returns {Set<string>} */
|
|
34
|
+
function bigrams(text) {
|
|
35
|
+
const chars = [...text]
|
|
36
|
+
if (chars.length < 2) return new Set(chars)
|
|
37
|
+
const out = new Set()
|
|
38
|
+
for (let index = 0; index < chars.length - 1; index += 1) out.add(chars[index] + chars[index + 1])
|
|
39
|
+
return out
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Dice coefficient over character bigrams: 0 when unrelated, 1 when identical.
|
|
44
|
+
*
|
|
45
|
+
* @param {unknown} left
|
|
46
|
+
* @param {unknown} right
|
|
47
|
+
* @returns {number}
|
|
48
|
+
*/
|
|
49
|
+
export function similarity(left, right) {
|
|
50
|
+
const a = bigrams(normalizeTitle(left))
|
|
51
|
+
const b = bigrams(normalizeTitle(right))
|
|
52
|
+
if (a.size === 0 || b.size === 0) return 0
|
|
53
|
+
let shared = 0
|
|
54
|
+
for (const gram of a) if (b.has(gram)) shared += 1
|
|
55
|
+
return (2 * shared) / (a.size + b.size)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Is this title already on the list, in a way a person would call a duplicate?
|
|
60
|
+
*
|
|
61
|
+
* @param {{ title?: unknown, existing?: object[], now?: number, similarity?: number, windowHours?: number, doneCooldownDays?: number }} input
|
|
62
|
+
* @returns {{ duplicate: boolean, reason?: string, matchId?: string }}
|
|
63
|
+
*/
|
|
64
|
+
export function findDuplicate(input) {
|
|
65
|
+
const title = input.title
|
|
66
|
+
const key = normalizeTitle(title)
|
|
67
|
+
if (key === '') return { duplicate: false }
|
|
68
|
+
const now = Number.isFinite(input.now) ? input.now : Date.now()
|
|
69
|
+
const windowMs = (input.windowHours ?? DEDUPE_WINDOW_HOURS) * 60 * 60 * 1000
|
|
70
|
+
const cooldownMs = (input.doneCooldownDays ?? DONE_COOLDOWN_DAYS) * 24 * 60 * 60 * 1000
|
|
71
|
+
const threshold = Number.isFinite(input.similarity) ? input.similarity : SIMILARITY
|
|
72
|
+
for (const todo of Array.isArray(input.existing) ? input.existing : []) {
|
|
73
|
+
if (todo === null || typeof todo !== 'object' || typeof todo.title !== 'string') continue
|
|
74
|
+
// Archived is history a person chose to keep out of the way; it must not block the same
|
|
75
|
+
// errand from being written again. Cancelled is likewise not a live duplicate.
|
|
76
|
+
if (todo.status === 'cancelled' || todo.status === 'archived') continue
|
|
77
|
+
const doneAt = Number.isFinite(todo.doneAt)
|
|
78
|
+
? todo.doneAt
|
|
79
|
+
: Number.isFinite(todo.updatedAt) ? todo.updatedAt : 0
|
|
80
|
+
if (normalizeTitle(todo.title) === key) {
|
|
81
|
+
if (todo.status === 'done') {
|
|
82
|
+
if (now - doneAt < cooldownMs) return { duplicate: true, reason: 'recently-done', matchId: todo.id }
|
|
83
|
+
} else {
|
|
84
|
+
return { duplicate: true, reason: 'same-active', matchId: todo.id }
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (todo.status !== 'done') {
|
|
88
|
+
const created = Number.isFinite(todo.createdAt) ? todo.createdAt : now
|
|
89
|
+
// A five-day-old item is not evidence that today's wording is a duplicate; only the
|
|
90
|
+
// recent window is compared for similarity.
|
|
91
|
+
if (now - created < windowMs && similarity(title, todo.title) >= threshold) {
|
|
92
|
+
return { duplicate: true, reason: 'similar-active', matchId: todo.id }
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return { duplicate: false }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The sentence a person reads in `skipped[].error`. */
|
|
100
|
+
export const CAPTURE_REASONS = Object.freeze({
|
|
101
|
+
'same-active': '清单里已有同名的未完成条目,未重复登记',
|
|
102
|
+
'similar-active': '清单里已有一条很相似的未完成条目,未重复登记',
|
|
103
|
+
'recently-done': '这条刚完成不久(7 天内),没有重复登记',
|
|
104
|
+
})
|
package/lib/client.js
CHANGED
|
@@ -50,7 +50,7 @@ window.__ModuleLoader__.load({
|
|
|
50
50
|
* different mechanisms (restart vs. hot reload), so "which one am I looking
|
|
51
51
|
* at" needs an answer that the bundle itself carries.
|
|
52
52
|
*/
|
|
53
|
-
const UI_REV = '
|
|
53
|
+
const UI_REV = 'ui25'
|
|
54
54
|
/** Last language resolved for the shell; module-level so a fetch failure can be worded too. */
|
|
55
55
|
let activeLang = 'zh'
|
|
56
56
|
|
|
@@ -75,6 +75,8 @@ window.__ModuleLoader__.load({
|
|
|
75
75
|
'action.done': '完成',
|
|
76
76
|
'action.reopen': '标回未完成',
|
|
77
77
|
'action.doing': '标为进行中',
|
|
78
|
+
'action.archive': '归档',
|
|
79
|
+
'action.unarchive': '取消归档',
|
|
78
80
|
'action.remove': '删除',
|
|
79
81
|
'action.removeConfirm': '确认删除',
|
|
80
82
|
'action.unlink': '摘掉',
|
|
@@ -102,6 +104,12 @@ window.__ModuleLoader__.load({
|
|
|
102
104
|
'select.all': '全选',
|
|
103
105
|
'select.allOn': '取消全选',
|
|
104
106
|
'select.assign': '一键分配',
|
|
107
|
+
'select.complete': '完成',
|
|
108
|
+
'select.archive': '归档',
|
|
109
|
+
'field.bulkTag': '标签…',
|
|
110
|
+
'select.addTag': '加标签',
|
|
111
|
+
'notice.bulk': '已更新 {n} 条',
|
|
112
|
+
'notice.bulkPartial': '已更新 {n} 条,跳过 {m} 条',
|
|
105
113
|
'select.clear': '清除',
|
|
106
114
|
'select.done': '完成',
|
|
107
115
|
'select.hint': '先勾几条待办,再点「一键分配」:优先回到写下它的那个对话,其次同工作区的对话,都找不到才让你选。',
|
|
@@ -173,6 +181,7 @@ window.__ModuleLoader__.load({
|
|
|
173
181
|
'filter.doing': '进行中',
|
|
174
182
|
'filter.done': '已完成',
|
|
175
183
|
'filter.overdue': '逾期',
|
|
184
|
+
'filter.archived': '已归档',
|
|
176
185
|
'filter.label': '筛选',
|
|
177
186
|
'field.title': '要做什么?回车即存',
|
|
178
187
|
'field.description': '描述:验收标准、已知的坑…',
|
|
@@ -276,6 +285,8 @@ window.__ModuleLoader__.load({
|
|
|
276
285
|
'action.done': 'Done',
|
|
277
286
|
'action.reopen': 'Reopen',
|
|
278
287
|
'action.doing': 'Start',
|
|
288
|
+
'action.archive': 'Archive',
|
|
289
|
+
'action.unarchive': 'Unarchive',
|
|
279
290
|
'action.remove': 'Delete',
|
|
280
291
|
'action.removeConfirm': 'Confirm delete',
|
|
281
292
|
'action.unlink': 'Remove',
|
|
@@ -303,6 +314,12 @@ window.__ModuleLoader__.load({
|
|
|
303
314
|
'select.all': 'Select all',
|
|
304
315
|
'select.allOn': 'Clear selection',
|
|
305
316
|
'select.assign': 'Assign',
|
|
317
|
+
'select.complete': 'Complete',
|
|
318
|
+
'select.archive': 'Archive',
|
|
319
|
+
'field.bulkTag': 'Tag…',
|
|
320
|
+
'select.addTag': 'Add tag',
|
|
321
|
+
'notice.bulk': 'Updated {n}',
|
|
322
|
+
'notice.bulkPartial': 'Updated {n}, skipped {m}',
|
|
306
323
|
'select.clear': 'Clear',
|
|
307
324
|
'select.done': 'Done',
|
|
308
325
|
'select.hint': 'Pick a few todos, then Assign: back to the conversation that wrote each one, else a conversation in the same workspace, else you choose.',
|
|
@@ -376,6 +393,7 @@ window.__ModuleLoader__.load({
|
|
|
376
393
|
'filter.doing': 'Doing',
|
|
377
394
|
'filter.done': 'Done',
|
|
378
395
|
'filter.overdue': 'Overdue',
|
|
396
|
+
'filter.archived': 'Archived',
|
|
379
397
|
'filter.label': 'Filters',
|
|
380
398
|
'field.title': 'What needs doing? Enter to save',
|
|
381
399
|
'field.description': 'Description: acceptance, known traps…',
|
|
@@ -1598,6 +1616,9 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
1598
1616
|
done
|
|
1599
1617
|
? { key: 'reopen', label: t('action.reopen'), icon: 'check', onSelect: () => onAction('reopen', todo) }
|
|
1600
1618
|
: { key: 'doing', label: t('action.doing'), icon: 'clock', onSelect: () => onAction('doing', todo) },
|
|
1619
|
+
todo.status === 'archived'
|
|
1620
|
+
? { key: 'unarchive', label: t('action.unarchive'), icon: 'check', onSelect: () => onAction('unarchive', todo) }
|
|
1621
|
+
: { key: 'archive', label: t('action.archive'), icon: 'doc', onSelect: () => onAction('archive', todo) },
|
|
1601
1622
|
{
|
|
1602
1623
|
key: 'remove',
|
|
1603
1624
|
label: confirming ? t('action.removeConfirm') : t('action.remove'),
|
|
@@ -1939,6 +1960,9 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
1939
1960
|
// The hidden file input behind 导入: a page cannot open a file dialog by itself.
|
|
1940
1961
|
const importRef = useRef(null)
|
|
1941
1962
|
const [selected, setSelected] = useState({})
|
|
1963
|
+
// One tag field for the whole selection: typing a tag then pressing add applies it to
|
|
1964
|
+
// every picked row in one request, which is the batch half that was missing.
|
|
1965
|
+
const [bulkTag, setBulkTag] = useState('')
|
|
1942
1966
|
const [assignBusy, setAssignBusy] = useState(false)
|
|
1943
1967
|
const [assignAsk, setAssignAsk] = useState(null)
|
|
1944
1968
|
const [groupMode, setGroupMode] = useState(() => readStoredGroupMode())
|
|
@@ -2619,6 +2643,10 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
2619
2643
|
if (kind === 'done') return run(todo.id, () => call('status', { method: 'POST', body: { id: todo.id, status: 'done' } }))
|
|
2620
2644
|
if (kind === 'reopen') return run(todo.id, () => call('status', { method: 'POST', body: { id: todo.id, status: 'open' } }))
|
|
2621
2645
|
if (kind === 'doing') return run(todo.id, () => call('status', { method: 'POST', body: { id: todo.id, status: 'doing' } }))
|
|
2646
|
+
// Archive keeps the history without cluttering the list; delete stays a separate,
|
|
2647
|
+
// deliberate action reached only through the two-step menu item.
|
|
2648
|
+
if (kind === 'archive') return run(todo.id, () => call('status', { method: 'POST', body: { id: todo.id, status: 'archived' } }))
|
|
2649
|
+
if (kind === 'unarchive') return run(todo.id, () => call('status', { method: 'POST', body: { id: todo.id, status: 'open' } }))
|
|
2622
2650
|
if (kind === 'save') return run(todo.id, () => call('update', { method: 'POST', body: { id: todo.id, ...payload } }))
|
|
2623
2651
|
if (kind === 'note') return run(todo.id, () => call('note', { method: 'POST', body: { id: todo.id, ...payload } }))
|
|
2624
2652
|
if (kind === 'link') return run(todo.id, () => call('link', { method: 'POST', body: { id: todo.id, ...payload } }))
|
|
@@ -2664,7 +2692,32 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
2664
2692
|
return undefined
|
|
2665
2693
|
}
|
|
2666
2694
|
|
|
2667
|
-
|
|
2695
|
+
/** Apply one change to the whole selection; a stale id is reported, not fatal. */
|
|
2696
|
+
const runBulk = async (change) => {
|
|
2697
|
+
const ids = Object.keys(selected)
|
|
2698
|
+
if (ids.length === 0) return
|
|
2699
|
+
try {
|
|
2700
|
+
const data = await call('bulk', { method: 'POST', body: { ids, ...change } })
|
|
2701
|
+
const skipped = (data?.unmatched?.length ?? 0) + (data?.errors?.length ?? 0)
|
|
2702
|
+
setNotice({
|
|
2703
|
+
text:
|
|
2704
|
+
skipped > 0
|
|
2705
|
+
? t('notice.bulkPartial', { n: data?.updated ?? 0, m: skipped })
|
|
2706
|
+
: t('notice.bulk', { n: data?.updated ?? 0 }),
|
|
2707
|
+
sessionId: '',
|
|
2708
|
+
})
|
|
2709
|
+
setTimeout(() => setNotice(null), 6000)
|
|
2710
|
+
setSelected({})
|
|
2711
|
+
setError(null)
|
|
2712
|
+
void reload()
|
|
2713
|
+
void refreshHealth()
|
|
2714
|
+
} catch (failure) {
|
|
2715
|
+
reportFailure('bulk', { message: failure?.message })
|
|
2716
|
+
setError(failure)
|
|
2717
|
+
}
|
|
2718
|
+
}
|
|
2719
|
+
|
|
2720
|
+
const counts = state.counts ?? { total: 0, open: 0, doing: 0, done: 0, cancelled: 0, archived: 0, overdue: 0 }
|
|
2668
2721
|
const groups = useMemo(
|
|
2669
2722
|
() => groupTodos(state.todos, Date.now(), groupMode, sessionTitles, t),
|
|
2670
2723
|
[state.todos, groupMode, sessionTitles, lang],
|
|
@@ -2875,6 +2928,9 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
2875
2928
|
['doing', counts.doing],
|
|
2876
2929
|
['done', counts.done],
|
|
2877
2930
|
['overdue', counts.overdue],
|
|
2931
|
+
// Only shown once something is archived: an empty filter that exists from
|
|
2932
|
+
// day one is furniture, not a control.
|
|
2933
|
+
...(counts.archived > 0 ? [['archived', counts.archived]] : []),
|
|
2878
2934
|
].map(([key, count]) => filterChip(key, key === 'all' ? undefined : count)),
|
|
2879
2935
|
),
|
|
2880
2936
|
h('span', { className: 'gt-divider' }),
|
|
@@ -3097,6 +3153,48 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
3097
3153
|
},
|
|
3098
3154
|
assignBusy ? t('assign.working') : t('select.assign'),
|
|
3099
3155
|
),
|
|
3156
|
+
h(
|
|
3157
|
+
'button',
|
|
3158
|
+
{
|
|
3159
|
+
type: 'button',
|
|
3160
|
+
className: 'gt-btn gt-btn-quiet',
|
|
3161
|
+
disabled: Object.keys(selected).length === 0,
|
|
3162
|
+
onClick: () => void runBulk({ status: 'done' }),
|
|
3163
|
+
},
|
|
3164
|
+
t('select.complete'),
|
|
3165
|
+
),
|
|
3166
|
+
h(
|
|
3167
|
+
'button',
|
|
3168
|
+
{
|
|
3169
|
+
type: 'button',
|
|
3170
|
+
className: 'gt-btn gt-btn-quiet',
|
|
3171
|
+
disabled: Object.keys(selected).length === 0,
|
|
3172
|
+
onClick: () => void runBulk({ status: 'archived' }),
|
|
3173
|
+
},
|
|
3174
|
+
t('select.archive'),
|
|
3175
|
+
),
|
|
3176
|
+
h('input', {
|
|
3177
|
+
className: 'gt-input gt-bulk-tag',
|
|
3178
|
+
value: bulkTag,
|
|
3179
|
+
placeholder: t('field.bulkTag'),
|
|
3180
|
+
'aria-label': t('field.bulkTag'),
|
|
3181
|
+
onChange: (event) => setBulkTag(event.target.value),
|
|
3182
|
+
}),
|
|
3183
|
+
h(
|
|
3184
|
+
'button',
|
|
3185
|
+
{
|
|
3186
|
+
type: 'button',
|
|
3187
|
+
className: 'gt-btn gt-btn-quiet',
|
|
3188
|
+
disabled: Object.keys(selected).length === 0 || bulkTag.trim() === '',
|
|
3189
|
+
onClick: () => {
|
|
3190
|
+
const tag = bulkTag.trim()
|
|
3191
|
+
if (tag === '') return
|
|
3192
|
+
void runBulk({ addTags: [tag] })
|
|
3193
|
+
setBulkTag('')
|
|
3194
|
+
},
|
|
3195
|
+
},
|
|
3196
|
+
t('select.addTag'),
|
|
3197
|
+
),
|
|
3100
3198
|
h(
|
|
3101
3199
|
'button',
|
|
3102
3200
|
{ type: 'button', className: 'gt-btn gt-btn-quiet', onClick: () => setSelected({}) },
|
package/lib/index.js
CHANGED
|
@@ -35,7 +35,7 @@ import { createStore, resolveStorePath } from './store.js'
|
|
|
35
35
|
import { createTools } from './tools.js'
|
|
36
36
|
|
|
37
37
|
/** Must equal `package.json`'s version; `npm run check` enforces that. */
|
|
38
|
-
export const VERSION = '0.4.
|
|
38
|
+
export const VERSION = '0.4.2'
|
|
39
39
|
|
|
40
40
|
/** Plugin id. Also the Loader row id in `cordis.patch.yml`. */
|
|
41
41
|
export const name = 'dsh-global-todo'
|
package/lib/model.js
CHANGED
|
@@ -20,7 +20,7 @@ import { LINK_KINDS, linkKey, normalizeLink } from './links.js'
|
|
|
20
20
|
export const SCHEMA_VERSION = 1
|
|
21
21
|
|
|
22
22
|
/** Lifecycle states. `doing` is the explicit "an agent (or I) am on it" state. */
|
|
23
|
-
export const STATUSES = Object.freeze(['open', 'doing', 'done', 'cancelled'])
|
|
23
|
+
export const STATUSES = Object.freeze(['open', 'doing', 'done', 'cancelled', 'archived'])
|
|
24
24
|
|
|
25
25
|
/** Priorities are deliberately coarse: three levels people actually use. */
|
|
26
26
|
/**
|
|
@@ -653,17 +653,17 @@ export function recordDispatch(todo, info, options = {}) {
|
|
|
653
653
|
* @returns {boolean}
|
|
654
654
|
*/
|
|
655
655
|
export function isOverdue(todo, now = Date.now()) {
|
|
656
|
-
if (todo.dueAt === null || todo.status === 'done' || todo.status === 'cancelled') return false
|
|
656
|
+
if (todo.dueAt === null || todo.status === 'done' || todo.status === 'cancelled' || todo.status === 'archived') return false
|
|
657
657
|
const due = Date.parse(todo.dueAt)
|
|
658
658
|
return Number.isFinite(due) && due < now
|
|
659
659
|
}
|
|
660
660
|
|
|
661
661
|
/**
|
|
662
662
|
* @param {object[]} todos
|
|
663
|
-
* @returns {{ total: number, open: number, doing: number, done: number, cancelled: number, overdue: number }}
|
|
663
|
+
* @returns {{ total: number, open: number, doing: number, done: number, cancelled: number, archived: number, overdue: number }}
|
|
664
664
|
*/
|
|
665
665
|
export function counts(todos, now = Date.now()) {
|
|
666
|
-
const out = { total: todos.length, open: 0, doing: 0, done: 0, cancelled: 0, overdue: 0 }
|
|
666
|
+
const out = { total: todos.length, open: 0, doing: 0, done: 0, cancelled: 0, archived: 0, overdue: 0 }
|
|
667
667
|
for (const todo of todos) {
|
|
668
668
|
if (todo.status in out) out[todo.status] += 1
|
|
669
669
|
if (isOverdue(todo, now)) out.overdue += 1
|
package/lib/routes.js
CHANGED
|
@@ -468,6 +468,15 @@ export function createRoutes(deps) {
|
|
|
468
468
|
return dispatcher.assignMany(body ?? {})
|
|
469
469
|
}),
|
|
470
470
|
|
|
471
|
+
exact('POST', `${ROUTE_BASE}/bulk`, async (request) => {
|
|
472
|
+
const body = await readJsonBody(request)
|
|
473
|
+
return service.bulk(
|
|
474
|
+
body.ids,
|
|
475
|
+
{ status: body.status, addTags: body.addTags, removeTags: body.removeTags },
|
|
476
|
+
{ by: actorOf(body) },
|
|
477
|
+
)
|
|
478
|
+
}),
|
|
479
|
+
|
|
471
480
|
exact('POST', `${ROUTE_BASE}/capture`, async (request) => {
|
|
472
481
|
const body = await readJsonBody(request)
|
|
473
482
|
return dispatcher.requestCapture(body ?? {})
|
package/lib/service.js
CHANGED
|
@@ -33,7 +33,7 @@ import {
|
|
|
33
33
|
} from './model.js'
|
|
34
34
|
|
|
35
35
|
/** Filters the panel and the tools may ask for. */
|
|
36
|
-
export const FILTERS = Object.freeze(['all', 'active', 'open', 'doing', 'done', 'cancelled', 'overdue'])
|
|
36
|
+
export const FILTERS = Object.freeze(['all', 'active', 'open', 'doing', 'done', 'cancelled', 'archived', 'overdue'])
|
|
37
37
|
|
|
38
38
|
/** Sort keys the panel and the tools may ask for. */
|
|
39
39
|
export const SORTS = Object.freeze(['created', 'updated', 'due', 'priority'])
|
|
@@ -618,6 +618,54 @@ export function createService(options) {
|
|
|
618
618
|
})
|
|
619
619
|
}
|
|
620
620
|
|
|
621
|
+
/**
|
|
622
|
+
* Apply one change to several todos.
|
|
623
|
+
*
|
|
624
|
+
* Each record goes through the same validation and trail as a single edit. A stale id is
|
|
625
|
+
* reported in `unmatched` instead of failing the batch: a selection is a human gesture, and
|
|
626
|
+
* one row that moved must not discard the other nine.
|
|
627
|
+
*
|
|
628
|
+
* @param {string[]} ids
|
|
629
|
+
* @param {{ status?: string, addTags?: string[], removeTags?: string[] }} change
|
|
630
|
+
* @param {{ by?: 'human' | 'agent' }} [context]
|
|
631
|
+
* @returns {Promise<{ updated: number, ids: string[], unmatched: string[], errors: { id: string, error: string }[] }>}
|
|
632
|
+
*/
|
|
633
|
+
async function bulk(ids, change = {}, context = {}) {
|
|
634
|
+
const wanted = Array.isArray(ids) ? ids.filter((id) => typeof id === 'string' && id.trim() !== '') : []
|
|
635
|
+
if (wanted.length === 0) throw new TodoError('invalid-body', '要改哪些待办?给一个非空的 ids')
|
|
636
|
+
const hasStatus = typeof change.status === 'string' && change.status !== ''
|
|
637
|
+
const addTags = Array.isArray(change.addTags) ? change.addTags : []
|
|
638
|
+
const removeTags = Array.isArray(change.removeTags) ? change.removeTags : []
|
|
639
|
+
if (!hasStatus && addTags.length === 0 && removeTags.length === 0) {
|
|
640
|
+
throw new TodoError('invalid-body', '批量修改需要 status / addTags / removeTags 之一')
|
|
641
|
+
}
|
|
642
|
+
const updated = []
|
|
643
|
+
const unmatched = []
|
|
644
|
+
const errors = []
|
|
645
|
+
for (const id of wanted) {
|
|
646
|
+
try {
|
|
647
|
+
const todo = await store.update((doc) => {
|
|
648
|
+
const target = doc.todos.find((candidate) => candidate.id === id)
|
|
649
|
+
if (target === undefined) return { result: null }
|
|
650
|
+
const patch = {}
|
|
651
|
+
if (hasStatus) patch.status = change.status
|
|
652
|
+
if (addTags.length > 0 || removeTags.length > 0) {
|
|
653
|
+
const merged = new Map(target.tags.map((tag) => [tag.toLowerCase(), tag]))
|
|
654
|
+
for (const tag of addTags) merged.set(String(tag).toLowerCase(), String(tag))
|
|
655
|
+
for (const tag of removeTags) merged.delete(String(tag).toLowerCase())
|
|
656
|
+
patch.tags = [...merged.values()]
|
|
657
|
+
}
|
|
658
|
+
const changed = applyPatch(target, patch, { ...modelOptions(context.by), ...linkOptions() })
|
|
659
|
+
return changed.length === 0 ? { result: target } : { next: doc.todos, result: target }
|
|
660
|
+
})
|
|
661
|
+
if (todo === null) unmatched.push(id)
|
|
662
|
+
else updated.push(id)
|
|
663
|
+
} catch (error) {
|
|
664
|
+
errors.push({ id, error: error instanceof TodoError ? error.message : String(error?.message ?? error) })
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
return { updated: updated.length, ids: updated, unmatched, errors }
|
|
668
|
+
}
|
|
621
669
|
/** @returns {Promise<object>} counts for the panel header and tool summaries */
|
|
622
670
|
async function stats() {
|
|
623
671
|
const todos = await store.all()
|
|
@@ -637,6 +685,7 @@ export function createService(options) {
|
|
|
637
685
|
findBySession,
|
|
638
686
|
remove,
|
|
639
687
|
restore,
|
|
688
|
+
bulk,
|
|
640
689
|
addLink,
|
|
641
690
|
removeLink,
|
|
642
691
|
recordDispatch,
|
package/lib/tools.js
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* @module dsh-global-todo/tools
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
+
import { CAPTURE_REASONS, findDuplicate } from './capture.js'
|
|
16
17
|
import { asTodoError } from './errors.js'
|
|
17
18
|
import { formatLink } from './links.js'
|
|
18
19
|
import { LIMITS, PRIORITIES, PRIORITY_LABELS, STATUSES } from './model.js'
|
|
@@ -384,27 +385,30 @@ export function createTools(service, options = {}) {
|
|
|
384
385
|
const origin = originOf(exec)
|
|
385
386
|
const todos = []
|
|
386
387
|
const skipped = []
|
|
387
|
-
//
|
|
388
|
-
//
|
|
388
|
+
// The quality gate decides per item: exact title, a close paraphrase within the
|
|
389
|
+
// recent window, or the same errand finished days ago. One lookup of everything —
|
|
390
|
+
// done and archived rows matter too, which the active-only lookup used to miss.
|
|
389
391
|
let existing = []
|
|
390
392
|
try {
|
|
391
|
-
existing = (await service.list({ status: '
|
|
393
|
+
existing = (await service.list({ status: 'all', limit: 1000 })).todos
|
|
392
394
|
} catch {
|
|
393
395
|
existing = []
|
|
394
396
|
}
|
|
395
|
-
const
|
|
396
|
-
String(title ?? '').trim().toLowerCase().replace(/\s+/g, '')
|
|
397
|
-
const seen = new Set(existing.map((todo) => keyOf(todo.title)))
|
|
397
|
+
const nowMs = now()
|
|
398
398
|
for (const item of items) {
|
|
399
|
-
const
|
|
400
|
-
if (
|
|
401
|
-
skipped.push({
|
|
399
|
+
const verdict = findDuplicate({ title: item?.title, existing, now: nowMs })
|
|
400
|
+
if (verdict.duplicate === true) {
|
|
401
|
+
skipped.push({
|
|
402
|
+
title: String(item?.title ?? '(无标题)'),
|
|
403
|
+
error: CAPTURE_REASONS[verdict.reason] ?? '清单里已有重复条目,未重复登记',
|
|
404
|
+
})
|
|
402
405
|
continue
|
|
403
406
|
}
|
|
404
407
|
const outcome = await attempt(() => service.create(item ?? {}, { by: 'agent', origin }))
|
|
405
408
|
if (outcome.ok === true) {
|
|
406
|
-
if (itemKey !== '') seen.add(itemKey)
|
|
407
409
|
todos.push(presentTodo(outcome.value, now()))
|
|
410
|
+
// Later items in the same batch must see this one as existing.
|
|
411
|
+
existing.push(outcome.value)
|
|
408
412
|
}
|
|
409
413
|
else skipped.push({ title: String(item?.title ?? '(无标题)'), error: outcome.error })
|
|
410
414
|
}
|
|
@@ -423,7 +427,7 @@ export function createTools(service, options = {}) {
|
|
|
423
427
|
properties: {
|
|
424
428
|
status: {
|
|
425
429
|
type: 'string',
|
|
426
|
-
enum: ['all', 'active', 'open', 'doing', 'done', 'cancelled', 'overdue'],
|
|
430
|
+
enum: ['all', 'active', 'open', 'doing', 'done', 'cancelled', 'archived', 'overdue'],
|
|
427
431
|
description: '缺省 active',
|
|
428
432
|
},
|
|
429
433
|
query: { type: 'string', description: '在标题、描述、标签、附件里模糊匹配' },
|
|
@@ -518,7 +522,7 @@ export function createTools(service, options = {}) {
|
|
|
518
522
|
{
|
|
519
523
|
name: 'global_todo_update',
|
|
520
524
|
description:
|
|
521
|
-
'改一条全局待办。只传要改的字段;`dueAt: ""` 清空截止时间,`tags: []` 清空标签,`links` 是整体替换(要单独加附件请用 global_todo_link
|
|
525
|
+
'改一条全局待办。只传要改的字段;`dueAt: ""` 清空截止时间,`tags: []` 清空标签,`links` 是整体替换(要单独加附件请用 global_todo_link),`status` 可以设为 archived(归档,不删除)。',
|
|
522
526
|
parameters: {
|
|
523
527
|
type: 'object',
|
|
524
528
|
properties: {
|
|
@@ -526,6 +530,9 @@ export function createTools(service, options = {}) {
|
|
|
526
530
|
title: { type: 'string' },
|
|
527
531
|
description: { type: 'string' },
|
|
528
532
|
priority: { type: 'string', enum: [...PRIORITIES] },
|
|
533
|
+
// `status` belongs here: marking something in progress used to require the tool
|
|
534
|
+
// named `global_todo_done`, and archiving had no entry point at all.
|
|
535
|
+
status: { type: 'string', enum: [...STATUSES], description: 'open / doing / done / cancelled / archived' },
|
|
529
536
|
dueAt: { oneOf: [{ type: 'string' }, { type: 'null' }], description: '空串或 null 表示清空' },
|
|
530
537
|
tags: { type: 'array', items: { type: 'string' } },
|
|
531
538
|
links: LINKS_INPUT,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-global-todo",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.2",
|
|
4
4
|
"icon": "./docs/icon.svg",
|
|
5
5
|
"description": "Global, cross-session todos for DeepSeek Harness: one list owned by the human and the agent together, with creation time, attached documents and links, and one-click hand-off to an agent session. 全局待办:跨会话一份清单,记录加入时间,可挂文档与地址,可一键交给 Agent 继续干。",
|
|
6
6
|
"type": "module",
|