cs-bots 0.1.1 → 0.7.5

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.
Files changed (48) hide show
  1. package/CHANGELOG.md +539 -0
  2. package/README.md +65 -10
  3. package/docs/01-/346/236/266/346/236/204/346/226/207/346/241/243.md +364 -0
  4. package/docs/02-/351/234/200/346/261/202/350/247/204/346/240/274/344/271/246.md +284 -0
  5. package/docs/03-/350/257/246/347/273/206/350/256/276/350/256/241.md +567 -0
  6. package/docs/04-/346/223/215/344/275/234/346/211/213/345/206/214.md +569 -0
  7. package/docs/ARCHITECTURE.md +277 -0
  8. 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
  9. 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
  10. 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
  11. 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
  12. package/docs/steps/step-04-/344/270/212/347/272/277/344/270/216/351/252/214/350/257/201.md +46 -0
  13. package/docs//344/272/244/344/273/230/350/257/264/346/230/216-v0.2.0.md +95 -0
  14. package/docs//346/223/215/344/275/234/346/211/213/345/206/214-v1.md +509 -0
  15. package/docs//350/277/201/347/247/273/346/226/271/346/241/210-v1.md +64 -0
  16. package/docs//351/225/277/346/234/237/350/256/260/345/277/206/346/226/271/346/241/210-v1.md +85 -0
  17. package/package.json +4 -2
  18. package/src/admission.js +365 -0
  19. package/src/api.js +4097 -420
  20. package/src/board.js +883 -14
  21. package/src/botprofile.js +190 -3
  22. package/src/client.js +6596 -1082
  23. package/src/cycle.js +516 -0
  24. package/src/delivery-watch.js +255 -0
  25. package/src/dirbrowse.js +20 -0
  26. package/src/discipline.js +1188 -0
  27. package/src/fence.js +142 -0
  28. package/src/index.js +387 -4
  29. package/src/kernel.js +636 -24
  30. package/src/library.js +1224 -11
  31. package/src/memory.js +617 -37
  32. package/src/metrics.js +339 -0
  33. package/src/prompts.js +1318 -0
  34. package/src/reviewer.js +709 -0
  35. package/src/room.js +655 -14
  36. package/src/roster.js +346 -26
  37. package/src/routing/complexity.js +320 -0
  38. package/src/routing/hints.js +707 -0
  39. package/src/scratch.js +477 -0
  40. package/src/settings.js +97 -0
  41. package/src/stage.js +263 -0
  42. package/src/store.js +133 -1
  43. package/src/subscription.js +571 -0
  44. package/src/suggest.js +1203 -0
  45. package/src/teams.js +527 -0
  46. package/src/todo.js +1301 -0
  47. package/src/tools.js +734 -10
  48. package/src/verify.js +356 -0
