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.
- 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/settings.js
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cs-bots /settings —— 平台设置(**持久化、且真正生效**)。
|
|
3
|
+
*
|
|
4
|
+
* 为什么单独一个文件:设置必须是**数据**而不是散落的魔法数。
|
|
5
|
+
* 本文件的键会被 `index.js` 在装配时读取并传给各域服务,因此改设置=改行为
|
|
6
|
+
*(内核级参数需重启 DSH 生效,UI 会明确标注)。
|
|
7
|
+
*
|
|
8
|
+
* 纪律:
|
|
9
|
+
* - **零依赖**:只用 node 内置 + store.js。
|
|
10
|
+
* - **缺文件用默认值**,坏文件**不静默**(返回 diagnostics 让 UI 显示)。
|
|
11
|
+
* - **写入前校验**:越界值一律拒绝并给可读原因(不写半坏配置)。
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { readJson, writeJsonAtomic, ensureDir, statePath } from './store.js'
|
|
15
|
+
|
|
16
|
+
/** 设置项定义:默认值 + 校验。 */
|
|
17
|
+
export const SETTING_SPECS = Object.freeze({
|
|
18
|
+
/** 任务默认租约时长(ms):owner claim 后多久未心跳即视为失联。 */
|
|
19
|
+
defaultLeaseMs: { def: 5 * 60_000, min: 10_000, max: 24 * 60 * 60_000, label: '任务租约默认时长(ms)' },
|
|
20
|
+
/** 唤醒节流窗口(ms):同一目标在窗内最多唤醒一次。 */
|
|
21
|
+
throttleMs: { def: 5_000, min: 0, max: 60 * 60_000, label: '唤醒节流窗口(ms)' },
|
|
22
|
+
/** 每目标唤醒队列上限(背压阈值)。 */
|
|
23
|
+
maxWakesPerTarget: { def: 50, min: 1, max: 1000, label: '每目标唤醒队列上限' },
|
|
24
|
+
/** 唤醒记录保留时长(ms)。 */
|
|
25
|
+
wakeTtlMs: { def: 24 * 60 * 60_000, min: 60_000, max: 30 * 24 * 60 * 60_000, label: '唤醒记录保留(ms)' },
|
|
26
|
+
/** 记忆单条上限(码点)。600 的依据:实测真实条目最长 826,ds-bot 的 300 会全拒收。 */
|
|
27
|
+
memoryEntryMax: { def: 600, min: 100, max: 10_000, label: '记忆单条上限(字符)' },
|
|
28
|
+
/** 超过该字符数的记忆条目在写入时自动切分(而非拒收)。 */
|
|
29
|
+
memorySplitOnOverflow: { def: true, bool: true, label: '记忆超长自动切分' },
|
|
30
|
+
/** K1 闸门:Bot 会话禁用 ask_user_question。关掉会让投递链有被堵风险。 */
|
|
31
|
+
k1Enabled: { def: true, bool: true, label: 'K1:Bot 禁阻塞式提问' },
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
/** 默认设置。 */
|
|
35
|
+
export function defaultSettings() {
|
|
36
|
+
const out = {}
|
|
37
|
+
for (const [key, spec] of Object.entries(SETTING_SPECS)) out[key] = spec.def
|
|
38
|
+
return out
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 校验单项设置。
|
|
43
|
+
* @returns {{ ok: true, value: unknown } | { ok: false, reason: string }}
|
|
44
|
+
*/
|
|
45
|
+
export function validateSetting(key, raw) {
|
|
46
|
+
const spec = SETTING_SPECS[key]
|
|
47
|
+
if (!spec) return { ok: false, reason: `未知设置项:${key}` }
|
|
48
|
+
if (spec.bool) {
|
|
49
|
+
if (typeof raw !== 'boolean') return { ok: false, reason: `${key} 必须是布尔值` }
|
|
50
|
+
return { ok: true, value: raw }
|
|
51
|
+
}
|
|
52
|
+
const n = Number(raw)
|
|
53
|
+
if (!Number.isFinite(n)) return { ok: false, reason: `${key} 必须是数字` }
|
|
54
|
+
if (n < spec.min || n > spec.max) {
|
|
55
|
+
return { ok: false, reason: `${key} 越界:${n} 不在 [${spec.min}, ${spec.max}]` }
|
|
56
|
+
}
|
|
57
|
+
return { ok: true, value: n }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* 读取设置(缺文件/坏文件都退回默认值,并把诊断暴露出来)。
|
|
62
|
+
* @param {string} stateDir
|
|
63
|
+
* @returns {{ values: object, diagnostics: object }}
|
|
64
|
+
*/
|
|
65
|
+
export function loadSettings(stateDir) {
|
|
66
|
+
const file = statePath(stateDir, 'settings.json')
|
|
67
|
+
const stored = readJson(file, undefined)
|
|
68
|
+
const values = defaultSettings()
|
|
69
|
+
const diagnostics = { file, source: 'default', badKeys: [] }
|
|
70
|
+
if (stored && typeof stored === 'object') {
|
|
71
|
+
diagnostics.source = 'file'
|
|
72
|
+
for (const [key, raw] of Object.entries(stored)) {
|
|
73
|
+
const result = validateSetting(key, raw)
|
|
74
|
+
if (result.ok) values[key] = result.value
|
|
75
|
+
else diagnostics.badKeys.push({ key, raw, reason: result.reason })
|
|
76
|
+
}
|
|
77
|
+
} else if (stored !== undefined) {
|
|
78
|
+
diagnostics.source = 'invalid'
|
|
79
|
+
}
|
|
80
|
+
return { values, diagnostics }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* 保存设置(只写校验过的键)。
|
|
85
|
+
* @param {string} stateDir
|
|
86
|
+
* @param {object} patch
|
|
87
|
+
* @returns {{ ok: true, values: object } | { ok: false, reason: string }}
|
|
88
|
+
*/
|
|
89
|
+
export function saveSettings(stateDir, patch) {
|
|
90
|
+
if (!patch || typeof patch !== 'object') return { ok: false, reason: 'patch 必须是对象' }
|
|
91
|
+
const rejected = []
|
|
92
|
+
const accepted = {}
|
|
93
|
+
for (const [key, raw] of Object.entries(patch)) {
|
|
94
|
+
const result = validateSetting(key, raw)
|
|
95
|
+
if (result.ok) accepted[key] = result.value
|
|
96
|
+
else rejected.push(result.reason)
|
|
97
|
+
}
|
|
98
|
+
if (rejected.length) return { ok: false, reason: rejected.join(';') }
|
|
99
|
+
|
|
100
|
+
if (!ensureDir(stateDir)) return { ok: false, reason: `状态目录不可写:${stateDir}` }
|
|
101
|
+
const { values } = loadSettings(stateDir)
|
|
102
|
+
const next = { ...values, ...accepted }
|
|
103
|
+
if (!writeJsonAtomic(statePath(stateDir, 'settings.json'), next)) {
|
|
104
|
+
return { ok: false, reason: '写入 settings.json 失败' }
|
|
105
|
+
}
|
|
106
|
+
return { ok: true, values: next }
|
|
107
|
+
}
|
package/src/store.js
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cs-bots 存储层 —— append-only JSONL + 原子快照。
|
|
3
|
+
*
|
|
4
|
+
* 纪律(依据实测踩坑,见 `11-融合最终稿-v1.md`):
|
|
5
|
+
* - **按文件性质分选写策略**(K3):状态/快照类用 `tmp → rename` 原子替换;
|
|
6
|
+
* 高频追加类**就地 append**(DS-BOT `resume.js:117-119` 原文:Windows 上
|
|
7
|
+
* rename 会撞读,且撕裂写只丢一次重启能捡回的东西)。
|
|
8
|
+
* - **单写者锁三段式**(照搬 DS-BOT 已验证做法):认领失败降级 warn 不阻塞、
|
|
9
|
+
* 易主检测转只读、写前校验归属。
|
|
10
|
+
* - **绝不静默失败**:任何丢弃都要能被调用方看见。
|
|
11
|
+
*
|
|
12
|
+
* 本文件不 import 任何 `@deepseek-ai/*`(零依赖 CI 检查 1 覆盖)。
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { mkdirSync, readFileSync, writeFileSync, renameSync, appendFileSync, existsSync, rmSync, statSync } from 'node:fs'
|
|
16
|
+
import { dirname, join } from 'node:path'
|
|
17
|
+
|
|
18
|
+
/** 确保目录存在。 */
|
|
19
|
+
export function ensureDir(dir) {
|
|
20
|
+
try {
|
|
21
|
+
mkdirSync(dir, { recursive: true })
|
|
22
|
+
return true
|
|
23
|
+
} catch {
|
|
24
|
+
return false
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** 读 JSON(失败返回 fallback,不抛)。 */
|
|
29
|
+
export function readJson(file, fallback = undefined) {
|
|
30
|
+
try {
|
|
31
|
+
if (!existsSync(file)) return fallback
|
|
32
|
+
const text = readFileSync(file, 'utf8').trim()
|
|
33
|
+
if (!text) return fallback
|
|
34
|
+
return JSON.parse(text)
|
|
35
|
+
} catch {
|
|
36
|
+
return fallback
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 原子写 JSON:`<file>.tmp-<pid>` → rename。
|
|
42
|
+
* 适用**状态/快照类**(半截 JSON 不可接受)。高频追加类请用 `appendJsonl`。
|
|
43
|
+
* @returns {boolean} 是否成功
|
|
44
|
+
*/
|
|
45
|
+
export function writeJsonAtomic(file, value) {
|
|
46
|
+
try {
|
|
47
|
+
ensureDir(dirname(file))
|
|
48
|
+
const tmp = `${file}.tmp-${process.pid}`
|
|
49
|
+
writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, 'utf8')
|
|
50
|
+
renameSync(tmp, file)
|
|
51
|
+
return true
|
|
52
|
+
} catch {
|
|
53
|
+
return false
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* 就地在文件尾追加一行 JSON(**不是**原子重写)。
|
|
59
|
+
* 适用高频追加类日志:`rooms/*.log.jsonl` / `board.jsonl` / `wakes.jsonl`。
|
|
60
|
+
* @returns {boolean} 是否成功
|
|
61
|
+
*/
|
|
62
|
+
export function appendJsonl(file, value) {
|
|
63
|
+
try {
|
|
64
|
+
ensureDir(dirname(file))
|
|
65
|
+
appendFileSync(file, `${JSON.stringify(value)}\n`, 'utf8')
|
|
66
|
+
return true
|
|
67
|
+
} catch {
|
|
68
|
+
return false
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* 读 JSONL 全部行。坏行**不静默丢弃**:计入 `bad` 返回给调用方。
|
|
74
|
+
* @param {string} file
|
|
75
|
+
* @param {{ limit?: number }} [options] 只取最后 limit 行(limit 为 0/undefined 表示不限)
|
|
76
|
+
* @returns {{ rows: object[], bad: number, total: number }}
|
|
77
|
+
*/
|
|
78
|
+
export function readJsonl(file, options = {}) {
|
|
79
|
+
const result = { rows: [], bad: 0, total: 0 }
|
|
80
|
+
try {
|
|
81
|
+
if (!existsSync(file)) return result
|
|
82
|
+
let lines = readFileSync(file, 'utf8').split('\n')
|
|
83
|
+
// 末尾空行不算行
|
|
84
|
+
if (lines.length && lines[lines.length - 1] === '') lines.pop()
|
|
85
|
+
result.total = lines.length
|
|
86
|
+
if (options.limit && options.limit > 0 && lines.length > options.limit) {
|
|
87
|
+
lines = lines.slice(-options.limit)
|
|
88
|
+
}
|
|
89
|
+
for (const line of lines) {
|
|
90
|
+
if (!line.trim()) continue
|
|
91
|
+
try {
|
|
92
|
+
result.rows.push(JSON.parse(line))
|
|
93
|
+
} catch {
|
|
94
|
+
result.bad += 1
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
} catch {
|
|
98
|
+
result.bad += 1
|
|
99
|
+
}
|
|
100
|
+
return result
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ── 单写者锁(三段式,照搬 DS-BOT 已验证做法)────────────────────────────────
|
|
104
|
+
|
|
105
|
+
/** 锁判死阈值(ms):超过则视为上一个持有者已死。 */
|
|
106
|
+
const LOCK_STALE_MS = 60_000
|
|
107
|
+
/** 心跳间隔(ms)。 */
|
|
108
|
+
const LOCK_HEARTBEAT_MS = 15_000
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* 认领一个单写者锁。
|
|
112
|
+
*
|
|
113
|
+
* 三段式之一:**认领失败不阻塞**(DS-BOT `store.js:45-56` 原文
|
|
114
|
+
* "An unusable lock must not stop the team")→ 返回 `{ owns: false }`,
|
|
115
|
+
* 调用方降级为只读并 warn,而不是拒绝启动。
|
|
116
|
+
*
|
|
117
|
+
* @param {string} lockFile
|
|
118
|
+
* @returns {{ owns: boolean, token: string, reason?: string }}
|
|
119
|
+
*/
|
|
120
|
+
export function claimLock(lockFile) {
|
|
121
|
+
const token = `${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
|
|
122
|
+
try {
|
|
123
|
+
ensureDir(dirname(lockFile))
|
|
124
|
+
if (existsSync(lockFile)) {
|
|
125
|
+
const current = readJson(lockFile, null)
|
|
126
|
+
if (current && typeof current.at === 'number' && Date.now() - current.at < LOCK_STALE_MS) {
|
|
127
|
+
// 锁仍新 → 不抢(别人在写)
|
|
128
|
+
return { owns: false, token, reason: 'held-by-other' }
|
|
129
|
+
}
|
|
130
|
+
// 锁已过期 → 视为上一个持有者已死,接管
|
|
131
|
+
}
|
|
132
|
+
writeJsonAtomic(lockFile, { token, at: Date.now(), pid: process.pid })
|
|
133
|
+
return { owns: true, token }
|
|
134
|
+
} catch (error) {
|
|
135
|
+
return { owns: false, token, reason: `claim-failed:${error?.message ?? error}` }
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* 校验锁是否仍归本 token 所有(三段式之三:**写前校验,失败即抛错不静默写**)。
|
|
141
|
+
* @param {string} lockFile
|
|
142
|
+
* @param {string} token
|
|
143
|
+
* @returns {boolean}
|
|
144
|
+
*/
|
|
145
|
+
export function ownsLock(lockFile, token) {
|
|
146
|
+
const current = readJson(lockFile, null)
|
|
147
|
+
return Boolean(current && current.token === token)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* 续期心跳(三段式之二的数据基础)。
|
|
152
|
+
* @param {string} lockFile
|
|
153
|
+
* @param {string} token
|
|
154
|
+
* @returns {boolean}
|
|
155
|
+
*/
|
|
156
|
+
export function heartbeatLock(lockFile, token) {
|
|
157
|
+
if (!ownsLock(lockFile, token)) return false
|
|
158
|
+
return writeJsonAtomic(lockFile, { token, at: Date.now(), pid: process.pid })
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** 释放锁(仅当仍归本 token)。 */
|
|
162
|
+
export function releaseLock(lockFile, token) {
|
|
163
|
+
try {
|
|
164
|
+
if (!ownsLock(lockFile, token)) return false
|
|
165
|
+
rmSync(lockFile, { force: true })
|
|
166
|
+
return true
|
|
167
|
+
} catch {
|
|
168
|
+
return false
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ── 幂等键 ──────────────────────────────────────────────────────────────────
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* 幂等去重器(内存 + 可选落盘快照)。
|
|
176
|
+
* 用于「同一逻辑消息重投不改语义」——避免二次副作用。
|
|
177
|
+
*/
|
|
178
|
+
export class IdempotencySet {
|
|
179
|
+
/** @param {number} [max] 内存中保留的键上限(LRU 近似:超限丢最旧) */
|
|
180
|
+
constructor(max = 10_000) {
|
|
181
|
+
this.max = max
|
|
182
|
+
/** @type {Set<string>} */
|
|
183
|
+
this.keys = new Set()
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** @returns {boolean} true=首次见到(可执行);false=重复(应跳过) */
|
|
187
|
+
check(key) {
|
|
188
|
+
if (!key) return true
|
|
189
|
+
if (this.keys.has(key)) return false
|
|
190
|
+
this.keys.add(key)
|
|
191
|
+
if (this.keys.size > this.max) {
|
|
192
|
+
const first = this.keys.values().next().value
|
|
193
|
+
this.keys.delete(first)
|
|
194
|
+
}
|
|
195
|
+
return true
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
has(key) {
|
|
199
|
+
return this.keys.has(key)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** 从 JSONL 事件流重建(重启后去重仍然有效)。 */
|
|
203
|
+
rebuildFrom(rows, keyField = 'dedupKey') {
|
|
204
|
+
for (const row of rows ?? []) {
|
|
205
|
+
const k = row?.[keyField]
|
|
206
|
+
if (k) this.keys.add(k)
|
|
207
|
+
}
|
|
208
|
+
return this.keys.size
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ── 单调序号 ────────────────────────────────────────────────────────────────
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* 从 JSONL 文件取下一个单调序号(`seq`),用于房间消息保序与断点续传。
|
|
216
|
+
* 空文件从 1 开始。
|
|
217
|
+
* @param {string} file
|
|
218
|
+
* @param {string} [field]
|
|
219
|
+
* @returns {number}
|
|
220
|
+
*/
|
|
221
|
+
export function nextSeq(file, field = 'seq') {
|
|
222
|
+
const { rows } = readJsonl(file)
|
|
223
|
+
let max = 0
|
|
224
|
+
for (const row of rows) {
|
|
225
|
+
const v = row?.[field]
|
|
226
|
+
if (typeof v === 'number' && v > max) max = v
|
|
227
|
+
}
|
|
228
|
+
return max + 1
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** 让 `statSync` 可被外部按需使用(避免调用方再 import node:fs)。 */
|
|
232
|
+
export function fileMtime(file) {
|
|
233
|
+
try {
|
|
234
|
+
return statSync(file).mtimeMs
|
|
235
|
+
} catch {
|
|
236
|
+
return undefined
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** 拼平台状态目录下的路径。 */
|
|
241
|
+
export function statePath(dir, ...parts) {
|
|
242
|
+
return join(dir, ...parts)
|
|
243
|
+
}
|