dsh-global-todo 0.4.1 → 0.4.3
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 +22 -0
- package/README.md +6 -0
- package/docs/DESIGN.md +17 -0
- package/docs/ROADMAP.md +8 -0
- package/docs/screenshots/panel-detail.png +0 -0
- package/docs/screenshots/panel-select.png +0 -0
- package/docs/screenshots/panel.png +0 -0
- package/lib/capture.js +104 -0
- package/lib/client.js +15 -3
- package/lib/index.js +1 -1
- package/lib/store.js +62 -3
- package/lib/tools.js +14 -10
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.4.3] — 2026-10-19
|
|
11
|
+
|
|
12
|
+
The list is meant to live in a synced folder; a change from another machine should not be
|
|
13
|
+
something you find out about a day later.
|
|
14
|
+
|
|
15
|
+
- **A file changed elsewhere is counted and reported.** The store keeps an `mtimeMs:size`
|
|
16
|
+
fingerprint of the file as this process last read or wrote it; a read that disagrees with it
|
|
17
|
+
increments `externalChanges` (once per change, not once per poll) and records
|
|
18
|
+
`lastExternalAt`, both surfaced through `/health.store`. The panel turns a new count into a
|
|
19
|
+
one-line notice. Reads still trust a file that parses — this process cannot merge two writers'
|
|
20
|
+
intent — but the person can see that it happened.
|
|
21
|
+
## [0.4.2] — 2026-10-19
|
|
22
|
+
|
|
23
|
+
Noise is the failure mode that matters for a list other people are asked to trust.
|
|
24
|
+
|
|
25
|
+
- **Capture decides with a gate, not a title comparison.** The old check compared normalized
|
|
26
|
+
titles exactly against active rows, which missed the ordinary case — same errand, different
|
|
27
|
+
wording — and happily re-admitted something finished yesterday. `lib/capture.js` now refuses an
|
|
28
|
+
item when its normalized title matches a live row, when a close paraphrase (Dice over character
|
|
29
|
+
bigrams, 0.8) appears within 72 hours, or when the same errand was finished in the last 7 days.
|
|
30
|
+
Archived and cancelled rows are set-aside history and do not block a new one; every refusal
|
|
31
|
+
carries its reason in `skipped[]`.
|
|
10
32
|
## [0.4.1] — 2026-10-19
|
|
11
33
|
|
|
12
34
|
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
|
@@ -57,6 +57,16 @@ $DSH_HOME/global-todo/todos.json ← { version: 1, todos: [ … ] }
|
|
|
57
57
|
version, and a future migration step would run before the first write, keeping the old
|
|
58
58
|
file as a backup.
|
|
59
59
|
|
|
60
|
+
## A shared file changes without asking
|
|
61
|
+
|
|
62
|
+
The list is deliberately portable — a notes repo, a synced folder, a NAS — so another machine
|
|
63
|
+
writing the same file is a normal event. It must not be a silent one either: the store keeps a
|
|
64
|
+
`mtimeMs:size` fingerprint of the file as it last read or wrote it, and a read that disagrees
|
|
65
|
+
means somebody else changed it. That is counted once per change (not once per poll, which would
|
|
66
|
+
be noise) and exported through `describe()` as `externalChanges` / `lastExternalAt`; the panel
|
|
67
|
+
turns a new count into a one-line notice. `store.read` still trusts a file that parses, because
|
|
68
|
+
**this process** has no way to merge two writers' intent — but the person can now see that it
|
|
69
|
+
happened instead of discovering it a day later.
|
|
60
70
|
## Attachments: references, not uploads
|
|
61
71
|
|
|
62
72
|
An attachment is either an address (`http`/`https`) or an absolute path. Rules:
|
|
@@ -157,6 +167,13 @@ rest of the batch — a partial success is reported as a partial success, never
|
|
|
157
167
|
The ceiling: **capture ends at the list.** Nothing the plugin does starts work by itself;
|
|
158
168
|
"hand it to an agent" is always a human click.
|
|
159
169
|
|
|
170
|
+
|
|
171
|
+
The gate has to be narrower than "remember things": a capture run that re-registers what the list
|
|
172
|
+
already holds is how a trusted list becomes noise. So an item is refused, with the reason on the
|
|
173
|
+
record, when its normalized title matches a live row, when a close paraphrase (Dice over character
|
|
174
|
+
bigrams, 0.8) appears in the last 72 hours, or when the same errand was finished within 7 days.
|
|
175
|
+
Archived and cancelled rows are history a person set aside and must not block a new one; the rules
|
|
176
|
+
are pure and live in `lib/capture.js` so they can be tested without a store.
|
|
160
177
|
## A fallback must never quietly outrank the real rule
|
|
161
178
|
|
|
162
179
|
The routes are fenced: the Host's connection service decides (`isTrustedApiRequest` against
|
package/docs/ROADMAP.md
CHANGED
|
@@ -102,6 +102,14 @@ 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.
|
|
109
|
+
**A change made elsewhere is visible** — the store fingerprints the file on every read/write and
|
|
110
|
+
counts a disagreement once (`externalChanges`, `lastExternalAt` in `lib/store.js`; surfaced by
|
|
111
|
+
`/health.store`). The panel shows a one-line notice instead of letting a synced folder change the
|
|
112
|
+
list silently.
|
|
105
113
|
### Partly landed
|
|
106
114
|
|
|
107
115
|
- **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/client.js
CHANGED
|
@@ -50,7 +50,7 @@ window.__ModuleLoader__.load({
|
|
|
50
50
|
* different mechanisms (restart vs. hot reload), so "which one am I looking
|
|
51
51
|
* at" needs an answer that the bundle itself carries.
|
|
52
52
|
*/
|
|
53
|
-
const UI_REV = '
|
|
53
|
+
const UI_REV = 'ui26'
|
|
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
|
|
|
@@ -134,6 +134,7 @@ window.__ModuleLoader__.load({
|
|
|
134
134
|
'autoCollect.reason.turn': '轮次没有干净结束({kind})',
|
|
135
135
|
'notice.autoCollectOn': '已开启自动获取:一次人为发言最多自动问一次',
|
|
136
136
|
'notice.autoCollectOff': '已关闭自动获取',
|
|
137
|
+
'notice.externalChange': '清单在别处被改过,已载入最新内容',
|
|
137
138
|
'action.exempt': '豁免',
|
|
138
139
|
'action.exemptOn': '已豁免',
|
|
139
140
|
'action.exemptHint': '这个工作区里的对话永不自动获取(再点一下取消)',
|
|
@@ -344,6 +345,7 @@ window.__ModuleLoader__.load({
|
|
|
344
345
|
'autoCollect.reason.turn': 'the turn did not end cleanly ({kind})',
|
|
345
346
|
'notice.autoCollectOn': 'Auto-collect on: at most one question per message from you',
|
|
346
347
|
'notice.autoCollectOff': 'Auto-collect off',
|
|
348
|
+
'notice.externalChange': 'The list changed somewhere else; the latest content is loaded',
|
|
347
349
|
'action.exempt': 'Exempt',
|
|
348
350
|
'action.exemptOn': 'Exempted',
|
|
349
351
|
'action.exemptHint': 'Never auto-collect in this workspace (click again to undo)',
|
|
@@ -2125,6 +2127,9 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
2125
2127
|
: []
|
|
2126
2128
|
const conversations = [...pinnedEntry, ...labelled]
|
|
2127
2129
|
const mounted = useRef(true)
|
|
2130
|
+
// The store counts file changes made outside this process (another machine, a sync
|
|
2131
|
+
// client). A new count means the panel's view was stale a moment ago; say so once.
|
|
2132
|
+
const externalRef = useRef(null)
|
|
2128
2133
|
const titleRef = useRef(null)
|
|
2129
2134
|
const searchRef = useRef(null)
|
|
2130
2135
|
const undoTimer = useRef(null)
|
|
@@ -2165,11 +2170,18 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
2165
2170
|
const refreshHealth = useCallback(async () => {
|
|
2166
2171
|
try {
|
|
2167
2172
|
const data = await call('health')
|
|
2168
|
-
if (mounted.current)
|
|
2173
|
+
if (!mounted.current) return
|
|
2174
|
+
const external = Number(data?.store?.externalChanges) || 0
|
|
2175
|
+
if (externalRef.current !== null && external > externalRef.current) {
|
|
2176
|
+
setNotice({ text: translate(lang, 'notice.externalChange'), sessionId: '' })
|
|
2177
|
+
setTimeout(() => setNotice(null), 8000)
|
|
2178
|
+
}
|
|
2179
|
+
externalRef.current = external
|
|
2180
|
+
setState((current) => ({ ...current, health: data }))
|
|
2169
2181
|
} catch {
|
|
2170
2182
|
/* the health mirror is not worth an error banner */
|
|
2171
2183
|
}
|
|
2172
|
-
}, [])
|
|
2184
|
+
}, [lang])
|
|
2173
2185
|
|
|
2174
2186
|
useEffect(() => {
|
|
2175
2187
|
mounted.current = true
|
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.
|
|
38
|
+
export const VERSION = '0.4.3'
|
|
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/store.js
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
* @module dsh-global-todo/store
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
|
-
import { open, mkdir, readFile, rename, writeFile } from 'node:fs/promises'
|
|
22
|
+
import { open, mkdir, readFile, rename, stat, writeFile } from 'node:fs/promises'
|
|
23
23
|
import { homedir } from 'node:os'
|
|
24
24
|
import path from 'node:path'
|
|
25
25
|
|
|
@@ -79,6 +79,46 @@ export function createStore(options) {
|
|
|
79
79
|
/** Serializes every mutation; also lets `update` await the writes before it. */
|
|
80
80
|
let chain = Promise.resolve()
|
|
81
81
|
|
|
82
|
+
/**
|
|
83
|
+
* Fingerprint of the file as this process last read or wrote it.
|
|
84
|
+
*
|
|
85
|
+
* The file is meant to be portable — a synced folder, a NAS — so another machine changing it
|
|
86
|
+
* is a normal event, not an error. It must not be silent either: a read that sees a different
|
|
87
|
+
* `mtimeMs:size` than the last one this process established is reported through `describe()`
|
|
88
|
+
* so the panel can say the list changed elsewhere. One increment per external change, not per
|
|
89
|
+
* poll, which is why `known` is updated as the change is noticed.
|
|
90
|
+
*/
|
|
91
|
+
let known = null
|
|
92
|
+
let externalChanges = 0
|
|
93
|
+
let lastExternalAt = null
|
|
94
|
+
|
|
95
|
+
/** @param {{ mtimeMs: number, size: number } | null} info */
|
|
96
|
+
function remember(info) {
|
|
97
|
+
if (info === null) {
|
|
98
|
+
known = null
|
|
99
|
+
return
|
|
100
|
+
}
|
|
101
|
+
known = `${info.mtimeMs}:${info.size}`
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Note a read that disagrees with what this process last saw. */
|
|
105
|
+
function observe(info) {
|
|
106
|
+
if (info === null) {
|
|
107
|
+
if (known !== null) {
|
|
108
|
+
externalChanges += 1
|
|
109
|
+
lastExternalAt = now()
|
|
110
|
+
}
|
|
111
|
+
known = null
|
|
112
|
+
return
|
|
113
|
+
}
|
|
114
|
+
const fingerprint = `${info.mtimeMs}:${info.size}`
|
|
115
|
+
if (known !== null && known !== fingerprint) {
|
|
116
|
+
externalChanges += 1
|
|
117
|
+
lastExternalAt = now()
|
|
118
|
+
}
|
|
119
|
+
known = fingerprint
|
|
120
|
+
}
|
|
121
|
+
|
|
82
122
|
/**
|
|
83
123
|
* Read the document. A missing file is an empty list; an unparseable file is
|
|
84
124
|
* an error, never a silent reset — overwriting a mangled-but-recoverable file
|
|
@@ -91,9 +131,15 @@ export function createStore(options) {
|
|
|
91
131
|
try {
|
|
92
132
|
text = await readFile(file, 'utf8')
|
|
93
133
|
} catch (error) {
|
|
94
|
-
if (error?.code === 'ENOENT')
|
|
134
|
+
if (error?.code === 'ENOENT') {
|
|
135
|
+
// The file this process knew about is gone: somebody else removed or moved it.
|
|
136
|
+
observe(null)
|
|
137
|
+
return { version: SCHEMA_VERSION, todos: [] }
|
|
138
|
+
}
|
|
95
139
|
throw new TodoError('store-unwritable', `读不到待办文件 ${file}:${error?.message ?? error}`)
|
|
96
140
|
}
|
|
141
|
+
// Compare before parsing: a corrupt write from elsewhere is still a change worth naming.
|
|
142
|
+
observe(await stat(file).catch(() => null))
|
|
97
143
|
if (text.trim() === '') return { version: SCHEMA_VERSION, todos: [] }
|
|
98
144
|
|
|
99
145
|
let parsed
|
|
@@ -147,6 +193,8 @@ export function createStore(options) {
|
|
|
147
193
|
} catch {
|
|
148
194
|
/* ignore */
|
|
149
195
|
}
|
|
196
|
+
// This write is the new baseline, so the next read does not report our own change.
|
|
197
|
+
remember(await stat(file).catch(() => null))
|
|
150
198
|
}
|
|
151
199
|
|
|
152
200
|
/**
|
|
@@ -206,7 +254,18 @@ export function createStore(options) {
|
|
|
206
254
|
logger?.warn?.(`dsh-global-todo: 无法统计 ${file}:${error?.message ?? error}`)
|
|
207
255
|
}
|
|
208
256
|
}
|
|
209
|
-
return {
|
|
257
|
+
return {
|
|
258
|
+
kind: 'json',
|
|
259
|
+
file,
|
|
260
|
+
exists,
|
|
261
|
+
bytes,
|
|
262
|
+
todos,
|
|
263
|
+
schemaVersion: SCHEMA_VERSION,
|
|
264
|
+
// How many times the file changed under this process (another machine, a sync client).
|
|
265
|
+
// In memory: it describes this process, and a restart is entitled to forget it.
|
|
266
|
+
externalChanges,
|
|
267
|
+
lastExternalAt,
|
|
268
|
+
}
|
|
210
269
|
}
|
|
211
270
|
|
|
212
271
|
return { kind: 'json', file, read, write, update, all, describe }
|
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
|
-
//
|
|
388
|
-
//
|
|
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: '
|
|
393
|
+
existing = (await service.list({ status: 'all', limit: 1000 })).todos
|
|
392
394
|
} catch {
|
|
393
395
|
existing = []
|
|
394
396
|
}
|
|
395
|
-
const
|
|
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
|
|
400
|
-
if (
|
|
401
|
-
skipped.push({
|
|
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.
|
|
3
|
+
"version": "0.4.3",
|
|
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",
|