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,40 @@
1
+ /** 独立装配入口只读取统一 rules 产物包;插件管理路径直接调用同一编译/装配接口。 */
2
+ import { readFileSync } from 'node:fs'
3
+ import { basename, dirname, isAbsolute } from 'node:path'
4
+ import { fileURLToPath, pathToFileURL } from 'node:url'
5
+ import { parse } from './vendor/yaml/index.js'
6
+ import { defineConfig, passthrough } from './fields.mjs'
7
+ import { compileRules, getRuleEditorMeta } from './rule-spec.mjs'
8
+ import { mountRuleSources } from './rule-runtime.mjs'
9
+ import { loadStandingMountFor } from './predicates.mjs'
10
+
11
+ export const name = 'rule-engine'
12
+ export const inject = ['systemPrompt', 'tools', 'llm']
13
+ export { compileRules, mountRuleSources, getRuleEditorMeta }
14
+ const fields = defineConfig({
15
+ rulesFile: passthrough(value => typeof value === 'string' && value.length ? value : '../rules.yml'),
16
+ moduleRoot: passthrough(value => typeof value === 'string' && value.length ? value : undefined),
17
+ presetRoot: passthrough(value => typeof value === 'string' && value.length ? value : undefined),
18
+ strategyDir: passthrough(value => typeof value === 'string' && value.length ? value : undefined),
19
+ })
20
+ export const configContract = { ...fields, parse(config, plugin = name) {
21
+ const source = fields.parse(config, plugin)
22
+ if (source.moduleRoot !== undefined && source.presetRoot !== undefined && source.moduleRoot !== source.presetRoot) throw new TypeError(`${plugin}: moduleRoot and presetRoot conflict`)
23
+ source.moduleRoot ??= source.presetRoot
24
+ delete source.presetRoot
25
+ return source
26
+ } }
27
+
28
+ export async function apply(ctx, config) {
29
+ const source = configContract.parse(config, name)
30
+ const file = isAbsolute(source.rulesFile) ? source.rulesFile : fileURLToPath(new URL(source.rulesFile, import.meta.url))
31
+ const packet = parse(readFileSync(file, 'utf8'), { logLevel: 'silent' })
32
+ if (packet === null || typeof packet !== 'object' || Array.isArray(packet) || !Array.isArray(packet.rules)) throw new TypeError('rule-engine: rules.yml must contain a rules package')
33
+ const templateBaseUrl = pathToFileURL(file)
34
+ const templateModuleRoot = source.moduleRoot === undefined ? new URL('./', templateBaseUrl)
35
+ : isAbsolute(source.moduleRoot) ? pathToFileURL(source.moduleRoot.replace(/[\\/]?$/, '/')) : new URL(source.moduleRoot.replace(/\/?$/, '/'), templateBaseUrl)
36
+ const strategyDir = source.strategyDir === undefined ? undefined : isAbsolute(source.strategyDir) ? pathToFileURL(source.strategyDir).href : new URL(source.strategyDir, templateBaseUrl).href
37
+ const moduleId = typeof packet.moduleId === 'string' ? packet.moduleId : basename(dirname(file))
38
+ const rules = compileRules(packet.rules, { moduleId, variables: packet.variables, variablesEnabled: packet.variablesEnabled, configOrder: packet.configOrder, ctx, standingMountFor: await loadStandingMountFor(), promptConfigOptions: { templateBaseUrl, templateModuleRoot, strategyDir } })
39
+ return mountRuleSources(ctx, [{ moduleId, rules }])
40
+ }
@@ -0,0 +1,110 @@
1
+ import { prepareAction } from './actions/index.mjs'
2
+ import { applyPromptConfigSources } from './executor.mjs'
3
+ import { wireLayers } from './layers.mjs'
4
+ import { wireTriggerObservers } from './trigger.mjs'
5
+ import { ruleFrame, actionMatches } from './conditions/evaluation.mjs'
6
+ import { createWarnOnce } from './shared.mjs'
7
+
8
+ const noNext = new Set(['agent/inbox/inserted', 'agent/turn-stopping', 'subagent/start', 'subagent/end'])
9
+ const compare = (a, b) => a.action.channelOrder - b.action.channelOrder
10
+ || a.moduleId.localeCompare(b.moduleId) || a.ruleIndex - b.ruleIndex || a.action.actionIndex - b.action.actionIndex
11
+
12
+ /** 所有来源只进入这个装配入口;注入保留原有批处理和协调器所有权。 */
13
+ export function mountRuleSources(ctx, sources, options = {}) {
14
+ const warnOnce = options.warnOnce ?? createWarnOnce(ctx, options.plugin ?? 'rule-engine')
15
+ const releases = []
16
+ const points = new Map()
17
+ const injections = new Map(sources.map(source => [source.moduleId, { sourceId: `module:${source.moduleId}`, configs: [], ruleActions: [], officialInstructions: source.officialInstructions === true }]))
18
+ const rules = sources.flatMap(source => source.rules.filter(rule => rule.enabled !== false))
19
+ const observer = wireTriggerObservers(ctx, rules, { plugin: options.plugin ?? 'rule-engine', warnOnce })
20
+ if (observer) releases.push(observer)
21
+ let active = true
22
+ const bind = (item, on) => prepareAction(item.action, { plugin: options.plugin, warnOnce, promptConfigOptions: item.rule.promptConfigOptions, on })(ctx)
23
+ try {
24
+ for (const source of sources) for (const [ruleIndex, rule] of source.rules.entries()) {
25
+ if (rule.enabled === false) continue
26
+ for (const action of rule.actions) {
27
+ const item = { moduleId: source.moduleId, ruleIndex, rule, action }
28
+ if (action.execution.lifecycle === 'registration' && action.kind === 'guard') { releases.push(bind(item)); continue }
29
+ const point = action.execution
30
+ const key = `${point.channel}:${point.phase}:${action.waterfallPosition}`
31
+ const bucket = points.get(key) ?? { ...point, waterfallPosition: action.waterfallPosition, items: [] }
32
+ bucket.items.push(item); points.set(key, bucket)
33
+ }
34
+ }
35
+ for (const point of points.values()) {
36
+ point.items.sort(compare)
37
+ const handlers = []
38
+ let textConfigs = []
39
+ const flushLayers = () => {
40
+ if (!textConfigs.length) return
41
+ releases.push(wireLayers(ctx, textConfigs, warnOnce, {
42
+ on: (channel, handler) => {
43
+ if (channel !== point.channel) throw new TypeError(`rule action registered outside ${point.channel}: ${channel}`)
44
+ handlers.push({ handler }); return () => {}
45
+ },
46
+ }))
47
+ textConfigs = []
48
+ }
49
+ for (const item of point.items) {
50
+ const { action, rule, moduleId } = item
51
+ if (action.kind === 'inject-text') {
52
+ const config = { ...action.compiledConfig, rule, sourceModuleId: moduleId, group: undefined, exclusive: false, enabled: true, actionWhen: action.actionWhen, bypassRuleWhen: action.bypassRuleWhen }
53
+ if (config.layer === 'pre-step') injections.get(moduleId).configs.push(config)
54
+ else textConfigs.push(config)
55
+ continue
56
+ }
57
+ flushLayers()
58
+ const on = (channel, handler) => {
59
+ if (channel !== point.channel) throw new TypeError(`rule action registered outside ${point.channel}: ${channel}`)
60
+ const entry = { rule, handler, sequence: action.channelOrder, sourceModuleId: moduleId, ruleId: rule.id, ruleActionIndex: action.actionIndex, id: action.id, actionWhen: action.actionWhen, bypassRuleWhen: action.bypassRuleWhen }
61
+ if (channel === 'agent/pre-step' && point.waterfallPosition === 'default') injections.get(moduleId).ruleActions.push(entry)
62
+ else handlers.push(entry)
63
+ return () => {}
64
+ }
65
+ releases.push(bind(item, on))
66
+ }
67
+ flushLayers()
68
+ if (!handlers.length) continue
69
+ releases.push(ctx.on(point.channel, (...args) => {
70
+ const free = noNext.has(point.channel)
71
+ const payload = free ? args : args.slice(0, -1)
72
+ const next = free ? () => undefined : args[args.length - 1]
73
+ if (!active) return next()
74
+ const frame = ruleFrame(point.channel, payload, warnOnce, ctx)
75
+ if (free) {
76
+ let pending
77
+ for (const entry of handlers) {
78
+ const invoke = () => active && actionMatches(entry, frame) ? entry.handler(...payload, undefined, frame) : undefined
79
+ pending = pending?.then ? pending.then(invoke) : invoke()
80
+ }
81
+ return pending
82
+ }
83
+ if (point.phase === 'after-next') return Promise.resolve(next()).then(async initial => {
84
+ let result = initial
85
+ for (const entry of handlers) {
86
+ if (!active || !actionMatches(entry, frame)) continue
87
+ result = await entry.handler(...payload, () => result, frame)
88
+ }
89
+ return result
90
+ })
91
+ const invoke = index => {
92
+ if (!active || index === handlers.length) return next()
93
+ const entry = handlers[index]
94
+ return actionMatches(entry, frame) ? entry.handler(...payload, () => invoke(index + 1), frame) : invoke(index + 1)
95
+ }
96
+ return invoke(0)
97
+ }, point.waterfallPosition === 'outermost' ? { prepend: true } : undefined))
98
+ }
99
+ const preStep = [...injections.values()].filter(source => source.configs.length || source.ruleActions.length)
100
+ if (preStep.length) releases.push(applyPromptConfigSources(ctx, preStep, { layers: false }))
101
+ } catch (error) {
102
+ active = false
103
+ for (const dispose of releases.reverse()) { try { dispose?.() } catch {} }
104
+ throw error
105
+ }
106
+ return () => {
107
+ active = false
108
+ for (const dispose of releases.splice(0).reverse()) { try { dispose?.() } catch {} }
109
+ }
110
+ }
@@ -0,0 +1,187 @@
1
+ /** module.yml.rules 的唯一运行时编译入口。旧声明仅由离线迁移器读取。 */
2
+ import { ACTION_KINDS, actionExecutionPoint, prepareAction, validateActionOptions } from './actions/index.mjs'
3
+ import { compileWhen, PREDICATE_FACTORIES, COMPOSITE_OPERATORS } from './conditions/index.mjs'
4
+ import { createPromptConfigs, KNOWN_LAYERS } from './schema.mjs'
5
+ import { WATERFALL_POSITIONS } from './trigger.mjs'
6
+ import { ACTION_EXAMPLES } from './actions/examples.mjs'
7
+ import { PREDICATE_EXAMPLES } from './conditions/examples.mjs'
8
+ import { conjunction, expandActions } from './branch.mjs'
9
+
10
+ /** 规则字段:`if`/`then`/`else` 是当前名,`when`/`do` 保留为兼容输入(见 pickAlias)。 */
11
+ const RULE_FIELDS = new Set(['id', 'name', 'enabled', 'layer', 'group', 'exclusive', 'if', 'then', 'else', 'when', 'do'])
12
+ const record = value => value !== null && typeof value === 'object' && !Array.isArray(value)
13
+ const nonempty = value => typeof value === 'string' && value.trim().length > 0
14
+
15
+ /** 规则及物化身份的唯一安全边界;host 保存、导入与直接编译共用。 */
16
+ export function assertRuleId(id) {
17
+ if (!nonempty(id)) throw new TypeError('rule id must be a non-empty string')
18
+ if (id === '.' || id === '..') throw new TypeError(`rule id ${JSON.stringify(id)} must not be a dot directory`)
19
+ const reserved = '/\\\0:*?"<>|'
20
+ for (const char of reserved) if (id.includes(char)) throw new TypeError(`rule id ${JSON.stringify(id)} contains path separators or reserved filename characters`)
21
+ for (let index = 0; index < id.length; index++) if (id.charCodeAt(index) < 0x20) throw new TypeError(`rule id ${JSON.stringify(id)} contains control characters`)
22
+ }
23
+
24
+ /** 注入叶子的唯一身份投影;稳定动作 id 保证重排不改变投递身份。 */
25
+ export function injectionConfigSpec(rule, action, options = {}) {
26
+ if (action?.kind !== 'inject-text' || !record(action.config)) throw new TypeError('inject-text config must be an object')
27
+ const config = { ...action.config, id: action.config.id ?? `rule:${encodeURIComponent(rule.id)}:${encodeURIComponent(action.id)}`, layer: action.config.layer ?? rule.layer ?? 'pre-step' }
28
+ const variables = record(options.variables) ? options.variables : {}
29
+ if (options.variablesEnabled === false) {
30
+ const keys = new Set(Object.keys(variables))
31
+ const strip = text => typeof text === 'string' ? text.replace(/\{\{([A-Za-z0-9_.\u4e00-\u9fff-]+)\}\}/g, (match, key) => keys.has(key) ? '' : match) : text
32
+ if (config.text !== undefined) config.text = strip(config.text)
33
+ if (Array.isArray(config.texts)) config.texts = config.texts.map(strip)
34
+ if (record(config.params) && typeof config.params.text === 'string') config.params = { ...config.params, text: strip(config.params.text) }
35
+ } else config.variables = { ...variables, ...(record(config.variables) ? config.variables : {}) }
36
+ return config
37
+ }
38
+
39
+ /** 非空同组任一成员声明互斥,整组最多允许一条 enabled;运行时不替作者选赢家。 */
40
+ export function validateRuleGroups(rules) {
41
+ const groups = new Map()
42
+ for (const rule of rules) {
43
+ if (!nonempty(rule.group)) continue
44
+ const group = groups.get(rule.group) ?? []; group.push(rule); groups.set(rule.group, group)
45
+ }
46
+ for (const [name, group] of groups) {
47
+ if (group.some(rule => rule.exclusive === true) && group.filter(rule => rule.enabled !== false).length > 1) {
48
+ throw new TypeError(`rules: exclusive group ${JSON.stringify(name)} has multiple enabled rules`)
49
+ }
50
+ }
51
+ }
52
+
53
+ export function ruleActionExecution(action) {
54
+ const fixed = action.kind === 'guard' || (action.kind === 'inject-text'
55
+ && (action.config?.params?.complete === true || action.config?.params?.suppressRuntimeContext === true))
56
+ return { ...actionExecutionPoint(action), lifecycle: fixed ? 'registration' : 'event' }
57
+ }
58
+
59
+ /** 通用门只属于 when;固定系统段的 audience 保留为注册目标。 */
60
+ function normalizeActionGates(action, execution) {
61
+ const inject = action.kind === 'inject-text'
62
+ if (!inject && action.kind !== 'request-params') return
63
+ const target = inject ? action.config : action
64
+ if (inject) {
65
+ if (target.enabled !== undefined && target.enabled !== true) throw new TypeError(`action ${action.id}: move config.enabled to rule.enabled`)
66
+ delete target.enabled
67
+ }
68
+ const fields = inject ? ['audience', 'modelScope', 'promotion', 'subject', 'match'] : ['audience', 'modelScope']
69
+ for (const field of fields) {
70
+ const value = target[field]
71
+ if (inject && field === 'audience' && target.layer === 'system-section' && execution.lifecycle === 'registration' && ['main', 'subagent'].includes(value)) continue
72
+ const neutral = value == null || value === '' || (field === 'modelScope' && value === 'all') || (field === 'promotion' && value === 'none')
73
+ if (!neutral) throw new TypeError(`action ${action.id}: ${inject ? 'config.' : ''}${field} is a legacy gate; move it to rule.if`)
74
+ delete target[field]
75
+ }
76
+ }
77
+
78
+ /** 注册制层:内容在 agent 装配时注册一次,没有逐轮求值时机,因此不接受分支条件。 */
79
+ const REGISTRATION_LAYERS = new Set(['system-section', 'runtime-context'])
80
+
81
+ /** options.promptConfigOptions 拥有模板/策略路径;configOrder 使用规则 id,moduleId 只标记来源。 */
82
+ export function compileRules(specs, options = {}) {
83
+ if (!Array.isArray(specs)) throw new TypeError('rules must be an array')
84
+ const ids = new Set()
85
+ const pendingConfigs = []
86
+ const moduleId = options.moduleId ?? options.sourceModuleId
87
+ const rules = specs.map((spec, index) => {
88
+ if (!record(spec)) throw new TypeError(`rules[${index}] must be an object`)
89
+ const unknown = Object.keys(spec).filter(key => !RULE_FIELDS.has(key))
90
+ if (unknown.length) throw new TypeError(`rules[${index}] unknown fields: ${unknown.join(', ')}`)
91
+ assertRuleId(spec.id)
92
+ if (ids.has(spec.id)) throw new TypeError(`rules: duplicate id ${spec.id}`)
93
+ ids.add(spec.id)
94
+ for (const key of ['enabled', 'exclusive']) if (spec[key] !== undefined && typeof spec[key] !== 'boolean') throw new TypeError(`rule ${spec.id}.${key} must be boolean`)
95
+ for (const key of ['name', 'group']) if (spec[key] !== undefined && typeof spec[key] !== 'string') throw new TypeError(`rule ${spec.id}.${key} must be string`)
96
+ if (spec.layer !== undefined && !KNOWN_LAYERS.has(spec.layer)) throw new TypeError(`rule ${spec.id}: unknown layer ${spec.layer}`)
97
+ // 旧名已按引擎重构退役:显式拒绝并给出新名,比 "unknown fields" 更可直接照做。
98
+ if (spec.when !== undefined) throw new TypeError(`rule ${spec.id}: "when" 已退役,改用 "if"`)
99
+ if (spec.do !== undefined) throw new TypeError(`rule ${spec.id}: "do" 已退役,改用 "then"`)
100
+ const ruleIf = spec.if
101
+ const ruleThen = spec.then
102
+ if (!Array.isArray(ruleThen) || ruleThen.length === 0) throw new TypeError(`rule ${spec.id}.then must be a non-empty array`)
103
+ const sequence = options.configOrder?.[spec.id] ?? index * 10
104
+ if (!Number.isSafeInteger(sequence) || sequence < 0) throw new TypeError(`rule ${spec.id}: invalid configOrder`)
105
+ const when = compileWhen(ruleIf, options)
106
+ // 规则级 `if` 由 `rule.when` 判定,then 分支的动作不再重复叠加它;规则级 `else` 的动作
107
+ // 自带 `not(if)` 并标记跳过规则级判定——否则两者自相矛盾,else 分支永远不会执行。
108
+ const expanded = [
109
+ ...expandActions(ruleThen, {}, `rule ${spec.id}.then`),
110
+ ...(spec.else === undefined ? [] : expandActions(spec.else, {
111
+ outer: ruleIf === undefined ? [] : [{ not: ruleIf }],
112
+ bypass: true,
113
+ }, `rule ${spec.id}.else`)),
114
+ ]
115
+ const actionIds = new Set()
116
+ const actions = expanded.map(({ node: source, conditions, bypass }, actionIndex) => {
117
+ if (!record(source) || !nonempty(source.id)) throw new TypeError(`rule ${spec.id}: action id must be non-empty`)
118
+ if (actionIds.has(source.id)) throw new TypeError(`rule ${spec.id}: duplicate action id ${source.id}`)
119
+ actionIds.add(source.id)
120
+ if (!Object.hasOwn(ACTION_KINDS, source.kind)) throw new TypeError(`rule ${spec.id}: unknown action ${source.kind}`)
121
+ const action = structuredClone(source)
122
+ if (action.kind === 'inject-text') {
123
+ action.config = injectionConfigSpec(spec, action, options)
124
+ if (action.config.strategy === 'custom-fallback') throw new TypeError(`action ${action.id}: migrate custom-fallback to an anchor condition and anchor-notice content`)
125
+ }
126
+ const execution = ruleActionExecution(action)
127
+ normalizeActionGates(action, execution)
128
+ const channelOrder = action.channelOrder ?? sequence
129
+ if (!Number.isSafeInteger(channelOrder) || channelOrder < 0) throw new TypeError(`action ${action.id}: channelOrder must be a non-negative safe integer`)
130
+ const waterfallPosition = action.waterfallPosition ?? 'default'
131
+ if (!WATERFALL_POSITIONS.has(waterfallPosition)) throw new TypeError(`action ${action.id}: invalid waterfallPosition`)
132
+ if (execution.lifecycle === 'registration' && (when !== undefined || waterfallPosition !== 'default')) throw new TypeError(`action ${action.id}: registration effects do not support when or waterfall positioning`)
133
+ if (action.kind === 'inject-text' && waterfallPosition !== 'default') throw new TypeError(`action ${action.id}: inject-text placement belongs to its official layer, not waterfallPosition`)
134
+ if (action.kind === 'inject-text') {
135
+ validateActionOptions(action)
136
+ if (!record(action.config)) throw new TypeError(`action ${action.id}: config must be an object`)
137
+ // 原始声明保持不变;稳定动作身份只在未提供正文身份时补入运行时编译输入。
138
+ pendingConfigs.push({ action, source: { ...action.config, id: action.config.id ?? `${spec.id}:${action.id}` }, sequence: channelOrder, ruleId: spec.id, actionIndex })
139
+ } else prepareAction(action, { promptConfigOptions: options.promptConfigOptions })
140
+ // 空条件数组经 conjunction → undefined → compileWhen 直接返回 undefined(无条件动作)。
141
+ const actionWhen = compileWhen(conjunction(conditions), options)
142
+ const branchLayer = action.kind === 'inject-text' ? action.config.layer : spec.layer ?? 'pre-step'
143
+ if (actionWhen !== undefined && (REGISTRATION_LAYERS.has(branchLayer) || execution.lifecycle === 'registration')) {
144
+ throw new TypeError(`action ${action.id}: layer ${branchLayer} has no per-turn evaluation point — move the branch to pre-step / subagent-* / tool-pipeline / turn-stop`)
145
+ }
146
+ return Object.assign(action, { execution, channelOrder, waterfallPosition, actionIndex, actionWhen, bypassRuleWhen: bypass })
147
+ })
148
+ const pointOrders = new Map()
149
+ for (const action of actions) {
150
+ const key = `${action.execution.channel}:${action.execution.phase}:${action.waterfallPosition}`
151
+ const previous = pointOrders.get(key)
152
+ if (previous !== undefined && previous.channelOrder !== action.channelOrder) throw new TypeError(`rule ${spec.id}: conflicting channelOrder for actions ${previous.id} and ${action.id} at ${key}`)
153
+ pointOrders.set(key, action)
154
+ }
155
+ return { id: spec.id, name: spec.name, layer: spec.layer, group: spec.group, exclusive: spec.exclusive === true, enabled: spec.enabled !== false, sequence, when, actions, promptConfigOptions: options.promptConfigOptions }
156
+ })
157
+ validateRuleGroups(rules)
158
+ const complete = rules.filter(rule => rule.enabled).flatMap(rule => rule.actions).filter(action => action.config?.params?.complete === true)
159
+ if (complete.length > 1 || (complete.length > 0 && options.personaComplete === true)) throw new TypeError('rules: multiple complete system sections are active')
160
+ if (pendingConfigs.length) {
161
+ const compiled = createPromptConfigs(pendingConfigs.map(item => item.source), { ...options.promptConfigOptions, ...(moduleId === undefined ? {} : { sourceModuleId: moduleId }) })
162
+ const byId = new Map(compiled.map(config => [config.id, config]))
163
+ for (const item of pendingConfigs) {
164
+ const config = byId.get(item.source.id)
165
+ Object.assign(config, { sequence: item.sequence, ruleId: item.ruleId, ruleActionIndex: item.actionIndex })
166
+ item.action.compiledConfig = config
167
+ }
168
+ }
169
+ return rules
170
+ }
171
+
172
+ export function getRuleEditorMeta() {
173
+ const predicates = Object.keys(PREDICATE_FACTORIES).map(kind => ({ kind, example: { [kind]: structuredClone(PREDICATE_EXAMPLES[kind]) } }))
174
+ for (const kind of COMPOSITE_OPERATORS) {
175
+ const child = { phase: { promoted: false } }
176
+ predicates.push({ kind, example: { [kind]: kind === 'not' ? child : [child] } })
177
+ }
178
+ for (const { example } of predicates) compileWhen(example)
179
+ const actions = Object.keys(ACTION_KINDS).map(kind => {
180
+ const example = { kind, ...structuredClone(ACTION_EXAMPLES[kind]) }
181
+ // 新规则的投递身份由稳定 rule/action id 派生;迁移的显式 config.id 仍保留。
182
+ if (kind === 'inject-text') delete example.config.id
183
+ const action = compileRules([{ id: kind, then: [{ ...example, id: 'example' }] }])[0].actions[0]
184
+ return { kind, example, channel: action.execution.channel, phase: action.execution.phase, lifecycle: action.execution.lifecycle, supportsWhen: action.execution.lifecycle === 'event' }
185
+ })
186
+ return { predicates, actions, composites: [...COMPOSITE_OPERATORS], waterfallPositions: [...WATERFALL_POSITIONS] }
187
+ }
package/engine/schema.mjs CHANGED
@@ -12,7 +12,7 @@ import { attachStRenderers } from './st-render.mjs'
12
12
  import { FILE_SEQUENCE, compareConfigSequence } from './order.mjs'
