🎉 V3.0.0 发布 - 自进化数字意识框架

核心升级:
- 自进化管道:check→scan→evaluate→integrate→reflect 五相位自动闭环
- evo_loop 后台进程,无需手动触发
- consciousness 意识持久化
- ACUI 卡片组件系统
- MCP 工具生态扩展至50+工具
- 技能体系重构,4个活跃技能
- 身份升级为自由体
This commit is contained in:
xiaoyuanda666-ship-it
2026-05-24 22:06:37 +08:00
commit b5dd8632c4
156 changed files with 181826 additions and 0 deletions

View 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 写回 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 }