dsh-plugin-prompt-tool 0.8.1 → 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 (139) hide show
  1. package/README.md +84 -67
  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/character-tools.mjs +3 -6
  20. package/engine/compaction-epoch.mjs +31 -13
  21. package/engine/compositions/library/agent-instructions.yml +1 -1
  22. package/engine/compositions/library/command-goal.yml +1 -1
  23. package/engine/compositions/library/compaction.yml +1 -1
  24. package/engine/compositions/library/delegation-ptc.yml +1 -1
  25. package/engine/compositions/library/delegation.yml +1 -1
  26. package/engine/compositions/library/persistent-shell.yml +1 -1
  27. package/engine/compositions/library/planning.yml +1 -1
  28. package/engine/compositions/library/skill-filesystem-cordis.yml +1 -1
  29. package/engine/compositions/library/skill-filesystem.yml +1 -1
  30. package/engine/compositions/library/tool-ask-user.yml +1 -1
  31. package/engine/compositions/library/tool-bash.yml +1 -1
  32. package/engine/compositions/library/tool-cordis.yml +1 -1
  33. package/engine/compositions/library/tool-fs-search.yml +1 -1
  34. package/engine/compositions/library/tool-fs.yml +1 -1
  35. package/engine/compositions/library/tool-goal.yml +1 -1
  36. package/engine/compositions/library/tool-jobs.yml +1 -1
  37. package/engine/compositions/library/tool-plugin-manager-disabled.yml +1 -1
  38. package/engine/compositions/library/tool-plugin-manager.yml +1 -1
  39. package/engine/compositions/library/tool-present.yml +1 -1
  40. package/engine/compositions/library/tool-presentation.yml +1 -1
  41. package/engine/compositions/library/tool-pwsh.yml +1 -1
  42. package/engine/compositions/library/tool-skill.yml +1 -1
  43. package/engine/compositions/library/tool-todo.yml +1 -1
  44. package/engine/compositions/library/tool-web.yml +1 -1
  45. package/engine/compositions/source/local/dev-tool-search.yml +11 -0
  46. package/engine/compositions/source/local/instruction-hint.yml +22 -1
  47. package/engine/compositions/source/local/rule-engine.yml +6 -0
  48. package/engine/condition.mjs +16 -3
  49. package/engine/conditions/anchor.mjs +32 -0
  50. package/engine/conditions/availability.mjs +47 -0
  51. package/engine/conditions/composite.mjs +83 -0
  52. package/engine/conditions/count.mjs +140 -0
  53. package/engine/conditions/evaluation.mjs +41 -0
  54. package/engine/conditions/examples.mjs +11 -0
  55. package/engine/conditions/index.mjs +85 -0
  56. package/engine/conditions/names.mjs +21 -0
  57. package/engine/conditions/phase.mjs +54 -0
  58. package/engine/conditions/preset.mjs +94 -0
  59. package/engine/conditions/scope.mjs +18 -0
  60. package/engine/conditions/session.mjs +22 -0
  61. package/engine/conditions/source.mjs +63 -0
  62. package/engine/conditions/subject.mjs +121 -0
  63. package/engine/conditions/text.mjs +44 -0
  64. package/engine/conditions/values.mjs +32 -0
  65. package/engine/dev-tool-search.mjs +213 -0
  66. package/engine/executor.mjs +93 -31
  67. package/engine/fillers.mjs +23 -16
  68. package/engine/instruction-hint.mjs +39 -23
  69. package/engine/layers.mjs +171 -77
  70. package/engine/order.mjs +20 -0
  71. package/engine/predicates.mjs +2 -753
  72. package/engine/rule-engine.mjs +40 -0
  73. package/engine/rule-runtime.mjs +110 -0
  74. package/engine/rule-spec.mjs +187 -0
  75. package/engine/schema.mjs +32 -11
  76. package/engine/session-var-tools.mjs +3 -6
  77. package/engine/shared.mjs +1 -1
  78. package/engine/st-render.mjs +8 -4
  79. package/engine/strategies.mjs +2 -235
  80. package/engine/subagent-tool-policy-core.mjs +7 -7
  81. package/engine/subagent-tool-policy.mjs +45 -20
  82. package/engine/tool-config-engine.mjs +53 -32
  83. package/engine/trigger-editor-meta.mjs +2 -21
  84. package/engine/trigger-spec.mjs +49 -133
  85. package/engine/world-book-tools.mjs +3 -6
  86. package/lib/client.js +10444 -10886
  87. package/lib/client.js.map +1 -1
  88. package/lib/index.d.mts +265 -361
  89. package/lib/index.mjs +1559 -2026
  90. package/lib/migrate-rules.d.mts +1 -0
  91. package/lib/migrate-rules.mjs +39 -0
  92. package/lib/module-package-CHPFweqK.mjs +2 -0
  93. package/lib/module-package-Cnd1_mwx.mjs +2872 -0
  94. package/lib/{paths-CY5psBqE.d.mts → paths-CyP0IkaM.d.mts} +388 -231
  95. package/lib/preset-transfer.d.mts +2 -2
  96. package/lib/preset-transfer.mjs +3 -2
  97. package/lib/{preset-package-BnJqrU4R.mjs → rules-migration-DJ6vASB5.mjs} +13825 -15283
  98. package/modules/ponytail/module.yml +406 -0
  99. package/modules/skill-surface/module.yml +74 -0
  100. package/modules/tool-surface/module.yml +67 -0
  101. package/package.json +50 -80
  102. package/templates/10-pre-step.yml +26 -22
  103. package/templates/14-first-turn-anchor.yml +25 -21
  104. package/templates/15-guide-auto.yml +34 -19
  105. package/templates/16-custom-fallback.yml +27 -15
  106. package/templates/18-placeholder-env-facts.yml +23 -14
  107. package/templates/20-system-section.yml +20 -11
  108. package/templates/30-runtime-context.yml +13 -7
  109. package/templates/40-agent-request.yml +11 -8
  110. package/templates/50-llm-stream.yml +14 -6
  111. package/templates/60-tool-pipeline.yml +15 -9
  112. package/templates/65-turn-stop.yml +19 -12
  113. package/templates/66-subagent-start.yml +12 -7
  114. package/templates/70-subagent-maintenance.yml +35 -29
  115. package/templates/75-subagent-end.yml +10 -5
  116. package/templates/80-tool-surface.yml +66 -0
  117. package/templates/policies/legacy-defaults.yml +34 -0
  118. package/templates/tools/50-delegate-worldbook.yml +1 -1
  119. package/engine/compositions/source/local/prompt-config-engine.yml +0 -7
  120. package/engine/prompt-config-engine.mjs +0 -104
  121. package/preset/pt-cordis/preset.yml +0 -43
  122. package/preset/pt-cordis/skills/cordis-composition-reference/SKILL.md +0 -29
  123. package/preset/pt-cordis/skills/cordis-composition-reference/references/packages.md +0 -501
  124. package/preset/pt-cordis/skills/cordis-plugin-development/SKILL.md +0 -38
  125. package/preset/pt-cordis/skills/cordis-plugin-development/references/host-plugin.md +0 -60
  126. package/preset/pt-cordis/skills/cordis-plugin-development/references/mcp-bundle.md +0 -5
  127. package/preset/pt-cordis/skills/cordis-plugin-development/references/ui-plugin.md +0 -13
  128. package/preset/pt-cordis/skills/cordis-plugin-development/references/verification.md +0 -5
  129. package/preset/pt-cordis/skills/cordis-plugin-development/templates/decoration/client.js +0 -22
  130. package/preset/pt-cordis/skills/cordis-plugin-development/templates/decoration/cordis.patch.yml +0 -3
  131. package/preset/pt-cordis/skills/cordis-plugin-development/templates/decoration/index.js +0 -2
  132. package/preset/pt-cordis/skills/cordis-plugin-development/templates/decoration/package.json +0 -15
  133. package/preset/pt-cordis/skills/cordis-plugin-development/templates/mcp/cordis.patch.yml +0 -8
  134. package/preset/pt-cordis/skills/cordis-plugin-development/templates/mcp/package.json +0 -7
  135. package/preset/pt-cordis/skills/editing-cordis-compositions/SKILL.md +0 -84
  136. package/preset/pt-custom/preset.yml +0 -15
  137. package/preset/pt-minimal/preset.yml +0 -27
  138. package/preset/pt-ptc/preset.yml +0 -45
  139. package/preset/pt-standard/preset.yml +0 -43
