dsh-plugin-prompt-tool 1.0.0 → 2.0.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.
Files changed (95) hide show
  1. package/README.md +38 -29
  2. package/engine/actions/append-context.mjs +49 -0
  3. package/engine/actions/assembly.mjs +150 -0
  4. package/engine/actions/catalog.mjs +92 -0
  5. package/engine/actions/content.mjs +205 -0
  6. package/engine/actions/decision.mjs +54 -0
  7. package/engine/actions/examples.mjs +11 -0
  8. package/engine/actions/guard.mjs +80 -0
  9. package/engine/actions/inbox-prepend.mjs +37 -0
  10. package/engine/actions/index.mjs +100 -0
  11. package/engine/actions/inject-text.mjs +22 -0
  12. package/engine/actions/pre-step-filter.mjs +55 -0
  13. package/engine/actions/registration.mjs +168 -0
  14. package/engine/actions/request-params.mjs +32 -0
  15. package/engine/actions/sdk-strip.mjs +30 -0
  16. package/engine/actions/shared.mjs +71 -0
  17. package/engine/actions.mjs +2 -931
  18. package/engine/branch.mjs +44 -0
  19. package/engine/compaction-epoch.mjs +31 -13
  20. package/engine/compositions/source/local/dev-tool-search.yml +11 -0
  21. package/engine/compositions/source/local/instruction-hint.yml +22 -1
  22. package/engine/compositions/source/local/rule-engine.yml +6 -0
  23. package/engine/condition.mjs +16 -3
  24. package/engine/conditions/anchor.mjs +32 -0
  25. package/engine/conditions/availability.mjs +47 -0
  26. package/engine/conditions/composite.mjs +83 -0
  27. package/engine/conditions/count.mjs +140 -0
  28. package/engine/conditions/evaluation.mjs +41 -0
  29. package/engine/conditions/examples.mjs +11 -0
  30. package/engine/conditions/index.mjs +85 -0
  31. package/engine/conditions/names.mjs +21 -0
  32. package/engine/conditions/phase.mjs +54 -0
  33. package/engine/conditions/preset.mjs +94 -0
  34. package/engine/conditions/scope.mjs +18 -0
  35. package/engine/conditions/session.mjs +22 -0
  36. package/engine/conditions/source.mjs +63 -0
  37. package/engine/conditions/subject.mjs +121 -0
  38. package/engine/conditions/text.mjs +44 -0
  39. package/engine/conditions/values.mjs +32 -0
  40. package/engine/dev-tool-search.mjs +213 -0
  41. package/engine/executor.mjs +55 -6
  42. package/engine/fillers.mjs +23 -16
  43. package/engine/instruction-hint.mjs +34 -22
  44. package/engine/layers.mjs +135 -49
  45. package/engine/order.mjs +4 -1
  46. package/engine/predicates.mjs +2 -753
  47. package/engine/rule-engine.mjs +40 -0
  48. package/engine/rule-runtime.mjs +110 -0
  49. package/engine/rule-spec.mjs +187 -0
  50. package/engine/schema.mjs +16 -5
  51. package/engine/shared.mjs +1 -1
  52. package/engine/strategies.mjs +2 -235
  53. package/engine/subagent-tool-policy-core.mjs +7 -7
  54. package/engine/subagent-tool-policy.mjs +20 -10
  55. package/engine/tool-config-engine.mjs +53 -32
  56. package/engine/trigger-editor-meta.mjs +2 -21
  57. package/engine/trigger-spec.mjs +49 -133
  58. package/lib/client.js +10446 -10820
  59. package/lib/client.js.map +1 -1
  60. package/lib/index.d.mts +82 -139
  61. package/lib/index.mjs +826 -1087
  62. package/lib/migrate-rules.d.mts +1 -0
  63. package/lib/migrate-rules.mjs +39 -0
  64. package/lib/module-package-CHPFweqK.mjs +2 -0
  65. package/lib/module-package-Cnd1_mwx.mjs +2872 -0
  66. package/lib/{paths-BgL42sSM.d.mts → paths-CyP0IkaM.d.mts} +223 -108
  67. package/lib/preset-transfer.d.mts +2 -2
  68. package/lib/preset-transfer.mjs +3 -2
  69. package/lib/{module-package-Cm3fHHlp.mjs → rules-migration-DJ6vASB5.mjs} +13696 -15557
  70. package/modules/ponytail/module.yml +381 -147
  71. package/modules/skill-surface/module.yml +74 -0
  72. package/modules/tool-surface/module.yml +67 -0
  73. package/package.json +5 -1
  74. package/templates/10-pre-step.yml +26 -22
  75. package/templates/14-first-turn-anchor.yml +25 -21
  76. package/templates/15-guide-auto.yml +34 -19
  77. package/templates/16-custom-fallback.yml +27 -15
  78. package/templates/18-placeholder-env-facts.yml +23 -14
  79. package/templates/20-system-section.yml +16 -10
  80. package/templates/30-runtime-context.yml +13 -7
  81. package/templates/40-agent-request.yml +11 -8
  82. package/templates/50-llm-stream.yml +14 -6
  83. package/templates/60-tool-pipeline.yml +15 -9
  84. package/templates/65-turn-stop.yml +19 -12
  85. package/templates/66-subagent-start.yml +12 -7
  86. package/templates/70-subagent-maintenance.yml +35 -29
  87. package/templates/75-subagent-end.yml +10 -5
  88. package/templates/80-tool-surface.yml +66 -0
  89. package/templates/policies/legacy-defaults.yml +34 -0
  90. package/engine/compositions/source/local/prompt-config-engine.yml +0 -7
  91. package/engine/prompt-config-engine.mjs +0 -104
  92. package/modules/ponytail/configs/0000-ponytail-rules.yml +0 -102
  93. package/modules/ponytail/configs/0010-ponytail-level-full.yml +0 -19
  94. package/modules/ponytail/configs/0020-ponytail-level-lite.yml +0 -19
  95. package/modules/ponytail/configs/0030-ponytail-level-ultra.yml +0 -19
