feat: v0.4.0 四阶段迭代 — 安全加固 + 工程基线 + 架构重构 + 双 Provider 扩展

P0 安全修复:
- API Key 加密存储(safeStorage 密钥链,版本化前缀,历史明文平滑兼容)
- 间接提示注入防护(SecurityScanHook 工具结果深扫描,网络工具脱敏/本地工具警示分级)
- error:report IPC 断链修复(渲染进程错误上报落 electron-log + 审计)
- abort 信号贯通工具层(run_command/dev-tools 子进程随会话中断终止)
- run_command 沙箱加固(cd 系统目录/敏感文件读取拦截 + chcp 前缀剥离防解析退化)
- .env 真实生效(dotenv 回退加载,应用内配置优先)

P1 工程基础:
- ESLint 9 flat config + 全部 34 条存量 warnings 清零(零容忍基线)
- 测试基线 118 用例 11 文件(token/文件防护/权限/沙箱/注入/命令/引擎/注册表/审计链/摘要分层)
- test:electron 双模式(ELECTRON_RUN_AS_NODE 跑 Electron ABI,SQLite 套件全执行)
- SessionRecorder 多会话隔离 + 9 种 TRACE 事件补全(含最终轮 iteration_end)
- Provider 故障转移(重试耗尽/不可重试一次性切换 fallback + 前端通知)
- MCP 真就绪(等待全部连接完成再广播 tools:ready)
- SLO/HealthChecker 真实接入(60s 巡检 + 托盘状态)
- CONFIG_DEFAULTS 单一来源(消除 SEED 双源漂移)

P2 架构升级:
- handlers.ts 1940 行拆分为 13 个 IPC 域模块(防重入注册 + 多窗口广播)
- AgentEngineManager 每会话独立引擎(LRU 30 + adapter 工厂隔离 abort 信号)
- TaskOrchestrator EngineProvider 改造 + abortByParent 联动中断 SubAgent
- 会话摘要分层上下文(session_summaries 滚动摘要 + 截断游标清理防因果污染)
- 消息编辑重发/重新生成(truncateAfter IPC + store 动作 + UI)
- Markdown 导出 / WebSearch 并行抓取(并发 3)/ 记忆 TF 缓存 / 版本构建期注入

