Files
Cyrene_v2/internal/runtime/memory/store.go
T
AskaEth 90f430216a
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(memory): D3 从「异步后置」改成「同步入库闸」—— 由整理层决定入库形态
主人 2026-10-07 裁定:「把记忆整理功能接到昔涟和记忆库中间做审查,由整理功能
进行入库」。现状是**异步 + 入库后软合并** ⇒ 行已经落库才审,面板/召回先脏后清,
而写入速率实测 ~25 条/小时(≈600/天)⇒ 清理永远追不上。

落法(三条硬规格,全部落地):
① **同步闸但有界**:闸在 `store.go:247` 的 `planIngest`(INSERT **之前**),
   本体 `preinsert_gate.go`。墙钟预算 **150ms**(实测最坏 p95 仅 4~7ms ⇒ 20~50 倍
   余量)、候选扫描上限 **100**(比异步侧 200 更紧,因为它在写路径上)。
   🔴 **零远端 embedding**:闸恒用词项余弦(`deps.Embedder` 根本不参与)——
   远端 embedding 一次 2.3~3.0s 且对同文本返回不同向量(不可复现),不许摆在同步
   路径上拍板。
② **fail-open,绝不丢记忆**:候选源报错 ⇒ `gate:candidates_unavailable`;超预算 ⇒
   `gate:budget_exceeded` ⇒ **照常 insert(active) + 投既有队列 + 留痕 + WARN**。
③ **三态落库(落 merged,而不是"不插入")**:确定性命中(字面重复 / 归一化等价 /
   真包含)⇒ INSERT **直接带 `status='merged'` + `merged_into=<存活行>`**,存活行
   `merged_from` 追加、`merge_count`+1 ⇒ **新行从不进 active、从不建向量索引**,
   而**行与原文都在表里 ⇒ revert 无损**。若"干脆不插",那条文本从未存在过行,
   revert 无从下手,违反 design/23 §8 的红线。
- **不与异步 D3 重复审**:闸做决定的那条 `Async=false`(留痕由闸自己落,
  审计动作名 `memory.ingest.gate`);真新/拿不准的走原有队列(留痕仍归异步层)。
- **开关**:`$DATA_DIR/config/memory-tidy.json` 的 `{"ingestGate": false}` ⇒ 一键回到
  「只插不审」(= 改动前的异步行为)。**零新 env 键**(`.env` 未碰)。
- 顺手把 `store.go` 的行映射拆到 `store_rows.go`(守 §10 的 ≤500 行)。

验收(主会话复跑 + 亲自变异):`go build ./...` + `go vet` 绿;memory 包测试绿;
我用 -overlay 摘掉闸的预算 ⇒ `TestIngestGate_Add墙钟有界` 与
`…failOpen_超预算绝不丢记忆` 变红("Add 等了 2.0s,越过闸预算 150ms")。
子代理另做 2 个变异(去掉预算 / 把 fail-open 改成 fail-closed)全红,并实测
**闸开 vs 关的 `Add` 额外墙钟:p50 ≈2.5~3.1ms、p95 ≈4~7ms**(基线批均值 35.8µs)。

⚠️ 未部署(要一次 build + restart);⚠️ 已知边界(如实):闸只用词项余弦 ⇒
「同义但字面差得远」拦不住(有意为之,由异步层/夜间整理兜);闸的候选是**最新
100 条**(更老的重复闸看不到);**并发同内容双写**仍可能两条都落 active(既有竞态,
SELECT 与 INSERT 不在同一事务,非本次引入,第二层会清)。
2026-10-07 11:41:44 +08:00

