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,475 @@
1
+ /**
2
+ * 持续记忆沉淀:把对话边聊边搬进 LRC 长期记忆。
3
+ *
4
+ * ## 与"压缩时抢救"的区别
5
+ *
6
+ * 压缩时抢救 = 上下文已经涨到阈值,才把结论捞出来。此时已经晚了:
7
+ * 宿主会生成一句干瘪摘要,约束全丢。
8
+ *
9
+ * 持续沉淀 = 每 N 步 / 8 轮 / 6000 字符就把结论搬走,上下文增速降到 1/8 左右。
10
+ *
11
+ * ## 三层过滤都在这里收口
12
+ *
13
+ * 送进 LRC 之前依次经过:硬规则(`hardFilter`)→ 本地查重(`isDuplicate`)。
14
+ * 宿主 LLM 的 `<lrc_memory>` 提炼由 `hardFilter` 优先采纳。
15
+ * LRC 只负责存,不做任何语义判断。
16
+ *
17
+ * ## 本模块不依赖 DSH
18
+ *
19
+ * 只做"给定新增文本 → 过滤 → 提炼 → 落库 → 产出快照行"。
20
+ * 事件订阅在 index.ts 里做,这样核心逻辑可以脱离宿主单测。
21
+ */
22
+ import type { DistilledItem, LrcClient } from './lrc-client.ts';
23
+ import { advanceCursor, sliceByCursor, type CursorStore, type RecentStore } from './cursor.ts';
24
+ import {
25
+ DEFAULT_DEDUP_THRESHOLD,
26
+ extractMemoryTag,
27
+ hardFilter,
28
+ isDuplicate,
29
+ isNoUpdate,
30
+ maskPaths,
31
+ } from './memory-filter.ts';
32
+
33
+ export interface OffloadConfig {
34
+ /** 累计多少轮触发一次卸载 */
35
+ turnsThreshold: number;
36
+ /** 累计多少字符触发一次卸载 */
37
+ charsThreshold: number;
38
+ /** 累计多少步触发一次卸载(长任务里正文很少,靠步数兜底) */
39
+ stepsThreshold: number;
40
+ /** 记忆归属项目 */
41
+ project: string;
42
+ /** 单次最多提炼条数 */
43
+ maxItems: number;
44
+ /** 快照里最多列几条结论 */
45
+ snapshotItems: number;
46
+ /** 本地查重阈值(Jaccard) */
47
+ dedupThreshold: number;
48
+ /** 查重时回看多少条已发送的块 */
49
+ recentWindow: number;
50
+ }
51
+
52
+ export const DEFAULT_OFFLOAD_CONFIG: OffloadConfig = {
53
+ turnsThreshold: 8,
54
+ charsThreshold: 6000,
55
+ stepsThreshold: 20,
56
+ project: 'dsh',
57
+ maxItems: 40,
58
+ snapshotItems: 3,
59
+ dedupThreshold: DEFAULT_DEDUP_THRESHOLD,
60
+ recentWindow: 50,
61
+ };
62
+
63
+ export interface OffloadResult {
64
+ /** 本次送进 distill 的字符数(验收标准 1 用) */
65
+ charsSent: number;
66
+ /** 提炼出的条数 */
67
+ distilled: number;
68
+ /** 新写入条数 */
69
+ written: number;
70
+ /** 合并进已有记忆的条数 */
71
+ merged: number;
72
+ /** 被本地查重丢弃的块数 */
73
+ deduped: number;
74
+ /** 注入下一轮的状态快照(1~3 行) */
75
+ snapshot: string;
76
+ /** 游标是否因 transcript 重写而失效 */
77
+ reset: boolean;
78
+ }
79
+
80
+ /** 沉淀累计统计(`lrc_status` 用)。 */
81
+ export interface OffloadStats {
82
+ /** 累计成功沉淀次数 */
83
+ flushCount: number;
84
+ /** 累计新写入条数 */
85
+ totalWritten: number;
86
+ /** 累计合并进已有记忆的条数 */
87
+ totalMerged: number;
88
+ /** 累计被本地查重丢弃的块数 */
89
+ totalDeduped: number;
90
+ /** 最近一次沉淀时间(毫秒时间戳;0 表示还没沉淀过) */
91
+ lastFlushAt: number;
92
+ /** 最近一次新写入条数 */
93
+ lastFlushWritten: number;
94
+ /** 最近一次合并条数 */
95
+ lastFlushMerged: number;
96
+ /** 当前查重窗口里的块数 */
97
+ recentWindow: number;
98
+ }
99
+
100
+ /** 把提炼结果压成一行状态快照。 */
101
+ export function buildSnapshot(items: DistilledItem[], maxItems: number): string {
102
+ if (items.length === 0) return '';
103
+ const picked = items.slice(0, maxItems).map((item) => {
104
+ // 去掉 `[决策] ` 这类前缀,快照里用更短的标签
105
+ const body = item.content.replace(/^\[[^\]]+\]\s*/, '');
106
+ // 40 字符会把中文结论截成半句(实测快照读起来像噪声),放宽到 120。
107
+ const short = body.length > 120 ? `${body.slice(0, 120)}…` : body;
108
+ return short;
109
+ });
110
+ return `[LRC 状态] ${picked.join(';')}。细节在 LRC,需要时 recall。`;
111
+ }
112
+
113
+ /**
114
+ * 过程叙述词:出现这些词说明句子在描述「接下来要做什么」,不是已完成的结论。
115
+ *
116
+ * 实测 DSH 会话里 AI 会把「接下来我要确认工具参数存放在哪里」写成 `- 结论:…`,
117
+ * 插件按行首标签直接挂 `[结论]`,导致结论计数约 10% 虚高。
118
+ */
119
+ const PROCESS_NARRATION = /接下来|下一步|我要|待确认|待验证|还需要|准备|打算|将要/;
120
+
121
+ /** 完成时态词:出现这些词说明句子已经得出结论,即使含过程词也不降级。 */
122
+ const COMPLETION_WORDS = /已完成|已确认|已验证|已修复|结论是|因此|所以|说明|表明/;
123
+
124
+ /** 是否是「过程叙述冒充结论」:含过程词且不含完成词。 */
125
+ function isProcessNarration(body: string): boolean {
126
+ return PROCESS_NARRATION.test(body) && !COMPLETION_WORDS.test(body);
127
+ }
128
+
129
+ /**
130
+ * 标签行的类别判定。
131
+ *
132
+ * **行首声明优先**:`决策:…` / `约束:…` / `结论:…` 是模型按系统提示写的固定格式,
133
+ * 必须直接采信。此前先跑关键词匹配,导致「结论:…约束→preference…」这种行
134
+ * 因为正文里出现「约束」二字被劫持成约束。
135
+ * 关键词只在没有行首声明时兜底。
136
+ */
137
+ function classifyTagLine(line: string): {
138
+ kind: string;
139
+ memoryType: string;
140
+ tag: string;
141
+ importance: number;
142
+ } {
143
+ const head = /^(决策|约束|结论)\s*[::]/.exec(line);
144
+ if (head !== null) {
145
+ const body = line.slice(head[0].length).trim();
146
+ switch (head[1]) {
147
+ case '约束':
148
+ return { kind: '约束', memoryType: 'preference', tag: 'constraint', importance: 7 };
149
+ case '决策':
150
+ return { kind: '决策', memoryType: 'decision', tag: 'decision', importance: 7 };
151
+ default:
152
+ // 「结论:接下来我要…」是过程叙述,不是结论 → 降级为事实
153
+ if (isProcessNarration(body)) {
154
+ return { kind: '事实', memoryType: 'fact', tag: 'fact', importance: 5 };
155
+ }
156
+ return { kind: '结论', memoryType: 'fact', tag: 'conclusion', importance: 6 };
157
+ }
158
+ }
159
+ // 关键词兜底也要先挖路径:`- 看下 docs/禁止.md` 不该被判成约束。
160
+ const body = maskPaths(line);
161
+ if (/禁止|严禁|不得|不要|不能|切勿|杜绝|不允许|必须|约束|一律|统一为/.test(body)) {
162
+ return { kind: '约束', memoryType: 'preference', tag: 'constraint', importance: 7 };
163
+ }
164
+ if (/决策|决定|采用|改用|选型|定为|换成|改成/.test(body)) {
165
+ return { kind: '决策', memoryType: 'decision', tag: 'decision', importance: 7 };
166
+ }
167
+ return { kind: '结论', memoryType: 'fact', tag: 'conclusion', importance: 6 };
168
+ }
169
+
170
+ /**
171
+ * 把 `<lrc_memory>` 标签内容直接转成记忆条目。
172
+ *
173
+ * **必须绕过 distill**:标签内容已经是宿主 LLM 提炼过的精粮,
174
+ * 再送进 LRC 的规则式打分会被二次过滤 —— 实测 `- 决策:用 pnpm 替代 npm`
175
+ * 因为 `DECISION_WORDS` 里没有「决策」而被直接丢掉。
176
+ */
177
+ export function itemsFromTag(tag: string): DistilledItem[] {
178
+ const items: DistilledItem[] = [];
179
+ const lines = tag.split('\n');
180
+ for (let i = 0; i < lines.length; i += 1) {
181
+ const line = lines[i].trim().replace(/^[-*+]\s*/, '').trim();
182
+ if (line.length === 0) continue;
183
+ const { kind, memoryType, tag: tagName, importance } = classifyTagLine(line);
184
+ // 去掉模型自己写的 `决策:` 前缀,避免入库变成 `[决策] 决策:…` 重复
185
+ const body = line.replace(/^(决策|约束|结论|决定)\s*[::]\s*/, '').trim();
186
+ items.push({
187
+ content: `[${kind}] ${body.length > 0 ? body : line}`,
188
+ memory_type: memoryType,
189
+ tags: [tagName, 'distilled', 'lrc_memory'],
190
+ importance,
191
+ source_turns: [i, i],
192
+ });
193
+ }
194
+ return items;
195
+ }
196
+
197
+ export class Offloader {
198
+ private readonly client: LrcClient;
199
+ private readonly cursors: CursorStore;
200
+ private readonly config: OffloadConfig;
201
+ private readonly recentStore?: RecentStore;
202
+ private pendingTurns = 0;
203
+ private pendingChars = 0;
204
+ private pendingSteps = 0;
205
+ /** 最近已发送的块,用于本地查重(跨会话共享,避免重复入库) */
206
+ private recent: string[] = [];
207
+ /** 累计统计(`lrc_status` 用) */
208
+ private flushCount = 0;
209
+ private totalWritten = 0;
210
+ private totalMerged = 0;
211
+ private totalDeduped = 0;
212
+ private lastFlushAt = 0;
213
+ private lastFlushWritten = 0;
214
+ private lastFlushMerged = 0;
215
+
216
+ constructor(
217
+ client: LrcClient,
218
+ cursors: CursorStore,
219
+ config: Partial<OffloadConfig> = {},
220
+ recentStore?: RecentStore,
221
+ ) {
222
+ this.client = client;
223
+ this.cursors = cursors;
224
+ this.config = { ...DEFAULT_OFFLOAD_CONFIG, ...config };
225
+ this.recentStore = recentStore;
226
+ // 查重窗口跨插件重启保留:否则重启后换措辞复述会重新入库
227
+ this.recent = recentStore?.load() ?? [];
228
+ }
229
+
230
+ /** 记录一轮新增文本(由 index.ts 在 assistant/message 事件里调用)。 */
231
+ observe(text: string): void {
232
+ const cleaned = hardFilter(text);
233
+ if (cleaned.trim().length === 0) return;
234
+ this.pendingTurns += 1;
235
+ this.pendingChars += cleaned.length;
236
+ }
237
+
238
+ /**
239
+ * 记录一步。
240
+ *
241
+ * 长任务(88 步 / 138 次工具调用)里正文可能只有最后一条,
242
+ * 只按"正文消息数"计数永远到不了阈值 —— 必须同时按步数兜底。
243
+ */
244
+ observeStep(): void {
245
+ this.pendingSteps += 1;
246
+ }
247
+
248
+ /** 是否达到卸载阈值。 */
249
+ shouldOffload(): boolean {
250
+ return (
251
+ this.pendingTurns >= this.config.turnsThreshold ||
252
+ this.pendingChars >= this.config.charsThreshold ||
253
+ this.pendingSteps >= this.config.stepsThreshold
254
+ );
255
+ }
256
+
257
+ /**
258
+ * 是否有待沉淀内容(turn/end 兜底用)。
259
+ *
260
+ * 必须包含 `pendingSteps`:长任务里可能只有工具调用、没有正文,
261
+ * 此时步数阈值已经触发,但 turns/chars 都是 0。漏掉步数会让
262
+ * "88 步兜底"恰好在这类任务上失效。
263
+ */
264
+ hasPending(): boolean {
265
+ return this.pendingTurns > 0 || this.pendingChars > 0 || this.pendingSteps > 0;
266
+ }
267
+
268
+ /** 重置计数器(卸载成功或会话切换时调用)。 */
269
+ resetCounters(): void {
270
+ this.pendingTurns = 0;
271
+ this.pendingChars = 0;
272
+ this.pendingSteps = 0;
273
+ }
274
+
275
+ /** 累计沉淀统计(`lrc_status` 用)。 */
276
+ stats(): OffloadStats {
277
+ return {
278
+ flushCount: this.flushCount,
279
+ totalWritten: this.totalWritten,
280
+ totalMerged: this.totalMerged,
281
+ totalDeduped: this.totalDeduped,
282
+ lastFlushAt: this.lastFlushAt,
283
+ lastFlushWritten: this.lastFlushWritten,
284
+ lastFlushMerged: this.lastFlushMerged,
285
+ recentWindow: this.recent.length,
286
+ };
287
+ }
288
+
289
+ /** 记录一次沉淀结果(只统计真正有产出的批次)。 */
290
+ private record(result: OffloadResult): void {
291
+ this.totalDeduped += result.deduped;
292
+ if (result.written === 0 && result.merged === 0) return;
293
+ this.flushCount += 1;
294
+ this.totalWritten += result.written;
295
+ this.totalMerged += result.merged;
296
+ this.lastFlushAt = Date.now();
297
+ this.lastFlushWritten = result.written;
298
+ this.lastFlushMerged = result.merged;
299
+ }
300
+
301
+ /**
302
+ * 执行一次卸载。
303
+ *
304
+ * @param sessionId 会话 ID(游标按会话分槽)
305
+ * @param lines 当前 transcript 的全部行
306
+ * @returns 卸载结果;没有新增内容时返回 null
307
+ */
308
+ async offload(sessionId: string, lines: string[]): Promise<OffloadResult | null> {
309
+ const state = this.cursors.get(sessionId);
310
+ const slice = sliceByCursor(lines, state);
311
+
312
+ if (slice.text.trim().length === 0) {
313
+ this.resetCounters();
314
+ return null;
315
+ }
316
+
317
+ // 第一层:硬规则过滤。
318
+ //
319
+ // **按"消息"整段过滤,不能按物理行**:`stripCodeFences` 需要跨行维护
320
+ // 围栏状态,逐行调用时状态永远是初始值,围栏内的代码会直接透传。
321
+ // `lines` 的每个元素就是一条完整消息(可能多行),正好是过滤单元。
322
+ const newLines = lines.slice(slice.fromLine);
323
+
324
+ // 第二层产物:标签内容直接成条目,**绕过 distill**(避免二次过滤)
325
+ const taggedItems: DistilledItem[] = [];
326
+ // 兜底路径:没有标签的消息走硬规则 + distill
327
+ const untagged: string[] = [];
328
+
329
+ for (const message of newLines) {
330
+ const tag = extractMemoryTag(message);
331
+ if (tag !== null) {
332
+ if (!isNoUpdate(tag)) taggedItems.push(...itemsFromTag(tag));
333
+ continue;
334
+ }
335
+ const cleaned = hardFilter(message);
336
+ if (cleaned.trim().length > 0) untagged.push(cleaned);
337
+ }
338
+
339
+ // 第三层:本地查重 —— 换措辞复述连 HTTP 都不发
340
+ const freshTagged: DistilledItem[] = [];
341
+ const freshBlocks: string[] = [];
342
+ let deduped = 0;
343
+ for (const item of taggedItems) {
344
+ if (isDuplicate(item.content, this.recent, this.config.dedupThreshold)) {
345
+ deduped += 1;
346
+ continue;
347
+ }
348
+ freshTagged.push(item);
349
+ }
350
+ for (const block of untagged) {
351
+ if (isDuplicate(block, this.recent, this.config.dedupThreshold)) {
352
+ deduped += 1;
353
+ continue;
354
+ }
355
+ freshBlocks.push(block);
356
+ }
357
+
358
+ // 没有新内容:这段确实没有可沉淀的东西,游标可以安全推进
359
+ if (freshTagged.length === 0 && freshBlocks.length === 0) {
360
+ this.commitCursor(sessionId, lines, slice.fromLine);
361
+ const empty: OffloadResult = {
362
+ charsSent: 0,
363
+ distilled: 0,
364
+ written: 0,
365
+ merged: 0,
366
+ deduped,
367
+ snapshot: '',
368
+ reset: slice.reset,
369
+ };
370
+ this.record(empty);
371
+ return empty;
372
+ }
373
+
374
+ let items: DistilledItem[] = [...freshTagged];
375
+ let charsSent = freshTagged.reduce((sum, item) => sum + item.content.length, 0);
376
+
377
+ if (freshBlocks.length > 0) {
378
+ const text = freshBlocks.join('\n');
379
+ charsSent += text.length;
380
+ const distilled = await this.client.distill(text, this.config.project, this.config.maxItems);
381
+ items = [...items, ...distilled.items];
382
+ }
383
+
384
+ let written = 0;
385
+ let merged = 0;
386
+ if (items.length > 0) {
387
+ const migrated = await this.client.migrate(items, this.config.project);
388
+ written = migrated.written;
389
+ merged = migrated.merged;
390
+ }
391
+
392
+ // **只有 distill + migrate 都成功才推进游标**。
393
+ // 提前推进会让任何一次网络抖动 / LRC 重启 / 超时把这一批对话永久丢掉。
394
+ this.commitCursor(sessionId, lines, slice.fromLine);
395
+
396
+ // 记录已发送的块,供后续查重
397
+ this.recent.push(...freshTagged.map((item) => item.content), ...freshBlocks);
398
+ if (this.recent.length > this.config.recentWindow) {
399
+ this.recent = this.recent.slice(-this.config.recentWindow);
400
+ }
401
+ this.recentStore?.save(this.recent);
402
+
403
+ const result: OffloadResult = {
404
+ charsSent,
405
+ distilled: items.length,
406
+ written,
407
+ merged,
408
+ deduped,
409
+ snapshot: buildSnapshot(items, this.config.snapshotItems),
410
+ reset: slice.reset,
411
+ };
412
+ this.record(result);
413
+ return result;
414
+ }
415
+
416
+ /**
417
+ * 直接沉淀一批已提炼的条目(**不走行游标**)。
418
+ *
419
+ * 用于推理结论兜底:这些句子来自 reasoning 流,不在 transcript 行流里,
420
+ * 无法用行号游标追踪,只能按内容查重。查重窗口与主路径共用。
421
+ *
422
+ * @param items 已分类的记忆条目
423
+ * @returns 卸载结果;全部被查重拦下时返回零值结果
424
+ */
425
+ async offloadItems(items: DistilledItem[]): Promise<OffloadResult> {
426
+ const fresh: DistilledItem[] = [];
427
+ let deduped = 0;
428
+ for (const item of items) {
429
+ if (isDuplicate(item.content, this.recent, this.config.dedupThreshold)) {
430
+ deduped += 1;
431
+ continue;
432
+ }
433
+ fresh.push(item);
434
+ }
435
+ if (fresh.length === 0) {
436
+ const empty: OffloadResult = {
437
+ charsSent: 0,
438
+ distilled: 0,
439
+ written: 0,
440
+ merged: 0,
441
+ deduped,
442
+ snapshot: '',
443
+ reset: false,
444
+ };
445
+ this.record(empty);
446
+ return empty;
447
+ }
448
+
449
+ const migrated = await this.client.migrate(fresh, this.config.project);
450
+ this.recent.push(...fresh.map((item) => item.content));
451
+ if (this.recent.length > this.config.recentWindow) {
452
+ this.recent = this.recent.slice(-this.config.recentWindow);
453
+ }
454
+ this.recentStore?.save(this.recent);
455
+
456
+ const result: OffloadResult = {
457
+ charsSent: fresh.reduce((sum, item) => sum + item.content.length, 0),
458
+ distilled: fresh.length,
459
+ written: migrated.written,
460
+ merged: migrated.merged,
461
+ deduped,
462
+ snapshot: buildSnapshot(fresh, this.config.snapshotItems),
463
+ reset: false,
464
+ };
465
+ this.record(result);
466
+ return result;
467
+ }
468
+
469
+ /** 推进游标并重置计数器(只在本次内容已处理完时调用)。 */
470
+ private commitCursor(sessionId: string, lines: string[], fromLine: number): void {
471
+ const consumed = lines.length - fromLine;
472
+ this.cursors.set(advanceCursor(lines, sessionId, fromLine, consumed));
473
+ this.resetCounters();
474
+ }
475
+ }