dsh-global-todo 0.4.6 → 0.5.0

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