dsh-personal-system-rules 0.6.0 → 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,16 +1,28 @@
1
1
  # 更新日志
2
2
 
3
+ ## 0.7.0 — 2026-11-05
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
+
3
18
  ## 0.6.0 — 2026-11-04
4
19
 
5
20
  - 新增任务状态守卫:本轮的 todo 列表里还有 `in_progress` 项、且连续 `maxStaleTodoSteps` 步没有重写列表时,经 `agent/pre-step` 注入一条提醒;之后每再累积同样步数提醒一次。默认 6 步,`0` 关闭。
6
21
  - 本轮没有 todo 列表(短任务可以主动跳过)或列表已全部 `completed` 时都不触发,避免把有意不写列表的任务也当成「没更新」。
7
22
  - 设置页「守卫」卡片新增「任务状态」分组,阈值与文案都可在弹窗编辑,文案支持 `{count}`(未更新步数)与 `{inProgress}`(进行中项数)。
8
23
  - 检测逻辑新增纯函数 `staleTodoSteps()`,`test/loop-guard.test.js` 与 `test/guards.test.js` 各有覆盖。
9
-
10
- ## 0.5.1 — 2026-11-04
11
-
12
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` 已覆盖该版本。
13
- - 不使用 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 内核验证加载与全部测试。
14
26
 
15
27
  ## 0.5.0 — 2026-10-31
16
28
 
package/README.md CHANGED
@@ -4,12 +4,12 @@
4
4
 
5
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 and a stale-todo guard. 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
14
  4. **任务状态守卫**:本轮的 todo 列表里还有 `in_progress` 项、且连续 `maxStaleTodoSteps`(默认 6)步没有重写列表时提醒一次,之后再每隔同样步数提醒;本轮没建列表(短任务)时不打扰,全部完成时也不触发。`0` 关闭。
15
15
  5. **设置弹窗页面**:`设置 → 个性化规则` 与插件详情页是同一个页面,含「规则正文」和「守卫」两块;保存经官方 `settings` 机制写回 profile patch 并热更新。
@@ -49,7 +49,7 @@ dsh plugin --profile my-profile add "https://github.com/NingYuanLee/dsh-personal
49
49
  header: |
50
50
  # 用户全局系统层规则
51
51
  以下内容来自用户全局 PERSONAL-SYSTEM-RULES.md,是系统层指令,必须遵守。
52
- maxSilentSteps: 2
52
+ maxSilentSeconds: 60
53
53
  silentReminder: ''
54
54
  repeatLimit: 0
55
55
  repeatAction: remind
@@ -58,7 +58,8 @@ dsh plugin --profile my-profile add "https://github.com/NingYuanLee/dsh-personal
58
58
  staleTodoReminder: ''
59
59
  ```
60
60
 
61
- - `maxSilentSteps`、`silentReminder`、`repeatLimit`、`repeatAction`、`repeatReminder`、`maxStaleTodoSteps`、`staleTodoReminder` 是 `volatile()` 字段,可在设置弹窗编辑并热更新。
61
+ - `maxSilentSeconds`、`silentReminder`、`repeatLimit`、`repeatAction`、`repeatReminder`、`maxStaleTodoSteps`、`staleTodoReminder` 是 `volatile()` 字段,可在设置弹窗编辑并热更新。
62
+ - 0.7.0 起静默提醒按秒判定,旧的 `maxSilentSteps` 键不再生效(仍可留在 patch 里,会被忽略);把阈值换成期望的秒数即可。
62
63
  - `file`、`header`、`order` 是普通配置,只在 `cordis.patch.yml` 里改;改 `file` 后需要重载插件。
63
64
  - `repeatLimit: 0` 关闭循环检测;文本回复或新用户轮次会重置重复计数。
64
65
  - `maxStaleTodoSteps: 0` 关闭任务状态守卫;提醒文案可含 `{count}`(未更新的步数)与 `{inProgress}`(进行中的项数)。
