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/LICENSE +21 -0
- package/README.md +182 -0
- package/cordis.patch.yml +23 -0
- package/dist/index.js +2985 -0
- package/package.json +64 -0
- package/src/budget.ts +195 -0
- package/src/capabilities.ts +185 -0
- package/src/context-profile.ts +182 -0
- package/src/context-trim.ts +1106 -0
- package/src/cursor.ts +195 -0
- package/src/event-classify.ts +75 -0
- package/src/governor.ts +168 -0
- package/src/handoff.ts +221 -0
- package/src/index.ts +1164 -0
- package/src/lrc-client.ts +210 -0
- package/src/memory-filter.ts +506 -0
- package/src/offloader.ts +475 -0
- package/src/runtime.ts +305 -0
- package/src/session-text.ts +188 -0
package/src/runtime.ts
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LRC 运行时管理:让插件自带 LRC,用户不需要先装桌面端。
|
|
3
|
+
*
|
|
4
|
+
* ## 启动顺序
|
|
5
|
+
*
|
|
6
|
+
* ```
|
|
7
|
+
* ① 探测已有 LRC(LRC_BASE / 3099 端口)→ 有就用(external)
|
|
8
|
+
* ② 检查缓存里的二进制 → 没有就下载(镜像 + SHA256 校验)
|
|
9
|
+
* ③ 启动隔离实例(独立数据目录 + 随机空闲端口)→ managed
|
|
10
|
+
* ④ 任一步失败 → degraded(纯客户端模式,卸载静默跳过)
|
|
11
|
+
* ```
|
|
12
|
+
*
|
|
13
|
+
* ## 数据隔离
|
|
14
|
+
*
|
|
15
|
+
* 插件自己的实例用 `$DSH_HOME/cache/lrc-plugin/data`,
|
|
16
|
+
* **绝不**写进用户的 `~/.loong-recall/global/data`。
|
|
17
|
+
*/
|
|
18
|
+
import { createHash } from 'node:crypto';
|
|
19
|
+
import { spawn, type ChildProcess } from 'node:child_process';
|
|
20
|
+
import { createServer } from 'node:net';
|
|
21
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
22
|
+
import { homedir, platform, arch } from 'node:os';
|
|
23
|
+
import { join } from 'node:path';
|
|
24
|
+
|
|
25
|
+
export type RuntimeMode = 'external' | 'managed' | 'degraded';
|
|
26
|
+
|
|
27
|
+
export interface RuntimeResult {
|
|
28
|
+
mode: RuntimeMode;
|
|
29
|
+
baseUrl: string;
|
|
30
|
+
/** degraded 时给出可读原因 */
|
|
31
|
+
reason?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 二进制清单:`<platform>-<arch>` → `{ file, sha256 }`。
|
|
36
|
+
*
|
|
37
|
+
* 文件名带版本号(`lrc-v0.10.0-...`),配合 `BINARY_RELEASE` 拼出**固定 URL**:
|
|
38
|
+
* `releases/download/v0.10.0/<file>`。这样不会因为 `latest` 漂移而拿到未校验的版本。
|
|
39
|
+
*
|
|
40
|
+
* **每次 LRC 发版都要更新这里**(文件名 + SHA256 + BINARY_RELEASE)。
|
|
41
|
+
* 后续可加一个 Action 自动回写,见 docs/PLAN.md §2.3。
|
|
42
|
+
*/
|
|
43
|
+
export const BINARY_RELEASE = 'v0.10.0';
|
|
44
|
+
|
|
45
|
+
export const BINARY_MANIFEST: Record<string, { file: string; sha256: string }> = {
|
|
46
|
+
'win32-x64': {
|
|
47
|
+
file: 'lrc-v0.10.0-windows-x86_64.exe',
|
|
48
|
+
sha256: '005fcad2e4e61e77b8efd15d43d37a7c39d73d4e4eff1fdbf8107b8bd30951d4',
|
|
49
|
+
},
|
|
50
|
+
'darwin-arm64': {
|
|
51
|
+
file: 'lrc-v0.10.0-macos-arm64',
|
|
52
|
+
sha256: 'd1fcfbb52f02a24d9cd277981b43884fd16e71ed8d5c7f1c8f2b918e4a8bce62',
|
|
53
|
+
},
|
|
54
|
+
'linux-x64': {
|
|
55
|
+
file: 'lrc-v0.10.0-linux-x86_64',
|
|
56
|
+
sha256: '2e6fd027c2f0c76a98f4970278e321fa39543860d3e446fc303df5950a3f15e0',
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const DEFAULT_MIRRORS = [
|
|
61
|
+
`https://github.com/zhibaiYingChuan/LRC/releases/download/${BINARY_RELEASE}`,
|
|
62
|
+
`https://ghproxy.net/https://github.com/zhibaiYingChuan/LRC/releases/download/${BINARY_RELEASE}`,
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
export interface RuntimeOptions {
|
|
66
|
+
/**
|
|
67
|
+
* 显式指定已有 LRC 实例(优先于 `LRC_BASE` 环境变量与本地探测)。
|
|
68
|
+
*
|
|
69
|
+
* 来自插件配置 `baseUrl`。此前只认环境变量,导致 `cordis.patch.yml`
|
|
70
|
+
* 里写 `baseUrl: http://127.0.0.1:3111` 不生效,永远打到 3099。
|
|
71
|
+
*/
|
|
72
|
+
baseUrl?: string;
|
|
73
|
+
/** 缓存根目录,默认 `$DSH_HOME/cache/lrc-plugin` */
|
|
74
|
+
cacheDir?: string;
|
|
75
|
+
/** 额外镜像前缀(优先于内置镜像) */
|
|
76
|
+
mirror?: string;
|
|
77
|
+
/** 日志回调 */
|
|
78
|
+
log?: (message: string) => void;
|
|
79
|
+
/** 健康检查超时(毫秒) */
|
|
80
|
+
healthTimeoutMs?: number;
|
|
81
|
+
/** 覆盖二进制清单(测试用:可注入错误哈希验证降级) */
|
|
82
|
+
manifest?: Record<string, { file: string; sha256: string }>;
|
|
83
|
+
/** 覆盖 Release 版本(测试用) */
|
|
84
|
+
release?: string;
|
|
85
|
+
/** 是否探测本地 3099 端口(测试用:置 false 可强制走下载路径) */
|
|
86
|
+
probeLocal?: boolean;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function defaultCacheDir(): string {
|
|
90
|
+
const dshHome = process.env.DSH_HOME ?? join(homedir(), '.dsh');
|
|
91
|
+
return join(dshHome, 'cache', 'lrc-plugin');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function platformKey(): string {
|
|
95
|
+
return `${platform()}-${arch()}`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function binaryName(): string {
|
|
99
|
+
return platform() === 'win32' ? 'lrc-sidecar.exe' : 'lrc-sidecar';
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** 找一个空闲端口。 */
|
|
103
|
+
function findFreePort(): Promise<number> {
|
|
104
|
+
return new Promise((resolve, reject) => {
|
|
105
|
+
const server = createServer();
|
|
106
|
+
server.once('error', reject);
|
|
107
|
+
server.listen(0, '127.0.0.1', () => {
|
|
108
|
+
const address = server.address();
|
|
109
|
+
if (address === null || typeof address === 'string') {
|
|
110
|
+
server.close();
|
|
111
|
+
reject(new Error('无法获取空闲端口'));
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
const { port } = address;
|
|
115
|
+
server.close(() => resolve(port));
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function isHealthy(baseUrl: string, timeoutMs = 3000): Promise<boolean> {
|
|
121
|
+
try {
|
|
122
|
+
const response = await fetch(`${baseUrl}/health`, {
|
|
123
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
124
|
+
});
|
|
125
|
+
return response.ok;
|
|
126
|
+
} catch {
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export class LrcRuntime {
|
|
132
|
+
private readonly cacheDir: string;
|
|
133
|
+
private readonly mirror?: string;
|
|
134
|
+
private readonly log: (message: string) => void;
|
|
135
|
+
private readonly healthTimeoutMs: number;
|
|
136
|
+
private readonly manifest: Record<string, { file: string; sha256: string }>;
|
|
137
|
+
private readonly mirrors: string[];
|
|
138
|
+
private readonly probeLocal: boolean;
|
|
139
|
+
/** 显式实例地址:配置 `baseUrl` 优先,其次 `LRC_BASE` 环境变量。 */
|
|
140
|
+
private readonly explicitBase?: string;
|
|
141
|
+
private child: ChildProcess | null = null;
|
|
142
|
+
private result: RuntimeResult | null = null;
|
|
143
|
+
|
|
144
|
+
constructor(options: RuntimeOptions = {}) {
|
|
145
|
+
this.explicitBase = options.baseUrl?.trim() || process.env.LRC_BASE;
|
|
146
|
+
this.cacheDir = options.cacheDir ?? defaultCacheDir();
|
|
147
|
+
this.mirror = options.mirror ?? process.env.LRC_MIRROR;
|
|
148
|
+
this.log = options.log ?? (() => {});
|
|
149
|
+
this.healthTimeoutMs = options.healthTimeoutMs ?? 15_000;
|
|
150
|
+
this.manifest = options.manifest ?? BINARY_MANIFEST;
|
|
151
|
+
this.probeLocal = options.probeLocal ?? true;
|
|
152
|
+
const release = options.release ?? BINARY_RELEASE;
|
|
153
|
+
this.mirrors = [
|
|
154
|
+
`https://github.com/zhibaiYingChuan/LRC/releases/download/${release}`,
|
|
155
|
+
`https://ghproxy.net/https://github.com/zhibaiYingChuan/LRC/releases/download/${release}`,
|
|
156
|
+
];
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** 幂等:多次调用只启动一次。 */
|
|
160
|
+
async ensure(): Promise<RuntimeResult> {
|
|
161
|
+
if (this.result !== null) return this.result;
|
|
162
|
+
|
|
163
|
+
// ① 已有实例(配置 baseUrl > LRC_BASE 环境变量)
|
|
164
|
+
const explicit = this.explicitBase;
|
|
165
|
+
if (explicit) {
|
|
166
|
+
if (await isHealthy(explicit)) {
|
|
167
|
+
this.result = { mode: 'external', baseUrl: explicit.replace(/\/+$/, '') };
|
|
168
|
+
this.log(`[lrc] 复用已有实例:${this.result.baseUrl}`);
|
|
169
|
+
return this.result;
|
|
170
|
+
}
|
|
171
|
+
// 显式指定了却连不上:**直接降级,不回落 3099**。
|
|
172
|
+
// 回落会让"配了 3111 却打到 3099",违反端口纪律,且用户无从察觉。
|
|
173
|
+
this.result = {
|
|
174
|
+
mode: 'degraded',
|
|
175
|
+
baseUrl: explicit.replace(/\/+$/, ''),
|
|
176
|
+
reason: `配置的实例不可用:${explicit}`,
|
|
177
|
+
};
|
|
178
|
+
this.log(`[lrc] 降级:${this.result.reason}`);
|
|
179
|
+
return this.result;
|
|
180
|
+
}
|
|
181
|
+
const local = 'http://127.0.0.1:3099';
|
|
182
|
+
if (this.probeLocal && (await isHealthy(local))) {
|
|
183
|
+
this.result = { mode: 'external', baseUrl: local };
|
|
184
|
+
this.log(`[lrc] 复用本地实例:${local}`);
|
|
185
|
+
return this.result;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ② 准备二进制
|
|
189
|
+
let binary: string;
|
|
190
|
+
try {
|
|
191
|
+
binary = await this.ensureBinary();
|
|
192
|
+
} catch (error) {
|
|
193
|
+
this.result = {
|
|
194
|
+
mode: 'degraded',
|
|
195
|
+
baseUrl: explicit ?? local,
|
|
196
|
+
reason: `LRC 二进制不可用:${error instanceof Error ? error.message : String(error)}`,
|
|
197
|
+
};
|
|
198
|
+
this.log(`[lrc] 降级:${this.result.reason}`);
|
|
199
|
+
return this.result;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ③ 启动隔离实例
|
|
203
|
+
try {
|
|
204
|
+
const port = await findFreePort();
|
|
205
|
+
const dataDir = join(this.cacheDir, 'data');
|
|
206
|
+
mkdirSync(dataDir, { recursive: true });
|
|
207
|
+
this.child = spawn(binary, ['--port', String(port), '--data-dir', dataDir], {
|
|
208
|
+
stdio: 'ignore',
|
|
209
|
+
detached: false,
|
|
210
|
+
});
|
|
211
|
+
this.child.on('error', (error) => this.log(`[lrc] 子进程错误:${error.message}`));
|
|
212
|
+
|
|
213
|
+
const baseUrl = `http://127.0.0.1:${port}`;
|
|
214
|
+
const deadline = Date.now() + this.healthTimeoutMs;
|
|
215
|
+
while (Date.now() < deadline) {
|
|
216
|
+
if (await isHealthy(baseUrl, 1000)) {
|
|
217
|
+
this.result = { mode: 'managed', baseUrl };
|
|
218
|
+
this.log(`[lrc] 已启动隔离实例:${baseUrl}(数据目录 ${dataDir})`);
|
|
219
|
+
return this.result;
|
|
220
|
+
}
|
|
221
|
+
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
222
|
+
}
|
|
223
|
+
throw new Error(`健康检查超时(${this.healthTimeoutMs}ms)`);
|
|
224
|
+
} catch (error) {
|
|
225
|
+
this.stop();
|
|
226
|
+
this.result = {
|
|
227
|
+
mode: 'degraded',
|
|
228
|
+
baseUrl: explicit ?? local,
|
|
229
|
+
reason: `LRC 启动失败:${error instanceof Error ? error.message : String(error)}`,
|
|
230
|
+
};
|
|
231
|
+
this.log(`[lrc] 降级:${this.result.reason}`);
|
|
232
|
+
return this.result;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** 确保二进制存在且校验通过。 */
|
|
237
|
+
private async ensureBinary(): Promise<string> {
|
|
238
|
+
const key = platformKey();
|
|
239
|
+
const entry = this.manifest[key];
|
|
240
|
+
if (entry === undefined) {
|
|
241
|
+
throw new Error(
|
|
242
|
+
`没有 ${key} 的二进制清单。请手动安装 LRC 桌面端,或设置 LRC_BASE 指向已有实例。`,
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const binDir = join(this.cacheDir, 'bin');
|
|
247
|
+
const target = join(binDir, binaryName());
|
|
248
|
+
mkdirSync(binDir, { recursive: true });
|
|
249
|
+
|
|
250
|
+
if (existsSync(target) && this.verify(target, entry.sha256)) {
|
|
251
|
+
return target;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const mirrors = this.mirror ? [this.mirror, ...this.mirrors] : this.mirrors;
|
|
255
|
+
let lastError: unknown = null;
|
|
256
|
+
let hashMismatch = false;
|
|
257
|
+
for (const mirror of mirrors) {
|
|
258
|
+
const url = `${mirror.replace(/\/+$/, '')}/${entry.file}`;
|
|
259
|
+
try {
|
|
260
|
+
this.log(`[lrc] 下载 ${url}`);
|
|
261
|
+
const response = await fetch(url, { signal: AbortSignal.timeout(120_000) });
|
|
262
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
263
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
264
|
+
writeFileSync(target, buffer);
|
|
265
|
+
if (!this.verify(target, entry.sha256)) {
|
|
266
|
+
rmSync(target, { force: true });
|
|
267
|
+
hashMismatch = true;
|
|
268
|
+
throw new Error('SHA256 校验失败');
|
|
269
|
+
}
|
|
270
|
+
if (platform() !== 'win32') chmodSync(target, 0o755);
|
|
271
|
+
return target;
|
|
272
|
+
} catch (error) {
|
|
273
|
+
lastError = error;
|
|
274
|
+
this.log(`[lrc] 镜像失败(${mirror}):${error instanceof Error ? error.message : error}`);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
// 哈希不符是**安全信号**,优先级高于网络错误:必须让用户看到真正的原因
|
|
278
|
+
if (hashMismatch) {
|
|
279
|
+
throw new Error(
|
|
280
|
+
`SHA256 校验失败(期望 ${entry.sha256.slice(0, 12)}…)。` +
|
|
281
|
+
'文件可能被篡改或清单过期,已删除下载内容。',
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
throw new Error(
|
|
285
|
+
`所有镜像均失败:${lastError instanceof Error ? lastError.message : String(lastError)}`,
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
private verify(file: string, expected: string): boolean {
|
|
290
|
+
try {
|
|
291
|
+
const actual = createHash('sha256').update(readFileSync(file)).digest('hex');
|
|
292
|
+
return actual === expected.toLowerCase();
|
|
293
|
+
} catch {
|
|
294
|
+
return false;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** 停止托管实例(插件卸载时调用)。 */
|
|
299
|
+
stop(): void {
|
|
300
|
+
if (this.child !== null && !this.child.killed) {
|
|
301
|
+
this.child.kill();
|
|
302
|
+
this.child = null;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 从 DSH 会话事件里抽文本。
|
|
3
|
+
*
|
|
4
|
+
* 单独成模块的原因:这里全是纯函数,不依赖 `@deepseek-ai/*`,
|
|
5
|
+
* 因此可以在没有 DSH 运行时的环境下直接单测(见 `scripts/e2e-host-payload.ts`)。
|
|
6
|
+
*
|
|
7
|
+
* ## 真实结构(来自 app.asar 内 `@deepseek-ai/dsh-session`)
|
|
8
|
+
*
|
|
9
|
+
* ```
|
|
10
|
+
* Session = { id, log: SessionEvent[], surface, header, requestHeader() }
|
|
11
|
+
* SessionEvent = { type, seq, time, data, surfaceOp }
|
|
12
|
+
* assistant/message → data.message.content
|
|
13
|
+
* user/message → data.content
|
|
14
|
+
* ```
|
|
15
|
+
*
|
|
16
|
+
* 注意 `Session` **没有 `lines` 字段** —— 早期实现读 `agent.session.lines`
|
|
17
|
+
* 恒为 `[]`,导致自动沉淀静默空转。
|
|
18
|
+
*/
|
|
19
|
+
import { hasConstraintMarker } from './memory-filter.ts';
|
|
20
|
+
import { classifyEvent, isRealUserEvent } from './event-classify.ts';
|
|
21
|
+
|
|
22
|
+
/** 取 assistant 消息的 content(兼容 `data.message` 与旧版 `message`)。 */
|
|
23
|
+
export function messageContent(event: unknown): unknown {
|
|
24
|
+
const candidate = event as {
|
|
25
|
+
type?: string;
|
|
26
|
+
data?: { message?: { content?: unknown } };
|
|
27
|
+
message?: { content?: unknown };
|
|
28
|
+
};
|
|
29
|
+
if (candidate?.type !== 'assistant/message') return undefined;
|
|
30
|
+
return candidate.data?.message?.content ?? candidate.message?.content;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* 从消息内容里抽纯文本:正文块优先,没有正文时退回推理块。
|
|
35
|
+
*
|
|
36
|
+
* DSH 里大量 assistant 消息只有 `reasoning` + `tool-call`,没有 `text` 块,
|
|
37
|
+
* 只认 `text` 会让沉淀计数器几乎不增长,8 轮阈值永远到不了。
|
|
38
|
+
*/
|
|
39
|
+
export function textFromContent(content: unknown): string {
|
|
40
|
+
if (typeof content === 'string') return content;
|
|
41
|
+
if (!Array.isArray(content)) return '';
|
|
42
|
+
const texts: string[] = [];
|
|
43
|
+
const fallbacks: string[] = [];
|
|
44
|
+
for (const block of content) {
|
|
45
|
+
const b = block as { type?: string; text?: unknown; reasoning?: unknown };
|
|
46
|
+
const value =
|
|
47
|
+
typeof b?.text === 'string' ? b.text : typeof b?.reasoning === 'string' ? b.reasoning : '';
|
|
48
|
+
if (value.length === 0) continue;
|
|
49
|
+
if (b?.type === 'text') texts.push(value);
|
|
50
|
+
else fallbacks.push(value);
|
|
51
|
+
}
|
|
52
|
+
return (texts.length > 0 ? texts : fallbacks).join('\n');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* 只取正文块(`type === 'text'`),**不含推理**。
|
|
57
|
+
*
|
|
58
|
+
* 沉淀源必须用这个:推理流是模型的内部思考,写进长期记忆就是噪声。
|
|
59
|
+
* 实测一个会话因此写进 92 条垃圾,去重率约 1%。
|
|
60
|
+
*/
|
|
61
|
+
export function textBlocksOnly(content: unknown): string {
|
|
62
|
+
if (typeof content === 'string') return content;
|
|
63
|
+
if (!Array.isArray(content)) return '';
|
|
64
|
+
const texts: string[] = [];
|
|
65
|
+
for (const block of content) {
|
|
66
|
+
const b = block as { type?: string; text?: unknown };
|
|
67
|
+
if (b?.type === 'text' && typeof b.text === 'string' && b.text.length > 0) texts.push(b.text);
|
|
68
|
+
}
|
|
69
|
+
return texts.join('\n');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* 只取推理块(`type === 'reasoning'`)。
|
|
74
|
+
*
|
|
75
|
+
* 工具密集型轮次里 assistant 往往只有 reasoning + tool-call,没有正文。
|
|
76
|
+
* 这类轮次无法靠 `<lrc_memory>` 标签沉淀,只能在轮末从推理里抽结论句兜底。
|
|
77
|
+
* 注意:**不能整段入库** —— 实测整段推理会写进 92 条垃圾,去重率约 1%。
|
|
78
|
+
*/
|
|
79
|
+
export function reasoningBlocksOnly(content: unknown): string {
|
|
80
|
+
if (!Array.isArray(content)) return '';
|
|
81
|
+
const texts: string[] = [];
|
|
82
|
+
for (const block of content) {
|
|
83
|
+
const b = block as { type?: string; text?: unknown };
|
|
84
|
+
if (b?.type === 'reasoning' && typeof b.text === 'string' && b.text.length > 0) {
|
|
85
|
+
texts.push(b.text);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return texts.join('\n');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* 是否存在**结构异常**的文本块:声明了 `text` / `reasoning` 但 `text` 不是字符串。
|
|
93
|
+
*
|
|
94
|
+
* 纯 tool-call 消息本来就没有这两类块,不该报警 —— 之前的告警条件
|
|
95
|
+
* 「有内容块却既无 text 又无 reasoning」把 39 条纯工具调用消息全算成异常。
|
|
96
|
+
*/
|
|
97
|
+
export function hasMalformedTextBlock(content: unknown): boolean {
|
|
98
|
+
if (!Array.isArray(content)) return false;
|
|
99
|
+
return content.some((block) => {
|
|
100
|
+
const b = block as { type?: string; text?: unknown };
|
|
101
|
+
return (b?.type === 'text' || b?.type === 'reasoning') && typeof b.text !== 'string';
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** 从单条事件里取 assistant 文本(正文优先,其次推理);仅用于计数与诊断。 */
|
|
106
|
+
export function extractAssistantText(event: unknown): string {
|
|
107
|
+
return textFromContent(messageContent(event));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* 消息里是否有**可提取**的内容块。
|
|
112
|
+
*
|
|
113
|
+
* 只认 `text` / `reasoning`:纯 `tool-call` 消息本来就没有正文,
|
|
114
|
+
* 把它算作"有内容块"会让告警在每次工具调用时误报。
|
|
115
|
+
*/
|
|
116
|
+
export function hasContentBlocks(event: unknown): boolean {
|
|
117
|
+
const content = messageContent(event);
|
|
118
|
+
if (typeof content === 'string') return content.length > 0;
|
|
119
|
+
if (!Array.isArray(content)) return false;
|
|
120
|
+
return content.some((block) => {
|
|
121
|
+
const b = block as { type?: string };
|
|
122
|
+
return b?.type === 'text' || b?.type === 'reasoning';
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* 是否是用户真正说的话。
|
|
128
|
+
*
|
|
129
|
+
* DSH 的 `user/message` 事件里 `data.source.kind` 有多种取值:
|
|
130
|
+
* `user` / `skill-catalog` / `runtime-context` / `time-context` / `model-selection`。
|
|
131
|
+
* 只有 `user` 是用户输入,其余都是宿主注入的上下文 —— 实测一份技能目录
|
|
132
|
+
* 就有 12K 字符,混进沉淀源会直接写满 `maxItems`。
|
|
133
|
+
*
|
|
134
|
+
* 没有 `source` 字段时按用户消息处理(兼容旧格式)。
|
|
135
|
+
*/
|
|
136
|
+
export function isRealUserMessage(event: unknown): boolean {
|
|
137
|
+
// 判定统一走 event-classify.ts,避免各模块各写一套
|
|
138
|
+
return isRealUserEvent(event);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* 用户消息里的**强约束词**。
|
|
143
|
+
*
|
|
144
|
+
* 用户消息是指令,不是结论。只有带约束信号的发言才值得进沉淀源 ——
|
|
145
|
+
* 否则「根据这个文档配色」这类任务描述会被 distill 当成约束入库
|
|
146
|
+
* (实测:文件名里的「规范」二字命中约定词表,整条指令被存成 `[约束]`)。
|
|
147
|
+
*
|
|
148
|
+
* 词表与路径预处理统一在 `memory-filter.ts`(`CONSTRAINT_WORDS` /
|
|
149
|
+
* `hasConstraintMarker`):先挖掉路径 token 再匹配,避免 `docs/禁止事项.md`
|
|
150
|
+
* 里的「禁止」把纯任务指令放进门槛。
|
|
151
|
+
*/
|
|
152
|
+
export function hasUserConstraint(text: string): boolean {
|
|
153
|
+
return hasConstraintMarker(text);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* 从 `agent.session.log` 投影出对话文本行。
|
|
158
|
+
*
|
|
159
|
+
* `log` 是 append-only 的事件数组,正好满足游标"按行号推进"的前提。
|
|
160
|
+
*/
|
|
161
|
+
export function linesFromSession(session: unknown): string[] {
|
|
162
|
+
const log = (session as { log?: unknown })?.log;
|
|
163
|
+
if (!Array.isArray(log)) return [];
|
|
164
|
+
const lines: string[] = [];
|
|
165
|
+
for (const raw of log) {
|
|
166
|
+
const event = raw as {
|
|
167
|
+
type?: string;
|
|
168
|
+
data?: { message?: { content?: unknown }; content?: unknown };
|
|
169
|
+
};
|
|
170
|
+
const category = classifyEvent(event);
|
|
171
|
+
if (category === 'assistant') {
|
|
172
|
+
// 只取正文块:推理流不进沉淀源(否则会把内部思考写成长期记忆)
|
|
173
|
+
const text = textBlocksOnly(event.data?.message?.content);
|
|
174
|
+
if (text.length > 0) lines.push(text);
|
|
175
|
+
} else if (category === 'user') {
|
|
176
|
+
// `user/message` 不只装用户说的话:DSH 会把技能目录、运行时上下文、
|
|
177
|
+
// 时间上下文、模型切换通知都塞进同一个事件类型,靠 `data.source.kind` 区分。
|
|
178
|
+
// 不过滤的话,一份 12K 字符的技能目录会被当成用户发言送进 distill。
|
|
179
|
+
if (!isRealUserMessage(event)) continue;
|
|
180
|
+
const text = textBlocksOnly(event.data?.content);
|
|
181
|
+
if (text.length === 0) continue;
|
|
182
|
+
// 用户消息是指令,不是结论:只有含强约束词才进沉淀源。
|
|
183
|
+
if (!hasUserConstraint(text)) continue;
|
|
184
|
+
lines.push(text);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return lines;
|
|
188
|
+
}
|