cs-bots 0.1.0 → 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/prompts.js
ADDED
|
@@ -0,0 +1,1318 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cs-bots PromptService —— 提示词库 + 注入轨(对标 dsh-memory-evolve 的 Prompt Manager,
|
|
3
|
+
* 但**完全自建、零第三方依赖**:只用 `node:*` 内置 + `./store.js` / `./kernel.js`)。
|
|
4
|
+
*
|
|
5
|
+
* ── 两个概念,别混 ──
|
|
6
|
+
* **提示词库(Prompt)**:可复用资产。有名字/分类/标签/正文/启用开关,CRUD + CAS。
|
|
7
|
+
* **注入轨(Injection)**:把某条提示词(或一段临时文本)按「次数 × 间隔」投进
|
|
8
|
+
* 某个会话的系统提示词。注入是**运行时调度**,不是内容:删提示词会级联清它的注入,
|
|
9
|
+
* 但改提示词正文会被后续注入自动读到新版本(见下「为什么正文实时解析」)。
|
|
10
|
+
*
|
|
11
|
+
* ── 三条不变量(与 board.js / discipline.js 同款,机械保证)──
|
|
12
|
+
* 1. **CAS 优先**:prompt/injection 的任何变更都带 `expectedRev`;不匹配即抛错。
|
|
13
|
+
* 2. **事件流是真相源**:先 append `prompts.jsonl`(失败即抛错,内存原封不动),
|
|
14
|
+
* 再改内存,最后原子写 `prompts.snap.json`(快照失败只降级 warn —— 事件流可 replay)。
|
|
15
|
+
* 3. **不静默失败**:未知字段、非法输入、不存在的 id 一律抛 `PromptError`(带 code+detail)。
|
|
16
|
+
*
|
|
17
|
+
* ── 为什么注入要单独一套「拿到即计数」的机制 ──
|
|
18
|
+
* `activeInjectionsFor()` 只负责**算出本轮该投什么**(纯查询);真正的
|
|
19
|
+
* 「消耗一次 / 剩余次数 -1 / 归零移除」在 `tickTurn()` 里做。
|
|
20
|
+
* 两者分离的理由:提示词装配可能在同一回合被调用多次(UI 预览、retry、多步推理),
|
|
21
|
+
* 如果查询本身扣次数,预览就会把注入预算吃光。所以查询只在内存里打一个
|
|
22
|
+
* 「本轮已产出」标记(pending),tick 时按标记精确扣一次 —— 同一回合重复查询幂等。
|
|
23
|
+
*
|
|
24
|
+
* ── 为什么 `useCount` 不走 prompt 的 rev(重要)──
|
|
25
|
+
* useCount 是**遥测**,不是内容版本。若每次注入都 `rev+1` 去写它,后台持续注入
|
|
26
|
+
* 会把 rev 一直推高,人在 UI 里编辑提示词几乎必然 CAS 冲突("版本冲突请刷新")。
|
|
27
|
+
* 所以 useCount 用独立的 `usage` 事件累计(不碰 prompt.rev),回放时重放计数,
|
|
28
|
+
* 计数按「每回合每提示词一条事件(带 count)」合并写入,避免 N 条注入产生 N 条事件。
|
|
29
|
+
*
|
|
30
|
+
* ── 为什么注入正文在**产出时**实时解析提示词 body ──
|
|
31
|
+
* 用户改了提示词正文,期望「持续注入」下一回合就念新版本,而不是继续念旧快照。
|
|
32
|
+
* `injection.text` 只作为创建时的留档与 ad-hoc 文本的载体;产出时以 prompt.body 为准
|
|
33
|
+
* (提示词被删会级联清注入,不会出现悬空引用)。
|
|
34
|
+
*
|
|
35
|
+
* 依赖面:node 内置(crypto)+ `./store.js` + `./kernel.js`。不 import 任何宿主包/第三方包。
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import { randomBytes } from 'node:crypto'
|
|
39
|
+
|
|
40
|
+
import {
|
|
41
|
+
appendJsonl,
|
|
42
|
+
readJson,
|
|
43
|
+
readJsonl,
|
|
44
|
+
statePath,
|
|
45
|
+
writeJsonAtomic,
|
|
46
|
+
} from './store.js'
|
|
47
|
+
|
|
48
|
+
// ── kernel 惰性桥(与 board.js 同款,裸检出时静态 import 会连累测试起不来)──────
|
|
49
|
+
|
|
50
|
+
/** @type {object | null} */
|
|
51
|
+
let kernel = null
|
|
52
|
+
/** @type {unknown} */
|
|
53
|
+
let kernelError = null
|
|
54
|
+
try {
|
|
55
|
+
kernel = await import('./kernel.js')
|
|
56
|
+
} catch (error) {
|
|
57
|
+
kernelError = error
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ── 常量(契约冻结)──────────────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
/** 未分类:分类被删除时,其下提示词迁到这里;**不可删除**,是兜底。 */
|
|
63
|
+
export const DEFAULT_CATEGORY = '未分类'
|
|
64
|
+
|
|
65
|
+
/** 缺省分类(首次启动播种;可被用户删除,删除动作会进事件流,回放后仍然生效)。 */
|
|
66
|
+
export const DEFAULT_CATEGORIES = ['未分类', '通用', '编程', '写作', '分析']
|
|
67
|
+
|
|
68
|
+
/** 名称上限(字符)。 */
|
|
69
|
+
export const MAX_NAME = 200
|
|
70
|
+
/** 描述上限(字符)。 */
|
|
71
|
+
export const MAX_DESCRIPTION = 2000
|
|
72
|
+
/** 正文上限(UTF-8 字节)——提示词是往系统提示词里塞的东西,太大直接爆上下文。 */
|
|
73
|
+
export const MAX_BODY = 256 * 1024
|
|
74
|
+
/** 标签个数上限。 */
|
|
75
|
+
export const MAX_TAGS = 20
|
|
76
|
+
/** 单个标签长度上限(字符)。 */
|
|
77
|
+
export const MAX_TAG_LEN = 48
|
|
78
|
+
/** 分类名长度上限(字符)。 */
|
|
79
|
+
export const MAX_CATEGORY_LEN = 64
|
|
80
|
+
/** 会话 id 长度上限(字符)。 */
|
|
81
|
+
export const MAX_SESSION_ID = 200
|
|
82
|
+
/** 提示词 id 前缀。 */
|
|
83
|
+
export const PROMPT_ID_PREFIX = 'pt-'
|
|
84
|
+
/** 注入 id 前缀。 */
|
|
85
|
+
export const INJECTION_ID_PREFIX = 'inj-'
|
|
86
|
+
|
|
87
|
+
/** 事件 kind(落盘事件的联合类型)。 */
|
|
88
|
+
const EVENT_KINDS = ['prompt', 'injection', 'category', 'session', 'usage']
|
|
89
|
+
|
|
90
|
+
// ── 错误 ────────────────────────────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* 提示词/注入结构化错误。所有失败都走它,**绝不静默**。
|
|
94
|
+
* 同时挂 `detail` 与 `details`(契约字段名 + 本仓既有习惯,两种取法都拿得到)。
|
|
95
|
+
*/
|
|
96
|
+
export class PromptError extends Error {
|
|
97
|
+
/**
|
|
98
|
+
* @param {string} message 人类可读、含定位信息(id / rev / 字段名)
|
|
99
|
+
* @param {string} [code]
|
|
100
|
+
* @param {object} [detail]
|
|
101
|
+
*/
|
|
102
|
+
constructor(message, code = 'PROMPT_ERROR', detail) {
|
|
103
|
+
super(message)
|
|
104
|
+
this.name = 'PromptError'
|
|
105
|
+
this.code = code
|
|
106
|
+
if (detail !== undefined) {
|
|
107
|
+
this.detail = detail
|
|
108
|
+
this.details = detail
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ── 小工具 ──────────────────────────────────────────────────────────────────
|
|
114
|
+
|
|
115
|
+
/** 深拷贝 + JSON 归一(去掉 undefined 键,避免内存态与磁盘态不等)。 */
|
|
116
|
+
function clone(value) {
|
|
117
|
+
if (value === undefined || value === null) return value
|
|
118
|
+
return JSON.parse(JSON.stringify(value))
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** 归一化 now(可注入,测试用):非有限数兜底当前时间,永不 NaN。 */
|
|
122
|
+
function normalizeNow(now) {
|
|
123
|
+
return Number.isFinite(now) ? Math.floor(now) : Date.now()
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** 归一化「谁在操作」——所有写操作都必须能追到人/会话,不允许匿名写入。 */
|
|
127
|
+
function requireActor(input, where) {
|
|
128
|
+
const raw = input?.by ?? input?.byId
|
|
129
|
+
if (typeof raw !== 'string' || raw.trim() === '') {
|
|
130
|
+
throw new PromptError(
|
|
131
|
+
`prompts.${where}:缺少操作者 by(所有变更都必须能追到人/会话,拒绝匿名写入)`,
|
|
132
|
+
'PROMPT_MISSING_ACTOR',
|
|
133
|
+
{ where },
|
|
134
|
+
)
|
|
135
|
+
}
|
|
136
|
+
return raw.trim()
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** 非空字符串字段校验。 */
|
|
140
|
+
function requireString(value, field, where, max = 0) {
|
|
141
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
142
|
+
throw new PromptError(
|
|
143
|
+
`prompts.${where}:${field} 必须是非空字符串(收到 ${value === null ? 'null' : JSON.stringify(value)})`,
|
|
144
|
+
'PROMPT_BAD_INPUT',
|
|
145
|
+
{ field, where },
|
|
146
|
+
)
|
|
147
|
+
}
|
|
148
|
+
const out = value.trim()
|
|
149
|
+
if (max > 0 && out.length > max) {
|
|
150
|
+
throw new PromptError(
|
|
151
|
+
`prompts.${where}:${field} 超过上限 ${max} 字符(实际 ${out.length})`,
|
|
152
|
+
'PROMPT_BAD_INPUT',
|
|
153
|
+
{ field, where, length: out.length, max },
|
|
154
|
+
)
|
|
155
|
+
}
|
|
156
|
+
return out
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** 提示词名称。 */
|
|
160
|
+
function normalizeName(value, where) {
|
|
161
|
+
return requireString(value, 'name', where, MAX_NAME)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** 描述(可空;空串是合法值,表示"没写描述")。 */
|
|
165
|
+
function normalizeDescription(value, where) {
|
|
166
|
+
if (value === undefined || value === null) return ''
|
|
167
|
+
if (typeof value !== 'string') {
|
|
168
|
+
throw new PromptError(
|
|
169
|
+
`prompts.${where}:description 必须是字符串(收到 ${typeof value})`,
|
|
170
|
+
'PROMPT_BAD_INPUT',
|
|
171
|
+
{ field: 'description', where },
|
|
172
|
+
)
|
|
173
|
+
}
|
|
174
|
+
const out = value.trim()
|
|
175
|
+
if (out.length > MAX_DESCRIPTION) {
|
|
176
|
+
throw new PromptError(
|
|
177
|
+
`prompts.${where}:description 超过上限 ${MAX_DESCRIPTION} 字符(实际 ${out.length})`,
|
|
178
|
+
'PROMPT_BAD_INPUT',
|
|
179
|
+
{ field: 'description', where, length: out.length, max: MAX_DESCRIPTION },
|
|
180
|
+
)
|
|
181
|
+
}
|
|
182
|
+
return out
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** 正文(必须是字符串;按 UTF-8 字节判上限)。 */
|
|
186
|
+
function normalizeBody(value, where) {
|
|
187
|
+
if (typeof value !== 'string') {
|
|
188
|
+
throw new PromptError(
|
|
189
|
+
`prompts.${where}:body 必须是字符串(收到 ${value === null ? 'null' : typeof value})`,
|
|
190
|
+
'PROMPT_BAD_BODY',
|
|
191
|
+
{ where, type: typeof value },
|
|
192
|
+
)
|
|
193
|
+
}
|
|
194
|
+
const bytes = Buffer.byteLength(value, 'utf8')
|
|
195
|
+
if (bytes > MAX_BODY) {
|
|
196
|
+
throw new PromptError(
|
|
197
|
+
`prompts.${where}:body ${bytes} 字节超过上限 ${MAX_BODY} 字节(256 KiB)——请拆分提示词`,
|
|
198
|
+
'PROMPT_TOO_LARGE',
|
|
199
|
+
{ where, bytes, max: MAX_BODY },
|
|
200
|
+
)
|
|
201
|
+
}
|
|
202
|
+
return value
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** 标签:去空、去重(大小写敏感去重,保留用户原始大小写)、限个数与长度。 */
|
|
206
|
+
function normalizeTags(value, where) {
|
|
207
|
+
if (value === undefined || value === null) return []
|
|
208
|
+
if (!Array.isArray(value)) {
|
|
209
|
+
throw new PromptError(
|
|
210
|
+
`prompts.${where}:tags 必须是字符串数组(收到 ${typeof value})`,
|
|
211
|
+
'PROMPT_BAD_TAGS',
|
|
212
|
+
{ where, type: typeof value },
|
|
213
|
+
)
|
|
214
|
+
}
|
|
215
|
+
const out = []
|
|
216
|
+
const seen = new Set()
|
|
217
|
+
for (const raw of value) {
|
|
218
|
+
if (typeof raw !== 'string') {
|
|
219
|
+
throw new PromptError(
|
|
220
|
+
`prompts.${where}:tags 含非字符串项(${typeof raw})`,
|
|
221
|
+
'PROMPT_BAD_TAGS',
|
|
222
|
+
{ where, item: String(raw) },
|
|
223
|
+
)
|
|
224
|
+
}
|
|
225
|
+
const tag = raw.trim()
|
|
226
|
+
if (tag === '' || seen.has(tag)) continue
|
|
227
|
+
if (tag.length > MAX_TAG_LEN) {
|
|
228
|
+
throw new PromptError(
|
|
229
|
+
`prompts.${where}:标签 ${JSON.stringify(tag)} 超过 ${MAX_TAG_LEN} 字符`,
|
|
230
|
+
'PROMPT_BAD_TAGS',
|
|
231
|
+
{ where, tag, max: MAX_TAG_LEN },
|
|
232
|
+
)
|
|
233
|
+
}
|
|
234
|
+
seen.add(tag)
|
|
235
|
+
out.push(tag)
|
|
236
|
+
}
|
|
237
|
+
if (out.length > MAX_TAGS) {
|
|
238
|
+
throw new PromptError(
|
|
239
|
+
`prompts.${where}:标签最多 ${MAX_TAGS} 个(实际 ${out.length})`,
|
|
240
|
+
'PROMPT_BAD_TAGS',
|
|
241
|
+
{ where, count: out.length, max: MAX_TAGS },
|
|
242
|
+
)
|
|
243
|
+
}
|
|
244
|
+
return out
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** 分类名(缺省落到「未分类」;`requireExplicit=true` 用于 addCategory 这种必须点名的场景)。 */
|
|
248
|
+
function normalizeCategoryName(value, where, requireExplicit = false) {
|
|
249
|
+
if (value === undefined || value === null || value === '') {
|
|
250
|
+
if (requireExplicit) {
|
|
251
|
+
throw new PromptError(
|
|
252
|
+
`prompts.${where}:category 不能为空`,
|
|
253
|
+
'PROMPT_BAD_CATEGORY',
|
|
254
|
+
{ where },
|
|
255
|
+
)
|
|
256
|
+
}
|
|
257
|
+
return DEFAULT_CATEGORY
|
|
258
|
+
}
|
|
259
|
+
if (typeof value !== 'string') {
|
|
260
|
+
throw new PromptError(
|
|
261
|
+
`prompts.${where}:category 必须是字符串(收到 ${typeof value})`,
|
|
262
|
+
'PROMPT_BAD_CATEGORY',
|
|
263
|
+
{ where, type: typeof value },
|
|
264
|
+
)
|
|
265
|
+
}
|
|
266
|
+
const name = value.trim()
|
|
267
|
+
if (name === '') {
|
|
268
|
+
if (requireExplicit) {
|
|
269
|
+
throw new PromptError(`prompts.${where}:category 不能为空`, 'PROMPT_BAD_CATEGORY', { where })
|
|
270
|
+
}
|
|
271
|
+
return DEFAULT_CATEGORY
|
|
272
|
+
}
|
|
273
|
+
if (name.length > MAX_CATEGORY_LEN) {
|
|
274
|
+
throw new PromptError(
|
|
275
|
+
`prompts.${where}:分类名超过 ${MAX_CATEGORY_LEN} 字符(实际 ${name.length})`,
|
|
276
|
+
'PROMPT_BAD_CATEGORY',
|
|
277
|
+
{ where, length: name.length, max: MAX_CATEGORY_LEN },
|
|
278
|
+
)
|
|
279
|
+
}
|
|
280
|
+
return name
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** 启用开关:缺省 true;只接受布尔('false' 字符串这种歧义输入直接拒绝)。 */
|
|
284
|
+
function normalizeEnabled(value, where) {
|
|
285
|
+
if (value === undefined || value === null) return true
|
|
286
|
+
if (typeof value !== 'boolean') {
|
|
287
|
+
throw new PromptError(
|
|
288
|
+
`prompts.${where}:enabled 必须是布尔值(收到 ${JSON.stringify(value)})`,
|
|
289
|
+
'PROMPT_BAD_ENABLED',
|
|
290
|
+
{ where, value },
|
|
291
|
+
)
|
|
292
|
+
}
|
|
293
|
+
return value
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** 注入次数:0 = 无限(默认),N = 有限 N 次。 */
|
|
297
|
+
function normalizeTimes(value, where) {
|
|
298
|
+
if (value === undefined || value === null) return 0
|
|
299
|
+
if (!Number.isInteger(value) || value < 0) {
|
|
300
|
+
throw new PromptError(
|
|
301
|
+
`prompts.${where}:times 必须是 >=0 的整数(0=无限,N=有限 N 次;收到 ${JSON.stringify(value)})`,
|
|
302
|
+
'PROMPT_BAD_TIMING',
|
|
303
|
+
{ where, field: 'times', value },
|
|
304
|
+
)
|
|
305
|
+
}
|
|
306
|
+
return value
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** 注入间隔:1 = 每回合(默认),N = 每 N 回合,0 = 只注入一次。 */
|
|
310
|
+
function normalizeEvery(value, where) {
|
|
311
|
+
if (value === undefined || value === null) return 1
|
|
312
|
+
if (!Number.isInteger(value) || value < 0) {
|
|
313
|
+
throw new PromptError(
|
|
314
|
+
`prompts.${where}:every 必须是 >=0 的整数(1=每回合,N=每 N 回合,0=只注入一次;收到 ${JSON.stringify(value)})`,
|
|
315
|
+
'PROMPT_BAD_TIMING',
|
|
316
|
+
{ where, field: 'every', value },
|
|
317
|
+
)
|
|
318
|
+
}
|
|
319
|
+
return value
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** 会话 id。 */
|
|
323
|
+
function normalizeSessionId(value, where) {
|
|
324
|
+
return requireString(value, 'sessionId', where, MAX_SESSION_ID)
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** 生成 `pt-xxxxxxxx` / `inj-xxxxxxxx`(8 位十六进制;碰撞则重试,绝不复用已存在的 id)。 */
|
|
328
|
+
function newId(prefix, taken) {
|
|
329
|
+
for (let i = 0; i < 100; i += 1) {
|
|
330
|
+
const id = `${prefix}${randomBytes(4).toString('hex')}`
|
|
331
|
+
if (!taken.has(id)) return id
|
|
332
|
+
}
|
|
333
|
+
throw new PromptError(
|
|
334
|
+
`prompts:连续 100 次生成 ${prefix}* id 均碰撞(几乎必然是随机源异常)`,
|
|
335
|
+
'PROMPT_ID_EXHAUSTED',
|
|
336
|
+
{ prefix },
|
|
337
|
+
)
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** 补零两位。 */
|
|
341
|
+
function pad2(n) {
|
|
342
|
+
return String(n).padStart(2, '0')
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** 本地日期 `YYYY-MM-DD`。 */
|
|
346
|
+
function formatDate(date) {
|
|
347
|
+
return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** 本地时间 `HH:mm`。 */
|
|
351
|
+
function formatTime(date) {
|
|
352
|
+
return `${pad2(date.getHours())}:${pad2(date.getMinutes())}`
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* 变量展开:`{{date}}` → 本地 `YYYY-MM-DD`,`{{time}}` → 本地 `HH:mm`。
|
|
357
|
+
* **在产出时展开**(不是创建时),这样"每天早上注入今日计划"这类提示词不会写着写着变成昨天。
|
|
358
|
+
* 未知变量原样保留(未来 `{{path}}`/`{{event}}` 由调用方自行扩展)。
|
|
359
|
+
*/
|
|
360
|
+
export function expandTemplates(text, now) {
|
|
361
|
+
const d = new Date(Number.isFinite(now) ? now : Date.now())
|
|
362
|
+
return String(text ?? '')
|
|
363
|
+
.replace(/\{\{date\}\}/g, formatDate(d))
|
|
364
|
+
.replace(/\{\{time\}\}/g, formatTime(d))
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* 解析状态目录:`config.stateDir` > `kernel.stateDir()` > 抛错(与 board/discipline 一致)。
|
|
369
|
+
* @param {{ stateDir?: string }} config
|
|
370
|
+
* @returns {string}
|
|
371
|
+
*/
|
|
372
|
+
function resolveStateDir(config) {
|
|
373
|
+
if (typeof config?.stateDir === 'string' && config.stateDir.trim() !== '') {
|
|
374
|
+
return config.stateDir.trim()
|
|
375
|
+
}
|
|
376
|
+
if (kernel && typeof kernel.stateDir === 'function') {
|
|
377
|
+
return kernel.stateDir()
|
|
378
|
+
}
|
|
379
|
+
const reason = kernelError?.message ?? (kernel ? 'kernel.stateDir 不存在' : 'kernel 未加载')
|
|
380
|
+
throw new PromptError(
|
|
381
|
+
`prompts.createPrompts:既未提供 config.stateDir,也无法从 kernel.js 取得 stateDir(${reason})。`
|
|
382
|
+
+ '裸检出(无宿主包)下必须显式传 config.stateDir。',
|
|
383
|
+
'PROMPT_NO_STATE_DIR',
|
|
384
|
+
)
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** `(ctx, config)` 归一,允许只传一个 config 对象的简写(与 discipline.js 同款)。 */
|
|
388
|
+
function splitArgs(ctx, config) {
|
|
389
|
+
const cfgGiven = config && typeof config === 'object' && Object.keys(config).length > 0
|
|
390
|
+
if (cfgGiven) return { context: ctx ?? {}, config }
|
|
391
|
+
if (ctx && typeof ctx === 'object' && typeof ctx.stateDir === 'string') {
|
|
392
|
+
return { context: {}, config: ctx }
|
|
393
|
+
}
|
|
394
|
+
return { context: ctx ?? {}, config: config ?? {} }
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// ── 工厂 ────────────────────────────────────────────────────────────────────
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* 创建提示词/注入服务实例。
|
|
401
|
+
*
|
|
402
|
+
* @param {{ logger?: { info?: Function, warn?: Function } }} [ctx] 宿主上下文(缺失即降级 no-op 日志)
|
|
403
|
+
* @param {{ stateDir?: string, name?: string }} [config]
|
|
404
|
+
* @returns {object} prompts service
|
|
405
|
+
*/
|
|
406
|
+
export function createPrompts(ctx, config = {}) {
|
|
407
|
+
const { context, config: cfg } = splitArgs(ctx, config)
|
|
408
|
+
const stateDir = resolveStateDir(cfg)
|
|
409
|
+
|
|
410
|
+
const jsonlPath = statePath(stateDir, 'prompts.jsonl')
|
|
411
|
+
const snapPath = statePath(stateDir, 'prompts.snap.json')
|
|
412
|
+
|
|
413
|
+
const info = (...args) => { try { context?.logger?.info?.(...args) } catch { /* 日志失败不影响业务 */ } }
|
|
414
|
+
const warn = (...args) => { try { context?.logger?.warn?.(...args) } catch { /* 同上 */ } }
|
|
415
|
+
|
|
416
|
+
/** @type {Map<string, object>} id → Prompt(插入序 = 创建序) */
|
|
417
|
+
const prompts = new Map()
|
|
418
|
+
/** @type {Map<string, object>} id → Injection(只存活跃注入;移除即删) */
|
|
419
|
+
const injections = new Map()
|
|
420
|
+
/** @type {Map<string, object>} name → { name, createdAt, createdBy } */
|
|
421
|
+
const categories = new Map()
|
|
422
|
+
/** @type {Map<string, number>} sessionId → 已完成的回合数(回合序号从 1 开始) */
|
|
423
|
+
const sessions = new Map()
|
|
424
|
+
/** @type {Map<string, Set<string>>} sessionId → 「本轮已产出、待 tick 扣次数」的注入 id */
|
|
425
|
+
const pendingConsume = new Map()
|
|
426
|
+
/** @type {object[]} 全部事件(审计流,按落盘顺序) */
|
|
427
|
+
const events = []
|
|
428
|
+
|
|
429
|
+
let disposed = false
|
|
430
|
+
|
|
431
|
+
const diagnostics = {
|
|
432
|
+
stateDir,
|
|
433
|
+
jsonlPath,
|
|
434
|
+
snapPath,
|
|
435
|
+
kernel: kernel ? 'loaded' : `load-failed:${kernelError?.name ?? 'unknown'}`,
|
|
436
|
+
hydratedFrom: /** @type {null|'jsonl'|'snapshot'|'empty'} */ (null),
|
|
437
|
+
replayedEvents: 0,
|
|
438
|
+
badLines: 0,
|
|
439
|
+
snapshotWriteFailures: 0,
|
|
440
|
+
lastReplayAt: /** @type {number|undefined} */ (undefined),
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/** 已销毁实例的调用一律抛错(静默返回空列表会被误解成"提示词全丢了")。 */
|
|
444
|
+
function assertAlive(where) {
|
|
445
|
+
if (disposed) {
|
|
446
|
+
throw new PromptError(
|
|
447
|
+
`prompts.${where}:实例已 dispose(),请重新 createPrompts()(拒绝在已销毁实例上静默返回空数据)`,
|
|
448
|
+
'PROMPT_DISPOSED',
|
|
449
|
+
{ where },
|
|
450
|
+
)
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// ── 持久化 ────────────────────────────────────────────────────────────────
|
|
455
|
+
|
|
456
|
+
/** 快照内容(落盘形态)。 */
|
|
457
|
+
function buildSnapshot() {
|
|
458
|
+
return {
|
|
459
|
+
version: 1,
|
|
460
|
+
savedAt: Date.now(),
|
|
461
|
+
prompts: [...prompts.values()],
|
|
462
|
+
injections: [...injections.values()],
|
|
463
|
+
categories: [...categories.values()],
|
|
464
|
+
sessions: Object.fromEntries(sessions),
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* 通用事件应用:**回放与新写入走同一函数**,保证「内存态」与「回放态」逐字一致
|
|
470
|
+
* (否则 restart 后状态会凭空分叉,这是最恶劣的静默失败)。
|
|
471
|
+
*/
|
|
472
|
+
function applyEvent(ev) {
|
|
473
|
+
switch (ev.kind) {
|
|
474
|
+
case 'prompt':
|
|
475
|
+
if (ev.after && typeof ev.after.id === 'string') prompts.set(ev.after.id, clone(ev.after))
|
|
476
|
+
else prompts.delete(ev.id)
|
|
477
|
+
break
|
|
478
|
+
case 'injection':
|
|
479
|
+
if (ev.after && typeof ev.after.id === 'string') injections.set(ev.after.id, clone(ev.after))
|
|
480
|
+
else injections.delete(ev.id)
|
|
481
|
+
break
|
|
482
|
+
case 'category':
|
|
483
|
+
if (ev.action === 'remove' || !ev.after) categories.delete(ev.id)
|
|
484
|
+
else categories.set(ev.after.name, clone(ev.after))
|
|
485
|
+
break
|
|
486
|
+
case 'session':
|
|
487
|
+
// 只接受推进(同时容忍重复投递)
|
|
488
|
+
if (Number.isFinite(ev.rev)) sessions.set(ev.id, Math.max(sessions.get(ev.id) ?? 0, ev.rev))
|
|
489
|
+
break
|
|
490
|
+
case 'usage': {
|
|
491
|
+
const prompt = prompts.get(ev.id)
|
|
492
|
+
if (prompt) {
|
|
493
|
+
const delta = Number.isInteger(ev.after?.count) && ev.after.count > 0 ? ev.after.count : 1
|
|
494
|
+
prompt.useCount = (Number.isInteger(prompt.useCount) ? prompt.useCount : 0) + delta
|
|
495
|
+
}
|
|
496
|
+
break
|
|
497
|
+
}
|
|
498
|
+
default:
|
|
499
|
+
break
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/** 写快照(失败只 warn:事件流已落盘,replay 能重建)。 */
|
|
504
|
+
function persistSnapshot() {
|
|
505
|
+
if (!writeJsonAtomic(snapPath, buildSnapshot())) {
|
|
506
|
+
diagnostics.snapshotWriteFailures += 1
|
|
507
|
+
warn(`[cs-bots/prompts] 快照写入失败(${snapPath})——事件流已落盘,replay() 可完整恢复`)
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* 提交一次变更:**先落事件,再改内存,最后写快照**。顺序不可颠倒
|
|
513
|
+
* (append 失败时内存必须原封不动,否则内存与磁盘分叉)。
|
|
514
|
+
*
|
|
515
|
+
* @param {'prompt'|'injection'|'category'|'session'|'usage'} kind
|
|
516
|
+
* @param {string} id
|
|
517
|
+
* @param {string} action
|
|
518
|
+
* @param {number} rev
|
|
519
|
+
* @param {string|null} byId
|
|
520
|
+
* @param {number} at
|
|
521
|
+
* @param {object|null} before
|
|
522
|
+
* @param {object|null} after
|
|
523
|
+
* @returns {object|null} 克隆后的 after
|
|
524
|
+
*/
|
|
525
|
+
function commit(kind, id, action, rev, byId, at, before, after) {
|
|
526
|
+
const event = {
|
|
527
|
+
kind,
|
|
528
|
+
id,
|
|
529
|
+
rev,
|
|
530
|
+
action,
|
|
531
|
+
byId: byId ?? null,
|
|
532
|
+
at,
|
|
533
|
+
before: before ? clone(before) : null,
|
|
534
|
+
after: after ? clone(after) : null,
|
|
535
|
+
}
|
|
536
|
+
if (!appendJsonl(jsonlPath, event)) {
|
|
537
|
+
throw new PromptError(
|
|
538
|
+
`prompts.${action}:事件追加失败(${jsonlPath})——拒绝「只改内存不落盘」`,
|
|
539
|
+
'PROMPT_APPEND_FAILED',
|
|
540
|
+
{ kind, id, action, path: jsonlPath },
|
|
541
|
+
)
|
|
542
|
+
}
|
|
543
|
+
applyEvent(event)
|
|
544
|
+
events.push(event)
|
|
545
|
+
persistSnapshot()
|
|
546
|
+
return after ? clone(after) : null
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// ── 回放 ──────────────────────────────────────────────────────────────────
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* 从 `prompts.jsonl`(真相源)重建内存态;jsonl 为空才回落到快照。
|
|
553
|
+
* 坏行**不静默丢弃**:计入 diagnostics 并 warn。
|
|
554
|
+
*
|
|
555
|
+
* 缺省分类先播种再回放事件 —— 这样"用户删除了缺省分类"这个动作在回放后仍然生效
|
|
556
|
+
* (否则每次重启默认分类又长回来,与用户操作相悖)。
|
|
557
|
+
*/
|
|
558
|
+
function hydrate() {
|
|
559
|
+
prompts.clear()
|
|
560
|
+
injections.clear()
|
|
561
|
+
categories.clear()
|
|
562
|
+
sessions.clear()
|
|
563
|
+
pendingConsume.clear()
|
|
564
|
+
events.length = 0
|
|
565
|
+
disposed = false
|
|
566
|
+
|
|
567
|
+
for (const name of DEFAULT_CATEGORIES) {
|
|
568
|
+
categories.set(name, { name, createdAt: 0, createdBy: 'system' })
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
const { rows, bad } = readJsonl(jsonlPath)
|
|
572
|
+
diagnostics.badLines = bad
|
|
573
|
+
diagnostics.replayedEvents = rows.length
|
|
574
|
+
if (bad > 0) {
|
|
575
|
+
warn(`[cs-bots/prompts] ${jsonlPath} 有 ${bad} 行无法解析(撕裂写或文件损坏)——已跳过,请核对`)
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
if (rows.length === 0) {
|
|
579
|
+
const snap = readJson(snapPath, null)
|
|
580
|
+
if (snap && typeof snap === 'object' && Array.isArray(snap.prompts)) {
|
|
581
|
+
for (const raw of snap.prompts) {
|
|
582
|
+
if (!raw || typeof raw.id !== 'string') continue
|
|
583
|
+
const p = clone(raw)
|
|
584
|
+
if (!Array.isArray(p.tags)) p.tags = []
|
|
585
|
+
if (typeof p.useCount !== 'number') p.useCount = 0
|
|
586
|
+
prompts.set(p.id, p)
|
|
587
|
+
}
|
|
588
|
+
for (const raw of Array.isArray(snap.injections) ? snap.injections : []) {
|
|
589
|
+
if (!raw || typeof raw.id !== 'string') continue
|
|
590
|
+
injections.set(raw.id, clone(raw))
|
|
591
|
+
}
|
|
592
|
+
if (Array.isArray(snap.categories)) {
|
|
593
|
+
categories.clear()
|
|
594
|
+
for (const raw of snap.categories) {
|
|
595
|
+
if (!raw || typeof raw.name !== 'string') continue
|
|
596
|
+
categories.set(raw.name, clone(raw))
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
if (snap.sessions && typeof snap.sessions === 'object') {
|
|
600
|
+
for (const [sid, turn] of Object.entries(snap.sessions)) {
|
|
601
|
+
if (typeof sid === 'string' && Number.isFinite(turn)) sessions.set(sid, turn)
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
diagnostics.hydratedFrom = 'snapshot'
|
|
605
|
+
info(`[cs-bots/prompts] 回放完成:无事件流 → 由快照恢复 ${prompts.size} 条提示词 / ${injections.size} 条注入`)
|
|
606
|
+
return
|
|
607
|
+
}
|
|
608
|
+
diagnostics.hydratedFrom = 'empty'
|
|
609
|
+
info('[cs-bots/prompts] 回放完成:空库(无事件流、无快照)')
|
|
610
|
+
return
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// 事件流是真相源:prompt/injection 只接受 rev 递增事件(同 rev 重放幂等,容忍重复投递)
|
|
614
|
+
const seenRev = new Map()
|
|
615
|
+
let skipped = 0
|
|
616
|
+
for (const ev of rows) {
|
|
617
|
+
if (!ev || typeof ev.kind !== 'string' || typeof ev.id !== 'string') {
|
|
618
|
+
skipped += 1
|
|
619
|
+
continue
|
|
620
|
+
}
|
|
621
|
+
if (!EVENT_KINDS.includes(ev.kind)) {
|
|
622
|
+
skipped += 1
|
|
623
|
+
continue
|
|
624
|
+
}
|
|
625
|
+
if (ev.kind === 'prompt' || ev.kind === 'injection') {
|
|
626
|
+
if (!Number.isFinite(ev.rev)) {
|
|
627
|
+
skipped += 1
|
|
628
|
+
continue
|
|
629
|
+
}
|
|
630
|
+
const key = `${ev.kind}:${ev.id}`
|
|
631
|
+
const seen = seenRev.get(key) ?? 0
|
|
632
|
+
if (ev.rev <= seen) {
|
|
633
|
+
skipped += 1
|
|
634
|
+
continue
|
|
635
|
+
}
|
|
636
|
+
seenRev.set(key, ev.rev)
|
|
637
|
+
}
|
|
638
|
+
applyEvent(ev)
|
|
639
|
+
events.push(clone(ev))
|
|
640
|
+
}
|
|
641
|
+
if (skipped > 0) {
|
|
642
|
+
warn(`[cs-bots/prompts] 回放跳过 ${skipped} 条非递增/残缺事件(已保留在文件中,未被静默丢弃)`)
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// 兜底:旧数据里可能没有 category 事件,但提示词引用了某个分类 —— 补齐,避免 UI 分类树丢条目
|
|
646
|
+
for (const p of prompts.values()) {
|
|
647
|
+
if (!categories.has(p.category)) {
|
|
648
|
+
categories.set(p.category, { name: p.category, createdAt: 0, createdBy: 'system' })
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
diagnostics.hydratedFrom = 'jsonl'
|
|
653
|
+
info(`[cs-bots/prompts] 回放完成:${rows.length} 条事件 → ${prompts.size} 条提示词 / ${injections.size} 条注入`)
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// ── 校验小件 ──────────────────────────────────────────────────────────────
|
|
657
|
+
|
|
658
|
+
/** CAS 前置校验:expectedRev 必须显式给、且与当前 rev 一致(不匹配绝不覆盖)。 */
|
|
659
|
+
function assertExpectedRev(current, expectedRev, where) {
|
|
660
|
+
if (!Number.isInteger(expectedRev)) {
|
|
661
|
+
throw new PromptError(
|
|
662
|
+
`prompts.${where}:expectedRev 必须是整数(CAS 需要显式期望版本,条目 ${current.id} 当前 rev=${current.rev},`
|
|
663
|
+
+ `收到 ${JSON.stringify(expectedRev)})`,
|
|
664
|
+
'PROMPT_MISSING_EXPECTED_REV',
|
|
665
|
+
{ id: current.id, rev: current.rev, where },
|
|
666
|
+
)
|
|
667
|
+
}
|
|
668
|
+
if (current.rev !== expectedRev) {
|
|
669
|
+
throw new PromptError(
|
|
670
|
+
`prompts.${where} CAS 冲突:条目 ${current.id} 期望 rev=${expectedRev},实际 rev=${current.rev} —— `
|
|
671
|
+
+ '拒绝静默覆盖,请重新 getPrompt() 后按最新 rev 重试',
|
|
672
|
+
'PROMPT_REV_CONFLICT',
|
|
673
|
+
{ id: current.id, expectedRev, rev: current.rev, where },
|
|
674
|
+
)
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/** 取必须存在的提示词。 */
|
|
679
|
+
function mustGetPrompt(id, where) {
|
|
680
|
+
const promptId = requireString(id, 'id', where)
|
|
681
|
+
const current = prompts.get(promptId)
|
|
682
|
+
if (!current) {
|
|
683
|
+
throw new PromptError(
|
|
684
|
+
`prompts.${where}:提示词 ${promptId} 不存在`,
|
|
685
|
+
'PROMPT_NOT_FOUND',
|
|
686
|
+
{ id: promptId, where },
|
|
687
|
+
)
|
|
688
|
+
}
|
|
689
|
+
return current
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/** 取必须存在的注入。 */
|
|
693
|
+
function mustGetInjection(id, where) {
|
|
694
|
+
const injectionId = requireString(id, 'id', where)
|
|
695
|
+
const current = injections.get(injectionId)
|
|
696
|
+
if (!current) {
|
|
697
|
+
throw new PromptError(
|
|
698
|
+
`prompts.${where}:注入 ${injectionId} 不存在(可能已被停止或过期)`,
|
|
699
|
+
'PROMPT_INJECTION_NOT_FOUND',
|
|
700
|
+
{ id: injectionId, where },
|
|
701
|
+
)
|
|
702
|
+
}
|
|
703
|
+
return current
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/**
|
|
707
|
+
* 确保分类存在(自由文本分类自动登记)。
|
|
708
|
+
* 为什么自动登记而不是抛错:UI 里新建提示词时直填分类是高频操作,
|
|
709
|
+
* 让用户先去建分类是多余摩擦;登记动作本身进事件流,可回放、可审计。
|
|
710
|
+
*/
|
|
711
|
+
function ensureCategory(name, by, at) {
|
|
712
|
+
if (categories.has(name)) return
|
|
713
|
+
commit('category', name, 'add', 1, by, at, null, { name, createdAt: at, createdBy: by })
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
// ── 提示词 CRUD ───────────────────────────────────────────────────────────
|
|
717
|
+
|
|
718
|
+
/**
|
|
719
|
+
* 新建提示词。
|
|
720
|
+
* @param {{ name: string, description?: string, category?: string, tags?: string[],
|
|
721
|
+
* body?: string, enabled?: boolean, by: string, now?: number }} input
|
|
722
|
+
* @returns {object} Prompt
|
|
723
|
+
*/
|
|
724
|
+
function createPrompt(input = {}) {
|
|
725
|
+
assertAlive('createPrompt')
|
|
726
|
+
const by = requireActor(input, 'create')
|
|
727
|
+
const at = normalizeNow(input?.now)
|
|
728
|
+
const fields = {
|
|
729
|
+
name: normalizeName(input?.name, 'create'),
|
|
730
|
+
description: normalizeDescription(input?.description, 'create'),
|
|
731
|
+
category: normalizeCategoryName(input?.category, 'create'),
|
|
732
|
+
tags: normalizeTags(input?.tags, 'create'),
|
|
733
|
+
body: normalizeBody(input?.body === undefined || input?.body === null ? '' : input?.body, 'create'),
|
|
734
|
+
enabled: normalizeEnabled(input?.enabled, 'create'),
|
|
735
|
+
}
|
|
736
|
+
ensureCategory(fields.category, by, at)
|
|
737
|
+
|
|
738
|
+
const id = newId(PROMPT_ID_PREFIX, prompts)
|
|
739
|
+
const record = {
|
|
740
|
+
id,
|
|
741
|
+
rev: 1,
|
|
742
|
+
...fields,
|
|
743
|
+
// useCount 起点 0;它是遥测不是内容版本,见文件头「为什么 useCount 不走 rev」
|
|
744
|
+
useCount: 0,
|
|
745
|
+
createdAt: at,
|
|
746
|
+
createdBy: by,
|
|
747
|
+
updatedAt: at,
|
|
748
|
+
updatedBy: by,
|
|
749
|
+
}
|
|
750
|
+
return commit('prompt', id, 'create', 1, by, at, null, record)
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
/**
|
|
754
|
+
* 按 `expectedRev` 做 CAS 变更。可改:name/description/category/tags/body/enabled。
|
|
755
|
+
* @param {{ id: string, expectedRev: number, name?: string, description?: string,
|
|
756
|
+
* category?: string, tags?: string[], body?: string, enabled?: boolean,
|
|
757
|
+
* by: string, now?: number }} input
|
|
758
|
+
* @returns {object} 更新后的 Prompt
|
|
759
|
+
*/
|
|
760
|
+
function updatePrompt(input = {}) {
|
|
761
|
+
assertAlive('updatePrompt')
|
|
762
|
+
// 注意:by / byId 必须一并解构出去,否则会落进 `rest` 被误判成未知字段
|
|
763
|
+
const {
|
|
764
|
+
id, expectedRev, name, description, category, tags, body, enabled, now,
|
|
765
|
+
by: _by, byId: _byId, ...rest
|
|
766
|
+
} = input ?? {}
|
|
767
|
+
const by = requireActor(input, 'update')
|
|
768
|
+
const current = mustGetPrompt(id, 'update')
|
|
769
|
+
assertExpectedRev(current, expectedRev, 'update')
|
|
770
|
+
|
|
771
|
+
// 字段级「给没给」判定必须用 `!== undefined`:description='' / tags=[] / body=''
|
|
772
|
+
// 都是**合法的显式清空**,不能当成"没提供"而静默忽略。
|
|
773
|
+
const unknown = Object.keys(rest ?? {})
|
|
774
|
+
if (unknown.length > 0) {
|
|
775
|
+
throw new PromptError(
|
|
776
|
+
`prompts.update:未知字段 ${unknown.join(', ')}(可改:name | description | category | tags | body | enabled;`
|
|
777
|
+
+ 'id/rev/useCount/createdAt 由系统维护,不可改)——拒绝静默忽略',
|
|
778
|
+
'PROMPT_UNKNOWN_FIELD',
|
|
779
|
+
{ id: current.id, unknown },
|
|
780
|
+
)
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
const patch = {}
|
|
784
|
+
if (name !== undefined) patch.name = normalizeName(name, 'update')
|
|
785
|
+
if (description !== undefined) patch.description = normalizeDescription(description, 'update')
|
|
786
|
+
if (category !== undefined) patch.category = normalizeCategoryName(category, 'update')
|
|
787
|
+
if (tags !== undefined) patch.tags = normalizeTags(tags, 'update')
|
|
788
|
+
if (body !== undefined) patch.body = normalizeBody(body, 'update')
|
|
789
|
+
if (enabled !== undefined) patch.enabled = normalizeEnabled(enabled, 'update')
|
|
790
|
+
|
|
791
|
+
if (Object.keys(patch).length === 0) {
|
|
792
|
+
throw new PromptError(
|
|
793
|
+
'prompts.update:未提供任何可改字段(name | description | category | tags | body | enabled)',
|
|
794
|
+
'PROMPT_NO_PATCH_FIELDS',
|
|
795
|
+
{ id: current.id },
|
|
796
|
+
)
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
const at = normalizeNow(now)
|
|
800
|
+
if (patch.category !== undefined) ensureCategory(patch.category, by, at)
|
|
801
|
+
|
|
802
|
+
const next = {
|
|
803
|
+
...clone(current),
|
|
804
|
+
...patch,
|
|
805
|
+
rev: current.rev + 1,
|
|
806
|
+
updatedAt: at,
|
|
807
|
+
updatedBy: by,
|
|
808
|
+
}
|
|
809
|
+
return commit('prompt', current.id, 'update', next.rev, by, at, clone(current), next)
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
/**
|
|
813
|
+
* 删除提示词(CAS)。**级联清理**它名下的活跃注入 ——
|
|
814
|
+
* 不级联就会留下「提示词已经没了、注入轨还在每回合念旧内容」的悬空引用。
|
|
815
|
+
* @param {{ id: string, expectedRev: number, by: string, now?: number }} input
|
|
816
|
+
* @returns {{ removed: string, injections: number }}
|
|
817
|
+
*/
|
|
818
|
+
function removePrompt(input = {}) {
|
|
819
|
+
assertAlive('removePrompt')
|
|
820
|
+
const { id, expectedRev, now } = input ?? {}
|
|
821
|
+
const by = requireActor(input, 'remove')
|
|
822
|
+
const current = mustGetPrompt(id, 'remove')
|
|
823
|
+
assertExpectedRev(current, expectedRev, 'remove')
|
|
824
|
+
const at = normalizeNow(now)
|
|
825
|
+
|
|
826
|
+
const related = [...injections.values()].filter((inj) => inj.promptId === current.id)
|
|
827
|
+
for (const inj of related) {
|
|
828
|
+
commit('injection', inj.id, 'stop', inj.rev + 1, by, at, clone(inj), null)
|
|
829
|
+
}
|
|
830
|
+
commit('prompt', current.id, 'remove', current.rev + 1, by, at, clone(current), null)
|
|
831
|
+
info(`[cs-bots/prompts] 已删除提示词 ${current.id}(级联清理 ${related.length} 条注入)`)
|
|
832
|
+
return { removed: current.id, injections: related.length }
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
/** 取一条提示词;不存在返回 null(与 board.get 同款,不抛错)。 */
|
|
836
|
+
function getPrompt(id) {
|
|
837
|
+
assertAlive('getPrompt')
|
|
838
|
+
if (typeof id !== 'string' || id.trim() === '') return null
|
|
839
|
+
const found = prompts.get(id.trim())
|
|
840
|
+
return found ? clone(found) : null
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
/**
|
|
844
|
+
* 列表查询。
|
|
845
|
+
* @param {{ category?: string, tag?: string, query?: string, enabled?: boolean, limit?: number }} [options]
|
|
846
|
+
* @returns {object[]} 按 updatedAt 倒序(最近改的在前),同时间按 id 升序保证稳定
|
|
847
|
+
*/
|
|
848
|
+
function listPrompts(options = {}) {
|
|
849
|
+
assertAlive('listPrompts')
|
|
850
|
+
const { category, tag, query, enabled, limit } = options ?? {}
|
|
851
|
+
const categoryFilter = typeof category === 'string' && category.trim() !== '' ? category.trim() : null
|
|
852
|
+
const tagFilter = typeof tag === 'string' && tag.trim() !== '' ? tag.trim() : null
|
|
853
|
+
const q = typeof query === 'string' && query.trim() !== '' ? query.trim().toLowerCase() : null
|
|
854
|
+
const enabledFilter = typeof enabled === 'boolean' ? enabled : null
|
|
855
|
+
const limitFilter = Number.isInteger(limit) && limit > 0 ? limit : 0
|
|
856
|
+
|
|
857
|
+
const out = []
|
|
858
|
+
for (const p of prompts.values()) {
|
|
859
|
+
if (categoryFilter !== null && p.category !== categoryFilter) continue
|
|
860
|
+
if (enabledFilter !== null && p.enabled !== enabledFilter) continue
|
|
861
|
+
if (tagFilter !== null && !(p.tags ?? []).some((x) => x.toLowerCase() === tagFilter.toLowerCase())) continue
|
|
862
|
+
if (q !== null) {
|
|
863
|
+
const haystack = `${p.name}\n${p.description}\n${p.body}\n${(p.tags ?? []).join(' ')}`.toLowerCase()
|
|
864
|
+
if (!haystack.includes(q)) continue
|
|
865
|
+
}
|
|
866
|
+
out.push(clone(p))
|
|
867
|
+
}
|
|
868
|
+
out.sort((a, b) => (b.updatedAt - a.updatedAt) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
|
|
869
|
+
return limitFilter > 0 ? out.slice(0, limitFilter) : out
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
// ── 分类 ──────────────────────────────────────────────────────────────────
|
|
873
|
+
|
|
874
|
+
/**
|
|
875
|
+
* 分类列表(含每个分类下的提示词数)。
|
|
876
|
+
* 按名字升序(稳定,便于 UI 与测试比对)。
|
|
877
|
+
* @returns {{ name: string, createdAt: number, createdBy: string, builtin: boolean,
|
|
878
|
+
* protected: boolean, count: number }[]}
|
|
879
|
+
*/
|
|
880
|
+
function listCategories() {
|
|
881
|
+
assertAlive('listCategories')
|
|
882
|
+
const counts = new Map()
|
|
883
|
+
for (const p of prompts.values()) {
|
|
884
|
+
counts.set(p.category, (counts.get(p.category) ?? 0) + 1)
|
|
885
|
+
}
|
|
886
|
+
return [...categories.values()]
|
|
887
|
+
.map((c) => ({
|
|
888
|
+
name: c.name,
|
|
889
|
+
createdAt: c.createdAt,
|
|
890
|
+
createdBy: c.createdBy,
|
|
891
|
+
// createdAt=0 是"启动播种的缺省分类";用户删了再加回来就转正为普通分类
|
|
892
|
+
builtin: c.createdAt === 0,
|
|
893
|
+
// 「未分类」是兜底分类,删掉它会让 removeCategory 无处可迁 —— 必须保护
|
|
894
|
+
protected: c.name === DEFAULT_CATEGORY,
|
|
895
|
+
count: counts.get(c.name) ?? 0,
|
|
896
|
+
}))
|
|
897
|
+
.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
/**
|
|
901
|
+
* 新建分类。
|
|
902
|
+
* @param {{ name: string, by: string, now?: number }} input
|
|
903
|
+
* @returns {{ name: string, createdAt: number, createdBy: string }}
|
|
904
|
+
*/
|
|
905
|
+
function addCategory(input = {}) {
|
|
906
|
+
assertAlive('addCategory')
|
|
907
|
+
const by = requireActor(input, 'addCategory')
|
|
908
|
+
const at = normalizeNow(input?.now)
|
|
909
|
+
const name = normalizeCategoryName(input?.name, 'addCategory', true)
|
|
910
|
+
if (categories.has(name)) {
|
|
911
|
+
throw new PromptError(
|
|
912
|
+
`prompts.addCategory:分类「${name}」已存在(拒绝重复创建,也不会静默合并)`,
|
|
913
|
+
'PROMPT_CATEGORY_EXISTS',
|
|
914
|
+
{ name },
|
|
915
|
+
)
|
|
916
|
+
}
|
|
917
|
+
return commit('category', name, 'add', 1, by, at, null, { name, createdAt: at, createdBy: by })
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
/**
|
|
921
|
+
* 删除分类:该分类下的提示词**迁移到「未分类」**(分类只是组织方式,删分类不删内容)。
|
|
922
|
+
* @param {{ name: string, by: string, now?: number }} input
|
|
923
|
+
* @returns {{ removed: string, moved: number }}
|
|
924
|
+
*/
|
|
925
|
+
function removeCategory(input = {}) {
|
|
926
|
+
assertAlive('removeCategory')
|
|
927
|
+
const by = requireActor(input, 'removeCategory')
|
|
928
|
+
const at = normalizeNow(input?.now)
|
|
929
|
+
const name = requireString(input?.name, 'name', 'removeCategory', MAX_CATEGORY_LEN)
|
|
930
|
+
if (name === DEFAULT_CATEGORY) {
|
|
931
|
+
throw new PromptError(
|
|
932
|
+
`prompts.removeCategory:「${DEFAULT_CATEGORY}」是兜底分类,不可删除(否则删其它分类时提示词无处迁)`,
|
|
933
|
+
'PROMPT_PROTECTED_CATEGORY',
|
|
934
|
+
{ name },
|
|
935
|
+
)
|
|
936
|
+
}
|
|
937
|
+
const record = categories.get(name)
|
|
938
|
+
if (!record) {
|
|
939
|
+
throw new PromptError(
|
|
940
|
+
`prompts.removeCategory:分类「${name}」不存在`,
|
|
941
|
+
'PROMPT_CATEGORY_NOT_FOUND',
|
|
942
|
+
{ name },
|
|
943
|
+
)
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
const affected = [...prompts.values()].filter((p) => p.category === name)
|
|
947
|
+
for (const p of affected) {
|
|
948
|
+
const next = {
|
|
949
|
+
...clone(p),
|
|
950
|
+
category: DEFAULT_CATEGORY,
|
|
951
|
+
rev: p.rev + 1,
|
|
952
|
+
updatedAt: at,
|
|
953
|
+
updatedBy: by,
|
|
954
|
+
}
|
|
955
|
+
commit('prompt', p.id, 'update', next.rev, by, at, clone(p), next)
|
|
956
|
+
}
|
|
957
|
+
commit('category', name, 'remove', 1, by, at, clone(record), null)
|
|
958
|
+
info(`[cs-bots/prompts] 已删除分类「${name}」,${affected.length} 条提示词迁到「${DEFAULT_CATEGORY}」`)
|
|
959
|
+
return { removed: name, moved: affected.length }
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
// ── 注入轨 ────────────────────────────────────────────────────────────────
|
|
963
|
+
|
|
964
|
+
/**
|
|
965
|
+
* 新建注入。
|
|
966
|
+
*
|
|
967
|
+
* `promptId` 与 `text` 二选一:前者把某条提示词投进注入轨(产出时实时读最新 body),
|
|
968
|
+
* 后者投一段临时文本(ad-hoc,比如一次性提醒)。两者都给会被拒绝 ——
|
|
969
|
+
* "到底念哪段"必须唯一确定,不能靠优先级猜测。
|
|
970
|
+
*
|
|
971
|
+
* @param {{ promptId?: string, text?: string, sessionId: string, times?: number,
|
|
972
|
+
* every?: number, immediate?: boolean, by: string, now?: number }} input
|
|
973
|
+
* @returns {object} Injection
|
|
974
|
+
*/
|
|
975
|
+
function inject(input = {}) {
|
|
976
|
+
assertAlive('inject')
|
|
977
|
+
const by = requireActor(input, 'inject')
|
|
978
|
+
const at = normalizeNow(input?.now)
|
|
979
|
+
const sessionId = normalizeSessionId(input?.sessionId, 'inject')
|
|
980
|
+
|
|
981
|
+
const hasPrompt = input?.promptId !== undefined && input?.promptId !== null && input?.promptId !== ''
|
|
982
|
+
const hasText = input?.text !== undefined && input?.text !== null
|
|
983
|
+
if (hasPrompt && hasText) {
|
|
984
|
+
throw new PromptError(
|
|
985
|
+
'prompts.inject:promptId 与 text 只能给一个(同时给会让「念哪段」歧义)',
|
|
986
|
+
'PROMPT_AMBIGUOUS_INPUT',
|
|
987
|
+
{ promptId: String(input.promptId) },
|
|
988
|
+
)
|
|
989
|
+
}
|
|
990
|
+
if (!hasPrompt && !hasText) {
|
|
991
|
+
throw new PromptError(
|
|
992
|
+
'prompts.inject:必须提供 promptId 或 text 之一',
|
|
993
|
+
'PROMPT_MISSING_TEXT',
|
|
994
|
+
{},
|
|
995
|
+
)
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
let promptId = null
|
|
999
|
+
let text
|
|
1000
|
+
if (hasPrompt) {
|
|
1001
|
+
const prompt = mustGetPrompt(input.promptId, 'inject')
|
|
1002
|
+
if (prompt.enabled !== true) {
|
|
1003
|
+
throw new PromptError(
|
|
1004
|
+
`prompts.inject:提示词 ${prompt.id} 处于停用状态,不能注入(请先启用)`,
|
|
1005
|
+
'PROMPT_DISABLED',
|
|
1006
|
+
{ id: prompt.id },
|
|
1007
|
+
)
|
|
1008
|
+
}
|
|
1009
|
+
promptId = prompt.id
|
|
1010
|
+
text = prompt.body
|
|
1011
|
+
} else {
|
|
1012
|
+
text = normalizeBody(input.text, 'inject')
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
const immediate = input?.immediate === true
|
|
1016
|
+
let every = normalizeEvery(input?.every, 'inject')
|
|
1017
|
+
let times = normalizeTimes(input?.times, 'inject')
|
|
1018
|
+
if (immediate) {
|
|
1019
|
+
// 立即注入 = 只注入一次,且无视间隔:固定 every=0 / times=1
|
|
1020
|
+
every = 0
|
|
1021
|
+
times = 1
|
|
1022
|
+
} else if (every === 0) {
|
|
1023
|
+
// every=0 的语义是「只注入一次」(用户直觉:没有间隔 = 只出现一下),
|
|
1024
|
+
// 它与「无限次」的 times=0 天然矛盾 —— 以 every=0 为准(次数被覆盖为 1)。
|
|
1025
|
+
times = 1
|
|
1026
|
+
}
|
|
1027
|
+
const remaining = times // times=0 是「无限」的哨兵;remaining 保持 0,tick 永不减
|
|
1028
|
+
|
|
1029
|
+
const id = newId(INJECTION_ID_PREFIX, injections)
|
|
1030
|
+
const record = {
|
|
1031
|
+
id,
|
|
1032
|
+
rev: 1,
|
|
1033
|
+
promptId,
|
|
1034
|
+
text,
|
|
1035
|
+
sessionId,
|
|
1036
|
+
times,
|
|
1037
|
+
every,
|
|
1038
|
+
remaining,
|
|
1039
|
+
immediate,
|
|
1040
|
+
createdAt: at,
|
|
1041
|
+
createdBy: by,
|
|
1042
|
+
}
|
|
1043
|
+
info(`[cs-bots/prompts] 新建注入 ${id} session=${sessionId} times=${times} every=${every} immediate=${immediate}`)
|
|
1044
|
+
return commit('injection', id, 'create', 1, by, at, null, record)
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
/**
|
|
1048
|
+
* 手动停止一条注入(提前结束)。
|
|
1049
|
+
* @param {{ id: string, by: string, now?: number }} input
|
|
1050
|
+
* @returns {{ stopped: string }}
|
|
1051
|
+
*/
|
|
1052
|
+
function stopInjection(input = {}) {
|
|
1053
|
+
assertAlive('stopInjection')
|
|
1054
|
+
const by = requireActor(input, 'stopInjection')
|
|
1055
|
+
const at = normalizeNow(input?.now)
|
|
1056
|
+
const current = mustGetInjection(input?.id, 'stopInjection')
|
|
1057
|
+
commit('injection', current.id, 'stop', current.rev + 1, by, at, clone(current), null)
|
|
1058
|
+
// 从各会话的待扣集合里摘掉,避免后续 tick 引用已删条目(虽然 tick 会跳过,但别留垃圾)
|
|
1059
|
+
for (const set of pendingConsume.values()) set.delete(current.id)
|
|
1060
|
+
return { stopped: current.id }
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
/**
|
|
1064
|
+
* 列活跃注入(按创建时间正序)。
|
|
1065
|
+
* @param {{ sessionId?: string }} [options] 省略 sessionId 则返回全部(UI 概览用)
|
|
1066
|
+
* @returns {object[]}
|
|
1067
|
+
*/
|
|
1068
|
+
function listInjections(options = {}) {
|
|
1069
|
+
assertAlive('listInjections')
|
|
1070
|
+
const sessionId = typeof options?.sessionId === 'string' && options.sessionId.trim() !== ''
|
|
1071
|
+
? options.sessionId.trim()
|
|
1072
|
+
: null
|
|
1073
|
+
const out = []
|
|
1074
|
+
for (const inj of injections.values()) {
|
|
1075
|
+
if (sessionId !== null && inj.sessionId !== sessionId) continue
|
|
1076
|
+
out.push(clone(inj))
|
|
1077
|
+
}
|
|
1078
|
+
out.sort((a, b) => (a.createdAt - b.createdAt) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
|
|
1079
|
+
return out
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
/**
|
|
1083
|
+
* **本轮应注入的内容**(纯查询,可重复调用)。
|
|
1084
|
+
*
|
|
1085
|
+
* 判定规则(严格实现契约):
|
|
1086
|
+
* - `immediate=true` → 固定产出一轮,不受 times/every 影响;
|
|
1087
|
+
* - `every > 1` → 只在「本回合序号 % every === 0」的回合产出;
|
|
1088
|
+
* - `every === 1` → 每回合产出;
|
|
1089
|
+
* - `every === 0` → 一次性(次数已归一为 1,产出即等问题)。
|
|
1090
|
+
*
|
|
1091
|
+
* 同回合重复调用**幂等**:产出过的 id 记在 pending 里,直接返回同一批
|
|
1092
|
+
* (次数扣减统一交给 `tickTurn`,避免预览把注入预算吃光)。
|
|
1093
|
+
*
|
|
1094
|
+
* @param {{ sessionId: string, now?: number }} input `now` 仅测试用,控制 {{date}}/{{time}} 展开
|
|
1095
|
+
* @returns {{ id: string, text: string }[]} 正文已展开 `{{date}}`/`{{time}}`
|
|
1096
|
+
*/
|
|
1097
|
+
function activeInjectionsFor(input = {}) {
|
|
1098
|
+
assertAlive('activeInjectionsFor')
|
|
1099
|
+
const sessionId = normalizeSessionId(input?.sessionId, 'activeInjectionsFor')
|
|
1100
|
+
const at = normalizeNow(input?.now)
|
|
1101
|
+
const turnNo = sessions.get(sessionId) ?? 0
|
|
1102
|
+
const currentTurn = turnNo + 1
|
|
1103
|
+
|
|
1104
|
+
let pending = pendingConsume.get(sessionId)
|
|
1105
|
+
if (!pending) {
|
|
1106
|
+
pending = new Set()
|
|
1107
|
+
pendingConsume.set(sessionId, pending)
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
const out = []
|
|
1111
|
+
for (const inj of injections.values()) {
|
|
1112
|
+
if (inj.sessionId !== sessionId) continue
|
|
1113
|
+
const already = pending.has(inj.id)
|
|
1114
|
+
let eligible
|
|
1115
|
+
if (already) {
|
|
1116
|
+
eligible = true
|
|
1117
|
+
} else if (inj.immediate === true) {
|
|
1118
|
+
// 立即注入:不看间隔,本轮就产出
|
|
1119
|
+
eligible = true
|
|
1120
|
+
} else if (inj.every > 1) {
|
|
1121
|
+
// 「本回合序号 % every === 0」:every=2 → 第 2、4、6… 回合产出
|
|
1122
|
+
eligible = currentTurn % inj.every === 0
|
|
1123
|
+
} else {
|
|
1124
|
+
eligible = true // every 1(每回合)或 0(一次性)
|
|
1125
|
+
}
|
|
1126
|
+
if (!eligible) continue
|
|
1127
|
+
pending.add(inj.id)
|
|
1128
|
+
out.push({ id: inj.id, text: renderInjection(inj, at) })
|
|
1129
|
+
}
|
|
1130
|
+
return out
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
/** 产出的正文:提示词注入实时读最新 body;ad-hoc 用创建时的 text。 */
|
|
1134
|
+
function renderInjection(inj, now) {
|
|
1135
|
+
let source = inj.text ?? ''
|
|
1136
|
+
if (inj.promptId) {
|
|
1137
|
+
const prompt = prompts.get(inj.promptId)
|
|
1138
|
+
// 理论上删除提示词会级联清注入;这里保留 text 兜底,防止旧数据悬空
|
|
1139
|
+
if (prompt) source = prompt.body ?? ''
|
|
1140
|
+
}
|
|
1141
|
+
return expandTemplates(source, now)
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
/**
|
|
1145
|
+
* 回合推进:对「本轮已产出」的注入扣一次剩余次数,归零则自动移除(记 `expire` 事件);
|
|
1146
|
+
* 同时 session 回合序号 +1。
|
|
1147
|
+
*
|
|
1148
|
+
* 为什么不在 `activeInjectionsFor` 里扣:见文件头「为什么注入要单独一套拿到即计数机制」。
|
|
1149
|
+
*
|
|
1150
|
+
* @param {{ sessionId: string, now?: number }} input
|
|
1151
|
+
* @returns {{ sessionId: string, turn: number, consumed: number, expired: number, active: number }}
|
|
1152
|
+
*/
|
|
1153
|
+
function tickTurn(input = {}) {
|
|
1154
|
+
assertAlive('tickTurn')
|
|
1155
|
+
const sessionId = normalizeSessionId(input?.sessionId, 'tickTurn')
|
|
1156
|
+
const at = normalizeNow(input?.now)
|
|
1157
|
+
|
|
1158
|
+
const pending = pendingConsume.get(sessionId)
|
|
1159
|
+
let consumed = 0
|
|
1160
|
+
let expired = 0
|
|
1161
|
+
/** @type {Map<string, number>} promptId → 本回合产出次数(合并成一条 usage 事件) */
|
|
1162
|
+
const usedPrompts = new Map()
|
|
1163
|
+
|
|
1164
|
+
if (pending && pending.size > 0) {
|
|
1165
|
+
for (const id of [...pending]) {
|
|
1166
|
+
const inj = injections.get(id)
|
|
1167
|
+
if (!inj) {
|
|
1168
|
+
pending.delete(id)
|
|
1169
|
+
continue
|
|
1170
|
+
}
|
|
1171
|
+
consumed += 1
|
|
1172
|
+
if (inj.promptId) usedPrompts.set(inj.promptId, (usedPrompts.get(inj.promptId) ?? 0) + 1)
|
|
1173
|
+
// times=0 = 无限注入:不消耗、永不自动过期(只能手动 stop)
|
|
1174
|
+
if (inj.times === 0) continue
|
|
1175
|
+
|
|
1176
|
+
const before = clone(inj)
|
|
1177
|
+
const next = clone(inj)
|
|
1178
|
+
next.rev = inj.rev + 1
|
|
1179
|
+
next.remaining = Math.max(0, (Number.isInteger(inj.remaining) ? inj.remaining : 0) - 1)
|
|
1180
|
+
if (next.remaining <= 0) {
|
|
1181
|
+
commit('injection', inj.id, 'expire', next.rev, null, at, before, null)
|
|
1182
|
+
expired += 1
|
|
1183
|
+
} else {
|
|
1184
|
+
commit('injection', inj.id, 'tick', next.rev, null, at, before, next)
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
pending.clear()
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
// useCount 遥测:每回合每提示词合并成一条 usage 事件(N 条注入不写 N 条事件)
|
|
1191
|
+
for (const [promptId, count] of usedPrompts) {
|
|
1192
|
+
if (!prompts.has(promptId)) continue
|
|
1193
|
+
commit('usage', promptId, 'inc', 0, null, at, null, { promptId, count })
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
const turn = (sessions.get(sessionId) ?? 0) + 1
|
|
1197
|
+
commit('session', sessionId, 'tick', turn, null, at, null, { sessionId, turn })
|
|
1198
|
+
|
|
1199
|
+
let active = 0
|
|
1200
|
+
for (const inj of injections.values()) {
|
|
1201
|
+
if (inj.sessionId === sessionId) active += 1
|
|
1202
|
+
}
|
|
1203
|
+
return { sessionId, turn, consumed, expired, active }
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
// ── 统计 / 回放 / 销毁 ────────────────────────────────────────────────────
|
|
1207
|
+
|
|
1208
|
+
/** 审计流查询(可选按 id 过滤)。 */
|
|
1209
|
+
function history(id) {
|
|
1210
|
+
assertAlive('history')
|
|
1211
|
+
const rows = typeof id === 'string' && id !== ''
|
|
1212
|
+
? events.filter((e) => e.id === id)
|
|
1213
|
+
: events
|
|
1214
|
+
return clone(rows)
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
/**
|
|
1218
|
+
* 统计(给 /status 与 UI 概览用)。
|
|
1219
|
+
* @returns {object}
|
|
1220
|
+
*/
|
|
1221
|
+
function stats() {
|
|
1222
|
+
assertAlive('stats')
|
|
1223
|
+
const byCategory = {}
|
|
1224
|
+
const bySession = {}
|
|
1225
|
+
let enabled = 0
|
|
1226
|
+
let disabled = 0
|
|
1227
|
+
const tagSet = new Set()
|
|
1228
|
+
for (const p of prompts.values()) {
|
|
1229
|
+
byCategory[p.category] = (byCategory[p.category] ?? 0) + 1
|
|
1230
|
+
if (p.enabled === false) disabled += 1
|
|
1231
|
+
else enabled += 1
|
|
1232
|
+
for (const tag of p.tags ?? []) tagSet.add(tag)
|
|
1233
|
+
}
|
|
1234
|
+
for (const inj of injections.values()) {
|
|
1235
|
+
bySession[inj.sessionId] = (bySession[inj.sessionId] ?? 0) + 1
|
|
1236
|
+
}
|
|
1237
|
+
return {
|
|
1238
|
+
prompts: prompts.size,
|
|
1239
|
+
enabled,
|
|
1240
|
+
disabled,
|
|
1241
|
+
categories: categories.size,
|
|
1242
|
+
tags: tagSet.size,
|
|
1243
|
+
injections: injections.size,
|
|
1244
|
+
sessions: sessions.size,
|
|
1245
|
+
events: events.length,
|
|
1246
|
+
byCategory,
|
|
1247
|
+
bySession,
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
/**
|
|
1252
|
+
* 从 `prompts.jsonl` 重建内存态(重启回放一致性)。
|
|
1253
|
+
* @returns {{ from: string, prompts: number, injections: number, categories: number, sessions: number, events: number, bad: number }}
|
|
1254
|
+
*/
|
|
1255
|
+
function replay() {
|
|
1256
|
+
assertAlive('replay')
|
|
1257
|
+
hydrate()
|
|
1258
|
+
diagnostics.lastReplayAt = Date.now()
|
|
1259
|
+
return {
|
|
1260
|
+
from: diagnostics.hydratedFrom ?? 'empty',
|
|
1261
|
+
prompts: prompts.size,
|
|
1262
|
+
injections: injections.size,
|
|
1263
|
+
categories: categories.size,
|
|
1264
|
+
sessions: sessions.size,
|
|
1265
|
+
events: events.length,
|
|
1266
|
+
bad: diagnostics.badLines,
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
/**
|
|
1271
|
+
* 销毁实例(拒绝后续调用)。不删文件:提示词是长期资产,dispose 只是「实例下班」。
|
|
1272
|
+
* @returns {{ disposed: boolean, prompts: number, injections: number, events: number }}
|
|
1273
|
+
*/
|
|
1274
|
+
function dispose() {
|
|
1275
|
+
const out = { disposed: true, prompts: prompts.size, injections: injections.size, events: events.length }
|
|
1276
|
+
disposed = true
|
|
1277
|
+
prompts.clear()
|
|
1278
|
+
injections.clear()
|
|
1279
|
+
categories.clear()
|
|
1280
|
+
sessions.clear()
|
|
1281
|
+
pendingConsume.clear()
|
|
1282
|
+
events.length = 0
|
|
1283
|
+
return out
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
/** 首次构造即回放,保证重启后 get/list 马上可用。 */
|
|
1287
|
+
hydrate()
|
|
1288
|
+
|
|
1289
|
+
const service = {
|
|
1290
|
+
name: cfg.name ?? 'prompts',
|
|
1291
|
+
paths: Object.freeze({ stateDir, jsonl: jsonlPath, snapshot: snapPath }),
|
|
1292
|
+
// 提示词
|
|
1293
|
+
createPrompt,
|
|
1294
|
+
updatePrompt,
|
|
1295
|
+
removePrompt,
|
|
1296
|
+
getPrompt,
|
|
1297
|
+
listPrompts,
|
|
1298
|
+
// 分类
|
|
1299
|
+
listCategories,
|
|
1300
|
+
addCategory,
|
|
1301
|
+
removeCategory,
|
|
1302
|
+
// 注入轨
|
|
1303
|
+
inject,
|
|
1304
|
+
stopInjection,
|
|
1305
|
+
listInjections,
|
|
1306
|
+
activeInjectionsFor,
|
|
1307
|
+
tickTurn,
|
|
1308
|
+
// 运维
|
|
1309
|
+
stats,
|
|
1310
|
+
replay,
|
|
1311
|
+
history,
|
|
1312
|
+
dispose,
|
|
1313
|
+
diagnostics: () => clone(diagnostics),
|
|
1314
|
+
}
|
|
1315
|
+
return service
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
export default createPrompts
|