13
13
  import { MATCH_LOGIC, createAnchorMatcher } from './anchor-match.mjs'
14
14
 
15
- const name = 'prompt-config-engine'
15
+ const name = 'rule-engine'
16
16
 
17
17
  /**
18
18
  * 内容模板加载:提示词配置可声明 templateFile,由外部 yml / json / 纯文本模板提供内容,
@@ -146,7 +146,7 @@ export function loadPromptConfigFiles(dirUrl) {
146
146
  return specs
147
147
  }
148
148
 
149
- export const KNOWN_STRATEGIES = new Set(['static', 'placeholder', 'first-turn-anchor', 'guide-auto', 'custom-fallback', 'world-book'])
149
+ export const KNOWN_STRATEGIES = new Set(['static', 'placeholder', 'first-turn-anchor', 'guide-auto', 'anchor-notice', 'world-book'])
150
150
  /**
151
151
  * 策略 × 层支持矩阵:`config.resolve` 只在 pre-step(executor)与 runtime-context 的
152
152
  * placeholder(layers)被消费,其他层声明非 static 策略会绑定 resolver 却无人调用,
@@ -158,7 +158,7 @@ export const STRATEGY_LAYER_SUPPORT = {
158
158
  placeholder: ['pre-step', 'runtime-context'],
159
159
  'first-turn-anchor': ['pre-step'],
160
160
  'guide-auto': ['pre-step'],
161
- 'custom-fallback': ['pre-step'],
161
+ 'anchor-notice': ['pre-step'],
162
162
  'world-book': ['pre-step'],
163
163
  }
164
164
  /** 模板专属策略允许的层:resolver 同样只在 pre-step / runtime-context 被调用。 */
