mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-10-15 12:15:16 +00:00
块自带负载(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 空实现,待⑥删包)
433 lines
12 KiB
Go
433 lines
12 KiB
Go
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)
|