package memory import ( "context" "database/sql" "errors" "fmt" "strings" "time" ) // 本文件是记忆整理的**应用路径**(切片 TIDY-1;design/23 §7/§8 的执行面)。 // // 动因是一条实测口径:整理只产候选、没有任何执行面,于是 77 条候选全 pending、 // 两天一条没应用。本文件把候选真正落地,五条不可走样的口径: // // 1. **只走软动作**:merge = 软合并(被吸收行 status='merged' + merged_into=存活 // id;存活行 merged_from/merge_count/updated_at 更新),archive = 软删 // (status='archived'),reclassify = 改 kind。**绝不改任何一行的 content**、 // **绝不物理删除**(design/23 §8:行保留 ⇒ 可 revert); // 2. **幂等**:候选状态的条件更新(`… WHERE id=? AND status='pending'` + // RowsAffected)是执行闸,记忆行的 `AND status='active'` 条件更新是第二道闸 // —— 两道都在同一事务里,重复应用同一条候选不产生第二次副作用; // 3. **可 revert**:RevertTidyCandidate 把 merge 的被吸收行复原为 active、把 // archive 的归档行复原、把 reclassify 的 kind 还原,并把候选退回 pending; // 4. **失败隔离**:单条候选失败只进 results(failed),批量里的其余条照跑 // (design/23 §9 的失败隔离同口径); // 5. **动作闭集**:link / keep 是**只读建议**(design/23 §14:link 的存储形态未定), // 因此不可应用 —— 应用它只会得到 skipped/action_not_applyable,绝不假装做过。 // 🔴 2026-10-07 起规则层**不再产出** link 候选(判 keep,见 tidy_rules.go); // 这条分支留着是给**库里既有的** link 行用:它们仍要能被如实判为「不可应用」, // 并由 RejectTidyCandidatesByAction 批量拒绝(tidy_reject.go)。 // // 作用域:应用只在候选自带的分区(persona_id + scope + project_id)内改行; // 候选与目标行的分区不一致时拒做(纵深防御 design/23 §5.2-7/8)。受限来源 // (群聊/陌生人)同样三类动作一律拒做(tidySkipSourceRestricted,design/23 §5.2-9/§6)。 // TidyApplyStatus 是一条候选应用结果的状态类别。 type TidyApplyStatus string const ( // TidyApplyApplied 表示这次调用真的改了记忆(或真的撤销了改动)。 TidyApplyApplied TidyApplyStatus = "applied" // TidyApplySkipped 表示没有改动,且**不是错误**:幂等命中(已决定)、 // 动作不可应用、候选形状不完整。 TidyApplySkipped TidyApplyStatus = "skipped" // TidyApplyFailed 表示尝试过并失败。它不影响批量里的其它候选。 TidyApplyFailed TidyApplyStatus = "failed" ) // 跳过的**机器可读**原因(面板按它聚合,不必解析中文文案)。 const ( // tidySkipAlreadyDecided 表示这条候选已被决定过 —— 重复应用/重复回滚的幂等口径。 tidySkipAlreadyDecided = "already_decided" // tidySkipNotApplied 表示回滚时这条候选并不在已应用状态。 tidySkipNotApplied = "not_applied" // tidySkipActionNotApplyable 表示该动作没有执行语义(link/keep)。 tidySkipActionNotApplyable = "action_not_applyable" // tidySkipSourceNotActive 表示候选指向的记忆行已不在册(已被别的动作改过)。 tidySkipSourceNotActive = "source_not_active" // tidySkipSourceRestricted 表示候选指向的记忆行来自群聊/陌生人等**只读**来源 // (design/23 §5.2-9 + §6)。规则层本就把这类条目排除在扫描外,这里是纵深防御: // 候选行是落库数据,可能来自更早的规则版本或手工改动,而「整理不得改写这类来源」 // 是红线而不是权衡 —— 宁可如实报 skipped,也不动那一行。 tidySkipSourceRestricted = "restricted_source" // tidySkipSourceMissing 表示候选指向的记忆行不存在。 tidySkipSourceMissing = "source_missing" // tidySkipPartitionMismatch 表示候选的分区与它指向的行不一致。 tidySkipPartitionMismatch = "partition_mismatch" // tidySkipInvalidCandidate 表示候选形状不完整(缺 target / 缺 source / kind 非法)。 tidySkipInvalidCandidate = "invalid_candidate" // tidySkipTemporaryRow 表示簇里有行在**临时层**(`expires_at` 非空): // 临时行 7 天后会被后台物理删除(design/23 §19),既不许当存活行(会让被吸收行 // 指向一个不存在的 id),也不值得当被吸收行(它本来就要消失)。整簇原子的 skip。 tidySkipTemporaryRow = "temporary_row" ) // 整理应用侧的哨兵错误。与 memory 包其余错误同一口径:调用点一律 %w 包裹, // 调用方用 errors.Is 判断。 var ( // ErrTidyCandidateNotFound 在按 id 操作一条不存在的候选时返回。 ErrTidyCandidateNotFound = errors.New("memory: tidy candidate not found") // ErrTidyRunRecordNotFound 在按 run 批量应用而该轮**留痕行**不存在时返回 —— // 拿一个拼错的 runId 来时,必须是「查不到这一轮」,不是「这一轮没有待定候选」。 // (与 ErrTidyRunNotFound 区分:那是异步试算的**进程内存**句柄,两者不是一回事。) ErrTidyRunRecordNotFound = errors.New("memory: tidy run record not found") // errTidyCandidateDecided 是事务内的幂等哨兵:候选状态的条件更新没命中, // 说明它已被决定过。它不越过本包(调用方看到的是 skipped/already_decided)。 errTidyCandidateDecided = errors.New("memory/tidy: candidate already decided") // errTidyItemNotActive 是事务内的第二道闸哨兵:目标记忆行已不在册 // (已被别的动作改过)。同样不越过本包。 errTidyItemNotActive = errors.New("memory/tidy: item is not active") ) // TidyApplyOptions 是一次应用/回滚的请求口径。 type TidyApplyOptions struct { // Actor 是决定者标签,落 memory_tidy_candidates.decided_by。 Actor string // Limit 是**批量应用**的条数上限(非正取 defaultTidyApplyBatchLimit, // 超过 maxTidyApplyBatchLimit 时钳到它)。单条应用忽略它。 Limit int } // normalizedLimit 归一批量上限:非正取默认值、超上限钳位(与既有查询面的 // limit 约定一致:钳位而不是报错)。 func (o TidyApplyOptions) normalizedLimit() int { if o.Limit <= 0 { return defaultTidyApplyBatchLimit } if o.Limit > maxTidyApplyBatchLimit { return maxTidyApplyBatchLimit } return o.Limit } // TidyApplyResult 是一条候选的应用/回滚结果。 type TidyApplyResult struct { CandidateID string `json:"candidateId"` Action TidyAction `json:"action"` Status TidyApplyStatus `json:"status"` Reason string `json:"reason,omitempty"` TargetID string `json:"targetId,omitempty"` // AffectedIDs 是这次真的被改动的 memory_items id(merge=被吸收行; // archive=被归档行;reclassify=被改 kind 的行)。它既是回滚的抓手, // 也是「软删除后总行数不变」这条断言的证据面。 AffectedIDs []string `json:"affectedIds"` // Error 是 failed 时的原因(面向运维;网关侧只记日志,不把原始正文上线)。 Error string `json:"error,omitempty"` } // TidyApplyBatch 是一次批量应用的回执。Total 是命中筛选的**真总数**(不是 // len(Results)),Truncated 报告「待定候选比上限多」—— 与扫描/候选上限的 // 诚实口径一致(超限绝不静默)。 type TidyApplyBatch struct { RunID string `json:"runId"` Limit int `json:"limit"` Total int `json:"total"` Truncated bool `json:"truncated"` Results []TidyApplyResult `json:"results"` Applied int `json:"applied"` Skipped int `json:"skipped"` Failed int `json:"failed"` // Merged / Archived / Reclassified 是**成功应用**的候选按动作分解 // (skipped 的不计)—— 「新增/合并/软删各多少」这条回执的领域来源。 Merged int `json:"merged"` Archived int `json:"archived"` Reclassified int `json:"reclassified"` // Deduped 是成功落地的 `dedupe_group` 簇数(2026-10-24)。它与 Merged 分开: // 「一对一并进」与「一簇 N 条并进」在面板上是两件事(后者一次动多行)。 Deduped int `json:"deduped"` // RowsAffected 是本次真的被改动的**记忆行数**(merge/archive/reclassify 各 1 行, // dedupe_group 是簇里的被吸收行数)。行预算 `maxTidyApplyRowsPerBatch` 按它计, // 因此这个数字是「这一次点击改了多少行记忆」的直接回答。 RowsAffected int `json:"rowsAffected"` } // ApplyTidyCandidate 应用**一条**候选(design/23 §11 的 apply 粒度之一)。 // 找不到候选返回 ErrTidyCandidateNotFound;已决定/不可应用/形状不完整都以 // TidyApplySkipped 结果如实作答(不是错误 —— 幂等命中不该是错误)。 func (s *Store) ApplyTidyCandidate(ctx context.Context, id string, opts TidyApplyOptions) (TidyApplyResult, error) { cand, err := s.GetTidyCandidate(ctx, id) if err != nil { return TidyApplyResult{}, err } return s.applyCandidate(ctx, cand, opts) } // applyCandidate 是单条应用的真实实现(批量路径也走它,保证两种粒度同一套语义)。 func (s *Store) applyCandidate(ctx context.Context, cand TidyCandidate, opts TidyApplyOptions) (TidyApplyResult, error) { base := TidyApplyResult{ CandidateID: cand.ID, Action: cand.Action, TargetID: cand.TargetID, AffectedIDs: []string{}, } if !cand.Action.applyable() { // link / keep 没有执行语义(design/23 §14;2026-10-07 起 link 也不再产出, // 这里兜的是库里既有的 link 行):如实说「不可应用」,不写任何行。 return skipTidyApply(base, tidySkipActionNotApplyable), nil } now := time.Now().UTC() switch cand.Action { case TidyMerge: return s.applyTidyMerge(ctx, cand, opts, now) case TidyArchive: return s.applyTidyArchive(ctx, cand, opts, now) case TidyReclassify: return s.applyTidyReclassify(ctx, cand, opts, now) case TidyDedupeGroup: return s.applyTidyDedupeGroup(ctx, cand, opts, now) default: return skipTidyApply(base, tidySkipActionNotApplyable), nil } } // applyable 报告一个动作是否有执行语义。link/keep **永远**返回 false: // 2026-10-07 起规则层已不产 link(无落点,见 tidy_rules.go),这条判据只服务 // 库里既有的历史行与「不许假装做过」的诚实口径。 func (a TidyAction) applyable() bool { switch a { case TidyMerge, TidyArchive, TidyReclassify, TidyDedupeGroup: return true default: return false } } // applyTidyMerge 把候选指认的「被吸收行」软合并进「存活行」。 func (s *Store) applyTidyMerge(ctx context.Context, cand TidyCandidate, opts TidyApplyOptions, now time.Time) (TidyApplyResult, error) { base := TidyApplyResult{ CandidateID: cand.ID, Action: cand.Action, TargetID: cand.TargetID, AffectedIDs: []string{}, } if len(cand.SourceIDs) != 1 || strings.TrimSpace(cand.TargetID) == "" { return skipTidyApply(base, tidySkipInvalidCandidate), nil } absorbed, survivor, skip, err := s.loadTidyMergeRows(ctx, cand) if err != nil { return base, err } if skip != "" { return skipTidyApply(base, skip), nil } err = s.tidyApplyTx(ctx, cand, opts, now, func(ctx context.Context, tx *sql.Tx) error { if err := markTidyAbsorbed(ctx, tx, absorbed.ID, survivor.ID, now); err != nil { return err } return bumpSurvivor(ctx, tx, survivor, absorbed, now) }) return finishTidyApply(base, []string{absorbed.ID}, err, tidySkipSourceNotActive) } // applyTidyArchive 软删一条一次性事件(status='archived'):行保留、内容不改, // 只是不再参与检索与整理(design/23 §10 的 status 列 + §8 的软删除口径)。 func (s *Store) applyTidyArchive(ctx context.Context, cand TidyCandidate, opts TidyApplyOptions, now time.Time) (TidyApplyResult, error) { base := TidyApplyResult{ CandidateID: cand.ID, Action: cand.Action, AffectedIDs: []string{}, } if len(cand.SourceIDs) != 1 { return skipTidyApply(base, tidySkipInvalidCandidate), nil } item, err := s.Get(ctx, cand.SourceIDs[0]) if errors.Is(err, ErrNotFound) { return skipTidyApply(base, tidySkipSourceMissing), nil } if err != nil { return base, err } if skip := tidyPartitionMismatch(cand, item); skip != "" { return skipTidyApply(base, skip), nil } if nonOwnerSource(item.SourceConversation) { // 只读来源(群聊/陌生人)绝不归档(design/23 §5.2-9/§6):纵深防御, // 规则层本就把这类条目排除在扫描范围外。 return skipTidyApply(base, tidySkipSourceRestricted), nil } err = s.tidyApplyTx(ctx, cand, opts, now, func(ctx context.Context, tx *sql.Tx) error { return archiveTidyItem(ctx, tx, item.ID, now) }) return finishTidyApply(base, []string{item.ID}, err, tidySkipSourceNotActive) } // applyTidyReclassify 修正 kind 误分类(design/23 §5.1 T0-6):**只改 kind 一列**, // 建议值来自候选证据里的 ProposedKind。 // // 边界(2026-10-07 纳入自动档时写死):它不能改正文、不能改作用域、不能碰第二条行。 // 候选上的 ProposedContent / MergedContent 之类正文字段**一律不采纳** —— 落地语句 // 只写 kind/updated_at(reclassifyTidyItemSQL),因此即使候选是手工塞进来的畸形行, // 也只会得到一次分类修正,不会有任何一个字节的正文被写入。 func (s *Store) applyTidyReclassify(ctx context.Context, cand TidyCandidate, opts TidyApplyOptions, now time.Time) (TidyApplyResult, error) { base := TidyApplyResult{ CandidateID: cand.ID, Action: cand.Action, AffectedIDs: []string{}, } if len(cand.SourceIDs) != 1 { return skipTidyApply(base, tidySkipInvalidCandidate), nil } kind := Kind(cand.Evidence.ProposedKind) if !kind.valid() { return skipTidyApply(base, tidySkipInvalidCandidate), nil } item, err := s.Get(ctx, cand.SourceIDs[0]) if errors.Is(err, ErrNotFound) { return skipTidyApply(base, tidySkipSourceMissing), nil } if err != nil { return base, err } if skip := tidyPartitionMismatch(cand, item); skip != "" { return skipTidyApply(base, skip), nil } if nonOwnerSource(item.SourceConversation) { // 只读来源(群聊/陌生人)绝不改(design/23 §5.2-9/§6):纵深防御,规则层本就 // 把这类条目排除在扫描范围外。archive/merge 早就有这道门,reclassify 在 // 2026-10-07 纳入自动档时必须补上同一道 —— 三类动作的只读来源口径必须一致, // 否则「自动档只碰主人自己的记忆」这句话就不成立。 return skipTidyApply(base, tidySkipSourceRestricted), nil } if item.Kind == kind { // 已经是建议值:没有可改的,如实说「源行不在册」的口径不适用,这里 // 用 already_decided 更像「无需再做」。 return skipTidyApply(base, tidySkipAlreadyDecided), nil } err = s.tidyApplyTx(ctx, cand, opts, now, func(ctx context.Context, tx *sql.Tx) error { return reclassifyTidyItem(ctx, tx, item.ID, kind, now) }) return finishTidyApply(base, []string{item.ID}, err, tidySkipSourceNotActive) } // loadTidyMergeRows 读取软合并的两行并做分区硬门校验。skip 非空表示应如实跳过 // (原因由它给出),此时两行不可用。 func (s *Store) loadTidyMergeRows(ctx context.Context, cand TidyCandidate) (MemoryItem, MemoryItem, string, error) { absorbed, err := s.Get(ctx, cand.SourceIDs[0]) if errors.Is(err, ErrNotFound) { return MemoryItem{}, MemoryItem{}, tidySkipSourceMissing, nil } if err != nil { return MemoryItem{}, MemoryItem{}, "", err } survivor, err := s.Get(ctx, cand.TargetID) if errors.Is(err, ErrNotFound) { return MemoryItem{}, MemoryItem{}, tidySkipSourceMissing, nil } if err != nil { return MemoryItem{}, MemoryItem{}, "", err } if skip := tidyPartitionMismatch(cand, absorbed); skip != "" { return MemoryItem{}, MemoryItem{}, skip, nil } if skip := tidyPartitionMismatch(cand, survivor); skip != "" { return MemoryItem{}, MemoryItem{}, skip, nil } // 只读来源硬门(design/23 §5.2-9/§6):两侧任一来自群聊/陌生人都不合。 // 规则层已把这类条目排除在扫描外,这里是纵深防御。 if nonOwnerSource(absorbed.SourceConversation) || nonOwnerSource(survivor.SourceConversation) { return MemoryItem{}, MemoryItem{}, tidySkipSourceRestricted, nil } if absorbed.ID == survivor.ID { return MemoryItem{}, MemoryItem{}, tidySkipInvalidCandidate, nil } return absorbed, survivor, "", nil } // tidyPartitionMismatch 报告候选与一条记忆是否不同分区(是 ⇒ 返回不匹配原因)。 // 分区键是 design/23 §5.2-7/8 的硬门,应用侧必须再核一次:候选行是落库数据, // 而落库数据可能来自更早的规则版本或手工改动。 func tidyPartitionMismatch(cand TidyCandidate, item MemoryItem) string { if cand.PersonaID != item.PersonaID || cand.Scope != item.Scope || cand.ProjectID != item.ProjectID { return tidySkipPartitionMismatch } return "" } // RevertTidyCandidate 撤销一条**已应用**的候选(design/23 §8 的回滚 + §11 的 // candidates/{id}/revert)。它把记忆行复原并把候选退回 pending(于是可以重新决定), // 全程不删任何一行。候选不在已应用状态时返回 skipped/not_applied。 func (s *Store) RevertTidyCandidate(ctx context.Context, id string, opts TidyApplyOptions) (TidyApplyResult, error) { cand, err := s.GetTidyCandidate(ctx, id) if err != nil { return TidyApplyResult{}, err } base := TidyApplyResult{ CandidateID: cand.ID, Action: cand.Action, TargetID: cand.TargetID, AffectedIDs: []string{}, } if !cand.Action.applyable() { return skipTidyApply(base, tidySkipActionNotApplyable), nil } if cand.Status != tidyCandidateApplied { return skipTidyApply(base, tidySkipNotApplied), nil } now := time.Now().UTC() var reverted []string err = s.tidyRevertTx(ctx, cand, opts, func(ctx context.Context, tx *sql.Tx) error { out, err := revertTidyAction(ctx, tx, cand, now) reverted = out return err }) if errors.Is(err, errTidyCandidateDecided) { return skipTidyApply(base, tidySkipNotApplied), nil } if err != nil { return tidyFailed(base, err), nil } base.Status = TidyApplyApplied base.AffectedIDs = reverted return base, nil } // ApplyTidyCandidates 批量应用待定候选(design/23 §11 的 apply 粒度之二/之三): // runID 非空 ⇒ 只应用该轮产出的候选(「按 run 批量应用」);runID 为空 ⇒ // 应用全部待定候选(「全部待定应用」)。上限由 opts.Limit 决定,超限如实回报 // Truncated + Total(绝不静默少应用)。 // // 逐条结果都在 Results 里(失败隔离:一条失败不阻塞其余)。 func (s *Store) ApplyTidyCandidates(ctx context.Context, runID string, opts TidyApplyOptions) (TidyApplyBatch, error) { limit := opts.normalizedLimit() if strings.TrimSpace(runID) != "" { if _, err := s.GetTidyRun(ctx, runID); err != nil { return TidyApplyBatch{}, fmt.Errorf("memory/tidy: apply by run %s: %w", runID, err) } } total, err := s.countTidyCandidates(ctx, tidyCandidatePending, runID) if err != nil { return TidyApplyBatch{}, err } cands, err := s.listTidyCandidates(ctx, tidyCandidatePending, runID, limit) if err != nil { return TidyApplyBatch{}, err } batch := TidyApplyBatch{ RunID: runID, Limit: limit, Total: total, Truncated: total > len(cands), Results: make([]TidyApplyResult, 0, len(cands)), } rows := 0 for _, cand := range cands { if err := ctx.Err(); err != nil { // 上下文取消时**已应用的保持已应用**,未做的留给下一次(与清扫同口径)。 break } // 按行计的批量上限(2026-10-24):一条 dedupe_group 候选会改 N 行,只按候选 // 条数限批会让「一次点击改上千行记忆」。超限时**如实标 truncated** 并停下, // 剩下的留给下一次点击(与扫描/候选上限同一口径:绝不静默少做)。 if cost := tidyApplyRowCost(cand); rows+cost > maxTidyApplyRowsPerBatch { batch.Truncated = true break } else { rows += cost } res, err := s.applyCandidate(ctx, cand, opts) if err != nil { res = tidyFailed(TidyApplyResult{ CandidateID: cand.ID, Action: cand.Action, TargetID: cand.TargetID, AffectedIDs: []string{}, }, err) } batch.Results = append(batch.Results, res) batch.RowsAffected += len(res.AffectedIDs) tallyTidyApply(&batch, res) } if len(batch.Results) < len(cands) { // 循环被行预算或 ctx 打断:`len(cands)` 之内还有未处理项 ⇒ 不完整。 batch.Truncated = true } if batch.RunID != "" && batch.Applied > 0 { // 该轮的 applied 计数累加(design/23 §10 的 memory_tidy_runs.applied): // 运行时那一轮写 0(当轮绝不自动应用),事后应用在这里补记。 if err := s.addTidyRunApplied(ctx, batch.RunID, batch.Applied); err != nil { return batch, err } } return batch, nil }