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 全链路接入
201 lines
7.9 KiB
TypeScript
201 lines
7.9 KiB
TypeScript
/**
|
||
* 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;
|
||
}
|
||
}
|
||
}
|