package/client.js CHANGED
@@ -8,7 +8,7 @@ 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
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";
14
14
  const zh = {
@@ -20,10 +20,10 @@ const zh = {
20
20
  rulesTitle: "\u89C4\u5219\u6B63\u6587",
21
21
  guardTitle: "\u5B88\u536B",
22
22
  silentTitle: "\u9759\u9ED8\u63D0\u9192",
23
- guardStepsLabel: "\u8FDE\u7EED\u65E0\u53EF\u89C1\u6587\u672C\u7684\u6B65\u6570\u9608\u503C",
24
- 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",
25
25
  guardReminderLabel: "\u63D0\u9192\u6587\u6848",
26
- 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",
27
27
  loopTitle: "\u5FAA\u73AF\u68C0\u6D4B",
28
28
  loopLimitLabel: "\u540C\u4E00\u64CD\u4F5C\u7684\u6700\u5927\u91CD\u590D\u6B21\u6570",
29
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",
@@ -46,7 +46,7 @@ const zh = {
46
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",
47
47
  optionsLoadFailed: "\u8BFB\u53D6\u5DF2\u4FDD\u5B58\u914D\u7F6E\u5931\u8D25\uFF0C\u5148\u6309\u9ED8\u8BA4\u503C\u663E\u793A\uFF1A{message}",
48
48
  optionsFailed: "\u4FDD\u5B58\u5931\u8D25\uFF1A{message}",
49
- 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",
50
50
  reload: "\u91CD\u65B0\u8BFB\u53D6",
51
51
  save: "\u4FDD\u5B58",
52
52
  saving: "\u4FDD\u5B58\u4E2D",
@@ -71,11 +71,11 @@ const en = {
71
71
  effective: "Effective file",
72
72
  rulesTitle: "Rules body",
73
73
  guardTitle: "Guards",
74
- silentTitle: "Silent-step reminder",
75
- guardStepsLabel: "Consecutive steps without visible text",
76
- 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.",
77
77
  guardReminderLabel: "Reminder text",
78
- 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.",
79
79
  loopTitle: "Loop detection",
80
80
  loopLimitLabel: "Maximum repeats of the same action",
81
81
  loopLimitHint: "Triggers after this many consecutive identical tool-call steps; 0 disables it (default).",
@@ -98,7 +98,7 @@ const en = {
98
98
  optionsMissing: "This plugin has no settings namespace here; showing defaults.",
99
99
  optionsLoadFailed: "Failed to read the saved config; showing defaults: {message}",
100
100
  optionsFailed: "Save failed: {message}",
101
- 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).",
102
102
  reload: "Reload",
103
103
  save: "Save",
104
104
  saving: "Saving",
@@ -177,7 +177,7 @@ function findSettingsRow(found) {
177
177
  if (exact) return exact;
178
178
  return rows.find((row) => {
179
179
  const value = row?.value && typeof row.value === "object" ? row.value : row;
180
- 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");
181
181
  }) ?? null;
182
182
  }
183
183
  function takeView(landed, fallbackRevision) {
@@ -282,7 +282,7 @@ function RulesSection(props) {
282
282
  }
283
283
  const value = row.value && typeof row.value === "object" && !Array.isArray(row.value) ? row.value : {};
284
284
  setConfigNs(typeof row.ns === "string" && row.ns ? row.ns : SETTINGS_NS);
285
- setStepsDraft(Number.isFinite(value.maxSilentSteps) ? String(value.maxSilentSteps) : "");
285
+ setStepsDraft(Number.isFinite(value.maxSilentSeconds) ? String(value.maxSilentSeconds) : "");
286
286
  setReminderDraft(typeof value.silentReminder === "string" ? value.silentReminder : "");
287
287
  setRepeatLimitDraft(Number.isFinite(value.repeatLimit) && value.repeatLimit > 0 ? String(value.repeatLimit) : "");
288
288
  setRepeatActionDraft(value.repeatAction === "stop" ? "stop" : "remind");
@@ -402,7 +402,7 @@ function RulesSection(props) {
402
402
  const staleReminder = staleReminderDraft.trim() === "" ? "" : staleReminderDraft;
403
403
  const action = repeatActionDraft === "stop" ? "stop" : "remind";
404
404
  const ops = [
405
- stepsText === "" ? { op: "unset", path: ["maxSilentSteps"] } : { op: "set", path: ["maxSilentSteps"], value: steps },
405
+ stepsText === "" ? { op: "unset", path: ["maxSilentSeconds"] } : { op: "set", path: ["maxSilentSeconds"], value: steps },
406
406
  reminder === "" ? { op: "unset", path: ["silentReminder"] } : { op: "set", path: ["silentReminder"], value: reminder },
407
407
  limitText === "" ? { op: "unset", path: ["repeatLimit"] } : { op: "set", path: ["repeatLimit"], value: limit },
408
408
  action === "remind" ? { op: "unset", path: ["repeatAction"] } : { op: "set", path: ["repeatAction"], value: action },
@@ -421,7 +421,7 @@ function RulesSection(props) {
421
421
  const view = takeView(landed, revision);
422
422
  setRevision(view.revision);
423
423
  if (view.value) {
424
- setStepsDraft(Number.isFinite(view.value.maxSilentSteps) ? String(view.value.maxSilentSteps) : "");
424
+ setStepsDraft(Number.isFinite(view.value.maxSilentSeconds) ? String(view.value.maxSilentSeconds) : "");
425
425
  setReminderDraft(typeof view.value.silentReminder === "string" ? view.value.silentReminder : "");
426
426
  setRepeatLimitDraft(Number.isFinite(view.value.repeatLimit) && view.value.repeatLimit > 0 ? String(view.value.repeatLimit) : "");
427
427
  setRepeatActionDraft(view.value.repeatAction === "stop" ? "stop" : "remind");
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, staleTodoSteps } 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,7 @@ 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
18
  const DEFAULT_MAX_STALE_TODO_STEPS = 6
19
19
  const DEFAULT_RULES_FILE = 'PERSONAL-SYSTEM-RULES.md'
20
20
  const DEFAULT_HEADER = [
@@ -23,8 +23,8 @@ const DEFAULT_HEADER = [
23
23
  '以下内容来自用户全局 PERSONAL-SYSTEM-RULES.md,是系统层指令,必须遵守;与项目内软性说明冲突时以本节为准。',
24
24
  ].join('\n')
25
25
 
26
- /** Default reminder used when consecutive steps carry no visible text. */
27
- const DEFAULT_SILENT_REMINDER = '你已经连续 {count} 步没有向用户说明进展。请在本步开头用一句话说明正在做什么、为什么,再继续调用工具。'
26
+ /** Default reminder used when the model goes too long without visible text. */
27
+ const DEFAULT_SILENT_REMINDER = '你已经 {count} 秒没有向用户说明进展。请在本步开头用一句话说明正在做什么、为什么,再继续调用工具。'
28
28
 
29
29
  /** Default reminder used when the same tool call repeats beyond the limit. */
30
30
  const DEFAULT_REPEAT_REMINDER = '你已经连续 {count} 步执行完全相同的操作。请停止重复:先说明当前阻塞和已完成的部分,再换一种做法;如果确实需要继续同一操作,请先向用户说明原因。'
@@ -41,7 +41,7 @@ const REMOTE_METHODS = '@deepseek-ai/dsh-typert-protocol/remote-methods'
41
41
  /**
42
42
  * Config schema projected into the DSH settings dialog.
43
43
  *
44
- * `file`, `maxSilentSteps` and `silentReminder` are `.volatile()`: that is what
44
+ * `file`, `maxSilentSeconds` and `silentReminder` are `.volatile()`: that is what
45
45
  * makes them editable from the GUI settings form (see volatileForm() in
46
46
  * @deepseek-ai/dsh-settings) and what lets a save apply without reloading the
47
47
  * plugin. The remaining fields stay ordinary config for cordis.patch.yml.
@@ -70,7 +70,7 @@ async function loadConfigSchema() {
70
70
  file: z.string().default(''),
71
71
  order: z.number().default(DEFAULT_ORDER),
72
72
  header: z.string().default(DEFAULT_HEADER),
73
- maxSilentSteps: z.number().min(0).default(DEFAULT_MAX_SILENT_STEPS).volatile(),
73
+ maxSilentSeconds: z.number().min(0).default(DEFAULT_MAX_SILENT_SECONDS).volatile(),
74
74
  silentReminder: z.string().default('').volatile(),
75
75
  repeatLimit: z.number().min(0).default(0).volatile(),
76
76
  repeatAction: z.union([z.const('remind'), z.const('stop')]).default('remind').volatile(),
@@ -136,8 +136,8 @@ function resolveSettings(config = {}) {
136
136
  const file = typeof fileValue === 'string' && fileValue.trim() ? fileValue.trim() : join(dshHome(), DEFAULT_RULES_FILE)
137
137
  const order = Number.isFinite(Number(config.order)) ? Number(config.order) : DEFAULT_ORDER
138
138
  const header = typeof config.header === 'string' ? config.header : DEFAULT_HEADER
139
- const stepsValue = Number(unwrapVolatile(config.maxSilentSteps))
140
- 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
141
141
  const reminderValue = unwrapVolatile(config.silentReminder)
142
142
  const silentTemplate = typeof reminderValue === 'string' && reminderValue.trim() ? reminderValue : DEFAULT_SILENT_REMINDER
143
143
  const repeatLimitValue = Number(unwrapVolatile(config.repeatLimit))
@@ -153,7 +153,7 @@ function resolveSettings(config = {}) {
153
153
  file,
154
154
  order,
155
155
  header,
156
- maxSilentSteps,
156
+ maxSilentSeconds,
157
157
  silentTemplate,
158
158
  repeatLimit,
159
159
  repeatAction,
@@ -279,39 +279,13 @@ function readRules(file) {
279
279
  }
280
280
  }
281
281
 
282
- /** Whether one assistant message carries visible text the user can read. */
283
- function hasVisibleText(message) {
284
- const content = message?.content
285
- if (!Array.isArray(content)) return false
286
- return content.some((part) => part?.type === 'text' && String(part.text ?? '').trim() !== '')
287
- }
288
-
289
- /** Count trailing assistant steps without visible text since the last user turn. */
290
- function countSilentSteps(agent) {
291
- const nodes = agent?.session?.surface?.nodes
292
- if (!Array.isArray(nodes)) return 0
293
- let count = 0
294
- for (let index = nodes.length - 1; index >= 0; index -= 1) {
295
- const event = agent.session.eventAt(nodes[index])
296
- if (event === undefined) continue
297
- if (event.type === 'assistant/message') {
298
- if (hasVisibleText(event.data?.message)) return count
299
- count += 1
300
- continue
301
- }
302
- if (event.type === 'turn/start') return count
303
- if (event.type === 'user/message' && event.data?.source?.kind === 'user') return count
304
- }
305
- return count
306
- }
307
-
308
282
  /**
309
283
  * Register the rules file as a global system-prompt section, keep it in sync
310
284
  * when the file or the volatile config changes, expose it to the settings and
311
- * detail pages, and nudge the model when it runs several steps without visible
312
- * text.
285
+ * detail pages, and nudge the model when it leaves the user without a word for
286
+ * too long.
313
287
  * @param {object} ctx - host plugin context.
314
- * @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]
315
289
  */
316
290
  export function apply(ctx, config = {}) {
317
291
  let current = resolveSettings(config)
@@ -367,11 +341,13 @@ export function apply(ctx, config = {}) {
367
341
  }, 'system-agents-md.section')
368
342
 
369
343
  // One pre-step hook drives three guards:
370
- // - silent guard: consecutive steps without visible text;
344
+ // - silent guard: elapsed time since the last readable assistant text;
371
345
  // - loop guard: consecutive identical tool-call steps (remind, or stop the
372
346
  // turn the way the user's own stop does, keeping queued input);
373
347
  // - stale-todo guard: a task list left with `in_progress` items that the
374
- // model has not rewritten for several steps.
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.
375
351
  // Settings edits are volatile, so `current` is read on every step. A guard
376
352
  // disabled with 0 resets its own per-agent state.
377
353
  const silentRemindedAt = new WeakMap()
@@ -384,18 +360,23 @@ export function apply(ctx, config = {}) {
384
360
  if (agent === undefined) return decision
385
361
  const additions = Array.isArray(decision.messages) ? [...decision.messages] : []
386
362
 
387
- const silentLimit = current.maxSilentSteps
388
- 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) {
389
369
  silentRemindedAt.delete(agent)
390
370
  } else {
391
- const streak = countSilentSteps(agent)
392
- if (streak < silentLimit) {
371
+ const silentFor = silentMs(agent)
372
+ if (silentFor < silentLimitMs) {
393
373
  silentRemindedAt.delete(agent)
394
- } else if ((silentRemindedAt.get(agent) ?? 0) < streak) {
395
- 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)
396
377
  additions.push(createUserMessage({
397
- content: [{ type: 'text', text: current.silentTemplate.replaceAll('{count}', String(streak)) }],
398
- 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' },
399
380
  }))
400
381
  }
401
382
  }
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, 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."
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/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
@@ -89,14 +90,88 @@ export function countRepeatSteps(agent) {
89
90
  return repeatStreak(recentToolSteps(agent))
90
91
  }
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
+
92
132
  /**
93
- * Steps taken since the newest `todo/write` in the current turn, plus how many
94
- * items that list left `in_progress`.
133
+ * The task list a `todo_write` call carries, or null when the message made none.
95
134
  *
96
- * The todo projection is reset by `turn/start`, so a list that is still
97
- * non-empty was necessarily written in this turn. `hasList` is false when the
98
- * turn wrote no list yet: the model may be skipping todos on purpose (short
99
- * tasks), so callers must not nag in that case.
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.
100
175
  * @param {object} agent live agent (session surface + eventAt)
101
176
  * @returns {{ steps: number, inProgress: number, hasList: boolean }}
102
177
  */
@@ -107,15 +182,15 @@ export function staleTodoSteps(agent) {
107
182
  for (let index = nodes.length - 1; index >= 0; index -= 1) {
108
183
  const event = agent.session.eventAt(nodes[index])
109
184
  if (event === undefined) continue
110
- if (event.type === 'todo/write') {
111
- const todos = Array.isArray(event.data?.todos) ? event.data.todos : []
112
- return {
113
- steps,
114
- inProgress: todos.filter((todo) => todo?.status === 'in_progress').length,
115
- hasList: true,
116
- }
117
- }
118
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
+ }
119
194
  steps += 1
120
195
  continue
121
196
  }
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "dsh-personal-system-rules",
3
- "version": "0.6.0",
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, 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
+ "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",