dsh-global-todo 0.3.2 → 0.4.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 +84 -0
- package/CONTRIBUTING.md +14 -0
- package/README.md +25 -3
- package/README.zh.md +21 -3
- package/cordis.patch.yml +6 -0
- package/docs/DESIGN.md +30 -3
- package/docs/ROADMAP.md +50 -42
- package/docs/icon.svg +19 -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/client.js +134 -19
- package/lib/index.js +108 -24
- package/lib/routes.js +20 -0
- package/lib/service.js +16 -5
- package/lib/settings.js +91 -6
- package/lib/tools.js +13 -2
- package/package.json +5 -2
package/lib/service.js
CHANGED
|
@@ -38,7 +38,10 @@ export const FILTERS = Object.freeze(['all', 'active', 'open', 'doing', 'done',
|
|
|
38
38
|
/** Sort keys the panel and the tools may ask for. */
|
|
39
39
|
export const SORTS = Object.freeze(['created', 'updated', 'due', 'priority'])
|
|
40
40
|
|
|
41
|
-
|
|
41
|
+
// Every level the record can hold, or the comparator silently produces NaN for
|
|
42
|
+
// the missing one and the highest-priority items sort last. `urgent` was added
|
|
43
|
+
// to the model without this table, so `sort=priority` put P0 at the bottom.
|
|
44
|
+
const PRIORITY_RANK = { urgent: 0, high: 1, normal: 2, low: 3 }
|
|
42
45
|
|
|
43
46
|
/**
|
|
44
47
|
* Fold imported records into the current list: a known id wins, a new one appends.
|
|
@@ -111,7 +114,7 @@ export function createService(options) {
|
|
|
111
114
|
/**
|
|
112
115
|
* @param {object[]} todos
|
|
113
116
|
* @param {{ status?: string, query?: string, tag?: string, sort?: string, limit?: number }} [query]
|
|
114
|
-
* @returns {object[]}
|
|
117
|
+
* @returns {{ todos: object[], matched: number }} the page and how many matched before it
|
|
115
118
|
*/
|
|
116
119
|
/** Friendly level aliases, so search can be written the way the panel talks. */
|
|
117
120
|
const LEVEL_ALIAS = Object.freeze({ p0: 'urgent', p1: 'high', p2: 'normal', p3: 'low' })
|
|
@@ -230,17 +233,25 @@ export function createService(options) {
|
|
|
230
233
|
})
|
|
231
234
|
|
|
232
235
|
const limit = Number.isInteger(query.limit) && query.limit > 0 ? Math.min(query.limit, 1000) : 200
|
|
233
|
-
|
|
236
|
+
// matched is the count before the page is cut. Returning only the page made the
|
|
237
|
+
// panel show 200 for a list of 350 with no way to tell -- the caller now has
|
|
238
|
+
// enough to say 'showing 200 of 350' instead of quietly lying.
|
|
239
|
+
return { todos: out.slice(0, limit), matched: out.length }
|
|
234
240
|
}
|
|
235
241
|
|
|
236
242
|
/**
|
|
237
243
|
* @param {{ status?: string, query?: string, tag?: string, sort?: string, limit?: number }} [query]
|
|
238
|
-
* @returns {Promise<{ todos: object[], counts: object, matched: number }>}
|
|
244
|
+
* @returns {Promise<{ todos: object[], counts: object, matched: number, truncated: boolean }>}
|
|
239
245
|
*/
|
|
240
246
|
async function list(query = {}) {
|
|
241
247
|
const todos = await store.all()
|
|
242
248
|
const selected = select(todos, query)
|
|
243
|
-
return {
|
|
249
|
+
return {
|
|
250
|
+
todos: selected.todos,
|
|
251
|
+
counts: counts(todos, now()),
|
|
252
|
+
matched: selected.matched,
|
|
253
|
+
truncated: selected.matched > selected.todos.length,
|
|
254
|
+
}
|
|
244
255
|
}
|
|
245
256
|
|
|
246
257
|
/**
|
package/lib/settings.js
CHANGED
|
@@ -13,6 +13,21 @@ import { readFile, writeFile } from 'node:fs/promises'
|
|
|
13
13
|
/** How many sessions the cooldown bookkeeping remembers. */
|
|
14
14
|
const ASKED_LIMIT = 50
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* Backoff after a failed question, in order. A failed resume is often transient, so the
|
|
18
|
+
* first retry is a minute away; three failures mean the conversation is not reachable now.
|
|
19
|
+
*/
|
|
20
|
+
export const RETRY_DELAYS_MS = Object.freeze([60_000, 300_000, 1_500_000])
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @param {number} attempts - 1-based failure count
|
|
24
|
+
* @returns {number} milliseconds to wait before the next attempt
|
|
25
|
+
*/
|
|
26
|
+
export function retryDelayMs(attempts) {
|
|
27
|
+
const index = Math.min(Math.max(Number(attempts) || 1, 1), RETRY_DELAYS_MS.length) - 1
|
|
28
|
+
return RETRY_DELAYS_MS[index]
|
|
29
|
+
}
|
|
30
|
+
|
|
16
31
|
/** How many exempt workspaces are remembered. */
|
|
17
32
|
const EXEMPT_LIMIT = 50
|
|
18
33
|
|
|
@@ -100,6 +115,9 @@ export function isExemptWorkspace(sessionDir, exemptWorkspaces) {
|
|
|
100
115
|
*/
|
|
101
116
|
export function createSettings(options) {
|
|
102
117
|
let state = normalizeSettings(SETTINGS_DEFAULTS)
|
|
118
|
+
// How the file arrived. An off switch and a broken file look identical from the panel,
|
|
119
|
+
// and that ambiguity is exactly what made a silent failure to load invisible.
|
|
120
|
+
let loadInfo = { file: options.file ?? '', source: 'default', at: 0, error: null }
|
|
103
121
|
// The startup read is kicked off without being awaited (the plugin cannot block its own
|
|
104
122
|
// mount on a file), and it replaces the *whole* state. A write that lands while it is
|
|
105
123
|
// still in flight is therefore undone by it — the panel's toggle would flip back and the
|
|
@@ -118,10 +136,25 @@ export function createSettings(options) {
|
|
|
118
136
|
/** Read once at startup; a missing or broken file means the defaults. */
|
|
119
137
|
async load() {
|
|
120
138
|
startup = (async () => {
|
|
139
|
+
let text = null
|
|
121
140
|
try {
|
|
122
|
-
|
|
123
|
-
} catch {
|
|
141
|
+
text = await readFile(options.file, 'utf8')
|
|
142
|
+
} catch (error) {
|
|
124
143
|
state = normalizeSettings(SETTINGS_DEFAULTS)
|
|
144
|
+
loadInfo = {
|
|
145
|
+
file: options.file ?? '',
|
|
146
|
+
source: error?.code === 'ENOENT' ? 'missing' : 'unreadable',
|
|
147
|
+
at: Date.now(),
|
|
148
|
+
error: error?.code === 'ENOENT' ? null : String(error?.message ?? error),
|
|
149
|
+
}
|
|
150
|
+
return
|
|
151
|
+
}
|
|
152
|
+
try {
|
|
153
|
+
state = normalizeSettings(JSON.parse(text))
|
|
154
|
+
loadInfo = { file: options.file ?? '', source: 'file', at: Date.now(), error: null }
|
|
155
|
+
} catch (error) {
|
|
156
|
+
state = normalizeSettings(SETTINGS_DEFAULTS)
|
|
157
|
+
loadInfo = { file: options.file ?? '', source: 'corrupt', at: Date.now(), error: String(error?.message ?? error) }
|
|
125
158
|
}
|
|
126
159
|
})()
|
|
127
160
|
await startup
|
|
@@ -131,6 +164,10 @@ export function createSettings(options) {
|
|
|
131
164
|
get() {
|
|
132
165
|
return { ...state, asked: { ...state.asked } }
|
|
133
166
|
},
|
|
167
|
+
/** Where the settings came from and whether the file was readable, for /health. */
|
|
168
|
+
info() {
|
|
169
|
+
return { ...loadInfo }
|
|
170
|
+
},
|
|
134
171
|
/** A patch a route or a test can send; unknown fields are ignored. */
|
|
135
172
|
async update(patch) {
|
|
136
173
|
await startup
|
|
@@ -146,6 +183,22 @@ export function createSettings(options) {
|
|
|
146
183
|
await persist()
|
|
147
184
|
return this.get()
|
|
148
185
|
},
|
|
186
|
+
/**
|
|
187
|
+
* Undo a booking whose question never arrived.
|
|
188
|
+
*
|
|
189
|
+
* A failed ask used to leave the booking behind, and `awaiting-human` then kept the
|
|
190
|
+
* conversation silent until a person spoke again — one transient resume failure turned
|
|
191
|
+
* the feature off for that conversation.
|
|
192
|
+
*/
|
|
193
|
+
async forgetAsked(sessionId) {
|
|
194
|
+
await startup
|
|
195
|
+
if (typeof sessionId !== 'string' || sessionId === '' || !(sessionId in state.asked)) return this.get()
|
|
196
|
+
const asked = { ...state.asked }
|
|
197
|
+
delete asked[sessionId]
|
|
198
|
+
state = normalizeSettings({ ...state, asked })
|
|
199
|
+
await persist()
|
|
200
|
+
return this.get()
|
|
201
|
+
},
|
|
149
202
|
}
|
|
150
203
|
}
|
|
151
204
|
|
|
@@ -166,11 +219,29 @@ export function createSettings(options) {
|
|
|
166
219
|
* @returns {{ sessionId: string, idleMs: number }[]}
|
|
167
220
|
*/
|
|
168
221
|
export function dueAutoCollect(input) {
|
|
169
|
-
|
|
222
|
+
return explainAutoCollect(input)
|
|
223
|
+
.filter((decision) => decision.ask)
|
|
224
|
+
.map((decision) => ({ sessionId: decision.sessionId, idleMs: decision.idleMs }))
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* The same decisions, with the reason kept.
|
|
229
|
+
*
|
|
230
|
+
* `dueAutoCollect` used to compute the reason and throw it away, which is why "why was this
|
|
231
|
+
* conversation not asked?" had no answer anywhere. `/health` reads this list.
|
|
232
|
+
*
|
|
233
|
+
* @param {object} input - the same shape `dueAutoCollect` takes, plus optional `retryAt`
|
|
234
|
+
* @returns {{ sessionId: string, ask: boolean, reason: string, idleMs: number }[]}
|
|
235
|
+
*/
|
|
236
|
+
export function explainAutoCollect(input) {
|
|
237
|
+
const out = []
|
|
170
238
|
for (const sessionId of Object.keys(input.pending ?? {})) {
|
|
171
239
|
const lastAt = input.activityAt?.[sessionId]
|
|
172
240
|
// Fail closed: without a known "went quiet at", there is nothing to wait for.
|
|
173
|
-
if (!Number.isFinite(lastAt) || lastAt <= 0)
|
|
241
|
+
if (!Number.isFinite(lastAt) || lastAt <= 0) {
|
|
242
|
+
out.push({ sessionId, ask: false, reason: 'no-activity', idleMs: 0 })
|
|
243
|
+
continue
|
|
244
|
+
}
|
|
174
245
|
const idleMs = input.now - lastAt
|
|
175
246
|
const decision = decideAutoCollect({
|
|
176
247
|
settings: input.settings,
|
|
@@ -180,11 +251,12 @@ export function dueAutoCollect(input) {
|
|
|
180
251
|
idleMs,
|
|
181
252
|
humanAt: input.humanAt,
|
|
182
253
|
sessionDir: input.dirAt?.[sessionId],
|
|
254
|
+
retryAt: input.retryAt,
|
|
183
255
|
now: input.now,
|
|
184
256
|
})
|
|
185
|
-
|
|
257
|
+
out.push({ sessionId, idleMs, ask: decision.ask, reason: decision.reason })
|
|
186
258
|
}
|
|
187
|
-
return
|
|
259
|
+
return out
|
|
188
260
|
}
|
|
189
261
|
|
|
190
262
|
/**
|
|
@@ -228,6 +300,7 @@ export function idleSessions(input) {
|
|
|
228
300
|
* @param {number} [input.idleMs]
|
|
229
301
|
* @param {Record<string, number>} [input.humanAt] - when a person last wrote, per session
|
|
230
302
|
* @param {string} [input.sessionDir] - the conversation's working directory, when known
|
|
303
|
+
* @param {Record<string, number>} [input.retryAt] - next retry time per session after a failure
|
|
231
304
|
* @param {number} input.now
|
|
232
305
|
* @returns {{ ask: boolean, reason: string }}
|
|
233
306
|
*/
|
|
@@ -240,12 +313,24 @@ export function decideAutoCollect(input) {
|
|
|
240
313
|
if (isExemptWorkspace(input.sessionDir ?? '', input.settings.exemptWorkspaces)) {
|
|
241
314
|
return { ask: false, reason: 'exempt-workspace' }
|
|
242
315
|
}
|
|
316
|
+
// Fail closed on an unknown directory. The exemption is keyed on where the conversation
|
|
317
|
+
// works, and a place nobody can name cannot be checked against it -- asking there would
|
|
318
|
+
// volunteer a question where the person may well have meant to exclude. The cost is a
|
|
319
|
+
// conversation whose cwd the Host never reported, and the event tracker normally supplies
|
|
320
|
+
// it on the first message.
|
|
321
|
+
if (typeof input.sessionDir !== 'string' || input.sessionDir === '') {
|
|
322
|
+
return { ask: false, reason: 'unknown-dir' }
|
|
323
|
+
}
|
|
243
324
|
if (input.turnReason !== 'completed') return { ask: false, reason: `turn-${input.turnReason ?? 'unknown'}` }
|
|
244
325
|
// Only where a person actually is: a hand-off, a subagent or a background run must
|
|
245
326
|
// not be interrupted with "anything to follow up on?".
|
|
246
327
|
if (input.recentSessionId === '' || input.sessionId !== input.recentSessionId) {
|
|
247
328
|
return { ask: false, reason: 'not-the-users-conversation' }
|
|
248
329
|
}
|
|
330
|
+
// A failed question backs off instead of retrying on the next tick. The booking is cleared
|
|
331
|
+
// on failure (see index.js), so without this the retry would be immediate.
|
|
332
|
+
const retryAt = input.retryAt?.[input.sessionId]
|
|
333
|
+
if (Number.isFinite(retryAt) && retryAt > input.now) return { ask: false, reason: 'retry-wait' }
|
|
249
334
|
// One question per person-turn — and the rule that ends the loop.
|
|
250
335
|
//
|
|
251
336
|
// The question is itself a message in the conversation: it restarted the quiet clock
|
package/lib/tools.js
CHANGED
|
@@ -416,7 +416,8 @@ export function createTools(service, options = {}) {
|
|
|
416
416
|
name: 'global_todo_list',
|
|
417
417
|
description:
|
|
418
418
|
'查「全局待办」。默认返回未完成(active = open + doing)的条目,按加入时间倒序。'
|
|
419
|
-
+ '每条都会带上加入时间;带附件的会列出附件标题。'
|
|
419
|
+
+ '每条都会带上加入时间;带附件的会列出附件标题。'
|
|
420
|
+
+ '结果超过 limit 时 truncated=true,matched 说明一共匹配了多少条。',
|
|
420
421
|
parameters: {
|
|
421
422
|
type: 'object',
|
|
422
423
|
properties: {
|
|
@@ -442,6 +443,10 @@ export function createTools(service, options = {}) {
|
|
|
442
443
|
count: { type: 'integer' },
|
|
443
444
|
counts: { type: 'object' },
|
|
444
445
|
todos: { type: 'array', items: TODO_SHAPE },
|
|
446
|
+
// Present only when the page was cut; the model should know it is not
|
|
447
|
+
// looking at the whole match set before it concludes anything.
|
|
448
|
+
matched: { type: 'integer' },
|
|
449
|
+
truncated: { type: 'boolean' },
|
|
445
450
|
},
|
|
446
451
|
required: ['count', 'counts', 'todos'],
|
|
447
452
|
},
|
|
@@ -452,8 +457,12 @@ export function createTools(service, options = {}) {
|
|
|
452
457
|
)
|
|
453
458
|
}
|
|
454
459
|
const lines = value.todos.map((todo) => todoLine(todo))
|
|
460
|
+
const cut =
|
|
461
|
+
value.truncated === true
|
|
462
|
+
? `\n(只显示前 ${value.count} 条,共 ${value.matched} 条匹配;用 status / query / limit 缩小范围)`
|
|
463
|
+
: ''
|
|
455
464
|
return text(
|
|
456
|
-
`${value.count} 条待办(全库 ${value.counts.total} 条,逾期 ${value.counts.overdue} 条):\n${lines.join('\n')}`,
|
|
465
|
+
`${value.count} 条待办(全库 ${value.counts.total} 条,逾期 ${value.counts.overdue} 条):\n${lines.join('\n')}${cut}`,
|
|
457
466
|
)
|
|
458
467
|
},
|
|
459
468
|
},
|
|
@@ -473,6 +482,8 @@ export function createTools(service, options = {}) {
|
|
|
473
482
|
count: outcome.value.todos.length,
|
|
474
483
|
counts: outcome.value.counts,
|
|
475
484
|
todos: outcome.value.todos.map((todo) => presentTodo(todo, now())),
|
|
485
|
+
matched: outcome.value.matched,
|
|
486
|
+
truncated: outcome.value.truncated,
|
|
476
487
|
}
|
|
477
488
|
},
|
|
478
489
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-global-todo",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"icon": "./docs/icon.svg",
|
|
4
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 继续干。",
|
|
5
6
|
"type": "module",
|
|
6
7
|
"license": "MIT",
|
|
@@ -41,6 +42,7 @@
|
|
|
41
42
|
"cordis.patch.yml",
|
|
42
43
|
"docs/DESIGN.md",
|
|
43
44
|
"docs/ROADMAP.md",
|
|
45
|
+
"docs/icon.svg",
|
|
44
46
|
"docs/screenshots",
|
|
45
47
|
"LICENSE",
|
|
46
48
|
"README.md",
|
|
@@ -58,7 +60,8 @@
|
|
|
58
60
|
"screenshots": "npm run preview && node tools/screenshot.mjs",
|
|
59
61
|
"prepack": "npm run verify",
|
|
60
62
|
"check:render": "node tools/preview.mjs --check",
|
|
61
|
-
"check:live": "node tools/live-check.mjs"
|
|
63
|
+
"check:live": "node tools/live-check.mjs",
|
|
64
|
+
"check:matrix": "node scripts/matrix.mjs"
|
|
62
65
|
},
|
|
63
66
|
"dsh": {
|
|
64
67
|
"manifestVersion": 1,
|