dsh-personal-system-rules 0.5.1 → 0.7.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,9 +1,28 @@
1
1
  # 更新日志
2
2
 
3
- ## 0.5.1 — 2026-11-04
3
+ ## 0.7.0 — 2026-11-05
4
4
 
5
+ - 静默守卫从「步数」改为「时长」:新增 `maxSilentSeconds`(默认 60 秒,`0` 关闭),按最后一条可见文字消息到当前时刻的间隔判定,同一段沉默每再满一个阈值提醒一次。提醒文案的 `{count}` 现在是秒数。
6
+ - 原因(会话 `session-2debd25a` 实测 258 个 step、43 次提醒):43 次里有 41 次发生在距上次说明不足 60 秒(中位 18 秒、最小 7 秒),而 18 个「已沉默 ≥60 秒」的 step 中有 16 个完全没提醒,最长沉默 629 秒。步数口径既太快打断,也看不见单步长耗时造成的长时间沉默。
7
+ - 新增纯函数 `silentMs(agent, now)`(读 surface 事件自带的时间戳,无可见文字时回落到本轮起点),`test/loop-guard.test.js` 覆盖测量与回落,`test/guards.test.js` 覆盖阈值、重置与分段提醒。
8
+ - 设置页字段改为「多久没有可见输出就提醒(秒)」,中英文案、README、locale 描述同步。
9
+ - 旧的 `maxSilentSteps` 键保留在 profile 里也无害(schemastery 不报未知键),但不再生效;迁移时换成期望的秒数。
10
+
11
+ ## 0.6.1 — 2026-11-05
12
+
13
+ - 修复任务状态守卫从不触发的问题:`staleTodoSteps()` 原先在 `session.surface.nodes` 里查找 `todo/write` 事件,但 surface 只承载消息类事件(`system`/`developer`/`user`/`assistant` 消息与 `tool/result`),`todo/write` 是仅落日志的事件,永远不会出现在 surface 上,于是每步都被判成「本轮没写列表」,守卫一直静默。
14
+ - 改为读取 assistant 消息里模型自己的 `todo_write` 工具调用(`arguments.todos`)作为列表来源,步数从最近一次该调用算起;日志里的 `todo/write` 事件不再参与判断。
15
+ - 回放会话 `session-2debd25a` 的真实 surface:修复前注入 0 次,修复后在第 6/12/18/24/30/36 步各注入一次。
16
+ - `test/loop-guard.test.js`、`test/guards.test.js` 的用例改为真实形状(`todo_write` 工具调用),并新增「仅落日志的 `todo/write` 事件不构成列表」「以最近一次调用为准」「非法 arguments 不误判」三个回归用例。
17
+
18
+ ## 0.6.0 — 2026-11-04
19
+
20
+ - 新增任务状态守卫:本轮的 todo 列表里还有 `in_progress` 项、且连续 `maxStaleTodoSteps` 步没有重写列表时,经 `agent/pre-step` 注入一条提醒;之后每再累积同样步数提醒一次。默认 6 步,`0` 关闭。
21
+ - 本轮没有 todo 列表(短任务可以主动跳过)或列表已全部 `completed` 时都不触发,避免把有意不写列表的任务也当成「没更新」。
22
+ - 设置页「守卫」卡片新增「任务状态」分组,阈值与文案都可在弹窗编辑,文案支持 `{count}`(未更新步数)与 `{inProgress}`(进行中项数)。
23
+ - 检测逻辑新增纯函数 `staleTodoSteps()`,`test/loop-guard.test.js` 与 `test/guards.test.js` 各有覆盖。
5
24
  - 声明并验证对 DeepSeek Harness 0.2.2 预发布版的支持:`settings.section` 与 `plugins.bundle.config` 的注册契约、`describe/mutate` 设置读写、locale 的 `register(ns, {zh, en})` 形式、`loader/volatile-update` 热更新路径在 0.2.2 均未变。`engines.dsh` 已覆盖该版本。
6
- - 不使用 Session Projection、spill 策略或 `dsh-fs-observation-policy`,因此 0.2.2 对这几项的改动不命中本插件。无代码改动,已用 DSH 0.2.2-rc.1 内核验证加载与全部测试。
25
+ - 不使用 Session Projection、spill 策略或 `dsh-fs-observation-policy`,因此 0.2.2 对这几项的改动不命中本插件。已用 DSH 0.2.2-rc.1 内核验证加载与全部测试。
7
26
 
8
27
  ## 0.5.0 — 2026-10-31
9
28
 
package/README.md CHANGED
@@ -2,18 +2,19 @@
2
2
 