457 lines
17 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"
"sync"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory/projectindex"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory/vector"
)
// 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
// vecMu 保护 vec,使持久语义索引能在装配后并发接入,而写入
// 路径每写一条记忆就 embed + add 到它(见 indexItem)。vec 为
// nil 表示没有配置持久索引,检索据此回退到内存余弦。
vecMu sync.RWMutex
vec vector.Index
// projMu 保护 proj,使项目索引存储能在装配后并发接入,而写入路径
// 每写一条项目作用域记忆就确保它链接的项目索引条目存在(见
// ensureProjectIndex)。proj 为 nil 表示未接入项目索引,此时写入
// 不触碰项目索引(design §3 项目记忆链接项目索引)。
projMu sync.RWMutex
proj *projectindex.Store
// reviewMu 保护 review,使入库前审查(design/23 §15.1 D3)能在装配后
// 并发接入:接入后每条经 Add 落库的条目都会**先过同步入库闸**(确定性判据
// 命中即落 merged,见 preinsert_gate.go),闸没拦下的再投入异步审查队列,
// 由独立的记忆管理会话判定 merge/modify/create/skip。review 为 nil 表示
// 未启用或未接线,此时写入路径行为逐字与接入前一致。
reviewMu sync.RWMutex
review *PreInsertReviewer
}
// 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
}
// 记忆整理(design/23 §10)的列与两张新表:同样在这一处幂等前滚,
// 使「在任意 *sql.DB 上都能整理」与 memory_items 自身口径一致。
if err := ensureTidySchema(ctx, db); err != nil {
return nil, err
}
// 入库前审查(design/23 §15.1 D3)的留痕表:同一口径 —— 表在这里幂等
// 创建,因此审查能力在任何 *sql.DB 上都可用;未接线审查时它只是一张空表。
if err := ensureIngestReviewSchema(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 与时间戳均已填充。
//
// 🔴 **同步入库闸**(主人 2026-10-07 裁定):本函数是全仓**唯一**写路径,因此
// 闸就摆在这里 —— 落库**之前**先在同分区内跑一次确定性判定(复用
// judgeIngest → tidy_rules.go / tidy_containment.go),判出「真重复」时这一行
// 直接落 status='merged' + merged_into=<存活行>(**照常插入、一个字不丢、可
// revert、从不进 active 集合**),于是 active 集合当场就是干净的。闸的总墙钟
// 预算显式且有界(见 defaultReviewGateBudget),超预算/候选源不可用一律
// fail-open(照常插入 + 留痕,**绝不静默丢记忆**)。闸未接线或被开关关掉时,
// 这段逻辑是零成本的 passthrough,写入路径逐字回到改动之前。
//
// 接入持久语义索引(见 attachVectorIndex)时,落库后同一条目也会
// 被向量化并写入索引,使索引与 memory_items 同步 —— 语义检索路径因此
// 真的有向量可查,而不是对一个恒空索引搜索(design/03 §3「真正启用
// 向量检索」、design/04 §7)。索引写入失败只记日志:条目已经在
// 关系表里,语义增强的失败绝不使记忆写入失败,检索侧也会回退到
// 内存余弦(见 Retriever.semanticScores)。被闸判为重复的行**不建索引**:
// 它不进召回、也不该占掉语义检索 top-K 的一个位置。
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)
}
plan := s.planIngest(ctx, item)
_, 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, status, merged_into)
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,
plan.insertStatus(), plan.absorbedID())
if err != nil {
return MemoryItem{}, fmt.Errorf("memory: insert item: %w", err)
}
s.finishIngest(ctx, item, plan)
if plan.Action != ingestPlanAbsorbNew {
s.indexItem(ctx, item)
s.ensureProjectIndex(ctx, item)
}
if r := s.ingestReviewer(); r != nil {
r.recordGate(ctx, item, plan, time.Now().UTC())
}
// 入库前审查(design/23 §15.1 D3)的**异步**那一层:闸没拦下的(真新 /
// 拿不准 / 闸关闭)照旧投入审查队列,由后台用语义召回再判一次;闸挡下的
// 那条**绝不再投**(同一件事不该有两条路径)。投递是非阻塞的。未接线审查
// 时是空操作。
if plan.Async {
s.enqueueReview(item)
}
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)。
//
// 只返回**在册**(status='active')条目:整理与入库前审查都只做软动作,
// 被合并/被归档的行内容原样留在表里,因此「能看到什么记忆」的每条读路径
// 都必须过 status 闸,否则清理过的行照样会被检索到、照样占满面板。
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 = ? AND status = ?
ORDER BY created_at DESC, rowid DESC`, personaID, string(tidyStatusActive))
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。
//
// 「在册」= status='active':被软合并('merged')与被归档('archived')
// 的行**内容与行都还在**,但不再参与检索、不再进上下文、也不再出现在
// 管理面板的记忆列表里(design/23 §8/§10 的软动作口径)。要看它们走
// 审查留痕 `GET /admin/memories/reviews` 或按 id 用 Get —— 后者刻意
// 不过 status 闸,因为撤销软合并必须先读得到被吸收的行。
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 WHERE status = ?
ORDER BY created_at DESC, rowid DESC`, string(tidyStatusActive))
if err != nil {
return nil, fmt.Errorf("memory: list items: %w", err)
}
return scanItems(rows, "item")
}
// 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
}
// 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))
}