dsh-plugin-lrc 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,506 @@
1
+ /**
2
+ * 沉淀前的三层过滤(**全部在插件端完成,LRC 只负责存**)。
3
+ *
4
+ * ## 为什么不在 LRC 端做
5
+ *
6
+ * LRC 的定位是极速存储基建(写入 ~29ms)。一旦在里面塞 LLM 做语义过滤,
7
+ * 每次写入都要等模型生成,耗时从毫秒级变成秒级,且安装包与本地算力开销
8
+ * 都会失控。筛选、提炼、决定"什么该存"是网关(插件)的职责。
9
+ *
10
+ * ## 三层
11
+ *
12
+ * 1. **硬规则**(`hardFilter`):寒暄、代码围栏、正常日志、过短且无关键词的行。
13
+ * 2. **宿主 LLM 提炼**(`extractMemoryTag`):系统提示要求模型在回答末尾输出
14
+ * `<lrc_memory>决策/约束/结论</lrc_memory>`,这里用正则抠出来。
15
+ * 3. **本地查重**(`isDuplicate`):Jaccard 相似度,重复复述连 HTTP 都不发。
16
+ *
17
+ * 本模块是纯函数,不依赖 `@deepseek-ai/*`,可直接单测。
18
+ */
19
+
20
+ /** 模型按系统提示输出的记忆标签。 */
21
+ export const MEMORY_TAG_OPEN = '<lrc_memory>';
22
+ export const MEMORY_TAG_CLOSE = '</lrc_memory>';
23
+
24
+ /**
25
+ * 找出所有**行首**标签块。
26
+ *
27
+ * 为什么要求行首:模型会在正文里行内提及标签,例如
28
+ * 「残留漏网依赖 `<lrc_memory>` 层兜底」。用 `indexOf` 找第一个标签会命中
29
+ * 这个行内提及,把整段报告正文当成标签内容切碎入库(实测产出
30
+ * `[结论] <lrc_memory>`、`[结论] ## 验证` 这类碎片)。
31
+ * 真正的标签按系统提示写在回答末尾、独占一行。
32
+ */
33
+ function findTagBlocks(text: string): Array<{ start: number; end: number; body: string }> {
34
+ const blocks: Array<{ start: number; end: number; body: string }> = [];
35
+ let offset = 0;
36
+ for (const line of text.split('\n')) {
37
+ if (line.trim().startsWith(MEMORY_TAG_OPEN)) {
38
+ const openAt = offset + line.indexOf(MEMORY_TAG_OPEN);
39
+ const closeAt = text.indexOf(MEMORY_TAG_CLOSE, openAt + MEMORY_TAG_OPEN.length);
40
+ if (closeAt >= 0) {
41
+ blocks.push({
42
+ start: openAt,
43
+ end: closeAt + MEMORY_TAG_CLOSE.length,
44
+ body: text.slice(openAt + MEMORY_TAG_OPEN.length, closeAt).trim(),
45
+ });
46
+ }
47
+ }
48
+ offset += line.length + 1;
49
+ }
50
+ return blocks;
51
+ }
52
+
53
+ /**
54
+ * 抠出 `<lrc_memory>…</lrc_memory>` 里的内容。
55
+ *
56
+ * 取**最后一个**行首标签块(真正的标签在回答末尾)。
57
+ * 没有标签、标签为空、标签未闭合都返回 `null` —— 调用方据此退回硬规则。
58
+ */
59
+ export function extractMemoryTag(text: string): string | null {
60
+ const blocks = findTagBlocks(text);
61
+ if (blocks.length === 0) return null;
62
+ const body = blocks[blocks.length - 1].body;
63
+ return body.length > 0 ? body : null;
64
+ }
65
+
66
+ /** 去掉所有行首标签块,保留其余正文。 */
67
+ export function stripMemoryTag(text: string): string {
68
+ const blocks = findTagBlocks(text);
69
+ if (blocks.length === 0) return text;
70
+ let out = '';
71
+ let cursor = 0;
72
+ for (const block of blocks) {
73
+ out += text.slice(cursor, block.start);
74
+ cursor = block.end;
75
+ }
76
+ return out + text.slice(cursor);
77
+ }
78
+
79
+ /**
80
+ * 标签内容是否表示"本轮无新增"。
81
+ *
82
+ * 系统提示要求模型**每次都输出标签**,没有可记的就写 `无新增`。
83
+ * 这样触发率可观测,也不会因为模型偶尔省略标签而退回噪声很大的硬规则。
84
+ */
85
+ export function isNoUpdate(body: string): boolean {
86
+ const t = body.trim().replace(/[。.!!\s]/g, '').toLowerCase();
87
+ return t === '无新增' || t === '无' || t === 'none' || t === 'noupdate' || t === 'nothing';
88
+ }
89
+
90
+ /** 寒暄词表(去掉标点后整句匹配,避免误伤正文)。 */
91
+ const SMALL_TALK = [
92
+ '你好',
93
+ '您好',
94
+ '谢谢',
95
+ '多谢',
96
+ '好的',
97
+ '收到',
98
+ '明白',
99
+ '了解',
100
+ 'ok',
101
+ 'okay',
102
+ 'thanks',
103
+ 'thankyou',
104
+ 'hello',
105
+ 'hi',
106
+ ];
107
+
108
+ /** 寒暄判定:去标点后不超过 12 字,且以寒暄词开头。 */
109
+ export function isSmallTalk(line: string): boolean {
110
+ const t = line
111
+ .trim()
112
+ .toLowerCase()
113
+ .replace(/[。!!,,、;;::\s]/g, '');
114
+ if (t.length === 0) return true;
115
+ if (t.length > 12) return false;
116
+ return SMALL_TALK.some((word) => t.startsWith(word));
117
+ }
118
+
119
+ /** 报错标记:命中即视为"值得留"的日志。 */
120
+ const ERROR_MARKERS = [
121
+ 'error',
122
+ 'exception',
123
+ 'panic',
124
+ 'failed',
125
+ 'failure',
126
+ 'traceback',
127
+ '错误',
128
+ '异常',
129
+ '失败',
130
+ '报错',
131
+ '崩溃',
132
+ '堆栈',
133
+ '调用栈',
134
+ ];
135
+
136
+ export function hasErrorMarker(line: string): boolean {
137
+ // 先挖路径:`[info] 轮转 error.log 完成` 里的 error 是文件名,不是报错。
138
+ const lower = maskPaths(line).toLowerCase();
139
+ return ERROR_MARKERS.some((marker) => lower.includes(marker));
140
+ }
141
+
142
+ /**
143
+ * 是否是"正常日志"行。
144
+ *
145
+ * 终端打印 100 行正常日志对长期记忆毫无价值;只有报错才值得留。
146
+ * 判定只看行首形态(时间戳 / 日志级别),不猜内容。
147
+ */
148
+ export function looksLikeNormalLog(line: string): boolean {
149
+ const t = line.trim();
150
+ if (hasErrorMarker(t)) return false;
151
+ if (/^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/.test(t)) return true;
152
+ if (/^\[?(INFO|DEBUG|TRACE|VERBOSE)\]?[\s:]/i.test(t)) return true;
153
+ return false;
154
+ }
155
+
156
+ /** 去掉 ``` 围栏代码块,只保留围栏外的说明文字。 */
157
+ export function stripCodeFences(text: string): string {
158
+ const out: string[] = [];
159
+ let inFence = false;
160
+ for (const line of text.split('\n')) {
161
+ if (line.trim().startsWith('```')) {
162
+ inFence = !inFence;
163
+ continue;
164
+ }
165
+ if (!inFence) out.push(line);
166
+ }
167
+ return out.join('\n');
168
+ }
169
+
170
+ /**
171
+ * 选项标签:`- **A**:…` / `- 1) …`。
172
+ *
173
+ * 这类行是报告里的"选项罗列 / 待办清单",不是可复用结论。
174
+ * 实测一次沉淀 11 条里 2 条是 `- **A**:…` 这种片段。
175
+ */
176
+ export function isOptionLabel(line: string): boolean {
177
+ const t = line.trim();
178
+ if (/^[-*+]\s*\*\*[A-Za-z0-9]{1,3}\*\*\s*[::]/.test(t)) return true;
179
+ if (/^[-*+]\s*[A-Za-z0-9]{1,3}[.、)]\s/.test(t)) return true;
180
+ return false;
181
+ }
182
+
183
+ // ────────────────────────────────────────────────────────────
184
+ // 词表命中路径
185
+ //
186
+ // 词表判定全部是 `includes`,天然分不清「正文里的词」和「路径里的词」:
187
+ // `见 docs/规范.md`、`materials/架构图.png` 这类纯指针行会因为路径里带
188
+ // 「规范 / 架构 / 端口」被当成结论留库;用户消息门槛也会被
189
+ // `帮我排版 docs/禁止事项.md` 里的「禁止」骗过。
190
+ //
191
+ // 因此所有词表判定都先过 `maskPaths()`:把 URL / 绝对路径 / 相对路径 /
192
+ // 带扩展名的文件名整段挖成空格,再在剩下的正文上匹配。
193
+ // 挖成等长空格而不是删除,是为了让 `explainKeywordHit()` 能报告
194
+ // 「命中词来自正文还是路径」。
195
+ // ────────────────────────────────────────────────────────────
196
+
197
+ const PATH_BOUNDARY = '[^\\s,。;:、!?"\'`()()【】\\[\\]{}<>《》「」]';
198
+
199
+ /** 路径类 token 的正则(顺序有意义:先 URL,再绝对路径,最后文件名)。 */
200
+ const PATH_PATTERNS: readonly RegExp[] = [
201
+ /\bhttps?:\/\/[^\s,。;:、!?"'`()()【】\[\]{}<>《》「」]+/gi,
202
+ /[A-Za-z]:[\\/][^\s,。;:、!?"'`()()【】\[\]{}<>《》「」]*/g,
203
+ /\\\\[^\s,。;:、!?"'`()()【】\[\]{}<>《》「」]*/g,
204
+ // 相对路径:**只认 ASCII 路径段**。
205
+ //
206
+ // 不能用 `${PATH_BOUNDARY}*[\\/]${PATH_BOUNDARY}*` —— PATH_BOUNDARY 含 CJK,
207
+ // 会把 `禁止使用3099/3111` 整段挖成空格,真实约束直接丢失(实测回归)。
208
+ // CJK 文件名由下面的扩展名规则兜住(`docs/规范.md` 仍会被完整挖掉)。
209
+ /(?:[A-Za-z0-9_.~-]+[\\/])+[A-Za-z0-9_.~-]*/g,
210
+ new RegExp(
211
+ `${PATH_BOUNDARY}+\\.(?:md|markdown|txt|ts|tsx|js|jsx|mjs|cjs|json|ya?ml|toml|ini|cfg|conf|` +
212
+ 'png|jpe?g|gif|svg|webp|bmp|pdf|docx?|xlsx?|pptx?|csv|log|exe|dll|so|dylib|lock|' +
213
+ 'html?|css|scss|less|py|rs|go|java|kt|sh|ps1|bat|cmd|zip|tar|gz|7z|mp4|mp3|wav)\\b',
214
+ 'gi',
215
+ ),
216
+ ];
217
+
218
+ /** 把路径类 token 挖成等长空格;返回同长度字符串。 */
219
+ export function maskPaths(text: string): string {
220
+ let out = text;
221
+ for (const pattern of PATH_PATTERNS) {
222
+ out = out.replace(pattern, (token) => ' '.repeat(token.length));
223
+ }
224
+ return out;
225
+ }
226
+
227
+ /**
228
+ * 约束词表:**用户消息门槛**与**短行豁免**共用这一份。
229
+ *
230
+ * 以前 `session-text.ts` 里另有一份 `USER_CONSTRAINT_WORDS`,两边只覆盖
231
+ * 「必须/禁止/不要…」这一小撮,结果 `统一用 3111`、`只允许 3111`、
232
+ * `never use 3099` 这类真实约束全被门槛丢掉。词表只留一份,避免再漂移。
233
+ */
234
+ export const CONSTRAINT_WORDS = [
235
+ '必须',
236
+ '禁止',
237
+ '严禁',
238
+ '切勿',
239
+ '不得',
240
+ '不能',
241
+ '不要',
242
+ '不允许',
243
+ '不许',
244
+ '不准',
245
+ '禁用',
246
+ '杜绝',
247
+ '一律',
248
+ '统一',
249
+ '统一为',
250
+ '默认',
251
+ '只允许',
252
+ '只能',
253
+ '务必',
254
+ '一定要',
255
+ '请用',
256
+ '记得',
257
+ '别忘',
258
+ '下次',
259
+ '别用',
260
+ '别再',
261
+ '别动',
262
+ '别改',
263
+ '别删',
264
+ '别碰',
265
+ '别连',
266
+ '别把',
267
+ '别写',
268
+ '别存',
269
+ '别加',
270
+ '改用',
271
+ '换成',
272
+ '改成',
273
+ '以后都',
274
+ '今后都',
275
+ ];
276
+
277
+ /** 英文约束标记(用词边界,避免 `notation` 之类误中 `not`)。 */
278
+ const ENGLISH_CONSTRAINT =
279
+ /\b(?:never|always|must(?:\s+not)?|should\s+not|do\s+not|don'?t|dont|avoid|forbidden|prohibited|not\s+allowed|only\s+use|required\s+to)\b/i;
280
+
281
+ /** 短行豁免词表 = 约束词 + 决策/名词类结论标记。 */
282
+ const SHORT_LINE_KEYWORDS: readonly string[] = [
283
+ ...new Set([
284
+ ...CONSTRAINT_WORDS,
285
+ '严禁',
286
+ '决定',
287
+ '决策',
288
+ '结论',
289
+ '约束',
290
+ '规范',
291
+ '约定',
292
+ '原则',
293
+ '端口',
294
+ '架构',
295
+ ]),
296
+ ];
297
+
298
+ /** 单行最短长度(低于此值且无关键词则丢弃)。 */
299
+ const MIN_CHARS = 20;
300
+
301
+ /** 去掉路径后,正文至少要剩这么多字符,否则视为纯指针行。 */
302
+ const MIN_BODY_CHARS = 8;
303
+
304
+ /** 关键词是否命中**正文**(路径部分已被挖掉)。 */
305
+ function hitsKeyword(body: string): boolean {
306
+ if (ENGLISH_CONSTRAINT.test(body)) return true;
307
+ return SHORT_LINE_KEYWORDS.some((k) => body.includes(k));
308
+ }
309
+
310
+ /**
311
+ * 用户消息是否带约束(门槛判定用)。
312
+ *
313
+ * 先挖路径再匹配,所以 `帮我排版 docs/禁止事项.md` 不再因为路径里的
314
+ * 「禁止」通过门槛。
315
+ */
316
+ export function hasConstraintMarker(text: string): boolean {
317
+ return hitsKeyword(maskPaths(text));
318
+ }
319
+
320
+ /** 词表命中来源:用来诊断"路径里的词"到底有没有被当成正文关键词。 */
321
+ export type KeywordSource = 'body' | 'path';
322
+
323
+ export interface KeywordHit {
324
+ word: string;
325
+ source: KeywordSource;
326
+ }
327
+
328
+ /**
329
+ * 解释一条文本为什么命中词表(诊断用)。
330
+ *
331
+ * `source === 'path'` 表示只有路径里含该词 —— 这类命中**不参与**
332
+ * 短行豁免与用户门槛判定,仅用于观察。
333
+ */
334
+ export function explainKeywordHit(
335
+ text: string,
336
+ words: readonly string[] = SHORT_LINE_KEYWORDS,
337
+ ): KeywordHit | null {
338
+ const masked = maskPaths(text);
339
+ const bodyWord = words.find((w) => masked.includes(w));
340
+ if (bodyWord !== undefined) return { word: bodyWord, source: 'body' };
341
+ const english = ENGLISH_CONSTRAINT.exec(masked);
342
+ if (english !== null) return { word: english[0].toLowerCase(), source: 'body' };
343
+ const pathWord = words.find((w) => text.includes(w));
344
+ if (pathWord !== undefined) return { word: pathWord, source: 'path' };
345
+ return null;
346
+ }
347
+
348
+ /**
349
+ * 第一层:硬规则过滤。
350
+ *
351
+ * 优先级:`<lrc_memory>` 标签 > 硬规则。有标签时直接用标签内容,
352
+ * 因为那是宿主 LLM 已经提炼过的精粮;标签写 `无新增` 时返回空串
353
+ * (尊重模型的判断,不再退回噪声很大的硬规则)。
354
+ *
355
+ * **必须整段调用**:`stripCodeFences` 要跨行维护围栏状态,
356
+ * 逐行调用会让围栏内的代码直接透传。
357
+ */
358
+ export function hardFilter(text: string): string {
359
+ const tag = extractMemoryTag(text);
360
+ if (tag !== null) {
361
+ if (isNoUpdate(tag)) return '';
362
+ // 防御:模型可能在标签里写代码块,同样剥掉
363
+ return stripCodeFences(tag).trim();
364
+ }
365
+
366
+ const body = stripCodeFences(stripMemoryTag(text));
367
+ const kept: string[] = [];
368
+ for (const raw of body.split('\n')) {
369
+ const line = raw.trim();
370
+ if (line.length === 0) continue;
371
+ if (isSmallTalk(line)) continue;
372
+ if (isOptionLabel(line)) continue;
373
+ if (looksLikeNormalLog(line)) continue;
374
+ // 路径先挖掉:`见 docs/规范.md` 不该因为路径里的「规范」被留下。
375
+ const masked = maskPaths(line);
376
+ const hasKeyword = hitsKeyword(masked);
377
+ if (line.length < MIN_CHARS && !hasKeyword) continue;
378
+ // 路径/URL 之外几乎没剩正文 —— 纯指针行,不是结论。
379
+ if (!hasKeyword && masked.replace(/\s+/g, '').length < MIN_BODY_CHARS) continue;
380
+ kept.push(line);
381
+ }
382
+ return kept.join('\n');
383
+ }
384
+
385
+ /**
386
+ * 结论句标记:推理流里只有带这些词的句子才可能是"结论"。
387
+ *
388
+ * 为什么需要:工具密集型轮次里 assistant 没有正文,`<lrc_memory>` 标签
389
+ * 根本没地方出现(实测 74 步会话正文占比 0%)。整段推理入库会写进大量
390
+ * 内部思考(实测 92 条垃圾),所以只抽带结论标记的句子。
391
+ */
392
+ const CONCLUSION_MARKERS = [
393
+ '结论',
394
+ '因此',
395
+ '所以',
396
+ '决定',
397
+ '必须',
398
+ '不能',
399
+ '应该',
400
+ '最终',
401
+ '综上',
402
+ '关键是',
403
+ '根因',
404
+ '原因',
405
+ '方案',
406
+ '改用',
407
+ '采用',
408
+ '确认',
409
+ '发现',
410
+ '注意',
411
+ ];
412
+
413
+ /**
414
+ * 英文**强**结论标记:出现即视为结论句。
415
+ *
416
+ * 推理流经常是英文,只认中文标记会把整轮内容漏掉。
417
+ */
418
+ const ENGLISH_STRONG =
419
+ /\b(?:therefore|thus|hence|conclusion|concluded|decided|decision|root\s+cause)\b/i;
420
+
421
+ /**
422
+ * 英文**弱**结论标记:`must` / `should` / `because` 在过程叙述里也常见
423
+ * ("I should check the file"),必须配合长度与句式过滤,否则会灌噪声。
424
+ */
425
+ const ENGLISH_WEAK = /\b(?:must|should|need\s+to|because)\b/i;
426
+
427
+ /**
428
+ * 过程叙述:第一人称 + 动作动词("I should check the file"),
429
+ * 或明显的步骤引导词。这类句子即使带弱标记也不是结论。
430
+ *
431
+ * 注意不能一刀切掉所有 `I` / `We`:`We should use ctx.get` 是建议,属于结论。
432
+ */
433
+ const PROCESS_NARRATION =
434
+ /^(?:(?:I|We)\s+(?:should|need\s+to|will|am|are|'ll|'re)\s+(?:check|look|read|see|try|start|begin|open|run|search|find|verify|inspect|edit|update|fix|add|remove|change)\b|Let\s+me\b|Now\b|First\b|Next\b|Then\b|Okay\b|OK\b|Hmm\b|Wait\b|Looking\b|Checking\b|Reading\b|Let's\b)/i;
435
+
436
+ /**
437
+ * 从推理流里抽结论句(兜底路径)。
438
+ *
439
+ * 只保留:长度 8~300、带结论标记、不含代码围栏、不是寒暄/选项/正常日志的句子。
440
+ * 去重后最多 `maxLines` 条。
441
+ *
442
+ * @param text - 本轮累计的推理文本
443
+ * @param maxLines - 最多返回几条
444
+ * @param maxChars - 单句字符上限(过长的多半是整段推理,不是结论)
445
+ * @returns 结论句数组(不含前缀,交给 `itemsFromTag` 分类)
446
+ */
447
+ export function conclusionsFromReasoning(
448
+ text: string,
449
+ maxLines = 5,
450
+ maxChars = 300,
451
+ ): string[] {
452
+ const out: string[] = [];
453
+ const seen = new Set<string>();
454
+ // 中文按标点切;英文按「句号 + 空白」切(避免把 v0.10.0 这类版本号切开)。
455
+ for (const raw of text.split(/[。!?;\n]+|(?<=[A-Za-z0-9])\.\s+/)) {
456
+ const line = raw.trim();
457
+ if (line.length < 8 || line.length > maxChars) continue;
458
+ if (isSmallTalk(line) || isOptionLabel(line) || looksLikeNormalLog(line)) continue;
459
+ if (stripCodeFences(line) !== line) continue;
460
+ const masked = maskPaths(line);
461
+ const chineseHit = CONCLUSION_MARKERS.some((marker) => masked.includes(marker));
462
+ const englishStrong = ENGLISH_STRONG.test(masked);
463
+ const englishWeak = ENGLISH_WEAK.test(masked);
464
+ if (!chineseHit && !englishStrong && !englishWeak) continue;
465
+ // 英文弱标记单独命中时,排除过程叙述与过短句
466
+ if (!chineseHit && !englishStrong && englishWeak) {
467
+ if (line.length < 20 || PROCESS_NARRATION.test(line)) continue;
468
+ }
469
+ const key = line.replace(/\s+/g, '');
470
+ if (seen.has(key)) continue;
471
+ seen.add(key);
472
+ out.push(line);
473
+ if (out.length >= maxLines) break;
474
+ }
475
+ return out;
476
+ }
477
+
478
+ /** 字符级 Jaccard 相似度(与 LRC 的合并判据同口径)。 */
479
+ export function jaccard(a: string, b: string): number {
480
+ const setA = new Set(a.replace(/\s+/g, ''));
481
+ const setB = new Set(b.replace(/\s+/g, ''));
482
+ if (setA.size === 0 && setB.size === 0) return 1;
483
+ let inter = 0;
484
+ for (const ch of setA) if (setB.has(ch)) inter += 1;
485
+ const union = setA.size + setB.size - inter;
486
+ return union === 0 ? 0 : inter / union;
487
+ }
488
+
489
+ /** 默认查重阈值:与 LRC 的相似合并阈值一致。 */
490
+ export const DEFAULT_DEDUP_THRESHOLD = 0.85;
491
+
492
+ /**
493
+ * 第三层:本地查重。
494
+ *
495
+ * 与"最近已发送的块"比对,高度重合说明是换措辞复述,直接丢弃 ——
496
+ * 连 HTTP 请求都不发。
497
+ */
498
+ export function isDuplicate(
499
+ text: string,
500
+ recent: readonly string[],
501
+ threshold: number = DEFAULT_DEDUP_THRESHOLD,
502
+ ): boolean {
503
+ const t = text.trim();
504
+ if (t.length === 0) return true;
505
+ return recent.some((r) => jaccard(r, t) >= threshold);
506
+ }