package/src/metrics.js ADDED
@@ -0,0 +1,339 @@
1
+ /**
2
+ * cs-bots MetricsService —— PDCA 测量面(Check 的数据基础)。
3
+ *
4
+ * 为什么需要它:PDCA 的「持续优化」没有测量就是空话。平台此前只有任务状态
5
+ * (status/rev),没有「周期过了多久 / 驳回率多少 / 吞吐多少」这类指标。
6
+ * 本模块是**纯被动记账**:任何动作(claim/complete/accept/review…)记一条,
7
+ * 聚合交给 `aggregate()`。不做定时器、不主动采集 —— 与 reviewer.js 同款哲学。
8
+ *
9
+ * 不变量(照抄 suggest.js / board.js 纪律):
10
+ * 1. **事件流是真相源**:先 append jsonl(失败即抛,内存回滚),再改内存,
11
+ * 最后写快照(失败只 warn + 计数,因为事件流已落盘,replay 可恢复)。
12
+ * 2. **只追加,绝不改写历史**。
13
+ * 3. **绝不静默失败**。
14
+ *
15
+ * 依赖面:node 内置 + `./store.js` + `./kernel.js`(惰性桥)。零第三方依赖。
16
+ */
17
+
18
+ import { appendJsonl, readJsonl, statePath, writeJsonAtomic } from './store.js'
19
+
20
+ // ── kernel 惰性桥(照抄 suggest.js:静态 import 会让裸检出连带失败)────────────
21
+ /** @type {object | null} */
22
+ let kernel = null
23
+ /** @type {unknown} */
24
+ let kernelError = null
25
+ try {
26
+ kernel = await import('./kernel.js')
27
+ } catch (error) {
28
+ kernelError = error
29
+ }
30
+
31
+ /** 合法指标种类。新增种类必须同步加测试。 */
32
+ export const METRIC_KINDS = Object.freeze([
33
+ 'cycle_start', 'cycle_complete', 'review_proposed', 'review_done',
34
+ 'accept_pass', 'accept_reject', 'task_claim', 'task_complete', 'task_blocked',
35
+ 'lead_create', 'bot_create', 'room_create', 'turn_end',
36
+ ])
37
+
38
+ /** 结构化错误:所有失败都走它,绝不静默。 */
39
+ export class MetricsError extends Error {
40
+ constructor(message, code = 'METRICS_ERROR', details) {
41
+ super(message)
42
+ this.name = 'MetricsError'
43
+ this.code = code
44
+ if (details !== undefined) this.details = details
45
+ }
46
+ }
47
+
48
+ /** 深拷贝 + JSON 归一(去掉 undefined 键,保证内存态与磁盘态逐字相等)。 */
49
+ function clone(value) {
50
+ if (value === undefined || value === null) return value
51
+ return JSON.parse(JSON.stringify(value))
52
+ }
53
+
54
+ /** `now` 归一:缺省当前时间;显式必须是有限数字。 */
55
+ function normalizeNow(now, where) {
56
+ if (now === undefined || now === null) return Date.now()
57
+ if (typeof now !== 'number' || !Number.isFinite(now)) {
58
+ throw new MetricsError(
59
+ `metrics.${where}:now 必须是有限数字(毫秒时间戳),收到 ${JSON.stringify(now)}`,
60
+ 'METRICS_BAD_FIELD',
61
+ { where, field: 'now' },
62
+ )
63
+ }
64
+ return now
65
+ }
66
+
67
+ /** 必填字符串(trim 后非空)。 */
68
+ function requireString(value, field, where) {
69
+ if (typeof value !== 'string' || value.trim() === '') {
70
+ throw new MetricsError(
71
+ `metrics.${where}:${field} 必须是非空字符串,收到 ${JSON.stringify(value)}`,
72
+ 'METRICS_BAD_FIELD',
73
+ { where, field },
74
+ )
75
+ }
76
+ return value.trim()
77
+ }
78
+
79
+ /**
80
+ * 解析状态目录:`config.stateDir` > `kernel.stateDir()` > 抛错。
81
+ * **不做静默兜底**(与 board.js 同一纪律):兜到临时目录会让数据重启即丢。
82
+ */
83
+ function resolveStateDir(config) {
84
+ if (typeof config?.stateDir === 'string' && config.stateDir.trim() !== '') {
85
+ return config.stateDir.trim()
86
+ }
87
+ if (kernel && typeof kernel.stateDir === 'function') return kernel.stateDir()
88
+ const reason = kernelError?.message ?? 'kernel.stateDir 不存在'
89
+ throw new MetricsError(
90
+ `metrics.createMetrics:既未提供 config.stateDir,也无法从 kernel.js 取得 stateDir(${reason})。`
91
+ + '裸检出(无宿主包)下必须显式传 config.stateDir。',
92
+ 'METRICS_NO_STATE_DIR',
93
+ )
94
+ }
95
+
96
+ /** `(ctx, config)` 归一,允许单参数简写(与 suggest.js 同款)。 */
97
+ function splitArgs(ctx, config) {
98
+ const cfgGiven = config && typeof config === 'object' && Object.keys(config).length > 0
99
+ if (cfgGiven) return { context: ctx ?? {}, config }
100
+ if (ctx && typeof ctx === 'object' && typeof ctx.stateDir === 'string') return { context: {}, config: ctx }
101
+ return { context: ctx ?? {}, config: config ?? {} }
102
+ }
103
+
104
+ /**
105
+ * 创建测量服务实例。
106
+ *
107
+ * @param {{ logger?: { info?: Function, warn?: Function } }} [ctx] 宿主上下文(只用于日志)
108
+ * @param {{ stateDir?: string, name?: string }} [config]
109
+ * @returns {object} metrics service
110
+ */
111
+ export function createMetrics(ctx, config = {}) {
112
+ const { context, config: cfg } = splitArgs(ctx, config)
113
+ const stateDir = resolveStateDir(cfg)
114
+
115
+ const jsonlPath = statePath(stateDir, 'metrics.jsonl')
116
+ const snapPath = statePath(stateDir, 'metrics.snap.json')
117
+
118
+ const info = (...args) => { try { context?.logger?.info?.(...args) } catch { /* 日志失败不影响业务 */ } }
119
+ const warn = (...args) => { try { context?.logger?.warn?.(...args) } catch { /* 同上 */ } }
120
+
121
+ /** @type {Map<string, object>} id → 记录 */
122
+ const entries = new Map()
123
+ /** @type {object[]} 全部事件(审计流) */
124
+ const events = []
125
+ let idCounter = 0
126
+ let disposed = false
127
+
128
+ const diagnostics = {
129
+ stateDir,
130
+ jsonlPath,
131
+ snapPath,
132
+ kernel: kernel ? 'loaded' : `load-failed:${kernelError?.name ?? 'unknown'}`,
133
+ hydratedFrom: /** @type {null|'jsonl'|'empty'} */ (null),
134
+ replayedEvents: 0,
135
+ badLines: 0,
136
+ snapshotWriteFailures: 0,
137
+ lastReplayAt: /** @type {number|undefined} */ (undefined),
138
+ }
139
+
140
+ /** 已销毁实例的调用一律抛错(静默返回空数据比崩掉更难查)。 */
141
+ function assertAlive(where) {
142
+ if (disposed) {
143
+ throw new MetricsError(
144
+ `metrics.${where}:实例已 dispose(),请重新 createMetrics()(拒绝在已销毁实例上静默返回空数据)`,
145
+ 'METRICS_DISPOSED',
146
+ { where },
147
+ )
148
+ }
149
+ }
150
+
151
+ /**
152
+ * 提交一次变更:**先落事件,再改内存**。顺序不可颠倒。
153
+ */
154
+ function commit(action, at, by, before, after) {
155
+ if (!appendJsonl(jsonlPath, { id: after.id, rev: after.rev, action, by, at, before, after })) {
156
+ throw new MetricsError(
157
+ `metrics.${action}:事件追加失败(${jsonlPath})—— 拒绝「只改内存不落盘」`,
158
+ 'METRICS_APPEND_FAILED',
159
+ { action },
160
+ )
161
+ }
162
+ entries.set(after.id, clone(after))
163
+ events.push({
164
+ id: after.id, rev: after.rev, action, by, at,
165
+ before: before ? clone(before) : null,
166
+ after: clone(after),
167
+ })
168
+ if (!writeJsonAtomic(snapPath, snapshot())) {
169
+ diagnostics.snapshotWriteFailures += 1
170
+ warn(`[cs-bots/metrics] 快照写入失败(${snapPath})——事件流已落盘,replay() 可恢复`)
171
+ }
172
+ }
173
+
174
+ function snapshot() {
175
+ return { version: 1, savedAt: Date.now(), entries: [...entries.values()] }
176
+ }
177
+
178
+ /** 从 `metrics.jsonl` 重建内存态;坏行**不静默丢弃**。 */
179
+ function hydrate() {
180
+ entries.clear()
181
+ events.length = 0
182
+ idCounter = 0
183
+ const { rows, bad } = readJsonl(jsonlPath)
184
+ diagnostics.badLines = bad
185
+ diagnostics.replayedEvents = rows.length
186
+ if (bad > 0) {
187
+ warn(`[cs-bots/metrics] ${jsonlPath} 有 ${bad} 行无法解析(撕裂写或文件损坏)——已跳过,请核对`)
188
+ }
189
+ if (rows.length === 0) {
190
+ diagnostics.hydratedFrom = 'empty'
191
+ return
192
+ }
193
+ let skipped = 0
194
+ for (const ev of rows) {
195
+ if (!ev || typeof ev.id !== 'string') { skipped += 1; continue }
196
+ if (!ev.after || typeof ev.after !== 'object') { skipped += 1; continue }
197
+ const current = entries.get(ev.id)
198
+ if (current && Number.isFinite(ev.rev) && ev.rev <= current.rev) {
199
+ // 同 rev 重放幂等(容忍重复投递)
200
+ skipped += 1
201
+ continue
202
+ }
203
+ entries.set(ev.id, clone(ev.after))
204
+ events.push(clone(ev))
205
+ const m = /^m-(\d+)$/.exec(ev.id)
206
+ if (m) idCounter = Math.max(idCounter, Number(m[1]))
207
+ }
208
+ if (skipped > 0) {
209
+ warn(`[cs-bots/metrics] 回放跳过 ${skipped} 条非递增/残缺事件(已保留在文件中,未被静默丢弃)`)
210
+ }
211
+ diagnostics.hydratedFrom = 'jsonl'
212
+ }
213
+
214
+ // ── API:写 ────────────────────────────────────────────────────────────────
215
+
216
+ /**
217
+ * 记一条指标。
218
+ * @param {{ kind: string, taskId?: string, cycleId?: string, sessionId?: string,
219
+ * value?: number, labels?: object, by: string, now?: number, source?: string }} input
220
+ * @returns {object} 完整记录
221
+ */
222
+ function record(input = {}) {
223
+ assertAlive('record')
224
+ const { kind, taskId, cycleId, sessionId, value, labels, by, now, source } = input ?? {}
225
+ if (!METRIC_KINDS.includes(kind)) {
226
+ throw new MetricsError(
227
+ `metrics.record:kind=${JSON.stringify(kind)} 非法(合法:${METRIC_KINDS.join(' | ')})`,
228
+ 'METRICS_BAD_KIND',
229
+ { kind },
230
+ )
231
+ }
232
+ const author = requireString(by, 'by', 'record')
233
+ const at = normalizeNow(now, 'record')
234
+
235
+ let val = null
236
+ if (value !== undefined && value !== null) {
237
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
238
+ throw new MetricsError(
239
+ `metrics.record:value 必须是有限数字,收到 ${JSON.stringify(value)}`,
240
+ 'METRICS_BAD_FIELD',
241
+ { field: 'value' },
242
+ )
243
+ }
244
+ val = value
245
+ }
246
+ const lab = labels !== undefined && labels !== null ? clone(labels) : null
247
+
248
+ idCounter += 1
249
+ const id = `m-${idCounter}`
250
+ /** @type {Record<string, unknown>} */
251
+ const rec = {
252
+ id,
253
+ rev: 1,
254
+ kind,
255
+ taskId: taskId ?? null,
256
+ cycleId: cycleId ?? null,
257
+ sessionId: sessionId ?? null,
258
+ value: val,
259
+ labels: lab,
260
+ source: source ?? 'model',
261
+ at,
262
+ by: author,
263
+ createdAt: at,
264
+ updatedAt: at,
265
+ }
266
+ commit('record', at, author, null, rec)
267
+ info(`[cs-bots/metrics] 记录 ${id}(${kind})by ${author}`)
268
+ return clone(rec)
269
+ }
270
+
271
+ // ── API:读 ────────────────────────────────────────────────────────────────
272
+
273
+ /** 按条件取记录(at 升序)。 */
274
+ function series(query = {}) {
275
+ assertAlive('series')
276
+ const { kind, taskId, since, until, limit } = query ?? {}
277
+ let rows = [...entries.values()]
278
+ if (kind !== undefined && kind !== null) rows = rows.filter((r) => r.kind === kind)
279
+ if (taskId !== undefined && taskId !== null) rows = rows.filter((r) => r.taskId === taskId)
280
+ if (since !== undefined && since !== null) rows = rows.filter((r) => r.at >= since)
281
+ if (until !== undefined && until !== null) rows = rows.filter((r) => r.at <= until)
282
+ rows.sort((a, b) => a.at - b.at || (a.id < b.id ? -1 : 1))
283
+ if (Number.isInteger(limit) && limit > 0) rows = rows.slice(-limit)
284
+ return clone(rows)
285
+ }
286
+
287
+ /** 聚合:按 kind 计数 + 求和 + 首末时间。 */
288
+ function aggregate(query = {}) {
289
+ assertAlive('aggregate')
290
+ const { kind, since } = query ?? {}
291
+ const byKind = {}
292
+ let total = 0
293
+ let sum = 0
294
+ let firstAt = null
295
+ let lastAt = null
296
+ for (const r of entries.values()) {
297
+ if (kind !== undefined && kind !== null && r.kind !== kind) continue
298
+ if (since !== undefined && since !== null && r.at < since) continue
299
+ byKind[r.kind] = (byKind[r.kind] ?? 0) + 1
300
+ total += 1
301
+ if (r.value !== null && r.value !== undefined) sum += r.value
302
+ if (firstAt === null || r.at < firstAt) firstAt = r.at
303
+ if (lastAt === null || r.at > lastAt) lastAt = r.at
304
+ }
305
+ return { byKind, total, sum, firstAt, lastAt }
306
+ }
307
+
308
+ function stats() {
309
+ const byKind = {}
310
+ for (const r of entries.values()) byKind[r.kind] = (byKind[r.kind] ?? 0) + 1
311
+ return { total: entries.size, byKind, events: events.length, diagnostics: clone(diagnostics) }
312
+ }
313
+
314
+ function replay() {
315
+ hydrate()
316
+ diagnostics.lastReplayAt = Date.now()
317
+ return { from: diagnostics.hydratedFrom, events: events.length, bad: diagnostics.badLines }
318
+ }
319
+
320
+ function dispose() { disposed = true }
321
+
322
+ /** 首次构造即回放,保证重启后马上可用。 */
323
+ hydrate()
324
+
325
+ const service = {
326
+ name: cfg.name ?? 'metrics',
327
+ paths: Object.freeze({ stateDir, jsonl: jsonlPath, snapshot: snapPath }),
328
+ record,
329
+ series,
330
+ aggregate,
331
+ stats,
332
+ replay,
333
+ diagnostics: () => clone(diagnostics),
334
+ dispose,
335
+ }
336
+ return service
337
+ }
338
+
339
+ export default createMetrics