dsh-global-todo 0.12.13 → 0.13.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 CHANGED
@@ -1,3 +1,22 @@
1
+ ## [0.13.0] — 2026-10-22
2
+
3
+ Batch capture now scores what it is given, and drops what is not worth a todo.
4
+
5
+ - **A weight per candidate, with a floor you can tune.** `global_todo_capture` asks a model for
6
+ "anything worth following up" and models oblige with hedged asides. Each candidate now gets a
7
+ 0–1 weight from named signals: a specific title, a parseable deadline, steps, an attachment, a
8
+ quoted original, commitment wording (记得 / 截止 / 等下周一 …) add; hesitation (可能 / 也许 /
9
+ 看看 / 再说), a bare question, or a one-liner with nothing attached subtract. Below
10
+ `captureMinWeight` the entry is skipped with its weight and the signals that pulled it down.
11
+ - **The threshold is a setting**, not a constant: `captureMinWeight` (0–1, default 0.35) lives in
12
+ the settings file, is validated by `POST /settings`, reported in `/health`, and editable in the
13
+ panel's settings dialog. A hedged one-liner scores ~0.30 and is dropped; a stated errand with
14
+ nothing attached yet scores ~0.40 and passes.
15
+ - **The skip reason stays honest.** A low-weight item is reported as `权重 0.30 低于阈值 0.35,未登记`
16
+ together with the penalty that applied, so "why was this not recorded" has an answer.
17
+ - **Explained where the model reads it**: the capture prompt and the tool description both say the
18
+ weight exists and that specificity pays.
19
+
1
20
  ## [0.12.13] — 2026-10-21
2
21
 
