dsh-global-todo 0.12.11 → 0.12.13

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
@@ -1,3 +1,32 @@
1
+ ## [0.12.13] — 2026-10-21
2
+
3
+ The dedupe promise now holds for short titles (#4).
4
+
5
+ - **An appended addendum counts as the same errand, at any title length.** `similar-recently-done`
6
+ relied on symmetric Dice, which mathematically cannot reach 0.8 once 「(再办一次)」 is appended
7
+ to a short title: 「X」 scored 0.00 and 「T3 逾期项」 0.67, both accepted, while the padded test
8
+ title passed. Titles are now compared with their trailing brackets stripped, and rejected when
9
+ the rest is identical and one raw title is a prefix of the other.
10
+ - **A *replaced* parenthetical is still a different errand.** 「修 A(后端)」 vs 「修 A(前端)」 strips
11
+ to the same text but replaces a word instead of appending one, so the prefix test refuses it.
12
+ - **The tests use the examples the comments and release notes promised** — `X`, `T3 逾期项`,
13
+ `写周报`, `配置小米 NAS 证书`, `A` — instead of only a padded title that happened to pass.
14
+
15
+ ## [0.12.12] — 2026-10-21
16
+
17
+ Three more silent coercions, found by reading the settings path instead of the tests.
18
+
19
+ - **A Windows workspace exemption only covered its exact path.** `isExemptWorkspace` tested
20
+ `${root}/`, so `C:\dev` matched itself but not `C:\dev\sub` — an exemption that looks
21
+ configured and silently asks conversations it should skip. Separators are now normalized and
22
+ the check is case-insensitive on Windows only.
23
+ - **`POST /settings` no longer coerces the wrong type.** The settings store is written for a
24
+ hand-edited file, where `autoCollect: "true"` becoming `false` and an unknown field being
25
+ ignored are reasonable; over HTTP they are typos that look successful. Booleans, arrays, the
26
+ token and the ingest sources are now type-checked, and an unknown key is `400` naming it.
27
+ - **A fractional minute is refused** (`{idleMinutes: 30.5}` used to fall back to the default with
28
+ a 200). Same rule as every other out-of-range value.
29
+
1
30
  ## [0.12.11] — 2026-10-21
2
31
 
3
32
  One more silent fallback, found while re-reading the settings path.
package/lib/capture.js CHANGED
@@ -30,6 +30,42 @@ export function normalizeTitle(title) {
30
30
  return String(title ?? '').normalize('NFKC').toLowerCase().replace(/[\s\p{P}\p{S}]+/gu, '')
31
31
  }
32
32
 
33
+
34
+ /**
35
+ * A trailing parenthetical/【…】 addendum, e.g. 「(再办一次)」.
36
+ *
37
+ * Titles like that are the common way a person says "the same errand again", and symmetric
38
+ * Dice cannot see them on short titles: the addendum inflates the denominator, so 「X」 vs
39
+ * 「X(再办一次)」 scores 0 no matter what the threshold is.
40
+ *
41
+ * @param {unknown} title
42
+ * @returns {string}
43
+ */
44
+ export function stripAddenda(title) {
45
+ const text = String(title ?? '')
46
+ return text.replace(/(\s*[((【\[][^))】\]]*[))】\]]\s*)+$/u, '')
47
+ }
48
+
49
+ /**
50
+ * Is `candidate` the same errand as `existing`, with only an addendum appended?
51
+ *
52
+ * The stripped titles must be identical, and one raw title must be a prefix of the other:
53
+ * that rules out 「修 A(后端)」 vs 「修 A(前端)」, which strip to the same text but replace
54
+ * a word instead of appending one.
55
+ *
56
+ * @param {unknown} candidate
57
+ * @param {unknown} existing
58
+ * @returns {boolean}
59
+ */
60
+ function isAppendedVariant(candidate, existing) {
61
+ const a = normalizeTitle(candidate)
62
+ const b = normalizeTitle(existing)
63
+ if (a === '' || b === '') return false
64
+ const sa = normalizeTitle(stripAddenda(candidate))
65
+ const sb = normalizeTitle(stripAddenda(existing))
66
+ if (sa === '' || sb === '' || sa !== sb) return false
67
+ return a === b || a.startsWith(b) || b.startsWith(a)
68
+ }
33
69
  /** @param {string} text @returns {Set<string>} */
