Repository navigation
Expand file tree
/
Copy pathindex.js
More file actions
300 lines (265 loc) · 13 KB
/
Copy pathindex.js
File metadata and controls
300 lines (265 loc) · 13 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
/**
* dsh-session-title —— 让模型按使用者自定义规则设置会话标题的 DSH 通用插件。
*
* 设计目标:
* 1. 规则完全可配置(通过 `ctx.settings` 的设置表单,与其它插件配置页一致),
* 不绑定任何具体仓库的命名约定。
* 2. 不 import 任何 Harness 运行期包,只依赖宿主服务:
* - `ctx.tools.register(...)`:注册模型可见工具。
* - `ctx.sessionTitle.rename(session, title)`:显式重命名(钉住标题、停止自动生成)。
* - `ctx.settings`(可选):声明本插件实例的设置页策略。
* 唯一例外是 `@deepseek-ai/schemastery`,它是声明 Config 所必需的(由宿主提供)。
*
* 关于 `parameters` 的形态:这里使用**原始 JSON Schema**,与 DSH 内置的 MCP 客户端
* 桥接做法一致(`ctx.tools.register()` 只校验 `output.schema`,`parameters` 原样
* 投影给模型)。`output.schema` 则必须是受支持的子集,见 `TITLE_OUTPUT_SCHEMA`。
*/
import z from '@deepseek-ai/schemastery'
/** 插件名(Cordis 插件标识)。 */
export const name = 'session-title-tool'
/** 需要的宿主服务:工具注册表与会话标题服务。 */
export const inject = ['tools', 'sessionTitle']
/** 默认标题字节上限,与 base 组合里 session-title 的 `maxTitleBytes` 一致。 */
export const DEFAULT_MAX_TITLE_BYTES = 80
/**
* 是否默认启用。
*
* 默认**关闭**:本插件会往模型的工具目录里增加一个工具,属于对模型上下文的改动,
* 应当由使用者显式开启,而不是装上就生效。开启方式:设置页「会话标题」里打开,
* 或把 `enabled: true` 写进 profile 的 `config:`。
*/
export const DEFAULT_ENABLED = false
/**
* 默认规则:`MMDD | 版本或 tag | 描述`。
*
* 三段、段内不得出现 `|`、各段不得为空。这只是一个**默认值**——使用者可在设置里
* 改成任意正则(例如 `^[A-Z]+-\\d+ .+$`),或把 `patternMode` 设为 `none` 关闭校验。
*/
const DEFAULT_PATTERN = '^(\\d{4}) \\| ([^|]+) \\| ([^|]+)$'
/**
* 标题结尾不允许出现的标点(中英文句末标点)。
* 这是「描述用简洁中文、不加句末标点」这类约定的通用兜底。
*/
const TRAILING_PUNCTUATION = /[。..!!??;;,,、::]$/u
/** 标题里不允许出现的控制字符(换行、制表符等)。 */
const CONTROL_CHARACTERS = /[\u0000-\u001F\u007F]/u
/** 设置表单里的规则配置。 */
export const Config = z.object({
/** 是否启用;关闭后不注册工具(默认关闭,需使用者显式开启)。 */
enabled: z.boolean().default(DEFAULT_ENABLED).volatile(),
/** 标题必须匹配的正则(JS 语法,不带动界符)。 */
pattern: z.string().default(DEFAULT_PATTERN).volatile(),
/** 正则的匹配方式:`none` 表示不做正则校验。 */
patternMode: z.union(['match', 'search', 'none']).default('match').volatile(),
/** 正则不匹配时给模型看的中文提示(可留空使用默认提示)。 */
patternHint: z.string().default('').volatile(),
/** 整标题的 UTF-8 字节上限;不得超过宿主 session-title 的配值。 */
maxTitleBytes: z.number().step(1).min(1).default(DEFAULT_MAX_TITLE_BYTES).volatile(),
/** 是否禁止以句末标点结尾。 */
forbidTrailingPunctuation: z.boolean().default(true).volatile(),
/** 是否禁止换行与制表符等控制字符。 */
forbidControlCharacters: z.boolean().default(true).volatile(),
/** 是否把标题首个 `|` 前后规范化为单空格(让模型不必精确控制空白)。 */
normalizeSeparatorSpacing: z.boolean().default(true).volatile(),
/** 正则匹配时是否忽略大小写。 */
caseInsensitive: z.boolean().default(false).volatile(),
})
/**
* `set_session_title` 的规范输出契约。
*
* 这里返回**标题字符串**而不是 `rename()` 的 snapshot 对象:注册表会用本 schema
* 校验 `execute` 的返回值,返回对象会直接抛 `ToolOutputError`(副作用已写入却报失败)。
*/
const TITLE_OUTPUT_SCHEMA = { type: 'string' }
/** 规则配置的解析结果(已去掉运行时引用)。 */
export function resolveRule(config) {
const raw = config ?? {}
const read = (value, fallback) => (typeof value?.get === 'function' ? value.get() : value) ?? fallback
return {
// 兜底值与 schema 默认值保持一致:配置缺省即「不启用」。
enabled: read(raw.enabled, DEFAULT_ENABLED),
pattern: read(raw.pattern, DEFAULT_PATTERN),
patternMode: read(raw.patternMode, 'match'),
patternHint: read(raw.patternHint, ''),
maxTitleBytes: read(raw.maxTitleBytes, DEFAULT_MAX_TITLE_BYTES),
forbidTrailingPunctuation: read(raw.forbidTrailingPunctuation, true),
forbidControlCharacters: read(raw.forbidControlCharacters, true),
normalizeSeparatorSpacing: read(raw.normalizeSeparatorSpacing, true),
caseInsensitive: read(raw.caseInsensitive, false),
}
}
/**
* 把标题里的 `|` 分隔符周边空白规范化为「单空格」。
* 让模型不必精确控制空白,同时保留 `|` 是否存在的语义。
* @param title - 原始标题。
* @returns 规范化空白后的标题。
*/
function normalizeSeparatorSpacing(title) {
return title
.split('|')
.map(segment => segment.trim())
.join(' | ')
}
/**
* 编译使用者配置的正则。
* @param rule - 已解析的规则。
* @returns 正则与其匹配方式;`none` 或空正则时返回 undefined。
* @throws {Error} 正则非法时抛出,便于在注册时尽早暴露配置错误。
*/
export function compilePattern(rule) {
if (rule.patternMode === 'none') return undefined
const source = rule.pattern.trim()
if (source === '') return undefined
const flags = rule.caseInsensitive ? 'iu' : 'u'
return { regex: new RegExp(source, flags), mode: rule.patternMode }
}
/**
* 校验一个候选标题是否符合当前规则。
* @param input - 模型给出的原始标题。
* @param config - 插件 Config(可含 Volatile 引用),省略则用默认规则。
* @returns 通过时给出规范化标题与字节数,否则给出中文原因。
*/
export function validateSessionTitle(input, config) {
const rule = resolveRule(config)
if (typeof input !== 'string') return { ok: false, reason: '标题必须是字符串' }
// 控制字符必须在任何规范化之前检查:分隔符规范化会 trim 掉 `\t`,若先规范化
// 就等于静默吞掉制表符而不是拒绝它。
if (rule.forbidControlCharacters && CONTROL_CHARACTERS.test(input)) {
return { ok: false, reason: '标题不能包含换行、制表符等控制字符' }
}
const title = rule.normalizeSeparatorSpacing ? normalizeSeparatorSpacing(input) : input.trim()
if (title === '') return { ok: false, reason: '标题不能为空' }
const bytes = Buffer.byteLength(title, 'utf8')
if (bytes > rule.maxTitleBytes) {
return { ok: false, reason: `标题为 ${bytes} 字节,超过上限 ${rule.maxTitleBytes} 字节,请压缩描述部分` }
}
let compiled
try {
compiled = compilePattern(rule)
} catch (error) {
return { ok: false, reason: `标题规则的正则表达式无效:${String(error?.message ?? error)}` }
}
if (compiled !== undefined) {
const matched = compiled.mode === 'search' ? compiled.regex.test(title) : compiled.regex.exec(title) !== null
if (!matched) {
const hint = rule.patternHint.trim() !== '' ? rule.patternHint.trim() : `标题不符合格式 \`${rule.pattern}\``
return { ok: false, reason: hint }
}
}
if (rule.forbidTrailingPunctuation && TRAILING_PUNCTUATION.test(title)) {
return { ok: false, reason: '标题不要以句末标点结尾' }
}
return { ok: true, title, bytes }
}
/**
* 把规范值渲染为模型可见内容。
* @param value - 已生效的标题字符串。
* @returns 单个文本内容块。
*/
function textBlock(value) {
return [{ type: 'text', text: value }]
}
/**
* 按当前规则生成面向模型的工具说明。
*
* 必须把**当前生效的规则**告诉模型,否则模型只能盲猜格式、反复被拒。
* 使用者在设置页改动规则后,注册会刷新,说明随之更新。
* @param rule - 已解析的规则。
* @returns 工具描述。
*/
function describeRule(rule) {
const parts = ['把当前会话的标题设置为使用者配置的命名格式,并钉住该标题(此后不再自动生成标题)。']
if (rule.patternMode !== 'none' && rule.pattern.trim() !== '') {
const how = rule.patternMode === 'search' ? '标题中需包含匹配该模式的内容' : '需整串匹配'
parts.push(`标题必须匹配正则 \`${rule.pattern}\`(${how}${rule.caseInsensitive ? ',忽略大小写' : ''})。`)
}
if (rule.patternHint.trim() !== '') parts.push(`额外要求:${rule.patternHint.trim()}`)
parts.push(`整标题不超过 ${rule.maxTitleBytes} 个 UTF-8 字节。`)
if (rule.forbidTrailingPunctuation) parts.push('不要以句末标点结尾。')
if (rule.forbidControlCharacters) parts.push('不要包含换行或制表符。')
if (rule.normalizeSeparatorSpacing) parts.push('分隔符 `|` 前后的空白会被自动规范化为单空格。')
parts.push('不符合规则时本工具会返回明确原因,请据此改写后重试。校验通过即生效,无需另行确认。使用者已明确指定标题时以使用者指定为准。')
return parts.join('')
}
/**
* 注册 `set_session_title` 工具,并按需接入设置页策略。
* @param ctx - 已注入 `tools` 与 `sessionTitle` 的 Cordis 上下文。
* @param config - 插件配置(Schemastery 解析结果,volatile 字段为引用)。
*/
export function apply(ctx, config) {
const rule = () => resolveRule(config)
// 配置错误(正则非法)在加载期就暴露,而不是等到模型第一次调用。
compilePattern(rule())
// 设置接入:本插件只导出带 `.volatile()` 字段的 Config,**不**调用
// `ctx.settings.configure({ auto: false })`。
//
// 原因:`auto: false` 的语义是「我有自己的设置页,别按 schema 自动生成」——
// DSH 里这样写的插件(llm-deepseek、permission-presets 等)都在配套的客户端包里
// 有专门的页面。本插件没有客户端半边,所以保持默认的 `autoGenerate: true`,
// 让任何按 schema 渲染页面的客户端都能直接渲染出配置表单。
// 表单命名空间取自 profile 条目 id(见 cordis.patch.yml)。
let registered
/**
* 让注册状态与当前配置一致:关闭时注销,开启时(重新)注册。
* 重新注册是同步的,观察者看不到中间的空档;它同时刷新工具说明,
* 使模型总能看到当前生效的规则。
*/
const syncRegistration = () => {
const current = rule()
// 规则非法时保留上一次注册,避免把工具直接弄没;错误已在加载期暴露。
try {
compilePattern(current)
} catch {
return
}
if (registered !== undefined) {
registered()
registered = undefined
}
if (current.enabled) registered = registerTool(ctx, config, current)
}
// 必须自己把注销函数挂到插件 effect 上:`ctx.tools.register()` 内部用的是
// ToolRuntime 自己的 context,插件卸载时不会自动清掉本次注册。
ctx.effect(() => () => {
registered?.()
registered = undefined
}, 'session-title-tool.registration')
syncRegistration()
// `enabled`/规则都是 volatile 字段:宿主提交新值时触发该事件,据此同步注册。
ctx.on?.('loader/volatile-update', syncRegistration)
}
/**
* 注册 `set_session_title` 工具。
* @param ctx - 已注入 `tools` 与 `sessionTitle` 的 Cordis 上下文。
* @param config - 插件配置(volatile 字段为引用,每次调用重新读取)。
* @param rule - 注册时生效的规则,用于生成工具说明。
* @returns 注销函数。
*/
function registerTool(ctx, config, rule) {
return ctx.tools.register({
name: 'set_session_title',
description: describeRule(rule),
parameters: {
type: 'object',
properties: {
title: { type: 'string', description: '完整标题,需符合使用者配置的命名规则' },
},
required: ['title'],
additionalProperties: false,
},
output: {
schema: TITLE_OUTPUT_SCHEMA,
render: (_args, value) => textBlock(`会话标题已设置为:${String(value)}`),
},
async execute(args, exec) {
const checked = validateSessionTitle(args?.title, config)
if (!checked.ok) throw new Error(`标题不合规:${checked.reason}`)
const agent = exec?.agent
if (agent === undefined) throw new Error('当前工具调用没有归属会话,无法设置标题')
// rename() 返回 snapshot 对象;本工具的契约是标题字符串,故取 .title。
const accepted = ctx.sessionTitle.rename(agent.session, checked.title)
const finalTitle = typeof accepted?.title === 'string' ? accepted.title : checked.title
return finalTitle
},
})
}