Files
Cyrene_v2/cmd/cyrene/wire_memory_tidy.go
T
AskaEth a017ab6cf8 feat(memory): 临时记忆到期物理删除(有界清扫 + 同步清向量 + 删前审计)
design/23 §19 的第 ② / ③ 步:读侧时效闸(M1)只负责「看不见」,本切片负责
「真删掉」。三条护栏照 §19.3:删除是本包内唯一 DELETE 出口、有界(500 行/批 ×
20 批)、删前留审计。

- internal/runtime/memory/expire.go:扫描集合写死为「expires_at ≠ '' ∧
  expires_at ≤ now ∧ status='active' ∧ 来源非受限」。空 expires_at(长期/日常
  记忆,含 M2 登记的「INSERT 与补写之间中断」窗口)结构性进不了集合;受限来源
  复用 nonOwnerSource(与整理/审查同一处判据)。键集游标 (expires_at, rowid)
  保证被跳过的行只读一次、扫描位置一定前移。审计串 = id + persona_id + scope +
  expires_at + 按 rune 截断(48)的摘要 —— 本仓唯一一处允许携带内容片段的审计。
- internal/runtime/memory/expire_loop.go:清扫循环挂在 TidyService 的生命周期上
  (AttachExpirySweep 由组合根调用),随心跳 ctx 跑、随 Stop 停;一轮只写一行
  汇总日志,逐条的那一行进审计。
- internal/runtime/memory/vector/delete.go:新增可选删除能力 vector.Deleter +
  两个后端实现,删行时同步清向量(M1 reviewer 的 finding:过期向量占 top-K 预算)。
- cmd/cyrene/wire_memory_tidy.go:挂上清扫;顺带把已过时的「这十键」改成「这十一
  键」(M2 加了第十一键 tempTtlSec)。
2026-10-13 21:43:27 +08:00

