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.
package/src/index.ts ADDED
@@ -0,0 +1,1164 @@
1
+ /**
2
+ * DSH 插件入口:内置 LRC + 持续记忆沉淀。
3
+ *
4
+ * ## 职责
5
+ *
6
+ * 1. 启动/复用 LRC 运行时(`runtime.ts`)
7
+ * 2. 注册系统提示段落,让宿主 LLM 顺手输出 `<lrc_memory>` 提炼结果
8
+ * 3. 订阅会话事件,累计新增对话
9
+ * 4. 达到阈值(N 步 / 8 轮 / 6000 字符)或 turn 结束时沉淀进 LRC(`offloader.ts`)
10
+ * 5. 注册 `lrc_remember_now` 工具,允许模型手动触发
11
+ *
12
+ * ## 架构边界
13
+ *
14
+ * **LRC 只存不筛**。硬规则过滤、宿主 LLM 提炼、本地查重全部在本插件完成,
15
+ * 送进 LRC 的已经是高密度结论。LRC 端不加任何 LLM,保持毫秒级写入。
16
+ *
17
+ * ## 关于事件名
18
+ *
19
+ * `session/event` 与 `agent/pre-step` 来自 DSH 官方压缩后端的用法。
20
+ * 本插件对事件载荷做**防御式解析**:字段对不上就跳过并记一次 warn,
21
+ * 不会因为 DSH 版本差异而崩溃。
22
+ */
23
+ import type { Context } from '@deepseek-ai/cordis';
24
+ import { defineTool } from '@deepseek-ai/dsh-tools';
25
+ import Schema from '@deepseek-ai/schemastery';
26
+ import { join } from 'node:path';
27
+ import { homedir } from 'node:os';
28
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
29
+ import { LrcRuntime } from './runtime.ts';
30
+ import { LrcClient } from './lrc-client.ts';
31
+ import { CursorStore, RecentStore, snapshotChanged } from './cursor.ts';
32
+ import { Offloader, itemsFromTag } from './offloader.ts';
33
+ import {
34
+ hasMalformedTextBlock,
35
+ linesFromSession,
36
+ messageContent,
37
+ reasoningBlocksOnly,
38
+ textBlocksOnly,
39
+ } from './session-text.ts';
40
+ import { conclusionsFromReasoning, extractMemoryTag, isNoUpdate } from './memory-filter.ts';
41
+ import {
42
+ pruneReasoning,
43
+ pruneToolCallArguments,
44
+ pruneToolResults,
45
+ trimContext,
46
+ } from './context-trim.ts';
47
+ import { buildHandoffText, createHandoffSession, extractHandoffSource } from './handoff.ts';
48
+ import { formatCapabilities, probeCapabilities } from './capabilities.ts';
49
+ import { formatBudget, resolveBudget, type ResolvedBudget } from './budget.ts';
50
+ import { formatProfile, profileSurface } from './context-profile.ts';
51
+ import {
52
+ createGovernorState,
53
+ formatGovernorState,
54
+ runGovernor,
55
+ type GovernorAction,
56
+ } from './governor.ts';
57
+
58
+ export const name = 'dsh-plugin-lrc';
59
+
60
+ /** 等工具注册表就绪后再执行 apply。 */
61
+ export const inject = ['tools'];
62
+
63
+ export interface Config {
64
+ /** 指向已有 LRC;留空则自动探测/启动 */
65
+ baseUrl: string;
66
+ token: string;
67
+ project: string;
68
+ /** 累计多少轮沉淀一次 */
69
+ offloadTurns: number;
70
+ /** 累计多少字符沉淀一次 */
71
+ offloadChars: number;
72
+ /** 累计多少步沉淀一次(长任务兜底) */
73
+ offloadSteps: number;
74
+ /** 单次最多提炼条数 */
75
+ maxItems: number;
76
+ /** 是否启用工具输出剪枝(路径 2,零 LLM,每步执行) */
77
+ pruneEnabled: boolean;
78
+ /** 工具输出剪枝阈值(字符);官方默认 8192,实测命中率太低 */
79
+ pruneResultThreshold: number;
80
+ /** 是否截断超长工具参数(路径 2b,官方 pruner 不覆盖) */
81
+ pruneArgsEnabled: boolean;
82
+ /** 工具参数超过多少字符才截断 */
83
+ pruneArgsThreshold: number;
84
+ /** 工具参数保留开头多少字符 */
85
+ pruneArgsHead: number;
86
+ /** 是否丢弃旧消息里的 reasoning 块(路径 2c) */
87
+ pruneReasoningEnabled: boolean;
88
+ /** 统一保留策略:最近多少个 surface 节点不裁剪、不丢推理 */
89
+ keepRecentNodes: number;
90
+ /** 是否启用确定性上下文裁剪(路径 1,零 LLM) */
91
+ trimEnabled: boolean;
92
+ /** 显式指定模型窗口;0 = 自动探测 */
93
+ contextWindowTokens: number;
94
+ /** 裁剪阈值占窗口比例 */
95
+ trimRatio: number;
96
+ /** 工具输出剪枝阈值占窗口比例 */
97
+ pruneRatio: number;
98
+ /** 窗口未知时的裁剪阈值(绝对值) */
99
+ trimMinTokens: number;
100
+
101
+ /** 两次裁剪之间至少间隔多少步 */
102
+ trimMinSteps: number;
103
+ /** surface 节点数硬上限:超过就跳过 token 阈值强制裁剪 */
104
+ trimMaxNodes: number;
105
+ /** 确定性摘要的字符上限 */
106
+ trimMaxSummaryChars: number;
107
+ }
108
+
109
+ export const Config: Schema = Schema.object({
110
+ baseUrl: Schema.string().default(''),
111
+ token: Schema.string().default(''),
112
+ project: Schema.string().default('dsh'),
113
+ offloadTurns: Schema.number().default(8),
114
+ offloadChars: Schema.number().default(6000),
115
+ // 20 步对工具密集型轮次太晚:实测 16 步的会话一次都没触发,
116
+ // 快照始终为空。降到 8 步,让中途也能沉淀出 `[LRC 状态]`。
117
+ offloadSteps: Schema.number().default(8),
118
+ maxItems: Schema.number().default(40),
119
+ pruneEnabled: Schema.boolean().default(true),
120
+ pruneResultThreshold: Schema.number().default(4096),
121
+ // ★2026-11-07:默认关闭。宿主 surface 校验不允许替换含 tool-call 的
122
+ // assistant 节点(会破坏 tool-pairing 平衡),本路径在当前宿主版本下不可用。
123
+ // 保留配置项,等宿主提供「原地改参数」能力后再启用。
124
+ pruneArgsEnabled: Schema.boolean().default(false),
125
+ pruneArgsThreshold: Schema.number().default(4000),
126
+ pruneArgsHead: Schema.number().default(2000),
127
+ pruneReasoningEnabled: Schema.boolean().default(true),
128
+ // 统一保留策略:trim 与 prune-reasoning 共用,避免两套 keep-recent 漂移
129
+ keepRecentNodes: Schema.number().default(8),
130
+ trimEnabled: Schema.boolean().default(true),
131
+ // 阈值按模型窗口比例推导:1M 窗口 → trim 30000 / prune 4000;
132
+ // 128K 窗口 → trim 8000(下限)/ prune 2048(下限)。
133
+ contextWindowTokens: Schema.number().default(0),
134
+ trimRatio: Schema.number().default(0.03),
135
+ pruneRatio: Schema.number().default(0.004),
136
+ // 窗口探测失败时的绝对值兜底
137
+ trimMinTokens: Schema.number().default(30000),
138
+
139
+ trimMinSteps: Schema.number().default(10),
140
+ // ★2026-11-08:节点数硬上限兜底。token 计量一旦失真(宿主换版本 / meter 返回 0 /
141
+ // 阈值配错),纯 token 触发会让上下文无限增长;节点数不依赖任何服务,用它兜底。
142
+ // 实测正常稳态 31–72 节点,120 只在异常时触发。
143
+ trimMaxNodes: Schema.number().default(120),
144
+ // ★2026-11-07:2000 → 4000。实测 2000 字符的摘要会把关键结论截掉,
145
+ // 用户反馈「关键信息基本都丢了」;4000 仍远小于被裁剪区间(约 9k tokens)。
146
+ trimMaxSummaryChars: Schema.number().default(4000),
147
+ });
148
+
149
+ /** 系统提示段落名(全局唯一,避免与宿主段落冲突)。 */
150
+ const PROMPT_SECTION = 'lrc:memory-capture';
151
+
152
+ /**
153
+ * 让宿主 LLM 顺手做提炼。
154
+ *
155
+ * 为什么放在系统提示而不是再起一次 LLM 调用:宿主本来就要生成回答,
156
+ * 顺手多写 1~3 句结论是零额外成本;单独调用一次模型既慢又费钱。
157
+ *
158
+ * 为什么要求"每次都输出":实测把标签写成可选项时,模型十来轮一次都没照做,
159
+ * 这一层等于不存在。改成强制契约后,触发率可观测,也能用 `无新增` 明确表达空。
160
+ */
161
+ const PROMPT_TEXT = [
162
+ '## 长期记忆(LRC)',
163
+ '',
164
+ '涉及项目历史、用户偏好、之前做过的决定、上次怎么改的,**先调 `lrc_recall` 工具**,',
165
+ '不要直接 Grep 或翻文件。检索为空时才回退内置搜索,并在回复中说明「LRC 未命中,已回退内置搜索」。',
166
+ '',
167
+ '发现某条记忆与事实不符时,用 `lrc_forget` 撤回(id 从 `lrc_recall` 输出里取),',
168
+ '不要让它继续污染后续对话。',
169
+ '',
170
+ '## 长期记忆沉淀(必须遵守)',
171
+ '',
172
+ '每次回答的最后一行,必须输出一个 <lrc_memory> 块,格式固定:',
173
+ '',
174
+ '<lrc_memory>',
175
+ '- 决策:…',
176
+ '- 约束:…',
177
+ '- 结论:…',
178
+ '</lrc_memory>',
179
+ '',
180
+ '规则:',
181
+ '- 只写本轮产生的决策、约束、最终结论;没有的条目直接省略。',
182
+ '- 本轮没有任何值得长期保留的内容时,输出 <lrc_memory>无新增</lrc_memory>。',
183
+ '- 只写结论,不写过程、待办清单、选项罗列或代码。',
184
+ '- 标签必须出现在回答的最末尾,且每轮只出现一次。',
185
+ ].join('\n');
186
+
187
+ export function apply(ctx: Context, config: Config): void {
188
+ const cacheDir = join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'cache', 'lrc-plugin');
189
+ const snapshotPath = join(cacheDir, 'snapshot.json');
190
+
191
+ /**
192
+ * 从磁盘恢复上次的快照。
193
+ *
194
+ * 不持久化的话,插件进程一重启 `latestSnapshot` 就变空串,
195
+ * 读路径(`[LRC 状态]` 注入)直接断掉 —— 而 LRC 里的记忆还在。
196
+ */
197
+ function loadSnapshot(): string {
198
+ try {
199
+ if (!existsSync(snapshotPath)) return '';
200
+ const raw = JSON.parse(readFileSync(snapshotPath, 'utf8')) as { text?: unknown };
201
+ return typeof raw.text === 'string' ? raw.text : '';
202
+ } catch {
203
+ return '';
204
+ }
205
+ }
206
+
207
+ function saveSnapshot(text: string): void {
208
+ try {
209
+ mkdirSync(cacheDir, { recursive: true });
210
+ writeFileSync(snapshotPath, JSON.stringify({ text, updatedAt: Date.now() }), 'utf8');
211
+ } catch {
212
+ // 落盘失败不影响对话
213
+ }
214
+ }
215
+
216
+ const runtime = new LrcRuntime({
217
+ // 配置里的 baseUrl 必须传下去,否则 patch 里写 3111 也会打到 3099
218
+ baseUrl: config.baseUrl,
219
+ cacheDir,
220
+ log: (message) => ctx.logger.info(message),
221
+ });
222
+
223
+ let offloader: Offloader | null = null;
224
+ let client: LrcClient | null = null;
225
+ let warnedShape = false;
226
+ let warnedEmpty = false;
227
+ /** 推理兜底抽不到结论句时只提示一次。 */
228
+ let warnedNoConclusion = false;
229
+ let flushing = false;
230
+ /** 能力矩阵是否已探测(首次 pre-step 探测一次)。 */
231
+ let probed = false;
232
+ /**
233
+ * `<lrc_memory>` 命中统计:**按轮计,不按消息计**。
234
+ *
235
+ * 按消息计时,工具密集型轮次里的中间说明("我先看下文件")会被算成失败,
236
+ * 分母被灌水,实测显示 2.5% 命中率,而真实带标签的轮次比例是 2/5、1/5、1/2。
237
+ */
238
+ let turnHits = 0;
239
+ let turnMisses = 0;
240
+ /** 整轮没有正文的轮次数(标签根本没地方出现,不该计入分母) */
241
+ let turnNoText = 0;
242
+ /** 当前轮状态 */
243
+ let turnHadText = false;
244
+ let turnTagSeen = false;
245
+ let turnReasoning: string[] = [];
246
+ let turnReasoningChars = 0;
247
+ /** 最近一次沉淀产出的状态快照,注入下一轮上下文(闭环的"读"路径)。 */
248
+ let latestSnapshot = loadSnapshot();
249
+ /** 快照是否已被渲染层取走(只记一次日志)。 */
250
+ let snapshotServed = false;
251
+ /** 步进控制器:能力门控 + 熔断 + 节流 + 有序执行。 */
252
+ const governor = createGovernorState();
253
+ /** 统一预算(首次 pre-step 解析一次)。 */
254
+ let budget: ResolvedBudget | null = null;
255
+ /** 已记录过的裁剪未生效原因(每种只记一次,避免每步刷屏)。 */
256
+ const trimReasonsLogged = new Set<string>();
257
+ /** 最近一次 pre-step 的 agent:`lrc_handoff` 工具需要它拿当前会话。 */
258
+ let lastAgent: unknown;
259
+
260
+ // 注册系统提示段落:让模型在回答末尾输出 <lrc_memory>。
261
+ //
262
+ // 用 `ctx.get()` 而不是 `inject`:systemPrompt 是可选依赖 ——
263
+ // 缺了它只影响第二层提炼,不该让整个插件(含沉淀)加载失败。
264
+ const systemPrompt = ctx.get('systemPrompt') as
265
+ | {
266
+ section?: (section: { name: string; order: number; text: string }) => unknown;
267
+ context?: (entry: { name: string; order: number; text: () => string }) => unknown;
268
+ }
269
+ | undefined;
270
+ if (typeof systemPrompt?.section === 'function') {
271
+ try {
272
+ systemPrompt.section({ name: PROMPT_SECTION, order: 9000, text: PROMPT_TEXT });
273
+ // 快照回填:把最近一次沉淀的状态注入下一轮上下文。
274
+ // 这是闭环的"读"路径 —— 之前 buildSnapshot() 只作为工具返回值渲染,
275
+ // 下一轮上下文里根本看不到 `[LRC 状态]`。
276
+ // 没有快照时返回空串,渲染层会跳过空段。
277
+ systemPrompt.context?.({
278
+ name: 'lrc:snapshot',
279
+ // ★2026-11-07(缓存命中率):order 取 9999,让 LRC 快照排在
280
+ // runtime-context 快照的**最末尾**(宿主内置 context 最高 210)。
281
+ // 宿主把整块 runtime-context 作为一条 user 消息 append 到 surface 尾部,
282
+ // 因此快照位于历史之后、不打断前缀;order 只决定块内顺序。
283
+ order: 9999,
284
+ text: () => {
285
+ // 诊断:确认快照到底有没有被渲染层取走。
286
+ // `systemPrompt.context` 是**动态 user-role 快照**,不进 system prompt,
287
+ // 所以查 `request/header.system` 永远看不到它 —— 这条日志才是判据。
288
+ if (latestSnapshot.length > 0 && !snapshotServed) {
289
+ snapshotServed = true;
290
+ ctx.logger.info(`[lrc] 快照已注入上下文(${latestSnapshot.length} 字符)`);
291
+ }
292
+ return latestSnapshot;
293
+ },
294
+ });
295
+ } catch (error) {
296
+ ctx.logger.warn(
297
+ `[lrc] 系统提示注册失败(不影响沉淀):${
298
+ error instanceof Error ? error.message : String(error)
299
+ }`,
300
+ );
301
+ }
302
+ } else {
303
+ ctx.logger.warn('[lrc] systemPrompt 服务不可用,跳过 <lrc_memory> 提示注入(沉淀仍走硬规则)');
304
+ }
305
+
306
+ // 插件卸载/重载时停掉托管实例,否则子进程会泄漏
307
+ ctx.effect(() => () => runtime.stop(), 'lrc.runtime.stop()');
308
+
309
+ // 诊断:探测 compaction 服务是否可达。
310
+ //
311
+ // 主动裁剪上下文(`ctx.compaction.compactRegion`)依赖这个服务。
312
+ // 桌面 profile 里它挂在 preset 的隔离组中(`isolate: { compaction: true }`),
313
+ // 宿主层插件可能拿不到 —— 这条日志用来确认到底可不可达。
314
+ const compaction = ctx.get('compaction') as { compactRegion?: unknown } | undefined;
315
+ ctx.logger.info(
316
+ `[lrc] compaction 服务:${
317
+ compaction === undefined
318
+ ? '不可达(preset 隔离)'
319
+ : typeof compaction.compactRegion === 'function'
320
+ ? '可用(支持 compactRegion)'
321
+ : '存在但无 compactRegion'
322
+ }`,
323
+ );
324
+
325
+ // 异步初始化:不阻塞插件加载
326
+ void (async () => {
327
+ const result = await runtime.ensure();
328
+ if (result.mode === 'degraded') {
329
+ ctx.logger.warn(`[lrc] 未启用记忆沉淀:${result.reason ?? '未知原因'}`);
330
+ return;
331
+ }
332
+ client = new LrcClient({ baseUrl: result.baseUrl, token: config.token || undefined });
333
+ offloader = new Offloader(
334
+ client,
335
+ new CursorStore(join(cacheDir, 'cursor.json')),
336
+ {
337
+ turnsThreshold: config.offloadTurns,
338
+ charsThreshold: config.offloadChars,
339
+ stepsThreshold: config.offloadSteps,
340
+ project: config.project,
341
+ maxItems: config.maxItems,
342
+ },
343
+ new RecentStore(join(cacheDir, 'recent.json')),
344
+ );
345
+ ctx.logger.info(`[lrc] 记忆沉淀已就绪(${result.mode}):${result.baseUrl}`);
346
+ // 快照与写入解耦:上次进程没来得及沉淀时,从 LRC 索引重建
347
+ void bootstrapSnapshot();
348
+ })();
349
+
350
+ /**
351
+ * 启动时从 LRC 索引重建快照。
352
+ *
353
+ * 快照原本只在上次沉淀成功时写盘;如果上次进程没来得及沉淀,
354
+ * 重启后读路径就是空的。这里用 `/v1/context/index` 兜底,
355
+ * 让 `[LRC 状态]` 在重启后也能立刻出现。
356
+ */
357
+ async function bootstrapSnapshot(): Promise<void> {
358
+ if (latestSnapshot.length > 0 || client === null) return;
359
+ try {
360
+ const index = await client.index(config.project, 3);
361
+ if (index.items.length === 0) return;
362
+ const picked = index.items.map((item) => {
363
+ const body = item.content_preview.replace(/^\[[^\]]+\]\s*/, '');
364
+ return body.length > 120 ? `${body.slice(0, 120)}…` : body;
365
+ });
366
+ latestSnapshot = `[LRC 状态] ${picked.join(';')}。细节在 LRC,需要时 recall。`;
367
+ saveSnapshot(latestSnapshot);
368
+ ctx.logger.info(`[lrc] 快照已从 LRC 索引重建(${index.items.length} 条)`);
369
+ } catch {
370
+ // 索引不可用不影响对话
371
+ }
372
+ }
373
+
374
+ /**
375
+ * 执行一次沉淀。
376
+ *
377
+ * `flushing` 是必要的:`agent/pre-step` 与 `turn/end` 可能同时触发,
378
+ * 并发跑两次会把同一段文本写两遍。
379
+ */
380
+ async function flush(session: unknown, reason: string): Promise<void> {
381
+ if (offloader === null || flushing) return;
382
+ if (!offloader.hasPending()) return;
383
+ flushing = true;
384
+ try {
385
+ const sessionId = (session as { id?: string })?.id ?? 'default';
386
+ const lines = linesFromSession(session);
387
+ if (lines.length === 0) {
388
+ // 只警告一次:否则每一步都会刷同一条日志
389
+ if (!warnedEmpty) {
390
+ warnedEmpty = true;
391
+ ctx.logger.warn('[lrc] 会话日志里没有可沉淀的文本行,本次跳过');
392
+ }
393
+ // **长任务中途兜底**:工具密集型轮次里 assistant 只有 reasoning + tool-call,
394
+ // 正文要等到最后一条回答才出现。如果这里直接 return,整轮中途快照永远是空的,
395
+ // `[LRC 状态]` 也就一直不出现。所以从本轮累计推理里抽结论句先沉淀一批。
396
+ const conclusions = conclusionsFromReasoning(turnReasoning.join('\n'));
397
+ if (conclusions.length === 0 && turnReasoning.length > 0 && !warnedNoConclusion) {
398
+ warnedNoConclusion = true;
399
+ ctx.logger.info(
400
+ `[lrc] 推理兜底:本轮推理 ${turnReasoningChars} 字符,未抽到结论句(词表未命中)`,
401
+ );
402
+ }
403
+ if (conclusions.length > 0) {
404
+ try {
405
+ const result = await offloader.offloadItems(itemsFromTag(conclusions.join('\n')));
406
+ if (result.distilled > 0) {
407
+ // ★2026-11-07:只有实质变化才更新快照,避免宿主每轮 append 新 runtime-context
408
+ if (result.snapshot.length > 0 && snapshotChanged(latestSnapshot, result.snapshot)) {
409
+ latestSnapshot = result.snapshot;
410
+ saveSnapshot(result.snapshot);
411
+ }
412
+ ctx.logger.info(
413
+ `[lrc] 推理兜底沉淀 ${result.distilled} 条(写入 ${result.written},合并 ${result.merged}` +
414
+ `${result.deduped > 0 ? `;本地去重 ${result.deduped}` : ''},触发:${reason})`,
415
+ );
416
+ }
417
+ } catch (error) {
418
+ ctx.logger.warn(
419
+ `[lrc] 推理兜底沉淀失败(不影响对话):${
420
+ error instanceof Error ? error.message : String(error)
421
+ }`,
422
+ );
423
+ }
424
+ // 已消费:清空避免 turn/end 再写一遍
425
+ turnReasoning = [];
426
+ turnReasoningChars = 0;
427
+ }
428
+ // **必须重置计数器**:否则 pendingSteps 会一直累积,
429
+ // 每一步都判定"应当沉淀"并白扫一遍全量日志(实测 60 次重复尝试)。
430
+ offloader.resetCounters();
431
+ return;
432
+ }
433
+ const result = await offloader.offload(sessionId, lines);
434
+ if (result !== null) {
435
+ // 快照回填:下一轮上下文会带上这一行。
436
+ // ★2026-11-07:只有实质变化才更新,避免宿主每轮 append 新 runtime-context。
437
+ if (result.snapshot.length > 0 && snapshotChanged(latestSnapshot, result.snapshot)) {
438
+ latestSnapshot = result.snapshot;
439
+ saveSnapshot(result.snapshot);
440
+ }
441
+ // LRC 的 `written` 是"成功处理条数",`merged` 是"其中走合并的条数",
442
+ // 两者是包含关系,不是互斥的"新增 / 合并"。
443
+ ctx.logger.info(
444
+ `[lrc] 已沉淀 ${result.distilled} 条(写入 ${result.written},其中合并 ${result.merged}` +
445
+ `${result.deduped > 0 ? `;本地去重 ${result.deduped}` : ''},触发:${reason})`,
446
+ );
447
+ }
448
+ } catch (error) {
449
+ ctx.logger.warn(
450
+ `[lrc] 沉淀失败(不影响对话):${error instanceof Error ? error.message : String(error)}`,
451
+ );
452
+ } finally {
453
+ flushing = false;
454
+ }
455
+ }
456
+
457
+ /** 重置当前轮状态(turn/start 与 turn/end 都调用,防止漏事件导致串轮)。 */
458
+ function resetTurnState(): void {
459
+ turnHadText = false;
460
+ turnTagSeen = false;
461
+ turnReasoning = [];
462
+ turnReasoningChars = 0;
463
+ }
464
+
465
+ /** 单轮累计推理的字符上限:超过就不再收,避免长任务把内存撑爆。 */
466
+ const REASONING_CAP = 20000;
467
+
468
+ /**
469
+ * 一轮结束时的收尾:按轮记账 → 主路径沉淀 → 推理兜底。
470
+ *
471
+ * 为什么需要推理兜底:工具密集型轮次里 assistant 只有 reasoning + tool-call,
472
+ * 正文占比 0%,`<lrc_memory>` 标签根本没地方出现。这类轮次只能从推理里
473
+ * 抽带结论标记的句子,否则整轮内容一条都搬不走。
474
+ */
475
+ async function finishTurn(session: unknown): Promise<void> {
476
+ // 1. 按轮记账:只有"本轮有正文"才计入命中率分母
477
+ if (turnHadText) {
478
+ if (turnTagSeen) turnHits += 1;
479
+ else turnMisses += 1;
480
+ const seen = turnHits + turnMisses;
481
+ if (seen % 20 === 0) {
482
+ ctx.logger.info(
483
+ `[lrc] <lrc_memory> 命中率 ${turnHits}/${seen} 轮(另有 ${turnNoText} 轮无正文)`,
484
+ );
485
+ }
486
+ } else {
487
+ turnNoText += 1;
488
+ }
489
+
490
+ const hadText = turnHadText;
491
+ const reasoning = turnReasoning.join('\n');
492
+ resetTurnState();
493
+
494
+ // 2. 主路径:正文行(长任务的最终报告只在这里出现一次)
495
+ await flush(session, 'turn/end');
496
+
497
+ // 3. 兜底:本轮没有正文时,从推理里抽结论句
498
+ if (hadText || offloader === null || reasoning.length === 0) return;
499
+ const conclusions = conclusionsFromReasoning(reasoning);
500
+ if (conclusions.length === 0) return;
501
+ try {
502
+ const result = await offloader.offloadItems(itemsFromTag(conclusions.join('\n')));
503
+ if (result.distilled > 0) {
504
+ // ★2026-11-07:只有实质变化才更新快照,避免宿主每轮 append 新 runtime-context
505
+ if (result.snapshot.length > 0 && snapshotChanged(latestSnapshot, result.snapshot)) {
506
+ latestSnapshot = result.snapshot;
507
+ saveSnapshot(result.snapshot);
508
+ }
509
+ ctx.logger.info(
510
+ `[lrc] 推理兜底沉淀 ${result.distilled} 条(写入 ${result.written},合并 ${result.merged}` +
511
+ `${result.deduped > 0 ? `;本地去重 ${result.deduped}` : ''})`,
512
+ );
513
+ }
514
+ } catch (error) {
515
+ ctx.logger.warn(
516
+ `[lrc] 推理兜底沉淀失败(不影响对话):${
517
+ error instanceof Error ? error.message : String(error)
518
+ }`,
519
+ );
520
+ }
521
+ }
522
+
523
+ // 订阅会话事件:累计新增文本 + turn 结束时兜底沉淀
524
+ ctx.on('session/event', (session: unknown, event: unknown) => {
525
+ const type = (event as { type?: string })?.type;
526
+
527
+ if (type === 'turn/start') {
528
+ resetTurnState();
529
+ return;
530
+ }
531
+
532
+ // turn 结束:长任务的最终报告只在这里出现一次,必须兜底收走
533
+ if (type === 'turn/end') {
534
+ void finishTurn(session);
535
+ return;
536
+ }
537
+
538
+ if (offloader === null) return;
539
+ // 只关心助手消息;其余事件(用户消息、工具调用等)直接跳过,
540
+ // 否则会在第一条非 assistant 事件上误报「取不到文本」。
541
+ if (type !== 'assistant/message') return;
542
+
543
+ const content = messageContent(event);
544
+ // **口径必须与 linesFromSession 一致**:只取正文块。
545
+ const text = textBlocksOnly(content);
546
+ const reasoning = reasoningBlocksOnly(content);
547
+
548
+ if (text.length > 0) {
549
+ turnHadText = true;
550
+ if (extractMemoryTag(text) !== null) turnTagSeen = true;
551
+ offloader.observe(text);
552
+ } else if (!warnedShape && hasMalformedTextBlock(content)) {
553
+ // 只有「声明了 text/reasoning 块但 text 不是字符串」才是结构异常。
554
+ // 纯 tool-call 消息本来就没有这两类块,不该报警(实测 39 条误报)。
555
+ warnedShape = true;
556
+ const data = (event as { data?: unknown })?.data;
557
+ const keys =
558
+ data !== null && typeof data === 'object' ? Object.keys(data as object).join(',') : typeof data;
559
+ ctx.logger.warn(`[lrc] assistant/message 文本块结构异常(data 字段:${keys})`);
560
+ }
561
+
562
+ // 累计推理:工具密集型轮次没有正文,轮末从这里抽结论句兜底
563
+ if (reasoning.length > 0 && turnReasoningChars < REASONING_CAP) {
564
+ turnReasoning.push(reasoning);
565
+ turnReasoningChars += reasoning.length;
566
+ }
567
+ });
568
+
569
+ // 每步开始前:能力探测(一次)→ 控制器有序执行 → 沉淀检查
570
+ ctx.on('agent/pre-step', async (payload: unknown, next: () => Promise<unknown>) => {
571
+ const agent = (payload as { agent?: unknown })?.agent;
572
+ lastAgent = agent;
573
+
574
+ // 能力矩阵:每个会话只探测一次,结果打一张表。
575
+ // 探测失败时 caps 为 null,动作按「未知即可用」处理,不让探测本身停摆。
576
+ if (!probed) {
577
+ probed = true;
578
+ try {
579
+ governor.capabilities = probeCapabilities(ctx, agent);
580
+ for (const line of formatCapabilities(governor.capabilities)) ctx.logger.info(line);
581
+ } catch (error) {
582
+ ctx.logger.warn(
583
+ `[lrc][cap] 能力探测异常:${error instanceof Error ? error.message : String(error)}`,
584
+ );
585
+ }
586
+ }
587
+ const caps = governor.capabilities;
588
+
589
+ // 统一预算:按模型窗口比例推导阈值,只解析一次
590
+ if (budget === null) {
591
+ try {
592
+ budget = await resolveBudget({
593
+ ctx,
594
+ agent,
595
+ config: {
596
+ contextWindowTokens: config.contextWindowTokens,
597
+ trimRatio: config.trimRatio,
598
+ pruneRatio: config.pruneRatio,
599
+ trimMinTokens: config.trimMinTokens,
600
+ pruneResultThreshold: config.pruneResultThreshold,
601
+ },
602
+ });
603
+ ctx.logger.info(formatBudget(budget));
604
+ } catch (error) {
605
+ ctx.logger.warn(
606
+ `[lrc][budget] 预算解析失败,使用配置绝对值:${
607
+ error instanceof Error ? error.message : String(error)
608
+ }`,
609
+ );
610
+ }
611
+ }
612
+ const trimMinTokens = budget?.trimMinTokens ?? config.trimMinTokens;
613
+ const pruneResultThreshold = budget?.pruneResultThreshold ?? config.pruneResultThreshold;
614
+
615
+ const actions: GovernorAction[] = [
616
+ {
617
+ name: 'prune-results',
618
+ enabled: config.pruneEnabled && (caps === null || caps.toolResultPruner.available),
619
+ run: () => {
620
+ const outcome = pruneToolResults({
621
+ ctx,
622
+ agent,
623
+ log: (message) => ctx.logger.info(message),
624
+ thresholdChars: pruneResultThreshold,
625
+ });
626
+ return { ok: outcome.ok, reason: outcome.reason };
627
+ },
628
+ },
629
+ {
630
+ name: 'prune-args',
631
+ enabled: config.pruneArgsEnabled,
632
+ run: () => {
633
+ const outcome = pruneToolCallArguments({
634
+ ctx,
635
+ agent,
636
+ log: (message) => ctx.logger.info(message),
637
+ options: {
638
+ thresholdChars: config.pruneArgsThreshold,
639
+ headChars: config.pruneArgsHead,
640
+ keepRecentNodes: config.keepRecentNodes,
641
+ },
642
+ });
643
+ return { ok: outcome.ok, reason: outcome.reason };
644
+ },
645
+ },
646
+ {
647
+ name: 'prune-reasoning',
648
+ enabled: config.pruneReasoningEnabled,
649
+ run: () => {
650
+ const outcome = pruneReasoning({
651
+ ctx,
652
+ agent,
653
+ log: (message) => ctx.logger.info(message),
654
+ options: { keepRecentNodes: config.keepRecentNodes },
655
+ });
656
+ return { ok: outcome.ok, reason: outcome.reason };
657
+ },
658
+ },
659
+ {
660
+ name: 'trim',
661
+ enabled:
662
+ config.trimEnabled &&
663
+ (caps === null || (caps.compaction.available && caps.tokenMeter.available)),
664
+ throttleSteps: config.trimMinSteps,
665
+ run: async () => {
666
+ // 节点数硬上限:不依赖 token 计量,作为「阈值失真」时的兜底触发
667
+ const surfaceNodes = (agent as { session?: { surface?: { nodes?: unknown } } })?.session
668
+ ?.surface?.nodes;
669
+ const nodeCount = Array.isArray(surfaceNodes) ? surfaceNodes.length : 0;
670
+ const force = nodeCount > config.trimMaxNodes;
671
+ if (force) {
672
+ ctx.logger.info(
673
+ `[lrc][trim] 节点数 ${nodeCount} 超过上限 ${config.trimMaxNodes},跳过 token 阈值强制裁剪`,
674
+ );
675
+ }
676
+ const outcome = await trimContext({
677
+ ctx,
678
+ agent,
679
+ signal: (payload as { signal?: AbortSignal })?.signal,
680
+ options: {
681
+ minTokens: trimMinTokens,
682
+ keepRecentNodes: config.keepRecentNodes,
683
+ maxSummaryChars: config.trimMaxSummaryChars,
684
+ force,
685
+ },
686
+ log: (message) => ctx.logger.info(message),
687
+ });
688
+ // 非异常原因(below-threshold / no-range)每种只记一次
689
+ if (!outcome.ok && !outcome.reason.startsWith('error')) {
690
+ if (!trimReasonsLogged.has(outcome.reason)) {
691
+ trimReasonsLogged.add(outcome.reason);
692
+ ctx.logger.info(
693
+ `[lrc][trim] 未裁剪:${outcome.reason}` +
694
+ `${outcome.detail === undefined ? '' : `(${outcome.detail})`}`,
695
+ );
696
+ }
697
+ }
698
+ return {
699
+ ok: outcome.ok,
700
+ reason: outcome.reason,
701
+ detail: outcome.summaryStats ?? outcome.detail,
702
+ };
703
+ },
704
+ },
705
+ ];
706
+
707
+ await runGovernor({
708
+ state: governor,
709
+ actions,
710
+ log: (message) => ctx.logger.info(message),
711
+ warn: (message) => ctx.logger.warn(message),
712
+ });
713
+
714
+ // 上下文画像:每 10 步打一行,哪一类在膨胀能立刻看出来
715
+ if (governor.steps % 10 === 0) {
716
+ try {
717
+ ctx.logger.info(formatProfile(profileSurface((agent as { session?: unknown })?.session)));
718
+ } catch {
719
+ // 画像失败不影响对话
720
+ }
721
+ }
722
+
723
+ if (offloader !== null) {
724
+ offloader.observeStep();
725
+ if (offloader.shouldOffload()) {
726
+ const session = (payload as { agent?: { session?: unknown } })?.agent?.session;
727
+ // **不 await**:沉淀要走 distill + migrate 两次 HTTP,
728
+ // 阻塞在这里会让模型调用前多等几百毫秒。`flushing` 已保证不并发。
729
+ void flush(session, 'threshold');
730
+ }
731
+ }
732
+ const decision = await next();
733
+ // ★2026-11-07(缓存命中率):把 runtime-context 快照移到当前用户消息**之前**。
734
+ //
735
+ // 宿主默认把 runtime-context 作为一条 user 消息 append 在 claimed 消息之后
736
+ // (见 dsh-agent-loop 的 step():先 append 用户消息,再 append context)。
737
+ // 这样快照落在用户提问之后,且每轮都会在历史中间留下一条。
738
+ //
739
+ // 这里只调整 `decision.messages` 里 runtime-context 标记的位置:
740
+ // 宿主遇到该标记时会把**整块** runtime-context 插到标记处,因此把它移到
741
+ // 队首即可让快照紧挨在用户提问之前,历史前缀(system + 旧对话)完全不变。
742
+ if (decision !== null && typeof decision === 'object') {
743
+ const d = decision as { kind?: string; messages?: unknown };
744
+ if (d.kind === 'enter' && Array.isArray(d.messages)) {
745
+ const messages = d.messages as Array<{ source?: { kind?: string } }>;
746
+ const ctxIdx = messages.findIndex((m) => m?.source?.kind === 'runtime-context');
747
+ if (ctxIdx > 0) {
748
+ const [ctx] = messages.splice(ctxIdx, 1);
749
+ messages.unshift(ctx);
750
+ }
751
+ }
752
+ }
753
+ return decision;
754
+ });
755
+
756
+ /**
757
+ * 注册工具,失败只告警不抛错。
758
+ *
759
+ * `defineTool()` 在**参数求值阶段**就会校验 schema(例如参数上写
760
+ * `required: false` 会抛 JsonSchemaError)。如果直接写成
761
+ * `ctx.tools.register(defineTool(...))`,异常会掀翻整个 apply(),
762
+ * 连沉淀、剪枝、事件监听一起停摆(实测回归)。
763
+ * 所以把 `defineTool` 的调用也包进 try。
764
+ */
765
+ function registerTool(name: string, factory: () => unknown): void {
766
+ try {
767
+ ctx.tools.register(factory() as never);
768
+ } catch (error) {
769
+ ctx.logger.warn(
770
+ `[lrc] 工具 ${name} 注册失败(不影响沉淀与剪枝):${
771
+ error instanceof Error ? error.message : String(error)
772
+ }`,
773
+ );
774
+ }
775
+ }
776
+
777
+ // 手动触发工具
778
+ registerTool('lrc_remember_now', () =>
779
+ defineTool({
780
+ name: 'lrc_remember_now',
781
+ description:
782
+ '立即把当前对话的结论沉淀到 LRC 长期记忆库。' +
783
+ '用于用户要求"把这段记下来"或上下文即将变长时。返回一行状态快照。',
784
+ parameters: {
785
+ text: {
786
+ type: 'string',
787
+ required: true,
788
+ description: '要沉淀的对话原文(可含多轮,按换行分隔)',
789
+ },
790
+ },
791
+ output: {
792
+ schema: {
793
+ type: 'object',
794
+ additionalProperties: false,
795
+ properties: {
796
+ snapshot: { type: 'string', required: true },
797
+ distilled: { type: 'number', required: true },
798
+ written: { type: 'number', required: true },
799
+ merged: { type: 'number', required: true },
800
+ },
801
+ },
802
+ render: (_args: unknown, value: { snapshot: string }) => [
803
+ { type: 'text', text: value.snapshot },
804
+ ],
805
+ },
806
+ async execute(args: { text: string }) {
807
+ if (client === null) {
808
+ return { snapshot: 'LRC 未就绪,本次未沉淀', distilled: 0, written: 0, merged: 0 };
809
+ }
810
+ try {
811
+ // 与自动沉淀路径保持一致:标签内容直接成条目,**绕过 distill**。
812
+ // 否则 `- 决策:…` 会被 LRC 的规则式打分丢掉(DECISION_WORDS 无「决策」)。
813
+ const tag = extractMemoryTag(args.text);
814
+ if (tag !== null) {
815
+ if (isNoUpdate(tag)) {
816
+ return { snapshot: '本轮无新增,未沉淀', distilled: 0, written: 0, merged: 0 };
817
+ }
818
+ const items = itemsFromTag(tag);
819
+ const migrated = await client.migrate(items, config.project);
820
+ ctx.logger.info(
821
+ `[lrc][tool] lrc_remember_now 沉淀 ${items.length} 条(写入 ${migrated.written},合并 ${migrated.merged})`,
822
+ );
823
+ return {
824
+ snapshot: `已沉淀 ${items.length} 条(写入 ${migrated.written},其中合并 ${migrated.merged})`,
825
+ distilled: items.length,
826
+ written: migrated.written,
827
+ merged: migrated.merged,
828
+ };
829
+ }
830
+ const result = await client.migrateText(args.text, config.project, config.maxItems);
831
+ ctx.logger.info(
832
+ `[lrc][tool] lrc_remember_now 沉淀 ${result.distilled} 条(写入 ${result.written},合并 ${result.merged})`,
833
+ );
834
+ return {
835
+ snapshot: result.indexLine,
836
+ distilled: result.distilled,
837
+ written: result.written,
838
+ merged: result.merged,
839
+ };
840
+ } catch (error) {
841
+ return {
842
+ snapshot: `LRC 未连接,本次未沉淀(${String(error).slice(0, 120)})`,
843
+ distilled: 0,
844
+ written: 0,
845
+ merged: 0,
846
+ };
847
+ }
848
+ },
849
+ }),
850
+ );
851
+
852
+ // 读路径:DSH 没有 MCP 客户端,AI 看不到 LRC 的 `recall` 工具,
853
+ // 只能通过这个原生工具把记忆读回来。没有它,插件就是纯单向写入器。
854
+ //
855
+ // 召回结果的统一措辞:日志与返回文本**共用同一函数**,避免两处漂移。
856
+ // `total` 是三路(fast/deep/semantic)候选并集、**融合前**,随库状态变化,
857
+ // 既不是「匹配数」也不是「删除量」——必须显式标注「融合前」,否则会被误读。
858
+ const describeRecall = (returned: number, total: number): string =>
859
+ `召回 ${returned} 条(三路候选 ${total} 条,融合前;已折叠近重复)`;
860
+ registerTool('lrc_recall', () =>
861
+ defineTool({
862
+ name: 'lrc_recall',
863
+ description:
864
+ '从 LRC 长期记忆库检索相关记忆。涉及项目历史、用户偏好、之前做过的决定、' +
865
+ '上次怎么改的,先调这个工具,不要直接 Grep 或翻文件。' +
866
+ '返回按相关度排序的记忆条目(已做近重复折叠)。',
867
+ parameters: {
868
+ query: {
869
+ type: 'string',
870
+ required: true,
871
+ description: '自然语言查询,如「上次那个检索接口怎么改的」',
872
+ },
873
+ top_k: {
874
+ type: 'number',
875
+ description: '返回条数,默认 5,最大 20',
876
+ },
877
+ project: {
878
+ type: 'string',
879
+ description: '按项目过滤(可选,缺省搜全部项目)',
880
+ },
881
+ },
882
+ output: {
883
+ schema: {
884
+ type: 'object',
885
+ additionalProperties: false,
886
+ properties: {
887
+ text: { type: 'string', required: true },
888
+ count: { type: 'number', required: true },
889
+ },
890
+ },
891
+ render: (_args: unknown, value: { text: string }) => [{ type: 'text', text: value.text }],
892
+ },
893
+ async execute(args: { query: string; top_k?: number; project?: string }) {
894
+ if (client === null) {
895
+ return { text: 'LRC 未就绪,无法召回。', count: 0 };
896
+ }
897
+ const query = typeof args.query === 'string' ? args.query.trim() : '';
898
+ if (query.length === 0) {
899
+ return { text: 'lrc_recall 需要非空的 query。', count: 0 };
900
+ }
901
+ try {
902
+ const topK = Math.min(Math.max(args.top_k ?? 5, 1), 20);
903
+ const result = await client.recall(query, {
904
+ topK,
905
+ // 缺省不按项目过滤:插件沉淀写 config.project,手工 remember 可能写别的项目,
906
+ // 过滤会漏掉一半。需要收窄时由调用方显式传 project。
907
+ project: args.project,
908
+ });
909
+ // 工具调用必须可观测:此前 recall 无任何日志,排查时看不到它是否被调用。
910
+ ctx.logger.info(
911
+ `[lrc][tool] lrc_recall "${query}" → ${describeRecall(result.memories.length, result.total)}`,
912
+ );
913
+ if (result.memories.length === 0) {
914
+ return { text: 'LRC 未命中,可回退内置搜索。', count: 0 };
915
+ }
916
+ const lines = result.memories.map((m, i) => {
917
+ const rel = Math.round((m.score_norm ?? 0) * 100);
918
+ const kind = m.memory_type ?? 'fact';
919
+ // 带上 id:AI 发现某条是错的时,用 lrc_forget 精确撤回
920
+ return `(记忆 #${i + 1} · 相关度 ${rel}% · ${kind} · id=${m.id})\n${m.content}`;
921
+ });
922
+ return {
923
+ text:
924
+ `LRC ${describeRecall(result.memories.length, result.total)}:\n\n` +
925
+ lines.join('\n\n'),
926
+ count: result.memories.length,
927
+ };
928
+ } catch (error) {
929
+ return { text: `LRC 召回失败:${String(error).slice(0, 200)}`, count: 0 };
930
+ }
931
+ },
932
+ }),
933
+ );
934
+
935
+ // 纠错路径:AI 发现某条记忆是错的(例如把「没找到」误写成「不存在」),
936
+ // 必须能撤回,否则错误结论会被持续召回、污染后续所有对话。
937
+ registerTool('lrc_forget', () =>
938
+ defineTool({
939
+ name: 'lrc_forget',
940
+ description:
941
+ '删除一条错误的 LRC 记忆。当 lrc_recall 返回的某条记忆与事实不符' +
942
+ '(例如把「没找到」误写成「不存在」、把旧结论当成现状),用这个工具撤回。' +
943
+ 'memory_id 从 lrc_recall 的输出里取。',
944
+ parameters: {
945
+ memory_id: {
946
+ type: 'string',
947
+ required: true,
948
+ description: '要删除的记忆 ID(lrc_recall 输出里的 id=...)',
949
+ },
950
+ },
951
+ output: {
952
+ schema: {
953
+ type: 'object',
954
+ additionalProperties: false,
955
+ properties: {
956
+ text: { type: 'string', required: true },
957
+ deleted: { type: 'boolean', required: true },
958
+ },
959
+ },
960
+ render: (_args: unknown, value: { text: string }) => [{ type: 'text', text: value.text }],
961
+ },
962
+ async execute(args: { memory_id: string }) {
963
+ if (client === null) {
964
+ return { text: 'LRC 未就绪,无法删除。', deleted: false };
965
+ }
966
+ const memoryId = typeof args.memory_id === 'string' ? args.memory_id.trim() : '';
967
+ if (memoryId.length === 0) {
968
+ return { text: 'lrc_forget 需要非空的 memory_id。', deleted: false };
969
+ }
970
+ try {
971
+ const result = await client.forget(memoryId);
972
+ // 工具调用必须可观测:删除是纠错闭环的关键一步,必须留痕。
973
+ ctx.logger.info(
974
+ `[lrc][tool] lrc_forget ${memoryId} → ${
975
+ result.deleted ? '已删除' : result.alreadyDeleted ? '幂等不存在' : '未删除'
976
+ }`,
977
+ );
978
+ if (result.deleted) {
979
+ return { text: `已删除记忆 ${memoryId}。`, deleted: true };
980
+ }
981
+ if (result.alreadyDeleted) {
982
+ return { text: `记忆 ${memoryId} 已不存在(幂等)。`, deleted: true };
983
+ }
984
+ return { text: `记忆 ${memoryId} 未删除(服务端返回 deleted=false)。`, deleted: false };
985
+ } catch (error) {
986
+ return { text: `LRC 删除失败:${String(error).slice(0, 200)}`, deleted: false };
987
+ }
988
+ },
989
+ }),
990
+ );
991
+
992
+ // 路径 6:会话交接(新建会话 + 摘要注入)
993
+ //
994
+ // 显式工具而非自动触发:新建会话会改变用户的工作现场,
995
+ // 自动判断失误会让用户突然发现自己在另一个会话里。
996
+ registerTool('lrc_handoff', () =>
997
+ defineTool({
998
+ name: 'lrc_handoff',
999
+ description:
1000
+ '把当前会话整理成交接摘要,并新建一个会话注入该摘要(原会话保留)。' +
1001
+ '用于上下文接近上限、需要另起炉灶继续同一任务时。',
1002
+ parameters: {
1003
+ // **不要写 `required: false`**:DSH 的 defineTool 规定参数上的
1004
+ // `required` 只要出现就必须是 `true`,省略该键才表示可选。
1005
+ // 写成 false 会抛 JsonSchemaError,把整个 apply() 掀翻(实测回归)。
1006
+ note: {
1007
+ type: 'string',
1008
+ description: '给接手方的下一步说明(可选)',
1009
+ },
1010
+ },
1011
+ output: {
1012
+ schema: {
1013
+ type: 'object',
1014
+ additionalProperties: false,
1015
+ properties: {
1016
+ ok: { type: 'boolean', required: true },
1017
+ sessionId: { type: 'string', required: true },
1018
+ mode: { type: 'string', required: true },
1019
+ text: { type: 'string', required: true },
1020
+ },
1021
+ },
1022
+ render: (_args: unknown, value: { ok: boolean; sessionId: string; mode: string; text: string }) => [
1023
+ {
1024
+ type: 'text',
1025
+ text:
1026
+ (value.ok
1027
+ ? `已创建交接会话(${value.mode}):${value.sessionId}`
1028
+ : '未能创建新会话,仅返回交接摘要') + `\n\n${value.text}`,
1029
+ },
1030
+ ],
1031
+ },
1032
+ async execute(args: { note?: string }) {
1033
+ const session = (lastAgent as { session?: unknown } | undefined)?.session;
1034
+ const source = extractHandoffSource(session);
1035
+
1036
+ // LRC 归档索引:拿不到就跳过,不影响交接
1037
+ let indexLines: string[] = [];
1038
+ if (client !== null) {
1039
+ try {
1040
+ const index = await client.index(config.project, 10);
1041
+ indexLines = index.items.map((item) => item.content_preview);
1042
+ } catch {
1043
+ // 忽略:索引只是锦上添花
1044
+ }
1045
+ }
1046
+
1047
+ const text = buildHandoffText({
1048
+ lastUserRequest: source.lastUserRequest,
1049
+ recentAssistant: source.recentAssistant,
1050
+ snapshot: latestSnapshot,
1051
+ indexLines,
1052
+ note: args.note ?? '',
1053
+ });
1054
+
1055
+ const result = await createHandoffSession({
1056
+ ctx,
1057
+ agent: lastAgent,
1058
+ text,
1059
+ log: (message) => ctx.logger.info(message),
1060
+ });
1061
+
1062
+ ctx.logger.info(
1063
+ `[lrc][tool] lrc_handoff → ${result.ok ? '已创建交接会话' : '仅返回摘要'}(${result.mode})`,
1064
+ );
1065
+ return {
1066
+ ok: result.ok,
1067
+ sessionId: result.sessionId ?? '',
1068
+ mode: result.mode,
1069
+ text: result.text,
1070
+ };
1071
+ },
1072
+ }),
1073
+ );
1074
+
1075
+ // 状态自检:把能力矩阵、熔断状态、计数器直接暴露出来,
1076
+ // 不用再靠翻日志和手工跑探针。
1077
+ registerTool('lrc_status', () =>
1078
+ defineTool({
1079
+ name: 'lrc_status',
1080
+ description:
1081
+ '查看 LRC 插件的运行状态:能力矩阵(哪些路径可用)、各动作执行次数与熔断状态、' +
1082
+ '当前配置阈值、快照是否存在。排查"为什么没生效"时先调这个。',
1083
+ parameters: {},
1084
+ output: {
1085
+ schema: {
1086
+ type: 'object',
1087
+ additionalProperties: false,
1088
+ properties: {
1089
+ text: { type: 'string', required: true },
1090
+ },
1091
+ },
1092
+ render: (_args: unknown, value: { text: string }) => [{ type: 'text', text: value.text }],
1093
+ },
1094
+ async execute() {
1095
+ ctx.logger.info('[lrc][tool] lrc_status 调用');
1096
+ const lines: string[] = ['# LRC 状态', ''];
1097
+
1098
+ lines.push('## 能力矩阵');
1099
+ if (governor.capabilities === null) {
1100
+ lines.push('尚未探测(还没有走过一步)');
1101
+ } else {
1102
+ lines.push(...formatCapabilities(governor.capabilities));
1103
+ }
1104
+ lines.push('');
1105
+
1106
+ lines.push('## 动作状态');
1107
+ lines.push(formatGovernorState(governor));
1108
+ lines.push('');
1109
+
1110
+ lines.push('## 上下文画像');
1111
+ try {
1112
+ lines.push(formatProfile(profileSurface((lastAgent as { session?: unknown } | undefined)?.session)));
1113
+ } catch {
1114
+ lines.push('不可用');
1115
+ }
1116
+ lines.push('');
1117
+
1118
+ lines.push('## 沉淀');
1119
+ if (offloader === null) {
1120
+ lines.push('未启用');
1121
+ } else {
1122
+ const stats = offloader.stats();
1123
+ lines.push(
1124
+ `累计 ${stats.flushCount} 次:新写入 ${stats.totalWritten} 条 / 合并 ${stats.totalMerged} 条` +
1125
+ ` / 本地查重拦下 ${stats.totalDeduped} 块`,
1126
+ );
1127
+ lines.push(
1128
+ stats.lastFlushAt === 0
1129
+ ? '最近一次:尚未沉淀'
1130
+ : `最近一次:${new Date(stats.lastFlushAt).toISOString()}` +
1131
+ `(写入 ${stats.lastFlushWritten} / 合并 ${stats.lastFlushMerged})`,
1132
+ );
1133
+ lines.push(`查重窗口:${stats.recentWindow} 块`);
1134
+ }
1135
+ lines.push('');
1136
+
1137
+ lines.push('## 预算');
1138
+ lines.push(budget === null ? '尚未解析' : formatBudget(budget));
1139
+ lines.push('');
1140
+
1141
+ lines.push('## 配置');
1142
+ lines.push(
1143
+ [
1144
+ `prune=${config.pruneEnabled}(阈值 ${budget?.pruneResultThreshold ?? config.pruneResultThreshold} 字符)`,
1145
+ `pruneArgs=${config.pruneArgsEnabled}(阈值 ${config.pruneArgsThreshold} 字符)`,
1146
+ `pruneReasoning=${config.pruneReasoningEnabled}(保留最近 ${config.keepRecentNodes} 节点)`,
1147
+ `trim=${config.trimEnabled}(阈值 ${budget?.trimMinTokens ?? config.trimMinTokens} tokens,每 ${config.trimMinSteps} 步,节点上限 ${config.trimMaxNodes})`,
1148
+ `offload=${config.offloadTurns} 轮 / ${config.offloadChars} 字符 / ${config.offloadSteps} 步`,
1149
+ ].join('\n'),
1150
+ );
1151
+ lines.push('');
1152
+
1153
+ lines.push('## 快照');
1154
+ lines.push(
1155
+ latestSnapshot.length > 0
1156
+ ? `已存在(${latestSnapshot.length} 字符)${snapshotServed ? ',已被渲染层取走' : ',尚未被取走'}`
1157
+ : '为空(还没有成功沉淀过)',
1158
+ );
1159
+
1160
+ return { text: lines.join('\n') };
1161
+ },
1162
+ }),
1163
+ );
1164
+ }