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/suggest.js
ADDED
|
@@ -0,0 +1,1203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cs-bots SuggestService —— 建议确认队列(记忆进化的**命门**,DESIGN-v2 §「建议确认制」)。
|
|
3
|
+
*
|
|
4
|
+
* ── 为什么必须存在 ──────────────────────────────────────────────────────────
|
|
5
|
+
* `memory.review()` 碰到 `explicit:true` 条目只会返回 `{ action:'suggest', ... }`,
|
|
6
|
+
* 那个返回值**刷新即丢**:没有队列、没有持久化、没有「人确认」这一步。
|
|
7
|
+
* 而「模型不能直接改长期记忆,只能提建议,人确认后才生效」正是记忆进化最核心的一条。
|
|
8
|
+
* 少了它,模型的一次幻觉就能直接污染人显式写下的偏好/人设 —— 这是不可接受的。
|
|
9
|
+
*
|
|
10
|
+
* 所以本模块把「模型想改」与「真的改」**拆成两步**:
|
|
11
|
+
*
|
|
12
|
+
* 模型/审查器 suggest() → 落盘 pending(带 reason,人看得懂为什么)
|
|
13
|
+
* → 人在 UI 里看理由、看目标层
|
|
14
|
+
* → accept({ id, apply }) 才真正入库
|
|
15
|
+
*
|
|
16
|
+
* ── 为什么**不 import** `memory.js` / `library.js` ──────────────────────────
|
|
17
|
+
* 三条理由,缺一条都会让这个模块变脆:
|
|
18
|
+
* ① **避免循环依赖**:memory.js / library.js 将来若要「发现值得建议的改动」,
|
|
19
|
+
* 它们会想调本模块;本模块再 import 它们就成环。ESM 的环会带来 TDZ 问题,
|
|
20
|
+
* 在 `type:module` 下表现为「某些情况下 undefined is not a function」这种难查故障。
|
|
21
|
+
* ② **写入边界**:本模块只许写自己的 `suggestions.jsonl`。真正入库的动作由调用方
|
|
22
|
+
* (api 层)通过 `apply` 回调注入 —— `accept` 自己**从不**碰记忆/技能库。
|
|
23
|
+
* 这样「建议」与「领域写入」的权限面是分离的,越权在结构上就不可能发生。
|
|
24
|
+
* ③ **可测**:`apply` 用假回调就能测通「采纳 → 落库 → 状态归位」整条链路,
|
|
25
|
+
* 不用先构造一个 memory 实例(memory.js 3k+ 行,拖进单测不划算)。
|
|
26
|
+
*
|
|
27
|
+
* ── 三条铁律(与 board.js / discipline.js 同款)────────────────────────────
|
|
28
|
+
* 1. **CAS(rev)**:accept/reject 支持 `expectedRev`;不匹配即抛 `SUGGEST_CAS_CONFLICT`,
|
|
29
|
+
* **绝不静默覆盖**(UI 双开两个页面时,后点的那个必须失败而不是覆盖先点的结果)。
|
|
30
|
+
* 2. **事件流是真相源**:先 append `suggestions.jsonl`(失败即抛错、内存原封不动),
|
|
31
|
+
* 再改内存,最后原子写 `suggestions.snap.json`(快照失败只 warn,replay() 可重建)。
|
|
32
|
+
* 3. **apply 失败绝不假装成功**:该条保持 `pending`、错误文本记进 `lastError`(并且落盘,
|
|
33
|
+
* 重启后仍可审计),同时把错误**抛给调用方**。绝不出现「界面说已采纳、库里啥也没有」。
|
|
34
|
+
* `apply` **可同步也可异步**(api 层的 applySuggestion 是 async:memory/library 的写入
|
|
35
|
+
* 可能跨异步边界)—— 因此 `accept`/`bulkAccept` 返回 Promise,内部**必定 await**;
|
|
36
|
+
* 不 await 就会在真正写库完成前标 accepted,那正是「假采纳」。
|
|
37
|
+
*
|
|
38
|
+
* ── 去重口径 ────────────────────────────────────────────────────────────────
|
|
39
|
+
* 同 `kind + layer + payload(trim + 压空白 + 大小写不敏感)` 且仍为 `pending` → 视为重复:
|
|
40
|
+
* 返回已有那条并标 `duplicate:true`,**不新增**。
|
|
41
|
+
* 为什么把 `layer` 也算进去:同一段文字写进 L1(偏好)与 L3(项目 key)是**两个不同目标**,
|
|
42
|
+
* 合并会把建议引到错误的层;为什么只比 pending:被拒绝过的建议允许再次提出(人可能只是当时忙)。
|
|
43
|
+
* `cwd`(可选,只对 L3/L4/L5 有意义)也参与归一化比较:同一句话进「A 项目」和「B 项目」
|
|
44
|
+
* 是两个不同的建议,合并会让项目记忆串味。
|
|
45
|
+
*
|
|
46
|
+
* 依赖面:`node:*` + `./store.js` + `./kernel.js`(惰性桥)。**零第三方依赖**。
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
import { randomBytes } from 'node:crypto'
|
|
50
|
+
|
|
51
|
+
import {
|
|
52
|
+
appendJsonl,
|
|
53
|
+
readJson,
|
|
54
|
+
readJsonl,
|
|
55
|
+
statePath,
|
|
56
|
+
writeJsonAtomic,
|
|
57
|
+
} from './store.js'
|
|
58
|
+
|
|
59
|
+
// ── kernel 惰性桥 ───────────────────────────────────────────────────────────
|
|
60
|
+
//
|
|
61
|
+
// 与 board.js / discipline.js 同款:kernel.js 是**唯一**允许静态 import 宿主包的文件,
|
|
62
|
+
// 裸检出(无宿主包)时它的加载会失败;这里用 try/catch 兜住,拿不到 kernel 时
|
|
63
|
+
// **不静默兜底** —— 调用方必须显式传 `config.stateDir`,错误码 `SUGGEST_NO_STATE_DIR`。
|
|
64
|
+
|
|
65
|
+
/** @type {object | null} */
|
|
66
|
+
let kernel = null
|
|
67
|
+
/** @type {unknown} */
|
|
68
|
+
let kernelError = null
|
|
69
|
+
try {
|
|
70
|
+
kernel = await import('./kernel.js')
|
|
71
|
+
} catch (error) {
|
|
72
|
+
kernelError = error
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ── 常量(契约冻结,改这里等于改 API 层契约)────────────────────────────────
|
|
76
|
+
|
|
77
|
+
/** 建议类型:记忆 / 技能 / 纪律。 */
|
|
78
|
+
export const KINDS = ['memory', 'skill', 'discipline']
|
|
79
|
+
/** 建议生命周期状态。 */
|
|
80
|
+
export const STATUSES = ['pending', 'accepted', 'rejected']
|
|
81
|
+
/** 建议来源(谁提的):审查 / 合并整理 / 模型主动。 */
|
|
82
|
+
export const SOURCES = ['review', 'consolidate', 'model']
|
|
83
|
+
/**
|
|
84
|
+
* 记忆层白名单:**L0 人格不在其中**。
|
|
85
|
+
* 为什么:L0 是「人显式写下的人格」,memory.js 用 `assertNotPersonaLayer` 硬拦
|
|
86
|
+
* 一切程序写入;建议队列如果允许往 L0 提,就只是把一个会被拒的动作延后了,
|
|
87
|
+
* 白让用户点一次「采纳」再看到失败。所以在 suggest 阶段就拒掉(SUGGEST_BAD_LAYER)。
|
|
88
|
+
*/
|
|
89
|
+
export const MEMORY_LAYERS = ['L1', 'L2', 'L3', 'L4', 'L5']
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* payload 各字段上限。
|
|
93
|
+
* 刻意与 library.js / DESIGN-v2 的既有上限对齐 —— 建议比库里更宽松没有意义:
|
|
94
|
+
* 在队列里放得下、采纳时却被库拒收,等于把失败推后到人点按钮的那一刻。
|
|
95
|
+
*/
|
|
96
|
+
export const LIMITS = Object.freeze({
|
|
97
|
+
reason: 2000,
|
|
98
|
+
memoryContent: 20000,
|
|
99
|
+
skillName: 64,
|
|
100
|
+
skillDescription: 2000,
|
|
101
|
+
skillBody: 20000,
|
|
102
|
+
disciplineTitle: 120,
|
|
103
|
+
disciplineContent: 4000,
|
|
104
|
+
/** cwd 单条长度上限(路径再长也不该超过这个数;超出多半是误把正文塞进来了)。 */
|
|
105
|
+
cwd: 4096,
|
|
106
|
+
tags: 20,
|
|
107
|
+
tagLength: 64,
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* 纪律模块的枚举**内联**,不从 `./discipline.js` import。
|
|
112
|
+
* 为什么:suggest.js 是「闸门」,不该因为纪律模块换实现而连带加载失败;
|
|
113
|
+
* 两边取值以 DESIGN-v2 §特性② 为唯一来源,这里只做校验用。
|
|
114
|
+
*/
|
|
115
|
+
const DISCIPLINE_SCOPES = ['global', 'workspace', 'room', 'bot']
|
|
116
|
+
const DISCIPLINE_KINDS = ['rule', 'requirement', 'principle', 'style']
|
|
117
|
+
|
|
118
|
+
/** 技能名的 kebab-case 硬校验(比 library.NAME_RE 更严,理由见 requireSkillName)。 */
|
|
119
|
+
const SKILL_NAME_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/
|
|
120
|
+
|
|
121
|
+
// ── 错误类 ──────────────────────────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
/** 建议队列错误:`code` 一律 `SUGGEST_` 前缀,`detail` 给机器读的上下文。 */
|
|
124
|
+
export class SuggestError extends Error {
|
|
125
|
+
/**
|
|
126
|
+
* @param {string} message 人类可读、含定位信息
|
|
127
|
+
* @param {string} [code] 大写下划线错误码(`SUGGEST_` 前缀)
|
|
128
|
+
* @param {object} [detail] 机器可读上下文
|
|
129
|
+
*/
|
|
130
|
+
constructor(message, code = 'SUGGEST_ERROR', detail) {
|
|
131
|
+
super(message)
|
|
132
|
+
this.name = 'SuggestError'
|
|
133
|
+
this.code = code
|
|
134
|
+
if (detail !== undefined) this.detail = detail
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ── 小工具 ──────────────────────────────────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
/** 深拷贝 + JSON 归一(去掉 undefined 键,保证内存态与磁盘态逐字相等)。 */
|
|
141
|
+
function clone(value) {
|
|
142
|
+
if (value === undefined || value === null) return value
|
|
143
|
+
return JSON.parse(JSON.stringify(value))
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** 错误 → 一行可读文本。 */
|
|
147
|
+
function errorText(error) {
|
|
148
|
+
if (error instanceof Error) return `${error.name}: ${error.message}`
|
|
149
|
+
if (typeof error === 'string') return error
|
|
150
|
+
try {
|
|
151
|
+
return JSON.stringify(error)
|
|
152
|
+
} catch {
|
|
153
|
+
return String(error)
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* `apply` 返回值的**可序列化**形态(入库证据要随事件流回显)。
|
|
159
|
+
* 为什么需要:若回调返回循环引用对象,直接 JSON 序列化会抛错,
|
|
160
|
+
* 把一次**已经成功**的采纳变成异常 —— 那是比失败更难查的误报。
|
|
161
|
+
*/
|
|
162
|
+
function safeResult(value) {
|
|
163
|
+
if (value === undefined) return null
|
|
164
|
+
if (value === null || typeof value !== 'object') return value
|
|
165
|
+
try {
|
|
166
|
+
return clone(value)
|
|
167
|
+
} catch {
|
|
168
|
+
return { unserializable: String(value) }
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** `now` 归一:缺省取当前时间;显式传必须是有限数字(拒绝字符串时间戳)。 */
|
|
173
|
+
function normalizeNow(now, where) {
|
|
174
|
+
if (now === undefined || now === null) return Date.now()
|
|
175
|
+
if (typeof now !== 'number' || !Number.isFinite(now)) {
|
|
176
|
+
throw new SuggestError(
|
|
177
|
+
`suggest.${where}:now 必须是有限数字(毫秒时间戳),收到 ${JSON.stringify(now)}`,
|
|
178
|
+
'SUGGEST_BAD_FIELD',
|
|
179
|
+
{ where, field: 'now' },
|
|
180
|
+
)
|
|
181
|
+
}
|
|
182
|
+
return now
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** 必填字符串(trim 后非空)。 */
|
|
186
|
+
function requireString(value, field, where, code = 'SUGGEST_BAD_FIELD') {
|
|
187
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
188
|
+
throw new SuggestError(
|
|
189
|
+
`suggest.${where}:${field} 必须是非空字符串,收到 ${JSON.stringify(value)}`,
|
|
190
|
+
code,
|
|
191
|
+
{ where, field },
|
|
192
|
+
)
|
|
193
|
+
}
|
|
194
|
+
return value.trim()
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** 长度上限(按码点计,避免中文被 UTF-16 长度误伤)。 */
|
|
198
|
+
function assertMaxLength(text, max, field, where) {
|
|
199
|
+
const length = [...text].length
|
|
200
|
+
if (length > max) {
|
|
201
|
+
throw new SuggestError(
|
|
202
|
+
`suggest.${where}:${field} 超长(${length} > ${max} 码点)—— 建议队列不放「采纳时必被库拒收」的内容`,
|
|
203
|
+
'SUGGEST_TOO_LONG',
|
|
204
|
+
{ where, field, length, max },
|
|
205
|
+
)
|
|
206
|
+
}
|
|
207
|
+
return text
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** payload 必须是纯对象(数组/null/字符串一律拒)。 */
|
|
211
|
+
function requirePlainObject(value, where) {
|
|
212
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
213
|
+
throw new SuggestError(
|
|
214
|
+
`suggest.${where}:payload 必须是对象,收到 ${Array.isArray(value) ? 'array' : JSON.stringify(value)}`,
|
|
215
|
+
'SUGGEST_BAD_PAYLOAD',
|
|
216
|
+
{ where, kind: typeof value },
|
|
217
|
+
)
|
|
218
|
+
}
|
|
219
|
+
return value
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** 未知字段一律拒(契约里没写的字段 = 调用方 bug,静默忽略会让人以为生效了)。 */
|
|
223
|
+
function assertOnlyKeys(payload, allowed, where) {
|
|
224
|
+
const extra = Object.keys(payload).filter((k) => !allowed.includes(k))
|
|
225
|
+
if (extra.length > 0) {
|
|
226
|
+
throw new SuggestError(
|
|
227
|
+
`suggest.${where}:payload 含未知字段 ${extra.join(', ')}(允许:${allowed.join(', ')})`,
|
|
228
|
+
'SUGGEST_UNKNOWN_FIELD',
|
|
229
|
+
{ where, fields: extra, allowed },
|
|
230
|
+
)
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** 标签归一:字符串数组、trim、去空、去重、条数与单条长度上限。 */
|
|
235
|
+
function normalizeTags(tags, where) {
|
|
236
|
+
if (tags === undefined || tags === null) return []
|
|
237
|
+
if (!Array.isArray(tags)) {
|
|
238
|
+
throw new SuggestError(
|
|
239
|
+
`suggest.${where}:payload.tags 必须是字符串数组,收到 ${JSON.stringify(tags)}`,
|
|
240
|
+
'SUGGEST_BAD_PAYLOAD',
|
|
241
|
+
{ where, field: 'tags' },
|
|
242
|
+
)
|
|
243
|
+
}
|
|
244
|
+
const out = []
|
|
245
|
+
const seen = new Set()
|
|
246
|
+
for (const raw of tags) {
|
|
247
|
+
if (typeof raw !== 'string' || raw.trim() === '') {
|
|
248
|
+
throw new SuggestError(
|
|
249
|
+
`suggest.${where}:payload.tags 每项必须是非空字符串,收到 ${JSON.stringify(raw)}`,
|
|
250
|
+
'SUGGEST_BAD_PAYLOAD',
|
|
251
|
+
{ where, field: 'tags' },
|
|
252
|
+
)
|
|
253
|
+
}
|
|
254
|
+
const tag = raw.trim()
|
|
255
|
+
assertMaxLength(tag, LIMITS.tagLength, 'payload.tags[]', where)
|
|
256
|
+
if (seen.has(tag)) continue
|
|
257
|
+
seen.add(tag)
|
|
258
|
+
out.push(tag)
|
|
259
|
+
}
|
|
260
|
+
if (out.length > LIMITS.tags) {
|
|
261
|
+
throw new SuggestError(
|
|
262
|
+
`suggest.${where}:payload.tags 最多 ${LIMITS.tags} 条,收到 ${out.length} 条`,
|
|
263
|
+
'SUGGEST_TOO_LONG',
|
|
264
|
+
{ where, field: 'tags', count: out.length, max: LIMITS.tags },
|
|
265
|
+
)
|
|
266
|
+
}
|
|
267
|
+
return out
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* 技能名校验。
|
|
272
|
+
* 为什么比 library.NAME_RE 更严(只允许 kebab-case):技能最终要落成
|
|
273
|
+
* `<cwd>/.agents/skills/<name>/SKILL.md` 的**目录名**,而 skill 目录约定就是 kebab-case;
|
|
274
|
+
* 允许大写/下划线会造出「库里一条、目录里一条」的同名分裂,人排查时极难看出。
|
|
275
|
+
*/
|
|
276
|
+
function requireSkillName(value, where) {
|
|
277
|
+
const name = requireString(value, 'payload.name', where, 'SUGGEST_BAD_PAYLOAD')
|
|
278
|
+
if (!SKILL_NAME_RE.test(name) || name.length > LIMITS.skillName) {
|
|
279
|
+
throw new SuggestError(
|
|
280
|
+
`suggest.${where}:payload.name ${JSON.stringify(name)} 必须是 kebab-case(小写字母数字,连字符分隔,≤${LIMITS.skillName})`,
|
|
281
|
+
'SUGGEST_BAD_PAYLOAD',
|
|
282
|
+
{ where, field: 'name', name },
|
|
283
|
+
)
|
|
284
|
+
}
|
|
285
|
+
return name
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** 把 payload 按 kind 归一(切掉两侧空白、校验必填与上限、拒未知字段)。 */
|
|
289
|
+
function normalizePayload(kind, rawPayload, where, layer = null) {
|
|
290
|
+
const payload = requirePlainObject(rawPayload, where)
|
|
291
|
+
const out = {}
|
|
292
|
+
if (kind === 'memory') {
|
|
293
|
+
// cwd 是可选字段:memory.js 已支持「按工作目录隔离」(只有 L3/L4/L5 认它)。
|
|
294
|
+
// 提前收进 payload,是为了让「给某个项目提一条 L3 建议」在队列里就能表达目标项目。
|
|
295
|
+
assertOnlyKeys(payload, ['content', 'tags', 'cwd'], where)
|
|
296
|
+
out.content = assertMaxLength(
|
|
297
|
+
requireString(payload.content, 'payload.content', where, 'SUGGEST_BAD_PAYLOAD'),
|
|
298
|
+
LIMITS.memoryContent,
|
|
299
|
+
'payload.content',
|
|
300
|
+
where,
|
|
301
|
+
)
|
|
302
|
+
out.tags = normalizeTags(payload.tags, where)
|
|
303
|
+
if (payload.cwd !== undefined && payload.cwd !== null) {
|
|
304
|
+
const cwd = requireString(payload.cwd, 'payload.cwd', where, 'SUGGEST_BAD_PAYLOAD')
|
|
305
|
+
assertMaxLength(cwd, LIMITS.cwd, 'payload.cwd', where)
|
|
306
|
+
// 全局层(L1/L2)带 cwd 会被 memory.add **静默忽略** —— 那会让人以为「已按项目隔离」,
|
|
307
|
+
// 实际写成了全局。宁可在入队时就拒掉(错误码带 field:cwd,UI 能说人话)。
|
|
308
|
+
if (!['L3', 'L4', 'L5'].includes(layer)) {
|
|
309
|
+
throw new SuggestError(
|
|
310
|
+
`suggest.${where}:memory 建议的 payload.cwd 只对按项目隔离的层有效(L3/L4/L5),`
|
|
311
|
+
+ `当前 layer=${layer} —— 全局层写 cwd 会被静默忽略,故拒绝`,
|
|
312
|
+
'SUGGEST_BAD_PAYLOAD',
|
|
313
|
+
{ where, field: 'cwd', layer },
|
|
314
|
+
)
|
|
315
|
+
}
|
|
316
|
+
out.cwd = cwd
|
|
317
|
+
}
|
|
318
|
+
return out
|
|
319
|
+
}
|
|
320
|
+
if (kind === 'skill') {
|
|
321
|
+
assertOnlyKeys(payload, ['name', 'description', 'body', 'tags'], where)
|
|
322
|
+
out.name = requireSkillName(payload.name, where)
|
|
323
|
+
out.description = payload.description === undefined || payload.description === null
|
|
324
|
+
? ''
|
|
325
|
+
: assertMaxLength(
|
|
326
|
+
requireString(payload.description, 'payload.description', where, 'SUGGEST_BAD_PAYLOAD'),
|
|
327
|
+
LIMITS.skillDescription,
|
|
328
|
+
'payload.description',
|
|
329
|
+
where,
|
|
330
|
+
)
|
|
331
|
+
out.body = assertMaxLength(
|
|
332
|
+
requireString(payload.body, 'payload.body', where, 'SUGGEST_BAD_PAYLOAD'),
|
|
333
|
+
LIMITS.skillBody,
|
|
334
|
+
'payload.body',
|
|
335
|
+
where,
|
|
336
|
+
)
|
|
337
|
+
out.tags = normalizeTags(payload.tags, where)
|
|
338
|
+
return out
|
|
339
|
+
}
|
|
340
|
+
// discipline
|
|
341
|
+
// scopeId 是**目标定位**(workspace=cwd / room=roomId / bot=botId),discipline.js 对
|
|
342
|
+
// 非 global 的 scope 强制要求它。允许它进 payload,是为了让「给某个工作区提一条纪律建议」
|
|
343
|
+
// 在队列里就能表达清楚;否则采纳时才炸「scopeId 必填」,等于把失败推给人点按钮那一刻。
|
|
344
|
+
assertOnlyKeys(payload, ['scope', 'scopeId', 'kind', 'title', 'content'], where)
|
|
345
|
+
out.scope = requireString(payload.scope, 'payload.scope', where, 'SUGGEST_BAD_PAYLOAD')
|
|
346
|
+
if (!DISCIPLINE_SCOPES.includes(out.scope)) {
|
|
347
|
+
throw new SuggestError(
|
|
348
|
+
`suggest.${where}:payload.scope=${JSON.stringify(out.scope)} 非法(合法:${DISCIPLINE_SCOPES.join(' | ')})`,
|
|
349
|
+
'SUGGEST_BAD_PAYLOAD',
|
|
350
|
+
{ where, field: 'scope' },
|
|
351
|
+
)
|
|
352
|
+
}
|
|
353
|
+
out.kind = requireString(payload.kind, 'payload.kind', where, 'SUGGEST_BAD_PAYLOAD')
|
|
354
|
+
if (!DISCIPLINE_KINDS.includes(out.kind)) {
|
|
355
|
+
throw new SuggestError(
|
|
356
|
+
`suggest.${where}:payload.kind=${JSON.stringify(out.kind)} 非法(合法:${DISCIPLINE_KINDS.join(' | ')})`,
|
|
357
|
+
'SUGGEST_BAD_PAYLOAD',
|
|
358
|
+
{ where, field: 'kind' },
|
|
359
|
+
)
|
|
360
|
+
}
|
|
361
|
+
// scopeId 与 scope 强绑定(与 discipline.normalizeScopeId 同口径,宁可在入队时就拒):
|
|
362
|
+
// global 带 id 是歧义状态;非 global 缺 id 则采纳时必被 discipline 拒收。
|
|
363
|
+
const rawScopeId = payload.scopeId === undefined || payload.scopeId === null ? '' : payload.scopeId
|
|
364
|
+
let scopeId = null
|
|
365
|
+
if (typeof rawScopeId === 'string' && rawScopeId.trim() !== '') {
|
|
366
|
+
scopeId = assertMaxLength(rawScopeId.trim(), LIMITS.cwd, 'payload.scopeId', where)
|
|
367
|
+
} else if (rawScopeId !== '') {
|
|
368
|
+
throw new SuggestError(
|
|
369
|
+
`suggest.${where}:payload.scopeId 必须是非空字符串(workspace=cwd / room=roomId / bot=botId),收到 ${JSON.stringify(payload.scopeId)}`,
|
|
370
|
+
'SUGGEST_BAD_PAYLOAD',
|
|
371
|
+
{ where, field: 'scopeId' },
|
|
372
|
+
)
|
|
373
|
+
}
|
|
374
|
+
if (out.scope === 'global' && scopeId !== null) {
|
|
375
|
+
throw new SuggestError(
|
|
376
|
+
`suggest.${where}:payload.scope=global 时不允许带 scopeId(收到 ${JSON.stringify(scopeId)})—— `
|
|
377
|
+
+ '这是歧义状态,discipline 会拒收',
|
|
378
|
+
'SUGGEST_BAD_PAYLOAD',
|
|
379
|
+
{ where, field: 'scopeId', scope: out.scope },
|
|
380
|
+
)
|
|
381
|
+
}
|
|
382
|
+
if (out.scope !== 'global' && scopeId === null) {
|
|
383
|
+
throw new SuggestError(
|
|
384
|
+
`suggest.${where}:payload.scope=${out.scope} 必须给非空 scopeId(workspace=cwd 绝对路径 / room=roomId / bot=botId)`,
|
|
385
|
+
'SUGGEST_BAD_PAYLOAD',
|
|
386
|
+
{ where, field: 'scopeId', scope: out.scope },
|
|
387
|
+
)
|
|
388
|
+
}
|
|
389
|
+
out.scopeId = scopeId
|
|
390
|
+
out.title = assertMaxLength(
|
|
391
|
+
requireString(payload.title, 'payload.title', where, 'SUGGEST_BAD_PAYLOAD'),
|
|
392
|
+
LIMITS.disciplineTitle,
|
|
393
|
+
'payload.title',
|
|
394
|
+
where,
|
|
395
|
+
)
|
|
396
|
+
out.content = assertMaxLength(
|
|
397
|
+
requireString(payload.content, 'payload.content', where, 'SUGGEST_BAD_PAYLOAD'),
|
|
398
|
+
LIMITS.disciplineContent,
|
|
399
|
+
'payload.content',
|
|
400
|
+
where,
|
|
401
|
+
)
|
|
402
|
+
return out
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** 递归归一化字符串:trim + 压缩空白 + 大小写不敏感(去重口径,见文件头)。 */
|
|
406
|
+
function normalizeForDedup(value) {
|
|
407
|
+
if (typeof value === 'string') return value.trim().replace(/\s+/g, ' ').toLowerCase()
|
|
408
|
+
if (Array.isArray(value)) return value.map(normalizeForDedup)
|
|
409
|
+
if (value !== null && typeof value === 'object') {
|
|
410
|
+
const out = {}
|
|
411
|
+
for (const key of Object.keys(value).sort()) {
|
|
412
|
+
if (value[key] === undefined) continue
|
|
413
|
+
out[key] = normalizeForDedup(value[key])
|
|
414
|
+
}
|
|
415
|
+
return out
|
|
416
|
+
}
|
|
417
|
+
return value
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** 去重键:`kind | layer | 归一化 payload`(稳定键序,避免键顺序不同导致漏判重复)。 */
|
|
421
|
+
function dedupKeyOf(kind, layer, payload) {
|
|
422
|
+
return `${kind}|${layer ?? ''}|${JSON.stringify(normalizeForDedup(payload))}`
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// ── 状态目录 ────────────────────────────────────────────────────────────────
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* 解析状态目录:`config.stateDir` > `kernel.stateDir()` > 抛错。
|
|
429
|
+
* **不做静默兜底**(与 board.js 同一纪律):兜到临时目录会造成「建议莫名其妙消失」。
|
|
430
|
+
* @param {{ stateDir?: string }} config
|
|
431
|
+
* @returns {string}
|
|
432
|
+
*/
|
|
433
|
+
function resolveStateDir(config) {
|
|
434
|
+
if (typeof config?.stateDir === 'string' && config.stateDir.trim() !== '') {
|
|
435
|
+
return config.stateDir.trim()
|
|
436
|
+
}
|
|
437
|
+
if (kernel && typeof kernel.stateDir === 'function') {
|
|
438
|
+
return kernel.stateDir()
|
|
439
|
+
}
|
|
440
|
+
const reason = kernelError?.message ?? (kernel ? 'kernel.stateDir 不存在' : 'kernel 未加载')
|
|
441
|
+
throw new SuggestError(
|
|
442
|
+
`suggest.createSuggest:既未提供 config.stateDir,也无法从 kernel.js 取得 stateDir(${reason})。`
|
|
443
|
+
+ '裸检出(无宿主包)下必须显式传 config.stateDir。',
|
|
444
|
+
'SUGGEST_NO_STATE_DIR',
|
|
445
|
+
)
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* 把 `(ctx, config)` 归一。允许「只传一个 config 对象」的简写(`{ stateDir }`),
|
|
450
|
+
* 与 discipline.js 的 splitArgs 同款 —— 测试/工具层手写实例时更顺手。
|
|
451
|
+
*/
|
|
452
|
+
function splitArgs(ctx, config) {
|
|
453
|
+
const cfgGiven = config && typeof config === 'object' && Object.keys(config).length > 0
|
|
454
|
+
if (cfgGiven) return { context: ctx ?? {}, config }
|
|
455
|
+
if (ctx && typeof ctx === 'object' && typeof ctx.stateDir === 'string') {
|
|
456
|
+
return { context: {}, config: ctx }
|
|
457
|
+
}
|
|
458
|
+
return { context: ctx ?? {}, config: config ?? {} }
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// ── 工厂 ────────────────────────────────────────────────────────────────────
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* 创建一个建议队列实例。
|
|
465
|
+
*
|
|
466
|
+
* @param {{ logger?: { info?: Function, warn?: Function } }} [ctx] 宿主上下文(只用于日志,缺失即 no-op)
|
|
467
|
+
* @param {{ stateDir?: string, name?: string }} [config]
|
|
468
|
+
* @returns {object} suggest service
|
|
469
|
+
*/
|
|
470
|
+
export function createSuggest(ctx, config = {}) {
|
|
471
|
+
const { context, config: cfg } = splitArgs(ctx, config)
|
|
472
|
+
const stateDir = resolveStateDir(cfg)
|
|
473
|
+
|
|
474
|
+
const jsonlPath = statePath(stateDir, 'suggestions.jsonl')
|
|
475
|
+
const snapPath = statePath(stateDir, 'suggestions.snap.json')
|
|
476
|
+
|
|
477
|
+
const info = (...args) => { try { context?.logger?.info?.(...args) } catch { /* 日志失败不影响业务 */ } }
|
|
478
|
+
const warn = (...args) => { try { context?.logger?.warn?.(...args) } catch { /* 同上 */ } }
|
|
479
|
+
|
|
480
|
+
/** @type {Map<string, object>} 建议 id → Suggestion(插入序 = 创建序) */
|
|
481
|
+
const suggestions = new Map()
|
|
482
|
+
/** @type {object[]} 全部事件(审计流,按落盘顺序;apply 失败也在这里留证) */
|
|
483
|
+
const events = []
|
|
484
|
+
|
|
485
|
+
let disposed = false
|
|
486
|
+
/** accept 正在执行 apply 的 id 集合(防 apply 回调里再次 accept 同一条 → 重复入库)。 */
|
|
487
|
+
const deciding = new Set()
|
|
488
|
+
|
|
489
|
+
const diagnostics = {
|
|
490
|
+
stateDir,
|
|
491
|
+
jsonlPath,
|
|
492
|
+
snapPath,
|
|
493
|
+
kernel: kernel ? 'loaded' : `load-failed:${kernelError?.name ?? 'unknown'}`,
|
|
494
|
+
hydratedFrom: /** @type {null|'jsonl'|'snapshot'|'empty'} */ (null),
|
|
495
|
+
replayedEvents: 0,
|
|
496
|
+
badLines: 0,
|
|
497
|
+
snapshotWriteFailures: 0,
|
|
498
|
+
duplicatesReturned: 0,
|
|
499
|
+
lastReplayAt: /** @type {number|undefined} */ (undefined),
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/** 已销毁实例的调用一律抛错:静默返回空队列比崩掉更难查(「建议都没了?」)。 */
|
|
503
|
+
function assertAlive(where) {
|
|
504
|
+
if (disposed) {
|
|
505
|
+
throw new SuggestError(
|
|
506
|
+
`suggest.${where}:实例已 dispose(),请重新 createSuggest()(拒绝在已销毁实例上静默返回空数据)`,
|
|
507
|
+
'SUGGEST_DISPOSED',
|
|
508
|
+
{ where },
|
|
509
|
+
)
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// ── 持久化 ────────────────────────────────────────────────────────────────
|
|
514
|
+
|
|
515
|
+
/** 快照内容(落盘形态)。 */
|
|
516
|
+
const snapshot = () => ({
|
|
517
|
+
version: 1,
|
|
518
|
+
savedAt: Date.now(),
|
|
519
|
+
suggestions: [...suggestions.values()],
|
|
520
|
+
})
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* 提交一次变更:**先落事件,再改内存,最后写快照**。
|
|
524
|
+
* 顺序不可颠倒:append 失败时内存必须原封不动,否则内存与磁盘分叉,
|
|
525
|
+
* 重启回放后「已采纳」凭空变回「待确认」,属于最恶劣的静默失败。
|
|
526
|
+
*
|
|
527
|
+
* @param {string} action add | accept | reject | apply_failed
|
|
528
|
+
* @param {number} at
|
|
529
|
+
* @param {string|null} by
|
|
530
|
+
* @param {object|null} before 变更前(add 为 null)
|
|
531
|
+
* @param {object} after 变更后
|
|
532
|
+
* @param {object|null} [extra] 额外事件字段(目前只有 accept 用:`{ applyResult }` 入库证据)
|
|
533
|
+
* @returns {object} 克隆后的 after
|
|
534
|
+
*/
|
|
535
|
+
function commit(action, at, by, before, after, extra = null) {
|
|
536
|
+
const event = {
|
|
537
|
+
id: after.id,
|
|
538
|
+
rev: after.rev,
|
|
539
|
+
action,
|
|
540
|
+
by: by ?? null,
|
|
541
|
+
at,
|
|
542
|
+
before: before ? clone(before) : null,
|
|
543
|
+
after: clone(after),
|
|
544
|
+
...(extra && typeof extra === 'object' ? clone(extra) : {}),
|
|
545
|
+
}
|
|
546
|
+
if (!appendJsonl(jsonlPath, event)) {
|
|
547
|
+
throw new SuggestError(
|
|
548
|
+
`suggest.${action}:事件追加失败(${jsonlPath})—— 拒绝「只改内存不落盘」`,
|
|
549
|
+
'SUGGEST_APPEND_FAILED',
|
|
550
|
+
{ id: after.id, action },
|
|
551
|
+
)
|
|
552
|
+
}
|
|
553
|
+
suggestions.set(after.id, clone(after))
|
|
554
|
+
events.push(event)
|
|
555
|
+
if (!writeJsonAtomic(snapPath, snapshot())) {
|
|
556
|
+
diagnostics.snapshotWriteFailures += 1
|
|
557
|
+
warn(`[cs-bots/suggest] 快照写入失败(${snapPath})—— 事件流已落盘,replay() 可完整恢复`)
|
|
558
|
+
}
|
|
559
|
+
return clone(after)
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* 从 `suggestions.jsonl`(真相源)重建内存态;jsonl 为空才回落到快照。
|
|
564
|
+
* 坏行**不静默丢弃**:计入 diagnostics 并 warn(撕裂写必须被人看见)。
|
|
565
|
+
*/
|
|
566
|
+
function hydrate() {
|
|
567
|
+
suggestions.clear()
|
|
568
|
+
events.length = 0
|
|
569
|
+
|
|
570
|
+
const { rows, bad } = readJsonl(jsonlPath)
|
|
571
|
+
diagnostics.badLines = bad
|
|
572
|
+
diagnostics.replayedEvents = rows.length
|
|
573
|
+
if (bad > 0) {
|
|
574
|
+
warn(`[cs-bots/suggest] ${jsonlPath} 有 ${bad} 行无法解析(撕裂写或文件损坏)——已跳过,请核对`)
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
if (rows.length === 0) {
|
|
578
|
+
const snap = readJson(snapPath, null)
|
|
579
|
+
const saved = Array.isArray(snap?.suggestions) ? snap.suggestions : null
|
|
580
|
+
if (saved) {
|
|
581
|
+
for (const raw of saved) {
|
|
582
|
+
if (!raw || typeof raw.id !== 'string' || !STATUSES.includes(raw.status)) continue
|
|
583
|
+
suggestions.set(raw.id, clone(raw))
|
|
584
|
+
}
|
|
585
|
+
diagnostics.hydratedFrom = 'snapshot'
|
|
586
|
+
info(`[cs-bots/suggest] 回放完成:无事件流 → 由快照恢复 ${suggestions.size} 条建议`)
|
|
587
|
+
return
|
|
588
|
+
}
|
|
589
|
+
diagnostics.hydratedFrom = 'empty'
|
|
590
|
+
info('[cs-bots/suggest] 回放完成:空队列(无事件流、无快照)')
|
|
591
|
+
return
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// 事件流是真相源:只接受 rev 递增的事件(同 rev 重放幂等,容忍重复投递)
|
|
595
|
+
let skipped = 0
|
|
596
|
+
for (const ev of rows) {
|
|
597
|
+
if (!ev || typeof ev.id !== 'string' || !ev.after || typeof ev.after.id !== 'string') {
|
|
598
|
+
skipped += 1
|
|
599
|
+
continue
|
|
600
|
+
}
|
|
601
|
+
const before = suggestions.get(ev.id)
|
|
602
|
+
if (before && Number.isFinite(ev.rev) && ev.rev <= before.rev) {
|
|
603
|
+
skipped += 1
|
|
604
|
+
continue
|
|
605
|
+
}
|
|
606
|
+
suggestions.set(ev.id, clone(ev.after))
|
|
607
|
+
events.push(clone(ev))
|
|
608
|
+
}
|
|
609
|
+
if (skipped > 0) {
|
|
610
|
+
warn(`[cs-bots/suggest] 回放跳过 ${skipped} 条非递增/残缺事件(已保留在文件中,未被静默丢弃)`)
|
|
611
|
+
}
|
|
612
|
+
diagnostics.hydratedFrom = 'jsonl'
|
|
613
|
+
info(`[cs-bots/suggest] 回放完成:${rows.length} 条事件 → ${suggestions.size} 条建议`)
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// ── 读 ────────────────────────────────────────────────────────────────────
|
|
617
|
+
|
|
618
|
+
/** 取值或抛 `SUGGEST_NOT_FOUND`(绝不返回 undefined 让上层猜)。 */
|
|
619
|
+
function requireSuggestion(id, where) {
|
|
620
|
+
const found = suggestions.get(id)
|
|
621
|
+
if (!found) {
|
|
622
|
+
throw new SuggestError(
|
|
623
|
+
`suggest.${where}:建议 [id:${id}] 不存在`,
|
|
624
|
+
'SUGGEST_NOT_FOUND',
|
|
625
|
+
{ where, id },
|
|
626
|
+
)
|
|
627
|
+
}
|
|
628
|
+
return clone(found)
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/**
|
|
632
|
+
* 列建议。缺省只列 `pending`(UI 红点/待办箱视角;已决的建议要显式要)。
|
|
633
|
+
* 排序:`confidence` 降序 → `createdAt` 降序 → id 升序(最后一条只为**确定性**:
|
|
634
|
+
* 相同置信度与时间戳时顺序不能随 Map 插入序漂移,否则测试与 UI 都会闪)。
|
|
635
|
+
*
|
|
636
|
+
* @param {{ kind?: string, status?: string, limit?: number, minConfidence?: number }} [query]
|
|
637
|
+
* @returns {object[]}
|
|
638
|
+
*/
|
|
639
|
+
function listSuggestions(query = {}) {
|
|
640
|
+
assertAlive('listSuggestions')
|
|
641
|
+
const { kind, status, limit, minConfidence } = query ?? {}
|
|
642
|
+
let wantStatus = 'pending'
|
|
643
|
+
if (status !== undefined && status !== null) {
|
|
644
|
+
if (status === 'all') wantStatus = 'all'
|
|
645
|
+
else if (STATUSES.includes(status)) wantStatus = status
|
|
646
|
+
else {
|
|
647
|
+
throw new SuggestError(
|
|
648
|
+
`suggest.listSuggestions:status=${JSON.stringify(status)} 非法(合法:pending | accepted | rejected | all)`,
|
|
649
|
+
'SUGGEST_BAD_FIELD',
|
|
650
|
+
{ field: 'status' },
|
|
651
|
+
)
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
if (kind !== undefined && kind !== null && !KINDS.includes(kind)) {
|
|
655
|
+
throw new SuggestError(
|
|
656
|
+
`suggest.listSuggestions:kind=${JSON.stringify(kind)} 非法(合法:${KINDS.join(' | ')})`,
|
|
657
|
+
'SUGGEST_BAD_FIELD',
|
|
658
|
+
{ field: 'kind' },
|
|
659
|
+
)
|
|
660
|
+
}
|
|
661
|
+
let limitNumber = 0
|
|
662
|
+
if (limit !== undefined && limit !== null) {
|
|
663
|
+
if (!Number.isInteger(limit) || limit < 0) {
|
|
664
|
+
throw new SuggestError(
|
|
665
|
+
`suggest.listSuggestions:limit 必须是非负整数,收到 ${JSON.stringify(limit)}`,
|
|
666
|
+
'SUGGEST_BAD_FIELD',
|
|
667
|
+
{ field: 'limit' },
|
|
668
|
+
)
|
|
669
|
+
}
|
|
670
|
+
limitNumber = limit
|
|
671
|
+
}
|
|
672
|
+
let min = 0
|
|
673
|
+
if (minConfidence !== undefined && minConfidence !== null) {
|
|
674
|
+
if (typeof minConfidence !== 'number' || !Number.isFinite(minConfidence)
|
|
675
|
+
|| minConfidence < 0 || minConfidence > 1) {
|
|
676
|
+
throw new SuggestError(
|
|
677
|
+
`suggest.listSuggestions:minConfidence 必须是 0..1 的数字,收到 ${JSON.stringify(minConfidence)}`,
|
|
678
|
+
'SUGGEST_BAD_FIELD',
|
|
679
|
+
{ field: 'minConfidence' },
|
|
680
|
+
)
|
|
681
|
+
}
|
|
682
|
+
min = minConfidence
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
const rows = [...suggestions.values()].filter((s) => {
|
|
686
|
+
if (wantStatus !== 'all' && s.status !== wantStatus) return false
|
|
687
|
+
if (kind !== undefined && kind !== null && s.kind !== kind) return false
|
|
688
|
+
if (s.confidence < min) return false
|
|
689
|
+
return true
|
|
690
|
+
})
|
|
691
|
+
rows.sort((a, b) => (
|
|
692
|
+
(b.confidence - a.confidence)
|
|
693
|
+
|| (b.createdAt - a.createdAt)
|
|
694
|
+
|| (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)
|
|
695
|
+
))
|
|
696
|
+
return clone(limitNumber > 0 ? rows.slice(0, limitNumber) : rows)
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/**
|
|
700
|
+
* 取单条建议。
|
|
701
|
+
* @param {string} id
|
|
702
|
+
* @returns {object}
|
|
703
|
+
*/
|
|
704
|
+
function getSuggestion(id) {
|
|
705
|
+
assertAlive('getSuggestion')
|
|
706
|
+
const suggestionId = requireString(id, 'id', 'getSuggestion')
|
|
707
|
+
return requireSuggestion(suggestionId, 'getSuggestion')
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
// ── 写:提建议 ─────────────────────────────────────────────────────────────
|
|
711
|
+
|
|
712
|
+
/**
|
|
713
|
+
* 提一条建议(**不写领域库**,只落自己的队列)。
|
|
714
|
+
*
|
|
715
|
+
* @param {{ kind: string, layer?: string|null, payload: object, reason: string,
|
|
716
|
+
* source?: string, confidence?: number, by: string, now?: number }} input
|
|
717
|
+
* @returns {object} Suggestion(重复时 `duplicate:true`,仍是已有那条)
|
|
718
|
+
*/
|
|
719
|
+
function suggest(input = {}) {
|
|
720
|
+
assertAlive('suggest')
|
|
721
|
+
const { kind, layer, payload, reason, source, confidence, by, now } = input ?? {}
|
|
722
|
+
const at = normalizeNow(now, 'suggest')
|
|
723
|
+
const author = requireString(by, 'by', 'suggest')
|
|
724
|
+
|
|
725
|
+
if (!KINDS.includes(kind)) {
|
|
726
|
+
throw new SuggestError(
|
|
727
|
+
`suggest.suggest:kind=${JSON.stringify(kind)} 非法(合法:${KINDS.join(' | ')})`,
|
|
728
|
+
'SUGGEST_BAD_KIND',
|
|
729
|
+
{ kind },
|
|
730
|
+
)
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
// layer 只在 memory 建议上出现:其他类型带 layer 是调用方串味,必须拒(不静默忽略)。
|
|
734
|
+
let targetLayer = null
|
|
735
|
+
if (kind === 'memory') {
|
|
736
|
+
targetLayer = requireString(layer, 'layer', 'suggest', 'SUGGEST_BAD_LAYER')
|
|
737
|
+
if (!MEMORY_LAYERS.includes(targetLayer)) {
|
|
738
|
+
throw new SuggestError(
|
|
739
|
+
`suggest.suggest:layer=${JSON.stringify(targetLayer)} 非法(合法:${MEMORY_LAYERS.join(' | ')};`
|
|
740
|
+
+ 'L0 人格由人专写,程序不得建议)',
|
|
741
|
+
'SUGGEST_BAD_LAYER',
|
|
742
|
+
{ kind, layer: targetLayer, allowed: MEMORY_LAYERS },
|
|
743
|
+
)
|
|
744
|
+
}
|
|
745
|
+
} else if (layer !== undefined && layer !== null) {
|
|
746
|
+
throw new SuggestError(
|
|
747
|
+
`suggest.suggest:kind=${kind} 不应带 layer(layer 只用于 memory 建议的目标层)`,
|
|
748
|
+
'SUGGEST_BAD_FIELD',
|
|
749
|
+
{ kind, field: 'layer' },
|
|
750
|
+
)
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
const normalizedPayload = normalizePayload(kind, payload, 'suggest', targetLayer)
|
|
754
|
+
const why = assertMaxLength(
|
|
755
|
+
requireString(reason, 'reason', 'suggest', 'SUGGEST_MISSING_REASON'),
|
|
756
|
+
LIMITS.reason,
|
|
757
|
+
'reason',
|
|
758
|
+
'suggest',
|
|
759
|
+
)
|
|
760
|
+
const from = source === undefined || source === null
|
|
761
|
+
? 'model'
|
|
762
|
+
: requireString(source, 'source', 'suggest')
|
|
763
|
+
if (!SOURCES.includes(from)) {
|
|
764
|
+
throw new SuggestError(
|
|
765
|
+
`suggest.suggest:source=${JSON.stringify(from)} 非法(合法:${SOURCES.join(' | ')})`,
|
|
766
|
+
'SUGGEST_BAD_SOURCE',
|
|
767
|
+
{ source: from, allowed: SOURCES },
|
|
768
|
+
)
|
|
769
|
+
}
|
|
770
|
+
let score = 0.5
|
|
771
|
+
if (confidence !== undefined && confidence !== null) {
|
|
772
|
+
if (typeof confidence !== 'number' || !Number.isFinite(confidence)
|
|
773
|
+
|| confidence < 0 || confidence > 1) {
|
|
774
|
+
throw new SuggestError(
|
|
775
|
+
`suggest.suggest:confidence 必须是 0..1 的数字,收到 ${JSON.stringify(confidence)}`,
|
|
776
|
+
'SUGGEST_BAD_CONFIDENCE',
|
|
777
|
+
{ confidence },
|
|
778
|
+
)
|
|
779
|
+
}
|
|
780
|
+
score = confidence
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
// ── 去重(只比 pending;口径见文件头)──
|
|
784
|
+
const key = dedupKeyOf(kind, targetLayer, normalizedPayload)
|
|
785
|
+
for (const existing of suggestions.values()) {
|
|
786
|
+
if (existing.status !== 'pending') continue
|
|
787
|
+
if (dedupKeyOf(existing.kind, existing.layer, existing.payload) !== key) continue
|
|
788
|
+
diagnostics.duplicatesReturned += 1
|
|
789
|
+
info(`[cs-bots/suggest] 重复建议命中 ${existing.id}(${kind})→ 返回已有那条,不新增`)
|
|
790
|
+
const dup = clone(existing)
|
|
791
|
+
dup.duplicate = true
|
|
792
|
+
return dup
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
const id = newId(suggestions)
|
|
796
|
+
/** @type {Record<string, unknown>} */
|
|
797
|
+
const entry = {
|
|
798
|
+
id,
|
|
799
|
+
rev: 1,
|
|
800
|
+
kind,
|
|
801
|
+
status: 'pending',
|
|
802
|
+
layer: targetLayer,
|
|
803
|
+
payload: normalizedPayload,
|
|
804
|
+
reason: why,
|
|
805
|
+
source: from,
|
|
806
|
+
confidence: score,
|
|
807
|
+
createdAt: at,
|
|
808
|
+
createdBy: author,
|
|
809
|
+
updatedAt: at,
|
|
810
|
+
updatedBy: author,
|
|
811
|
+
decidedAt: null,
|
|
812
|
+
decidedBy: null,
|
|
813
|
+
}
|
|
814
|
+
const created = commit('add', at, author, null, entry)
|
|
815
|
+
info(`[cs-bots/suggest] 新建议 ${id}(${kind}${targetLayer ? `/${targetLayer}` : ''})来自 ${from}`)
|
|
816
|
+
|
|
817
|
+
// 返回时也带 duplicate:false,让调用方**永远**能读同一字段而不是靠「有没有这个键」判断。
|
|
818
|
+
const out = clone(created)
|
|
819
|
+
out.duplicate = false
|
|
820
|
+
return out
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
/** 生成 `sg-xxxxxxxx` 形式的 id(8 位十六进制,碰撞则重试;耗尽属于极不可能,也抛错不静默)。 */
|
|
824
|
+
function newId(taken) {
|
|
825
|
+
for (let i = 0; i < 8; i += 1) {
|
|
826
|
+
const id = `sg-${randomBytes(4).toString('hex')}`
|
|
827
|
+
if (!taken.has(id)) return id
|
|
828
|
+
}
|
|
829
|
+
throw new SuggestError(
|
|
830
|
+
'suggest.suggest:连续 8 次生成 id 均碰撞 —— 建议队列状态异常,拒绝静默覆盖',
|
|
831
|
+
'SUGGEST_ID_EXHAUSTED',
|
|
832
|
+
)
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
// ── 写:人确认 ─────────────────────────────────────────────────────────────
|
|
836
|
+
|
|
837
|
+
/** 共用的 CAS/状态闸门(accept 与 reject 都要过)。 */
|
|
838
|
+
function assertDecidable(current, expectedRev, where) {
|
|
839
|
+
if (expectedRev !== undefined && expectedRev !== null) {
|
|
840
|
+
if (!Number.isInteger(expectedRev)) {
|
|
841
|
+
throw new SuggestError(
|
|
842
|
+
`suggest.${where}:expectedRev 必须是整数(CAS 需要显式期望版本;当前 rev=${current.rev},`
|
|
843
|
+
+ `收到 ${JSON.stringify(expectedRev)})`,
|
|
844
|
+
'SUGGEST_BAD_FIELD',
|
|
845
|
+
{ where, field: 'expectedRev', id: current.id, rev: current.rev },
|
|
846
|
+
)
|
|
847
|
+
}
|
|
848
|
+
if (current.rev !== expectedRev) {
|
|
849
|
+
throw new SuggestError(
|
|
850
|
+
`suggest.${where} CAS 冲突:建议 [id:${current.id}] 期望 rev=${expectedRev},实际 rev=${current.rev} —— `
|
|
851
|
+
+ '拒绝静默覆盖,请重新 getSuggestion() 后按最新 rev 重试',
|
|
852
|
+
'SUGGEST_CAS_CONFLICT',
|
|
853
|
+
{ where, id: current.id, expectedRev, rev: current.rev },
|
|
854
|
+
)
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
if (current.status !== 'pending') {
|
|
858
|
+
throw new SuggestError(
|
|
859
|
+
`suggest.${where}:建议 [id:${current.id}] 当前状态是 ${current.status},`
|
|
860
|
+
+ '只有 pending 才能裁决(已裁决的建议不允许再审一次 —— 否则可能重复入库)',
|
|
861
|
+
'SUGGEST_BAD_STATE',
|
|
862
|
+
{ where, id: current.id, status: current.status, expected: 'pending' },
|
|
863
|
+
)
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
/**
|
|
868
|
+
* 采纳一条建议:**先等 `apply` 真正入库,成功后才标 accepted**。
|
|
869
|
+
*
|
|
870
|
+
* 为什么必须由调用方注入 `apply`(而不是本模块自己写):见文件头「不 import memory/library」。
|
|
871
|
+
*
|
|
872
|
+
* `apply` 允许同步也允许异步(api 层的 applySuggestion 是 async:memory/library 的写入
|
|
873
|
+
* 可能跨异步边界),因此本函数内部**一律 await**,返回 Promise —— **调用方必须 await**。
|
|
874
|
+
* 不 await 就等于不等结果,失败会被吞成 unhandled rejection(那是最坏的静默失败)。
|
|
875
|
+
*
|
|
876
|
+
* 失败语义(**这条绝不能改**):
|
|
877
|
+
* - `apply` 抛错/拒绝 → 该条保持 `pending`、错误文本写入 `lastError`(落盘,可审计)、
|
|
878
|
+
* 并把错误**抛给调用方**。绝不出现「界面说已采纳、库里啥也没有」。
|
|
879
|
+
* - 同一条建议**不允许重复采纳**:`status !== 'pending'` 直接抛 SUGGEST_BAD_STATE;
|
|
880
|
+
* apply 执行期间再次 accept 同一条 → SUGGEST_BUSY(防 apply 回调递归触发重复入库)。
|
|
881
|
+
*
|
|
882
|
+
* @param {{ id: string, by: string, now?: number, expectedRev?: number,
|
|
883
|
+
* apply: (suggestion: object) => any }} input
|
|
884
|
+
* @returns {Promise<object>} 采纳后的 Suggestion(附加 `applyResult` = 入库证据)
|
|
885
|
+
*/
|
|
886
|
+
async function accept(input = {}) {
|
|
887
|
+
assertAlive('accept')
|
|
888
|
+
const { id, by, now, expectedRev, apply } = input ?? {}
|
|
889
|
+
const suggestionId = requireString(id, 'id', 'accept')
|
|
890
|
+
const author = requireString(by, 'by', 'accept')
|
|
891
|
+
const at = normalizeNow(now, 'accept')
|
|
892
|
+
const current = requireSuggestion(suggestionId, 'accept')
|
|
893
|
+
|
|
894
|
+
if (typeof apply !== 'function') {
|
|
895
|
+
throw new SuggestError(
|
|
896
|
+
`suggest.accept:缺少 apply 回调 —— 采纳必须真正入库。`
|
|
897
|
+
+ '「只把状态改成 accepted 而不写库」等于假采纳,是最坏的静默失败,故拒绝',
|
|
898
|
+
'SUGGEST_NO_APPLY',
|
|
899
|
+
{ id: suggestionId },
|
|
900
|
+
)
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
assertDecidable(current, expectedRev, 'accept')
|
|
904
|
+
|
|
905
|
+
if (deciding.has(suggestionId)) {
|
|
906
|
+
throw new SuggestError(
|
|
907
|
+
`suggest.accept:建议 [id:${suggestionId}] 正在 apply 中(apply 回调里不要再次 accept 同一条)—— `
|
|
908
|
+
+ '否则同一个入库动作会执行两次',
|
|
909
|
+
'SUGGEST_BUSY',
|
|
910
|
+
{ id: suggestionId },
|
|
911
|
+
)
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
deciding.add(suggestionId)
|
|
915
|
+
let result
|
|
916
|
+
try {
|
|
917
|
+
// **必定 await**:同步回调直接拿到值,异步回调等它真正落地。
|
|
918
|
+
// 这里的 await 也是「防重复采纳」的窗口期 —— deciding 守卫在 await 期间有效。
|
|
919
|
+
result = await apply(clone(current))
|
|
920
|
+
} catch (error) {
|
|
921
|
+
// apply 期间实例被销毁:内存态已清空,不能再写盘,但必须把「副作用可能已发生」说清楚
|
|
922
|
+
if (disposed) {
|
|
923
|
+
throw new SuggestError(
|
|
924
|
+
`suggest.accept:实例在 apply 期间被 dispose() —— 建议 [id:${suggestionId}] 状态未写入;`
|
|
925
|
+
+ 'apply 可能已产生副作用,请人工核对',
|
|
926
|
+
'SUGGEST_DISPOSED',
|
|
927
|
+
{ id: suggestionId },
|
|
928
|
+
)
|
|
929
|
+
}
|
|
930
|
+
const live = suggestions.get(suggestionId)
|
|
931
|
+
if (!live || live.rev !== current.rev) {
|
|
932
|
+
// apply 期间状态被改动:既不能标 accepted,也不该用旧 rev 写失败事件(会覆盖别人的决定)
|
|
933
|
+
throw new SuggestError(
|
|
934
|
+
`suggest.accept:建议 [id:${suggestionId}] 在 apply 期间状态被并发改动(rev 已变)—— `
|
|
935
|
+
+ '保持原状不写盘,请重新 getSuggestion() 核对(本次 apply 可能已产生副作用,需人工确认)',
|
|
936
|
+
'SUGGEST_CAS_CONFLICT',
|
|
937
|
+
{ id: suggestionId, rev: current.rev, liveRev: live?.rev ?? null },
|
|
938
|
+
)
|
|
939
|
+
}
|
|
940
|
+
const before = clone(current)
|
|
941
|
+
const after = clone(current)
|
|
942
|
+
after.rev = current.rev + 1
|
|
943
|
+
after.lastError = errorText(error)
|
|
944
|
+
after.lastErrorCode = (error && typeof error === 'object' && typeof error.code === 'string')
|
|
945
|
+
? error.code
|
|
946
|
+
: 'SUGGEST_APPLY_THREW'
|
|
947
|
+
after.lastErrorAt = at
|
|
948
|
+
after.updatedAt = at
|
|
949
|
+
after.updatedBy = author
|
|
950
|
+
commit('apply_failed', at, author, before, after)
|
|
951
|
+
const wrapped = new SuggestError(
|
|
952
|
+
`suggest.accept:采纳建议 [id:${suggestionId}] 时 apply 失败 → 该条**保持 pending**,`
|
|
953
|
+
+ `错误已记入 lastError:${errorText(error)}`,
|
|
954
|
+
'SUGGEST_APPLY_FAILED',
|
|
955
|
+
{ id: suggestionId, kind: current.kind, errorCode: after.lastErrorCode },
|
|
956
|
+
)
|
|
957
|
+
wrapped.cause = error
|
|
958
|
+
throw wrapped
|
|
959
|
+
} finally {
|
|
960
|
+
deciding.delete(suggestionId)
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
if (disposed) {
|
|
964
|
+
throw new SuggestError(
|
|
965
|
+
`suggest.accept:实例在 apply 期间被 dispose() —— 建议 [id:${suggestionId}] 状态未写入;`
|
|
966
|
+
+ 'apply 可能已产生副作用,请人工核对',
|
|
967
|
+
'SUGGEST_DISPOSED',
|
|
968
|
+
{ id: suggestionId },
|
|
969
|
+
)
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
// apply 成功 → 再检查一次 rev(apply 回调理论上可能改同一条建议)。
|
|
973
|
+
const live = suggestions.get(suggestionId)
|
|
974
|
+
if (!live || live.rev !== current.rev) {
|
|
975
|
+
throw new SuggestError(
|
|
976
|
+
`suggest.accept:apply 已执行,但建议 [id:${suggestionId}] 在 apply 期间被改动(rev 已变)—— `
|
|
977
|
+
+ '本次入库已发生,请人工核对后决定是否回滚/保留(拒绝把状态覆盖成 accepted 掩盖问题)',
|
|
978
|
+
'SUGGEST_CAS_CONFLICT',
|
|
979
|
+
{ id: suggestionId, rev: current.rev, liveRev: live?.rev ?? null },
|
|
980
|
+
)
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
const before = clone(current)
|
|
984
|
+
const after = clone(current)
|
|
985
|
+
after.rev = current.rev + 1
|
|
986
|
+
after.status = 'accepted'
|
|
987
|
+
after.decidedAt = at
|
|
988
|
+
after.decidedBy = author
|
|
989
|
+
after.updatedAt = at
|
|
990
|
+
after.updatedBy = author
|
|
991
|
+
// 采纳成功后清 lastError:失败痕迹保留在事件流(audit),记录本身保持干净,
|
|
992
|
+
// 免得 UI 上一条已成功的建议还挂着「上次失败」的红字。
|
|
993
|
+
delete after.lastError
|
|
994
|
+
delete after.lastErrorCode
|
|
995
|
+
delete after.lastErrorAt
|
|
996
|
+
const evidence = safeResult(result)
|
|
997
|
+
const decided = commit('accept', at, author, before, after, { applyResult: evidence })
|
|
998
|
+
info(`[cs-bots/suggest] 采纳建议 ${suggestionId}(${current.kind})by ${author}`)
|
|
999
|
+
// 返回**建议本身**(而不是 `{suggestion,result}` 包一层):api 层直接把它塞进
|
|
1000
|
+
// `{ ok:true, suggestion: ... }` 回给页面,UI 不用多拆一层;入库证据放 `applyResult`。
|
|
1001
|
+
const out = clone(decided)
|
|
1002
|
+
out.applyResult = evidence
|
|
1003
|
+
return out
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
/**
|
|
1007
|
+
* 拒绝一条建议(**必须给原因**:没有原因,三个月后没人知道为什么否掉)。
|
|
1008
|
+
* @param {{ id: string, reason: string, by: string, now?: number, expectedRev?: number }} input
|
|
1009
|
+
* @returns {object} 拒绝后的 Suggestion
|
|
1010
|
+
*/
|
|
1011
|
+
function reject(input = {}) {
|
|
1012
|
+
assertAlive('reject')
|
|
1013
|
+
const { id, reason, by, now, expectedRev } = input ?? {}
|
|
1014
|
+
const suggestionId = requireString(id, 'id', 'reject')
|
|
1015
|
+
const author = requireString(by, 'by', 'reject')
|
|
1016
|
+
const at = normalizeNow(now, 'reject')
|
|
1017
|
+
const current = requireSuggestion(suggestionId, 'reject')
|
|
1018
|
+
assertDecidable(current, expectedRev, 'reject')
|
|
1019
|
+
const why = assertMaxLength(
|
|
1020
|
+
requireString(reason, 'reason', 'reject', 'SUGGEST_MISSING_REASON'),
|
|
1021
|
+
LIMITS.reason,
|
|
1022
|
+
'reason',
|
|
1023
|
+
'reject',
|
|
1024
|
+
)
|
|
1025
|
+
|
|
1026
|
+
const before = clone(current)
|
|
1027
|
+
const after = clone(current)
|
|
1028
|
+
after.rev = current.rev + 1
|
|
1029
|
+
after.status = 'rejected'
|
|
1030
|
+
after.decidedAt = at
|
|
1031
|
+
after.decidedBy = author
|
|
1032
|
+
after.rejectReason = why
|
|
1033
|
+
after.updatedAt = at
|
|
1034
|
+
after.updatedBy = author
|
|
1035
|
+
delete after.lastError
|
|
1036
|
+
delete after.lastErrorCode
|
|
1037
|
+
delete after.lastErrorAt
|
|
1038
|
+
const decided = commit('reject', at, author, before, after)
|
|
1039
|
+
info(`[cs-bots/suggest] 拒绝建议 ${suggestionId} by ${author}:${why}`)
|
|
1040
|
+
return decided
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
/**
|
|
1044
|
+
* 批量采纳:**部分失败不中断**(人点「全部采纳」时,一条撞错不该让其余全不处理),
|
|
1045
|
+
* 但失败必须逐条回报(`failed[]` 带 code + reason),绝不吞掉。
|
|
1046
|
+
*
|
|
1047
|
+
* 顺序执行(不并发):apply 回调可能是异步写库,并发会让「同一条被两条路径同时写」
|
|
1048
|
+
* 的排查变得很困难;队列通常只有个位数条目,顺序执行的性能代价可以忽略。
|
|
1049
|
+
*
|
|
1050
|
+
* @param {{ ids: string[], by: string, now?: number, apply: (suggestion: object) => any }} input
|
|
1051
|
+
* @returns {Promise<{ accepted: object[], failed: object[], deduped: number }>}
|
|
1052
|
+
* `accepted[]` 是采纳后的建议(带 `applyResult`);`failed[]` 是 `{ id, code, reason }`。
|
|
1053
|
+
*/
|
|
1054
|
+
async function bulkAccept(input = {}) {
|
|
1055
|
+
assertAlive('bulkAccept')
|
|
1056
|
+
const { ids, by, now, apply } = input ?? {}
|
|
1057
|
+
if (!Array.isArray(ids) || ids.length === 0) {
|
|
1058
|
+
throw new SuggestError(
|
|
1059
|
+
`suggest.bulkAccept:ids 必须是非空数组,收到 ${JSON.stringify(ids)}`,
|
|
1060
|
+
'SUGGEST_BAD_FIELD',
|
|
1061
|
+
{ field: 'ids' },
|
|
1062
|
+
)
|
|
1063
|
+
}
|
|
1064
|
+
// apply 先整体校验一次:否则每条都会以 SUGGEST_NO_APPLY 失败,噪音掩盖真问题。
|
|
1065
|
+
if (typeof apply !== 'function') {
|
|
1066
|
+
throw new SuggestError(
|
|
1067
|
+
'suggest.bulkAccept:缺少 apply 回调 —— 批量采纳同样必须真正入库(见 accept 的说明)',
|
|
1068
|
+
'SUGGEST_NO_APPLY',
|
|
1069
|
+
{ count: ids.length },
|
|
1070
|
+
)
|
|
1071
|
+
}
|
|
1072
|
+
// 去重(同一 id 传两次 → 第二次必然 BAD_STATE;先去重,并把去重条数回报,不静默)
|
|
1073
|
+
const unique = []
|
|
1074
|
+
const seen = new Set()
|
|
1075
|
+
for (const raw of ids) {
|
|
1076
|
+
const id = requireString(raw, 'ids[]', 'bulkAccept')
|
|
1077
|
+
if (seen.has(id)) continue
|
|
1078
|
+
seen.add(id)
|
|
1079
|
+
unique.push(id)
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
const accepted = []
|
|
1083
|
+
const failed = []
|
|
1084
|
+
for (const id of unique) {
|
|
1085
|
+
try {
|
|
1086
|
+
accepted.push(await accept({ id, by, now, apply }))
|
|
1087
|
+
} catch (error) {
|
|
1088
|
+
failed.push({
|
|
1089
|
+
id,
|
|
1090
|
+
code: (error && typeof error.code === 'string') ? error.code : 'SUGGEST_ERROR',
|
|
1091
|
+
reason: errorText(error),
|
|
1092
|
+
})
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
if (failed.length > 0) {
|
|
1096
|
+
warn(`[cs-bots/suggest] bulkAccept:${accepted.length} 条成功 / ${failed.length} 条失败(失败原因已随返回值回报)`)
|
|
1097
|
+
}
|
|
1098
|
+
info(`[cs-bots/suggest] bulkAccept:采纳 ${accepted.length} 条(去重 ${ids.length - unique.length} 条)`)
|
|
1099
|
+
return { accepted, failed, deduped: ids.length - unique.length }
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
// ── 审计 / 恢复 / 统计 ─────────────────────────────────────────────────────
|
|
1103
|
+
|
|
1104
|
+
/**
|
|
1105
|
+
* 事件历史(审计流)。`history(id)` 只取某一建议的事件。
|
|
1106
|
+
* @param {string} [id]
|
|
1107
|
+
* @returns {object[]}
|
|
1108
|
+
*/
|
|
1109
|
+
function history(id) {
|
|
1110
|
+
assertAlive('history')
|
|
1111
|
+
const rows = typeof id === 'string' && id !== ''
|
|
1112
|
+
? events.filter((e) => e.id === id)
|
|
1113
|
+
: events
|
|
1114
|
+
return clone(rows)
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
/**
|
|
1118
|
+
* 统计(给 /status 与 UI 概览用)。
|
|
1119
|
+
* @returns {{ total: number, pending: number, accepted: number, rejected: number,
|
|
1120
|
+
* byKind: object, bySource: object, events: number }}
|
|
1121
|
+
*/
|
|
1122
|
+
function stats() {
|
|
1123
|
+
assertAlive('stats')
|
|
1124
|
+
const byKind = { memory: 0, skill: 0, discipline: 0 }
|
|
1125
|
+
const bySource = { review: 0, consolidate: 0, model: 0 }
|
|
1126
|
+
let pending = 0
|
|
1127
|
+
let accepted = 0
|
|
1128
|
+
let rejected = 0
|
|
1129
|
+
for (const s of suggestions.values()) {
|
|
1130
|
+
byKind[s.kind] = (byKind[s.kind] ?? 0) + 1
|
|
1131
|
+
bySource[s.source] = (bySource[s.source] ?? 0) + 1
|
|
1132
|
+
if (s.status === 'pending') pending += 1
|
|
1133
|
+
else if (s.status === 'accepted') accepted += 1
|
|
1134
|
+
else if (s.status === 'rejected') rejected += 1
|
|
1135
|
+
}
|
|
1136
|
+
return {
|
|
1137
|
+
total: suggestions.size,
|
|
1138
|
+
pending,
|
|
1139
|
+
accepted,
|
|
1140
|
+
rejected,
|
|
1141
|
+
byKind,
|
|
1142
|
+
bySource,
|
|
1143
|
+
events: events.length,
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
/**
|
|
1148
|
+
* 从 `suggestions.jsonl` 重建内存态(重启回放一致性)。
|
|
1149
|
+
* @returns {{ from: string, suggestions: number, events: number, bad: number }}
|
|
1150
|
+
*/
|
|
1151
|
+
function replay() {
|
|
1152
|
+
assertAlive('replay')
|
|
1153
|
+
hydrate()
|
|
1154
|
+
diagnostics.lastReplayAt = Date.now()
|
|
1155
|
+
return {
|
|
1156
|
+
from: diagnostics.hydratedFrom ?? 'empty',
|
|
1157
|
+
suggestions: suggestions.size,
|
|
1158
|
+
events: events.length,
|
|
1159
|
+
bad: diagnostics.badLines,
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
/**
|
|
1164
|
+
* 销毁实例(释放内存、拒绝后续调用)。
|
|
1165
|
+
* 为什么不写盘/不删文件:事件流是长期资产(「谁在什么时候建议了什么、谁采纳的」),
|
|
1166
|
+
* dispose 只是「这个实例下班了」,不等于删数据。
|
|
1167
|
+
* @returns {{ disposed: boolean, suggestions: number, events: number }}
|
|
1168
|
+
*/
|
|
1169
|
+
function dispose() {
|
|
1170
|
+
const out = { disposed: true, suggestions: suggestions.size, events: events.length }
|
|
1171
|
+
disposed = true
|
|
1172
|
+
suggestions.clear()
|
|
1173
|
+
events.length = 0
|
|
1174
|
+
deciding.clear()
|
|
1175
|
+
return out
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
/** 首次构造即回放,保证 `listSuggestions()` 在重启后马上可用(否则每次重启红点清零)。 */
|
|
1179
|
+
hydrate()
|
|
1180
|
+
|
|
1181
|
+
const service = {
|
|
1182
|
+
name: cfg.name ?? 'suggest',
|
|
1183
|
+
paths: Object.freeze({ stateDir, jsonl: jsonlPath, snapshot: snapPath }),
|
|
1184
|
+
// 契约函数名(任务书/API 层按这些名字调)
|
|
1185
|
+
suggest,
|
|
1186
|
+
listSuggestions,
|
|
1187
|
+
getSuggestion,
|
|
1188
|
+
accept,
|
|
1189
|
+
reject,
|
|
1190
|
+
bulkAccept,
|
|
1191
|
+
stats,
|
|
1192
|
+
replay,
|
|
1193
|
+
history,
|
|
1194
|
+
dispose,
|
|
1195
|
+
// 短别名:api 层若按 board 的 create/get/list 风格调用也能直接走
|
|
1196
|
+
list: listSuggestions,
|
|
1197
|
+
get: getSuggestion,
|
|
1198
|
+
diagnostics: () => clone(diagnostics),
|
|
1199
|
+
}
|
|
1200
|
+
return service
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
export default createSuggest
|