feat(memory): 媒体接入 L0/L2——digest 挂到对话事件,归档时引用随之转移

a822674 的 CAS 层之上把媒体真正接进记忆链路。此前 CAS 只是个孤立的
存储包,没有任何写入方。

## 媒体进入对话有两条路,两条都只把文字留给记忆

  1. 用户直接发图 → processMediaInput → mediaToBlocks
     ContextEvent.Input 只存 alt 文本("[从 qq 收到了 image]"),
     base64 随 message 数组发给模型后就丢了。
  2. 插件注入 → SetToolBlocks → process.go 的 mediaMsg
     ToolResultItem.Output 只存那句 "[已将图片注入后续对话] /tmp/x.png"。

于是下一轮起,模型能看到的只剩一句路径或一句 alt。那个文件被删、被覆盖,
或者本来就是 /tmp 下的临时产物,连线索都断了。

现在两条路在同一处收口(captureBlockMedia):从 ContentBlock 的 data URL
取出字节存进 CAS,digest 挂到当轮 ContextEvent。

## 改动

internal/agent/core/mediaref.go(新)
  - captureBlockMedia:ContentBlock → CAS。只处理 data URL——http(s) URL
    拿不到字节就无法内容寻址,而「下载它再存」会把一次对话变成一次网络
    请求(超时、鉴权、SSRF 全来了),不在本层解决。
  - stage/drainMediaDigests:媒体在 process() 期间被捕获,而承载它的
    ContextEvent 要等 process() 返回后才 Append——此刻还没有 owner_id,
    故先缓存。与既有 pendingMedia 同一手法,同受 a.mu 保护。
  - bindEventMedia:双向落地。evt.Media 让事件记得引了什么(随
    context.json 持久化),media_refs 让 CAS 知道谁在引用(GC 的判断依据)。
    只写一边的话,要么 GC 误删仍被引用的内容,要么孤儿永远清不掉。
  - mediaSummaryForEvent:把已有描述拼成一行写进 Input。这是方案 C 的
    落点——**描述文本才是持久语义记忆,blob 只是缓存**。blob 可能被容量
    GC 淘汰,但描述会一直留在 L0/L2/L3 的文本里,让「那张紫蓝红三色带图」
    几个月后仍可被检索。

ContextEvent 新增 ID 与 Media 两个字段,都是 omitempty:
  - ID 懒生成,只有真要挂媒体时才赋值。绝大多数对话没有媒体,全量生成
    会让每条事件都多一个字段进 context.json。
  - 存量 context.json 读回来两字段皆空,不影响任何既有行为(有测试)。

RelevanceContext.Prune 归档时转移引用(transferMediaRefs):
  **先挂到归档文档、再注销原事件引用**。顺序不能反——先销后挂会让引用
  计数瞬时归零,若此刻后台 GC 正在跑就会把仍被记忆引用的内容当孤儿清掉。
  为此把 Prune 内的局部类型 scored 提为包级 scoredEvent(局部类型无法
  出现在方法签名上)。

media 包新增 OwnerContext/OwnerDocument/OwnerGraphSentence 常量:
  owner_kind 进了主键,拼错一个字符就是一条永远对不上的孤立引用——
  AddRef 不报错,DropOwner 也永远匹配不到。

## 配置

core.memory.media.enabled(默认 true)、.dir、.max_mb(2048)、
.gc_interval(6h)、.gc_min_age(1h)。

关闭后全链路静默跳过,对话行为与本特性上线前完全一致(有测试)。
mediaStore 为 nil 时同理——它是记忆增强,不是对话必需品,开不起来
只记一条 warning 不阻止启动。

## 测试(11 例)

入库与 MIME 归类、http URL 跳过、nil store 全链路 no-op、音视频混合、
stage/drain 清空语义、懒生成 ID、描述作为持久记忆、**归档转移期间内容
始终可读且 refcount 不归零**、无媒体存储时归档照常、context.json
向后兼容往返。

全仓 go build / go vet / go test 通过,SDK 冻结 diff = 0。

## 尚未接入

L3 图库的 graph_sentence owner(常量已备好,无写入方)、
描述生成的后台任务(Pending() 已就绪,尚无消费者)、
媒体 GC 的定时触发(配置项已注册,尚未接 ticker)。
This commit is contained in:
JianFeeeee
2026-09-04 20:53:32 +08:00
parent 9b7b2675da
commit f855893d1c
9 changed files with 685 additions and 30 deletions

View File

