dsh-global-todo 0.5.1 → 0.5.3
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 +31 -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 +316 -4
- package/lib/index.js +35 -1
- package/lib/model.js +3 -0
- package/lib/reminders.js +39 -0
- package/lib/routes.js +18 -1
- package/lib/service.js +16 -0
- package/lib/settings.js +7 -0
- package/lib/store.js +20 -1
- package/lib/tools.js +4 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,18 @@
|
|
|
1
|
+
## [0.5.3] — 2026-10-20
|
|
2
|
+
|
|
3
|
+
Settings a person can reach, and reminders that behave like reminders.
|
|
4
|
+
|
|
5
|
+
- **A settings popover.** Reminders and the external ingest door were both API-only; the header
|
|
6
|
+
now opens a small panel for the reminder switch, the lead time, the ingest switch, the token
|
|
7
|
+
(generate a fresh one in place; leave blank to keep the current) and the inbox confidence
|
|
8
|
+
threshold. The token still never comes back from the host.
|
|
9
|
+
- **Reminders gained a lead time and a switch.** remindersEnabled and reminderLeadMinutes live in
|
|
10
|
+
settings; the host sweep reads them each tick, announces at dueAt minus lead, and clears the due
|
|
11
|
+
list when reminders are off.
|
|
12
|
+
- **Browser notifications are opt-in.** When the panel is open and a new deadline becomes due, it
|
|
13
|
+
fires a system notification if permission was granted; otherwise the due bar offers to ask for
|
|
14
|
+
permission. The notification is a bonus — the bar is the source of truth.
|
|
15
|
+
|
|
1
16
|
# Changelog
|
|
2
17
|
|
|
3
18
|
All notable changes to this project are documented in this file.
|
|
@@ -7,6 +22,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
22
|
|
|
8
23
|
## [Unreleased]
|
|
9
24
|
|
|
25
|
+
## [0.5.2] — 2026-10-19
|
|
26
|
+
|
|
27
|
+
Three things the leaders in this category do that we did not.
|
|
28
|
+
|
|
29
|
+
- **Deadlines now say something.** A 30-second host sweep finds active records whose `dueAt` has
|
|
30
|
+
passed and that were not announced yet; the panel shows a due bar, clicking an item opens it,
|
|
31
|
+
and acknowledging clears it. The announced set lives in memory, so a restart re-announces an
|
|
32
|
+
unacknowledged deadline once — the offline catch-up a reminder should have. A postponed
|
|
33
|
+
deadline is a new reminder; an announced one stays quiet.
|
|
34
|
+
- **Readable numbers: `#42`.** Records get a stable, human-facing sequence number assigned on
|
|
35
|
+
create and backfilled in creation order for legacy files; deleting #1 does not renumber #2. It
|
|
36
|
+
shows in the panel meta, in tool output and in the list line, so "把 #42 交给 Agent" means the
|
|
37
|
+
same thing to both sides.
|
|
38
|
+
- **Import previews before writing.** The panel runs a dry run first and shows 新增/更新 counts
|
|
39
|
+
with a confirm button; a wrong file can no longer overwrite by mis-click. The preview is also
|
|
40
|
+
available as `dryRun` on the route.
|
|
10
41
|
## [0.5.1] — 2026-10-19
|
|
11
42
|
|
|
12
43
|
The last two loose ends from the D4 audit.
|
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 = 'ui35'
|
|
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
|
|
|
@@ -90,6 +90,19 @@ window.__ModuleLoader__.load({
|
|
|
90
90
|
'action.import': '导入',
|
|
91
91
|
'action.exportHint': '把整份清单导出成 JSON 文件(就是存储用的那份文档)',
|
|
92
92
|
'action.importHint': '从 JSON 文件导入:同 id 覆盖、新 id 追加,不删除现有待办',
|
|
93
|
+
'settings.title': '设置',
|
|
94
|
+
'settings.reminders': '到点提醒',
|
|
95
|
+
'settings.remindersEnable': '开启到点提醒',
|
|
96
|
+
'settings.lead': '提前(分钟)',
|
|
97
|
+
'settings.ingest': '外部接入(ingest)',
|
|
98
|
+
'settings.ingestEnable': '启用外部写入',
|
|
99
|
+
'settings.token': '访问 token',
|
|
100
|
+
'settings.tokenSet': '已设置,留空保持不变',
|
|
101
|
+
'settings.generate': '生成新 token',
|
|
102
|
+
'settings.confirmBelow': '低于此置信度进收件箱',
|
|
103
|
+
'settings.save': '保存',
|
|
104
|
+
'settings.cancel': '取消',
|
|
105
|
+
'settings.saved': '设置已保存',
|
|
93
106
|
'notice.exported': '已导出 {n} 条待办',
|
|
94
107
|
'notice.imported': '已导入 {n} 条(合并,现有共 {total} 条)',
|
|
95
108
|
'import.badJson': '这个文件不是合法的 JSON',
|
|
@@ -136,6 +149,13 @@ window.__ModuleLoader__.load({
|
|
|
136
149
|
'notice.autoCollectOn': '已开启自动获取:一次人为发言最多自动问一次',
|
|
137
150
|
'notice.autoCollectOff': '已关闭自动获取',
|
|
138
151
|
'notice.externalChange': '清单在别处被改过,已载入最新内容',
|
|
152
|
+
'reminder.title': '到点提醒({n})',
|
|
153
|
+
'reminder.dismiss': '知道了',
|
|
154
|
+
'reminder.enableNotifications': '开启系统通知',
|
|
155
|
+
'reminder.notifyTitle': '待办到点',
|
|
156
|
+
'import.preview': '将导入 {total} 条:新增 {create}、更新 {update}',
|
|
157
|
+
'import.confirm': '导入',
|
|
158
|
+
'meta.seqHint': '给人看的编号',
|
|
139
159
|
'action.exempt': '豁免',
|
|
140
160
|
'action.exemptOn': '已豁免',
|
|
141
161
|
'action.exemptHint': '这个工作区里的对话永不自动获取(再点一下取消)',
|
|
@@ -306,6 +326,19 @@ window.__ModuleLoader__.load({
|
|
|
306
326
|
'action.import': 'Import',
|
|
307
327
|
'action.exportHint': 'Download the whole list as JSON (the document the store keeps)',
|
|
308
328
|
'action.importHint': 'Import a JSON file: same ids are updated, new ids appended, nothing deleted',
|
|
329
|
+
'settings.title': 'Settings',
|
|
330
|
+
'settings.reminders': 'Due reminders',
|
|
331
|
+
'settings.remindersEnable': 'Announce deadlines',
|
|
332
|
+
'settings.lead': 'Lead time (min)',
|
|
333
|
+
'settings.ingest': 'External ingest',
|
|
334
|
+
'settings.ingestEnable': 'Accept external writes',
|
|
335
|
+
'settings.token': 'Access token',
|
|
336
|
+
'settings.tokenSet': 'Set — leave blank to keep',
|
|
337
|
+
'settings.generate': 'New token',
|
|
338
|
+
'settings.confirmBelow': 'Below this confidence → inbox',
|
|
339
|
+
'settings.save': 'Save',
|
|
340
|
+
'settings.cancel': 'Cancel',
|
|
341
|
+
'settings.saved': 'Settings saved',
|
|
309
342
|
'notice.exported': 'Exported {n} todos',
|
|
310
343
|
'notice.imported': 'Imported {n} (merged; {total} in the list now)',
|
|
311
344
|
'import.badJson': 'That file is not valid JSON',
|
|
@@ -352,6 +385,13 @@ window.__ModuleLoader__.load({
|
|
|
352
385
|
'notice.autoCollectOn': 'Auto-collect on: at most one question per message from you',
|
|
353
386
|
'notice.autoCollectOff': 'Auto-collect off',
|
|
354
387
|
'notice.externalChange': 'The list changed somewhere else; the latest content is loaded',
|
|
388
|
+
'reminder.title': 'Due now ({n})',
|
|
389
|
+
'reminder.dismiss': 'Dismiss',
|
|
390
|
+
'reminder.enableNotifications': 'Enable notifications',
|
|
391
|
+
'reminder.notifyTitle': 'Todo due',
|
|
392
|
+
'import.preview': 'Will import {total}: {create} new, {update} updated',
|
|
393
|
+
'import.confirm': 'Import',
|
|
394
|
+
'meta.seqHint': 'Human-facing number',
|
|
355
395
|
'action.exempt': 'Exempt',
|
|
356
396
|
'action.exemptOn': 'Exempted',
|
|
357
397
|
'action.exemptHint': 'Never auto-collect in this workspace (click again to undo)',
|
|
@@ -995,6 +1035,22 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
995
1035
|
.gt-item-body { flex: 1; display: flex; flex-direction: column; gap: 5px; min-width: 0; }
|
|
996
1036
|
.gt-item-title { font-weight: 600; font-size: 13.5px; word-break: break-word; line-height: 1.45; }
|
|
997
1037
|
.gt-input-xs { width: 68px; padding: 3px 6px; font-size: 12px; }
|
|
1038
|
+
.gt-seq { font-variant-numeric: tabular-nums; color: var(--dsw-alias-label-secondary); }
|
|
1039
|
+
.gt-reminders { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 8px 10px;
|
|
1040
|
+
border: 1px solid var(--dsw-alias-state-warn-primary); border-radius: 10px;
|
|
1041
|
+
background: var(--dsw-alias-bg-layer-1); font-size: 12.5px; }
|
|
1042
|
+
.gt-reminders-title { color: var(--dsw-alias-state-warn-primary); font-weight: 600; }
|
|
1043
|
+
.gt-reminder-item { border: 1px solid var(--dsw-alias-border-l1); background: var(--dsw-alias-bg-layer-2);
|
|
1044
|
+
color: var(--dsw-alias-label-primary); font: inherit; font-size: 12.5px; padding: 3px 8px;
|
|
1045
|
+
border-radius: 999px; cursor: pointer; max-width: 260px; overflow: hidden; text-overflow: ellipsis;
|
|
1046
|
+
white-space: nowrap; }
|
|
1047
|
+
.gt-settings { align-self: flex-end; width: 330px; display: flex; flex-direction: column; gap: 8px;
|
|
1048
|
+
padding: 12px 14px; border: 1px solid var(--dsw-alias-border-l2); border-radius: 12px;
|
|
1049
|
+
background: var(--dsw-alias-bg-layer-1); font-size: 12.5px; }
|
|
1050
|
+
.gt-settings-head { font-weight: 650; }
|
|
1051
|
+
.gt-check-line { display: flex; align-items: center; gap: 8px; justify-content: space-between;
|
|
1052
|
+
color: var(--dsw-alias-label-primary); }
|
|
1053
|
+
.gt-settings-row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
|
998
1054
|
.gt-inbox { margin-left: 6px; padding: 1px 6px; border-radius: 999px; font-size: 10.5px; font-weight: 600;
|
|
999
1055
|
color: var(--dsw-alias-state-warn-primary); border: 1px solid var(--dsw-alias-state-warn-primary);
|
|
1000
1056
|
vertical-align: 1px; }
|
|
@@ -1412,6 +1468,9 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
1412
1468
|
draft.due !== toLocalInput(todo.dueAt)
|
|
1413
1469
|
|
|
1414
1470
|
const meta = []
|
|
1471
|
+
if (Number.isInteger(todo.seq) && todo.seq > 0) {
|
|
1472
|
+
meta.push(h('span', { key: 'seq', className: 'gt-seq', title: t('meta.seqHint') }, `#${todo.seq}`))
|
|
1473
|
+
}
|
|
1415
1474
|
meta.push(
|
|
1416
1475
|
h('span', { key: 'added', title: new Date(todo.createdAt).toLocaleString() }, `${t('meta.added')} ${absoluteTime(todo.createdAt)}(${ageText(lang, todo.createdAt, now)})`),
|
|
1417
1476
|
)
|
|
@@ -2017,6 +2076,23 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
2017
2076
|
const [bulkTag, setBulkTag] = useState('')
|
|
2018
2077
|
const [assignBusy, setAssignBusy] = useState(false)
|
|
2019
2078
|
const [assignAsk, setAssignAsk] = useState(null)
|
|
2079
|
+
// A picked file is previewed first: a merge is not undoable, and "did I pick the right
|
|
2080
|
+
// export" is exactly the kind of question a dry run answers before anything changes.
|
|
2081
|
+
const [importAsk, setImportAsk] = useState(null)
|
|
2082
|
+
// One settings popover: reminders and the external door both need a place a person can
|
|
2083
|
+
// reach without curl.
|
|
2084
|
+
const [settingsOpen, setSettingsOpen] = useState(false)
|
|
2085
|
+
const [tokenDraft, setTokenDraft] = useState('')
|
|
2086
|
+
const [ingestDraft, setIngestDraft] = useState(false)
|
|
2087
|
+
const [belowDraft, setBelowDraft] = useState('0.8')
|
|
2088
|
+
const [remindersDraft, setRemindersDraft] = useState(true)
|
|
2089
|
+
const [leadDraft, setLeadDraft] = useState('0')
|
|
2090
|
+
const [notificationPerm, setNotificationPerm] = useState(
|
|
2091
|
+
typeof window !== 'undefined' && typeof window.Notification === 'function'
|
|
2092
|
+
? window.Notification.permission
|
|
2093
|
+
: 'unsupported',
|
|
2094
|
+
)
|
|
2095
|
+
const notifiedRef = useRef(new Set())
|
|
2020
2096
|
const [groupMode, setGroupMode] = useState(() => readStoredGroupMode())
|
|
2021
2097
|
// A notice may carry an action: after a hand-off the useful next step is
|
|
2022
2098
|
// to go and look at the conversation the work is actually in.
|
|
@@ -2680,17 +2756,31 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
2680
2756
|
} catch {
|
|
2681
2757
|
throw new Error(t('import.badJson'))
|
|
2682
2758
|
}
|
|
2683
|
-
const
|
|
2759
|
+
const preview = await call('import', { method: 'POST', body: { doc: parsed, mode: 'merge', dryRun: true } })
|
|
2760
|
+
setError(null)
|
|
2761
|
+
setImportAsk({ doc: parsed, summary: preview })
|
|
2762
|
+
} catch (failure) {
|
|
2763
|
+
reportFailure('import', { message: failure?.message })
|
|
2764
|
+
setError(failure)
|
|
2765
|
+
} finally {
|
|
2766
|
+
if (input !== undefined && input !== null) input.value = ''
|
|
2767
|
+
}
|
|
2768
|
+
}
|
|
2769
|
+
|
|
2770
|
+
/** The bar from the dry run: this is the step that actually writes. */
|
|
2771
|
+
const confirmImport = async () => {
|
|
2772
|
+
if (importAsk === null) return
|
|
2773
|
+
try {
|
|
2774
|
+
const data = await call('import', { method: 'POST', body: { doc: importAsk.doc, mode: 'merge' } })
|
|
2684
2775
|
setError(null)
|
|
2685
2776
|
setNotice({ text: t('notice.imported', { n: data?.imported ?? 0, total: data?.total ?? 0 }), sessionId: '' })
|
|
2686
2777
|
setTimeout(() => setNotice(null), 6000)
|
|
2778
|
+
setImportAsk(null)
|
|
2687
2779
|
void reload()
|
|
2688
2780
|
void refreshHealth()
|
|
2689
2781
|
} catch (failure) {
|
|
2690
2782
|
reportFailure('import', { message: failure?.message })
|
|
2691
2783
|
setError(failure)
|
|
2692
|
-
} finally {
|
|
2693
|
-
if (input !== undefined && input !== null) input.value = ''
|
|
2694
2784
|
}
|
|
2695
2785
|
}
|
|
2696
2786
|
|
|
@@ -2824,6 +2914,58 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
2824
2914
|
return undefined
|
|
2825
2915
|
}
|
|
2826
2916
|
|
|
2917
|
+
/** Fill the popover from the host's state, so saving cannot send stale values. */
|
|
2918
|
+
const openSettings = () => {
|
|
2919
|
+
const current = state.health?.settings ?? {}
|
|
2920
|
+
setIngestDraft(current.ingest?.enabled === true)
|
|
2921
|
+
setBelowDraft(String(current.ingest?.confirmBelow ?? 0.8))
|
|
2922
|
+
setRemindersDraft(current.remindersEnabled !== false)
|
|
2923
|
+
setLeadDraft(String(current.reminderLeadMinutes ?? 0))
|
|
2924
|
+
setTokenDraft('')
|
|
2925
|
+
setSettingsOpen(true)
|
|
2926
|
+
}
|
|
2927
|
+
|
|
2928
|
+
const generateToken = () => {
|
|
2929
|
+
const bytes = new window.Uint8Array(24)
|
|
2930
|
+
window.crypto.getRandomValues(bytes)
|
|
2931
|
+
setTokenDraft(Array.from(bytes).map((byte) => byte.toString(16).padStart(2, '0')).join(''))
|
|
2932
|
+
setIngestDraft(true)
|
|
2933
|
+
}
|
|
2934
|
+
|
|
2935
|
+
const saveSettings = async () => {
|
|
2936
|
+
try {
|
|
2937
|
+
await call('settings', {
|
|
2938
|
+
method: 'POST',
|
|
2939
|
+
body: {
|
|
2940
|
+
remindersEnabled: remindersDraft,
|
|
2941
|
+
reminderLeadMinutes: Number(leadDraft),
|
|
2942
|
+
ingestEnabled: ingestDraft,
|
|
2943
|
+
ingestConfirmBelow: Number(belowDraft),
|
|
2944
|
+
...(tokenDraft.trim() === '' ? {} : { ingestToken: tokenDraft.trim() }),
|
|
2945
|
+
},
|
|
2946
|
+
})
|
|
2947
|
+
setSettingsOpen(false)
|
|
2948
|
+
setTokenDraft('')
|
|
2949
|
+
await refreshHealth()
|
|
2950
|
+
setNotice({ text: t('settings.saved'), sessionId: '' })
|
|
2951
|
+
setTimeout(() => setNotice(null), 5000)
|
|
2952
|
+
} catch (failure) {
|
|
2953
|
+
reportFailure('settings', { message: failure?.message })
|
|
2954
|
+
setError(failure)
|
|
2955
|
+
}
|
|
2956
|
+
}
|
|
2957
|
+
|
|
2958
|
+
/** Acknowledge reminders so the host stops listing them. */
|
|
2959
|
+
const ackReminders = async (ids) => {
|
|
2960
|
+
try {
|
|
2961
|
+
await call('reminders/ack', { method: 'POST', body: { ids } })
|
|
2962
|
+
await refreshHealth()
|
|
2963
|
+
} catch (failure) {
|
|
2964
|
+
reportFailure('reminders', { message: failure?.message })
|
|
2965
|
+
setError(failure)
|
|
2966
|
+
}
|
|
2967
|
+
}
|
|
2968
|
+
|
|
2827
2969
|
/** Apply one change to the whole selection; a stale id is reported, not fatal. */
|
|
2828
2970
|
const runBulk = async (change) => {
|
|
2829
2971
|
const ids = Object.keys(selected)
|
|
@@ -2850,6 +2992,31 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
2850
2992
|
}
|
|
2851
2993
|
|
|
2852
2994
|
const counts = state.counts ?? { total: 0, open: 0, doing: 0, done: 0, cancelled: 0, archived: 0, overdue: 0, inbox: 0 }
|
|
2995
|
+
const remindersDue = Array.isArray(state.health?.reminders?.due) ? state.health.reminders.due : []
|
|
2996
|
+
const dueKey = remindersDue.map((item) => `${item.id}:${item.dueAt}`).join('|')
|
|
2997
|
+
useEffect(() => {
|
|
2998
|
+
if (typeof window === 'undefined' || typeof window.Notification !== 'function') return
|
|
2999
|
+
for (const item of remindersDue) {
|
|
3000
|
+
const key = `${item.id}:${item.dueAt}`
|
|
3001
|
+
if (notifiedRef.current.has(key)) continue
|
|
3002
|
+
notifiedRef.current.add(key)
|
|
3003
|
+
if (window.Notification.permission !== 'granted') continue
|
|
3004
|
+
try {
|
|
3005
|
+
const note = new window.Notification(t('reminder.notifyTitle'), { body: item.title, tag: item.id })
|
|
3006
|
+
note.onclick = () => {
|
|
3007
|
+
try {
|
|
3008
|
+
window.focus()
|
|
3009
|
+
} catch {
|
|
3010
|
+
/* focus is a nicety */
|
|
3011
|
+
}
|
|
3012
|
+
setExpandedId(item.id)
|
|
3013
|
+
}
|
|
3014
|
+
} catch {
|
|
3015
|
+
/* a notification is a bonus, never a failure */
|
|
3016
|
+
}
|
|
3017
|
+
}
|
|
3018
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
3019
|
+
}, [dueKey])
|
|
2853
3020
|
const groups = useMemo(
|
|
2854
3021
|
() => groupTodos(state.todos, Date.now(), groupMode, sessionTitles, t),
|
|
2855
3022
|
[state.todos, groupMode, sessionTitles, lang],
|
|
@@ -2960,6 +3127,16 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
2960
3127
|
},
|
|
2961
3128
|
t('action.import'),
|
|
2962
3129
|
),
|
|
3130
|
+
h(
|
|
3131
|
+
'button',
|
|
3132
|
+
{
|
|
3133
|
+
type: 'button',
|
|
3134
|
+
className: 'gt-btn gt-btn-quiet',
|
|
3135
|
+
'aria-expanded': settingsOpen,
|
|
3136
|
+
onClick: () => (settingsOpen ? setSettingsOpen(false) : openSettings()),
|
|
3137
|
+
},
|
|
3138
|
+
t('settings.title'),
|
|
3139
|
+
),
|
|
2963
3140
|
h('input', {
|
|
2964
3141
|
ref: importRef,
|
|
2965
3142
|
type: 'file',
|
|
@@ -2969,6 +3146,71 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
2969
3146
|
onChange: (event) => void importJson(event),
|
|
2970
3147
|
}),
|
|
2971
3148
|
),
|
|
3149
|
+
settingsOpen
|
|
3150
|
+
? h(
|
|
3151
|
+
'div',
|
|
3152
|
+
{ className: 'gt-settings', role: 'dialog', 'aria-label': t('settings.title') },
|
|
3153
|
+
h('div', { className: 'gt-settings-head' }, t('settings.title')),
|
|
3154
|
+
h('div', { className: 'gt-section-title' }, t('settings.reminders')),
|
|
3155
|
+
h(
|
|
3156
|
+
'label',
|
|
3157
|
+
{ className: 'gt-check-line' },
|
|
3158
|
+
h('input', { type: 'checkbox', checked: remindersDraft, onChange: (event) => setRemindersDraft(event.target.checked) }),
|
|
3159
|
+
t('settings.remindersEnable'),
|
|
3160
|
+
),
|
|
3161
|
+
h(
|
|
3162
|
+
'label',
|
|
3163
|
+
{ className: 'gt-check-line' },
|
|
3164
|
+
t('settings.lead'),
|
|
3165
|
+
h('input', {
|
|
3166
|
+
type: 'number',
|
|
3167
|
+
min: 0,
|
|
3168
|
+
max: 1440,
|
|
3169
|
+
className: 'gt-input gt-input-xs',
|
|
3170
|
+
value: leadDraft,
|
|
3171
|
+
onChange: (event) => setLeadDraft(event.target.value),
|
|
3172
|
+
}),
|
|
3173
|
+
),
|
|
3174
|
+
h('div', { className: 'gt-section-title' }, t('settings.ingest')),
|
|
3175
|
+
h(
|
|
3176
|
+
'label',
|
|
3177
|
+
{ className: 'gt-check-line' },
|
|
3178
|
+
h('input', { type: 'checkbox', checked: ingestDraft, onChange: (event) => setIngestDraft(event.target.checked) }),
|
|
3179
|
+
t('settings.ingestEnable'),
|
|
3180
|
+
),
|
|
3181
|
+
h('input', {
|
|
3182
|
+
className: 'gt-input',
|
|
3183
|
+
type: 'password',
|
|
3184
|
+
value: tokenDraft,
|
|
3185
|
+
placeholder:
|
|
3186
|
+
state.health?.settings?.ingest?.tokenSet === true ? t('settings.tokenSet') : t('settings.token'),
|
|
3187
|
+
autoComplete: 'new-password',
|
|
3188
|
+
onChange: (event) => setTokenDraft(event.target.value),
|
|
3189
|
+
}),
|
|
3190
|
+
h(
|
|
3191
|
+
'div',
|
|
3192
|
+
{ className: 'gt-settings-row' },
|
|
3193
|
+
h('button', { type: 'button', className: 'gt-btn gt-btn-quiet', onClick: generateToken }, t('settings.generate')),
|
|
3194
|
+
h('button', { type: 'button', className: 'gt-btn gt-btn-brand', onClick: () => void saveSettings() }, t('settings.save')),
|
|
3195
|
+
h('button', { type: 'button', className: 'gt-btn gt-btn-quiet', onClick: () => setSettingsOpen(false) }, t('settings.cancel')),
|
|
3196
|
+
),
|
|
3197
|
+
h(
|
|
3198
|
+
'label',
|
|
3199
|
+
{ className: 'gt-check-line' },
|
|
3200
|
+
t('settings.confirmBelow'),
|
|
3201
|
+
h('input', {
|
|
3202
|
+
type: 'number',
|
|
3203
|
+
min: 0,
|
|
3204
|
+
max: 1,
|
|
3205
|
+
step: 0.05,
|
|
3206
|
+
className: 'gt-input gt-input-xs',
|
|
3207
|
+
value: belowDraft,
|
|
3208
|
+
onChange: (event) => setBelowDraft(event.target.value),
|
|
3209
|
+
}),
|
|
3210
|
+
),
|
|
3211
|
+
)
|
|
3212
|
+
: null,
|
|
3213
|
+
|
|
2972
3214
|
error !== null
|
|
2973
3215
|
? h(
|
|
2974
3216
|
'div',
|
|
@@ -3387,6 +3629,50 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
3387
3629
|
)
|
|
3388
3630
|
: null,
|
|
3389
3631
|
|
|
3632
|
+
// Deadlines that passed. The host announces each one once; acknowledging here is
|
|
3633
|
+
// what stops it listing them on the next poll.
|
|
3634
|
+
remindersDue.length === 0
|
|
3635
|
+
? null
|
|
3636
|
+
: h(
|
|
3637
|
+
'div',
|
|
3638
|
+
{ className: 'gt-reminders', role: 'status' },
|
|
3639
|
+
h('span', { className: 'gt-reminders-title' }, t('reminder.title', { n: remindersDue.length })),
|
|
3640
|
+
...remindersDue.slice(0, 3).map((item) =>
|
|
3641
|
+
h(
|
|
3642
|
+
'button',
|
|
3643
|
+
{
|
|
3644
|
+
key: item.id,
|
|
3645
|
+
type: 'button',
|
|
3646
|
+
className: 'gt-reminder-item',
|
|
3647
|
+
title: item.title,
|
|
3648
|
+
onClick: () => {
|
|
3649
|
+
void ackReminders([item.id])
|
|
3650
|
+
setExpandedId(item.id)
|
|
3651
|
+
},
|
|
3652
|
+
},
|
|
3653
|
+
`${item.seq > 0 ? `#${item.seq} ` : ''}${item.title}`,
|
|
3654
|
+
),
|
|
3655
|
+
),
|
|
3656
|
+
notificationPerm === 'default'
|
|
3657
|
+
? h(
|
|
3658
|
+
'button',
|
|
3659
|
+
{
|
|
3660
|
+
type: 'button',
|
|
3661
|
+
className: 'gt-btn gt-btn-quiet',
|
|
3662
|
+
onClick: () => {
|
|
3663
|
+
void window.Notification.requestPermission().then((permission) => setNotificationPerm(permission))
|
|
3664
|
+
},
|
|
3665
|
+
},
|
|
3666
|
+
t('reminder.enableNotifications'),
|
|
3667
|
+
)
|
|
3668
|
+
: null,
|
|
3669
|
+
h(
|
|
3670
|
+
'button',
|
|
3671
|
+
{ type: 'button', className: 'gt-btn gt-btn-quiet', onClick: () => void ackReminders(remindersDue.map((item) => item.id)) },
|
|
3672
|
+
t('reminder.dismiss'),
|
|
3673
|
+
),
|
|
3674
|
+
),
|
|
3675
|
+
|
|
3390
3676
|
// Nothing could be worked out for these: the user picks one target for them
|
|
3391
3677
|
// rather than having them sent somewhere nobody chose.
|
|
3392
3678
|
assignAsk === null
|
|
@@ -3412,6 +3698,32 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
3412
3698
|
),
|
|
3413
3699
|
),
|
|
3414
3700
|
|
|
3701
|
+
importAsk === null
|
|
3702
|
+
? null
|
|
3703
|
+
: h(
|
|
3704
|
+
'div',
|
|
3705
|
+
{ className: 'gt-selectbar' },
|
|
3706
|
+
h(
|
|
3707
|
+
'span',
|
|
3708
|
+
null,
|
|
3709
|
+
t('import.preview', {
|
|
3710
|
+
total: importAsk.summary?.wouldImport ?? 0,
|
|
3711
|
+
create: importAsk.summary?.wouldCreate ?? 0,
|
|
3712
|
+
update: importAsk.summary?.wouldUpdate ?? 0,
|
|
3713
|
+
}),
|
|
3714
|
+
),
|
|
3715
|
+
h(
|
|
3716
|
+
'button',
|
|
3717
|
+
{ type: 'button', className: 'gt-btn gt-btn-brand', onClick: () => void confirmImport() },
|
|
3718
|
+
t('import.confirm'),
|
|
3719
|
+
),
|
|
3720
|
+
h(
|
|
3721
|
+
'button',
|
|
3722
|
+
{ type: 'button', className: 'gt-btn gt-btn-quiet', onClick: () => setImportAsk(null) },
|
|
3723
|
+
t('select.clear'),
|
|
3724
|
+
),
|
|
3725
|
+
),
|
|
3726
|
+
|
|
3415
3727
|
loading && state.todos.length === 0
|
|
3416
3728
|
? h('div', { className: 'gt-empty' }, t('loading'))
|
|
3417
3729
|
: 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.3'
|
|
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,38 @@ 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 current = settings.get()
|
|
422
|
+
if (current.remindersEnabled === false) {
|
|
423
|
+
reminderState.due = []
|
|
424
|
+
return
|
|
425
|
+
}
|
|
426
|
+
const { todos } = await service.list({ status: 'active' })
|
|
427
|
+
const due = dueReminders(todos, {
|
|
428
|
+
now: Date.now(),
|
|
429
|
+
reminded: reminderState.reminded,
|
|
430
|
+
leadMs: (current.reminderLeadMinutes ?? 0) * 60_000,
|
|
431
|
+
})
|
|
432
|
+
if (due.length === 0) return
|
|
433
|
+
for (const item of due) reminderState.reminded[item.id] = item.dueAt
|
|
434
|
+
const known = new Set(reminderState.due.map((item) => item.id))
|
|
435
|
+
reminderState.due = [...reminderState.due, ...due.filter((item) => !known.has(item.id))]
|
|
436
|
+
} catch (error) {
|
|
437
|
+
logger?.warn?.(`dsh-global-todo: reminder sweep failed: ${error?.message ?? error}`)
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
void tick()
|
|
441
|
+
if (typeof setInterval !== 'function') return () => {}
|
|
442
|
+
const timer = setInterval(() => void tick(), 30_000)
|
|
443
|
+
if (typeof timer.unref === 'function') timer.unref()
|
|
444
|
+
return () => clearInterval(timer)
|
|
445
|
+
}, 'dsh-global-todo: due reminders')
|
|
413
446
|
const autoCollectState = {
|
|
414
447
|
enabled: false,
|
|
415
448
|
asking: false,
|
|
@@ -488,6 +521,7 @@ export function apply(ctx, config) {
|
|
|
488
521
|
target: recentSession,
|
|
489
522
|
settings,
|
|
490
523
|
autoCollect: autoCollectState,
|
|
524
|
+
reminders: reminderState,
|
|
491
525
|
version: VERSION,
|
|
492
526
|
})
|
|
493
527
|
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,39 @@
|
|
|
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
|
+
// A lead time turns "at the deadline" into "a little before it": the same rule, shifted.
|
|
21
|
+
const lead = Number.isFinite(options.leadMs) && options.leadMs > 0 ? options.leadMs : 0
|
|
22
|
+
const reminded = options.reminded !== null && typeof options.reminded === 'object' ? options.reminded : {}
|
|
23
|
+
const due = []
|
|
24
|
+
for (const todo of Array.isArray(todos) ? todos : []) {
|
|
25
|
+
if (todo === null || typeof todo !== 'object') continue
|
|
26
|
+
if (todo.status !== 'open' && todo.status !== 'doing') continue
|
|
27
|
+
if (todo.dueAt === null || todo.dueAt === undefined || typeof todo.dueAt !== 'string') continue
|
|
28
|
+
const at = Date.parse(todo.dueAt)
|
|
29
|
+
if (!Number.isFinite(at) || at - lead > now) continue
|
|
30
|
+
if (reminded[todo.id] === todo.dueAt) continue
|
|
31
|
+
due.push({
|
|
32
|
+
id: todo.id,
|
|
33
|
+
seq: Number.isInteger(todo.seq) ? todo.seq : 0,
|
|
34
|
+
title: typeof todo.title === 'string' ? todo.title : '',
|
|
35
|
+
dueAt: todo.dueAt,
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
return due
|
|
39
|
+
}
|
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 () => {
|
|
@@ -417,6 +417,8 @@ export function createRoutes(deps) {
|
|
|
417
417
|
loadError: settingsInfo.error,
|
|
418
418
|
// The token itself never leaves the settings file; the panel only needs to know
|
|
419
419
|
// whether one is set before it can usefully show the ingest section.
|
|
420
|
+
remindersEnabled: deps.settings?.get?.().remindersEnabled !== false,
|
|
421
|
+
reminderLeadMinutes: deps.settings?.get?.().reminderLeadMinutes ?? 0,
|
|
420
422
|
ingest: {
|
|
421
423
|
enabled: deps.settings?.get?.().ingestEnabled === true,
|
|
422
424
|
sources: deps.settings?.get?.().ingestSources ?? [],
|
|
@@ -443,6 +445,9 @@ export function createRoutes(deps) {
|
|
|
443
445
|
lastRetry: deps.autoCollect?.lastRetry ?? null,
|
|
444
446
|
lastDrop: deps.autoCollect?.lastDrop ?? null,
|
|
445
447
|
},
|
|
448
|
+
// Deadlines that passed and have not been acknowledged. In memory: the host
|
|
449
|
+
// announces once, the panel acknowledges, a restart re-announces unacknowledged ones.
|
|
450
|
+
reminders: { due: deps.reminders?.due ?? [] },
|
|
446
451
|
// 「交给 Agent」 resolves its target from the conversation the user last
|
|
447
452
|
// wrote in, so whether that feed attached is worth reporting too.
|
|
448
453
|
target: {
|
|
@@ -480,6 +485,18 @@ export function createRoutes(deps) {
|
|
|
480
485
|
}
|
|
481
486
|
}),
|
|
482
487
|
|
|
488
|
+
exact('POST', `${ROUTE_BASE}/reminders/ack`, async (request) => {
|
|
489
|
+
const body = await readJsonBody(request)
|
|
490
|
+
const ids = new Set(
|
|
491
|
+
Array.isArray(body.ids) ? body.ids.filter((id) => typeof id === 'string' && id !== '') : [],
|
|
492
|
+
)
|
|
493
|
+
const state = deps.reminders
|
|
494
|
+
if (state !== undefined && state !== null && Array.isArray(state.due)) {
|
|
495
|
+
state.due = state.due.filter((item) => !ids.has(item.id))
|
|
496
|
+
}
|
|
497
|
+
return { cleared: ids.size }
|
|
498
|
+
}),
|
|
499
|
+
|
|
483
500
|
exact('POST', `${ROUTE_BASE}/ingest`, async (request) => {
|
|
484
501
|
const settings = deps.settings?.get?.()
|
|
485
502
|
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/settings.js
CHANGED
|
@@ -66,6 +66,10 @@ export const SETTINGS_DEFAULTS = Object.freeze({
|
|
|
66
66
|
ingestSources: [...INGEST_SOURCES],
|
|
67
67
|
/** Items below this confidence wait in the inbox instead of joining the list. */
|
|
68
68
|
ingestConfirmBelow: 0.8,
|
|
69
|
+
/** Announce deadlines in the panel. On by default; a deadline nobody hears about is just data. */
|
|
70
|
+
remindersEnabled: true,
|
|
71
|
+
/** Announce this many minutes before the deadline (0 = at the deadline). */
|
|
72
|
+
reminderLeadMinutes: 0,
|
|
69
73
|
})
|
|
70
74
|
|
|
71
75
|
/**
|
|
@@ -105,6 +109,7 @@ export function normalizeSettings(raw) {
|
|
|
105
109
|
sources.push(kind)
|
|
106
110
|
}
|
|
107
111
|
const below = Number(source.ingestConfirmBelow)
|
|
112
|
+
const lead = Number(source.reminderLeadMinutes)
|
|
108
113
|
return {
|
|
109
114
|
autoCollect: source.autoCollect === true,
|
|
110
115
|
cooldownMinutes: Number.isInteger(minutes) ? Math.min(Math.max(minutes, 5), 24 * 60) : SETTINGS_DEFAULTS.cooldownMinutes,
|
|
@@ -117,6 +122,8 @@ export function normalizeSettings(raw) {
|
|
|
117
122
|
ingestToken: typeof source.ingestToken === 'string' ? source.ingestToken.trim().slice(0, 512) : '',
|
|
118
123
|
ingestSources: sources.length === 0 ? [...INGEST_SOURCES] : sources,
|
|
119
124
|
ingestConfirmBelow: Number.isFinite(below) ? Math.min(Math.max(below, 0), 1) : SETTINGS_DEFAULTS.ingestConfirmBelow,
|
|
125
|
+
remindersEnabled: source.remindersEnabled !== false,
|
|
126
|
+
reminderLeadMinutes: Number.isFinite(lead) ? Math.min(Math.max(lead, 0), 24 * 60) : SETTINGS_DEFAULTS.reminderLeadMinutes,
|
|
120
127
|
}
|
|
121
128
|
}
|
|
122
129
|
|
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.3",
|
|
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",
|