dsh-plugin-prompt-tool 0.5.0 → 0.6.1

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 (38) hide show
  1. package/README.md +18 -12
  2. package/engine/anchor-match.mjs +117 -0
  3. package/engine/compositions/library/context-gate.yml +2 -2
  4. package/engine/compositions/library/delegation.yml +2 -2
  5. package/engine/compositions/library/str-replace-editor.yml +1 -1
  6. package/engine/compositions/library/tool-bootstrap.yml +2 -2
  7. package/engine/compositions/library/tool-filter.yml +3 -3
  8. package/engine/compositions/source/local/context-gate.yml +2 -2
  9. package/engine/compositions/source/local/tool-bootstrap.yml +3 -3
  10. package/engine/executor.mjs +9 -2
  11. package/engine/interpolate.mjs +116 -0
  12. package/engine/layers.mjs +26 -22
  13. package/engine/schema.mjs +18 -0
  14. package/engine/session-vars.mjs +52 -0
  15. package/engine/shared.mjs +0 -19
  16. package/engine/strategies.mjs +28 -38
  17. package/lib/client.js +471 -162
  18. package/lib/client.js.map +1 -1
  19. package/lib/index.d.mts +35 -9
  20. package/lib/index.mjs +376 -92
  21. package/lib/preset-core.d.mts +2 -2
  22. package/lib/preset-core.mjs +3 -6
  23. package/lib/{prompt-configs-BcP6miu9.d.mts → prompt-configs-B4vH09wx.d.mts} +1 -3
  24. package/lib/{prompt-configs-k7wuZs0C.mjs → prompt-configs-ThS4iXPg.mjs} +85 -33
  25. package/package.json +105 -104
  26. package/preset/anchored/preset.yml +40 -42
  27. package/preset/creative/preset.yml +33 -30
  28. package/preset/creative/skills/cordis-plugin-development/SKILL.md +420 -0
  29. package/preset/creative/skills/editing-cordis-compositions/SKILL.md +165 -0
  30. package/preset/liangshen/preset.yml +24 -18
  31. package/preset/minimal/preset.yml +27 -24
  32. package/preset/ptc/preset.yml +23 -17
  33. package/preset/standard/preset.yml +23 -17
  34. package/templates/20-system-section.yml +5 -0
  35. package/templates/70-subagent-maintenance.yml +38 -0
  36. package/engine/compositions/library/router-first-turn.yml +0 -9
  37. package/engine/compositions/source/local/router-first-turn.yml +0 -5
  38. package/engine/router-first-turn.mjs +0 -85
package/README.md CHANGED
@@ -21,14 +21,16 @@ dsh --profile prompt-tool # 首次启
21
21
  - 🔌 **六层一次接线**:一个引擎注册全部可注入层级,共享同一套过滤与降级语义
22
22
  - ✍️ **一切皆可配置**:`layer / strategy / position / promotion / subagents / modelScope / mergeMode / order / text / texts / fill / variables / params` 全开放
23
23
  - 🧑‍🤝‍🧑 **子代理三态**:`subagents: none / inherit / only`,身份类提示词可只注入子代理
24
- - 🗂️ **内容与执行分离**:每条提示词配置一个 yml/json 放 `prompt-configs/`,引擎按文件名数字前缀顺序扫描
25
- - 🧩 **三源合并**:默认提示词配置 < 目录文件 < settings 数组,同名 `id` 覆盖
24
+ - 🗂️ **内容与执行分离**:每条提示词配置渲染为 `~/.dsh/.agent-presets/<预设>/prompt-configs/` 下的 yml,引擎按文件名数字前缀顺序扫描
25
+ - 🧩 **三层合并**:引擎默认(按 params 生成)< 模板默认 promptConfigs < 预设 promptConfigs,同名 `id` 覆盖
26
26
  - 🖥️ **独立工作台**:侧边栏「提示词工具」入口(主会话/子代理/注入层/技能设置/预设配置五页)+ 设置面板提示词配置页(列表/表单/模板插入/保存前校验)
27
- - 🧪 **七种内容策略**:`static / first-turn-anchor / guide-auto / custom-fallback / instruction-hint / placeholder / world-book`
27
+ - 🧪 **七种内容策略**:`static / first-turn-anchor / guide-auto / custom-fallback / instruction-hint / placeholder / world-book`(world-book 支持 ST selectiveLogic 选择性触发:任一/副键全中/排除)
28
28
  - 🛡️ **失败不伤会话**:单条失败跳过 + `warnOnce`;配置错误挂载时 fail loud;`dedupe: session` 持久幂等
29
- - 🎭 **SillyTavern 导入**:JSON 预设卡片一键转换为本地预设——`prompts[]` 映射提示词配置、采样参数进 `params.model*`、`enable_web_search` 按开关装配工具
29
+ - 🎭 **SillyTavern 导入**:JSON 预设卡片一键转换为本地预设——`prompts[]` 映射提示词配置、setvar/getvar 收集进顶层 `variables`(未定义自定义宏自动登记空值占位)、`enable_web_search` 按开关装配工具;采样参数剥离(模型设置 UI 管理)
30
30
  - 🎴 **角色卡库**:SillyTavern 角色卡(PNG tEXt chunk `ccv3`/`chara`,或 chara_card JSON)导入独立库(`.characters/<id>/`,含原图/转换参数/角色记忆),按需「导入到当前预设」(`chara-<卡>-` 前缀合并、幂等可移除),多文件自动合并