3
22
  The dedupe promise now holds for short titles (#4).
package/README.md CHANGED
@@ -322,7 +322,7 @@ Your list is not touched by install or uninstall: it lives outside the plugin, a
322
322
  |---|---|
323
323
  | `global_todo_add` | Record an item — description, attachments, steps, tags, priority, due time |
324
324
  | `global_todo_note` | Append a progress note — where you are, what blocks you, who must decide |
325
- | `global_todo_capture` | **Record a batch**: for 「抓一下待办」 or a turn that produced several follow-ups; each entry is validated on its own |
325
+ | `global_todo_capture` | **Record a batch**: for 「抓一下待办」 or a turn that produced several follow-ups; each entry is validated on its own, and entries scoring below `captureMinWeight` are reported as skipped with their weight instead of being registered |
326
326
  | `global_todo_list` | Query by state, tag or text; every row carries its creation time |
327
327
  | `global_todo_get` | Read one item in full, including attachments and the last hand-off |
328
328
  | `global_todo_update` | Change any field of one item, including `status` (`archived` archives without deleting); `""` clears, `[]` empties |
@@ -393,7 +393,7 @@ fence when that service is present, and by a strict loopback check otherwise.
393
393
  | POST | `/api/global-todo/link` | `{ id, add?: [...], remove?: [value \| index] }` |
394
394
  | POST | `/api/global-todo/dispatch` | `{ id, mode?: auto\|bound\|new\|current, sessionId?, dir? }` |
395
395
  | POST | `/api/global-todo/assign` | `{ ids, items?, target?, dir? }` — hand several todos over in one call; an id that matches nothing comes back instead of being sent elsewhere |
396
- | POST | `/api/global-todo/settings` | `{ autoCollect?, idleMinutes?, cooldownMinutes?, exemptWorkspaces? }` — the Host-side switches behind automatic collect |
396
+ | POST | `/api/global-todo/settings` | `{ autoCollect?, idleMinutes?, cooldownMinutes?, exemptWorkspaces?, captureMinWeight? }` — the Host-side switches behind automatic collect; `captureMinWeight` (0–1, default 0.35) is the floor below which a batch-capture candidate is reported as skipped instead of registered |
397
397
  | POST | `/api/global-todo/diagnostics` | `{ scope, status?, reason?, message?, url? }` — the panel's own error report |
398
398
 
399
399
  `by` on `/update` and `/status` says whose write this is: omitted (what the panel sends) it is
package/README.zh.md CHANGED
@@ -219,6 +219,7 @@ dsh plugin --profile web remove dsh-global-todo
219
219
  - **分组维度**:工具栏上有 `对话 / 时间 / 级别` 三个维度,**默认按对话**——同一个 agent 对话提交的待办会**合并成一条**(显示「N 条」,点开才展开),这样几次「抓一下待办」不会把清单糊成一片。单条的对话直接显示那一行;没有来源对话的(在面板里手加的)归到「手动添加」。`时间` 是原来的逾期 / 今天 / 接下来 / 以后 / 没有截止时间,`级别` 按高 / 一般 / 低分组。
220
220
  - **级别 P0–P3**:每条待办可设 `P0 紧急 / P1 高 / P2 常规 / P3 低`;接口两种写法都收(`priority: "P0"` 或 `"urgent"`),旧记录里的 high/normal/low 原样有效,默认 P2。行上有徽标,对话分组头显示组内**最高级别**,绿色小圆点表示该对话里还有在跑的会话。
221
221
  - **从本对话抓取**:面板上一个按钮,把「请把这段对话里需要跟进的事用 `global_todo_capture` 抓一下」注入你正在说话的那个对话——聊着聊着把该跟进的事一次变成待办,不用自己一条条敲。提示词里同样写清了「不要记什么」(会话计划、闲聊、已完成的事),否则抓取只会制造噪音。
222
+ - **权重下限**:每条候选会被打一个 0–1 的权重(具体标题、截止时间、步骤、附件、原话、承诺措辞加分;「可能 / 也许 / 看看 / 再说」这类犹豫语、纯疑问句、没有任何补充信息的条目减分),低于设置里的 `captureMinWeight`(默认 0.35)就不登记,只在 `skipped` 里报出权重与原因。设置面板里可以直接改这个值。
222
223
  - **自动完成**:派发出去的会话**干净结束**时,宿主自己把这条待办标成完成,并附上**它最后那段回复**作为完成说明、**这一轮改动的文件**作为产出——不用等模型记得调工具。面板上完成的行显示「完成于 …」加产出芯片,展开详情有「完成记录」段(说明 + 产出路径,可点开或复制)。
223
224
  两条护栏值得知道:**只对刚派发过的会话生效**(你在那个会话里随便聊两句不会把待办完成掉),**失败 / 中断的回合不标记完成**(宁可留着未完成,也不能让一份干砸的活显示成已完成)。
224
225
  你也可以自己收尾:`global_todo_done` 支持 `summary` 与 `artifacts`,那是主路径,自动收尾只是兜底。
@@ -295,7 +296,7 @@ dsh plugin --profile web remove dsh-global-todo
295
296
  | POST | `/api/global-todo/link` | `{ id, add?: [...], remove?: [值 或 下标] }` |
296
297
  | POST | `/api/global-todo/dispatch` | `{ id, mode?: auto\|bound\|new\|current, sessionId?, dir? }` |
297
298
  | POST | `/api/global-todo/assign` | `{ ids, items?, target?, dir? }`——一次把多条交给 Agent;配不上的 id 原样报回,不擅自送到别处 |
298
- | POST | `/api/global-todo/settings` | `{ autoCollect?, idleMinutes?, cooldownMinutes?, exemptWorkspaces? }`——自动获取的开关与阈值 |
299
+ | POST | `/api/global-todo/settings` | `{ autoCollect?, idleMinutes?, cooldownMinutes?, exemptWorkspaces?, captureMinWeight? }`——自动获取的开关与阈值;`captureMinWeight`(0–1,默认 0.35)是批量抓取的权重下限,低于它的候选只在 `skipped` 里报出、不登记 |
299
300
  | POST | `/api/global-todo/diagnostics` | `{ scope, status?, reason?, message?, url? }`——面板自己上报的错误 |
300
301
 
301
302
  `/update` 与 `/status` 的 `by` 说明这次写是谁做的:不传(面板就是这样)就是人在键盘前,
package/lib/client.js CHANGED
@@ -93,6 +93,9 @@ window.__ModuleLoader__.load({
93
93
  'action.importHint': '从 JSON 文件导入:同 id 覆盖、新 id 追加,不删除现有待办',
94
94
  'settings.title': '设置',
95
95
  'settings.context': '上下文',
96
+ 'settings.capture': '抓取权重',
97
+ 'settings.captureWeight': '低于此权重不登记',
98
+ 'settings.captureWeightHint': '批量抓取时,权重低于这个值的条目直接跳过(0–1,默认 0.35)。犹豫措辞、纯疑问句、没有任何补充信息的条目权重低。',
96
99
  'settings.reminders': '到点提醒',
97
100
  'settings.remindersEnable': '开启到点提醒',
98
101
  'settings.lead': '提前(分钟)',
@@ -393,6 +396,9 @@ window.__ModuleLoader__.load({
393
396
  'action.importHint': 'Import a JSON file: same ids are updated, new ids appended, nothing deleted',
394
397
  'settings.title': 'Settings',
395
398
  'settings.context': 'Context',
399
+ 'settings.capture': 'Capture weight',
400
+ 'settings.captureWeight': 'Do not register below',
401
+ 'settings.captureWeightHint': 'Batch capture skips a candidate whose weight is below this value (0–1, default 0.35). Hedged wording, bare questions and one-liners without detail score low.',
396
402
  'settings.reminders': 'Due reminders',
397
403
  'settings.remindersEnable': 'Announce deadlines',
398
404
  'settings.lead': 'Lead time (min)',
@@ -2548,6 +2554,8 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
2548
2554
  const [tokenDraft, setTokenDraft] = useState('')
2549
2555
  const [ingestDraft, setIngestDraft] = useState(false)
2550
2556
  const [belowDraft, setBelowDraft] = useState('0.8')
2557
+ // Capture weight threshold: candidates the batch tool should not register at all.
2558
+ const [minWeightDraft, setMinWeightDraft] = useState('0.35')
2551
2559
  const [remindersDraft, setRemindersDraft] = useState(true)
2552
2560
  const [leadDraft, setLeadDraft] = useState('0')
2553
2561
  const [notificationPerm, setNotificationPerm] = useState(
@@ -3525,6 +3533,8 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
3525
3533
  const current = state.health?.settings ?? {}
3526
3534
  setIngestDraft(current.ingest?.enabled === true)
3527
3535
  setBelowDraft(String(current.ingest?.confirmBelow ?? 0.8))
3536
+ // Read from /health, which reports the value the capture tool is actually using.
3537
+ setMinWeightDraft(String(state.health?.settings?.captureMinWeight ?? 0.35))
3528
3538
  setRemindersDraft(current.remindersEnabled !== false)
3529
3539
  setLeadDraft(String(current.reminderLeadMinutes ?? 0))
3530
3540
  setTokenDraft('')
@@ -3547,6 +3557,7 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
3547
3557
  reminderLeadMinutes: Number(leadDraft),
3548
3558
  ingestEnabled: ingestDraft,
3549
3559
  ingestConfirmBelow: Number(belowDraft),
3560
+ captureMinWeight: Number(minWeightDraft),
3550
3561
  ...(tokenDraft.trim() === '' ? {} : { ingestToken: tokenDraft.trim() }),
3551
3562
  },
3552
3563
  })
@@ -3842,6 +3853,27 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
3842
3853
  }),
3843
3854
  ),
