package memory import ( "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/config" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding" ) // 本文件收拢记忆整理(design/23-记忆整理与去重)全部的标识、默认值与旋钮, // 使运行路径上不出现裸魔法数字(AGENTS §10),并让「设计稿默认值」 // 只有一处定义、可被复核。 // // 形态(design/23 §4 方案 A/C):整理是一件**定期后台任务**(安静时段降频)—— // 跑在专属隔离会话下、零上下文启动、只产候选**不写记忆**,绝不是会话内派 // subagent。 const ( // TidySessionID 是记忆整理运行的专属隔离会话标识(design/23 §4.1 B)。 // 它与主会话 history.MainSessionID("web")不同,因此整理不改主人的心情、 // 不进主会话泳道(cmd/cyrene 的 isConversationLane 对它恒为 false), // 默认也不进 flowPub 的图源白名单(design/23 §4.1 F 的可选小开关默认关)。 TidySessionID = "sess-memorytidy" // TidyRuleVersion 是 T0 规则集的版本号,进候选的幂等键(design/23 §9): // 规则不变时重复运行绝不重复产候选;升版后同一数据重新产候选, // 因此被 reject 的对可以重评。**改判定规则必须同时升它**。 // // ⚠️ 2026-10-07「link 不再产候选」**刻意没有**升它(不是漏了):升版的目的是 // 「让历史 reject 的对被重评」,而本次变更不改变任何一对/一条的**判定结论** // (互不包含的对仍判 keep,只是不再落一条不可应用的候选);升版只会让活库里 // 87 条既存候选凭 idempotency_key 复制出一份重复行,与主人「该删就删」相反。 // 真正需要升版的是「判定结论会变」的改动(阈值语义、包含算法、否决规则)。 TidyRuleVersion = "t0.1" // defaultTidyIntervalSec 是整理周期秒数,默认 86400 = 每天 1 次 // (design/23 §13 开放点 8 的建议值)。 defaultTidyIntervalSec = 86400 // defaultTidyQuietStartHour / defaultTidyQuietEndHour 复用 backthink 的 // 安静时段语义(默认 23:00-07:00,窗口跨午夜;两端相等且非 0 则关闭窗口)。 // // 🔴 2026-10-07 起窗口不再等于「唯一可跑时段」:它是**降频档位**的判据 // (安静时段用 defaultTidyIntervalSec 之外的另一档,见 intervalAt),并且 // 仍然决定「首次/未跑过的窗口要补跑一轮」(dueNow 判据 1/3)。 defaultTidyQuietStartHour = 23 defaultTidyQuietEndHour = 7 // defaultTidyRecallThreshold 是候选召回的余弦**召回门**,0.80 // (design/23 §5.1 T0-2 与 §13 开放点 2 的建议值)。它只决定「哪些对值得 // 看一眼」:能否合并由包含判定 + 规则否决决定,绝不退化成阈值判断 // (design/23 §1.2:「必合」0.8330 与「必不合」0.7100 只差 0.12)。 defaultTidyRecallThreshold = 0.80 // defaultTidyScanLimit 是每分区每轮的扫描上限(design/23 §9)。 defaultTidyScanLimit = 5000 // maxTidyCompareItems 是每分区每轮**真正参与两两比对**的条目上限的兜底默认 // 值(可直接从 MEMORY_TIDY_COMPARE_LIMIT 调,见 TidyConfig.CompareLimit)。 // 两两相似度是 O(n²·dim),5000 条会到十亿级乘加,后台任务也不该跑成那样。 // 2026-10-06 由 400 抬到 2000:首轮真跑 459 条的分区被旧值截到 400, // 于是 compared(705) < scanned(784) —— 截断确实如实登记了,但护栏成为结 // 果的主因本身就是缺陷。超限仍按「最新优先」截断并记一条 WARN(绝不静默), // 见 tidy_recall.go。 maxTidyCompareItems = 2000 // defaultTidyCandidateLimit 是每分区每轮的候选上限(design/23 §9)。 // 2026-10-06 由 20 抬到 200:首轮真跑四个分区同时打满旧值 ⇒ 候选数由 // 上限而不是规则决定(77 是下限)。 defaultTidyCandidateLimit = 200 // tidyLowConfidenceThreshold 是置信度否决门(design/23 §5.2-6): // confidence 不能当合并门([2]0.7 与 [5]0.8 是同一事实),但必须当否决门。 tidyLowConfidenceThreshold = 0.7 // tidyEmbedBatch 是单次 Embed 调用的文本条数上限,避免把整个分区的文本 // 一次性交给远端 embedding API。 // // 🔴 它**不是**拍出来的数字,而是 internal/runtime/embedding 里**同一条推演** // 的结果(budget.go:一次请求的耗时上界 ≤ 单次超时的一半 ⇒ 批上限 = 超时的一半 // ÷ 单条预算上界)。2026-10-13 的实测沿革: // - 旧值 64:单请求实测 6.872s(对比超时 5s)⇒ 客户端放弃、服务端继续算 ⇒ // 本机 embedding 服务被顶住,单条请求(写向量索引)也跟着超时; // - 先收到 4:单请求落回界内,但把**一次调用**放大成 ⌈N/4⌉ 个请求 —— // user 分区一轮(CompareLimit 2000 条)就是 500 个请求、审查链 51 个请求; // - 本次放到 **8**(= embedding.DefaultBatchLimit,`perItemBudget` 500ms→300ms): // 同样的分区 500 → **250** 个请求、审查链 51 → **26** 个请求,而单请求实测最大 // 1.782s,仍在 5s 之内(16 条/片已复现出 11.043s 的越界单请求,故不取更大)。 // // ⚠️ 它**不缩短整理轮的墙钟**:链路耗时由「条数 × 每条成本」主导,批次只改请求 // 数(实测 201 条在 4 条/片与 8 条/片下都是 ~21s)。「整理轮每轮把整个分区重新 // 向量化」这个放大源由**增量向量化**收口(embed_incremental.go,2026-10-30): // 索引里已有向量的行不再重嵌、算出来的向量回填索引,因此同一分区**第二轮起** // 的请求数趋近 0(第一轮仍按 ⌈条数/批⌉ 发,那是把向量算出来的一次性成本)。 // // 批上限在 embedder 内部**还有一道强制**(Embed 自己切片),因此这里即使被改大 // 也不会把服务端顶住;这一行只是让调用方别去构造注定被切开的超大切片。 tidyEmbedBatch = embedding.DefaultBatchLimit // minTidyContentRunes 是参与比对的最小内容长度(按 rune)。过短的内容 // (如单字)没有可判定的事实要素,进比对只会产出噪声,直接跳过。 minTidyContentRunes = 3 // defaultTidyApplyBatchLimit 是「批量应用候选」的单次条数上限(切片 TIDY-1)。 // 它有上限是**刻意**的:一次点下去改几百条记忆是不可回头的观感,因此宁可 // 让主人分批点,且回执如实带上 Total/Truncated(超限绝不静默少应用)。 // 它是**代码默认值**(`.env` 已冻结、design/13 §2.1:新旋钮一律走 // $DATA_DIR/config/** 或代码默认值),不新增任何 env 键。 defaultTidyApplyBatchLimit = 100 // maxTidyApplyBatchLimit 是批量应用上限的硬顶:请求给更大的值就钳到这里 // (与既有查询面「钳位而不是报错」同一约定)。 maxTidyApplyBatchLimit = 500 // defaultTidyAutoApplyPerRound 是**轮末自动落地**的每轮条数上限(2026-10-07 // 主人口径「该删就删,怎么这个记忆整理机制这么保守呢」)。 // // 它是**代码默认值**,可被 `$DATA_DIR/config/memory-tidy.json` 的 // autoApplyPerRound 覆盖(`.env` 已冻结、design/13 §2.1:新旋钮一律走 // $DATA_DIR/config/** 或代码默认值,不进 .env)。 // // 上限存在的理由与 design/23 §7 原本「merge/archive 永远人工确认」的顾虑同源: // 自动落地是**批量改记忆**,一天一轮最多 50 条,坏判据最多伤 50 条且每条都能 // revert —— 而不是一次点下去改几百条、事后无从追溯。 defaultTidyAutoApplyPerRound = 50 // maxTidyAutoApplyScan 是轮末自动档一趟**读取**待定候选的条数上限。自动档要在 // Go 侧按判据筛(判据要读候选证据 JSON,SQL 表达不了),因此它必须有个上限; // 命中上限时自动档只处理最新的一批,直到**下一轮**再继续(与扫描上限同一口径: // 最新优先 + 绝不静默,见 tidy_auto.go 的 ScanTruncated 与那条 WARN)。 maxTidyAutoApplyScan = 2000 // tidyAutoMergeMinSimilarity 是自动合并第二档判据的相似度下限:**事实集合等价 // (双向包含 = containBoth)且相似度 ≥ 0.95**。合同等价但相似度低的对说明 // 「关键要素相同、表述差得远」,那不是重复而是两种说法,交人工。 tidyAutoMergeMinSimilarity = 0.95 // defaultTidyRejectBatchLimit 是「按动作批量拒绝候选」的单次条数上限 // (2026-10-07:给活库里 501 条无执行语义的 link 候选一个干净去处)。 // // 它比批量应用的上限宽得多是**刻意的**:拒绝只写候选表的状态列,**不碰 // memory_items 任何一行**,因此没有「一次改坏几百条记忆」的风险;但仍有上限, // 且超限如实回报 total/truncated(绝不静默少做)。 defaultTidyRejectBatchLimit = 1000 // maxTidyRejectBatchLimit 是上面那个上限的硬顶(钳位而不是报错)。 maxTidyRejectBatchLimit = 10000 ) // 编译期不变式(2026-10-13):批上限一旦被调回超过 embedding 推出来的值 // (例:写回旧值 64),下面这行常量表达式会变成负数、uint 溢出 ⇒ **编译失败**。 // 这条断言把「批上限与超时同源」从注释里的承诺变成编译器守的门。 const _ = uint(embedding.DefaultBatchLimit - tidyEmbedBatch) // TidyConfig 携带记忆整理的调优旋钮。零值经 normalize 选取安全默认值, // 因此半填的 Config 仍可用。 type TidyConfig struct { // Enabled 是总开关。关闭时组合根不建服务、不跑任何整理 // (design/23 §4 的 MEMORY_TIDY_ENABLED)。 Enabled bool // Interval 是整理周期(**非安静时段**)。为零时由 IntervalSec(或其默认值)推导。 Interval time.Duration // IntervalSec 是整理周期秒数(**非安静时段**),映射自 // config.MemoryTidyConfig.IntervalSec(env),并可由 // `$DATA_DIR/config/memory-tidy.json` 的 `intervalSec` 覆盖(面板优先、`.env` // 兜底,唯一实现点是 ApplyTidyScheduleTuning)。设置了 Interval 时忽略。 IntervalSec int // QuietIntervalSec 是**安静时段**的整理周期秒数(2026-10-07 主人「或者动态 // 分配,比如白天间隔短一点,晚上到了静默时期稍微长一点」)。 // // 非正 ⇒ 等于 IntervalSec:**缺省 = 不因时段换档**,而不是「夜里不跑」 // (normalize 里那一条是唯一落点)。它没有 env 键(`.env` 已冻结),只由 // `$DATA_DIR/config/memory-tidy.json` 的 `quietIntervalSec` 提供。 QuietIntervalSec int // QuietInterval 是安静时段的整理周期。为零时由 QuietIntervalSec 推导。 QuietInterval time.Duration // QuietStartHour / QuietEndHour 以本地钟点约束低峰窗口 // (复用 backthink 语义:窗口可跨午夜,两端相等且非 0 则关闭窗口)。 // // 🔴 2026-10-07 起窗口的**语义一字未改**(inQuietHours 逐字不动),但它的 // **作用面**收窄了一格:从「整理唯一可跑的时段」变成「整理降频的时段」 // (见 intervalAt)。理由是主人的频率口径要求白天也跑,且白天跑得比夜里勤。 QuietStartHour int QuietEndHour int // RecallThreshold 是候选召回的余弦门(见 defaultTidyRecallThreshold)。 // 非正用默认值。 RecallThreshold float64 // ScanLimit 是每分区每轮扫描条数上限。非正用默认值。 ScanLimit int // CandidateLimit 是每分区每轮候选条数上限。非正用默认值。 CandidateLimit int // CompareLimit 是每分区每轮真正参与两两比对的条目上限(性能护栏)。 // 非正用默认值(maxTidyCompareItems)。 CompareLimit int // DisableAutoApply 关闭**轮末自动落地**(见 defaultTidyAutoApplyPerRound)。 // // 它是**反向**字段,且这是刻意的:bool 的零值必须落在「默认行为」上 —— // 自动落地是本机制的默认取向(2026-10-07 主人「该删就删」),若写成正向的 // AutoApply,任何没显式配过的地方(含测试与组合根构造的一半配置)都会被读成 // 「关掉」,与默认相反。要一键回到「只产建议」,写 // `$DATA_DIR/config/memory-tidy.json` 的 `{"autoApply": false}`(不加 env 键: // `.env` 已冻结)。 DisableAutoApply bool // AutoApplyPerRound 是轮末自动落地的**每轮**条数上限。非正用默认值 // (defaultTidyAutoApplyPerRound);最终还会被 maxTidyApplyBatchLimit 钳位。 AutoApplyPerRound int } // TidyConfigFromConfig 把 config.MemoryTidyConfig(env 侧旋钮)映射成 // TidyConfig,env 未暴露的项保持零值,交给 normalize 填默认值。 // // 它是**兜底层**:组合根随后用 ApplyTidyScheduleTuning 把 // `$DATA_DIR/config/memory-tidy.json` 的调度旋钮叠上来(面板优先、env 兜底); // env 没有的项(如 quietIntervalSec)在这里保持零值,由 normalize 取默认。 func TidyConfigFromConfig(cfg config.MemoryTidyConfig) TidyConfig { return TidyConfig{ Enabled: cfg.Enabled, IntervalSec: cfg.IntervalSec, QuietStartHour: cfg.QuietStartHour, QuietEndHour: cfg.QuietEndHour, RecallThreshold: cfg.RecallThreshold, ScanLimit: cfg.ScanLimit, CandidateLimit: cfg.CandidateLimit, CompareLimit: cfg.CompareLimit, } } // normalize 用设计稿默认值填充 TidyConfig 的零值字段。 func normalizeTidyConfig(cfg TidyConfig) TidyConfig { if cfg.IntervalSec <= 0 { cfg.IntervalSec = defaultTidyIntervalSec } if cfg.Interval <= 0 { cfg.Interval = time.Duration(cfg.IntervalSec) * time.Second } if cfg.QuietIntervalSec <= 0 { // 缺省 = 与普通间隔同档。放在 IntervalSec 归一之后,因此「面板/env 都没给 // quietIntervalSec」时它取的是**已解析的** intervalSec,而不是魔法数字。 cfg.QuietIntervalSec = cfg.IntervalSec } if cfg.QuietInterval <= 0 { cfg.QuietInterval = time.Duration(cfg.QuietIntervalSec) * time.Second } if cfg.QuietStartHour == 0 && cfg.QuietEndHour == 0 { cfg.QuietStartHour = defaultTidyQuietStartHour cfg.QuietEndHour = defaultTidyQuietEndHour } if cfg.RecallThreshold <= 0 { cfg.RecallThreshold = defaultTidyRecallThreshold } if cfg.ScanLimit <= 0 { cfg.ScanLimit = defaultTidyScanLimit } if cfg.CandidateLimit <= 0 { cfg.CandidateLimit = defaultTidyCandidateLimit } if cfg.CompareLimit <= 0 { cfg.CompareLimit = maxTidyCompareItems } if cfg.AutoApplyPerRound <= 0 { cfg.AutoApplyPerRound = defaultTidyAutoApplyPerRound } return cfg } // autoApplyEnabled 报告轮末自动落地是否开启。判据只有 DisableAutoApply 一个 // (见它的零值口径),因此「默认开启」是可证的一条,而不是注释里的承诺。 func (c TidyConfig) autoApplyEnabled() bool { return !c.DisableAutoApply } // quietWindowDisabled 报告低峰窗口是否被显式关闭(两端相等且非 0)。 func (c TidyConfig) quietWindowDisabled() bool { return c.QuietStartHour != 0 && c.QuietStartHour == c.QuietEndHour } // inQuietHours 报告 at 是否落在低峰窗口内。窗口可跨午夜(23:00-07:00); // 与 backthink.inQuietHours 同语义。 func (c TidyConfig) inQuietHours(at time.Time) bool { if c.quietWindowDisabled() { return true } h := at.Hour() if c.QuietStartHour <= c.QuietEndHour { return h >= c.QuietStartHour && h < c.QuietEndHour } return h >= c.QuietStartHour || h < c.QuietEndHour } // intervalAt 按 **at 所在时段** 选整理周期:安静时段用 QuietInterval,其余用 // Interval(= design/12 §N A1 给后台思考心跳定下的「窗口内降频」口径,搬进整理)。 // // 主人 2026-10-07 的原话是「**频率必须调**,可以调到 1 小时或者三小时,**或者动态 // 分配**,比如白天间隔短一点,晚上到了静默时期稍微长一点」——这个函数就是「动态 // 分配」的唯一判据。 // // 它是**纯函数**(只读 cfg 与入参),因此心跳可以在**每一拍之前**重算它:跨过 // 安静时段边界之后,下一拍自然换档,而不是启动时定死一次(调度侧的用法见 // TidyService.nextDelay)。 // // 两条口径: // - 安静判定**直接复用** inQuietHours,跨午夜口径与其余各处以字节为单位一致, // 这里不另写一份「越走越偏」的窗口算法; // - 窗口被显式关闭(两端相等且非 0)时不换档 —— 关闭窗口的意思是「没有低峰 // 这回事」,而不是「全天都算低峰」。 func (c TidyConfig) intervalAt(at time.Time) time.Duration { if c.quietWindowDisabled() { return c.Interval } if c.QuietInterval > 0 && c.inQuietHours(at) { return c.QuietInterval } return c.Interval } // nextQuietStart 返回 at 之后(严格晚于 at)的下一个低峰窗口起点: // 今天/明天的 QuietStartHour 整点。窗口关闭时返回零值时间,调用方据此 // 改走纯周期路径。 func (c TidyConfig) nextQuietStart(at time.Time) time.Time { if c.quietWindowDisabled() { return time.Time{} } start := time.Date(at.Year(), at.Month(), at.Day(), c.QuietStartHour, 0, 0, 0, at.Location()) if !start.After(at) { start = start.AddDate(0, 0, 1) } return start } // currentQuietStart 返回 at **所在**的低峰窗口起点(不晚于 at): // 落在窗口里(含跨午夜的半夜那一段)时是今天/昨天的 QuietStartHour 整点。 // 窗口关闭时返回零值时间。 // // 它服务**补跑判据**(切片 TIDY-1):判断「这个窗口是不是已经跑过」只需要 // 「窗口起点 ≤ 上次 run」这一个比较,不必去构造「窗口内哪天」这种概念 —— // 跨午夜的窗口(23:00–07:00)用日期算会算错。 func (c TidyConfig) currentQuietStart(at time.Time) time.Time { if c.quietWindowDisabled() { return time.Time{} } start := time.Date(at.Year(), at.Month(), at.Day(), c.QuietStartHour, 0, 0, 0, at.Location()) if start.After(at) { start = start.AddDate(0, 0, -1) } return start }