Files
metona-ai-desktop/electron/ipc/handlers.ts
T
thzxx 516d8a728f feat: 升级至 v0.3.15 — MiMo 多模态 + 工作空间数据库继承 + 引导窗口上下文配置 + TS 错误修复
- feat(mimo): 适配器支持图片输入,将 images 转为 OpenAI 兼容 content parts 数组
- feat(workspace): 切换工作空间时支持继承数据库 agent.db(SQLite backup API 原子导出,自动 checkpoint WAL)
- feat(onboarding): 引导弹窗 LLM 配置加上下文长度字段(DeepSeek/Agnes=1000000、MiMo=131072、Ollama=null)
- fix(types): 修复 16 个 TypeScript 编译错误(setConfigLoaded 接口声明、10 个 IPC 返回类型加 error 字段、MUI TextField readOnly slot 迁移)
- chore(workspace): 移除死代码 traces 目录(trace 数据实际存储于数据库 sessions.metadata 字段,traces 目录从未被任何代码读写)
- docs(mimo): API 文档补充多模态输入章节和 curl/Python 代码示例
- chore: 版本号 0.3.14 → 0.3.15
2026-07-21 21:05:50 +08:00

1792 lines
77 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* IPC Handlers — 所有 IPC 通道处理逻辑
*
* 集成三层日志:
* - TOOL 层:AuditService 记录工具调用到 audit_logs 表
* - TRACE 层:SessionRecorder 写入 session_*.jsonl
* - AGENT 层:electron-log 记录状态转换
*
* @see docs/MetonaAI-Desktop 架构与交互设计.html — IPC 架构 + 日志分层
*/
import { ipcMain, BrowserWindow, shell, app, dialog } from 'electron';
import { join } from 'path';
import { nanoid } from 'nanoid';
import type { SessionService } from '../services/session.service';
import type { ConfigService } from '../services/config.service';
import type { WorkspaceService } from '../services/workspace.service';
import type { ContextBuilder } from '../harness/prompts/context-builder';
import type { AgentLoopEngine } from '../harness/agent-loop';
import type { ToolRegistry } from '../harness/tools/registry';
import type { AuditService, AuditEventType } from '../services/audit.service';
import type { SessionRecorder } from '../services/session-recorder.service';
import type { MemoryManager, MemoryType } from '../harness/memory/manager';
import type { MCPManager } from '../services/mcp-manager.service';
import type { PromptInjectionDefender } from '../harness/security/prompt-injection-defense';
import type { OutputValidator } from '../harness/verification/output-validator';
import type { ConfirmationHook } from '../harness/hooks/confirmation-hook';
import type { MemoryConsolidator } from '../harness/memory/consolidator';
import type { TaskOrchestrator } from '../harness/orchestration/orchestrator';
import type { MetonaMessage, MetonaStreamEvent } from '../harness/types';
import { MetonaErrorCode, MetonaStreamEventType } from '../harness/types';
import type { MetonaError } from '../harness/types';
import { estimateMessagesTokens } from '../harness/utils/token-estimator';
import log from 'electron-log';
/**
* 注册所有 IPC 处理器
*/
export function registerAllIPCHandlers(
mainWindow: BrowserWindow,
sessionService: SessionService,
configService: ConfigService,
workspaceService: WorkspaceService,
contextBuilder: ContextBuilder,
agentLoop: AgentLoopEngine,
toolRegistry: ToolRegistry,
auditService: AuditService,
sessionRecorder: SessionRecorder,
memoryManager: MemoryManager,
mcpManager: MCPManager,
reloadAdapter: () => boolean,
promptInjectionDefender: PromptInjectionDefender,
outputValidator: OutputValidator,
confirmationHook: ConfirmationHook,
memoryConsolidator: MemoryConsolidator,
orchestrator: TaskOrchestrator,
): void {
// ===== Agent 交互 =====
ipcMain.handle('agent:sendMessage', async (_event, userMessage: MetonaMessage, sessionId: string) => {
// M-33 修复: 参数校验,防止 undefined/非字符串导致下游异常
// P1-5 修复: 校验失败时也发 ERROR+DONE 流事件,防止 isStreaming 永久卡死
if (!sessionId || typeof sessionId !== 'string') {
log.warn('[AGENT] sendMessage rejected: invalid sessionId');
if (!mainWindow.isDestroyed()) {
const errorEvent: MetonaStreamEvent = {
type: MetonaStreamEventType.ERROR,
requestId: '', sessionId: sessionId ?? '', iteration: 0, seq: 0, timestamp: Date.now(),
error: { code: MetonaErrorCode.UNKNOWN, message: '无效的会话 ID', retryable: false },
};
mainWindow.webContents.send('agent:streamEvent', errorEvent);
mainWindow.webContents.send('agent:streamEvent', { ...errorEvent, type: MetonaStreamEventType.DONE });
}
return { success: false, error: 'Invalid sessionId' };
}
if (!userMessage || typeof userMessage !== 'object' || typeof userMessage.content !== 'string') {
log.warn('[AGENT] sendMessage rejected: invalid userMessage');
if (!mainWindow.isDestroyed()) {
const errorEvent: MetonaStreamEvent = {
type: MetonaStreamEventType.ERROR,
requestId: '', sessionId, iteration: 0, seq: 0, timestamp: Date.now(),
error: { code: MetonaErrorCode.UNKNOWN, message: '无效的消息格式', retryable: false },
};
mainWindow.webContents.send('agent:streamEvent', errorEvent);
mainWindow.webContents.send('agent:streamEvent', { ...errorEvent, type: MetonaStreamEventType.DONE });
}
return { success: false, error: 'Invalid message format' };
}
log.info('[AGENT] sendMessage:', sessionId, (userMessage.content ?? '').slice(0, 80));
// 发送消息前确保 Adapter 使用最新配置(失败则中止,防止用旧 Provider 的 adapter 发送)
if (!reloadAdapter()) {
const errorMsg = 'Adapter 加载失败,请检查 LLM 配置(Provider、API Key、Base URL、Model 是否完整)';
log.error('[AGENT]', errorMsg);
if (!mainWindow.isDestroyed()) {
const errorEvent: MetonaStreamEvent = {
type: MetonaStreamEventType.ERROR,
requestId: '',
sessionId,
iteration: 0,
seq: 0,
timestamp: Date.now(),
error: { code: MetonaErrorCode.UNKNOWN, message: errorMsg, retryable: false },
};
mainWindow.webContents.send('agent:streamEvent', errorEvent);
mainWindow.webContents.send('agent:streamEvent', {
...errorEvent,
type: MetonaStreamEventType.DONE,
});
}
sessionRecorder.stopRecording({ totalIterations: 0, totalTokens: 0, durationMs: 0, terminationReason: 'error' });
return { success: false, error: errorMsg };
}
// TRACE 层:开始录制
sessionRecorder.startRecording(sessionId);
// TOOL 层:记录会话开始
auditService.logSessionStart(sessionId);
// 保存用户消息到数据库
// IPC boundary: frontend sends attachments not defined in the MetonaMessage type
sessionService.saveMessage({
sessionId,
role: 'user',
content: userMessage.content,
attachments: (userMessage as MetonaMessage & { attachments?: unknown[] }).attachments,
});
// 加载历史消息
const historyRows = sessionService.getMessages(sessionId);
const history: MetonaMessage[] = historyRows
.filter((m) => m.role !== 'system')
.slice(0, -1)
.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,
}));
// 从工作空间文件构建 System Prompt(注入当前工作空间路径,便于 LLM 在需要绝对路径时使用)
const workspaceFiles = workspaceService.getFiles();
const systemPrompt = contextBuilder.buildSystemPrompt(workspaceFiles, workspaceService.getPath());
// 检索与用户消息相关的记忆,注入到 System Prompt 动态区
try {
const memories = memoryManager.search(userMessage.content, { topK: 5, minImportance: 0.3 });
if (memories.length > 0) {
const memorySection = memories.map((m, i) =>
`[${i + 1}] (${m.type}, 重要度: ${m.importance.toFixed(1)}) ${m.content.slice(0, 200)}`,
).join('\n');
const memoryBlock = `## Relevant Memories (Retrieved)\n${memorySection}`;
systemPrompt.dynamicReminders = systemPrompt.dynamicReminders
? `${systemPrompt.dynamicReminders}\n\n---\n\n${memoryBlock}`
: memoryBlock;
log.debug(`[AGENT] Injected ${memories.length} memories into system prompt`);
}
} catch (err) {
log.warn('[AGENT] Memory retrieval failed, proceeding without memories:', err);
}
// 监听 Agent Loop 事件
// F8: 主进程 text_delta 节流合并
// 问题:主进程 1:1 转发所有流式事件,text_delta 频率 30-50 次/秒,
// 每次 IPC 调用都有跨进程开销,叠加渲染进程处理成本。
// 方案:在主进程聚合 text_delta,32ms 间隔合并转发(IPC 频率降至 ~30 次/秒)。
// 非 text_delta 事件立即转发(先 flush 缓冲区保证顺序)。
// done/error 时立即 flush,避免最后一段 delta 丢失。
// 与 F5 渲染进程 rAF 批处理叠加,总延迟约 48ms(人眼不敏感)。
let textDeltaBuffer = '';
let lastTextEventMeta: Pick<MetonaStreamEvent, 'requestId' | 'sessionId' | 'iteration' | 'seq' | 'timestamp' | 'runId'> | null = null;
let flushTimer: ReturnType<typeof setTimeout> | null = null;
const flushTextDeltaBuffer = () => {
flushTimer = null;
if (!textDeltaBuffer || !lastTextEventMeta || mainWindow.isDestroyed()) {
textDeltaBuffer = '';
lastTextEventMeta = null;
return;
}
// 构建合并的 text_delta 事件,保留最后一个 delta 的元数据
const mergedEvent: MetonaStreamEvent = {
...lastTextEventMeta,
type: MetonaStreamEventType.TEXT_DELTA,
delta: textDeltaBuffer,
seq: lastTextEventMeta.seq, // 使用最后一个 delta 的 seq
timestamp: Date.now(),
};
mainWindow.webContents.send('agent:streamEvent', mergedEvent);
textDeltaBuffer = '';
lastTextEventMeta = null;
};
const onStreamEvent = (event: MetonaStreamEvent) => {
if (mainWindow.isDestroyed()) return;
// F8: text_delta 聚合,其他事件立即转发(先 flush 保证顺序)
if (event.type === MetonaStreamEventType.TEXT_DELTA && event.delta) {
if (textDeltaBuffer === '') {
lastTextEventMeta = {
requestId: event.requestId,
sessionId: event.sessionId,
iteration: event.iteration,
seq: event.seq,
timestamp: event.timestamp,
runId: event.runId,
};
} else {
// 更新 seq 为最新 delta 的 seq(保证前端 seq 连续性判断)
lastTextEventMeta = {
...lastTextEventMeta!,
seq: event.seq,
timestamp: event.timestamp,
runId: event.runId,
};
}
textDeltaBuffer += event.delta;
if (flushTimer === null) {
flushTimer = setTimeout(flushTextDeltaBuffer, 32);
}
return;
}
// 非 text_delta 事件:先 flush 缓冲区,再立即转发(保证事件顺序)
if (flushTimer !== null) {
clearTimeout(flushTimer);
flushTextDeltaBuffer();
}
mainWindow.webContents.send('agent:streamEvent', event);
};
const onStateChange = (data: { previous?: string; current?: string; sessionId?: string; iteration?: number; state?: string; runId?: string }) => {
// AGENT 层:记录状态转换
if (data.previous) log.info(`[AGENT] State: ${data.previous}${data.current}`);
// 转发到渲染进程(携带迭代号)
if (!mainWindow.isDestroyed()) {
mainWindow.webContents.send('agent:stateChange', data);
}
};
// M-6: 转发上下文压缩事件为 toast 通知
const onCompressed = (data: { iteration?: number; originalTokens?: number; compressedTokens?: number }) => {
if (!mainWindow.isDestroyed()) {
mainWindow.webContents.send('toast:show', {
type: 'info',
message: `上下文压缩: ${data.originalTokens ?? '?'}${data.compressedTokens ?? '?'} tokens`,
});
}
};
// v0.3.0: 转发死循环检测事件为 toast 警告
const onDeadLoop = (data: { iteration?: number; runId?: string; sessionId?: string }) => {
log.warn(`[AGENT] Dead loop detected at iteration ${data.iteration ?? '?'}`);
if (!mainWindow.isDestroyed()) {
mainWindow.webContents.send('toast:show', {
type: 'warning',
message: `检测到死循环(第 ${data.iteration ?? '?'} 轮):连续3轮重复相同工具调用,已自动终止`,
});
}
};
agentLoop.on('streamEvent', onStreamEvent);
agentLoop.on('stateChange', onStateChange);
agentLoop.on('compressed', onCompressed);
agentLoop.on('deadLoop', onDeadLoop);
try {
// 提示注入检测(安全模块)
const injectionResult = promptInjectionDefender.detect(userMessage.content);
if (injectionResult.riskScore >= 7) {
log.warn('[PromptInjectionDefender] Blocked message:', injectionResult.findings);
if (!mainWindow.isDestroyed()) {
const metonaError: MetonaError = {
code: MetonaErrorCode.UNKNOWN,
message: `Message blocked by prompt injection defense: ${injectionResult.recommendation}`,
retryable: false,
};
const errorEvent: MetonaStreamEvent = {
type: MetonaStreamEventType.ERROR,
requestId: '',
sessionId,
iteration: 0,
seq: 0,
timestamp: Date.now(),
error: metonaError,
};
mainWindow.webContents.send('agent:streamEvent', errorEvent);
}
// TRACE 层:停止录制
sessionRecorder.stopRecording({
totalIterations: 0,
totalTokens: 0,
durationMs: 0,
terminationReason: 'error',
});
return { success: false, error: 'Message blocked by prompt injection defense' };
}
if (injectionResult.riskScore >= 4) {
log.warn('[PromptInjectionDefender] Suspicious patterns detected:', injectionResult.findings);
}
// TRACE 层:记录上下文构建
sessionRecorder.recordContextBuilt({
tokenCount: estimateMessagesTokens(history),
usageRatio: 0,
});
// 启动 Agent Loop
const output = await agentLoop.runStream(userMessage, sessionId, history, systemPrompt);
// 输出验证(不阻塞响应,仅记录警告)
// v0.3.0 修复: 传入 toolResults 和 context,启用事实一致性检查和幻觉检测
try {
const toolResults = output.iterations
.flatMap((step) => step.toolResults ?? [])
.map((r) => (typeof r.result === 'string' ? r.result : JSON.stringify(r.result)));
const context = [...history, { role: 'user', content: userMessage.content }]
.map((m) => `${m.role}: ${m.content}`).join('\n');
const validation = await outputValidator.validate(output.finalAnswer, {
toolResults: toolResults.length > 0 ? toolResults : undefined,
context,
});
if (!validation.valid || validation.issues.length > 0) {
log.warn('[OutputValidator] Validation issues:', validation.issues);
}
log.debug(`[OutputValidator] Score: ${validation.score}, Valid: ${validation.valid}`);
} catch (err) {
log.error('[OutputValidator] Validation failed:', err);
}
// 保存每轮迭代的 assistant 消息到数据库(含思考内容和工具调用)
for (const step of output.iterations) {
if (!step.thought) continue;
// 将 MetonaToolCall + MetonaToolResult 转换为前端 ToolCallInfo 格式
const toolCallsWithResults = step.toolCalls?.map((tc) => {
const result = step.toolResults?.find((r) => r.toolCallId === tc.id);
return {
id: tc.id,
name: tc.name,
args: tc.args,
status: result?.success ? 'success' as const : 'error' as const,
result: result?.result,
durationMs: result?.durationMs,
error: result?.error,
};
});
// 只有当有内容、思考内容或工具调用时才保存
if (step.thought.content || step.thought.reasoningContent || toolCallsWithResults?.length) {
// C-6 修复: assistant 消息仅有 tool_calls 时 content 必须为 null(而非空字符串)
// @see project_memory.md — Assistant messages with tool_calls must set content to null
// 空字符串会导致 LLM API 返回 400 错误,必须使用 null
const assistantContent = (toolCallsWithResults?.length && !step.thought.content)
? null
: step.thought.content;
sessionService.saveMessage({
sessionId,
role: 'assistant',
content: assistantContent,
reasoningContent: step.thought.reasoningContent || undefined,
toolCalls: toolCallsWithResults,
iteration: step.iteration,
});
}
// v0.3.0 修复: 保存 tool 结果消息到数据库
// DeepSeek/OpenAI API 要求 assistant 消息有 tool_calls 时,后续必须有对应的 tool 结果消息
// 缺失会导致第二次发消息时 API 返回 400 Bad Request
if (step.toolResults) {
for (const result of step.toolResults) {
const resultContent = typeof result.result === 'string'
? result.result
: JSON.stringify(result.result);
sessionService.saveMessage({
sessionId,
role: 'tool',
content: result.error ?? resultContent,
toolResult: result,
iteration: step.iteration,
});
}
}
}
// 更新 Token 统计
if (output.totalTokenUsage.totalTokens > 0) {
sessionService.updateTokenUsage(sessionId, output.totalTokenUsage.totalTokens);
}
// 更新 MEMORY.md 时间戳
workspaceService.updateMemoryTimestamp();
// 会话结束:AI 判断本次对话有哪些重要内容需要持久化到 MEMORY.md
// 异步执行,不阻塞主流程返回;失败仅记录日志
memoryConsolidator
.consolidate(userMessage.content, output.finalAnswer, output.iterations)
.then((result) => {
if (result.appended > 0) {
log.info(`[AGENT] Memory consolidated: ${result.appended} entries appended to MEMORY.md`);
// 通知渲染进程记忆已更新
if (!mainWindow.isDestroyed()) {
mainWindow.webContents.send('toast:show', {
type: 'info',
message: `AI 已将 ${result.appended} 条重要记忆写入 MEMORY.md`,
});
}
}
})
.catch((err) => {
log.warn('[AGENT] Memory consolidation failed:', err);
});
// TOOL 层:记录会话结束
auditService.logSessionEnd({
sessionId,
totalIterations: output.iterations.length,
totalTokens: output.totalTokenUsage.totalTokens,
durationMs: output.durationMs,
terminationReason: output.terminationReason,
});
// TRACE 层:停止录制
sessionRecorder.stopRecording({
totalIterations: output.iterations.length,
totalTokens: output.totalTokenUsage.totalTokens,
durationMs: output.durationMs,
terminationReason: output.terminationReason,
});
log.info(`[AGENT] Completed: ${output.terminationReason}, ${output.iterations.length} iterations, ${output.durationMs}ms`);
return { success: true };
} catch (error) {
log.error('[AGENT] Error:', error);
// TOOL 层:记录错误
auditService.log({
sessionId,
eventType: 'error',
actor: 'agent',
target: 'agent_loop',
details: { error: (error as Error).message },
outcome: 'error',
});
// TRACE 层:停止录制
sessionRecorder.stopRecording({
totalIterations: 0,
totalTokens: 0,
durationMs: 0,
terminationReason: 'error',
});
// 发送错误事件到 UI
if (!mainWindow.isDestroyed()) {
const metonaError: MetonaError = {
code: MetonaErrorCode.UNKNOWN,
message: (error as Error).message,
retryable: false,
};
const errorEvent: MetonaStreamEvent = {
type: MetonaStreamEventType.ERROR,
requestId: '',
sessionId,
iteration: 0,
seq: 0,
timestamp: Date.now(),
error: metonaError,
};
mainWindow.webContents.send('agent:streamEvent', errorEvent);
}
return { success: false, error: (error as Error).message };
} finally {
// F8: 注销前 flush 残留的 text_delta 缓冲区,避免丢失最后一段内容
if (flushTimer !== null) {
clearTimeout(flushTimer);
flushTextDeltaBuffer();
}
agentLoop.off('streamEvent', onStreamEvent);
agentLoop.off('stateChange', onStateChange);
agentLoop.off('compressed', onCompressed);
agentLoop.off('deadLoop', onDeadLoop);
}
});
ipcMain.handle('agent:abortSession', async (_event, sessionId) => {
log.info('[AGENT] Abort:', sessionId);
agentLoop.abort();
// MT-1 修复: 等待当前 run 完全结束再返回,防止用户立即重发时新消息卡在
// runStream 的 currentRunPromise 等待中(最长卡 120s 工具超时)
await agentLoop.waitForAbort();
// v0.3.0 修复: 清理所有等待中的工具确认,避免定时器泄漏和超时 toast 在新会话中弹出
confirmationHook.clearPending();
// TOOL 层:记录中断
auditService.log({
sessionId,
eventType: 'session_end',
actor: 'user',
target: 'session',
details: { reason: 'user_abort' },
outcome: 'denied',
});
return { success: true };
});
// ===== 会话管理 =====
ipcMain.handle('sessions:list', async () => {
return sessionService.list();
});
ipcMain.handle('sessions:create', async (_event, title?: string) => {
return sessionService.create(title);
});
// M-34 修复: 统一 sessionId 校验辅助函数
const isValidSessionId = (id: unknown): id is string =>
typeof id === 'string' && id.length > 0 && id.length <= 200;
ipcMain.handle('sessions:rename', async (_event, sessionId: unknown, title: unknown) => {
// M-34 修复: 校验 sessionId 和 title
if (!isValidSessionId(sessionId)) return { success: false, error: 'Invalid sessionId' };
if (typeof title !== 'string' || !title.trim()) return { success: false, error: 'Invalid title' };
return { success: sessionService.rename(sessionId, title) };
});
ipcMain.handle('sessions:delete', async (_event, sessionId: unknown) => {
// M-34 修复: 校验 sessionId
if (!isValidSessionId(sessionId)) return { success: false, error: 'Invalid sessionId' };
return { success: sessionService.delete(sessionId) };
});
ipcMain.handle('sessions:getMessages', async (_event, sessionId: unknown) => {
// M-34 修复: 校验 sessionId
if (!isValidSessionId(sessionId)) return [];
return sessionService.getMessages(sessionId);
});
ipcMain.handle('sessions:pin', async (_event, sessionId: unknown, pinned: unknown) => {
// M-34 修复: 校验 sessionId 和 pinned
if (!isValidSessionId(sessionId)) return { success: false, error: 'Invalid sessionId' };
if (typeof pinned !== 'boolean') return { success: false, error: 'Invalid pinned flag' };
return { success: sessionService.pin(sessionId, pinned) };
});
ipcMain.handle('sessions:archive', async (_event, sessionId: unknown, archived: unknown) => {
// M-34 修复: 校验 sessionId 和 archived
if (!isValidSessionId(sessionId)) return { success: false, error: 'Invalid sessionId' };
if (typeof archived !== 'boolean') return { success: false, error: 'Invalid archived flag' };
return { success: sessionService.archive(sessionId, archived) };
});
ipcMain.handle('sessions:deleteMessage', async (_event, messageId: unknown) => {
// M-34 修复: 校验 messageId
if (typeof messageId !== 'string' || !messageId) return { success: false, error: 'Invalid messageId' };
return { success: sessionService.deleteMessage(messageId) };
});
ipcMain.handle('sessions:clearMessages', async (_event, sessionId: unknown) => {
// M-34 修复: 校验 sessionId
if (!isValidSessionId(sessionId)) return { success: false, error: 'Invalid sessionId' };
return { success: sessionService.clearMessages(sessionId) };
});
ipcMain.handle('sessions:saveTrace', async (_event, sessionId: unknown, data: unknown) => {
// M-34 修复: 校验 sessionId 和 data
if (!isValidSessionId(sessionId)) return { success: false, error: 'Invalid sessionId' };
// 严格校验 data 结构:traceSteps 必须为数组,tokenUsage 必须存在
if (!data || typeof data !== 'object') return { success: false, error: 'Invalid trace data' };
const traceData = data as { traceSteps?: unknown; tokenUsage?: unknown };
if (!Array.isArray(traceData.traceSteps)) {
return { success: false, error: 'Invalid trace data: traceSteps must be an array' };
}
if (traceData.tokenUsage === undefined) {
return { success: false, error: 'Invalid trace data: tokenUsage is required' };
}
try {
sessionService.saveTraceData(sessionId, {
traceSteps: traceData.traceSteps,
tokenUsage: traceData.tokenUsage,
});
return { success: true };
} catch (error) {
return { success: false, error: (error as Error).message };
}
});
ipcMain.handle('sessions:getTrace', async (_event, sessionId: unknown) => {
// M-34 修复: 校验 sessionId
if (!isValidSessionId(sessionId)) return null;
return sessionService.getTraceData(sessionId);
});
// ===== MCP 管理 =====
ipcMain.handle('mcp:listServers', async () => {
return mcpManager.getServerStates();
});
ipcMain.handle('mcp:addServer', async (_event, config: { name: string; transport: string; command?: string; args?: string[]; url?: string }) => {
// M-38 修复: 完整参数校验,防止字段缺失或类型不符导致异常行为
if (!config || typeof config !== 'object') {
return { success: false, error: 'Invalid config' };
}
if (typeof config.name !== 'string' || !config.name.trim()) {
return { success: false, error: 'Server name is required' };
}
// M-5 修复: transport 运行时校验(替代 as 'stdio' | 'sse' 断言)
if (config.transport !== 'stdio' && config.transport !== 'sse') {
return { success: false, error: `Invalid transport: ${config.transport}. Must be 'stdio' or 'sse'` };
}
// stdio 类型必须有 command
if (config.transport === 'stdio' && (typeof config.command !== 'string' || !config.command.trim())) {
return { success: false, error: 'command is required for stdio transport' };
}
// sse 类型必须有合法 url
if (config.transport === 'sse') {
if (typeof config.url !== 'string' || !config.url.trim()) {
return { success: false, error: 'url is required for sse transport' };
}
try { new URL(config.url); } catch {
return { success: false, error: 'Invalid url format' };
}
}
try {
await mcpManager.addServer({
name: config.name,
transport: config.transport, // 已校验,无需断言
command: config.command,
args: config.args,
url: config.url,
enabled: true,
});
log.info(`MCP server added: ${config.name}`);
return { success: true };
} catch (error) {
return { success: false, error: (error instanceof Error ? error.message : String(error)) };
}
});
ipcMain.handle('mcp:removeServer', async (_event, name: string) => {
// M-38 修复: name 校验
if (typeof name !== 'string' || !name.trim()) {
return { success: false, error: 'Invalid server name' };
}
try {
await mcpManager.removeServer(name);
return { success: true };
} catch (error) {
return { success: false, error: (error instanceof Error ? error.message : String(error)) };
}
});
ipcMain.handle('mcp:toggleServer', async (_event, name: string, enabled: boolean) => {
// M-38 修复: name 和 enabled 校验
if (typeof name !== 'string' || !name.trim()) {
return { success: false, error: 'Invalid server name' };
}
if (typeof enabled !== 'boolean') {
return { success: false, error: 'Invalid enabled flag' };
}
try {
await mcpManager.toggleServer(name, enabled);
return { success: true };
} catch (error) {
return { success: false, error: (error instanceof Error ? error.message : String(error)) };
}
});
// ===== 记忆 =====
ipcMain.handle('db:searchMemories', async (_event, query: unknown, options: unknown) => {
// M-37 修复: query 和 options 校验
if (typeof query !== 'string' || !query.trim()) {
return [];
}
// 构造合法的搜索选项(仅保留已知字段,强制类型安全)
const VALID_MEMORY_TYPES: readonly MemoryType[] = ['episodic', 'semantic', 'working'];
const searchOptions: { topK?: number; sessionId?: string; type?: MemoryType; minImportance?: number } = {};
if (options && typeof options === 'object') {
const opts = options as Record<string, unknown>;
// topK 限制范围 1-100(防止过大查询)
if (opts.topK !== undefined) {
const topK = Number(opts.topK);
if (Number.isFinite(topK) && topK >= 1 && topK <= 100) {
searchOptions.topK = topK;
} else {
searchOptions.topK = 10; // 默认值
}
}
if (typeof opts.sessionId === 'string') searchOptions.sessionId = opts.sessionId;
// type 必须是合法的 MemoryType 枚举值
if (typeof opts.type === 'string' && (VALID_MEMORY_TYPES as readonly string[]).includes(opts.type)) {
searchOptions.type = opts.type as MemoryType;
}
if (typeof opts.minImportance === 'number' && Number.isFinite(opts.minImportance)) {
searchOptions.minImportance = opts.minImportance;
}
}
return memoryManager.search(query, searchOptions);
});
// ===== 配置 =====
ipcMain.handle('config:get', async (_event, key) => {
return configService.get(key);
});
ipcMain.handle('config:set', async (_event, key: unknown, value: unknown) => {
// M-42 修复: 参数校验 + 敏感数据脱敏
if (typeof key !== 'string' || !key.trim()) {
return { success: false, error: 'Invalid config key' };
}
// value 必须是可序列化的基本类型(string|number|boolean|null
if (value !== null && typeof value !== 'string' && typeof value !== 'number' && typeof value !== 'boolean') {
return { success: false, error: 'Invalid config value: must be string, number, boolean, or null' };
}
// C-1 修复: Provider 切换时清空 API key,防止使用不兼容的 key 导致 401 错误
// @see project_memory.md — When switching LLM providers, the API key must be cleared
if (key === 'llm.provider') {
const oldProvider = configService.get<string>('llm.provider') ?? '';
const newProvider = (value as string) ?? '';
if (oldProvider && newProvider && oldProvider !== newProvider) {
configService.set('llm.apiKey', '');
log.info(`[CONFIG] Provider changed (${oldProvider}${newProvider}), API key cleared to prevent incompatible key usage`);
}
}
configService.set(key, value);
// M-42 修复: 敏感配置项脱敏后再写入审计日志,防止 API Key 明文泄露
// 敏感 key 包括 llm.apiKey、llm.fallbackApiKey、agnes.apiKey 等
// 审计补充修复: 短值(length <= 4)时 slice(-4) 会返回整个 value 导致脱敏失效,
// 改为:长值保留后 4 位核对,短值完全掩码为 '***'
const SENSITIVE_KEY_PATTERNS = ['apikey', 'api_key', 'apitoken', 'token', 'secret', 'password'];
const isSensitiveKey = SENSITIVE_KEY_PATTERNS.some(p => key.toLowerCase().includes(p));
const auditValue = isSensitiveKey && typeof value === 'string' && value.length > 0
? (value.length > 4 ? '***' + value.slice(-4) : '***')
: value;
// TOOL 层:记录配置变更
auditService.log({
sessionId: '',
eventType: 'config_change',
actor: 'user',
target: key,
details: { value: auditValue },
outcome: 'success',
});
// LLM 相关配置变更时热重载 Adapter
// v0.3.1: 加入 deepseek.contextWindow / agnes.contextWindow / mimo.contextWindow,使上下文窗口配置变化也触发热重载
if (['llm.provider', 'llm.model', 'llm.apiKey', 'llm.baseURL', 'ollama.numCtx',
'deepseek.contextWindow', 'agnes.contextWindow', 'mimo.contextWindow'].includes(key)) {
// P0-3 修复: reloadAdapter 失败时返回 success:false,让前端知道配置不完整
const reloadSuccess = reloadAdapter();
if (!reloadSuccess) {
log.warn(`[CONFIG] Adapter reload failed after ${key} change`);
return { success: false, error: 'LLM 配置不完整,请检查 Provider、API Key、Base URL 和 Model 是否都已填写' };
} else {
log.info(`[CONFIG] LLM config changed (${key}), adapter reloaded`);
}
}
// Agent 配置变更时热更新 Engine
if (key === 'agent.maxIterations') {
agentLoop.updateConfig({ maxIterations: value as number });
log.info(`[CONFIG] Agent maxIterations updated to ${value}`);
} else if (key === 'agent.totalTimeoutMs') {
agentLoop.updateConfig({ totalTimeoutMs: value as number });
log.info(`[CONFIG] Agent totalTimeoutMs updated to ${value}`);
} else if (key === 'agent.enableThinking') {
agentLoop.updateConfig({ thinkingEnabled: value as boolean });
// L-18 修复: 同步更新 orchestrator.defaultConfig,确保新建 SubAgent 使用最新配置
orchestrator.updateDefaultConfig({ thinkingEnabled: value as boolean });
log.info(`[CONFIG] Agent thinkingEnabled updated to ${value}`);
} else if (key === 'agent.thinkingEffort') {
agentLoop.updateConfig({ thinkingEffort: value as 'low' | 'medium' | 'high' | 'max' });
orchestrator.updateDefaultConfig({ thinkingEffort: value as 'low' | 'medium' | 'high' | 'max' });
log.info(`[CONFIG] Agent thinkingEffort updated to ${value}`);
} else if (key === 'ollama.numCtx') {
// numCtx 同时更新 Engine 的 contextLength
agentLoop.updateConfig({ contextLength: (value as number) || undefined });
orchestrator.updateDefaultConfig({ contextLength: (value as number) || undefined });
log.info(`[CONFIG] Agent contextLength updated to ${value}`);
} else if (key === 'deepseek.contextWindow' || key === 'agnes.contextWindow' || key === 'mimo.contextWindow') {
// v0.3.1: DeepSeek/Agnes/MiMo contextWindow 变更,同步到 Engine 和 Orchestrator
// reloadAdapter 已重建 adapter,此处确保 Engine contextWindow 同步(兜底)
const ctxWindow = (value as number) || undefined;
agentLoop.updateConfig({ contextWindow: ctxWindow });
orchestrator.updateDefaultConfig({ contextWindow: ctxWindow });
log.info(`[CONFIG] ${key} updated to ${value}`);
} else if (key === 'agent.confirmationTimeoutMs') {
// 工具确认超时变更,即时应用到 ConfirmationHook
confirmationHook.setConfirmationTimeout(value as number);
log.info(`[CONFIG] Confirmation timeout updated to ${value}ms`);
} else if (key === 'agent.toolExecutionTimeoutMs') {
// 工具执行兜底超时变更,即时应用到 Engine
agentLoop.updateConfig({ toolExecutionTimeoutMs: value as number });
log.info(`[CONFIG] Tool execution timeout updated to ${value}ms`);
}
// 日志级别变更时即时应用
if (key === 'logging.level') {
const level = value as 'error' | 'warn' | 'info' | 'debug' | 'verbose' | 'silly';
log.transports.file.level = level;
log.transports.console.level = level;
log.info(`[CONFIG] Log level updated to ${level}`);
}
// 工作空间路径变更时写入独立文件(下次启动生效)
if (key === 'workspace.path') {
try {
const { writeWorkspacePathToFile } = await import('../main');
if (value) writeWorkspacePathToFile(value as string);
log.info(`[CONFIG] Workspace path saved (restart required): ${value}`);
} catch (err) {
log.error('[CONFIG] Failed to save workspace path:', err);
// v0.3.10: 工作空间路径写入失败必须告知用户,否则下次启动仍使用旧路径
// 用户看到"配置已保存"却实际未生效,会误以为配置系统故障
return { success: false, error: `工作空间路径保存失败:${(err as Error).message}` };
}
}
return { success: true };
});
// ===== v0.3.9: 批量保存配置(解决串行保存中间态触发 reloadAdapter 失败问题)=====
// 设计原因:前端设置页一次保存 8 个字段,串行 config:set 会在中间态(如 provider
// 已改但 apiKey 还没保存)触发 reloadAdapter,导致返回"LLM 配置不完整"错误。
// 批量保存:先写入所有字段,最后统一触发一次 reloadAdapter 和 Engine/Orchestrator 同步。
ipcMain.handle('config:setBatch', async (_event, entries: unknown) => {
// 参数校验:必须是 {key, value}[] 非空数组
if (!Array.isArray(entries) || entries.length === 0) {
return { success: false, error: 'Invalid entries: must be non-empty array of {key, value}' };
}
// 逐条校验每个元素的结构和类型
for (const entry of entries) {
if (!entry || typeof entry !== 'object') {
return { success: false, error: 'Invalid entry: must be {key, value} object' };
}
const e = entry as { key?: unknown; value?: unknown };
if (typeof e.key !== 'string' || !e.key.trim()) {
return { success: false, error: 'Invalid config key in batch' };
}
const v = e.value;
if (v !== null && typeof v !== 'string' && typeof v !== 'number' && typeof v !== 'boolean') {
return { success: false, error: `Invalid config value for key "${e.key}": must be string, number, boolean, or null` };
}
}
// 敏感字段脱敏工具(与 config:set 保持一致)
const SENSITIVE_KEY_PATTERNS = ['apikey', 'api_key', 'apitoken', 'token', 'secret', 'password'];
const maskSensitive = (key: string, value: unknown): unknown => {
const isSensitive = SENSITIVE_KEY_PATTERNS.some(p => key.toLowerCase().includes(p));
if (isSensitive && typeof value === 'string' && value.length > 0) {
return value.length > 4 ? '***' + value.slice(-4) : '***';
}
return value;
};
// LLM 相关字段集合(用于判断是否需要 reloadAdapter
const LLM_KEYS = ['llm.provider', 'llm.model', 'llm.apiKey', 'llm.baseURL', 'ollama.numCtx',
'deepseek.contextWindow', 'agnes.contextWindow', 'mimo.contextWindow'];
// 第一步:逐条写入 configService + 审计日志
// 不在此处触发 reloadAdapter,避免中间态失败
let needsReloadAdapter = false;
const engineUpdates: { key: string; value: unknown }[] = [];
let workspacePathValue: string | null = null;
let logLevelValue: 'error' | 'warn' | 'info' | 'debug' | 'verbose' | 'silly' | null = null;
// v0.3.10 防御性修复: Provider 切换时清空 API key 的逻辑必须在循环前执行,
// 不能依赖 entries 中 llm.provider 出现在 llm.apiKey 之前。
// 旧实现:若前端误把 llm.apiKey 放在 llm.provider 之前,会先 set apiKey
// 随后处理 llm.provider 时清空 apiKey,导致用户填写的 apiKey 丢失。
// 新实现:先扫描 entries 找出 llm.provider 的值,与当前值比较,若变化则清空 apiKey,
// 然后循环按 entries 顺序 set(包括 llm.apiKey),最终值正确。
const providerEntry = (entries as Array<{ key: string; value: unknown }>)
.find((e) => e.key === 'llm.provider');
if (providerEntry) {
const oldProvider = configService.get<string>('llm.provider') ?? '';
const newProvider = (providerEntry.value as string) ?? '';
if (oldProvider && newProvider && oldProvider !== newProvider) {
configService.set('llm.apiKey', '');
log.info(`[CONFIG] Provider changed (${oldProvider}${newProvider}), API key cleared (batch mode)`);
}
}
for (const entry of entries) {
const { key, value } = entry as { key: string; value: unknown };
configService.set(key, value);
needsReloadAdapter = needsReloadAdapter || LLM_KEYS.includes(key);
// 收集 Engine/Orchestrator 相关更新(稍后统一应用)
if (key === 'agent.maxIterations' || key === 'agent.totalTimeoutMs' ||
key === 'agent.enableThinking' || key === 'agent.thinkingEffort' ||
key === 'ollama.numCtx' || key === 'deepseek.contextWindow' ||
key === 'agnes.contextWindow' || key === 'mimo.contextWindow' ||
key === 'agent.toolExecutionTimeoutMs' || key === 'agent.confirmationTimeoutMs') {
engineUpdates.push({ key, value });
}
if (key === 'workspace.path' && typeof value === 'string') {
workspacePathValue = value;
}
if (key === 'logging.level' && typeof value === 'string') {
logLevelValue = value as 'error' | 'warn' | 'info' | 'debug' | 'verbose' | 'silly';
}
// 审计日志(脱敏)
auditService.log({
sessionId: '',
eventType: 'config_change',
actor: 'user',
target: key,
details: { value: maskSensitive(key, value) },
outcome: 'success',
});
}
// 第二步:统一触发一次 reloadAdapter(仅在 LLM 配置变更时)
if (needsReloadAdapter) {
const reloadSuccess = reloadAdapter();
if (!reloadSuccess) {
log.warn('[CONFIG] Adapter reload failed after batch config save');
return { success: false, error: 'LLM 配置不完整,请检查 Provider、API Key、Base URL 和 Model 是否都已填写' };
} else {
log.info('[CONFIG] LLM config batch changed, adapter reloaded');
}
}
// 第三步:统一应用 Engine/Orchestrator 更新(取每个 key 的最后值)
// 注意:reloadAdapter 内部已重建 adapter 并同步 contextWindow,此处仅处理非 LLM 字段
// 的 Engine 配置(maxIterations/totalTimeoutMs/enableThinking 等)
for (const { key, value } of engineUpdates) {
if (key === 'agent.maxIterations') {
agentLoop.updateConfig({ maxIterations: value as number });
} else if (key === 'agent.totalTimeoutMs') {
agentLoop.updateConfig({ totalTimeoutMs: value as number });
} else if (key === 'agent.enableThinking') {
agentLoop.updateConfig({ thinkingEnabled: value as boolean });
orchestrator.updateDefaultConfig({ thinkingEnabled: value as boolean });
} else if (key === 'agent.thinkingEffort') {
agentLoop.updateConfig({ thinkingEffort: value as 'low' | 'medium' | 'high' | 'max' });
orchestrator.updateDefaultConfig({ thinkingEffort: value as 'low' | 'medium' | 'high' | 'max' });
} else if (key === 'ollama.numCtx') {
agentLoop.updateConfig({ contextLength: (value as number) || undefined });
orchestrator.updateDefaultConfig({ contextLength: (value as number) || undefined });
} else if (key === 'deepseek.contextWindow' || key === 'agnes.contextWindow' || key === 'mimo.contextWindow') {
const ctxWindow = (value as number) || undefined;
agentLoop.updateConfig({ contextWindow: ctxWindow });
orchestrator.updateDefaultConfig({ contextWindow: ctxWindow });
} else if (key === 'agent.confirmationTimeoutMs') {
confirmationHook.setConfirmationTimeout(value as number);
} else if (key === 'agent.toolExecutionTimeoutMs') {
agentLoop.updateConfig({ toolExecutionTimeoutMs: value as number });
}
}
if (engineUpdates.length > 0) {
log.info(`[CONFIG] Engine/Orchestrator updated with ${engineUpdates.length} config changes (batch mode)`);
}
// 第四步:日志级别即时应用
if (logLevelValue) {
log.transports.file.level = logLevelValue;
log.transports.console.level = logLevelValue;
log.info(`[CONFIG] Log level updated to ${logLevelValue}`);
}
// 第五步:工作空间路径写入独立文件(下次启动生效)
if (workspacePathValue) {
try {
const { writeWorkspacePathToFile } = await import('../main');
writeWorkspacePathToFile(workspacePathValue);
log.info(`[CONFIG] Workspace path saved (restart required): ${workspacePathValue}`);
} catch (err) {
log.error('[CONFIG] Failed to save workspace path:', err);
// v0.3.10: 工作空间路径写入失败必须告知用户,否则下次启动仍使用旧路径
return { success: false, error: `工作空间路径保存失败:${(err as Error).message}` };
}
}
return { success: true };
});
// ===== 应用工具 =====
ipcMain.handle('app:getVersion', async () => {
return app.getVersion();
});
ipcMain.handle('app:getAppDataPath', async () => {
return app.getPath('userData');
});
ipcMain.handle('app:openExternal', async (_event, url: unknown) => {
// M-12 修复: URL 协议白名单校验,防止打开 file:///smb:// 等危险协议
// @see main.ts setWindowOpenHandler — 两处安全策略保持一致
if (typeof url !== 'string' || !url) {
return { success: false, error: 'Invalid URL' };
}
try {
const parsed = new URL(url);
const ALLOWED_PROTOCOLS = ['http:', 'https:', 'mailto:'];
if (!ALLOWED_PROTOCOLS.includes(parsed.protocol)) {
log.warn(`[IPC] openExternal blocked: protocol "${parsed.protocol}" not in whitelist`);
return { success: false, error: `Protocol not allowed: ${parsed.protocol}` };
}
await shell.openExternal(url);
return { success: true };
} catch (error) {
log.warn('[IPC] openExternal failed:', (error as Error).message);
return { success: false, error: (error as Error).message };
}
});
ipcMain.handle('app:showItemInFolder', async (_event, path: unknown) => {
// M-36 修复: path 类型校验,防止非字符串参数
if (typeof path !== 'string' || !path) {
return { success: false, error: 'Invalid path' };
}
try {
shell.showItemInFolder(path);
return { success: true };
} catch (error) {
return { success: false, error: (error as Error).message };
}
});
ipcMain.handle('app:selectFolder', async (_event, defaultPath?: string) => {
const result = await dialog.showOpenDialog(mainWindow, {
properties: ['openDirectory', 'createDirectory'],
defaultPath: defaultPath ?? app.getPath('home'),
title: '选择工作空间目录',
});
if (result.canceled || result.filePaths.length === 0) return { canceled: true, path: '' };
return { canceled: false, path: result.filePaths[0] };
});
// 重启应用(工作空间切换后调用)
ipcMain.handle('app:restart', async () => {
log.info('[APP] Restart requested');
// 延迟 200ms 让 IPC 响应先返回
setTimeout(() => {
app.relaunch();
app.exit(0);
}, 200);
return { success: true };
});
// ===== 工作空间校验与继承 =====
// 校验工作空间路径:检测路径有效性 + 4 个必需文件状态
ipcMain.handle('workspace:check', async (_event, targetPath: string) => {
if (!targetPath || typeof targetPath !== 'string') {
return { valid: false, reason: '路径不能为空' };
}
const { existsSync, statSync } = await import('fs');
const { resolve } = await import('path');
const resolvedPath = resolve(targetPath);
// 校验 1: 路径是否存在
if (!existsSync(resolvedPath)) {
return {
valid: true,
path: resolvedPath,
exists: false,
missingFiles: ['SOUL.md', 'MEMORY.md'],
isNewWorkspace: true,
reason: '目录不存在,将在切换后自动创建',
};
}
// 校验 2: 是否为目录
try {
const stat = statSync(resolvedPath);
if (!stat.isDirectory()) {
return { valid: false, reason: '路径不是目录' };
}
} catch {
return { valid: false, reason: '无法访问路径' };
}
// 校验 3: 检测 2 个必需文件状态(SOUL.md + MEMORY.md
const requiredFiles = ['SOUL.md', 'MEMORY.md'];
const missingFiles: string[] = [];
for (const f of requiredFiles) {
if (!existsSync(join(resolvedPath, f))) missingFiles.push(f);
}
return {
valid: true,
path: resolvedPath,
exists: true,
missingFiles,
isNewWorkspace: missingFiles.length === requiredFiles.length,
};
});
// 从旧工作空间继承文件到新工作空间
ipcMain.handle('workspace:inheritFiles', async (_event, params: {
targetPath: string;
sourcePath: string;
files: string[];
}) => {
const { targetPath, sourcePath, files } = params;
if (!targetPath || !sourcePath || !Array.isArray(files)) {
return { success: false, error: '参数无效' };
}
const { existsSync, mkdirSync, copyFileSync } = await import('fs');
const { resolve } = await import('path');
const resolvedTarget = resolve(targetPath);
const resolvedSource = resolve(sourcePath);
// 确保目标目录存在
if (!existsSync(resolvedTarget)) {
mkdirSync(resolvedTarget, { recursive: true });
}
// 继承白名单:键为前端传入的标识,值为实际相对路径分段
// - SOUL.md: 工作空间根目录的 Agent 身份定义
// - .metona/agent.db: 工作空间内的 SQLite 数据库(会话/消息/记忆/Trace)
// 白名单映射防止路径遍历攻击(不直接使用用户传入的路径拼接到 fs 调用)
const INHERIT_WHITELIST: Record<string, string[]> = {
'SOUL.md': ['SOUL.md'],
'.metona/agent.db': ['.metona', 'agent.db'],
};
const inherited: string[] = [];
const failed: Array<{ file: string; error: string }> = [];
for (const fileName of files) {
const pathSegments = INHERIT_WHITELIST[fileName];
// 不在白名单中:拒绝(防止路径遍历)
if (!pathSegments) {
failed.push({ file: fileName, error: '不在继承白名单中' });
continue;
}
const srcFile = join(resolvedSource, ...pathSegments);
const dstFile = join(resolvedTarget, ...pathSegments);
try {
// 确保目标文件的父目录存在(如 .metona/)
const dstDir = join(resolvedTarget, ...pathSegments.slice(0, -1));
if (!existsSync(dstDir)) {
mkdirSync(dstDir, { recursive: true });
}
if (!existsSync(srcFile)) {
failed.push({ file: fileName, error: '源文件不存在' });
continue;
}
// === 数据库文件特殊处理 ===
// agent.db 启用了 WAL 模式(database.service.ts:53),直接 copyFileSync 存在两个问题:
// 1. WAL 中未 checkpoint 的事务会丢失(最近会话/消息可能没了)
// 2. 主进程可能仍持有 db 句柄,Windows 上文件锁定会导致复制失败或不一致
// 解决方案:用 better-sqlite3 的 backup API(自带 checkpoint + 一致性快照)
if (fileName === '.metona/agent.db') {
try {
const Database = (await import('better-sqlite3')).default;
// 以只读方式打开源库(如果主进程已持有句柄,better-sqlite3 会共享锁)
const srcDb = new Database(srcFile, { readonly: true, fileMustExist: true });
try {
// backup API 原子性导出完整快照,自动合并 WAL 内容
srcDb.backup(dstFile);
inherited.push(fileName);
log.info(`[WORKSPACE] Inherited ${fileName} (via SQLite backup): ${resolvedSource}${resolvedTarget}`);
} finally {
srcDb.close();
}
} catch (err) {
// backup 失败时回退到 copyFileSync(至少保证基本可用)
log.warn(`[WORKSPACE] SQLite backup failed, fallback to copyFileSync: ${(err as Error).message}`);
try {
copyFileSync(srcFile, dstFile);
inherited.push(fileName);
log.info(`[WORKSPACE] Inherited ${fileName} (fallback copyFileSync): ${resolvedSource}${resolvedTarget}`);
} catch (err2) {
failed.push({ file: fileName, error: `backup 和 fallback 均失败: ${(err2 as Error).message}` });
}
}
continue;
}
// === 普通文件直接复制 ===
copyFileSync(srcFile, dstFile);
inherited.push(fileName);
log.info(`[WORKSPACE] Inherited ${fileName}: ${resolvedSource}${resolvedTarget}`);
} catch (err) {
failed.push({ file: fileName, error: (err as Error).message });
}
}
return { success: true, inherited, failed };
});
// 获取当前工作空间详情:路径 + 2 个核心文件状态 + 自动目录状态
ipcMain.handle('workspace:getInfo', async () => {
const { existsSync, statSync, readFileSync, readdirSync } = await import('fs');
const workspacePath = workspaceService.getPath();
const files = workspaceService.reload(); // 同步外部可能的手动修改
const REQUIRED = ['SOUL.md', 'MEMORY.md'] as const;
const AUTO_DIRS = ['logs', '.metona'] as const;
const fileInfos = REQUIRED.map((name) => {
const filePath = join(workspacePath, name);
let exists = false;
let size = 0;
let mtime = 0;
let preview = '';
try {
if (existsSync(filePath)) {
const stat = statSync(filePath);
exists = true;
size = stat.size;
mtime = stat.mtimeMs;
// 截取前 500 字符作为预览
const content = readFileSync(filePath, 'utf-8');
preview = content.length > 500 ? content.slice(0, 500) + '\n...(已截断)' : content;
}
} catch {
// ignore
}
// 文件内容映射:SOUL.md → files.soul, MEMORY.md → files.memory
const contentKey = name.toLowerCase().replace('.md', '') as 'soul' | 'memory';
return {
name,
path: filePath,
exists,
size,
mtime,
preview: exists ? preview : (files[contentKey] ?? ''),
};
});
const dirInfos = AUTO_DIRS.map((name) => {
const dirPath = join(workspacePath, name);
let exists = false;
let fileCount = 0;
try {
if (existsSync(dirPath)) {
exists = true;
const stat = statSync(dirPath);
if (stat.isDirectory()) {
fileCount = readdirSync(dirPath).length;
}
}
} catch {
// ignore
}
return { name, path: dirPath, exists, fileCount };
});
return {
path: workspacePath,
files: fileInfos,
dirs: dirInfos,
};
});
// ===== 工具管理 =====
ipcMain.handle('tools:list', async () => {
return toolRegistry.listAllTools().map((t) => ({
name: t.name,
description: t.description,
category: t.category,
riskLevel: t.riskLevel,
requiresPermission: t.requiresPermission,
enabled: t.enabled,
}));
});
ipcMain.handle('tools:toggle', async (_event, toolName: string, enabled: boolean) => {
// M-35 修复: 校验 toolName 合法性,防止配置 key 污染
if (typeof toolName !== 'string' || !toolName || typeof enabled !== 'boolean') {
return { success: false, error: 'Invalid parameters' };
}
// 校验 toolName 在已注册工具列表中(防止写入任意配置 key)
const allTools = toolRegistry.listAllTools();
if (!allTools.some((t) => t.name === toolName)) {
log.warn(`[IPC] tools:toggle rejected: unknown tool "${toolName}"`);
return { success: false, error: `Unknown tool: ${toolName}` };
}
// 工具开关通过配置持久化
configService.set(`tools.${toolName}.enabled`, enabled);
// 同步到 ToolRegistry(立即生效)
toolRegistry.setToolEnabled(toolName, enabled);
// 同步到 AgentLoop 的工具列表
agentLoop.setTools(toolRegistry.listTools());
log.info(`Tool ${toolName} ${enabled ? 'enabled' : 'disabled'}`);
return { success: true };
});
// ===== 数据管理 =====
ipcMain.handle('data:export', async (_event, sessionId?: string) => {
try {
if (sessionId) {
// 导出单个会话
const messages = sessionService.getMessages(sessionId);
return { success: true, data: messages };
}
// 导出所有会话
const sessions = sessionService.list();
const allData: Record<string, unknown> = { sessions: [], config: configService.getAll() };
for (const session of sessions) {
(allData.sessions as Array<Record<string, unknown>>).push({
...session,
messages: sessionService.getMessages(session.id),
});
}
return { success: true, data: allData };
} catch (error) {
return { success: false, error: (error as Error).message };
}
});
ipcMain.handle('data:clearSessions', async () => {
// M-40 修复: 危险操作审计日志,便于追踪异常调用
log.warn('[DATA] DANGER: clearSessions invoked — all sessions and messages will be deleted');
const db = sessionService.getDB();
try {
db.exec('BEGIN');
const msgCount = db.prepare('SELECT COUNT(*) as c FROM messages').get() as { c: number };
const sessCount = db.prepare('SELECT COUNT(*) as c FROM sessions').get() as { c: number };
db.exec('DELETE FROM messages');
db.exec('DELETE FROM sessions');
db.exec('COMMIT');
log.info(`[DATA] All sessions cleared: ${sessCount.c} sessions, ${msgCount.c} messages deleted`);
return { success: true, deletedSessions: sessCount.c, deletedMessages: msgCount.c };
} catch (error) {
try { db.exec('ROLLBACK'); } catch { /* 忽略回滚错误 */ }
log.error('[DATA] clearSessions failed:', (error as Error).message);
return { success: false, error: (error as Error).message };
}
});
ipcMain.handle('data:clearMemories', async () => {
// M-40 修复: 危险操作审计日志,便于追踪异常调用
log.warn('[DATA] DANGER: clearMemories invoked — all memories will be deleted');
const db = sessionService.getDB();
try {
// M-41 修复: 三个 DELETE 操作用事务包裹,防止部分失败导致三类记忆数据不一致
const epiCount = db.prepare('SELECT COUNT(*) as c FROM episodic_memories').get() as { c: number };
const semCount = db.prepare('SELECT COUNT(*) as c FROM semantic_memories').get() as { c: number };
const workCount = db.prepare('SELECT COUNT(*) as c FROM working_memories').get() as { c: number };
db.exec('BEGIN');
db.exec('DELETE FROM episodic_memories');
db.exec('DELETE FROM semantic_memories');
db.exec('DELETE FROM working_memories');
db.exec('COMMIT');
log.info(`[DATA] All memories cleared: ${epiCount.c} episodic, ${semCount.c} semantic, ${workCount.c} working memories deleted`);
return { success: true, deletedEpisodic: epiCount.c, deletedSemantic: semCount.c, deletedWorking: workCount.c };
} catch (error) {
// M-41 修复: 失败时回滚事务,确保数据一致性
try { db.exec('ROLLBACK'); } catch { /* 忽略回滚错误 */ }
log.error('[DATA] clearMemories failed:', (error as Error).message);
return { success: false, error: (error as Error).message };
}
});
ipcMain.handle('data:clearAuditLogs', async () => {
// 审计日志是 INSERT-ONLY,需要先禁用触发器
const db = sessionService.getDB();
try {
db.exec('BEGIN');
db.exec('DROP TRIGGER IF EXISTS audit_no_delete');
db.exec('DELETE FROM audit_logs');
db.exec(`
CREATE TRIGGER audit_no_delete BEFORE DELETE ON audit_logs
BEGIN
SELECT RAISE(ABORT, 'Audit logs are INSERT-ONLY. Deletion is not allowed.');
END
`);
db.exec('COMMIT');
log.info('[DATA] Audit logs cleared');
return { success: true };
} catch (error) {
try { db.exec('ROLLBACK'); } catch { /* 忽略回滚错误 */ }
return { success: false, error: (error as Error).message };
}
});
// ===== SearXNG =====
ipcMain.handle('searxng:testConnection', async (_event, url: string, authKey: string, authType: string) => {
try {
if (!url || !/^https?:\/\//.test(url)) {
return { success: false, error: 'URL 需以 http:// 或 https:// 开头' };
}
const startTime = Date.now();
const headers: Record<string, string> = {};
if (authKey) {
if (authType === 'bearer') {
headers['Authorization'] = `Bearer ${authKey}`;
} else if (authType === 'basic') {
headers['Authorization'] = `Basic ${Buffer.from(authKey).toString('base64')}`;
}
}
const testUrl = `${url.replace(/\/$/, '')}/search?q=test&format=json&pageno=1`;
const response = await fetch(testUrl, {
headers,
signal: AbortSignal.timeout(10_000),
});
const latencyMs = Date.now() - startTime;
if (response.ok) {
return { success: true, statusCode: response.status, latencyMs };
}
return { success: false, statusCode: response.status, latencyMs, error: `HTTP ${response.status} ${response.statusText}` };
} catch (error) {
return { success: false, error: (error as Error).message };
}
});
// ===== v0.2.0: 工具确认响应(ConfirmationDialog → 主进程)=====
// 使用 ipcMain.on(渲染进程通过 ipcRenderer.send 发送)
ipcMain.on('tool:confirmationResponse', (_event, data: unknown) => {
// M-43 修复: 校验 data 结构,防止 undefined/null 导致 TypeError
if (!data || typeof data !== 'object') {
log.warn('[IPC] tool:confirmationResponse rejected: invalid data');
return;
}
const req = data as { toolCallId?: unknown; approved?: unknown; remember?: unknown; autoExecute?: unknown };
if (typeof req.toolCallId !== 'string' || !req.toolCallId) {
log.warn('[IPC] tool:confirmationResponse rejected: invalid toolCallId');
return;
}
if (typeof req.approved !== 'boolean') {
log.warn('[IPC] tool:confirmationResponse rejected: invalid approved');
return;
}
const remember = typeof req.remember === 'boolean' ? req.remember : false;
const autoExecute = typeof req.autoExecute === 'boolean' ? req.autoExecute : false;
confirmationHook.resolveConfirmation(req.toolCallId, req.approved, remember, autoExecute);
log.info(`[CONFIRM] Tool ${req.toolCallId} ${req.approved ? 'approved' : 'denied'}${remember ? ' (remembered)' : ''}${autoExecute ? ' (autoExecute)' : ''}`);
});
// ===== v0.3.2: 批量工具确认响应(并行工具调用一次性审批)=====
// 设计原因:多个工具并行触发时,逐条 IPC 响应会产生 N 次往返,
// 且前端 state 覆盖会导致部分请求丢失。批量响应一次解决。
ipcMain.on('tool:confirmationResponseBatch', (_event, data: unknown) => {
if (!data || typeof data !== 'object') {
log.warn('[IPC] tool:confirmationResponseBatch rejected: invalid data');
return;
}
const req = data as {
toolCallIds?: unknown;
approved?: unknown;
remember?: unknown;
autoExecute?: unknown;
};
// 严格校验 toolCallIds 数组
if (!Array.isArray(req.toolCallIds) || req.toolCallIds.length === 0) {
log.warn('[IPC] tool:confirmationResponseBatch rejected: toolCallIds must be non-empty array');
return;
}
// 每个元素必须是字符串
for (const id of req.toolCallIds) {
if (typeof id !== 'string' || !id) {
log.warn('[IPC] tool:confirmationResponseBatch rejected: invalid toolCallId in array');
return;
}
}
if (typeof req.approved !== 'boolean') {
log.warn('[IPC] tool:confirmationResponseBatch rejected: invalid approved');
return;
}
const remember = typeof req.remember === 'boolean' ? req.remember : false;
const autoExecute = typeof req.autoExecute === 'boolean' ? req.autoExecute : false;
const resolved = confirmationHook.resolveConfirmationsBatch(
req.toolCallIds as string[],
req.approved,
remember,
autoExecute,
);
log.info(`[CONFIRM] Batch ${req.approved ? 'approved' : 'denied'}: ${resolved.length}/${req.toolCallIds.length} resolved${remember ? ' (remembered)' : ''}${autoExecute ? ' (autoExecute)' : ''}`);
});
// ===== v0.3.2: 拉取当前所有 pending 确认(前端弹框打开时调用)=====
// 用途:解决并行工具触发的多个 IPC 事件在前端 state 中互相覆盖导致丢失的问题。
// 前端在弹框打开时主动拉取,确保拿到完整的 pending 列表。
ipcMain.handle('tool:getPendingConfirmations', async () => {
return { success: true, data: confirmationHook.getPendingConfirmations() };
});
// ===== v0.2.0: 持久化自动执行设置 =====
ipcMain.handle('tool:setAutoExecute', async (_event, toolName: unknown, enabled: unknown) => {
// M-44 修复: 校验 toolName 合法性和 enabled 类型,防止配置 key 污染
if (typeof toolName !== 'string' || !toolName) {
return { success: false, error: 'Invalid toolName' };
}
if (typeof enabled !== 'boolean') {
return { success: false, error: 'Invalid enabled (must be boolean)' };
}
// 校验 toolName 在已注册工具列表中(与 tools:toggle 保持一致)
const allTools = toolRegistry.listAllTools();
if (!allTools.some((t) => t.name === toolName)) {
log.warn(`[IPC] tool:setAutoExecute rejected: unknown tool "${toolName}"`);
return { success: false, error: `Unknown tool: ${toolName}` };
}
try {
confirmationHook.setAutoExecute(toolName, enabled);
log.info(`[CONFIRM] Tool ${toolName} autoExecute set to ${enabled}`);
return { success: true };
} catch (error) {
return { success: false, error: (error as Error).message };
}
});
ipcMain.handle('tool:getAutoExecuteList', async () => {
return { success: true, data: confirmationHook.getAutoExecuteList() };
});
// ===== v0.2.0: 审计日志链式哈希验证 =====
ipcMain.handle('audit:verifyChain', async () => {
try {
const result = auditService.verifyChain();
log.info(`[AUDIT] Chain verification: ${result.valid ? 'valid' : 'TAMPERED'} (${result.verifiedRecords}/${result.totalRecords})`);
return { success: true, ...result };
} catch (error) {
log.error('[AUDIT] Chain verification failed:', error);
return { success: false, error: (error as Error).message };
}
});
ipcMain.handle('audit:query', async (_event, filters?: unknown) => {
// M-45 修复: 校验 filters 参数类型和范围
const VALID_AUDIT_EVENT_TYPES: readonly AuditEventType[] = [
'tool_call', 'permission_check', 'error', 'llm_request',
'llm_response', 'session_start', 'session_end', 'config_change',
];
let safeFilters: { sessionId?: string; eventType?: AuditEventType; limit?: number } = {};
if (filters && typeof filters === 'object') {
const f = filters as Record<string, unknown>;
if (typeof f.sessionId === 'string' && f.sessionId) safeFilters.sessionId = f.sessionId;
if (typeof f.eventType === 'string' && f.eventType) {
// 校验 eventType 是否在合法枚举内
if (!(VALID_AUDIT_EVENT_TYPES as readonly string[]).includes(f.eventType)) {
return { success: false, error: `Invalid eventType (must be one of: ${VALID_AUDIT_EVENT_TYPES.join(', ')})` };
}
safeFilters.eventType = f.eventType as AuditEventType;
}
if (f.limit !== undefined) {
const limit = Number(f.limit);
// 审计补充修复: limit 越界时返回 error(与 memory:listAll 策略一致,原为静默降级为 100)
// 限制 1-1000 范围,防止过大查询拖慢性能
if (!Number.isFinite(limit) || limit < 1 || limit > 1000) {
return { success: false, error: 'Invalid limit (must be 1-1000)' };
}
safeFilters.limit = Math.floor(limit);
}
}
try {
return { success: true, data: auditService.query(safeFilters) };
} catch (error) {
return { success: false, error: (error as Error).message };
}
});
// ===== v0.2.0: 任务管理 IPCTaskList UI=====
const VALID_TASK_PRIORITIES: readonly string[] = ['low', 'medium', 'high', 'critical'];
const VALID_TASK_STATUSES: readonly string[] = ['pending', 'in_progress', 'completed', 'blocked', 'cancelled'];
ipcMain.handle('tasks:list', async (_event, sessionId?: unknown) => {
// M-46 修复: 校验 sessionId 类型(可选参数)
if (sessionId !== undefined && (typeof sessionId !== 'string' || !sessionId)) {
return { success: false, error: 'Invalid sessionId' };
}
const db = sessionService.getDB();
let sql = 'SELECT * FROM tasks';
const params: unknown[] = [];
if (sessionId) {
sql += ' WHERE session_id = ?';
params.push(sessionId);
}
sql += ' ORDER BY order_idx ASC, created_at ASC';
return { success: true, data: db.prepare(sql).all(...params) };
});
ipcMain.handle('tasks:create', async (_event, data: unknown) => {
// M-46 修复: 校验 data 结构和字段类型/枚举
if (!data || typeof data !== 'object') {
return { success: false, error: 'Invalid task data' };
}
const req = data as Record<string, unknown>;
if (typeof req.sessionId !== 'string' || !req.sessionId.trim()) {
return { success: false, error: 'Invalid sessionId' };
}
if (typeof req.title !== 'string' || !req.title.trim()) {
return { success: false, error: 'Invalid title' };
}
if (req.priority !== undefined && !VALID_TASK_PRIORITIES.includes(req.priority as string)) {
return { success: false, error: `Invalid priority (must be one of: ${VALID_TASK_PRIORITIES.join(', ')})` };
}
if (req.parentId !== undefined && req.parentId !== null && typeof req.parentId !== 'string') {
return { success: false, error: 'Invalid parentId' };
}
const db = sessionService.getDB();
// P4 统一(v0.3.13: ID 生成方式与 task_manager 工具一致(nanoid
const id = `task_${nanoid(12)}`;
try {
// P4 统一: 计算 order_idx = MAX(同 session+parent 的 order_idx) + 1(与 task_manager 工具一致)
// 注意:parent_id IS NULL 时需要单独处理,COALESCE(NULL, NULL) 仍为 NULL
const parentId = (req.parentId as string | null) ?? null;
let orderIdx = 0;
if (parentId === null) {
const orderRow = db.prepare(
'SELECT COALESCE(MAX(order_idx), -1) AS maxOrder FROM tasks WHERE session_id = ? AND parent_id IS NULL'
).get(req.sessionId) as { maxOrder: number } | undefined;
orderIdx = (orderRow?.maxOrder ?? -1) + 1;
} else {
const orderRow = db.prepare(
'SELECT COALESCE(MAX(order_idx), -1) AS maxOrder FROM tasks WHERE session_id = ? AND parent_id = ?'
).get(req.sessionId, parentId) as { maxOrder: number } | undefined;
orderIdx = (orderRow?.maxOrder ?? -1) + 1;
}
db.prepare(`
INSERT INTO tasks (id, session_id, title, description, status, priority, parent_id, order_idx, created_at, updated_at)
VALUES (?, ?, ?, ?, 'pending', ?, ?, ?, ?, ?)
`).run(
id,
req.sessionId,
req.title,
typeof req.description === 'string' ? req.description : '',
(req.priority as string) ?? 'medium',
parentId,
orderIdx,
Date.now(),
Date.now(),
);
return { success: true, id };
} catch (error) {
return { success: false, error: (error as Error).message };
}
});
ipcMain.handle('tasks:update', async (_event, id: unknown, updates: unknown, sessionId: unknown) => {
// M-47 修复: 校验 id 和 updates 结构/枚举
if (typeof id !== 'string' || !id) {
return { success: false, error: 'Invalid task id' };
}
// P1 修复(v0.3.13: 补 session_id 越权保护(与 task_manager 工具一致)
if (typeof sessionId !== 'string' || !sessionId) {
return { success: false, error: 'Invalid sessionId' };
}
if (!updates || typeof updates !== 'object') {
return { success: false, error: 'Invalid updates' };
}
const u = updates as Record<string, unknown>;
if (u.status !== undefined && !VALID_TASK_STATUSES.includes(u.status as string)) {
return { success: false, error: `Invalid status (must be one of: ${VALID_TASK_STATUSES.join(', ')})` };
}
if (u.priority !== undefined && !VALID_TASK_PRIORITIES.includes(u.priority as string)) {
return { success: false, error: `Invalid priority (must be one of: ${VALID_TASK_PRIORITIES.join(', ')})` };
}
if (u.assignedTo !== undefined && u.assignedTo !== null && typeof u.assignedTo !== 'string') {
return { success: false, error: 'Invalid assignedTo' };
}
// 审计补充修复: 补全 title/description 类型校验(与 tasks:create 的严格校验一致)
if (u.title !== undefined && typeof u.title !== 'string') {
return { success: false, error: 'Invalid title (must be string)' };
}
if (u.description !== undefined && typeof u.description !== 'string') {
return { success: false, error: 'Invalid description (must be string)' };
}
const db = sessionService.getDB();
try {
const fields: string[] = [];
const values: unknown[] = [];
if (u.title !== undefined) { fields.push('title = ?'); values.push(u.title); }
if (u.description !== undefined) { fields.push('description = ?'); values.push(u.description); }
if (u.status !== undefined) { fields.push('status = ?'); values.push(u.status); }
if (u.priority !== undefined) { fields.push('priority = ?'); values.push(u.priority); }
if (u.assignedTo !== undefined) { fields.push('assigned_to = ?'); values.push(u.assignedTo); }
if (fields.length === 0) return { success: true };
fields.push('updated_at = ?'); values.push(Date.now());
if (u.status === 'completed') { fields.push('completed_at = ?'); values.push(Date.now()); }
values.push(id, sessionId);
// P1 修复(v0.3.13: WHERE 补 session_id 校验,防越权修改其他会话任务
db.prepare(`UPDATE tasks SET ${fields.join(', ')} WHERE id = ? AND session_id = ?`).run(...values);
return { success: true };
} catch (error) {
return { success: false, error: (error as Error).message };
}
});
ipcMain.handle('tasks:delete', async (_event, id: unknown, sessionId: unknown) => {
// M-48 修复: 校验 id 类型
if (typeof id !== 'string' || !id) {
return { success: false, error: 'Invalid task id' };
}
// P1 修复(v0.3.13: 补 session_id 越权保护
if (typeof sessionId !== 'string' || !sessionId) {
return { success: false, error: 'Invalid sessionId' };
}
const db = sessionService.getDB();
try {
// P1 修复: WHERE 补 session_id 校验
db.prepare('DELETE FROM tasks WHERE id = ? AND session_id = ?').run(id, sessionId);
return { success: true };
} catch (error) {
return { success: false, error: (error as Error).message };
}
});
// ===== v0.2.0: 记忆系统增强查询(Memory Viewer UI=====
const VALID_MEMORY_TYPES_LIST: readonly string[] = ['episodic', 'semantic', 'working'];
ipcMain.handle('memory:listAll', async (_event, options?: unknown) => {
// M-49 修复: 校验 options.type 枚举和 limit 范围
let limit = 100;
let type: string | undefined;
if (options && typeof options === 'object') {
const opts = options as Record<string, unknown>;
if (opts.type !== undefined) {
if (typeof opts.type !== 'string' || !VALID_MEMORY_TYPES_LIST.includes(opts.type)) {
return { success: false, error: `Invalid type (must be one of: ${VALID_MEMORY_TYPES_LIST.join(', ')})` };
}
type = opts.type;
}
if (opts.limit !== undefined) {
const num = Number(opts.limit);
// 限制 1-1000 范围,SQLite 中 LIMIT -1 表示无限制,需阻止
if (!Number.isFinite(num) || num < 1 || num > 1000) {
return { success: false, error: 'Invalid limit (must be 1-1000)' };
}
limit = Math.floor(num);
}
}
const db = sessionService.getDB();
const results: Record<string, unknown[]> = {};
try {
if (!type || type === 'episodic') {
const rows = db.prepare('SELECT * FROM episodic_memories ORDER BY created_at DESC LIMIT ?').all(limit) as Array<Record<string, unknown>>;
results.episodic = rows.map((r) => ({ ...r, type: 'episodic', content: r.content ?? '' }));
}
if (!type || type === 'semantic') {
const rows = db.prepare('SELECT * FROM semantic_memories ORDER BY updated_at DESC LIMIT ?').all(limit) as Array<Record<string, unknown>>;
results.semantic = rows.map((r) => ({ ...r, type: 'semantic', content: r.value ?? r.key ?? '', importance: r.confidence ?? 0, created_at: r.created_at ?? r.updated_at }));
}
if (!type || type === 'working') {
const rows = db.prepare('SELECT * FROM working_memories ORDER BY updated_at DESC LIMIT ?').all(limit) as Array<Record<string, unknown>>;
results.working = rows.map((r) => ({ ...r, type: 'working', content: r.value ?? r.key ?? '', importance: 0.5, created_at: r.updated_at ?? Date.now() }));
}
return { success: true, data: results };
} catch (error) {
return { success: false, error: (error as Error).message };
}
});
ipcMain.handle('memory:delete', async (_event, type: unknown, id: unknown) => {
// M-50 修复: 校验 type 枚举(防止三元表达式默认映射到 working_memories)和 id 类型
if (typeof type !== 'string' || !VALID_MEMORY_TYPES_LIST.includes(type)) {
return { success: false, error: `Invalid type (must be one of: ${VALID_MEMORY_TYPES_LIST.join(', ')})` };
}
if (typeof id !== 'string' || !id) {
return { success: false, error: 'Invalid memory id' };
}
const db = sessionService.getDB();
try {
const table = type === 'episodic' ? 'episodic_memories' : type === 'semantic' ? 'semantic_memories' : 'working_memories';
db.prepare(`DELETE FROM ${table} WHERE id = ?`).run(id);
return { success: true };
} catch (error) {
return { success: false, error: (error as Error).message };
}
});
log.info('[SYS] All IPC handlers registered');
}