cs-bots 0.1.1 → 0.7.4
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/CHANGELOG.md +533 -0
- package/README.md +65 -10
- package/docs/01-/346/236/266/346/236/204/346/226/207/346/241/243.md +364 -0
- package/docs/02-/351/234/200/346/261/202/350/247/204/346/240/274/344/271/246.md +284 -0
- package/docs/03-/350/257/246/347/273/206/350/256/276/350/256/241.md +567 -0
- package/docs/04-/346/223/215/344/275/234/346/211/213/345/206/214.md +569 -0
- package/docs/ARCHITECTURE.md +277 -0
- package/docs/adr/ADR-001-/351/241/265/347/255/276/347/273/223/346/236/204/345/233/236/345/210/260/344/270/203/351/241/265.md +70 -0
- package/docs/steps/step-01-/345/216/237/346/236/266/346/236/204/345/244/215/345/216/237/344/270/216IA/350/257/212/346/226/255.md +80 -0
- package/docs/steps/step-02-/351/241/265/347/255/276/346/224/266/346/225/233/344/270/216/345/215/207/347/272/247/351/200/232/351/201/223.md +51 -0
- package/docs/steps/step-03-/346/236/266/346/236/204/350/265/204/346/226/231/346/210/220/346/226/207.md +16 -0
- package/docs/steps/step-04-/344/270/212/347/272/277/344/270/216/351/252/214/350/257/201.md +46 -0
- package/docs//344/272/244/344/273/230/350/257/264/346/230/216-v0.2.0.md +95 -0
- package/docs//346/223/215/344/275/234/346/211/213/345/206/214-v1.md +509 -0
- package/docs//350/277/201/347/247/273/346/226/271/346/241/210-v1.md +64 -0
- package/docs//351/225/277/346/234/237/350/256/260/345/277/206/346/226/271/346/241/210-v1.md +85 -0
- package/package.json +4 -2
- package/src/admission.js +365 -0
- package/src/api.js +4097 -420
- package/src/board.js +883 -14
- package/src/botprofile.js +190 -3
- package/src/client.js +6596 -1082
- package/src/cycle.js +516 -0
- package/src/delivery-watch.js +255 -0
- package/src/dirbrowse.js +20 -0
- package/src/discipline.js +1188 -0
- package/src/fence.js +142 -0
- package/src/index.js +387 -4
- package/src/kernel.js +636 -24
- package/src/library.js +1224 -11
- package/src/memory.js +617 -37
- package/src/metrics.js +339 -0
- package/src/prompts.js +1318 -0
- package/src/reviewer.js +709 -0
- package/src/room.js +655 -14
- package/src/roster.js +346 -26
- package/src/routing/complexity.js +320 -0
- package/src/routing/hints.js +707 -0
- package/src/scratch.js +477 -0
- package/src/settings.js +97 -0
- package/src/stage.js +263 -0
- package/src/store.js +133 -1
- package/src/subscription.js +571 -0
- package/src/suggest.js +1203 -0
- package/src/teams.js +527 -0
- package/src/todo.js +1301 -0
- package/src/tools.js +734 -10
- package/src/verify.js +356 -0
package/src/scratch.js
ADDED
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cs-bots ScratchService —— 临时便签(自由 Markdown,按作用域隔离,原子写)。
|
|
3
|
+
*
|
|
4
|
+
* 对标 dsh-memory-evolve 的 scratch 子系统,但**完全自建、零第三方依赖**
|
|
5
|
+
* (只用 `node:fs` / `node:*` 内置 + `./store.js` / `./kernel.js`)。
|
|
6
|
+
*
|
|
7
|
+
* ── 作用域(scope)为什么按文件隔离而不是塞一个大 JSON ──
|
|
8
|
+
* 便签是**人写给人看的自由 Markdown**,最怕两件事:
|
|
9
|
+
* ① 一处写坏拖垮全部(大 JSON 里一个引号错,所有便签读不出来);
|
|
10
|
+
* ② 两个人/两个项目同时写互相覆盖(同一文件必冲突)。
|
|
11
|
+
* 按 `<scope>.md` 一文件一便签,天然满足:读坏一个不影响别的;
|
|
12
|
+
* 不同 scope 落在不同 inode,写 A 不碰 B。
|
|
13
|
+
* 项目便签用 cwd 的 hash(`project-<sha1前8位>`),由调用方算好传入 ——
|
|
14
|
+
* 本模块**不猜测** cwd 的语义(谁的项目、是否同一项目由上层/调用方定义)。
|
|
15
|
+
*
|
|
16
|
+
* ── 为什么必须「临时文件 + rename」而不是直接写目标文件 ──
|
|
17
|
+
* 直接 writeFileSync 目标文件时,进程崩在 write 中途、或断电,目标文件就变成
|
|
18
|
+
* 半截 Markdown(前一半新内容 + 后一半旧内容 / 干脆空文件)。rename 在
|
|
19
|
+
* POSIX 上是原子的:读者要么看到旧全文、要么看到新全文,**不存在中间态**。
|
|
20
|
+
* 另外先 fsync 临时文件再 rename:否则 rename 只是把「页缓存里的名字」换掉,
|
|
21
|
+
* 掉电后新名字可能指向尚未落盘的数据 —— 这就是「rename 原子但仍丢内容」的经典坑。
|
|
22
|
+
*
|
|
23
|
+
* ── 与 board.js 相同的一条纪律:**不静默失败** ──
|
|
24
|
+
* 读不存在 → 返回空(这是正常状态,不是错误,新便签天然为空);
|
|
25
|
+
* 其余任何 IO 失败 → 抛 `ScratchError`(带 code + detail),绝不假装成功。
|
|
26
|
+
*
|
|
27
|
+
* 依赖面:node 内置 + `./store.js` + `./kernel.js`。不 import 任何宿主包/第三方包。
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import {
|
|
31
|
+
closeSync,
|
|
32
|
+
fsyncSync,
|
|
33
|
+
mkdirSync,
|
|
34
|
+
openSync,
|
|
35
|
+
readdirSync,
|
|
36
|
+
readFileSync,
|
|
37
|
+
renameSync,
|
|
38
|
+
statSync,
|
|
39
|
+
unlinkSync,
|
|
40
|
+
utimesSync,
|
|
41
|
+
writeSync,
|
|
42
|
+
} from 'node:fs'
|
|
43
|
+
import { randomBytes } from 'node:crypto'
|
|
44
|
+
|
|
45
|
+
import { statePath } from './store.js'
|
|
46
|
+
|
|
47
|
+
// ── kernel 惰性桥(与 board.js 同款,原因见文件头)────────────────────────────
|
|
48
|
+
//
|
|
49
|
+
// kernel.js 静态 import 了宿主包(只在 DSH profile 内可解析)。本仓库是裸检出,
|
|
50
|
+
// 静态 import 会让任意用到 scratch 的测试连带加载失败。拿不到 kernel 时
|
|
51
|
+
// **不静默兜底**:调用方必须显式传 config.stateDir,否则抛可读错误。
|
|
52
|
+
|
|
53
|
+
/** @type {object | null} */
|
|
54
|
+
let kernel = null
|
|
55
|
+
/** @type {unknown} */
|
|
56
|
+
let kernelError = null
|
|
57
|
+
try {
|
|
58
|
+
kernel = await import('./kernel.js')
|
|
59
|
+
} catch (error) {
|
|
60
|
+
kernelError = error
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ── 常量 ────────────────────────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
/** 缺省作用域。 */
|
|
66
|
+
export const DEFAULT_SCOPE = 'global'
|
|
67
|
+
|
|
68
|
+
/** 单条便签上限:512 KiB(按 **UTF-8 字节**算,不是字符数)。 */
|
|
69
|
+
export const MAX_BYTES = 512 * 1024
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* 作用域名白名单。
|
|
73
|
+
*
|
|
74
|
+
* 为什么必须白名单而不是「过滤掉 / 就行」:scope 直接拼进文件名,任何路径穿越
|
|
75
|
+
* (`../memory`、`..`)或空字节(`\0`)都会让写入落到便签目录之外。
|
|
76
|
+
* 白名单同时挡掉 Windows 非法字符与隐藏文件,规则简单到能一眼验证。
|
|
77
|
+
*/
|
|
78
|
+
const SCOPE_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/
|
|
79
|
+
|
|
80
|
+
/** 文件后缀(`<scope>.md`)。 */
|
|
81
|
+
const FILE_EXT = '.md'
|
|
82
|
+
|
|
83
|
+
// ── 错误 ────────────────────────────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* 便签结构化错误。所有失败都走它,**绝不静默**。
|
|
87
|
+
*
|
|
88
|
+
* 同时挂 `detail` 与 `details`:前者是任务契约里的字段名,后者是本仓
|
|
89
|
+
* board/discipline 的既有习惯,两边都认,避免调用方按哪种写法取都取不到。
|
|
90
|
+
*/
|
|
91
|
+
export class ScratchError extends Error {
|
|
92
|
+
/**
|
|
93
|
+
* @param {string} message 人类可读、含定位信息(scope / 路径)
|
|
94
|
+
* @param {string} [code]
|
|
95
|
+
* @param {object} [detail]
|
|
96
|
+
*/
|
|
97
|
+
constructor(message, code = 'SCRATCH_ERROR', detail) {
|
|
98
|
+
super(message)
|
|
99
|
+
this.name = 'ScratchError'
|
|
100
|
+
this.code = code
|
|
101
|
+
if (detail !== undefined) {
|
|
102
|
+
this.detail = detail
|
|
103
|
+
this.details = detail
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ── 小工具 ──────────────────────────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
/** 深拷贝 + JSON 归一(去掉 undefined 键,避免内存态与磁盘态不等)。 */
|
|
111
|
+
function clone(value) {
|
|
112
|
+
if (value === undefined || value === null) return value
|
|
113
|
+
return JSON.parse(JSON.stringify(value))
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** 归一化 scope:缺省 `'global'`,非法即抛错(不静默改写成 global)。 */
|
|
117
|
+
function normalizeScope(scope, where) {
|
|
118
|
+
if (scope === undefined || scope === null || scope === '') return DEFAULT_SCOPE
|
|
119
|
+
if (typeof scope !== 'string') {
|
|
120
|
+
throw new ScratchError(
|
|
121
|
+
`scratch.${where}:scope 必须是字符串(收到 ${typeof scope})`,
|
|
122
|
+
'SCRATCH_BAD_SCOPE',
|
|
123
|
+
{ scope, where },
|
|
124
|
+
)
|
|
125
|
+
}
|
|
126
|
+
if (!SCOPE_RE.test(scope)) {
|
|
127
|
+
throw new ScratchError(
|
|
128
|
+
`scratch.${where}:非法 scope=${JSON.stringify(scope)}(允许 [A-Za-z0-9._-],`
|
|
129
|
+
+ '须以字母/数字开头,长度 1–64)——scope 会拼进文件名,必须挡路径穿越',
|
|
130
|
+
'SCRATCH_BAD_SCOPE',
|
|
131
|
+
{ scope, where },
|
|
132
|
+
)
|
|
133
|
+
}
|
|
134
|
+
return scope
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** 归一化正文:只接受字符串(不把对象 JSON.stringify 成"便签",那是静默变形)。 */
|
|
138
|
+
function normalizeText(text, where) {
|
|
139
|
+
if (typeof text !== 'string') {
|
|
140
|
+
throw new ScratchError(
|
|
141
|
+
`scratch.${where}:text 必须是字符串(收到 ${text === null ? 'null' : typeof text})`,
|
|
142
|
+
'SCRATCH_BAD_TEXT',
|
|
143
|
+
{ type: typeof text, where },
|
|
144
|
+
)
|
|
145
|
+
}
|
|
146
|
+
return text
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** 归一化 now(可注入,测试用):非有限数即兜底当前时间,保证永不 NaN。 */
|
|
150
|
+
function normalizeNow(now) {
|
|
151
|
+
return Number.isFinite(now) ? Math.floor(now) : Date.now()
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** 归一化 by(可选的写入者标识,仅用于人读的审计统计)。 */
|
|
155
|
+
function normalizeBy(by) {
|
|
156
|
+
if (by === undefined || by === null || by === '') return null
|
|
157
|
+
return typeof by === 'string' ? by : String(by)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** UTF-8 字节数(中文一字 3 字节,必须按字节判上限,否则 512KiB 会被悄悄突破)。 */
|
|
161
|
+
function byteLength(text) {
|
|
162
|
+
return Buffer.byteLength(text, 'utf8')
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* 解析状态目录:`config.stateDir` > `kernel.stateDir()` > 抛错。
|
|
167
|
+
* 与 board.js 一致,**不做静默兜底**。
|
|
168
|
+
* @param {{ stateDir?: string }} config
|
|
169
|
+
* @returns {string}
|
|
170
|
+
*/
|
|
171
|
+
function resolveStateDir(config) {
|
|
172
|
+
if (typeof config?.stateDir === 'string' && config.stateDir.trim() !== '') {
|
|
173
|
+
return config.stateDir.trim()
|
|
174
|
+
}
|
|
175
|
+
if (kernel && typeof kernel.stateDir === 'function') {
|
|
176
|
+
return kernel.stateDir()
|
|
177
|
+
}
|
|
178
|
+
const reason = kernelError?.message ?? (kernel ? 'kernel.stateDir 不存在' : 'kernel 未加载')
|
|
179
|
+
throw new ScratchError(
|
|
180
|
+
`scratch.createScratch:既未提供 config.stateDir,也无法从 kernel.js 取得 stateDir(${reason})。`
|
|
181
|
+
+ '裸检出(无宿主包)下必须显式传 config.stateDir。',
|
|
182
|
+
'SCRATCH_NO_STATE_DIR',
|
|
183
|
+
)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* 把 `(ctx, config)` 归一,允许「只传一个 config 对象」的简写(`{ stateDir }`)。
|
|
188
|
+
* 与 discipline.js 的 splitArgs 同款:两种写法语义完全一致。
|
|
189
|
+
*/
|
|
190
|
+
function splitArgs(ctx, config) {
|
|
191
|
+
const cfgGiven = config && typeof config === 'object' && Object.keys(config).length > 0
|
|
192
|
+
if (cfgGiven) return { context: ctx ?? {}, config }
|
|
193
|
+
if (ctx && typeof ctx === 'object' && typeof ctx.stateDir === 'string') {
|
|
194
|
+
return { context: {}, config: ctx }
|
|
195
|
+
}
|
|
196
|
+
return { context: ctx ?? {}, config: config ?? {} }
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ── 工厂 ────────────────────────────────────────────────────────────────────
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* 创建一个便签实例。
|
|
203
|
+
*
|
|
204
|
+
* @param {{ logger?: { info?: Function, warn?: Function } }} [ctx] 宿主上下文(缺失即降级为 no-op 日志)
|
|
205
|
+
* @param {{ stateDir?: string, name?: string }} [config]
|
|
206
|
+
* @returns {object} scratch service
|
|
207
|
+
*/
|
|
208
|
+
export function createScratch(ctx, config = {}) {
|
|
209
|
+
const { context, config: cfg } = splitArgs(ctx, config)
|
|
210
|
+
const stateDir = resolveStateDir(cfg)
|
|
211
|
+
|
|
212
|
+
/** 便签目录(`<stateDir>/scratch/`;与 jsonl 混在根目录会让 `listScopes` 到处误扫)。 */
|
|
213
|
+
const dir = statePath(stateDir, 'scratch')
|
|
214
|
+
|
|
215
|
+
const info = (...args) => { try { context?.logger?.info?.(...args) } catch { /* 日志失败不影响业务 */ } }
|
|
216
|
+
const warn = (...args) => { try { context?.logger?.warn?.(...args) } catch { /* 同上 */ } }
|
|
217
|
+
|
|
218
|
+
let disposed = false
|
|
219
|
+
/** 运行期统计(不落盘:便签正文本身就是唯一真相,统计只是给 /status 看)。 */
|
|
220
|
+
const diagnostics = {
|
|
221
|
+
stateDir,
|
|
222
|
+
dir,
|
|
223
|
+
writes: 0,
|
|
224
|
+
reads: 0,
|
|
225
|
+
listCalls: 0,
|
|
226
|
+
lastWrite: /** @type {null|{scope:string, by:string|null, at:number, bytes:number}} */ (null),
|
|
227
|
+
lastError: /** @type {null|string} */ (null),
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** 已销毁实例的调用一律抛错:静默返回空便签会让人以为「内容没了」,比崩掉更难查。 */
|
|
231
|
+
function assertAlive(where) {
|
|
232
|
+
if (disposed) {
|
|
233
|
+
throw new ScratchError(
|
|
234
|
+
`scratch.${where}:实例已 dispose(),请重新 createScratch()(拒绝在已销毁实例上静默返回空数据)`,
|
|
235
|
+
'SCRATCH_DISPOSED',
|
|
236
|
+
{ where },
|
|
237
|
+
)
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** scope → 文件绝对路径。 */
|
|
242
|
+
function fileOf(scope) {
|
|
243
|
+
return statePath(dir, `${scope}${FILE_EXT}`)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** 建目录(失败即抛错,不静默继续写)。 */
|
|
247
|
+
function ensureDir() {
|
|
248
|
+
try {
|
|
249
|
+
mkdirSync(dir, { recursive: true })
|
|
250
|
+
} catch (error) {
|
|
251
|
+
throw new ScratchError(
|
|
252
|
+
`scratch:无法创建便签目录 ${dir}(${error?.message ?? error})`,
|
|
253
|
+
'SCRATCH_MKDIR_FAILED',
|
|
254
|
+
{ dir, error: String(error?.message ?? error) },
|
|
255
|
+
)
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* 读一条便签。
|
|
261
|
+
*
|
|
262
|
+
* **不存在 → 返回空**(`exists:false`),不抛错:新便签天然为空,
|
|
263
|
+
* 「还没写过」不是异常。其余 IO 错误(EACCES/ENOTDIR…)才抛。
|
|
264
|
+
*
|
|
265
|
+
* @param {{ scope?: string }} [input]
|
|
266
|
+
* @returns {{ scope: string, text: string, updatedAt: number, bytes: number, exists: boolean }}
|
|
267
|
+
*/
|
|
268
|
+
function read(input = {}) {
|
|
269
|
+
assertAlive('read')
|
|
270
|
+
const scope = normalizeScope(input?.scope, 'read')
|
|
271
|
+
const file = fileOf(scope)
|
|
272
|
+
diagnostics.reads += 1
|
|
273
|
+
try {
|
|
274
|
+
const st = statSync(file)
|
|
275
|
+
if (!st.isFile()) {
|
|
276
|
+
throw new ScratchError(
|
|
277
|
+
`scratch.read:${file} 存在但不是普通文件(scope=${scope})`,
|
|
278
|
+
'SCRATCH_READ_FAILED',
|
|
279
|
+
{ scope, file },
|
|
280
|
+
)
|
|
281
|
+
}
|
|
282
|
+
const text = readFileSync(file, 'utf8')
|
|
283
|
+
return {
|
|
284
|
+
scope,
|
|
285
|
+
text,
|
|
286
|
+
// mtime 是**磁盘真相**:用 round 而不是 floor —— utimesSync 写入整数毫秒后
|
|
287
|
+
// stat 会回读成 x.999(浮点误差),floor 会平白少 1 毫秒。
|
|
288
|
+
updatedAt: Math.round(st.mtimeMs),
|
|
289
|
+
bytes: st.size,
|
|
290
|
+
exists: true,
|
|
291
|
+
}
|
|
292
|
+
} catch (error) {
|
|
293
|
+
if (error instanceof ScratchError) throw error
|
|
294
|
+
if (error?.code === 'ENOENT') {
|
|
295
|
+
return { scope, text: '', updatedAt: 0, bytes: 0, exists: false }
|
|
296
|
+
}
|
|
297
|
+
diagnostics.lastError = String(error?.message ?? error)
|
|
298
|
+
throw new ScratchError(
|
|
299
|
+
`scratch.read:读取 ${file} 失败(${error?.message ?? error})`,
|
|
300
|
+
'SCRATCH_READ_FAILED',
|
|
301
|
+
{ scope, file, error: String(error?.message ?? error) },
|
|
302
|
+
)
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* 写一条便签(原子覆盖)。
|
|
308
|
+
*
|
|
309
|
+
* 顺序:临时文件 → write → fsync →(可选 utimes)→ rename → fsync 目录。
|
|
310
|
+
* 任何一步失败都清掉临时文件并抛错;**目标文件在 rename 之前始终是旧的完整内容**。
|
|
311
|
+
*
|
|
312
|
+
* @param {{ scope?: string, text: string, by?: string, now?: number }} input
|
|
313
|
+
* @returns {{ scope: string, text: string, updatedAt: number, bytes: number, exists: boolean }}
|
|
314
|
+
*/
|
|
315
|
+
function write(input = {}) {
|
|
316
|
+
assertAlive('write')
|
|
317
|
+
const scope = normalizeScope(input?.scope, 'write')
|
|
318
|
+
const text = normalizeText(input?.text, 'write')
|
|
319
|
+
const by = normalizeBy(input?.by)
|
|
320
|
+
const at = normalizeNow(input?.now)
|
|
321
|
+
const bytes = byteLength(text)
|
|
322
|
+
|
|
323
|
+
if (bytes > MAX_BYTES) {
|
|
324
|
+
throw new ScratchError(
|
|
325
|
+
`scratch.write:便签 ${scope} 正文 ${bytes} 字节,超过上限 ${MAX_BYTES} 字节(512 KiB)——`
|
|
326
|
+
+ '请拆分到多个 scope 或先精简内容',
|
|
327
|
+
'SCRATCH_TOO_LARGE',
|
|
328
|
+
{ scope, bytes, max: MAX_BYTES },
|
|
329
|
+
)
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
ensureDir()
|
|
333
|
+
const file = fileOf(scope)
|
|
334
|
+
// 临时文件名带 pid + 随机串:多进程/多实例同时写同一 scope 时互不踩踏,
|
|
335
|
+
// 最后 rename 者胜(最后一次写赢,符合"保存"语义);不共享固定 tmp 名是为了
|
|
336
|
+
// 避免 A 进程 rename 掉 B 进程正在写的 tmp(那会丢 B 的整次保存)。
|
|
337
|
+
const tmp = `${file}.tmp-${process.pid}-${randomBytes(4).toString('hex')}`
|
|
338
|
+
|
|
339
|
+
try {
|
|
340
|
+
const fd = openSync(tmp, 'w')
|
|
341
|
+
try {
|
|
342
|
+
writeSync(fd, text, null, 'utf8')
|
|
343
|
+
// fsync 必须在 rename 之前:只 rename 不 fsync,掉电后新名字可能指向空数据。
|
|
344
|
+
fsyncSync(fd)
|
|
345
|
+
} finally {
|
|
346
|
+
closeSync(fd)
|
|
347
|
+
}
|
|
348
|
+
// 调用方给了 now → 把它落到文件 mtime 上,使 read() 的 updatedAt 与
|
|
349
|
+
// write() 的逻辑时间一致(否则测试/审计会看到"刚写完却显示旧时间")。
|
|
350
|
+
// 失败不算致命:内容已写对,mtime 只是元数据,记 warn 继续。
|
|
351
|
+
if (input?.now !== undefined) {
|
|
352
|
+
try {
|
|
353
|
+
utimesSync(tmp, new Date(at), new Date(at))
|
|
354
|
+
} catch (error) {
|
|
355
|
+
warn(`[cs-bots/scratch] 设置 ${file} 的 mtime 失败(${error?.message ?? error})——内容不受影响`)
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
renameSync(tmp, file)
|
|
359
|
+
} catch (error) {
|
|
360
|
+
try { unlinkSync(tmp) } catch { /* 临时文件可能根本没建成;清理失败不掩盖原始错误 */ }
|
|
361
|
+
diagnostics.lastError = String(error?.message ?? error)
|
|
362
|
+
throw new ScratchError(
|
|
363
|
+
`scratch.write:写入 ${file} 失败(${error?.message ?? error})——原便签保持完整未被破坏`,
|
|
364
|
+
'SCRATCH_WRITE_FAILED',
|
|
365
|
+
{ scope, file, tmp, error: String(error?.message ?? error) },
|
|
366
|
+
)
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// 目录 fsync:rename 的**目录项**落盘也需要它,否则断电后可能出现
|
|
370
|
+
// 「文件内容在、但目录里看不到这个名字」。best-effort,失败只 warn。
|
|
371
|
+
try {
|
|
372
|
+
const dfd = openSync(dir, 'r')
|
|
373
|
+
try { fsyncSync(dfd) } finally { closeSync(dfd) }
|
|
374
|
+
} catch (error) {
|
|
375
|
+
warn(`[cs-bots/scratch] fsync 目录 ${dir} 失败(${error?.message ?? error})`)
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
diagnostics.writes += 1
|
|
379
|
+
diagnostics.lastWrite = { scope, by, at, bytes }
|
|
380
|
+
info(`[cs-bots/scratch] 已保存 scope=${scope} bytes=${bytes} by=${by ?? 'unknown'}`)
|
|
381
|
+
|
|
382
|
+
let updatedAt = at
|
|
383
|
+
try {
|
|
384
|
+
updatedAt = Math.round(statSync(file).mtimeMs)
|
|
385
|
+
} catch { /* stat 失败就用逻辑时间;内容已落盘 */ }
|
|
386
|
+
return { scope, text, updatedAt, bytes, exists: true }
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* 列出所有已存在的便签(按 scope 字典序,保证跨平台/跨次调用顺序稳定)。
|
|
391
|
+
*
|
|
392
|
+
* 只认 `<scope>.md`:临时文件(`.md.tmp-<pid>-<rand>`)与别的东西一律忽略,
|
|
393
|
+
* 否则并发写期间的半成品会被误报成"一条便签"。
|
|
394
|
+
*
|
|
395
|
+
* @returns {{ scope: string, updatedAt: number, bytes: number }[]}
|
|
396
|
+
*/
|
|
397
|
+
function listScopes() {
|
|
398
|
+
assertAlive('listScopes')
|
|
399
|
+
diagnostics.listCalls += 1
|
|
400
|
+
let names
|
|
401
|
+
try {
|
|
402
|
+
names = readdirSync(dir)
|
|
403
|
+
} catch (error) {
|
|
404
|
+
if (error?.code === 'ENOENT') return []
|
|
405
|
+
throw new ScratchError(
|
|
406
|
+
`scratch.listScopes:读取目录 ${dir} 失败(${error?.message ?? error})`,
|
|
407
|
+
'SCRATCH_LIST_FAILED',
|
|
408
|
+
{ dir, error: String(error?.message ?? error) },
|
|
409
|
+
)
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const out = []
|
|
413
|
+
for (const name of names) {
|
|
414
|
+
if (typeof name !== 'string' || !name.endsWith(FILE_EXT)) continue
|
|
415
|
+
const scope = name.slice(0, -FILE_EXT.length)
|
|
416
|
+
if (!SCOPE_RE.test(scope)) continue
|
|
417
|
+
try {
|
|
418
|
+
const st = statSync(statePath(dir, name))
|
|
419
|
+
if (!st.isFile()) continue
|
|
420
|
+
out.push({ scope, updatedAt: Math.round(st.mtimeMs), bytes: st.size })
|
|
421
|
+
} catch {
|
|
422
|
+
// 列目录与 stat 之间被删(并发/外部清理)→ 跳过这一条,不炸整个列表
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
out.sort((a, b) => (a.scope < b.scope ? -1 : a.scope > b.scope ? 1 : 0))
|
|
426
|
+
return out
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* 统计(给 /status 与 UI 概览用)。
|
|
431
|
+
* @returns {{ scopes: number, bytes: number, maxBytes: number, dir: string,
|
|
432
|
+
* writes: number, reads: number, lastWrite: object|null, lastError: string|null }}
|
|
433
|
+
*/
|
|
434
|
+
function stats() {
|
|
435
|
+
assertAlive('stats')
|
|
436
|
+
const items = listScopes()
|
|
437
|
+
let bytes = 0
|
|
438
|
+
for (const item of items) bytes += item.bytes
|
|
439
|
+
return {
|
|
440
|
+
scopes: items.length,
|
|
441
|
+
bytes,
|
|
442
|
+
maxBytes: MAX_BYTES,
|
|
443
|
+
dir,
|
|
444
|
+
writes: diagnostics.writes,
|
|
445
|
+
reads: diagnostics.reads,
|
|
446
|
+
lastWrite: clone(diagnostics.lastWrite),
|
|
447
|
+
lastError: diagnostics.lastError,
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* 销毁实例(拒绝后续调用)。
|
|
453
|
+
* 为什么不删文件:便签是用户资产,dispose 只是「这个实例下班了」。
|
|
454
|
+
* @returns {{ disposed: boolean, scopes: number }}
|
|
455
|
+
*/
|
|
456
|
+
function dispose() {
|
|
457
|
+
const scopes = (() => {
|
|
458
|
+
try { return listScopes().length } catch { return 0 }
|
|
459
|
+
})()
|
|
460
|
+
disposed = true
|
|
461
|
+
return { disposed: true, scopes }
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
const service = {
|
|
465
|
+
name: cfg.name ?? 'scratch',
|
|
466
|
+
paths: Object.freeze({ stateDir, dir }),
|
|
467
|
+
read,
|
|
468
|
+
write,
|
|
469
|
+
listScopes,
|
|
470
|
+
stats,
|
|
471
|
+
dispose,
|
|
472
|
+
diagnostics: () => clone(diagnostics),
|
|
473
|
+
}
|
|
474
|
+
return service
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
export default createScratch
|
package/src/settings.js
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import { readJson, writeJsonAtomic, ensureDir, statePath } from './store.js'
|
|
15
|
+
import { resolve, sep, isAbsolute, join } from 'node:path'
|
|
15
16
|
|
|
16
17
|
/** 设置项定义:默认值 + 校验。 */
|
|
17
18
|
export const SETTING_SPECS = Object.freeze({
|
|
@@ -29,8 +30,66 @@ export const SETTING_SPECS = Object.freeze({
|
|
|
29
30
|
memorySplitOnOverflow: { def: true, bool: true, label: '记忆超长自动切分' },
|
|
30
31
|
/** K1 闸门:Bot 会话禁用 ask_user_question。关掉会让投递链有被堵风险。 */
|
|
31
32
|
k1Enabled: { def: true, bool: true, label: 'K1:Bot 禁阻塞式提问' },
|
|
33
|
+
// ── 记忆进化对等配置(2026-11-05,主管要求逐项核对)──
|
|
34
|
+
/** 记忆审查调度:每 N 回合提醒模型做记忆自检。 */
|
|
35
|
+
reviewEnabled: { def: true, bool: true, label: '记忆自审:启用' },
|
|
36
|
+
/** 自审间隔(用户回合数)。 */
|
|
37
|
+
reviewInterval: { def: 10, min: 1, max: 1000, label: '记忆自审间隔(回合)' },
|
|
38
|
+
/** 自审模式:suggest=只提议,auto=自动执行(更安全)。 */
|
|
39
|
+
reviewMode: { def: 'suggest', label: '记忆自审模式', options: ['suggest', 'auto'] },
|
|
40
|
+
/** 技能审查:是否定期检查技能是否过期。 */
|
|
41
|
+
skillReviewEnabled: { def: false, bool: true, label: '技能审查' },
|
|
42
|
+
/** 技能单文件大小上限(字节)。 */
|
|
43
|
+
skillMaxBytes: { def: 65536, min: 1024, max: 512 * 1024, label: '技能大小上限(字节)' },
|
|
44
|
+
/** 记忆条目是否带日期前缀。 */
|
|
45
|
+
entryDatePrefix: { def: true, bool: true, label: '记忆条目带日期前缀' },
|
|
46
|
+
/** 渐进式披露:摘要模式 vs 全文模式。 */
|
|
47
|
+
keyProgressiveDisclosure: { def: 'off', label: '渐进式披露', options: ['off', 'auto', 'on'] },
|
|
48
|
+
/** auto 模式全量注入的条目数阈值。 */
|
|
49
|
+
keyFullInjectThreshold: { def: 3, min: 1, max: 100, label: '全量注入条目数阈值' },
|
|
50
|
+
/** auto 模式全量注入的字符阈值。 */
|
|
51
|
+
keyFullInjectCharLimit: { def: 1500, min: 200, max: 10000, label: '全量注入字符阈值' },
|
|
52
|
+
/** 每回合写入 duty 提醒:是否提醒模型该写记忆了。 */
|
|
53
|
+
perTurnWrites: { def: true, bool: true, label: '每回合写入提醒' },
|
|
54
|
+
/** 写入看门狗:连续 N 轮未写记忆时提醒。 */
|
|
55
|
+
writeGuardThreshold: { def: 2, min: 1, max: 20, label: '写入看门狗阈值(轮)' },
|
|
56
|
+
/** 写入看门狗开关。 */
|
|
57
|
+
perTurnWriteGuard: { def: false, bool: true, label: '写入看门狗' },
|
|
58
|
+
// ── P0-A:WIP 与 Feature Flag ──
|
|
59
|
+
/** 每 Bot 同时 in_progress 任务上限。 */
|
|
60
|
+
wipLimitPerBot: { def: 2, min: 1, max: 10, label: '每 Bot WIP 上限' },
|
|
61
|
+
/** Feature Flag 集合:plain object,每个 value 必须是 boolean。 */
|
|
62
|
+
featureFlags: { def: {}, object: true, label: 'Feature Flags' },
|
|
63
|
+
// ── CS-BOTS 专用工作区(改造06)─────────────────────────────────────────
|
|
64
|
+
/**
|
|
65
|
+
* 新建 Bot 的默认目录:绝对路径,且必须落在 $DSH_HOME 之外(见 validateSetting)。
|
|
66
|
+
*
|
|
67
|
+
* 默认值**不写死**,而是「$DSH_HOME 的兄弟目录 `cs-bots`」——这是为了**通用性**:
|
|
68
|
+
* 插件可能装在任意机器、任意用户下(`/home/alice`、`/Users/bob`、容器里 `/root`),
|
|
69
|
+
* 写死 `/home/ubuntu/...` 会让其他用户的默认工作区指向一个不存在或无权的目录。
|
|
70
|
+
*
|
|
71
|
+
* 为什么是兄弟而不是 $DSH_HOME 内:`roster.js` 的 cwd 校验明确禁止 Bot 工作目录
|
|
72
|
+
* 落在 $DSH_HOME 子树内(那是凭据/会话/记忆库所在,Bot 写进去 = 宿主数据污染)。
|
|
73
|
+
* 兄弟目录既满足「放在主目录附近、一眼能找到」,又不越过那条安全线。
|
|
74
|
+
*
|
|
75
|
+
* 用户可在设置页改成任意绝对路径(只要不在 $DSH_HOME 内)。
|
|
76
|
+
*/
|
|
77
|
+
botWorkspaceRoot: { def: defaultBotWorkspaceRoot(), path: true, label: 'CS-BOTS 专用工作区(新建 Bot 默认目录)' },
|
|
32
78
|
})
|
|
33
79
|
|
|
80
|
+
/**
|
|
81
|
+
* 计算默认的 CS-BOTS 工作区根目录:`<$DSH_HOME 的父目录>/cs-bots`。
|
|
82
|
+
*
|
|
83
|
+
* 之所以暴露成函数:测试与 `GET /workspace` 需要知道「默认应该是哪」,
|
|
84
|
+
* 避免在多处重复同一段 resolve 逻辑导致口径漂移。
|
|
85
|
+
*
|
|
86
|
+
* @returns {string} 归一化绝对路径
|
|
87
|
+
*/
|
|
88
|
+
export function defaultBotWorkspaceRoot() {
|
|
89
|
+
const home = resolve(process.env.DSH_HOME || `${process.env.HOME ?? '.'}/.dsh`)
|
|
90
|
+
return join(resolve(home, '..'), 'cs-bots')
|
|
91
|
+
}
|
|
92
|
+
|
|
34
93
|
/** 默认设置。 */
|
|
35
94
|
export function defaultSettings() {
|
|
36
95
|
const out = {}
|
|
@@ -45,10 +104,48 @@ export function defaultSettings() {
|
|
|
45
104
|
export function validateSetting(key, raw) {
|
|
46
105
|
const spec = SETTING_SPECS[key]
|
|
47
106
|
if (!spec) return { ok: false, reason: `未知设置项:${key}` }
|
|
107
|
+
// options 类型:字符串值,必须在白名单里
|
|
108
|
+
if (spec.options) {
|
|
109
|
+
if (typeof raw !== 'string') return { ok: false, reason: `${key} 必须是字符串` }
|
|
110
|
+
if (!spec.options.includes(raw)) return { ok: false, reason: `${key} 非法:${raw}(可选:${spec.options.join(' | ')})` }
|
|
111
|
+
return { ok: true, value: raw }
|
|
112
|
+
}
|
|
48
113
|
if (spec.bool) {
|
|
49
114
|
if (typeof raw !== 'boolean') return { ok: false, reason: `${key} 必须是布尔值` }
|
|
50
115
|
return { ok: true, value: raw }
|
|
51
116
|
}
|
|
117
|
+
if (spec.object) {
|
|
118
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
119
|
+
return { ok: false, reason: `${key} 必须是 plain object` }
|
|
120
|
+
}
|
|
121
|
+
// 原型链检查:必须是 plain object
|
|
122
|
+
if (Object.getPrototypeOf(raw) !== Object.prototype && Object.getPrototypeOf(raw) !== null) {
|
|
123
|
+
return { ok: false, reason: `${key} 必须是 plain object` }
|
|
124
|
+
}
|
|
125
|
+
for (const [k, v] of Object.entries(raw)) {
|
|
126
|
+
if (typeof v !== 'boolean') {
|
|
127
|
+
return { ok: false, reason: `${key}.${k} 必须是 boolean,得到 ${typeof v}` }
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return { ok: true, value: { ...raw } }
|
|
131
|
+
}
|
|
132
|
+
if (spec.path) {
|
|
133
|
+
// 工作区目录:必须是绝对路径,且不得落在 $DSH_HOME 子树内
|
|
134
|
+
//(那是宿主数据/凭据目录,Bot 写进去就是数据污染 —— 与 roster.js 的 cwd 校验同口径)。
|
|
135
|
+
if (typeof raw !== 'string' || raw.trim() === '') {
|
|
136
|
+
return { ok: false, reason: '工作区必须是非空字符串' }
|
|
137
|
+
}
|
|
138
|
+
const rawTrim = raw.trim()
|
|
139
|
+
if (!isAbsolute(rawTrim)) {
|
|
140
|
+
return { ok: false, reason: '工作区必须是绝对路径' }
|
|
141
|
+
}
|
|
142
|
+
const dir = resolve(rawTrim)
|
|
143
|
+
const home = resolve(process.env.DSH_HOME || `${process.env.HOME ?? '.'}/.dsh`)
|
|
144
|
+
if (dir === home || dir.startsWith(home + sep)) {
|
|
145
|
+
return { ok: false, reason: '工作区必须是 $DSH_HOME 之外的绝对路径' }
|
|
146
|
+
}
|
|
147
|
+
return { ok: true, value: dir }
|
|
148
|
+
}
|
|
52
149
|
const n = Number(raw)
|
|
53
150
|
if (!Number.isFinite(n)) return { ok: false, reason: `${key} 必须是数字` }
|
|
54
151
|
if (n < spec.min || n > spec.max) {
|