@@ -528,6 +528,9 @@ function resolveConfigFields(spec, label, layer) {
528
528
 
529
529
  /** 从 YAML 提示词配置描述构造运行时提示词配置。配置错误必须在挂载时暴露(fail loud)。 */
530
530
  export function createPromptConfigs(specs, options = {}) {
531
+ if (options.templateModuleRoot !== undefined && options.templatePresetRoot !== undefined
532
+ && String(options.templateModuleRoot) !== String(options.templatePresetRoot)) throw new TypeError('templateModuleRoot and templatePresetRoot conflict')
533
+ const templateModuleRoot = options.templateModuleRoot ?? options.templatePresetRoot
531
534
  if (specs === undefined) return []
532
535
  if (!Array.isArray(specs)) throw new TypeError(`${name}: config.configs must be an array`)
533
536
  // 重复 ID 拒绝:后者覆盖前者会静默丢卡,挂载前 fail loud。
@@ -550,6 +553,7 @@ export function createPromptConfigs(specs, options = {}) {
550
553
  }
551
554
  const rawStrategy = spec.strategy ?? 'static'
552
555
  const strategy = rawStrategy
556
+ if (strategy === 'custom-fallback') throw new TypeError(`${name}: migrate custom-fallback to an anchor condition and anchor-notice content`)
553
557
  if (!KNOWN_STRATEGIES.has(strategy)) {
554
558
  // 模板专属策略:声明了 strategyDir 时由 strategies.bindResolver 懒加载,
555
559
  // 否则视为未知策略 fail loud。
@@ -591,7 +595,7 @@ export function createPromptConfigs(specs, options = {}) {
591
595
  }
592
596
  }
593
597
  // 安装预检可把文件读取定向到尚未提交的候选目录,默认运行期仍走原解析器。
594
- const template = (options.loadTemplate ?? loadTemplate)(spec.templateFile, options.templateBaseUrl, options.templatePresetRoot)
598
+ const template = (options.loadTemplate ?? loadTemplate)(spec.templateFile, options.templateBaseUrl, templateModuleRoot)
595
599
  const templatePatch = template !== null && typeof template === 'object'
596
600
  ? { id: template.id, role: template.role, content: template.content, source: template.source }
597
601
  : undefined
@@ -618,7 +622,14 @@ export function createPromptConfigs(specs, options = {}) {
618
622
  modelScope: fields.modelScope,
619
623
  subject: fields.subject,
620
624
  match,
621
- sourceKind: typeof spec.sourceKind === 'string' && spec.sourceKind.length > 0 ? spec.sourceKind : spec.id,
625
+ // sourceKind 进的是消息 source.kind,必须是 **v4 的生产者身份**(`plugin:<owner>`):
626
+ // 裸规则 id 会被 `userMessagesText` 的注入过滤漏掉(它只认 `source.plugin` 与
627
+ // `plugin:` 前缀),于是本引擎注入过的规则正文会回来参与下一轮 when 判定——实测
628
+ // 后果是只读档正文里的「只读」二字让写档的 notAny 在第二轮翻转,同一子代理两档并注。
629
+ sourceKind: (() => {
630
+ const declared = typeof spec.sourceKind === 'string' && spec.sourceKind.length > 0 ? spec.sourceKind : spec.id
631
+ return declared.startsWith('plugin:') ? declared : `plugin:${declared}`
632
+ })(),
622
633
  form: typeof spec.form === 'string' ? spec.form : 'notice',
623
634
  summary: typeof spec.summary === 'string' ? spec.summary : '',
624
635
  identity: fields.identity,
package/engine/shared.mjs CHANGED
@@ -115,7 +115,7 @@ export function isFlashModel(modelId) {
115
115
 
116
116
  /** 模型范围过滤:flash=仅 Flash 家族模型;pro=仅非 Flash;all=全部。 */
117
117
  export function matchesModel(scope, model) {
118
- if (scope === 'all') return true
118
+ if (scope == null || scope === 'all') return true
119
119
  const isFlash = isFlashModel(model)
120
120
  return scope === 'flash' ? isFlash : !isFlash
121
121
  }