152 lines
9.2 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 main
import (
"fmt"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
)
// 本文件把记忆整理(DevDocs/design/23-记忆整理与去重 §4)接进组合根。
//
// 六件事只在这里定:
// - **形态**:定期后台任务(安静时段降频)的心跳(memory.TidyService),不是
// 会话内派 subagent。它在 main.go 与 backthink 并列启动、在 close 里先于关库停止;
// - **依赖面**:只给它记忆存储与 embedder —— 服务侧拿不到任何会话/历史读数,
// 因此 design §4.1「绝不碰主会话上下文」是结构性成立的,而不是靠自觉;
// - **审计**:接到网关的 AuditEvent(design §8 的 memory.tidy.*),出口在调用
// 时才解析 a.gw —— 网关比本服务晚组装(wireFrontend),与 onToolAudit
// 同一手法。未就绪时只记日志,绝不假装写过审计;
// - **运行期调优**(2026-10-07 新增):从 `$DATA_DIR/config/memory-tidy.json`
// 读「自动落地开关 + 每轮上限」,以及(同日追加)**整理频率**四项
// (intervalSec / quietIntervalSec / quietStartHour / quietEndHour)。它是
// `.env` 冻结(design/13 §2.1)之后新旋钮的唯一去处,**零新 env 键**;面板优先、
// `.env` 兜底,键缺位或值不可用一律回落下一档并记 WARN(坏配置绝不能让自动档
// 变成「无限制」,也不能让整理频率变成忙轮询);
// - **启动播种**(2026-10-07 追加,主人「后端**自动创建**独立的面板配置文件**要做**」):
// 那个文件不存在时由后端自己写一份「当前生效值」进去(seedMemoryTidyPanel),
// 使「面板优先」从第一次启动起就成立,而不是靠 `.env` 一直兜着;
// - **到期清扫的挂点**(2026-10-13 追加,切片 M3,design/23 §19):临时记忆到期
// 后要**真删掉**(读侧时效闸只负责「看不见」)。清扫是 memory 包内的后台件
// (expire.go / expire_loop.go),这里只把它挂到整理服务的生命周期上
// (AttachExpirySweep)—— 它随心跳 ctx 跑、随 Stop 停,因此不新开关闭链、
// 不新开端口、不加 env 键。
//
// 刻意**不做**的一件事(design §4.1 F):
// - 不把 sess-memorytidy 加进 flowPub 的图源白名单(默认关:整理是系统任务,
// 不是主人的会话)。
func (a *app) wireMemoryTidy() error {
cfg := memory.TidyConfigFromConfig(a.cfg.MemoryTidy)
a.applyMemoryTidyTuning(&cfg)
// 启动播种(主人 2026-10-07:「后端**自动创建**独立的面板配置文件**要做**」):
// 文件不存在/空白 ⇒ 把**此刻的生效值**写成面板的一等来源,此后 `.env` 只兜底。
// 放在 applyMemoryTidyTuning **之后**是刻意的:那一刻的 cfg 就是
// 「面板 > `.env` > 代码默认」的结果(面板缺失时即 `.env` + 默认),播它 ⇒ 行为
// 零变化。文件已存在(含损坏)⇒ 一个字节都不动(幂等,见 runtimeconfig.SeedMemoryTidy)。
a.seedMemoryTidyPanel(cfg)
svc, err := memory.NewTidyService(cfg, memory.TidyDeps{
Store: a.memStore,
Embedder: a.embedder,
Audit: a.auditMemoryTidy,
Log: logger.WithSource(a.log, "memory/tidy"),
})
if err != nil {
return fmt.Errorf("memory tidy: %w", err)
}
// 到期清扫(design/23 §19 的第 ② / ③ 步)挂到整理服务的生命周期上:它随心跳
// 的 ctx 跑、随 a.memoryTidy.Stop 停(`design/23 §19.3-3` 的「可停」),因此
// 这里**不新起一条关闭链**、不新开监听端口、不加任何 env 键。清扫自身的能力面
// 只在 memory 包内(expire.go / expire_loop.go),组合根这一句只负责挂上去。
svc.AttachExpirySweep()
a.memoryTidy = svc
return nil
}
// seedMemoryTidyPanel 在 `$DATA_DIR/config/memory-tidy.json` 缺失/空白时播种一份
// 「当前生效值」快照(`memory.TidyTuningSnapshot`),使面板配置从第一次启动起就自足,
// 而不是每次都靠 `.env` 兜。失败只记日志、**绝不阻断启动**:运行期仍按 `.env` +
// 代码默认跑,行为与播种前逐字相同(与 materializeModels 同一口径)。
//
// 闸与 T1 的四键(ingestGate / t1Judge / t1PerWindow / t1WindowSec)**外加**临时
// 记忆 TTL 一键(tempTtlSec,切片 M2 加进来的第五个 review 键)在 `.env` 里
// **都没有出处**(`.env` 已冻结,它们只由这个面板文件与代码默认决定,见
// wireMemoryReview 的四个 helper:memoryIngestGateOff / memoryT1JudgeOff /
// memoryT1RateLimit / memoryTempTTL),而播种只发生在**面板文件不存在**时 —— 那正是
// 「零值 + normalizeReviewConfig」这一条路径,因此这里给零值 ReviewConfig 就是
// **当时**的生效值,不是猜的。审查开关本身(MEMORY_REVIEW_ENABLED)不在这十一键里,
// 因此不必为它去读一次 env。
func (a *app) seedMemoryTidyPanel(tidy memory.TidyConfig) {
path := runtimeconfig.DefaultMemoryTidyPath(resolveDataDir(a.cfg))
log := logger.WithSource(a.log, "memory/tidy")
// 播种函数自己会把「跳过 / 无法解析」记成带路径的日志(含 WARN),这里只报
// 「该写却没写成」那一种 —— 不重复记同一条 WARN。
if _, err := runtimeconfig.SeedMemoryTidy(path, memory.TidyTuningSnapshot(tidy, memory.ReviewConfig{}), log); err != nil {
log.Warn("memory-tidy.json 播种失败(面板配置仍缺位,.env 继续兜底)", "path", path, "error", err)
}
}
// applyMemoryTidyTuning 把 `$DATA_DIR/config/memory-tidy.json` 叠到 env 侧的
// TidyConfig 上(见 runtimeconfig.MemoryTidyTuning 的零值口径):
// - 文件里**没写** autoApply ⇒ 保持代码默认(开);
// - 写 `false` ⇒ DisableAutoApply = true(一键回到「只产建议」,不必改代码);
// - autoApplyPerRound ≤ 0 ⇒ 不覆盖,交给 normalizeTidyConfig 填默认上限;
// - **调度四键**(intervalSec / quietIntervalSec / quietStartHour /
// quietEndHour)⇒ 交 memory.ApplyTidyScheduleTuning 逐键解析(面板 >
// `.env` > 代码默认),坏值回落 + WARN。
//
// 读不到/解不开时只记 WARN 并保持 env 侧的值:那正是「回落下一档」——坏文件时
// tuning 是零值(四个调度键都是 nil),因此 ApplyTidyScheduleTuning 一个键都不覆盖,
// env 值原样留着(`.env` 也缺位时由 normalizeTidyConfig 落到代码默认)。
func (a *app) applyMemoryTidyTuning(cfg *memory.TidyConfig) {
tuning, path, err := a.readMemoryTidyTuning()
log := logger.WithSource(a.log, "memory/tidy")
if err != nil {
log.Warn(
"memory tidy tuning unreadable, falling back to the env layer (.env values kept)",
"path", path, "error", err)
} else {
cfg.DisableAutoApply = tuning.AutoApplyOff()
if tuning.AutoApplyPerRound > 0 {
cfg.AutoApplyPerRound = tuning.AutoApplyPerRound
}
}
// 调度四键在两种情形下都要过一遍:成功时按面板值覆盖,失败时 tuning 是零值
// ⇒ 原样返回(= 保持 env 值),因此不必为它再分一条路径。
*cfg = memory.ApplyTidyScheduleTuning(*cfg, tuning, log)
// 这里打的是**归一化之前**的值(quiet_interval_sec 可能还是 0 = 「缺省,等于
// intervalSec」):真正生效的两档周期由 TidyService.Start 的启动日志报。
// panel_decoded = 「文件缺失/空白/解析成功」三者之一(LoadMemoryTidyTuning 把
// 前两者都当作「还没配过」而不是错误),为 false 即坏文件 ⇒ 四个键都回落 `.env`。
log.Info(
"memory tidy tuning applied (panel > .env > code defaults)",
"path", path, "panel_decoded", err == nil, "auto_apply", !cfg.DisableAutoApply,
"auto_apply_per_round", cfg.AutoApplyPerRound,
"interval_sec", cfg.IntervalSec, "quiet_interval_sec", cfg.QuietIntervalSec,
"quiet_hours", fmt.Sprintf("%02d:00-%02d:00", cfg.QuietStartHour, cfg.QuietEndHour))
}
// readMemoryTidyTuning 读一次 `$DATA_DIR/config/memory-tidy.json`(**只读**)。
// 它把「落点 + 读取 + 路径回报」收在一处,供整理自动档与**同步入库闸**两个
// 消费者共用 —— 后者(wireMemoryReview)不属于整理服务,但它的开关就在同一个
// 文件里(零新 env 键,design/13 §2.1)。错误由调用方记 WARN 并回落代码默认值。
func (a *app) readMemoryTidyTuning() (runtimeconfig.MemoryTidyTuning, string, error) {
path := runtimeconfig.DefaultMemoryTidyPath(resolveDataDir(a.cfg))
tuning, err := runtimeconfig.LoadMemoryTidyTuning(path)
if err != nil {
return runtimeconfig.MemoryTidyTuning{}, path, err
}
return tuning, path, nil
}
// auditMemoryTidy 把一轮整理的计数写进网关审计链。它**只带计数**,绝不带
// 任何记忆正文(design §8 的 who/what/when/result 口径)。
func (a *app) auditMemoryTidy(who, what, result string) {
if a.gw != nil {
a.gw.AuditEvent(who, what, result)
return
}
logger.WithSource(a.log, "memory/tidy").Info(
"memory tidy audit dropped: gateway not ready", "who", who, "what", what, "result", result)
}