feat: v0.8.1 记忆深化 · 观测闭环 · 体验收口 — 窗口/输出上限全局单一配置 · 2478 用例全量回归 + E2E 冒烟
硬性契约:删除代码中一切写死的上下文窗口与最大输出上限(含六家模型元信息
钳制与全部兜底值)——唯一合法来源是设置面板「上下文长度」(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:
+132
-48
@@ -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:重定向 userData(workspace-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: 不再携带写死的 contextWindow(4096)—— 未配置时 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_ctx(contextLength)与「上下文长度」同源 —— 同一设置同时驱动
|
||||
// 压缩预算与 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 返回
|
||||
// null,MemoryManager 全量回退纯 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 属性);
|
||||
|
||||
Reference in New Issue
Block a user