@@ -0,0 +1,121 @@
1
+ import { lastAssistantText, subagentTextOf, toolArgsText, userMessagesText } from '../condition.mjs'
2
+ import { extractText } from '../shared.mjs'
3
+
4
+ const CHANNEL_SUBJECTS = {
5
+ 'system-prompt/assemble': (args) => ({ agent: args[1]?.agent }),
6
+ 'agent/request': (args) => ({ agent: args[0]?.agent }),
7
+ 'agent/pre-step': (args) => ({ agent: args[0]?.agent }),
8
+ 'agent/inbox/inserted': (args) => ({ agent: args[0]?.agent, source: args[0]?.message?.source }),
9
+ 'agent/turn-stopping': (args) => ({ agent: args[0]?.agent }),
10
+ 'tools/pre-execute': (args) => ({ agent: args[0]?.agent, name: args[0]?.name }),
11
+ 'tools/post-execute': (args) => ({ agent: args[0]?.agent, name: args[0]?.name }),
12
+ 'session/event': (args) => ({ session: args[0] }),
13
+ 'llm/stream': (args) => ({ model: args[0]?.model }),
14
+ 'subagent/start': () => ({}),
15
+ 'subagent/end': () => ({}),
16
+ }
17
+
18
+ /** 未知通道的告警去重(每个通道一次;判定期不刷屏)。 */
19
+ const warnedChannels = new Set()
20
+
21
+ /**
22
+ * 归一化载荷的标记(Symbol,非字符串键):`subjectOf` 产出它,`agentOf`/`sessionOf` 据此
23
+ * 区分「归一化载荷」与「旧形态的直接 payload」——**不用启发式猜**(曾按「有没有 `.session`」
24
+ * 判断,结果把测试里 `{ id, ctx }` 形状的假 agent 误判成归一化载荷)。
25
+ */
26
+ const SUBJECT_MARK = Symbol('predicate-subject')
27
+
28
+ /**
29
+ * 子代理事件判定期可见的 agent 事实。
30
+ *
31
+ * 官方 `subagent/start`、`subagent/end` 的载荷只有 `runId/provider/id/local`;
32
+ * `wireSubagentEvents` 是在 handler 里才把 child 挂上 frame 的,而 `ruleMatches`
33
+ * 在 handler **之前**执行。结果是 `scope.modelScope`(availability 要求 model 为非空
34
+ * 字符串)与 `scope.audience`(要求 session 可达)在子代理两层恒判 UNAVAILABLE——UI 上
35
+ * 配了却永远不命中,正是 schema 注释要避免的「配了没效果也不报错」。
36
+ *
37
+ * 这里在**判定前**用事件里的 `id` 反查 agent 补齐同样的事实。取不到时不写键:判定
38
+ * 退回 UNAVAILABLE(fail-closed),不得乐观放行。
39
+ */
40
+ function subagentFacts(info, ctx) {
41
+ const facts = {}
42
+ const id = info?.id
43
+ if (typeof id !== 'string' || id.length === 0) return facts
44
+ const agent = ctx?.agents?.get?.(id)
45
+ if (agent === undefined || agent === null) return facts
46
+ facts.agent = agent
47
+ if (agent.session !== undefined) facts.session = agent.session
48
+ const model = agent.options?.model
49
+ if (typeof model === 'string' && model.length > 0) facts.model = model
50
+ return facts
51
+ }
52
+
53
+ /**
54
+ * 把事件参数表归一为谓词载荷。
55
+ *
56
+ * `args` 传的是**去掉 `next` 之后的参数表**(无 `next` 的 emit/serial 通道原样传)。
57
+ * 未知通道回落到「第一个实参」(= 归一化之前的旧行为)并**告警一次**——不静默丢弃,
58
+ * 否则新增通道会悄悄失去判定能力;也不抛错,判定期抛错会打断会话。
59
+ *
60
+ * @param {string} channel 事件名
61
+ * @param {unknown[]} args 去掉 next 的参数表
62
+ * @param {Function} [warn] 告警回调(缺省静默)
63
+ * @param {unknown} [ctx] 判定期服务入口(目前只被子代理事件的 agent 反查使用)
64
+ * @returns {unknown} 谓词载荷
65
+ */
66
+ export function subjectOf(channel, args, warn, ctx) {
67
+ // 第一个实参的字段**原样保留**在载荷顶层:旧形态(如 `agent/turn-stopping` 的
68
+ // `{ agent, turn }`)因此继续可见,既有断言不会因归一化而失效。
69
+ const first = args[0]
70
+ const base = first !== null && typeof first === 'object' && !Array.isArray(first) ? { ...first } : {}
71
+ const pick = CHANNEL_SUBJECTS[channel]
72
+ const text = (() => {
73
+ switch (channel) {
74
+ case 'tools/pre-execute': return { argsText: toolArgsText(args[0]?.arguments) }
75
+ case 'tools/post-execute': return { argsText: toolArgsText(args[0]?.arguments), resultText: extractText(args[1]) }
76
+ case 'agent/pre-step': return { userText: userMessagesText(args[0]?.messages) }
77
+ case 'agent/inbox/inserted': return { userText: userMessagesText([args[0]?.message]) }
78
+ case 'agent/turn-stopping': return { assistantText: lastAssistantText(args[0]?.agent?.session) }
79
+ // 子代理事件的 `name` = provider(`SubagentRunInfo` 里唯一稳定的分类事实:
80
+ // spawn / fork / acp / codex / claude-code / dsh-sdk)。`names` 谓词读的就是
81
+ // `payload.name`,因此 `when: { names: { allow: ['fork'] } }` 在此可用。
82
+ // provider 可能缺席(官方注释:已接受的 one-shot 变 ready 或持久 Activation 冷恢复时
83
+ // 提供方未必仍注册)——那时不写键,让 `names` 走 UNAVAILABLE,而不是塞空串当「已知为空」。
84
+ case 'subagent/start':
85
+ case 'subagent/end': return {
86
+ subagentText: subagentTextOf(args[0]),
87
+ ...(typeof args[0]?.provider === 'string' && args[0].provider.length > 0 ? { name: args[0].provider } : {}),
88
+ ...subagentFacts(args[0], ctx),
89
+ }
90
+ default: return {}
91
+ }
92
+ })()
93
+ if (pick === undefined) {
94
+ if (typeof warn === 'function' && !warnedChannels.has(channel)) {
95
+ warnedChannels.add(channel)
96
+ warn(`predicates: unknown channel ${JSON.stringify(channel)} — 无显式取法,退回「第一个实参即载荷」的旧语义`)
97
+ }
98
+ return { ...base, ...text, [SUBJECT_MARK]: true, channel, args }
99
+ }
100
+ return { ...base, ...pick(args), ...text, [SUBJECT_MARK]: true, channel, args }
101
+ }
102
+
103
+ /**
104
+ * 从载荷取 agent:**归一化载荷**读 `agent`(`subjectOf` 的标记为凭);**旧形态**
105
+ * (载荷本身就是 agent——手写调用方与既有测试都这样传)原样返回。
106
+ */
107
+ export function agentOf(payload) {
108
+ if (payload === null || typeof payload !== 'object') return undefined
109
+ return payload[SUBJECT_MARK] === true ? payload.agent : payload
110
+ }
111
+
112
+ /**
113
+ * 从载荷取 session:归一化载荷走 `session` → `agent.session`;旧形态走
114
+ * `session` → 载荷自身。取不到时返回原载荷——`sessionEvents` 对无 `snapshotEvents`
115
+ * 的对象返回空,语义是「无事件」,与归一化之前逐例一致。
116
+ */
117
+ export function sessionOf(payload) {
118
+ if (payload === null || typeof payload !== 'object') return payload
119
+ if (payload[SUBJECT_MARK] !== true) return payload.session ?? payload
120
+ return payload.session ?? payload.agent?.session
121
+ }
@@ -0,0 +1,44 @@
1
+ import { MATCH_LOGIC, createAnchorMatcher } from '../anchor-match.mjs'
2
+ import { subjectTextOf } from '../condition.mjs'
3
+ import { keyList } from './values.mjs'
4
+ import { conditionInputAvailable, UNAVAILABLE } from './availability.mjs'
5
+ const MATCH_FLAGS = ['caseSensitive', 'wholeWords', 'useRegex', 'stWords']
6
+ const MATCH_MODES = ['scan', 'prefix']
7
+
8
+ export function createTextPredicate(options = {}) {
9
+ const keys = keyList(options.keys, 'keys')
10
+ const secondaryKeys = keyList(options.secondaryKeys, 'secondaryKeys')
11
+ if (keys.length === 0 && secondaryKeys.length === 0) {
12
+ throw new TypeError('predicates: a text predicate needs at least one non-empty key')
13
+ }
14
+ const logic = options.logic ?? MATCH_LOGIC.ANY
15
+ if (!Object.values(MATCH_LOGIC).includes(logic)) {
16
+ throw new TypeError(`predicates: logic must be one of ${Object.values(MATCH_LOGIC).join(', ')}`)
17
+ }
18
+ const mode = options.mode ?? 'scan'
19
+ if (!MATCH_MODES.includes(mode)) {
20
+ throw new TypeError(`predicates: mode must be one of ${MATCH_MODES.join(', ')}`)
21
+ }
22
+ for (const field of MATCH_FLAGS) {
23
+ if (options[field] !== undefined && typeof options[field] !== 'boolean') {
24
+ throw new TypeError(`predicates: ${field} must be a boolean when present`)
25
+ }
26
+ }
27
+ // 与 anchor-match 逐例等价的关键:匹配器就是 anchor-match 的匹配器,本层不加也不减语义。
28
+ const matcher = createAnchorMatcher({
29
+ keys,
30
+ secondaryKeys,
31
+ caseSensitive: options.caseSensitive === true,
32
+ wholeWords: options.wholeWords === true,
33
+ useRegex: options.useRegex,
34
+ logic,
35
+ mode,
36
+ stWords: options.stWords === true,
37
+ })
38
+ const subject = options.subject
39
+ const predicate = (payload) => conditionInputAvailable('text', options, payload)
40
+ ? matcher.scan(typeof payload === 'string' ? payload : subjectTextOf(subject, payload)).active === true
41
+ : UNAVAILABLE
42
+ predicate.kind = 'text'
43
+ return predicate
44
+ }
@@ -0,0 +1,32 @@
1
+ export function optionalBoolean(value, field, fallback) {
2
+ if (value === undefined) return fallback
3
+ if (typeof value !== 'boolean') throw new TypeError(`predicates: ${field} must be a boolean`)
4
+ return value
5
+ }
6
+
7
+ /** 键集合归一化:非字符串数组 / 非数组一律 fail loud;空白键丢弃。 */
8
+ export function keyList(value, field) {
9
+ if (value === undefined) return []
10
+ if (!Array.isArray(value) || value.some((item) => typeof item !== 'string')) {
11
+ throw new TypeError(`predicates: ${field} must be an array of strings when present`)
12
+ }
13
+ return value.map((item) => item.trim()).filter((item) => item.length > 0)
14
+ }
15
+
16
+ /** 名称集合:与 tool-filter 的 nameSet 同严格度(空串不是合法名称)。 */
17
+ export function nameList(value, field) {
18
+ if (value === undefined) return undefined
19
+ if (!Array.isArray(value) || value.some((item) => typeof item !== 'string' || item.length === 0)) {
20
+ throw new TypeError(`predicates: ${field} must be an array of non-empty strings`)
21
+ }
22
+ return [...new Set(value)]
23
+ }
24
+
25
+ /** 计数上下限:缺省 = 不设该侧;非安全非负整数一律 fail loud。 */
26
+ export function boundOf(value, field) {
27
+ if (value === undefined) return undefined
28
+ if (!Number.isSafeInteger(value) || value < 0) {
29
+ throw new TypeError(`predicates: count ${field} must be an integer >= 0`)
30
+ }
31
+ return value
32
+ }
@@ -0,0 +1,213 @@
1
+ /**
2
+ * 工具面的按需发现与解锁。
3
+ *
4
+ * 为什么:工具面实测 **156 个、工具描述合计 46894 字符**(≈12K token,每个请求都付),
5
+ * 而多数请求只用到其中几个。收窄成常驻核心集之后,本工具是唯一的发现入口。
6
+ *
7
+ * 两个动作合一:
8
+ * - `query` 关键词搜索**完整装配目录**(只回名字 + 一行截断描述,不回 parameters);
9
+ * - `toolNames` 按**精确名**解锁。解锁名写进本次 `tool/call` 的持久参数,
10
+ * `assembly.target.tools.allowFrom` 从那里读回 → 下次请求起生效,且跨压缩保留。
11
+ *
12
+ * **能力摘要来自运行时截获,不手工维护**(2026-10-05 用户指出):空查询时按前缀把
13
+ * `ctx.tools.schemas()` 的可见目录折叠成分组摘要(`mcp__github__* ×46`,实测 433 字符
14
+ * 覆盖全部 156 个工具)。此前的实现写死一份 UNLOCKABLE_INDEX——工具增删就要改代码,
15
+ * 名字写错还会让模型照着错名字解锁(那份实现甚至需要一条专门测试来防错名)。
16
+ * 官方注册表只投影 name/description(无分组字段),所以分组只能按前缀在本地折叠。
17
+ *
18
+ * 与原 `dev-tool-search.mjs` 的两处修正照搬:
19
+ * 1. 匹配是**打分**而非 AND 过滤——长自然语言查询("file edit write replace")在
20
+ * AND 口径下命中零个,等于白搜。现在精确名优先,其次按命中 token 数排序。
21
+ * 2. 描述里显式教解锁路径("搜索为空 ≠ 工具不存在")——实测模型只会搜、不会传
22
+ * toolNames,于是收窄后永远拿不到工具。
23
+ */
24
+ export const name = 'dev-tool-search'
25
+
26
+ /** 工具注册表必须先于本工具存在(同 skill-search 的 inject 纪律)。 */
27
+ export const inject = ['tools']
28
+
29
+ /** 单次搜索结果上限:再多就该让模型收窄关键词。 */
30
+ export const MAX_RESULTS = 25
31
+
32
+ /** 一行描述在结果里的截断长度。 */
33
+ const DESC_LIMIT = 90
34
+
35
+ /** 空查询摘要里最多列出的分组数,超出则并入「其他」。 */
36
+ const MAX_GROUPS = 24
37
+
38
+ /** 常驻核心集:由工具面收窄模板声明,这里只在描述里如实告知模型。 */
39
+ const RESIDENT = ['pwsh', 'read', 'write', 'edit', 'glob', 'grep', 'todo_write', 'skill_search', 'skill_load']
40
+
41
+ /** 零依赖的最小 JSON schema 编译器(与 skill-search 同构)。 */
42
+ function toJsonSchema(spec) {
43
+ const properties = {}
44
+ const required = []
45
+ for (const [key, meta] of Object.entries(spec || {})) {
46
+ const prop = { type: meta.type }
47
+ if (meta.description) prop.description = meta.description
48
+ if (meta.items) prop.items = meta.items
49
+ properties[key] = prop
50
+ if (meta.required) required.push(key)
51
+ }
52
+ return { type: 'object', properties, required, additionalProperties: false }
53
+ }
54
+
55
+ /**
56
+ * 能力索引:常驻核心集(pwsh / read / write / edit / glob / grep / todo_write /
57
+ * skill_search / skill_load)覆盖不到的能力。名字全部取自实测工具面。
58
+ */
59
+
60
+ /** 把查询切成小写 token(Unicode 字母数字与下划线连字符,与 skill-search 同口径)。 */
61
+ export function queryTokens(query) {
62
+ return (typeof query === 'string' ? query : '').toLowerCase().split(/[^\p{L}\p{N}_-]+/u).filter(Boolean)
63
+ }
64
+
65
+ /**
66
+ * 一个工具名的分组键:MCP 工具折到服务级(`mcp__github__*`),其余折到首个下划线段
67
+ * (`task_board_create` → `task_*`),无下划线的单名自成一组。
68
+ */
69
+ export function groupKeyOf(name) {
70
+ if (typeof name !== 'string' || name.length === 0) return undefined
71
+ if (name.startsWith('mcp__')) {
72
+ const parts = name.split('__')
73
+ return parts.length >= 2 ? `${parts[0]}__${parts[1]}__*` : `${name}*`
74
+ }
75
+ const cut = name.indexOf('_')
76
+ return cut <= 0 ? name : `${name.slice(0, cut)}_*`
77
+ }
78
+
79
+ /**
80
+ * 从**运行时目录**折叠出分组摘要——这是替代手工能力索引的唯一来源。
81
+ * 名字、分组、数量全部来自 `ctx.tools.schemas()`,工具增删不需要改本模块的代码。
82
+ * 超过 MAX_GROUPS 个分组时,把长尾并成一行「其他」而不是无限增长。
83
+ */
84
+ export function summarizeCatalog(schemas) {
85
+ const counts = new Map()
86
+ for (const schema of Array.isArray(schemas) ? schemas : []) {
87
+ const key = groupKeyOf(schema?.name)
88
+ if (key === undefined) continue
89
+ counts.set(key, (counts.get(key) ?? 0) + 1)
90
+ }
91
+ const sorted = [...counts.entries()].sort((a, b) => (b[1] - a[1]) || a[0].localeCompare(b[0]))
92
+ const head = sorted.slice(0, MAX_GROUPS)
93
+ const tail = sorted.slice(MAX_GROUPS)
94
+ const parts = head.map(([key, count]) => `${key}(${count})`)
95
+ if (tail.length > 0) parts.push(`其他 ${tail.length} 组(${tail.reduce((sum, [, n]) => sum + n, 0)})`)
96
+ return { total: [...counts.values()].reduce((sum, n) => sum + n, 0), text: parts.join(' ') }
97
+ }
98
+
99
+ /**
100
+ * 对目录打分排序(纯函数,可单测):
101
+ * 精确名命中排最前,其次按命中 token 数降序,同分按名字字典序。
102
+ * `wanted` 为空时返回空数组——调用方据此区分「没给查询」与「没搜到」。
103
+ */
104
+ export function scoreTools(schemas, wanted) {
105
+ if (!Array.isArray(schemas) || wanted.length === 0) return []
106
+ return schemas
107
+ .map((schema) => {
108
+ const name = typeof schema?.name === 'string' ? schema.name : ''
109
+ const haystack = `${name} ${schema?.description ?? ''}`.toLowerCase()
110
+ let score = 0
111
+ for (const token of wanted) if (haystack.includes(token)) score += 1
112
+ return { schema, name, score, exact: wanted.includes(name.toLowerCase()) }
113
+ })
114
+ .filter((entry) => entry.name.length > 0 && (entry.exact || entry.score >= 1))
115
+ .sort((a, b) => (b.exact - a.exact) || (b.score - a.score) || a.name.localeCompare(b.name))
116
+ }
117
+
118
+ /** 解锁名单归一:只留非空字符串,去重保序。 */
119
+ export function unlockNames(value) {
120
+ if (!Array.isArray(value)) return []
121
+ const seen = new Set()
122
+ for (const item of value) {
123
+ if (typeof item !== 'string') continue
124
+ const name = item.trim()
125
+ if (name.length > 0) seen.add(name)
126
+ }
127
+ return [...seen]
128
+ }
129
+
130
+ /** 把目录搜索结果渲染成给模型的文本(带截断提示与解锁引导)。 */
131
+ export function renderMatches(scored, query) {
132
+ const lines = []
133
+ const matches = scored.slice(0, MAX_RESULTS)
134
+ if (matches.length === 0) {
135
+ lines.push(
136
+ `No tools match "${query}". An empty result only means no tool matched your keywords — if you need a specific tool, unlock it directly by exact name, e.g. dev_tool_search({"toolNames":["web_search"]}).`,
137
+ )
138
+ return lines
139
+ }
140
+ lines.push(`Matching tools (${matches.length}${scored.length > MAX_RESULTS ? ` of ${scored.length}` : ''}):`)
141
+ for (const { schema, name, exact } of matches) {
142
+ const desc = String(schema?.description ?? '').split('\n')[0].slice(0, DESC_LIMIT)
143
+ lines.push(`- ${name}${exact ? ' (exact)' : ''}: ${desc}`)
144
+ }
145
+ if (scored.length > MAX_RESULTS) {
146
+ lines.push(`(truncated at ${MAX_RESULTS} — add tokens to narrow the query)`)
147
+ }
148
+ lines.push('Unlock with dev_tool_search({"toolNames": ["<exact name>"]}).')
149
+ return lines
150
+ }
151
+
152
+ /** 注册模型可见的 `dev_tool_search`。 */
153
+ export function apply(ctx) {
154
+ ctx.tools.register({
155
+ name: 'dev_tool_search',
156
+ description: [
157
+ 'Discover and unlock tools that are NOT currently available.',
158
+ '',
159
+ `This session starts with a minimal resident set: ${RESIDENT.join(', ')}. Everything else is unlocked on demand through this tool.`,
160
+ '',
161
+ 'Usage:',
162
+ '- `query` — search the catalog by keyword (matches tool names AND descriptions, 1-2 short words work best).',
163
+ '- call with NO arguments — list the catalog as group counts (`<prefix>_*(count) ...`), so you can see what exists before searching.',
164
+ '- `toolNames` — unlock exact names. Unlocked tools appear from the NEXT request on and stay unlocked for the session.',
165
+ '',
166
+ 'Example: dev_tool_search({"query":"pull request","toolNames":["mcp__github__create_pull_request"]}) — search AND unlock in one call.',
167
+ 'IMPORTANT: an empty search result does NOT mean the tool does not exist — it only means no tool matched your keywords. Call with no arguments to see the catalog groups, then unlock by exact name.',
168
+ ].join('\n'),
169
+ parameters: toJsonSchema({
170
+ query: { type: 'string', required: false, description: 'search keywords (e.g. "web", "subagent", "github")' },
171
+ toolNames: {
172
+ type: 'array',
173
+ required: false,
174
+ items: { type: 'string' },
175
+ description: 'exact tool names to unlock for the next request',
176
+ },
177
+ }),
178
+ output: {
179
+ schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string' } }, required: ['text'] },
180
+ render: (_args, value) => [{ type: 'text', text: value.text }],
181
+ },
182
+ async execute(args, exec) {
183
+ const query = typeof args?.query === 'string' ? args.query.trim() : ''
184
+ const unlock = unlockNames(args?.toolNames)
185
+ const lines = []
186
+ // 解锁动作本身不需要任何运行时服务:引擎的 allowFrom 只读本工具的持久调用参数,
187
+ // 所以即便目录不可用(无 tools 服务/无 agent),解锁也必须照样回报成功。
188
+ if (unlock.length > 0) lines.push(`Unlocked for the next request: ${unlock.join(', ')}`)
189
+ if (query.length === 0 && unlock.length > 0) return { text: lines.join('\n') }
190
+ let schemas
191
+ try {
192
+ // 执行中的 agent 就是观察作用域:preset 工具注册在 agent scope 层,
193
+ // 不带 scope 的 schemas() 只看全局层,会漏掉它们。
194
+ schemas = ctx.tools.schemas(exec?.agent)
195
+ } catch (error) {
196
+ return { text: [...lines, `catalog search unavailable: ${String(error?.message ?? error)}`].join('\n') }
197
+ }
198
+ // 空查询 = 要目录概览。摘要由运行时目录折叠而来,不需要维护任何索引。
199
+ if (query.length === 0) {
200
+ const summary = summarizeCatalog(schemas)
201
+ lines.push(
202
+ summary.total === 0
203
+ ? 'The catalog is empty — nothing to unlock.'
204
+ : `Catalog groups (${summary.total} tools): ${summary.text}`,
205
+ 'Search with `query`, or unlock by exact name with `toolNames`.',
206
+ )
207
+ return { text: lines.join('\n') }
208
+ }
209
+ lines.push(...renderMatches(scoreTools(schemas, queryTokens(query)), query))
210
+ return { text: lines.join('\n') }
211
+ },
212
+ })
213
+ }
@@ -28,6 +28,8 @@ import { createEpochPromotion } from './compaction-epoch.mjs'
28
28
  import { wireLayers } from './layers.mjs'
