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:
@@ -127,7 +127,7 @@ function makeCtx(overrides: Record<string, unknown> = {}) {
|
||||
logSessionEnd: vi.fn(),
|
||||
log: vi.fn(),
|
||||
},
|
||||
memoryManager: { search: vi.fn(() => []) },
|
||||
memoryManager: { search: vi.fn(() => []), clearWorkingMemory: vi.fn() },
|
||||
promptInjectionDefender: {
|
||||
detect: vi.fn(() => ({
|
||||
isInjection: false,
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Replay Buffer 单元测试(v0.8.1 P0-3 模块化收口)
|
||||
*
|
||||
* 锁定有界缓冲契约:条数/字节双上限溢出丢最旧、truncated 标记、runId 记录、
|
||||
* TERMINATED 保留 + 新内容兜底重置、终态清除(会话删除联动)。
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import {
|
||||
appendReplay,
|
||||
resetReplayBuffer,
|
||||
markReplayTerminated,
|
||||
clearReplayBuffer,
|
||||
getReplayBufferData,
|
||||
} from '../replay-buffer';
|
||||
|
||||
describe('replay-buffer — 有界回放缓冲(v0.8.1 P0-3)', () => {
|
||||
beforeEach(() => {
|
||||
clearReplayBuffer('s1');
|
||||
});
|
||||
|
||||
it('append + get:按序返回事件与 runId', () => {
|
||||
appendReplay('s1', { channel: 'stateChange', payload: { runId: 'run_a' }, ts: 1 });
|
||||
appendReplay('s1', { channel: 'streamEvent', payload: { type: 'text_delta' }, ts: 2 });
|
||||
const data = getReplayBufferData('s1');
|
||||
expect(data.events).toHaveLength(2);
|
||||
expect(data.events[0].channel).toBe('stateChange');
|
||||
expect(data.runId).toBe('run_a');
|
||||
expect(data.truncated).toBe(false);
|
||||
});
|
||||
|
||||
it('条数上限:超过 2000 条丢弃最旧并置 truncated', () => {
|
||||
for (let i = 0; i < 2005; i++) {
|
||||
appendReplay('s1', { channel: 'streamEvent', payload: { i }, ts: i });
|
||||
}
|
||||
const data = getReplayBufferData('s1');
|
||||
expect(data.events.length).toBeLessThanOrEqual(2000);
|
||||
expect(data.truncated).toBe(true);
|
||||
// 最旧的已被丢弃
|
||||
expect((data.events[0].payload as { i: number }).i).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('字节上限:单条 1MB 的超大 payload 被丢弃并置 truncated', () => {
|
||||
const big = 'x'.repeat(1024 * 1024);
|
||||
for (let i = 0; i < 6; i++) {
|
||||
appendReplay('s1', { channel: 'streamEvent', payload: { big }, ts: i });
|
||||
}
|
||||
const data = getReplayBufferData('s1');
|
||||
expect(data.truncated).toBe(true);
|
||||
// 缓冲字节被控制在 4MB 上限附近(至少留最后一条)
|
||||
expect(data.events.length).toBeGreaterThanOrEqual(1);
|
||||
expect(data.events.length).toBeLessThan(6);
|
||||
});
|
||||
|
||||
it('TERMINATED 保留缓冲;新内容到达兜底重置', () => {
|
||||
appendReplay('s1', { channel: 'stateChange', payload: { state: 'TERMINATED' }, ts: 1 });
|
||||
markReplayTerminated('s1');
|
||||
// 完成后仍可回放最终内容
|
||||
expect(getReplayBufferData('s1').events).toHaveLength(1);
|
||||
// 下一次 run 的新内容 → 缓冲重置
|
||||
appendReplay('s1', { channel: 'streamEvent', payload: { type: 'text_delta' }, ts: 2 });
|
||||
const data = getReplayBufferData('s1');
|
||||
expect(data.events).toHaveLength(1);
|
||||
expect(data.truncated).toBe(false);
|
||||
});
|
||||
|
||||
it('INIT 重置:清除上一 run 的缓冲', () => {
|
||||
appendReplay('s1', { channel: 'streamEvent', payload: { a: 1 }, ts: 1 });
|
||||
resetReplayBuffer('s1');
|
||||
expect(getReplayBufferData('s1').events).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('clearReplayBuffer:终态清除(会话删除联动)', () => {
|
||||
appendReplay('s1', { channel: 'streamEvent', payload: { a: 1 }, ts: 1 });
|
||||
clearReplayBuffer('s1');
|
||||
const data = getReplayBufferData('s1');
|
||||
expect(data.events).toHaveLength(0);
|
||||
expect(data.runId).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -115,7 +115,8 @@ describe('sessions 域 — 参数校验矩阵', () => {
|
||||
|
||||
expect(await getHandler('sessions:getMessages')(null, 42)).toEqual([]);
|
||||
await getHandler('sessions:getMessages')(null, 's1');
|
||||
expect(svc.getMessages).toHaveBeenCalledWith('s1');
|
||||
// v0.8.1 P2-3: 未传选项时以 undefined 透传(全量语义向后兼容)
|
||||
expect(svc.getMessages).toHaveBeenCalledWith('s1', undefined);
|
||||
});
|
||||
|
||||
it('saveTrace 严格校验:traceSteps 必须为数组且 tokenUsage 必填', async () => {
|
||||
@@ -149,6 +150,8 @@ describe('sessions:clearMessages — v0.7.2 A1 语义修正', () => {
|
||||
function makeCtx(): IPCContext {
|
||||
return {
|
||||
sessionService: { clearMessages: vi.fn() },
|
||||
// v0.8.1 P0-2: clearMessages 联动清理工作记忆
|
||||
memoryManager: { clearWorkingMemory: vi.fn() },
|
||||
} as unknown as IPCContext;
|
||||
}
|
||||
|
||||
@@ -442,6 +445,8 @@ describe('sessions 域 — 补充校验矩阵', () => {
|
||||
disposeEngine: vi.fn(),
|
||||
},
|
||||
confirmationHook: { forgetSession: vi.fn() },
|
||||
// v0.8.1 P0-2: delete/purge/clearMessages 联动清理工作记忆
|
||||
memoryManager: { clearWorkingMemory: vi.fn() },
|
||||
};
|
||||
return { ctx: raw as unknown as IPCContext, raw };
|
||||
}
|
||||
|
||||
+58
-103
@@ -29,6 +29,8 @@ import { buildUserContextPrefix, withUserContextPrefix } from '../harness/prompt
|
||||
// v0.7.3 P1-5: 记忆固化触发决策(纯函数)
|
||||
import { shouldConsolidate } from '../harness/memory/consolidation-policy';
|
||||
import log from 'electron-log';
|
||||
// v0.8.1 P0-4: 主进程 toast 文案双语(ui.locale 驱动)
|
||||
import { mt } from '../utils/main-locale';
|
||||
|
||||
/** 构建 "时区名 (UTC±N)" 标签(注入用户上下文前置块;失败回退 UTC) */
|
||||
function buildTimezoneLabel(): string {
|
||||
@@ -67,34 +69,14 @@ interface IterationTrace {
|
||||
finishReason?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.8.0 P1-1a: 单会话的流式回放缓冲 —— 渲染层切走会话期间,事件管道仍照常
|
||||
* 投递(按会话隔离被渲染层准入拒绝),此缓冲按序保存 streamEvent + stateChange
|
||||
* 双通道事件;用户切回会话时经 agent:getReplayState 拉取并灌入渲染层事件总线,
|
||||
* 实现"后台会话运行内容不丢失"。
|
||||
*
|
||||
* 有界设计:单会话上限 MAX_REPLAY_EVENTS 条 / MAX_REPLAY_BYTES 字节,溢出丢弃
|
||||
* 最旧并置 truncated 标记;TERMINATED 后保留(供"完成后切回"看到最终内容),
|
||||
* 下一次 run 启动(INIT stateChange)时清空重建。
|
||||
*/
|
||||
interface ReplayEntry {
|
||||
channel: 'streamEvent' | 'stateChange';
|
||||
payload: unknown;
|
||||
ts: number;
|
||||
}
|
||||
|
||||
interface ReplayBuffer {
|
||||
events: ReplayEntry[];
|
||||
bytes: number;
|
||||
truncated: boolean;
|
||||
/** 最近一次观察到的 runId(供渲染层回放后对齐 run 守卫) */
|
||||
runId?: string;
|
||||
terminated: boolean;
|
||||
}
|
||||
|
||||
const MAX_REPLAY_EVENTS = 2000;
|
||||
const MAX_REPLAY_BYTES = 4 * 1024 * 1024;
|
||||
const MAX_REPLAY_SESSIONS = 50;
|
||||
// v0.8.1 P0-3: 回放缓冲抽为独立模块(replay-buffer.ts)—— 会话删除/彻底删除
|
||||
// 的终态路径可显式清除缓冲,杜绝已删会话最多 4MB/会话的内存滞留
|
||||
import {
|
||||
appendReplay,
|
||||
resetReplayBuffer,
|
||||
markReplayTerminated,
|
||||
getReplayBufferData,
|
||||
} from './replay-buffer';
|
||||
|
||||
export function registerAgentHandlers(ctx: IPCContext): void {
|
||||
const {
|
||||
@@ -118,54 +100,9 @@ export function registerAgentHandlers(ctx: IPCContext): void {
|
||||
// ===== 常驻事件管道:text_delta 按会话节流(F8) =====
|
||||
const throttleStates = new Map<string, ThrottleState>();
|
||||
const iterationTraces = new Map<string, IterationTrace>();
|
||||
// v0.8.0 P1-1a: 每会话流式回放缓冲(后台会话内容恢复)
|
||||
const replayBuffers = new Map<string, ReplayBuffer>();
|
||||
|
||||
/** 追加一条事件到会话回放缓冲(有界:条数/字节双上限,溢出丢最旧) */
|
||||
const appendReplay = (sessionId: string, entry: ReplayEntry): void => {
|
||||
let buf = replayBuffers.get(sessionId);
|
||||
if (!buf) {
|
||||
// 会话数上限保护:超出后淘汰最早的缓冲(Map 迭代序 = 插入序)
|
||||
if (replayBuffers.size >= MAX_REPLAY_SESSIONS) {
|
||||
const oldest = replayBuffers.keys().next().value as string | undefined;
|
||||
if (oldest !== undefined) replayBuffers.delete(oldest);
|
||||
}
|
||||
buf = { events: [], bytes: 0, truncated: false, terminated: false };
|
||||
replayBuffers.set(sessionId, buf);
|
||||
}
|
||||
// run 启动(INIT)后旧 run 缓冲作废 —— 由 stateChange 监听器在 INIT 时清空,
|
||||
// 此处仅在 terminated 缓冲上遇到新内容时兜底重置(正常路径 INIT 先行)
|
||||
if (buf.terminated) {
|
||||
replayBuffers.delete(sessionId);
|
||||
buf = { events: [], bytes: 0, truncated: false, terminated: false };
|
||||
replayBuffers.set(sessionId, buf);
|
||||
}
|
||||
let size = 0;
|
||||
try {
|
||||
size = JSON.stringify(entry.payload).length;
|
||||
} catch {
|
||||
size = 256; // 序列化失败按保守值计入
|
||||
}
|
||||
buf.events.push(entry);
|
||||
buf.bytes += size;
|
||||
while (
|
||||
buf.events.length > MAX_REPLAY_EVENTS ||
|
||||
(buf.bytes > MAX_REPLAY_BYTES && buf.events.length > 1)
|
||||
) {
|
||||
const dropped = buf.events.shift();
|
||||
buf.truncated = true;
|
||||
if (dropped) {
|
||||
try {
|
||||
buf.bytes -= JSON.stringify(dropped.payload).length;
|
||||
} catch {
|
||||
buf.bytes -= 256;
|
||||
}
|
||||
}
|
||||
}
|
||||
// 记录 runId(首个携带者)
|
||||
const payload = entry.payload as { runId?: string } | null;
|
||||
if (!buf.runId && payload?.runId) buf.runId = payload.runId;
|
||||
};
|
||||
// v0.8.1 review: MEMORY.md 体积告警去重(每会话一次)
|
||||
const memoryOversizeWarned = new Set<string>();
|
||||
|
||||
// v0.7.3 P1-5: 会话级记忆固化时间戳(consolidation-policy 频率门控的状态持有方)
|
||||
// v0.7.4 P3-5: LRU 化 —— 旧实现每会话一条永不清除,长期运行后无界增长。
|
||||
@@ -340,12 +277,11 @@ export function registerAgentHandlers(ctx: IPCContext): void {
|
||||
if (sessionId) {
|
||||
const stateRaw = data.state ?? data.current ?? '';
|
||||
if (stateRaw === 'INIT') {
|
||||
replayBuffers.delete(sessionId);
|
||||
resetReplayBuffer(sessionId);
|
||||
} else {
|
||||
appendReplay(sessionId, { channel: 'stateChange', payload: data, ts: Date.now() });
|
||||
if (stateRaw === 'TERMINATED') {
|
||||
const buf = replayBuffers.get(sessionId);
|
||||
if (buf) buf.terminated = true;
|
||||
markReplayTerminated(sessionId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -434,7 +370,11 @@ export function registerAgentHandlers(ctx: IPCContext): void {
|
||||
// toast 通知用户压缩已发生
|
||||
broadcast('toast:show', {
|
||||
type: 'info',
|
||||
message: `上下文压缩: ${data.originalTokens ?? '?'} → ${data.compressedTokens ?? '?'} tokens(节省 ${savedTokens})`,
|
||||
message: mt('agent.toast.compressed', {
|
||||
original: data.originalTokens ?? '?',
|
||||
compressed: data.compressedTokens ?? '?',
|
||||
saved: savedTokens,
|
||||
}),
|
||||
});
|
||||
// 通过 streamEvent 转发,前端 useAgentStream 监听 'compressed' 类型后更新 store
|
||||
broadcast('agent:streamEvent', {
|
||||
@@ -454,7 +394,7 @@ export function registerAgentHandlers(ctx: IPCContext): void {
|
||||
log.warn(`[AGENT] Dead loop detected at iteration ${data.iteration ?? '?'}`);
|
||||
broadcast('toast:show', {
|
||||
type: 'warning',
|
||||
message: `检测到死循环(第 ${data.iteration ?? '?'} 轮):连续3轮重复相同工具调用,已自动终止`,
|
||||
message: mt('agent.toast.deadLoop', { iteration: data.iteration ?? '?' }),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -470,7 +410,7 @@ export function registerAgentHandlers(ctx: IPCContext): void {
|
||||
});
|
||||
broadcast('toast:show', {
|
||||
type: 'warning',
|
||||
message: `Provider 故障转移: ${data.from ?? '?'} → ${data.to ?? '?'}(主 Provider 请求失败)`,
|
||||
message: mt('agent.toast.providerSwitched', { from: data.from ?? '?', to: data.to ?? '?' }),
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -498,7 +438,7 @@ export function registerAgentHandlers(ctx: IPCContext): void {
|
||||
|
||||
if (!sessionId || typeof sessionId !== 'string') {
|
||||
log.warn('[AGENT] sendMessage rejected: invalid sessionId');
|
||||
sendErrorEvent('无效的会话 ID', sessionId ?? '');
|
||||
sendErrorEvent(mt('agent.error.invalidSessionId'), sessionId ?? '');
|
||||
return { success: false, error: 'Invalid sessionId' };
|
||||
}
|
||||
if (
|
||||
@@ -507,7 +447,7 @@ export function registerAgentHandlers(ctx: IPCContext): void {
|
||||
typeof userMessage.content !== 'string'
|
||||
) {
|
||||
log.warn('[AGENT] sendMessage rejected: invalid userMessage');
|
||||
sendErrorEvent('无效的消息格式', sessionId);
|
||||
sendErrorEvent(mt('agent.error.invalidMessage'), sessionId);
|
||||
return { success: false, error: 'Invalid message format' };
|
||||
}
|
||||
log.info('[AGENT] sendMessage:', sessionId, (userMessage.content ?? '').slice(0, 80));
|
||||
@@ -517,7 +457,7 @@ export function registerAgentHandlers(ctx: IPCContext): void {
|
||||
// 排队 30s 后强制 abort 旧 run,用户看到"上一次操作未完成")。此处直接拒绝并
|
||||
// 广播明确错误事件,前端 isStreaming 正常收尾。
|
||||
if (agentEngineManager.isRunning(sessionId)) {
|
||||
const busyMsg = '该会话正在执行任务,请等待完成或先中断后再发送';
|
||||
const busyMsg = mt('agent.error.sessionBusy');
|
||||
log.warn(`[AGENT] sendMessage rejected: session ${sessionId} is already running`);
|
||||
sendErrorEvent(busyMsg, sessionId);
|
||||
return { success: false, error: busyMsg };
|
||||
@@ -529,7 +469,7 @@ export function registerAgentHandlers(ctx: IPCContext): void {
|
||||
// 此处提前校验并走统一的 sendErrorEvent + stopRecording 收尾路径。
|
||||
const sessionExists = sessionService.getSession(sessionId) != null;
|
||||
if (!sessionExists) {
|
||||
const missingMsg = '会话不存在或已被删除,请刷新后重试';
|
||||
const missingMsg = mt('agent.error.sessionMissing');
|
||||
log.warn(`[AGENT] sendMessage rejected: session ${sessionId} not found`);
|
||||
sendErrorEvent(missingMsg, sessionId);
|
||||
await sessionRecorder.stopRecording(sessionId, {
|
||||
@@ -543,8 +483,7 @@ export function registerAgentHandlers(ctx: IPCContext): void {
|
||||
|
||||
// 发送消息前确保 Adapter 使用最新配置(失败则中止,防止用旧 Provider 的 adapter 发送)
|
||||
if (!ctx.reloadAdapter()) {
|
||||
const errorMsg =
|
||||
'Adapter 加载失败,请检查 LLM 配置(Provider、API Key、Base URL、Model 是否完整)';
|
||||
const errorMsg = mt('agent.error.adapterLoadFailed');
|
||||
log.error('[AGENT]', errorMsg);
|
||||
sendErrorEvent(errorMsg, sessionId);
|
||||
await sessionRecorder.stopRecording(sessionId, {
|
||||
@@ -593,8 +532,7 @@ export function registerAgentHandlers(ctx: IPCContext): void {
|
||||
if (contextBuilder.isUsingFallbackRole()) {
|
||||
broadcast('toast:show', {
|
||||
type: 'info',
|
||||
message:
|
||||
'未找到 SOUL.md 或内容为空,已使用默认 Metona 身份。可在工作空间根目录创建 SOUL.md 自定义 Agent 人格',
|
||||
message: mt('agent.soul.fallbackToast'),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -603,7 +541,8 @@ export function registerAgentHandlers(ctx: IPCContext): void {
|
||||
// 每条消息都改变 system 字节 → 跨 run 缓存全 miss。现随首条 user 消息注入
|
||||
// (LLM 语义等价),system prompt 保持跨 run 字节级稳定。
|
||||
try {
|
||||
const memories = memoryManager.search(userMessage.content, {
|
||||
// v0.8.1: search 升级 async(向量混合检索)
|
||||
const memories = await memoryManager.search(userMessage.content, {
|
||||
topK: 5,
|
||||
minImportance: 0.3,
|
||||
});
|
||||
@@ -850,7 +789,7 @@ export function registerAgentHandlers(ctx: IPCContext): void {
|
||||
);
|
||||
broadcast('toast:show', {
|
||||
type: 'info',
|
||||
message: `AI 已将 ${result.appended} 条重要记忆写入 MEMORY.md`,
|
||||
message: mt('agent.toast.consolidated', { count: result.appended }),
|
||||
});
|
||||
}
|
||||
})
|
||||
@@ -861,6 +800,18 @@ export function registerAgentHandlers(ctx: IPCContext): void {
|
||||
log.debug(`[AGENT] Memory consolidation skipped (${decision.reason})`);
|
||||
}
|
||||
|
||||
// v0.8.1 review 修复: MEMORY.md 体积告警移出固化分支 —— 每次成功 run 后
|
||||
// 检查(此前仅固化触发时检查,跳过固化的大文件永不告警);每会话只告警
|
||||
// 一次,避免连续消息刷屏。
|
||||
const memorySize = (workspaceService.getFiles().memory ?? '').length;
|
||||
if (memorySize > 51_200 && !memoryOversizeWarned.has(sessionId)) {
|
||||
memoryOversizeWarned.add(sessionId);
|
||||
broadcast('toast:show', {
|
||||
type: 'warning',
|
||||
message: mt('agent.toast.memoryOversize'),
|
||||
});
|
||||
}
|
||||
|
||||
// v0.7.3 P4-1: 首个完成的 run 之后生成精炼会话标题(每会话幂等,失败静默)
|
||||
if (output.terminationReason === 'completed') {
|
||||
titleGenerator
|
||||
@@ -955,7 +906,7 @@ export function registerAgentHandlers(ctx: IPCContext): void {
|
||||
}
|
||||
const balance = await adapter.getBalance();
|
||||
if (!balance) {
|
||||
return { success: false, error: '余额查询失败(API Key 无效或网络错误)' };
|
||||
return { success: false, error: mt('llm.balance.queryFailed') };
|
||||
}
|
||||
return { success: true, data: balance };
|
||||
} catch (error) {
|
||||
@@ -975,18 +926,18 @@ export function registerAgentHandlers(ctx: IPCContext): void {
|
||||
const model = configService.get<string>('llm.model') ?? '';
|
||||
const apiKey = configService.get<string>('llm.apiKey') || '';
|
||||
if (!provider || !model) {
|
||||
return { success: false, error: 'LLM 未配置(Provider/Model 为空),无法获取模型列表' };
|
||||
return { success: false, error: mt('llm.listModels.notConfigured') };
|
||||
}
|
||||
if (!apiKey && provider !== 'ollama') {
|
||||
return { success: false, error: 'API Key 未配置,无法获取模型列表' };
|
||||
return { success: false, error: mt('llm.listModels.noApiKey') };
|
||||
}
|
||||
// 列表基于当前已保存配置 —— 先幂等重载 adapter(配置签名未变时为 no-op)
|
||||
if (!ctx.reloadAdapter()) {
|
||||
return { success: false, error: 'LLM 配置校验失败,请先在设置中修正配置' };
|
||||
return { success: false, error: mt('llm.listModels.configInvalid') };
|
||||
}
|
||||
const adapter = agentEngineManager.getAdapter();
|
||||
if (!adapter.listModels) {
|
||||
return { success: false, error: '当前 Provider 不支持模型列表查询' };
|
||||
return { success: false, error: mt('llm.listModels.unsupported') };
|
||||
}
|
||||
const models = await adapter.listModels();
|
||||
return { success: true, data: models };
|
||||
@@ -1014,10 +965,10 @@ export function registerAgentHandlers(ctx: IPCContext): void {
|
||||
}
|
||||
const adapter = agentEngineManager.getAdapter();
|
||||
if (!(adapter instanceof OllamaAdapter)) {
|
||||
return { success: false, error: '仅 Ollama Provider 支持模型下载' };
|
||||
return { success: false, error: mt('llm.pull.ollamaOnly') };
|
||||
}
|
||||
if (ollamaPullController) {
|
||||
return { success: false, error: '已有模型下载任务进行中,请先取消' };
|
||||
return { success: false, error: mt('llm.pull.inProgress') };
|
||||
}
|
||||
const controller = new AbortController();
|
||||
ollamaPullController = controller;
|
||||
@@ -1045,7 +996,7 @@ export function registerAgentHandlers(ctx: IPCContext): void {
|
||||
|
||||
ipcMain.handle('llm:ollamaPullCancel', async () => {
|
||||
if (!ollamaPullController) {
|
||||
return { success: false, error: '没有进行中的下载任务' };
|
||||
return { success: false, error: mt('llm.pull.none') };
|
||||
}
|
||||
ollamaPullController.abort();
|
||||
return { success: true };
|
||||
@@ -1071,6 +1022,8 @@ export function registerAgentHandlers(ctx: IPCContext): void {
|
||||
// 旧实现只覆盖 taskCompleted/taskError 路径,被 abort 的 SubAgent 残留 Map 条目
|
||||
for (const taskId of abortedTaskIds) {
|
||||
confirmationHook.forgetSession(taskId);
|
||||
// v0.8.1 P0-2: 被中止 SubAgent 的工作记忆一并清理
|
||||
memoryManager.clearWorkingMemory(taskId);
|
||||
subTraces.delete(taskId);
|
||||
subMeta.delete(taskId);
|
||||
// 中止的 SubAgent 录制文件也收尾(TRACE 完整性:标记为中断终止)
|
||||
@@ -1104,14 +1057,14 @@ export function registerAgentHandlers(ctx: IPCContext): void {
|
||||
if (typeof sessionId !== 'string' || !sessionId) {
|
||||
return { success: false, error: 'Invalid sessionId' };
|
||||
}
|
||||
const buf = replayBuffers.get(sessionId);
|
||||
const data = getReplayBufferData(sessionId);
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
isRunning: agentEngineManager.isRunning(sessionId),
|
||||
runId: buf?.runId ?? null,
|
||||
truncated: buf?.truncated ?? false,
|
||||
events: buf?.events ?? [],
|
||||
runId: data.runId,
|
||||
truncated: data.truncated,
|
||||
events: data.events,
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -1151,6 +1104,8 @@ export function registerAgentHandlers(ctx: IPCContext): void {
|
||||
subMeta.delete(taskId);
|
||||
// v0.7.3 P2-3: SubAgent 终态 —— 决策记忆随任务终结清理(防长期运行泄漏)
|
||||
confirmationHook.forgetSession(taskId);
|
||||
// v0.8.1 P0-2: SubAgent 终态联动清理工作记忆(taskId 作为 sessionId 写入)
|
||||
memoryManager.clearWorkingMemory(taskId);
|
||||
};
|
||||
|
||||
orchestrator.on(
|
||||
|
||||
@@ -122,6 +122,32 @@ export function registerAppHandlers(ctx: IPCContext): void {
|
||||
return { canceled: false, path: result.filePaths[0] };
|
||||
});
|
||||
|
||||
// ===== v0.8.1 P2-4: 开机自启(app.setLoginItemSettings,跨平台尽力语义)=====
|
||||
ipcMain.handle('app:setLoginItem', async (_event, enabled: unknown) => {
|
||||
if (typeof enabled !== 'boolean') {
|
||||
return { success: false, error: 'Invalid enabled flag' };
|
||||
}
|
||||
try {
|
||||
app.setLoginItemSettings({ openAtLogin: enabled });
|
||||
// 设置后回读真实状态(Linux 等 setLoginItemSettings 不可用平台保持 false)
|
||||
const actual = app.getLoginItemSettings().openAtLogin;
|
||||
if (enabled && !actual) {
|
||||
log.warn('[APP] setLoginItemSettings not effective on this platform (Linux?)');
|
||||
}
|
||||
return { success: true, data: { openAtLogin: actual } };
|
||||
} catch (error) {
|
||||
return { success: false, error: (error as Error).message };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('app:getLoginItem', async () => {
|
||||
try {
|
||||
return { success: true, data: { openAtLogin: app.getLoginItemSettings().openAtLogin } };
|
||||
} catch (error) {
|
||||
return { success: false, error: (error as Error).message };
|
||||
}
|
||||
});
|
||||
|
||||
// 重启应用(工作空间切换后调用)
|
||||
ipcMain.handle('app:restart', async () => {
|
||||
log.info('[APP] Restart requested');
|
||||
|
||||
@@ -13,6 +13,7 @@ import type { WorkspaceService } from '../services/workspace.service';
|
||||
import type { ContextBuilder } from '../harness/prompts/context-builder';
|
||||
import type { AgentEngineManager } from '../services/agent-engine-manager.service';
|
||||
import type { ToolRegistry } from '../harness/tools/registry';
|
||||
import type { PolicyEngine } from '../harness/sandbox/permissions';
|
||||
import type { AuditService } from '../services/audit.service';
|
||||
import type { SessionRecorder } from '../services/session-recorder.service';
|
||||
import type { MemoryManager } from '../harness/memory/manager';
|
||||
@@ -21,6 +22,7 @@ import type { PromptInjectionDefender } from '../harness/security/prompt-injecti
|
||||
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 { MemoryMaintainer } from '../harness/memory/maintainer';
|
||||
import type { TaskOrchestrator } from '../harness/orchestration/orchestrator';
|
||||
import type { SessionSummaryService } from '../services/session-summary.service';
|
||||
import type { TitleGenerator } from '../services/title-generator.service';
|
||||
@@ -49,6 +51,8 @@ export interface IPCContext {
|
||||
contextBuilder: ContextBuilder;
|
||||
agentEngineManager: AgentEngineManager;
|
||||
toolRegistry: ToolRegistry;
|
||||
/** v0.8.1 P2-1: 策略引擎(tools.{name}.policy 用户自定义策略热加载) */
|
||||
policyEngine: PolicyEngine;
|
||||
auditService: AuditService;
|
||||
sessionRecorder: SessionRecorder;
|
||||
memoryManager: MemoryManager;
|
||||
@@ -58,6 +62,8 @@ export interface IPCContext {
|
||||
outputValidator: OutputValidator;
|
||||
confirmationHook: ConfirmationHook;
|
||||
memoryConsolidator: MemoryConsolidator;
|
||||
/** v0.8.1 P1-2: MEMORY.md 维护闭环(memory:analyzeMaintenance / memory:applyMaintenance) */
|
||||
memoryMaintainer: MemoryMaintainer;
|
||||
orchestrator: TaskOrchestrator;
|
||||
sessionSummaryService: SessionSummaryService;
|
||||
/** v0.7.3 P4-1: 会话标题生成器 */
|
||||
|
||||
@@ -107,6 +107,9 @@ export function registerDataHandlers(ctx: IPCContext): void {
|
||||
const sessCount = db.prepare('SELECT COUNT(*) as c FROM sessions').get() as { c: number };
|
||||
db.exec('DELETE FROM messages');
|
||||
db.exec('DELETE FROM sessions');
|
||||
// v0.8.1 P0-2: working_memories 无 sessions 外键(历史 schema),此处显式清理
|
||||
// 防止全量清空后工作记忆成为无主孤数据
|
||||
db.exec('DELETE FROM working_memories');
|
||||
db.exec('COMMIT');
|
||||
log.info(
|
||||
`[DATA] All sessions cleared: ${sessCount.c} sessions, ${msgCount.c} messages deleted`,
|
||||
|
||||
@@ -152,4 +152,60 @@ export function registerMCPHandlers(ctx: IPCContext): void {
|
||||
}
|
||||
return { success: true, data: mcpManager.getServerContents(name) };
|
||||
});
|
||||
|
||||
// ===== v0.8.1 P1-4: Prompt 渲染(prompts/get,ChatInput 斜杠菜单消费) =====
|
||||
ipcMain.handle(
|
||||
'mcp:getPrompt',
|
||||
async (_event, serverName: unknown, promptName: unknown, args: unknown) => {
|
||||
if (typeof serverName !== 'string' || !serverName.trim()) {
|
||||
return { success: false, error: 'Invalid server name' };
|
||||
}
|
||||
if (typeof promptName !== 'string' || !promptName.trim()) {
|
||||
return { success: false, error: 'Invalid prompt name' };
|
||||
}
|
||||
// args 可选,必须是 string→string 扁平对象(MCP prompts/get arguments 契约)
|
||||
let promptArgs: Record<string, string> | undefined;
|
||||
if (args !== undefined && args !== null) {
|
||||
if (!args || typeof args !== 'object' || Array.isArray(args)) {
|
||||
return { success: false, error: 'Invalid arguments' };
|
||||
}
|
||||
promptArgs = {};
|
||||
for (const [k, v] of Object.entries(args as Record<string, unknown>)) {
|
||||
if (typeof v !== 'string') {
|
||||
return { success: false, error: `Argument "${k.slice(0, 40)}" must be a string` };
|
||||
}
|
||||
promptArgs[k] = v;
|
||||
}
|
||||
}
|
||||
try {
|
||||
const result = await mcpManager.getPrompt(serverName, promptName, promptArgs);
|
||||
return { success: true, data: result };
|
||||
} catch (error) {
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ===== v0.8.1 P1-4: Resource 读取(resources/read,@mcp 提及消费;512KB 上限) =====
|
||||
const MAX_RESOURCE_BYTES = 512 * 1024;
|
||||
ipcMain.handle('mcp:readResource', async (_event, serverName: unknown, uri: unknown) => {
|
||||
if (typeof serverName !== 'string' || !serverName.trim()) {
|
||||
return { success: false, error: 'Invalid server name' };
|
||||
}
|
||||
if (typeof uri !== 'string' || !uri.trim()) {
|
||||
return { success: false, error: 'Invalid resource uri' };
|
||||
}
|
||||
try {
|
||||
const result = await mcpManager.readResource(serverName, uri);
|
||||
if (result && result.text.length > MAX_RESOURCE_BYTES) {
|
||||
return {
|
||||
success: true,
|
||||
data: { text: result.text.slice(0, MAX_RESOURCE_BYTES), truncated: true },
|
||||
};
|
||||
}
|
||||
return { success: true, data: { text: result?.text ?? '', truncated: false } };
|
||||
} catch (error) {
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
+110
-12
@@ -5,11 +5,72 @@
|
||||
import { ipcMain } from 'electron';
|
||||
import type { IPCContext } from './context';
|
||||
import type { MemoryType } from '../harness/memory/manager';
|
||||
import type { MemoryMaintenanceAction } from '../harness/memory/maintainer';
|
||||
import log from 'electron-log';
|
||||
|
||||
const VALID_MEMORY_TYPES: readonly MemoryType[] = ['episodic', 'semantic', 'working'];
|
||||
|
||||
export function registerMemoryHandlers(ctx: IPCContext): void {
|
||||
const { memoryManager, sessionService } = ctx;
|
||||
const { memoryManager, sessionService, memoryMaintainer, auditService } = ctx;
|
||||
|
||||
// ===== v0.8.1 P1-2: MEMORY.md 维护闭环(分析/应用两阶段) =====
|
||||
|
||||
// 阶段一:LLM 分析当前 MEMORY.md → 结构化维护建议(不改任何文件/DB)
|
||||
ipcMain.handle('memory:analyzeMaintenance', async () => {
|
||||
try {
|
||||
const proposal = await memoryMaintainer.analyze();
|
||||
return { success: true, data: proposal };
|
||||
} catch (error) {
|
||||
log.warn('[IPC] memory:analyzeMaintenance failed:', (error as Error).message);
|
||||
return { success: false, error: (error as Error).message };
|
||||
}
|
||||
});
|
||||
|
||||
// 阶段二:应用用户确认(勾选)后的动作 —— 精确匹配校验 + 重写 MEMORY.md +
|
||||
// 同步 semantic_memories + 审计留痕
|
||||
ipcMain.handle('memory:applyMaintenance', async (_event, actions: unknown) => {
|
||||
if (!Array.isArray(actions)) {
|
||||
return { success: false, error: 'Invalid actions: must be an array' };
|
||||
}
|
||||
// 结构校验:只透传合法字段,其余拒绝
|
||||
const valid: MemoryMaintenanceAction[] = [];
|
||||
for (const item of actions.slice(0, 30)) {
|
||||
if (!item || typeof item !== 'object') continue;
|
||||
const a = item as Record<string, unknown>;
|
||||
if (a.action !== 'delete' && a.action !== 'update') continue;
|
||||
if (typeof a.section !== 'string' || typeof a.entry !== 'string') continue;
|
||||
if (a.action === 'update' && typeof a.newEntry !== 'string') continue;
|
||||
valid.push({
|
||||
action: a.action,
|
||||
section: a.section,
|
||||
entry: a.entry,
|
||||
newEntry: typeof a.newEntry === 'string' ? a.newEntry : undefined,
|
||||
reason: typeof a.reason === 'string' ? a.reason : undefined,
|
||||
});
|
||||
}
|
||||
try {
|
||||
const result = memoryMaintainer.apply(valid);
|
||||
auditService.log({
|
||||
sessionId: '',
|
||||
eventType: 'tool_call',
|
||||
actor: 'user',
|
||||
target: 'memory_maintenance',
|
||||
details: { requested: valid.length, applied: result.applied, skipped: result.skipped },
|
||||
outcome: 'success',
|
||||
});
|
||||
return { success: true, data: result };
|
||||
} catch (error) {
|
||||
auditService.log({
|
||||
sessionId: '',
|
||||
eventType: 'error',
|
||||
actor: 'user',
|
||||
target: 'memory_maintenance',
|
||||
details: { error: (error as Error).message },
|
||||
outcome: 'error',
|
||||
});
|
||||
return { success: false, error: (error as Error).message };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('db:searchMemories', async (_event, query: unknown, options: unknown) => {
|
||||
// M-37 修复: query 和 options 校验
|
||||
@@ -17,7 +78,12 @@ export function registerMemoryHandlers(ctx: IPCContext): void {
|
||||
return [];
|
||||
}
|
||||
// 构造合法的搜索选项(仅保留已知字段,强制类型安全)
|
||||
const searchOptions: { topK?: number; sessionId?: string; type?: MemoryType; minImportance?: number } = {};
|
||||
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(防止过大查询)
|
||||
@@ -26,12 +92,15 @@ export function registerMemoryHandlers(ctx: IPCContext): void {
|
||||
if (Number.isFinite(topK) && topK >= 1 && topK <= 100) {
|
||||
searchOptions.topK = topK;
|
||||
} else {
|
||||
searchOptions.topK = 10; // 默认值
|
||||
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)) {
|
||||
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)) {
|
||||
@@ -52,7 +121,10 @@ export function registerMemoryHandlers(ctx: IPCContext): void {
|
||||
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(', ')})` };
|
||||
return {
|
||||
success: false,
|
||||
error: `Invalid type (must be one of: ${VALID_MEMORY_TYPES_LIST.join(', ')})`,
|
||||
};
|
||||
}
|
||||
type = opts.type;
|
||||
}
|
||||
@@ -69,16 +141,34 @@ export function registerMemoryHandlers(ctx: IPCContext): void {
|
||||
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>>;
|
||||
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 }));
|
||||
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() }));
|
||||
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) {
|
||||
@@ -89,14 +179,22 @@ export function registerMemoryHandlers(ctx: IPCContext): void {
|
||||
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(', ')})` };
|
||||
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';
|
||||
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) {
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Replay Buffer — 每会话流式回放缓冲(v0.8.0 P1-1a 引入;v0.8.1 P0-3 模块化收口)
|
||||
*
|
||||
* 渲染层切走会话期间,事件管道仍照常投递(按会话隔离被渲染层准入拒绝),
|
||||
* 此缓冲按序保存 streamEvent + stateChange 双通道事件;用户切回会话时经
|
||||
* agent:getReplayState 拉取并灌入渲染层事件总线,实现"后台会话运行内容不丢失"。
|
||||
*
|
||||
* v0.8.1 P0-3 根治:缓冲原为 ipc/agent.ts 内部 Map —— 会话删除/彻底删除时无
|
||||
* 联动清理,仅靠 50 会话 LRU 兜底,已删会话的缓冲(最多 4MB/会话)滞留内存。
|
||||
* 现抽为独立模块,sessions:delete / sessions:purge 终态路径显式清除。
|
||||
*
|
||||
* 有界设计:单会话上限 MAX_REPLAY_EVENTS 条 / MAX_REPLAY_BYTES 字节,溢出丢弃
|
||||
* 最旧并置 truncated 标记;TERMINATED 后保留(供"完成后切回"看到最终内容),
|
||||
* 下一次 run 启动(INIT stateChange)时清空重建。
|
||||
*/
|
||||
|
||||
const MAX_REPLAY_EVENTS = 2000;
|
||||
const MAX_REPLAY_BYTES = 4 * 1024 * 1024;
|
||||
const MAX_REPLAY_SESSIONS = 50;
|
||||
|
||||
export interface ReplayEntry {
|
||||
channel: 'streamEvent' | 'stateChange';
|
||||
payload: unknown;
|
||||
ts: number;
|
||||
}
|
||||
|
||||
export interface ReplayBuffer {
|
||||
events: ReplayEntry[];
|
||||
bytes: number;
|
||||
truncated: boolean;
|
||||
/** 最近一次观察到的 runId(供渲染层回放后对齐 run 守卫) */
|
||||
runId?: string;
|
||||
terminated: boolean;
|
||||
}
|
||||
|
||||
const replayBuffers = new Map<string, ReplayBuffer>();
|
||||
|
||||
/** 追加一条事件到会话回放缓冲(有界:条数/字节双上限,溢出丢最旧) */
|
||||
export function appendReplay(sessionId: string, entry: ReplayEntry): void {
|
||||
let buf = replayBuffers.get(sessionId);
|
||||
if (!buf) {
|
||||
// 会话数上限保护:超出后淘汰最早的缓冲(Map 迭代序 = 插入序)
|
||||
if (replayBuffers.size >= MAX_REPLAY_SESSIONS) {
|
||||
const oldest = replayBuffers.keys().next().value as string | undefined;
|
||||
if (oldest !== undefined) replayBuffers.delete(oldest);
|
||||
}
|
||||
buf = { events: [], bytes: 0, truncated: false, terminated: false };
|
||||
replayBuffers.set(sessionId, buf);
|
||||
}
|
||||
// run 启动(INIT)后旧 run 缓冲作废 —— 由 stateChange 监听器在 INIT 时清空,
|
||||
// 此处仅在 terminated 缓冲上遇到新内容时兜底重置(正常路径 INIT 先行)
|
||||
if (buf.terminated) {
|
||||
replayBuffers.delete(sessionId);
|
||||
buf = { events: [], bytes: 0, truncated: false, terminated: false };
|
||||
replayBuffers.set(sessionId, buf);
|
||||
}
|
||||
let size = 0;
|
||||
try {
|
||||
size = JSON.stringify(entry.payload).length;
|
||||
} catch {
|
||||
size = 256; // 序列化失败按保守值计入
|
||||
}
|
||||
buf.events.push(entry);
|
||||
buf.bytes += size;
|
||||
while (
|
||||
buf.events.length > MAX_REPLAY_EVENTS ||
|
||||
(buf.bytes > MAX_REPLAY_BYTES && buf.events.length > 1)
|
||||
) {
|
||||
const dropped = buf.events.shift();
|
||||
buf.truncated = true;
|
||||
if (dropped) {
|
||||
try {
|
||||
buf.bytes -= JSON.stringify(dropped.payload).length;
|
||||
} catch {
|
||||
buf.bytes -= 256;
|
||||
}
|
||||
}
|
||||
}
|
||||
// 记录 runId(首个携带者)
|
||||
const payload = entry.payload as { runId?: string } | null;
|
||||
if (!buf.runId && payload?.runId) buf.runId = payload.runId;
|
||||
}
|
||||
|
||||
/** run 启动(INIT)时清空上一 run 的缓冲 */
|
||||
export function resetReplayBuffer(sessionId: string): void {
|
||||
replayBuffers.delete(sessionId);
|
||||
}
|
||||
|
||||
/** TERMINATED 时置终态标记(保留缓冲供"完成后切回"回放最终内容) */
|
||||
export function markReplayTerminated(sessionId: string): void {
|
||||
const buf = replayBuffers.get(sessionId);
|
||||
if (buf) buf.terminated = true;
|
||||
}
|
||||
|
||||
/** v0.8.1 P0-3: 会话终态(删除/彻底删除)时清除缓冲,杜绝内存滞留 */
|
||||
export function clearReplayBuffer(sessionId: string): void {
|
||||
replayBuffers.delete(sessionId);
|
||||
}
|
||||
|
||||
/** 拉取会话回放状态(agent:getReplayState 消费) */
|
||||
export function getReplayBufferData(sessionId: string): {
|
||||
runId: string | null;
|
||||
truncated: boolean;
|
||||
events: ReplayEntry[];
|
||||
} {
|
||||
const buf = replayBuffers.get(sessionId);
|
||||
return {
|
||||
runId: buf?.runId ?? null,
|
||||
truncated: buf?.truncated ?? false,
|
||||
events: buf?.events ?? [],
|
||||
};
|
||||
}
|
||||
@@ -9,6 +9,8 @@ import { ipcMain } from 'electron';
|
||||
import { join, resolve as resolvePath, sep } from 'path';
|
||||
import { existsSync, readdirSync, readFileSync, statSync } from 'fs';
|
||||
import type { IPCContext } from './context';
|
||||
// v0.8.1 P0-3: 会话终态清除流式回放缓冲
|
||||
import { clearReplayBuffer } from './replay-buffer';
|
||||
|
||||
/** M-34 修复: 统一 sessionId 校验辅助函数 */
|
||||
const isValidSessionId = (id: unknown): id is string =>
|
||||
@@ -54,6 +56,11 @@ export function registerSessionHandlers(ctx: IPCContext): void {
|
||||
// (防 rememberedDecisions 随会话数累积泄漏)
|
||||
if (result.success) {
|
||||
confirmationHook.forgetSession(sessionId);
|
||||
// v0.8.1 P0-2: 会话终态联动清理工作记忆(此前 clearWorkingMemory 零调用方,
|
||||
// working_memories 随会话数永久残留)
|
||||
ctx.memoryManager.clearWorkingMemory(sessionId);
|
||||
// v0.8.1 P0-3: 清除该会话的流式回放缓冲(最多 4MB/会话)
|
||||
clearReplayBuffer(sessionId);
|
||||
// v0.7.4 P3-5: 显式销毁引擎与 adapter 实例(内存收口)
|
||||
// —— 旧实现只靠 LRU 上限 30 淘汰,会话删除后引擎仍驻留
|
||||
agentEngineManager.disposeEngine(sessionId);
|
||||
@@ -78,14 +85,35 @@ export function registerSessionHandlers(ctx: IPCContext): void {
|
||||
if (result.success) {
|
||||
confirmationHook.forgetSession(sessionId);
|
||||
agentEngineManager.disposeEngine(sessionId);
|
||||
// v0.8.1 P0-2: 终态联动清理工作记忆
|
||||
ctx.memoryManager.clearWorkingMemory(sessionId);
|
||||
// v0.8.1 P0-3: 清除该会话的流式回放缓冲
|
||||
clearReplayBuffer(sessionId);
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
ipcMain.handle('sessions:getMessages', async (_event, sessionId: unknown) => {
|
||||
ipcMain.handle('sessions:getMessages', async (_event, sessionId: unknown, options?: unknown) => {
|
||||
// M-34 修复: 校验 sessionId
|
||||
if (!isValidSessionId(sessionId)) return [];
|
||||
return sessionService.getMessages(sessionId);
|
||||
// v0.8.1 P2-3: 游标分页选项(limit 1-1000 / beforeRowid 正整数),非法值忽略
|
||||
let safeOptions: { limit?: number; beforeRowid?: number } | undefined;
|
||||
if (options && typeof options === 'object') {
|
||||
const o = options as Record<string, unknown>;
|
||||
safeOptions = {};
|
||||
if (typeof o.limit === 'number' && Number.isFinite(o.limit) && o.limit >= 1) {
|
||||
safeOptions.limit = Math.min(1000, Math.floor(o.limit));
|
||||
}
|
||||
if (
|
||||
typeof o.beforeRowid === 'number' &&
|
||||
Number.isFinite(o.beforeRowid) &&
|
||||
o.beforeRowid > 0
|
||||
) {
|
||||
safeOptions.beforeRowid = Math.floor(o.beforeRowid);
|
||||
}
|
||||
if (Object.keys(safeOptions).length === 0) safeOptions = undefined;
|
||||
}
|
||||
return sessionService.getMessages(sessionId, safeOptions);
|
||||
});
|
||||
|
||||
// v0.7.4 P3-10: 更新单条用户消息内容(编辑"仅保存"落库)。
|
||||
@@ -129,6 +157,8 @@ export function registerSessionHandlers(ctx: IPCContext): void {
|
||||
if (!isValidSessionId(sessionId)) return { success: false, error: 'Invalid sessionId' };
|
||||
try {
|
||||
sessionService.clearMessages(sessionId);
|
||||
// v0.8.1 P0-2: 清空会话 = 会话内容重置 —— 工作记忆一并清理
|
||||
ctx.memoryManager.clearWorkingMemory(sessionId);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: (error as Error).message };
|
||||
|
||||
+58
-14
@@ -10,11 +10,15 @@ import log from 'electron-log';
|
||||
import type { IPCContext } from './context';
|
||||
import { broadcast } from './context';
|
||||
import { isSensitiveConfigKey } from '../utils/secure-config';
|
||||
// v0.8.1 P2-1: 工具自定义策略解析
|
||||
import { parseToolPolicy } from '../harness/sandbox/permissions';
|
||||
// v0.8.0 P1-5: 配置 URL 深校验(域名真实 DNS 解析,拦"解析到云元数据 IP"绕过)
|
||||
import {
|
||||
assertSafeConfigTargetDeep,
|
||||
DeepCheckSoftFailure,
|
||||
} from '../harness/tools/built-in/ssrf-guard';
|
||||
// v0.8.1 P0-4: 主进程文案双语
|
||||
import { setMainLocale, mt } from '../utils/main-locale';
|
||||
|
||||
/**
|
||||
* v0.7.4 P2-9-C: URL 类配置键 —— 写入时须过 assertSafeConfigTarget 高危目标校验。
|
||||
@@ -65,16 +69,12 @@ export const LLM_CONFIG_KEYS = [
|
||||
'llm.model',
|
||||
'llm.apiKey',
|
||||
'llm.baseURL',
|
||||
// v0.8.1: 全局单一「上下文长度」—— 重建 adapter(携带新窗口)+ 引擎配置同步
|
||||
'llm.contextWindow',
|
||||
'llm.fallbackProvider',
|
||||
'llm.fallbackModel',
|
||||
'llm.fallbackApiKey',
|
||||
'llm.fallbackBaseURL',
|
||||
'ollama.numCtx',
|
||||
'deepseek.contextWindow',
|
||||
'agnes.contextWindow',
|
||||
'mimo.contextWindow',
|
||||
'openai.contextWindow',
|
||||
'anthropic.contextWindow',
|
||||
];
|
||||
|
||||
/** 敏感配置值脱敏(审计日志用:长值保留后 4 位,短值完全掩码) */
|
||||
@@ -129,7 +129,7 @@ export function clearApiKeyOnProviderChange(
|
||||
* 应用单条配置的引擎/编排器副作用(Engine/Orchestrator/ConfirmationHook 同步)
|
||||
*/
|
||||
export function applyEngineConfigKey(ctx: IPCContext, key: string, value: unknown): void {
|
||||
const { agentEngineManager, orchestrator, confirmationHook } = ctx;
|
||||
const { agentEngineManager, orchestrator, confirmationHook, configService } = ctx;
|
||||
switch (key) {
|
||||
case 'agent.maxIterations':
|
||||
agentEngineManager.updateConfigAll({ maxIterations: value as number });
|
||||
@@ -162,8 +162,15 @@ export function applyEngineConfigKey(ctx: IPCContext, key: string, value: unknow
|
||||
orchestrator.updateDefaultConfig({ temperature: Number(value) });
|
||||
break;
|
||||
case 'llm.maxTokens':
|
||||
agentEngineManager.updateConfigAll({ maxTokens: Number(value) });
|
||||
orchestrator.updateDefaultConfig({ maxTokens: Number(value) });
|
||||
// v0.8.1 review 修复: null/空串 = 用户清空「最大输出上限」→ 未配置语义
|
||||
//(undefined 下发,由 Provider 服务端默认值决定);此前 Number(null)=0
|
||||
// 会把引擎预算清零。
|
||||
agentEngineManager.updateConfigAll({
|
||||
maxTokens: value == null || value === '' ? undefined : Number(value) || undefined,
|
||||
});
|
||||
orchestrator.updateDefaultConfig({
|
||||
maxTokens: value == null || value === '' ? undefined : Number(value) || undefined,
|
||||
});
|
||||
break;
|
||||
case 'agent.toolExecutionTimeoutMs':
|
||||
agentEngineManager.updateConfigAll({ toolExecutionTimeoutMs: value as number });
|
||||
@@ -172,17 +179,34 @@ export function applyEngineConfigKey(ctx: IPCContext, key: string, value: unknow
|
||||
confirmationHook.setConfirmationTimeout(value as number);
|
||||
break;
|
||||
case 'ollama.numCtx':
|
||||
// v0.8.1: ollama.numCtx 已废除 —— 与「上下文长度」合并为 llm.contextWindow。
|
||||
// 保留此分支仅为兼容存量库中的遗留键写入(迁移 12 已清理),行为同 llm.contextWindow。
|
||||
agentEngineManager.updateConfigAll({ contextLength: (value as number) || undefined });
|
||||
orchestrator.updateDefaultConfig({ contextLength: (value as number) || undefined });
|
||||
break;
|
||||
// v0.8.1: 全局单一「上下文长度」—— 替代旧的分 Provider contextWindow 键与
|
||||
// ollama.numCtx。Ollama Provider 下同时作为 num_ctx(contextLength)下发。
|
||||
case 'llm.contextWindow': {
|
||||
const ctx = (value as number) || undefined;
|
||||
const isOllama = (configService.get<string>('llm.provider') ?? '') === 'ollama';
|
||||
agentEngineManager.updateConfigAll({
|
||||
contextWindow: ctx,
|
||||
contextLength: isOllama ? ctx : undefined,
|
||||
});
|
||||
orchestrator.updateDefaultConfig({
|
||||
contextWindow: ctx,
|
||||
contextLength: isOllama ? ctx : undefined,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'deepseek.contextWindow':
|
||||
case 'agnes.contextWindow':
|
||||
case 'mimo.contextWindow':
|
||||
case 'openai.contextWindow':
|
||||
case 'anthropic.contextWindow':
|
||||
// reloadAdapter 已重建 adapter 并同步 contextWindow,此处确保 Engine 配置同步(兜底)
|
||||
agentEngineManager.updateConfigAll({ contextWindow: (value as number) || undefined });
|
||||
orchestrator.updateDefaultConfig({ contextWindow: (value as number) || undefined });
|
||||
// v0.8.1: 分 Provider 键已废除 —— 兼容分支收敛为空操作(迁移 12 已清理
|
||||
// 存量库;若前端旧版本仍写入,静默忽略以防双源语义复活)。
|
||||
log.warn(`[CONFIG] Deprecated provider contextWindow key ignored: ${key}`);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
@@ -208,13 +232,27 @@ export async function applyConfigSideEffects(
|
||||
if (entries.some((e) => LLM_CONFIG_KEYS.includes(e.key))) {
|
||||
if (!ctx.reloadAdapter()) {
|
||||
log.warn('[CONFIG] Adapter reload failed after config save');
|
||||
return 'LLM 配置不完整,请检查 Provider、API Key、Base URL 和 Model 是否都已填写';
|
||||
return mt('config.error.configIncomplete');
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Engine/Orchestrator/ConfirmationHook 配置同步
|
||||
for (const { key, value } of entries) {
|
||||
applyEngineConfigKey(ctx, key, value);
|
||||
// v0.8.1 P2-1: 工具自定义策略热加载(tools.{name}.policy,JSON 字符串)
|
||||
const m = /^tools\.(.+)\.policy$/.exec(key);
|
||||
if (m) {
|
||||
const toolName = m[1];
|
||||
const known = ctx.toolRegistry.listAllTools().some((t) => t.name === toolName);
|
||||
if (known) {
|
||||
const parsed =
|
||||
typeof value === 'string' && value.trim() !== '' ? parseToolPolicy(value) : null;
|
||||
ctx.policyEngine.setPolicyOverride(toolName, parsed);
|
||||
if (typeof value === 'string' && value.trim() !== '' && !parsed) {
|
||||
log.warn(`[Policy] Ignored invalid policy config for tool: ${toolName}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 日志级别即时应用
|
||||
@@ -255,7 +293,7 @@ export async function applyConfigSideEffects(
|
||||
} catch (err) {
|
||||
log.error('[CONFIG] Failed to save workspace path:', err);
|
||||
// v0.3.10: 写入失败必须告知用户,否则下次启动仍使用旧路径
|
||||
return `工作空间路径保存失败:${(err as Error).message}`;
|
||||
return mt('config.error.workspaceSaveFailed', { message: (err as Error).message });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -266,6 +304,12 @@ export async function applyConfigSideEffects(
|
||||
await applySessionProxy(typeof proxyValue === 'string' ? proxyValue : null);
|
||||
}
|
||||
|
||||
// v0.8.1 P0-4: 界面语言变更 → 主进程 toast/通知语言热切换(无需重启)
|
||||
const localeEntry = entries.find((e) => e.key === 'ui.locale');
|
||||
if (localeEntry && typeof localeEntry.value === 'string') {
|
||||
setMainLocale(localeEntry.value);
|
||||
}
|
||||
|
||||
// v0.7.3 P4-2: MCP 自动重连开关变更 → 即时联动(关闭时取消全部已排程重连)
|
||||
const autoReconnectEntry = entries.find((e) => e.key === 'mcp.autoReconnect');
|
||||
if (autoReconnectEntry) {
|
||||
|
||||
Reference in New Issue
Block a user