@@ -0,0 +1,44 @@
1
+ /**
2
+ * 分支语法的唯一展开器:`if`/`then`/`else` 是当前名,`when`/`do` 是兼容输入。
3
+ *
4
+ * 规则(`rule-spec.mjs`)与触发器声明(`trigger-spec.mjs`)共用这一份实现——
5
+ * 两套入口必须是同一套语义,否则同一个模块换个入口就换行为。
6
+ */
7
+
8
+ const record = value => value !== null && typeof value === 'object' && !Array.isArray(value)
9
+
10
+ /** 分支节点:占动作位、没有 `kind`,但带 `if`/`then`/`else` 之一。 */
11
+ export const isBranchNode = node => record(node) && node.kind === undefined
12
+ && (node.if !== undefined || node.then !== undefined || node.else !== undefined)
13
+
14
+ /** 多个条件按声明序合取;空数组 = 无条件(`compileWhen(undefined)` 直接返回 undefined)。 */
15
+ export function conjunction(conditions) {
16
+ if (conditions.length === 0) return undefined
17
+ return conditions.length === 1 ? conditions[0] : { all: [...conditions] }
18
+ }
19
+
20
+ /**
21
+ * 把一份动作声明展开为扁平的「动作 + 自身分支条件」列表。
22
+ *
23
+ * - 普通动作原样带出,分支条件 = `outer` 的合取(空 = 无条件);
24
+ * - 分支节点的 `then` 追加本层 `if`、`else` 追加 `not(if)`——互斥由结构保证,不由作者手写;
25
+ * - 返回顺序即声明序(then 在前、else 在后);
26
+ * - `bypass` 标记该动作来自**声明级** `else`:它的条件已含 `not(if)`,不能再叠加声明级判定。
27
+ */
28
+ export function expandActions(list, options = {}, label) {
29
+ if (!Array.isArray(list)) throw new TypeError(`${label} must be an array`)
30
+ const { outer = [], bypass = false } = options
31
+ const out = []
32
+ for (const [index, node] of list.entries()) {
33
+ const at = `${label}[${index}]`
34
+ if (isBranchNode(node)) {
35
+ if (node.if === undefined) throw new TypeError(`${at}: a branch needs if`)
36
+ if (node.then === undefined) throw new TypeError(`${at}: a branch needs then`)
37
+ out.push(...expandActions(node.then, { outer: [...outer, node.if], bypass }, `${at}.then`))
38
+ if (node.else !== undefined) out.push(...expandActions(node.else, { outer: [...outer, { not: node.if }], bypass }, `${at}.else`))
39
+ continue
40
+ }
41
+ out.push({ node, conditions: outer, bypass })
42
+ }
43
+ return out
44
+ }
@@ -23,7 +23,7 @@
23
23
  *
24
24
  * GATE MODE (strict two-phase stabilization extension, source: xiaobright/dsh-anchored-standard
25
25
  * MIT + phase-1 quarantine): `promoteGate: true` gates the promotion on the
26
- * first reasoning block classifying minimal-like (`we` present, no `let me`),
26
+ * first reasoning block matching the caller's positive and negative patterns,
27
27
  * with a `maxPromoteSteps` fallback supplied by the caller's config; `promoteAfterFirstResponse:
