diff --git a/README.md b/README.md
index 6a97dc9..1278325 100644
--- a/README.md
+++ b/README.md
@@ -10,7 +10,7 @@
-
+
@@ -657,6 +657,11 @@ OLLAMA_BASE_URL=http://localhost:11434
| `agent.thinkingEnabled` | `true` | 启用 Thinking 推理模式 |
| `agent.thinkingEffort` | `high` | 推理强度 (low / medium / high / max) |
| `agent.confirmationTimeoutMs` | `120000` | 确认弹窗超时 (30s ~ 600s) |
+| `agent.enableReflection` | `false` | 反思阶段开关 — 开启后每轮工具执行经过 REFLECTING 状态(失败结果告警,不阻断) |
+| `memory.consolidationEnabled` | `true` | 会话结束记忆固化总开关(v0.7.3 节流策略) |
+| `memory.consolidationMinChars` | `200` | 固化内容门控:回答字符数阈值(或存在成功工具调用) |
+| `memory.consolidationIntervalMs` | `600000` | 固化频率窗口(同会话两次固化的最小间隔) |
+| `mcp.autoReconnect` | `true` | MCP 断连自动重连(指数退避 5s/15s/60s,最多 3 次) |
| `deepseek.contextWindow` | `1000000` | DeepSeek 上下文窗口 |
| `agnes.contextWindow` | `1000000` | Agnes 上下文窗口 |
| `mimo.contextWindow` | `1000000` | MiMo 上下文窗口 |
@@ -873,8 +878,8 @@ npm run lint:fix # ESLint 自动修复
npm run format # Prettier 格式化
# ─── 测试 ─────────────────────────────────
-npm test # 运行单元测试 (Vitest, 系统 Node — 473 通过, 34 个 SQLite 依赖用例因 better-sqlite3 ABI 自动跳过)
-npm run test:electron # 运行全量单元测试 (Electron Node ABI, 507 用例全执行, 含 SQLite 审计链哈希 + 引擎工具链集成)
+npm test # 运行单元测试 (Vitest, 系统 Node — SQLite 依赖用例因 better-sqlite3 ABI 自动跳过)
+npm run test:electron # 运行全量单元测试 (Electron Node ABI, 全部用例执行, 含 SQLite 审计链哈希 + 引擎工具链集成)
npm run test:watch # 测试监听模式
# ─── 构建 ─────────────────────────────────
diff --git a/electron/harness/adapters/__tests__/anthropic-cache-control.test.ts b/electron/harness/adapters/__tests__/anthropic-cache-control.test.ts
new file mode 100644
index 0000000..f5e7a92
--- /dev/null
+++ b/electron/harness/adapters/__tests__/anthropic-cache-control.test.ts
@@ -0,0 +1,133 @@
+/**
+ * Anthropic system cache_control 断言测试(v0.7.3 P1-1)
+ *
+ * Anthropic 缓存按"内容块前缀"命中:system 必须以块数组传递并在块上打
+ * cache_control 才可缓存。本文件锁定:
+ * C1 非空 system → 块数组 + {type:'ephemeral'};
+ * C2 空 system → 保持空字符串(不发空块);
+ * C3 thinking 模式下断言仍然存在(cache 与 thinking 不互斥);
+ * C4 system 块文本为四分区完整拼接(roleDefinition/outputConstraints/
+ * safetyGuidelines/dynamicReminders)。
+ */
+
+import { describe, it, expect, vi, afterEach } from 'vitest';
+
+vi.mock('electron-log', () => ({
+ default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
+}));
+
+import { AnthropicAdapter } from '../anthropic.adapter';
+import type { MetonaRequest } from '../../types';
+
+function captureFetch(): { bodies: Array> } {
+ const bodies: Array> = [];
+ const genericBody = {
+ content: [{ type: 'text', text: 'ok' }],
+ usage: { input_tokens: 3, output_tokens: 2 },
+ stop_reason: 'end_turn',
+ };
+ const fetchMock = vi.fn(async (_url: string | URL, init?: RequestInit) => {
+ bodies.push(JSON.parse(String(init?.body ?? '{}')) as Record);
+ return new Response(JSON.stringify(genericBody), {
+ status: 200,
+ headers: { 'Content-Type': 'application/json' },
+ });
+ });
+ vi.stubGlobal('fetch', fetchMock);
+ return { bodies };
+}
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+});
+
+function makeAdapter(): AnthropicAdapter {
+ return new AnthropicAdapter({
+ provider: 'anthropic',
+ baseURL: 'http://a.test',
+ apiKey: 'k',
+ defaultModel: 'claude-sonnet-4-5',
+ });
+}
+
+function makeRequest(overrides?: Partial): MetonaRequest {
+ return {
+ meta: {
+ sessionId: 's1',
+ iteration: 1,
+ requestId: 'r1',
+ timestamp: Date.now(),
+ agentVersion: 'test',
+ },
+ systemPrompt: {
+ roleDefinition: 'You are Metona.',
+ outputConstraints: 'Be concise.',
+ safetyGuidelines: 'Stay safe.',
+ dynamicReminders: '## Current Workspace\n`/ws`',
+ },
+ messages: [{ role: 'user', content: 'hi', timestamp: Date.now() }],
+ params: { maxTokens: 63_488, temperature: 0, stream: false },
+ ...overrides,
+ };
+}
+
+describe('AnthropicAdapter — system cache_control(P1-1)', () => {
+ it('C1: 非空 system → 单 text 块 + cache_control ephemeral', async () => {
+ const adapter = makeAdapter();
+ const { bodies } = captureFetch();
+ await adapter.send(makeRequest());
+
+ const system = bodies[0].system as Array<{
+ type: string;
+ text: string;
+ cache_control: { type: string };
+ }>;
+ expect(Array.isArray(system)).toBe(true);
+ expect(system).toHaveLength(1);
+ expect(system[0].type).toBe('text');
+ expect(system[0].cache_control).toEqual({ type: 'ephemeral' });
+ });
+
+ it('C2: 空 system → 保持空字符串(不发空块)', async () => {
+ const adapter = makeAdapter();
+ const { bodies } = captureFetch();
+ await adapter.send(
+ makeRequest({
+ systemPrompt: { roleDefinition: '', outputConstraints: '', safetyGuidelines: '' },
+ }),
+ );
+ expect(bodies[0].system).toBe('');
+ });
+
+ it('C3: thinking 模式下 cache_control 断言仍然存在', async () => {
+ const adapter = makeAdapter();
+ const { bodies } = captureFetch();
+ await adapter.send(
+ makeRequest({
+ params: {
+ maxTokens: 8192,
+ temperature: 0,
+ stream: false,
+ thinkingEnabled: true,
+ thinkingEffort: 'high',
+ },
+ }),
+ );
+ const system = bodies[0].system as Array<{ cache_control: { type: string } }>;
+ expect(system[0].cache_control).toEqual({ type: 'ephemeral' });
+ // thinking 与 cache 共存:thinking 块也在请求体中
+ expect(bodies[0].thinking).toMatchObject({ type: 'enabled' });
+ });
+
+ it('C4: system 块文本为四分区完整拼接', async () => {
+ const adapter = makeAdapter();
+ const { bodies } = captureFetch();
+ await adapter.send(makeRequest());
+
+ const system = bodies[0].system as Array<{ text: string }>;
+ expect(system[0].text).toContain('You are Metona.');
+ expect(system[0].text).toContain('Be concise.');
+ expect(system[0].text).toContain('Stay safe.');
+ expect(system[0].text).toContain('## Current Workspace');
+ });
+});
diff --git a/electron/harness/adapters/__tests__/provider-request-shapes.test.ts b/electron/harness/adapters/__tests__/provider-request-shapes.test.ts
index 7378ce9..a5331db 100644
--- a/electron/harness/adapters/__tests__/provider-request-shapes.test.ts
+++ b/electron/harness/adapters/__tests__/provider-request-shapes.test.ts
@@ -109,29 +109,69 @@ describe('AnthropicAdapter — 请求体契约', () => {
role: 'assistant',
content: null,
toolCalls: [
- { id: 'tc_1', name: 'read_file', args: { path: 'a.txt' }, iteration: 1, timestamp: Date.now() },
+ {
+ id: 'tc_1',
+ name: 'read_file',
+ args: { path: 'a.txt' },
+ iteration: 1,
+ timestamp: Date.now(),
+ },
],
timestamp: Date.now(),
},
- { role: 'tool', content: null, toolResult: { toolCallId: 'tc_1', toolName: 'read_file', result: 'data', success: true, durationMs: 1, timestamp: Date.now() }, timestamp: Date.now() },
+ {
+ role: 'tool',
+ content: null,
+ toolResult: {
+ toolCallId: 'tc_1',
+ toolName: 'read_file',
+ result: 'data',
+ success: true,
+ durationMs: 1,
+ timestamp: Date.now(),
+ },
+ timestamp: Date.now(),
+ },
// 孤立 tool_result(前面没有对应 tool_use)应被过滤
- { role: 'tool', content: null, toolResult: { toolCallId: 'tc_orphan', toolName: 'x', result: '', success: true, durationMs: 1, timestamp: Date.now() }, timestamp: Date.now() },
+ {
+ role: 'tool',
+ content: null,
+ toolResult: {
+ toolCallId: 'tc_orphan',
+ toolName: 'x',
+ result: '',
+ success: true,
+ durationMs: 1,
+ timestamp: Date.now(),
+ },
+ timestamp: Date.now(),
+ },
{ role: 'user', content: 'next?', timestamp: Date.now() },
],
}),
);
const body = bodies[0];
- expect(body.system).toContain('You are Metona.');
+ // v0.7.3 P1-1: system 转为块数组并打 cache_control 断言(稳定前缀 prompt cache)
+ const system = body.system as Array<{
+ type: string;
+ text: string;
+ cache_control: { type: string };
+ }>;
+ expect(Array.isArray(system)).toBe(true);
+ expect(system[0].text).toContain('You are Metona.');
+ expect(system[0].cache_control).toEqual({ type: 'ephemeral' });
expect(Array.isArray(body.messages)).toBe(true);
const msgs = body.messages as Array<{ role: string; content: Array> }>;
// tool_use 的 assistant 消息存在且携带 id/name
const assistantToolMsg = msgs.find((m) => m.role === 'assistant');
- expect(assistantToolMsg?.content[0]).toMatchObject({ type: 'tool_use', id: 'tc_1', name: 'read_file' });
+ expect(assistantToolMsg?.content[0]).toMatchObject({
+ type: 'tool_use',
+ id: 'tc_1',
+ name: 'read_file',
+ });
// tool 结果以 user 角色 tool_result 形态出现且配对 id 正确;孤立者被丢弃
- const toolResultBlocks = msgs.flatMap((m) =>
- m.content.filter((c) => c.type === 'tool_result'),
- );
+ const toolResultBlocks = msgs.flatMap((m) => m.content.filter((c) => c.type === 'tool_result'));
expect(toolResultBlocks).toHaveLength(1);
expect(toolResultBlocks[0].tool_use_id).toBe('tc_1');
});
@@ -166,7 +206,15 @@ describe('AnthropicAdapter — 请求体契约', () => {
});
const { bodies } = captureFetch();
await adapter.send(
- makeRequest({ params: { maxTokens: 1500, temperature: 0, stream: false, thinkingEnabled: true, thinkingEffort: 'low' } }),
+ makeRequest({
+ params: {
+ maxTokens: 1500,
+ temperature: 0,
+ stream: false,
+ thinkingEnabled: true,
+ thinkingEffort: 'low',
+ },
+ }),
);
const body = bodies[0];
const thinking = body.thinking as { type: string; budget_tokens: number };
@@ -185,13 +233,17 @@ describe('AnthropicAdapter — 请求体契约', () => {
});
const { bodies } = captureFetch();
await adapter.send(
- makeRequest({ params: { maxTokens: 4096, temperature: 0.7, stream: false, thinkingEnabled: true } }),
+ makeRequest({
+ params: { maxTokens: 4096, temperature: 0.7, stream: false, thinkingEnabled: true },
+ }),
);
expect(bodies[0].temperature).toBeUndefined();
expect(bodies[0].thinking).toBeDefined();
await adapter.send(
- makeRequest({ params: { maxTokens: 4096, temperature: 0.7, stream: false, thinkingEnabled: false } }),
+ makeRequest({
+ params: { maxTokens: 4096, temperature: 0.7, stream: false, thinkingEnabled: false },
+ }),
);
expect(bodies[1].temperature).toBe(0.7);
expect(bodies[1].thinking).toBeUndefined();
@@ -236,13 +288,37 @@ describe('OllamaAdapter — 请求体契约', () => {
const adapter = makeOllama();
const { bodies } = captureFetch();
- await adapter.send(makeRequest({ params: { maxTokens: 4096, temperature: 0, stream: false, thinkingEnabled: true, thinkingEffort: 'low' } }));
+ await adapter.send(
+ makeRequest({
+ params: {
+ maxTokens: 4096,
+ temperature: 0,
+ stream: false,
+ thinkingEnabled: true,
+ thinkingEffort: 'low',
+ },
+ }),
+ );
expect(bodies[0].think).toBe('low');
- await adapter.send(makeRequest({ params: { maxTokens: 4096, temperature: 0, stream: false, thinkingEnabled: true, thinkingEffort: 'max' } }));
+ await adapter.send(
+ makeRequest({
+ params: {
+ maxTokens: 4096,
+ temperature: 0,
+ stream: false,
+ thinkingEnabled: true,
+ thinkingEffort: 'max',
+ },
+ }),
+ );
expect(bodies[1].think).toBe(true);
- await adapter.send(makeRequest({ params: { maxTokens: 4096, temperature: 0, stream: false, thinkingEnabled: false } }));
+ await adapter.send(
+ makeRequest({
+ params: { maxTokens: 4096, temperature: 0, stream: false, thinkingEnabled: false },
+ }),
+ );
expect(bodies[2].think).toBeUndefined();
});
@@ -319,12 +395,26 @@ describe('AgnesAdapter — 思考模式对称性(v0.6.4)', () => {
});
const { bodies } = captureFetch();
- await adapter.send(makeRequest({ params: { maxTokens: 4096, temperature: 0, stream: false, thinkingEnabled: true, thinkingEffort: 'high' } }));
+ await adapter.send(
+ makeRequest({
+ params: {
+ maxTokens: 4096,
+ temperature: 0,
+ stream: false,
+ thinkingEnabled: true,
+ thinkingEffort: 'high',
+ },
+ }),
+ );
expect(
((bodies[0].chat_template_kwargs as Record) ?? {}).enable_thinking,
).toBe(true);
- await adapter.send(makeRequest({ params: { maxTokens: 4096, temperature: 0, stream: false, thinkingEnabled: false } }));
+ await adapter.send(
+ makeRequest({
+ params: { maxTokens: 4096, temperature: 0, stream: false, thinkingEnabled: false },
+ }),
+ );
expect(
((bodies[1].chat_template_kwargs as Record) ?? {}).enable_thinking,
).toBe(false);
diff --git a/electron/harness/adapters/anthropic.adapter.ts b/electron/harness/adapters/anthropic.adapter.ts
index f7a20a5..8bc3d68 100644
--- a/electron/harness/adapters/anthropic.adapter.ts
+++ b/electron/harness/adapters/anthropic.adapter.ts
@@ -248,8 +248,7 @@ export class AnthropicAdapter extends BaseAdapter {
usage: {
inputTokens: messageStartInputTokens,
outputTokens: (usage.output_tokens as number) ?? 0,
- totalTokens:
- messageStartInputTokens + ((usage.output_tokens as number) ?? 0),
+ totalTokens: messageStartInputTokens + ((usage.output_tokens as number) ?? 0),
// v0.6.4: 补采 Anthropic 自己的缓存字段(其他 provider 均已采集,
// cache_read/creation_input_tokens 与 output_tokens 同在 usage 内)
cacheHitTokens: (usage.cache_read_input_tokens as number) ?? undefined,
@@ -343,10 +342,7 @@ export class AnthropicAdapter extends BaseAdapter {
try {
args = block.argsBuffer ? JSON.parse(block.argsBuffer) : {};
} catch (err) {
- args = truncatedArgumentsPayload(
- (err as Error).message,
- block.argsBuffer.slice(-120),
- );
+ args = truncatedArgumentsPayload((err as Error).message, block.argsBuffer.slice(-120));
}
yield {
type: MetonaStreamEventType.TOOL_CALL_COMPLETE,
@@ -506,11 +502,22 @@ export class AnthropicAdapter extends BaseAdapter {
const body: Record = {
model: this.config.defaultModel,
max_tokens: maxTokensForRequest,
- system,
messages: merged,
stream,
};
+ // v0.7.3 P1-1: system 稳定前缀打 prompt cache 断言。
+ // Anthropic 缓存按"内容块前缀"命中 —— system 以字符串传递时无法附加
+ // cache_control,必须转为块数组并在最后一个块上打 {type:'ephemeral'}。
+ // 缓存前缀覆盖 tools + system(请求组装顺序 tools 在前):system 稳定后,
+ // 多轮对话/多轮迭代复用同一前缀,输入 token 计费按缓存读价(约 1/10)。
+ // 前缀稳定性由 P1-1 保证:易变内容(日期/记忆/附件提示)已迁入用户消息。
+ if (system) {
+ body.system = [{ type: 'text', text: system, cache_control: { type: 'ephemeral' } }];
+ } else {
+ body.system = system;
+ }
+
// 工具定义(input_schema 命名)
if (request.tools?.length) {
body.tools = request.tools.map((t) => ({
diff --git a/electron/harness/adapters/ollama.adapter.ts b/electron/harness/adapters/ollama.adapter.ts
index d5b17cc..b31f5fe 100644
--- a/electron/harness/adapters/ollama.adapter.ts
+++ b/electron/harness/adapters/ollama.adapter.ts
@@ -59,17 +59,21 @@ export class OllamaAdapter extends BaseAdapter {
const nativeRequest = await this.toNativeRequest(request);
// #24 修复: 使用 fetchWithTimeout 替代 getFetchSignal + fetch,确保 timer 清理
- const response = await this.fetchWithTimeout(`${this.baseURL}/api/chat`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ ...nativeRequest, stream: false }),
- }, this.config.timeoutMs ?? 300_000);
+ const response = await this.fetchWithTimeout(
+ `${this.baseURL}/api/chat`,
+ {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ ...nativeRequest, stream: false }),
+ },
+ this.config.timeoutMs ?? 300_000,
+ );
if (!response.ok) {
await this.throwHttpError(response, 'Ollama API error');
}
- const data = await response.json() as Record;
+ const data = (await response.json()) as Record;
return this.toMetonaResponse(data, request.meta.requestId, request.meta.iteration);
}
@@ -79,11 +83,15 @@ export class OllamaAdapter extends BaseAdapter {
const nativeRequest = await this.toNativeRequest(request);
// #24 修复: 使用 fetchWithTimeout 替代 getFetchSignal + fetch,确保 timer 清理
- const response = await this.fetchWithTimeout(`${this.baseURL}/api/chat`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ ...nativeRequest, stream: true }),
- }, this.config.timeoutMs ?? 300_000);
+ const response = await this.fetchWithTimeout(
+ `${this.baseURL}/api/chat`,
+ {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ ...nativeRequest, stream: true }),
+ },
+ this.config.timeoutMs ?? 300_000,
+ );
if (!response.ok || !response.body) {
await this.throwHttpError(response, 'Ollama stream error');
@@ -209,7 +217,10 @@ export class OllamaAdapter extends BaseAdapter {
}
} catch (parseErr) {
// P2-8 修复: 与 sse-stream.ts 一致,记录解析失败行便于诊断
- log.warn(`[Ollama] Failed to parse NDJSON line: ${(parseErr as Error).message}`, trimmed.slice(0, 200));
+ log.warn(
+ `[Ollama] Failed to parse NDJSON line: ${(parseErr as Error).message}`,
+ trimmed.slice(0, 200),
+ );
}
}
}
@@ -239,7 +250,13 @@ export class OllamaAdapter extends BaseAdapter {
format?: string | object;
images?: string[];
options?: Record;
- }): Promise<{ response: string; thinking?: string; done: boolean; totalDuration: number; evalCount: number }> {
+ }): Promise<{
+ response: string;
+ thinking?: string;
+ done: boolean;
+ totalDuration: number;
+ evalCount: number;
+ }> {
const response = await fetch(`${this.baseURL}/api/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -248,9 +265,12 @@ export class OllamaAdapter extends BaseAdapter {
});
if (!response.ok) throw new Error(`Ollama generate error: ${response.status}`);
- const data = await response.json() as {
- response?: string; thinking?: string; done?: boolean;
- total_duration?: number; eval_count?: number;
+ const data = (await response.json()) as {
+ response?: string;
+ thinking?: string;
+ done?: boolean;
+ total_duration?: number;
+ eval_count?: number;
};
return {
@@ -277,7 +297,7 @@ export class OllamaAdapter extends BaseAdapter {
});
if (!response.ok) throw new Error(`Ollama embed error: ${response.status}`);
- const data = await response.json() as { embeddings?: number[][]; total_duration?: number };
+ const data = (await response.json()) as { embeddings?: number[][]; total_duration?: number };
return {
embeddings: data.embeddings ?? [],
@@ -299,7 +319,7 @@ export class OllamaAdapter extends BaseAdapter {
signal: AbortSignal.timeout(10_000),
});
if (response.ok) {
- const data = await response.json() as {
+ const data = (await response.json()) as {
models?: Array<{
name: string;
size?: number;
@@ -309,6 +329,8 @@ export class OllamaAdapter extends BaseAdapter {
if (data.models?.length) {
// v0.6.4 P4-1: 能力标志改为逐模型 /api/show 实测探测;单个探测失败
// 该模型回退保守 true(不可用时行为与旧实现一致,fail-open 保可用性)
+ // v0.7.3 P1-4: supportsVision 随探测结果透出(undefined = 未知 → 前端保守放行),
+ // 供上传入口拒绝不支持图片的本地语言模型
const enriched = await Promise.all(
data.models.map(async (m) => {
const caps = await this.probeCapabilities(m.name);
@@ -319,6 +341,7 @@ export class OllamaAdapter extends BaseAdapter {
contextWindow: OllamaAdapter.DEFAULT_CONTEXT_WINDOW,
supportsToolCalling: caps ? caps.supportsTools : true,
supportsThinking: caps ? caps.supportsThinking : true,
+ supportsVision: caps ? caps.supportsVision : undefined,
description: m.details
? `${m.details.family ?? 'unknown'} / ${m.details.parameter_size ?? '?'} / ${m.details.quantization_level ?? '?'}`
: undefined,
@@ -403,7 +426,9 @@ export class OllamaAdapter extends BaseAdapter {
// ===== POST /api/show =====
- async showModel(model: string): Promise<{ parameters: string; template: string; capabilities: string[] } | null> {
+ async showModel(
+ model: string,
+ ): Promise<{ parameters: string; template: string; capabilities: string[] } | null> {
try {
const response = await fetch(`${this.baseURL}/api/show`, {
method: 'POST',
@@ -412,7 +437,11 @@ export class OllamaAdapter extends BaseAdapter {
signal: AbortSignal.timeout(10_000),
});
if (!response.ok) return null;
- const data = await response.json() as { parameters?: string; template?: string; capabilities?: string[] };
+ const data = (await response.json()) as {
+ parameters?: string;
+ template?: string;
+ capabilities?: string[];
+ };
return {
parameters: data.parameters ?? '',
template: data.template ?? '',
@@ -470,13 +499,22 @@ export class OllamaAdapter extends BaseAdapter {
// ===== GET /api/ps =====
- async listRunning(): Promise> {
+ async listRunning(): Promise<
+ Array<{ name: string; size: number; sizeVram: number; contextLength: number }>
+ > {
try {
const response = await fetch(`${this.baseURL}/api/ps`, {
signal: AbortSignal.timeout(10_000),
});
if (!response.ok) return [];
- const data = await response.json() as { models?: Array<{ name: string; size?: number; size_vram?: number; context_length?: number }> };
+ const data = (await response.json()) as {
+ models?: Array<{
+ name: string;
+ size?: number;
+ size_vram?: number;
+ context_length?: number;
+ }>;
+ };
return (data.models ?? []).map((m) => ({
name: m.name ?? '',
size: m.size ?? 0,
@@ -496,7 +534,7 @@ export class OllamaAdapter extends BaseAdapter {
signal: AbortSignal.timeout(5_000),
});
if (!response.ok) return 'unknown';
- const data = await response.json() as { version?: string };
+ const data = (await response.json()) as { version?: string };
return data.version ?? 'unknown';
} catch {
return 'unknown';
@@ -525,7 +563,9 @@ export class OllamaAdapter extends BaseAdapter {
const buf = Buffer.from(await res.arrayBuffer());
return buf.toString('base64');
} catch (error) {
- log.warn(`[Ollama] Failed to download image ${url.slice(0, 100)}: ${(error as Error).message}`);
+ log.warn(
+ `[Ollama] Failed to download image ${url.slice(0, 100)}: ${(error as Error).message}`,
+ );
return '';
}
}
@@ -539,58 +579,60 @@ export class OllamaAdapter extends BaseAdapter {
request.systemPrompt.outputConstraints,
request.systemPrompt.safetyGuidelines,
request.systemPrompt.dynamicReminders,
- ].filter(Boolean).join('\n\n'),
+ ]
+ .filter(Boolean)
+ .join('\n\n'),
},
];
// #2 修复: 改为 for 循环以支持 async 图片下载(map 回调无法 await)
for (const m of request.messages) {
if (m.role === 'system') continue;
- // C-6 修复: Ollama API 不支持 null content,assistant 仅有 tool_calls 时转为空字符串
- const msg: Record = { role: m.role, content: m.content ?? '' };
- // Ollama 图片使用 images 字段(纯 base64 数组,不含 data: 前缀)
- if (m.images?.length) {
- // #2 修复: 支持公网 URL 图片,下载后转为纯 base64
- // 之前直接将 URL 字符串传给 Ollama,导致 base64 解码错误
- const resolvedImages: string[] = [];
- for (const img of m.images) {
- const url = img.url;
- if (url.startsWith('data:')) {
- // data:image/png;base64,iVBOR... → iVBOR...
- const base64Part = url.split(',')[1];
- resolvedImages.push(base64Part ?? url);
- } else if (url.startsWith('http://') || url.startsWith('https://')) {
- // #2 修复: 公网 URL → 下载 → 纯 base64
- const base64 = await this.resolveImageToBase64(url);
- if (base64) resolvedImages.push(base64);
- } else {
- // 已是纯 base64 字符串(无 data: 前缀)
- resolvedImages.push(url);
- }
+ // C-6 修复: Ollama API 不支持 null content,assistant 仅有 tool_calls 时转为空字符串
+ const msg: Record = { role: m.role, content: m.content ?? '' };
+ // Ollama 图片使用 images 字段(纯 base64 数组,不含 data: 前缀)
+ if (m.images?.length) {
+ // #2 修复: 支持公网 URL 图片,下载后转为纯 base64
+ // 之前直接将 URL 字符串传给 Ollama,导致 base64 解码错误
+ const resolvedImages: string[] = [];
+ for (const img of m.images) {
+ const url = img.url;
+ if (url.startsWith('data:')) {
+ // data:image/png;base64,iVBOR... → iVBOR...
+ const base64Part = url.split(',')[1];
+ resolvedImages.push(base64Part ?? url);
+ } else if (url.startsWith('http://') || url.startsWith('https://')) {
+ // #2 修复: 公网 URL → 下载 → 纯 base64
+ const base64 = await this.resolveImageToBase64(url);
+ if (base64) resolvedImages.push(base64);
+ } else {
+ // 已是纯 base64 字符串(无 data: 前缀)
+ resolvedImages.push(url);
}
- msg.images = resolvedImages;
}
- // 工具结果
- if (m.role === 'tool' && m.toolResult) {
- msg.tool_call_id = m.toolResult.toolCallId;
- // CE-2 修复: 工具失败时 result 为 null,优先用 error 字段作为 content
- msg.content = m.toolResult.error
- ? m.toolResult.error
- : (typeof m.toolResult.result === 'string'
- ? m.toolResult.result
- : JSON.stringify(m.toolResult.result));
- }
- // assistant 工具调用(Ollama REST API 要求 arguments 为 JSON 字符串)
- if (m.role === 'assistant' && m.toolCalls?.length) {
- msg.tool_calls = m.toolCalls.map((tc) => ({
- function: { name: tc.name, arguments: JSON.stringify(tc.args) },
- }));
- }
- // 推理内容回传(保持多轮推理链完整)
- if (m.role === 'assistant' && m.reasoningContent) {
- (msg as Record).reasoning_content = m.reasoningContent;
- }
- messages.push(msg);
+ msg.images = resolvedImages;
+ }
+ // 工具结果
+ if (m.role === 'tool' && m.toolResult) {
+ msg.tool_call_id = m.toolResult.toolCallId;
+ // CE-2 修复: 工具失败时 result 为 null,优先用 error 字段作为 content
+ msg.content = m.toolResult.error
+ ? m.toolResult.error
+ : typeof m.toolResult.result === 'string'
+ ? m.toolResult.result
+ : JSON.stringify(m.toolResult.result);
+ }
+ // assistant 工具调用(Ollama REST API 要求 arguments 为 JSON 字符串)
+ if (m.role === 'assistant' && m.toolCalls?.length) {
+ msg.tool_calls = m.toolCalls.map((tc) => ({
+ function: { name: tc.name, arguments: JSON.stringify(tc.args) },
+ }));
+ }
+ // 推理内容回传(保持多轮推理链完整)
+ if (m.role === 'assistant' && m.reasoningContent) {
+ (msg as Record).reasoning_content = m.reasoningContent;
+ }
+ messages.push(msg);
}
const body: Record = {
@@ -619,14 +661,23 @@ export class OllamaAdapter extends BaseAdapter {
// Thinking 模式
if (request.params.thinkingEnabled) {
- const effortMap: Record = { low: 'low', medium: 'medium', high: 'high', max: true };
+ const effortMap: Record = {
+ low: 'low',
+ medium: 'medium',
+ high: 'high',
+ max: true,
+ };
body.think = effortMap[request.params.thinkingEffort ?? 'high'] ?? true;
}
return body;
}
- private toMetonaResponse(data: Record, requestId: string, iteration: number = 0): MetonaResponse {
+ private toMetonaResponse(
+ data: Record,
+ requestId: string,
+ iteration: number = 0,
+ ): MetonaResponse {
const message = data.message as Record | undefined;
const toolCalls = message?.tool_calls as Array> | undefined;
return {
@@ -638,11 +689,14 @@ export class OllamaAdapter extends BaseAdapter {
timestamp: Date.now(),
perfStats: {
loadDurationMs: data.load_duration ? (data.load_duration as number) / 1e6 : undefined,
- promptEvalDurationMs: data.prompt_eval_duration ? (data.prompt_eval_duration as number) / 1e6 : undefined,
- evalDurationMs: data.eval_duration ? (data.eval_duration as number) / 1e6 : undefined,
- tokensPerSecond: data.eval_count && data.eval_duration
- ? ((data.eval_count as number) / ((data.eval_duration as number) / 1e9))
+ promptEvalDurationMs: data.prompt_eval_duration
+ ? (data.prompt_eval_duration as number) / 1e6
: undefined,
+ evalDurationMs: data.eval_duration ? (data.eval_duration as number) / 1e6 : undefined,
+ tokensPerSecond:
+ data.eval_count && data.eval_duration
+ ? (data.eval_count as number) / ((data.eval_duration as number) / 1e9)
+ : undefined,
},
},
content: (message?.content as string) ?? '',
@@ -652,7 +706,10 @@ export class OllamaAdapter extends BaseAdapter {
const rawArgs = fn?.arguments;
let args: Record = {};
try {
- args = typeof rawArgs === 'string' ? JSON.parse(rawArgs) : (rawArgs as Record) ?? {};
+ args =
+ typeof rawArgs === 'string'
+ ? JSON.parse(rawArgs)
+ : ((rawArgs as Record) ?? {});
} catch (parseErr) {
// v0.6.4: 非流式路径截断自愈对齐 —— 原 catch 静默降级 {},与流式修复后的
// 行为不一致。统一转为 _truncatedArguments 错误参数。
@@ -677,7 +734,10 @@ export class OllamaAdapter extends BaseAdapter {
outputTokens: (data.eval_count as number) ?? 0,
totalTokens: ((data.prompt_eval_count as number) ?? 0) + ((data.eval_count as number) ?? 0),
},
- finishReason: mapOllamaDoneReason(data.done_reason as string | undefined, !!message?.tool_calls),
+ finishReason: mapOllamaDoneReason(
+ data.done_reason as string | undefined,
+ !!message?.tool_calls,
+ ),
};
}
}
@@ -693,10 +753,15 @@ function mapOllamaDoneReason(
): MetonaFinishReason {
if (hasToolCalls) return MetonaFinishReason.TOOL_CALLS;
switch (reason) {
- case 'stop': return MetonaFinishReason.STOP;
- case 'length': return MetonaFinishReason.LENGTH;
- case 'load': return MetonaFinishReason.STOP; // 冷启动加载完成,非错误
- case 'unload': return MetonaFinishReason.STOP;
- default: return MetonaFinishReason.STOP;
+ case 'stop':
+ return MetonaFinishReason.STOP;
+ case 'length':
+ return MetonaFinishReason.LENGTH;
+ case 'load':
+ return MetonaFinishReason.STOP; // 冷启动加载完成,非错误
+ case 'unload':
+ return MetonaFinishReason.STOP;
+ default:
+ return MetonaFinishReason.STOP;
}
}
diff --git a/electron/harness/agent-loop/__tests__/engine.test.ts b/electron/harness/agent-loop/__tests__/engine.test.ts
index 9f9aa43..1aec24e 100644
--- a/electron/harness/agent-loop/__tests__/engine.test.ts
+++ b/electron/harness/agent-loop/__tests__/engine.test.ts
@@ -15,7 +15,10 @@ import type { IMetonaProviderAdapter, MetonaResponse, MetonaStreamEvent } from '
import { MetonaStreamEventType } from '../../types';
/** 构造 Mock Adapter:sendStream 按脚本产出事件 */
-function createMockAdapter(scripts: MetonaStreamEvent[][], opts?: { failWith?: Error }): IMetonaProviderAdapter {
+function createMockAdapter(
+ scripts: MetonaStreamEvent[][],
+ opts?: { failWith?: Error },
+): IMetonaProviderAdapter {
let call = 0;
return {
providerId: 'mock',
@@ -23,12 +26,20 @@ function createMockAdapter(scripts: MetonaStreamEvent[][], opts?: { failWith?: E
supportsToolCalling: true,
supportsThinking: false,
getContextWindow: () => 1_000_000,
- send: vi.fn(async (): Promise => ({
- meta: { requestId: 'r_test', provider: 'mock', model: 'mock-model', latencyMs: 1, timestamp: Date.now() },
- content: 'ok',
- usage: { inputTokens: 10, outputTokens: 5, totalTokens: 15 },
- finishReason: 'stop' as never,
- })),
+ send: vi.fn(
+ async (): Promise => ({
+ meta: {
+ requestId: 'r_test',
+ provider: 'mock',
+ model: 'mock-model',
+ latencyMs: 1,
+ timestamp: Date.now(),
+ },
+ content: 'ok',
+ usage: { inputTokens: 10, outputTokens: 5, totalTokens: 15 },
+ finishReason: 'stop' as never,
+ }),
+ ),
sendStream: vi.fn(async function* (): AsyncIterable {
if (opts?.failWith) throw opts.failWith;
const script = scripts[call % scripts.length];
@@ -42,8 +53,23 @@ function createMockAdapter(scripts: MetonaStreamEvent[][], opts?: { failWith?: E
function textDoneEvent(text: string): MetonaStreamEvent[] {
return [
- { type: MetonaStreamEventType.TEXT_DELTA, requestId: 'r1', sessionId: 's1', iteration: 1, seq: 0, timestamp: Date.now(), delta: text },
- { type: MetonaStreamEventType.DONE, requestId: 'r1', sessionId: 's1', iteration: 1, seq: 1, timestamp: Date.now() },
+ {
+ type: MetonaStreamEventType.TEXT_DELTA,
+ requestId: 'r1',
+ sessionId: 's1',
+ iteration: 1,
+ seq: 0,
+ timestamp: Date.now(),
+ delta: text,
+ },
+ {
+ type: MetonaStreamEventType.DONE,
+ requestId: 'r1',
+ sessionId: 's1',
+ iteration: 1,
+ seq: 1,
+ timestamp: Date.now(),
+ },
];
}
@@ -51,10 +77,21 @@ function toolCallEvent(name: string, args: Record): MetonaStrea
return [
{
type: MetonaStreamEventType.TOOL_CALL_COMPLETE,
- requestId: 'r1', sessionId: 's1', iteration: 1, seq: 0, timestamp: Date.now(),
+ requestId: 'r1',
+ sessionId: 's1',
+ iteration: 1,
+ seq: 0,
+ timestamp: Date.now(),
toolCall: { id: 'tc_test', name, args, iteration: 1, timestamp: Date.now() },
},
- { type: MetonaStreamEventType.DONE, requestId: 'r1', sessionId: 's1', iteration: 1, seq: 1, timestamp: Date.now() },
+ {
+ type: MetonaStreamEventType.DONE,
+ requestId: 'r1',
+ sessionId: 's1',
+ iteration: 1,
+ seq: 1,
+ timestamp: Date.now(),
+ },
];
}
@@ -121,7 +158,9 @@ describe('AgentLoopEngine', () => {
});
it('不可重试错误直接 ERROR(无 fallback 时)', async () => {
- const adapter = createMockAdapter([], { failWith: Object.assign(new Error('401 unauthorized'), { status: 401 }) });
+ const adapter = createMockAdapter([], {
+ failWith: Object.assign(new Error('401 unauthorized'), { status: 401 }),
+ });
const engine = new AgentLoopEngine({ retryCount: 0 }, adapter);
const output = await engine.runStream(userMessage, 's1', [], systemPrompt);
expect(output.terminationReason).toBe(TerminationReason.ERROR);
@@ -129,7 +168,9 @@ describe('AgentLoopEngine', () => {
it('P1 故障转移:主 Provider 失败后切换到 fallback Provider', async () => {
// 主 adapter 每次都失败(401 不可重试)
- const primary = createMockAdapter([], { failWith: Object.assign(new Error('401 invalid key'), { status: 401 }) });
+ const primary = createMockAdapter([], {
+ failWith: Object.assign(new Error('401 invalid key'), { status: 401 }),
+ });
// fallback 正常返回
const fallback = createMockAdapter([textDoneEvent('fallback answer')]);
@@ -151,8 +192,12 @@ describe('AgentLoopEngine', () => {
});
it('P1 故障转移仅触发一次(fallback 也失败不回切)', async () => {
- const primary = createMockAdapter([], { failWith: Object.assign(new Error('401'), { status: 401 }) });
- const fallback = createMockAdapter([], { failWith: Object.assign(new Error('500'), { status: 500 }) });
+ const primary = createMockAdapter([], {
+ failWith: Object.assign(new Error('401'), { status: 401 }),
+ });
+ const fallback = createMockAdapter([], {
+ failWith: Object.assign(new Error('500'), { status: 500 }),
+ });
const engine = new AgentLoopEngine({ retryCount: 0 }, primary);
engine.setFallbackAdapter(fallback);
@@ -162,3 +207,72 @@ describe('AgentLoopEngine', () => {
expect(output.terminationReason).toBe(TerminationReason.ERROR);
});
});
+
+// ===== v0.7.3 P4-4 / P3-1: 死循环乒乓检测 + REFLECTING 状态接线 =====
+
+describe('AgentLoopEngine — 死循环乒乓检测(ABAB,P4-4)', () => {
+ it('最近 4 轮 A→B→A→B 交替(A≠B)触发 DEAD_LOOP(驻留模式抓不住的乒乓)', async () => {
+ const readScript = toolCallEvent('read_file', { file_path: 'x.ts' });
+ const writeScript = toolCallEvent('write_file', { file_path: 'x.ts' });
+ // 1:read 2:write 3:read 4:write ← 第 4 轮 PARSING 时滑窗构成 ABAB
+ const adapter = createMockAdapter([readScript, writeScript, readScript, writeScript]);
+ const engine = new AgentLoopEngine({ maxIterations: 6 }, adapter);
+ const deadLoopEvents: unknown[] = [];
+ engine.on('deadLoop', (d) => deadLoopEvents.push(d));
+
+ const output = await engine.runStream(userMessage, 's1', [], systemPrompt);
+ expect(output.terminationReason).toBe(TerminationReason.DEAD_LOOP);
+ expect(deadLoopEvents.length).toBe(1);
+ });
+
+ it('A→B→C 交替(无重复模式)不误报,按 MAX_ITERATIONS 终止', async () => {
+ const scripts = [
+ toolCallEvent('read_file', { file_path: 'a.ts' }),
+ toolCallEvent('write_file', { file_path: 'a.ts' }),
+ toolCallEvent('lint_code', {}),
+ ];
+ const adapter = createMockAdapter(scripts);
+ const engine = new AgentLoopEngine({ maxIterations: 4 }, adapter);
+ const output = await engine.runStream(userMessage, 's1', [], systemPrompt);
+ expect(output.terminationReason).toBe(TerminationReason.MAX_ITERATIONS);
+ });
+
+ it('A→B→B→B 前缀不构成 ABAB(A≠B 约束),由驻留模式在 3 连 B 时接管', async () => {
+ const readScript = toolCallEvent('read_file', { file_path: 'x.ts' });
+ const writeScript = toolCallEvent('write_file', { file_path: 'x.ts' });
+ // 1:read 2:write 3:write 4:write —— 第 4 轮时 ABAB 不成立,但 3 连 write 命中驻留模式
+ const adapter = createMockAdapter([readScript, writeScript, writeScript, writeScript]);
+ const engine = new AgentLoopEngine({ maxIterations: 6 }, adapter);
+ const output = await engine.runStream(userMessage, 's1', [], systemPrompt);
+ expect(output.terminationReason).toBe(TerminationReason.DEAD_LOOP);
+ });
+});
+
+describe('AgentLoopEngine — REFLECTING 状态接线(P3-1 enableReflection)', () => {
+ const collectStates = async (config: Record): Promise => {
+ const adapter = createMockAdapter([
+ toolCallEvent('read_file', { file_path: 'a.ts' }),
+ textDoneEvent('done'),
+ ]);
+ const engine = new AgentLoopEngine(config as never, adapter);
+ const states: string[] = [];
+ engine.on('stateChange', (d: { state?: string; current?: string }) => {
+ const s = d.state ?? d.current ?? '';
+ if (!states.includes(s)) states.push(s);
+ });
+ await engine.runStream(userMessage, 's1', [], systemPrompt);
+ return states;
+ };
+
+ it('enableReflection=true 时工具执行后进入 REFLECTING 状态', async () => {
+ const states = await collectStates({ maxIterations: 2, enableReflection: true });
+ expect(states).toContain('REFLECTING');
+ expect(states).toContain('EXECUTING');
+ expect(states).toContain('OBSERVING');
+ });
+
+ it('enableReflection=false(默认)时不进入 REFLECTING', async () => {
+ const states = await collectStates({ maxIterations: 2, enableReflection: false });
+ expect(states).not.toContain('REFLECTING');
+ });
+});
diff --git a/electron/harness/agent-loop/engine.ts b/electron/harness/agent-loop/engine.ts
index 62ef773..0919469 100644
--- a/electron/harness/agent-loop/engine.ts
+++ b/electron/harness/agent-loop/engine.ts
@@ -50,7 +50,6 @@ class DeadLoopError extends Error {
const DEFAULT_CONFIG: AgentLoopConfig = {
maxIterations: 20,
- timeoutMs: 120_000,
totalTimeoutMs: 600_000,
enableReflection: false,
compressionThreshold: 0.8,
@@ -563,7 +562,7 @@ export class AgentLoopEngine extends EventEmitter {
});
// 抛出特殊错误,主循环捕获后以 DEAD_LOOP 原因终止
throw new DeadLoopError(
- `Detected a potential infinite loop: the same tool calls were repeated for 3 consecutive iterations. Please refine the approach or provide more specific instructions.`,
+ `Detected a potential infinite loop: the same tool calls were repeated for 3 consecutive iterations, or two alternating call patterns kept cycling (A→B→A→B) without progress. Please refine the approach or provide more specific instructions.`,
);
}
}
@@ -620,7 +619,11 @@ export class AgentLoopEngine extends EventEmitter {
await this.transitionTo(AgentLoopState.OBSERVING);
// === v0.2.0: REFLECTING 状态 — 观察工具结果,决定是否继续 ===
- // 如果有工具调用且需要后续推理,进入 REFLECTING 状态
+ // v0.7.3 接线说明:REFLECTING 分支此前依赖 enableReflection 配置,但该配置
+ // 全链路无任何置 true 的路径(死配置)。现由 agent.enableReflection 配置
+ // 真实驱动(main.ts baseConfig → updateConfigAll → 本分支),启用后每轮
+ // 工具执行完毕会经过 REFLECTING 状态:工具结果存在失败时记录告警日志,
+ // 供 SLO 与排障观察(不阻断循环——错误结果已由 CE-2 路径回传模型自愈)。
if (this.config.enableReflection && step.toolCalls && step.toolCalls.length > 0) {
await this.transitionTo(AgentLoopState.REFLECTING);
// 检查工具执行是否有错误,如果有严重错误可以提前终止
@@ -1087,9 +1090,13 @@ export class AgentLoopEngine extends EventEmitter {
/**
* v0.3.0: 死循环检测
*
- * 检测策略:
- * 将每轮的工具调用序列化为签名字符串,检查最近3轮的签名是否完全相同。
- * 如果连续3轮使用完全相同的参数调用相同的工具,判定为死循环。
+ * 检测策略(v0.7.3 起双模式):
+ * 1. 驻留模式 — 将每轮的工具调用序列化为签名字符串,检查最近3轮的签名是否完全相同。
+ * 如果连续3轮使用完全相同的参数调用相同的工具,判定为死循环。
+ * 2. 乒乓模式(v0.7.3 新增)— 最近4轮构成 ABAB 交替(r1===r3 && r2===r4 && r1!==r2)。
+ * 典型场景:模型在"读文件 A → 写文件 B"两步之间无限往返(每次读完又改回),
+ * 单步签名各不相同,驻留模式永不命中;docs/Agentic-Loop详解.md 第五章将
+ * "两种状态间反复来回切换、毫无进展"列为必须检测的停滞模式。
*
* v0.3.0 修复:
* - 对 args 的键进行排序,避免 JSON.stringify 键顺序不一致导致漏报
@@ -1123,21 +1130,31 @@ export class AgentLoopEngine extends EventEmitter {
this.toolCallHistory.push(signature);
- // 只保留最近5轮的记录(足够检测3轮重复,同时避免内存增长)
+ // 只保留最近5轮的记录(足够检测3轮重复与4轮乒乓,同时避免内存增长)
if (this.toolCallHistory.length > 5) {
this.toolCallHistory.shift();
}
- // 需要至少3轮数据才能检测
- if (this.toolCallHistory.length < 3) return false;
-
const len = this.toolCallHistory.length;
- const r1 = this.toolCallHistory[len - 1]; // 当前轮
- const r2 = this.toolCallHistory[len - 2]; // 上一轮
- const r3 = this.toolCallHistory[len - 3]; // 上上一轮
- // 连续3轮完全相同 → 死循环
- return r1 === r2 && r2 === r3;
+ // 模式 1:连续3轮完全相同 → 死循环
+ if (len >= 3) {
+ const r1 = this.toolCallHistory[len - 1]; // 当前轮
+ const r2 = this.toolCallHistory[len - 2]; // 上一轮
+ const r3 = this.toolCallHistory[len - 3]; // 上上一轮
+ if (r1 === r2 && r2 === r3) return true;
+ }
+
+ // 模式 2(v0.7.3):最近4轮 ABAB 交替(A≠B)→ 乒乓死循环
+ if (len >= 4) {
+ const a1 = this.toolCallHistory[len - 4];
+ const b1 = this.toolCallHistory[len - 3];
+ const a2 = this.toolCallHistory[len - 2];
+ const b2 = this.toolCallHistory[len - 1];
+ if (a1 === a2 && b1 === b2 && a1 !== b1) return true;
+ }
+
+ return false;
}
/**
diff --git a/electron/harness/agent-loop/types.ts b/electron/harness/agent-loop/types.ts
index f7f4bfb..c44da6b 100644
--- a/electron/harness/agent-loop/types.ts
+++ b/electron/harness/agent-loop/types.ts
@@ -60,7 +60,6 @@ export interface TokenUsage {
export interface AgentLoopConfig {
maxIterations: number;
- timeoutMs: number;
totalTimeoutMs: number;
enableReflection: boolean;
compressionThreshold: number;
diff --git a/electron/harness/hooks/__tests__/forget-session.test.ts b/electron/harness/hooks/__tests__/forget-session.test.ts
new file mode 100644
index 0000000..4f6c1af
--- /dev/null
+++ b/electron/harness/hooks/__tests__/forget-session.test.ts
@@ -0,0 +1,88 @@
+/**
+ * ConfirmationHook forgetSession 测试(v0.7.3 P2-3)
+ *
+ * 锁定会话终态清理契约:
+ * F1 forgetSession 清空该会话的决策记忆(拒绝记忆不再残留);
+ * F2 forgetSession 同时拒绝该会话等待中的确认(clearPending 语义);
+ * F3 会话隔离:清理 A 不影响 B;
+ * F4 空/未知 sessionId 幂等无副作用。
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+
+// 模拟一个存活窗口 —— beforeExecute 的 hasAvailableWindow 守卫需要它,
+// 否则确认请求在创建 pending 之前即被短路(测不到记忆/pending 路径)
+const fakeWindow = {
+ isDestroyed: () => false,
+ webContents: { send: vi.fn() },
+};
+vi.mock('electron', () => ({
+ BrowserWindow: { getAllWindows: vi.fn(() => [fakeWindow]) },
+}));
+
+import { ConfirmationHook } from '../confirmation-hook';
+import type { MetonaToolCall, MetonaToolDef } from '../../../harness/types';
+import { MetonaRiskLevel, MetonaToolCategory } from '../../../harness/types';
+
+function makeToolCall(name: string, id = `tc_${name}`): MetonaToolCall {
+ return { id, name, args: {}, iteration: 1, timestamp: Date.now() };
+}
+
+const NEEDS_CONFIRM_DEF: MetonaToolDef = {
+ name: 'run_command',
+ description: 'test',
+ parameters: { type: 'object', properties: {}, required: [] },
+ category: MetonaToolCategory.CODE_EXECUTION,
+ riskLevel: MetonaRiskLevel.HIGH,
+ requiresPermission: true,
+ timeoutMs: 1000,
+};
+
+describe('ConfirmationHook — forgetSession(P2-3)', () => {
+ let hook: ConfirmationHook;
+
+ beforeEach(() => {
+ vi.useFakeTimers();
+ hook = new ConfirmationHook(null, null);
+ hook.setToolDefs([NEEDS_CONFIRM_DEF]);
+ });
+
+ it('F1: 会话删除后决策记忆被清空(拒绝记忆不再跨生命周期残留)', async () => {
+ const pending = hook.beforeExecute(makeToolCall('run_command'), 'sess-A');
+ hook.resolveConfirmation(`tc_run_command`, false, true); // 记住拒绝
+ await pending;
+
+ expect(hook.getRememberedDenials('sess-A')).toHaveLength(1);
+ hook.forgetSession('sess-A');
+ expect(hook.getRememberedDenials('sess-A')).toHaveLength(0);
+ });
+
+ it('F2: forgetSession 拒绝该会话等待中的确认(clearPending 语义)', async () => {
+ const p1 = hook.beforeExecute(makeToolCall('run_command'), 'sess-A');
+ // resolve pending(拒绝)后走记忆清理路径
+ hook.forgetSession('sess-A');
+ await expect(p1).resolves.toMatchObject({ blocked: true });
+ // pending 已清空 —— getPendingConfirmations 无残留
+ expect(hook.getPendingConfirmations()).toHaveLength(0);
+ });
+
+ it('F3: 会话隔离 —— 清理 A 不影响 B 的决策记忆', async () => {
+ const p1 = hook.beforeExecute(makeToolCall('run_command', 'tc_A'), 'sess-A');
+ hook.resolveConfirmation('tc_A', false, true);
+ await p1;
+
+ const p2 = hook.beforeExecute(makeToolCall('run_command', 'tc_B'), 'sess-B');
+ hook.resolveConfirmation('tc_B', false, true);
+ await p2;
+
+ hook.forgetSession('sess-A');
+ expect(hook.getRememberedDenials('sess-A')).toHaveLength(0);
+ expect(hook.getRememberedDenials('sess-B')).toHaveLength(1);
+ });
+
+ it('F4: 空 sessionId 幂等无副作用;未知会话不抛错', () => {
+ expect(() => hook.forgetSession('')).not.toThrow();
+ expect(() => hook.forgetSession('nonexistent')).not.toThrow();
+ expect(hook.getRememberedDenials()).toHaveLength(0);
+ });
+});
diff --git a/electron/harness/hooks/confirmation-hook.ts b/electron/harness/hooks/confirmation-hook.ts
index eed5649..a0595cf 100644
--- a/electron/harness/hooks/confirmation-hook.ts
+++ b/electron/harness/hooks/confirmation-hook.ts
@@ -550,6 +550,22 @@ export class ConfirmationHook implements PreToolHook {
}
}
+ /**
+ * v0.7.3 P2-3: 会话生命周期终态清理(会话删除 / SubAgent 终结时调用)。
+ *
+ * 此前 rememberedDecisions 两级 Map 只增不减 —— 会话删除/子任务终结后其
+ * 决策记忆永久残留,长期运行实例随会话数缓慢泄漏。本方法与 clearPending
+ * 的区别:clearPending 只处理等待中的确认(会话中断时用,会话本身仍存活),
+ * 本方法面向"会话已终结"的终态,同时清空 pending 与决策记忆。
+ *
+ * @param sessionId 会话 ID(主会话或 SubAgent taskId)
+ */
+ forgetSession(sessionId: string): void {
+ if (!sessionId) return;
+ this.clearPending(sessionId);
+ this.rememberedDecisions.delete(sessionId);
+ }
+
// ===== 私有辅助(v0.5.0: 会话隔离) =====
/** 获取(或创建)指定会话的决策记忆表 */
diff --git a/electron/harness/memory/__tests__/consolidation-policy.test.ts b/electron/harness/memory/__tests__/consolidation-policy.test.ts
new file mode 100644
index 0000000..6783cb7
--- /dev/null
+++ b/electron/harness/memory/__tests__/consolidation-policy.test.ts
@@ -0,0 +1,97 @@
+/**
+ * 记忆固化触发决策测试(v0.7.3 P1-5)
+ *
+ * 锁定 shouldConsolidate 的四类判定:总开关 / 内容门控(回答长度 ∨ 成功工具调用)/
+ * 频率窗口(含首次不限)/ 配置兜底(非法数值回退安全下限)。
+ */
+
+import { describe, it, expect } from 'vitest';
+import {
+ shouldConsolidate,
+ MIN_CONSOLIDATION_INTERVAL_MS,
+ MIN_CONSOLIDATION_MIN_CHARS,
+} from '../consolidation-policy';
+
+const BASE = {
+ enabled: true,
+ answerChars: 500,
+ minChars: 200,
+ hadSuccessfulToolCall: false,
+ lastConsolidationAt: 0,
+ now: 1_000_000,
+ intervalMs: 600_000,
+};
+
+describe('shouldConsolidate', () => {
+ it('总开关显式关闭 → disabled', () => {
+ expect(shouldConsolidate({ ...BASE, enabled: false })).toEqual({
+ consolidate: false,
+ reason: 'disabled',
+ });
+ });
+
+ it('回答够长且首次固化 → 允许(lastConsolidationAt=0 不受频率限制)', () => {
+ expect(shouldConsolidate(BASE)).toEqual({
+ consolidate: true,
+ reason: 'content-and-frequency-pass',
+ });
+ });
+
+ it('回答过短且无成功工具调用 → below-threshold(短寒暄不触发固化)', () => {
+ expect(shouldConsolidate({ ...BASE, answerChars: 50, hadSuccessfulToolCall: false })).toEqual({
+ consolidate: false,
+ reason: 'below-threshold',
+ });
+ });
+
+ it('回答过短但存在成功工具调用 → 允许(事实性上下文可沉淀)', () => {
+ expect(shouldConsolidate({ ...BASE, answerChars: 50, hadSuccessfulToolCall: true })).toEqual({
+ consolidate: true,
+ reason: 'content-and-frequency-pass',
+ });
+ });
+
+ it('频率窗口内重复触发 → throttled', () => {
+ expect(
+ shouldConsolidate({
+ ...BASE,
+ lastConsolidationAt: BASE.now - 60_000, // 1 分钟前刚固化过
+ }),
+ ).toEqual({ consolidate: false, reason: 'throttled' });
+ });
+
+ it('频率窗口已过 → 允许', () => {
+ expect(
+ shouldConsolidate({
+ ...BASE,
+ lastConsolidationAt: BASE.now - 600_001,
+ }),
+ ).toEqual({ consolidate: true, reason: 'content-and-frequency-pass' });
+ });
+
+ it('配置兜底:minChars=0 不会让纯寒暄触发(安全下限生效)', () => {
+ expect(shouldConsolidate({ ...BASE, minChars: 0, answerChars: 10 })).toEqual({
+ consolidate: false,
+ reason: 'below-threshold',
+ });
+ expect(MIN_CONSOLIDATION_MIN_CHARS).toBeGreaterThan(0);
+ });
+
+ it('配置兜底:intervalMs=0 不会退化为每条消息固化(安全下限生效)', () => {
+ expect(
+ shouldConsolidate({
+ ...BASE,
+ intervalMs: 0,
+ lastConsolidationAt: BASE.now - 30_000, // 30 秒前刚固化
+ }),
+ ).toEqual({ consolidate: false, reason: 'throttled' });
+ expect(MIN_CONSOLIDATION_INTERVAL_MS).toBeGreaterThanOrEqual(60_000);
+ });
+
+ it('配置兜底:minChars/intervalMs 为 NaN 时按默认值处理', () => {
+ expect(shouldConsolidate({ ...BASE, minChars: Number.NaN, intervalMs: Number.NaN })).toEqual({
+ consolidate: true,
+ reason: 'content-and-frequency-pass',
+ });
+ });
+});
diff --git a/electron/harness/memory/consolidation-policy.ts b/electron/harness/memory/consolidation-policy.ts
new file mode 100644
index 0000000..1299fd8
--- /dev/null
+++ b/electron/harness/memory/consolidation-policy.ts
@@ -0,0 +1,74 @@
+/**
+ * Consolidation Policy — 记忆固化触发决策(v0.7.3 P1-5)
+ *
+ * 背景:MemoryConsolidator 在每次 run 完成后无条件发起一次非流式 LLM 请求
+ * (30s 超时)判断本次对话是否有值得持久化的记忆。短寒暄/单轮问答同样触发,
+ * 纯成本浪费且对 Provider 构成无意义请求压力。
+ *
+ * 本模块把触发决策收敛为纯函数(可表测),决策输入:
+ * - 总开关 memory.consolidationEnabled(fail-secure:仅显式 false 才关闭)
+ * - 内容门控:本次回答 ≥ minChars 字符 **或** 本次 run 存在成功的工具调用
+ * (工具调用意味着产生了可沉淀的事实性上下文)
+ * - 频率门控:距该会话上次固化 ≥ intervalMs(首次不设限,但仍受内容门控约束)
+ *
+ * 决策与执行解耦:本模块不做 IO,调用方(ipc/agent.ts)持有会话级
+ * lastConsolidationAt 状态并执行 consolidate。
+ */
+
+export interface ConsolidationDecisionInput {
+ /** 总开关(memory.consolidationEnabled;undefined/null 视为开启) */
+ enabled: boolean | null | undefined;
+ /** 本次 Agent 最终回答的字符数 */
+ answerChars: number;
+ /** 内容门控阈值(memory.consolidationMinChars,默认 200) */
+ minChars: number;
+ /** 本次 run 是否存在成功的工具调用 */
+ hadSuccessfulToolCall: boolean;
+ /** 该会话上次固化的时间戳(0 = 从未固化) */
+ lastConsolidationAt: number;
+ /** 当前时间戳 */
+ now: number;
+ /** 频率门控窗口(memory.consolidationIntervalMs,默认 600000) */
+ intervalMs: number;
+}
+
+export type ConsolidationDecision =
+ | { consolidate: true; reason: 'content-and-frequency-pass' }
+ | { consolidate: false; reason: 'disabled' | 'below-threshold' | 'throttled' };
+
+/** 频率窗口合法下限(防误配 0/负值导致门控失效——0 等价于每条消息都固化) */
+export const MIN_CONSOLIDATION_INTERVAL_MS = 60_000;
+
+/** 内容门控合法下限(防误配 0 导致纯寒暄也固化) */
+export const MIN_CONSOLIDATION_MIN_CHARS = 20;
+
+/**
+ * 判定本次 run 是否应触发记忆固化。
+ */
+export function shouldConsolidate(input: ConsolidationDecisionInput): ConsolidationDecision {
+ // 1. 总开关 —— fail-secure 语义由调用方负责(!== false 才视为开启后传入布尔)
+ if (input.enabled === false) {
+ return { consolidate: false, reason: 'disabled' };
+ }
+
+ // 2. 内容门控:回答够长 或 有成功的工具调用(事实性上下文)
+ const minChars = Math.max(
+ MIN_CONSOLIDATION_MIN_CHARS,
+ Number.isFinite(input.minChars) ? input.minChars : 200,
+ );
+ const contentWorthy = input.answerChars >= minChars || input.hadSuccessfulToolCall;
+ if (!contentWorthy) {
+ return { consolidate: false, reason: 'below-threshold' };
+ }
+
+ // 3. 频率门控:上次固化距今不足窗口 → 跳过(首次 lastConsolidationAt=0 不受限)
+ const intervalMs = Math.max(
+ MIN_CONSOLIDATION_INTERVAL_MS,
+ Number.isFinite(input.intervalMs) ? input.intervalMs : 600_000,
+ );
+ if (input.lastConsolidationAt > 0 && input.now - input.lastConsolidationAt < intervalMs) {
+ return { consolidate: false, reason: 'throttled' };
+ }
+
+ return { consolidate: true, reason: 'content-and-frequency-pass' };
+}
diff --git a/electron/harness/orchestration/orchestrator.ts b/electron/harness/orchestration/orchestrator.ts
index 58d7f3f..7fa260f 100644
--- a/electron/harness/orchestration/orchestrator.ts
+++ b/electron/harness/orchestration/orchestrator.ts
@@ -167,6 +167,8 @@ export class TaskOrchestrator extends EventEmitter {
thinkingEffort: this.defaultConfig?.thinkingEffort ?? 'medium',
contextLength: this.defaultConfig?.contextLength,
contextWindow: this.defaultConfig?.contextWindow ?? 128_000,
+ // v0.7.3 P3-1: SubAgent 与主引擎同源消费 enableReflection(REFLECTING 状态开关)
+ enableReflection: this.defaultConfig?.enableReflection ?? false,
},
this.engines.createAdapter(),
this.toolRegistry,
diff --git a/electron/harness/prompts/__tests__/context-builder.test.ts b/electron/harness/prompts/__tests__/context-builder.test.ts
index 3fe52f6..4c87b75 100644
--- a/electron/harness/prompts/__tests__/context-builder.test.ts
+++ b/electron/harness/prompts/__tests__/context-builder.test.ts
@@ -64,11 +64,23 @@ describe('ContextBuilder — isUsingFallbackRole 首次降级通知语义', () =
});
describe('ContextBuilder — 动态区注入', () => {
- it('注入当前日期时间(含本地时区)', () => {
+ it('v0.7.3 P1-1: 不再注入日期时间(prompt cache 前缀稳定性)', () => {
const cb = new ContextBuilder();
const prompt = cb.buildSystemPrompt({ soul: 'x', memory: '' });
- expect(prompt.dynamicReminders).toContain('## Current Date & Time');
- expect(prompt.dynamicReminders).toMatch(/UTC[+-]/);
+ expect(prompt.dynamicReminders).not.toContain('## Current Date & Time');
+ expect(prompt.dynamicReminders).not.toMatch(/UTC[+-]/);
+ });
+
+ it('v0.7.3 P1-1: system 输出跨调用字节级稳定(同输入 → 同字节)', () => {
+ const cb = new ContextBuilder();
+ const files = { soul: 'x', memory: '## 用户偏好\n- 偏好深色主题' };
+ const a = cb.buildSystemPrompt(files, '/tmp/ws-demo');
+ const b = cb.buildSystemPrompt(files, '/tmp/ws-demo');
+ // 跨 run 缓存命中的前提:四分区逐字节一致(日期时间已移入用户消息前置块)
+ expect(a.roleDefinition).toBe(b.roleDefinition);
+ expect(a.outputConstraints).toBe(b.outputConstraints);
+ expect(a.safetyGuidelines).toBe(b.safetyGuidelines);
+ expect(a.dynamicReminders).toBe(b.dynamicReminders);
});
it('注入工作空间路径(动态区,路径可切换)', () => {
diff --git a/electron/harness/prompts/__tests__/user-context.test.ts b/electron/harness/prompts/__tests__/user-context.test.ts
new file mode 100644
index 0000000..67eb94f
--- /dev/null
+++ b/electron/harness/prompts/__tests__/user-context.test.ts
@@ -0,0 +1,94 @@
+/**
+ * 用户上下文前置块测试(v0.7.3 P1-1)
+ *
+ * 锁定三类动态内容(日期时间 / 记忆 / 附件提示)在用户消息前置块的
+ * 分组结构与空值收缩行为 —— 它们从 system prompt 迁出的契约面。
+ */
+
+import { describe, it, expect } from 'vitest';
+import { buildUserContextPrefix, withUserContextPrefix } from '../user-context';
+
+describe('buildUserContextPrefix', () => {
+ it('恒含头部说明与日期时间分区(唯一无条件分区)', () => {
+ const prefix = buildUserContextPrefix({ now: Date.UTC(2026, 7, 30, 6, 30) });
+ expect(prefix).toContain('[Contextual information for this message');
+ expect(prefix).toContain('## Current Date & Time');
+ });
+
+ it('无记忆/附件时不产出对应分区(空值收缩)', () => {
+ const prefix = buildUserContextPrefix({ now: Date.now() });
+ expect(prefix).not.toContain('## Relevant Memories (Retrieved)');
+ expect(prefix).not.toContain('## User Attachments (Direct Upload)');
+ });
+
+ it('记忆分区:条目格式与截断口径(沿用原 system 注入契约)', () => {
+ const prefix = buildUserContextPrefix({
+ now: Date.now(),
+ memories: [
+ {
+ id: 'm1',
+ type: 'semantic',
+ content: 'x'.repeat(500),
+ source: 'agent_thought',
+ importance: 0.9,
+ score: 0.8,
+ createdAt: Date.now(),
+ },
+ ],
+ });
+ expect(prefix).toContain('## Relevant Memories (Retrieved)');
+ expect(prefix).toMatch(/\[1\] \(semantic, 重要度: 0\.9\)/);
+ // 内容截断到 200 字符
+ expect(prefix).toContain('x'.repeat(200));
+ expect(prefix).not.toContain('x'.repeat(201));
+ });
+
+ it('附件分区:图片提示禁止重复读图;文本截断标记透传', () => {
+ const prefix = buildUserContextPrefix({
+ now: Date.now(),
+ attachments: [
+ { name: 'shot.png', type: 'image' },
+ { name: 'big.log', type: 'text', truncated: true },
+ ],
+ });
+ expect(prefix).toContain('## User Attachments (Direct Upload)');
+ expect(prefix).toContain('1. [image] shot.png');
+ expect(prefix).toContain('do NOT call view_image');
+ expect(prefix).toContain('2. [text file] big.log');
+ expect(prefix).toContain('TRUNCATED — only the first 512KB is included');
+ });
+
+ it('分区以 --- 分隔且以前缀分隔符收尾(调用方可直接拼接用户内容)', () => {
+ const prefix = buildUserContextPrefix({
+ now: Date.now(),
+ memories: [
+ {
+ id: 'm',
+ type: 'episodic',
+ content: 'c',
+ source: 'user_input',
+ importance: 0.5,
+ score: 0.5,
+ createdAt: Date.now(),
+ },
+ ],
+ attachments: [{ name: 'a.txt', type: 'text' }],
+ });
+ expect(prefix).toMatch(/---\s*$/);
+ // 三个分区恰好两个内部 --- + 收尾 1 个 ---(共 3 个独立行)
+ expect(prefix.match(/^---$/gm)?.length ?? 0).toBe(3);
+ });
+});
+
+describe('withUserContextPrefix', () => {
+ it('前置块与用户内容拼接(前置块自带收尾分隔符)', () => {
+ const prefix = buildUserContextPrefix({ now: Date.now() });
+ const out = withUserContextPrefix(prefix, '你好,帮我写个脚本');
+ expect(out.startsWith(prefix)).toBe(true);
+ expect(out.endsWith('你好,帮我写个脚本')).toBe(true);
+ });
+
+ it('空前缀原样返回(契约防御)', () => {
+ expect(withUserContextPrefix('', 'hello')).toBe('hello');
+ });
+});
diff --git a/electron/harness/prompts/context-builder.ts b/electron/harness/prompts/context-builder.ts
index 77afc36..73d9cd1 100644
--- a/electron/harness/prompts/context-builder.ts
+++ b/electron/harness/prompts/context-builder.ts
@@ -58,7 +58,10 @@ export class ContextBuilder {
*
* v0.3.14: 移除 AGENTS.md 和 USERS.md 的读取,SOUL.md 仅做角色定义
*/
- buildSystemPrompt(workspaceFiles?: WorkspaceFiles, workspacePath?: string): {
+ buildSystemPrompt(
+ workspaceFiles?: WorkspaceFiles,
+ workspacePath?: string,
+ ): {
roleDefinition: string;
outputConstraints: string;
safetyGuidelines: string;
@@ -76,23 +79,19 @@ export class ContextBuilder {
// ===== 动态区:记忆 =====
const dynamicParts: string[] = [];
- // v0.3.14: 注入当前系统日期时间(每次构建时获取最新时间)
- // 用于让 AI 准确理解"今天"、"昨天"等相对时间表达
- // #43 修复: 时区硬编码 Asia/Shanghai 改为使用系统本地时区,跨时区用户显示正确
- const now = new Date();
- const localTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone ?? 'Asia/Shanghai';
- // 审查修复: 恢复 UTC 偏移显示,在时区名后附加 UTC 偏移,避免丢失时区偏移信息
- const offset = -now.getTimezoneOffset() / 60;
- const offsetStr = offset >= 0 ? `UTC+${offset}` : `UTC${offset}`;
- const dateTimeStr = now.toLocaleString('zh-CN', {
- timeZone: localTimezone,
- hour12: false,
- });
- dynamicParts.push(`## Current Date & Time\n${dateTimeStr} (${localTimezone}, ${offsetStr})`);
+ // v0.7.3 P1-1 根治: 当前日期时间不再注入 system prompt —— 此前每次构建都
+ // 产生不同字节(秒级时间戳 + 时区),导致跨 run 的 system 前缀永不一致,
+ // DeepSeek 自动上下文缓存 / Anthropic 显式缓存全部 miss。现移入用户消息
+ // 前置块(@see user-context.ts),system 保持跨 run 字节级稳定。
+ // MEMORY.md 的 `> 创建时间/最后更新` 元数据行由 extractContent 剥离,
+ // 正文提取不受时间戳更新影响 —— 此处无需额外处理。
- // 注入当前工作空间路径(动态区,路径可能切换故不放入静态区)
+ // 注入当前工作空间路径(动态区,路径可能切换故不放入静态区;
+ // 会话期间路径恒定,不破坏缓存)
if (workspacePath) {
- dynamicParts.push(`## Current Workspace\nWorkspace root path: \`${workspacePath}\`\n\nAll relative paths in tool calls are resolved against this workspace root. Use this path when absolute paths are required (e.g., in run_command).`);
+ dynamicParts.push(
+ `## Current Workspace\nWorkspace root path: \`${workspacePath}\`\n\nAll relative paths in tool calls are resolved against this workspace root. Use this path when absolute paths are required (e.g., in run_command).`,
+ );
}
if (workspaceFiles?.memory) {
@@ -138,7 +137,9 @@ export class ContextBuilder {
} else {
// v0.3.18 修复: 降级时打 WARN 日志 + 设置标志,供 IPC 层读取后发 toast
this.lastUsedFallbackRole = true;
- log.warn('[ContextBuilder] SOUL.md is missing or empty, falling back to default Metona identity');
+ log.warn(
+ '[ContextBuilder] SOUL.md is missing or empty, falling back to default Metona identity',
+ );
// 兜底身份定义(Metona 灵魂定义)
parts.push(`# Metona — 灵魂定义
> "想清楚再动手,做对比做快重要"
diff --git a/electron/harness/prompts/user-context.ts b/electron/harness/prompts/user-context.ts
new file mode 100644
index 0000000..74b0e0e
--- /dev/null
+++ b/electron/harness/prompts/user-context.ts
@@ -0,0 +1,140 @@
+/**
+ * User Context Prefix — 每条用户消息的系统上下文前置块(v0.7.3 P1-1 根治)
+ *
+ * 背景(Prompt Cache 被打穿的根因):
+ * 此前「当前日期时间」「检索到的相关记忆」「附件提示」三类**每条消息都在变**
+ * 的内容被追加进 systemPrompt.dynamicReminders —— OpenAI 兼容系将其拼进首条
+ * system 消息、Anthropic 写入顶层 system 字段。任何一次变化都会使整个 system
+ * 前缀失配,DeepSeek 自动上下文缓存 / Anthropic 显式缓存全部 miss。长 system
+ * (SOUL + 安全准则 + MEMORY.md)× 每 run 最多 20 轮迭代 × 全量重算输入 token,
+ * 成本与首字延迟被系统性放大。
+ *
+ * 现契约(单一事实来源):
+ * - system prompt 只保留跨 run 字节级稳定的内容(SOUL / 约束 / 安全准则 /
+ * 工作空间路径 / MEMORY.md 正文——其易变的 `> 最后更新` 元数据行本就被
+ * extractContent 剥离);Anthropic 侧对该稳定前缀打 cache_control 断言;
+ * - 易变内容(日期时间 / 记忆 / 附件提示)由本模块构建为**用户消息前置块**,
+ * 随当次请求注入首条 user 消息(LLM 语义等价:Claude Code 同款上下文注入位);
+ * - DB 持久化 / 前端展示 / 记忆固化 / 注入检测均使用**原始干净内容**,
+ * 前置块只存在于发给引擎的副本上。
+ *
+ * 纯函数、零副作用:可在 node vitest 下直接表测(稳定性/分组/空值收缩)。
+ */
+
+import type { SearchResult } from '../memory/manager';
+
+/** 附件提示所需的元信息子集(与 agent-store AttachmentInfo 对齐的渲染端子集) */
+export interface AttachmentHint {
+ name: string;
+ type: string;
+ truncated?: boolean;
+}
+
+export interface UserContextPrefixInput {
+ /** 当前时间戳(前置块内降精度到分钟,减少无意义抖动) */
+ now?: number;
+ /** 检索到的相关记忆(空数组时不产出记忆分区) */
+ memories?: SearchResult[];
+ /** 用户附件元信息(空数组/undefined 时不产出附件分区) */
+ attachments?: AttachmentHint[];
+ /**
+ * 时区标签(如 "Asia/Shanghai (UTC+8)")。
+ * 由调用方计算(Intl.DateTimeFormat().resolvedOptions().timeZone)——
+ * 本模块保持纯函数语义,不做 Electron/Intl 环境依赖。
+ */
+ timezoneLabel?: string;
+}
+
+/** 记忆注入条目的内容截断(与原 dynamicReminders 注入口径一致) */
+const MEMORY_EXCERPT_CHARS = 200;
+/** 附件提示上限(与输入侧 5 个附件的硬上限对齐) */
+const MAX_ATTACHMENT_HINTS = 8;
+
+/**
+ * 构建用户消息上下文前置块。
+ *
+ * 输出形态(各分区以 `\n\n---\n\n` 分隔,整体以分隔符结尾,
+ * 调用方直接 `${prefix}${userContent}` 拼接):
+ * ```
+ * [Contextual information for this message — system-generated metadata, not part of the user's request.]
+ *
+ * ## Current Date & Time
+ * 2026/8/30 14:30:00 (Asia/Shanghai, UTC+8)
+ *
+ * ---
+ *
+ * ## Relevant Memories (Retrieved)
+ * [1] (semantic, 重要度: 0.9) ...
+ *
+ * ---
+ *
+ * ## User Attachments (Direct Upload)
+ * ...
+ * ```
+ */
+export function buildUserContextPrefix(input: UserContextPrefixInput): string {
+ const parts: string[] = [];
+
+ // ===== 分区 1:当前日期时间(降精度到分钟) =====
+ const now = input.now ?? Date.now();
+ const timezoneLabel = input.timezoneLabel ?? 'UTC';
+ const dateStr = new Date(now).toLocaleString('sv-SE', {
+ timeZone: undefined,
+ hour12: false,
+ }); // sv-SE 给出 ISO 形态 "2026-08-30 14:30:00"
+ parts.push(`## Current Date & Time\n${dateStr} (${timezoneLabel})`);
+
+ // ===== 分区 2:相关记忆注入(沿用原 system 注入的展示口径) =====
+ const memories = (input.memories ?? []).slice(0, 5);
+ if (memories.length > 0) {
+ const memorySection = memories
+ .map(
+ (m, i) =>
+ `[${i + 1}] (${m.type}, 重要度: ${m.importance.toFixed(1)}) ${m.content.slice(0, MEMORY_EXCERPT_CHARS)}`,
+ )
+ .join('\n');
+ parts.push(`## Relevant Memories (Retrieved)\n${memorySection}`);
+ }
+
+ // ===== 分区 3:附件提示(沿用原 system 注入的语义与文案契约) =====
+ const attachments = (input.attachments ?? []).slice(0, MAX_ATTACHMENT_HINTS);
+ if (attachments.length > 0) {
+ const attachmentList = attachments
+ .map((att, i) => {
+ const typeLabel =
+ att.type === 'image' ? 'image' : att.type === 'text' ? 'text file' : 'file';
+ // 文本附件被上传入口截断(512KB 上限)时,明确告知 LLM 内容不完整,
+ // 防止模型把残缺内容当作完整文件事实(v0.7.2 A5 契约延续)
+ const truncatedNote =
+ att.truncated === true
+ ? ' (TRUNCATED — only the first 512KB is included; the full content is NOT available)'
+ : '';
+ const note =
+ att.type === 'image'
+ ? 'already provided to you via vision capability — you can SEE it directly, do NOT call view_image or any tool to read it again'
+ : att.type === 'text'
+ ? `content${truncatedNote} already inlined in the user message, do NOT search in workspace or read it again`
+ : 'uploaded directly by user, do NOT search in workspace';
+ return `${i + 1}. [${typeLabel}] ${att.name} — ${note}`;
+ })
+ .join('\n');
+
+ parts.push(
+ `## User Attachments (Direct Upload)\nThe following files were uploaded directly by the user to this conversation. They are inline attachments, NOT workspace files:\n${attachmentList}\n\n**IMPORTANT**: Images listed above are already visible to you in this conversation. Do NOT call \`view_image\`, \`read_file\`, or any file tool to read them — doing so wastes a tool call and may fail (they are not workspace files).`,
+ );
+ }
+
+ const header =
+ "[Contextual information for this message — system-generated metadata, not part of the user's request.]";
+
+ return `${header}\n\n${parts.join('\n\n---\n\n')}\n\n---\n\n`;
+}
+
+/**
+ * 将前置块与用户原始内容拼装为发送给引擎的消息内容。
+ * 空前缀(理论上不会发生——日期分区恒存在,但契约上防御)时原样返回。
+ */
+export function withUserContextPrefix(prefix: string, userContent: string): string {
+ if (!prefix) return userContent;
+ return `${prefix}${userContent}`;
+}
diff --git a/electron/harness/tools/built-in/__tests__/ssrf-dispatcher.test.ts b/electron/harness/tools/built-in/__tests__/ssrf-dispatcher.test.ts
new file mode 100644
index 0000000..4b92519
--- /dev/null
+++ b/electron/harness/tools/built-in/__tests__/ssrf-dispatcher.test.ts
@@ -0,0 +1,103 @@
+/**
+ * SSRF DNS Pinning 测试(v0.7.3 P2-1)
+ *
+ * 锁定三个单元:
+ * D1 createPinnedLookup —— 只返回校验阶段锁定的 IP 集合(过滤非法 family),
+ * 空集合返回 ENOTFOUND(防御)。
+ * D2 resolveRedirectTarget —— 重定向状态识别 + 相对 Location 解析 +
+ * 非法/缺失 Location 返回 null。
+ * D3 resolvePinnedIps —— IP 直连与私网拒绝(走 ssrf-guard 单一事实来源;
+ * 域名解析路径由 ssrf-guard 表测覆盖,此处不重复触网)。
+ */
+
+import { describe, it, expect } from 'vitest';
+import { createPinnedLookup, resolveRedirectTarget, resolvePinnedIps } from '../ssrf-dispatcher';
+import type { LookupCallback } from '../ssrf-dispatcher';
+
+describe('createPinnedLookup', () => {
+ it('D1: 仅返回钉死的 IP 集合(忽略 hostname),family 正确标注', async () => {
+ const lookup = createPinnedLookup(['93.184.216.34', '2606:2800:220:1:248:1893:25c8:1946']);
+ const result = await new Promise<{ address: string; family: number }[]>((resolve, reject) => {
+ const cb: LookupCallback = (err, addresses) => (err ? reject(err) : resolve(addresses!));
+ lookup('attacker.example', {}, cb);
+ });
+ expect(result).toHaveLength(2);
+ expect(result[0]).toEqual({ address: '93.184.216.34', family: 4 });
+ expect(result[1].family).toBe(6);
+ });
+
+ it('D1: 非法 family(非 IPv4/IPv6 字符串)被过滤', async () => {
+ const lookup = createPinnedLookup(['not-an-ip']);
+ await expect(
+ new Promise((resolve, reject) => {
+ const cb: LookupCallback = (err, addresses) => (err ? reject(err) : resolve(addresses));
+ lookup('h', {}, cb as never);
+ }),
+ ).rejects.toMatchObject({ code: 'ENOTFOUND' });
+ });
+
+ it('D1: 空集合 → ENOTFOUND(防御:调用方不应构造空 pin dispatcher)', async () => {
+ const lookup = createPinnedLookup([]);
+ await expect(
+ new Promise((resolve, reject) => {
+ const cb: LookupCallback = (err, addresses) => (err ? reject(err) : resolve(addresses));
+ lookup('h', {}, cb as never);
+ }),
+ ).rejects.toMatchObject({ code: 'ENOTFOUND' });
+ });
+});
+
+describe('resolveRedirectTarget', () => {
+ const makeResponse = (status: number, location?: string) => ({
+ status,
+ headers: {
+ get: (name: string) => (name.toLowerCase() === 'location' ? (location ?? null) : null),
+ },
+ });
+
+ it('D2: 301/302/303/307/308 识别并解析绝对 Location', () => {
+ for (const status of [301, 302, 303, 307, 308]) {
+ expect(
+ resolveRedirectTarget(makeResponse(status, 'https://cdn.example.com/x'), 'https://a.test/'),
+ ).toBe('https://cdn.example.com/x');
+ }
+ });
+
+ it('D2: 相对 Location 以当前 URL 为基解析(RFC 7231)', () => {
+ expect(resolveRedirectTarget(makeResponse(302, '/next?a=1'), 'https://a.test/dir/page')).toBe(
+ 'https://a.test/next?a=1',
+ );
+ });
+
+ it('D2: 非 3xx 状态 → null(终态)', () => {
+ expect(resolveRedirectTarget(makeResponse(200), 'https://a.test/')).toBeNull();
+ expect(resolveRedirectTarget(makeResponse(404), 'https://a.test/')).toBeNull();
+ });
+
+ it('D2: 缺失/非法 Location → null', () => {
+ expect(resolveRedirectTarget(makeResponse(302), 'https://a.test/')).toBeNull();
+ expect(resolveRedirectTarget(makeResponse(302, ''), 'https://a.test/')).toBeNull();
+ expect(resolveRedirectTarget(makeResponse(302, 'http://[::bad'), 'https://a.test/')).toBeNull();
+ });
+});
+
+describe('resolvePinnedIps', () => {
+ it('D3: IP 直连 URL —— 公网 IP 直接返回', async () => {
+ const ips = await resolvePinnedIps('https://93.184.216.34/x');
+ expect(ips).toEqual(['93.184.216.34']);
+ });
+
+ it('D3: 私有/回环 IP 直连被拒(单一事实来源 ssrf-guard)', async () => {
+ for (const host of ['127.0.0.1', '10.0.0.5', '169.254.169.254', '192.168.1.1', '[::1]']) {
+ await expect(resolvePinnedIps(`http://${host}/latest`)).rejects.toThrow(/Blocked SSRF/);
+ }
+ });
+
+ it('D3: 非 http/https 协议被拒', async () => {
+ await expect(resolvePinnedIps('ftp://example.com')).rejects.toThrow(/not allowed/);
+ });
+
+ it('D3: 非法 URL 被拒', async () => {
+ await expect(resolvePinnedIps('not a url')).rejects.toThrow(/Invalid URL/);
+ });
+});
diff --git a/electron/harness/tools/built-in/browser-window-manager.ts b/electron/harness/tools/built-in/browser-window-manager.ts
index da29de5..c2ef523 100644
--- a/electron/harness/tools/built-in/browser-window-manager.ts
+++ b/electron/harness/tools/built-in/browser-window-manager.ts
@@ -9,6 +9,8 @@
import { BrowserWindow, session } from 'electron';
import log from 'electron-log';
+// v0.7.3 P2-2: CORS Origin 回显(纯函数在 network-utils,可表测)
+import { corsAllowOrigin, extractOriginHeader } from './network-utils';
/** Agent 浏览器专用 session partition — 与主应用 default session 完全隔离 */
const AGENT_PARTITION = 'persist:metona-agent-browser';
@@ -128,12 +130,22 @@ export class BrowserWindowManager {
// v0.3.0 修复: 使用 CORS 放行替代 webSecurity: false
// 仅对 agent session 放行 CORS,不影响主应用
+ // v0.7.3 P2-2 收紧: ACAO 从通配 '*' 改为回显请求 Origin —— 通配值让任意
+ // 第三方页面都能借该分区跨域读取;回显等价保留截图/页面自身跨域能力,
+ // 并附加 Vary: Origin 防止共享缓存把定向值串到其他 Origin。
const agentSession = session.fromPartition(AGENT_PARTITION);
agentSession.webRequest.onHeadersReceived((details, callback) => {
+ // Electron 类型在此版本的 OnHeadersReceivedListenerDetails 上不暴露
+ // requestHeaders —— 显式声明读取面(Origin 大小写不敏感提取)
+ const requestHeaders = (
+ details as unknown as { requestHeaders?: Record }
+ ).requestHeaders;
+ const originHeader = extractOriginHeader(requestHeaders);
callback({
responseHeaders: {
...details.responseHeaders,
- 'Access-Control-Allow-Origin': ['*'],
+ 'Access-Control-Allow-Origin': corsAllowOrigin(originHeader),
+ Vary: [...(details.responseHeaders?.Vary ?? []), 'Origin'],
},
});
});
diff --git a/electron/harness/tools/built-in/command.ts b/electron/harness/tools/built-in/command.ts
index 90e1ef7..30f372b 100644
--- a/electron/harness/tools/built-in/command.ts
+++ b/electron/harness/tools/built-in/command.ts
@@ -25,6 +25,9 @@ import type { MetonaToolDef } from '../../../harness/types';
import { MetonaToolCategory, MetonaRiskLevel } from '../../../harness/types';
import { commandTouchesProtectedFile, isPathWithinWorkspace } from './file-guard';
import type { SandboxManager } from '../../sandbox/sandbox';
+// v0.7.3 P3-2: 子进程环境净化收敛到 utils/safe-env.ts 单源
+// (与 MCP stdio 启动共用同一黑名单,历史双实现已漂移过一次)
+import { buildSafeChildEnv } from '../../../utils/safe-env';
const execAsync = promisify(exec);
const execFileAsync = promisify(execFile);
@@ -52,47 +55,22 @@ function decodeBuffer(buf: Buffer): string {
/**
* #9 修复 + 审查修复: 构建安全的子进程环境变量
*
- * 审查修复: 原白名单方案遗漏了 GIT_* / PYTHONPATH / HTTP_PROXY 等常用变量,导致子进程功能破坏。
- * 改为黑名单方案:剔除包含敏感后缀的变量,保留其余。
+ * v0.7.3 P3-2: 实现收敛到 utils/safe-env.ts(buildSafeChildEnv)——
+ * 与 MCP stdio 启动共用同一黑名单,本文件仅保留 run_command 的运行时差异注入
+ * (Windows 中文编码变量)。黑名单方案的设计原因见 safe-env.ts 模块注释:
+ * 白名单方案会遗漏 GIT_* / PYTHONPATH / HTTP_PROXY 等常用变量导致子进程功能破坏。
*/
function buildSafeCommandEnv(isWindows: boolean): Record {
- // 敏感变量后缀黑名单
- const SENSITIVE_SUFFIXES = [
- '_API_KEY',
- '_TOKEN',
- '_SECRET',
- '_PASSWORD',
- '_PASSWD',
- '_CREDENTIAL',
- '_CREDENTIALS',
- '_PRIVATE_KEY',
- ];
- // 敏感变量名黑名单(精确匹配)
- const SENSITIVE_KEYS = new Set([
- 'DEEPSEEK_API_KEY',
- 'AGNES_API_KEY',
- 'MIMO_API_KEY',
- 'GITEA_PASSWORD',
- 'DATABASE_PASSWORD',
- ]);
-
- const env: Record = {};
- for (const [key, val] of Object.entries(process.env)) {
- if (!val) continue;
- if (SENSITIVE_KEYS.has(key)) continue;
- if (SENSITIVE_SUFFIXES.some((suffix) => key.toUpperCase().endsWith(suffix))) continue;
- env[key] = val;
- }
-
- // 添加必要的运行时变量
- env.NODE_ENV = 'production';
- if (isWindows) {
- env.PYTHONIOENCODING = 'utf-8';
- env.LANG = 'zh_CN.UTF-8';
- env.LC_ALL = 'zh_CN.UTF-8';
- }
-
- return env;
+ return buildSafeChildEnv({
+ runtime: isWindows
+ ? {
+ NODE_ENV: 'production',
+ PYTHONIOENCODING: 'utf-8',
+ LANG: 'zh_CN.UTF-8',
+ LC_ALL: 'zh_CN.UTF-8',
+ }
+ : { NODE_ENV: 'production' },
+ });
}
/**
diff --git a/electron/harness/tools/built-in/http-request.ts b/electron/harness/tools/built-in/http-request.ts
index 7b7a59a..3a2709b 100644
--- a/electron/harness/tools/built-in/http-request.ts
+++ b/electron/harness/tools/built-in/http-request.ts
@@ -14,7 +14,11 @@ import type { MetonaToolDef } from '../../../harness/types';
import { MetonaToolCategory, MetonaRiskLevel } from '../../../harness/types';
// v0.6.4 P2-2: SSRF 校验收敛到共享模块 ssrf-guard.ts —— 原实现是本文件私有逻辑,
// web_fetch 无校验造成工具层最大的安全不对称。单源后所有网络工具行为一致。
+// v0.7.3 P2-1 根治: 请求层升级为 ssrfPinnedFetch —— 校验通过的 IP 集合 pin 到
+// 连接层(undici connect.lookup),校验与连接共用同一批 IP,DNS rebinding
+// 窗口(M7 已知限制)就此关闭;代理激活时自动退化为仅入口校验(见模块注释)。
import { validateSSRF } from './ssrf-guard';
+import { ssrfPinnedFetch } from './ssrf-dispatcher';
const ALLOWED_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD'] as const;
const MAX_BODY_BYTES = 50 * 1024; // 50KB
@@ -52,7 +56,8 @@ const MAX_BODY_BYTES = 50 * 1024; // 50KB
export class HttpRequestTool implements IMetonaTool {
readonly definition: MetonaToolDef = {
name: 'http_request',
- description: 'Send an HTTP/REST API request. Supports GET/POST/PUT/PATCH/DELETE/HEAD methods with custom headers and body. Response body is truncated to 50KB.',
+ description:
+ 'Send an HTTP/REST API request. Supports GET/POST/PUT/PATCH/DELETE/HEAD methods with custom headers and body. Response body is truncated to 50KB.',
parameters: {
type: 'object',
properties: {
@@ -64,7 +69,10 @@ export class HttpRequestTool implements IMetonaTool {
},
headers: { type: 'object', description: 'Request headers as key-value pairs' },
body: { type: 'string', description: 'Request body (string)' },
- timeout: { type: 'number', description: 'Timeout in milliseconds (default 30000, max 60000)' },
+ timeout: {
+ type: 'number',
+ description: 'Timeout in milliseconds (default 30000, max 60000)',
+ },
},
required: ['url'],
},
@@ -102,15 +110,12 @@ export class HttpRequestTool implements IMetonaTool {
};
}
- // 超时控制
- const controller = new AbortController();
- const timer = setTimeout(() => controller.abort(), timeout);
-
- try {
+ // 超时控制由 ssrfPinnedFetch 内部管理(超时 → ETIMEDOUT;
+ // 工具执行层的 abort signal 经 context 传入 registry 兜底)
+ {
const fetchOptions: RequestInit = {
method,
headers,
- signal: controller.signal,
// #10 修复: 禁用自动重定向跟随 — 防止重定向到内网地址绕过 SSRF 校验
// 重定向后的 URL 由用户自行处理(响应中会包含 Location 头)
redirect: 'manual',
@@ -120,7 +125,9 @@ export class HttpRequestTool implements IMetonaTool {
fetchOptions.body = body;
}
- const response = await fetch(url, fetchOptions);
+ // v0.7.3 P2-1: pinned fetch —— 校验通过的 IP pin 到连接层,
+ // 关闭校验-连接之间的 DNS rebinding 窗口
+ const response = await ssrfPinnedFetch(url, fetchOptions, timeout);
const text = await response.text();
// 截断到 50KB
@@ -144,14 +151,14 @@ export class HttpRequestTool implements IMetonaTool {
body: safeBody,
truncated,
ok: response.ok,
- success: true, // v0.3.1 修复 WARN-4: 成功路径添加 success 字段
+ success: true, // v0.3.1 修复 WARN-4: 成功路径添加 success 字段
};
- } finally {
- clearTimeout(timer);
}
} catch (error) {
- // 区分超时(AbortError)与其他网络错误
- if (error instanceof Error && error.name === 'AbortError') {
+ // 区分超时与其他网络错误:AbortError(外部中断)与
+ // ETIMEDOUT(ssrfPinnedFetch 超时转译,v0.7.3 P2-1)均归为超时语义
+ const err = error as Error & { code?: string };
+ if (err?.name === 'AbortError' || err?.code === 'ETIMEDOUT') {
return { error: 'Request timeout', success: false };
}
const errMsg = error instanceof Error ? error.message : String(error);
diff --git a/electron/harness/tools/built-in/network-utils.ts b/electron/harness/tools/built-in/network-utils.ts
index 2aed309..01af136 100644
--- a/electron/harness/tools/built-in/network-utils.ts
+++ b/electron/harness/tools/built-in/network-utils.ts
@@ -34,7 +34,8 @@ export const UA_POOL = [
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0',
];
-export const MOBILE_UA = 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1';
+export const MOBILE_UA =
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1';
export const ACCEPT_LANGUAGE_POOL = [
'zh-CN,zh;q=0.9,en;q=0.8',
@@ -54,21 +55,25 @@ export function buildAntiCrawlHeaders(
const userAgent = mobileUA ? MOBILE_UA : UA_POOL[uaIdx];
let origin = '';
- try { origin = new URL(url).origin; } catch { /* ignore */ }
+ try {
+ origin = new URL(url).origin;
+ } catch {
+ /* ignore */
+ }
return {
'User-Agent': userAgent,
- 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8',
+ Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8',
'Accept-Language': ACCEPT_LANGUAGE_POOL[langIdx],
'Accept-Encoding': 'gzip, deflate, br',
'Cache-Control': 'no-cache',
- 'DNT': '1',
- 'Referer': origin || '',
+ DNT: '1',
+ Referer: origin || '',
'Sec-Fetch-Dest': 'document',
'Sec-Fetch-Mode': 'navigate',
'Sec-Fetch-Site': 'none',
'Sec-Fetch-User': '?1',
- 'Pragma': 'no-cache',
+ Pragma: 'no-cache',
};
}
@@ -107,7 +112,15 @@ export function normalizeUrl(url: string): string {
const u = new URL(url);
// 去除追踪参数
- const trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'gclid', 'fbclid'];
+ const trackingParams = [
+ 'utm_source',
+ 'utm_medium',
+ 'utm_campaign',
+ 'utm_term',
+ 'utm_content',
+ 'gclid',
+ 'fbclid',
+ ];
for (const p of trackingParams) u.searchParams.delete(p);
// H-6 增强: 排序查询参数(确保参数顺序一致,便于去重)
@@ -125,7 +138,7 @@ export function normalizeUrl(url: string): string {
(u.protocol === 'https:' && u.port === '443') ||
(u.protocol === 'ws:' && u.port === '80') ||
(u.protocol === 'wss:' && u.port === '443');
- const portSuffix = isDefaultPort ? '' : (u.port ? `:${u.port}` : '');
+ const portSuffix = isDefaultPort ? '' : u.port ? `:${u.port}` : '';
// 强制小写 host
return `${u.protocol}//${u.hostname.toLowerCase()}${portSuffix}${path}${u.search}${u.hash}`;
@@ -158,47 +171,68 @@ export function isInterceptedPage(html: string): boolean {
// ===== HTML → 纯文本转换 =====
const HTML_ENTITY_MAP: Record = {
- ' ': ' ', '<': '<', '>': '>', '&': '&', '"': '"',
- ''': "'", '…': '…', '—': '—', '–': '–',
- '«': '«', '»': '»', '×': '×', '÷': '÷',
- '©': '©', '®': '®', '™': '™', '€': '€',
- '£': '£', '¥': '¥', '¢': '¢', '°': '°',
+ ' ': ' ',
+ '<': '<',
+ '>': '>',
+ '&': '&',
+ '"': '"',
+ ''': "'",
+ '…': '…',
+ '—': '—',
+ '–': '–',
+ '«': '«',
+ '»': '»',
+ '×': '×',
+ '÷': '÷',
+ '©': '©',
+ '®': '®',
+ '™': '™',
+ '€': '€',
+ '£': '£',
+ '¥': '¥',
+ '¢': '¢',
+ '°': '°',
};
export function htmlToText(html: string): string {
- return html
- // 移除噪声标签及内容
- .replace(/