P3 能力扩展:
- OpenAI Adapter(o 系列推理模型 reasoning_effort/max_completion_tokens)
- Anthropic Adapter(原生 Messages API:tool_use 块/角色合并/thinking budget/图片 base64/SSE 事件机)
- 设置页/Onboarding 六 Provider 全链路接入
This commit is contained in:
2026-08-20 23:17:02 +08:00
parent b9f7ec5118
commit 2230bcec3f
90 changed files with 6581 additions and 2771 deletions
@@ -0,0 +1,200 @@
/**
* Session Summary Service — 会话摘要分层上下文(P2-11)
*
* 解决"历史消息全量加载"问题:sendMessage 原实现从 DB 加载全部历史消息进 LLM
* 上下文,超长会话(数百条消息)token 成本线性膨胀,只能依赖运行时压缩兜底。
*
* 分层策略(DB 层持久化,跨 run 生效):
* 1. 会话消息数超过阈值(50 条)后,将较早消息(保留尾部 20 条原文)交由 LLM
* 生成滚动摘要,持久化到 session_summaries 表(含 summarized_until_rowid 游标)
* 2. 下次 sendMessage 只加载:[摘要消息] + [rowid > 游标的近期原文消息]
* 3. 摘要随新消息增量滚动更新(旧摘要作为上下文参与新一轮总结)
*
* 与 engine 运行时压缩(compressMessages)的关系:
* 运行时压缩处理"单次 run 内"的上下文膨胀;本服务处理"跨 run"的 DB 历史分层,
* 两者互补,运行时压缩触发频率将显著下降。
*
* @see electron/services/database.service.ts — session_summaries 表结构
*/
import { nanoid } from 'nanoid';
import type Database from 'better-sqlite3';
import log from 'electron-log';
import type { IMetonaProviderAdapter, MetonaMessage, MetonaRequest } from '../harness/types';
import type { SessionService } from './session.service';
/** 触发摘要的最小消息总数(低于此值保持全量加载) */
const MIN_MESSAGES_TO_SUMMARIZE = 50;
/** 摘要后保留的尾部原文条数 */
const TAIL_KEEP = 20;
/** 每次摘要需新增的最小未总结消息数(避免每条消息都触发 LLM 摘要) */
const MIN_NEW_TO_SUMMARIZE = 15;
/** LLM 摘要调用超时 */
const SUMMARY_TIMEOUT_MS = 30_000;
/** 传给 LLM 的单条消息内容截断 */
const PER_MESSAGE_TRUNCATE = 600;
/** 传给 LLM 的总字符上限 */
const MAX_DIGEST_CHARS = 24_000;
export class SessionSummaryService {
constructor(
private getDB: () => Database.Database,
private sessionService: SessionService,
private adapterGetter: () => IMetonaProviderAdapter,
) {}
/**
* 构建分层历史消息(sendMessage 的加载入口)
*
* @returns MetonaMessage 数组:存在摘要时为 [摘要消息, 近期原文...],否则全量原文
*/
buildHistoryMessages(sessionId: string): MetonaMessage[] {
const existing = this.getSummary(sessionId);
const tail = this.sessionService.getMessages(sessionId, {
afterRowid: existing?.summarizedUntilRowid ?? 0,
});
const messages: MetonaMessage[] = tail
.filter((m) => m.role !== 'system')
.map((m) => ({
role: m.role as MetonaMessage['role'],
content: m.content,
reasoningContent: m.reasoningContent,
toolCalls: m.toolCalls as MetonaMessage['toolCalls'],
toolResult: m.toolResult as MetonaMessage['toolResult'],
timestamp: m.timestamp,
iteration: m.iteration,
}));
if (existing && messages.length > 0) {
// 摘要以 assistant 角色注入(与 engine 运行时压缩的注入策略一致)
const summaryMessage: MetonaMessage = {
role: 'assistant',
content: `[Context Summary] The following is a rolling summary of earlier conversation history:\n\n${existing.summary}`,
timestamp: 0,
};
return [summaryMessage, ...messages];
}
return messages;
}
/**
* 会话结束后评估并生成滚动摘要(fire-and-forget 调用,失败仅记录日志)
*/
async maybeSummarize(sessionId: string): Promise<void> {
const rows = this.sessionService.getMessages(sessionId);
if (rows.length < MIN_MESSAGES_TO_SUMMARIZE) return;
const existing = this.getSummary(sessionId);
const lastSummarized = existing?.summarizedUntilRowid ?? 0;
const unsummarized = rows.filter((r) => (r.rowId ?? 0) > lastSummarized);
// 未总结增量不足(保留尾部 TAIL_KEEP 后仍需 ≥ MIN_NEW_TO_SUMMARIZE 条)
if (unsummarized.length <= TAIL_KEEP + MIN_NEW_TO_SUMMARIZE) return;
const toSummarize = unsummarized.slice(0, unsummarized.length - TAIL_KEEP);
if (toSummarize.length < MIN_NEW_TO_SUMMARIZE) return;
const summary = await this.summarizeViaLLM(existing?.summary ?? '', toSummarize);
if (!summary) return;
const untilRowid = toSummarize[toSummarize.length - 1].rowId ?? 0;
this.saveSummary(sessionId, summary, untilRowid);
log.info(
`[SessionSummary] session ${sessionId}: summarized ${toSummarize.length} messages (up to rowid ${untilRowid}), kept ${TAIL_KEEP} recent`,
);
}
// ===== 摘要表 CRUD =====
getSummary(sessionId: string): { summary: string; summarizedUntilRowid: number } | null {
const db = this.getDB();
const row = db
.prepare('SELECT summary, summarized_until_rowid FROM session_summaries WHERE session_id = ?')
.get(sessionId) as { summary: string; summarized_until_rowid: number } | undefined;
return row ? { summary: row.summary, summarizedUntilRowid: row.summarized_until_rowid } : null;
}
saveSummary(sessionId: string, summary: string, untilRowid: number): void {
const db = this.getDB();
db.prepare(`
INSERT INTO session_summaries (session_id, summary, summarized_until_rowid, updated_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(session_id) DO UPDATE SET
summary = excluded.summary,
summarized_until_rowid = excluded.summarized_until_rowid,
updated_at = excluded.updated_at
`).run(sessionId, summary, untilRowid, Date.now());
}
// ===== LLM 摘要 =====
/**
* 调用 LLM 生成滚动摘要(复用主 Provider adapter
*
* @param priorSummary 既有摘要(滚动总结上下文,可为空)
* @param messages 本轮待总结的消息
*/
private async summarizeViaLLM(
priorSummary: string,
messages: Array<{ role: string; content: string | null }>,
): Promise<string | null> {
let total = 0;
const transcript = messages
.map((m) => {
const content = (m.content ?? '').slice(0, PER_MESSAGE_TRUNCATE);
if (total < MAX_DIGEST_CHARS) {
total += content.length;
return `[${m.role.toUpperCase()}] ${content}`;
}
return null;
})
.filter((s): s is string => s !== null)
.join('\n\n');
const request: MetonaRequest = {
meta: {
sessionId: 'session-summary',
iteration: 0,
requestId: `ss_${nanoid(12)}`,
timestamp: Date.now(),
agentVersion: '1.0.0',
},
systemPrompt: {
roleDefinition: 'You are a conversation summarizer for an AI agent application.',
outputConstraints:
'Produce a rolling summary of the conversation history below. ' +
'If a prior summary exists, merge it with the new content into one updated summary. ' +
'Preserve key facts, decisions, tool outcomes, file paths, and open questions needed for future reasoning. ' +
'Output in the same language as the conversation. Maximum 400 words. Output ONLY the summary text.',
safetyGuidelines: 'Do not include sensitive data like passwords or API keys in the summary.',
},
messages: [
{
role: 'user',
content: `${priorSummary ? `## Prior summary (merge and update):\n\n${priorSummary}\n\n` : ''}## New messages to summarize:\n\n${transcript}`,
timestamp: Date.now(),
},
],
params: {
maxTokens: 2048,
temperature: 0.0,
stream: false,
thinkingEnabled: false,
thinkingEffort: 'low',
},
};
try {
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error('summary timeout')), SUMMARY_TIMEOUT_MS);
});
const response = await Promise.race([this.adapterGetter().send(request), timeoutPromise]);
const summary = response.content.trim();
return summary || null;
} catch (err) {
log.warn('[SessionSummary] LLM summarization failed:', (err as Error).message);
return null;
}
}
}