34
70
  function bigrams(text) {
35
71
  const chars = [...text]
@@ -91,10 +127,12 @@ export function findDuplicate(input) {
91
127
  if (now - created < windowMs && similarity(title, todo.title) >= threshold) {
92
128
  return { duplicate: true, reason: 'similar-active', matchId: todo.id }
93
129
  }
94
- } else if (now - doneAt < cooldownMs && similarity(title, todo.title) >= threshold) {
95
- // The cooldown was exact-title only, so 「T3 逾期项」 finished two minutes ago and
96
- // 「T3 逾期项(再办一次)」 was accepted — the same errand, re-registered. Similar
97
- // wording counts here for the same reason it counts among active rows.
130
+ } else if (
131
+ now - doneAt < cooldownMs &&
132
+ // Symmetric Dice alone only catches long titles: 「X」 vs 「X(再办一次)」 scores 0.
133
+ // An appended addendum is its own signal, so it is checked before the score.
134
+ (isAppendedVariant(title, todo.title) || similarity(title, todo.title) >= threshold)
135
+ ) {
98
136
  return { duplicate: true, reason: 'similar-recently-done', matchId: todo.id }
99
137
  }
100
138
  }
package/lib/index.js CHANGED
@@ -37,7 +37,7 @@ import { createStore, resolveStorePath } from './store.js'
37
37
  import { createTools } from './tools.js'
38
38
 
39
39
  /** Must equal `package.json`'s version; `npm run check` enforces that. */
40
- export const VERSION = '0.12.11'
40
+ export const VERSION = '0.12.13'
41
41
 
42
42
  /** Plugin id. Also the Loader row id in `cordis.patch.yml`. */
43
43
  export const name = 'dsh-global-todo'
package/lib/routes.js CHANGED
@@ -28,6 +28,7 @@ import path from 'node:path'
28
28
  import { TodoError, asTodoError } from './errors.js'
29
29
  import { MAX_INGEST_ITEMS } from './ingest.js'
30
30
  import { fromMarkdown, toMarkdown } from './markdown.js'
31
+ import { INGEST_SOURCES } from './settings.js'
31
32
  import { weeklyReport } from './report.js'
32
33
 
33
34
  /** Route prefix. The client appends this to the document base. */
