dsh-plugin-prompt-tool 0.7.1 → 0.8.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 (169) hide show
  1. package/README.md +185 -46
  2. package/engine/THIRD_PARTY_LICENSES +25 -0
  3. package/engine/actions.mjs +931 -0
  4. package/engine/anchor-match.mjs +6 -3
  5. package/engine/character-tools.mjs +11 -0
  6. package/engine/classify-task.mjs +18 -0
  7. package/engine/compaction-epoch.mjs +41 -20
  8. package/engine/compositions/library/agent-instructions.yml +9 -0
  9. package/engine/compositions/library/command-goal.yml +2 -6
  10. package/engine/compositions/library/compaction.yml +2 -32
  11. package/engine/compositions/library/delegation-ptc.yml +58 -0
  12. package/engine/compositions/library/delegation.yml +5 -38
  13. package/engine/compositions/library/persistent-shell.yml +4 -13
  14. package/engine/compositions/library/planning.yml +8 -23
  15. package/engine/compositions/library/skill-filesystem-cordis.yml +10 -0
  16. package/engine/compositions/library/skill-filesystem.yml +2 -8
  17. package/engine/compositions/library/tool-ask-user.yml +2 -3
  18. package/engine/compositions/library/tool-bash.yml +4 -12
  19. package/engine/compositions/library/tool-cordis.yml +7 -0
  20. package/engine/compositions/library/tool-fs-search.yml +2 -11
  21. package/engine/compositions/library/tool-fs.yml +2 -5
  22. package/engine/compositions/library/tool-goal.yml +2 -6
  23. package/engine/compositions/library/tool-jobs.yml +2 -18
  24. package/engine/compositions/library/tool-plugin-manager-disabled.yml +8 -0
  25. package/engine/compositions/library/tool-plugin-manager.yml +8 -0
  26. package/engine/compositions/library/tool-present.yml +7 -0
  27. package/engine/compositions/library/tool-presentation.yml +9 -0
  28. package/engine/compositions/library/tool-pwsh.yml +2 -6
  29. package/engine/compositions/library/tool-skill.yml +7 -0
  30. package/engine/compositions/library/tool-todo.yml +2 -4
  31. package/engine/compositions/library/tool-web.yml +2 -1
  32. package/engine/compositions/source/local/character-tools.yml +5 -0
  33. package/engine/compositions/source/local/declared-triggers.yml +12 -0
  34. package/engine/compositions/source/local/filesystem-editor.yml +30 -0
  35. package/engine/compositions/source/local/instruction-hint.yml +15 -0
  36. package/engine/compositions/{library/official-persistent-shell.yml → source/local/persistent-shell-posix.yml} +10 -8
  37. package/engine/compositions/source/local/prompt-config-engine.yml +3 -9
  38. package/engine/compositions/source/local/run-code-env.yml +4 -7
  39. package/engine/compositions/source/local/session-var-tools.yml +5 -0
  40. package/engine/compositions/source/local/skill-search.yml +3 -9
  41. package/engine/compositions/source/local/subagent-tool-policy.yml +21 -0
  42. package/engine/compositions/source/local/tool-bash-disabled.yml +9 -0
  43. package/engine/compositions/source/local/tool-config-engine.yml +8 -0
  44. package/engine/compositions/source/local/tool-git-bash.yml +15 -0
  45. package/engine/compositions/source/local/world-book-tools.yml +5 -0
  46. package/engine/condition.mjs +83 -0
  47. package/engine/declared-triggers.mjs +93 -0
  48. package/engine/executor.mjs +364 -185
  49. package/engine/fields.mjs +219 -0
  50. package/engine/fillers.mjs +12 -141
  51. package/engine/host-package.mjs +51 -0
  52. package/engine/instruction-hint.mjs +285 -0
  53. package/engine/interpolate.mjs +157 -29
  54. package/engine/layers.mjs +495 -75
  55. package/engine/predicates.mjs +753 -0
  56. package/engine/prompt-config-engine.mjs +63 -8
  57. package/engine/run-code-env.mjs +48 -31
  58. package/engine/schema.mjs +444 -103
  59. package/engine/sdk-strip.mjs +365 -0
  60. package/engine/session-var-tools.mjs +11 -0
  61. package/engine/session-vars.mjs +8 -6
  62. package/engine/shared.mjs +137 -0
  63. package/engine/skill-search.mjs +34 -21
  64. package/engine/st-macros.d.mts +22 -0
  65. package/engine/st-macros.mjs +208 -0
  66. package/engine/st-render.mjs +105 -0
  67. package/engine/st-world-book.d.mts +10 -0
  68. package/engine/st-world-book.mjs +259 -0
  69. package/engine/strategies.mjs +31 -20
  70. package/engine/subagent-tool-policy-core.mjs +400 -0
  71. package/engine/subagent-tool-policy.mjs +327 -0
  72. package/engine/tool-config-engine.mjs +207 -145
  73. package/engine/tool-definition.mjs +143 -0
  74. package/engine/{custom-bash.mjs → tool-git-bash.mjs} +38 -13
  75. package/engine/trigger-editor-meta.mjs +48 -0
  76. package/engine/trigger-spec.mjs +257 -0
  77. package/engine/trigger.mjs +304 -0
  78. package/engine/world-book-tools.mjs +11 -0
  79. package/lib/client.js +16495 -5456
  80. package/lib/client.js.map +1 -1
  81. package/lib/index.d.mts +628 -382
  82. package/lib/index.mjs +5391 -2942
  83. package/lib/paths-CY5psBqE.d.mts +1233 -0
  84. package/lib/preset-package-BnJqrU4R.mjs +16792 -0
  85. package/lib/preset-transfer.d.mts +2 -0
  86. package/lib/preset-transfer.mjs +2 -0
  87. package/package.json +91 -49
  88. package/preset/pt-cordis/preset.yml +43 -0
  89. package/preset/pt-cordis/skills/cordis-composition-reference/SKILL.md +29 -0
  90. package/preset/pt-cordis/skills/cordis-composition-reference/references/packages.md +501 -0
  91. package/preset/pt-cordis/skills/cordis-plugin-development/SKILL.md +38 -0
  92. package/preset/pt-cordis/skills/cordis-plugin-development/references/host-plugin.md +60 -0
  93. package/preset/pt-cordis/skills/cordis-plugin-development/references/mcp-bundle.md +5 -0
  94. package/preset/pt-cordis/skills/cordis-plugin-development/references/ui-plugin.md +13 -0
  95. package/preset/pt-cordis/skills/cordis-plugin-development/references/verification.md +5 -0
  96. package/preset/pt-cordis/skills/cordis-plugin-development/templates/decoration/client.js +22 -0
  97. package/preset/pt-cordis/skills/cordis-plugin-development/templates/decoration/cordis.patch.yml +3 -0
  98. package/preset/pt-cordis/skills/cordis-plugin-development/templates/decoration/index.js +2 -0
  99. package/preset/pt-cordis/skills/cordis-plugin-development/templates/decoration/package.json +15 -0
  100. package/preset/pt-cordis/skills/cordis-plugin-development/templates/mcp/cordis.patch.yml +8 -0
  101. package/preset/pt-cordis/skills/cordis-plugin-development/templates/mcp/package.json +7 -0
  102. package/preset/pt-cordis/skills/editing-cordis-compositions/SKILL.md +84 -0
  103. package/preset/pt-custom/preset.yml +15 -0
  104. package/preset/pt-minimal/preset.yml +27 -0
  105. package/preset/pt-ptc/preset.yml +45 -0
  106. package/preset/pt-standard/preset.yml +43 -0
  107. package/templates/14-first-turn-anchor.yml +4 -4
  108. package/templates/15-guide-auto.yml +3 -3
  109. package/templates/16-custom-fallback.yml +1 -0
  110. package/templates/18-placeholder-env-facts.yml +3 -3
  111. package/templates/20-system-section.yml +3 -2
  112. package/templates/65-turn-stop.yml +18 -0
  113. package/templates/66-subagent-start.yml +14 -0
  114. package/templates/75-subagent-end.yml +15 -0
  115. package/templates/tools/10-shell-command.yml +1 -1
  116. package/templates/tools/40-delegate-character.yml +1 -1
  117. package/templates/tools/50-delegate-worldbook.yml +1 -1
  118. package/templates/tools/60-delegate-session-var.yml +1 -1
  119. package/templates/tools/70-delegate-worldbook-upsert.yml +1 -1
  120. package/engine/anchor-turn.mjs +0 -68
  121. package/engine/code-presentation.mjs +0 -103
  122. package/engine/compositions/library/anchor-turn.yml +0 -10
  123. package/engine/compositions/library/bootstrap-filesystem.yml +0 -19
  124. package/engine/compositions/library/code-presentation.yml +0 -8
  125. package/engine/compositions/library/context-gate.yml +0 -39
  126. package/engine/compositions/library/cot-drip.yml +0 -11
  127. package/engine/compositions/library/custom-bash.yml +0 -14
  128. package/engine/compositions/library/deliberation-gate.yml +0 -11
  129. package/engine/compositions/library/official-agent-instructions.yml +0 -17
  130. package/engine/compositions/library/official-skill-filesystem-cordis.yml +0 -10
  131. package/engine/compositions/library/official-tool-bash.yml +0 -6
  132. package/engine/compositions/library/official-tool-cordis.yml +0 -13
  133. package/engine/compositions/library/official-tool-presentation.yml +0 -7
  134. package/engine/compositions/library/official-tool-skill.yml +0 -14
  135. package/engine/compositions/library/persona.yml +0 -13
  136. package/engine/compositions/library/prompt-config-engine.yml +0 -16
  137. package/engine/compositions/library/run-code-env.yml +0 -15
  138. package/engine/compositions/library/skill-search.yml +0 -14
  139. package/engine/compositions/library/str-replace-editor.yml +0 -10
  140. package/engine/compositions/library/tool-bootstrap.yml +0 -14
  141. package/engine/compositions/library/tool-config-engine.yml +0 -9
  142. package/engine/compositions/library/tool-filter.yml +0 -8
  143. package/engine/compositions/source/local/code-presentation.yml +0 -8
  144. package/engine/compositions/source/local/context-gate.yml +0 -36
  145. package/engine/compositions/source/local/custom-bash.yml +0 -11
  146. package/engine/compositions/source/local/str-replace-editor.yml +0 -10
  147. package/engine/compositions/source/local/tool-bootstrap.yml +0 -11
  148. package/engine/compositions/source/local/tool-filter.yml +0 -8
  149. package/engine/context-gate.mjs +0 -305
  150. package/engine/cot-drip.mjs +0 -137
  151. package/engine/deliberation-gate.mjs +0 -127
  152. package/engine/tool-bootstrap.mjs +0 -476
  153. package/engine/tool-filter.mjs +0 -80
  154. package/lib/preset-core.d.mts +0 -10
  155. package/lib/preset-core.mjs +0 -77
  156. package/lib/prompt-configs-BnQKRHVb.mjs +0 -884
  157. package/lib/prompt-configs-CN0sJTUL.d.mts +0 -193
  158. package/preset/anchored/preset.yml +0 -296
  159. package/preset/creative/preset.yml +0 -67
  160. package/preset/creative/skills/cordis-plugin-development/SKILL.md +0 -420
  161. package/preset/creative/skills/editing-cordis-compositions/SKILL.md +0 -165
  162. package/preset/custom/preset.yml +0 -12
  163. package/preset/liangshen/preset.yml +0 -75
  164. package/preset/minimal/preset.yml +0 -45
  165. package/preset/ptc/preset.yml +0 -58
  166. package/preset/standard/preset.yml +0 -57
  167. package/skills/manifest.json +0 -7
  168. package/skills/sandboxmod/SKILL.md +0 -49
  169. package/skills/web ui/SKILL.md +0 -42
