Files
BaiLongma/src/memory/focus-compress.js
xiaoyuanda666-ship-it b5dd8632c4 🎉 V3.0.0 发布 - 自进化数字意识框架
核心升级:
- 自进化管道:check→scan→evaluate→integrate→reflect 五相位自动闭环
- evo_loop 后台进程,无需手动触发
- consciousness 意识持久化
- ACUI 卡片组件系统
- MCP 工具生态扩展至50+工具
- 技能体系重构,4个活跃技能
- 身份升级为自由体
2026-05-24 22:06:37 +08:00

241 lines
9.9 KiB
JavaScript
Raw 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.
// Focus Compress —— 动态上下文记忆池架构第 3c 步:专注帧压缩回填
//
// 当一帧被 pop用户回到主线、子主题切走、栈深超限、stale 失活),
// 这里把那帧期间的对话片段 + 工具调用日志压成一句话结论:
// - 挂到当前栈顶帧的 conclusions让 LLM 在 <focus> 段里看到子主题的沉淀)
// - 同时沉淀到长期记忆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、去掉 <think> 块、再 trim
function cleanConclusion(content) {
if (!content) return ''
let s = String(content)
// 移除 <think>...</think> / <thinking>...</thinking> 块
s = s.replace(/<think(?:ing)?>[\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 写回 db5c 步)。
* 不传则只改内存,不持久化。
* @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 走到这里说明压缩流程没崩。否则对话会被
// 错误地永久从下一轮主线注入中隐藏。
//
// 已知 racev0 接受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 }