dsh-plugin-prompt-tool 0.3.1 → 0.4.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.
@@ -1,127 +1,112 @@
1
- /**
2
- * prompt-injector — anchored-standard 的附加件(prompt-tool 自研,唯一本地件)。
3
- *
4
- * 职责(tools / maxTokens / 上下文剥离全部留给原版 tool-bootstrap):
5
- * 会话晋升(首个 tool/call 或 assistant/message,与 promoteOn: either 同定义)后,
6
- * turn1 reasoning 以 "we" 开头 → 下一轮注入 promptText 一次;
7
- * we 未确认 → 最多再等一轮,仍无则强制注入(兜底,绝不卡死)。
8
- *
9
- * promptText 由宿主在生成 preset 时准备好:
10
- * - 默认等于 preset.md 原文;
11
- * - 开启 injectAgentsPrompt 时,宿主会把 AGENTS.md 拼接到 preset.md 头部。
12
- * 本模块只负责把最终文本作为一条 user 消息注入,不做路径探测。
13
- *
14
- * 实测依据:
15
- * - 复杂任务 + 原版 2 工具 + maxTokens 1024 → turn1 reasoning "We need";
16
- * - 简单任务模型直接调工具干活(无 we 首词),走兜底注入;
17
- * - 锚定消息("Tools are not open yet")在 2 工具下不产生 we
18
- * (模型回复 "The user says..." 叙述语气),已废弃。
19
- *
20
- * 状态全部从持久 session events 推导(resume/reload 安全):
21
- * - promoted:events 含 tool/call 或 assistant/message;
22
- * - 已注入:events 含 source.kind=plugin + source.plugin=prompt-injector 的
23
- * 消息事件(注入消息本身就是持久事件),进程重启/插件重载后据此跳过;
24
- * - 内存 Set/Map 只做进程内快路径 memo,不承载跨进程真相。
25
- * 注入仅一次。
26
- */
27
-
28
- /** Cordis plugin name used by loader diagnostics. */
29
- export const name = 'prompt-injector'
30
-
31
- /** No inject list: nothing to resolve, only event listeners. */
32
- export const inject = []
33
-
34
- export function apply(ctx, config) {
35
- const promptText = typeof config.promptText === 'string' && config.promptText.length > 0
36
- ? config.promptText
37
- : undefined
38
-
39
- /** 消息 id:优先 crypto.randomUUID,旧运行时回退到随机串。 */
40
- const newMessageId = () =>
41
- typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
42
- ? crypto.randomUUID()
43
- : `prompt-injector-${Date.now()}-${Math.random().toString(36).slice(2)}`
44
-
45
- /** Sessions whose promptText has been injected (append-only). */
46
- const injectedPrompt = new Set()
47
- /** Sessions already promoted in this process (memoized). */
48
- const promoted = new Set()
49
- /** Sessions whose first assistant reasoning has been scanned (memoized). */
50
- const weScanned = new Map()
51
-
52
- /**
53
- * 从持久 session events 推导「是否已经注入过 promptText」。
54
- * 注入消息本身就是 user/message 持久事件(data 即消息本体),因此跨进程
55
- * 重启 / 插件热重载后仍能识别;兼容 router-standard 观测到的
56
- * `data.message` 嵌套形状。内存 Set 只做快路径 memo,真相在事件流。
57
- */
58
- const hasInjectedPrompt = (session) =>
59
- session.events.some((event) => {
60
- const payload = event.data && typeof event.data.message === 'object' && event.data.message !== null
61
- ? event.data.message
62
- : event.data
63
- const source = payload?.source
64
- return source?.kind === 'plugin' && source?.plugin === 'prompt-injector'
65
- })
66
-
67
- /** 与 tool-bootstrap 的 promoteOn: either 同定义。 */
68
- const isPromoted = (agent) => {
69
- if (agent === undefined) return true
70
- const session = agent.session
71
- if (session === undefined) return true
72
- if ((session.header.delegationDepth ?? 0) > 0) return true
73
- if (promoted.has(session.id)) return true
74
- const hit = session.events.some((event) => event.type === 'tool/call' || event.type === 'assistant/message')
75
- if (hit) promoted.add(session.id)
76
- return hit
77
- }
78
-
79
- /** 第一个 assistant/message(锚定轮)的 reasoning 是否以 "we" 开头。 */
80
- const isWeAnchored = (agent) => {
81
- const session = agent.session
82
- const cached = weScanned.get(session.id)
83
- if (cached !== undefined) return cached
84
- const first = session.events.find((event) => event.type === 'assistant/message')
85
- // 首个 assistant 消息尚未落库时不要缓存 false,等待下一轮再查。
86
- if (first === undefined) return false
87
- const content = first.data?.message?.content ?? []
88
- const reasoning = content.find((block) => block.type === 'reasoning')
89
- const anchored = reasoning !== undefined && /^we\b/i.test(String(reasoning.text ?? '').trim())
90
- weScanned.set(session.id, anchored)
91
- return anchored
92
- }
93
-
94
- /** 已落库的 assistant 回合数(we 未确认时的兜底轮计数)。 */
95
- const assistantRounds = (agent) =>
96
- agent.session.events.filter((event) => event.type === 'assistant/message').length
97
-
98
- // 锚定确认后注入一次 promptText(we 确认立即注入;未确认最多再等一轮)。
99
- ctx.on('agent/pre-step', async ({ agent }, next) => {
100
- const decision = await next()
101
- if (decision.kind === 'reject') return decision
102
- if (promptText === undefined) return decision
103
- if (!isPromoted(agent)) return decision
104
- const session = agent.session
105
- if (session === undefined || injectedPrompt.has(session.id)) return decision
106
- // 持久事件级幂等:进程重启/插件重载后,已注入消息仍在事件流里,据此跳过。
107
- if (hasInjectedPrompt(session)) {
108
- injectedPrompt.add(session.id)
109
- return decision
110
- }
111
- const weOk = isWeAnchored(agent)
112
- if (!weOk && assistantRounds(agent) <= 1) return decision
113
- injectedPrompt.add(session.id)
114
- const message = {
115
- id: newMessageId(),
116
- role: 'user',
117
- content: [{ type: 'text', text: promptText }],
118
- source: {
119
- kind: 'plugin',
120
- plugin: 'prompt-injector',
121
- form: 'notice',
122
- summary: weOk ? 'prompt-tool 提示词(we 锚定确认后注入)' : 'prompt-tool 提示词(we 未确认,兜底注入)',
123
- },
124
- }
125
- return { ...decision, messages: [message, ...decision.messages] }
126
- })
1
+ /**
2
+ * prompt-injector — prompt-tool 的提示词注入层。
3
+ *
4
+ * 职责:会话晋升(首个 tool/call 或 assistant/message,与 promoteOn: either
5
+ * 同定义)后,turn1 reasoning 以 "we" 开头 → 下一轮注入 promptText 一次;
6
+ * we 未确认 → 最多再等一轮,仍无则强制注入(兜底,绝不卡死)。
7
+ *
8
+ * promptText 由宿主在生成 preset 时写入 config(默认等于 preset.md 原文)。
9
+ * AGENTS.md 的注入走 instruction-hint.mjs,不由本模块拼接。
10
+ *
11
+ * 实测依据:
12
+ * - 复杂任务 + 原版 2 工具 + maxTokens 1024 → turn1 reasoning "We need";
13
+ * - 简单任务模型直接调工具干活(无 we 首词),走兜底注入;
14
+ * - 锚定消息("Tools are not open yet")在 2 工具下不产生 we
15
+ * (模型回复 "The user says..." 叙述语气),已废弃。
16
+ *
17
+ * 状态全部从持久 session events 推导(resume/reload 安全):
18
+ * - promoted:events 含 tool/call 或 assistant/message;
19
+ * - 已注入:events 含 source.kind=plugin + source.plugin=prompt-injector 的
20
+ * 消息事件(注入消息本身就是持久事件),进程重启/插件重载后据此跳过;
21
+ * - 内存 Set/Map 只做进程内快路径 memo,不承载跨进程真相。
22
+ * 注入仅一次。
23
+ */
24
+
25
+ import { createEpochPromotion } from './compaction-epoch.mjs'
26
+ import { newMessageId, PROMOTE_EVENTS } from './shared.mjs'
27
+
28
+ /** Cordis plugin name used by loader diagnostics. */
29
+ export const name = 'prompt-injector'
30
+
31
+ /** No inject list: nothing to resolve, only event listeners. */
32
+ export const inject = []
33
+
34
+ export function apply(ctx, config) {
35
+ const promptText = typeof config.promptText === 'string' && config.promptText.length > 0
36
+ ? config.promptText
37
+ : undefined
38
+
39
+ /** Sessions whose promptText has been injected (append-only). */
40
+ const injectedPrompt = new Set()
41
+ /** Sessions whose first assistant reasoning has been scanned (memoized). */
42
+ const weScanned = new Map()
43
+ /** Epoch-aware promotion, same definition as tool-bootstrap/context-gate. */
44
+ const promotion = createEpochPromotion(PROMOTE_EVENTS.either, { includeSubagents: false })
45
+ ctx.on('session/event', (session, event) => promotion.observe(session, event))
46
+
47
+ /**
48
+ * 从持久 session events 推导「是否已经注入过 promptText」。
49
+ * 注入消息本身就是 user/message 持久事件(data 即消息本体),因此跨进程
50
+ * 重启 / 插件热重载后仍能识别;兼容 router-standard 观测到的
51
+ * `data.message` 嵌套形状。内存 Set 只做快路径 memo,真相在事件流。
52
+ */
53
+ const hasInjectedPrompt = (session) =>
54
+ session.events.some((event) => {
55
+ const payload = event.data && typeof event.data.message === 'object' && event.data.message !== null
56
+ ? event.data.message
57
+ : event.data
58
+ const source = payload?.source
59
+ return source?.kind === 'plugin' && source?.plugin === 'prompt-injector'
60
+ })
61
+
62
+
63
+
64
+ /** 第一个 assistant/message(锚定轮)的 reasoning 是否以 "we" 开头。 */
65
+ const isWeAnchored = (agent) => {
66
+ const session = agent.session
67
+ const cached = weScanned.get(session.id)
68
+ if (cached !== undefined) return cached
69
+ const first = session.events.find((event) => event.type === 'assistant/message')
70
+ // 首个 assistant 消息尚未落库时不要缓存 false,等待下一轮再查。
71
+ if (first === undefined) return false
72
+ const content = first.data?.message?.content ?? []
73
+ const reasoning = content.find((block) => block.type === 'reasoning')
74
+ const anchored = reasoning !== undefined && /^we\b/i.test(String(reasoning.text ?? '').trim())
75
+ weScanned.set(session.id, anchored)
76
+ return anchored
77
+ }
78
+
79
+ /** 已落库的 assistant 回合数(we 未确认时的兜底轮计数)。 */
80
+ const assistantRounds = (agent) =>
81
+ agent.session.events.filter((event) => event.type === 'assistant/message').length
82
+
83
+ // 锚定确认后注入一次 promptText(we 确认立即注入;未确认最多再等一轮)。
84
+ ctx.on('agent/pre-step', async ({ agent }, next) => {
85
+ const decision = await next()
86
+ if (decision.kind === 'reject') return decision
87
+ if (agent === undefined || promptText === undefined) return decision
88
+ if (!promotion.status(agent).promoted) return decision
89
+ const session = agent.session
90
+ if (session === undefined || injectedPrompt.has(session.id)) return decision
91
+ // 持久事件级幂等:进程重启/插件重载后,已注入消息仍在事件流里,据此跳过。
92
+ if (hasInjectedPrompt(session)) {
93
+ injectedPrompt.add(session.id)
94
+ return decision
95
+ }
96
+ const weOk = isWeAnchored(agent)
97
+ if (!weOk && assistantRounds(agent) <= 1) return decision
98
+ injectedPrompt.add(session.id)
99
+ const message = {
100
+ id: newMessageId('prompt-injector'),
101
+ role: 'user',
102
+ content: [{ type: 'text', text: promptText }],
103
+ source: {
104
+ kind: 'plugin',
105
+ plugin: 'prompt-injector',
106
+ form: 'notice',
107
+ summary: weOk ? 'prompt-tool 提示词(we 锚定确认后注入)' : 'prompt-tool 提示词(we 未确认,兜底注入)',
108
+ },
109
+ }
110
+ return { ...decision, messages: [message, ...decision.messages] }
111
+ })
127
112
  }
@@ -1,70 +1,73 @@
1
- /**
2
- * router-first-turn — prompt-tool 本地附加件(最优组合)。
3
- *
4
- * 依据 dsh-router-standard 的 router-bootstrap 组装逻辑:
5
- * - 只替换 persona 段,保留计划模式段与其他第三方 section;
6
- * - 首轮隐藏 mnemon:* 自动注入段并清空 contexts,晋升后恢复;
7
- * - 按主会话模型自动选择 persona:
8
- * Pro → 官方训练原句;
9
- * Flash → dsh-router-standard 的 Flash 弱路由人设(build/fix 分类 +
10
- * 回顾锚 + 收敛锚 + 反跑题锚 + 先深想再产出);
11
- * - 子代理(委托深度 > 0)直接放行完整结果,保证 dsh-mnemon 等插件
12
- * 通过工具白名单要求的 mnemon_* 工具首轮可见。
13
- *
14
- * 工具目录裁剪仍由上游 tool-bootstrap 负责(首轮 = 真实 Minimal 工具对;
15
- * 晋升后 = resident 集),本模块不触碰 tools,避免两层过滤器冲突。
16
- */
17
-
18
- /** Cordis 插件名,供 loader 诊断使用。 */
19
- export const name = 'router-first-turn'
20
-
21
- /** 无服务依赖,只监听 system-prompt 组装。 */
22
- export const inject = []
23
-
24
- /** 官方 RL 训练原句(保持与 Minimal 预设逐字节一致)。 */
25
- const RL_PERSONA = 'You are a helpful software engineer assistant.'
26
-
27
- /** dsh-router-standard 的 Flash 弱路由人设(build/fix 分类 + 三锚 + 深想)。 */
28
- const FLASH_PERSONA = [
29
- 'You are a helpful assistant.',
30
- 'Before acting, decide the task type (build or fix) and adopt the matching style: build → hands-on production; fix → inspect-and-plan.',
31
- 'Before acting, briefly review what you have already done in this session and continue from where you left off; do not repeat completed steps. Do not run environment checks (echo, whoami, uname, node --version, date) or exhaustive grep/glob scans.',
32
- 'Think deeply first, then produce.',
33
- ].join('\n')
34
-
35
- /** 是否为 persona 段:旧 persona 段由本模块替换。 */
36
- function isPersonaSection(section) {
37
- if (section === null || typeof section !== 'object') return false
38
- const name = String(section.name ?? '')
39
- return name === 'persona' || /persona/i.test(name)
40
- }
41
-
42
- /** 首轮需要隐藏的 mnemon 自动注入段(晋升后恢复)。 */
43
- function isMnemonSection(section) {
44
- if (section === null || typeof section !== 'object') return false
45
- return String(section.name ?? '').startsWith('mnemon:')
46
- }
47
-
48
- export function apply(ctx) {
49
- ctx.on('system-prompt/assemble', async (_assembly, context, next) => {
50
- const assembled = await next()
51
- const agent = context.agent
52
- if (agent === undefined) return assembled
53
- const session = agent.session
54
- if (session === undefined) return assembled
55
-
56
- // 子代理不裁剪任何 section/context:它与 dsh-mnemon 的工具白名单协作。
57
- if ((session.header?.delegationDepth ?? 0) > 0) return assembled
58
-
59
- const modelId = agent.options?.model
60
- const persona = typeof modelId === 'string' && /flash/i.test(modelId) ? FLASH_PERSONA : RL_PERSONA
61
-
62
- const sections = assembled.sections ?? []
63
- const promoted = session.events.some((event) => event.type === 'tool/call')
64
- const kept = sections.filter((section) => !isPersonaSection(section) && (promoted || !isMnemonSection(section)))
65
- const routerSections = [...kept, { name: 'router-persona', text: persona, order: 0 }]
66
- return promoted
67
- ? { ...assembled, sections: routerSections }
68
- : { ...assembled, sections: routerSections, contexts: [] }
69
- })
70
- }
1
+ /**
2
+ * router-first-turn — prompt-tool 本地附加件(最优组合)。
3
+ *
4
+ * 依据 dsh-router-standard 的 router-bootstrap 组装逻辑:
5
+ * - 只替换 persona 段,保留计划模式段与其他第三方 section;
6
+ * - 首轮隐藏 mnemon:* 自动注入段,晋升后恢复(contexts 由 context-gate 清空/恢复);
7
+ * - 按主会话模型自动选择 persona:
8
+ * Pro → 官方训练原句;
9
+ * Flash → dsh-router-standard 的 Flash 弱路由人设(build/fix 分类 +
10
+ * 回顾锚 + 收敛锚 + 反跑题锚 + 先深想再产出);
11
+ * - 子代理(委托深度 > 0)直接放行完整结果,保证 dsh-mnemon 等插件
12
+ * 通过工具白名单要求的 mnemon_* 工具首轮可见。
13
+ *
14
+ * 工具目录裁剪仍由 tool-bootstrap 负责(首轮 = 真实 Minimal 工具对;晋升后
15
+ * 恢复完整目录或切换 PTC),本模块不触碰 tools,避免两层过滤器冲突。
16
+ */
17
+
18
+ import { createEpochPromotion } from './compaction-epoch.mjs'
19
+ import { isDelegated, isFlashModel, PROMOTE_EVENTS } from './shared.mjs'
20
+
21
+ /** Cordis 插件名,供 loader 诊断使用。 */
22
+ export const name = 'router-first-turn'
23
+
24
+ /** 无服务依赖,只监听 system-prompt 组装。 */
25
+ export const inject = []
26
+
27
+ /** 官方 RL 训练原句(保持与 Minimal 预设逐字节一致)。 */
28
+ const RL_PERSONA = 'You are a helpful software engineer assistant.'
29
+
30
+ /** dsh-router-standard 的 Flash 弱路由人设(build/fix 分类 + 三锚 + 深想)。 */
31
+ const FLASH_PERSONA = [
32
+ 'You are a helpful assistant.',
33
+ 'Before acting, decide the task type (build or fix) and adopt the matching style: build → hands-on production; fix → inspect-and-plan.',
34
+ 'Before acting, briefly review what you have already done in this session and continue from where you left off; do not repeat completed steps. Do not run environment checks (echo, whoami, uname, node --version, date) or exhaustive grep/glob scans.',
35
+ 'Think deeply first, then produce.',
36
+ ].join('\n')
37
+
38
+ /** 是否为 persona 段:旧 persona 段由本模块替换。 */
39
+ function isPersonaSection(section) {
40
+ if (section === null || typeof section !== 'object') return false
41
+ const name = String(section.name ?? '')
42
+ return name === 'persona' || /persona/i.test(name)
43
+ }
44
+
45
+ /** 首轮需要隐藏的 mnemon 自动注入段(晋升后恢复)。 */
46
+ function isMnemonSection(section) {
47
+ if (section === null || typeof section !== 'object') return false
48
+ return String(section.name ?? '').startsWith('mnemon:')
49
+ }
50
+
51
+ export function apply(ctx) {
52
+ const promotion = createEpochPromotion(PROMOTE_EVENTS.either, { includeSubagents: false })
53
+ ctx.on('session/event', (session, event) => promotion.observe(session, event))
54
+
55
+ ctx.on('system-prompt/assemble', async (_assembly, context, next) => {
56
+ const assembled = await next()
57
+ const agent = context.agent
58
+ if (agent === undefined) return assembled
59
+ const session = agent.session
60
+ if (session === undefined) return assembled
61
+
62
+ // 子代理不裁剪任何 section/context:它与 dsh-mnemon 的工具白名单协作。
63
+ if (isDelegated(session)) return assembled
64
+
65
+ const persona = isFlashModel(agent.options?.model) ? FLASH_PERSONA : RL_PERSONA
66
+
67
+ const sections = assembled.sections ?? []
68
+ const promoted = promotion.status(agent).promoted
69
+ const kept = sections.filter((section) => !isPersonaSection(section) && (promoted || !isMnemonSection(section)))
70
+ // contexts 由 context-gate 统一清空/恢复,本模块只替换 persona 与隐藏 mnemon 段。
71
+ return { ...assembled, sections: [...kept, { name: 'router-persona', text: persona, order: 0 }] }
72
+ })
73
+ }
@@ -11,10 +11,13 @@
11
11
  * 子代理不注入;首轮不注入(首句锚定由 near-anchor 负责)。
12
12
  */
13
13
 
14
+ import { createEpochPromotion } from './compaction-epoch.mjs'
15
+ import { extractText, isDelegated, isFlashModel, newMessageId, PROMOTE_EVENTS } from './shared.mjs'
16
+
14
17
  /** Cordis 插件名,供 loader 诊断使用。 */
15
18
  export const name = 'router-guide'
16
19
 
17
- /** 无服务依赖,只监听 pre-step 消息组装。 */
20
+ /** 无服务依赖,只监听 session/event 与 pre-step。 */
18
21
  export const inject = []
19
22
 
20
23
  /** 复杂任务判定:长度或架构关键词。 */
@@ -23,26 +26,19 @@ const COMPLEX_RE = /(架构|重构|全面|详细|设计|系统|优化|分析|arc
23
26
  const GUIDE_WEAK = '\nRouter: classify this task (build or fix) now, then adopt the matching style — build: direct production; fix: inspect-first. Think deeply first, then commit and act.'
24
27
  const GUIDE_DEEP = '\nRouter: classify this task (build or fix) now, then adopt the matching style — build: direct production; fix: inspect-first. Think deeply about the architecture, edge cases, and integration points. Do not spend reasoning on the environment or tooling. Produce when your information is complete. End each reasoning block with a decision or an information need.'
25
28
 
26
- function newMessageId() {
27
- return typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
28
- ? crypto.randomUUID()
29
- : `router-guide-${Date.now()}-${Math.random().toString(36).slice(2)}`
30
- }
29
+ /** 宿主 UI 写入的默认编辑框文本;未改动时等价自动模式。 */
30
+ const DEFAULT_GUIDE_TEXT = ['简单任务自动引导:', GUIDE_WEAK.trim(), '', '复杂任务自动引导:', GUIDE_DEEP.trim()].join('\n')
31
+
31
32
 
32
- function extractText(message) {
33
- if (!message) return ''
34
- const content = Array.isArray(message.content) ? message.content : []
35
- return content.map((block) => (typeof block === 'string' ? block : (block?.text ?? ''))).join(' ').trim()
36
- }
37
33
 
38
34
  export function apply(ctx, config) {
39
35
  const useCustom = config?.useCustom === true
40
36
  const customText = typeof config?.text === 'string' ? config.text : ''
41
37
  const enabled = config?.enabled !== false
42
- // 与宿主写入的默认内容一致:打开自定义但未改动默认文本时,等价自动模式。
43
- const DEFAULT_GUIDE_TEXT = ['简单任务自动引导:', GUIDE_WEAK.trim(), '', '复杂任务自动引导:', GUIDE_DEEP.trim()].join('\n')
44
38
  const unchangedDefault = customText.trim() === DEFAULT_GUIDE_TEXT.trim()
45
- const effectiveAuto = !useCustom || unchangedDefault
39
+
40
+ const promotion = createEpochPromotion(PROMOTE_EVENTS.either, { includeSubagents: false })
41
+ ctx.on('session/event', (session, event) => promotion.observe(session, event))
46
42
 
47
43
  ctx.on('agent/pre-step', async ({ agent }, next) => {
48
44
  const decision = await next()
@@ -51,14 +47,12 @@ export function apply(ctx, config) {
51
47
  const session = agent.session
52
48
  if (session === undefined) return decision
53
49
  // 子代理不注入。
54
- if ((session.header?.delegationDepth ?? 0) > 0) return decision
50
+ if (isDelegated(session)) return decision
55
51
  // 自动模式只对 Flash 主会话注入;自定义开关打开后,Pro 与 Flash 一样注入。
56
- const modelId = agent.options?.model
57
- const isFlash = typeof modelId === 'string' && /flash/i.test(modelId)
52
+ const isFlash = isFlashModel(agent.options?.model)
58
53
  if (!useCustom && !isFlash) return decision
59
54
  // 晋升后才注入(首轮由 near-anchor 负责)。
60
- const promoted = session.events.some((event) => event.type === 'tool/call' || event.type === 'assistant/message')
61
- if (!promoted) return decision
55
+ if (!promotion.status(agent).promoted) return decision
62
56
 
63
57
  const messages = Array.isArray(decision.messages) ? decision.messages : []
64
58
  const userIndex = messages.findIndex((message) => message?.source?.kind === 'user')
@@ -75,7 +69,7 @@ export function apply(ctx, config) {
75
69
 
76
70
  const nextMessages = [...messages]
77
71
  nextMessages.splice(userIndex + 1, 0, {
78
- id: newMessageId(),
72
+ id: newMessageId('router-guide'),
79
73
  role: 'user',
80
74
  content: [{ type: 'text', text: guide }],
81
75
  source: { kind: 'router-guide', plugin: 'router-guide', form: 'notice', summary: 'router-guide 每轮近距离引导' },
@@ -0,0 +1,83 @@
1
+ /**
2
+ * shared — small utilities shared by the prompt-tool preset scripts.
3
+ * Not a plugin row: no name/inject exports and no listeners.
4
+ */
5
+
6
+ /** Durable session event types that count as a promotion signal per mode. */
7
+ export const PROMOTE_EVENTS = {
8
+ 'tool-call': ['tool/call'],
9
+ 'assistant-message': ['assistant/message'],
10
+ either: ['tool/call', 'assistant/message'],
11
+ }
12
+
13
+ /** Parse the shared promoteOn vocabulary. */
14
+ export function parsePromoteOn(pluginName, value) {
15
+ if (value === undefined || value === 'either') return PROMOTE_EVENTS.either
16
+ if (value === 'tool-call' || value === 'assistant-message') return PROMOTE_EVENTS[value]
17
+ throw new TypeError(`${pluginName}: promoteOn must be one of "tool-call", "assistant-message", "either"; got ${JSON.stringify(value)}`)
18
+ }
19
+
20
+ /** Validate an optional boolean config flag with a default. */
21
+ export function booleanOption(pluginName, value, field, fallback) {
22
+ if (value === undefined) return fallback
23
+ if (typeof value !== 'boolean') {
24
+ throw new TypeError(`${pluginName}: ${field} must be a boolean`)
25
+ }
26
+ return value
27
+ }
28
+
29
+ /** Validate the shared config envelope and unknown-key contract. */
30
+ export function validateConfig(pluginName, source, allowedKeys) {
31
+ const config = source === undefined ? {} : source
32
+ if (typeof config !== 'object' || config === null || Array.isArray(config)) {
33
+ throw new TypeError(`${pluginName}: config must be an object`)
34
+ }
35
+ const unknown = Object.keys(config).filter((key) => !allowedKeys.has(key))
36
+ if (unknown.length > 0) {
37
+ throw new TypeError(
38
+ `${pluginName}: unknown config key(s) ${unknown.join(', ')} — allowed keys: ${[...allowedKeys].sort().join(', ')}`,
39
+ )
40
+ }
41
+ return config
42
+ }
43
+
44
+ /** One-shot logger guard shared by filters that degrade instead of throwing. */
45
+ export function createWarnOnce(ctx, pluginName) {
46
+ let warned = false
47
+ return (message) => {
48
+ if (warned) return
49
+ warned = true
50
+ try {
51
+ ctx.logger.warn(message)
52
+ } catch {
53
+ // Logger unavailable — the guard exists only to avoid spamming.
54
+ }
55
+ }
56
+ }
57
+
58
+ /** Random message id with a crypto.randomUUID fast path. */
59
+ export function newMessageId(prefix) {
60
+ return typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
61
+ ? crypto.randomUUID()
62
+ : `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2)}`
63
+ }
64
+
65
+ /** Extract plain text from a message or from a wrapped event data shape. */
66
+ export function extractText(message) {
67
+ if (!message) return ''
68
+ const payload = message && typeof message.message === 'object' && message.message !== null
69
+ ? message.message
70
+ : message
71
+ const content = Array.isArray(payload.content) ? payload.content : []
72
+ return content.map((block) => (typeof block === 'string' ? block : (block?.text ?? ''))).join(' ').trim()
73
+ }
74
+
75
+ /** True for subagent sessions. */
76
+ export function isDelegated(session) {
77
+ return (session?.header?.delegationDepth ?? 0) > 0
78
+ }
79
+
80
+ /** True when a model id looks like a Flash-family model. */
81
+ export function isFlashModel(modelId) {
82
+ return typeof modelId === 'string' && /flash/i.test(modelId)
83
+ }