dsh-anima 0.0.0-stage → 2.2.2

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/lib/memory.mjs ADDED
@@ -0,0 +1,703 @@
1
+ // lib/memory.mjs — 待审缓冲区记忆层
2
+ //
3
+ // 形态(对应拍板 q13 用户原话「每晚整理到一个缓冲区然后给我说我去看哪些要哪些不要」):
4
+ // Agent 投递候选 → pending 缓冲区 → 每晚自动整理(去重/合并/限额)→ 通知用户审阅
5
+ // → 用户逐条采纳或丢弃 → 只有 adopted 才进入提示词
6
+ //
7
+ // 关键约束:**绝不自动写入长期记忆**。pending 永远不进提示词的「已采纳」段,
8
+ // 只以「待审」形式提示存在,防止未裁决内容被模型当成事实。
9
+
10
+ import { createHash } from 'node:crypto'
11
+ import {
12
+ MEMORY_NOTE_MAX, MEMORY_TEXT_MAX, MEMORY_LAYERS,
13
+ MEMORY_TYPES, DEFAULT_MEMORY_TYPE, MEMORY_STATES, DEFAULT_MEMORY_STATE
14
+ } from './config.mjs'
15
+ import { decayInfo, decayRank, decayTag } from './decay.mjs'
16
+ export { MEMORY_LAYERS, MEMORY_TYPES, MEMORY_STATES }
17
+
18
+ /**
19
+ * 凭据检测(拍板 q8:独立实现,只借鉴 .dsh-memory 的 forbidden 正则)。
20
+ *
21
+ * 只收高精度模式 —— 宁可漏报也不误伤正常记忆(长 hex 如 git SHA 刻意不收)。
22
+ * 命中的候选**直接拒绝入库**,不进待审缓冲区:缓冲区一旦落盘就已经是
23
+ * 「等待被写进提示词」的状态,凭据不该走这条路。
24
+ */
25
+ export const CREDENTIAL_PATTERNS = [
26
+ { name: 'private-key', re: /-----BEGIN [A-Z ]*PRIVATE KEY-----/ },
27
+ { name: 'openai-key', re: /\bsk-[A-Za-z0-9_-]{20,}/ },
28
+ { name: 'aws-key', re: /\bAKIA[0-9A-Z]{16}\b/ },
29
+ { name: 'github-token', re: /\bgh[pousr]_[A-Za-z0-9]{20,}\b/ },
30
+ { name: 'generic-api-key', re: /\bapi[_-]?key\s*[:=]\s*[A-Za-z0-9_-]{16,}/i },
31
+ { name: 'secret-assignment', re: /\b(app[_-]?secret|appsecret|client[_-]?secret|secret[_-]?key|access[_-]?key)\s*[:=]?\s*[A-Za-z0-9]{16,}/i },
32
+ { name: 'password', re: /(?:\b(?:password|passwd|pwd)\b|密码|口令)\s*[:=:]\s*\S{6,}/i },
33
+ { name: 'bearer', re: /\bBearer\s+[A-Za-z0-9._-]{20,}/i },
34
+ { name: 'url-credentials', re: /[a-z][a-z0-9+.-]*:\/\/[^/\s:@]+:[^/\s@]+@/ },
35
+ { name: 'lingxu-token', re: /\btk_[0-9a-f]{8,}\b/ },
36
+ { name: 'mdcg-token', re: /mdcg1\.[A-Za-z0-9._-]{20,}/ }
37
+ ]
38
+
39
+ /**
40
+ * 扫描文本中的凭据形态。
41
+ * @returns {string[]} 命中的模式名(空数组表示干净)
42
+ */
43
+ export function findCredentials(text) {
44
+ const body = String(text ?? '')
45
+ const hits = []
46
+ for (const { name, re } of CREDENTIAL_PATTERNS) {
47
+ if (re.test(body)) hits.push(name)
48
+ }
49
+ return hits
50
+ }
51
+
52
+ /** 归一化文本:用于去重与合并比较。 */
53
+ export function normalizeMemoryText(text) {
54
+ return String(text ?? '')
55
+ .replace(/\s+/g, ' ')
56
+ .trim()
57
+ }
58
+
59
+ /** 记忆 id:内容 + 时间戳的稳定摘要,避免同一内容重复生成不同 id 时无法识别。 */
60
+ export function memoryId(text, at = new Date()) {
61
+ const h = createHash('sha256').update(normalizeMemoryText(text)).digest('hex').slice(0, 12)
62
+ return `m-${h}-${at.getTime().toString(36)}`
63
+ }
64
+
65
+ /** 内容指纹(仅内容,不含时间)。 */
66
+ export function contentDigest(text) {
67
+ return createHash('sha256').update(normalizeMemoryText(text).toLowerCase()).digest('hex').slice(0, 16)
68
+ }
69
+
70
+ /**
71
+ * 渲染后文本的 UTF-8 字节数。
72
+ *
73
+ * 为什么除了字符还要报字节:配置项叫 `maxInjectedChars`,按字符算本身没错,
74
+ * 但「4000 字符」在中文里能到 12000 字节 —— 用户看设置页只会以为「我还有富余」。
75
+ * 两个数并排报,他才能自己换算成模型那边真实的上下文开销。
76
+ *
77
+ * 手写而不是用 Buffer / TextEncoder:本模块同时被宿主、CLI 脚本和预览路径
78
+ * import,少依赖一个宿主才有的全局对象就少一处「在别处炸掉」的可能。
79
+ *
80
+ * @param {string} text
81
+ * @returns {number} 字节数,非字符串按空串处理
82
+ */
83
+ export function utf8Bytes(text) {
84
+ if (typeof text !== 'string' || text === '') return 0
85
+ let bytes = 0
86
+ for (const ch of text) {
87
+ const cp = ch.codePointAt(0)
88
+ if (cp < 0x80) bytes += 1
89
+ else if (cp < 0x800) bytes += 2
90
+ else if (cp < 0x10000) bytes += 3
91
+ else bytes += 4
92
+ }
93
+ return bytes
94
+ }
95
+
96
+ /** 默认层级 —— 存量记录没有 layer 字段时按 global 处理(不损失可见性)。 */
97
+ export const DEFAULT_MEMORY_LAYER = 'global'
98
+
99
+ /**
100
+ * 因**预算**而没进去的三种原因。
101
+ *
102
+ * 与 `tombstoned` / `layer-mismatch` 分开列,是因为它们对模型的含义完全不同:
103
+ * 前者是「有这条,但这轮放不下」,后者是「这条根本不适用于当前项目」。
104
+ * 只有前者必须写进提示词 —— 不写,模型就会把「没看到」当成「没记过」。
105
+ */
106
+ export const BUDGET_DROP_REASONS = Object.freeze(['over-max', 'over-budget', 'dropped-by-budget'])
107
+
108
+ /**
109
+ * 从一次选取结果里抽出「有几条是纯粹因为预算被挤掉的」。
110
+ *
111
+ * 单独成一个导出,是为了让段渲染(index.mjs)与设置页预览走**同一个**口径:
112
+ * 两边各算一遍,迟早会出现「预览说 3 条没进、实际注入说 1 条」。
113
+ *
114
+ * @param {object} selection selectForInjection 的返回值
115
+ * @returns {{count: number, maxCount: number, maxChars: number, usedBytes: number}}
116
+ */
117
+ export function summarizeOmission(selection) {
118
+ const list = (selection?.dropped ?? []).filter((d) => BUDGET_DROP_REASONS.includes(d?.reason))
119
+ const budget = selection?.budget ?? {}
120
+ return {
121
+ count: list.length,
122
+ maxCount: budget.maxCount ?? 0,
123
+ maxChars: budget.maxChars ?? 0,
124
+ usedBytes: budget.usedBytes ?? 0
125
+ }
126
+ }
127
+
128
+ /**
129
+ * 归一化工作区路径,用于比较。
130
+ *
131
+ * Windows 路径大小写不敏感、`\` 与 `/` 混写、还可能带尾部分隔符,
132
+ * 直接字符串比较会把同一个目录判成两个 —— 于是「本工作区的记忆」在某些
133
+ * 调用路径下悄悄漏掉。统一小写 + 正斜杠 + 去尾分隔符后再比。
134
+ */
135
+ export function normalizeCwd(cwd) {
136
+ const raw = String(cwd ?? '').trim()
137
+ if (!raw) return ''
138
+ return raw.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase()
139
+ }
140
+
141
+ /** 归一化层级:未知值一律回落到默认层级。 */
142
+ export function normalizeLayer(layer) {
143
+ return MEMORY_LAYERS.includes(layer) ? layer : DEFAULT_MEMORY_LAYER
144
+ }
145
+
146
+ /**
147
+ * 构造一条候选记忆记录。
148
+ *
149
+ * `layer` 决定这条记忆的生效范围(global / workspace),`cwd` 是它属于哪个工作区。
150
+ * **两者都是真字段,参与注入筛选** —— 这一点是 2026-10-11 修的设计空洞:
151
+ * 此前 scope 与 cwd 投递时写入、注入时完全忽略,导致在 A 项目沉淀的记忆
152
+ * 会在 B 项目作为「用户已采纳的背景」被注入,跨工作区污染且用户无从察觉。
153
+ *
154
+ * @returns {{ok:true, record:object} | {ok:false, error:string}}
155
+ */
156
+ export function makeCandidate(
157
+ { text, note, layer, scope, source, sessionId, cwd, type, sourceRef, verifyAt },
158
+ at = new Date()
159
+ ) {
160
+ const body = normalizeMemoryText(text)
161
+ if (!body) return { ok: false, error: '记忆内容不能为空' }
162
+ if (body.length > MEMORY_TEXT_MAX) {
163
+ return { ok: false, error: `记忆内容长度超过上限 ${MEMORY_TEXT_MAX} 字符(当前 ${body.length})` }
164
+ }
165
+
166
+ // 凭据闸门:命中即拒收,绝不落盘到缓冲区(拍板 q8)
167
+ const credentials = findCredentials(body)
168
+ if (credentials.length > 0) {
169
+ return {
170
+ ok: false,
171
+ error: `记忆内容疑似包含凭据(命中 ${credentials.join('、')}),已拒绝入库。凭据不应写入任何记忆文件。`
172
+ }
173
+ }
174
+
175
+ const trimmedNote = normalizeMemoryText(note)
176
+ const record = {
177
+ id: memoryId(body, at),
178
+ text: body,
179
+ digest: contentDigest(body),
180
+ status: 'pending',
181
+ layer: normalizeLayer(layer),
182
+ source: source || 'agent',
183
+ capturedAt: at.toISOString(),
184
+ // 新字段一律给出确定值,不留 undefined:
185
+ // type 决定注入行尾部会不会出现时间衰减标签(见 lib/decay.mjs),
186
+ // state 决定措辞(现行 / 曾如此 / 有争议 / 墓碑),
187
+ // 缺失时下游要到处写 `?? 'fact'`,那种兜底散落在五个文件里迟早会漏。
188
+ type: normalizeMemoryType(type),
189
+ state: DEFAULT_MEMORY_STATE
190
+ }
191
+ if (trimmedNote) record.note = trimmedNote.slice(0, MEMORY_NOTE_MAX)
192
+ // cwd **总是**记录(只要调用方给了),哪怕这条是全局记忆:
193
+ // ① 匹配只看 layer==='workspace' 的记录,全局记忆带着 cwd 不会被误判;
194
+ // ② 它是设置页「这条其实来自 X 项目」的依据 —— 用户在裁决时最需要的信息
195
+ // 恰恰是「它从哪来」,而事后无法补录。
196
+ if (cwd) record.cwd = normalizeCwd(cwd)
197
+ if (scope) record.scope = scope
198
+ if (sessionId) record.sessionId = sessionId
199
+ // 来源引用与复核点都可选:没给就不写,避免伪造一个看起来像证据的空串。
200
+ if (normalizeMemoryText(sourceRef)) record.sourceRef = normalizeMemoryText(sourceRef).slice(0, MEMORY_NOTE_MAX)
201
+ if (normalizeMemoryText(verifyAt)) record.verifyAt = normalizeMemoryText(verifyAt).slice(0, 40)
202
+ return { ok: true, record }
203
+ }
204
+
205
+ /** 归一化记忆类型:未知值回落到 fact(最保守:事实类衰减最慢,宁可多留)。 */
206
+ export function normalizeMemoryType(type) {
207
+ return MEMORY_TYPES.includes(type) ? type : DEFAULT_MEMORY_TYPE
208
+ }
209
+
210
+ /** 归一化生命周期状态:未知值回落到 current。 */
211
+ export function normalizeMemoryState(state) {
212
+ return MEMORY_STATES.includes(state) ? state : DEFAULT_MEMORY_STATE
213
+ }
214
+
215
+ /**
216
+ * 改写一条已有记忆的元数据(类型 / 生命周期 / 复核点 / 来源引用)。
217
+ *
218
+ * 为什么需要这个函数:`makeCandidate` 只在**新建**时写这些字段,而真实介质里
219
+ * 已有 21 条旧记忆全部没有类型(字段加进 schema 之前写入的)。要回填,就必须
220
+ * 有一条**只改元数据、不动正文**的写入路径 —— 直接 `putMemory({...record, type})`
221
+ * 散落在路由/命令/脚本里,迟早有人顺手把 status 或 text 一起改了。
222
+ *
223
+ * 刻意不允许改的:id、text、digest、status、capturedAt。
224
+ * - text 改了 id 就与内容不再对应,而 id 是引用与取代关系的锚;
225
+ * - status 是「用户的裁决」,只能走 adopt/discard 那条路(带 decidedAt);
226
+ * - capturedAt 是「这条什么时候进来的」,改它等于伪造时间线。
227
+ *
228
+ * @param {object} record 现有记录
229
+ * @param {object} changes {type?, state?, verifyAt?, sourceRef?}
230
+ * @param {Date} [now]
231
+ * @returns {{ok:true, record:object, changed:string[]} | {ok:false, error:string}}
232
+ */
233
+ export function applyMemoryMeta(record, changes = {}, now = new Date()) {
234
+ if (!record || typeof record !== 'object' || !record.id) {
235
+ return { ok: false, error: '没有可改的记录' }
236
+ }
237
+ const next = { ...record }
238
+ const changed = []
239
+
240
+ if (changes.type !== undefined) {
241
+ const type = normalizeMemoryType(changes.type)
242
+ if (type !== record.type) {
243
+ next.type = type
244
+ changed.push('type')
245
+ }
246
+ }
247
+
248
+ if (changes.state !== undefined) {
249
+ const state = normalizeMemoryState(changes.state)
250
+ if (state !== (record.state ?? DEFAULT_MEMORY_STATE)) {
251
+ next.state = state
252
+ changed.push('state')
253
+ // 回到 current 时必须把取代指针清掉,否则注入行会同时带着「现行」与
254
+ // 「被谁取代」两种互相矛盾的信息,读的人不知道该信哪个。
255
+ if (state === 'current' && 'supersededBy' in next) delete next.supersededBy
256
+ }
257
+ }
258
+
259
+ if (changes.verifyAt !== undefined) {
260
+ const verifyAt = String(changes.verifyAt ?? '').trim()
261
+ if (verifyAt === '') {
262
+ if ('verifyAt' in next) {
263
+ delete next.verifyAt
264
+ changed.push('verifyAt')
265
+ }
266
+ } else {
267
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(verifyAt)) {
268
+ return { ok: false, error: `复核点必须是 YYYY-MM-DD,收到「${verifyAt}」` }
269
+ }
270
+ // 复核点设成过去没有意义:它一进去就立刻显示「可能已过时」,
271
+ // 等于用户以为自己在设一个提醒,实际只是给这条记忆盖了个过期章。
272
+ if (verifyAt < now.toISOString().slice(0, 10)) {
273
+ return { ok: false, error: `复核点 ${verifyAt} 已经过去了;要标过期请直接改 state` }
274
+ }
275
+ if (verifyAt !== record.verifyAt) {
276
+ next.verifyAt = verifyAt
277
+ changed.push('verifyAt')
278
+ }
279
+ }
280
+ }
281
+
282
+ if (changes.sourceRef !== undefined) {
283
+ const sourceRef = normalizeMemoryText(changes.sourceRef).slice(0, MEMORY_NOTE_MAX)
284
+ if (sourceRef === '') {
285
+ if ('sourceRef' in next) {
286
+ delete next.sourceRef
287
+ changed.push('sourceRef')
288
+ }
289
+ } else if (sourceRef !== record.sourceRef) {
290
+ next.sourceRef = sourceRef
291
+ changed.push('sourceRef')
292
+ }
293
+ }
294
+
295
+ // 取代指针:只在「这条被那条取代」时才有意义。
296
+ //
297
+ // 为什么必须显式给而不是自动推断:判断「这两条讲的是不是同一件事、新的有没有
298
+ // 推翻旧的」是语义判断,而记忆库一旦自动做语义判断,就会生成用户没说过的话。
299
+ // 所以这里只记录事实(谁取代谁),不做推断。
300
+ if (changes.supersededBy !== undefined) {
301
+ const by = normalizeMemoryText(changes.supersededBy).slice(0, 120)
302
+ if (by === '') {
303
+ if ('supersededBy' in next) {
304
+ delete next.supersededBy
305
+ changed.push('supersededBy')
306
+ }
307
+ } else if (by !== record.supersededBy) {
308
+ // 指向自己等于自环:它既是被取代的,又取代了自己,读的人无从判断哪条算数。
309
+ if (by === record.id) {
310
+ return { ok: false, error: '不能指向自己' }
311
+ }
312
+ next.supersededBy = by
313
+ // 有取代者却没标状态,等于记了一笔没人看的账:
314
+ // 注入行会照旧按 current 渲染,用户以为标了,模型照旧当真。
315
+ if ((next.state ?? DEFAULT_MEMORY_STATE) !== 'superseded') {
316
+ next.state = 'superseded'
317
+ if (!changed.includes('state')) changed.push('state')
318
+ }
319
+ changed.push('supersededBy')
320
+ }
321
+ }
322
+
323
+ return { ok: true, record: next, changed }
324
+ }
325
+
326
+ /**
327
+ * 把一条记忆渲染成注入用的那一行。
328
+ *
329
+ * 三条信息必须出现在行里,否则模型会把它当成「此刻仍然成立」:
330
+ * - 状态:superseded → 「曾如此」,contradicted → 「有争议」;
331
+ * - 日期:写入日期让人知道这条有多旧;
332
+ * - 复核点:过期后标「可能已过时」,把「要不要更新」交回给用户。
333
+ *
334
+ * 刻意不加任何形容词(如「重要」「关键」):那是我在替用户排序。
335
+ *
336
+ * @param {object} record
337
+ * @param {Date} [now]
338
+ * @returns {string}
339
+ */
340
+ export function formatMemoryLine(record, now = new Date()) {
341
+ const body = String(record?.text ?? '')
342
+ if (!body) return ''
343
+ const state = normalizeMemoryState(record.state)
344
+ const tags = []
345
+ if (state === 'superseded') tags.push('曾如此')
346
+ else if (state === 'contradicted') tags.push('有争议')
347
+ else if (state === 'dropped') tags.push('已删除(墓碑)')
348
+ const day = String(record?.capturedAt ?? '').slice(0, 10)
349
+ if (day) tags.push(day)
350
+ // 时间衰减:只按类型 + 日历算,绝不按「我觉不觉得它过时了」算。
351
+ // 墓碑不加这个标签 —— 「已删除」和「很久没更新」同时出现,读者会以为这是两条信息,
352
+ // 其实第二句对一个已经不再注入的条目毫无意义。
353
+ if (state !== 'dropped') {
354
+ const decay = decayTag(record, now)
355
+ if (decay) tags.push(decay)
356
+ }
357
+ // 复核点只在**过期之后**才提示:没到期就天天提一遍,等于制造噪音,
358
+ // 用户很快会学会无视这一行,那整条机制就废了。
359
+ const verify = String(record?.verifyAt ?? '').slice(0, 10)
360
+ if (verify && /^\d{4}-\d{2}-\d{2}$/.test(verify) && verify < now.toISOString().slice(0, 10)) {
361
+ tags.push('可能已过时')
362
+ }
363
+ return tags.length > 0 ? `${body}|${tags.join('|')}` : body
364
+ }
365
+
366
+ /** 按状态分组。 */
367
+ export function groupByStatus(records) {
368
+ const out = { pending: [], adopted: [], discarded: [] }
369
+ for (const r of records ?? []) {
370
+ if (out[r.status]) out[r.status].push(r)
371
+ }
372
+ const byTime = (a, b) => String(a.capturedAt).localeCompare(String(b.capturedAt))
373
+ out.pending.sort(byTime)
374
+ out.adopted.sort(byTime)
375
+ out.discarded.sort(byTime)
376
+ return out
377
+ }
378
+
379
+ /**
380
+ * 每晚整理:在待审缓冲区内做去重、合并与限额淘汰。
381
+ *
382
+ * 纯本地、无模型调用 —— 目的只是让用户面对一份干净的清单,而不是做语义抽取。
383
+ * (语义归纳由 Agent 的 remember 工具在对话中完成。)
384
+ *
385
+ * @param {object[]} records 全量记忆
386
+ * @param {object} options {maxPending, now}
387
+ * @returns {{adopted: string[], discarded: string[], kept: object[], summary: object}}
388
+ * adopted/discarded 为需要改状态的 id 列表,由调用方落盘(保持本函数纯函数)
389
+ */
390
+ export function triage(records, { maxPending = 200, now = new Date() } = {}) {
391
+ const groups = groupByStatus(records)
392
+ const toDiscard = []
393
+ const kept = []
394
+
395
+ // 1) 已被采纳的内容,其 pending 副本直接丢弃
396
+ const adoptedDigests = new Set(groups.adopted.map((r) => r.digest || contentDigest(r.text)))
397
+ const seen = new Set()
398
+ let duplicates = 0
399
+
400
+ for (const record of groups.pending) {
401
+ const digest = record.digest || contentDigest(record.text)
402
+ if (adoptedDigests.has(digest)) {
403
+ toDiscard.push({ id: record.id, note: '与已采纳记忆重复,自动丢弃' })
404
+ duplicates += 1
405
+ continue
406
+ }
407
+ if (seen.has(digest)) {
408
+ toDiscard.push({ id: record.id, note: '与本次缓冲区中的其他候选重复,自动合并' })
409
+ duplicates += 1
410
+ continue
411
+ }
412
+ seen.add(digest)
413
+ kept.push(record)
414
+ }
415
+
416
+ // 2) 限额淘汰:超出上限的按最早优先丢弃
417
+ const overflow = kept.length > maxPending ? kept.slice(0, kept.length - maxPending) : []
418
+ const survivors = overflow.length > 0 ? kept.slice(kept.length - maxPending) : kept
419
+ for (const record of overflow) {
420
+ toDiscard.push({ id: record.id, note: `缓冲区超过 ${maxPending} 条上限,按最早优先淘汰` })
421
+ }
422
+
423
+ const summary = {
424
+ ranAt: now.toISOString(),
425
+ pendingBefore: groups.pending.length,
426
+ pendingAfter: survivors.length,
427
+ duplicatesRemoved: duplicates,
428
+ overflowRemoved: overflow.length,
429
+ discardedTotal: toDiscard.length,
430
+ adoptedTotal: groups.adopted.length
431
+ }
432
+
433
+ return { toDiscard, kept: survivors, summary }
434
+ }
435
+
436
+ /**
437
+ * 判断一条记忆是否适用于某个工作区。
438
+ *
439
+ * 规则只有两条,刻意做得可预测:
440
+ * - global 记忆处处适用;
441
+ * - workspace 记忆只在 cwd 归一化后完全相等时适用。
442
+ *
443
+ * 不做「子目录继承」:工作区的 override 是精确匹配语义(见 lib/overrides.mjs),
444
+ * 记忆若采用继承语义,用户在子目录里会看到父目录的记忆,排查起来极难。
445
+ *
446
+ * @returns {{applicable: boolean, reason: string}} reason 是机器可读的原因码
447
+ */
448
+ export function matchLayer(record, cwd) {
449
+ const layer = normalizeLayer(record?.layer)
450
+ if (layer === 'global') return { applicable: true, reason: 'global' }
451
+ if (!record?.cwd) return { applicable: false, reason: 'workspace-scope-unknown' }
452
+ const here = normalizeCwd(cwd)
453
+ if (!here) return { applicable: false, reason: 'no-workspace-context' }
454
+ if (normalizeCwd(record.cwd) !== here) return { applicable: false, reason: 'other-workspace' }
455
+ return { applicable: true, reason: 'workspace-match' }
456
+ }
457
+
458
+ /** 原因码 → 人话(设置页与 /state 都用它,避免两处各写一份翻译)。 */
459
+ export const INJECTION_REASON_TEXT = {
460
+ 'global': '全局记忆,处处生效',
461
+ 'workspace-match': '本工作区的记忆',
462
+ 'no-injection': '记忆层已关闭或上限为 0',
463
+ 'other-workspace': '属于别的工作区,不在本会话注入',
464
+ 'workspace-scope-unknown': '标记为工作区记忆但没记录属于哪个工作区',
465
+ 'no-workspace-context': '当前会话没有工作区信息,无法匹配工作区记忆',
466
+ 'not-adopted': '尚未被采纳',
467
+ 'over-max': '已采纳条数超过注入上限,按时间取最新的',
468
+ 'over-budget': '已采纳内容超出注入字符预算,取不下',
469
+ 'dropped-by-budget': '前面几条已占满字符预算',
470
+ 'render-failed': '这条记忆渲染失败(字段形状不对),本轮没注入,其余照常',
471
+ 'tombstoned': '用户已删除(保留墓碑,不参与注入)',
472
+ 'included': '已注入提示词'
473
+ }
474
+
475
+ /** 原因码翻译;未收录的码原样返回,不吞掉诊断信息。 */
476
+ export function describeInjectionReason(code) {
477
+ return INJECTION_REASON_TEXT[code] ?? code
478
+ }
479
+
480
+ /**
481
+ * 本地相似度:字符二元组(bigram)的 Jaccard 系数。
482
+ *
483
+ * 为什么不用编辑距离:记忆多为中文短句,编辑距离对同义改写完全无感,
484
+ * 而 bigram 集合比较「同义改写」很灵 —— 且纯本地、零依赖、零模型调用。
485
+ *
486
+ * 为什么只给建议不自动合并:合并意味着**合成一段用户没说过的话**。
487
+ * 本插件的立场是「判断什么值得记交给模型,什么真的生效交给用户」,
488
+ * 自动合并恰好违背它 —— 所以聚类只用来把「这几条像在说同一件事」摆到
489
+ * 用户面前,让裁决更快,而不是替他裁决。
490
+ */
491
+ export function similarity(a, b) {
492
+ const grams = (text) => {
493
+ const body = normalizeMemoryText(text).toLowerCase().replace(/\s+/g, '')
494
+ const set = new Set()
495
+ for (let i = 0; i + 1 < body.length; i += 1) set.add(body.slice(i, i + 2))
496
+ // 单字内容退化成一个整体,避免空集合恒等于 1。
497
+ if (set.size === 0 && body.length > 0) set.add(body)
498
+ return set
499
+ }
500
+ const left = grams(a)
501
+ const right = grams(b)
502
+ if (left.size === 0 || right.size === 0) return 0
503
+ let inter = 0
504
+ for (const g of left) if (right.has(g)) inter += 1
505
+ return inter / (left.size + right.size - inter)
506
+ }
507
+
508
+ /** 默认的相似度阈值:低于它不算「像在说同一件事」。 */
509
+ export const CLUSTER_THRESHOLD = 0.6
510
+
511
+ /**
512
+ * 在待审缓冲区里找出「疑似同一件事」的簇,以及可能与已采纳记忆冲突的新候选。
513
+ *
514
+ * @param {object[]} records 全量记忆
515
+ * @param {object} options {threshold}
516
+ * @returns {{clusters: Array, conflicts: Array}}
517
+ * clusters 每簇含 ids/texts/similarity 范围,供设置页提示「这 3 条像在说同一件事」;
518
+ * conflicts 是「候选与某条已采纳记忆高度相似」——采纳它就等于制造重复。
519
+ */
520
+ export function clusterPending(records, { threshold = CLUSTER_THRESHOLD } = {}) {
521
+ const { pending, adopted } = groupByStatus(records)
522
+ const clusters = []
523
+ const assigned = new Set()
524
+
525
+ for (let i = 0; i < pending.length; i += 1) {
526
+ const seed = pending[i]
527
+ if (assigned.has(seed.id)) continue
528
+ const members = [seed]
529
+ let weakest = 1
530
+ for (let j = i + 1; j < pending.length; j += 1) {
531
+ const other = pending[j]
532
+ if (assigned.has(other.id)) continue
533
+ const score = similarity(seed.text, other.text)
534
+ if (score >= threshold) {
535
+ members.push(other)
536
+ weakest = Math.min(weakest, score)
537
+ }
538
+ }
539
+ if (members.length > 1) {
540
+ for (const m of members) assigned.add(m.id)
541
+ clusters.push({
542
+ ids: members.map((m) => m.id),
543
+ texts: members.map((m) => m.text),
544
+ // 汇报最弱的一对:簇的可信度取决于最弱的一环,不是最强的那对。
545
+ similarity: Number(weakest.toFixed(3)),
546
+ hint: `${members.length} 条候选像在说同一件事,建议只采纳其一或先自行合并`
547
+ })
548
+ }
549
+ }
550
+
551
+ const conflicts = []
552
+ for (const candidate of pending) {
553
+ for (const keep of adopted) {
554
+ const score = similarity(candidate.text, keep.text)
555
+ if (score >= threshold) {
556
+ conflicts.push({
557
+ candidateId: candidate.id,
558
+ candidateText: candidate.text,
559
+ adoptedId: keep.id,
560
+ adoptedText: keep.text,
561
+ similarity: Number(score.toFixed(3)),
562
+ hint: '与一条已采纳记忆高度相似,采纳它等于制造重复'
563
+ })
564
+ }
565
+ }
566
+ }
567
+
568
+ return { clusters, conflicts }
569
+ }
570
+
571
+ /**
572
+ * 选取注入提示词的已采纳记忆,并给出**每一条为什么进 / 为什么不进**。
573
+ *
574
+ * 这是本插件相对「有记忆功能」的多数实现的差异点之一:注入不是黑箱。
575
+ * 用户看得见「这条没进去,因为它属于别的工作区」或「因为字符预算不够」,
576
+ * 而不是面对一个静默截断的列表。
577
+ *
578
+ * 双约束:条数(maxInjected)与字符数(maxInjectedChars)同时生效,取先到者。
579
+ * 只有条数约束时,40 条长记忆一样能挤爆上下文。
580
+ *
581
+ * @param {object[]} records 全量记忆
582
+ * @param {object} options {maxInjectedChars, cwd}
583
+ * @returns {{selected: object[], decisions: object[], dropped: object[]}}
584
+ * decisions 覆盖**每一条已采纳记忆**(含未选中的),dropped 是其子集。
585
+ */
586
+ /**
587
+ * 把「这一轮为什么没有全部注入」压成一个原因码。
588
+ *
589
+ * 顺序即优先级:越靠前的越能解释「为什么没进来的比预期少」。
590
+ * disabled 优先于一切 —— 记忆层被关掉时,任何预算原因都是假象。
591
+ * 抽成函数而不是嵌套三元:原来的写法套了三层,规则明确禁止。
592
+ */
593
+ function pickLimitedBy(disabled, decisions) {
594
+ if (disabled) return 'disabled'
595
+ if (decisions.some((d) => d.reason === 'over-max')) return 'count'
596
+ if (decisions.some((d) => d.reason === 'over-budget' || d.reason === 'dropped-by-budget')) return 'chars'
597
+ if (decisions.some((d) => d.reason === 'render-failed')) return 'error'
598
+ return 'none'
599
+ }
600
+
601
+ export function selectForInjection(records, maxInjected, options = {}) {
602
+ const { maxInjectedChars = 0, cwd = '', now = new Date() } = options
603
+ const { adopted } = groupByStatus(records)
604
+ const selected = []
605
+ const decisions = []
606
+ let usedChars = 0
607
+
608
+ // 新的优先:记忆的有效性随时间衰减,最新的最贴近当下。
609
+ //
610
+ // 排序键是两段:**先看有没有超窗**(超窗的整段挪到最后),再按写入时间倒序。
611
+ // 为什么不按连续权重排:连续权重算出来的顺序,用户没法用一句话解释「为什么这条
612
+ // 排在前面」;「超窗的排最后」可以解释。而这个问题本来就只需要一个二选一。
613
+ //
614
+ // 这是本函数对衰减的**全部**用法:只影响顺序,不改记录。
615
+ // 超窗条目在条数够、字数够的时候照样注入,只是行尾多一个标签。
616
+ const ordered = [...adopted].sort((a, b) => {
617
+ const rankDiff = decayRank(a, now) - decayRank(b, now)
618
+ if (rankDiff !== 0) return rankDiff
619
+ return String(b.capturedAt).localeCompare(String(a.capturedAt))
620
+ })
621
+
622
+ const disabled = !maxInjected || maxInjected <= 0
623
+ let rank = 0
624
+
625
+ for (const record of ordered) {
626
+ const { applicable, reason: matchReason } = matchLayer(record, cwd)
627
+ const state = normalizeMemoryState(record.state)
628
+ const decay = decayInfo(record, now)
629
+ const decide = (included, reason) => {
630
+ decisions.push({
631
+ id: record.id,
632
+ text: record.text,
633
+ layer: normalizeLayer(record.layer),
634
+ type: normalizeMemoryType(record.type),
635
+ state,
636
+ capturedAt: record.capturedAt,
637
+ verifyAt: record.verifyAt,
638
+ // 衰减只报事实(几岁、超没超窗),不报判断。设置页靠它回答
639
+ // 「这条为什么排到最后」,否则用户只看到结果看不到原因。
640
+ ageDays: decay.ageDays,
641
+ decayed: decay.decayed,
642
+ included,
643
+ reason,
644
+ reasonText: describeInjectionReason(reason)
645
+ })
646
+ }
647
+
648
+ if (disabled) { decide(false, 'no-injection'); continue }
649
+ if (state === 'dropped') { decide(false, 'tombstoned'); continue }
650
+ if (!applicable) { decide(false, matchReason); continue }
651
+ if (rank >= maxInjected) { decide(false, 'over-max'); continue }
652
+ // 预算按**渲染后**的行长算,不按原文长度:注入进提示词的是带日期/状态标签的
653
+ // 那一行,若按原文算,标签带来的额外字符会让实际注入超出 maxInjectedChars。
654
+ //
655
+ // 渲染失败不能把整条记忆链弄崩:`formatMemoryLine` 会读 capturedAt / verifyAt /
656
+ // type 去算标签与衰减,这些字段来自别的插件或用户手写,形状没有保证。
657
+ // 一条坏数据抛出去,代价是 `/api/anima/state` 整个 500、设置页全白 ——
658
+ // 而真正该发生的是「这一条渲染不出来,其余照常,并且明确记下那一条没进去」。
659
+ let body = ''
660
+ try {
661
+ body = formatMemoryLine(record, now)
662
+ } catch {
663
+ decide(false, 'render-failed')
664
+ continue
665
+ }
666
+ if (maxInjectedChars > 0 && usedChars + body.length > maxInjectedChars) {
667
+ // 单条就超预算与「前面占了位置」是两种情况,分开报,用户才知道该删谁。
668
+ decide(false, usedChars === 0 ? 'over-budget' : 'dropped-by-budget')
669
+ continue
670
+ }
671
+ usedChars += body.length
672
+ rank += 1
673
+ selected.push({ ...record, line: body })
674
+ decide(true, 'included')
675
+ }
676
+
677
+ return {
678
+ selected,
679
+ decisions,
680
+ dropped: decisions.filter((d) => !d.included),
681
+ // 预算用量一并回报:设置页要能画「43/40 条、1180/4000 字」这种进度,
682
+ // 否则用户只知道「有东西没进去」,不知道离上限还有多远。
683
+ budget: {
684
+ usedCount: selected.length,
685
+ maxCount: disabled ? 0 : maxInjected,
686
+ usedChars,
687
+ maxChars: maxInjectedChars,
688
+ // 字节账本:不参与判定(判定仍按字符,与配置项名字一致),
689
+ // 只是把「这些字符在中文里到底是多少字节」如实报出来。
690
+ usedBytes: utf8Bytes(selected.map((r) => `- ${r.line}`).join('\n')),
691
+ // 卡在哪个闸上。三档分开,是因为用户能做的事完全不同:
692
+ // count → 删条目;chars → 调预算或删长条目;none → 没卡。
693
+ //
694
+ // ⚠️ 不能只看 usedChars >= maxChars 来判断 chars:一条都没放下时
695
+ // usedChars 仍然是 0,那样会报成 none —— 而「0 条注入、全部因预算」
696
+ // 恰恰是最需要说清楚的那种情况。
697
+ limitedBy: pickLimitedBy(disabled, decisions),
698
+ // 渲染失败单独记数。它不是预算问题:调大预算也没用,得去修那条数据。
699
+ // 混进 over-budget 会误导用户去改配置,而配置根本不是原因。
700
+ renderFailed: decisions.filter((d) => d.reason === 'render-failed').length
701
+ }
702
+ }
703
+ }