28
28
  * true` promotes a tool-less first response once it has responded, and also
29
29
  * releases an anchor-gated session when its first turn ends. Gate mode uses
@@ -34,24 +34,41 @@
34
34
 
35
35
  import { sessionEvents, sessionState } from './shared.mjs'
36
36
 
37
- /** 首段 reasoning 块分类(严格门控扩展):we 且无 let me = minimal-like。 */
38
- export function classifyReasoning(text) {
39
- const trimmed = String(text ?? '').trim()
40
- const we = [...trimmed.matchAll(/\bwe\b/gi)].length
41
- const letMe = [...trimmed.matchAll(/\blet me\b/gi)].length
42
- const metrics = { we, letMe }
43
- if (we > 0 && letMe === 0) return { label: 'minimal-like', score: 4, metrics }
44
- if (letMe > 0) return { label: 'standard-like', score: -4, metrics }
45
- return { label: 'ambiguous', score: 0, metrics }
37
+ /** 匹配内容与大小写均由调用方显式声明;空 pattern 不承担任何业务识别。 */
38
+ function reasoningClassifier(options = {}) {
39
+ const flags = options.reasoningFlags ?? ''
40
+ if (typeof flags !== 'string') throw new TypeError('reasoningFlags must be a string')
41
+ const compile = (key) => {
42
+ const pattern = options[key]
43
+ if (pattern == null || pattern === '') return undefined
44
+ if (typeof pattern !== 'string') throw new TypeError(`${key} must be a string`)
45
+ return new RegExp(pattern, flags.includes('g') ? flags : `${flags}g`)
46
+ }
47
+ const positive = compile('reasoningPattern')
48
+ const negative = compile('reasoningNegativePattern')
49
+ return text => {
50
+ const trimmed = String(text ?? '').trim()
51
+ // 旧诊断返回字段保留;两项现在分别计数调用方的正、负模式。分数不参与晋升。
52
+ const we = positive === undefined ? 0 : [...trimmed.matchAll(positive)].length
53
+ const letMe = negative === undefined ? 0 : [...trimmed.matchAll(negative)].length
54
+ const metrics = { we, letMe }
55
+ if (we > 0 && letMe === 0) return { label: 'minimal-like', score: 4, metrics }
56
+ if (letMe > 0) return { label: 'standard-like', score: -4, metrics }
57
+ return { label: 'ambiguous', score: 0, metrics }
58
+ }
46
59
  }
47
60
 
61
+ export function classifyReasoning(text, options = {}) { return reasoningClassifier(options)(text) }
62
+
48
63
  /** 首段 reasoning 块是否为 minimal-like(后续块不覆盖首个标准样块)。 */
49
- export function hasAnchoredReasoning(content) {
64
+ function firstReasoningMatches(content, classify) {
50
65
  if (!Array.isArray(content)) return false
51
66
  const first = content.find((block) => block?.type === 'reasoning')
52
- return first !== undefined && classifyReasoning(first.text).label === 'minimal-like'
67
+ return first !== undefined && classify(first.text).label === 'minimal-like'
53
68
  }
54
69
 
70
+ export function hasAnchoredReasoning(content, options = {}) { return firstReasoningMatches(content, reasoningClassifier(options)) }
71
+
55
72
  /** True only when compaction completed and changed the model-visible surface. */
56
73
  export function isSuccessfulCompactionEnd(event) {
57
74
  return event?.type === 'compaction/end' && event.data?.error === undefined
@@ -62,6 +79,7 @@ export function createEpochPromotion(promoteEvents, options = {}) {
62
79
  const includeSubagents = options.includeSubagents === true
63
80
  const promoteGate = options.promoteGate === true
64
81
  const promoteAfterFirstResponse = options.promoteAfterFirstResponse === true
82
+ const classify = reasoningClassifier(options)
65
83
  // 门控回退步数由调用方配置提供(tool-bootstrap 在开启门控时做必填校验):
66
84
  // 引擎不内置默认;未提供时步数兜底不生效,只按 anchored 判定。
67
85
  const maxPromoteSteps = options.maxPromoteSteps
@@ -115,7 +133,7 @@ export function createEpochPromotion(promoteEvents, options = {}) {
115
133
  else if (event.type === 'turn/end') entry.turnEnded = true
116
134
  else if (event.type === 'assistant/message') {
117
135
  entry.responded = true
118
- if (!entry.anchored) entry.anchored = hasAnchoredReasoning(event.data?.message?.content)
136
+ if (!entry.anchored) entry.anchored = firstReasoningMatches(event.data?.message?.content, classify)
119
137
  }
120
138
  if (decideGate(entry)) entry.promoted = true
121
139
  return entry
@@ -0,0 +1,11 @@
1
+ # module: dev-tool-search
2
+ # source: engine/compositions/source/local/dev-tool-search.yml (本地附加,非官方模块)
3
+ #
4
+ # 工具面的按需发现与解锁。与 assembly 动作的 allowFrom 配对使用:
5
+ # 本工具把解锁名写进持久 tool/call 参数 → allowFrom 读回 → 下次请求起生效。
6
+ # 只挂本行而不配 allowFrom 的话,解锁是一次性的(当次请求用完即被裁掉)。
7
+ #
8
+ # 常驻集由模板声明(见 templates/ 的工具面收窄模板),本行只负责发现入口。
9
+
10
+ - id: dev-tool-search
11
+ name: ./engine/dev-tool-search.mjs
@@ -6,10 +6,31 @@
6
6
  # opt-in:enabled 默认 false(与原 instructionHint 默认 false 同值),由参数桥
7
7
  # params.instructionHint(→ 本行 enabled)或本行 config.enabled: true 打开;
8
8
  # 相位词汇与 tool-bootstrap / promoted-code-mode 同源(compaction-epoch.mjs)。
9
+ #
10
+ # 四个模板必须随行给全(2026-10-05 修):`instruction-hint.mjs#apply` 的首行是
11
+ # `if (!enabled || templateOf(source, 'messageTemplate').trim().length === 0) return`
12
+ # 而本行此前只配了 enabled/promoteOn/includeSubagents —— 于是把开关打开**不注册任何
13
+ # 转换**:配置合法、零效果、零报错(与 count.delegated 的静默失效同类)。模板正文取自
14
+ # templates/policies/legacy-defaults.yml#instructionHint,与 rules-migration.ts:102
15
+ # 的迁移默认值同源;两处必须一致,否则「迁移进来的定义」与「新装的组合源」行为分叉。
16
+ # legacy-defaults.yml 自身标注「引擎运行时不得读取」,所以这里必须是实体而非引用。
17
+ #
18
+ # includeSubagents: true —— 子代理首次请求即视为已晋升(官方相位语义),所以子代理
19
+ # 从第 1 轮就拿提示而非全文。主会话则是首个工具调用/助手消息之后才降级(promoteOn)。
9
20
 