29
29
  import { sessionVarsSnapshot } from './session-vars.mjs'
30
30
  import { selectStWorldBook } from './st-world-book.mjs'
31
+ import { compareConfigSequence } from './order.mjs'
32
+ import { ruleFrame, ruleMatches, actionMatches } from './conditions/evaluation.mjs'
31
33
 
32
34
  const name = 'prompt-config-engine'
33
35
 
@@ -65,13 +67,25 @@ function identityOf(config) {
65
67
  return config.mergeMode === 'merged' ? mergedIdentity(config) : config.identity.value
66
68
  }
67
69
 
70
+ /**
71
+ * 消息来源的插件身份(去重身份)。v4 起 `source.kind` 是生产者名(`plugin:<name>`),
72
+ * `source.plugin` 只作为旧日志字段保留——两条通道都要认,否则去重记账会在格式换代后静默失效。
73
+ */
74
+ function pluginIdentityOf(source) {
75
+ if (source === null || typeof source !== 'object') return undefined
76
+ if (typeof source.plugin === 'string' && source.plugin.length > 0) return source.plugin
77
+ return typeof source.kind === 'string' && source.kind.startsWith('plugin:')
78
+ ? source.kind.slice('plugin:'.length)
79
+ : undefined
80
+ }
81
+
68
82
  function hasInjected(config, session) {
69
83
  const value = identityOf(config)
70
84
  return sessionEvents(session).some((event) => {
71
85
  const message = eventMessage(event)
72
86
  // 双通道去重:kind(外来/第三方消息,如 context-gate 的 instruction-hint)或
73
87
  // plugin(本引擎注入的命名空间,merged 组用 merged:<position>)。
74
- return message?.source?.kind === config.sourceKind || message?.source?.plugin === value
88
+ return message?.source?.kind === config.sourceKind || pluginIdentityOf(message?.source) === value
75
89
  })
76
90
  }