31
- - 📚 **世界书**:`character_book` 转 world-book 策略配置(`keys` 命中触发 / `constant` 常驻 / `useRegex` 正则),与模块卡片同一存储与编辑(模块列表「世界书」过滤 + 批量启用/禁用);ST 变量规则(`setvar`/`getvar` 含默认值)由 params fallback 插值承载
31
+ - 📚 **世界书**:`character_book` 转 world-book 策略配置(`keys` 命中触发 / `constant` 常驻 / `useRegex` 正则 / `selectiveLogic` 组合逻辑),与模块卡片同一存储与编辑(模块列表「世界书」过滤 + 批量启用/禁用)
32
+ - 🧩 **模板变量**:预设级 `variables` 段(`{{key}}` 插值源)——模块列表顶部「模板变量」卡片统一编辑(可折叠/清空/停用/失焦自动保存);锚定匹配引擎(anchor-match)统一 custom-fallback 与 world-book 的匹配语义
33
+ - 💬 **会话变量工具**:`session_var`(list/get/set/clear)——模型维护角色状态(`{{心情}}` 等),会话级覆盖预设默认;ST 运行时宏(`{{lastusermessage}}` / `{{lastcharmessage}}`)从会话事件提取
32
34
 
33
35
  ## 预设参数体系
34
36
 
@@ -50,7 +52,7 @@ dsh --profile prompt-tool # 首次启
50
52
  | PTC/门控 | `usePtcMode` `bootstrapMaxTokens` `injectPrompt` `allowKinds` |
51
53
  | 主对话模型 | `modelProvider` `modelName` `modelReasoningEffort` `modelTemperature` `modelMaxTokens` |
52
54
  | 子代理模型 | `subagentModelProvider` `subagentModelName` `subagentReasoningEffort` `subagentTemperature` `subagentMaxTokens` |
53
- | 人设 | `mainPersona`(主对话 Flash 档)`subagentPersona`(子代理,回退 `mainPersona` 继承主会话) |
55
+ | 人设 | 模块列表的 `persona-main`(system-section + `deployment:persona`,complete 互斥 + suppressRuntimeContext);`subagentPersona`(子代理显式,缺省经 scope 链继承主会话) |
54
56
  | 工具集 | `toolFilterAllow` `toolFilterDeny`(子代理 toolFilter;主对话 tool-filter 模块共用) |
55
57
  | 深度 | `maxDepth`(0 禁止委派 / `provider-managed` / 正整数) |
56
58
 
@@ -71,7 +73,7 @@ dsh --profile prompt-tool # 首次启
71
73
 
72
74
  - `mergeMode`:`separate`(默认)同位置多条为独立消息;`merged` 同位置拼接为一条
73
75
  - `order`:数值小者更靠近插入锚点,同时决定 `merged` 组内拼接顺序
74
- - 文本插值:仅 `pre-step` 支持内置环境变量与 filler 变量;`system-section` 额外走官方 `{{variable}}` 渲染
76
+ - 文本插值:`{{key}}` 全层支持——配置/预设 `variables` 优先,ST 运行时宏(lastusermessage 等)次之,内置 `{{DSH_HOME}}/{{WORKSPACE}}/{{CWD}}` 兜底,未注册保留字面(system-section 注册期无会话时运行时宏替换为空,不残留)
75
77
 
76
78
 
77
79
  ## SillyTavern 导入
@@ -79,7 +81,8 @@ dsh --profile prompt-tool # 首次启
79
81
  工作台「预设配置」页导入 SillyTavern JSON 预设卡片(导入包无定义文件、仅含单个 `.json` 时自动识别转换),按注入层级映射为本地预设:
80
82
 
81
83
  - `prompts[]` → `promptConfigs`:`system_prompt + role=system` → `system-section`(多条可 `mergeMode: merged` 拼接);其余 → `pre-step`(`injection_position=0` → `before-all`,否则 `after-user`);OFF 状态与 `injection_order` 原样保留
82
- - 采样参数 → 顶层 `params.model*`:`temperature` / `openai_max_tokens` / `reasoning_effort`
84
+ - 采样参数(`temperature` / `openai_max_tokens` / `reasoning_effort`)**剥离**——模型参数统一由「模型设置」UI / 宿主默认管理
85
+ - ST 变量:`setvar`/`getvar`(含默认值)收集进顶层 `variables`;未定义自定义宏自动登记空值占位(不留字面)
83
86
  - `enable_web_search`:`true` → 组装 `tool-web`(fetch 启用);`false` → 不组装,改加 `tool-filter` 黑名单 `web_search / web_fetch`
84
87
  - `modules` 按需装配:`prompt-config-engine` 始终;含 system-section 时补 `persona`(`complete: false` 允许 system 段生效)
85
88
 
@@ -92,7 +95,7 @@ dsh --profile prompt-tool # 首次启
92
95
  - **PNG**:tEXt chunk(`ccv3` 优先 / `chara` 兜底)base64 解析,原图存 `avatar.png`(字节无损)
93
96
  - **JSON**:chara_card_v2/v3 直接转换;多文件(角色卡 × 响应预设)自动合并
