feat: v0.8.1 记忆深化 · 观测闭环 · 体验收口 — 窗口/输出上限全局单一配置 · 2478 用例全量回归 + E2E 冒烟
CI / 类型检查 + Lint + 单元测试 (push) Failing after 9m8s
CI / 全量测试 (Electron ABI) (push) Failing after 6m0s
CI / 产物编译验证 (push) Successful in 10m58s

硬性契约:删除代码中一切写死的上下文窗口与最大输出上限(含六家模型元信息
钳制与全部兜底值)——唯一合法来源是设置面板「上下文长度」(llm.contextWindow)
与「最大输出上限」(llm.maxTokens),跨 Provider/模型原样透传。

P0 正确性收口:
- 迁移 11/12(SCHEMA_VERSION 5):记忆表 embedding 列 + 分 Provider 窗口键清理
- 记忆生命周期接线:会话终态清理 working memory / episodic 90 天 TTL / access_count 回写
- 回放缓冲模块化 + 会话终态清理(杜绝 4MB/会话内存滞留)
- i18n 收口:主进程 main-locale(zh/en,ui.locale 热切换)+ 渲染层 17 处出层

P1 能力演进:
- 本地向量混合检索:0.6×向量余弦 + 0.4×TF-IDF,Ollama embeddings 首次投产,
  存量记忆惰性回填,嵌入不可用自动回退 TF-IDF
- MEMORY.md 维护闭环:固化去重消除截断盲区;两阶段维护(AI 建议 → 用户确认 →
  原子改写 + 语义记忆双轨同步 + 审计);>50KB 告警
- 可观测闭环:cacheTokens 引擎→前端透传(Token 面板命中率/成本行)+ 输入框
  上下文占用指示条
- MCP Prompts/Resources 对话可用:/mcp:{server}:{prompt} 与 @mcp:{server}:{uri}

P2 体验补全:
- 工具自定义策略(正则白/黑名单 + 频率 + 强制确认,热生效)
- 连续 ≥3 同类工具确认聚合为单弹框
- 会话消息游标分页(首屏 200 条向上翻页)
- 开机自启;Playwright + Electron E2E 冒烟(本地 mock LLM 零外联)

Review 回归修复:MCP 大小写失配 / 分页状态复位 / 清空=未配置语义(Number(null)=0
隐患)/ MEMORY.md 告警位置 / working_memories FK(迁移 13)/ 全局配置层废键清理;
附带根治权限加固启动时序、代理回环放行、safeStorage 降级、悬空 symlink 逃逸。

