Files
Cyrene_v2/internal/runtime/memory/store.go
T
AskaEth 61cf80d5ee
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
docs(comments): 注释中文化 · internal/runtime/memory(31 文件,纯替换)
- 只翻注释/文档字符串:820 增 / 820 删 严格 1:1;剥掉整行注释与行尾注释尾巴后,
  逐文件与 HEAD 比对「代码部分差异 = 0」;裁决/design 编号计数逐项与 HEAD 一致
- 保留可执行 SQL 样例、SQLite 报错原文、pgvector/HNSW/ANN/BLOB 等技术词原文
- 复核:go build 绿 + memory/projectindex/vector 三包测试绿;gofmt 仅剩 2 个既有未格式化文件(改动前即如此,未顺手修)
2026-10-04 13:32:54 +08:00

470 lines
16 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.
// memory 包实现 Cyrene v2 记忆子系统,规格见
// DevDocs/design/03-记忆.md。
//
// 它对外暴露三层:
//
// - Store:基于 SQLite 的长期记忆存储。它按角色隔离持久化记忆条目
// (design §0 layer ②、§4),经 AddScoped/ListScope/ListProject/
// DeleteScoped 施加三作用域访问规则(design §3),并提供
// Add/Get/ListByPersona/Delete,外加一条手动的
// 「记住」路径(design §1)。每条条目可携带 project_id,链到
// 项目索引层级。
// - WorkingMemory:每会话的消息滑动窗口加一份摘要
// (design §0 layer ①)。
// - Retriever:相关性(关键词 + 可选语义)× 衰减检索,带
// 溯源与上下文注入(design §2)。它可选接收
// vector.Index 作为持久化语义路径,以及 projectindex.Store,使
// 项目作用域的命中带出其链接的项目条目。
//
// 提取(design §1)经 Extractor 接口暴露,有两个
// 实现:ManualExtractor(规则式,记录一条显式的「记住 X」
// 指令)与 LLMExtractor(驱动 model.Adapter 对
// 一轮对话去噪去重,经 Extract 同步运行或经 ExtractAsync 异步运行)。
//
// M4 里程碑在此实现:schema 携带 decay_weight 与
// project_id,检索器接入可选的 embedding.Embedder / vector.Index
// 与项目索引存储,存储层施加 user/persona/project
// 作用域规则。除既有包外不需要任何外部依赖。
package memory
import (
"context"
"crypto/rand"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"strings"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
)
// Scope 分类一条长期记忆归属于谁。它与 Kind 正交,
// 两者都存于每条条目上(design §3 注)。
type Scope string
const (
// ScopeUser 是关于用户习惯的记忆,跨角色共享。
ScopeUser Scope = "user"
// ScopePersona 是角色的日常记忆;其他角色只读。
ScopePersona Scope = "persona"
// ScopeProject 是项目作用域记忆,人人可读可写。
ScopeProject Scope = "project"
)
// valid 报告 s 是否为已定义的作用域之一。
func (s Scope) valid() bool {
switch s {
case ScopeUser, ScopePersona, ScopeProject:
return true
default:
return false
}
}
// Kind 分类一条长期记忆条目的内容类别。
type Kind string
const (
// KindRelationship 是关于「你和我」的记忆:偏好、习惯、
// 约定(design §0「关系记忆」)。
KindRelationship Kind = "relationship"
// KindWorldEvent 是关于客观事实与世界事件的记忆
// (design §0「世界/事件记忆」)。
KindWorldEvent Kind = "world_event"
)
// valid 报告 k 是否为已定义的 kind 之一。
func (k Kind) valid() bool {
switch k {
case KindRelationship, KindWorldEvent:
return true
default:
return false
}
}
// MemoryItem 是一条长期记忆记录。零值不是合法
// 条目:PersonaID 与 Content 必填,Scope 与 Kind 由 Add 规范化到
// 其默认值。
type MemoryItem struct {
ID string
PersonaID string
RoleID string
ProjectID string
Scope Scope
Kind Kind
Content string
Tags []string
Confidence float64
SourceConversation string
CreatedAt time.Time
UpdatedAt time.Time
DecayWeight float64
}
// 区分 memory 包各类失败的哨兵错误。每个都在
// 调用点用 %w 包裹,调用方可用 errors.Is 判断。
var (
// ErrMissingPersona 在条目没有 PersonaID 时返回。
ErrMissingPersona = errors.New("memory: persona_id is required")
// ErrMissingContent 在条目没有内容时返回。
ErrMissingContent = errors.New("memory: content is required")
// ErrInvalidScope 在条目的 Scope 不是已定义作用域时返回。
ErrInvalidScope = errors.New("memory: invalid scope")
// ErrInvalidKind 在条目的 Kind 不是已定义 kind 时返回。
ErrInvalidKind = errors.New("memory: invalid kind")
// ErrNotFound 在条目 id 不存在时返回。
ErrNotFound = errors.New("memory: item not found")
// ErrNilDB 在 NewStore 收到 nil *sql.DB 时返回。
ErrNilDB = errors.New("memory: nil db")
)
// schema 默认值与时间窗口常量。
const (
defaultConfidence = 1.0
defaultDecayWeight = 1.0
)
// Store 是基于 *sql.DB 的长期记忆存储。
//
// 存储自己拥有 schema:NewStore 幂等地创建 memory_items 表及其
// 索引。并发交由 *sql.DB,按约定它可安全
// 并发使用,因此 Store 不再持锁。每个查询都
// 参数化(?)并显式列出列(无 SELECT *),读取带
// context.Cancel。
type Store struct {
db *sql.DB
log *slog.Logger
}
// rowScanner 同时被 *sql.Row 与 *sql.Rows 满足,因此单个 scanItem
// 辅助函数即可服务 Get 与 ListByPersona。
type rowScanner interface {
Scan(dest ...any) error
}
// NewStore 在 db 上构建 Store,确保 memory_items schema 存在。
//
// db 必须是存活且已迁移的连接(迁移框架本身由
// internal/db 拥有)。nil db 返回 ErrNilDB。log 可为 nil,此时
// 使用丢弃型 logger。ctx 控制 schema 创建,调用方必须
// 遵守。任何旧的变宽时间戳都就地
// 规范化(见 normalizeMemoryTimestamps),使文本顺序与时间顺序一致。
func NewStore(ctx context.Context, db *sql.DB, log *slog.Logger) (*Store, error) {
if db == nil {
return nil, ErrNilDB
}
log = logger.WithSource(discardLogger(log), "memory")
for _, ddl := range []string{
createMemoryItemsTable,
createMemoryItemsPersonaIndex,
createMemoryItemsPersonaCreatedIndex,
} {
if _, err := db.ExecContext(ctx, ddl); err != nil {
return nil, fmt.Errorf("memory: apply schema: %w", err)
}
}
if err := ensureProjectIDColumn(ctx, db); err != nil {
return nil, err
}
if err := normalizeMemoryTimestamps(ctx, db); err != nil {
return nil, err
}
return &Store{db: db, log: log}, nil
}
// Add 持久化 item,分配 id 与缺失的时间戳,并把
// Scope/Kind/Confidence/DecayWeight 规范化到默认值。它返回已落库的
// 条目,id 与时间戳均已填充。
func (s *Store) Add(ctx context.Context, item MemoryItem) (MemoryItem, error) {
if err := item.normalize(); err != nil {
return MemoryItem{}, err
}
var err error
if item, err = applyTimestamps(item); err != nil {
return MemoryItem{}, err
}
tagsJSON, err := json.Marshal(item.Tags)
if err != nil {
return MemoryItem{}, fmt.Errorf("memory: encode tags: %w", err)
}
_, err = s.db.ExecContext(ctx, `INSERT INTO memory_items
(id, persona_id, role_id, project_id, scope, kind, content, tags, confidence,
source_conversation, created_at, updated_at, decay_weight)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
item.ID, item.PersonaID, item.RoleID, item.ProjectID, string(item.Scope), string(item.Kind),
item.Content, string(tagsJSON), item.Confidence, item.SourceConversation,
formatTime(item.CreatedAt), formatTime(item.UpdatedAt), item.DecayWeight)
if err != nil {
return MemoryItem{}, fmt.Errorf("memory: insert item: %w", err)
}
return item, nil
}
// Get 返回给定 id 的条目。id 为空或
// 不存在时返回 ErrNotFound。
func (s *Store) Get(ctx context.Context, id string) (MemoryItem, error) {
if strings.TrimSpace(id) == "" {
return MemoryItem{}, ErrNotFound
}
row := s.db.QueryRowContext(ctx, `SELECT id, persona_id, role_id, project_id, scope, kind,
content, tags, confidence, source_conversation, created_at, updated_at,
decay_weight FROM memory_items WHERE id = ?`, id)
item, err := scanItem(row)
if errors.Is(err, sql.ErrNoRows) {
return MemoryItem{}, ErrNotFound
}
if err != nil {
return MemoryItem{}, fmt.Errorf("memory: get item: %w", err)
}
return item, nil
}
// ListByPersona 返回 personaID 的全部条目,最新的在前。rowid 打破
// created_at 并列,使同一时钟刻内写入的条目保持稳定的
// 最新优先顺序。它是角色隔离边界:属于
// 其他角色的条目绝不返回,因此角色之间从不共享各自的角色日常
// 命名空间(design §4)。它只是检索候选集的*自有*
// 那一半 —— 检索器在排名前把它与共享的用户/项目作用域
// 取并集(design §3/§4,见 Retriever.visibleCandidates)。
func (s *Store) ListByPersona(ctx context.Context, personaID string) ([]MemoryItem, error) {
if strings.TrimSpace(personaID) == "" {
return nil, ErrMissingPersona
}
rows, err := s.db.QueryContext(ctx, `SELECT id, persona_id, role_id, project_id, scope, kind,
content, tags, confidence, source_conversation, created_at, updated_at,
decay_weight FROM memory_items WHERE persona_id = ?
ORDER BY created_at DESC, rowid DESC`, personaID)
if err != nil {
return nil, fmt.Errorf("memory: list persona items: %w", err)
}
return scanItems(rows, "persona item")
}
// List 返回跨所有角色的每条记忆条目,最新的在前。rowid
// 打破 created_at 并列,使同一时钟刻内写入的条目保持
// 稳定的最新优先顺序。它是聚合/管理视图,枚举全部
// 已持久化记忆,不受角色隔离边界限制;必须
// 遵守隔离的调用方应改用 ListByPersona 或 ListScope。返回的
// 切片非 nil。
func (s *Store) List(ctx context.Context) ([]MemoryItem, error) {
rows, err := s.db.QueryContext(ctx, `SELECT id, persona_id, role_id, project_id, scope, kind,
content, tags, confidence, source_conversation, created_at, updated_at,
decay_weight FROM memory_items
ORDER BY created_at DESC, rowid DESC`)
if err != nil {
return nil, fmt.Errorf("memory: list items: %w", err)
}
return scanItems(rows, "item")
}
// scanItems 把每一行读入 MemoryItem 切片,关闭 rows 并用
// 操作名包裹 scan/遍历失败。成功时总是返回
// 非 nil、可能为空的切片。
func scanItems(rows *sql.Rows, op string) ([]MemoryItem, error) {
defer func() { _ = rows.Close() }()
items := make([]MemoryItem, 0)
for rows.Next() {
item, err := scanItem(rows)
if err != nil {
return nil, fmt.Errorf("memory: scan %s: %w", op, err)
}
items = append(items, item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("memory: iterate %s: %w", op, err)
}
return items, nil
}
// Delete 删除给定 id 的条目,id 为空或
// 没有删除任何行时返回 ErrNotFound。调用方只应传入经
// 角色作用域读取(ListByPersona/Get)得到的 id,使删除始终留在
// 调用方的角色边界内。
func (s *Store) Delete(ctx context.Context, id string) error {
if strings.TrimSpace(id) == "" {
return ErrNotFound
}
res, err := s.db.ExecContext(ctx, `DELETE FROM memory_items WHERE id = ?`, id)
if err != nil {
return fmt.Errorf("memory: delete item: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("memory: delete row count: %w", err)
}
if n == 0 {
return ErrNotFound
}
return nil
}
// RememberManual 为 content 文本写入一条长期记忆条目 —— design §1 中的
// 「记住 X」路径。条目默认取角色作用域,
// 并以满置信度记为关系记忆。返回已落库的条目。
func (s *Store) RememberManual(ctx context.Context, personaID, roleID, text string) (MemoryItem, error) {
item := MemoryItem{
PersonaID: personaID,
RoleID: roleID,
Scope: ScopePersona,
Kind: KindRelationship,
Content: strings.TrimSpace(text),
}
if item.Content == "" {
return MemoryItem{}, ErrMissingContent
}
return s.Add(ctx, item)
}
// normalize 填充默认的 Scope/Kind/Confidence/DecayWeight,然后校验。
// 它由 Add 调用,因此调用方可以省略取值,同时仍能
// 捕获显式非法值。
func (it *MemoryItem) normalize() error {
if it.Scope == "" {
it.Scope = ScopePersona
}
if it.Kind == "" {
it.Kind = KindRelationship
}
if it.Confidence == 0 {
it.Confidence = defaultConfidence
}
if it.DecayWeight == 0 {
it.DecayWeight = defaultDecayWeight
}
return it.validate()
}
// validate 检查必填与枚举字段。它是纯函数:不施加
// 默认值,因此也可用于拒绝调用方提供的值。
func (it *MemoryItem) validate() error {
if strings.TrimSpace(it.PersonaID) == "" {
return ErrMissingPersona
}
if strings.TrimSpace(it.Content) == "" {
return ErrMissingContent
}
if !it.Scope.valid() {
return ErrInvalidScope
}
if !it.Kind.valid() {
return ErrInvalidKind
}
return nil
}
// applyTimestamps 分配 id 与缺失的 CreatedAt/UpdatedAt 值。
// 仅在无法生成新 id 时返回错误。
func applyTimestamps(item MemoryItem) (MemoryItem, error) {
now := time.Now().UTC()
if item.ID == "" {
id, err := newID()
if err != nil {
return MemoryItem{}, err
}
item.ID = id
}
if item.CreatedAt.IsZero() {
item.CreatedAt = now
}
if item.UpdatedAt.IsZero() {
item.UpdatedAt = now
}
return item, nil
}
// scanItem 读取一行 memory_items。tags 以 JSON 数组字符串存储;
// 时间戳以定宽 UTC 字符串(timeLayout)存储,因此文本
// 比较即可按时间排序。
func scanItem(sc rowScanner) (MemoryItem, error) {
var (
it MemoryItem
scope, kind string
tagsJSON string
createdAt, updatedAt string
)
if err := sc.Scan(&it.ID, &it.PersonaID, &it.RoleID, &it.ProjectID, &scope, &kind,
&it.Content, &tagsJSON, &it.Confidence, &it.SourceConversation,
&createdAt, &updatedAt, &it.DecayWeight); err != nil {
return MemoryItem{}, err
}
it.Scope = Scope(scope)
it.Kind = Kind(kind)
if err := json.Unmarshal([]byte(tagsJSON), &it.Tags); err != nil {
return MemoryItem{}, fmt.Errorf("memory: decode tags: %w", err)
}
if it.Tags == nil {
it.Tags = []string{}
}
var err error
if it.CreatedAt, err = parseTime(createdAt); err != nil {
return MemoryItem{}, err
}
if it.UpdatedAt, err = parseTime(updatedAt); err != nil {
return MemoryItem{}, err
}
return it, nil
}
// newID 返回一个 128 位密码学随机 id,十六进制编码。它避开
// UUID 依赖,使本包保持自包含。rand.Read 只在
// 平台熵源损坏时失败,因此错误向上传播。
func newID() (string, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "", fmt.Errorf("memory: generate id: %w", err)
}
return hex.EncodeToString(b), nil
}
// discardLogger 在 log 非 nil 时返回 log,否则返回一个不写任何地方的 logger。
func discardLogger(log *slog.Logger) *slog.Logger {
if log != nil {
return log
}
return slog.New(slog.NewTextHandler(io.Discard, nil))
}
// schema DDL。internal/db 中的迁移框架是领域表的权威
// 机制,但这里刻意不用它:db 包的迁移测试断言精确的
// 迁移数量,而记忆存储必须自包含、
// 可在任意 *sql.DB 上使用。表与索引因此
// 由 NewStore 幂等创建。
const createMemoryItemsTable = `
CREATE TABLE IF NOT EXISTS memory_items (
id TEXT PRIMARY KEY,
persona_id TEXT NOT NULL,
role_id TEXT NOT NULL DEFAULT '',
project_id TEXT NOT NULL DEFAULT '',
scope TEXT NOT NULL,
kind TEXT NOT NULL,
content TEXT NOT NULL,
tags TEXT NOT NULL DEFAULT '[]',
confidence REAL NOT NULL DEFAULT 1.0,
source_conversation TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
decay_weight REAL NOT NULL DEFAULT 1.0
)`
const createMemoryItemsPersonaIndex = `
CREATE INDEX IF NOT EXISTS idx_memory_items_persona_id
ON memory_items (persona_id)`
const createMemoryItemsPersonaCreatedIndex = `
CREATE INDEX IF NOT EXISTS idx_memory_items_persona_id_created_at
ON memory_items (persona_id, created_at)`