cs-bots 0.0.0-stage → 0.1.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.
@@ -0,0 +1,331 @@
1
+ /**
2
+ * cs-bots /botprofile —— 每个 Bot 的「身份 / 经验 / 技能 / 预制记忆」。
3
+ *
4
+ * ── 为什么这样实现(不用动态注册预设)─────────────────────────────────────
5
+ * DSH 的身份体系是三层官方机制,我们**直接复用**,不自造:
6
+ *
7
+ * ① 身份 + 经验 → `<cwd>/AGENTS.md`
8
+ * `@deepseek-ai/dsh-agent-instructions` 是官方「工作区指令加载器」,
9
+ * 文档原文:*"Baseline instructions enter durable context before the first request"*
10
+ * ⇒ 只要把文字写进 Bot 自己目录的 AGENTS.md,它**首次请求前**就会进上下文。
11
+ * 这是最稳的一条:不碰组合、不需要重启、不依赖预设注册。
12
+ *
13
+ * ② 技能 → `<cwd>/.agents/skills/<name>/SKILL.md`
14
+ * `dsh-skill-filesystem` 的 `skills.list({ cwd })` 用 **cwd 选项目根**
15
+ * ⇒ Bot 自己目录下的技能自动被发现,互不干扰。
16
+ *
17
+ * ③ 预制记忆 → 双写
18
+ * · 写 MemoryService(带 botId):可检索、可审计、可增删
19
+ * · 同时镜像进 AGENTS.md 托管段:保证**一定被注入**(检索可能不命中,注入不会漏)
20
+ *
21
+ * ── 托管段协议 ────────────────────────────────────────────────────────────
22
+ * 我们在 AGENTS.md 里只用 `<!-- cs-bots:begin -->…<!-- cs-bots:end -->` 之间的区域,
23
+ * **绝不改动用户在段外的任何内容**(人写的东西优先,我们不覆盖)。
24
+ */
25
+
26
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, statSync } from 'node:fs'
27
+ import { isAbsolute, join, resolve } from 'node:path'
28
+
29
+ /** 托管段起止标记。 */
30
+ export const MARK_BEGIN = '<!-- cs-bots:begin —— 以下内容由 cs-bots 维护,改动可能被覆盖 -->'
31
+ export const MARK_END = '<!-- cs-bots:end -->'
32
+
33
+ /** AGENTS.md 文件名。 */
34
+ export const AGENTS_FILE = 'AGENTS.md'
35
+
36
+ /** 技能目录(相对 Bot 的 cwd)。 */
37
+ export const SKILLS_DIR = '.agents/skills'
38
+
39
+ /** 各段上限(防止把上下文撑爆)。 */
40
+ export const LIMITS = Object.freeze({
41
+ identity: 4000,
42
+ experience: 8000,
43
+ memoryEach: 600,
44
+ memoryCount: 50,
45
+ skillsCount: 50,
46
+ workflowsCount: 50,
47
+ skillBody: 20000,
48
+ })
49
+
50
+ /** 档案校验错误。 */
51
+ export class ProfileError extends Error {
52
+ constructor(message, code, detail) {
53
+ super(message)
54
+ this.name = 'ProfileError'
55
+ this.code = code
56
+ this.detail = detail
57
+ }
58
+ }
59
+
60
+ /** 取字符串,超长则报错(不静默截断 —— 截断会让用户以为写进去了)。 */
61
+ function takeText(value, field, max) {
62
+ if (value === undefined || value === null) return undefined
63
+ const s = String(value)
64
+ if (s.length > max) {
65
+ throw new ProfileError(
66
+ `profile.${field} 超长:${s.length} > ${max} 字符`,
67
+ 'PROFILE_TOO_LONG',
68
+ { field, length: s.length, max },
69
+ )
70
+ }
71
+ return s
72
+ }
73
+
74
+ /** 取字符串数组。 */
75
+ function takeList(value, field, maxItems, maxEach) {
76
+ if (value === undefined || value === null) return undefined
77
+ if (!Array.isArray(value)) {
78
+ throw new ProfileError(`profile.${field} 必须是数组`, 'PROFILE_BAD_FIELD', { field })
79
+ }
80
+ if (value.length > maxItems) {
81
+ throw new ProfileError(
82
+ `profile.${field} 条目过多:${value.length} > ${maxItems}`,
83
+ 'PROFILE_TOO_MANY',
84
+ { field, count: value.length, maxItems },
85
+ )
86
+ }
87
+ return value.map((v, i) => {
88
+ const s = String(v ?? '')
89
+ if (s.length > maxEach) {
90
+ throw new ProfileError(
91
+ `profile.${field}[${i}] 超长:${s.length} > ${maxEach} 字符`,
92
+ 'PROFILE_TOO_LONG',
93
+ { field: `${field}[${i}]`, length: s.length, max: maxEach },
94
+ )
95
+ }
96
+ return s
97
+ })
98
+ }
99
+
100
+ /**
101
+ * 规范化档案输入(只取认识的字段,其余忽略;越界一律抛错)。
102
+ * @param {object} input
103
+ * @returns {{ identity?: string, experience?: string, skills?: string[], memory?: string[] }}
104
+ */
105
+ export function normalizeProfile(input) {
106
+ const src = input ?? {}
107
+ if (typeof src !== 'object') {
108
+ throw new ProfileError('profile 必须是对象', 'PROFILE_BAD_FIELD')
109
+ }
110
+ const out = {}
111
+ const identity = takeText(src.identity, 'identity', LIMITS.identity)
112
+ if (identity !== undefined) out.identity = identity
113
+ const experience = takeText(src.experience, 'experience', LIMITS.experience)
114
+ if (experience !== undefined) out.experience = experience
115
+ const skills = takeList(src.skills, 'skills', LIMITS.skillsCount, 64)
116
+ if (skills !== undefined) out.skills = skills
117
+ const workflows = takeList(src.workflows, 'workflows', LIMITS.workflowsCount, 64)
118
+ if (workflows !== undefined) out.workflows = workflows
119
+ const memory = takeList(src.memory, 'memory', LIMITS.memoryCount, LIMITS.memoryEach)
120
+ if (memory !== undefined) out.memory = memory
121
+ return out
122
+ }
123
+
124
+ /** 校验 cwd 可用于写档案(必须绝对路径且存在)。 */
125
+ export function assertWritableCwd(cwd) {
126
+ if (typeof cwd !== 'string' || cwd.trim() === '') {
127
+ throw new ProfileError('cwd 必填', 'PROFILE_BAD_CWD', { cwd })
128
+ }
129
+ if (!isAbsolute(cwd)) {
130
+ throw new ProfileError(`cwd 必须是绝对路径,收到 ${JSON.stringify(cwd)}`, 'PROFILE_BAD_CWD', { cwd })
131
+ }
132
+ const abs = resolve(cwd)
133
+ try {
134
+ if (!statSync(abs).isDirectory()) {
135
+ throw new ProfileError(`cwd 不是目录:${abs}`, 'PROFILE_BAD_CWD', { cwd: abs })
136
+ }
137
+ } catch (error) {
138
+ if (error instanceof ProfileError) throw error
139
+ throw new ProfileError(`cwd 不存在或不可访问:${abs}`, 'PROFILE_BAD_CWD', { cwd: abs, cause: String(error?.message ?? error) })
140
+ }
141
+ return abs
142
+ }
143
+
144
+ /**
145
+ * 把档案渲染成 AGENTS.md 里的一段 Markdown。
146
+ * @param {{ identity?: string, experience?: string, skills?: string[], memory?: string[], botName?: string, preset?: string }} profile
147
+ * @returns {string}
148
+ */
149
+ export function renderManagedSection(profile) {
150
+ const p = profile ?? {}
151
+ const lines = [MARK_BEGIN, '']
152
+ lines.push(`# 你的身份(由 cs-bots 维护)`, '')
153
+ if (p.botName) lines.push(`- 角色名:**${p.botName}**`)
154
+ if (p.preset) lines.push(`- 预设:\`${p.preset}\``)
155
+ if (p.identity && p.identity.trim()) {
156
+ lines.push('', p.identity.trim())
157
+ } else {
158
+ lines.push('', '_(未填写身份描述)_')
159
+ }
160
+
161
+ if (p.experience && p.experience.trim()) {
162
+ lines.push('', '## 经验与规矩', '', p.experience.trim())
163
+ }
164
+
165
+ if (Array.isArray(p.skills) && p.skills.length) {
166
+ lines.push('', '## 可用技能', '')
167
+ for (const s of p.skills) lines.push(`- \`${s}\``)
168
+ lines.push('', `技能文件位于本工作目录的 \`${SKILLS_DIR}/<技能名>/SKILL.md\`,用 skill 工具加载。`)
169
+ }
170
+
171
+ if (Array.isArray(p.workflows) && p.workflows.length) {
172
+ lines.push('', '## 该遵循的工作流', '')
173
+ for (const w of p.workflows) lines.push(`- \`${w}\``)
174
+ lines.push('', '执行相关任务时,按以上工作流库里的步骤走(工作流库在协作平台的「工作流库」页)。')
175
+ }
176
+
177
+ if (Array.isArray(p.memory) && p.memory.length) {
178
+ lines.push('', '## 预制记忆', '')
179
+ for (const m of p.memory) lines.push(`- ${m}`)
180
+ }
181
+
182
+ lines.push('', MARK_END)
183
+ return lines.join('\n')
184
+ }
185
+
186
+ /**
187
+ * 读现有 AGENTS.md,替换或追加托管段,**保留段外内容原样**。
188
+ * @param {string} cwd
189
+ * @param {object} profile
190
+ * @returns {{ path: string, created: boolean, hadManaged: boolean, outsideLength: number }}
191
+ */
192
+ export function writeAgentsFile(cwd, profile) {
193
+ const dir = assertWritableCwd(cwd)
194
+ const file = join(dir, AGENTS_FILE)
195
+ let existing = ''
196
+ let created = false
197
+ if (existsSync(file)) {
198
+ try {
199
+ existing = readFileSync(file, 'utf8')
200
+ } catch (error) {
201
+ throw new ProfileError(`读取 ${file} 失败:${error?.message ?? error}`, 'PROFILE_READ_FAILED', { file })
202
+ }
203
+ } else {
204
+ created = true
205
+ }
206
+
207
+ const begin = existing.indexOf(MARK_BEGIN)
208
+ const end = existing.indexOf(MARK_END)
209
+ const hadManaged = begin !== -1 && end !== -1 && end > begin
210
+
211
+ let outside = existing
212
+ if (hadManaged) {
213
+ outside = existing.slice(0, begin) + existing.slice(end + MARK_END.length)
214
+ }
215
+ outside = outside.replace(/\n{3,}/g, '\n\n').trim()
216
+
217
+ const managed = renderManagedSection(profile)
218
+ const next = outside ? `${outside}\n\n${managed}\n` : `${managed}\n`
219
+
220
+ try {
221
+ writeFileSync(file, next, 'utf8')
222
+ } catch (error) {
223
+ throw new ProfileError(`写入 ${file} 失败:${error?.message ?? error}`, 'PROFILE_WRITE_FAILED', { file })
224
+ }
225
+ return { path: file, created, hadManaged, outsideLength: outside.length }
226
+ }
227
+
228
+ /**
229
+ * 读回 AGENTS.md 的托管段(不含标记)。
230
+ * @param {string} cwd
231
+ * @returns {{ path: string, exists: boolean, managed?: string, outside?: string }}
232
+ */
233
+ export function readAgentsFile(cwd) {
234
+ const dir = assertWritableCwd(cwd)
235
+ const file = join(dir, AGENTS_FILE)
236
+ if (!existsSync(file)) return { path: file, exists: false }
237
+ let text = ''
238
+ try {
239
+ text = readFileSync(file, 'utf8')
240
+ } catch (error) {
241
+ throw new ProfileError(`读取 ${file} 失败:${error?.message ?? error}`, 'PROFILE_READ_FAILED', { file })
242
+ }
243
+ const begin = text.indexOf(MARK_BEGIN)
244
+ const end = text.indexOf(MARK_END)
245
+ if (begin === -1 || end === -1 || end <= begin) {
246
+ return { path: file, exists: true, outside: text }
247
+ }
248
+ const managed = text.slice(begin + MARK_BEGIN.length, end).trim()
249
+ const outside = (text.slice(0, begin) + text.slice(end + MARK_END.length)).trim()
250
+ return { path: file, exists: true, managed, outside }
251
+ }
252
+
253
+ /**
254
+ * 为一个技能写 `<cwd>/.agents/skills/<name>/SKILL.md`。
255
+ *
256
+ * 技能名必须安全(字母数字 - _),防止路径穿越。
257
+ * @param {string} cwd
258
+ * @param {string} name
259
+ * @param {{ description?: string, body: string }} spec
260
+ * @returns {{ dir: string, file: string }}
261
+ */
262
+ export function writeSkill(cwd, name, spec) {
263
+ const dir = assertWritableCwd(cwd)
264
+ const safe = String(name ?? '').trim()
265
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(safe)) {
266
+ throw new ProfileError(
267
+ `技能名不合法:${JSON.stringify(name)}(只允许字母、数字、. _ -,且以字母数字开头)`,
268
+ 'PROFILE_BAD_SKILL_NAME',
269
+ { name },
270
+ )
271
+ }
272
+ const body = String(spec?.body ?? '')
273
+ if (body.length > LIMITS.skillBody) {
274
+ throw new ProfileError(
275
+ `技能正文超长:${body.length} > ${LIMITS.skillBody}`,
276
+ 'PROFILE_TOO_LONG',
277
+ { name: safe, length: body.length },
278
+ )
279
+ }
280
+ const skillDir = join(dir, SKILLS_DIR, safe)
281
+ try {
282
+ mkdirSync(skillDir, { recursive: true })
283
+ } catch (error) {
284
+ throw new ProfileError(`创建技能目录失败:${error?.message ?? error}`, 'PROFILE_WRITE_FAILED', { dir: skillDir })
285
+ }
286
+ const desc = String(spec?.description ?? '').trim()
287
+ // SKILL.md 的头部是 YAML frontmatter(官方约定)
288
+ const head = ['---', `name: ${safe}`, desc ? `description: ${JSON.stringify(desc)}` : '', '---', ''].filter(Boolean).join('\n')
289
+ const file = join(skillDir, 'SKILL.md')
290
+ try {
291
+ writeFileSync(file, `${head}\n${body}\n`, 'utf8')
292
+ } catch (error) {
293
+ throw new ProfileError(`写入技能失败:${error?.message ?? error}`, 'PROFILE_WRITE_FAILED', { file })
294
+ }
295
+ return { dir: skillDir, file }
296
+ }
297
+
298
+ /** 列出 Bot 目录下已有的技能名。 */
299
+ export function listSkills(cwd) {
300
+ const dir = assertWritableCwd(cwd)
301
+ const root = join(dir, SKILLS_DIR)
302
+ if (!existsSync(root)) return []
303
+ try {
304
+ return readdirSafe(root).filter((n) => {
305
+ try {
306
+ return statSync(join(root, n)).isDirectory() && existsSync(join(root, n, 'SKILL.md'))
307
+ } catch {
308
+ return false
309
+ }
310
+ })
311
+ } catch {
312
+ return []
313
+ }
314
+ }
315
+
316
+ function readdirSafe(p) {
317
+ try {
318
+ // 动态取 fs.readdirSync,避免顶层多引一个符号
319
+ const fs = require('node:fs')
320
+ return fs.readdirSync(p)
321
+ } catch {
322
+ return []
323
+ }
324
+ }
325
+
326
+ function require(spec) {
327
+ try {
328
+ if (typeof process.getBuiltinModule === 'function') return process.getBuiltinModule(spec)
329
+ } catch {}
330
+ return {}
331
+ }