@@ -0,0 +1,753 @@
1
+ /**
2
+ * predicates — 判断原语库(B3/T1):`判断 → 执行` 的前半身。
3
+ *
4
+ * 统一接口:`predicate(payload) → boolean`。判定**只读、无副作用、判定期不做 IO**;
5
+ * 配置错误(空键集合、非法 logic、非法正则、非法取值)一律在**挂载期** fail loud,
6
+ * 运行期取不到数据只按「不命中」降级,绝不抛出——静默失效比拒绝挂载更难发现。
7
+ * 每个谓词函数带 `kind` 标签(诊断层按类别输出,不打印正文)。
8
+ *
9
+ * 七类原语与它们包装的既有实现(B1–B6 零行为变更:同一输入下判定逐例一致):
10
+ *
11
+ * | 类别 | 工厂 | 包装的既有实现 |
12
+ * |----------|-----------------------------|--------------------------------------------------|
13
+ * | 文本 | createTextPredicate | anchor-match.mjs(字/词/句、any/all/not/notAny、整词、正则) |
14
+ * | 相位 | createPhasePredicate | compaction-epoch.mjs(epoch 边界 + 两种复位语义) |
15
+ * | 来源 | createSourcePredicate | context-gate / executor 的 source.kind / source.plugin 比较 |
16
+ * | 计数 | createCountPredicate | deliberation-gate / progress-reminder 的计数(冷扫重建) |
17
+ * | 名单 | createNameListPredicate | tool-filter 的 nameSet + allow/deny 语义 |
18
+ * | 会话状态 | createSessionStatePredicate | anchor-turn 的 isFreshSession |
19
+ * | 当前预设 | createPresetPredicate | 官方 agent-preset-registry 的 composedPreset / standingMountFor |
20
+ *
21
+ * 组合:`composite({ any | all | not | notAny })`,短路求值;运算符词汇与
22
+ * anchor-match 的 `MATCH_LOGIC` 同源,但**语义不同**:组合层的 `not` 是标准取反,
23
+ * anchor-match 的 `NOT` 是「主键命中且副键全部未命中」的二集语义,两者不可混用。
24
+ */
25
+
26
+ import { MATCH_LOGIC, createAnchorMatcher } from './anchor-match.mjs'
27
+ import { createEpochPromotion } from './compaction-epoch.mjs'
28
+ import {
29
+ lastAssistantText,
30
+ subjectTextOf,
31
+ subagentTextOf,
32
+ toolArgsText,
33
+ userMessagesText,
34
+ } from './condition.mjs'
35
+ import { importHostPackage } from './host-package.mjs'
36
+ import {
37
+ MAX_TRACKED_SESSIONS,
38
+ extractText,
39
+ getService,
40
+ isDelegated,
41
+ parsePromoteOn,
42
+ sessionEvents,
43
+ } from './shared.mjs'
44
+
45
+ /** 组合运算符词汇(与 anchor-match 的 logic 同源,避免两处各记一套)。 */
46
+ const COMPOSITE_OPERATORS = Object.values(MATCH_LOGIC)
47
+
48
+ /** 文本匹配的布尔开关(与 schema.mjs 的 match 归一化同一套字段)。 */
49
+ const MATCH_FLAGS = ['caseSensitive', 'wholeWords', 'useRegex', 'stWords']
50
+
51
+ /** 文本匹配模式(anchor-match 支持的两档)。 */
52
+ const MATCH_MODES = ['scan', 'prefix']
53
+
54
+ /**
55
+ * 通道 → 谓词载荷的取法表(**每条都对应处理器实际解构过的字段**,不是推测)。
56
+ *
57
+ * 为什么需要这一层:谓词契约是**单个对象**(见本文件头「统一接口」),而事件处理器收到的
58
+ * 是**参数表**(waterfall 的最后一个参数是 `next`)。此前两条挂载路径
59
+ * (`actions.mjs` 的 `withWhen`、`trigger.mjs` 的内联 handler)都直接 `when(...payload)`
60
+ * 把参数表**展开成多个实参**,于是除 `names`(`tools/pre-execute` 的第一个实参恰好是
61
+ * `exec`)外,所有谓词在真实通道上都拿不到它要的域——`phase` 甚至恒为「已晋升」
62
+ * (`compaction-epoch.mjs` 的 `status(undefined)` → `promoted: true`),使 `not phase`
63
+ * 恒为 false、声明永不命中。
64
+ *
65
+ * 依据(各通道的处理器签名,见 `actions.mjs` 的注册器与 `context-gate.mjs` 的用法):
66
+ * `system-prompt/assemble` `(assembly, context, next)` → agent 在 **第二个**实参
67
+ * `agent/request` `(payload, next)` → `payload.agent`
68
+ * `agent/pre-step` `({ agent, messages, … })` → `agent`
69
+ * `tools/pre-execute` `(exec, next)` → `exec.agent` / `exec.name`
70
+ * `tools/post-execute` `(exec, result, next)` → `exec.agent` / `exec.name`
71
+ * `agent/turn-stopping` `({ agent, turn })` → `agent`
72
+ * `agent/inbox/inserted` `({ agent, message })` → `agent` / `message.source`
73
+ * `session/event` `(session, event)` → `session`
74
+ */
75
+ const CHANNEL_SUBJECTS = {
76
+ 'system-prompt/assemble': (args) => ({ agent: args[1]?.agent }),
77
+ 'agent/request': (args) => ({ agent: args[0]?.agent }),
78
+ 'agent/pre-step': (args) => ({ agent: args[0]?.agent }),
79
+ 'agent/inbox/inserted': (args) => ({ agent: args[0]?.agent, source: args[0]?.message?.source }),
80
+ 'agent/turn-stopping': (args) => ({ agent: args[0]?.agent }),
81
+ 'tools/pre-execute': (args) => ({ agent: args[0]?.agent, name: args[0]?.name }),
82
+ 'tools/post-execute': (args) => ({ agent: args[0]?.agent, name: args[0]?.name }),
83
+ 'session/event': (args) => ({ session: args[0] }),
84
+ }
85
+
86
+ /** 未知通道的告警去重(每个通道一次;判定期不刷屏)。 */
87
+ const warnedChannels = new Set()
88
+
89
+ /**
90
+ * 归一化载荷的标记(Symbol,非字符串键):`subjectOf` 产出它,`agentOf`/`sessionOf` 据此
91
+ * 区分「归一化载荷」与「旧形态的直接 payload」——**不用启发式猜**(曾按「有没有 `.session`」
92
+ * 判断,结果把测试里 `{ id, ctx }` 形状的假 agent 误判成归一化载荷)。
93
+ */
94
+ const SUBJECT_MARK = Symbol('predicate-subject')
95
+
96
+ /**
97
+ * 把事件参数表归一为谓词载荷。
98
+ *
99
+ * `args` 传的是**去掉 `next` 之后的参数表**(无 `next` 的 emit/serial 通道原样传)。
100
+ * 未知通道回落到「第一个实参」(= 归一化之前的旧行为)并**告警一次**——不静默丢弃,
101
+ * 否则新增通道会悄悄失去判定能力;也不抛错,判定期抛错会打断会话。
102
+ *
103
+ * @param {string} channel 事件名
104
+ * @param {unknown[]} args 去掉 next 的参数表
105
+ * @param {Function} [warn] 告警回调(缺省静默)
106
+ * @returns {unknown} 谓词载荷
107
+ */
108
+ export function subjectOf(channel, args, warn) {
109
+ // 第一个实参的字段**原样保留**在载荷顶层:旧形态(如 `agent/turn-stopping` 的
110
+ // `{ agent, turn }`)因此继续可见,既有断言不会因归一化而失效。
111
+ const first = args[0]
112
+ const base = first !== null && typeof first === 'object' && !Array.isArray(first) ? { ...first } : {}
113
+ const pick = CHANNEL_SUBJECTS[channel]
114
+ const text = (() => {
115
+ switch (channel) {
116
+ case 'tools/pre-execute': return { argsText: toolArgsText(args[0]?.arguments) }
117
+ case 'tools/post-execute': return { argsText: toolArgsText(args[0]?.arguments), resultText: extractText(args[1]) }
118
+ case 'agent/pre-step': return { userText: userMessagesText(args[0]?.messages) }
119
+ case 'agent/inbox/inserted': return { userText: userMessagesText([args[0]?.message]) }
120
+ case 'agent/turn-stopping': return { assistantText: lastAssistantText(args[0]?.agent?.session) }
121
+ case 'subagent/start':
122
+ case 'subagent/end': return { subagentText: subagentTextOf(args[0]) }
123
+ default: return {}
124
+ }
125
+ })()
126
+ if (pick === undefined) {
127
+ if (typeof warn === 'function' && !warnedChannels.has(channel)) {
128
+ warnedChannels.add(channel)
129
+ warn(`predicates: unknown channel ${JSON.stringify(channel)} — 无显式取法,退回「第一个实参即载荷」的旧语义`)
130
+ }
131
+ return { ...base, ...text, [SUBJECT_MARK]: true, channel, args }
132
+ }
133
+ return { ...base, ...pick(args), ...text, [SUBJECT_MARK]: true, channel, args }
134
+ }
135
+
136
+ /**
137
+ * 从载荷取 agent:**归一化载荷**读 `agent`(`subjectOf` 的标记为凭);**旧形态**
138
+ * (载荷本身就是 agent——手写调用方与既有测试都这样传)原样返回。
139
+ */
140
+ function agentOf(payload) {
141
+ if (payload === null || typeof payload !== 'object') return undefined
142
+ return payload[SUBJECT_MARK] === true ? payload.agent : payload
143
+ }
144
+
145
+ /**
146
+ * 从载荷取 session:归一化载荷走 `session` → `agent.session`;旧形态走
147
+ * `session` → 载荷自身。取不到时返回原载荷——`sessionEvents` 对无 `snapshotEvents`
148
+ * 的对象返回空,语义是「无事件」,与归一化之前逐例一致。
149
+ */
150
+ function sessionOf(payload) {
151
+ if (payload === null || typeof payload !== 'object') return payload
152
+ if (payload[SUBJECT_MARK] !== true) return payload.session ?? payload
153
+ return payload.session ?? payload.agent?.session
154
+ }
155
+
156
+ /** 可选布尔:缺省返回 fallback;显式非布尔一律 fail loud。 */
157
+ function optionalBoolean(value, field, fallback) {
158
+ if (value === undefined) return fallback
159
+ if (typeof value !== 'boolean') throw new TypeError(`predicates: ${field} must be a boolean`)
160
+ return value
161
+ }
162
+
163
+ /** 键集合归一化:非字符串数组 / 非数组一律 fail loud;空白键丢弃。 */
164
+ function keyList(value, field) {
165
+ if (value === undefined) return []
166
+ if (!Array.isArray(value) || value.some((item) => typeof item !== 'string')) {
167
+ throw new TypeError(`predicates: ${field} must be an array of strings when present`)
168
+ }
169
+ return value.map((item) => item.trim()).filter((item) => item.length > 0)
170
+ }
171
+
172
+ /** 名称集合:与 tool-filter 的 nameSet 同严格度(空串不是合法名称)。 */
173
+ function nameList(value, field) {
174
+ if (value === undefined) return undefined
175
+ if (!Array.isArray(value) || value.some((item) => typeof item !== 'string' || item.length === 0)) {
176
+ throw new TypeError(`predicates: ${field} must be an array of non-empty strings`)
177
+ }
178
+ return [...new Set(value)]
179
+ }
180
+
181
+ /** 计数上下限:缺省 = 不设该侧;非安全非负整数一律 fail loud。 */
182
+ function boundOf(value, field) {
183
+ if (value === undefined) return undefined
184
+ if (!Number.isSafeInteger(value) || value < 0) {
185
+ throw new TypeError(`predicates: count ${field} must be an integer >= 0`)
186
+ }
187
+ return value
188
+ }
189
+
190
+ /**
191
+ * 文本锚定判断(包装 anchor-match)。
192
+ *
193
+ * 判定对象:`payload` 为字符串时直接匹配;否则按 `subject` 从载荷取文本
194
+ * (复用 condition.mjs 的 subjectTextOf,取不到即空串 = 不命中)。
195
+ * 空键集合、非法 logic、非法正则、非法模式都在**构造期**抛错——运行时静默
196
+ * 不命中会让「配了锚定却没生效」变成不可见的失效。
197
+ *
198
+ * @param {object} options
199
+ * @param {string[]} [options.keys] 主键
200
+ * @param {string[]} [options.secondaryKeys] 副键
201
+ * @param {'any'|'all'|'not'|'notAny'} [options.logic] 组合逻辑(缺省 any)
202
+ * @param {boolean} [options.caseSensitive]
203
+ * @param {boolean} [options.wholeWords]
204
+ * @param {boolean} [options.useRegex] 三态:true=强制正则 / false=强制字面 / 缺省=ST 自动检测
205
+ * @param {boolean} [options.stWords] ST 整词规则(仅 ST 导入开启)
206
+ * @param {'scan'|'prefix'} [options.mode]
207
+ * @param {string} [options.subject] 从对象载荷取文本的字段名(condition.mjs 的 subject 词汇)
208
+ * @returns {(payload: unknown) => boolean}
209
+ */
210
+ export function createTextPredicate(options = {}) {
211
+ const keys = keyList(options.keys, 'keys')
212
+ const secondaryKeys = keyList(options.secondaryKeys, 'secondaryKeys')
213
+ if (keys.length === 0 && secondaryKeys.length === 0) {
214
+ throw new TypeError('predicates: a text predicate needs at least one non-empty key')
215
+ }
216
+ const logic = options.logic ?? MATCH_LOGIC.ANY
217
+ if (!Object.values(MATCH_LOGIC).includes(logic)) {
218
+ throw new TypeError(`predicates: logic must be one of ${Object.values(MATCH_LOGIC).join(', ')}`)
219
+ }
220
+ const mode = options.mode ?? 'scan'
221
+ if (!MATCH_MODES.includes(mode)) {
222
+ throw new TypeError(`predicates: mode must be one of ${MATCH_MODES.join(', ')}`)
223
+ }
224
+ for (const field of MATCH_FLAGS) {
225
+ if (options[field] !== undefined && typeof options[field] !== 'boolean') {
226
+ throw new TypeError(`predicates: ${field} must be a boolean when present`)
227
+ }
228
+ }
229
+ // 与 anchor-match 逐例等价的关键:匹配器就是 anchor-match 的匹配器,本层不加也不减语义。
230
+ const matcher = createAnchorMatcher({
231
+ keys,
232
+ secondaryKeys,
233
+ caseSensitive: options.caseSensitive === true,
234
+ wholeWords: options.wholeWords === true,
235
+ useRegex: options.useRegex,
236
+ logic,
237
+ mode,
238
+ stWords: options.stWords === true,
239
+ })
240
+ const subject = options.subject
241
+ const predicate = (payload) => matcher.scan(
242
+ typeof payload === 'string' ? payload : subjectTextOf(subject, payload),
243
+ ).active === true
244
+ predicate.kind = 'text'
245
+ return predicate
246
+ }
247
+
248
+ /**
249
+ * 相位判断(包装 compaction-epoch 的 epoch 感知晋升机)。
250
+ *
251
+ * 两种复位语义都从这里表达,差别只在**谁负责记忆**:
252
+ * - `subscribe: true`(缺省,等价于 tool-bootstrap / context-gate 的现状):
253
+ * 调用方把 `session/event` 喂给 `predicate.observe`,成功 `compaction/end`
254
+ * 在喂入时复位;判定读的是喂入维护的状态。
255
+ * - `subscribe: false`(不订阅):判定自己每次从 durable log 冷扫重建,
256
+ * 所以日志里新增的压缩边界**无需任何通知**就改变结果——适合一次性判定,
257
+ * 代价是每次判定 O(事件数)(ponytail: 不订阅即每次冷扫,需要热路径时改用订阅)。
258
+ *
259
+ * 相位语义本身(谁算晋升、子代理默认已晋升、非法 promoteOn fail loud)全部沿用
260
+ * 现有实现,本层只做包装。
261
+ *
262
+ * @param {object} options
263
+ * @param {'either'|'tool-call'|'assistant-message'} [options.promoteOn]
264
+ * @param {string[]} [options.promoteEvents] 显式事件集合(优先于 promoteOn)
265
+ * @param {boolean} [options.includeSubagents]
266
+ * @param {boolean} [options.promoteGate]
267
+ * @param {boolean} [options.promoteAfterFirstResponse]
268
+ * @param {number} [options.maxPromoteSteps]
269
+ * @param {boolean} [options.subscribe] 缺省 true
270
+ * @returns {((agent: unknown) => boolean) & { kind: string, observe?: Function }}
271
+ */
272
+ export function createPhasePredicate(options = {}) {
273
+ const promoteEvents = options.promoteEvents ?? parsePromoteOn('predicates', options.promoteOn)
274
+ if (!Array.isArray(promoteEvents) || promoteEvents.some((type) => typeof type !== 'string')) {
275
+ throw new TypeError('predicates: promoteEvents must be an array of event type strings')
276
+ }
277
+ const subscribe = optionalBoolean(options.subscribe, 'subscribe', true)
278
+ const trackerOptions = {
279
+ includeSubagents: options.includeSubagents === true,
280
+ promoteGate: options.promoteGate === true,
281
+ promoteAfterFirstResponse: options.promoteAfterFirstResponse === true,
282
+ maxPromoteSteps: options.maxPromoteSteps,
283
+ }
284
+ const tracker = createEpochPromotion(promoteEvents, trackerOptions)
285
+ // 载荷归一后 agent 在 `payload.agent`;旧的手工形态(载荷本身就是 agent)由 agentOf 兼容。
286
+ //
287
+ // `compacted`(2026-09-22 用户拍板「扩原语」):`status()` 本来就返回 `boundary`
288
+ // (最近一次成功 `compaction/end` 的 seq,未压缩过为 -1),此前只用了 `promoted`。
289
+ // `compacted: true` = 发生过成功压缩;`false` = 从未压缩;未声明 = 不判该维度。
290
+ const compacted = optionalBoolean(options.compacted, 'compacted', undefined)
291
+ // `promoted` 三态:缺省 `true`(= 原行为,向后兼容)/ `false` / `'ignore'`。
292
+ //
293
+ // 为什么需要它(对拍实测):原实现把 `compacted` 与 `promoted` 写成**合取**,于是可表达的
294
+ // 原子只有 `P`、`C∧P`、`¬C∧P`——三者在 **¬P 的会话上全为 false**,用 `any`/`all`/`not`/
295
+ // `notAny` 的任何组合都表达不出原 `tool-bootstrap` compaction 回退要的 **`C ∧ ¬P`**
296
+ // (受控相位 + 已压缩:压缩后回到受控工具集;声明侧见
297
+ // `test/engine/declarations/tool-bootstrap.yml` 声明 2)。解耦后
298
+ // `{ compacted: true, promoted: false }` 就是 `C ∧ ¬P`。
299
+ const promotedOption = options.promoted
300
+ if (promotedOption !== undefined && promotedOption !== true && promotedOption !== false && promotedOption !== 'ignore') {
301
+ throw new TypeError("predicates: phase promoted must be true, false or 'ignore'")
302
+ }
303
+ if (promotedOption === 'ignore' && compacted === undefined) {
304
+ throw new TypeError("predicates: phase promoted 'ignore' needs compacted — 否则该判定恒真")
305
+ }
306
+ const predicate = (payload) => {
307
+ const status = (subscribe ? tracker : createEpochPromotion(promoteEvents, trackerOptions))
308
+ .status(agentOf(payload))
309
+ if (compacted !== undefined && (status.boundary >= 0) !== compacted) return false
310
+ if (promotedOption === 'ignore') return true
311
+ return status.promoted === (promotedOption ?? true)
312
+ }
313
+ predicate.kind = 'phase'
314
+ // 只有订阅模式才有增量入口:不订阅时多一个入口只会诱使调用方以为状态被记住了。
315
+ if (subscribe) predicate.observe = (session, event) => tracker.observe(session, event)
316
+ return predicate
317
+ }
318
+
319
+ /** 来源通道取值归一化:未声明 = 该通道不参与判定;空集合在挂载期抛错("恒不命中"是配置错误)。 */
320
+ function sourceChannel(value, field) {
321
+ if (value === undefined) return undefined
322
+ if (typeof value === 'string' && value.length > 0) return [value]
323
+ if (Array.isArray(value) && value.length > 0 && value.every((item) => typeof item === 'string' && item.length > 0)) {
324
+ return [...new Set(value)]
325
+ }
326
+ throw new TypeError(`predicates: ${field} must be a non-empty string or an array of non-empty strings`)
327
+ }
328
+
329
+ /**
330
+ * 来源判断:`source.kind` / `source.plugin` 的比较(context-gate 的 allowKinds 一族
331
+ * 与 executor 的双通道去重都是这套比较)。
332
+ *
333
+ * - **精确**(缺省)为 `===` 语义,且**大小写敏感**——现有四处调用
334
+ * (context-gate / executor / anchor-turn / instruction-hint)全是精确且区分大小写,
335
+ * 统一不得放宽;大小写不敏感必须显式 `caseSensitive: false`。
336
+ * - **前缀**仅在 `match: 'prefix'` 时启用。
337
+ * - 声明了多个通道时是**合取**(都命中才算命中);需要析取用
338
+ * `composite({ any: [source({kind}), source({plugin})] })`——不隐式二选一。
339
+ *
340
+ * @param {object} options
341
+ * @param {string|string[]} [options.kind]
342
+ * @param {string|string[]} [options.plugin]
343
+ * @param {boolean} [options.caseSensitive] 缺省 true
344
+ * @param {'exact'|'prefix'} [options.match] 缺省 exact
345
+ * @returns {(payload: unknown) => boolean}
346
+ */
347
+ export function createSourcePredicate(options = {}) {
348
+ const kinds = sourceChannel(options.kind, 'kind')
349
+ const plugins = sourceChannel(options.plugin, 'plugin')
350
+ if (kinds === undefined && plugins === undefined) {
351
+ throw new TypeError('predicates: a source predicate needs kind or plugin')
352
+ }
353
+ const caseSensitive = optionalBoolean(options.caseSensitive, 'caseSensitive', true)
354
+ const match = options.match ?? 'exact'
355
+ if (match !== 'exact' && match !== 'prefix') {
356
+ throw new TypeError('predicates: source match must be "exact" or "prefix"')
357
+ }
358
+ const normalize = (value) => (caseSensitive ? value : value.toLowerCase())
359
+ const asSet = (list) => (list === undefined ? undefined : new Set(list.map(normalize)))
360
+ const kindSet = asSet(kinds)
361
+ const pluginSet = asSet(plugins)
362
+ const channelHit = (value, allowed) => {
363
+ if (allowed === undefined) return true
364
+ if (typeof value !== 'string' || value.length === 0) return false
365
+ const normalized = normalize(value)
366
+ if (match === 'exact') return allowed.has(normalized)
367
+ for (const prefix of allowed) {
368
+ if (normalized.startsWith(prefix)) return true
369
+ }
370
+ return false
371
+ }
372
+ const predicate = (payload) => {
373
+ const source = payload?.source
374
+ return channelHit(source?.kind, kindSet) && channelHit(source?.plugin, pluginSet)
375
+ }
376
+ predicate.kind = 'source'
377
+ return predicate
378
+ }
379
+
380
+ /**
381
+ * 计数信号词汇:语义相近但不相同的判断归入同一类,靠参数区分。
382
+ * `chars` 只计 `extractText(event.data).length`(与 deliberation-gate 的深度代理
383
+ * 逐字相同:只读 message.content,不读同一事件的 stream delta,避免双计)。
384
+ */
385
+ const COUNT_SIGNALS = {
386
+ 'tool-call': { type: 'tool/call', measure: 'count' },
387
+ 'tool-result': { type: 'tool/result', measure: 'count' },
388
+ 'assistant-message': { type: 'assistant/message', measure: 'count' },
389
+ 'assistant-chars': { type: 'assistant/message', measure: 'chars' },
390
+ 'user-message': { type: 'user/message', measure: 'count' },
391
+ turn: { type: 'turn/start', measure: 'count' },
392
+ }
393
+
394
+ /** 每会话最多保留的轮次计数(与 deliberation-gate 的 MAX_TRACKED_TURNS 同策略)。 */
395
+ const MAX_TRACKED_TURNS = 8
396
+
397
+ /** 轮边界事件:`per: 'turn'` 的当前轮由它与计数信号自身的轮号共同推进。 */
398
+ const TURN_EVENT = 'turn/start'
399
+
400
+ /**
401
+ * 计数判断:次数 / 字符数 / 轮次,带上下限与**冷启动重建**。
402
+ *
403
+ * - `per: 'session'` 计整个 durable log;`per: 'turn'` 只计**最新轮**,当前轮由
404
+ * `turn/start` 与计数信号自身带轮号的事件共同推进(与 deliberation-gate 的
405
+ * `turnOf` 同规则),轮号取自 `event.data.turn`;无轮号的事件不参与轮内计数
406
+ * (宁可少计,也不把跨轮数据算进当前轮)。
407
+ * - 冷启动:首次判定冷扫 `sessionEvents(session)` 重建(重启/恢复/重挂后同一结果),
408
+ * 之后 O(1);`observe` 是增量喂入,**只喂已判定过的会话**,避免「冷扫 + 增量」重复计数。
409
+ * - 上下限:`count >= min`(声明时)且 `count <= max`(声明时)。至少要声明一侧,
410
+ * 否则该判定恒真——那是把配置错误伪装成命中。
411
+ * - 取不到会话 = 无事件 = 计数 0(与空会话同一语义,不发明数据);无 `id` 的会话
412
+ * 不记账(共享一个 Map 键会让两个会话串味),每次冷扫。
413
+ *
414
+ * @param {object} options
415
+ * @param {'tool-call'|'tool-result'|'assistant-message'|'assistant-chars'|'user-message'|'turn'} options.of
416
+ * @param {'session'|'turn'} [options.per] 缺省 session
417
+ * @param {number} [options.min]
418
+ * @param {number} [options.max]
419
+ * @returns {((payload: unknown) => boolean) & { kind: string, observe: Function }}
420
+ */
421
+ export function createCountPredicate(options = {}) {
422
+ const signal = COUNT_SIGNALS[options.of]
423
+ if (signal === undefined) {
424
+ throw new TypeError(`predicates: count of must be one of ${Object.keys(COUNT_SIGNALS).join(', ')}`)
425
+ }
426
+ const per = options.per ?? 'session'
427
+ if (per !== 'session' && per !== 'turn') {
428
+ throw new TypeError('predicates: count per must be "session" or "turn"')
429
+ }
430
+ const min = boundOf(options.min, 'min')
431
+ const max = boundOf(options.max, 'max')
432
+ // 节奏判据(2026-09-22 用户拍板⑤「改用可重建计数」的核心语义):`every: N` = 每 N 次命中一次。
433
+ // 原 `progress-reminder` 的 `results % every === 0` 配的是**自增后**的计数
434
+ // (声明侧见 `test/engine/declarations/progress-reminder.yml` 的 `count.every`),
435
+ // 所以这里必须要求 `count > 0`——否则 `0 % N === 0` 会让第 0 次就命中。
436
+ const every = options.every === undefined
437
+ ? undefined
438
+ : Number.isSafeInteger(options.every) && options.every > 0
439
+ ? options.every
440
+ : (() => { throw new TypeError('predicates: count every must be a positive integer') })()
441
+ if (min === undefined && max === undefined && every === undefined) {
442
+ throw new TypeError('predicates: a count predicate needs min, max or every')
443
+ }
444
+ // 计数时点对齐(2026-09-22 用户拍板「扩原语」):某些通道的 `when` 在**处理器入口**求值,
445
+ // 早于本次信号事件落盘——`tools/post-execute` 就是 `dsh-tools` 的
446
+ // `finalizeScheduledExecution` 内部一环,而该包内不写 durable `tool/result`,落盘发生在
447
+ // `execute()` 返回后的 agent loop。`includeCurrent: true` 把「本次触发即将成为的那个事件」
448
+ // 计入,从而与模块自己的自增计数时点对齐。
449
+ // **适用边界由声明者负责**:谓词看不到通道名,只有在「当前事件即将成为该信号」的通道上
450
+ // 这个 +1 才成立(post-execute 上的 tool-result 成立;pre-execute 上的 tool-result 不成立)。
451
+ const includeCurrent = optionalBoolean(options.includeCurrent, 'includeCurrent', false)
452
+ // 受众判据(与 `session` 谓词的 `delegated` 同语义、同实现 `shared.isDelegated`)。
453
+ // 需求的来源是原 `deliberation-gate` 与原 `progress-reminder`:这两个能力的
454
+ // **主判据就是 count**,受众不能只挂在 `session` 谓词上——那会迫使声明额外耦合一个
455
+ // durable 事件类型(还要回答「哪种 type 恒真」,而哨兵轮表明甚至可能没有轮事件)。
456
+ const delegated = optionalBoolean(options.delegated, 'delegated', undefined)
457
+ /** sessionId -> { total, turns, lastTurn }(进程内快路径,真相在 durable 事件流)。 */
458
+ const state = new Map()
459
+ const fresh = () => ({ total: 0, turns: new Map(), lastTurn: undefined })
460
+ const measure = (event) => (signal.measure === 'chars' ? extractText(event.data).length : 1)
461
+ /** 轮号推进:与 deliberation-gate 的 turnOf/turnEntryOf 同规则——`turn/start` 与
462
+ * 计数信号自身带轮号的事件都推进当前轮,且取最大值;新轮先落 0,再累加。 */
463
+ const trackTurn = (entry, turn) => {
464
+ if (!entry.turns.has(turn)) {
465
+ if (entry.turns.size >= MAX_TRACKED_TURNS) {
466
+ const oldest = [...entry.turns.keys()]
467
+ .sort((left, right) => left - right)
468
+ .slice(0, entry.turns.size - MAX_TRACKED_TURNS + 1)
469
+ for (const key of oldest) entry.turns.delete(key)
470
+ }
471
+ entry.turns.set(turn, 0)
472
+ }
473
+ if (entry.lastTurn === undefined || turn > entry.lastTurn) entry.lastTurn = turn
474
+ }
475
+ const applyEvent = (entry, event) => {
476
+ const type = event?.type
477
+ const turn = event?.data?.turn
478
+ const tagged = typeof turn === 'number' && Number.isFinite(turn)
479
+ if (per === 'turn' && tagged && (type === TURN_EVENT || type === signal.type)) trackTurn(entry, turn)
480
+ if (type !== signal.type) return entry
481
+ const amount = measure(event)
482
+ if (per === 'session') {
483
+ entry.total += amount
484
+ return entry
485
+ }
486
+ // 轮内计数只认带轮号的事件(与 deliberation-gate 一致:宁可少计,也不把跨轮数据算进当前轮)。
487
+ if (!tagged) return entry
488
+ entry.turns.set(turn, (entry.turns.get(turn) ?? 0) + amount)
489
+ return entry
490
+ }
491
+ const countOf = (entry) => (per === 'session'
492
+ ? entry.total
493
+ : (entry.lastTurn === undefined ? 0 : entry.turns.get(entry.lastTurn) ?? 0))
494
+ const rebuild = (session) => {
495
+ const entry = fresh()
496
+ for (const event of sessionEvents(session)) applyEvent(entry, event)
497
+ return entry
498
+ }
499
+ const entryOf = (session) => {
500
+ if (session?.id === undefined) return rebuild(session)
501
+ const known = state.get(session.id)
502
+ if (known !== undefined) return known
503
+ const entry = rebuild(session)
504
+ if (state.size >= MAX_TRACKED_SESSIONS) state.clear()
505
+ state.set(session.id, entry)
506
+ return entry
507
+ }
508
+ const predicate = (payload) => {
509
+ if (delegated !== undefined && isDelegated(sessionOf(payload)) !== delegated) return false
510
+ const count = countOf(entryOf(sessionOf(payload))) + (includeCurrent ? 1 : 0)
511
+ if (every !== undefined && (count === 0 || count % every !== 0)) return false
512
+ return (min === undefined || count >= min) && (max === undefined || count <= max)
513
+ }
514
+ predicate.kind = 'count'
515
+ predicate.observe = (session, event) => {
516
+ const entry = session?.id === undefined ? undefined : state.get(session.id)
517
+ if (entry !== undefined) applyEvent(entry, event)
518
+ }
519
+ return predicate
520
+ }
521
+
522
+ /**
523
+ * 名单判断:名称集合的 allow / deny(tool-filter 的 applyMask 语义逐条保留)。
524
+ *
525
+ * - deny 命中即不通过;`allow` 已声明时不在其中也不通过;
526
+ * - **两侧都未声明 = 不过滤**(恒通过),与「两个列表都空 = 零开销不干预」一致;
527
+ * - 显式空数组是**已声明**:`allow: []` 一个都不通过,`deny: []` 一个都不拦;
528
+ * - 空名 / 非字符串名一律通过(动作侧对无名项的策略,保持 tool-filter 原样);
529
+ * - 大小写缺省敏感;不敏感必须显式声明(构造期统一小写,不在判定里做转换)。
530
+ *
531
+ * @param {object} options
532
+ * @param {string[]} [options.allow]
533
+ * @param {string[]} [options.deny]
534
+ * @param {boolean} [options.caseSensitive] 缺省 true
535
+ * @returns {(payload: unknown) => boolean}
536
+ */
537
+ export function createNameListPredicate(options = {}) {
538
+ const allow = nameList(options.allow, 'allow')
539
+ const deny = nameList(options.deny, 'deny')
540
+ const caseSensitive = optionalBoolean(options.caseSensitive, 'caseSensitive', true)
541
+ const normalize = (name) => (caseSensitive ? name : name.toLowerCase())
542
+ const allowSet = allow === undefined ? undefined : new Set(allow.map(normalize))
543
+ const denySet = deny === undefined ? undefined : new Set(deny.map(normalize))
544
+ const predicate = (payload) => {
545
+ const name = typeof payload === 'string' ? payload : payload?.name
546
+ if (typeof name !== 'string' || name.length === 0) return true
547
+ const value = normalize(name)
548
+ if (denySet !== undefined && denySet.has(value)) return false
549
+ if (allowSet !== undefined && !allowSet.has(value)) return false
550
+ return true
551
+ }
552
+ predicate.kind = 'names'
553
+ return predicate
554
+ }
555
+
556
+ /**
557
+ * 会话状态判断:某类 durable 事件在会话内是否出现过。
558
+ *
559
+ * 无状态:每次判定直接读 durable log 快照(与 anchor-turn 的 isFreshSession 同一读法,
560
+ * 逐例等价),因此重挂、恢复、压缩都不会给出过期答案。
561
+ * 缺省 `present: false` = 「尚无」,即 `createSessionStatePredicate({ type: 'user/message' })`
562
+ * 表达「会话内无 user/message」。
563
+ *
564
+ * @param {object} options
565
+ * @param {string} options.type 事件类型(如 'user/message')
566
+ * @param {boolean} [options.present] 缺省 false
567
+ * @returns {(payload: unknown) => boolean}
568
+ */
569
+ export function createSessionStatePredicate(options = {}) {
570
+ const type = options.type
571
+ if (typeof type !== 'string' || type.length === 0) {
572
+ throw new TypeError('predicates: a session-state predicate needs an event type')
573
+ }
574
+ const present = optionalBoolean(options.present, 'present', false)
575
+ // 受众判据(2026-09-22 用户拍板「扩原语」):`delegated: false` = 只在主会话命中,
576
+ // `true` = 只在子代理命中,未声明 = 不判受众。子代理判定复用 `shared.isDelegated`,
577
+ // 不写字面量比较(trigger.mjs 的会话态契约第 (2) 条纪律)。
578
+ const delegated = optionalBoolean(options.delegated, 'delegated', undefined)
579
+ const predicate = (payload) => {
580
+ if (delegated !== undefined && isDelegated(sessionOf(payload)) !== delegated) return false
581
+ return sessionEvents(sessionOf(payload))
582
+ .some((event) => event?.type === type) === present
583
+ }
584
+ predicate.kind = 'session'
585
+ return predicate
586
+ }
587
+
588
+ /** 官方 agent-preset-registry 包:standingMountFor 与 composedPreset 同源。 */
589
+ const AGENT_PRESETS_PACKAGE = '@deepseek-ai/dsh-agent-preset-registry'
590
+
591
+ /**
592
+ * 当前预设 id(进程内判定;官方唯一正确取法)。
593
+ *
594
+ * const id = ctx.get?.('agentPresets')?.composedPreset?.(agent.ctx) // 首选
595
+ * ?? standingMountFor(agent.ctx)?.presetId // 服务未就绪时的同源兜底
596
+ *
597
+ * 依据:官方 0.1.7 的 `agent-preset-registry/src/index.ts` 中 composedPreset
598
+ * 直接包装 `standingMountFor(agentCtx)?.presetId`;mount.ts 读取 live scope chain,
599
+ * invariant.ts 也以该结果判定 agent 是否已绑定预设,不依赖事件落盘。
600
+ *
601
+ * 三条硬约束(改这里之前先读 PLAN 的「审查结论」):
602
+ * 1. **禁止读 `session.header.agentPreset`**——它是出生预设。官方 `agent-preset-registry/src/session.ts`
603
+ * 明写「Reconstruction reads the `agentPreset` Session projection, never the header alone」,
604
+ * 投影初值取自 header、之后由 `agent-preset/selected` 推进;真机实测亦有 header 与
605
+ * 实际挂载不一致的案例。
606
+ * 2. **禁止用 registry 的 defaultId** 当当前预设——它是
607
+ * **新会话的默认值**,与任何已存在 agent 的挂载无关。
608
+ * 3. **`undefined` 的含义是「该 agent 没有预设」**(裸 agent),不是「取不到」;
609
+ * **不得回退到默认值**,调用方按「不干预 / 不命中」处理。
610
+ *
611
+ * 适用边界:`composedPreset` 只在**进程内、拿得到 `agent.ctx`** 时可用(装配期
612
+ * `context.agent.ctx`、子代理认领、引擎能力)。跨 API/客户端边界没有这个方法——
613
+ * 官方 `ctx.remote.agentPresets` 提供 `list / select` 等远程操作,
614
+ * 客户端拿不到 `agent.ctx` 也无法用本方法。**两种取法是分工而非替代**:
615
+ * 进程内判定用本函数,跨边界用会话投影(`sessionProjections.stateOf(session, 'agentPreset')`)。
616
+ *
617
+ * @param {object|undefined} ctx 插件/运行时 ctx(服务查询基准)
618
+ * @param {{ctx?: unknown}|undefined} agent 判定对象(读它的 `ctx` = agent scope context)
619
+ * @param {Function} [standingMountFor] 官方模块导出兜底(服务未就绪时用)
620
+ * @returns {string|undefined} 预设 id;`undefined` = 该 agent 没有预设
621
+ */
622
+ export function agentPresetId(ctx, agent, standingMountFor) {
623
+ try {
624
+ const service = getService(ctx, 'agentPresets')
625
+ const composed = typeof service?.composedPreset === 'function'
626
+ ? service.composedPreset(agent?.ctx)
627
+ : undefined
628
+ if (composed !== undefined && composed !== null) return composed
629
+ const mount = typeof standingMountFor === 'function' ? standingMountFor(agent?.ctx) : undefined
630
+ return mount?.presetId
631
+ } catch {
632
+ // 判定不抛出:拿不到就是「没有预设」(不命中),与 condition.mjs 的降级纪律一致。
633
+ return undefined
634
+ }
635
+ }
636
+
637
+ /**
638
+ * 惰性解析官方 `standingMountFor`(挂载期一次,判定期零 IO)。
639
+ * 官方包缺席(最小组合、测试环境)=没有兜底,不是错误:此时服务缺席即判定恒 undefined。
640
+ * @param {string} [entry] 解析基准(缺省按宿主入口解析;测试/嵌入可显式指定)
641
+ * @returns {Promise<Function|undefined>}
642
+ */
643
+ export async function loadStandingMountFor(entry) {
644
+ try {
645
+ const module = await importHostPackage(AGENT_PRESETS_PACKAGE, entry)
646
+ return typeof module?.standingMountFor === 'function' ? module.standingMountFor : undefined
647
+ } catch {
648
+ return undefined
649
+ }
650
+ }
651
+
652
+ /**
653
+ * 当前预设判断:`agentPresetId(...) === presetId`。
654
+ *
655
+ * `ctx` 在**构造期**绑定(服务查询基准,通常是触发器运行时的插件 ctx);
656
+ * 判定只读 payload(agent)。服务与兜底都取不到时恒不命中——**不回落默认值**。
657
+ *
658
+ * @param {object} options
659
+ * @param {string} options.presetId 目标预设 id
660
+ * @param {object} [options.ctx] 插件/运行时 ctx
661
+ * @param {Function} [options.standingMountFor] 官方模块导出兜底
662
+ * @returns {(agent: unknown) => boolean}
663
+ */
664
+ export function createPresetPredicate(options = {}) {
665
+ const presetId = options.presetId
666
+ if (typeof presetId !== 'string' || presetId.length === 0) {
667
+ throw new TypeError('predicates: a preset predicate needs a presetId')
668
+ }
669
+ const ctx = options.ctx
670
+ const standingMountFor = options.standingMountFor
671
+ const predicate = (payload) => agentPresetId(ctx, agentOf(payload), standingMountFor) === presetId
672
+ predicate.kind = 'preset'
673
+ return predicate
674
+ }
675
+
676
+ /**
677
+ * 组合判断(短路求值):`{ any: [...] }` / `{ all: [...] }` / `{ not: p }` / `{ notAny: [...] }`。
678
+ *
679
+ * - 一个节点**只能声明一个运算符**:混合声明(如同时给 all 与 not)在挂载期抛错。
680
+ * 优先级因此永远是显式的树形嵌套,而不是靠"先算 not 再算 and"的隐式约定。
681
+ * - 短路:`any` 首个命中即返回 true;`all` 首个未命中即返回 false;`notAny` 首个命中即返回 false。
682
+ * - 数组必须非空、子节点必须是谓词函数;空数组在挂载期抛错("恒真/恒假"是配置错误,不是命中)。
683
+ *
684
+ * @param {Function|object} node 谓词函数,或 `{ any|all|not|notAny }` 组合节点
685
+ * @returns {Function} 谓词函数
686
+ */
687
+ export function composite(node) {
688
+ if (typeof node === 'function') return node
689
+ if (node === null || typeof node !== 'object' || Array.isArray(node)) {
690
+ throw new TypeError(`predicates: a composite must be a predicate or { ${COMPOSITE_OPERATORS.join(' | ')} }`)
691
+ }
692
+ const unknown = Object.keys(node).filter((key) => !COMPOSITE_OPERATORS.includes(key))
693
+ if (unknown.length > 0) {
694
+ throw new TypeError(`predicates: unknown composite key(s) ${unknown.join(', ')} — allowed: ${COMPOSITE_OPERATORS.join(', ')}`)
695
+ }
696
+ const declared = COMPOSITE_OPERATORS.filter((key) => node[key] !== undefined)
697
+ if (declared.length !== 1) {
698
+ throw new TypeError(`predicates: a composite node must declare exactly one of ${COMPOSITE_OPERATORS.join(', ')}`)
699
+ }
700
+ const operator = declared[0]
701
+ if (operator === MATCH_LOGIC.NOT) {
702
+ const child = composite(node[operator])
703
+ const predicate = (payload) => !child(payload)
704
+ predicate.kind = 'composite'
705
+ if (typeof child.observe === 'function') {
706
+ predicate.observe = (session, event) => child.observe(session, event)
707
+ }
708
+ return predicate
709
+ }
710
+ const list = node[operator]
711
+ if (!Array.isArray(list) || list.length === 0) {
712
+ throw new TypeError(`predicates: composite ${operator} needs a non-empty array of predicates`)
713
+ }
714
+ const children = list.map(composite)
715
+ const predicates = {
716
+ [MATCH_LOGIC.ANY]: (payload) => {
717
+ for (const child of children) {
718
+ if (child(payload)) return true
719
+ }
720
+ return false
721
+ },
722
+ [MATCH_LOGIC.ALL]: (payload) => {
723
+ for (const child of children) {
724
+ if (!child(payload)) return false
725
+ }
726
+ return true
727
+ },
728
+ [MATCH_LOGIC.NOT_ANY]: (payload) => {
729
+ for (const child of children) {
730
+ if (child(payload)) return false
731
+ }
732
+ return true
733
+ },
734
+ }
735
+ const predicate = predicates[operator]
736
+ if (predicate === undefined) {
737
+ throw new TypeError(`predicates: composite ${operator} is not a supported operator`)
738
+ }
739
+ predicate.kind = 'composite'
740
+ // 组合层必须**转发子谓词的 observe**(B7 T1 的跨能力缺口修复):两条挂载路径的观察器
741
+ // 接线都按 `when.observe` 过滤(`trigger.mjs` 的 `wireTriggerObservers`),被组合包住的
742
+ // phase / count / session 谓词否则永远收不到活的 `session/event`——状态按 `session.id`
743
+ // 缓存后不再重扫(`shared.mjs` 的 `sessionMapGet`),判定会停在冷扫那一刻(计数恒旧值、
744
+ // 相位永不晋升)。`phase` 有 `subscribe: false` 可绕(代价是每次判定冷扫),
745
+ // `count` / `session` 没有该档,所以这条转发是它们唯一的活路。
746
+ const observers = children.filter((child) => typeof child.observe === 'function')
747
+ if (observers.length > 0) {
748
+ predicate.observe = (session, event) => {
749
+ for (const child of observers) child.observe(session, event)
750
+ }
751
+ }
752
+ return predicate
753
+ }