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
+132 -48
View File
@@ -36,6 +36,7 @@ import { TitleGenerator } from './services/title-generator.service';
import { ContextBuilder } from './harness/prompts/context-builder';
import { MemoryManager } from './harness/memory/manager';
import { MemoryConsolidator } from './harness/memory/consolidator';
import { MemoryMaintainer } from './harness/memory/maintainer';
import { registerAllIPCHandlers } from './ipc';
import type { ToolsReadyRef } from './ipc';
import { ToolRegistry } from './harness/tools/registry';
@@ -85,12 +86,14 @@ import {
SecurityScanHook,
} from './harness/hooks';
import { ConfirmationHook } from './harness/hooks/confirmation-hook';
import { PolicyEngine } from './harness/sandbox/permissions';
import { PolicyEngine, parseToolPolicy } from './harness/sandbox/permissions';
import { SandboxManager } from './harness/sandbox/sandbox';
import { PromptInjectionDefender } from './harness/security/prompt-injection-defense';
import { OutputValidator } from './harness/verification/output-validator';
import { TaskOrchestrator } from './harness/orchestration/orchestrator';
import { HealthChecker, SLOMonitor } from './utils/slo';
// v0.8.1 P0-4: 主进程 toast/通知文案双语(ui.locale 驱动)
import { setMainLocale, mt } from './utils/main-locale';
// v0.6.4 P4-5: session 级网络代理应用工具(default + agent-browser 分区)
// ===== 步骤 1: 初始化日志系统(SYS 层)=====
@@ -117,6 +120,16 @@ app.on('child-process-gone', (_event, details) => {
);
});
// ===== E2E 测试引导(v0.8.1 P2-5=====
// METONA_USER_DATA_DIR:重定向 userDataworkspace-config.json / 全局配置层),
// 使 Playwright 冒烟测试与真实用户数据完全隔离;
// METONA_E2E_SEED_CONFIG:跳过引导向导并写入一组确定性的合法 LLM 配置
//Provider/Model/输出上限/上下文长度 —— 全部为设置面板合法配置项),
// 指向 e2e/mock-llm.ts 启动的本地 OpenAI 兼容服务。仅在显式设置时生效。
if (process.env['METONA_USER_DATA_DIR']) {
app.setPath('userData', process.env['METONA_USER_DATA_DIR']);
}
// ===== 工作空间路径独立存储(解决 DB 在 workspace 内的鸡生蛋问题)=====
const WORKSPACE_CONFIG_FILE = join(app.getPath('userData'), 'workspace-config.json');
@@ -204,6 +217,21 @@ async function initialize(): Promise<void> {
// 注入全局配置层:读取时回退到全局 JSON,写入时双写
configService.setGlobalConfig(globalConfigService);
// v0.8.1 P0-4: 主进程语言随 ui.locale 注入(变更经 applyConfigSideEffects 热切换)
setMainLocale(configService.get<string>('ui.locale'));
// v0.8.1 P2-5: E2E 引导种子(见文件顶部说明;仅 METONA_E2E_SEED_CONFIG 时生效)
if (process.env['METONA_E2E_SEED_CONFIG'] === '1') {
configService.set('onboarding.completed', true);
configService.set('llm.provider', 'deepseek');
configService.set('llm.model', 'deepseek-v4-flash');
configService.set('llm.baseURL', process.env['METONA_E2E_LLM_URL'] ?? 'http://127.0.0.1:0');
configService.set('llm.apiKey', 'e2e-test-key');
configService.set('llm.maxTokens', 2048);
configService.set('llm.contextWindow', 32768);
log.info('[E2E] Seeded deterministic LLM config for smoke test');
}
// v0.3.17 迁移: 首次启用全局配置层时,把工作空间 DB 中的全局配置同步到全局 JSON
// 幂等设计:migrateFromWorkspaceDB 仅写入全局层不存在的 key
const configRows = db.prepare('SELECT key, value FROM app_config').all() as Array<{
@@ -265,11 +293,10 @@ async function initialize(): Promise<void> {
return null;
}
// 读取 Provider 对应的 contextWindow 配置(Ollama 不使用此字段)
const contextWindow =
provider !== 'ollama'
? (configService.get<number>(`${provider}.contextWindow`) ?? undefined)
: undefined;
// v0.8.1 硬性契约: 上下文窗口唯一合法来源是设置面板「上下文长度」
// llm.contextWindow,全局单一配置,适用于一切 Provider/模型)。
// 删除了旧的 deepseek/agnes/mimo/openai/anthropic.contextWindow 分 Provider 键。
const contextWindow = configService.get<number>('llm.contextWindow') ?? undefined;
const adapterConfig = { provider, baseURL, apiKey, defaultModel: model, contextWindow };
switch (provider) {
@@ -288,13 +315,14 @@ async function initialize(): Promise<void> {
}
};
// 配置未就绪时的 fallback adapter — getContextWindow 返回安全值,send/sendStream 会报错但前端可见
// 配置未就绪时的 fallback adapter — send/sendStream 会报错但前端可见
// v0.8.1: 不再携带写死的 contextWindow4096)—— 未配置时 getContextWindow
// 返回 0,压缩判定跳过;该 adapter 仅在 LLM 配置缺失时兜底存在。
const FALLBACK_ADAPTER = new DeepSeekAdapter({
provider: '',
baseURL: '',
apiKey: '',
defaultModel: '',
contextWindow: 4096,
});
// ===== 步骤 5: 工作空间文件 + System Prompt =====
@@ -429,15 +457,23 @@ async function initialize(): Promise<void> {
}
// ===== P2-10: Agent Engine Manager(每会话独立引擎,替代全局单引擎) =====
// v0.8.1: contextWindow / maxTokens 唯一来源是设置面板 llm.contextWindow / llm.maxTokens
// Ollama 的 num_ctxcontextLength)与「上下文长度」同源 —— 同一设置同时驱动
// 压缩预算与 num_ctx 下发,不再存在独立的 ollama.numCtx 配置键。
const buildAdapter = (): IMetonaProviderAdapter => createAdapter() ?? FALLBACK_ADAPTER;
const ollamaNumCtx = configService.get<number>('ollama.numCtx');
const getContextWindowSetting = (): number | undefined =>
configService.get<number>('llm.contextWindow') ?? undefined;
const getOllamaContextLength = (): number | undefined =>
(configService.get<string>('llm.provider') ?? '') === 'ollama'
? getContextWindowSetting()
: undefined;
const agentEngineManager = new AgentEngineManager({
buildAdapter,
baseConfig: {
maxIterations: configService.get<number>('agent.maxIterations') ?? 20,
totalTimeoutMs: configService.get<number>('agent.totalTimeoutMs') ?? 600_000,
contextLength: ollamaNumCtx ?? undefined,
contextWindow: buildAdapter().getContextWindow(),
contextLength: getOllamaContextLength(),
contextWindow: getContextWindowSetting(),
thinkingEnabled: configService.get<boolean>('agent.enableThinking') ?? true,
thinkingEffort:
(configService.get<string>('agent.thinkingEffort') as
@@ -452,7 +488,9 @@ async function initialize(): Promise<void> {
enableReflection: configService.get<boolean>('agent.enableReflection') === true,
// F-8 接通: llm.temperature / llm.maxTokens 此前为死配置(引擎硬编码 0.0/63488)
temperature: configService.get<number>('llm.temperature') ?? 0.0,
maxTokens: configService.get<number>('llm.maxTokens') ?? 63488,
// v0.8.1 硬性契约: 直接读设置面板值(种子默认由 CONFIG_DEFAULTS 保证),
// 不再在代码侧携带 63488 写死兜底
maxTokens: configService.get<number>('llm.maxTokens') ?? undefined,
},
toolRegistry,
preToolHooks,
@@ -483,10 +521,8 @@ async function initialize(): Promise<void> {
);
return null;
}
const contextWindow =
provider !== 'ollama'
? (configService.get<number>(`${provider}.contextWindow`) ?? undefined)
: undefined;
// v0.8.1: 上下文窗口全局单一配置(llm.contextWindow),不再按 Provider 读取
const contextWindow = configService.get<number>('llm.contextWindow') ?? undefined;
const cfg = { provider, baseURL, apiKey, defaultModel: model, contextWindow };
switch (provider) {
case 'agnes':
@@ -505,6 +541,27 @@ async function initialize(): Promise<void> {
};
agentEngineManager.setFallbackAdapter(buildFallbackAdapter());
// ===== v0.8.1 P1-1: 本地向量记忆嵌入器装配 =====
// 仅在 Provider=Ollama(本地推理、数据不出设备、零 token 成本)且用户在设置面板
// 配置了 memory.embeddingModel(如 nomic-embed-text)时启用;否则 embedder 返回
// nullMemoryManager 全量回退纯 TF-IDF 检索(历史行为兼容)。
// adapter 经闭包动态读取 —— 故障转移/热重载后无需重新装配。
memoryManager.setEmbedder({
embed: async (text) => {
const adapter = agentEngineManager.getAdapter();
if (!(adapter instanceof OllamaAdapter)) return null;
const model = configService.get<string>('memory.embeddingModel') ?? '';
if (!model.trim()) return null;
try {
const r = await adapter.embed({ model: model.trim(), input: text });
return r.embeddings?.[0] ?? null;
} catch (err) {
log.warn('[Memory] Embedding failed (falling back to TF-IDF):', (err as Error).message);
return null;
}
},
});
// ===== P1-11: MCP 初始化等待所有连接完成后再广播 tools:ready(修复工具未注册即广播的窗口) =====
// v0.3.18: toolsReadyRef 供 tools:isReady 查询(解决事件竞态)
// v0.5.3: MCP 工具集合运行中变更(添加/断开/启停 server)→ 同步所有已存在引擎。
@@ -544,6 +601,13 @@ async function initialize(): Promise<void> {
// v0.3.18: 注入 MemoryManager,实现 DB 记忆与 MEMORY.md 双轨交叉
memoryConsolidator.setMemoryManager(memoryManager);
// ===== v0.8.1 P1-2: MEMORY.md 维护闭环(分析/应用两阶段,应用前需用户确认) =====
const memoryMaintainer = new MemoryMaintainer(
() => agentEngineManager.getAdapter(),
workspaceService,
() => db,
);
// ===== P2-11: 会话摘要分层上下文服务 =====
const sessionSummaryService = new SessionSummaryService(
() => db,
@@ -566,8 +630,8 @@ async function initialize(): Promise<void> {
| 'high'
| 'max'
| null) ?? 'high',
contextLength: ollamaNumCtx ?? undefined,
contextWindow: buildAdapter().getContextWindow(),
contextLength: getOllamaContextLength(),
contextWindow: getContextWindowSetting(),
// v0.7.3 P3-1: SubAgent 与主引擎同源消费 enableReflection
enableReflection: configService.get<boolean>('agent.enableReflection') === true,
},
@@ -577,6 +641,17 @@ async function initialize(): Promise<void> {
agentEngineManager.setToolsAll(toolRegistry.listTools());
// 在所有工具(包括 DelegateTaskTool)注册完成后,刷新 ConfirmationHook 的工具定义缓存
confirmationHook.setToolDefs(toolRegistry.listAllTools());
// ===== v0.8.1 P2-1: 加载用户自定义工具策略(tools.{name}.policy=====
// 设置面板保存经 shared.ts 热加载;此处覆盖应用启动的冷加载。
for (const toolDef of toolRegistry.listAllTools()) {
const raw = configService.get<string>(`tools.${toolDef.name}.policy`);
if (raw != null && raw !== '') {
const parsed = parseToolPolicy(raw);
policyEngine.setPolicyOverride(toolDef.name, parsed);
if (!parsed) log.warn(`[Policy] Ignored invalid policy config for tool: ${toolDef.name}`);
}
}
log.info(`Registered ${toolRegistry.size} built-in tools`);
// ===== 热重载 Adapter 回调(设置变更时触发) =====
@@ -593,10 +668,8 @@ async function initialize(): Promise<void> {
fallbackModel: configService.get<string>('llm.fallbackModel') ?? '',
fallbackApiKey: configService.get<string>('llm.fallbackApiKey') ?? '',
fallbackBaseURL: configService.get<string>('llm.fallbackBaseURL') ?? '',
contextWindow:
provider === 'ollama'
? configService.get<number>('ollama.numCtx')
: configService.get<number>(`${provider}.contextWindow`),
// v0.8.1: 全局单一「上下文长度」配置进入签名(替代旧的分 Provider 键)
contextWindow: configService.get<number>('llm.contextWindow') ?? null,
});
};
let lastConfigSig = buildConfigSig();
@@ -619,7 +692,7 @@ async function initialize(): Promise<void> {
for (const win of wins) {
win.webContents.send('toast:show', {
type: 'warning',
message: 'LLM 配置不完整,请在设置中补全 Provider、API Key、Base URL 和 Model',
message: mt('config.toast.configIncomplete'),
});
}
return false;
@@ -630,20 +703,17 @@ async function initialize(): Promise<void> {
agentEngineManager.setFallbackAdapter(buildFallbackAdapter());
memoryConsolidator.setAdapter(agentEngineManager.getAdapter());
// Provider 切换时同步 contextLength 和 contextWindow
// v0.8.1: 同步「上下文长度」到所有引擎(Ollama 同时作为 num_ctx/contextLength
const provider = configService.get<string>('llm.provider') ?? '';
if (provider === 'ollama') {
const numCtx = configService.get<number>('ollama.numCtx');
agentEngineManager.updateConfigAll({
contextLength: numCtx ?? undefined,
contextWindow: agentEngineManager.getAdapter().getContextWindow(),
});
} else {
agentEngineManager.updateConfigAll({
contextLength: undefined,
contextWindow: agentEngineManager.getAdapter().getContextWindow(),
});
}
const ctxSetting = getContextWindowSetting();
agentEngineManager.updateConfigAll({
contextLength: provider === 'ollama' ? ctxSetting : undefined,
contextWindow: ctxSetting,
});
orchestrator.updateDefaultConfig({
contextLength: provider === 'ollama' ? ctxSetting : undefined,
contextWindow: ctxSetting,
});
log.info(`[CONFIG] Adapter reloaded: provider=${provider}`);
// 仅在 Provider 真正变化时通知渲染进程
if (lastProvider && lastProvider !== provider) {
@@ -656,7 +726,7 @@ async function initialize(): Promise<void> {
});
win.webContents.send('toast:show', {
type: 'success',
message: `Provider 已切换: ${lastProvider}${provider}`,
message: mt('config.toast.providerSwitched', { from: lastProvider, to: provider }),
});
}
}
@@ -668,7 +738,7 @@ async function initialize(): Promise<void> {
for (const win of BrowserWindow.getAllWindows()) {
win.webContents.send('toast:show', {
type: 'error',
message: `Provider 切换失败: ${(err as Error).message}`,
message: mt('config.toast.providerSwitchFailed', { message: (err as Error).message }),
});
}
return false;
@@ -749,6 +819,7 @@ async function initialize(): Promise<void> {
contextBuilder,
agentEngineManager,
toolRegistry,
policyEngine,
auditService,
sessionRecorder,
memoryManager,
@@ -758,6 +829,7 @@ async function initialize(): Promise<void> {
outputValidator,
confirmationHook,
memoryConsolidator,
memoryMaintainer,
orchestrator,
sessionSummaryService,
titleGenerator,
@@ -789,8 +861,8 @@ async function initialize(): Promise<void> {
}
if (event.status === 'available') {
trayManager?.sendNotification(
'MetonaAI 更新可用',
`新版本 ${event.latestVersion} 已发布,可在 设置 → 日志与数据 中下载安装`,
mt('notify.updateAvailable.title'),
mt('notify.updateAvailable.body', { version: event.latestVersion }),
);
}
});
@@ -830,8 +902,8 @@ async function initialize(): Promise<void> {
return;
}
trayManager?.sendNotification(
'MetonaAI — 任务完成',
`Agent 已完成任务 (${(data.durationMs / 1000).toFixed(1)}s)`,
mt('notify.taskCompleted.title'),
mt('notify.taskCompleted.body', { seconds: (data.durationMs / 1000).toFixed(1) }),
() => windowManager?.focusWindow(),
);
},
@@ -998,13 +1070,25 @@ if (!gotSingleInstanceLock) {
// 两条防线均为 deny-by-default 白名单制,任何一条失败都不放大攻击面。
{
// 防线一:权限请求白名单(deny-by-default
// v0.8.1 根治: session.defaultSession 必须在 app ready 后才能访问 —— 此前在
// 模块加载期(ready 前)直接调用,每次启动都抛 "Session can only be received
// when app is ready",权限白名单静默失效(FATAL 日志为证)。现延迟到 ready 后
// 注册,防线真正生效。
const ALLOWED_PERMISSIONS = new Set<string>(['clipboard-sanitized-write', 'fullscreen']);
session.defaultSession.setPermissionRequestHandler((_wc, permission, callback) => {
callback(ALLOWED_PERMISSIONS.has(permission));
});
session.defaultSession.setPermissionCheckHandler((_wc, permission) =>
ALLOWED_PERMISSIONS.has(permission),
);
void app
.whenReady()
.then(() => {
session.defaultSession.setPermissionRequestHandler((_wc, permission, callback) => {
callback(ALLOWED_PERMISSIONS.has(permission));
});
session.defaultSession.setPermissionCheckHandler((_wc, permission) =>
ALLOWED_PERMISSIONS.has(permission),
);
log.info('[Security] Permission whitelist registered (app ready)');
})
.catch((err) => {
log.error('[Security] Failed to register permission whitelist:', err);
});
// 防线二:生产环境 CSP 注入(仅 mainFrame,不触碰 dev server 的 HMR)。
// MUI/emotion 需要 style-src 'unsafe-inline'(运行时注入 <style> 标签与 style 属性);