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 +16 -4
- package/README.md +5 -4
- package/client.js +14 -14
- package/index.js +29 -48
- package/locale/en.json +1 -1
- package/loop-guard.js +90 -15
- package/package.json +2 -2
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
|
|
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-
|
|
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
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
|
-
|
|
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
|
-
- `
|
|
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
|
|
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: "\
|
|
24
|
-
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",
|
|
25
25
|
guardReminderLabel: "\u63D0\u9192\u6587\u6848",
|
|
26
|
-
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",
|
|
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: "\
|
|
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
|
|
75
|
-
guardStepsLabel: "
|
|
76
|
-
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.",
|
|
77
77
|
guardReminderLabel: "Reminder text",
|
|
78
|
-
guardReminderHint: "Use {count} for the
|
|
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
|
|
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.
|
|
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.
|
|
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: ["
|
|
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.
|
|
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
|
|
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
|
|
27
|
-
const DEFAULT_SILENT_REMINDER = '
|
|
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`, `
|
|
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
|
-
|
|
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
|
|
140
|
-
const
|
|
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
|
-
|
|
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
|
|
312
|
-
*
|
|
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,
|
|
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:
|
|
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
|
-
|
|
388
|
-
|
|
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
|
|
392
|
-
if (
|
|
371
|
+
const silentFor = silentMs(agent)
|
|
372
|
+
if (silentFor < silentLimitMs) {
|
|
393
373
|
silentRemindedAt.delete(agent)
|
|
394
|
-
} else if ((silentRemindedAt.get(agent) ?? 0)
|
|
395
|
-
silentRemindedAt.set(agent,
|
|
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(
|
|
398
|
-
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' },
|
|
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-
|
|
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
|
|
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
|
-
*
|
|
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
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
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.
|
|
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",
|