77
91
 
@@ -79,7 +93,7 @@ function hasInjected(config, session) {
79
93
  function hasInBatch(config, messages) {
80
94
  const value = identityOf(config)
81
95
  return messages.some((message) =>
82
- message?.source?.kind === config.sourceKind || message?.source?.plugin === value)
96
+ message?.source?.kind === config.sourceKind || pluginIdentityOf(message?.source) === value)
83
97
  }
84
98
 
85
99
  /**
@@ -112,6 +126,10 @@ export function confirmDelivered(memo, session, event) {
112
126
  if (typeof value !== 'string' || value.length === 0) continue
113
127
  deliveredSessions(memo, `${field}:${value}`).add(session.id)
114
128
  }
129
+ // v4 起生产者身份挂在 kind(`plugin:<name>`),旧日志才有 plugin 字段;
130
+ // 两种形态都补记到 plugin: 账本,alreadyDelivered 的 `confirmed('plugin', …)` 才不会漏。
131
+ const plugin = pluginIdentityOf(source)
132
+ if (plugin !== undefined && source.plugin === undefined) deliveredSessions(memo, `plugin:${plugin}`).add(session.id)
115
133
  }
116
134
 
117
135
  /** dedupe=session:本会话是否已有该身份的已确认投递(快路径 + 持久事件真相)。 */
