dsh-global-todo 0.4.1 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.4.2] — 2026-10-19
11
+
12
+ Noise is the failure mode that matters for a list other people are asked to trust.
13
+
14
+ - **Capture decides with a gate, not a title comparison.** The old check compared normalized
15
+ titles exactly against active rows, which missed the ordinary case — same errand, different
16
+ wording — and happily re-admitted something finished yesterday. `lib/capture.js` now refuses an
17
+ item when its normalized title matches a live row, when a close paraphrase (Dice over character
18
+ bigrams, 0.8) appears within 72 hours, or when the same errand was finished in the last 7 days.
19
+ Archived and cancelled rows are set-aside history and do not block a new one; every refusal
20
+ carries its reason in `skipped[]`.
10
21
  ## [0.4.1] — 2026-10-19
11
22
 
12
23
  The half-finished half: the selection could only be assigned, and the only way to get a record out
package/README.md CHANGED
@@ -227,6 +227,12 @@ produces noise, and a noisy list is worse than an empty one:
227
227
  The boundary: it **records, it never starts work**. The list belongs to the human, and
228
228
  starting a task stays a human decision (see the non-goals in [ROADMAP](docs/ROADMAP.md)).
229
229
 
230
+
231
+ Capture is not allowed to become noise. Before an item is written, the same normalized title
232
+ (full-width, case, spacing and punctuation folded away), a close paraphrase within the last 72
233
+ hours (Dice similarity over character bigrams, 0.8), and the same errand finished in the last 7
234
+ days are all refused with a reason on the record; archived and cancelled rows do not block a
235
+ new one. The rules live in `lib/capture.js` and are tested on their own.
230
236
  ### HTTP API
231
237
 
232
238
  The panel talks to the host over these routes. They are fenced by the host connection
package/docs/DESIGN.md CHANGED
@@ -157,6 +157,13 @@ rest of the batch — a partial success is reported as a partial success, never
157
157
  The ceiling: **capture ends at the list.** Nothing the plugin does starts work by itself;
158
158
  "hand it to an agent" is always a human click.
159
159
 
160
+
161
+ The gate has to be narrower than "remember things": a capture run that re-registers what the list
162
+ already holds is how a trusted list becomes noise. So an item is refused, with the reason on the
163
+ record, when its normalized title matches a live row, when a close paraphrase (Dice over character
164
+ bigrams, 0.8) appears in the last 72 hours, or when the same errand was finished within 7 days.
165
+ Archived and cancelled rows are history a person set aside and must not block a new one; the rules
166
+ are pure and live in `lib/capture.js` so they can be tested without a store.
160
167
  ## A fallback must never quietly outrank the real rule
161
168
 
162
169
  The routes are fenced: the Host's connection service decides (`isTrustedApiRequest` against
package/docs/ROADMAP.md CHANGED
@@ -102,6 +102,10 @@ selection in one request; a stale id is reported in `unmatched` instead of disca
102
102
  (`bulk()` in `lib/service.js`, `runBulk` in `lib/client.js`). `archived` joined the statuses, so a
103
103
  record can leave the active view without being deleted; the filter chip only appears once something
104
104
  is archived, and `global_todo_update` finally accepts `status`.
105
+ **A capture gate, not a title comparison** — before `global_todo_capture` writes an item it asks
106
+ `findDuplicate` (`lib/capture.js`): normalized title against live rows, a close paraphrase inside
107
+ 72 hours, and the same errand finished in the last 7 days. The decision is pure, the reason lands
108
+ in `skipped[]`, and archived/cancelled rows deliberately do not block a new one.
105
109
  ### Partly landed
106
110
 
107
111
  - **Batch and multi-select** — the selection mode, its per-row markers and the batch
