dsh-global-todo 0.3.2 → 0.4.1
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 +105 -0
- package/CONTRIBUTING.md +14 -0
- package/README.md +27 -4
- package/README.zh.md +23 -4
- package/cordis.patch.yml +6 -0
- package/docs/DESIGN.md +31 -4
- package/docs/ROADMAP.md +55 -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 +233 -20
- package/lib/index.js +108 -24
- package/lib/model.js +4 -4
- package/lib/routes.js +29 -0
- package/lib/service.js +66 -6
- package/lib/settings.js +91 -6
- package/lib/tools.js +18 -4
- package/package.json +5 -2
package/lib/index.js
CHANGED
|
@@ -25,7 +25,7 @@ import path from 'node:path'
|
|
|
25
25
|
|
|
26
26
|
import { createCompletionWatcher } from './completion.js'
|
|
27
27
|
import { createDispatcher } from './dispatch.js'
|
|
28
|
-
import { createSettings,
|
|
28
|
+
import { createSettings, explainAutoCollect, idleSessions, retryDelayMs } from './settings.js'
|
|
29
29
|
import { createDiagnostics } from './diagnostics.js'
|
|
30
30
|
import { createMetrics } from './metrics.js'
|
|
31
31
|
import { LIMITS } from './model.js'
|
|
@@ -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.
|
|
38
|
+
export const VERSION = '0.4.1'
|
|
39
39
|
|
|
40
40
|
/** Plugin id. Also the Loader row id in `cordis.patch.yml`. */
|
|
41
41
|
export const name = 'dsh-global-todo'
|
|
@@ -47,6 +47,10 @@ export const DEFAULTS = Object.freeze({
|
|
|
47
47
|
activityLimit: LIMITS.activity,
|
|
48
48
|
requireExistingFiles: true,
|
|
49
49
|
guidance: true,
|
|
50
|
+
// Starting point for the built-in folder browser when the shell has no picker.
|
|
51
|
+
// Documented here because a patch override replaces `config` wholesale and has
|
|
52
|
+
// to restate every key it wants to keep.
|
|
53
|
+
browseRoot: '',
|
|
50
54
|
})
|
|
51
55
|
|
|
52
56
|
/**
|
|
@@ -169,6 +173,35 @@ export function apply(ctx, config) {
|
|
|
169
173
|
// clock, so a session in use is never asked.
|
|
170
174
|
const collectPending = {}
|
|
171
175
|
const collectActivity = {}
|
|
176
|
+
// Sessions whose question failed: when the next attempt is allowed, and how many
|
|
177
|
+
// attempts have failed. Two maps because the decision layer takes timestamps only.
|
|
178
|
+
const collectRetry = {}
|
|
179
|
+
const collectAttempts = {}
|
|
180
|
+
const ASK_TIMEOUT_MS = 10_000
|
|
181
|
+
const ASK_MAX_ATTEMPTS = 3
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Bound a promise the plugin does not control.
|
|
185
|
+
*
|
|
186
|
+
* `requestCapture` may wait on `agents.resume`, and a resume that never settles left
|
|
187
|
+
* `asking` true forever — which silences the feature for every conversation, not just
|
|
188
|
+
* the broken one.
|
|
189
|
+
*/
|
|
190
|
+
const withinMs = (promise, ms) =>
|
|
191
|
+
new Promise((resolve, reject) => {
|
|
192
|
+
const timer = setTimeout(() => reject(new Error(`等待 ${Math.round(ms / 1000)} 秒没有结果`)), ms)
|
|
193
|
+
if (typeof timer.unref === 'function') timer.unref()
|
|
194
|
+
promise.then(
|
|
195
|
+
(value) => {
|
|
196
|
+
clearTimeout(timer)
|
|
197
|
+
resolve(value)
|
|
198
|
+
},
|
|
199
|
+
(error) => {
|
|
200
|
+
clearTimeout(timer)
|
|
201
|
+
reject(error)
|
|
202
|
+
},
|
|
203
|
+
)
|
|
204
|
+
})
|
|
172
205
|
// When a *person* last wrote in each conversation. The question we send is a message in
|
|
173
206
|
// the conversation too, and re-arming on it is exactly the loop this exists to break — so
|
|
174
207
|
// only `source.kind === 'user'` counts as somebody having answered.
|
|
@@ -177,24 +210,46 @@ export function apply(ctx, config) {
|
|
|
177
210
|
// The live lookup below is only a fallback for a conversation that has not been seen yet.
|
|
178
211
|
const collectDir = {}
|
|
179
212
|
|
|
180
|
-
const askSession = (sessionId) => {
|
|
213
|
+
const askSession = async (sessionId) => {
|
|
214
|
+
if (autoCollectState.asking === true) return
|
|
181
215
|
autoCollectState.asking = true
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
.
|
|
185
|
-
|
|
186
|
-
.
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
.
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
216
|
+
autoCollectState.stats.asked += 1
|
|
217
|
+
try {
|
|
218
|
+
// Book first: a question that fails must not be repeated in a burst. The booking is
|
|
219
|
+
// released below when the question did not arrive, because a failure is not an answer.
|
|
220
|
+
await settings.rememberAsked(sessionId, Date.now())
|
|
221
|
+
const result = await withinMs(dispatcher.requestCapture({ sessionId, authoritative: true }), ASK_TIMEOUT_MS)
|
|
222
|
+
autoCollectState.lastAsk = { at: Date.now(), sessionId: result?.sessionId ?? sessionId }
|
|
223
|
+
autoCollectState.error = null
|
|
224
|
+
autoCollectState.stats.succeeded += 1
|
|
225
|
+
autoCollectState.lastRetry = null
|
|
226
|
+
delete collectRetry[sessionId]
|
|
227
|
+
delete collectAttempts[sessionId]
|
|
228
|
+
delete collectPending[sessionId]
|
|
229
|
+
} catch (error) {
|
|
230
|
+
const message = String(error?.message ?? error)
|
|
231
|
+
const attempts = (collectAttempts[sessionId] ?? 0) + 1
|
|
232
|
+
autoCollectState.error = message
|
|
233
|
+
autoCollectState.stats.failed += 1
|
|
234
|
+
// A failure is not an answer: leaving the booking behind made `awaiting-human` keep
|
|
235
|
+
// the conversation quiet until a person happened to speak again.
|
|
236
|
+
await settings.forgetAsked(sessionId).catch(() => {})
|
|
237
|
+
if (attempts >= ASK_MAX_ATTEMPTS) {
|
|
238
|
+
delete collectRetry[sessionId]
|
|
239
|
+
delete collectAttempts[sessionId]
|
|
196
240
|
delete collectPending[sessionId]
|
|
197
|
-
|
|
241
|
+
autoCollectState.stats.dropped += 1
|
|
242
|
+
autoCollectState.lastDrop = { at: Date.now(), sessionId, attempts, error: message }
|
|
243
|
+
} else {
|
|
244
|
+
const at = Date.now()
|
|
245
|
+
collectAttempts[sessionId] = attempts
|
|
246
|
+
collectRetry[sessionId] = at + retryDelayMs(attempts)
|
|
247
|
+
autoCollectState.lastRetry = { at, sessionId, attempts, nextAt: collectRetry[sessionId] }
|
|
248
|
+
}
|
|
249
|
+
logger?.warn?.(`dsh-global-todo: 自动抓取失败(${message}),第 ${attempts} 次`)
|
|
250
|
+
} finally {
|
|
251
|
+
autoCollectState.asking = false
|
|
252
|
+
}
|
|
198
253
|
}
|
|
199
254
|
|
|
200
255
|
ctx.effect(() => {
|
|
@@ -266,10 +321,14 @@ export function apply(ctx, config) {
|
|
|
266
321
|
// With the switch off there is nothing to ask for either, so the candidates are
|
|
267
322
|
// dropped rather than left to fire in a burst the moment it is switched on.
|
|
268
323
|
if (current.autoCollect !== true) {
|
|
269
|
-
for (const sessionId of idle)
|
|
324
|
+
for (const sessionId of idle) {
|
|
325
|
+
delete collectPending[sessionId]
|
|
326
|
+
// A retry belongs to a candidate; with the switch off there is nothing to retry.
|
|
327
|
+
delete collectRetry[sessionId]
|
|
328
|
+
delete collectAttempts[sessionId]
|
|
329
|
+
}
|
|
270
330
|
}
|
|
271
331
|
autoCollectState.waiting = Object.keys(collectPending).length
|
|
272
|
-
if (current.autoCollect !== true || autoCollectState.asking === true) return
|
|
273
332
|
// An exemption is keyed on the working directory. What the conversation's own events
|
|
274
333
|
// reported wins, because an unloaded conversation is the normal state of one that has
|
|
275
334
|
// gone quiet; the agents service is only the fallback, and it answers '' for a session
|
|
@@ -278,18 +337,30 @@ export function apply(ctx, config) {
|
|
|
278
337
|
for (const sessionId of Object.keys(collectPending)) {
|
|
279
338
|
dirAt[sessionId] = collectDir[sessionId] ?? dispatcher.sessionDir(sessionId)
|
|
280
339
|
}
|
|
281
|
-
const
|
|
340
|
+
const decisions = explainAutoCollect({
|
|
282
341
|
pending: collectPending,
|
|
283
342
|
activityAt: collectActivity,
|
|
284
343
|
humanAt: collectHuman,
|
|
285
344
|
dirAt,
|
|
345
|
+
retryAt: collectRetry,
|
|
286
346
|
settings: current,
|
|
287
347
|
recentSessionId: recentSession.id,
|
|
288
|
-
now
|
|
348
|
+
now,
|
|
289
349
|
})
|
|
350
|
+
const due = decisions.filter((decision) => decision.ask)
|
|
290
351
|
autoCollectState.waiting = Object.keys(collectPending).length
|
|
352
|
+
autoCollectState.due = due.length
|
|
353
|
+
// The reason used to be computed and dropped on the floor; keep the list so /health
|
|
354
|
+
// can answer "why was this conversation not asked?".
|
|
355
|
+
autoCollectState.decisions = decisions.slice(0, 20)
|
|
356
|
+
const shown = due[0] ?? decisions[0]
|
|
357
|
+
autoCollectState.lastDecision =
|
|
358
|
+
shown === undefined
|
|
359
|
+
? null
|
|
360
|
+
: { at: now, sessionId: shown.sessionId, ask: shown.ask === true, reason: shown.reason, idleMs: shown.idleMs }
|
|
361
|
+
if (current.autoCollect !== true || autoCollectState.asking === true) return
|
|
291
362
|
if (due.length === 0) return
|
|
292
|
-
askSession(due[0].sessionId)
|
|
363
|
+
void askSession(due[0].sessionId)
|
|
293
364
|
} catch (error) {
|
|
294
365
|
autoCollectState.error = String(error?.message ?? error)
|
|
295
366
|
}
|
|
@@ -322,7 +393,20 @@ export function apply(ctx, config) {
|
|
|
322
393
|
const diagnostics = createDiagnostics({ file: path.join(path.dirname(file), 'client-errors.log'), logger })
|
|
323
394
|
const settingsFile = path.join(path.dirname(file), 'settings.json')
|
|
324
395
|
const settings = createSettings({ file: settingsFile, logger })
|
|
325
|
-
const autoCollectState = {
|
|
396
|
+
const autoCollectState = {
|
|
397
|
+
enabled: false,
|
|
398
|
+
asking: false,
|
|
399
|
+
lastAsk: null,
|
|
400
|
+
error: null,
|
|
401
|
+
waiting: 0,
|
|
402
|
+
due: 0,
|
|
403
|
+
decisions: [],
|
|
404
|
+
lastDecision: null,
|
|
405
|
+
lastRetry: null,
|
|
406
|
+
lastDrop: null,
|
|
407
|
+
// In-memory on purpose: it describes this process, not the list. Restarting resets it.
|
|
408
|
+
stats: { asked: 0, succeeded: 0, failed: 0, dropped: 0 },
|
|
409
|
+
}
|
|
326
410
|
ctx.effect(() => {
|
|
327
411
|
void settings.load().then((loaded) => {
|
|
328
412
|
autoCollectState.enabled = loaded.autoCollect === true
|
package/lib/model.js
CHANGED
|
@@ -20,7 +20,7 @@ import { LINK_KINDS, linkKey, normalizeLink } from './links.js'
|
|
|
20
20
|
export const SCHEMA_VERSION = 1
|
|
21
21
|
|
|
22
22
|
/** Lifecycle states. `doing` is the explicit "an agent (or I) am on it" state. */
|
|
23
|
-
export const STATUSES = Object.freeze(['open', 'doing', 'done', 'cancelled'])
|
|
23
|
+
export const STATUSES = Object.freeze(['open', 'doing', 'done', 'cancelled', 'archived'])
|
|
24
24
|
|
|
25
25
|
/** Priorities are deliberately coarse: three levels people actually use. */
|
|
26
26
|
/**
|
|
@@ -653,17 +653,17 @@ export function recordDispatch(todo, info, options = {}) {
|
|
|
653
653
|
* @returns {boolean}
|
|
654
654
|
*/
|
|
655
655
|
export function isOverdue(todo, now = Date.now()) {
|
|
656
|
-
if (todo.dueAt === null || todo.status === 'done' || todo.status === 'cancelled') return false
|
|
656
|
+
if (todo.dueAt === null || todo.status === 'done' || todo.status === 'cancelled' || todo.status === 'archived') return false
|
|
657
657
|
const due = Date.parse(todo.dueAt)
|
|
658
658
|
return Number.isFinite(due) && due < now
|
|
659
659
|
}
|
|
660
660
|
|
|
661
661
|
/**
|
|
662
662
|
* @param {object[]} todos
|
|
663
|
-
* @returns {{ total: number, open: number, doing: number, done: number, cancelled: number, overdue: number }}
|
|
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, overdue: 0 }
|
|
666
|
+
const out = { total: todos.length, open: 0, doing: 0, done: 0, cancelled: 0, archived: 0, overdue: 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
|
package/lib/routes.js
CHANGED
|
@@ -358,6 +358,10 @@ export function createRoutes(deps) {
|
|
|
358
358
|
// Counted once: the size feeds the metrics snapshot and the counts the panel
|
|
359
359
|
// reads are the same numbers, so /health cannot contradict itself.
|
|
360
360
|
const counts = await service.stats()
|
|
361
|
+
// Where the automatic-collect switch came from. `false` because nobody turned it on
|
|
362
|
+
// and `false` because the file is unreadable look identical from the panel, and the
|
|
363
|
+
// second one used to be invisible everywhere.
|
|
364
|
+
const settingsInfo = deps.settings?.info?.() ?? { file: '', at: 0, source: 'default', error: null }
|
|
361
365
|
return {
|
|
362
366
|
plugin: 'dsh-global-todo',
|
|
363
367
|
version,
|
|
@@ -403,6 +407,11 @@ export function createRoutes(deps) {
|
|
|
403
407
|
// The exempted workspaces, so the panel can show which conversations are out of
|
|
404
408
|
// scope instead of leaving the person to guess why nothing was ever asked.
|
|
405
409
|
exemptWorkspaces: deps.settings?.get?.().exemptWorkspaces ?? [],
|
|
410
|
+
// Reported so "off" can be told apart from "the file did not load".
|
|
411
|
+
file: settingsInfo.file,
|
|
412
|
+
loadedAt: settingsInfo.at,
|
|
413
|
+
loadSource: settingsInfo.source,
|
|
414
|
+
loadError: settingsInfo.error,
|
|
406
415
|
},
|
|
407
416
|
autoCollect: {
|
|
408
417
|
// Derived from the setting, not from a copy kept elsewhere: two sources for one
|
|
@@ -411,6 +420,17 @@ export function createRoutes(deps) {
|
|
|
411
420
|
asking: deps.autoCollect?.asking === true,
|
|
412
421
|
lastAsk: deps.autoCollect?.lastAsk ?? null,
|
|
413
422
|
error: deps.autoCollect?.error ?? null,
|
|
423
|
+
// Why a conversation was or was not asked. The decision used to keep the reason
|
|
424
|
+
// in a local and drop it, so this question had no answer even in the Host.
|
|
425
|
+
waiting: deps.autoCollect?.waiting ?? 0,
|
|
426
|
+
due: deps.autoCollect?.due ?? 0,
|
|
427
|
+
lastDecision: deps.autoCollect?.lastDecision ?? null,
|
|
428
|
+
decisions: deps.autoCollect?.decisions ?? [],
|
|
429
|
+
stats: deps.autoCollect?.stats ?? { asked: 0, succeeded: 0, failed: 0, dropped: 0 },
|
|
430
|
+
// Set after a failed question, so a retry in flight is visible rather than
|
|
431
|
+
// looking like the feature silently stopped.
|
|
432
|
+
lastRetry: deps.autoCollect?.lastRetry ?? null,
|
|
433
|
+
lastDrop: deps.autoCollect?.lastDrop ?? null,
|
|
414
434
|
},
|
|
415
435
|
// 「交给 Agent」 resolves its target from the conversation the user last
|
|
416
436
|
// wrote in, so whether that feed attached is worth reporting too.
|
|
@@ -448,6 +468,15 @@ export function createRoutes(deps) {
|
|
|
448
468
|
return dispatcher.assignMany(body ?? {})
|
|
449
469
|
}),
|
|
450
470
|
|
|
471
|
+
exact('POST', `${ROUTE_BASE}/bulk`, async (request) => {
|
|
472
|
+
const body = await readJsonBody(request)
|
|
473
|
+
return service.bulk(
|
|
474
|
+
body.ids,
|
|
475
|
+
{ status: body.status, addTags: body.addTags, removeTags: body.removeTags },
|
|
476
|
+
{ by: actorOf(body) },
|
|
477
|
+
)
|
|
478
|
+
}),
|
|
479
|
+
|
|
451
480
|
exact('POST', `${ROUTE_BASE}/capture`, async (request) => {
|
|
452
481
|
const body = await readJsonBody(request)
|
|
453
482
|
return dispatcher.requestCapture(body ?? {})
|
package/lib/service.js
CHANGED
|
@@ -33,12 +33,15 @@ import {
|
|
|
33
33
|
} from './model.js'
|
|
34
34
|
|
|
35
35
|
/** Filters the panel and the tools may ask for. */
|
|
36
|
-
export const FILTERS = Object.freeze(['all', 'active', 'open', 'doing', 'done', 'cancelled', 'overdue'])
|
|
36
|
+
export const FILTERS = Object.freeze(['all', 'active', 'open', 'doing', 'done', 'cancelled', 'archived', 'overdue'])
|
|
37
37
|
|
|
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
|
/**
|
|
@@ -607,6 +618,54 @@ export function createService(options) {
|
|
|
607
618
|
})
|
|
608
619
|
}
|
|
609
620
|
|
|
621
|
+
/**
|
|
622
|
+
* Apply one change to several todos.
|
|
623
|
+
*
|
|
624
|
+
* Each record goes through the same validation and trail as a single edit. A stale id is
|
|
625
|
+
* reported in `unmatched` instead of failing the batch: a selection is a human gesture, and
|
|
626
|
+
* one row that moved must not discard the other nine.
|
|
627
|
+
*
|
|
628
|
+
* @param {string[]} ids
|
|
629
|
+
* @param {{ status?: string, addTags?: string[], removeTags?: string[] }} change
|
|
630
|
+
* @param {{ by?: 'human' | 'agent' }} [context]
|
|
631
|
+
* @returns {Promise<{ updated: number, ids: string[], unmatched: string[], errors: { id: string, error: string }[] }>}
|
|
632
|
+
*/
|
|
633
|
+
async function bulk(ids, change = {}, context = {}) {
|
|
634
|
+
const wanted = Array.isArray(ids) ? ids.filter((id) => typeof id === 'string' && id.trim() !== '') : []
|
|
635
|
+
if (wanted.length === 0) throw new TodoError('invalid-body', '要改哪些待办?给一个非空的 ids')
|
|
636
|
+
const hasStatus = typeof change.status === 'string' && change.status !== ''
|
|
637
|
+
const addTags = Array.isArray(change.addTags) ? change.addTags : []
|
|
638
|
+
const removeTags = Array.isArray(change.removeTags) ? change.removeTags : []
|
|
639
|
+
if (!hasStatus && addTags.length === 0 && removeTags.length === 0) {
|
|
640
|
+
throw new TodoError('invalid-body', '批量修改需要 status / addTags / removeTags 之一')
|
|
641
|
+
}
|
|
642
|
+
const updated = []
|
|
643
|
+
const unmatched = []
|
|
644
|
+
const errors = []
|
|
645
|
+
for (const id of wanted) {
|
|
646
|
+
try {
|
|
647
|
+
const todo = await store.update((doc) => {
|
|
648
|
+
const target = doc.todos.find((candidate) => candidate.id === id)
|
|
649
|
+
if (target === undefined) return { result: null }
|
|
650
|
+
const patch = {}
|
|
651
|
+
if (hasStatus) patch.status = change.status
|
|
652
|
+
if (addTags.length > 0 || removeTags.length > 0) {
|
|
653
|
+
const merged = new Map(target.tags.map((tag) => [tag.toLowerCase(), tag]))
|
|
654
|
+
for (const tag of addTags) merged.set(String(tag).toLowerCase(), String(tag))
|
|
655
|
+
for (const tag of removeTags) merged.delete(String(tag).toLowerCase())
|
|
656
|
+
patch.tags = [...merged.values()]
|
|
657
|
+
}
|
|
658
|
+
const changed = applyPatch(target, patch, { ...modelOptions(context.by), ...linkOptions() })
|
|
659
|
+
return changed.length === 0 ? { result: target } : { next: doc.todos, result: target }
|
|
660
|
+
})
|
|
661
|
+
if (todo === null) unmatched.push(id)
|
|
662
|
+
else updated.push(id)
|
|
663
|
+
} catch (error) {
|
|
664
|
+
errors.push({ id, error: error instanceof TodoError ? error.message : String(error?.message ?? error) })
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
return { updated: updated.length, ids: updated, unmatched, errors }
|
|
668
|
+
}
|
|
610
669
|
/** @returns {Promise<object>} counts for the panel header and tool summaries */
|
|
611
670
|
async function stats() {
|
|
612
671
|
const todos = await store.all()
|
|
@@ -626,6 +685,7 @@ export function createService(options) {
|
|
|
626
685
|
findBySession,
|
|
627
686
|
remove,
|
|
628
687
|
restore,
|
|
688
|
+
bulk,
|
|
629
689
|
addLink,
|
|
630
690
|
removeLink,
|
|
631
691
|
recordDispatch,
|
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,13 +416,14 @@ 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: {
|
|
423
424
|
status: {
|
|
424
425
|
type: 'string',
|
|
425
|
-
enum: ['all', 'active', 'open', 'doing', 'done', 'cancelled', 'overdue'],
|
|
426
|
+
enum: ['all', 'active', 'open', 'doing', 'done', 'cancelled', 'archived', 'overdue'],
|
|
426
427
|
description: '缺省 active',
|
|
427
428
|
},
|
|
428
429
|
query: { type: 'string', description: '在标题、描述、标签、附件里模糊匹配' },
|
|
@@ -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
|
},
|
|
@@ -507,7 +518,7 @@ export function createTools(service, options = {}) {
|
|
|
507
518
|
{
|
|
508
519
|
name: 'global_todo_update',
|
|
509
520
|
description:
|
|
510
|
-
'改一条全局待办。只传要改的字段;`dueAt: ""` 清空截止时间,`tags: []` 清空标签,`links` 是整体替换(要单独加附件请用 global_todo_link
|
|
521
|
+
'改一条全局待办。只传要改的字段;`dueAt: ""` 清空截止时间,`tags: []` 清空标签,`links` 是整体替换(要单独加附件请用 global_todo_link),`status` 可以设为 archived(归档,不删除)。',
|
|
511
522
|
parameters: {
|
|
512
523
|
type: 'object',
|
|
513
524
|
properties: {
|
|
@@ -515,6 +526,9 @@ export function createTools(service, options = {}) {
|
|
|
515
526
|
title: { type: 'string' },
|
|
516
527
|
description: { type: 'string' },
|
|
517
528
|
priority: { type: 'string', enum: [...PRIORITIES] },
|
|
529
|
+
// `status` belongs here: marking something in progress used to require the tool
|
|
530
|
+
// named `global_todo_done`, and archiving had no entry point at all.
|
|
531
|
+
status: { type: 'string', enum: [...STATUSES], description: 'open / doing / done / cancelled / archived' },
|
|
518
532
|
dueAt: { oneOf: [{ type: 'string' }, { type: 'null' }], description: '空串或 null 表示清空' },
|
|
519
533
|
tags: { type: 'array', items: { type: 'string' } },
|
|
520
534
|
links: LINKS_INPUT,
|