dsh-plugin-prompt-tool 2.1.0 → 3.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.
- package/README.md +100 -233
- package/engine/actions/append-context.mjs +3 -24
- package/engine/actions/assembly.mjs +70 -94
- package/engine/actions/catalog.mjs +65 -28
- package/engine/actions/content.mjs +8 -152
- package/engine/actions/examples.mjs +8 -4
- package/engine/actions/inbox-prepend.mjs +4 -5
- package/engine/actions/index.mjs +17 -9
- package/engine/actions/inject-text.mjs +24 -4
- package/engine/actions/pre-step-filter.mjs +3 -4
- package/engine/actions/registration.mjs +20 -28
- package/engine/actions/request-params.mjs +21 -1
- package/engine/actions/shared.mjs +22 -13
- package/engine/actions/stream-output.mjs +26 -0
- package/engine/actions/tool-gate.mjs +31 -0
- package/engine/actions/tool-result.mjs +36 -0
- package/engine/actions/tool-visibility.mjs +248 -0
- package/engine/agent-capability.mjs +67 -0
- package/engine/anchor-match.mjs +14 -21
- package/engine/audience.d.mts +2 -0
- package/engine/audience.mjs +102 -0
- package/engine/branch.d.mts +4 -0
- package/engine/branch.mjs +61 -18
- package/engine/compaction-epoch.d.mts +25 -0
- package/engine/compaction-epoch.mjs +27 -4
- package/engine/compositions/source/local/filesystem-editor.yml +3 -3
- package/engine/compositions/source/local/instruction-hint.yml +2 -4
- package/engine/compositions/source/local/module-file-tools.yml +2 -0
- package/engine/compositions/source/local/module-import-tools.yml +4 -0
- package/engine/compositions/source/local/module-manifest-tools.yml +2 -0
- package/engine/compositions/source/local/module-rule-tools.yml +5 -0
- package/engine/compositions/source/local/module-variable-tools.yml +5 -0
- package/engine/compositions/source/local/present-as.yml +26 -0
- package/engine/compositions/source/local/tool-config-engine.yml +2 -3
- package/engine/compositions/source/local/tool-search.yml +11 -0
- package/engine/condition-facts.mjs +84 -0
- package/engine/conditions/anchor.mjs +63 -23
- package/engine/conditions/availability.mjs +1 -1
- package/engine/conditions/composite.mjs +3 -3
- package/engine/conditions/count.mjs +42 -11
- package/engine/conditions/evaluation.mjs +74 -19
- package/engine/conditions/examples.mjs +2 -1
- package/engine/conditions/index.d.mts +15 -0
- package/engine/conditions/index.mjs +23 -9
- package/engine/conditions/operators.mjs +4 -0
- package/engine/conditions/phase.mjs +9 -3
- package/engine/conditions/preset.mjs +1 -1
- package/engine/conditions/session.mjs +8 -2
- package/engine/conditions/subject.mjs +144 -65
- package/engine/conditions/text.mjs +26 -8
- package/engine/conditions/variable.mjs +83 -0
- package/engine/context-activation.d.mts +11 -0
- package/engine/{st-world-book.mjs → context-activation.mjs} +104 -64
- package/engine/delivery.d.mts +2 -0
- package/engine/delivery.mjs +93 -0
- package/engine/discovery.d.mts +38 -0
- package/engine/discovery.mjs +146 -0
- package/engine/env-safety.mjs +14 -0
- package/engine/executor.d.mts +28 -0
- package/engine/executor.mjs +290 -205
- package/engine/fillers.mjs +39 -8
- package/engine/fixed-registration.mjs +12 -0
- package/engine/history.mjs +71 -0
- package/engine/instruction-blocks.d.mts +13 -0
- package/engine/instruction-blocks.mjs +21 -0
- package/engine/instruction-files.d.mts +5 -0
- package/engine/instruction-files.mjs +97 -0
- package/engine/instruction-hint.mjs +69 -103
- package/engine/interpolate.d.mts +7 -0
- package/engine/interpolate.mjs +44 -13
- package/engine/layers.mjs +163 -227
- package/engine/machine-paths.mjs +40 -0
- package/engine/module-file-tools.mjs +8 -0
- package/engine/module-import-tools.mjs +10 -0
- package/engine/module-manifest-tools.mjs +8 -0
- package/engine/module-rule-tools.mjs +10 -0
- package/engine/module-variable-tools.mjs +10 -0
- package/engine/order.d.mts +14 -0
- package/engine/order.mjs +0 -3
- package/engine/path-safety.d.mts +2 -0
- package/engine/path-safety.mjs +58 -0
- package/engine/pre-step-source.d.mts +69 -0
- package/engine/pre-step-source.mjs +103 -0
- package/engine/present-as.mjs +77 -0
- package/engine/promotion-pool.d.mts +9 -0
- package/engine/promotion-pool.mjs +69 -0
- package/engine/record.mjs +2 -0
- package/engine/rule-engine.mjs +3 -2
- package/engine/rule-facts.d.mts +11 -0
- package/engine/rule-facts.mjs +60 -0
- package/engine/rule-runtime.d.mts +46 -0
- package/engine/rule-runtime.mjs +98 -13
- package/engine/rule-spec.d.mts +106 -0
- package/engine/rule-spec.mjs +301 -27
- package/engine/run-code-env.mjs +73 -19
- package/engine/schema.mjs +165 -243
- package/engine/sdk-strip.mjs +2 -2
- package/engine/service-names.d.mts +10 -0
- package/engine/service-names.mjs +10 -0
- package/engine/shared.d.mts +21 -0
- package/engine/shared.mjs +46 -13
- package/engine/skill-search.mjs +125 -53
- package/engine/source-kind.d.mts +9 -0
- package/engine/source-kind.mjs +16 -0
- package/engine/strategy-ids.mjs +5 -0
- package/engine/template-evaluation.d.mts +16 -0
- package/engine/template-evaluation.mjs +178 -0
- package/engine/template-frame.d.mts +16 -0
- package/engine/template-frame.mjs +69 -0
- package/engine/tool-config-engine.mjs +38 -34
- package/engine/tool-definition.d.mts +4 -0
- package/engine/tool-definition.mjs +5 -1
- package/engine/tool-git-bash.mjs +5 -1
- package/engine/tool-scope.mjs +22 -0
- package/engine/{dev-tool-search.mjs → tool-search.mjs} +63 -65
- package/engine/trigger.mjs +15 -270
- package/engine/vendor/yaml/dist/compose/resolve-flow-scalar.js +19 -18
- package/engine/vendor/yaml/dist/nodes/Alias.js +25 -23
- package/engine/vendor/yaml/package.json +1 -1
- package/lib/client.js +8312 -6724
- package/lib/client.js.map +1 -1
- package/lib/index.d.mts +16 -2120
- package/lib/index.mjs +26914 -5816
- package/modules/ponytail/module.yml +83 -203
- package/modules/ptc-mode/module.yml +45 -0
- package/modules/soul/assets/core-hard.md +6 -0
- package/modules/soul/assets/criteria.md +12 -0
- package/modules/soul/assets/echo.md +12 -0
- package/modules/soul/assets/layers.md +11 -0
- package/modules/soul/assets/ledger.md +9 -0
- package/modules/soul/assets/manifest.yml +20 -0
- package/modules/soul/assets/reference/00-layers.md +27 -0
- package/modules/soul/assets/reference/01-criteria.md +32 -0
- package/modules/soul/assets/reference/02-echo.md +158 -0
- package/modules/soul/assets/reference/03-vital.md +183 -0
- package/modules/soul/assets/reference/04-manifest.json +106 -0
- package/modules/soul/assets/reference/05-doctor.md +67 -0
- package/modules/soul/assets/reference/06-reference.md +189 -0
- package/modules/soul/assets/reference/07-bundle.json +431 -0
- package/modules/soul/assets/reference/08-/350/270/251/345/235/221/345/256/236/345/275/225.md +59 -0
- package/modules/soul/assets/reference/09-dev-workflow.md +109 -0
- package/modules/soul/assets/reference/10-echo-anatomy.md +100 -0
- package/modules/soul/assets/reference/11-self.md +80 -0
- package/modules/soul/assets/reference/12-ledger.md +159 -0
- package/modules/soul/assets/reference/README.md +10 -0
- package/modules/soul/assets/templates/agents-template.md +6 -0
- package/modules/soul/assets/vital.md +9 -0
- package/modules/soul/module.yml +311 -0
- package/modules/superpowers/module.yml +122 -0
- package/modules/tool-surface/module.yml +51 -47
- package/package.json +37 -40
- package/skills/dsh-module/SKILL.md +67 -147
- package/skills/dsh-module/reference.md +113 -86
- package/templates/10-pre-step.yml +2 -3
- package/templates/14-first-turn-anchor.yml +10 -14
- package/templates/15-guide-auto.yml +32 -28
- package/templates/16-custom-fallback.yml +33 -21
- package/templates/20-system-section.yml +1 -4
- package/templates/50-llm-stream.yml +4 -10
- package/templates/60-tool-pipeline.yml +12 -10
- package/templates/63-append-context.yml +4 -6
- package/templates/65-turn-stop.yml +2 -2
- package/templates/67-inbox-prepend.yml +2 -2
- package/templates/70-subagent-maintenance.yml +9 -10
- package/templates/75-subagent-end.yml +2 -2
- package/templates/80-tool-surface.yml +41 -57
- package/templates/tools/40-delegate-module-import.yml +22 -0
- package/templates/tools/50-delegate-module-rules.yml +14 -0
- package/templates/tools/60-delegate-module-variables.yml +17 -0
- package/templates/tools/70-delegate-module-rules-edit.yml +31 -0
- package/engine/actions/decision.mjs +0 -54
- package/engine/actions/guard.mjs +0 -80
- package/engine/actions/sdk-strip.mjs +0 -30
- package/engine/character-tools.mjs +0 -8
- package/engine/classify-task.mjs +0 -53
- package/engine/compositions/source/local/character-tools.yml +0 -5
- package/engine/compositions/source/local/dev-tool-search.yml +0 -11
- package/engine/compositions/source/local/session-var-tools.yml +0 -5
- package/engine/compositions/source/local/subagent-tool-policy.yml +0 -21
- package/engine/compositions/source/local/world-book-tools.yml +0 -5
- package/engine/condition.mjs +0 -96
- package/engine/declared-triggers.mjs +0 -93
- package/engine/session-var-tools.mjs +0 -8
- package/engine/session-vars.mjs +0 -54
- package/engine/st-macros.d.mts +0 -22
- package/engine/st-macros.mjs +0 -208
- package/engine/st-render.mjs +0 -109
- package/engine/st-world-book.d.mts +0 -10
- package/engine/subagent-tool-policy-core.mjs +0 -400
- package/engine/subagent-tool-policy.mjs +0 -352
- package/engine/trigger-spec.mjs +0 -173
- package/engine/world-book-tools.mjs +0 -8
- package/lib/migrate-rules.d.mts +0 -1
- package/lib/migrate-rules.mjs +0 -610
- package/lib/module-package-DqJZaE71.mjs +0 -2
- package/lib/module-package-q2gDRu9H.mjs +0 -2869
- package/lib/rule-builder-DMrJten6.mjs +0 -14767
- package/templates/61-deny-tools-presentation.yml +0 -27
- package/templates/62-guard-deny-tools.yml +0 -20
- package/templates/policies/legacy-defaults.yml +0 -34
- package/templates/tools/40-delegate-character.yml +0 -15
- package/templates/tools/50-delegate-worldbook.yml +0 -15
- package/templates/tools/60-delegate-session-var.yml +0 -15
- package/templates/tools/70-delegate-worldbook-upsert.yml +0 -36
package/engine/sdk-strip.mjs
CHANGED
|
@@ -125,7 +125,7 @@ function parseTsMembers(lines, block) {
|
|
|
125
125
|
|
|
126
126
|
/** 行多重集包含:`after` 的每一行都能在 `before` 里找到未用尽的同款(= 只删不增)。 */
|
|
127
127
|
function linesOnlyRemoved(before, after) {
|
|
128
|
-
//
|
|
128
|
+
// shortcut: O(行长) 计数表,够用;段文本是单次 assembly 的投影,不做字典序指纹。
|
|
129
129
|
const pool = new Map()
|
|
130
130
|
for (const line of before) pool.set(line, (pool.get(line) ?? 0) + 1)
|
|
131
131
|
for (const line of after) {
|
|
@@ -256,7 +256,7 @@ function stripPython(lines, names) {
|
|
|
256
256
|
}
|
|
257
257
|
if (removed.size === 0) return undefined
|
|
258
258
|
// 删空协议体(只剩注释)不是合法 Python,而补 pass 属于"新增":保守放弃。
|
|
259
|
-
//
|
|
259
|
+
// shortcut: 这是刻意的保守上限,不是缺口——真要「一个工具都不给」应在名单/guard
|
|
260
260
|
// 层表达(执行边界),文本裁剪只做剩余呈现补救;若将来需要裁剪到空,改法是把
|
|
261
261
|
// pruned 名单交回官方渲染器重生全文,而不是在这里补 pass。
|
|
262
262
|
const body = lines.slice(header + 1, footer)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** `service-names.mjs` 的手写声明:只声明 `src/` 消费的导出。 */
|
|
2
|
+
|
|
3
|
+
/** pre-step 协调服务名:插件 provide、引擎 getService 读取,两侧共用这一个名字。 */
|
|
4
|
+
export const PRE_STEP_COORDINATOR_SERVICE: string
|
|
5
|
+
|
|
6
|
+
/** 发现引擎注册表服务名:宿主 root provide,adapter 行按 agent 注册,动作按 agent 解析。 */
|
|
7
|
+
export const DISCOVERY_SERVICE: string
|
|
8
|
+
|
|
9
|
+
/** 四个模块工具外壳的服务名:`pt-<引擎模块 id>`。前缀是本函数的内部细节。 */
|
|
10
|
+
export function toolServiceName(engineModuleId: string): string
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** pre-step 协调服务名:插件 provide、引擎 getService 读取,两侧共用这一个名字。 */
|
|
2
|
+
export const PRE_STEP_COORDINATOR_SERVICE = 'promptToolPreStep'
|
|
3
|
+
|
|
4
|
+
/** 发现引擎注册表:宿主 root provide,adapter 行按 agent 注册,动作按 agent 解析。 */
|
|
5
|
+
export const DISCOVERY_SERVICE = 'promptToolDiscovery'
|
|
6
|
+
|
|
7
|
+
/** 五个模块工具外壳的服务名:`pt-<引擎模块 id>`。前缀是本函数的内部细节。 */
|
|
8
|
+
export function toolServiceName(engineModuleId) {
|
|
9
|
+
return `pt-${engineModuleId}`
|
|
10
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** `shared.mjs` 的手写声明:只声明 `src/` 消费的导出。 */
|
|
2
|
+
|
|
3
|
+
/** 会话对象的结构化最小形状:引擎只读 `header.delegationDepth` 判主子身份。 */
|
|
4
|
+
export interface SessionLike {
|
|
5
|
+
header?: { delegationDepth?: number } | null
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** 主子身份判据:`delegationDepth > 0` 即子代理。 */
|
|
9
|
+
export function isDelegated(session: SessionLike | null | undefined): boolean
|
|
10
|
+
|
|
11
|
+
/** 视为晋升信号的 durable 会话事件类型,按 `promoteOn` 词表分档。 */
|
|
12
|
+
export const PROMOTE_EVENTS: {
|
|
13
|
+
'tool-call': string[]
|
|
14
|
+
'assistant-message': string[]
|
|
15
|
+
either: string[]
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** 按消息去重的有界告警出口。 */
|
|
19
|
+
export type WarnOnce = (message: string, key?: string) => void
|
|
20
|
+
|
|
21
|
+
export function createWarnOnce(ctx: { logger?: { warn(message: string): void } } | undefined, pluginName: string): WarnOnce
|
package/engine/shared.mjs
CHANGED
|
@@ -63,17 +63,34 @@ export function validateConfig(pluginName, source, allowedKeys) {
|
|
|
63
63
|
return config
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
-
/**
|
|
66
|
+
/** 同一句告警只记一次的 key 上限(进程内、按 mount 一份),防不同故障刷屏。 */
|
|
67
|
+
export const WARN_ONCE_LIMIT = 64
|
|
68
|
+
/** 抑制提示的固定键(提示本身不占新故障的名额)。 */
|
|
69
|
+
const WARN_ONCE_SUPPRESSED = Symbol('warnOnceSuppressed')
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* 按消息去重的有界 logger 守卫:同一条消息只记一次,不同故障各记一次。
|
|
73
|
+
*
|
|
74
|
+
* `key` 缺省取整条消息(内嵌错误文本,因此不同故障不同 key、重复故障同 key)。
|
|
75
|
+
* 只有 logger 调用**成功之后**才登记,失败的告警不占名额;到上限时补一条抑制提示,此后静默。
|
|
76
|
+
* shortcut: 消息全文当键 → 同一故障的变体文本(id/数值不同)各占一个 key;等出现这种刷屏源再抽结构化键。
|
|
77
|
+
*/
|
|
67
78
|
export function createWarnOnce(ctx, pluginName) {
|
|
68
|
-
|
|
69
|
-
return (message) => {
|
|
70
|
-
if (
|
|
71
|
-
|
|
79
|
+
const seen = new Set()
|
|
80
|
+
return (message, key = message) => {
|
|
81
|
+
if (seen.has(key)) return
|
|
82
|
+
const suppressed = seen.size >= WARN_ONCE_LIMIT
|
|
83
|
+
if (suppressed) {
|
|
84
|
+
if (seen.has(WARN_ONCE_SUPPRESSED)) return
|
|
85
|
+
message = `${pluginName}: 后续告警已抑制(已达 ${WARN_ONCE_LIMIT} 条不同告警上限)`
|
|
86
|
+
}
|
|
72
87
|
try {
|
|
73
88
|
ctx.logger.warn(message)
|
|
74
89
|
} catch {
|
|
75
90
|
// Logger unavailable — the guard exists only to avoid spamming.
|
|
91
|
+
return
|
|
76
92
|
}
|
|
93
|
+
seen.add(suppressed ? WARN_ONCE_SUPPRESSED : key)
|
|
77
94
|
}
|
|
78
95
|
}
|
|
79
96
|
|
|
@@ -99,13 +116,9 @@ export function isDelegated(session) {
|
|
|
99
116
|
return (session?.header?.delegationDepth ?? 0) > 0
|
|
100
117
|
}
|
|
101
118
|
|
|
102
|
-
/**
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
*/
|
|
106
|
-
export function sessionEvents(session) {
|
|
107
|
-
const snapshot = session?.snapshotEvents?.()
|
|
108
|
-
return Array.isArray(snapshot) ? snapshot : []
|
|
119
|
+
/** 已校验的静态受众;未声明或 null 同时覆盖主会话与子代理。 */
|
|
120
|
+
export function matchesAudience(declared, target) {
|
|
121
|
+
return declared == null || declared === target
|
|
109
122
|
}
|
|
110
123
|
|
|
111
124
|
/** True when a model id looks like a Flash-family model. */
|
|
@@ -120,6 +133,26 @@ export function matchesModel(scope, model) {
|
|
|
120
133
|
return scope === 'flash' ? isFlash : !isFlash
|
|
121
134
|
}
|
|
122
135
|
|
|
136
|
+
/**
|
|
137
|
+
* 零依赖的最小 JSON schema 编译器:把工具参数的字段字面量编译成宿主
|
|
138
|
+
* `ctx.tools.register` 需要的形状。`tool-search` 与 `skill-search` 共用这一份。
|
|
139
|
+
*
|
|
140
|
+
* `meta.items` 是数组元素的项描述——保留这一支:删掉它等于让带 items 的参数丢掉项描述
|
|
141
|
+
* (`tool-search` 的 `toolNames` 正是这种参数)。
|
|
142
|
+
*/
|
|
143
|
+
export function toJsonSchema(spec) {
|
|
144
|
+
const properties = {}
|
|
145
|
+
const required = []
|
|
146
|
+
for (const [key, meta] of Object.entries(spec || {})) {
|
|
147
|
+
const prop = { type: meta.type }
|
|
148
|
+
if (meta.description) prop.description = meta.description
|
|
149
|
+
if (meta.items) prop.items = meta.items
|
|
150
|
+
properties[key] = prop
|
|
151
|
+
if (meta.required) required.push(key)
|
|
152
|
+
}
|
|
153
|
+
return { type: 'object', properties, required, additionalProperties: false }
|
|
154
|
+
}
|
|
155
|
+
|
|
123
156
|
/** 逗号分隔的 token 列表;空 = 空数组(调用方自行决定"全部"语义)。 */
|
|
124
157
|
export function parseToolNames(value) {
|
|
125
158
|
if (typeof value !== 'string' || value.trim() === '') return []
|
|
@@ -187,7 +220,7 @@ export const MAX_TRACKED_SESSIONS = 4096
|
|
|
187
220
|
export function sessionState(ctx, create, { weak = false, limit = MAX_TRACKED_SESSIONS, evict = 'clear', reset } = {}) {
|
|
188
221
|
const store = weak ? new WeakMap() : new Map()
|
|
189
222
|
const keyOf = (session) => (weak ? session : session?.id)
|
|
190
|
-
//
|
|
223
|
+
// shortcut: 无上限档(weak / limit: null / evict: null)与有上限档共用一条读写路径,
|
|
191
224
|
// 只在上限判定处分支;未声明上限时不创建任何额外计数器。
|
|
192
225
|
const finite = !weak && typeof limit === 'number' && limit > 0 && evict !== null
|
|
193
226
|
/** 超限处理:`clear` 与 `oldest` 是**两档不同语义**,绝不互相降级。 */
|
package/engine/skill-search.mjs
CHANGED
|
@@ -21,10 +21,24 @@
|
|
|
21
21
|
* dsh-tool-skill. If skills are unavailable the tools answer with a short
|
|
22
22
|
* message instead of throwing.
|
|
23
23
|
*
|
|
24
|
+
* 与 `tool-search.mjs` 同形状的三条修正(2026-10-16,技能面照搬工具面):
|
|
25
|
+
* 1. **打分而非 AND 过滤**:完整命中(命中全部 token)优先,其次近似命中;
|
|
26
|
+
* 长自然语言查询不再全灭。精确名命中最前。中文与英文同口径——中文整句
|
|
27
|
+
* 是一个 token,`includes` 仍是子串匹配。
|
|
28
|
+
* 2. **空查询列出全部(截到上限)**:`skill_load` 已有精确名时直接加载,空查询是
|
|
29
|
+
* 「先看看有什么」的入口——完整列表连同截断标注比分组摘要更好用。
|
|
30
|
+
* 3. **截断与空结果都明说**:显示条数 / 命中总数分列,并写明「空结果只说明
|
|
31
|
+
* 关键词没命中」——模型据此收窄查询或直接按精确名 `skill_load`,而不是
|
|
32
|
+
* 转去翻文件系统猜名字。
|
|
33
|
+
*
|
|
24
34
|
* NOTE: this plugin REPLACES the `dsh-tool-skill` row in the composition —
|
|
25
35
|
* the composition must NOT mount both, or the catalog injection returns.
|
|
26
36
|
*/
|
|
27
37
|
import { importHostPackage } from './host-package.mjs'
|
|
38
|
+
import { toJsonSchema } from './shared.mjs'
|
|
39
|
+
import { currentEvents } from './history.mjs'
|
|
40
|
+
import { queryTokens, renderRows, rankRows, scoreRows } from './discovery.mjs'
|
|
41
|
+
import { DISCOVERY_SERVICE } from './service-names.mjs'
|
|
28
42
|
|
|
29
43
|
const { isModelInvocable, renderSkillContent } = await importHostPackage('@deepseek-ai/dsh-skill')
|
|
30
44
|
|
|
@@ -34,78 +48,108 @@ export const name = 'skill-search'
|
|
|
34
48
|
/** The agent, tools, and skills services must exist before these tools can register. */
|
|
35
49
|
export const inject = ['agents', 'tools', 'skills']
|
|
36
50
|
|
|
51
|
+
/** 参数 schema 由 `shared.mjs` 的统一实现生成(与 `tool-search` 同一份)。 */
|
|
52
|
+
|
|
53
|
+
/** 把查询切成小写 token(Unicode 字母数字与下划线连字符,与 tool-search 同口径)。 */
|
|
54
|
+
export { queryTokens }
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 对目录打分排序(纯函数,可单测):精确名命中排最前,其次按命中 token 数降序,
|
|
58
|
+
* 同分按名字字典序。`wanted` 为空时返回空数组——调用方据此区分「没给查询」与「没搜到」。
|
|
59
|
+
*
|
|
60
|
+
* 与工具面同一口径(any-token 打分,不做 AND 过滤):长自然语言查询在 AND 下命中零个,
|
|
61
|
+
* 等于白搜;噪声由分数排序与 MAX_RESULTS 截断控制(2026-10-22 用户指出工具面无此问题)。
|
|
62
|
+
*/
|
|
63
|
+
export function scoreSkills(skills, wanted) {
|
|
64
|
+
return rankRows(scoreRows(skills, wanted, {
|
|
65
|
+
nameOf: (skill) => skill?.name,
|
|
66
|
+
haystackOf: (skill) => `${skill?.name ?? ''} ${skill?.description ?? ''} ${skill?.whenToUse ?? ''}`,
|
|
67
|
+
})).map((row) => ({ ...row, skill: row.item }))
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** 搜索结果上限:再多就该让模型收窄关键词。 */
|
|
37
71
|
const MAX_RESULTS = 20
|
|
38
72
|
|
|
39
|
-
/**
|
|
40
|
-
function
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
73
|
+
/** 把目录搜索结果渲染成给模型的文本(带截断提示与加载引导)。 */
|
|
74
|
+
export function renderMatches(scored, query, complete = true) {
|
|
75
|
+
return renderRows(scored, {
|
|
76
|
+
query,
|
|
77
|
+
limit: MAX_RESULTS,
|
|
78
|
+
noun: 'skills',
|
|
79
|
+
emptyText: complete
|
|
80
|
+
? `No skills match "${query}" — no skill matched your keywords; skill_load still loads any exact name in the registry, and calling skill_search with no arguments lists every visible skill.`
|
|
81
|
+
: `Skill discovery did not complete, so nothing can be concluded about "${query}". Retry skill_search before treating a skill as unavailable.`,
|
|
82
|
+
lineOf: ({ skill, name, exact }) => `- ${name}${exact ? ' (exact)' : ''}: ${String(skill?.description ?? '').split('\n')[0]}`,
|
|
83
|
+
warnings: complete ? [] : ['Skill discovery is incomplete; these results may be partial.'],
|
|
84
|
+
footer: 'Load one with skill_load({"name": "<exact name>"}).',
|
|
85
|
+
})
|
|
50
86
|
}
|
|
51
87
|
|
|
52
|
-
/**
|
|
88
|
+
/**
|
|
89
|
+
* 该技能是否已在本会话的**当前可见上下文**里注入过——去重按 `currentEvents()` 口径:
|
|
90
|
+
* 压缩/位置替换遮蔽掉的注入不算「已加载」,下次 `skill_load` 仍会重新注入正文。
|
|
91
|
+
* 官方 `/名字` 手势与 `skill_load` 写的是同一形状的 `skill-invocation` 事件,因此一并覆盖。
|
|
92
|
+
*/
|
|
93
|
+
export function skillLoadedInContext(session, name) {
|
|
94
|
+
return currentEvents(session).some(event => event?.type === 'user/message'
|
|
95
|
+
&& event.data?.source?.kind === 'skill-invocation'
|
|
96
|
+
&& event.data.source.name === name)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** 注册两个按需技能工具。 */
|
|
53
100
|
/**
|
|
54
101
|
* 本模块**刻意保持单参形态**:它不读任何 config 键,因此没有白名单可声明。
|
|
55
102
|
* B2 T4 要求「四个无白名单插件行补字段声明与校验」,本行按 PLAN 的例外处理——
|
|
56
|
-
* 若将来需要 config
|
|
103
|
+
* 若将来需要 config,先确认组合行与调用方确实在传 config,再补 defineConfig 声明。
|
|
57
104
|
*/
|
|
58
|
-
export function apply(ctx) {
|
|
59
|
-
|
|
60
|
-
const tokens = (text) => (text || '').toLowerCase().split(/[^\p{L}\p{N}_-]+/u).filter(Boolean)
|
|
61
|
-
|
|
62
|
-
ctx.tools.register({
|
|
105
|
+
export function apply(ctx, _config, options = {}) {
|
|
106
|
+
const disposeSearch = ctx.tools.register({
|
|
63
107
|
name: 'skill_search',
|
|
64
|
-
description:
|
|
108
|
+
description: [
|
|
109
|
+
'Search the available skills by keyword and return matching skill names with short descriptions.',
|
|
110
|
+
'',
|
|
111
|
+
'skill_load loads any exact name in the registry, so a skill the search did not surface still loads by its name.',
|
|
112
|
+
'',
|
|
113
|
+
'Usage:',
|
|
114
|
+
'- `query` — search skill names and descriptions (Chinese keywords work).',
|
|
115
|
+
'- call with NO arguments — list every visible skill (truncation is marked with the matching total).',
|
|
116
|
+
].join('\n'),
|
|
65
117
|
parameters: toJsonSchema({
|
|
66
|
-
query: { type: 'string', required:
|
|
118
|
+
query: { type: 'string', required: false, description: 'search keywords (e.g. "pdf", "obsidian", "代码审查")' },
|
|
67
119
|
}),
|
|
68
120
|
output: {
|
|
69
121
|
schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string' } }, required: ['text'] },
|
|
70
122
|
render: (_a, v) => [{ type: 'text', text: v.text }],
|
|
71
123
|
},
|
|
72
124
|
async execute(args, exec) {
|
|
73
|
-
const
|
|
74
|
-
const scope = exec?.agent ?? ctx
|
|
125
|
+
const query = typeof args?.query === 'string' ? args.query.trim() : ''
|
|
75
126
|
try {
|
|
127
|
+
// 执行中的 agent 就是观察作用域(同 tool-search):preset 层注册的技能不带
|
|
128
|
+
// scope 时看不到;cwd 决定项目级技能根,两个入口共用同一组查询参数。
|
|
76
129
|
const snapshot = await ctx.skills.snapshot({
|
|
77
|
-
scope,
|
|
130
|
+
scope: exec?.agent ?? ctx,
|
|
78
131
|
cwd: exec?.agent?.session?.header?.cwd,
|
|
79
132
|
signal: exec?.signal,
|
|
80
133
|
})
|
|
81
134
|
exec?.signal?.throwIfAborted()
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
const desc = (skill.description || '').split('\n')[0]
|
|
90
|
-
return `- ${skill.name}: ${desc}`
|
|
91
|
-
})
|
|
92
|
-
if (lines.length === 0) return { text: snapshot.complete
|
|
93
|
-
? `No skills match "${args.query}". Use skill_search with other keywords.`
|
|
94
|
-
: 'Skill discovery is incomplete. Retry skill_search before concluding no skills match.' }
|
|
95
|
-
const extra = matches.length > MAX_RESULTS ? `\n…(${matches.length - MAX_RESULTS} more)` : ''
|
|
96
|
-
const incomplete = snapshot.complete ? '' : '\nSkill discovery is incomplete; these results may be partial.'
|
|
97
|
-
return { text: `Matching skills (${matches.length}):\n${lines.join('\n')}${extra}${incomplete}\n\nLoad one with skill_load (exact name).` }
|
|
135
|
+
const visible = snapshot.skills.filter(isModelInvocable)
|
|
136
|
+
// 空查询列出可见技能(`scoreSkills` 按契约对空 token 返回 `[]`,不能代表「列出全部」);
|
|
137
|
+
// 不完整读只作标注、不作否决,避免把「没读全」变成「搜不到」。
|
|
138
|
+
const scored = query.length === 0
|
|
139
|
+
? visible.map((skill) => ({ skill, name: skill.name, score: 0, exact: false }))
|
|
140
|
+
: scoreSkills(visible, queryTokens(query))
|
|
141
|
+
return { text: renderMatches(scored, query, snapshot.complete).join('\n') }
|
|
98
142
|
} catch (error) {
|
|
99
143
|
return { text: `skill_search unavailable: ${String((error && error.message) || error)}` }
|
|
100
144
|
}
|
|
101
145
|
},
|
|
102
146
|
})
|
|
103
147
|
|
|
104
|
-
ctx.tools.register({
|
|
148
|
+
const disposeLoad = ctx.tools.register({
|
|
105
149
|
name: 'skill_load',
|
|
106
|
-
description: 'Load the full instructions of ONE skill by its exact name
|
|
150
|
+
description: 'Load the full instructions of ONE skill by its exact registered name and inject them for the next request. Call this before acting on a task that matches the skill. The name may come from skill_search, from the skill list, or from the user — any exact name in the registry works, and when nothing matches, ask the user for it.',
|
|
107
151
|
parameters: toJsonSchema({
|
|
108
|
-
name: { type: 'string', required: true, description: 'exact skill name (kebab-case,
|
|
152
|
+
name: { type: 'string', required: true, description: 'exact skill name (kebab-case, as registered)' },
|
|
109
153
|
}),
|
|
110
154
|
output: {
|
|
111
155
|
schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string' } }, required: ['text'] },
|
|
@@ -115,6 +159,13 @@ export function apply(ctx) {
|
|
|
115
159
|
try {
|
|
116
160
|
const agent = exec?.agent
|
|
117
161
|
if (agent === undefined) return { text: 'skill_load requires an agent context.' }
|
|
162
|
+
// 名字归一:非字符串与去空白后为空都算「没给名字」,与 tool_search 的
|
|
163
|
+
// unlockNames 同口径。否则空串会落进「未注册」文案,调用方分不清是输入
|
|
164
|
+
// 非法还是名字拼错——这两件事该做的事不同。
|
|
165
|
+
const name = typeof args?.name === 'string' ? args.name.trim() : ''
|
|
166
|
+
if (name.length === 0) {
|
|
167
|
+
return { text: 'skill_load needs an exact skill name — call it with one, or run skill_search with no arguments to list every visible skill.' }
|
|
168
|
+
}
|
|
118
169
|
const options = {
|
|
119
170
|
scope: agent,
|
|
120
171
|
cwd: agent.session.header.cwd,
|
|
@@ -122,34 +173,55 @@ export function apply(ctx) {
|
|
|
122
173
|
}
|
|
123
174
|
const snapshot = await ctx.skills.snapshot(options)
|
|
124
175
|
options.signal?.throwIfAborted()
|
|
125
|
-
const summary = snapshot.skills.find((entry) => entry.name ===
|
|
176
|
+
const summary = snapshot.skills.find((entry) => entry.name === name)
|
|
126
177
|
if (summary === undefined) {
|
|
178
|
+
// 名字错、发现没跑完、调用面缺 agent——三种成因的应对不同。
|
|
127
179
|
return { text: snapshot.complete
|
|
128
|
-
? `No skill named "${
|
|
129
|
-
: 'Skill discovery is incomplete. Retry
|
|
180
|
+
? `No skill named "${name}" is registered for this session. Check the exact name against what skill_search lists, or ask the user for it.`
|
|
181
|
+
: 'Skill discovery is incomplete, so the name could not be confirmed. Retry skill_load before concluding the skill does not exist.' }
|
|
130
182
|
}
|
|
131
|
-
if (!isModelInvocable(summary)) return { text: `Skill "${
|
|
132
|
-
const skill = await ctx.skills.get(
|
|
183
|
+
if (!isModelInvocable(summary)) return { text: `Skill "${name}" is not available for model invocation.` }
|
|
184
|
+
const skill = await ctx.skills.get(name, options)
|
|
133
185
|
options.signal?.throwIfAborted()
|
|
134
186
|
if (skill === undefined) {
|
|
135
|
-
return { text: `Skill "${
|
|
187
|
+
return { text: `Skill "${name}" is no longer available — it was registered but its body could not be loaded. Retry once, then run skill_search to see what is still registered.` }
|
|
188
|
+
}
|
|
189
|
+
// 去重:正文已在当前可见上下文里就不再注入(少一份重复 token)。
|
|
190
|
+
// 只有「已加载且仍可见」才跳过——压缩遮蔽掉的注入不算已加载,见 skillLoadedInContext。
|
|
191
|
+
// shortcut: 同一批次内连调两次 `skill_load` 时,第一次的注入尚未落入会话,去重看不到,
|
|
192
|
+
// 会重复注入一份;跨批次重复才是常态,等真出现同批重复再按 step 消息判重。
|
|
193
|
+
if (skillLoadedInContext(agent.session, name)) {
|
|
194
|
+
return { text: `Skill "${name}" is already loaded in this context — its instructions are above (or earlier in this session), so nothing was injected again. Follow them without another load; if the user explicitly asks to reload it, let them re-send it as a /${name} gesture.` }
|
|
136
195
|
}
|
|
137
|
-
if (!isModelInvocable(skill)) return { text: `Skill "${
|
|
196
|
+
if (!isModelInvocable(skill)) return { text: `Skill "${name}" is not available for model invocation.` }
|
|
138
197
|
if (skill.content.trim().length === 0) {
|
|
139
|
-
return { text: `Skill "${
|
|
198
|
+
return { text: `Skill "${name}" has no loadable body.` }
|
|
140
199
|
}
|
|
141
200
|
// Queue the skill content as a non-waking next-step context message,
|
|
142
201
|
// exactly like dsh-tool-skill's invocation injection.
|
|
143
202
|
agent.inject({
|
|
144
|
-
id: `skill-load-${
|
|
203
|
+
id: `skill-load-${name}-${Date.now()}`,
|
|
145
204
|
role: 'user',
|
|
146
205
|
content: [{ type: 'text', text: renderSkillContent(skill) }],
|
|
147
|
-
source: { kind: 'skill-invocation', name
|
|
206
|
+
source: { kind: 'skill-invocation', name, form: 'instructions' },
|
|
148
207
|
})
|
|
149
|
-
return { text: `Skill "${
|
|
208
|
+
return { text: `Skill "${name}" loaded; its instructions will be injected for the next request.` }
|
|
150
209
|
} catch (error) {
|
|
151
210
|
return { text: `skill_load failed: ${String((error && error.message) || error)}` }
|
|
152
211
|
}
|
|
153
212
|
},
|
|
154
213
|
})
|
|
214
|
+
const registry = typeof ctx.get === 'function' ? ctx.get(DISCOVERY_SERVICE) : undefined
|
|
215
|
+
const disposeAdapter = registry === undefined ? undefined : registry.register(options.agent, 'skills', discoveryAdapter)
|
|
216
|
+
return () => {
|
|
217
|
+
if (typeof disposeAdapter === 'function') disposeAdapter()
|
|
218
|
+
if (typeof disposeSearch === 'function') disposeSearch()
|
|
219
|
+
if (typeof disposeLoad === 'function') disposeLoad()
|
|
220
|
+
}
|
|
155
221
|
}
|
|
222
|
+
|
|
223
|
+
/** 本引擎向发现注册表提供的元数据:技能面没有「解锁名单」,只登记工具身份。 */
|
|
224
|
+
export const discoveryAdapter = Object.freeze({
|
|
225
|
+
kind: 'skills',
|
|
226
|
+
tool: 'skill_search',
|
|
227
|
+
})
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `source-kind.mjs` 的手写声明:只声明 `src/` 消费的导出,形状按实现写实。
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/** 拼接 v4 生产者身份。 */
|
|
6
|
+
export declare const pluginKind: (owner: string) => string
|
|
7
|
+
|
|
8
|
+
/** 从 v4 `plugin:<owner>` 解析 owner;裸 `plugin` 与其他形态返回 undefined。 */
|
|
9
|
+
export declare const pluginOwner: (kind: unknown) => string | undefined
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v4 消息来源的生产者身份:`plugin:<owner>`。
|
|
3
|
+
* 前缀拼接、owner 解析与「是否插件来源」只在这里解释;旧日志的裸 `plugin` 只在
|
|
4
|
+
* `isPluginKind` 兼容,不进入 `pluginOwner`。
|
|
5
|
+
*/
|
|
6
|
+
const PLUGIN_KIND_PREFIX = 'plugin:'
|
|
7
|
+
|
|
8
|
+
export const pluginKind = owner => `${PLUGIN_KIND_PREFIX}${owner}`
|
|
9
|
+
|
|
10
|
+
export const pluginOwner = kind => typeof kind === 'string' && kind.startsWith(PLUGIN_KIND_PREFIX)
|
|
11
|
+
? kind.slice(PLUGIN_KIND_PREFIX.length)
|
|
12
|
+
: undefined
|
|
13
|
+
|
|
14
|
+
/** 本插件的 kind:v4 `plugin:<owner>`,或旧日志的裸 `plugin`(不是合法 v4 kind)。 */
|
|
15
|
+
export const isPluginKind = kind => kind === 'plugin'
|
|
16
|
+
|| (typeof kind === 'string' && kind.startsWith(PLUGIN_KIND_PREFIX))
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export interface TemplateSnapshot {
|
|
2
|
+
module?: Readonly<Record<string, string>>
|
|
3
|
+
global?: Readonly<Record<string, string>>
|
|
4
|
+
session?: unknown
|
|
5
|
+
sourceId?: string
|
|
6
|
+
readOnly?: boolean
|
|
7
|
+
samples?: Map<string, string>
|
|
8
|
+
}
|
|
9
|
+
export interface TemplateResult {
|
|
10
|
+
text: string
|
|
11
|
+
reads: Array<'global' | 'module'>
|
|
12
|
+
changes?: { scope: 'global' | 'module'; values: Record<string, string> }
|
|
13
|
+
}
|
|
14
|
+
export function evaluateTemplate(text: string, options?: TemplateSnapshot): TemplateResult
|
|
15
|
+
export function transformTemplate(text: string, macro: (body: string, expand: (value: string) => string, occurrence: number) => string): string
|
|
16
|
+
export function splitTemplateArgument(text: string): [string, string | undefined]
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/** 有界模板解析与原生求值;只返回正文和变更,不持有或写入持久状态。 */
|
|
2
|
+
import { Buffer } from 'node:buffer'
|
|
3
|
+
import { interpolateVariables, isReservedInterpolationName, normalizeMacroSyntax } from './interpolate.mjs'
|
|
4
|
+
|
|
5
|
+
const MAX_BYTES = 1024 * 1024
|
|
6
|
+
const MAX_DEPTH = 32
|
|
7
|
+
const FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype'])
|
|
8
|
+
|
|
9
|
+
function byteLength(text) {
|
|
10
|
+
const size = Buffer.byteLength(text, 'utf8')
|
|
11
|
+
if (size > MAX_BYTES) throw new RangeError('Template text exceeds 1 MiB')
|
|
12
|
+
return size
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** JSON 单花括号及字符串内的右括号不构成模板边界。 */
|
|
16
|
+
function macroEnd(text, start, depth = 0) {
|
|
17
|
+
const frames = [{ braces: 0, brackets: 0, quote: false, escape: false }]
|
|
18
|
+
if (depth >= MAX_DEPTH) throw new RangeError('Template depth exceeds 32')
|
|
19
|
+
for (let index = start + 2; index < text.length; index++) {
|
|
20
|
+
const frame = frames.at(-1), char = text[index]
|
|
21
|
+
if (frame.escape) { frame.escape = false; continue }
|
|
22
|
+
if (frame.quote && char === '\\') { frame.escape = true; continue }
|
|
23
|
+
if (char === '"' && (frame.quote || frame.braces > 0 || frame.brackets > 0)) { frame.quote = !frame.quote; continue }
|
|
24
|
+
if (text.startsWith('{{', index)) {
|
|
25
|
+
if (depth + frames.length >= MAX_DEPTH) throw new RangeError('Template depth exceeds 32')
|
|
26
|
+
frames.push({ braces: 0, brackets: 0, quote: false, escape: false }); index++; continue
|
|
27
|
+
}
|
|
28
|
+
if (frame.quote) continue
|
|
29
|
+
if (char === '{') frame.braces++
|
|
30
|
+
else if (char === '}' && frame.braces > 0) frame.braces--
|
|
31
|
+
else if (text.startsWith('}}', index)) {
|
|
32
|
+
frames.pop()
|
|
33
|
+
if (!frames.length) return index + 2
|
|
34
|
+
index++
|
|
35
|
+
} else if (char === '[') frame.brackets++
|
|
36
|
+
else if (char === ']') frame.brackets = Math.max(0, frame.brackets - 1)
|
|
37
|
+
}
|
|
38
|
+
return -1
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** ST 编辑残片不是可执行表达式;只保留顶层尾部未知裸名,不放宽操作或嵌套语法。 */
|
|
42
|
+
function isLiteralFragment(text) {
|
|
43
|
+
const match = /^\{\{\s*([A-Za-z0-9_.\u4e00-\u9fff-]+)\s*\}?$/.exec(text.trimEnd())
|
|
44
|
+
if (!match) return false
|
|
45
|
+
const key = match[1]
|
|
46
|
+
return !FORBIDDEN_KEYS.has(key) && !isReservedInterpolationName(key)
|
|
47
|
+
&& !/^(?:(?:module|global)(?:\.|$)|(?:set|add|get|inc|dec)(?:global)?var$|char$|charifnotgroup$|user$|trim$|ERA$)/i.test(key)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** 转换端和求值端共用边界与预算;出现身份不含正文字符偏移。 */
|
|
51
|
+
export function transformTemplate(text, macro) {
|
|
52
|
+
let work = 0, occurrence = 0
|
|
53
|
+
function walk(source, depth = 0) {
|
|
54
|
+
work += byteLength(source)
|
|
55
|
+
if (work > MAX_BYTES * MAX_DEPTH) throw new RangeError('Template expansion work limit exceeded')
|
|
56
|
+
const chunks = []
|
|
57
|
+
let size = 0, cursor = 0
|
|
58
|
+
const append = value => {
|
|
59
|
+
size += byteLength(value)
|
|
60
|
+
if (size > MAX_BYTES) throw new RangeError('Template output exceeds 1 MiB')
|
|
61
|
+
chunks.push(value)
|
|
62
|
+
}
|
|
63
|
+
while (cursor < source.length) {
|
|
64
|
+
const start = source.indexOf('{{', cursor)
|
|
65
|
+
if (start < 0) { append(source.slice(cursor)); break }
|
|
66
|
+
append(source.slice(cursor, start))
|
|
67
|
+
const end = macroEnd(source, start, depth)
|
|
68
|
+
if (end < 0) {
|
|
69
|
+
const rest = source.slice(start)
|
|
70
|
+
if (depth === 0 && isLiteralFragment(rest)) { append(rest); break }
|
|
71
|
+
throw new TypeError('Unclosed template expression')
|
|
72
|
+
}
|
|
73
|
+
append(macro(source.slice(start + 2, end - 2), value => walk(value, depth + 1), occurrence++))
|
|
74
|
+
cursor = end
|
|
75
|
+
}
|
|
76
|
+
return chunks.join('')
|
|
77
|
+
}
|
|
78
|
+
return walk(text)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** 当前层第一个分隔符;嵌套表达式里的分隔符属于子表达式。 */
|
|
82
|
+
export function splitTemplateArgument(text) {
|
|
83
|
+
for (let index = 0; index < text.length; index++) {
|
|
84
|
+
if (text.startsWith('{{', index)) {
|
|
85
|
+
const end = macroEnd(text, index)
|
|
86
|
+
if (end < 0) throw new TypeError('Unclosed template expression')
|
|
87
|
+
index = end - 1
|
|
88
|
+
} else if (text.startsWith('::', index)) return [text.slice(0, index), text.slice(index + 2)]
|
|
89
|
+
}
|
|
90
|
+
return [text, undefined]
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function addValue(value, increment) {
|
|
94
|
+
const current = value || '0'
|
|
95
|
+
let parsed
|
|
96
|
+
try { parsed = JSON.parse(current) } catch { /* 非 JSON 继续按数值或文本处理。 */ }
|
|
97
|
+
if (Array.isArray(parsed)) { parsed.push(increment); return JSON.stringify(parsed) }
|
|
98
|
+
if (Number.isNaN(Number(increment)) || Number.isNaN(Number(current))) return (value || '') + increment
|
|
99
|
+
const result = Number(current) + Number(increment)
|
|
100
|
+
if (!Number.isFinite(result)) throw new RangeError('Template variable number is not finite')
|
|
101
|
+
return String(result)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function evaluateTemplate(text, { module = {}, global = {}, session, sourceId = '', readOnly = false, samples = new Map() } = {}) {
|
|
105
|
+
const tables = { module: { ...module }, global: { ...global } }
|
|
106
|
+
const reads = new Set(), resolving = new Set(), values = {}
|
|
107
|
+
let scope, pick = 0
|
|
108
|
+
const keyOf = key => {
|
|
109
|
+
if (!key || FORBIDDEN_KEYS.has(key) || isReservedInterpolationName(key)) throw new TypeError(`Template variable key blocked: ${key}`)
|
|
110
|
+
return key
|
|
111
|
+
}
|
|
112
|
+
const raw = (domain, key) => {
|
|
113
|
+
reads.add(domain)
|
|
114
|
+
if (Object.hasOwn(tables[domain], key)) return { domain, value: tables[domain][key] }
|
|
115
|
+
if (domain === 'module') {
|
|
116
|
+
reads.add('global')
|
|
117
|
+
if (Object.hasOwn(tables.global, key)) return { domain: 'global', value: tables.global[key] }
|
|
118
|
+
}
|
|
119
|
+
return undefined
|
|
120
|
+
}
|
|
121
|
+
const read = (found, key, expand) => {
|
|
122
|
+
if (!found) return ''
|
|
123
|
+
const id = `${found.domain}:${key}`
|
|
124
|
+
if (resolving.has(id)) throw new RangeError(`Template variable cycle: ${id}`)
|
|
125
|
+
resolving.add(id)
|
|
126
|
+
try { return expand(String(found.value)) } finally { resolving.delete(id) }
|
|
127
|
+
}
|
|
128
|
+
const write = (domain, key, value) => {
|
|
129
|
+
if (readOnly) throw new TypeError('Read-only template cannot write variables')
|
|
130
|
+
if (scope !== undefined && scope !== domain) throw new TypeError('One template may write only one variable scope')
|
|
131
|
+
byteLength(value)
|
|
132
|
+
scope = domain
|
|
133
|
+
tables[domain][key] = value
|
|
134
|
+
values[key] = value
|
|
135
|
+
}
|
|
136
|
+
const output = transformTemplate(text, (body, expand, occurrence) => {
|
|
137
|
+
const [name, args] = splitTemplateArgument(body)
|
|
138
|
+
const operation = /^(module|global)\.(set|get|add|inc|dec)$/.exec(name.trim())
|
|
139
|
+
if (operation) {
|
|
140
|
+
if (args === undefined) throw new TypeError(`Missing arguments for ${name}`)
|
|
141
|
+
const [rawKey, rawValue] = splitTemplateArgument(args)
|
|
142
|
+
const key = keyOf(expand(rawKey).trim()), domain = operation[1], action = operation[2]
|
|
143
|
+
if (readOnly && (action !== 'get' || rawValue !== undefined)) throw new TypeError('Read-only template cannot write variables')
|
|
144
|
+
if (action === 'get') {
|
|
145
|
+
const found = raw(domain, key)
|
|
146
|
+
if (found) return read(found, key, expand)
|
|
147
|
+
const fallback = rawValue === undefined ? '' : expand(rawValue)
|
|
148
|
+
if (fallback !== '') write(domain, key, fallback)
|
|
149
|
+
return fallback
|
|
150
|
+
}
|
|
151
|
+
if ((action === 'set' || action === 'add') && rawValue !== undefined) {
|
|
152
|
+
const value = expand(rawValue)
|
|
153
|
+
write(domain, key, action === 'set' ? value : addValue(read(raw(domain, key), key, expand), value))
|
|
154
|
+
return ''
|
|
155
|
+
}
|
|
156
|
+
if ((action === 'inc' || action === 'dec') && rawValue === undefined) {
|
|
157
|
+
const value = addValue(read(raw(domain, key), key, expand), action === 'inc' ? '1' : '-1')
|
|
158
|
+
write(domain, key, value)
|
|
159
|
+
return value
|
|
160
|
+
}
|
|
161
|
+
throw new TypeError(`Invalid arguments for ${name}`)
|
|
162
|
+
}
|
|
163
|
+
if (/^(set|add|get|inc|dec)(global)?var$/i.test(name.trim())) throw new TypeError('ST variable operations are retired; reimport or explicitly convert to module/global operations')
|
|
164
|
+
const whole = normalizeMacroSyntax(`{{${expand(body)}}}`)
|
|
165
|
+
const [key] = splitTemplateArgument(whole.slice(2, -2))
|
|
166
|
+
if (FORBIDDEN_KEYS.has(key)) throw new TypeError(`Template variable key blocked: ${key}`)
|
|
167
|
+
if (args === undefined && !isReservedInterpolationName(key)) {
|
|
168
|
+
const found = raw('module', key)
|
|
169
|
+
if (found) return read(found, key, expand)
|
|
170
|
+
}
|
|
171
|
+
if (whole.slice(2, -2).includes('{{')) return whole
|
|
172
|
+
const identity = key.toLowerCase() === 'pick' ? `pick:${pick++}` : String(occurrence)
|
|
173
|
+
const sample = `${identity}:${whole}`
|
|
174
|
+
if (!samples.has(sample)) samples.set(sample, interpolateVariables(whole, {}, session, undefined, `${sourceId}:${identity}`))
|
|
175
|
+
return samples.get(sample)
|
|
176
|
+
})
|
|
177
|
+
return { text: output, reads: [...reads], ...(scope === undefined ? {} : { changes: { scope, values } }) }
|
|
178
|
+
}
|