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/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, dueAutoCollect, idleSessions } from './settings.js'
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.3.2'
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
- // Book the cooldown *before* asking: a failed question must not loop.
183
- void settings
184
- .rememberAsked(sessionId, Date.now())
185
- .then(() => dispatcher.requestCapture({ sessionId, authoritative: true }))
186
- .then((result) => {
187
- autoCollectState.lastAsk = { at: Date.now(), sessionId: result?.sessionId ?? sessionId }
188
- autoCollectState.error = null
189
- })
190
- .catch((error) => {
191
- autoCollectState.error = String(error?.message ?? error)
192
- logger?.warn?.(`dsh-global-todo: 自动抓取失败(${autoCollectState.error})`)
193
- })
194
- .finally(() => {
195
- autoCollectState.asking = false
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) delete collectPending[sessionId]
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 due = dueAutoCollect({
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: Date.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 = { enabled: false, asking: false, lastAsk: null, error: null }
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
- const PRIORITY_RANK = { high: 0, normal: 1, low: 2 }
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
- return out.slice(0, limit)
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 { todos: selected, counts: counts(todos, now()), matched: selected.length }
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
- state = normalizeSettings(JSON.parse(await readFile(options.file, 'utf8')))
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
- const due = []
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) continue
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
- if (decision.ask) due.push({ sessionId, idleMs })
257
+ out.push({ sessionId, idleMs, ask: decision.ask, reason: decision.reason })
186
258
  }
187
- return due
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,