Binary file
Binary file
Binary file
package/lib/capture.js ADDED
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Capture quality gate.
3
+ *
4
+ * `global_todo_capture` is the one path that can add several records at once, and a capture run
5
+ * that keeps re-registering what the list already has is how the list turns into noise. The
6
+ * first version compared normalised titles exactly against the active rows; that misses the
7
+ * common case — same errand, different wording — and it re-admits something that was finished
8
+ * yesterday. Both are pure decisions, so they live here and are tested without a store.
9
+ *
10
+ * @module dsh-global-todo/capture
11
+ */
12
+
13
+ /** Titles closer than this are treated as the same errand. */
14
+ const SIMILARITY = 0.8
15
+ /** Only compare against rows from the last few days; older ones are history, not duplicates. */
16
+ const DEDUPE_WINDOW_HOURS = 72
17
+ /** A finished errand stays done this long before the same title may come back. */
18
+ const DONE_COOLDOWN_DAYS = 7
19
+
20
+ /**
21
+ * Fold a title to what it means: width, case, spacing and punctuation are noise.
22
+ *
23
+ * Numbers and dates are kept on purpose — "10 月 20 日交" and "10 月 21 日交" are different
24
+ * errands even though they differ by one character.
25
+ *
26
+ * @param {unknown} title
27
+ * @returns {string}
28
+ */
29
+ export function normalizeTitle(title) {
30
+ return String(title ?? '').normalize('NFKC').toLowerCase().replace(/[\s\p{P}\p{S}]+/gu, '')
31
+ }
32
+
33
+ /** @param {string} text @returns {Set<string>} */
34
+ function bigrams(text) {
35
+ const chars = [...text]
36
+ if (chars.length < 2) return new Set(chars)
37
+ const out = new Set()
38
+ for (let index = 0; index < chars.length - 1; index += 1) out.add(chars[index] + chars[index + 1])
39
+ return out
40
+ }
41
+
42
+ /**
43
+ * Dice coefficient over character bigrams: 0 when unrelated, 1 when identical.
44
+ *
45
+ * @param {unknown} left
46
+ * @param {unknown} right
47
+ * @returns {number}
48
+ */
49
+ export function similarity(left, right) {
50
+ const a = bigrams(normalizeTitle(left))
51
+ const b = bigrams(normalizeTitle(right))
52
+ if (a.size === 0 || b.size === 0) return 0
53
+ let shared = 0
54
+ for (const gram of a) if (b.has(gram)) shared += 1
55
+ return (2 * shared) / (a.size + b.size)
56
+ }
57
+
58
+ /**
59
+ * Is this title already on the list, in a way a person would call a duplicate?
60
+ *
61
+ * @param {{ title?: unknown, existing?: object[], now?: number, similarity?: number, windowHours?: number, doneCooldownDays?: number }} input
62
+ * @returns {{ duplicate: boolean, reason?: string, matchId?: string }}
63
+ */
64
+ export function findDuplicate(input) {
65
+ const title = input.title
66
+ const key = normalizeTitle(title)
67
+ if (key === '') return { duplicate: false }
68
+ const now = Number.isFinite(input.now) ? input.now : Date.now()
69
+ const windowMs = (input.windowHours ?? DEDUPE_WINDOW_HOURS) * 60 * 60 * 1000
70
+ const cooldownMs = (input.doneCooldownDays ?? DONE_COOLDOWN_DAYS) * 24 * 60 * 60 * 1000
71
+ const threshold = Number.isFinite(input.similarity) ? input.similarity : SIMILARITY
72
+ for (const todo of Array.isArray(input.existing) ? input.existing : []) {
73
+ if (todo === null || typeof todo !== 'object' || typeof todo.title !== 'string') continue
74
+ // Archived is history a person chose to keep out of the way; it must not block the same
75
+ // errand from being written again. Cancelled is likewise not a live duplicate.
76
+ if (todo.status === 'cancelled' || todo.status === 'archived') continue
77
+ const doneAt = Number.isFinite(todo.doneAt)
78
+ ? todo.doneAt
79
+ : Number.isFinite(todo.updatedAt) ? todo.updatedAt : 0
80
+ if (normalizeTitle(todo.title) === key) {
81
+ if (todo.status === 'done') {
82
+ if (now - doneAt < cooldownMs) return { duplicate: true, reason: 'recently-done', matchId: todo.id }
83
+ } else {
84
+ return { duplicate: true, reason: 'same-active', matchId: todo.id }
85
+ }
86
+ }
87
+ if (todo.status !== 'done') {
88
+ const created = Number.isFinite(todo.createdAt) ? todo.createdAt : now
89
+ // A five-day-old item is not evidence that today's wording is a duplicate; only the
90
+ // recent window is compared for similarity.
91
+ if (now - created < windowMs && similarity(title, todo.title) >= threshold) {
92
+ return { duplicate: true, reason: 'similar-active', matchId: todo.id }
93
+ }
94
+ }
95
+ }
96
+ return { duplicate: false }
97
+ }
98
+
99
+ /** The sentence a person reads in `skipped[].error`. */
100
+ export const CAPTURE_REASONS = Object.freeze({
101
+ 'same-active': '清单里已有同名的未完成条目,未重复登记',
102
+ 'similar-active': '清单里已有一条很相似的未完成条目,未重复登记',
103
+ 'recently-done': '这条刚完成不久(7 天内),没有重复登记',
104
+ })
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.1'
38
+ export const VERSION = '0.4.2'
39
39
 
