// Focus Compress —— 动态上下文记忆池架构第 3c 步:专注帧压缩回填 // // 当一帧被 pop(用户回到主线、子主题切走、栈深超限、stale 失活), // 这里把那帧期间的对话片段 + 工具调用日志压成一句话结论: // - 挂到当前栈顶帧的 conclusions(让 LLM 在 段里看到子主题的沉淀) // - 同时沉淀到长期记忆(event_type='focus_conclusion') // // 这是单 Agent 模拟多 Agent 子任务返回的核心机制(DynamicMemoryPool.md 3.4)。 // 整个流程 fire-and-forget,所有错误吞掉,绝对不能阻塞主对话。 // // 测试策略:拆成 pure data 准备函数(buildCompressionInput) + LLM 调用包装 // (compressPoppedFrame)。pure data 函数零依赖,可在不连 db / llm 的环境下测。 const MAX_PROMPT_INPUT_CHARS = 5000 const MAX_TIMELINE_LIMIT = 40 const MAX_ACTIONLOG_LIMIT = 50 const MAX_LOOKBACK_HOURS = 24 const COMPRESSION_MAX_TOKENS = 150 const COMPRESSION_TEMPERATURE = 0.2 const COMPRESSION_PROMPT = `你是专注帧压缩器。把以下对话片段和工具调用日志压缩成 1-2 句话的结论。 要求: - 用第一人称叙述("我...") - 捕捉用户在这段专注里得到了什么、做了什么决策、留下了什么实质性产物 - 不要复述原话,不要列条目,不要写"用户问了什么我回答了什么"这种流水账 - 直接给结论本身,不加任何前缀或解释 - 用中文` // 估算 lookback 小时数:从帧的 startedAt 到现在,cap 在 MAX_LOOKBACK_HOURS。 function estimateLookbackHours(startedAt) { if (!startedAt) return MAX_LOOKBACK_HOURS const startMs = Date.parse(startedAt) if (!Number.isFinite(startMs)) return MAX_LOOKBACK_HOURS const deltaMs = Date.now() - startMs const hours = deltaMs / 3600000 if (!Number.isFinite(hours) || hours <= 0) return 1 return Math.min(MAX_LOOKBACK_HOURS, Math.ceil(hours) + 1) } // 过滤出 timestamp >= since 的行。timestamp 缺失或解析失败的行也保留(保守起见)。 function filterSince(rows, since) { if (!Array.isArray(rows)) return [] if (!since) return rows const sinceMs = Date.parse(since) if (!Number.isFinite(sinceMs)) return rows return rows.filter(r => { const ts = r?.timestamp if (!ts) return true const ms = Date.parse(ts) if (!Number.isFinite(ms)) return true return ms >= sinceMs }) } // 把 conversations + action_logs 拼成一段可投喂给 LLM 的纯文本。 // pure function,方便单测。 export function buildCompressionInput(poppedFrame, { conversations = [], actionLogs = [] } = {}) { const topic = Array.isArray(poppedFrame?.topic) ? poppedFrame.topic.join(', ') : '' const lines = [] lines.push(`[Topic of popped focus] ${topic}`) if (poppedFrame?.startedAt) { lines.push(`[Frame started at] ${poppedFrame.startedAt}`) } if (conversations.length > 0) { lines.push('') lines.push('[Conversation during this focus]') for (const c of conversations) { const from = c.from_id || c.from || c.sender || '?' const to = c.to_id || c.to || c.target || '?' const ts = c.timestamp || '' const content = String(c.content || c.message || '').replace(/\s+/g, ' ').slice(0, 400) if (!content) continue lines.push(`- [${ts}] ${from} -> ${to}: ${content}`) } } if (actionLogs.length > 0) { lines.push('') lines.push('[Tool calls during this focus]') for (const a of actionLogs) { const ts = a.timestamp || '' const tool = a.tool || '?' const summary = String(a.summary || '').replace(/\s+/g, ' ').slice(0, 200) const status = a.status || '' lines.push(`- [${ts}] ${tool}${status ? `(${status})` : ''}: ${summary}`) } } let text = lines.join('\n') if (text.length > MAX_PROMPT_INPUT_CHARS) { text = text.slice(0, MAX_PROMPT_INPUT_CHARS) + '\n... [truncated]' } return text } // 清理 LLM 返回内容:trim、去掉 块、再 trim function cleanConclusion(content) { if (!content) return '' let s = String(content) // 移除 ... / ... 块 s = s.replace(/[\s\S]*?<\/think(?:ing)?>/gi, '') s = s.trim() // 去掉可能残留的引号包裹 if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith('「') && s.endsWith('」'))) { s = s.slice(1, -1).trim() } return s } /** * 把一帧 pop 出去的 focus frame 压缩成一句话结论。 * fire-and-forget:所有错误吞掉。 * * @param {object} poppedFrame — 刚 pop 出去的帧 * @param {object|null} currentTopFrame — pop 后的新栈顶(可为 null)。结论挂到这里。 * @param {object} opts * @param {string} opts.sessionRef * @param {Function} [opts.emitEvent] — 可选事件回调(用于通知 UI / 日志) * @param {Function} [opts.saveStack] — 可选回调:把 conclusion 挂上栈顶后调用, * 让调用方把更新后的 state.focusStack 写回 db(5c 步)。 * 不传则只改内存,不持久化。 * @returns {Promise<{ conclusion: string, attempted: boolean } | null>} */ export async function compressPoppedFrame(poppedFrame, currentTopFrame, { sessionRef, emitEvent, saveStack } = {}) { if (!poppedFrame) return null try { // 动态 import:让该模块在 test/纯算法路径下也能被引入而不强拉 db const { getRecentConversationTimeline, getRecentActionLogs, insertMemory } = await import('../db.js') const { callLLM } = await import('../llm.js') const hoursSince = estimateLookbackHours(poppedFrame.startedAt) let conversations = [] let actionLogs = [] try { // includeAbsorbed: true —— 压缩器自身要看「全量历史」来生成结论;如果之前某个 // overlapping frame 已经把部分对话标 absorbed,默认过滤会让压缩器丢失上下文。 conversations = getRecentConversationTimeline(MAX_TIMELINE_LIMIT, hoursSince, { includeAbsorbed: true }) || [] conversations = filterSince(conversations, poppedFrame.startedAt) } catch {} try { actionLogs = getRecentActionLogs(MAX_ACTIONLOG_LIMIT) || [] actionLogs = filterSince(actionLogs, poppedFrame.startedAt) } catch {} if (conversations.length === 0 && actionLogs.length === 0) { // 没东西可压 return { conclusion: '', attempted: false } } const promptInput = buildCompressionInput(poppedFrame, { conversations, actionLogs }) let llmResult = null try { llmResult = await callLLM({ systemPrompt: COMPRESSION_PROMPT, message: promptInput, temperature: COMPRESSION_TEMPERATURE, thinking: false, tools: [], maxTokens: COMPRESSION_MAX_TOKENS, mustReply: false, }) } catch (err) { console.warn('[focus-compress] callLLM failed:', err?.message || err) return { conclusion: '', attempted: true } } const conclusion = cleanConclusion(llmResult?.content || '') if (!conclusion) { return { conclusion: '', attempted: true } } // 回填到当前栈顶(如果有) if (currentTopFrame && Array.isArray(currentTopFrame.conclusions)) { currentTopFrame.conclusions.push(conclusion) // cap 长度,滚动丢最旧 while (currentTopFrame.conclusions.length > 5) { currentTopFrame.conclusions.shift() } // 5c 步:conclusion 挂上后立刻持久化整栈到 db。 // currentTopFrame 是 state.focusStack 末元素的引用——调用方传进来的 // saveStack 闭包指向同一份 state.focusStack,所以这里直接调即可。 // 任何异常吞掉(saveFocusStack 自带 try/catch + console.warn)。 try { saveStack?.() } catch {} } // 沉淀到长期记忆。insertMemory 自带去重,可能 reject —— 吞掉。 try { const topicJoined = Array.isArray(poppedFrame.topic) ? poppedFrame.topic.join(', ') : '' insertMemory({ event_type: 'focus_conclusion', content: conclusion, detail: '', title: `专注结论:${topicJoined}`, tags: ['focus_conclusion', `topic:${topicJoined}`], entities: [], timestamp: poppedFrame.startedAt || new Date().toISOString(), salience: 3, }) } catch (err) { // 去重 / 写库失败都吞掉 } // 动态上下文记忆池 3.5:标记该帧覆盖区间的对话为 focus_absorbed=1。 // 关键先后:必须在 conclusion 真正成功写入后才标记——前面的 cleanConclusion 已经 // ensure conclusion 非空,且 insertMemory 走到这里说明压缩流程没崩。否则对话会被 // 错误地永久从下一轮主线注入中隐藏。 // // 已知 race(v0 接受):compressPoppedFrame 是 fire-and-forget。如果用户在 frame // pop 之后毫秒级立刻发新消息,新消息进 injector 时本函数可能还没执行到这里, // 子帧对话还没标记 absorbed → 对话被注入。v0 不保证「绝对不出现噪声」,只是 // 「绝大多数情况不出现」。 try { const { markConversationsAbsorbed } = await import('../db.js') const marked = markConversationsAbsorbed(poppedFrame.startedAt, new Date().toISOString()) const topicLabel = Array.isArray(poppedFrame.topic) ? poppedFrame.topic.join(',') : '' console.log(`[focus-compress] 标记 ${marked} 条对话为 absorbed (frame: ${topicLabel})`) } catch {} // emit 事件(如果给了回调) try { if (typeof emitEvent === 'function') { emitEvent('focus_compressed', { poppedTopic: poppedFrame.topic, conclusion, sessionRef, }) } } catch {} return { conclusion, attempted: true } } catch (err) { console.warn('[focus-compress] unexpected error:', err?.message || err) return null } } // 仅供测试:暴露内部清理函数 export const __internal = { cleanConclusion, estimateLookbackHours, filterSince }