验证:typecheck/lint 0 问题;test:electron 2478/2478(0 跳过);E2E 2/2;
docs/v0.8.1-迭代实施清单.md 全项留档。
This commit is contained in:
2026-09-08 09:35:58 +08:00
parent 839860083f
commit 9b45c445bf
85 changed files with 5286 additions and 1158 deletions
+58 -103
View File
@@ -29,6 +29,8 @@ import { buildUserContextPrefix, withUserContextPrefix } from '../harness/prompt
// v0.7.3 P1-5: 记忆固化触发决策(纯函数)
import { shouldConsolidate } from '../harness/memory/consolidation-policy';
import log from 'electron-log';
// v0.8.1 P0-4: 主进程 toast 文案双语(ui.locale 驱动)
import { mt } from '../utils/main-locale';
/** 构建 "时区名 (UTC±N)" 标签(注入用户上下文前置块;失败回退 UTC) */
function buildTimezoneLabel(): string {
@@ -67,34 +69,14 @@ interface IterationTrace {
finishReason?: string;
}
/**
* v0.8.0 P1-1a: 单会话的流式回放缓冲 —— 渲染层切走会话期间,事件管道仍照常
* 投递(按会话隔离被渲染层准入拒绝),此缓冲按序保存 streamEvent + stateChange
* 双通道事件;用户切回会话时经 agent:getReplayState 拉取并灌入渲染层事件总线,
* 实现"后台会话运行内容不丢失"。
*
* 有界设计:单会话上限 MAX_REPLAY_EVENTS 条 / MAX_REPLAY_BYTES 字节,溢出丢弃
* 最旧并置 truncated 标记;TERMINATED 后保留(供"完成后切回"看到最终内容),
* 下一次 run 启动(INIT stateChange)时清空重建。
*/
interface ReplayEntry {
channel: 'streamEvent' | 'stateChange';
payload: unknown;
ts: number;
}
interface ReplayBuffer {
events: ReplayEntry[];
bytes: number;
truncated: boolean;
/** 最近一次观察到的 runId(供渲染层回放后对齐 run 守卫) */
runId?: string;
terminated: boolean;
}
const MAX_REPLAY_EVENTS = 2000;
const MAX_REPLAY_BYTES = 4 * 1024 * 1024;
const MAX_REPLAY_SESSIONS = 50;
// v0.8.1 P0-3: 回放缓冲抽为独立模块(replay-buffer.ts)—— 会话删除/彻底删除
// 的终态路径可显式清除缓冲,杜绝已删会话最多 4MB/会话的内存滞留
import {
appendReplay,
resetReplayBuffer,
markReplayTerminated,
getReplayBufferData,
} from './replay-buffer';
export function registerAgentHandlers(ctx: IPCContext): void {
const {
@@ -118,54 +100,9 @@ export function registerAgentHandlers(ctx: IPCContext): void {
// ===== 常驻事件管道:text_delta 按会话节流(F8 =====
const throttleStates = new Map<string, ThrottleState>();
const iterationTraces = new Map<string, IterationTrace>();
// v0.8.0 P1-1a: 每会话流式回放缓冲(后台会话内容恢复)
const replayBuffers = new Map<string, ReplayBuffer>();
/** 追加一条事件到会话回放缓冲(有界:条数/字节双上限,溢出丢最旧) */
const appendReplay = (sessionId: string, entry: ReplayEntry): void => {
let buf = replayBuffers.get(sessionId);
if (!buf) {
// 会话数上限保护:超出后淘汰最早的缓冲(Map 迭代序 = 插入序)
if (replayBuffers.size >= MAX_REPLAY_SESSIONS) {
const oldest = replayBuffers.keys().next().value as string | undefined;
if (oldest !== undefined) replayBuffers.delete(oldest);
}
buf = { events: [], bytes: 0, truncated: false, terminated: false };
replayBuffers.set(sessionId, buf);
}
// run 启动(INIT)后旧 run 缓冲作废 —— 由 stateChange 监听器在 INIT 时清空,
// 此处仅在 terminated 缓冲上遇到新内容时兜底重置(正常路径 INIT 先行)
if (buf.terminated) {
replayBuffers.delete(sessionId);
buf = { events: [], bytes: 0, truncated: false, terminated: false };
replayBuffers.set(sessionId, buf);
}
let size = 0;
try {
size = JSON.stringify(entry.payload).length;
} catch {
size = 256; // 序列化失败按保守值计入
}
buf.events.push(entry);
buf.bytes += size;
while (
buf.events.length > MAX_REPLAY_EVENTS ||
(buf.bytes > MAX_REPLAY_BYTES && buf.events.length > 1)
) {
const dropped = buf.events.shift();
buf.truncated = true;
if (dropped) {
try {
buf.bytes -= JSON.stringify(dropped.payload).length;
} catch {
buf.bytes -= 256;
}
}
}
// 记录 runId(首个携带者)
const payload = entry.payload as { runId?: string } | null;
if (!buf.runId && payload?.runId) buf.runId = payload.runId;
};
// v0.8.1 review: MEMORY.md 体积告警去重(每会话一次)
const memoryOversizeWarned = new Set<string>();
// v0.7.3 P1-5: 会话级记忆固化时间戳(consolidation-policy 频率门控的状态持有方)
// v0.7.4 P3-5: LRU 化 —— 旧实现每会话一条永不清除,长期运行后无界增长。
@@ -340,12 +277,11 @@ export function registerAgentHandlers(ctx: IPCContext): void {
if (sessionId) {
const stateRaw = data.state ?? data.current ?? '';
if (stateRaw === 'INIT') {
replayBuffers.delete(sessionId);
resetReplayBuffer(sessionId);
} else {
appendReplay(sessionId, { channel: 'stateChange', payload: data, ts: Date.now() });
if (stateRaw === 'TERMINATED') {
const buf = replayBuffers.get(sessionId);
if (buf) buf.terminated = true;
markReplayTerminated(sessionId);
}
}
}
@@ -434,7 +370,11 @@ export function registerAgentHandlers(ctx: IPCContext): void {
// toast 通知用户压缩已发生
broadcast('toast:show', {
type: 'info',
message: `上下文压缩: ${data.originalTokens ?? '?'}${data.compressedTokens ?? '?'} tokens(节省 ${savedTokens}`,
message: mt('agent.toast.compressed', {
original: data.originalTokens ?? '?',
compressed: data.compressedTokens ?? '?',
saved: savedTokens,
}),
});
// 通过 streamEvent 转发,前端 useAgentStream 监听 'compressed' 类型后更新 store
broadcast('agent:streamEvent', {
@@ -454,7 +394,7 @@ export function registerAgentHandlers(ctx: IPCContext): void {
log.warn(`[AGENT] Dead loop detected at iteration ${data.iteration ?? '?'}`);
broadcast('toast:show', {
type: 'warning',
message: `检测到死循环(第 ${data.iteration ?? '?'} 轮):连续3轮重复相同工具调用,已自动终止`,
message: mt('agent.toast.deadLoop', { iteration: data.iteration ?? '?' }),
});
});
@@ -470,7 +410,7 @@ export function registerAgentHandlers(ctx: IPCContext): void {
});
broadcast('toast:show', {
type: 'warning',
message: `Provider 故障转移: ${data.from ?? '?'}${data.to ?? '?'}(主 Provider 请求失败)`,
message: mt('agent.toast.providerSwitched', { from: data.from ?? '?', to: data.to ?? '?' }),
});
},
);
@@ -498,7 +438,7 @@ export function registerAgentHandlers(ctx: IPCContext): void {
if (!sessionId || typeof sessionId !== 'string') {
log.warn('[AGENT] sendMessage rejected: invalid sessionId');
sendErrorEvent('无效的会话 ID', sessionId ?? '');
sendErrorEvent(mt('agent.error.invalidSessionId'), sessionId ?? '');
return { success: false, error: 'Invalid sessionId' };
}
if (
@@ -507,7 +447,7 @@ export function registerAgentHandlers(ctx: IPCContext): void {
typeof userMessage.content !== 'string'
) {
log.warn('[AGENT] sendMessage rejected: invalid userMessage');
sendErrorEvent('无效的消息格式', sessionId);
sendErrorEvent(mt('agent.error.invalidMessage'), sessionId);
return { success: false, error: 'Invalid message format' };
}
log.info('[AGENT] sendMessage:', sessionId, (userMessage.content ?? '').slice(0, 80));
@@ -517,7 +457,7 @@ export function registerAgentHandlers(ctx: IPCContext): void {
// 排队 30s 后强制 abort 旧 run,用户看到"上一次操作未完成")。此处直接拒绝并
// 广播明确错误事件,前端 isStreaming 正常收尾。
if (agentEngineManager.isRunning(sessionId)) {
const busyMsg = '该会话正在执行任务,请等待完成或先中断后再发送';
const busyMsg = mt('agent.error.sessionBusy');
log.warn(`[AGENT] sendMessage rejected: session ${sessionId} is already running`);
sendErrorEvent(busyMsg, sessionId);
return { success: false, error: busyMsg };
@@ -529,7 +469,7 @@ export function registerAgentHandlers(ctx: IPCContext): void {
// 此处提前校验并走统一的 sendErrorEvent + stopRecording 收尾路径。
const sessionExists = sessionService.getSession(sessionId) != null;
if (!sessionExists) {
const missingMsg = '会话不存在或已被删除,请刷新后重试';
const missingMsg = mt('agent.error.sessionMissing');
log.warn(`[AGENT] sendMessage rejected: session ${sessionId} not found`);
sendErrorEvent(missingMsg, sessionId);
await sessionRecorder.stopRecording(sessionId, {
@@ -543,8 +483,7 @@ export function registerAgentHandlers(ctx: IPCContext): void {
// 发送消息前确保 Adapter 使用最新配置(失败则中止,防止用旧 Provider 的 adapter 发送)
if (!ctx.reloadAdapter()) {
const errorMsg =
'Adapter 加载失败,请检查 LLM 配置(Provider、API Key、Base URL、Model 是否完整)';
const errorMsg = mt('agent.error.adapterLoadFailed');
log.error('[AGENT]', errorMsg);
sendErrorEvent(errorMsg, sessionId);
await sessionRecorder.stopRecording(sessionId, {
@@ -593,8 +532,7 @@ export function registerAgentHandlers(ctx: IPCContext): void {
if (contextBuilder.isUsingFallbackRole()) {
broadcast('toast:show', {
type: 'info',
message:
'未找到 SOUL.md 或内容为空,已使用默认 Metona 身份。可在工作空间根目录创建 SOUL.md 自定义 Agent 人格',
message: mt('agent.soul.fallbackToast'),
});
}
@@ -603,7 +541,8 @@ export function registerAgentHandlers(ctx: IPCContext): void {
// 每条消息都改变 system 字节 → 跨 run 缓存全 miss。现随首条 user 消息注入
// LLM 语义等价),system prompt 保持跨 run 字节级稳定。
try {
const memories = memoryManager.search(userMessage.content, {
// v0.8.1: search 升级 async(向量混合检索)
const memories = await memoryManager.search(userMessage.content, {
topK: 5,
minImportance: 0.3,
});
@@ -850,7 +789,7 @@ export function registerAgentHandlers(ctx: IPCContext): void {
);
broadcast('toast:show', {
type: 'info',
message: `AI 已将 ${result.appended} 条重要记忆写入 MEMORY.md`,
message: mt('agent.toast.consolidated', { count: result.appended }),
});
}
})
@@ -861,6 +800,18 @@ export function registerAgentHandlers(ctx: IPCContext): void {
log.debug(`[AGENT] Memory consolidation skipped (${decision.reason})`);
}
// v0.8.1 review 修复: MEMORY.md 体积告警移出固化分支 —— 每次成功 run 后
// 检查(此前仅固化触发时检查,跳过固化的大文件永不告警);每会话只告警
// 一次,避免连续消息刷屏。
const memorySize = (workspaceService.getFiles().memory ?? '').length;
if (memorySize > 51_200 && !memoryOversizeWarned.has(sessionId)) {
memoryOversizeWarned.add(sessionId);
broadcast('toast:show', {
type: 'warning',
message: mt('agent.toast.memoryOversize'),
});
}
// v0.7.3 P4-1: 首个完成的 run 之后生成精炼会话标题(每会话幂等,失败静默)
if (output.terminationReason === 'completed') {
titleGenerator
@@ -955,7 +906,7 @@ export function registerAgentHandlers(ctx: IPCContext): void {
}
const balance = await adapter.getBalance();
if (!balance) {
return { success: false, error: '余额查询失败(API Key 无效或网络错误)' };
return { success: false, error: mt('llm.balance.queryFailed') };
}
return { success: true, data: balance };
} catch (error) {
@@ -975,18 +926,18 @@ export function registerAgentHandlers(ctx: IPCContext): void {
const model = configService.get<string>('llm.model') ?? '';
const apiKey = configService.get<string>('llm.apiKey') || '';
if (!provider || !model) {
return { success: false, error: 'LLM 未配置(Provider/Model 为空),无法获取模型列表' };
return { success: false, error: mt('llm.listModels.notConfigured') };
}
if (!apiKey && provider !== 'ollama') {
return { success: false, error: 'API Key 未配置,无法获取模型列表' };
return { success: false, error: mt('llm.listModels.noApiKey') };
}
// 列表基于当前已保存配置 —— 先幂等重载 adapter(配置签名未变时为 no-op
if (!ctx.reloadAdapter()) {
return { success: false, error: 'LLM 配置校验失败,请先在设置中修正配置' };
return { success: false, error: mt('llm.listModels.configInvalid') };
}
const adapter = agentEngineManager.getAdapter();
if (!adapter.listModels) {
return { success: false, error: '当前 Provider 不支持模型列表查询' };
return { success: false, error: mt('llm.listModels.unsupported') };
}
const models = await adapter.listModels();
return { success: true, data: models };
@@ -1014,10 +965,10 @@ export function registerAgentHandlers(ctx: IPCContext): void {
}
const adapter = agentEngineManager.getAdapter();
if (!(adapter instanceof OllamaAdapter)) {
return { success: false, error: '仅 Ollama Provider 支持模型下载' };
return { success: false, error: mt('llm.pull.ollamaOnly') };
}
if (ollamaPullController) {
return { success: false, error: '已有模型下载任务进行中,请先取消' };
return { success: false, error: mt('llm.pull.inProgress') };
}
const controller = new AbortController();
ollamaPullController = controller;
@@ -1045,7 +996,7 @@ export function registerAgentHandlers(ctx: IPCContext): void {
ipcMain.handle('llm:ollamaPullCancel', async () => {
if (!ollamaPullController) {
return { success: false, error: '没有进行中的下载任务' };
return { success: false, error: mt('llm.pull.none') };
}
ollamaPullController.abort();
return { success: true };
@@ -1071,6 +1022,8 @@ export function registerAgentHandlers(ctx: IPCContext): void {
// 旧实现只覆盖 taskCompleted/taskError 路径,被 abort 的 SubAgent 残留 Map 条目
for (const taskId of abortedTaskIds) {
confirmationHook.forgetSession(taskId);
// v0.8.1 P0-2: 被中止 SubAgent 的工作记忆一并清理
memoryManager.clearWorkingMemory(taskId);
subTraces.delete(taskId);
subMeta.delete(taskId);
// 中止的 SubAgent 录制文件也收尾(TRACE 完整性:标记为中断终止)
@@ -1104,14 +1057,14 @@ export function registerAgentHandlers(ctx: IPCContext): void {
if (typeof sessionId !== 'string' || !sessionId) {
return { success: false, error: 'Invalid sessionId' };
}
const buf = replayBuffers.get(sessionId);
const data = getReplayBufferData(sessionId);
return {
success: true,
data: {
isRunning: agentEngineManager.isRunning(sessionId),
runId: buf?.runId ?? null,
truncated: buf?.truncated ?? false,
events: buf?.events ?? [],
runId: data.runId,
truncated: data.truncated,
events: data.events,
},
};
});
@@ -1151,6 +1104,8 @@ export function registerAgentHandlers(ctx: IPCContext): void {
subMeta.delete(taskId);
// v0.7.3 P2-3: SubAgent 终态 —— 决策记忆随任务终结清理(防长期运行泄漏)
confirmationHook.forgetSession(taskId);
// v0.8.1 P0-2: SubAgent 终态联动清理工作记忆(taskId 作为 sessionId 写入)
memoryManager.clearWorkingMemory(taskId);
};
orchestrator.on(