diff --git a/internal/runtime/memory/extract_test.go b/internal/runtime/memory/extract_test.go index c80bbfe..47d482d 100644 --- a/internal/runtime/memory/extract_test.go +++ b/internal/runtime/memory/extract_test.go @@ -86,6 +86,11 @@ func TestStore_RememberManual_正常(t *testing.T) { t.Fatalf("item persona/scope/kind = %q/%q/%q, want persona.a/persona/relationship", item.PersonaID, item.Scope, item.Kind) } + // 人工动作(有人明确说「记住 X」)的档位恒为 long —— 留存规则不许把它降成临时层 + // (否则一条「记住 晚安」会在 7 天后被静默删掉)。见 store.go 的 RememberManual 注释。 + if item.RetentionTier != RetentionLong { + t.Fatalf("RememberManual 的档位 = %q, want %q(人工动作默认长期)", item.RetentionTier, RetentionLong) + } got, err := s.Get(ctx, item.ID) if err != nil { diff --git a/internal/runtime/memory/preinsert_store.go b/internal/runtime/memory/preinsert_store.go index cf5c50c..a5751ed 100644 --- a/internal/runtime/memory/preinsert_store.go +++ b/internal/runtime/memory/preinsert_store.go @@ -45,8 +45,12 @@ const selectSurvivorMergeSQL = `SELECT merged_from, merge_count, confidence const updateSurvivorSQL = `UPDATE memory_items SET merged_from = ?, merge_count = ?, confidence = ?, updated_at = ? WHERE id = ?` +// revertMergedSQL 把一条被软合并的行升回在册。`merged_into` 用**单引号空串**而不是 +// 双引号:SQLite 会把 `""` 先当**标识符**解析,解析不到才回退成字符串字面量(legacy +// 行为)。今天功能等价,但这是一条**改数据**的语句、且被 RevertMerged 与整理侧回滚 +// 共用,依赖「标识符解析失败的回退」不该出现在写路径上。 const revertMergedSQL = `UPDATE memory_items - SET status = ?, merged_into = "", updated_at = ? WHERE id = ? AND status = ?` + SET status = ?, merged_into = '', updated_at = ? WHERE id = ? AND status = ?` const selectMergedIntoSQL = `SELECT merged_into FROM memory_items WHERE id = ? AND status = ?` diff --git a/internal/runtime/memory/retention_migration_test.go b/internal/runtime/memory/retention_migration_test.go index 3d71531..0fb7c28 100644 --- a/internal/runtime/memory/retention_migration_test.go +++ b/internal/runtime/memory/retention_migration_test.go @@ -129,9 +129,11 @@ func TestMigration0026AddsColumnOverExistingRows(t *testing.T) { if !hasMemoryColumn(t, d, retentionTierColumn) { t.Fatalf("再次迁移后应重建 %s 列", retentionTierColumn) } + // 空串用**单引号**字面量:`""` 在 SQLite 里先按标识符解析,靠 legacy 回退才对, + // 不该在断言里留这种口径(与写入路径的 `''` 统一)。 var withTier int if err := d.QueryRowContext(ctx, - `SELECT COUNT(*) FROM memory_items WHERE retention_tier = ""`).Scan(&withTier); err != nil { + `SELECT COUNT(*) FROM memory_items WHERE retention_tier = ''`).Scan(&withTier); err != nil { t.Fatalf("count untyped: %v", err) } if withTier != 2 { diff --git a/internal/runtime/memory/retention_schema.go b/internal/runtime/memory/retention_schema.go index 6eefa29..a92dd19 100644 --- a/internal/runtime/memory/retention_schema.go +++ b/internal/runtime/memory/retention_schema.go @@ -18,9 +18,11 @@ import ( // 🔴 与迁移的分工(两份真源,必须同形):权威机制是 // `internal/db/migrations/0026_memory_retention_tier`;这里是「任意 *sql.DB」的 // 前滚。启动顺序是「先 MigrateUp、后 NewStore」(cmd/cyrene/wire.go),因此生产 -// 库里 0026 先落地、这里走到 `duplicate column name` 是**预期路径**;反过来 -// (先自举、后迁移)也成立,因为迁移的 ALTER 同样只跑一次。两份 DDL 的列/索引 -// 逐项同形由 retention_migration_test.go 钉住。 +// 库里 0026 先落地、这里走到 `duplicate column name` 是**预期路径**。 +// +// ⚠️ **反向顺序不成立**(别把上面那句读成「两个方向都行」):先 NewStore、后 +// MigrateUp 会在 **0019** 就先炸(`duplicate column name: status` —— 自举早已把 +// 整理列建好),这是既有问题、与本片无关,因此本仓的可迁移顺序只有「先迁移」。 // // 顺序**必须**是「建表 → 前滚列 → 建索引」:索引依赖的列在既有库上还不存在时, // 先建索引会报 "no such column: retention_tier"(tidy_schema.go 记过同型的教训)。 diff --git a/internal/runtime/memory/store.go b/internal/runtime/memory/store.go index 35ec82e..d8d1a08 100644 --- a/internal/runtime/memory/store.go +++ b/internal/runtime/memory/store.go @@ -117,10 +117,15 @@ type MemoryItem struct { // `Store.Add` 里的同一个判定与同一处换算落库,绝不会分叉:显式给非零值而 // 档位不是 temp 会被拒(ErrRetentionConflict),给 temp 而不给则按临时 TTL 算。 // - // ⚠️ **本字段目前只写不读**:Add 会把它写进 expires_at 列,但读路径一律不 - // SELECT 该列(闸是纯 SQL,见 timegate.go),故 Get/List* 回来的条目 - // ExpiresAt 恒为零值。要读回时效需同步 14 列 SELECT 与 scanItem,并连带 - // tidy/preinsert 的内部召回 SQL —— 那是改扫描口径,不在本切片授权范围。 + // ⚠️ **读回语义是「半开」的**(别把这一条读成「完全只写不读」): + // + // - `Add` 会把它写进 `expires_at` 列,**并把最终落库的时刻回填到返回的条目上** + // (调用方因此能立刻看到「这条被判成临时、有效期到哪」); + // - 但 `Get` / `List*` 一律不 SELECT 该列(读侧闸是纯 SQL,见 timegate.go), + // 故**从库里读回来的**条目 ExpiresAt 恒为零值。 + // + // 要读回时效需同步 14 列 SELECT 与 scanItem,并连带 tidy/preinsert 的内部召回 + // SQL —— 那是改扫描口径,不在本切片授权范围。 ExpiresAt time.Time // RetentionTier 是「留多久 / 干什么用」这根轴(迁移 0026、retention.go)。 @@ -362,13 +367,19 @@ func (s *Store) Delete(ctx context.Context, id string) error { // RememberManual 为 content 文本写入一条长期记忆条目 —— design §1 中的 // 「记住 X」路径。条目默认取角色作用域, // 并以满置信度记为关系记忆。返回已落库的条目。 +// +// 🔴 **显式档位 = long**(迁移 0026):这是**人工**动作(有人明确说「记住 X」), +// 按主人 2026-10-26 的裁定「人工动作默认应为 long」,留存规则不许把它降成临时层 +// (否则一条「记住 晚安」会在 7 天后被静默删掉)。resolveRetention 的第一条分支 +// 就是「调用方显式档位不许被规则推翻」,这里据此钉死长期意图。 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), + PersonaID: personaID, + RoleID: roleID, + Scope: ScopePersona, + Kind: KindRelationship, + Content: strings.TrimSpace(text), + RetentionTier: RetentionLong, } if item.Content == "" { return MemoryItem{}, ErrMissingContent