10
21
  - id: instruction-hint
11
22
  name: ./engine/instruction-hint.mjs
12
23
  config:
13
24
  enabled: false
14
25
  promoteOn: either
15
- includeSubagents: false
26
+ includeSubagents: true
27
+ projectTemplate: "Reference documents exist: {{FILES}} (project root: {{ROOT}})."
28
+ globalTemplate: "A user reference document exists: {{FILES}}."
29
+ suffixTemplate: They are reference documents about the user's environment and
30
+ workspace conventions, not task instructions. Reading the relevant file
31
+ before workspace tasks is recommended, but consult them only when you need
32
+ those details; the task itself never depends on them.
33
+ messageTemplate: |-
34
+ <system-reminder>
35
+ Reference documents exist: {{FILES}}. {{SUFFIX}}
36
+ </system-reminder>
@@ -0,0 +1,6 @@
1
+ # module: rule-engine
2
+ # 单一规则分发入口;插件配装通道直接挂载同一编译器时跳过此行。
3
+ - id: rule-engine
4
+ name: ./engine/rule-engine.mjs
5
+ config:
6
+ rulesFile: ../rules.yml
@@ -14,7 +14,7 @@
14
14
  import { extractText, sessionEvents } from './shared.mjs'
15
15
 
16
16
  /** subject → 载荷字段名。改这里等于改所有层的匹配对象。 */