3844
3855
  ),
3856
+ h(
3857
+ 'div',
3858
+ { className: 'gt-settings-group' },
3859
+ h('div', { className: 'gt-settings-group-title' }, t('settings.capture')),
3860
+ h(
3861
+ 'div',
3862
+ { className: 'gt-field-row' },
3863
+ h('label', { className: 'gt-field-label', htmlFor: 'gt-min-weight' }, t('settings.captureWeight')),
3864
+ h('input', {
3865
+ id: 'gt-min-weight',
3866
+ type: 'number',
3867
+ min: 0,
3868
+ max: 1,
3869
+ step: 0.05,
3870
+ className: 'gt-input gt-input-num',
3871
+ value: minWeightDraft,
3872
+ onChange: (event) => setMinWeightDraft(event.target.value),
3873
+ }),
3874
+ ),
3875
+ h('div', { className: 'gt-hint' }, t('settings.captureWeightHint')),
3876
+ ),
3845
3877
  h(
3846
3878
  'div',
3847
3879
  { className: 'gt-settings-group' },
package/lib/index.js CHANGED
@@ -37,7 +37,7 @@ import { createStore, resolveStorePath } from './store.js'
37
37
  import { createTools } from './tools.js'
38
38
 
39
39
  /** Must equal `package.json`'s version; `npm run check` enforces that. */
40
- export const VERSION = '0.12.13'
40
+ export const VERSION = '0.13.0'
41
41
 
42
42
  /** Plugin id. Also the Loader row id in `cordis.patch.yml`. */
43
43
  export const name = 'dsh-global-todo'
@@ -502,7 +502,7 @@ export function apply(ctx, config) {
502
502
  ctx.inject(['tools'], (toolCtx) => {
503
503
  toolCtx.effect(() => {
504
504
  try {
505
- const definitions = createTools(service, { metrics })
505
+ const definitions = createTools(service, { metrics, settings })
506
506
  const disposers = definitions.map((definition) => toolCtx.tools.register(definition))
507
507
  toolState.registered = definitions.map((definition) => definition.name)
508
508
  toolState.error = null
package/lib/routes.js CHANGED
@@ -467,6 +467,9 @@ export function createRoutes(deps) {
467
467
  // cannot tell whether the idle gate is deployed at all.
468
468
  idleMinutes: deps.settings?.get?.().idleMinutes ?? 30,
469
469
  cooldownMinutes: deps.settings?.get?.().cooldownMinutes ?? 30,
470
+ // Shown and edited by the panel; the capture tool reads the same value when it
471
+ // decides whether a candidate is worth registering at all.
472
+ captureMinWeight: deps.settings?.get?.().captureMinWeight ?? 0.35,
470
473
  // The exempted workspaces, so the panel can show which conversations are out of
471
474
  // scope instead of leaving the person to guess why nothing was ever asked.
472
475
  exemptWorkspaces: deps.settings?.get?.().exemptWorkspaces ?? [],
@@ -747,7 +750,7 @@ export function createRoutes(deps) {
747
750
  * @param {Record<string, unknown>} body
748
751
  */
749
752
  function assertSettingsShape(body) {
750
- const known = ['autoCollect', 'idleMinutes', 'cooldownMinutes', 'exemptWorkspaces', 'ingestEnabled', 'ingestSources', 'ingestConfirmBelow', 'ingestToken', 'remindersEnabled', 'reminderLeadMinutes']
753
+ const known = ['autoCollect', 'idleMinutes', 'cooldownMinutes', 'exemptWorkspaces', 'ingestEnabled', 'ingestSources', 'ingestConfirmBelow', 'ingestToken', 'remindersEnabled', 'reminderLeadMinutes', 'captureMinWeight']
751
754
  for (const key of Object.keys(body)) {
752
755
  if (!known.includes(key)) {
753
756
  throw new TodoError('invalid-body', '未知的设置项 ' + key + ';可写的是 ' + known.join(' / '))
@@ -789,6 +792,7 @@ function assertSettingsRange(body) {
789
792
  cooldownMinutes: [5, 24 * 60, true],
790
793
  ingestConfirmBelow: [0, 1, false],
791
794
  reminderLeadMinutes: [0, 24 * 60, true],
795
+ captureMinWeight: [0, 1, false],
792
796
  }
793
797
  for (const [field, [low, high, whole]] of Object.entries(ranges)) {
794
798
  const value = body[field]
package/lib/service.js CHANGED
@@ -616,6 +616,8 @@ export function createService(options) {
616
616
  '',
617
617
  '该记的:等对方回复、待确认的决定、答应别人的事、还没收尾且需要另开一个会话继续的一步。',
618
618
  '每条必要时补描述、挂上相关文档**绝对路径**或网址,并标级别(P0 紧急 / P1 高 / P2 常规 / P3 低)。',
619
+ '工具会给每条算一个 0–1 的权重,低于设置的 captureMinWeight 会被跳过(犹豫措辞、纯疑问句、只有一句话没有细节的条目权重低)——'
620
+ + '所以标题写具体、带上原话和截止时间,比多写几条更有用。',
619
621
  '',
620
622
  '**带上原话**(重要):每条用 `evidence` 逐字引用用户说的那一句(最多 ' + LIMITS.evidence + ' 字,不要改写、不要总结)。',
621
623
  '以后看到这条待办的人,要能一眼核对「有没有听错」;没有原话的抓取,只是一条无从核对的转述。',
package/lib/settings.js CHANGED
@@ -11,6 +11,7 @@
11
11
  import { chmod, readFile } from 'node:fs/promises'
12
12
  import { isAbsolutePath } from './links.js'
13
13
  import { atomicWrite } from './store.js'
14
+ import { DEFAULT_MIN_WEIGHT, normalizeThreshold } from './weight.js'
14
15
 
15
16
  /** How many sessions the cooldown bookkeeping remembers. */
16
17
  const ASKED_LIMIT = 50
@@ -72,6 +73,14 @@ export const SETTINGS_DEFAULTS = Object.freeze({
72
73
  remindersEnabled: true,
73
74
  /** Announce this many minutes before the deadline (0 = at the deadline). */
74
75
  reminderLeadMinutes: 0,
76
+ /**
77
+ * Candidates below this weight are not registered by the batch capture tool.
78
+ *
79
+ * The tool asks a model for "anything worth following up", and models oblige with hedged
80
+ * asides. The threshold is the difference between a list a person reads and one they skim;
81
+ * 0.35 sits just above a bare, hedged one-liner and below a stated errand.
82
+ */
83
+ captureMinWeight: 0.35,
75
84
  })
76
85
 
77
86
  /**
@@ -132,6 +141,7 @@ export function normalizeSettings(raw) {
132
141
  ingestConfirmBelow: Number.isFinite(below) ? Math.min(Math.max(below, 0), 1) : SETTINGS_DEFAULTS.ingestConfirmBelow,
133
142
  remindersEnabled: source.remindersEnabled !== false,
134
143
  reminderLeadMinutes: Number.isFinite(lead) ? Math.min(Math.max(lead, 0), 24 * 60) : SETTINGS_DEFAULTS.reminderLeadMinutes,
144
+ captureMinWeight: normalizeThreshold(source.captureMinWeight, DEFAULT_MIN_WEIGHT),
135
145
  }
136
146
  }
137
147
 
package/lib/tools.js CHANGED
@@ -14,6 +14,7 @@
14
14
  */
15
15
 
16
16
  import { CAPTURE_REASONS, findDuplicate } from './capture.js'
17
+ import { DEFAULT_MIN_WEIGHT, scoreItem } from './weight.js'
17
18
  import { asTodoError } from './errors.js'
18
19
  import { formatLink } from './links.js'
19
20
  import { LIMITS, PRIORITIES, PRIORITY_LABELS, STATUSES } from './model.js'
@@ -326,6 +327,12 @@ function originOf(exec) {
326
327
  */
327
328
  export function createTools(service, options = {}) {
328
329
  const now = options.now ?? (() => Date.now())
330
+ // Read lazily: the settings object is created before the tools and its value can change
331
+ // at runtime, so a captured snapshot at mount would freeze the threshold.
332
+ const captureMinWeight = () => {
333
+ const value = options.settings?.get?.()?.captureMinWeight
334
+ return typeof value === 'number' && Number.isFinite(value) ? value : DEFAULT_MIN_WEIGHT
335
+ }
329
336
  // Optional counters for /health; when absent the definitions ship untouched.
330
337
  const metrics = options.metrics
331
338
 
@@ -376,7 +383,9 @@ export function createTools(service, options = {}) {
376
383
  '把一次工作里攒下的多件事**一次性**记进全局待办(批量抓取)。'
377
384
  + '两种时机用它:用户说「抓一下待办 / 整理一下要跟进的」,你扫一遍最近的对话把该跟进的事提出来;'
378
385
  + '或者你自己在一轮里发现好几件需要跨会话跟进的事。'
379
- + '逐条校验:某条不合法只跳过那一条并在 skipped 里说明原因,其余照常写入,不用整批重来。',
386
+ + '逐条校验:某条不合法只跳过那一条并在 skipped 里说明原因,其余照常写入,不用整批重来。'
387
+ + '低于权重阈值(设置里的 captureMinWeight,默认 0.35)的条目不会登记,skipped 里会给出权重——'
388
+ + '犹豫语(可能/也许/看看/再说)、纯疑问句、没有任何补充信息的条目会被判低。',
380
389
  parameters: {
381
390
  type: 'object',
382
391
  properties: {
@@ -443,7 +452,19 @@ export function createTools(service, options = {}) {
443
452
  existing = []
444
453
  }
445
454
  const nowMs = now()
455
+ // The weight gate comes first: a hedged aside is not worth a dedupe lookup, and the
456
+ // skip reason should say what it was missing rather than "duplicate".
457
+ const threshold = captureMinWeight()
446
458
  for (const item of items) {
459
+ const { weight, signals } = scoreItem(item)
460
+ if (weight < threshold) {
461
+ const why = signals.filter((signal) => signal.delta < 0).map((signal) => signal.note).join('、')
462
+ skipped.push({
463
+ title: String(item?.title ?? '(无标题)'),
464
+ error: `权重 ${weight.toFixed(2)} 低于阈值 ${threshold},未登记${why === '' ? '' : `(${why})`}`,
465
+ })
466
+ continue
467
+ }
447
468
  const verdict = findDuplicate({ title: item?.title, existing, now: nowMs })
448
469
  if (verdict.duplicate === true) {
449
470
  skipped.push({
package/lib/weight.js ADDED
@@ -0,0 +1,87 @@
1
+ /**
2
+ * How much a captured candidate looks like a real todo.
3
+ *
4
+ * The batch capture tool is asked for "anything worth following up", and models oblige:
5
+ * they return hedged observations ("maybe look at the log later"), questions, and the
6
+ * occasional aside. Rather than trusting the model's own ordering, each candidate gets a
7
+ * small, explicit weight from signals a person would recognise, and anything below the
8
+ * configured threshold is reported as skipped instead of filling the list.
9
+ *
10
+ * The score is deliberately explainable: every contribution is named, so the skip reason
11
+ * can say *why* something was left out rather than "low quality".
12
+ *
13
+ * @module dsh-global-todo/weight
14
+ */
15
+
16
+ /** A phrase that reads like a commitment rather than an observation. */
17
+ const COMMITMENT = ['记得', '别忘', '回头', '下周', '明天', '后天', '截止', 'deadline', '要交', '待确认', '等回复', '跟进', '安排', '发我', '确认一下', '记得要', '别忘了']
18
+
19
+ /** A phrase that reads like thinking aloud. */
20
+ const HESITATION = ['可能', '也许', '考虑', '有空', '再说', '看看', '随便', '或许', '不确定', 'optional']
21
+
22
+ /** Default threshold; a bare one-line observation scores below it. */
23
+ export const DEFAULT_MIN_WEIGHT = 0.35
24
+
25
+ const clamp01 = (value) => Math.min(1, Math.max(0, value))
26
+
27
+ /**
28
+ * @param {unknown} item - a candidate as the capture tool receives it
29
+ * @param {{ now?: number }} [options]
30
+ * @returns {{ weight: number, signals: { id: string, delta: number, note: string }[] }}
31
+ */
32
+ export function scoreItem(item, options = {}) {
33
+ const signals = []
34
+ const add = (id, delta, note) => signals.push({ id, delta, note })
35
+ if (item === null || typeof item !== 'object' || Array.isArray(item)) {
36
+ return { weight: 0, signals: [{ id: 'shape', delta: 0, note: '不是对象' }] }
37
+ }
38
+ const title = typeof item.title === 'string' ? item.title.trim() : ''
39
+ const description = typeof item.description === 'string' ? item.description.trim() : ''
40
+ const text = `${title} ${description}`
41
+ const length = [...title].length
42
+
43
+ // The title says how specific the errand is. A one-character title cannot.
44
+ if (length >= 10) add('specific', 0.15, '标题足够具体')
45
+ else if (length >= 6) add('specific', 0.1, '标题较具体')
46
+ else if (length >= 3) add('specific', 0.05, '标题偏短')
47
+ else add('tiny', -0.15, '标题几乎没有信息')
48
+
49
+ const due = typeof item.dueAt === 'string' && Number.isFinite(Date.parse(item.dueAt))
50
+ if (due) add('due', 0.2, '有可解析的截止时间')
51
+
52
+ if (Array.isArray(item.steps) && item.steps.length > 0) add('steps', 0.15, '已经拆成步骤')
53
+ if (Array.isArray(item.links) && item.links.length > 0) add('links', 0.1, '挂了文档或地址')
54
+ if (typeof item.evidence === 'string' && item.evidence.trim() !== '') add('evidence', 0.1, '引用了原话')
55
+ if (item.priority === 'urgent') add('priority', 0.15, '标了 P0')
56
+ else if (item.priority === 'high') add('priority', 0.1, '标了 P1')
57
+
58
+ const hesitation = HESITATION.find((word) => text.includes(word))
59
+ if (hesitation !== undefined) add('hesitation', -0.2, `含犹豫语「${hesitation}」`)
60
+ // A hedged sentence does not also earn the commitment bonus: 「回头再说吧,有空看看」 contains
61
+ // 「回头」, and crediting it for that is exactly how an aside becomes a todo.
62
+ const commitment = hesitation === undefined ? COMMITMENT.find((word) => text.includes(word)) : undefined
63
+ if (commitment !== undefined) add('commitment', 0.15, `含承诺语「${commitment}」`)
64
+
65
+ if (/[??]\s*$/.test(title)) add('question', -0.15, '标题是疑问句')
66
+
67
+ // A naked one-liner with nothing attached is the shape of an aside.
68
+ if (description === '' && !due && !(Array.isArray(item.steps) && item.steps.length > 0) && !(Array.isArray(item.links) && item.links.length > 0)) {
69
+ add('naked', -0.1, '没有任何补充信息')
70
+ }
71
+
72
+ // The base is what a bare, plausible errand scores: specific enough to state, with nothing
73
+ // attached yet. Everything above or below that is a named signal.
74
+ const weight = clamp01(Math.round((0.45 + signals.reduce((sum, signal) => sum + signal.delta, 0)) * 100) / 100)
75
+ return { weight, signals }
76
+ }
77
+
78
+ /**
79
+ * @param {unknown} weight
80
+ * @param {number} [fallback]
81
+ * @returns {number} a threshold in 0..1
82
+ */
83
+ export function normalizeThreshold(weight, fallback = DEFAULT_MIN_WEIGHT) {
84
+ const value = Number(weight)
85
+ if (!Number.isFinite(value)) return fallback
86
+ return clamp01(Math.round(value * 100) / 100)
87
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-global-todo",
3
- "version": "0.12.13",
3
+ "version": "0.13.0",
4
4
  "icon": "./docs/icon.svg",
5
5
  "description": "Cross-session todo list for DeepSeek Harness: one JSON file a person and an agent both write to, with step plans that ship as a numbered hand-off, session read-back, and a human confirmation before an agent's \"done\" counts.",
6
6
  "type": "module",