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 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 = 'ui30'
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('div', { className: 'gt-item-title' }, todo.title),
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 status = next.filter ?? filter
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', { query: { status, ...(search.trim() === '' ? {} : { query: search.trim() }) } })
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
@@ -27,6 +27,7 @@ export const ERROR_CODES = Object.freeze([
27
27
  'agents-unavailable',
28
28
  'dispatch-failed',
29
29
  'forbidden',
30
+ 'unauthorized',
30
31
  'internal',
31
32
  ])
32
33
 
package/lib/index.js CHANGED
@@ -35,7 +35,7 @@ import { createStore, resolveStorePath } from './store.js'
35
35
  import { createTools } from './tools.js'
36
36
 
37
37
  /** Must equal `package.json`'s version; `npm run check` enforces that. */
38
- export const VERSION = '0.4.7'
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.4.7",
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",