3
3
  [![CI](https://github.com/NingYuanLee/dsh-personal-system-rules/actions/workflows/ci.yml/badge.svg)](https://github.com/NingYuanLee/dsh-personal-system-rules/actions/workflows/ci.yml) [![npm](https://img.shields.io/npm/v/dsh-personal-system-rules.svg)](https://www.npmjs.com/package/dsh-personal-system-rules) [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
4
4
 
5
- 把个性化规则文件注入 DeepSeek Harness(dsh)的**系统提示**,并提供**静默提醒**与**重复操作循环检测**;规则正文与守卫参数都能在设置弹窗「个性化规则」里编辑。
5
+ 把个性化规则文件注入 DeepSeek Harness(dsh)的**系统提示**,并提供**静默提醒**、**重复操作循环检测**与**任务状态守卫**;规则正文与守卫参数都能在设置弹窗「个性化规则」里编辑。
6
6
 
7
- A dsh plugin that injects a personal rules file as a system-prompt section and adds silent-step / repeated-action loop guards. The rules body and guard options are editable from the dsh settings dialog.
7
+ A dsh plugin that injects a personal rules file as a system-prompt section and adds a silent (elapsed-time) reminder, a repeated-action loop guard and a stale-todo guard. The rules body and guard options are editable from the dsh settings dialog.
8
8
 
9
9
  ## 功能
10
10
 
11
11
  1. **系统提示段**:把 `~/.dsh/PERSONAL-SYSTEM-RULES.md`(或 `config.file` 指定文件)注册成段 `user:agents-md`,order 默认 10150,原样注入;`watchFile` 1 秒轮询,改完下一次模型请求生效,不用重启。
12
- 2. **静默提醒**:连续 `maxSilentSteps`(默认 2)步没有可见文本时,注入一条提醒要求模型说明进展;同一条链只提醒一次,`0` 关闭。
12
+ 2. **静默提醒**:距离上一次**可见文字**超过 `maxSilentSeconds`(默认 60 秒)时,注入一条提醒要求模型说明进展;同一段沉默每再满一个阈值提醒一次,`0` 关闭。判定用时长而不是步数:单个长耗时步骤不会再掩盖十分钟的沉默,两步之间的十几秒也不会提前打断。
13
13
  3. **循环检测**:连续 `repeatLimit` 步执行**完全相同**的工具调用(工具名 + 归一化参数)时触发,`repeatAction` 可选 `remind`(提醒)或 `stop`(像用户点停止一样中止本轮并保留排队输入);默认 0 关闭。
14
- 4. **设置弹窗页面**:`设置 → 个性化规则` 与插件详情页是同一个页面,含「规则正文」和「守卫」两块;保存经官方 `settings` 机制写回 profile patch 并热更新。
14
+ 4. **任务状态守卫**:本轮的 todo 列表里还有 `in_progress` 项、且连续 `maxStaleTodoSteps`(默认 6)步没有重写列表时提醒一次,之后再每隔同样步数提醒;本轮没建列表(短任务)时不打扰,全部完成时也不触发。`0` 关闭。
15
+ 5. **设置弹窗页面**:`设置 → 个性化规则` 与插件详情页是同一个页面,含「规则正文」和「守卫」两块;保存经官方 `settings` 机制写回 profile patch 并热更新。
15
16
 
16
- 静默提醒与循环检测共用同一个 `agent/pre-step` 钩子;检测逻辑是纯函数,独立测试。
17
+ 三个守卫共用同一个 `agent/pre-step` 钩子;检测逻辑是纯函数,独立测试。
17
18
 
18
19
  ## 安装
19
20
 
@@ -27,13 +28,13 @@ dsh plugin --profile my-profile add dsh-personal-system-rules
27
28
  **从 git(固定 tag):**
28
29
 
29
30
  ```powershell
30
- dsh plugin --profile my-profile add "git+https://github.com/NingYuanLee/dsh-personal-system-rules.git#v0.5.0"
31
+ dsh plugin --profile my-profile add "git+https://github.com/NingYuanLee/dsh-personal-system-rules.git#v0.6.0"
31
32
  ```
32
33
 
33
34
  **从 GitHub Release tarball:**
34
35
 
35
36
  ```powershell
36
- dsh plugin --profile my-profile add "https://github.com/NingYuanLee/dsh-personal-system-rules/releases/download/v0.5.0/dsh-personal-system-rules-0.5.0.tgz"
37
+ dsh plugin --profile my-profile add "https://github.com/NingYuanLee/dsh-personal-system-rules/releases/download/v0.6.0/dsh-personal-system-rules-0.6.0.tgz"
37
38
  ```
38
39
 
39
40
  包没有 `prepare` / `postinstall` 脚本,安装不执行构建代码。
@@ -48,16 +49,20 @@ dsh plugin --profile my-profile add "https://github.com/NingYuanLee/dsh-personal
48
49
  header: |
49
50
  # 用户全局系统层规则
50
51
  以下内容来自用户全局 PERSONAL-SYSTEM-RULES.md,是系统层指令,必须遵守。
51
- maxSilentSteps: 2
52
+ maxSilentSeconds: 60
52
53
  silentReminder: ''
53
54
  repeatLimit: 0
54
55
  repeatAction: remind
55
56
  repeatReminder: ''
57
+ maxStaleTodoSteps: 6
58
+ staleTodoReminder: ''
56
59
  ```
57
60
 
58
- - `maxSilentSteps`、`silentReminder`、`repeatLimit`、`repeatAction`、`repeatReminder` 是 `volatile()` 字段,可在设置弹窗编辑并热更新。
61
+ - `maxSilentSeconds`、`silentReminder`、`repeatLimit`、`repeatAction`、`repeatReminder`、`maxStaleTodoSteps`、`staleTodoReminder` 是 `volatile()` 字段,可在设置弹窗编辑并热更新。
62
+ - 0.7.0 起静默提醒按秒判定,旧的 `maxSilentSteps` 键不再生效(仍可留在 patch 里,会被忽略);把阈值换成期望的秒数即可。
59
63
  - `file`、`header`、`order` 是普通配置,只在 `cordis.patch.yml` 里改;改 `file` 后需要重载插件。
60
64
  - `repeatLimit: 0` 关闭循环检测;文本回复或新用户轮次会重置重复计数。
65
+ - `maxStaleTodoSteps: 0` 关闭任务状态守卫;提醒文案可含 `{count}`(未更新的步数)与 `{inProgress}`(进行中的项数)。
61
66
  - 设置弹窗保存会写回 profile 的 `cordis.patch.yml`。
62
67
 
63
68
  ## 兼容性
package/client.js CHANGED
@@ -8,8 +8,9 @@ const PKG = "dsh-personal-system-rules";
8
8
  const SETTINGS_NS = "system-agents-md";
9
9
  const READ = "systemAgentsMd/read";
10
10
  const WRITE = "systemAgentsMd/write";
11
- const DEFAULT_REMINDER = "\u4F60\u5DF2\u7ECF\u8FDE\u7EED {count} \u6B65\u6CA1\u6709\u5411\u7528\u6237\u8BF4\u660E\u8FDB\u5C55\u3002\u8BF7\u5728\u672C\u6B65\u5F00\u5934\u7528\u4E00\u53E5\u8BDD\u8BF4\u660E\u6B63\u5728\u505A\u4EC0\u4E48\u3001\u4E3A\u4EC0\u4E48\uFF0C\u518D\u7EE7\u7EED\u8C03\u7528\u5DE5\u5177\u3002";
11
+ const DEFAULT_REMINDER = "\u4F60\u5DF2\u7ECF {count} \u79D2\u6CA1\u6709\u5411\u7528\u6237\u8BF4\u660E\u8FDB\u5C55\u3002\u8BF7\u5728\u672C\u6B65\u5F00\u5934\u7528\u4E00\u53E5\u8BDD\u8BF4\u660E\u6B63\u5728\u505A\u4EC0\u4E48\u3001\u4E3A\u4EC0\u4E48\uFF0C\u518D\u7EE7\u7EED\u8C03\u7528\u5DE5\u5177\u3002";
12
12
  const DEFAULT_REPEAT_REMINDER = "\u4F60\u5DF2\u7ECF\u8FDE\u7EED {count} \u6B65\u6267\u884C\u5B8C\u5168\u76F8\u540C\u7684\u64CD\u4F5C\u3002\u8BF7\u505C\u6B62\u91CD\u590D\uFF1A\u5148\u8BF4\u660E\u5F53\u524D\u963B\u585E\u548C\u5DF2\u5B8C\u6210\u7684\u90E8\u5206\uFF0C\u518D\u6362\u4E00\u79CD\u505A\u6CD5\uFF1B\u5982\u679C\u786E\u5B9E\u9700\u8981\u7EE7\u7EED\u540C\u4E00\u64CD\u4F5C\uFF0C\u8BF7\u5148\u5411\u7528\u6237\u8BF4\u660E\u539F\u56E0\u3002";
13
+ const DEFAULT_STALE_TODO_REMINDER = "\u4EFB\u52A1\u5217\u8868\u91CC\u6709 {inProgress} \u9879\u4ECD\u6807\u7740\u8FDB\u884C\u4E2D\uFF0C\u4F46\u5DF2\u8FDE\u7EED {count} \u6B65\u6CA1\u6709\u66F4\u65B0\u3002\u8BF7\u91CD\u5199 todo \u5217\u8868\uFF1A\u628A\u5DF2\u5B8C\u6210\u7684\u9879\u6807\u6210 completed\uFF0C\u53EA\u7559\u4E0B\u771F\u6B63\u5728\u505A\u7684\u4E00\u9879\uFF0C\u518D\u7EE7\u7EED\u3002";
13
14
  const zh = {
14
15
  nav: "\u4E2A\u6027\u5316\u89C4\u5219",
15
16
  title: "\u4E2A\u6027\u5316\u89C4\u5219",
@@ -19,10 +20,10 @@ const zh = {
19
20
  rulesTitle: "\u89C4\u5219\u6B63\u6587",
20
21
  guardTitle: "\u5B88\u536B",
21
22
  silentTitle: "\u9759\u9ED8\u63D0\u9192",
22
- guardStepsLabel: "\u8FDE\u7EED\u65E0\u53EF\u89C1\u6587\u672C\u7684\u6B65\u6570\u9608\u503C",
23
- guardStepsHint: "\u8FBE\u5230\u8BE5\u6B65\u6570\u65F6\u63D0\u9192\u4E00\u6B21\uFF1B0 \u8868\u793A\u5173\u95ED\u3002\u9ED8\u8BA4 2\u3002",
23
+ guardStepsLabel: "\u591A\u4E45\u6CA1\u6709\u53EF\u89C1\u8F93\u51FA\u5C31\u63D0\u9192\uFF08\u79D2\uFF09",
24
+ guardStepsHint: "\u8FBE\u5230\u8BE5\u65F6\u957F\u65F6\u63D0\u9192\u4E00\u6B21\uFF0C\u4E4B\u540E\u6BCF\u518D\u6EE1\u540C\u6837\u65F6\u957F\u63D0\u9192\u4E00\u6B21\uFF1B0 \u8868\u793A\u5173\u95ED\u3002\u9ED8\u8BA4 60 \u79D2\u3002",
24
25
  guardReminderLabel: "\u63D0\u9192\u6587\u6848",
25
- guardReminderHint: "\u53EF\u7528 {count} \u8868\u793A\u5F53\u524D\u8FDE\u7EED\u6B65\u6570\uFF1B\u7559\u7A7A\u4F7F\u7528\u9ED8\u8BA4\u6587\u6848\u3002",
26
+ guardReminderHint: "\u53EF\u7528 {count} \u8868\u793A\u5F53\u524D\u5DF2\u9759\u9ED8\u7684\u79D2\u6570\uFF1B\u7559\u7A7A\u4F7F\u7528\u9ED8\u8BA4\u6587\u6848\u3002",
26
27
  loopTitle: "\u5FAA\u73AF\u68C0\u6D4B",
27
28
  loopLimitLabel: "\u540C\u4E00\u64CD\u4F5C\u7684\u6700\u5927\u91CD\u590D\u6B21\u6570",
28
29
  loopLimitHint: "\u8FDE\u7EED\u8FD9\u4E48\u591A\u6B65\u6267\u884C\u5B8C\u5168\u76F8\u540C\u7684\u5DE5\u5177\u8C03\u7528\u65F6\u89E6\u53D1\uFF1B0 \u8868\u793A\u5173\u95ED\uFF0C\u9ED8\u8BA4\u5173\u95ED\u3002",
@@ -31,6 +32,11 @@ const zh = {
31
32
  loopActionStop: "\u76F4\u63A5\u53EB\u505C\u672C\u8F6E",
32
33
  loopReminderLabel: "\u63D0\u9192\u6587\u6848",
33
34
  loopReminderHint: "\u53EF\u7528 {count} \u8868\u793A\u5DF2\u91CD\u590D\u7684\u6B65\u6570\uFF1B\u7559\u7A7A\u4F7F\u7528\u9ED8\u8BA4\u6587\u6848\u3002",
35
+ staleTitle: "\u4EFB\u52A1\u72B6\u6001",
36
+ staleStepsLabel: "\u4EFB\u52A1\u5217\u8868\u672A\u66F4\u65B0\u7684\u6B65\u6570\u9608\u503C",
37
+ staleStepsHint: "\u5217\u8868\u91CC\u8FD8\u6709\u300C\u8FDB\u884C\u4E2D\u300D\u7684\u9879\u3001\u4E14\u8FDE\u7EED\u8FD9\u4E48\u591A\u6B65\u6CA1\u6709\u91CD\u5199 todo \u65F6\u63D0\u9192\uFF1B0 \u8868\u793A\u5173\u95ED\u3002\u9ED8\u8BA4 6\u3002",
38
+ staleReminderLabel: "\u63D0\u9192\u6587\u6848",
39
+ staleReminderHint: "\u53EF\u7528 {count} \u8868\u793A\u672A\u66F4\u65B0\u7684\u6B65\u6570\u3001{inProgress} \u8868\u793A\u8FDB\u884C\u4E2D\u7684\u9879\u6570\uFF1B\u7559\u7A7A\u4F7F\u7528\u9ED8\u8BA4\u6587\u6848\u3002",
34
40
  invalidLimit: "\u91CD\u590D\u6B21\u6570\u5FC5\u987B\u662F\u4E0D\u5C0F\u4E8E 0 \u7684\u6574\u6570\u3002",
35
41
  saveGuard: "\u4FDD\u5B58\u5B88\u536B",
36
42
  savingGuard: "\u4FDD\u5B58\u4E2D",
@@ -40,7 +46,7 @@ const zh = {
40
46
  optionsMissing: "\u6CA1\u6709\u627E\u5230\u8FD9\u4E2A\u63D2\u4EF6\u7684\u8BBE\u7F6E\u547D\u540D\u7A7A\u95F4\uFF0C\u5148\u6309\u9ED8\u8BA4\u503C\u663E\u793A\u3002",
41
47
  optionsLoadFailed: "\u8BFB\u53D6\u5DF2\u4FDD\u5B58\u914D\u7F6E\u5931\u8D25\uFF0C\u5148\u6309\u9ED8\u8BA4\u503C\u663E\u793A\uFF1A{message}",
42
48
  optionsFailed: "\u4FDD\u5B58\u5931\u8D25\uFF1A{message}",
43
- invalidSteps: "\u6B65\u6570\u9608\u503C\u5FC5\u987B\u662F\u4E0D\u5C0F\u4E8E 0 \u7684\u6574\u6570\u3002",
49
+ invalidSteps: "\u9759\u9ED8\u9608\u503C\u5FC5\u987B\u662F\u4E0D\u5C0F\u4E8E 0 \u7684\u6574\u6570\uFF08\u79D2\uFF09\u3002",
44
50
  reload: "\u91CD\u65B0\u8BFB\u53D6",
45
51
  save: "\u4FDD\u5B58",
46
52
  saving: "\u4FDD\u5B58\u4E2D",
@@ -65,11 +71,11 @@ const en = {
65
71
  effective: "Effective file",
66
72
  rulesTitle: "Rules body",
67
73
  guardTitle: "Guards",
68
- silentTitle: "Silent-step reminder",
69
- guardStepsLabel: "Consecutive steps without visible text",
70
- guardStepsHint: "Reminds once when reached; 0 disables it. Default 2.",
74
+ silentTitle: "Silent reminder",
75
+ guardStepsLabel: "Seconds without visible output before reminding",
76
+ guardStepsHint: "Reminds once when reached, then once per further interval; 0 disables it. Default 60 seconds.",
71
77
  guardReminderLabel: "Reminder text",
72
- guardReminderHint: "Use {count} for the current streak; leave blank for the default.",
78
+ guardReminderHint: "Use {count} for the seconds of silence; leave blank for the default.",
73
79
  loopTitle: "Loop detection",
74
80
  loopLimitLabel: "Maximum repeats of the same action",
75
81
  loopLimitHint: "Triggers after this many consecutive identical tool-call steps; 0 disables it (default).",
@@ -78,6 +84,11 @@ const en = {
78
84
  loopActionStop: "Stop this turn",
79
85
  loopReminderLabel: "Reminder text",
80
86
  loopReminderHint: "Use {count} for the repeated step count; leave blank for the default.",
87
+ staleTitle: "Task status",
88
+ staleStepsLabel: "Steps without a todo update",
89
+ staleStepsHint: "Triggers while the list still has items in progress and it has not been rewritten for this many steps; 0 disables it. Default 6.",
90
+ staleReminderLabel: "Reminder text",
91
+ staleReminderHint: "Use {count} for the stale step count and {inProgress} for the in-progress items; leave blank for the default.",
81
92
  invalidLimit: "The repeat limit must be an integer of 0 or more.",
82
93
  saveGuard: "Save guards",
83
94
  savingGuard: "Saving",
@@ -87,7 +98,7 @@ const en = {
87
98
  optionsMissing: "This plugin has no settings namespace here; showing defaults.",
88
99
  optionsLoadFailed: "Failed to read the saved config; showing defaults: {message}",
89
100
  optionsFailed: "Save failed: {message}",
90
- invalidSteps: "The step threshold must be an integer of 0 or more.",
101
+ invalidSteps: "The silence threshold must be an integer of 0 or more (seconds).",
91
102
  reload: "Reload",
92
103
  save: "Save",
93
104
  saving: "Saving",
@@ -166,7 +177,7 @@ function findSettingsRow(found) {
166
177
  if (exact) return exact;
167
178
  return rows.find((row) => {
168
179
  const value = row?.value && typeof row.value === "object" ? row.value : row;
169
- return value !== null && typeof value === "object" && (typeof value.maxSilentSteps === "number" || typeof value.silentReminder === "string");
180
+ return value !== null && typeof value === "object" && (typeof value.maxSilentSeconds === "number" || typeof value.silentReminder === "string");
170
181
  }) ?? null;
171
182
  }
172
183
  function takeView(landed, fallbackRevision) {
@@ -191,6 +202,7 @@ function RulesSection(props) {
191
202
  const area = useRef(null);
192
203
  const reminderArea = useRef(null);
193
204
  const loopArea = useRef(null);
205
+ const staleArea = useRef(null);
194
206
  const request = useRef(0);
195
207
  const [configPhase, setConfigPhase] = useState("loading");
196
208
  const [configNs, setConfigNs] = useState(SETTINGS_NS);
@@ -202,6 +214,8 @@ function RulesSection(props) {
202
214
  const [repeatLimitDraft, setRepeatLimitDraft] = useState("");
203
215
  const [repeatActionDraft, setRepeatActionDraft] = useState("remind");
204
216
  const [loopReminderDraft, setLoopReminderDraft] = useState("");
217
+ const [staleStepsDraft, setStaleStepsDraft] = useState("");
218
+ const [staleReminderDraft, setStaleReminderDraft] = useState("");
205
219
  function applyLoaded(value) {
206
220
  setPath(value.path || "");
207
221
  setDraft(value.text || "");
@@ -268,11 +282,13 @@ function RulesSection(props) {
268
282
  }
269
283
  const value = row.value && typeof row.value === "object" && !Array.isArray(row.value) ? row.value : {};
270
284
  setConfigNs(typeof row.ns === "string" && row.ns ? row.ns : SETTINGS_NS);
271
- setStepsDraft(Number.isFinite(value.maxSilentSteps) ? String(value.maxSilentSteps) : "");
285
+ setStepsDraft(Number.isFinite(value.maxSilentSeconds) ? String(value.maxSilentSeconds) : "");
272
286
  setReminderDraft(typeof value.silentReminder === "string" ? value.silentReminder : "");
273
287
  setRepeatLimitDraft(Number.isFinite(value.repeatLimit) && value.repeatLimit > 0 ? String(value.repeatLimit) : "");
274
288
  setRepeatActionDraft(value.repeatAction === "stop" ? "stop" : "remind");
275
289
  setLoopReminderDraft(typeof value.repeatReminder === "string" ? value.repeatReminder : "");
290
+ setStaleStepsDraft(Number.isFinite(value.maxStaleTodoSteps) && value.maxStaleTodoSteps > 0 ? String(value.maxStaleTodoSteps) : "");
291
+ setStaleReminderDraft(typeof value.staleTodoReminder === "string" ? value.staleTodoReminder : "");
276
292
  setRevision(Number.isFinite(row.revision) ? row.revision : 0);
277
293
  setConfigPhase("ready");
278
294
  } catch (error) {
@@ -374,13 +390,25 @@ function RulesSection(props) {
374
390
  }
375
391
  const reminder = reminderDraft.trim() === "" ? "" : reminderDraft;
376
392
  const loopReminder = loopReminderDraft.trim() === "" ? "" : loopReminderDraft;
393
+ const staleText = staleStepsDraft.trim();
394
+ let staleSteps = 0;
395
+ if (staleText !== "") {
396
+ staleSteps = Number(staleText);
397
+ if (!Number.isInteger(staleSteps) || staleSteps < 0) {
398
+ setConfigNote({ tone: "error", text: tr("invalidSteps") });
399
+ return;
400
+ }
401
+ }
402
+ const staleReminder = staleReminderDraft.trim() === "" ? "" : staleReminderDraft;
377
403
  const action = repeatActionDraft === "stop" ? "stop" : "remind";
378
404
  const ops = [
379
- stepsText === "" ? { op: "unset", path: ["maxSilentSteps"] } : { op: "set", path: ["maxSilentSteps"], value: steps },
405
+ stepsText === "" ? { op: "unset", path: ["maxSilentSeconds"] } : { op: "set", path: ["maxSilentSeconds"], value: steps },
380
406
  reminder === "" ? { op: "unset", path: ["silentReminder"] } : { op: "set", path: ["silentReminder"], value: reminder },
381
407
  limitText === "" ? { op: "unset", path: ["repeatLimit"] } : { op: "set", path: ["repeatLimit"], value: limit },
382
408
  action === "remind" ? { op: "unset", path: ["repeatAction"] } : { op: "set", path: ["repeatAction"], value: action },
383
- loopReminder === "" ? { op: "unset", path: ["repeatReminder"] } : { op: "set", path: ["repeatReminder"], value: loopReminder }
409
+ loopReminder === "" ? { op: "unset", path: ["repeatReminder"] } : { op: "set", path: ["repeatReminder"], value: loopReminder },
410
+ staleText === "" ? { op: "unset", path: ["maxStaleTodoSteps"] } : { op: "set", path: ["maxStaleTodoSteps"], value: staleSteps },
411
+ staleReminder === "" ? { op: "unset", path: ["staleTodoReminder"] } : { op: "set", path: ["staleTodoReminder"], value: staleReminder }
384
412
  ];
385
413
  setOptionsBusy(true);
386
414
  setConfigNote(null);
@@ -393,11 +421,13 @@ function RulesSection(props) {
393
421
  const view = takeView(landed, revision);
394
422
  setRevision(view.revision);
395
423
  if (view.value) {
396
- setStepsDraft(Number.isFinite(view.value.maxSilentSteps) ? String(view.value.maxSilentSteps) : "");
424
+ setStepsDraft(Number.isFinite(view.value.maxSilentSeconds) ? String(view.value.maxSilentSeconds) : "");
397
425
  setReminderDraft(typeof view.value.silentReminder === "string" ? view.value.silentReminder : "");
398
426
  setRepeatLimitDraft(Number.isFinite(view.value.repeatLimit) && view.value.repeatLimit > 0 ? String(view.value.repeatLimit) : "");
399
427
  setRepeatActionDraft(view.value.repeatAction === "stop" ? "stop" : "remind");
400
428
  setLoopReminderDraft(typeof view.value.repeatReminder === "string" ? view.value.repeatReminder : "");
429
+ setStaleStepsDraft(Number.isFinite(view.value.maxStaleTodoSteps) && view.value.maxStaleTodoSteps > 0 ? String(view.value.maxStaleTodoSteps) : "");
430
+ setStaleReminderDraft(typeof view.value.staleTodoReminder === "string" ? view.value.staleTodoReminder : "");
401
431
  }
402
432
  setConfigNote({ tone: "ok", text: tr("guardSaved") });
403
433
  } catch (error) {
@@ -586,6 +616,44 @@ function RulesSection(props) {
586
616
  })
587
617
  ),
588
618
  h("p", { className: "dsh-agents-md-hint" }, tr("loopReminderHint")),
619
+ h("p", { className: "dsh-agents-md-group" }, tr("staleTitle")),
620
+ h(
621
+ "label",
622
+ { className: "dsh-agents-md-label" },
623
+ tr("staleStepsLabel"),
624
+ h("input", {
625
+ className: "dsh-agents-md-field",
626
+ type: "number",
627
+ min: "0",
628
+ step: "1",
629
+ value: staleStepsDraft,
630
+ disabled: optionsBusy || configPhase !== "ready",
631
+ onChange: (event) => {
632
+ setStaleStepsDraft(event.target.value);
633
+ setConfigNote(null);
634
+ }
635
+ })
636
+ ),
637
+ h("p", { className: "dsh-agents-md-hint" }, tr("staleStepsHint")),
638
+ h(
639
+ "label",
640
+ { className: "dsh-agents-md-label" },
641
+ tr("staleReminderLabel"),
642
+ h("textarea", {
643
+ ref: staleArea,
644
+ className: "dsh-agents-md-field dsh-agents-md-field-area",
645
+ rows: 3,
646
+ value: staleReminderDraft,
647
+ spellCheck: false,
648
+ disabled: optionsBusy || configPhase !== "ready",
649
+ placeholder: DEFAULT_STALE_TODO_REMINDER,
650
+ onChange: (event) => {
651
+ setStaleReminderDraft(event.target.value);
652
+ setConfigNote(null);
653
+ }
654
+ })
655
+ ),
656
+ h("p", { className: "dsh-agents-md-hint" }, tr("staleReminderHint")),
589
657
  configNote ? h("p", {
590
658
  className: configNote.tone === "ok" ? "dsh-agents-md-ok" : "dsh-agents-md-error",
591
659
  role: configNote.tone === "ok" ? "status" : "alert"
package/index.js CHANGED
@@ -4,7 +4,7 @@ import { homedir } from 'node:os'
4
4
  import { join } from 'node:path'
5
5
  import { pathToFileURL } from 'node:url'
6
6
  import { MAX_BYTES, readAgentsFile, writeAgentsFile } from './file.js'
7
- import { countRepeatSteps } from './loop-guard.js'
7
+ import { countRepeatSteps, silentMs, staleTodoSteps } from './loop-guard.js'
8
8
 
9
9
  /** Cordis plugin name. */
10
10
  export const name = 'system-agents-md'
@@ -14,7 +14,8 @@ export const inject = ['systemPrompt']
14
14
 
15
15
  const SECTION_NAME = 'user:agents-md'
16
16
  const DEFAULT_ORDER = 10150
17
- const DEFAULT_MAX_SILENT_STEPS = 2
17
+ const DEFAULT_MAX_SILENT_SECONDS = 60
18
+ const DEFAULT_MAX_STALE_TODO_STEPS = 6
18
19
  const DEFAULT_RULES_FILE = 'PERSONAL-SYSTEM-RULES.md'
19
20
  const DEFAULT_HEADER = [
20
21
  '# 用户全局系统层规则',
@@ -22,12 +23,15 @@ const DEFAULT_HEADER = [
22
23
  '以下内容来自用户全局 PERSONAL-SYSTEM-RULES.md,是系统层指令,必须遵守;与项目内软性说明冲突时以本节为准。',
23
24
  ].join('\n')
24
25
 
25
- /** Default reminder used when consecutive steps carry no visible text. */
26
- const DEFAULT_SILENT_REMINDER = '你已经连续 {count} 步没有向用户说明进展。请在本步开头用一句话说明正在做什么、为什么,再继续调用工具。'
26
+ /** Default reminder used when the model goes too long without visible text. */
27
+ const DEFAULT_SILENT_REMINDER = '你已经 {count} 秒没有向用户说明进展。请在本步开头用一句话说明正在做什么、为什么,再继续调用工具。'
27
28
 
28
29
  /** Default reminder used when the same tool call repeats beyond the limit. */
29
30
  const DEFAULT_REPEAT_REMINDER = '你已经连续 {count} 步执行完全相同的操作。请停止重复:先说明当前阻塞和已完成的部分,再换一种做法;如果确实需要继续同一操作,请先向用户说明原因。'
30
31
 
32
+ /** Default reminder used when the task list stops moving while items stay in progress. */
33
+ const DEFAULT_STALE_TODO_REMINDER = '任务列表里有 {inProgress} 项仍标着进行中,但已连续 {count} 步没有更新。请重写 todo 列表:把已完成的项标成 completed,只留下真正在做的一项,再继续。'
34
+
31
35
  /** Wire namespace for the detail-page editor and the settings dialog. */
32
36
  export const FILE_SERVICE = 'systemAgentsMd'
33
37
 
@@ -37,7 +41,7 @@ const REMOTE_METHODS = '@deepseek-ai/dsh-typert-protocol/remote-methods'
37
41
  /**
38
42
  * Config schema projected into the DSH settings dialog.
39
43
  *
40
- * `file`, `maxSilentSteps` and `silentReminder` are `.volatile()`: that is what
44
+ * `file`, `maxSilentSeconds` and `silentReminder` are `.volatile()`: that is what
41
45
  * makes them editable from the GUI settings form (see volatileForm() in
42
46
  * @deepseek-ai/dsh-settings) and what lets a save apply without reloading the
43
47
  * plugin. The remaining fields stay ordinary config for cordis.patch.yml.
@@ -66,11 +70,13 @@ async function loadConfigSchema() {
66
70
  file: z.string().default(''),
67
71
  order: z.number().default(DEFAULT_ORDER),
68
72
  header: z.string().default(DEFAULT_HEADER),
69
- maxSilentSteps: z.number().min(0).default(DEFAULT_MAX_SILENT_STEPS).volatile(),
73
+ maxSilentSeconds: z.number().min(0).default(DEFAULT_MAX_SILENT_SECONDS).volatile(),
70
74
  silentReminder: z.string().default('').volatile(),
71
75
  repeatLimit: z.number().min(0).default(0).volatile(),
72
76
  repeatAction: z.union([z.const('remind'), z.const('stop')]).default('remind').volatile(),
73
77
  repeatReminder: z.string().default('').volatile(),
78
+ maxStaleTodoSteps: z.number().min(0).default(DEFAULT_MAX_STALE_TODO_STEPS).volatile(),
79
+ staleTodoReminder: z.string().default('').volatile(),
74
80
  })
75
81
  }
76
82
 
@@ -130,8 +136,8 @@ function resolveSettings(config = {}) {
130
136
  const file = typeof fileValue === 'string' && fileValue.trim() ? fileValue.trim() : join(dshHome(), DEFAULT_RULES_FILE)
131
137
  const order = Number.isFinite(Number(config.order)) ? Number(config.order) : DEFAULT_ORDER
132
138
  const header = typeof config.header === 'string' ? config.header : DEFAULT_HEADER
133
- const stepsValue = Number(unwrapVolatile(config.maxSilentSteps))
134
- const maxSilentSteps = Number.isFinite(stepsValue) ? Math.max(0, Math.floor(stepsValue)) : DEFAULT_MAX_SILENT_STEPS
139
+ const silentValue = Number(unwrapVolatile(config.maxSilentSeconds))
140
+ const maxSilentSeconds = Number.isFinite(silentValue) ? Math.max(0, Math.floor(silentValue)) : DEFAULT_MAX_SILENT_SECONDS
135
141
  const reminderValue = unwrapVolatile(config.silentReminder)
136
142
  const silentTemplate = typeof reminderValue === 'string' && reminderValue.trim() ? reminderValue : DEFAULT_SILENT_REMINDER
137
143
  const repeatLimitValue = Number(unwrapVolatile(config.repeatLimit))
@@ -139,7 +145,22 @@ function resolveSettings(config = {}) {
139
145
  const repeatAction = unwrapVolatile(config.repeatAction) === 'stop' ? 'stop' : 'remind'
140
146
  const repeatReminderValue = unwrapVolatile(config.repeatReminder)
141
147
  const repeatTemplate = typeof repeatReminderValue === 'string' && repeatReminderValue.trim() ? repeatReminderValue : DEFAULT_REPEAT_REMINDER
142
- return { file, order, header, maxSilentSteps, silentTemplate, repeatLimit, repeatAction, repeatTemplate }
148
+ const staleStepsValue = Number(unwrapVolatile(config.maxStaleTodoSteps))
149
+ const maxStaleTodoSteps = Number.isFinite(staleStepsValue) ? Math.max(0, Math.floor(staleStepsValue)) : DEFAULT_MAX_STALE_TODO_STEPS
150
+ const staleReminderValue = unwrapVolatile(config.staleTodoReminder)
151
+ const staleTemplate = typeof staleReminderValue === 'string' && staleReminderValue.trim() ? staleReminderValue : DEFAULT_STALE_TODO_REMINDER
152
+ return {
153
+ file,
154
+ order,
155
+ header,
156
+ maxSilentSeconds,
157
+ silentTemplate,
158
+ repeatLimit,
159
+ repeatAction,
160
+ repeatTemplate,
161
+ maxStaleTodoSteps,
162
+ staleTemplate,
163
+ }
143
164
  }
144
165
 
145
166
  /**
@@ -258,39 +279,13 @@ function readRules(file) {
258
279
  }
259
280
  }
260
281
 
261
- /** Whether one assistant message carries visible text the user can read. */
262
- function hasVisibleText(message) {
263
- const content = message?.content
264
- if (!Array.isArray(content)) return false
265
- return content.some((part) => part?.type === 'text' && String(part.text ?? '').trim() !== '')
266
- }
267
-
268
- /** Count trailing assistant steps without visible text since the last user turn. */
269
- function countSilentSteps(agent) {
270
- const nodes = agent?.session?.surface?.nodes
271
- if (!Array.isArray(nodes)) return 0
272
- let count = 0
273
- for (let index = nodes.length - 1; index >= 0; index -= 1) {
274
- const event = agent.session.eventAt(nodes[index])
275
- if (event === undefined) continue
276
- if (event.type === 'assistant/message') {
277
- if (hasVisibleText(event.data?.message)) return count
278
- count += 1
279
- continue
280
- }
281
- if (event.type === 'turn/start') return count
282
- if (event.type === 'user/message' && event.data?.source?.kind === 'user') return count
283
- }
284
- return count
285
- }
286
-
287
282
  /**
288
283
  * Register the rules file as a global system-prompt section, keep it in sync
289
284
  * when the file or the volatile config changes, expose it to the settings and
290
- * detail pages, and nudge the model when it runs several steps without visible
291
- * text.
285
+ * detail pages, and nudge the model when it leaves the user without a word for
286
+ * too long.
292
287
  * @param {object} ctx - host plugin context.
293
- * @param {{file?: string, order?: number, header?: string, maxSilentSteps?: number, silentReminder?: string}} [config]
288
+ * @param {{file?: string, order?: number, header?: string, maxSilentSeconds?: number, silentReminder?: string}} [config]
294
289
  */
295
290
  export function apply(ctx, config = {}) {
296
291
  let current = resolveSettings(config)
@@ -345,14 +340,19 @@ export function apply(ctx, config = {}) {
345
340
  }
346
341
  }, 'system-agents-md.section')
347
342
 
348
- // One pre-step hook drives both guards:
349
- // - silent guard: consecutive steps without visible text;
343
+ // One pre-step hook drives three guards:
344
+ // - silent guard: elapsed time since the last readable assistant text;
350
345
  // - loop guard: consecutive identical tool-call steps (remind, or stop the
351
- // turn the way the user's own stop does, keeping queued input).
346
+ // turn the way the user's own stop does, keeping queued input);
347
+ // - stale-todo guard: a task list left with `in_progress` items that the
348
+ // model has not rewritten for several steps. The list is read from the
349
+ // model's `todo_write` tool call: the `todo/write` session event the tool
350
+ // appends is log-only and never reaches the surface the guards scan.
352
351
  // Settings edits are volatile, so `current` is read on every step. A guard
353
352
  // disabled with 0 resets its own per-agent state.
354
353
  const silentRemindedAt = new WeakMap()
355
354
  const repeatState = new WeakMap()
355
+ const staleRemindedAt = new WeakMap()
356
356
  ctx.effect(() => ctx.on('agent/pre-step', async (payload, next) => {
357
357
  const decision = await next()
358
358
  if (decision.kind === 'reject') return decision
@@ -360,18 +360,23 @@ export function apply(ctx, config = {}) {
360
360
  if (agent === undefined) return decision
361
361
  const additions = Array.isArray(decision.messages) ? [...decision.messages] : []
362
362
 
363
- const silentLimit = current.maxSilentSteps
364
- if (silentLimit <= 0) {
363
+ // Silent guard: wall-clock time since the last readable assistant text, so
364
+ // one long tool step cannot hide ten minutes of silence behind a single
365
+ // step, and quick steps do not nag before the user has waited a while.
366
+ // One reminder per `maxSilentSeconds` of further silence.
367
+ const silentLimitMs = current.maxSilentSeconds * 1000
368
+ if (silentLimitMs <= 0) {
365
369
  silentRemindedAt.delete(agent)
366
370
  } else {
367
- const streak = countSilentSteps(agent)
368
- if (streak < silentLimit) {
371
+ const silentFor = silentMs(agent)
372
+ if (silentFor < silentLimitMs) {
369
373
  silentRemindedAt.delete(agent)
370
- } else if ((silentRemindedAt.get(agent) ?? 0) < streak) {
371
- silentRemindedAt.set(agent, streak)
374
+ } else if (silentFor >= (silentRemindedAt.get(agent) ?? 0) + silentLimitMs) {
375
+ silentRemindedAt.set(agent, silentFor)
376
+ const seconds = Math.round(silentFor / 1000)
372
377
  additions.push(createUserMessage({
373
- content: [{ type: 'text', text: current.silentTemplate.replaceAll('{count}', String(streak)) }],
374
- source: { kind: 'system-agents-md', form: 'notice', summary: streak + ' steps without visible text' },
378
+ content: [{ type: 'text', text: current.silentTemplate.replaceAll('{count}', String(seconds)) }],
379
+ source: { kind: 'system-agents-md', form: 'notice', summary: seconds + ' seconds without visible text' },
375
380
  }))
376
381
  }
377
382
  }
@@ -402,6 +407,30 @@ export function apply(ctx, config = {}) {
402
407
  }
403
408
  }
404
409
 
410
+ const staleLimit = current.maxStaleTodoSteps
411
+ if (staleLimit <= 0) {
412
+ staleRemindedAt.delete(agent)
413
+ } else {
414
+ const stale = staleTodoSteps(agent)
415
+ if (!stale.hasList || stale.inProgress === 0 || stale.steps < staleLimit) {
416
+ staleRemindedAt.delete(agent)
417
+ } else if (stale.steps >= (staleRemindedAt.get(agent) ?? 0) + staleLimit) {
418
+ // One reminder per staleLimit further steps: the counter cannot drop
419
+ // until the model rewrites the list, so a plain `> last` would nag on
420
+ // every following step.
421
+ staleRemindedAt.set(agent, stale.steps)
422
+ additions.push(createUserMessage({
423
+ content: [{
424
+ type: 'text',
425
+ text: current.staleTemplate
426
+ .replaceAll('{count}', String(stale.steps))
427
+ .replaceAll('{inProgress}', String(stale.inProgress)),
428
+ }],
429
+ source: { kind: 'system-agents-md', form: 'notice', summary: stale.steps + ' steps since the last todo update' },
430
+ }))
431
+ }
432
+ }
433
+
405
434
  return additions.length === 0 ? decision : { ...decision, messages: additions }
406
435
  }), 'system-agents-md.guards')
407
436
 
package/locale/en.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "meta": {
3
3
  "title": "Personalized rules",
4
- "description": "Inject a user-chosen rules file as a system-prompt section, and configure its body plus silent-step and repeated-action guards from the DSH settings dialog or the plugin detail page. Inactive in the minimal preset; nothing is injected when the plugin is disabled or fails to load."
4
+ "description": "Inject a user-chosen rules file as a system-prompt section, and configure its body plus the silent (elapsed-time) reminder, repeated-action and stale-todo guards from the DSH settings dialog or the plugin detail page. Inactive in the minimal preset; nothing is injected when the plugin is disabled or fails to load."
5
5
  }
6
6
  }
package/locale/zh.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "meta": {
3
3
  "title": "个性化规则",
4
- "description": "把用户指定的规则文件注入为系统提示段,并在 DSH 设置弹窗或插件详情页配置规则正文、静默提醒与循环检测守卫。极简模式下不生效;插件停用或加载失败时不注入任何内容。"
4
+ "description": "把用户指定的规则文件注入为系统提示段,并在 DSH 设置弹窗或插件详情页配置规则正文、静默提醒、循环检测与任务状态守卫。极简模式下不生效;插件停用或加载失败时不注入任何内容。"
5
5
  }
6
6
  }
package/loop-guard.js CHANGED
@@ -1,5 +1,6 @@
1
1
  /**
2
- * Pure helpers for the repeated-action loop guard.
2
+ * Pure helpers for the pre-step guards: the silent reminder, the repeated-action
3
+ * loop guard, and the stale-todo guard.
3
4
  *
4
5
  * A "repeat step" is one assistant message whose ordered tool calls are
5
6
  * identical to the previous assistant message's calls: same tool names and the
@@ -88,3 +89,113 @@ export function repeatStreak(steps) {
88
89
  export function countRepeatSteps(agent) {
89
90
  return repeatStreak(recentToolSteps(agent))
90
91
  }
92
+
93
+ /** Name of the tool whose call carries the complete task list. */
94
+ const TODO_TOOL_NAME = 'todo_write'
95
+
96
+ /** Whether one assistant message carries visible text the user can read. */
97
+ export function hasVisibleText(message) {
98
+ const content = message?.content
99
+ if (!Array.isArray(content)) return false
100
+ return content.some((part) => part?.type === 'text' && String(part.text ?? '').trim() !== '')
101
+ }
102
+
103
+ /**
104
+ * Milliseconds since the model last showed the user something readable.
105
+ *
106
+ * Measured from the newest assistant message with visible text; when the turn
107
+ * has shown none yet, from the newest turn boundary. A step-count threshold
108
+ * cannot express this: one long tool step can hold the user in silence for ten
109
+ * minutes while still counting as a single step.
110
+ * @param {object} agent live agent (session surface + eventAt)
111
+ * @param {number} [now] reference clock in epoch milliseconds
112
+ * @returns {number} elapsed milliseconds, 0 when nothing datable is on the surface
113
+ */
114
+ export function silentMs(agent, now = Date.now()) {
115
+ const nodes = agent?.session?.surface?.nodes
116
+ if (!Array.isArray(nodes)) return 0
117
+ for (let index = nodes.length - 1; index >= 0; index -= 1) {
118
+ const event = agent.session.eventAt(nodes[index])
119
+ if (event === undefined) continue
120
+ const time = Number(event.time)
121
+ if (!Number.isFinite(time)) continue
122
+ if (event.type === 'assistant/message') {
123
+ if (hasVisibleText(event.data?.message)) return Math.max(0, now - time)
124
+ continue
125
+ }
126
+ if (event.type === 'turn/start') return Math.max(0, now - time)
127
+ if (event.type === 'user/message' && event.data?.source?.kind === 'user') return Math.max(0, now - time)
128
+ }
129
+ return 0
130
+ }
131
+
132
+ /**
133
+ * The task list a `todo_write` call carries, or null when the message made none.
134
+ *
135
+ * The `todo/write` session event the tool appends is log-only: the surface
136
+ * carries message-producing events only (`system`/`developer`/`user`/
137
+ * `assistant` messages and `tool/result`), so a guard reading
138
+ * `session.surface.nodes` can never see that event. The model's own tool call
139
+ * is the surface-visible record of the same list.
140
+ * @param {object} message one assistant message
141
+ * @returns {object[] | null}
142
+ */
143
+ export function todoWriteList(message) {
144
+ const content = message?.content
145
+ if (!Array.isArray(content)) return null
146
+ let todos = null
147
+ for (const part of content) {
148
+ if (part?.type !== 'tool-call' || part.name !== TODO_TOOL_NAME) continue
149
+ todos = parseTodoList(part.arguments) ?? todos
150
+ }
151
+ return todos
152
+ }
153
+
154
+ /** Parse a `todo_write` argument string into its todo array, or null. */
155
+ function parseTodoList(raw) {
156
+ if (typeof raw !== 'string' || raw.trim() === '') return null
157
+ let parsed
158
+ try {
159
+ parsed = JSON.parse(raw)
160
+ } catch {
161
+ return null
162
+ }
163
+ if (parsed === null || typeof parsed !== 'object' || !Array.isArray(parsed.todos)) return null
164
+ return parsed.todos
165
+ }
166
+
167
+ /**
168
+ * Steps taken since the newest `todo_write` call in the current turn, plus how
169
+ * many items that list left `in_progress`.
170
+ *
171
+ * A list written in an earlier turn is not counted: the scan stops at the turn
172
+ * boundary, and the todos projection is itself reset by `turn/start`.
173
+ * `hasList` is false when the turn wrote no list yet: the model may be skipping
174
+ * todos on purpose (short tasks), so callers must not nag in that case.
175
+ * @param {object} agent live agent (session surface + eventAt)
176
+ * @returns {{ steps: number, inProgress: number, hasList: boolean }}
177
+ */
178
+ export function staleTodoSteps(agent) {
179
+ const nodes = agent?.session?.surface?.nodes
180
+ if (!Array.isArray(nodes)) return { steps: 0, inProgress: 0, hasList: false }
181
+ let steps = 0
182
+ for (let index = nodes.length - 1; index >= 0; index -= 1) {
183
+ const event = agent.session.eventAt(nodes[index])
184
+ if (event === undefined) continue
185
+ if (event.type === 'assistant/message') {
186
+ const todos = todoWriteList(event.data?.message)
187
+ if (todos !== null) {
188
+ return {
189
+ steps,
190
+ inProgress: todos.filter((todo) => todo?.status === 'in_progress').length,
191
+ hasList: true,
192
+ }
193
+ }
194
+ steps += 1
195
+ continue
196
+ }
197
+ if (event.type === 'turn/start') break
198
+ if (event.type === 'user/message' && event.data?.source?.kind === 'user') break
199
+ }
200
+ return { steps, inProgress: 0, hasList: false }
201
+ }
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "dsh-personal-system-rules",
3
- "version": "0.5.1",
3
+ "version": "0.7.0",
4
4
  "displayName": "个性化规则",
5
- "description": "Inject a user-chosen rules file as a system-prompt section, and configure its body plus silent-step and repeated-action guards from the DSH settings dialog or the plugin detail page. Inactive in the minimal preset; nothing is injected when the plugin is disabled or fails to load.",
5
+ "description": "Inject a user-chosen rules file as a system-prompt section, and configure its body plus the silent (elapsed-time) reminder, repeated-action and stale-todo guards from the DSH settings dialog or the plugin detail page. Inactive in the minimal preset; nothing is injected when the plugin is disabled or fails to load.",
6
6
  "license": "MIT",
7
7
  "author": "NingYuanLee",
8
8
  "type": "module",