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/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
- 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
  /**
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,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.2",
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,