Files
Cyrene_v2/cmd/cyrene/wire_memory_tidy.go
T
AskaEth 23b6e3fe1e
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(memory): 启动时自动播种面板配置文件(缺失才写、绝不覆盖)
主人 2026-10-07:「后端自动创建独立的面板配置文件要做」。

- 启动路径(wireMemoryTidy → applyMemoryTidyTuning 之后)若
  $DATA_DIR/config/memory-tidy.json 不存在或全空白 => 写一份「当时生效值」
  (十键:既有六键 + 频率四键),此后面板接管
- 已存在(非空)=> 跳过,该分支没有任何写盘语句;损坏 => WARN + 按 .env 跑、
  一个字不写盘(不「修好它」)
- 幂等(第二次启动必落到跳过分支);原子写(tmp 0600 + rename)
- 落盘前完整性闸:缺键 / 非正周期 / 钟点越界 => WARN + 不落盘
- 值来自 TidyTuningSnapshot(先跑两个归一化再翻回正向键)=> 播种那一刻运行
  行为零变化,有强断言(归一化后配置逐字相等)
- 零新增依赖、零 env 键变更;5 个变异(含「让已存在走写盘」=> 红)
2026-10-07 20:38:37 +08:00

140 lines
8.1 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` 一直兜着。
//
// 刻意**不做**的一件事(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)
}
a.memoryTidy = svc
return nil
}
// seedMemoryTidyPanel 在 `$DATA_DIR/config/memory-tidy.json` 缺失/空白时播种一份
// 「当前生效值」快照(`memory.TidyTuningSnapshot`),使面板配置从第一次启动起就自足,
// 而不是每次都靠 `.env` 兜。失败只记日志、**绝不阻断启动**:运行期仍按 `.env` +
// 代码默认跑,行为与播种前逐字相同(与 materializeModels 同一口径)。
//
// 闸与 T1 的四键(ingestGate / t1Judge / t1PerWindow / t1WindowSec)在 `.env` 里
// **没有出处**(`.env` 已冻结,它们只由这个面板文件与代码默认决定,见
// wireMemoryReview 的三个 helper),而播种只发生在**面板文件不存在**时 —— 那正是
// 「零值 + 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)
}