diff --git a/dsh-mneme/CHANGELOG.md b/dsh-mneme/CHANGELOG.md index a7a5a9ae..828d7625 100644 --- a/dsh-mneme/CHANGELOG.md +++ b/dsh-mneme/CHANGELOG.md @@ -2,6 +2,9 @@ ## [Unreleased] +- **`dream_runs` 第一次有了保留期:`dreamRunsTrimDays`(opt-in 默认 0)裁掉窗口外轮次的重载列,行与骨架一个字节都不动(#557)**:这张表是四个 `*RetentionDays` 都不覆盖、也没有任何 `DELETE` 路径的唯一一张——单行 `input` 快照体量随记忆数涨、轮数随天数涨,两个因子**相乘超线性**(报告库占约 43% 的页),再叠加热快照 `VACUUM INTO` 是整库副本,它同时放大库与备份两处开销。issue 的建议 1(加保留期键 + 启动期清理)方向对,但按 11-04 的评论对齐口径落地:维护者 2026-09-23 拍板 3 是「**裁列同意、删行不同意**」,而 `dream_runs` 的每一行能回放一次巩固是 `distill_runs` 不复用它的理由——所以这里**不删行、不判「最近 N 轮」**(那三处读最新行的消费者 `lastDreamRunAt` / `lastDreamBaseline` / `getLatestPolicyEpoch` 读的全是骨架列,不受裁剪影响,自然也不必为它们保尾)。裁的是三列重载:`input`(记忆库快照,可由库重建)、`decisions`(原始 LLM 决策)、`outcome`(action 映射)——issue 评论实测列文本 13.26 MB 里 50.1% + 30.5% + 17.8%;`snapshot_hash` / `status` / `provider` / `receipt` / `policy_epoch` / `store_count` 等骨架列与行本身永远保留,窗口外轮次的离线回放降级为**骨架级**(与 `reclaim` 清 input、`dreamInputSummaryEnabled` 的瘦身同一条口径,`docs/CONFIGURATION.md` 写明三处互不冲突)。**`organize` 行整体豁免**:它的 `input` 是 apply 的重放载荷、`outcome` 挂着 `dry_run_id` / `applied_at` 链,裁了会让 apply 静默空转并锁死报告——谓词与 `clearDreamRunInputs` 同一条边界。**opt-in 默认 0 = 不裁**:升级后行为逐字节不变,键的形态对齐 `recallRetentionDays` 一族(顶层扁平键、只从配置文件改、`settings.js` 白名单同族现状一样不进——族里还没人开「进面板」这个头,要开得同时补 `api.js` 的 NESTED 表与 `client.js` 的面板行)。启动期执行(与其余四张表同策略:读装配后的 cfg、失败只 warn 不挡启动),**真裁到了才出一行 info**——常态一个字都不出,别把启动日志刷成心跳。`dreamRunsTrimStats` 与 `trimDreamRunPayloads` 共用同一谓词,报告数字与执行数字同源。回归测试 7 条(窗口外裁三列且骨架列逐列不变 / 行永不删 / organize 整体豁免 / 重跑 changes 为 0 / stats 与执行同口径 / 空表与无命中不抛 / 配置键默认 0 与边界 + 文档面列出该键 + 接线锁「0 不裁、读 cfg、失败只 warn」)。变异自证 4 例(删 organize 豁免 → 2 红;`> 0` 改 `>= 0` → 1 红;默认改 90 → 1 红;裁剪 SQL 漏掉 `outcome` → 3 红),恢复后全绿。 + +## [0.14.0-beta.3] - 2026-11-12 - **品牌视觉换新:README 头图与插件列表图标**。三处 README(根、`dsh-mneme/`、`mcp/`)顶部改用 `images/mneme-banner-hero.webp`(1672×941,282 KiB)——原图 2.1 MiB 的 PNG 会让 `images/` 目录体积翻倍。根目录用相对路径,两个子目录用 raw 绝对地址:npm 详情页不解析相对图片路径,`../images/...` 在 npmjs.com 上必然裂图。 - **插件列表图标从手绘 SVG 换成位图**:`package.json` 的 `icon` 改为 `assets/icon.png`(256×256 RGBA、圆角透明、120 KiB,仍在宿主 `iconOf()` 的 256 KiB 上限内);`assets/icon.svg` 删除。列表图标不再要求与客户端内联 mark(`MNEME_MARK_D`)同形,`test/client.test.js` 的图标用例改验 PNG 魔数、IHDR 边长、位深与 RGBA 通道——宿主对坏文件是静默回落成通用插画,不报错。 diff --git a/dsh-mneme/README.md b/dsh-mneme/README.md index d7a81535..56dd3eb0 100644 --- a/dsh-mneme/README.md +++ b/dsh-mneme/README.md @@ -523,6 +523,7 @@ dsh web | `sleepReasoningEffort` | 未配置=最低档 | sleep 各阶段 LLM 推理档位(同 dreamReasoningEffort 语义,v0.7.26) | | `recallRecordDefault` | `true` | recall_runs 记录默认开(显式传 `recordRecall:false` 的调用方不受影响);注入同样落账(`mode='inject'`,随本开关,#217) | | `recallRetentionDays` | `90` | recall_runs 滚动清理保留天数 | +| `dreamRunsTrimDays` | `0` | dream_runs 重载列(input/decisions/outcome)的启动期裁剪窗口(天);0 = 不裁。裁列不删行(#557),organize 行豁免,窗口外回放降级为骨架级 | | `hotMemoryRounds` | `5` | 会话级短期热记忆轮次(v0.5.0):最近 N 轮对话滚动注入,从会话事件日志无状态重建、不落库 | | `hotMemoryMaxTokens` | `2000` | 热记忆 token 预算(v0.5.0,200-32000),超出滚动截断 | | `selectiveInjectEnabled` | `true` | 选择性注入(v0.5.0):query 向量可用时注入候选按主题相似度重排,替代固定规则序 | diff --git a/dsh-mneme/docs/CONFIGURATION.md b/dsh-mneme/docs/CONFIGURATION.md index 314aff96..d47c1129 100644 --- a/dsh-mneme/docs/CONFIGURATION.md +++ b/dsh-mneme/docs/CONFIGURATION.md @@ -306,6 +306,7 @@ | `evalPersistTestResults` | `false` | 检索评测快照落 `recall_evals` | 生产检索恒只进 `recall_runs`,与此键无关 | | `recallRecordDefault` | `true` | `recall_runs` 记录默认开 | 显式传 `false` 的调用方不受影响 | | `recallRetentionDays` | `90` | `recall_runs` 滚动清理保留天数 | — | +| `dreamRunsTrimDays` | `0` | `dream_runs` 重载列(input / decisions / outcome)的启动期裁剪窗口(天) | opt-in,`0` = 不裁(#557:该表单行体量随记忆数、轮数随天数,相乘超线性,是四个保留期键都不覆盖的无界大户)。**裁列不删行**(拍板 3):骨架列与 receipt 永远保留,窗口外轮次的离线回放降级为骨架级;`organize` 行整体豁免(input 是 apply 的重放载荷)。与 `dreamInputSummaryEnabled`(写入期瘦身)、`dsh-mneme reclaim`(手动回收)互不冲突:三处都只是把对应列置空 | | `trustEpistemicWeighting` | `false` | 来源可信度(observation/subjective/inferred)参与排序与标注 | opt-in;关时 epistemic_status 只是惰性数据 | ### 重排层 diff --git a/dsh-mneme/lib/config.js b/dsh-mneme/lib/config.js index e79d4947..4ab71de5 100644 --- a/dsh-mneme/lib/config.js +++ b/dsh-mneme/lib/config.js @@ -1053,6 +1053,16 @@ export const Config = z.object({ recallRecordDefault: z.boolean().default(true), // recall_runs 滚动清理保留天数。 recallRetentionDays: z.natural().min(1).max(3650).default(90), + // dream_runs 重载列(input 快照 / decisions / outcome)的启动期裁剪窗口(天), + // opt-in 默认 0 = 不裁(现状逐字节不变;#557:这张表是四个 *RetentionDays 都不 + // 覆盖的无界增长大户,单行体量随记忆数涨、轮数随天数涨,相乘超线性)。拍板 3: + // 裁列同意、删行不同意——行与骨架列永远保留,回放能力对窗口外的轮次降级为骨架 + // 级(与 reclaim 清 input、dreamInputSummaryEnabled 的瘦身同一条口径);organize + // 行整体豁免(input 是 apply 的重放载荷)。顶层扁平键、只从配置文件改,与 + // recallRetentionDays 同族(进面板要同时补 api.js 的 NESTED 表与 client.js 的 + // 面板行,族里还没人开这个头)。窗口别配得比「最近几轮」还短:/dream-status 的 + // 最近 5 轮里被裁掉的老轮,decisions.demotion 会退化成 null(骨架与时间戳不受影响)。 + dreamRunsTrimDays: z.natural().min(0).max(3650).default(0), // --- tool exposure: 慢模型/轻量模型的工具往返节流(v0.8.5)---------------- // 跨会话记忆已由 inject.js 每轮自动注入系统提示词,memory_search 只用于 diff --git a/dsh-mneme/lib/index.js b/dsh-mneme/lib/index.js index 6f0edeb6..00b03bf2 100644 --- a/dsh-mneme/lib/index.js +++ b/dsh-mneme/lib/index.js @@ -333,6 +333,21 @@ export const apply = (ctx, config) => { ctx.logger?.warn?.(`[dsh-mneme] recall_runs retention purge failed: ${String(err)}`); } + // #557:dream_runs 重载列(input / decisions / outcome)的启动期裁剪。opt-in 默认 + // 0 = 不裁(零行为变化);开窗后与上面四处同策略——读装配后的 cfg、失败只 warn + // 不挡启动。裁的是列不是行(拍板 3),organize 行豁免(store 层谓词,与 reclaim + // 的 clearDreamRunInputs 同一条边界)。真裁到了才出一行 info:常态(库还小 / + // 没开窗)一个字都不出,别把启动日志刷成心跳。 + try { + const dreamRunsTrimDays = Number.isInteger(cfg.dreamRunsTrimDays) ? cfg.dreamRunsTrimDays : 0; + if (dreamRunsTrimDays > 0) { + const trimmed = store.trimDreamRunPayloads(new Date(Date.now() - dreamRunsTrimDays * 86400000).toISOString()); + if (trimmed > 0) ctx.logger?.info?.(`[dsh-mneme] dream_runs payload trim: ${trimmed} run(s) beyond the ${dreamRunsTrimDays}d window (columns cleared, rows kept)`); + } + } catch (err) { + ctx.logger?.warn?.(`[dsh-mneme] dream_runs payload trim failed: ${String(err)}`); + } + // 记忆语言(memory.language):本实例逐层传入 inject / summarize / dream / // sleep / mirror,多实例(如 agent preset 内挂载)互不影响。 const mirror = createMirror(memoryDir, langOf(cfg)); diff --git a/dsh-mneme/lib/store.js b/dsh-mneme/lib/store.js index 0c62cb5e..ce6dae77 100644 --- a/dsh-mneme/lib/store.js +++ b/dsh-mneme/lib/store.js @@ -3871,6 +3871,38 @@ export function createStore(path) { ).run(before).changes; } + // #557 的裁列面:input 之外的大头是 decisions / outcome 两列 JSON(issue 评论区 + // 实测 13.26 MB 列文本里 50.1% + 30.5%)。拍板 3 的口径是「裁列同意、删行不同意」 + // ——行与骨架列(status / provider / receipt / policy_epoch / store_count…)永远 + // 保留,回放能力对窗口外的轮次降级为骨架级(与 reclaim 清 input、#625 的瘦身同 + // 一条口径)。organize 行整体豁免:input 是 apply 的重放载荷、outcome 挂着 + // dry_run_id / applied_at 链,裁了会让 apply 静默空转(clearDreamRunInputs 的同一 + // 条边界)。读侧已逐个核对:lastDreamRunAt / lastDreamBaseline / getLatestPolicyEpoch + // 读的都是骨架列,/dream-status 的 decisions.demotion 是可选链(裁掉的老轮退化成 + // null);reclaim 的 A 项刻意不碰 decisions/outcome——那是「随手跑的无损回收」的 + // 边界,这里是显式配置键(dreamRunsTrimDays,opt-in 默认 0 = 关)驱动的启动期裁剪。 + function dreamRunsTrimStats(before) { + const row = db.prepare( + `SELECT count(*) AS c, + COALESCE(SUM(LENGTH(input)), 0) + COALESCE(SUM(LENGTH(decisions)), 0) + + COALESCE(SUM(LENGTH(outcome)), 0) AS b + FROM dream_runs + WHERE created_at < ? AND run_type != 'organize' + AND (input IS NOT NULL OR decisions IS NOT NULL OR outcome IS NOT NULL)` + ).get(before); + return { runs: row.c, bytes: row.b }; + } + + /** 裁掉窗口外 run 的三列重载(organize 豁免)。返回实际改动的行数;重跑幂等。 */ + function trimDreamRunPayloads(before) { + return db.prepare( + `UPDATE dream_runs + SET input = NULL, decisions = NULL, outcome = NULL + WHERE created_at < ? AND run_type != 'organize' + AND (input IS NOT NULL OR decisions IS NOT NULL OR outcome IS NOT NULL)` + ).run(before).changes; + } + /** * 归档行仍带的向量统计(#275 B 项)。检索 SQL 恒带 `archived = 0`,所以这部分 * 向量按定义不可达,清掉不改变任何检索结果。 @@ -5986,6 +6018,8 @@ export function createStore(path) { // 存储生命周期(#275 第一批):无损回收的统计、清理与体积口径。 dreamRunInputStats, clearDreamRunInputs, + dreamRunsTrimStats, + trimDreamRunPayloads, archivedEmbeddingStats, clearArchivedEmbeddings, storageStats, diff --git a/dsh-mneme/src/config.js b/dsh-mneme/src/config.js index e79d4947..4ab71de5 100644 --- a/dsh-mneme/src/config.js +++ b/dsh-mneme/src/config.js @@ -1053,6 +1053,16 @@ export const Config = z.object({ recallRecordDefault: z.boolean().default(true), // recall_runs 滚动清理保留天数。 recallRetentionDays: z.natural().min(1).max(3650).default(90), + // dream_runs 重载列(input 快照 / decisions / outcome)的启动期裁剪窗口(天), + // opt-in 默认 0 = 不裁(现状逐字节不变;#557:这张表是四个 *RetentionDays 都不 + // 覆盖的无界增长大户,单行体量随记忆数涨、轮数随天数涨,相乘超线性)。拍板 3: + // 裁列同意、删行不同意——行与骨架列永远保留,回放能力对窗口外的轮次降级为骨架 + // 级(与 reclaim 清 input、dreamInputSummaryEnabled 的瘦身同一条口径);organize + // 行整体豁免(input 是 apply 的重放载荷)。顶层扁平键、只从配置文件改,与 + // recallRetentionDays 同族(进面板要同时补 api.js 的 NESTED 表与 client.js 的 + // 面板行,族里还没人开这个头)。窗口别配得比「最近几轮」还短:/dream-status 的 + // 最近 5 轮里被裁掉的老轮,decisions.demotion 会退化成 null(骨架与时间戳不受影响)。 + dreamRunsTrimDays: z.natural().min(0).max(3650).default(0), // --- tool exposure: 慢模型/轻量模型的工具往返节流(v0.8.5)---------------- // 跨会话记忆已由 inject.js 每轮自动注入系统提示词,memory_search 只用于 diff --git a/dsh-mneme/src/index.js b/dsh-mneme/src/index.js index 6f0edeb6..00b03bf2 100644 --- a/dsh-mneme/src/index.js +++ b/dsh-mneme/src/index.js @@ -333,6 +333,21 @@ export const apply = (ctx, config) => { ctx.logger?.warn?.(`[dsh-mneme] recall_runs retention purge failed: ${String(err)}`); } + // #557:dream_runs 重载列(input / decisions / outcome)的启动期裁剪。opt-in 默认 + // 0 = 不裁(零行为变化);开窗后与上面四处同策略——读装配后的 cfg、失败只 warn + // 不挡启动。裁的是列不是行(拍板 3),organize 行豁免(store 层谓词,与 reclaim + // 的 clearDreamRunInputs 同一条边界)。真裁到了才出一行 info:常态(库还小 / + // 没开窗)一个字都不出,别把启动日志刷成心跳。 + try { + const dreamRunsTrimDays = Number.isInteger(cfg.dreamRunsTrimDays) ? cfg.dreamRunsTrimDays : 0; + if (dreamRunsTrimDays > 0) { + const trimmed = store.trimDreamRunPayloads(new Date(Date.now() - dreamRunsTrimDays * 86400000).toISOString()); + if (trimmed > 0) ctx.logger?.info?.(`[dsh-mneme] dream_runs payload trim: ${trimmed} run(s) beyond the ${dreamRunsTrimDays}d window (columns cleared, rows kept)`); + } + } catch (err) { + ctx.logger?.warn?.(`[dsh-mneme] dream_runs payload trim failed: ${String(err)}`); + } + // 记忆语言(memory.language):本实例逐层传入 inject / summarize / dream / // sleep / mirror,多实例(如 agent preset 内挂载)互不影响。 const mirror = createMirror(memoryDir, langOf(cfg)); diff --git a/dsh-mneme/src/store.js b/dsh-mneme/src/store.js index 0c62cb5e..ce6dae77 100644 --- a/dsh-mneme/src/store.js +++ b/dsh-mneme/src/store.js @@ -3871,6 +3871,38 @@ export function createStore(path) { ).run(before).changes; } + // #557 的裁列面:input 之外的大头是 decisions / outcome 两列 JSON(issue 评论区 + // 实测 13.26 MB 列文本里 50.1% + 30.5%)。拍板 3 的口径是「裁列同意、删行不同意」 + // ——行与骨架列(status / provider / receipt / policy_epoch / store_count…)永远 + // 保留,回放能力对窗口外的轮次降级为骨架级(与 reclaim 清 input、#625 的瘦身同 + // 一条口径)。organize 行整体豁免:input 是 apply 的重放载荷、outcome 挂着 + // dry_run_id / applied_at 链,裁了会让 apply 静默空转(clearDreamRunInputs 的同一 + // 条边界)。读侧已逐个核对:lastDreamRunAt / lastDreamBaseline / getLatestPolicyEpoch + // 读的都是骨架列,/dream-status 的 decisions.demotion 是可选链(裁掉的老轮退化成 + // null);reclaim 的 A 项刻意不碰 decisions/outcome——那是「随手跑的无损回收」的 + // 边界,这里是显式配置键(dreamRunsTrimDays,opt-in 默认 0 = 关)驱动的启动期裁剪。 + function dreamRunsTrimStats(before) { + const row = db.prepare( + `SELECT count(*) AS c, + COALESCE(SUM(LENGTH(input)), 0) + COALESCE(SUM(LENGTH(decisions)), 0) + + COALESCE(SUM(LENGTH(outcome)), 0) AS b + FROM dream_runs + WHERE created_at < ? AND run_type != 'organize' + AND (input IS NOT NULL OR decisions IS NOT NULL OR outcome IS NOT NULL)` + ).get(before); + return { runs: row.c, bytes: row.b }; + } + + /** 裁掉窗口外 run 的三列重载(organize 豁免)。返回实际改动的行数;重跑幂等。 */ + function trimDreamRunPayloads(before) { + return db.prepare( + `UPDATE dream_runs + SET input = NULL, decisions = NULL, outcome = NULL + WHERE created_at < ? AND run_type != 'organize' + AND (input IS NOT NULL OR decisions IS NOT NULL OR outcome IS NOT NULL)` + ).run(before).changes; + } + /** * 归档行仍带的向量统计(#275 B 项)。检索 SQL 恒带 `archived = 0`,所以这部分 * 向量按定义不可达,清掉不改变任何检索结果。 @@ -5986,6 +6018,8 @@ export function createStore(path) { // 存储生命周期(#275 第一批):无损回收的统计、清理与体积口径。 dreamRunInputStats, clearDreamRunInputs, + dreamRunsTrimStats, + trimDreamRunPayloads, archivedEmbeddingStats, clearArchivedEmbeddings, storageStats, diff --git a/dsh-mneme/test/dream-runs-trim.test.js b/dsh-mneme/test/dream-runs-trim.test.js new file mode 100644 index 00000000..4abb9d94 --- /dev/null +++ b/dsh-mneme/test/dream-runs-trim.test.js @@ -0,0 +1,153 @@ +// #557:dream_runs 重载列的启动期裁剪(dreamRunsTrimDays)。 +// +// 这张表此前是四个 *RetentionDays 都不覆盖的无界增长大户:单行 input 快照体量 +// 随记忆数涨、轮数随天数涨,两个因子相乘超线性(报告库占约 43% 的页)。拍板 3 +// 的边界是「裁列同意、删行不同意」,所以本批锁三件事: +// 1. 行为锁——窗口外的行被裁掉三列、行还在;窗口内不动;organize 整体豁免。 +// 2. 幂等锁——重跑一次 changes 为 0,不重复改写(启动期每次都会调)。 +// 3. 接线锁——index.js 读装配后的 cfg、opt-in(0 不裁)、失败只 warn 不挡启动。 +// 启动期清理只在运维时才跑到,运行时冒烟测不到「这一行被挪回装配之前了」, +// 所以用源码文本断言(与 failure-retention / recall-retention 同款)。 +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { Config } from "../src/config.js"; +import { createStore } from "../src/store.js"; + +const here = dirname(fileURLToPath(import.meta.url)); +const srcText = (name) => readFileSync(join(here, "..", "src", name), "utf8"); +const DAY = 86400000; +const NOW = Date.parse("2026-11-12T00:00:00.000Z"); +const daysAgo = (n) => new Date(NOW - n * DAY).toISOString(); + +function seedRun(store, id, createdAt, overrides = {}) { + store.saveDreamRun({ + id, + created_at: createdAt, + status: "ok", + snapshot_hash: `h-${id}`, + input_count: 1, + input: JSON.stringify([{ id: "m1", type: "project", title: "t", content: "x".repeat(400) }]), + decisions: JSON.stringify([{ action: "keep", ids: ["m1"] }]), + outcome: JSON.stringify({ byId: { m1: "keep" } }), + receipt: `dsh-mneme:run:${id}:ok:h-${id}`, + ...overrides, + }); +} + +const raw = (store, id) => + store.db.prepare("SELECT input, decisions, outcome, status, snapshot_hash, receipt, run_type, applied FROM dream_runs WHERE id = ?").get(id); +const allIds = (store) => + store.db.prepare("SELECT id FROM dream_runs ORDER BY id").all().map((r) => r.id); + +test("裁列:窗口外的 run 三列置空、行与骨架列完好;窗口内一行不动", () => { + const store = createStore(":memory:"); + seedRun(store, "old", daysAgo(200)); + seedRun(store, "fresh", daysAgo(1)); + const before = raw(store, "old"); + + const changed = store.trimDreamRunPayloads(daysAgo(90)); + assert.equal(changed, 1, "只有窗口外那一行被裁"); + + const oldRow = raw(store, "old"); + assert.equal(oldRow.input, null); + assert.equal(oldRow.decisions, null); + assert.equal(oldRow.outcome, null); + // 删行不同意的形状锁:行还在,骨架与 receipt 一列不动(离线回放降级为骨架级, + // 不是「这一轮没跑过」)。 + assert.deepEqual(allIds(store), ["fresh", "old"], "行永不删"); + for (const col of ["status", "snapshot_hash", "receipt", "run_type", "applied"]) { + assert.equal(oldRow[col], before[col], `${col} 是骨架列,裁列不许动它`); + } + // 窗口内完全不动 + const freshRow = raw(store, "fresh"); + assert.notEqual(freshRow.input, null); + assert.notEqual(freshRow.decisions, null); + assert.notEqual(freshRow.outcome, null); + store.close(); +}); + +test("organize 行整体豁免:input 是 apply 的重放载荷,窗口外也不许裁", () => { + const store = createStore(":memory:"); + seedRun(store, "organize-old", daysAgo(200), { + run_type: "organize", + input: JSON.stringify([{ index: 0, candidate: { type: "project", title: "候选", content: "正文" } }]), + outcome: JSON.stringify({ dry_run_id: "organize-old" }), + }); + seedRun(store, "auto-old", daysAgo(200)); + + assert.equal(store.trimDreamRunPayloads(daysAgo(90)), 1, "只裁 auto 行"); + const org = raw(store, "organize-old"); + assert.notEqual(org.input, null, "organize 的 input 必须留下(否则 apply 静默空转)"); + assert.notEqual(org.outcome, null, "organize 的 outcome 挂着 dry_run_id / applied_at 链"); + assert.equal(raw(store, "auto-old").input, null); + store.close(); +}); + +test("幂等:重跑一次 changes 为 0(启动期每次都调,不能每次重写整批行)", () => { + const store = createStore(":memory:"); + seedRun(store, "old", daysAgo(200)); + assert.equal(store.trimDreamRunPayloads(daysAgo(90)), 1); + assert.equal(store.trimDreamRunPayloads(daysAgo(90)), 0, "第二次没有可裁的行"); + store.close(); +}); + +test("统计与裁剪同口径:stats 的 runs/bytes 覆盖的全是裁得掉的行", () => { + const store = createStore(":memory:"); + seedRun(store, "old-a", daysAgo(300)); + seedRun(store, "old-b", daysAgo(120)); + seedRun(store, "fresh", daysAgo(2)); + seedRun(store, "org", daysAgo(300), { run_type: "organize" }); + + const stats = store.dreamRunsTrimStats(daysAgo(90)); + assert.equal(stats.runs, 2, "窗口外两条 auto 行;organize 与窗口内都不算"); + assert.ok(stats.bytes > 0, "字节数是三列之和,非零"); + // 报告的数字必须与执行的数字同源——否则 dry-run 里报 2 条、真跑裁 1 条 + assert.equal(store.trimDreamRunPayloads(daysAgo(90)), stats.runs); + assert.equal(store.dreamRunsTrimStats(daysAgo(90)).runs, 0, "裁完再统计为 0"); + store.close(); +}); + +test("空表 / 没有可裁行时返回 0 且不抛", () => { + const store = createStore(":memory:"); + assert.equal(store.trimDreamRunPayloads(daysAgo(90)), 0); + seedRun(store, "fresh", daysAgo(1)); + assert.equal(store.trimDreamRunPayloads(daysAgo(90)), 0); + store.close(); +}); + +test("配置键 dreamRunsTrimDays:opt-in 默认 0、单位是天、边界钉死", () => { + assert.equal(Config({}).dreamRunsTrimDays, 0, "默认不裁——升级后的行为必须逐字节不变"); + assert.equal(Config({ dreamRunsTrimDays: 7 }).dreamRunsTrimDays, 7); + assert.equal(Config({ dreamRunsTrimDays: 3650 }).dreamRunsTrimDays, 3650); + assert.throws(() => Config({ dreamRunsTrimDays: -1 }), "负数没有意义"); + assert.throws(() => Config({ dreamRunsTrimDays: 3651 })); + assert.throws(() => Config({ dreamRunsTrimDays: 2.5 })); + // 形状锁:与 recallRetentionDays 一族同形(顶层扁平键 + natural + 上限 3650)。 + // 下限刻意是 0 而不是 1——0 = 关,这一族里唯一以「关」为默认值的键。 + assert.match(srcText("config.js"), /dreamRunsTrimDays: z\.natural\(\)\.min\(0\)\.max\(3650\)\.default\(0\)/); + for (const doc of ["README.md", "docs/CONFIGURATION.md"]) { + const text = readFileSync(join(here, "..", doc), "utf8"); + assert.ok(text.includes("`dreamRunsTrimDays`"), `${doc} 必须列出 dreamRunsTrimDays(加键不改文档正是这一族的欠账来源)`); + } +}); + +test("接线锁:index.js 读装配后的 cfg、0 不裁、失败只 warn", () => { + const index = srcText("index.js"); + const callAt = index.indexOf("store.trimDreamRunPayloads("); + assert.ok(callAt > 0, "index.js 必须调用 store.trimDreamRunPayloads"); + // 面板/预设合进来的值只有装配后可见——这族四个键的清理全都读 cfg 而不是 rawCfg + const cfgAt = index.indexOf("const cfg = { ...applyLightModePreset("); + assert.ok(cfgAt > 0 && cfgAt < callAt, "裁剪必须排在 cfg 装配之后"); + const block = index.slice(callAt - 900, callAt + 400); + assert.match(block, /cfg\.dreamRunsTrimDays/, "窗口必须取自配置键"); + assert.match(block, /dreamRunsTrimDays > 0/, "0 = 关:不许把「没配」读成「裁今天以前的」"); + assert.match(block, /Number\.isInteger\(/, "非法值要能落回默认,别让 NaN 传给 SQL"); + assert.match(block, /dreamRunsTrimDays \* 86400000/, "键的单位是天,换算在这儿"); + assert.match(block, /catch/, "失败必须兜住"); + // 「只 warn」不是修辞:这族清理唯一的事后线索就是那行日志;而 warn 必须在 + // catch 里、不能反过来挡住启动。 + assert.match(block, /logger\?\.warn\?\./, "失败要出声"); +}); \ No newline at end of file