@ -25,6 +25,7 @@ import (
luapkg "gitcode.com/JianFeeeee/HomeAgent/internal/lua"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/media"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/pipeline"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/social"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/text"
@ -324,6 +325,27 @@ func main() {
log.Printf("[homed] warning: document store: %v", err)
}
// 媒体存储(内容寻址):对话里出现的图片/音频按 sha256 落盘去重,
// L0/L2/L3 只记 digest。开关默认开关闭后全部媒体接线静默跳过
// 对话行为与本特性上线前完全一致。
var mediaStore *media.Store
if cfgReg.GetBool("core.memory.media.enabled", true) {
mediaDir := cfgReg.GetString("core.memory.media.dir",
filepath.Join(cfg.Daemon.DataDir, "memory", "media"))
maxMB := cfgReg.GetInt("core.memory.media.max_mb", 2048)
ms, err := media.New(mediaDir, int64(maxMB)*1024*1024)
if err != nil {
// 媒体存储开不起来不该阻止启动——它是记忆增强,不是对话必需品
log.Printf("[homed] warning: media store: %v媒体记忆已禁用", err)
} else {
mediaStore = ms
defer mediaStore.Close()
st := mediaStore.Stats()
log.Printf("[homed] media store active: %v 条 / %v 字节(上限 %d MB",
st["count"], st["total_bytes"], maxMB)
}
}
ks := knowledge.NewStore(filepath.Join(cfg.Daemon.DataDir, "knowledge"))
if err := ks.Start(); err != nil {
log.Printf("[homed] warning: knowledge store: %v", err)
@ -431,6 +453,7 @@ func main() {
Knowledge: ks,
SocialStore: socialStore,
TextMemory: textMem,
MediaStore: mediaStore,
Personality: personality,
PluginReg: pluginReg,
PluginDir: cfg.Plugin.Dir,

View File

@ -14,6 +14,7 @@ import (
"gitcode.com/JianFeeeee/HomeAgent/internal/knowledge"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/media"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/social"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/text"
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin"
@ -50,6 +51,11 @@ type Agent struct {
// 文本记忆(原始对话日志)
textMem *text.Memory
// 媒体存储(内容寻址):对话里出现的图片/音频按 sha256 落盘去重,
// L0/L2/L3 只记 digest。为 nil 时全部媒体接线静默跳过——
// 它是记忆增强而非对话必需品,缺了不该让对话失败。
mediaStore *media.Store
// 人格设定
personality *agentPkg.Personality
@ -106,6 +112,13 @@ type Agent struct {
// 当前轮次的非文本媒体数据(图片/音频),供 describe_image 等工具访问
pendingMedia map[string]interface{}
// pendingMediaDigests 累积本轮已落进 CAS 的媒体 digest。
//
// 需要缓存而不是当场挂到事件上:媒体在 process() 执行期间被捕获,
// 而承载它的 ContextEvent 要等 process() 返回后才 Append——此刻还没有 owner_id。
// 与 pendingMedia 同受 a.mu 保护。
pendingMediaDigests []string
// 当前输入是否为工具提醒/中断(以 system 角色注入,避免被当成用户消息)
interruptInput bool
@ -119,7 +132,7 @@ type Agent struct {
// 输入去重:防 webui/GUI 断线重连导致的消息重放
// key=source+"|"+content, value=上次接收时间;短窗口内同内容丢弃
lastInput map[string]time.Time
lastInput map[string]time.Time
lastInputMu sync.Mutex
// 词嵌入模型,用于实体语义相似度计算
@ -151,16 +164,17 @@ type AgentConfig struct {
Knowledge *knowledge.Store
SocialStore *social.SocialStore
TextMemory *text.Memory
MediaStore *media.Store
Personality *agentPkg.Personality
PluginReg *plugin.Registry
PluginDir string
DistillInterval time.Duration
ArchiveInterval time.Duration // 冷文档归档间隔L2→L30 则使用 DistillInterval
ReviewInterval time.Duration // 关系复审间隔0 则使用 DistillInterval
MergeInterval time.Duration // 实体合并检测间隔0 则使用 DistillInterval
MaxContextSize int // 活跃上下文最大条数,超出按相关性裁剪
ContextSavePath string // 上下文持久化路径,空则不持久化
EmbeddingModelPath string // 预训练词嵌入模型路径word2vec 文本格式),空则不使用
ArchiveInterval time.Duration // 冷文档归档间隔L2→L30 则使用 DistillInterval
ReviewInterval time.Duration // 关系复审间隔0 则使用 DistillInterval
MergeInterval time.Duration // 实体合并检测间隔0 则使用 DistillInterval
MaxContextSize int // 活跃上下文最大条数,超出按相关性裁剪
ContextSavePath string // 上下文持久化路径,空则不持久化
EmbeddingModelPath string // 预训练词嵌入模型路径word2vec 文本格式),空则不使用
Embedder *memory.StaticEmbedder // 共享词嵌入实例nil 时按 EmbeddingModelPath 自建
StageHost *StageHost
EventBus *events.Bus
@ -227,6 +241,7 @@ func New(cfg AgentConfig) *Agent {
knowledge: cfg.Knowledge,
social: cfg.SocialStore,
textMem: cfg.TextMemory,
mediaStore: cfg.MediaStore,
personality: cfg.Personality,
pluginReg: cfg.PluginReg,
pluginDir: cfg.PluginDir,

View File

@ -3,6 +3,7 @@ package core
import (
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"sort"
@ -12,6 +13,7 @@ import (
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/media"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/vector"
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
)
@ -22,13 +24,25 @@ type ToolResultItem struct {
}
type ContextEvent struct {
Timestamp time.Time `json:"timestamp"`
Source string `json:"source"`
Input string `json:"input"`
Response string `json:"response,omitempty"`
ToolsUsed []string `json:"tools_used,omitempty"`
ToolResults []ToolResultItem `json:"tool_results,omitempty"`
Vector vector.Vector `json:"-"`
// ID 是事件的稳定标识媒体引用media_refs.owner_id挂在它上面。
//
// 惰性生成:只有真的要挂媒体时才赋值(见 bindEventMedia
// 全量生成会让每条事件都多一个字段进 context.json而绝大多数对话没有媒体。
// omitempty 保证存量 context.json 读回来时该字段为空,不影响任何既有行为。
ID string `json:"id,omitempty"`
Timestamp time.Time `json:"timestamp"`
Source string `json:"source"`
Input string `json:"input"`
Response string `json:"response,omitempty"`
ToolsUsed []string `json:"tools_used,omitempty"`
ToolResults []ToolResultItem `json:"tool_results,omitempty"`
// Media 是本轮对话涉及的媒体 digestsha256 十六进制)。
//
// 存 digest 而不存路径:路径会失效(/tmp 探针图、下载缓存、别的进程的
// 临时产物digest 是内容本身的身份,配合 internal/memory/media 的 CAS
// 永远能取回原始字节——只要它还没被容量 GC 淘汰。
Media []string `json:"media,omitempty"`
Vector vector.Vector `json:"-"`
}
const contextFlushInterval = 5 * time.Second
@ -42,6 +56,41 @@ type RelevanceContext struct {
dirty bool
toolDefLookup func(name string) *sdk.ToolDef
channelDefLookup func(name string) (sdk.ChannelDef, bool)
// mediaStore 只用于 Prune 时把媒体引用从事件转给归档文档。
// 为 nil 时引用转移静默跳过(媒体存储未启用)。
mediaStore *media.Store
}
// SetMediaStore 注入媒体存储,供 L0→L2 归档时转移媒体引用。
func (c *RelevanceContext) SetMediaStore(s *media.Store) {
c.mu.Lock()
defer c.mu.Unlock()
c.mediaStore = s
}
// transferMediaRefs 把被归档事件的媒体引用转给目标文档(调用方已持 c.mu
//
// 先挂后销:若反序,引用计数会瞬时归零,此时若后台 GC 正在跑
// 就会把仍被记忆引用的内容当孤儿清掉。
func (c *RelevanceContext) transferMediaRefs(archive []scoredEvent, docID string) {
if c.mediaStore == nil || docID == "" {
return
}
for _, s := range archive {
evt := s.event
if evt == nil || evt.ID == "" || len(evt.Media) == 0 {
continue
}
for _, d := range evt.Media {
if err := c.mediaStore.AddRef(d, media.OwnerDocument, docID); err != nil {
log.Printf("[media] 归档转移 AddRef 失败 (%s → doc %s): %v", shortDigest(d), docID, err)
}
}
if _, err := c.mediaStore.DropOwner(media.OwnerContext, evt.ID); err != nil {
log.Printf("[media] 归档转移 DropOwner 失败 (evt %s): %v", evt.ID, err)
}
}
}
func NewRelevanceContext(savePath string, embedder *memory.StaticEmbedder) *RelevanceContext {
@ -256,6 +305,16 @@ func (c *RelevanceContext) flush() {
c.dirty = false
}
// scoredEvent 是 Prune 里按相关度排序的事件。
//
// 提为包级类型(原先是 Prune 内的局部类型transferMediaRefs 需要
// 把待归档列表传进去,局部类型无法出现在方法签名上。
type scoredEvent struct {
event *ContextEvent
score float64
idx int
}
func (c *RelevanceContext) Prune(currentInput string, topK int, docStore *document.Store) int {
c.mu.Lock()
defer c.mu.Unlock()
@ -277,15 +336,10 @@ func (c *RelevanceContext) Prune(currentInput string, topK int, docStore *docume
queryVec := c.embedder.VectorizeClean(currentInput)
type scored struct {
event *ContextEvent
score float64
idx int
}
scoredEvents := make([]scored, len(candidates))
scoredEvents := make([]scoredEvent, len(candidates))
for i, evt := range candidates {
score := vector.CosineSimilarity(queryVec, evt.Vector)
scoredEvents[i] = scored{event: evt, score: score, idx: i}
scoredEvents[i] = scoredEvent{event: evt, score: score, idx: i}
}
sort.Slice(scoredEvents, func(i, j int) bool {
@ -327,6 +381,11 @@ func (c *RelevanceContext) Prune(currentInput string, topK int, docStore *docume
doc, err := docStore.ContextToDoc("context_archived", entries, c.embedder, nil, c.toolOutputClean, c.channelCleanerForDoc())
if err == nil && doc != nil {
archived = len(entries)
// 媒体引用随事件一起从 L0 转到 L2先把引用挂到归档文档上
// 再注销原事件的引用。顺序不能反——先销后挂会让引用计数
// 瞬时归零,若此时 GC 正在跑(后台任务)就会把仍被记忆引用的
// 内容当孤儿清掉。
c.transferMediaRefs(archive, doc.ID)
}
}
@ -385,5 +444,3 @@ func convertToolResults(items []ToolResultItem) []document.ToolResultItem {
}
return result
}

View File

@ -174,6 +174,11 @@ func (a *Agent) processMediaInput(evt *agentIO.InputEvent) {
blocks, fallback := a.mediaToBlocks(evt.Payload, evt.Type, evt.Source)
// 用户直接发来的媒体:先落进 CAS。
// 不存的后果是 ContextEvent.Input 只剩一句 alt 文本
//"[从 qq 收到了 image]"base64 随 message 数组发给模型后就丢了。
a.stageMediaDigests(a.captureBlockMedia(blocks, "input_"+evt.Type)...)
stageCtx := a.stageCtxFromInput(fallback, evt.Source, "")
stageCtx.Extra = map[string]interface{}{
"media_blocks": blocks,
@ -216,14 +221,22 @@ func (a *Agent) processMediaInput(evt *agentIO.InputEvent) {
elapsed := time.Since(start)
log.Printf("[agent] %s from %s → response (%dms, tools=%v)", evt.Type, evt.Source, elapsed.Milliseconds(), toolsUsed)
a.context.Append(ContextEvent{
// 本轮捕获的媒体(用户发的 + 工具注入的)挂到这条事件上。
// 媒体描述并进 Input描述文本才是持久语义记忆blob 只是缓存。
digests := a.drainMediaDigests()
mediaEvt := ContextEvent{
Timestamp: time.Now(),
Source: "agent",
Input: fallback,
Response: response,
ToolsUsed: toolsUsed,
ToolResults: toolResults,
})
}
a.bindEventMedia(&mediaEvt, digests)
if s := a.mediaSummaryForEvent(mediaEvt.Media); s != "" {
mediaEvt.Input = mediaEvt.Input + "\n" + s
}
a.context.Append(mediaEvt)
a.emitResponse(evt, response)
@ -271,12 +284,12 @@ func (a *Agent) mediaToBlocks(payload map[string]interface{}, mediaType string,
}
if mediaType == "image" {
blocks = append(blocks, agentAPI.ContentBlock{
Type: "image_url",
Type: "image_url",
ImageURL: &agentAPI.ImageURL{URL: imgURL, Detail: "auto"},
})
} else if mediaType == "audio" {
blocks = append(blocks, agentAPI.ContentBlock{
Type: "audio_url",
Type: "audio_url",
AudioURL: &agentAPI.AudioURL{URL: imgURL},
})
}
@ -371,14 +384,21 @@ func (a *Agent) processTextInput(evt *agentIO.InputEvent, input string) {
elapsed := time.Since(start)
log.Printf("[agent] input from %s → response (%dms, tools=%v)", evt.Source, elapsed.Milliseconds(), toolsUsed)
a.context.Append(ContextEvent{
// 纯文本输入也可能产生媒体:模型调 multimodal_see_picture / see_video 等工具时,
// 插件经 SetToolBlocks 注入的块已在 process() 里被捕获。
textEvt := ContextEvent{
Timestamp: time.Now(),
Source: "agent",
Input: cleanInput,
Response: response,
ToolsUsed: toolsUsed,
ToolResults: toolResults,
})
}
a.bindEventMedia(&textEvt, a.drainMediaDigests())
if s := a.mediaSummaryForEvent(textEvt.Media); s != "" {
textEvt.Input = textEvt.Input + "\n" + s
}
a.context.Append(textEvt)
a.emitResponse(evt, response)

View File

@ -0,0 +1,160 @@
package core
import (
"fmt"
"log"
"strings"
"time"
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/media"
)
// 媒体记忆接线:把对话里出现的图片/音频落进内容寻址存储CAS
// 并让 L0 的 ContextEvent 记住它们的 digest。
//
// 为何需要这一层:媒体进入对话有两条路,两条都只把**文字**留给记忆——
//
// 1. 用户直接发图 → processMediaInput → mediaToBlocks
// ContextEvent.Input 只存 alt 文本("[从 qq 收到了 image]"
// base64 随 message 数组发给模型后就丢了。
// 2. 插件注入 → SetToolBlocks → process.go 的 mediaMsg
// ToolResultItem.Output 只存那句 "[已将图片注入后续对话] /tmp/x.png"。
//
// 于是下一轮对话起,模型能看到的只有一句路径或一句 alt。那个文件被删、
// 被覆盖,或者本来就是 /tmp 下的临时产物,连线索都断了。
//
// 现在两条路都在同一处收口:从 ContentBlock 的 data URL 取出字节存进 CAS
// digest 挂到当轮 ContextEvent 上;事件被 Prune 归档进 L2 时引用随之转移。
// captureBlockMedia 把 blocks 里的 data URL 媒体落进 CAS返回 digest 列表。
//
// 只处理 data URLhttp(s) URL 拿不到字节就无法做内容寻址,
// 而"下载它再存"会把一次对话变成一次网络请求超时、鉴权、SSRF 全来了),
// 不在本层解决。
func (a *Agent) captureBlockMedia(blocks []agentAPI.ContentBlock, tool string) []string {
if a.mediaStore == nil || len(blocks) == 0 {
return nil
}
var digests []string
for _, b := range blocks {
var url string
switch {
case b.ImageURL != nil && b.ImageURL.URL != "":
url = b.ImageURL.URL
case b.AudioURL != nil && b.AudioURL.URL != "":
url = b.AudioURL.URL
default:
continue
}
mime, data, ok := media.ParseDataURL(url)
if !ok {
continue // http(s) URL 或格式不认,跳过
}
d, err := a.mediaStore.Put(data, media.Item{
MIME: mime,
Tool: tool,
})
if err != nil {
// 媒体存不进去不该让对话失败——它是记忆增强,不是对话必需品
log.Printf("[media] 落盘失败 (tool=%s mime=%s): %v", tool, mime, err)
continue
}
digests = append(digests, d)
}
return digests
}
// stageMediaDigests 累积本轮捕获的 digest等 ContextEvent 建好后一起挂上。
//
// 为何要缓存而不是当场 AddRef媒体在 process() 执行期间被捕获,而承载它的
// ContextEvent 要等 process() 返回后才 Append——此刻还没有 owner_id。
// 与既有的 a.pendingMedia 同一手法(都在 a.mu 保护下)。
func (a *Agent) stageMediaDigests(digests ...string) {
if len(digests) == 0 {
return
}
a.pendingMediaDigests = append(a.pendingMediaDigests, digests...)
}
// drainMediaDigests 取出并清空本轮累积的 digest。
func (a *Agent) drainMediaDigests() []string {
if len(a.pendingMediaDigests) == 0 {
return nil
}
out := a.pendingMediaDigests
a.pendingMediaDigests = nil
return out
}
// bindEventMedia 把 digest 列表登记到某个 ContextEvent 上。
//
// 双向落地evt.Media 让事件自己记得引了哪些媒体(随 context.json 持久化),
// media_refs 表让 CAS 侧知道谁在引用GC 据此判断能不能清)。
// 两边都写才闭环——只写一边的话,要么 GC 会误删仍被记忆引用的内容,
// 要么孤儿永远清不掉。
func (a *Agent) bindEventMedia(evt *ContextEvent, digests []string) {
if a.mediaStore == nil || evt == nil || len(digests) == 0 {
return
}
if evt.ID == "" {
evt.ID = newEventID()
}
for _, d := range digests {
if err := a.mediaStore.AddRef(d, media.OwnerContext, evt.ID); err != nil {
log.Printf("[media] AddRef 失败 (%s → %s): %v", shortDigest(d), evt.ID, err)
continue
}
evt.Media = append(evt.Media, d)
}
}
// mediaSummaryForEvent 给已有描述的媒体生成一行文字,供写进 ContextEvent.Input。
//
// 这是方案 C 的落点:**描述文本才是持久语义记忆blob 只是缓存**。
// blob 可能被容量 GC 淘汰,但描述会一直留在 L0/L2/L3 的文本里,
// 让"那张紫蓝红三色带图"在几个月后仍然可被检索到。
func (a *Agent) mediaSummaryForEvent(digests []string) string {
if a.mediaStore == nil || len(digests) == 0 {
return ""
}
var lines []string
for _, d := range digests {
it, err := a.mediaStore.Stat(d)
if err != nil || it == nil {
continue
}
label := string(it.Kind)
if it.MIME != "" {
label = it.MIME
}
if it.Description != "" {
lines = append(lines, fmt.Sprintf("[%s %s] %s", label, shortDigest(d), it.Description))
} else {
lines = append(lines, fmt.Sprintf("[%s %s] (未描述)", label, shortDigest(d)))
}
}
if len(lines) == 0 {
return ""
}
return "媒体内容:\n" + strings.Join(lines, "\n")
}
// newEventID 生成 ContextEvent 的稳定标识。
//
// 沿用 document.Store 的 doc_<unixnano> 手法(同一份代码库里保持一致,
// 也避免为此引入 uuid 依赖)。纳秒精度足够:同一 Agent 的事件由
// a.mu 串行化 Append不存在同纳秒两条。
func newEventID() string {
return fmt.Sprintf("evt_%d", time.Now().UnixNano())
}
func shortDigest(d string) string {
if len(d) > 12 {
return d[:12]
}
return d
}

View File

@ -0,0 +1,355 @@
package core
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/media"
)
// 媒体记忆接线测试:验证媒体从对话进入 CAS、挂到 L0 事件、
// 随归档转到 L2 文档的完整链路。
//
// 核心断言不是"函数被调用了",而是不变量:
// 1. 媒体存不进去时对话照常(它是记忆增强,不是对话必需品)
// 2. 引用转移期间内容始终可读(先挂后销,不留归零窗口)
// 3. mediaStore 为 nil 时全链路静默跳过,行为与本特性上线前一致
func newTestAgentWithMedia(t *testing.T) (*Agent, *media.Store) {
t.Helper()
dir := t.TempDir()
ms, err := media.New(filepath.Join(dir, "media"), 0)
if err != nil {
t.Fatalf("media.New: %v", err)
}
t.Cleanup(func() { ms.Close() })
emb := memory.NewStaticEmbedder()
a := &Agent{
mediaStore: ms,
context: NewRelevanceContext(filepath.Join(dir, "context.json"), emb),
}
a.context.SetMediaStore(ms)
return a, ms
}
// imageBlockURL 造一个带指定 URL 的图片块。
// 名字带 URL 后缀是为了不与 modalfallback_test.go 里固定用 testPNG 的
// imageBlock() 撞名——两者用途不同:那个验回退链,这个验入库。
func imageBlockURL(dataURL string) agentAPI.ContentBlock {
return agentAPI.ContentBlock{
Type: "image_url",
ImageURL: &agentAPI.ImageURL{URL: dataURL, Detail: "auto"},
}
}
func TestCaptureBlockMedia_StoresDataURL(t *testing.T) {
a, ms := newTestAgentWithMedia(t)
raw := []byte{0x89, 'P', 'N', 'G', 1, 2, 3}
blocks := []agentAPI.ContentBlock{
{Type: "text", Text: "看这张图"},
imageBlockURL(media.DataURL("image/png", raw)),
}
digests := a.captureBlockMedia(blocks, "multimodal_see_picture")
if len(digests) != 1 {
t.Fatalf("应捕获 1 个媒体,实际 %d", len(digests))
}
got, err := ms.Get(digests[0])
if err != nil {
t.Fatalf("回读失败: %v", err)
}
if string(got) != string(raw) {
t.Fatal("内容不一致")
}
it, _ := ms.Stat(digests[0])
if it.MIME != "image/png" || it.Tool != "multimodal_see_picture" || it.Kind != media.KindImage {
t.Fatalf("元数据不对: %+v", it)
}
}
func TestCaptureBlockMedia_SkipsHTTPURL(t *testing.T) {
// http(s) URL 拿不到字节就无法内容寻址;"下载它再存"会把一次对话
// 变成一次网络请求超时、鉴权、SSRF 全来了),不在本层解决。
a, _ := newTestAgentWithMedia(t)
blocks := []agentAPI.ContentBlock{
imageBlockURL("https://example.com/x.png"),
}
if d := a.captureBlockMedia(blocks, "t"); len(d) != 0 {
t.Fatalf("http URL 不该被捕获,实际 %d 个", len(d))
}
}
func TestCaptureBlockMedia_NilStoreIsNoop(t *testing.T) {
// mediaStore 未启用时全链路静默跳过,不能 panic 也不能报错——
// 行为必须与本特性上线前完全一致。
a := &Agent{}
blocks := []agentAPI.ContentBlock{imageBlockURL(media.DataURL("image/png", []byte("x")))}
if d := a.captureBlockMedia(blocks, "t"); d != nil {
t.Fatalf("nil store 应返回 nil实际 %v", d)
}
a.stageMediaDigests("deadbeef")
if got := a.drainMediaDigests(); len(got) != 1 {
t.Fatal("stage/drain 不依赖 store应正常工作")
}
// bindEventMedia 对 nil store 也必须安全
evt := &ContextEvent{}
a.bindEventMedia(evt, []string{"deadbeef"})
if len(evt.Media) != 0 || evt.ID != "" {
t.Fatalf("nil store 时不该改动事件: %+v", evt)
}
if s := a.mediaSummaryForEvent([]string{"deadbeef"}); s != "" {
t.Fatalf("nil store 时摘要应为空,得到 %q", s)
}
}
func TestCaptureBlockMedia_AudioAndVideo(t *testing.T) {
a, ms := newTestAgentWithMedia(t)
blocks := []agentAPI.ContentBlock{
imageBlockURL(media.DataURL("image/jpeg", []byte("frame"))),
{Type: "audio_url", AudioURL: &agentAPI.AudioURL{URL: media.DataURL("audio/wav", []byte("sound"))}},
}
digests := a.captureBlockMedia(blocks, "multimodal_see_video")
if len(digests) != 2 {
t.Fatalf("应捕获 2 个,实际 %d", len(digests))
}
kinds := map[media.Kind]int{}
for _, d := range digests {
it, err := ms.Stat(d)
if err != nil {
t.Fatal(err)
}
kinds[it.Kind]++
}
if kinds[media.KindImage] != 1 || kinds[media.KindAudio] != 1 {
t.Fatalf("大类归属不对: %v", kinds)
}
}
func TestStageDrainMediaDigests(t *testing.T) {
a, _ := newTestAgentWithMedia(t)
a.stageMediaDigests("a", "b")
a.stageMediaDigests("c")
got := a.drainMediaDigests()
if len(got) != 3 {
t.Fatalf("应累积 3 个,实际 %d", len(got))
}
// drain 后必须清空——否则下一轮对话会把上一轮的媒体又挂一遍
if again := a.drainMediaDigests(); again != nil {
t.Fatalf("drain 后应为空,实际 %v", again)
}
}
func TestBindEventMedia_CreatesIDAndRefs(t *testing.T) {
a, ms := newTestAgentWithMedia(t)
d, err := ms.Put([]byte("img"), media.Item{MIME: "image/png"})
if err != nil {
t.Fatal(err)
}
evt := &ContextEvent{Timestamp: time.Now(), Source: "qq", Input: "看图"}
a.bindEventMedia(evt, []string{d})
if evt.ID == "" {
t.Fatal("应懒生成事件 ID")
}
if len(evt.Media) != 1 || evt.Media[0] != d {
t.Fatalf("事件应记住 digest: %+v", evt.Media)
}
// 双向落地CAS 侧也要知道谁在引用,否则 GC 会误删
it, _ := ms.Stat(d)
if it.RefCount != 1 {
t.Fatalf("引用计数应为 1实际 %d", it.RefCount)
}
refs, _ := ms.Refs(media.OwnerContext, evt.ID)
if len(refs) != 1 {
t.Fatalf("media_refs 应有 1 条,实际 %d", len(refs))
}
}
func TestBindEventMedia_LazyIDOnlyWhenNeeded(t *testing.T) {
// 绝大多数对话没有媒体,不该为它们都生成 ID 塞进 context.json
a, _ := newTestAgentWithMedia(t)
evt := &ContextEvent{Input: "纯文本"}
a.bindEventMedia(evt, nil)
if evt.ID != "" {
t.Fatalf("无媒体时不该生成 ID得到 %q", evt.ID)
}
}
func TestMediaSummary_DescriptionIsThePersistentMemory(t *testing.T) {
// 方案 C 的核心描述文本才是持久语义记忆blob 只是缓存。
// blob 被容量 GC 淘汰后,描述仍留在 L0/L2/L3 的文本里可被检索。
a, ms := newTestAgentWithMedia(t)
d, _ := ms.Put([]byte("img"), media.Item{MIME: "image/png"})
if s := a.mediaSummaryForEvent([]string{d}); s == "" {
t.Fatal("未描述项也应产出一行(标注未描述)")
}
ms.Describe(d, "一张紫蓝红三色带图", "visionllm")
s := a.mediaSummaryForEvent([]string{d})
if s == "" {
t.Fatal("应产出摘要")
}
if !strings.Contains(s, "紫蓝红三色带图") {
t.Fatalf("摘要应含描述文本: %q", s)
}
if !strings.Contains(s, "image/png") {
t.Fatalf("摘要应含 MIME 标注: %q", s)
}
}
func TestPrune_TransfersMediaRefsToDocument(t *testing.T) {
// L0→L2 归档:媒体引用从 context 事件转到归档文档,
// 且转移期间内容必须始终可读(先挂后销,不留归零窗口)。
dir := t.TempDir()
ms, err := media.New(filepath.Join(dir, "media"), 0)
if err != nil {
t.Fatal(err)
}
defer ms.Close()
emb := memory.NewStaticEmbedder()
docStore := document.NewStore(filepath.Join(dir, "docs"))
if err := docStore.Start(); err != nil {
t.Fatal(err)
}
rc := NewRelevanceContext(filepath.Join(dir, "context.json"), emb)
rc.SetMediaStore(ms)
a := &Agent{mediaStore: ms, context: rc}
// 造一张被引用的图,挂到一条会被淘汰的老事件上
payload := []byte("archived-image")
d, _ := ms.Put(payload, media.Item{MIME: "image/png"})
oldEvt := ContextEvent{
Timestamp: time.Now().Add(-time.Hour),
Source: "qq",
Input: "很久以前的一张图",
}
a.bindEventMedia(&oldEvt, []string{d})
oldEvtID := oldEvt.ID
rc.Append(oldEvt)
// 再塞满 12 条新事件,逼 Prune 把老事件淘汰
// Prune 保护最近 10 条topK 传 5 使候选全部进归档)
for i := 0; i < 12; i++ {
rc.Append(ContextEvent{
Timestamp: time.Now().Add(time.Duration(i) * time.Second),
Source: "qq",
Input: "无关内容",
})
}
archived := rc.Prune("完全不相关的查询", 5, docStore)
if archived == 0 {
t.Fatal("应有事件被归档")
}
// 关键断言:内容仍可读(引用被转走而非归零后被清)
got, err := ms.Get(d)
if err != nil {
t.Fatalf("归档后内容应仍可读: %v", err)
}
if string(got) != string(payload) {
t.Fatal("内容被改")
}
it, err := ms.Stat(d)
if err != nil {
t.Fatal(err)
}
if it.RefCount < 1 {
t.Fatalf("引用应转移而非归零,实际 refcount=%d", it.RefCount)
}
// 原 context 引用应已注销
if refs, _ := ms.Refs(media.OwnerContext, oldEvtID); len(refs) != 0 {
t.Fatalf("原事件引用应已注销,仍有 %d 条", len(refs))
}
// 应挂到某个 document owner 上
var docOwned bool
docs := docStore.RecentDocs(10)
for _, doc := range docs {
if refs, _ := ms.Refs(media.OwnerDocument, doc.ID); len(refs) > 0 {
docOwned = true
break
}
}
if !docOwned {
t.Fatal("引用应已挂到归档文档上")
}
}
func TestPrune_NilMediaStoreStillArchives(t *testing.T) {
// 媒体存储未启用时归档链路必须照常工作
dir := t.TempDir()
emb := memory.NewStaticEmbedder()
docStore := document.NewStore(filepath.Join(dir, "docs"))
if err := docStore.Start(); err != nil {
t.Fatal(err)
}
rc := NewRelevanceContext(filepath.Join(dir, "context.json"), emb)
// 刻意不 SetMediaStore
for i := 0; i < 15; i++ {
rc.Append(ContextEvent{
Timestamp: time.Now().Add(time.Duration(i) * time.Second),
Source: "qq",
Input: "内容",
})
}
if n := rc.Prune("查询", 5, docStore); n == 0 {
t.Fatal("无媒体存储时归档也应正常")
}
}
func TestContextEvent_MediaFieldRoundTrip(t *testing.T) {
// context.json 加字段必须向后兼容:存量文件读回来 Media 为空、ID 为空,
// 不影响任何既有行为。
dir := t.TempDir()
path := filepath.Join(dir, "context.json")
// 写一份"存量格式"(无 id / media 字段)
legacy := `[{"timestamp":"2026-09-04T10:00:00Z","source":"qq","input":"老数据","response":"回复"}]`
if err := os.WriteFile(path, []byte(legacy), 0644); err != nil {
t.Fatal(err)
}
emb := memory.NewStaticEmbedder()
rc := NewRelevanceContext(path, emb)
if rc.Len() != 1 {
t.Fatalf("应读回 1 条,实际 %d", rc.Len())
}
// 新写入带媒体的事件,再读回
ms, err := media.New(filepath.Join(dir, "media"), 0)
if err != nil {
t.Fatal(err)
}
defer ms.Close()
a := &Agent{mediaStore: ms, context: rc}
d, _ := ms.Put([]byte("img"), media.Item{MIME: "image/png"})
evt := ContextEvent{Timestamp: time.Now(), Source: "qq", Input: "新数据"}
a.bindEventMedia(&evt, []string{d})
rc.Append(evt)
rc.flush()
rc2 := NewRelevanceContext(path, emb)
if rc2.Len() != 2 {
t.Fatalf("应有 2 条,实际 %d", rc2.Len())
}
}

View File

@ -344,6 +344,11 @@ func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response stri
}
}
if len(blocks) > 0 {
// 先落进 CAS无论下面走直视还是回退转写媒体本体都该进记忆。
// 不存的后果是 ToolResultItem.Output 只剩那句
// "[已将图片注入后续对话] /tmp/x.png",文件一删线索就断了。
a.stageMediaDigests(a.captureBlockMedia(blocks, tc.Name)...)
if native, fallbackText := a.prepareToolBlocks(blocks); len(native) > 0 {
// 能直视:另起一条 user message 承载媒体,并补一句来源说明,
// 否则模型会把它当成用户新发的图而不是工具拉回来的。

View File

@ -544,6 +544,7 @@ func (r *ConfigRegistry) seedDBValues(dataDir string) {
set("core.memory.graph", filepath.Join(dataDir, "memory", "graph.db"))
set("core.memory.text", filepath.Join(dataDir, "memory", "text"))
set("core.memory.documents", filepath.Join(dataDir, "memory", "documents"))
set("core.memory.media.dir", filepath.Join(dataDir, "memory", "media"))
set("core.knowledge.path", filepath.Join(dataDir, "knowledge"))
set("core.log.path", filepath.Join(dataDir, "log"))
@ -648,6 +649,11 @@ func (r *ConfigRegistry) seedCoreDefs(dataDir string) {
reg(ConfigDef{Key: "core.memory.graph", Default: filepath.Join(dataDir, "memory", "graph.db"), Type: "string", DisplayName: "图数据库路径", Description: "长期记忆(图数据库)存储路径", Category: "paths"})
reg(ConfigDef{Key: "core.memory.text", Default: filepath.Join(dataDir, "memory", "text"), Type: "string", DisplayName: "文本记忆路径", Description: "短期文本记忆存储目录", Category: "paths"})
reg(ConfigDef{Key: "core.memory.documents", Default: filepath.Join(dataDir, "memory", "documents"), Type: "string", DisplayName: "文档记忆路径", Description: "文档记忆存储目录", Category: "paths"})
reg(ConfigDef{Key: "core.memory.media.enabled", Default: "true", Type: "bool", DisplayName: "媒体记忆", Description: "把对话里出现的图片/音频按内容摘要sha256落盘去重记忆各层只记 digest。关闭后媒体仅在当前对话内可见下一轮起只剩路径或 alt 文本", Category: "memory"})
reg(ConfigDef{Key: "core.memory.media.dir", Default: filepath.Join(dataDir, "memory", "media"), Type: "string", DisplayName: "媒体存储路径", Description: "媒体内容寻址存储目录(内含 media.db 与 blobs/", Category: "paths"})
reg(ConfigDef{Key: "core.memory.media.max_mb", Default: "2048", Type: "int", DisplayName: "媒体容量上限(MB)", Description: "超限时按最后访问时间淘汰无引用的媒体;被记忆引用的内容即使超限也不会删除(宁可超限也不断引用)。描述文本不受此限,淘汰后仍可检索", Category: "memory"})
reg(ConfigDef{Key: "core.memory.media.gc_interval", Default: "6h", Type: "duration", DisplayName: "媒体 GC 间隔", Description: "清理无引用媒体的周期0 表示不自动清理", Category: "memory"})
reg(ConfigDef{Key: "core.memory.media.gc_min_age", Default: "1h", Type: "duration", DisplayName: "媒体 GC 保护期", Description: "新入库媒体在此时长内不被清理。刚落盘还没来得及挂到记忆上的项引用计数也是 0靠这个保护期避免被误删", Category: "memory"})
reg(ConfigDef{Key: "core.knowledge.path", Default: filepath.Join(dataDir, "knowledge"), Type: "string", DisplayName: "知识库路径", Description: "知识库存储目录", Category: "paths"})
reg(ConfigDef{Key: "core.log.path", Default: filepath.Join(dataDir, "log"), Type: "string", DisplayName: "日志目录", Description: "日志文件输出目录", Category: "paths"})

View File

@ -32,8 +32,22 @@ import (
_ "github.com/mattn/go-sqlite3"
)
// OwnerKind 是 media_refs.owner_kind 的取值,对应引用媒体的记忆层。
//
// 定义为常量而不是让调用方写字符串owner_kind 进了主键,
// 拼错一个字符就是一条永远对不上的孤立引用AddRef 不会报错,
// DropOwner 也永远匹配不到)。
const (
// OwnerContext 是 L0 对话上下文事件ContextEvent.ID
OwnerContext = "context"
// OwnerDocument 是 L2 文档记忆Doc.ID
OwnerDocument = "document"
// OwnerGraphSentence 是 L3 图库句子节点sentences.id
OwnerGraphSentence = "graph_sentence"
)
// Kind 是媒体大类。刻意只分三类而不细分具体格式:
// 记忆检索关心的是"这是张图还是段音频",具体编码交给 MIME 字段。
// 记忆检索关心的是这是张图还是段音频,具体编码交给 MIME 字段。
type Kind string
const (