Files
HomeAgent/internal/sdk/memory_impl.go
JianFeeeee 86572d134d refactor(memory)!: 块 union 化,彻底淘汰 CAS 第一阶段(生产代码)
块自带负载(MemoryBlock.Data),删 PayloadDigest;检索纯向量,媒体随块
同生灭。生产代码全绿;CAS 专属测试将在下一提交随判据重写。

- memory: Data BLOB + migration9;blockColumns 不含 data(召回只比向量);
  MediaBlockID 内容派生 ID;BlockByIDPrefix/BlockData 按需加载
- sdk: memory_impl 去 media.Store;MemoryBlockView PayloadDigest→HasData
- core: mediaref/graphmedia/crossmodal/medialoop 全部改块语义;
  captureBlockMedia 直接产块;resolveMediaRefs 按块 ID 解析
- knowledge: 媒体字节随条目目录自带(File+Data),删 mediaGet/mediaPut
- webui: graphBlockView/pulse payload_digest→has_data
- bootstrap/registry: 去 MediaStore 接线(SetMediaStore 空实现,待⑥删包)
2026-10-12 10:13:36 +08:00

433 lines
12 KiB
Go
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.

package sdk
import (
"bytes"
"fmt"
"log"
"sync/atomic"
"time"
"github.com/JianFeeeee/HomeAgent/internal/memory"
doc "github.com/JianFeeeee/HomeAgent/internal/memory/document"
"github.com/JianFeeeee/HomeAgent/internal/memory/text"
)
// 插件侧记忆接口的实现(SDK 桥接层)。
//
// 这一层原先的缺陷是**静默裁字段**:插件把 Triple / Doc 交进来,包装层只挑
// 自己认识的几个字段转成内部结构,其余丢弃且不报错。
//
// 现在的规则:内部结构有的字段一律透传;媒体一律变成一等记忆块。
//
// ★ 2026-10-11:块自带负载(media.Store/CAS 已彻底淘汰)。
//
// 一个 MemoryBlock 要么是文本块(负载在 Text),要么是媒体块(负载在 Data)。
// 不再有块外的字节存储层,因此本层也不再需要 media.Store 句柄 ——
// 附件字节直接进块的 Data,随块同生同灭。
// sdkBlockSeq 保证块 ID 全局唯一:Graph 的 memory_blocks 以 id 为主键,
// 不同文档里序号相同的块会在 L2→L3 迁移时相互覆盖。
var sdkBlockSeq int64
func sdkNewBlockID() string {
return fmt.Sprintf("blk_%d_%d", time.Now().UnixNano(), atomic.AddInt64(&sdkBlockSeq, 1))
}
// sdkBlockModality 按 MIME 大类推断块的模态。
func sdkBlockModality(mime string) memory.BlockModality {
switch {
case bytes.HasPrefix([]byte(mime), []byte("image/")):
return memory.BlockImage
case bytes.HasPrefix([]byte(mime), []byte("video/")):
return memory.BlockVideo
case bytes.HasPrefix([]byte(mime), []byte("audio/")):
return memory.BlockAudio
default:
return memory.BlockText
}
}
// sdkBlocksFromAttachments 把附件直接变成一等记忆块(字节进块的 Data)。
//
// 去重按内容字节:同一份内容被多次提交时只留一个块(块的身份仍是内容,
// 只是这个"内容身份"不再需要一层独立的寻址存储来承载)。
func sdkBlocksFromAttachments(atts []MediaAttachment, tool string) []memory.MemoryBlock {
if len(atts) == 0 {
return nil
}
seen := map[string]bool{}
var blocks []memory.MemoryBlock
for _, a := range atts {
if len(a.Data) == 0 {
continue
}
key := string(a.Data)
if seen[key] {
continue
}
seen[key] = true
mime := a.MIME
if mime == "" {
mime = "application/octet-stream"
}
blocks = append(blocks, memory.MemoryBlock{
ID: sdkNewBlockID(),
Modality: sdkBlockModality(mime),
Data: a.Data,
MIME: mime,
Size: int64(len(a.Data)),
Source: tool,
CreatedAt: time.Now(),
})
}
return blocks
}
// ---------- 图记忆 ----------
type graphMemory struct {
db *memory.GraphDB
plugin string
}
func NewGraphMemory(db *memory.GraphDB) MemoryAPI { return &graphMemory{db: db} }
// NewGraphMemoryWithPlugin 创建带上报名的图记忆包装。
//
// 旧签名曾接 media.Store;块自带负载后不再需要它,故改名为 WithPlugin。
func NewGraphMemoryWithPlugin(plugin string, db *memory.GraphDB) MemoryAPI {
return &graphMemory{db: db, plugin: plugin}
}
func (m *graphMemory) Recall(query []string, depth int) ([]Entity, []Relation, error) {
if m.db == nil {
return nil, nil, nil
}
result, err := m.db.Recall(query, nil, depth, "")
if err != nil {
return nil, nil, err
}
entities := make([]Entity, len(result.Entities))
for i, e := range result.Entities {
entities[i] = Entity{Name: e.Name, Type: e.Type, MentionCount: e.MentionCount}
}
relations := make([]Relation, len(result.Relations))
for i, r := range result.Relations {
relations[i] = Relation{
SourceName: r.SourceName,
TargetName: r.TargetName,
RelationType: r.RelationType,
Confidence: r.Confidence,
}
}
return entities, relations, nil
}
// Commit 把插件的三元组写入图库,并把结构化媒体变成 L3 一等块。
//
// 媒体通过 sentence --contains--> block 结构边挂接,不读写任何正文 marker。
// 块自带字节负载,因此需要媒体元数据(Data/MIME)而不仅是一个 digest。
func (m *graphMemory) Commit(triples []Triple) error {
if m.db == nil {
return nil
}
ts := make([]memory.Triple, 0, len(triples))
for _, t := range triples {
mt := memory.Triple{
Subject: t.Subject,
Relation: t.Relation,
Object: t.Object,
Confidence: t.Confidence,
SubjectType: t.SubjectType,
ObjectType: t.ObjectType,
SentenceText: t.SentenceText,
MediaDigests: t.MediaDigests,
}
ts = append(ts, mt)
}
sentenceIDs, _, _, err := m.db.CommitWithMedia(ts, "plugin", 0)
if err != nil {
return err
}
m.bindSentences(sentenceIDs, ts)
return nil
}
// bindSentences 把每个三元组显式携带的媒体变成 L3 一等记忆块,
// 并以 sentence --contains--> block 结构边关联。
//
// ★ sentenceIDs 是「原句文本 → 原句**块 ID**」(原为 sentences 表行号)。
// ★ 媒体负载:Triple.MediaDigests 只带短前缀(跨进程/跨层的轻量引用),
// 这里按前缀在**本进程已建的块**里找不到字节时如实记日志,不静默造空块。
func (m *graphMemory) bindSentences(sentenceIDs map[string]string, triples []memory.Triple) {
if m.db == nil || len(sentenceIDs) == 0 {
return
}
bound := 0
for _, t := range triples {
if len(t.MediaDigests) == 0 {
continue
}
sid := sentenceIDs[t.SentenceText]
if sid == "" {
continue
}
for _, ref := range t.MediaDigests {
// ref 可以是完整块 ID,也可以是前缀(插件跨进程只带短引用)。
// 媒体块 ID 是内容派生的 blk_media_<hash>,故前缀足以唯一定位。
b, ok, err := m.db.BlockByIDPrefix(ref)
if err != nil {
log.Printf("[sdk media] 插件 %s 查块 %s 失败: %v", m.plugin, ref, err)
continue
}
if !ok {
log.Printf("[sdk media] 插件 %s 的媒体引用 %s 无唯一对应块,跳过", m.plugin, ref)
continue
}
if err := m.db.PutMemoryBlocks([]memory.MemoryBlock{b}); err != nil {
log.Printf("[sdk media] 插件 %s 写入 L3 记忆块失败: %v", m.plugin, err)
continue
}
if err := m.db.AddMemoryBlockEdge("block", sid, "block", b.ID, "contains"); err != nil {
log.Printf("[sdk media] 插件 %s 建立句子→块边失败: %v", m.plugin, err)
continue
}
bound++
}
}
if bound > 0 {
log.Printf("[sdk media] 插件 %s 的三元组写入 %d 个 L3 记忆块", m.plugin, bound)
}
}
func (m *graphMemory) Introspect() (map[string]interface{}, error) {
if m.db == nil {
return map[string]interface{}{}, nil
}
return m.db.Introspect()
}
func (m *graphMemory) MergeEntities(source, target string) (int, error) {
if m.db == nil {
return 0, nil
}
return m.db.MergeEntities(source, target)
}
func (m *graphMemory) Purge(criteria map[string]string, mode string) (int, error) {
if m.db == nil {
return 0, nil
}
return m.db.Purge(criteria, mode)
}
func (m *graphMemory) GraphData() (map[string]interface{}, error) {
if m.db == nil {
return map[string]interface{}{}, nil
}
return m.db.GraphData()
}
// BlocksChangedSince 实现 MemoryAPI(星图轻量轮询)。
func (m *graphMemory) BlocksChangedSince(since time.Time, limit int) ([]MemoryBlockView, error) {
if m.db == nil {
return nil, nil
}
blocks, err := m.db.BlocksChangedSince(since, limit)
if err != nil {
return nil, err
}
out := make([]MemoryBlockView, 0, len(blocks))
for _, b := range blocks {
out = append(out, MemoryBlockView{
ID: b.ID,
Modality: string(b.Modality),
Text: b.Text,
HasData: len(b.Data) > 0,
MIME: b.MIME,
Source: b.Source,
Tool: b.Tool,
Scene: b.Scene,
CreatedAt: b.CreatedAt,
UpdatedAt: b.UpdatedAt,
})
}
return out, nil
}
// ---------- 文本记忆 ----------
type textMemoryImpl struct {
tm *text.Memory
plugin string
}
func NewTextMemory(tm *text.Memory) TextMemoryAPI { return &textMemoryImpl{tm: tm} }
// NewTextMemoryWithPlugin 创建带上报名的文本记忆包装。
func NewTextMemoryWithPlugin(plugin string, tm *text.Memory) TextMemoryAPI {
return &textMemoryImpl{tm: tm, plugin: plugin}
}
// Append 追加一条文本事件。
//
// 文本记忆是追加写 JSONL 的原始日志,只有字符串字段,没有块容器;
// 因此附件在这里无法结构化存下。不假装用文本标记承载媒体——
// 需要保存媒体请用文档记忆或图记忆(它们持有一等记忆块)。
func (m *textMemoryImpl) Append(evt TextEvent) error {
if m.tm == nil {
return nil
}
if len(evt.Attachments) > 0 {
log.Printf("[sdk media] 插件 %s 向文本记忆追加了 %d 份附件,已忽略:"+
"文本层是字符串日志,不具备块存储;请改用文档/图记忆保存媒体", m.plugin, len(evt.Attachments))
}
return m.tm.Append(text.Event{
Timestamp: evt.Timestamp, Source: evt.Role, Input: evt.Content, AgentID: evt.Channel,
})
}
func (m *textMemoryImpl) RecentEvents(n int) ([]TextEvent, error) {
if m.tm == nil {
return nil, nil
}
got, err := m.tm.RecentEvents(n)
if err != nil {
return nil, err
}
out := make([]TextEvent, len(got))
for i, e := range got {
out[i] = TextEvent{
Role: e.Source, Content: e.Input, Timestamp: e.Timestamp, Channel: e.AgentID,
}
}
return out, nil
}
func (m *textMemoryImpl) Stats() map[string]interface{} {
if m.tm == nil {
return map[string]interface{}{}
}
return m.tm.Stats()
}
// ---------- 文档记忆(知识库) ----------
type docMemoryImpl struct {
ds *doc.Store
plugin string
}
func NewDocMemory(ds *doc.Store) DocMemoryAPI { return &docMemoryImpl{ds: ds} }
// NewDocMemoryWithPlugin 创建带上报名的文档记忆包装。
func NewDocMemoryWithPlugin(plugin string, ds *doc.Store) DocMemoryAPI {
return &docMemoryImpl{ds: ds, plugin: plugin}
}
// Query 检索文档,并从文档持有的一等块补齐媒体元数据。
//
// 只返回块的 MIME/ID,不返回字节:字节留在这条文档的块里,
// 需要时按块 ID 单取(避免把整批媒体读进内存)。
func (m *docMemoryImpl) Query(text string, topK int) []*Doc {
if m.ds == nil {
return nil
}
got := m.ds.Query(text, topK)
out := make([]*Doc, len(got))
for i, d := range got {
out[i] = &Doc{ID: d.ID, Title: d.Summary, Content: d.Content}
m.fillMedia(out[i], d)
}
return out
}
// fillMedia 填充文档的媒体字段。
//
// 唯一的来源是文档直接持有的一等记忆块:媒体不靠正文标记、
// 也不靠任何生成的描述文本。
func (m *docMemoryImpl) fillMedia(out *Doc, d *doc.Doc) {
if d == nil {
return
}
for _, b := range d.Blocks {
if b.Modality == memory.BlockText && len(b.Data) == 0 {
continue
}
out.MediaDigests = append(out.MediaDigests, b.ID)
out.Attachments = append(out.Attachments, MediaAttachment{
Digest: b.ID,
MIME: b.MIME,
})
}
}
// appendBlocks 把新的块追加到已有块之后(按内容去重)。
func appendBlocks(existing []memory.MemoryBlock, add []memory.MemoryBlock) []memory.MemoryBlock {
seen := make(map[string]bool, len(existing))
for _, b := range existing {
seen[string(b.Data)] = true
}
for _, b := range add {
k := string(b.Data)
if k != "" && seen[k] {
continue
}
existing = append(existing, b)
if k != "" {
seen[k] = true
}
}
return existing
}
// Insert 写入文档。
func (m *docMemoryImpl) Insert(d *Doc) error { return m.InsertWithMedia(d, nil) }
// InsertWithMedia 写入文档并关联媒体。
//
// 媒体直接成为文档持有的一等记忆块(字节在块的 Data)。
// 不往正文写 marker——文档向量会融合这些块的媒体向量(同一统一空间),
// 图片按自己的向量被召回。
func (m *docMemoryImpl) InsertWithMedia(d *Doc, attachments []MediaAttachment) error {
if m.ds == nil || d == nil {
return nil
}
target := &doc.Doc{ID: d.ID, Summary: d.Title, Content: d.Content}
if target.Source == "" {
target.Source = "plugin:" + m.plugin
}
target.Blocks = appendBlocks(target.Blocks,
sdkBlocksFromAttachments(attachments, "plugin_doc:"+m.plugin))
if err := m.ds.Insert(target); err != nil {
return err
}
d.ID = target.ID
return nil
}
// Remove 删除文档。
//
// 块随文档一起消失,块里的字节也随之消失——无需引用计数或外部 GC:
// 字节就在块里,块没了字节就没了。
func (m *docMemoryImpl) Remove(id string) {
if m.ds == nil {
return
}
m.ds.Remove(id)
}
func (m *docMemoryImpl) Stats() map[string]interface{} {
if m.ds == nil {
return map[string]interface{}{}
}
return m.ds.Stats()
}
var _ MemoryAPI = (*graphMemory)(nil)
var _ TextMemoryAPI = (*textMemoryImpl)(nil)
var _ DocMemoryAPI = (*docMemoryImpl)(nil)