94
97
  - 正文映射:`first_mes` → 开场白(`dedupe: session`)、`alternate_greetings` → 备用开场白、
95
- `description/personality/scenario` → 角色设定、采样参数 `params.model*`
98
+ `description/personality/scenario` → 角色设定;采样参数剥离(模型设置 UI 管理)
96
99
  - **导入到当前预设**:参数合并进当前预设 promptConfigs(`chara-<卡>-` 前缀、幂等);可一键移除
97
100
  - **角色记忆**:`memory.md` 跟随角色卡跨预设,应用时合并为 world-book constant 配置注入
98
101
 
@@ -104,8 +107,11 @@ dsh --profile prompt-tool # 首次启
104
107
  - **匹配选项**:`caseSensitive` / `wholeWords` / `useRegex`(keys 按正则)
105
108
  - **管理**:模块列表顶部下拉选「世界书」过滤(完整模块卡片编辑 + 批量启用/禁用);
106
109
  模型工具 `world_book_list/upsert/delete`(`note` 写入角色卡记忆)
107
- - **ST 变量**:`setvar` 收集进 params、`getvar`(含默认值)改写为 `{{key}}` 由引擎插值兜底;
108
- `trim`/注释/ERA 剥离,`{{user}}`/`{{char}}` 替换;TavernHelper 扩展注入物自动剥离
110
+ - **ST 变量**:`setvar`/`getvar` 收集进顶层 `variables`、未定义自定义宏自动登记空值占位;
111
+ `trim`/注释/ERA 剥离,`{{user}}`/`{{char}}` 替换;运行时宏(lastusermessage/lastcharmessage)
112
+ 从会话事件提取;TavernHelper 扩展注入物自动剥离
113
+ - **会话变量**:`session_var` 工具(list/get/set/clear)维护角色状态(会话级覆盖预设默认,
114
+ 结束即失);跨会话长期记忆用 `world_book` note(持久 memory.md 跟随角色卡)
109
115
 
110
116
  详细转换规则见 [SillyTavern.md](SillyTavern.md)。
111
117
 
@@ -113,7 +119,7 @@ dsh --profile prompt-tool # 首次启
113
119
 