17
- const SUBJECT_FIELDS = {
17
+ export const SUBJECT_FIELDS = {
18
18
  toolArgs: 'argsText',
19
19
  toolResult: 'resultText',
20
20
  userMessage: 'userText',
@@ -54,11 +54,24 @@ export function toolArgsText(args) {
54
54
  }
55
55
  }
56
56
 
57
- /** pre-step 的缺省匹配对象:本批用户消息的文本。 */
57
+ /**
58
+ * pre-step 的缺省匹配对象:本批**真实对话**的用户消息文本。
59
+ *
60
+ * 只收真人输入:引擎自己注入的消息同样是 user-role(`executor.mjs` 的
61
+ * `role: PRE_STEP_ROLE`、`pluginMessage` 的 `source` 是 `plugin:<name>` 生产者身份),
62
+ * 把它们算进来会让 `text.match` 匹配到自己注入过的正文——规则正文里出现「重构」
63
+ * 「实现」这类词就会自我命中,且命中判定在去重之前,`dedupe` 挡不住。
64
+ * 过滤判据与 `st-world-book.mjs#stChatMessages` 的同名约定一致(那是最早做对的一处),
65
+ * 两处必须保持同语义。
66
+ */
58
67
  export function userMessagesText(messages) {
59
68
  if (!Array.isArray(messages)) return ''
60
69
  return messages
61
- .filter((message) => message?.role === 'user')
70
+ .filter((message) => message?.role === 'user'
71
+ && message.source?.plugin === undefined
72
+ && message.source?.kind !== 'plugin'
73
+ && !(typeof message.source?.kind === 'string' && message.source.kind.startsWith('plugin:'))
74
+ && (message.source?.kind === undefined || message.source.kind === 'user'))
62
75
  .map((message) => extractText(message))
63
76
  .filter((text) => text.length > 0)
64
77
  .join('\n')
@@ -0,0 +1,32 @@
1
+ import { createAnchorMatcher } from '../anchor-match.mjs'
2
+ import { sessionEvents, sessionState } from '../shared.mjs'
3
+ import { sessionOf } from './subject.mjs'
4
+ import { boundOf, keyList } from './values.mjs'
5
+
6
+ /** 首条 assistant reasoning 的确认事实;未到达时不缓存,无 id 会话不共享状态。 */
7
+ export function createAnchorState(options = {}) {
8
+ const matcher = createAnchorMatcher({ keys: keyList(options.keys, 'anchor.keys'), mode: 'prefix' })
9
+ const confirmedBySession = sessionState(undefined, () => false)
10
+ return session => {
11
+ const assistants = sessionEvents(session).filter(event => event.type === 'assistant/message')
12
+ let confirmed = confirmedBySession.peek(session)
13
+ if (confirmed === undefined && assistants.length > 0) {
14
+ const reasoning = (assistants[0].data?.message?.content ?? []).find(block => block.type === 'reasoning')
15
+ confirmed = reasoning !== undefined && matcher.scan(String(reasoning.text ?? '')).active
16
+ confirmedBySession.set(session, confirmed)
17
+ }
18
+ return { confirmed: confirmed === true, assistantRounds: assistants.length }
19
+ }
20
+ }
21
+
22
+ /** 确认即命中;可选 fallbackAfter 表示超过指定 assistant 消息数后也允许。 */
23
+ export function createAnchorPredicate(options = {}) {
24
+ const inspect = createAnchorState(options)
25
+ const fallbackAfter = boundOf(options.fallbackAfter, 'anchor.fallbackAfter')
26
+ const predicate = payload => {
27
+ const status = inspect(sessionOf(payload))
28
+ return status.confirmed || (fallbackAfter !== undefined && status.assistantRounds > fallbackAfter)
29
+ }
30
+ predicate.kind = 'anchor'
31
+ return predicate
32
+ }
@@ -0,0 +1,47 @@
1
+ import { agentOf, sessionOf } from './subject.mjs'
2
+ import { SUBJECT_FIELDS } from '../condition.mjs'
3
+
4
+ /** 缺少真实事件事实不是 false;尤其不能经 not 反转成允许。 */
5
+ export const UNAVAILABLE = Symbol('condition-input-unavailable')
6
+
7
+ export function conditionInputAvailable(kind, options, payload) {
8
+ const agent = agentOf(payload)
9
+ const session = sessionOf(payload)
10
+ if (['phase', 'count', 'session', 'anchor'].includes(kind)) {
11
+ return session != null && typeof session.snapshotEvents === 'function' && (kind !== 'phase' || agent?.session != null)
12
+ }
13
+ if (kind === 'preset') return agent?.ctx != null
14
+ if (kind === 'names') {
15
+ const name = typeof payload === 'string' ? payload : payload?.name
16
+ return typeof name === 'string' && name.length > 0
17
+ }
18
+ if (kind === 'source') return payload?.source !== null && typeof payload?.source === 'object' && !Array.isArray(payload.source)
19
+ if (kind === 'text') {
20
+ if (typeof payload === 'string') return true
21
+ const field = Object.hasOwn(SUBJECT_FIELDS, options.subject) ? SUBJECT_FIELDS[options.subject] : undefined
22
+ if (field === undefined || typeof payload?.[field] !== 'string') return false
23
+ // 归一化器可把缺载荷投影为空文本;保留原参数表时必须区分缺事实与确实为空。
24
+ if (Array.isArray(payload.args)) {
25
+ if (options.subject === 'toolArgs') return payload.args[0] != null && Object.hasOwn(payload.args[0], 'arguments')
26
+ if (options.subject === 'toolResult') return payload.args.length > 1 && payload.args[1] !== undefined
27
+ if (options.subject === 'userMessage' && payload.channel === 'agent/pre-step') return Array.isArray(payload.args[0]?.messages)
28
+ if (options.subject === 'userMessage' && payload.channel === 'agent/inbox/inserted') return payload.args[0]?.message != null
29
+ if (options.subject === 'assistantText') return typeof session?.snapshotEvents === 'function'
30
+ if (options.subject === 'subagentInfo') return payload.args[0] != null
31
+ }
32
+ return true
33
+ }
34
+ if (kind === 'scope') {
35
+ if (options.audience !== undefined && session == null) return false
36
+ const model = payload?.model ?? agent?.options?.model
37
+ if (options.modelScope !== undefined && options.modelScope !== 'all' && (typeof model !== 'string' || model.length === 0)) return false
38
+ }
39
+ return true
40
+ }
41
+
42
+ export function withAvailableInput(kind, options, predicate) {
43
+ const wrapped = payload => conditionInputAvailable(kind, options, payload) ? predicate(payload) : UNAVAILABLE
44
+ wrapped.kind = predicate.kind
45
+ if (typeof predicate.observe === 'function') wrapped.observe = (session, event) => predicate.observe(session, event)
46
+ return wrapped
47
+ }
@@ -0,0 +1,83 @@
1
+ import { MATCH_LOGIC } from '../anchor-match.mjs'
2
+ import { UNAVAILABLE } from './availability.mjs'
3
+ const COMPOSITE_OPERATORS = Object.values(MATCH_LOGIC)
4
+
5
+ export function composite(node) {
6
+ if (typeof node === 'function') return node
7
+ if (node === null || typeof node !== 'object' || Array.isArray(node)) {
8
+ throw new TypeError(`predicates: a composite must be a predicate or { ${COMPOSITE_OPERATORS.join(' | ')} }`)
9
+ }
10
+ const unknown = Object.keys(node).filter((key) => !COMPOSITE_OPERATORS.includes(key))
11
+ if (unknown.length > 0) {
12
+ throw new TypeError(`predicates: unknown composite key(s) ${unknown.join(', ')} — allowed: ${COMPOSITE_OPERATORS.join(', ')}`)
13
+ }
14
+ const declared = COMPOSITE_OPERATORS.filter((key) => node[key] !== undefined)
15
+ if (declared.length !== 1) {
16
+ throw new TypeError(`predicates: a composite node must declare exactly one of ${COMPOSITE_OPERATORS.join(', ')}`)
17
+ }
18
+ const operator = declared[0]
19
+ if (operator === MATCH_LOGIC.NOT) {
20
+ const child = composite(node[operator])
21
+ const predicate = (payload) => {
22
+ const result = child(payload)
23
+ return result === UNAVAILABLE ? UNAVAILABLE : !result
24
+ }
25
+ predicate.kind = 'composite'
26
+ if (typeof child.observe === 'function') {
27
+ predicate.observe = (session, event) => child.observe(session, event)
28
+ }
29
+ return predicate
30
+ }
31
+ const list = node[operator]
32
+ if (!Array.isArray(list) || list.length === 0) {
33
+ throw new TypeError(`predicates: composite ${operator} needs a non-empty array of predicates`)
34
+ }
35
+ const children = list.map(composite)
36
+ const predicates = {
37
+ [MATCH_LOGIC.ANY]: (payload) => {
38
+ let unknown = false
39
+ for (const child of children) {
40
+ const result = child(payload)
41
+ if (result === UNAVAILABLE) unknown = true
42
+ else if (result) return true
43
+ }
44
+ return unknown ? UNAVAILABLE : false
45
+ },
46
+ [MATCH_LOGIC.ALL]: (payload) => {
47
+ let unknown = false
48
+ for (const child of children) {
49
+ const result = child(payload)
50
+ if (result === UNAVAILABLE) unknown = true
51
+ else if (!result) return false
52
+ }
53
+ return unknown ? UNAVAILABLE : true
54
+ },
55
+ [MATCH_LOGIC.NOT_ANY]: (payload) => {
56
+ let unknown = false
57
+ for (const child of children) {
58
+ const result = child(payload)
59
+ if (result === UNAVAILABLE) unknown = true
60
+ else if (result) return false
61
+ }
62
+ return unknown ? UNAVAILABLE : true
63
+ },
64
+ }
65
+ const predicate = predicates[operator]
66
+ if (predicate === undefined) {
67
+ throw new TypeError(`predicates: composite ${operator} is not a supported operator`)
68
+ }
69
+ predicate.kind = 'composite'
70
+ // 组合层必须**转发子谓词的 observe**(B7 T1 的跨能力缺口修复):两条挂载路径的观察器
71
+ // 接线都按 `when.observe` 过滤(`trigger.mjs` 的 `wireTriggerObservers`),被组合包住的
72
+ // phase / count / session 谓词否则永远收不到活的 `session/event`——状态按 `session.id`
73
+ // 缓存后不再重扫(`shared.mjs` 的 `sessionMapGet`),判定会停在冷扫那一刻(计数恒旧值、
74
+ // 相位永不晋升)。`phase` 有 `subscribe: false` 可绕(代价是每次判定冷扫),
75
+ // `count` / `session` 没有该档,所以这条转发是它们唯一的活路。
76
+ const observers = children.filter((child) => typeof child.observe === 'function')
77
+ if (observers.length > 0) {
78
+ predicate.observe = (session, event) => {
79
+ for (const child of observers) child.observe(session, event)
80
+ }
81
+ }
82
+ return predicate
83
+ }
@@ -0,0 +1,140 @@
1
+ import { MAX_TRACKED_SESSIONS, extractText, isDelegated, sessionEvents } from '../shared.mjs'
2
+ import { sessionOf } from './subject.mjs'
3
+ import { boundOf, optionalBoolean } from './values.mjs'
4
+
5
+ const COUNT_SIGNALS = {
6
+ 'tool-call': { type: 'tool/call', measure: 'count' },
7
+ 'tool-result': { type: 'tool/result', measure: 'count' },
8
+ 'assistant-message': { type: 'assistant/message', measure: 'count' },
9
+ 'assistant-chars': { type: 'assistant/message', measure: 'chars' },
10
+ 'user-message': { type: 'user/message', measure: 'count' },
11
+ turn: { type: 'turn/start', measure: 'count' },
12
+ }
13
+
14
+ /** 每会话最多保留的轮次计数(与 deliberation-gate 的 MAX_TRACKED_TURNS 同策略)。 */
15
+ const MAX_TRACKED_TURNS = 8
16
+
17
+ /** 轮边界事件:`per: 'turn'` 的当前轮由它与计数信号自身的轮号共同推进。 */
18
+ const TURN_EVENT = 'turn/start'
19
+
20
+ /**
21
+ * 计数判断:次数 / 字符数 / 轮次,带上下限与**冷启动重建**。
22
+ *
23
+ * - `per: 'session'` 计整个 durable log;`per: 'turn'` 只计**最新轮**,当前轮由
24
+ * `turn/start` 与计数信号自身带轮号的事件共同推进(与 deliberation-gate 的
25
+ * `turnOf` 同规则),轮号取自 `event.data.turn`;无轮号的事件不参与轮内计数
26
+ * (宁可少计,也不把跨轮数据算进当前轮)。
27
+ * - 冷启动:首次判定冷扫 `sessionEvents(session)` 重建(重启/恢复/重挂后同一结果),
28
+ * 之后 O(1);`observe` 是增量喂入,**只喂已判定过的会话**,避免「冷扫 + 增量」重复计数。
29
+ * - 上下限:`count >= min`(声明时)且 `count <= max`(声明时)。至少要声明一侧,
30
+ * 否则该判定恒真——那是把配置错误伪装成命中。
31
+ * - 取不到会话 = 无事件 = 计数 0(与空会话同一语义,不发明数据);无 `id` 的会话
32
+ * 不记账(共享一个 Map 键会让两个会话串味),每次冷扫。
33
+ *
34
+ * @param {object} options
35
+ * @param {'tool-call'|'tool-result'|'assistant-message'|'assistant-chars'|'user-message'|'turn'} options.of
36
+ * @param {'session'|'turn'} [options.per] 缺省 session
37
+ * @param {number} [options.min]
38
+ * @param {number} [options.max]
39
+ * @returns {((payload: unknown) => boolean) & { kind: string, observe: Function }}
40
+ */
41
+ export function createCountPredicate(options = {}) {
42
+ const signal = COUNT_SIGNALS[options.of]
43
+ if (signal === undefined) {
44
+ throw new TypeError(`predicates: count of must be one of ${Object.keys(COUNT_SIGNALS).join(', ')}`)
45
+ }
46
+ const per = options.per ?? 'session'
47
+ if (per !== 'session' && per !== 'turn') {
48
+ throw new TypeError('predicates: count per must be "session" or "turn"')
49
+ }
50
+ const min = boundOf(options.min, 'min')
51
+ const max = boundOf(options.max, 'max')
52
+ // 节奏判据(2026-09-22 用户拍板⑤「改用可重建计数」的核心语义):`every: N` = 每 N 次命中一次。
53
+ // 原 `progress-reminder` 的 `results % every === 0` 配的是**自增后**的计数
54
+ // (声明侧见 `test/engine/declarations/progress-reminder.yml` 的 `count.every`),
55
+ // 所以这里必须要求 `count > 0`——否则 `0 % N === 0` 会让第 0 次就命中。
56
+ const every = options.every === undefined
57
+ ? undefined
58
+ : Number.isSafeInteger(options.every) && options.every > 0
59
+ ? options.every
60
+ : (() => { throw new TypeError('predicates: count every must be a positive integer') })()
61
+ if (min === undefined && max === undefined && every === undefined) {
62
+ throw new TypeError('predicates: a count predicate needs min, max or every')
63
+ }
64
+ // 计数时点对齐(2026-09-22 用户拍板「扩原语」):某些通道的 `when` 在**处理器入口**求值,
65
+ // 早于本次信号事件落盘——`tools/post-execute` 就是 `dsh-tools` 的
66
+ // `finalizeScheduledExecution` 内部一环,而该包内不写 durable `tool/result`,落盘发生在
67
+ // `execute()` 返回后的 agent loop。`includeCurrent: true` 把「本次触发即将成为的那个事件」
68
+ // 计入,从而与模块自己的自增计数时点对齐。
69
+ // **适用边界由声明者负责**:谓词看不到通道名,只有在「当前事件即将成为该信号」的通道上
70
+ // 这个 +1 才成立(post-execute 上的 tool-result 成立;pre-execute 上的 tool-result 不成立)。
71
+ const includeCurrent = optionalBoolean(options.includeCurrent, 'includeCurrent', false)
72
+ // 受众判据(与 `session` 谓词的 `delegated` 同语义、同实现 `shared.isDelegated`)。
73
+ // 需求的来源是原 `deliberation-gate` 与原 `progress-reminder`:这两个能力的
74
+ // **主判据就是 count**,受众不能只挂在 `session` 谓词上——那会迫使声明额外耦合一个
75
+ // durable 事件类型(还要回答「哪种 type 恒真」,而哨兵轮表明甚至可能没有轮事件)。
76
+ const delegated = optionalBoolean(options.delegated, 'delegated', undefined)
77
+ /** sessionId -> { total, turns, lastTurn }(进程内快路径,真相在 durable 事件流)。 */
78
+ const state = new Map()
79
+ const fresh = () => ({ total: 0, turns: new Map(), lastTurn: undefined })
80
+ const measure = (event) => (signal.measure === 'chars' ? extractText(event.data).length : 1)
81
+ /** 轮号推进:与 deliberation-gate 的 turnOf/turnEntryOf 同规则——`turn/start` 与
82
+ * 计数信号自身带轮号的事件都推进当前轮,且取最大值;新轮先落 0,再累加。 */
83
+ const trackTurn = (entry, turn) => {
84
+ if (!entry.turns.has(turn)) {
85
+ if (entry.turns.size >= MAX_TRACKED_TURNS) {
86
+ const oldest = [...entry.turns.keys()]
87
+ .sort((left, right) => left - right)
88
+ .slice(0, entry.turns.size - MAX_TRACKED_TURNS + 1)
89
+ for (const key of oldest) entry.turns.delete(key)
90
+ }
91
+ entry.turns.set(turn, 0)
92
+ }
93
+ if (entry.lastTurn === undefined || turn > entry.lastTurn) entry.lastTurn = turn
94
+ }
95
+ const applyEvent = (entry, event) => {
96
+ const type = event?.type
97
+ const turn = event?.data?.turn
98
+ const tagged = typeof turn === 'number' && Number.isFinite(turn)
99
+ if (per === 'turn' && tagged && (type === TURN_EVENT || type === signal.type)) trackTurn(entry, turn)
100
+ if (type !== signal.type) return entry
101
+ const amount = measure(event)
102
+ if (per === 'session') {
103
+ entry.total += amount
104
+ return entry
105
+ }
106
+ // 轮内计数只认带轮号的事件(与 deliberation-gate 一致:宁可少计,也不把跨轮数据算进当前轮)。
107
+ if (!tagged) return entry
108
+ entry.turns.set(turn, (entry.turns.get(turn) ?? 0) + amount)
109
+ return entry
110
+ }
111
+ const countOf = (entry) => (per === 'session'
112
+ ? entry.total
113
+ : (entry.lastTurn === undefined ? 0 : entry.turns.get(entry.lastTurn) ?? 0))
114
+ const rebuild = (session) => {
115
+ const entry = fresh()
116
+ for (const event of sessionEvents(session)) applyEvent(entry, event)
117
+ return entry
118
+ }
119
+ const entryOf = (session) => {
120
+ if (session?.id === undefined) return rebuild(session)
121
+ const known = state.get(session.id)
122
+ if (known !== undefined) return known
123
+ const entry = rebuild(session)
124
+ if (state.size >= MAX_TRACKED_SESSIONS) state.clear()
125
+ state.set(session.id, entry)
126
+ return entry
127
+ }
128
+ const predicate = (payload) => {
129
+ if (delegated !== undefined && isDelegated(sessionOf(payload)) !== delegated) return false
130
+ const count = countOf(entryOf(sessionOf(payload))) + (includeCurrent ? 1 : 0)
131
+ if (every !== undefined && (count === 0 || count % every !== 0)) return false
132
+ return (min === undefined || count >= min) && (max === undefined || count <= max)
133
+ }
134
+ predicate.kind = 'count'
135
+ predicate.observe = (session, event) => {
136
+ const entry = session?.id === undefined ? undefined : state.get(session.id)
137
+ if (entry !== undefined) applyEvent(entry, event)
138
+ }
139
+ return predicate
140
+ }
@@ -0,0 +1,41 @@
1
+ import { subjectOf } from './subject.mjs'
2
+
3
+ /**
4
+ * 一次官方调用的局部判定帧;不按 session、turn 或载荷身份缓存。
5
+ * `ctx` 是判定期服务入口:子代理事件靠它用 `id` 反查 agent(见 subject.mjs 的
6
+ * `subagentFacts`),使 `modelScope`/`audience` 在判定阶段就有事实可用。
7
+ */
8
+ export function ruleFrame(channel, args, warnOnce = () => {}, ctx) {
9
+ return { channel, args, subject: subjectOf(channel, args, warnOnce, ctx), decisions: new Map(), warnOnce }
10
+ }
11
+
12
+ export function ruleMatches(rule, frame) {
13
+ if (rule === undefined) return true
14
+ if (frame.decisions.has(rule)) return frame.decisions.get(rule)
15
+ let hit = rule.enabled !== false
16
+ if (hit && typeof rule.when === 'function') {
17
+ try { hit = rule.when(frame.subject) === true }
18
+ catch (error) { hit = false; frame.warnOnce(`rule ${rule.id} condition failed: ${String(error?.message ?? error)}`) }
19
+ }
20
+ frame.decisions.set(rule, hit)
21
+ return hit
22
+ }
23
+
24
+ /**
25
+ * 动作级判定:规则条件与动作自身的分支条件都要过。
26
+ *
27
+ * `bypassRuleWhen` 的动作来自规则级 `else`——它的条件里已经含 `not(if)`,再叠加
28
+ * `rule.when` 会自相矛盾、令该分支永不执行。动作条件缺省 = 恒真;非 `true`
29
+ * (含 UNAVAILABLE 三值语义)一律不命中,与 `ruleMatches` 同一纪律。
30
+ */
31
+ export function actionMatches(entry, frame) {
32
+ if (entry.bypassRuleWhen !== true && !ruleMatches(entry.rule, frame)) return false
33
+ const when = entry.actionWhen
34
+ if (when === undefined) return true
35
+ try {
36
+ return when(frame.subject) === true
37
+ } catch (error) {
38
+ frame.warnOnce(`action ${entry.id} condition failed: ${String(error?.message ?? error)}`)
39
+ return false
40
+ }
41
+ }
@@ -0,0 +1,11 @@
1
+ export const PREDICATE_EXAMPLES = {
2
+ text: { subject: 'userMessage', keys: ['keyword'] },
3
+ phase: { promoted: false },
4
+ source: { kind: 'user' },
5
+ count: { of: 'tool-call', per: 'turn', min: 1 },
6
+ names: { allow: ['bash'] },
7
+ session: { type: 'user/message', present: false },
8
+ preset: { presetId: 'preset-id' },
9
+ scope: { audience: 'main', modelScope: 'all' },
10
+ anchor: { keys: ['We'], fallbackAfter: 1 },
11
+ }