Files
HomeAgent/internal/memory/block.go
JianFeeeee e44164f5bd feat(memory): Graph 原生一等记忆块节点与结构边(§13.12)
媒体/文本在 L3 不再是正文标记反解出的代理实体,而是带原生
modality/digest/MIME/size/vector/fingerprint 的 memory_blocks 节点;
contains/depicts/derived_from 等语义边落在 memory_block_edges,
端点必须是真实图节点(block/entity/sentence),不复用 owner 字符串。

本步只建立存储与查询能力,不接入 media_refs,也不改变 L0/L2 路径。
2026-09-11 10:08:59 +08:00

220 lines
7.3 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 memory
import (
"database/sql"
"encoding/json"
"fmt"
"time"
)
// BlockModality 是一等记忆块的原生模态。
type BlockModality string
const (
BlockText BlockModality = "text"
BlockImage BlockModality = "image"
BlockVideo BlockModality = "video"
BlockAudio BlockModality = "audio"
)
// MemoryBlock 是 Context、Document、Graph 三层共同使用的记忆块值。
//
// 它不携带 Layer、Owner 或 RefCount块当前由哪个层的容器持有哪个层就是
// 唯一事实源。Context→Document→Graph 迁移的是这个值本身,不建立平行保活账本。
// PayloadDigest 仅用于定位内容寻址的原始字节,不表示另一条逻辑记忆。
type MemoryBlock struct {
ID string `json:"id"`
Modality BlockModality `json:"modality"`
Text string `json:"text,omitempty"`
PayloadDigest string `json:"payload_digest,omitempty"`
MIME string `json:"mime,omitempty"`
Size int64 `json:"size,omitempty"`
Width int `json:"width,omitempty"`
Height int `json:"height,omitempty"`
Vector []float64 `json:"vector,omitempty"`
Fingerprint string `json:"fingerprint,omitempty"`
Source string `json:"source,omitempty"`
Tool string `json:"tool,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// MemoryBlockEdge 是 L3 中连接一等记忆节点的结构化语义边。
// source/target kind 当前允许 block、entity、sentence。
type MemoryBlockEdge struct {
ID int64 `json:"id"`
SourceKind string `json:"source_kind"`
SourceID string `json:"source_id"`
TargetKind string `json:"target_kind"`
TargetID string `json:"target_id"`
Type string `json:"type"`
CreatedAt time.Time `json:"created_at"`
}
func validBlockModality(modality BlockModality) bool {
return modality == BlockText || modality == BlockImage || modality == BlockVideo || modality == BlockAudio
}
// PutMemoryBlocks 将完成 L2→L3 迁移的块写成 GraphDB 原生节点。
// 调用方只有在本事务成功后才能从 Document 删除这些块。
func (g *GraphDB) PutMemoryBlocks(blocks []MemoryBlock) error {
if len(blocks) == 0 {
return nil
}
g.mu.Lock()
defer g.mu.Unlock()
tx, err := g.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
for _, block := range blocks {
if block.ID == "" {
return fmt.Errorf("memory block id is required")
}
if !validBlockModality(block.Modality) {
return fmt.Errorf("memory block %s has invalid modality %q", block.ID, block.Modality)
}
vectorJSON, err := json.Marshal(block.Vector)
if err != nil {
return fmt.Errorf("marshal memory block %s vector: %w", block.ID, err)
}
now := time.Now()
if block.CreatedAt.IsZero() {
block.CreatedAt = now
}
_, err = tx.Exec(`INSERT INTO memory_blocks (
id, modality, text_content, payload_digest, mime, size, width, height,
vector, fingerprint, source, tool, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
modality = excluded.modality,
text_content = excluded.text_content,
payload_digest = excluded.payload_digest,
mime = excluded.mime,
size = excluded.size,
width = excluded.width,
height = excluded.height,
vector = excluded.vector,
fingerprint = excluded.fingerprint,
source = excluded.source,
tool = excluded.tool,
updated_at = excluded.updated_at`,
block.ID, block.Modality, block.Text, block.PayloadDigest, block.MIME,
block.Size, block.Width, block.Height, string(vectorJSON), block.Fingerprint,
block.Source, block.Tool, block.CreatedAt, now)
if err != nil {
return fmt.Errorf("put memory block %s: %w", block.ID, err)
}
}
return tx.Commit()
}
// MemoryBlocks 查询 Graph 层实际持有的一等记忆节点。
func (g *GraphDB) MemoryBlocks() ([]MemoryBlock, error) {
g.mu.RLock()
defer g.mu.RUnlock()
rows, err := g.db.Query(`SELECT id, modality, text_content, payload_digest, mime,
size, width, height, vector, fingerprint, source, tool, created_at, updated_at
FROM memory_blocks ORDER BY created_at, id`)
if err != nil {
return nil, err
}
defer rows.Close()
var blocks []MemoryBlock
for rows.Next() {
var block MemoryBlock
var vectorJSON string
if err := rows.Scan(&block.ID, &block.Modality, &block.Text, &block.PayloadDigest,
&block.MIME, &block.Size, &block.Width, &block.Height, &vectorJSON,
&block.Fingerprint, &block.Source, &block.Tool, &block.CreatedAt,
&block.UpdatedAt); err != nil {
return nil, err
}
if vectorJSON != "" && vectorJSON != "null" {
if err := json.Unmarshal([]byte(vectorJSON), &block.Vector); err != nil {
return nil, fmt.Errorf("decode memory block %s vector: %w", block.ID, err)
}
}
blocks = append(blocks, block)
}
return blocks, rows.Err()
}
func validGraphNodeKind(kind string) bool {
return kind == "block" || kind == "entity" || kind == "sentence"
}
func graphNodeExists(tx *sql.Tx, kind, id string) (bool, error) {
var n int
var err error
switch kind {
case "block":
err = tx.QueryRow(`SELECT COUNT(*) FROM memory_blocks WHERE id = ?`, id).Scan(&n)
case "entity":
err = tx.QueryRow(`SELECT COUNT(*) FROM entities WHERE CAST(id AS TEXT) = ?`, id).Scan(&n)
case "sentence":
err = tx.QueryRow(`SELECT COUNT(*) FROM sentences WHERE CAST(id AS TEXT) = ?`, id).Scan(&n)
default:
return false, fmt.Errorf("invalid graph node kind %q", kind)
}
return n == 1, err
}
// AddMemoryBlockEdge 建立 contains、depicts、derived_from 等原生图边。
// 端点必须是真实 Graph 节点,不能用 owner 字符串伪装关系。
func (g *GraphDB) AddMemoryBlockEdge(sourceKind, sourceID, targetKind, targetID, edgeType string) error {
if !validGraphNodeKind(sourceKind) || !validGraphNodeKind(targetKind) {
return fmt.Errorf("invalid memory block edge kinds %q -> %q", sourceKind, targetKind)
}
if sourceID == "" || targetID == "" || edgeType == "" {
return fmt.Errorf("memory block edge endpoints and type are required")
}
g.mu.Lock()
defer g.mu.Unlock()
tx, err := g.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
for _, endpoint := range []struct{ kind, id string }{{sourceKind, sourceID}, {targetKind, targetID}} {
exists, err := graphNodeExists(tx, endpoint.kind, endpoint.id)
if err != nil {
return err
}
if !exists {
return fmt.Errorf("%s graph node %s does not exist", endpoint.kind, endpoint.id)
}
}
_, err = tx.Exec(`INSERT OR IGNORE INTO memory_block_edges
(source_kind, source_id, target_kind, target_id, edge_type)
VALUES (?, ?, ?, ?, ?)`, sourceKind, sourceID, targetKind, targetID, edgeType)
if err != nil {
return err
}
return tx.Commit()
}
func (g *GraphDB) MemoryBlockEdges() ([]MemoryBlockEdge, error) {
g.mu.RLock()
defer g.mu.RUnlock()
rows, err := g.db.Query(`SELECT id, source_kind, source_id, target_kind, target_id,
edge_type, created_at FROM memory_block_edges ORDER BY id`)
if err != nil {
return nil, err
}
defer rows.Close()
var edges []MemoryBlockEdge
for rows.Next() {
var edge MemoryBlockEdge
if err := rows.Scan(&edge.ID, &edge.SourceKind, &edge.SourceID, &edge.TargetKind,
&edge.TargetID, &edge.Type, &edge.CreatedAt); err != nil {
return nil, err
}
edges = append(edges, edge)
}
return edges, rows.Err()
}