dsh-global-todo 0.5.0 → 0.5.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 +26 -0
- package/README.md +2 -1
- package/README.zh.md +2 -1
- 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 +158 -4
- package/lib/index.js +26 -1
- package/lib/model.js +3 -0
- package/lib/reminders.js +37 -0
- package/lib/routes.js +16 -1
- package/lib/service.js +16 -0
- package/lib/store.js +20 -1
- package/lib/tools.js +4 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.5.2] — 2026-10-19
|
|
11
|
+
|
|
12
|
+
Three things the leaders in this category do that we did not.
|
|
13
|
+
|
|
14
|
+
- **Deadlines now say something.** A 30-second host sweep finds active records whose `dueAt` has
|
|
15
|
+
passed and that were not announced yet; the panel shows a due bar, clicking an item opens it,
|
|
16
|
+
and acknowledging clears it. The announced set lives in memory, so a restart re-announces an
|
|
17
|
+
unacknowledged deadline once — the offline catch-up a reminder should have. A postponed
|
|
18
|
+
deadline is a new reminder; an announced one stays quiet.
|
|
19
|
+
- **Readable numbers: `#42`.** Records get a stable, human-facing sequence number assigned on
|
|
20
|
+
create and backfilled in creation order for legacy files; deleting #1 does not renumber #2. It
|
|
21
|
+
shows in the panel meta, in tool output and in the list line, so "把 #42 交给 Agent" means the
|
|
22
|
+
same thing to both sides.
|
|
23
|
+
- **Import previews before writing.** The panel runs a dry run first and shows 新增/更新 counts
|
|
24
|
+
with a confirm button; a wrong file can no longer overwrite by mis-click. The preview is also
|
|
25
|
+
available as `dryRun` on the route.
|
|
26
|
+
## [0.5.1] — 2026-10-19
|
|
27
|
+
|
|
28
|
+
The last two loose ends from the D4 audit.
|
|
29
|
+
|
|
30
|
+
- **The idle window is editable where the switch is.** It was already in the settings file and
|
|
31
|
+
the API; now the panel has a number field next to 自动获取, writing on blur so typing 1 on the
|
|
32
|
+
way to 120 does not save 1.
|
|
33
|
+
- **The inbox can be worked in batches.** In the inbox view, the selection bar offers 确认
|
|
34
|
+
(removes the inbox mark) and 丢弃 (archives and removes it) instead of complete/archive, which
|
|
35
|
+
do not mean anything for records nobody has confirmed yet.
|
|
10
36
|
## [0.5.0] — 2026-10-19
|
|
11
37
|
|
|
12
38
|
The last two pieces of the automatic-capture plan: an external door, and somewhere for its
|
package/README.md
CHANGED
|
@@ -244,7 +244,8 @@ fence when that service is present, and by a strict loopback check otherwise.
|
|
|
244
244
|
| GET | `/api/global-todo/dirs` | `path?` — list subdirectories for the panel's built-in folder picker (the shell's own picker is preferred when mounted) |
|
|
245
245
|
| GET | `/api/global-todo/get` | `id` |
|
|
246
246
|
| GET | `/api/global-todo/export` | — (the store's own JSON document, for backup or migration) |
|
|
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) |
|
|
247
|
+
| POST | `/api/global-todo/import` | `{ doc, mode?: replace\|merge, dryRun? }` — `replace` is an exact round trip, `merge` updates by id and appends (what the panel sends); `dryRun` returns `wouldCreate` / `wouldUpdate` and writes nothing |
|
|
248
|
+
| POST | `/api/global-todo/reminders/ack` | `{ ids }` — stop listing these passed deadlines as due |
|
|
248
249
|
| POST | `/api/global-todo/capture` | `{ sessionId? }` — ask the current conversation to collect its follow-ups |
|
|
249
250
|
| 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
251
|
| 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 |
|
package/README.zh.md
CHANGED
|
@@ -140,7 +140,8 @@ dsh plugin --profile web remove dsh-global-todo
|
|
|
140
140
|
| GET | `/api/global-todo/dirs` | `path?`——列子目录,供面板内置的目录浏览器使用(宿主自己的选择器在时会优先) |
|
|
141
141
|
| GET | `/api/global-todo/get` | `id` |
|
|
142
142
|
| GET | `/api/global-todo/export` | ——(存储用的那份 JSON 文档,用于备份或迁移) |
|
|
143
|
-
| POST | `/api/global-todo/import` | `{ doc, mode?: replace\|merge }`——`replace` 是无损往返,`merge` 同 id 覆盖、新 id
|
|
143
|
+
| POST | `/api/global-todo/import` | `{ doc, mode?: replace\|merge, dryRun? }`——`replace` 是无损往返,`merge` 同 id 覆盖、新 id 追加(面板用的是这个);`dryRun` 只返回 `wouldCreate` / `wouldUpdate`,不落盘 |
|
|
144
|
+
| POST | `/api/global-todo/reminders/ack` | `{ ids }`——这些已到期的提醒不再列出来 |
|
|
144
145
|
| POST | `/api/global-todo/note` | `{ id, text }`(追加进展记录) |
|
|
145
146
|
| POST | `/api/global-todo/capture` | `{ sessionId? }`(请当前对话把要跟进的事抓进清单) |
|
|
146
147
|
| POST | `/api/global-todo/bulk` | `{ ids, status?, addTags?, removeTags? }`——一次改一整批;失效的 id 进 `unmatched`,不会让整批失败 |
|
|
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 = 'ui33'
|
|
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
|
|
|
@@ -119,6 +119,7 @@ window.__ModuleLoader__.load({
|
|
|
119
119
|
'assign.ask': '这 {n} 条找不到原对话,也没有同工作区的对话:选一个目标一起分配。',
|
|
120
120
|
'assign.failed': '{n} 条没分出去:{detail}',
|
|
121
121
|
'autoCollect.label': '自动获取',
|
|
122
|
+
'autoCollect.idle': '安静窗口(分钟)',
|
|
122
123
|
'autoCollect.hint': '开启后:某个对话**安静满 {n} 分钟**(期间没有任何新发言)才自动问它一次「有没有要跟进的事」;你一边说话它一边计时重置,所以工作时不会被打扰。它问过之后,**只有你在那个对话里再说一句话**才会重新问——不会追着自己的提问反复问。关掉就完全靠你和 Agent 手动记。',
|
|
123
124
|
'autoCollect.last': '最近一次判定:{reason}(等待 {waiting} · 到点 {due})',
|
|
124
125
|
'autoCollect.reason.off': '开关关着',
|
|
@@ -135,6 +136,11 @@ window.__ModuleLoader__.load({
|
|
|
135
136
|
'notice.autoCollectOn': '已开启自动获取:一次人为发言最多自动问一次',
|
|
136
137
|
'notice.autoCollectOff': '已关闭自动获取',
|
|
137
138
|
'notice.externalChange': '清单在别处被改过,已载入最新内容',
|
|
139
|
+
'reminder.title': '到点提醒({n})',
|
|
140
|
+
'reminder.dismiss': '知道了',
|
|
141
|
+
'import.preview': '将导入 {total} 条:新增 {create}、更新 {update}',
|
|
142
|
+
'import.confirm': '导入',
|
|
143
|
+
'meta.seqHint': '给人看的编号',
|
|
138
144
|
'action.exempt': '豁免',
|
|
139
145
|
'action.exemptOn': '已豁免',
|
|
140
146
|
'action.exemptHint': '这个工作区里的对话永不自动获取(再点一下取消)',
|
|
@@ -334,6 +340,7 @@ window.__ModuleLoader__.load({
|
|
|
334
340
|
'assign.ask': '{n} of them have no origin and no same-workspace conversation: pick one target for those.',
|
|
335
341
|
'assign.failed': '{n} could not be assigned: {detail}',
|
|
336
342
|
'autoCollect.label': 'Auto-collect',
|
|
343
|
+
'autoCollect.idle': 'Idle window (minutes)',
|
|
337
344
|
'autoCollect.hint': 'When on: a conversation is asked once whether anything needs following up, but only after it has been **quiet for {n} minutes** — every new message restarts that clock, so a session you are working in is never interrupted. Once it has asked, only **you writing in that conversation** arms it again, so the question can never chase its own answer.',
|
|
338
345
|
'autoCollect.last': 'Last decision: {reason} (waiting {waiting}, due {due})',
|
|
339
346
|
'autoCollect.reason.off': 'the switch is off',
|
|
@@ -350,6 +357,11 @@ window.__ModuleLoader__.load({
|
|
|
350
357
|
'notice.autoCollectOn': 'Auto-collect on: at most one question per message from you',
|
|
351
358
|
'notice.autoCollectOff': 'Auto-collect off',
|
|
352
359
|
'notice.externalChange': 'The list changed somewhere else; the latest content is loaded',
|
|
360
|
+
'reminder.title': 'Due now ({n})',
|
|
361
|
+
'reminder.dismiss': 'Dismiss',
|
|
362
|
+
'import.preview': 'Will import {total}: {create} new, {update} updated',
|
|
363
|
+
'import.confirm': 'Import',
|
|
364
|
+
'meta.seqHint': 'Human-facing number',
|
|
353
365
|
'action.exempt': 'Exempt',
|
|
354
366
|
'action.exemptOn': 'Exempted',
|
|
355
367
|
'action.exemptHint': 'Never auto-collect in this workspace (click again to undo)',
|
|
@@ -992,6 +1004,16 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
992
1004
|
border: 1px solid var(--dsw-alias-brand-primary); border-radius: 9px; background: var(--dsw-alias-bg-layer-1); font-size: 12.5px; }
|
|
993
1005
|
.gt-item-body { flex: 1; display: flex; flex-direction: column; gap: 5px; min-width: 0; }
|
|
994
1006
|
.gt-item-title { font-weight: 600; font-size: 13.5px; word-break: break-word; line-height: 1.45; }
|
|
1007
|
+
.gt-input-xs { width: 68px; padding: 3px 6px; font-size: 12px; }
|
|
1008
|
+
.gt-seq { font-variant-numeric: tabular-nums; color: var(--dsw-alias-label-secondary); }
|
|
1009
|
+
.gt-reminders { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 8px 10px;
|
|
1010
|
+
border: 1px solid var(--dsw-alias-state-warn-primary); border-radius: 10px;
|
|
1011
|
+
background: var(--dsw-alias-bg-layer-1); font-size: 12.5px; }
|
|
1012
|
+
.gt-reminders-title { color: var(--dsw-alias-state-warn-primary); font-weight: 600; }
|
|
1013
|
+
.gt-reminder-item { border: 1px solid var(--dsw-alias-border-l1); background: var(--dsw-alias-bg-layer-2);
|
|
1014
|
+
color: var(--dsw-alias-label-primary); font: inherit; font-size: 12.5px; padding: 3px 8px;
|
|
1015
|
+
border-radius: 999px; cursor: pointer; max-width: 260px; overflow: hidden; text-overflow: ellipsis;
|
|
1016
|
+
white-space: nowrap; }
|
|
995
1017
|
.gt-inbox { margin-left: 6px; padding: 1px 6px; border-radius: 999px; font-size: 10.5px; font-weight: 600;
|
|
996
1018
|
color: var(--dsw-alias-state-warn-primary); border: 1px solid var(--dsw-alias-state-warn-primary);
|
|
997
1019
|
vertical-align: 1px; }
|
|
@@ -1409,6 +1431,9 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
1409
1431
|
draft.due !== toLocalInput(todo.dueAt)
|
|
1410
1432
|
|
|
1411
1433
|
const meta = []
|
|
1434
|
+
if (Number.isInteger(todo.seq) && todo.seq > 0) {
|
|
1435
|
+
meta.push(h('span', { key: 'seq', className: 'gt-seq', title: t('meta.seqHint') }, `#${todo.seq}`))
|
|
1436
|
+
}
|
|
1412
1437
|
meta.push(
|
|
1413
1438
|
h('span', { key: 'added', title: new Date(todo.createdAt).toLocaleString() }, `${t('meta.added')} ${absoluteTime(todo.createdAt)}(${ageText(lang, todo.createdAt, now)})`),
|
|
1414
1439
|
)
|
|
@@ -2014,6 +2039,9 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
2014
2039
|
const [bulkTag, setBulkTag] = useState('')
|
|
2015
2040
|
const [assignBusy, setAssignBusy] = useState(false)
|
|
2016
2041
|
const [assignAsk, setAssignAsk] = useState(null)
|
|
2042
|
+
// A picked file is previewed first: a merge is not undoable, and "did I pick the right
|
|
2043
|
+
// export" is exactly the kind of question a dry run answers before anything changes.
|
|
2044
|
+
const [importAsk, setImportAsk] = useState(null)
|
|
2017
2045
|
const [groupMode, setGroupMode] = useState(() => readStoredGroupMode())
|
|
2018
2046
|
// A notice may carry an action: after a hand-off the useful next step is
|
|
2019
2047
|
// to go and look at the conversation the work is actually in.
|
|
@@ -2501,6 +2529,23 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
2501
2529
|
},
|
|
2502
2530
|
t('autoCollect.label'),
|
|
2503
2531
|
),
|
|
2532
|
+
// The idle window is a number, not a switch: expose it where the switch lives and
|
|
2533
|
+
// write on blur, so typing "1" on the way to "120" does not save 1.
|
|
2534
|
+
h('input', {
|
|
2535
|
+
key: 'idle',
|
|
2536
|
+
type: 'number',
|
|
2537
|
+
min: 5,
|
|
2538
|
+
max: 1440,
|
|
2539
|
+
className: 'gt-input gt-input-xs',
|
|
2540
|
+
defaultValue: Number(state.health?.settings?.idleMinutes) || 30,
|
|
2541
|
+
title: t('autoCollect.idle'),
|
|
2542
|
+
'aria-label': t('autoCollect.idle'),
|
|
2543
|
+
onBlur: (event) => {
|
|
2544
|
+
const next = Number(event.target.value)
|
|
2545
|
+
if (!Number.isInteger(next) || next < 5 || next > 1440) return
|
|
2546
|
+
void call('settings', { method: 'POST', body: { idleMinutes: next } }).then(() => refreshHealth())
|
|
2547
|
+
},
|
|
2548
|
+
}),
|
|
2504
2549
|
]
|
|
2505
2550
|
|
|
2506
2551
|
const autoCollectOn = settingsOn
|
|
@@ -2660,17 +2705,31 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
2660
2705
|
} catch {
|
|
2661
2706
|
throw new Error(t('import.badJson'))
|
|
2662
2707
|
}
|
|
2663
|
-
const
|
|
2708
|
+
const preview = await call('import', { method: 'POST', body: { doc: parsed, mode: 'merge', dryRun: true } })
|
|
2709
|
+
setError(null)
|
|
2710
|
+
setImportAsk({ doc: parsed, summary: preview })
|
|
2711
|
+
} catch (failure) {
|
|
2712
|
+
reportFailure('import', { message: failure?.message })
|
|
2713
|
+
setError(failure)
|
|
2714
|
+
} finally {
|
|
2715
|
+
if (input !== undefined && input !== null) input.value = ''
|
|
2716
|
+
}
|
|
2717
|
+
}
|
|
2718
|
+
|
|
2719
|
+
/** The bar from the dry run: this is the step that actually writes. */
|
|
2720
|
+
const confirmImport = async () => {
|
|
2721
|
+
if (importAsk === null) return
|
|
2722
|
+
try {
|
|
2723
|
+
const data = await call('import', { method: 'POST', body: { doc: importAsk.doc, mode: 'merge' } })
|
|
2664
2724
|
setError(null)
|
|
2665
2725
|
setNotice({ text: t('notice.imported', { n: data?.imported ?? 0, total: data?.total ?? 0 }), sessionId: '' })
|
|
2666
2726
|
setTimeout(() => setNotice(null), 6000)
|
|
2727
|
+
setImportAsk(null)
|
|
2667
2728
|
void reload()
|
|
2668
2729
|
void refreshHealth()
|
|
2669
2730
|
} catch (failure) {
|
|
2670
2731
|
reportFailure('import', { message: failure?.message })
|
|
2671
2732
|
setError(failure)
|
|
2672
|
-
} finally {
|
|
2673
|
-
if (input !== undefined && input !== null) input.value = ''
|
|
2674
2733
|
}
|
|
2675
2734
|
}
|
|
2676
2735
|
|
|
@@ -2804,6 +2863,17 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
2804
2863
|
return undefined
|
|
2805
2864
|
}
|
|
2806
2865
|
|
|
2866
|
+
/** Acknowledge reminders so the host stops listing them. */
|
|
2867
|
+
const ackReminders = async (ids) => {
|
|
2868
|
+
try {
|
|
2869
|
+
await call('reminders/ack', { method: 'POST', body: { ids } })
|
|
2870
|
+
await refreshHealth()
|
|
2871
|
+
} catch (failure) {
|
|
2872
|
+
reportFailure('reminders', { message: failure?.message })
|
|
2873
|
+
setError(failure)
|
|
2874
|
+
}
|
|
2875
|
+
}
|
|
2876
|
+
|
|
2807
2877
|
/** Apply one change to the whole selection; a stale id is reported, not fatal. */
|
|
2808
2878
|
const runBulk = async (change) => {
|
|
2809
2879
|
const ids = Object.keys(selected)
|
|
@@ -2830,6 +2900,7 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
2830
2900
|
}
|
|
2831
2901
|
|
|
2832
2902
|
const counts = state.counts ?? { total: 0, open: 0, doing: 0, done: 0, cancelled: 0, archived: 0, overdue: 0, inbox: 0 }
|
|
2903
|
+
const remindersDue = Array.isArray(state.health?.reminders?.due) ? state.health.reminders.due : []
|
|
2833
2904
|
const groups = useMemo(
|
|
2834
2905
|
() => groupTodos(state.todos, Date.now(), groupMode, sessionTitles, t),
|
|
2835
2906
|
[state.todos, groupMode, sessionTitles, lang],
|
|
@@ -3299,6 +3370,32 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
3299
3370
|
},
|
|
3300
3371
|
t('select.archive'),
|
|
3301
3372
|
),
|
|
3373
|
+
// In the inbox view the useful batch actions are confirm and discard, not
|
|
3374
|
+
// complete: nothing here is done yet.
|
|
3375
|
+
filter === 'inbox'
|
|
3376
|
+
? h(
|
|
3377
|
+
'button',
|
|
3378
|
+
{
|
|
3379
|
+
type: 'button',
|
|
3380
|
+
className: 'gt-btn gt-btn-quiet',
|
|
3381
|
+
disabled: Object.keys(selected).length === 0,
|
|
3382
|
+
onClick: () => void runBulk({ removeTags: ['inbox'] }),
|
|
3383
|
+
},
|
|
3384
|
+
t('action.confirm'),
|
|
3385
|
+
)
|
|
3386
|
+
: null,
|
|
3387
|
+
filter === 'inbox'
|
|
3388
|
+
? h(
|
|
3389
|
+
'button',
|
|
3390
|
+
{
|
|
3391
|
+
type: 'button',
|
|
3392
|
+
className: 'gt-btn gt-btn-quiet',
|
|
3393
|
+
disabled: Object.keys(selected).length === 0,
|
|
3394
|
+
onClick: () => void runBulk({ status: 'archived', removeTags: ['inbox'] }),
|
|
3395
|
+
},
|
|
3396
|
+
t('action.discard'),
|
|
3397
|
+
)
|
|
3398
|
+
: null,
|
|
3302
3399
|
h('input', {
|
|
3303
3400
|
className: 'gt-input gt-bulk-tag',
|
|
3304
3401
|
value: bulkTag,
|
|
@@ -3341,6 +3438,37 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
3341
3438
|
)
|
|
3342
3439
|
: null,
|
|
3343
3440
|
|
|
3441
|
+
// Deadlines that passed. The host announces each one once; acknowledging here is
|
|
3442
|
+
// what stops it listing them on the next poll.
|
|
3443
|
+
remindersDue.length === 0
|
|
3444
|
+
? null
|
|
3445
|
+
: h(
|
|
3446
|
+
'div',
|
|
3447
|
+
{ className: 'gt-reminders', role: 'status' },
|
|
3448
|
+
h('span', { className: 'gt-reminders-title' }, t('reminder.title', { n: remindersDue.length })),
|
|
3449
|
+
...remindersDue.slice(0, 3).map((item) =>
|
|
3450
|
+
h(
|
|
3451
|
+
'button',
|
|
3452
|
+
{
|
|
3453
|
+
key: item.id,
|
|
3454
|
+
type: 'button',
|
|
3455
|
+
className: 'gt-reminder-item',
|
|
3456
|
+
title: item.title,
|
|
3457
|
+
onClick: () => {
|
|
3458
|
+
void ackReminders([item.id])
|
|
3459
|
+
setExpandedId(item.id)
|
|
3460
|
+
},
|
|
3461
|
+
},
|
|
3462
|
+
`${item.seq > 0 ? `#${item.seq} ` : ''}${item.title}`,
|
|
3463
|
+
),
|
|
3464
|
+
),
|
|
3465
|
+
h(
|
|
3466
|
+
'button',
|
|
3467
|
+
{ type: 'button', className: 'gt-btn gt-btn-quiet', onClick: () => void ackReminders(remindersDue.map((item) => item.id)) },
|
|
3468
|
+
t('reminder.dismiss'),
|
|
3469
|
+
),
|
|
3470
|
+
),
|
|
3471
|
+
|
|
3344
3472
|
// Nothing could be worked out for these: the user picks one target for them
|
|
3345
3473
|
// rather than having them sent somewhere nobody chose.
|
|
3346
3474
|
assignAsk === null
|
|
@@ -3366,6 +3494,32 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
3366
3494
|
),
|
|
3367
3495
|
),
|
|
3368
3496
|
|
|
3497
|
+
importAsk === null
|
|
3498
|
+
? null
|
|
3499
|
+
: h(
|
|
3500
|
+
'div',
|
|
3501
|
+
{ className: 'gt-selectbar' },
|
|
3502
|
+
h(
|
|
3503
|
+
'span',
|
|
3504
|
+
null,
|
|
3505
|
+
t('import.preview', {
|
|
3506
|
+
total: importAsk.summary?.wouldImport ?? 0,
|
|
3507
|
+
create: importAsk.summary?.wouldCreate ?? 0,
|
|
3508
|
+
update: importAsk.summary?.wouldUpdate ?? 0,
|
|
3509
|
+
}),
|
|
3510
|
+
),
|
|
3511
|
+
h(
|
|
3512
|
+
'button',
|
|
3513
|
+
{ type: 'button', className: 'gt-btn gt-btn-brand', onClick: () => void confirmImport() },
|
|
3514
|
+
t('import.confirm'),
|
|
3515
|
+
),
|
|
3516
|
+
h(
|
|
3517
|
+
'button',
|
|
3518
|
+
{ type: 'button', className: 'gt-btn gt-btn-quiet', onClick: () => setImportAsk(null) },
|
|
3519
|
+
t('select.clear'),
|
|
3520
|
+
),
|
|
3521
|
+
),
|
|
3522
|
+
|
|
3369
3523
|
loading && state.todos.length === 0
|
|
3370
3524
|
? h('div', { className: 'gt-empty' }, t('loading'))
|
|
3371
3525
|
: state.todos.length === 0
|
package/lib/index.js
CHANGED
|
@@ -28,6 +28,7 @@ import { createDispatcher } from './dispatch.js'
|
|
|
28
28
|
import { createSettings, explainAutoCollect, idleSessions, retryDelayMs } from './settings.js'
|
|
29
29
|
import { createDiagnostics } from './diagnostics.js'
|
|
30
30
|
import { createMetrics } from './metrics.js'
|
|
31
|
+
import { dueReminders } from './reminders.js'
|
|
31
32
|
import { LIMITS } from './model.js'
|
|
32
33
|
import { ROUTE_BASE, createRoutes } from './routes.js'
|
|
33
34
|
import { createService } from './service.js'
|
|
@@ -35,7 +36,7 @@ import { createStore, resolveStorePath } from './store.js'
|
|
|
35
36
|
import { createTools } from './tools.js'
|
|
36
37
|
|
|
37
38
|
/** Must equal `package.json`'s version; `npm run check` enforces that. */
|
|
38
|
-
export const VERSION = '0.5.
|
|
39
|
+
export const VERSION = '0.5.2'
|
|
39
40
|
|
|
40
41
|
/** Plugin id. Also the Loader row id in `cordis.patch.yml`. */
|
|
41
42
|
export const name = 'dsh-global-todo'
|
|
@@ -410,6 +411,29 @@ export function apply(ctx, config) {
|
|
|
410
411
|
const diagnostics = createDiagnostics({ file: path.join(path.dirname(file), 'client-errors.log'), logger })
|
|
411
412
|
const settingsFile = path.join(path.dirname(file), 'settings.json')
|
|
412
413
|
const settings = createSettings({ file: settingsFile, logger })
|
|
414
|
+
// Deadlines that have passed and were not announced yet. In memory on purpose: a restart
|
|
415
|
+
// re-announces an unacknowledged deadline once, which is the offline catch-up a reminder
|
|
416
|
+
// is supposed to have.
|
|
417
|
+
const reminderState = { due: [], reminded: {} }
|
|
418
|
+
ctx.effect(() => {
|
|
419
|
+
const tick = async () => {
|
|
420
|
+
try {
|
|
421
|
+
const { todos } = await service.list({ status: 'active' })
|
|
422
|
+
const due = dueReminders(todos, { now: Date.now(), reminded: reminderState.reminded })
|
|
423
|
+
if (due.length === 0) return
|
|
424
|
+
for (const item of due) reminderState.reminded[item.id] = item.dueAt
|
|
425
|
+
const known = new Set(reminderState.due.map((item) => item.id))
|
|
426
|
+
reminderState.due = [...reminderState.due, ...due.filter((item) => !known.has(item.id))]
|
|
427
|
+
} catch (error) {
|
|
428
|
+
logger?.warn?.(`dsh-global-todo: reminder sweep failed: ${error?.message ?? error}`)
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
void tick()
|
|
432
|
+
if (typeof setInterval !== 'function') return () => {}
|
|
433
|
+
const timer = setInterval(() => void tick(), 30_000)
|
|
434
|
+
if (typeof timer.unref === 'function') timer.unref()
|
|
435
|
+
return () => clearInterval(timer)
|
|
436
|
+
}, 'dsh-global-todo: due reminders')
|
|
413
437
|
const autoCollectState = {
|
|
414
438
|
enabled: false,
|
|
415
439
|
asking: false,
|
|
@@ -488,6 +512,7 @@ export function apply(ctx, config) {
|
|
|
488
512
|
target: recentSession,
|
|
489
513
|
settings,
|
|
490
514
|
autoCollect: autoCollectState,
|
|
515
|
+
reminders: reminderState,
|
|
491
516
|
version: VERSION,
|
|
492
517
|
})
|
|
493
518
|
const disposers = routes.map((definition) => webCtx.webServer.register(definition))
|
package/lib/model.js
CHANGED
|
@@ -278,6 +278,9 @@ export function normalizeStored(raw, options = {}) {
|
|
|
278
278
|
|
|
279
279
|
const todo = {
|
|
280
280
|
id,
|
|
281
|
+
// Human-facing number ("把 #42 交给 Agent"). Assigned by the store from creation
|
|
282
|
+
// order; 0 means "not assigned yet" and is filled in on the next read.
|
|
283
|
+
seq: Number.isInteger(raw.seq) && raw.seq > 0 ? raw.seq : 0,
|
|
281
284
|
title,
|
|
282
285
|
description: typeof raw.description === 'string' ? raw.description : '',
|
|
283
286
|
status: STATUSES.includes(raw.status) ? raw.status : 'open',
|
package/lib/reminders.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Due reminders.
|
|
3
|
+
*
|
|
4
|
+
* The list already knows `dueAt`; what it never did was say anything when one passed. The rule
|
|
5
|
+
* is kept pure so the timer can be tested without waiting: a record is due when it is still
|
|
6
|
+
* active, its deadline is in the past, and this exact deadline has not been announced yet.
|
|
7
|
+
* Keying on `id → dueAt` means postponing a reminder makes it due again, while an announced one
|
|
8
|
+
* stays quiet across ticks and restarts (the map lives with the process).
|
|
9
|
+
*
|
|
10
|
+
* @module dsh-global-todo/reminders
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @param {object[]} todos
|
|
15
|
+
* @param {{ now?: number, reminded?: Record<string, string|null> }} [options]
|
|
16
|
+
* @returns {{ id: string, seq: number, title: string, dueAt: string }[]}
|
|
17
|
+
*/
|
|
18
|
+
export function dueReminders(todos, options = {}) {
|
|
19
|
+
const now = Number.isFinite(options.now) ? options.now : Date.now()
|
|
20
|
+
const reminded = options.reminded !== null && typeof options.reminded === 'object' ? options.reminded : {}
|
|
21
|
+
const due = []
|
|
22
|
+
for (const todo of Array.isArray(todos) ? todos : []) {
|
|
23
|
+
if (todo === null || typeof todo !== 'object') continue
|
|
24
|
+
if (todo.status !== 'open' && todo.status !== 'doing') continue
|
|
25
|
+
if (todo.dueAt === null || todo.dueAt === undefined || typeof todo.dueAt !== 'string') continue
|
|
26
|
+
const at = Date.parse(todo.dueAt)
|
|
27
|
+
if (!Number.isFinite(at) || at > now) continue
|
|
28
|
+
if (reminded[todo.id] === todo.dueAt) continue
|
|
29
|
+
due.push({
|
|
30
|
+
id: todo.id,
|
|
31
|
+
seq: Number.isInteger(todo.seq) ? todo.seq : 0,
|
|
32
|
+
title: typeof todo.title === 'string' ? todo.title : '',
|
|
33
|
+
dueAt: todo.dueAt,
|
|
34
|
+
})
|
|
35
|
+
}
|
|
36
|
+
return due
|
|
37
|
+
}
|
package/lib/routes.js
CHANGED
|
@@ -354,7 +354,7 @@ export function createRoutes(deps) {
|
|
|
354
354
|
*/
|
|
355
355
|
exact('POST', `${ROUTE_BASE}/import`, async (request) => {
|
|
356
356
|
const body = await readJsonBody(request)
|
|
357
|
-
return service.importDocument(body.doc ?? body, { mode: body.mode })
|
|
357
|
+
return service.importDocument(body.doc ?? body, { mode: body.mode, dryRun: body.dryRun === true })
|
|
358
358
|
}),
|
|
359
359
|
|
|
360
360
|
exact('GET', `${ROUTE_BASE}/health`, async () => {
|
|
@@ -443,6 +443,9 @@ export function createRoutes(deps) {
|
|
|
443
443
|
lastRetry: deps.autoCollect?.lastRetry ?? null,
|
|
444
444
|
lastDrop: deps.autoCollect?.lastDrop ?? null,
|
|
445
445
|
},
|
|
446
|
+
// Deadlines that passed and have not been acknowledged. In memory: the host
|
|
447
|
+
// announces once, the panel acknowledges, a restart re-announces unacknowledged ones.
|
|
448
|
+
reminders: { due: deps.reminders?.due ?? [] },
|
|
446
449
|
// 「交给 Agent」 resolves its target from the conversation the user last
|
|
447
450
|
// wrote in, so whether that feed attached is worth reporting too.
|
|
448
451
|
target: {
|
|
@@ -480,6 +483,18 @@ export function createRoutes(deps) {
|
|
|
480
483
|
}
|
|
481
484
|
}),
|
|
482
485
|
|
|
486
|
+
exact('POST', `${ROUTE_BASE}/reminders/ack`, async (request) => {
|
|
487
|
+
const body = await readJsonBody(request)
|
|
488
|
+
const ids = new Set(
|
|
489
|
+
Array.isArray(body.ids) ? body.ids.filter((id) => typeof id === 'string' && id !== '') : [],
|
|
490
|
+
)
|
|
491
|
+
const state = deps.reminders
|
|
492
|
+
if (state !== undefined && state !== null && Array.isArray(state.due)) {
|
|
493
|
+
state.due = state.due.filter((item) => !ids.has(item.id))
|
|
494
|
+
}
|
|
495
|
+
return { cleared: ids.size }
|
|
496
|
+
}),
|
|
497
|
+
|
|
483
498
|
exact('POST', `${ROUTE_BASE}/ingest`, async (request) => {
|
|
484
499
|
const settings = deps.settings?.get?.()
|
|
485
500
|
if (settings?.ingestEnabled !== true) {
|
package/lib/service.js
CHANGED
|
@@ -277,6 +277,9 @@ export function createService(options) {
|
|
|
277
277
|
usedIds: doc.todos.map((candidate) => candidate.id),
|
|
278
278
|
...(context.origin === undefined ? {} : { origin: context.origin }),
|
|
279
279
|
})
|
|
280
|
+
// Stable, human-facing number: the store fills gaps on read, the service assigns the
|
|
281
|
+
// next one on create so "#42" keeps meaning #42.
|
|
282
|
+
todo.seq = doc.todos.reduce((max, candidate) => Math.max(max, candidate.seq ?? 0), 0) + 1
|
|
280
283
|
doc.todos.push(todo)
|
|
281
284
|
metrics?.recordList?.('created', doc.todos.length)
|
|
282
285
|
logger?.debug?.(`dsh-global-todo: created ${todo.id}`)
|
|
@@ -610,6 +613,19 @@ export function createService(options) {
|
|
|
610
613
|
seen.add(todo.id)
|
|
611
614
|
records.push(todo)
|
|
612
615
|
}
|
|
616
|
+
if (context.dryRun === true) {
|
|
617
|
+
const existing = await store.all()
|
|
618
|
+
const known = new Set(existing.map((todo) => todo.id))
|
|
619
|
+
const wouldCreate = records.filter((record) => !known.has(record.id)).length
|
|
620
|
+
return {
|
|
621
|
+
mode,
|
|
622
|
+
dryRun: true,
|
|
623
|
+
wouldImport: records.length,
|
|
624
|
+
wouldCreate,
|
|
625
|
+
wouldUpdate: records.length - wouldCreate,
|
|
626
|
+
total: mode === 'merge' ? existing.length + wouldCreate : records.length,
|
|
627
|
+
}
|
|
628
|
+
}
|
|
613
629
|
return store.update((existing) => {
|
|
614
630
|
const todos = mode === 'merge' ? mergeById(existing.todos, records) : records
|
|
615
631
|
metrics?.recordList?.('imports', todos.length)
|
package/lib/store.js
CHANGED
|
@@ -64,7 +64,26 @@ function salvage(value, activityLimit) {
|
|
|
64
64
|
seen.add(todo.id)
|
|
65
65
|
todos.push(todo)
|
|
66
66
|
}
|
|
67
|
-
return { version: Number.isInteger(doc.version) ? doc.version : SCHEMA_VERSION, todos }
|
|
67
|
+
return { version: Number.isInteger(doc.version) ? doc.version : SCHEMA_VERSION, todos: assignSequence(todos) }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Give every record its human-facing number.
|
|
72
|
+
*
|
|
73
|
+
* Existing numbers are kept so "把 #42 交给 Agent" stays true after other records are
|
|
74
|
+
* deleted; records without one are numbered in creation order, and the next write
|
|
75
|
+
* persists them. A file that never gets written again keeps deriving the same numbers.
|
|
76
|
+
*
|
|
77
|
+
* @param {object[]} todos
|
|
78
|
+
* @returns {object[]}
|
|
79
|
+
*/
|
|
80
|
+
function assignSequence(todos) {
|
|
81
|
+
let max = 0
|
|
82
|
+
for (const todo of todos) if (Number.isInteger(todo.seq) && todo.seq > max) max = todo.seq
|
|
83
|
+
const missing = todos.filter((todo) => !Number.isInteger(todo.seq) || todo.seq <= 0)
|
|
84
|
+
missing.sort((left, right) => left.createdAt - right.createdAt || left.id.localeCompare(right.id))
|
|
85
|
+
for (const todo of missing) todo.seq = ++max
|
|
86
|
+
return todos
|
|
68
87
|
}
|
|
69
88
|
|
|
70
89
|
/**
|
package/lib/tools.js
CHANGED
|
@@ -85,6 +85,7 @@ const COMPLETION_SHAPE = {
|
|
|
85
85
|
const TODO_SHAPE = { type: 'object',
|
|
86
86
|
properties: {
|
|
87
87
|
id: { type: 'string' },
|
|
88
|
+
seq: { type: 'integer', description: '给人看的编号(#42);0 表示尚未分配' },
|
|
88
89
|
title: { type: 'string' },
|
|
89
90
|
description: { type: 'string' },
|
|
90
91
|
status: { type: 'string', enum: [...STATUSES] },
|
|
@@ -144,6 +145,7 @@ export function presentTodo(todo, now = Date.now()) {
|
|
|
144
145
|
const due = todo.dueAt === null ? Number.POSITIVE_INFINITY : Date.parse(todo.dueAt)
|
|
145
146
|
return {
|
|
146
147
|
id: todo.id,
|
|
148
|
+
seq: todo.seq ?? 0,
|
|
147
149
|
title: todo.title,
|
|
148
150
|
description: todo.description,
|
|
149
151
|
status: todo.status,
|
|
@@ -181,7 +183,8 @@ export function presentTodo(todo, now = Date.now()) {
|
|
|
181
183
|
*/
|
|
182
184
|
export function todoLine(todo) {
|
|
183
185
|
const mark = todo.status === 'done' ? 'x' : todo.status === 'doing' ? '~' : todo.status === 'cancelled' ? '-' : ' '
|
|
184
|
-
const
|
|
186
|
+
const label = Number.isInteger(todo.seq) && todo.seq > 0 ? `#${todo.seq} ` : ''
|
|
187
|
+
const parts = [`- [${mark}] ${label}${todo.title} id=${todo.id}`, `加入于 ${new Date(todo.createdAt).toISOString()}`]
|
|
185
188
|
if (todo.priority !== 'normal') parts.push(PRIORITY_LABELS[todo.priority] ?? todo.priority)
|
|
186
189
|
if (todo.dueAt !== null) parts.push(`截止 ${todo.dueAt}`)
|
|
187
190
|
if (todo.tags.length > 0) parts.push(todo.tags.map((tag) => `#${tag}`).join(' '))
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-global-todo",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.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",
|