# dsh-idle-compactor(中文) [English](README.md) | [简体中文](README.zh.md) 按「闲置时长」触发的上下文压缩插件:会话上下文涨过设定的体量下限、并且安静了设定这么久,就自动压一次。 下次回到这个会话,看到的是已收敛的历史,而不是每次请求都拖着一条没人再看的长尾巴。 DSH 内置的自动压缩只认两个触发条件:请求压力 `pressure` 与提供方确认的上下文溢出。它不看「这个会话 多久没人用了」。本插件补的就是这一维 —— 闲置时长和上下文体量,两个都得过线才动手。 ## 环境要求 - 带 `compaction` seam 与 `tokenMeter` 的 DeepSeek Harness(在 `0.1.2-alpha.3` 上验证)。 - 零运行时依赖:源码里的 `@deepseek-ai/*` 全是 `import type`,编译后消失,`lib/index.js` 是一个自包含的 ESM 文件。 ## 安装 ```sh git clone https://github.com/QuanhuZeYu/dsh-idle-compactor.git dsh plugin --profile web add ./dsh-idle-compactor # pnpm 9 往 workspace root 加依赖需要显式 -w: dsh plugin --profile web add -w ./dsh-idle-compactor ``` 装完重启 profile:patch 层是热加载的,bundle 挂载不是。 bundle 自带已提交的 `lib/` 产物,所以无论 git 还是路径安装都不触发构建脚本,也就不需要 `allowBuilds` 授权。 `dsh plugin add` 做的事就是把 bundle 追加进 profile 的层栈,它的 `cordis.patch.yml` 才会生效。想手工配,就往 `~/.dsh/profiles/web/package.json` 里写这两处: ```json { "dsh": { "profile": { "bundles": ["@deepseek-ai/dsh-base", "@deepseek-ai/dsh-web-app", "dsh-idle-compactor"] } }, "dependencies": { "dsh-idle-compactor": "link:../../dsh-idle-compactor" } } ``` ## 判定条件 每 `scanMs`(默认 30 秒)扫一遍当前进程里的活体 agent,全部满足才压: | 条件 | 依据 | |---|---| | agent 处于 `idle`(没有回合、没有维护任务在跑) | `agent.status` | | 会话最新一条日志事件距今超过 `idleMs` | `session.events.at(-1).time`(durable,重启不会把老会话误判成新会话) | | 实测请求压力 ≥ `thresholdTokens` | `ctx.tokenMeter.measure(session).totalTokens` | | 模型可见表面也 ≥ `thresholdTokens` | 同一次 measure 的 `surfaceTokens` | | 上次压缩之后确实又有新内容 | 每会话 `compactedAt` 水位 | | 冷却 / 失败退避已到期 | `cooldownMs`、`retryBackoffMs` | | 该会话没有被归档 | `ctx.workspaceRegistry.archivedSessionIds` | | 该会话没有还在等的后台工作 | 自己名下未结的 `ctx.jobs`、自己收件箱、以及它作为父会话的活体子会话 | 归档只是把会话从所有一级分组表面隐藏掉,并且**故意留下活体 agent 继续跑**。所以扫描必须显式点名归档 id: 隐藏一段对话不等于授权改写它给模型看的内容。归档集合还在加载中的会话同样跳过 —— 分类不明的会话一律不动。 ### 压力和表面为什么要各查一遍 `totalTokens` 是**请求压力**:锚在上一次成功调用的 provider usage 上,只有下一次调用才会重新定价。 `surfaceTokens` 才是压缩能动的那部分 —— 模型可见表面的实际内容。刚压完的会话会呈现「压力仍高达 33 万、 表面只剩 7 千」的状态;此时再压只是把已经很短的上下文又摘要一遍,白丢细节。所以两个数都要过线才动手 (这种「压力过线、表面不够」的跳过会记在 debug 级日志里)。 这也是为什么界面上的上下文数字在压缩之后可能还是很高:它显示的是压力锚点,要等下一次请求才重新定价。 执行走标准 seam `ctx.compaction.compactNow(agent, signal)`,也就是 `/compact` 用的同一个入口:轮次之间的 maintenance 事务,选区与保留策略仍由压缩后端决定。插件自己不选区、不写摘要。 ### 安静不等于干完了 父代理派完子代理、自己结束回合之后,按 loop 的判定它就是 `idle`;而这段等待多久只取决于子代理,跟 `idleMs` 无关 —— 一次委托可能跑十分钟,也可能跑三小时。在等待期间压缩父会话,等于把它马上要回去接续 的那份上下文先改写掉。所以扫描还会查三本账: - 本会话名下的未结束 job(`ctx.jobs.list(agent)`,只算 `ownerSession` 就是本会话的那些;因此一个跑很久 的后台 `pwsh` 构建同样会把住它); - 本会话的收件箱,那里可能已经落了通知但还没开回合; - 本会话直接或者间接当父会话的**活体子会话**。 第三本账不是重复的。continuable 子代理(以及被 `send_message` 唤醒的子代理)在 harness 里**根本不登记 job**,而子代理 driver 在「已接受 prompt」和「真的开回合」之间那一段还会报 `idle`;所以只要某个后代在 安静窗口之内产生过任何日志,就算作在飞的活 —— 包括仍在创建窗口里的。已经安静满 `idleMs` 的子会话不再 把住父会话;不在本进程里的冷子会话本来就管不住,因为没有东西会替它唤醒父会话。 `waitWarnMs` 只做通报不做放行:一段等待超过它,就在这一段里警告一次,然后继续等。 ### 为什么 bundle 还要额外启用一个 host 平面的压缩后端 Agent preset 把 `compaction-basic` 挂在 `isolate` realm 里,而 `dsh-web-app` 关掉了 host 平面那一份,host 光纤 读不到 realm 实例。所以本 bundle 的 patch 层把 host 那一份重新启用并设 `auto: false`:它只应答闲置 `compactNow()`, 不注册自己的压力压缩,压力压缩仍完全归 preset 那份后端管。附带好处:`minimal` 模式的会话本来根本没有压缩 后端,现在也能被压。 ## 配置 写在 profile 的 `cordis.patch.yml` 里(后层会覆盖整份 config,要保留的键请一并重写): ```yaml - id: idle-compactor config: thresholdTokens: 131072 # 128K idleMs: 600000 # 10 分钟 ``` | 键 | 默认 | 含义 | |---|---|---| | `enabled` | `true` | 关掉就完全不注册扫描。 | | `thresholdTokens` | `131072` | 上下文体量下限;要十进制 128K 就写 `128000`。 | | `idleMs` | `600000` | 闲置窗口。 | | `scanMs` | `30000` | 扫描间隔,决定闲置达标后最多再等多久动手。 | | `includeSubagents` | `false` | 是否也压 `origin: subagent` 的子会话。 | | `excludeArchived` | `true` | 跳过 workspace registry 标记为归档的会话。 | | `skipAwaitingWork` | `true` | 有未结束后台工作(自己的或活体后代的)时一律不压。 | | `waitWarnMs` | `10800000` | 一段等待把会话把住这么久就警告一次(每段只警告一次)。 | | `cooldownMs` | `60000` | 同一会话两次压缩的最小间隔。 | | `retryBackoffMs` | `300000` | 失败或「无可选区」后的退避。 | | `maxPerScan` | `1` | 单轮最多压几个会话,限制并发摘要请求。 | | `dryRun` | `false` | 只记日志不真压。 | 未知键名或越界值在装载时报错,不会静默兜底。 ## 模型侧影响 - 每次落地压缩多一次摘要请求,由压缩后端用该会话的路由模型发起;不往对话里插任何提示,也不加 prompt section。 - 模型可见的唯一变化,就是替换掉被压区段的那一个 checkpoint 节点。 - 低于 `thresholdTokens` 的会话测一次就转入下一轮,不会每 tick 重复测量。 ## 压缩不会做什么 日志永远不被重写。一次压缩追加 `compaction/start`、`compaction/summary`、`compaction/end`,再用一条带 `surfaceOp: replace` 的 `user/message` 替换选中区段;`shadowedSeqs` 记录了被移出模型可见表面的每一条事件, 原文仍在会话日志里,消费方可以读回。存储索引和归档标记都不变。 ## 已知限制与待办 - **只管活体会话。** 进程里没有 agent 的会话既没有可测的 surface,也没有承载 maintenance 的 agent,只有被 打开之后才会进入候选。要覆盖冷会话,就得为候选者临时 resume 一个 agent;`includeSubagents` 默认关掉也是 同一个考虑:后台子会话本来就归父会话的生命周期管。 - **绝不打断正在跑的回合。** 资格条件要求 `agent.status === "idle"`,且 `compactNow` 是轮次之间的维护任务。 它的另一半 —— 回合之间但还在等子代理 —— 由 `skipAwaitingWork` 兜住:等待没有上限的父会话,只会在等待 结束之后才被压。 - **没有设置面板。** 目前只能改 patch 层;加一个带热重载的 `settings.section` 是下一步。 ## 开发 ```sh npm install # typescript + vitest 作为 devDependency npm run link-dsh -- # 从 checkout 解析 harness 包 npm run build # src/index.ts -> lib/index.js npm test # 12 个集成测试 npm run typecheck:tests # 连测试一起类型检查(tsconfig.check.json) ``` 源码依赖的多数 `@deepseek-ai/*` 包并不在公共 registry 上,所以 `scripts/link-checkout.mjs` 是把一个本地 DeepSeek Harness checkout 链接进本项目自己的(已被 gitignore 的)`node_modules`。它**不会往那个 checkout 里写 任何东西**;同级存在多个 checkout 时它会停下来让你指定,不会替你猜版本。`npm install` 不是必需的:没有它 脚本会借用 checkout 自带的 `typescript` 和 `vitest`,而 npm scripts 是按文件路径而不是按 bin 垫片调用它们的, 所以借来的和装的一样能用。 `tests/idle-compactor.spec.ts` 跑在真实 agent loop + 真实会话日志 + 真实 `tokenMeter` + 真实 `BasicCompactionEngine`(只脚本化它的 summarize)之上,断言的是日志里出现的 `compaction/summary` 事件, 不是 mock 调用次数。默认测的就是已构建产物 —— 和 profile 加载的是同一份;设 `DSH_IDLE_COMPACTOR_BUNDLE` 可以指向别的构建。 覆盖:闲置超阈值恰好压一次且实测上下文下降;同一安静期不重复压;有新活动后重新武装;未达阈值不动; 压力过线但表面已低于阈值时不动;归档会话不动(归档集合还在加载时同样不动);`dryRun` 不写任何东西; 坏配置直接抛。等待这块正反都测:名下有未结 job 的、子会话正在跑回合的,都不压,等工作结束后才压; 而「无关会话在跑」和「job 属于别的会话」两种情况照点压 —— 说明起作用的是父子关联与 job 归属,不是 场上有没有别的动静。 ## 目录结构 ``` src/index.ts 插件本体(纯类型导入,零运行时依赖) lib/ 已提交的构建产物 —— profile 实际加载的东西 cordis.patch.yml bundle 层:插件行 + host 平面压缩后端行 tests/ 在真实组装的 harness 上跑的集成测试 scripts/link-checkout.mjs 仅开发用:把 checkout 的包只读链接进来 vitest.config.mjs 独立测试根,不依赖任何 checkout 配置 tsconfig.json 只输出可擦除语法的 ESM,声明文件进 lib/types tsconfig.check.json 测试平面类型检查:src + specs,带 Node 环境类型 ``` ## 许可证 MIT,见 [LICENSE](LICENSE)。