🎉 V3.0.0 发布 - 自进化数字意识框架
核心升级: - 自进化管道:check→scan→evaluate→integrate→reflect 五相位自动闭环 - evo_loop 后台进程,无需手动触发 - consciousness 意识持久化 - ACUI 卡片组件系统 - MCP 工具生态扩展至50+工具 - 技能体系重构,4个活跃技能 - 身份升级为自由体
This commit is contained in:
240
src/memory/focus-compress.js
Normal file
240
src/memory/focus-compress.js
Normal file
@@ -0,0 +1,240 @@
|
||||
// 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 写回 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 }
|
||||
Reference in New Issue
Block a user