dsh-global-todo 0.4.7 → 0.5.1
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 +27 -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 +92 -5
- 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,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.5.1] — 2026-10-19
|
|
11
|
+
|
|
12
|
+
The last two loose ends from the D4 audit.
|
|
13
|
+
|
|
14
|
+
- **The idle window is editable where the switch is.** It was already in the settings file and
|
|
15
|
+
the API; now the panel has a number field next to 自动获取, writing on blur so typing 1 on the
|
|
16
|
+
way to 120 does not save 1.
|
|
17
|
+
- **The inbox can be worked in batches.** In the inbox view, the selection bar offers 确认
|
|
18
|
+
(removes the inbox mark) and 丢弃 (archives and removes it) instead of complete/archive, which
|
|
19
|
+
do not mean anything for records nobody has confirmed yet.
|
|
20
|
+
## [0.5.0] — 2026-10-19
|
|
21
|
+
|
|
22
|
+
The last two pieces of the automatic-capture plan: an external door, and somewhere for its
|
|
23
|
+
uncertain items to wait.
|
|
24
|
+
|
|
25
|
+
- **`POST /ingest` is the only external entrance.** It is closed until `ingestEnabled` is true
|
|
26
|
+
and a token is set; the token is compared in constant time, never returned by `/health`, and
|
|
27
|
+
the settings file is chmod 600 because it may hold it. Every item must name a source
|
|
28
|
+
(`mail` / `notification` / `announcement`) and a confidence, so nothing anonymous lands in the
|
|
29
|
+
list.
|
|
30
|
+
- **Low-confidence items wait in an inbox instead of joining the list.** Below
|
|
31
|
+
`ingestConfirmBelow` (default 0.8) a record is tagged `inbox`; the panel shows an inbox filter
|
|
32
|
+
chip, a 待确认 badge, and 确认 / 丢弃 in the row menu. Confirming only removes the mark;
|
|
33
|
+
discarding archives it. A weekly sweep archives inbox records nobody confirmed in seven days,
|
|
34
|
+
so the inbox cannot become a second list.
|
|
35
|
+
- Items still pass the same dedupe gate as `global_todo_capture`; provenance stays visible as a
|
|
36
|
+
`source:<kind>` tag after confirmation.
|
|
10
37
|
## [0.4.7] — 2026-10-19
|
|
11
38
|
|
|
12
39
|
Verified against the running GUI this time, with measured numbers instead of arithmetic.
|
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 = 'ui32'
|
|
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': '开关关着',
|
|
@@ -183,6 +184,10 @@ window.__ModuleLoader__.load({
|
|
|
183
184
|
'filter.done': '已完成',
|
|
184
185
|
'filter.overdue': '逾期',
|
|
185
186
|
'filter.archived': '已归档',
|
|
187
|
+
'filter.inbox': '收件箱',
|
|
188
|
+
'action.confirm': '确认',
|
|
189
|
+
'action.discard': '丢弃',
|
|
190
|
+
'meta.inbox': '待确认',
|
|
186
191
|
'filter.label': '筛选',
|
|
187
192
|
'field.title': '要做什么?回车即存',
|
|
188
193
|
'field.description': '描述:验收标准、已知的坑…',
|
|
@@ -330,6 +335,7 @@ window.__ModuleLoader__.load({
|
|
|
330
335
|
'assign.ask': '{n} of them have no origin and no same-workspace conversation: pick one target for those.',
|
|
331
336
|
'assign.failed': '{n} could not be assigned: {detail}',
|
|
332
337
|
'autoCollect.label': 'Auto-collect',
|
|
338
|
+
'autoCollect.idle': 'Idle window (minutes)',
|
|
333
339
|
'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.',
|
|
334
340
|
'autoCollect.last': 'Last decision: {reason} (waiting {waiting}, due {due})',
|
|
335
341
|
'autoCollect.reason.off': 'the switch is off',
|
|
@@ -396,6 +402,10 @@ window.__ModuleLoader__.load({
|
|
|
396
402
|
'filter.done': 'Done',
|
|
397
403
|
'filter.overdue': 'Overdue',
|
|
398
404
|
'filter.archived': 'Archived',
|
|
405
|
+
'filter.inbox': 'Inbox',
|
|
406
|
+
'action.confirm': 'Confirm',
|
|
407
|
+
'action.discard': 'Discard',
|
|
408
|
+
'meta.inbox': 'Needs review',
|
|
399
409
|
'filter.label': 'Filters',
|
|
400
410
|
'field.title': 'What needs doing? Enter to save',
|
|
401
411
|
'field.description': 'Description: acceptance, known traps…',
|
|
@@ -984,6 +994,10 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
984
994
|
border: 1px solid var(--dsw-alias-brand-primary); border-radius: 9px; background: var(--dsw-alias-bg-layer-1); font-size: 12.5px; }
|
|
985
995
|
.gt-item-body { flex: 1; display: flex; flex-direction: column; gap: 5px; min-width: 0; }
|
|
986
996
|
.gt-item-title { font-weight: 600; font-size: 13.5px; word-break: break-word; line-height: 1.45; }
|
|
997
|
+
.gt-input-xs { width: 68px; padding: 3px 6px; font-size: 12px; }
|
|
998
|
+
.gt-inbox { margin-left: 6px; padding: 1px 6px; border-radius: 999px; font-size: 10.5px; font-weight: 600;
|
|
999
|
+
color: var(--dsw-alias-state-warn-primary); border: 1px solid var(--dsw-alias-state-warn-primary);
|
|
1000
|
+
vertical-align: 1px; }
|
|
987
1001
|
.gt-item[data-status="done"] .gt-item-title, .gt-item[data-status="cancelled"] .gt-item-title {
|
|
988
1002
|
text-decoration: line-through; color: var(--dsw-alias-label-secondary); }
|
|
989
1003
|
.gt-meta { display: flex; gap: 5px 8px; flex-wrap: wrap; color: var(--dsw-alias-label-secondary); font-size: 11.5px;
|
|
@@ -1516,7 +1530,14 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
1516
1530
|
h(
|
|
1517
1531
|
'div',
|
|
1518
1532
|
{ className: 'gt-item-body' },
|
|
1519
|
-
h(
|
|
1533
|
+
h(
|
|
1534
|
+
'div',
|
|
1535
|
+
{ className: 'gt-item-title' },
|
|
1536
|
+
todo.title,
|
|
1537
|
+
todo.tags.some((tag) => tag.toLowerCase() === 'inbox')
|
|
1538
|
+
? h('span', { className: 'gt-inbox' }, t('meta.inbox'))
|
|
1539
|
+
: null,
|
|
1540
|
+
),
|
|
1520
1541
|
h(
|
|
1521
1542
|
'div',
|
|
1522
1543
|
{ className: 'gt-meta' },
|
|
@@ -1632,6 +1653,12 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
1632
1653
|
h(Menu, {
|
|
1633
1654
|
label: t('action.menu'),
|
|
1634
1655
|
items: [
|
|
1656
|
+
...(todo.tags.some((tag) => tag.toLowerCase() === 'inbox')
|
|
1657
|
+
? [
|
|
1658
|
+
{ key: 'confirm', label: t('action.confirm'), icon: 'check', onSelect: () => onAction('confirm', todo) },
|
|
1659
|
+
{ key: 'discard', label: t('action.discard'), icon: 'trash', danger: true, onSelect: () => onAction('discard', todo) },
|
|
1660
|
+
]
|
|
1661
|
+
: []),
|
|
1635
1662
|
{
|
|
1636
1663
|
key: 'toggle',
|
|
1637
1664
|
label: open ? t('action.hide') : t('action.detail'),
|
|
@@ -2163,10 +2190,18 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
2163
2190
|
|
|
2164
2191
|
const reload = useCallback(
|
|
2165
2192
|
async (next = {}) => {
|
|
2166
|
-
const
|
|
2193
|
+
const selected = next.filter ?? filter
|
|
2194
|
+
// The inbox is a tag view over active records, not a status of its own.
|
|
2195
|
+
const status = selected === 'inbox' ? 'active' : selected
|
|
2167
2196
|
const search = next.query ?? query
|
|
2168
2197
|
try {
|
|
2169
|
-
const data = await call('list', {
|
|
2198
|
+
const data = await call('list', {
|
|
2199
|
+
query: {
|
|
2200
|
+
status,
|
|
2201
|
+
...(selected === 'inbox' ? { tag: 'inbox' } : {}),
|
|
2202
|
+
...(search.trim() === '' ? {} : { query: search.trim() }),
|
|
2203
|
+
},
|
|
2204
|
+
})
|
|
2170
2205
|
if (!mounted.current) return
|
|
2171
2206
|
setState((current) => ({
|
|
2172
2207
|
...current,
|
|
@@ -2469,6 +2504,23 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
2469
2504
|
},
|
|
2470
2505
|
t('autoCollect.label'),
|
|
2471
2506
|
),
|
|
2507
|
+
// The idle window is a number, not a switch: expose it where the switch lives and
|
|
2508
|
+
// write on blur, so typing "1" on the way to "120" does not save 1.
|
|
2509
|
+
h('input', {
|
|
2510
|
+
key: 'idle',
|
|
2511
|
+
type: 'number',
|
|
2512
|
+
min: 5,
|
|
2513
|
+
max: 1440,
|
|
2514
|
+
className: 'gt-input gt-input-xs',
|
|
2515
|
+
defaultValue: Number(state.health?.settings?.idleMinutes) || 30,
|
|
2516
|
+
title: t('autoCollect.idle'),
|
|
2517
|
+
'aria-label': t('autoCollect.idle'),
|
|
2518
|
+
onBlur: (event) => {
|
|
2519
|
+
const next = Number(event.target.value)
|
|
2520
|
+
if (!Number.isInteger(next) || next < 5 || next > 1440) return
|
|
2521
|
+
void call('settings', { method: 'POST', body: { idleMinutes: next } }).then(() => refreshHealth())
|
|
2522
|
+
},
|
|
2523
|
+
}),
|
|
2472
2524
|
]
|
|
2473
2525
|
|
|
2474
2526
|
const autoCollectOn = settingsOn
|
|
@@ -2715,6 +2767,14 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
2715
2767
|
if (kind === 'done') return run(todo.id, () => call('status', { method: 'POST', body: { id: todo.id, status: 'done' } }))
|
|
2716
2768
|
if (kind === 'reopen') return run(todo.id, () => call('status', { method: 'POST', body: { id: todo.id, status: 'open' } }))
|
|
2717
2769
|
if (kind === 'doing') return run(todo.id, () => call('status', { method: 'POST', body: { id: todo.id, status: 'doing' } }))
|
|
2770
|
+
// Confirming keeps the record on the list; discarding archives it. Both only remove
|
|
2771
|
+
// the "waiting for a person" mark — the provenance tag stays.
|
|
2772
|
+
if (kind === 'confirm') {
|
|
2773
|
+
return run(todo.id, () => call('bulk', { method: 'POST', body: { ids: [todo.id], removeTags: ['inbox'] } }))
|
|
2774
|
+
}
|
|
2775
|
+
if (kind === 'discard') {
|
|
2776
|
+
return run(todo.id, () => call('bulk', { method: 'POST', body: { ids: [todo.id], status: 'archived', removeTags: ['inbox'] } }))
|
|
2777
|
+
}
|
|
2718
2778
|
// Archive keeps the history without cluttering the list; delete stays a separate,
|
|
2719
2779
|
// deliberate action reached only through the two-step menu item.
|
|
2720
2780
|
if (kind === 'archive') return run(todo.id, () => call('status', { method: 'POST', body: { id: todo.id, status: 'archived' } }))
|
|
@@ -2789,7 +2849,7 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
2789
2849
|
}
|
|
2790
2850
|
}
|
|
2791
2851
|
|
|
2792
|
-
const counts = state.counts ?? { total: 0, open: 0, doing: 0, done: 0, cancelled: 0, archived: 0, overdue: 0 }
|
|
2852
|
+
const counts = state.counts ?? { total: 0, open: 0, doing: 0, done: 0, cancelled: 0, archived: 0, overdue: 0, inbox: 0 }
|
|
2793
2853
|
const groups = useMemo(
|
|
2794
2854
|
() => groupTodos(state.todos, Date.now(), groupMode, sessionTitles, t),
|
|
2795
2855
|
[state.todos, groupMode, sessionTitles, lang],
|
|
@@ -3016,6 +3076,7 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
3016
3076
|
// Only shown once something is archived: an empty filter that exists from
|
|
3017
3077
|
// day one is furniture, not a control.
|
|
3018
3078
|
...(counts.archived > 0 ? [['archived', counts.archived]] : []),
|
|
3079
|
+
...(counts.inbox > 0 ? [['inbox', counts.inbox]] : []),
|
|
3019
3080
|
].map(([key, count]) => filterChip(key, key === 'all' ? undefined : count)),
|
|
3020
3081
|
),
|
|
3021
3082
|
h('span', { className: 'gt-divider' }),
|
|
@@ -3258,6 +3319,32 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
3258
3319
|
},
|
|
3259
3320
|
t('select.archive'),
|
|
3260
3321
|
),
|
|
3322
|
+
// In the inbox view the useful batch actions are confirm and discard, not
|
|
3323
|
+
// complete: nothing here is done yet.
|
|
3324
|
+
filter === 'inbox'
|
|
3325
|
+
? h(
|
|
3326
|
+
'button',
|
|
3327
|
+
{
|
|
3328
|
+
type: 'button',
|
|
3329
|
+
className: 'gt-btn gt-btn-quiet',
|
|
3330
|
+
disabled: Object.keys(selected).length === 0,
|
|
3331
|
+
onClick: () => void runBulk({ removeTags: ['inbox'] }),
|
|
3332
|
+
},
|
|
3333
|
+
t('action.confirm'),
|
|
3334
|
+
)
|
|
3335
|
+
: null,
|
|
3336
|
+
filter === 'inbox'
|
|
3337
|
+
? h(
|
|
3338
|
+
'button',
|
|
3339
|
+
{
|
|
3340
|
+
type: 'button',
|
|
3341
|
+
className: 'gt-btn gt-btn-quiet',
|
|
3342
|
+
disabled: Object.keys(selected).length === 0,
|
|
3343
|
+
onClick: () => void runBulk({ status: 'archived', removeTags: ['inbox'] }),
|
|
3344
|
+
},
|
|
3345
|
+
t('action.discard'),
|
|
3346
|
+
)
|
|
3347
|
+
: null,
|
|
3261
3348
|
h('input', {
|
|
3262
3349
|
className: 'gt-input gt-bulk-tag',
|
|
3263
3350
|
value: bulkTag,
|
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.1'
|
|
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.1",
|
|
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",
|