114
120
  ```sh
115
121
  pnpm install && pnpm build
116
- pnpm test # 235 单测:渲染/合并/六层接线/preset 生成
122
+ pnpm test # 287 单测:渲染/合并/六层接线/preset 生成/锚定匹配/插值/会话变量
117
123
  pnpm typecheck && pnpm lint
118
124
  pnpm sync:anchored # 刷新 upstream/dsh-anchored-standard 内联快照
119
125
  pnpm sync:yaml # 刷新 engine/vendor/yaml(生成目录运行时 YAML 解析器)
@@ -0,0 +1,117 @@
1
+ /**
2
+ * anchor-match — 关键词锚定匹配引擎(纯函数,无状态,无依赖)。
3
+ *
4
+ * 从 strategies 拆解:custom-fallback(自定义锚定词)与 world-book(关键词触发)
5
+ * 共用同一套匹配语义——主键/副键、大小写、整词、正则、组合逻辑。
6
+ *
7
+ * 逻辑对齐 ST world_info_logic:
8
+ * any = AND_ANY(0):主键或副键任一命中即激活(world-book 默认 / custom-fallback 单锚);
9
+ * all = AND_ALL(3):主键命中且副键全部命中;
10
+ * not = NOT_ALL(1):主键命中且副键全部未命中(排除);
11
+ * notAny = NOT_ANY(2):主键命中且至少一个副键未命中(部分排除)。
12
+ *
13
+ * 模式:
14
+ * scan = 全文扫描(world-book 消息批匹配);
15
+ * prefix = 文本开头匹配(custom-fallback 首轮 reasoning 锚定确认:ASCII 词边界前缀,
16
+ * 非 ASCII 直 prefix)。
17
+ */
18
+
19
+ /** 组合逻辑(ST world_info_logic 映射)。 */
20
+ export const MATCH_LOGIC = {
21
+ ANY: 'any',
22
+ ALL: 'all',
23
+ NOT: 'not',
24
+ NOT_ANY: 'notAny',
25
+ }
26
+
27
+ /** 正则转义(关键词原样匹配时用)。 */
28
+ export function escapeRegExp(text) {
29
+ return String(text).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
30
+ }
31
+
32
+ /** 单键正则编译(逐键匹配:any/all/not 需要精确的命中键数,捕获组会干扰 match 计数)。 */
33
+ function compileSingle(key, { caseSensitive, wholeWords, useRegex }) {
34
+ const flags = caseSensitive ? '' : 'i'
35
+ if (useRegex) {
36
+ // ST use_regex=true:键原样作为正则(作者负责合法性)。
37
+ return new RegExp(key, flags)
38
+ }
39
+ if (wholeWords) {
40
+ return new RegExp(`(^|[^\\p{L}\\p{N}])(${escapeRegExp(key)})(?![\\p{L}\\p{N}])`, `${flags}u`)
41
+ }
42
+ return new RegExp(escapeRegExp(key), flags)
43
+ }
44
+
45
+ /** 编译键列表 → [{ key, re }](剔除空键)。 */
46
+ function compileKeyList(list, options) {
47
+ return (Array.isArray(list) ? list : [])
48
+ .map((key) => String(key).trim())
49
+ .filter((key) => key.length > 0)
50
+ .map((key) => ({ key, re: compileSingle(key, options) }))
51
+ }
52
+
53
+ /**
54
+ * 创建锚定匹配器。
55
+ * @param {object} options
56
+ * @param {string[]} options.keys 主键
57
+ * @param {string[]} [options.secondaryKeys] 副键
58
+ * @param {boolean} [options.caseSensitive]
59
+ * @param {boolean} [options.wholeWords]
60
+ * @param {boolean} [options.useRegex]
61
+ * @param {'any'|'all'|'not'|'notAny'} [options.logic] 组合逻辑(缺省 any)
62
+ * @param {'scan'|'prefix'} [options.mode] 匹配模式(缺省 scan)
63
+ * @returns {{ scan: (text: string) => { primary: number, secondary: number, active: boolean } }}
64
+ */
65
+ export function createAnchorMatcher(options = {}) {
66
+ const {
67
+ keys = [],
68
+ secondaryKeys = [],
69
+ caseSensitive = false,
70
+ wholeWords = false,
71
+ useRegex = false,
72
+ logic = MATCH_LOGIC.ANY,
73
+ mode = 'scan',
74
+ } = options
75
+ const primaryList = compileKeyList(keys, { caseSensitive, wholeWords, useRegex })
76
+ const secondaryList = compileKeyList(secondaryKeys, { caseSensitive, wholeWords, useRegex })
77
+
78
+ const scan = (raw) => {
79
+ const text = String(raw ?? '')
80
+ if (mode === 'prefix') {
81
+ // custom-fallback 锚定确认:仅主键首词,文本开头匹配。
82
+ const word = (Array.isArray(keys) ? keys : []).map((key) => String(key).trim())
83
+ .find((key) => key.length > 0)
84
+ if (word === undefined || text.length === 0) return { primary: 0, secondary: 0, active: false }
85
+ const hit = /^[\x20-\x7E]+$/.test(word)
86
+ ? new RegExp(`^${escapeRegExp(word.toLowerCase())}\\b`, 'i').test(text)
87
+ : text.startsWith(word)
88
+ return { primary: hit ? 1 : 0, secondary: 0, active: hit }
89
+ }
90
+ if (primaryList.length === 0 && secondaryList.length === 0) {
91
+ return { primary: 0, secondary: 0, active: false }
92
+ }
93
+ const countHits = (list) => {
94
+ let hits = 0
95
+ for (const { re } of list) {
96
+ re.lastIndex = 0
97
+ if (re.test(text)) hits += 1
98
+ }
99
+ return hits
100
+ }
101
+ const primary = countHits(primaryList)
102
+ const secondary = countHits(secondaryList)
103
+ let active = false
104
+ if (logic === MATCH_LOGIC.ALL) {
105
+ active = primary > 0 && (secondaryList.length === 0 || secondary === secondaryList.length)
106
+ } else if (logic === MATCH_LOGIC.NOT) {
107
+ active = primary > 0 && secondary === 0
108
+ } else if (logic === MATCH_LOGIC.NOT_ANY) {
109
+ active = primary > 0 && secondaryList.length > 0 && secondary < secondaryList.length
110
+ } else {
111
+ active = primary > 0 || secondary > 0
112
+ }
113
+ return { primary, secondary, active }
114
+ }
115
+
116
+ return { scan }
117
+ }
@@ -6,7 +6,7 @@
6
6
  config:
7
7
  promoteOn: either
8
8
  includeSubagents: false
9
- allowKinds: __ALLOW_KINDS__
9
+ allowKinds: __allowKinds__
10
10
 
11
11
  # ── bootstrap ───────────────────────────────────────────────────────────────
12
12
 
@@ -37,4 +37,4 @@
37
37
  #
38
38
  # `includeSubagents: true` keeps the subagent phase in sync with the
39
39
  # context-gate row: a subagent's first request sees the bootstrap pair, then
40
- # its own first reply or tool call promotes it to the assembled catalog.
40
+ # its own first reply or tool call promotes it to the assembled catalog.
@@ -37,7 +37,7 @@
37
37
  provider: spawn
38
38
  toolName: subagent
39
39
  backgroundMode: continuable
40
- __SUBAGENT_CONFIG__
40
+ __subagentConfig__
41
41
 
42
42
  - id: tool-subagent-fork
43
43
  name: '@deepseek-ai/dsh-tool-subagent'
@@ -45,7 +45,7 @@
45
45
  provider: fork
46
46
  toolName: subagent_fork
47
47
  backgroundMode: continuable
48
- __SUBAGENT_CONFIG__
48
+ __subagentConfig__
49
49
 
50
50
  # Production dsh does not install these optional providers. An opting-in
51
51
  # Profile mounts each provider once on the host plane; copy this preset,
@@ -7,4 +7,4 @@
7
7
  - id: str-replace-editor
8
8
  name: '@deepseek-ai/dsh-tool-str-replace-editor'
9
9
  config:
10
- maxOutputChars: __STR_REPLACE_EDITOR_MAX_OUTPUT_CHARS__
10
+ maxOutputChars: __strReplaceEditorMaxOutputChars__
@@ -7,8 +7,8 @@
7
7
  bootstrapTools: [bash, str_replace_editor]
8
8
  promoteOn: either
9
9
  includeSubagents: false
10
- usePtcMode: __USE_PTC_MODE__
11
- __BOOTSTRAP_MAX_TOKENS__
10
+ usePtcMode: __usePtcMode__
11
+ __bootstrapMaxTokens__
12
12
  # Post-compaction core work set: the model is mid-task and needs to keep
13
13
  # working, but faces a small catalog instead of the full Standard set.
14
14
  compactionTools: [read, write, edit, glob, grep, todo_write, ask_user_question]
@@ -7,6 +7,6 @@
7
7
  - id: tool-filter
8
8
  name: ./engine/tool-filter.mjs
9
9
  config:
10
- allow: __MAIN_TOOL_FILTER_ALLOW__
11
- deny: __MAIN_TOOL_FILTER_DENY__
12
- includeSubagents: __MAIN_TOOL_FILTER_SUBAGENTS__
10
+ allow: __mainToolFilterAllow__
11
+ deny: __mainToolFilterDeny__
12
+ includeSubagents: __mainToolFilterSubagents__
@@ -3,7 +3,7 @@
3
3
  config:
4
4
  promoteOn: either
5
5
  includeSubagents: false
6
- allowKinds: __ALLOW_KINDS__
6
+ allowKinds: __allowKinds__
7
7
 
8
8
  # ── bootstrap ───────────────────────────────────────────────────────────────
9
9
 
@@ -34,4 +34,4 @@
34
34
  #
35
35
  # `includeSubagents: true` keeps the subagent phase in sync with the
36
36
  # context-gate row: a subagent's first request sees the bootstrap pair, then
37
- # its own first reply or tool call promotes it to the assembled catalog.
37
+ # its own first reply or tool call promotes it to the assembled catalog.
@@ -4,10 +4,10 @@
4
4
  bootstrapTools: [bash, str_replace_editor]
5
5
  promoteOn: either
6
6
  includeSubagents: true
7
- usePtcMode: __USE_PTC_MODE__
8
- __BOOTSTRAP_MAX_TOKENS__
7
+ usePtcMode: __usePtcMode__
8
+ __bootstrapMaxTokens__
9
9
  # Post-compaction core work set: the model is mid-task and needs to keep
10
10
  # working, but faces a small catalog instead of the full Standard set.
11
11
  compactionTools: [read, write, edit, glob, grep, todo_write, ask_user_question]
12
12
 
13
- # ── prompt-tool 模板自带行(由 write-preset 按 manifest 变量物化) ─────────────
13
+ # ── prompt-tool 模板自带行(由 write-preset 按 manifest 变量物化) ─────────────
@@ -7,13 +7,14 @@
7
7
  import {
8
8
  PROMOTE_EVENTS,
9
9
  createWarnOnce,
10
- interpolateVariables,
11
10
  isDelegated,
12
11
  matchesModel,
13
12
  newMessageId,
14
13
  } from './shared.mjs'
14
+ import { interpolateVariables } from './interpolate.mjs'
15
15
  import { createEpochPromotion } from './compaction-epoch.mjs'
16
16
  import { wireLayers } from './layers.mjs'
17
+ import { sessionVarsSnapshot } from './session-vars.mjs'
17
18
 
18
19
  const name = 'prompt-config-engine'
19
20
 
@@ -169,7 +170,13 @@ export function applyPromptConfigs(ctx, configs, options = {}) {
169
170
  const patched = { ...resolved }
170
171
  // params 并入插值变量:ST 变量(setvar/getvar 收集 + 预设参数)顶层 key 直接可插值
171
172
  //(含中文 key 如 {{接受值}}——引擎正则已支持 Unicode 字母)。
172
- const mergedVars = { ...config.variables, ...config.params, ...(resolved.variables !== null && typeof resolved.variables === 'object' ? resolved.variables : {}) }
173
+ // 会话变量(session_var 工具维护)覆盖配置/预设默认:resolved > 会话 > params > 配置。
174
+ const mergedVars = {
175
+ ...config.variables,
176
+ ...config.params,
177
+ ...sessionVarsSnapshot(session),
178
+ ...(resolved.variables !== null && typeof resolved.variables === 'object' ? resolved.variables : {}),
179
+ }
173
180
  if (typeof patched.text === 'string') {
174
181
  // 提示词配置级模板变量 + filler 变量 + 内置环境变量插值。
175
182
  patched.text = interpolateVariables(patched.text, mergedVars, session)
@@ -0,0 +1,116 @@
1
+ /**
2
+ * interpolate — 模板变量插值引擎(纯函数,无依赖)。
3
+ *
4
+ * 从 shared 拆出:{{key}} 插值是引擎官方对齐的核心语义(variables 优先 + 内置
5
+ * 兜底),独立为纯能力包,与 anchor-match 同级;消费方(layers / executor /
6
+ * fillers)按需引用。
7
+ *
8
+ * 插值规则:
9
+ * - {{key}}:配置 variables 有值 → 替换;否则 ST 运行时宏(lastusermessage /
10
+ * lastcharmessage 等,大小写不敏感)→ 会话事件提取;否则动态宏(roll/random/
11
+ * pick/chance/time/date 等,支持 {{name::arg}} 参数)→ 运行时计算;否则内置
12
+ * 变量(DSH_HOME / WORKSPACE / CWD)→ 替换;否则保留字面(宽容)。
13
+ * - 键字符集:字母数字、下划线、点、中文、连字符(与 ST setvar/getvar 一致)。
14
+ */
15
+
16
+ /** 会话事件中最后一条指定类型消息的文本(事件倒序扫描;无则空串)。 */
17
+ function lastMessageOf(session, type) {
18
+ const events = session?.events
19
+ if (!Array.isArray(events)) return ''
20
+ for (let index = events.length - 1; index >= 0; index--) {
21
+ const event = events[index]
22
+ if (event?.type !== type) continue
23
+ const content = event.data?.message?.content
24
+ if (!Array.isArray(content)) continue
25
+ const text = content
26
+ .map((block) => (block !== null && typeof block === 'object' && typeof block.text === 'string') ? block.text : '')
27
+ .join('')
28
+ .trim()
29
+ if (text.length > 0) return text
30
+ }
31
+ return ''
32
+ }
33
+
34
+ /** 逗号分隔随机选一个({{random::a,b,c}} / {{pick::a,b,c}})。 */
35
+ function pickRandom(arg) {
36
+ const items = String(arg ?? '').split(',').map((item) => item.trim()).filter((item) => item.length > 0)
37
+ if (items.length === 0) return ''
38
+ return items[Math.floor(Math.random() * items.length)]
39
+ }
40
+
41
+ /** 骰子表达式({{roll::2d6+3}} / {{roll::1d20}};非法表达式原样返回)。 */
42
+ function rollDice(arg) {
43
+ const text = String(arg ?? '').replace(/\s+/g, '').toLowerCase()
44
+ const match = text.match(/^(\d*)d(\d+)([+-]\d+)?$/)
45
+ if (match === null) return text
46
+ const count = match[1] === '' ? 1 : Number.parseInt(match[1], 10)
47
+ const sides = Number.parseInt(match[2], 10)
48
+ const modifier = match[3] === undefined ? 0 : Number.parseInt(match[3], 10)
49
+ if (!Number.isSafeInteger(count) || count <= 0 || count > 100 || !Number.isSafeInteger(sides) || sides <= 0) return text
50
+ let sum = 0
51
+ for (let index = 0; index < count; index++) sum += 1 + Math.floor(Math.random() * sides)
52
+ return String(sum + modifier)
53
+ }
54
+
55
+ /** 百分比概率({{chance::50}} → true/false)。 */
56
+ function chancePercent(arg) {
57
+ const value = Number(String(arg ?? '').replace('%', '').trim())
58
+ if (!Number.isFinite(value)) return ''
59
+ return Math.random() * 100 < value ? 'true' : 'false'
60
+ }
61
+
62
+ /** 本地 HH:MM({{time}})。 */
63
+ function formatTime(now) {
64
+ return `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`
65
+ }
66
+
67
+ /** UTC YYYY-MM-DD({{date}})。 */
68
+ function formatDate(now) {
69
+ return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
70
+ }
71
+
72
+ /** ST 动态宏(大小写不敏感;函数签名 (arg) => string;无会话上下文时同样可用)。 */
73
+ const DYNAMIC_MACROS = {
74
+ lastusermessage: (_arg, session) => lastMessageOf(session, 'user/message'),
75
+ lastcharmessage: (_arg, session) => lastMessageOf(session, 'assistant/message'),
76
+ // charIfNotGroup:ST 群聊时空、单聊为角色名;dsh 会话 header 无角色名
77
+ //(单角色会话),统一返回空串(不残留字面,也不注入错误内容)。
78
+ charifnotgroup: () => '',
79
+ random: (arg) => pickRandom(arg),
80
+ pick: (arg) => pickRandom(arg),
81
+ roll: (arg) => rollDice(arg),
82
+ chance: (arg) => chancePercent(arg),
83
+ time: () => formatTime(new Date()),
84
+ date: () => formatDate(new Date()),
85
+ weekday: () => '星期' + '日一二三四五六'[new Date().getDay()],
86
+ isotime: () => new Date().toISOString().slice(11, 19),
87
+ isodate: () => new Date().toISOString().slice(0, 10),
88
+ newline: () => '\n',
89
+ pipe: () => '|',
90
+ }
91
+
92
+ /** 模板变量插值:配置 variables 优先,ST 运行时宏次之,内置 {{DSH_HOME}} / {{WORKSPACE}} / {{CWD}} 兜底。 */
93
+ export function interpolateVariables(text, variables, session) {
94
+ const builtins = {
95
+ DSH_HOME: process.env.DSH_HOME ?? (process.env.USERPROFILE ? `${process.env.USERPROFILE}\\.dsh` : ''),
96
+ WORKSPACE: process.env.DSH_WORKSPACE ?? session?.header?.cwd ?? process.cwd(),
97
+ CWD: session?.header?.cwd ?? process.cwd(),
98
+ }
99
+ return text.replace(/\{\{([A-Za-z0-9_.\u4e00-\u9fff-]+)(?:::(.*?))?\}\}/g, (whole, key, arg) => {
100
+ if (Object.prototype.hasOwnProperty.call(variables, key)) return String(variables[key])
101
+ const dynamic = DYNAMIC_MACROS[key.toLowerCase()]
102
+ if (dynamic !== undefined) return dynamic(arg, session)
103
+ return Object.prototype.hasOwnProperty.call(builtins, key) ? builtins[key] : whole
104
+ })
105
+ }
106
+
107
+ /** 仅做配置级静态变量替换(无 session 上下文的层)。 */
108
+ export function interpolateStatic(text, variables) {
109
+ return text.replace(/\{\{([A-Za-z0-9_.\u4e00-\u9fff-]+)(?:::(.*?))?\}\}/g, (whole, key, arg) => {
110
+ if (Object.prototype.hasOwnProperty.call(variables, key)) return String(variables[key])
111
+ // ST 运行时宏在无会话上下文(system-section 注册期)时替换为空串——
112
+ // 不残留字面,也不触发官方 unknown variable 渲染报错。
113
+ const dynamic = DYNAMIC_MACROS[key.toLowerCase()]
114
+ return dynamic !== undefined ? dynamic(arg, undefined) : whole
115
+ })
116
+ }
package/engine/layers.mjs CHANGED
@@ -3,15 +3,14 @@
3
3
  * 所有注册都只作用于本插件提示词配置;单条失败 warnOnce 后继续。
4
4
  */
5
5
 
6
- import {
7
- getService,
8
- interpolateVariables,
9
- interpolateStatic,
10
- isDelegated,
11
- keepDisposer,
12
- matchesModel,
13
- parseToolNames,
14
- } from './shared.mjs'
6
+ import {
7
+ getService,
8
+ isDelegated,
9
+ keepDisposer,
10
+ matchesModel,
11
+ parseToolNames,
12
+ } from './shared.mjs'
13
+ import { interpolateVariables, interpolateStatic } from './interpolate.mjs'
15
14
 
16
15
  const name = 'prompt-config-engine'
17
16
 
@@ -54,8 +53,8 @@ function textLayerGroups(configs) {
54
53
  return groups
55
54
  }
56
55
 
57
- /** system-section:注册静态 system prompt 段(支持官方 {{variable}} 渲染与 merged 拼接)。 */
58
- function wireSystemSections(ctx, configs, warnOnce) {
56
+ /** system-section:注册静态 system prompt 段(支持官方 {{variable}} 渲染与 merged 拼接)。 */
57
+ function wireSystemSections(ctx, configs, warnOnce) {
59
58
  const systemPrompt = getService(ctx, 'systemPrompt')
60
59
  if (systemPrompt === undefined || typeof systemPrompt.section !== 'function') {
61
60
  if (configs.length > 0) warnOnce(`${name}: systemPrompt service unavailable — system-section configs skipped`)
@@ -66,17 +65,22 @@ function wireSystemSections(ctx, configs, warnOnce) {
66
65
  try {
67
66
  const text = group.map((config) => configText(config)).filter((item) => item.length > 0).join('\n\n')
68
67
  if (text.length === 0) continue
69
- keepDisposer(ctx, systemPrompt.section({
70
- name: typeof base.params?.sectionName === 'string' && base.params.sectionName.length > 0 ? base.params.sectionName : base.id,
71
- order: base.order,
72
- text,
73
- ...(base.params?.complete === true ? { complete: true } : {}),
74
- }), `${name}: section ${base.id}`)
75
- } catch (error) {
76
- warnOnce(`${name}: system-section config ${base.id} failed: ${String(error?.message ?? error)}`)
77
- }
78
- }
79
- }
68
+ keepDisposer(ctx, systemPrompt.section({
69
+ name: typeof base.params?.sectionName === 'string' && base.params.sectionName.length > 0 ? base.params.sectionName : base.id,
70
+ order: base.order,
71
+ text,
72
+ ...(base.params?.complete === true ? { complete: true } : {}),
73
+ }), `${name}: section ${base.id}`)
74
+ // persona 语义透传:suppressRuntimeContext 抑制该 scope 的动态 runtime-context
75
+ // 快照(官方 dsh-persona includeRuntimeContext:false 等价;多段重复调用幂等)。
76
+ if (base.params?.suppressRuntimeContext === true) {
77
+ keepDisposer(ctx, systemPrompt.suppressRuntimeContext(), `${name}: suppressRuntimeContext ${base.id}`)
78
+ }
79
+ } catch (error) {
80
+ warnOnce(`${name}: system-section config ${base.id} failed: ${String(error?.message ?? error)}`)
81
+ }
82
+ }
83
+ }
80
84
 
81
85
  /** runtime-context:注册动态运行时上下文(晋升后由 context-gate 差分投影;支持 merged 拼接与 placeholder 函数 provider)。 */
82
86
  function wireRuntimeContexts(ctx, configs, warnOnce) {
package/engine/schema.mjs CHANGED
@@ -70,11 +70,22 @@ export function loadPromptConfigFiles(dirUrl) {
70
70
  } catch (error) {
71
71
  throw new TypeError(`${name}: configsDir ${String(dirUrl)} is not readable: ${String(error?.message ?? error)}`)
72
72
  }
73
+ // 预设级模板变量(writePreset 生成 variables.yml):读入后合并进每条配置
74
+ // variables(配置自身优先);variables.yml 本身不当作配置解析。缺失或损坏
75
+ // 时为空变量源,不阻断加载。
76
+ let presetVariables = {}
77
+ try {
78
+ const parsed = parseYaml(readFileSync(new URL('variables.yml', dirUrl), 'utf8'))
79
+ if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) presetVariables = parsed
80
+ } catch {
81
+ // 无 variables.yml(旧产物/手写目录)或解析失败:保持空变量源。
82
+ }
73
83
  const specs = []
74
84
  const files = entries
75
85
  .filter((entry) => entry.isFile() && /\.(ya?ml|json)$/i.test(entry.name))
76
86
  .sort((a, b) => a.name.localeCompare(b.name))
77
87
  for (const entry of files) {
88
+ if (entry.name === 'variables.yml') continue
78
89
  const url = new URL(entry.name, dirUrl)
79
90
  const raw = readFileSync(url, 'utf8')
80
91
  if (/\.json$/i.test(entry.name)) {
@@ -83,6 +94,13 @@ export function loadPromptConfigFiles(dirUrl) {
83
94
  specs.push(parsePromptConfigYaml(raw))
84
95
  }
85
96
  }
97
+ for (const spec of specs) {
98
+ if (spec === null || typeof spec !== 'object' || Array.isArray(spec)) continue
99
+ const own = spec.variables !== null && typeof spec.variables === 'object' && !Array.isArray(spec.variables)
100
+ ? spec.variables
101
+ : {}
102
+ spec.variables = { ...presetVariables, ...own }
103
+ }
86
104
  return specs
87
105
  }
88
106
 
@@ -0,0 +1,52 @@
1
+ /**
2
+ * session-vars — 会话变量(ST setvar/getvar 运行时语义)。
3
+ *
4
+ * 变量挂在 session 对象上(SESSION_VARS_KEY 属性),因此 .engine 引擎实例与
5
+ * 插件进程工具实例(不同模块副本)操作同一 session 即共享同一份数据——
6
+ * 无跨实例状态同步问题。WeakMap 不需要:session 释放后属性随对象回收。
7
+ *
8
+ * 插值优先级:resolved 运行时 > 会话变量 > 配置 params > 配置 variables(含预设)。
9
+ */
10
+
11
+ /** session 对象上的变量属性键(字符串常量,跨模块实例一致)。 */
12
+ export const SESSION_VARS_KEY = '__pt_session_vars__'
13
+
14
+ function varsOf(session) {
15
+ if (session === null || typeof session !== 'object') return undefined
16
+ let vars = session[SESSION_VARS_KEY]
17
+ if (vars === undefined) {
18
+ vars = {}
19
+ session[SESSION_VARS_KEY] = vars
20
+ }
21
+ return vars
22
+ }
23
+
24
+ /** 会话变量快照(无会话/未设置 → 空对象)。 */
25
+ export function sessionVarsSnapshot(session) {
26
+ const vars = varsOf(session)
27
+ return vars === undefined ? {} : { ...vars }
28
+ }
29
+
30
+ /** 读取单个会话变量(未设置 → undefined)。 */
31
+ export function getSessionVar(session, key) {
32
+ const vars = varsOf(session)
33
+ return vars === undefined ? undefined : vars[String(key)]
34
+ }
35
+
36
+ /** 设置会话变量(值转字符串;空值仍记录)。 */
37
+ export function setSessionVar(session, key, value) {
38
+ const vars = varsOf(session)
39
+ if (vars === undefined) return
40
+ vars[String(key)] = String(value ?? '')
41
+ }
42
+
43
+ /** 清除会话变量;key 缺省时清空全部。 */
44
+ export function clearSessionVars(session, key) {
45
+ const vars = varsOf(session)
46
+ if (vars === undefined) return
47
+ if (key === undefined || key === '') {
48
+ for (const name of Object.keys(vars)) delete vars[name]
49
+ } else {
50
+ delete vars[String(key)]
51
+ }
52
+ }
package/engine/shared.mjs CHANGED
@@ -116,22 +116,3 @@ export function keepDisposer(ctx, disposer, label) {
116
116
  // 极简测试桩没有 effect:保持注册随进程,测试自行隔离。
117
117
  }
118
118
  }
119
-
120
- /** 模板变量插值:配置 variables 优先,内置 {{DSH_HOME}} / {{WORKSPACE}} / {{CWD}}。 */
121
- export function interpolateVariables(text, variables, session) {
122
- const builtins = {
123
- DSH_HOME: process.env.DSH_HOME ?? (process.env.USERPROFILE ? `${process.env.USERPROFILE}\\.dsh` : ''),
124
- WORKSPACE: process.env.DSH_WORKSPACE ?? session?.header?.cwd ?? process.cwd(),
125
- CWD: session?.header?.cwd ?? process.cwd(),
126
- }
127
- return text.replace(/\{\{([A-Za-z0-9_.\u4e00-\u9fff-]+)\}\}/g, (whole, key) => {
128
- if (Object.prototype.hasOwnProperty.call(variables, key)) return String(variables[key])
129
- return Object.prototype.hasOwnProperty.call(builtins, key) ? builtins[key] : whole
130
- })
131
- }
132
-
133
- /** 仅做配置级静态变量替换(无 session 上下文的层)。 */
134
- export function interpolateStatic(text, variables) {
135
- return text.replace(/\{\{([A-Za-z0-9_.\u4e00-\u9fff-]+)\}\}/g, (whole, key) =>
136
- Object.prototype.hasOwnProperty.call(variables, key) ? String(variables[key]) : whole)
137
- }