40
40
  /** Plugin id. Also the Loader row id in `cordis.patch.yml`. */
41
41
  export const name = 'dsh-global-todo'
package/lib/tools.js CHANGED
@@ -13,6 +13,7 @@
13
13
  * @module dsh-global-todo/tools
14
14
  */
15
15
 
16
+ import { CAPTURE_REASONS, findDuplicate } from './capture.js'
16
17
  import { asTodoError } from './errors.js'
17
18
  import { formatLink } from './links.js'
18
19
  import { LIMITS, PRIORITIES, PRIORITY_LABELS, STATUSES } from './model.js'
@@ -384,27 +385,30 @@ export function createTools(service, options = {}) {
384
385
  const origin = originOf(exec)
385
386
  const todos = []
386
387
  const skipped = []
387
- // A capture run that keeps re-registering what is already on the list is how the
388
- // list fills with near-duplicates. One lookup, then skip by normalised title.
388
+ // The quality gate decides per item: exact title, a close paraphrase within the
389
+ // recent window, or the same errand finished days ago. One lookup of everything —
390
+ // done and archived rows matter too, which the active-only lookup used to miss.
389
391
  let existing = []
390
392
  try {
391
- existing = (await service.list({ status: 'active' })).todos
393
+ existing = (await service.list({ status: 'all', limit: 1000 })).todos
392
394
  } catch {
393
395
  existing = []
394
396
  }
395
- const keyOf = (title) =>
396
- String(title ?? '').trim().toLowerCase().replace(/\s+/g, '')
397
- const seen = new Set(existing.map((todo) => keyOf(todo.title)))
397
+ const nowMs = now()
398
398
  for (const item of items) {
399
- const itemKey = keyOf(item?.title)
400
- if (itemKey !== '' && seen.has(itemKey)) {
401
- skipped.push({ title: String(item?.title ?? '(无标题)'), error: '清单里已有同名的未完成条目,未重复登记' })
399
+ const verdict = findDuplicate({ title: item?.title, existing, now: nowMs })
400
+ if (verdict.duplicate === true) {
401
+ skipped.push({
402
+ title: String(item?.title ?? '(无标题)'),
403
+ error: CAPTURE_REASONS[verdict.reason] ?? '清单里已有重复条目,未重复登记',
404
+ })
402
405
  continue
403
406
  }
404
407
  const outcome = await attempt(() => service.create(item ?? {}, { by: 'agent', origin }))
405
408
  if (outcome.ok === true) {
406
- if (itemKey !== '') seen.add(itemKey)
407
409
  todos.push(presentTodo(outcome.value, now()))
410
+ // Later items in the same batch must see this one as existing.
411
+ existing.push(outcome.value)
408
412
  }
409
413
  else skipped.push({ title: String(item?.title ?? '(无标题)'), error: outcome.error })
410
414
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-global-todo",
3
- "version": "0.4.1",
3
+ "version": "0.4.2",
4
4
  "icon": "./docs/icon.svg",
5
5
  "description": "Global, cross-session todos for DeepSeek Harness: one list owned by the human and the agent together, with creation time, attached documents and links, and one-click hand-off to an agent session. 全局待办:跨会话一份清单,记录加入时间,可挂文档与地址,可一键交给 Agent 继续干。",
6
6
  "type": "module",