dsh-global-todo 0.4.6 → 0.5.0
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 +28 -0
- package/README.md +7 -0
- package/README.zh.md +6 -0
- package/docs/DESIGN.md +10 -0
- package/docs/ROADMAP.md +3 -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/client.js +55 -7
- package/lib/errors.js +1 -0
- package/lib/index.js +18 -1
- package/lib/ingest.js +80 -0
- package/lib/model.js +6 -1
- package/lib/routes.js +53 -0
- package/lib/service.js +71 -0
- package/lib/settings.js +32 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.5.0] — 2026-10-19
|
|
11
|
+
|
|
12
|
+
The last two pieces of the automatic-capture plan: an external door, and somewhere for its
|
|
13
|
+
uncertain items to wait.
|
|
14
|
+
|
|
15
|
+
- **`POST /ingest` is the only external entrance.** It is closed until `ingestEnabled` is true
|
|
16
|
+
and a token is set; the token is compared in constant time, never returned by `/health`, and
|
|
17
|
+
the settings file is chmod 600 because it may hold it. Every item must name a source
|
|
18
|
+
(`mail` / `notification` / `announcement`) and a confidence, so nothing anonymous lands in the
|
|
19
|
+
list.
|
|
20
|
+
- **Low-confidence items wait in an inbox instead of joining the list.** Below
|
|
21
|
+
`ingestConfirmBelow` (default 0.8) a record is tagged `inbox`; the panel shows an inbox filter
|
|
22
|
+
chip, a 待确认 badge, and 确认 / 丢弃 in the row menu. Confirming only removes the mark;
|
|
23
|
+
discarding archives it. A weekly sweep archives inbox records nobody confirmed in seven days,
|
|
24
|
+
so the inbox cannot become a second list.
|
|
25
|
+
- Items still pass the same dedupe gate as `global_todo_capture`; provenance stays visible as a
|
|
26
|
+
`source:<kind>` tag after confirmation.
|
|
27
|
+
## [0.4.7] — 2026-10-19
|
|
28
|
+
|
|
29
|
+
Verified against the running GUI this time, with measured numbers instead of arithmetic.
|
|
30
|
+
|
|
31
|
+
- **The card no longer overlaps the list by its own padding.** `width: 360px` was applied
|
|
32
|
+
content-box, so the card was 394px wide while the list reserved 380px — a 14px overlap that
|
|
33
|
+
only showed up in a live box measurement. It is `box-sizing: border-box` now (990–1350 against
|
|
34
|
+
a list ending at 970 at 1440×900).
|
|
35
|
+
- **Its vertical brackets come from the list, not the panel.** The panel fills the window, so
|
|
36
|
+
measuring its bottom put the card over the footer; the anchors now come from `.gt-groups`
|
|
37
|
+
(top + 10) and its bottom (+ 8 gap above the footer).
|
|
10
38
|
## [0.4.6] — 2026-10-19
|
|
11
39
|
|
|
12
40
|
The card existed; where it landed depended on a viewport assumption that the real shell does not
|
package/README.md
CHANGED
|
@@ -247,6 +247,7 @@ fence when that service is present, and by a strict loopback check otherwise.
|
|
|
247
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) |
|
|
248
248
|
| POST | `/api/global-todo/capture` | `{ sessionId? }` — ask the current conversation to collect its follow-ups |
|
|
249
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 |
|
|
250
|
+
| 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 |
|
|
250
251
|
| 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) |
|
|
251
252
|
| POST | `/api/global-todo/create` | `{ title, description?, links?, tags?, priority?, dueAt? }` |
|
|
252
253
|
| POST | `/api/global-todo/update` | `{ id, ...fields, by?: human\|agent }` |
|
|
@@ -360,6 +361,12 @@ docs/ DESIGN.md (data model and rejected alternatives), ROADMAP.md
|
|
|
360
361
|
|
|
361
362
|
Contributions are welcome — read [CONTRIBUTING.md](CONTRIBUTING.md) first.
|
|
362
363
|
|
|
364
|
+
## Feedback
|
|
365
|
+
|
|
366
|
+
Questions, bug reports and ideas go to [Discussions](https://github.com/LUgABeEEE/dsh-global-todo/discussions).
|
|
367
|
+
When reporting a bug, include the DSH version, the plugin version from the panel footer, your OS and
|
|
368
|
+
the steps that reproduce it; a screenshot of the notice is worth more than a paragraph. Security
|
|
369
|
+
issues go through [SECURITY.md](SECURITY.md) instead, not the discussion board.
|
|
363
370
|
## Security
|
|
364
371
|
|
|
365
372
|
The HTTP surface is local-only, the list is a local file, and no credential is ever stored.
|
package/README.zh.md
CHANGED
|
@@ -144,6 +144,7 @@ dsh plugin --profile web remove dsh-global-todo
|
|
|
144
144
|
| POST | `/api/global-todo/note` | `{ id, text }`(追加进展记录) |
|
|
145
145
|
| POST | `/api/global-todo/capture` | `{ sessionId? }`(请当前对话把要跟进的事抓进清单) |
|
|
146
146
|
| POST | `/api/global-todo/bulk` | `{ ids, status?, addTags?, removeTags? }`——一次改一整批;失效的 id 进 `unmatched`,不会让整批失败 |
|
|
147
|
+
| POST | `/api/global-todo/ingest` | `{ items: [{ title, source: { kind }, confidence }], dryRun? }`,带 `Authorization: Bearer <settings.ingestToken>`——外部条目;置信度低于 `ingestConfirmBelow` 的先进收件箱 |
|
|
147
148
|
| GET | `/api/global-todo/health` | —(数据位置、统计、派发能力,以及**工具注册状态**与**自动收尾观察者是否挂上**——这两种失败是静默的,所以必须能查;还有 `metrics`:派发成功率、工具调用次数、清单增长;`autoCollect`:每个候选对话**为什么被问/没被问**、计次,以及设置文件是怎么加载进来的) |
|
|
148
149
|
| POST | `/api/global-todo/create` | `{ title, description?, links?, tags?, priority?, dueAt? }` |
|
|
149
150
|
| POST | `/api/global-todo/update` | `{ id, ...字段, by?: human\|agent }` |
|
|
@@ -251,6 +252,11 @@ docs/ DESIGN.md(数据模型与取舍)、ROADMAP.md
|
|
|
251
252
|
|
|
252
253
|
欢迎贡献,先读 [CONTRIBUTING.md](CONTRIBUTING.md)。
|
|
253
254
|
|
|
255
|
+
## 反馈
|
|
256
|
+
|
|
257
|
+
问题、bug 和想法都发到 [Discussions](https://github.com/LUgABeEEE/dsh-global-todo/discussions)。
|
|
258
|
+
报 bug 时带上 DSH 版本、面板页脚里的插件版本、系统,以及能复现的步骤;提示语截图比一段描述更有用。
|
|
259
|
+
安全问题请走 [SECURITY.md](SECURITY.md),不要发在讨论区。
|
|
254
260
|
## 安全
|
|
255
261
|
|
|
256
262
|
HTTP 接口只服务本机,清单是本地文件,任何凭据都不会被存储。
|
package/docs/DESIGN.md
CHANGED
|
@@ -79,6 +79,16 @@ be noise) and exported through `describe()` as `externalChanges` / `lastExternal
|
|
|
79
79
|
turns a new count into a one-line notice. `store.read` still trusts a file that parses, because
|
|
80
80
|
**this process** has no way to merge two writers' intent — but the person can now see that it
|
|
81
81
|
happened instead of discovering it a day later.
|
|
82
|
+
## An external door with a waiting room
|
|
83
|
+
|
|
84
|
+
Ingest is the only entrance a person did not open in a conversation, so it is closed by default:
|
|
85
|
+
`ingestEnabled` must be true and `ingestToken` must match (compared over SHA-256 digests, in
|
|
86
|
+
constant time), and `/health` reports only `tokenSet`. Every item names a source kind and a
|
|
87
|
+
confidence; a kind outside the whitelist is refused rather than filed as "unknown". Below
|
|
88
|
+
`ingestConfirmBelow` the record is created with the `inbox` tag — it exists, but it is not on the
|
|
89
|
+
list until a person confirms it. Confirming removes the tag, discarding archives the record, and a
|
|
90
|
+
weekly sweep archives what nobody touched in seven days. Provenance survives as a `source:<kind>`
|
|
91
|
+
tag, because "where did this come from" is the first question a surprised person asks.
|
|
82
92
|
## Attachments: references, not uploads
|
|
83
93
|
|
|
84
94
|
An attachment is either an address (`http`/`https`) or an absolute path. Rules:
|
package/docs/ROADMAP.md
CHANGED
|
@@ -113,6 +113,9 @@ list silently.
|
|
|
113
113
|
**Side detail on wide windows** — above 1100px the open row's detail becomes a card at the
|
|
114
114
|
panel's right edge instead of pushing the list down (CSS-only over the same markup; one detail
|
|
115
115
|
implementation), while narrow windows keep the inline expansion.
|
|
116
|
+
**External ingest with an inbox** — `POST /ingest` takes source-tagged, confidence-scored items
|
|
117
|
+
behind a Bearer token; low-confidence records wait in an `inbox` tag view with 确认 / 丢弃 and a
|
|
118
|
+
seven-day sweep, so an external source cannot silently fill the list.
|
|
116
119
|
### Partly landed
|
|
117
120
|
|
|
118
121
|
- **Batch and multi-select** — the selection mode, its per-row markers and the batch
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
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 = 'ui31'
|
|
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
|
|
|
@@ -183,6 +183,10 @@ window.__ModuleLoader__.load({
|
|
|
183
183
|
'filter.done': '已完成',
|
|
184
184
|
'filter.overdue': '逾期',
|
|
185
185
|
'filter.archived': '已归档',
|
|
186
|
+
'filter.inbox': '收件箱',
|
|
187
|
+
'action.confirm': '确认',
|
|
188
|
+
'action.discard': '丢弃',
|
|
189
|
+
'meta.inbox': '待确认',
|
|
186
190
|
'filter.label': '筛选',
|
|
187
191
|
'field.title': '要做什么?回车即存',
|
|
188
192
|
'field.description': '描述:验收标准、已知的坑…',
|
|
@@ -396,6 +400,10 @@ window.__ModuleLoader__.load({
|
|
|
396
400
|
'filter.done': 'Done',
|
|
397
401
|
'filter.overdue': 'Overdue',
|
|
398
402
|
'filter.archived': 'Archived',
|
|
403
|
+
'filter.inbox': 'Inbox',
|
|
404
|
+
'action.confirm': 'Confirm',
|
|
405
|
+
'action.discard': 'Discard',
|
|
406
|
+
'meta.inbox': 'Needs review',
|
|
399
407
|
'filter.label': 'Filters',
|
|
400
408
|
'field.title': 'What needs doing? Enter to save',
|
|
401
409
|
'field.description': 'Description: acceptance, known traps…',
|
|
@@ -984,6 +992,9 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
984
992
|
border: 1px solid var(--dsw-alias-brand-primary); border-radius: 9px; background: var(--dsw-alias-bg-layer-1); font-size: 12.5px; }
|
|
985
993
|
.gt-item-body { flex: 1; display: flex; flex-direction: column; gap: 5px; min-width: 0; }
|
|
986
994
|
.gt-item-title { font-weight: 600; font-size: 13.5px; word-break: break-word; line-height: 1.45; }
|
|
995
|
+
.gt-inbox { margin-left: 6px; padding: 1px 6px; border-radius: 999px; font-size: 10.5px; font-weight: 600;
|
|
996
|
+
color: var(--dsw-alias-state-warn-primary); border: 1px solid var(--dsw-alias-state-warn-primary);
|
|
997
|
+
vertical-align: 1px; }
|
|
987
998
|
.gt-item[data-status="done"] .gt-item-title, .gt-item[data-status="cancelled"] .gt-item-title {
|
|
988
999
|
text-decoration: line-through; color: var(--dsw-alias-label-secondary); }
|
|
989
1000
|
.gt-meta { display: flex; gap: 5px 8px; flex-wrap: wrap; color: var(--dsw-alias-label-secondary); font-size: 11.5px;
|
|
@@ -1021,6 +1032,9 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
1021
1032
|
always fill the window, and a viewport-centred offset put the card over the list. */
|
|
1022
1033
|
/* The var fallback matters for the static previews (server-rendered, so effects never
|
|
1023
1034
|
run): they assume the panel is the centred 980px column. */
|
|
1035
|
+
/* border-box matters: 360px is the whole card, so the list's 380px reservation leaves
|
|
1036
|
+
a gap instead of overlapping by the padding width. */
|
|
1037
|
+
box-sizing: border-box;
|
|
1024
1038
|
position: fixed; top: var(--gt-detail-top, 208px);
|
|
1025
1039
|
right: var(--gt-detail-right, max(24px, calc((100vw - 980px) / 2 + 24px)));
|
|
1026
1040
|
bottom: var(--gt-detail-bottom, 64px); width: 360px;
|
|
@@ -1513,7 +1527,14 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
1513
1527
|
h(
|
|
1514
1528
|
'div',
|
|
1515
1529
|
{ className: 'gt-item-body' },
|
|
1516
|
-
h(
|
|
1530
|
+
h(
|
|
1531
|
+
'div',
|
|
1532
|
+
{ className: 'gt-item-title' },
|
|
1533
|
+
todo.title,
|
|
1534
|
+
todo.tags.some((tag) => tag.toLowerCase() === 'inbox')
|
|
1535
|
+
? h('span', { className: 'gt-inbox' }, t('meta.inbox'))
|
|
1536
|
+
: null,
|
|
1537
|
+
),
|
|
1517
1538
|
h(
|
|
1518
1539
|
'div',
|
|
1519
1540
|
{ className: 'gt-meta' },
|
|
@@ -1629,6 +1650,12 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
1629
1650
|
h(Menu, {
|
|
1630
1651
|
label: t('action.menu'),
|
|
1631
1652
|
items: [
|
|
1653
|
+
...(todo.tags.some((tag) => tag.toLowerCase() === 'inbox')
|
|
1654
|
+
? [
|
|
1655
|
+
{ key: 'confirm', label: t('action.confirm'), icon: 'check', onSelect: () => onAction('confirm', todo) },
|
|
1656
|
+
{ key: 'discard', label: t('action.discard'), icon: 'trash', danger: true, onSelect: () => onAction('discard', todo) },
|
|
1657
|
+
]
|
|
1658
|
+
: []),
|
|
1632
1659
|
{
|
|
1633
1660
|
key: 'toggle',
|
|
1634
1661
|
label: open ? t('action.hide') : t('action.detail'),
|
|
@@ -2160,10 +2187,18 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
2160
2187
|
|
|
2161
2188
|
const reload = useCallback(
|
|
2162
2189
|
async (next = {}) => {
|
|
2163
|
-
const
|
|
2190
|
+
const selected = next.filter ?? filter
|
|
2191
|
+
// The inbox is a tag view over active records, not a status of its own.
|
|
2192
|
+
const status = selected === 'inbox' ? 'active' : selected
|
|
2164
2193
|
const search = next.query ?? query
|
|
2165
2194
|
try {
|
|
2166
|
-
const data = await call('list', {
|
|
2195
|
+
const data = await call('list', {
|
|
2196
|
+
query: {
|
|
2197
|
+
status,
|
|
2198
|
+
...(selected === 'inbox' ? { tag: 'inbox' } : {}),
|
|
2199
|
+
...(search.trim() === '' ? {} : { query: search.trim() }),
|
|
2200
|
+
},
|
|
2201
|
+
})
|
|
2167
2202
|
if (!mounted.current) return
|
|
2168
2203
|
setState((current) => ({
|
|
2169
2204
|
...current,
|
|
@@ -2216,10 +2251,14 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
2216
2251
|
const element = innerRef.current
|
|
2217
2252
|
if (element === null) return
|
|
2218
2253
|
const rect = element.getBoundingClientRect()
|
|
2254
|
+
// Bracket the card against the scrolling list, not the panel box: the footer sits
|
|
2255
|
+
// below the list, and the panel's own bottom is below that.
|
|
2256
|
+
const groups = document.querySelector('.gt-groups')
|
|
2257
|
+
const list = groups === null ? null : groups.getBoundingClientRect()
|
|
2219
2258
|
setDetailAnchor({
|
|
2220
|
-
top: Math.round(rect.top +
|
|
2259
|
+
top: Math.round((list === null ? rect.top + 190 : list.top) + 10),
|
|
2221
2260
|
right: Math.round(window.innerWidth - rect.right + 20),
|
|
2222
|
-
bottom: Math.round(window.innerHeight - rect.bottom +
|
|
2261
|
+
bottom: Math.round(window.innerHeight - (list === null ? rect.bottom : list.bottom) + 8),
|
|
2223
2262
|
})
|
|
2224
2263
|
}
|
|
2225
2264
|
measure()
|
|
@@ -2708,6 +2747,14 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
2708
2747
|
if (kind === 'done') return run(todo.id, () => call('status', { method: 'POST', body: { id: todo.id, status: 'done' } }))
|
|
2709
2748
|
if (kind === 'reopen') return run(todo.id, () => call('status', { method: 'POST', body: { id: todo.id, status: 'open' } }))
|
|
2710
2749
|
if (kind === 'doing') return run(todo.id, () => call('status', { method: 'POST', body: { id: todo.id, status: 'doing' } }))
|
|
2750
|
+
// Confirming keeps the record on the list; discarding archives it. Both only remove
|
|
2751
|
+
// the "waiting for a person" mark — the provenance tag stays.
|
|
2752
|
+
if (kind === 'confirm') {
|
|
2753
|
+
return run(todo.id, () => call('bulk', { method: 'POST', body: { ids: [todo.id], removeTags: ['inbox'] } }))
|
|
2754
|
+
}
|
|
2755
|
+
if (kind === 'discard') {
|
|
2756
|
+
return run(todo.id, () => call('bulk', { method: 'POST', body: { ids: [todo.id], status: 'archived', removeTags: ['inbox'] } }))
|
|
2757
|
+
}
|
|
2711
2758
|
// Archive keeps the history without cluttering the list; delete stays a separate,
|
|
2712
2759
|
// deliberate action reached only through the two-step menu item.
|
|
2713
2760
|
if (kind === 'archive') return run(todo.id, () => call('status', { method: 'POST', body: { id: todo.id, status: 'archived' } }))
|
|
@@ -2782,7 +2829,7 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
2782
2829
|
}
|
|
2783
2830
|
}
|
|
2784
2831
|
|
|
2785
|
-
const counts = state.counts ?? { total: 0, open: 0, doing: 0, done: 0, cancelled: 0, archived: 0, overdue: 0 }
|
|
2832
|
+
const counts = state.counts ?? { total: 0, open: 0, doing: 0, done: 0, cancelled: 0, archived: 0, overdue: 0, inbox: 0 }
|
|
2786
2833
|
const groups = useMemo(
|
|
2787
2834
|
() => groupTodos(state.todos, Date.now(), groupMode, sessionTitles, t),
|
|
2788
2835
|
[state.todos, groupMode, sessionTitles, lang],
|
|
@@ -3009,6 +3056,7 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
3009
3056
|
// Only shown once something is archived: an empty filter that exists from
|
|
3010
3057
|
// day one is furniture, not a control.
|
|
3011
3058
|
...(counts.archived > 0 ? [['archived', counts.archived]] : []),
|
|
3059
|
+
...(counts.inbox > 0 ? [['inbox', counts.inbox]] : []),
|
|
3012
3060
|
].map(([key, count]) => filterChip(key, key === 'all' ? undefined : count)),
|
|
3013
3061
|
),
|
|
3014
3062
|
h('span', { className: 'gt-divider' }),
|
package/lib/errors.js
CHANGED
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.
|
|
38
|
+
export const VERSION = '0.5.0'
|
|
39
39
|
|
|
40
40
|
/** Plugin id. Also the Loader row id in `cordis.patch.yml`. */
|
|
41
41
|
export const name = 'dsh-global-todo'
|
|
@@ -99,6 +99,23 @@ export function apply(ctx, config) {
|
|
|
99
99
|
// back by the plugin itself.
|
|
100
100
|
const metrics = createMetrics()
|
|
101
101
|
const service = createService({ store, config: resolved, logger, metrics })
|
|
102
|
+
// The inbox must not become a second list. A weekly sweep archives what nobody
|
|
103
|
+
// confirmed; the record keeps its history, it just leaves the active view.
|
|
104
|
+
ctx.effect(() => {
|
|
105
|
+
const sweep = () => {
|
|
106
|
+
void service
|
|
107
|
+
.expireInbox({ days: 7 })
|
|
108
|
+
.then((result) => {
|
|
109
|
+
if (result?.expired > 0) logger?.info?.(`dsh-global-todo: archived ${result.expired} unconfirmed inbox item(s)`)
|
|
110
|
+
})
|
|
111
|
+
.catch((error) => logger?.warn?.(`dsh-global-todo: inbox expiry failed: ${error?.message ?? error}`))
|
|
112
|
+
}
|
|
113
|
+
sweep()
|
|
114
|
+
if (typeof setInterval !== 'function') return () => {}
|
|
115
|
+
const timer = setInterval(sweep, 6 * 60 * 60 * 1000)
|
|
116
|
+
if (typeof timer.unref === 'function') timer.unref()
|
|
117
|
+
return () => clearInterval(timer)
|
|
118
|
+
}, 'dsh-global-todo: inbox expiry')
|
|
102
119
|
// Which conversation did the user last write in?
|
|
103
120
|
//
|
|
104
121
|
// The panel is the main view while it is open, so it cannot always see that.
|
package/lib/ingest.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ingest quality gate.
|
|
3
|
+
*
|
|
4
|
+
* The external door is the only entrance to the list that a person did not open in a
|
|
5
|
+
* conversation, so it gets checked before it is opened: a named source, a stated confidence,
|
|
6
|
+
* and — below the confidence threshold — the inbox rather than the main list. The rules are
|
|
7
|
+
* pure so they can be tested without a store or a route.
|
|
8
|
+
*
|
|
9
|
+
* @module dsh-global-todo/ingest
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { INGEST_SOURCES } from './settings.js'
|
|
13
|
+
|
|
14
|
+
/** Tag that marks a record as waiting for a person. */
|
|
15
|
+
export const INBOX_TAG = 'inbox'
|
|
16
|
+
/** Upper bound on one ingest request; a mail batch is not a migration. */
|
|
17
|
+
export const MAX_INGEST_ITEMS = 100
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Validate one external item and decide where it lands.
|
|
21
|
+
*
|
|
22
|
+
* @param {unknown} raw
|
|
23
|
+
* @param {{ confirmBelow?: number }} [options]
|
|
24
|
+
* @returns {{ item?: object, source?: string, confidence?: number, inbox?: boolean, error?: string }}
|
|
25
|
+
*/
|
|
26
|
+
export function normalizeIngestItem(raw, options = {}) {
|
|
27
|
+
const confirmBelow = Number.isFinite(options.confirmBelow) ? options.confirmBelow : 0.8
|
|
28
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return { error: '条目必须是对象' }
|
|
29
|
+
const title = typeof raw.title === 'string' ? raw.title.trim() : ''
|
|
30
|
+
if (title === '') return { error: '缺 title' }
|
|
31
|
+
if (title.length > 500) return { error: 'title 超过 500 字' }
|
|
32
|
+
const source = raw.source !== null && typeof raw.source === 'object' && !Array.isArray(raw.source) ? raw.source : {}
|
|
33
|
+
const kind = typeof source.kind === 'string' ? source.kind.trim().toLowerCase() : ''
|
|
34
|
+
if (!INGEST_SOURCES.includes(kind)) return { error: `source.kind 必须是 ${INGEST_SOURCES.join(' / ')}` }
|
|
35
|
+
const confidence = Number(raw.confidence)
|
|
36
|
+
if (!Number.isFinite(confidence) || confidence < 0 || confidence > 1) return { error: 'confidence 必须是 0–1' }
|
|
37
|
+
// High-confidence items join the list; the rest wait. Both keep the provenance tag so a
|
|
38
|
+
// person can still see where a record came from after it is confirmed.
|
|
39
|
+
const inbox = confidence < confirmBelow
|
|
40
|
+
const tags = [INBOX_TAG, `source:${kind}`]
|
|
41
|
+
if (!inbox) tags.shift()
|
|
42
|
+
const item = { title, tags }
|
|
43
|
+
if (typeof raw.description === 'string') item.description = raw.description
|
|
44
|
+
if (raw.dueAt !== undefined) item.dueAt = raw.dueAt
|
|
45
|
+
if (raw.priority !== undefined) item.priority = raw.priority
|
|
46
|
+
if (Array.isArray(raw.links)) item.links = raw.links
|
|
47
|
+
return { item, source: kind, confidence, inbox }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Does this record still wait for a person?
|
|
52
|
+
*
|
|
53
|
+
* @param {object} todo
|
|
54
|
+
* @returns {boolean}
|
|
55
|
+
*/
|
|
56
|
+
export function isInbox(todo) {
|
|
57
|
+
if (todo === null || typeof todo !== 'object' || !Array.isArray(todo.tags)) return false
|
|
58
|
+
return todo.tags.some((tag) => String(tag).toLowerCase() === INBOX_TAG)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Which inbox records are old enough to be archived. The inbox must not become a second
|
|
63
|
+
* list: something nobody confirmed in a week is not a task any more, it is a decision not
|
|
64
|
+
* taken, and it belongs in the archive rather than at the top of the panel.
|
|
65
|
+
*
|
|
66
|
+
* @param {object[]} todos
|
|
67
|
+
* @param {{ now?: number, days?: number }} [options]
|
|
68
|
+
* @returns {object[]}
|
|
69
|
+
*/
|
|
70
|
+
export function expiredInbox(todos, options = {}) {
|
|
71
|
+
const now = Number.isFinite(options.now) ? options.now : Date.now()
|
|
72
|
+
const days = Number.isFinite(options.days) && options.days > 0 ? options.days : 7
|
|
73
|
+
const cutoff = now - days * 24 * 60 * 60 * 1000
|
|
74
|
+
return (Array.isArray(todos) ? todos : []).filter((todo) =>
|
|
75
|
+
isInbox(todo) &&
|
|
76
|
+
(todo.status === 'open' || todo.status === 'doing') &&
|
|
77
|
+
Number.isFinite(todo.createdAt) &&
|
|
78
|
+
todo.createdAt < cutoff,
|
|
79
|
+
)
|
|
80
|
+
}
|
package/lib/model.js
CHANGED
|
@@ -663,10 +663,15 @@ export function isOverdue(todo, now = Date.now()) {
|
|
|
663
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, archived: 0, overdue: 0 }
|
|
666
|
+
const out = { total: todos.length, open: 0, doing: 0, done: 0, cancelled: 0, archived: 0, overdue: 0, inbox: 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
|
|
670
|
+
// Waiting for a person counts only while it is still active; an archived inbox item is
|
|
671
|
+
// history, not a pending decision.
|
|
672
|
+
if ((todo.status === 'open' || todo.status === 'doing') && (todo.tags ?? []).some((tag) => tag.toLowerCase() === 'inbox')) {
|
|
673
|
+
out.inbox += 1
|
|
674
|
+
}
|
|
670
675
|
}
|
|
671
676
|
return out
|
|
672
677
|
}
|
package/lib/routes.js
CHANGED
|
@@ -20,11 +20,13 @@
|
|
|
20
20
|
* @module dsh-global-todo/routes
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
+
import { createHash, timingSafeEqual } from 'node:crypto'
|
|
23
24
|
import { readdir, stat } from 'node:fs/promises'
|
|
24
25
|
import { homedir } from 'node:os'
|
|
25
26
|
import path from 'node:path'
|
|
26
27
|
|
|
27
28
|
import { TodoError, asTodoError } from './errors.js'
|
|
29
|
+
import { MAX_INGEST_ITEMS } from './ingest.js'
|
|
28
30
|
|
|
29
31
|
/** Route prefix. The client appends this to the document base. */
|
|
30
32
|
export const ROUTE_BASE = '/api/global-todo'
|
|
@@ -119,6 +121,7 @@ async function readJsonBody(request) {
|
|
|
119
121
|
export function statusForCode(code) {
|
|
120
122
|
if (code === 'not-found') return 404
|
|
121
123
|
if (code === 'payload-too-large') return 413
|
|
124
|
+
if (code === 'unauthorized') return 401
|
|
122
125
|
if (code === 'forbidden') return 403
|
|
123
126
|
if (code === 'store-corrupt' || code === 'store-unwritable' || code === 'internal') return 500
|
|
124
127
|
if (code === 'agents-unavailable' || code === 'dispatch-failed') return 502
|
|
@@ -412,6 +415,14 @@ export function createRoutes(deps) {
|
|
|
412
415
|
loadedAt: settingsInfo.at,
|
|
413
416
|
loadSource: settingsInfo.source,
|
|
414
417
|
loadError: settingsInfo.error,
|
|
418
|
+
// The token itself never leaves the settings file; the panel only needs to know
|
|
419
|
+
// whether one is set before it can usefully show the ingest section.
|
|
420
|
+
ingest: {
|
|
421
|
+
enabled: deps.settings?.get?.().ingestEnabled === true,
|
|
422
|
+
sources: deps.settings?.get?.().ingestSources ?? [],
|
|
423
|
+
confirmBelow: deps.settings?.get?.().ingestConfirmBelow ?? 0.8,
|
|
424
|
+
tokenSet: typeof deps.settings?.get?.().ingestToken === 'string' && deps.settings.get().ingestToken !== '',
|
|
425
|
+
},
|
|
415
426
|
},
|
|
416
427
|
autoCollect: {
|
|
417
428
|
// Derived from the setting, not from a copy kept elsewhere: two sources for one
|
|
@@ -459,10 +470,52 @@ export function createRoutes(deps) {
|
|
|
459
470
|
idleMinutes: next.idleMinutes,
|
|
460
471
|
cooldownMinutes: next.cooldownMinutes,
|
|
461
472
|
exemptWorkspaces: next.exemptWorkspaces,
|
|
473
|
+
ingest: {
|
|
474
|
+
enabled: next.ingestEnabled,
|
|
475
|
+
sources: next.ingestSources,
|
|
476
|
+
confirmBelow: next.ingestConfirmBelow,
|
|
477
|
+
tokenSet: next.ingestToken !== '',
|
|
478
|
+
},
|
|
462
479
|
},
|
|
463
480
|
}
|
|
464
481
|
}),
|
|
465
482
|
|
|
483
|
+
exact('POST', `${ROUTE_BASE}/ingest`, async (request) => {
|
|
484
|
+
const settings = deps.settings?.get?.()
|
|
485
|
+
if (settings?.ingestEnabled !== true) {
|
|
486
|
+
throw new TodoError('forbidden', '外部 ingest 未开启(settings.ingestEnabled)')
|
|
487
|
+
}
|
|
488
|
+
const expected = typeof settings.ingestToken === 'string' ? settings.ingestToken : ''
|
|
489
|
+
const header = typeof request.headers.authorization === 'string' ? request.headers.authorization : ''
|
|
490
|
+
const match = /^Bearer\s+(.+)$/i.exec(header)
|
|
491
|
+
const supplied = match === null ? '' : match[1].trim()
|
|
492
|
+
// Hash both sides so the comparison is constant-time regardless of the token's length;
|
|
493
|
+
// an empty expected token refuses every request rather than matching an empty one.
|
|
494
|
+
const equal =
|
|
495
|
+
expected !== '' &&
|
|
496
|
+
supplied !== '' &&
|
|
497
|
+
timingSafeEqual(createHash('sha256').update(supplied).digest(), createHash('sha256').update(expected).digest())
|
|
498
|
+
if (!equal) throw new TodoError('unauthorized', 'ingest token 不正确')
|
|
499
|
+
const body = await readJsonBody(request)
|
|
500
|
+
if (!Array.isArray(body.items) || body.items.length === 0) {
|
|
501
|
+
throw new TodoError('invalid-body', 'items 必须是非空数组')
|
|
502
|
+
}
|
|
503
|
+
if (body.items.length > MAX_INGEST_ITEMS) {
|
|
504
|
+
throw new TodoError('invalid-body', `一次最多 ${MAX_INGEST_ITEMS} 条`)
|
|
505
|
+
}
|
|
506
|
+
const result = await service.ingest(body.items, {
|
|
507
|
+
confirmBelow: settings.ingestConfirmBelow,
|
|
508
|
+
dryRun: body.dryRun === true,
|
|
509
|
+
})
|
|
510
|
+
return {
|
|
511
|
+
dryRun: body.dryRun === true,
|
|
512
|
+
accepted: result.created.length,
|
|
513
|
+
created: result.created,
|
|
514
|
+
skipped: result.skipped,
|
|
515
|
+
preview: result.preview,
|
|
516
|
+
}
|
|
517
|
+
}),
|
|
518
|
+
|
|
466
519
|
exact('POST', `${ROUTE_BASE}/assign`, async (request) => {
|
|
467
520
|
const body = await readJsonBody(request)
|
|
468
521
|
return dispatcher.assignMany(body ?? {})
|
package/lib/service.js
CHANGED
|
@@ -11,7 +11,9 @@
|
|
|
11
11
|
|
|
12
12
|
import { existsSync } from 'node:fs'
|
|
13
13
|
|
|
14
|
+
import { findDuplicate } from './capture.js'
|
|
14
15
|
import { TodoError } from './errors.js'
|
|
16
|
+
import { expiredInbox, normalizeIngestItem } from './ingest.js'
|
|
15
17
|
import { formatLink } from './links.js'
|
|
16
18
|
import {
|
|
17
19
|
LIMITS,
|
|
@@ -666,6 +668,73 @@ export function createService(options) {
|
|
|
666
668
|
}
|
|
667
669
|
return { updated: updated.length, ids: updated, unmatched, errors }
|
|
668
670
|
}
|
|
671
|
+
|
|
672
|
+
/**
|
|
673
|
+
* Write external items through the same path as everything else.
|
|
674
|
+
*
|
|
675
|
+
* The gate decides per item; a rejected item is reported, never silently dropped, and a
|
|
676
|
+
* partial batch is normal — one bad mail must not discard the other nine.
|
|
677
|
+
*
|
|
678
|
+
* @param {object[]} items
|
|
679
|
+
* @param {{ confirmBelow?: number, dryRun?: boolean }} [options]
|
|
680
|
+
* @returns {Promise<{ created: object[], skipped: object[], preview: object[] }>}
|
|
681
|
+
*/
|
|
682
|
+
async function ingest(items, options = {}) {
|
|
683
|
+
const existing = (await store.all()).slice()
|
|
684
|
+
const created = []
|
|
685
|
+
const skipped = []
|
|
686
|
+
const preview = []
|
|
687
|
+
for (const raw of Array.isArray(items) ? items : []) {
|
|
688
|
+
const verdict = normalizeIngestItem(raw, { confirmBelow: options.confirmBelow })
|
|
689
|
+
if (verdict.error !== undefined) {
|
|
690
|
+
skipped.push({
|
|
691
|
+
title: typeof raw?.title === 'string' ? raw.title.slice(0, 80) : '',
|
|
692
|
+
reason: 'invalid',
|
|
693
|
+
error: verdict.error,
|
|
694
|
+
})
|
|
695
|
+
continue
|
|
696
|
+
}
|
|
697
|
+
const duplicate = findDuplicate({ title: verdict.item.title, existing, now: now() })
|
|
698
|
+
if (duplicate.duplicate === true) {
|
|
699
|
+
skipped.push({
|
|
700
|
+
title: verdict.item.title.slice(0, 80),
|
|
701
|
+
reason: duplicate.reason,
|
|
702
|
+
error: '清单里已有重复条目,未重复登记',
|
|
703
|
+
})
|
|
704
|
+
continue
|
|
705
|
+
}
|
|
706
|
+
if (options.dryRun === true) {
|
|
707
|
+
preview.push({ title: verdict.item.title, tags: verdict.item.tags, inbox: verdict.inbox })
|
|
708
|
+
continue
|
|
709
|
+
}
|
|
710
|
+
const todo = await create(verdict.item, { by: 'agent' })
|
|
711
|
+
created.push(todo)
|
|
712
|
+
existing.push(todo)
|
|
713
|
+
}
|
|
714
|
+
return { created, skipped, preview }
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/**
|
|
718
|
+
* Archive inbox records nobody confirmed in time.
|
|
719
|
+
*
|
|
720
|
+
* @param {{ days?: number }} [options]
|
|
721
|
+
* @returns {Promise<{ expired: number }>}
|
|
722
|
+
*/
|
|
723
|
+
async function expireInbox(options = {}) {
|
|
724
|
+
const days = Number.isFinite(options.days) && options.days > 0 ? options.days : 7
|
|
725
|
+
const at = now()
|
|
726
|
+
// No stale records, no write: the mount-time sweep must not touch the file just to
|
|
727
|
+
// report that it had nothing to do.
|
|
728
|
+
if (expiredInbox(await store.all(), { now: at, days }).length === 0) return { expired: 0 }
|
|
729
|
+
return store.update((doc) => {
|
|
730
|
+
const stale = expiredInbox(doc.todos, { now: at, days })
|
|
731
|
+
for (const todo of stale) {
|
|
732
|
+
setStatusOn(todo, 'archived', { now: at, by: 'agent', reason: `收件箱超过 ${days} 天未确认` })
|
|
733
|
+
}
|
|
734
|
+
return { next: doc.todos, result: { expired: stale.length } }
|
|
735
|
+
})
|
|
736
|
+
}
|
|
737
|
+
|
|
669
738
|
/** @returns {Promise<object>} counts for the panel header and tool summaries */
|
|
670
739
|
async function stats() {
|
|
671
740
|
const todos = await store.all()
|
|
@@ -686,6 +755,8 @@ export function createService(options) {
|
|
|
686
755
|
remove,
|
|
687
756
|
restore,
|
|
688
757
|
bulk,
|
|
758
|
+
ingest,
|
|
759
|
+
expireInbox,
|
|
689
760
|
addLink,
|
|
690
761
|
removeLink,
|
|
691
762
|
recordDispatch,
|
package/lib/settings.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* @module dsh-global-todo/settings
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import { readFile, writeFile } from 'node:fs/promises'
|
|
11
|
+
import { chmod, readFile, writeFile } from 'node:fs/promises'
|
|
12
12
|
|
|
13
13
|
/** How many sessions the cooldown bookkeeping remembers. */
|
|
14
14
|
const ASKED_LIMIT = 50
|
|
@@ -31,6 +31,13 @@ export function retryDelayMs(attempts) {
|
|
|
31
31
|
/** How many exempt workspaces are remembered. */
|
|
32
32
|
const EXEMPT_LIMIT = 50
|
|
33
33
|
|
|
34
|
+
/**
|
|
35
|
+
* Where an external item may come from. An ingest request with a kind outside this list is
|
|
36
|
+
* refused rather than filed under "unknown": provenance the list cannot name is provenance it
|
|
37
|
+
* cannot reason about later.
|
|
38
|
+
*/
|
|
39
|
+
export const INGEST_SOURCES = Object.freeze(['mail', 'notification', 'announcement'])
|
|
40
|
+
|
|
34
41
|
export const SETTINGS_DEFAULTS = Object.freeze({
|
|
35
42
|
/** Ask a conversation to collect todos on its own. Off until a person says so. */
|
|
36
43
|
autoCollect: false,
|
|
@@ -52,6 +59,13 @@ export const SETTINGS_DEFAULTS = Object.freeze({
|
|
|
52
59
|
* The path is the stable fact about such a place.
|
|
53
60
|
*/
|
|
54
61
|
exemptWorkspaces: [],
|
|
62
|
+
/** External ingest is off until a person turns it on and sets a token. */
|
|
63
|
+
ingestEnabled: false,
|
|
64
|
+
/** Bearer token for `/ingest`. Empty refuses every request. Never returned by `/health`. */
|
|
65
|
+
ingestToken: '',
|
|
66
|
+
ingestSources: [...INGEST_SOURCES],
|
|
67
|
+
/** Items below this confidence wait in the inbox instead of joining the list. */
|
|
68
|
+
ingestConfirmBelow: 0.8,
|
|
55
69
|
})
|
|
56
70
|
|
|
57
71
|
/**
|
|
@@ -83,12 +97,26 @@ export function normalizeSettings(raw) {
|
|
|
83
97
|
if (exemptWorkspaces.length >= EXEMPT_LIMIT) break
|
|
84
98
|
exemptWorkspaces.push(dir)
|
|
85
99
|
}
|
|
100
|
+
const sources = []
|
|
101
|
+
for (const entry of Array.isArray(source.ingestSources) ? source.ingestSources : []) {
|
|
102
|
+
if (typeof entry !== 'string') continue
|
|
103
|
+
const kind = entry.trim().toLowerCase()
|
|
104
|
+
if (!INGEST_SOURCES.includes(kind) || sources.includes(kind)) continue
|
|
105
|
+
sources.push(kind)
|
|
106
|
+
}
|
|
107
|
+
const below = Number(source.ingestConfirmBelow)
|
|
86
108
|
return {
|
|
87
109
|
autoCollect: source.autoCollect === true,
|
|
88
110
|
cooldownMinutes: Number.isInteger(minutes) ? Math.min(Math.max(minutes, 5), 24 * 60) : SETTINGS_DEFAULTS.cooldownMinutes,
|
|
89
111
|
idleMinutes: Number.isInteger(idle) ? Math.min(Math.max(idle, 5), 24 * 60) : SETTINGS_DEFAULTS.idleMinutes,
|
|
90
112
|
asked,
|
|
91
113
|
exemptWorkspaces,
|
|
114
|
+
ingestEnabled: source.ingestEnabled === true,
|
|
115
|
+
// A token is opaque: no shape rule beyond "not empty", trimmed and capped so a pasted
|
|
116
|
+
// blob cannot become the whole settings file.
|
|
117
|
+
ingestToken: typeof source.ingestToken === 'string' ? source.ingestToken.trim().slice(0, 512) : '',
|
|
118
|
+
ingestSources: sources.length === 0 ? [...INGEST_SOURCES] : sources,
|
|
119
|
+
ingestConfirmBelow: Number.isFinite(below) ? Math.min(Math.max(below, 0), 1) : SETTINGS_DEFAULTS.ingestConfirmBelow,
|
|
92
120
|
}
|
|
93
121
|
}
|
|
94
122
|
|
|
@@ -127,6 +155,9 @@ export function createSettings(options) {
|
|
|
127
155
|
const persist = async () => {
|
|
128
156
|
try {
|
|
129
157
|
await writeFile(options.file, `${JSON.stringify({ version: 1, ...state }, null, 2)}\n`, 'utf8')
|
|
158
|
+
// The file can hold an ingest token; keep it readable by the Host user only. Best-effort:
|
|
159
|
+
// a filesystem without POSIX modes must not fail the write.
|
|
160
|
+
await chmod(options.file, 0o600).catch(() => {})
|
|
130
161
|
} catch (error) {
|
|
131
162
|
options.logger?.warn?.(`dsh-global-todo: 设置无法写入(${String(error?.message ?? error)})`)
|
|
132
163
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-global-todo",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
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",
|