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 +21 -2
- package/README.md +14 -9
- package/client.js +83 -15
- package/index.js +78 -49
- package/locale/en.json +1 -1
- package/locale/zh.json +1 -1
- package/loop-guard.js +112 -1
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,9 +1,28 @@
|
|
|
1
1
|
# 更新日志
|
|
2
2
|
|
|
3
|
-
## 0.
|
|
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
|
|
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
|
[](https://github.com/NingYuanLee/dsh-personal-system-rules/actions/workflows/ci.yml) [](https://www.npmjs.com/package/dsh-personal-system-rules) [](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-
|
|
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.
|
|
12
|
+
2. **静默提醒**:距离上一次**可见文字**超过 `maxSilentSeconds`(默认 60 秒)时,注入一条提醒要求模型说明进展;同一段沉默每再满一个阈值提醒一次,`0` 关闭。判定用时长而不是步数:单个长耗时步骤不会再掩盖十分钟的沉默,两步之间的十几秒也不会提前打断。
|
|
13
13
|
3. **循环检测**:连续 `repeatLimit` 步执行**完全相同**的工具调用(工具名 + 归一化参数)时触发,`repeatAction` 可选 `remind`(提醒)或 `stop`(像用户点停止一样中止本轮并保留排队输入);默认 0 关闭。
|
|
14
|
-
4.
|
|
14
|
+
4. **任务状态守卫**:本轮的 todo 列表里还有 `in_progress` 项、且连续 `maxStaleTodoSteps`(默认 6)步没有重写列表时提醒一次,之后再每隔同样步数提醒;本轮没建列表(短任务)时不打扰,全部完成时也不触发。`0` 关闭。
|
|
15
|
+
5. **设置弹窗页面**:`设置 → 个性化规则` 与插件详情页是同一个页面,含「规则正文」和「守卫」两块;保存经官方 `settings` 机制写回 profile patch 并热更新。
|
|
15
16
|
|
|
16
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
- `
|
|
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
|
|
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: "\
|
|
23
|
-
guardStepsHint: "\u8FBE\u5230\u8BE5\
|
|
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\
|
|
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: "\
|
|
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
|
|
69
|
-
guardStepsLabel: "
|
|
70
|
-
guardStepsHint: "Reminds once when reached; 0 disables it. Default
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
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: ["
|
|
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.
|
|
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
|
|
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
|
|
26
|
-
const DEFAULT_SILENT_REMINDER = '
|
|
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`, `
|
|
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
|
-
|
|
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
|
|
134
|
-
const
|
|
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
|
-
|
|
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
|
|
291
|
-
*
|
|
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,
|
|
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
|
|
349
|
-
// - silent guard:
|
|
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
|
-
|
|
364
|
-
|
|
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
|
|
368
|
-
if (
|
|
371
|
+
const silentFor = silentMs(agent)
|
|
372
|
+
if (silentFor < silentLimitMs) {
|
|
369
373
|
silentRemindedAt.delete(agent)
|
|
370
|
-
} else if ((silentRemindedAt.get(agent) ?? 0)
|
|
371
|
-
silentRemindedAt.set(agent,
|
|
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(
|
|
374
|
-
source: { kind: 'system-agents-md', form: 'notice', summary:
|
|
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-
|
|
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
package/loop-guard.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Pure helpers for the repeated-action
|
|
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.
|
|
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-
|
|
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",
|