@@ -535,6 +536,7 @@ export function createRoutes(deps) {
535
536
  // The settings store clamps silently, which is right for a hand-edited file and wrong
536
537
  // for an API: `{idleMinutes: -5}` answering 200-but-5 hides the rejected intent. A
537
538
  // provided value outside its range is reported instead.
539
+ assertSettingsShape(body ?? {})
538
540
  assertSettingsRange(body ?? {})
539
541
  const next = await deps.settings.update(body ?? {})
540
542
  return {
@@ -735,6 +737,47 @@ export function createRoutes(deps) {
735
737
  ]
736
738
  }
737
739
 
740
+ /**
741
+ * Types the settings route insists on, because the store coerces instead of rejecting.
742
+ *
743
+ * \`normalizeSettings\` is written for a hand-edited file: \`autoCollect: "true"\` becomes
744
+ * false, an unknown source is dropped, a typo silently changes nothing. That is the right
745
+ * behaviour when reading a file and the wrong behaviour for a request.
746
+ *
747
+ * @param {Record<string, unknown>} body
748
+ */
749
+ function assertSettingsShape(body) {
750
+ const known = ['autoCollect', 'idleMinutes', 'cooldownMinutes', 'exemptWorkspaces', 'ingestEnabled', 'ingestSources', 'ingestConfirmBelow', 'ingestToken', 'remindersEnabled', 'reminderLeadMinutes']
751
+ for (const key of Object.keys(body)) {
752
+ if (!known.includes(key)) {
753
+ throw new TodoError('invalid-body', '未知的设置项 ' + key + ';可写的是 ' + known.join(' / '))
754
+ }
755
+ }
756
+ for (const field of ['autoCollect', 'ingestEnabled', 'remindersEnabled']) {
757
+ const value = body[field]
758
+ if (value !== undefined && typeof value !== 'boolean') {
759
+ throw new TodoError('invalid-body', field + ' 必须是 true / false,收到 ' + JSON.stringify(value))
760
+ }
761
+ }
762
+ if (body.ingestToken !== undefined && typeof body.ingestToken !== 'string') {
763
+ throw new TodoError('invalid-body', 'ingestToken 必须是字符串')
764
+ }
765
+ if (body.exemptWorkspaces !== undefined) {
766
+ if (!Array.isArray(body.exemptWorkspaces) || body.exemptWorkspaces.some((entry) => typeof entry !== 'string')) {
767
+ throw new TodoError('invalid-body', 'exemptWorkspaces 必须是字符串数组')
768
+ }
769
+ }
770
+ if (body.ingestSources !== undefined) {
771
+ if (!Array.isArray(body.ingestSources)) {
772
+ throw new TodoError('invalid-body', 'ingestSources 必须是数组')
773
+ }
774
+ const bad = body.ingestSources.filter((entry) => typeof entry !== 'string' || !INGEST_SOURCES.includes(entry))
775
+ if (bad.length > 0) {
776
+ throw new TodoError('invalid-body', 'ingestSources 只能是 ' + INGEST_SOURCES.join(' / ') + ',收到 ' + bad.join(', '))
777
+ }
778
+ }
779
+ }
780
+
738
781
  /**
739
782
  * Ranges the settings route refuses, mirroring the clamps in `normalizeSettings`.
740
783
  *
package/lib/settings.js CHANGED
@@ -145,12 +145,31 @@ export function normalizeSettings(raw) {
145
145
  * @param {string[]} exemptWorkspaces
146
146
  * @returns {boolean}
147
147
  */
148
+ /**
149
+ * One separator for comparisons, whatever the platform wrote.
150
+ *
151
+ * @param {string} value
152
+ * @returns {string}
153
+ */
154
+ function normalizeSeparators(value) {
155
+ return value.replace(/\\/g, '/')
156
+ }
148
157
  export function isExemptWorkspace(sessionDir, exemptWorkspaces) {
149
158
  if (typeof sessionDir !== 'string' || sessionDir === '') return false
150
- const dir = sessionDir.replace(/\/+$/, '')
151
- return (Array.isArray(exemptWorkspaces) ? exemptWorkspaces : []).some(
152
- (root) => typeof root === 'string' && root !== '' && (dir === root || dir.startsWith(`${root}/`)),
153
- )
159
+ // Compare on one separator: a Windows exemption of `C:\\dev` has to cover `C:\\dev\\sub`,
160
+ // and the old `${root}/` test made that false there while the exact match still worked —
161
+ // a half-working exemption is worse than none, because it looks configured.
162
+ const dir = normalizeSeparators(sessionDir).replace(/\/+$/, '')
163
+ const inWindows = process.platform === 'win32'
164
+ return (Array.isArray(exemptWorkspaces) ? exemptWorkspaces : []).some((root) => {
165
+ if (typeof root !== 'string' || root === '') return false
166
+ const base = normalizeSeparators(root).replace(/\/+$/, '')
167
+ const same = inWindows ? dir.toLowerCase() === base.toLowerCase() : dir === base
168
+ const inside = inWindows
169
+ ? dir.toLowerCase().startsWith(`${base.toLowerCase()}/`)
170
+ : dir.startsWith(`${base}/`)
171
+ return same || inside
172
+ })
154
173
  }
155
174
 
156
175
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-global-todo",
3
- "version": "0.12.11",
3
+ "version": "0.12.13",
4
4
  "icon": "./docs/icon.svg",
5
5
  "description": "Cross-session todo list for DeepSeek Harness: one JSON file a person and an agent both write to, with step plans that ship as a numbered hand-off, session read-back, and a human confirmation before an agent's \"done\" counts.",
6
6
  "type": "module",