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/dist/index.js ADDED
@@ -0,0 +1,2985 @@
1
+ // src/index.ts
2
+ import { defineTool } from "@deepseek-ai/dsh-tools";
3
+ import Schema from "@deepseek-ai/schemastery";
4
+ import { join as join2 } from "node:path";
5
+ import { homedir as homedir2 } from "node:os";
6
+ import { existsSync as existsSync3, mkdirSync as mkdirSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "node:fs";
7
+
8
+ // src/runtime.ts
9
+ import { createHash } from "node:crypto";
10
+ import { spawn } from "node:child_process";
11
+ import { createServer } from "node:net";
12
+ import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
13
+ import { homedir, platform, arch } from "node:os";
14
+ import { join } from "node:path";
15
+ var BINARY_RELEASE = "v0.10.0";
16
+ var BINARY_MANIFEST = {
17
+ "win32-x64": {
18
+ file: "lrc-v0.10.0-windows-x86_64.exe",
19
+ sha256: "005fcad2e4e61e77b8efd15d43d37a7c39d73d4e4eff1fdbf8107b8bd30951d4"
20
+ },
21
+ "darwin-arm64": {
22
+ file: "lrc-v0.10.0-macos-arm64",
23
+ sha256: "d1fcfbb52f02a24d9cd277981b43884fd16e71ed8d5c7f1c8f2b918e4a8bce62"
24
+ },
25
+ "linux-x64": {
26
+ file: "lrc-v0.10.0-linux-x86_64",
27
+ sha256: "2e6fd027c2f0c76a98f4970278e321fa39543860d3e446fc303df5950a3f15e0"
28
+ }
29
+ };
30
+ var DEFAULT_MIRRORS = [
31
+ `https://github.com/zhibaiYingChuan/LRC/releases/download/${BINARY_RELEASE}`,
32
+ `https://ghproxy.net/https://github.com/zhibaiYingChuan/LRC/releases/download/${BINARY_RELEASE}`
33
+ ];
34
+ function defaultCacheDir() {
35
+ const dshHome = process.env.DSH_HOME ?? join(homedir(), ".dsh");
36
+ return join(dshHome, "cache", "lrc-plugin");
37
+ }
38
+ function platformKey() {
39
+ return `${platform()}-${arch()}`;
40
+ }
41
+ function binaryName() {
42
+ return platform() === "win32" ? "lrc-sidecar.exe" : "lrc-sidecar";
43
+ }
44
+ function findFreePort() {
45
+ return new Promise((resolve, reject) => {
46
+ const server = createServer();
47
+ server.once("error", reject);
48
+ server.listen(0, "127.0.0.1", () => {
49
+ const address = server.address();
50
+ if (address === null || typeof address === "string") {
51
+ server.close();
52
+ reject(new Error("无法获取空闲端口"));
53
+ return;
54
+ }
55
+ const { port } = address;
56
+ server.close(() => resolve(port));
57
+ });
58
+ });
59
+ }
60
+ async function isHealthy(baseUrl, timeoutMs = 3e3) {
61
+ try {
62
+ const response = await fetch(`${baseUrl}/health`, {
63
+ signal: AbortSignal.timeout(timeoutMs)
64
+ });
65
+ return response.ok;
66
+ } catch {
67
+ return false;
68
+ }
69
+ }
70
+ var LrcRuntime = class {
71
+ cacheDir;
72
+ mirror;
73
+ log;
74
+ healthTimeoutMs;
75
+ manifest;
76
+ mirrors;
77
+ probeLocal;
78
+ /** 显式实例地址:配置 `baseUrl` 优先,其次 `LRC_BASE` 环境变量。 */
79
+ explicitBase;
80
+ child = null;
81
+ result = null;
82
+ constructor(options = {}) {
83
+ this.explicitBase = options.baseUrl?.trim() || process.env.LRC_BASE;
84
+ this.cacheDir = options.cacheDir ?? defaultCacheDir();
85
+ this.mirror = options.mirror ?? process.env.LRC_MIRROR;
86
+ this.log = options.log ?? (() => {
87
+ });
88
+ this.healthTimeoutMs = options.healthTimeoutMs ?? 15e3;
89
+ this.manifest = options.manifest ?? BINARY_MANIFEST;
90
+ this.probeLocal = options.probeLocal ?? true;
91
+ const release = options.release ?? BINARY_RELEASE;
92
+ this.mirrors = [
93
+ `https://github.com/zhibaiYingChuan/LRC/releases/download/${release}`,
94
+ `https://ghproxy.net/https://github.com/zhibaiYingChuan/LRC/releases/download/${release}`
95
+ ];
96
+ }
97
+ /** 幂等:多次调用只启动一次。 */
98
+ async ensure() {
99
+ if (this.result !== null) return this.result;
100
+ const explicit = this.explicitBase;
101
+ if (explicit) {
102
+ if (await isHealthy(explicit)) {
103
+ this.result = { mode: "external", baseUrl: explicit.replace(/\/+$/, "") };
104
+ this.log(`[lrc] 复用已有实例:${this.result.baseUrl}`);
105
+ return this.result;
106
+ }
107
+ this.result = {
108
+ mode: "degraded",
109
+ baseUrl: explicit.replace(/\/+$/, ""),
110
+ reason: `配置的实例不可用:${explicit}`
111
+ };
112
+ this.log(`[lrc] 降级:${this.result.reason}`);
113
+ return this.result;
114
+ }
115
+ const local = "http://127.0.0.1:3099";
116
+ if (this.probeLocal && await isHealthy(local)) {
117
+ this.result = { mode: "external", baseUrl: local };
118
+ this.log(`[lrc] 复用本地实例:${local}`);
119
+ return this.result;
120
+ }
121
+ let binary;
122
+ try {
123
+ binary = await this.ensureBinary();
124
+ } catch (error) {
125
+ this.result = {
126
+ mode: "degraded",
127
+ baseUrl: explicit ?? local,
128
+ reason: `LRC 二进制不可用:${error instanceof Error ? error.message : String(error)}`
129
+ };
130
+ this.log(`[lrc] 降级:${this.result.reason}`);
131
+ return this.result;
132
+ }
133
+ try {
134
+ const port = await findFreePort();
135
+ const dataDir = join(this.cacheDir, "data");
136
+ mkdirSync(dataDir, { recursive: true });
137
+ this.child = spawn(binary, ["--port", String(port), "--data-dir", dataDir], {
138
+ stdio: "ignore",
139
+ detached: false
140
+ });
141
+ this.child.on("error", (error) => this.log(`[lrc] 子进程错误:${error.message}`));
142
+ const baseUrl = `http://127.0.0.1:${port}`;
143
+ const deadline = Date.now() + this.healthTimeoutMs;
144
+ while (Date.now() < deadline) {
145
+ if (await isHealthy(baseUrl, 1e3)) {
146
+ this.result = { mode: "managed", baseUrl };
147
+ this.log(`[lrc] 已启动隔离实例:${baseUrl}(数据目录 ${dataDir})`);
148
+ return this.result;
149
+ }
150
+ await new Promise((resolve) => setTimeout(resolve, 300));
151
+ }
152
+ throw new Error(`健康检查超时(${this.healthTimeoutMs}ms)`);
153
+ } catch (error) {
154
+ this.stop();
155
+ this.result = {
156
+ mode: "degraded",
157
+ baseUrl: explicit ?? local,
158
+ reason: `LRC 启动失败:${error instanceof Error ? error.message : String(error)}`
159
+ };
160
+ this.log(`[lrc] 降级:${this.result.reason}`);
161
+ return this.result;
162
+ }
163
+ }
164
+ /** 确保二进制存在且校验通过。 */
165
+ async ensureBinary() {
166
+ const key = platformKey();
167
+ const entry = this.manifest[key];
168
+ if (entry === void 0) {
169
+ throw new Error(
170
+ `没有 ${key} 的二进制清单。请手动安装 LRC 桌面端,或设置 LRC_BASE 指向已有实例。`
171
+ );
172
+ }
173
+ const binDir = join(this.cacheDir, "bin");
174
+ const target = join(binDir, binaryName());
175
+ mkdirSync(binDir, { recursive: true });
176
+ if (existsSync(target) && this.verify(target, entry.sha256)) {
177
+ return target;
178
+ }
179
+ const mirrors = this.mirror ? [this.mirror, ...this.mirrors] : this.mirrors;
180
+ let lastError = null;
181
+ let hashMismatch = false;
182
+ for (const mirror of mirrors) {
183
+ const url = `${mirror.replace(/\/+$/, "")}/${entry.file}`;
184
+ try {
185
+ this.log(`[lrc] 下载 ${url}`);
186
+ const response = await fetch(url, { signal: AbortSignal.timeout(12e4) });
187
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
188
+ const buffer = Buffer.from(await response.arrayBuffer());
189
+ writeFileSync(target, buffer);
190
+ if (!this.verify(target, entry.sha256)) {
191
+ rmSync(target, { force: true });
192
+ hashMismatch = true;
193
+ throw new Error("SHA256 校验失败");
194
+ }
195
+ if (platform() !== "win32") chmodSync(target, 493);
196
+ return target;
197
+ } catch (error) {
198
+ lastError = error;
199
+ this.log(`[lrc] 镜像失败(${mirror}):${error instanceof Error ? error.message : error}`);
200
+ }
201
+ }
202
+ if (hashMismatch) {
203
+ throw new Error(
204
+ `SHA256 校验失败(期望 ${entry.sha256.slice(0, 12)}…)。文件可能被篡改或清单过期,已删除下载内容。`
205
+ );
206
+ }
207
+ throw new Error(
208
+ `所有镜像均失败:${lastError instanceof Error ? lastError.message : String(lastError)}`
209
+ );
210
+ }
211
+ verify(file, expected) {
212
+ try {
213
+ const actual = createHash("sha256").update(readFileSync(file)).digest("hex");
214
+ return actual === expected.toLowerCase();
215
+ } catch {
216
+ return false;
217
+ }
218
+ }
219
+ /** 停止托管实例(插件卸载时调用)。 */
220
+ stop() {
221
+ if (this.child !== null && !this.child.killed) {
222
+ this.child.kill();
223
+ this.child = null;
224
+ }
225
+ }
226
+ };
227
+
228
+ // src/lrc-client.ts
229
+ var LrcClient = class {
230
+ baseUrl;
231
+ token;
232
+ timeoutMs;
233
+ constructor(options = {}) {
234
+ const raw = options.baseUrl ?? process.env.LRC_BASE ?? "http://127.0.0.1:3099";
235
+ this.baseUrl = raw.replace(/\/+$/, "");
236
+ this.token = options.token ?? process.env.LRC_TOKEN;
237
+ this.timeoutMs = options.timeoutMs ?? 2e4;
238
+ }
239
+ headers() {
240
+ const headers = { "Content-Type": "application/json" };
241
+ if (this.token) headers.Authorization = `Bearer ${this.token}`;
242
+ return headers;
243
+ }
244
+ /**
245
+ * 底层请求封装。
246
+ *
247
+ * 非 2xx 一律抛错并带上状态码与响应体前 300 字符 —— 调用方必须能区分
248
+ * "服务没起"(fetch 抛错)与"服务起了但拒绝"(带状态码的 Error)。
249
+ */
250
+ async request(path, init) {
251
+ const response = await fetch(`${this.baseUrl}${path}`, {
252
+ ...init,
253
+ headers: this.headers(),
254
+ signal: AbortSignal.timeout(this.timeoutMs)
255
+ });
256
+ const text = await response.text();
257
+ if (!response.ok) {
258
+ throw new Error(`LRC ${path} 返回 ${response.status}: ${text.slice(0, 300)}`);
259
+ }
260
+ return JSON.parse(text);
261
+ }
262
+ /** 健康检查:服务是否可用。 */
263
+ async health() {
264
+ try {
265
+ const response = await fetch(`${this.baseUrl}/health`, {
266
+ signal: AbortSignal.timeout(3e3)
267
+ });
268
+ return response.ok;
269
+ } catch {
270
+ return false;
271
+ }
272
+ }
273
+ /** 提炼对话文本(不写库)。 */
274
+ async distill(text, project, maxItems = 40) {
275
+ return this.request("/v1/context/distill", {
276
+ method: "POST",
277
+ body: JSON.stringify({ text, project, max_items: maxItems })
278
+ });
279
+ }
280
+ /** 批量写入(幂等:同内容走相似合并)。 */
281
+ async migrate(items, project) {
282
+ return this.request("/v1/context/migrate", {
283
+ method: "POST",
284
+ body: JSON.stringify({ items, project })
285
+ });
286
+ }
287
+ /** 取归档索引。 */
288
+ async index(project, limit = 20) {
289
+ const query = new URLSearchParams();
290
+ if (project) query.set("project", project);
291
+ query.set("limit", String(limit));
292
+ return this.request(`/v1/context/index?${query.toString()}`, {
293
+ method: "GET"
294
+ });
295
+ }
296
+ /** 记忆总数(用于验收"不膨胀")。 */
297
+ async totalMemories() {
298
+ const stats = await this.request("/v1/memories/stats", {
299
+ method: "GET"
300
+ });
301
+ return stats.total_memories ?? 0;
302
+ }
303
+ /**
304
+ * 召回相关记忆(三路融合 + 近重复折叠)。
305
+ *
306
+ * 这是插件唯一的**读**路径:DSH 没有 MCP 客户端,AI 看不到 LRC 的
307
+ * `recall` 工具,只能通过插件注册的原生工具读回记忆。
308
+ */
309
+ async recall(query, options = {}) {
310
+ const body = {
311
+ query,
312
+ top_k: options.topK ?? 5
313
+ };
314
+ if (options.project !== void 0 && options.project.length > 0) {
315
+ body.project = options.project;
316
+ }
317
+ const data = await this.request(
318
+ "/v1/memories/enrich",
319
+ { method: "POST", body: JSON.stringify(body) }
320
+ );
321
+ return { memories: data.memories ?? [], total: data.total ?? 0 };
322
+ }
323
+ /**
324
+ * 删除一条记忆(幂等)。
325
+ *
326
+ * 用于**纠错**:AI 发现某条记忆是错的(例如把「没找到」误写成「不存在」),
327
+ * 必须能撤回,否则错误结论会被持续召回、污染后续所有对话。
328
+ */
329
+ async forget(memoryId) {
330
+ const data = await this.request("/v1/memories/forget", {
331
+ method: "POST",
332
+ body: JSON.stringify({ memory_id: memoryId })
333
+ });
334
+ return {
335
+ deleted: data.deleted === true,
336
+ alreadyDeleted: data.already_deleted === true
337
+ };
338
+ }
339
+ /** 一步完成"提炼 + 迁移"。 */
340
+ async migrateText(text, project, maxItems = 40) {
341
+ const distilled = await this.distill(text, project, maxItems);
342
+ if (distilled.items.length === 0) {
343
+ return { distilled: 0, written: 0, merged: 0, indexLine: distilled.index_line };
344
+ }
345
+ const migrated = await this.migrate(distilled.items, project);
346
+ return {
347
+ distilled: distilled.count,
348
+ written: migrated.written,
349
+ merged: migrated.merged,
350
+ indexLine: distilled.index_line
351
+ };
352
+ }
353
+ };
354
+
355
+ // src/cursor.ts
356
+ import { createHash as createHash2 } from "node:crypto";
357
+ import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "node:fs";
358
+ import { dirname } from "node:path";
359
+ function hashLine(line) {
360
+ return createHash2("sha256").update(line, "utf8").digest("hex").slice(0, 16);
361
+ }
362
+ function snapshotFingerprint(text) {
363
+ return hashLine(text.replace(/\s+/g, " ").trim());
364
+ }
365
+ function snapshotChanged(previous, next) {
366
+ if (previous === next) return false;
367
+ return snapshotFingerprint(previous) !== snapshotFingerprint(next);
368
+ }
369
+ function sliceByCursor(lines, state) {
370
+ if (state === null || state.lastLine <= 0) {
371
+ return { text: lines.join("\n"), fromLine: 0, reset: false };
372
+ }
373
+ if (state.lastLine > lines.length) {
374
+ return { text: lines.join("\n"), fromLine: 0, reset: true };
375
+ }
376
+ const anchor = lines[state.lastLine - 1] ?? "";
377
+ if (hashLine(anchor) !== state.lastHash) {
378
+ return { text: lines.join("\n"), fromLine: 0, reset: true };
379
+ }
380
+ return {
381
+ text: lines.slice(state.lastLine).join("\n"),
382
+ fromLine: state.lastLine,
383
+ reset: false
384
+ };
385
+ }
386
+ function advanceCursor(lines, sessionId, fromLine, consumedLines) {
387
+ const lastLine = Math.min(fromLine + consumedLines, lines.length);
388
+ return {
389
+ sessionId,
390
+ lastLine,
391
+ lastHash: lastLine > 0 ? hashLine(lines[lastLine - 1] ?? "") : "",
392
+ updatedAt: Date.now()
393
+ };
394
+ }
395
+ var CursorStore = class {
396
+ filePath;
397
+ cache = null;
398
+ constructor(filePath) {
399
+ this.filePath = filePath;
400
+ }
401
+ loadAll() {
402
+ if (this.cache !== null) return this.cache;
403
+ try {
404
+ if (existsSync2(this.filePath)) {
405
+ this.cache = JSON.parse(readFileSync2(this.filePath, "utf8"));
406
+ } else {
407
+ this.cache = {};
408
+ }
409
+ } catch {
410
+ this.cache = {};
411
+ }
412
+ return this.cache;
413
+ }
414
+ get(sessionId) {
415
+ return this.loadAll()[sessionId] ?? null;
416
+ }
417
+ set(state) {
418
+ const all = this.loadAll();
419
+ all[state.sessionId] = state;
420
+ try {
421
+ mkdirSync2(dirname(this.filePath), { recursive: true });
422
+ writeFileSync2(this.filePath, JSON.stringify(all, null, 2), "utf8");
423
+ } catch {
424
+ }
425
+ }
426
+ };
427
+ var RecentStore = class {
428
+ filePath;
429
+ cache = null;
430
+ constructor(filePath) {
431
+ this.filePath = filePath;
432
+ }
433
+ load() {
434
+ if (this.cache !== null) return this.cache;
435
+ try {
436
+ if (existsSync2(this.filePath)) {
437
+ const parsed = JSON.parse(readFileSync2(this.filePath, "utf8"));
438
+ this.cache = Array.isArray(parsed) ? parsed.filter((item) => typeof item === "string") : [];
439
+ } else {
440
+ this.cache = [];
441
+ }
442
+ } catch {
443
+ this.cache = [];
444
+ }
445
+ return this.cache;
446
+ }
447
+ save(items) {
448
+ this.cache = items;
449
+ try {
450
+ mkdirSync2(dirname(this.filePath), { recursive: true });
451
+ writeFileSync2(this.filePath, JSON.stringify(items), "utf8");
452
+ } catch {
453
+ }
454
+ }
455
+ };
456
+
457
+ // src/memory-filter.ts
458
+ var MEMORY_TAG_OPEN = "<lrc_memory>";
459
+ var MEMORY_TAG_CLOSE = "</lrc_memory>";
460
+ function findTagBlocks(text) {
461
+ const blocks = [];
462
+ let offset = 0;
463
+ for (const line of text.split("\n")) {
464
+ if (line.trim().startsWith(MEMORY_TAG_OPEN)) {
465
+ const openAt = offset + line.indexOf(MEMORY_TAG_OPEN);
466
+ const closeAt = text.indexOf(MEMORY_TAG_CLOSE, openAt + MEMORY_TAG_OPEN.length);
467
+ if (closeAt >= 0) {
468
+ blocks.push({
469
+ start: openAt,
470
+ end: closeAt + MEMORY_TAG_CLOSE.length,
471
+ body: text.slice(openAt + MEMORY_TAG_OPEN.length, closeAt).trim()
472
+ });
473
+ }
474
+ }
475
+ offset += line.length + 1;
476
+ }
477
+ return blocks;
478
+ }
479
+ function extractMemoryTag(text) {
480
+ const blocks = findTagBlocks(text);
481
+ if (blocks.length === 0) return null;
482
+ const body = blocks[blocks.length - 1].body;
483
+ return body.length > 0 ? body : null;
484
+ }
485
+ function stripMemoryTag(text) {
486
+ const blocks = findTagBlocks(text);
487
+ if (blocks.length === 0) return text;
488
+ let out = "";
489
+ let cursor = 0;
490
+ for (const block of blocks) {
491
+ out += text.slice(cursor, block.start);
492
+ cursor = block.end;
493
+ }
494
+ return out + text.slice(cursor);
495
+ }
496
+ function isNoUpdate(body) {
497
+ const t = body.trim().replace(/[。.!!\s]/g, "").toLowerCase();
498
+ return t === "无新增" || t === "无" || t === "none" || t === "noupdate" || t === "nothing";
499
+ }
500
+ var SMALL_TALK = [
501
+ "你好",
502
+ "您好",
503
+ "谢谢",
504
+ "多谢",
505
+ "好的",
506
+ "收到",
507
+ "明白",
508
+ "了解",
509
+ "ok",
510
+ "okay",
511
+ "thanks",
512
+ "thankyou",
513
+ "hello",
514
+ "hi"
515
+ ];
516
+ function isSmallTalk(line) {
517
+ const t = line.trim().toLowerCase().replace(/[。!!,,、;;::\s]/g, "");
518
+ if (t.length === 0) return true;
519
+ if (t.length > 12) return false;
520
+ return SMALL_TALK.some((word) => t.startsWith(word));
521
+ }
522
+ var ERROR_MARKERS = [
523
+ "error",
524
+ "exception",
525
+ "panic",
526
+ "failed",
527
+ "failure",
528
+ "traceback",
529
+ "错误",
530
+ "异常",
531
+ "失败",
532
+ "报错",
533
+ "崩溃",
534
+ "堆栈",
535
+ "调用栈"
536
+ ];
537
+ function hasErrorMarker(line) {
538
+ const lower = maskPaths(line).toLowerCase();
539
+ return ERROR_MARKERS.some((marker) => lower.includes(marker));
540
+ }
541
+ function looksLikeNormalLog(line) {
542
+ const t = line.trim();
543
+ if (hasErrorMarker(t)) return false;
544
+ if (/^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/.test(t)) return true;
545
+ if (/^\[?(INFO|DEBUG|TRACE|VERBOSE)\]?[\s:]/i.test(t)) return true;
546
+ return false;
547
+ }
548
+ function stripCodeFences(text) {
549
+ const out = [];
550
+ let inFence = false;
551
+ for (const line of text.split("\n")) {
552
+ if (line.trim().startsWith("```")) {
553
+ inFence = !inFence;
554
+ continue;
555
+ }
556
+ if (!inFence) out.push(line);
557
+ }
558
+ return out.join("\n");
559
+ }
560
+ function isOptionLabel(line) {
561
+ const t = line.trim();
562
+ if (/^[-*+]\s*\*\*[A-Za-z0-9]{1,3}\*\*\s*[::]/.test(t)) return true;
563
+ if (/^[-*+]\s*[A-Za-z0-9]{1,3}[.、)]\s/.test(t)) return true;
564
+ return false;
565
+ }
566
+ var PATH_BOUNDARY = "[^\\s,。;:、!?\"'`()()【】\\[\\]{}<>《》「」]";
567
+ var PATH_PATTERNS = [
568
+ /\bhttps?:\/\/[^\s,。;:、!?"'`()()【】\[\]{}<>《》「」]+/gi,
569
+ /[A-Za-z]:[\\/][^\s,。;:、!?"'`()()【】\[\]{}<>《》「」]*/g,
570
+ /\\\\[^\s,。;:、!?"'`()()【】\[\]{}<>《》「」]*/g,
571
+ // 相对路径:**只认 ASCII 路径段**。
572
+ //
573
+ // 不能用 `${PATH_BOUNDARY}*[\\/]${PATH_BOUNDARY}*` —— PATH_BOUNDARY 含 CJK,
574
+ // 会把 `禁止使用3099/3111` 整段挖成空格,真实约束直接丢失(实测回归)。
575
+ // CJK 文件名由下面的扩展名规则兜住(`docs/规范.md` 仍会被完整挖掉)。
576
+ /(?:[A-Za-z0-9_.~-]+[\\/])+[A-Za-z0-9_.~-]*/g,
577
+ new RegExp(
578
+ `${PATH_BOUNDARY}+\\.(?:md|markdown|txt|ts|tsx|js|jsx|mjs|cjs|json|ya?ml|toml|ini|cfg|conf|png|jpe?g|gif|svg|webp|bmp|pdf|docx?|xlsx?|pptx?|csv|log|exe|dll|so|dylib|lock|html?|css|scss|less|py|rs|go|java|kt|sh|ps1|bat|cmd|zip|tar|gz|7z|mp4|mp3|wav)\\b`,
579
+ "gi"
580
+ )
581
+ ];
582
+ function maskPaths(text) {
583
+ let out = text;
584
+ for (const pattern of PATH_PATTERNS) {
585
+ out = out.replace(pattern, (token) => " ".repeat(token.length));
586
+ }
587
+ return out;
588
+ }
589
+ var CONSTRAINT_WORDS = [
590
+ "必须",
591
+ "禁止",
592
+ "严禁",
593
+ "切勿",
594
+ "不得",
595
+ "不能",
596
+ "不要",
597
+ "不允许",
598
+ "不许",
599
+ "不准",
600
+ "禁用",
601
+ "杜绝",
602
+ "一律",
603
+ "统一",
604
+ "统一为",
605
+ "默认",
606
+ "只允许",
607
+ "只能",
608
+ "务必",
609
+ "一定要",
610
+ "请用",
611
+ "记得",
612
+ "别忘",
613
+ "下次",
614
+ "别用",
615
+ "别再",
616
+ "别动",
617
+ "别改",
618
+ "别删",
619
+ "别碰",
620
+ "别连",
621
+ "别把",
622
+ "别写",
623
+ "别存",
624
+ "别加",
625
+ "改用",
626
+ "换成",
627
+ "改成",
628
+ "以后都",
629
+ "今后都"
630
+ ];
631
+ var ENGLISH_CONSTRAINT = /\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;
632
+ var SHORT_LINE_KEYWORDS = [
633
+ .../* @__PURE__ */ new Set([
634
+ ...CONSTRAINT_WORDS,
635
+ "严禁",
636
+ "决定",
637
+ "决策",
638
+ "结论",
639
+ "约束",
640
+ "规范",
641
+ "约定",
642
+ "原则",
643
+ "端口",
644
+ "架构"
645
+ ])
646
+ ];
647
+ var MIN_CHARS = 20;
648
+ var MIN_BODY_CHARS = 8;
649
+ function hitsKeyword(body) {
650
+ if (ENGLISH_CONSTRAINT.test(body)) return true;
651
+ return SHORT_LINE_KEYWORDS.some((k) => body.includes(k));
652
+ }
653
+ function hasConstraintMarker(text) {
654
+ return hitsKeyword(maskPaths(text));
655
+ }
656
+ function hardFilter(text) {
657
+ const tag = extractMemoryTag(text);
658
+ if (tag !== null) {
659
+ if (isNoUpdate(tag)) return "";
660
+ return stripCodeFences(tag).trim();
661
+ }
662
+ const body = stripCodeFences(stripMemoryTag(text));
663
+ const kept = [];
664
+ for (const raw of body.split("\n")) {
665
+ const line = raw.trim();
666
+ if (line.length === 0) continue;
667
+ if (isSmallTalk(line)) continue;
668
+ if (isOptionLabel(line)) continue;
669
+ if (looksLikeNormalLog(line)) continue;
670
+ const masked = maskPaths(line);
671
+ const hasKeyword = hitsKeyword(masked);
672
+ if (line.length < MIN_CHARS && !hasKeyword) continue;
673
+ if (!hasKeyword && masked.replace(/\s+/g, "").length < MIN_BODY_CHARS) continue;
674
+ kept.push(line);
675
+ }
676
+ return kept.join("\n");
677
+ }
678
+ var CONCLUSION_MARKERS = [
679
+ "结论",
680
+ "因此",
681
+ "所以",
682
+ "决定",
683
+ "必须",
684
+ "不能",
685
+ "应该",
686
+ "最终",
687
+ "综上",
688
+ "关键是",
689
+ "根因",
690
+ "原因",
691
+ "方案",
692
+ "改用",
693
+ "采用",
694
+ "确认",
695
+ "发现",
696
+ "注意"
697
+ ];
698
+ var ENGLISH_STRONG = /\b(?:therefore|thus|hence|conclusion|concluded|decided|decision|root\s+cause)\b/i;
699
+ var ENGLISH_WEAK = /\b(?:must|should|need\s+to|because)\b/i;
700
+ var PROCESS_NARRATION = /^(?:(?: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;
701
+ function conclusionsFromReasoning(text, maxLines = 5, maxChars = 300) {
702
+ const out = [];
703
+ const seen = /* @__PURE__ */ new Set();
704
+ for (const raw of text.split(/[。!?;\n]+|(?<=[A-Za-z0-9])\.\s+/)) {
705
+ const line = raw.trim();
706
+ if (line.length < 8 || line.length > maxChars) continue;
707
+ if (isSmallTalk(line) || isOptionLabel(line) || looksLikeNormalLog(line)) continue;
708
+ if (stripCodeFences(line) !== line) continue;
709
+ const masked = maskPaths(line);
710
+ const chineseHit = CONCLUSION_MARKERS.some((marker) => masked.includes(marker));
711
+ const englishStrong = ENGLISH_STRONG.test(masked);
712
+ const englishWeak = ENGLISH_WEAK.test(masked);
713
+ if (!chineseHit && !englishStrong && !englishWeak) continue;
714
+ if (!chineseHit && !englishStrong && englishWeak) {
715
+ if (line.length < 20 || PROCESS_NARRATION.test(line)) continue;
716
+ }
717
+ const key = line.replace(/\s+/g, "");
718
+ if (seen.has(key)) continue;
719
+ seen.add(key);
720
+ out.push(line);
721
+ if (out.length >= maxLines) break;
722
+ }
723
+ return out;
724
+ }
725
+ function jaccard(a, b) {
726
+ const setA = new Set(a.replace(/\s+/g, ""));
727
+ const setB = new Set(b.replace(/\s+/g, ""));
728
+ if (setA.size === 0 && setB.size === 0) return 1;
729
+ let inter = 0;
730
+ for (const ch of setA) if (setB.has(ch)) inter += 1;
731
+ const union = setA.size + setB.size - inter;
732
+ return union === 0 ? 0 : inter / union;
733
+ }
734
+ var DEFAULT_DEDUP_THRESHOLD = 0.85;
735
+ function isDuplicate(text, recent, threshold = DEFAULT_DEDUP_THRESHOLD) {
736
+ const t = text.trim();
737
+ if (t.length === 0) return true;
738
+ return recent.some((r) => jaccard(r, t) >= threshold);
739
+ }
740
+
741
+ // src/offloader.ts
742
+ var DEFAULT_OFFLOAD_CONFIG = {
743
+ turnsThreshold: 8,
744
+ charsThreshold: 6e3,
745
+ stepsThreshold: 20,
746
+ project: "dsh",
747
+ maxItems: 40,
748
+ snapshotItems: 3,
749
+ dedupThreshold: DEFAULT_DEDUP_THRESHOLD,
750
+ recentWindow: 50
751
+ };
752
+ function buildSnapshot(items, maxItems) {
753
+ if (items.length === 0) return "";
754
+ const picked = items.slice(0, maxItems).map((item) => {
755
+ const body = item.content.replace(/^\[[^\]]+\]\s*/, "");
756
+ const short = body.length > 120 ? `${body.slice(0, 120)}…` : body;
757
+ return short;
758
+ });
759
+ return `[LRC 状态] ${picked.join(";")}。细节在 LRC,需要时 recall。`;
760
+ }
761
+ var PROCESS_NARRATION2 = /接下来|下一步|我要|待确认|待验证|还需要|准备|打算|将要/;
762
+ var COMPLETION_WORDS = /已完成|已确认|已验证|已修复|结论是|因此|所以|说明|表明/;
763
+ function isProcessNarration(body) {
764
+ return PROCESS_NARRATION2.test(body) && !COMPLETION_WORDS.test(body);
765
+ }
766
+ function classifyTagLine(line) {
767
+ const head = /^(决策|约束|结论)\s*[::]/.exec(line);
768
+ if (head !== null) {
769
+ const body2 = line.slice(head[0].length).trim();
770
+ switch (head[1]) {
771
+ case "约束":
772
+ return { kind: "约束", memoryType: "preference", tag: "constraint", importance: 7 };
773
+ case "决策":
774
+ return { kind: "决策", memoryType: "decision", tag: "decision", importance: 7 };
775
+ default:
776
+ if (isProcessNarration(body2)) {
777
+ return { kind: "事实", memoryType: "fact", tag: "fact", importance: 5 };
778
+ }
779
+ return { kind: "结论", memoryType: "fact", tag: "conclusion", importance: 6 };
780
+ }
781
+ }
782
+ const body = maskPaths(line);
783
+ if (/禁止|严禁|不得|不要|不能|切勿|杜绝|不允许|必须|约束|一律|统一为/.test(body)) {
784
+ return { kind: "约束", memoryType: "preference", tag: "constraint", importance: 7 };
785
+ }
786
+ if (/决策|决定|采用|改用|选型|定为|换成|改成/.test(body)) {
787
+ return { kind: "决策", memoryType: "decision", tag: "decision", importance: 7 };
788
+ }
789
+ return { kind: "结论", memoryType: "fact", tag: "conclusion", importance: 6 };
790
+ }
791
+ function itemsFromTag(tag) {
792
+ const items = [];
793
+ const lines = tag.split("\n");
794
+ for (let i = 0; i < lines.length; i += 1) {
795
+ const line = lines[i].trim().replace(/^[-*+]\s*/, "").trim();
796
+ if (line.length === 0) continue;
797
+ const { kind, memoryType, tag: tagName, importance } = classifyTagLine(line);
798
+ const body = line.replace(/^(决策|约束|结论|决定)\s*[::]\s*/, "").trim();
799
+ items.push({
800
+ content: `[${kind}] ${body.length > 0 ? body : line}`,
801
+ memory_type: memoryType,
802
+ tags: [tagName, "distilled", "lrc_memory"],
803
+ importance,
804
+ source_turns: [i, i]
805
+ });
806
+ }
807
+ return items;
808
+ }
809
+ var Offloader = class {
810
+ client;
811
+ cursors;
812
+ config;
813
+ recentStore;
814
+ pendingTurns = 0;
815
+ pendingChars = 0;
816
+ pendingSteps = 0;
817
+ /** 最近已发送的块,用于本地查重(跨会话共享,避免重复入库) */
818
+ recent = [];
819
+ /** 累计统计(`lrc_status` 用) */
820
+ flushCount = 0;
821
+ totalWritten = 0;
822
+ totalMerged = 0;
823
+ totalDeduped = 0;
824
+ lastFlushAt = 0;
825
+ lastFlushWritten = 0;
826
+ lastFlushMerged = 0;
827
+ constructor(client, cursors, config = {}, recentStore) {
828
+ this.client = client;
829
+ this.cursors = cursors;
830
+ this.config = { ...DEFAULT_OFFLOAD_CONFIG, ...config };
831
+ this.recentStore = recentStore;
832
+ this.recent = recentStore?.load() ?? [];
833
+ }
834
+ /** 记录一轮新增文本(由 index.ts 在 assistant/message 事件里调用)。 */
835
+ observe(text) {
836
+ const cleaned = hardFilter(text);
837
+ if (cleaned.trim().length === 0) return;
838
+ this.pendingTurns += 1;
839
+ this.pendingChars += cleaned.length;
840
+ }
841
+ /**
842
+ * 记录一步。
843
+ *
844
+ * 长任务(88 步 / 138 次工具调用)里正文可能只有最后一条,
845
+ * 只按"正文消息数"计数永远到不了阈值 —— 必须同时按步数兜底。
846
+ */
847
+ observeStep() {
848
+ this.pendingSteps += 1;
849
+ }
850
+ /** 是否达到卸载阈值。 */
851
+ shouldOffload() {
852
+ return this.pendingTurns >= this.config.turnsThreshold || this.pendingChars >= this.config.charsThreshold || this.pendingSteps >= this.config.stepsThreshold;
853
+ }
854
+ /**
855
+ * 是否有待沉淀内容(turn/end 兜底用)。
856
+ *
857
+ * 必须包含 `pendingSteps`:长任务里可能只有工具调用、没有正文,
858
+ * 此时步数阈值已经触发,但 turns/chars 都是 0。漏掉步数会让
859
+ * "88 步兜底"恰好在这类任务上失效。
860
+ */
861
+ hasPending() {
862
+ return this.pendingTurns > 0 || this.pendingChars > 0 || this.pendingSteps > 0;
863
+ }
864
+ /** 重置计数器(卸载成功或会话切换时调用)。 */
865
+ resetCounters() {
866
+ this.pendingTurns = 0;
867
+ this.pendingChars = 0;
868
+ this.pendingSteps = 0;
869
+ }
870
+ /** 累计沉淀统计(`lrc_status` 用)。 */
871
+ stats() {
872
+ return {
873
+ flushCount: this.flushCount,
874
+ totalWritten: this.totalWritten,
875
+ totalMerged: this.totalMerged,
876
+ totalDeduped: this.totalDeduped,
877
+ lastFlushAt: this.lastFlushAt,
878
+ lastFlushWritten: this.lastFlushWritten,
879
+ lastFlushMerged: this.lastFlushMerged,
880
+ recentWindow: this.recent.length
881
+ };
882
+ }
883
+ /** 记录一次沉淀结果(只统计真正有产出的批次)。 */
884
+ record(result) {
885
+ this.totalDeduped += result.deduped;
886
+ if (result.written === 0 && result.merged === 0) return;
887
+ this.flushCount += 1;
888
+ this.totalWritten += result.written;
889
+ this.totalMerged += result.merged;
890
+ this.lastFlushAt = Date.now();
891
+ this.lastFlushWritten = result.written;
892
+ this.lastFlushMerged = result.merged;
893
+ }
894
+ /**
895
+ * 执行一次卸载。
896
+ *
897
+ * @param sessionId 会话 ID(游标按会话分槽)
898
+ * @param lines 当前 transcript 的全部行
899
+ * @returns 卸载结果;没有新增内容时返回 null
900
+ */
901
+ async offload(sessionId, lines) {
902
+ const state = this.cursors.get(sessionId);
903
+ const slice = sliceByCursor(lines, state);
904
+ if (slice.text.trim().length === 0) {
905
+ this.resetCounters();
906
+ return null;
907
+ }
908
+ const newLines = lines.slice(slice.fromLine);
909
+ const taggedItems = [];
910
+ const untagged = [];
911
+ for (const message of newLines) {
912
+ const tag = extractMemoryTag(message);
913
+ if (tag !== null) {
914
+ if (!isNoUpdate(tag)) taggedItems.push(...itemsFromTag(tag));
915
+ continue;
916
+ }
917
+ const cleaned = hardFilter(message);
918
+ if (cleaned.trim().length > 0) untagged.push(cleaned);
919
+ }
920
+ const freshTagged = [];
921
+ const freshBlocks = [];
922
+ let deduped = 0;
923
+ for (const item of taggedItems) {
924
+ if (isDuplicate(item.content, this.recent, this.config.dedupThreshold)) {
925
+ deduped += 1;
926
+ continue;
927
+ }
928
+ freshTagged.push(item);
929
+ }
930
+ for (const block of untagged) {
931
+ if (isDuplicate(block, this.recent, this.config.dedupThreshold)) {
932
+ deduped += 1;
933
+ continue;
934
+ }
935
+ freshBlocks.push(block);
936
+ }
937
+ if (freshTagged.length === 0 && freshBlocks.length === 0) {
938
+ this.commitCursor(sessionId, lines, slice.fromLine);
939
+ const empty = {
940
+ charsSent: 0,
941
+ distilled: 0,
942
+ written: 0,
943
+ merged: 0,
944
+ deduped,
945
+ snapshot: "",
946
+ reset: slice.reset
947
+ };
948
+ this.record(empty);
949
+ return empty;
950
+ }
951
+ let items = [...freshTagged];
952
+ let charsSent = freshTagged.reduce((sum, item) => sum + item.content.length, 0);
953
+ if (freshBlocks.length > 0) {
954
+ const text = freshBlocks.join("\n");
955
+ charsSent += text.length;
956
+ const distilled = await this.client.distill(text, this.config.project, this.config.maxItems);
957
+ items = [...items, ...distilled.items];
958
+ }
959
+ let written = 0;
960
+ let merged = 0;
961
+ if (items.length > 0) {
962
+ const migrated = await this.client.migrate(items, this.config.project);
963
+ written = migrated.written;
964
+ merged = migrated.merged;
965
+ }
966
+ this.commitCursor(sessionId, lines, slice.fromLine);
967
+ this.recent.push(...freshTagged.map((item) => item.content), ...freshBlocks);
968
+ if (this.recent.length > this.config.recentWindow) {
969
+ this.recent = this.recent.slice(-this.config.recentWindow);
970
+ }
971
+ this.recentStore?.save(this.recent);
972
+ const result = {
973
+ charsSent,
974
+ distilled: items.length,
975
+ written,
976
+ merged,
977
+ deduped,
978
+ snapshot: buildSnapshot(items, this.config.snapshotItems),
979
+ reset: slice.reset
980
+ };
981
+ this.record(result);
982
+ return result;
983
+ }
984
+ /**
985
+ * 直接沉淀一批已提炼的条目(**不走行游标**)。
986
+ *
987
+ * 用于推理结论兜底:这些句子来自 reasoning 流,不在 transcript 行流里,
988
+ * 无法用行号游标追踪,只能按内容查重。查重窗口与主路径共用。
989
+ *
990
+ * @param items 已分类的记忆条目
991
+ * @returns 卸载结果;全部被查重拦下时返回零值结果
992
+ */
993
+ async offloadItems(items) {
994
+ const fresh = [];
995
+ let deduped = 0;
996
+ for (const item of items) {
997
+ if (isDuplicate(item.content, this.recent, this.config.dedupThreshold)) {
998
+ deduped += 1;
999
+ continue;
1000
+ }
1001
+ fresh.push(item);
1002
+ }
1003
+ if (fresh.length === 0) {
1004
+ const empty = {
1005
+ charsSent: 0,
1006
+ distilled: 0,
1007
+ written: 0,
1008
+ merged: 0,
1009
+ deduped,
1010
+ snapshot: "",
1011
+ reset: false
1012
+ };
1013
+ this.record(empty);
1014
+ return empty;
1015
+ }
1016
+ const migrated = await this.client.migrate(fresh, this.config.project);
1017
+ this.recent.push(...fresh.map((item) => item.content));
1018
+ if (this.recent.length > this.config.recentWindow) {
1019
+ this.recent = this.recent.slice(-this.config.recentWindow);
1020
+ }
1021
+ this.recentStore?.save(this.recent);
1022
+ const result = {
1023
+ charsSent: fresh.reduce((sum, item) => sum + item.content.length, 0),
1024
+ distilled: fresh.length,
1025
+ written: migrated.written,
1026
+ merged: migrated.merged,
1027
+ deduped,
1028
+ snapshot: buildSnapshot(fresh, this.config.snapshotItems),
1029
+ reset: false
1030
+ };
1031
+ this.record(result);
1032
+ return result;
1033
+ }
1034
+ /** 推进游标并重置计数器(只在本次内容已处理完时调用)。 */
1035
+ commitCursor(sessionId, lines, fromLine) {
1036
+ const consumed = lines.length - fromLine;
1037
+ this.cursors.set(advanceCursor(lines, sessionId, fromLine, consumed));
1038
+ this.resetCounters();
1039
+ }
1040
+ };
1041
+
1042
+ // src/event-classify.ts
1043
+ function classifyEvent(event) {
1044
+ const e = event;
1045
+ switch (e?.type) {
1046
+ case "system/message":
1047
+ return "system";
1048
+ case "assistant/message":
1049
+ return "assistant";
1050
+ case "tool/result":
1051
+ return "tool-result";
1052
+ case "user/message": {
1053
+ const kind = e.data?.source?.kind;
1054
+ return kind === void 0 || kind === "user" ? "user" : "injected";
1055
+ }
1056
+ default:
1057
+ return "unknown";
1058
+ }
1059
+ }
1060
+ function isConversationNode(event) {
1061
+ const category = classifyEvent(event);
1062
+ return category === "user" || category === "assistant" || category === "tool-result";
1063
+ }
1064
+ function isRealUserEvent(event) {
1065
+ return classifyEvent(event) === "user";
1066
+ }
1067
+
1068
+ // src/session-text.ts
1069
+ function messageContent(event) {
1070
+ const candidate = event;
1071
+ if (candidate?.type !== "assistant/message") return void 0;
1072
+ return candidate.data?.message?.content ?? candidate.message?.content;
1073
+ }
1074
+ function textBlocksOnly(content) {
1075
+ if (typeof content === "string") return content;
1076
+ if (!Array.isArray(content)) return "";
1077
+ const texts = [];
1078
+ for (const block of content) {
1079
+ const b = block;
1080
+ if (b?.type === "text" && typeof b.text === "string" && b.text.length > 0) texts.push(b.text);
1081
+ }
1082
+ return texts.join("\n");
1083
+ }
1084
+ function reasoningBlocksOnly(content) {
1085
+ if (!Array.isArray(content)) return "";
1086
+ const texts = [];
1087
+ for (const block of content) {
1088
+ const b = block;
1089
+ if (b?.type === "reasoning" && typeof b.text === "string" && b.text.length > 0) {
1090
+ texts.push(b.text);
1091
+ }
1092
+ }
1093
+ return texts.join("\n");
1094
+ }
1095
+ function hasMalformedTextBlock(content) {
1096
+ if (!Array.isArray(content)) return false;
1097
+ return content.some((block) => {
1098
+ const b = block;
1099
+ return (b?.type === "text" || b?.type === "reasoning") && typeof b.text !== "string";
1100
+ });
1101
+ }
1102
+ function isRealUserMessage(event) {
1103
+ return isRealUserEvent(event);
1104
+ }
1105
+ function hasUserConstraint(text) {
1106
+ return hasConstraintMarker(text);
1107
+ }
1108
+ function linesFromSession(session) {
1109
+ const log = session?.log;
1110
+ if (!Array.isArray(log)) return [];
1111
+ const lines = [];
1112
+ for (const raw of log) {
1113
+ const event = raw;
1114
+ const category = classifyEvent(event);
1115
+ if (category === "assistant") {
1116
+ const text = textBlocksOnly(event.data?.message?.content);
1117
+ if (text.length > 0) lines.push(text);
1118
+ } else if (category === "user") {
1119
+ if (!isRealUserMessage(event)) continue;
1120
+ const text = textBlocksOnly(event.data?.content);
1121
+ if (text.length === 0) continue;
1122
+ if (!hasUserConstraint(text)) continue;
1123
+ lines.push(text);
1124
+ }
1125
+ }
1126
+ return lines;
1127
+ }
1128
+
1129
+ // src/context-trim.ts
1130
+ import { randomUUID } from "node:crypto";
1131
+ function safeGet(ctx, name2) {
1132
+ try {
1133
+ return ctx?.get?.(name2);
1134
+ } catch {
1135
+ return void 0;
1136
+ }
1137
+ }
1138
+ function resolveSessionEvents(session) {
1139
+ const s = session;
1140
+ try {
1141
+ const snapshot = s?.snapshotEvents?.();
1142
+ if (Array.isArray(snapshot)) return snapshot;
1143
+ } catch {
1144
+ }
1145
+ if (Array.isArray(s?.events)) return s.events;
1146
+ if (Array.isArray(s?.log)) return s.log;
1147
+ return void 0;
1148
+ }
1149
+ var SUMMARY_TEXT_PREFIXES = [
1150
+ "## 已裁剪的历史",
1151
+ "This is an automatically generated checkpoint",
1152
+ "<compacted-summary>"
1153
+ ];
1154
+ function isSummaryNode(event) {
1155
+ const e = event;
1156
+ if (e?.type !== "user/message") return false;
1157
+ const kind = e.data?.source?.kind;
1158
+ if (kind === "compact-checkpoint" || kind === "compaction" || kind === "summary" || // LRC 自己产出的「已剪枝」节点(prune-reasoning 的替换体):
1159
+ // 它是旧对话的改写版,必须可再次被 trim 覆盖,否则会像摘要一样累积。
1160
+ kind === "lrc-prune") {
1161
+ return true;
1162
+ }
1163
+ const content = e.data?.content;
1164
+ if (!Array.isArray(content)) return false;
1165
+ for (const block of content) {
1166
+ const text = block?.text;
1167
+ if (typeof text !== "string") continue;
1168
+ const head = text.trimStart();
1169
+ if (SUMMARY_TEXT_PREFIXES.some((prefix) => head.startsWith(prefix))) return true;
1170
+ }
1171
+ return false;
1172
+ }
1173
+ function isTrimBoundaryEligible(event) {
1174
+ return isConversationNode(event) || isSummaryNode(event);
1175
+ }
1176
+ function toolDelta(event) {
1177
+ const e = event;
1178
+ if (e?.type === "assistant/message") {
1179
+ const content = e.data?.message?.content;
1180
+ if (!Array.isArray(content)) return 0;
1181
+ let calls = 0;
1182
+ for (const block of content) {
1183
+ if (block?.type === "tool-call") calls += 1;
1184
+ }
1185
+ return calls;
1186
+ }
1187
+ if (e?.type === "tool/result") return -1;
1188
+ return 0;
1189
+ }
1190
+ function selectTrimRange(nodes, events, keepRecentNodes) {
1191
+ const n = nodes.length;
1192
+ if (n <= keepRecentNodes + 1) return null;
1193
+ const cut = new Array(n + 1).fill(false);
1194
+ let running = 0;
1195
+ cut[0] = true;
1196
+ for (let i = 0; i < n; i += 1) {
1197
+ running += toolDelta(events[nodes[i]]);
1198
+ cut[i + 1] = running === 0;
1199
+ }
1200
+ let endIdx = n - keepRecentNodes - 1;
1201
+ while (endIdx >= 0 && (cut[endIdx + 1] !== true || !isTrimBoundaryEligible(events[nodes[endIdx]]))) {
1202
+ endIdx -= 1;
1203
+ }
1204
+ if (endIdx < 0) return null;
1205
+ let startIdx = 0;
1206
+ while (startIdx <= endIdx && (cut[startIdx] !== true || !isTrimBoundaryEligible(events[nodes[startIdx]]))) {
1207
+ startIdx += 1;
1208
+ }
1209
+ if (startIdx > endIdx) return null;
1210
+ return {
1211
+ start: nodes[startIdx],
1212
+ end: nodes[endIdx],
1213
+ startIdx,
1214
+ endIdx
1215
+ };
1216
+ }
1217
+ function findRetryStart(nodes, events, startIdx, endIdx) {
1218
+ let running = 0;
1219
+ for (let i = startIdx; i < endIdx; i += 1) {
1220
+ running += toolDelta(events[nodes[i]]);
1221
+ const candidate = i + 1;
1222
+ if (running === 0 && isTrimBoundaryEligible(events[nodes[candidate]])) {
1223
+ return candidate;
1224
+ }
1225
+ }
1226
+ return null;
1227
+ }
1228
+ function textOf(content) {
1229
+ if (typeof content === "string") return content;
1230
+ if (!Array.isArray(content)) return "";
1231
+ const out = [];
1232
+ for (const block of content) {
1233
+ const b = block;
1234
+ if (b?.type === "text" && typeof b.text === "string" && b.text.length > 0) out.push(b.text);
1235
+ }
1236
+ return out.join("\n");
1237
+ }
1238
+ function oneLine(text, max) {
1239
+ const flat = text.replace(/\s+/g, " ").trim();
1240
+ return flat.length <= max ? flat : `${flat.slice(0, max - 1)}…`;
1241
+ }
1242
+ function collect(target, seen, line, limit) {
1243
+ if (target.length >= limit) return;
1244
+ if (line.length === 0 || seen.has(line)) return;
1245
+ seen.add(line);
1246
+ target.push(line);
1247
+ }
1248
+ var NOISE_PREFIXES = [
1249
+ "Current runtime context.",
1250
+ "Current working directory:",
1251
+ "Current DSH file policy:",
1252
+ "Current DeepSeek Harness",
1253
+ "This is an automatically generated checkpoint",
1254
+ "<compacted-summary>",
1255
+ "</compacted-summary>",
1256
+ "<system-reminder>",
1257
+ "The available skill catalog changed",
1258
+ "The user changed the approval policy",
1259
+ "Approval prompts are disabled",
1260
+ // 每轮 prepare 时宿主采样时间并注入,同样伪装成 role:'user'
1261
+ "Time sampled while preparing",
1262
+ "Browser time zone for this request:",
1263
+ "## 已裁剪的历史",
1264
+ "### 用户请求",
1265
+ "### 工具调用与结果",
1266
+ "### 工具错误",
1267
+ "### 助手结论",
1268
+ "### 早期结论",
1269
+ "[LRC 状态]"
1270
+ ];
1271
+ function isNoiseText(text) {
1272
+ const t = text.trimStart();
1273
+ if (t.length === 0) return true;
1274
+ const head = t.length > 160 ? t.slice(0, 160) : t;
1275
+ return NOISE_PREFIXES.some((p) => head.startsWith(p));
1276
+ }
1277
+ function isProcessNarration2(text) {
1278
+ if (!/接下来|下一步|我要|待确认|待验证|还需要|准备|打算|将要/.test(text)) return false;
1279
+ return !/已完成|已确认|已验证|已修复|结论是|因此|所以|说明|表明/.test(text);
1280
+ }
1281
+ function absorbPriorSummary(text, buckets, seen) {
1282
+ const limits = { requests: 8, calls: 20, errors: 10, conclusions: 10 };
1283
+ let section;
1284
+ for (const rawLine of text.split("\n")) {
1285
+ const line = rawLine.trim();
1286
+ if (line.length === 0) continue;
1287
+ if (line.startsWith("## ")) continue;
1288
+ if (line.startsWith("### ")) {
1289
+ const title = line.slice(4).trim();
1290
+ if (title.startsWith("用户请求")) section = "requests";
1291
+ else if (title.startsWith("工具调用")) section = "calls";
1292
+ else if (title.startsWith("工具错误")) section = "errors";
1293
+ else if (title.startsWith("助手结论") || title.startsWith("早期结论")) section = "conclusions";
1294
+ else section = void 0;
1295
+ continue;
1296
+ }
1297
+ if (section === void 0) continue;
1298
+ if (!line.startsWith("- ")) continue;
1299
+ const body = line.slice(2);
1300
+ if (isNoiseText(body)) continue;
1301
+ collect(buckets[section], seen, `- ${body}`, limits[section]);
1302
+ }
1303
+ }
1304
+ function buildDeterministicSummary(messages, maxChars, realCounts) {
1305
+ const requests = [];
1306
+ const calls = [];
1307
+ const errors = [];
1308
+ const conclusions = [];
1309
+ const seen = /* @__PURE__ */ new Set();
1310
+ const priorSummaries = [];
1311
+ for (const raw of messages) {
1312
+ const message = raw;
1313
+ const content = message?.content;
1314
+ if (!Array.isArray(content)) continue;
1315
+ if (message.role === "tool") {
1316
+ const text2 = textOf(content);
1317
+ if (text2.length > 0) {
1318
+ const isError = message.isError === true || Array.isArray(content) && content.some((b) => b?.isError === true);
1319
+ if (isError) collect(errors, seen, `- ${oneLine(text2, 300)}`, 15);
1320
+ else collect(calls, seen, `- 结果:${oneLine(text2.split("\n")[0] ?? "", 150)}`, 30);
1321
+ }
1322
+ continue;
1323
+ }
1324
+ for (const block of content) {
1325
+ const b = block;
1326
+ if (b?.type === "text" && typeof b.text === "string") {
1327
+ if (isNoiseText(b.text) && b.text.trimStart().startsWith("## 已裁剪的历史")) {
1328
+ priorSummaries.push(b.text);
1329
+ continue;
1330
+ }
1331
+ if (isNoiseText(b.text)) continue;
1332
+ const line = oneLine(b.text, 300);
1333
+ if (message.role === "user") collect(requests, seen, `- ${line}`, 12);
1334
+ else if (!isProcessNarration2(b.text)) collect(conclusions, seen, `- ${line}`, 15);
1335
+ } else if (b?.type === "tool-call") {
1336
+ const name2 = typeof b.name === "string" ? b.name : "tool";
1337
+ const args = typeof b.arguments === "string" ? oneLine(b.arguments, 150) : "";
1338
+ collect(calls, seen, `- ${name2}(${args})`, 30);
1339
+ } else if (b?.type === "tool-result") {
1340
+ const text2 = textOf(b.content);
1341
+ if (text2.length === 0) continue;
1342
+ if (b.isError === true) collect(errors, seen, `- ${oneLine(text2, 300)}`, 15);
1343
+ else collect(calls, seen, `- 结果:${oneLine(text2.split("\n")[0] ?? "", 150)}`, 30);
1344
+ }
1345
+ }
1346
+ }
1347
+ if (realCounts !== void 0) {
1348
+ realCounts.requests = requests.length;
1349
+ realCounts.conclusions = conclusions.length;
1350
+ realCounts.errors = errors.length;
1351
+ realCounts.calls = calls.length;
1352
+ }
1353
+ for (let i = priorSummaries.length - 1; i >= 0; i -= 1) {
1354
+ absorbPriorSummary(priorSummaries[i], { requests, calls, errors, conclusions }, seen);
1355
+ }
1356
+ const all = [
1357
+ { title: "### 用户请求", lines: requests, weight: 3 },
1358
+ { title: "### 助手结论", lines: conclusions, weight: 2 },
1359
+ { title: "### 工具错误", lines: errors, weight: 1 },
1360
+ { title: "### 工具调用与结果", lines: calls, weight: 0 }
1361
+ ].filter((s) => s.lines.length > 0);
1362
+ const render = (sections) => {
1363
+ const out = ["## 已裁剪的历史(确定性摘要,无 LLM)"];
1364
+ for (const s of sections) out.push(s.title, ...s.lines);
1365
+ return out.join("\n");
1366
+ };
1367
+ let text = render(all);
1368
+ if (text.length <= maxChars) return text;
1369
+ const working = all.map((s) => ({ ...s, lines: [...s.lines] }));
1370
+ while (text.length > maxChars) {
1371
+ const target = working.filter((s) => s.lines.length > 0).sort((a, b) => a.weight - b.weight)[0];
1372
+ if (!target) break;
1373
+ target.lines.pop();
1374
+ text = render(working.filter((s) => s.lines.length > 0));
1375
+ }
1376
+ if (text.length <= maxChars) return text;
1377
+ return `${text.slice(0, maxChars - 1)}…`;
1378
+ }
1379
+ function summaryStats(text, realCounts) {
1380
+ const counts = { requests: 0, conclusions: 0, errors: 0, calls: 0 };
1381
+ let section;
1382
+ for (const rawLine of text.split("\n")) {
1383
+ const line = rawLine.trim();
1384
+ if (line.startsWith("### ")) {
1385
+ const title = line.slice(4).trim();
1386
+ if (title.startsWith("用户请求")) section = "requests";
1387
+ else if (title.startsWith("助手结论") || title.startsWith("早期结论")) section = "conclusions";
1388
+ else if (title.startsWith("工具错误")) section = "errors";
1389
+ else if (title.startsWith("工具调用")) section = "calls";
1390
+ else section = void 0;
1391
+ continue;
1392
+ }
1393
+ if (section !== void 0 && line.startsWith("- ")) counts[section] += 1;
1394
+ }
1395
+ const part = (label, total, real) => real !== void 0 && real !== total ? `${label} ${total}(真实 ${real})` : `${label} ${total}`;
1396
+ return `${part("请求", counts.requests, realCounts?.requests)} / ${part("结论", counts.conclusions, realCounts?.conclusions)} / ${part("错误", counts.errors, realCounts?.errors)} / ${part("调用", counts.calls, realCounts?.calls)}`;
1397
+ }
1398
+ function meterOf(engine, ctx) {
1399
+ const engineCtx = engine?.ctx;
1400
+ const own = safeGet(engineCtx, "tokenMeter");
1401
+ if (own !== void 0) return own;
1402
+ return safeGet(ctx, "tokenMeter");
1403
+ }
1404
+ function resolvePruner(ctx, agent) {
1405
+ const direct = safeGet(ctx, "toolResultPruner");
1406
+ if (typeof direct?.pruneSession === "function") {
1407
+ return direct;
1408
+ }
1409
+ const presets = safeGet(ctx, "agentPresets");
1410
+ try {
1411
+ const viaPreset = presets?.serviceFor?.(agent, "toolResultPruner");
1412
+ if (typeof viaPreset?.pruneSession === "function") {
1413
+ return viaPreset;
1414
+ }
1415
+ } catch {
1416
+ }
1417
+ try {
1418
+ const engine = presets?.serviceFor?.(agent, "compaction");
1419
+ const viaEngine = engine?.ctx?.get?.(
1420
+ "toolResultPruner"
1421
+ );
1422
+ if (typeof viaEngine?.pruneSession === "function") {
1423
+ return viaEngine;
1424
+ }
1425
+ } catch {
1426
+ }
1427
+ return void 0;
1428
+ }
1429
+ function pruneToolResults(deps) {
1430
+ const { ctx, agent, log } = deps;
1431
+ const session = agent?.session;
1432
+ if (session === void 0) return { ok: false, reason: "no-session" };
1433
+ const pruner = resolvePruner(ctx, agent);
1434
+ if (typeof pruner?.pruneSession !== "function") {
1435
+ return { ok: false, reason: "pruner-unavailable" };
1436
+ }
1437
+ const originalConfig = pruner.config;
1438
+ let patched = false;
1439
+ if (deps.thresholdChars !== void 0 && originalConfig !== void 0) {
1440
+ const threshold = deps.thresholdChars;
1441
+ const head = Math.min(2048, Math.max(0, threshold - 600));
1442
+ const tail = Math.min(512, Math.max(0, threshold - head - 100));
1443
+ try {
1444
+ pruner.config = { ...originalConfig, thresholdChars: threshold, headChars: head, tailChars: tail };
1445
+ patched = true;
1446
+ } catch {
1447
+ }
1448
+ }
1449
+ try {
1450
+ const result = pruner.pruneSession(session);
1451
+ const count = Array.isArray(result?.pruned) ? result.pruned.length : 0;
1452
+ const charsRemoved = result?.charsRemoved ?? 0;
1453
+ if (count > 0) {
1454
+ log(`[lrc][prune] 裁剪 ${count} 条工具输出(省 ${charsRemoved} 字符)`);
1455
+ }
1456
+ return { ok: true, reason: "ok", pruned: count, charsRemoved };
1457
+ } catch (error) {
1458
+ return {
1459
+ ok: false,
1460
+ reason: `error:${error instanceof Error ? error.message : String(error)}`
1461
+ };
1462
+ } finally {
1463
+ if (patched) pruner.config = originalConfig;
1464
+ }
1465
+ }
1466
+ function appendReplace(session, content, seq) {
1467
+ const append = session?.append;
1468
+ if (typeof append !== "function") throw new Error("session.append unavailable");
1469
+ if (content.some((block) => block?.type === "tool-call")) {
1470
+ return false;
1471
+ }
1472
+ append.call(
1473
+ session,
1474
+ "user/message",
1475
+ // 必须带 `role: 'user'` 与 `id`:宿主 `createUserMessage` 会补这两个字段,
1476
+ // 而 `deriveEventMessage` 对 user/message 是**原样返回 data**,
1477
+ // 缺 role 会让 LLM 适配器拿不到消息角色。
1478
+ { role: "user", id: randomUUID(), content, source: { kind: "lrc-prune" } },
1479
+ {
1480
+ surfaceOp: { op: "replace", startSeq: seq, endSeq: seq },
1481
+ sourceEventSeqs: [seq]
1482
+ }
1483
+ );
1484
+ return true;
1485
+ }
1486
+ function pruneToolCallArguments(deps) {
1487
+ const { agent, log } = deps;
1488
+ const thresholdChars = deps.options?.thresholdChars ?? 4e3;
1489
+ const headChars = deps.options?.headChars ?? 2e3;
1490
+ const keepRecentNodes = deps.options?.keepRecentNodes ?? 8;
1491
+ const session = agent?.session;
1492
+ if (session === void 0) return { ok: false, reason: "no-session" };
1493
+ const nodes = session?.surface?.nodes;
1494
+ const events = resolveSessionEvents(session);
1495
+ const append = session?.append;
1496
+ if (!Array.isArray(nodes) || events === void 0 || typeof append !== "function") {
1497
+ return { ok: false, reason: "no-range" };
1498
+ }
1499
+ return { ok: false, reason: "incompatible-with-host-surface" };
1500
+ }
1501
+ function pruneReasoning(deps) {
1502
+ const { agent, log } = deps;
1503
+ const keepRecentNodes = deps.options?.keepRecentNodes ?? 8;
1504
+ const session = agent?.session;
1505
+ if (session === void 0) return { ok: false, reason: "no-session" };
1506
+ const nodes = session?.surface?.nodes;
1507
+ const events = resolveSessionEvents(session);
1508
+ if (!Array.isArray(nodes) || events === void 0) {
1509
+ return { ok: false, reason: "no-range" };
1510
+ }
1511
+ const cutoff = nodes.length - keepRecentNodes;
1512
+ let pruned = 0;
1513
+ let charsRemoved = 0;
1514
+ try {
1515
+ for (let index = 0; index < cutoff; index += 1) {
1516
+ const seq = nodes[index];
1517
+ const event = events[seq];
1518
+ if (event?.type !== "assistant/message") continue;
1519
+ const message = event.data?.message;
1520
+ const content = message?.content;
1521
+ if (!Array.isArray(content)) continue;
1522
+ const reasoningBlocks = content.filter(
1523
+ (block) => block?.type === "reasoning"
1524
+ );
1525
+ if (reasoningBlocks.length === 0) continue;
1526
+ if (reasoningBlocks.length === content.length) continue;
1527
+ const nextContent = content.filter(
1528
+ (block) => block?.type !== "reasoning"
1529
+ );
1530
+ if (appendReplace(session, nextContent, seq)) {
1531
+ for (const block of reasoningBlocks) {
1532
+ const text = block.text;
1533
+ if (typeof text === "string") charsRemoved += text.length;
1534
+ }
1535
+ pruned += 1;
1536
+ }
1537
+ }
1538
+ } catch (error) {
1539
+ return {
1540
+ ok: false,
1541
+ reason: `error:${error instanceof Error ? error.message : String(error)}`,
1542
+ pruned,
1543
+ charsRemoved
1544
+ };
1545
+ }
1546
+ if (pruned > 0) {
1547
+ log(`[lrc][prune-reasoning] 丢弃 ${pruned} 条旧推理(省 ${charsRemoved} 字符)`);
1548
+ }
1549
+ return { ok: true, reason: "ok", pruned, charsRemoved };
1550
+ }
1551
+ async function trimContext(deps) {
1552
+ const { ctx, agent, signal, options, log } = deps;
1553
+ const presets = safeGet(ctx, "agentPresets");
1554
+ let engine;
1555
+ try {
1556
+ engine = presets?.serviceFor?.(agent, "compaction");
1557
+ } catch (error) {
1558
+ return { ok: false, reason: `error:serviceFor ${String(error)}` };
1559
+ }
1560
+ const compactRegion = engine?.compactRegion;
1561
+ if (typeof compactRegion !== "function") {
1562
+ return {
1563
+ ok: false,
1564
+ reason: "engine-unavailable",
1565
+ detail: `serviceFor=${typeof presets?.serviceFor} engine=${engine === void 0 ? "undefined" : "present"}`
1566
+ };
1567
+ }
1568
+ const session = agent?.session;
1569
+ const nodes = session?.surface?.nodes;
1570
+ const events = resolveSessionEvents(session);
1571
+ if (!Array.isArray(nodes) || events === void 0) {
1572
+ return {
1573
+ ok: false,
1574
+ reason: "no-range",
1575
+ detail: `nodes=${Array.isArray(nodes) ? nodes.length : "missing"} events=${events === void 0 ? "missing" : events.length}`
1576
+ };
1577
+ }
1578
+ const range = selectTrimRange(nodes, events, options.keepRecentNodes);
1579
+ if (range === null) {
1580
+ return {
1581
+ ok: false,
1582
+ reason: "no-range",
1583
+ detail: `nodes=${nodes.length} keepRecent=${options.keepRecentNodes}(节点太少或找不到平衡切点)`
1584
+ };
1585
+ }
1586
+ const meter = meterOf(engine, ctx);
1587
+ let totalTokens = 0;
1588
+ let shadowedTokens = 0;
1589
+ try {
1590
+ const measurement = meter?.measure?.(session);
1591
+ totalTokens = measurement?.totalTokens ?? 0;
1592
+ const priced = measurement?.nodes;
1593
+ if (Array.isArray(priced)) {
1594
+ for (let i = range.startIdx; i <= range.endIdx; i += 1) {
1595
+ shadowedTokens += priced[i]?.tokens ?? 0;
1596
+ }
1597
+ }
1598
+ } catch {
1599
+ }
1600
+ if (!options.force && totalTokens < options.minTokens) {
1601
+ return {
1602
+ ok: false,
1603
+ reason: "below-threshold",
1604
+ detail: `totalTokens=${totalTokens} min=${options.minTokens} meter=${meter === void 0 ? "missing" : "present"}`
1605
+ };
1606
+ }
1607
+ const target = engine;
1608
+ if (typeof target.summarize !== "function") {
1609
+ return {
1610
+ ok: false,
1611
+ reason: "engine-unavailable",
1612
+ detail: `summarize=${typeof target.summarize}`
1613
+ };
1614
+ }
1615
+ const hadOwn = Object.prototype.hasOwnProperty.call(target, "summarize");
1616
+ const original = target.summarize;
1617
+ let summaryChars = 0;
1618
+ let summaryText = "";
1619
+ let summaryRealCounts = {
1620
+ requests: 0,
1621
+ conclusions: 0,
1622
+ errors: 0,
1623
+ calls: 0
1624
+ };
1625
+ target.summarize = async (input) => {
1626
+ const messages = input?.messages;
1627
+ const list = Array.isArray(messages) ? messages : [];
1628
+ let sourceChars = 0;
1629
+ for (const message of list) {
1630
+ const content = message?.content;
1631
+ if (Array.isArray(content)) {
1632
+ for (const block of content) {
1633
+ const b = block;
1634
+ if (typeof b?.text === "string") sourceChars += b.text.length;
1635
+ if (typeof b?.arguments === "string") sourceChars += b.arguments.length;
1636
+ }
1637
+ }
1638
+ }
1639
+ const cap = Math.max(200, Math.min(options.maxSummaryChars, Math.floor(sourceChars / 4)));
1640
+ const text = buildDeterministicSummary(list, cap, summaryRealCounts);
1641
+ summaryChars = text.length;
1642
+ summaryText = text;
1643
+ return {
1644
+ summary: [{ type: "text", text }],
1645
+ provider: "lrc",
1646
+ model: "deterministic"
1647
+ };
1648
+ };
1649
+ try {
1650
+ await target.compactRegion(range.start, range.end, agent, signal);
1651
+ log(
1652
+ `[lrc][trim] 已裁剪 ${range.endIdx - range.startIdx + 1} 个节点(总 ${totalTokens} tokens,${options.force ? "节点数强制" : "超阈值"};约 ${shadowedTokens} tokens → 摘要 ${summaryChars} 字符:${summaryStats(summaryText, summaryRealCounts)})`
1653
+ );
1654
+ return { ok: true, reason: "ok", shadowedTokens, summaryChars, summaryStats: summaryStats(summaryText, summaryRealCounts) };
1655
+ } catch (error) {
1656
+ const message = error instanceof Error ? error.message : String(error);
1657
+ if (message.includes("holds the system prompt")) {
1658
+ const retryIdx = findRetryStart(nodes, events, range.startIdx, range.endIdx);
1659
+ if (retryIdx !== null) {
1660
+ try {
1661
+ await target.compactRegion(nodes[retryIdx], range.end, agent, signal);
1662
+ log(
1663
+ `[lrc][trim] 起点回退后成功(${retryIdx}..${range.endIdx})(总 ${totalTokens} tokens,${options.force ? "节点数强制" : "超阈值"};约 ${shadowedTokens} tokens → 摘要 ${summaryChars} 字符:${summaryStats(summaryText, summaryRealCounts)})`
1664
+ );
1665
+ return { ok: true, reason: "ok", shadowedTokens, summaryChars, summaryStats: summaryStats(summaryText, summaryRealCounts) };
1666
+ } catch (retryError) {
1667
+ return {
1668
+ ok: false,
1669
+ reason: `error:${retryError instanceof Error ? retryError.message : String(retryError)}`,
1670
+ detail: `retry range=${retryIdx}..${range.endIdx}`
1671
+ };
1672
+ }
1673
+ }
1674
+ }
1675
+ return {
1676
+ ok: false,
1677
+ reason: `error:${message}`,
1678
+ detail: `range=${range.startIdx}..${range.endIdx} shadowedTokens=${shadowedTokens}`
1679
+ };
1680
+ } finally {
1681
+ if (hadOwn) target.summarize = original;
1682
+ else delete target.summarize;
1683
+ }
1684
+ }
1685
+
1686
+ // src/handoff.ts
1687
+ function buildHandoffText(input) {
1688
+ const sections = ["# 会话交接", ""];
1689
+ sections.push("## 当前任务");
1690
+ sections.push(input.lastUserRequest.trim().length > 0 ? input.lastUserRequest.trim() : "(未记录)");
1691
+ sections.push("");
1692
+ if (input.recentAssistant.length > 0) {
1693
+ sections.push("## 最近结论");
1694
+ for (const line of input.recentAssistant) {
1695
+ const flat = line.replace(/\s+/g, " ").trim();
1696
+ if (flat.length > 0) sections.push(`- ${flat.length > 300 ? `${flat.slice(0, 300)}…` : flat}`);
1697
+ }
1698
+ sections.push("");
1699
+ }
1700
+ if (input.snapshot.trim().length > 0) {
1701
+ sections.push("## 已沉淀状态");
1702
+ sections.push(input.snapshot.trim());
1703
+ sections.push("");
1704
+ }
1705
+ if (input.indexLines.length > 0) {
1706
+ sections.push("## 长期记忆索引");
1707
+ for (const line of input.indexLines) sections.push(`- ${line}`);
1708
+ sections.push("");
1709
+ }
1710
+ sections.push("## 下一步");
1711
+ sections.push(input.note.trim().length > 0 ? input.note.trim() : "(由接手方根据上述上下文继续)");
1712
+ return sections.join("\n");
1713
+ }
1714
+ function extractHandoffSource(session) {
1715
+ const log = session?.log;
1716
+ if (!Array.isArray(log)) return { lastUserRequest: "", recentAssistant: [] };
1717
+ let lastUserRequest = "";
1718
+ const assistant = [];
1719
+ for (const raw of log) {
1720
+ const event = raw;
1721
+ if (event?.type === "user/message") {
1722
+ const kind = event.data?.source?.kind;
1723
+ if (kind !== void 0 && kind !== "user") continue;
1724
+ const text = textOf2(event.data?.content);
1725
+ if (text.length > 0) lastUserRequest = text;
1726
+ } else if (event?.type === "assistant/message") {
1727
+ const text = textOf2(event.data?.message?.content);
1728
+ if (text.length > 0) assistant.push(text);
1729
+ }
1730
+ }
1731
+ return { lastUserRequest, recentAssistant: assistant.slice(-5) };
1732
+ }
1733
+ function textOf2(content) {
1734
+ if (typeof content === "string") return content;
1735
+ if (!Array.isArray(content)) return "";
1736
+ const out = [];
1737
+ for (const block of content) {
1738
+ const b = block;
1739
+ if (b?.type === "text" && typeof b.text === "string" && b.text.length > 0) out.push(b.text);
1740
+ }
1741
+ return out.join("\n");
1742
+ }
1743
+ function safeGet2(ctx, name2) {
1744
+ try {
1745
+ return ctx?.get?.(name2);
1746
+ } catch {
1747
+ return void 0;
1748
+ }
1749
+ }
1750
+ async function createHandoffSession(deps) {
1751
+ const { ctx, agent, text, log } = deps;
1752
+ const current = agent;
1753
+ const currentId = current?.session?.id;
1754
+ const cwd = current?.session?.header?.cwd;
1755
+ const meta = {
1756
+ ...cwd === void 0 ? {} : { cwd },
1757
+ ...currentId === void 0 ? {} : { parentSession: currentId }
1758
+ };
1759
+ const userMessage = {
1760
+ content: [{ type: "text", text }],
1761
+ source: { kind: "user" }
1762
+ };
1763
+ const agents = safeGet2(ctx, "agents");
1764
+ if (typeof agents?.create === "function") {
1765
+ try {
1766
+ const handle = await agents.create({
1767
+ sessionId: `session-handoff-${Date.now()}`,
1768
+ meta,
1769
+ ...current?.options === void 0 ? {} : { agentOptions: current.options }
1770
+ });
1771
+ const session = handle?.agent?.session;
1772
+ if (typeof session?.append === "function") {
1773
+ session.append("user/message", userMessage);
1774
+ log(`[lrc][handoff] 已创建带 agent 的新会话:${session.id ?? "unknown"}`);
1775
+ return { ok: true, sessionId: session.id, mode: "agent", text };
1776
+ }
1777
+ } catch (error) {
1778
+ log(
1779
+ `[lrc][handoff] agents.create 失败,退回 sessions.create:${error instanceof Error ? error.message : String(error)}`
1780
+ );
1781
+ }
1782
+ }
1783
+ const sessions = safeGet2(ctx, "sessions");
1784
+ if (typeof sessions?.create === "function") {
1785
+ try {
1786
+ const session = sessions.create(void 0, { meta });
1787
+ if (typeof session?.append === "function") {
1788
+ session.append("user/message", userMessage);
1789
+ log(`[lrc][handoff] 已创建新会话(无 agent):${session.id ?? "unknown"}`);
1790
+ return { ok: true, sessionId: session.id, mode: "session", text };
1791
+ }
1792
+ } catch (error) {
1793
+ log(
1794
+ `[lrc][handoff] sessions.create 失败,只返回摘要:${error instanceof Error ? error.message : String(error)}`
1795
+ );
1796
+ }
1797
+ }
1798
+ return { ok: false, mode: "text-only", text, reason: "sessions-unavailable" };
1799
+ }
1800
+
1801
+ // src/capabilities.ts
1802
+ function safeGet3(ctx, name2) {
1803
+ try {
1804
+ return ctx?.get?.(name2);
1805
+ } catch {
1806
+ return void 0;
1807
+ }
1808
+ }
1809
+ function hasFn(value, name2) {
1810
+ return typeof value?.[name2] === "function";
1811
+ }
1812
+ function fromEngine(engine, name2) {
1813
+ try {
1814
+ return engine?.ctx?.get?.(name2);
1815
+ } catch {
1816
+ return void 0;
1817
+ }
1818
+ }
1819
+ function probeSurfaceOpShape(session) {
1820
+ const events = resolveSessionEvents(session);
1821
+ if (events === void 0) return "unknown";
1822
+ for (const raw of events) {
1823
+ const op = raw?.surfaceOp;
1824
+ if (op === null || typeof op !== "object") continue;
1825
+ const record = op;
1826
+ if (record["op"] !== "replace") continue;
1827
+ if (Object.hasOwn(record, "start") && Object.hasOwn(record, "end")) return "start-end";
1828
+ if (Object.hasOwn(record, "startSeq") && Object.hasOwn(record, "endSeq")) return "startSeq-endSeq";
1829
+ }
1830
+ return "unknown";
1831
+ }
1832
+ function probeCapabilities(ctx, agent) {
1833
+ const presets = safeGet3(ctx, "agentPresets");
1834
+ let engine;
1835
+ let engineError = "";
1836
+ try {
1837
+ engine = presets?.serviceFor?.(agent, "compaction");
1838
+ } catch (error) {
1839
+ engineError = error instanceof Error ? error.message : String(error);
1840
+ }
1841
+ const compaction = {
1842
+ available: hasFn(engine, "compactRegion"),
1843
+ detail: engineError !== "" ? `serviceFor 抛错:${engineError}` : engine === void 0 ? "serviceFor 返回 undefined" : `引擎=${engine?.constructor?.name ?? "unknown"}`
1844
+ };
1845
+ const meter = fromEngine(engine, "tokenMeter") ?? safeGet3(ctx, "tokenMeter");
1846
+ const tokenMeter = {
1847
+ available: hasFn(meter, "measure"),
1848
+ detail: meter === void 0 ? "tokenMeter 不可达" : "measure 可用"
1849
+ };
1850
+ const pruner = safeGet3(ctx, "toolResultPruner") ?? (() => {
1851
+ try {
1852
+ return presets?.serviceFor?.(agent, "toolResultPruner");
1853
+ } catch {
1854
+ return void 0;
1855
+ }
1856
+ })() ?? fromEngine(engine, "toolResultPruner");
1857
+ const toolResultPruner = {
1858
+ available: hasFn(pruner, "pruneSession"),
1859
+ detail: pruner === void 0 ? "toolResultPruner 不可达" : "pruneSession 可用"
1860
+ };
1861
+ const sessions = safeGet3(ctx, "sessions");
1862
+ const agents = safeGet3(ctx, "agents");
1863
+ const systemPrompt = safeGet3(ctx, "systemPrompt");
1864
+ return {
1865
+ compaction,
1866
+ tokenMeter,
1867
+ toolResultPruner,
1868
+ sessions: {
1869
+ available: hasFn(sessions, "create"),
1870
+ detail: sessions === void 0 ? "sessions 不可达" : "create 可用"
1871
+ },
1872
+ agents: {
1873
+ available: hasFn(agents, "create"),
1874
+ detail: agents === void 0 ? "agents 不可达" : "create 可用"
1875
+ },
1876
+ systemPrompt: {
1877
+ available: hasFn(systemPrompt, "section") || hasFn(systemPrompt, "context"),
1878
+ detail: systemPrompt === void 0 ? "systemPrompt 不可达" : "section/context 可用"
1879
+ },
1880
+ surfaceOpShape: probeSurfaceOpShape(agent?.session),
1881
+ probedAt: Date.now()
1882
+ };
1883
+ }
1884
+ function formatCapabilities(matrix) {
1885
+ const rows = [
1886
+ ["compaction", matrix.compaction],
1887
+ ["tokenMeter", matrix.tokenMeter],
1888
+ ["toolResultPruner", matrix.toolResultPruner],
1889
+ ["sessions", matrix.sessions],
1890
+ ["agents", matrix.agents],
1891
+ ["systemPrompt", matrix.systemPrompt]
1892
+ ];
1893
+ const lines = rows.map(
1894
+ ([name2, cap]) => `[lrc][cap] ${cap.available ? "可用 " : "不可用"} ${name2} | ${cap.detail}`
1895
+ );
1896
+ lines.push(`[lrc][cap] surfaceOp 形状:${matrix.surfaceOpShape}`);
1897
+ return lines;
1898
+ }
1899
+
1900
+ // src/budget.ts
1901
+ var MIN_SANE_WINDOW = 8192;
1902
+ var MAX_TRUSTED_WINDOW = 1e6;
1903
+ function clamp(value, min, max) {
1904
+ return Math.min(max, Math.max(min, value));
1905
+ }
1906
+ function safeGet4(ctx, name2) {
1907
+ try {
1908
+ return ctx?.get?.(name2);
1909
+ } catch {
1910
+ return void 0;
1911
+ }
1912
+ }
1913
+ function routedTarget(session) {
1914
+ const config = session?.requestHeader?.()?.config;
1915
+ const provider = config?.provider;
1916
+ const model = config?.model;
1917
+ if (typeof provider !== "string" || typeof model !== "string") return void 0;
1918
+ if (provider.length === 0 || model.length === 0) return void 0;
1919
+ return { provider, model };
1920
+ }
1921
+ async function resolveBudget(deps) {
1922
+ const { ctx, agent, config } = deps;
1923
+ if (config.contextWindowTokens > 0) {
1924
+ return derive(config.contextWindowTokens, config, "configured");
1925
+ }
1926
+ const session = agent?.session;
1927
+ const target = routedTarget(session);
1928
+ if (target === void 0) {
1929
+ return {
1930
+ windowTokens: 0,
1931
+ trimMinTokens: config.trimMinTokens,
1932
+ pruneResultThreshold: config.pruneResultThreshold,
1933
+ source: "fallback"
1934
+ };
1935
+ }
1936
+ const llm = safeGet4(ctx, "llm");
1937
+ if (typeof llm?.resolveModelInfo !== "function") {
1938
+ return {
1939
+ windowTokens: 0,
1940
+ trimMinTokens: config.trimMinTokens,
1941
+ pruneResultThreshold: config.pruneResultThreshold,
1942
+ source: "fallback"
1943
+ };
1944
+ }
1945
+ try {
1946
+ const info = await llm.resolveModelInfo(target.provider, target.model);
1947
+ const window = info?.context?.contextWindow;
1948
+ if (typeof window === "number" && Number.isFinite(window) && window > 0) {
1949
+ if (window >= MAX_TRUSTED_WINDOW) {
1950
+ return {
1951
+ windowTokens: 0,
1952
+ trimMinTokens: config.trimMinTokens,
1953
+ pruneResultThreshold: config.pruneResultThreshold,
1954
+ source: "fallback",
1955
+ model: target.model,
1956
+ // 提示可显式配置:哨兵值无法区分「真实 1M 窗口」与「未知模型」,
1957
+ // 若确知模型窗口,配置 contextWindowTokens 即可按真实窗口推导阈值。
1958
+ note: `probe=${window} 达到可信上限,视为未知模型;如确知窗口请显式配置 contextWindowTokens`
1959
+ };
1960
+ }
1961
+ const sane = Math.max(MIN_SANE_WINDOW, window);
1962
+ return { ...derive(sane, config, "auto"), model: target.model };
1963
+ }
1964
+ } catch {
1965
+ }
1966
+ return {
1967
+ windowTokens: 0,
1968
+ trimMinTokens: config.trimMinTokens,
1969
+ pruneResultThreshold: config.pruneResultThreshold,
1970
+ source: "fallback"
1971
+ };
1972
+ }
1973
+ function derive(windowTokens, config, source) {
1974
+ return {
1975
+ windowTokens,
1976
+ trimMinTokens: clamp(Math.floor(windowTokens * config.trimRatio), 8e3, 2e5),
1977
+ pruneResultThreshold: clamp(Math.floor(windowTokens * config.pruneRatio), 2048, 8192),
1978
+ source
1979
+ };
1980
+ }
1981
+ function formatBudget(budget) {
1982
+ const window = budget.windowTokens > 0 ? `${budget.windowTokens} tokens` : "未知";
1983
+ const model = budget.model === void 0 ? "" : ` model=${budget.model}`;
1984
+ const note = budget.note === void 0 ? "" : ` note=${budget.note}`;
1985
+ return `[lrc][budget] 窗口=${window}(${budget.source})${model}${note} trim=${budget.trimMinTokens} tokens prune=${budget.pruneResultThreshold} 字符`;
1986
+ }
1987
+
1988
+ // src/context-profile.ts
1989
+ var EMPTY = {
1990
+ system: 0,
1991
+ injected: 0,
1992
+ user: 0,
1993
+ assistantText: 0,
1994
+ reasoning: 0,
1995
+ toolArgs: 0,
1996
+ toolResult: 0
1997
+ };
1998
+ function textChars(content) {
1999
+ if (typeof content === "string") return content.length;
2000
+ if (!Array.isArray(content)) return 0;
2001
+ let chars = 0;
2002
+ for (const block of content) {
2003
+ const b = block;
2004
+ if (b?.type === "text" && typeof b.text === "string") chars += b.text.length;
2005
+ }
2006
+ return chars;
2007
+ }
2008
+ function reasoningChars(content) {
2009
+ if (!Array.isArray(content)) return 0;
2010
+ let chars = 0;
2011
+ for (const block of content) {
2012
+ const b = block;
2013
+ if (b?.type === "reasoning" && typeof b.text === "string") chars += b.text.length;
2014
+ }
2015
+ return chars;
2016
+ }
2017
+ function toolArgsChars(content) {
2018
+ if (!Array.isArray(content)) return 0;
2019
+ let chars = 0;
2020
+ for (const block of content) {
2021
+ const b = block;
2022
+ if (b?.type === "tool-call" && typeof b.arguments === "string") chars += b.arguments.length;
2023
+ }
2024
+ return chars;
2025
+ }
2026
+ function profileSurface(session) {
2027
+ const nodes = session?.surface?.nodes;
2028
+ const events = resolveSessionEvents(session);
2029
+ if (!Array.isArray(nodes) || events === void 0) {
2030
+ return { nodes: 0, totalChars: 0, checkpoints: 0, byCategory: { ...EMPTY }, shares: { ...EMPTY } };
2031
+ }
2032
+ const byCategory = { ...EMPTY };
2033
+ let checkpoints = 0;
2034
+ for (const seq of nodes) {
2035
+ const event = events[seq];
2036
+ if (event === void 0) continue;
2037
+ if (isSummaryNode(event)) checkpoints += 1;
2038
+ const category = classifyEvent(event);
2039
+ if (category === "system") {
2040
+ byCategory.system += textChars(event.data?.content ?? event.data?.message?.content);
2041
+ } else if (category === "injected") {
2042
+ byCategory.injected += textChars(event.data?.content);
2043
+ } else if (category === "user") {
2044
+ byCategory.user += textChars(event.data?.content);
2045
+ } else if (category === "assistant") {
2046
+ const content = event.data?.message?.content;
2047
+ byCategory.assistantText += textChars(content);
2048
+ byCategory.reasoning += reasoningChars(content);
2049
+ byCategory.toolArgs += toolArgsChars(content);
2050
+ } else if (category === "tool-result") {
2051
+ const content = event.data?.message?.content;
2052
+ byCategory.toolResult += textChars(content);
2053
+ if (Array.isArray(content)) {
2054
+ for (const block of content) {
2055
+ const nested = block?.content;
2056
+ if (nested !== void 0) byCategory.toolResult += textChars(nested);
2057
+ }
2058
+ }
2059
+ }
2060
+ }
2061
+ const totalChars = Object.values(byCategory).reduce((sum, value) => sum + value, 0);
2062
+ const shares = { ...EMPTY };
2063
+ if (totalChars > 0) {
2064
+ for (const key of Object.keys(byCategory)) {
2065
+ shares[key] = byCategory[key] / totalChars;
2066
+ }
2067
+ }
2068
+ return { nodes: nodes.length, totalChars, checkpoints, byCategory, shares };
2069
+ }
2070
+ function formatProfile(profile) {
2071
+ const pct = (value) => `${Math.round(value * 100)}%`;
2072
+ const parts = [
2073
+ `toolResult ${pct(profile.shares.toolResult)}`,
2074
+ `reasoning ${pct(profile.shares.reasoning)}`,
2075
+ `toolArgs ${pct(profile.shares.toolArgs)}`,
2076
+ `user ${pct(profile.shares.user)}`,
2077
+ `injected ${pct(profile.shares.injected)}`,
2078
+ `system ${pct(profile.shares.system)}`,
2079
+ `assistantText ${pct(profile.shares.assistantText)}`
2080
+ ];
2081
+ const warn = profile.checkpoints > 3 ? " ⚠摘要堆叠" : "";
2082
+ return `[lrc][profile] 节点=${profile.nodes} 总字符=${profile.totalChars} checkpoint=${profile.checkpoints}${warn} | ${parts.join(" ")}`;
2083
+ }
2084
+
2085
+ // src/governor.ts
2086
+ var MAX_CONSECUTIVE_FAILURES = 3;
2087
+ function createGovernorState() {
2088
+ return {
2089
+ capabilities: null,
2090
+ steps: 0,
2091
+ failures: {},
2092
+ disabled: /* @__PURE__ */ new Set(),
2093
+ lastOutcome: {},
2094
+ lastRunStep: {},
2095
+ runs: {}
2096
+ };
2097
+ }
2098
+ async function runGovernor(deps) {
2099
+ const { state, actions, log, warn } = deps;
2100
+ state.steps += 1;
2101
+ const executed = [];
2102
+ for (const action of actions) {
2103
+ if (!action.enabled) continue;
2104
+ if (state.disabled.has(action.name)) continue;
2105
+ const throttle = action.throttleSteps ?? 1;
2106
+ const last = state.lastRunStep[action.name];
2107
+ if (last !== void 0 && state.steps - last < throttle) continue;
2108
+ state.lastRunStep[action.name] = state.steps;
2109
+ state.runs[action.name] = (state.runs[action.name] ?? 0) + 1;
2110
+ executed.push(action.name);
2111
+ let outcome;
2112
+ try {
2113
+ outcome = await action.run();
2114
+ } catch (error) {
2115
+ outcome = {
2116
+ ok: false,
2117
+ reason: `error:${error instanceof Error ? error.message : String(error)}`
2118
+ };
2119
+ }
2120
+ state.lastOutcome[action.name] = outcome;
2121
+ if (outcome.ok) {
2122
+ state.failures[action.name] = 0;
2123
+ continue;
2124
+ }
2125
+ if (!outcome.reason.startsWith("error")) continue;
2126
+ const failures = (state.failures[action.name] ?? 0) + 1;
2127
+ state.failures[action.name] = failures;
2128
+ if (failures >= MAX_CONSECUTIVE_FAILURES) {
2129
+ state.disabled.add(action.name);
2130
+ warn(
2131
+ `[lrc][governor] ${action.name} 连续失败 ${failures} 次,已熔断(本次会话不再尝试):${outcome.reason}`
2132
+ );
2133
+ } else {
2134
+ warn(
2135
+ `[lrc][governor] ${action.name} 失败(${failures}/${MAX_CONSECUTIVE_FAILURES}):${outcome.reason}`
2136
+ );
2137
+ }
2138
+ }
2139
+ if (executed.length > 0) {
2140
+ log(`[lrc][governor] 第 ${state.steps} 步执行:${executed.join(" → ")}`);
2141
+ }
2142
+ return executed;
2143
+ }
2144
+ function formatGovernorState(state) {
2145
+ const lines = [`步数:${state.steps}`];
2146
+ const names = /* @__PURE__ */ new Set([
2147
+ ...Object.keys(state.runs),
2148
+ ...Object.keys(state.lastOutcome),
2149
+ ...state.disabled
2150
+ ]);
2151
+ if (names.size === 0) {
2152
+ lines.push("尚未执行任何动作");
2153
+ return lines.join("\n");
2154
+ }
2155
+ for (const name2 of names) {
2156
+ const outcome = state.lastOutcome[name2];
2157
+ const disabled = state.disabled.has(name2) ? " [已熔断]" : "";
2158
+ const runs = state.runs[name2] ?? 0;
2159
+ const failures = state.failures[name2] ?? 0;
2160
+ lines.push(
2161
+ `- ${name2}${disabled}:执行 ${runs} 次,连续失败 ${failures}${outcome === void 0 ? "" : `,最近 ${outcome.ok ? "成功" : outcome.reason}`}${outcome?.detail === void 0 ? "" : `(${outcome.detail})`}`
2162
+ );
2163
+ }
2164
+ return lines.join("\n");
2165
+ }
2166
+
2167
+ // src/index.ts
2168
+ var name = "dsh-plugin-lrc";
2169
+ var inject = ["tools"];
2170
+ var Config = Schema.object({
2171
+ baseUrl: Schema.string().default(""),
2172
+ token: Schema.string().default(""),
2173
+ project: Schema.string().default("dsh"),
2174
+ offloadTurns: Schema.number().default(8),
2175
+ offloadChars: Schema.number().default(6e3),
2176
+ // 20 步对工具密集型轮次太晚:实测 16 步的会话一次都没触发,
2177
+ // 快照始终为空。降到 8 步,让中途也能沉淀出 `[LRC 状态]`。
2178
+ offloadSteps: Schema.number().default(8),
2179
+ maxItems: Schema.number().default(40),
2180
+ pruneEnabled: Schema.boolean().default(true),
2181
+ pruneResultThreshold: Schema.number().default(4096),
2182
+ // ★2026-11-07:默认关闭。宿主 surface 校验不允许替换含 tool-call 的
2183
+ // assistant 节点(会破坏 tool-pairing 平衡),本路径在当前宿主版本下不可用。
2184
+ // 保留配置项,等宿主提供「原地改参数」能力后再启用。
2185
+ pruneArgsEnabled: Schema.boolean().default(false),
2186
+ pruneArgsThreshold: Schema.number().default(4e3),
2187
+ pruneArgsHead: Schema.number().default(2e3),
2188
+ pruneReasoningEnabled: Schema.boolean().default(true),
2189
+ // 统一保留策略:trim 与 prune-reasoning 共用,避免两套 keep-recent 漂移
2190
+ keepRecentNodes: Schema.number().default(8),
2191
+ trimEnabled: Schema.boolean().default(true),
2192
+ // 阈值按模型窗口比例推导:1M 窗口 → trim 30000 / prune 4000;
2193
+ // 128K 窗口 → trim 8000(下限)/ prune 2048(下限)。
2194
+ contextWindowTokens: Schema.number().default(0),
2195
+ trimRatio: Schema.number().default(0.03),
2196
+ pruneRatio: Schema.number().default(4e-3),
2197
+ // 窗口探测失败时的绝对值兜底
2198
+ trimMinTokens: Schema.number().default(3e4),
2199
+ trimMinSteps: Schema.number().default(10),
2200
+ // ★2026-11-08:节点数硬上限兜底。token 计量一旦失真(宿主换版本 / meter 返回 0 /
2201
+ // 阈值配错),纯 token 触发会让上下文无限增长;节点数不依赖任何服务,用它兜底。
2202
+ // 实测正常稳态 31–72 节点,120 只在异常时触发。
2203
+ trimMaxNodes: Schema.number().default(120),
2204
+ // ★2026-11-07:2000 → 4000。实测 2000 字符的摘要会把关键结论截掉,
2205
+ // 用户反馈「关键信息基本都丢了」;4000 仍远小于被裁剪区间(约 9k tokens)。
2206
+ trimMaxSummaryChars: Schema.number().default(4e3)
2207
+ });
2208
+ var PROMPT_SECTION = "lrc:memory-capture";
2209
+ var PROMPT_TEXT = [
2210
+ "## 长期记忆(LRC)",
2211
+ "",
2212
+ "涉及项目历史、用户偏好、之前做过的决定、上次怎么改的,**先调 `lrc_recall` 工具**,",
2213
+ "不要直接 Grep 或翻文件。检索为空时才回退内置搜索,并在回复中说明「LRC 未命中,已回退内置搜索」。",
2214
+ "",
2215
+ "发现某条记忆与事实不符时,用 `lrc_forget` 撤回(id 从 `lrc_recall` 输出里取),",
2216
+ "不要让它继续污染后续对话。",
2217
+ "",
2218
+ "## 长期记忆沉淀(必须遵守)",
2219
+ "",
2220
+ "每次回答的最后一行,必须输出一个 <lrc_memory> 块,格式固定:",
2221
+ "",
2222
+ "<lrc_memory>",
2223
+ "- 决策:…",
2224
+ "- 约束:…",
2225
+ "- 结论:…",
2226
+ "</lrc_memory>",
2227
+ "",
2228
+ "规则:",
2229
+ "- 只写本轮产生的决策、约束、最终结论;没有的条目直接省略。",
2230
+ "- 本轮没有任何值得长期保留的内容时,输出 <lrc_memory>无新增</lrc_memory>。",
2231
+ "- 只写结论,不写过程、待办清单、选项罗列或代码。",
2232
+ "- 标签必须出现在回答的最末尾,且每轮只出现一次。"
2233
+ ].join("\n");
2234
+ function apply(ctx, config) {
2235
+ const cacheDir = join2(process.env.DSH_HOME ?? join2(homedir2(), ".dsh"), "cache", "lrc-plugin");
2236
+ const snapshotPath = join2(cacheDir, "snapshot.json");
2237
+ function loadSnapshot() {
2238
+ try {
2239
+ if (!existsSync3(snapshotPath)) return "";
2240
+ const raw = JSON.parse(readFileSync3(snapshotPath, "utf8"));
2241
+ return typeof raw.text === "string" ? raw.text : "";
2242
+ } catch {
2243
+ return "";
2244
+ }
2245
+ }
2246
+ function saveSnapshot(text) {
2247
+ try {
2248
+ mkdirSync3(cacheDir, { recursive: true });
2249
+ writeFileSync3(snapshotPath, JSON.stringify({ text, updatedAt: Date.now() }), "utf8");
2250
+ } catch {
2251
+ }
2252
+ }
2253
+ const runtime = new LrcRuntime({
2254
+ // 配置里的 baseUrl 必须传下去,否则 patch 里写 3111 也会打到 3099
2255
+ baseUrl: config.baseUrl,
2256
+ cacheDir,
2257
+ log: (message) => ctx.logger.info(message)
2258
+ });
2259
+ let offloader = null;
2260
+ let client = null;
2261
+ let warnedShape = false;
2262
+ let warnedEmpty = false;
2263
+ let warnedNoConclusion = false;
2264
+ let flushing = false;
2265
+ let probed = false;
2266
+ let turnHits = 0;
2267
+ let turnMisses = 0;
2268
+ let turnNoText = 0;
2269
+ let turnHadText = false;
2270
+ let turnTagSeen = false;
2271
+ let turnReasoning = [];
2272
+ let turnReasoningChars = 0;
2273
+ let latestSnapshot = loadSnapshot();
2274
+ let snapshotServed = false;
2275
+ const governor = createGovernorState();
2276
+ let budget = null;
2277
+ const trimReasonsLogged = /* @__PURE__ */ new Set();
2278
+ let lastAgent;
2279
+ const systemPrompt = ctx.get("systemPrompt");
2280
+ if (typeof systemPrompt?.section === "function") {
2281
+ try {
2282
+ systemPrompt.section({ name: PROMPT_SECTION, order: 9e3, text: PROMPT_TEXT });
2283
+ systemPrompt.context?.({
2284
+ name: "lrc:snapshot",
2285
+ // ★2026-11-07(缓存命中率):order 取 9999,让 LRC 快照排在
2286
+ // runtime-context 快照的**最末尾**(宿主内置 context 最高 210)。
2287
+ // 宿主把整块 runtime-context 作为一条 user 消息 append 到 surface 尾部,
2288
+ // 因此快照位于历史之后、不打断前缀;order 只决定块内顺序。
2289
+ order: 9999,
2290
+ text: () => {
2291
+ if (latestSnapshot.length > 0 && !snapshotServed) {
2292
+ snapshotServed = true;
2293
+ ctx.logger.info(`[lrc] 快照已注入上下文(${latestSnapshot.length} 字符)`);
2294
+ }
2295
+ return latestSnapshot;
2296
+ }
2297
+ });
2298
+ } catch (error) {
2299
+ ctx.logger.warn(
2300
+ `[lrc] 系统提示注册失败(不影响沉淀):${error instanceof Error ? error.message : String(error)}`
2301
+ );
2302
+ }
2303
+ } else {
2304
+ ctx.logger.warn("[lrc] systemPrompt 服务不可用,跳过 <lrc_memory> 提示注入(沉淀仍走硬规则)");
2305
+ }
2306
+ ctx.effect(() => () => runtime.stop(), "lrc.runtime.stop()");
2307
+ const compaction = ctx.get("compaction");
2308
+ ctx.logger.info(
2309
+ `[lrc] compaction 服务:${compaction === void 0 ? "不可达(preset 隔离)" : typeof compaction.compactRegion === "function" ? "可用(支持 compactRegion)" : "存在但无 compactRegion"}`
2310
+ );
2311
+ void (async () => {
2312
+ const result = await runtime.ensure();
2313
+ if (result.mode === "degraded") {
2314
+ ctx.logger.warn(`[lrc] 未启用记忆沉淀:${result.reason ?? "未知原因"}`);
2315
+ return;
2316
+ }
2317
+ client = new LrcClient({ baseUrl: result.baseUrl, token: config.token || void 0 });
2318
+ offloader = new Offloader(
2319
+ client,
2320
+ new CursorStore(join2(cacheDir, "cursor.json")),
2321
+ {
2322
+ turnsThreshold: config.offloadTurns,
2323
+ charsThreshold: config.offloadChars,
2324
+ stepsThreshold: config.offloadSteps,
2325
+ project: config.project,
2326
+ maxItems: config.maxItems
2327
+ },
2328
+ new RecentStore(join2(cacheDir, "recent.json"))
2329
+ );
2330
+ ctx.logger.info(`[lrc] 记忆沉淀已就绪(${result.mode}):${result.baseUrl}`);
2331
+ void bootstrapSnapshot();
2332
+ })();
2333
+ async function bootstrapSnapshot() {
2334
+ if (latestSnapshot.length > 0 || client === null) return;
2335
+ try {
2336
+ const index = await client.index(config.project, 3);
2337
+ if (index.items.length === 0) return;
2338
+ const picked = index.items.map((item) => {
2339
+ const body = item.content_preview.replace(/^\[[^\]]+\]\s*/, "");
2340
+ return body.length > 120 ? `${body.slice(0, 120)}…` : body;
2341
+ });
2342
+ latestSnapshot = `[LRC 状态] ${picked.join(";")}。细节在 LRC,需要时 recall。`;
2343
+ saveSnapshot(latestSnapshot);
2344
+ ctx.logger.info(`[lrc] 快照已从 LRC 索引重建(${index.items.length} 条)`);
2345
+ } catch {
2346
+ }
2347
+ }
2348
+ async function flush(session, reason) {
2349
+ if (offloader === null || flushing) return;
2350
+ if (!offloader.hasPending()) return;
2351
+ flushing = true;
2352
+ try {
2353
+ const sessionId = session?.id ?? "default";
2354
+ const lines = linesFromSession(session);
2355
+ if (lines.length === 0) {
2356
+ if (!warnedEmpty) {
2357
+ warnedEmpty = true;
2358
+ ctx.logger.warn("[lrc] 会话日志里没有可沉淀的文本行,本次跳过");
2359
+ }
2360
+ const conclusions = conclusionsFromReasoning(turnReasoning.join("\n"));
2361
+ if (conclusions.length === 0 && turnReasoning.length > 0 && !warnedNoConclusion) {
2362
+ warnedNoConclusion = true;
2363
+ ctx.logger.info(
2364
+ `[lrc] 推理兜底:本轮推理 ${turnReasoningChars} 字符,未抽到结论句(词表未命中)`
2365
+ );
2366
+ }
2367
+ if (conclusions.length > 0) {
2368
+ try {
2369
+ const result2 = await offloader.offloadItems(itemsFromTag(conclusions.join("\n")));
2370
+ if (result2.distilled > 0) {
2371
+ if (result2.snapshot.length > 0 && snapshotChanged(latestSnapshot, result2.snapshot)) {
2372
+ latestSnapshot = result2.snapshot;
2373
+ saveSnapshot(result2.snapshot);
2374
+ }
2375
+ ctx.logger.info(
2376
+ `[lrc] 推理兜底沉淀 ${result2.distilled} 条(写入 ${result2.written},合并 ${result2.merged}${result2.deduped > 0 ? `;本地去重 ${result2.deduped}` : ""},触发:${reason})`
2377
+ );
2378
+ }
2379
+ } catch (error) {
2380
+ ctx.logger.warn(
2381
+ `[lrc] 推理兜底沉淀失败(不影响对话):${error instanceof Error ? error.message : String(error)}`
2382
+ );
2383
+ }
2384
+ turnReasoning = [];
2385
+ turnReasoningChars = 0;
2386
+ }
2387
+ offloader.resetCounters();
2388
+ return;
2389
+ }
2390
+ const result = await offloader.offload(sessionId, lines);
2391
+ if (result !== null) {
2392
+ if (result.snapshot.length > 0 && snapshotChanged(latestSnapshot, result.snapshot)) {
2393
+ latestSnapshot = result.snapshot;
2394
+ saveSnapshot(result.snapshot);
2395
+ }
2396
+ ctx.logger.info(
2397
+ `[lrc] 已沉淀 ${result.distilled} 条(写入 ${result.written},其中合并 ${result.merged}${result.deduped > 0 ? `;本地去重 ${result.deduped}` : ""},触发:${reason})`
2398
+ );
2399
+ }
2400
+ } catch (error) {
2401
+ ctx.logger.warn(
2402
+ `[lrc] 沉淀失败(不影响对话):${error instanceof Error ? error.message : String(error)}`
2403
+ );
2404
+ } finally {
2405
+ flushing = false;
2406
+ }
2407
+ }
2408
+ function resetTurnState() {
2409
+ turnHadText = false;
2410
+ turnTagSeen = false;
2411
+ turnReasoning = [];
2412
+ turnReasoningChars = 0;
2413
+ }
2414
+ const REASONING_CAP = 2e4;
2415
+ async function finishTurn(session) {
2416
+ if (turnHadText) {
2417
+ if (turnTagSeen) turnHits += 1;
2418
+ else turnMisses += 1;
2419
+ const seen = turnHits + turnMisses;
2420
+ if (seen % 20 === 0) {
2421
+ ctx.logger.info(
2422
+ `[lrc] <lrc_memory> 命中率 ${turnHits}/${seen} 轮(另有 ${turnNoText} 轮无正文)`
2423
+ );
2424
+ }
2425
+ } else {
2426
+ turnNoText += 1;
2427
+ }
2428
+ const hadText = turnHadText;
2429
+ const reasoning = turnReasoning.join("\n");
2430
+ resetTurnState();
2431
+ await flush(session, "turn/end");
2432
+ if (hadText || offloader === null || reasoning.length === 0) return;
2433
+ const conclusions = conclusionsFromReasoning(reasoning);
2434
+ if (conclusions.length === 0) return;
2435
+ try {
2436
+ const result = await offloader.offloadItems(itemsFromTag(conclusions.join("\n")));
2437
+ if (result.distilled > 0) {
2438
+ if (result.snapshot.length > 0 && snapshotChanged(latestSnapshot, result.snapshot)) {
2439
+ latestSnapshot = result.snapshot;
2440
+ saveSnapshot(result.snapshot);
2441
+ }
2442
+ ctx.logger.info(
2443
+ `[lrc] 推理兜底沉淀 ${result.distilled} 条(写入 ${result.written},合并 ${result.merged}${result.deduped > 0 ? `;本地去重 ${result.deduped}` : ""})`
2444
+ );
2445
+ }
2446
+ } catch (error) {
2447
+ ctx.logger.warn(
2448
+ `[lrc] 推理兜底沉淀失败(不影响对话):${error instanceof Error ? error.message : String(error)}`
2449
+ );
2450
+ }
2451
+ }
2452
+ ctx.on("session/event", (session, event) => {
2453
+ const type = event?.type;
2454
+ if (type === "turn/start") {
2455
+ resetTurnState();
2456
+ return;
2457
+ }
2458
+ if (type === "turn/end") {
2459
+ void finishTurn(session);
2460
+ return;
2461
+ }
2462
+ if (offloader === null) return;
2463
+ if (type !== "assistant/message") return;
2464
+ const content = messageContent(event);
2465
+ const text = textBlocksOnly(content);
2466
+ const reasoning = reasoningBlocksOnly(content);
2467
+ if (text.length > 0) {
2468
+ turnHadText = true;
2469
+ if (extractMemoryTag(text) !== null) turnTagSeen = true;
2470
+ offloader.observe(text);
2471
+ } else if (!warnedShape && hasMalformedTextBlock(content)) {
2472
+ warnedShape = true;
2473
+ const data = event?.data;
2474
+ const keys = data !== null && typeof data === "object" ? Object.keys(data).join(",") : typeof data;
2475
+ ctx.logger.warn(`[lrc] assistant/message 文本块结构异常(data 字段:${keys})`);
2476
+ }
2477
+ if (reasoning.length > 0 && turnReasoningChars < REASONING_CAP) {
2478
+ turnReasoning.push(reasoning);
2479
+ turnReasoningChars += reasoning.length;
2480
+ }
2481
+ });
2482
+ ctx.on("agent/pre-step", async (payload, next) => {
2483
+ const agent = payload?.agent;
2484
+ lastAgent = agent;
2485
+ if (!probed) {
2486
+ probed = true;
2487
+ try {
2488
+ governor.capabilities = probeCapabilities(ctx, agent);
2489
+ for (const line of formatCapabilities(governor.capabilities)) ctx.logger.info(line);
2490
+ } catch (error) {
2491
+ ctx.logger.warn(
2492
+ `[lrc][cap] 能力探测异常:${error instanceof Error ? error.message : String(error)}`
2493
+ );
2494
+ }
2495
+ }
2496
+ const caps = governor.capabilities;
2497
+ if (budget === null) {
2498
+ try {
2499
+ budget = await resolveBudget({
2500
+ ctx,
2501
+ agent,
2502
+ config: {
2503
+ contextWindowTokens: config.contextWindowTokens,
2504
+ trimRatio: config.trimRatio,
2505
+ pruneRatio: config.pruneRatio,
2506
+ trimMinTokens: config.trimMinTokens,
2507
+ pruneResultThreshold: config.pruneResultThreshold
2508
+ }
2509
+ });
2510
+ ctx.logger.info(formatBudget(budget));
2511
+ } catch (error) {
2512
+ ctx.logger.warn(
2513
+ `[lrc][budget] 预算解析失败,使用配置绝对值:${error instanceof Error ? error.message : String(error)}`
2514
+ );
2515
+ }
2516
+ }
2517
+ const trimMinTokens = budget?.trimMinTokens ?? config.trimMinTokens;
2518
+ const pruneResultThreshold = budget?.pruneResultThreshold ?? config.pruneResultThreshold;
2519
+ const actions = [
2520
+ {
2521
+ name: "prune-results",
2522
+ enabled: config.pruneEnabled && (caps === null || caps.toolResultPruner.available),
2523
+ run: () => {
2524
+ const outcome = pruneToolResults({
2525
+ ctx,
2526
+ agent,
2527
+ log: (message) => ctx.logger.info(message),
2528
+ thresholdChars: pruneResultThreshold
2529
+ });
2530
+ return { ok: outcome.ok, reason: outcome.reason };
2531
+ }
2532
+ },
2533
+ {
2534
+ name: "prune-args",
2535
+ enabled: config.pruneArgsEnabled,
2536
+ run: () => {
2537
+ const outcome = pruneToolCallArguments({
2538
+ ctx,
2539
+ agent,
2540
+ log: (message) => ctx.logger.info(message),
2541
+ options: {
2542
+ thresholdChars: config.pruneArgsThreshold,
2543
+ headChars: config.pruneArgsHead,
2544
+ keepRecentNodes: config.keepRecentNodes
2545
+ }
2546
+ });
2547
+ return { ok: outcome.ok, reason: outcome.reason };
2548
+ }
2549
+ },
2550
+ {
2551
+ name: "prune-reasoning",
2552
+ enabled: config.pruneReasoningEnabled,
2553
+ run: () => {
2554
+ const outcome = pruneReasoning({
2555
+ ctx,
2556
+ agent,
2557
+ log: (message) => ctx.logger.info(message),
2558
+ options: { keepRecentNodes: config.keepRecentNodes }
2559
+ });
2560
+ return { ok: outcome.ok, reason: outcome.reason };
2561
+ }
2562
+ },
2563
+ {
2564
+ name: "trim",
2565
+ enabled: config.trimEnabled && (caps === null || caps.compaction.available && caps.tokenMeter.available),
2566
+ throttleSteps: config.trimMinSteps,
2567
+ run: async () => {
2568
+ const surfaceNodes = agent?.session?.surface?.nodes;
2569
+ const nodeCount = Array.isArray(surfaceNodes) ? surfaceNodes.length : 0;
2570
+ const force = nodeCount > config.trimMaxNodes;
2571
+ if (force) {
2572
+ ctx.logger.info(
2573
+ `[lrc][trim] 节点数 ${nodeCount} 超过上限 ${config.trimMaxNodes},跳过 token 阈值强制裁剪`
2574
+ );
2575
+ }
2576
+ const outcome = await trimContext({
2577
+ ctx,
2578
+ agent,
2579
+ signal: payload?.signal,
2580
+ options: {
2581
+ minTokens: trimMinTokens,
2582
+ keepRecentNodes: config.keepRecentNodes,
2583
+ maxSummaryChars: config.trimMaxSummaryChars,
2584
+ force
2585
+ },
2586
+ log: (message) => ctx.logger.info(message)
2587
+ });
2588
+ if (!outcome.ok && !outcome.reason.startsWith("error")) {
2589
+ if (!trimReasonsLogged.has(outcome.reason)) {
2590
+ trimReasonsLogged.add(outcome.reason);
2591
+ ctx.logger.info(
2592
+ `[lrc][trim] 未裁剪:${outcome.reason}${outcome.detail === void 0 ? "" : `(${outcome.detail})`}`
2593
+ );
2594
+ }
2595
+ }
2596
+ return {
2597
+ ok: outcome.ok,
2598
+ reason: outcome.reason,
2599
+ detail: outcome.summaryStats ?? outcome.detail
2600
+ };
2601
+ }
2602
+ }
2603
+ ];
2604
+ await runGovernor({
2605
+ state: governor,
2606
+ actions,
2607
+ log: (message) => ctx.logger.info(message),
2608
+ warn: (message) => ctx.logger.warn(message)
2609
+ });
2610
+ if (governor.steps % 10 === 0) {
2611
+ try {
2612
+ ctx.logger.info(formatProfile(profileSurface(agent?.session)));
2613
+ } catch {
2614
+ }
2615
+ }
2616
+ if (offloader !== null) {
2617
+ offloader.observeStep();
2618
+ if (offloader.shouldOffload()) {
2619
+ const session = payload?.agent?.session;
2620
+ void flush(session, "threshold");
2621
+ }
2622
+ }
2623
+ const decision = await next();
2624
+ if (decision !== null && typeof decision === "object") {
2625
+ const d = decision;
2626
+ if (d.kind === "enter" && Array.isArray(d.messages)) {
2627
+ const messages = d.messages;
2628
+ const ctxIdx = messages.findIndex((m) => m?.source?.kind === "runtime-context");
2629
+ if (ctxIdx > 0) {
2630
+ const [ctx2] = messages.splice(ctxIdx, 1);
2631
+ messages.unshift(ctx2);
2632
+ }
2633
+ }
2634
+ }
2635
+ return decision;
2636
+ });
2637
+ function registerTool(name2, factory) {
2638
+ try {
2639
+ ctx.tools.register(factory());
2640
+ } catch (error) {
2641
+ ctx.logger.warn(
2642
+ `[lrc] 工具 ${name2} 注册失败(不影响沉淀与剪枝):${error instanceof Error ? error.message : String(error)}`
2643
+ );
2644
+ }
2645
+ }
2646
+ registerTool(
2647
+ "lrc_remember_now",
2648
+ () => defineTool({
2649
+ name: "lrc_remember_now",
2650
+ description: '立即把当前对话的结论沉淀到 LRC 长期记忆库。用于用户要求"把这段记下来"或上下文即将变长时。返回一行状态快照。',
2651
+ parameters: {
2652
+ text: {
2653
+ type: "string",
2654
+ required: true,
2655
+ description: "要沉淀的对话原文(可含多轮,按换行分隔)"
2656
+ }
2657
+ },
2658
+ output: {
2659
+ schema: {
2660
+ type: "object",
2661
+ additionalProperties: false,
2662
+ properties: {
2663
+ snapshot: { type: "string", required: true },
2664
+ distilled: { type: "number", required: true },
2665
+ written: { type: "number", required: true },
2666
+ merged: { type: "number", required: true }
2667
+ }
2668
+ },
2669
+ render: (_args, value) => [
2670
+ { type: "text", text: value.snapshot }
2671
+ ]
2672
+ },
2673
+ async execute(args) {
2674
+ if (client === null) {
2675
+ return { snapshot: "LRC 未就绪,本次未沉淀", distilled: 0, written: 0, merged: 0 };
2676
+ }
2677
+ try {
2678
+ const tag = extractMemoryTag(args.text);
2679
+ if (tag !== null) {
2680
+ if (isNoUpdate(tag)) {
2681
+ return { snapshot: "本轮无新增,未沉淀", distilled: 0, written: 0, merged: 0 };
2682
+ }
2683
+ const items = itemsFromTag(tag);
2684
+ const migrated = await client.migrate(items, config.project);
2685
+ ctx.logger.info(
2686
+ `[lrc][tool] lrc_remember_now 沉淀 ${items.length} 条(写入 ${migrated.written},合并 ${migrated.merged})`
2687
+ );
2688
+ return {
2689
+ snapshot: `已沉淀 ${items.length} 条(写入 ${migrated.written},其中合并 ${migrated.merged})`,
2690
+ distilled: items.length,
2691
+ written: migrated.written,
2692
+ merged: migrated.merged
2693
+ };
2694
+ }
2695
+ const result = await client.migrateText(args.text, config.project, config.maxItems);
2696
+ ctx.logger.info(
2697
+ `[lrc][tool] lrc_remember_now 沉淀 ${result.distilled} 条(写入 ${result.written},合并 ${result.merged})`
2698
+ );
2699
+ return {
2700
+ snapshot: result.indexLine,
2701
+ distilled: result.distilled,
2702
+ written: result.written,
2703
+ merged: result.merged
2704
+ };
2705
+ } catch (error) {
2706
+ return {
2707
+ snapshot: `LRC 未连接,本次未沉淀(${String(error).slice(0, 120)})`,
2708
+ distilled: 0,
2709
+ written: 0,
2710
+ merged: 0
2711
+ };
2712
+ }
2713
+ }
2714
+ })
2715
+ );
2716
+ const describeRecall = (returned, total) => `召回 ${returned} 条(三路候选 ${total} 条,融合前;已折叠近重复)`;
2717
+ registerTool(
2718
+ "lrc_recall",
2719
+ () => defineTool({
2720
+ name: "lrc_recall",
2721
+ description: "从 LRC 长期记忆库检索相关记忆。涉及项目历史、用户偏好、之前做过的决定、上次怎么改的,先调这个工具,不要直接 Grep 或翻文件。返回按相关度排序的记忆条目(已做近重复折叠)。",
2722
+ parameters: {
2723
+ query: {
2724
+ type: "string",
2725
+ required: true,
2726
+ description: "自然语言查询,如「上次那个检索接口怎么改的」"
2727
+ },
2728
+ top_k: {
2729
+ type: "number",
2730
+ description: "返回条数,默认 5,最大 20"
2731
+ },
2732
+ project: {
2733
+ type: "string",
2734
+ description: "按项目过滤(可选,缺省搜全部项目)"
2735
+ }
2736
+ },
2737
+ output: {
2738
+ schema: {
2739
+ type: "object",
2740
+ additionalProperties: false,
2741
+ properties: {
2742
+ text: { type: "string", required: true },
2743
+ count: { type: "number", required: true }
2744
+ }
2745
+ },
2746
+ render: (_args, value) => [{ type: "text", text: value.text }]
2747
+ },
2748
+ async execute(args) {
2749
+ if (client === null) {
2750
+ return { text: "LRC 未就绪,无法召回。", count: 0 };
2751
+ }
2752
+ const query = typeof args.query === "string" ? args.query.trim() : "";
2753
+ if (query.length === 0) {
2754
+ return { text: "lrc_recall 需要非空的 query。", count: 0 };
2755
+ }
2756
+ try {
2757
+ const topK = Math.min(Math.max(args.top_k ?? 5, 1), 20);
2758
+ const result = await client.recall(query, {
2759
+ topK,
2760
+ // 缺省不按项目过滤:插件沉淀写 config.project,手工 remember 可能写别的项目,
2761
+ // 过滤会漏掉一半。需要收窄时由调用方显式传 project。
2762
+ project: args.project
2763
+ });
2764
+ ctx.logger.info(
2765
+ `[lrc][tool] lrc_recall "${query}" → ${describeRecall(result.memories.length, result.total)}`
2766
+ );
2767
+ if (result.memories.length === 0) {
2768
+ return { text: "LRC 未命中,可回退内置搜索。", count: 0 };
2769
+ }
2770
+ const lines = result.memories.map((m, i) => {
2771
+ const rel = Math.round((m.score_norm ?? 0) * 100);
2772
+ const kind = m.memory_type ?? "fact";
2773
+ return `(记忆 #${i + 1} · 相关度 ${rel}% · ${kind} · id=${m.id})
2774
+ ${m.content}`;
2775
+ });
2776
+ return {
2777
+ text: `LRC ${describeRecall(result.memories.length, result.total)}:
2778
+
2779
+ ` + lines.join("\n\n"),
2780
+ count: result.memories.length
2781
+ };
2782
+ } catch (error) {
2783
+ return { text: `LRC 召回失败:${String(error).slice(0, 200)}`, count: 0 };
2784
+ }
2785
+ }
2786
+ })
2787
+ );
2788
+ registerTool(
2789
+ "lrc_forget",
2790
+ () => defineTool({
2791
+ name: "lrc_forget",
2792
+ description: "删除一条错误的 LRC 记忆。当 lrc_recall 返回的某条记忆与事实不符(例如把「没找到」误写成「不存在」、把旧结论当成现状),用这个工具撤回。memory_id 从 lrc_recall 的输出里取。",
2793
+ parameters: {
2794
+ memory_id: {
2795
+ type: "string",
2796
+ required: true,
2797
+ description: "要删除的记忆 ID(lrc_recall 输出里的 id=...)"
2798
+ }
2799
+ },
2800
+ output: {
2801
+ schema: {
2802
+ type: "object",
2803
+ additionalProperties: false,
2804
+ properties: {
2805
+ text: { type: "string", required: true },
2806
+ deleted: { type: "boolean", required: true }
2807
+ }
2808
+ },
2809
+ render: (_args, value) => [{ type: "text", text: value.text }]
2810
+ },
2811
+ async execute(args) {
2812
+ if (client === null) {
2813
+ return { text: "LRC 未就绪,无法删除。", deleted: false };
2814
+ }
2815
+ const memoryId = typeof args.memory_id === "string" ? args.memory_id.trim() : "";
2816
+ if (memoryId.length === 0) {
2817
+ return { text: "lrc_forget 需要非空的 memory_id。", deleted: false };
2818
+ }
2819
+ try {
2820
+ const result = await client.forget(memoryId);
2821
+ ctx.logger.info(
2822
+ `[lrc][tool] lrc_forget ${memoryId} → ${result.deleted ? "已删除" : result.alreadyDeleted ? "幂等不存在" : "未删除"}`
2823
+ );
2824
+ if (result.deleted) {
2825
+ return { text: `已删除记忆 ${memoryId}。`, deleted: true };
2826
+ }
2827
+ if (result.alreadyDeleted) {
2828
+ return { text: `记忆 ${memoryId} 已不存在(幂等)。`, deleted: true };
2829
+ }
2830
+ return { text: `记忆 ${memoryId} 未删除(服务端返回 deleted=false)。`, deleted: false };
2831
+ } catch (error) {
2832
+ return { text: `LRC 删除失败:${String(error).slice(0, 200)}`, deleted: false };
2833
+ }
2834
+ }
2835
+ })
2836
+ );
2837
+ registerTool(
2838
+ "lrc_handoff",
2839
+ () => defineTool({
2840
+ name: "lrc_handoff",
2841
+ description: "把当前会话整理成交接摘要,并新建一个会话注入该摘要(原会话保留)。用于上下文接近上限、需要另起炉灶继续同一任务时。",
2842
+ parameters: {
2843
+ // **不要写 `required: false`**:DSH 的 defineTool 规定参数上的
2844
+ // `required` 只要出现就必须是 `true`,省略该键才表示可选。
2845
+ // 写成 false 会抛 JsonSchemaError,把整个 apply() 掀翻(实测回归)。
2846
+ note: {
2847
+ type: "string",
2848
+ description: "给接手方的下一步说明(可选)"
2849
+ }
2850
+ },
2851
+ output: {
2852
+ schema: {
2853
+ type: "object",
2854
+ additionalProperties: false,
2855
+ properties: {
2856
+ ok: { type: "boolean", required: true },
2857
+ sessionId: { type: "string", required: true },
2858
+ mode: { type: "string", required: true },
2859
+ text: { type: "string", required: true }
2860
+ }
2861
+ },
2862
+ render: (_args, value) => [
2863
+ {
2864
+ type: "text",
2865
+ text: (value.ok ? `已创建交接会话(${value.mode}):${value.sessionId}` : "未能创建新会话,仅返回交接摘要") + `
2866
+
2867
+ ${value.text}`
2868
+ }
2869
+ ]
2870
+ },
2871
+ async execute(args) {
2872
+ const session = lastAgent?.session;
2873
+ const source = extractHandoffSource(session);
2874
+ let indexLines = [];
2875
+ if (client !== null) {
2876
+ try {
2877
+ const index = await client.index(config.project, 10);
2878
+ indexLines = index.items.map((item) => item.content_preview);
2879
+ } catch {
2880
+ }
2881
+ }
2882
+ const text = buildHandoffText({
2883
+ lastUserRequest: source.lastUserRequest,
2884
+ recentAssistant: source.recentAssistant,
2885
+ snapshot: latestSnapshot,
2886
+ indexLines,
2887
+ note: args.note ?? ""
2888
+ });
2889
+ const result = await createHandoffSession({
2890
+ ctx,
2891
+ agent: lastAgent,
2892
+ text,
2893
+ log: (message) => ctx.logger.info(message)
2894
+ });
2895
+ ctx.logger.info(
2896
+ `[lrc][tool] lrc_handoff → ${result.ok ? "已创建交接会话" : "仅返回摘要"}(${result.mode})`
2897
+ );
2898
+ return {
2899
+ ok: result.ok,
2900
+ sessionId: result.sessionId ?? "",
2901
+ mode: result.mode,
2902
+ text: result.text
2903
+ };
2904
+ }
2905
+ })
2906
+ );
2907
+ registerTool(
2908
+ "lrc_status",
2909
+ () => defineTool({
2910
+ name: "lrc_status",
2911
+ description: '查看 LRC 插件的运行状态:能力矩阵(哪些路径可用)、各动作执行次数与熔断状态、当前配置阈值、快照是否存在。排查"为什么没生效"时先调这个。',
2912
+ parameters: {},
2913
+ output: {
2914
+ schema: {
2915
+ type: "object",
2916
+ additionalProperties: false,
2917
+ properties: {
2918
+ text: { type: "string", required: true }
2919
+ }
2920
+ },
2921
+ render: (_args, value) => [{ type: "text", text: value.text }]
2922
+ },
2923
+ async execute() {
2924
+ ctx.logger.info("[lrc][tool] lrc_status 调用");
2925
+ const lines = ["# LRC 状态", ""];
2926
+ lines.push("## 能力矩阵");
2927
+ if (governor.capabilities === null) {
2928
+ lines.push("尚未探测(还没有走过一步)");
2929
+ } else {
2930
+ lines.push(...formatCapabilities(governor.capabilities));
2931
+ }
2932
+ lines.push("");
2933
+ lines.push("## 动作状态");
2934
+ lines.push(formatGovernorState(governor));
2935
+ lines.push("");
2936
+ lines.push("## 上下文画像");
2937
+ try {
2938
+ lines.push(formatProfile(profileSurface(lastAgent?.session)));
2939
+ } catch {
2940
+ lines.push("不可用");
2941
+ }
2942
+ lines.push("");
2943
+ lines.push("## 沉淀");
2944
+ if (offloader === null) {
2945
+ lines.push("未启用");
2946
+ } else {
2947
+ const stats = offloader.stats();
2948
+ lines.push(
2949
+ `累计 ${stats.flushCount} 次:新写入 ${stats.totalWritten} 条 / 合并 ${stats.totalMerged} 条 / 本地查重拦下 ${stats.totalDeduped} 块`
2950
+ );
2951
+ lines.push(
2952
+ stats.lastFlushAt === 0 ? "最近一次:尚未沉淀" : `最近一次:${new Date(stats.lastFlushAt).toISOString()}(写入 ${stats.lastFlushWritten} / 合并 ${stats.lastFlushMerged})`
2953
+ );
2954
+ lines.push(`查重窗口:${stats.recentWindow} 块`);
2955
+ }
2956
+ lines.push("");
2957
+ lines.push("## 预算");
2958
+ lines.push(budget === null ? "尚未解析" : formatBudget(budget));
2959
+ lines.push("");
2960
+ lines.push("## 配置");
2961
+ lines.push(
2962
+ [
2963
+ `prune=${config.pruneEnabled}(阈值 ${budget?.pruneResultThreshold ?? config.pruneResultThreshold} 字符)`,
2964
+ `pruneArgs=${config.pruneArgsEnabled}(阈值 ${config.pruneArgsThreshold} 字符)`,
2965
+ `pruneReasoning=${config.pruneReasoningEnabled}(保留最近 ${config.keepRecentNodes} 节点)`,
2966
+ `trim=${config.trimEnabled}(阈值 ${budget?.trimMinTokens ?? config.trimMinTokens} tokens,每 ${config.trimMinSteps} 步,节点上限 ${config.trimMaxNodes})`,
2967
+ `offload=${config.offloadTurns} 轮 / ${config.offloadChars} 字符 / ${config.offloadSteps} 步`
2968
+ ].join("\n")
2969
+ );
2970
+ lines.push("");
2971
+ lines.push("## 快照");
2972
+ lines.push(
2973
+ latestSnapshot.length > 0 ? `已存在(${latestSnapshot.length} 字符)${snapshotServed ? ",已被渲染层取走" : ",尚未被取走"}` : "为空(还没有成功沉淀过)"
2974
+ );
2975
+ return { text: lines.join("\n") };
2976
+ }
2977
+ })
2978
+ );
2979
+ }
2980
+ export {
2981
+ Config,
2982
+ apply,
2983
+ inject,
2984
+ name
2985
+ };