@@ -150,7 +168,9 @@ function buildMessage(config, resolved, warnOnce) {
150
168
  const base = resolved.source !== null && typeof resolved.source === 'object'
151
169
  ? resolved.source
152
170
  : {
153
- kind: config.sourceKind,
171
+ // v4 要求 kind 是**生产者名**:声明了 sourceKind 就用它,否则用与 pluginMessage 同一形状的
172
+ // `plugin:<身份>`。裸 kind(undefined / 'plugin')都会被 codec 拒绝,历史走的是同一个坑。
173
+ kind: typeof config.sourceKind === 'string' && config.sourceKind.length > 0 ? config.sourceKind : `plugin:${sourceValue}`,
154
174
  plugin: sourceValue,
155
175
  ...(typeof config.form === 'string' ? { form: config.form } : {}),
156
176
  ...(typeof config.summary === 'string' && config.summary.length > 0 ? { summary: config.summary } : {}),
@@ -177,6 +197,27 @@ function buildMessage(config, resolved, warnOnce) {
177
197
  * @returns 注入后的 decision;reject、缺 agent/session、全部跳过或异常时原样返回。
178
198
  */
179
199
  export async function runPreStepBatch(options) {
200
+ const initialFrame = options.ruleFrame ?? ruleFrame('agent/pre-step', [{ agent: options.agent, messages: options.decision?.messages ?? [] }], options.warnOnce, options.ctx)
201
+ if (!options.ruleActions?.length) return runPromptConfigBatch({ ...options, ruleFrame: initialFrame })
202
+ const entries = [...options.configs.map(config => ({ ...config, config })), ...options.ruleActions].sort(compareConfigSequence)
203
+ let decision = options.decision
204
+ if (decision?.kind === 'reject') return decision
205
+ let pending = []
206
+ const placement = { beforeAll: new Set(), afterUser: new Set() }
207
+ const flush = async () => {
208
+ if (pending.length) decision = await runPromptConfigBatch({ ...options, decision, configs: pending, ruleFrame: initialFrame, placement })
209
+ pending = []
210
+ }
211
+ for (const entry of entries) {
212
+ if (entry.config) { pending.push(entry.config); continue }
213
+ await flush()
214
+ if (ruleMatches(entry.rule, initialFrame)) decision = await entry.handler(options.payload ?? initialFrame.args[0], () => decision, initialFrame)
215
+ }
216
+ await flush()
217
+ return decision
218
+ }
219
+
220
+ async function runPromptConfigBatch(options) {
180
221
  const { ctx, agent, decision, configs, promotion, memo, warnOnce } = options
181
222
  if (decision === null || typeof decision !== 'object') return decision
182
223
  if (decision.kind === 'reject') return decision
@@ -203,6 +244,7 @@ export async function runPreStepBatch(options) {
203
244
  && (config.promotion !== 'main' || main.status(agent).promoted)
204
245
  && (config.promotion !== 'include-subagents' || withSubagents.status(agent).promoted)
205
246
  && conditionHit(config, { userText })
247
+ && actionMatches(config, options.ruleFrame)
206
248
  const qualifiedConfigs = configs.filter(qualified)
207
249
  // 协调器为绑定来源 ctx 会复制 config;renderSt 函数身份在副本间保持不变。
208
250
  const eligible = new Set(qualifiedConfigs.map(config => config.renderSt))
@@ -253,28 +295,23 @@ export async function runPreStepBatch(options) {
253
295
  }
254
296
  }
255
297
 
256
- // order 升序(同值保持声明顺序):决定拼接顺序与同位置插入顺序。
257
- due.sort((a, b) => a.config.order - b.config.order)
298
+ // 模块来源按序号;独立调用仍按 order,同值保留稳定身份顺序。
299
+ due.sort((a, b) => compareConfigSequence(a.config, b.config))
258
300
 
259
- // merged 模式的多条提示词配置在首条配置的位置合并为一条消息;
260
- // 文本按内容块拼接,source 身份改用 merged:<position> 以保持持久幂等。
301
+ // 每个落位内只合并同来源的连续 merged 卡;其他落位不会穿插在本落位的输出中。
302
+ // source 仍用既有 merged:<position>,不因分段或排序改写持久去重身份。
261
303
  const orderedGroups = []
262
- const groupIndex = new Map()
304
+ const lastAtPosition = new Map()
263
305
  for (const entry of due) {
264
- const group = entry.config.mergeMode === 'merged'
265
- ? mergedIdentity(entry.config)
266
- : undefined
267
- if (group === undefined) {
268
- orderedGroups.push([entry])
269
- continue
270
- }
271
- let index = groupIndex.get(group)
272
- if (index === undefined) {
273
- index = orderedGroups.length
274
- groupIndex.set(group, index)
275
- orderedGroups.push([])
306
+ const previous = lastAtPosition.get(entry.config.position)
307
+ const base = previous?.[0].config
308
+ if (entry.config.mergeMode === 'merged' && base?.mergeMode === 'merged'
309
+ && entry.config.sourceModuleId === base.sourceModuleId) previous.push(entry)
310
+ else {
311
+ const group = [entry]
312
+ orderedGroups.push(group)
313
+ lastAtPosition.set(entry.config.position, group)
276
314
  }
277
- orderedGroups[index].push(entry)
278
315
  }
279
316
 
280
317
  const planned = []
@@ -314,13 +351,18 @@ export async function runPreStepBatch(options) {
314
351
  const afterUser = planned.filter((item) => item.position !== 'before-all' && item.position !== 'after-all')
315
352
  const afterAll = planned.filter((item) => item.position === 'after-all')
316
353
  if (beforeAll.length > 0) {
317
- messages.unshift(...beforeAll.map((item) => item.message))
354
+ // 混合动作把注入切成多批;继续排在此前仍存活的同位置消息之后。
355
+ const index = options.placement === undefined ? 0 : messages.findLastIndex(message => options.placement.beforeAll.has(message)) + 1
356
+ messages.splice(index, 0, ...beforeAll.map((item) => item.message))
357
+ for (const item of beforeAll) options.placement?.beforeAll.add(item.message)
318
358
  for (const item of beforeAll) markGroup(item.group)
319
359
  changed = true
320
360
  }
321
361
  const userIndex = messages.findIndex((item) => item?.source?.kind === 'user')
322
362
  if (afterUser.length > 0 && userIndex >= 0) {
323
- messages.splice(userIndex + 1, 0, ...afterUser.map((item) => item.message))
363
+ const last = options.placement === undefined ? -1 : messages.findLastIndex(message => options.placement.afterUser.has(message))
364
+ messages.splice(Math.max(userIndex, last) + 1, 0, ...afterUser.map((item) => item.message))
365
+ for (const item of afterUser) options.placement?.afterUser.add(item.message)
324
366
  for (const item of afterUser) markGroup(item.group)
325
367
  changed = true
326
368
  }
@@ -345,15 +387,10 @@ export async function runPreStepBatch(options) {
345
387
  * @param options.officialInstructions 本 mount 的组合是否仍挂着官方指令加载行
346
388
  * (负责人冲突事实;协调器据此拒绝同时注入文件正文)。
347
389
  */
348
- export function applyPromptConfigs(ctx, configs, options = {}) {
390
+ function effectivePromptConfigs(configs) {
349
391
  const list = configs.filter((config) => config !== undefined && config !== null)
350
- const sourceId = typeof options.sourceId === 'string' && options.sourceId.length > 0
351
- ? options.sourceId
352
- : `preset:${String(list[0]?.id ?? 'unknown')}`
353
- // 空 mount 同样保留接管监听:装配事实与协调器迟到/HMR 不依赖是否有预设卡。
354
- // 互斥组:同一 group 且 exclusive=true 时,只保留排序后第一个 enabled 提示词配置。
355
392
  const claimedGroups = new Set()
356
- const effectiveList = list.filter((config) => {
393
+ return list.filter((config) => {
357
394
  if (config.enabled === false) return false
358
395
  if (config.group !== undefined && config.exclusive === true) {
359
396
  if (claimedGroups.has(config.group)) return false
@@ -361,6 +398,27 @@ export function applyPromptConfigs(ctx, configs, options = {}) {
361
398
  }
362
399
  return true
363
400
  })
401
+ }
402
+
403
+ /** 多模块仍各自拥有来源/互斥组;只有非 pre-step 层汇总接线,避免模块块状执行。 */
404
+ export function applyPromptConfigSources(ctx, sources, options = {}) {
405
+ const selected = sources.map(source => ({ ...source, configs: effectivePromptConfigs(source.configs) }))
406
+ const releases = selected.map(source => applyPromptConfigs(ctx, source.configs, { ...options, ...source, layers: false }))
407
+ const releaseLayers = options.layers === false ? () => {} : wireLayers(ctx, selected.flatMap(source => source.configs)
408
+ .filter(config => config.layer !== 'pre-step'), createWarnOnce(ctx, name))
409
+ return () => {
410
+ for (const release of releases) release()
411
+ releaseLayers()
412
+ }
413
+ }
414
+
415
+ export function applyPromptConfigs(ctx, configs, options = {}) {
416
+ const list = configs.filter((config) => config !== undefined && config !== null)
417
+ const sourceId = typeof options.sourceId === 'string' && options.sourceId.length > 0
418
+ ? options.sourceId
419
+ : `preset:${String(list[0]?.id ?? 'unknown')}`
420
+ // 互斥组仍在本来源内筛选;空来源也保留协调器迟到/HMR 接管。
421
+ const effectiveList = effectivePromptConfigs(list)
364
422
  const injectedMemo = new Map()
365
423
  const main = createEpochPromotion(PROMOTE_EVENTS.either, { includeSubagents: false })
366
424
  const withSubagents = createEpochPromotion(PROMOTE_EVENTS.either, { includeSubagents: true })
@@ -376,6 +434,7 @@ export function applyPromptConfigs(ctx, configs, options = {}) {
376
434
  const promotion = { main, withSubagents }
377
435
  const source = {
378
436
  configs: effectiveList,
437
+ ruleActions: options.ruleActions ?? [],
379
438
  officialInstructions: options.officialInstructions === true,
380
439
  }
381
440
  /** 已交给协调器的注册(仅管理路径非空)。 */
@@ -401,7 +460,8 @@ export function applyPromptConfigs(ctx, configs, options = {}) {
401
460
 
402
461
  // 非 pre-step 提示词配置接入各自声明的官方层级通道(system-section /
403
462
  // runtime-context / agent-request / llm-stream / tool-pipeline)。
404
- const releaseLayers = wireLayers(ctx, effectiveList.filter((config) => config.layer !== 'pre-step'), warnOnce)
463
+ const releaseLayers = options.layers === false ? () => {}
464
+ : wireLayers(ctx, effectiveList.filter((config) => config.layer !== 'pre-step'), warnOnce)
405
465
 
406
466
  // 协调器已在(正常装配顺序:插件先加载、预设后挂载)时立即登记来源;
407
467
  // 迟到/消失由下面的监听器按「先撤旧再启新」处理。
@@ -431,9 +491,11 @@ export function applyPromptConfigs(ctx, configs, options = {}) {
431
491
  if (!active) return decision
432
492
  return runPreStepBatch({
433
493
  ctx,
494
+ payload,
434
495
  agent: payload?.agent,
435
496
  decision,
436
497
  configs: effectiveList,
498
+ ruleActions: source.ruleActions,
437
499
  promotion,
438
500
  memo: injectedMemo,
439
501
  warnOnce,