cs-bots 0.0.0-stage → 0.1.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.
- package/LICENSE +21 -0
- package/README.md +164 -2
- package/cordis.patch.agent-team.yml +31 -0
- package/cordis.patch.yml +30 -0
- package/install.sh +218 -0
- package/package.json +57 -4
- package/src/api.js +706 -0
- package/src/board.js +1056 -0
- package/src/botprofile.js +331 -0
- package/src/client.js +2089 -0
- package/src/compat.js +196 -0
- package/src/dirbrowse.js +555 -0
- package/src/dispatcher.js +651 -0
- package/src/index.js +311 -0
- package/src/kernel.js +480 -0
- package/src/library.js +740 -0
- package/src/memory.js +1905 -0
- package/src/registry.js +34 -0
- package/src/room.js +1550 -0
- package/src/roster.js +1119 -0
- package/src/settings.js +107 -0
- package/src/store.js +243 -0
- package/src/tools.js +469 -0
package/src/library.js
ADDED
|
@@ -0,0 +1,740 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cs-bots /library —— 技能库(SkillLibrary)+ 工作流库(WorkflowLibrary)。
|
|
3
|
+
*
|
|
4
|
+
* ── 这是什么 ──────────────────────────────────────────────────────────────
|
|
5
|
+
* 两个**全局可复用**的条目库:技能(会落成 Bot 目录下的
|
|
6
|
+
* `.agents/skills/<name>/SKILL.md`,落盘由调用方做)与工作流模板(纯数据)。
|
|
7
|
+
* 本文件只管「存条目 + 保证条目没被写坏」,**不执行任何东西**:
|
|
8
|
+
* 工作流的 `steps` 永远只是数组,库不是编排器 —— 这是纪律,不是偷懒。
|
|
9
|
+
*
|
|
10
|
+
* ── 为什么这样实现(踩过的坑)─────────────────────────────────────────────
|
|
11
|
+
* ① **不复用 store.js 的 `readJson` 来做库文件读取**:
|
|
12
|
+
* 它的契约是「坏文件返回 fallback」,对配置文件是对的,对**数据库**是灾难 ——
|
|
13
|
+
* 坏一次就把整库静默重置成空,用户以为条目丢了是「自己记错了」。
|
|
14
|
+
* ⇒ 本文件自己读:缺文件=空库,坏 JSON=抛 `LIB_STORE_CORRUPT`(带路径)。
|
|
15
|
+
* 其余能力仍复用 store.js(`writeJsonAtomic` / `ensureDir` / `statePath`)。
|
|
16
|
+
* ② **先落盘、再换内存**(照 board.js 的 commit 顺序):
|
|
17
|
+
* 写失败时内存必须原封不动,否则内存与磁盘分叉 → 重启后数据凭空回退,
|
|
18
|
+
* 属于最恶劣的静默失败。
|
|
19
|
+
* ③ **name 必须能当目录名**:技能要落成 `<cwd>/.agents/skills/<name>/`,
|
|
20
|
+
* 放行 `../evil` 等于把写入路径交给调用方拼 `..` ⇒ 这里用白名单正则硬拦。
|
|
21
|
+
* ④ **越界一律抛错,不截断**:截断后假装成功,用户会以为写进去了。
|
|
22
|
+
*
|
|
23
|
+
* 本文件不 import 任何 `@deepseek-ai/*` 或 npm 包(零依赖 CI 检查 1 覆盖)。
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
27
|
+
import { resolve } from 'node:path'
|
|
28
|
+
|
|
29
|
+
import { ensureDir, statePath, writeJsonAtomic } from './store.js'
|
|
30
|
+
|
|
31
|
+
/** 技能库文件名。 */
|
|
32
|
+
export const SKILLS_FILE = 'library-skills.json'
|
|
33
|
+
|
|
34
|
+
/** 工作流库文件名。 */
|
|
35
|
+
export const WORKFLOWS_FILE = 'library-workflows.json'
|
|
36
|
+
|
|
37
|
+
/** 落盘格式版本(结构不兼容时改它,让读取端显式吵起来)。 */
|
|
38
|
+
export const LIBRARY_VERSION = 1
|
|
39
|
+
|
|
40
|
+
/** 各字段上限。 */
|
|
41
|
+
export const LIMITS = Object.freeze({
|
|
42
|
+
/** 技能名/工作流名长度上限(含首字符)。 */
|
|
43
|
+
name: 64,
|
|
44
|
+
/** 技能正文上限:与 botprofile 的 skillBody 对齐,避免库能存、落盘被拒。 */
|
|
45
|
+
skillBody: 20000,
|
|
46
|
+
/** 描述上限。 */
|
|
47
|
+
description: 2000,
|
|
48
|
+
/** 标签条数 / 单条标签长度上限。 */
|
|
49
|
+
tags: 20,
|
|
50
|
+
tagLength: 64,
|
|
51
|
+
/** 工作流步数上限。 */
|
|
52
|
+
steps: 50,
|
|
53
|
+
stepTitle: 200,
|
|
54
|
+
stepDetail: 4000,
|
|
55
|
+
stepOwnerHint: 128,
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* 技能名 / 工作流名白名单:字母数字开头,其后允许 `. _ -`。
|
|
60
|
+
* 冒号、斜杠、空格、`..` 开头一律拒绝 —— 因为这个名字会变成磁盘上的目录名。
|
|
61
|
+
*/
|
|
62
|
+
const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/
|
|
63
|
+
|
|
64
|
+
/** 库错误(照 botprofile.js 的 `ProfileError` 写法:message + code + detail)。 */
|
|
65
|
+
export class LibraryError extends Error {
|
|
66
|
+
/**
|
|
67
|
+
* @param {string} message 人能看懂的原因
|
|
68
|
+
* @param {string} code 稳定错误码(HTTP 层按它映射状态码,别去解析 message)
|
|
69
|
+
* @param {object} [detail] 结构化上下文
|
|
70
|
+
*/
|
|
71
|
+
constructor(message, code, detail) {
|
|
72
|
+
super(message)
|
|
73
|
+
this.name = 'LibraryError'
|
|
74
|
+
this.code = code
|
|
75
|
+
this.detail = detail
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* 深拷贝 + JSON 归一(去掉 `undefined` 键)。
|
|
81
|
+
* 必须走 JSON 而不能用 structuredClone:否则内存态可能比磁盘态多出
|
|
82
|
+
* `{detail: undefined}` 这类键,导致重启回放后与内存值不等。
|
|
83
|
+
*/
|
|
84
|
+
function clone(value) {
|
|
85
|
+
if (value === undefined || value === null) return value
|
|
86
|
+
return JSON.parse(JSON.stringify(value))
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** 非空字符串必填校验。 */
|
|
90
|
+
function requireString(value, field, where) {
|
|
91
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
92
|
+
throw new LibraryError(`${where}:${field} 必填(非空字符串)`, 'LIB_BAD_FIELD', { field, value })
|
|
93
|
+
}
|
|
94
|
+
return value.trim()
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** 操作者必填 —— 库没有审计流,但「谁改的」不许缺失,否则 HTTP 层无从追责。 */
|
|
98
|
+
function requireBy(by, where) {
|
|
99
|
+
return requireString(by, 'by', where)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* `now` 允许外部注入(测试要可控时间);不传用当前时间。
|
|
104
|
+
* 传了就必须是合法时间戳 —— 静默忽略非法 `now` 会让调用方以为时间被采信了。
|
|
105
|
+
*/
|
|
106
|
+
function normalizeNow(now, where) {
|
|
107
|
+
if (now === undefined || now === null) return Date.now()
|
|
108
|
+
if (typeof now !== 'number' || !Number.isFinite(now) || now <= 0) {
|
|
109
|
+
throw new LibraryError(`${where}:now 必须是正的时间戳(ms)`, 'LIB_BAD_FIELD', { field: 'now', now })
|
|
110
|
+
}
|
|
111
|
+
return Math.floor(now)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** 名字校验(白名单正则,见 `NAME_RE` 的注释)。 */
|
|
115
|
+
function requireName(value, where, code) {
|
|
116
|
+
const raw = typeof value === 'string' ? value.trim() : value
|
|
117
|
+
if (typeof raw !== 'string' || !NAME_RE.test(raw)) {
|
|
118
|
+
throw new LibraryError(
|
|
119
|
+
`${where}:name 不合法 ${JSON.stringify(value)}(只允许字母数字开头,其后为字母数字 . _ -,长度 1-${LIMITS.name})`,
|
|
120
|
+
code,
|
|
121
|
+
{ name: value },
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
return raw
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* 显示名校验(用于**不会变成文件路径**的字段,例如工作流名)。
|
|
129
|
+
*
|
|
130
|
+
* 为什么和 requireName 分开:技能名要落成目录名 `.agents/skills/<name>/`,
|
|
131
|
+
* 所以必须限 ASCII 防路径穿越;但工作流名只是给人看的标题,
|
|
132
|
+
* 限 ASCII 会让中文用户没法起名(实测被拒过)。
|
|
133
|
+
* 这里只禁控制字符与路径分隔符,其余放行。
|
|
134
|
+
*
|
|
135
|
+
* @param {unknown} value
|
|
136
|
+
* @param {string} where
|
|
137
|
+
* @param {string} code
|
|
138
|
+
* @returns {string}
|
|
139
|
+
*/
|
|
140
|
+
function requireDisplayName(value, where, code) {
|
|
141
|
+
const raw = typeof value === 'string' ? value.trim() : value
|
|
142
|
+
const bad = typeof raw !== 'string' || raw === '' || raw.length > LIMITS.name
|
|
143
|
+
|| /[\u0000-\u001f\/\\]/.test(raw)
|
|
144
|
+
if (bad) {
|
|
145
|
+
throw new LibraryError(
|
|
146
|
+
`${where}:name 不合法 ${JSON.stringify(value)}(不能为空、不能含 / \\ 或控制字符,长度 1-${LIMITS.name})`,
|
|
147
|
+
code,
|
|
148
|
+
{ name: value },
|
|
149
|
+
)
|
|
150
|
+
}
|
|
151
|
+
return raw
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** 名字归一化:比较唯一性时大小写不敏感(`Refactor` 与 `refactor` 视为同一个)。 */
|
|
155
|
+
function nameKey(name) {
|
|
156
|
+
return String(name).trim().toLowerCase()
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** 可选文本字段(undefined=不改/不填;null=显式清空)。 */
|
|
160
|
+
function normalizeText(value, field, max, where) {
|
|
161
|
+
if (value === undefined) return undefined
|
|
162
|
+
if (value === null) return ''
|
|
163
|
+
if (typeof value !== 'string') {
|
|
164
|
+
throw new LibraryError(`${where}:${field} 必须是字符串`, 'LIB_BAD_FIELD', { field })
|
|
165
|
+
}
|
|
166
|
+
if (value.length > max) {
|
|
167
|
+
throw new LibraryError(`${where}:${field} 超长 ${value.length} > ${max} 字符`, 'LIB_TOO_LONG', { field, length: value.length, max })
|
|
168
|
+
}
|
|
169
|
+
return value
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** 标签数组:去空、按大小写不敏感去重,保留首次写法。 */
|
|
173
|
+
function normalizeTags(value, where) {
|
|
174
|
+
if (value === undefined) return undefined
|
|
175
|
+
if (value === null) return []
|
|
176
|
+
if (!Array.isArray(value)) {
|
|
177
|
+
throw new LibraryError(`${where}:tags 必须是字符串数组`, 'LIB_BAD_FIELD', { field: 'tags' })
|
|
178
|
+
}
|
|
179
|
+
if (value.length > LIMITS.tags) {
|
|
180
|
+
throw new LibraryError(`${where}:tags 条数过多 ${value.length} > ${LIMITS.tags}`, 'LIB_TOO_MANY', { field: 'tags', count: value.length, max: LIMITS.tags })
|
|
181
|
+
}
|
|
182
|
+
/** @type {string[]} */
|
|
183
|
+
const out = []
|
|
184
|
+
const seen = new Set()
|
|
185
|
+
for (const raw of value) {
|
|
186
|
+
if (typeof raw !== 'string') {
|
|
187
|
+
throw new LibraryError(`${where}:tags 里每项必须是字符串`, 'LIB_BAD_FIELD', { field: 'tags', value: raw })
|
|
188
|
+
}
|
|
189
|
+
const tag = raw.trim()
|
|
190
|
+
// 空标签本身没有内容,丢弃不算「静默丢数据」
|
|
191
|
+
if (!tag) continue
|
|
192
|
+
if (tag.length > LIMITS.tagLength) {
|
|
193
|
+
throw new LibraryError(`${where}:tag 超长 ${tag.length} > ${LIMITS.tagLength} 字符`, 'LIB_TOO_LONG', { field: 'tags', tag })
|
|
194
|
+
}
|
|
195
|
+
if (seen.has(tag.toLowerCase())) continue
|
|
196
|
+
seen.add(tag.toLowerCase())
|
|
197
|
+
out.push(tag)
|
|
198
|
+
}
|
|
199
|
+
return out
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** 技能正文:必填、非空、不超上限(超长抛错,不截断)。 */
|
|
203
|
+
function requireBody(value, where) {
|
|
204
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
205
|
+
throw new LibraryError(`${where}:body 必填(非空字符串)`, 'LIB_BAD_FIELD', { field: 'body' })
|
|
206
|
+
}
|
|
207
|
+
if (value.length > LIMITS.skillBody) {
|
|
208
|
+
throw new LibraryError(
|
|
209
|
+
`${where}:body 超长 ${value.length} > ${LIMITS.skillBody} 字符`,
|
|
210
|
+
'LIB_TOO_LONG',
|
|
211
|
+
{ field: 'body', length: value.length, max: LIMITS.skillBody },
|
|
212
|
+
)
|
|
213
|
+
}
|
|
214
|
+
return value
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* 工作流步骤校验:非空数组、≤50 步、每步 title 必填。
|
|
219
|
+
* 只取认识的字段(title / detail / ownerHint / done),其余忽略 —— 与
|
|
220
|
+
* `botprofile.normalizeProfile` 的口径一致:多给的键属于调用方的自由,不是数据。
|
|
221
|
+
*/
|
|
222
|
+
function normalizeSteps(value, where) {
|
|
223
|
+
if (!Array.isArray(value)) {
|
|
224
|
+
throw new LibraryError(`${where}:steps 必须是数组`, 'LIB_BAD_FIELD', { field: 'steps' })
|
|
225
|
+
}
|
|
226
|
+
if (value.length === 0) {
|
|
227
|
+
throw new LibraryError(`${where}:steps 不能为空(空工作流没有意义)`, 'LIB_STEPS_EMPTY', { field: 'steps' })
|
|
228
|
+
}
|
|
229
|
+
if (value.length > LIMITS.steps) {
|
|
230
|
+
throw new LibraryError(
|
|
231
|
+
`${where}:steps 步数过多 ${value.length} > ${LIMITS.steps}`,
|
|
232
|
+
'LIB_TOO_MANY',
|
|
233
|
+
{ field: 'steps', count: value.length, max: LIMITS.steps },
|
|
234
|
+
)
|
|
235
|
+
}
|
|
236
|
+
return value.map((raw, index) => {
|
|
237
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
238
|
+
throw new LibraryError(`${where}:steps[${index}] 必须是对象`, 'LIB_BAD_FIELD', { field: `steps[${index}]` })
|
|
239
|
+
}
|
|
240
|
+
const title = requireString(raw.title, `steps[${index}].title`, where)
|
|
241
|
+
if (title.length > LIMITS.stepTitle) {
|
|
242
|
+
throw new LibraryError(
|
|
243
|
+
`${where}:steps[${index}].title 超长 ${title.length} > ${LIMITS.stepTitle} 字符`,
|
|
244
|
+
'LIB_TOO_LONG',
|
|
245
|
+
{ field: `steps[${index}].title`, length: title.length, max: LIMITS.stepTitle },
|
|
246
|
+
)
|
|
247
|
+
}
|
|
248
|
+
const detail = normalizeText(raw.detail, `steps[${index}].detail`, LIMITS.stepDetail, where)
|
|
249
|
+
const ownerHint = normalizeText(raw.ownerHint, `steps[${index}].ownerHint`, LIMITS.stepOwnerHint, where)
|
|
250
|
+
if (raw.done !== undefined && raw.done !== null && typeof raw.done !== 'boolean') {
|
|
251
|
+
throw new LibraryError(`${where}:steps[${index}].done 必须是布尔值`, 'LIB_BAD_FIELD', { field: `steps[${index}].done` })
|
|
252
|
+
}
|
|
253
|
+
/** @type {{ title: string, detail?: string, ownerHint?: string, done: boolean }} */
|
|
254
|
+
const step = { title }
|
|
255
|
+
if (detail !== undefined) step.detail = detail
|
|
256
|
+
if (ownerHint !== undefined) step.ownerHint = ownerHint
|
|
257
|
+
// done 显式落成布尔值:模板被 UI 当清单展示时不该出现 undefined 分支
|
|
258
|
+
step.done = raw.done === true
|
|
259
|
+
return step
|
|
260
|
+
})
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* 读一个库文件。**缺文件=空库;坏 JSON=抛错**(绝不静默重置)。
|
|
265
|
+
* @param {string} file
|
|
266
|
+
* @returns {object[]}
|
|
267
|
+
*/
|
|
268
|
+
function readStore(file) {
|
|
269
|
+
if (!existsSync(file)) return []
|
|
270
|
+
let text
|
|
271
|
+
try {
|
|
272
|
+
text = readFileSync(file, 'utf8')
|
|
273
|
+
} catch (error) {
|
|
274
|
+
throw new LibraryError(
|
|
275
|
+
`读取库文件失败:${file}(${error?.message ?? error})`,
|
|
276
|
+
'LIB_STORE_READ_FAILED',
|
|
277
|
+
{ file, cause: String(error?.message ?? error) },
|
|
278
|
+
)
|
|
279
|
+
}
|
|
280
|
+
const trimmed = text.trim()
|
|
281
|
+
// 空文件 = 没有任何数据,按空库处理并不丢东西(writeJsonAtomic 也不会留下空文件)
|
|
282
|
+
if (!trimmed) return []
|
|
283
|
+
|
|
284
|
+
let parsed
|
|
285
|
+
try {
|
|
286
|
+
parsed = JSON.parse(trimmed)
|
|
287
|
+
} catch (error) {
|
|
288
|
+
throw new LibraryError(
|
|
289
|
+
`库文件损坏(JSON 非法):${file}。拒绝静默重置 —— 请人工确认后再修或删。`,
|
|
290
|
+
'LIB_STORE_CORRUPT',
|
|
291
|
+
{ file, cause: String(error?.message ?? error) },
|
|
292
|
+
)
|
|
293
|
+
}
|
|
294
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
295
|
+
throw new LibraryError(`库文件结构非法(应为 { version, items }):${file}`, 'LIB_STORE_CORRUPT', { file })
|
|
296
|
+
}
|
|
297
|
+
if (parsed.version !== LIBRARY_VERSION) {
|
|
298
|
+
throw new LibraryError(
|
|
299
|
+
`库文件版本不支持:${file} 是 version=${JSON.stringify(parsed.version)},本程序只认 ${LIBRARY_VERSION}`,
|
|
300
|
+
'LIB_STORE_VERSION',
|
|
301
|
+
{ file, version: parsed.version },
|
|
302
|
+
)
|
|
303
|
+
}
|
|
304
|
+
if (!Array.isArray(parsed.items)) {
|
|
305
|
+
throw new LibraryError(`库文件结构非法(items 不是数组):${file}`, 'LIB_STORE_CORRUPT', { file })
|
|
306
|
+
}
|
|
307
|
+
for (const [index, item] of parsed.items.entries()) {
|
|
308
|
+
if (!item || typeof item !== 'object' || typeof item.id !== 'string' || !item.id) {
|
|
309
|
+
throw new LibraryError(
|
|
310
|
+
`库文件结构非法:${file} 的 items[${index}] 缺少字符串 id`,
|
|
311
|
+
'LIB_STORE_CORRUPT',
|
|
312
|
+
{ file, index },
|
|
313
|
+
)
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
return parsed.items
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** 排序键:创建时间 → 名字(大小写不敏感)→ id,保证落盘与列表顺序都确定。 */
|
|
320
|
+
function ordered(map) {
|
|
321
|
+
return [...map.values()].sort((a, b) => {
|
|
322
|
+
const at = Number(a.createdAt) || 0
|
|
323
|
+
const bt = Number(b.createdAt) || 0
|
|
324
|
+
if (at !== bt) return at - bt
|
|
325
|
+
const an = nameKey(a.name ?? '')
|
|
326
|
+
const bn = nameKey(b.name ?? '')
|
|
327
|
+
if (an !== bn) return an < bn ? -1 : 1
|
|
328
|
+
return String(a.id) < String(b.id) ? -1 : String(a.id) > String(b.id) ? 1 : 0
|
|
329
|
+
})
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* 创建技能库/工作流库实例。
|
|
334
|
+
*
|
|
335
|
+
* @param {{ logger?: { info?: Function, warn?: Function } }} [ctx] 宿主上下文(仅用于日志,缺失即静默降级)
|
|
336
|
+
* @param {{ stateDir: string }} config stateDir 必填:本模块不猜状态目录(裸检出无宿主时也没得猜)
|
|
337
|
+
* @returns {object} 库实例(方法见 README/HTTP 层契约)
|
|
338
|
+
*/
|
|
339
|
+
export function createLibrary(ctx, config = {}) {
|
|
340
|
+
const rawStateDir = config?.stateDir
|
|
341
|
+
if (typeof rawStateDir !== 'string' || rawStateDir.trim() === '') {
|
|
342
|
+
throw new LibraryError(
|
|
343
|
+
'createLibrary:config.stateDir 必填(形如 { stateDir: "/path/to/state" })',
|
|
344
|
+
'LIB_BAD_STATE_DIR',
|
|
345
|
+
{ stateDir: rawStateDir },
|
|
346
|
+
)
|
|
347
|
+
}
|
|
348
|
+
const stateDir = resolve(rawStateDir.trim())
|
|
349
|
+
if (!ensureDir(stateDir)) {
|
|
350
|
+
throw new LibraryError(`状态目录不可创建或不可写:${stateDir}`, 'LIB_BAD_STATE_DIR', { stateDir })
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const skillsFile = statePath(stateDir, SKILLS_FILE)
|
|
354
|
+
const workflowsFile = statePath(stateDir, WORKFLOWS_FILE)
|
|
355
|
+
const paths = Object.freeze({ stateDir, skillsFile, workflowsFile })
|
|
356
|
+
|
|
357
|
+
const info = (...args) => { try { ctx?.logger?.info?.(...args) } catch { /* 日志失败不影响业务 */ } }
|
|
358
|
+
const warn = (...args) => { try { ctx?.logger?.warn?.(...args) } catch { /* 同上 */ } }
|
|
359
|
+
|
|
360
|
+
/** @type {Map<string, object>} 技能 id → 技能条目(创建序不可靠,输出时统一排序) */
|
|
361
|
+
let skills = new Map()
|
|
362
|
+
/** @type {Map<string, object>} 工作流 id → 工作流条目 */
|
|
363
|
+
let workflows = new Map()
|
|
364
|
+
let disposed = false
|
|
365
|
+
|
|
366
|
+
// ── 生命周期与持久化 ──────────────────────────────────────────────────────
|
|
367
|
+
|
|
368
|
+
function assertAlive(where) {
|
|
369
|
+
if (disposed) {
|
|
370
|
+
throw new LibraryError(`${where}:库实例已 dispose,不能继续使用`, 'LIB_DISPOSED', { where })
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** 写入一个库文件;失败**抛错**(writeJsonAtomic 只回 false,不解释原因)。 */
|
|
375
|
+
function persist(file, items, kind) {
|
|
376
|
+
if (!writeJsonAtomic(file, { version: LIBRARY_VERSION, items })) {
|
|
377
|
+
throw new LibraryError(`写入${kind}失败:${file}`, 'LIB_STORE_WRITE_FAILED', { file })
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** 技能库走一条提交路径:先落盘,成功后再换内存。 */
|
|
382
|
+
function commitSkills(next) {
|
|
383
|
+
persist(skillsFile, ordered(next), '技能库')
|
|
384
|
+
skills = next
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** 工作流库同上。 */
|
|
388
|
+
function commitWorkflows(next) {
|
|
389
|
+
persist(workflowsFile, ordered(next), '工作流库')
|
|
390
|
+
workflows = next
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** 从磁盘重建内存态(构造与 `replay()` 都用它)。坏文件在这里抛,不在读取时偷偷吞掉。 */
|
|
394
|
+
function hydrate() {
|
|
395
|
+
const nextSkills = new Map()
|
|
396
|
+
for (const item of readStore(skillsFile)) {
|
|
397
|
+
if (nextSkills.has(item.id)) {
|
|
398
|
+
throw new LibraryError(`技能库文件里有重复 id:${item.id}(${skillsFile})`, 'LIB_STORE_CORRUPT', { file: skillsFile, id: item.id })
|
|
399
|
+
}
|
|
400
|
+
nextSkills.set(item.id, item)
|
|
401
|
+
}
|
|
402
|
+
const nextWorkflows = new Map()
|
|
403
|
+
for (const item of readStore(workflowsFile)) {
|
|
404
|
+
if (nextWorkflows.has(item.id)) {
|
|
405
|
+
throw new LibraryError(`工作流库文件里有重复 id:${item.id}(${workflowsFile})`, 'LIB_STORE_CORRUPT', { file: workflowsFile, id: item.id })
|
|
406
|
+
}
|
|
407
|
+
nextWorkflows.set(item.id, item)
|
|
408
|
+
}
|
|
409
|
+
skills = nextSkills
|
|
410
|
+
workflows = nextWorkflows
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// ── 内部小工具 ────────────────────────────────────────────────────────────
|
|
414
|
+
|
|
415
|
+
/** 生成 id:时间戳 + 随机后缀,跨重启不需要持久化计数器也不会撞。 */
|
|
416
|
+
function nextId(prefix, map) {
|
|
417
|
+
for (let attempt = 0; attempt < 5; attempt += 1) {
|
|
418
|
+
const candidate = `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
|
|
419
|
+
if (!map.has(candidate)) return candidate
|
|
420
|
+
}
|
|
421
|
+
throw new LibraryError(`生成 id 失败:${prefix} 连续 5 次碰撞`, 'LIB_ID_CONFLICT', { prefix })
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/** expectRev 必填且为正整数 —— CAS 少了它就等于无条件覆盖。 */
|
|
425
|
+
function requireRev(expectedRev, where) {
|
|
426
|
+
if (typeof expectedRev !== 'number' || !Number.isInteger(expectedRev) || expectedRev < 1) {
|
|
427
|
+
throw new LibraryError(`${where}:expectedRev 必填(正整数,用于乐观并发校验)`, 'LIB_BAD_FIELD', { field: 'expectedRev', expectedRev })
|
|
428
|
+
}
|
|
429
|
+
return expectedRev
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function findById(map, id, notFoundCode, where) {
|
|
433
|
+
const item = typeof id === 'string' ? map.get(id) : undefined
|
|
434
|
+
if (!item) {
|
|
435
|
+
throw new LibraryError(`${where}:找不到 id=${JSON.stringify(id)}`, notFoundCode, { id })
|
|
436
|
+
}
|
|
437
|
+
return item
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/** 名字唯一性(大小写不敏感),selfId 用于 update 时排除自己。 */
|
|
441
|
+
function assertFreeName(map, name, selfId, code, where) {
|
|
442
|
+
const key = nameKey(name)
|
|
443
|
+
for (const item of map.values()) {
|
|
444
|
+
if (item.id === selfId) continue
|
|
445
|
+
if (nameKey(item.name) === key) {
|
|
446
|
+
throw new LibraryError(
|
|
447
|
+
`${where}:名字已被占用 ${JSON.stringify(name)}(大小写不敏感比较)`,
|
|
448
|
+
code,
|
|
449
|
+
{ name, existingId: item.id },
|
|
450
|
+
)
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/** 按 tag 过滤(大小写不敏感),tags 为空 = 不筛。 */
|
|
456
|
+
function filterByTag(items, tag) {
|
|
457
|
+
if (tag === undefined || tag === null) return items
|
|
458
|
+
if (typeof tag !== 'string') {
|
|
459
|
+
throw new LibraryError('listX:tag 必须是字符串', 'LIB_BAD_FIELD', { field: 'tag', tag })
|
|
460
|
+
}
|
|
461
|
+
const key = tag.trim().toLowerCase()
|
|
462
|
+
if (!key) return items
|
|
463
|
+
return items.filter((item) => (item.tags ?? []).some((t) => String(t).toLowerCase() === key))
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/** 当前技能条目快照(排序后,深拷贝)。 */
|
|
467
|
+
function skillsList() {
|
|
468
|
+
return ordered(skills).map((item) => clone(item))
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/** 当前工作流条目快照(排序后,深拷贝)。 */
|
|
472
|
+
function workflowsList() {
|
|
473
|
+
return ordered(workflows).map((item) => clone(item))
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// ── 技能库 ────────────────────────────────────────────────────────────────
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* 新建技能条目。
|
|
480
|
+
* @param {{ name: string, description?: string, body: string, tags?: string[], by: string, now?: number }} input
|
|
481
|
+
* @returns {object}
|
|
482
|
+
*/
|
|
483
|
+
function createSkill(input = {}) {
|
|
484
|
+
assertAlive('createSkill')
|
|
485
|
+
requireBy(input.by, 'createSkill')
|
|
486
|
+
const at = normalizeNow(input.now, 'createSkill')
|
|
487
|
+
const name = requireName(input.name, 'createSkill', 'LIB_BAD_NAME')
|
|
488
|
+
assertFreeName(skills, name, null, 'LIB_SKILL_NAME_TAKEN', 'createSkill')
|
|
489
|
+
const description = normalizeText(input.description, 'description', LIMITS.description, 'createSkill') ?? ''
|
|
490
|
+
const body = requireBody(input.body, 'createSkill')
|
|
491
|
+
const tags = normalizeTags(input.tags, 'createSkill') ?? []
|
|
492
|
+
|
|
493
|
+
const item = {
|
|
494
|
+
id: nextId('sk', skills),
|
|
495
|
+
name,
|
|
496
|
+
description,
|
|
497
|
+
body,
|
|
498
|
+
tags,
|
|
499
|
+
createdAt: at,
|
|
500
|
+
updatedAt: at,
|
|
501
|
+
rev: 1,
|
|
502
|
+
}
|
|
503
|
+
const next = new Map(skills)
|
|
504
|
+
next.set(item.id, item)
|
|
505
|
+
commitSkills(next)
|
|
506
|
+
info('[cs-bots] library.createSkill', { id: item.id, name })
|
|
507
|
+
return clone(item)
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* 按 `expectedRev` 做 CAS 更新。**rev 不匹配即抛错,绝不静默覆盖。**
|
|
512
|
+
* @param {{ id: string, expectedRev: number, name?: string, description?: string, body?: string, tags?: string[], by: string, now?: number }} input
|
|
513
|
+
* @returns {object}
|
|
514
|
+
*/
|
|
515
|
+
function updateSkill(input = {}) {
|
|
516
|
+
assertAlive('updateSkill')
|
|
517
|
+
requireBy(input.by, 'updateSkill')
|
|
518
|
+
const at = normalizeNow(input.now, 'updateSkill')
|
|
519
|
+
const id = requireString(input.id, 'id', 'updateSkill')
|
|
520
|
+
const expectedRev = requireRev(input.expectedRev, 'updateSkill')
|
|
521
|
+
const current = findById(skills, id, 'LIB_SKILL_NOT_FOUND', 'updateSkill')
|
|
522
|
+
if (current.rev !== expectedRev) {
|
|
523
|
+
throw new LibraryError(
|
|
524
|
+
`updateSkill:rev 冲突(期望 ${expectedRev},实际 ${current.rev})—— 请重新读取后再改`,
|
|
525
|
+
'LIB_CAS_CONFLICT',
|
|
526
|
+
{ id, expectedRev, actualRev: current.rev },
|
|
527
|
+
)
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
const next = { ...current }
|
|
531
|
+
if (input.name !== undefined) {
|
|
532
|
+
const name = requireName(input.name, 'updateSkill', 'LIB_BAD_NAME')
|
|
533
|
+
assertFreeName(skills, name, current.id, 'LIB_SKILL_NAME_TAKEN', 'updateSkill')
|
|
534
|
+
next.name = name
|
|
535
|
+
}
|
|
536
|
+
if (input.description !== undefined) {
|
|
537
|
+
next.description = normalizeText(input.description, 'description', LIMITS.description, 'updateSkill') ?? ''
|
|
538
|
+
}
|
|
539
|
+
if (input.body !== undefined) next.body = requireBody(input.body, 'updateSkill')
|
|
540
|
+
if (input.tags !== undefined) next.tags = normalizeTags(input.tags, 'updateSkill') ?? []
|
|
541
|
+
next.rev = current.rev + 1
|
|
542
|
+
next.updatedAt = at
|
|
543
|
+
|
|
544
|
+
const map = new Map(skills)
|
|
545
|
+
map.set(next.id, next)
|
|
546
|
+
commitSkills(map)
|
|
547
|
+
return clone(next)
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* 真删除(技能是库条目,不是审计对象 —— 删了就是删了)。
|
|
552
|
+
* @param {{ id: string, by: string, now?: number }} input
|
|
553
|
+
* @returns {{ id: string, removed: true }}
|
|
554
|
+
*/
|
|
555
|
+
function removeSkill(input = {}) {
|
|
556
|
+
assertAlive('removeSkill')
|
|
557
|
+
requireBy(input.by, 'removeSkill')
|
|
558
|
+
normalizeNow(input.now, 'removeSkill')
|
|
559
|
+
const id = requireString(input.id, 'id', 'removeSkill')
|
|
560
|
+
findById(skills, id, 'LIB_SKILL_NOT_FOUND', 'removeSkill')
|
|
561
|
+
const map = new Map(skills)
|
|
562
|
+
map.delete(id)
|
|
563
|
+
commitSkills(map)
|
|
564
|
+
warn('[cs-bots] library.removeSkill', { id })
|
|
565
|
+
return { id, removed: true }
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* @param {string} id
|
|
570
|
+
* @returns {object}
|
|
571
|
+
*/
|
|
572
|
+
function getSkill(id) {
|
|
573
|
+
assertAlive('getSkill')
|
|
574
|
+
return clone(findById(skills, id, 'LIB_SKILL_NOT_FOUND', 'getSkill'))
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* @param {{ tag?: string }} [query]
|
|
579
|
+
* @returns {object[]}
|
|
580
|
+
*/
|
|
581
|
+
function listSkills(query = {}) {
|
|
582
|
+
assertAlive('listSkills')
|
|
583
|
+
return filterByTag(skillsList(), query?.tag)
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// ── 工作流库 ──────────────────────────────────────────────────────────────
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* 新建工作流模板。`steps` 只是数据,本模块不执行、不调度任何一步。
|
|
590
|
+
* @param {{ name: string, description?: string, steps: object[], tags?: string[], by: string, now?: number }} input
|
|
591
|
+
* @returns {object}
|
|
592
|
+
*/
|
|
593
|
+
function createWorkflow(input = {}) {
|
|
594
|
+
assertAlive('createWorkflow')
|
|
595
|
+
requireBy(input.by, 'createWorkflow')
|
|
596
|
+
const at = normalizeNow(input.now, 'createWorkflow')
|
|
597
|
+
const name = requireDisplayName(input.name, 'createWorkflow', 'LIB_BAD_NAME')
|
|
598
|
+
assertFreeName(workflows, name, null, 'LIB_WORKFLOW_NAME_TAKEN', 'createWorkflow')
|
|
599
|
+
const description = normalizeText(input.description, 'description', LIMITS.description, 'createWorkflow') ?? ''
|
|
600
|
+
const steps = normalizeSteps(input.steps, 'createWorkflow')
|
|
601
|
+
const tags = normalizeTags(input.tags, 'createWorkflow') ?? []
|
|
602
|
+
|
|
603
|
+
const item = {
|
|
604
|
+
id: nextId('wf', workflows),
|
|
605
|
+
name,
|
|
606
|
+
description,
|
|
607
|
+
steps,
|
|
608
|
+
tags,
|
|
609
|
+
createdAt: at,
|
|
610
|
+
updatedAt: at,
|
|
611
|
+
rev: 1,
|
|
612
|
+
}
|
|
613
|
+
const next = new Map(workflows)
|
|
614
|
+
next.set(item.id, item)
|
|
615
|
+
commitWorkflows(next)
|
|
616
|
+
info('[cs-bots] library.createWorkflow', { id: item.id, name, steps: steps.length })
|
|
617
|
+
return clone(item)
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* @param {{ id: string, expectedRev: number, name?: string, description?: string, steps?: object[], tags?: string[], by: string, now?: number }} input
|
|
622
|
+
* @returns {object}
|
|
623
|
+
*/
|
|
624
|
+
function updateWorkflow(input = {}) {
|
|
625
|
+
assertAlive('updateWorkflow')
|
|
626
|
+
requireBy(input.by, 'updateWorkflow')
|
|
627
|
+
const at = normalizeNow(input.now, 'updateWorkflow')
|
|
628
|
+
const id = requireString(input.id, 'id', 'updateWorkflow')
|
|
629
|
+
const expectedRev = requireRev(input.expectedRev, 'updateWorkflow')
|
|
630
|
+
const current = findById(workflows, id, 'LIB_WORKFLOW_NOT_FOUND', 'updateWorkflow')
|
|
631
|
+
if (current.rev !== expectedRev) {
|
|
632
|
+
throw new LibraryError(
|
|
633
|
+
`updateWorkflow:rev 冲突(期望 ${expectedRev},实际 ${current.rev})—— 请重新读取后再改`,
|
|
634
|
+
'LIB_CAS_CONFLICT',
|
|
635
|
+
{ id, expectedRev, actualRev: current.rev },
|
|
636
|
+
)
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
const next = { ...current }
|
|
640
|
+
if (input.name !== undefined) {
|
|
641
|
+
const name = requireDisplayName(input.name, 'updateWorkflow', 'LIB_BAD_NAME')
|
|
642
|
+
assertFreeName(workflows, name, current.id, 'LIB_WORKFLOW_NAME_TAKEN', 'updateWorkflow')
|
|
643
|
+
next.name = name
|
|
644
|
+
}
|
|
645
|
+
if (input.description !== undefined) {
|
|
646
|
+
next.description = normalizeText(input.description, 'description', LIMITS.description, 'updateWorkflow') ?? ''
|
|
647
|
+
}
|
|
648
|
+
if (input.steps !== undefined) next.steps = normalizeSteps(input.steps, 'updateWorkflow')
|
|
649
|
+
if (input.tags !== undefined) next.tags = normalizeTags(input.tags, 'updateWorkflow') ?? []
|
|
650
|
+
next.rev = current.rev + 1
|
|
651
|
+
next.updatedAt = at
|
|
652
|
+
|
|
653
|
+
const map = new Map(workflows)
|
|
654
|
+
map.set(next.id, next)
|
|
655
|
+
commitWorkflows(map)
|
|
656
|
+
return clone(next)
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/**
|
|
660
|
+
* @param {{ id: string, by: string, now?: number }} input
|
|
661
|
+
* @returns {{ id: string, removed: true }}
|
|
662
|
+
*/
|
|
663
|
+
function removeWorkflow(input = {}) {
|
|
664
|
+
assertAlive('removeWorkflow')
|
|
665
|
+
requireBy(input.by, 'removeWorkflow')
|
|
666
|
+
normalizeNow(input.now, 'removeWorkflow')
|
|
667
|
+
const id = requireString(input.id, 'id', 'removeWorkflow')
|
|
668
|
+
findById(workflows, id, 'LIB_WORKFLOW_NOT_FOUND', 'removeWorkflow')
|
|
669
|
+
const map = new Map(workflows)
|
|
670
|
+
map.delete(id)
|
|
671
|
+
commitWorkflows(map)
|
|
672
|
+
warn('[cs-bots] library.removeWorkflow', { id })
|
|
673
|
+
return { id, removed: true }
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/**
|
|
677
|
+
* @param {string} id
|
|
678
|
+
* @returns {object}
|
|
679
|
+
*/
|
|
680
|
+
function getWorkflow(id) {
|
|
681
|
+
assertAlive('getWorkflow')
|
|
682
|
+
return clone(findById(workflows, id, 'LIB_WORKFLOW_NOT_FOUND', 'getWorkflow'))
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/**
|
|
686
|
+
* @param {{ tag?: string }} [query]
|
|
687
|
+
* @returns {object[]}
|
|
688
|
+
*/
|
|
689
|
+
function listWorkflows(query = {}) {
|
|
690
|
+
assertAlive('listWorkflows')
|
|
691
|
+
return filterByTag(workflowsList(), query?.tag)
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
// ── 通用 ──────────────────────────────────────────────────────────────────
|
|
695
|
+
|
|
696
|
+
/** @returns {{ skills: number, workflows: number }} */
|
|
697
|
+
function stats() {
|
|
698
|
+
assertAlive('stats')
|
|
699
|
+
return { skills: skills.size, workflows: workflows.size }
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/**
|
|
703
|
+
* 从磁盘重建并返回当前内容(重启一致性用:磁盘是真相源)。
|
|
704
|
+
* @returns {{ skills: object[], workflows: object[] }}
|
|
705
|
+
*/
|
|
706
|
+
function replay() {
|
|
707
|
+
assertAlive('replay')
|
|
708
|
+
hydrate()
|
|
709
|
+
return { skills: skillsList(), workflows: workflowsList() }
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/** 释放实例:清空内存态并拒绝后续调用(避免半死对象被继续用)。 */
|
|
713
|
+
function dispose() {
|
|
714
|
+
skills = new Map()
|
|
715
|
+
workflows = new Map()
|
|
716
|
+
disposed = true
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/** 构造即 hydrate:坏文件在**创建时**就炸,而不是等第一次写入才炸。 */
|
|
720
|
+
hydrate()
|
|
721
|
+
|
|
722
|
+
return {
|
|
723
|
+
paths,
|
|
724
|
+
createSkill,
|
|
725
|
+
updateSkill,
|
|
726
|
+
removeSkill,
|
|
727
|
+
getSkill,
|
|
728
|
+
listSkills,
|
|
729
|
+
createWorkflow,
|
|
730
|
+
updateWorkflow,
|
|
731
|
+
removeWorkflow,
|
|
732
|
+
getWorkflow,
|
|
733
|
+
listWorkflows,
|
|
734
|
+
stats,
|
|
735
|
+
replay,
|
|
736
|
+
dispose,
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
export default createLibrary
|