diff --git a/AGENTS.md b/AGENTS.md index 2ec5241..e647131 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -65,8 +65,11 @@ - 上游来源: - 初始导入快照:`f1d0efd24a7f4ae99d19e10c5f4c3770a3bdd845`(`@firstpick/pi-extension-codex-fast-mode@0.1.1`)。 - 该目录从上游源码仓库导入并由本仓库直接维护,不使用 npm 预编译产物,不是 submodule,也不保留嵌套 `.git`。 -- `pi-condense@2.9.1`:总结已完成的工具调用批次,以短 stub 替换历史原始输出,并通过 `context_tree_query` 按需恢复;组合包在用户尚未配置 `contextPrune.enabled` 时默认开启。 -- 根包还固定安装 `@tavily/pi-extension@0.1.2`、`@keenable/pi-search@0.1.2`、`pi-hashline-edit@0.8.3`、`typescript-language-server@5.3.0`、`typescript@6.0.3` 和 `pi-context-view`;`@firstpick/pi-extension-codex-fast-mode`、`@gotgenes/pi-permission-system`、`@smoose/pi-notify`、`pi-ask-user`、`pi-chrome`、`pi-minimal-footer`、`pi-ssh` 与 `pi-tool-search` 分别指向仓库内对应目录的本地 `file:` 依赖;`pi-subagents/` 仅保留源码,不作为根运行时依赖。 +- `pi-condense/`:总结已完成的工具调用批次,以短 stub 替换历史原始输出,并通过 `context_tree_query` 按需恢复;瞬时摘要故障使用有界指数退避,默认最多重试 2 次。 +- 上游来源: +- 初始导入快照:`59c95110aacd8131db71e21296765dd80b72fae8`(`v2.9.1`)。 +- 该目录从官方明确 tag 的源码导入并由本仓库直接维护,不使用 npm 预编译产物,不是 submodule,也不保留嵌套 `.git`、上游 `.pi` / agent 状态、`node_modules` 或构建产物。 +- 根包还固定安装 `@tavily/pi-extension@0.1.2`、`@keenable/pi-search@0.1.2`、`pi-hashline-edit@0.8.3`、`typescript-language-server@5.3.0`、`typescript@6.0.3` 和 `pi-context-view`;`@firstpick/pi-extension-codex-fast-mode`、`@gotgenes/pi-permission-system`、`@smoose/pi-notify`、`pi-ask-user`、`pi-chrome`、`pi-condense`、`pi-minimal-footer`、`pi-ssh` 与 `pi-tool-search` 分别指向仓库内对应目录的本地 `file:` 依赖;`pi-subagents/` 仅保留源码,不作为根运行时依赖。 - `install.sh`:先安装根组合包,再交互检查 Hippo Memory CLI、Kitty/Solarized Dark、Oh My Zsh/Powerlevel10k/Zsh 插件、CodeGraph、Kotlin LSP、Java 21+ 和 JDT LS;缺失项目只在用户明确选择 `Y` 后安装或配置。Hippo 项目目录与 `hippo init` 始终由用户自行决定。Powerlevel10k 默认配置来自仓库内置的 `config/p10k.zsh`(当前 Rainbow/ASCII 单行紧凑主题)。 - `update.sh`:先通过 `pi update` 升级根组合包,再只升级当前已安装的终端环境和机器级依赖;未安装项直接跳过。不得在 `pi update` 前预清理安装缓存;仅当默认 Git 来源首次升级失败、Pi 的精确中断标记存在、canonical checkout 根目录和 `origin` 均通过身份校验时,才允许对该缓存执行最多 4 次连续 `git clean -fdx`,随后只重试一次 `pi update`,由 Pi 自己恢复依赖和清除标记;自定义来源、标记缺失或身份不匹配必须保持 fail-fast。升级成功后只能调用可定位的更新后组合包 Node helper 检查 Pi agent `settings.json`:只有 `toolSearch` 完整精确匹配旧版 `3` 组 / `20` 工具默认快照时,才创建从生成起即为权限 `600` 的时间戳备份、以同目录临时文件原子迁移到 `5` / `28` 并写入 `bundleDefaultsVersion: 2`;任何自定义或无效配置必须保留,提交前检测到内容变化必须中止。无法从 `PI_PACKAGE_SOURCE` 定位更新后 helper 时必须保留配置并报告失败,不能回退到可能陈旧的脚本目录 helper。其他组件升级前先查询并比较本地与远端版本,只有版本不同时才下载或替换;Powerlevel10k 配置优先从 `pi update` 后的已安装组合包读取,并与 `.zshrc` 受管块一起按内容比较后增量同步。 - `uninstall.sh`:先移除根组合包;若检测到 Hippo Memory CLI,再明确询问是否卸载确认属于 npm 全局安装的 `hippo-memory`,默认保留且始终不删除 `.hippo/` 或用户记忆数据。其他共享终端环境和机器工具不卸载。 @@ -94,7 +97,8 @@ - Exa MCP 使用托管 Streamable HTTP 端点和 `eager` lifecycle;`EXA_API_KEY` 通过 `x-api-key` 请求头发送,不得放入 URL、仓库文件或日志。所有在线搜索工具统一采用“来源名 + 原始语义工具名”的命名形式,例如 `tavily_web_search`、`exa_web_search`、`keenable_search`。Exa 高级搜索必须显式约束结果数量和文本长度;Tavily 输出默认控制 `max_results` 且非必要不请求 raw content;Keenable 优先利用中文、站点和日期筛选能力。 - 搜索 key 默认保存在 `${XDG_CONFIG_HOME:-$HOME/.config}/my-pi/search.env`,文件必须为 `600` 且不得提交;`search_config.sh` 只输出配置状态,禁止回显 key。 - `pi-context-view` 只观察上下文占用,不参与压缩策略。 -- `pi-condense` 负责压缩已经进入会话的历史工具结果,与 Context Mode 的输入隔离职责互补;`extensions/condense.ts` 只为 `settings.json` 的 `contextPrune` 补入缺失的组合包默认字段,必须保留全部用户显式值(包括 `enabled: false`),配置无效时不得覆盖原文件,配置完整时不得重复改写。组合包默认开启,使用 `agent-message` 触发、`turn` 批处理、73.5% 总预算与 4% 单轮增量触发,摘要模型和 thinking 均为 `default`,空闲/总超时为 90 秒/5 分钟,`quietOversizedSkips` 为 `false`;未显式部署的参数继续沿用上游默认,包括 skill 路径保护。 +- `pi-condense` 负责压缩已经进入会话的历史工具结果,与 Context Mode 的输入隔离职责互补;根组合包直接加载 `pi-condense/index.ts`,不得恢复 `extensions/condense.ts` / `extensions/condense-config.ts` 注入层或同时加载 npm 实现。维护默认值直接位于 `pi-condense/src/types.ts`,缺失字段只在内存中归一化,扩展加载不得写回 `settings.json`,全部用户显式值(包括 `enabled: false` 与重试 `0`)必须保留。默认开启,使用 `agent-message` 触发、`turn` 批处理、73.5% 总预算与 4% 单轮增量触发,摘要模型和 thinking 均为 `default`,单次空闲/总超时为 90 秒/5 分钟,`quietOversizedSkips` 为 `false`,skill 路径保护保持上游值。 +- 摘要瞬时错误默认 `summarizerMaxRetries: 2`,即初始尝试加 2 次重试共最多 3 次请求;`summarizerRetryBaseDelayMs: 1000` 与 `summarizerRetryMaxDelayMs: 8000` 在默认重试数下形成 1/2 秒有界指数退避,最终耗尽后才通知错误。认证失败、空/长度截断摘要和用户取消不得重试,取消在 backoff 中也必须立即生效。配置独立摘要模型时最后一个重试名额用于会话模型 fallback,整个调用仍不得超过 `1 + summarizerMaxRetries` 次;设为 `0` 必须同时禁用自动重试和 fallback 救援。 - Codex fast mode 只为符合条件的 `openai-codex-responses` 请求设置 priority service tier。`/fast-mode on|off` 同时更新当前分支记录与 Pi agent 目录中的 owner-only 全局默认值;新会话继承全局值,已有分支记录优先,配置缺失或无效时回退为关闭。 - Kitty 完成通知只在 TUI 模式的 `agent_settled` 后发送;自动重试、自动 compaction 和 follow-up 期间不得提前通知或重置总耗时。权限通知只订阅 `permissions:ui_prompt`,在人类确认界面即将出现时立即发送,不得为策略或 AutoReview 已直接决定的请求通知,也不受完成通知时长门槛影响。默认 `o=always`、`a=focus`,每个 Pi Session 使用独立稳定 ID,标题和正文必须 Base64 编码。 - `/notify on|off` 只覆盖当前 Session;持久默认来自 `PI_NOTIFY_*` 环境变量。`PI_NOTIFY_MESSAGE_SOURCE=none` 必须继续提供不泄露回复正文、权限值或请求者细节的隐私模式,非 TUI 模式不得写入 OSC 序列。 @@ -185,6 +189,15 @@ - 修改该扩展后至少运行包内 `npm test`、`npm run check`、`npm run smoke` 与 `npm pack --dry-run --json`;加载或依赖入口变化还要运行根扩展联合加载测试和实际 packed tarball 的隔离安装验证。 +## `pi-condense` 开发约定 + +- 扩展入口是 `pi-condense/index.ts`,主要实现和测试位于 `pi-condense/src/`,来源记录是 `pi-condense/UPSTREAM.md`。 +- 同步上游必须从官方 `jjuraszek/pi-condense` 的明确 tag/commit 移植并保留 README、CHANGELOG、PRUNING、规格文档、作者和 LICENSE;不得以 npm tarball、缓存、`node_modules` 或构建产物覆盖本地源码。 +- 根包必须通过 `file:./pi-condense` 提供依赖并直接加载仓库源码;本地包只保留宿主 peer dependencies,不得把 Pi host runtime 的 upstream devDependencies 带入根安装或锁文件。 +- 修改默认配置、归一化、设置 UI、重试/timeout/fallback、摘要通知或取消语义时,必须同步包内 README、配置文档、CHANGELOG、根 README、测试与本节。显式用户配置必须优先,扩展初始化不得为了部署默认值写入用户 `settings.json`。 +- 重试上限表示初始请求之后的重试次数;带独立摘要模型的 session fallback 必须占用最后一个重试名额而不是额外增加请求。只重试 `runOnce` 已分类的 transient outcome,auth/unusable/abort 不得重试,backoff 必须可取消。 + + ## `pi-subagents` 开发约定 - 扩展入口是 `pi-subagents/src/index.ts`,测试保留在该目录的 `test/`,来源记录是 `pi-subagents/UPSTREAM.md`。 @@ -266,6 +279,9 @@ Hippo 脚本变化必须额外用隔离 HOME/PATH 和 mock `pi`、`npm`、`hippo 在 `pi-notify/` 内至少运行 `npm run typecheck`、`npm run test` 和 `npm pack --dry-run --json`;涉及加载入口或 lifecycle 时还要运行根扩展联合加载测试和实际 packed tarball 的隔离安装验证。 +在 `pi-condense/` 内至少运行 `npm test` 和 `npm pack --dry-run --json`;涉及依赖或加载入口时还要验证根 `npm install` 幂等、锁文件仅包含本地 link 变化、根扩展联合加载测试,以及实际 packed 根 tarball 的隔离安装/加载。 + + 在 `pi-tool-search/` 内至少运行 `npm run typecheck`、`npm run test` 和 `npm run build`;涉及加载入口或 active-tool 行为时还要运行根扩展联合加载测试和实际 packed tarball 的隔离安装验证。 diff --git a/README.md b/README.md index 240083e..6666d32 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ - `@ogulcancelik/pi-codex-compaction@0.1.3`:为 `openai-codex` 提供原生远程压缩。 - 本仓库维护的 `pi-minimal-footer`:基于 `@ogulcancelik/pi-minimal-footer@0.1.10`,用紧凑的上下文仪表和订阅用量条替换默认 footer,并在 Codex Fast 模式开启时显示 `Fast on`。 - 本仓库维护的 `pi-notify`:基于 `@smoose/pi-notify@0.1.1`,使用 Kitty OSC 99 在 Pi 完全 settled 或权限系统即将请求人工确认时通知,并支持点击精确聚焦来源 Kitty 窗口。 -- `pi-condense@2.9.1`:把已完成的工具调用批次总结为可恢复的短摘要,并通过 `context_tree_query` 按需取回原始输出;组合包首次加载时默认开启。 +- 本仓库维护的 `pi-condense`:从官方 `v2.9.1` 源码导入,把已完成的工具调用批次总结为可恢复的短摘要,并通过 `context_tree_query` 按需取回原始输出;默认开启,瞬时摘要故障默认最多重试 2 次。 - 本仓库维护的 `pi-ask-user`:提供模型主动调用的 `ask_user_question`,用一个顺序 TUI 同时支持单题、多题、选择题、文本题、自定义回答与提交前复核。 - 本仓库维护的 `pi-tool-search`:从完整工具定义生成并缓存经过校验的工作流分组,默认以最多 5 个动态组、28 个动态工具的 LRU 策略加载原始完整 schema,并支持原子批量激活。 - 本仓库维护的 `pi-chrome`:从上游 `0.15.46` 源码导入,通过显式会话授权接入用户当前已登录的 Chrome Profile;大体积页面快照写入 Session 临时文件并交给 Context Mode 隔离分析,21 个 `chrome_*` 工具仍由 Tool Search 按工作流加载。 @@ -368,11 +368,13 @@ compaction 和排队 follow-up 完成前不会提前通知;同时监听 `pi-pe ### Pi Condense -组合包加载时会只为 `contextPrune` 中缺失的字段补入默认策略,任何用户显式值(包括 `/pruner off` 写入的 `enabled: false`)均优先且不会被覆盖。默认使用 `agent-message` 触发模式和 `turn` 批处理;正常上下文达到窗口的 73.5% 时中途整理,单轮增长达到窗口的 4% 时提前整理。对于 272K 上下文模型,两者约为 200K 和 11K tokens;其他模型按各自窗口比例计算。摘要使用当前默认模型和默认 thinking,连续 90 秒无输出才判定空闲超时,每次最长运行 5 分钟,并保留未压缩或摘要过大等跳过提示。 +组合包直接加载仓库内维护的 `pi-condense/` 源码;以前由 `extensions/condense-config.ts` 写入的默认策略已集成到扩展自身。`contextPrune` 缺失字段只在内存中采用维护默认值,加载时不再改写 `settings.json`;任何用户显式值(包括 `/pruner off` 写入的 `enabled: false`)始终优先。默认使用 `agent-message` 触发模式和 `turn` 批处理;正常上下文达到窗口的 73.5% 时中途整理,单轮增长达到窗口的 4% 时提前整理。对于 272K 上下文模型,两者约为 200K 和 11K tokens;其他模型按各自窗口比例计算。摘要使用当前默认模型和默认 thinking,连续 90 秒无输出才判定单次空闲超时,每次尝试最长 5 分钟,并保留未压缩或摘要过大等跳过提示。 同一用户任务中的多轮工具调用通常在最终文本回复后触发整理,预算触发器则可在长任务中提前处理;后续上下文只保留摘要和 `t1`、`t2` 等引用。需要原文时,模型可调用 `context_tree_query({ toolCallIds: ["t1"] })` 恢复。 -常用命令:`/pruner status` 查看状态,`/pruner settings` 调整配置,`/pruner now` 立即处理待总结批次,`/pruner tree` 浏览已归档调用,`/pruner stats` 查看累计成本和节省,`/pruner off` 关闭。配置保存在 Pi agent 目录 `settings.json` 的 `contextPrune` 字段。建议保留默认的 skill 路径保护,并把必须逐字复用输出的工具加入 `protectedTools`。 +瞬时错误(包括 provider overload、流错误和摘要超时)默认执行最多 2 次重试,即初始请求加重试共最多 3 次;退避为 1、2 秒,最后仍失败才显示错误。认证失败、空或长度截断的不可用摘要以及用户取消不重试。若配置了不同的摘要模型,最后一个重试名额保留给当前会话模型 fallback。可用 `summarizerMaxRetries`、`summarizerRetryBaseDelayMs` 和 `summarizerRetryMaxDelayMs` 调整,设 `summarizerMaxRetries: 0` 会禁用重试及 fallback 救援。 + +常用命令:`/pruner status` 查看状态与重试参数,`/pruner settings` 调整配置,`/pruner now` 立即处理待总结批次,`/pruner tree` 浏览已归档调用,`/pruner stats` 查看累计成本和节省,`/pruner off` 关闭。显式配置保存在 Pi agent 目录 `settings.json` 的 `contextPrune` 字段。建议保留默认的 skill 路径保护,并把必须逐字复用输出的工具加入 `protectedTools`。完整配置与本地差异见 [`pi-condense/README.md`](pi-condense/README.md) 和 [`pi-condense/UPSTREAM.md`](pi-condense/UPSTREAM.md)。 它与 Context Mode 的职责不同:Context Mode 避免大型原始输出进入当前模型上下文,Pi Condense 则压缩已经进入会话、且已完成使用的历史工具结果。它也不替代 Codex 原生远程 compaction;不希望自动整理历史时可运行 `/pruner off`。 diff --git a/extensions/condense-config.ts b/extensions/condense-config.ts deleted file mode 100644 index e5c9c6b..0000000 --- a/extensions/condense-config.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; -import { dirname, join } from "node:path"; - -export type CondenseDefaultResult = "updated" | "unchanged" | "skipped-invalid"; - -const CONDENSE_BUNDLE_DEFAULTS = { - enabled: true, - pruneOn: "agent-message", - batchingMode: "turn", - autoBudgetThreshold: 0.735, - budgetTurnDelta: 0.04, - summarizerModel: "default", - summarizerThinking: "default", - summarizerIdleTimeoutMs: 90_000, - summarizerMaxTimeoutMs: 300_000, - quietOversizedSkips: false, -} as const; - -function isObject(value: unknown): value is Record { - return value !== null && typeof value === "object" && !Array.isArray(value); -} - -/** Fill missing pi-condense settings with bundle defaults while preserving every explicit user choice. */ -export function ensureCondenseDefaults(agentDir: string): CondenseDefaultResult { - const targetPath = join(agentDir, "settings.json"); - let settings: Record = {}; - - try { - const parsed: unknown = JSON.parse(readFileSync(targetPath, "utf8")); - if (!isObject(parsed)) return "skipped-invalid"; - settings = parsed; - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ENOENT") return "skipped-invalid"; - } - - const existing = settings.contextPrune; - if (existing !== undefined && !isObject(existing)) return "skipped-invalid"; - - const contextPrune = existing ?? {}; - const alreadyComplete = Object.keys(CONDENSE_BUNDLE_DEFAULTS).every((key) => Object.hasOwn(contextPrune, key)); - if (alreadyComplete) return "unchanged"; - - settings.contextPrune = { ...CONDENSE_BUNDLE_DEFAULTS, ...contextPrune }; - mkdirSync(dirname(targetPath), { recursive: true }); - const temporaryPath = `${targetPath}.my-pi.tmp`; - writeFileSync(temporaryPath, `${JSON.stringify(settings, null, 2)}\n`, "utf8"); - renameSync(temporaryPath, targetPath); - return "updated"; -} diff --git a/extensions/condense.ts b/extensions/condense.ts deleted file mode 100644 index f976a85..0000000 --- a/extensions/condense.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { getAgentDir, type ExtensionAPI } from "@earendil-works/pi-coding-agent"; -import { ensureCondenseDefaults } from "./condense-config.ts"; - -/** Apply the bundle default before pi-condense reads settings on session_start. */ -export default async function bundledCondenseExtension(pi: ExtensionAPI): Promise { - const result = ensureCondenseDefaults(getAgentDir()); - if (result === "skipped-invalid") { - console.warn("my-pi: skipped the pi-condense default because settings.json or contextPrune is invalid"); - } - const { default: condenseExtension } = await import("../node_modules/pi-condense/index.ts"); - condenseExtension(pi); -} diff --git a/package-lock.json b/package-lock.json index 6b5fb17..bde0562 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,7 +20,7 @@ "jiti": "2.7.0", "pi-ask-user": "file:./pi-ask-user", "pi-chrome": "file:./pi-chrome", - "pi-condense": "2.9.1", + "pi-condense": "file:./pi-condense", "pi-context-view": "0.4.2", "pi-hashline-edit": "0.8.3", "pi-lsp": "0.1.7", @@ -5689,23 +5689,8 @@ "link": true }, "node_modules/pi-condense": { - "version": "2.9.1", - "resolved": "https://registry.npmjs.org/pi-condense/-/pi-condense-2.9.1.tgz", - "integrity": "sha512-FDwd0RuncXTeUHmfUiKBxCnSjmgf4JOiZ83ABsxiw6IYPqIfmWWmouFVopDnfcL/36UOfyv3lP5ZV30pj2ffZA==", - "license": "MIT", - "engines": { - "node": ">=22.19.0" - }, - "funding": { - "type": "buymeacoffee", - "url": "https://buymeacoffee.com/jjurasszek" - }, - "peerDependencies": { - "@earendil-works/pi-ai": "*", - "@earendil-works/pi-coding-agent": "*", - "@earendil-works/pi-tui": "*", - "@sinclair/typebox": "*" - } + "resolved": "pi-condense", + "link": true }, "node_modules/pi-context-view": { "version": "0.4.2", @@ -7175,6 +7160,23 @@ } } }, + "pi-condense": { + "version": "2.9.1", + "license": "MIT", + "engines": { + "node": ">=22.19.0" + }, + "funding": { + "type": "buymeacoffee", + "url": "https://buymeacoffee.com/jjurasszek" + }, + "peerDependencies": { + "@earendil-works/pi-ai": "*", + "@earendil-works/pi-coding-agent": "*", + "@earendil-works/pi-tui": "*", + "@sinclair/typebox": "*" + } + }, "pi-extension-codex-fast-mode": { "name": "@firstpick/pi-extension-codex-fast-mode", "version": "0.1.1", diff --git a/package.json b/package.json index 126496e..d8687a6 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "extensions", "hippo-memory-pi", "pi-ask-user", + "pi-condense", "pi-chrome", "pi-extension-codex-fast-mode", "pi-rtk-optimizer", @@ -62,7 +63,7 @@ "./node_modules/@ogulcancelik/pi-codex-compaction/index.ts", "./pi-minimal-footer/index.ts", "./pi-notify/src/index.ts", - "./extensions/condense.ts", + "./pi-condense/index.ts", "./pi-permission-auto-review/index.ts", "./extensions/permission-system.ts", "./pi-ssh/index.ts", @@ -89,7 +90,7 @@ "jiti": "2.7.0", "pi-ask-user": "file:./pi-ask-user", "pi-chrome": "file:./pi-chrome", - "pi-condense": "2.9.1", + "pi-condense": "file:./pi-condense", "pi-context-view": "0.4.2", "pi-hashline-edit": "0.8.3", "pi-lsp": "0.1.7", diff --git a/pi-condense/.gitignore b/pi-condense/.gitignore new file mode 100644 index 0000000..fbb1842 --- /dev/null +++ b/pi-condense/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +package-lock.json +.worktrees/ diff --git a/pi-condense/CHANGELOG.md b/pi-condense/CHANGELOG.md new file mode 100644 index 0000000..9ca5d76 --- /dev/null +++ b/pi-condense/CHANGELOG.md @@ -0,0 +1,181 @@ +# Changelog + +Keep-a-Changelog style (`## [X.Y.Z] - `, newest first), matching sibling +pi packages (e.g. [`pi-cohort`](https://github.com/jjuraszek/pi-cohort/blob/main/CHANGELOG.md)). + +Upstream releases are published to npm as [`pi-condense`](https://www.npmjs.com/package/pi-condense). This repository maintains the source directly inside the `my-pi` bundle; see [UPSTREAM.md](UPSTREAM.md). + +## [my-pi-unreleased] + +- Imported official `v2.9.1` source at commit `59c95110aacd8131db71e21296765dd80b72fae8` and replaced the root npm dependency with `file:./pi-condense`. +- Integrated the former bundle-injected defaults directly into `DEFAULT_CONFIG`: enabled pruning, `agent-message`/`turn`, 73.5% total-budget and 4% turn-delta triggers, default model/thinking, 90s idle and 300s attempt ceilings, and non-quiet oversized skips. Missing settings now use these values in memory without rewriting explicit user configuration. +- Added transient summarizer retries with configurable retry count, base delay, and maximum delay. Maintained defaults make up to three total attempts (`summarizerMaxRetries: 2`) with `1s → 2s` bounded exponential backoff; a distinct session-model fallback consumes the final retry slot, abort remains prompt, and the final error is emitted only after exhaustion. +- Added settings UI/status visibility, normalization, documentation, and tests for the maintained defaults and retry lifecycle. + +## [2.9.1] - 2026-08-18 + +- **Orphan sweep: any foreign message is now a barrier ([#11](https://github.com/jjuraszek/pi-condense/issues/11)).** `sweepOrphanToolResults` only reset its open-call set on `assistant` messages, while pi-ai flushes synthetic tool results at both `assistant` and `user` boundaries (`convertToLlm` maps `custom`/`branchSummary`/`compactionSummary`/`bashExecution` to `user`). A foreign message spliced between a toolCall and its toolResult (observed: a pi-cohort control notice) let the real result through alongside pi-ai's synthetic - a duplicate `tool_use_id` the provider rejects permanently (Anthropic 400, branch bricked). Now any message that is neither `assistant` nor `toolResult` clears the open set, so the interleaved result is swept and pi-ai's repairable synthetic stands alone; already-broken branches un-brick on the next render. Deliberate conservative over-sweep (unknown roles, `excludeFromContext` bashExecution) - no role allowlist. Test helper `expectNoOrphanToolResults` carries the identical rule. Spec: `doc/specs/2026-08-18-gh-11-orphan-sweep-barrier.md` (partially supersedes the 2026-08-12 spec's section C). + +## [2.9.0] - 2026-08-14 + +- **Uncovered chains now compress deterministically instead of stranding forever ([#10](https://github.com/jjuraszek/pi-condense/issues/10)).** Chain compression (Phase 3) required per-batch summary coverage; a closed eligible chain whose span produced no summaries (trivial batches, `skipped-oversized`, fully-deduped spans, capture misses) hit a permanent `no-summary` skip - once the prune frontier passed it, nothing could ever reclaim it. The motivating incident left a 639-call, ~935k-char chain (~62% of a 620k window) live in context indefinitely. + - **Deterministic zero-LLM fallback.** When coverage is zero, `compressEligible` extracts the chain's middle tool calls positionally (`resolveRange`; protected and already-indexed members excluded), backfills them into the tool-call index, and compresses the chain with a synthetic body - call count, tool histogram, span duration, first/last args excerpts (200-char cap), and `t` refs - carried in `rangeSummaryText` with a new optional `bodySource: "deterministic"` marker. No summarizer traffic, no renderer changes, body render-stable (cache-friendly after the one-time compression). + - **Mandatory fail-closed recoverability backfill.** New `ToolCallIndexer.backfillChainRecords`: append-before-commit atomicity (in-memory maps mutate only after the `context-prune-index` entry persisted), oversized results spilled via the shared `applySpill` helper (extracted from the eager spill path), refs ride the backfilled entry (`backfilled: true` + `refs`) so they survive restart without a summary message, and backfilled records never seed content-hash dedup canonicals - live or on reconstruction. Any failure preserves the historical skip; the next flush retries and converges by composing from durable records without duplicate index entries. + - **Edge cases.** Fully-protected chains keep the plain skip (compressing would save zero tokens - every output relocates verbatim). A genuine span mismatch (zero extractable, zero indexed, not fully protected) emits a new `backfill-empty` diagnostic (widget segment is now `diag u/m/o/b`). `/pruner compact` handles all uncovered chains in one pass. Known limitation: an idle session heals a stranded chain on the next working flush or `/pruner compact`, not via `/pruner now` on an empty queue. + - **Anti-regression cage.** Covered-path entries pinned byte-identical; legacy chain/index entries round-trip unchanged; restart-mid-failure-window convergence, fully-deduped backfill, protected exclusion+verbatim relocation, and multi-chain compact all integration-pinned. All optional fields - pre-upgrade sessions load with today's semantics. + - **Docs.** `PRUNING.md` deterministic-fallback subsection + diagnostics row, README widget legend + compact note, AGENTS.md entry-table updates. Spec: `doc/specs/2026-08-14-uncovered-chain-deterministic-backfill.md`. + +## [2.8.0] - 2026-08-13 + +- **Both token-budget flush triggers now cap the context window they reason about at 300,000 tokens** (`MAX_BUDGET_WINDOW`, `src/budget.ts`) ([#7](https://github.com/jjuraszek/pi-condense/issues/7)). Previously both scaled purely off the model's advertised window, which made them unreachable as windows grew: on a 1M-window model `autoBudgetThreshold: 0.9` meant 900k tokens - a session ends long before that, so pruning never fired - while the same setting worked on a 200k model. `budgetTurnDelta: 0.1` was worse than late: it meant +100k context growth inside a single turn, which effectively never happens, so the re-arm trigger was dead. + - `autoBudgetThreshold` now fires at `min(300_000, threshold * contextWindow)` tokens - your percentage of the model's window, or 300k tokens, whichever comes first. The threshold keeps its literal meaning; the cap is only a ceiling. + - `budgetTurnDelta` applies the same ceiling in the other shape - a 300k ceiling on a single turn's *growth* could never bind - so the fraction is measured against `min(contextWindow, 300_000)`: `0.1` means +30k tokens in one turn on any model at or above 300k (+20k on a 200k model, unchanged). + - **No new setting, and no behavior change for any model advertising 300k or less, at any setting** - a 256k window at `0.9` still fires at 230.4k. Above 300k flushes happen earlier; nothing ever flushes later than before. Downward control is unaffected: `0.1` on a 1M model still means 100k tokens. + - `usageFraction` may now exceed `1.0` above the ceiling (600k tokens on a 1M window returns `2.0`) and is deliberately **not** clamped - clamping would saturate the delta trigger and stop it re-arming. + - Docs updated in step: `README.md`, `doc/configuration.md`, `PRUNING.md`. Spec: `doc/specs/2026-08-13-budget-window-cap.md`. + +## [2.7.0] - 2026-08-12 + +- **Single-chain observability + reload trigger repair ([#6](https://github.com/jjuraszek/pi-condense/issues/6)).** A 5-hour, 256-turn session running one uninterrupted tool chain (no text-only assistant reply ever closed it) accumulated ~195k tokens of raw toolResults while `/pruner status` showed near-zero activity: chain compression (Phase 3) requires *closed* chains by design, and a session reload cleared the in-memory pending queue, leaving the automatic flush trigger stranded even though the branch rescan could recover the work. + - **Reload rearm.** `session_start` / `session_tree` now probe the branch via the existing capture rescan (no LLM work); when completed unsummarized batches exist past the frontier, a transient `rearmedPending` flag makes the `turn_end` budget/delta gate reachable without a freshly pushed batch - the next threshold crossing flushes automatically. Boolean only: no queue reconstruction, no flush at boot; reload followed by total idleness gets visibility, not silent compaction. Probe failures are reported (`console.error`) and never fail the reload. With the flag unset, `turn_end` behavior is byte-identical to before (regression-pinned). + - **Context metrics.** New pure module `src/context-metrics.ts` computes what the pruner cannot (yet) reclaim: **open-cycle thinking tokens** (thinking blocks in the trailing open segment), **largest-chain share** (max of largest closed chain vs the open segment, % of total branch chars), and **frontier gap** (summarization-eligible unsummarized toolResults past the frontier, occurrence-keyed and protection-aware). All `chars/4` estimates over the persisted branch, including persisted summary messages. + - **Surfacing.** `/pruner status` grows a `--- context ---` block (plus `rearmed: yes` when armed); the footer status line grows a self-hiding ` · think Nk · gap Nk · chain P%` suffix (rendered only while the frontier gap is non-zero); `agent_end` shows `prune: recovered pending (reload)` instead of a misleading `0 pending`. + - **New `context-prune-flush-metrics` session entry.** One per flush attempt - every outcome including `empty` and `error`, single `finally` emit site, delivery-routed writer - recording the trigger (`budget` / `delta` / `message-end` / `manual` / `rearmed`), batch counts, and the pre-flush metrics snapshot. Append-only observability log: never in LLM context, never reconstructed on load; exists so the next incident of this shape is diagnosable post-hoc. `cost:external` payload and `SummarizerStats` semantics unchanged. + - **Docs.** `PRUNING.md` documents the single-chain limitation (a design property of Phase 3) with `autoBudgetThreshold` / `budgetTurnDelta` guidance for long autonomous runs; README, `doc/configuration.md`, and the AGENTS.md entry table cover the new surfaces. Spec: `doc/specs/2026-08-12-single-chain-observability-trigger-repair.md`. + - Also hardens the test suite's `@earendil-works/pi-ai/compat` mocks to preserve real module exports (they were silently layout-sensitive under flat/deduped node_modules installs). + +## [2.6.0] - 2026-08-12 + +- **Fix: reused provider tool-call ids could delete a live turn and produce a rejected request ([#8](https://github.com/jjuraszek/pi-condense/issues/8)).** Provider `toolCallId`s (e.g. `bash_23`) are unique only within one response - some providers restart a `${tool}_${n}` counter, so the same bare id recurs across a session denoting different tool calls. `applyChainCompressions` treated the id as session-durable identity: it unioned every persisted chain entry's `droppedToolCallIds` into one session-wide set and dropped any message matching it anywhere in the array, deleting a live assistant turn that happened to reuse a compressed chain's id and orphaning its tool result - rejected outright by Anthropic (`unexpected tool_use_id found in tool_result blocks`) and Kimi K3 (unresolvable tool name), unrecoverable without hand-editing the session JSONL. The same bare ids also mis-keyed the indexer, dedup, `isSummarized`, and batch capture. + - **Positional chain ranges.** `applyChainCompressions` now resolves each persisted `ChainCompressionEntry` to an index range via `resolveRange` (`src/chain-range-prune.ts`): exactly one `user`-role message at `startUserTimestamp`, exactly one `assistant`-role message at `finalAssistantTimestamp`, `start < end` - any other outcome (including `finalAssistantTimestamp === null`) drops nothing and inserts no synthetic, fail-closed. Drops are role-restricted to `assistant` / `toolResult` / per-batch-summary messages inside the range, so `user`-role messages - including the user-role `` synthetic, which keeps re-application idempotent - and third-party `custom_message` entries survive. Per-batch summary suppression stays coverage-based (`toolCallRefs` overlap), not index-membership, because under `batchingMode: "agent-message"` the summary lands after the range. `droppedToolCallIds` is retained only as a diagnostic cross-check against the range's actual contents; the range always wins. + - **Occurrence-keyed identity.** Records are now keyed `id@resultTimestamp` (`src/occurrence-key.ts`), the discriminant being the `ToolResultMessage`'s own timestamp (not `ToolCallRecord.timestamp`, which is a batch-level timestamp computed inconsistently between the live-capture and session-rescan paths). Persisted shapes gained optional `resultTimestamp` (index records, summary refs), `newResultTimestamp` / `originalResultTimestamp` (dedup aliases), and `droppedOccurrenceKeys` (chain entries) - all optional, so pre-upgrade entries keep bare-id keying and a session spanning the upgrade retains today's behavior for its pre-upgrade half. `isSummarized` is now a strict occurrence-key lookup; the sole sanctioned bare-id fallback is `hasLegacyBareRecord`, true only for a bare id with no occurrence-keyed siblings (a mixed id fails closed on both). Spill sidecars are named from the occurrence key going forward; recovery still reads the persisted `spillPath`, so pre-upgrade blobs keep resolving. + - **Orphan sweep.** `pruneMessages` now ends with a structural post-condition, `sweepOrphanToolResults` (`src/orphan-sweep.ts`): one forward pass with **per-turn** open-call tracking (each assistant message replaces the open set rather than accumulating into it, so an id used validly early cannot license a later genuine orphan under the same reused id) removes any `toolResult` no surviving assistant opened. Provider-agnostic and structural rather than keyed to any provider's error string; reference-preserving when nothing is swept, so the no-op / prompt-cache-prefix invariant (`doc/specs/2026-08-04-pruner-noop-serialization.md`) holds. + - **Diagnostics.** New `context-prune-diagnostic` session entry (`{ kind, detail }`, kinds `unresolved-range` / `range-id-mismatch` / `orphan-sweep`) surfaces these degradations without ever entering LLM context - zero tokens, zero cache-prefix change. Deduped per `(kind, dedupKey)`, reset on `session_start` / `session_tree`. The `/pruner` status line grows a self-hiding ` · diag u/m/o` segment (u/m/o map to the three kinds above in that order); each zero counter is omitted and the whole segment disappears when all three are zero. + - **`context_tree_query` multi-occurrence recovery.** A raw provider id that was reused now returns every matching occurrence (not just one), each labelled `id@timestamp`, chronologically; a legacy record with no `resultTimestamp` is labelled with the bare id plus an explicit `Occurrence: legacy (no resultTimestamp)` line. Short refs (`tN`) are unaffected - still 1:1 and the primary recovery path. + - **Accepted limitation:** a session that spans the upgrade keeps bare-id keying for everything captured before it landed; only the post-upgrade half of such a session gets occurrence-keyed identity. No migration runs on load. Concretely, within such a session a live tool result whose provider id collides with a pre-upgrade summarized record can still be stub-replaced with that record's stale content (`hasLegacyBareRecord`'s bare-id fallback in `src/pruner.ts` cannot distinguish the two once both denote the same bare id with no occurrence-keyed siblings); new records are unaffected, and the exposure disappears once a session contains no legacy records. + +## [2.5.0] - 2026-08-05 + +- **Removed the main-loop thinking strip (breaking: `contextPrune.thinkingStrip.*` no longer read).** The feature assumed thinking blocks we send are thinking blocks we are billed for. Verified against the live Anthropic API (`count_tokens` and real billed usage agree to the token), the actual rule is: thinking in a *closed* cycle bills 0 input tokens, and thinking in an *open* cycle survives only as an unbroken run starting at the cycle's first assistant turn — any gap discards everything after it. `keepLastTurns` kept the **last** K, so firing it punched a gap at the front and the API dropped all of it: the model received no thinking either way, and the no-gap → gap transition cost a full cache invalidation (measured +33% in a controlled 24-tool-call A/B, $0.6430 → $0.8531, with turn 17 rewriting 63113 tokens at 0% reuse). Across 58 sessions / 1233 open cycles the strip fired 253 times and reached its ~130-turn break-even twice. It was also redundant: chain compression's synthetic block is a `role: "user"` text message, which closes the cycle and frees all prior thinking server-side at zero cache cost. `pruneMessages` drops to three phases (`stub-replace → error-purge → chain-range-prune`); `src/thinking-strip.ts`, `ThinkingStripConfig`, `KEEP_LAST_TURNS_PRESETS`, the two `/pruner` settings entries, and `PruneFrontier.thinkingStripBoundaryTimestamp` are gone. A leftover `thinkingStrip` block in `settings.json` is inert and round-trips untouched (`normalize()` never filters unknown keys); a persisted `context-prune-frontier` entry carrying the old boundary field loads and is ignored. Rationale and measurements: `doc/specs/2026-08-05-remove-thinking-strip.md`. + +## [2.4.3] - 2026-08-04 + +- **Flush-gated, timestamp-keyed thinking strip ([#3](https://github.com/jjuraszek/pi-condense/issues/3)).** Phase 4 (`stripOldThinking`, `src/thinking-strip.ts`) recomputed its keep-window from the *live assistant count on every `context` render*, so each turn the `(count - keepLastTurns)`-th assistant slid forward and its thinking was stripped **deep in history**. pi-ai sets prompt-cache breakpoints only at `tools + system + last message` (verified in `@earendil-works/pi-ai` `api/anthropic-messages.js` `convertMessages` - no in-history breakpoint), so every deep mutation busted the cached suffix - roughly every render inside a tool loop. The strip boundary is now a **flush-computed, persisted assistant-message timestamp** (`thinkingStripBoundaryTimestamp`, added to the `PruneFrontier` snapshot on the existing `context-prune-frontier` entry): fixed between flushes so consecutive renders are byte-stable in their historical prefix (the cache survives a whole tool loop), advancing only on a non-empty flush (piggybacking summarization's own cache bust - zero marginal busts), monotonically clamped (never re-adds thinking to an already-stripped message, even when `keepLastTurns` is increased mid-session), and keyed by timestamp rather than array index so it is robust to phase-3 chain-range middle drops. An absent boundary (pre-feature sessions, pre-first-flush) falls back to the original live-count window verbatim. Additive optional field, fully backward-compatible; **no new config key**, `keepLastTurns` presets unchanged. `PRUNING.md` cache-impact model corrected. Turns the old k-busts-per-render into ~1-per-request. + +## [2.4.2] - 2026-08-04 + +- **No-op renders skip context serialization.** `pruneMessages` (`src/pruner.ts`) computed `sizeMessages` (a full `JSON.stringify` over the entire message array) unconditionally on every `context` render, including no-ops where the result is never read (`index.ts` consumes `beforeChars`/`afterChars` only under `if (result.pruned)`). It now computes both sizes lazily in the pruned branch and returns a `{ beforeChars: 0, afterChars: 0 }` sentinel on a no-op, so a render that prunes nothing does zero `JSON.stringify` over the array. CPU/GC only - zero token cost, no wire or return-shape change. Also corrects a stale fast-path comment in the `context` handler (`index.ts`): index/registry emptiness alone does not imply a no-op, because error-purge and thinking-strip prune independently. +- **Bumped `engines.node` to `>=22.19.0`** to match the host pi runtime (`@earendil-works/pi-coding-agent@0.83.0`); drops advertised support for Node 20/21, which cannot run current pi. Bumped the `@sinclair/typebox` devDependency floor to `^0.34.52` (freshness only; the caret already resolved there). + +## [2.4.1] - 2026-07-30 + +- **Fix 421 Misdirected Request for GitHub Copilot business/enterprise seats.** The summarizer called pi-ai's `stream()` with the static model definition, whose shipped `baseUrl` pins the individual Copilot host (`api.individual.githubcopilot.com`); business/enterprise tokens are rejected there with `421 Misdirected Request`, so any `github-copilot/*` `summarizerModel` failed on those seats. `runOnce` now mirrors the main agent loop (pi's `model-runtime`): it resolves provider auth via `ctx.modelRegistry.getProviderAuth(model.provider)` and, when the resolved auth carries a seat-specific `baseUrl`, rebases the model onto it before streaming. Also bumps `@earendil-works/*` devDependencies to `^0.83.0` (needed for `getProviderAuth` on the extension-facing `ModelRegistry`) and imports `stream` from `@earendil-works/pi-ai/compat` (its export moved off the root in current pi-ai). + +## [2.4.0] - 2026-07-09 + +- **Summarizer call timeout.** Every summarizer stream call is now bounded by an idle timeout (`summarizerIdleTimeoutMs`, default 20s - reset on every stream event, so it never false-aborts a flowing or reasoning generation) and a total-duration ceiling (`summarizerMaxTimeoutMs`, default 180s). Previously a stalled-but-open provider connection hung the whole agent turn indefinitely, since `runOnce` had no time budget and the automatic flush paths pass no abort signal. A timeout classifies as transient and feeds the existing outage-fallback retry (one bounded session-model attempt when a distinct `summarizerModel` is set), then surfaces a `warning` notice. Both timers are `0`-disablable and exposed in `/pruner settings` and `/pruner status`. + +## [2.3.0] - 2026-07-06 + +- **Recovery grace window for `context_tree_query` output.** The pruner used to re-stub its own recovery output at the next turn boundary, forcing a retrieve -> re-stub -> re-query loop the agent experiences as "fighting the pruner" (observed in a real session: a recovered tool dump was re-summarized on the very next flush, so the agent had to keep re-querying the same ref). A new `recoveryGraceTurns` setting (default `3`, `0` disables) keeps a recovered output verbatim for that many user-turn-groups before reverting to the stub. Enforced at **render time** in two places - Phase 1 stub-replace (`src/pruner.ts`) and chain-compression eligibility (`src/chain-compressor.ts`, which defers compressing any chain whose span still holds an in-grace recovery id) - never at capture, so the frontier, dedup, spill, and live `turn_end` paths are unchanged. The window is computed positionally from the message stream (no new `ToolCallRecord` field). Default `3` covers ~81% of same-ref re-queries observed in the local session corpus; the accepted trade-off is that a reference past the window is re-stubbed and may be re-queried, keeping context regrowth bounded rather than permanent. Tunable via `/pruner recovery-grace [n]` and the `/pruner settings` overlay. See [PRUNING.md § What Pruning Does](PRUNING.md#what-pruning-does). + +## [2.2.1] - 2026-07-06 + +- **Fix probe starvation in the summarizer outage fallback.** `FallbackController.onFallbackOnlyFail` reset the re-probe cooldown on every steady-state fallback failure, so a fallback (session) model that failed at least once per 10-minute cooldown perpetually pushed out the primary re-probe - a recovered `summarizerModel` was never re-tested and summarization stayed on the pricier session model indefinitely (the exact stall the feature exists to kill, in the fallback direction). The method is now a no-op on `lastProbeAt`: the primary re-probe fires on schedule regardless of fallback failures. In-memory only; no wire/config change. + +## [2.2.0] - 2026-07-06 + +- **Summarizer outage fallback to the session model.** Per-model provider outages (e.g. a cheap `summarizerModel` like Haiku degraded while the session's main model stays healthy) previously stalled pruning for the whole outage - `runSummarization` returned null and the batch retried the same dead model every flush, growing context unbounded. A new sticky in-memory `FallbackController` (`src/summarizer-fallback.ts`) now routes summarization to `ctx.model` on a **transient** failure of the configured model, retrying the failed call once on the session model. Fallback is sticky: while engaged, all calls use the session model until a single probe batch re-tests the configured model after a 10-minute cooldown, then auto-recovers. Trigger is transient-only - auth (pre-flight key failure), unusable (empty/truncated), and abort never trip it. A one-time `warning` fires on enter and an `info` on recovery via `ctx.ui.notify` (UI only, never injected into LLM context). No config key: the target is always `ctx.model`, and the controller is inert when no distinct fallback model exists (`summarizerModel: default` or the resolved model equals `ctx.model`), preserving today's single-attempt behavior byte-for-byte. State is in-memory only (reset on `session_start`, no `context-prune-*` entry). + +## [2.1.2] - 2026-07-05 + +Branding, funding, and gallery preview. No behavior change. + +### Added + +- **Logo + pi.dev gallery preview.** Repo-root `pi-condense.png` (640x640), shown in the README and wired as `pi.image`. +- **Buy Me a Coffee funding.** `funding` in `package.json`, `.github/FUNDING.yml`, and a README badge. + +### Changed + +- Sharpened `description`; added `context-pruning`, `llm`, `prompt-caching` keywords. +- README reframed product-first (credit to `championswimmer/pi-context-prune` kept as attribution); fixed a stale `pi-superpowers` -> `pi-gauntlet` reference in a spec doc. + +## [2.1.1] - 2026-07-04 + +- **`release.yml` posts GitHub Release notes.** A new `release-notes` job (`needs: publish`, `contents: write`) extracts the CHANGELOG section matching the pushed tag with `awk` (skipping `## [Unreleased]`) and publishes it as the GitHub Release body via `gh release create` (falling back to `gh release edit`). No LLM or API key; only `github.token`. + +## [2.1.0] - 2026-07-04 + +- **Per-bullet recovery refs in prune summaries** (closes #2). Each per-tool block in a summary now carries its own inline `` `tN` `` ref, so the model recovers a specific tool's raw output in one hop instead of guessing which flat-footer ref maps to which bullet. The serializer labels each tool block `[[N:toolname]]` (`src/batch-capture.ts`), the summarizer prompt tells the model to copy that label onto its first bullet (`src/summarizer.ts`), and `substituteInlineRefs` (`src/summary-refs.ts`) validates the echoed tool name against the tool at position N before rewriting to `` `tN` ``. The flat footer is retained unchanged as the always-correct fallback. Deterministic number->shortId map over the shared post-dedup `batch.toolCalls` order; the tool-name tag downgrades a confident wrong-ref (skip-induced renumber) to footer-only, and mismatched / out-of-range / wrapped / mid-line labels are stripped (fence-aware leak guard) so no raw `[[N:name]]` token ever leaks into context. No new tool, config key, or index. + +## [2.0.1] - 2026-07-03 + +- **Prune summaries are now hidden from Pi's main window** (`display: false` at both injection sites in `index.ts`). They stay in LLM context and session history (recoverable via `context_tree_query`) but no longer print the full markdown block into the TUI. Mirrors upstream `pi-context-prune` `2fd6127`. +- **Sharpened the summarizer prompt** (`src/summarizer.ts`): the key-outcome bullet now requires copying file paths, identifiers, signatures, and error strings *verbatim* (never reworded), and the summarizer skips tool calls that succeeded with nothing reusable to record. Scoped to short tokens - values/full output stay summarized, so the ~8x median compression and the oversized guard are unaffected. +- **`context_tree_query` description now shows a literal call example** (`{ toolCallIds: ["t12", "t3"] }`) and consolidates the two near-duplicate parameter descriptions into one that marks the field required. Reduces the empty-first-call retry seen in real sessions. No schema or behavior change. + +## [2.0.0] - 2026-07-02 + +- **Renamed the package `pi-context-prune` -> `pi-condense`** and switched distribution from git-tag pins to npm. Install with `pi install npm:pi-condense` (was `git:github.com/jjuraszek/pi-context-prune@vX.Y.Z`). Migrate pinned `settings.json` entries; `release.sh sync-presets` reports stale pins. +- **Added npm release machinery:** `.github/workflows/release.yml` (tag-triggered, OIDC + provenance, `tag == package.json` gate) and `test.yml` (bun, Ubuntu + Windows matrix). Ported `release.sh` to the shared sibling skeleton (`propose` / `patch|minor|major` / `current` / `verify` / `sync-presets`), replacing the old tag-pin `release.mjs`. +- **Added** `LICENSE` (MIT), `files` allowlist, `engines`, and `author` to `package.json`; removed dead `pi.skills` / `pi.prompts` manifest entries (those directories never existed). +- **Renamed** the cost-event producer id `EXTERNAL_COST_SOURCE` `"pi-context-prune"` -> `"pi-condense"`. Aggregators keyed on the old `source` string see it as a new producer. + +## [1.0.0] - 2026-05-31 + +- **Removed three `pruneOn` modes**, leaving `agent-message` (default) and `on-demand`: + - `every-turn` - debugging-only trigger with the worst prompt-cache churn. + - `on-context-tag` - depended on the external `ttttmr/pi-context` extension and overlapped its `context_compact`. + - `agentic-auto` - the scaffolded DCP-style model-driven `context_prune` tool was never wired to range compression (see `PRUNING.md`, Future Work). +- **Removed** the `context_prune` tool, the agentic-auto system prompt, the `` unpruned-count reminder, and the `remindUnprunedCount` setting. Deleted `src/reminder.ts`, `src/context-prune-tool.ts`, `src/progress-text.ts`. +- **Migration:** none required. Configs pinned to a removed mode fall back to `agent-message` via `isPruneOn()`. A stale `remindUnprunedCount` key in `settings.json` is ignored. + +## [0.11.1] - 2026-05-28 + +- **Release flow:** `release.sh` now rewrites every `~/.pi/agent*/settings.json` pin of `git:github.com/jjuraszek/pi-context-prune@` to the new `@vX.Y.Z` automatically after pushing the tag. Opt out with `--no-update-pins`. Aligns this fork's release workflow with sibling pi-* packages. +- **Docs:** `README.md` install section leads with the jjuraszek tag-pin (was upstream npm/sha references). `AGENTS.md` release blurb updated to reflect tag pins + automatic settings rewrite. `.agents/skills/release/SKILL.md` documents the new flow + flags. Adds this `CHANGELOG.md` for parity with sibling pi-* packages. + +## [0.11.0] - 2026-05-28 + +- **Pre-flush pipeline:** content-hash dedup (re-reads of identical `(toolName, content)` pairs alias the original instead of going through the LLM), trivial-batch skip (`minBatchChars`), protected tools allowlist, stub-replace rather than delete. See `PRUNING.md`, Pre-flush Pipeline & Safeguards. +- **Settings:** moved to `/settings.json#contextPrune` namespace (was a separate file). Honors `$PI_CODING_AGENT_DIR`. + +## [0.10.0] - 2026-05-11 + +- `quietOversizedSkips` setting to suppress `skipped-oversized` notifications. +- Demote oversized-skip notification severity to info. +- Use short refs in pruned summaries (e.g. `t1`, `t2` rather than full toolCallIds) so the model can pass them back through `context_tree_query` more reliably. + +## [0.9.x] - 2026-05-05 to 2026-05-11 + +- `0.9.3`: spinner animation fix for `/pruner now`. +- `0.9.2`: replace footer progress with aboveEditor widget during `/pruner now`. +- `0.9.1`: allow `ESC` to cancel `context_prune` tool call. +- `0.9.0`: agentic-auto mode (`pruneOn: "agentic-auto"`), `context_prune` tool surfaced to the LLM, `remindUnprunedCount` setting. + +## [0.8.x] - 2026-05-04 to 2026-05-05 + +- `0.8.1`: bug fixes around session-start index rebuild. +- `0.8.0`: `agent-message` trigger mode + batching, footer status widget. + +## [0.7.0] - 2026-05-04 + +- `on-context-tag` trigger mode (integrates with `pi-context` `context_checkpoint`). + +## [0.6.x] - 2026-05-02 + +- Tree browser (`/pruner tree`) + summary overlay (`Ctrl-O`). +- `dedupByContentHash` cross-flush dedup. + +## [0.5.0] - 2026-05-01 + +- Cumulative summarizer token/cost stats (`/pruner stats`). + +## [0.4.0] - 2026-05-01 + +- Configurable summarizer model + thinking level (`/pruner model`, `/pruner thinking`). + +## Earlier (v0.1.x - v0.3.x) + +Initial extension scaffolding, `context_tree_query` tool, base summarization loop, session-JSONL index persistence. See `git log` for granular history. diff --git a/pi-condense/LICENSE b/pi-condense/LICENSE new file mode 100644 index 0000000..0a96885 --- /dev/null +++ b/pi-condense/LICENSE @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) 2025 championswimmer (original pi-context-prune) +Copyright (c) 2026 Jacek Juraszek (pi-condense) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/pi-condense/PRUNING.md b/pi-condense/PRUNING.md new file mode 100644 index 0000000..e8cc673 --- /dev/null +++ b/pi-condense/PRUNING.md @@ -0,0 +1,1166 @@ +# Understanding Context Pruning in Pi + +> How `pi-condense` compresses tool-call history, why it matters for long-running sessions, and how it balances context size against provider-side prefix caching. + +--- + +## Table of Contents + +1. [What Does a Long Session Look Like?](#what-does-a-long-session-look-like) +2. [What Pruning Does](#what-pruning-does) +3. [Pruned Data Is Still Available](#pruned-data-is-still-available) +4. [What Actually Lives in the Pruner Index](#what-actually-lives-in-the-pruner-index) +5. [How the Model Re-reads Raw Outputs](#how-the-model-re-reads-raw-outputs) +6. [How Prefix Caching Works](#how-prefix-caching-works) +7. [Why Frequent Pruning Busts Cache](#why-frequent-pruning-busts-cache) +8. [The Sweet Spot: Batch and Prune](#the-sweet-spot-batch-and-prune) +9. [Pre-flush Pipeline & Safeguards](#pre-flush-pipeline--safeguards) + - [Stub-replace instead of delete](#stub-replace-instead-of-delete) + - [Protected tools](#protected-tools) + - [Eager single-result spill](#eager-single-result-spill) + - [Trivial-batch skip (minBatchChars)](#trivial-batch-skip-minbatchchars) + - [Content-hash dedup](#content-hash-dedup) + - [Oversized summary skip](#oversized-summary-skip) + - [Frontier persistence](#frontier-persistence) + - [Other UI / observability features](#other-ui--observability-features) + - [Token-budget auto-flush trigger](#token-budget-auto-flush-trigger) + - [Budget-delta flush](#budget-delta-flush) +10. [Chain Compression](#chain-compression) + - [Protected-output relocation](#protected-output-relocation) +11. [Occurrence Identity](#occurrence-identity) +12. [Error Purge](#error-purge) +13. [Orphan Sweep](#orphan-sweep) +14. [Diagnostics](#diagnostics) +15. [Why Summarization Works: Research Evidence](#why-summarization-works-research-evidence) + - [SUPO — Summarization augmented Policy Optimization](#supo--summarization-augmented-policy-optimization) + - [ReSum — Recursive Summarization for Long-Horizon Agents](#resum--recursive-summarization-for-long-horizon-agents) + - [ACON — Agent Context Optimization](#acon--agent-context-optimization) +16. [Summary](#summary) + +--- + +## What Does a Long Session Look Like? + +In Pi, every assistant turn that calls tools produces a sequence of messages in the context tree. In a long coding or research session, this accumulates rapidly: + +### ASCII: A typical Pi context tree (before pruning) + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ SESSION CONTEXT (growing without bound) │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ [system] You are Pi, a helpful coding assistant... │ +│ │ +│ [user] Build a React component that fetches data from │ +│ an API and displays it in a table... │ +│ │ +│ ── Turn 1 ───────────────────────────────────────── │ +│ [assistant] │ +│ {"path": "src/App.tsx"} │ +│ [tool] export default function App() { ... } ← 45 tokens │ +│ │ +│ ── Turn 2 ───────────────────────────────────────── │ +│ [assistant] │ +│ {"path": "package.json"} │ +│ [tool] { "dependencies": { "react": "^18.2.0", ... } ← 120 tok │ +│ │ +│ ── Turn 3 ───────────────────────────────────────── │ +│ [assistant] │ +│ │ +│ [tool-003] React Table v7 docs, TanStack Table API... ← 3,400 tok │ +│ [tool-004] import { useState } from 'react'; ... ← 200 tokens │ +│ │ +│ ── Turn 4 ───────────────────────────────────────── │ +│ [assistant] │ +│ [tool] ✔︎ File updated successfully ← 15 tokens │ +│ │ +│ ── Turn 5 ───────────────────────────────────────── │ +│ [assistant] │ +│ │ +│ [tool-006] BUILD OUTPUT (npm run build): ← 2,800 tok │ +│ [warn] Circular dependency detected... │ +│ [warn] Chunk size exceeds 500kb... │ +│ [error] TypeScript compilation failed... │ +│ [tool-007] Updated file contents... ← 180 tokens │ +│ │ +│ ── Turn 6 ── ... (more turns, more tool calls) ── │ +│ │ +│ ═══════════════════════════════════════════════════════ │ +│ Context size: ~15,000 tokens and growing... │ +│ Most tokens are raw tool outputs the model already "consumed" │ +│ ═══════════════════════════════════════════════════════ │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +In a long session this can grow to **30k–100k+ tokens**. The model pays for every token on every subsequent request. More importantly, the "signal" (what the model actually needs to know) is buried in a mountain of "noise" (full build logs, search results, file contents it already processed). + +--- + +## What Pruning Does + +`pi-condense` intercepts completed tool-call batches, summarizes them, and replaces each raw `ToolResultMessage` in future context with a small breadcrumb stub that points at `context_tree_query` for recovery. The original full output is archived in the session index. + +### ASCII: The same session *after* pruning Turns 1–5 + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ SESSION CONTEXT (after pruning Turns 1-5) │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ [system] You are Pi, a helpful coding assistant... │ +│ │ +│ [user] Build a React component that fetches data from │ +│ an API and displays it in a table... │ +│ │ +│ [summary] ╔════════════════════════════════════════════╗ │ +│ ║ ⚃ [pruner] Turn 1–5 summary (7 tools) ║ │ +│ ║ ║ │ +│ ║ • Read existing App.tsx and package.json ║ │ +│ ║ • Searched React Table docs; decided on ║ │ +│ ║ @tanstack/react-table v8 ║ │ +│ ║ • Created DataTable component with ║ │ +│ ║ sorting, pagination, useQuery hook ║ │ +│ ║ • Build failed: circular dependency in ║ │ +│ ║ utils/index.ts → fix by inlining helpers ║ │ +│ ║ ║ │ +│ ║ Summarized tool refs: t1..t7 ║ │ +│ ║ Use context_tree_query for raw outputs ║ │ +│ ╚════════════════════════════════════════════╝ │ +│ ← ~200 tokens (was ~6,760 tokens) │ +│ │ +│ ── Turn 6 ───────────────────────────────────────── │ +│ [assistant] │ +│ │ +│ [tool-008] import { helperA } from './helpers'; ... ← 90 tokens │ +│ [tool-009] ✔︎ File updated successfully ← 15 tokens │ +│ │ +│ ═══════════════════════════════════════════════════════ │ +│ Context size: ~500 tokens (plus current turn) │ +│ ~96% reduction in "stale" context tokens │ +│ ═══════════════════════════════════════════════════════ │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +### Mermaid: The pruning transformation + +```mermaid +%%{init: {'theme': 'base', 'themeVariables': { 'primaryColor': '#e1f5fe', 'primaryTextColor': '#01579b', 'primaryBorderColor': '#0288d1', 'lineColor': '#0288d1', 'secondaryColor': '#fff3e0', 'tertiaryColor': '#e8f5e9'}}}%% + +graph TB + subgraph Before["Context BEFORE Pruning"] + direction TB + U1["👤 User Request"] --> A1["🤖 Assistant (tool calls)"] + A1 --> T1["🔧 Tool Result 1
~3,400 tokens"] + A1 --> T2["🔧 Tool Result 2
~200 tokens"] + T1 --> A2["🤖 Assistant (tool calls)"] + A2 --> T3["🔧 Tool Result 3
~2,800 tokens"] + A2 --> T4["🔧 Tool Result 4
~180 tokens"] + T3 --> A3["🤖 Assistant (text response)"] + end + + subgraph After["Context AFTER Pruning"] + direction TB + U2["👤 User Request"] --> S["📋 Summary Message
~200 tokens"] + S --> A4["🤖 Assistant (tool calls)"] + A4 --> T5["🔧 Tool Result 5
~90 tokens"] + A4 --> T6["🔧 Tool Result 6
~15 tokens"] + end + + Before -->|"prune summarized
tool results"| After + + style T1 fill:#ffebee + style T3 fill:#ffebee + style T2 fill:#ffebee + style T4 fill:#ffebee + style T5 fill:#e8f5e9 + style T6 fill:#e8f5e9 + style S fill:#fff3e0 +``` + +**Key points:** + +- The `AssistantMessage` tool-call blocks are **kept** (they carry the `toolCallId`s the model uses to reference originals via `context_tree_query`). +- `ToolResultMessage` entries for summarized tool calls are **replaced with a small stub** (`[Summarized in pruner summary, ref \`tN\`. Use context_tree_query to retrieve full output.]`) carrying`role: "toolResult"`, the original`toolCallId`/`toolName`/`timestamp`, and`isError: false`. The stub preserves role alternation, so pi-ai's`transformMessages.insertSyntheticToolResults` no longer injects a synthetic `{ isError: true, "No result provided" }` for the (no-longer-)orphaned tool call. See [Stub-replace instead of delete](#stub-replace-instead-of-delete). +- Every pruned tool call is also copied into the pruner's runtime/session index with its `toolCallId`, tool name, args, status, turn index, timestamp, and full `resultText`. +- A summary message is injected as a `"steer"` (`pi.sendMessage` runtime path) or appended directly via `sessionManager.appendCustomMessageEntry` (session path, used when Pi may already be shutting down). Both deliver before the next LLM call. +- The session JSONL file retains the original tool-result entries unchanged — pruning only affects what the *next* request sees in active context. +- **Recovery grace window (`recoveryGraceTurns`, default 3):** after the model recovers a tool call via `context_tree_query`, that tool call's output is rendered **verbatim** (not re-stubbed) for the next N user-turn-groups, then reverts to the normal stub. Without this, the pruner re-prunes its own recovery output on the very next flush, forcing the model into a retrieve -> re-stub -> re-query loop ("fighting the pruner") whenever it keeps referencing the same recovered data across a few turns. + - Enforced at **render time**, in Phase 1 stub-replace and in chain-compression eligibility — NOT at capture time. Capture-time exclusion would collide with frontier trim: a tool call already past the frontier is dropped forever, so excluding a recovered call from capture would either need to resurrect frontier state or degrade the lifetime bound into permanent verbatim retention for anything ever recovered. + - Trade-off: this bounds but does not eliminate regrowth. A tool call still referenced after its grace window expires is re-stubbed and may be re-queried again — the accepted cost of keeping the window's context-growth impact bounded instead of unbounded. + +--- + +## Pruned Data Is Still Available + +Pruning does **not** delete data. It moves raw tool results out of the hot path (active LLM context) and into an indexed archive the model can query later. + +There are two separate things happening during pruning: + +1. **Context filtering:** future requests stop including the old `toolResult` messages. +2. **Index preservation:** the extension stores each summarized tool call in the pruner index, keyed by its occurrence key (`id@resultTimestamp`, or the bare id for legacy records - see [Occurrence Identity](#occurrence-identity)). + +That distinction is the core idea: + +- **Pruned from context** does **not** mean **lost** +- It means **hidden from the default prompt**, but still **recoverable on demand** + +### ASCII: How `context_tree_query` recovers pruned data + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ RECOVERING PRUNED DATA via context_tree_query │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ [summary] ... build failed: circular dependency ... │ +│ - `t1` read config.ts -> 3 exports │ +│ Summarized tool refs: `t1` │ +│ Use `context_tree_query` with these refs │ +│ │ +│ ── LLM calls context_tree_query({ toolCallIds: ["t1"] }) ── │ +│ │ +│ [tool] ⌕ context_tree_query result │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ Tool: bash (t1) │ │ +│ │ Status: OK │ │ +│ │ ───────────────────────────────────────────────── │ │ +│ │ $ npm run build │ │ +│ │ > react-app@0.1.0 build │ │ +│ │ > tsc && vite build │ │ +│ │ │ │ +│ │ [warn] Circular dependency: src/utils/index.ts -> │ │ +│ │ src/utils/helpers.ts -> src/utils/index.ts │ │ +│ │ [warn] (!) Some chunks are larger than 500 kBs │ │ +│ │ [error] TS2345: Argument of type 'X' not assignable│ │ +│ │ to parameter of type 'Y'... │ │ +│ │ │ │ +│ │ [Output truncated: 200/512 lines shown] │ │ +│ └─────────────────────────────────────────────────────┘ │ +│ │ +│ The LLM now has the full build log back in context, on demand, │ +│ without permanently inflating the context window. │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +## What Actually Lives in the Pruner Index + +When a batch is summarized, the extension writes a record for each tool call into `ToolCallIndexer` and persists that record into the session as a custom index entry. + +Conceptually, each indexed record looks like this (see `ToolCallRecord` in `src/types.ts`): + +```ts +{ + toolCallId: "tc-006", + toolName: "bash", + args: { command: "npm run build" }, + resultText: "full original raw output...", + isError: false, + turnIndex: 5, + timestamp: 1745251200000 // epoch ms +} +``` + +This matters because the summary is **not** the only surviving representation of the old tool call. +The model still has access to: + +- the original `toolCallId` +- the tool name and arguments +- whether the tool errored +- which turn it came from +- the full original raw result text + +So after pruning, the model is working with a **two-layer memory**: + +1. **Hot memory:** compact summary text kept directly in context +2. **Cold memory:** full raw tool outputs stored in the pruner index and retrievable by ID + +### What is removed vs what is preserved + +| Part of old turn | After pruning | Why | +|---|---|---| +| Assistant tool-call block | **Kept in context** | Preserves the `toolCallId` anchors the model uses to reference originals | +| Tool result message | **Replaced by a short stub in active context** | Saves tokens (typical 50–100× reduction per call) while keeping the `toolCallId` anchor and giving the model an explicit `context_tree_query` breadcrumb. The stub keeps `role: "toolResult"` and `isError: false` so role alternation stays intact | +| Summary message | **Added to context** | Gives the model a compact description of what happened | +| Indexed tool-call record | **Stored in pruner index** (`context-prune-index` session entry) | Lets the model re-open the original raw output later via `context_tree_query` | +| Duplicate of an already-indexed record (same toolName + content) | **Aliased to the original; no new summary, no LLM call** (`context-prune-dedup-alias` session entry) | See [Content-hash dedup](#content-hash-dedup) | + +## How the Model Re-reads Raw Outputs + +The intended recovery flow is: + +1. The model reads a summary message. +2. The summary lists the short refs (`t1`, `t2`, …) that were summarized. Each per-tool bullet also carries its own inline `` `tN` `` ref (copied from a `[[N:toolname]]` label the summarizer emits, validated against the tool at that position), so the model can jump from a bullet straight to its ref; the flat footer still lists every ref as a fallback. +3. The model decides the summary is not enough and wants exact raw output. +4. The model calls `context_tree_query({ toolCallIds: ["t1", ...] })`. The tool accepts short refs and full `toolCallId`s interchangeably (`indexer.resolveToolCallId`). +5. The tool looks up those IDs in the pruner index. +6. The tool returns the original stored output back into the current turn. +7. The model can now inspect that raw result and continue reasoning. + +### ASCII: end-to-end "prune, then re-read" flow + +```text +assistant turn with tools + │ + ▼ +raw tool results exist in context + │ + ▼ +batch gets summarized + │ + ├─► summary message added to context + │ └─► includes short refs (`t1`, `t2`, …) + │ + ├─► tool results indexed by occurrence key (id@resultTimestamp) + │ └─► full raw resultText stored in index/session + │ + └─► old toolResult messages removed from future context + +later... + │ + ▼ +model sees summary and decides: "I need the exact old output" + │ + ▼ +context_tree_query({ toolCallIds: ["t1"] }) + │ + ▼ +query tool resolves short ref / id and loads the indexed record + │ + ▼ +original raw output is returned into the current turn + │ + ▼ +model continues with exact old context back in view +``` + +### Why this is important + +- summaries keep the default context small +- short refs / `toolCallId`s keep old work addressable +- `context_tree_query` makes the archive readable again +- the model can "page in" exact old context only when it actually needs it + +Summaries are the default view; raw data remains addressable through `context_tree_query`. + +--- + +## How Prefix Caching Works + +Modern LLM API providers (Anthropic, OpenAI, vLLM, etc.) implement **prefix caching** (also called "prompt caching") to speed up repeated requests with similar prompts. + +### How it works + +LLM inference has two phases: + +1. **Prefill** — compute Key-Value (KV) attention states for all input tokens +2. **Decode** — generate output tokens autoregressively, reusing the cached KV states + +Prefix caching stores the KV states for an exact token sequence on the provider's GPU. When a new request shares an identical prefix, the provider **skips prefill** for that prefix and starts from the cached KV state. + +### ASCII: Cache hit vs cache miss + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ WITHOUT PREFIX CACHING │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ Request 1: [System] [User Q1] ──► LLM computes KV for ALL tokens │ +│ │ │ +│ ▼ │ +│ Generate answer │ +│ │ +│ Request 2: [System] [User Q2] ──► LLM computes KV for ALL tokens │ +│ ▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲ │ (EVERYTHING recomputed) │ +│ Same prefix as Request 1 ▼ │ +│ Generate answer │ +│ │ +│ Time: ████████████████████████████████████████ ~2.5s each │ +│ Cost: Full input tokens priced at standard rate │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ + +┌─────────────────────────────────────────────────────────────────────────┐ +│ WITH PREFIX CACHING (HIT) │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ Request 1: [System] [User Q1] ──► LLM computes KV for ALL tokens │ +│ │ │ +│ ┌───────────────────┘ │ +│ ▼ │ +│ [STORED IN CACHE] │ +│ │ +│ Request 2: [System] [User Q2] ──► SKIP! KV loaded from cache │ +│ ▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲ │ (prefix match = instant) │ +│ Cache hit! ▼ │ +│ Only compute NEW tokens │ +│ Generate answer │ +│ │ +│ Time: ████████████████████░░░░░░░░░░░░░░░░░░░ ~0.5s (80% faster) │ +│ Cost: Cached prefix at 50-90% discount (provider-dependent) │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +### Provider specifics + +| Provider | Cache Activation | Match Type | Duration | +|---|---|---|---| +| **Anthropic** | Manual — `cache_control: {type: "ephemeral"}` on message blocks | Exact prefix from breakpoints | Tied to usage pattern | +| **OpenAI** | Automatic for prompts ≥1,024 tokens | Exact prefix match; ~first 256 tokens hashed for routing | 5–10 min inactivity (up to 1 hr) | +| **vLLM / Self-hosted** | Automatic via hash-based block matching | Exact block match | Instance lifetime | + +### Critical rule + +> **Cache matching requires *exact* token sequences.** Any change — reordering messages, editing text, adding/removing tool results, even whitespace — alters the prefix hash and triggers a **cache miss**. + +--- + +## Why Frequent Pruning Busts Cache + +Every time you prune, you **rewrite the prefix**. The message that was previously a 3,400-token tool result is now a 200-token summary. That's a different token sequence, so the cache is invalidated. + +### ASCII: The per-turn pruning trap (naive per-turn pruning) + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ NAIVE PER-TURN PRUNING — AGGRESSIVE BUT CACHE-UNFRIENDLY │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ Turn 1: Read file → 45 tokens │ +│ └──► summarize → inject summary │ +│ │ │ +│ └──► CACHE BUST: context changed from [toolResult] to [sum] │ +│ │ +│ Turn 2: Read package.json → 120 tokens │ +│ └──► summarize → inject summary │ +│ │ │ +│ └──► CACHE BUST again: prefix rewritten AGAIN │ +│ │ +│ Turn 3: Web search + read file → 3,600 tokens │ +│ └──► summarize → inject summary │ +│ │ │ +│ └──► CACHE BUST again: prefix rewritten AGAIN │ +│ │ +│ After 5 turns: │ +│ • 5 summarizer LLM calls (latency + cost) │ +│ • 5 cache busts (FULL prefill every time) │ +│ • No prefix ever stayed stable long enough to benefit from caching │ +│ │ +│ Time: ████████████████░░░░░░ ~80% spent on re-computing prefixes │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## The Sweet Spot: Batch and Prune + +The insight is simple: **batch many tool turns, then prune once**. Everything before the prune point stays in the prefix cache and remains cacheable. Only the new suffix (since the last prune) needs fresh computation. + +### ASCII: Batch pruning (`agent-message` mode) + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ agent-message MODE — BATCH THEN PRUNE (RECOMMENDED) │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ BATCH PHASE: Tool turns accumulate (not pruned yet) │ │ +│ │ │ │ +│ │ Turn 1: Read file → 45 tokens ═══════╗ │ │ +│ │ Turn 2: Read package → 120 tokens ════╬══════╗ │ │ +│ │ Turn 3: Web search + read → 3,600 tok ═════╬═╬═════╗ │ │ +│ │ Turn 4: Edit file → 15 tokens ═══════╬═╬═╬═╬════╬═════╗ │ │ +│ │ Turn 5: Build + read → 2,980 tokens ═╬═╬═╬═╬════╬═════╬═╗ │ │ +│ │ Turn 6: Read + edit → 105 tokens ═════╬═╬═╬═╬════╬═════╬═╬ │ │ +│ │ ▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼ │ │ +│ │ All these tool results stay in context UNCHANGED │ │ +│ │ → Prefix cache is STABLE → cache HITS on every turn │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ │ +│ │ Agent sends final text reply │ +│ ▼ │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ PRUNE PHASE: Single summary replaces batch │ │ +│ │ │ │ +│ │ [summary] "Built React table component. Key decisions:..." │ │ +│ │ │ │ +│ │ ONE cache bust, then context is STABLE again │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ STABLE PHASE: New requests reuse cached prefix │ │ +│ │ │ │ +│ │ User: "Now add sorting" │ │ +│ │ → Cache HIT on [system] + [user] + [summary] prefix │ │ +│ │ → Only "Now add sorting" needs prefill │ │ +│ │ → Fast + cheap │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +│ Result: 1 cache bust per meaningful work unit, not per turn │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +### Mermaid: Cache-friendly pruning lifecycle + +```mermaid +%%{init: {'theme': 'base', 'themeVariables': { 'primaryColor': '#e8f5e9', 'primaryTextColor': '#1b5e20', 'primaryBorderColor': '#4caf50', 'lineColor': '#4caf50', 'secondaryColor': '#ffebee', 'tertiaryColor': '#fff3e0'}}}%% + +graph LR + subgraph Phase1["🟢 Stable Prefix
(Cacheable)"] + direction TB + S1["System Prompt"] --> U1["User Request"] + U1 --> P1["Previous Summary"] + P1 --> C1["Current Turn"] + end + + subgraph Phase2["🟡 Batch & Grow
(Still Stable)"] + direction TB + B1["Turn N: tool calls"] + B2["Turn N+1: tool calls"] + B3["Turn N+2: tool calls"] + B1 --> B2 --> B3 + end + + subgraph Phase3["🔴 Prune Once
(Cache Bust)"] + P["Single Summarize + Prune"] + end + + subgraph Phase4["🟢 Stable Prefix Again
(Cacheable)"] + direction TB + S2["System Prompt"] --> U2["User Request"] + U2 --> P2["New Summary"] + P2 --> C2["Current Turn"] + end + + Phase1 -->|"agent starts
tool work"| Phase2 + Phase2 -->|"final text reply
or loop ends"| Phase3 + Phase3 -->|"context now
compact & stable"| Phase4 + Phase4 -->|"next task batch"| Phase2 + + style Phase1 fill:#e8f5e9 + style Phase2 fill:#fff3e0 + style Phase3 fill:#ffebee + style Phase4 fill:#e8f5e9 +``` + +### Cache impact trade-off + +| Mode | Cache Busts per 5 Turns | Context Reclaimed | Recommended for | +|---|---|---|---| +| `agent-message` | 1 | After batch | **Default** — best balance | +| `on-demand` | 0–1 | When you say so | Maximum cache preservation | + +> **Everything before the previous pruning point stays in the prefix cache.** The cached prefix is the stable foundation; only the new suffix (recent turns since last prune) changes per request. + +--- + +## Pre-flush Pipeline & Safeguards + +`flushPending` runs a deterministic pipeline BEFORE any summarizer LLM call. Each step is configurable and each one can drop a batch entirely, in which case the prune frontier still advances so the same tool calls are not reconsidered next flush. + +``` +captured batches (from turn_end or session scan) + │ + ├─ 1. Protected-tools/paths filter (capture-time, see below) + │ tool calls whose toolName is in protectedTools, OR whose args.path + │ matches any protectedPaths glob, never enter the batch + │ + ├─ 2. Eager single-result spill (config: spillThreshold, default 65536) + │ turn_end: single result >= spillThreshold chars → write sidecar; + │ index immediately via addBatch (no LLM); pruner emits file-pointer stub; + │ dedup precedence: protected → dedup → spill (duplicate → alias, no second file) + │ + ├─ 3. Frontier trim (drop tool calls already past the frontier) + │ + ├─ 4. Content-hash dedup (config: dedupByContentHash, default ON) + │ identical (toolName, normalize(resultText)) → alias of original; + │ no LLM call; persist as context-prune-dedup-alias + │ + ├─ 5. Trivial-batch skip (config: minBatchChars, default 1000) + │ batches whose remaining raw chars < threshold → skip; no LLM call; + │ leave originals in context; advance frontier + │ + ├─ 6. Summarizer LLM call (parallel: one call per batch) + │ resolveModel + summarizeBatch / summarizeBatches + │ + └─ 7. Oversized post-check (summary >= raw? → skip; advance frontier) +``` + +The outcome label written into `context-prune-frontier` is one of `summarized`, `skipped-deduped`, `skipped-trivial`, or `skipped-oversized` so the audit trail captures *why* a range was passed over. + +### Stub-replace instead of delete + +Before v0.11.0 the pruner deleted summarized `ToolResultMessage` entries outright. This worked, but it left the matching `AssistantMessage.toolCall` blocks orphaned, and pi-ai's `transformMessages.insertSyntheticToolResults` would inject `{ isError: true, content: "No result provided" }` for every orphan at LLM call time. The model then saw a parade of fake "tool errors" before the unrelated summary user-message appeared later, which mildly confused larger reasoning models. + +The pruner now keeps the toolResult message but replaces its content with a small stub: + +``` +[Summarized in pruner summary, ref `tN`. Use context_tree_query to retrieve full output.] +``` + +Properties: + +- `role: "toolResult"` and the original `toolCallId` / `toolName` / `timestamp` are preserved — role alternation is intact; no synthetic-result injection. +- `isError: false`, so the model does not interpret the stub as a tool failure. +- The stub references the **short ref** (`t1`, `t2`, …) the indexer assigned at summary time. Legacy entries from before short-refs landed fall back to the raw `toolCallId`. +- Deterministic per occurrence key — the stub text never changes across renders of the same `toolResult`, so the prefix cache continues to hit on the pruned range. A reused `toolCallId` is a *different* occurrence (different `resultTimestamp`) and gets its own stub and its own short ref. + +Implementation: `src/pruner.ts` `pruneMessages(messages, indexer)` returns `{ messages, pruned }`. When `pruned === false`, the original array reference is returned and the `context` handler skips reconstruction entirely. + +### Protected tools & paths + +A tool call is protected if **either** its `toolName` is in `protectedTools` **or** its `args.path` (string) matches any glob in `protectedPaths`. Protected calls are filtered out **at capture time** - they never enter the `pendingBatches` queue, so their raw `ToolResultMessage` stays verbatim in future LLM context. + +**`protectedTools: string[]`** (default `[]`) - allowlist of tool names. Covers tools whose output is a small handle that must be reused byte-for-byte (e.g. a session-id) or planning tools like `todowrite` / `todoread`. + +**`protectedPaths: string[]`** (default `["**/skills/**/*.md"]`) - glob list matched against `args.path`. Designed for skill files that carry multi-step workflow gates; summarizing them is categorically lossy. Non-string or missing `path` arguments never match. Set `[]` to disable. Edit with `/pruner protected-paths`. + +Glob contract: full-path match against the raw `args.path` string with `\` normalized to `/`. `*` and `?` match within a segment (no `/`); `**` crosses segments; `**/` also matches zero directories (so `**/SKILL.md` matches a bare relative `SKILL.md`). Case-sensitive. All other characters are regex-escaped literals. + +**Render-time re-check:** stub replacement runs in-flight on every turn (`pruneMessages`). If a tool call's persisted `args` now satisfy `isProtected` (e.g. a pattern was added mid-session), the stub is skipped and the raw result is left verbatim - this repairs already-summarized records in existing sessions with no schema change. Declared limitation: records inside already-compressed chains (`context-prune-chain` entries) are NOT repaired - their `protectedToolCallIds` set is fixed at compression time (forward-only). Dedup-alias edge: an alias resolving to an unprotected original stays stubbed. + +Names and patterns that don't match any captured tool call are silently ignored. + +### Eager single-result spill + +`spillThreshold: number` (default `65536`) is a capture-time safeguard for outsized single tool results (e.g. a 1 MB web fetch, a full binary diff). When a single `ToolResultMessage`'s `resultText.length` reaches the threshold, the result is spilled immediately at `turn_end` — before the pending-queue trim and before any LLM call. + +**Sidecar location:** `/-blobs/.txt`. + +**Index entry:** `addBatch` is called synchronously with the spilled body (no LLM round-trip). The record is immediately `isSummarized = true`; the pruner emits a mechanical file-pointer stub: + +``` +[Spilled: ( bytes). Head preview: + +Full output: . Use context_tree_query() to retrieve.] +``` + +**Dedup precedence:** `protected → dedup → spill`. An oversized result that is also a content-hash duplicate of a prior record is aliased to the original; no second sidecar is written. + +**Atomicity:** the sidecar is written first; only on success is the in-memory record mutated. A write failure leaves the result inline for the normal flush and is logged via `console.error` — no data is lost. + +**Hybrid storage:** bodies below `spillThreshold` stay inline in the `context-prune-index` session entry (portable, as before); only oversized bodies are spilled. Moving the session `.jsonl` without its `-blobs/` directory loses only the giant-blob recovery path; the stub and head preview remain in the index entry. + +### Trivial-batch skip (minBatchChars) + +`minBatchChars: number` (default `1000`) is a pre-flush guard against "summary would be roughly the same size as the input" cases. If the total raw `resultText` across a batch is below the threshold, the batch is skipped: no summarizer LLM call, no `context-prune-index` entry, no `context-prune-summary` injection. The frontier still advances, so the same tool calls are not reconsidered next flush. + +Why it exists: a short LLM summary like "Tool X did Y" is itself ~50–150 chars per call. For a 200-byte file read or an `ls` of a short directory, the summary is the same size or larger than the input — the post-call `skipped-oversized` mechanism would catch it anyway, but only after the LLM round-trip and the cost. `minBatchChars` short-circuits the obvious cases at zero LLM cost. + +Set `minBatchChars: 0` to disable. The default `1000` skips obvious trivial batches (`git status`, small file reads, short directory listings) without affecting realistic tool outputs. Edit with `/pruner min-batch-chars ` or via the settings overlay. + +### Content-hash dedup + +`dedupByContentHash: boolean` (default `true`) catches re-reads of already-pruned tool outputs at zero LLM cost. + +Mechanism: + +1. When a batch enters `flushPending`, each tool call is hashed by `SHA-1(toolName + "\0" + normalize(resultText))`. +2. The indexer's `contentHashToOriginal` map (populated by every earlier `addBatch` / `reconstructFromSession`) is consulted. +3. A hit means an earlier prune already covered identical content. The duplicate is registered as an alias of the original via `indexer.registerDuplicate(newKey, originalKey, appendEntry)`, where both are occurrence keys (or legacy bare ids): + - `dedupAliasToOriginal[newKey] = originalKey` (so `isSummarized(newKey) === true` and `resolveToolCallId(newKey) === originalKey`). + - `toolCallIdToAlias[newKey] = toolCallIdToAlias[originalKey]` (so `getShortRefForToolCallId(newKey)` returns the **same** `tN` as the original). + - Keying by occurrence key, not bare id, means a duplicate is only ever aliased to the specific earlier *occurrence* it matches byte-for-byte - a reused id whose later occurrence has different content is not conflated with the stale one. + - A `context-prune-dedup-alias` custom entry is persisted so `reconstructFromSession` rebuilds the maps after a restart. +4. The duplicate is removed from the batch — no summarizer call, no new index entry. +5. Later, `pruneMessages` stub-replaces the duplicate's `ToolResultMessage` using the original's short ref, and `context_tree_query` returns the original's record whether the model passes the duplicate's id or the original's. + +Normalization is conservative: `\r\n` → `\n`, per-line trailing whitespace stripping, final `trim()`. Internal whitespace, tabs, and capitalization are preserved so two genuinely different outputs do **not** collide. + +Typical wins: re-reading an unchanged file, repeated `git status` / `ls`, retries of the same command. v1 only matches against records **already in the indexer** (cross-flush dedup); intra-flush dedup is deferred so canonicals that get skipped as oversized / trivial never produce dangling aliases. + +Edit with `/pruner dedup on|off|status` or the settings overlay. + +### Oversized summary skip + +Last-resort safeguard: if the summarizer LLM produces a summary longer than the raw tool-result text it would replace, the batch is left untouched — the original tool results stay in context, no summary is injected, and the frontier still advances so the next prune attempt starts after this range instead of retrying it. The `quietOversizedSkips` config silences the info notification (the skip itself still happens). + +This is rare in practice once `minBatchChars` is on, because the cases where summarization makes things bigger are exactly the cases the trivial-batch skip already catches earlier. + +### Frontier persistence + +The last attempted prune boundary is persisted as `context-prune-frontier` so `flushPending` knows where the previous attempt left off, even if that attempt was a skip rather than a real summary. Without this, a batch that's been skipped as oversized would be re-attempted (with the same LLM call, the same oversize result, the same skip) on every subsequent flush. + +### Other UI / observability features + +- **Tree browser (`/pruner tree`):** interactive, foldable tree of pruned tool calls grouped under their summaries. `Ctrl-O` on a summary node opens the full markdown summary in a bordered overlay. +- **Configurable summarizer thinking (`summarizerThinking`):** trade summary cost / latency for quality (`off` / `minimal` / `low` / `medium` / `high` / `xhigh`). `default` omits the option entirely so the provider chooses. +- **Cumulative stats:** `context-prune-stats` entries track input/output tokens and cost of every summarizer call; full detail surfaces in `/pruner stats`. Cost is also emitted on the `cost:external` pi.events channel for external aggregators (cumulative per session, live only). +- **Live reclaim ratio:** measured once per `pruneMessages` call via `sizeMessages(messages) = JSON.stringify(messages).length`, comparing the input array before pruning to the result after. Estimated tokens = chars / 4. The measurement covers all four phases in a single point (stub-replace, error-purge, chain-range-prune, orphan-sweep); appears on the status line as `│ prune: ON · 92.0k->14.0k (-85%)` once at least one prune has occurred (the leading `│` keeps the segment visually isolated in the shared footer, load-order independent - there is no trailing divider, since the footer's own space-join between segments already provides one). +- **Live progress for `/pruner now`:** an `aboveEditor` widget shows one row per pending batch with braille spinner, streamed summary-char count, and ✓ / ⚠ status. + +### Summarizer outage fallback + +Provider incidents are routinely per-model: a cheap summarizer model (e.g. +Haiku) can be down while the session's main model (Sonnet/Opus) stays healthy. +Without a fallback, `runSummarization` returns `null`, the batch is re-queued, +and the next flush retries the same dead model — pruning stalls for the whole +outage and context grows unbounded. + +`src/summarizer-fallback.ts` adds a sticky, in-memory `FallbackController` that +engages only on **transient** failures of the configured summarizer model and +only when a distinct fallback model exists (`summarizerModel` != `default` and +the resolved model differs from `ctx.model`): + +- **Classification is coarse** (pi-ai surfaces no status code on the throw): + `auth` (pre-flight key failure) and `unusable` (empty / length-truncated) + never trip the controller; a `transient` stream error / `stopReason: error` + does. Aborts propagate unchanged. +- **Enter:** a transient primary failure is retried once on the session model. + If that succeeds, the session flips to fallback and a one-time warning fires. +- **Sticky + probe:** while in fallback, all calls route to the session model. + After a 10-minute cooldown (`COOLDOWN_MS`, internal, not configurable) one + batch of the next flush probes the primary; success recovers (info notify), + failure stays in fallback. +- **In-memory only:** no `context-prune-*` entry; `reset()` on `session_start`. + A restart mid-outage re-detects on the next flush. + +Cost note: the initial detection flush can fire up to N doomed primary calls +before the outage is known; steady state is 0 doomed calls, plus exactly 1 +probe per 10 minutes. After the primary recovers, summarization keeps running +on the (often pricier) session model for up to one cooldown before the probe +switches back. + +--- + +## Why Summarization Works: Research Evidence + +Summarizing tool-call history is not just a hack — it is an active research area with strong empirical support. Three recent papers establish the benefits: + +--- + +### SUPO — Summarization augmented Policy Optimization + +> **Paper:** *SUPO (arXiv:2510.06727)* — Miao Lu et al. +> **TL;DR:** RL-trained agents with built-in summarization outperform standard agents on long-horizon tasks while using *less* context. + +**Core idea:** +SUPO integrates summarization directly into the RL training pipeline for tool-using agents. Instead of treating context compression as an afterthought, the policy gradient is derived to optimize **both** tool-use behavior **and** summarization strategy end-to-end. + +**Method:** + +- Periodically compresses tool-using history via LLM-generated summaries +- Retains task-relevant information in compact form +- Derives a policy gradient that lets standard LLM RL infrastructure optimize both behaviors simultaneously +- Enables training beyond fixed context limits + +**Key results:** + +- Significantly improved success rate on interactive function calling and search tasks +- **Same or lower working context length** compared to baselines that don't summarize +- Test-time scaling: increasing the maximum summarization rounds during evaluation further improves performance + +**Why this matters for Pi:** +SUPO proves that summarization is not just about saving tokens — it actively **improves task success** on long-horizon multi-turn problems by preventing context overflow and keeping relevant signals prominent. + +--- + +### ReSum — Recursive Summarization for Long-Horizon Agents + +> **Paper:** *ReSum (arXiv:2509.13313)* — Xixi Wu et al. (Alibaba) +> **TL;DR:** A plug-and-play summarization tool enables web agents to explore indefinitely without hitting context limits, achieving 4.5–12.7% gains over ReAct. + +**Core idea:** +ReSum addresses the fundamental conflict between **exploration** (needing many tool calls) and **context limits** (fixed window size). Current agents append every thought/action/observation to history until they crash into the context ceiling. + +**Method:** + +- Periodically invokes an external **summary tool** to condense interaction history +- The agent restarts reasoning from the compressed summary +- Introduces **ReSum-GRPO**: adapts Group Relative Policy Optimization with **advantage broadcasting** — propagates final trajectory rewards across all segments so early exploration steps get proper credit +- Trained a specialized **ReSumTool-30B** to extract key evidence and propose next steps + +**Key results:** + +- **4.5% improvement** over ReAct in training-free settings +- **Further 8.2% gain** with ReSum-GRPO training +- A 30B ReSum-enhanced agent with only 1K training samples achieves competitive performance with leading open-source models +- Enables "unbounded exploration" — the agent never hits a hard context wall + +**Why this matters for Pi:** +ReSum validates the exact architecture `pi-condense` uses: an external summarizer module, periodic compression, and recovery from compressed state. The plug-and-play nature means it works with off-the-shelf agents — no retraining required. + +--- + +### ACON — Agent Context Optimization + +> **Paper:** *ACON (arXiv:2510.00615)* — Minki Kang et al. (Microsoft/ KAIST) +> **TL;DR:** Optimized compression guidelines reduce memory by 26–54% while preserving accuracy; distilled compressors retain >95% of performance. + +**Core idea:** +ACON is a unified framework that compresses **both** environment observations and interaction histories into "concise yet informative condensations." It treats compression as an optimization problem: maximize task reward while minimizing context cost. + +**Method:** + +- **Gradient-free** — uses natural language space optimization (no model fine-tuning) +- **Failure-driven guideline optimization:** runs the agent with and without compression, collects cases where compression caused failure, and uses an optimizer LLM to refine compression guidelines +- Two-step alternation: + 1. **Utility maximization** — ensure task success is preserved + 2. **Compression maximization** — make summaries shorter while keeping sufficiency +- **Distillation:** optimized compressor can be distilled into smaller models (e.g., Qwen-14B) with >95% accuracy retention + +**Key results:** + +- **26–54% reduction in peak tokens** across AppWorld, OfficeBench, and Multi-objective QA +- Preserves task performance with large models +- **Smaller LMs improve 20–46%** as agents when context compression removes distracting noise +- Distilled compressor retains **>95% accuracy** + +**Why this matters for Pi:** +ACON demonstrates that **compression not only saves tokens but can improve agent performance** — especially for smaller models, where long noisy context actively degrades reasoning quality. The failure-driven optimization approach shows that even simple summarization, when guided by task structure, preserves critical signals. + +### Token-budget auto-flush trigger + +`autoBudgetThreshold` (maintained default `0.735`) is an ADDITIONAL flush trigger orthogonal to `pruneOn`. When set to a fraction in `(0, 1]`, the extension evaluates context usage at the end of every tool-using turn; when `tokens` reaches `min(MAX_BUDGET_WINDOW, threshold * contextWindow)` - i.e. the configured share of the model's window, or 300,000 tokens, whichever comes first - all pending batches are flushed immediately regardless of the configured `pruneOn` mode. `null` disables the trigger. + +**Why we compute this ourselves rather than using `ContextUsage.percent`:** the provider's `percent` field is a 0-100 value, and both it and `tokens` are `null` immediately after a provider-side compaction. Comparing `tokens` against a token level we derive ourselves keeps the unit unambiguous and is independently null-safe - a `null` tokens value makes the trigger a no-op until usage is reported again. Note this trigger never divides: the fraction form (`tokens / min(contextWindow, MAX_BUDGET_WINDOW)`) belongs to `usageFraction` and the delta trigger below, and reading the threshold as a share of a capped window would be wrong - on a 1M-window model, `0.4` fires at 300,000 tokens, not at 120,000. + +**Why the 300k ceiling (`MAX_BUDGET_WINDOW`, `src/budget.ts`):** the trigger was originally a pure fraction of the advertised window, which made it unreachable as windows grew. On a 1M-window model, `0.9` means 900k tokens - a session ends long before that, so users observed "pruning never happens" (issue #7), while the same setting worked on a 200k model. The ceiling is deliberately chosen so it never binds at or below a 300k advertised window (a 256k model at `0.9` still fires at 230.4k), so it changes behavior only where the fraction was already unusable. Users keep full downward control: `0.1` on a 1M model still means 100k tokens. + +Lineage: simplified take on DCP's `maxContextLimit` nudging — a single threshold that forces a flush rather than separate nudge/force thresholds. + +### Budget-delta flush + +`budgetTurnDelta: number | null` (maintained default `0.04`) is a per-turn usage-jump trigger ORed with `autoBudgetThreshold`. When set to a fraction in `(0, 1]`, the extension compares the current turn's usage fraction (`tokens / min(contextWindow, MAX_BUDGET_WINDOW)`) to the previous turn's and forces a flush if the jump meets or exceeds the delta. Because the denominator carries the same 300k ceiling, the required growth is `delta * min(contextWindow, MAX_BUDGET_WINDOW)` tokens: `0.04` = +12k on any model at or above 300k, +8k on a 200k model. The ceiling enters through the denominator here rather than bounding a level, because a 300k ceiling on one turn's growth could never bind. Note the fraction is therefore not bounded by 1 above the ceiling (600k tokens on a 1M window = 2.0) and is deliberately not clamped - clamping would saturate this trigger and stop it re-arming. + +Use case: a single enormous tool result can jump context usage by 20–30 percentage points in one turn; `autoBudgetThreshold` misses this until the next turn. `budgetTurnDelta` catches the spike immediately. + +**`previousFraction` tracking:** + +- Reset to `null` on `session_start` and `session_tree` (session reload). +- Left unchanged on a null-tokens turn immediately following a provider-side compaction (treating a post-compaction null as `0` would produce a spurious spike on the next real turn). +- The post-restart first turn cannot fire a delta trigger (no prior fraction to compare) and falls back to `autoBudgetThreshold` alone. + +`null` = off; the maintained bundle enables this trigger at `0.04`. + +--- + +## Chain Compression + +Chain compression is a second layer on top of the per-batch tool-result stub pruner. It operates on entire closed conversation chains rather than individual tool results, reclaiming the tokens that the stub pruner cannot touch: assistant thinking blocks, encrypted thinking signatures, and tool-call argument bodies. + +### What a closed chain is + +A **closed chain** is a span of messages from one user message through any number of tool-using assistant turns and their results, ending in a final text-only assistant reply: + +``` +[user msg] ← chain start (kept raw) +[assistant: thinking + toolCalls] ← middle (dropped) +[toolResult] ← middle (dropped) +[assistant: thinking + toolCalls] ← middle (dropped) +[toolResult] ← middle (dropped) +[assistant: text-only] ← chain close (kept, thinking stripped) +``` + +### What gets dropped vs. kept + +| Part | After chain compression | +|---|---| +| Start user message | **Kept raw** | +| Middle assistant turns (all) | **Dropped** — assistant thinking + signatures + toolCall argument blocks | +| Middle tool results (all) | **Dropped** — already stub-replaced by the per-batch pruner; now fully removed | +| Per-batch summary message(s) for this chain | **Suppressed** — replaced by the chain-level synthetic | +| Final text-only assistant | **Kept**, thinking blocks stripped (safe — no following tool cycle depends on the signature) | +| Synthetic `` user message | **Injected** immediately after the start user message | + +**Why there is no separate thinking strip.** The synthetic `` block is injected as a `role: "user"` text message, which Anthropic reads as a genuine user turn. That closes the assistant cycle, and the API drops every prior thinking block from the context window server-side - verified against the live API: the same 8-turn loop bills 3152 thinking tokens with a full `toolResult` history and 0 once a user text block precedes the final assistant. So chain compression already reclaims thinking mass for free, as a side effect of the range drop that was rewriting that region anyway. Stub replacement does not have this effect: it preserves `role: "toolResult"`, so the cycle stays open. A dedicated strip phase shipped through v2.4.3; removed in v2.5.0 - see `doc/specs/2026-08-05-remove-thinking-strip.md`. + +### Transform composition order + +``` +raw messages from session + │ + ├─ [1] tool-result stub-replace (per-batch; existing) + ├─ [2] error-purge (phase 2) + ├─ [3] chain-range-prune (runs AFTER stubs) + │ resolve each entry to a positional index range + │ drop assistant / toolResult / per-batch-summary inside the range + │ inject after start user + │ strip thinking from final assistant + └─ [4] orphan sweep (structural post-condition, see below) +``` + +### Identification model: positional ranges, not ids + +Pi-ai's `Message` union (`UserMessage | AssistantMessage | ToolResultMessage`) has no `.id` field, and provider `toolCallId`s are not session-durable (see [Occurrence Identity](#occurrence-identity) below). Chain compression therefore decides **what to drop** by message position, not by id membership. + +`resolveRange` (`src/chain-range-prune.ts`) maps a persisted `ChainCompressionEntry` to an index range in the current message array: + +- exactly one `user`-role message at `entry.startUserTimestamp` +- exactly one `assistant`-role message at `entry.finalAssistantTimestamp` +- `startIndex < endIndex` + +Any other outcome - zero or multiple matches on either boundary, `finalAssistantTimestamp === null`, or a non-ordered pair - resolves to `null` and the entry drops **nothing** and inserts **no synthetic** for that range. This is fail-closed on purpose: an id-set or timestamp-window fallback (accepting a match even when it's ambiguous) risks deleting a live assistant turn that reuses a compressed chain's provider id, orphaning its tool result and producing a rejected request. A resolution failure is invisible in context - it is recorded as an `unresolved-range` diagnostic (see [Diagnostics](#diagnostics) below) and the affected range simply stays raw in context instead of being silently mis-compressed. + +**Drops are role-restricted**, not index-membership-restricted: inside an accepted range, only `assistant`, `toolResult`, and per-batch-summary (`custom`, `context-prune-summary`) messages are removed. `user`-role messages inside the range - including the already-inserted `` synthetic, which is itself `role: "user"` - and any third-party `custom_message` entries survive untouched. Preserving the synthetic is what makes re-application idempotent: calling `applyChainCompressions` a second time with the same entries sees its own synthetic already present (matched by `blockId`) and skips re-inserting it. + +**Per-batch summary suppression is coverage-based, not index-membership-based.** Under `batchingMode: "agent-message"` a per-batch summary is appended *after* `finalAssistantTimestamp`, i.e. outside the resolved range. Suppressing it by index membership would miss it entirely, so suppression instead checks whether the summary's own `toolCallRefs` overlap the set of ids actually dropped in this pass (`perBatchSummaryOverlapsDropped`). + +**Diagnostic cross-check.** Each accepted entry also carries `droppedToolCallIds` from detection time. At render time the ids actually inside the resolved range are compared against that recorded set purely as a health check - a mismatch never changes what gets dropped (the range always wins) but emits a `range-id-mismatch` diagnostic, surfacing drift between detection-time and render-time state without ever acting on it. + +### Rolling window + +`chainCompression.rollingWindow` (default `3`) controls how many recently-closed chains stay raw. Once the (K+1)-th chain closes, the oldest chain beyond the window is compressed. + +- With K=3: the three most-recently-closed chains stay as-is; the fourth triggers compression of the oldest. Exactly K closed chains are kept raw (the open/live chain is never counted). +- `/pruner compact` bypasses the window and compresses every eligible chain immediately (retroactive). + +**Closing-message threading.** In agent-message mode the flush runs from `message_end`, which pi emits to extensions *before* it persists the message to the session (`agent-session.js` runs `_emitExtensionEvent` ahead of `sessionManager.appendMessage`). So at flush time `getBranch()` is missing the just-closed final assistant, and the newest chain would read as open — over-retaining by one (effective K+1). `index.ts` threads the triggering `event.message` through `FlushOptions.closingMessage` into `withClosingMessage` so the chain closes and the window is exactly K. + +### Synthetic message format + +The injected user message body: + +``` + +[existing per-batch summary text] + +``` + +- `id="b1"` is a stable monotonic block ID (`b1`, `b2`, …) assigned at compression time and persisted in the `context-prune-chain` session entry. +- `tools="t3,t4,t5"` lists the short `tN` refs from the per-batch index so the model can call `context_tree_query` to recover individual tool outputs. +- The body is the cohesive LLM **range summary** when present (see below), else the existing `context-prune-summary` text reused verbatim. + +### Range summary fusion + +By default (`chainCompression.fuseRangeSummary`, default `true`) a compressed chain's per-batch summaries are fused into ONE cohesive summary by a single summarizer call at compression time, persisted as `rangeSummaryText` on the `context-prune-chain` entry and used as the synthetic body. This is recursive summarization (summary-of-summaries): the input is the already-pruned per-batch summary text, so it never re-sends raw tool output. + +- **Gate:** only spans with >= 2 distinct per-batch summaries are fused (nothing to fuse otherwise); single-summary spans use that summary directly. +- **Non-fatal:** if the fusion call fails or returns empty, the entry stores no `rangeSummaryText` and the renderer falls back to the per-batch concatenation — the chain still compresses. +- **Cost:** one extra summarizer call per multi-batch span, charged to the same summarizer model and folded into the usage stats (`rangesSummarized` counter). Set `fuseRangeSummary: false` to keep the zero-LLM concatenation. +- **Recovery:** unchanged — the span's tool outputs stay in `context-prune-index` and are recoverable via `context_tree_query`; the raw span text stays in the session JSONL. + +### Cache impact + +Each new chain compression busts the prefix cache from the affected chain's start-user-message onward. Mitigation: the rolling window concentrates compression decisions at predictable points (one event per chain close), so cache is only invalidated when an old chain ages out of the window — not on every turn. + +### Recovery + +Chain compression does not delete data from the session JSONL. The original tool outputs remain in `context-prune-index` entries and are recoverable via `context_tree_query`. To undo compression of a specific chain, delete the matching `context-prune-chain` entry from the session file — the chain will re-appear in context on next load. + +### Protected-output relocation + +**Contract:** outputs protected by tool name (`protectedTools`) or path glob (`protectedPaths`) must never be pruned from LLM context. The per-batch pipeline honours this at capture time (protected tool calls never enter the batch). Chain compression, however, operates at the *message range* level and drops entire middle turns wholesale - which would silently remove any protected `ToolResultMessage` residing in those turns. + +**Mechanism:** detection (`detectChains`) records which middle tool-call ids are protected on the `ChainRange`; `compressEligible` copies that id list onto the persisted `ChainCompressionEntry.protectedToolCallIds` (it stores ids only, no text). At render time, `applyChainCompressions` pulls each protected `ToolResultMessage`'s verbatim text live from the raw branch and embeds it inside the synthetic `` block: + +``` + +[range summary text] +...verbatim output... + +``` + +The protected output is relocated (moved), not copied — the original `ToolResultMessage` is dropped with the rest of the middle turns. The text stays in LLM context because it is embedded in the surviving synthetic block. It is NOT registered in the tool-call index and is NOT recoverable via `context_tree_query`; it does not need to be, because it is present verbatim. + +The `context-prune-chain` session entry carries the matching `protectedToolCallIds` array so `session_start` reconstruction can re-embed the outputs on reload. + +**Rejected alternative:** skip compression for any chain that contains a protected tool. Rejected because `todowrite`/`todoread` recur in most chains for opted-in users, so this strategy would forfeit most chain compression for the people who most need `protectedTools`. + +### Deterministic fallback (uncovered chains) + +A chain becomes eligible for compression (closed, older than the rolling window) independently of whether its middle tool calls were ever summarized. Per-batch coverage can be zero - a trivial batch, an oversized-skip, a fully-deduped batch, or a plain capture miss - and historically that meant `compressEligible` skipped the chain forever with `reason: "no-summary"`: the prune frontier had already advanced past the span, so no future flush would recapture it. A 639-call, ~935k-char chain stranded live this way (`doc/specs/2026-08-14-uncovered-chain-deterministic-backfill.md`). + +When `hasPerBatchSummaryCoveringAny` is false for a chain's middle ids, `compressEligible` takes a second, zero-LLM branch instead of skipping: + +1. **Resolve + extract.** `extractChainRecords` resolves the chain's span with the same `resolveRange` used by the drop path, then walks it positionally. Middles are excluded when protected (relocated verbatim at render, never phase-1 stubbed) or already indexed (occurrence-key membership in the indexer's record map - not `isSummarized`, which also covers dedup aliases and would wrongly re-strand a fully-deduped chain). Each surviving call becomes a `ToolCallRecord` with `turnIndex: -1` (no batch turn; `context_tree_query` renders `Turn: -1`, a pinned cosmetic). +2. **Backfill, atomically.** `indexer.backfillChainRecords` spills oversized results via the shared spill helpers (`blobPathFor`/`applySpill`), a fail-closed variant of the eager `spillOversizedBatch` path, allocates `t` refs, and appends **one** `context-prune-index` entry carrying the records plus `backfilled: true` and the allocated refs - append-before-commit, so refs are durable the instant the records are. Only after the append succeeds does it commit to the in-memory index and alias maps. Backfilled records never seed `contentHashToOriginal`: a poisoned dedup canonical could point future identical outputs at a record whose durability was never actually verified end-to-end for that purpose, so backfilled entries are excluded from canonical-seeding on both the live path and `reconstructFromSession`. +3. **Compose a deterministic body.** `buildDeterministicBody` builds a zero-LLM stub - call count, a tool-name histogram, span duration, and a `First:`/`Last:` line with args JSON capped at 200 chars, plus the `t` refs - and stores it as `rangeSummaryText` on the `context-prune-chain` entry with `bodySource: "deterministic"`. The renderer already prefers `entry.rangeSummaryText` (`src/pruner.ts`), so no renderer change was needed; entries without `bodySource` keep today's semantics unchanged. +4. **Fail closed.** Any throw during backfill (spill I/O, append failure, malformed span) preserves the historical `no-summary` skip - nothing partial is committed. A chain with zero freshly-extracted records is only treated as a genuine span mismatch (fail-closed skip + `backfill-empty` diagnostic) when it ALSO has zero members already present in the index; if a prior attempt's index append succeeded but the chain-entry append then failed, the retry composes and persists straight from the durable index records instead of re-extracting, so recovery never produces a second `context-prune-index` entry for the same span. + +**Fully-protected exception.** If every middle id in a zero-extractable, zero-indexed chain is protected (`chain.protectedToolCallIds` covers all of `chain.middleToolCallIds`), the chain stays on the plain `no-summary` skip with no `backfill-empty` diagnostic. Compressing would relocate every output verbatim into the synthetic body anyway (zero tokens saved), and the diagnostic would misreport a healthy span - firing on every restart for protected-heavy configs. + +**Known limitation.** `flushPending` returns early when there are no pending batches, before chain detection runs at all. A chain stranded in an otherwise-idle session is not healed by `/pruner now` on an empty queue - it heals on the next flush that has any work, or immediately via `/pruner compact`. + +**Cache-prefix impact.** Same as any chain compression: rewriting the span busts the prefix cache from that chain's start-user-message onward, once. The deterministic body has no `now()` or LLM nondeterminism, so it renders byte-identical on every subsequent pass - no repeated invalidation from retries or reloads. + +### Deferred + +- **Model-driven trigger.** The compressor is autonomous (rolling window). A model-callable compress tool (DCP-style: the model compresses a sub-task as it closes) is not implemented; the earlier scaffolded `agentic-auto` mode + `context_prune` tool were removed in v1.0.0. +- **Multi-turn span merging.** Each compressed span maps 1:1 to a closed chain (one user -> text-only-assistant round). Merging several consecutive closed spans into one topic summary is future work. + +### Single-chain sessions + +Phase 3 (chain compression) only ever acts on **closed** chains - a chain closes when a text-only assistant reply follows the tool-call round (see [What a closed chain is](#what-a-closed-chain-is)). A session that runs one long, uninterrupted tool chain - an autonomous agent loop that never emits a text-only reply - never closes a chain, so the rolling window has nothing to compress. The entire run accumulates as one open segment that Phase 3 cannot touch. This is a known design property, not a bug: closed-chain detection is load-bearing for Phase 3's positional-range resolution (`resolveRange`), and there is no forced-close/reopen valve (see [Deferred](#deferred) above and issue #6 - a corpus replay would be needed before any such valve is considered). + +Phase 1 (per-batch summarization) is unaffected by chain closure and remains the only lever for this shape of session. For long autonomous runs where no chain is expected to close: + +- Lower `autoBudgetThreshold` (e.g. `0.5`-`0.6` instead of `0.8`) so the budget trigger fires well before the open segment dominates the window. On a window larger than 300k the ceiling already caps the trigger point at 300,000 tokens once `threshold * contextWindow` exceeds it - that is any setting at or above `300k / contextWindow` (`0.3` on a 1M window, `0.75` on a 400k one) - so lowering the setting only matters below that point. +- Set `budgetTurnDelta` so a single turn's sudden context jump force-flushes even between budget-threshold crossings - this catches spikes a static threshold misses until the next turn. + +Neither knob makes a chain close; they just keep Phase 1 flushing on schedule so raw toolResults do not pile up unsummarized for the whole run. + +**Observability metrics** (`src/context-metrics.ts`, `computeContextMetrics`) exist precisely to make this shape of session visible instead of silently reporting `calls: 1` the way the triggering incident did. All three are chars/4 token estimates (`Math.round`, same convention as the reclaim footer) and surface on `/pruner status` (a `--- context ---` block), the footer status line (a compact suffix, shown only when the frontier gap is non-zero), and a `context-prune-flush-metrics` session entry written once per flush attempt regardless of outcome: + +| Metric | Definition | +|---|---| +| Open-cycle thinking tokens | Est. tokens of `thinking` content blocks in assistant messages strictly after the last text-only assistant reply (the open segment). Not windowed by the frontier, so a skip/oversized/trivial outcome (which advances the frontier without removing anything from context) never masks stranded thinking as ~0. | +| Largest-chain share | `max(largest closed chain, open segment)` chars, as a percentage of total branch chars. Interrupted chains count as closed for this purpose - they are retained context regardless of compressibility. A single-chain session's whole branch is its open segment, so this reads near 100% for the incident shape. | +| Frontier gap | Est. tokens of `ToolResultMessage`s after the persisted prune frontier that are summarization-eligible: not already summarized, not protected (`protectedTools`/`protectedPaths`). 0 when there is nothing left to capture. | + +See `doc/specs/2026-08-12-single-chain-observability-trigger-repair.md` for the incident and full design rationale. + +### Reload rearm + +A reload (`session_start` or `session_tree`) rescans the branch for completed-but-unflushed tool-call batches past the frontier (the same rescan `flushPending` itself uses, no queue reconstruction). If that rescan finds recoverable work, a transient in-memory flag arms: the next `turn_end` evaluates the budget/delta trigger even on a turn that contributes no new tool results itself, instead of silently requiring a fresh batch to reach the gate. No flush runs at reload time - arming only changes what an *existing* trigger sees on the next turn. + +The flag clears the moment any flush attempt runs (any outcome) and is re-evaluated on the next reload. A reload followed by total idleness - no further turns at all - gets visibility only: the footer, `/pruner status`, and the `agent_end` pending notice (`prune: recovered pending (reload)`) all reflect the recoverable work, but nothing flushes automatically. This is by design - no immediate flush at boot (print-mode sessions may die under it; a boot-time compact was not requested by the user). + +--- + +## Occurrence Identity + +Every lookup keyed on a tool call - the indexer's record map, dedup aliases, `isSummarized`, batch capture - needs a string that uniquely names one *occurrence* of a tool call for the lifetime of a session. + +### Why the provider `toolCallId` cannot be that string + +A provider's `toolCallId` (e.g. `bash_23`) is unique only within the single response that produced it. Some providers restart a `${tool}_${n}` counter, so the same bare id recurs across turns and across a session, denoting a different tool call each time. The indexer cannot treat it as session-durable identity by itself - see [Identification model](#identification-model-positional-ranges-not-ids) for how chain compression sidesteps the same problem by keying on position instead of id. + +### Why `ToolCallRecord.timestamp` cannot serve as the discriminant either + +`ToolCallRecord.timestamp` is the *batch's* timestamp, computed once per captured batch (`src/batch-capture.ts`) and shared by every tool call inside that batch - it cannot discriminate between two calls captured together, let alone across a reused id. It is also not computed the way this discriminant would need: the live `turn_end` path stamps it unconditionally with `Date.now()` (`index.ts`'s `turn_end` handler), not from the assistant message's own timestamp. This is exactly why a separate per-tool-call field was needed - `resultTimestamp`, sourced from the `ToolResultMessage`'s own `timestamp` instead of the batch. On reconstruction, `reconstructFromSession` replays whatever value was persisted on the record verbatim; it does not recompute it. + +### The key: `id@resultTimestamp` + +The one value read identically at capture time and at every later render or rescan is the `ToolResultMessage`'s own `timestamp` field, stamped once by the message itself. `src/occurrence-key.ts` combines it with the bare id: + +``` +occKey(toolCallId, resultTimestamp) = `${toolCallId}@${resultTimestamp}` +``` + +A record with no `resultTimestamp` is keyed by its bare id - the shape for any record that predates this field, not a separate code path. + +**Where the key lives:** `ToolCallRecord.resultTimestamp` and `SummaryToolCallRef.resultTimestamp` (both optional), the dedup-alias pair `newResultTimestamp` / `originalResultTimestamp`, and `ChainCompressionEntry.droppedOccurrenceKeys`. Bare-id and occurrence keys coexist in the same maps: `getRecord` / `isSummarized` compare keys as opaque strings, with no casting between the two shapes. + +**`isSummarized` is strict.** It does an exact lookup on the occurrence key (or dedup-alias map) and never falls back to the bare id on a miss. The sole sanctioned bare-id path is `hasLegacyBareRecord(toolCallId)`, true only when the bare id is indexed **and** has no occurrence-keyed siblings (`bareIdToKeys`). A bare id with mixed legacy and occurrence-keyed entries fails closed on both checks - a permissive fallback would stub or recover the wrong occurrence whenever an id was reused. + +**Spill sidecars** are named from the occurrence key, so a reused id spills to distinct files per occurrence. Recovery reads the `spillPath` persisted on the record rather than re-deriving it from the id, so bare-id-named sidecars still resolve. + +**Render-time (`src/pruner.ts` stub-replace) gates the bare-id fallback on `hasLegacyBareRecord`, not on "message has no timestamp".** The first lookup key is `occKey(msg.toolCallId, msg.timestamp)` when the message carries a `timestamp` (a live `ToolResultMessage` almost always does) or the bare id otherwise. If that first key misses `isSummarized`, the bare id is tried as a second rung - but **only** when `indexer.hasLegacyBareRecord(msg.toolCallId)` is true (bare id indexed, no occurrence-keyed siblings), regardless of whether the message itself carried a timestamp. Gating that second rung on "the message has no timestamp" instead would mean a pure-legacy session (every record captured before `resultTimestamp` existed, so every occurrence-key lookup misses) never stubs at all, since a timestamped message would never even attempt the bare-id rung. A mixed bare+occurrence id still fails closed on both lookups. + +### No migration, and the limitation that leaves + +A record persisted before `resultTimestamp` existed is bare-keyed and stays that way - `reconstructFromSession` does not scan the branch to retroactively assign it one. No migration runs on load; `hasLegacyBareRecord` is the only accommodation, and it is a derivation (single-key check on `bareIdToKeys`), not a rewrite. + +This leaves one accepted, documented exposure: within a session that spans the upgrade, a live tool result whose provider id collides with a pre-upgrade summarized record can be stub-replaced with that record's stale content - the render-time bare-id fallback in `src/pruner.ts` cannot tell the two apart once both denote the same bare `toolCallId` with no occurrence-keyed siblings. New records (both sides occurrence-keyed) are unaffected; the exposure is confined to the pre-upgrade half of a spanning session and disappears entirely once a session contains no legacy records. + +### Multi-occurrence recovery via `context_tree_query` + +Short refs (`tN`) always resolve 1:1 - one ref, one record - and are the primary, recommended recovery path. A raw provider `toolCallId` passed to `context_tree_query` is looked up against every record that shares it via `getRecordsForId` (`src/indexer.ts`): if the id was reused, the tool returns **every** matching occurrence instead of silently picking one, one block each, chronologically, each labelled `id@timestamp`. This includes occurrences that were content-deduplicated to an earlier record: `getRecordsForId` also scans the dedup-alias map for aliases sharing the queried bare id and returns each aliased occurrence too, labelled with **its own** occurrence timestamp rather than the original's, so a reader can tell the two apart. A record with no `resultTimestamp` caught up in such a set is labelled with the bare id plus an explicit `Occurrence: legacy (no resultTimestamp)` line. + +--- + +## Error Purge + +Failed tool calls often embed large argument bodies in the assistant message — a `write` call with a 30 KB file body, an `edit` call with a multiline diff. The error result is small (e.g. `"Error: file not found"`), but the original `arguments` stay in the assistant turn indefinitely. + +Error purge replaces those arg bodies with compact stubs after the error has cooled down: + +``` +{ "_purged": "" } +``` + +**What triggers a purge:** + +- The matching `ToolResultMessage` has `isError: true`. +- The error occurred at least `purgeErrors.cooldownTurns` assistant turns ago (default 2). The cooldown gives the model 1–2 turns to retry before context is mutated. +- The JSON-stringified argument body is at least `purgeErrors.minArgChars` characters long (default 500). Small args are not worth the substitution. + +**What error purge does NOT touch:** + +- The `ToolResultMessage` content — the error message stays visible so the model can see what went wrong. +- Non-errored `toolCall` argument bodies. +- Argument bodies below `minArgChars`. +- Anything when `purgeErrors.enabled` is `false`. + +**Transform position:** Error purge runs in Phase 2, after stub-replace and before chain range prune. + +``` +[stub-replace] → [error-purge] → [chain-range-prune] → [orphan-sweep] +``` + +**Config keys:** + +| Key | Default | Description | +|---|---|---| +| `purgeErrors.enabled` | `true` | Master toggle | +| `purgeErrors.cooldownTurns` | `2` | Turns to wait after error before purging | +| `purgeErrors.minArgChars` | `500` | Minimum argument body size to purge | + +--- + +## Orphan Sweep + +`pruneMessages` ends with an unconditional structural pass, `sweepOrphanToolResults` (`src/orphan-sweep.ts`): a `toolResult` message whose id is not open is removed, where "open" means: opened by the most recent `assistant` message and not interrupted by any barrier since. It runs after every other phase, over whatever the previous three produced, as a final post-condition rather than a targeted fix for one code path. + +**Why it exists.** pi-ai's auto-repair (`insertSyntheticToolResults`) only fills in a missing `toolResult` for an orphaned `toolCall` - it has no equivalent repair for the opposite shape, an orphaned `toolResult` with no matching `toolCall`. Providers reject that shape outright (Anthropic: `unexpected tool_use_id found in tool_result blocks`; Kimi K3: a tool message needs a resolvable preceding tool_call). An orphaned `toolResult` can appear from any combination of id reuse, an unresolved chain range, or a bug in an upstream phase; the sweep is the last line of defense regardless of cause, and it is intentionally not keyed to any provider's specific error string - the fix is structural ("does this id have an open call"), not reactive to how one provider happens to phrase rejection. + +**Per-turn, not cumulative, open-call tracking.** The sweep does one forward pass over the message array. Each `assistant` message **replaces** the current "open" id set with its own `toolCall` ids; each `toolResult` message either consumes (removes) a matching id from that set or, if its id is not open, is swept. Any message that is neither `assistant` nor `toolResult` is a **barrier** that clears the open set. This tracks the post-`convertToLlm` flush points of pi-ai's `insertSyntheticToolResults` - `convertToLlm` maps `custom`, `branchSummary`, `compactionSummary`, and `bashExecution` to `role: "user"`, and pi-ai flushes synthetic tool results at both `assistant` and `user` boundaries - **plus** a deliberate conservative over-sweep: `bashExecution` with `excludeFromContext: true` and unknown roles are dropped by `convertToLlm` and strictly need not be barriers, but a role allowlist would have to track an open interface for zero observed benefit. Not an exact mirror, and not an allowlist. Trade-off: a foreign message spliced mid-cycle now costs one tool output - the interleaved real result is swept and pi-ai injects its repairable `{isError: true, "No result provided"}` synthetic - converting an unrepairable provider shape (duplicate `tool_use_id`, permanent Anthropic 400) into a visible-but-recoverable tool failure. See `doc/specs/2026-08-18-gh-11-orphan-sweep-barrier.md`. A cumulative seen-set across the whole array would be wrong here: it would let an id used validly by an early turn license a *later* genuine orphan under the same reused id - exactly the collision scenario this exists to catch. Per-turn tracking means only the most recent assistant turn, uninterrupted by a barrier, can vouch for a `toolResult`'s id. + +**Provider-agnostic and reference-preserving.** The sweep has no knowledge of which provider is in play; it operates purely on message shape. When nothing is swept it returns the **same array reference** it was given, so a clean render is a true no-op and the no-op / prompt-cache-prefix invariant of `doc/specs/2026-08-04-pruner-noop-serialization.md` holds - the sweep never forces a cache bust on a session where nothing is actually orphaned. + +Every swept id is reported once (hashed batch, not per-id) as an `orphan-sweep` diagnostic. + +--- + +## Diagnostics + +Prune-time degradations - an unresolvable chain range, a detection/render id mismatch, a swept orphan, a chain with nothing left to backfill - are recorded on an out-of-band channel instead of surfacing in the model's context. `DiagnosticSink` (`src/diagnostics.ts`) writes a `context-prune-diagnostic` session entry (`{ kind, detail }`) for each of four kinds: + +| Kind | Emitted from | Meaning | +|---|---|---| +| `unresolved-range` | `applyChainCompressions` | A persisted chain entry's boundaries didn't resolve to a unique range (or the range was rejected as nested/duplicate) - the entry compressed nothing | +| `range-id-mismatch` | `applyChainCompressions` | The ids actually inside a resolved range don't match the entry's recorded `droppedToolCallIds` - informational only, the range still wins | +| `orphan-sweep` | `pruneMessages` (Phase 4) | One or more `toolResult` messages were removed for having no open matching `toolCall` | +| `backfill-empty` | `chain-compressor.compressEligible` | A zero-coverage chain's deterministic-backfill span yielded zero extractable records AND zero members already in the index AND the chain is not fully-protected - a genuine span mismatch, not a partial-failure retry state. Fully-protected zero-coverage chains skip silently instead (see [Deterministic fallback § Fully-protected exception](#deterministic-fallback-uncovered-chains)). Deduped per chain start timestamp (`chain.startUserTimestamp`). Widget letter `b` | + +**Never in LLM context.** These are session entries only - zero tokens added, zero cache-prefix change, never read back into the message array the model sees. + +**Deduped per `(kind, dedupKey)`**, so a permanently degraded condition (e.g. the same chain entry failing to resolve on every render) writes one entry, not one per render. The dedup key is entry-specific: a chain `blockId` for `range-id-mismatch` and for a genuinely unresolvable `unresolved-range`; `overlap:` for the same `unresolved-range` kind when the entry was instead skipped as nested inside or duplicating another range's start (a distinct dedup-key prefix keeps the two cases greppable under one kind); and a short hash of the sorted swept-id list for `orphan-sweep` (hashing rather than joining the raw list avoids `DiagnosticSink`'s dedup set retaining an ever-longer key per additional orphan over a session's lifetime). A write is only marked seen after the `appendEntry` call succeeds, so a failed write is retried on the next render instead of being silently dropped. + +**Reset on `session_start` and `session_tree`**, matching every other in-memory, non-persisted piece of prune state. + +**Surfaced on the status line.** The footer status widget (`setPruneStatusWidget`, gated by `showPruneStatusLine`) appends a self-hiding ` · diag u/m/o/b` segment (u = `unresolved-range`, m = `range-id-mismatch`, o = `orphan-sweep`, b = `backfill-empty`) built from the sink's live counters via `pruneStatusText`. Each letter is omitted when its counter is zero, and the whole segment is absent when all four are zero. `/pruner status` (the slash command) prints a separate settings/stats block and does not include this segment. + +--- + +## Summary + +| Concern | How Pruning Addresses It | +|---|---| +| **Context grows without bound** | Replaces raw tool outputs (~thousands of tokens) with compact summaries (~hundreds) | +| **Signal lost in noise** | Summaries surface the key decisions and facts; raw data is demoted to on-demand query | +| **Cache performance** | Batch-then-prune (`agent-message`) minimizes cache invalidation; stub-replace keeps the pruned tail deterministic so it stays cacheable | +| **Data availability** | `context_tree_query` recovers full original outputs at any time, including aliased duplicates | +| **Wasted LLM calls** | Pre-flush content-hash dedup catches re-reads at zero cost; `minBatchChars` short-circuits batches too small to be worth summarizing | +| **Plan / state churn** | `protectedTools` keeps allowlisted tool outputs verbatim across turns | +| **Empirical benefit** | SUPO, ReSum, and ACON all show summarization improves or preserves task success while reducing context length 26–54% | + +### Choosing a mode + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ MODE DECISION TREE │ +├─────────────────────────────────────────────────────────────────────┤ +│ │ +│ "I want maximum control" │ +│ └──► on-demand + /pruner now │ +│ │ +│ "I want the best balance of automation, savings, and cache hits" │ +│ └──► agent-message ◄── DEFAULT │ +│ │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +### Recommended reading + +- Anthropic prompt caching docs: +- OpenAI prompt caching docs: +- SUPO: +- ReSum: +- ACON: diff --git a/pi-condense/README.md b/pi-condense/README.md new file mode 100644 index 0000000..f318327 --- /dev/null +++ b/pi-condense/README.md @@ -0,0 +1,205 @@ +

+ pi-condense +

+ +# pi-condense + +[![Buy Me A Coffee](https://img.shields.io/badge/Buy%20Me%20A%20Coffee-donate-yellow?logo=buymeacoffee&logoColor=black)](https://buymeacoffee.com/jjurasszek) + +A [Pi coding-agent](https://github.com/earendil-works/pi) extension that keeps long agent sessions cheap by pruning context - **the context-economy layer of the pi agent toolkit** (see below). + +## The problem + +Every long agent session accumulates raw tool output - file reads, command dumps, search results - that the model already used and will never need again verbatim. Left in context, it degrades reasoning on later turns, inflates the cost of *every* subsequent request, and pushes a smaller/cheaper model past the point where it can still drive. Provider prompt-caching does not fix this on its own: naive trimming actively fights it, because rewriting the prompt on every turn busts the cache you were relying on to keep costs down. + +**pi-condense** replaces finished tool-call batches with short, recoverable summaries, timed around exactly that caching problem. Nothing is deleted - the session file on disk is untouched, and any summarized result can be pulled back verbatim via the `context_tree_query` tool. Net effect: long sessions stay affordable, and a smaller/cheaper driver model stays viable for longer. + +## Why this, not naive trimming + +- **Recoverable, not lossy.** Originals are archived and addressable by a short ref. Summarizing only changes what the model *sees by default* - not what it can retrieve on demand. +- **Batched against the cache, not per turn.** Pruning fires once per finished unit of work (configurable), so the prompt prefix stays stable in between and providers keep serving it from cache. Pruning every turn instead would bust the cache on every single turn - the opposite of the intended savings. + +The full argument, with diagrams, is in **[PRUNING.md](PRUNING.md)**; this README stays at the "why and how to use it" level. + +## Part of the pi agent toolkit + +Four independent extensions for the [pi coding agent](https://github.com/earendil-works/pi), each owning one concern of running agents seriously: + +- [pi-quiver](https://github.com/jjuraszek/pi-quiver) - capabilities (fetch, doc conversion, session tools) +- [pi-cohort](https://github.com/jjuraszek/pi-cohort) - coordination (delegate to focused child agents) +- **pi-condense - context economy (this repo): prune context, keep it recoverable** +- [pi-gauntlet](https://github.com/jjuraszek/pi-gauntlet) - process (the gated brainstorm->ship workflow) + +No code dependency either way. The practical coupling: pi-condense is what keeps a long pi-cohort fan-out or a long pi-gauntlet gated run affordable as it grows, and both can surface pi-condense's live cost via the shared `cost:external` channel (see [External cost channel](#external-cost-channel)). + +## Mental model + +Two-layer memory, not deletion: + +- **Hot:** a compact summary of a finished batch, kept in active context. +- **Cold:** the full original tool results, archived in a session-local index and addressable by a short ref (`t1`, `t2`, ...). + +The model reads the hot summary by default and calls `context_tree_query` when it actually needs the cold original back. See [PRUNING.md](PRUNING.md) for the full before/after diagrams and the prefix-cache mechanics behind the batching schedule. + +```mermaid +flowchart LR + B["finished tool-call batch
(t1, t2, t3 ...)"] --> S[summarize into short stub] + S --> H["hot: model sees the stub"] + B -.archived, session file untouched.-> C["cold: originals by ref"] + H -->|"context_tree_query(t2)"| C + C -.restores original.-> H +``` + +## Quick example + +```bash +pi install npm:pi-condense +``` + +```bash +/pruner on # enable pruning (off by default) +/pruner model openai/gpt-4.1-mini # pick a cheap summarizer +/pruner status # see mode, model, trigger, cumulative stats +``` + +## Architecture + +| Trigger mode | Fires | Cache impact | +|---|---|---| +| `agent-message` (default) | When the agent sends a final text-only reply | ~1 cache rewrite per task batch | +| `on-demand` | Only when you run `/pruner now` | None until you ask | + +Before any summarizer call, a pre-flush pipeline can drop or redirect a batch at zero LLM cost: protected tools/paths are never touched, content-hash duplicates are aliased to the original, batches too small to be worth summarizing are skipped outright, and oversized single results are spilled straight to a sidecar file. Closed tool-call chains older than a rolling window are additionally range-compressed. Full pipeline and each safeguard: [PRUNING.md § Pre-flush Pipeline & Safeguards](PRUNING.md#pre-flush-pipeline--safeguards), [§ Chain Compression](PRUNING.md#chain-compression). + +### External cost channel + +Every summarizer cost update is emitted on the shared `pi.events` channel `cost:external` (`source: "pi-condense"`, cumulative per session, live only - not persisted, not re-seeded on restart). This is a generic channel: pi-condense is a producer, not the owner. [pi-cohort](https://github.com/jjuraszek/pi-cohort) is the canonical consumer, folding it into a single `Σ$` total alongside its own subagent costs. + +## Key concepts + +| Term | Meaning | +|---|---| +| Stub | The short breadcrumb (`[Summarized in pruner summary, ref \`t1\`...]`) that replaces a pruned tool result in context | +| `context_tree_query` | The tool the model calls to recover a stubbed original by ref (`tN`) or `toolCallId`. A reused id returns every matching occurrence, not just one, including any that were content-deduplicated to an earlier record - see [PRUNING.md § Occurrence Identity](PRUNING.md#occurrence-identity) | +| Batch vs chain | A batch is one flush's worth of tool calls; a chain is a longer closed sequence eligible for range compression | +| Prune frontier | The last attempted prune boundary - advances even on a skip, so nothing is reconsidered twice | +| Diagnostics (`diag u/m/o/b`) | A self-hiding status-line segment surfacing prune-time degradations: `u` = unresolved chain range, `m` = detection/render id mismatch (informational, does not change what's dropped), `o` = orphan tool-result sweep, `b` = a zero-coverage chain with nothing left to backfill (genuine span mismatch, see below). Each letter's count is omitted when zero; the whole segment disappears when all four are zero. Backing session entries are `context-prune-diagnostic` - see below | +| Context metrics (`think`/`gap`/`chain`) | Open-cycle thinking tokens, largest-chain share, frontier gap - what the pruner cannot (yet) reclaim, notably in single-chain sessions. See below and [PRUNING.md § Single-chain sessions](PRUNING.md#single-chain-sessions) | +| Prompt-cache interaction | Why batching (not per-turn pruning) is the default - see [PRUNING.md](PRUNING.md#how-prefix-caching-works) | +| `cost:external` | The shared cost-reporting channel pi-condense emits on (see above) | + +### Diagnostic entries (`context-prune-diagnostic`) + +The status-line `diag u/m/o/b` segment above is backed by `context-prune-diagnostic` session entries - session-log-only, never added to what the model sees. Full mechanics: [PRUNING.md § Diagnostics](PRUNING.md#diagnostics). + +### Uncovered chains compress too + +`/pruner compact` and the automatic flush both compress eligible chains **even when no per-batch summary ever covered them** - a trivial batch, an oversized-skip, a fully-deduped batch, or a plain capture miss all used to strand the chain permanently with a `no-summary` skip. These chains now get a deterministic, zero-LLM-cost stub body (call count, tool histogram, span duration, working `t` refs) instead; the raw tool outputs are archived exactly like the covered path and stay recoverable via `context_tree_query`. **Exception:** a zero-coverage chain whose middle calls are *all* protected stays uncompressed (plain `no-summary` skip, no diagnostic) - every output would relocate verbatim into the synthetic body anyway, so compressing saves nothing. Full mechanics: [PRUNING.md § Deterministic fallback (uncovered chains)](PRUNING.md#deterministic-fallback-uncovered-chains). + +**Limitation:** a chain stranded in an otherwise-idle session is not healed by `/pruner now` on an empty queue (the flush returns early before chain detection runs at all) - it heals on the next flush that has any work, or immediately via `/pruner compact`. + +### Context metrics (`context-prune-flush-metrics`) + +Three metrics the pruner cannot yet reclaim - open-cycle thinking tokens, largest-chain share (%), frontier gap tokens - surface in three places, all backed by `computeContextMetrics` (`src/context-metrics.ts`): + +- `/pruner status` prints a `--- context ---` block: `thinking:`, `chain share:`, `frontier gap:`, plus a `rearmed: yes` line while a reload-rearm probe (below) has recoverable work armed. +- The footer status line appends `· think Nk · gap Nk · chain P%` - only when the frontier gap is non-zero, so an idle session's footer is unchanged. +- Each flush attempt (every outcome, including empty/error) writes one `context-prune-flush-metrics` session entry with the pre-flush snapshot - session-log-only, never added to what the model sees, and not reconstructed on reload. + +These are most informative for long single-chain sessions where Phase 3 (chain compression) never gets a closed chain to act on - see [PRUNING.md § Single-chain sessions](PRUNING.md#single-chain-sessions) for the limitation and config guidance, and [PRUNING.md § Reload rearm](PRUNING.md#reload-rearm) for how a reload with recoverable pending work re-arms the automatic flush trigger. + +## When to use / when NOT to use + +**Use it for:** long coding or research sessions where tool output dominates the prompt; setups deliberately running a smaller/cheaper driver model; pi-cohort fan-outs or pi-gauntlet runs where cost compounds across many turns or many children. + +**Don't reach for it when:** the session is short and one-shot - there is nothing accumulated to prune, only latency to add. It also doesn't replace a provider's own native context-compaction feature if you already rely on that, and it doesn't reduce the cost of the *current* turn's tool calls - only of history that has already been produced. + +## Limitations + +- Pruning only applies to batches captured *while enabled*. Enabling mid-session does not retroactively summarize earlier turns. +- Summarizer calls run synchronously inside the turn boundary, so they add latency proportional to the summarizer model's response time. Pick a fast one. +- Content-hash dedup only matches against records already in the indexer (cross-flush); two identical outputs within the *same* flush both go through the summarizer. +- The tree browser (`/pruner tree`) does not inline original tool outputs - use `context_tree_query` for that. + +## Install + +Published to npm as [`pi-condense`](https://www.npmjs.com/package/pi-condense). + +**User scope** (all repos under your pi profile): + +```bash +pi install npm:pi-condense +``` + +**Project scope** (current repo only, committable via `.pi/settings.json`): + +```bash +pi install -l npm:pi-condense +``` + +**Try without installing**: + +```bash +pi -e npm:pi-condense +``` + +**From a local checkout** (for hacking on the extension itself): + +```bash +git clone git@github.com:jjuraszek/pi-condense.git ~/repos/pi-condense +cd ~/path/to/your/repo +pi install -l ~/repos/pi-condense +# or one-shot, no install: +pi -e ~/repos/pi-condense/index.ts +``` + +This copy is maintained inside the `my-pi` bundle from the official `v2.9.1` source snapshot; see [UPSTREAM.md](UPSTREAM.md). The root bundle loads this source directly and does not load the npm implementation in parallel. + +The maintained bundle default is **on**. `/pruner off` disables it and the explicit choice persists across sessions in the same pi agent directory. + +## Configuration - the knobs most people touch + +Settings live under `contextPrune` in `/settings.json` (`$PI_CODING_AGENT_DIR` if set, else `~/.pi/agent`). Each pi preset gets its own settings. Missing fields use the maintained defaults in memory; existing explicit values always win and are not rewritten during extension load. + +| Key | Default | Notes | +|---|---|---| +| `enabled` | `true` | Master switch (or use `/pruner on` / `/pruner off`) | +| `summarizerModel` | `"default"` | Reuse the active model; pin a distinct cheap model if desired | +| `pruneOn` / `batchingMode` | `agent-message` / `turn` | Trigger at completed agent messages and summarize turn batches | +| `autoBudgetThreshold` / `budgetTurnDelta` | `0.735` / `0.04` | Force-flush at 73.5% total context or after 4% same-turn growth | +| `summarizerIdleTimeoutMs` / `summarizerMaxTimeoutMs` | `90000` / `300000` | Per-attempt idle and hard-ceiling timeouts | +| `summarizerMaxRetries` | `2` | Retry transient failures twice; error only after three total attempts | +| `summarizerRetryBaseDelayMs` / `summarizerRetryMaxDelayMs` | `1000` / `8000` | Bounded exponential retry backoff (`1s → 2s` with the default retry count) | +| `protectedTools` / `protectedPaths` | `[]` / `["**/skills/**/*.md"]` | Tool names / path globs that are never pruned | +| `spillThreshold` | `65536` | Chars above which a single oversized result spills straight to a sidecar file | + +Retries apply only to outcomes classified as transient by the summarizer path. Authentication failures, unusable empty/truncated summaries, and user aborts are not retried. When a distinct summarizer model is configured, the final retry slot is reserved for the existing session-model fallback. Setting `summarizerMaxRetries` to `0` disables both automatic retries and that fallback rescue attempt. + +The full settings JSON, every key, the commands table, footer widget states, spilled-output details, and the summarizer-model-by-plan table live in **[doc/configuration.md](doc/configuration.md)**. + +## Relationship to the rest of the platform + +pi-condense is the context-economy layer: it has no code dependency on the other three, but it is what keeps a long [pi-cohort](https://github.com/jjuraszek/pi-cohort) parallel fan-out or a long [pi-gauntlet](https://github.com/jjuraszek/pi-gauntlet) gated run affordable as they grow, and research on summarization-based context management suggests it can also make a smaller driver model hold up better on long tasks (see [PRUNING.md § Research Evidence](PRUNING.md#why-summarization-works-research-evidence) - a cited hypothesis, not a benchmark run in this repo). + +## Roadmap + +No committed roadmap beyond what's already tracked in [CHANGELOG.md](CHANGELOG.md); proposals and in-progress work show up there and in repo issues first. + +## Support + +If this saves you tokens, [buy me a coffee](https://buymeacoffee.com/jjurasszek). + +## Lineage + +Adds pre-flush safeguards, agent-message batching, chain compression, and an npm release flow on top of the original approach from [`championswimmer/pi-context-prune`](https://github.com/championswimmer/pi-context-prune). + +## References + +- Anthropic prompt caching: +- AWS Bedrock prompt caching: +- OpenAI prompt caching: +- Research backing summarization-based context management: see [PRUNING.md § Research Evidence](PRUNING.md#why-summarization-works-research-evidence) + +## License + +MIT - see [LICENSE](LICENSE). diff --git a/pi-condense/UPSTREAM.md b/pi-condense/UPSTREAM.md new file mode 100644 index 0000000..8ef1eaa --- /dev/null +++ b/pi-condense/UPSTREAM.md @@ -0,0 +1,23 @@ +# Upstream source + +- Official repository: +- Imported tag: `v2.9.1` +- Imported commit: `59c95110aacd8131db71e21296765dd80b72fae8` +- Upstream package version: `2.9.1` +- Imported on: 2026-08-31 + +The snapshot was cloned from the official Git repository at the tag above. It was not imported from an npm tarball, npm cache, `node_modules`, or a compiled distribution. + +## Import filtering + +The maintained snapshot keeps the extension source, tests, documentation, changelog, package metadata, image, and MIT license. Upstream repository-local automation and agent state (`.git/`, `.github/`, `.agents/`, `.pi/`, and upstream `AGENTS*.md`) are intentionally excluded. No `node_modules`, coverage, or build artifacts are retained. + +The upstream package's host-only `devDependencies` and agent-core check script are omitted from the maintained package metadata. The same packages remain peer dependencies supplied by Pi; omitting the development copies prevents a local `file:` dependency from installing a second Pi runtime into the root bundle. + +## Local maintenance changes + +- The root bundle depends on this directory through `file:./pi-condense` and loads `pi-condense/index.ts` directly. +- The former bundle wrapper that wrote missing `contextPrune` settings into the user's `settings.json` was removed. Its defaults now live in `pi-condense/src/types.ts`; explicit user settings still take precedence through upstream normalization. +- Transient summarizer failures support configurable bounded retries with exponential backoff. The maintained defaults are two retries, a 1-second base delay, and an 8-second per-wait cap. With a distinct configured summarizer model, the final retry slot is reserved for the existing session-model fallback. + +Upstream remains the reference source. Future synchronization must compare these local changes before porting a tagged upstream release; do not overwrite the maintained defaults or retry behavior wholesale. diff --git a/pi-condense/doc/configuration.md b/pi-condense/doc/configuration.md new file mode 100644 index 0000000..7c20618 --- /dev/null +++ b/pi-condense/doc/configuration.md @@ -0,0 +1,211 @@ +# Configuration reference + +Full `contextPrune` settings, commands, footer widget states, spilled outputs, +and the summarizer-model-by-plan table. See the [README](../README.md) for the +6 knobs most people touch and the conceptual model; see +[PRUNING.md](../PRUNING.md) for the algorithm behind each setting. + +## Full settings JSON + +Settings live under the `contextPrune` key in `/settings.json` (i.e. pi's own settings file). `` is `$PI_CODING_AGENT_DIR` if set, otherwise `~/.pi/agent`. Each pi preset gets its own settings, so you can run different summarizer models per preset. Missing fields use the maintained my-pi defaults in memory; explicit values are preserved and extension load does not rewrite the file. + +```json +{ + "contextPrune": { + "enabled": true, + "showPruneStatusLine": true, + "summarizerModel": "default", + "summarizerThinking": "default", + "summarizerIdleTimeoutMs": 90000, + "summarizerMaxTimeoutMs": 300000, + "summarizerMaxRetries": 2, + "summarizerRetryBaseDelayMs": 1000, + "summarizerRetryMaxDelayMs": 8000, + "pruneOn": "agent-message", + "batchingMode": "turn", + "quietOversizedSkips": false, + "minBatchChars": 1000, + "recoveryGraceTurns": 3, + "protectedTools": [], + "protectedPaths": ["**/skills/**/*.md"], + "dedupByContentHash": true, + "autoBudgetThreshold": 0.735, + "spillThreshold": 65536, + "spillPreviewBytes": 2048, + "budgetTurnDelta": 0.04, + "chainCompression": { + "enabled": true, + "rollingWindow": 3, + "stripFinalAssistantThinking": true, + "fuseRangeSummary": true + } + } +} +``` + +## Every key + +| Key | Values | Default | Notes | +|---|---|---|---| +| `enabled` | `true` / `false` | `true` | Master switch | +| `showPruneStatusLine` | `true` / `false` | `true` | Footer widget + queued-turn notifications | +| `summarizerModel` | `"default"` or `"provider/model-id"` | `"default"` | `default` = your active pi model. See [Choosing a summarizer model](#choosing-a-summarizer-model) | +| `summarizerThinking` | `default`/`off`/`minimal`/`low`/`medium`/`high`/`xhigh` | `default` | Provider-specific reasoning effort knob | +| `pruneOn` | `agent-message` / `on-demand` | `agent-message` | Trigger mode - see README Architecture section | +| `batchingMode` | `turn` / `agent-message` | `turn` | How coarse each summary is (independent of `pruneOn`) | +| `quietOversizedSkips` | `true` / `false` | `false` | Silences `skipped-oversized` / `skipped-trivial` info notifications | +| `minBatchChars` | non-negative integer, `0` disables | `1000` | Pre-flush guard - batches smaller than this skip the LLM entirely | +| `recoveryGraceTurns` | non-negative integer (user-turn-groups), `0` disables | `3` | After a `context_tree_query` recovery, render that tool's output verbatim for this many user-turn-groups before re-stubbing it. Enforced at render time (Phase 1 + chain-compression eligibility), not at capture time. See [PRUNING.md § What Pruning Does](../PRUNING.md#what-pruning-does) | +| `summarizerIdleTimeoutMs` | non-negative integer (ms), `0` disables | `90000` | Abort a summarizer attempt after this much silence. Resets on every stream event and also bounds time-to-first-token. A timeout is transient and can consume retry slots. | +| `summarizerMaxTimeoutMs` | non-negative integer (ms), `0` disables | `300000` | Hard ceiling on one summarizer attempt. Each retry is independently bounded. | +| `summarizerMaxRetries` | integer `0`-`10` | `2` | Transient retries after the initial attempt. `2` means three total attempts. With a distinct summarizer model, the final slot is reserved for session-model fallback. `0` reports the first failure immediately and disables fallback rescue. | +| `summarizerRetryBaseDelayMs` | non-negative integer (ms) | `1000` | Delay before retry 1; subsequent waits double. `0` makes retries immediate. | +| `summarizerRetryMaxDelayMs` | non-negative integer (ms) | `8000` | Per-wait cap for exponential retry backoff. `0` makes retries immediate. | +| `protectedTools` | `string[]` | `[]` | Never-pruned tool names (e.g. `["todowrite","todoread"]`). When a protected tool's chain is range-compressed, its output is preserved verbatim inside the `` block as `` - protected outputs are never lost. | +| `protectedPaths` | `string[]` | `["**/skills/**/*.md"]` | Globs matched against a tool call's `args.path`; matching outputs are never pruned (same semantics as `protectedTools`, including `` relocation in compressed chains). Already-summarized matching reads are repaired on the next turn; chain-compressed ones are not. Set `[]` to disable. | +| `dedupByContentHash` | `true` / `false` | `true` | Re-reads of identical (toolName, content) skip the LLM and alias the original | +| `autoBudgetThreshold` | fraction `0`-`1`, or `null` | `0.735` | Token-budget auto-flush at 73.5% of the window or 300k tokens, whichever comes first, regardless of `pruneOn`. `null` disables it. See [Token-budget auto-flush](#token-budget-auto-flush) | +| `spillThreshold` | positive integer | `65536` | Minimum chars (`resultText.length`) for a single tool result to be spilled eagerly to a sidecar file at capture time rather than waiting for normal summarization. Non-positive / invalid values fall back to the default; to effectively disable spilling, set it above any result you expect. See [Spilled outputs](#spilled-outputs) | +| `spillPreviewBytes` | non-negative integer | `2048` | Head preview (bytes) kept inline in the stub and index record for a spilled result. Full body is on disk. | +| `budgetTurnDelta` | fraction `0`-`1` | `0.04` | Force a flush when one turn grows context by at least 4%, ORed with `autoBudgetThreshold`. The fraction is measured against `min(context window, 300k)`. | +| `chainCompression.enabled` | `true` / `false` | `true` | Master toggle for chain-level range compression | +| `chainCompression.rollingWindow` | positive integer | `3` | Keep this many most-recent closed chains raw; compress older ones | +| `chainCompression.stripFinalAssistantThinking` | `true` / `false` | `true` | Strip thinking blocks from the kept final text-only assistant when compressing | +| `chainCompression.fuseRangeSummary` | `true` / `false` | `true` | Fuse a compressed chain's per-batch summaries into one cohesive LLM summary (one extra summarizer call per multi-batch span); off keeps the per-batch concatenation | +| `purgeErrors.enabled` | `true` / `false` | `true` | Replace failed toolCall argument bodies with compact stubs after cooldown | +| `purgeErrors.cooldownTurns` | positive integer | `2` | Turns to wait after a tool error before purging its argument body | +| `purgeErrors.minArgChars` | non-negative integer | `500` | Only purge arg bodies at least this many characters long | + +See [PRUNING.md § Chain Compression](../PRUNING.md#chain-compression) and [PRUNING.md § Error Purge](../PRUNING.md#error-purge) for the full algorithms. + +The three pre-flush features (`minBatchChars`, `protectedTools`, `dedupByContentHash`) are explained in [PRUNING.md § Pre-flush Pipeline & Safeguards](../PRUNING.md#pre-flush-pipeline--safeguards). They run BEFORE any summarizer LLM call and can each drop a batch outright while still advancing the prune frontier. + +### Token-budget auto-flush + +When `autoBudgetThreshold` is set to a value in `(0, 1]`, the extension checks context usage at the end of every tool-using turn. If `tokens` reaches `min(300_000, threshold * contextWindow)`, ALL pending batches are flushed immediately - regardless of `pruneOn` mode. This is an **additional** trigger layered on top of `pruneOn`, not a replacement. + +- `0.8` means 80% of the context window - it is a **fraction**, not a percentage. `0.8 != 80`. +- **The trigger point is capped at 300,000 tokens** (`MAX_BUDGET_WINDOW` in `src/budget.ts`). Without it, an advertised 1M window makes every setting unreachable: `0.9` would mean 900k tokens, so a session ends before pruning ever fires. The ceiling never binds on a model advertising 300k or less - a 256k window at `0.9` still fires at 230.4k - so only huge-window models change behavior, and they flush earlier. +- `budgetTurnDelta` applies the same ceiling in the other shape: growth is measured against `min(context window, 300k)`, because a 300k ceiling on a single turn's *growth* could never bind. +- The trigger is a no-op when `tokens` is `null` (right after a provider-side compaction); it resumes once usage is known again. +- Editable live via `/pruner settings` (row "Auto-flush at context %", presets Off / 60 / 70 / 80 / 90%). +- Default `null` = off. + +Inspired by DCP's `maxContextLimit` nudging; simplified to a single threshold that forces a flush rather than separate nudge/force levels. + +### Spilled outputs + +Single tool results larger than `spillThreshold` chars are written to `/-blobs/.txt` at capture time and replaced in context with a short stub (tool name, byte size, head preview, file path). `` is the sanitized occurrence key `id@resultTimestamp` (`@` becomes `_`, so `bash_23@1150` -> `bash_23_1150.txt`), not the bare `toolCallId` - keying on the bare id alone would let two occurrences of one reused provider id share a path and the second overwrite the first's blob. Recovery reads the `spillPath` persisted on the record rather than re-deriving it from the id, so a bare-id-named sidecar (no occurrence timestamp) still resolves. See [PRUNING.md § Occurrence Identity](../PRUNING.md#occurrence-identity). The full body is recoverable via the native `read` tool at the embedded path (offset/limit supported) or via `context_tree_query` by id, which falls back to the inline preview if the sidecar is missing. Moving a session `.jsonl` without its `-blobs/` directory loses only the giant-blob recovery path; bodies under `spillThreshold` stay inline in the index entry as usual. + +### Choosing a summarizer model + +The `default` setting reuses whatever model you have active in pi - convenient but wasteful, since summary writing doesn't need a top-tier coding model. Picking the smallest/fastest model on your plan saves both latency and cost. + +If the configured summarizer model suffers a transient outage while your active pi model is healthy, pi-condense automatically falls back to the session model for the duration (with a one-time notice) and probes the configured model back every few minutes - no configuration needed. + +| Plan | Suggested summarizer | +|---|---| +| OpenAI / Codex / Copilot | `openai/gpt-4.1-mini`, `google/gemini-2.5-flash`, `xai/grok-3-fast` | +| OpenRouter | `openrouter/qwen/qwen3-30b-a3b` (cheap MoE) | +| Anthropic direct | `anthropic/claude-haiku-3-5` | +| Google AI direct | `google/gemini-2.5-flash` | + +Set it from the slash command (saves immediately): + +```bash +/pruner model openai/gpt-4.1-mini +/pruner thinking low +# or both in one go: +/pruner model openai/gpt-4.1-mini:low +``` + +### Summarizer timeouts and retries + +Every summarizer attempt is bounded by two independent timers: + +- **Idle timeout** (`summarizerIdleTimeoutMs`, default 90s) resets on every + stream event, including reasoning/`thinking` events. It also bounds + time-to-first-token. +- **Total-duration ceiling** (`summarizerMaxTimeoutMs`, default 300s) is a hard + upper bound for one attempt. + +Timeouts and provider failures are classified as transient. The initial call is +followed by at most `summarizerMaxRetries` retries (default 2), with exponential +backoff starting at `summarizerRetryBaseDelayMs` and capped by +`summarizerRetryMaxDelayMs` (`1s → 2s` under maintained defaults). The UI +reports an error only after the retry budget is exhausted. Each attempt has its +own idle and ceiling timers, so the worst-case duration can span multiple attempt +ceilings; user abort remains immediate, including during backoff. + +Authentication failures and unusable empty/length-truncated summaries do not +consume retries. With a distinct `summarizerModel`, the final retry slot switches +to the active session model and feeds the sticky fallback controller. Set +`summarizerMaxRetries` to `0` to disable retries and fallback rescue. Both timeout +fields can independently be set to `0`. + +## Commands + +| Command | Effect | +|---|---| +| `/pruner` | Interactive picker over all subcommands | +| `/pruner settings` | Settings overlay (toggle / cycle every option) | +| `/pruner on` / `off` | Enable / disable pruning | +| `/pruner status` | Show mode, model, trigger, cumulative stats, and a `--- context ---` block (see below) | +| `/pruner stats` | Detailed cumulative summarizer token/cost stats | +| `/pruner model [id[:thinking]]` | Get / set summarizer model (and optionally thinking level) | +| `/pruner thinking [level]` | Get / set summarizer reasoning effort | +| `/pruner prune-on [mode]` | Get / set trigger mode | +| `/pruner batching [mode]` | Get / set batching granularity (`turn` / `agent-message`) | +| `/pruner protected-tools [names]` | Show or edit the never-pruned tool allowlist (comma- or space-separated; `none` clears) | +| `/pruner protected-paths [globs]` | Show or edit the never-pruned path globs (`none` clears) | +| `/pruner min-batch-chars [n]` | Show or set the pre-flush trivial-batch threshold (`0` disables) | +| `/pruner recovery-grace [n]` | Show or set the post-recovery verbatim grace window, in user-turn-groups (`0` disables) | +| `/pruner dedup [on\|off\|status]` | Toggle pre-flush content-hash dedup | +| `/pruner tree` | Foldable browser of pruned tool calls; `Ctrl-O` opens the full summary in an overlay | +| `/pruner compact` | Retroactively compress every eligible closed chain (bypasses `rollingWindow`) | +| `/pruner now` | Flush pending batches immediately with a multi-row progress widget above the input | +| `/pruner help` | Full help text | + +## Tools surfaced to the LLM + +**`context_tree_query`** - always available when the extension is loaded. Pruned summaries end with short refs like `Summarized tool refs: \`t1\`, \`t2\`. Use \`context_tree_query\` with these refs to retrieve the original full outputs.` The model passes those refs (or full `toolCallId`s) and gets back the original tool result text from the session index. Each per-tool bullet in the summary also carries its own inline `` `tN` `` ref, so recovering a specific tool is a single hop; the footer still lists every ref as a fallback. Content-hash-deduped duplicates resolve to the original's record automatically. + +Short refs (`tN`) always resolve 1:1 and are the primary, recommended path; a reused `toolCallId` returns every matching occurrence instead of silently picking one, including occurrences deduplicated to an earlier record. See [PRUNING.md § Occurrence Identity](../PRUNING.md#occurrence-identity) for multi-occurrence recovery mechanics. + +## Footer status widget + +A footer widget shows the current state, controlled by `showPruneStatusLine`: + +Every rendered state is prefixed with a single leading `|` divider so the segment stays visually isolated in the shared footer regardless of where other extensions' status segments land (load-order independent); there is no trailing divider - the footer's own space-join between segments already provides one. + +- `| prune: OFF` - disabled +- `| prune: ON` - enabled, no flushes yet +- `| prune: ON . 92.0k->14.0k (-85%)` - enabled; live reclaim ratio (estimated tokens before->after, percent reduction). Updates on every `pruneMessages` call. +- `| prune: 3 pending` - batches queued, waiting for the trigger +- `| prune: summarizing...` - flush in progress +- `| prune: ON . 92.0k->14.0k (-85%) . diag u1/o2` - same as above, plus a self-hiding diagnostic segment (`u`/`m`/`o` counters, each hidden at zero). See [PRUNING.md § Diagnostics](../PRUNING.md#diagnostics). +- `| prune: ON . think 3.2k . gap 5.1k . chain 41%` - a context-pressure suffix, shown only when `frontierGapTokens > 0` (nothing to append when the frontier is fully caught up). All three are chars/4 token estimates, same convention as the reclaim ratio above: + - `think` - `openCycleThinkingTokens`: thinking-block tokens trapped in the still-open cycle (after the last text-only assistant reply), not yet eligible for pruning. + - `gap` - `frontierGapTokens`: summarization-eligible tool-result tokens sitting past the persisted prune frontier. + - `chain` - `largestChainSharePct`: the largest single chain's (or the open segment's) share of total branch chars. + See [PRUNING.md § Single-chain sessions](../PRUNING.md#single-chain-sessions) for exact definitions. +- `| prune: recovered pending (reload)` - shown at `agent_end` when a session reload found recoverable unflushed work but no new turn re-queued it. See [PRUNING.md § Reload rearm](../PRUNING.md#reload-rearm) for what "recovered" means and when it actually flushes. + +Setting `showPruneStatusLine: false` hides the widget and silences the queued-turn notice; pruning still runs. + +The status line does not show cost. Full token/cost detail is available via `/pruner stats`. The extension also emits cumulative session cost on the `cost:external` pi.events channel for external aggregators - see [README § External cost channel](../README.md#external-cost-channel). + +### `/pruner status` context block + +When context metrics are available, `/pruner status` appends a `--- context ---` block below the settings summary: + +``` +--- context --- +thinking: 3.2k tokens (open segment) +chain share: 41% +frontier gap: 5.1k tokens +rearmed: yes +``` + +- `thinking` / `chain share` / `frontier gap` are the same `openCycleThinkingTokens` / `largestChainSharePct` / `frontierGapTokens` metrics shown in the footer suffix above, formatted as full labels here instead of a compact suffix. +- `rearmed: yes` appears only while the reload-rearm flag is armed (cleared the moment any flush attempt runs). See [PRUNING.md § Reload rearm](../PRUNING.md#reload-rearm). diff --git a/pi-condense/doc/specs/2026-05-30-protected-chain-empty-summary-budget.md b/pi-condense/doc/specs/2026-05-30-protected-chain-empty-summary-budget.md new file mode 100644 index 0000000..fed8173 --- /dev/null +++ b/pi-condense/doc/specs/2026-05-30-protected-chain-empty-summary-budget.md @@ -0,0 +1,414 @@ +--- +title: Protected-tool chain safety, empty-summary guard, typecheck fix, token-budget auto-flush +date: 2026-05-30 +status: draft +branch: protected-chain-budget +worktree: .worktrees/protected-chain-budget +supersedes: untracked .agents/plans/034-protectedTools-dedup-pending-fixes.md (deleted) +--- + +# Summary + +Four changes shipped together. Three are correctness/hygiene fixes surfaced by the DCP-parity +review; one is an opt-in feature (default-off). + +| # | Item | Type | Default behavior change | +|---|---|---|---| +| 1 | `protectedTools` × chain compression: relocate protected outputs into the compressed-chain body instead of dropping them | correctness | none (only fires when `protectedTools` non-empty) | +| 2 | Empty / truncated summary soft-fail guard | correctness | none (only changes the empty/`length` failure path) | +| 3 | Test fixtures `fuseRangeSummary` typecheck fix | hygiene | none | +| 4 | Token-budget auto-flush (`autoBudgetThreshold`) | feature, opt-in | none (`null` = off) | + +**Regression invariant for the whole release:** with default config +(`protectedTools: []`, `autoBudgetThreshold: null`) every code path below is inert and behavior +is byte-identical to today. Items 1 and 4 only activate on explicit opt-in. Item 2 only changes +behavior when the summarizer returns empty or truncated text (today a latent bug). Item 3 is +test-only. + +Dropped from the original review scope: the `selectEligible` "no-summary blocks next-oldest" +claim (false positive — the loop `continue`s past no-summary chains and processes all remaining +eligible chains; `chain-compressor.ts:88-95`), and the content-hash dedup normalization / +args-blind concerns (downgraded to Minor; tracked separately, not in this release). + +--- + +# Item 1 — protectedTools × chain compression + +## Problem (confirmed against source) + +`protectedTools` is an allowlist of tool names whose outputs must never be pruned +(`types.ts` `ContextPruneConfig.protectedTools`, doc-comment: "outputs must NEVER be pruned or +summarized"). Enforced at capture time: `turn_end` filters them out of the `CapturedBatch` +(`index.ts:710-722`) so they are **never indexed** and stay verbatim in context. + +Chain range-compression violates this: + +1. `detectChains` adds **every** middle `toolCallId` to `ChainRange.middleToolCallIds`, protected + included (`chain-detector.ts:62-71`). +2. `compressEligible` sets `entry.droppedToolCallIds = chain.middleToolCallIds` with no protection + filter (`chain-compressor.ts:118`). The eligibility gate `hasPerBatchSummaryCoveringAny` + passes whenever **any one** sibling tool in the chain was summarized + (`chain-compressor.ts:92`), so a mixed chain (≥1 summarized + ≥1 protected) is eligible. +3. `applyChainCompressions` drops any `toolResult` whose id ∈ `droppedToolCallIds` + (`chain-range-prune.ts:79`). + +Because protected outputs are never indexed, a dropped protected output is **unrecoverable** via +`context_tree_query`. Trigger: `protectedTools` non-empty (e.g. the documented `todowrite`/ +`todoread`) + `chainCompression.enabled` (default on). Default `protectedTools: []` masks it +out-of-box, but the documented opt-in breaks the contract. + +## Why not the obvious fix (skip mixed chains — rejected "Approach C") + +Skipping any chain that contains a protected tool would forfeit a large fraction of compaction for +exactly the users who enable the feature: a chain is `[user] → [tool turns…] → [text]` +(`chain-detector.ts:30`), i.e. usually a whole task, and high-frequency protected tools +(`todowrite`/`todoread`/`edit`) recur throughout most tasks. Most chains would become ineligible. +Rejected. + +Also rejected — "Approach B1" (exclude protected ids from `droppedToolCallIds`, keep the protected +turn in place): a mixed assistant turn that calls both a protected and a non-protected tool is +dropped as a whole (`chain-range-prune.ts:84-87` drops the assistant message if *any* of its +toolCall ids is dropped), which would orphan the surviving protected `toolResult` (no matching +`toolCall`). pi-ai's `insertSyntheticToolResults` repairs orphaned *calls*, not orphaned +*results*, so this risks breaking role alternation. + +## Design (chosen — "Approach B", DCP-faithful: relocate) + +Keep the contiguous range-drop exactly as today (protected ids stay in `droppedToolCallIds`, so the +protected `toolResult` and its assistant turn drop cleanly — no orphans, no alternation risk), but +**relocate** the protected outputs' verbatim text into the synthetic `` body. + +Protected outputs are never indexed, but the `context` event always re-runs `pruneMessages` on the +**raw branch** (session is never mutated), so the protected `toolResult` is always present at render +time. Therefore the verbatim text is pulled live from `messages` during +`applyChainCompressions` — nothing large is persisted in the entry (only the id list). + +### Data model change + +`ChainRange` (detection-time, `types.ts`): add + +```ts +/** Subset of middleToolCallIds whose tool name ∈ protectedTools (detection-time fact). */ +protectedToolCallIds: string[]; +``` + +`ChainCompressionEntry` (persisted, `types.ts`): add + +```ts +/** + * Subset of droppedToolCallIds whose tool was user-protected. Membership is decided + * per call by tool name (every call whose name ∈ protectedTools), not a per-id allowlist — + * so every todowrite call in the chain is captured regardless of its id. Their verbatim + * ToolResultMessage text is relocated into the synthetic body + * at render time (pulled live from the raw branch) instead of being dropped. + * Absent/empty ⇒ no protected outputs in this chain (identical to pre-feature behavior). + */ +protectedToolCallIds?: string[]; +``` + +### Code changes + +1. **`src/chain-detector.ts`** — `detectChains(messages, protectedTools: string[] = [])`: + - Build a `Set` from `protectedTools`. + - While collecting middle ids, also record protected ones. The assistant branch has + `b.name`+`b.id` (`collectToolCallIds` currently drops the name); the toolResult branch has + `msg.toolName`+`msg.toolCallId`. Track a per-chain `protectedIds: Set` alongside + `middleIds` and populate from both branches when the name is in the protected set. + - Emit `protectedToolCallIds: [...protectedIds]` on every `ChainRange` (including the + `emitInterrupted` path). Default param `[]` ⇒ always empty ⇒ current behavior. + +2. **`src/chain-compressor.ts`** — `compressEligible`: + - Copy `protectedToolCallIds` from the `ChainRange` onto the `ChainCompressionEntry` + (`...(chain.protectedToolCallIds?.length ? { protectedToolCallIds: chain.protectedToolCallIds } : {})`). + - `droppedToolCallIds` is unchanged (still `chain.middleToolCallIds`, protected included). + - No new dependency: names were resolved in the detector; the compressor only forwards ids. + +3. **`src/chain-range-prune.ts`** — `applyChainCompressions` + `buildSyntheticChainMessage`: + - Before the rebuild loop, build `protectedTextByBlock: Map` + by scanning `messages` once: for each `toolResult` whose `toolCallId` is in some entry's + `protectedToolCallIds`, push `{ tool: msg.toolName, text: extractToolResultText(msg) }` under + that entry's `blockId`. Preserve message order. + - `buildSyntheticChainMessage` gains a `protectedOutputs: {tool; text}[]` param. Render each as a + verbatim block appended after the (substituted) summary, inside the `` tag: + + ``` + + {resolvedSummary} + + + {verbatim text} + + + ``` + + No `id=` attribute on `` (not recoverable via `context_tree_query` — must not + look queryable). Empty `protectedOutputs` ⇒ no block ⇒ byte-identical to today's render. + - Protected `toolResult`s are still dropped by the existing `droppedToolCallIds` filter — the + relocation does not change the drop set. + +4. **`src/batch-capture.ts`** — extract the inline toolResult text logic (lines 28-38) into an + exported `extractToolResultText(msg): string` and reuse it in both `captureBatch` and + `chain-range-prune` so the relocated text is identical to what capture would have produced. + +5. **`index.ts`** — pass `currentConfig.value.protectedTools` to both `detectChains` call sites + (`:521` flush path via `withClosingMessage`, `:859` `/pruner compact`). + +6. **Session rebuild** — confirm the `session_start` rebuild that replays `CUSTOM_TYPE_CHAIN` + entries into the chain registry preserves the new optional `protectedToolCallIds` (it should, if + it stores the entry as-is; verify in `indexer.ts` `registerChain` / rebuild and add coverage). + +### Edge cases + +- **Chain of only protected tools** (no summarizable sibling): `hasPerBatchSummaryCoveringAny` + returns false (protected tools are never summarized) ⇒ chain ineligible ⇒ never compressed. + Naturally safe; no special-casing. +- **Protected toolResult missing at render** (truncated/edited history): skip that id (best-effort); + the rest of the chain still compresses. +- **Protected tool's call args** (in the assistant `toolCall` block) are dropped with the turn. The + contract protects *outputs*, not call args (matches DCP). Documented, not preserved. The relocated + block is labeled ``; the feature targets self-describing state tools + (`todowrite`/`todoread`) whose output stands alone. Protecting a tool whose output is meaningless + without its args (e.g. `read_file`, where the path lives in the args) is a misuse, not a goal of + this release. +- **Large protected outputs** inflate the synthetic message. Inherent to "keep verbatim" — net + context size is comparable to leaving the raw `toolResult` in place. No size guard in this release. +- **`protectedTools` changed after compression**: the entry's `protectedToolCallIds` is fixed at + compression time, so already-compressed chains keep their relocation regardless of later config + edits. Correct and stable — by design. + +--- + +# Item 2 — empty / truncated summary guard + +## Problem (confirmed) + +`runSummarization` (`summarizer.ts:171-180`) returns `{ summaryText: llmText, usage }` where +`llmText` is the joined text blocks. Two unsafe cases pass through: + +- **Empty text** (e.g. a reasoning model emits only `thinking`, no `text`): `llmText === ""`. In + `flushPending`, `summaryText = "" + formatSummaryToolCallRefs(refs)` (footer only), + `shouldSkipOversized = footer.length > batchRawCharCount` is usually false ⇒ the footer-only + "summary" is injected and the raw batch is pruned behind it (`index.ts:393-394`). +- **Truncated** (`response.stopReason === "length"`): currently unhandled (only `aborted`/`error` + are; `summarizer.ts:159-166`) ⇒ a partial summary is treated as complete. + +## Design + +Treat both as a **soft failure** (return `null`). `flushPending` already maps a `null` per-batch +result to `firstFailureIndex` → `restoreBatches(...)`, keeping the raw batch in context +(`index.ts:431-447`), and reports `summarizer-failed` only if nothing processed. This is the exact +desired behavior — no raw pruned behind a useless summary, retried next flush. + +### Code change (`src/summarizer.ts`) + +Extract a pure decision helper for testability and wire it into `runSummarization` after computing +`llmText`: + +```ts +/** A summary is usable only if it has non-whitespace text and was not truncated. */ +export function isUsableSummary(llmText: string, stopReason: string): boolean { + return llmText.trim().length > 0 && stopReason !== "length"; +} +``` + +In `runSummarization`, after building `llmText`: + +```ts +if (!isUsableSummary(llmText, response.stopReason)) return null; +``` + +Both the per-batch (`summarizeBatch`) and range-fusion (`summarizeRange`) callers already handle +`null`: range fusion falls back to per-batch concatenation (`chain-compressor.ts:104-112`), per-batch +restores the batch. No caller changes needed. + +--- + +# Item 3 — test fixtures typecheck fix + +## Problem (confirmed) + +`fuseRangeSummary` is a required field of `ChainCompressionConfig` (`types.ts`), but two test +fixtures omit it: + +- `src/pruner.test.ts:31` → TS2741 +- `src/pruner.test.ts:299` → TS2345 + +Runtime tests pass (the field is unread by those cases), but the repo does not typecheck. AGENTS.md +mandates a clean `tsc` before commit. + +## Design + +Set `fuseRangeSummary: false` on both literals (concrete, no runtime effect): +- `:31` is the shared `enabledCC` const — these tests pass no `fuseRange` dep and assert no fusion, so `false` keeps behavior. +- `:299` is an inline literal with `enabled: false`, so the value is inert. + +Two-line change. + +**Done-when:** the AGENTS.md typecheck command runs clean across the whole repo **including tests**: + +``` +bun x tsc --noEmit --target es2022 --module nodenext --moduleResolution nodenext --strict \ + --skipLibCheck --allowJs --esModuleInterop --resolveJsonModule --lib es2022 --types node \ + index.ts src/*.ts src/*.test.ts +``` + +--- + +# Item 4 — token-budget auto-flush (opt-in) + +## Motivation + DCP lineage + +DCP nudges/compresses when context approaches `maxContextLimit`. This fork only triggers flushes on +turn/agent-message/context-tag boundaries — a long task can pile up raw tool output and approach the +context limit *between* flush boundaries (e.g. many tool turns before the final text response in +`agent-message` mode). A single opt-in threshold closes that gap: when context usage crosses the +threshold, flush the pending batches immediately regardless of `pruneOn`. + +## Design + +### Config (`src/types.ts`) + +Add to `ContextPruneConfig`: + +```ts +/** + * Token-budget auto-flush trigger. When set to a fraction in (0, 1] (a 0–1 share of the + * context window, NOT a 0–100 percentage; e.g. 0.8 = flush at 80% of the window), a flush of all + * pending batches is forced at the end of any tool-using turn once context usage + * (tokens / contextWindow) reaches the threshold — regardless of `pruneOn`. This is an + * ADDITIONAL trigger layered on top of `pruneOn`, not a replacement; it lets long tasks + * compact before hitting the context limit instead of waiting for the next flush boundary. + * + * null (default) = disabled, exactly preserving pre-feature behavior. Out-of-range values + * (<= 0 or > 1) are treated as disabled. + */ +autoBudgetThreshold: number | null; +``` + +`DEFAULT_CONFIG.autoBudgetThreshold = null`. + +### Pure helper (`src/budget.ts`, new) + +```ts +import type { ContextUsage } from "@mariozechner/pi-coding-agent"; + +/** + * True iff a budget-triggered flush should fire. Computes the ratio ourselves + * (tokens / contextWindow, a 0–1 fraction) rather than ContextUsage.percent (a 0–100 value, + * null when tokens is null). tokens is also null right after a compaction — guarded below. + */ +export function shouldBudgetFlush(usage: ContextUsage | undefined, threshold: number | null): boolean { + if (threshold == null || threshold <= 0 || threshold > 1) return false; + if (!usage || usage.tokens == null || !(usage.contextWindow > 0)) return false; + return usage.tokens / usage.contextWindow >= threshold; +} +``` + +(`ContextUsage` = `{ tokens: number | null; contextWindow: number; percent: number | null }`, +verified in `@mariozechner/pi-coding-agent` types. `getContextUsage(): ContextUsage | undefined` is +on `ExtensionContext`.) + +### Trigger wiring (`index.ts`, `turn_end`) + +At the **tail** of the `turn_end` handler (after the existing `pruneOn` dispatch block, ~`:755`): + +```ts +if ( + currentConfig.value.pruneOn !== "every-turn" && + shouldBudgetFlush(ctx.getContextUsage?.(), currentConfig.value.autoBudgetThreshold) && + !isFlushing +) { + const before = pendingBatches.length; + if (before > 0) { + safeNotify(ctx, `pruner: context budget reached — compacting ${before} pending turn(s)`, "info"); + await flushPending(ctx, { delivery: "session" }); + } +} +``` + +Notes: +- `every-turn` already flushed above ⇒ excluded (no double flush). +- `isFlushing` (existing guard, `index.ts:63`) makes this race-safe; `flushPending` also re-checks + and returns `already-flushing`. +- Budget flush only summarizes **completed** turns' pending batches. The current open chain is not + closed here (the detector drops open chains), so chain compression is untouched mid-task — only + per-batch summarization runs. Safe. +- Guarded on `before > 0` so a hot-but-empty queue (everything already pruned) is a no-op. + +### Settings surface (`src/commands.ts`, `src/config.ts`) + +- `config.ts`: normalize/clamp on load — coerce non-number/out-of-range to `null`. +- `commands.ts`: add a cycling preset to the `/pruner` settings overlay and a + `AUTO_BUDGET_PRESETS` list in `types.ts`: + + ```ts + export const AUTO_BUDGET_PRESETS: { value: string; label: string }[] = [ + { value: "0", label: "Off (default)" }, + { value: "0.6", label: "60%" }, + { value: "0.7", label: "70%" }, + { value: "0.8", label: "80%" }, + { value: "0.9", label: "90%" }, + ]; + ``` + + `"0"` is the disabled sentinel (maps to `null`), consistent with `minBatchChars`'s `"0"` pattern. + +--- + +# Documentation deliverables (required — feature must be well-documented) + +| File | Addition | +|---|---| +| `src/types.ts` | Doc-comments on `autoBudgetThreshold` (units = fraction 0–1, `null`=off, out-of-range=off) and on the two new `protectedToolCallIds` fields (already specified above). | +| `README.md` | New config-table row for `autoBudgetThreshold`. New subsection **"Token-budget auto-flush"**: what it does, default off, fraction semantics (`0.8` = 80% of the window, not `80`), that it's *additional* to `pruneOn`, the `tokens==null`-after-compaction caveat, and the `/pruner` setting. One line in the protectedTools section noting protected outputs are now preserved (relocated into the compressed-chain block) when their chain is compressed. | +| `PRUNING.md` | Short subsection: budget trigger as an additional flush trigger orthogonal to `pruneOn`, why we compute `tokens/contextWindow` as a 0–1 fraction (percent is 0–100 and null when tokens is null; tokens is null right after compaction), and the DCP `maxContextLimit` lineage. Update the chain-compression section to document protected-output relocation (Approach B) and why skip-the-chain (C) was rejected. | +| `AGENTS.md` | Update the `CUSTOM_TYPE_CHAIN` row to note the entry now carries optional `protectedToolCallIds`. | + +--- + +# Testing strategy + +All pure-unit where possible; existing 108 tests must stay green and `tsc` must be clean. + +| Item | Test | Key cases | +|---|---|---| +| 1 | `chain-detector` | `protectedTools` populates `ChainRange.protectedToolCallIds` for both assistant-call and toolResult branches; empty/omitted ⇒ `[]`; interrupted-chain path also populated. | +| 1 | `chain-range-prune` | Mixed chain: non-protected results dropped + summarized, protected result dropped from position **and** rendered verbatim inside `` under ``; pure chain (no protected) renders byte-identical to pre-change; idempotent across two passes; protected id missing from messages ⇒ skipped, rest compresses; relocated `` is NOT registered in the tool-call index ⇒ not recoverable via `context_tree_query` and carries no queryable id. | +| 1 | `chain-compressor` | `protectedToolCallIds` copied from range onto entry; all-protected chain stays ineligible (no per-batch summary). | +| 1 | session rebuild | `CUSTOM_TYPE_CHAIN` replay preserves `protectedToolCallIds`. | +| 2 | `summarizer` (new file) | `isUsableSummary`: empty/whitespace ⇒ false; `stopReason==="length"` ⇒ false; normal ⇒ true. (summarizer.ts currently has zero tests.) | +| 3 | n/a | `tsc` clean across `index.ts src/*.ts src/*.test.ts`. | +| 4 | `budget` (new file) | `shouldBudgetFlush`: `null`/`0`/`>1` threshold ⇒ false; `usage` undefined ⇒ false; `tokens==null` ⇒ false; `contextWindow<=0` ⇒ false; exactly-at ⇒ true; over ⇒ true; under ⇒ false. | + +**Done-when:** new + existing tests green (`bun test`); `tsc` clean; manual smoke per AGENTS.md +(`pi -e ./index.ts …` against an isolated `$PI_CODING_AGENT_DIR`) confirming (a) a protected-tool +output survives a chain compression inside the synthetic block, and (b) setting +`autoBudgetThreshold` forces an early flush. + +--- + +# Implementation order + +The four items are **mutually orthogonal** — no shared runtime path and no shared mutable test +state. Specifically, Item 4's budget trigger flushes pending per-batch summaries on `turn_end` and +**never invokes chain compression** (chains only close on `message_end`), so Item 1's protected-output +relocation does not affect Item 4's behavior or its tests. There is therefore no hard ordering +requirement. + +Recommended commit/PR sequence for **review hygiene only** (not a dependency): #3 (typecheck — makes +the repo green immediately) → #2 (empty-summary guard) → #1 (protected-tool relocation) → #4 +(token-budget). Each can land and be tested in isolation. + +--- + +# Out of scope + +- Content-hash dedup normalization / args-blind keying (Minor; separate follow-up). +- `selectEligible` change (withdrawn false positive). +- DCP parity items deliberately deferred (not regressions — features we don't yet have): + `turnProtection`, `protectedFilePatterns`, `protectTags` (DCP's `` content tags), + `/pruner decompress|recompress`, `focus` param on the compress tool, message-mode compression. +- DCP `protectUserMessages`: N/A in this harness — this extension never prunes user messages (only + tool results and assistant turns), so user messages are already always preserved. +- Size guard on relocated protected outputs (revisit only if large protected outputs bloat context + in practice). diff --git a/pi-condense/doc/specs/2026-05-31-remove-prune-on-modes.md b/pi-condense/doc/specs/2026-05-31-remove-prune-on-modes.md new file mode 100644 index 0000000..057aedb --- /dev/null +++ b/pi-condense/doc/specs/2026-05-31-remove-prune-on-modes.md @@ -0,0 +1,147 @@ +# Remove underdeveloped `pruneOn` modes (→ v1.0.0) + +- **Date:** 2026-05-31 +- **Branch / worktree:** `remove-prune-on-modes` (`.worktrees/remove-prune-on-modes`) +- **Status:** spec — awaiting review +- **Release target:** `v1.0.0` (major) + +## Summary + +Drop three `pruneOn` trigger modes — `every-turn`, `on-context-tag`, `agentic-auto` — and every artifact that exists only to serve them. Keep the two load-bearing modes: `agent-message` (default) and `on-demand`. Delete the dead `remindUnprunedCount` config field. Cut a `v1.0.0` release marking the stable, two-mode core after removing experimental/underdeveloped surface. + +The removed surface is either experimental-but-unwired (`agentic-auto`'s `context_prune` tool was scaffolded, not productionized), coupled to an external extension we don't control (`on-context-tag` soft-depends on `ttttmr/pi-context`), or a cache-hostile debugging aid (`every-turn`). + +## Motivation & recorded assessment + +**Local setup impact: none.** Verified across all three agent profiles: + +| Profile | `contextPrune.pruneOn` | Affected? | +|---|---|---| +| `~/.pi/agent` | (no `contextPrune` key) | no | +| `~/.pi/agent.anthropic` | `agent-message` | no | +| `~/.pi/agent.bedrock` | `agent-message` | no | + +Both `.anthropic` and `.bedrock` also carry `remindUnprunedCount: true`, which is already a no-op for them (only ever honored in `agentic-auto`). + +**Graceful fallback already exists.** `isPruneOn()` in `src/config.ts` validates the persisted `pruneOn` against `PRUNE_ON_MODES`; an unknown/removed value falls back to `DEFAULT_CONFIG.pruneOn` (`agent-message`). So even a config pinned to a removed mode degrades silently rather than erroring. + +**pi-context coupling is soft, and overlaps this extension.** There is no package/peer dependency on `ttttmr/pi-context` (`package.json` has zero deps on it). The only coupling is runtime: the `tool_execution_end` handler listens for the `context_tag` / `context_checkpoint` tool names. If pi-context isn't installed, `on-context-tag` silently never fires. Separately, pi-context's own `context_compact` tool performs LLM-driven compaction of a path into a handoff summary — partial functional overlap with this extension's rolling tool-batch summarization (different trigger models: checkpoint/time-travel vs. automatic). Removing `on-context-tag` drops a redundant trigger wired to an uncontrolled extension. + +## Scope + +| Mode | Disposition | Rationale | +|---|---|---| +| `agent-message` | **keep (default)** | Flushes on the agent's next text response; prefix-cache friendly. | +| `on-demand` | **keep** | Manual flush via `/pruner now`. | +| `every-turn` | **remove** | Debugging-only; worst prompt-cache churn. | +| `on-context-tag` | **remove** | Soft-depends on external `pi-context`; redundant with its `context_compact`. | +| `agentic-auto` | **remove** | Model-driven `context_prune` tool was scaffolded, never productionized. | + +End state: `export type PruneOn = "on-demand" | "agent-message";` + +## Removal inventory (the contract) + +### Files deleted + +| File | Why it's now dead | +|---|---| +| `src/reminder.ts` | `annotateWithUnprunedCount` / `countUnprunedToolCalls` feed the ``, only emitted in `agentic-auto`. | +| `src/context-prune-tool.ts` | Registers the `context_prune` tool, only activated in `agentic-auto`. | +| `src/progress-text.ts` | `pruneProgressText` is imported only by `context-prune-tool.ts`. (`formatCharProgress` lives in `src/stats.ts` and stays — used by the `/pruner now` widget.) | + +### `index.ts` + +- Remove imports: `annotateWithUnprunedCount, countUnprunedToolCalls` (`./src/reminder.js`); `registerContextPruneTool` (`./src/context-prune-tool.js`); `CONTEXT_PRUNE_TOOL_NAME`, `AGENTIC_AUTO_SYSTEM_PROMPT` (`./src/types.js`). +- Fix initial `currentConfig` value: `{ ...DEFAULT_CONFIG, pruneOn: "every-turn" }` → `{ ...DEFAULT_CONFIG }` (the `every-turn` seed is a pre-`session_start` placeholder and references a removed mode). +- Delete the `syncToolActivation` helper and all calls to it (also its `session_start` call). +- Delete the `tool_execution_end` handler in full (`on-context-tag`). +- Delete the `before_agent_start` handler in full (`agentic-auto` system prompt). +- Delete the `registerContextPruneTool(...)` registration call. +- In the `context` handler, delete the `agentic-auto` `` block (the `countUnprunedToolCalls` / `annotateWithUnprunedCount` path). +- In `turn_end`: delete the `if (pruneOn === "every-turn") { flush }` branch; drop the `pruneOn !== "every-turn"` clause from the budget-flush guard (now unconditional); collapse the trigger-label switch to the two remaining modes: + ```ts + const trigger = currentConfig.value.pruneOn === "agent-message" + ? "agent's next text response" + : "/pruner now"; + ``` +- Drop `CONTEXT_PRUNE_TOOL_NAME` from the two capture-exclusion sets (the `turn_end` protected-tool `Set` and the `capturePendingBatches` exclude list); both reduce to `currentConfig.value.protectedTools`. +- Drop the `syncToolActivation` argument from the `registerCommands(...)` call. + +### `types.ts` + +- `PruneOn`: narrow to `"on-demand" | "agent-message"`; update its doc comment. +- `PRUNE_ON_MODES`: keep only `agent-message` (listed first, the default) and `on-demand`. +- Delete `CONTEXT_PRUNE_TOOL_NAME` and `AGENTIC_AUTO_SYSTEM_PROMPT` consts. (`PROGRESS_WIDGET_ID` stays.) +- Delete `remindUnprunedCount` from the `ContextPruneConfig` interface and from `DEFAULT_CONFIG`. +- Update the `protectedTools` doc comment that references the `agentic-auto` ``. + +### `config.ts` + +- Delete the `remindUnprunedCount` normalization block in `normalize()`. +- No other change: `isPruneOn` derives from `PRUNE_ON_MODES`, so trimming that array narrows validation automatically. +- **No migration code added** (per decision below). + +### `commands.ts` + +- `PRUNE_MODE_GUIDANCE`: remove the `every-turn` / `on-context-tag` / `agentic-auto` entries. +- Delete `remindUnprunedCountDescription` and its uses. +- Settings overlay: remove the `remindUnprunedCount` `SettingItem`, its `onChange` branch, and the cross-update of its description in the `pruneOn` `onChange` branch. +- Remove the `syncToolActivation` parameter from `registerCommands` and every call site inside it. +- `/pruner status`: drop the `remind: … (agentic-auto only)` line. +- `HELP_TEXT`: remove the `prune-on every-turn` / `on-context-tag` / `agentic-auto` lines, the `remindUnprunedCount` note, the `protectedTools` `` sentence, and the removed-mode guidance bullets. In the `Related:` block, drop the `pi-context` link line (orphaned by removing `on-context-tag`); keep the `Related:` header and the Anthropic prompt-caching bullet. +- Update the comment that parses a selector label (example currently `"every-turn — Every turn"`) to use a surviving mode. + +## Config & migration behavior + +- **Persisted removed modes** (`pruneOn: "every-turn" | "on-context-tag" | "agentic-auto"`): silently fall back to `agent-message` via `isPruneOn()`. No crash, no warning. +- **Dead `remindUnprunedCount` key:** no strip/migration code is added. Because `normalize()` does `{ ...DEFAULT_CONFIG, ...existing }`, a stale `remindUnprunedCount` in an existing file is carried through into the runtime config object and re-persisted on the next settings write — until the key is physically removed from the file. It is therefore **hand-cleaned from the two settings files after release** (see Release plan). Once removed from the file it cannot reappear: nothing writes it back (the field is gone from the interface, `DEFAULT_CONFIG`, and the settings UI). +- **Rationale for no migration code:** the user explicitly chose hand-clean-after-release over adding a one-line strip. Keeps the change pure-deletion; the release script rewrites the version pin in those same files, so tidying the dead key afterward avoids racing it. + +## Documentation changes + +| Doc | Edits | +|---|---| +| `README.md` | Mode table: drop the three removed rows, keep `agent-message` + `on-demand`. Remove `remindUnprunedCount` from the example config block and the settings table. Update the `autoBudgetThreshold` note that references `every-turn`. Remove the `context_prune` tool description. Remove the pi-context "Related extensions" link tied to `on-context-tag` (keep unrelated pi-cache-graph tuning references). | +| `PRUNING.md` | **Reframe** the "per-turn pruning trap" ASCII section as *naive per-turn pruning* (a hypothetical bad approach that motivates the batched `agent-message` default), not a selectable mode. Remove the three modes from the comparison table and the trigger decision-tree. Fix the `protectedTools` `` reference, the `remindUnprunedCount` bullet, the `autoBudgetThreshold`/`every-turn` note, the "scaffolded but not wired" `agentic-auto` compress-tool note, the cache-performance mode mentions, and the pi-context link. | +| `AGENTS.md` | Project-layout block: drop `context-prune-tool` from the `(other src/*.ts)` comment. | +| `CHANGELOG.md` | New top entry for `v1.0.0` (terse bullets, newest-first, dated), recording the mode/field removals and the 1.0 framing. | +| `src/batch-capture.ts` | Update the comment (~line 128) that references `agentic-auto`; the trim-to-ready-tool-calls behavior stays (valid for any flush). | + +## Release plan (`v1.0.0`) + +Final phase, in order: + +1. **Typecheck** — the AGENTS.md one-liner (`bun x tsc --noEmit …`). Must pass. +2. **Pre-release smoke (release gate)** — end-to-end dummy session against an isolated `$PI_CODING_AGENT_DIR`: `pi -e ./index.ts --no-extensions -p "…"`. Confirm the flush path still writes the expected `context-prune-*` session entries and that startup logs no error about missing modes/symbols. Inspect with: + ```bash + jq -r 'select(.type=="custom" or .type=="custom_message") | .customType' session.jsonl | sort | uniq -c + ``` +3. **CHANGELOG entry + commit** on the worktree branch. +4. **Release** via the `release` skill (`release.sh`): bump `package.json` to `1.0.0`, create + push tag `v1.0.0`, auto-rewrite the `git:…@vX.Y.Z` pins in `~/.pi/agent*/settings.json`. +5. **Clean config** — hand-remove the dead `remindUnprunedCount` key from `~/.pi/agent.anthropic/settings.json` and `~/.pi/agent.bedrock/settings.json`. +6. **Final sanity** — one more end-to-end dummy session, now against the real pinned `v1.0.0` + cleaned config, confirming `context-prune-*` entries are written and the session is error-free. + +## Testing / verification approach + +- **Baseline (captured now):** `bun test src/` → 130 pass, 0 fail. No test references any removed symbol, mode, or deleted file, so the suite stays green by construction. +- **Typecheck** gates the release (step 1 above); catches any missed import or symbol reference. +- **End-to-end dummy session** is the integration gate (steps 2 and 6): pure-deletion changes that typecheck can still break runtime wiring (e.g. a removed handler another path depended on), so a live flush is exercised before and after release. +- No new unit tests: this is removal of code paths that have no dedicated tests; the surviving `agent-message` / `on-demand` paths are already covered by `pruner.test.ts` and friends. + +## Edge cases & risks + +- **Stale `remindUnprunedCount` re-persisted before hand-clean.** Expected and benign — nothing reads it. Resolved permanently by step 5. +- **A user (not in scope here) on a removed mode.** Falls back to `agent-message`; acceptable for a 1.0 that drops the modes. +- **`PRUNE_ON_MODES` ordering.** Cosmetic (drives only the settings selector + a label-parse). Spec lists `agent-message` first as the default; the label-parse splits on `—` and is order-independent. +- **Broken release tag if step 2 is skipped.** Mitigated by making the pre-release smoke a hard gate before `release.sh`. + +## Out of scope + +- Touching the surviving `agent-message` / `on-demand` flush logic, batching, dedup, chain compression, or thinking-strip. +- Any change to `package.json` beyond the version bump. +- Adding migration/normalization code for the dead key (explicitly declined). +- Changing pi-context or interacting with it in any way. + +## Open questions + +- **Changelog wording for the 1.0 framing.** The major bump was justified as "DCP parity + remove underdeveloped parts." This spec records the substance neutrally ("stable two-mode core after removing experimental surface"). If the literal "DCP parity" phrasing should appear verbatim in the `CHANGELOG`/release notes, say so and name what "DCP" refers to; otherwise the neutral phrasing ships. diff --git a/pi-condense/doc/specs/2026-06-02-oversized-output-spill.md b/pi-condense/doc/specs/2026-06-02-oversized-output-spill.md new file mode 100644 index 0000000..d1b6bc2 --- /dev/null +++ b/pi-condense/doc/specs/2026-06-02-oversized-output-spill.md @@ -0,0 +1,324 @@ +# Oversized tool-result spill + budget-delta flush + +Date: 2026-06-02 +Branch/worktree: `oversized-output-spill` +Status: spec (pre-implementation) + +## Problem + +A single large tool result can dominate the context window before any +existing pruning mechanism reacts. Observed: a session at ~70% of a 1M-token +window where one `fetch` result was ~1.08 MB (~270K tokens, ~27% of the +window) on its own. + +Why nothing "kicked in": + +1. **No single-result cap.** Every captured result — regardless of size — + waits for normal batch close → async summarization → rolling-window aging + before its raw body leaves context. A 1 MB output sits at full size for + several turns. +2. **Budget trigger is a single aggregate gate.** `shouldBudgetFlush` + (`src/budget.ts`) fires only when `usage.tokens / usage.contextWindow >= + autoBudgetThreshold`, evaluated once per `turn_end` + (`index.ts:714-730`). The user's `autoBudgetThreshold` was `0.8` + (`DEFAULT_CONFIG` ships `null` = off); at 70% it had not yet tripped, and a + single turn can jump from below to over-limit between two boundaries. + +This spec adds two layers: + +- **A — eager spill:** synchronously offload any single oversized result to a + file and index it immediately, so it never reaches a request at full size. +- **B — budget-delta flush:** force a flush when one turn adds a large + fraction of the window, independent of the absolute aggregate threshold. + +Both reuse existing machinery (`Indexer.addBatch` → `isSummarized` → +`pruneMessages` stub-replace; `shouldBudgetFlush` + `turn_end` flush). + +## Ground truth (package namespace) + +This extension currently pins and imports **`@mariozechner/*`** +(`pi-coding-agent`, `pi-ai`, `pi-tui`, `pi-agent-core`) — that is what is +installed and what every source file imports, so API verification for this +spec resolves under `node_modules/@mariozechner/pi-coding-agent/dist`. + +**Caveat:** the entire `@mariozechner/*` family is **deprecated**; each +package's npm metadata says "please use `@earendil-works/` instead going +forward" (the global pi harness already runs `@earendil-works/pi-coding-agent`). +`@earendil-works/*` is the renamed continuation — same symbols, same paths — +so the table below is expected to hold verbatim after migration. Migrating +this repo's peer deps from `@mariozechner` to `@earendil-works` is a +repo-wide dependency rename, **out of scope for this spec** and tracked +separately; this feature builds against whatever the peer deps resolve to at +implementation time. + +Symbols this spec depends on, verified present (in the installed +`@mariozechner` types): + +| symbol | location | note | +|---|---|---| +| `getSessionDir()`, `getSessionId()`, `getSessionFile()` | `core/session-manager.d.ts:189-191` (on `ReadonlySessionManager`, l.136) | spill dir + id | +| `getContextUsage(): ContextUsage \| undefined` | `core/extensions/types.d.ts:231` | budget triggers | +| `ContextUsage.tokens: number \| null`, `contextWindow: number`, `percent: number \| null` | `core/extensions/types.d.ts:192-198` | **`tokens` is null right after compaction** — must guard | +| `truncateHead(content, options?)`, `DEFAULT_MAX_BYTES`, `DEFAULT_MAX_LINES` | `core/tools/truncate.d.ts` (re-exported from index) | options object, not positional | + +## Non-goals + +- Changing the `fetch` / `bash` tools (harness-owned, separate repo). A is + tool-agnostic and covers them all. +- Orphan-blob garbage collection / startup sweep. Out of scope for v1; blob + cleanup rides on session deletion only (see Cleanup). +- Replacing the LLM summarizer. Eager-spilled results are NOT summarized by an + LLM; the stub is mechanical. + +## A — Eager single-result spill + +### Trigger + +At tool-result capture (live `turn_end` path in `src/batch-capture.ts`, and +the reconstruction path `captureUnindexedBatchesFromSession`), evaluate each +`CapturedToolCall` independently. A call is **oversized** when +`resultText.length >= spillThreshold` (chars — matches `resultText.length`, +not bytes). + +Ordering relative to existing pre-flush passes (precedence is strict): + +1. **Protected-tools filter first** — a `protectedTools` result is never + spilled. +2. **Dedup check second** — compute the content hash on the full in-memory + body. If it matches an existing original, alias as today (`registerDuplicate`) + and **do not** write a file. A duplicate of an already-spilled original + aliases to that original's record (and thus its `spillPath`); the byte cost + is paid once. +3. **Spill last** — only a non-duplicate oversized result is written to a + sidecar file and indexed. + +The spill + `addBatch` happens before the existing `flushPending` call in the +`turn_end` handler. No extra `context` event is forced; the stub takes effect +on the next natural `context` build (same as today's summarized records). + +### Storage (hybrid) + +- Bodies `< spillThreshold` stay **inline** in the `context-prune-index` + entry exactly as today (`IndexEntryData.toolCalls[].resultText`). Portable: + the JSONL remains self-contained for all normal content. +- Bodies `>= spillThreshold` are written to a **sidecar file**; the index + entry stores a reference + head preview instead of the full body. + +File location: + +``` +/-blobs/.txt +``` + +- `sessionDir = ctx.sessionManager.getSessionDir()`, `sessionId = + ctx.sessionManager.getSessionId()` (confirmed on `ReadonlySessionManager`, + `session-manager.d.ts`). Persistent root alongside the session JSONL — + survives reboot/resume, not subject to `os.tmpdir()` reapers. +- One file per tool call, full verbatim `resultText`, UTF-8. +- `toolCallId` is filename-safe in practice (provider ids like + `toolu_...`); sanitize defensively (`[^A-Za-z0-9_-]` → `_`) to avoid path + traversal / separators. + +### Record / persistence changes + +`ToolCallRecord` (`src/types.ts`) gains optional fields: + +```ts +spillPath?: string; // absolute path to the sidecar blob +spillBytes?: number; // full byte length of the spilled body +resultPreview?: string; // head preview kept inline when spilled +contentHash?: string; // dedup hash of the FULL body, persisted +``` + +**`resultText` invariant.** Persisting the full body in the index entry would +defeat the spill (MB back in the JSONL). So for a spilled record the persisted +entry carries `resultPreview` (not the full body), `spillPath`, `spillBytes`, +and `contentHash`. The full `resultText` exists only transiently in memory at +capture time — long enough to compute `contentHash` and write the file — and +is not serialized. + +**Dedup hash persistence.** Today `addBatch` computes the content hash from +`resultText` and `reconstructFromSession` recomputes it on reload. A spilled +record has no full `resultText` after reload, so the hash is computed once at +spill time and **persisted** as `contentHash`; reconstruct reads it back +instead of recomputing. Non-spilled records keep the existing +recompute-from-`resultText` path unchanged. + +**No summarizer path.** Eager-spilled records bypass +`serializeBatchForSummarizer` entirely (they are never sent to the LLM +summarizer), so there is no double-truncation between preview and summarizer +input. + +`IndexEntryData.toolCalls[]` already serializes `ToolCallRecord`, so adding the +optional fields persists them with no new custom entry type. +`Indexer.reconstructFromSession` replays them unchanged; the preview + hash +travel inline, the full body stays on disk. + +### What the model sees (stub) + +`pruneMessages` already stub-replaces any `isSummarized` tool result. For a +spilled record the stub text is **mechanical** (no LLM summary) and contains: + +- tool name + a compact rendering of args, +- size (`spillBytes` and line count), +- the head preview (`resultText`), +- the absolute `spillPath` and an explicit hint: + `read this file (offset/limit supported) to access the full output`, +- the existing short-ref line so `context_tree_query` still works. + +This gives file-handle ergonomics: the model slices the body on demand with +the native `read` tool; no LLM round-trip happened on capture. + +### Stub branching (`src/pruner.ts`) + +`pruneMessages` Phase 1 already replaces a summarized tool result with a single +hardcoded `[Summarized … ref tN]` stub. It must branch on the resolved record: + +- `getRecord(toolCallId)` has `spillPath` set → emit the **mechanical spill + stub**: tool name, compact args, `spillBytes` + line count, the + `resultPreview`, the absolute `spillPath`, a `read this file (offset/limit + supported)` hint, and the existing short-ref line. +- otherwise → emit the **existing summary-ref stub** unchanged. + +Both branches return a stub message (never delete the result) so pi-ai's +orthan-tool-result repair is not triggered. + +### Indexing (synchronous, no summarizer) + +Eager spill must make the result `isSummarized` immediately so `pruneMessages` +stubs it on the very next `context` build, without waiting for `flushPending` +or an LLM call. Reuse `Indexer.addBatch` with a single-call batch carrying the +spilled record(s); it issues short refs, persists the index entry via +`appendEntry`, and updates the in-memory map. No `context-prune-summary` +message is emitted for eager-spilled results (the stub is self-describing). + +### Recovery + +`context_tree_query` (`src/query-tool.ts`): when the resolved record has +`spillPath`, read the file and apply the **existing** +`truncateHead(content, { maxBytes: DEFAULT_MAX_BYTES, maxLines: +DEFAULT_MAX_LINES })` call already used in this file to the file contents +instead of to `resultText` — no new constants or exports. Falls back to +`resultPreview` if the file is missing. The primary recovery path the model +should use is the native `read` tool on `spillPath` (full slicing); +`context_tree_query` remains the head-truncated fallback. + +### Failure handling + +- **Write-then-mutate (atomic).** Write the sidecar file first; mutate the + record fields (`spillPath`/`spillBytes`/`resultPreview`/`contentHash`) only + after `writeFile` resolves. A throw leaves the `CapturedToolCall` untouched + and the result falls through to the normal flush pipeline. Never drop a + result. Log at debug. +- **Filename collision.** `toolCallId` is unique per call and the sanitized + alphabet (`[^A-Za-z0-9_-]` → `_`) collides only on pathological ids; before + writing, if the target path already exists for a different record, fall back + to inline rather than overwrite. +- Missing blob at query time → return `resultPreview` + a note that the full + body is unavailable. + +## B — Budget-delta flush + +Add a second, delta-based flush trigger in the `turn_end` handler, evaluated +alongside the existing absolute `shouldBudgetFlush`. + +- New config `budgetTurnDelta: number | null` (fraction of window, default + `null` = off; suggested operational value `0.15`). +- Track the previous turn's usage fraction (`tokens / contextWindow`) as a + module-level variable in `index.ts`, set to `null` on `session_start`. + After a restart the first turn cannot fire the delta trigger (no previous + fraction); this gap is accepted because the absolute `autoBudgetThreshold` + still guards the post-restart aggregate. On a turn where `usage.tokens` is + null (e.g. immediately after compaction), leave `previousFraction` + unchanged rather than overwriting it, so the next real reading is compared + against the last known fraction. +- If `(currentFraction - previousFraction) >= budgetTurnDelta`, call + `flushPending(ctx, { delivery: "session" })` — same flush the aggregate + trigger uses. +- Either trigger (absolute OR delta) firing causes a flush; they are ORed. + +Rationale: catches a turn that adds a large aggregate (many medium outputs, or +a spike that A's per-result threshold didn't individually catch) without +waiting to cross the absolute `autoBudgetThreshold`. + +Helper lives in `src/budget.ts` next to `shouldBudgetFlush`: + +```ts +export function shouldDeltaFlush( + usage: ContextUsage | undefined, + previousFraction: number | null, + delta: number | null, +): boolean +``` + +Pure; mirrors `shouldBudgetFlush`'s guards exactly — returns `false` when +`!usage || usage.tokens == null || !(usage.contextWindow > 0) || delta == null +|| previousFraction == null`. Uses the same 0–1 `tokens / contextWindow` +fraction (not `ContextUsage.percent`, which is 0–100 and null when `tokens` +is). Because `tokens` is null right after a compaction, `previousFraction` is +left unchanged on a null-token turn (no spurious delta when usage +"reappears"). + +## Config (`DEFAULT_CONFIG`, `src/types.ts`) + +| field | type | default | meaning | +|---|---|---|---| +| `spillThreshold` | `number` | `65536` | min chars (`resultText.length`) for a single result to spill | +| `spillPreviewBytes` | `number` | `2048` | head-preview size in **bytes** kept inline as `resultPreview` | +| `budgetTurnDelta` | `number \| null` | `null` | per-turn usage-fraction increase that forces a flush | + +`spillThreshold` is intentionally high (64 KB): only genuinely huge outputs +spill; medium outputs (tens of KB) keep flowing through normal +summarization/dedup so the model sees them without a `read` round-trip. + +## Cleanup + +- The blob dir is a sibling of the session file; deleting the session dir + removes its blobs. No separate GC in v1. +- No orphan sweep on startup (explicit non-goal). Accepted: blobs for deleted + sessions whose dir was partially removed can linger; revisit if it matters. + +## Touched files + +| file | change | +|---|---| +| `src/types.ts` | `ToolCallRecord.spillPath?/spillBytes?`; `DEFAULT_CONFIG` + `ContextPruneConfig` gain `spillThreshold`, `spillPreviewBytes`, `budgetTurnDelta` | +| `src/spill.ts` (new) | pure-ish helpers: `blobDirFor`, `blobPathFor`, `headPreview`, `writeSpill`, `readSpill`; isolates fs + path sanitize | +| `src/batch-capture.ts` | protected filter → dedup → spill non-duplicate oversized `CapturedToolCall`s (write file, set `spillPath`/`spillBytes`/`resultPreview`/`contentHash`), `addBatch` synchronously | +| `src/indexer.ts` | persist + replay `contentHash` for spilled records (skip recompute when present); pass new optional fields through `reconstructFromSession` | +| `src/pruner.ts` | branch on `spillPath`: mechanical spill stub vs. existing summary-ref stub | +| `src/query-tool.ts` | `spillPath` → read file then `truncateHead`; fallback to preview | +| `src/budget.ts` | `shouldDeltaFlush` | +| `index.ts` | `turn_end`: track previous usage fraction; OR `shouldDeltaFlush` into the flush decision; reset on `session_start` | +| `README.md` | document the 3 new settings + `read`-the-blob recovery | +| `PRUNING.md` | document eager-spill layer + budget-delta trigger | + +## Testing + +Pure unit: +- threshold gating (`< / == / >` `spillThreshold`). +- `headPreview` (line-boundary cut, byte cap, multibyte safety). +- blob path sanitize (no traversal/separators). +- `shouldDeltaFlush` truth table (null delta, missing usage, below/at/above). + +Integration (Pi extension smoke, per AGENTS.md): +- oversized result → sidecar file exists with full body; index entry has + `spillPath` + preview, not full body. +- `pruneMessages` stubs the spilled result on the next `context` build with + **no** `context-prune-summary` and **no** summarizer LLM call. +- `context_tree_query` on the spilled id returns file-backed (head-truncated) + content; `read` on `spillPath` returns the full body. +- spill + dedup: a large result spills once; an identical later result aliases + to the spilled original (no second file write) and resolves to the same + `spillPath`. +- reconstruct-from-session: after restart, record still resolves, blob still + read. +- budget-delta: a turn whose usage jump `>= budgetTurnDelta` triggers + `flushPending`; below it does not; a null-`tokens` turn neither fires nor + overwrites `previousFraction`. + +Typecheck before commit per AGENTS.md (`bun x tsc --noEmit ...`). + +## Open questions + +None. Threshold (64 KB), B in scope, no orphan sweep — all decided. diff --git a/pi-condense/doc/specs/2026-06-11-protected-paths.md b/pi-condense/doc/specs/2026-06-11-protected-paths.md new file mode 100644 index 0000000..33743ec --- /dev/null +++ b/pi-condense/doc/specs/2026-06-11-protected-paths.md @@ -0,0 +1,86 @@ +# Path-based protection: `protectedPaths` + +GitHub issue: [#1](https://github.com/jjuraszek/pi-context-prune/issues/1) + +## Problem + +The pruner treats all `read` results as data, but skill bodies are standing instructions. Summarizing them is categorically lossy: the model later executes the summary, not the skill. + +Observed failure (real session): an SDD run read `subagent-driven-development/SKILL.md`; the pruner replaced it ~6 turns later with a 2-sentence summary that omitted the closing-loop step, and the session marked verify complete without running the gate. Verified against the session JSONL: all 4 SKILL.md reads (`writing-plans`, `subagent-driven-development`, `finishing-a-development-branch`, `linear`) were indexed and summarized. All 4 went through `read` with a `path` argument. + +`protectedTools` cannot fix this: it is tool-name based, and protecting all of `read` defeats the pruner. This bites hardest with pi-gauntlet, where skill files carry multi-step workflow gates. + +## Decision + +New config field `protectedPaths: string[]`, default `["**/skills/**/*.md"]`. A tool call whose `args.path` matches any pattern is protected with **identical semantics to `protectedTools`**: never enters a batch, never indexed, never spilled, raw output stays verbatim in context; inside compressed chains it is relocated verbatim as ``. + +### Defaults and matching + +| Aspect | Decision | +|---|---| +| Default | `["**/skills/**/*.md"]` — covers `SKILL.md` AND sibling reference files under any `skills/` dir (superpowers `.../skills//*.md`, in-repo `.agents/skills/...`, `~/.agents/skills/...`) | +| Matched argument | `args.path` only (string). pi's `read` and `navigator_slice` use `path`. Non-string or missing `path` → not protected. | +| Glob contract | Hand-rolled glob→regex, no new runtime dependency. Full-path match against the raw arg with `\` normalized to `/`. `*` and `?` match within a path segment (no `/`); `**` crosses segments; `**/` also matches zero directories (so `**/SKILL.md` matches a bare relative `SKILL.md`). All other characters are regex-escaped literals. Case-sensitive. | +| Kill switch | `"contextPrune": { "protectedPaths": [] }` in settings.json. `normalize()` spreads `existing` over `DEFAULT_CONFIG`, so an explicit `[]` wins. | +| Command | `/pruner protected-paths [patterns]` mirroring `protected-tools` (interactive prompt; `none` clears). Also shown in the `/pruner settings` overlay. | + +**Behavior change on upgrade:** unlike `protectedTools` (default `[]`), the non-empty default changes behavior for existing users — that is the point of the issue. Risk: an unrelated large docs tree under a `skills/` dir never prunes; mitigated by the kill switch. + +## Implementation + +### New module: `src/protected.ts` (pure) + +```ts +isProtected(toolName: string, args: unknown, config): boolean +``` + +True iff `toolName ∈ protectedTools` OR `args.path` is a string matching any `protectedPaths` glob. Compiled patterns cached in a module-level `Map` keyed by pattern string (config edits mid-session pick up new patterns without invalidation logic). + +### Wiring — same 3 surfaces as `protectedTools` + +| Surface | Today | Change | +|---|---|---| +| Live capture filter (`index.ts` turn_end) | name-set check | call `isProtected(name, args, config)` with the captured call's normalized args; protected calls never enter the batch (also exempts them from eager spill) | +| `captureUnindexedBatchesFromSession` (`src/batch-capture.ts`) | `excludeToolNames: Set` | generalize to a predicate param `(toolName, args) => boolean`; call sites extract args from the raw toolCall block as `tc.input ?? tc.arguments` | +| `detectChains` (`src/chain-detector.ts`) | `protectedSet.has(name)` on assistant toolCall blocks and toolResult fallback | assistant block check uses the predicate with `block.input ?? block.arguments`. The toolResult fallback stays **name-only**: results carry no args, and the assistant block always precedes its result, so no protection is lost. | + +### Repairing already-summarized reads (render-time re-check) + +Stub replacement is applied in-flight by `pruneMessages` on every turn; the raw toolResult bodies still live in the session JSONL, and `ToolCallRecord.args` is already persisted in `context-prune-index` entries and rebuilt on `session_start`. So `pruneMessages` adds one condition to the stub-replace path: if the record's `args` now satisfies `isProtected`, skip stubbing and leave the raw result verbatim. This repairs existing sessions (including the motivating one) with no schema change and no migration. + +**Declared limitation:** records inside already-compressed chains (`context-prune-chain` entries) are NOT repaired — the chain's `protectedToolCallIds` set is fixed at compression time. Forward-only there. + +### Config plumbing + +- `src/types.ts`: add `protectedPaths: string[]` to `ContextPruneConfig`; `DEFAULT_CONFIG.protectedPaths = ["**/skills/**/*.md"]`. +- `src/config.ts`: no explicit `normalize()` clause (follows `protectedTools` precedent — spread handles it). +- `src/commands.ts`: `protected-paths` subcommand + config-overlay row, cloned from `protectedTools` handling. + +### Out of scope + +- `file_path`/other arg names (pi tools use `path`; revisit if a real tool surfaces). +- Dedup, error-purge, thinking-strip — untouched. +- Protecting skill content injected via user/system messages — the pruner never touches those. +- Path resolution/normalization: match against the raw `path` string as the model sent it (absolute or relative). Patterns anchored with `**/` make this robust. + +## Edge cases + +- Oversized protected read: stays verbatim in context forever, never spilled. By design — that is what "protected" means. +- Pattern matching a non-read tool's `path` arg (e.g. a write target): protected. Harmless — write results are tiny. +- Invalid glob: hand-rolled converter cannot throw; every pattern compiles to some regex. + +## Testing + +- Unit tests: glob matcher (`**`, `*`, `?`, zero-dir `**/`, segment-locality of `*`, regex-char escaping, `\` normalization, no-match cases) and `isProtected` (name OR path, non-string path, empty config). +- Unit test: chain compression with a path-protected call — its output relocates verbatim as `` into the synthetic body. +- Unit test: render-time re-check — a record summarized under old config is left unstubbed once its path matches `protectedPaths`. +- Smoke test per AGENTS.md: isolated `$PI_CODING_AGENT_DIR`, read a `skills/**/*.md` file plus enough unprotected calls to clear `minBatchChars`, force a flush (`/pruner now`), then assert (a) a `context-prune-index` entry exists for the batch, (b) the protected toolCallId is absent from it, (c) the raw result is intact in the next-turn context. Asserting (a) guards against the false-pass where the whole batch was skipped as trivial. +- Regression: existing `protectedTools` tests stay green. + +## Documentation updates + +- `README.md`: `protectedPaths` row in the config table (next to `protectedTools`), default shown in the example settings block, `/pruner protected-paths` in the command table. +- `PRUNING.md`: extend "Protected tools" section to "Protected tools & paths" — predicate semantics, capture-time filtering, chain-compression relocation contract (§ existing protected-output rationale applies unchanged). +- `AGENTS.md`: no structural change needed; `context-prune-chain` row already documents `protectedToolCallIds` — reword to say ids can be protected by name or path. + +Keep doc tone dense and human-readable per repo conventions. diff --git a/pi-condense/doc/specs/2026-06-21-cost-event-reclaim-status.md b/pi-condense/doc/specs/2026-06-21-cost-event-reclaim-status.md new file mode 100644 index 0000000..80c9d5b --- /dev/null +++ b/pi-condense/doc/specs/2026-06-21-cost-event-reclaim-status.md @@ -0,0 +1,185 @@ +# Spec: unified cost event + live-reclaim status line + +Date: 2026-06-21 +Branch: `cost-event-reclaim-status` + +## Problem + +Two defects in how the pruner surfaces itself: + +1. **Pruner spend is invisible to the unified total.** The summarizer makes its own + `stream()` calls (`src/summarizer.ts` `runSummarization`) that bypass the agent loop, so + they never reach `message_end` and never enter pi-subagents' grand total (`Σ$...`). The + pruner instead shows its own isolated cost (`↑3.0k ↓1.3k $0.021`). +2. **The status line shows price, not value.** `prune: ON (On agent message) │ ↑3.0k ↓1.3k + $0.021` reports what pruning *costs*, never what it *reclaims*. A user cannot tell whether + pruning is doing a good job. + +## Goals + +- Pruner publishes its cumulative LLM spend on a shared `pi.events` channel so pi-subagents + (or any aggregator) can fold it into one `Σ$` as a third cost signal. Fire-and-forget; no + hard dependency on pi-subagents. +- Status line headline becomes a compact reclaim ratio: `prune: ON │ 92k->14k (-85%)`. + +## Non-goals + +- pi-subagents-side aggregation (separate spec in that repo, against the same contract). +- Durability of the external-cost slice across session reload. Decided **live-only**: the + external cost is ephemeral; no session reseed. Rationale: it is a minor fraction of the + total and durability is not worth coupling an aggregator to the pruner's entry schema. +- Exact token accounting for reclaim. The headline uses an estimate (chars / 4). + +## Change 1 - emit cost on the shared bus + +### Contract (fixed interface, shared with the pi-subagents spec) + +- **Channel:** `pi.events` string channel `"cost:external"`. + `EventBus.emit(channel: string, data: unknown)` per + `node_modules/@earendil-works/pi-coding-agent/dist/core/event-bus.d.ts`. `ExtensionAPI` + exposes this bus as `events: EventBus` (types.d.ts ~line 962), so any site that already + holds `pi: ExtensionAPI` can emit with no signature change. +- **Payload** (cumulative-per-source, NOT deltas): + + ```ts + interface ExternalCostUpdate { + source: string; // stable producer id: "pi-context-prune" + totalCost: number; // cumulative USD this source spent THIS SESSION + inputTokens?: number; // cumulative this session, optional (for breakdown display) + outputTokens?: number; // cumulative this session, optional + } + ``` + + `ExternalCostUpdate`, plus the constants `EXTERNAL_COST_CHANNEL = "cost:external"` and + `EXTERNAL_COST_SOURCE = "pi-context-prune"`, are declared and **exported** from + `src/types.ts` (not spec-prose only) so the contract is importable in one place. +- **Semantics:** "cumulative" means cumulative **for the current session**, not all-time. The + producer emits its running session total on every update. Cumulative-per-source is + idempotent and replay-safe within an aggregator run: a re-emit overwrites the same key, + never double-counts. The aggregator keeps `Map` and sums `totalCost`. + +### Producer wiring (this repo) + +**Session-scoped delta, not raw cumulative.** `StatsAccumulator.reconstructFromSession` +(`src/stats.ts:96`) reloads prior-session `context-prune-stats` totals on `session_start`. +Emitting `getStats()` directly would republish historical spend to a freshly started +aggregator, breaking the live-only contract. Fix: + +- `StatsAccumulator` captures a **session baseline** - a snapshot of `{ totalCost, + totalInputTokens, totalOutputTokens }` taken at construction (zero) and re-captured at the + end of `reconstructFromSession` (equal to the carried-over totals). +- New method `getSessionDelta()` returns `{ totalCost, inputTokens, outputTokens }` = current + totals minus the baseline - i.e. only this session's spend. +- The `cost:external` payload maps from `getSessionDelta()`, with the constant `source`. + +**Emit at every stats-write site, not only `persist`.** `persist(pi)` is NOT the sole +stats-write path: the default `message_end` -> `delivery: "session"` branch writes via +`appendEntry(CUSTOM_TYPE_STATS, statsAccum.getStats())` (`index.ts:498`), bypassing +`persist`. Emitting only inside `persist` would miss the main agent-message summarization +path. Fix: + +- Add a free helper `emitExternalCost(pi, accumulator)` (in `src/stats.ts` or a small util) + that emits `{ source: EXTERNAL_COST_SOURCE, ...accumulator.getSessionDelta() }` on + `EXTERNAL_COST_CHANNEL`. +- Call it immediately after **every** site that writes stats: both branches of the + stats-write in `flushPending` (`index.ts` runtime `statsAccum.persist(pi)` ~493 and session + `appendEntry` ~498) and the chain-compression stats writes (~534, ~839). Simplest form: + fold the emit just after the runtime/session if-else so it runs unconditionally on each + flush. +- Idempotency makes the multi-site emit safe: re-emitting the same session delta is a no-op + at the aggregator. + +## Change 2 - live reclaim headline + +### Measurement (single point, all mechanisms) + +`pruneMessages` (`src/pruner.ts`) is the one place where every reclaim mechanism +(stub-replace, error-purge, chain-range-prune, thinking-strip) runs against the full message +array. Measure once there, only when a prune actually occurs (the function already +short-circuits and returns the original array reference when nothing changes): + +- `beforeChars` = `sizeMessages(messages)` (the input array, raw outputs still present). +- `afterChars` = `sizeMessages(pruned)` (the returned pruned array). + +`sizeMessages(messages: AgentMessage[]): number` is a pure helper defined as +`JSON.stringify(messages).length`. Serializing the whole array (not just visible `.text`) +is deliberate: it counts tool-call argument bodies (error-purge reclaim), thinking blocks +(thinking-strip reclaim), and tool-result arrays (stub-replace / chain-range reclaim) - +so all four mechanisms register. It is an estimate of context weight, not an exact token +count. + +This captures all four mechanisms with zero per-path plumbing. It is a **live** measurement +(current pruned context vs the same context unpruned), self-correcting each turn, with no +cumulative drift. + +### Carrying the measurement to the status line + +- `StatsAccumulator` (`src/stats.ts`) gains a **transient** live-reclaim field + (`liveBeforeChars`, `liveAfterChars`), set via a new method (e.g. `setLiveReclaim(before, + after)`). +- These transient fields are **separate from `SummarizerStats`** (which stays the persisted + schema). `getStats()` returns the persisted `SummarizerStats` for the + `context-prune-stats` entry unchanged; the status widget reads the live reclaim via a + distinct accessor (e.g. `getLiveReclaim()`) or an extended widget-only view object. The + persisted entry schema is untouched; the live value resets to unset on `session_start` and + stays so until the next prune. +- The `context` event handler in `index.ts:787` currently destructures the context as `_ctx` + (unused). Rename it to `ctx` and use it: stash the `pruneMessages` before/after into the + accumulator and refresh the status widget via `setPruneStatusWidget(ctx, config, + statsAccum.getStats())`. `ExtensionContext.ui.setStatus` is already available - no + signature change beyond using the parameter. + +### Status line format + +`pruneStatusText(config, stats)` (`src/commands.ts`) renders: + +| State | Line | +|---|---| +| Enabled, reclaim measured this session | `prune: ON \| 92k->14k (-85%)` | +| Enabled, nothing pruned yet | `prune: ON` | +| Disabled | `prune: OFF` | + +- Tokens estimated as `Math.round(chars / 4)`, formatted with the existing + `formatCompactCount` (`k`/`M`) helper used elsewhere in `commands.ts`. +- Reduction `= round((1 - afterTokens / beforeTokens) * 100)`, clamped to `>= 0`. A full + strip (`afterTokens == 0`, `beforeTokens > 0`) correctly renders `-100%`. +- Drops the `(On agent message)` mode label and the `↑↓$` figures from the line. +- **Cost leaves the status line entirely.** It now flows into pi-subagents' `Σ$`. Full + token/cost/call detail remains available in `/pruner stats` (unchanged). + +## Edge cases + +- **No prune this turn:** `pruneMessages` returns early; no measurement, status line keeps + the last value (or `prune: ON` if never pruned). +- **`beforeChars == 0`:** show `prune: ON` (no ratio); never divide by zero. +- **Negative/zero reclaim** (summary larger than raw, rare): clamp reduction to `0%`. +- **Oversized/spilled outputs not in context:** measured as-is; the headline is an estimate, + not an exact byte count. +- **No aggregator listening:** `pi.events.emit` is a no-op with no subscribers; pruner + behavior is unchanged. + +## Testing + +- **Reclaim calc (pure):** unit test in the `pruner` test that before/after char totals and + the formatted ratio string are correct, including the clamp and zero-before cases. +- **Emit (unit):** assert `persist` emits one `"cost:external"` event whose payload carries + the cumulative `{ source, totalCost, inputTokens, outputTokens }` from current stats. +- **Status text (unit):** `pruneStatusText` renders each of the three states correctly. +- **Smoke:** `pi -e ./index.ts -p "..."` against an isolated `$PI_CODING_AGENT_DIR`. + `--no-extensions` is dropped: it would suppress the very extension under test. Verify the + ratio renders (inspect the status widget / session output) and that `context-prune-stats` + JSONL entries still validate against the unchanged `SummarizerStats` schema (`jq` the + `customType` counts per AGENTS.md). + +## Documentation impact + +- **README.md:** update the status-line description; document the `cost:external` emit and the + `ExternalCostUpdate` contract. +- **PRUNING.md:** note the live-reclaim metric (single-point measurement in `pruneMessages`, + estimated tokens). +- **AGENTS.md:** add `cost:external` to the event/contract surface (it is an outbound + `pi.events` emit, not a session custom entry, so note it as such). + +## Open questions + +None. diff --git a/pi-condense/doc/specs/2026-07-04-per-bullet-summary-refs.md b/pi-condense/doc/specs/2026-07-04-per-bullet-summary-refs.md new file mode 100644 index 0000000..681c6ad --- /dev/null +++ b/pi-condense/doc/specs/2026-07-04-per-bullet-summary-refs.md @@ -0,0 +1,357 @@ +# Spec: per-bullet ref mapping in prune summaries + +Date: 2026-07-04 +Branch: `per-bullet-refs` +Issue: #2 (Per-bullet ref mapping in prune summaries (recovery UX)) + +## Problem + +A prune summary lists its recovery refs as a flat footer appended by +`formatSummaryToolCallRefs` (`src/summary-refs.ts:46`): + +``` +--- +**Summarized tool refs**: `t12`, `t3`, `t7` +Use `context_tree_query` with these refs to retrieve the original full outputs. +``` + +The refs are positional against the summarized batch's tool-call order, but no +per-bullet body carries its own ref. When the model wants to recover one tool's +raw output it must guess which footer ref maps to the bullet it just read, then +call `context_tree_query`. The mapping exists deterministically in the code +(both the serializer and the ref allocator iterate the same post-dedup +`batch.toolCalls` in the same order) but is never surfaced onto the bullet, so +the one datum that would make recovery a single hop is discarded at render time. + +The roast on issue #2 (two reviewers) killed the originally-proposed +`context_search` FTS tool and converged on this 1:1 bullet->ref mapping as the +only real gap: "put the ref on the bullet, not build a search tool." The roast +rationale, inlined so an implementer needs no issue access: a search tool adds a +new tool surface, spill/sidecar coverage, and match-cap handling for payoff a +deterministic per-bullet ref already delivers; the model is not ref-blind (the +prompt already orders paths/ids/errors into the body and refs are in-context) - +the fix is to attach the ref to the bullet. No new tool, no index. + +## Goals + +- Each per-tool block in a per-batch prune summary carries its own recovery ref + inline (`` `t12` ``), so the model recovers a specific tool's raw output in one + hop instead of eyeballing a flat footer. +- The existing footer stays verbatim as an always-correct fallback (satisfies + issue #2 AC "footer preserved"). +- Deterministic mapping: number->shortId is pure index arithmetic on the shared + `batch.toolCalls` order; the LLM only copies a label, it never invents a ref. +- Graceful degradation: any missing, malformed, out-of-range, **or + tool-name-mismatched** label leaves the affected bullet ref-less (footer still + covers it) rather than emitting a broken *or confidently wrong* token. + +## Non-goals + +- **No `context_search` / FTS / recovery-search tool.** Killed in the roast; + explicitly out of scope. +- **No change to ref allocation, shortId format, or `context_tree_query`.** + `buildShortToolCallRefs` (`t${startIndex+offset}`) and the indexer's + `allocateSummaryRefs` are untouched. +- **No new config knob.** Decoration is unconditional; there is no opt-out. + Rationale: it strictly improves recovery UX, degrades to today's footer when + the LLM omits a label, so a knob gating an unconditional-improvement would be + dead weight (YAGNI). +- **No separate range-summary implementation.** Range fusion inherits inline + refs for free (see "Range-summary propagation"); we add no code there beyond a + confirming test. + +## Mechanism + +The change rests on an ordering invariant that already holds end-to-end: +`serializeBatchForSummarizer` (`src/batch-capture.ts:147`) and +`allocateSummaryRefs` (`src/indexer.ts`, via `buildShortToolCallRefs`) both +iterate the **same** post-dedup `batch.toolCalls` array in the **same** order. +So the serialized tool at 1-based position `N` maps deterministically to +`refs[N-1].shortId`. Two transforms that could have desynced it do not: + +- **Dedup** rewrites `batches[i] = { ...batch, toolCalls: remaining }` in + `index.ts` *before* both serialize and allocate run, so both observe the + identical filtered array. +- **agent-message merge** (`groupBatchesByMode`, `src/batch-capture.ts`) + concatenates `toolCalls` in order into one `CapturedBatch`; refs are allocated + over that merged array. Numbering stays 1..N over the merged whole. + +### Touch point 1 - label the serialization (`src/batch-capture.ts`) + +In `serializeBatchForSummarizer`, prepend `[[N:toolname]] ` (double-bracket +label carrying position **and** tool name + one space) immediately before +`Tool:` at the start of each tool block, so the model can copy (not count) it. +`N = index + 1` from the existing `batch.toolCalls.map((tc, index) => ...)` loop; +`toolname` is `tc.toolName` (already in scope). The name is carried so the +substitution step can *validate* the model's echoed label against the actual +tool at that position (see Touch point 3). Nothing else in the block changes. +Concrete before/after (block for the first tool): + +``` +Before: +Tool: read({"path":"a.ts"}) +Result (OK): ... + +After: +[[1:read]] Tool: read({"path":"a.ts"}) +Result (OK): ... +``` + +The change to the returned block string is exactly: +``return `[[${index + 1}:${tc.toolName}]] Tool: ${tc.toolName}(${argsJson})\nResult (${status}): ${resultText}`;`` + +### Touch point 2 - one instruction line (`src/summarizer.ts`) + +`SYSTEM_PROMPT` is concatenated as the leading text of the single user message +(`summarizeBatch`: `SYSTEM_PROMPT + "\n\n\n" + serialized + ...`; +there is no separate system role in this call path), so adding a line to the +constant lands it in the message the model reads. Append one paragraph to +`SYSTEM_PROMPT` **after** the existing final paragraph (`Keep each tool call to +1-3 bullet points. ... Be concise.`), and only there - `RANGE_SYSTEM_PROMPT` is +left untouched. Exact text to add: + +> Begin the first bullet of each tool call with that tool's `[[N:toolname]]` +> label, copied verbatim (both the number and the name) from its line in the +> input, as the plain, first thing on the line (no bold, backticks, or list +> numbering around it). Do not renumber, rename, or invent labels; if you skip a +> tool, skip its label too. + +"Copy the label" is a far more reliable ask than "count correctly": if the model +skips a tool (the prompt already says "Skip calls that succeeded with nothing +reusable to record"), copying keeps every emitted label pointing at the right +source, whereas a running counter would silently shift. + +### Touch point 3 - substitute inline (`src/summary-refs.ts` + `index.ts`) + +New pure function in `src/summary-refs.ts`: + +```ts +export function substituteInlineRefs( + text: string, + refs: SummaryToolCallRef[], + toolNames: string[], +): string +``` + +It rewrites line-leading `[[N:name]]` labels to the backtick-wrapped shortId +using `refs[N-1]`, **after validating** that the echoed `name` matches +`toolNames[N-1]` (comparison is trimmed + case-insensitive). `refs` and +`toolNames` are positionally aligned to the same `batch.toolCalls` order +(`refs` from `buildShortToolCallRefs(batch.toolCalls.map(tc => tc.toolCallId))`, +`toolNames` from `batch.toolCalls.map(tc => tc.toolName)` - the exact array +`makeSummaryDetails` already builds). `SummaryToolCallRef` carries no tool name, +so `toolNames` is passed as a third argument rather than read off `refs`. + +Note `SummaryToolCallRef.shortId` **already includes** the `t` prefix +(`buildShortToolCallRefs` returns `` shortId: `${SHORT_ID_PREFIX}${...}` ``, e.g. +`"t12"`), so the replacement wraps `shortId` verbatim - it does **not** prepend +another `t`. The emitted token is `` `t12` ``, never `` `tt12` ``. + +Applied at the single existing call site in `index.ts` (currently line 389-390), +between allocation and footer append. `toolNames` come from the same `batch`, +keeping the two arrays aligned: + +```ts +const summaryRefs = indexer.allocateSummaryRefs(batch); +const toolNames = batch.toolCalls.map((tc) => tc.toolName); +const decorated = substituteInlineRefs(result.summaryText, summaryRefs, toolNames); +const summaryText = decorated + formatSummaryToolCallRefs(summaryRefs); +``` + +`decorated` is the body with inline refs; `summaryText` is `decorated` plus the +unchanged footer. `shouldSkipOversized = summaryText.length > batchRawCharCount` +is computed on `summaryText`; substituting `[[9:read]]` -> `` `t12` `` is roughly +length-neutral and does not meaningfully move the oversize guard. + +## Marker choice and substitution rules (load-bearing) + +The marker is `[[N:name]]` (double square bracket, `N` = decimal digits, `name` += the tool name), matched **only as the first non-whitespace token of a line and +outside fenced code blocks**, via: + +``` +/^(\s*(?:[-*]\s+)?)\[\[(\d+):([^\]\n]+)\]\]\s*/gm +``` + +(capture 3 = echoed tool name, validated against `toolNames[N-1]`). The name +capture is `[^\]\n]+` (any run of non-`]`, non-newline chars, stopping before +the closing `]]`) rather than a restricted `[A-Za-z0-9_-]` class **so the +degradation contract holds for every tool name**: a namespaced/dotted tool +(`server.tool`, `a:b`) still produces a *matching* label that is then validated +and, on mismatch or out-of-range, stripped. A narrower class would fail to match +such a label at all - leaving raw `[[N:name]]` in the body (fail-open), which +contradicts "any malformed/mismatched label degrades to footer-only". Trailing +whitespace after `]]` is consumed with `\s*` (not `\s?`) so the substitution +normalizes to exactly one separating space regardless of how many spaces the +model emitted. + +processed line-by-line while tracking a fenced-code toggle (a line whose trimmed +form starts with ```` ``` ```` flips "inside fence"; lines inside a fence are +never substituted). Guards protecting the verbatim content the prompt is +explicitly told to copy (`argv[1]`, `items[0]`, file paths, error strings, quoted +tool output): + +1. **Double brackets** are rare in copied code/prose/errors; single `[1]` (the + common array-index shape) never matches. Obsidian-style `[[Page]]` wikilinks + are non-numeric and never match `\d+`. +2. **Line-start anchor** (`^` with the `m` flag, allowing leading whitespace and + an optional `-`/`*` bullet marker) means a mid-line `[[x]]`-lookalike is left + untouched. +3. **Fenced-code exclusion** shields the realistic false-positive vector: a + verbatim-copied log or serialized block whose line happens to start with + `[[]]`. + +Substitution semantics, per matched label with captured number `N` and echoed +name (capture 1 = leading whitespace + optional bullet prefix; capture 2 = +digits; capture 3 = echoed tool name). The name check runs first: + +| Case | Action | +|---|---| +| `1 <= N <= refs.length` **and** echoed name == `toolNames[N-1]` (trimmed, case-insensitive) | Replace the whole match with `` $1`SHORTID` `` - the captured prefix, then the backtick-wrapped `refs[N-1].shortId`, then one space separator (so `- [[1:read]] Read` becomes ``- `t1` Read``, never ``- `t1`Read``). `shortId` is emitted as-is (no extra `t`) | +| `1 <= N <= refs.length` **but** echoed name != `toolNames[N-1]` | **Mismatch -> strip** the whole label (prefix re-emitted, no ref); the model desynced, so footer-only is the safe outcome instead of a confident wrong ref | +| `N` out of range (`< 1` or `> refs.length`) | Strip the label, re-emitting the captured prefix only (no dangling token); footer still covers that tool | +| `N` unparseable | Cannot occur - regex captures `\d+` only; non-numeric never matches and is left verbatim | +| Label absent on a line | No-op for that block; it renders ref-less, footer covers it | +| Same `[[N:name]]` on multiple lines of one tool | Each occurrence validated + substituted to the same `` `tN` `` (harmless, mildly helpful) | +| Label wrapped/formatted or non-standard bullet (`**[[1:read]]**`, `` `[[1:read]]` ``, `1. [[1:read]]`, `> [[1:read]]`) | The anchored substitution does not match, so no inline ref is produced. A second **catch-all strip** pass (below) then removes the raw `[[N:name]]` token from the line, leaving footer-only recovery with **no internal syntax leaked** into context. | + +**Catch-all strip (leak guard).** After the anchored substitution, every +non-fenced line is swept once more with `/\[\[\d+:[^\]\n]+\]\]\s*/g` and any +surviving well-formed label token is deleted. This guarantees the invariant +*"the feature never injects a raw `[[N:name]]` token into LLM context"* even when +the model ignores the prompt and wraps/renumbers its label (the anchored pass +recovers the ref for compliant `-`/`*` bullets; the strip pass discards an +unrecoverable label rather than leaking it). It runs inside the same fence-aware +line loop, so labels inside fenced code blocks are still never touched. It +cannot hit mid-line lookalikes (`argv[1]` is a single bracket; `[[Page]]` +wikilinks have no `:digits`). + +The footer from `formatSummaryToolCallRefs` is appended unchanged in every case, +so recovery is never worse than today. + +### Why the name tag (mis-copy mitigation) + +The number->shortId map is deterministic, but the number->bullet association is +the model's; a bare number could be copied onto the wrong bullet (typically the +model renumbering after skipping a tool) and substitute a *confidently wrong* +`` `tX` ``. Echoing the tool name in the label and validating it against the +known tool at position `N` converts that failure into a **detected mismatch that +degrades to footer-only**. This catches the dominant desync - a skip-induced +renumber lands `[[2:read]]` where position 2 is a `bash`, mismatch, strip. It is +not the earlier-rejected *prose* cross-check (parsing the bullet's free text for +a tool name, which is unreliable); it validates the model's own echoed tag, a +machine-exact token. + +### Residual risk (accepted) + +- **Same-type swap.** Two calls of the *same* tool (two `read`s) with their + labels swapped both pass the name check, so a swapped `` `tX` `` still points + at a same-kind call (wrong file). Rarer than cross-type renumber and + lower-harm; catching it would need a content discriminator in the label + (`[[1:read:a.ts]]`), forcing the model to copy a path verbatim - more + error-prone and heavier, so out of scope. Footer still lists every ref. +- **Non-fenced line-start lookalike.** A verbatim-copied non-fenced line that + begins with `[[:]]` can be mis-handled (substituted if it + happens to name-match, else stripped). Judged vanishingly rare given the + double-bracket + `N:name` shape + line-start + fenced guards; accepted rather + than parsing summary prose further. +- **Non-fenced mid-line label.** The catch-all strip also removes a well-formed + `[[:]]` token appearing mid-line in non-fenced prose (not just + at line start). This is the intended leak-guard behavior; a verbatim-copied + prose sentence that contains that exact token shape mid-line is vanishingly + rare and accepted. +- **No flush-path integration test.** The `index.ts` flush pipeline + (`allocateSummaryRefs` -> `substituteInlineRefs` -> footer) has no isolated + unit harness, so the positional alignment between `refs` and `toolNames` is + covered by the pure-function + composition unit tests, not an end-to-end + stubbed-summarizer test. Accepted: building a `flushPending` harness is + disproportionate to a five-line wiring change whose invariant is verified by + reading `allocateSummaryRefs` (both arrays map the same `batch.toolCalls`). + +## Range-summary propagation (inherited, not implemented) + +`RANGE_SYSTEM_PROMPT` (`src/summarizer.ts`) already instructs: "Keep any +reference tokens like `` `t12` `` or `` `b3` `` intact." Chain range fusion +(`summarizeRange`) consumes the **stored** per-batch summary text, which by then +already has inline `` `tX` `` refs substituted in. So inline refs **propagate +when present in the per-batch input** with no additional code - subject to the +same LLM-compliance caveat as any ref token in the range prompt (the fusion +model is instructed to keep them but is not forced to). No production change in +the range path; covered by a confirming test asserting the fusion *input* text +carries the inline ref (the fusion *output* is mocked in existing tests, so +asserting against it would be circular). + +## Testing approach + +Tests follow the repo convention (`src/*.test.ts`, run by `bun test src/`). Two +new files, since neither exists today: + +New file `src/summary-refs.test.ts` - `substituteInlineRefs(text, refs, toolNames)`: + +- in-range + name-match replaces `[[1:read]]`/`[[2:bash]]` with the right + shortIds (asserting the exact `` `t1` `` token, no `tt1`); +- **name mismatch** (`[[2:read]]` where `toolNames[1] === "bash"`) stripped, no + ref emitted (the core mis-copy-mitigation case); +- name-match is trimmed + case-insensitive (`[[1:Read]]` vs `read` -> match); +- out-of-range `[[9:read]]` (only 2 refs) stripped, leaving the bullet prefix + and no dangling token; +- absent label -> text unchanged; +- duplicate `[[2:bash]]` on two lines both substituted; +- separator preserved: `- [[1:read]] Read` -> ``- `t1` Read`` (single space, + not glued); +- left untouched: mid-line `argv[1]`, a mid-line `[[1:read]]`-lookalike, a + line-start `[[1:read]]` **inside a fenced code block**, and a wrapped + `**[[1:read]]**`. + +New file `src/batch-capture.test.ts` - `serializeBatchForSummarizer`: + +- emits `[[1:name]]..[[N:name]]` (correct position and tool name) immediately + before each `Tool:` in tool order; +- numbering stays 1..N over a dedup-filtered batch (fewer tool calls, still + contiguous from 1); +- numbering stays 1..N over an agent-message-merged batch (concatenated + `toolCalls` via `groupBatchesByMode`). + +Composition (unit, in `src/summary-refs.test.ts`) - no `index.ts`/`flushPending` +integration harness exists, so the no-drift check is a pure composition of the +two helpers over one `SummaryToolCallRef[]`: + +- `substituteInlineRefs(body, refs, toolNames)` inline refs and + `formatSummaryToolCallRefs(refs)` footer refs reference the same shortIds for + the same positions. +- A per-batch body carrying an inline `` `tX` `` still contains that token after + passing through the range-fusion **input** assembly (asserted on the input, per + the range-propagation note). + +## Documentation impact + +- Feature / user-facing docs introduced: none (behavioral refinement of an + existing output; no new command, setting, or surface). +- Materially amended existing docs: + - `PRUNING.md` - the "How the Model Re-reads Raw Outputs" section (line ~284) + and the recovery ASCII ("RECOVERING PRUNED DATA", line ~209) gain a short + note that per-tool bullets now carry an inline `` `tX` `` ref (footer + retained as always-correct fallback). + - `README.md` - the `context_tree_query` paragraph (line 195) currently + describes only the footer ("Pruned summaries end with short refs like + `Summarized tool refs: ...`"); amended in the same commit to add that each + per-tool bullet also carries its own inline ref. (Checked now: this is the + only summary-format description in `README.md`; no code-block sample to + update.) +- Derived / memory docs invalidated: none (no router, AGENTS.md section, or + index changes; the `context-prune-summary` customType row in `AGENTS.md` + still accurately describes what the entry is). + +## Acceptance criteria (from issue #2) + +1. A multi-tool prune summary shows, per tool block, either an inline ref or a + stable positional number->ref mapping. **Met** by inline `` `tX` `` + substitution. +2. The flat footer ref list is preserved. **Met** - `formatSummaryToolCallRefs` + output is appended unchanged. +3. No new tool, config knob, or index is added. **Met** - three edits to + existing functions plus one new pure helper; no config, no tool, no index. +4. Degrades safely when the LLM omits/garbles a label. **Met** - name-mismatch + and out-of-range stripped, absent label -> footer-only, mid-line lookalikes + untouched. The name-tag validation additionally downgrades a *confidently + wrong* in-range ref (the dominant desync) to footer-only. diff --git a/pi-condense/doc/specs/2026-07-06-recovery-grace-window.md b/pi-condense/doc/specs/2026-07-06-recovery-grace-window.md new file mode 100644 index 0000000..123974f --- /dev/null +++ b/pi-condense/doc/specs/2026-07-06-recovery-grace-window.md @@ -0,0 +1,159 @@ +# Recovery grace window: render-time bounded verbatim for `context_tree_query` output + +## Problem + +The pruner re-prunes its own recovery tool's output, forcing a retrieve -> re-stub -> re-query loop the agent experiences as "fighting the pruner." + +Observed failure (real session `019f383f-...`, repo `gridstrong`, 2026-07-06): a `collect.py` standup dump (7784 chars, ref `t1`) was summarized to a 742-char stub at the first turn boundary. A later user question ("why the Jacek prefix?") needed the raw output, so the agent issued `context_tree_query(["t1"])` and recovered the full text into context. At the **next** turn boundary the pruner captured and re-stubbed that recovered output - the batch index at that turn lists `toolNames: ["context_tree_query", "context_tree_query", "bash", "bash"]`, i.e. the extension summarized its own recovery call. The agent had to re-query, and the cycle repeated. The agent's own words in that session: *"I've been fighting the context pruner (my `context_tree_query` calls kept coming back summarized)."* + +Root cause: `context_tree_query` output is an ordinary tool result. It has `toolName: "context_tree_query"`, is `> minBatchChars` (1000), and is not in `protectedTools` (default `[]`), so it is captured, indexed, and stubbed at render like any other output. Content-hash dedup does not help: two identical `context_tree_query(["t1"])` results dedup to *each other*, but dedup runs at prune time, not call time, so it never prevents the agent from issuing the re-query - it only avoids double-summarizing. + +`protectedTools` cannot fix this cleanly. Adding `context_tree_query` there means **permanent** verbatim retention (the record is never stubbed, ever), so a session that recovers many large originals slowly regrows context with no ceiling - defeating the pruner's purpose. The exemption needs a **lifetime bound**. + +### Empirical grounding + +Mined from the local pi session corpus (`~/.pi/agent.anthropic/sessions/**`, 4194 sessions, 3879 with pi-condense active, 518 actually issuing `context_tree_query`, 1415 total ctq calls): + +Re-query gap - user-turn-groups (UTG) between successive `context_tree_query` calls for the **same** ref within a session: + +| metric | value | +|---|---| +| sessions with >=1 re-query | 54 (10.4% of ctq sessions) | +| total re-query events | 99 | +| gap median | 2 UTG | +| gap <= 1 / <= 2 / <= 3 / <= 5 | 44% / 71% / 81% / 86% | +| gap == 0 (same user-turn) | 0 | + +Two load-bearing facts: + +1. **Every re-query crosses a user turn** (gap == 0 is literally zero across 99 events). The thrash is an across-user-turn phenomenon, never within a single user-turn-group. So user-turn-groups is the correct unit, and a window of **3 UTG covers 81%** of re-queries (the gap<=3 mass; diminishing returns past 3). + +2. **A user-turn-group window must NOT be applied globally.** Session length is median **1** UTG, and **77% of pi-condense sessions have <= 3 UTG**. Those single-UTG sessions are long autonomous agent runs - exactly where tool-output accumulation is worst and pruning matters most. A general "keep last K UTG of all tool outputs verbatim" window would keep the entire session verbatim in 77% of sessions and disable pruning where it is needed most. Therefore the grace window is scoped to `context_tree_query` output only, not all outputs. + +## Decision + +Add a **bounded-lifetime verbatim exemption** for `context_tree_query` output, enforced at **render/prune time**, not at capture time. + +Recovery output is captured, indexed, spilled, deduped, and frontier-tracked exactly like any other output - no capture-path change. What changes is the two points where a summarized output is actually *dropped from what the model sees*: + +- **Phase 1 stub-replace** (`src/pruner.ts`) renders the recovery output **verbatim** (skips the stub) while it is within grace, and renders the normal stub once it ages out. +- **Chain compression eligibility** (`src/chain-compressor.ts`) **defers** compressing any chain whose span still contains an in-grace recovery output, so Phase 3 cannot drop the span out from under Phase 1. + +Both consult one predicate: a recovery output is *in grace* while `nowUTG - recoveryUTG <= recoveryGraceTurns`. + +- **K is configurable**, default **3** (empirical: covers 81% of re-queries at gap<=3). +- **Unit is user-turn-groups.** +- **Clock is fixed from recovery.** Recovered in UTG N -> verbatim through UTG N+K -> stub from UTG N+K+1. No reset-on-reference (the model reading a block is not observable) and no reset-on-re-query (per the dedup analysis, a re-query can only fire *after* the output aged out, and the fresh record starts its own clock, so a reset path would be dead code). + +### Why render-time, not capture-time exclusion + +An earlier draft excluded recovery output from *capture*. The spec council (4 members + chair) killed it against source: `trimBatchToPendingRange` (`index.ts:113`) drops any batch whose `turnIndex < frontier`. A capture-excluded recovery call keeps its old `turnIndex`; by the time it "ages out," the frontier has advanced past it, so the batch returns `null` and is dropped **forever** - i.e. the lifetime bound silently degrades to permanent verbatim, the exact unbounded-regrowth failure `protectedTools` was rejected for. Capture-time exclusion also bypassed the live `turn_end` path (`index.ts:696-724`, which spills + marks summarized before any branch scan) and the chain-compression drop path. + +Render-time enforcement sidesteps all three: because indexing stays uniform, the frontier, dedup, spill, and live path all behave exactly as today. The recovery output always has a `t` ref and a record; the only decision deferred to render is *stub vs verbatim*, and that decision is recomputed every turn from live state. `src/pruner.ts:74-81` **already** performs a render-time verbatim skip for `isProtected` records - this feature extends that exact seam with a time-bounded predicate. + +### Computing UTG at render (positional, no stored field) + +`pruneMessages` receives the full branch message array every turn. UTG is derived positionally, requiring **no** new field on `ToolCallRecord` and **no** `addBatch` change: + +- `nowUTG` = total count of `role === "user"` messages in the array. +- `recoveryUTG` for a given toolResult = count of `role === "user"` messages at or before its position (a prefix count over the same array). +- age (in UTG) = `nowUTG - recoveryUTG`; in grace while `age <= recoveryGraceTurns`. + +This works uniformly for branch-scan-captured, live-`turn_end`-captured, and spilled recovery outputs, because it reads the rendered message stream rather than capture metadata. (The existing `userTurnGroup` counter in `src/batch-capture.ts` is a capture-path concept used by `groupBatchesByMode`; it is deliberately not reused here.) + +### Spilled recovery output + +No special handling. A live-captured oversized recovery output is spilled to a sidecar and marked summarized (unchanged). At render, the raw toolResult is still present in the branch, so the grace path returns it verbatim and the sidecar simply goes unused until the output ages out, at which point Phase 1 renders the existing spill-pointer stub. Consequence, stated deliberately: an oversized recovery stays fully in context for up to K UTG (the agent recovered it because it needs it); this is bounded and intended. + +### What this does NOT change + +- Capture, indexing, frontier trim, dedup, spill, the live `turn_end` path - all unchanged. +- All other tool outputs (`bash`, `read`, etc.) stub at render exactly as today. No global grace window. +- `protectedTools`, `protectedPaths`, `minBatchChars`, `pruneOn`, `batchingMode`, `thinkingStrip`, error-purge - unchanged. +- A recovery call also present in `protectedTools` stays permanently verbatim: the existing `isProtected` skip (`pruner.ts:78`) runs first, so permanent protection wins over the bounded grace. + +### Deliberate trade-off + +A recovery output still referenced *after* K UTG is stubbed and may be re-queried (the ~19% tail of the gap distribution). This is the accepted cost of bounding regrowth; permanent retention (rejected) would eliminate the tail but reintroduce unbounded growth. + +## Config + +New field on `ContextPruneConfig` (`src/types.ts`), added to `DEFAULT_CONFIG`: + +| Aspect | Decision | +|---|---| +| Field | `recoveryGraceTurns: number` | +| Default | `3` (covers gap<=3 = 81% of re-queries) | +| Unit | user-turn-groups | +| Semantics | recovery output rendered verbatim while `nowUTG - recoveryUTG <= recoveryGraceTurns` | +| Disable | `0` = no grace; recovery output stubs immediately like today (opt-out to pre-feature behavior) | +| Placement | top-level scalar on `ContextPruneConfig`, sibling to `minBatchChars` (it governs the Phase-1 render path and chain eligibility, not chain compression internals) | +| Consumed in | `src/pruner.ts` (Phase 1 render) and `src/chain-compressor.ts` (eligibility deferral) - **not** the capture path | + +Normalization matches the sibling-scalar convention in `normalize()` (align with how `minBatchChars` / `rollingWindow` handle bad input): an explicit `0` is a valid kill switch and is preserved; values `< 0`, `NaN`, or non-finite fall back to the default `3`; a positive non-integer is floored. + +### Tool-name constant + +`src/query-tool.ts:9` registers the name as the string literal `"context_tree_query"`. Introduce a shared exported constant (`QUERY_TOOL_NAME` in `src/types.ts`, beside the `CUSTOM_TYPE_*` constants) and reference it from `query-tool.ts`, `src/pruner.ts`, and `src/chain-compressor.ts`, so the registration and the two grace checks cannot drift. + +### UI surface + +Mirror the existing scalar knobs (`minBatchChars`, `rollingWindow`): add a `RECOVERY_GRACE_PRESETS` array in `src/types.ts`, a `recovery-grace` subcommand in `src/commands.ts` (show-or-set with a description string), and a row in the `/pruner settings` interactive overlay. Settings-parity requirement, not a new UX pattern - a user who can tune `minBatchChars` must be able to tune `recoveryGraceTurns` the same way. + +## Implementation + +### 1. `src/pruner.ts` - Phase 1 grace render + +`pruneMessages` gains the `recoveryGraceTurns` value (threaded from config alongside the existing `protection` argument). Before the map, compute a prefix count of user messages; `nowUTG` is the total. Inside the existing summarized-toolResult branch, after the `isProtected` skip (`pruner.ts:78`) and before building the stub: + +``` +if (recoveryGraceTurns > 0 && record?.toolName === QUERY_TOOL_NAME) { + const age = nowUTG - userTurnsAtOrBefore[i]; + if (age <= recoveryGraceTurns) return msg; // verbatim; raw toolResult already in branch +} +``` + +`msg` is returned unchanged, so an in-grace recovery output (spilled or not) stays verbatim. The `.map` index `i` keys into the prefix array. Error-result recovery calls (e.g. empty-arg `ctq({})`) are trivially small, so grace on them is a no-op; no special-casing. + +### 2. `src/chain-compressor.ts` - eligibility deferral + +`compressEligible` selects closed chains beyond the rolling window and drops all `middleToolCallIds` (`chain-compressor.ts:92,118`). Add a guard: a chain is **not** eligible while its span contains a `context_tree_query` toolCallId whose recovery is within grace. The compressor runs over the branch (in `flushPending` and `/pruner compact`) and can compute UTG the same positional way. Once the recovery ages out, the chain becomes eligible normally. This keeps the raw span verbatim during grace, consistent with Phase 1, and prevents the deterministic `/pruner compact` drop the council flagged. + +### 3. Config plumbing + +`recoveryGraceTurns` flows from `ContextPruneConfig` to the `pruneMessages` call site (`index.ts`, the `context` handler) and to the chain-compressor call sites, beside the values already passed there. No new event wiring, no new custom session-entry type, no summarizer change, no `addBatch`/`ToolCallRecord` change. + +## Testing + +Unit tests, matching existing `src/` test style. + +`src/pruner.ts` (Phase 1 render): +- Recovery output at `nowUTG` (age 0) -> returned verbatim. +- Recovery output aged exactly `K` UTG -> verbatim (`<= K`). +- Recovery output aged `K+1` UTG -> stubbed. +- `recoveryGraceTurns: 0` -> recovery output stubbed immediately at age 0 (pre-feature behavior). +- Non-`context_tree_query` output at age 0 -> stubbed (no global window). +- Spilled recovery output in grace -> verbatim (raw returned, sidecar stub not used); same output past grace -> spill-pointer stub. +- Recovery call also in `protectedTools` -> verbatim at any age (permanent protection precedence). +- Multi-ref / repeated recovery of the same ref -> each toolResult judged by its own position. + +`src/chain-compressor.ts` (eligibility): +- Chain whose span holds an in-grace recovery id -> deferred (not compressed). +- Same chain after the recovery ages out -> eligible and compressed normally. +- Chain with no recovery id -> unaffected. + +`normalize()`: +- `0` preserved (kill switch); `-1` / `NaN` -> default `3`; `2.7` -> floored to `2`; unset -> default `3`. + +Manual smoke-test (concrete repro, no private-session dependency): in an isolated `$PI_CODING_AGENT_DIR`, run a tool producing >1KB output, let it stub, then in a later user turn `context_tree_query` its ref; within K UTG the recovered output must stay verbatim across turn boundaries (inspect the rendered context / session JSONL), and past K UTG it must return to a stub. + +## Out of scope + +- **Empty-arg `context_tree_query({})` calls.** The corpus shows 475 (33.6% of all ctq calls) issued with no `toolCallIds` - the leaky "first call" that v2.0.1 tried to design out via the tool description. Separate defect (tool-call hygiene, not render policy); deferred to its own ticket. +- A general grace window over all tool outputs (rejected on the 77%-single-UTG data). +- Any change to the summarizer, dedup, spill, frontier, or the capture path. + +## Documentation impact +- Feature / user-facing docs introduced: none +- Materially amended existing docs: `README.md` (config + `/pruner` reference gains `recoveryGraceTurns` - tunable parameter); `PRUNING.md` (algorithm section notes the render-time bounded recovery exemption at Phase 1 + chain-eligibility deferral - non-obvious rationale, incl. why render-time over capture-time); `CHANGELOG.md` - deferred: release +- Derived / memory docs invalidated: none diff --git a/pi-condense/doc/specs/2026-07-06-summarizer-outage-fallback.md b/pi-condense/doc/specs/2026-07-06-summarizer-outage-fallback.md new file mode 100644 index 0000000..242d34e --- /dev/null +++ b/pi-condense/doc/specs/2026-07-06-summarizer-outage-fallback.md @@ -0,0 +1,239 @@ +# Summarizer outage fallback + +## Context + +pi-condense summarizes completed tool-call batches with an LLM (`src/summarizer.ts`, +`runSummarization`). Users commonly point `summarizerModel` at a cheap model (e.g. +`anthropic/claude-haiku-*`) distinct from the session's main model. Provider incidents are +routinely **per-model**: Anthropic's status page regularly shows one model (Haiku) degraded +while Sonnet/Opus stay green. + +Today, when the configured summarizer model fails at runtime, `runSummarization` catches the +error, fires `ctx.ui.notify(..., "error")`, and returns `null`. `index.ts` `flushPending` +restores the batch to `pendingBatches` and retries it **on the next flush against the same +dead model**. During a multi-hour single-model outage, pruning stalls for the whole outage +even though the session's main model (`ctx.model`) is healthy and already authenticated +in-session. No data is lost (raw outputs stay in context), but context grows unbounded until +the outage ends. + +## Problem + +The retry loop re-hits the one model that is down. There is no path that routes summarization +to a different, healthy model during a per-model outage. The result is stalled pruning and +unbounded context growth for the outage duration, despite a usable main model being available. + +## Idea + +Add a **sticky, in-memory fallback** to the session's main model (`ctx.model`) that engages +only on **transient** (outage-shaped) failures of the configured summarizer model, and returns +to the primary model automatically when it recovers. + +Non-goals (explicitly out of scope): + +- No new config knob. The fallback target is always `ctx.model`; the 10-minute re-probe + cooldown is an internal constant. A pin-a-specific-fallback-model config is YAGNI until + requested. +- No fallback on **auth** failures (bad/missing key is a config problem; `ctx.model` may use + different auth, so switching would mask a real misconfig). +- No fallback on **truncated/empty** results (`!isUsableSummary`); a one-off bad generation is + not an outage and must not flip the whole session onto the main model. +- No persistence. Fallback state is a live signal (like the `cost:external` channel), never + written as a `context-prune-*` session entry. +- Aborts (`signal.aborted`) remain re-thrown, exactly as today. + +### Architecture + +New module `src/summarizer-fallback.ts` exports a session-scoped `FallbackController` +(class). `index.ts` constructs one instance in the module scope of the extension and calls +`controller.reset()` from the `session_start` handler (matching how `indexer` / `statsAccum` +are reconstructed there), so each session starts with `inFallback=false`. State it holds: + +- `inFallback: boolean` +- `lastProbeAt: number` (epoch ms) +- an injected `now(): number` clock (defaults to `Date.now`) for deterministic tests + +Method surface (the wiring tests exercise these; a plain field holder cannot implement the +concurrency/dedup behavior below): + +- `reset(): void` - clears `inFallback`, `lastProbeAt`; called from `session_start`. +- `pickTarget(primary, ctx, isProbe): Model` - returns `primary` when `!inFallback || isProbe`, + else `ctx.model`. +- `claimProbe(batchIndex): boolean` - returns `true` for exactly the elected probe batch of a + flush when `inFallback && now - lastProbeAt >= COOLDOWN_MS`; advances `lastProbeAt` on claim + so no sibling batch in the same flush also probes. Idempotent per flush. +- `recordOutcome({ target, outcome, wasProbe }): void` - the single transition point; + `outcome` is one of `success | transient | auth | unusable | abort`. Applies the matrix + below and fires at most one notify per transition (gated on the `inFallback` flag flip, so N + concurrent batches produce one warning, not N). +- `notifier` - the controller is constructed with a `notify` callback (bound to `ctx.ui.notify`) + so transitions can message without importing `ctx`. + +Controller plumbing must reach **every** summarization call site, not just the parallel +fan-out. Enumerated call sites (verified in `index.ts` / `src/summarizer.ts`): + +1. `summarizeBatches` (parallel fan-out) - controller passed via `SummarizeBatchesOptions`. +2. The sequential progress path in `flushPending` (the `options.onProgress` branch, ~lines + 291-311) calls `summarizeBatch` per batch directly - controller passed via + `SummarizeBatchOptions`. +3. Range/chain fusion: `makeFuseRange` calls `summarizeRange(text, config, ctx, {})` and the + `/pruner compact` path via `chain-compressor` - both must pass the controller through + `summarizeRange`'s options. + +`isProbe` is a new field on `SummarizeBatchOptions`, set only by the caller that elected the +probe (see Probe / recovery). All three paths funnel into `runSummarization`, which calls +`pickTarget` -> runs -> `recordOutcome`. + +The controller is **inert** when no distinct fallback exists. `Model.provider` is a plain +string in `@earendil-works/pi-ai` (`Provider = KnownProvider | string`), not an object, so +"distinct fallback exists" is evaluated per call as: + +``` +ctx.model != null && ( primary.provider !== ctx.model.provider || primary.id !== ctx.model.id ) +``` + +where `primary = resolveModel(config, ctx)`. When they match (e.g. `summarizerModel:"default"`) +or `ctx.model` is unavailable, all controller logic is skipped and behavior is byte-for-byte +today's. + +### Failure classification + +A caught summarizer failure carries no structured status code: `runSummarization` either +throws `new Error(response.errorMessage ?? ...)` on `stopReason === "error"` or catches a raw +stream throw as `err.message` (`src/summarizer.ts` ~lines 160, 175). There is no reliable +5xx-vs-4xx signal to inspect, so a precise `isTransientError` classifier is **not buildable** +without brittle message string-matching, which this spec rejects. + +Classification is therefore **coarse**, keyed off the existing control flow rather than error +introspection: + +- `abort` - `options.signal.aborted` (re-thrown today; unchanged, never trips the controller). +- `auth` - the **pre-flight** `getApiKeyAndHeaders` failure branch (`src/summarizer.ts` + ~line 107) returns before any stream call. This is the common bad/missing-key case; it does + **not** reach the fallback path, so the "don't mask auth misconfig" non-goal holds for it. +- `unusable` - `!isUsableSummary` (empty or `stopReason === "length"`); returns `null` today, + never trips the controller. +- `transient` - **everything else** that reaches the catch (stream throw or + `stopReason === "error"`). This is the outage bucket that engages fallback. + +**Known bounded gap (accepted):** a key that passes pre-flight but is rejected by the provider +mid-stream surfaces as a generic throw and is bucketed `transient`, so it would flip to +`ctx.model`. This is rare (pre-flight already caught the usual bad-key case) and is not silent +- the enter-fallback warning fires. Falling back to the session model + notifying is an +acceptable degradation, not the masked-misconfig failure the non-goal targets. + +### State machine + +Per call, `target = controller.pickTarget(primary, ctx, isProbe)`: `primary` when +`!inFallback || isProbe`, else `fallback` (= `ctx.model`). After the call resolves, +`recordOutcome` applies the matrix below. Outcome x target grid (rows = classified outcome, +cells describe state change / notify / return for that target): + +| Outcome | target = primary (initial or probe) | target = fallback (steady-state) | +|---|---|---| +| `success` | if this was a probe: **recover** - `inFallback=false`, info notify. Else no transition. Return summary. | no transition; return summary. | +| `transient` | retry **once** on `fallback`. If retry `success`: if `!inFallback` **enter fallback** (`inFallback=true`, `lastProbeAt=now`, warning notify); if probe, **stay** (`lastProbeAt=now`, no notify). Return the fallback summary; suppress the legacy `error` notify. If retry also `transient`: **both-down** (see below). | **both-down**: keep legacy `error` notify, return `null`, set/keep `inFallback=true`, `lastProbeAt=now`. | +| `auth` | pre-flight: return `null` as today, **no** fallback retry, no transition (keeps legacy auth notify). | return `null`, legacy auth notify, no transition. | +| `unusable` | return `null` as today. If this was a probe, treat as probe-inconclusive: **stay**, `lastProbeAt=now` (do not recover on a truncated probe). Else no transition. | return `null`, no transition. | +| `abort` | re-throw; no transition. | re-throw; no transition. | + +Retry orchestration lives in `runSummarization`: a call targeting `primary` that returns +`transient` re-invokes the model run once against `fallback` in the same call before returning. +Calls already targeting `fallback` never retry. + +**both-down messaging:** the enter-fallback warning is suppressed on a both-down event (the +fallback attempt failed, so nothing is "using the session model" yet). To avoid the user never +learning the session flipped, the warning fires on the **first subsequent flush where a +fallback call succeeds** (i.e. the warning is owed until an actual successful fallback run +emits it once). + +Notify text (uses `model.name`; falls back to `provider/id` if `name` is empty): + +- warning: `pi-condense: summarizer model failing, using session model until it recovers` +- info: `pi-condense: summarizer model recovered` +- legacy (unchanged, `src/summarizer.ts` ~line 179): `pruner: summarization failed: `; auth variant ~line 110: `pruner: summarization failed: `. + +The legacy per-failure `error` notify is **suppressed when the fallback retry succeeds** +(otherwise the user gets error + warning for one event). It is kept for the both-down and +steady-state-fallback-failure cases. + +### Probe / recovery + +While `inFallback`, summarization keeps routing to `fallback`. On the **first flush after the +10-minute cooldown** (`now - lastProbeAt >= COOLDOWN_MS`), exactly **one** batch is elected as +the probe and routed to `primary`; the remaining batches stay on `fallback`. This bounds +steady-state doomed calls to **1 per cooldown interval**. + +Probe election is via `claimProbe(batchIndex)`, not a raw "index 0" assumption. `Promise.all` +gives no completion-order guarantee, so: + +- The **caller** iterating batches (before dispatch) calls `claimProbe` synchronously as it + builds each batch's options; the first eligible index wins and `lastProbeAt` advances + immediately, so no sibling in the same flush also claims. That batch's options carry + `isProbe: true`. +- Probe **outcome** handling runs in that batch's own `recordOutcome` (keyed to its result), + independent of when other batches settle - recovery is never triggered by a non-probe batch. +- Sequential progress path and single-batch / fusion calls: the probe is the **first eligible + call of the flush** (`claimProbe` on the first batch index / the fusion call). A fusion-only + flush may serve as the probe. + +Probe `success` -> recover; probe `transient` -> stay, that batch retries on fallback (no work +lost), cooldown already reset by `claimProbe`; probe `unusable` -> stay (inconclusive). + +`COOLDOWN_MS = 10 * 60 * 1000`, an internal constant in `src/summarizer-fallback.ts`. + +### Cost / blast radius (honest notes) + +- The **initial detection flush** can fire up to N primary calls: before the controller knows + the model is down, `inFallback` is false, so every batch in that flush targets `primary`, + fails, and retries on `fallback`. This is unavoidable - you cannot detect an outage without + attempting the model. Steady-state is **0** doomed calls; re-probe is exactly **1** per 10 + minutes. +- Fallback calls run on `ctx.model` and their cost is attributed to the summarizer exactly as + today (the `cost:external` / stats path is unchanged - it keys on the summarizer, not the + model id). +- A session restart mid-outage yields a fresh controller (`inFallback=false`), so the next + flush re-detects (one detection flush) and re-enters fallback. Acceptable per the + no-persistence non-goal. +- Whole-provider outages (both primary and `ctx.model` down) fall through to the both-down + `error` + `null` + retry-next-flush path. One behavior delta vs today: after such an outage + ends, primary is re-tried only on the next 10-min probe (not every flush), so summarization + runs on the (typically pricier) `ctx.model` for up to `COOLDOWN_MS` post-recovery before + probing back. Bounded and acceptable; not "zero change". + +## Testing approach + +Unit tests over `FallbackController` with an injected clock and simulated call outcomes +(`transient-fail` / `success`), plus targeted `summarizer.ts` wiring tests: + +- enter-fallback: primary transient fail -> fallback success flips `inFallback`, emits one + warning. +- single-notify: N concurrent batches all failing primary emit **one** warning, not N. +- steady-state: while `inFallback`, non-probe batches route straight to `ctx.model`, never + touch primary. +- cooldown gating: no probe before `COOLDOWN_MS`; exactly one probe batch after; `lastProbeAt` + advances on probe. +- recover: probe success clears `inFallback`, emits one info. +- probe-fail: probe transient fail keeps `inFallback`, resets cooldown, probe batch still + produces a summary via fallback. +- same-model no-op: `primary` equal to `ctx.model` -> controller inert, zero transitions, + identical to current behavior. +- both-down: primary + fallback both transient-fail -> `error` notify preserved, `null` + returned, `inFallback` set; owed warning fires on the next successful fallback call. +- both-down mixed error types (e.g. primary `stopReason==='error'`, fallback raw stream throw) + -> same both-down handling. +- auth / unusable: neither trips the controller; probe returning `unusable` stays in fallback + (no false recovery). +- abort: `signal.aborted` still re-throws, no transition. +- bypass coverage: sequential progress path, single-batch, and `summarizeRange` fusion calls + all consult the controller (probe election + transitions), not just `summarizeBatches`. +- restart-mid-outage: `reset()` clears state; next flush re-detects and re-enters fallback. + +## Documentation impact +- Feature / user-facing docs introduced: none +- Materially amended existing docs: PRUNING.md (new "Summarizer outage fallback" subsection - + state machine, transient-only trigger, sticky + 10-min re-probe, cost notes; clears the + materiality bar as durable rationale for non-obvious runtime behavior). README.md gets a + one-line mention in the summarizer/model section noting automatic fallback to the session + model during a summarizer-model outage (no config). +- Derived / memory docs invalidated: AGENTS.md "Project Layout" list (add `src/summarizer-fallback.ts`); + no customType table change (state is in-memory, not a `context-prune-*` entry). diff --git a/pi-condense/doc/specs/2026-07-09-summarizer-call-timeout.md b/pi-condense/doc/specs/2026-07-09-summarizer-call-timeout.md new file mode 100644 index 0000000..3102c25 --- /dev/null +++ b/pi-condense/doc/specs/2026-07-09-summarizer-call-timeout.md @@ -0,0 +1,195 @@ +# Summarizer call timeout (idle + ceiling) + +Date: 2026-07-09 +Status: proposed +Area: `src/summarizer.ts`, `src/types.ts`, `src/config.ts`, `src/commands.ts`, docs + +## Context + +`runOnce` in `src/summarizer.ts` drives a `stream()` call with exactly one escape hatch: `options.signal` (the user's Esc). It has no time budget. The stream loop `for await (const event of responseStream)` and the trailing `await responseStream.result()` both block indefinitely if the provider accepts the connection but stalls (no bytes, no close — common under provider congestion). + +The automatic flush paths (`turn_end`, `message_end`, `agent_end` in `index.ts`) call `flushPending` **without** a signal, and `flushPending` awaits summarization inline. So a stalled summarizer call hangs the whole agent turn with no user-visible cause and no recovery. + +Measured on this laptop (32,122 real assistant-generation latency samples across all sessions; dominant model `claude-opus-4-8`), a **legitimate** summarizer call is not "a handful of seconds": + +| Summarizer output size | median | p95 | p99 | observed max | +|---|---|---|---|---| +| ~800 tok (median call) | 12.9s | 29.3s | 40.7s | 331s | +| ~1.7-2k tok (p90-95) | 26.6s | 44.2s | 55.4s | 290s | +| ~2.8k tok (p99) | 40.7s | 62.7s | 76.3s | 256s | +| ~5k tok (max) | 64s | 92.5s | 124.6s | 208s | + +Per-call output-token distribution (11,092 samples from consecutive `context-prune-stats` deltas): median 798, p95 2032, p99 2779 tokens. + +Implication: a flat total-duration timeout that avoids false-aborting healthy calls would have to sit at ~200s+, and under the outage-fallback retry (below) a hang would then burn ~2x that before giving up. That is not an acceptable "short" bound. + +## Problem + +A stalled summarizer connection hangs the agent turn indefinitely. There is no timeout anywhere in the call chain, and the automatic flush paths pass no abort signal, so nothing can cancel it. + +## Idea + +Bound every summarizer call with a **two-part timeout inside `runOnce`**, driven by a local `AbortController` combined with the caller's `options.signal`: + +1. **Idle (inactivity) timeout — primary mechanism.** A healthy stream emits `text_start` / `text_delta` / `text_end` events continuously (sub-second inter-token gaps). A true hang emits nothing. Arm a timer for `summarizerIdleTimeoutMs` **before** the loop (this covers time-to-first-token) and reset it on every stream event. If it fires, abort the stream. Because it measures silence, not total time, it never false-aborts a legitimately long-but-flowing generation regardless of output size. +2. **Total-duration ceiling — backstop.** A single timer for `summarizerMaxTimeoutMs` armed once at call start, never reset. Catches the pathological "slow-drip-forever" stream that keeps emitting a token every N < idle seconds but never completes. Generous by design: it only clips calls beyond the observed legitimate p99. + +Both are configurable; `0` disables either independently. + +A timeout classifies as **`transient`** so it flows through the existing outage-fallback state machine in `runSummarization` (option B): when a distinct `summarizerModel` is configured, a primary that times out gets the one existing session-model retry (itself bounded by the same idle+ceiling timeout). This is **not** a new retry loop — it reuses the machine that already exists for provider outages. When `summarizerModel` is `"default"` (no distinct fallback), the single attempt times out, warns, and drops the batch (retried on the next natural flush; frontier does not advance). + +A timeout is surfaced to the user via `ctx.ui.notify(..., "warning")` with timeout-specific wording, distinct from the existing `"error"`-severity API-failure notice. + +### Values (grounded in the data above) + +| Setting | Default | Rationale | +|---|---|---| +| `summarizerIdleTimeoutMs` | `20000` (20s) | Healthy inter-token gaps are sub-second; the only real spike is TTFT under congestion (~<3s, occasionally ~10s). 20s gives comfortable headroom over TTFT while catching a silent hang fast. Values < 15s risk false-aborting TTFT spikes. | +| `summarizerMaxTimeoutMs` | `180000` (180s / 3 min) | Clears every observed p99 (max p99 was 124.6s) with headroom; only clips pathological multi-minute outliers (208-331s completed calls, i.e. provider degradation). Pure backstop. | + +Worst-case wall-clock for a genuine silent hang under option B: `idle` (primary stalls, ~20s) + `idle` (session-model retry also stalls, ~20s) = ~40s, then one warning + drop. The 180s ceiling only participates when a stream is dribbling (never goes fully idle) yet never finishes. + +## Design + +### Config (`src/types.ts`) + +Add two fields to `ContextPruneConfig` and `DEFAULT_CONFIG`: + +```ts +/** + * Idle (inactivity) timeout for a single summarizer stream call, in ms. + * Reset on every received stream event; armed before the first event so it + * also bounds time-to-first-token. If no event arrives within this window the + * call is aborted and classified transient (feeds the outage-fallback retry). + * 0 disables the idle timer. Default 20000. + */ +summarizerIdleTimeoutMs: number; +/** + * Total-duration ceiling for a single summarizer stream call, in ms. Armed + * once at call start, never reset — a hard upper bound that catches a stream + * that keeps dribbling events but never completes. Same transient/warning + * handling as the idle timeout. 0 disables the ceiling. Default 180000. + */ +summarizerMaxTimeoutMs: number; +``` + +`DEFAULT_CONFIG`: `summarizerIdleTimeoutMs: 20000`, `summarizerMaxTimeoutMs: 180000`. + +Add two preset arrays for the settings overlay (strings, `"0"` = disabled sentinel), mirroring the existing `*_PRESETS` pattern: + +```ts +export const IDLE_TIMEOUT_PRESETS = [ + { value: "0", label: "0 (disabled)" }, + { value: "10000", label: "10s" }, + { value: "20000", label: "20s (default)" }, + { value: "45000", label: "45s" }, + { value: "90000", label: "90s" }, +]; +export const MAX_TIMEOUT_PRESETS = [ + { value: "0", label: "0 (disabled)" }, + { value: "120000", label: "120s" }, + { value: "180000", label: "180s (default)" }, + { value: "300000", label: "300s" }, + { value: "600000", label: "600s" }, +]; +``` + +### Normalization (`src/config.ts`) + +In `normalize()`, clamp both like the existing `minBatchChars` guard: finite `number >= 0` → `Math.floor(value)`, else the default. `0` is a valid (disabling) value, so the guard is `>= 0`, not `> 0`. + +### `runOnce` (`src/summarizer.ts`) — the core change + +- Extend the existing `transient` variant with an **optional** `timedOut` field — it keeps `message` (do not drop it or add a new variant): + ```ts + | { kind: "transient"; message: string; timedOut?: boolean } + ``` +- Read the budgets from config at the top of `runOnce`: + ```ts + const idleMs = config.summarizerIdleTimeoutMs; + const maxMs = config.summarizerMaxTimeoutMs; + let timedOut = false; + let timeoutKind: "idle" | "ceiling" | null = null; + let idleTimerId: ReturnType | null = null; + let ceilingTimerId: ReturnType | null = null; + ``` +- `combineSignals` is a small local helper (inline in `summarizer.ts`), tolerant of an absent caller signal: + ```ts + function combineSignals(...signals: (AbortSignal | undefined)[]): AbortSignal | undefined { + const present = signals.filter((s): s is AbortSignal => !!s); + if (present.length === 0) return undefined; + if (present.length === 1) return present[0]; + return AbortSignal.any(present); // Node 20+; host runtime is node 24.5.0 + } + ``` + Build a local `AbortController` (`timeoutController`) and pass `stream(..., { signal: combineSignals(options.signal, timeoutController.signal), ... })`. +- Idle timer: a helper `bumpIdle()` that `clearTimeout(idleTimerId)` and, when `idleMs > 0`, re-arms `idleTimerId = setTimeout(() => { timedOut = true; timeoutKind = "idle"; timeoutController.abort(); }, idleMs)`. Call `bumpIdle()` once before the loop (covers TTFT), then inside the loop **on every received event** — not only `text_*`. Reasoning-capable summarizer models (`summarizerThinking` wires `reasoningEffort`) emit `thinking_start`/`thinking_delta`/`thinking_end` (pi-ai `types.d.ts:275-287`); bumping on any event keeps a legitimately-reasoning-but-quiet stream alive, so extended reasoning never false-aborts. `reportTextProgress` stays gated to `text_*` events (unchanged). +- Ceiling timer: `ceilingTimerId = setTimeout(() => { timedOut = true; timeoutKind ??= "ceiling"; timeoutController.abort(); }, maxMs)` when `maxMs > 0`, armed once at call start. +- Clear both timers in a `finally` (`clearTimeout(idleTimerId); clearTimeout(ceilingTimerId);`) so no timer leaks after the call returns or throws. +- Classification (centralize so no transient site escapes it): a timeout abort can surface either as a throw in the catch OR as `response.stopReason === "error"` (pi-ai's error event, `types.d.ts:307`). At **every** transient return site — the `stopReason === "error"` branch and the catch — set `timedOut`/`timeoutKind` into the outcome: if `timedOut && !options.signal?.aborted`, return `{ kind: "transient", timedOut: true, message: }` — e.g. `` `summarizer ${modelLabel(model)} stalled (no output for ${idleMs / 1000}s)` `` for idle, `` `summarizer ${modelLabel(model)} exceeded ${maxMs / 1000}s ceiling` `` for ceiling. A simple way: compute the timeout message once from `timeoutKind` and prefer it whenever `timedOut` is set, at both sites. +- User abort is unchanged: `options.signal?.aborted` still short-circuits and re-throws so `flushPending` restores state silently. The timeout controller aborting does **not** set `options.signal.aborted` (it is a separate controller), so a timeout is never misclassified as a user abort. + +### `runSummarization` (`src/summarizer.ts`) — warning severity + +Replace the bare `notifyError` sites with a helper that picks severity from the outcome. Wording is **path-neutral** — `runSummarization` is shared by `summarizeBatch` and `summarizeRange`, and a timed-out range fusion neither "drops a batch" nor "retries next flush" (it falls back to per-batch concat at render, see below), so the message must not assert batch semantics: + +```ts +const notifyFailure = (o: { message: string; timedOut?: boolean }) => + ctx.ui.notify( + o.timedOut + ? `pi-condense: ${o.message}; summarizer call abandoned` + : `pruner: summarization failed: ${o.message}`, + o.timedOut ? "warning" : "error", + ); +``` + +Threaded through: +- Legacy no-fallback path: `case "transient": notifyFailure(r); return null;` +- Fallback path, fallback-only fail: `notifyFailure(r)`. +- Fallback path, both-down: `notifyFailure(r2.kind === "transient" || r2.kind === "auth" ? r2 : r)` — preserves today's exact message-selection (the auth arm must stay: a session-model auth failure after a primary timeout should surface the auth error, not the stale timeout message) and carries whichever `timedOut` flag applies. +- Fallback rescue (primary times out, session model succeeds): no failure notice; the existing `"enter"` fallback warning (`summarizer model X failing, using session model Y until it recovers`, already `"warning"` severity) fires via `emit`. A timeout is treated exactly like any transient here. **Scope note:** the "every timeout gets timeout-specific wording" guarantee applies to **non-rescued** timeouts only; a rescued primary timeout is reported solely as the generic fallback-entry warning. This is intentional — the rescue path is not a failure and does not warrant a second, redundant notice. + +No change to the `FallbackController` itself: a timeout is a transient, and the controller already handles transient primary → session retry → sticky-until-probe. Option B falls out of the existing machine for free. + +### Commands / UI (`src/commands.ts`) + +- **Imports:** export `IDLE_TIMEOUT_PRESETS` / `MAX_TIMEOUT_PRESETS` from `src/types.ts` and import them in `src/commands.ts` alongside the existing `MIN_BATCH_CHARS_PRESETS` etc. +- **SettingsList entries:** add two entries in the settings overlay (placed after `recoveryGraceTurns`), each using its preset array via `values: PRESETS.map((p) => p.value)` with the same `currentValue`-in-cycle fallback as `minBatchChars` (fall back to the default preset value when the stored value is not in the cycle). Description text states the disabling sentinel (`0`) and the idle-vs-ceiling distinction. +- **`onChange` wiring (required):** every setting has an explicit `id`-matched branch in the overlay's `onChange` handler; a `SettingsList` entry without one renders but never persists. Add branches for `summarizerIdleTimeoutMs` and `summarizerMaxTimeoutMs` that `parseInt` the new value, clamp `>= 0` (fall back to the default on NaN/negative), assign to `newConfig`, and refresh the description — mirroring the `minBatchChars` branch. +- **`/pruner status`:** append two lines alongside the existing model/thinking lines, formatted `idle timeout: 20s` / `max timeout: 180s`, rendering `disabled` when the value is `0`. +- **Out of scope:** dedicated `/pruner idle-timeout` / `/pruner max-timeout` subcommands. The settings overlay + `settings.json` are the surfaces; a bespoke subcommand each is YAGNI. Add later if asked. + +## Error handling and edge cases + +- **Idle disabled (`0`), ceiling set:** only the ceiling timer arms. The ceiling `setTimeout` is armed at call start and fires on schedule regardless of stream activity (JS timers are not blocked by a pending `await`), so even a stream that never yields its first event is still caught — at the ceiling (up to 180s), not fast. Accepted; it is the user's explicit choice. With **both** disabled there is no first-token bound at all (see next). +- **Both disabled (`0`/`0`):** exact current behavior (no timeout), including no first-token bound — a stream that never yields hangs indefinitely, the pre-feature bug. Preserved deliberately as the escape hatch for anyone who hits false aborts. +- **Reasoning summarizer models:** because the idle timer resets on `thinking_*` events too (not just `text_*`), a model doing long extended reasoning before emitting text keeps the idle timer alive and is not false-aborted. Residual risk only if a provider streams reasoning with inter-event gaps beyond `summarizerIdleTimeoutMs`; mitigation is to raise it. +- **User Esc during a bounded call:** `options.signal` fires; the combined signal aborts the stream; the existing `options.signal?.aborted` checks re-throw the abort so `flushPending` restores state. Not classified as a timeout (no warning). +- **Timeout fires after the stream already completed:** the `finally` clears both timers on the normal-completion path before they can fire; a race where the timer fires between `result()` resolving and cleanup is harmless (the outcome is already built; aborting a settled stream is a no-op). +- **Parallel batches (`summarizeBatches`):** each `runOnce` owns its own timeout controller, so one batch timing out does not abort its siblings; each is independently classified. +- **Range fusion (`summarizeRange`) and per-batch both** route through `runSummarization` → `runOnce`, so both inherit the timeout with no extra code. On a range-fusion timeout, `runSummarization` returns `null`; `compressEligible` then proceeds **without** `rangeSummaryText`, and the renderer falls back to the per-batch concatenation (per `ChainCompressionEntry.rangeSummaryText` semantics). The chain is still compressed — only the fused summary is skipped. This is why the warning wording is batch-neutral. +- **Timer leak:** `finally` clears both `setTimeout` handles on every exit path (success, unusable, transient, throw). + +## Testing approach + +`bun test src/` — extend `src/summarizer-wiring.test.ts` (its `mock.module` swap of `stream` and note-capturing `ctx` are the right harness). **Mock-harness change required:** the current stub is `stream: (model: any) => streamImpl(model)`, which drops the options/signal argument, so a hanging stream cannot observe the abort. Change it to forward all args — `stream: (...args: any[]) => streamImpl(...args)` — and type `streamImpl` as `(model, input, opts)` so helpers can read `opts.signal`. Add a `hangingStream(signal)` helper whose async iterator awaits a promise that rejects when `signal` aborts, and whose `result()` likewise never resolves until abort. Drive with a tiny `summarizerIdleTimeoutMs` (e.g. `20`) so tests run fast. + +Cases: +1. **Idle timeout → transient + warning.** `summarizerModel: "default"`, hanging stream, `summarizerIdleTimeoutMs: 20`. Assert `summarizeBatch` returns `null`, one note at level `"warning"` whose text mentions stalling. +2. **Ceiling timeout → transient + warning.** Idle disabled (`0`), tiny `summarizerMaxTimeoutMs`, a stream that dribbles events forever (never idles). Assert ceiling warning fires. +3. **Option B: primary idle-times-out, session model rescues.** Distinct `summarizerModel`, controller present; `streamImpl` returns a hanging stream for `PRIMARY` and an `okStream` for `SESSION`. Assert result is the session summary, `"enter"` fallback warning present, no failure notice. +4. **Both time out → both-down warning.** Distinct model; both models hang. Assert `null` + `onBothDown`-path warning at `"warning"` level. +5. **User abort is not a timeout.** Pre-aborted `options.signal`; assert the call throws (propagated abort) and no `"warning"` note is pushed. +6. **Disabled (`0`/`0`) → no timer, unchanged behavior.** Existing ok/err wiring tests still pass with the new fields defaulted; add one asserting an `okStream` succeeds with both timeouts `0`. + +## Documentation impact +- Feature / user-facing docs introduced: none. +- Materially amended existing docs: `doc/configuration.md` and `CHANGELOG.md` (one entry). `README.md` settings table lists only a subset, so no change there. The exact `doc/configuration.md` deltas: + - Two rows in the "Every key" table: + ``` + | `summarizerIdleTimeoutMs` | non-negative integer (ms), `0` disables | `20000` | Abort a summarizer stream call after this much silence (no stream event). Resets on every event, so it never false-aborts a flowing generation; catches a stalled connection fast. A timeout feeds the same outage-fallback retry as a provider error. `0` = no idle bound. | + | `summarizerMaxTimeoutMs` | non-negative integer (ms), `0` disables | `180000` | Hard ceiling on total duration of a single summarizer stream call. Backstop for a stream that dribbles forever without going idle. Generous by design (clears the observed p99). `0` = no ceiling. | + ``` + - Two keys added to the "Full settings JSON" sample (after `summarizerThinking`): `"summarizerIdleTimeoutMs": 20000,` and `"summarizerMaxTimeoutMs": 180000,`. + - A short "Summarizer timeouts" subsection near the existing outage-fallback note (line ~98): idle-vs-ceiling distinction, that a timeout classifies as transient and feeds the same session-model fallback retry, and that both tries are timeout-bounded. +- Derived / memory docs invalidated: none. (`AGENTS.md` project-layout and customType tables are unaffected — no new session entry types, events, or `src/*.ts` files; the change is confined to existing files.) diff --git a/pi-condense/doc/specs/2026-08-04-pruner-noop-serialization.md b/pi-condense/doc/specs/2026-08-04-pruner-noop-serialization.md new file mode 100644 index 0000000..2688d9d --- /dev/null +++ b/pi-condense/doc/specs/2026-08-04-pruner-noop-serialization.md @@ -0,0 +1,90 @@ +# Pruner no-op serialization + fast-path comment fix + +Fixes [issue #4](https://github.com/jjuraszek/pi-condense/issues/4). Two coupled corrections in the render-time prune path plus a folded-in dependency refresh. + +## Problem + +`pruneMessages` (`src/pruner.ts`) runs on every main-loop render. It computes `const beforeChars = sizeMessages(messages)` **unconditionally** at line 72 - a full `JSON.stringify(messages).length` over the entire context - before any pruning phase runs. On a no-op render (nothing to prune) that serialization is pure waste: the result is returned as both `beforeChars` and `afterChars` but the sole consumer never reads them on the no-op path. + +Cost is CPU/GC only (zero token cost), and it is narrower than it first looks: at large context under the default config, phase 4 (thinking-strip) fires most turns, so `pruned` is usually `true` and `beforeChars` **is** consumed. The wasted serialization bites on small sessions and genuine no-ops. + +Separately, the fast-path comment at `index.ts:820-823` is wrong: it claims the fast path triggers "when both the tool-call index and chain registry are empty." Phases 2 (error-purge) and 4 (thinking-strip) prune independently of index/registry state, so that emptiness does not imply a no-op. The comment is worth correcting regardless of the serialization fix, and the two belong in one change. + +## Non-goals + +- **Removing the phase-1 `.map()`** (`src/pruner.ts:76`). It allocates an N-pointer array, not a deep copy; skipping it needs a pre-scan and is not worth it. Asymptotically it is O(n) pointer copies against the O(total serialized bytes) of the `JSON.stringify` this spec removes - a different order of magnitude, so the `.map()` is not the win here. +- **Coupling to issue #1** ("fast-path predicate covers all four phases"). Speculative; dropped. +- **CI node pinning.** `test.yml` is bun-only (no node pin) and `release.yml` pins node 24; leave both as-is. +- **Any Python work.** There is no Python in this repo (confirmed: no `.py`, `pyproject.toml`, `requirements.txt`, `.python-version`, mise/`.tool-versions`, or Python in CI/scripts). pi-condense is pure TS/Bun/Node. + +## Ground truth (verified against source) + +- `src/pruner.ts:71` return type is inline: `{ messages: any[]; pruned: boolean; beforeChars: number; afterChars: number }`. JSDoc (lines 33-61) documents phases + `pruned`/`messages` but not `beforeChars`/`afterChars`. +- `src/pruner.ts:161` currently returns `{ messages: current, pruned, beforeChars, afterChars: pruned ? sizeMessages(current) : beforeChars }`. +- `sizeMessages` (`src/pruner.ts:15-17`): `JSON.stringify(messages).length`; deliberately serializes hidden fields (thinking, tool args, tool-result arrays). +- **Mutation invariant (load-bearing):** no phase mutates the input `messages` in place. Phase 1 builds `next` via `.map()` and sets `current = pruned ? next : messages`; phase 2 (`error-purge.ts:60`) returns `messages` unchanged when inactive; phase 3 (`chain-range-prune.ts`) builds a fresh `out`; phase 4 (`thinking-strip.ts:37`) returns `messages` unchanged when inactive. Therefore `sizeMessages(messages)` evaluated at return time equals its value at entry - moving the call into the pruned branch is behavior-preserving. +- **No-op path is `JSON.stringify`-free apart from the `beforeChars` line:** with default `recoveryGraceTurns=0`, `inGraceRecoveryToolCallIds` returns immediately without serializing; phase-1 `.map` and all phase gates use no serialization. So removing line 72's call makes a no-op perform zero `JSON.stringify` over the message array. + +### Consumers of `beforeChars` / `afterChars` + +- `index.ts:836` `statsAccum.setLiveReclaim(result.beforeChars, result.afterChars)` - **gated on `result.pruned`** (`index.ts:835`). Never runs on a no-op, so `{0,0}` on the no-op path is never read. +- `src/commands.ts:63` `pruneStatusText` reads the stored `LiveReclaim` and guards `if (!reclaim || reclaim.beforeChars <= 0) return "prune: ON"`. `LiveReclaim` (`src/types.ts:695-698`) is only ever fed by `setLiveReclaim`, i.e. only from the pruned path. +- `commands.test.ts:28-29` already asserts `{beforeChars:0, afterChars:0} -> "prune: ON"`. The `{0,0}` sentinel is already an accepted input shape downstream. + +## Design + +### 1. Core fix - `src/pruner.ts` + +Remove the unconditional `const beforeChars = sizeMessages(messages)` at line 72. Compute both sizes lazily in the pruned branch only, and return the `{0,0}` sentinel on no-op: + +```ts +return pruned + ? { messages: current, pruned, beforeChars: sizeMessages(messages), afterChars: sizeMessages(current) } + : { messages, pruned, beforeChars: 0, afterChars: 0 }; +``` + +Net effect: a no-op render performs zero `JSON.stringify` over the message array. The pruned path is unchanged (still two serializations: input and output). This is **non-breaking** for consumers: the return shape is identical, and the only reader of the size fields (`index.ts:836`) is gated on `result.pruned`, so it never observes the `{0,0}` sentinel; `pruneStatusText` already treats `beforeChars <= 0` as "no reclaim data yet". + +Approaches considered: (A) `{0,0}` sentinel - chosen; (B) lazy getter fields; (C) optional fields dropped on no-op. B and C add type churn and touch consumers for a CPU-only win. A keeps the flat return shape and matches the issue AC. + +### 2. Field-semantics JSDoc - `src/pruner.ts:33-61` + +Extend the existing JSDoc to document the two size fields: when `pruned` is `true` they are the serialized-context sizes before and after pruning; when `pruned` is `false` both are `0` - a no-op sentinel, not a measurement. Match the existing comment style in the file (no banner comments). + +### 3. Fast-path comment fix - `index.ts:820-823` + +Replace the inaccurate claim ("fast path when the tool-call index and chain registry are empty") with the truth: phases 2 (error-purge) and 4 (thinking-strip) can prune regardless of index/registry state, so `pruned` reflects any of the four phases firing - index/registry emptiness alone does not imply a no-op. + +### 4. Dependency refresh - `package.json` + +- `engines.node`: `>=20.3.0` -> `>=22.19.0`, matching the host pi runtime (`@earendil-works/pi-coding-agent@0.83.0` declares `engines.node: >=22.19.0`). This is the one real "match current pi recommendation" gap; it drops advertised support for Node 20/21, which pi itself no longer runs on. Consumer impact: no CI or runtime impact (the test/release pipeline is Bun-only), but `npm install` under Node <22.19.0 will now warn (or fail under `engine-strict`) - acceptable, since such a Node can't run the host pi anyway. +- Preserve `package.json`'s trailing newline when editing (it ends with `}\n` in HEAD); do not let an editor strip it. +- `@sinclair/typebox`: floor `^0.34.49` -> `^0.34.52` (latest; the caret already resolved to `.52`, so this is a freshness-only bump with no resolution change). +- `@earendil-works/pi-coding-agent`, `pi-ai`, `pi-tui`: left at `^0.83.0` (already the latest published; == host runtime). + +This is an independent workstream from the pruner fix, folded into one change at the user's request; both are small. + +## Edge cases + +- **Empty input** (`messages = []`): no-op path returns `{0,0}`. Correct - no serialization needed. +- **Reverted fix regression:** if a future change moves `sizeMessages(messages)` back to the unconditional path, `beforeChars` becomes nonzero on a no-op and the rewritten test (below) fails. This is the intended guard. +- **`prune: ON` status:** unaffected - `pruneStatusText` already treats `beforeChars <= 0` as "no reclaim data yet" and the live-reclaim store is only written from the pruned path. + +## Testing + +Runner: `bun test src/` (per AGENTS.md). + +- **Rewrite `src/pruner.test.ts:593-601`** (the no-op case). It currently asserts `beforeChars === afterChars === sizeMessages(input)` - the exact behavior being removed. New assertion, with a **non-empty** input: no-op returns `{ pruned:false, beforeChars:0, afterChars:0 }`. This asserts the return contract, not the serialization-skip directly. What it catches: the realistic regression where `sizeMessages(messages)` is moved back to the unconditional path *and consumed* into `beforeChars` (the pre-fix code shape) - the non-empty input then yields nonzero and the test fails. Accepted residual gap: a contrived regression that calls `sizeMessages(messages)`, discards the result, and still returns the `{0,0}` sentinel would pass this test while re-introducing the CPU cost. A `JSON.stringify` call-count spy would close that gap but was **rejected** as disproportionate for a CPU-only micro-opt (it would also couple the test to every phase's internal gating and false-positive on any future legitimate cheap serialization on the path). This is an informed decision, not an oversight. +- **Pruning-path test (`src/pruner.test.ts:603-623`)** stays valid and unchanged - it asserts real `beforeChars`/`afterChars` values on a pruning render. +- **`sizeMessages` test (`src/pruner.test.ts:571-589`)** unchanged. + +Verification commands: +- `bun test src/` - all green. +- Typecheck per AGENTS.md: `bun x tsc --noEmit --target es2022 --module nodenext --moduleResolution nodenext --strict --skipLibCheck --allowJs --esModuleInterop --resolveJsonModule --lib es2022 --types node index.ts`. +- `bun install --no-save` - resolves clean under the bumped `engines.node` and typebox floor. Use `--no-save` (or delete the generated `bun.lock` afterward): the repo tracks no lockfile and `.gitignore` excludes only `package-lock.json`, so a bare `bun install` under Bun 1.3.x leaves an untracked `bun.lock`. +- `git status --porcelain` after the above - confirm no stray files (e.g. `bun.lock`) beyond the intended edits. + +## Documentation impact +- Feature / user-facing docs introduced: none +- Materially amended existing docs: CHANGELOG.md (fix entry: no-op zero-serialization + corrected fast-path comment; plus `engines.node` bump to `>=22.19.0` and typebox floor bump). `src/pruner.ts` JSDoc field semantics (implementation surface, in the same change). +- Derived / memory docs invalidated: none (no AGENTS.md routing, PRUNING.md algorithm, or README config surface changes - the return shape and phase behavior are unchanged, only the no-op field values and an inaccurate comment). diff --git a/pi-condense/doc/specs/2026-08-04-thinking-strip-flush-gated.md b/pi-condense/doc/specs/2026-08-04-thinking-strip-flush-gated.md new file mode 100644 index 0000000..b06029f --- /dev/null +++ b/pi-condense/doc/specs/2026-08-04-thinking-strip-flush-gated.md @@ -0,0 +1,164 @@ +# Flush-gated thinking strip (timestamp-keyed boundary) + +> **Superseded by:** [doc/specs/2026-08-05-remove-thinking-strip.md](./2026-08-05-remove-thinking-strip.md) - fully + +Issue: [jjuraszek/pi-condense#3](https://github.com/jjuraszek/pi-condense/issues/3) - "thinkingStrip advances its window every render, so the prompt cache falls back to tools+system on ~57% of turns". + +Status: accepted (brainstorm). Target: v2.4.x. Worktree: `fix/thinking-strip-flush-gated`. + +## Problem + +Phase 4 of `pruneMessages` (`stripOldThinking`, `src/thinking-strip.ts`) derives its keep-window from the **live assistant count in the current array on every `context` render**. As turns advance, the `(count - keepLastTurns)`-th assistant slides forward one turn per render, so the message that falls out of the window has its thinking stripped - a mutation **deep in history**, not at the tail. + +pi-ai's serializer sets prompt-cache breakpoints only at `tools + system + last message` (no in-history breakpoint). Verified in `node_modules/@earendil-works/pi-ai/dist/api/anthropic-messages.js` `convertMessages`: `cache_control` is attached to exactly the system prompt block, the last tool definition (`index === tools.length - 1`), and the last conversation message ("Add cache_control to the last user message to cache conversation history"). A deep-history mutation therefore invalidates the entire cached suffix after `tools + system`. Because phase 4 mutates on *every* render, the cache busts on roughly every render inside a tool loop - the ~57% tools+system fallback the issue reports. + +The behaviour (bounding retained thinking to the last K turns) is wanted; unbounded thinking bloats context. Only the **cadence** (per-render) and the **documented cost model** are wrong. + +## Goals + +- Phase 4 **does not mutate any previously-rendered historical message between flushes** - so the shared historical prefix of consecutive `context` renders is byte-stable and the cache prefix survives a tool loop. (Not "the whole array is byte-identical": a tool loop appends a new toolCall/toolResult at the tail before each render, so the arrays differ at the tail by construction; the invariant is about the *unchanged prefix*, and `error-purge` remains an independent, out-of-scope live-count mutator - see [Cache economics](#cache-economics).) +- Retained-thinking window stays anchored to `keepLastTurns` (preset semantics preserved: 4/8/16/32/64). +- No new config key. +- `PRUNING.md` "Cache impact" corrected. +- Existing 12 `thinking-strip.test.ts` tests pass unchanged. + +## Non-goals (out of scope) + +- **No cadence/stride knob.** The core fix already turns k-busts-per-request into 1 (see [Cache economics](#cache-economics)); a stride is a modest, workload-dependent further lever, deferred until telemetry justifies it. If ever added, it is expressed in **turns**, not messages (self-adjusts to variable request length; `keepLastTurns` is already in turns). +- **No change to default budget knobs** (`autoBudgetThreshold` / `budgetTurnDelta` stay `null`). +- **Not** reusing `PruneFrontier.lastAttemptedTimestamp` for the window (rejected in Q1 - it is a batch-capture wall-clock time at the *front* of the un-summarized region, coupled to prune progress and `rollingWindow`, unrelated to `keepLastTurns`; reusing it would deprecate `keepLastTurns`). + +## Design overview + +The strip boundary stops being derived per-render and becomes a **flush-computed, persisted assistant-message timestamp** that only moves forward at flushes. Everything else in phase 4 (all-or-nothing per-message strip, same-ref fast paths, order/length preservation) is untouched. + +- **Compute at flush** (`flushPending`): `boundary = timestamp of the (assistantCount - keepLastTurns)-th assistant message`, over the raw session branch plus the not-yet-persisted `closingMessage`, clamped monotonic non-decreasing. Stored on the existing frontier snapshot. +- **Consume at render** (`context` handler -> `pruneMessages` -> `stripOldThinking`): strip thinking from every assistant message with `timestamp < boundary`. Between flushes the boundary is a fixed number -> byte-identical output. +- **Persist** on the existing `context-prune-frontier` entry - no new customType, no new persistence layer, no new config key. + +Boundary keyed by **message timestamp**, not array index: phase 3 (`chain-range-prune`) drops middle messages, so an absolute index into a shrinking array is a landmine. Timestamp identity is the precedent phase 3 already uses (`msg.timestamp`). + +## Components and signatures + +Five touch points. All new params are **optional**; absent/`undefined` -> the current live-count logic runs verbatim (this is what keeps the 12 existing tests green and handles genuinely pre-first-flush and pre-feature session entries). Feature-written frontier entries must survive session reload - see `src/frontier.ts` below; without that change every resumed session drops the boundary and falls back to the buggy per-render path until its next flush. + +### `src/types.ts` - one new frontier field +Add to `PruneFrontier`: +```ts +thinkingStripBoundaryTimestamp?: number; +``` +Rides the existing `CUSTOM_TYPE_FRONTIER` entry. No change to `ThinkingStripConfig` (`{ enabled, keepLastTurns }`) or `DEFAULT_CONFIG`. + +### `src/frontier.ts` - persist the field across reload +`PruneFrontierTracker.fromJSON` (frontier.ts:22-40, verified) reconstructs the frontier by **explicitly whitelisting** known fields, so a field added only in `types.ts` is silently dropped on every `session_start` / `session_tree` reconstruct. Add one line inside `fromJSON`: +```ts +thinkingStripBoundaryTimestamp: data.thinkingStripBoundaryTimestamp, +``` +(Left `undefined` when absent, which the render path already treats as the live-count fallback.) This is a **blocker**: without it the fix regresses to the per-render bug on every resumed session, not just genuinely pre-feature ones. Requires a persist-then-`fromJSON` round-trip test asserting the field survives. + +### `src/thinking-strip.ts` - boundary input + pure helper +- `stripOldThinking(messages, config, boundaryTimestamp?)` - new optional 3rd arg. + - `boundaryTimestamp` is a `number` -> strip thinking from every assistant message with `msg.timestamp < boundaryTimestamp` (all-or-nothing per message; same same-ref fast paths). + - `undefined` / `null` -> **fall back to the current live-count logic verbatim.** +- `computeThinkingBoundary(assistantTimestamps, keepLastTurns, prev?)` (new, pure, unit-testable): + - Input is the ordered list of assistant-message timestamps (raw branch + `closingMessage` when it is an assistant message). + - **Clamp `keep = Math.max(1, keepLastTurns)` before indexing** - mirrors the existing `stripOldThinking` clamp (thinking-strip.ts:25) and the locked `keepLastTurns=0` contract (thinking-strip.test.ts:99). Without it `keepLastTurns=0` indexes `assistantTimestamps[count]` (out of bounds -> `undefined`/`NaN` boundary that then gets persisted). + - `count <= keep` -> return `prev` (nothing to strip yet; do not regress). + - else `candidate = assistantTimestamps[count - keep]`; return `max(prev ?? candidate, candidate)` (monotonic non-decreasing = advance-only). + +### `src/pruner.ts` - thread the boundary through +- `pruneMessages(messages, indexer, chainCompression?, errorPurge?, thinkingStrip?, protection?, recoveryGraceTurns=0, thinkingBoundaryTimestamp?)` - one new trailing optional param, passed straight into the phase-4 call at pruner.ts:154 (`stripOldThinking(current, thinkingStrip, thinkingBoundaryTimestamp)`). + +### `index.ts` - source at render, compute at flush +- **Render** (`context` handler, index.ts:824): pass the persisted boundary as the new trailing arg - + ```ts + pruneMessages(messages, indexer, ...existing args..., frontier.get()?.thinkingStripBoundaryTimestamp) + ``` + which reaches phase 4 as `stripOldThinking(current, thinkingStrip, thinkingBoundaryTimestamp)`. +- **Flush** (`flushPending`, at the `frontierSnapshot` construction, index.ts:489): when `thinkingStrip.enabled`, gather assistant timestamps from the session branch, using the exact unwrap precedent already in-repo (index.ts:527-530): + ```ts + const branchMessages = ctx.sessionManager.getBranch() + .filter((e: any) => e.type === "message" && e.message) + .map((e: any) => e.message); + ``` + This must run **regardless of `chainCompression.enabled`** - the existing occurrence is inside the `chainCompression.enabled` branch, so the boundary computation needs its own independent call. Include `options.closingMessage` when it is a not-yet-persisted assistant message (agent-message mode fires `message_end` before pi persists it), take assistant-message timestamps in order, call `computeThinkingBoundary(..., prev = frontier.get()?.thinkingStripBoundaryTimestamp)`, and set `thinkingStripBoundaryTimestamp` on `frontierSnapshot`. It then persists via the existing `frontier.advance(frontierSnapshot)` + `context-prune-frontier` write. When disabled, leave the field carrying `prev` (harmless; phase 4 ignores it). + +## Data flow + +- **Render (every provider request):** read `frontier.get()?.thinkingStripBoundaryTimestamp` -> `pruneMessages` -> phase 4 strips against that fixed value. Between flushes the value is identical -> byte-identical output -> cached prefix survives the whole tool loop. +- **Flush (`flushPending`, non-empty batches only):** the boundary is computed and stored inside the *same* `frontierSnapshot` transaction that already advances the prune frontier and writes `context-prune-frontier`. This is the [Q2 decision](#boundary-cadence-q2): advance on **every non-empty flush**. The `batches.length === 0` early-return (index.ts:176) means a pure-text turn with no captured batches does **not** advance the boundary (nothing to summarize either). +- **`session_start` / `session_tree`:** the frontier reconstructs from the persisted entry (existing behaviour); the new field rides along. Entries written before this change lack the field -> `undefined` -> live-count fallback until the first new flush. + +## Cache economics + +The premise "the boundary still moves once per request, so the cache still busts" is true but must be read as *per request*, not *per render* - that distinction is the whole win: + +| Design | Thinking-strip cache busts | Thinking retained | +|---|---|---| +| Today (bug: advance every render) | **k per request** (k = tool-loop length, ~5-30 renders) | `keepLastTurns` | +| This spec (advance every non-empty flush) | **1 per request** | `keepLastTurns` (drifts up to `keepLastTurns + turns-since-flush` between flushes) | +| (deferred) flush-gated + turns-stride M | ~1 per M turns of growth | `keepLastTurns + up to M` | + +The retained-window "drift up" between flushes is deliberate, not a leak: freezing the boundary is exactly what keeps renders prefix-stable, and the extra thinking is re-read from cache at ~0.1x until the next flush snaps the window back to `keepLastTurns`. Separately, `error-purge` (`purgeErroredArgs`, error-purge.ts:20) derives its cooldown from a **live per-render assistant count** and can rewrite an old assistant's toolCall arguments with no flush involved - an independent live-count history mutator outside this fix's scope, so a literal whole-array-identity claim would be false regardless. That is why the goal/AC are scoped to phase 4's prefix stability, not global byte-identity. + +Grounded in the code, the residual 1-per-request bust is **not** always free: + +- Phase-1 stub-replace keys off `indexer.isSummarized` (pruner.ts:78), a cumulative set; `rollingWindow` gates only phase-3 chain compression, not per-batch summarization. At a flush the current request's batches are summarized immediately, so at the next render they turn to stubs. The oldest such change sits at ~*start of the just-finished request*. +- **Short request (< `keepLastTurns` turns, the common interactive case):** start-of-request is newer than `tail - keepLastTurns`, so the thinking boundary is the **deeper, dominant** invalidator - not subsumed by summarization. Residual: ~1 deep reprocess per request. +- **Long request (>= `keepLastTurns` turns):** summarization's stub-replace reaches older than the thinking boundary -> thinking-strip is subsumed -> ~0 marginal bust. + +Net: flush-gating removes the k-per-request busts (the ~57% figure) unconditionally, and leaves at most one deep reprocess per short request. The steady-state cost of *not* adding a stride knob is that ~1/request reprocess in high-frequency short-request sessions; the cost of adding one would be a config knob plus more retained thinking read from cache every call. Deferred by decision (option 1). + +## Boundary semantics + +**Stateless recompute, not a running counter.** At each flush the boundary is recomputed from scratch as the `(count - keepLastTurns)`-th assistant timestamp over the raw branch (+ `closingMessage`). There is no "turns since last flush" tally to maintain or reset - the recompute *is* the re-anchor. This matches the current live-count mechanism and how the frontier reconstructs on `session_start`, and it self-corrects for free on session reload. (Relative-advance `prev + (count_now - count_prev)` collapses to `count_now - keepLastTurns` anyway, so the stateful form buys nothing and adds reload/rebuild failure modes.) + +**Retained window over time:** `keepLastTurns` **raw-session turns** immediately after a flush; drifts up to `keepLastTurns + (turns since flush)` between flushes (boundary frozen for cache stability); snaps back on the next flush. The count is over the raw branch, so the number of *surviving rendered* turns that keep thinking can be fewer than `keepLastTurns` when phase-3 chain compression has dropped closed chains inside the window - benign, see [Edge cases](#edge-cases). + +**Monotonic clamp `max(prev, candidate)`** is a no-op in the normal case (`count` only grows, so `candidate >= prev`). Its sole purpose is the **`keepLastTurns` increased mid-session** case (user cycles the preset up, e.g. 16 -> 32): `count - 32 < count - 16`, so `candidate` would move *backward*, re-adding thinking to already-stripped messages and busting the cache. The clamp forbids that: a larger window applies going forward only. This is the issue's AC restated as a **logical invariant** - thinking is never re-added to a message already stripped - not a numeric one. (`keepLastTurns` *decreased* mid-session moves `candidate` forward and strips more aggressively next flush - allowed, that is what the user asked for.) + +## Edge cases + +- **Pre-first-flush / no frontier field / old persisted entry:** `undefined` -> live-count fallback (today's behaviour). Cache is not yet valuable at low turn counts. +- **`count <= keepLastTurns`:** `computeThinkingBoundary` returns `prev`; phase 4 fast-paths to the same array reference. +- **Phase-3 middle drops:** the boundary is a timestamp computed over the **raw branch**, while phase 4 runs at render over the post-phase-1-3 survivor array. The timestamp comparison is robust to drops (a dropped message simply is not present to compare). Because chain compression's rolling window (default 3 turns) is *inside* the `keepLastTurns` (16) window, it can drop closed chains that sit newer than the boundary, so immediately after such a flush fewer than `keepLastTurns` surviving turns retain thinking. This is **intended and benign**: it strips *more*, never re-adds, and a dropped turn's thinking is gone with the whole turn anyway. It does **not** "self-correct at the next flush" (the raw branch keeps counting the dropped positions); computing over the raw branch is the deliberate choice because it keeps the boundary stable and decoupled from chain-compression cadence. If "exactly K surviving turns" ever became a hard requirement, the flush would instead compute over the post-phase-1-3 survivor sequence - explicitly out of scope here. +- **Messages without a `timestamp`:** the design assumes every assistant message carries a `timestamp` (true across current fixtures and pi's message shapes). Stated as an explicit assumption; a message lacking one would be treated as older-than-any-boundary by a `< boundary` comparison against `undefined` - acceptable, but noted. +- **Multi-thinking-block message:** `withoutThinkingBlocks` (chain-range-prune.ts:16) filters *all* `type === "thinking"` parts (including signatures), not just the last - locked by an explicit test. +- **`enabled: false`:** phase 4 no-ops as today; the flush skips boundary computation (field left at `prev`). +- **Long tool-only chain, budgets off (default):** no `message_end`, no budget flush -> no flush -> boundary frozen -> thinking accumulates until the chain finally flushes. This is the existing agent-message flush contract (summarization is gated identically); the budget flush is the named backstop when a user opts in. Documented, not fixed here. +- **Session reload:** frontier reconstructs the field from `context-prune-frontier`; stateless recompute resumes at the next flush. + +## Testing + +`bun test src/` (258 green at baseline) must stay green. + +- **Unchanged:** all 12 existing `thinking-strip.test.ts` tests (2-arg calls hit the fallback path). Note the existing suite *already* covers all-or-nothing multi-thinking-block stripping (thinking-strip.test.ts:132) and the `keepLastTurns=0` clamp (thinking-strip.test.ts:99) - do not duplicate them. +- **New - prefix stability across a growing array:** starting from a fixed boundary, render N times where each render **appends** a tail toolCall/toolResult (a real tool loop), and assert the serialized *historical prefix* (everything before the appended tail) is byte-identical across renders. This exercises the actual bug; N reruns over one static array would only re-test idempotence. +- **New - advance only on flush + monotonic:** boundary moves only when recomputed at flush; never regresses, including under a mid-session `keepLastTurns` increase. +- **New - frontier reload round-trip:** persist a frontier carrying `thinkingStripBoundaryTimestamp`, run it through `fromJSON`, assert the field survives (guards the blocker). +- **New - post-chain-drop window:** after a flush whose phase-3 dropped a closed chain inside the K-window, surviving turns older than the boundary have no thinking and the boundary is unchanged from its raw-branch computation. +- **New - `computeThinkingBoundary` unit tests:** `count <= keep` returns `prev`; `count > keep` returns the correct timestamp; `keepLastTurns=0` is clamped to 1 (no out-of-bounds); monotonic clamp holds on `keepLastTurns` increase; `closingMessage` is counted. +- **New - flush integration:** an agent-message flush (`message_end` with captured batches) advances the boundary and strips thinking older than it. + +## Acceptance criteria + +- [ ] Phase 4 does not mutate any previously-rendered historical message between flushes (shared prefix byte-stable across a growing tool loop). +- [ ] Strip boundary advances on every non-empty flush (covers `message_end` and budget flushes; both route through `flushPending`), not on assistant-turn count per render. +- [ ] Boundary keyed by assistant-message timestamp, computed over the raw branch; robust to phase-3 middle drops (surviving-turn count may dip below K after a chain drop - accepted). +- [ ] Persisted `thinkingStripBoundaryTimestamp` survives session reload (`fromJSON` copies it through). +- [ ] Logical invariant: thinking is never re-added to a message already stripped (monotonic clamp; holds under `keepLastTurns` increase). +- [ ] `keepLastTurns=0` clamped to 1 in `computeThinkingBoundary` (no out-of-bounds boundary). +- [ ] No new config key; `keepLastTurns` presets 4/8/16/32/64 retain their meaning. +- [ ] `PRUNING.md` "Cache impact" corrected. +- [ ] Existing 12 `thinking-strip.test.ts` tests pass unchanged; new tests above added and green. + +## Documentation impact +- Feature / user-facing docs introduced: none +- Materially amended existing docs: `PRUNING.md` "Cache impact" (in "Main-loop Thinking Strip", PRUNING.md:1024-1028). Replace the current paragraph ("Each new assistant turn slides the keep-window by one ... Smaller K is cheaper on both savings and churn (worse only for reasoning continuity).") with, verbatim: + + > pi-ai serializes prompt-cache breakpoints only at `tools`, `system`, and the last conversation message (verified in `@earendil-works/pi-ai` `api/anthropic-messages.js` `convertMessages`) - there is **no in-history breakpoint**. So the strip boundary is flush-gated, not per-render: it is a persisted assistant-message timestamp on the `context-prune-frontier` entry that stays fixed between flushes and advances only on a non-empty flush. Between flushes every render is byte-stable in its historical prefix, so the cache holds through a whole tool loop; the boundary moves at most once per flush (~once per request), turning the old k-busts-per-render into ~1-per-request. That residual bust is not always free: for a request shorter than `keepLastTurns` turns the boundary (tail-K) sits deeper than the current request's just-summarized tool results, so thinking-strip is the dominant invalidator, ~1 deep reprocess per request; for a request longer than `keepLastTurns` turns summarization's stub-replace reaches deeper and subsumes it (~0 marginal). Retained thinking is bounded to `keepLastTurns` raw-session turns, drifting up to `keepLastTurns + turns-since-flush` between flushes (deliberate: the frozen boundary is what buys cache stability). Note `error-purge` still mutates old history off a live per-render count, an independent cache-bust source not addressed here. +- Derived / memory docs invalidated: none - no new customType; the new field rides the existing `context-prune-frontier` entry, whose row in the `AGENTS.md` customType table describes purpose, not schema, so no amendment is required + +## Open questions + +- **Attribution is inferential.** Phase 4 is the only *per-render* history mutator this fix removes, but flush stub-replace, model switches, and `error-purge`'s live-count cooldown also mutate history; a residual cache bust after this change may come from those (notably `error-purge`, error-purge.ts:20, which is out of scope here). Not blocking - the fix is correct on its own terms; attribution matters only for interpreting post-ship cache telemetry. A follow-up could give `error-purge` the same flush-gated treatment. diff --git a/pi-condense/doc/specs/2026-08-05-remove-thinking-strip.md b/pi-condense/doc/specs/2026-08-05-remove-thinking-strip.md new file mode 100644 index 0000000..8fc8c98 --- /dev/null +++ b/pi-condense/doc/specs/2026-08-05-remove-thinking-strip.md @@ -0,0 +1,145 @@ +# Remove the thinking strip + +Supersedes: [doc/specs/2026-08-04-thinking-strip-flush-gated.md](./2026-08-04-thinking-strip-flush-gated.md) - fully. + +## Context + +`thinkingStrip` removes `thinking` blocks from older assistant turns at render time, keeping them on the last `keepLastTurns` (default 16). It has shipped since v0.13.0; v2.4.3 made its boundary flush-gated to stop a per-render cache break. + +The feature was designed against an assumed API model: that thinking blocks we send are thinking blocks the model reads and we are billed for, so removing old ones saves tokens. That model is wrong. This spec records the measurements that disprove it and removes the feature. + +## Problem + +Anthropic's actual handling of `thinking` blocks, verified against the live API (`count_tokens` and real billed usage agree to the token): + +| regime | billed input tokens | probe | +|---|---|---| +| thinking in a **closed** cycle (a user text turn has since occurred) | 0 | `withThinking=566 stripped=566 delta=0` | +| thinking in an **open** cycle, contiguous from the cycle's first assistant turn | billed in full | `first 4` bills 4 blocks, `ALL 0-7` bills 8 | +| thinking in an open cycle **after any gap** | 0 | `last 4 (4-7)` bills 0, `middle (3-4)` bills 0 | + +Three consequences, each independently fatal to the feature: + +**1. It strips what is already free.** Closed-cycle thinking costs zero input tokens and is dropped from the context window before the model attends to it. Stripping it saves nothing and changes nothing the model sees. + +**2. It delivers no thinking to the model, and pays for the privilege.** In an open cycle, retention is a *prefix* property: only the unbroken run starting at the cycle's first assistant turn survives. `keepLastTurns` keeps the *last* K, so the moment it fires it punches a gap at the front and Anthropic discards every block after it - including the K it just preserved. The model receives zero thinking either way, and the transition from no-gap to gap is a full cache invalidation. + +Measured in a controlled A/B (24 sequential file reads, isolated `PI_CODING_AGENT_DIR`, identical prompts): + +``` +BASE (bare pi, no extension) cacheWrite= 89449 cost=$0.6495 +NOSTRIP (pi-condense, thinkingStrip disabled) cacheWrite= 87781 cost=$0.6430 +COND (pi-condense, defaults) cacheWrite=148222 cost=$0.8531 +33% +``` + +`COND`'s only non-warmup break is turn 17 - the first turn past `keepLastTurns=16` - which rewrote 63113 tokens at 0% cache reuse. `NOSTRIP` has no extra breaks over `BASE`. + +Direct API confirmation that gap *creation* is the invalidating event, and that moving an existing gap is free: + +``` +req2 a1=think (no gap) write=5004 read=0 prime +req3 a1=strip a2=think (GAP) write=3485 read=0 full miss +req3 a1+a2=think (no gap) write=1633 read=5004 hit +``` + +**3. The economics can never work.** Retained thinking bills at cache-read rates ($0.30/Mtok); removing it costs a cache write ($3.75/Mtok). The 12.5x asymmetry means a strip only pays back if the same cycle continues for roughly another 130 turns. Across 58 gridstrong sessions (1233 open cycles): + +| | | +|---|---| +| median cycle length | 3 assistant turns | +| cycles > 16 turns (strip fires) | 253 (20.5%) | +| cycles > ~130 turns (strip breaks even) | **2 (0.2%)** | +| estimated strip cost across the archive | ~$95 of $2564 total spend | + +The feature is net-negative in roughly 250 of its 253 firings. + +**4. It is redundant with chain compression.** `applyChainCompressions` in `src/chain-range-prune.ts` emits the synthetic chain block as `role: "user"` with a `type: "text"` block (line 34 at time of writing). Anthropic reads that as a genuine user turn, which closes the cycle and discards all prior thinking - at zero cache cost, because the range drop was already rewriting that region. Stub replacement does not do this: `src/pruner.ts:109` preserves `role: "toolResult"`, so the cycle stays open. + +``` +full toolResult, a1+a2 think billedThinking=3152 +STUBBED toolResult (role kept), a1+a2 think billedThinking=3152 no effect +chain-drop user TEXT inserted before a2 billedThinking=0 cycle closed +``` + +Chain compression is enabled by default and fires several times per session, against the strip's ~4.3. It already performs the strip's intended job, for free, at a semantic boundary rather than an arbitrary turn count. + +## Decision + +Remove `thinkingStrip` entirely. No replacement mechanism, no replacement config key. + +The resulting behavior is the union of two things that are already correct and already free: Anthropic drops closed-cycle thinking server-side, and chain compression closes cycles as a structural side effect of range dropping. What remains resident is open-cycle thinking since the last chain drop, which is the reasoning the model is actively using and which bills at cache-read rates. + +### Alternatives considered and rejected + +**Lower or raise `keepLastTurns`.** The knob has two reachable states, not a range. Below the cycle length there is a gap and the model sees no thinking; above it there is no gap and the model sees all of it. Every value from 1 to 15 produces identical behavior in a 20-turn cycle. Raising to 32 moves the cliff later and makes more cycles pay full freight before reaching it. + +**Keep the first N turns instead of the last N.** Matches the API's prefix semantics, but inverts relevance: the model would retain reasoning about work already completed and lose the reasoning that produced the tool call whose result just arrived. Dominated by keep-all (which includes those same blocks plus the relevant ones) and by keep-none (which is free). + +**Keep only the most recent turn (keep-last-1).** Forces a no-gap-to-gap transition at the third request of *every* cycle, raising the invalidation count from ~4.3/session to ~21/session. Roughly 4x worse than the bug it would replace. + +**Gate the boundary to the stub frontier** (strip thinking exactly when its tool results are stubbed - the original proposal this worktree was opened for). Genuinely free: the invalidation is already happening at that position. But worth $4.59 across all 58 sessions (15.3M resident tokens at cache-read rates), or $0.08/session against a $44/session average. It also cannot express the intent cleanly - punching the gap drops *all* open-cycle thinking, including reasoning about tool results that are still fully present in context. Rejected on the repo's own bar: the coupling from `thinking-strip` to `frontier` is not worth 0.2%. + +### Accepted consequence + +Thinking blocks that reason about pruned tool outputs are retained rather than stripped. This is bounded and acceptable: + +- Before the last chain drop: thinking is dropped server-side and never reaches the model. No exposure. +- After it: the referenced outputs are mostly unpruned anyway (`recoveryGraceTurns` protects the last 3 turns), and pruned ones are replaced by a stub plus its summary, recoverable via `context_tree_query`. +- The extension already retains assistant *text* and *tool calls* that reference pruned outputs, unconditionally and always live in the window. Singling out thinking would be inconsistent. + +Residual risk is summarizer fidelity - a summary that drops a detail the retained thinking cites. That is a summarizer concern, not a thinking-retention one, and is out of scope here. + +With the strip gone, `chainCompression.enabled: false` becomes the only configuration that carries open-cycle thinking indefinitely. Measured worst case across 1233 cycles is 30750 tokens (126 turns); median 388, p99 8968; zero cycles above 50k. Documented, not guarded. + +## Scope + +Delete: + +| target | note | +|---|---| +| `src/thinking-strip.ts` | whole module (`stripOldThinking`, `computeThinkingBoundary`) | +| `src/pruner.ts` phase 4 | and the `thinkingStrip` + `thinkingBoundaryTimestamp` params of `pruneMessages` | +| `src/types.ts` | `ThinkingStripConfig`, `ContextPruneConfig.thinkingStrip`, `DEFAULT_CONFIG.thinkingStrip`, `PruneFrontier.thinkingStripBoundaryTimestamp`, and the `KEEP_LAST_TURNS_PRESETS` export (line 171) - it has no other consumer | +| `index.ts` | the `computeThinkingBoundary` import (line 39); boundary computation in `flushPending` (lines 507-511); its assignment to `frontierSnapshot`; the argument at the `pruneMessages` call site. Also narrow the `branchMessages` unwrap conditional (line 495) from `thinkingStrip.enabled \|\| chainCompression.enabled` to `chainCompression.enabled` alone | +| `src/frontier.ts` | `thinkingStripBoundaryTimestamp` in `fromJSON` | +| `src/commands.ts` | the `KEEP_LAST_TURNS_PRESETS` import (line 19), the `thinkingStripEnabled` toggle and `thinkingStripKeepLastTurns` selector (its preset usage at lines 661-664), and both `onChange` handlers | +| tests | delete `src/thinking-strip.test.ts`; strip phase-4 cases from `src/pruner.test.ts` and boundary cases from `src/frontier.test.ts`; update the positional `pruneMessages` call sites in `src/range-compression.integration.test.ts` and `src/oversized-spill.integration.test.ts` for the shortened signature | + +Backward compatibility, both directions of an in-flight session: + +- A `contextPrune.thinkingStrip` block left in a user's `settings.json` is inert but **persists**. `normalize()` in `src/config.ts` builds `{ ...DEFAULT_CONFIG, ...existing }` and then re-spreads it, so unrecognized keys are neither filtered nor rejected: the block survives `loadConfig()` untouched and is re-serialized verbatim by the next `saveConfig()`. It never throws or warns. No code change and no deprecation notice - the key simply stops being read, and stays in the file until a user removes it by hand. +- A persisted `context-prune-frontier` entry carrying `thinkingStripBoundaryTimestamp` is ignored on `session_start`. The field is optional and read-only at that site. + +## Testing + +- Delete `src/thinking-strip.test.ts`. Remove the phase-4 assertions from `src/pruner.test.ts` and the boundary-specific cases from `src/frontier.test.ts`. Update the positional `pruneMessages` calls in `src/range-compression.integration.test.ts` and `src/oversized-spill.integration.test.ts` to the shortened signature. +- **New** (additive) test: `pruneMessages` leaves `thinking` blocks untouched on assistant messages. Pin `stripFinalAssistantThinking: false` in this fixture - with the default `true`, `applyChainCompressions` legitimately strips thinking from a compressed chain's kept final assistant, so an unqualified "untouched everywhere" assertion would contradict that mechanism. +- Leave the existing `"strips thinking blocks from final assistant when stripFinalAssistantThinking is true"` case (`src/pruner.test.ts:182`) in place - that mechanism is out of scope and must keep working. +- Assert a session resumed from a frontier entry containing `thinkingStripBoundaryTimestamp` loads without error and strips nothing. +- Assert a `settings.json` containing `contextPrune.thinkingStrip` loads without error and round-trips the key unchanged. +- Full suite green: baseline on this branch is `274 pass, 0 fail, 23 files` (`bun test src/`). +- Typecheck per AGENTS.md before committing. +- Smoke test against an isolated `$PI_CODING_AGENT_DIR`: `DEFAULT_CONFIG.enabled` is `false`, so the settings file must set `contextPrune.enabled: true` and the prompt must force real tool calls and a flush. Assert **at least one** `context-prune-frontier` entry was written before asserting that none of them carries `thinkingStripBoundaryTimestamp` - otherwise the check passes vacuously on a session that never flushed. + +## Documentation impact + +- Feature / user-facing docs introduced: none +- Materially amended existing docs: `PRUNING.md` (delete the "Main-loop thinking strip" section and its cache-impact discussion; add a short note that chain drops close the assistant cycle server-side and that closed-cycle thinking is free, so no strip is needed); `doc/configuration.md` (delete the `thinkingStrip` block from the settings JSON example at line 39 and both `thinkingStrip.*` rows from the settings table at lines 76-77 - this is the file README designates as the full settings reference); `CHANGELOG.md` (breaking: config key removed). `README.md` needs no edit - it contains no `thinking` references. +- Derived / memory docs invalidated: `AGENTS.md` - the Project Layout block lists `src/thinking-strip.ts`, and the `src/pruner.ts` line describes the phase composition as `stub-replace -> error-purge -> chain-range-prune -> thinking-strip`; both need updating in the same commit + +## Out of scope + +- **`chainCompression.stripFinalAssistantThinking`** - a separate mechanism, not measured here, plausibly carrying the same defect. Belongs to chain compression; decide separately rather than removing it as a ride-along. +- **Chain compression's own cache behavior.** A 6-prompt A/B showed chain compression costing +4.5% ($0.4784 -> $0.5000) with 2 full invalidations from 3 chain drops, but the fixture peaked at 20k tokens where the archive median is 131k - the regime where compression is supposed to pay. Not a verdict; needs a large-fixture A/B before any conclusion. +- **The unattributed remainder of archive rewrite spend.** The strip explains ~$95 of ~$1997. A prompt-delta proxy suggested far more breaks than the controlled runs reproduce; the proxy is unreliable and the remainder is unexplained. +- **Summarizer fidelity** for details cited by retained thinking. + +## Measurement caveats + +- The 58-session gridstrong archive predates v2.4.3 for 55 of 58 sessions, so it measures the per-render boundary, not the flush-gated one. The cycle-length and thinking-volume statistics are unaffected by this (they are properties of the transcript, not of the strip); the ~$95 cost figure is specific to the pre-2.4.3 path and is an upper bound for the current one. +- Cost figures use Sonnet 4.5 pricing: $3/Mtok input, $15/Mtok output, $3.75/Mtok cache write (5m), $0.30/Mtok cache read. +- The controlled A/B peaks at 89k tokens; archive median peak is 224k. Conclusions about the strip hold regardless of scale (the mechanism is positional, not size-dependent), but absolute costs do not extrapolate linearly. + +## Open questions + +- `chainCompression.stripFinalAssistantThinking` defaults to `true` and is untested against the prefix-retention rule. Track separately. diff --git a/pi-condense/doc/specs/2026-08-12-single-chain-observability-trigger-repair.md b/pi-condense/doc/specs/2026-08-12-single-chain-observability-trigger-repair.md new file mode 100644 index 0000000..53e1f57 --- /dev/null +++ b/pi-condense/doc/specs/2026-08-12-single-chain-observability-trigger-repair.md @@ -0,0 +1,144 @@ +# Single-chain observability + reload trigger repair (issue #6) + +Implements [jjuraszek/pi-condense#6](https://github.com/jjuraszek/pi-condense/issues/6): long single-chain sessions expose two compounding gaps - no observability into what the pruner *cannot* see (open-cycle thinking, chain dominance, frontier gap), and reloads discarding the in-memory `pendingBatches` queue, which strands the automatic flush trigger even though the branch-rescan data path could recover the work. + +Issue AC 2, verbatim: "`/pruner status` + footer show open-cycle thinking, largest-chain share, frontier gap; a session entry records them per flush." All three metrics therefore appear on both surfaces. + +## Incident summary (evidence basis) + +Real 1M-window session, 256 assistant turns, ~5h, single chain (zero mid-run text-only assistant replies, so the `agent-message` flush boundary never fired). `autoBudgetThreshold` 0.8 never crossed (peak 53.7%). Result: ~195k tokens of raw toolResults never summarized; `/pruner status` showed `calls: 1, input: 3 tokens`. + +**AC 1 disposition (user decision):** the incident JSONL is gone. AC 1 is downgraded to source-level verification, which is complete: + +- The flush data path is reload-safe: `capturePendingBatches` (`index.ts:138-152`) rescans the persisted branch via `captureUnindexedBatchesFromSession` (`src/batch-capture.ts:69-145`), occurrence-key-filters against the indexer, and trims at the frontier. Captured-but-unflushed work is always recoverable from the session. +- The flush *trigger* is not: `pendingBatches` is zeroed on `session_start` (`index.ts:679`) and `session_tree` (`index.ts:709`). The `turn_end` handler returns early when the turn has no toolResults or when trim leaves no batch (`index.ts:719-724`, `764`), so the budget gate at `index.ts:794` is only ever reached with a freshly pushed batch - after a reload, no path evaluates the budget trigger against the recoverable work until a *new* unprotected toolResult turn arrives. The `agent_end` pending-notify (`index.ts:823-825`) is likewise silent when the in-memory queue is empty. +- The "nothing pending" `/pruner now` report from the incident is **an accepted unknown** - trigger loss alone does not explain it, and without the JSONL it cannot be investigated further. Recorded here so a future recurrence has a named prior (the per-attempt flush-metrics entries in Component 3 exist precisely to make a recurrence diagnosable). + +## Goals + +1. **Metrics (AC 2):** three new observability metrics, surfaced in `/pruner status`, the footer widget, and a per-flush-attempt session entry. +2. **Trigger repair (AC 3):** after a reload with recoverable work on the branch, the budget/delta trigger fires on the next `turn_end` - including turns that contribute no new batch - and the recoverable work is visible at `agent_end` and in `/pruner status`. Reload followed by total idleness (no further turns at all) remains visibility-only by design (no flush at boot). +3. **Docs (AC 4):** PRUNING.md documents the single-chain limitation of Phase 3 as a known design property, with config guidance. +4. **No `cost:external` change (AC 5):** satisfied by non-action; the payload and emission points are untouched. + +## Out of scope (roast-killed in the issue; restated as binding) + +- No new pruning valve of any kind (forced-close/reopen, stub-replace-inside-recent-chain, cycle-closing via toolResult mutation, thinking stripping). NO-GO pending a corpus replay this change does not authorize. +- No executor-behavior fixes (upstream cause shipped in pi-gauntlet v4.7.0, jjuraszek/pi-gauntlet#6). +- No queue reconstruction on reload - the repair is a trigger flag over the existing branch-rescan data path. +- No immediate flush on `session_start`/`session_tree` (no LLM call at boot; print-mode sessions may die under it; compacts work the user did not ask to compact yet). + +## Design + +### Component 1: metrics module (`src/context-metrics.ts`, pure) + +One pure function computing a snapshot from a branch (`AgentMessage[]`, the raw unwrapped `type === "message"` projection of `getBranch()` - measuring what is persisted, not the post-prune in-flight context), the persisted frontier, a summarized-predicate, and a protection predicate: + +```ts +interface ContextMetricsSnapshot { + openCycleThinkingTokens: number; // est. tokens of thinking blocks retained in the trailing open segment + largestChainSharePct: number; // max(largest closed chain, open segment) chars / total branch chars, 0-100 + frontierGapTokens: number; // est. tokens of summarization-eligible unsummarized toolResults after the frontier +} +computeContextMetrics(branch, frontier: PruneFrontier | null, isSummarized, isProtected): ContextMetricsSnapshot +``` + +**Measurement convention (one convention for everything in this module):** per-message chars = `JSON.stringify(message).length`; tokens = `Math.round(chars / 4)`. This matches the reclaim footer's basis (`src/commands.ts:76-77`); the `Math.ceil` used by the compact preview (`src/commands.ts:1032`) is not adopted. Numerator and denominator of the share metric use this same basis. + +**Open segment:** the message range strictly after the last text-only assistant message (assistant with no `toolCall` content blocks, same predicate as `isFinalAssistantMessage`, `index.ts:100-106`) through the branch tail. A branch with zero text-only assistants has the whole branch as its open segment (the incident case). All messages in the range count toward its chars, including user/steer messages - the range is measured as retained context, not filtered content. + +**Open-cycle thinking tokens:** sum over assistant messages in the open segment of the chars of their content blocks with `type === "thinking"`, divided per the convention. Deliberately *not* windowed by the frontier: a skipped/oversized/trivial flush attempt advances the frontier but removes nothing from context, so a frontier window would reset to ~0 right after a skip while the stranded thinking is still fully retained - reproducing the incident's misleading-small-numbers symptom. This metric answers "how much thinking is retained in the chain Phase 3 cannot yet compress". + +**Largest-chain share:** numerator = `max(largest closed chain, open segment)` - max, not sum. Closed chains come from `detectChains` over the branch; interrupted chains (`finalAssistantTimestamp: null`) count as closed for measurement purposes (they are retained context regardless of compressibility). A chain's chars = sum of per-message chars over the messages in its range. Denominator = sum of per-message chars over the entire branch projection (including custom summary messages present as branch messages). Share = `Math.round(100 * numerator / denominator)`; 0 when the denominator is 0. `src/chain-detector.ts` is not modified - the open-segment scan is a metrics-module concern (its closed-chain contract is load-bearing for Phase 3). + +**Frontier boundary locator (used by frontier gap only):** replicate `captureUnindexedBatchesFromSession`'s turn-counter convention (`src/batch-capture.ts:81-86`): walk the branch incrementing a counter on *every* assistant message. The boundary turn is the assistant message whose counter equals `frontier.lastAttemptedTurnIndex` and which contains a `toolCall` block with id `frontier.lastAttemptedToolCallId`. Within the boundary turn, toolResults paired to calls up to and including that id are *at or before* the boundary (excluded); results paired to later calls in that turn and all messages after that turn are *after* the boundary. If `frontier` is null, or the turn is not found, or the id is not in that turn's calls (bare-id miss after tree navigation), the boundary is the branch start - whole-branch window. This mirrors `trimBatchToPendingRange`'s miss semantics (`index.ts:108-129`) without pretending to share its code, which operates on captured batches, not branches. + +**Frontier gap:** sum of per-convention tokens over `ToolResultMessage`s after the boundary that are summarization-eligible: occurrence key not summarized (`isSummarized`) *and* not protected (`isProtected`, same predicate the `turn_end` capture filter uses, `index.ts:735-738`). Protected outputs are excluded because they are never capturable - counting them would keep the gap permanently non-zero with no pending work, contradicting the metric's meaning ("what could summarization reclaim"). + +### Component 2: reload rearm flag (`index.ts`) + +New module-level `rearmedPending: boolean` (transient, never persisted): + +- On `session_start` and `session_tree`, **after** reconstruction and the `pendingBatches` clear, run the existing capture rescan (`capturePendingBatches` - a pure branch scan + grouping, no LLM work). If it yields at least one non-empty batch after frontier trim, set `rearmedPending = true`. The rescan result is discarded - only the boolean survives (no queue reconstruction). In the same pass, compute the metrics snapshot for the new branch and refresh the footer widget (see Component 4) - this is the reload-with-no-further-turn visibility path. +- **`turn_end` restructure (the load-bearing part):** the budget/delta gate must be reachable without a freshly pushed batch. When `rearmedPending` is true, `turn_end` skips the `!hasToolResults` and `!batch` early returns for the purpose of gate evaluation: it still performs capture/spill/trim/push only when the turn has toolResults, but the budget/delta computation and the flush gate run regardless. The gate becomes `(pendingBatches.length > 0 || rearmedPending) && !isFlushing && (budgetHit || deltaHit)`. When `rearmedPending` is false, current behavior is unchanged (early returns intact; the pushed-batch guarantee makes the `pendingBatches.length > 0` operand redundant there, which is exactly why the flag alone - without this restructure - would be dead code). +- `message_end` (agent-message mode) already calls `flushPending` unconditionally, so "reload, then the chain closes" needs no change - the flush's own rescan recovers the work. +- `agent_end`: the guard becomes `pendingBatches.length === 0 && !rearmedPending`; widget text when the queue is empty but rearmed: `prune: recovered pending (reload)` (never the misleading `prune: 0 pending`); otherwise the existing `prune: N pending`. +- `rearmedPending` clears at the start of every `flushPending` invocation (any outcome - the flush's own capture rescan is then the source of truth) and is re-evaluated on the next reload. +- **Rescan failure:** if the reload rearm probe throws, `console.error` (the codebase's existing sink for non-prune-time failures, e.g. `src/spill.ts:71`) and leave `rearmedPending = false` - reload must never fail because of the probe. `DiagnosticSink` is not used: it is scoped to prune-time degradations. +- **`enabled: false`:** probe skipped entirely. + +Rationale for flag-not-flush: the armed state changes nothing until an existing trigger (budget, delta, `message_end`, `/pruner now`) fires; immediate flush at boot was rejected (see out of scope). Honest residual gap: reload followed by *no further turns at all* gets visibility (footer, `agent_end`, `/pruner status`) but no automatic flush - by design. + +### Component 3: per-attempt flush-metrics entry (`context-prune-flush-metrics`) + +New custom session entry type, constant in `src/types.ts`. Written **once per non-concurrent `flushPending` invocation, regardless of outcome** - including empty-capture and error paths, because the incident's undiagnosable "`/pruner now` said nothing pending" is exactly an empty-capture attempt this log must record. Payload: + +```ts +{ + ts: number; + trigger: "budget" | "delta" | "message-end" | "manual" | "rearmed"; + capturedBatches: number; // batches after rescan+trim, before processing + processedBatches: number; + outcome: "summarized" | "skipped-oversized" | "skipped-deduped" | "skipped-trivial" | "empty" | "error"; + metrics: ContextMetricsSnapshot; // computed at flush ENTRY (pre-flush), so the pressure that triggered the flush is recorded +} +``` + +`outcome` reuses the `PruneFrontier.outcome` union verbatim for processed attempts and adds `empty` (nothing captured) and `error`. `trigger` is threaded from the call site. + +- **Emission point:** the very end of `flushPending`, after the chain-compression block and outside its try/catch (compression failure is non-fatal and must not eat the entry). Routing mirrors frontier persistence (`index.ts:517-531`): `delivery === "runtime"` -> `pi.appendEntry`, else `sessionManager.appendCustomEntry`. Write failure is non-fatal (same posture as stats persistence). +- **Never in LLM context** (same hidden treatment as `context-prune-stats`). +- **Not reconstructed on `session_start`** - append-only observability log, not state; nothing reads it back at runtime. +- Deliberately **not** an extension of `context-prune-stats`: point-in-time metrics vs cumulative last-snapshot-wins counters would force reconstruction to distinguish field classes. `SummarizerStats`, `StatsAccumulator`, and the `cost:external` emission are untouched. +- AGENTS.md custom-entry table gains one row. + +### Component 4: surfacing (`src/commands.ts` + cache in `index.ts`) + +- **Snapshot cache:** owned by the `index.ts` closure. Recomputed at: reload rearm probe (Component 2), `turn_end` with toolResults, and `flushPending` entry. `commands.ts` call sites never compute - they read the cache via a getter callback wired at registration (`index.ts:891`, same pattern as `getStats`). Call sites that refresh the widget for unrelated reasons (`commands.ts:819/854/863/982/1079`) reuse the cached snapshot as-is. +- **`/pruner status`:** new `context` block - open-cycle thinking, largest-chain share, frontier gap (from an on-demand fresh computation via the callback, not the cache - status is rare and should be exact), plus a `rearmed: yes` line rendered only while the flag is set (omitted otherwise). +- **Footer widget:** `pruneStatusText` gains a compact suffix rendered **only when `frontierGapTokens > 0`**: `· think k · gap k · chain

%` (all three metrics per AC 2; `formatCompactCount` for the numbers). Idle footer (gap 0) is unchanged - no new noise. The suffix source is the cached snapshot, threaded as a new optional parameter alongside the existing `reclaim`/`diagnostics` parameters. + +### Data flow + +``` +session_start/session_tree -> rebuild index/stats/frontier -> clear pendingBatches + -> rearm probe: capture rescan -> rearmedPending = batches found + -> compute metrics snapshot -> refresh footer (visibility without further turns) +turn_end -> [capture/spill/trim/push when toolResults] -> recompute snapshot + -> gate (reachable even with no new batch when rearmed): + (pending>0 || rearmed) && !isFlushing && (budgetHit || deltaHit) -> flushPending +flushPending -> clear rearmedPending -> pre-flush snapshot -> capture rescan (existing) + -> summarize -> persist index/frontier/stats -> chain compression + -> write context-prune-flush-metrics (always, outside compression try/catch) +/pruner status -> on-demand metrics computation -> context block +``` + +### Error handling / edge cases + +- **Rearm probe throws:** `console.error`, `rearmedPending = false`, reload proceeds (Component 2). +- **Rearmed but flush-time rescan finds nothing** (another client summarized; trim excluded everything): flush takes its existing empty path; the flush-metrics entry records `outcome: "empty"`; flag already cleared. +- **Empty branch / no assistant messages:** all metrics 0; share 0 when the denominator is 0. +- **Frontier boundary unresolvable in branch** (tree navigation, legacy frontier): whole-branch window (Component 1 locator). +- **Flush-metrics entry write failure:** non-fatal; flush outcome unaffected. +- **`enabled: false`:** rearm probe skipped; no flush-metrics entries; `/pruner status` context block still renders (read-only scan, useful while deciding whether to enable). + +### Testing + +Bun test style: + +- `src/context-metrics.test.ts` (new, pure): open-segment detection (incl. zero-text-only-assistant branch = whole branch); thinking sum over open segment only; share max-not-sum, interrupted chains as closed, denominator with custom summary messages, zero denominator; frontier-gap boundary location (intra-turn split, bare-id miss -> whole branch, null frontier), protected + summarized exclusion; measurement convention (JSON.stringify, Math.round). +- **AC 3 regression test** (`src/reload-rearm.integration.test.ts`, new): the repo has no existing harness that drives `index.ts` event handlers (all current suites test pure modules), so this test builds one: invoke the extension's default export with a mock `pi` whose `on` captures handlers into a map; fabricate a `ctx` with a fake `sessionManager` (`getBranch` returning a branch containing a captured-but-unflushed toolResult batch, `appendCustomEntry`, `getSessionDir`/`getSessionId`) and `getContextUsage` returning budget-crossing usage; stub the summarizer via the `mock.module("@earendil-works/pi-ai/compat")` pattern from `src/summarizer-wiring.test.ts`. Scenario: fire `session_start` (asserts rearm), then a `turn_end` with **no toolResults** and budget-crossing usage -> assert `flushPending` ran (summarizer stub invoked), the batch was summarized/indexed, and `pruneMessages` stubs its result. This scenario **fails on current `main`** (the `!hasToolResults` early return prevents the gate from ever being evaluated), which is what makes it a regression test. +- `src/commands.test.ts` additions: footer suffix present iff gap > 0 and contains all three metrics; status context block formatting; `rearmed:` line only when armed; `agent_end` string `prune: recovered pending (reload)` for the rearmed-empty case. +- Flush-metrics entry: exactly one entry per flush attempt, including empty-capture attempts and when chain compression runs or fails (guards the double-persist shape of stats at `index.ts:500-524` + `561-563` and the try/catch placement). + +Verification: `bun test src/` plus the AGENTS.md typecheck command. Smoke test per AGENTS.md (`pi -e ./index.ts --no-extensions -p ...`, then jq over the session JSONL asserting a `context-prune-flush-metrics` entry appears). + +## Documentation impact + +- Feature / user-facing docs introduced: none (no new standalone doc; all changes amend existing docs) +- Materially amended existing docs: `doc/configuration.md` (footer metrics-suffix state, `/pruner status` context block, `prune: recovered pending (reload)` state - it is the repo's designated home for footer states and full command detail; ratified at the finish gate, G6), `PRUNING.md` (new "Single-chain sessions" subsection: Phase 3's closed-chain requirement as a known design property; guidance to lower `autoBudgetThreshold` / rely on `budgetTurnDelta` for long autonomous runs; the three metrics and their definitions; the rearm behavior), `README.md` (`/pruner status` context block, footer suffix) +- Derived / memory docs invalidated: `AGENTS.md` (custom-entry table: add `context-prune-flush-metrics` row; project-layout line for `src/context-metrics.ts`) + +## Open questions + +None blocking. Accepted unknown: the incident's "nothing pending" `/pruner now` output remains unexplained (JSONL gone); the per-attempt flush-metrics entries exist precisely so a recurrence is diagnosable. diff --git a/pi-condense/doc/specs/2026-08-12-toolcall-id-collisions.md b/pi-condense/doc/specs/2026-08-12-toolcall-id-collisions.md new file mode 100644 index 0000000..18d0a28 --- /dev/null +++ b/pi-condense/doc/specs/2026-08-12-toolcall-id-collisions.md @@ -0,0 +1,236 @@ +# Tool-call id collisions: positional chain drops, occurrence identity, orphan sweep + +> **Superseded by:** [doc/specs/2026-08-18-gh-11-orphan-sweep-barrier.md](./2026-08-18-gh-11-orphan-sweep-barrier.md) - "C. Orphan sweep" section: barrier rule and the claim that C covers the `stopReason: error|aborted` orphan path + +Issue: [jjuraszek/pi-condense#8](https://github.com/jjuraszek/pi-condense/issues/8) (issue #9 merged in as section B). + +## Problem + +Provider tool-call ids are unique only within one response. `pi-ai` passes them through verbatim (Google mints its own, `api/google-generative-ai.js:136`), and github-copilot/kimi-k3 restarts a `${tool}_${n}` counter, so `bash_23` recurs inside one session. pi-condense treats those ids as session-durable identity in two places, producing one fatal and one silent failure. + +**Fatal.** `applyChainCompressions` (`src/chain-range-prune.ts:61-119`) unions every `ChainCompressionEntry.droppedToolCallIds` into one session-wide `Set` and then drops *any* message in the array whose id is in that set - with no positional bound. `:104-110` drops an entire assistant message when **any** of its `toolCall` ids matches. `pi-ai`'s `insertSyntheticToolResults` repairs orphan tool *calls* only, never orphan tool *results*, so the provider rejects the request, and every retry rebuilds the identical broken context. The session is unrecoverable without hand-editing the JSONL. Risk grows with session length - worst on exactly the long sessions pi-condense targets. + +Provider rejection wording varies while the defect is identical: Anthropic returns `400 unexpected tool_use_id found in tool_result blocks`; Kimi K3 returns `400 Kimi K3 tool messages need a resolvable tool name: carry `tool`/`name`, or match a preceding assistant tool_call by order` - i.e. it fails to resolve the name of a tool message that matches no preceding `tool_call` **by order**, which is an orphan result by another name. Any orphan-result check must therefore be provider-agnostic and structural, not keyed to one error string. + +**Why this is a code defect, not an observed-once anomaly.** The drop test is `dropped.has(id)` evaluated over the entire message array, and `:104-110` removes an entire assistant message when any single one of its `toolCall` ids matches. Nothing bounds the match to the compressed range. Therefore *any* recurrence of a dropped id later in the session deletes a live turn, and whether that turn's siblings survive orphaned depends only on whether the live assistant also carried a non-dropped id. Reuse frequency sets the blast radius; the defect is unconditional. + +**Observation (no longer reproducible).** A live 2.5.0 kimi-k3 session that failed with the Kimi wording was inspected mid-incident and showed id reuse at scale (one id recurring across hundreds of tool results) plus one orphan result in the pruned view. That session, and every other affected one, was subsequently **repaired in place** - the repair rewrote ids to be unique and removed the orphan - so the pre-repair bytes are gone and those figures cannot be re-derived. They are recorded here as provenance only and **no acceptance criterion depends on them**; the case for the fix rests on the code reading above plus the constructed fixture in Testing. Post-repair, the same file replays cleanly through the shipped `pruneMessages` (1731 -> 1660 messages, zero orphans), which is expected: with unique ids the current logic and a positional range coincide. + +**Silent.** The same ids key `ToolCallIndexer.index` (`src/indexer.ts:77`), `contentHashToOriginal` dedup (`:81-82`), the summarized-set check in `pruneMessages` (`src/pruner.ts:74`), and batch-capture result matching (`src/batch-capture.ts:36` first-wins live, `:73-78` last-wins on rescan). A colliding id therefore stub-replaces a live result with a stale `tN`, skips capture of a live batch, resolves `context_tree_query` to the wrong batch, and can alias unrelated content in dedup. The session keeps running on fabricated recovery data with no error. + +## Goals / non-goals + +Goals: remove the fatal path; make record identity collision-safe; add a last-resort orphan invariant. All three land in one branch (issue #8: "land together"). + +Non-goals, decided: + +- **No human intervention anywhere in the repair path.** The orphan sweep repairs unconditionally at render time - no prompt, no `/pruner` confirmation, no gate. The only alternatives at that point are "drop the orphan" or "send a request that 400s"; there is nothing a user could usefully decide, and the condition is not explainable in situ. The human sees a counter after the fact. +- No id rewriting at capture time. pi-condense does not own session ids, and `pi-ai` re-normalizes them per target model anyway. +- No migration and no re-summarization of existing sessions. +- No new config key. +- Filing the upstream `pi-ai` ask (repair orphan tool *results*, not only orphan tool *calls*) is out of scope here; this spec's sweep is defense in depth and is not contingent on it. + +## Design + +### A. Chain drops become positional (`src/chain-range-prune.ts`) + +The range information already exists - `detectChains` (`src/chain-detector.ts:36-108`) emits `{ startUserTimestamp, middleToolCallIds, protectedToolCallIds, finalAssistantTimestamp }`, and `droppedToolCallIds` is only a denormalization of the middles. The drop decision moves back onto positions. + +`resolveRange(entry, messages): { startIndex, endIndex } | null`: + +- `startIndex` = index of the **user-role** message with `timestamp === entry.startUserTimestamp`; `endIndex` = index of the **assistant-role** message with `timestamp === entry.finalAssistantTimestamp`. Role-gating is required, not cosmetic: the incident session has two `toolResult`s sharing millisecond `1786533716958`, and a role-less scan would read cross-role timestamp equality as ambiguity and disable compression. This mirrors the existing role-gated insertion lookup at `chain-range-prune.ts:94-97`. +- **Exactly one match on each boundary, and `startIndex < endIndex`, or the result is `null`.** More than one match is treated as unresolvable, not disambiguated: strictest reading of "resolve or drop nothing", and no rule can over-drop. Cost of a duplicated boundary timestamp is that the chain's compression is disabled until it is re-detected (context regrows for that range), made visible by a diagnostic rather than silent. +- `entry.finalAssistantTimestamp === null` (interrupted chain) is unresolvable by definition. `selectEligible` (`src/chain-compressor.ts:28-29`) already filters those out of eligibility, but the persisted type permits `null` and old entries exist, so the explicit skip stays. +- **No id-array-wide fallback, ever.** Failing closed to a no-op is the point; the previously considered timestamp-*window* variant is rejected (timestamps interleave on `/import` and forked sessions, and the incident session itself has two `toolResult`s sharing millisecond `1786533716958`). + +`null` → the entry contributes nothing: no drops, and **no synthetic message inserted** (a synthetic without its range would present a summary next to the live originals). + +Resolved entries contribute the half-open index range `(startIndex, endIndex)` to a `Set`. Composition across entries is union-of-indices. `chain-detector` emits non-overlapping chains, but entries persist independently, so an entry whose `startIndex` falls inside another entry's dropped range is skipped (its synthetic would be dropped anyway) with a diagnostic. + +Per resolved entry: the synthetic `` message is inserted immediately after `startIndex`; `endIndex` is **kept** and thinking-stripped when `stripFinalThinking`. `endIndex` is always a text-only assistant (`chain-detector.ts:96` closes a chain on an assistant message with no `toolCall`s), so keeping it cannot orphan anything. + +**Drops are role-restricted: only `assistant`, `toolResult`, and `CUSTOM_TYPE_SUMMARY` messages inside a range are removed.** Two consequences, both load-bearing: + +- The synthetic chain message is itself **user-role** (`chain-range-prune.ts:61-64` recovers `existingSyntheticBlockIds` by regex over user-message text). Under a blanket index drop, a second application would delete the synthetic sitting at `startIndex + 1` - inside its own `(startIndex, endIndex)` - and then `existingSyntheticBlockIds` would suppress re-insertion, silently deleting the chain body. Role restriction preserves it in place, so re-application is a true no-op (AC 5). Today's id-based drop never touches user-role messages; the restriction keeps that property. +- Third-party extensions' `custom_message` entries inside a chain range are **not** dropped. A blanket positional drop would widen today's behavior (which only removes assistant-with-dropped-id, `toolResult`-with-dropped-id, and overlapping summaries) into deleting other extensions' context. Out of scope here. + +Ids stop driving drops entirely. The three id/timestamp-keyed matches inside the function become range-scoped as a consequence: + +| Today | After | +|---|---| +| `perBatchSummaryOverlapsDropped` matches `details.toolCallRefs` against the dropped id set (`:11-14`) | range-scoped **coverage** check: a `CUSTOM_TYPE_SUMMARY` message whose `toolCallRefs` overlap the ids inside `(startIndex, endIndex)` is suppressed **regardless of its own index** (see the `agent-message` note below) | +| `protectedIdToBlock` resolves protected ids array-wide (`:74`) | protected ids resolved **within the entry's range only** | +| `stripFinalAtTimestamp.has(msg.timestamp)` strips any assistant sharing a millisecond (`:110`) | strip at the resolved `endIndex` | + +Pure index membership would be insufficient for the first row: under `batchingMode: "agent-message"`, `flushPending` runs at agent-message end, i.e. *after* `finalAssistantTimestamp`, so the per-batch summary is appended **outside** `(startIndex, endIndex)`. Today's id-overlap check drops it; an index-only rule would keep it, leaving both the old per-batch summary and the new chain synthetic in context. The coverage check preserves today's behavior for a shipped config mode. + +Ids remain in use for summary text, `tN` refs, per-batch summary coverage, and the `droppedToolCallIds` cross-check (below). + +### B. Occurrence identity for records (`src/indexer.ts`, `src/batch-capture.ts`, `src/types.ts`) + +Records are keyed by an occurrence key, `` `${toolCallId}@${resultTimestamp}` ``, instead of the bare provider id. + +The discriminant is the **`toolResult` message timestamp** (`ToolResultMessage.timestamp`, required per `@earendil-works/pi-ai/dist/types.d.ts:318`). It is the one piece of identity material readable identically on both sides: `pruneMessages` has the `toolResult` message in hand at `src/pruner.ts:74`, and capture has the matched result message. + +The existing `ToolCallRecord.timestamp` is **not** usable: the live path stamps it `Date.now()` (`index.ts:721`) while the branch-rescan path uses the session entry / message timestamp (`src/batch-capture.ts:132`), so it cannot be re-derived from context. Hence a new field rather than reuse. + +Residual collision: the same provider id on two results persisted in the same millisecond. Accepted - it requires id reuse *and* a sub-millisecond coincidence, and an occurrence-ordinal alternative (k-th occurrence in branch order, recomputed per render) was rejected as a materially larger diff for that margin. + +Surfaces changed: + +- `src/types.ts`: `CapturedToolCall.resultTimestamp?: number`, `ToolCallRecord.resultTimestamp?: number` (persisted in `context-prune-index`, optional so pre-upgrade entries stay readable). **Also `SummaryToolCallRef.resultTimestamp?: number` (`:613-616`) and `DedupAliasEntryData` (`:603-607`) gaining `newResultTimestamp?`/`originalResultTimestamp?`** - without these the occurrence-keyed maps cannot survive a restart: `tN` aliases live only in the summary message's `details.toolCallRefs`, so `registerSummaryRefs` would rebuild `t5 -> bash_23` while the index holds `bash_23@ts`, and `resolveToolCallId("t5")` would miss. `buildShortToolCallRefs` / `makeSummaryDetails` propagate the field; `registerSummaryRefs` and the dedup-alias rebuild key by occurrence key. +- `src/batch-capture.ts`: both capture paths record the matched result's `timestamp`; the `isSummarized` check at `:119` passes the occurrence key. **The rescan path's pairing changes**: `captureUnindexedBatchesFromSession` currently builds one branch-wide `Map` (`:73-78`, last-wins), so with two `bash_23` calls the *earlier* assistant's call is paired with the *later* turn's result - a fabricated record that occurrence keys make distinct but no less wrong. Pairing becomes positional: for each assistant message, scan forward to the next assistant message and match its `toolCall` blocks against the `toolResult`s in that span. The live path's first-wins pairing (`:36`) already operates within a single turn and is unchanged. +- `src/indexer.ts`: an `occKey(id, resultTimestamp)` helper; `index`, `contentHashToOriginal`, `dedupAliasToOriginal`, `aliasToToolCallId`/`toolCallIdToAlias`, and `summaryBodies` refs are keyed by occurrence key. `tN` aliases stay 1:1 with occurrences and remain the primary recovery handle. `isSummarized` / `getRecord` accept an occurrence key, a `tN`, or a bare id. +- `src/pruner.ts`: computes `id@msg.timestamp` for each `toolResult` before the `isSummarized`/`getRecord` pair, and for `getShortRefForToolCallId` at `:88`. **The bare-id fallback is fail-closed**: it applies only when the message carries no `timestamp` (legacy shape). When `msg.timestamp` is present and `id@timestamp` is absent from the index, `isSummarized` returns **false**. A permissive fallback would stub a live `bash_23` result because an *older* `bash_23` was summarized - exactly the silent failure AC 7 forbids. +- `src/chain-compressor.ts`: `chain.middleToolCallIds` are bare ids, and `:99-111` feeds them to `hasPerBatchSummaryCoveringAny` / `getToolRefsForToolCallIds` / `getPerBatchSummariesForToolCallIds`, which now live in occurrence-key space - bare lookups would find nothing and every chain would be skipped with empty `toolRefs`. `ChainRange` gains an in-memory `middleOccurrenceKeys: string[]` collected during the same detection walk; the three lookups take it. `middleToolCallIds` stays bare (the recovery-grace filter at `:34` is untouched); the persisted `ChainCompressionEntry.droppedToolCallIds` shape gains a companion field for the same reason - see "`droppedToolCallIds` becomes diagnostic-only" below. +- `src/spill.ts`: dedup registration (`:56-58`) uses occurrence keys. `blobPathFor` (`:16-17`) names sidecars from the sanitized **occurrence key**, so two occurrences of one id cannot overwrite each other's blob; recovery falls back to the legacy bare-id path when the occurrence-keyed file is absent. +- `index.ts:106`: the pending-capture filter's `isSummarized(tc.toolCallId)` passes the occurrence key. +- `src/tree-browser.ts:122`: `getRecord(ref.toolCallId)` resolves via the `SummaryToolCallRef`'s occurrence key. +- `src/query-tool.ts`: a bare id that matches more than one occurrence returns **all** matches, chronological, one `## toolRef:` block each. No newest-wins pick, no error-out: `tN` is the primary path, raw ids are the documented fallback (`src/query-tool.ts` tool description), collisions are rare, and each block is already truncation-bounded. Bare-id multi-match is as likely as id reuse itself, which is the premise of the issue - so the honest answer is all of them. +- `src/commands.ts`: the lookup at `:1014` follows the same resolution. + +### C. Orphan sweep (`src/pruner.ts`) + +A final post-condition after all three existing phases: a single forward pass with **per-turn** open-call tracking. Walking the array, an assistant message *replaces* the open set with its own `toolCall` ids; each matching `toolResult` consumes its id; a `toolResult` whose id is not open at that point is an orphan and is removed. O(n). + +The open set must not be session-cumulative. With one global seen-id set, an id used *validly* in an early turn stays "open" forever, so a later genuine orphan carrying that same id passes the check - which is precisely the collision case this spec exists for. + +This is exactly the incident's shape (live assistant dropped, its sibling's result left behind), and it also covers orphans A cannot reach: `pi-ai`'s `transform-messages.js` skips assistants with `stopReason === "error" | "aborted"` while keeping their results. + +A and C are not substitutes and the split of duties is exact: **A prevents the deletion of live turns; C prevents a deletion (from any cause) from being fatal.** C alone would have turned the observed incident into a legal request over a gutted context - failure with no error, which is worse to diagnose. A alone would leave the `stopReason === "error" | "aborted"` orphan path unhandled. + +Synthetic `"No result provided"` results injected by `insertSyntheticToolResults` have a matching `toolCall`, so the sweep leaves them alone. + +**Cache-prefix neutrality (load-bearing).** `doc/specs/2026-08-04-pruner-noop-serialization.md` pins two invariants: `pruneMessages` never mutates its input in place, and a no-op render returns the *same array reference* with `pruned: false` - that is what keeps the prompt-cache prefix byte-identical across renders. The sweep pre-scans and, finding no orphan, returns `messages` unchanged: no `.map()`, no re-serialization, `pruned` untouched. When it does fire it changes the prefix once, which is acceptable (the alternative is a fatal request); rewriting the prefix every turn to carry bookkeeping is not. + +### Diagnostics + +Three conditions report out-of-band: `unresolved-range` (A: unresolvable/ambiguous boundary, or skipped overlapping entry), `range-id-mismatch` (the `droppedToolCallIds` cross-check below), `orphan-sweep` (C fired). + +Channel: one new session entry type `context-prune-diagnostic` (`CUSTOM_TYPE_DIAGNOSTIC`, `DiagnosticEntryData { kind, detail }`) plus in-memory counters shown in the `/pruner` status line. Like every `context-prune-*` type except `-summary`, it is **not** in LLM context: zero tokens, zero prefix change, and it survives a restart so a user reporting a recurrence has evidence. Steering a warning into context is rejected (invalidates the cache prefix and spends tokens on a condition the agent cannot act on); `console.warn` alone is rejected (leaves no record). + +Diagnostics are deduped per `(blockId, kind)` per session, so a permanently unresolvable entry writes one entry, not one per render. `orphan-sweep` has no `blockId`; its dedup key is `("orphan-sweep", hash of the sorted swept ids)`, so a recurring identical sweep writes once per session while a *new* orphan shape still reports. + +### `droppedToolCallIds` becomes diagnostic-only + +The boundary timestamps A needs are *already* persisted on `ChainCompressionEntry` (`src/types.ts:422-459`), so old entries resolve to ranges with no migration and no re-summarization. That leaves the id array with no role in dropping. + +It is kept, still written by `compressEligible`, and **consumed as a cross-check**: after resolving a range, compare the ids actually dropped against the persisted array; on mismatch emit `range-id-mismatch` with both counts. The range always wins - the check never changes what is dropped. This keeps `droppedToolCallIds` itself untouched (smallest diff, no writer/test churn for that field) while making it earn its place by detecting boundary-resolution drift. A benign mismatch class exists (ids the persisted array picked up from a duplicate-id collision), so this is an inspection signal, not an error. The field's doc comment is demoted accordingly. + +### `ChainCompressionEntry` gains `droppedOccurrenceKeys` (persisted) + +The cross-check above is the only thing that still needs `droppedToolCallIds` at render time. The *synthetic chain body* needs something else: `chainSummaryText` (`src/pruner.ts:154-155`) looks up per-batch summary text via `indexer.getPerBatchSummaryTextForToolCallIds`, and that lookup is occurrence-keyed (section B) - `summaryBodies` entries are stored under `id@resultTimestamp`, not the bare id. A bare-id lookup at render time would either miss entirely (empty synthetic body on restart) or, worse, hit a *different* occurrence of a reused id and splice an unrelated batch's summary into this chain - the exact collision this spec's section B exists to prevent. Session-only (in-memory) `middleOccurrenceKeys` is not enough: it does not survive a restart, and `droppedToolCallIds` is all that gets replayed from `context-prune-chain` entries into the chain registry. + +`ChainCompressionEntry` therefore gains a second optional field, written alongside `droppedToolCallIds`: + +- `droppedOccurrenceKeys?: string[]` - the occurrence keys (`id@resultTimestamp`) for the same calls as `droppedToolCallIds`, populated from `chain.middleOccurrenceKeys` at compression time (`src/chain-compressor.ts`) and persisted on the same `context-prune-chain` entry. +- Read at render time by `src/pruner.ts` (`chainSummaryText`) and by `src/commands.ts` (`/pruner compact` re-summarization lookup): both prefer `entry.droppedOccurrenceKeys ?? entry.droppedToolCallIds`, falling back to the bare-id array when the new field is absent. +- **Additive and backward-compatible.** Optional, so pre-upgrade `context-prune-chain` entries - persisted before this field existed - deserialize unchanged and simply take the fallback arm: they look up their own summary bodies by bare id, which is correct for them because those bodies were themselves stored bare-keyed (an entry from before occurrence keying shipped never has an occurrence-keyed sibling to collide with). +- The cross-check in the previous section still reads `droppedToolCallIds` only - `droppedOccurrenceKeys` has no diagnostic role, it exists purely to make the synthetic-body lookup resolve correctly after a restart. + +This supersedes the "persisted shape untouched" framing used when `droppedToolCallIds` was demoted to diagnostic-only above (and in section B's summary of `src/chain-compressor.ts` changes): that statement is true of `droppedToolCallIds` itself, not of `ChainCompressionEntry` as a whole, which gains this one companion field. + +## Data flow + +**Capture (`turn_end`).** `captureBatch` pairs each assistant `toolCall` with its `toolResult` and stores that result's `timestamp` on the `CapturedToolCall`. `addBatch` keys the record `id@resultTimestamp`, mints/reuses the `tN` alias against that key, persists both in `context-prune-index`. Dedup compares content hashes as today but stores occurrence keys on both sides, so a repeated id can no longer alias an unrelated call. + +**Render (`pruneMessages`).** Phase order unchanged. Phase 1 computes each `toolResult`'s occurrence key for the summarized/record lookups. Phase 3 resolves ranges positionally, unions index ranges, inserts synthetics after each `startIndex`, strips thinking at each `endIndex`. Then the sweep runs once over the result. + +**Chain compression.** `compressEligible` keeps its shape and both entry points (`flushPending`, `/pruner compact` via `src/commands.ts:995-1026`) are unaffected, but its three indexer summary lookups take `middleOccurrenceKeys` instead of bare `middleToolCallIds` (section B). + +**Rebuild (`session_start`).** Index/alias/dedup replay from persisted entries; entries lacking `resultTimestamp` key by bare id, i.e. today's behavior. Chain entries replay as today. + +## Edge cases + +- **Unresolvable / ambiguous boundary, `finalAssistantTimestamp === null`, boundary message absent** → zero drops, no synthetic, one deduped `unresolved-range` diagnostic. +- **Overlapping entries** → union of indices; an entry starting inside another's dropped range is skipped with a diagnostic. +- **Legacy records** (pre-upgrade, no `resultTimestamp`) → bare-id keyed; a session spanning the upgrade retains today's mis-key risk for its older half: a live tool result whose provider id collides with a pre-upgrade summarized record can be stub-replaced with that record's stale content via the `hasLegacyBareRecord` fallback in `src/pruner.ts`. Accepted explicitly: no migration, no re-summarization. New (occurrence-keyed) records are unaffected. +- **Idempotency** → the existing `existingSyntheticBlockIds` pre-scan (`chain-range-prune.ts:56-58`) stays, and because drops are role-restricted the already-inserted user-role synthetic survives inside its own range, so a second application yields the identical output. The sweep is a pure function of its input. +- **Mixed batching modes** → a session that switched `batchingMode` mid-flight holds per-batch summaries both inside and outside chain ranges; the coverage check suppresses both. + +## Testing + +`bun test src/`, colocated `*.test.ts`, existing fixture style (plain messages with explicit `timestamp`s; `entry()` helper for persisted chain entries). Fixtures without timestamps gain them - "existing tests pass unchanged" is explicitly **not** an acceptance criterion. + +- **#8 regression, failing-first**, constructed to the shape the code defect permits (two compressed chains, then a live turn reusing a dropped id alongside a fresh one). Fully specified here; no incident artifact is needed to write it: + + ``` + 0 user ts=1000 <- chain b5 start + 1 assistant ts=1100 toolCalls[bash_18] + 2 toolResult ts=1150 bash_18 + 3 assistant ts=1200 (text only) <- chain b5 end + 4 user ts=2000 <- chain b7 start + 5 assistant ts=2100 toolCalls[bash_23] + 6 toolResult ts=2150 bash_23 + 7 assistant ts=2200 (text only) <- chain b7 end + 8 user ts=3000 <- live turn, no chain entry + 9 assistant ts=3100 toolCalls[bash_23, gauntlet_setting_24] <- id reuse + 10 toolResult ts=3150 bash_23 + 11 toolResult ts=3160 gauntlet_setting_24 + ``` + + Entries: `b5 = {start 1000, final 1200, dropped [bash_18]}`, `b7 = {start 2000, final 2200, dropped [bash_23]}`. Pre-fix, indices 9-10 are deleted and 11 survives orphaned. Post-fix: 9, 10, 11 all survive; only 1-2 and 5-6 are dropped. +- `resolveRange`: unique match; duplicate boundary timestamp; `finalAssistantTimestamp === null`; `endIndex <= startIndex`; missing boundary message - each "drop nothing, no synthetic", each with its diagnostic. +- Positional replacements for the id-based assertions: protected-output relocation (`chain-range-prune.test.ts:422-443`, `:448-510`), per-batch summary suppression (both inside and after the range), thinking strip at `endIndex`, overlapping-entry skip, role restriction (a third-party `custom_message` inside a range survives). +- Occurrence identity (issue #9's AC set): two batches both containing `bash_23` keep both records recoverable and each `tN` returns its own batch's output; a live result colliding with a summarized id is not stub-replaced (fail-closed fallback); a live batch colliding with summarized ids is still captured and summarized; content-hash dedup does not alias across batches that merely share a colliding id; branch rescan pairs each turn's calls with its own results; `session_start` rebuild reproduces both colliding records *and* both `tN` aliases via summary refs and dedup aliases; two spilled occurrences of one id get distinct sidecar paths, and a legacy bare-id blob still resolves. +- `query-tool`: bare id with two occurrences returns both blocks, chronological. +- **Orphan invariant as one shared helper** ("no `toolResult` survives without its `toolCall`"), applied across the chain/prune cases rather than restated per test. +- Sweep: fires on a hand-built orphan and emits its diagnostic; clean input returns the **identical** array (`toBe`, not `toEqual`) so the cache/no-op guarantee is pinned by a test; a fixture-wide assertion that the sweep fires **zero** times across every existing chain/prune fixture - it is a net, not a crutch. +- Idempotency: applying the same `ChainCompressionEntry[]` twice is a no-op. +- Rebuild from `context-prune-index` entries lacking `resultTimestamp` → id-only keys, today's behavior. + +Plus the repo's `tsc --noEmit` invocation (AGENTS.md) and an end-to-end smoke run (`pi -e ./index.ts --no-extensions -p ...` against an isolated `$PI_CODING_AGENT_DIR`), checking that `context-prune-diagnostic` entries appear and do not appear as expected. + +**No replay against a real incident session is possible or required.** Every affected session was repaired in place (ids rewritten unique, orphans removed), so no artifact reproduces the failure; the constructed fixture above is the sole regression vehicle. A repaired session remains useful as a **non-regression** check only: replaying one through the new `pruneMessages` must leave its orphan count at zero and must not drop more messages than the persisted chain ranges cover. + +## Acceptance criteria + +Issue #8's own AC section is empty (two headings, `Drop path:` and `Identity:`, no bullets; part C has no heading at all). These are the criteria for this change. + +Drop path: + +1. A live assistant turn whose tool-call id collides with a dropped chain id survives the render intact, together with all of its tool results (failing-first constructed fixture; no real-session artifact is required, and none exists). +2. Chain drops are decided by index range only; no chain code path drops or strips by id-set or timestamp membership without a positional bound. The part C orphan sweep is excepted - it is an id/ordering invariant by construction, not a range rule. +3. Unresolvable or ambiguous boundaries, and `finalAssistantTimestamp === null`, drop nothing and insert no synthetic; no id-array-wide fallback exists. +4. `perBatchSummaryOverlapsDropped`, protected-output relocation, and final-thinking strip are each range-scoped and each has a test; per-batch summary suppression also holds when the summary sits *after* `endIndex` (`batchingMode: "agent-message"`). +5. Re-applying the same `ChainCompressionEntry[]` is a no-op, including preservation of the already-inserted synthetic chain message. +5a. Only `assistant`, `toolResult`, and `CUSTOM_TYPE_SUMMARY` messages are dropped from a range; user-role and third-party `custom_message` entries inside the range survive. + +Identity: + +6. Two batches that both contain `bash_23` keep both records recoverable, and `context_tree_query` on each `tN` returns its own batch's output. +7. A live tool result whose id collides with a summarized id is not stub-replaced. +8. A live batch whose ids collide with summarized ids is still captured and summarized. +9. Content-hash dedup does not alias across batches that merely share a colliding id, and two spilled occurrences of one id do not share a sidecar path. +10. `session_start` rebuild reproduces both colliding records **and both `tN` aliases** (summary refs and dedup aliases included); entries without `resultTimestamp` fall back to bare-id keys. +10a. Branch rescan pairs each assistant's tool calls with the results of its own turn, so a colliding id cannot pair an earlier call with a later result. +10b. A live tool result carrying a `timestamp` with no occurrence-keyed record is treated as *not* summarized (fail-closed), even when an older occurrence of the same bare id was summarized. +11. `context_tree_query` with a bare id matching multiple occurrences returns every match, chronologically. + +Orphan post-condition: + +12. A rendered array never contains a `toolResult` without a preceding surviving `toolCall`, asserted via one shared helper across cases. The check is structural and provider-agnostic: it must not branch on any provider's error string (see Problem section for why Anthropic's and Kimi K3's rejections are the same defect). +13. The sweep fires zero times across all existing chain/prune fixtures, and its open-call tracking is per-turn (an id validly used earlier does not license a later orphan). +14. A render in which the sweep does not fire returns the identical input array reference (`toBe`) with `pruned` unchanged. +15. Every diagnostic condition writes a `context-prune-diagnostic` session entry and increments a `/pruner` status counter; none of them adds anything to LLM context. + +## Provenance + +Two incidents, same defect, both on pi-condense 2.5.0: + +1. The session referenced in issue #8 (`400 unexpected tool_use_id found in tool_result blocks`). Its JSONL is hand-edited-during-recovery and was not retrievable via `gh issue view --json body,comments`; provenance only. +2. A `gridstrong` session that failed with `400 Kimi K3 tool messages need a resolvable tool name: carry `tool`/`name`, or match a preceding assistant tool_call by order` - the Kimi wording for an unmatchable (orphan) tool message. Inspected mid-incident, then repaired in place, so its pre-repair state is unrecoverable. + +**No incident artifact survives**: all affected sessions were repaired in place, which rewrote tool-call ids to be unique and removed orphans. Nothing in this spec depends on one - the constructed fixture in Testing is self-contained, and the acceptance criteria are stated against it. + +## Open questions + +None open. (The raw incident JSONL would add nothing beyond the minimized fixture; if it later surfaces, it is a cross-check, not a prerequisite.) + +## Documentation impact + +Materiality bar: `pi-gauntlet/skills/brainstorming/reference/documentation-impact.md`. + +- Feature / user-facing docs introduced: none +- Materially amended existing docs: `PRUNING.md` (range drop is positional; identity is occurrence-keyed; orphan sweep and its cache-neutrality), `README.md` (`/pruner` status diagnostic counters, `context_tree_query` bare-id multi-match), `CHANGELOG.md` +- Derived / memory docs invalidated: `AGENTS.md` customTypes table (new `context-prune-diagnostic` row; the `context-prune-chain` row's id-drop wording) diff --git a/pi-condense/doc/specs/2026-08-13-budget-window-cap.md b/pi-condense/doc/specs/2026-08-13-budget-window-cap.md new file mode 100644 index 0000000..866dc0a --- /dev/null +++ b/pi-condense/doc/specs/2026-08-13-budget-window-cap.md @@ -0,0 +1,161 @@ +# Budget-trigger window ceiling (300k) + +Both token-budget flush triggers scale off the host-reported model context window, which makes them unreachable on huge-window models. This spec caps the window the triggers reason about at 300,000 tokens, without adding a setting. + +Origin: [jjuraszek/pi-condense#7](https://github.com/jjuraszek/pi-condense/issues/7) - not its title (which asked for a threshold trigger that already shipped), but the reporter's follow-up comment. + +## Problem + +`src/budget.ts` computes both triggers against `usage.contextWindow`: + +- `shouldBudgetFlush` (`src/budget.ts:9-16`): `usage.tokens / usage.contextWindow >= threshold` +- `usageFraction` (`src/budget.ts:18-22`), consumed by `shouldDeltaFlush` (`src/budget.ts:24-38`): the same ratio + +`usage.contextWindow` comes from the host's `AgentSession.getContextUsage()`, whose value is `model.contextWindow` - the model's advertised window. On a 1M-window model, therefore: + +| setting | 200k model | 1M model | +|---|---|---| +| `autoBudgetThreshold = 0.4` | fires at 80k tokens | fires at 400k tokens | +| `autoBudgetThreshold = 0.9` | fires at 180k tokens | fires at 900k tokens | +| `budgetTurnDelta = 0.1` | fires on +20k in a turn | fires on +100k in a turn | + +Two consequences: + +1. **The threshold trigger fires far too late, or never.** A session ends long before 900k tokens, so a user who set 0.9 observes "pruning never happens" - exactly what #7's reporter reported: *"using gpt model that have 1 million window when I set 272k window config still not pruning it, but using deepseek flash at 200k is pruning."* +2. **The delta trigger is dead, not merely late.** A single turn practically never grows context by 100k tokens, so on big-window models the re-arm path never fires at all. + +This repo already has the receipts for the cost of pruning too late: `doc/specs/2026-08-12-single-chain-observability-trigger-repair.md` documents a real 1M-window session - 256 turns, ~5h, single tool chain, ~195k raw tool-result tokens resident, peak usage 53.7%, `autoBudgetThreshold = 0.8` never crossed. Every one of those 256 turns paid full prompt cost on tool output that pruning exists to remove. + +Prior work addressed adjacent gaps and not this one: `doc/specs/2026-05-30-protected-chain-empty-summary-budget.md:257-365` introduced `autoBudgetThreshold`; `doc/specs/2026-06-02-oversized-output-spill.md:220-318` added `budgetTurnDelta` for the *timing* gap (a single oversized result spiking usage between turn boundaries); the #6 spec addressed the *structural* gap (no `agent-message` boundary in a single-chain session). The window-size interpretation mismatch is a third, independent failure mode. + +### Non-goals + +- No new user-facing setting. `autoBudgetThreshold` and `budgetTurnDelta` remain the only budget knobs; a third knob would have to be explained in terms of the other two. +- No escape hatch to restore uncapped behavior. This is a product decision, not a compatibility argument: above the cap, flushing earlier is the fix, and below it nothing changes. +- No change to what pruning *does* once triggered, to `pruneOn` modes, to chain compression, or to the status widget. +- Not a diagnostic-only fix (e.g. surfacing the reported `contextWindow` on `/pruner status`). That would inform users while leaving the default behavior broken. + +## Design + +One exported constant and two one-line formula changes in `src/budget.ts` - the only **production-logic** change. Full changed-file list: `src/budget.ts` (logic + docstrings), `src/budget.test.ts` (tests), `src/types.ts` + `src/commands.ts` (docstrings and the live settings description), `README.md`, `doc/configuration.md`, `PRUNING.md` (docs). + +```ts +export const MAX_BUDGET_WINDOW = 300_000; +``` + +**Threshold trigger** - absolute ceiling on the trigger *level*: + +```ts +return usage.tokens >= Math.min(MAX_BUDGET_WINDOW, threshold * usage.contextWindow); +``` + +Reads as: *fire at your percentage of the model's window, or at 300k tokens, whichever comes first.* The threshold keeps its literal meaning; the cap is only a ceiling. (`threshold <= 1` is already enforced, so a `min` against `usage.contextWindow` itself would be redundant and is omitted.) + +**Delta trigger** - ceiling moved into the denominator: + +```ts +return usage.tokens / Math.min(usage.contextWindow, MAX_BUDGET_WINDOW); +``` + +The two shapes differ deliberately, and that asymmetry is the one non-obvious thing in this change, so it carries a code comment above `MAX_BUDGET_WINDOW`, verbatim: + +```ts +// Ceiling on what the budget triggers treat as the context window. Advertised +// windows reach 1M, which makes any (0,1] fraction unreachable in a real session. +// The two triggers apply it in different shapes on purpose: the threshold is a +// LEVEL, so the cap bounds the level itself (min(CAP, threshold * window)); the +// delta is a GROWTH RATE, where a 300k ceiling could never bind, so the cap +// enters through the denominator instead (delta * min(window, CAP)). +``` + +The threshold is a *level*, so a ceiling on the level is meaningful. The delta is a *growth rate*, and a 300k ceiling on growth would never bind (no turn grows context by 300k), so the cap has to enter through the denominator. Algebraically `delta * min(W, CAP)` is `min(delta*W, delta*CAP)` - it is still a ceiling, just one proportional to the configured delta. A standalone growth constant (e.g. a hardcoded 30k) was rejected: at `budgetTurnDelta = 0.3` on a 200k model it would fire at +30k where today it fires at +60k, breaking the invariant below, and it would be a second magic number silently required to stay in ratio with the first. + +### Why 300,000 + +The ceiling's only job is to stop the setting from being *unreachable*. Users keep full downward control - on a 1M model, `autoBudgetThreshold = 0.1` still means 100k - so the ceiling does not need to be tight, it needs to clip the pathological tail. + +300k buys a clean invariant: **the ceiling never binds on a model advertising 300k or less, at any threshold or delta value** - so no user on a model at or below 300k sees any change. Above the cap, behavior does change: those users now flush earlier, which is the entire point of the fix (a 400k window at `0.9` moves from 360k to 300k; 1M at `0.9` from 900k to 300k). Alternatives considered and rejected: + +- **200k** - breaks the invariant: a 256k-window model at `0.9` would clip from 230.4k to 200k, silently changing behavior for a mainstream model. +- **250k** - breaks it marginally (a 272k window at `>= 0.92`). +- **400k** - fixes `0.9` on 1M (900k -> 400k) but leaves `0.4` on 1M unchanged, i.e. no improvement for the common case. + +Long-context quality evidence supports a ceiling in this region but is **not** the primary argument: RULER ([arXiv:2404.06654](https://arxiv.org/abs/2404.06654), COLM 2024) found that of 17 models claiming >= 32k, "only half of them can maintain satisfactory performance at the length of 32K"; NoLiMa ([arXiv:2502.05167](https://arxiv.org/abs/2502.05167), ICML 2025) found that at 32k, 11 of 13 models claiming >= 128k fall below 50% of their own short-context baseline (GPT-4o: 99.3% -> 69.7%). Neither covers a 2026 frontier 1M-window model, so neither can justify a specific ceiling on quality grounds. The primary argument is per-turn prompt cost, evidenced by the #6 session above. + +### Effect + +| model window | setting | today | after | +|---|---|---|---| +| 200k | `threshold 0.4` | 80k | 80k (unchanged) | +| 256k | `threshold 0.9` | 230.4k | 230.4k (unchanged) | +| 272k | `threshold 1.0` | 272k | 272k (unchanged) | +| 1M | `threshold 0.4` | 400k | 300k | +| 1M | `threshold 0.9` | 900k | 300k | +| 200k | `delta 0.1` | +20k | +20k (unchanged) | +| 1M | `delta 0.1` | +100k (never fires) | +30k | + +For any model at or below the cap, the change is a no-op; above it, flushes happen earlier by design, and no case flushes *later* than today. + +### Contract change: `usageFraction` may exceed 1 + +`usageFraction`'s docstring currently promises a 0-1 fraction. That promise is retracted: at 600k tokens on a 1M-window model it now returns 2.0. The value is **not** clamped - clamping at 1.0 would make the delta trigger saturate above the cap and stop re-arming, reproducing the exact failure this spec removes. + +Both consumers are safe with unbounded values, verified: `shouldDeltaFlush` (`src/budget.ts:24-38`) only takes a difference of two fractions, and `index.ts:950` stores the value as `previousFraction` for the next turn. The status widget does not consume `usageFraction`, so there is no display math to reconcile. + +### Edge cases + +| case | behavior | +|---|---| +| `contextWindow <= 300k` | `min` never binds; byte-identical to today | +| `usage.tokens == null` (right after host compaction; the host only trusts assistant usage newer than the last compaction) | existing guards return `false` / `null`; unchanged | +| `usage` absent, or `contextWindow <= 0` | existing guards return `false` / `null`; unchanged | +| `threshold` outside `(0, 1]` | rejected by existing guard in `shouldBudgetFlush` and by `src/config.ts:83-89` validation; unchanged | +| fraction above 1.0 | permitted, unclamped; `previousFraction` may hold `> 1` | +| `threshold = 1.0` on a 1M model | fires at 300k, not 1M - intended tail-clipping | +| tiny window (e.g. 8k local model) | `min(300k, 0.4 * 8k) = 3.2k`; unchanged | +| `previousFraction == null` (first turn after load) | delta never fires; unchanged - the threshold trigger covers that turn | + +## Testing + +Extend `src/budget.test.ts` (bun, existing `describe` / `it` / `expect` style). Tests import `MAX_BUDGET_WINDOW` rather than duplicating the literal, except the one assertion on the constant's own value. + +`shouldBudgetFlush`: +- 1M window, `0.4`: fires at 300,000; does not fire at 299,999. +- 1M window, `0.9`: fires at 300,000 (the ceiling, not 900k). +- 200k window, `0.4`: fires at 80,000, not at 79,999 (unchanged behavior). +- 256k window, `0.9`: fires at 230,400, not below (invariant at the boundary that 200k would have broken). +- 300k window, `1.0`: fires at exactly 300,000. + +`usageFraction`: +- 1M window, 600k tokens: returns 2.0 (proves no clamp). +- 200k window, 80k tokens: returns 0.4 (unchanged). + +`shouldDeltaFlush`: +- 1M window, `0.1`: fires on 100k -> 130k; does not fire on 100k -> 120k. +- 200k window, `0.3`: still requires +60k (unchanged; the rejected standalone-constant design would fail this). + +All existing tests in `src/budget.test.ts` must pass unmodified - that is the mechanical proof of the invariant. + +Verification: `bun test src/` plus the typecheck command in `AGENTS.md`. + +## Documentation impact + +- Feature / user-facing docs introduced: none +- Materially amended existing docs: `README.md` - the single `autoBudgetThreshold` row (`README.md:163`); the settings table has no `budgetTurnDelta` row and this spec does not add one (that knob is documented in `doc/configuration.md`, which the README row links onward to). Replacement text for that row's description: `Fraction (e.g. 0.8) of the context window that force-flushes everything regardless of pruneOn; the trigger point is capped at 300k tokens`. `doc/configuration.md` - the `autoBudgetThreshold` row (`:61`), the `budgetTurnDelta` row (`:64`), and the "Token-budget auto-flush" section (`:77`). `PRUNING.md` - the two trigger sections, "Token-budget auto-flush trigger" (`:817`) and "Budget-delta flush" (`:825`, whose formula is written as `tokens / contextWindow`), **and separately** "Single-chain sessions" (`:968-976`), whose "lower `autoBudgetThreshold`" advice is partly obsoleted by the ceiling +- Derived / memory docs invalidated: none - `AGENTS.md`'s routing table and layout section describe `src/budget.ts` at a granularity this change does not alter + +In-code documentation surface (implementation, not doc-impact entries): the `src/budget.ts` docstrings (including `usageFraction`'s retracted 0-1 promise and the level-vs-growth asymmetry comment), `src/types.ts:385-407` setting docstrings, and `src/commands.ts:236-240` `autoBudgetThresholdDescription`, which renders live in `/pruner settings`. + +Wording across all sites states the two contracts separately and exactly as coded - there is no single unified "effective window" phrase, because the two triggers do not share one denominator: + +- threshold: fires when context reaches `min(300k, threshold * model window)` tokens - *your percentage of the model's window, or 300k tokens, whichever comes first* +- delta: fires when a single turn's growth reaches `delta * min(model window, 300k)` tokens - i.e. the growth fraction is measured against a window capped at 300k + +Writing the threshold as "`threshold` of an effective window of `min(window, 300k)`" would be wrong: on a 1M model at `0.4` that reads as 120k, where the implementation fires at 300k. + +## Open questions + +None blocking. Recorded for completeness, all deliberately not resolved by this spec: + +- Where the reporter's "272k window config" actually lived (host `models.json` override, a gateway, or misremembering) is unknown; the reporter never replied and #7 is closed. The design does not depend on the answer - a host-side `contextWindow` override does reach `getContextUsage()`, and the ceiling works whether or not one is configured. +- Whether to reply on the closed #7 or open a fresh issue per this repo's ticket convention is a process choice, made at ship time, not a design input. diff --git a/pi-condense/doc/specs/2026-08-14-uncovered-chain-deterministic-backfill.md b/pi-condense/doc/specs/2026-08-14-uncovered-chain-deterministic-backfill.md new file mode 100644 index 0000000..0dd1c62 --- /dev/null +++ b/pi-condense/doc/specs/2026-08-14-uncovered-chain-deterministic-backfill.md @@ -0,0 +1,360 @@ +# Uncovered-chain deterministic compression with recoverability backfill + +Fixes [jjuraszek/pi-condense#10](https://github.com/jjuraszek/pi-condense/issues/10). + +## Problem + +`compressEligible` (`src/chain-compressor.ts:118-126`) permanently skips eligible +chains with zero per-batch summary coverage (`reason: "no-summary"`). The skip is +structural: once the prune frontier passes a span without a summary body (trivial +batch, `skipped-oversized`, fully-deduped batch, or a capture miss), +`trimBatchToPendingRange` excludes it from future capture forever. No amount of +waiting, `/pruner now`, or `/pruner compact` recovers it. + +Motivating incident (session `2026-08-12T10-47-56`): a 639-call chain, ~935k chars +(~300k tokens, ~62% of a 620k context window) stranded live, repeatedly skipped +with `no-summary`. + +Zero coverage is a normal-path occurrence, not exotic - the fix must be harmless +when triggered: deterministic, zero LLM calls, fail-closed. + +## Decisions (user-ratified) + +| # | Decision | +|---|---| +| D1 | Body contract: reuse `rangeSummaryText` for the deterministic stub text + optional `bodySource: "deterministic"` discriminant on the chain entry. Zero renderer change; entries without `bodySource` keep current semantics. | +| D2 | Anti-regression cage: deterministic unit tests only, exhaustive over the ACs. No automated e2e harness (repo convention: `bun test src/`; e2e smoke stays manual per AGENTS.md). | +| D3 | Architecture: compressor-owned backfill. Branch messages threaded into `compressEligible` deps; new indexer method `backfillChainRecords` persists the index entry and issues refs, skipping dedup-canonical registration. Covered path untouched. | +| D4 | Design rule (now in AGENTS.core.md, all four repos): no new machinery if not essential. Every addition below reuses an existing field, type, or path plus a small discriminant. | +| D5 | Adjudicated at review, not pre-planned (see "Implementation deviations" below): fully-protected zero-coverage chains skip silently (no `backfill-empty`); `extractChainRecords` takes `isIndexed` only, protection is read from `chain.protectedToolCallIds`; `buildDeterministicBody` drops the unused `chain` param; `backfillChainRecords` takes an `opts` object (spill config + session identity + `appendEntry`), not a bare callback; `resolveRange`'s entry param widens to a `Pick<...>` type (no behavior change). | + +## Architecture + +`compressEligible` gains a second branch at the current `no-summary` skip. +`CompressEligibleDeps` grows: `messages: any[]` (**the same array chain +detection ran on**, i.e. the `withClosingMessage(...)` output - threading raw +`branchMessages` would lose the closing assistant on the message_end path with +`rollingWindow: 0` and spuriously fail span resolution), `diagnostics: +Pick`, and `backfill: { spillThreshold; +spillPreviewBytes; sessionDir; sessionId }` (the spill seam). Both call sites +(`flushPending` at `index.ts:658-671` and `/pruner compact` at +`index.ts:1035-1043`) already hold all of these. + +For an eligible zero-coverage chain: + +1. **Resolve + extract** - resolve the chain's positional span with the existing + `resolveRange` (`src/chain-range-prune.ts`) - same fail-closed duplicate- + timestamp semantics as the drop path. As shipped, `resolveRange`'s entry + param widens to `Pick` so `extractChainRecords` can call it with a + `ChainRange` instead of a persisted chain entry - type-level only, behavior + identical, regression-tested (see "Unchanged" below for scope). Walk the + resolved span; for each middle tool call **not protected** (read from + `chain.protectedToolCallIds`, the same in-chain data `detectChains` already + populated - protected outputs are relocated verbatim at render time and must + never be phase-1 stubbed, so they are never backfilled) and **not already + indexed** (predicate: index membership, i.e. the indexer's private `index` map + has the occurrence key - NOT `isSummarized()`, which also covers dedup aliases + and would strand the fully-deduped sub-case named in Problem), build a + `ToolCallRecord`: + - `toolCallId`, `toolName`, `args`, `isError` from the messages; + - `resultText` = full result text; `timestamp` = the toolResult message + timestamp; `resultTimestamp` likewise (occurrence-key discriminant, via + existing `occurrence-key.ts` helpers); + - `turnIndex` = `-1` sentinel (backfilled records have no batch turn; + `context_tree_query` renders `Turn: -1` - accepted cosmetic). + Dedup-aliased members are re-indexed verbatim; their existing alias entries + keep resolving unchanged. Extraction is a pure walk returning full-text + records; spilling happens in step 2. +2. **Backfill (fail-closed, append-before-commit)** - + `indexer.backfillChainRecords(records, opts)` (async), where `opts = { + spillThreshold, spillPreviewBytes, sessionDir, sessionId, appendEntry }`: + - spill oversized results via the shared spill helpers (`blobPathFor`/ + `applySpill`), a fail-closed variant of the eager `spillOversizedBatch` + path (record gets `spillPath`/`spillBytes`/`resultPreview`/`contentHash`, + `resultText` emptied) - a ~935k-char member must not be inlined into the + session JSONL; + - allocate `t` refs via the existing `allocateSummaryRefs` / + `buildShortToolCallRefs` (monotonic `nextShortAliasNumber`); + - persist **one** `context-prune-index` entry carrying the records, the + allocated `SummaryToolCallRef[]`, and `backfilled: true` - refs ride the + index entry so they are durable the moment the records are (atomic); + - **only after the append succeeds**, commit in-memory state: records into + the `index` map, aliases via `registerSummaryRefs`. A failed append leaves + records and alias maps unchanged (burned alias numbers are acceptable - + `t` is opaque and monotonic, gaps are harmless); + - never touch `contentHashToOriginal`. + Any throw -> keep the existing `no-summary` skip; retried next flush. +3. **Compose** - `buildDeterministicBody(records, refs)`: call count, + tool-name histogram, span duration, first/last call name + args excerpt capped + at 200 chars, and the refs. Zero LLM calls. On a retry where some/all members + are already indexed (prior partial failure), refs for indexed members are + recovered via the existing `getToolRefsForToolCallIds` and the records via the + index - the body is composed over the full chain, not just newly extracted + records. +4. **Persist** - chain entry as today, plus `rangeSummaryText` (the deterministic + body) and `bodySource: "deterministic"`. + +**Retry discriminator** (partial-failure convergence): zero extractable records +AND zero chain members present in the index -> genuine span mismatch -> +fail-closed skip + `backfill-empty` diagnostic. Zero newly extracted records but +members ARE in the index (prior index append succeeded, chain append failed) -> +proceed to compose + persist the chain entry using the already-durable records +and refs. The same observable "nothing new to extract" state never routes to a +permanent skip when recovery data exists. + +**Fully-protected exception to the discriminator** (D5, adjudicated at review): +if the zero-extractable/zero-indexed state above is instead explained by every +middle call being protected (`chain.middleToolCallIds` non-empty and all present +in `chain.protectedToolCallIds`), the chain still takes the plain `no-summary` +skip but **without** the `backfill-empty` diagnostic. Compressing such a chain +saves zero tokens - every output would be relocated verbatim into the synthetic +body at render time regardless - and reporting `backfill-empty` for it would +misreport a healthy, fully-protected span as a mismatch, re-firing on every +restart for protected-heavy configs. This guard runs first; `backfill-empty` +only fires for the remaining genuine-mismatch case. + +Covered chains take the existing branch untouched - byte-identical by construction. + +## Components + +### `src/chain-compressor.ts` + +- `CompressEligibleDeps` gains `messages`, `diagnostics`, and the spill seam + (see Architecture). +- New pure helper `extractChainRecords(messages, chain, isIndexed): + ToolCallRecord[]` - `resolveRange`-based span walk; no separate protection + predicate param, protection is read from `chain.protectedToolCallIds` + (in-chain data already populated by `detectChains`). +- New pure helper `buildDeterministicBody(records, refs): string` - no `chain` + param (unused by the body grammar). +- `CompressEligibleResult` unchanged (no new counters - cut as non-essential per + D4; `bodySource` on the persisted entries already distinguishes deterministic + compressions for offline inspection). + +### `src/indexer.ts` + +- `backfillChainRecords(records, opts): Promise` where + `opts = { spillThreshold, spillPreviewBytes, sessionDir, sessionId, + appendEntry }` - async; spill, allocate refs, append the marked index entry, + then commit maps (order per Architecture step 2). `ToolCallIndexer` owns no + appender today, so the callback rides the options object alongside the spill + config and session identity it also needs (mirrors what the `turn_end` + eager-spill path already assembles for `spillOversizedBatch`, `index.ts:900`). +- `reconstructFromSession`: index entries with `backfilled: true` -> records + restored into the `index` map, refs re-registered via `registerSummaryRefs`, + dedup-canonical registration (`contentHashToOriginal`) skipped. Rationale: + alias reconstruction today happens only from `CUSTOM_TYPE_SUMMARY` entries + (`src/indexer.ts:95-97`); backfilled chains have no summary message, so the + backfilled index entry is the durable ref carrier. + +### `src/types.ts` + +- `ChainCompressionEntry` gains `bodySource?: "deterministic"`. +- Index entry data gains `backfilled?: true` and optional + `refs?: SummaryToolCallRef[]` (present only on backfilled entries). +- `DiagnosticKind` union gains `"backfill-empty"` (closed 3-member union today, + `src/types.ts:97`). +- All optional -> pre-existing entries load unchanged. + +### `src/diagnostics.ts` / `src/commands.ts` + +- `DiagnosticSink.counters` literal gains the new kind. +- `pruneStatusText` (`src/commands.ts:70-77`) hardcodes the three existing kinds; + gains `backfill-empty` (widget suffix, e.g. `diag ... b`). +- `/pruner compact` report text unchanged (deterministic-vs-fused distinction cut + as non-essential; the persisted `bodySource` field carries that information). + +### `index.ts` + +- Threads `messages` (the `withClosingMessage` array), `diagnostics`, and the + spill seam into both `compressEligible` call sites. +- `context-prune-flush-metrics`: unchanged (new counter cut as non-essential). + +### `src/chain-range-prune.ts` + +- `resolveRange`'s entry param widens from `ChainCompressionEntry` to + `Pick` + so `extractChainRecords` (which walks a `ChainRange`, not a persisted entry) + can call it directly. Type-level only; behavior identical, regression-tested. + Renderer behavior otherwise unchanged: still prefers `entry.rangeSummaryText` + (`src/pruner.ts:154`); protected-output relocation is still live-by-id, + coverage-independent. + +### Unchanged + +`src/pruner.ts`, `src/summarizer.ts`, `src/block-refs.ts` (`b` issuance +identical for both branches), `CompressEligibleResult`, flush-metrics entry +shape, compact report text. + +## Data flow + +**Live flush (automatic - no command required).** `flushPending` finishes +per-batch summarization -> `detectChains` -> `compressEligible`. Zero-coverage +chains take resolve/extract -> backfill -> compose -> persist. Ordering: index +entry (with refs) before chain entry. If the chain-entry persist fails, records +AND refs are already durable; the next flush retries and the retry discriminator +routes it to compose-from-index, converging without duplicate index entries. + +**Known limitation (stated, not fixed here):** `flushPending` returns early when +there are no pending batches, before chain detection. A stranded chain in an +otherwise idle session is therefore healed on the next flush that has any work, +or immediately by `/pruner compact` - not by `/pruner now` on an empty queue. +Acceptable: the motivating incident occurred in an actively flushing session. + +**Render.** Unchanged: `pruneMessages` resolves ranges positionally and prefers +`entry.rangeSummaryText`. The deterministic body renders inside the same +`` envelope with working `t` refs. + +**Recovery.** `context_tree_query("tN")` -> alias map -> occurrence key -> +backfilled record (or its spill file) -> raw body. Identical to summary-issued +refs. + +**Restart.** `reconstructFromSession` replays entries in order: backfilled index +entries restore records + aliases (dedup skipped), chain entries restore the +registry. Post-restart behavior is identical to pre-restart, including the +partial-failure retry (records + refs reconstruct from the index entry alone). + +**`/pruner compact`.** Same `compressEligible` call - all uncovered chains handled +in one pass, no cap (no LLM cost to bound), zero summarizer traffic. + +## Error handling and edge cases + +- **Backfill throws** (spill I/O, append failure, malformed span) -> local catch, + chain skipped with `reason: "no-summary"` (fail-closed). Append-before-commit + guarantees in-memory maps are untouched on failure, so phase-1 stub-replace + cannot fire for records whose backfill never became durable. Retried next flush. +- **Index entry persisted, chain entry persist fails** -> records + refs durable; + retry discriminator (see Architecture) composes from the index. Converges, no + duplicate index entries, no re-stranding. +- **Genuine span mismatch** (zero extractable records AND zero members indexed, + and NOT explained by full protection - see next bullet) -> fail-closed skip + + `DiagnosticSink` report, kind `backfill-empty`, deduped per chain key. +- **Fully-protected uncovered chain** (D5) -> zero extractable records and zero + members indexed, but every middle call is in `chain.protectedToolCallIds` -> + fail-closed skip, same as a genuine mismatch, but **no** `backfill-empty` + diagnostic. Compressing would save zero tokens (everything is relocated + verbatim at render regardless), and reporting a mismatch here would misreport + a healthy span, re-firing every restart for protected-heavy configs. +- **Oversized member results** -> spilled via the shared spill helpers + (`blobPathFor`/`applySpill`), a fail-closed variant of the eager + `spillOversizedBatch` path; the index entry carries `spillPath`/ + `resultPreview`, never the full body. +- **Empty args/results** -> body non-empty by construction (count / histogram / + duration always present); excerpts capped at 200 chars. +- **Protected outputs inside an uncovered chain** -> excluded from backfill + entirely (never indexed -> never phase-1 stubbed, even in the chain-entry + failure window); at render time `chain-range-prune.ts` relocates them verbatim + by bare id within the resolved range, unchanged. If protection covers *every* + middle call in the chain, the chain hits the fully-protected exception above + instead of `backfill-empty`. +- **Dedup** - backfilled records never become `contentHashToOriginal` canonicals + (live and reconstruction paths both honor `backfilled: true`). A later identical + output finds no canonical and is indexed normally. Fully-deduped chains ARE + backfilled (index-membership filter, not `isSummarized`); their pre-existing + alias entries keep resolving. +- **`isSummarized()`** is true for backfilled occurrences once the index append + commits: phase-1 stub-replace may fire even if the chain entry append later + fails - acceptable, the raw content is durable and recoverable. +- **Legacy entries** (no `bodySource` / `backfilled` / `refs`) -> absence + preserves today's semantics exactly. +- **Cache-prefix impact** - compressing a chain rewrites in-flight history from + that point, invalidating the provider prompt-cache suffix **once**. Frequency + is unchanged: eligibility timing is owned by the existing rolling-window logic; + this fix only removes the terminal skip, so marginal invalidations = one per + previously-stranded chain. The deterministic body is render-stable (no `now()`, + no LLM nondeterminism), so post-compression renders are byte-identical and + cache-friendly. Retry convergence cannot thrash: retries run only while the + chain entry never persisted (nothing was rewritten yet); once it lands the + output is frozen. Economics: a stranded ~300k-token chain costs a cache-read + (~0.1x input price) on every call forever; the one-time suffix re-write + (~1.25x) breaks even within ~1-2 calls. Same trade every existing prune + (phase-1 stub replacement) already makes - no new invalidation class. + +## Out of scope (follow-up tickets per the issue's roast) + +- Coverage-ratio gate for `hasPerBatchSummaryCoveringAny` (any-overlap today: a + 639-call chain with 1 covered call takes the covered path). +- Capture-layer anomaly root cause (why an 800-turn agent-message cycle produced + no batch). +- Partial-coverage handling: chains with any coverage take the existing covered + path, unchanged. +- Chain-only compression on an empty flush (see Known limitation). + +## Testing approach + +Deterministic unit cage (D2), all through pure seams (`compressEligible` with +stubbed deps, indexer directly). `bun test src/` green is an AC. + +New behavior (`src/chain-compressor.test.ts`, `src/indexer.test.ts`): + +- zero-coverage chain -> compressed; body contains count, histogram, duration, + refs; `fuseRange` spy never called (zero-LLM assertion) +- `backfillChainRecords`: records queryable by raw id and `t` ref; + `contentHashToOriginal` untouched; index entry carries `backfilled: true` + + `refs` +- append failure -> in-memory index and alias maps unchanged, no chain entry, + `no-summary` skip (fail-closed transactionality) +- chain-append-failure -> restart -> retry: records + refs reconstruct from the + backfilled index entry; retry composes and persists the chain entry; no + duplicate index entries (convergence test) +- retry discriminator: members-indexed state composes; zero-extracted + + zero-indexed state skips with `backfill-empty` diagnostic +- fully-deduped chain -> backfilled (index-membership filter), compressed, alias + entries still resolve +- protected member -> excluded from backfill records and refs; relocated verbatim + at render +- oversized member -> spilled (`spillPath`/`resultPreview`/`contentHash` set, + `resultText` empty, full body absent from the persisted entry) +- empty-args chain -> non-empty body; 200-char excerpt cap enforced +- backfilled record renders `Turn: -1` in `context_tree_query` output (pinned + cosmetic) + +Anti-regression cage: + +- covered-path byte-identity: chain with coverage -> entry deep-equals pre-change + snapshot; `bodySource` unset; backfill spy never invoked (the existing + "skips chain with no summary" test at `src/chain-compressor.test.ts:129-140` + becomes the deterministic-compression test; the covered path gets its own + identity test) +- legacy round-trip: real pre-change `context-prune-chain` and + `context-prune-index` JSONL fixtures reconstruct to identical state +- restart: write entries -> `reconstructFromSession` -> refs resolve, dedup + canonicals exclude backfilled records +- integration (`src/range-compression.integration.test.ts`, existing file): one + covered + one uncovered chain in a single flush -> both compressed, covered + output unchanged, `expectNoOrphanToolResults` holds +- multi-chain compact: three uncovered chains, one `compressEligible` call -> + three deterministic entries + +## Documentation impact + +- Feature / user-facing docs introduced: none +- Materially amended existing docs: PRUNING.md (deterministic-fallback subsection + in the algorithm + rationale; diagnostics table gains `backfill-empty`), + README.md (compact behavior note + empty-flush limitation), AGENTS.md + (`context-prune-chain` row: `bodySource`; `context-prune-index` row: + `backfilled` + `refs`; diagnostics row: `backfill-empty`) +- Derived / memory docs invalidated: none + +## Acceptance criteria (from the issue, verbatim intent) + +- [ ] Covered chains: output byte-identical to current behavior; new unit test + asserts the deterministic path is not taken when coverage exists. +- [ ] An eligible zero-coverage chain is compressed with a deterministic body: + call count, tool histogram, span duration, working `t` refs; zero LLM + calls asserted. +- [ ] `context_tree_query` returns the raw output for a tool call inside a + backfilled compressed range. +- [ ] Backfill failure -> chain remains uncompressed with `no-summary` skip; + partial failure (index persisted, chain entry not) converges on retry + instead of re-stranding. +- [ ] Backfilled index records are excluded from content-hash dedup canonical + lookup. +- [ ] Synthetic body renders non-empty even for a chain whose calls have empty + args/results. +- [ ] `session_start` reconstruction round-trips the new entry fields (including + refs on backfilled index entries); pre-existing entries without them load + unchanged. +- [ ] `/pruner compact` handles multiple uncovered chains in one invocation. +- [ ] PRUNING.md and README updated; `bun test src/` green. diff --git a/pi-condense/doc/specs/2026-08-18-gh-11-orphan-sweep-barrier.md b/pi-condense/doc/specs/2026-08-18-gh-11-orphan-sweep-barrier.md new file mode 100644 index 0000000..2d30005 --- /dev/null +++ b/pi-condense/doc/specs/2026-08-18-gh-11-orphan-sweep-barrier.md @@ -0,0 +1,74 @@ +# Orphan-sweep barrier semantics (gh-11) + +Ticket: [jjuraszek/pi-condense#11](https://github.com/jjuraszek/pi-condense/issues/11). +Partially supersedes `doc/specs/2026-08-12-toolcall-id-collisions.md` - the "C. Orphan sweep" section's barrier rule and its claim that C covers the `stopReason: error|aborted` orphan path (it does not - see Out of scope); its per-turn open-set tracking, no-op invariant, and diagnostics stay authoritative. + +## Problem + +`sweepOrphanToolResults` (`src/orphan-sweep.ts`) is the last phase of `pruneMessages` and owns the structural post-condition: never hand the provider a `toolResult` whose `tool_use` is gone. Its open-call set is replaced only on `role === "assistant"`; every other non-`toolResult` role is silent pass-through. + +Downstream, the barrier definition is different. `convertToLlm` (`pi-coding-agent/dist/core/messages.js`, wired into the agent via `core/sdk.js`; `pi-agent-core/dist/harness/messages.js:57-96` is an equivalent duplicate, cited by the ticket) maps `custom`, `branchSummary`, `compactionSummary`, `bashExecution` to `role: "user"` (dropping `bashExecution{excludeFromContext:true}` and unknown roles). `transformMessages` (`pi-ai/dist/api/transform-messages.js`) flushes `insertSyntheticToolResults()` on both `assistant` and `user` boundaries. + +So `assistant(toolCall X) / custom(...) / toolResult(X)` passes the sweep clean, but at the provider boundary pi-ai emits a synthetic `{isError: true, "No result provided"}` for X at the barrier and then still pushes the real result - a duplicate `tool_use_id`. Anthropic 400s permanently on that branch; the shape is unrepairable by pi-ai (it repairs orphan calls, not orphan results). Measured occurrence: pi-cohort spliced a `subagent_control_notice` mid-cycle (upstream cause: jjuraszek/pi-cohort#7); the session survived ~100 assistant turns on kimi-k3 and hard-bricked on the first Claude request. Corpus: 690 mid-cycle barriers across 2170 sessions (687 `user`, 3 `context-prune-summary`), of which 0 are followed by a real `toolResult` for a still-open id - the barrier reset sweeps nothing in today's corpus (the one known bricking instance was hand-repaired out). Rare shape, total failure mode. + +This fix is defense-in-depth in pi-condense; it does not gate on, and is not substituted by, pi-cohort#7's steer-delivery fix. + +## Decision + +Any message that is neither `assistant` nor `toolResult` is a barrier that resets the open-call set: + +```ts +if (msg.role !== "assistant" && msg.role !== "toolResult") { + open = new Set(); + continue; +} +``` + +placed as the fall-through branch in the existing forward pass of `src/orphan-sweep.ts` (equivalently: an `else` after the `toolResult` branch). Everything else is unchanged: per-turn replacement on `assistant`, single-consume on `toolResult`, swept-id ordering, and the same-array-reference no-op return (`doc/specs/2026-08-04-pruner-noop-serialization.md`). + +Consequence, accepted: the interleaved real `toolResult` is swept, and pi-ai injects its repairable synthetic failure for the now-orphaned call. One visible synthetic tool failure instead of a bricked session; structurally the model already saw that synthetic (`transformMessages` emitted it before the barrier). The fix is retroactive - an already-broken branch un-bricks on the next render with no session surgery. + +Deliberate over-sweep, not an exact mirror of `convertToLlm`: `bashExecution{excludeFromContext:true}` and unknown roles are dropped by `convertToLlm`, so strictly they need not be barriers - we treat them as barriers anyway. A role allowlist would have to track the open `CustomAgentMessages` interface for zero observed benefit (0 `bashExecution` entries in the corpus). Do not "fix" this into an allowlist. + +Rejected alternative: hoisting the barrier message out of the cycle - the hoist index moves as the branch grows (breaks render-is-a-stable-function-of-prefix), reorders a message this extension does not own, and cannot be expressed in the single forward pass. + +## Change surface + +Minimal by explicit user constraint; tests carry the regression guarantee. + +1. `src/orphan-sweep.ts` - the barrier branch above; update the header comment's "Open-call tracking is PER TURN" paragraph to state the barrier reset. (The "immediately preceding assistant turn" phrasing does not appear in this file - it lives in `PRUNING.md:1099,1103`, `src/pruner.ts:51`, and `AGENTS.md:97`, handled in items 4 and 6.) +2. `src/test-support.ts` - `expectNoOrphanToolResults` gets the identical barrier reset. It copies the sweep's own orphan rule, over-sweep included (decision: do NOT delegate to `sweepOrphanToolResults`; that would make sweep tests tautological). One definition of "orphan", two lockstep call sites. +3. `src/orphan-sweep.test.ts` - invert the line-63 test ("non-assistant messages between a call and its result do not close the open set") plus the new tests below. Add a minimal `bashExecution`-role fixture helper; a bare `{role: "bashExecution"}` message is sufficient - the sweep never reads `excludeFromContext`, and plumbing it would imply a distinction the barrier deliberately ignores. +4. `PRUNING.md` (Orphan Sweep section, ~lines 1095-1107 - re-verify at implementation time) - the barrier rule, described as tracking the post-`convertToLlm` `user`/`assistant` flush points of `insertSyntheticToolResults` **plus** the accepted `excludeFromContext`/unknown-role over-sweep (not an exact mirror), and the trade-off: a mid-cycle foreign message now costs one tool output and converts an unrepairable provider shape into pi-ai's repairable one. Fix the "immediately preceding assistant turn" phrasing at lines 1099 and 1103. +5. `doc/specs/2026-08-12-toolcall-id-collisions.md` - supersession banner (per the marking convention): scope `"C. Orphan sweep" section: barrier rule and stopReason error|aborted coverage claim`. Already applied in this worktree; ships with the spec commit - do not add a second banner. +6. Comment-only touch-ups where the old rule is restated: `src/pruner.ts:51` (Phase 4 JSDoc) and `AGENTS.md:97` (layout-table row for `orphan-sweep.ts`). + +No behavior changes to `src/pruner.ts` (Phase 4 wiring, diagnostics, and the `orphan-sweep` diagnostic emission are untouched), no signature changes, no new modules. + +## Testing + +TDD. Exactly one existing test goes red before the change (`orphan-sweep.test.ts:63`, verified by the roaster: patch applied -> 466 pass / 1 fail); new tests are written red-first. Bun `describe`/`test`/`expect` with the file's small message-factory style. + +- **AC1** - parametrized over `[custom, user, branchSummary, compactionSummary, bashExecution]` **plus one unknown role** (e.g. `{role: "future-role"}`) so a role-allowlist implementation cannot pass: `assistant(toolCall X) / / toolResult(X)` sweeps X and reports it in `sweptIds`. The line-63 test is inverted to this. +- **AC2** - multi-call turn `assistant(X, Y) / toolResult(X) / custom / toolResult(Y)` sweeps only Y (barrier clears the remainder of the open set; `toolResult` itself is not a barrier - keep the existing consecutive-results control test). +- **AC3** - `assistant(toolCall X) / toolResult(X) / custom` is untouched and returns the **same array reference**. +- **AC4** - pi-condense's own output never trips the barrier, via the existing `pruneWithZeroSweepAssertion` / `expectZeroOrphanSweep` helpers (`src/test-support.ts:31,47`). Shape (a) - a compressed-chain fixture whose range contains a third-party `custom` message - already exists: `chain-range-prune.test.ts:789-793` ("a third-party custom message inside a range survives"), driven through `expectZeroOrphanSweep` at `:862`; it must stay green, no new fixture. Shape (b) - a `context-prune-summary` following a completed cycle sweeps zero - is genuinely missing and is the only new AC4 fixture; extend the nearest suite (`chain-range-prune.test.ts` zero-sweep table or `pruner.test.ts:941-1033`) rather than authoring a fresh harness. Structural reason the invariant holds: summaries are delivered via `deliverAs: "steer"` or at `turn_end`/final-assistant `message_end`, and `applyChainCompressions` inserts its synthetic strictly after a `user`-role `startIndex` and drops every in-range `assistant`/`toolResult` - no partial cycle survives to be split. +- **AC5** - one red-first test that directly exercises the helper's barrier reset: `expectNoOrphanToolResults([assistant(X), custom, toolResult(X)])` must throw (today it passes - the helper still uses the pre-fix rule, and none of its 9 existing call sites across `chain-range-prune.test.ts`, `range-compression.integration.test.ts`, `id-collision.integration.test.ts` contains a mid-cycle foreign message, so "staying green" alone proves nothing). Those 9 call sites additionally stay green. +- **AC6** - `bun test src/` green (baseline 467 pass / 0 fail). + +Constraint: do not value-import or execute pi-ai's real `transformMessages` in the suite - it is reachable only via the `./api/*` subpath and nothing else in this repo value-imports pi-ai; wiring it in would let unrelated pi-ai version bumps red this repo's CI. The end-to-end evidence (fixture -> real `applyChainCompressions` -> sweep -> real `convertToLlm` -> real `transformMessages`) is roast-time manual verification, not automated. + +## Out of scope + +- `transformMessages` skips `stopReason: "error" | "aborted"` assistant messages before recording their tool calls, so their real tool results become provider-side orphans the sweep (before and after this fix) keeps. 0 corpus occurrences; no code. The predecessor spec claimed the sweep covers this path; the supersession banner's scope explicitly includes that claim. +- pi-cohort#7 (upstream steer-delivery fix) - separate repo, separate PR, no code dependency either way. +- `AGENTS.md` ground-truth pointer cites `pi-ai/dist/providers/transform-messages.js`; the actual path is `pi-ai/dist/api/transform-messages.js`. Known one-liner, not part of this ticket. + +## Documentation impact +- Feature / user-facing docs introduced: none +- Materially amended existing docs: `PRUNING.md` (Orphan Sweep section - barrier rule, rationale, over-sweep trade-off) +- Derived / memory docs invalidated: `doc/specs/2026-08-12-toolcall-id-collisions.md` (supersession banner, section C barrier rule only) + +## Open questions + +None. diff --git a/pi-condense/index.ts b/pi-condense/index.ts new file mode 100644 index 0000000..4369386 --- /dev/null +++ b/pi-condense/index.ts @@ -0,0 +1,1088 @@ +/** + * context-prune — Pi extension entry point + * + * Wires together all modules: + * config — load/save /settings.json `contextPrune` namespace (honors PI_CODING_AGENT_DIR) + * batch-capture — serialize turn_end event into CapturedBatch + * summarizer — call LLM to summarize a CapturedBatch + * indexer — maintain Map + session persistence + * pruner — filter context event messages + * query-tool — register context_tree_query tool + * commands — register /pruner command + message renderer + * + * Usage: pi -e . + */ + +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import { loadConfig } from "./src/config.js"; +import { captureBatch, captureUnindexedBatchesFromSession, groupBatchesByMode } from "./src/batch-capture.js"; +import { summarizeBatch, summarizeBatches, summarizeRange } from "./src/summarizer.js"; +import { FallbackController } from "./src/summarizer-fallback.js"; +import { ToolCallIndexer } from "./src/indexer.js"; +import { pruneMessages } from "./src/pruner.js"; +import { isProtected } from "./src/protected.js"; +import { registerQueryTool } from "./src/query-tool.js"; +import { registerCommands, setPruneStatusWidget } from "./src/commands.js"; +import { formatSummaryToolCallRefs, makeSummaryDetails, substituteInlineRefs } from "./src/summary-refs.js"; +import type { + ContextPruneConfig, + CapturedBatch, + PruneFrontier, + FlushOptions, + ContextMetricsSnapshot, + FlushMetricsEntry, + FlushTrigger, +} from "./src/types.js"; +import { + DEFAULT_CONFIG, + CUSTOM_TYPE_SUMMARY, + CUSTOM_TYPE_STATS, + CUSTOM_TYPE_FRONTIER, + CUSTOM_TYPE_FLUSH_METRICS, +} from "./src/types.js"; +import { computeContextMetrics } from "./src/context-metrics.js"; +import { StatsAccumulator, emitExternalCost } from "./src/stats.js"; +import { PruneFrontierTracker } from "./src/frontier.js"; +import { BlockRefIssuer } from "./src/block-refs.js"; +import { compressEligible } from "./src/chain-compressor.js"; +import { detectChains, withClosingMessage } from "./src/chain-detector.js"; +import { inGraceRecoveryToolCallIds } from "./src/recovery-grace.js"; +import { shouldBudgetFlush, shouldDeltaFlush, usageFraction } from "./src/budget.js"; +import { spillOversizedBatch } from "./src/spill.js"; +import { occKey } from "./src/occurrence-key.js"; +import { DiagnosticSink } from "./src/diagnostics.js"; + +const EMPTY_METRICS_SNAPSHOT: ContextMetricsSnapshot = { openCycleThinkingTokens: 0, largestChainSharePct: 0, frontierGapTokens: 0 }; + +export default function (pi: ExtensionAPI) { + // Shared mutable config reference — updated by /pruner commands + const currentConfig: { value: ContextPruneConfig } = { + value: { ...DEFAULT_CONFIG }, + }; + + const protectionPredicate = (name: string, args: unknown) => isProtected(name, args, currentConfig.value); + + // Shared indexer — rebuilt from session on every session_start / session_tree + const indexer = new ToolCallIndexer(); + + // Shared stats accumulator — tracks cumulative token/cost stats for summarizer calls + const statsAccum = new StatsAccumulator(); + + // Session-scoped summarizer outage-fallback controller (in-memory; reset on session_start). + const fallbackController = new FallbackController(); + + // Shared prune frontier — tracks the last completed prune attempt boundary + const frontier = new PruneFrontierTracker(); + + // Shared block-ref issuer — issues monotonic b IDs for compressed chains; + // rebuilt from session on session_start / session_tree + const blockRefs = new BlockRefIssuer(); + + // Session-scoped diagnostic sink — tracks recovery-path anomaly counters + // (dedup'd across the session's lifetime, not per-render). + const diagnostics = new DiagnosticSink((type, data) => pi.appendEntry(type, data)); + + // Pending batches — accumulated until the prune trigger fires + const pendingBatches: CapturedBatch[] = []; + let isFlushing = false; + let previousFraction: number | null = null; + // Set on session_start/session_tree when the branch rescan finds recoverable + // work but pendingBatches was just zeroed (reload/tree-switch). Lets the + // turn_end budget gate fire without a freshly pushed batch. Boolean only — + // no queue reconstruction; flushPending's own rescan is the data path. + // Cleared on every non-concurrent flushPending invocation. + let rearmedPending = false; + + // Latest ContextMetricsSnapshot, recomputed at reload probes, batch capture, + // and flush entry. Cached (rather than recomputed on every widget refresh) + // because computeContextMetrics walks the full branch. + let metricsCache: ContextMetricsSnapshot | undefined; + const computeMetricsSnapshot = (ctx: any): ContextMetricsSnapshot | undefined => { + try { + // Includes persisted custom_message entries (e.g. this extension's own + // summary messages) alongside plain "message" entries: both are retained + // LLM context, so both belong in the largest-chain-share denominator. + // Projected inline (rather than importing pi-coding-agent's + // createCustomMessage) because that helper isn't re-exported from the + // package's "." export map -- shape mirrors createCustomMessage's output + // (role "custom"), which never matches the user/assistant/toolResult + // roles computeContextMetrics keys off, so it only inflates totalChars. + const branch = ctx.sessionManager.getBranch() + .filter((e: any) => (e.type === "message" && e.message) || e.type === "custom_message") + .map((e: any) => + e.type === "custom_message" + ? { role: "custom", customType: e.customType, content: e.content, display: e.display, details: e.details, timestamp: new Date(e.timestamp).getTime() } + : e.message, + ); + metricsCache = computeContextMetrics( + branch, + frontier.get(), + (k: string) => indexer.isSummarized(k), + protectionPredicate, + ); + } catch (err) { + console.error("pi-condense: context metrics computation failed", err); + } + return metricsCache; + }; + + type FlushResult = + | { ok: true; reason: "flushed" | "skipped-oversized" | "skipped-trivial" | "skipped-deduped"; batchCount: number; toolCallCount: number; rawCharCount: number; summaryCharCount: number; dedupedCount?: number } + | { ok: false; reason: "empty" | "already-flushing" | "summarizer-failed" | "stale-context" | "failed" | "aborted"; error?: string }; + + type SessionAppender = { + appendCustomEntry(customType: string, data?: unknown): string; + appendCustomMessageEntry(customType: string, content: string, display: boolean, details?: unknown): string; + }; + + const isStaleContextError = (err: unknown) => + err instanceof Error && err.message.includes("This extension ctx is stale"); + + const errorMessage = (err: unknown) => (err instanceof Error ? err.message : String(err)); + + const safeNotify = (ctx: any, message: string, type: "info" | "warning" | "error" = "info") => { + try { + ctx.ui.notify(message, type); + } catch (err) { + if (!isStaleContextError(err)) throw err; + } + }; + + const assistantMessageHasToolCalls = (message: any) => + message?.role === "assistant" && + Array.isArray(message.content) && + message.content.some((block: any) => block?.type === "toolCall"); + + const isFinalAssistantMessage = (message: any) => message?.role === "assistant" && !assistantMessageHasToolCalls(message); + + const trimBatchToPendingRange = (batch: CapturedBatch): CapturedBatch | null => { + const currentFrontier = frontier.get(); + let toolCalls = batch.toolCalls; + + // The indexer tells us what was successfully summarized earlier. + toolCalls = toolCalls.filter((tc) => !indexer.isSummarized(occKey(tc.toolCallId, tc.resultTimestamp))); + if (toolCalls.length === 0) return null; + + // The frontier tells us the last attempted boundary even when the attempt did + // not persist index entries (e.g. skipped-oversized). When the LLM prunes in + // the middle of a long tool chain, keep later tool calls from the same turn + // instead of dropping the whole batch on the floor. + if (!currentFrontier) return { ...batch, toolCalls }; + if (batch.turnIndex < currentFrontier.lastAttemptedTurnIndex) return null; + if (batch.turnIndex > currentFrontier.lastAttemptedTurnIndex) return { ...batch, toolCalls }; + + const originalIndex = toolCalls.findIndex((tc) => tc.toolCallId === currentFrontier.lastAttemptedToolCallId); + if (originalIndex < 0) return { ...batch, toolCalls }; + + const remaining = toolCalls.slice(originalIndex + 1); + if (remaining.length === 0) return null; + return { ...batch, toolCalls: remaining }; + }; + + const restoreBatches = (batches: CapturedBatch[]) => { + pendingBatches.unshift(...batches); + }; + + // ── Helper: capture + trim + group pending batches (no LLM work) ────────── + // Exposed to commands.ts via registerCommands so /pruner now can preview the + // queue before opening the multi-row progress overlay. + // `rethrow` is for the reload rearm probe only (session_start/session_tree): + // it needs to observe a rescan failure so it can console.error and leave + // rearmedPending false, per spec. Every other caller (turn_end capture path, + // flushPending, /pruner commands) keeps the existing swallow-and-fall-back + // behavior so a transient getBranch failure there never blocks the turn. + const capturePendingBatches = (ctx: any, opts?: { rethrow?: boolean }): CapturedBatch[] => { + let batches: CapturedBatch[] = []; + try { + const branch = ctx.sessionManager.getBranch(); + batches = captureUnindexedBatchesFromSession(branch, indexer, protectionPredicate); + } catch (err) { + if (opts?.rethrow) throw err; + batches = pendingBatches.slice(); + } + batches = batches + .map((batch) => trimBatchToPendingRange(batch)) + .filter((batch): batch is CapturedBatch => batch !== null); + return groupBatchesByMode(batches, currentConfig.value.batchingMode); + }; + + // Summarizes + indexes all pending batches. + // When options.onProgress is provided batches are processed sequentially + // (one LLM call each) so the caller can update per-row UI. Otherwise all + // batches are summarized in parallel (one summarizeBatches call). + // Runtime delivery is used while the agent/tool loop is active so Pi can place + // steer messages at protocol-safe boundaries. Session delivery is used only for + // agent-message's final-message flush, where print-mode Pi may invalidate pi.* + // while the summarizer LLM call is in flight. + // Range-summary fuser injected into compressEligible (B). Returns undefined + // when fuseRangeSummary is off so the compressor keeps the per-batch concat. + // Each successful fusion folds its usage + bumps the rangesSummarized counter. + const makeFuseRange = (ctx: any): ((text: string) => Promise) | undefined => { + if (!currentConfig.value.chainCompression.fuseRangeSummary) return undefined; + return async (text: string) => { + const r = await summarizeRange(text, currentConfig.value, ctx, { controller: fallbackController }); + if (r) { + statsAccum.add(r.usage); + statsAccum.addRangesSummarized(1); + } + return r?.summaryText ?? null; + }; + }; + + const flushPending = async (ctx: any, options: FlushOptions = {}): Promise => { + if (isFlushing) return { ok: false, reason: "already-flushing" }; + + // Clear on every non-concurrent invocation, regardless of outcome — the + // rearm is a one-shot nudge for the very next eligible gate check. + rearmedPending = false; + + // Pre-flush pressure snapshot — recorded once at flush entry so the + // observability entry reflects what triggered this attempt, not what's + // left after it ran. + const entryMetrics: ContextMetricsSnapshot = computeMetricsSnapshot(ctx) ?? EMPTY_METRICS_SNAPSHOT; + const trigger: FlushTrigger = options.trigger ?? "manual"; + const delivery = options.delivery ?? "runtime"; + + // One-entry-per-attempt tracking, emitted once from the outer `finally` + // below. `appendEntry` is assigned only once `sessionManager` is captured + // (session delivery); until then (empty/aborted/pre-capture-failure exits) + // the emitter falls back to pi.appendEntry. + let capturedBatches = 0; + let processedCount = 0; + let outcome: FlushMetricsEntry["outcome"] = "empty"; + let appendEntry: ((customType: string, data?: unknown) => void) | undefined; + + // Non-fatal by construction: observability must never affect the flush outcome. + const emitFlushMetricsOnce = () => { + const entry: FlushMetricsEntry = { + ts: Date.now(), + trigger, + capturedBatches, + processedBatches: processedCount, + outcome, + metrics: entryMetrics, + }; + const appender: (type: string, data: unknown) => void = appendEntry + ? delivery === "runtime" ? (type, data) => pi.appendEntry(type, data) : appendEntry + : (type, data) => pi.appendEntry(type, data); + try { + appender(CUSTOM_TYPE_FLUSH_METRICS, entry); + } catch { + // non-fatal: observability must never fail the flush + } + }; + + let batches: CapturedBatch[] = []; + let sessionManager: SessionAppender | undefined; + try { + // Bind the session appender as soon as delivery is known, BEFORE the + // empty-capture/aborted exits below — so emitFlushMetricsOnce's finally + // emit routes through sessionManager for those exits too, instead of + // falling back to the (possibly stale, print-mode) pi.appendEntry. + if (delivery === "session") { + try { + sessionManager = ctx.sessionManager as unknown as SessionAppender; + appendEntry = (customType: string, data?: unknown) => sessionManager!.appendCustomEntry(customType, data); + } catch (err) { + outcome = "error"; + return { ok: false, reason: isStaleContextError(err) ? "stale-context" : "failed", error: errorMessage(err) }; + } + } + + // Use pre-captured batches if provided (avoids double-capture when the + // caller previewed the queue before opening the progress overlay). + batches = options.previewedBatches ?? capturePendingBatches(ctx); + capturedBatches = batches.length; + + if (batches.length === 0) { + outcome = "empty"; + return { ok: false, reason: "empty" }; + } + + // Bail out before we drain pendingBatches so they don't need restoring. + if (options.signal?.aborted) { + outcome = "error"; + return { ok: false, reason: "aborted" }; + } + + // Draining the queue since we've captured the state via session or slice. + // We drain BEFORE the await so concurrent calls (though guarded by isFlushing) + // or rapid turn-ends don't result in double-summarization. + pendingBatches.length = 0; + + isFlushing = true; + + const appendSummaryMessage = (content: string, details: unknown) => + sessionManager!.appendCustomMessageEntry(CUSTOM_TYPE_SUMMARY, content, false, details); + + // Routes alias persistence through whichever delivery is active so the + // dedup pre-flush pass writes CUSTOM_TYPE_DEDUP_ALIAS entries via the + // same path the rest of the flush uses. + const persistAlias: (customType: string, data?: unknown) => void = + delivery === "runtime" + ? (type, data) => pi.appendEntry(type, data) + : appendEntry!; + + // ── Pre-flush content-hash dedup pass ──────────────────────────── + // For each tool call, check the indexer's contentHashToOriginal map. + // A hit means an identical (toolName, normalized resultText) pair has + // already been summarized in an earlier flush. Register the duplicate + // as an alias of the original (so pruneMessages stub-replaces its + // ToolResultMessage with the original's short ref) and drop it from + // the batch BEFORE the summarizer / trivial classifier runs. + // + // We track per-batch deduped counts so we can: + // - count dedup'd tool calls toward `totalToolCallCount` and + // `totalRawCharCount` (they were addressed by this flush even + // though no LLM call was made for them), + // - tag fully-dedup'd batches with a `"deduped"` ResultSlot so the + // existing result loop treats them the same way it treats trivial + // batches (advance the frontier without writing a summary). + const dedupedPerBatch: { toolCalls: import("./src/types.js").CapturedToolCall[]; rawChars: number }[] = batches.map(() => ({ toolCalls: [], rawChars: 0 })); + const dedupEnabled = currentConfig.value.dedupByContentHash; + if (dedupEnabled) { + for (let i = 0; i < batches.length; i++) { + const batch = batches[i]; + const remaining: typeof batch.toolCalls = []; + for (const tc of batch.toolCalls) { + const originalId = indexer.lookupByContent(tc.toolName, tc.resultText); + const key = occKey(tc.toolCallId, tc.resultTimestamp); + if (originalId && originalId !== key) { + indexer.registerDuplicate(key, originalId, persistAlias); + dedupedPerBatch[i].toolCalls.push(tc); + dedupedPerBatch[i].rawChars += tc.resultText.length; + } else { + remaining.push(tc); + } + } + // Shallow-clone the batch so we don't mutate the captured array + // (pendingBatches consumers retain the original shape on retry). + batches[i] = { ...batch, toolCalls: remaining }; + } + } + + // ── Pre-flush trivial filter ───────────────────────────────── + // Classify each batch by total raw resultText chars BEFORE any LLM call. + // Batches below minBatchChars are marked trivial: the summarizer is + // skipped entirely, the frontier still advances, and the original + // tool-result messages stay verbatim in context. minBatchChars === 0 + // disables the guard (every batch goes to the summarizer). + // + // A batch whose entire toolCalls array was just deduped is flagged + // `isFullyDeduped` so the result loop slots it as "deduped" without + // confusing it with the trivial path (different outcome + notification). + const minChars = currentConfig.value.minBatchChars; + const batchRawChars = batches.map((b) => + b.toolCalls.reduce((s, tc) => s + tc.resultText.length, 0), + ); + const isFullyDeduped = batches.map((b, i) => + dedupedPerBatch[i].toolCalls.length > 0 && b.toolCalls.length === 0, + ); + const isTrivial = batchRawChars.map( + (c, i) => !isFullyDeduped[i] && minChars > 0 && c < minChars && batches[i].toolCalls.length > 0, + ); + const nonTrivialIndices: number[] = []; + for (let i = 0; i < batches.length; i++) { + if (!isTrivial[i] && !isFullyDeduped[i]) nonTrivialIndices.push(i); + } + + // Only show "summarizing…" if at least one batch will actually be sent + // to the LLM. An all-trivial flush is purely bookkeeping. + if (nonTrivialIndices.length > 0) { + setPruneStatusWidget(ctx, currentConfig.value, "prune: summarizing…"); + } + + const reportBatchTextProgress = (index: number, total: number, batch: CapturedBatch, receivedChars: number) => { + options.onBatchTextProgress?.(index, total, batch, receivedChars); + }; + + // Summarize the non-trivial subset. When onProgress is provided + // (/pruner now overlay) we process sequentially so each row can be + // checked off as its LLM call completes. Trivial and fully-deduped + // batches emit a "skipped" progress event immediately, with no + // spinner / no LLM call. The final `results` array is index-aligned + // to `batches`, with possible values: SummarizeResult (success), + // null (LLM failure), "trivial" (pre-flush small-batch skip), or + // "deduped" (pre-flush dedup ate every tool call in this batch). + type ResultSlot = import("./src/types.js").SummarizeResult | null | "trivial" | "deduped"; + const results: ResultSlot[] = new Array(batches.length).fill(null); + + if (options.onProgress) { + for (let i = 0; i < batches.length; i++) { + if (isFullyDeduped[i]) { + options.onProgress(i, batches.length, batches[i], "skipped"); + results[i] = "deduped"; + continue; + } + if (isTrivial[i]) { + options.onProgress(i, batches.length, batches[i], "skipped"); + results[i] = "trivial"; + continue; + } + options.onProgress(i, batches.length, batches[i], "start"); + const r = await summarizeBatch(batches[i], currentConfig.value, ctx, { + signal: options.signal, + controller: fallbackController, + onTextProgress: (receivedChars) => { + reportBatchTextProgress(i, batches.length, batches[i], receivedChars); + }, + }); + results[i] = r; + options.onProgress(i, batches.length, batches[i], r ? "done" : "skipped"); + } + } else { + // Mark all trivial + fully-deduped slots up front, then call + // summarizeBatches with only the remaining batches (parallel — one + // LLM call each). + for (let i = 0; i < batches.length; i++) { + if (isFullyDeduped[i]) results[i] = "deduped"; + else if (isTrivial[i]) results[i] = "trivial"; + } + if (nonTrivialIndices.length > 0) { + const nonTrivialBatches = nonTrivialIndices.map((i) => batches[i]); + const ntResults = await summarizeBatches(nonTrivialBatches, currentConfig.value, ctx, { + onBatchTextProgress: (ntIndex, _ntTotal, batch, receivedChars) => { + const origIndex = nonTrivialIndices[ntIndex]; + reportBatchTextProgress(origIndex, batches.length, batch, receivedChars); + }, + signal: options.signal, + controller: fallbackController, + }); + for (let k = 0; k < nonTrivialIndices.length; k++) { + results[nonTrivialIndices[k]] = ntResults[k]; + } + } + } + + // Process results in order; stop at first null (individual call failure). + // Batches before the first failure are persisted; remaining are restored to + // pendingBatches so they are retried on the next flush. + const processedBatches: CapturedBatch[] = []; + let totalRawCharCount = 0; + let totalSummaryCharCount = 0; + let totalToolCallCount = 0; + let totalDedupedCount = 0; + const oversizedBatches: CapturedBatch[] = []; + const trivialBatches: CapturedBatch[] = []; + const dedupedBatches: CapturedBatch[] = []; + let firstFailureIndex = -1; + + for (let i = 0; i < batches.length; i++) { + const result = results[i]; + if (result === null) { + firstFailureIndex = i; + break; + } + + const batch = batches[i]; + const batchRawCharCount = batchRawChars[i]; + const dedupCount = dedupedPerBatch[i].toolCalls.length; + const dedupRawChars = dedupedPerBatch[i].rawChars; + + // Fully-deduped batches: every tool call matched an existing + // indexed record. The alias entries are already persisted; we just + // need to advance the frontier past this turn and count the + // dedup'd raw chars toward the flush totals so the user sees the + // savings. + if (result === "deduped") { + totalRawCharCount += dedupRawChars; + totalToolCallCount += dedupCount; + totalDedupedCount += dedupCount; + dedupedBatches.push(batch); + processedBatches.push(batch); + continue; + } + + // Trivial batches: no summary text, no index entry, no stats usage — + // just bookkeeping so the frontier can advance past this range and + // the next flush does not reconsider these tool calls. + if (result === "trivial") { + // Count dedup'd tool calls (if any) on a partial-dedup batch even + // though the rest of the batch was below minBatchChars. + totalRawCharCount += batchRawCharCount + dedupRawChars; + totalToolCallCount += batch.toolCalls.length + dedupCount; + totalDedupedCount += dedupCount; + trivialBatches.push(batch); + processedBatches.push(batch); + continue; + } + + const summaryRefs = indexer.allocateSummaryRefs(batch); + const toolNames = batch.toolCalls.map((tc) => tc.toolName); + const decorated = substituteInlineRefs(result.summaryText, summaryRefs, toolNames); + const summaryText = decorated + formatSummaryToolCallRefs(summaryRefs); + const shouldSkipOversized = summaryText.length > batchRawCharCount; + + statsAccum.add(result.usage); + totalRawCharCount += batchRawCharCount + dedupRawChars; + totalSummaryCharCount += summaryText.length; + totalToolCallCount += batch.toolCalls.length + dedupCount; + totalDedupedCount += dedupCount; + + const batchDetails = makeSummaryDetails(batch, summaryRefs); + + try { + if (!shouldSkipOversized) { + // Write one hidden summary message per turn and index its tool calls. + // `display: false` keeps the summary in future LLM context (convertToLlm + // ignores `display`) while suppressing the full markdown block from Pi's + // main window; rebuild keys on customType, not display. + const batchOccurrenceKeys = batch.toolCalls.map((tc) => occKey(tc.toolCallId, tc.resultTimestamp)); + if (delivery === "runtime") { + pi.sendMessage( + { customType: CUSTOM_TYPE_SUMMARY, content: summaryText, display: false, details: batchDetails }, + { deliverAs: "steer" } + ); + indexer.registerSummaryRefs(summaryRefs); + indexer.addBatch(batch, (type, data) => pi.appendEntry(type, data)); + } else { + appendSummaryMessage(summaryText, batchDetails); + indexer.registerSummaryRefs(summaryRefs); + indexer.addBatch(batch, appendEntry!); + } + // Keep the in-memory summary-body registry current so chain compression + // can build synthetic chain messages without rescanning session entries. + indexer.registerSummaryBody(batchOccurrenceKeys, summaryText); + } else { + oversizedBatches.push(batch); + } + } catch (err) { + // Persistence error mid-loop: stop here, restore this and remaining batches. + if (isStaleContextError(err)) { + restoreBatches(batches.slice(i)); + // Advance frontier to what we managed to persist before this point + break; + } + throw err; + } + + processedBatches.push(batch); + } + + // Restore unprocessed batches (those at and after the first failure) + if (firstFailureIndex >= 0) { + restoreBatches(batches.slice(firstFailureIndex)); + } + + if (processedBatches.length === 0) { + // Nothing was persisted (all calls failed or first call failed) + setPruneStatusWidget(ctx, currentConfig.value, statsAccum.getLiveReclaim(), diagnostics.counts(), metricsCache); + outcome = "error"; + return { ok: false, reason: "summarizer-failed" }; + } + + // Advance frontier to the last batch we actually processed. A fully + // deduped batch has `toolCalls === []` (the dedup pass shallow-cloned + // the batch with only the remaining non-dup calls). In that case, fall + // back to the matching `dedupedPerBatch[i].toolCalls` so the frontier + // anchor still points at a real tool call — otherwise we'd dereference + // `undefined.toolCallId` and the whole flush would throw, silently + // dropping the dedup-alias write's effect on subsequent flushes. + const lastBatch = processedBatches[processedBatches.length - 1]; + const lastBatchOrigIndex = batches.indexOf(lastBatch); + const lastBatchAllTCs = + lastBatch.toolCalls.length > 0 + ? lastBatch.toolCalls + : (lastBatchOrigIndex >= 0 ? dedupedPerBatch[lastBatchOrigIndex].toolCalls : []); + const lastTC = lastBatchAllTCs[lastBatchAllTCs.length - 1]; + + // Outcome precedence: any actual summary wins; oversized beats deduped + // beats trivial. (Trivial and deduped are both zero-LLM-cost; deduped + // is the more interesting signal because it implies the indexer caught + // a redundancy, so it wins the tiebreaker.) + const actuallyFlushedCount = + processedBatches.length - trivialBatches.length - oversizedBatches.length - dedupedBatches.length; + const flushOutcome: PruneFrontier["outcome"] = + actuallyFlushedCount > 0 + ? "summarized" + : oversizedBatches.length > 0 + ? "skipped-oversized" + : dedupedBatches.length > 0 + ? "skipped-deduped" + : "skipped-trivial"; + + // Raw session branch, unwrapped once for the chain-compression block below. + // Only materialized when chain compression is enabled. + let branchMessages: any[] | undefined; + if (currentConfig.value.chainCompression.enabled) { + branchMessages = ctx.sessionManager.getBranch() + .filter((e: any) => e.type === "message" && e.message) + .map((e: any) => e.message); + } + + const frontierSnapshot: PruneFrontier = { + lastAttemptedToolCallId: lastTC.toolCallId, + lastAttemptedToolName: lastTC.toolName, + lastAttemptedTurnIndex: lastBatch.turnIndex, + lastAttemptedTimestamp: lastBatch.timestamp, + attemptedBatchCount: processedBatches.length, + attemptedToolCallCount: totalToolCallCount, + rawCharCount: totalRawCharCount, + summaryCharCount: totalSummaryCharCount, + outcome: flushOutcome, + }; + + try { + if (delivery === "runtime") { + frontier.advance(frontierSnapshot); + frontier.persist(pi); + statsAccum.persist(pi); + } else { + frontier.advance(frontierSnapshot); + appendEntry!(CUSTOM_TYPE_FRONTIER, frontierSnapshot); + try { + appendEntry!(CUSTOM_TYPE_STATS, statsAccum.getStats()); + } catch { + // Ignore stats persistence failures; the prune result and frontier are the contract. + } + } + } catch (err) { + // Batches were summarized/persisted before the frontier/stats write failed; + // reflect that in processedBatches rather than reporting 0. + processedCount = processedBatches.length; + outcome = "error"; + return { ok: false, reason: isStaleContextError(err) ? "stale-context" : "failed", error: errorMessage(err) }; + } + + setPruneStatusWidget(ctx, currentConfig.value, statsAccum.getLiveReclaim(), diagnostics.counts(), metricsCache); + emitExternalCost(pi, statsAccum); + + // Chain compression — compress closed chains beyond the rolling window. + // Runs after the per-batch summarization so summaryBodies are up to date. + // Non-fatal: a failure here does not roll back the successful summarization. + if (currentConfig.value.chainCompression.enabled) { + try { + // message_end fires before pi persists the closing assistant, so thread it + // in here; otherwise the newest chain reads as open and K over-retains by 1. + // branchMessages was unwrapped once above, gated on chainCompression.enabled. + const detectionMessages = withClosingMessage(branchMessages!, options.closingMessage); + const chains = detectChains(detectionMessages, protectionPredicate); + const inGrace = inGraceRecoveryToolCallIds(branchMessages!, currentConfig.value.recoveryGraceTurns); + const { compressedEntries } = await compressEligible( + chains, + currentConfig.value.chainCompression.rollingWindow, + { + indexer, + blockRefs, + appendEntry: persistAlias, + now: () => Date.now(), + fuseRange: makeFuseRange(ctx), + messages: detectionMessages, + diagnostics, + backfill: { + spillThreshold: currentConfig.value.spillThreshold, + spillPreviewBytes: currentConfig.value.spillPreviewBytes, + sessionDir: ctx.sessionManager.getSessionDir(), + sessionId: ctx.sessionManager.getSessionId(), + }, + }, + inGrace, + ); + if (compressedEntries.length > 0) { + statsAccum.addChainsCompressed(compressedEntries.length); + statsAccum.persist(pi); + emitExternalCost(pi, statsAccum); + safeNotify( + ctx, + `pruner: compressed ${compressedEntries.length} chain${compressedEntries.length === 1 ? "" : "s"} (${compressedEntries.map((e) => e.blockId).join(", ")})`, + "info", + ); + } + } catch (err) { + if (!isStaleContextError(err)) { + safeNotify(ctx, `pruner: chain compression failed: ${errorMessage(err)}`, "warning"); + } + } + } + + // Notify about any batches that were skipped — either oversized or + // trivial. Neither is an error: the pruner correctly chose not to grow + // context (oversized) or to skip the LLM call entirely (trivial). Both + // are silenced by `quietOversizedSkips`, which acts as a single + // "quiet all non-error skips" toggle. + if (!currentConfig.value.quietOversizedSkips) { + for (const batch of oversizedBatches) { + const batchRaw = batch.toolCalls.reduce((s, tc) => s + tc.resultText.length, 0); + const slot = results[batches.indexOf(batch)]; + const batchSummaryLen = slot && slot !== "trivial" && slot !== "deduped" ? slot.summaryText.length : 0; + safeNotify( + ctx, + `pruner: skipped pruning turn ${batch.turnIndex} (${batch.toolCalls.length} tool call${batch.toolCalls.length === 1 ? "" : "s"}) — summary was ${batchSummaryLen} chars vs ${batchRaw} raw chars; frontier advanced past this range`, + "info" + ); + } + for (const batch of trivialBatches) { + const batchRaw = batch.toolCalls.reduce((s, tc) => s + tc.resultText.length, 0); + safeNotify( + ctx, + `pruner: skipped pruning turn ${batch.turnIndex} (${batch.toolCalls.length} tool call${batch.toolCalls.length === 1 ? "" : "s"}) — only ${batchRaw} raw chars (< minBatchChars=${minChars}); no LLM call made; frontier advanced past this range`, + "info" + ); + } + for (const batch of dedupedBatches) { + const idx = batches.indexOf(batch); + const n = dedupedPerBatch[idx].toolCalls.length; + const chars = dedupedPerBatch[idx].rawChars; + safeNotify( + ctx, + `pruner: deduplicated ${n} tool call${n === 1 ? "" : "s"} (turn ${batch.turnIndex}, ${chars} raw chars) against earlier prunes; no LLM call made; frontier advanced past this range`, + "info" + ); + } + if (totalDedupedCount > 0 && dedupedBatches.length === 0) { + // Partial-dedup case: some tool calls were dedup'd but the rest + // of the batch went through the summarizer. Surface a single + // aggregate notification so users see the savings. + safeNotify( + ctx, + `pruner: deduplicated ${totalDedupedCount} tool call${totalDedupedCount === 1 ? "" : "s"} against earlier prunes (no LLM call for those); remaining tool calls were summarized normally.`, + "info" + ); + } + } + + // Very end of the try block, deliberately after (and outside) the + // chain-compression block's own try/catch above: a compression failure + // must not eat this entry — the summarization phase already succeeded. + processedCount = processedBatches.length; + outcome = flushOutcome; + + const returnReason: "flushed" | "skipped-oversized" | "skipped-trivial" | "skipped-deduped" = + actuallyFlushedCount > 0 + ? "flushed" + : oversizedBatches.length > 0 + ? "skipped-oversized" + : dedupedBatches.length > 0 + ? "skipped-deduped" + : "skipped-trivial"; + + return { + ok: true, + reason: returnReason, + batchCount: processedBatches.length, + toolCallCount: totalToolCallCount, + rawCharCount: totalRawCharCount, + summaryCharCount: totalSummaryCharCount, + dedupedCount: totalDedupedCount, + }; + } catch (err) { + restoreBatches(batches); + outcome = "error"; + // When the abort signal fired, summarizeBatch rethrows rather than + // swallowing the error. Don't show a UI error — the user intended this. + if (options.signal?.aborted) { + setPruneStatusWidget(ctx, currentConfig.value, statsAccum.getLiveReclaim(), diagnostics.counts(), metricsCache); + return { ok: false, reason: "aborted" }; + } + if (isStaleContextError(err)) { + return { ok: false, reason: "stale-context", error: errorMessage(err) }; + } + safeNotify(ctx, `pruner: summarization failed: ${errorMessage(err)}`, "error"); + return { ok: false, reason: "failed", error: errorMessage(err) }; + } finally { + isFlushing = false; + emitFlushMetricsOnce(); + } + }; + + // ── session_start: restore config + index + stats ──────────────────────────────── + pi.on("session_start", async (_event, ctx) => { + // Load config from /settings.json `contextPrune` key (honors PI_CODING_AGENT_DIR) + currentConfig.value = await loadConfig(); + + // Rebuild in-memory index from persisted session entries + indexer.reconstructFromSession(ctx); + + // Rebuild block-ref counter so new chain IDs don't collide with existing ones + blockRefs.rebuildFrom(indexer.getChainEntries().map((e) => e.blockId)); + + // Rebuild stats accumulator from persisted session entries + statsAccum.reconstructFromSession(ctx); + fallbackController.reset(); + diagnostics.reset(); + + // Rebuild prune frontier from persisted session entries + frontier.reconstructFromSession(ctx); + + // Clear any batches queued before the session reload + pendingBatches.length = 0; + previousFraction = null; + rearmedPending = false; + if (currentConfig.value.enabled) { + try { + rearmedPending = capturePendingBatches(ctx, { rethrow: true }).length > 0; + } catch (err) { + console.error("pi-condense: reload rearm probe failed", err); + } + } + + computeMetricsSnapshot(ctx); + + // Update footer status + setPruneStatusWidget(ctx, currentConfig.value, statsAccum.getLiveReclaim(), diagnostics.counts(), metricsCache); + + ctx.ui.setWidget( + "pruner-boot", + [ + `pruner loaded — pruning ${currentConfig.value.enabled ? "ON" : "OFF"} | model: ${currentConfig.value.summarizerModel}`, + ], + { placement: "belowEditor" }, + ); + setTimeout(() => { + try { + ctx.ui.setWidget("pruner-boot", undefined); + } catch { + // UI owner may be gone after session replacement. + } + }, 10000).unref?.(); + }); + + // Rebuild index and stats after tree navigation too (branch may have different history) + pi.on("session_tree", async (_event, ctx) => { + indexer.reconstructFromSession(ctx); + blockRefs.rebuildFrom(indexer.getChainEntries().map((e) => e.blockId)); + statsAccum.reconstructFromSession(ctx); + diagnostics.reset(); + frontier.reconstructFromSession(ctx); + // Pending batches belong to the old branch — discard them + pendingBatches.length = 0; + previousFraction = null; + rearmedPending = false; + if (currentConfig.value.enabled) { + try { + rearmedPending = capturePendingBatches(ctx, { rethrow: true }).length > 0; + } catch (err) { + console.error("pi-condense: reload rearm probe failed", err); + } + } + + computeMetricsSnapshot(ctx); + setPruneStatusWidget(ctx, currentConfig.value, statsAccum.getLiveReclaim(), diagnostics.counts(), metricsCache); + }); + + // ── turn_end: capture batch, flush immediately or queue ────────────────── + pi.on("turn_end", async (event, ctx) => { + if (!currentConfig.value.enabled) return; + + const hasToolResults = event.toolResults && event.toolResults.length > 0; + + // Text-only final turns are handled by message_end in agent-message mode. + // In print mode, turn_end can fire after session shutdown, so do not start + // deferred LLM work from this late lifecycle event — UNLESS a reload probe + // (session_start/session_tree) found recoverable pending work: that flag + // must still reach the budget/delta gate below without a freshly captured + // batch on this turn. + if (!hasToolResults && !rearmedPending) return; + + let pushedBatch = false; + if (hasToolResults) { + const capturedBatch = captureBatch( + event.message, + event.toolResults, + event.turnIndex, + Date.now() + ); + // Drop user-protected tool/path results so they stay verbatim in context. + // Filtering at capture time keeps the + // underlying assistant `toolCall` block AND its `ToolResultMessage` + // untouched in Pi's session/event stream — only the in-memory + // CapturedBatch is pruned, which is exactly what we want. + const filtered = { + ...capturedBatch, + toolCalls: capturedBatch.toolCalls.filter((tc) => !isProtected(tc.toolName, tc.args, currentConfig.value)), + }; + + // Eager spill: offload oversized single results to sidecar files before they + // ever reach a request. addBatch inside marks them isSummarized, so + // trimBatchToPendingRange drops them from the pending set below. Best-effort: + // a spill failure leaves the result inline for the normal flush pipeline. + try { + await spillOversizedBatch({ + batch: filtered, + indexer, + config: { + spillThreshold: currentConfig.value.spillThreshold, + spillPreviewBytes: currentConfig.value.spillPreviewBytes, + dedupByContentHash: currentConfig.value.dedupByContentHash, + }, + sessionDir: ctx.sessionManager.getSessionDir(), + sessionId: ctx.sessionManager.getSessionId(), + appendEntry: (type, data) => (ctx.sessionManager as unknown as SessionAppender).appendCustomEntry(type, data), + }); + } catch { + // best-effort; never block the turn + } + + const batch = trimBatchToPendingRange(filtered); + if (batch) { + pushedBatch = true; + pendingBatches.push(batch); + + // Let the user know a batch is queued + const n = pendingBatches.length; + const trigger = currentConfig.value.pruneOn === "agent-message" + ? "agent's next text response" + : "/pruner now"; + if (currentConfig.value.showPruneStatusLine) { + setPruneStatusWidget(ctx, currentConfig.value, `prune: ${n} pending`); + safeNotify( + ctx, + `pruner: ${n} turn${n === 1 ? "" : "s"} queued — will summarize on ${trigger}`, + "info" + ); + } + } + } + + // Recompute regardless of whether trim produced a batch: a turn whose + // toolResults are all protected/spilled/summarized/trimmed-empty still + // changes the branch (thinking, open-segment size), so the cache must not + // go stale on it. Placed before the pushedBatch/rearmedPending early + // return below — a cache write is not gate evaluation. + if (hasToolResults) computeMetricsSnapshot(ctx); + + // Mirrors main's `if (!batch) return;`: no freshly pushed batch this turn + // means no gate evaluation, regardless of leftover pendingBatches from an + // earlier turn — UNLESS a reload probe armed rearmedPending, in which case + // the gate below must still run. + if (!pushedBatch && !rearmedPending) return; + + // Token-budget auto-flush: an additional, mode-independent trigger. When context + // usage crosses autoBudgetThreshold, compact the queued batches now instead of + // waiting for this mode's flush boundary. The pendingBatches.length-or-rearmed + // guard makes an already-drained, non-rearmed queue a no-op. + const usage = ctx.getContextUsage?.(); + const budgetHit = shouldBudgetFlush(usage, currentConfig.value.autoBudgetThreshold); + const deltaHit = shouldDeltaFlush(usage, previousFraction, currentConfig.value.budgetTurnDelta); + // Update the per-turn baseline; leave it unchanged when tokens is null (e.g. + // right after a compaction) so the next real reading compares to the last known. + const f = usageFraction(usage); + if (f != null) previousFraction = f; + + const n = pendingBatches.length; + if ((n > 0 || rearmedPending) && !isFlushing && (budgetHit || deltaHit)) { + // Always surface this flush (even when the routine status line is off): it's a + // significant, infrequent event — context crossed a threshold or jumped sharply + // this turn — and it self-throttles because pendingBatches is drained right after. + safeNotify( + ctx, + n > 0 + ? `pruner: ${budgetHit ? "context budget reached" : "context jumped this turn"} — compacting ${n} pending turn${n === 1 ? "" : "s"}` + : `pruner: ${budgetHit ? "context budget reached" : "context jumped this turn"} — compacting work recovered after reload`, + "info", + ); + await flushPending(ctx, { delivery: "session", trigger: n === 0 ? "rearmed" : budgetHit ? "budget" : "delta" }); + } + }); + + // ── message_end: flush after the final assistant response in agent-message mode ── + // A final assistant message is the earliest reliable boundary where the agent has + // finished using the raw tool results. flushPending captures the SessionManager + // before awaiting summarization so print-mode shutdown cannot invalidate the + // persistence path while the summarizer model is running. + pi.on("message_end", async (event, ctx) => { + if (!currentConfig.value.enabled) return; + if (currentConfig.value.pruneOn !== "agent-message") return; + if (!isFinalAssistantMessage(event.message)) return; + await flushPending(ctx, { delivery: "session", closingMessage: event.message, trigger: "message-end" }); + }); + + // ── agent_end: last-chance cleanup only ───────────────────────────────────── + // agent-message normally flushes on message_end. By agent_end, print-mode Pi may + // already be disposing the session, so avoid starting a best-effort LLM call here. + pi.on("agent_end", async (_event, ctx) => { + if (!currentConfig.value.enabled) return; + if (pendingBatches.length === 0 && !rearmedPending) return; + setPruneStatusWidget( + ctx, + currentConfig.value, + pendingBatches.length > 0 ? `prune: ${pendingBatches.length} pending` : "prune: recovered pending (reload)", + ); + }); + + // ── context: prune summarized tool results from next LLM call ───────────── + pi.on("context", async (event, ctx) => { + if (!currentConfig.value.enabled) return undefined; + + let messages = event.messages; + let changed = false; + + // pruneMessages is the single source of truth for "is there work to do". + // It returns the original array reference (pruned: false) only when none of + // the four phases changed anything; index/registry emptiness alone does not + // imply a no-op, since error-purge (phase 2) prunes independently of them. + // Calling it unconditionally is safe and avoids a split gate here. + const result = pruneMessages( + messages, + indexer, + currentConfig.value.chainCompression, + currentConfig.value.purgeErrors, + currentConfig.value, + currentConfig.value.recoveryGraceTurns, + diagnostics, + ); + if (result.pruned) { + messages = result.messages; + changed = true; + statsAccum.setLiveReclaim(result.beforeChars, result.afterChars); + } + setPruneStatusWidget(ctx, currentConfig.value, statsAccum.getLiveReclaim(), diagnostics.counts(), metricsCache); + + if (!changed) return undefined; + return { messages }; + }); + + // ── Register context_tree_query tool ────────────────────────────────────── + registerQueryTool(pi, indexer); + + // ── Register /pruner command + summary message renderer ──────────── + const compactChains = async (ctx: any) => { + const branch = ctx.sessionManager.getBranch(); + const branchMessages = branch + .filter((e: any) => e.type === "message" && e.message) + .map((e: any) => e.message); + const chains = detectChains(branchMessages, protectionPredicate); + const inGrace = inGraceRecoveryToolCallIds(branchMessages, currentConfig.value.recoveryGraceTurns); + const result = await compressEligible( + chains, + 0, // effectiveK=0: compress every closed chain not already compressed + { + indexer, + blockRefs, + appendEntry: (type: string, data: unknown) => pi.appendEntry(type, data), + now: () => Date.now(), + fuseRange: makeFuseRange(ctx), + messages: branchMessages, + diagnostics, + backfill: { + spillThreshold: currentConfig.value.spillThreshold, + spillPreviewBytes: currentConfig.value.spillPreviewBytes, + sessionDir: ctx.sessionManager.getSessionDir(), + sessionId: ctx.sessionManager.getSessionId(), + }, + }, + inGrace, + ); + if (result.compressedEntries.length > 0) { + statsAccum.addChainsCompressed(result.compressedEntries.length); + statsAccum.persist(pi); + emitExternalCost(pi, statsAccum); + } + return { compressedEntries: result.compressedEntries, skipped: result.skipped.filter((s) => s.reason === "no-summary").length }; + }; + + registerCommands( + pi, + currentConfig, + flushPending, + capturePendingBatches, + () => statsAccum.getStats(), + () => statsAccum.getLiveReclaim(), + indexer, + compactChains, + () => diagnostics.counts(), + (ctx: any) => computeMetricsSnapshot(ctx) ?? EMPTY_METRICS_SNAPSHOT, + () => metricsCache, + () => rearmedPending, + ); +} diff --git a/pi-condense/package.json b/pi-condense/package.json new file mode 100644 index 0000000..81671e8 --- /dev/null +++ b/pi-condense/package.json @@ -0,0 +1,62 @@ +{ + "name": "pi-condense", + "version": "2.9.1", + "description": "Pi coding-agent extension that summarizes completed tool-call batches, replaces raw outputs with short stubs, compresses closed tool-call chains, and recovers any original on demand via context_tree_query.", + "author": "Jacek Juraszek", + "license": "MIT", + "type": "module", + "repository": { + "type": "git", + "url": "git+https://github.com/jjuraszek/pi-condense.git" + }, + "homepage": "https://github.com/jjuraszek/pi-condense#readme", + "bugs": { + "url": "https://github.com/jjuraszek/pi-condense/issues" + }, + "keywords": [ + "pi-package", + "pi", + "pi-coding-agent", + "context", + "context-management", + "context-pruning", + "pruning", + "summarization", + "llm", + "prompt-caching", + "ai" + ], + "engines": { + "node": ">=22.19.0" + }, + "files": [ + "index.ts", + "src/**/*.ts", + "doc", + "pi-condense.png", + "README.md", + "CHANGELOG.md", + "PRUNING.md", + "LICENSE", + "UPSTREAM.md" + ], + "scripts": { + "test": "bun test src/" + }, + "pi": { + "extensions": [ + "./index.ts" + ], + "image": "https://raw.githubusercontent.com/jjuraszek/pi-condense/main/pi-condense.png" + }, + "funding": { + "type": "buymeacoffee", + "url": "https://buymeacoffee.com/jjurasszek" + }, + "peerDependencies": { + "@earendil-works/pi-coding-agent": "*", + "@earendil-works/pi-ai": "*", + "@earendil-works/pi-tui": "*", + "@sinclair/typebox": "*" + } +} diff --git a/pi-condense/pi-condense.png b/pi-condense/pi-condense.png new file mode 100644 index 0000000..8ba5d2e Binary files /dev/null and b/pi-condense/pi-condense.png differ diff --git a/pi-condense/src/batch-capture.test.ts b/pi-condense/src/batch-capture.test.ts new file mode 100644 index 0000000..9a1a996 --- /dev/null +++ b/pi-condense/src/batch-capture.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, test } from "bun:test"; +import { captureBatch, captureUnindexedBatchesFromSession, serializeBatchForSummarizer } from "./batch-capture.js"; +import type { CapturedBatch, CapturedToolCall } from "./types.js"; + +function toolCall(overrides: Partial = {}): CapturedToolCall { + return { + toolCallId: "id", + toolName: "read", + args: {}, + resultText: "ok", + isError: false, + ...overrides, + }; +} + +function batch(toolCalls: CapturedToolCall[]): CapturedBatch { + return { + turnIndex: 0, + timestamp: 0, + assistantText: "", + toolCalls, + }; +} + +describe("serializeBatchForSummarizer", () => { + test("prefixes each tool block with [[N:toolname]] in order", () => { + const b = batch([ + toolCall({ toolCallId: "a", toolName: "read" }), + toolCall({ toolCallId: "b", toolName: "bash" }), + ]); + + const result = serializeBatchForSummarizer(b); + + expect(result).toContain("[[1:read]] Tool: read("); + expect(result).toContain("[[2:bash]] Tool: bash("); + }); + + test("numbering is contiguous 1..N regardless of toolCallId values", () => { + const b = batch([ + toolCall({ toolCallId: "zzz", toolName: "read" }), + toolCall({ toolCallId: "aaa", toolName: "read" }), + toolCall({ toolCallId: "mmm", toolName: "write" }), + ]); + + const result = serializeBatchForSummarizer(b); + + expect(result).toContain("[[1:read]] Tool:"); + expect(result).toContain("[[2:read]] Tool:"); + expect(result).toContain("[[3:write]] Tool:"); + }); +}); + +describe("occurrence capture", () => { + test("captureBatch records the matched result's timestamp", () => { + const message = { + role: "assistant", + content: [{ type: "toolCall", id: "bash_23", name: "bash", input: { cmd: "ls" } }], + timestamp: 2100, + }; + const results = [ + { role: "toolResult", toolCallId: "bash_23", toolName: "bash", content: [{ type: "text", text: "ok" }], isError: false, timestamp: 2150 }, + ]; + const batch = captureBatch(message, results, 0, 9999); + expect(batch.toolCalls[0].resultTimestamp).toBe(2150); + }); + + test("captureBatch omits resultTimestamp when no result matched", () => { + const message = { role: "assistant", content: [{ type: "toolCall", id: "x", name: "bash", input: {} }], timestamp: 1 }; + const batch = captureBatch(message, [], 0, 9999); + expect(batch.toolCalls[0].resultTimestamp).toBeUndefined(); + expect("resultTimestamp" in batch.toolCalls[0]).toBe(false); + expect(batch.toolCalls[0].resultText).toBe("(no result)"); + }); + + test("rescan pairs each assistant with the results of its OWN turn when ids repeat", () => { + const entry = (message: any) => ({ type: "message", message, timestamp: undefined }); + const branch = [ + entry({ role: "user", content: [{ type: "text", text: "go" }], timestamp: 1000 }), + entry({ role: "assistant", content: [{ type: "toolCall", id: "bash_23", name: "bash", input: {} }], timestamp: 1100 }), + entry({ role: "toolResult", toolCallId: "bash_23", toolName: "bash", content: [{ type: "text", text: "FIRST" }], isError: false, timestamp: 1150 }), + entry({ role: "assistant", content: [{ type: "toolCall", id: "bash_23", name: "bash", input: {} }], timestamp: 2100 }), + entry({ role: "toolResult", toolCallId: "bash_23", toolName: "bash", content: [{ type: "text", text: "SECOND" }], isError: false, timestamp: 2150 }), + ]; + const batches = captureUnindexedBatchesFromSession(branch, { isSummarized: () => false }); + expect(batches).toHaveLength(2); + expect(batches[0].toolCalls[0].resultText).toBe("FIRST"); + expect(batches[0].toolCalls[0].resultTimestamp).toBe(1150); + expect(batches[1].toolCalls[0].resultText).toBe("SECOND"); + expect(batches[1].toolCalls[0].resultTimestamp).toBe(2150); + }); + + test("rescan asks isSummarized with the occurrence key, not the bare id", () => { + const asked: string[] = []; + const entry = (message: any) => ({ type: "message", message }); + const branch = [ + entry({ role: "assistant", content: [{ type: "toolCall", id: "bash_23", name: "bash", input: {} }], timestamp: 1100 }), + entry({ role: "toolResult", toolCallId: "bash_23", toolName: "bash", content: [{ type: "text", text: "x" }], isError: false, timestamp: 1150 }), + ]; + captureUnindexedBatchesFromSession(branch, { isSummarized: (id: string) => (asked.push(id), false) }); + expect(asked).toContain("bash_23@1150"); + }); + + test("rescan still skips a call whose result has not arrived", () => { + const entry = (message: any) => ({ type: "message", message }); + const branch = [ + entry({ role: "assistant", content: [{ type: "toolCall", id: "pending", name: "bash", input: {} }], timestamp: 1 }), + ]; + expect(captureUnindexedBatchesFromSession(branch, { isSummarized: () => false })).toEqual([]); + }); + + test("rescan does not pair a result that falls outside its own assistant's turn window", () => { + // bash_23's result lands AFTER the next assistant message, i.e. in the + // second assistant's window, not the first's. Per-turn scanning does not + // fabricate a pair for the first assistant (no in-window result), and the + // second assistant has no bash_23 call to attach the result to either, so + // no batch is emitted at all. + const entry = (message: any) => ({ type: "message", message }); + const branch = [ + entry({ role: "assistant", content: [{ type: "toolCall", id: "bash_23", name: "bash", input: {} }], timestamp: 1000 }), + entry({ role: "assistant", content: [{ type: "toolCall", id: "other", name: "bash", input: {} }], timestamp: 1100 }), + entry({ role: "toolResult", toolCallId: "bash_23", toolName: "bash", content: [{ type: "text", text: "late" }], isError: false, timestamp: 1150 }), + ]; + expect(captureUnindexedBatchesFromSession(branch, { isSummarized: () => false })).toEqual([]); + }); +}); diff --git a/pi-condense/src/batch-capture.ts b/pi-condense/src/batch-capture.ts new file mode 100644 index 0000000..a30517a --- /dev/null +++ b/pi-condense/src/batch-capture.ts @@ -0,0 +1,235 @@ +import type { CapturedBatch, CapturedToolCall, BatchingMode } from "./types.js"; +import { occKey, resultTimestampOf } from "./occurrence-key.js"; + +/** Joins the text blocks of a ToolResultMessage into a single string. */ +export function extractToolResultText(msg: any): string { + const content: any[] = Array.isArray(msg?.content) ? msg.content : []; + return content + .filter((c: any) => c.type === "text") + .map((c: any) => c.text) + .join("\n"); +} + +/** + * Converts turn_end event data into a CapturedBatch. + * @param message AssistantMessage (content: Array of TextContent|ThinkingContent|ToolCall) + * @param toolResults ToolResultMessage[] + */ +export function captureBatch( + message: any, + toolResults: any[], + turnIndex: number, + timestamp: number +): CapturedBatch { + const content: any[] = Array.isArray(message?.content) ? message.content : []; + + // Collect assistant prose text + const assistantText = content + .filter((block: any) => block.type === "text") + .map((block: any) => block.text) + .join("\n") + .trim(); + + // Collect tool calls, matching each to its result + const toolCalls: CapturedToolCall[] = content + .filter((block: any) => block.type === "toolCall") + .map((block: any) => { + const match = toolResults.find((result: any) => result.toolCallId === block.id); + + let resultText = "(no result)"; + let isError = false; + const resultTimestamp = match ? resultTimestampOf(match.timestamp) : undefined; + + if (match) { + resultText = extractToolResultText(match); + isError = match.isError ?? false; + } + + return { + toolCallId: block.id, + toolName: block.name, + args: block.input ?? block.args ?? block.arguments ?? {}, + resultText, + isError, + ...(resultTimestamp !== undefined ? { resultTimestamp } : {}), + } satisfies CapturedToolCall; + }); + + return { turnIndex, timestamp, assistantText, toolCalls }; +} + +/** + * Scans a session branch for unsummarized tool results and groups them into CapturedBatches. + * Useful for capturing results from the current in-progress turn when a prune is triggered. + * + * @param branch The session message branch (from ctx.sessionManager.getBranch()) + * @param indexer The pruner indexer to check for already-summarized IDs + * @param exclude Optional predicate; matching tool calls are skipped (user-protected tools/paths) + */ +export function captureUnindexedBatchesFromSession( + branch: any[], + indexer: { isSummarized(id: string): boolean }, + exclude: (toolName: string, args: unknown) => boolean = () => false +): CapturedBatch[] { + // branch is SessionEntry[]. Each message entry has { type: "message", message: AgentMessage }. + // We must unwrap the SessionEntry wrapper before accessing role/toolCallId. + const entries = branch.filter((entry: any) => entry.type === "message"); + + const batches: CapturedBatch[] = []; + // turnCounter increments for EVERY assistant message (not just prunable ones). + // This makes turnIndex stable across multiple prune cycles: pruning removes + // ToolResultMessages from the context event but leaves AssistantMessages in the + // session branch, so the count of all assistant messages never decreases and + // always matches Pi's own event.turnIndex numbering. + let turnCounter = 0; + + // userTurnGroup increments on every user message seen while walking the branch. + // All assistant tool-call batches between two consecutive user messages share the + // same userTurnGroup. This is used by groupBatchesByMode to merge turns within + // a single user → final-agent-message span when batchingMode === "agent-message". + let userTurnGroup = 0; + + for (let i = 0; i < entries.length; i++) { + const entry = entries[i]; + const msg = entry.message; + + // Advance userTurnGroup on every user message so all subsequent assistant + // batches get a new group number. + if (msg.role === "user") { + userTurnGroup++; + continue; + } + + if (msg.role !== "assistant") continue; + + // Stable turn index: count every assistant message regardless of pruning state + const currentTurnIndex = turnCounter++; + + // Per-turn result map: only the results between this assistant message and + // the next one. A branch-wide map is last-wins and mis-pairs repeated ids. + const turnResults = new Map(); + for (let j = i + 1; j < entries.length; j++) { + const m = entries[j].message; + if (m.role === "assistant") break; + if (m.role === "toolResult" && m.toolCallId && !turnResults.has(m.toolCallId)) { + turnResults.set(m.toolCallId, m); + } + } + + const content = Array.isArray(msg.content) ? msg.content : []; + const toolCallBlocks = content.filter((c: any) => c.type === "toolCall"); + + // Find tool calls that have results in this branch and are not yet summarized + const readyToPrune = toolCallBlocks.filter((tc: any) => { + const id = tc.id; + if (!id) return false; + const result = turnResults.get(id); + if (!result) return false; + if (indexer.isSummarized(occKey(id, resultTimestampOf(result.timestamp)))) return false; + if (exclude(tc.name, tc.input ?? tc.arguments)) return false; + return true; + }); + + if (readyToPrune.length > 0) { + const results = readyToPrune.map((tc: any) => turnResults.get(tc.id)); + const readyIds = new Set(readyToPrune.map((tc: any) => tc.id)); + // We pass the full message but then trim back down to only the tool calls + // whose results already exist in the session. This lets a flush prune + // an intermediate completed subset in the middle of a longer tool chain + // without accidentally capturing later unresolved calls from the same + // assistant message as "(no result)" placeholders. + const ts = entry.timestamp ? new Date(entry.timestamp).getTime() : (msg.timestamp ?? Date.now()); + const batch = captureBatch(msg, results, currentTurnIndex, ts); + batches.push({ + ...batch, + toolCalls: batch.toolCalls.filter((tc) => readyIds.has(tc.toolCallId)), + // Tag with the current group so flushPending can merge by mode + userTurnGroup, + }); + } + } + + return batches; +} + +/** Serializes a single CapturedBatch into readable text for the summarizer LLM. */ +export function serializeBatchForSummarizer(batch: CapturedBatch): string { + const parts: string[] = []; + + if (batch.assistantText) { + parts.push(`Assistant said: ${batch.assistantText}\n`); + } + + const toolParts = batch.toolCalls.map((tc, index) => { + const status = tc.isError ? "ERROR" : "OK"; + const argsJson = JSON.stringify(tc.args, null, 2); + + let resultText = tc.resultText; + const MAX_CHARS = 2000; + if (resultText.length > MAX_CHARS) { + const remaining = resultText.length - MAX_CHARS; + resultText = resultText.slice(0, MAX_CHARS) + ` ...[${remaining} chars truncated]`; + } + + return `[[${index + 1}:${tc.toolName}]] Tool: ${tc.toolName}(${argsJson})\nResult (${status}): ${resultText}`; + }); + + parts.push(toolParts.join("\n---\n")); + + return parts.join("\n"); +} + +/** + * Groups CapturedBatches according to the chosen batching mode. + * + * - "turn" : returns the input array unchanged (one summary per assistant turn). + * - "agent-message" : merges all consecutive batches that share the same `userTurnGroup` + * into a single CapturedBatch, producing one summary per + * user → final-agent-message span. + * + * Batches without a `userTurnGroup` (e.g. from the live `turn_end` capture path) are + * always passed through one-per-batch regardless of mode — grouping only applies to + * batches captured from the session branch scan. + * + * Merge rules: + * - `assistantText` = non-empty values joined with "\n\n" + * - `toolCalls` = concatenation in original order + * - `turnIndex` = last batch's turnIndex (latest turn in the group) + * - `timestamp` = last batch's timestamp + * - `userTurnGroup` = shared group value of the merged batches + */ +export function groupBatchesByMode(batches: CapturedBatch[], mode: BatchingMode): CapturedBatch[] { + if (mode !== "agent-message") return batches; + + const out: CapturedBatch[] = []; + // current tracks the mutable merged batch being built for the current group. + // We spread into a plain object so we can mutate it without affecting the source. + let current: CapturedBatch & { userTurnGroup: number } | null = null; + + for (const batch of batches) { + // Batches without a group key are passed through individually; they break + // any open merge group too since we can't confidently assign them a span. + if (batch.userTurnGroup === undefined) { + current = null; + out.push(batch); + continue; + } + + if (current !== null && current.userTurnGroup === batch.userTurnGroup) { + // Same span — merge into the current accumulated batch + const textParts = [current.assistantText, batch.assistantText].filter(Boolean); + current.assistantText = textParts.join("\n\n"); + current.toolCalls = current.toolCalls.concat(batch.toolCalls); + // Advance to the latest turn metadata + current.turnIndex = batch.turnIndex; + current.timestamp = batch.timestamp; + } else { + // New group — create a fresh accumulated batch (shallow copy so mutations + // to `current` do not bleed back into the original `batch` object) + current = { ...batch, userTurnGroup: batch.userTurnGroup }; + out.push(current); + } + } + + return out; +} diff --git a/pi-condense/src/block-refs.test.ts b/pi-condense/src/block-refs.test.ts new file mode 100644 index 0000000..ffade14 --- /dev/null +++ b/pi-condense/src/block-refs.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, test } from "bun:test"; +import { BlockRefIssuer } from "./block-refs.js"; + +describe("BlockRefIssuer", () => { + test("first issue() returns b1", () => { + const issuer = new BlockRefIssuer(); + expect(issuer.issue()).toBe("b1"); + }); + + test("subsequent calls are monotonic", () => { + const issuer = new BlockRefIssuer(); + expect(issuer.issue()).toBe("b1"); + expect(issuer.issue()).toBe("b2"); + expect(issuer.issue()).toBe("b3"); + }); + + test("rebuildFrom([]) → next is b1", () => { + const issuer = new BlockRefIssuer(); + issuer.rebuildFrom([]); + expect(issuer.issue()).toBe("b1"); + }); + + test("rebuildFrom([b1, b2]) → next is b3", () => { + const issuer = new BlockRefIssuer(); + issuer.rebuildFrom(["b1", "b2"]); + expect(issuer.issue()).toBe("b3"); + }); + + test("rebuildFrom gapped sequence → max+1, not gap-fill", () => { + const issuer = new BlockRefIssuer(); + issuer.rebuildFrom(["b1", "b3"]); + expect(issuer.issue()).toBe("b4"); + }); + + test("rebuildFrom resets counter even after prior issues", () => { + const issuer = new BlockRefIssuer(); + issuer.issue(); // b1 + issuer.issue(); // b2 + issuer.rebuildFrom(["b5"]); + expect(issuer.issue()).toBe("b6"); + }); +}); diff --git a/pi-condense/src/block-refs.ts b/pi-condense/src/block-refs.ts new file mode 100644 index 0000000..9ff4445 --- /dev/null +++ b/pi-condense/src/block-refs.ts @@ -0,0 +1,16 @@ +export class BlockRefIssuer { + private next = 1; + + issue(): string { + return `b${this.next++}`; + } + + rebuildFrom(existingBlockIds: string[]): void { + if (existingBlockIds.length === 0) { + this.next = 1; + return; + } + const max = Math.max(...existingBlockIds.map((id) => parseInt(id.slice(1), 10))); + this.next = max + 1; + } +} diff --git a/pi-condense/src/budget.test.ts b/pi-condense/src/budget.test.ts new file mode 100644 index 0000000..5bf3781 --- /dev/null +++ b/pi-condense/src/budget.test.ts @@ -0,0 +1,113 @@ +import { describe, it, expect } from "bun:test"; +import { shouldBudgetFlush, shouldDeltaFlush, usageFraction, MAX_BUDGET_WINDOW } from "./budget.js"; + +const usage = (tokens: number | null, contextWindow: number) => + ({ tokens, contextWindow, percent: null }) as any; + +describe("shouldBudgetFlush", () => { + it("is false when threshold is null", () => { + expect(shouldBudgetFlush(usage(900, 1000), null)).toBe(false); + }); + it("is false for non-positive or >1 thresholds", () => { + expect(shouldBudgetFlush(usage(900, 1000), 0)).toBe(false); + expect(shouldBudgetFlush(usage(900, 1000), 1.5)).toBe(false); + }); + it("is false when usage is undefined", () => { + expect(shouldBudgetFlush(undefined, 0.8)).toBe(false); + }); + it("is false when tokens is null (post-compaction)", () => { + expect(shouldBudgetFlush(usage(null, 1000), 0.8)).toBe(false); + }); + it("is false when contextWindow is non-positive", () => { + expect(shouldBudgetFlush(usage(900, 0), 0.8)).toBe(false); + }); + it("is true at or over the threshold, false under", () => { + expect(shouldBudgetFlush(usage(800, 1000), 0.8)).toBe(true); + expect(shouldBudgetFlush(usage(900, 1000), 0.8)).toBe(true); + expect(shouldBudgetFlush(usage(799, 1000), 0.8)).toBe(false); + }); + + it("treats threshold of exactly 1.0 as valid (flush only at 100%)", () => { + expect(shouldBudgetFlush(usage(1000, 1000), 1)).toBe(true); + expect(shouldBudgetFlush(usage(999, 1000), 1)).toBe(false); + }); + + it("exposes the window ceiling as 300k", () => { + expect(MAX_BUDGET_WINDOW).toBe(300_000); + }); + + it("caps the trigger level at MAX_BUDGET_WINDOW on a huge window", () => { + expect(shouldBudgetFlush(usage(300_000, 1_000_000), 0.4)).toBe(true); + expect(shouldBudgetFlush(usage(299_999, 1_000_000), 0.4)).toBe(false); + expect(shouldBudgetFlush(usage(300_000, 1_000_000), 0.9)).toBe(true); + }); + + it("leaves models at or below the ceiling unchanged", () => { + expect(shouldBudgetFlush(usage(80_000, 200_000), 0.4)).toBe(true); + expect(shouldBudgetFlush(usage(79_999, 200_000), 0.4)).toBe(false); + expect(shouldBudgetFlush(usage(230_400, 256_000), 0.9)).toBe(true); + expect(shouldBudgetFlush(usage(230_399, 256_000), 0.9)).toBe(false); + expect(shouldBudgetFlush(usage(300_000, 300_000), 1)).toBe(true); + }); + + it("lets a low threshold govern below the ceiling on a huge window", () => { + expect(shouldBudgetFlush(usage(100_000, 1_000_000), 0.1)).toBe(true); + expect(shouldBudgetFlush(usage(99_999, 1_000_000), 0.1)).toBe(false); + }); +}); + +describe("usageFraction", () => { + it("returns null for undefined / null tokens / non-positive window", () => { + expect(usageFraction(undefined)).toBeNull(); + expect(usageFraction(usage(null, 1000))).toBeNull(); + expect(usageFraction(usage(900, 0))).toBeNull(); + }); + it("returns the fraction against the effective window", () => { + expect(usageFraction(usage(750, 1000))).toBe(0.75); + }); + + it("is not clamped above 1 when the window exceeds the ceiling", () => { + expect(usageFraction(usage(600_000, 1_000_000))).toBe(2); + expect(usageFraction(usage(80_000, 200_000))).toBe(0.4); + }); +}); + +describe("shouldDeltaFlush", () => { + it("is false when delta is null, non-positive, or >1", () => { + expect(shouldDeltaFlush(usage(900, 1000), 0.5, null)).toBe(false); + expect(shouldDeltaFlush(usage(900, 1000), 0.5, 0)).toBe(false); + expect(shouldDeltaFlush(usage(900, 1000), 0.5, 1.5)).toBe(false); + }); + it("is false when previousFraction is null (first turn / post-restart)", () => { + expect(shouldDeltaFlush(usage(900, 1000), null, 0.15)).toBe(false); + }); + it("is false when usage missing or tokens null", () => { + expect(shouldDeltaFlush(undefined, 0.5, 0.15)).toBe(false); + expect(shouldDeltaFlush(usage(null, 1000), 0.5, 0.15)).toBe(false); + }); + it("fires when the jump meets the delta, not below", () => { + expect(shouldDeltaFlush(usage(700, 1000), 0.5, 0.15)).toBe(true); // 0.20 >= 0.15 + expect(shouldDeltaFlush(usage(650, 1000), 0.5, 0.15)).toBe(true); // 0.15 exactly + expect(shouldDeltaFlush(usage(640, 1000), 0.5, 0.15)).toBe(false); // 0.14 < 0.15 + expect(shouldDeltaFlush(usage(600, 1000), 0.5, 0.15)).toBe(false); // 0.10 < 0.15 + }); + + it("measures growth against the capped window on a huge-window model", () => { + // previousFraction for 100k tokens on a 1M window = 100_000 / 300_000 + const prev = 100_000 / MAX_BUDGET_WINDOW; + expect(shouldDeltaFlush(usage(130_000, 1_000_000), prev, 0.1)).toBe(true); + expect(shouldDeltaFlush(usage(120_000, 1_000_000), prev, 0.1)).toBe(false); + }); + + it("leaves the growth requirement unchanged at or below the ceiling", () => { + const prev = 40_000 / 200_000; // 0.2 + expect(shouldDeltaFlush(usage(100_000, 200_000), prev, 0.3)).toBe(true); // +60k + expect(shouldDeltaFlush(usage(99_000, 200_000), prev, 0.3)).toBe(false); // +59k + }); + + it("keeps re-arming above the ceiling, where the fraction exceeds 1", () => { + const prev = 600_000 / MAX_BUDGET_WINDOW; // 2.0 - unclamped by design + expect(shouldDeltaFlush(usage(630_000, 1_000_000), prev, 0.1)).toBe(true); + expect(shouldDeltaFlush(usage(620_000, 1_000_000), prev, 0.1)).toBe(false); + }); +}); diff --git a/pi-condense/src/budget.ts b/pi-condense/src/budget.ts new file mode 100644 index 0000000..a1c0ac4 --- /dev/null +++ b/pi-condense/src/budget.ts @@ -0,0 +1,53 @@ +import type { ContextUsage } from "@earendil-works/pi-coding-agent"; + +// Ceiling on what the budget triggers treat as the context window. Advertised +// windows reach 1M, which makes any (0,1] fraction unreachable in a real session. +// The two triggers apply it in different shapes on purpose: the threshold is a +// LEVEL, so the cap bounds the level itself (min(CAP, threshold * window)); the +// delta is a GROWTH RATE, where a 300k ceiling could never bind, so the cap +// enters through the denominator instead (delta * min(window, CAP)). +export const MAX_BUDGET_WINDOW = 300_000; + +/** + * True iff a budget-triggered flush should fire: at `threshold` of the model's + * window, or at MAX_BUDGET_WINDOW tokens, whichever comes first. Computes the + * level ourselves rather than using ContextUsage.percent (a 0–100 value, null + * when tokens is null). tokens is also null right after a compaction — guarded here. + */ +export function shouldBudgetFlush( + usage: ContextUsage | undefined, + threshold: number | null, +): boolean { + if (threshold == null || threshold <= 0 || threshold > 1) return false; + if (!usage || usage.tokens == null || !(usage.contextWindow > 0)) return false; + return usage.tokens >= Math.min(MAX_BUDGET_WINDOW, threshold * usage.contextWindow); +} + +/** + * Usage fraction against the effective window (min(contextWindow, MAX_BUDGET_WINDOW)), + * or null when usage is missing / tokens null / window non-positive. NOT bounded by 1: + * 600k tokens on a 1M window returns 2.0. Deliberately unclamped — clamping would make + * shouldDeltaFlush saturate above the ceiling and stop re-arming. + */ +export function usageFraction(usage: ContextUsage | undefined): number | null { + if (!usage || usage.tokens == null || !(usage.contextWindow > 0)) return null; + return usage.tokens / Math.min(usage.contextWindow, MAX_BUDGET_WINDOW); +} + +/** + * True iff this turn's usage fraction rose by at least `delta` versus the previous turn, + * i.e. growth of at least `delta * min(contextWindow, MAX_BUDGET_WINDOW)` tokens. + * Mirrors shouldBudgetFlush's guards. previousFraction === null (first turn or post-restart) + * never fires; the absolute autoBudgetThreshold covers that gap. + */ +export function shouldDeltaFlush( + usage: ContextUsage | undefined, + previousFraction: number | null, + delta: number | null, +): boolean { + if (delta == null || delta <= 0 || delta > 1) return false; + if (previousFraction == null) return false; + const current = usageFraction(usage); + if (current == null) return false; + return current - previousFraction >= delta; +} diff --git a/pi-condense/src/chain-compressor.test.ts b/pi-condense/src/chain-compressor.test.ts new file mode 100644 index 0000000..aba78e8 --- /dev/null +++ b/pi-condense/src/chain-compressor.test.ts @@ -0,0 +1,841 @@ +import { describe, expect, it, test } from "bun:test"; +import { selectEligible, compressEligible, extractChainRecords, buildDeterministicBody } from "./chain-compressor.js"; +import type { ChainCompressorIndexerDeps } from "./chain-compressor.js"; +import type { ChainRange, ChainCompressionEntry, ToolCallRecord } from "./types.js"; +import { CUSTOM_TYPE_CHAIN } from "./types.js"; +import { detectChains } from "./chain-detector.js"; +import { inGraceRecoveryToolCallIds } from "./recovery-grace.js"; +import { occKey } from "./occurrence-key.js"; + +function closed(startUserTimestamp: number, toolCallIds: string[] = [`tc-${startUserTimestamp}`]): ChainRange { + return { startUserTimestamp, middleToolCallIds: toolCallIds, finalAssistantTimestamp: startUserTimestamp + 100 }; +} + +function emptyMiddle(startUserTimestamp: number): ChainRange { + return { startUserTimestamp, middleToolCallIds: [], finalAssistantTimestamp: startUserTimestamp + 100 }; +} + +function open(startUserTimestamp: number): ChainRange { + return { startUserTimestamp, middleToolCallIds: [], finalAssistantTimestamp: null }; +} + +describe("selectEligible", () => { + test("empty input → empty output", () => { + expect(selectEligible([], 3, new Set())).toEqual([]); + }); + + test("chains.length < K → empty", () => { + expect(selectEligible([closed(100), closed(300)], 3, new Set())).toHaveLength(0); + }); + + test("chains.length === K → empty (window exactly full)", () => { + expect(selectEligible([closed(100), closed(300), closed(500)], 3, new Set())).toHaveLength(0); + }); + + test("chains.length === K+1 → 1 chain (oldest)", () => { + const chains = [closed(100), closed(300), closed(500), closed(700)]; + const result = selectEligible(chains, 3, new Set()); + expect(result).toHaveLength(1); + expect(result[0].startUserTimestamp).toBe(100); + }); + + test("chains.length === K+3 → 3 chains (3 oldest, in input order)", () => { + const chains = [100, 300, 500, 700, 900, 1100].map((t) => closed(t)); + const result = selectEligible(chains, 3, new Set()); + expect(result).toHaveLength(3); + expect(result.map((c) => c.startUserTimestamp)).toEqual([100, 300, 500]); + }); + + test("open chains are never returned regardless of position", () => { + // 4 closed + 1 open; K=3 → only 1 closed oldest eligible (open doesn't count toward window) + const chains = [closed(100), open(200), closed(500), closed(700), closed(900)]; + const result = selectEligible(chains, 3, new Set()); + expect(result).toHaveLength(1); + expect(result[0].startUserTimestamp).toBe(100); + }); + + test("already-compressed chains are excluded and don't count toward window", () => { + // closed: [100, 300, 500, 700], K=1, already={100,300} + // not-already-compressed closed: [500, 700]; 2 chains, K=1 → take 1 → [500] + const chains = [closed(100), closed(300), closed(500), closed(700)]; + const result = selectEligible(chains, 1, new Set([100, 300])); + expect(result).toHaveLength(1); + expect(result[0].startUserTimestamp).toBe(500); + }); + + test("K=0 → all closed not-already-compressed chains returned", () => { + const chains = [closed(100), closed(300), closed(500)]; + expect(selectEligible(chains, 0, new Set())).toHaveLength(3); + }); + + test("K=0 with already-compressed → only not-yet-compressed", () => { + const chains = [closed(100), closed(300), closed(500)]; + const result = selectEligible(chains, 0, new Set([100])); + expect(result).toHaveLength(2); + expect(result.map((c) => c.startUserTimestamp)).toEqual([300, 500]); + }); + + test("empty-middle chains never selected regardless of K", () => { + // Conversational exchanges (no tool calls) must never occupy rolling-window slots. + const withTools = closed(300, ["tc1", "tc2"]); + const withTools2 = closed(400, ["tc3"]); + // K=0 means compress everything eligible; empty-middle chains should still be excluded. + const result = selectEligible([emptyMiddle(100), emptyMiddle(200), withTools, withTools2], 0, new Set()); + expect(result).toHaveLength(2); + expect(result.map((c) => c.startUserTimestamp)).toEqual([300, 400]); + // K=1 — only withTools2 stays in window; withTools is oldest eligible. + const result2 = selectEligible([emptyMiddle(100), emptyMiddle(200), withTools, withTools2], 1, new Set()); + expect(result2).toHaveLength(1); + expect(result2[0].startUserTimestamp).toBe(300); + }); +}); + +describe("compressEligible", () => { + function makeIndexer(opts: { + chainEntries?: ChainCompressionEntry[]; + hasSummary?: boolean; + toolRefs?: string[]; + perBatchSummaries?: string[]; + } = {}): ChainCompressorIndexerDeps { + return { + getChainEntries: () => opts.chainEntries ?? [], + hasPerBatchSummaryCoveringAny: (_ids: string[]) => opts.hasSummary ?? true, + getPerBatchSummariesForToolCallIds: (_ids: string[]) => opts.perBatchSummaries ?? [], + getToolRefsForToolCallIds: (_ids: string[]) => opts.toolRefs ?? [], + registerChain: (_entry: ChainCompressionEntry) => {}, + getIndex: () => new Map(), + backfillChainRecords: async () => [{ shortId: "t1", toolCallId: "c1", resultTimestamp: 1050 }], + } satisfies ChainCompressorIndexerDeps; + } + + function makeBlockRefs(ids: string[] = ["b1", "b2", "b3"]) { + let i = 0; + return { issue: () => ids[i++] ?? `b${i}` } satisfies Pick; + } + + // Covered-path fixtures never take the deterministic branch (hasSummary + // defaults true), so these three fields are unused at runtime - but + // CompressEligibleDeps requires them, so the fixture supplies inert defaults. + const NOOP_BACKFILL_DEPS = { + messages: [] as any[], + diagnostics: { report: () => {} }, + backfill: { spillThreshold: 1_000_000, spillPreviewBytes: 2048, sessionDir: "/tmp", sessionId: "s1" }, + }; + + test("compresses eligible chains and returns entries", async () => { + const chains = [closed(100, ["tc1"]), closed(300), closed(500), closed(700)]; + const appended: unknown[] = []; + const result = await compressEligible(chains, 3, { + indexer: makeIndexer({ hasSummary: true }), + blockRefs: makeBlockRefs(["b1"]), + appendEntry: (_type, data) => appended.push(data), + now: () => 9999, + ...NOOP_BACKFILL_DEPS, + }); + expect(result.compressedEntries).toHaveLength(1); + expect(result.compressedEntries[0].blockId).toBe("b1"); + expect(result.compressedEntries[0].startUserTimestamp).toBe(100); + expect(result.compressedEntries[0].compressedAt).toBe(9999); + expect(appended).toHaveLength(1); + }); + + test("covered-path chain entry pinned shape (identity pin, pre-deterministic-branch)", async () => { + // Pins the FULL entry shape produced by the existing covered path before + // the deterministic zero-LLM branch is introduced. Must stay byte-identical. + const chains = [closed(100, ["tc1"]), closed(300), closed(500), closed(700)]; + const appended: unknown[] = []; + const result = await compressEligible(chains, 3, { + indexer: makeIndexer({ hasSummary: true, toolRefs: ["t1"] }), + blockRefs: makeBlockRefs(["b1"]), + appendEntry: (_type, data) => appended.push(data), + now: () => 42, + }); + expect(result.compressedEntries).toHaveLength(1); + const entry = result.compressedEntries[0]; + expect(entry).toEqual({ + blockId: "b1", + startUserTimestamp: 100, + droppedToolCallIds: ["tc1"], + finalAssistantTimestamp: 200, + toolRefs: ["t1"], + compressedAt: 42, + }); + expect(entry.bodySource).toBeUndefined(); + expect(appended[0]).toEqual(entry); + }); + + test("no coverage -> deterministic compression, not a permanent skip", async () => { + // Was: "skips chain with no summary and records reason". Per spec + // 2026-08-14, zero coverage now routes to the deterministic zero-LLM + // branch instead of a terminal skip. + const chainMessages = [ + { role: "user", timestamp: 100, content: [{ type: "text", text: "u" }] }, + { role: "assistant", timestamp: 101, content: [{ type: "toolCall", id: "tc1", name: "bash", input: { cmd: "a" } }] }, + { role: "toolResult", toolCallId: "tc1", toolName: "bash", timestamp: 150, isError: false, content: [{ type: "text", text: "out" }] }, + { role: "assistant", timestamp: 200, content: [{ type: "text", text: "done" }] }, + ]; + const chains = [closed(100, ["tc1"]), closed(300, ["tc2"]), closed(500), closed(700)]; + const result = await compressEligible(chains, 3, { + indexer: { + ...makeIndexer({ hasSummary: false }), + getIndex: () => new Map(), + backfillChainRecords: async () => [{ shortId: "t1", toolCallId: "tc1", resultTimestamp: 1050 }], + }, + blockRefs: makeBlockRefs(), + appendEntry: () => {}, + now: () => 1, + messages: chainMessages, + diagnostics: { report: () => {} }, + backfill: { spillThreshold: 1_000_000, spillPreviewBytes: 2048, sessionDir: "/tmp", sessionId: "s1" }, + }); + expect(result.skipped).toHaveLength(0); + expect(result.compressedEntries).toHaveLength(1); + expect(result.compressedEntries[0].bodySource).toBe("deterministic"); + expect(result.compressedEntries[0].startUserTimestamp).toBe(100); + }); + + test("reports already-compressed chains in skipped list", async () => { + const existing: ChainCompressionEntry = { + blockId: "b1", + startUserTimestamp: 100, + droppedToolCallIds: ["tc-100"], + finalAssistantTimestamp: 200, + toolRefs: [], + compressedAt: 0, + }; + // 4 closed chains, K=3, chain@100 already compressed → none newly eligible + const chains = [closed(100), closed(300), closed(500), closed(700)]; + const result = await compressEligible(chains, 3, { + indexer: makeIndexer({ chainEntries: [existing] }), + blockRefs: makeBlockRefs(), + appendEntry: () => {}, + now: () => 1, + ...NOOP_BACKFILL_DEPS, + }); + // Primary contract: already-compressed chains must never be double-compressed. + expect(result.compressedEntries).toHaveLength(0); + expect(result.skipped).toHaveLength(1); + expect(result.skipped[0]).toEqual({ startUserTimestamp: 100, reason: "already-compressed" }); + }); + + test("appendEntry is called with CUSTOM_TYPE_CHAIN as the type argument", async () => { + const chains = [closed(100, ["tc1"]), closed(300), closed(500), closed(700)]; + const calls: Array<{ type: string; data: unknown }> = []; + await compressEligible(chains, 3, { + indexer: makeIndexer({ hasSummary: true }), + blockRefs: makeBlockRefs(["b1"]), + appendEntry: (type, data) => calls.push({ type, data }), + now: () => 0, + ...NOOP_BACKFILL_DEPS, + }); + expect(calls).toHaveLength(1); + expect(calls[0].type).toBe(CUSTOM_TYPE_CHAIN); + }); + + test("fuses range summary when >=2 per-batch summaries and fuseRange is provided", async () => { + const chains = [closed(100, ["tc1"]), closed(300), closed(500), closed(700)]; + const fuseCalls: string[] = []; + const result = await compressEligible(chains, 3, { + indexer: makeIndexer({ hasSummary: true, perBatchSummaries: ["s1", "s2"] }), + blockRefs: makeBlockRefs(["b1"]), + appendEntry: () => {}, + now: () => 1, + ...NOOP_BACKFILL_DEPS, + fuseRange: async (text) => { + fuseCalls.push(text); + return "FUSED"; + }, + }); + expect(fuseCalls).toEqual(["s1\n\ns2"]); + expect(result.compressedEntries[0].rangeSummaryText).toBe("FUSED"); + }); + + test("does not fuse a single per-batch summary", async () => { + const chains = [closed(100, ["tc1"]), closed(300), closed(500), closed(700)]; + let fuseCalled = false; + const result = await compressEligible(chains, 3, { + indexer: makeIndexer({ hasSummary: true, perBatchSummaries: ["only-one"] }), + blockRefs: makeBlockRefs(["b1"]), + appendEntry: () => {}, + now: () => 1, + ...NOOP_BACKFILL_DEPS, + fuseRange: async () => { + fuseCalled = true; + return "FUSED"; + }, + }); + expect(fuseCalled).toBe(false); + expect(result.compressedEntries[0].rangeSummaryText).toBeUndefined(); + }); + + test("fusion returning null falls back to no rangeSummaryText (still compresses)", async () => { + const chains = [closed(100, ["tc1"]), closed(300), closed(500), closed(700)]; + const result = await compressEligible(chains, 3, { + indexer: makeIndexer({ hasSummary: true, perBatchSummaries: ["s1", "s2"] }), + blockRefs: makeBlockRefs(["b1"]), + appendEntry: () => {}, + now: () => 1, + ...NOOP_BACKFILL_DEPS, + fuseRange: async () => null, + }); + expect(result.compressedEntries).toHaveLength(1); + expect(result.compressedEntries[0].rangeSummaryText).toBeUndefined(); + }); + + test("fusion throwing is non-fatal — chain still compresses via fallback", async () => { + const chains = [closed(100, ["tc1"]), closed(300), closed(500), closed(700)]; + const result = await compressEligible(chains, 3, { + indexer: makeIndexer({ hasSummary: true, perBatchSummaries: ["s1", "s2"] }), + blockRefs: makeBlockRefs(["b1"]), + appendEntry: () => {}, + now: () => 1, + ...NOOP_BACKFILL_DEPS, + fuseRange: async () => { + throw new Error("boom"); + }, + }); + expect(result.compressedEntries).toHaveLength(1); + expect(result.compressedEntries[0].rangeSummaryText).toBeUndefined(); + }); + + test("copies protectedToolCallIds from the range onto the entry when non-empty", async () => { + const chainWithProtected: ChainRange = { + startUserTimestamp: 100, + middleToolCallIds: ["tc1"], + finalAssistantTimestamp: 200, + protectedToolCallIds: ["b"], + }; + const appended: unknown[] = []; + const result = await compressEligible( + [chainWithProtected, closed(300), closed(500), closed(700)], + 3, + { + indexer: makeIndexer({ hasSummary: true }), + blockRefs: makeBlockRefs(["b1"]), + appendEntry: (_type, data) => appended.push(data), + now: () => 1, + ...NOOP_BACKFILL_DEPS, + }, + ); + expect(result.compressedEntries).toHaveLength(1); + expect(result.compressedEntries[0].protectedToolCallIds).toEqual(["b"]); + expect((appended[0] as ChainCompressionEntry).protectedToolCallIds).toEqual(["b"]); + }); + + test("omits protectedToolCallIds field entirely when range has none", async () => { + const chains = [closed(100, ["tc1"]), closed(300), closed(500), closed(700)]; + const result = await compressEligible(chains, 3, { + indexer: makeIndexer({ hasSummary: true }), + blockRefs: makeBlockRefs(["b1"]), + appendEntry: () => {}, + now: () => 1, + ...NOOP_BACKFILL_DEPS, + }); + expect(result.compressedEntries).toHaveLength(1); + expect("protectedToolCallIds" in result.compressedEntries[0]).toBe(false); + }); + + test("no fuseRange provided → no rangeSummaryText (concat fallback at render)", async () => { + const chains = [closed(100, ["tc1"]), closed(300), closed(500), closed(700)]; + const result = await compressEligible(chains, 3, { + indexer: makeIndexer({ hasSummary: true, perBatchSummaries: ["s1", "s2"] }), + blockRefs: makeBlockRefs(["b1"]), + appendEntry: () => {}, + now: () => 1, + ...NOOP_BACKFILL_DEPS, + }); + expect(result.compressedEntries[0].rangeSummaryText).toBeUndefined(); + }); +}); + +describe("selectEligible - recovery grace deferral", () => { + const chain = (startTs: number, ids: string[]) => + ({ + startUserTimestamp: startTs, + finalAssistantTimestamp: startTs + 10, + middleToolCallIds: ids, + protectedToolCallIds: [], + }) as any; + it("defers a chain whose span holds an in-grace recovery id", () => { + const chains = [chain(1, ["a", "t1"]), chain(2, ["b"])]; + const eligible = selectEligible(chains, 0, new Set(), new Set(["t1"])); + expect(eligible.map((c) => c.startUserTimestamp)).toEqual([2]); + }); + it("compresses normally when no in-grace recovery id is present", () => { + const chains = [chain(1, ["a", "t1"]), chain(2, ["b"])]; + const eligible = selectEligible(chains, 0, new Set(), new Set()); + expect(eligible.map((c) => c.startUserTimestamp)).toEqual([1, 2]); + }); + it("defers only the grace chain without shrinking the rolling-window buffer", () => { + // 5 eligible chains, rollingWindow=2 -> the window boundary is at n-W=3, so chains + // 1,2,3 are candidates for compression and 4,5 sit in the protected buffer. + // The in-grace id lives on chain 4, which is already outside the compress slice and + // must not affect it. A pre-slice filter (the bug) removes chain 4 from `candidates` + // before the boundary is computed, shrinking it to 4 items and shifting the cut to + // n-W=2 -> wrongly dropping chain 3 too. The fix computes the boundary first, so the + // in-buffer grace id changes nothing and the compress set stays [1, 2, 3]. + const chains = [chain(1, ["a"]), chain(2, ["b"]), chain(3, ["c"]), chain(4, ["d", "t1"]), chain(5, ["e"])]; + const eligible = selectEligible(chains, 2, new Set(), new Set(["t1"])); + expect(eligible.map((c) => c.startUserTimestamp)).toEqual([1, 2, 3]); + }); +}); + +describe("occurrence keys in compression", () => { + const chain = { + startUserTimestamp: 1000, + middleToolCallIds: ["bash_23"], + middleOccurrenceKeys: ["bash_23@1150"], + protectedToolCallIds: [], + finalAssistantTimestamp: 1200, + }; + + test("summary/toolRef lookups receive occurrence keys, not bare ids", async () => { + const asked: string[][] = []; + const deps = { + indexer: { + getChainEntries: () => [], + hasPerBatchSummaryCoveringAny: (ids: string[]) => (asked.push(ids), true), + getPerBatchSummariesForToolCallIds: (ids: string[]) => (asked.push(ids), ["s1"]), + getToolRefsForToolCallIds: (ids: string[]) => (asked.push(ids), ["t1"]), + registerChain: () => {}, + }, + blockRefs: { issue: () => "b1" }, + appendEntry: () => {}, + now: () => 5000, + }; + await compressEligible([chain as any], 0, deps as any); + expect(asked).toEqual([["bash_23@1150"], ["bash_23@1150"]]); + }); + + test("persists droppedOccurrenceKeys alongside droppedToolCallIds", async () => { + const appended: any[] = []; + const deps = { + indexer: { + getChainEntries: () => [], + hasPerBatchSummaryCoveringAny: () => true, + getPerBatchSummariesForToolCallIds: () => ["s1"], + getToolRefsForToolCallIds: () => ["t1"], + registerChain: () => {}, + }, + blockRefs: { issue: () => "b1" }, + appendEntry: (_t: string, data: unknown) => appended.push(data), + now: () => 5000, + }; + const { compressedEntries } = await compressEligible([chain as any], 0, deps as any); + expect(compressedEntries[0].droppedToolCallIds).toEqual(["bash_23"]); + expect(compressedEntries[0].droppedOccurrenceKeys).toEqual(["bash_23@1150"]); + expect((appended[0] as any).droppedOccurrenceKeys).toEqual(["bash_23@1150"]); + }); + + test("a chain without middleOccurrenceKeys falls back to bare ids", async () => { + const deps = { + indexer: { + getChainEntries: () => [], + hasPerBatchSummaryCoveringAny: () => true, + getPerBatchSummariesForToolCallIds: () => ["s1"], + getToolRefsForToolCallIds: () => ["t1"], + registerChain: () => {}, + }, + blockRefs: { issue: () => "b1" }, + appendEntry: () => {}, + now: () => 5000, + }; + const { compressedEntries } = await compressEligible( + [{ ...chain, middleOccurrenceKeys: undefined } as any], + 0, + deps as any, + ); + expect(compressedEntries[0].droppedOccurrenceKeys).toBeUndefined(); + }); +}); + +describe("selectEligible - recovery grace wiring (production boundary)", () => { + // Crosses the real boundary: real detectChains() + real inGraceRecoveryToolCallIds() + // feed real selectEligible(). Hand-injecting bare ids (as the suite above does) hides + // the format mismatch this test pins down. + it("does not compress a chain whose recovery-query result is still in grace", () => { + const messages = [ + { role: "user", timestamp: 1, content: [{ type: "text", text: "u1" }] }, + { + role: "assistant", + timestamp: 1, + content: [{ type: "toolCall", id: "t1", name: "context_tree_query", input: {} }], + }, + { + role: "toolResult", + toolCallId: "t1", + toolName: "context_tree_query", + timestamp: 100, + content: [{ type: "text", text: "recovered" }], + }, + { role: "assistant", timestamp: 2, content: [{ type: "text", text: "done" }] }, + { role: "user", timestamp: 3, content: [{ type: "text", text: "u2" }] }, + { role: "assistant", timestamp: 3, content: [{ type: "toolCall", id: "b", name: "bash", input: {} }] }, + { + role: "toolResult", + toolCallId: "b", + toolName: "bash", + timestamp: 200, + content: [{ type: "text", text: "ok" }], + }, + { role: "assistant", timestamp: 4, content: [{ type: "text", text: "done2" }] }, + ]; + + const chains = detectChains(messages); + expect(chains.map((c) => c.startUserTimestamp)).toEqual([1, 3]); + + const inGrace = inGraceRecoveryToolCallIds(messages, 3); + expect([...inGrace]).toEqual(["t1@100"]); + + const eligible = selectEligible(chains, 0, new Set(), inGrace); + expect(eligible.map((c) => c.startUserTimestamp)).toEqual([3]); + }); +}); + +describe("extractChainRecords", () => { + function messages() { + return [ + { role: "user", timestamp: 1000, content: [{ type: "text", text: "u" }] }, + { role: "assistant", timestamp: 1001, content: [{ type: "toolCall", id: "c1", name: "bash", input: { cmd: "a" } }] }, + { role: "toolResult", toolCallId: "c1", toolName: "bash", timestamp: 1050, isError: false, content: [{ type: "text", text: "out1" }] }, + { role: "assistant", timestamp: 1002, content: [{ type: "toolCall", id: "c2", name: "read", input: { path: "x" } }] }, + { role: "toolResult", toolCallId: "c2", toolName: "read", timestamp: 1150, isError: false, content: [{ type: "text", text: "out2" }] }, + { role: "assistant", timestamp: 1200, content: [{ type: "text", text: "done" }] }, + ]; + } + const chain = { startUserTimestamp: 1000, finalAssistantTimestamp: 1200, protectedToolCallIds: [] as string[] }; + + test("happy path: 2 middle calls -> 2 records with resultTimestamp/turnIndex/resultText", () => { + const records = extractChainRecords(messages(), chain, () => false); + expect(records).toHaveLength(2); + expect(records[0]).toEqual({ + toolCallId: "c1", + toolName: "bash", + args: { cmd: "a" }, + resultText: "out1", + isError: false, + turnIndex: -1, + timestamp: 1050, + resultTimestamp: 1050, + }); + expect(records[1]).toEqual({ + toolCallId: "c2", + toolName: "read", + args: { path: "x" }, + resultText: "out2", + isError: false, + turnIndex: -1, + timestamp: 1150, + resultTimestamp: 1150, + }); + }); + + test("excludes protected and already-indexed occurrence keys", () => { + const protectedChain = { ...chain, protectedToolCallIds: ["c1"] }; + const indexedKey = occKey("c2", 1150); + const records = extractChainRecords(messages(), protectedChain, (k) => k === indexedKey); + expect(records).toHaveLength(0); + }); + + test("falls back to block.args when block.input is absent (batch-capture parity)", () => { + const msgs = [ + { role: "user", timestamp: 1000, content: [{ type: "text", text: "u" }] }, + { role: "assistant", timestamp: 1001, content: [{ type: "toolCall", id: "c1", name: "bash", args: { cmd: "a" } }] }, + { role: "toolResult", toolCallId: "c1", toolName: "bash", timestamp: 1050, isError: false, content: [{ type: "text", text: "out1" }] }, + { role: "assistant", timestamp: 1200, content: [{ type: "text", text: "done" }] }, + ]; + const records = extractChainRecords(msgs, { ...chain, finalAssistantTimestamp: 1200 }, () => false); + expect(records).toHaveLength(1); + expect(records[0].args).toEqual({ cmd: "a" }); + }); +}); + +describe("buildDeterministicBody", () => { + function record(overrides: Partial): ToolCallRecord { + return { + toolCallId: "x", + toolName: "bash", + args: {}, + resultText: "", + isError: false, + turnIndex: -1, + timestamp: 0, + ...overrides, + }; + } + + test("full grammar pin", () => { + const records = [ + record({ toolCallId: "a1", toolName: "bash", args: { cmd: "ls" }, resultText: "r1", timestamp: 1000, resultTimestamp: 1000 }), + record({ toolCallId: "a2", toolName: "bash", args: { cmd: "pwd" }, resultText: "r2", timestamp: 2000, resultTimestamp: 2000 }), + record({ toolCallId: "a3", toolName: "read", args: { path: "f" }, resultText: "r3", timestamp: 3000, resultTimestamp: 3000 }), + ]; + const body = buildDeterministicBody(records, ["t1", "t2", "t3"]); + expect(body).toBe( + [ + "Deterministic chain compression (no per-batch summary existed for this span; raw outputs recoverable via context_tree_query).", + "Calls: 3", + "Tools: bash x2, read x1", + `Span: ${new Date(1000).toISOString()} -> ${new Date(3000).toISOString()} (2s)`, + 'First: bash {"cmd":"ls"}', + 'Last: read {"path":"f"}', + "Refs: t1, t2, t3", + ].join("\n"), + ); + }); + + test("sorts/spans by resultTimestamp when it differs from timestamp", () => { + // timestamp order is reversed vs resultTimestamp order; resultTimestamp must win. + const records = [ + record({ toolCallId: "a1", toolName: "bash", args: { cmd: "ls" }, timestamp: 9000, resultTimestamp: 1000 }), + record({ toolCallId: "a2", toolName: "read", args: { path: "f" }, timestamp: 1000, resultTimestamp: 9000 }), + ]; + const body = buildDeterministicBody(records, ["t1", "t2"]); + expect(body).toContain("First: bash "); + expect(body).toContain("Last: read "); + expect(body).toContain(`Span: ${new Date(1000).toISOString()} -> ${new Date(9000).toISOString()} (8s)`); + }); + + test("empty toolRefs falls back to bare toolCallIds for the Refs line", () => { + const records = [ + record({ toolCallId: "a1", toolName: "bash", timestamp: 1000 }), + record({ toolCallId: "a2", toolName: "read", timestamp: 2000 }), + ]; + const body = buildDeterministicBody(records, []); + expect(body).toContain("Refs: a1, a2"); + }); + + test("300-char args excerpt is capped at 200 chars + '...'", () => { + const longArgs = { s: "a".repeat(300) }; + const records = [record({ toolName: "bash", args: longArgs, timestamp: 0 })]; + const body = buildDeterministicBody(records, ["t1"]); + const firstLine = body.split("\n").find((l) => l.startsWith("First: "))!; + const excerptText = firstLine.slice("First: bash ".length); + expect(excerptText).toHaveLength(203); + expect(excerptText.endsWith("...")).toBe(true); + }); + + test("empty-args chain still produces a non-empty body with Calls/Tools/Span lines", () => { + const records = [record({ toolName: "bash", args: {}, resultText: "", timestamp: 500 })]; + const body = buildDeterministicBody(records, ["t1"]); + expect(body.length).toBeGreaterThan(0); + expect(body).toContain("Calls: 1"); + expect(body).toContain("Tools: bash x1"); + expect(body).toContain("Span:"); + }); +}); + +describe("compressEligible - deterministic zero-LLM branch", () => { + function chainMessages() { + return [ + { role: "user", timestamp: 1000, content: [{ type: "text", text: "u" }] }, + { role: "assistant", timestamp: 1001, content: [{ type: "toolCall", id: "c1", name: "bash", input: { cmd: "a" } }] }, + { role: "toolResult", toolCallId: "c1", toolName: "bash", timestamp: 1050, isError: false, content: [{ type: "text", text: "out1" }] }, + { role: "assistant", timestamp: 1002, content: [{ type: "toolCall", id: "c2", name: "read", input: { path: "x" } }] }, + { role: "toolResult", toolCallId: "c2", toolName: "read", timestamp: 1150, isError: false, content: [{ type: "text", text: "out2" }] }, + { role: "assistant", timestamp: 1200, content: [{ type: "text", text: "done" }] }, + ]; + } + + function uncoveredChain(): ChainRange { + return { + startUserTimestamp: 1000, + middleToolCallIds: ["c1", "c2"], + finalAssistantTimestamp: 1200, + protectedToolCallIds: [], + }; + } + + function makeDeterministicDeps(opts: { + messages?: any[]; + indexRecords?: Map; + backfillImpl?: (records: ToolCallRecord[], opts: unknown) => Promise; + toolRefs?: string[]; + diagnosticsReport?: (kind: string, dedupKey: string, detail: string) => void; + fuseRange?: (text: string) => Promise; + } = {}) { + const backfillCalls: Array<{ records: ToolCallRecord[]; opts: unknown }> = []; + const registerChainCalls: ChainCompressionEntry[] = []; + const appended: unknown[] = []; + const deps = { + indexer: { + getChainEntries: () => [], + hasPerBatchSummaryCoveringAny: () => false, + getPerBatchSummariesForToolCallIds: () => [], + getToolRefsForToolCallIds: () => opts.toolRefs ?? ["t1", "t2"], + registerChain: (entry: ChainCompressionEntry) => registerChainCalls.push(entry), + getIndex: () => opts.indexRecords ?? new Map(), + backfillChainRecords: async (records: ToolCallRecord[], backfillOpts: unknown) => { + backfillCalls.push({ records, opts: backfillOpts }); + if (opts.backfillImpl) return opts.backfillImpl(records, backfillOpts); + return [ + { shortId: "t1", toolCallId: "c1", resultTimestamp: 1050 }, + { shortId: "t2", toolCallId: "c2", resultTimestamp: 1150 }, + ]; + }, + } satisfies ChainCompressorIndexerDeps, + blockRefs: { issue: () => "b1" } satisfies Pick, + appendEntry: (_type: string, data: unknown) => appended.push(data), + now: () => 1, + fuseRange: opts.fuseRange, + messages: opts.messages ?? chainMessages(), + diagnostics: { report: opts.diagnosticsReport ?? (() => {}) }, + backfill: { spillThreshold: 1_000_000, spillPreviewBytes: 2048, sessionDir: "/tmp", sessionId: "s1" }, + }; + return { deps, backfillCalls, registerChainCalls, appended }; + } + + test("compresses an uncovered chain with a deterministic body", async () => { + let fuseCalled = false; + const { deps, backfillCalls, registerChainCalls } = makeDeterministicDeps({ + fuseRange: async () => { + fuseCalled = true; + return "FUSED"; + }, + }); + const result = await compressEligible( + [{ ...uncoveredChain(), protectedToolCallIds: ["c2"] }], + 0, + deps as any, + ); + expect(result.compressedEntries).toHaveLength(1); + const entry = result.compressedEntries[0]; + expect(entry.bodySource).toBe("deterministic"); + expect(entry.rangeSummaryText).toBeTruthy(); + expect(entry.toolRefs).toEqual(["t1", "t2"]); + expect(fuseCalled).toBe(false); + expect(backfillCalls).toHaveLength(1); + expect(backfillCalls[0].records).toHaveLength(1); + expect(backfillCalls[0].records.map((r) => r.toolCallId)).not.toContain("c2"); + expect(registerChainCalls).toHaveLength(1); + }); + + test("covered path is untouched: backfill never invoked, entry matches identity pin", async () => { + const { deps, backfillCalls } = makeDeterministicDeps(); + // Override to simulate coverage so the covered branch (not the deterministic one) runs. + (deps.indexer as any).hasPerBatchSummaryCoveringAny = () => true; + (deps.indexer as any).getToolRefsForToolCallIds = () => ["t1"]; + const chain: ChainRange = { + startUserTimestamp: 100, + middleToolCallIds: ["tc1"], + finalAssistantTimestamp: 200, + protectedToolCallIds: [], + }; + const result = await compressEligible([chain], 0, deps as any); + expect(result.compressedEntries).toHaveLength(1); + const entry = result.compressedEntries[0]; + expect(entry).toEqual({ + blockId: "b1", + startUserTimestamp: 100, + droppedToolCallIds: ["tc1"], + finalAssistantTimestamp: 200, + toolRefs: ["t1"], + compressedAt: 1, + }); + expect(backfillCalls).toHaveLength(0); + }); + + test("fail-closed: backfillChainRecords rejecting keeps the no-summary skip", async () => { + const { deps, registerChainCalls, appended } = makeDeterministicDeps({ + backfillImpl: async () => { + throw new Error("spill failed"); + }, + }); + const result = await compressEligible([uncoveredChain()], 0, deps as any); + expect(result.compressedEntries).toHaveLength(0); + expect(result.skipped).toEqual([{ startUserTimestamp: 1000, reason: "no-summary" }]); + expect(registerChainCalls).toHaveLength(0); + expect(appended).toHaveLength(0); + }); + + test("retry discriminator: members already indexed, nothing new extracted -> composes from index", async () => { + const indexed: ToolCallRecord = { + toolCallId: "c1", + toolName: "bash", + args: { cmd: "a" }, + resultText: "out1", + isError: false, + turnIndex: -1, + timestamp: 1050, + resultTimestamp: 1050, + }; + const indexed2: ToolCallRecord = { + toolCallId: "c2", + toolName: "read", + args: { path: "x" }, + resultText: "out2", + isError: false, + turnIndex: -1, + timestamp: 1150, + resultTimestamp: 1150, + }; + const index = new Map([ + [occKey("c1", 1050), indexed], + [occKey("c2", 1150), indexed2], + ]); + const chainWithOccKeys: ChainRange = { + ...uncoveredChain(), + middleOccurrenceKeys: [occKey("c1", 1050), occKey("c2", 1150)], + }; + const { deps, backfillCalls } = makeDeterministicDeps({ indexRecords: index }); + const result = await compressEligible([chainWithOccKeys], 0, deps as any); + expect(result.compressedEntries).toHaveLength(1); + expect(result.compressedEntries[0].bodySource).toBe("deterministic"); + expect(backfillCalls).toHaveLength(0); + }); + + test("retry discriminator: genuine span mismatch -> skip + backfill-empty diagnostic", async () => { + const reports: Array<[string, string, string]> = []; + const { deps } = makeDeterministicDeps({ + messages: [], // span cannot resolve -> extraction empty, index empty + diagnosticsReport: (kind, dedupKey, detail) => reports.push([kind, dedupKey, detail]), + }); + const result = await compressEligible([uncoveredChain()], 0, deps as any); + expect(result.compressedEntries).toHaveLength(0); + expect(result.skipped).toEqual([{ startUserTimestamp: 1000, reason: "no-summary" }]); + expect(reports).toHaveLength(1); + expect(reports[0][0]).toBe("backfill-empty"); + expect(reports[0][1]).toBe("1000"); + }); + + test("fully-protected uncovered chain: plain no-summary skip, no backfill-empty diagnostic", async () => { + // Every middle id is protected -> extraction excludes all of them by design, + // not by span mismatch. Reporting backfill-empty here would be a false alarm. + const reports: Array<[string, string, string]> = []; + const { deps } = makeDeterministicDeps({ + diagnosticsReport: (kind, dedupKey, detail) => reports.push([kind, dedupKey, detail]), + }); + const chain: ChainRange = { + startUserTimestamp: 1000, + middleToolCallIds: ["c1", "c2"], + finalAssistantTimestamp: 1200, + protectedToolCallIds: ["c1", "c2"], + }; + const result = await compressEligible([chain], 0, deps as any); + expect(result.compressedEntries).toHaveLength(0); + expect(result.skipped).toEqual([{ startUserTimestamp: 1000, reason: "no-summary" }]); + expect(reports).toHaveLength(0); + }); + + test("empty-args chain compresses with a non-empty deterministic body", async () => { + const messages = [ + { role: "user", timestamp: 1000, content: [{ type: "text", text: "u" }] }, + { role: "assistant", timestamp: 1001, content: [{ type: "toolCall", id: "c1", name: "bash", input: {} }] }, + { role: "toolResult", toolCallId: "c1", toolName: "bash", timestamp: 1050, isError: false, content: [{ type: "text", text: "" }] }, + { role: "assistant", timestamp: 1200, content: [{ type: "text", text: "done" }] }, + ]; + const chain: ChainRange = { + startUserTimestamp: 1000, + middleToolCallIds: ["c1"], + finalAssistantTimestamp: 1200, + protectedToolCallIds: [], + }; + const { deps } = makeDeterministicDeps({ messages }); + const result = await compressEligible([chain], 0, deps as any); + expect(result.compressedEntries).toHaveLength(1); + const body = result.compressedEntries[0].rangeSummaryText!; + expect(body).toContain("Calls: 1"); + expect(body).toContain("Tools: bash x1"); + expect(body.length).toBeGreaterThan(0); + }); +}); diff --git a/pi-condense/src/chain-compressor.ts b/pi-condense/src/chain-compressor.ts new file mode 100644 index 0000000..664c8c6 --- /dev/null +++ b/pi-condense/src/chain-compressor.ts @@ -0,0 +1,305 @@ +import { CUSTOM_TYPE_CHAIN } from "./types.js"; +import type { ChainRange, ChainCompressionEntry, ToolCallRecord } from "./types.js"; +import type { ToolCallIndexer } from "./indexer.js"; +import type { BlockRefIssuer } from "./block-refs.js"; +import type { DiagnosticSink } from "./diagnostics.js"; +import { bareToolCallId, occKey, parseOccKey, resultTimestampOf } from "./occurrence-key.js"; +import { resolveRange } from "./chain-range-prune.js"; +import { extractToolResultText } from "./batch-capture.js"; + +/** + * Grace ids are keyed the same way `recovery-grace.ts` keys them: occurrence + * (`id@timestamp`) when the recovery message carried a timestamp, bare id + * otherwise. A chain's middles are compared occurrence-first (exact match on + * `middleOccurrenceKeys`, falling back to `middleToolCallIds` for chains built + * before the field existed), so a graced occurrence never defers a chain + * holding a DIFFERENT occurrence of the same reused provider id. The only + * bare-to-bare fallback is for grace entries that themselves have no + * timestamp discriminant — there is no exact key to compare in that case. + */ +function chainMatchesGrace(chain: ChainRange, inGraceToolCallIds: Set): boolean { + const keys = chain.middleOccurrenceKeys?.length ? chain.middleOccurrenceKeys : chain.middleToolCallIds; + if (keys.some((k) => inGraceToolCallIds.has(k))) return true; + for (const g of inGraceToolCallIds) { + if (parseOccKey(g).resultTimestamp === undefined && keys.some((k) => bareToolCallId(k) === g)) return true; + } + return false; +} + +/** + * Pure eligibility filter: given all detected chains, return the subset + * that should be compressed — closed, not already compressed, and older + * than the rolling window. + * + * Extracted for unit testing without needing a real indexer or appendEntry. + * + * @param chains Must be in chronological order (oldest first), as emitted by + * chain-detector. Ordering is not validated here; out-of-order input silently + * picks wrong chains because the rolling-window slice is positional. + * @param inGraceToolCallIds Recovery ids still within their grace window. Chains + * spanning one of these ids are deferred from compression, but the rolling-window + * boundary itself is computed BEFORE grace exclusion, so a grace-protected chain + * never shrinks the window buffer or shifts which other chains become eligible. + */ +export function selectEligible( + chains: ChainRange[], + rollingWindow: number, + alreadyCompressed: Set, + inGraceToolCallIds: Set = new Set(), +): ChainRange[] { + const candidates = chains.filter( + (c) => + c.finalAssistantTimestamp !== null && + !alreadyCompressed.has(c.startUserTimestamp) && + c.middleToolCallIds.length > 0, + ); + const toCompress = candidates.slice(0, Math.max(0, candidates.length - rollingWindow)); + return toCompress.filter((c) => !chainMatchesGrace(c, inGraceToolCallIds)); +} + +/** + * The subset of ToolCallIndexer that compressEligible actually uses. + * Accepting this narrower interface keeps the function testable without a full indexer + * and documents its real dependency surface. + */ +export interface ChainCompressorIndexerDeps { + getChainEntries(): import("./types.js").ChainCompressionEntry[]; + hasPerBatchSummaryCoveringAny(toolCallIds: string[]): boolean; + getPerBatchSummariesForToolCallIds(toolCallIds: string[]): string[]; + getToolRefsForToolCallIds(toolCallIds: string[]): string[]; + registerChain(entry: import("./types.js").ChainCompressionEntry): void; + getIndex(): Map; + backfillChainRecords( + records: ToolCallRecord[], + opts: { + spillThreshold: number; + spillPreviewBytes: number; + sessionDir: string; + sessionId: string; + appendEntry: (customType: string, data?: unknown) => void; + }, + ): Promise; +} + +export interface CompressEligibleDeps { + indexer: ChainCompressorIndexerDeps; + blockRefs: BlockRefIssuer; + /** pi.appendEntry binding — routes to session or runtime depending on caller context */ + appendEntry: (customType: string, data: unknown) => void; + /** Injectable clock for deterministic tests */ + now: () => number; + /** + * Optional range-summary fuser (B). When present, a span with >= 2 per-batch + * summaries gets one LLM call fusing them into a cohesive `rangeSummaryText`. + * Returning null (or throwing) is non-fatal: the chain still compresses and + * the renderer falls back to the per-batch concatenation. + */ + fuseRange?: (perBatchSummaryText: string) => Promise; + /** MUST be the same withClosingMessage(...) array chain detection ran on - raw branch messages spuriously fail span resolution on the message_end path (see doc/specs/2026-08-14-uncovered-chain-deterministic-backfill.md). */ + messages: any[]; + diagnostics: Pick; + backfill: { spillThreshold: number; spillPreviewBytes: number; sessionDir: string; sessionId: string }; +} + +/** + * Pure span walk backing the deterministic zero-LLM branch. Excludes + * protected middles (relocated verbatim at render, never phase-1 stubbed) + * and already-indexed occurrence keys (retry idempotence). + */ +export function extractChainRecords( + messages: any[], + chain: Pick, + isIndexed: (occurrenceKey: string) => boolean, +): ToolCallRecord[] { + const range = resolveRange(chain, messages); + if (!range) return []; + const protectedIds = new Set(chain.protectedToolCallIds ?? []); + const open = new Map(); + const records: ToolCallRecord[] = []; + for (let i = range.startIndex + 1; i < range.endIndex; i++) { + const msg = messages[i]; + if (msg.role === "assistant" && Array.isArray(msg.content)) { + for (const block of msg.content) { + if (block.type === "toolCall") open.set(block.id, { toolName: block.name, args: block.input ?? block.args ?? block.arguments ?? {} }); + } + } else if (msg.role === "toolResult") { + const call = open.get(msg.toolCallId); + if (!call) continue; + if (protectedIds.has(msg.toolCallId)) continue; + const resultTimestamp = resultTimestampOf(msg.timestamp); + if (resultTimestamp === undefined) continue; + const key = occKey(msg.toolCallId, resultTimestamp); + if (isIndexed(key)) continue; + records.push({ + toolCallId: msg.toolCallId, + toolName: call.toolName, + args: call.args as Record, + resultText: extractToolResultText(msg), + isError: msg.isError === true, + turnIndex: -1, // backfilled records have no batch turn; query tool renders "Turn: -1" (pinned) + timestamp: resultTimestamp, + resultTimestamp, + }); + } + } + return records; +} + +const EXCERPT_CAP = 200; +function excerpt(args: unknown): string { + const s = JSON.stringify(args) ?? ""; + return s.length <= EXCERPT_CAP ? s : s.slice(0, EXCERPT_CAP) + "..."; +} + +/** Deterministic zero-LLM body. Grammar pinned by tests - change both together. */ +export function buildDeterministicBody(records: ToolCallRecord[], refs: string[]): string { + const counts = new Map(); + for (const r of records) counts.set(r.toolName, (counts.get(r.toolName) ?? 0) + 1); + const histogram = [...counts.entries()] + .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) + .map(([name, n]) => `${name} x${n}`) + .join(", "); + const at = (r: ToolCallRecord) => r.resultTimestamp ?? r.timestamp; + const sorted = [...records].sort((a, b) => at(a) - at(b)); + const first = sorted[0]; + const last = sorted[sorted.length - 1]; + const seconds = Math.round((at(last) - at(first)) / 1000); + const refsLine = refs.length > 0 ? refs.join(", ") : records.map((r) => r.toolCallId).join(", "); + return [ + "Deterministic chain compression (no per-batch summary existed for this span; raw outputs recoverable via context_tree_query).", + `Calls: ${records.length}`, + `Tools: ${histogram}`, + `Span: ${new Date(at(first)).toISOString()} -> ${new Date(at(last)).toISOString()} (${seconds}s)`, + `First: ${first.toolName} ${excerpt(first.args)}`, + `Last: ${last.toolName} ${excerpt(last.args)}`, + `Refs: ${refsLine}`, + ].join("\n"); +} + +export interface CompressEligibleResult { + compressedEntries: ChainCompressionEntry[]; + skipped: Array<{ startUserTimestamp: number; reason: "no-summary" | "already-compressed" }>; +} + +/** + * Compresses all chains that are outside the rolling window. + * Reads existing chain state from the indexer so calls are safe to repeat + * (already-compressed chains are detected and reported, not double-compressed). + */ +export async function compressEligible( + chains: ChainRange[], + rollingWindow: number, + deps: CompressEligibleDeps, + inGraceToolCallIds: Set = new Set(), +): Promise { + const alreadyCompressedTimestamps = new Set( + deps.indexer.getChainEntries().map((e) => e.startUserTimestamp), + ); + + const skipped: CompressEligibleResult["skipped"] = []; + + // Report already-compressed closed chains for observability. + for (const chain of chains) { + if (chain.finalAssistantTimestamp !== null && alreadyCompressedTimestamps.has(chain.startUserTimestamp)) { + skipped.push({ startUserTimestamp: chain.startUserTimestamp, reason: "already-compressed" }); + } + } + + const eligible = selectEligible(chains, rollingWindow, alreadyCompressedTimestamps, inGraceToolCallIds); + + const compressedEntries: ChainCompressionEntry[] = []; + for (const chain of eligible) { + // summaryBodies / toolRefs live in occurrence-key space (src/indexer.ts). + // Bare ids would match nothing and silently skip every chain. + const lookupKeys = chain.middleOccurrenceKeys?.length ? chain.middleOccurrenceKeys : chain.middleToolCallIds; + + if (!deps.indexer.hasPerBatchSummaryCoveringAny(lookupKeys)) { + // Deterministic zero-LLM fallback (spec 2026-08-14). Fail-closed: any + // failure below preserves the historical no-summary skip. + const index = deps.indexer.getIndex(); + const indexed: ToolCallRecord[] = []; + for (const key of lookupKeys) { + const r = index.get(key); + if (r) indexed.push(r); + } + const fresh = extractChainRecords(deps.messages, chain, (k) => index.has(k)); + if (fresh.length === 0 && indexed.length === 0) { + const protectedIds = new Set(chain.protectedToolCallIds ?? []); + const fullyProtected = + chain.middleToolCallIds.length > 0 && chain.middleToolCallIds.every((id) => protectedIds.has(id)); + if (!fullyProtected) { + // Genuine span mismatch - nothing extractable, nothing durable. + deps.diagnostics.report( + "backfill-empty", + String(chain.startUserTimestamp), + `middles=${chain.middleToolCallIds.length}`, + ); + } + skipped.push({ startUserTimestamp: chain.startUserTimestamp, reason: "no-summary" }); + continue; + } + try { + if (fresh.length > 0) { + await deps.indexer.backfillChainRecords(fresh, { ...deps.backfill, appendEntry: deps.appendEntry }); + } + } catch { + skipped.push({ startUserTimestamp: chain.startUserTimestamp, reason: "no-summary" }); + continue; + } + const allRecords = [...indexed, ...fresh]; + const toolRefs = deps.indexer.getToolRefsForToolCallIds(lookupKeys); + const entry: ChainCompressionEntry = { + blockId: deps.blockRefs.issue(), + startUserTimestamp: chain.startUserTimestamp, + droppedToolCallIds: chain.middleToolCallIds, + finalAssistantTimestamp: chain.finalAssistantTimestamp, + toolRefs, + compressedAt: deps.now(), + rangeSummaryText: buildDeterministicBody(allRecords, toolRefs), + bodySource: "deterministic", + ...(chain.protectedToolCallIds?.length ? { protectedToolCallIds: chain.protectedToolCallIds } : {}), + ...(chain.middleOccurrenceKeys?.length ? { droppedOccurrenceKeys: chain.middleOccurrenceKeys } : {}), + }; + deps.appendEntry(CUSTOM_TYPE_CHAIN, entry); + deps.indexer.registerChain(entry); + compressedEntries.push(entry); + continue; + } + + const blockId = deps.blockRefs.issue(); + const toolRefs = deps.indexer.getToolRefsForToolCallIds(lookupKeys); + + // B: fuse this span's per-batch summaries into one cohesive summary. + // Gated on >= 2 summaries (nothing to fuse otherwise). Non-fatal. + let rangeSummaryText: string | undefined; + if (deps.fuseRange) { + const summaries = deps.indexer.getPerBatchSummariesForToolCallIds(lookupKeys); + if (summaries.length >= 2) { + try { + const fused = await deps.fuseRange(summaries.join("\n\n")); + if (fused && fused.trim()) rangeSummaryText = fused; + } catch { + // fall back to the per-batch concatenation at render time + } + } + } + + const entry: ChainCompressionEntry = { + blockId, + startUserTimestamp: chain.startUserTimestamp, + droppedToolCallIds: chain.middleToolCallIds, + finalAssistantTimestamp: chain.finalAssistantTimestamp, + toolRefs, + compressedAt: deps.now(), + ...(rangeSummaryText ? { rangeSummaryText } : {}), + ...(chain.protectedToolCallIds?.length ? { protectedToolCallIds: chain.protectedToolCallIds } : {}), + ...(chain.middleOccurrenceKeys?.length ? { droppedOccurrenceKeys: chain.middleOccurrenceKeys } : {}), + }; + + deps.appendEntry(CUSTOM_TYPE_CHAIN, entry); + deps.indexer.registerChain(entry); + compressedEntries.push(entry); + } + + return { compressedEntries, skipped }; +} diff --git a/pi-condense/src/chain-detector.test.ts b/pi-condense/src/chain-detector.test.ts new file mode 100644 index 0000000..7c35905 --- /dev/null +++ b/pi-condense/src/chain-detector.test.ts @@ -0,0 +1,351 @@ +import { describe, expect, test } from "bun:test"; +import { detectChains, withClosingMessage } from "./chain-detector.js"; + +// ── Minimal message factories ────────────────────────────────────────────── + +function userMsg(timestamp: number, text = "do the thing"): any { + return { role: "user", content: [{ type: "text", text }], timestamp }; +} + +function syntheticChainMsg(timestamp: number, blockId = "b1"): any { + return { + role: "user", + content: [{ type: "text", text: `summary` }], + timestamp, + }; +} + +function assistantWithTools(timestamp: number, toolCallIds: string[]): any { + return { + role: "assistant", + content: [ + { type: "text", text: "working..." }, + ...toolCallIds.map((id) => ({ type: "toolCall", id, name: "bash", arguments: {} })), + ], + timestamp, + usage: {}, + stopReason: "toolUse", + }; +} + +function toolResult(timestamp: number, toolCallId: string): any { + return { + role: "toolResult", + toolCallId, + toolName: "bash", + content: [{ type: "text", text: "output" }], + isError: false, + timestamp, + }; +} + +function assistantText(timestamp: number, text = "done"): any { + return { + role: "assistant", + content: [{ type: "text", text }, { type: "thinking", thinking: "thoughts", thinkingSignature: "sig" }], + timestamp, + usage: {}, + stopReason: "stop", + }; +} + +// ── Tests ────────────────────────────────────────────────────────────────── + +describe("detectChains", () => { + test("empty input returns empty array", () => { + expect(detectChains([])).toEqual([]); + }); + + test("single complete chain produces one range", () => { + const msgs = [ + userMsg(100), + assistantWithTools(200, ["tc1", "tc2"]), + toolResult(300, "tc1"), + toolResult(310, "tc2"), + assistantText(400), + ]; + const ranges = detectChains(msgs); + expect(ranges).toHaveLength(1); + expect(ranges[0].startUserTimestamp).toBe(100); + expect(ranges[0].middleToolCallIds).toContain("tc1"); + expect(ranges[0].middleToolCallIds).toContain("tc2"); + expect(ranges[0].finalAssistantTimestamp).toBe(400); + }); + + test("multi-chain sequence produces N ranges in order", () => { + const msgs = [ + userMsg(100), + assistantWithTools(200, ["tc1"]), + toolResult(300, "tc1"), + assistantText(400), + userMsg(500), + assistantWithTools(600, ["tc2"]), + toolResult(700, "tc2"), + assistantText(800), + ]; + const ranges = detectChains(msgs); + expect(ranges).toHaveLength(2); + expect(ranges[0].startUserTimestamp).toBe(100); + expect(ranges[0].finalAssistantTimestamp).toBe(400); + expect(ranges[1].startUserTimestamp).toBe(500); + expect(ranges[1].finalAssistantTimestamp).toBe(800); + }); + + test("open chain (no text-only close) is not emitted", () => { + const msgs = [ + userMsg(100), + assistantWithTools(200, ["tc1"]), + toolResult(300, "tc1"), + // no text-only assistant close + ]; + expect(detectChains(msgs)).toHaveLength(0); + }); + + test("synthetic chain message is not treated as a chain start", () => { + const msgs = [ + syntheticChainMsg(50), // should be skipped + assistantText(200), // text-only but no prior real chain start + ]; + expect(detectChains(msgs)).toHaveLength(0); + }); + + test("synthetic chain message in a real multi-chain sequence is a passthrough", () => { + // Represents a session after one chain was already compressed: + // synthetic summary, then the next real chain + const msgs = [ + syntheticChainMsg(50), + userMsg(100), + assistantWithTools(200, ["tc1"]), + toolResult(300, "tc1"), + assistantText(400), + ]; + const ranges = detectChains(msgs); + expect(ranges).toHaveLength(1); + expect(ranges[0].startUserTimestamp).toBe(100); + }); + + test("interrupted chain (user interrupts before text-only close) emits with null final", () => { + const msgs = [ + userMsg(100), + assistantWithTools(200, ["tc1"]), + toolResult(300, "tc1"), + userMsg(400), // second user message interrupts before chain close + assistantText(500), + ]; + const ranges = detectChains(msgs); + // First chain is interrupted → emitted with null finalAssistantTimestamp + expect(ranges).toHaveLength(2); + expect(ranges[0].startUserTimestamp).toBe(100); + expect(ranges[0].finalAssistantTimestamp).toBeNull(); + // Second "chain" opened at ts=400, but text-only at 500 closes it (no tool calls needed) + // Actually, the second user at 400 starts a chain, but its assistant turn has no toolCalls + // so middleToolCallIds is empty and it immediately closes with the text-only assistant. + expect(ranges[1].startUserTimestamp).toBe(400); + expect(ranges[1].middleToolCallIds).toEqual([]); + expect(ranges[1].finalAssistantTimestamp).toBe(500); + }); + + test("agent-message flush: closing assistant absent from branch → newest chain reads as open", () => { + // Reproduces the runtime state at message_end: pi emits the event to extensions + // BEFORE appending event.message, so the just-closed assistant is missing here. + const branch = [ + userMsg(100), + assistantWithTools(200, ["tc1"]), + toolResult(300, "tc1"), + assistantText(400), + userMsg(500), + assistantWithTools(600, ["tc2"]), + toolResult(700, "tc2"), + // closing assistant for chain 2 not yet in branch + ]; + const ranges = detectChains(branch); + expect(ranges).toHaveLength(1); // only chain 1 closed; chain 2 open → dropped + }); + + test("middleToolCallIds contains all toolCallIds from assistant and toolResult messages", () => { + const msgs = [ + userMsg(100), + assistantWithTools(200, ["tc1", "tc2"]), + toolResult(300, "tc1"), + toolResult(310, "tc2"), + assistantWithTools(400, ["tc3"]), + toolResult(500, "tc3"), + assistantText(600), + ]; + const [range] = detectChains(msgs); + expect(range.middleToolCallIds.sort()).toEqual(["tc1", "tc2", "tc3"].sort()); + }); +}); + +describe("detectChains protectedToolCallIds", () => { + const chainMsgs = () => [ + { role: "user", timestamp: 1 }, + { + role: "assistant", + timestamp: 2, + content: [ + { type: "toolCall", id: "tc-read", name: "read" }, + { type: "toolCall", id: "tc-todo", name: "todowrite" }, + ], + }, + { role: "toolResult", toolCallId: "tc-read", toolName: "read", content: [{ type: "text", text: "x" }] }, + { role: "toolResult", toolCallId: "tc-todo", toolName: "todowrite", content: [{ type: "text", text: "plan" }] }, + { role: "assistant", timestamp: 5, content: [{ type: "text", text: "done" }] }, + ]; + + test("is empty when no protectedTools are given", () => { + const [chain] = detectChains(chainMsgs()); + expect(chain.protectedToolCallIds).toEqual([]); + expect(chain.middleToolCallIds.sort()).toEqual(["tc-read", "tc-todo"]); + }); + + test("collects protected ids by tool name from both branches", () => { + const [chain] = detectChains(chainMsgs(), (name) => name === "todowrite"); + expect(chain.protectedToolCallIds).toEqual(["tc-todo"]); + expect(chain.middleToolCallIds.sort()).toEqual(["tc-read", "tc-todo"]); + }); + + test("does not leak protected ids across consecutive chains", () => { + const msgs = [ + // chain 1: has a protected todowrite + { role: "user", timestamp: 1 }, + { role: "assistant", timestamp: 2, content: [{ type: "toolCall", id: "tc-todo", name: "todowrite" }] }, + { role: "toolResult", toolCallId: "tc-todo", toolName: "todowrite", content: [{ type: "text", text: "p" }] }, + { role: "assistant", timestamp: 4, content: [{ type: "text", text: "done 1" }] }, + // chain 2: only an unprotected read + { role: "user", timestamp: 5 }, + { role: "assistant", timestamp: 6, content: [{ type: "toolCall", id: "tc-read", name: "read" }] }, + { role: "toolResult", toolCallId: "tc-read", toolName: "read", content: [{ type: "text", text: "x" }] }, + { role: "assistant", timestamp: 8, content: [{ type: "text", text: "done 2" }] }, + ]; + const chains = detectChains(msgs, (name) => name === "todowrite"); + expect(chains.length).toBe(2); + expect(chains[0].protectedToolCallIds).toEqual(["tc-todo"]); + expect(chains[1].protectedToolCallIds).toEqual([]); + expect(chains[1].middleToolCallIds).toEqual(["tc-read"]); + }); + + test("collects protected ids by args.path via predicate", () => { + const msgs = [ + { role: "user", timestamp: 1 }, + { + role: "assistant", + timestamp: 2, + content: [ + { type: "toolCall", id: "tc-skill", name: "read", input: { path: "/h/skills/x/SKILL.md" } }, + { type: "toolCall", id: "tc-src", name: "read", input: { path: "/h/src/app.ts" } }, + ], + }, + { role: "toolResult", toolCallId: "tc-skill", toolName: "read", timestamp: 3, content: [] }, + { role: "toolResult", toolCallId: "tc-src", toolName: "read", timestamp: 4, content: [] }, + { role: "assistant", timestamp: 5, content: [{ type: "text", text: "done" }] }, + ]; + const pred = (name: string, args: unknown) => + typeof (args as any)?.path === "string" && (args as any).path.includes("/skills/"); + const [chain] = detectChains(msgs, pred); + expect(chain.protectedToolCallIds).toEqual(["tc-skill"]); + }); + + test("populates protectedToolCallIds on an interrupted (open→new user) chain", () => { + const msgs = [ + { role: "user", timestamp: 1 }, + { role: "assistant", timestamp: 2, content: [{ type: "toolCall", id: "tc-todo", name: "todowrite" }] }, + { role: "toolResult", toolCallId: "tc-todo", toolName: "todowrite", content: [{ type: "text", text: "p" }] }, + { role: "user", timestamp: 4 }, + ]; + const [interrupted] = detectChains(msgs, (name) => name === "todowrite"); + expect(interrupted.finalAssistantTimestamp).toBeNull(); + expect(interrupted.protectedToolCallIds).toEqual(["tc-todo"]); + }); +}); + +describe("middleOccurrenceKeys", () => { + test("emits one occurrence key per middle tool result", () => { + const messages = [ + { role: "user", content: [{ type: "text", text: "go" }], timestamp: 1000 }, + { role: "assistant", content: [{ type: "toolCall", id: "bash_1", name: "bash", input: {} }], timestamp: 1100 }, + { role: "toolResult", toolCallId: "bash_1", toolName: "bash", content: [{ type: "text", text: "x" }], isError: false, timestamp: 1150 }, + { role: "assistant", content: [{ type: "text", text: "done" }], timestamp: 1200 }, + ]; + const [chain] = detectChains(messages); + expect(chain.middleToolCallIds).toEqual(["bash_1"]); + expect(chain.middleOccurrenceKeys).toEqual(["bash_1@1150"]); + }); + + test("a tool call with no result contributes no occurrence key", () => { + const messages = [ + { role: "user", content: [{ type: "text", text: "go" }], timestamp: 1000 }, + { role: "assistant", content: [{ type: "toolCall", id: "bash_1", name: "bash", input: {} }], timestamp: 1100 }, + { role: "assistant", content: [{ type: "text", text: "done" }], timestamp: 1200 }, + ]; + const [chain] = detectChains(messages); + expect(chain.middleToolCallIds).toEqual(["bash_1"]); + expect(chain.middleOccurrenceKeys).toEqual([]); + }); + + test("two chains reusing one id get distinct occurrence keys", () => { + const turn = (base: number) => [ + { role: "user", content: [{ type: "text", text: "go" }], timestamp: base }, + { role: "assistant", content: [{ type: "toolCall", id: "bash_23", name: "bash", input: {} }], timestamp: base + 100 }, + { role: "toolResult", toolCallId: "bash_23", toolName: "bash", content: [{ type: "text", text: "x" }], isError: false, timestamp: base + 150 }, + { role: "assistant", content: [{ type: "text", text: "done" }], timestamp: base + 200 }, + ]; + const chains = detectChains([...turn(1000), ...turn(2000)]); + expect(chains.map((c) => c.middleOccurrenceKeys)).toEqual([["bash_23@1150"], ["bash_23@2150"]]); + }); + + test("an interrupted chain still reports the keys it collected", () => { + const messages = [ + { role: "user", content: [{ type: "text", text: "a" }], timestamp: 1000 }, + { role: "assistant", content: [{ type: "toolCall", id: "bash_1", name: "bash", input: {} }], timestamp: 1100 }, + { role: "toolResult", toolCallId: "bash_1", toolName: "bash", content: [{ type: "text", text: "x" }], isError: false, timestamp: 1150 }, + { role: "user", content: [{ type: "text", text: "b" }], timestamp: 2000 }, + { role: "assistant", content: [{ type: "text", text: "done" }], timestamp: 2200 }, + ]; + const [interrupted] = detectChains(messages); + expect(interrupted.finalAssistantTimestamp).toBeNull(); + expect(interrupted.middleOccurrenceKeys).toEqual(["bash_1@1150"]); + }); +}); + +describe("withClosingMessage", () => { + test("undefined closing returns the same array reference", () => { + const msgs = [userMsg(100)]; + expect(withClosingMessage(msgs, undefined)).toBe(msgs); + }); + + test("appends closing when branch does not already end with it", () => { + const branch = [userMsg(100), assistantWithTools(200, ["tc1"]), toolResult(300, "tc1")]; + const closing = assistantText(400); + const merged = withClosingMessage(branch, closing); + expect(merged).toHaveLength(4); + expect(merged[3]).toBe(closing); + expect(branch).toHaveLength(3); // original not mutated + }); + + test("does not double-append when branch already ends with the closing message (role+timestamp)", () => { + const closing = assistantText(400); + const branch = [userMsg(100), assistantWithTools(200, ["tc1"]), toolResult(300, "tc1"), closing]; + expect(withClosingMessage(branch, closing)).toBe(branch); + // also dedups a distinct object with the same role+timestamp + const branch2 = [userMsg(100), assistantText(400)]; + expect(withClosingMessage(branch2, assistantText(400))).toBe(branch2); + }); + + test("threading the closing message makes the newest chain close (effective window = K)", () => { + const branch = [ + userMsg(100), + assistantWithTools(200, ["tc1"]), + toolResult(300, "tc1"), + assistantText(400), + userMsg(500), + assistantWithTools(600, ["tc2"]), + toolResult(700, "tc2"), + ]; + const closing = assistantText(800); + const ranges = detectChains(withClosingMessage(branch, closing)); + expect(ranges).toHaveLength(2); + expect(ranges[1].startUserTimestamp).toBe(500); + expect(ranges[1].finalAssistantTimestamp).toBe(800); + }); +}); diff --git a/pi-condense/src/chain-detector.ts b/pi-condense/src/chain-detector.ts new file mode 100644 index 0000000..bb1e932 --- /dev/null +++ b/pi-condense/src/chain-detector.ts @@ -0,0 +1,135 @@ +import { occKey, resultTimestampOf } from "./occurrence-key.js"; +import type { ChainRange } from "./types.js"; + +/** Prefix that identifies a synthetic chain-compression user message. */ +const COMPRESSED_CHAIN_PREFIX = " b.type === "toolCall"); +} + +function collectToolCalls(msg: any): { id: string; name: string; args: unknown }[] { + if (!Array.isArray(msg.content)) return []; + return msg.content + .filter((b: any) => b.type === "toolCall" && b.id && b.name) + .map((b: any) => ({ id: b.id as string, name: b.name as string, args: b.input ?? b.arguments })); +} + +type State = "idle" | "inChain"; + +/** + * Walks an AgentMessage array and emits ChainRange records for each detectable chain. + * + * A chain is: [user message] → [assistant+toolResult turns...] → [text-only assistant]. + * Synthetic chain messages (injected by chain-range-prune) are treated as passthroughs — + * not chain starts. This is defensive; the detector normally runs pre-compression. + * + * NOTE: Message identity uses `timestamp` (for user / final text-only assistant) and + * `toolCallId` sets (for middle tool-using turns). AgentMessage has no `.id` field. + * + * @param isProtected Predicate over (toolName, args); matching calls are never pruned + * and their outputs are relocated verbatim into compressed chains. + */ +export function detectChains( + messages: any[], + isProtected: (toolName: string, args: unknown) => boolean = () => false, +): ChainRange[] { + const ranges: ChainRange[] = []; + let state: State = "idle"; + let chainStart: { timestamp: number } | null = null; + let middleIds = new Set(); + let middleKeys = new Set(); + let protectedIds = new Set(); + + const emitInterrupted = () => { + if (state === "inChain" && chainStart) { + ranges.push({ + startUserTimestamp: chainStart.timestamp, + middleToolCallIds: [...middleIds], + middleOccurrenceKeys: [...middleKeys], + protectedToolCallIds: [...protectedIds], + finalAssistantTimestamp: null, + }); + } + }; + + for (const msg of messages) { + if (msg.role === "user") { + if (isSyntheticChainMessage(msg)) continue; // passthrough — not a chain start + emitInterrupted(); + chainStart = { timestamp: msg.timestamp }; + middleIds = new Set(); + middleKeys = new Set(); + protectedIds = new Set(); + state = "inChain"; + continue; + } + + if (state !== "inChain") continue; + + if (msg.role === "assistant" && hasToolCalls(msg)) { + for (const { id, name, args } of collectToolCalls(msg)) { + middleIds.add(id); + if (isProtected(name, args)) protectedIds.add(id); + } + continue; + } + + if (msg.role === "toolResult") { + if (msg.toolCallId) { + middleIds.add(msg.toolCallId); + middleKeys.add(occKey(msg.toolCallId, resultTimestampOf(msg.timestamp))); + // toolResult fallback — results carry no args; name-only by design, + // the assistant block always precedes its result so no protection is lost + if (isProtected(msg.toolName, undefined)) protectedIds.add(msg.toolCallId); + } + continue; + } + + if (msg.role === "assistant" && !hasToolCalls(msg)) { + ranges.push({ + startUserTimestamp: chainStart!.timestamp, + middleToolCallIds: [...middleIds], + middleOccurrenceKeys: [...middleKeys], + protectedToolCallIds: [...protectedIds], + finalAssistantTimestamp: msg.timestamp, + }); + chainStart = null; + middleIds = new Set(); + middleKeys = new Set(); + protectedIds = new Set(); + state = "idle"; + } + } + + // Open chain at end of input is intentionally dropped (in-flight). + + return ranges; +} + +/** + * Appends `closing` to a copy of `branchMessages` unless the array already ends with it. + * + * pi emits `message_end` to extensions BEFORE persisting the message to the session + * (agent-session.js `_processAgentEvent` runs `_emitExtensionEvent` ahead of + * `sessionManager.appendMessage`). At the agent-message flush boundary the just-closed + * final assistant is therefore still missing from `getBranch()`; without threading it + * in, the newest chain reads as open and the rolling window over-retains by one + * (effective K+1 instead of K). Identity is role+timestamp (AgentMessage has no id, + * matching the detector's own identity model), so a future pi that persists before + * emitting keeps this a no-op. + */ +export function withClosingMessage(branchMessages: any[], closing: any): any[] { + if (!closing) return branchMessages; + const last = branchMessages[branchMessages.length - 1]; + if (last && last.role === closing.role && last.timestamp === closing.timestamp) return branchMessages; + return [...branchMessages, closing]; +} diff --git a/pi-condense/src/chain-range-prune.test.ts b/pi-condense/src/chain-range-prune.test.ts new file mode 100644 index 0000000..8509285 --- /dev/null +++ b/pi-condense/src/chain-range-prune.test.ts @@ -0,0 +1,873 @@ +import { describe, expect, test } from "bun:test"; +import { + applyChainCompressions, + buildSyntheticChainMessage, + isPerBatchSummaryMessage, + perBatchSummaryOverlapsDropped, + resolveRange, + withoutThinkingBlocks, +} from "./chain-range-prune.js"; +import { CUSTOM_TYPE_SUMMARY } from "./types.js"; +import type { ChainCompressionEntry } from "./types.js"; +import { expectNoOrphanToolResults, expectZeroOrphanSweep } from "./test-support.js"; + + +function userMsg(timestamp: number, text = "do the thing"): any { + return { role: "user", content: [{ type: "text", text }], timestamp }; +} + +function assistantWithTools(timestamp: number, toolCallIds: string[]): any { + return { + role: "assistant", + content: [ + { type: "text", text: "working..." }, + ...toolCallIds.map((id) => ({ type: "toolCall", id, name: "bash", arguments: {} })), + ], + timestamp, + usage: {}, + stopReason: "toolUse", + }; +} + +function toolResult(timestamp: number, toolCallId: string): any { + return { + role: "toolResult", + toolCallId, + toolName: "bash", + content: [{ type: "text", text: "output" }], + isError: false, + timestamp, + }; +} + +function assistantText(timestamp: number, includeThinking = false): any { + const content: any[] = [{ type: "text", text: "done" }]; + if (includeThinking) { + content.push({ type: "thinking", thinking: "deep thoughts", thinkingSignature: "sig123" }); + } + return { role: "assistant", content, timestamp, usage: {}, stopReason: "stop" }; +} + +function summaryMsg(timestamp: number, toolCallIds: string[]): any { + return { + role: "custom", + customType: "context-prune-summary", + content: "summary text", + display: false, + details: { toolCallRefs: toolCallIds.map((id, i) => ({ shortId: `t${i + 1}`, toolCallId: id })) }, + timestamp, + }; +} + +function summaryMsgOcc(timestamp: number, refs: { toolCallId: string; resultTimestamp: number }[]): any { + return { + role: "custom", + customType: "context-prune-summary", + content: "summary text", + display: false, + details: { + toolCallRefs: refs.map((r, i) => ({ shortId: `t${i + 1}`, toolCallId: r.toolCallId, resultTimestamp: r.resultTimestamp })), + }, + timestamp, + }; +} + +function entry( + blockId: string, + startUserTimestamp: number, + droppedToolCallIds: string[], + finalAssistantTimestamp: number | null, + toolRefs: string[] = [], +): ChainCompressionEntry { + return { + blockId, + startUserTimestamp, + droppedToolCallIds, + finalAssistantTimestamp, + toolRefs, + compressedAt: startUserTimestamp + 9999, + }; +} + +const noopSummary = (_e: ChainCompressionEntry) => "chain summary"; + +describe("isPerBatchSummaryMessage", () => { + test("returns true for context-prune-summary custom message", () => { + expect(isPerBatchSummaryMessage({ role: "custom", customType: "context-prune-summary" })).toBe(true); + }); + + test("returns false for other custom messages", () => { + expect(isPerBatchSummaryMessage({ role: "custom", customType: "context-prune-index" })).toBe(false); + }); + + test("returns false for user/assistant/toolResult roles", () => { + expect(isPerBatchSummaryMessage({ role: "user" })).toBe(false); + expect(isPerBatchSummaryMessage({ role: "assistant" })).toBe(false); + expect(isPerBatchSummaryMessage({ role: "toolResult" })).toBe(false); + }); +}); + +describe("perBatchSummaryOverlapsDropped", () => { + test("returns true when at least one legacy (no resultTimestamp) toolCallRef is in the dropped bare set", () => { + const msg = summaryMsg(999, ["tc1", "tc2"]); + expect(perBatchSummaryOverlapsDropped(msg, new Set(), new Set(["tc1"]))).toBe(true); + }); + + test("returns false when no legacy toolCallRefs are in the dropped bare set", () => { + const msg = summaryMsg(999, ["tc3"]); + expect(perBatchSummaryOverlapsDropped(msg, new Set(), new Set(["tc1", "tc2"]))).toBe(false); + }); + + test("returns false when details is missing", () => { + const msg = { role: "custom", customType: "context-prune-summary", content: "x", timestamp: 1 }; + expect(perBatchSummaryOverlapsDropped(msg, new Set(), new Set(["tc1"]))).toBe(false); + }); + + test("an occurrence-keyed ref matches only its exact occurrence, not the bare id of a different one", () => { + const msg = summaryMsgOcc(999, [{ toolCallId: "tc1", resultTimestamp: 3150 }]); + // Dropped set contains a DIFFERENT occurrence of the same bare id (tc1@2000), plus the bare + // id in the legacy fallback set — neither should cause a match for the live tc1@3150 ref. + expect(perBatchSummaryOverlapsDropped(msg, new Set(["tc1@2000"]), new Set(["tc1"]))).toBe(false); + }); + + test("an occurrence-keyed ref matches its exact occurrence in droppedOccKeys", () => { + const msg = summaryMsgOcc(999, [{ toolCallId: "tc1", resultTimestamp: 2000 }]); + expect(perBatchSummaryOverlapsDropped(msg, new Set(["tc1@2000"]), new Set())).toBe(true); + }); +}); + +describe("withoutThinkingBlocks", () => { + test("removes thinking blocks, keeps text blocks", () => { + const msg = assistantText(100, true); + const result = withoutThinkingBlocks(msg); + expect(result.content).toHaveLength(1); + expect(result.content[0].type).toBe("text"); + }); + + test("returns copy, not mutation", () => { + const msg = assistantText(100, true); + const result = withoutThinkingBlocks(msg); + expect(result).not.toBe(msg); + expect(msg.content).toHaveLength(2); // original unchanged + }); + + test("no-op when no thinking blocks present", () => { + const msg = assistantText(100, false); + const result = withoutThinkingBlocks(msg); + expect(result.content).toHaveLength(1); + expect(result.content[0].type).toBe("text"); + }); +}); + +describe("buildSyntheticChainMessage", () => { + test("produces a user-role message with F2 XML wrapper", () => { + const e = entry("b1", 100, ["tc1"], 400, ["t1"]); + const msg = buildSyntheticChainMessage(e, "the summary"); + expect(msg.role).toBe("user"); + expect(msg.content[0].type).toBe("text"); + expect(msg.content[0].text).toContain(`id="b1"`); + expect(msg.content[0].text).toContain(`tools="t1"`); + expect(msg.content[0].text).toContain("the summary"); + }); + + test("uses compressedAt as timestamp", () => { + const e = entry("b1", 100, ["tc1"], 400); + const msg = buildSyntheticChainMessage(e, "summary"); + expect(msg.timestamp).toBe(e.compressedAt); + }); + + test("multiple toolRefs are comma-joined", () => { + const e = entry("b2", 200, ["tc1", "tc2"], 500, ["t1", "t2"]); + const msg = buildSyntheticChainMessage(e, "summary"); + expect(msg.content[0].text).toContain(`tools="t1,t2"`); + }); +}); + +describe("applyChainCompressions", () => { + test("no-op when chainEntries is empty", () => { + const msgs = [userMsg(100), assistantText(200)]; + const result = applyChainCompressions(msgs, [], noopSummary, true); + expect(result).toBe(msgs); // same reference + }); + + test("drops ToolResultMessage whose toolCallId is in droppedToolCallIds", () => { + const msgs = [ + userMsg(100), + assistantWithTools(200, ["tc1"]), + toolResult(300, "tc1"), + assistantText(400), + ]; + const e = entry("b1", 100, ["tc1"], 400); + const result = applyChainCompressions(msgs, [e], noopSummary, false); + const roles = result.map((m: any) => m.role); + expect(roles).not.toContain("toolResult"); + }); + + test("drops AssistantMessage whose ToolCall blocks include a dropped id", () => { + const msgs = [ + userMsg(100), + assistantWithTools(200, ["tc1"]), + toolResult(300, "tc1"), + assistantText(400), + ]; + const e = entry("b1", 100, ["tc1"], 400); + const result = applyChainCompressions(msgs, [e], noopSummary, false); + // Only the final text-only assistant should remain (the one at 400) + const assistants = result.filter((m: any) => m.role === "assistant"); + expect(assistants).toHaveLength(1); + expect(assistants[0].timestamp).toBe(400); + }); + + test("inserts synthetic chain message immediately after the start user message", () => { + const msgs = [ + userMsg(100), + assistantWithTools(200, ["tc1"]), + toolResult(300, "tc1"), + assistantText(400), + ]; + const e = entry("b1", 100, ["tc1"], 400, ["t1"]); + const result = applyChainCompressions(msgs, [e], noopSummary, false); + + const userIdx = result.findIndex((m: any) => m.role === "user" && m.timestamp === 100); + expect(userIdx).not.toBe(-1); + const nextMsg = result[userIdx + 1]; + expect(nextMsg.role).toBe("user"); + expect(nextMsg.content[0].text).toContain("compressed-chain"); + }); + + test("ordering invariant: output preserves input order for surviving messages", () => { + const msgs = [ + userMsg(100), + assistantWithTools(200, ["tc1"]), + toolResult(300, "tc1"), + assistantText(400), + userMsg(500), + assistantText(600), + ]; + const e = entry("b1", 100, ["tc1"], 400); + const result = applyChainCompressions(msgs, [e], noopSummary, false); + + // Timestamps of remaining real messages should be in ascending order + const timestamps = result + .filter((m: any) => !(m.content?.[0]?.text ?? "").includes("compressed-chain")) + .map((m: any) => m.timestamp); + for (let i = 1; i < timestamps.length; i++) { + expect(timestamps[i]).toBeGreaterThan(timestamps[i - 1]); + } + }); + + test("suppresses per-batch summary whose toolCallRefs overlap droppedToolCallIds", () => { + const msgs = [ + userMsg(100), + assistantWithTools(200, ["tc1"]), + toolResult(300, "tc1"), + summaryMsg(350, ["tc1"]), + assistantText(400), + ]; + const e = entry("b1", 100, ["tc1"], 400); + const result = applyChainCompressions(msgs, [e], noopSummary, false); + const hasCustomSummary = result.some( + (m: any) => m.role === "custom" && m.customType === "context-prune-summary", + ); + expect(hasCustomSummary).toBe(false); + }); + + test("does not suppress per-batch summary whose toolCallRefs do not overlap", () => { + // Summary placed AFTER the resolved range (agent-message batching order) - + // positionally-inside summaries are dropped unconditionally regardless of + // coverage; coverage-based suppression only applies outside the range. + const msgs = [ + userMsg(100), + assistantWithTools(200, ["tc1"]), + toolResult(300, "tc1"), + assistantText(400), + summaryMsg(450, ["tc2"]), // different toolCallId, outside the range + ]; + const e = entry("b1", 100, ["tc1"], 400); + const result = applyChainCompressions(msgs, [e], noopSummary, false); + const hasCustomSummary = result.some( + (m: any) => m.role === "custom" && m.customType === "context-prune-summary", + ); + expect(hasCustomSummary).toBe(true); + }); + + test("a live turn's per-batch summary survives even when it reuses a compressed chain's bare id (occurrence collision)", () => { + // Compressed chain drops tc1@300 (dropped range: user@100..assistant@400). + // A later, LIVE turn reuses the bare id "tc1" with a different resultTimestamp (3150) and + // is never dropped (outside the resolved range). Its per-batch summary references that + // live occurrence and must not be suppressed by the earlier drop of the same bare id. + const msgs = [ + userMsg(100), + assistantWithTools(200, ["tc1"]), + toolResult(300, "tc1"), + assistantText(400), + userMsg(3000), + assistantWithTools(3100, ["tc1"]), + toolResult(3150, "tc1"), + summaryMsgOcc(3200, [{ toolCallId: "tc1", resultTimestamp: 3150 }]), + assistantText(3300), + ]; + const e = entry("b1", 100, ["tc1"], 400); + const result = applyChainCompressions(msgs, [e], noopSummary, false); + const hasCustomSummary = result.some( + (m: any) => m.role === "custom" && m.customType === "context-prune-summary", + ); + expect(hasCustomSummary).toBe(true); + }); + + test("strips thinking blocks from final assistant when stripFinalThinking=true", () => { + const msgs = [ + userMsg(100), + assistantWithTools(200, ["tc1"]), + toolResult(300, "tc1"), + assistantText(400, true), // has thinking block + ]; + const e = entry("b1", 100, ["tc1"], 400); + const result = applyChainCompressions(msgs, [e], noopSummary, true); + const finalAssistant = result.find((m: any) => m.role === "assistant" && m.timestamp === 400); + expect(finalAssistant).toBeDefined(); + expect(finalAssistant.content.some((c: any) => c.type === "thinking")).toBe(false); + expect(finalAssistant.content.some((c: any) => c.type === "text")).toBe(true); + }); + + test("keeps thinking blocks on final assistant when stripFinalThinking=false", () => { + const msgs = [ + userMsg(100), + assistantWithTools(200, ["tc1"]), + toolResult(300, "tc1"), + assistantText(400, true), + ]; + const e = entry("b1", 100, ["tc1"], 400); + const result = applyChainCompressions(msgs, [e], noopSummary, false); + const finalAssistant = result.find((m: any) => m.role === "assistant" && m.timestamp === 400); + expect(finalAssistant.content.some((c: any) => c.type === "thinking")).toBe(true); + }); + + test("idempotency: calling twice with same chainEntries yields same output", () => { + const msgs = [ + userMsg(100), + assistantWithTools(200, ["tc1"]), + toolResult(300, "tc1"), + assistantText(400), + userMsg(500), + assistantText(600), + ]; + const e = entry("b1", 100, ["tc1"], 400); + const first = applyChainCompressions(msgs, [e], noopSummary, true); + const second = applyChainCompressions(first, [e], noopSummary, true); + expect(second).toEqual(first); + }); + + test("idempotency: stable with blockSummaryLookup active", () => { + // Exercises the substitution code path across two passes. + const msgs = [ + userMsg(100), + assistantWithTools(200, ["tc1"]), + toolResult(300, "tc1"), + assistantText(400), + userMsg(500), + assistantText(600), + ]; + const e = entry("b1", 100, ["tc1"], 400, ["t1"]); + const summaryFn = (_: ChainCompressionEntry) => "chain summary text"; + const blockLookup = (id: string) => (id === "b1" ? "chain summary text" : undefined); + const first = applyChainCompressions(msgs, [e], summaryFn, false, blockLookup); + const second = applyChainCompressions(first, [e], summaryFn, false, blockLookup); + expect(second).toEqual(first); + }); + + test("multiple chains in one pass: each behaves independently", () => { + const msgs = [ + userMsg(100), + assistantWithTools(200, ["tc1"]), + toolResult(300, "tc1"), + assistantText(400), + userMsg(500), + assistantWithTools(600, ["tc2"]), + toolResult(700, "tc2"), + assistantText(800), + userMsg(900), + assistantText(1000), + ]; + const e1 = entry("b1", 100, ["tc1"], 400, ["t1"]); + const e2 = entry("b2", 500, ["tc2"], 800, ["t2"]); + const result = applyChainCompressions(msgs, [e1, e2], noopSummary, false); + + // Both toolResult messages should be gone + const toolResults = result.filter((m: any) => m.role === "toolResult"); + expect(toolResults).toHaveLength(0); + + // Both synthetic chain messages should be present + const synthetics = result.filter( + (m: any) => (m.content?.[0]?.text ?? "").includes("compressed-chain"), + ); + expect(synthetics).toHaveLength(2); + expect(synthetics[0].content[0].text).toContain(`id="b1"`); + expect(synthetics[1].content[0].text).toContain(`id="b2"`); + + // The uncompressed chain (userMsg 900 + assistantText 1000) should survive intact + expect(result.some((m: any) => m.role === "user" && m.timestamp === 900)).toBe(true); + expect(result.some((m: any) => m.role === "assistant" && m.timestamp === 1000)).toBe(true); + }); + + test("summaryTextForChain callback receives the correct entry", () => { + const msgs = [ + userMsg(100), + assistantWithTools(200, ["tc1"]), + toolResult(300, "tc1"), + assistantText(400), + ]; + const e = entry("b1", 100, ["tc1"], 400, ["t1"]); + const capturedEntries: ChainCompressionEntry[] = []; + const summary = (entry: ChainCompressionEntry) => { + capturedEntries.push(entry); + return "custom summary for " + entry.blockId; + }; + const result = applyChainCompressions(msgs, [e], summary, false); + expect(capturedEntries).toHaveLength(1); + expect(capturedEntries[0].blockId).toBe("b1"); + const synthetic = result.find((m: any) => (m.content?.[0]?.text ?? "").includes("compressed-chain")); + expect(synthetic?.content[0].text).toContain("custom summary for b1"); + }); + + test("blockSummaryLookup: {bN} in summary text is substituted", () => { + // Two chains: b1 (startUser=100) and b2 (startUser=500). + // b2's summary references {b1}. With a lookup, {b1} should be replaced inline. + const msgs = [ + userMsg(100), + assistantWithTools(200, ["tc1"]), + toolResult(300, "tc1"), + assistantText(400), + userMsg(500), + assistantWithTools(600, ["tc2"]), + toolResult(700, "tc2"), + assistantText(800), + ]; + const e1 = entry("b1", 100, ["tc1"], 400, ["t1"]); + const e2 = entry("b2", 500, ["tc2"], 800, ["t2"]); + const b1SummaryText = "b1 summary text"; + const summaryLookup = (entry: ChainCompressionEntry) => + entry.blockId === "b1" ? b1SummaryText : "see {b1} for details"; + const blockLookup = (blockId: string) => + blockId === "b1" ? b1SummaryText : undefined; + const result = applyChainCompressions(msgs, [e1, e2], summaryLookup, false, blockLookup); + const b2Synthetic = result.find( + (m: any) => (m.content?.[0]?.text ?? "").includes('id="b2"'), + ); + // {b1} inside b2's summary should be expanded + expect(b2Synthetic?.content[0].text).toContain(`see ${b1SummaryText} for details`); + expect(b2Synthetic?.content[0].text).not.toContain("{b1}"); + // b1's own synthetic should not be affected + const b1Synthetic = result.find( + (m: any) => (m.content?.[0]?.text ?? "").includes('id="b1"'), + ); + expect(b1Synthetic?.content[0].text).toContain(b1SummaryText); + }); + + test("relocates protected output verbatim into the compressed-chain body and still drops it from position", () => { + const e = { + blockId: "b1", + startUserTimestamp: 1, + droppedToolCallIds: ["tc-read", "tc-todo"], + protectedToolCallIds: ["tc-todo"], + finalAssistantTimestamp: 9, + toolRefs: ["t1", "t2"], + compressedAt: 100, + }; + const messages = [ + { role: "user", timestamp: 1, content: [{ type: "text", text: "go" }] }, + { role: "assistant", timestamp: 2, content: [ + { type: "toolCall", id: "tc-read", name: "read" }, + { type: "toolCall", id: "tc-todo", name: "todowrite" }, + ] }, + { role: "toolResult", toolCallId: "tc-read", toolName: "read", content: [{ type: "text", text: "FILE" }] }, + { role: "toolResult", toolCallId: "tc-todo", toolName: "todowrite", content: [{ type: "text", text: "PLAN-STATE" }] }, + { role: "assistant", timestamp: 9, content: [{ type: "text", text: "done" }] }, + ]; + const out = applyChainCompressions(messages, [e] as any, () => "SUMMARY", false); + // protected toolResult dropped from original position + expect(out.find((m: any) => m.role === "toolResult" && m.toolCallId === "tc-todo")).toBeUndefined(); + // text relocated into the synthetic block, under a labeled tag + const synthetic = out.find((m: any) => typeof m.content?.[0]?.text === "string" && m.content[0].text.startsWith("'); + expect(synthetic.content[0].text).toContain("PLAN-STATE"); + // non-protected output is NOT relocated + expect(synthetic.content[0].text).not.toContain("FILE"); + }); + + test("renders byte-identical to pre-feature output when no protected ids", () => { + const e = { + blockId: "b1", startUserTimestamp: 1, droppedToolCallIds: ["tc-read"], + finalAssistantTimestamp: 9, toolRefs: ["t1"], compressedAt: 100, + }; + const messages = [ + { role: "user", timestamp: 1, content: [{ type: "text", text: "go" }] }, + { role: "assistant", timestamp: 2, content: [{ type: "toolCall", id: "tc-read", name: "read" }] }, + { role: "toolResult", toolCallId: "tc-read", toolName: "read", content: [{ type: "text", text: "FILE" }] }, + { role: "assistant", timestamp: 9, content: [{ type: "text", text: "done" }] }, + ]; + const out = applyChainCompressions(messages, [e] as any, () => "SUMMARY", false); + const synthetic = out.find((m: any) => typeof m.content?.[0]?.text === "string" && m.content[0].text.startsWith("\nSUMMARY\n'); + }); + + test("relocates multiple protected outputs in message order within one block", () => { + const e = { + blockId: "b1", + startUserTimestamp: 1, + droppedToolCallIds: ["tc-a", "tc-b"], + protectedToolCallIds: ["tc-a", "tc-b"], + finalAssistantTimestamp: 9, + toolRefs: ["t1", "t2"], + compressedAt: 100, + }; + const messages = [ + { role: "user", timestamp: 1, content: [{ type: "text", text: "go" }] }, + { role: "assistant", timestamp: 2, content: [ + { type: "toolCall", id: "tc-a", name: "todowrite" }, + { type: "toolCall", id: "tc-b", name: "todoread" }, + ] }, + { role: "toolResult", toolCallId: "tc-a", toolName: "todowrite", content: [{ type: "text", text: "FIRST" }] }, + { role: "toolResult", toolCallId: "tc-b", toolName: "todoread", content: [{ type: "text", text: "SECOND" }] }, + { role: "assistant", timestamp: 9, content: [{ type: "text", text: "done" }] }, + ]; + const out = applyChainCompressions(messages, [e] as any, () => "SUMMARY", false); + const synthetic = out.find((m: any) => typeof m.content?.[0]?.text === "string" && m.content[0].text.startsWith("\nFIRST\n'); + expect(text).toContain('\nSECOND\n'); + expect(text.indexOf("FIRST")).toBeLessThan(text.indexOf("SECOND")); + }); + + test("skips a protected id whose toolResult is absent from input", () => { + const e = { + blockId: "b1", + startUserTimestamp: 1, + droppedToolCallIds: ["tc-gone"], + protectedToolCallIds: ["tc-gone"], + finalAssistantTimestamp: 9, + toolRefs: ["t1"], + compressedAt: 100, + }; + const messages = [ + { role: "user", timestamp: 1, content: [{ type: "text", text: "go" }] }, + { role: "assistant", timestamp: 9, content: [{ type: "text", text: "done" }] }, + ]; + const out = applyChainCompressions(messages, [e] as any, () => "SUMMARY", false); + const synthetic = out.find((m: any) => typeof m.content?.[0]?.text === "string" && m.content[0].text.startsWith(" { + const msgs = [ + userMsg(100), + assistantWithTools(200, ["tc1"]), + toolResult(300, "tc1"), + assistantText(400), + ]; + const e = entry("b1", 100, ["tc1"], 400, ["t1"]); + const summaryFn = () => "refers to {b99} unknown"; + const blockLookup = (_: string) => undefined; + const result = applyChainCompressions(msgs, [e], summaryFn, false, blockLookup); + const synthetic = result.find((m: any) => (m.content?.[0]?.text ?? "").includes("compressed-chain")); + // {b99} unknown block stays as literal + expect(synthetic?.content[0].text).toContain("{b99}"); + }); +}); + +describe("resolveRange", () => { + const base = () => [ + { role: "user", content: [{ type: "text", text: "go" }], timestamp: 1000 }, + { role: "assistant", content: [{ type: "toolCall", id: "bash_1", name: "bash", input: {} }], timestamp: 1100 }, + { role: "toolResult", toolCallId: "bash_1", toolName: "bash", content: [{ type: "text", text: "x" }], isError: false, timestamp: 1150 }, + { role: "assistant", content: [{ type: "text", text: "done" }], timestamp: 1200 }, + ]; + const entry = (over: any = {}) => ({ + blockId: "b1", + startUserTimestamp: 1000, + droppedToolCallIds: ["bash_1"], + droppedOccurrenceKeys: ["bash_1@1150"], + finalAssistantTimestamp: 1200, + toolRefs: ["t1"], + compressedAt: 5000, + ...over, + }); + + test("resolves the unique role-gated boundaries", () => { + expect(resolveRange(entry(), base())).toEqual({ startIndex: 0, endIndex: 3 }); + }); + + test("returns null when finalAssistantTimestamp is null", () => { + expect(resolveRange(entry({ finalAssistantTimestamp: null }), base())).toBeNull(); + }); + + test("returns null when the start timestamp matches two user messages", () => { + const msgs = [...base(), { role: "user", content: [{ type: "text", text: "dup" }], timestamp: 1000 }]; + expect(resolveRange(entry(), msgs)).toBeNull(); + }); + + test("returns null when a boundary message is absent", () => { + expect(resolveRange(entry({ startUserTimestamp: 999 }), base())).toBeNull(); + }); + + test("returns null when the end precedes the start", () => { + // Both boundaries resolve uniquely, but the assistant match sits before + // the user match positionally - startIndex < endIndex must still hold. + const msgs = [ + { role: "assistant", content: [{ type: "text", text: "early" }], timestamp: 900 }, + { role: "user", content: [{ type: "text", text: "go" }], timestamp: 1000 }, + { role: "assistant", content: [{ type: "text", text: "done" }], timestamp: 1200 }, + ]; + expect(resolveRange(entry({ finalAssistantTimestamp: 900 }), msgs)).toBeNull(); + }); + + test("ignores a toolResult sharing the final assistant timestamp (role gating)", () => { + const msgs = base(); + msgs[2] = { ...msgs[2], timestamp: 1200 }; + expect(resolveRange(entry(), msgs)).toEqual({ startIndex: 0, endIndex: 3 }); + }); + + test("resolveRange accepts a minimal timestamp pair (backfill span walk)", () => { + const messages = [ + { role: "user", timestamp: 100 }, + { role: "assistant", timestamp: 200 }, + ]; + const range = resolveRange({ startUserTimestamp: 100, finalAssistantTimestamp: 200 }, messages); + expect(range).toEqual({ startIndex: 0, endIndex: 1 }); + }); +}); + +describe("applyChainCompressions - positional", () => { + // Incident fixture: chains b5/b7 compressed, then a live turn reusing bash_23. + const incident = () => [ + { role: "user", content: [{ type: "text", text: "1" }], timestamp: 1000 }, + { role: "assistant", content: [{ type: "toolCall", id: "bash_18", name: "bash", input: {} }], timestamp: 1100 }, + { role: "toolResult", toolCallId: "bash_18", toolName: "bash", content: [{ type: "text", text: "a" }], isError: false, timestamp: 1150 }, + { role: "assistant", content: [{ type: "text", text: "done 1" }], timestamp: 1200 }, + { role: "user", content: [{ type: "text", text: "2" }], timestamp: 2000 }, + { role: "assistant", content: [{ type: "toolCall", id: "bash_23", name: "bash", input: {} }], timestamp: 2100 }, + { role: "toolResult", toolCallId: "bash_23", toolName: "bash", content: [{ type: "text", text: "b" }], isError: false, timestamp: 2150 }, + { role: "assistant", content: [{ type: "text", text: "done 2" }], timestamp: 2200 }, + { role: "user", content: [{ type: "text", text: "3" }], timestamp: 3000 }, + { role: "assistant", content: [{ type: "toolCall", id: "bash_23", name: "bash", input: {} }, { type: "toolCall", id: "gauntlet_setting_24", name: "gauntlet_setting", input: {} }], timestamp: 3100 }, + { role: "toolResult", toolCallId: "bash_23", toolName: "bash", content: [{ type: "text", text: "LIVE" }], isError: false, timestamp: 3150 }, + { role: "toolResult", toolCallId: "gauntlet_setting_24", toolName: "gauntlet_setting", content: [{ type: "text", text: "LIVE2" }], isError: false, timestamp: 3160 }, + ]; + const entries = [ + // compressedAt kept below 3000 so the "live turn" ts>=3000 filter in the + // survives-with-both-results test below doesn't also catch the synthetics. + { blockId: "b5", startUserTimestamp: 1000, droppedToolCallIds: ["bash_18"], droppedOccurrenceKeys: ["bash_18@1150"], finalAssistantTimestamp: 1200, toolRefs: ["t1"], compressedAt: 1050 }, + { blockId: "b7", startUserTimestamp: 2000, droppedToolCallIds: ["bash_23"], droppedOccurrenceKeys: ["bash_23@2150"], finalAssistantTimestamp: 2200, toolRefs: ["t2"], compressedAt: 2050 }, + ]; + const summaryFor = (e: any) => `summary ${e.blockId}`; + + test("the live turn reusing a dropped id survives with both of its results", () => { + const out = applyChainCompressions(incident(), entries as any, summaryFor, false); + const live = out.filter((m: any) => (m.timestamp ?? 0) >= 3000); + expect(live).toHaveLength(4); + expect(live.filter((m: any) => m.role === "toolResult").map((m: any) => m.toolCallId)).toEqual([ + "bash_23", + "gauntlet_setting_24", + ]); + }); + + test("drops exactly the two chain interiors and inserts both synthetics", () => { + const out = applyChainCompressions(incident(), entries as any, summaryFor, false); + expect(out).toHaveLength(12 - 4 + 2); + const synthetics = out.filter((m: any) => m.role === "user" && m.content?.[0]?.text?.startsWith(" { + const out = applyChainCompressions(incident(), entries as any, summaryFor, false); + expectNoOrphanToolResults(out); + }); + + test("an unresolved entry drops nothing and inserts no synthetic", () => { + const reports: any[] = []; + const sink = { report: (kind: string, dedupKey: string, detail: string) => reports.push({ kind, dedupKey, detail }), counts: () => ({}) as any }; + const bad = [{ ...entries[0], finalAssistantTimestamp: null }]; + const msgs = incident(); + const out = applyChainCompressions(msgs, bad as any, summaryFor, false, undefined, sink as any); + expect(out).toBe(msgs); + expect(reports.map((r) => r.kind)).toEqual(["unresolved-range"]); + }); + + test("re-applying the same entries is a no-op (synthetic preserved)", () => { + const first = applyChainCompressions(incident(), entries as any, summaryFor, false); + const second = applyChainCompressions(first, entries as any, summaryFor, false); + expect(second).toEqual(first); + }); + + test("a third-party custom message inside a range survives", () => { + const msgs = incident(); + msgs.splice(2, 0, { role: "custom", customType: "other-extension", content: "keepme", timestamp: 1120 } as any); + const out = applyChainCompressions(msgs, entries as any, summaryFor, false); + expect(out.some((m: any) => m.customType === "other-extension")).toBe(true); + }); + + test("a per-batch summary AFTER the range is still suppressed (agent-message mode)", () => { + const msgs = incident(); + msgs.splice(4, 0, { + role: "custom", + customType: CUSTOM_TYPE_SUMMARY, + content: "batch summary", + details: { toolCallRefs: [{ shortId: "t1", toolCallId: "bash_18", resultTimestamp: 1150 }] }, + timestamp: 1300, + } as any); + const out = applyChainCompressions(msgs, entries as any, summaryFor, false); + expect(out.some((m: any) => m.customType === CUSTOM_TYPE_SUMMARY)).toBe(false); + }); + + test("strips thinking at the resolved endIndex only", () => { + const msgs = incident(); + msgs[3] = { ...msgs[3], content: [{ type: "thinking", thinking: "hmm" }, { type: "text", text: "done 1" }] } as any; + const out = applyChainCompressions(msgs, entries as any, summaryFor, true); + const end = out.find((m: any) => m.timestamp === 1200); + expect(end.content.some((c: any) => c.type === "thinking")).toBe(false); + }); + + test("reports range-id-mismatch when the range drops a different id set", () => { + const reports: any[] = []; + const sink = { report: (kind: string, dedupKey: string, detail: string) => reports.push({ kind, dedupKey, detail }), counts: () => ({}) as any }; + const skewed = [{ ...entries[0], droppedToolCallIds: ["bash_18", "ghost_1"] }]; + applyChainCompressions(incident(), skewed as any, summaryFor, false, undefined, sink as any); + expect(reports.map((r) => r.kind)).toContain("range-id-mismatch"); + }); + + test("skips an entry whose start falls strictly inside another entry's range", () => { + // b9 opens at the user message at index 4, which sits inside a wide b8 + // range (index 0 -> 7). b8 wins; b9 contributes nothing and is reported. + const wide = { blockId: "b8", startUserTimestamp: 1000, droppedToolCallIds: [], finalAssistantTimestamp: 2200, toolRefs: [], compressedAt: 9002 }; + const nestedInner = { blockId: "b9", startUserTimestamp: 2000, droppedToolCallIds: [], finalAssistantTimestamp: 2200, toolRefs: [], compressedAt: 9003 }; + const reports: any[] = []; + const sink = { report: (kind: string, dedupKey: string, detail: string) => reports.push({ kind, dedupKey, detail }), counts: () => ({}) as any }; + const out = applyChainCompressions(incident(), [wide, nestedInner] as any, summaryFor, false, undefined, sink as any); + const synthetics = out.filter((m: any) => m.role === "user" && m.content?.[0]?.text?.startsWith(" /id="([^"]+)"/.exec(m.content[0].text)![1])).toEqual(["b8"]); + expect(reports.filter((r) => r.kind === "unresolved-range").map((r) => r.dedupKey)).toEqual(["overlap:b9"]); + expectNoOrphanToolResults(out); + }); + + test("a genuinely unresolvable boundary and a benign overlap skip both report unresolved-range but with distinct dedup keys", () => { + const wide = { blockId: "b8", startUserTimestamp: 1000, droppedToolCallIds: [], finalAssistantTimestamp: 2200, toolRefs: [], compressedAt: 9002 }; + const nestedInner = { blockId: "b9", startUserTimestamp: 2000, droppedToolCallIds: [], finalAssistantTimestamp: 2200, toolRefs: [], compressedAt: 9003 }; + const brokenBoundary = { ...entries[0], blockId: "b11", finalAssistantTimestamp: null }; + const reports: any[] = []; + const sink = { report: (kind: string, dedupKey: string, detail: string) => reports.push({ kind, dedupKey, detail }), counts: () => ({}) as any }; + applyChainCompressions(incident(), [wide, nestedInner, brokenBoundary] as any, summaryFor, false, undefined, sink as any); + const kinds = reports.filter((r) => r.kind === "unresolved-range"); + const dedupKeys = kinds.map((r) => r.dedupKey).sort(); + expect(dedupKeys).toEqual(["b11", "overlap:b9"]); + expect(new Set(dedupKeys).size).toBe(dedupKeys.length); + const overlapReport = kinds.find((r) => r.dedupKey === "overlap:b9")!; + const boundaryReport = kinds.find((r) => r.dedupKey === "b11")!; + expect(overlapReport.detail).toContain("b8"); + expect(boundaryReport.detail).toContain("start="); + expect(boundaryReport.detail).toContain("final="); + }); + + test("two entries resolving to the same startIndex insert one synthetic", () => { + const twin = { ...entries[0], blockId: "b10", compressedAt: 9004 }; + const out = applyChainCompressions(incident(), [entries[0], twin] as any, summaryFor, false); + const synthetics = out.filter((m: any) => m.role === "user" && m.content?.[0]?.text?.startsWith(" { + // Runs the real orphan sweep (src/orphan-sweep.ts, backing pruner.ts Phase 4) + // over the output of every clean applyChainCompressions fixture in this + // describe block. None of these are expected to leave an orphan behind - + // if one does, that is a real finding (chain-range-prune would be relying + // on the sweep as a crutch, not producing clean output on its own). + const fixtures: Array<[string, () => any[]]> = [ + ["the live turn reusing a dropped id survives with both of its results", () => applyChainCompressions(incident(), entries as any, summaryFor, false)], + ["drops exactly the two chain interiors and inserts both synthetics", () => applyChainCompressions(incident(), entries as any, summaryFor, false)], + ["re-applying the same entries is a no-op", () => applyChainCompressions(applyChainCompressions(incident(), entries as any, summaryFor, false), entries as any, summaryFor, false)], + ["a third-party custom message inside a range survives", () => { + const msgs = incident(); + msgs.splice(2, 0, { role: "custom", customType: "other-extension", content: "keepme", timestamp: 1120 } as any); + return applyChainCompressions(msgs, entries as any, summaryFor, false); + }], + ["strips thinking at the resolved endIndex only", () => { + const msgs = incident(); + msgs[3] = { ...msgs[3], content: [{ type: "thinking", thinking: "hmm" }, { type: "text", text: "done 1" }] } as any; + return applyChainCompressions(msgs, entries as any, summaryFor, true); + }], + ["a context-prune-summary after a completed cycle", () => { + const msgs = incident(); + msgs.splice(8, 0, { role: "custom", customType: CUSTOM_TYPE_SUMMARY, content: "batch summary", timestamp: 2250 } as any); + const out = applyChainCompressions(msgs, entries as any, summaryFor, false); + expect(out.some((m: any) => m.customType === CUSTOM_TYPE_SUMMARY)).toBe(true); + return out; + }], + ["skips an entry whose start falls strictly inside another entry's range", () => { + const wide = { blockId: "b8", startUserTimestamp: 1000, droppedToolCallIds: [], finalAssistantTimestamp: 2200, toolRefs: [], compressedAt: 9002 }; + const nestedInner = { blockId: "b9", startUserTimestamp: 2000, droppedToolCallIds: [], finalAssistantTimestamp: 2200, toolRefs: [], compressedAt: 9003 }; + return applyChainCompressions(incident(), [wide, nestedInner] as any, summaryFor, false); + }], + ["two entries resolving to the same startIndex insert one synthetic", () => { + const twin = { ...entries[0], blockId: "b10", compressedAt: 9004 }; + return applyChainCompressions(incident(), [entries[0], twin] as any, summaryFor, false); + }], + ["single dropped chain, ordering invariant fixture", () => { + const msgs = [ + userMsg(100), + assistantWithTools(200, ["tc1"]), + toolResult(300, "tc1"), + assistantText(400), + userMsg(500), + assistantText(600), + ]; + return applyChainCompressions(msgs, [entry("b1", 100, ["tc1"], 400)], noopSummary, false); + }], + ["multiple chains in one pass", () => { + const msgs = [ + userMsg(100), + assistantWithTools(200, ["tc1"]), + toolResult(300, "tc1"), + assistantText(400), + userMsg(500), + assistantWithTools(600, ["tc2"]), + toolResult(700, "tc2"), + assistantText(800), + userMsg(900), + assistantText(1000), + ]; + const e1 = entry("b1", 100, ["tc1"], 400, ["t1"]); + const e2 = entry("b2", 500, ["tc2"], 800, ["t2"]); + return applyChainCompressions(msgs, [e1, e2], noopSummary, false); + }], + ["protected output relocation", () => { + const e = { + blockId: "b1", + startUserTimestamp: 1, + droppedToolCallIds: ["tc-read", "tc-todo"], + protectedToolCallIds: ["tc-todo"], + finalAssistantTimestamp: 9, + toolRefs: ["t1", "t2"], + compressedAt: 100, + }; + const messages = [ + { role: "user", timestamp: 1, content: [{ type: "text", text: "go" }] }, + { role: "assistant", timestamp: 2, content: [ + { type: "toolCall", id: "tc-read", name: "read" }, + { type: "toolCall", id: "tc-todo", name: "todowrite" }, + ] }, + { role: "toolResult", toolCallId: "tc-read", toolName: "read", content: [{ type: "text", text: "FILE" }] }, + { role: "toolResult", toolCallId: "tc-todo", toolName: "todowrite", content: [{ type: "text", text: "PLAN-STATE" }] }, + { role: "assistant", timestamp: 9, content: [{ type: "text", text: "done" }] }, + ]; + return applyChainCompressions(messages, [e] as any, () => "SUMMARY", false); + }], + ]; + + for (const [name, run] of fixtures) { + test(`zero orphan sweeps: ${name}`, () => { + expectZeroOrphanSweep(run()); + }); + } + }); +}); diff --git a/pi-condense/src/chain-range-prune.ts b/pi-condense/src/chain-range-prune.ts new file mode 100644 index 0000000..8eb3501 --- /dev/null +++ b/pi-condense/src/chain-range-prune.ts @@ -0,0 +1,241 @@ +import type { AssistantMessage, UserMessage } from "@earendil-works/pi-ai"; +import { CUSTOM_TYPE_SUMMARY } from "./types.js"; +import type { ChainCompressionEntry } from "./types.js"; +import { substituteBlockRefs } from "./nested-placeholders.js"; +import { extractToolResultText } from "./batch-capture.js"; +import { bareToolCallId, occKey, resultTimestampOf } from "./occurrence-key.js"; +import type { DiagnosticSink } from "./diagnostics.js"; + +export function isPerBatchSummaryMessage(msg: any): boolean { + return msg.role === "custom" && msg.customType === CUSTOM_TYPE_SUMMARY; +} + +/** Refs a per-batch summary message carries, as (toolCallId, resultTimestamp) pairs. */ +function summaryRefs(msg: any): { toolCallId: string; resultTimestamp?: number }[] { + return msg.details?.toolCallRefs ?? []; +} + +/** + * A ref with a `resultTimestamp` is matched by exact occurrence key against + * `droppedOccKeys` - a live turn reusing a dropped chain's bare id must not + * suppress that live turn's summary. A ref with no `resultTimestamp` (legacy) + * falls back to bare-id membership in `droppedBareIds`, since no occurrence + * discriminant was ever recorded for it. + */ +export function perBatchSummaryOverlapsDropped( + msg: any, + droppedOccKeys: Set, + droppedBareIds: Set, +): boolean { + return summaryRefs(msg).some((r) => + r.resultTimestamp !== undefined + ? droppedOccKeys.has(occKey(r.toolCallId, r.resultTimestamp)) + : droppedBareIds.has(bareToolCallId(r.toolCallId)), + ); +} + +export function withoutThinkingBlocks(msg: AssistantMessage): AssistantMessage { + return { ...msg, content: msg.content.filter((c) => c.type !== "thinking") }; +} + +export function buildSyntheticChainMessage( + entry: ChainCompressionEntry, + summary: string, + blockSummaryLookup?: (blockId: string) => string | undefined, + protectedOutputs: { tool: string; text: string }[] = [], +): UserMessage { + const resolvedSummary = blockSummaryLookup + ? substituteBlockRefs(summary, blockSummaryLookup, { selfBlockId: entry.blockId }) + : summary; + const tools = entry.toolRefs.join(","); + const protectedBlocks = protectedOutputs + .map((p) => `\n\n\n${p.text}\n`) + .join(""); + return { + role: "user", + content: [ + { + type: "text", + text: `\n${resolvedSummary}${protectedBlocks}\n`, + }, + ], + // compressedAt is the deterministic timestamp — set at compression-decision time, + // never collides with real user messages whose timestamps come from the live session clock + timestamp: entry.compressedAt, + }; +} + +/** + * Resolves a persisted chain entry to a positional index range. + * + * Role-gated and unique-match-or-nothing: exactly one user message at + * startUserTimestamp, exactly one assistant at finalAssistantTimestamp, and + * start < end. Otherwise null - the entry drops nothing. Fail-closed is the + * whole point: an id-set or timestamp-window fallback is what deleted live + * turns (doc/specs/2026-08-12-toolcall-id-collisions.md). + */ +export function resolveRange( + entry: Pick, + messages: any[], +): { startIndex: number; endIndex: number } | null { + if (entry.finalAssistantTimestamp === null) return null; + let startIndex = -1; + let startMatches = 0; + let endIndex = -1; + let endMatches = 0; + for (let i = 0; i < messages.length; i++) { + const msg = messages[i]; + if (msg.role === "user" && msg.timestamp === entry.startUserTimestamp) { + startMatches++; + if (startIndex < 0) startIndex = i; + } else if (msg.role === "assistant" && msg.timestamp === entry.finalAssistantTimestamp) { + endMatches++; + if (endIndex < 0) endIndex = i; + } + } + if (startMatches !== 1 || endMatches !== 1) return null; + if (!(startIndex < endIndex)) return null; + return { startIndex, endIndex }; +} + +export function applyChainCompressions( + messages: any[], + chainEntries: ChainCompressionEntry[], + summaryTextForChain: (entry: ChainCompressionEntry) => string, + stripFinalThinking: boolean, + blockSummaryLookup?: (blockId: string) => string | undefined, + diagnostics?: DiagnosticSink, +): any[] { + if (chainEntries.length === 0) return messages; + + // Pre-scan: collect blockIds of synthetic chain messages already in the input. + // Skipping re-insertion for matching blockIds makes the transform idempotent — + // calling twice with the same chainEntries yields the same output. + const existingSyntheticBlockIds = new Set(); + for (const msg of messages) { + if (msg.role === "user") { + const text: string = msg.content?.[0]?.text ?? ""; + const m = /^(); + for (const candidate of resolved) { + const loser = resolved.find( + (other) => other !== candidate && candidate.startIndex > other.startIndex && candidate.startIndex < other.endIndex, + ); + if (loser || claimedStart.has(candidate.startIndex)) { + // Same DiagnosticKind as a genuinely unresolvable boundary, but a + // distinct dedup-key prefix: this is a benign, expected skip (nested or + // duplicate range), not a compression failure. Keeping the kind fixed + // to the spec's set while still making the two cases greppable. + diagnostics?.report( + "unresolved-range", + `overlap:${candidate.entry.blockId}`, + loser + ? `blockId=${candidate.entry.blockId} skipped: range nests inside blockId=${loser.entry.blockId}` + : `blockId=${candidate.entry.blockId} skipped: range duplicates startIndex=${candidate.startIndex} already claimed`, + ); + continue; + } + claimedStart.add(candidate.startIndex); + accepted.push(candidate); + } + + if (accepted.length === 0) return messages; + + // 3. index sets + per-entry facts + const dropIndices = new Set(); + const stripAtIndex = new Set(); + const insertAfterIndex = new Map(); + const droppedBareIds = new Set(); + const droppedOccKeys = new Set(); + const protectedByBlock = new Map(); + + for (const { entry, startIndex, endIndex } of accepted) { + const protectedIds = new Set(entry.protectedToolCallIds ?? []); + const inRangeBareIds: string[] = []; + for (let i = startIndex + 1; i < endIndex; i++) { + const msg = messages[i]; + dropIndices.add(i); + if (msg.role === "toolResult") { + inRangeBareIds.push(msg.toolCallId); + droppedBareIds.add(msg.toolCallId); + droppedOccKeys.add(occKey(msg.toolCallId, resultTimestampOf(msg.timestamp))); + if (protectedIds.has(msg.toolCallId)) { + const arr = protectedByBlock.get(entry.blockId) ?? []; + arr.push({ tool: msg.toolName, text: extractToolResultText(msg) }); + protectedByBlock.set(entry.blockId, arr); + } + } else if (msg.role === "assistant") { + for (const block of msg.content ?? []) { + if (block.type === "toolCall") { + inRangeBareIds.push(block.id); + droppedBareIds.add(block.id); + } + } + } + } + // Diagnostic-only cross-check: the range always wins. + const recorded = new Set((entry.droppedToolCallIds ?? []).map(bareToolCallId)); + const actual = new Set(inRangeBareIds); + if (recorded.size !== actual.size || [...recorded].some((id) => !actual.has(id))) { + diagnostics?.report( + "range-id-mismatch", + entry.blockId, + `blockId=${entry.blockId} recorded=${recorded.size} actual=${actual.size}`, + ); + } + if (stripFinalThinking) stripAtIndex.add(endIndex); + insertAfterIndex.set(startIndex, { + synthetic: buildSyntheticChainMessage( + entry, + summaryTextForChain(entry), + blockSummaryLookup, + protectedByBlock.get(entry.blockId) ?? [], + ), + blockId: entry.blockId, + }); + } + + // 4. emit. Drops are role-restricted: user-role messages (including the + // already-inserted synthetic, which is user-role) and third-party custom + // messages inside a range are preserved. Preserving the synthetic is what + // makes re-application a true no-op. + const out: any[] = []; + for (let i = 0; i < messages.length; i++) { + const msg = messages[i]; + const isSummary = isPerBatchSummaryMessage(msg); + const droppable = msg.role === "assistant" || msg.role === "toolResult" || isSummary; + if (dropIndices.has(i) && droppable) continue; + // Coverage, not index membership: in agent-message batching the per-batch + // summary is appended AFTER finalAssistantTimestamp, outside the range. + if (isSummary && perBatchSummaryOverlapsDropped(msg, droppedOccKeys, droppedBareIds)) continue; + + if (msg.role === "assistant" && stripAtIndex.has(i)) out.push(withoutThinkingBlocks(msg)); + else out.push(msg); + + const info = insertAfterIndex.get(i); + if (info && !existingSyntheticBlockIds.has(info.blockId)) out.push(info.synthetic); + } + return out; +} diff --git a/pi-condense/src/commands.test.ts b/pi-condense/src/commands.test.ts new file mode 100644 index 0000000..03bb915 --- /dev/null +++ b/pi-condense/src/commands.test.ts @@ -0,0 +1,240 @@ +import { describe, it, expect, mock } from "bun:test"; +import { pruneStatusText, setPruneStatusWidget, registerCommands } from "./commands.js"; +import type { ContextPruneConfig, ContextMetricsSnapshot, SummarizerStats } from "./types.js"; +import { DEFAULT_CONFIG } from "./types.js"; + +const cfg = (enabled: boolean): ContextPruneConfig => ({ enabled } as ContextPruneConfig); +const cfgVisible = (enabled: boolean): ContextPruneConfig => + ({ enabled, showPruneStatusLine: true } as ContextPruneConfig); + +function captureStatus( + config: ContextPruneConfig, + value?: Parameters[2], + diagnostics?: Parameters[3], + metrics?: Parameters[4], +): string | undefined { + let captured: string | undefined; + setPruneStatusWidget({ ui: { setStatus: (_id, text) => { captured = text; } } }, config, value, diagnostics, metrics); + return captured; +} + +// ── /pruner command handler harness (registerCommands) ────────────────────── +// Drives the real switch-statement handler registered by registerCommands, +// with all injected collaborators stubbed. This is the seam for exercising +// /pruner subcommands without booting the full index.ts extension. +function setupPrunerCommand(overrides: { + capturePendingBatches?: () => any[]; + flushPending?: (ctx: any, options?: any) => Promise; + getRearmed?: () => boolean; + getContextMetrics?: (ctx: any) => ContextMetricsSnapshot; +} = {}) { + let handler: (args: string, ctx: any) => Promise; + const notifications: { message: string; type?: string }[] = []; + const flushCalls: any[] = []; + + const flushPending = + overrides.flushPending ?? + (async (_ctx: any, options?: any) => { + flushCalls.push(options); + return { ok: false, reason: "empty" }; + }); + + const pi: any = { + registerCommand(_name: string, spec: { handler: (args: string, ctx: any) => Promise }) { + handler = spec.handler; + }, + registerMessageRenderer() {}, + }; + + const currentConfig = { value: { ...DEFAULT_CONFIG, enabled: true } }; + + registerCommands( + pi, + currentConfig, + flushPending, + overrides.capturePendingBatches ?? (() => []), + () => ({ callCount: 0, totalInputTokens: 0, totalOutputTokens: 0, totalCost: 0 } as SummarizerStats), + () => undefined, + {} as any, + async () => ({ compressedEntries: [], skipped: 0 }), + undefined, + overrides.getContextMetrics, + undefined, + overrides.getRearmed, + ); + + const ctx: any = { + ui: { + notify(message: string, type?: string) { + notifications.push({ message, type }); + }, + }, + }; + + return { + run: (args: string) => handler(args, ctx), + notifications, + flushCalls, + }; +} + +describe("/pruner now (empty capture)", () => { + it("invokes flushPending even when nothing is pending, so a flush-metrics entry is recorded", async () => { + const harness = setupPrunerCommand({ capturePendingBatches: () => [] }); + await harness.run("now"); + + expect(harness.flushCalls.length).toBe(1); + expect(harness.flushCalls[0]).toMatchObject({ previewedBatches: [], trigger: "manual" }); + expect(harness.notifications[0]?.message).toBe("pruner: nothing pending — no batches to summarize"); + }); +}); + +describe("/pruner status context block", () => { + const metrics: ContextMetricsSnapshot = { + openCycleThinkingTokens: 12000, + largestChainSharePct: 62, + frontierGapTokens: 195000, + }; + + it("renders the --- context --- block with all three metrics", async () => { + const harness = setupPrunerCommand({ getContextMetrics: () => metrics, getRearmed: () => false }); + await harness.run("status"); + + const text = harness.notifications[0]?.message ?? ""; + expect(text).toContain("--- context ---"); + expect(text).toContain("thinking: 12.0k tokens (open segment)"); + expect(text).toContain("chain share: 62%"); + expect(text).toContain("frontier gap: 195.0k tokens"); + }); + + it("appends the rearmed: line only when getRearmed() is true", async () => { + const armed = setupPrunerCommand({ getContextMetrics: () => metrics, getRearmed: () => true }); + await armed.run("status"); + expect(armed.notifications[0]?.message).toContain("rearmed: yes"); + + const notArmed = setupPrunerCommand({ getContextMetrics: () => metrics, getRearmed: () => false }); + await notArmed.run("status"); + expect(notArmed.notifications[0]?.message).not.toContain("rearmed:"); + }); + + it("omits the context block entirely when getContextMetrics is unwired", async () => { + const harness = setupPrunerCommand(); + await harness.run("status"); + expect(harness.notifications[0]?.message).not.toContain("--- context ---"); + }); +}); + +describe("pruneStatusText", () => { + it("disabled config -> 'prune: OFF'", () => { + expect(pruneStatusText(cfg(false))).toBe("prune: OFF"); + }); + + it("enabled, no reclaim -> 'prune: ON'", () => { + expect(pruneStatusText(cfg(true))).toBe("prune: ON"); + }); + + it("enabled, undefined reclaim -> 'prune: ON'", () => { + expect(pruneStatusText(cfg(true), undefined)).toBe("prune: ON"); + }); + + it("enabled, beforeChars=0, afterChars=0 -> 'prune: ON' (guard divide-by-zero)", () => { + expect(pruneStatusText(cfg(true), { beforeChars: 0, afterChars: 0 })).toBe("prune: ON"); + }); + + it("enabled, {beforeChars:368000, afterChars:56000} -> ratio line", () => { + // beforeTok=92000 -> "92.0k", afterTok=14000 -> "14.0k", reduction=85% + expect(pruneStatusText(cfg(true), { beforeChars: 368000, afterChars: 56000 })).toBe( + "prune: ON \u00b7 92.0k->14.0k (-85%)", + ); + }); + + it("enabled, no reduction (before==after) -> clamps to 0%", () => { + // beforeTok=25, afterTok=25 + expect(pruneStatusText(cfg(true), { beforeChars: 100, afterChars: 100 })).toBe( + "prune: ON \u00b7 25->25 (-0%)", + ); + }); + + it("enabled, expansion (afterChars > beforeChars) -> clamps to 0%", () => { + expect(pruneStatusText(cfg(true), { beforeChars: 100, afterChars: 150 })).toMatch(/\(-0%\)$/); + }); +}); + +describe("setPruneStatusWidget", () => { + it("prefixes every rendered state with a single leading '\u2502' for load-order-independent isolation", () => { + expect(captureStatus(cfgVisible(false))).toBe("\u2502 prune: OFF"); + expect(captureStatus(cfgVisible(true))).toBe("\u2502 prune: ON"); + expect(captureStatus(cfgVisible(true), { beforeChars: 368000, afterChars: 56000 })).toBe( + "\u2502 prune: ON \u00b7 92.0k->14.0k (-85%)", + ); + }); + + it("prefixes string progress values too", () => { + expect(captureStatus(cfgVisible(true), "prune: 3 pending")).toBe("\u2502 prune: 3 pending"); + }); + + it("clears (no wrap) when the status line is hidden", () => { + expect(captureStatus(cfg(true))).toBeUndefined(); + }); +}); + +describe("diagnostic counters on the status line", () => { + const zeroDiag = { "unresolved-range": 0, "range-id-mismatch": 0, "orphan-sweep": 0 } as const; + const mixedDiag = { "unresolved-range": 2, "range-id-mismatch": 0, "orphan-sweep": 1 } as const; + + it("omits the diagnostic segment when all counters are zero", () => { + expect(pruneStatusText(cfg(true), undefined, zeroDiag)).toBe("prune: ON"); + }); + + it("appends a compact segment when a counter fires, omitting zero kinds", () => { + const text = pruneStatusText(cfg(true), undefined, mixedDiag); + expect(text).toBe("prune: ON \u00b7 diag u2/o1"); + }); + + it("appends the diagnostic segment to the reclaim form too", () => { + const text = pruneStatusText(cfg(true), { beforeChars: 368000, afterChars: 56000 }, mixedDiag); + expect(text).toBe("prune: ON \u00b7 92.0k->14.0k (-85%) \u00b7 diag u2/o1"); + }); + + it("setPruneStatusWidget forwards the counters", () => { + expect(captureStatus(cfgVisible(true), undefined, { "unresolved-range": 1, "range-id-mismatch": 0, "orphan-sweep": 0 })).toBe( + "\u2502 prune: ON \u00b7 diag u1", + ); + }); + + it("appends b for backfill-empty alongside u/m/o", () => { + const text = pruneStatusText(cfg(true), undefined, { + "unresolved-range": 2, + "range-id-mismatch": 0, + "orphan-sweep": 1, + "backfill-empty": 2, + }); + expect(text).toBe("prune: ON \u00b7 diag u2/o1/b2"); + }); +}); + +describe("context metrics suffix on the status line", () => { + const metrics: ContextMetricsSnapshot = { + openCycleThinkingTokens: 12000, + largestChainSharePct: 62, + frontierGapTokens: 195000, + }; + + it("appends a compact think/gap/chain segment when frontierGapTokens > 0", () => { + const text = pruneStatusText(cfg(true), undefined, undefined, metrics); + expect(text).toContain("\u00b7 think 12.0k \u00b7 gap 195.0k \u00b7 chain 62%"); + }); + + it("omits the suffix when frontierGapTokens is 0", () => { + const withZeroGap = { ...metrics, frontierGapTokens: 0 }; + expect(pruneStatusText(cfg(true), undefined, undefined, withZeroGap)).toBe( + pruneStatusText(cfg(true)), + ); + }); + + it("composes after the diag suffix when both are present", () => { + const mixedDiag = { "unresolved-range": 2, "range-id-mismatch": 0, "orphan-sweep": 1 } as const; + const text = pruneStatusText(cfg(true), undefined, mixedDiag, metrics); + expect(text).toBe("prune: ON \u00b7 diag u2/o1 \u00b7 think 12.0k \u00b7 gap 195.0k \u00b7 chain 62%"); + }); +}); diff --git a/pi-condense/src/commands.ts b/pi-condense/src/commands.ts new file mode 100644 index 0000000..6e7794a --- /dev/null +++ b/pi-condense/src/commands.ts @@ -0,0 +1,1372 @@ +import { + type ContextPruneConfig, + type SummarizerStats, + type LiveReclaim, + type CapturedBatch, + type ChainCompressionEntry, + type FlushOptions, + type DiagnosticKind, + type ContextMetricsSnapshot, + PRUNE_ON_MODES, + BATCHING_MODES, + STATUS_WIDGET_ID, + PROGRESS_WIDGET_ID, + SUMMARIZER_THINKING_LEVELS, + MIN_BATCH_CHARS_PRESETS, + RECOVERY_GRACE_PRESETS, + SUMMARIZER_IDLE_TIMEOUT_PRESETS, + SUMMARIZER_MAX_TIMEOUT_PRESETS, + SUMMARIZER_MAX_RETRIES_PRESETS, + SUMMARIZER_RETRY_BASE_DELAY_PRESETS, + SUMMARIZER_RETRY_MAX_DELAY_PRESETS, + AUTO_BUDGET_PRESETS, + ROLLING_WINDOW_PRESETS, + PURGE_COOLDOWN_PRESETS, + PURGE_MIN_ARG_PRESETS, + DEFAULT_CONFIG, +} from "./types.js"; +import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent"; +import { saveConfig } from "./config.js"; +import { MAX_BUDGET_WINDOW } from "./budget.js"; +import { formatTokens, formatCost, formatCharProgress, formatCompactCount } from "./stats.js"; +import { Container, Text, SettingsList, type SettingItem } from "@earendil-works/pi-tui"; +import { DynamicBorder, getSettingsListTheme } from "@earendil-works/pi-coding-agent"; +import { buildPruneTree, TreeBrowser } from "./tree-browser.js"; +import { normalizeSummaryToolCallRefs } from "./summary-refs.js"; +import type { ToolCallIndexer } from "./indexer.js"; + +/** + * Wraps a SettingsList with a border + title, delegating all input handling + * to the inner list. Container alone doesn't handle input, so we must + * forward handleInput manually. + */ +class SettingsOverlay extends Container { + constructor( + title: string, + private readonly settingsList: SettingsList, + ) { + super(); + this.addChild(new DynamicBorder()); + this.addChild(new Text(title, 0, 0)); + this.addChild(settingsList); + this.addChild(new DynamicBorder()); + } + + handleInput(data: string) { + this.settingsList.handleInput(data); + } + + invalidate() { + this.settingsList.invalidate(); + } +} + +// ── Status widget text ────────────────────────────────────────────────────── + +export function pruneStatusText( + config: ContextPruneConfig, + reclaim?: LiveReclaim, + diagnostics?: Record, + metrics?: ContextMetricsSnapshot, +): string { + if (!config.enabled) return "prune: OFF"; + const diag = diagnostics + ? [ + diagnostics["unresolved-range"] ? `u${diagnostics["unresolved-range"]}` : "", + diagnostics["range-id-mismatch"] ? `m${diagnostics["range-id-mismatch"]}` : "", + diagnostics["orphan-sweep"] ? `o${diagnostics["orphan-sweep"]}` : "", + diagnostics["backfill-empty"] ? `b${diagnostics["backfill-empty"]}` : "", + ].filter(Boolean) + : []; + const suffix = diag.length > 0 ? ` \u00b7 diag ${diag.join("/")}` : ""; + const metricsSuffix = metrics && metrics.frontierGapTokens > 0 + ? ` \u00b7 think ${formatCompactCount(metrics.openCycleThinkingTokens)} \u00b7 gap ${formatCompactCount(metrics.frontierGapTokens)} \u00b7 chain ${metrics.largestChainSharePct}%` + : ""; + if (!reclaim || reclaim.beforeChars <= 0) return `prune: ON${suffix}${metricsSuffix}`; + const beforeTok = Math.round(reclaim.beforeChars / 4); + const afterTok = Math.round(reclaim.afterChars / 4); + const reduction = Math.max(0, Math.round((1 - afterTok / beforeTok) * 100)); + return `prune: ON \u00b7 ${formatCompactCount(beforeTok)}->${formatCompactCount(afterTok)} (-${reduction}%)${suffix}${metricsSuffix}`; +} + +export function setPruneStatusWidget( + ctx: { ui: { setStatus: (id: string, text?: string) => void } }, + config: ContextPruneConfig, + value?: LiveReclaim | string, + diagnostics?: Record, + metrics?: ContextMetricsSnapshot, +): void { + if (!config.showPruneStatusLine) { + ctx.ui.setStatus(STATUS_WIDGET_ID, undefined); + return; + } + const text = typeof value === "string" ? value : pruneStatusText(config, value, diagnostics, metrics); + // Leading-only separator: the footer joins extension status segments with a + // single space, so a trailing divider collides with the next segment's leading + // one and renders doubled. One leading bar yields single dividers between + // sections, load-order independent. + ctx.ui.setStatus(STATUS_WIDGET_ID, `\u2502 ${text}`); +} + +// ── Subcommand list (for completions & interactive picker) ────────────────── + +const SUBCOMMANDS = [ + { value: "settings", label: "settings — interactive settings overlay" }, + { value: "on", label: "on — enable context pruning" }, + { value: "off", label: "off — disable context pruning" }, + { value: "status", label: "status — show status, model, thinking, prune trigger, and status line" }, + { value: "model", label: "model — show or set the summarizer model" }, + { value: "thinking", label: "thinking — show or set the summarizer thinking level" }, + { value: "prune-on", label: "prune-on — show or set the trigger mode" }, + { value: "batching", label: "batching — show or set the batching mode (turn / agent-message)" }, + { value: "stats", label: "stats — show cumulative summarizer token/cost stats" }, + { value: "tree", label: "tree — browse pruned tool calls in a foldable tree" }, + { value: "now", label: "now — flush pending tool calls immediately (widget progress)" }, + { value: "compact", label: "compact — retroactively compress all eligible closed chains" }, + { value: "protected-tools", label: "protected-tools — show or edit the never-pruned tool allowlist" }, + { value: "protected-paths", label: "protected-paths — show or edit the never-pruned path globs" }, + { value: "min-batch-chars", label: "min-batch-chars — show or set the pre-flush trivial-batch threshold" }, + { value: "recovery-grace", label: "recovery-grace - show or set how long context_tree_query output stays verbatim (user-turn-groups)" }, + { value: "dedup", label: "dedup — toggle pre-flush content-hash dedup (on/off/status)" }, + { value: "help", label: "help — show this help" }, +] as const; + +// ── Help text ─────────────────────────────────────────────────────────────── + +const PRUNE_MODE_GUIDANCE: Record = { + "agent-message": "Recommended default. Batches tool work and prunes once after the final text reply, giving the best balance of automation, context savings, and cache stability.", + "on-demand": "Maximum manual control. Nothing is pruned until you run /pruner now, so cache invalidation happens only when you choose.", +}; + +function pruneModeGuidance(mode: ContextPruneConfig["pruneOn"]): string { + return PRUNE_MODE_GUIDANCE[mode] ?? "Controls when summarized tool outputs replace raw tool results in future context."; +} + +function pruneModeLabel(mode: ContextPruneConfig["pruneOn"]): string { + return PRUNE_ON_MODES.find((entry) => entry.value === mode)?.label ?? mode; +} + +function summarizerThinkingLabel(level: ContextPruneConfig["summarizerThinking"]): string { + return SUMMARIZER_THINKING_LEVELS.find((entry) => entry.value === level)?.label ?? level; +} + +function summarizerThinkingDescription(level: ContextPruneConfig["summarizerThinking"]): string { + if (level === "default") { + return "Preserve old behavior: send no explicit thinking option for summarizer calls."; + } + if (level === "off") { + return "Request no summarizer reasoning where the provider adapter supports it; some providers may fall back to their default."; + } + return `Request ${level} thinking/reasoning for summarizer calls where supported.`; +} + +function parseModelAndThinkingArg( + value: string, +): { model: string; thinking?: ContextPruneConfig["summarizerThinking"]; error?: string } { + const separatorIndex = value.lastIndexOf(":"); + if (separatorIndex === -1) { + return { model: value }; + } + + const model = value.slice(0, separatorIndex); + const suffix = value.slice(separatorIndex + 1); + const thinking = SUMMARIZER_THINKING_LEVELS.find((level) => level.value === suffix)?.value; + if (!model || !thinking) { + return { + model: value, + error: `Invalid model thinking suffix: ${suffix}. Use one of: ${SUMMARIZER_THINKING_LEVELS.map((level) => level.value).join(", ")}.`, + }; + } + return { model, thinking }; +} + +function pruneTriggerDescription(mode: ContextPruneConfig["pruneOn"]): string { + return `When to summarize tool outputs. Current mode: ${pruneModeLabel(mode)} (${mode}) — ${pruneModeGuidance(mode)} Press Enter/Space to cycle through modes.`; +} + +function batchingModeLabel(mode: ContextPruneConfig["batchingMode"]): string { + return BATCHING_MODES.find((m) => m.value === mode)?.label ?? mode; +} + +function batchingModeDescription(mode: ContextPruneConfig["batchingMode"]): string { + if (mode === "turn") { + return "Per turn (default): one summary per assistant turn. Keeps summaries small and granular."; + } + return "Per agent message: merges all assistant turns between two user messages into one summary. Fewer, larger summaries per conversation exchange."; +} + +function pruneStatusLineDescription(config: ContextPruneConfig): string { + const base = config.showPruneStatusLine ? "ON" : "OFF"; + if (config.showPruneStatusLine) { + return `Show the prune footer status line and queued turn notifications. Currently ${base}.`; + } + return `Hide the prune footer status line and queued turn notifications. Currently ${base}.`; +} + +function quietOversizedSkipsDescription(config: ContextPruneConfig): string { + const base = config.quietOversizedSkips ? "ON" : "OFF"; + if (config.quietOversizedSkips) { + return `Suppress all non-error 'skipped pruning' notifications — both 'oversized' (summary was larger than the raw output) and 'trivial' (batch was below minBatchChars, no LLM call made). The frontier still advances in both cases. Currently ${base}.`; + } + return `Show 'skipped pruning' info notifications when a batch is skipped — either because the summary would have been larger than the raw output (oversized) or because the batch was below minBatchChars (trivial, no LLM call). Currently ${base}.`; +} + +function minBatchCharsDescription(config: ContextPruneConfig): string { + if (config.minBatchChars === 0) { + return `Pre-flush guard: skip batches whose total raw resultText is below this many chars (no LLM call, frontier advances anyway). Currently 0 — disabled, every batch is sent to the summarizer.`; + } + return `Pre-flush guard: skip batches whose total raw resultText is below this many chars (no LLM call, frontier advances anyway). Currently ${config.minBatchChars}. Useful for sessions with many tiny tool calls. Set to 0 to disable.`; +} + +function recoveryGraceDescription(config: ContextPruneConfig): string { + if (config.recoveryGraceTurns === 0) { + return "context_tree_query output is stubbed immediately (grace disabled). Set to a positive integer to keep recovered output verbatim for that many user-turn-groups."; + } + return `context_tree_query (recovery) output stays verbatim for ${config.recoveryGraceTurns} user-turn-group(s) after recovery, then reverts to the stub. Bounds the recover->re-stub->re-query loop. Currently ${config.recoveryGraceTurns}. Set to 0 to disable.`; +} + +function idleTimeoutDescription(config: ContextPruneConfig): string { + if (config.summarizerIdleTimeoutMs === 0) { + return "Summarizer idle timeout DISABLED - a stalled stream is only bounded by the ceiling (or not at all if that is 0 too)."; + } + return `Abort a summarizer call after ${Math.round(config.summarizerIdleTimeoutMs / 1000)}s of silence (no stream event). Resets on every event, so it never aborts a flowing generation; a timeout feeds the same outage-fallback retry as a provider error. Set 0 to disable.`; +} +function maxTimeoutDescription(config: ContextPruneConfig): string { + if (config.summarizerMaxTimeoutMs === 0) { + return "Summarizer total-duration ceiling DISABLED - only the idle timeout bounds a call."; + } + return `Hard ceiling on total duration of a single summarizer call: ${Math.round(config.summarizerMaxTimeoutMs / 1000)}s. Backstop for a stream that dribbles forever without going idle. Set 0 to disable.`; +} + +function maxRetriesDescription(config: ContextPruneConfig): string { + return config.summarizerMaxRetries === 0 + ? "Transient summarizer retries are disabled. The first failure is reported immediately." + : `Retry transient summarizer failures up to ${config.summarizerMaxRetries} time(s) before reporting the final error (${config.summarizerMaxRetries + 1} total attempts). A distinct session-model fallback consumes the final retry slot.`; +} + +function retryBaseDelayDescription(config: ContextPruneConfig): string { + return `Initial transient-retry delay: ${config.summarizerRetryBaseDelayMs}ms. Later waits double up to the configured cap. Set 0 for immediate retries.`; +} + +function retryMaxDelayDescription(config: ContextPruneConfig): string { + return `Maximum delay for one transient retry wait: ${config.summarizerRetryMaxDelayMs}ms. Set 0 for immediate retries.`; +} + +function autoBudgetThresholdDescription(config: ContextPruneConfig): string { + const cap = `${MAX_BUDGET_WINDOW / 1000}k`; + if (config.autoBudgetThreshold == null) { + return `Token-budget auto-flush: force a prune when context usage reaches this share of the window (or ${cap} tokens, whichever comes first), regardless of prune-on mode. Currently off. Pick a percentage to enable.`; + } + const pct = Number((config.autoBudgetThreshold * 100).toFixed(1)); + return `Token-budget auto-flush: force a prune when context usage reaches ${pct}% of the window or ${cap} tokens, whichever comes first, regardless of prune-on mode. The ${cap} ceiling keeps this reachable on huge-window models. Set to Off to disable.`; +} + +function protectedToolsDisplay(list: string[]): string { + return list.length === 0 ? "(none)" : list.join(", "); +} + +function dedupByContentHashDescription(config: ContextPruneConfig): string { + const state = config.dedupByContentHash ? "ON" : "OFF"; + if (config.dedupByContentHash) { + return `Pre-flush content-hash dedup. When a captured tool call's (toolName, normalized resultText) matches a record already in the indexer, the duplicate is registered as an alias of the original — no summarizer LLM call. Currently ${state}.`; + } + return `Pre-flush content-hash dedup. Currently ${state}. Identical re-reads will be sent to the summarizer like any other tool call.`; +} + +function protectedToolsDescription(config: ContextPruneConfig): string { + return `Tool names whose outputs are NEVER pruned (kept verbatim in context). Currently: ${protectedToolsDisplay(config.protectedTools)}. Edit via \`/pruner protected-tools\` for an interactive prompt, or \`/pruner protected-tools \` to set directly. Common candidates: todowrite, todoread.`; +} + +function protectedPathsDescription(config: ContextPruneConfig): string { + return `Glob patterns matched against a tool call's \`args.path\`; matching outputs are NEVER pruned. Currently: ${protectedToolsDisplay(config.protectedPaths)}. Edit via \`/pruner protected-paths\` (interactive) or \`/pruner protected-paths \`. Set to 'none' to disable (kill switch). Default protects skill files: **/skills/**/*.md`; +} + +const HELP_TEXT = `pruner — automatically summarizes tool-call outputs to keep context lean. + +Usage: + /pruner settings Interactive settings overlay + /pruner on Enable context pruning + /pruner off Disable context pruning + /pruner status Show status, model, prune trigger, batching mode, and stats + /pruner model Show the current summarizer model + /pruner model Set summarizer model (e.g. anthropic/claude-haiku-3-5) + /pruner model : Set summarizer model and thinking together (e.g. openai/gpt-5-mini:low) + /pruner thinking Show the current summarizer thinking level + /pruner thinking Set summarizer thinking: default, off, minimal, low, medium, high, xhigh + /pruner prune-on Show or interactively pick the trigger + /pruner prune-on on-demand Only summarize when /pruner now runs + /pruner prune-on agent-message Summarize after the agent's final text reply (default; safest for cache stability) + /pruner batching Show or interactively pick the batching granularity + /pruner batching turn One summary per assistant turn (default) + /pruner batching agent-message One summary per user→final-agent-message span (merges all turns in a span) + /pruner stats Show cumulative summarizer token/cost stats + /pruner tree Browse pruned tool calls in a foldable tree (Ctrl-O opens selected summary) + /pruner now Flush pending tool calls immediately (shows live footer progress) + /pruner protected-tools Interactively edit the never-pruned tool allowlist + /pruner protected-tools Set the allowlist (comma- or space-separated; 'none' clears) + /pruner protected-paths Interactively edit the never-pruned path globs + /pruner protected-paths Set the globs (comma- or space-separated; 'none' clears) + /pruner min-batch-chars Show the current pre-flush trivial-batch threshold + /pruner min-batch-chars Set the threshold (non-negative integer; 0 disables) + /pruner recovery-grace Show the current recovery grace window (user-turn-groups) + /pruner recovery-grace Set the window (non-negative integer; 0 disables) + /pruner compact Retroactively compress all closed chains (ignores rollingWindow; force-compresses every eligible chain) + /pruner dedup Show the current pre-flush content-hash dedup state + /pruner dedup on|off Enable or disable content-hash dedup + /pruner help Show this help + +Trivial-batch skip (minBatchChars): + If the total raw resultText across a batch is below minBatchChars, the + batch is skipped: no summarizer LLM call is made, no summary message is + injected, and the prune frontier still advances so the same tool calls are + not reconsidered next flush. Default is 1000. Set to 0 to disable. + This runs BEFORE summarization, so it is cheaper than the post-LLM + skipped-oversized path that also rejects summaries larger than the raw + input. Both skip notifications are silenced by quietOversizedSkips. + +Protected tools: + Some tools' outputs must stay verbatim across turns — typically planning tools + like todowrite / todoread that carry state the agent re-reads later. List + those tool names in 'protectedTools' (settings) or via /pruner protected-tools. + Protected calls bypass the summarizer/index entirely: their raw + ToolResultMessage stays in context. Names that don't match any captured + tool call are silently ignored. + +Content-hash dedup (dedupByContentHash): + When ON (default), each captured tool call is hashed by + (toolName, normalize(resultText)) using SHA-1 and compared against records + already in the indexer. If an earlier prune already covered identical + content, the duplicate is registered as an alias of the original — no + summarizer LLM call is made, the duplicate's ToolResultMessage gets + stub-replaced via pruneMessages, and context_tree_query returns the + original record when asked with the duplicate's id. Normalization is + conservative: line endings, per-line trailing whitespace, and a final + trim() only. Internal whitespace and capitalization are preserved. + V1 dedupes only against records ALREADY in the indexer (from previous + flushes); intra-flush dedup is deferred to v2 to avoid dangling aliases + when a canonical batch is skipped as oversized / trivial. + +Batching mode: + - turn (default): each assistant turn that used tools gets its own summary block. Small, granular. + - agent-message: all assistant turns between two consecutive user messages are merged into one summary. + Use this when a single user request triggers many back-to-back tool rounds that belong together. + +Mode guidance: + - on-demand: maximum manual control. Best when you want to decide exactly when to trade cache stability for shorter context. + - agent-message: recommended default. Batches a whole tool-using run, then prunes once after the final text reply so future requests become cacheable again. + +Why this matters: + Frequent edits to earlier context can reduce prompt/prefix cache hits on providers that cache identical prefixes. Batched pruning is usually cheaper and faster than pruning every turn. + +Related: + - Anthropic prompt caching docs: https://docs.claude.com/en/docs/build-with-claude/prompt-caching + +Settings are saved under the "contextPrune" key in /settings.json (where is $PI_CODING_AGENT_DIR or ~/.pi/agent).`; + +// ── Pruner progress widget ──────────────────────────────────────────────────── + +const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] as const; +const SPINNER_INTERVAL_MS = 120; + +type RowStatus = "pending" | "running" | "done" | "skipped"; + +interface WidgetRow { + label: string; + toolCallCount: number; + rawChars: number; + status: RowStatus; + receivedChars: number; +} + +/** + * Registers a multi-row progress widget above the editor for /pruner now. + * Returns helpers to update row state and clear the widget when done. + * Each row shows a spinner, label, tool-call count, and live summary char count. + */ +function startPrunerWidget( + ctx: ExtensionCommandContext, + batches: CapturedBatch[], +): { + updateRow: (index: number, status: RowStatus, chars?: number) => void; + clearWidget: () => void; +} { + const total = batches.length; + const rows: WidgetRow[] = batches.map((b, i) => ({ + label: `Batch ${i + 1}/${total}`, + toolCallCount: b.toolCalls.length, + rawChars: b.toolCalls.reduce((sum, tc) => sum + tc.resultText.length, 0), + status: "pending", + receivedChars: 0, + })); + + // Capture tui reference from the factory so updateRow can call requestRender. + let requestRender: (() => void) | undefined; + let animationTimer: ReturnType | undefined; + + const hasRunningRows = () => rows.some((row) => row.status === "running"); + + const stopAnimationLoop = () => { + if (!animationTimer) return; + clearInterval(animationTimer); + animationTimer = undefined; + }; + + // The widget only re-renders when Pi is asked to draw again. Drive a tiny + // timer while any row is running so the spinner advances even before the + // summarizer streams its first text chunk. + const ensureAnimationLoop = () => { + if (animationTimer || !requestRender || !hasRunningRows()) return; + animationTimer = setInterval(() => { + if (!hasRunningRows()) { + stopAnimationLoop(); + return; + } + requestRender?.(); + }, SPINNER_INTERVAL_MS); + animationTimer.unref?.(); + }; + + const syncAnimationLoop = () => { + if (hasRunningRows()) { + ensureAnimationLoop(); + } else { + stopAnimationLoop(); + } + requestRender?.(); + }; + + ctx.ui.setWidget( + PROGRESS_WIDGET_ID, + (tui, _theme) => { + requestRender = () => tui.requestRender(); + syncAnimationLoop(); + return { + invalidate() {}, + render(_width: number): string[] { + return rows.map((row) => { + const count = `${row.toolCallCount} tool call${row.toolCallCount === 1 ? "" : "s"}`; + if (row.status === "running") { + const frame = SPINNER_FRAMES[Math.floor(Date.now() / SPINNER_INTERVAL_MS) % SPINNER_FRAMES.length]; + const chars = + row.receivedChars > 0 + ? ` · ${formatCharProgress(row.receivedChars, row.rawChars)}` + : ""; + return `${frame} ${row.label} · ${count}${chars}`; + } else if (row.status === "done") { + return `✓ ${row.label} · ${count} · ${formatCharProgress(row.receivedChars, row.rawChars)}`; + } else if (row.status === "skipped") { + return `⚠ ${row.label} · ${count} · skipped`; + } else { + return `○ ${row.label} · ${count} · pending`; + } + }); + }, + }; + }, + { placement: "aboveEditor" }, + ); + + return { + updateRow(index: number, status: RowStatus, chars?: number) { + if (index >= 0 && index < rows.length) { + rows[index].status = status; + if (chars !== undefined) rows[index].receivedChars = chars; + syncAnimationLoop(); + } + }, + clearWidget() { + stopAnimationLoop(); + requestRender = undefined; + ctx.ui.setWidget(PROGRESS_WIDGET_ID, undefined); + }, + }; +} + +// ── Command registration ──────────────────────────────────────────────────── + +export function registerCommands( + pi: ExtensionAPI, + currentConfig: { value: ContextPruneConfig }, + flushPending: (ctx: ExtensionCommandContext, options?: FlushOptions) => Promise< + | { ok: true; reason: "flushed" | "skipped-oversized" | "skipped-trivial" | "skipped-deduped"; batchCount: number; toolCallCount: number; rawCharCount: number; summaryCharCount: number; dedupedCount?: number } + | { ok: false; reason: string; error?: string } + >, + capturePendingBatches: (ctx: ExtensionCommandContext) => CapturedBatch[], + getStats: () => SummarizerStats, + getLiveReclaim: () => LiveReclaim | undefined, + indexer: ToolCallIndexer, + compactChains: (ctx: ExtensionCommandContext) => Promise<{ compressedEntries: ChainCompressionEntry[]; skipped: number }>, + getDiagnosticCounts?: () => Record, + getContextMetrics?: (ctx: ExtensionCommandContext) => ContextMetricsSnapshot, + getCachedMetrics?: () => ContextMetricsSnapshot | undefined, + getRearmed?: () => boolean, +): void { + // Register the /pruner command + pi.registerCommand("pruner", { + description: "Context-prune settings and commands", + getArgumentCompletions(prefix: string) { + return SUBCOMMANDS.filter((s) => s.value.startsWith(prefix)); + }, + async handler(args: string, ctx: ExtensionCommandContext) { + // Parse subcommand and remaining args from the raw argument string + const parts = args.trim().split(/\s+/); + let subcommand = parts[0] || undefined; + const subArgs = parts.slice(1); // e.g. ["model", "anthropic/claude-haiku-3-5"] or ["on"]) + + // ── Bare /pruner → interactive picker ── + if (!subcommand) { + const options = SUBCOMMANDS.map((s) => s.label); + const choice = await ctx.ui.select("pruner — choose a subcommand", options); + if (!choice) return; + // Extract the value (first word) from the label like "settings — interactive settings overlay" + subcommand = choice.split(/\s+/)[0]; + } + + switch (subcommand) { + // ── /pruner settings ── interactive overlay ── + case "settings": { + const config = currentConfig.value; + const availableModels = ctx.modelRegistry?.getAvailable() ?? []; + + const items: SettingItem[] = [ + { + id: "enabled", + label: "Enabled", + values: ["true", "false"], + currentValue: String(config.enabled), + description: "Enable or disable context pruning", + }, + { + id: "showPruneStatusLine", + label: "Prune status line", + values: ["true", "false"], + currentValue: String(config.showPruneStatusLine), + description: pruneStatusLineDescription(config), + }, + { + id: "pruneOn", + label: "Prune trigger", + values: PRUNE_ON_MODES.map((m) => m.value), + currentValue: config.pruneOn, + description: pruneTriggerDescription(config.pruneOn), + }, + { + id: "summarizerModel", + label: "Summarizer model", + values: [config.summarizerModel], // show current value as the cycling option + currentValue: config.summarizerModel, + description: "Model used for summarizing tool outputs — press Enter to browse models", + submenu: (currentValue: string, done: (newValue?: string) => void) => { + const modelItems: SettingItem[] = [ + { + id: "default", + label: "default (active model)", + values: ["default"], + currentValue: currentValue === "default" ? "default" : "", + description: "Use the currently active model for summarization", + }, + ...availableModels.map((m) => { + const displayId = `${m.provider}/${m.id}`; + return { + id: displayId, + label: displayId, + values: [displayId], + currentValue: currentValue === displayId ? displayId : "", + description: m.name || displayId, + }; + }), + ]; + return new SettingsList( + modelItems, + 15, + getSettingsListTheme(), + (_id: string, newValue: string) => done(newValue), + () => done(undefined), // onCancel — ESC closes submenu, returns to parent + { enableSearch: true }, + ); + }, + }, + { + id: "summarizerThinking", + label: "Summarizer thinking", + values: SUMMARIZER_THINKING_LEVELS.map((level) => level.value), + currentValue: config.summarizerThinking, + description: summarizerThinkingDescription(config.summarizerThinking), + }, + { + id: "batchingMode", + label: "Batching mode", + values: BATCHING_MODES.map((m) => m.value), + currentValue: config.batchingMode, + description: batchingModeDescription(config.batchingMode), + }, + { + id: "quietOversizedSkips", + label: "Quiet skip notifications", + values: ["true", "false"], + currentValue: String(config.quietOversizedSkips), + description: quietOversizedSkipsDescription(config), + }, + { + id: "minBatchChars", + label: "Min batch chars", + values: MIN_BATCH_CHARS_PRESETS.map((p) => p.value), + currentValue: MIN_BATCH_CHARS_PRESETS.some((p) => p.value === String(config.minBatchChars)) + ? String(config.minBatchChars) + : MIN_BATCH_CHARS_PRESETS[2].value, // fall back to "1000" if a custom value isn't in the preset cycle + description: minBatchCharsDescription(config), + }, + { + id: "recoveryGraceTurns", + label: "Recovery grace (user-turn-groups)", + values: RECOVERY_GRACE_PRESETS.map((p) => p.value), + currentValue: RECOVERY_GRACE_PRESETS.some((p) => p.value === String(config.recoveryGraceTurns)) + ? String(config.recoveryGraceTurns) + : RECOVERY_GRACE_PRESETS[2].value, + description: recoveryGraceDescription(config), + }, + { + id: "summarizerIdleTimeoutMs", + label: "Summarizer idle timeout", + values: SUMMARIZER_IDLE_TIMEOUT_PRESETS.map((p) => p.value), + currentValue: SUMMARIZER_IDLE_TIMEOUT_PRESETS.some((p) => p.value === String(config.summarizerIdleTimeoutMs)) + ? String(config.summarizerIdleTimeoutMs) + : (SUMMARIZER_IDLE_TIMEOUT_PRESETS.find((p) => p.value === String(DEFAULT_CONFIG.summarizerIdleTimeoutMs))?.value ?? SUMMARIZER_IDLE_TIMEOUT_PRESETS[0].value), // fall back to the default preset if a custom value isn't in the cycle + description: idleTimeoutDescription(config), + }, + { + id: "summarizerMaxTimeoutMs", + label: "Summarizer max timeout", + values: SUMMARIZER_MAX_TIMEOUT_PRESETS.map((p) => p.value), + currentValue: SUMMARIZER_MAX_TIMEOUT_PRESETS.some((p) => p.value === String(config.summarizerMaxTimeoutMs)) + ? String(config.summarizerMaxTimeoutMs) + : (SUMMARIZER_MAX_TIMEOUT_PRESETS.find((p) => p.value === String(DEFAULT_CONFIG.summarizerMaxTimeoutMs))?.value ?? SUMMARIZER_MAX_TIMEOUT_PRESETS[0].value), // fall back to the default preset if a custom value isn't in the cycle + description: maxTimeoutDescription(config), + }, + { + id: "summarizerMaxRetries", + label: "Summarizer max retries", + values: SUMMARIZER_MAX_RETRIES_PRESETS.map((p) => p.value), + currentValue: String(config.summarizerMaxRetries), + description: maxRetriesDescription(config), + }, + { + id: "summarizerRetryBaseDelayMs", + label: "Retry base delay", + values: SUMMARIZER_RETRY_BASE_DELAY_PRESETS.map((p) => p.value), + currentValue: String(config.summarizerRetryBaseDelayMs), + description: retryBaseDelayDescription(config), + }, + { + id: "summarizerRetryMaxDelayMs", + label: "Retry max delay", + values: SUMMARIZER_RETRY_MAX_DELAY_PRESETS.map((p) => p.value), + currentValue: String(config.summarizerRetryMaxDelayMs), + description: retryMaxDelayDescription(config), + }, + { + id: "autoBudgetThreshold", + label: "Auto-flush at context %", + values: AUTO_BUDGET_PRESETS.map((p) => p.value), + currentValue: (() => { + const v = config.autoBudgetThreshold == null ? "0" : String(config.autoBudgetThreshold); + return AUTO_BUDGET_PRESETS.some((p) => p.value === v) ? v : "0"; + })(), + description: autoBudgetThresholdDescription(config), + }, + { + id: "dedupByContentHash", + label: "Dedup by content hash", + values: ["true", "false"], + currentValue: String(config.dedupByContentHash), + description: dedupByContentHashDescription(config), + }, + { + id: "chainCompressionEnabled", + label: "Chain compression", + values: ["true", "false"], + currentValue: String(config.chainCompression.enabled), + description: `Range-compress closed chains beyond the rolling window (K=${config.chainCompression.rollingWindow}). Drops middle assistant turns + tool results, injects a synthetic summary. Currently ${config.chainCompression.enabled ? "ON" : "OFF"}.`, + }, + { + id: "chainCompressionRollingWindow", + label: "Chain window (K)", + values: ROLLING_WINDOW_PRESETS.map((p) => p.value), + // Fall back to the closest preset if the persisted value isn't in the cycle + // (e.g. user hand-edited settings.json with a non-preset integer). + currentValue: ROLLING_WINDOW_PRESETS.some((p) => p.value === String(config.chainCompression.rollingWindow)) + ? String(config.chainCompression.rollingWindow) + : ROLLING_WINDOW_PRESETS[2].value, + description: `Keep the K most-recently-closed chains raw; compress older ones. Currently ${config.chainCompression.rollingWindow}.`, + }, + { + id: "chainCompressionStripThinking", + label: "Strip final thinking", + values: ["true", "false"], + currentValue: String(config.chainCompression.stripFinalAssistantThinking), + description: `Strip thinking blocks from the kept final text-only assistant message when compressing a chain. Currently ${config.chainCompression.stripFinalAssistantThinking ? "ON" : "OFF"}.`, + }, + { + id: "chainCompressionFuseRange", + label: "Fuse range summary", + values: ["true", "false"], + currentValue: String(config.chainCompression.fuseRangeSummary), + description: `Fuse a compressed chain's per-batch summaries into one cohesive LLM summary (one extra summarizer call per multi-batch span). Off keeps the per-batch concatenation. Currently ${config.chainCompression.fuseRangeSummary ? "ON" : "OFF"}.`, + }, + { + id: "purgeErrorsEnabled", + label: "Error purge", + values: ["true", "false"], + currentValue: String(config.purgeErrors.enabled), + description: `Replace failed toolCall argument bodies with compact stubs after a cooldown. Reclaims context from large write/edit args that will never succeed. Currently ${config.purgeErrors.enabled ? "ON" : "OFF"}.`, + }, + { + id: "purgeErrorsCooldown", + label: "Error purge cooldown (turns)", + values: PURGE_COOLDOWN_PRESETS.map((p) => p.value), + currentValue: PURGE_COOLDOWN_PRESETS.some((p) => p.value === String(config.purgeErrors.cooldownTurns)) + ? String(config.purgeErrors.cooldownTurns) + : PURGE_COOLDOWN_PRESETS[1].value, + description: `Wait this many turns after a tool error before purging its argument body. Currently ${config.purgeErrors.cooldownTurns}.`, + }, + { + id: "purgeErrorsMinArgChars", + label: "Error purge min arg chars", + values: PURGE_MIN_ARG_PRESETS.map((p) => p.value), + currentValue: PURGE_MIN_ARG_PRESETS.some((p) => p.value === String(config.purgeErrors.minArgChars)) + ? String(config.purgeErrors.minArgChars) + : PURGE_MIN_ARG_PRESETS[1].value, + description: `Only purge arg bodies at least this many chars. Currently ${config.purgeErrors.minArgChars}.`, + }, + { + // Read-only display row. Editing goes through `/pruner protected-tools` + // because SettingsList.submenu requires a synchronous Component, + // while editing a free-form list needs `ctx.ui.input()` (async). + id: "protectedTools", + label: "Protected tools", + values: [protectedToolsDisplay(config.protectedTools)], + currentValue: protectedToolsDisplay(config.protectedTools), + description: protectedToolsDescription(config), + }, + { + id: "protectedPaths", + label: "Protected paths", + values: [protectedToolsDisplay(config.protectedPaths)], + currentValue: protectedToolsDisplay(config.protectedPaths), + description: protectedPathsDescription(config), + }, + ]; + + let settingsList: SettingsList; + let closeSettingsOverlay = () => {}; + + const onChange = (id: string, newValue: string) => { + // Read-only row — SettingsList still fires onChange when the user + // presses Enter on a single-value item. Short-circuit so we don't + // do a redundant saveConfig / status-widget refresh on no-op presses. + if (id === "protectedTools" || id === "protectedPaths") return; + const newConfig = { ...currentConfig.value }; + if (id === "enabled") { + newConfig.enabled = newValue === "true"; + } else if (id === "showPruneStatusLine") { + newConfig.showPruneStatusLine = newValue === "true"; + const statusLineItem = items.find((item) => item.id === "showPruneStatusLine"); + if (statusLineItem) { + statusLineItem.description = pruneStatusLineDescription(newConfig); + } + } else if (id === "pruneOn") { + newConfig.pruneOn = newValue as ContextPruneConfig["pruneOn"]; + const pruneTriggerItem = items.find((item) => item.id === "pruneOn"); + if (pruneTriggerItem) { + pruneTriggerItem.description = pruneTriggerDescription(newConfig.pruneOn); + } + } else if (id === "summarizerModel") { + newConfig.summarizerModel = newValue; + } else if (id === "summarizerThinking") { + newConfig.summarizerThinking = newValue as ContextPruneConfig["summarizerThinking"]; + const thinkingItem = items.find((item) => item.id === "summarizerThinking"); + if (thinkingItem) { + thinkingItem.description = summarizerThinkingDescription(newConfig.summarizerThinking); + } + } else if (id === "batchingMode") { + newConfig.batchingMode = newValue as ContextPruneConfig["batchingMode"]; + const batchingItem = items.find((item) => item.id === "batchingMode"); + if (batchingItem) { + batchingItem.description = batchingModeDescription(newConfig.batchingMode); + } + } else if (id === "quietOversizedSkips") { + newConfig.quietOversizedSkips = newValue === "true"; + const quietItem = items.find((item) => item.id === "quietOversizedSkips"); + if (quietItem) { + quietItem.description = quietOversizedSkipsDescription(newConfig); + } + } else if (id === "minBatchChars") { + const parsed = Number.parseInt(newValue, 10); + newConfig.minBatchChars = Number.isFinite(parsed) && parsed >= 0 ? parsed : DEFAULT_CONFIG.minBatchChars; + const mbItem = items.find((item) => item.id === "minBatchChars"); + if (mbItem) { + mbItem.description = minBatchCharsDescription(newConfig); + } + } else if (id === "recoveryGraceTurns") { + const parsed = Number.parseInt(newValue, 10); + newConfig.recoveryGraceTurns = Number.isFinite(parsed) && parsed >= 0 ? parsed : DEFAULT_CONFIG.recoveryGraceTurns; + const rgItem = items.find((item) => item.id === "recoveryGraceTurns"); + if (rgItem) { + rgItem.description = recoveryGraceDescription(newConfig); + } + } else if (id === "summarizerIdleTimeoutMs") { + const parsed = Number.parseInt(newValue, 10); + newConfig.summarizerIdleTimeoutMs = Number.isFinite(parsed) && parsed >= 0 ? parsed : DEFAULT_CONFIG.summarizerIdleTimeoutMs; + const it = items.find((item) => item.id === "summarizerIdleTimeoutMs"); + if (it) it.description = idleTimeoutDescription(newConfig); + } else if (id === "summarizerMaxTimeoutMs") { + const parsed = Number.parseInt(newValue, 10); + newConfig.summarizerMaxTimeoutMs = Number.isFinite(parsed) && parsed >= 0 ? parsed : DEFAULT_CONFIG.summarizerMaxTimeoutMs; + const it = items.find((item) => item.id === "summarizerMaxTimeoutMs"); + if (it) it.description = maxTimeoutDescription(newConfig); + } else if (id === "summarizerMaxRetries") { + const parsed = Number.parseInt(newValue, 10); + newConfig.summarizerMaxRetries = Number.isFinite(parsed) && parsed >= 0 && parsed <= 10 + ? parsed + : DEFAULT_CONFIG.summarizerMaxRetries; + const it = items.find((item) => item.id === "summarizerMaxRetries"); + if (it) it.description = maxRetriesDescription(newConfig); + } else if (id === "summarizerRetryBaseDelayMs") { + const parsed = Number.parseInt(newValue, 10); + newConfig.summarizerRetryBaseDelayMs = Number.isFinite(parsed) && parsed >= 0 + ? parsed + : DEFAULT_CONFIG.summarizerRetryBaseDelayMs; + const it = items.find((item) => item.id === "summarizerRetryBaseDelayMs"); + if (it) it.description = retryBaseDelayDescription(newConfig); + } else if (id === "summarizerRetryMaxDelayMs") { + const parsed = Number.parseInt(newValue, 10); + newConfig.summarizerRetryMaxDelayMs = Number.isFinite(parsed) && parsed >= 0 + ? parsed + : DEFAULT_CONFIG.summarizerRetryMaxDelayMs; + const it = items.find((item) => item.id === "summarizerRetryMaxDelayMs"); + if (it) it.description = retryMaxDelayDescription(newConfig); + } else if (id === "autoBudgetThreshold") { + const parsed = Number.parseFloat(newValue); + newConfig.autoBudgetThreshold = + Number.isFinite(parsed) && parsed > 0 && parsed <= 1 ? parsed : null; + const abItem = items.find((item) => item.id === "autoBudgetThreshold"); + if (abItem) { + abItem.description = autoBudgetThresholdDescription(newConfig); + } + } else if (id === "dedupByContentHash") { + newConfig.dedupByContentHash = newValue === "true"; + const dedupItem = items.find((item) => item.id === "dedupByContentHash"); + if (dedupItem) { + dedupItem.description = dedupByContentHashDescription(newConfig); + } + } else if (id === "chainCompressionEnabled") { + newConfig.chainCompression = { ...newConfig.chainCompression, enabled: newValue === "true" }; + } else if (id === "chainCompressionRollingWindow") { + const parsed = Number.parseInt(newValue, 10); + newConfig.chainCompression = { + ...newConfig.chainCompression, + rollingWindow: Number.isFinite(parsed) && parsed >= 1 ? parsed : DEFAULT_CONFIG.chainCompression.rollingWindow, + }; + } else if (id === "chainCompressionStripThinking") { + newConfig.chainCompression = { ...newConfig.chainCompression, stripFinalAssistantThinking: newValue === "true" }; + } else if (id === "chainCompressionFuseRange") { + newConfig.chainCompression = { ...newConfig.chainCompression, fuseRangeSummary: newValue === "true" }; + } else if (id === "purgeErrorsEnabled") { + newConfig.purgeErrors = { ...newConfig.purgeErrors, enabled: newValue === "true" }; + } else if (id === "purgeErrorsCooldown") { + const parsed = Number.parseInt(newValue, 10); + newConfig.purgeErrors = { + ...newConfig.purgeErrors, + cooldownTurns: Number.isFinite(parsed) && parsed >= 1 ? parsed : DEFAULT_CONFIG.purgeErrors.cooldownTurns, + }; + } else if (id === "purgeErrorsMinArgChars") { + const parsed = Number.parseInt(newValue, 10); + newConfig.purgeErrors = { + ...newConfig.purgeErrors, + minArgChars: Number.isFinite(parsed) && parsed >= 0 ? parsed : DEFAULT_CONFIG.purgeErrors.minArgChars, + }; + } + currentConfig.value = newConfig; + saveConfig(newConfig); + setPruneStatusWidget(ctx, newConfig, getLiveReclaim(), getDiagnosticCounts?.(), getCachedMetrics?.()); + settingsList?.invalidate(); + }; + + settingsList = new SettingsList( + items, + 10, + getSettingsListTheme(), + onChange, + () => closeSettingsOverlay(), // onCancel — close the custom overlay + { enableSearch: false }, + ); + + // Use ctx.ui.custom() to show the settings list as an overlay. + // The factory receives (tui, theme, keybindings, done) and returns a Component. + // Wire Escape through the SettingsList constructor's onCancel callback instead + // of mutating private SettingsList fields. + await ctx.ui.custom( + (_tui, _theme, _keybindings, done) => { + closeSettingsOverlay = () => done(undefined); + return new SettingsOverlay("pruner settings", settingsList); + }, + { + overlay: true, + overlayOptions: { width: 60 }, + }, + ); + break; + } + + // ── /pruner on ── + case "on": { + currentConfig.value = { ...currentConfig.value, enabled: true }; + saveConfig(currentConfig.value); + ctx.ui.notify("Context pruning enabled."); + setPruneStatusWidget(ctx, currentConfig.value, getLiveReclaim(), getDiagnosticCounts?.(), getCachedMetrics?.()); + break; + } + + // ── /pruner off ── + case "off": { + currentConfig.value = { ...currentConfig.value, enabled: false }; + saveConfig(currentConfig.value); + ctx.ui.notify("Context pruning disabled."); + setPruneStatusWidget(ctx, currentConfig.value, getLiveReclaim(), getDiagnosticCounts?.(), getCachedMetrics?.()); + break; + } + + // ── /pruner status ── + case "status": { + const cfg = currentConfig.value; + const mode = PRUNE_ON_MODES.find((m) => m.value === cfg.pruneOn)?.label ?? cfg.pruneOn; + const s = getStats(); + const statsLine = s.callCount > 0 + ? `\n --- summarizer ---\n calls: ${s.callCount}\n input: ${formatTokens(s.totalInputTokens)} tokens\n output: ${formatTokens(s.totalOutputTokens)} tokens\n cost: ${formatCost(s.totalCost)}` + : "\n (no summarizer calls yet)"; + const fmtTimeout = (ms: number) => (ms === 0 ? "disabled" : `${Math.round(ms / 1000)}s`); + const m = getContextMetrics?.(ctx); + const contextLine = m + ? `\n --- context ---\n thinking: ${formatTokens(m.openCycleThinkingTokens)} tokens (open segment)\n chain share: ${m.largestChainSharePct}%\n frontier gap: ${formatTokens(m.frontierGapTokens)} tokens${getRearmed?.() ? "\n rearmed: yes" : ""}` + : ""; + ctx.ui.notify( + `pruner status:\n enabled: ${cfg.enabled}\n model: ${cfg.summarizerModel}\n thinking: ${summarizerThinkingLabel(cfg.summarizerThinking)} (${cfg.summarizerThinking})\n idle to: ${fmtTimeout(cfg.summarizerIdleTimeoutMs)}\n max to: ${fmtTimeout(cfg.summarizerMaxTimeoutMs)}\n retries: ${cfg.summarizerMaxRetries} (${cfg.summarizerRetryBaseDelayMs}-${cfg.summarizerRetryMaxDelayMs}ms backoff)\n trigger: ${mode}\n batching: ${batchingModeLabel(cfg.batchingMode)} (${cfg.batchingMode})\n dedup: ${cfg.dedupByContentHash ? "on" : "off"}\n status: ${cfg.showPruneStatusLine ? "on" : "off"}${statsLine}${contextLine}`, + ); + break; + } + + // ── /pruner tree ── foldable tree browser ── + case "tree": { + const roots = buildPruneTree(ctx, indexer); + if (roots.length === 0) { + ctx.ui.notify("No pruned tool calls found in this session.", "info"); + break; + } + + await ctx.ui.custom( + (_tui, theme, _keybindings, done) => { + const browser = new TreeBrowser(roots, theme, () => done(undefined)); + return browser; + }, + { + overlay: true, + overlayOptions: { width: "80%", maxHeight: "70%", anchor: "center" }, + }, + ); + break; + } + + // ── /pruner stats ── + case "stats": { + const s = getStats(); + if (s.callCount === 0 && s.chainsCompressed === 0) { + ctx.ui.notify("pruner stats: no summarizer calls yet."); + } else { + const chainsLine = s.chainsCompressed > 0 ? `\n chains: ${s.chainsCompressed} compressed` : ""; + ctx.ui.notify( + `pruner stats:\n calls: ${s.callCount}\n input: ${formatTokens(s.totalInputTokens)} tokens\n output: ${formatTokens(s.totalOutputTokens)} tokens\n cost: ${formatCost(s.totalCost)}${chainsLine}`, + ); + } + break; + } + + // ── /pruner model [value] ── + case "model": { + const modelArg = subArgs[0]; + if (!modelArg) { + ctx.ui.notify( + `Current summarizer model: ${currentConfig.value.summarizerModel}\nCurrent summarizer thinking: ${summarizerThinkingLabel(currentConfig.value.summarizerThinking)} (${currentConfig.value.summarizerThinking})`, + ); + } else { + const parsed = parseModelAndThinkingArg(modelArg); + if (parsed.error) { + ctx.ui.notify(parsed.error, "warning"); + return; + } + currentConfig.value = { + ...currentConfig.value, + summarizerModel: parsed.model, + summarizerThinking: parsed.thinking ?? currentConfig.value.summarizerThinking, + }; + saveConfig(currentConfig.value); + const thinkingText = parsed.thinking ? ` with thinking ${parsed.thinking}` : ""; + ctx.ui.notify(`Summarizer model set to: ${parsed.model}${thinkingText}`); + } + break; + } + + // ── /pruner thinking [value] ── + case "thinking": { + const thinkingArg = subArgs[0]; + if (!thinkingArg) { + ctx.ui.notify( + `Current summarizer thinking: ${summarizerThinkingLabel(currentConfig.value.summarizerThinking)} (${currentConfig.value.summarizerThinking})`, + ); + return; + } + if (SUMMARIZER_THINKING_LEVELS.some((level) => level.value === thinkingArg)) { + currentConfig.value = { + ...currentConfig.value, + summarizerThinking: thinkingArg as ContextPruneConfig["summarizerThinking"], + }; + } else { + ctx.ui.notify( + `Invalid summarizer thinking level: ${thinkingArg}. Use one of: ${SUMMARIZER_THINKING_LEVELS.map((level) => level.value).join(", ")}.`, + "warning", + ); + return; + } + saveConfig(currentConfig.value); + ctx.ui.notify(`Summarizer thinking set to: ${currentConfig.value.summarizerThinking}`); + break; + } + + // ── /pruner prune-on [value] ── + case "prune-on": { + const modeArg = subArgs[0]; + if (!modeArg) { + const options = PRUNE_ON_MODES.map((m) => `${m.value} — ${m.label}`); + const choice = await ctx.ui.select("pruner — choose when to trigger summarization", options); + if (!choice) return; + // Extract the value (first word) from "agent-message — On agent message" + const chosenValue = choice.split(/\s+/)[0] as ContextPruneConfig["pruneOn"]; + currentConfig.value = { ...currentConfig.value, pruneOn: chosenValue }; + } else { + currentConfig.value = { ...currentConfig.value, pruneOn: modeArg as ContextPruneConfig["pruneOn"] }; + } + saveConfig(currentConfig.value); + setPruneStatusWidget(ctx, currentConfig.value, getLiveReclaim(), getDiagnosticCounts?.(), getCachedMetrics?.()); + break; + } + + // ── /pruner batching [value] ── + case "batching": { + const batchArg = subArgs[0]; + if (!batchArg) { + const options = BATCHING_MODES.map((m) => `${m.value} — ${m.label}`); + const choice = await ctx.ui.select("pruner — choose batching granularity", options); + if (!choice) return; + const chosenValue = choice.split(/\s+/)[0] as ContextPruneConfig["batchingMode"]; + currentConfig.value = { ...currentConfig.value, batchingMode: chosenValue }; + } else { + if (!BATCHING_MODES.some((m) => m.value === batchArg)) { + ctx.ui.notify( + `Invalid batching mode: ${batchArg}. Use one of: ${BATCHING_MODES.map((m) => m.value).join(", ")}.`, + "warning", + ); + return; + } + currentConfig.value = { ...currentConfig.value, batchingMode: batchArg as ContextPruneConfig["batchingMode"] }; + } + saveConfig(currentConfig.value); + ctx.ui.notify(`Batching mode set to: ${batchingModeLabel(currentConfig.value.batchingMode)}`); + break; + } + + // ── /pruner compact ── + // Runs regardless of chainCompression.enabled — that flag gates automatic compression; + // the user invoking /pruner compact is explicit intent. + case "compact": { + try { + const { compressedEntries, skipped } = await compactChains(ctx); + if (compressedEntries.length === 0) { + ctx.ui.notify( + skipped > 0 + ? `pruner: no chains eligible for compaction (${skipped} skipped — no per-batch summary available)` + : "pruner: no chains eligible for compaction", + "info", + ); + break; + } + // Coarse estimate: uses original (unstubbed) toolResult sizes which overstates + // tool-result savings; but assistant-message savings (thinking + toolCall args + text) + // are not counted at all, so the two errors partly cancel. Treat as a rough proxy. + const droppedChars = compressedEntries.reduce((total, entry) => { + const records = indexer.lookupToolCalls(entry.droppedOccurrenceKeys ?? entry.droppedToolCallIds); + return total + records.reduce((s, r) => s + r.resultText.length, 0); + }, 0); + const reclaimedTokens = Math.ceil(droppedChars / 4); + const ids = compressedEntries.map((e) => e.blockId).join(", "); + ctx.ui.notify( + `pruner: compacted ${compressedEntries.length} chain${compressedEntries.length === 1 ? "" : "s"} (${ids}), reclaimed ~${reclaimedTokens} tokens`, + "info", + ); + } catch (err) { + ctx.ui.notify(`pruner: compact failed: ${err instanceof Error ? err.message : String(err)}`, "warning"); + } + break; + } + + // ── /pruner now ── + case "now": { + if (!currentConfig.value.enabled) { + ctx.ui.notify("Context pruning is disabled. Run /pruner on first.", "warning"); + return; + } + + // Capture the pending queue first so we can pre-build the widget rows. + const batches = capturePendingBatches(ctx); + if (batches.length === 0) { + ctx.ui.notify("pruner: nothing pending — no batches to summarize", "info"); + // Still invoke flushPending so its finally-emitted flush-metrics entry + // records this attempt (outcome "empty") — the incident's exact + // undiagnosable "nothing pending" report is precisely what this log + // exists to make diagnosable on recurrence. + await flushPending(ctx, { previewedBatches: batches, trigger: "manual" }); + break; + } + + // Open the progress widget above the editor — one row per batch. + const { updateRow, clearWidget } = startPrunerWidget(ctx, batches); + + const result = await flushPending(ctx, { + previewedBatches: batches, + onProgress: (index, _total, _batch, stage) => { + if (stage === "start") { + updateRow(index, "running", 0); + } else if (stage === "done") { + updateRow(index, "done"); + } else { + updateRow(index, "skipped"); + } + }, + onBatchTextProgress: (index, _total, _batch, receivedChars) => { + updateRow(index, "running", receivedChars); + }, + }); + + // Remove the widget and restore the normal footer status. + clearWidget(); + setPruneStatusWidget(ctx, currentConfig.value, getLiveReclaim(), getDiagnosticCounts?.(), getCachedMetrics?.()); + + if (!result.ok) { + const suffix = "error" in result && result.error ? ` (${result.error})` : ""; + ctx.ui.notify(`pruner: nothing flushed — ${result.reason}${suffix}`, result.reason === "empty" ? "info" : "warning"); + break; + } + + if (result.reason === "skipped-oversized") { + ctx.ui.notify( + `pruner: skipped pruning ${result.toolCallCount} tool call${result.toolCallCount === 1 ? "" : "s"} — summary was ${result.summaryCharCount} chars vs ${result.rawCharCount} raw chars; frontier advanced past this range`, + "warning" + ); + break; + } + + if (result.reason === "skipped-trivial") { + ctx.ui.notify( + `pruner: skipped ${result.toolCallCount} trivial tool call${result.toolCallCount === 1 ? "" : "s"} — only ${result.rawCharCount} raw chars below minBatchChars=${currentConfig.value.minBatchChars}; no LLM call made; frontier advanced past this range`, + "info" + ); + break; + } + + if (result.reason === "skipped-deduped") { + const n = result.dedupedCount ?? result.toolCallCount; + ctx.ui.notify( + `pruner: deduplicated ${n} tool call${n === 1 ? "" : "s"} (${result.rawCharCount} raw chars) against earlier prunes; no LLM call made; frontier advanced past this range`, + "info" + ); + break; + } + + ctx.ui.notify( + `pruner: pruned ${result.toolCallCount} tool call${result.toolCallCount === 1 ? "" : "s"} from ${result.batchCount} batch${result.batchCount === 1 ? "" : "es"} — summary ${result.summaryCharCount} chars vs ${result.rawCharCount} raw chars`, + "info" + ); + break; + } + + // ── /pruner protected-tools [list] ── + // Bare form opens ctx.ui.input() so the user can edit the list + // interactively (pre-filled with the current value). Argument form + // accepts a comma- and/or whitespace-separated list, or the sentinels + // `none` / `clear` to empty the list. + case "protected-tools": { + const raw = subArgs.join(" ").trim(); + let nextList: string[] | undefined; + + if (!raw) { + const currentDisplay = + currentConfig.value.protectedTools.length === 0 + ? "" + : currentConfig.value.protectedTools.join(", "); + const entered = await ctx.ui.input( + "Protected tools (comma-separated tool names; empty or 'none' to clear)", + currentDisplay, + ); + if (entered === undefined) return; // user cancelled + const trimmed = entered.trim(); + if (trimmed === "" || trimmed.toLowerCase() === "none" || trimmed.toLowerCase() === "clear") { + nextList = []; + } else { + nextList = trimmed.split(/[\s,]+/).map((s) => s.trim()).filter(Boolean); + } + } else if (raw.toLowerCase() === "none" || raw.toLowerCase() === "clear") { + nextList = []; + } else { + nextList = raw.split(/[\s,]+/).map((s) => s.trim()).filter(Boolean); + } + + currentConfig.value = { ...currentConfig.value, protectedTools: nextList }; + saveConfig(currentConfig.value); + ctx.ui.notify(`Protected tools: ${protectedToolsDisplay(nextList)}`); + break; + } + + // ── /pruner protected-paths [list] ── + case "protected-paths": { + const raw = subArgs.join(" ").trim(); + let nextList: string[] | undefined; + + if (!raw) { + const currentDisplay = + currentConfig.value.protectedPaths.length === 0 + ? "" + : currentConfig.value.protectedPaths.join(", "); + const entered = await ctx.ui.input( + "Protected paths (comma-separated globs; empty or 'none' to clear)", + currentDisplay, + ); + if (entered === undefined) return; // user cancelled + const trimmed = entered.trim(); + if (trimmed === "" || trimmed.toLowerCase() === "none" || trimmed.toLowerCase() === "clear") { + nextList = []; + } else { + nextList = trimmed.split(/[\s,]+/).map((s) => s.trim()).filter(Boolean); + } + } else if (raw.toLowerCase() === "none" || raw.toLowerCase() === "clear") { + nextList = []; + } else { + nextList = raw.split(/[\s,]+/).map((s) => s.trim()).filter(Boolean); + } + + currentConfig.value = { ...currentConfig.value, protectedPaths: nextList }; + saveConfig(currentConfig.value); + ctx.ui.notify(`Protected paths: ${protectedToolsDisplay(nextList)}`); + break; + } + + // ── /pruner min-batch-chars [value] ── + // Bare form shows the current value. Numeric form sets it directly + // (any non-negative integer accepted; not restricted to the preset + // cycle exposed in the SettingsList). `0` disables the pre-flush + // guard. + case "min-batch-chars": { + const arg = subArgs[0]; + if (!arg) { + const cur = currentConfig.value.minBatchChars; + const state = cur === 0 ? "disabled" : `${cur} chars`; + ctx.ui.notify(`Current minBatchChars: ${state}.`); + break; + } + const parsed = Number.parseInt(arg, 10); + if (!Number.isFinite(parsed) || parsed < 0) { + ctx.ui.notify(`Invalid minBatchChars: "${arg}". Expected a non-negative integer (0 disables).`, "warning"); + break; + } + currentConfig.value = { ...currentConfig.value, minBatchChars: parsed }; + saveConfig(currentConfig.value); + ctx.ui.notify( + parsed === 0 + ? "minBatchChars set to 0 — pre-flush trivial-batch skipping disabled." + : `minBatchChars set to ${parsed}.`, + ); + break; + } + + case "recovery-grace": { + const arg = subArgs[0]; + if (!arg) { + const cur = currentConfig.value.recoveryGraceTurns; + const state = cur === 0 ? "disabled" : `${cur} user-turn-group(s)`; + ctx.ui.notify(`Current recovery grace: ${state}.`); + break; + } + const parsed = Number.parseInt(arg, 10); + if (!Number.isFinite(parsed) || parsed < 0) { + ctx.ui.notify(`Invalid recovery-grace: "${arg}". Expected a non-negative integer (0 disables).`, "warning"); + break; + } + currentConfig.value = { ...currentConfig.value, recoveryGraceTurns: parsed }; + saveConfig(currentConfig.value); + ctx.ui.notify( + parsed === 0 + ? "recovery-grace set to 0 - context_tree_query output stubs immediately." + : `recovery-grace set to ${parsed} user-turn-group(s).`, + ); + break; + } + + // ── /pruner dedup [on|off|status] ── + // Bare form shows current state; `on`/`off` flip and persist; + // `status` is an explicit synonym for bare. + case "dedup": { + const arg = (subArgs[0] ?? "").toLowerCase(); + if (!arg || arg === "status") { + const state = currentConfig.value.dedupByContentHash ? "ON" : "OFF"; + ctx.ui.notify(`Content-hash dedup is ${state}. ${dedupByContentHashDescription(currentConfig.value)}`); + break; + } + if (arg !== "on" && arg !== "off" && arg !== "true" && arg !== "false") { + ctx.ui.notify(`Invalid dedup value: "${arg}". Expected on, off, status, true, or false.`, "warning"); + break; + } + const next = arg === "on" || arg === "true"; + currentConfig.value = { ...currentConfig.value, dedupByContentHash: next }; + saveConfig(currentConfig.value); + ctx.ui.notify(`Content-hash dedup turned ${next ? "ON" : "OFF"}.`); + break; + } + + // ── /pruner help ── + case "help": + ctx.ui.notify(HELP_TEXT); + break; + + // ── Unknown subcommand ── + default: + ctx.ui.notify( + `Unknown subcommand: "${subcommand}". Run /pruner help for usage.`, + ); + } + }, + }); + + // Register custom renderer for context-prune-summary messages + pi.registerMessageRenderer("context-prune-summary", (message, { expanded }, theme) => { + const details = message.details as { + toolCallRefs?: { shortId: string; toolCallId: string }[]; + toolCallIds?: string[]; + turnIndex: number; + toolNames: string[]; + }; + const turnIndex = details?.turnIndex ?? "?"; + const toolCount = normalizeSummaryToolCallRefs(details).length; + const header = theme.fg("accent", `[pruner] Turn ${turnIndex} summary (${toolCount} tool${toolCount === 1 ? "" : "s"})`); + if (expanded) { + return new Text(header + "\n" + message.content, 0, 0); + } + return new Text(header, 0, 0); + }); +} \ No newline at end of file diff --git a/pi-condense/src/config.test.ts b/pi-condense/src/config.test.ts new file mode 100644 index 0000000..8c07889 --- /dev/null +++ b/pi-condense/src/config.test.ts @@ -0,0 +1,219 @@ +import { describe, expect, it, beforeAll, afterAll } from "bun:test"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { DEFAULT_CONFIG } from "./types.js"; + +/** + * config.ts resolves the settings path from getAgentDir() lazily on each + * read/write, so PI_CODING_AGENT_DIR set here is honored regardless of import + * order (bun shares the module registry across test files). normalize() itself + * isn't exported; loadConfig() is the only public entry point that exercises + * it, so these tests drive normalization indirectly by writing settings.json + * into an isolated agent dir and reading it back. + */ +let tmpDir: string; +let loadConfig: typeof import("./config.js").loadConfig; +let saveConfig: typeof import("./config.js").saveConfig; +let settingsPath: typeof import("./config.js").settingsPath; + +beforeAll(async () => { + tmpDir = await mkdtemp(join(tmpdir(), "pi-condense-config-test-")); + process.env.PI_CODING_AGENT_DIR = tmpDir; + const mod = await import("./config.js"); + loadConfig = mod.loadConfig; + saveConfig = mod.saveConfig; + settingsPath = mod.settingsPath; +}); + +afterAll(async () => { + delete process.env.PI_CODING_AGENT_DIR; + await rm(tmpDir, { recursive: true, force: true }); +}); + +async function writeContextPrune(overrides: Record): Promise { + await writeFile(settingsPath(), JSON.stringify({ contextPrune: overrides })); +} + +describe("my-pi maintained defaults", () => { + it("uses maintained defaults without creating or rewriting settings.json", async () => { + await rm(settingsPath(), { force: true }); + const config = await loadConfig(); + expect(config.enabled).toBe(true); + expect(config.summarizerMaxRetries).toBe(2); + await expect(readFile(settingsPath(), "utf-8")).rejects.toThrow(); + }); + + + it("integrates the former bundle wrapper defaults into pi-condense", async () => { + await writeContextPrune({}); + const config = await loadConfig(); + expect(config).toMatchObject({ + enabled: true, + pruneOn: "agent-message", + batchingMode: "turn", + autoBudgetThreshold: 0.735, + budgetTurnDelta: 0.04, + summarizerModel: "default", + summarizerThinking: "default", + summarizerIdleTimeoutMs: 90000, + summarizerMaxTimeoutMs: 300000, + summarizerMaxRetries: 2, + summarizerRetryBaseDelayMs: 1000, + summarizerRetryMaxDelayMs: 8000, + quietOversizedSkips: false, + }); + }); + + it("preserves explicit user values instead of replacing them with bundle defaults", async () => { + await writeContextPrune({ + enabled: false, + autoBudgetThreshold: 0.9, + summarizerMaxRetries: 1, + summarizerRetryBaseDelayMs: 250, + summarizerRetryMaxDelayMs: 500, + }); + const config = await loadConfig(); + expect(config.enabled).toBe(false); + expect(config.autoBudgetThreshold).toBe(0.9); + expect(config.summarizerMaxRetries).toBe(1); + expect(config.summarizerRetryBaseDelayMs).toBe(250); + expect(config.summarizerRetryMaxDelayMs).toBe(500); + }); + + it("preserves explicit null budget triggers as disabled", async () => { + await writeContextPrune({ autoBudgetThreshold: null, budgetTurnDelta: null }); + const config = await loadConfig(); + expect(config.autoBudgetThreshold).toBeNull(); + expect(config.budgetTurnDelta).toBeNull(); + }); +}); + +describe("loadConfig recoveryGraceTurns normalization", () => { + it("preserves an explicit 0", async () => { + await writeContextPrune({ recoveryGraceTurns: 0 }); + const config = await loadConfig(); + expect(config.recoveryGraceTurns).toBe(0); + }); + + it("falls back to the default for a negative value", async () => { + await writeContextPrune({ recoveryGraceTurns: -1 }); + const config = await loadConfig(); + expect(config.recoveryGraceTurns).toBe(DEFAULT_CONFIG.recoveryGraceTurns); + }); + + it("falls back to the default for NaN", async () => { + await writeContextPrune({ recoveryGraceTurns: Number.NaN }); + const config = await loadConfig(); + expect(config.recoveryGraceTurns).toBe(DEFAULT_CONFIG.recoveryGraceTurns); + }); + + it("floors a fractional value", async () => { + await writeContextPrune({ recoveryGraceTurns: 2.7 }); + const config = await loadConfig(); + expect(config.recoveryGraceTurns).toBe(2); + }); + + it("falls back to the default when unset", async () => { + await writeContextPrune({}); + const config = await loadConfig(); + expect(config.recoveryGraceTurns).toBe(DEFAULT_CONFIG.recoveryGraceTurns); + }); +}); + +describe("loadConfig summarizer timeout normalization", () => { + it("defaults both timeouts when absent", async () => { + await writeContextPrune({}); + const config = await loadConfig(); + expect(config.summarizerIdleTimeoutMs).toBe(DEFAULT_CONFIG.summarizerIdleTimeoutMs); + expect(config.summarizerMaxTimeoutMs).toBe(DEFAULT_CONFIG.summarizerMaxTimeoutMs); + }); + + it("preserves explicit 0 (disabled) for both", async () => { + await writeContextPrune({ summarizerIdleTimeoutMs: 0, summarizerMaxTimeoutMs: 0 }); + const config = await loadConfig(); + expect(config.summarizerIdleTimeoutMs).toBe(0); + expect(config.summarizerMaxTimeoutMs).toBe(0); + }); + + it("falls back to default for a negative idle timeout", async () => { + await writeContextPrune({ summarizerIdleTimeoutMs: -5 }); + const config = await loadConfig(); + expect(config.summarizerIdleTimeoutMs).toBe(DEFAULT_CONFIG.summarizerIdleTimeoutMs); + }); + + it("falls back to default for NaN max timeout", async () => { + // JSON.stringify serializes NaN to null; normalize's typeof-number guard rejects it. + await writeContextPrune({ summarizerMaxTimeoutMs: Number.NaN }); + const config = await loadConfig(); + expect(config.summarizerMaxTimeoutMs).toBe(DEFAULT_CONFIG.summarizerMaxTimeoutMs); + }); + + it("floors a fractional idle timeout", async () => { + await writeContextPrune({ summarizerIdleTimeoutMs: 1234.9 }); + const config = await loadConfig(); + expect(config.summarizerIdleTimeoutMs).toBe(1234); + }); +}); + +describe("loadConfig summarizer retry normalization", () => { + it("preserves zero to disable retries and waiting", async () => { + await writeContextPrune({ + summarizerMaxRetries: 0, + summarizerRetryBaseDelayMs: 0, + summarizerRetryMaxDelayMs: 0, + }); + const config = await loadConfig(); + expect(config.summarizerMaxRetries).toBe(0); + expect(config.summarizerRetryBaseDelayMs).toBe(0); + expect(config.summarizerRetryMaxDelayMs).toBe(0); + }); + + it("floors valid fractional retry values", async () => { + await writeContextPrune({ + summarizerMaxRetries: 2.9, + summarizerRetryBaseDelayMs: 125.9, + summarizerRetryMaxDelayMs: 1000.9, + }); + const config = await loadConfig(); + expect(config.summarizerMaxRetries).toBe(2); + expect(config.summarizerRetryBaseDelayMs).toBe(125); + expect(config.summarizerRetryMaxDelayMs).toBe(1000); + }); + + it("falls back for negative or excessive retry values", async () => { + await writeContextPrune({ + summarizerMaxRetries: 11, + summarizerRetryBaseDelayMs: -1, + summarizerRetryMaxDelayMs: -1, + }); + const config = await loadConfig(); + expect(config.summarizerMaxRetries).toBe(DEFAULT_CONFIG.summarizerMaxRetries); + expect(config.summarizerRetryBaseDelayMs).toBe(DEFAULT_CONFIG.summarizerRetryBaseDelayMs); + expect(config.summarizerRetryMaxDelayMs).toBe(DEFAULT_CONFIG.summarizerRetryMaxDelayMs); + }); +}); + +describe("loadConfig backward compatibility with removed thinkingStrip key", () => { + it("loads without error and round-trips a stale contextPrune.thinkingStrip block unchanged", async () => { + const stale = { enabled: true, keepLastTurns: 16 }; + await writeContextPrune({ thinkingStrip: stale }); + + const config = await loadConfig(); + + // thinkingStrip is no longer a recognized key: DEFAULT_CONFIG carries no + // such field, so nothing reads or acts on it. + expect((DEFAULT_CONFIG as unknown as Record).thinkingStrip).toBeUndefined(); + // normalize() spreads { ...DEFAULT_CONFIG, ...existing } and re-spreads + // the merge, so the unrecognized key survives verbatim on the loaded value. + expect((config as unknown as Record).thinkingStrip).toEqual(stale); + + // saveConfig() re-serializes the same config object it's given, so the + // stale block written above must still be present, byte-equivalent, after + // a full load -> save round trip through the real settingsPath() file. + await saveConfig(config); + const raw = await readFile(settingsPath(), "utf-8"); + const written = JSON.parse(raw); + expect(written.contextPrune.thinkingStrip).toEqual(stale); + }); +}); diff --git a/pi-condense/src/config.ts b/pi-condense/src/config.ts new file mode 100644 index 0000000..3bc1b8f --- /dev/null +++ b/pi-condense/src/config.ts @@ -0,0 +1,174 @@ +import { readFile, writeFile, mkdir, rename } from "node:fs/promises"; +import { randomBytes } from "node:crypto"; +import { join, dirname } from "node:path"; +import { getAgentDir } from "@earendil-works/pi-coding-agent"; +import type { ContextPruneConfig, PruneOn, SummarizerThinking } from "./types.js"; +import { DEFAULT_CONFIG, PRUNE_ON_MODES, SUMMARIZER_THINKING_LEVELS } from "./types.js"; + +/** + * Settings location: the active pi agent's main `settings.json` under the + * `contextPrune` namespace, mirroring pi's own conventions for `compaction`, + * `retry`, `branchSummary`, etc. Pi's SettingsManager preserves unknown + * top-level keys when it rewrites settings, so the namespace coexists safely + * with pi's own settings. + * + * Resolved against `getAgentDir()` so it honors `PI_CODING_AGENT_DIR` + * (defaults to `~/.pi/agent`). Each pi preset directory therefore gets its + * own context-prune config — including its own summarizer model. + * + * Computed lazily on each read/write rather than frozen at module load, so the + * resolved path always reflects the current `PI_CODING_AGENT_DIR` regardless of + * when the module was first imported. + */ +export function settingsPath(): string { + return join(getAgentDir(), "settings.json"); +} + +/** Top-level key under which context-prune state lives in `settings.json`. */ +export const SETTINGS_KEY = "contextPrune" as const; + +function isPruneOn(value: unknown): value is PruneOn { + return typeof value === "string" && PRUNE_ON_MODES.some((mode) => mode.value === value); +} + +function isSummarizerThinking(value: unknown): value is SummarizerThinking { + return typeof value === "string" && SUMMARIZER_THINKING_LEVELS.some((level) => level.value === value); +} + +function normalize(existing: Partial): ContextPruneConfig { + const merged = { ...DEFAULT_CONFIG, ...existing }; + return { + ...merged, + enabled: typeof merged.enabled === "boolean" ? merged.enabled : DEFAULT_CONFIG.enabled, + showPruneStatusLine: + typeof merged.showPruneStatusLine === "boolean" + ? merged.showPruneStatusLine + : DEFAULT_CONFIG.showPruneStatusLine, + pruneOn: isPruneOn(merged.pruneOn) ? merged.pruneOn : DEFAULT_CONFIG.pruneOn, + summarizerThinking: isSummarizerThinking(merged.summarizerThinking) + ? merged.summarizerThinking + : DEFAULT_CONFIG.summarizerThinking, + quietOversizedSkips: + typeof merged.quietOversizedSkips === "boolean" + ? merged.quietOversizedSkips + : DEFAULT_CONFIG.quietOversizedSkips, + minBatchChars: + typeof merged.minBatchChars === "number" && + Number.isFinite(merged.minBatchChars) && + merged.minBatchChars >= 0 + ? Math.floor(merged.minBatchChars) + : DEFAULT_CONFIG.minBatchChars, + summarizerIdleTimeoutMs: + typeof merged.summarizerIdleTimeoutMs === "number" && + Number.isFinite(merged.summarizerIdleTimeoutMs) && + merged.summarizerIdleTimeoutMs >= 0 + ? Math.floor(merged.summarizerIdleTimeoutMs) + : DEFAULT_CONFIG.summarizerIdleTimeoutMs, + summarizerMaxTimeoutMs: + typeof merged.summarizerMaxTimeoutMs === "number" && + Number.isFinite(merged.summarizerMaxTimeoutMs) && + merged.summarizerMaxTimeoutMs >= 0 + ? Math.floor(merged.summarizerMaxTimeoutMs) + : DEFAULT_CONFIG.summarizerMaxTimeoutMs, + summarizerMaxRetries: + typeof merged.summarizerMaxRetries === "number" && + Number.isFinite(merged.summarizerMaxRetries) && + merged.summarizerMaxRetries >= 0 && + merged.summarizerMaxRetries <= 10 + ? Math.floor(merged.summarizerMaxRetries) + : DEFAULT_CONFIG.summarizerMaxRetries, + summarizerRetryBaseDelayMs: + typeof merged.summarizerRetryBaseDelayMs === "number" && + Number.isFinite(merged.summarizerRetryBaseDelayMs) && + merged.summarizerRetryBaseDelayMs >= 0 + ? Math.floor(merged.summarizerRetryBaseDelayMs) + : DEFAULT_CONFIG.summarizerRetryBaseDelayMs, + summarizerRetryMaxDelayMs: + typeof merged.summarizerRetryMaxDelayMs === "number" && + Number.isFinite(merged.summarizerRetryMaxDelayMs) && + merged.summarizerRetryMaxDelayMs >= 0 + ? Math.floor(merged.summarizerRetryMaxDelayMs) + : DEFAULT_CONFIG.summarizerRetryMaxDelayMs, + recoveryGraceTurns: + typeof merged.recoveryGraceTurns === "number" && + Number.isFinite(merged.recoveryGraceTurns) && + merged.recoveryGraceTurns >= 0 + ? Math.floor(merged.recoveryGraceTurns) + : DEFAULT_CONFIG.recoveryGraceTurns, + dedupByContentHash: + typeof merged.dedupByContentHash === "boolean" + ? merged.dedupByContentHash + : DEFAULT_CONFIG.dedupByContentHash, + autoBudgetThreshold: + merged.autoBudgetThreshold === null + ? null + : typeof merged.autoBudgetThreshold === "number" && + Number.isFinite(merged.autoBudgetThreshold) && + merged.autoBudgetThreshold > 0 && + merged.autoBudgetThreshold <= 1 + ? merged.autoBudgetThreshold + : DEFAULT_CONFIG.autoBudgetThreshold, + spillThreshold: + typeof merged.spillThreshold === "number" && + Number.isFinite(merged.spillThreshold) && + merged.spillThreshold > 0 + ? Math.floor(merged.spillThreshold) + : DEFAULT_CONFIG.spillThreshold, + spillPreviewBytes: + typeof merged.spillPreviewBytes === "number" && + Number.isFinite(merged.spillPreviewBytes) && + merged.spillPreviewBytes >= 0 + ? Math.floor(merged.spillPreviewBytes) + : DEFAULT_CONFIG.spillPreviewBytes, + budgetTurnDelta: + merged.budgetTurnDelta === null + ? null + : typeof merged.budgetTurnDelta === "number" && + Number.isFinite(merged.budgetTurnDelta) && + merged.budgetTurnDelta > 0 && + merged.budgetTurnDelta <= 1 + ? merged.budgetTurnDelta + : DEFAULT_CONFIG.budgetTurnDelta, + }; +} + +async function readJsonObject(path: string): Promise | undefined> { + try { + const raw = await readFile(path, "utf-8"); + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + return parsed as Record; + } + return undefined; + } catch { + return undefined; + } +} + +/** Reads `/settings.json` and returns the `contextPrune` block, or defaults. */ +export async function loadConfig(): Promise { + const main = await readJsonObject(settingsPath()); + const namespaced = main?.[SETTINGS_KEY]; + if (namespaced && typeof namespaced === "object" && !Array.isArray(namespaced)) { + return normalize(namespaced as Partial); + } + return { ...DEFAULT_CONFIG }; +} + +/** + * Writes the full config back to `/settings.json` under + * {@link SETTINGS_KEY}, preserving every other top-level key in the file. Uses + * a tmp-file + atomic rename so concurrent pi writes (e.g. theme changes via + * `/settings`) cannot observe a partial file. We do not coordinate with pi's + * own internal lock since both writers do whole-file replacements and a + * last-write-wins race only loses a single change, never corrupts the file. + */ +export async function saveConfig(config: ContextPruneConfig): Promise { + const path = settingsPath(); + const current = (await readJsonObject(path)) ?? {}; + const next = { ...current, [SETTINGS_KEY]: config }; + await mkdir(dirname(path), { recursive: true }); + const tmpPath = `${path}.${randomBytes(8).toString("hex")}.tmp`; + await writeFile(tmpPath, `${JSON.stringify(next, null, 2)}\n`); + await rename(tmpPath, path); +} diff --git a/pi-condense/src/content-hash.ts b/pi-condense/src/content-hash.ts new file mode 100644 index 0000000..4035b40 --- /dev/null +++ b/pi-condense/src/content-hash.ts @@ -0,0 +1,35 @@ +/** + * Content-hash helper for the dedup pre-flush pass. + * + * Hashes (toolName, normalize(resultText)) via SHA-1 into a hex digest. + * Normalization is intentionally conservative: line-ending normalization + * (`\r\n` → `\n`), per-line trailing whitespace stripping, and a final + * whole-string trim. Internal whitespace, tabs, and capitalization are + * preserved so two genuinely different outputs do NOT collide. + * + * SHA-1 is overkill at this scale (~10⁵ records per long session at most) + * but is fast, built into Node's `crypto` module, and trivially readable. + * + * The `\0` separator between toolName and resultText prevents pathological + * collisions where two `(name, payload)` pairs concatenate to the same + * string (e.g. `("ab", "c")` vs `("a", "bc")`). + */ + +import { createHash } from "node:crypto"; + +function normalize(resultText: string): string { + const lf = resultText.replace(/\r\n/g, "\n"); + return lf + .split("\n") + .map((line) => line.replace(/[ \t]+$/g, "")) + .join("\n") + .trim(); +} + +export function hashToolResult(toolName: string, resultText: string): string { + return createHash("sha1") + .update(toolName) + .update("\0") + .update(normalize(resultText)) + .digest("hex"); +} diff --git a/pi-condense/src/context-metrics.test.ts b/pi-condense/src/context-metrics.test.ts new file mode 100644 index 0000000..9cb47dd --- /dev/null +++ b/pi-condense/src/context-metrics.test.ts @@ -0,0 +1,335 @@ +import { describe, expect, test } from "bun:test"; +import { computeContextMetrics } from "./context-metrics.js"; +import type { PruneFrontier } from "./types.js"; + +// ── Minimal message factories (mirrors src/chain-detector.test.ts style) ─── + +function userMsg(timestamp: number, text = "do the thing"): any { + return { role: "user", content: [{ type: "text", text }], timestamp }; +} + +function assistantWithTools(timestamp: number, toolCallIds: string[]): any { + return { + role: "assistant", + content: [ + { type: "text", text: "working..." }, + ...toolCallIds.map((id) => ({ type: "toolCall", id, name: "bash", arguments: {} })), + ], + timestamp, + usage: {}, + stopReason: "toolUse", + }; +} + +function assistantWithToolsAndThinking(timestamp: number, toolCallIds: string[], thinking = "hmm"): any { + return { + role: "assistant", + content: [ + { type: "thinking", thinking, thinkingSignature: "sig" }, + { type: "text", text: "working..." }, + ...toolCallIds.map((id) => ({ type: "toolCall", id, name: "bash", arguments: {} })), + ], + timestamp, + usage: {}, + stopReason: "toolUse", + }; +} + +function toolResult(timestamp: number, toolCallId: string, toolName = "bash", text = "output"): any { + return { + role: "toolResult", + toolCallId, + toolName, + content: [{ type: "text", text }], + isError: false, + timestamp, + }; +} + +function assistantText(timestamp: number, text = "done"): any { + return { role: "assistant", content: [{ type: "text", text }], timestamp, usage: {}, stopReason: "stop" }; +} + +function assistantTextWithThinking(timestamp: number, thinking = "closing thought", text = "done"): any { + return { + role: "assistant", + content: [ + { type: "thinking", thinking, thinkingSignature: "sig" }, + { type: "text", text }, + ], + timestamp, + usage: {}, + stopReason: "stop", + }; +} + +const noSummarized = () => false; +const noProtected = () => false; + +function fullFrontier(overrides: Partial): PruneFrontier { + return { + lastAttemptedToolCallId: "tc1", + lastAttemptedToolName: "bash", + lastAttemptedTurnIndex: 0, + lastAttemptedTimestamp: 0, + attemptedBatchCount: 1, + attemptedToolCallCount: 1, + rawCharCount: 0, + summaryCharCount: 0, + outcome: "summarized", + ...overrides, + }; +} + +describe("computeContextMetrics", () => { + test("empty branch -> all zeros", () => { + const result = computeContextMetrics([], null, noSummarized, noProtected); + expect(result).toEqual({ openCycleThinkingTokens: 0, largestChainSharePct: 0, frontierGapTokens: 0 }); + }); + + test("open segment thinking: only counts thinking blocks strictly after the last text-only assistant", () => { + const openThinkingBlock = { type: "thinking", thinking: "open thought that survives", thinkingSignature: "sig" }; + const msgs = [ + userMsg(100), + assistantTextWithThinking(200, "excluded thought A"), // text-only -> excluded (at/before boundary) + userMsg(300), + assistantWithTools(400, ["tc1"]), + toolResult(500, "tc1"), + assistantTextWithThinking(600, "excluded thought B"), // new last text-only assistant + userMsg(700), + { + role: "assistant", + content: [openThinkingBlock, { type: "text", text: "working" }, { type: "toolCall", id: "tc2", name: "bash", arguments: {} }], + timestamp: 800, + usage: {}, + stopReason: "toolUse", + }, + toolResult(900, "tc2"), + ]; + const result = computeContextMetrics(msgs, null, noSummarized, noProtected); + const expected = Math.round(JSON.stringify(openThinkingBlock).length / 4); + expect(result.openCycleThinkingTokens).toBe(expected); + }); + + test("zero text-only assistants -> whole branch is the open segment", () => { + const thinkingBlock = { type: "thinking", thinking: "the only thought", thinkingSignature: "sig" }; + const msgs = [ + userMsg(100), + { role: "assistant", content: [thinkingBlock, { type: "toolCall", id: "tc1", name: "bash", arguments: {} }], timestamp: 200, usage: {}, stopReason: "toolUse" }, + toolResult(300, "tc1"), + ]; + const result = computeContextMetrics(msgs, null, noSummarized, noProtected); + expect(result.openCycleThinkingTokens).toBe(Math.round(JSON.stringify(thinkingBlock).length / 4)); + }); + + test("no thinking blocks anywhere -> openCycleThinkingTokens is 0", () => { + const msgs = [userMsg(100), assistantWithTools(200, ["tc1"]), toolResult(300, "tc1")]; + const result = computeContextMetrics(msgs, null, noSummarized, noProtected); + expect(result.openCycleThinkingTokens).toBe(0); + }); + + test("largestChainSharePct: closed chain larger than open segment -> chain dominates", () => { + const msgs = [ + userMsg(100), + assistantWithTools(200, ["tc1"]), + toolResult(300, "tc1", "bash", "x".repeat(2000)), // big closed chain + assistantText(400), + userMsg(500), + assistantText(600), // tiny open segment (single text-only assistant, itself excluded from open... ) + ]; + // Recompute manually to avoid relying on the implementation under test. + const chars = msgs.map((m) => JSON.stringify(m).length); + const totalChars = chars.reduce((a, b) => a + b, 0); + const chainChars = chars[0] + chars[1] + chars[2] + chars[3]; // userMsg..assistantText(400) + const openSegmentChars = 0; // last text-only assistant is msgs[5] itself; open segment is empty (after index 5) + const expectedPct = Math.round((100 * Math.max(chainChars, openSegmentChars)) / totalChars); + + const result = computeContextMetrics(msgs, null, noSummarized, noProtected); + expect(result.largestChainSharePct).toBe(expectedPct); + expect(chainChars).toBeGreaterThan(openSegmentChars); + }); + + test("largestChainSharePct: open segment larger than any closed chain -> open segment dominates", () => { + const msgs = [ + userMsg(100), + assistantWithTools(200, ["tc1"]), + toolResult(300, "tc1"), // small closed chain + assistantText(400), // closes chain 1 + userMsg(500), + assistantWithTools(600, ["tc2"]), + toolResult(700, "tc2", "bash", "y".repeat(3000)), // large open segment (never closes) + ]; + const chars = msgs.map((m) => JSON.stringify(m).length); + const totalChars = chars.reduce((a, b) => a + b, 0); + const chainChars = chars[0] + chars[1] + chars[2] + chars[3]; + const openSegmentChars = chars[4] + chars[5] + chars[6]; + const expectedPct = Math.round((100 * Math.max(chainChars, openSegmentChars)) / totalChars); + + const result = computeContextMetrics(msgs, null, noSummarized, noProtected); + expect(result.largestChainSharePct).toBe(expectedPct); + expect(openSegmentChars).toBeGreaterThan(chainChars); + }); + + test("largestChainSharePct: a projected custom_message entry (role \"custom\") counts toward the denominator only, never the chain numerator", () => { + // Mirrors index.ts's branch projection for persisted summary custom_message + // entries: role "custom" never matches the user/assistant/toolResult roles + // detectChains and isTextOnlyAssistant key off, so it cannot join a chain + // or the open segment -- it only inflates totalChars (the denominator). + // The customEntry sits between two final text-only assistant messages, so + // it lands outside both the chain range and the open-cycle segment -- + // isolating the denominator effect from any open-segment interaction. + const chainMsgs = [ + userMsg(100), + assistantWithTools(200, ["tc1"]), + toolResult(300, "tc1", "bash", "x".repeat(2000)), + assistantText(400), + ]; + const customEntry = { role: "custom", customType: "pi-condense:summary", content: "s".repeat(3000), display: true, timestamp: 450 }; + const closer = assistantText(500, "ok"); + + const withoutCustom = computeContextMetrics(chainMsgs, null, noSummarized, noProtected); + const withCustom = computeContextMetrics([...chainMsgs, customEntry, closer], null, noSummarized, noProtected); + + const chainChars = chainMsgs.map((m) => JSON.stringify(m).length).reduce((a, b) => a + b, 0); + const totalWithCustom = [...chainMsgs, customEntry, closer].map((m) => JSON.stringify(m).length).reduce((a, b) => a + b, 0); + const expectedPctWithCustom = Math.round((100 * chainChars) / totalWithCustom); + + expect(withoutCustom.largestChainSharePct).toBe(100); + expect(withCustom.largestChainSharePct).toBe(expectedPctWithCustom); + expect(withCustom.largestChainSharePct).toBeLessThan(withoutCustom.largestChainSharePct); + }); + + test("largestChainSharePct: interrupted chain (null finalAssistantTimestamp) is counted", () => { + const msgs = [ + userMsg(100), + assistantWithTools(200, ["tc1"]), + toolResult(300, "tc1", "bash", "z".repeat(5000)), // huge interrupted chain + userMsg(400), // interrupts before text-only close + assistantText(500), // closes the second (tiny) chain + ]; + const chars = msgs.map((m) => JSON.stringify(m).length); + const totalChars = chars.reduce((a, b) => a + b, 0); + const interruptedChainChars = chars[0] + chars[1] + chars[2]; // startIdx..(nextUserIdx - 1) + const openSegmentChars = 0; // last text-only assistant is msgs[4] itself + const expectedPct = Math.round((100 * Math.max(interruptedChainChars, openSegmentChars)) / totalChars); + + const result = computeContextMetrics(msgs, null, noSummarized, noProtected); + expect(result.largestChainSharePct).toBe(expectedPct); + expect(expectedPct).toBeGreaterThan(0); + }); + + test("largestChainSharePct: empty branch denominator is 0 -> 0 (not NaN)", () => { + const result = computeContextMetrics([], null, noSummarized, noProtected); + expect(result.largestChainSharePct).toBe(0); + }); + + test("frontierGapTokens: null frontier -> whole branch counted", () => { + const msgs = [userMsg(100), assistantWithTools(200, ["tc1", "tc2"]), toolResult(300, "tc1"), toolResult(310, "tc2")]; + const result = computeContextMetrics(msgs, null, noSummarized, noProtected); + const expected = Math.round(JSON.stringify(msgs[2]).length / 4) + Math.round(JSON.stringify(msgs[3]).length / 4); + expect(result.frontierGapTokens).toBe(expected); + }); + + test("frontierGapTokens: boundary mid-turn split excludes at-or-before calls, includes later calls in same turn", () => { + const msgs = [userMsg(100), assistantWithTools(200, ["tc1", "tc2"]), toolResult(300, "tc1"), toolResult(310, "tc2")]; + const frontier = fullFrontier({ lastAttemptedToolCallId: "tc1", lastAttemptedTurnIndex: 0 }); + const result = computeContextMetrics(msgs, frontier, noSummarized, noProtected); + const expected = Math.round(JSON.stringify(msgs[3]).length / 4); // only tc2's result + expect(result.frontierGapTokens).toBe(expected); + }); + + test("frontierGapTokens: bare-id miss (id not present in the matched turn) -> whole branch counted", () => { + const msgs = [userMsg(100), assistantWithTools(200, ["tc1", "tc2"]), toolResult(300, "tc1"), toolResult(310, "tc2")]; + const frontier = fullFrontier({ lastAttemptedToolCallId: "tc-does-not-exist", lastAttemptedTurnIndex: 0 }); + const result = computeContextMetrics(msgs, frontier, noSummarized, noProtected); + const expected = Math.round(JSON.stringify(msgs[2]).length / 4) + Math.round(JSON.stringify(msgs[3]).length / 4); + expect(result.frontierGapTokens).toBe(expected); + }); + + test("frontierGapTokens: turn index not found in branch -> whole branch counted", () => { + const msgs = [userMsg(100), assistantWithTools(200, ["tc1"]), toolResult(300, "tc1")]; + const frontier = fullFrontier({ lastAttemptedToolCallId: "tc1", lastAttemptedTurnIndex: 99 }); + const result = computeContextMetrics(msgs, frontier, noSummarized, noProtected); + const expected = Math.round(JSON.stringify(msgs[2]).length / 4); + expect(result.frontierGapTokens).toBe(expected); + }); + + test("frontierGapTokens: excludes a toolResult whose occurrence key is already summarized", () => { + const msgs = [userMsg(100), assistantWithTools(200, ["tc1"]), toolResult(300, "tc1")]; + const isSummarized = (key: string) => key === "tc1@300"; + const result = computeContextMetrics(msgs, null, isSummarized, noProtected); + expect(result.frontierGapTokens).toBe(0); + }); + + test("frontierGapTokens: excludes a toolResult from a protected tool (args looked up from the pairing toolCall)", () => { + const msgs = [ + userMsg(100), + { + role: "assistant", + content: [{ type: "toolCall", id: "tc1", name: "read", arguments: { path: "/skills/secret.md" } }], + timestamp: 200, + usage: {}, + stopReason: "toolUse", + }, + toolResult(300, "tc1", "read"), + ]; + const isProtected = (toolName: string, args: unknown) => + toolName === "read" && typeof (args as any)?.path === "string" && (args as any).path.includes("/skills/"); + const result = computeContextMetrics(msgs, null, noSummarized, isProtected); + expect(result.frontierGapTokens).toBe(0); + }); + + test("exact-value pin: frontierGapTokens equals Math.round(JSON.stringify(msg).length / 4) for a single toolResult", () => { + const result_msg = toolResult(300, "tc1", "bash", "a fixed output payload"); + const msgs = [userMsg(100), assistantWithTools(200, ["tc1"]), result_msg]; + const result = computeContextMetrics(msgs, null, noSummarized, noProtected); + expect(result.frontierGapTokens).toBe(Math.round(JSON.stringify(result_msg).length / 4)); + }); + + test("frontierGapTokens: a toolCall id reused in a LATER turn (past the boundary) still counts its result — exclusion is positional, not id-global", () => { + const laterResult = toolResult(700, "tc1", "bash", "y".repeat(196)); // ~49 tokens + const msgs = [ + userMsg(100), + assistantWithTools(200, ["tc1"]), // turn 0 — this is the boundary turn + toolResult(300, "tc1"), // boundary result — correctly excluded + assistantText(400), // closes turn 0's chain + userMsg(500), + assistantWithTools(600, ["tc1"]), // turn 1 — reuses bare id "tc1" (legal: ids are only unique per turn) + laterResult, // must be counted: it is positionally after the boundary + ]; + const frontier = fullFrontier({ lastAttemptedToolCallId: "tc1", lastAttemptedTurnIndex: 0 }); + const result = computeContextMetrics(msgs, frontier, noSummarized, noProtected); + const expected = Math.round(JSON.stringify(laterResult).length / 4); + expect(result.frontierGapTokens).toBe(expected); + }); + + test("frontierGapTokens: args pairing for a reused id uses the nearest preceding assistant's toolCall, not a global id map", () => { + const msgs = [ + userMsg(100), + { + role: "assistant", + content: [{ type: "toolCall", id: "tc1", name: "read", arguments: { path: "/normal.md" } }], + timestamp: 200, + usage: {}, + stopReason: "toolUse", + }, // turn 0 — unprotected args + toolResult(300, "tc1", "read"), + assistantText(400), + userMsg(500), + { + role: "assistant", + content: [{ type: "toolCall", id: "tc1", name: "read", arguments: { path: "/skills/secret.md" } }], + timestamp: 600, + usage: {}, + stopReason: "toolUse", + }, // turn 1 — reuses bare id "tc1" with protected args + toolResult(700, "tc1", "read"), + ]; + const isProtected = (toolName: string, args: unknown) => + toolName === "read" && typeof (args as any)?.path === "string" && (args as any).path.includes("/skills/"); + const result = computeContextMetrics(msgs, null, noSummarized, isProtected); + // turn 0's result (unprotected args) counts; turn 1's result (protected args) is excluded. + const expected = Math.round(JSON.stringify(msgs[2]).length / 4); + expect(result.frontierGapTokens).toBe(expected); + }); +}); diff --git a/pi-condense/src/context-metrics.ts b/pi-condense/src/context-metrics.ts new file mode 100644 index 0000000..1532a36 --- /dev/null +++ b/pi-condense/src/context-metrics.ts @@ -0,0 +1,152 @@ +import { detectChains } from "./chain-detector.js"; +import { occKey, resultTimestampOf } from "./occurrence-key.js"; +import type { ContextMetricsSnapshot, PruneFrontier } from "./types.js"; + +function charsOf(msg: any): number { + return JSON.stringify(msg).length; +} + +function tokensOf(msg: any): number { + return Math.round(charsOf(msg) / 4); +} + +function isTextOnlyAssistant(msg: any): boolean { + if (msg.role !== "assistant") return false; + if (!Array.isArray(msg.content)) return true; + return !msg.content.some((b: any) => b.type === "toolCall"); +} + +function toolCallBlocksOf(msg: any): { id: string; input?: unknown; arguments?: unknown }[] { + if (!Array.isArray(msg.content)) return []; + return msg.content.filter((b: any) => b.type === "toolCall"); +} + +function findArgsForToolCallId(branch: any[], resultIdx: number, toolCallId: string): unknown { + for (let i = resultIdx - 1; i >= 0; i--) { + const m = branch[i]; + if (m.role !== "assistant") continue; + const call = toolCallBlocksOf(m).find((c) => c.id === toolCallId); + if (call) return (call as any).input ?? (call as any).arguments; + } + return undefined; +} + +/** + * Pure snapshot of what the pruner cannot (yet) reclaim: thinking tokens + * trapped in the trailing open cycle, the largest single chain's share of + * the branch, and unsummarized toolResult tokens past the prune frontier. + */ +export function computeContextMetrics( + branch: any[], + frontier: PruneFrontier | null, + isSummarized: (occurrenceKey: string) => boolean, + isProtected: (toolName: string, args: unknown) => boolean, +): ContextMetricsSnapshot { + if (branch.length === 0) { + return { openCycleThinkingTokens: 0, largestChainSharePct: 0, frontierGapTokens: 0 }; + } + + // ── Open segment: strictly after the last text-only assistant ────────── + let lastTextOnlyIdx = -1; + for (let i = 0; i < branch.length; i++) { + if (isTextOnlyAssistant(branch[i])) lastTextOnlyIdx = i; + } + const openStart = lastTextOnlyIdx + 1; + + let thinkingChars = 0; + for (let i = openStart; i < branch.length; i++) { + const m = branch[i]; + if (m.role !== "assistant" || !Array.isArray(m.content)) continue; + for (const block of m.content) { + if (block.type === "thinking") thinkingChars += JSON.stringify(block).length; + } + } + const openCycleThinkingTokens = Math.round(thinkingChars / 4); + + // ── Largest chain share ───────────────────────────────────────────────── + const branchChars = branch.map(charsOf); + const sumChars = (start: number, end: number): number => { + let sum = 0; + for (let i = start; i <= end; i++) sum += branchChars[i]; + return sum; + }; + const totalChars = branchChars.reduce((a, b) => a + b, 0); + + const chains = detectChains(branch, isProtected); + let largestClosedChainChars = 0; + for (const range of chains) { + const startIdx = branch.findIndex((m) => m.role === "user" && m.timestamp === range.startUserTimestamp); + if (startIdx === -1) continue; + let endIdx: number; + if (range.finalAssistantTimestamp !== null) { + endIdx = branch.findIndex( + (m, i) => i >= startIdx && m.role === "assistant" && m.timestamp === range.finalAssistantTimestamp, + ); + if (endIdx === -1) continue; + } else { + let nextUserIdx = -1; + for (let i = startIdx + 1; i < branch.length; i++) { + if (branch[i].role === "user") { + nextUserIdx = i; + break; + } + } + endIdx = nextUserIdx === -1 ? branch.length - 1 : nextUserIdx - 1; + } + if (endIdx < startIdx) continue; + const chainChars = sumChars(startIdx, endIdx); + if (chainChars > largestClosedChainChars) largestClosedChainChars = chainChars; + } + + const openSegmentChars = openStart < branch.length ? sumChars(openStart, branch.length - 1) : 0; + const numerator = Math.max(largestClosedChainChars, openSegmentChars); + const largestChainSharePct = totalChars === 0 ? 0 : Math.round((100 * numerator) / totalChars); + + // ── Frontier gap ───────────────────────────────────────────────────────── + // Exclusion is positional: only toolResults belonging to the boundary turn's + // own calls (up to and including the last-attempted call) are excluded. + // Ids are only unique per turn (see occurrence-key.ts), so a later turn may + // legally reuse a bare id — its result must still count toward the gap. + let boundaryIdx = -1; + let boundaryTurnEndIdx = branch.length; + const boundaryExcludedIds = new Set(); + if (frontier) { + let counter = 0; + for (let i = 0; i < branch.length; i++) { + const m = branch[i]; + if (m.role !== "assistant") continue; + const turnIdx = counter; + counter++; + if (turnIdx === frontier.lastAttemptedTurnIndex) { + const calls = toolCallBlocksOf(m); + const k = calls.findIndex((c) => c.id === frontier.lastAttemptedToolCallId); + if (k !== -1) { + boundaryIdx = i; + for (let j = 0; j <= k; j++) boundaryExcludedIds.add(calls[j].id); + for (let j = i + 1; j < branch.length; j++) { + if (branch[j].role === "assistant") { + boundaryTurnEndIdx = j; + break; + } + } + } + break; + } + } + } + + let frontierGapTokens = 0; + const scanStart = boundaryIdx === -1 ? 0 : boundaryIdx + 1; + for (let i = scanStart; i < branch.length; i++) { + const m = branch[i]; + if (m.role !== "toolResult") continue; + if (i < boundaryTurnEndIdx && boundaryExcludedIds.has(m.toolCallId)) continue; + const key = occKey(m.toolCallId, resultTimestampOf(m.timestamp)); + if (isSummarized(key)) continue; + const args = findArgsForToolCallId(branch, i, m.toolCallId); + if (isProtected(m.toolName, args)) continue; + frontierGapTokens += tokensOf(m); + } + + return { openCycleThinkingTokens, largestChainSharePct, frontierGapTokens }; +} diff --git a/pi-condense/src/diagnostics.test.ts b/pi-condense/src/diagnostics.test.ts new file mode 100644 index 0000000..ed3e3c8 --- /dev/null +++ b/pi-condense/src/diagnostics.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, spyOn, test } from "bun:test"; +import { DiagnosticSink } from "./diagnostics.js"; +import { CUSTOM_TYPE_DIAGNOSTIC } from "./types.js"; + +const sinkWithLog = () => { + const appended: Array<{ type: string; data: any }> = []; + const sink = new DiagnosticSink((type, data) => appended.push({ type, data })); + return { sink, appended }; +}; + +describe("DiagnosticSink", () => { + test("writes one session entry per distinct (kind, dedupKey)", () => { + const { sink, appended } = sinkWithLog(); + sink.report("unresolved-range", "b5", "blockId=b5 start=1000 final=null"); + expect(appended).toHaveLength(1); + expect(appended[0].type).toBe(CUSTOM_TYPE_DIAGNOSTIC); + expect(appended[0].data).toEqual({ kind: "unresolved-range", detail: "blockId=b5 start=1000 final=null" }); + }); + + test("dedupes a repeated (kind, dedupKey) within the session", () => { + const { sink, appended } = sinkWithLog(); + sink.report("unresolved-range", "b5", "first"); + sink.report("unresolved-range", "b5", "second"); + expect(appended).toHaveLength(1); + expect(sink.counts()["unresolved-range"]).toBe(1); + }); + + test("a different dedupKey of the same kind still reports", () => { + const { sink, appended } = sinkWithLog(); + sink.report("unresolved-range", "b5", "x"); + sink.report("unresolved-range", "b7", "y"); + expect(appended).toHaveLength(2); + expect(sink.counts()["unresolved-range"]).toBe(2); + }); + + test("the same dedupKey under two different kinds both report", () => { + const { sink, appended } = sinkWithLog(); + sink.report("unresolved-range", "b5", "x"); + sink.report("range-id-mismatch", "b5", "y"); + expect(appended).toHaveLength(2); + expect(appended[0].type).toBe(CUSTOM_TYPE_DIAGNOSTIC); + expect(appended[1].type).toBe(CUSTOM_TYPE_DIAGNOSTIC); + expect(sink.counts()["unresolved-range"]).toBe(1); + expect(sink.counts()["range-id-mismatch"]).toBe(1); + }); + + test("counts are per kind and start at zero", () => { + const { sink } = sinkWithLog(); + expect(sink.counts()).toEqual({ "unresolved-range": 0, "range-id-mismatch": 0, "orphan-sweep": 0, "backfill-empty": 0 }); + sink.report("orphan-sweep", "a,b", "swept 2"); + expect(sink.counts()["orphan-sweep"]).toBe(1); + }); + + test("an appendEntry failure never throws into the render path, and does not mark the key as seen", () => { + const spy = spyOn(console, "error").mockImplementation(() => {}); + const sink = new DiagnosticSink(() => { + throw new Error("session closed"); + }); + expect(() => sink.report("orphan-sweep", "a", "detail")).not.toThrow(); + expect(spy).toHaveBeenCalled(); + expect(sink.counts()["orphan-sweep"]).toBe(0); + spy.mockRestore(); + }); + + test("a retry of the same (kind, dedupKey) after appendEntry starts working persists and counts", () => { + const spy = spyOn(console, "error").mockImplementation(() => {}); + let working = false; + const appended: Array<{ type: string; data: any }> = []; + const sink = new DiagnosticSink((type, data) => { + if (!working) throw new Error("session closed"); + appended.push({ type, data }); + }); + + expect(() => sink.report("orphan-sweep", "a", "detail")).not.toThrow(); + expect(sink.counts()["orphan-sweep"]).toBe(0); + expect(appended).toHaveLength(0); + + working = true; + sink.report("orphan-sweep", "a", "detail"); + expect(sink.counts()["orphan-sweep"]).toBe(1); + expect(appended).toHaveLength(1); + + spy.mockRestore(); + }); + + test("counts() returns a snapshot; mutating it does not affect internal counters", () => { + const { sink } = sinkWithLog(); + sink.report("orphan-sweep", "a", "detail"); + const snapshot = sink.counts(); + snapshot["orphan-sweep"] = 999; + expect(sink.counts()["orphan-sweep"]).toBe(1); + }); + + test("reset() zeroes all counters", () => { + const { sink } = sinkWithLog(); + sink.report("unresolved-range", "b5", "x"); + sink.report("orphan-sweep", "a", "y"); + sink.reset(); + expect(sink.counts()).toEqual({ "unresolved-range": 0, "range-id-mismatch": 0, "orphan-sweep": 0, "backfill-empty": 0 }); + }); + + test("reset() allows a previously-seen (kind, dedupKey) to report again", () => { + const { sink, appended } = sinkWithLog(); + sink.report("unresolved-range", "b5", "first"); + expect(appended).toHaveLength(1); + + sink.reset(); + + sink.report("unresolved-range", "b5", "second"); + expect(appended).toHaveLength(2); + expect(appended[1].data).toEqual({ kind: "unresolved-range", detail: "second" }); + expect(sink.counts()["unresolved-range"]).toBe(1); + }); +}); + +test("counts backfill-empty like any other kind", () => { + const appended: Array<{ type: string; data: any }> = []; + const sink = new DiagnosticSink((type, data) => appended.push({ type, data })); + sink.report("backfill-empty", "b5", "middleCount=0"); + sink.report("backfill-empty", "b5", "middleCount=0"); // deduped + expect(sink.counts()["backfill-empty"]).toBe(1); + expect(appended.length).toBe(1); +}); diff --git a/pi-condense/src/diagnostics.ts b/pi-condense/src/diagnostics.ts new file mode 100644 index 0000000..7085f6b --- /dev/null +++ b/pi-condense/src/diagnostics.ts @@ -0,0 +1,47 @@ +import { CUSTOM_TYPE_DIAGNOSTIC } from "./types.js"; +import type { DiagnosticEntryData, DiagnosticKind } from "./types.js"; + +/** + * Out-of-band diagnostic channel for prune-time degradations. Session entries + * only - never LLM context, so zero tokens and zero cache-prefix change. + * Deduped per (kind, dedupKey) so a permanently degraded condition writes one + * entry, not one per render. + */ +export class DiagnosticSink { + private readonly seen = new Set(); + private readonly counters: Record = { + "unresolved-range": 0, + "range-id-mismatch": 0, + "orphan-sweep": 0, + "backfill-empty": 0, + }; + + constructor(private readonly appendEntry: (customType: string, data?: unknown) => void) {} + + report(kind: DiagnosticKind, dedupKey: string, detail: string): void { + const key = `${kind}:${dedupKey}`; + if (this.seen.has(key)) return; + const payload: DiagnosticEntryData = { kind, detail }; + try { + this.appendEntry(CUSTOM_TYPE_DIAGNOSTIC, payload); + } catch (err) { + // The render path must never fail because bookkeeping failed. + console.error(`pruner: failed to persist ${kind} diagnostic:`, err); + return; + } + this.seen.add(key); + this.counters[kind]++; + } + + counts(): Record { + return { ...this.counters }; + } + + /** Clears session-scoped state; call on session_start/session_tree since this sink is process-scoped, not session-scoped. */ + reset(): void { + this.seen.clear(); + for (const kind of Object.keys(this.counters) as DiagnosticKind[]) { + this.counters[kind] = 0; + } + } +} diff --git a/pi-condense/src/error-purge.test.ts b/pi-condense/src/error-purge.test.ts new file mode 100644 index 0000000..76e6bf9 --- /dev/null +++ b/pi-condense/src/error-purge.test.ts @@ -0,0 +1,186 @@ +import { describe, expect, it } from "bun:test"; +import { purgeErroredArgs } from "./error-purge.js"; +import type { ErrorPurgeConfig } from "./types.js"; + +const defaultConfig: ErrorPurgeConfig = { + enabled: true, + cooldownTurns: 2, + minArgChars: 10, +}; + +function makeAssistant(toolCallId: string, argsObj: Record, turnN?: number) { + return { + role: "assistant", + content: [ + { + type: "toolCall", + id: toolCallId, + name: "bash", + arguments: argsObj, + }, + ], + timestamp: turnN ?? 1, + }; +} + +function makeToolResult(toolCallId: string, isError: boolean) { + return { + role: "toolResult", + toolCallId, + toolName: "bash", + content: [{ type: "text", text: isError ? "Error: file not found" : "ok" }], + isError, + timestamp: 2, + }; +} + +describe("purgeErroredArgs", () => { + it("returns input array reference unchanged when no errored tool results", () => { + const messages = [ + makeAssistant("tc1", { cmd: "ls" }), + makeToolResult("tc1", false), + ]; + const result = purgeErroredArgs(messages, defaultConfig); + expect(result).toBe(messages); + }); + + it("does not purge while still within cooldown", () => { + // Error at turn 1, current = turn 2, age = 1 < cooldownTurns 2 + const messages = [ + makeAssistant("tc1", { content: "a very long argument body here" }), + makeToolResult("tc1", true), + makeAssistant("tc2", { cmd: "ls" }), + makeToolResult("tc2", false), + ]; + const result = purgeErroredArgs(messages, { ...defaultConfig, cooldownTurns: 2 }); + expect(result).toBe(messages); + const asstMsg = result[0] as any; + expect(asstMsg.content[0].arguments).toEqual({ content: "a very long argument body here" }); + }); + + it("purges args after cooldown when args meet minArgChars", () => { + const largeArgs = { content: "a very long argument body here" }; + const messages = [ + makeAssistant("tc1", largeArgs), + makeToolResult("tc1", true), + makeAssistant("tc2", { cmd: "ls" }), + makeToolResult("tc2", false), + makeAssistant("tc3", { cmd: "pwd" }), + makeToolResult("tc3", false), + ]; + const result = purgeErroredArgs(messages, { ...defaultConfig, cooldownTurns: 2, minArgChars: 10 }); + expect(result).not.toBe(messages); + const purgedAsst = result[0] as any; + const originalArgLen = JSON.stringify(largeArgs).length; + expect(purgedAsst.content[0].arguments).toEqual({ + _purged: ``, + }); + // toolResult stays unchanged + expect((result[1] as any).content[0].text).toBe("Error: file not found"); + }); + + it("does not purge when args are below minArgChars", () => { + const messages = [ + makeAssistant("tc1", { x: "hi" }), // JSON is only ~10 chars + makeToolResult("tc1", true), + makeAssistant("tc2", { cmd: "ls" }), + makeToolResult("tc2", false), + makeAssistant("tc3", { cmd: "pwd" }), + makeToolResult("tc3", false), + ]; + const result = purgeErroredArgs(messages, { ...defaultConfig, cooldownTurns: 2, minArgChars: 1000 }); + expect(result).toBe(messages); + }); + + it("does not purge when isError is false", () => { + const messages = [ + makeAssistant("tc1", { content: "a very long argument body here" }), + makeToolResult("tc1", false), // NOT an error + makeAssistant("tc2", { cmd: "ls" }), + makeToolResult("tc2", false), + makeAssistant("tc3", { cmd: "pwd" }), + makeToolResult("tc3", false), + ]; + const result = purgeErroredArgs(messages, defaultConfig); + expect(result).toBe(messages); + }); + + it("only purges errored toolCalls in a multi-toolCall assistant message", () => { + const largeArgs = { content: "a very long argument body here that is big" }; + const okArgs = { cmd: "ls" }; + const messages = [ + // One assistant with two toolCalls: tc-err is errored, tc-ok is not + { + role: "assistant", + content: [ + { type: "toolCall", id: "tc-err", name: "write", arguments: largeArgs }, + { type: "toolCall", id: "tc-ok", name: "bash", arguments: okArgs }, + ], + timestamp: 1, + }, + makeToolResult("tc-err", true), + makeToolResult("tc-ok", false), + makeAssistant("tc2", { cmd: "pwd" }), + makeToolResult("tc2", false), + makeAssistant("tc3", { cmd: "date" }), + makeToolResult("tc3", false), + ]; + const result = purgeErroredArgs(messages, { ...defaultConfig, cooldownTurns: 2, minArgChars: 5 }); + expect(result).not.toBe(messages); + const asst = result[0] as any; + // errored one is purged + const originalArgLen = JSON.stringify(largeArgs).length; + expect(asst.content[0].arguments).toEqual({ + _purged: ``, + }); + // non-errored one is untouched + expect(asst.content[1].arguments).toEqual(okArgs); + }); + + it("does not mutate the input messages array or any message object", () => { + const largeArgs = { content: "a very long argument body here" }; + const messages = [ + makeAssistant("tc1", largeArgs), + makeToolResult("tc1", true), + makeAssistant("tc2", { cmd: "ls" }), + makeToolResult("tc2", false), + makeAssistant("tc3", { cmd: "pwd" }), + makeToolResult("tc3", false), + ]; + const originalAsst = messages[0]; + const originalArgs = (messages[0] as any).content[0].arguments; + purgeErroredArgs(messages, { ...defaultConfig, cooldownTurns: 2, minArgChars: 10 }); + // Input array unchanged + expect(messages[0]).toBe(originalAsst); + expect((messages[0] as any).content[0].arguments).toBe(originalArgs); + }); + + it("exactly-at-cooldown boundary: age === cooldownTurns is purged", () => { + // Error at turn 1, 2 more assistant turns → age = 2 = cooldownTurns (should purge) + const largeArgs = { content: "argument body that is long enough to purge" }; + const messages = [ + makeAssistant("tc1", largeArgs), + makeToolResult("tc1", true), + makeAssistant("tc2", { cmd: "ls" }), + makeToolResult("tc2", false), + makeAssistant("tc3", { cmd: "pwd" }), + makeToolResult("tc3", false), + ]; + const result = purgeErroredArgs(messages, { ...defaultConfig, cooldownTurns: 2, minArgChars: 5 }); + expect(result).not.toBe(messages); + expect((result[0] as any).content[0].arguments._purged).toBeDefined(); + }); + + it("one-below-cooldown boundary: age === cooldownTurns - 1 is NOT purged", () => { + // Error at turn 1, 1 more assistant turn → age = 1 < cooldownTurns 2 + const largeArgs = { content: "argument body that is long enough to purge" }; + const messages = [ + makeAssistant("tc1", largeArgs), + makeToolResult("tc1", true), + makeAssistant("tc2", { cmd: "ls" }), + makeToolResult("tc2", false), + ]; + const result = purgeErroredArgs(messages, { ...defaultConfig, cooldownTurns: 2, minArgChars: 5 }); + expect(result).toBe(messages); + }); +}); diff --git a/pi-condense/src/error-purge.ts b/pi-condense/src/error-purge.ts new file mode 100644 index 0000000..84e63d1 --- /dev/null +++ b/pi-condense/src/error-purge.ts @@ -0,0 +1,71 @@ +import type { ErrorPurgeConfig } from "./types.js"; + +/** + * Replaces the `arguments` body of failed toolCall blocks with a compact stub + * once the error is old enough to be beyond the cooldown window. + * + * Why only the arguments, not the whole toolCall or its toolResult: + * - The toolResult content (e.g. "Error: file not found") is small and carries + * the failure signal the model needs to understand what went wrong. + * - The toolCall block itself must remain so the provider can pair it with its + * result and avoid injecting a synthetic "No result provided" error. + * - The arguments body is what grows large — failed `write` / `edit` calls + * embed the full file content that will never be acted on again. + * + * Why the cooldown: + * - Gives the model 1–2 turns to retry before context is mutated. Purging + * immediately would remove the call detail before the model has had a + * chance to see the error and adapt. + * + * Turn index is computed internally by counting AssistantMessages in the input. + * This avoids threading a turn counter through index.ts. + */ +export function purgeErroredArgs(messages: any[], config: ErrorPurgeConfig): any[] { + // Pass 1: collect errored toolCallIds → the turn index at which the error occurred. + // Turn index = number of AssistantMessages seen up to and including the one that + // issued the tool call (ToolResultMessages follow immediately after). + const erroredAtTurn = new Map(); + let turnCount = 0; + for (const msg of messages) { + if (msg.role === "assistant") { + // Count each assistant turn; toolResults referencing the turn come next. + turnCount++; + } else if (msg.role === "toolResult" && msg.isError === true) { + // Record the turn this errored call belongs to for cooldown comparison. + erroredAtTurn.set(msg.toolCallId, turnCount); + } + } + + if (erroredAtTurn.size === 0) return messages; + + const currentTurnIndex = turnCount; + + // Pass 2: rewrite AssistantMessages whose toolCall args should be purged. + let anyModified = false; + const result = messages.map((msg) => { + if (msg.role !== "assistant") return msg; + + let contentModified = false; + const newContent = (msg.content as any[]).map((block) => { + if (block.type !== "toolCall") return block; + + const errorTurn = erroredAtTurn.get(block.id); + if (errorTurn === undefined) return block; + + const age = currentTurnIndex - errorTurn; + if (age < config.cooldownTurns) return block; + + const argBody = JSON.stringify(block.arguments); + if (argBody.length < config.minArgChars) return block; + + contentModified = true; + return { ...block, arguments: { _purged: `` } }; + }); + + if (!contentModified) return msg; + anyModified = true; + return { ...msg, content: newContent }; + }); + + return anyModified ? result : messages; +} diff --git a/pi-condense/src/frontier.test.ts b/pi-condense/src/frontier.test.ts new file mode 100644 index 0000000..2f1ca3f --- /dev/null +++ b/pi-condense/src/frontier.test.ts @@ -0,0 +1,174 @@ +import { describe, expect, test } from "bun:test"; +import { PruneFrontierTracker } from "./frontier.js"; +import { pruneMessages } from "./pruner.js"; +import type { PruneFrontier } from "./types.js"; + +const base: PruneFrontier = { + lastAttemptedToolCallId: "tc1", + lastAttemptedToolName: "bash", + lastAttemptedTurnIndex: 3, + lastAttemptedTimestamp: 1000, + attemptedBatchCount: 1, + attemptedToolCallCount: 2, + rawCharCount: 500, + summaryCharCount: 100, + outcome: "summarized", +}; + +describe("PruneFrontierTracker.fromJSON", () => { + test("round-trips a full frontier", () => { + const t = new PruneFrontierTracker(); + t.fromJSON({ ...base }); + expect(t.get()?.lastAttemptedToolCallId).toBe("tc1"); + expect(t.get()?.outcome).toBe("summarized"); + }); + + test("ignores an entry with no lastAttemptedToolCallId", () => { + const t = new PruneFrontierTracker(); + t.fromJSON({} as PruneFrontier); + expect(t.get()).toBeNull(); + }); + + test("tolerates a legacy entry carrying the removed thinkingStripBoundaryTimestamp", () => { + const t = new PruneFrontierTracker(); + t.fromJSON({ ...base, thinkingStripBoundaryTimestamp: 777 } as PruneFrontier); + expect(t.get()?.lastAttemptedToolCallId).toBe("tc1"); + expect((t.get() as any).thinkingStripBoundaryTimestamp).toBeUndefined(); + }); +}); + +describe("PruneFrontierTracker.reconstructFromSession", () => { + test("reconstructs from a persisted frontier entry", () => { + const t = new PruneFrontierTracker(); + const entries = [ + { type: "custom", customType: "context-prune-frontier", data: { ...base, lastAttemptedTimestamp: 2000 } }, + ]; + const fakeCtx = { sessionManager: { getBranch: () => entries } } as any; + t.reconstructFromSession(fakeCtx); + expect(t.get()?.lastAttemptedTimestamp).toBe(2000); + }); + + // Spans frontier.ts + pruner.ts on purpose: proves a resumed legacy frontier carries + // no boundary into an *actively-pruning* pipeline, not just an inert one. Phase 1 + // (stub-replace) and Phase 3 (chain-range-prune) are both wired live here -- a + // summarized toolResult gets stubbed and a chain entry produces a synthetic + // message -- and every surviving assistant turn, both older and + // newer than the legacy boundary, still carries its thinking block. This does not + // (and cannot) prove the deleted thinking-strip phase stays deleted; it proves the + // phases that remain do not touch thinking regardless of the legacy field's presence. + test("a legacy frontier entry with thinkingStripBoundaryTimestamp resumes without error and strips nothing", () => { + const legacyBoundary = 555; + const t = new PruneFrontierTracker(); + const entries = [ + { + type: "custom", + customType: "context-prune-frontier", + data: { ...base, lastAttemptedTimestamp: 2000, thinkingStripBoundaryTimestamp: legacyBoundary }, + }, + ]; + const fakeCtx = { sessionManager: { getBranch: () => entries } } as any; + + expect(() => t.reconstructFromSession(fakeCtx)).not.toThrow(); + const frontier = t.get(); + expect(frontier).not.toBeNull(); + expect(frontier?.lastAttemptedToolCallId).toBe("tc1"); + expect((frontier as any).thinkingStripBoundaryTimestamp).toBeUndefined(); + + const chainEntry = { + blockId: "b1", + startUserTimestamp: 560, + droppedToolCallIds: ["tc-old"], + finalAssistantTimestamp: 600, + toolRefs: ["told"], + compressedAt: 9999, + }; + + const indexer = { + isSummarized: (id: string) => id === "tc-old" || id === "tc-stub", + hasLegacyBareRecord: (id: string) => id === "tc-old" || id === "tc-stub", + getShortRefForToolCallId: (id: string) => (id === "tc-stub" ? "t1" : id === "tc-old" ? "told" : undefined), + getRecord: () => undefined, + getChainEntries: () => [chainEntry], + getPerBatchSummaryTextForToolCallIds: () => "chain summary text", + findChainEntryByBlockId: () => undefined, + } as any; + + const mkAsst = (ts: number) => ({ + role: "assistant", + content: [ + { type: "thinking", thinking: "t", thinkingSignature: "s" }, + { type: "text", text: "x" }, + ], + timestamp: ts, + usage: {}, + stopReason: "end_turn", + }); + const mkAsstWithCall = (ts: number, toolCallId: string) => ({ + role: "assistant", + content: [ + { type: "thinking", thinking: "t", thinkingSignature: "s" }, + { type: "toolCall", id: toolCallId, name: "bash", arguments: {} }, + ], + timestamp: ts, + usage: {}, + stopReason: "tool_use", + }); + + // Timestamps straddle the legacy boundary: old code would have stripped the ones below it. + // tc-stub is a plain summarized tool result (phase 1 target, outside the chain). + // tc-old is dropped by the chain entry (phase 3 target). + const messages: any[] = [ + { role: "user", content: [{ type: "text", text: "go" }], timestamp: 1 }, + mkAsst(legacyBoundary - 100), + mkAsstWithCall(legacyBoundary - 55, "tc-stub"), + { + role: "toolResult", + toolCallId: "tc-stub", + toolName: "bash", + content: [{ type: "text", text: "raw stub-target output" }], + isError: false, + timestamp: legacyBoundary - 50, + }, + mkAsst(legacyBoundary - 1), + { role: "user", content: [{ type: "text", text: "do it" }], timestamp: 560 }, + mkAsstWithCall(570, "tc-old"), + { + role: "toolResult", + toolCallId: "tc-old", + toolName: "bash", + content: [{ type: "text", text: "raw chain output" }], + isError: false, + timestamp: 575, + }, + mkAsst(600), + mkAsst(legacyBoundary + 100), + ]; + + const { messages: out, pruned } = pruneMessages(messages, indexer, { + enabled: true, + rollingWindow: 0, + stripFinalAssistantThinking: false, + fuseRangeSummary: false, + }); + + // Non-vacuity: the pipeline actually did something. + expect(pruned).toBe(true); + + // Phase 1 fired: the summarized-but-not-chained toolResult was stub-replaced. + const stubResult = out.find((m: any) => m.role === "toolResult" && m.toolCallId === "tc-stub") as any; + expect(stubResult).toBeDefined(); + expect(stubResult.content[0].text).toContain("`t1`"); + expect(stubResult.content[0].text).not.toContain("raw stub-target output"); + + // Phase 3 fired: the chain entry produced a synthetic compressed-chain message. + const synthetic = out.find( + (m: any) => m.role === "user" && typeof m.content?.[0]?.text === "string" && m.content[0].text.startsWith(" m.role === "assistant"); + expect(assistants.length).toBe(5); + expect(assistants.every((a: any) => a.content.some((c: any) => c.type === "thinking"))).toBe(true); + }); +}); diff --git a/pi-condense/src/frontier.ts b/pi-condense/src/frontier.ts new file mode 100644 index 0000000..758ebc2 --- /dev/null +++ b/pi-condense/src/frontier.ts @@ -0,0 +1,62 @@ +import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; +import type { PruneFrontier } from "./types.js"; +import { CUSTOM_TYPE_FRONTIER } from "./types.js"; + +/** + * Tracks the most recent completed prune-attempt boundary. + * + * The frontier advances when a prune attempt finishes, regardless of whether it + * produced a persisted summary or was skipped because the summary was larger + * than the raw tool outputs. It does not advance on operational failures. + */ +export class PruneFrontierTracker { + private frontier: PruneFrontier | null = null; + + reset(): void { + this.frontier = null; + } + + get(): PruneFrontier | null { + return this.frontier ? { ...this.frontier } : null; + } + + fromJSON(data: PruneFrontier): void { + if (!data?.lastAttemptedToolCallId) return; + this.frontier = { + lastAttemptedToolCallId: data.lastAttemptedToolCallId, + lastAttemptedToolName: data.lastAttemptedToolName ?? "unknown", + lastAttemptedTurnIndex: data.lastAttemptedTurnIndex ?? 0, + lastAttemptedTimestamp: data.lastAttemptedTimestamp ?? 0, + attemptedBatchCount: data.attemptedBatchCount ?? 0, + attemptedToolCallCount: data.attemptedToolCallCount ?? 0, + rawCharCount: data.rawCharCount ?? 0, + summaryCharCount: data.summaryCharCount ?? 0, + outcome: data.outcome ?? "summarized", + }; + } + + reconstructFromSession(ctx: ExtensionContext): void { + this.reset(); + const branch = ctx.sessionManager.getBranch(); + for (const entry of branch) { + if ( + entry.type === "custom" && + (entry as any).customType === CUSTOM_TYPE_FRONTIER + ) { + const data = (entry as any).data as PruneFrontier; + if (data) { + this.fromJSON(data); + } + } + } + } + + advance(frontier: PruneFrontier): void { + this.frontier = { ...frontier }; + } + + persist(pi: ExtensionAPI): void { + if (!this.frontier) return; + pi.appendEntry(CUSTOM_TYPE_FRONTIER, this.frontier); + } +} diff --git a/pi-condense/src/id-collision.integration.test.ts b/pi-condense/src/id-collision.integration.test.ts new file mode 100644 index 0000000..3d5a8ba --- /dev/null +++ b/pi-condense/src/id-collision.integration.test.ts @@ -0,0 +1,269 @@ +import { describe, expect, test } from "bun:test"; +import { ToolCallIndexer } from "./indexer.js"; +import { pruneMessages } from "./pruner.js"; +import { detectChains } from "./chain-detector.js"; +import { compressEligible } from "./chain-compressor.js"; +import { captureUnindexedBatchesFromSession } from "./batch-capture.js"; +import { expectNoOrphanToolResults } from "./test-support.js"; +import { CUSTOM_TYPE_CHAIN, CUSTOM_TYPE_INDEX, CUSTOM_TYPE_SUMMARY } from "./types.js"; +import type { CapturedBatch } from "./types.js"; + +const user = (ts: number, text: string) => ({ role: "user", content: [{ type: "text", text }], timestamp: ts }); +const callTurn = (ts: number, ids: string[]) => ({ + role: "assistant", + content: ids.map((id) => ({ type: "toolCall", id, name: "bash", input: { cmd: id } })), + timestamp: ts, +}); +const result = (ts: number, id: string, text: string) => ({ + role: "toolResult", + toolCallId: id, + toolName: "bash", + content: [{ type: "text", text }], + isError: false, + timestamp: ts, +}); +const finalTurn = (ts: number, text: string) => ({ role: "assistant", content: [{ type: "text", text }], timestamp: ts }); + +/** + * The incident shape: two closeable chains, then a live turn reusing bash_23. + * Pre-fix, the session-wide id-set drop deleted the live `bash_23` assistant + * turn and left `LIVE 24` (`gauntlet_setting_24`) orphaned, which the + * provider rejects with a 400. + */ +const buildSession = () => [ + user(1000, "1"), + callTurn(1100, ["bash_18"]), + result(1150, "bash_18", "OUT 18"), + finalTurn(1200, "done 1"), + user(2000, "2"), + callTurn(2100, ["bash_23"]), + result(2150, "bash_23", "OUT 23 first"), + finalTurn(2200, "done 2"), + user(3000, "3"), + callTurn(3100, ["bash_23", "gauntlet_setting_24"]), + result(3150, "bash_23", "LIVE 23"), + result(3160, "gauntlet_setting_24", "LIVE 24"), +]; + +const batchFor = (turnIndex: number, ts: number, id: string, resultTs: number, text: string): CapturedBatch => ({ + turnIndex, + timestamp: ts, + assistantText: "", + toolCalls: [{ toolCallId: id, toolName: "bash", args: { cmd: id }, resultText: text, isError: false, resultTimestamp: resultTs }], +}); + +/** + * Indexes + summarizes the two older batches, then compresses their chains. + * Mirrors index.ts's live-flush ordering and keying exactly: allocate refs -> + * send/append the summary -> registerSummaryRefs -> addBatch -> + * registerSummaryBody(). `keyer` controls the shape of the ids passed + * to registerSummaryBody so both the production (bare-id) bug and the fixed + * (occurrence-key) contract can be exercised with the same helper. + */ +const primeIndexer = async ( + messages: any[], + keyer: (r: { toolCallId: string; resultTimestamp?: number }) => string = (r) => + `${r.toolCallId}@${r.resultTimestamp}`, +) => { + const appended: Array<{ type: string; data: any }> = []; + const indexer = new ToolCallIndexer(); + const append = (type: string, data?: unknown) => appended.push({ type, data }); + const refsByToolCallId = new Map(); + + for (const [turnIndex, spec] of [ + [0, { id: "bash_18", ts: 1000, resultTs: 1150, text: "OUT 18" }], + [1, { id: "bash_23", ts: 2000, resultTs: 2150, text: "OUT 23 first" }], + ] as const) { + const batch = batchFor(turnIndex, spec.ts, spec.id, spec.resultTs, spec.text); + const refs = indexer.allocateSummaryRefs(batch); + // (send/append the summary message here in the real flush; no-op for this harness) + indexer.registerSummaryRefs(refs); + indexer.addBatch(batch, append); + indexer.registerSummaryBody(refs.map(keyer), `summary of ${spec.id}`); + for (const ref of refs) refsByToolCallId.set(ref.toolCallId, ref); + } + + let nextBlock = 1; + await compressEligible(detectChains(messages), 0, { + indexer, + blockRefs: { issue: () => `b${nextBlock++}` } as any, + appendEntry: append, + now: () => 9000, + messages, + diagnostics: { report: () => {} }, + backfill: { spillThreshold: 100_000, spillPreviewBytes: 500, sessionDir: "/tmp/unused", sessionId: "s" }, + }); + + return { indexer, appended, refsByToolCallId }; +}; + +const chainConfig = { enabled: true, rollingWindow: 0, stripFinalAssistantThinking: false, fuseRangeSummary: false } as any; +const syntheticsOf = (messages: any[]) => + messages.filter((m: any) => m.role === "user" && m.content?.[0]?.text?.startsWith(" { + test("render keeps the live turn, drops both chain interiors, leaves no orphan", async () => { + const messages = buildSession(); + const { indexer } = await primeIndexer(messages); + const out = pruneMessages(messages, indexer, chainConfig); + + expect(out.pruned).toBe(true); + // live turn intact, both results verbatim. Synthetics also carry + // timestamp=compressedAt(9000) here, so they must be excluded from this + // filter or they'd inflate the count - see chain-range-prune.test.ts's + // "compressedAt kept below 3000" comment for the same caveat. + const liveReal = out.messages.filter( + (m: any) => m.timestamp >= 3000 && !(m.content?.[0]?.text ?? "").startsWith(" m.timestamp === 3150).content[0].text).toBe("LIVE 23"); + expect(out.messages.find((m: any) => m.timestamp === 3160).content[0].text).toBe("LIVE 24"); + // both chain interiors gone, one synthetic each, bodies non-empty + expect(out.messages.some((m: any) => m.timestamp === 1150 || m.timestamp === 2150)).toBe(false); + const synthetics = syntheticsOf(out.messages); + expect(synthetics).toHaveLength(2); + const bash18Synthetic = synthetics.find((s: any) => s.content[0].text.includes("summary of bash_18")); + const bash23Synthetic = synthetics.find((s: any) => s.content[0].text.includes("summary of bash_23")); + expect(bash18Synthetic).toBeDefined(); + expect(bash23Synthetic).toBeDefined(); + expect(bash18Synthetic).not.toBe(bash23Synthetic); + expect(bash18Synthetic.content[0].text).not.toContain("summary of bash_23"); + expect(bash23Synthetic.content[0].text).not.toContain("summary of bash_18"); + expectNoOrphanToolResults(out.messages); + }); + + // Regression for the live-flush bug (ref #8, index.ts registerSummaryBody + // call): production must key registerSummaryBody with the occurrence key + // (`id@resultTimestamp`), because hasPerBatchSummaryCoveringAny / + // getPerBatchSummariesForToolCallIds are always queried with occurrence + // keys (src/chain-compressor.ts's `lookupKeys`). Bare ids (`tc.toolCallId`) + // silently mismatch and every chain is skipped as "no-summary" - it only + // appears to work after a restart because reconstructFromSession rebuilds + // bodies from summary refs, which DO carry resultTimestamp. The default + // `keyer` on primeIndexer above pins the correct (occurrence-key) shape; + // this test pins the failure mode of the bare-id shape as a contrast. + test("live-flush occurrence-key contract: chains compress with non-empty, per-chain-distinct bodies", async () => { + const messages = buildSession(); + const { indexer } = await primeIndexer(messages); // default keyer = occurrence key, i.e. the fixed index.ts contract + const out = pruneMessages(messages, indexer, chainConfig); + + const synthetics = syntheticsOf(out.messages); + expect(synthetics).toHaveLength(2); + expect(synthetics.some((s: any) => s.content[0].text.includes("summary of bash_18"))).toBe(true); + expect(synthetics.some((s: any) => s.content[0].text.includes("summary of bash_23"))).toBe(true); + expectNoOrphanToolResults(out.messages); + }); + + // Regression for the bare-id keying bug (ref #8): registerSummaryBody keyed + // with `tc.toolCallId` (no resultTimestamp) mismatches hasPerBatchSummaryCoveringAny's + // occurrence-key lookups, so both chains fall through to the "no per-batch + // summary covers this span" branch. Pre-2026-08-14 that branch was a permanent + // no-summary skip (the bug this test used to pin). Since the deterministic + // backfill fallback (doc/specs/2026-08-14-uncovered-chain-deterministic-backfill.md), + // that branch instead compresses deterministically from already-indexed + // records (both chains' tool calls were indexed via addBatch, just not + // summary-covered under the right key) - so the keying bug can no longer + // strand a chain through this path. Pin the new correct behavior instead. + test("live-flush bare-id keying bug: chains compress deterministically instead of stranding", async () => { + const messages = buildSession(); + // Mirrors the production BUG exactly: `tc.toolCallId` with no resultTimestamp, + // matching index.ts's pre-fix `batch.toolCalls.map((tc) => tc.toolCallId)`. + const { indexer } = await primeIndexer(messages, (r) => r.toolCallId); + const out = pruneMessages(messages, indexer, chainConfig); + + const synthetics = syntheticsOf(out.messages); + expect(synthetics).toHaveLength(2); + for (const s of synthetics) { + expect(s.content[0].text).toContain("Deterministic chain compression"); + expect(s.content[0].text).toMatch(/Refs: t\d+/); + } + expectNoOrphanToolResults(out.messages); + }); + + test("re-rendering the same session is deep-equal", async () => { + const messages = buildSession(); + const { indexer } = await primeIndexer(messages); + const first = pruneMessages(messages, indexer, chainConfig); + const second = pruneMessages(first.messages, indexer, chainConfig); + expect(second.messages).toEqual(first.messages); + expectNoOrphanToolResults(second.messages); + }); + + test("G4/C2: a live collision batch is captured (not filtered as summarized) and separately addressable after summarization", () => { + // Prime the indexer with an already-summarized bash_23 occurrence. + const indexer = new ToolCallIndexer(); + indexer.addBatch( + { + turnIndex: 0, + timestamp: 2000, + assistantText: "", + toolCalls: [{ toolCallId: "bash_23", toolName: "bash", args: {}, resultText: "OUT 23 first", isError: false, resultTimestamp: 2150 }], + }, + () => {}, + ); + expect(indexer.isSummarized("bash_23@2150")).toBe(true); + + // A NEW live occurrence of the same bare id, at a later resultTimestamp, + // not yet in the index. + const branch = [ + { type: "message", message: user(3000, "3") }, + { type: "message", message: callTurn(3100, ["bash_23"]) }, + { type: "message", message: result(3150, "bash_23", "LIVE 23") }, + ]; + + const batches = captureUnindexedBatchesFromSession(branch, indexer); + // The live occurrence must be captured, NOT skipped as already-summarized - + // isSummarized is asked with the occurrence key (bash_23@3150), which is + // distinct from the primed bash_23@2150. + expect(batches).toHaveLength(1); + expect(batches[0].toolCalls).toHaveLength(1); + expect(batches[0].toolCalls[0].toolCallId).toBe("bash_23"); + expect(batches[0].toolCalls[0].resultTimestamp).toBe(3150); + expect(batches[0].toolCalls[0].resultText).toBe("LIVE 23"); + + // Capture it into the index (mirrors a successful summarization flush) and + // confirm both occurrences remain separately addressable. + indexer.addBatch(batches[0], () => {}); + expect(indexer.getRecord("bash_23@2150")?.resultText).toBe("OUT 23 first"); + expect(indexer.getRecord("bash_23@3150")?.resultText).toBe("LIVE 23"); + expect(indexer.isSummarized("bash_23@3150")).toBe(true); + }); + + test("a restart-shaped rebuild reproduces both tN refs and non-empty synthetics", async () => { + const messages = buildSession(); + const { appended, refsByToolCallId } = await primeIndexer(messages); + + // Replay only what the session would hold: index, summary and chain entries. + const branch: any[] = []; + for (const { type, data } of appended) { + if (type === CUSTOM_TYPE_INDEX) branch.push({ type: "custom", customType: CUSTOM_TYPE_INDEX, data }); + if (type === CUSTOM_TYPE_CHAIN) branch.push({ type: "custom", customType: CUSTOM_TYPE_CHAIN, data }); + } + // Use the refs the flush actually allocated (via allocateSummaryRefs), not + // hand-picked shortIds - this proves the restart replay honors whatever + // numbering the flush produced instead of assuming t1/t2. + for (const id of ["bash_18", "bash_23"]) { + const ref = refsByToolCallId.get(id); + if (!ref) throw new Error(`primeIndexer did not allocate a ref for ${id}`); + branch.push({ + type: "custom_message", + customType: CUSTOM_TYPE_SUMMARY, + content: `summary of ${id}`, + details: { toolCallRefs: [ref] }, + }); + } + + const [ref18, ref23] = [refsByToolCallId.get("bash_18")!, refsByToolCallId.get("bash_23")!]; + const rebuilt = new ToolCallIndexer(); + rebuilt.reconstructFromSession({ sessionManager: { getBranch: () => branch } } as any); + expect(rebuilt.getRecord(ref18.shortId)?.resultText).toBe("OUT 18"); + expect(rebuilt.getRecord(ref23.shortId)?.resultText).toBe("OUT 23 first"); + + const out = pruneMessages(buildSession(), rebuilt, chainConfig); + const synthetics = syntheticsOf(out.messages); + expect(synthetics).toHaveLength(2); + expect(synthetics.some((s: any) => s.content[0].text.includes("summary of bash_18"))).toBe(true); + expect(synthetics.some((s: any) => s.content[0].text.includes("summary of bash_23"))).toBe(true); + expectNoOrphanToolResults(out.messages); + }); +}); diff --git a/pi-condense/src/indexer.test.ts b/pi-condense/src/indexer.test.ts new file mode 100644 index 0000000..8095627 --- /dev/null +++ b/pi-condense/src/indexer.test.ts @@ -0,0 +1,545 @@ +import { describe, expect, test } from "bun:test"; +import { mkdtemp, readFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { ToolCallIndexer } from "./indexer.js"; +import { CUSTOM_TYPE_INDEX, CUSTOM_TYPE_SUMMARY, CUSTOM_TYPE_DEDUP_ALIAS, CUSTOM_TYPE_CHAIN } from "./types.js"; +import type { CapturedBatch, ToolCallRecord } from "./types.js"; +import { occKey } from "./occurrence-key.js"; + +const batch = ( + turnIndex: number, + timestamp: number, + calls: { id: string; ts?: number; text: string }[], +): CapturedBatch => ({ + turnIndex, + timestamp, + assistantText: "", + toolCalls: calls.map((c) => ({ + toolCallId: c.id, + toolName: "bash", + args: { cmd: "ls" }, + resultText: c.text, + isError: false, + ...(c.ts !== undefined ? { resultTimestamp: c.ts } : {}), + })), +}); + +describe("occurrence keying", () => { + test("two batches reusing one provider id keep both records", () => { + const indexer = new ToolCallIndexer(); + indexer.addBatch(batch(0, 1000, [{ id: "bash_23", ts: 1150, text: "FIRST" }]), () => {}); + indexer.addBatch(batch(1, 2000, [{ id: "bash_23", ts: 2150, text: "SECOND" }]), () => {}); + + expect(indexer.getRecord("bash_23@1150")?.resultText).toBe("FIRST"); + expect(indexer.getRecord("bash_23@2150")?.resultText).toBe("SECOND"); + }); + + test("isSummarized is keyed by occurrence, so a live reused id is not summarized", () => { + const indexer = new ToolCallIndexer(); + indexer.addBatch(batch(0, 1000, [{ id: "bash_23", ts: 1150, text: "FIRST" }]), () => {}); + + expect(indexer.isSummarized("bash_23@1150")).toBe(true); + expect(indexer.isSummarized("bash_23@3150")).toBe(false); + }); + + test("getRecordsForId returns every occurrence of a bare id, chronologically", () => { + const indexer = new ToolCallIndexer(); + indexer.addBatch(batch(0, 2000, [{ id: "bash_23", ts: 2150, text: "SECOND" }]), () => {}); + indexer.addBatch(batch(1, 1000, [{ id: "bash_23", ts: 1150, text: "FIRST" }]), () => {}); + + const records = indexer.getRecordsForId("bash_23"); + expect(records.map((r) => r.resultText)).toEqual(["FIRST", "SECOND"]); + }); + + test("getRecordsForId on an occurrence key or short ref returns exactly one", () => { + const indexer = new ToolCallIndexer(); + indexer.addBatch(batch(0, 1000, [{ id: "bash_23", ts: 1150, text: "FIRST" }]), () => {}); + indexer.registerSummaryRefs([{ shortId: "t1", toolCallId: "bash_23", resultTimestamp: 1150 }]); + + expect(indexer.getRecordsForId("bash_23@1150").map((r) => r.resultText)).toEqual(["FIRST"]); + expect(indexer.getRecordsForId("t1").map((r) => r.resultText)).toEqual(["FIRST"]); + }); + + test("short refs resolve per occurrence", () => { + const indexer = new ToolCallIndexer(); + indexer.addBatch(batch(0, 1000, [{ id: "bash_23", ts: 1150, text: "FIRST" }]), () => {}); + indexer.addBatch(batch(1, 2000, [{ id: "bash_23", ts: 2150, text: "SECOND" }]), () => {}); + indexer.registerSummaryRefs([ + { shortId: "t1", toolCallId: "bash_23", resultTimestamp: 1150 }, + { shortId: "t2", toolCallId: "bash_23", resultTimestamp: 2150 }, + ]); + + expect(indexer.getRecord("t1")?.resultText).toBe("FIRST"); + expect(indexer.getRecord("t2")?.resultText).toBe("SECOND"); + expect(indexer.getShortRefForToolCallId("bash_23@2150")).toBe("t2"); + }); + + test("allocateSummaryRefs mints refs carrying the occurrence timestamp", () => { + const indexer = new ToolCallIndexer(); + const b = batch(0, 1000, [{ id: "bash_23", ts: 1150, text: "FIRST" }]); + const refs = indexer.allocateSummaryRefs(b); + + expect(refs).toEqual([{ shortId: "t1", toolCallId: "bash_23", resultTimestamp: 1150 }]); + }); + + test("content dedup does not alias across batches that merely share an id", () => { + const indexer = new ToolCallIndexer(); + indexer.addBatch(batch(0, 1000, [{ id: "bash_23", ts: 1150, text: "FIRST" }]), () => {}); + indexer.addBatch(batch(1, 2000, [{ id: "bash_23", ts: 2150, text: "SECOND" }]), () => {}); + + // Each occurrence's content resolves to its OWN occurrence key, never + // to the other occurrence merely because they share a bare id. + expect(indexer.lookupByContent("bash", "FIRST")).toBe("bash_23@1150"); + expect(indexer.lookupByContent("bash", "SECOND")).toBe("bash_23@2150"); + expect(indexer.lookupByContent("bash", "UNSEEN")).toBeUndefined(); + }); + + test("registerDuplicate persists both occurrence sides and reuses the short ref", () => { + const indexer = new ToolCallIndexer(); + indexer.addBatch(batch(0, 1000, [{ id: "bash_23", ts: 1150, text: "SAME" }]), () => {}); + indexer.registerSummaryRefs([{ shortId: "t1", toolCallId: "bash_23", resultTimestamp: 1150 }]); + + let persisted: any; + indexer.registerDuplicate("bash_99@4150", "bash_23@1150", (customType, data) => { + persisted = { customType, data }; + }); + + expect(persisted).toEqual({ + customType: CUSTOM_TYPE_DEDUP_ALIAS, + data: { + newToolCallId: "bash_99", + newResultTimestamp: 4150, + originalToolCallId: "bash_23", + originalResultTimestamp: 1150, + }, + }); + expect(indexer.isSummarized("bash_99@4150")).toBe(true); + expect(indexer.getRecord("bash_99@4150")?.resultText).toBe("SAME"); + expect(indexer.getShortRefForToolCallId("bash_99@4150")).toBe("t1"); + }); + + test("getRecordsForId includes a dedup-alias occurrence, labelled with its own timestamp", () => { + const indexer = new ToolCallIndexer(); + indexer.addBatch(batch(0, 1000, [{ id: "bash_23", ts: 1150, text: "SAME" }]), () => {}); + // Alias shares the SAME bare id as the original but occurred later - the + // G5 conformance shape: a content-deduplicated collision on a reused id. + indexer.registerDuplicate("bash_23@9150", "bash_23@1150", () => {}); + + const records = indexer.getRecordsForId("bash_23"); + expect(records).toHaveLength(2); + expect(records.map((r) => r.resultText)).toEqual(["SAME", "SAME"]); + expect(records.map((r) => r.resultTimestamp)).toEqual([1150, 9150]); + }); + + test("summary body lookups are occurrence-keyed", () => { + const indexer = new ToolCallIndexer(); + indexer.registerSummaryBody(["bash_23@1150"], "summary of FIRST"); + + expect(indexer.hasPerBatchSummaryCoveringAny(["bash_23@1150"])).toBe(true); + expect(indexer.hasPerBatchSummaryCoveringAny(["bash_23@3150"])).toBe(false); + expect(indexer.getPerBatchSummaryTextForToolCallIds(["bash_23@1150"])).toBe("summary of FIRST"); + }); +}); + +describe("fail-closed asymmetry: isSummarized vs bare-id resolution", () => { + test("single occurrence: isSummarized on the bare id is strict-false, resolveToolCallId/getRecord fall back", () => { + const indexer = new ToolCallIndexer(); + indexer.addBatch(batch(0, 1000, [{ id: "bash_23", ts: 1150, text: "FIRST" }]), () => {}); + + expect(indexer.isSummarized("bash_23")).toBe(false); + expect(indexer.resolveToolCallId("bash_23")).toBe("bash_23@1150"); + expect(indexer.getRecord("bash_23")?.resultText).toBe("FIRST"); + }); + + test("two occurrences: resolveToolCallId on the bare id is ambiguous, getRecordsForId expands both", () => { + const indexer = new ToolCallIndexer(); + indexer.addBatch(batch(0, 1000, [{ id: "bash_23", ts: 1150, text: "FIRST" }]), () => {}); + indexer.addBatch(batch(1, 2000, [{ id: "bash_23", ts: 2150, text: "SECOND" }]), () => {}); + + expect(indexer.resolveToolCallId("bash_23")).toBeUndefined(); + expect(indexer.getRecordsForId("bash_23").map((r) => r.resultText)).toEqual(["FIRST", "SECOND"]); + }); + + test("mixed legacy + new occurrence under the same bare id: hasLegacyBareRecord is false (fail-closed), getRecordsForId returns both", () => { + const indexer = new ToolCallIndexer(); + indexer.addBatch(batch(0, 1000, [{ id: "bash_23", text: "OLD" }]), () => {}); + indexer.addBatch(batch(1, 2000, [{ id: "bash_23", ts: 2150, text: "NEW" }]), () => {}); + + // A mixed bare+occurrence id must fail closed: the pruner's legacy-bare + // path would otherwise stub an unrelated live occurrence with the stale + // legacy record (F1 regression - see pruner.test.ts). + expect(indexer.hasLegacyBareRecord("bash_23")).toBe(false); + expect(indexer.getRecordsForId("bash_23").map((r) => r.resultText)).toEqual(["OLD", "NEW"]); + }); + + test("hasLegacyBareRecord: true for pure-legacy, false for pure-occurrence, false for mixed", () => { + const legacyOnly = new ToolCallIndexer(); + legacyOnly.addBatch(batch(0, 1000, [{ id: "bash_1", text: "OLD" }]), () => {}); + expect(legacyOnly.hasLegacyBareRecord("bash_1")).toBe(true); + + const occOnly = new ToolCallIndexer(); + occOnly.addBatch(batch(0, 1000, [{ id: "bash_2", ts: 1150, text: "NEW" }]), () => {}); + expect(occOnly.hasLegacyBareRecord("bash_2")).toBe(false); + + const mixed = new ToolCallIndexer(); + mixed.addBatch(batch(0, 1000, [{ id: "bash_3", text: "OLD" }]), () => {}); + mixed.addBatch(batch(1, 2000, [{ id: "bash_3", ts: 2150, text: "NEW" }]), () => {}); + expect(mixed.hasLegacyBareRecord("bash_3")).toBe(false); + }); + + test("rebuild is order-independent: a dedup alias entry appearing BEFORE its summary entry still inherits the short ref", () => { + const indexEntry = { + type: "custom", + customType: CUSTOM_TYPE_INDEX, + data: { + toolCalls: [ + { + toolCallId: "bash_23", + toolName: "bash", + args: {}, + resultText: "FIRST", + isError: false, + turnIndex: 0, + timestamp: 1000, + resultTimestamp: 1150, + }, + ], + }, + }; + const dedupEntry = { + type: "custom", + customType: CUSTOM_TYPE_DEDUP_ALIAS, + data: { + newToolCallId: "bash_99", + newResultTimestamp: 4150, + originalToolCallId: "bash_23", + originalResultTimestamp: 1150, + }, + }; + const summaryEntry = { + type: "custom_message", + customType: CUSTOM_TYPE_SUMMARY, + content: "summary text", + details: { + toolCallRefs: [{ shortId: "t1", toolCallId: "bash_23", resultTimestamp: 1150 }], + toolNames: ["bash"], + turnIndex: 0, + timestamp: 1000, + }, + }; + + const indexer = new ToolCallIndexer(); + // dedup entry appears BEFORE the summary entry in the branch, unlike every other test here. + const ctx = { sessionManager: { getBranch: () => [indexEntry, dedupEntry, summaryEntry] } } as any; + indexer.reconstructFromSession(ctx); + + expect(indexer.getShortRefForToolCallId("bash_99@4150")).toBe("t1"); + }); +}); + +describe("session rebuild", () => { + test("rebuilds occurrence records, both short refs and the dedup alias", () => { + const indexEntry = { + type: "custom", + customType: CUSTOM_TYPE_INDEX, + data: { + toolCalls: [ + { + toolCallId: "bash_23", + toolName: "bash", + args: {}, + resultText: "FIRST", + isError: false, + turnIndex: 0, + timestamp: 1000, + resultTimestamp: 1150, + }, + { + toolCallId: "bash_23", + toolName: "bash", + args: {}, + resultText: "SECOND", + isError: false, + turnIndex: 1, + timestamp: 2000, + resultTimestamp: 2150, + }, + ], + }, + }; + const summaryEntry = { + type: "custom_message", + customType: CUSTOM_TYPE_SUMMARY, + content: "summary text", + details: { + toolCallRefs: [ + { shortId: "t1", toolCallId: "bash_23", resultTimestamp: 1150 }, + { shortId: "t2", toolCallId: "bash_23", resultTimestamp: 2150 }, + ], + toolNames: ["bash", "bash"], + turnIndex: 1, + timestamp: 2000, + }, + }; + const dedupEntry = { + type: "custom", + customType: CUSTOM_TYPE_DEDUP_ALIAS, + data: { + newToolCallId: "bash_99", + newResultTimestamp: 4150, + originalToolCallId: "bash_23", + originalResultTimestamp: 1150, + }, + }; + + const indexer = new ToolCallIndexer(); + const ctx = { sessionManager: { getBranch: () => [indexEntry, summaryEntry, dedupEntry] } } as any; + indexer.reconstructFromSession(ctx); + + expect(indexer.getRecord("t1")?.resultText).toBe("FIRST"); + expect(indexer.getRecord("t2")?.resultText).toBe("SECOND"); + expect(indexer.getRecord("bash_99@4150")?.resultText).toBe("FIRST"); + expect(indexer.getShortRefForToolCallId("bash_99@4150")).toBe("t1"); + expect(indexer.getPerBatchSummaryTextForToolCallIds(["bash_23@2150"])).toBe("summary text"); + }); + + test("legacy entries without resultTimestamp keep bare-id keys", () => { + const indexEntry = { + type: "custom", + customType: CUSTOM_TYPE_INDEX, + data: { + toolCalls: [ + { + toolCallId: "bash_7", + toolName: "bash", + args: {}, + resultText: "OLD", + isError: false, + turnIndex: 0, + timestamp: 1000, + }, + ], + }, + }; + const legacyDedupEntry = { + type: "custom", + customType: CUSTOM_TYPE_DEDUP_ALIAS, + data: { newToolCallId: "bash_8", originalToolCallId: "bash_7" }, + }; + + const indexer = new ToolCallIndexer(); + const ctx = { sessionManager: { getBranch: () => [indexEntry, legacyDedupEntry] } } as any; + indexer.reconstructFromSession(ctx); + + expect(indexer.getRecord("bash_7")?.resultText).toBe("OLD"); + expect(indexer.hasLegacyBareRecord("bash_7")).toBe(true); + expect(indexer.hasLegacyBareRecord("bash_23")).toBe(false); + expect(indexer.isSummarized("bash_8")).toBe(true); + }); +}); + +function backfillOpts(appended: Array<{ type: string; data: any }>, tmpDir: string) { + return { + spillThreshold: 100_000, + spillPreviewBytes: 500, + sessionDir: tmpDir, + sessionId: "test-session", + appendEntry: (type: string, data?: unknown) => appended.push({ type, data }), + }; +} + +function record(id: string, ts: number, over: Partial = {}): ToolCallRecord { + return { + toolCallId: id, + toolName: "bash", + args: { command: "ls" }, + resultText: "out-" + id, + isError: false, + turnIndex: -1, + timestamp: ts, + resultTimestamp: ts, + ...over, + }; +} + +describe("backfillChainRecords", () => { + test("happy path: indexes records, returns refs, persists one entry", async () => { + const indexer = new ToolCallIndexer(); + const appended: Array<{ type: string; data: any }> = []; + const records = [record("a", 1), record("b", 2)]; + + const refs = await indexer.backfillChainRecords(records, backfillOpts(appended, "/tmp/unused")); + + expect(refs).toHaveLength(2); + for (const ref of refs) expect(ref.shortId).toMatch(/^t\d+$/); + + expect(indexer.getRecord("a")?.toolCallId).toBe("a"); + expect(indexer.resolveToolCallId(refs[0].shortId)).toBe(occKey("a", 1)); + + expect(appended).toHaveLength(1); + expect(appended[0].type).toBe(CUSTOM_TYPE_INDEX); + expect(appended[0].data.backfilled).toBe(true); + expect(appended[0].data.refs).toHaveLength(2); + expect(appended[0].data.toolCalls).toHaveLength(2); + }); + + test("dedup exclusion: backfilled records never seed contentHashToOriginal", async () => { + const indexer = new ToolCallIndexer(); + const appended: Array<{ type: string; data: any }> = []; + await indexer.backfillChainRecords([record("a", 1)], backfillOpts(appended, "/tmp/unused")); + + expect(indexer.lookupByContent("bash", "out-a")).toBeUndefined(); + }); + + test("append failure aborts atomically: no partial index state", async () => { + const indexer = new ToolCallIndexer(); + const opts = { + ...backfillOpts([], "/tmp/unused"), + appendEntry: () => { + throw new Error("append failed"); + }, + }; + + await expect(indexer.backfillChainRecords([record("a", 1)], opts)).rejects.toThrow(); + + expect(indexer.getIndex().size).toBe(0); + expect(indexer.getShortRefForToolCallId(occKey("a", 1))).toBeUndefined(); + }); + + test("spill: large result text is written to a sidecar blob", async () => { + const tmpDir = await mkdtemp(join(tmpdir(), "pi-condense-backfill-")); + const indexer = new ToolCallIndexer(); + const appended: Array<{ type: string; data: any }> = []; + const bigText = "x".repeat(200_001); + const opts = { ...backfillOpts(appended, tmpDir), spillThreshold: 200_000 }; + + await indexer.backfillChainRecords([record("a", 1, { resultText: bigText })], opts); + + const persistedRecord = appended[0].data.toolCalls[0]; + expect(persistedRecord.spillPath).toBeTruthy(); + expect(persistedRecord.resultText).toBe(""); + expect(persistedRecord.resultPreview).toBeTruthy(); + expect(persistedRecord.contentHash).toBeTruthy(); + + const blobContents = await readFile(persistedRecord.spillPath, "utf-8"); + expect(blobContents).toBe(bigText); + }); + + test("reconstruction round-trip: backfilled entry replays without seeding dedup", async () => { + const indexer = new ToolCallIndexer(); + const appended: Array<{ type: string; data: any }> = []; + const refs = await indexer.backfillChainRecords([record("a", 1)], backfillOpts(appended, "/tmp/unused")); + + const indexEntry = { type: "custom", customType: CUSTOM_TYPE_INDEX, data: appended[0].data }; + const rebuilt = new ToolCallIndexer(); + const ctx = { sessionManager: { getBranch: () => [indexEntry] } } as any; + rebuilt.reconstructFromSession(ctx); + + expect(rebuilt.getRecord("a")?.toolCallId).toBe("a"); + expect(rebuilt.resolveToolCallId(refs[0].shortId)).toBe(occKey("a", 1)); + expect(rebuilt.lookupByContent("bash", "out-a")).toBeUndefined(); + }); + + test("legacy round-trip unchanged: pre-change entries (no backfilled/refs) still seed dedup", () => { + const indexEntry = { + type: "custom", + customType: CUSTOM_TYPE_INDEX, + data: { toolCalls: [record("a", 1)] }, + }; + const indexer = new ToolCallIndexer(); + const ctx = { sessionManager: { getBranch: () => [indexEntry] } } as any; + indexer.reconstructFromSession(ctx); + + expect(indexer.lookupByContent("bash", "out-a")).toBe(occKey("a", 1)); + }); + + test("full legacy session round-trip: pre-change context-prune-chain + context-prune-index entries reconstruct to identical state", () => { + // Mirrors a real pre-change session: a summarized batch (index entry + + // per-batch summary entry, both pre-existing shapes) followed by a + // pre-change chain-compression entry (no bodySource - that field didn't + // exist yet) recording the range-drop decision over that batch's calls. + const indexEntry = { + type: "custom", + customType: CUSTOM_TYPE_INDEX, + data: { + toolCalls: [ + { + toolCallId: "tc1", + toolName: "bash", + args: { cmd: "ls" }, + resultText: "listing", + isError: false, + turnIndex: 0, + timestamp: 100, + resultTimestamp: 110, + }, + { + toolCallId: "tc2", + toolName: "read", + args: { path: "f" }, + resultText: "file contents", + isError: false, + turnIndex: 0, + timestamp: 100, + resultTimestamp: 210, + }, + ], + }, + }; + const summaryEntry = { + type: "custom_message", + customType: CUSTOM_TYPE_SUMMARY, + content: "summarized tc1 and tc2", + details: { + toolCallRefs: [ + { shortId: "t1", toolCallId: "tc1", resultTimestamp: 110 }, + { shortId: "t2", toolCallId: "tc2", resultTimestamp: 210 }, + ], + toolNames: ["bash", "read"], + turnIndex: 0, + timestamp: 100, + }, + }; + // Pre-change shape: no `bodySource` field (introduced by the uncovered- + // chain backfill feature; its absence must preserve existing semantics). + const chainEntry = { + type: "custom", + customType: CUSTOM_TYPE_CHAIN, + data: { + blockId: "b1", + startUserTimestamp: 50, + droppedToolCallIds: ["tc1", "tc2"], + droppedOccurrenceKeys: [occKey("tc1", 110), occKey("tc2", 210)], + finalAssistantTimestamp: 300, + toolRefs: ["t1", "t2"], + compressedAt: 12345, + rangeSummaryText: "fused range summary of tc1+tc2", + }, + }; + + const indexer = new ToolCallIndexer(); + const ctx = { + // Deep-clone so the byte-identity assertion below compares against the + // original fixture, not the same object reference the registry stored. + sessionManager: { getBranch: () => structuredClone([indexEntry, summaryEntry, chainEntry]) }, + } as any; + indexer.reconstructFromSession(ctx); + + // Chain registry populated, entry reconstructs byte-identical to what was persisted. + const entries = indexer.getChainEntries(); + expect(entries).toHaveLength(1); + expect(entries[0]).toEqual(chainEntry.data as any); + expect(entries[0].bodySource).toBeUndefined(); + + // Refs resolve exactly as a pre-change session would have resolved them. + expect(indexer.getRecord("t1")?.resultText).toBe("listing"); + expect(indexer.getRecord("t2")?.resultText).toBe("file contents"); + expect(indexer.resolveToolCallId("t1")).toBe(occKey("tc1", 110)); + expect(indexer.resolveToolCallId("t2")).toBe(occKey("tc2", 210)); + + // Dedup canonical seeded (non-backfilled legacy index entry -> normal addBatch-equivalent seeding). + expect(indexer.lookupByContent("bash", "listing")).toBe(occKey("tc1", 110)); + expect(indexer.lookupByContent("read", "file contents")).toBe(occKey("tc2", 210)); + }); +}); diff --git a/pi-condense/src/indexer.ts b/pi-condense/src/indexer.ts new file mode 100644 index 0000000..77eaba7 --- /dev/null +++ b/pi-condense/src/indexer.ts @@ -0,0 +1,547 @@ +import type { ExtensionContext } from "@earendil-works/pi-coding-agent"; +import type { + CapturedBatch, + ChainCompressionEntry, + DedupAliasEntryData, + IndexEntryData, + ToolCallRecord, +} from "./types.js"; +import { + CUSTOM_TYPE_CHAIN, + CUSTOM_TYPE_DEDUP_ALIAS, + CUSTOM_TYPE_INDEX, + CUSTOM_TYPE_SUMMARY, +} from "./types.js"; +import { + buildShortToolCallRefs, + normalizeSummaryToolCallRefs, + type SummaryToolCallRef, +} from "./summary-refs.js"; +import { hashToolResult } from "./content-hash.js"; +import { bareToolCallId, occKey, parseOccKey } from "./occurrence-key.js"; +import { mkdir, writeFile } from "node:fs/promises"; +import { applySpill, blobDirFor, blobPathFor } from "./spill.js"; + +export class ToolCallIndexer { + /** occurrence key (`id@resultTimestamp`, or bare id for legacy) -> record */ + private index = new Map(); + /** bare toolCallId -> its occurrence keys, in insertion order */ + private bareIdToKeys = new Map(); + private aliasToToolCallId = new Map(); + private toolCallIdToAlias = new Map(); + private nextShortAliasNumber = 1; + /** + * hash -> original occurrence key (or legacy bare id). Populated as + * records enter the indexer (`addBatch`) and on `reconstructFromSession`. + * Drives the pre-flush dedup pass via `lookupByContent`. + */ + private contentHashToOriginal = new Map(); + /** + * Duplicate occurrence key (or legacy bare id) -> original occurrence key + * (or legacy bare id). Populated by `registerDuplicate` during the + * pre-flush dedup pass and rebuilt from CUSTOM_TYPE_DEDUP_ALIAS entries on + * reconstruction. + * + * Both `isSummarized` and `resolveToolCallId` consult this map so + * `pruneMessages` stub-replaces dup toolResults and `context_tree_query` + * resolves dup ids to the original record. + */ + private dedupAliasToOriginal = new Map(); + /** + * Per-batch summary bodies for chain-compression summary text lookup. + * Each entry maps a set of toolCallIds to the summary's markdown body. + * Populated from CUSTOM_TYPE_SUMMARY entries at rebuild time and via + * `registerSummaryBody` after a successful flush. + */ + private summaryBodies: Array<{ toolCallIds: string[]; text: string }> = []; + /** Compressed chains, keyed on startUserTimestamp for O(1) dedup checks. */ + private chainRegistry = new Map(); + + /** + * Rebuilds the in-memory index from session history by scanning all + * custom entries with customType === CUSTOM_TYPE_INDEX. + */ + reconstructFromSession(ctx: ExtensionContext): void { + this.index.clear(); + this.bareIdToKeys.clear(); + this.aliasToToolCallId.clear(); + this.toolCallIdToAlias.clear(); + this.contentHashToOriginal.clear(); + this.dedupAliasToOriginal.clear(); + this.nextShortAliasNumber = 1; + this.summaryBodies = []; + this.chainRegistry.clear(); + + // Two passes so dedup aliases land AFTER the original short refs they + // need to reuse, regardless of the underlying append order. + const branch = ctx.sessionManager.getBranch(); + const dedupAliasEntries: DedupAliasEntryData[] = []; + + for (const entry of branch) { + if (entry.type === "custom" && (entry as any).customType === CUSTOM_TYPE_INDEX) { + const data = (entry as any).data as IndexEntryData; + const backfilled = data.backfilled === true; + if (data && Array.isArray(data.toolCalls)) { + for (const toolCall of data.toolCalls) { + const key = this.indexRecord(toolCall); + // First-seen wins so the contentHashToOriginal map matches what + // addBatch would have produced at append time. Backfilled records + // never seed this map (dedup poison guard - see backfillChainRecords). + if (!backfilled) { + const hash = toolCall.contentHash ?? hashToolResult(toolCall.toolName, toolCall.resultText); + if (!this.contentHashToOriginal.has(hash)) { + this.contentHashToOriginal.set(hash, key); + } + } + } + } + if (backfilled && Array.isArray(data.refs)) this.registerSummaryRefs(data.refs); + continue; + } + + if (entry.type === "custom_message" && (entry as any).customType === CUSTOM_TYPE_SUMMARY) { + const refs = normalizeSummaryToolCallRefs((entry as any).details); + this.registerSummaryRefs(refs); + const raw = (entry as any).content; + const text = + typeof raw === "string" + ? raw + : Array.isArray(raw) + ? raw + .filter((c: any) => c.type === "text") + .map((c: any) => c.text as string) + .join("\n") + : ""; + if (text) { + this.summaryBodies.push({ toolCallIds: refs.map((r) => occKey(r.toolCallId, r.resultTimestamp)), text }); + } + continue; + } + + if (entry.type === "custom" && (entry as any).customType === CUSTOM_TYPE_CHAIN) { + const data = (entry as any).data as ChainCompressionEntry; + if (data?.blockId && typeof data.startUserTimestamp === "number") { + this.chainRegistry.set(data.startUserTimestamp, data); + } + continue; + } + + if (entry.type === "custom" && (entry as any).customType === CUSTOM_TYPE_DEDUP_ALIAS) { + const data = (entry as any).data as DedupAliasEntryData; + if (data?.newToolCallId && data?.originalToolCallId) { + dedupAliasEntries.push(data); + } + } + } + + for (const data of dedupAliasEntries) { + const newKey = occKey(data.newToolCallId, data.newResultTimestamp); + const originalKey = occKey(data.originalToolCallId, data.originalResultTimestamp); + this.dedupAliasToOriginal.set(newKey, originalKey); + const originalShortRef = this.toolCallIdToAlias.get(originalKey); + if (originalShortRef) { + // Keep `getShortRefForToolCallId(dupId)` returning the SAME short ref + // as the original so pruneMessages emits a consistent `tN` for both. + this.toolCallIdToAlias.set(newKey, originalShortRef); + } + } + } + + /** + * Indexes a single record under its occurrence key and updates the + * bare-id reverse index + legacy-shape tracking. Shared by `addBatch` and + * `reconstructFromSession` so both paths key identically. + */ + private indexRecord(record: ToolCallRecord): string { + const key = occKey(record.toolCallId, record.resultTimestamp); + this.index.set(key, record); + const keys = this.bareIdToKeys.get(record.toolCallId) ?? []; + if (!keys.includes(key)) keys.push(key); + this.bareIdToKeys.set(record.toolCallId, keys); + return key; + } + + /** + * Returns true if the given occurrence key has been pruned - either + * because its full record is in the index, or because it has been + * registered as an alias of an already-indexed original via the + * content-hash dedup pass. + * + * STRICT lookup: no bare-id uniquification happens here, unlike + * `resolveToolCallId`/`getRecord`/`getRecordsForId`. A bare-id fallback + * in this method would be a silent correctness bug - it would report an + * unrelated LIVE tool result as summarized merely because an older + * occurrence of the same provider-reused id was summarized. Callers that + * need bare-id resolution use `resolveToolCallId` (unambiguous case) or + * `getRecordsForId` (all occurrences). + * + * `pruneMessages` uses this to decide whether to stub-replace a + * ToolResultMessage; both index and dedup-alias hits need the same + * treatment. + */ + isSummarized(occurrenceKey: string): boolean { + return this.index.has(occurrenceKey) || this.dedupAliasToOriginal.has(occurrenceKey); + } + + /** + * Returns the full runtime index map. + */ + getIndex(): Map { + return this.index; + } + + /** + * Register short aliases for a summary message so future recovery queries can + * resolve the short ids back to the persisted toolCallIds. + */ + registerSummaryRefs(refs: SummaryToolCallRef[]): void { + for (const ref of refs) { + if (!ref.shortId || !ref.toolCallId) continue; + const key = occKey(ref.toolCallId, ref.resultTimestamp); + if (ref.shortId !== key) { + this.aliasToToolCallId.set(ref.shortId, key); + this.toolCallIdToAlias.set(key, ref.shortId); + } + const match = /^t(\d+)$/.exec(ref.shortId); + if (match) { + this.nextShortAliasNumber = Math.max(this.nextShortAliasNumber, Number(match[1]) + 1); + } + } + } + + /** + * Allocates short aliases for a batch's tool calls and registers them in the + * runtime alias map. + */ + allocateSummaryRefs(batch: CapturedBatch): SummaryToolCallRef[] { + const calls = batch.toolCalls.map((tc) => ({ toolCallId: tc.toolCallId, resultTimestamp: tc.resultTimestamp })); + const { refs, nextIndex } = buildShortToolCallRefs(calls, this.nextShortAliasNumber); + this.nextShortAliasNumber = nextIndex; + return refs; + } + + /** + * Resolve a short alias, a duplicate's occurrence key, or a full occurrence + * key (or legacy bare id) to the canonical occurrence key backing it. + * + * Order: + * 1. Direct hit in `this.index` (canonical occurrence key). + * 2. Dedup alias → underlying original occurrence key. + * 3. Short-ref (`t3`) → underlying occurrence key. + * 4. Bare id with exactly ONE occurrence → that occurrence's key. + * + * A bare id with several occurrences resolves to undefined here — that + * ambiguity is fail-closed by design; callers that must handle collisions + * use `getRecordsForId`. + * + * Used by `getRecord`/`lookupToolCalls` so `context_tree_query` returns + * the original record for both short refs and dedup'd ids. + */ + resolveToolCallId(input: string): string | undefined { + if (this.index.has(input)) return input; + const dedupTarget = this.dedupAliasToOriginal.get(input); + if (dedupTarget) return dedupTarget; + const aliased = this.aliasToToolCallId.get(input); + if (aliased) return aliased; + const keys = this.bareIdToKeys.get(input); + if (keys && keys.length === 1) return keys[0]; + return undefined; + } + + /** + * Returns the short alias (e.g. "t1") registered for the given occurrence + * key (or legacy bare id), or undefined if none was registered. Legacy + * summaries written before short-refs were introduced map shortId === key + * and intentionally return undefined here so callers (e.g. the pruner + * stub) can fall back to the key itself. + */ + getShortRefForToolCallId(occurrenceKey: string): string | undefined { + return this.toolCallIdToAlias.get(occurrenceKey); + } + + /** + * Look up a single record by occurrence key, short alias, or (unambiguous) + * bare id (used by query tool). + */ + getRecord(toolCallIdOrAlias: string): ToolCallRecord | undefined { + const resolved = this.resolveToolCallId(toolCallIdOrAlias); + if (!resolved) return undefined; + return this.index.get(resolved); + } + + /** + * Looks up multiple tool call records by occurrence key / short alias. + * Skips any not found. + */ + lookupToolCalls(toolCallIds: string[]): ToolCallRecord[] { + const results: ToolCallRecord[] = []; + for (const id of toolCallIds) { + const record = this.getRecord(id); + if (record !== undefined) { + results.push(record); + } + } + return results; + } + + /** + * Every record a bare toolCallId, occurrence key, or short ref can denote, + * sorted by `resultTimestamp ?? timestamp` ascending. A bare id with + * multiple occurrences returns all of them; an occurrence key/short ref + * returns exactly the one record it resolves to. + * + * The sort is display order for a multi-match listing, not a causal + * clock: it mixes a tool-result timestamp with a batch-capture timestamp, + * which are both epoch ms from the same session and adequate for a + * listing but not a strict ordering guarantee. + */ + getRecordsForId(input: string): ToolCallRecord[] { + const keys = this.bareIdToKeys.get(input); + const records = (keys ?? []) + .map((k) => this.index.get(k)) + .filter((r): r is ToolCallRecord => r !== undefined); + + // Dedup-alias occurrences aren't tracked in `bareIdToKeys` (it only + // covers indexed records), so a bare id whose collision was content- + // deduplicated would otherwise be silently omitted here. Resolve each + // matching alias to the record it aliases, but label it with the + // ALIAS's own occurrence timestamp (not the original's) so a reader can + // tell the two occurrences apart. + for (const [aliasKey, originalKey] of this.dedupAliasToOriginal) { + if (bareToolCallId(aliasKey) !== input) continue; + const original = this.index.get(originalKey); + if (!original) continue; + const { resultTimestamp } = parseOccKey(aliasKey); + records.push({ ...original, resultTimestamp }); + } + + if (records.length > 0) { + return records.sort((a, b) => (a.resultTimestamp ?? a.timestamp) - (b.resultTimestamp ?? b.timestamp)); + } + const record = this.getRecord(input); + return record ? [record] : []; + } + + /** + * True when the bare id is LEGACY-ONLY: a record is indexed under the + * bare key AND that bare id has no occurrence-keyed siblings. A legacy + * record has no `resultTimestamp`, so `occKey` keys it under its bare id + * - meaning it already lives in `index` under that exact string; this is + * a derivation, not a separate container. + * + * A session that spans the upgrade can hold BOTH a legacy bare record + * and modern occurrence records under the same bare id (e.g. legacy + * `bash_23` plus `bash_23@2150`). In that mixed case this must return + * false: a live, unrelated `bash_23@9150` result is not the legacy one, + * and a permissive true here would stub it with the stale legacy content + * - the exact collision this bare-id path exists to avoid re-introducing. + * `bareIdToKeys` already tracks every key minted under a bare id, so a + * single-key entry is the discriminant. The pruner's only sanctioned + * bare-id path. + */ + hasLegacyBareRecord(toolCallId: string): boolean { + if (!this.index.has(toolCallId)) return false; + const keys = this.bareIdToKeys.get(toolCallId); + return keys !== undefined && keys.length === 1; + } + + /** + * Returns the toolCallId of an already-indexed record whose + * `(toolName, normalize(resultText))` matches the supplied input, or + * `undefined` if there is no match. Driven by the in-memory + * `contentHashToOriginal` map; only consults records that entered the + * indexer via `addBatch` (i.e. previous successful prunes) or were + * replayed at reconstruction time. + * + * Returns `undefined` for hash misses; consumers should treat that as + * "not a duplicate". + */ + lookupByContent(toolName: string, resultText: string): string | undefined { + const hash = hashToolResult(toolName, resultText); + return this.contentHashToOriginal.get(hash); + } + + /** + * Registers `newKey` as a duplicate of `originalKey` (each an occurrence + * key, or a legacy bare id). The new id reuses the original's short alias + * (so `pruneMessages` emits the same `tN` ref for both) and is persisted + * via the supplied `appendEntry` so reconstruction can replay it later. + * + * No-op when `newKey === originalKey` (defensive). + */ + registerDuplicate( + newKey: string, + originalKey: string, + appendEntry: (customType: string, data?: unknown) => void, + ): void { + if (newKey === originalKey) return; + this.dedupAliasToOriginal.set(newKey, originalKey); + const originalShortRef = this.toolCallIdToAlias.get(originalKey); + if (originalShortRef) { + this.toolCallIdToAlias.set(newKey, originalShortRef); + } + const { toolCallId: newToolCallId, resultTimestamp: newResultTimestamp } = parseOccKey(newKey); + const { toolCallId: originalToolCallId, resultTimestamp: originalResultTimestamp } = parseOccKey(originalKey); + const payload: DedupAliasEntryData = { + newToolCallId, + originalToolCallId, + ...(newResultTimestamp !== undefined ? { newResultTimestamp } : {}), + ...(originalResultTimestamp !== undefined ? { originalResultTimestamp } : {}), + }; + appendEntry(CUSTOM_TYPE_DEDUP_ALIAS, payload); + } + + /** + * Stores summary body text keyed by the toolCallIds it covers. + * Called after a successful flush so `getPerBatchSummaryTextForToolCallIds` + * can serve chain summaries without re-scanning session entries. + */ + registerSummaryBody(toolCallIds: string[], text: string): void { + if (text && toolCallIds.length > 0) { + this.summaryBodies.push({ toolCallIds, text }); + } + } + + /** Returns true if at least one stored summary covers any of the given toolCallIds. */ + hasPerBatchSummaryCoveringAny(toolCallIds: string[]): boolean { + if (toolCallIds.length === 0) return false; + const idSet = new Set(toolCallIds); + return this.summaryBodies.some((s) => s.toolCallIds.some((id) => idSet.has(id))); + } + + /** + * Returns the distinct per-batch summary texts whose toolCallIds overlap the + * given set (dedup'd by text). Used to build the synthetic chain body and as + * the fusion input for the range summarizer; the >= 2 count gates fusion. + */ + getPerBatchSummariesForToolCallIds(toolCallIds: string[]): string[] { + if (toolCallIds.length === 0) return []; + const idSet = new Set(toolCallIds); + const texts: string[] = []; + const seen = new Set(); + for (const s of this.summaryBodies) { + if (s.toolCallIds.some((id) => idSet.has(id)) && !seen.has(s.text)) { + seen.add(s.text); + texts.push(s.text); + } + } + return texts; + } + + /** + * Returns the concatenated summary text for all per-batch summaries whose + * toolCallIds overlap the given set, joined with "\n\n". + * Used by chain-range-prune to build the synthetic chain message body. + */ + getPerBatchSummaryTextForToolCallIds(toolCallIds: string[]): string { + return this.getPerBatchSummariesForToolCallIds(toolCallIds).join("\n\n"); + } + + /** + * Returns the short t refs for the given toolCallIds. + * Skips ids with no registered short ref (tool calls not yet summarized). + */ + getToolRefsForToolCallIds(toolCallIds: string[]): string[] { + const refs: string[] = []; + for (const id of toolCallIds) { + const ref = this.toolCallIdToAlias.get(id); + if (ref) refs.push(ref); + } + return refs; + } + + /** Registers a chain entry in the in-memory registry. Called by chain-compressor after persisting. */ + registerChain(entry: ChainCompressionEntry): void { + this.chainRegistry.set(entry.startUserTimestamp, entry); + } + + /** Returns all compressed chain entries sorted by startUserTimestamp ascending. */ + getChainEntries(): ChainCompressionEntry[] { + return [...this.chainRegistry.values()].sort((a, b) => a.startUserTimestamp - b.startUserTimestamp); + } + + /** O(n) scan over the chain registry by blockId. Registry is small (bounded by session chain count). */ + findChainEntryByBlockId(blockId: string): ChainCompressionEntry | undefined { + for (const entry of this.chainRegistry.values()) { + if (entry.blockId === blockId) return entry; + } + return undefined; + } + + /** + * Adds all tool calls from a captured batch to the runtime index and + * persists an IndexEntryData entry to the session via the supplied + * appendEntry callback. The callback exists so callers can route the + * append through either `pi.appendEntry` (runtime delivery) or + * `ctx.sessionManager.appendCustomEntry` (session delivery), without the + * indexer needing to know which one is active. + */ + addBatch( + batch: CapturedBatch, + appendEntry: (customType: string, data?: unknown) => void, + ): void { + const records: ToolCallRecord[] = []; + + for (const tc of batch.toolCalls) { + const record: ToolCallRecord = { + toolCallId: tc.toolCallId, + toolName: tc.toolName, + args: tc.args, + resultText: tc.resultText, + isError: tc.isError, + turnIndex: batch.turnIndex, + timestamp: batch.timestamp, + ...(tc.resultTimestamp !== undefined ? { resultTimestamp: tc.resultTimestamp } : {}), + ...(tc.spillPath !== undefined ? { spillPath: tc.spillPath } : {}), + ...(tc.spillBytes !== undefined ? { spillBytes: tc.spillBytes } : {}), + ...(tc.resultPreview !== undefined ? { resultPreview: tc.resultPreview } : {}), + ...(tc.contentHash !== undefined ? { contentHash: tc.contentHash } : {}), + }; + const key = this.indexRecord(record); + records.push(record); + // Populate the dedup hash map AFTER the record is indexed so a future + // flush can dedup against this record. First-seen wins to keep the + // canonical id stable across multiple identical entries. + const hash = record.contentHash ?? hashToolResult(record.toolName, record.resultText); + if (!this.contentHashToOriginal.has(hash)) { + this.contentHashToOriginal.set(hash, key); + } + } + + appendEntry(CUSTOM_TYPE_INDEX, { toolCalls: records } as IndexEntryData); + } + + /** + * Atomic recoverability backfill for an uncovered chain (spec + * 2026-08-14-uncovered-chain-deterministic-backfill). Append-before-commit: + * in-memory maps are touched only after the index entry persisted. Records + * never seed contentHashToOriginal (dedup poison guard). Refs ride the + * entry so they survive session restart without a summary message. + */ + async backfillChainRecords( + records: ToolCallRecord[], + opts: { + spillThreshold: number; + spillPreviewBytes: number; + sessionDir: string; + sessionId: string; + appendEntry: (customType: string, data?: unknown) => void; + }, + ): Promise { + for (const r of records) { + if (r.resultText.length < opts.spillThreshold) continue; + const key = occKey(r.toolCallId, r.resultTimestamp); + const path = blobPathFor(opts.sessionDir, opts.sessionId, key); + await mkdir(blobDirFor(opts.sessionDir, opts.sessionId), { recursive: true }); + await writeFile(path, r.resultText, "utf-8"); // throw = abort backfill (fail-closed) + applySpill(r, path, opts.spillPreviewBytes); + } + const calls = records.map((r) => ({ toolCallId: r.toolCallId, resultTimestamp: r.resultTimestamp })); + const { refs, nextIndex } = buildShortToolCallRefs(calls, this.nextShortAliasNumber); + this.nextShortAliasNumber = nextIndex; // burned numbers on failure are acceptable (monotonic, opaque) + opts.appendEntry(CUSTOM_TYPE_INDEX, { toolCalls: records, backfilled: true, refs } satisfies IndexEntryData); + for (const r of records) this.indexRecord(r); + this.registerSummaryRefs(refs); + return refs; + } +} diff --git a/pi-condense/src/nested-placeholders.test.ts b/pi-condense/src/nested-placeholders.test.ts new file mode 100644 index 0000000..4da4578 --- /dev/null +++ b/pi-condense/src/nested-placeholders.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, test } from "bun:test"; +import { substituteBlockRefs } from "./nested-placeholders.js"; + +const lookup = (id: string): string | undefined => { + const map: Record = { + b1: "summary of chain one", + b2: "summary of chain two", + b3: "chain three with {b2} inside", + }; + return map[id]; +}; + +describe("substituteBlockRefs", () => { + test("empty text returns empty string", () => { + expect(substituteBlockRefs("", lookup)).toBe(""); + }); + + test("text with no placeholders is unchanged", () => { + const text = "no placeholders here, just prose"; + expect(substituteBlockRefs(text, lookup)).toBe(text); + }); + + test("single {b1} is substituted", () => { + expect(substituteBlockRefs("{b1}", lookup)).toBe("summary of chain one"); + }); + + test("multiple references in one text are all resolved", () => { + expect(substituteBlockRefs("{b1} and {b2}", lookup)).toBe( + "summary of chain one and summary of chain two", + ); + }); + + test("missing blockId leaves the literal {bN} in place", () => { + expect(substituteBlockRefs("{b99}", lookup)).toBe("{b99}"); + }); + + test("missing blockId among valid ones leaves only the unknown one", () => { + expect(substituteBlockRefs("{b1} and {b99}", lookup)).toBe("summary of chain one and {b99}"); + }); + + test("self-reference is refused even when lookup has a value", () => { + // b3 is in the lookup, but selfBlockId=b3 means it must stay literal + expect(substituteBlockRefs("{b3} foo", lookup, { selfBlockId: "b3" })).toBe("{b3} foo"); + }); + + test("self-reference refused, other refs still substituted", () => { + expect(substituteBlockRefs("{b3} and {b1}", lookup, { selfBlockId: "b3" })).toBe( + "{b3} and summary of chain one", + ); + }); + + test("one-level only: substituted text containing {b2} is NOT re-expanded", () => { + // b3 → "chain three with {b2} inside" + // The {b2} inside that expansion must survive as a literal in the output. + const result = substituteBlockRefs("{b3}", lookup); + expect(result).toBe("chain three with {b2} inside"); + // Explicitly: {b2} in the output is the literal string, not "summary of chain two" + expect(result).not.toContain("summary of chain two"); + }); + + test("adjacent placeholders handled cleanly", () => { + expect(substituteBlockRefs("{b1}{b2}", lookup)).toBe( + "summary of chain onesummary of chain two", + ); + }); + + test("placeholders embedded in surrounding prose", () => { + expect(substituteBlockRefs("Before {b1} middle {b2} after", lookup)).toBe( + "Before summary of chain one middle summary of chain two after", + ); + }); + + test("lookup returning undefined leaves placeholder literal (null-safe contract)", () => { + // `resolved ?? match` only falls through to the literal when lookup returns undefined. + // pruner.ts collapses empty-string summary bodies to undefined before calling here + // (`|| undefined`), so this test pins the undefined-→-literal path that the + // pruner relies on to avoid silently erasing placeholders when summary bodies are missing. + const missingLookup = (_: string): string | undefined => undefined; + expect(substituteBlockRefs("{b1}", missingLookup)).toBe("{b1}"); + expect(substituteBlockRefs("{b1} text", missingLookup)).toBe("{b1} text"); + }); +}); diff --git a/pi-condense/src/nested-placeholders.ts b/pi-condense/src/nested-placeholders.ts new file mode 100644 index 0000000..a44a7c9 --- /dev/null +++ b/pi-condense/src/nested-placeholders.ts @@ -0,0 +1,20 @@ +// One-level substitution only: the replaced text is not re-scanned. +// This is intentional — if summary B contains "{b1}", that placeholder +// was already resolved (or left literal) when B was originally generated. +// Re-scanning would require cycle detection across an arbitrary graph and +// could silently expand stale content if the session is replayed later. +const BLOCK_REF_RE = /\{b(\d+)\}/g; + +export function substituteBlockRefs( + text: string, + blockSummaryLookup: (blockId: string) => string | undefined, + options?: { selfBlockId?: string }, +): string { + const selfBlockId = options?.selfBlockId; + return text.replace(BLOCK_REF_RE, (match, digits) => { + const blockId = `b${digits}`; + if (blockId === selfBlockId) return match; + const resolved = blockSummaryLookup(blockId); + return resolved ?? match; + }); +} diff --git a/pi-condense/src/occurrence-key.test.ts b/pi-condense/src/occurrence-key.test.ts new file mode 100644 index 0000000..f50e712 --- /dev/null +++ b/pi-condense/src/occurrence-key.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, test } from "bun:test"; +import { occKey, parseOccKey, bareToolCallId, resultTimestampOf } from "./occurrence-key.js"; + +describe("occKey", () => { + test("joins id and timestamp", () => { + expect(occKey("bash_23", 1700)).toBe("bash_23@1700"); + }); + + test("returns the bare id when the timestamp is absent (legacy shape)", () => { + expect(occKey("bash_23", undefined)).toBe("bash_23"); + }); + + test("round-trips through parseOccKey", () => { + expect(parseOccKey("bash_23@1700")).toEqual({ toolCallId: "bash_23", resultTimestamp: 1700 }); + }); + + test("treats a key with no separator as a bare id", () => { + expect(parseOccKey("bash_23")).toEqual({ toolCallId: "bash_23" }); + expect("resultTimestamp" in parseOccKey("bash_23")).toBe(false); + }); + + test("treats a trailing non-numeric segment as part of a bare id", () => { + // github-copilot ids embed base64 payloads after a '|' and may contain '@' + expect(parseOccKey("call_abc@sha")).toEqual({ toolCallId: "call_abc@sha" }); + }); + + test("splits on the LAST separator so ids containing '@' survive", () => { + expect(parseOccKey("call@abc@1700")).toEqual({ toolCallId: "call@abc", resultTimestamp: 1700 }); + }); + + test("bareToolCallId strips the occurrence suffix", () => { + expect(bareToolCallId("bash_23@1700")).toBe("bash_23"); + expect(bareToolCallId("bash_23")).toBe("bash_23"); + }); +}); + +describe("resultTimestampOf", () => { + test("passes a number through", () => { + expect(resultTimestampOf(1700)).toBe(1700); + }); + + test("returns undefined for undefined", () => { + expect(resultTimestampOf(undefined)).toBeUndefined(); + }); + + test("returns undefined for a string", () => { + expect(resultTimestampOf("1700")).toBeUndefined(); + }); + + test("returns undefined for null", () => { + expect(resultTimestampOf(null)).toBeUndefined(); + }); + + test("passes NaN through (typeof NaN === 'number'; a NaN timestamp cannot arise from pi's typed ToolResultMessage.timestamp)", () => { + expect(resultTimestampOf(NaN)).toBeNaN(); + }); +}); diff --git a/pi-condense/src/occurrence-key.ts b/pi-condense/src/occurrence-key.ts new file mode 100644 index 0000000..ac4b4e8 --- /dev/null +++ b/pi-condense/src/occurrence-key.ts @@ -0,0 +1,36 @@ +/** + * Provider tool-call ids are unique only within one response (see + * doc/specs/2026-08-12-toolcall-id-collisions.md), so bare ids cannot key + * session-durable records. The ToolResultMessage timestamp is the one + * discriminant readable identically at capture time and at render time. + * + * A key with no parsable numeric suffix IS a bare id - that is the legacy + * shape for records persisted before this field existed. + */ +const SEP = "@"; + +export function occKey(toolCallId: string, resultTimestamp?: number): string { + return resultTimestamp === undefined ? toolCallId : `${toolCallId}${SEP}${resultTimestamp}`; +} + +/** + * Narrows an untrusted value to the numeric timestamp discriminant, or + * `undefined` if it isn't a number. Shared by every ingress point that reads + * a ToolResultMessage-shaped `.timestamp` off data of uncertain provenance + * (live turn events, session JSON, summary details JSON). + */ +export function resultTimestampOf(value: unknown): number | undefined { + return typeof value === "number" ? value : undefined; +} + +export function parseOccKey(key: string): { toolCallId: string; resultTimestamp?: number } { + const i = key.lastIndexOf(SEP); + if (i <= 0) return { toolCallId: key }; + const suffix = key.slice(i + 1); + if (!/^\d+$/.test(suffix)) return { toolCallId: key }; + return { toolCallId: key.slice(0, i), resultTimestamp: Number(suffix) }; +} + +export function bareToolCallId(key: string): string { + return parseOccKey(key).toolCallId; +} diff --git a/pi-condense/src/orphan-sweep.test.ts b/pi-condense/src/orphan-sweep.test.ts new file mode 100644 index 0000000..ec5b8bf --- /dev/null +++ b/pi-condense/src/orphan-sweep.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, test } from "bun:test"; +import { sweepOrphanToolResults } from "./orphan-sweep.js"; +import { expectNoOrphanToolResults } from "./test-support.js"; + +const asst = (ts: number, ids: string[]) => ({ + role: "assistant", + content: ids.map((id) => ({ type: "toolCall", id, name: "bash", input: {} })), + timestamp: ts, +}); +const res = (ts: number, id: string) => ({ + role: "toolResult", + toolCallId: id, + toolName: "bash", + content: [{ type: "text", text: "ok" }], + isError: false, + timestamp: ts, +}); +const user = (ts: number) => ({ role: "user", content: [{ type: "text", text: "go" }], timestamp: ts }); + +describe("sweepOrphanToolResults", () => { + test("returns the SAME array reference when there is no orphan", () => { + const msgs = [user(1), asst(2, ["a"]), res(3, "a")]; + const out = sweepOrphanToolResults(msgs); + expect(out.messages).toBe(msgs); + expect(out.sweptIds).toEqual([]); + }); + + test("removes a toolResult whose call was never opened", () => { + const msgs = [user(1), asst(2, ["a"]), res(3, "a"), res(4, "ghost")]; + const out = sweepOrphanToolResults(msgs); + expect(out.sweptIds).toEqual(["ghost"]); + expect(out.messages).toHaveLength(3); + expect(out.messages.some((m: any) => m.toolCallId === "ghost")).toBe(false); + }); + + test("open-call tracking is per turn: a validly used id does not license a later orphan", () => { + // 'a' is opened and consumed in turn 1; the later 'a' result has no opener + const msgs = [user(1), asst(2, ["a"]), res(3, "a"), asst(4, ["b"]), res(5, "b"), res(6, "a")]; + const out = sweepOrphanToolResults(msgs); + expect(out.sweptIds).toEqual(["a"]); + expect(out.messages).toHaveLength(5); + }); + + test("legitimate reuse of the same bare id across turns is kept, not swept", () => { + const msgs = [asst(1, ["a"]), res(2, "a"), asst(3, ["a"]), res(4, "a")]; + const out = sweepOrphanToolResults(msgs); + expect(out.sweptIds).toEqual([]); + expect(out.messages).toBe(msgs); + expect(out.messages).toHaveLength(4); + }); + + test("a duplicate result for one call is swept (id consumed once)", () => { + const msgs = [asst(1, ["a"]), res(2, "a"), res(3, "a")]; + const out = sweepOrphanToolResults(msgs); + expect(out.sweptIds).toEqual(["a"]); + expect(out.messages).toHaveLength(2); + }); + + test("keeps results for every id of a multi-call assistant turn", () => { + const msgs = [asst(1, ["a", "b"]), res(2, "a"), res(3, "b")]; + expect(sweepOrphanToolResults(msgs).messages).toBe(msgs); + }); + + // AC1: any non-assistant/non-toolResult role is a barrier - including an + // unknown role, so a role-allowlist implementation cannot pass. + const barrierRoles: Array<[string, any]> = [ + ["custom", { role: "custom", customType: "x", timestamp: 2 }], + ["user", user(2)], + ["branchSummary", { role: "branchSummary", timestamp: 2 }], + ["compactionSummary", { role: "compactionSummary", timestamp: 2 }], + ["bashExecution", { role: "bashExecution", timestamp: 2 }], + ["unknown future role", { role: "future-role", timestamp: 2 }], + ]; + for (const [name, barrier] of barrierRoles) { + test(`a ${name} message between a call and its result is a barrier: the result is swept`, () => { + const msgs = [asst(1, ["a"]), barrier, res(3, "a")]; + const out = sweepOrphanToolResults(msgs); + expect(out.sweptIds).toEqual(["a"]); + expect(out.messages).toHaveLength(2); + expect(out.messages.some((m: any) => m.role === "toolResult")).toBe(false); + }); + } + + // AC2: barrier clears only what is still open; results consumed before it stay. + test("multi-call turn: barrier sweeps only the not-yet-consumed result", () => { + const msgs = [asst(1, ["a", "b"]), res(2, "a"), { role: "custom", customType: "x", timestamp: 3 }, res(4, "b")]; + const out = sweepOrphanToolResults(msgs); + expect(out.sweptIds).toEqual(["b"]); + expect(out.messages).toHaveLength(3); + }); + + // AC3: a barrier after a completed cycle is untouched - same array reference. + test("trailing barrier after a completed cycle is a no-op (same array reference)", () => { + const msgs = [asst(1, ["a"]), res(2, "a"), { role: "custom", customType: "x", timestamp: 3 }]; + const out = sweepOrphanToolResults(msgs); + expect(out.messages).toBe(msgs); + expect(out.sweptIds).toEqual([]); + }); + + // AC5: the test helper enforces the same barrier rule (it copies the sweep's + // orphan definition; delegation would make sweep tests tautological). + test("expectNoOrphanToolResults throws on a mid-cycle barrier orphan and passes on a clean trailing barrier", () => { + const bad = [asst(1, ["a"]), { role: "custom", customType: "x", timestamp: 2 }, res(3, "a")]; + expect(() => expectNoOrphanToolResults(bad)).toThrow(); + expectNoOrphanToolResults([asst(1, ["a"]), res(2, "a"), { role: "custom", customType: "x", timestamp: 3 }]); + }); +}); diff --git a/pi-condense/src/orphan-sweep.ts b/pi-condense/src/orphan-sweep.ts new file mode 100644 index 0000000..a6c8b58 --- /dev/null +++ b/pi-condense/src/orphan-sweep.ts @@ -0,0 +1,50 @@ +/** + * pi-ai repairs orphan tool calls only; providers reject orphan tool results. + * + * Open-call tracking is PER TURN: an assistant message replaces the open set + * with its own toolCall ids, and any message that is neither assistant nor + * toolResult is a barrier that clears it (matching where pi-ai flushes + * synthetic tool results). A cumulative seen-set would let an id used + * validly in an early turn license a later genuine orphan - exactly the + * id-collision case this exists for. + * + * Returns the input array reference when nothing is swept, preserving the + * no-op / prompt-cache-prefix invariant of + * doc/specs/2026-08-04-pruner-noop-serialization.md. + */ +export function sweepOrphanToolResults(messages: any[]): { messages: any[]; sweptIds: string[] } { + let open = new Set(); + const orphanIndices = new Set(); + const sweptIds: string[] = []; + + for (let i = 0; i < messages.length; i++) { + const msg = messages[i]; + if (msg.role === "assistant") { + open = new Set( + (msg.content ?? []) + .filter((c: any) => c.type === "toolCall") + .map((c: any) => c.id as string), + ); + continue; + } + if (msg.role === "toolResult") { + if (open.has(msg.toolCallId)) { + open.delete(msg.toolCallId); + } else { + orphanIndices.add(i); + sweptIds.push(msg.toolCallId); + } + continue; + } + // Barrier: any other role converts to a user-boundary at the provider + // (convertToLlm maps custom/branchSummary/compactionSummary/bashExecution + // to role "user"; pi-ai flushes synthetic tool results there), so a + // still-open call can no longer be legally answered after it. Unknown + // roles and excludeFromContext bashExecutions are a deliberate + // conservative over-sweep - no allowlist. + open = new Set(); + } + + if (orphanIndices.size === 0) return { messages, sweptIds: [] }; + return { messages: messages.filter((_, i) => !orphanIndices.has(i)), sweptIds }; +} diff --git a/pi-condense/src/oversized-spill.integration.test.ts b/pi-condense/src/oversized-spill.integration.test.ts new file mode 100644 index 0000000..67c3b5b --- /dev/null +++ b/pi-condense/src/oversized-spill.integration.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect } from "bun:test"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { ToolCallIndexer } from "./indexer.js"; +import { spillOversizedBatch, blobPathFor } from "./spill.js"; +import { pruneMessages } from "./pruner.js"; +import type { CapturedBatch } from "./types.js"; +import { CUSTOM_TYPE_INDEX } from "./types.js"; + +const cfg = { spillThreshold: 10, spillPreviewBytes: 16, dedupByContentHash: true }; +const batch = (tc: any): CapturedBatch => ({ turnIndex: 0, timestamp: 1, assistantText: "", toolCalls: [tc] }); + +describe("oversized spill end-to-end", () => { + it("spills, stubs in context, keeps full body on disk, survives reconstruct", async () => { + const dir = await mkdtemp(join(tmpdir(), "spill-e2e-")); + try { + const indexer = new ToolCallIndexer(); + const entries: any[] = []; + const appendEntry = (customType: string, data?: unknown) => { + entries.push({ type: "custom", customType, data }); + }; + + const body = "BIG\n".repeat(1000); + await spillOversizedBatch({ + batch: batch({ toolCallId: "tc1", toolName: "fetch", args: { url: "u" }, resultText: body, isError: false }), + indexer, + config: cfg, + sessionDir: dir, + sessionId: "sid", + appendEntry, + }); + + // (a) full body on disk + expect(await readFile(blobPathFor(dir, "sid", "tc1"), "utf-8")).toBe(body); + + // (b) persisted index entry has spillPath + preview, NOT the full body + const idxEntry = entries.find((e) => e.customType === CUSTOM_TYPE_INDEX); + expect(idxEntry).toBeTruthy(); + const persisted = idxEntry.data.toolCalls[0]; + expect(persisted.spillPath).toBe(blobPathFor(dir, "sid", "tc1")); + expect(persisted.resultText).toBe(""); + expect(persisted.resultPreview.length).toBeGreaterThan(0); + expect(persisted.contentHash).toBeTruthy(); + + // (c) pruneMessages emits the mechanical spill stub (no summary, no LLM) + const msgs = [ + { + role: "assistant", + content: [{ type: "toolCall", id: "tc1", name: "fetch", input: {} }], + timestamp: 0, + }, + { + role: "toolResult", + toolCallId: "tc1", + toolName: "fetch", + content: [{ type: "text", text: body }], + isError: false, + timestamp: 1, + }, + ]; + const { messages: out, pruned } = pruneMessages(msgs as any, indexer); + expect(pruned).toBe(true); + expect((out[1] as any).content[0].text).toContain(blobPathFor(dir, "sid", "tc1")); + expect((out[1] as any).content[0].text).not.toContain("Summarized in pruner summary"); + + // (d) reconstruct from the persisted entries: record still resolves, hash intact + const indexer2 = new ToolCallIndexer(); + const fakeCtx = { sessionManager: { getBranch: () => entries } } as any; + indexer2.reconstructFromSession(fakeCtx); + const rec = indexer2.getRecord("tc1"); + expect(rec?.spillPath).toBe(blobPathFor(dir, "sid", "tc1")); + expect(indexer2.lookupByContent("fetch", body)).toBe("tc1"); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/pi-condense/src/protected.test.ts b/pi-condense/src/protected.test.ts new file mode 100644 index 0000000..d7eaef7 --- /dev/null +++ b/pi-condense/src/protected.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, test } from "bun:test"; +import { globToRegExp, isProtected } from "./protected.js"; + +describe("globToRegExp", () => { + test("** crosses path segments", () => { + expect(globToRegExp("**/skills/**/*.md").test("/Users/x/.agents/skills/release/SKILL.md")).toBe(true); + expect(globToRegExp("**/skills/**/*.md").test("a/skills/b/c/notes.md")).toBe(true); + }); + + test("**/ matches zero directories", () => { + expect(globToRegExp("**/SKILL.md").test("SKILL.md")).toBe(true); + expect(globToRegExp("**/skills/**/*.md").test("skills/foo.md")).toBe(true); + }); + + test("* and ? are segment-local", () => { + expect(globToRegExp("skills/*.md").test("skills/a/b.md")).toBe(false); + expect(globToRegExp("skills/?.md").test("skills/a.md")).toBe(true); + expect(globToRegExp("skills/?.md").test("skills//x.md")).toBe(false); + }); + + test("regex metacharacters in pattern are literals", () => { + expect(globToRegExp("a+b/(c).md").test("a+b/(c).md")).toBe(true); + expect(globToRegExp("a.md").test("aXmd")).toBe(false); + }); + + test("full-path anchored match", () => { + expect(globToRegExp("skills/a.md").test("xskills/a.md")).toBe(false); + expect(globToRegExp("skills/a.md").test("skills/a.md.bak")).toBe(false); + }); + + test("case-sensitive", () => { + expect(globToRegExp("**/SKILL.md").test("a/skill.md")).toBe(false); + }); +}); + +describe("isProtected", () => { + const cfg = { protectedTools: ["todowrite"], protectedPaths: ["**/skills/**/*.md"] }; + + test("matches by tool name regardless of args", () => { + expect(isProtected("todowrite", undefined, cfg)).toBe(true); + }); + + test("matches by path glob", () => { + expect(isProtected("read", { path: "/h/skills/x/SKILL.md" }, cfg)).toBe(true); + expect(isProtected("read", { path: "/h/src/app.ts" }, cfg)).toBe(false); + }); + + test("backslashes normalized to forward slashes", () => { + expect(isProtected("read", { path: "h\\skills\\x\\SKILL.md" }, cfg)).toBe(true); + }); + + test("non-string / missing path is not protected", () => { + expect(isProtected("read", { path: 42 }, cfg)).toBe(false); + expect(isProtected("read", {}, cfg)).toBe(false); + expect(isProtected("read", undefined, cfg)).toBe(false); + expect(isProtected("read", null, cfg)).toBe(false); + }); + + test("empty config protects nothing", () => { + expect(isProtected("read", { path: "skills/a/SKILL.md" }, { protectedTools: [], protectedPaths: [] })).toBe(false); + }); +}); diff --git a/pi-condense/src/protected.ts b/pi-condense/src/protected.ts new file mode 100644 index 0000000..4b6fd63 --- /dev/null +++ b/pi-condense/src/protected.ts @@ -0,0 +1,51 @@ +/** Structural pick of ContextPruneConfig — keeps this module dependency-free. */ +export interface ProtectionConfig { + protectedTools: readonly string[]; + protectedPaths: readonly string[]; +} + +// Compile-once: pattern -> RegExp is pure, so the cache never needs +// invalidation. Patterns only come from config arrays, so growth is bounded. +const patternCache = new Map(); + +export function globToRegExp(pattern: string): RegExp { + const cached = patternCache.get(pattern); + if (cached) return cached; + let re = ""; + let i = 0; + while (i < pattern.length) { + const ch = pattern[i]; + if (ch === "*") { + if (pattern[i + 1] === "*") { + if (pattern[i + 2] === "/") { + re += "(?:[^/]*/)*"; // `**/` — zero or more whole directories + i += 3; + } else { + re += ".*"; // bare `**` + i += 2; + } + } else { + re += "[^/]*"; // `*` — segment-local + i += 1; + } + } else if (ch === "?") { + re += "[^/]"; + i += 1; + } else { + re += ch.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + i += 1; + } + } + const compiled = new RegExp(`^${re}$`); + patternCache.set(pattern, compiled); + return compiled; +} + +export function isProtected(toolName: string, args: unknown, config: ProtectionConfig): boolean { + if (config.protectedTools.includes(toolName)) return true; + if (config.protectedPaths.length === 0) return false; + const path = (args as Record | null | undefined)?.path; + if (typeof path !== "string") return false; + const normalized = path.replace(/\\/g, "/"); + return config.protectedPaths.some((p) => globToRegExp(p).test(normalized)); +} diff --git a/pi-condense/src/pruner.test.ts b/pi-condense/src/pruner.test.ts new file mode 100644 index 0000000..095e55d --- /dev/null +++ b/pi-condense/src/pruner.test.ts @@ -0,0 +1,1055 @@ +import { describe, expect, it } from "bun:test"; +import { pruneMessages, sizeMessages } from "./pruner.js"; +import { ToolCallIndexer } from "./indexer.js"; +import { CUSTOM_TYPE_INDEX } from "./types.js"; +import type { ChainCompressionConfig, ChainCompressionEntry } from "./types.js"; +import { DiagnosticSink } from "./diagnostics.js"; +import { pruneWithZeroSweepAssertion } from "./test-support.js"; + +// Minimal mock exposing only the ToolCallIndexer surface that pruneMessages calls. +// `hasLegacyBareRecord` defaults to the bare `summarized` set: most of the fixture +// messages in this file carry no occurrence-keyed records, so the fail-closed lookup +// in pruneMessages falls through to this legacy-bare-id path for them, matching how +// pre-upgrade sessions behave (that path must stay covered - it is a supported +// shape). The "occurrence-keyed coverage" describe block below seeds `summarized` / +// `records` / `shortRefs` with `id@timestamp`-shaped keys instead, so those tests +// hit `isSummarized(key)` directly - the occurrence branch of the ladder - rather +// than falling through to `hasLegacyBareRecord`. +function makeMockIndexer({ + summarized = new Set(), + shortRefs = new Map(), + chainEntries = [] as ChainCompressionEntry[], + summaryBodyMap = new Map(), + records = new Map(), +}: { + summarized?: Set; + shortRefs?: Map; + chainEntries?: ChainCompressionEntry[]; + summaryBodyMap?: Map; + records?: Map; +} = {}) { + return { + isSummarized: (id: string) => summarized.has(id), + hasLegacyBareRecord: (id: string) => summarized.has(id), + getShortRefForToolCallId: (id: string) => shortRefs.get(id), + getRecord: (id: string) => records.get(id), + getChainEntries: () => chainEntries, + getPerBatchSummaryTextForToolCallIds: (ids: string[]) => { + for (const id of ids) { + const text = summaryBodyMap.get(id); + if (text) return text; + } + return ""; + }, + } as any; +} + +const enabledCC: ChainCompressionConfig = { + enabled: true, + rollingWindow: 0, + stripFinalAssistantThinking: true, + fuseRangeSummary: false, +}; + +describe("pruneMessages", () => { + it("stub-replaces a summarized tool result", () => { + const indexer = makeMockIndexer({ + summarized: new Set(["tc1"]), + shortRefs: new Map([["tc1", "t1"]]), + }); + const messages = [ + { role: "assistant", content: [{ type: "toolCall", id: "tc1", name: "bash", input: {} }], timestamp: 0 }, + { + role: "toolResult", + toolCallId: "tc1", + toolName: "bash", + content: [{ type: "text", text: "big output" }], + isError: false, + timestamp: 1, + }, + ]; + const { messages: out, pruned } = pruneMessages(messages, indexer); + expect(pruned).toBe(true); + expect(out[1].content[0].text).toContain("`t1`"); + expect(out[1].content[0].text).toContain("context_tree_query"); + }); + + it("returns original array reference when nothing is summarized or compressed", () => { + const indexer = makeMockIndexer(); + const messages = [{ role: "user", content: "hello", timestamp: 1 }]; + const { messages: out, pruned } = pruneMessages(messages, indexer, enabledCC); + expect(pruned).toBe(false); + expect(out).toBe(messages); + }); + + it("applies chain compression after stub-replace", () => { + const toolCallId = "tc-mid"; + const chainEntry: ChainCompressionEntry = { + blockId: "b1", + startUserTimestamp: 100, + droppedToolCallIds: [toolCallId], + finalAssistantTimestamp: 300, + toolRefs: ["t1"], + compressedAt: 999, + }; + const summaryText = "ran bash, got results"; + const indexer = makeMockIndexer({ + summarized: new Set([toolCallId]), + shortRefs: new Map([[toolCallId, "t1"]]), + chainEntries: [chainEntry], + summaryBodyMap: new Map([[toolCallId, summaryText]]), + }); + + const messages: any[] = [ + { role: "user", content: [{ type: "text", text: "do it" }], timestamp: 100 }, + { + role: "assistant", + content: [{ type: "toolCall", id: toolCallId, name: "bash", arguments: {} }], + timestamp: 200, + api: "anthropic", + provider: "anthropic", + model: "x", + usage: {}, + stopReason: "tool_use", + }, + { + role: "toolResult", + toolCallId, + toolName: "bash", + content: [{ type: "text", text: "output" }], + isError: false, + timestamp: 210, + }, + { + role: "assistant", + content: [{ type: "text", text: "done" }], + timestamp: 300, + api: "anthropic", + provider: "anthropic", + model: "x", + usage: {}, + stopReason: "end_turn", + }, + ]; + + const { messages: out, pruned } = pruneMessages(messages, indexer, enabledCC); + expect(pruned).toBe(true); + + // Middle assistant + toolResult are dropped + const roles = out.map((m: any) => m.role); + expect(roles.filter((r: string) => r === "toolResult")).toHaveLength(0); + + // Synthetic chain message injected after the start user message + const synthetic = out.find( + (m: any) => + m.role === "user" && typeof m.content?.[0]?.text === "string" && m.content[0].text.startsWith(" m.role === "user" && m.timestamp === 100); + expect(startUser).toBeDefined(); + + // Final assistant kept (no thinking block to strip here) + const finalAsst = out.find((m: any) => m.role === "assistant" && m.timestamp === 300); + expect(finalAsst).toBeDefined(); + + // Ordering: start user → synthetic → final assistant + const startIdx = out.indexOf(startUser); + const synthIdx = out.indexOf(synthetic); + const finalIdx = out.indexOf(finalAsst); + expect(startIdx).toBeLessThan(synthIdx); + expect(synthIdx).toBeLessThan(finalIdx); + }); + + it("prefers rangeSummaryText over per-batch concat in the synthetic body (B)", () => { + const toolCallId = "tc-range"; + const chainEntry: ChainCompressionEntry = { + blockId: "b9", + startUserTimestamp: 100, + droppedToolCallIds: [toolCallId], + finalAssistantTimestamp: 300, + toolRefs: ["t9"], + compressedAt: 777, + rangeSummaryText: "FUSED cohesive summary", + }; + const indexer = makeMockIndexer({ + chainEntries: [chainEntry], + summaryBodyMap: new Map([[toolCallId, "per-batch concat body"]]), + }); + const messages: any[] = [ + { role: "user", content: [{ type: "text", text: "do it" }], timestamp: 100 }, + { role: "assistant", content: [{ type: "toolCall", id: toolCallId, name: "bash", arguments: {} }], timestamp: 200, usage: {}, stopReason: "tool_use" }, + { role: "toolResult", toolCallId, toolName: "bash", content: [{ type: "text", text: "output" }], isError: false, timestamp: 210 }, + { role: "assistant", content: [{ type: "text", text: "done" }], timestamp: 300, usage: {}, stopReason: "end_turn" }, + ]; + const { messages: out } = pruneMessages(messages, indexer, enabledCC); + const synthetic = out.find((m: any) => m.role === "user" && m.content?.[0]?.text?.startsWith(" { + const toolCallId = "tc-think"; + const chainEntry: ChainCompressionEntry = { + blockId: "b2", + startUserTimestamp: 100, + droppedToolCallIds: [toolCallId], + finalAssistantTimestamp: 300, + toolRefs: ["t2"], + compressedAt: 888, + }; + const indexer = makeMockIndexer({ + summarized: new Set([toolCallId]), + shortRefs: new Map([[toolCallId, "t2"]]), + chainEntries: [chainEntry], + summaryBodyMap: new Map([[toolCallId, "summary"]]), + }); + + const messages: any[] = [ + { role: "user", content: [{ type: "text", text: "think" }], timestamp: 100 }, + { + role: "assistant", + content: [{ type: "toolCall", id: toolCallId, name: "bash", arguments: {} }], + timestamp: 200, + api: "anthropic", + provider: "anthropic", + model: "x", + usage: {}, + stopReason: "tool_use", + }, + { + role: "toolResult", + toolCallId, + toolName: "bash", + content: [{ type: "text", text: "out" }], + isError: false, + timestamp: 210, + }, + { + role: "assistant", + content: [ + { type: "thinking", thinking: "deep thoughts", thinkingSignature: "sig123" }, + { type: "text", text: "answer" }, + ], + timestamp: 300, + api: "anthropic", + provider: "anthropic", + model: "x", + usage: {}, + stopReason: "end_turn", + }, + ]; + + const { messages: out } = pruneMessages(messages, indexer, enabledCC); + const finalAsst = out.find((m: any) => m.role === "assistant" && m.timestamp === 300); + expect(finalAsst).toBeDefined(); + const contentTypes = finalAsst.content.map((c: any) => c.type); + expect(contentTypes).not.toContain("thinking"); + expect(contentTypes).toContain("text"); + }); + + it("purges errored toolCall args through errorPurge wiring", () => { + const indexer = makeMockIndexer(); + const largeArgs = { content: "x".repeat(200) }; + const messages: any[] = [ + { + role: "assistant", + content: [{ type: "toolCall", id: "tc-err", name: "write", arguments: largeArgs }], + timestamp: 100, + api: "anthropic", + provider: "anthropic", + model: "x", + usage: {}, + stopReason: "tool_use", + }, + { + role: "toolResult", + toolCallId: "tc-err", + toolName: "write", + content: [{ type: "text", text: "Error: permission denied" }], + isError: true, + timestamp: 110, + }, + { + role: "assistant", + content: [{ type: "toolCall", id: "tc2", name: "bash", arguments: { cmd: "ls" } }], + timestamp: 200, + api: "anthropic", + provider: "anthropic", + model: "x", + usage: {}, + stopReason: "tool_use", + }, + { + role: "toolResult", + toolCallId: "tc2", + toolName: "bash", + content: [{ type: "text", text: "ok" }], + isError: false, + timestamp: 210, + }, + { + role: "assistant", + content: [{ type: "toolCall", id: "tc3", name: "bash", arguments: { cmd: "pwd" } }], + timestamp: 300, + api: "anthropic", + provider: "anthropic", + model: "x", + usage: {}, + stopReason: "tool_use", + }, + { + role: "toolResult", + toolCallId: "tc3", + toolName: "bash", + content: [{ type: "text", text: "ok" }], + isError: false, + timestamp: 310, + }, + ]; + const { messages: out, pruned } = pruneMessages( + messages, + indexer, + { enabled: false, rollingWindow: 3, stripFinalAssistantThinking: true, fuseRangeSummary: false }, + { enabled: true, cooldownTurns: 2, minArgChars: 100 }, + ); + expect(pruned).toBe(true); + const errAsst = out.find((m: any) => m.role === "assistant" && m.timestamp === 100) as any; + expect(errAsst).toBeDefined(); + expect(errAsst.content[0].arguments._purged).toMatch(/^ { + const indexer = makeMockIndexer({ + summarized: new Set(["tc1"]), + records: new Map([["tc1", { + toolCallId: "tc1", toolName: "bash", args: {}, resultText: "", + spillPath: "/blobs/tc1.txt", isError: false, turnIndex: 0, timestamp: 1, + }]]), + }); + const messages = [ + { role: "assistant", content: [{ type: "toolCall", id: "tc1", name: "bash", input: {} }], timestamp: 0 }, + { role: "toolResult", toolCallId: "tc1", toolName: "bash", content: [{ type: "text", text: "x" }], isError: false, timestamp: 1 }, + ]; + const { messages: out } = pruneMessages(messages, indexer); + const text = out[1].content[0].text as string; + expect(text).toContain("/blobs/tc1.txt"); + expect(text).toContain("?"); + expect(text).not.toContain("Summarized in pruner summary"); + }); + + it("emits a mechanical spill stub for a spilled record", () => { + const indexer = makeMockIndexer({ + summarized: new Set(["tc1"]), + records: new Map([["tc1", { + toolCallId: "tc1", toolName: "fetch", args: { url: "https://x" }, + resultText: "", resultPreview: "PREVIEW-HEAD", spillPath: "/blobs/tc1.txt", + spillBytes: 1048576, isError: false, turnIndex: 0, timestamp: 1, + }]]), + }); + const messages = [ + { role: "assistant", content: [{ type: "toolCall", id: "tc1", name: "fetch", input: {} }], timestamp: 0 }, + { + role: "toolResult", toolCallId: "tc1", toolName: "fetch", + content: [{ type: "text", text: "huge" }], isError: false, timestamp: 1, + }, + ]; + const { messages: out, pruned } = pruneMessages(messages, indexer); + expect(pruned).toBe(true); + const text = out[1].content[0].text as string; + expect(text).toContain("/blobs/tc1.txt"); + expect(text).toContain("PREVIEW-HEAD"); + expect(text).toContain("1048576"); + expect(text).not.toContain("Summarized in pruner summary"); + }); + + it("skips chain compression when disabled", () => { + const chainEntry: ChainCompressionEntry = { + blockId: "b1", + startUserTimestamp: 100, + droppedToolCallIds: ["tc-x"], + finalAssistantTimestamp: 200, + toolRefs: [], + compressedAt: 999, + }; + const indexer = makeMockIndexer({ chainEntries: [chainEntry] }); + const messages = [ + { role: "user", content: "hi", timestamp: 100 }, + { role: "assistant", content: [{ type: "toolCall", id: "tc-x", name: "bash", input: {} }], timestamp: 140 }, + { + role: "toolResult", + toolCallId: "tc-x", + toolName: "bash", + content: [], + isError: false, + timestamp: 150, + }, + ]; + const disabled: ChainCompressionConfig = { ...enabledCC, enabled: false }; + const { pruned } = pruneMessages(messages, indexer, disabled); + // tc-x is not in summarized set, so stub-replace doesn't fire; chain disabled + expect(pruned).toBe(false); + }); + + it("leaves thinking blocks on every assistant turn (no thinking-strip phase)", () => { + const indexer = makeMockIndexer({ summarized: new Set(["c10"]), shortRefs: new Map([["c10", "t1"]]) }); + const mkAsst = (ts: number) => ({ + role: "assistant", + content: [ + { type: "thinking", thinking: "t", thinkingSignature: "s" }, + { type: "text", text: "x" }, + { type: "toolCall", id: `c${ts}`, name: "bash", arguments: {} }, + ], + timestamp: ts, + usage: {}, + stopReason: "tool_use", + }); + const messages: any[] = [{ role: "user", content: [{ type: "text", text: "go" }], timestamp: 1 }]; + for (let i = 0; i < 5; i++) { + const id = `c${10 + i}`; + messages.push(mkAsst(10 + i)); + messages.push({ role: "toolResult", toolCallId: id, toolName: "bash", content: [{ type: "text", text: "o" }], isError: false, timestamp: 100 + i }); + } + const { messages: out, pruned } = pruneMessages(messages, indexer, { + enabled: true, rollingWindow: 0, stripFinalAssistantThinking: false, fuseRangeSummary: false, + }); + // Phase 1 still fires: c10's toolResult is stub-replaced. + expect(pruned).toBe(true); + const tr = out.find((m: any) => m.role === "toolResult" && m.toolCallId === "c10") as any; + expect(tr.content[0].text).toContain("`t1`"); + // No phase strips thinking any more — all five assistants keep theirs. + const assistants = out.filter((m: any) => m.role === "assistant"); + expect(assistants.length).toBe(5); + expect(assistants.every((a: any) => a.content.some((c: any) => c.type === "thinking"))).toBe(true); + }); +}); + +describe("render-time protection re-check", () => { + const skillAsst = { role: "assistant", content: [{ type: "toolCall", id: "tc-skill", name: "read", input: {} }], timestamp: 5 }; + const skillMsg = { + role: "toolResult", + toolCallId: "tc-skill", + toolName: "read", + content: [{ type: "text", text: "FULL SKILL BODY" }], + isError: false, + timestamp: 10, + }; + + const indexer = makeMockIndexer({ + summarized: new Set(["tc-skill"]), + shortRefs: new Map([["tc-skill", "t1"]]), + records: new Map([["tc-skill", { + toolCallId: "tc-skill", + toolName: "read", + args: { path: "/h/skills/x/SKILL.md" }, + resultText: "", + isError: false, + turnIndex: 0, + timestamp: 10, + }]]), + }); + + it("leaves a summarized record verbatim once its path matches protectedPaths", () => { + const { messages, pruned } = pruneMessages( + [skillAsst, skillMsg], indexer as any, undefined, undefined, + { protectedTools: [], protectedPaths: ["**/skills/**/*.md"] }, + ); + expect(pruned).toBe(false); + expect(messages[1].content[0].text).toBe("FULL SKILL BODY"); + }); + + it("still stubs when no protection config is passed", () => { + const { messages, pruned } = pruneMessages([skillAsst, skillMsg], indexer as any); + expect(pruned).toBe(true); + expect(messages[1].content[0].text).toContain("context_tree_query"); + }); +}); + +describe("pruneMessages recovery grace", () => { + const mkQueryResult = (toolCallId: string, timestamp: number) => ({ + role: "toolResult", + toolCallId, + toolName: "context_tree_query", + content: [{ type: "text", text: "VERBATIM RECOVERY OUTPUT" }], + isError: false, + timestamp, + }); + const mkUser = (timestamp: number) => ({ role: "user", content: [{ type: "text", text: "go" }], timestamp }); + const mkAsst = (toolCallId: string, toolName: string, timestamp: number) => ({ + role: "assistant", + content: [{ type: "toolCall", id: toolCallId, name: toolName, input: {} }], + timestamp, + }); + + it("renders a context_tree_query recovery output verbatim at age 0 within grace", () => { + const indexer = makeMockIndexer({ + summarized: new Set(["tc-recover"]), + shortRefs: new Map([["tc-recover", "t1"]]), + }); + const messages = [mkAsst("tc-recover", "context_tree_query", 0), mkQueryResult("tc-recover", 1)]; + const { messages: out } = pruneMessages(messages, indexer, undefined, undefined, undefined, 3); + expect(out[1].content[0].text).toBe("VERBATIM RECOVERY OUTPUT"); + }); + + it("stubs a context_tree_query recovery output aged past the grace window", () => { + const indexer = makeMockIndexer({ + summarized: new Set(["tc-recover"]), + shortRefs: new Map([["tc-recover", "t1"]]), + }); + const messages: any[] = [mkAsst("tc-recover", "context_tree_query", 0), mkQueryResult("tc-recover", 1), mkUser(2), mkUser(3), mkUser(4), mkUser(5)]; + const { messages: out } = pruneMessages(messages, indexer, undefined, undefined, undefined, 3); + const tr = out.find((m: any) => m.toolCallId === "tc-recover") as any; + expect(tr.content[0].text).toContain("context_tree_query"); + expect(tr.content[0].text).not.toBe("VERBATIM RECOVERY OUTPUT"); + }); + + it("stubs at age 0 when recoveryGraceTurns is 0 (feature off)", () => { + const indexer = makeMockIndexer({ + summarized: new Set(["tc-recover"]), + shortRefs: new Map([["tc-recover", "t1"]]), + }); + const messages = [mkAsst("tc-recover", "context_tree_query", 0), mkQueryResult("tc-recover", 1)]; + const { messages: out } = pruneMessages(messages, indexer, undefined, undefined, undefined, 0); + expect(out[1].content[0].text).not.toBe("VERBATIM RECOVERY OUTPUT"); + expect(out[1].content[0].text).toContain("context_tree_query"); + }); + + it("does not apply the grace window to non-context_tree_query outputs", () => { + const indexer = makeMockIndexer({ + summarized: new Set(["tc-bash"]), + shortRefs: new Map([["tc-bash", "t1"]]), + }); + const messages = [ + mkAsst("tc-bash", "bash", 0), + { + role: "toolResult", + toolCallId: "tc-bash", + toolName: "bash", + content: [{ type: "text", text: "VERBATIM RECOVERY OUTPUT" }], + isError: false, + timestamp: 1, + }, + ]; + const { messages: out } = pruneMessages(messages, indexer, undefined, undefined, undefined, 3); + expect(out[1].content[0].text).not.toBe("VERBATIM RECOVERY OUTPUT"); + expect(out[1].content[0].text).toContain("context_tree_query"); + }); + + it("isProtected precedence: a protected context_tree_query output stays verbatim even with grace off", () => { + const indexer = makeMockIndexer({ + summarized: new Set(["tc-recover"]), + shortRefs: new Map([["tc-recover", "t1"]]), + records: new Map([["tc-recover", { + toolCallId: "tc-recover", toolName: "context_tree_query", args: { path: "/h/skills/x/SKILL.md" }, + resultText: "", isError: false, turnIndex: 0, timestamp: 1, + }]]), + }); + const messages: any[] = [mkAsst("tc-recover", "context_tree_query", 0), mkQueryResult("tc-recover", 1), mkUser(2), mkUser(3), mkUser(4), mkUser(5)]; + const { messages: out } = pruneMessages( + messages, indexer, undefined, undefined, + { protectedTools: [], protectedPaths: ["**/skills/**/*.md"] }, + 0, + ); + const tr = out.find((m: any) => m.toolCallId === "tc-recover") as any; + expect(tr.content[0].text).toBe("VERBATIM RECOVERY OUTPUT"); + }); + + it("renders a spilled context_tree_query recovery output verbatim at age 0 within grace", () => { + const indexer = makeMockIndexer({ + summarized: new Set(["tc-recover"]), + shortRefs: new Map([["tc-recover", "t1"]]), + records: new Map([["tc-recover", { + toolCallId: "tc-recover", toolName: "context_tree_query", args: {}, + resultText: "", resultPreview: "PREVIEW-HEAD", spillPath: "/blobs/tc-recover.txt", + spillBytes: 1048576, isError: false, turnIndex: 0, timestamp: 1, + }]]), + }); + const messages = [mkAsst("tc-recover", "context_tree_query", 0), mkQueryResult("tc-recover", 1)]; + const { messages: out } = pruneMessages(messages, indexer, undefined, undefined, undefined, 3); + expect(out[1].content[0].text).toBe("VERBATIM RECOVERY OUTPUT"); + }); + + it("stubs a spilled context_tree_query recovery output aged past the grace window to the spill-pointer stub", () => { + const indexer = makeMockIndexer({ + summarized: new Set(["tc-recover"]), + shortRefs: new Map([["tc-recover", "t1"]]), + records: new Map([["tc-recover", { + toolCallId: "tc-recover", toolName: "context_tree_query", args: {}, + resultText: "", resultPreview: "PREVIEW-HEAD", spillPath: "/blobs/tc-recover.txt", + spillBytes: 1048576, isError: false, turnIndex: 0, timestamp: 1, + }]]), + }); + const messages: any[] = [mkAsst("tc-recover", "context_tree_query", 0), mkQueryResult("tc-recover", 1), mkUser(2), mkUser(3), mkUser(4), mkUser(5)]; + const { messages: out } = pruneMessages(messages, indexer, undefined, undefined, undefined, 3); + const tr = out.find((m: any) => m.toolCallId === "tc-recover") as any; + expect(tr.content[0].text).not.toBe("VERBATIM RECOVERY OUTPUT"); + expect(tr.content[0].text).toContain("/blobs/tc-recover.txt"); + expect(tr.content[0].text).toContain("spilled"); + }); +}); + +describe("occurrence-keyed stub replacement", () => { + it("stubs the summarized occurrence and leaves the live one verbatim", () => { + const idx = new ToolCallIndexer(); + idx.addBatch( + { + turnIndex: 0, + timestamp: 1000, + assistantText: "", + toolCalls: [{ toolCallId: "bash_23", toolName: "bash", args: {}, resultText: "OLD", isError: false, resultTimestamp: 1150 }], + } as any, + () => {}, + ); + const messages: any[] = [ + { role: "assistant", content: [{ type: "toolCall", id: "bash_23", name: "bash", input: {} }], timestamp: 1100 }, + { role: "toolResult", toolCallId: "bash_23", toolName: "bash", content: [{ type: "text", text: "OLD" }], isError: false, timestamp: 1150 }, + { role: "assistant", content: [{ type: "toolCall", id: "bash_23", name: "bash", input: {} }], timestamp: 3100 }, + { role: "toolResult", toolCallId: "bash_23", toolName: "bash", content: [{ type: "text", text: "LIVE" }], isError: false, timestamp: 3150 }, + ]; + const out = pruneMessages(messages, idx); + expect(out.messages[1].content[0].text).toContain("Summarized in pruner summary"); + expect(out.messages[3].content[0].text).toBe("LIVE"); + }); + + it("fail-closed: a timestamped result with no occurrence record is never stubbed", () => { + const idx = new ToolCallIndexer(); + idx.addBatch( + { + turnIndex: 0, + timestamp: 1000, + assistantText: "", + toolCalls: [{ toolCallId: "bash_23", toolName: "bash", args: {}, resultText: "OLD", isError: false, resultTimestamp: 1150 }], + } as any, + () => {}, + ); + const messages: any[] = [ + { role: "assistant", content: [{ type: "toolCall", id: "bash_23", name: "bash", input: {} }], timestamp: 9100 }, + { role: "toolResult", toolCallId: "bash_23", toolName: "bash", content: [{ type: "text", text: "LIVE" }], isError: false, timestamp: 9150 }, + ]; + const out = pruneMessages(messages, idx); + expect(out.pruned).toBe(false); + expect(out.messages).toBe(messages); + }); + + it("fail-closed: a mixed legacy+occurrence bare id does not stub a live later occurrence (F1 regression)", () => { + const idx = new ToolCallIndexer(); + idx.reconstructFromSession({ + sessionManager: { + getBranch: () => [ + { + type: "custom", + customType: CUSTOM_TYPE_INDEX, + data: { toolCalls: [{ toolCallId: "bash_23", toolName: "bash", args: {}, resultText: "OLD-LEGACY", isError: false, turnIndex: 0, timestamp: 500 }] }, + }, + ], + }, + } as any); + idx.addBatch( + { + turnIndex: 1, + timestamp: 2000, + assistantText: "", + toolCalls: [{ toolCallId: "bash_23", toolName: "bash", args: {}, resultText: "MID", isError: false, resultTimestamp: 2150 }], + } as any, + () => {}, + ); + const messages: any[] = [ + { role: "assistant", content: [{ type: "toolCall", id: "bash_23", name: "bash", input: {} }], timestamp: 9100 }, + { role: "toolResult", toolCallId: "bash_23", toolName: "bash", content: [{ type: "text", text: "LIVE" }], isError: false, timestamp: 9150 }, + ]; + const out = pruneMessages(messages, idx); + expect(out.messages[1].content[0].text).toBe("LIVE"); + expect(out.pruned).toBe(false); + }); + + it("legacy bare-id records still stub (pre-upgrade sessions keep working)", () => { + const idx = new ToolCallIndexer(); + idx.reconstructFromSession({ + sessionManager: { + getBranch: () => [ + { + type: "custom", + customType: CUSTOM_TYPE_INDEX, + data: { toolCalls: [{ toolCallId: "bash_7", toolName: "bash", args: {}, resultText: "OLD", isError: false, turnIndex: 0, timestamp: 500 }] }, + }, + ], + }, + } as any); + const messages: any[] = [ + { role: "assistant", content: [{ type: "toolCall", id: "bash_7", name: "bash", input: {} }], timestamp: 500 }, + { role: "toolResult", toolCallId: "bash_7", toolName: "bash", content: [{ type: "text", text: "OLD" }], isError: false, timestamp: 550 }, + ]; + const out = pruneMessages(messages, idx); + expect(out.pruned).toBe(true); + expect(out.messages[1].content[0].text).toContain("Summarized in pruner summary"); + }); + + it("accepted limitation: a pure-legacy summarized bash_7 stubs a LIVE colliding bash_7 result (pre-upgrade sessions only)", () => { + const idx = new ToolCallIndexer(); + idx.reconstructFromSession({ + sessionManager: { + getBranch: () => [ + { + type: "custom", + customType: CUSTOM_TYPE_INDEX, + data: { toolCalls: [{ toolCallId: "bash_7", toolName: "bash", args: {}, resultText: "OLD", isError: false, turnIndex: 0, timestamp: 500 }] }, + }, + ], + }, + } as any); + + // No migration: a bare-keyed legacy record has no occurrence-keyed + // siblings, so hasLegacyBareRecord stays true even though a later, live, + // unrelated occurrence of the same reused provider id now exists. + expect(idx.hasLegacyBareRecord("bash_7")).toBe(true); + + const messages: any[] = [ + { role: "assistant", content: [{ type: "toolCall", id: "bash_7", name: "bash", input: {} }], timestamp: 9100 }, + { role: "toolResult", toolCallId: "bash_7", toolName: "bash", content: [{ type: "text", text: "LIVE" }], isError: false, timestamp: 9150 }, + ]; + const out = pruneMessages(messages, idx); + // Accepted, documented exposure (PRUNING.md): a session spanning the + // upgrade keeps this pre-upgrade behavior for its legacy half - the live + // result is stub-replaced with the stale legacy record's content. + expect(out.pruned).toBe(true); + expect(out.messages[1].content[0].text).toContain("Summarized in pruner summary"); + }); +}); + +describe("orphan sweep in pruneMessages", () => { + it("a clean render returns the identical array reference with pruned false", () => { + const idx = new ToolCallIndexer(); + const messages: any[] = [ + { role: "assistant", content: [{ type: "toolCall", id: "a", name: "bash", input: {} }], timestamp: 1 }, + { role: "toolResult", toolCallId: "a", toolName: "bash", content: [{ type: "text", text: "x" }], isError: false, timestamp: 2 }, + ]; + const out = pruneMessages(messages, idx); + expect(out.messages).toBe(messages); + expect(out.pruned).toBe(false); + expect(out.beforeChars).toBe(0); + }); + + it("sweeps an orphan and reports the diagnostic once across repeated renders of the same input", () => { + const idx = new ToolCallIndexer(); + const appended: any[] = []; + const sink = new DiagnosticSink((_customType, data) => appended.push(data)); + const messages: any[] = [ + { role: "assistant", content: [{ type: "toolCall", id: "a", name: "bash", input: {} }], timestamp: 1 }, + { role: "toolResult", toolCallId: "a", toolName: "bash", content: [{ type: "text", text: "x" }], isError: false, timestamp: 2 }, + { role: "toolResult", toolCallId: "ghost", toolName: "bash", content: [{ type: "text", text: "y" }], isError: false, timestamp: 3 }, + ]; + const first = pruneMessages(messages, idx, undefined, undefined, undefined, 0, sink as any); + expect(first.pruned).toBe(true); + expect(first.messages).toHaveLength(2); + expect(first.messages.some((m: any) => m.toolCallId === "ghost")).toBe(false); + expect(first.messages.some((m: any) => m.toolCallId === "a")).toBe(true); + + // Same orphan on a second render of the same (still-unsupplemented) input + // must not write a second diagnostic entry: DiagnosticSink dedups per + // (kind, dedupKey), and pruneMessages must compute the same dedupKey both + // times for the same swept id set. + const second = pruneMessages(messages, idx, undefined, undefined, undefined, 0, sink as any); + expect(second.pruned).toBe(true); + + expect(appended).toHaveLength(1); + expect(appended[0].kind).toBe("orphan-sweep"); + expect(appended[0].detail).toContain("ghost"); + expect(appended[0].detail).toContain("swept 1 orphan"); + }); + + it("bounds the sweep dedup key and truncation marker for a large orphan set", () => { + const idx = new ToolCallIndexer(); + const reports: any[] = []; + const sink = { report: (kind: string, key: string, detail: string) => reports.push({ kind, key, detail }), counts: () => ({}) as any }; + const messages: any[] = [ + { role: "assistant", content: [{ type: "toolCall", id: "keep", name: "bash", input: {} }], timestamp: 1 }, + { role: "toolResult", toolCallId: "keep", toolName: "bash", content: [{ type: "text", text: "x" }], isError: false, timestamp: 2 }, + ]; + for (let i = 0; i < 12; i++) { + messages.push({ role: "toolResult", toolCallId: `ghost-${i}`, toolName: "bash", content: [{ type: "text", text: "y" }], isError: false, timestamp: 3 + i }); + } + const out = pruneMessages(messages, idx, undefined, undefined, undefined, 0, sink as any); + expect(out.pruned).toBe(true); + expect(reports).toHaveLength(1); + // A short, bounded hash key regardless of how many ids were swept. + expect(reports[0].key.length).toBe(16); + expect(reports[0].detail).toContain("swept 12 orphan"); + expect(reports[0].detail).toContain("... +7 more"); + }); +}); + +describe("occurrence-keyed coverage via mock indexer (spill / protection / grace)", () => { + it("stub-replaces via a direct occurrence-key hit (not the legacy branch)", () => { + const indexer = makeMockIndexer({ + summarized: new Set(["tc1@1500"]), + shortRefs: new Map([["tc1@1500", "t1"]]), + }); + const messages = [ + { role: "assistant", content: [{ type: "toolCall", id: "tc1", name: "bash", input: {} }], timestamp: 1400 }, + { role: "toolResult", toolCallId: "tc1", toolName: "bash", content: [{ type: "text", text: "big output" }], isError: false, timestamp: 1500 }, + ]; + const { messages: out, pruned } = pruneMessages(messages, indexer); + expect(pruned).toBe(true); + expect(out[1].content[0].text).toContain("`t1`"); + }); + + it("emits the mechanical spill stub via a direct occurrence-key hit", () => { + const indexer = makeMockIndexer({ + summarized: new Set(["tc1@1500"]), + records: new Map([["tc1@1500", { + toolCallId: "tc1", toolName: "fetch", args: { url: "https://x" }, + resultText: "", resultPreview: "PREVIEW-HEAD", spillPath: "/blobs/tc1.txt", + spillBytes: 1048576, isError: false, turnIndex: 0, resultTimestamp: 1500, timestamp: 1400, + }]]), + }); + const messages = [ + { role: "assistant", content: [{ type: "toolCall", id: "tc1", name: "fetch", input: {} }], timestamp: 1400 }, + { role: "toolResult", toolCallId: "tc1", toolName: "fetch", content: [{ type: "text", text: "huge" }], isError: false, timestamp: 1500 }, + ]; + const { messages: out, pruned } = pruneMessages(messages, indexer); + expect(pruned).toBe(true); + const text = out[1].content[0].text as string; + expect(text).toContain("/blobs/tc1.txt"); + expect(text).toContain("PREVIEW-HEAD"); + expect(text).not.toContain("Summarized in pruner summary"); + }); + + it("render-time protection re-check applies to a direct occurrence-key hit", () => { + const indexer = makeMockIndexer({ + summarized: new Set(["tc-skill@1500"]), + shortRefs: new Map([["tc-skill@1500", "t1"]]), + records: new Map([["tc-skill@1500", { + toolCallId: "tc-skill", toolName: "read", args: { path: "/h/skills/x/SKILL.md" }, + resultText: "", isError: false, turnIndex: 0, resultTimestamp: 1500, timestamp: 1400, + }]]), + }); + const messages = [ + { role: "assistant", content: [{ type: "toolCall", id: "tc-skill", name: "read", input: {} }], timestamp: 1400 }, + { role: "toolResult", toolCallId: "tc-skill", toolName: "read", content: [{ type: "text", text: "FULL SKILL BODY" }], isError: false, timestamp: 1500 }, + ]; + const { messages: out, pruned } = pruneMessages( + messages, indexer, undefined, undefined, + { protectedTools: [], protectedPaths: ["**/skills/**/*.md"] }, + ); + expect(pruned).toBe(false); + expect(out[1].content[0].text).toBe("FULL SKILL BODY"); + }); + + it("recovery grace protects a direct occurrence-key hit at age 0", () => { + const indexer = makeMockIndexer({ + summarized: new Set(["tc-recover@1500"]), + shortRefs: new Map([["tc-recover@1500", "t1"]]), + }); + const messages = [ + { role: "assistant", content: [{ type: "toolCall", id: "tc-recover", name: "context_tree_query", input: {} }], timestamp: 1400 }, + { role: "toolResult", toolCallId: "tc-recover", toolName: "context_tree_query", content: [{ type: "text", text: "VERBATIM RECOVERY OUTPUT" }], isError: false, timestamp: 1500 }, + ]; + const { messages: out } = pruneMessages(messages, indexer, undefined, undefined, undefined, 3); + expect(out[1].content[0].text).toBe("VERBATIM RECOVERY OUTPUT"); + }); + + it("a graced occurrence does not protect a different occurrence of the same reused bare id", () => { + const indexer = makeMockIndexer({ + summarized: new Set(["reused@1500", "reused@9500"]), + shortRefs: new Map([["reused@1500", "t1"], ["reused@9500", "t2"]]), + }); + const messages = [ + // Graced context_tree_query recovery at occurrence reused@1500 (age 0). + { role: "assistant", content: [{ type: "toolCall", id: "reused", name: "context_tree_query", input: {} }], timestamp: 1400 }, + { role: "toolResult", toolCallId: "reused", toolName: "context_tree_query", content: [{ type: "text", text: "VERBATIM RECOVERY OUTPUT" }], isError: false, timestamp: 1500 }, + // A LATER, unrelated summarized occurrence of the same reused provider id. + { role: "assistant", content: [{ type: "toolCall", id: "reused", name: "bash", input: {} }], timestamp: 9400 }, + { role: "toolResult", toolCallId: "reused", toolName: "bash", content: [{ type: "text", text: "different output" }], isError: false, timestamp: 9500 }, + ]; + const { messages: out } = pruneMessages(messages, indexer, undefined, undefined, undefined, 3); + // The grace-protected recovery output stays verbatim... + expect(out[1].content[0].text).toBe("VERBATIM RECOVERY OUTPUT"); + // ...but the later, different occurrence of the same bare id is NOT + // shielded by that grace entry - it gets stubbed on its own merits. + expect(out[3].content[0].text).toContain("`t2`"); + expect(out[3].content[0].text).not.toBe("different output"); + }); +}); + +describe("sizeMessages", () => { + it("counts hidden fields (thinking blocks), not just visible text", () => { + // Two messages with identical visible .text but different hidden content. + // sizeMessages must count the full serialized weight so all reclaim + // mechanisms (stub-replace, error-purge, chain-range-prune) register correctly. + const withThinking = [{ + role: "assistant", + content: [ + { type: "thinking", thinking: "x".repeat(1000) }, + { type: "text", text: "hello" }, + ], + }]; + const withoutThinking = [{ + role: "assistant", + content: [ + { type: "text", text: "hello" }, + ], + }]; + expect(sizeMessages(withThinking)).toBeGreaterThan(sizeMessages(withoutThinking)); + }); +}); + +describe("pruneMessages beforeChars/afterChars", () => { + it("no-op returns {0,0} sentinel and pruned false (no serialization on no-op path)", () => { + const indexer = makeMockIndexer(); + // Non-empty input: a reverted fix that recomputes sizeMessages(messages) + // on the unconditional path would yield a nonzero beforeChars here and fail. + const messages = [{ role: "user", content: "hello", timestamp: 1 }]; + const result = pruneMessages(messages, indexer); + expect(result.pruned).toBe(false); + expect(result.beforeChars).toBe(0); + expect(result.afterChars).toBe(0); + // No-op returns the original array reference unchanged. + expect(result.messages).toBe(messages); + }); + + it("pruning path: beforeChars > afterChars when stubs shrink content", () => { + const indexer = makeMockIndexer({ + summarized: new Set(["tc1"]), + shortRefs: new Map([["tc1", "t1"]]), + }); + const messages = [ + { role: "assistant", content: [{ type: "toolCall", id: "tc1", name: "bash", input: {} }], timestamp: 0 }, + { + role: "toolResult", + toolCallId: "tc1", + toolName: "bash", + content: [{ type: "text", text: "x".repeat(500) }], + isError: false, + timestamp: 1, + }, + ]; + const result = pruneMessages(messages, indexer); + expect(result.pruned).toBe(true); + expect(result.beforeChars).toBe(sizeMessages(messages)); + expect(result.afterChars).toBe(sizeMessages(result.messages)); + expect(result.afterChars).toBeLessThan(result.beforeChars); + expect(result.messages).toHaveLength(2); + }); +}); + +describe("G4/C3: orphan-sweep zero-fire proof across pruner fixtures", () => { + // Wraps a representative set of existing pruneMessages fixtures with a + // counting DiagnosticSink (pruneWithZeroSweepAssertion, src/test-support.ts) + // and fails if the orphan-sweep diagnostic ever fires. Deliberately excludes + // the two tests in "orphan sweep in pruneMessages" above that construct an + // orphan on purpose - those pin the OPPOSITE contract (the sweep firing when + // it should). + const fixtures: Array<[string, () => void]> = [ + ["stub-replaces a summarized tool result", () => { + const indexer = makeMockIndexer({ summarized: new Set(["tc1"]), shortRefs: new Map([["tc1", "t1"]]) }); + const messages = [ + { role: "assistant", content: [{ type: "toolCall", id: "tc1", name: "bash", input: {} }], timestamp: 0 }, + { role: "toolResult", toolCallId: "tc1", toolName: "bash", content: [{ type: "text", text: "big output" }], isError: false, timestamp: 1 }, + ]; + pruneWithZeroSweepAssertion(messages, indexer); + }], + ["applies chain compression after stub-replace", () => { + const toolCallId = "tc-mid"; + const chainEntry: ChainCompressionEntry = { + blockId: "b1", startUserTimestamp: 100, droppedToolCallIds: [toolCallId], + finalAssistantTimestamp: 300, toolRefs: ["t1"], compressedAt: 999, + }; + const indexer = makeMockIndexer({ + summarized: new Set([toolCallId]), shortRefs: new Map([[toolCallId, "t1"]]), + chainEntries: [chainEntry], summaryBodyMap: new Map([[toolCallId, "ran bash, got results"]]), + }); + const messages: any[] = [ + { role: "user", content: [{ type: "text", text: "do it" }], timestamp: 100 }, + { role: "assistant", content: [{ type: "toolCall", id: toolCallId, name: "bash", arguments: {} }], timestamp: 200, usage: {}, stopReason: "tool_use" }, + { role: "toolResult", toolCallId, toolName: "bash", content: [{ type: "text", text: "output" }], isError: false, timestamp: 210 }, + { role: "assistant", content: [{ type: "text", text: "done" }], timestamp: 300, usage: {}, stopReason: "end_turn" }, + ]; + pruneWithZeroSweepAssertion(messages, indexer, enabledCC); + }], + ["purges errored toolCall args through errorPurge wiring", () => { + const indexer = makeMockIndexer(); + const largeArgs = { content: "x".repeat(200) }; + const messages: any[] = [ + { role: "assistant", content: [{ type: "toolCall", id: "tc-err", name: "write", arguments: largeArgs }], timestamp: 100, usage: {}, stopReason: "tool_use" }, + { role: "toolResult", toolCallId: "tc-err", toolName: "write", content: [{ type: "text", text: "Error: permission denied" }], isError: true, timestamp: 110 }, + { role: "assistant", content: [{ type: "toolCall", id: "tc2", name: "bash", arguments: { cmd: "ls" } }], timestamp: 200, usage: {}, stopReason: "tool_use" }, + { role: "toolResult", toolCallId: "tc2", toolName: "bash", content: [{ type: "text", text: "ok" }], isError: false, timestamp: 210 }, + ]; + pruneWithZeroSweepAssertion( + messages, indexer, + { enabled: false, rollingWindow: 3, stripFinalAssistantThinking: true, fuseRangeSummary: false }, + { enabled: true, cooldownTurns: 2, minArgChars: 100 }, + ); + }], + ["legacy bare-id records still stub (pre-upgrade sessions)", () => { + const idx = new ToolCallIndexer(); + idx.reconstructFromSession({ + sessionManager: { + getBranch: () => [ + { type: "custom", customType: CUSTOM_TYPE_INDEX, data: { toolCalls: [{ toolCallId: "bash_7", toolName: "bash", args: {}, resultText: "OLD", isError: false, turnIndex: 0, timestamp: 500 }] } }, + { type: "message", message: { role: "toolResult", toolCallId: "bash_7", toolName: "bash", content: [{ type: "text", text: "OLD" }], isError: false, timestamp: 550 } }, + ], + }, + } as any); + const messages: any[] = [ + { role: "assistant", content: [{ type: "toolCall", id: "bash_7", name: "bash", input: {} }], timestamp: 500 }, + { role: "toolResult", toolCallId: "bash_7", toolName: "bash", content: [{ type: "text", text: "OLD" }], isError: false, timestamp: 550 }, + ]; + pruneWithZeroSweepAssertion(messages, idx); + }], + ["occurrence-keyed: stubs the summarized occurrence and leaves the live one verbatim", () => { + const idx = new ToolCallIndexer(); + idx.addBatch( + { turnIndex: 0, timestamp: 1000, assistantText: "", toolCalls: [{ toolCallId: "bash_23", toolName: "bash", args: {}, resultText: "OLD", isError: false, resultTimestamp: 1150 }] } as any, + () => {}, + ); + const messages: any[] = [ + { role: "assistant", content: [{ type: "toolCall", id: "bash_23", name: "bash", input: {} }], timestamp: 1100 }, + { role: "toolResult", toolCallId: "bash_23", toolName: "bash", content: [{ type: "text", text: "OLD" }], isError: false, timestamp: 1150 }, + { role: "assistant", content: [{ type: "toolCall", id: "bash_23", name: "bash", input: {} }], timestamp: 3100 }, + { role: "toolResult", toolCallId: "bash_23", toolName: "bash", content: [{ type: "text", text: "LIVE" }], isError: false, timestamp: 3150 }, + ]; + pruneWithZeroSweepAssertion(messages, idx); + }], + ["G1 conformance fixture: the accepted pre-upgrade legacy collision case still triggers no orphan sweep", () => { + const idx = new ToolCallIndexer(); + idx.reconstructFromSession({ + sessionManager: { + getBranch: () => [ + { type: "custom", customType: CUSTOM_TYPE_INDEX, data: { toolCalls: [{ toolCallId: "bash_7", toolName: "bash", args: {}, resultText: "OLD", isError: false, turnIndex: 0, timestamp: 500 }] } }, + { type: "message", message: { role: "toolResult", toolCallId: "bash_7", toolName: "bash", content: [{ type: "text", text: "OLD" }], isError: false, timestamp: 550 } }, + { type: "message", message: { role: "toolResult", toolCallId: "bash_7", toolName: "bash", content: [{ type: "text", text: "LIVE" }], isError: false, timestamp: 9150 } }, + ], + }, + } as any); + const messages: any[] = [ + { role: "assistant", content: [{ type: "toolCall", id: "bash_7", name: "bash", input: {} }], timestamp: 9100 }, + { role: "toolResult", toolCallId: "bash_7", toolName: "bash", content: [{ type: "text", text: "LIVE" }], isError: false, timestamp: 9150 }, + ]; + pruneWithZeroSweepAssertion(messages, idx); + }], + ["spill mechanical stub for a spilled record", () => { + const indexer = makeMockIndexer({ + summarized: new Set(["tc1"]), + records: new Map([["tc1", { + toolCallId: "tc1", toolName: "fetch", args: { url: "https://x" }, + resultText: "", resultPreview: "PREVIEW-HEAD", spillPath: "/blobs/tc1.txt", + spillBytes: 1048576, isError: false, turnIndex: 0, timestamp: 1, + }]]), + }); + const messages = [ + { role: "assistant", content: [{ type: "toolCall", id: "tc1", name: "fetch", input: {} }], timestamp: 0 }, + { role: "toolResult", toolCallId: "tc1", toolName: "fetch", content: [{ type: "text", text: "huge" }], isError: false, timestamp: 1 }, + ]; + pruneWithZeroSweepAssertion(messages, indexer); + }], + ]; + + for (const [name, run] of fixtures) { + it(`zero orphan sweeps: ${name}`, run); + } +}); diff --git a/pi-condense/src/pruner.ts b/pi-condense/src/pruner.ts new file mode 100644 index 0000000..9d16512 --- /dev/null +++ b/pi-condense/src/pruner.ts @@ -0,0 +1,201 @@ +import { createHash } from "node:crypto"; +import type { ToolCallIndexer } from "./indexer.js"; +import type { ChainCompressionConfig, ErrorPurgeConfig } from "./types.js"; +import { isProtected, type ProtectionConfig } from "./protected.js"; +import { applyChainCompressions } from "./chain-range-prune.js"; +import { purgeErroredArgs } from "./error-purge.js"; +import { inGraceRecoveryToolCallIds } from "./recovery-grace.js"; +import { occKey } from "./occurrence-key.js"; +import { sweepOrphanToolResults } from "./orphan-sweep.js"; +import type { DiagnosticSink } from "./diagnostics.js"; + +/** + * Estimate of a message array's context weight. Serializing the whole array + * (not just visible text) is deliberate: it counts tool-call argument bodies + * (error-purge) and tool-result arrays (stub-replace / chain-range) so all + * reclaim mechanisms register. + */ +export function sizeMessages(messages: any[]): number { + return JSON.stringify(messages).length; +} + +/** + * Transforms the `context` event message array in four phases: + * + * Phase 1 — stub-replace: ToolResultMessages for summarized tool calls are + * replaced with short stubs pointing the model at `context_tree_query`. + * + * Why stubs instead of dropping the message entirely: + * - Dropping orphans the matching `toolCall` block inside the + * preceding AssistantMessage. pi-ai's `transformMessages` then + * injects a synthetic `{ role: "toolResult", isError: true, + * content: "No result provided" }` for every orphan, which the LLM + * reads as a real tool failure. Replacing the toolResult with a + * stub keeps role alternation intact and suppresses that injection. + * - The stub carries the short ref (`tN`) the model can pass to + * `context_tree_query` to recover the raw output, so the breadcrumb + * to recovery is present on the toolResult itself, not only in the + * separate summary message. + * + * Phase 2 — error purge: replaces failed toolCall arg bodies with stubs after a + * cooldown, reclaiming context from large `write`/`edit` arguments that will + * never succeed. The toolResult error message stays visible. + * + * Phase 3 — chain range prune: closed chains older than the rolling window + * are dropped (middle assistant + toolResult messages) and replaced with a + * synthetic user message wrapping the existing per-batch summary text. + * Only runs when `chainCompression.enabled` and chain entries exist. + * + * Phase 4 — orphan sweep: structural post-condition run unconditionally over + * the final array. Removes any toolResult whose matching toolCall id is not + * open: opened by the most recent assistant turn and uninterrupted by a + * barrier (any non-assistant/non-toolResult message) — see + * src/orphan-sweep.ts. Reference-preserving when nothing is swept, so a + * clean render still returns the identical input array. + * + * Return shape: + * - `pruned: true` — at least one change happened; the returned + * `messages` is a freshly allocated array. + * - `pruned: false` — nothing matched; the returned `messages` is the + * **original input array reference** so the caller can cheaply skip + * the reconstruction path. + * - `beforeChars` / `afterChars` — serialized context size (`sizeMessages`) + * before and after pruning when `pruned` is true. When `pruned` is false + * both are `0`: a no-op sentinel, not a measurement — the size is never + * computed on the no-op path (zero `JSON.stringify` over the array), and + * the only consumer (`index.ts` live-reclaim) reads them solely under + * `if (result.pruned)`. + * + * AssistantMessage tool-call blocks (which carry the IDs) are kept + * unchanged so the model can still reference them by id when calling + * `context_tree_query`. + */ +export function pruneMessages( + messages: any[], + indexer: ToolCallIndexer, + chainCompression?: ChainCompressionConfig, + errorPurge?: ErrorPurgeConfig, + protection?: ProtectionConfig, + recoveryGraceTurns: number = 0, + diagnostics?: DiagnosticSink, +): { messages: any[]; pruned: boolean; beforeChars: number; afterChars: number } { + // Phase 1: stub-replace summarized tool results + let pruned = false; + const inGrace = inGraceRecoveryToolCallIds(messages, recoveryGraceTurns); + const next = messages.map((msg) => { + if (msg.role !== "toolResult") return msg; + + // Fail-closed: when the message carries a timestamp, the occurrence key + // is tried first. The bare id is consulted only as a fallback, and only + // when `hasLegacyBareRecord` confirms it is LEGACY-ONLY (no occurrence + // siblings) - a mixed bare+occurrence id fails closed there too, since a + // live result under a reused id is not the legacy one. A permissive + // bare-id fallback would stub a live result because an older occurrence + // (or the legacy record) of the same provider id was summarized. + const key = typeof msg.timestamp === "number" ? occKey(msg.toolCallId, msg.timestamp) : msg.toolCallId; + const lookupKey = indexer.isSummarized(key) + ? key + : indexer.hasLegacyBareRecord(msg.toolCallId) + ? msg.toolCallId + : undefined; + if (lookupKey === undefined) return msg; + + const record = indexer.getRecord(lookupKey); + // Render-time re-check: a record summarized before protectedPaths + // covered it is repaired here — the raw toolResult still lives in the + // session JSONL, so skipping the stub restores it verbatim. + // Dedup aliases resolve to the original record, so an alias whose own + // path is protected but whose original isn't stays stubbed (edge case). + if (protection && record && isProtected(record.toolName, record.args, protection)) { + return msg; + } + if (inGrace.has(key)) { + return msg; + } + pruned = true; + const ref = indexer.getShortRefForToolCallId(lookupKey) ?? msg.toolCallId; + const text = record?.spillPath + ? [ + `[Oversized output spilled to file — ${record.spillBytes ?? "?"} bytes.]`, + `Tool: ${record.toolName}`, + `Preview (head):`, + record.resultPreview ?? "", + `Full output — read this file (offset/limit supported): ${record.spillPath}`, + `Or use context_tree_query with ref \`${ref}\`.`, + ].join("\n") + : `[Summarized in pruner summary, ref \`${ref}\`. Use context_tree_query to retrieve full output.]`; + return { + role: "toolResult", + toolCallId: msg.toolCallId, + toolName: msg.toolName, + content: [{ type: "text", text }], + isError: false, + timestamp: msg.timestamp, + }; + }); + + let current: any[] = pruned ? next : messages; + + // Phase 2: error purge — replace failed toolCall arg bodies after cooldown + if (errorPurge?.enabled) { + const afterPurge = purgeErroredArgs(current, errorPurge); + if (afterPurge !== current) { + current = afterPurge; + pruned = true; + } + } + + // Phase 3: chain range prune — drop closed chains beyond the rolling window + if (chainCompression?.enabled) { + const chainEntries = indexer.getChainEntries(); + if (chainEntries.length > 0) { + // Prefer the cohesive LLM range summary (B) when present; fall back to the + // per-batch concatenation for spans compressed before fusion / on failure. + const chainSummaryText = (entry: typeof chainEntries[number]): string => + entry.rangeSummaryText ?? + indexer.getPerBatchSummaryTextForToolCallIds(entry.droppedOccurrenceKeys ?? entry.droppedToolCallIds); + const blockSummaryLookup = (blockId: string): string | undefined => { + const entry = indexer.findChainEntryByBlockId(blockId); + if (!entry) return undefined; + return chainSummaryText(entry) || undefined; + }; + const compressed = applyChainCompressions( + current, + chainEntries, + chainSummaryText, + chainCompression.stripFinalAssistantThinking, + blockSummaryLookup, + diagnostics, + ); + if (compressed !== current) { + current = compressed; + pruned = true; + } + } + } + + // Phase 4: orphan sweep — structural post-condition. Reference-preserving + // when clean, so a no-op render leaves the prompt-cache prefix untouched. + const swept = sweepOrphanToolResults(current); + if (swept.messages !== current) { + current = swept.messages; + pruned = true; + const sortedIds = [...swept.sweptIds].sort(); + // Hash the id list into a short, stable dedup key instead of the raw + // sorted join: a growing orphan set would otherwise write ever-longer + // keys ("a", "a,b", "a,b,c", ...), and DiagnosticSink.seen retains every + // prefix forever - O(n^2) characters over a session's lifetime. + const dedupKey = createHash("sha1").update(sortedIds.join(",")).digest("hex").slice(0, 16); + const shown = sortedIds.slice(0, 5); + const more = sortedIds.length > shown.length ? ` ... +${sortedIds.length - shown.length} more` : ""; + diagnostics?.report( + "orphan-sweep", + dedupKey, + `swept ${swept.sweptIds.length} orphan toolResult(s): ${shown.join(", ")}${more}`, + ); + } + + return pruned + ? { messages: current, pruned, beforeChars: sizeMessages(messages), afterChars: sizeMessages(current) } + : { messages, pruned, beforeChars: 0, afterChars: 0 }; +} diff --git a/pi-condense/src/query-tool.test.ts b/pi-condense/src/query-tool.test.ts new file mode 100644 index 0000000..35bd8c2 --- /dev/null +++ b/pi-condense/src/query-tool.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, test } from "bun:test"; +import { registerQueryTool } from "./query-tool.js"; +import { ToolCallIndexer } from "./indexer.js"; +import type { CapturedBatch } from "./types.js"; + +const capture = (idx: ToolCallIndexer, id: string, ts: number, text: string, turnIndex: number) => { + const batch: CapturedBatch = { + turnIndex, + timestamp: ts - 50, + assistantText: "", + toolCalls: [{ toolCallId: id, toolName: "bash", args: { cmd: "ls" }, resultText: text, isError: false, resultTimestamp: ts }], + }; + idx.addBatch(batch, () => {}); +}; + +const captureLegacy = (idx: ToolCallIndexer, id: string, timestamp: number, text: string, turnIndex: number) => { + const batch: CapturedBatch = { + turnIndex, + timestamp, + assistantText: "", + toolCalls: [{ toolCallId: id, toolName: "bash", args: { cmd: "ls" }, resultText: text, isError: false }], + }; + idx.addBatch(batch, () => {}); +}; + +// execute returns { content: [{ type: "text", text }], details } (src/query-tool.ts:73-76) +const runTool = async (indexer: ToolCallIndexer, toolCallIds: string[]): Promise => { + let registered: any; + registerQueryTool({ registerTool: (def: any) => (registered = def) } as any, indexer); + const result = await registered.execute("call-1", { toolCallIds }, undefined, undefined, undefined); + return result.content[0].text as string; +}; + +describe("context_tree_query occurrence handling", () => { + test("a bare id with two occurrences returns both blocks, chronologically", async () => { + const idx = new ToolCallIndexer(); + capture(idx, "bash_23", 2150, "SECOND", 1); + capture(idx, "bash_23", 1150, "FIRST", 0); + const text = await runTool(idx, ["bash_23"]); + expect(text.indexOf("FIRST")).toBeGreaterThan(-1); + expect(text.indexOf("SECOND")).toBeGreaterThan(-1); + expect(text.indexOf("FIRST")).toBeLessThan(text.indexOf("SECOND")); + expect(text.match(/## toolRef:/g)).toHaveLength(2); + }); + + test("an occurrence key returns exactly one block", async () => { + const idx = new ToolCallIndexer(); + capture(idx, "bash_23", 1150, "FIRST", 0); + capture(idx, "bash_23", 2150, "SECOND", 1); + const text = await runTool(idx, ["bash_23@2150"]); + expect(text.match(/## toolRef:/g)).toHaveLength(1); + expect(text).toContain("SECOND"); + }); + + test("an unknown id still reports not-found once", async () => { + const idx = new ToolCallIndexer(); + const text = await runTool(idx, ["nope"]); + expect(text).toContain("not found in index"); + }); + + test("a single match still labels with the caller's input and has no @ suffix", async () => { + const idx = new ToolCallIndexer(); + capture(idx, "bash_23", 1150, "FIRST", 0); + const text = await runTool(idx, ["bash_23"]); + expect(text.match(/## toolRef:/g)).toHaveLength(1); + expect(text).toContain("## toolRef: bash_23\n"); + expect(text).not.toContain("bash_23@"); + }); + + test("G5 conformance: a bare id whose collision was content-deduplicated still returns both occurrences via context_tree_query", async () => { + const idx = new ToolCallIndexer(); + capture(idx, "bash_23", 1150, "SAME", 0); + // Same bare id, later occurrence, content-deduplicated against the original. + idx.registerDuplicate("bash_23@9150", "bash_23@1150", () => {}); + + const records = idx.getRecordsForId("bash_23"); + expect(records).toHaveLength(2); + + const text = await runTool(idx, ["bash_23"]); + expect(text.match(/## toolRef:/g)).toHaveLength(2); + expect(text).toContain("bash_23@1150"); + expect(text).toContain("bash_23@9150"); + }); + + test("G4/C1: colliding short refs each resolve to their OWN batch through the registered tool", async () => { + const idx = new ToolCallIndexer(); + capture(idx, "bash_23", 1150, "FIRST", 0); + capture(idx, "bash_23", 2150, "SECOND", 1); + idx.registerSummaryRefs([ + { shortId: "t1", toolCallId: "bash_23", resultTimestamp: 1150 }, + { shortId: "t2", toolCallId: "bash_23", resultTimestamp: 2150 }, + ]); + + const firstText = await runTool(idx, ["t1"]); + expect(firstText.match(/## toolRef:/g)).toHaveLength(1); + expect(firstText).toContain("FIRST"); + expect(firstText).not.toContain("SECOND"); + + const secondText = await runTool(idx, ["t2"]); + expect(secondText.match(/## toolRef:/g)).toHaveLength(1); + expect(secondText).toContain("SECOND"); + expect(secondText).not.toContain("FIRST"); + }); + + test("mixed legacy + new occurrence under the same bare id: legacy record labels with the bare id plus an explicit note, not a fake @legacy key", async () => { + const idx = new ToolCallIndexer(); + captureLegacy(idx, "bash_23", 1000, "OLD", 0); + capture(idx, "bash_23", 2150, "NEW", 1); + const text = await runTool(idx, ["bash_23"]); + expect(text.match(/## toolRef:/g)).toHaveLength(2); + expect(text).toContain("## toolRef: bash_23\n"); + expect(text).toContain("Occurrence: legacy (no resultTimestamp)"); + expect(text).toContain("bash_23@2150"); + expect(text).not.toContain("undefined"); + expect(text).not.toContain("@legacy"); + }); + + test("backfilled record (turnIndex -1) renders 'Turn: -1' (cosmetic pin, ref #10)", async () => { + const idx = new ToolCallIndexer(); + const appended: Array<{ type: string; data: any }> = []; + await idx.backfillChainRecords( + [ + { + toolCallId: "bash_bf", + toolName: "bash", + args: { command: "ls" }, + resultText: "backfilled-output", + isError: false, + turnIndex: -1, + timestamp: 1, + resultTimestamp: 1, + }, + ], + { + spillThreshold: 100_000, + spillPreviewBytes: 500, + sessionDir: "/tmp/unused", + sessionId: "test-session", + appendEntry: (type: string, data?: unknown) => appended.push({ type, data }), + }, + ); + + const text = await runTool(idx, ["bash_bf@1"]); + expect(text).toContain("Turn: -1"); + }); +}); diff --git a/pi-condense/src/query-tool.ts b/pi-condense/src/query-tool.ts new file mode 100644 index 0000000..384c459 --- /dev/null +++ b/pi-condense/src/query-tool.ts @@ -0,0 +1,95 @@ +import { readFile } from "node:fs/promises"; +import { Type } from "@sinclair/typebox"; +import { truncateHead, DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES } from "@earendil-works/pi-coding-agent"; +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import type { ToolCallIndexer } from "./indexer.js"; +import { QUERY_TOOL_NAME } from "./types.js"; + +// Legacy records (persisted before resultTimestamp existed) have no timestamp +// to disambiguate. Do NOT mint an `id@legacy` string: it is occurrence-key- +// SHAPED but not a real key — parseOccKey would treat "legacy" as part of a +// literal id, so echoing it back into context_tree_query would fail lookup. +// Label with the bare id and call out the ambiguity in the block body instead. +function occurrenceLabel(id: string, resultTimestamp: number | undefined): string { + return resultTimestamp === undefined ? id : `${id}@${resultTimestamp}`; +} + +export function registerQueryTool(pi: ExtensionAPI, indexer: ToolCallIndexer): void { + pi.registerTool({ + name: QUERY_TOOL_NAME, + label: "Query Original Tool History", + description: + "Retrieve original tool call results that have been pruned from active context. Pass the short refs listed in a pruner-summary message, e.g. context_tree_query({ toolCallIds: [\"t12\", \"t3\"] }), to get back the full original outputs. A raw id that was reused returns every occurrence, each labelled id@timestamp.", + promptSnippet: "Retrieve original pruned tool outputs by short ref", + promptGuidelines: [ + "When you need the full output of a tool call that was summarized and pruned from context, use context_tree_query with the short refs listed in the relevant pruner-summary message.", + ], + parameters: Type.Object({ + toolCallIds: Type.Array(Type.String(), { + description: 'Required. One or more short refs (e.g. "t12") or raw tool call IDs from a pruner-summary message. A reused raw id returns every occurrence, each labelled id@timestamp.', + }), + }), + + async execute(_toolCallId, params, _signal, _onUpdate, _ctx) { + const foundRecords: Record = {}; + const blocks: string[] = []; + + for (const id of params.toolCallIds) { + const records = indexer.getRecordsForId(id); + + if (records.length === 0) { + blocks.push(`## toolRef: ${id}\n(not found in index — may not have been summarized yet)`); + continue; + } + + for (const record of records) { + // A reused provider id denotes several occurrences; return all of + // them rather than silently picking one. `tN` refs stay 1:1. + const label = records.length > 1 ? occurrenceLabel(id, record.resultTimestamp) : id; + + foundRecords[label] = record; + + const status = record.isError ? "ERROR" : "OK"; + const header = [ + `## toolRef: ${label}`, + ...(records.length > 1 && record.resultTimestamp === undefined ? ["Occurrence: legacy (no resultTimestamp)"] : []), + `Tool: ${record.toolName}`, + `Args: ${JSON.stringify(record.args, null, 2)}`, + `Status: ${status}`, + `Turn: ${record.turnIndex}`, + "", + ].join("\n"); + + let raw = record.resultText; + if (record.spillPath) { + try { + raw = await readFile(record.spillPath, "utf-8"); + } catch (err) { + console.error(`context_tree_query: failed to read spilled output at ${record.spillPath}:`, err); + raw = record.resultPreview ?? "(spilled output unavailable — sidecar file missing)"; + } + } + + const t = truncateHead(raw, { + maxLines: DEFAULT_MAX_LINES, + maxBytes: DEFAULT_MAX_BYTES, + }); + + let body = t.content; + if (t.truncated) { + body += `\n[Output truncated: ${t.outputLines}/${t.totalLines} lines shown]`; + } + + blocks.push(`${header}\n${body}`); + } + } + + const combined = blocks.join("\n\n---\n\n"); + + return { + content: [{ type: "text", text: combined }], + details: { results: foundRecords }, + }; + }, + }); +} diff --git a/pi-condense/src/range-compression.integration.test.ts b/pi-condense/src/range-compression.integration.test.ts new file mode 100644 index 0000000..cf9048d --- /dev/null +++ b/pi-condense/src/range-compression.integration.test.ts @@ -0,0 +1,650 @@ +import { describe, expect, test } from "bun:test"; +import { ToolCallIndexer } from "./indexer.js"; +import { BlockRefIssuer } from "./block-refs.js"; +import { compressEligible } from "./chain-compressor.js"; +import { pruneMessages } from "./pruner.js"; +import { detectChains } from "./chain-detector.js"; +import { isProtected } from "./protected.js"; +import { bareToolCallId, occKey } from "./occurrence-key.js"; +import { expectNoOrphanToolResults } from "./test-support.js"; +import { CUSTOM_TYPE_CHAIN, CUSTOM_TYPE_INDEX } from "./types.js"; +import type { ChainRange, ChainCompressionConfig } from "./types.js"; + +const noopDiagnostics = { report: () => {} }; +const testBackfill = { spillThreshold: 1_000_000, spillPreviewBytes: 2048, sessionDir: "/tmp", sessionId: "s1" }; + +// End-to-end of the in-memory B path (everything except the LLM call, which is +// the shared runSummarization already exercised live): a span's per-batch +// summaries are fused by compressEligible, the entry lands in the real indexer's +// chain registry, and pruneMessages renders the fused text as the synthetic body. +describe("range compression integration", () => { + test("fused range summary flows compressEligible → registry → render", async () => { + const indexer = new ToolCallIndexer(); + const blockRefs = new BlockRefIssuer(); + + // Two per-batch summaries covering the span's two tool calls. + indexer.registerSummaryRefs([ + { shortId: "t1", toolCallId: "tc1" }, + { shortId: "t2", toolCallId: "tc2" }, + ]); + indexer.registerSummaryBody(["tc1"], "summary of batch 1"); + indexer.registerSummaryBody(["tc2"], "summary of batch 2"); + + const chain: ChainRange = { + startUserTimestamp: 100, + middleToolCallIds: ["tc1", "tc2"], + finalAssistantTimestamp: 400, + }; + + const fuseInputs: string[] = []; + const { compressedEntries } = await compressEligible([chain], 0, { + indexer, + blockRefs, + appendEntry: () => {}, + now: () => 999, + fuseRange: async (text) => { + fuseInputs.push(text); + return "FUSED COHESIVE SUMMARY"; + }, + messages: [], + diagnostics: noopDiagnostics, + backfill: testBackfill, + }); + + // Fusion received the concatenated per-batch summaries and stored its result. + expect(fuseInputs).toEqual(["summary of batch 1\n\nsummary of batch 2"]); + expect(compressedEntries).toHaveLength(1); + expect(compressedEntries[0].rangeSummaryText).toBe("FUSED COHESIVE SUMMARY"); + // Entry is now in the real registry (what the renderer reads). + expect(indexer.getChainEntries()[0].rangeSummaryText).toBe("FUSED COHESIVE SUMMARY"); + + const messages: any[] = [ + { role: "user", content: [{ type: "text", text: "go" }], timestamp: 100 }, + { role: "assistant", content: [{ type: "toolCall", id: "tc1", name: "bash", arguments: {} }], timestamp: 200, usage: {}, stopReason: "tool_use" }, + { role: "toolResult", toolCallId: "tc1", toolName: "bash", content: [{ type: "text", text: "o1" }], isError: false, timestamp: 210 }, + { role: "assistant", content: [{ type: "toolCall", id: "tc2", name: "bash", arguments: {} }], timestamp: 300, usage: {}, stopReason: "tool_use" }, + { role: "toolResult", toolCallId: "tc2", toolName: "bash", content: [{ type: "text", text: "o2" }], isError: false, timestamp: 310 }, + { role: "assistant", content: [{ type: "text", text: "done" }], timestamp: 400, usage: {}, stopReason: "end_turn" }, + ]; + + const cc: ChainCompressionConfig = { + enabled: true, + rollingWindow: 0, + stripFinalAssistantThinking: true, + fuseRangeSummary: true, + }; + const { messages: out, pruned } = pruneMessages(messages, indexer, cc); + expect(pruned).toBe(true); + + const synthetic = out.find( + (m: any) => m.role === "user" && typeof m.content?.[0]?.text === "string" && m.content[0].text.startsWith(" m.role === "toolResult")).toHaveLength(0); + }); + + test("protected tool output is relocated into synthetic block", async () => { + const indexer = new ToolCallIndexer(); + const blockRefs = new BlockRefIssuer(); + + // tc1 = bash (non-protected), tc2 = todowrite (protected) + indexer.registerSummaryRefs([{ shortId: "t1", toolCallId: "tc1" }]); + indexer.registerSummaryBody(["tc1"], "bash output summary"); + + const chain: ChainRange = { + startUserTimestamp: 100, + middleToolCallIds: ["tc1", "tc2"], + finalAssistantTimestamp: 400, + protectedToolCallIds: ["tc2"], + }; + + const { compressedEntries } = await compressEligible([chain], 0, { + indexer, + blockRefs, + appendEntry: () => {}, + now: () => 999, + messages: [], + diagnostics: noopDiagnostics, + backfill: testBackfill, + }); + + expect(compressedEntries).toHaveLength(1); + // protectedToolCallIds round-trips through the real registry + expect(indexer.getChainEntries()[0].protectedToolCallIds).toEqual(["tc2"]); + + const messages: any[] = [ + { role: "user", content: [{ type: "text", text: "go" }], timestamp: 100 }, + { role: "assistant", content: [{ type: "toolCall", id: "tc1", name: "bash", arguments: {} }], timestamp: 200, usage: {}, stopReason: "tool_use" }, + { role: "toolResult", toolCallId: "tc1", toolName: "bash", content: [{ type: "text", text: "bash-result" }], isError: false, timestamp: 210 }, + { role: "assistant", content: [{ type: "toolCall", id: "tc2", name: "todowrite", arguments: {} }], timestamp: 300, usage: {}, stopReason: "tool_use" }, + { role: "toolResult", toolCallId: "tc2", toolName: "todowrite", content: [{ type: "text", text: "PLAN-STATE-XYZ" }], isError: false, timestamp: 310 }, + { role: "assistant", content: [{ type: "text", text: "done" }], timestamp: 400, usage: {}, stopReason: "end_turn" }, + ]; + + const cc: ChainCompressionConfig = { + enabled: true, + rollingWindow: 0, + stripFinalAssistantThinking: true, + fuseRangeSummary: false, + }; + const { messages: out, pruned } = pruneMessages(messages, indexer, cc); + expect(pruned).toBe(true); + + const synthetic = out.find( + (m: any) => m.role === "user" && typeof m.content?.[0]?.text === "string" && m.content[0].text.startsWith("'); + expect(synthetic.content[0].text).toContain("PLAN-STATE-XYZ"); + // Protected toolResult is no longer a standalone message + expect(out.filter((m: any) => m.role === "toolResult")).toHaveLength(0); + // protected tool has no short ref in production → absent from the tools= attribute + expect(synthetic.content[0].text).not.toContain("t2"); + }); + + test("path-protected output is relocated via detectChains predicate", async () => { + const indexer = new ToolCallIndexer(); + const blockRefs = new BlockRefIssuer(); + + // tc1 = read /h/src/app.ts (unprotected), tc2 = read /h/skills/x/SKILL.md (protected) + const messages: any[] = [ + { role: "user", content: [{ type: "text", text: "go" }], timestamp: 100 }, + { + role: "assistant", + content: [{ type: "toolCall", id: "tc1", name: "read", input: { path: "/h/src/app.ts" } }], + timestamp: 200, + usage: {}, + stopReason: "tool_use", + }, + { + role: "toolResult", + toolCallId: "tc1", + toolName: "read", + content: [{ type: "text", text: "app-source-code" }], + isError: false, + timestamp: 210, + }, + { + role: "assistant", + content: [{ type: "toolCall", id: "tc2", name: "read", input: { path: "/h/skills/x/SKILL.md" } }], + timestamp: 300, + usage: {}, + stopReason: "tool_use", + }, + { + role: "toolResult", + toolCallId: "tc2", + toolName: "read", + content: [{ type: "text", text: "SKILL-VERBATIM-CONTENT" }], + isError: false, + timestamp: 310, + }, + { role: "assistant", content: [{ type: "text", text: "done" }], timestamp: 400, usage: {}, stopReason: "end_turn" }, + ]; + + const pred = (name: string, args: unknown) => + isProtected(name, args, { protectedTools: [], protectedPaths: ["**/skills/**/*.md"] }); + const chains = detectChains(messages, pred); + + expect(chains).toHaveLength(1); + expect(chains[0].protectedToolCallIds).toEqual(["tc2"]); + + // Only unprotected tc1 has a per-batch summary; tc2 is protected, no short ref. + // Keyed by the occurrence key detectChains actually produced for tc1's result + // (id + resultTimestamp), not the bare toolCallId, so this can't silently drift. + const tc1OccKey = chains[0].middleOccurrenceKeys!.find((k) => bareToolCallId(k) === "tc1")!; + expect(tc1OccKey).toBeDefined(); + indexer.registerSummaryRefs([{ shortId: "t1", toolCallId: "tc1" }]); + indexer.registerSummaryBody([tc1OccKey], "read app.ts summary"); + + const { compressedEntries } = await compressEligible(chains, 0, { + indexer, + blockRefs, + appendEntry: () => {}, + now: () => 999, + messages, + diagnostics: noopDiagnostics, + backfill: testBackfill, + }); + + expect(compressedEntries).toHaveLength(1); + expect(indexer.getChainEntries()[0].protectedToolCallIds).toEqual(["tc2"]); + + const cc: ChainCompressionConfig = { + enabled: true, + rollingWindow: 0, + stripFinalAssistantThinking: true, + fuseRangeSummary: false, + }; + const { messages: out, pruned } = pruneMessages(messages, indexer, cc); + expect(pruned).toBe(true); + + const synthetic = out.find( + (m: any) => m.role === "user" && typeof m.content?.[0]?.text === "string" && m.content[0].text.startsWith("'); + expect(synthetic.content[0].text).toContain("SKILL-VERBATIM-CONTENT"); + // Unprotected result text is not present in the synthetic block + expect(synthetic.content[0].text).not.toContain("app-source-code"); + // No standalone toolResult messages remain + expect(out.filter((m: any) => m.role === "toolResult")).toHaveLength(0); + // Protected tc2 has no short ref → absent from the tools= attribute + expect(synthetic.content[0].text).not.toContain("t2"); + }); + + test("falls back to per-batch concat when fuseRange is absent", async () => { + const indexer = new ToolCallIndexer(); + const blockRefs = new BlockRefIssuer(); + indexer.registerSummaryRefs([ + { shortId: "t1", toolCallId: "tc1" }, + { shortId: "t2", toolCallId: "tc2" }, + ]); + indexer.registerSummaryBody(["tc1"], "batch one body"); + indexer.registerSummaryBody(["tc2"], "batch two body"); + + const chain: ChainRange = { startUserTimestamp: 100, middleToolCallIds: ["tc1", "tc2"], finalAssistantTimestamp: 400 }; + await compressEligible([chain], 0, { + indexer, + blockRefs, + appendEntry: () => {}, + now: () => 1, + messages: [], + diagnostics: noopDiagnostics, + backfill: testBackfill, + }); + + const messages: any[] = [ + { role: "user", content: [{ type: "text", text: "go" }], timestamp: 100 }, + { role: "assistant", content: [{ type: "toolCall", id: "tc1", name: "bash", arguments: {} }], timestamp: 200, usage: {}, stopReason: "tool_use" }, + { role: "toolResult", toolCallId: "tc1", toolName: "bash", content: [{ type: "text", text: "o1" }], isError: false, timestamp: 210 }, + { role: "assistant", content: [{ type: "toolCall", id: "tc2", name: "bash", arguments: {} }], timestamp: 300, usage: {}, stopReason: "tool_use" }, + { role: "toolResult", toolCallId: "tc2", toolName: "bash", content: [{ type: "text", text: "o2" }], isError: false, timestamp: 310 }, + { role: "assistant", content: [{ type: "text", text: "done" }], timestamp: 400, usage: {}, stopReason: "end_turn" }, + ]; + const cc: ChainCompressionConfig = { enabled: true, rollingWindow: 0, stripFinalAssistantThinking: true, fuseRangeSummary: false }; + const { messages: out } = pruneMessages(messages, indexer, cc); + const synthetic = out.find((m: any) => m.role === "user" && m.content?.[0]?.text?.startsWith(" { + const indexer = new ToolCallIndexer(); + const blockRefs = new BlockRefIssuer(); + + // Chain A (covered): per-batch summary exists for tcA. + indexer.registerSummaryRefs([{ shortId: "t1", toolCallId: "tcA" }]); + indexer.registerSummaryBody(["tcA"], "summary of tcA"); + + const messages: any[] = [ + { role: "user", content: [{ type: "text", text: "go A" }], timestamp: 100 }, + { role: "assistant", content: [{ type: "toolCall", id: "tcA", name: "bash", arguments: {} }], timestamp: 200, usage: {}, stopReason: "tool_use" }, + { role: "toolResult", toolCallId: "tcA", toolName: "bash", content: [{ type: "text", text: "outA" }], isError: false, timestamp: 210 }, + { role: "assistant", content: [{ type: "text", text: "done A" }], timestamp: 400, usage: {}, stopReason: "end_turn" }, + // Chain B (uncovered): no per-batch summary for tcB1/tcB2. + { role: "user", content: [{ type: "text", text: "go B" }], timestamp: 1000 }, + { role: "assistant", content: [{ type: "toolCall", id: "tcB1", name: "bash", arguments: { cmd: "one" } }], timestamp: 1100, usage: {}, stopReason: "tool_use" }, + { role: "toolResult", toolCallId: "tcB1", toolName: "bash", content: [{ type: "text", text: "outB1" }], isError: false, timestamp: 1110 }, + { role: "assistant", content: [{ type: "toolCall", id: "tcB2", name: "bash", arguments: { cmd: "two" } }], timestamp: 1200, usage: {}, stopReason: "tool_use" }, + { role: "toolResult", toolCallId: "tcB2", toolName: "bash", content: [{ type: "text", text: "outB2" }], isError: false, timestamp: 1210 }, + { role: "assistant", content: [{ type: "text", text: "done B" }], timestamp: 1400, usage: {}, stopReason: "end_turn" }, + ]; + + const chainA: ChainRange = { startUserTimestamp: 100, middleToolCallIds: ["tcA"], finalAssistantTimestamp: 400 }; + const chainB: ChainRange = { + startUserTimestamp: 1000, + middleToolCallIds: ["tcB1", "tcB2"], + middleOccurrenceKeys: [occKey("tcB1", 1110), occKey("tcB2", 1210)], + finalAssistantTimestamp: 1400, + }; + + const { compressedEntries } = await compressEligible([chainA, chainB], 0, { + indexer, + blockRefs, + appendEntry: () => {}, + now: () => 999, + messages, + diagnostics: noopDiagnostics, + backfill: testBackfill, + }); + + expect(compressedEntries).toHaveLength(2); + const entryA = compressedEntries.find((e) => e.startUserTimestamp === 100)!; + const entryB = compressedEntries.find((e) => e.startUserTimestamp === 1000)!; + expect(entryA.bodySource).toBeUndefined(); + expect(entryB.bodySource).toBe("deterministic"); + + const cc: ChainCompressionConfig = { enabled: true, rollingWindow: 0, stripFinalAssistantThinking: true, fuseRangeSummary: false }; + const { messages: out } = pruneMessages(messages, indexer, cc); + + const synthetics = out.filter((m: any) => m.role === "user" && typeof m.content?.[0]?.text === "string" && m.content[0].text.startsWith(" s.content[0].text.includes("Deterministic chain compression")); + expect(uncoveredSynthetic).toBeDefined(); + expectNoOrphanToolResults(out); + + // Covered-output identity: the presence of chain B's deterministic branch + // must not perturb chain A's rendering at all. Rebuild chain A alone (chain + // B never existed) and require byte-identical entry + rendered text. + const referenceIndexer = new ToolCallIndexer(); + const referenceBlockRefs = new BlockRefIssuer(); + referenceIndexer.registerSummaryRefs([{ shortId: "t1", toolCallId: "tcA" }]); + referenceIndexer.registerSummaryBody(["tcA"], "summary of tcA"); + const referenceMessages = messages.slice(0, 4); // chain A's own span only + const { compressedEntries: refEntries } = await compressEligible([chainA], 0, { + indexer: referenceIndexer, + blockRefs: referenceBlockRefs, + appendEntry: () => {}, + now: () => 999, + messages: referenceMessages, + diagnostics: noopDiagnostics, + backfill: testBackfill, + }); + expect(refEntries).toHaveLength(1); + expect(entryA).toEqual(refEntries[0]); + + const { messages: refOut } = pruneMessages(referenceMessages, referenceIndexer, cc); + const refSynthetic = refOut.find( + (m: any) => m.role === "user" && typeof m.content?.[0]?.text === "string" && m.content[0].text.startsWith(" !s.content[0].text.includes("Deterministic chain compression")); + expect(coveredSynthetic).toEqual(refSynthetic); + }); + + test("recovery through backfill: an uncovered chain's raw output is recoverable via its t ref", async () => { + const indexer = new ToolCallIndexer(); + const blockRefs = new BlockRefIssuer(); + + const messages: any[] = [ + { role: "user", content: [{ type: "text", text: "go" }], timestamp: 1000 }, + { role: "assistant", content: [{ type: "toolCall", id: "tcB1", name: "bash", arguments: { cmd: "one" } }], timestamp: 1100, usage: {}, stopReason: "tool_use" }, + { role: "toolResult", toolCallId: "tcB1", toolName: "bash", content: [{ type: "text", text: "outB1 raw" }], isError: false, timestamp: 1110 }, + { role: "assistant", content: [{ type: "text", text: "done" }], timestamp: 1400, usage: {}, stopReason: "end_turn" }, + ]; + const chainB: ChainRange = { + startUserTimestamp: 1000, + middleToolCallIds: ["tcB1"], + middleOccurrenceKeys: [occKey("tcB1", 1110)], + finalAssistantTimestamp: 1400, + }; + + const { compressedEntries } = await compressEligible([chainB], 0, { + indexer, + blockRefs, + appendEntry: () => {}, + now: () => 999, + messages, + diagnostics: noopDiagnostics, + backfill: testBackfill, + }); + + expect(compressedEntries).toHaveLength(1); + const ref = compressedEntries[0].toolRefs[0]; + const resolved = indexer.resolveToolCallId(ref); + expect(resolved).toBeDefined(); + expect(indexer.getRecord(resolved!)?.resultText).toBe("outB1 raw"); + }); + + test("restart mid-failure-window: index entry persists even when the chain-entry append fails, and converges on retry", async () => { + const indexer = new ToolCallIndexer(); + const blockRefs = new BlockRefIssuer(); + + const messages: any[] = [ + { role: "user", content: [{ type: "text", text: "go" }], timestamp: 1000 }, + { role: "assistant", content: [{ type: "toolCall", id: "tcB1", name: "bash", arguments: { cmd: "one" } }], timestamp: 1100, usage: {}, stopReason: "tool_use" }, + { role: "toolResult", toolCallId: "tcB1", toolName: "bash", content: [{ type: "text", text: "outB1 raw" }], isError: false, timestamp: 1110 }, + { role: "assistant", content: [{ type: "text", text: "done" }], timestamp: 1400, usage: {}, stopReason: "end_turn" }, + ]; + const chainB: ChainRange = { + startUserTimestamp: 1000, + middleToolCallIds: ["tcB1"], + middleOccurrenceKeys: [occKey("tcB1", 1110)], + finalAssistantTimestamp: 1400, + }; + + const captured: Array<{ type: string; data: unknown }> = []; + const flakyAppendEntry = (type: string, data?: unknown) => { + if (type === CUSTOM_TYPE_CHAIN) throw new Error("session write failed"); + captured.push({ type, data }); + }; + + // compressEligible does not catch chain-entry append failures itself + // (index.ts's caller wraps the whole call in try/catch); the throw + // propagates, but the index entry (with its refs) is already durable in + // `captured` by the time it does, per the append-before-commit ordering. + await expect( + compressEligible([chainB], 0, { + indexer, + blockRefs, + appendEntry: flakyAppendEntry, + now: () => 999, + messages, + diagnostics: noopDiagnostics, + backfill: testBackfill, + }), + ).rejects.toThrow("session write failed"); + + expect(captured.filter((c) => c.type === CUSTOM_TYPE_INDEX)).toHaveLength(1); + expect(captured.filter((c) => c.type === CUSTOM_TYPE_CHAIN)).toHaveLength(0); + + // Rebuild a fresh indexer from exactly what got captured (simulating a restart). + const branch = captured.map(({ type, data }) => ({ type: "custom", customType: type, data })); + const rebuilt = new ToolCallIndexer(); + rebuilt.reconstructFromSession({ sessionManager: { getBranch: () => branch } } as any); + + // Re-run with an honest appendEntry: records are already indexed, so this + // composes-and-persists the chain entry without re-backfilling. + const captured2: Array<{ type: string; data: unknown }> = []; + const round2 = await compressEligible([chainB], 0, { + indexer: rebuilt, + blockRefs, + appendEntry: (type, data) => captured2.push({ type, data }), + now: () => 1000, + messages, + diagnostics: noopDiagnostics, + backfill: testBackfill, + }); + + expect(round2.compressedEntries).toHaveLength(1); + expect(captured2.filter((c) => c.type === CUSTOM_TYPE_INDEX)).toHaveLength(0); + expect(captured2.filter((c) => c.type === CUSTOM_TYPE_CHAIN)).toHaveLength(1); + + // Refs are identical to round 1's - the index entry (with its refs) never + // got re-persisted, so round 2 reused the durable ref from round 1. + const round1IndexEntry = captured.find((c) => c.type === CUSTOM_TYPE_INDEX)!.data as any; + const round1Refs = round1IndexEntry.refs.map((r: { shortId: string }) => r.shortId); + expect(round2.compressedEntries[0].toolRefs).toEqual(round1Refs); + }); + + test("multi-chain compact: three uncovered chains compress in one compressEligible call", async () => { + const indexer = new ToolCallIndexer(); + const blockRefs = new BlockRefIssuer(); + + const messages: any[] = []; + const chains: ChainRange[] = []; + for (let i = 0; i < 3; i++) { + const base = 1000 + i * 1000; + const startTs = base; + const callTs = base + 100; + const resultTs = base + 110; + const finalTs = base + 400; + const id = `tc${i}`; + messages.push( + { role: "user", content: [{ type: "text", text: `go ${i}` }], timestamp: startTs }, + { role: "assistant", content: [{ type: "toolCall", id, name: "bash", arguments: { cmd: id } }], timestamp: callTs, usage: {}, stopReason: "tool_use" }, + { role: "toolResult", toolCallId: id, toolName: "bash", content: [{ type: "text", text: `out ${i}` }], isError: false, timestamp: resultTs }, + { role: "assistant", content: [{ type: "text", text: `done ${i}` }], timestamp: finalTs, usage: {}, stopReason: "end_turn" }, + ); + chains.push({ + startUserTimestamp: startTs, + middleToolCallIds: [id], + middleOccurrenceKeys: [occKey(id, resultTs)], + finalAssistantTimestamp: finalTs, + }); + } + + const { compressedEntries } = await compressEligible(chains, 0, { + indexer, + blockRefs, + appendEntry: () => {}, + now: () => 9999, + messages, + diagnostics: noopDiagnostics, + backfill: testBackfill, + }); + + expect(compressedEntries).toHaveLength(3); + expect(compressedEntries.every((e) => e.bodySource === "deterministic")).toBe(true); + }); + + test("fully-deduped chain: backfilled via index-membership filter (not isSummarized), compressed, pre-existing alias entries still resolve", async () => { + const indexer = new ToolCallIndexer(); + const blockRefs = new BlockRefIssuer(); + + // Originals indexed in an earlier flush (as if summarized then). + indexer.addBatch( + { + turnIndex: 0, + timestamp: 10, + assistantText: "", + toolCalls: [ + { toolCallId: "origA", toolName: "bash", args: {}, resultText: "SAME_A", isError: false, resultTimestamp: 20 }, + { toolCallId: "origB", toolName: "bash", args: {}, resultText: "SAME_B", isError: false, resultTimestamp: 21 }, + ], + }, + () => {}, + ); + indexer.registerSummaryBody([occKey("origA", 20), occKey("origB", 21)], "summary of originals"); + indexer.registerSummaryRefs([ + { shortId: "t1", toolCallId: "origA", resultTimestamp: 20 }, + { shortId: "t2", toolCallId: "origB", resultTimestamp: 21 }, + ]); + + // Pre-existing, unrelated duplicate of origA (registered before the chain + // under test even ran) - the control used below to prove the chain's own + // backfill does not disturb unrelated alias entries. + indexer.registerDuplicate(occKey("dup3", 30), occKey("origA", 20), () => {}); + + // The chain's own middle calls (tc1, tc2) were FULLY deduped by the + // pre-flush content-hash pass: both matched already-indexed originals, + // so neither was ever sent to the summarizer and neither has a direct + // index entry - only a dedup-alias entry pointing at origA/origB. + indexer.registerDuplicate(occKey("tc1", 110), occKey("origA", 20), () => {}); + indexer.registerDuplicate(occKey("tc2", 120), occKey("origB", 21), () => {}); + + // isSummarized is true (dedup-alias hit) but the record is NOT in the + // index map directly - this is exactly the distinction the backfill + // filter (index membership) must honor instead of isSummarized(). + expect(indexer.isSummarized(occKey("tc1", 110))).toBe(true); + expect(indexer.getIndex().has(occKey("tc1", 110))).toBe(false); + expect(indexer.isSummarized(occKey("tc2", 120))).toBe(true); + expect(indexer.getIndex().has(occKey("tc2", 120))).toBe(false); + + const messages: any[] = [ + { role: "user", content: [{ type: "text", text: "go" }], timestamp: 100 }, + { role: "assistant", content: [{ type: "toolCall", id: "tc1", name: "bash", arguments: {} }], timestamp: 105, usage: {}, stopReason: "tool_use" }, + { role: "toolResult", toolCallId: "tc1", toolName: "bash", content: [{ type: "text", text: "SAME_A" }], isError: false, timestamp: 110 }, + { role: "assistant", content: [{ type: "toolCall", id: "tc2", name: "bash", arguments: {} }], timestamp: 115, usage: {}, stopReason: "tool_use" }, + { role: "toolResult", toolCallId: "tc2", toolName: "bash", content: [{ type: "text", text: "SAME_B" }], isError: false, timestamp: 120 }, + { role: "assistant", content: [{ type: "text", text: "done" }], timestamp: 200, usage: {}, stopReason: "end_turn" }, + ]; + const chain: ChainRange = { + startUserTimestamp: 100, + middleToolCallIds: ["tc1", "tc2"], + middleOccurrenceKeys: [occKey("tc1", 110), occKey("tc2", 120)], + finalAssistantTimestamp: 200, + }; + + // Zero coverage: no summary body was ever registered for tc1/tc2's own keys. + expect(indexer.hasPerBatchSummaryCoveringAny([occKey("tc1", 110), occKey("tc2", 120)])).toBe(false); + + const { compressedEntries } = await compressEligible([chain], 0, { + indexer, + blockRefs, + appendEntry: () => {}, + now: () => 1, + messages, + diagnostics: noopDiagnostics, + backfill: testBackfill, + }); + + expect(compressedEntries).toHaveLength(1); + expect(compressedEntries[0].bodySource).toBe("deterministic"); + + // tc1/tc2 are now directly backfilled (index-membership filter let them through). + expect(indexer.getRecord(occKey("tc1", 110))?.resultText).toBe("SAME_A"); + expect(indexer.getRecord(occKey("tc2", 120))?.resultText).toBe("SAME_B"); + + // Pre-existing, unrelated alias entry (dup3 -> origA) still resolves unchanged. + expect(indexer.resolveToolCallId(occKey("dup3", 30))).toBe(occKey("origA", 20)); + expect(indexer.getRecord(occKey("dup3", 30))?.resultText).toBe("SAME_A"); + + // Backfilled records never seed contentHashToOriginal - the canonical for + // "SAME_A"/"SAME_B" content stays origA/origB, unpoisoned by tc1/tc2. + expect(indexer.lookupByContent("bash", "SAME_A")).toBe(occKey("origA", 20)); + expect(indexer.lookupByContent("bash", "SAME_B")).toBe(occKey("origB", 21)); + }); + + test("protected member in an uncovered chain: excluded from backfill index+refs, relocated verbatim at render, unprotected middle dropped", async () => { + const indexer = new ToolCallIndexer(); + const blockRefs = new BlockRefIssuer(); + + const messages: any[] = [ + { role: "user", content: [{ type: "text", text: "go" }], timestamp: 1000 }, + { role: "assistant", content: [{ type: "toolCall", id: "tcB1", name: "bash", arguments: { cmd: "one" } }], timestamp: 1100, usage: {}, stopReason: "tool_use" }, + { role: "toolResult", toolCallId: "tcB1", toolName: "bash", content: [{ type: "text", text: "outB1" }], isError: false, timestamp: 1110 }, + { role: "assistant", content: [{ type: "toolCall", id: "tcB2", name: "todowrite", arguments: {} }], timestamp: 1200, usage: {}, stopReason: "tool_use" }, + { role: "toolResult", toolCallId: "tcB2", toolName: "todowrite", content: [{ type: "text", text: "PLAN-STATE-XYZ" }], isError: false, timestamp: 1210 }, + { role: "assistant", content: [{ type: "text", text: "done" }], timestamp: 1400, usage: {}, stopReason: "end_turn" }, + ]; + const chainB: ChainRange = { + startUserTimestamp: 1000, + middleToolCallIds: ["tcB1", "tcB2"], + middleOccurrenceKeys: [occKey("tcB1", 1110), occKey("tcB2", 1210)], + finalAssistantTimestamp: 1400, + protectedToolCallIds: ["tcB2"], + }; + + const { compressedEntries } = await compressEligible([chainB], 0, { + indexer, + blockRefs, + appendEntry: () => {}, + now: () => 999, + messages, + diagnostics: noopDiagnostics, + backfill: testBackfill, + }); + + expect(compressedEntries).toHaveLength(1); + const entry = compressedEntries[0]; + expect(entry.bodySource).toBe("deterministic"); + + // (a) protected id absent from the backfilled index entry's records and from toolRefs. + expect(indexer.getRecord(occKey("tcB2", 1210))).toBeUndefined(); + expect(indexer.getRecord(occKey("tcB1", 1110))).toBeDefined(); + expect(entry.toolRefs).toHaveLength(1); + expect(entry.toolRefs.map((r) => indexer.resolveToolCallId(r))).not.toContain(occKey("tcB2", 1210)); + + // (b) + (c) render: protected relocated verbatim inside the compressed-chain block; unprotected middle dropped. + const cc: ChainCompressionConfig = { enabled: true, rollingWindow: 0, stripFinalAssistantThinking: true, fuseRangeSummary: false }; + const { messages: out } = pruneMessages(messages, indexer, cc); + const synthetic = out.find( + (m: any) => m.role === "user" && typeof m.content?.[0]?.text === "string" && m.content[0].text.startsWith("'); + expect(synthetic.content[0].text).toContain("PLAN-STATE-XYZ"); + expect(out.filter((m: any) => m.role === "toolResult")).toHaveLength(0); + expectNoOrphanToolResults(out); + }); +}); diff --git a/pi-condense/src/recovery-grace.test.ts b/pi-condense/src/recovery-grace.test.ts new file mode 100644 index 0000000..4a4bad9 --- /dev/null +++ b/pi-condense/src/recovery-grace.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect } from "bun:test"; +import { inGraceRecoveryToolCallIds } from "./recovery-grace.js"; + +const user = () => ({ role: "user", content: [{ type: "text", text: "u" }] }); +const ctq = (id: string) => ({ role: "toolResult", toolCallId: id, toolName: "context_tree_query", content: [{ type: "text", text: "x" }] }); +const ctqAt = (id: string, timestamp: number) => ({ role: "toolResult", toolCallId: id, toolName: "context_tree_query", content: [{ type: "text", text: "x" }], timestamp }); +const bash = (id: string) => ({ role: "toolResult", toolCallId: id, toolName: "bash", content: [{ type: "text", text: "x" }] }); + +describe("inGraceRecoveryToolCallIds", () => { + it("includes a recovery output in the current user-turn-group (age 0)", () => { + const msgs = [user(), ctq("t1")]; + expect([...inGraceRecoveryToolCallIds(msgs, 3)]).toEqual(["t1"]); + }); + it("includes a recovery output exactly K user-turns old", () => { + const msgs = [user(), ctq("t1"), user(), user(), user()]; + expect(inGraceRecoveryToolCallIds(msgs, 3).has("t1")).toBe(true); + }); + it("excludes a recovery output older than K user-turns", () => { + const msgs = [user(), ctq("t1"), user(), user(), user(), user()]; + expect(inGraceRecoveryToolCallIds(msgs, 3).has("t1")).toBe(false); + }); + it("returns empty set when grace disabled (K=0)", () => { + const msgs = [user(), ctq("t1")]; + expect(inGraceRecoveryToolCallIds(msgs, 0).size).toBe(0); + }); + it("ignores non-recovery tool outputs", () => { + const msgs = [user(), bash("t1"), ctq("t2")]; + expect([...inGraceRecoveryToolCallIds(msgs, 3)]).toEqual(["t2"]); + }); + it("judges multiple recovery outputs by their own positions", () => { + const msgs = [user(), ctq("t1"), user(), user(), user(), user(), ctq("t2")]; + const set = inGraceRecoveryToolCallIds(msgs, 3); + expect(set.has("t1")).toBe(false); + expect(set.has("t2")).toBe(true); + }); + it("keys a timestamped recovery output by occurrence (id@timestamp), not the bare id", () => { + const msgs = [user(), ctqAt("reused", 100)]; + const set = inGraceRecoveryToolCallIds(msgs, 3); + expect(set.has("reused@100")).toBe(true); + expect(set.has("reused")).toBe(false); + }); + it("does not conflate two occurrences of the same reused bare id", () => { + const msgs = [user(), ctqAt("reused", 100), user(), user(), user(), user(), ctqAt("reused", 200)]; + const set = inGraceRecoveryToolCallIds(msgs, 3); + expect(set.has("reused@100")).toBe(false); + expect(set.has("reused@200")).toBe(true); + }); +}); diff --git a/pi-condense/src/recovery-grace.ts b/pi-condense/src/recovery-grace.ts new file mode 100644 index 0000000..1f6a366 --- /dev/null +++ b/pi-condense/src/recovery-grace.ts @@ -0,0 +1,42 @@ +import { QUERY_TOOL_NAME } from "./types.js"; +import { occKey } from "./occurrence-key.js"; + +/** + * Set of `context_tree_query` occurrence keys (or bare ids when the message + * carries no timestamp) still inside the recovery grace window, computed + * positionally from the message array (no stored metadata). + * + * Keyed by occurrence, not bare id: the pruner's fail-closed ladder in + * `pruneMessages` looks this set up with the SAME `occKey(id, timestamp)` it + * computed for the message under test, so a graced occurrence never leaks + * protection onto a different, later occurrence that happens to reuse the + * same provider id. + * + * A recovery output's "user-turn-group" is the count of `role === "user"` + * messages at or before its position; its age is `nowUTG - that count`, where + * `nowUTG` is the total user messages in the array. It is in grace while + * `age <= graceTurns`. Works uniformly for render-context and session-branch + * arrays, so pruner Phase 1 and chain-compressor eligibility share one rule. + * + * `graceTurns <= 0` returns an empty set (feature disabled). + */ +export function inGraceRecoveryToolCallIds(messages: any[], graceTurns: number): Set { + const result = new Set(); + if (!(graceTurns > 0)) return result; + + let nowUTG = 0; + for (const m of messages) if (m?.role === "user") nowUTG++; + + let seen = 0; + for (const m of messages) { + if (m?.role === "user") { + seen++; + continue; + } + if (m?.role === "toolResult" && m.toolName === QUERY_TOOL_NAME && typeof m.toolCallId === "string") { + const key = typeof m.timestamp === "number" ? occKey(m.toolCallId, m.timestamp) : m.toolCallId; + if (nowUTG - seen <= graceTurns) result.add(key); + } + } + return result; +} diff --git a/pi-condense/src/reload-rearm.integration.test.ts b/pi-condense/src/reload-rearm.integration.test.ts new file mode 100644 index 0000000..83f4fe6 --- /dev/null +++ b/pi-condense/src/reload-rearm.integration.test.ts @@ -0,0 +1,647 @@ +import { describe, it, expect, mock } from "bun:test"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import * as actualCompat from "@earendil-works/pi-ai/compat"; + +// This must run before any module that transitively reads PI_CODING_AGENT_DIR +// (src/config.ts's getAgentDir()) is imported/executed. +const tmpAgentDir = mkdtempSync(join(tmpdir(), "pi-condense-rearm-")); +process.env.PI_CODING_AGENT_DIR = tmpAgentDir; +writeFileSync( + join(tmpAgentDir, "settings.json"), + JSON.stringify({ + contextPrune: { + enabled: true, + pruneOn: "agent-message", + batchingMode: "agent-message", + autoBudgetThreshold: 0.5, + summarizerModel: "default", + minBatchChars: 1, + showPruneStatusLine: true, + chainCompression: { + enabled: false, + rollingWindow: 3, + stripFinalAssistantThinking: true, + fuseRangeSummary: true, + }, + }, + }), +); + +let summarizerCalls = 0; + +const USAGE = { + input: 1, + output: 1, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 2, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, +}; + +function okStream() { + return { + async *[Symbol.asyncIterator]() {}, + async result() { + return { stopReason: "stop", content: [{ type: "text", text: "[[1:read]] summary" }], usage: USAGE }; + }, + }; +} + +let streamImpl: (model: any, input?: any, opts?: any) => any = () => { + summarizerCalls++; + return okStream(); +}; + +mock.module("@earendil-works/pi-ai/compat", () => ({ + ...actualCompat, + stream: (...args: any[]) => streamImpl(...args), +})); + +type AppendedEntry = { type: string; data: unknown }; + +// The captured-but-unflushed batch: a user message, an assistant message with +// one open toolCall (no closing text-only assistant — chain stays open), and +// its toolResult. This is what a reload's branch rescan must pick up +// (src/batch-capture.ts captureUnindexedBatchesFromSession). +function defaultBranch(): any[] { + return [ + { type: "message", message: { role: "user", content: [{ type: "text", text: "read the file" }] } }, + { + type: "message", + message: { + role: "assistant", + content: [{ type: "toolCall", id: "tc1", name: "read", arguments: {} }], + }, + }, + { + type: "message", + message: { + role: "toolResult", + toolCallId: "tc1", + toolName: "read", + content: [{ type: "text", text: "x".repeat(400) }], + timestamp: Date.now(), + }, + }, + ]; +} + +// Builds `count` independent closed chains (each: user -> assistant toolCall +// -> toolResult -> final text-only assistant), one per user turn, so each +// becomes its own captured batch under batchingMode "agent-message" and the +// chain detector (src/chain-detector.ts) sees `count` closed candidates. +// Used by the chain-compression-failure scenario below, which needs enough +// closed chains to clear the rolling window (hardcoded to 3 in this file's +// settings fixtures) and make at least one chain actually eligible for +// compression (src/chain-compressor.ts selectEligible). +function closedChainBranch(count: number): any[] { + const msgs: any[] = []; + let t = Date.now(); + for (let i = 0; i < count; i++) { + t += 1000; + msgs.push({ type: "message", message: { role: "user", content: [{ type: "text", text: `do task ${i}` }], timestamp: t } }); + msgs.push({ + type: "message", + message: { role: "assistant", content: [{ type: "toolCall", id: `tc${i}`, name: "read", arguments: {} }] }, + }); + t += 1000; + msgs.push({ + type: "message", + message: { role: "toolResult", toolCallId: `tc${i}`, toolName: "read", content: [{ type: "text", text: "x".repeat(400) }], timestamp: t }, + }); + t += 1000; + msgs.push({ type: "message", message: { role: "assistant", content: [{ type: "text", text: `done ${i}` }], timestamp: t } }); + } + return msgs; +} + +// Boots a fresh index.ts extension instance against an isolated agent dir + +// session, mirroring the fixtures shared across the three scenarios below. +// +// By default pi.appendEntry and ctx.sessionManager.appendCustomEntry push +// into the SAME `appended` array (matching flushPending's actual behavior: +// most delivery="session" writes go through sessionManager, with pi.appendEntry +// only used as the emit-time fallback for empty/aborted/pre-capture-failure +// exits) — so `appended` is the single chronological log the first two +// scenarios assert against. +// +// `separatePiAppended: true` (stale-runtime scenario) gives pi.appendEntry +// its own array so a test can assert an entry never reached it, independent +// of what landed in `sessionAppended`. +// +// `sessionAppendCustomEntry`/`piAppendEntry` wrap the underlying push (still +// targeting the same array) so a scenario can inject a throw for a specific +// customType without duplicating the harness. +function bootExtension( + options: { + chainCompressionEnabled?: boolean; + separatePiAppended?: boolean; + piAppendEntry?: (push: (type: string, data?: unknown) => void) => (type: string, data?: unknown) => void; + sessionAppendCustomEntry?: (push: (type: string, data?: unknown) => void) => (type: string, data?: unknown) => string; + branch?: any[]; + protectedTools?: string[]; + } = {}, +) { + const agentDir = mkdtempSync(join(tmpdir(), "pi-condense-rearm-")); + process.env.PI_CODING_AGENT_DIR = agentDir; + writeFileSync( + join(agentDir, "settings.json"), + JSON.stringify({ + contextPrune: { + enabled: true, + pruneOn: "agent-message", + batchingMode: "agent-message", + autoBudgetThreshold: 0.5, + summarizerModel: "default", + minBatchChars: 1, + showPruneStatusLine: true, + protectedTools: options.protectedTools ?? [], + chainCompression: { + enabled: options.chainCompressionEnabled ?? false, + rollingWindow: 3, + stripFinalAssistantThinking: true, + fuseRangeSummary: true, + }, + }, + }), + ); + + const sessionDir = mkdtempSync(join(tmpdir(), "pi-condense-rearm-session-")); + const appended: AppendedEntry[] = []; + const piAppended: AppendedEntry[] = options.separatePiAppended ? [] : appended; + const sessionAppended: AppendedEntry[] = appended; + const handlers = new Map any>(); + + const pushPi = (type: string, data?: unknown) => { + piAppended.push({ type, data }); + }; + const pushSession = (type: string, data?: unknown) => { + sessionAppended.push({ type, data }); + }; + + const pi: any = { + on(name: string, fn: (event: any, ctx: any) => any) { + handlers.set(name, fn); + }, + appendEntry: options.piAppendEntry ? options.piAppendEntry(pushPi) : pushPi, + sendMessage() {}, + registerCommand() {}, + registerTool() {}, + registerMessageRenderer() {}, + events: { emit() {} }, + }; + + const branch = options.branch ?? defaultBranch(); + + const ctx: any = { + sessionManager: { + getBranch: () => branch, + appendCustomEntry: options.sessionAppendCustomEntry + ? options.sessionAppendCustomEntry(pushSession) + : (type: string, data?: unknown) => { + pushSession(type, data); + return "id"; + }, + appendCustomMessageEntry(type: string, content: string, _display: boolean, details?: unknown) { + sessionAppended.push({ type, data: { content, details } }); + return "id"; + }, + getSessionDir: () => sessionDir, + getSessionId: () => "test", + }, + getContextUsage: () => ({ tokens: 600000, contextWindow: 1000000 }), + model: { id: "m", provider: "p", name: "M" }, + modelRegistry: { + find: () => undefined, + getApiKeyAndHeaders: async () => ({ ok: true, apiKey: "test", headers: {} }), + getProviderAuth: async () => undefined, + }, + ui: { + setStatus() {}, + setWidget() {}, + notify() {}, + select: async () => undefined, + }, + }; + + return { handlers, ctx, pi, piAppended, sessionAppended, appended, branch }; +} + +async function boot(options?: Parameters[0]) { + const harness = bootExtension(options); + const extension = (await import("../index.js")).default; + extension(harness.pi); + return harness; +} + +describe("reload rearm (issue #6)", () => { + it("rearms the turn_end budget gate after a reload so recovered pending work still flushes", async () => { + const { handlers, ctx, appended } = await boot(); + + await handlers.get("session_start")!({}, ctx); + + // Gate reachable without a fresh turn_end batch: the reload probe found + // recoverable work, so the budget-crossing turn_end below must flush it + // even though event.toolResults is empty. + await handlers.get("turn_end")!( + { toolResults: [], message: { role: "assistant", content: [{ type: "text", text: "hi" }] }, turnIndex: 2 }, + ctx, + ); + + expect(summarizerCalls).toBeGreaterThan(0); + + const indexEntry = appended.find((e) => e.type === "context-prune-index"); + expect(indexEntry).toBeDefined(); + + // Prune visibility: the raw tc1 toolResult must now render as a stub. + const rawMessages = ctx.sessionManager.getBranch().filter((e: any) => e.type === "message").map((e: any) => e.message); + const res = await handlers.get("context")!({ messages: rawMessages }, ctx); + const prunedToolResult = res.messages.find((m: any) => m.role === "toolResult" && m.toolCallId === "tc1"); + expect(prunedToolResult).toBeDefined(); + const prunedText = Array.isArray(prunedToolResult.content) + ? prunedToolResult.content.map((c: any) => c.text).join("\n") + : String(prunedToolResult.content); + expect(prunedText).toContain("context_tree_query"); + + const callsAfterFirstFlush = summarizerCalls; + + // Second identical turn_end: the flag was cleared by the first flush, and + // the work is now summarized, so this must not re-trigger the summarizer. + await handlers.get("turn_end")!( + { toolResults: [], message: { role: "assistant", content: [{ type: "text", text: "hi" }] }, turnIndex: 3 }, + ctx, + ); + + expect(summarizerCalls).toBe(callsAfterFirstFlush); + + // ── Per-attempt flush-metrics entry (issue #6, Task 5) ────────────────── + const flushMetricsEntries = appended.filter((e) => e.type === "context-prune-flush-metrics"); + expect(flushMetricsEntries.length).toBe(1); + const fm = flushMetricsEntries[0].data as any; + expect(fm.trigger).toBe("rearmed"); + expect(fm.outcome).toBe("summarized"); + expect(fm.capturedBatches).toBe(1); + expect(fm.processedBatches).toBe(1); + expect(fm.metrics.frontierGapTokens).toBeGreaterThan(0); + + // Empty-attempt: message_end's unconditional flushPending rescans and finds + // nothing (the only batch was already summarized above). One entry per + // attempt, including empty ones. + await handlers.get("message_end")!( + { message: { role: "assistant", content: [{ type: "text", text: "done" }] } }, + ctx, + ); + + const flushMetricsEntriesAfterEmpty = appended.filter((e) => e.type === "context-prune-flush-metrics"); + expect(flushMetricsEntriesAfterEmpty.length).toBe(2); + const empty = flushMetricsEntriesAfterEmpty[1].data as any; + expect(empty.trigger).toBe("message-end"); + expect(empty.outcome).toBe("empty"); + expect(empty.capturedBatches).toBe(0); + expect(empty.processedBatches).toBe(0); + }); + + it("agent_end shows 'recovered pending (reload)' when rearmed but the in-memory queue is empty", async () => { + const { handlers, ctx } = await boot(); + + await handlers.get("session_start")!({}, ctx); + + const statusCalls: unknown[] = []; + ctx.ui.setStatus = (_id: string, text?: string) => statusCalls.push(text); + + await handlers.get("agent_end")!({}, ctx); + + expect(statusCalls).toContain("\u2502 prune: recovered pending (reload)"); + }); + + it("reports a rescan failure to console.error and leaves rearmedPending false, without failing session_start (G4)", async () => { + // Spec (Component 2, Rescan failure): "if the reload rearm probe throws, + // console.error ... and leave rearmedPending = false - reload must never + // fail because of the probe." The probe's own try/catch in session_start + // can only observe a failure if the branch rescan actually propagates + // one out of capturePendingBatches for this call site. + const { handlers, ctx } = await boot(); + + let getBranchCalls = 0; + const realGetBranch = ctx.sessionManager.getBranch; + ctx.sessionManager.getBranch = () => { + getBranchCalls++; + // Within session_start, getBranch() is called once each by + // indexer/stats/frontier reconstruction (calls 1-3) before the reload + // rearm probe's own rescan (call 4). Fail only call 4 so the earlier + // reconstruction steps are unaffected and the failure is isolated to + // the probe. + if (getBranchCalls === 4) { + throw new Error("simulated branch rescan failure"); + } + return realGetBranch(); + }; + + const errorSpy: unknown[][] = []; + const realConsoleError = console.error; + console.error = (...args: unknown[]) => { + errorSpy.push(args); + }; + + try { + await handlers.get("session_start")!({}, ctx); + } finally { + console.error = realConsoleError; + } + + expect(errorSpy.some((args) => String(args[0]).includes("reload rearm probe"))).toBe(true); + + // rearmedPending must have stayed false: a toolResult-free turn_end must + // not reach the budget/delta gate (the observable proxy for the flag, + // exercised elsewhere in this file), so no flush/summarizer call happens. + const callsBefore = summarizerCalls; + await handlers.get("turn_end")!( + { toolResults: [], message: { role: "assistant", content: [{ type: "text", text: "hi" }] }, turnIndex: 2 }, + ctx, + ); + expect(summarizerCalls).toBe(callsBefore); + }); + + it("non-rearmed turn_end with an all-excluded batch does not evaluate the budget gate (main parity)", async () => { + // Regression for the rearmed=false path: a turn whose toolResults are + // entirely protected (so trimBatchToPendingRange yields null) must + // return before touching previousFraction or the budget/delta gate — + // exactly main's `if (!batch) return;` — even when pendingBatches + // already holds a batch queued by an earlier turn. + const { handlers, ctx, appended } = await boot({ branch: [], protectedTools: ["secret_tool"] }); + + await handlers.get("session_start")!({}, ctx); + + // Turn 1: a non-protected batch, low usage — pushes into pendingBatches + // without triggering a flush. + ctx.getContextUsage = () => ({ tokens: 100, contextWindow: 1000000 }); + await handlers.get("turn_end")!( + { + message: { role: "assistant", content: [{ type: "toolCall", id: "tc-a", name: "read", arguments: {} }] }, + toolResults: [ + { role: "toolResult", toolCallId: "tc-a", toolName: "read", content: [{ type: "text", text: "x".repeat(400) }], timestamp: Date.now() }, + ], + turnIndex: 5, + }, + ctx, + ); + + expect(appended.some((e) => e.type === "context-prune-flush-metrics")).toBe(false); + const callsBeforeTurn2 = summarizerCalls; + + // Turn 2: every tool call is protected, so trimBatchToPendingRange + // returns null — but usage now crosses the budget threshold. Main + // returns before the gate for this turn; the leftover batch from turn 1 + // must not cause a flush here. + ctx.getContextUsage = () => ({ tokens: 900000, contextWindow: 1000000 }); + await handlers.get("turn_end")!( + { + message: { role: "assistant", content: [{ type: "toolCall", id: "tc-b", name: "secret_tool", arguments: {} }] }, + toolResults: [ + { role: "toolResult", toolCallId: "tc-b", toolName: "secret_tool", content: [{ type: "text", text: "y".repeat(400) }], timestamp: Date.now() }, + ], + turnIndex: 6, + }, + ctx, + ); + + expect(summarizerCalls).toBe(callsBeforeTurn2); + expect(appended.some((e) => e.type === "context-prune-flush-metrics")).toBe(false); + }); + + it("still writes the flush-metrics entry when chain compression fails", async () => { + // The chain-compression block routes its appendEntry through the same + // sessionManager.appendCustomEntry as the rest of the session-delivery + // path (delivery: "session" here). Throwing only for the chain entry's + // customType breaks compressEligible's write without touching the index / + // frontier / stats writes the summarization phase already made. + // + // The compressor only attempts a write when a chain is actually eligible: + // closed (has a final text-only assistant turn) AND older than the + // rolling window (bootExtension hardcodes chainCompression.rollingWindow + // to 3 — see the settings fixture above). A single closed chain never + // clears that window, so the fixture below builds four independent + // closed chains (separate user turns, so each becomes its own captured + // batch and gets its own per-batch summary before compression runs) — + // the oldest one becomes eligible and drives the injected throw. A local + // counter proves the throw actually fired, so this test can never go + // vacuous again. + let chainWriteAttempts = 0; + const { handlers, ctx, appended } = await boot({ + chainCompressionEnabled: true, + branch: closedChainBranch(4), + sessionAppendCustomEntry: (push) => (type: string, data?: unknown) => { + if (type === "context-prune-chain") { + chainWriteAttempts++; + throw new Error("simulated chain-compression persistence failure"); + } + push(type, data); + return "id"; + }, + }); + + await handlers.get("session_start")!({}, ctx); + + // message_end drives an agent-message flush directly (no reload rearm + // needed): captures the four batches, summarizes them, then attempts + // chain compression on the oldest eligible chain, which fails and is + // swallowed. + await handlers.get("message_end")!( + { message: { role: "assistant", content: [{ type: "text", text: "hi" }] } }, + ctx, + ); + + // Positive proof the injected throw was actually exercised (not vacuous). + expect(chainWriteAttempts).toBeGreaterThan(0); + + const flushMetricsEntries = appended.filter((e) => e.type === "context-prune-flush-metrics"); + expect(flushMetricsEntries.length).toBe(1); + const fm = flushMetricsEntries[0].data as any; + expect(fm.outcome).toBe("summarized"); + expect(fm.trigger).toBe("message-end"); + expect(fm.capturedBatches).toBe(4); + expect(fm.processedBatches).toBe(4); + }); + + it("binds the sessionManager appender before the empty-capture exit, so an empty session-delivery flush still lands via sessionManager", async () => { + // Regression: the sessionManager-backed appender used to bind only after + // a non-empty capture, so an empty rescan on a session-delivery flush + // fell back to pi.appendEntry for the flush-metrics emit — a stale-pi + // drop risk (print-mode, reload). message_end always flushes with + // delivery: "session"; an empty branch makes the rescan find nothing. + const { handlers, ctx, piAppended, sessionAppended } = await boot({ + branch: [], + separatePiAppended: true, + piAppendEntry: (push) => (type: string, data?: unknown) => { + push(type, data); + throw new Error("simulated stale runtime: pi.appendEntry unavailable"); + }, + }); + + await handlers.get("session_start")!({}, ctx); + + await handlers.get("message_end")!( + { message: { role: "assistant", content: [{ type: "text", text: "done" }] } }, + ctx, + ); + + const flushMetricsEntries = sessionAppended.filter((e) => e.type === "context-prune-flush-metrics"); + expect(flushMetricsEntries.length).toBe(1); + expect((flushMetricsEntries[0].data as any).outcome).toBe("empty"); + expect(piAppended.some((e) => e.type === "context-prune-flush-metrics")).toBe(false); + }); + + it("still writes the flush-metrics entry via sessionManager when pi.appendEntry is stale (print-mode) during session delivery", async () => { + // Simulates a stale runtime `pi` reference during print-mode: any call + // that still routes through pi.appendEntry throws, as it would against a + // dead/replaced runtime. + const { handlers, ctx, piAppended, sessionAppended } = await boot({ + separatePiAppended: true, + piAppendEntry: (push) => (type: string, data?: unknown) => { + push(type, data); + throw new Error("simulated stale runtime: pi.appendEntry unavailable"); + }, + }); + + await handlers.get("session_start")!({}, ctx); + + // message_end drives a session-delivery flush (delivery: "session"). + // Under the pre-fix unconditional pi.appendEntry, this entry is lost to + // the swallowing try/catch because pi.appendEntry throws above. + await handlers.get("message_end")!( + { message: { role: "assistant", content: [{ type: "text", text: "hi" }] } }, + ctx, + ); + + const flushMetricsEntries = sessionAppended.filter((e) => e.type === "context-prune-flush-metrics"); + expect(flushMetricsEntries.length).toBe(1); + const fm = flushMetricsEntries[0].data as any; + expect(fm.outcome).toBe("summarized"); + expect(fm.trigger).toBe("message-end"); + + // Confirms the routing decision, not just a lucky duplicate write: the + // stale pi.appendEntry must never be the source of this entry. + expect(piAppended.some((e) => e.type === "context-prune-flush-metrics")).toBe(false); + }); + + it("recomputes the cached metrics snapshot on a turn_end whose toolResults produce no pushed batch (G3)", async () => { + // Component 4 (spec): the snapshot cache recomputes at every enabled + // turn_end carrying toolResults, unconditional on whether trim yields a + // batch to push. Observed via the footer widget suffix (commands.ts's + // pruneStatusText), which is rendered from the cache, not recomputed + // itself — the honest seam here since the harness's pi.registerCommand + // is a no-op stub and the registerCommands getCachedMetrics callback is + // therefore unreachable from a test. + const { handlers, ctx, branch } = await boot({ protectedTools: ["secret_tool"] }); + + // Neutralize the budget/delta gate so this test only observes the + // recompute, not a side-effect flush (harness default usage is 0.6, + // above the fixture's 0.5 autoBudgetThreshold). + ctx.getContextUsage = () => ({ tokens: 10, contextWindow: 1000000 }); + + await handlers.get("session_start")!({}, ctx); + + const statusCalls: unknown[] = []; + ctx.ui.setStatus = (_id: string, text?: string) => statusCalls.push(text); + + // Force a render of the current (session_start-computed) cache. + const rawBefore = ctx.sessionManager.getBranch().filter((e: any) => e.type === "message").map((e: any) => e.message); + await handlers.get("context")!({ messages: rawBefore }, ctx); + const textBefore = statusCalls[statusCalls.length - 1]; + + // Grow the branch as Pi would before firing turn_end: a new assistant + // turn with a large thinking block and a protected tool call, plus its + // toolResult. Protected content is excluded from frontierGapTokens by + // design, but NOT from openCycleThinkingTokens or largestChainSharePct — + // so this turn still moves the cache if recomputed. + const newAssistant = { + type: "message", + message: { + role: "assistant", + content: [ + { type: "thinking", text: "t".repeat(4000) }, + { type: "toolCall", id: "tc2", name: "secret_tool", arguments: {} }, + ], + }, + }; + const newToolResult = { + type: "message", + message: { + role: "toolResult", + toolCallId: "tc2", + toolName: "secret_tool", + content: [{ type: "text", text: "s".repeat(400) }], + timestamp: Date.now(), + }, + }; + branch.push(newAssistant, newToolResult); + + // This turn's toolResults are entirely protected, so trimBatchToPendingRange + // returns null and no batch is pushed — the case this fix targets. + await handlers.get("turn_end")!( + { message: newAssistant.message, toolResults: [newToolResult.message], turnIndex: 3 }, + ctx, + ); + + const rawAfter = ctx.sessionManager.getBranch().filter((e: any) => e.type === "message").map((e: any) => e.message); + await handlers.get("context")!({ messages: rawAfter }, ctx); + const textAfter = statusCalls[statusCalls.length - 1]; + + // Pre-fix, the cache is stale (computed once at session_start, on the + // pre-growth branch) — the widget text does not move. Post-fix, the + // turn_end recompute picks up the larger open segment/thinking. + expect(textAfter).not.toBe(textBefore); + }); + + it("includes a persisted summary custom_message entry in the largest-chain-share denominator (G1)", async () => { + // Component 1 (spec): denominator = per-message chars over the entire + // branch projection, INCLUDING retained custom_message summary entries. + // A pre-fix `e.type === "message"` filter drops them, so the chain's + // share comes out inflated (denominator too small). + // + // The custom_message sits between two final text-only assistant + // messages so it lands outside both the chain range and the open-cycle + // segment (see src/context-metrics.test.ts's matching pure-level test) -- + // isolating the denominator effect from any open-segment interaction. + const closedChain = closedChainBranch(1); // user -> assistant toolCall -> toolResult -> final text-only assistant + const summaryEntry = { + type: "custom_message", + customType: "context-prune-summary", + content: "s".repeat(3000), + display: false, + details: {}, + timestamp: new Date().toISOString(), + }; + const closer = { + type: "message", + message: { role: "assistant", content: [{ type: "text", text: "ok" }], timestamp: Date.now() + 100000 }, + }; + const branch = [...closedChain, summaryEntry, closer]; + + const { handlers, ctx } = await boot({ branch }); + ctx.getContextUsage = () => ({ tokens: 10, contextWindow: 1000000 }); + + const statusCalls: unknown[] = []; + ctx.ui.setStatus = (_id: string, text?: string) => statusCalls.push(text); + + await handlers.get("session_start")!({}, ctx); + + const rawMessages = ctx.sessionManager.getBranch().filter((e: any) => e.type === "message").map((e: any) => e.message); + await handlers.get("context")!({ messages: rawMessages }, ctx); + const text = statusCalls[statusCalls.length - 1] as string; + + const chainChars = closedChain.map((e: any) => JSON.stringify(e.message).length).reduce((a, b) => a + b, 0); + const totalWithSummary = [...closedChain.map((e: any) => e.message), summaryEntry, closer.message] + .map((m) => JSON.stringify(m).length) + .reduce((a, b) => a + b, 0); + const expectedPct = Math.round((100 * chainChars) / totalWithSummary); + const inflatedPct = Math.round( + (100 * chainChars) / closedChain.map((e: any) => JSON.stringify(e.message).length).reduce((a, b) => a + b, 0), + ); + + expect(text).toContain(`chain ${expectedPct}%`); + expect(expectedPct).toBeLessThan(inflatedPct); + }); +}); diff --git a/pi-condense/src/spill.test.ts b/pi-condense/src/spill.test.ts new file mode 100644 index 0000000..228a0b3 --- /dev/null +++ b/pi-condense/src/spill.test.ts @@ -0,0 +1,209 @@ +import { describe, it, expect } from "bun:test"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { sanitizeId, blobDirFor, blobPathFor, headPreview, spillOversizedBatch } from "./spill.js"; +import { ToolCallIndexer } from "./indexer.js"; +import { registerQueryTool } from "./query-tool.js"; +import { occKey } from "./occurrence-key.js"; +import { CUSTOM_TYPE_INDEX } from "./types.js"; +import type { CapturedBatch } from "./types.js"; + +describe("sanitizeId", () => { + it("replaces path separators and unsafe chars", () => { + expect(sanitizeId("toolu_abc-123")).toBe("toolu_abc-123"); + expect(sanitizeId("../../etc/passwd")).toBe("______etc_passwd"); + expect(sanitizeId("a/b\\c")).toBe("a_b_c"); + }); +}); + +describe("blobDirFor / blobPathFor", () => { + it("builds /-blobs/.txt", () => { + expect(blobDirFor("/s", "sid")).toBe(join("/s", "sid-blobs")); + expect(blobPathFor("/s", "sid", "tc1")).toBe(join("/s", "sid-blobs", "tc1.txt")); + }); +}); + +describe("headPreview", () => { + it("returns the whole string when under the byte cap", () => { + expect(headPreview("hello", 1024)).toBe("hello"); + }); + it("cuts at a line boundary when one exists in budget", () => { + expect(headPreview("aaaa\nbbbb\ncccc", 7)).toBe("aaaa"); + }); + it("never exceeds the byte cap and stays valid UTF-8", () => { + const s = "é".repeat(100); + const out = headPreview(s, 11); + expect(Buffer.byteLength(out, "utf8")).toBeLessThanOrEqual(11); + expect(() => Buffer.from(out, "utf8").toString("utf8")).not.toThrow(); + }); +}); + +describe("occurrence-keyed spill", () => { + it("blobPathFor distinguishes two occurrences of one id", () => { + const a = blobPathFor("/tmp/s", "sess", occKey("bash_23", 1150)); + const b = blobPathFor("/tmp/s", "sess", occKey("bash_23", 3150)); + expect(a).not.toBe(b); + expect(a.endsWith("bash_23_1150.txt")).toBe(true); + }); + + it("legacy bare-id sidecar path is unchanged", () => { + expect(blobPathFor("/tmp/s", "sess", "bash_23").endsWith("bash_23.txt")).toBe(true); + }); + + it("registerDuplicate is called with occurrence keys on both sides", async () => { + const calls: string[][] = []; + const indexer = { + lookupByContent: () => "bash_1@1000", + registerDuplicate: (a: string, b: string) => calls.push([a, b]), + } as any; + const batch = { + turnIndex: 0, + timestamp: 2000, + assistantText: "", + toolCalls: [ + { toolCallId: "bash_2", toolName: "bash", args: {}, resultText: "x".repeat(100), isError: false, resultTimestamp: 2150 }, + ], + }; + await spillOversizedBatch({ + batch: batch as any, + indexer, + config: { spillThreshold: 10, spillPreviewBytes: 10, dedupByContentHash: true }, + sessionDir: "/tmp/s", + sessionId: "sess", + appendEntry: () => {}, + }); + expect(calls).toEqual([["bash_2@2150", "bash_1@1000"]]); + }); +}); + +describe("G4/C4: legacy bare-id sidecar recovery", () => { + it("a pre-upgrade legacy record whose spillPath points at a bare-id-named sidecar still resolves through context_tree_query", async () => { + const dir = await mkdtemp(join(tmpdir(), "spill-legacy-")); + try { + // A pre-occurrence-key sidecar, written and named exactly the way a + // pre-upgrade session would have (bare id, no resultTimestamp suffix). + const sidecarPath = blobPathFor(dir, "sid", "bash_7"); + await mkdir(blobDirFor(dir, "sid"), { recursive: true }); + await writeFile(sidecarPath, "OLD SPILLED BODY".repeat(20)); + + const indexEntry = { + type: "custom", + customType: CUSTOM_TYPE_INDEX, + data: { + toolCalls: [ + { + toolCallId: "bash_7", + toolName: "fetch", + args: { url: "https://x" }, + resultText: "", + resultPreview: "OLD SPILLED", + spillPath: sidecarPath, + spillBytes: 340, + isError: false, + turnIndex: 0, + timestamp: 500, + }, + ], + }, + }; + // No matching ToolResultMessage in the branch (a genuinely pre-upgrade, + // truncated session) - an index entry persisted without resultTimestamp + // stays bare-keyed (no migration), so its sidecar keeps its bare-id + // filename and still resolves via the persisted spillPath. + const indexer = new ToolCallIndexer(); + indexer.reconstructFromSession({ sessionManager: { getBranch: () => [indexEntry] } } as any); + expect(indexer.hasLegacyBareRecord("bash_7")).toBe(true); + + let registered: any; + registerQueryTool({ registerTool: (def: any) => (registered = def) } as any, indexer); + const result = await registered.execute("call-1", { toolCallIds: ["bash_7"] }, undefined, undefined, undefined); + const text = result.content[0].text as string; + + expect(text).toContain("OLD SPILLED BODY"); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); +}); + +describe("spillOversizedBatch", () => { + const cfg = { spillThreshold: 10, spillPreviewBytes: 8, dedupByContentHash: true }; + const mkBatch = (toolCalls: any[]): CapturedBatch => ({ turnIndex: 0, timestamp: 1, assistantText: "", toolCalls }); + + it("spills an oversized result: writes file, mutates record, indexes it", async () => { + const dir = await mkdtemp(join(tmpdir(), "spill-")); + try { + const indexer = new ToolCallIndexer(); + const batch = mkBatch([{ toolCallId: "tc1", toolName: "fetch", args: {}, resultText: "X".repeat(50), isError: false }]); + const spilled = await spillOversizedBatch({ batch, indexer, config: cfg, sessionDir: dir, sessionId: "sid", appendEntry: () => {} }); + expect(spilled.has("tc1")).toBe(true); + const rec = indexer.getRecord("tc1")!; + expect(rec.spillPath).toBe(blobPathFor(dir, "sid", "tc1")); + expect(rec.spillBytes).toBe(50); + expect(rec.resultText).toBe(""); + expect(rec.resultPreview!.length).toBeGreaterThan(0); + expect(await readFile(rec.spillPath!, "utf-8")).toBe("X".repeat(50)); + expect(indexer.isSummarized("tc1")).toBe(true); + } finally { await rm(dir, { recursive: true, force: true }); } + }); + + it("leaves a small result untouched (not spilled)", async () => { + const dir = await mkdtemp(join(tmpdir(), "spill-")); + try { + const indexer = new ToolCallIndexer(); + const batch = mkBatch([{ toolCallId: "tc1", toolName: "bash", args: {}, resultText: "tiny", isError: false }]); + const spilled = await spillOversizedBatch({ batch, indexer, config: cfg, sessionDir: dir, sessionId: "sid", appendEntry: () => {} }); + expect(spilled.size).toBe(0); + expect(indexer.isSummarized("tc1")).toBe(false); + } finally { await rm(dir, { recursive: true, force: true }); } + }); + + it("leaves the tool call untouched when the sidecar write fails", async () => { + const base = await mkdtemp(join(tmpdir(), "spill-")); + const filePath = join(base, "not-a-dir"); + await writeFile(filePath, "x"); // sessionDir is a FILE → mkdir under it throws + try { + const indexer = new ToolCallIndexer(); + const big = "Z".repeat(50); + const batch = mkBatch([{ toolCallId: "tc1", toolName: "fetch", args: {}, resultText: big, isError: false }]); + const spilled = await spillOversizedBatch({ batch, indexer, config: cfg, sessionDir: filePath, sessionId: "sid", appendEntry: () => {} }); + expect(spilled.size).toBe(0); + expect(indexer.isSummarized("tc1")).toBe(false); + expect(batch.toolCalls[0].resultText).toBe(big); // untouched + } finally { + await rm(base, { recursive: true, force: true }); + } + }); + + it("two occurrences of one toolCallId spill to distinct sidecar files", async () => { + const dir = await mkdtemp(join(tmpdir(), "spill-")); + try { + const indexer = new ToolCallIndexer(); + const batch1 = mkBatch([{ toolCallId: "bash_23", toolName: "bash", args: {}, resultText: "FIRST".repeat(20), isError: false, resultTimestamp: 1150 }]); + const batch2 = mkBatch([{ toolCallId: "bash_23", toolName: "bash", args: {}, resultText: "SECOND".repeat(20), isError: false, resultTimestamp: 3150 }]); + await spillOversizedBatch({ batch: batch1, indexer, config: { ...cfg, dedupByContentHash: false }, sessionDir: dir, sessionId: "sid", appendEntry: () => {} }); + await spillOversizedBatch({ batch: batch2, indexer, config: { ...cfg, dedupByContentHash: false }, sessionDir: dir, sessionId: "sid", appendEntry: () => {} }); + const rec1 = indexer.getRecord("bash_23@1150")!; + const rec2 = indexer.getRecord("bash_23@3150")!; + expect(rec1.spillPath).not.toBe(rec2.spillPath); + expect(await readFile(rec1.spillPath!, "utf-8")).toBe("FIRST".repeat(20)); + expect(await readFile(rec2.spillPath!, "utf-8")).toBe("SECOND".repeat(20)); + } finally { await rm(dir, { recursive: true, force: true }); } + }); + + it("dedups an oversized duplicate to the original without a second file", async () => { + const dir = await mkdtemp(join(tmpdir(), "spill-")); + try { + const indexer = new ToolCallIndexer(); + const body = "Y".repeat(50); + const append = () => {}; + await spillOversizedBatch({ batch: mkBatch([{ toolCallId: "tc1", toolName: "fetch", args: {}, resultText: body, isError: false }]), indexer, config: cfg, sessionDir: dir, sessionId: "sid", appendEntry: append }); + const spilled2 = await spillOversizedBatch({ batch: mkBatch([{ toolCallId: "tc2", toolName: "fetch", args: {}, resultText: body, isError: false }]), indexer, config: cfg, sessionDir: dir, sessionId: "sid", appendEntry: append }); + expect(spilled2.has("tc2")).toBe(true); + expect(indexer.isSummarized("tc2")).toBe(true); + expect(indexer.getRecord("tc2")!.toolCallId).toBe("tc1"); + await expect(readFile(blobPathFor(dir, "sid", "tc2"), "utf-8")).rejects.toBeDefined(); + } finally { await rm(dir, { recursive: true, force: true }); } + }); +}); diff --git a/pi-condense/src/spill.ts b/pi-condense/src/spill.ts new file mode 100644 index 0000000..9b4ba83 --- /dev/null +++ b/pi-condense/src/spill.ts @@ -0,0 +1,105 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import type { CapturedBatch, CapturedToolCall } from "./types.js"; +import type { ToolCallIndexer } from "./indexer.js"; +import { hashToolResult } from "./content-hash.js"; +import { occKey } from "./occurrence-key.js"; + +/** Replace anything outside [A-Za-z0-9_-] so the id can't escape the blob dir. */ +export function sanitizeId(toolCallId: string): string { + return toolCallId.replace(/[^A-Za-z0-9_-]/g, "_"); +} + +export function blobDirFor(sessionDir: string, sessionId: string): string { + return join(sessionDir, `${sessionId}-blobs`); +} + +export function blobPathFor(sessionDir: string, sessionId: string, toolCallId: string): string { + return join(blobDirFor(sessionDir, sessionId), `${sanitizeId(toolCallId)}.txt`); +} + +/** Head of `text` capped at `maxBytes` (UTF-8 safe), preferring a line boundary. */ +export function headPreview(text: string, maxBytes: number): string { + const buf = Buffer.from(text, "utf8"); + if (buf.length <= maxBytes) return text; + let end = maxBytes; + while (end > 0 && (buf[end] & 0xc0) === 0x80) end--; + let slice = buf.subarray(0, end).toString("utf8"); + const lastNl = slice.lastIndexOf("\n"); + if (lastNl > 0) slice = slice.slice(0, lastNl); + return slice; +} + +interface SpillConfig { + spillThreshold: number; + spillPreviewBytes: number; + dedupByContentHash: boolean; +} + +interface SpillableRecord { + toolName: string; + resultText: string; + spillBytes?: number; + resultPreview?: string; + spillPath?: string; + contentHash?: string; +} + +/** Mutates `record` in place: spillBytes/resultPreview/spillPath/contentHash set, resultText emptied. */ +export function applySpill(record: SpillableRecord, spillPath: string, previewBytes: number): void { + record.spillBytes = Buffer.byteLength(record.resultText, "utf8"); + record.resultPreview = headPreview(record.resultText, previewBytes); + record.spillPath = spillPath; + record.contentHash = hashToolResult(record.toolName, record.resultText); + record.resultText = ""; +} + +export async function spillOversizedBatch(args: { + batch: CapturedBatch; + indexer: ToolCallIndexer; + config: SpillConfig; + sessionDir: string; + sessionId: string; + appendEntry: (customType: string, data?: unknown) => void; +}): Promise> { + const { batch, indexer, config, sessionDir, sessionId, appendEntry } = args; + const handled = new Set(); + const toIndex: CapturedToolCall[] = []; + + for (const tc of batch.toolCalls) { + if (tc.resultText.length < config.spillThreshold) continue; + + const key = occKey(tc.toolCallId, tc.resultTimestamp); + + if (config.dedupByContentHash) { + const original = indexer.lookupByContent(tc.toolName, tc.resultText); + if (original && original !== key) { + indexer.registerDuplicate(key, original, appendEntry); + handled.add(tc.toolCallId); + continue; + } + } + + const path = blobPathFor(sessionDir, sessionId, key); + try { + await mkdir(blobDirFor(sessionDir, sessionId), { recursive: true }); + await writeFile(path, tc.resultText, "utf-8"); + } catch (err) { + console.error(`spill: failed to write sidecar for ${tc.toolCallId} at ${path}:`, err); + continue; + } + + applySpill(tc, path, config.spillPreviewBytes); + toIndex.push(tc); + handled.add(tc.toolCallId); + } + + if (toIndex.length > 0) { + indexer.addBatch( + { turnIndex: batch.turnIndex, timestamp: batch.timestamp, assistantText: "", toolCalls: toIndex }, + appendEntry, + ); + } + + return handled; +} diff --git a/pi-condense/src/stats.test.ts b/pi-condense/src/stats.test.ts new file mode 100644 index 0000000..ddd44b8 --- /dev/null +++ b/pi-condense/src/stats.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from "bun:test"; +import { + StatsAccumulator, + emitExternalCost, +} from "./stats.js"; +import { + CUSTOM_TYPE_STATS, + EXTERNAL_COST_CHANNEL, + EXTERNAL_COST_SOURCE, +} from "./types.js"; + +// Minimal Usage shape matching the private interface in stats.ts +function makeUsage(input: number, output: number, costTotal: number) { + return { + input, + output, + cacheRead: 0, + cacheWrite: 0, + totalTokens: input + output, + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + total: costTotal, + }, + }; +} + +describe("StatsAccumulator.getSessionDelta", () => { + it("(a) fresh accumulator: delta equals what was added", () => { + const acc = new StatsAccumulator(); + acc.add(makeUsage(100, 50, 0.01)); + const delta = acc.getSessionDelta(); + expect(delta.totalCost).toBeCloseTo(0.01); + expect(delta.inputTokens).toBe(100); + expect(delta.outputTokens).toBe(50); + }); + + it("(b) after reconstructFromSession, prior totals are excluded from delta", () => { + const acc = new StatsAccumulator(); + + // Build a mock ctx that returns one CUSTOM_TYPE_STATS entry with prior totals + const priorStats = { + totalInputTokens: 500, + totalOutputTokens: 250, + totalCost: 0.05, + callCount: 3, + chainsCompressed: 1, + rangesSummarized: 0, + }; + const mockCtx = { + sessionManager: { + getBranch: () => [ + { + type: "custom", + customType: CUSTOM_TYPE_STATS, + data: priorStats, + }, + ], + }, + } as any; + + acc.reconstructFromSession(mockCtx); + + // Delta should be zero right after reconstruction + const deltaAfterRecon = acc.getSessionDelta(); + expect(deltaAfterRecon.totalCost).toBeCloseTo(0); + expect(deltaAfterRecon.inputTokens).toBe(0); + expect(deltaAfterRecon.outputTokens).toBe(0); + + // A subsequent add should show only the new spend + acc.add(makeUsage(200, 80, 0.02)); + const deltaAfterAdd = acc.getSessionDelta(); + expect(deltaAfterAdd.totalCost).toBeCloseTo(0.02); + expect(deltaAfterAdd.inputTokens).toBe(200); + expect(deltaAfterAdd.outputTokens).toBe(80); + }); +}); + +describe("StatsAccumulator.getLiveReclaim / setLiveReclaim", () => { + it("(c) undefined initially; round-trips after set", () => { + const acc = new StatsAccumulator(); + expect(acc.getLiveReclaim()).toBeUndefined(); + acc.setLiveReclaim(1000, 200); + expect(acc.getLiveReclaim()).toEqual({ beforeChars: 1000, afterChars: 200 }); + }); +}); + +describe("emitExternalCost", () => { + it("(d) emits exactly one event on EXTERNAL_COST_CHANNEL with session delta", () => { + const acc = new StatsAccumulator(); + acc.add(makeUsage(100, 50, 0.01)); + + const calls: Array<{ channel: string; data: unknown }> = []; + const fakePi = { + events: { + emit: (channel: string, data: unknown) => { + calls.push({ channel, data }); + }, + }, + } as any; + + emitExternalCost(fakePi, acc); + + expect(calls).toHaveLength(1); + expect(calls[0].channel).toBe(EXTERNAL_COST_CHANNEL); + const payload = calls[0].data as any; + expect(payload.source).toBe(EXTERNAL_COST_SOURCE); + expect(payload.totalCost).toBeCloseTo(0.01); + expect(payload.inputTokens).toBe(100); + expect(payload.outputTokens).toBe(50); + }); +}); diff --git a/pi-condense/src/stats.ts b/pi-condense/src/stats.ts new file mode 100644 index 0000000..f7cb568 --- /dev/null +++ b/pi-condense/src/stats.ts @@ -0,0 +1,190 @@ +import type { SummarizerStats, ExternalCostUpdate, LiveReclaim } from "./types.js"; +import { CUSTOM_TYPE_STATS, EXTERNAL_COST_CHANNEL, EXTERNAL_COST_SOURCE } from "./types.js"; +import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; + +/** + * Usage shape returned by the LLM `complete()` call. + * Mirrors the `Usage` interface from `@earendil-works/pi-ai` but declared locally + * so we don't need a runtime import just for the type. + */ +interface Usage { + input: number; + output: number; + cacheRead: number; + cacheWrite: number; + totalTokens: number; + cost: { + input: number; + output: number; + cacheRead: number; + cacheWrite: number; + total: number; + }; +} + +/** + * Accumulates cumulative token/cost stats for summarizer LLM calls. + * Stats are persisted to the session via `pi.appendEntry(CUSTOM_TYPE_STATS, ...)` + * and reconstructed on `session_start` / `session_tree`. + */ +export class StatsAccumulator { + private stats: SummarizerStats = { + totalInputTokens: 0, + totalOutputTokens: 0, + totalCost: 0, + callCount: 0, + chainsCompressed: 0, + rangesSummarized: 0, + }; + private baseline = { totalInputTokens: 0, totalOutputTokens: 0, totalCost: 0 }; + private liveReclaim: LiveReclaim | undefined = undefined; + + /** Add usage data from one summarizer LLM call. */ + add(usage: Usage): void { + this.stats.totalInputTokens += usage.input ?? 0; + this.stats.totalOutputTokens += usage.output ?? 0; + this.stats.totalCost += usage.cost?.total ?? 0; + this.stats.callCount += 1; + } + + /** Return session-delta spend (current stats minus baseline set at reconstructFromSession). */ + getSessionDelta(): { totalCost: number; inputTokens: number; outputTokens: number } { + return { + totalCost: this.stats.totalCost - this.baseline.totalCost, + inputTokens: this.stats.totalInputTokens - this.baseline.totalInputTokens, + outputTokens: this.stats.totalOutputTokens - this.baseline.totalOutputTokens, + }; + } + + /** Store the before/after context-char measurement from the last prune. */ + setLiveReclaim(beforeChars: number, afterChars: number): void { + this.liveReclaim = { beforeChars, afterChars }; + } + + /** Return the last live-reclaim measurement, or undefined if none yet. */ + getLiveReclaim(): LiveReclaim | undefined { + return this.liveReclaim; + } + + /** Return a snapshot of the current cumulative stats. */ + getStats(): SummarizerStats { + return { ...this.stats }; + } + + /** Increment the chain-compression counter. */ + addChainsCompressed(n: number): void { + this.stats.chainsCompressed += n; + } + + /** Increment the fused-range-summary counter. */ + addRangesSummarized(n: number): void { + this.stats.rangesSummarized += n; + } + + /** Reset all accumulated stats to zero. Produces the same state as a fresh accumulator. */ + reset(): void { + this.stats = { + totalInputTokens: 0, + totalOutputTokens: 0, + totalCost: 0, + callCount: 0, + chainsCompressed: 0, + rangesSummarized: 0, + }; + this.baseline = { totalInputTokens: 0, totalOutputTokens: 0, totalCost: 0 }; + this.liveReclaim = undefined; + } + + /** Serialize stats for session persistence. */ + toJSON(): SummarizerStats { + return { ...this.stats }; + } + + /** Restore stats from a previously persisted snapshot. */ + fromJSON(data: SummarizerStats): void { + this.stats = { + totalInputTokens: data.totalInputTokens ?? 0, + totalOutputTokens: data.totalOutputTokens ?? 0, + totalCost: data.totalCost ?? 0, + callCount: data.callCount ?? 0, + chainsCompressed: data.chainsCompressed ?? 0, + rangesSummarized: data.rangesSummarized ?? 0, + }; + } + + /** + * Reconstruct stats from session history by scanning all custom entries + * with customType === CUSTOM_TYPE_STATS. + */ + reconstructFromSession(ctx: ExtensionContext): void { + this.reset(); + const branch = ctx.sessionManager.getBranch(); + for (const entry of branch) { + if ( + entry.type === "custom" && + (entry as any).customType === CUSTOM_TYPE_STATS + ) { + const data = (entry as any).data as SummarizerStats; + if (data) { + this.fromJSON(data); + } + } + } + this.baseline = { + totalInputTokens: this.stats.totalInputTokens, + totalOutputTokens: this.stats.totalOutputTokens, + totalCost: this.stats.totalCost, + }; + } + + /** + * Persist current stats to the session. + * Each call appends a new entry; on reconstructFromSession we scan + * all entries and apply the LAST one (since each entry is a full snapshot). + */ + persist(pi: ExtensionAPI): void { + pi.appendEntry(CUSTOM_TYPE_STATS, this.toJSON()); + } +} + +// ── Formatting helpers ────────────────────────────────────────────────────── + +/** Format compact counts like Pi's status line (e.g. "1.2k", "340") */ +export function formatCompactCount(n: number): string { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; + if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`; + return String(n); +} + +/** Format token counts like Pi's status line (e.g. "1.2k", "340") */ +export function formatTokens(n: number): string { + return formatCompactCount(n); +} + +/** Format live char progress like "1.2k summary chars / 8.4k raw chars". */ +export function formatCharProgress(receivedChars: number, rawChars?: number): string { + const receivedLabel = `${formatCompactCount(receivedChars)} summary char${receivedChars === 1 ? "" : "s"}`; + if (rawChars == null) return receivedLabel; + return `${receivedLabel} / ${formatCompactCount(rawChars)} raw char${rawChars === 1 ? "" : "s"}`; +} + +/** Format cost like "$0.003" */ +export function formatCost(n: number): string { + if (n < 0.001 && n > 0) return `<$0.001`; + return `$${n.toFixed(3)}`; +} + +/** + * Emit the session-delta cost from `accumulator` on EXTERNAL_COST_CHANNEL. + * Idempotent from the aggregator's perspective: keyed by source, re-emitting overwrites. + */ +export function emitExternalCost(pi: ExtensionAPI, accumulator: StatsAccumulator): void { + const delta = accumulator.getSessionDelta(); + const payload: ExternalCostUpdate = { + source: EXTERNAL_COST_SOURCE, + totalCost: delta.totalCost, + inputTokens: delta.inputTokens, + outputTokens: delta.outputTokens, + }; + pi.events.emit(EXTERNAL_COST_CHANNEL, payload); +} diff --git a/pi-condense/src/summarizer-fallback.test.ts b/pi-condense/src/summarizer-fallback.test.ts new file mode 100644 index 0000000..cdeccd3 --- /dev/null +++ b/pi-condense/src/summarizer-fallback.test.ts @@ -0,0 +1,163 @@ +import { describe, it, expect } from "bun:test"; +import { FallbackController, COOLDOWN_MS } from "./summarizer-fallback.js"; + +const HAIKU = { id: "claude-haiku", provider: "anthropic", name: "Haiku" }; +const SONNET = { id: "claude-sonnet", provider: "anthropic", name: "Sonnet" }; + +function clockAt(t: { now: number }) { + return () => t.now; +} + +describe("hasDistinctFallback", () => { + it("false when models are identical", () => { + expect(FallbackController.hasDistinctFallback(HAIKU, HAIKU)).toBe(false); + }); + it("false when either model is missing", () => { + expect(FallbackController.hasDistinctFallback(undefined, SONNET)).toBe(false); + expect(FallbackController.hasDistinctFallback(HAIKU, undefined)).toBe(false); + }); + it("true when id or provider differ", () => { + expect(FallbackController.hasDistinctFallback(HAIKU, SONNET)).toBe(true); + expect( + FallbackController.hasDistinctFallback( + { id: "m", provider: "a" }, + { id: "m", provider: "b" }, + ), + ).toBe(true); + }); +}); + +describe("chooseTarget", () => { + it("targets primary when not in fallback", () => { + const c = new FallbackController(); + expect(c.chooseTarget()).toEqual({ target: "primary", wasProbe: false }); + }); + + it("targets fallback while in fallback and before cooldown", () => { + const t = { now: 1_000_000 }; + const c = new FallbackController(clockAt(t)); + c.onPrimaryFailFallbackOk(false); + expect(c.inFallback).toBe(true); + t.now += COOLDOWN_MS - 1; + expect(c.chooseTarget()).toEqual({ target: "fallback", wasProbe: false }); + }); + + it("elects exactly one probe after cooldown elapses", () => { + const t = { now: 0 }; + const c = new FallbackController(clockAt(t)); + c.onPrimaryFailFallbackOk(false); // enter, lastProbeAt = 0 + t.now = COOLDOWN_MS; + expect(c.chooseTarget()).toEqual({ target: "primary", wasProbe: true }); + expect(c.chooseTarget()).toEqual({ target: "fallback", wasProbe: false }); + }); +}); + +describe("enter fallback (single notify across N)", () => { + it("first rescued transient returns 'enter', rest return 'none'", () => { + const c = new FallbackController(); + expect(c.onPrimaryFailFallbackOk(false)).toBe("enter"); + expect(c.inFallback).toBe(true); + expect(c.onPrimaryFailFallbackOk(false)).toBe("none"); + expect(c.onPrimaryFailFallbackOk(false)).toBe("none"); + }); +}); + +describe("recover", () => { + it("probe success while in fallback returns 'recover' and clears state", () => { + const c = new FallbackController(); + c.onPrimaryFailFallbackOk(false); // enter + expect(c.onPrimarySuccess(true)).toBe("recover"); + expect(c.inFallback).toBe(false); + }); + it("non-probe primary success is a no-op", () => { + const c = new FallbackController(); + expect(c.onPrimarySuccess(false)).toBe("none"); + expect(c.inFallback).toBe(false); + }); +}); + +describe("probe transient failure keeps fallback (stay)", () => { + it("rescued probe returns 'none' and stays in fallback", () => { + const t = { now: 0 }; + const c = new FallbackController(clockAt(t)); + c.onPrimaryFailFallbackOk(false); // enter + t.now = COOLDOWN_MS; + const d = c.chooseTarget(); // claims probe + expect(d.wasProbe).toBe(true); + t.now = COOLDOWN_MS + 5; + expect(c.onPrimaryFailFallbackOk(true)).toBe("none"); + expect(c.inFallback).toBe(true); + }); +}); + +describe("both-down owes a deferred enter warning", () => { + it("owed warning fires on the first later fallback success", () => { + const c = new FallbackController(); + c.onBothDown(); // initial detection both-down: enter silently, owe warning + expect(c.inFallback).toBe(true); + expect(c.onFallbackSuccess()).toBe("enter"); + expect(c.onFallbackSuccess()).toBe("none"); + }); + it("both-down while already in fallback does not owe a new warning", () => { + const c = new FallbackController(); + c.onPrimaryFailFallbackOk(false); // enter (warning already shown) + c.onBothDown(); // probe both-down while in fallback + expect(c.onFallbackSuccess()).toBe("none"); + }); +}); + +describe("recover clears an owed enter warning", () => { + it("a probe recovery after both-down does not later emit a stale enter", () => { + const t = { now: 0 }; + const c = new FallbackController(clockAt(t)); + c.onBothDown(); // enter silently, owe warning + t.now = COOLDOWN_MS; + const d = c.chooseTarget(); // claims probe + expect(d.wasProbe).toBe(true); + expect(c.onPrimarySuccess(true)).toBe("recover"); + expect(c.inFallback).toBe(false); + // owed warning must be cleared by recovery + expect(c.onFallbackSuccess()).toBe("none"); + }); +}); + +describe("unusable probe does not falsely recover", () => { + it("no onPrimarySuccess call means inFallback is retained", () => { + const t = { now: 0 }; + const c = new FallbackController(clockAt(t)); + c.onPrimaryFailFallbackOk(false); // enter + t.now = COOLDOWN_MS; + const d = c.chooseTarget(); + expect(d.wasProbe).toBe(true); + expect(c.inFallback).toBe(true); + }); +}); + +describe("probe schedule survives steady-state fallback failures", () => { + it("onFallbackOnlyFail does not push out the next primary probe", () => { + const t = { now: 0 }; + const c = new FallbackController(clockAt(t)); + c.onPrimaryFailFallbackOk(false); // enter, lastProbeAt = 0 + // fallback keeps failing every minute, well within the cooldown + for (let i = 1; i < 10; i++) { + t.now = i * 60_000; + expect(c.chooseTarget()).toEqual({ target: "fallback", wasProbe: false }); + c.onFallbackOnlyFail(); + } + // at COOLDOWN_MS the primary must still be probed despite the failures + t.now = COOLDOWN_MS; + expect(c.chooseTarget()).toEqual({ target: "primary", wasProbe: true }); + }); +}); + +describe("reset", () => { + it("clears all state", () => { + const t = { now: 5 }; + const c = new FallbackController(clockAt(t)); + c.onBothDown(); + c.reset(); + expect(c.inFallback).toBe(false); + expect(c.onFallbackSuccess()).toBe("none"); // owed cleared + expect(c.chooseTarget()).toEqual({ target: "primary", wasProbe: false }); + }); +}); diff --git a/pi-condense/src/summarizer-fallback.ts b/pi-condense/src/summarizer-fallback.ts new file mode 100644 index 0000000..ad9f948 --- /dev/null +++ b/pi-condense/src/summarizer-fallback.ts @@ -0,0 +1,126 @@ +/** + * Session-scoped, in-memory state machine for summarizer-model outage fallback. + * + * Pure of model IO and notify plumbing: transition methods mutate state and + * return a transition tag; the caller (runSummarization) performs the LLM runs + * and emits any notify text. `now()` is injected for deterministic tests. + * + * Engaged ONLY on transient (outage-shaped) failures of the configured + * summarizer model, and only when a distinct fallback model exists. Sticky: + * once in fallback, all calls route to the session model until a single + * per-cooldown probe of the primary succeeds. See + * doc/specs/2026-07-06-summarizer-outage-fallback.md. + */ + +/** Re-probe cooldown while in fallback. Internal; deliberately not configurable. */ +export const COOLDOWN_MS = 10 * 60 * 1000; + +export type FallbackTransition = "enter" | "recover" | "none"; +export type CallTarget = "primary" | "fallback"; + +export interface TargetDecision { + target: CallTarget; + wasProbe: boolean; +} + +/** Minimal structural view of a pi-ai Model (avoids the generic Api type param). */ +export interface ModelLike { + id: string; + provider: string; + name?: string; +} + +export class FallbackController { + inFallback = false; + private lastProbeAt = 0; + private owedEnterWarning = false; + + constructor(private readonly now: () => number = Date.now) {} + + reset(): void { + this.inFallback = false; + this.lastProbeAt = 0; + this.owedEnterWarning = false; + } + + /** + * True when primary and the session model are genuinely different. When + * false the controller must NOT be consulted (behavior identical to today). + * `Model.provider` is a plain string in pi-ai, not an object. + */ + static hasDistinctFallback( + primary: ModelLike | undefined, + sessionModel: ModelLike | undefined, + ): boolean { + if (!primary || !sessionModel) return false; + return primary.provider !== sessionModel.provider || primary.id !== sessionModel.id; + } + + /** + * Pick the model target for the next call and, if eligible, claim the single + * per-cooldown probe. The claim is synchronous: the first of N concurrent + * callers in a flush advances `lastProbeAt`, so siblings see the cooldown as + * not elapsed and route to the fallback. Call before the first await. + */ + chooseTarget(): TargetDecision { + if (!this.inFallback) return { target: "primary", wasProbe: false }; + if (this.now() - this.lastProbeAt >= COOLDOWN_MS) { + this.lastProbeAt = this.now(); + return { target: "primary", wasProbe: true }; + } + return { target: "fallback", wasProbe: false }; + } + + /** Primary (initial or probe) failed transiently but the fallback retry succeeded. */ + onPrimaryFailFallbackOk(_wasProbe: boolean): FallbackTransition { + this.lastProbeAt = this.now(); + if (this.owedEnterWarning) { + this.owedEnterWarning = false; + this.inFallback = true; + return "enter"; + } + if (!this.inFallback) { + this.inFallback = true; + return "enter"; + } + // probe transient, rescued by fallback -> stay, no notify + return "none"; + } + + /** Both the primary call and the fallback retry failed transiently. */ + onBothDown(): void { + this.lastProbeAt = this.now(); + if (!this.inFallback) { + this.inFallback = true; + this.owedEnterWarning = true; + } + } + + /** + * A steady-state fallback call (already in fallback) failed transiently. + * Deliberately a no-op on `lastProbeAt`: a fallback failure is not a probe, + * so it must not push out the next primary re-probe. Resetting the cooldown + * here starves the probe whenever the fallback fails at least once per + * COOLDOWN_MS, leaving a recovered primary undetected indefinitely. + */ + onFallbackOnlyFail(): void {} + + /** A primary call succeeded. Recover only when it was the probe. */ + onPrimarySuccess(wasProbe: boolean): FallbackTransition { + if (wasProbe && this.inFallback) { + this.inFallback = false; + this.owedEnterWarning = false; + return "recover"; + } + return "none"; + } + + /** A steady-state fallback call succeeded. Fire the deferred enter warning if owed. */ + onFallbackSuccess(): FallbackTransition { + if (this.owedEnterWarning) { + this.owedEnterWarning = false; + return "enter"; + } + return "none"; + } +} diff --git a/pi-condense/src/summarizer-wiring.test.ts b/pi-condense/src/summarizer-wiring.test.ts new file mode 100644 index 0000000..137acb1 --- /dev/null +++ b/pi-condense/src/summarizer-wiring.test.ts @@ -0,0 +1,415 @@ +import { describe, it, expect, mock } from "bun:test"; +import * as actualCompat from "@earendil-works/pi-ai/compat"; + +// Stub pi-ai's `stream` so runSummarization can be exercised without a network +// call. `streamImpl` is swapped per test to simulate primary/fallback outcomes. +let streamImpl: (model: any, input?: any, opts?: any) => any = () => { + throw new Error("streamImpl not set"); +}; +mock.module("@earendil-works/pi-ai/compat", () => ({ + ...actualCompat, + stream: (...args: any[]) => streamImpl(...args), +})); + +const { summarizeBatch, summarizerRetryDelayMs } = await import("./summarizer.js"); +const { FallbackController } = await import("./summarizer-fallback.js"); +const { DEFAULT_CONFIG } = await import("./types.js"); + +const PRIMARY = { id: "primary-model", provider: "provider-a", name: "Primary" }; +const SESSION = { id: "session-model", provider: "provider-b", name: "Session" }; + +const USAGE = { + input: 1, + output: 1, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 2, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, +}; + +function okStream(text: string) { + return { + async *[Symbol.asyncIterator]() { + // no events; runOnce only needs .result() + }, + async result() { + return { stopReason: "stop", content: [{ type: "text", text }], usage: USAGE }; + }, + }; +} + +function errStream(message: string) { + return { + async *[Symbol.asyncIterator]() {}, + async result() { + return { stopReason: "error", errorMessage: message, content: [], usage: USAGE }; + }, + }; +} + +// Hangs until `opts.signal` (the combined caller+timeout signal runOnce +// passes to stream()) aborts. With no signal it never settles. +function hangingStream(opts: any) { + const signal: AbortSignal | undefined = opts?.signal; + const untilAbort = () => + new Promise((_, reject) => { + if (!signal) return; // no signal => never settles + if (signal.aborted) return reject(new Error("aborted")); + signal.addEventListener("abort", () => reject(new Error("aborted")), { once: true }); + }); + return { + async *[Symbol.asyncIterator]() { + await untilAbort(); + }, + async result() { + return untilAbort(); + }, + }; +} + +// Emits `events` thinking_delta events spaced `gapMs` apart, then completes +// successfully — UNLESS `opts.signal` aborts mid-drip, in which case the +// current sleep rejects, exactly like a real provider stream cancelling on +// abort. This is what gives the idle-reset test teeth: if runOnce's in-loop +// bumpIdle() is ever removed, the idle timer fires at the configured window +// and the combined signal aborts, so this stream rejects instead of +// completing — the test then fails instead of passing vacuously. +function drippingStream(opts: any, text: string, events: number, gapMs: number) { + const signal: AbortSignal | undefined = opts?.signal; + const sleepOrAbort = (ms: number) => + new Promise((resolve, reject) => { + if (signal?.aborted) return reject(new Error("aborted")); + const timer = setTimeout(resolve, ms); + signal?.addEventListener( + "abort", + () => { + clearTimeout(timer); + reject(new Error("aborted")); + }, + { once: true } + ); + }); + return { + async *[Symbol.asyncIterator]() { + for (let i = 0; i < events; i++) { + await sleepOrAbort(gapMs); + yield { type: "thinking_delta" }; + } + }, + async result() { + return { stopReason: "stop", content: [{ type: "text", text }], usage: USAGE }; + }, + }; +} + +interface Note { + msg: string; + level: string; +} + +function makeCtx(notes: Note[], sessionModel: any = SESSION, primaryModel: any = PRIMARY) { + return { + model: sessionModel, + modelRegistry: { + find: () => primaryModel, + getApiKeyAndHeaders: async () => ({ ok: true, apiKey: "k", headers: {} }), + getProviderAuth: async () => undefined, + }, + ui: { notify: (msg: string, level: string) => notes.push({ msg, level }) }, + } as any; +} + +function makeBatch() { + return { + turnIndex: 0, + timestamp: 0, + assistantText: "", + toolCalls: [ + { toolCallId: "t1", toolName: "read", args: {}, resultText: "x".repeat(50), isError: false }, + ], + } as any; +} + +const noRetryConfig = { + ...DEFAULT_CONFIG, + summarizerMaxRetries: 0, + summarizerRetryBaseDelayMs: 0, + summarizerRetryMaxDelayMs: 0, +}; +const immediateRetryConfig = { ...noRetryConfig, summarizerMaxRetries: 2 }; +const distinctConfig = { + ...noRetryConfig, + summarizerModel: "provider-a/primary-model", + summarizerMaxRetries: 1, // preserve one primary -> session fallback attempt in legacy wiring tests +}; + +describe("runSummarization wiring — same-model no-op (legacy path)", () => { + it("summarizerModel=default: transient failure notifies error, returns null, controller untouched", async () => { + streamImpl = () => errStream("provider overloaded"); + const notes: Note[] = []; + const ctx = makeCtx(notes); + const controller = new FallbackController(); + const r = await summarizeBatch(makeBatch(), { ...noRetryConfig, summarizerModel: "default" }, ctx, { + controller, + }); + expect(r).toBeNull(); + expect(controller.inFallback).toBe(false); + expect(notes).toHaveLength(1); + expect(notes[0].level).toBe("error"); + expect(notes[0].msg).toContain("provider overloaded"); + }); +}); + +describe("runSummarization wiring — configurable retries", () => { + it("computes 1s, 2s, 4s backoff and caps later waits at 8s", () => { + expect([1, 2, 3, 4, 5].map((retry) => summarizerRetryDelayMs(DEFAULT_CONFIG, retry))).toEqual([ + 1000, + 2000, + 4000, + 8000, + 8000, + ]); + }); + + it("retries two transient failures and returns the third successful attempt", async () => { + let calls = 0; + streamImpl = () => { + calls += 1; + return calls < 3 ? errStream("provider overloaded") : okStream("- recovered"); + }; + const notes: Note[] = []; + const r = await summarizeBatch(makeBatch(), immediateRetryConfig, makeCtx(notes), {}); + expect(r?.summaryText).toBe("- recovered"); + expect(calls).toBe(3); + expect(notes).toHaveLength(0); + }); + + it("reports only after the initial attempt and both retries fail", async () => { + let calls = 0; + streamImpl = () => { + calls += 1; + return errStream("provider overloaded"); + }; + const notes: Note[] = []; + const r = await summarizeBatch(makeBatch(), immediateRetryConfig, makeCtx(notes), {}); + expect(r).toBeNull(); + expect(calls).toBe(3); + expect(notes).toHaveLength(1); + expect(notes[0]).toMatchObject({ level: "error" }); + expect(notes[0].msg).toContain("summarization failed after 2 retries"); + }); + + it("reserves the final retry slot for a distinct session-model fallback", async () => { + const seen: string[] = []; + streamImpl = (model) => { + seen.push(model.id); + return model.id === PRIMARY.id ? errStream("primary down") : okStream("- fallback"); + }; + const notes: Note[] = []; + const controller = new FallbackController(); + const config = { + ...distinctConfig, + summarizerMaxRetries: 2, + }; + const r = await summarizeBatch(makeBatch(), config, makeCtx(notes), { controller }); + expect(r?.summaryText).toBe("- fallback"); + expect(seen).toEqual([PRIMARY.id, PRIMARY.id, SESSION.id]); + expect(notes.filter((n) => n.level === "error")).toHaveLength(0); + }); + + it("does not retry authentication failures", async () => { + let streamCalls = 0; + streamImpl = () => { + streamCalls += 1; + return okStream("- unreachable"); + }; + const notes: Note[] = []; + const ctx = makeCtx(notes); + ctx.modelRegistry.getApiKeyAndHeaders = async () => ({ ok: false, error: "authentication failed" }); + const r = await summarizeBatch(makeBatch(), immediateRetryConfig, ctx, {}); + expect(r).toBeNull(); + expect(streamCalls).toBe(0); + expect(notes).toHaveLength(1); + expect(notes[0].msg).not.toContain("after 2 retries"); + }); + + + it("aborts promptly during retry backoff without showing a final error", async () => { + let calls = 0; + streamImpl = () => { + calls += 1; + return errStream("temporary outage"); + }; + const notes: Note[] = []; + const ac = new AbortController(); + const config = { + ...immediateRetryConfig, + summarizerRetryBaseDelayMs: 100, + summarizerRetryMaxDelayMs: 100, + }; + setTimeout(() => ac.abort(), 5); + await expect(summarizeBatch(makeBatch(), config, makeCtx(notes), { signal: ac.signal })).rejects.toThrow(); + expect(calls).toBe(1); + expect(notes).toHaveLength(0); + }); +}); + +describe("runSummarization wiring — enter fallback", () => { + it("primary transient + fallback ok: returns summary, one warning, no error notify, sticky", async () => { + streamImpl = (model) => (model.id === PRIMARY.id ? errStream("down") : okStream("- fallback summary")); + const notes: Note[] = []; + const ctx = makeCtx(notes); + const controller = new FallbackController(); + const r = await summarizeBatch(makeBatch(), distinctConfig, ctx, { controller }); + expect(r?.summaryText).toBe("- fallback summary"); + expect(controller.inFallback).toBe(true); + const warnings = notes.filter((n) => n.level === "warning"); + const errors = notes.filter((n) => n.level === "error"); + expect(warnings).toHaveLength(1); + expect(warnings[0].msg).toContain("Primary"); + expect(warnings[0].msg).toContain("Session"); + expect(errors).toHaveLength(0); + }); + + it("steady-state after enter routes to the session model only (no primary call, no notify)", async () => { + const seen: string[] = []; + streamImpl = (model) => { + seen.push(model.id); + return model.id === PRIMARY.id ? errStream("down") : okStream("- ok"); + }; + const notes: Note[] = []; + const ctx = makeCtx(notes); + const controller = new FallbackController(); // real clock: cooldown (10m) will not elapse in-test + await summarizeBatch(makeBatch(), distinctConfig, ctx, { controller }); // enter + seen.length = 0; + notes.length = 0; + const r = await summarizeBatch(makeBatch(), distinctConfig, ctx, { controller }); // steady-state + expect(r?.summaryText).toBe("- ok"); + expect(seen).toEqual([SESSION.id]); // primary never called again before cooldown + expect(notes).toHaveLength(0); + }); +}); + +describe("runSummarization wiring — both-down + deferred warning", () => { + it("primary + fallback both transient: null, error notify, enters fallback with owed warning", async () => { + streamImpl = () => errStream("everything down"); + const notes: Note[] = []; + const ctx = makeCtx(notes); + const controller = new FallbackController(); + const r = await summarizeBatch(makeBatch(), distinctConfig, ctx, { controller }); + expect(r).toBeNull(); + expect(controller.inFallback).toBe(true); + const warnings = notes.filter((n) => n.level === "warning"); + const errors = notes.filter((n) => n.level === "error"); + expect(warnings).toHaveLength(0); // warning is owed, not yet fired + expect(errors).toHaveLength(1); + + // Next flush: fallback now succeeds -> owed warning fires once. + streamImpl = (model) => (model.id === PRIMARY.id ? errStream("still down") : okStream("- rescued")); + notes.length = 0; + const r2 = await summarizeBatch(makeBatch(), distinctConfig, ctx, { controller }); + expect(r2?.summaryText).toBe("- rescued"); + expect(notes.filter((n) => n.level === "warning")).toHaveLength(1); + }); +}); + +describe("runSummarization wiring — abort", () => { + it("re-throws when the signal is already aborted", async () => { + streamImpl = () => okStream("- never"); + const notes: Note[] = []; + const ctx = makeCtx(notes); + const controller = new FallbackController(); + const ac = new AbortController(); + ac.abort(); + await expect( + summarizeBatch(makeBatch(), distinctConfig, ctx, { controller, signal: ac.signal }), + ).rejects.toThrow(); + }); +}); + +describe("runSummarization wiring — timeouts", () => { + it("idle timeout (default model): transient warning, returns null", async () => { + streamImpl = (_m, _i, opts) => hangingStream(opts); + const notes: Note[] = []; + const ctx = makeCtx(notes); + const cfg = { ...noRetryConfig, summarizerModel: "default", summarizerIdleTimeoutMs: 20, summarizerMaxTimeoutMs: 0 }; + const r = await summarizeBatch(makeBatch(), cfg, ctx, {}); + expect(r).toBeNull(); + const warnings = notes.filter((n) => n.level === "warning"); + expect(warnings).toHaveLength(1); + expect(warnings[0].msg).toMatch(/stalled/); + expect(notes.filter((n) => n.level === "error")).toHaveLength(0); + }); + + it("ceiling timeout (idle disabled): transient warning mentioning ceiling", async () => { + streamImpl = (_m, _i, opts) => hangingStream(opts); + const notes: Note[] = []; + const ctx = makeCtx(notes); + const cfg = { ...noRetryConfig, summarizerModel: "default", summarizerIdleTimeoutMs: 0, summarizerMaxTimeoutMs: 20 }; + const r = await summarizeBatch(makeBatch(), cfg, ctx, {}); + expect(r).toBeNull(); + const warnings = notes.filter((n) => n.level === "warning"); + expect(warnings).toHaveLength(1); + expect(warnings[0].msg).toMatch(/ceiling/); + }); + + it("option B: primary idle-times-out, session model rescues", async () => { + streamImpl = (model, _i, opts) => (model.id === PRIMARY.id ? hangingStream(opts) : okStream("- rescued")); + const notes: Note[] = []; + const ctx = makeCtx(notes); + const controller = new FallbackController(); + const cfg = { ...distinctConfig, summarizerIdleTimeoutMs: 20 }; + const r = await summarizeBatch(makeBatch(), cfg, ctx, { controller }); + expect(r?.summaryText).toBe("- rescued"); + expect(controller.inFallback).toBe(true); + expect(notes.filter((n) => n.level === "warning")).toHaveLength(1); // generic "enter" fallback warning + expect(notes.filter((n) => n.level === "error")).toHaveLength(0); + }); + + it("both time out: null, both-down notice at warning severity", async () => { + streamImpl = (_m, _i, opts) => hangingStream(opts); + const notes: Note[] = []; + const ctx = makeCtx(notes); + const controller = new FallbackController(); + const cfg = { ...distinctConfig, summarizerIdleTimeoutMs: 20 }; + const r = await summarizeBatch(makeBatch(), cfg, ctx, { controller }); + expect(r).toBeNull(); + expect(notes.filter((n) => n.level === "warning")).toHaveLength(1); + expect(notes.filter((n) => n.level === "error")).toHaveLength(0); + }); + + it("pre-aborted signal is not a timeout (throws, no warning)", async () => { + streamImpl = (_m, _i, opts) => hangingStream(opts); + const notes: Note[] = []; + const ctx = makeCtx(notes); + const ac = new AbortController(); + ac.abort(); + const cfg = { ...distinctConfig, summarizerIdleTimeoutMs: 20 }; + await expect(summarizeBatch(makeBatch(), cfg, ctx, { signal: ac.signal })).rejects.toThrow(); + expect(notes.filter((n) => n.level === "warning")).toHaveLength(0); + }); + + it("both timeouts disabled: okStream succeeds unchanged", async () => { + streamImpl = () => okStream("- ok"); + const notes: Note[] = []; + const ctx = makeCtx(notes); + const cfg = { ...noRetryConfig, summarizerModel: "default", summarizerIdleTimeoutMs: 0, summarizerMaxTimeoutMs: 0 }; + const r = await summarizeBatch(makeBatch(), cfg, ctx, {}); + expect(r?.summaryText).toBe("- ok"); + expect(notes).toHaveLength(0); + }); +}); + +describe("runSummarization wiring — idle reset keeps a flowing stream alive", () => { + it("does not time out while events keep arriving within the idle window", async () => { + // 6 events, 10ms apart = 60ms total > 25ms idle window; only survives if + // the idle timer resets on every event (bumpIdle() inside the loop). + streamImpl = (_m, _i, opts) => drippingStream(opts, "- flowing summary", 6, 10); + const notes: Note[] = []; + const ctx = makeCtx(notes); + const cfg = { ...noRetryConfig, summarizerModel: "default", summarizerIdleTimeoutMs: 25, summarizerMaxTimeoutMs: 0 }; + const r = await summarizeBatch(makeBatch(), cfg, ctx, {}); + expect(r?.summaryText).toBe("- flowing summary"); + expect(notes.filter((n) => n.level === "warning")).toHaveLength(0); + }); +}); diff --git a/pi-condense/src/summarizer.test.ts b/pi-condense/src/summarizer.test.ts new file mode 100644 index 0000000..aec7127 --- /dev/null +++ b/pi-condense/src/summarizer.test.ts @@ -0,0 +1,17 @@ +import { describe, it, expect } from "bun:test"; +import { isUsableSummary } from "./summarizer.js"; + +describe("isUsableSummary", () => { + it("accepts non-empty text that stopped normally", () => { + expect(isUsableSummary("- did a thing", "stop")).toBe(true); + }); + it("rejects empty text", () => { + expect(isUsableSummary("", "stop")).toBe(false); + }); + it("rejects whitespace-only text", () => { + expect(isUsableSummary(" \n\t ", "stop")).toBe(false); + }); + it("rejects truncated output even with text", () => { + expect(isUsableSummary("- partial", "length")).toBe(false); + }); +}); diff --git a/pi-condense/src/summarizer.ts b/pi-condense/src/summarizer.ts new file mode 100644 index 0000000..bec259c --- /dev/null +++ b/pi-condense/src/summarizer.ts @@ -0,0 +1,509 @@ +import { stream } from "@earendil-works/pi-ai/compat"; +import type { AssistantMessage } from "@earendil-works/pi-ai"; +import type { ExtensionContext } from "@earendil-works/pi-coding-agent"; +import type { + CapturedBatch, + ContextPruneConfig, + SummarizerThinking, + SummarizeBatchOptions, + SummarizeBatchesOptions, + SummarizeResult, +} from "./types.js"; +import { serializeBatchForSummarizer } from "./batch-capture.js"; +import { FallbackController, type FallbackTransition } from "./summarizer-fallback.js"; + +const SYSTEM_PROMPT = `You are summarizing a batch of tool calls made by an AI coding assistant. +For each tool call provide: +- Tool name and a one-sentence description of what it did +- Key outcome, plus any file paths, identifiers, signatures, or error strings copied verbatim - never reword these +- Any findings the future conversation needs to remember + +Keep each tool call to 1-3 bullet points. Skip calls that succeeded with nothing reusable to record. Be concise. + +Begin the first bullet of each tool call with that tool's [[N:toolname]] label, copied verbatim (both the number and the name) from its line in the input, as the plain, first thing on the line - no bold, backticks, or list numbering around it. Do not renumber, rename, or invent labels; if you skip a tool, skip its label too.`; + +const RANGE_SYSTEM_PROMPT = `You are fusing several per-step summaries of one CLOSED sub-task from an AI coding assistant's history into a SINGLE cohesive summary. +- Merge overlapping or repeated information; do not restate each step separately. +- Preserve concrete outcomes, decisions, file paths, identifiers, and anything later work needs to remember. +- Keep any reference tokens like \`t12\` or \`b3\` intact. +- Be concise: a short narrative or a few grouped bullets, not one bullet per step.`; + +export function summarizerThinkingOptions(config: ContextPruneConfig): Record { + const level: SummarizerThinking = config.summarizerThinking; + if (level === "default") { + return {}; + } + + // stream()/complete() accept provider-level options. For reasoning-capable providers, + // pi-ai adapters translate reasoningEffort into the provider-specific field. + // "off" intentionally sends no effort; adapters that support explicit disable + // handle that the same way as an absent effort, while preserving compatibility. + return { reasoningEffort: level === "off" ? undefined : level }; +} + +/** + * Returns the model to use for summarization. + * config.summarizerModel === "default" => ctx.model + * "provider/model-id" => ctx.modelRegistry.find(provider, modelId), fallback to ctx.model with warning + */ +export function resolveModel(config: ContextPruneConfig, ctx: ExtensionContext): any { + if (config.summarizerModel === "default") { + return ctx.model; + } + + const slashIndex = config.summarizerModel.indexOf("/"); + if (slashIndex === -1) { + ctx.ui.notify( + `pruner: invalid summarizerModel "${config.summarizerModel}", expected "provider/model-id". Falling back to default model.`, + "warning" + ); + return ctx.model; + } + + const provider = config.summarizerModel.slice(0, slashIndex); + const modelId = config.summarizerModel.slice(slashIndex + 1); + + const found = ctx.modelRegistry.find(provider, modelId); + if (!found) { + ctx.ui.notify( + `pruner: model "${config.summarizerModel}" not found in registry. Falling back to default model.`, + "warning" + ); + return ctx.model; + } + + return found; +} + +function receivedTextChars(message: AssistantMessage): number { + return message.content.reduce((sum, content) => { + return content.type === "text" ? sum + content.text.length : sum; + }, 0); +} + +/** A summary is usable only if it has non-whitespace text and was not truncated. */ +export function isUsableSummary(llmText: string, stopReason: string): boolean { + return llmText.trim().length > 0 && stopReason !== "length"; +} + +type RunOutcome = + | { kind: "ok"; result: SummarizeResult } + | { kind: "auth"; message: string } + | { kind: "unusable" } + | { kind: "transient"; message: string; timedOut?: boolean }; + +/** Human label for a model in notify text: prefer name, fall back to provider/id. */ +function modelLabel(model: any): string { + if (!model) return "unknown model"; + return model.name || `${model.provider}/${model.id}`; +} + +/** Combines any present abort signals into one; undefined if none are given. */ +function combineSignals(...signals: (AbortSignal | undefined)[]): AbortSignal | undefined { + const present = signals.filter((s): s is AbortSignal => !!s); + if (present.length === 0) return undefined; + if (present.length === 1) return present[0]; + return AbortSignal.any(present); // Node 20+; host runtime is node 24.5.0 +} + +/** + * One summarization attempt against a specific model. Returns a classified + * outcome instead of throwing (except aborts, which propagate so flushPending + * can restore state). Auth failure is detected pre-stream and never reaches + * the fallback path. `unusable` = empty or length-truncated. Everything else + * that reaches the catch is `transient` (the outage bucket) — pi-ai surfaces + * no structured status code on the throw, so classification is coarse by design. + */ +async function runOnce( + model: any, + userMessage: string, + config: ContextPruneConfig, + ctx: ExtensionContext, + options: SummarizeBatchOptions +): Promise { + const idleMs = config.summarizerIdleTimeoutMs; + const maxMs = config.summarizerMaxTimeoutMs; + const timeoutController = new AbortController(); + let timedOut = false; + let timeoutKind: "idle" | "ceiling" | null = null; + let idleTimerId: ReturnType | null = null; + let ceilingTimerId: ReturnType | null = null; + + const bumpIdle = () => { + if (idleTimerId !== null) clearTimeout(idleTimerId); + if (idleMs > 0) { + idleTimerId = setTimeout(() => { + timedOut = true; + timeoutKind = "idle"; + timeoutController.abort(); + }, idleMs); + } + }; + const timeoutMessage = () => + timeoutKind === "ceiling" + ? `summarizer ${modelLabel(model)} exceeded ${Math.round(maxMs / 1000)}s ceiling` + : `summarizer ${modelLabel(model)} stalled (no output for ${Math.round(idleMs / 1000)}s)`; + + try { + const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model); + if (!auth.ok) { + const authMessage = "error" in auth ? auth.error : "authentication failed"; + return { kind: "auth", message: authMessage }; + } + + // Mirror the main loop (model-runtime stream): auth resolution can carry a + // seat-specific baseUrl (e.g. GitHub Copilot business/enterprise endpoints). + // The shipped model data pins the individual host, which 421s other seats, + // so the resolved auth baseUrl must win over the static model baseUrl. + const providerAuth = await ctx.modelRegistry.getProviderAuth(model.provider); + const effectiveModel = providerAuth?.auth.baseUrl + ? { ...model, baseUrl: providerAuth.auth.baseUrl } + : model; + + // Pass the combined signal so the underlying fetch is cancelled immediately + // either when the user presses Esc, or when an idle/ceiling timeout fires. + const responseStream = stream( + effectiveModel, + { + messages: [ + { + role: "user", + content: [{ type: "text", text: userMessage }], + timestamp: Date.now(), + }, + ], + }, + { + apiKey: auth.apiKey, + headers: auth.headers, + signal: combineSignals(options.signal, timeoutController.signal), + ...summarizerThinkingOptions(config), + } + ); + + // Ceiling arms once at call start; idle arms/resets on every stream event + // (including before the first one, so it also bounds time-to-first-token). + if (maxMs > 0) { + ceilingTimerId = setTimeout(() => { + timedOut = true; + timeoutKind ??= "ceiling"; + timeoutController.abort(); + }, maxMs); + } + bumpIdle(); + + let lastReportedChars = -1; + options.onTextProgress?.(0); + const reportTextProgress = (message: AssistantMessage) => { + const chars = receivedTextChars(message); + if (chars !== lastReportedChars) { + lastReportedChars = chars; + options.onTextProgress?.(chars); + } + }; + + for await (const event of responseStream) { + // Reset idle on ANY event (text_* and thinking_*), not just text — a + // reasoning-heavy model stays alive via thinking_delta and is never + // false-aborted for being quiet on text while it reasons. + bumpIdle(); + // Belt-and-suspenders: break early when signal fires mid-stream. + if (options.signal?.aborted) break; + if (event.type === "text_start" || event.type === "text_delta" || event.type === "text_end") { + reportTextProgress(event.partial); + } + } + + // If signal fired while we were iterating, propagate the abort so + // flushPending can detect it and restore batches. + if (options.signal?.aborted) throw new Error("summarize: aborted during stream"); + + const response = await responseStream.result(); + reportTextProgress(response); + // stopReason "aborted" means the provider cut the stream short (e.g. signal + // fired just before the final chunk). Treat identically to the signal check + // above — throw so the catch below can detect options.signal.aborted. + if (response.stopReason === "aborted") { + throw new Error("summarize: stream stopped with reason aborted"); + } + if (response.stopReason === "error") { + if (timedOut) return { kind: "transient", message: timeoutMessage(), timedOut: true }; + return { kind: "transient", message: response.errorMessage ?? "Summarizer stopped with reason: error" }; + } + + const llmText = response.content + .filter((c: any) => c.type === "text") + .map((c: any) => c.text) + .join("\n"); + + if (!isUsableSummary(llmText, response.stopReason)) return { kind: "unusable" }; + + return { kind: "ok", result: { summaryText: llmText, usage: response.usage } }; + } catch (err: any) { + // Propagate abort errors upward so flushPending can check signal.aborted + // and return { ok: false, reason: "aborted" } without showing a UI error. + if (options.signal?.aborted) throw err; + if (timedOut) return { kind: "transient", message: timeoutMessage(), timedOut: true }; + return { kind: "transient", message: err.message }; + } finally { + if (idleTimerId !== null) clearTimeout(idleTimerId); + if (ceilingTimerId !== null) clearTimeout(ceilingTimerId); + } +} + +/** Returns the bounded exponential-backoff delay for a one-based retry number. */ +export function summarizerRetryDelayMs(config: ContextPruneConfig, retryNumber: number): number { + if (retryNumber <= 0 || config.summarizerRetryBaseDelayMs <= 0 || config.summarizerRetryMaxDelayMs <= 0) { + return 0; + } + return Math.min( + config.summarizerRetryMaxDelayMs, + config.summarizerRetryBaseDelayMs * 2 ** Math.max(0, retryNumber - 1) + ); +} + +async function waitBeforeRetry( + config: ContextPruneConfig, + retryNumber: number, + signal?: AbortSignal +): Promise { + if (signal?.aborted) throw new Error("summarize: aborted before retry"); + const delayMs = summarizerRetryDelayMs(config, retryNumber); + if (delayMs === 0) return; + + await new Promise((resolve, reject) => { + let settled = false; + let timer: ReturnType; + const cleanup = () => signal?.removeEventListener("abort", onAbort); + const onAbort = () => { + if (settled) return; + settled = true; + clearTimeout(timer); + cleanup(); + reject(new Error("summarize: aborted during retry backoff")); + }; + timer = setTimeout(() => { + if (settled) return; + settled = true; + cleanup(); + resolve(); + }, delayMs); + signal?.addEventListener("abort", onAbort, { once: true }); + // Close the race where abort happened after the pre-check but before the listener. + if (signal?.aborted) onAbort(); + }); +} + +/** Retries only outcomes already classified as transient by runOnce. */ +async function runWithTransientRetries( + model: any, + userMessage: string, + config: ContextPruneConfig, + ctx: ExtensionContext, + options: SummarizeBatchOptions, + retryCount: number, + retryOffset = 0 +): Promise { + let outcome = await runOnce(model, userMessage, config, ctx, options); + for (let retry = 1; outcome.kind === "transient" && retry <= retryCount; retry += 1) { + await waitBeforeRetry(config, retryOffset + retry, options.signal); + outcome = await runOnce(model, userMessage, config, ctx, options); + } + return outcome; +} + +/** + * Shared LLM-call machinery for both per-batch and range summarization. + * `userMessage` already embeds the relevant system prompt as leading text + * (the summarizer is a single-user-message call). Returns the formatted text + * + usage, or null on failure. Abort errors are re-thrown so flushPending can + * detect options.signal.aborted and restore state without a UI error. + * + * Transient outcomes consume up to `summarizerMaxRetries` retry slots with + * bounded exponential backoff. When a distinct fallback model exists, the + * final retry slot is reserved for the session model; the controller stays + * sticky in fallback until a per-cooldown probe of the primary succeeds. + */ +async function runSummarization( + userMessage: string, + config: ContextPruneConfig, + ctx: ExtensionContext, + options: SummarizeBatchOptions +): Promise { + // Fast-fail if already aborted before we even start. + if (options.signal?.aborted) throw new Error("summarize: aborted before start"); + + const primary = resolveModel(config, ctx); + const controller = options.controller; + const sessionModel = ctx.model; + + const notifyFailure = (o: { message: string; timedOut?: boolean }, afterRetries = false) => { + const retrySuffix = afterRetries ? ` after ${config.summarizerMaxRetries} retries` : ""; + ctx.ui.notify( + o.timedOut + ? `pi-condense: ${o.message}; summarizer call abandoned${retrySuffix}` + : `pruner: summarization failed${retrySuffix}: ${o.message}`, + o.timedOut ? "warning" : "error", + ); + }; + + // No controller or no distinct fallback: all retry slots stay on the selected model. + if (!controller || !FallbackController.hasDistinctFallback(primary, sessionModel)) { + const r = await runWithTransientRetries( + primary, + userMessage, + config, + ctx, + options, + config.summarizerMaxRetries + ); + switch (r.kind) { + case "ok": + return r.result; + case "auth": + notifyFailure(r); + return null; + case "transient": + notifyFailure(r, config.summarizerMaxRetries > 0); + return null; + case "unusable": + return null; + } + } + + const emit = (t: FallbackTransition) => { + if (t === "enter") { + ctx.ui.notify( + `pi-condense: summarizer model ${modelLabel(primary)} failing, using session model ${modelLabel(sessionModel)} until it recovers`, + "warning" + ); + } else if (t === "recover") { + ctx.ui.notify(`pi-condense: summarizer model ${modelLabel(primary)} recovered`, "info"); + } + }; + + const decision = controller.chooseTarget(); + const model = decision.target === "primary" ? primary : sessionModel; + // A primary-target run reserves the final configured retry for the session + // fallback. A sticky fallback run can spend the full budget on that model. + const reserveFallback = decision.target === "primary" && config.summarizerMaxRetries > 0; + const retryCount = reserveFallback + ? config.summarizerMaxRetries - 1 + : config.summarizerMaxRetries; + const r = await runWithTransientRetries(model, userMessage, config, ctx, options, retryCount); + + switch (r.kind) { + case "ok": + if (decision.target === "primary") emit(controller.onPrimarySuccess(decision.wasProbe)); + else emit(controller.onFallbackSuccess()); + return r.result; + case "auth": + notifyFailure(r); // auth never trips the controller + return null; + case "unusable": + return null; // probe unusable => stay (no state change) + case "transient": { + if (decision.target === "fallback") { + controller.onFallbackOnlyFail(); + notifyFailure(r, retryCount > 0); + return null; + } + if (!reserveFallback) { + notifyFailure(r); + return null; + } + // The last retry slot switches to the session model instead of spending + // another attempt on a primary that already produced transient failures. + await waitBeforeRetry(config, config.summarizerMaxRetries, options.signal); + const r2 = await runOnce(sessionModel, userMessage, config, ctx, options); + if (r2.kind === "ok") { + emit(controller.onPrimaryFailFallbackOk(decision.wasProbe)); + return r2.result; // suppress the final error notify — fallback rescued the call + } + controller.onBothDown(); + notifyFailure(r2.kind === "transient" || r2.kind === "auth" ? r2 : r, true); + return null; + } + } +} + +/** + * Summarizes a captured batch. Returns formatted markdown string, or null on failure. + * Shows user-visible errors via ctx.ui.notify. + */ +export async function summarizeBatch( + batch: CapturedBatch, + config: ContextPruneConfig, + ctx: ExtensionContext, + options: SummarizeBatchOptions = {} +): Promise { + const serialized = serializeBatchForSummarizer(batch); + const userMessage = + SYSTEM_PROMPT + "\n\n\n" + serialized + "\n"; + return runSummarization(userMessage, config, ctx, options); +} + +/** + * Fuses a closed chain's already-computed per-batch summaries into one cohesive + * range summary (recursive summarization). Input is the span's per-batch summary + * text — small and already pruned — so this never re-sends raw tool output. + * Returns the fused text + usage, or null on failure. Used by chain compression + * to replace the concatenated per-batch body with a single coherent summary. + */ +export async function summarizeRange( + perBatchSummaryText: string, + config: ContextPruneConfig, + ctx: ExtensionContext, + options: SummarizeBatchOptions = {} +): Promise { + const userMessage = + RANGE_SYSTEM_PROMPT + "\n\n\n" + perBatchSummaryText + "\n"; + return runSummarization(userMessage, config, ctx, options); +} + +/** + * Summarizes multiple captured batches — one LLM call per batch, run in parallel. + * + * Returns an array of per-batch results. Each element is either a SummarizeResult + * (success) or null (that specific batch's call failed). The array length always + * equals batches.length so callers can zip by index. + * + * Rationale for parallel-per-batch instead of a single merged call: + * • Each batch becomes its own summary message (one per turn), so they can be + * rendered, browsed, and recovered independently via context_tree_query. + * • Parallel calls give similar end-to-end latency to a single merged call while + * keeping the summaries strictly separated. + */ +export async function summarizeBatches( + batches: CapturedBatch[], + config: ContextPruneConfig, + ctx: ExtensionContext, + options: SummarizeBatchesOptions = {} +): Promise> { + if (batches.length === 0) return []; + // Single batch — delegate to the single-batch path (no extra overhead) + if (batches.length === 1) { + return [ + await summarizeBatch(batches[0], config, ctx, { + signal: options.signal, + controller: options.controller, + onTextProgress: (receivedChars) => { + options.onBatchTextProgress?.(0, 1, batches[0], receivedChars); + }, + }), + ]; + } + + // Multiple batches — run in parallel; each produces its own SummarizeResult + return Promise.all( + batches.map((batch, index) => + summarizeBatch(batch, config, ctx, { + signal: options.signal, + controller: options.controller, + onTextProgress: (receivedChars) => { + options.onBatchTextProgress?.(index, batches.length, batch, receivedChars); + }, + }) + ) + ); +} diff --git a/pi-condense/src/summary-refs.test.ts b/pi-condense/src/summary-refs.test.ts new file mode 100644 index 0000000..b5b5ed3 --- /dev/null +++ b/pi-condense/src/summary-refs.test.ts @@ -0,0 +1,172 @@ +import { describe, expect, test } from "bun:test"; +import { + substituteInlineRefs, + formatSummaryToolCallRefs, + buildShortToolCallRefs, + normalizeSummaryToolCallRefs, + type SummaryToolCallRef, +} from "./summary-refs.js"; + +describe("substituteInlineRefs", () => { + const refs: SummaryToolCallRef[] = [ + { shortId: "t1", toolCallId: "a" }, + { shortId: "t2", toolCallId: "b" }, + ]; + const names = ["read", "bash"]; + + test("in-range and matching name rewrites to inline ref", () => { + expect(substituteInlineRefs("- [[1:read]] Read a.ts", refs, names)).toBe("- `t1` Read a.ts"); + expect(substituteInlineRefs("- [[2:bash]] ran ls", refs, names)).toBe("- `t2` ran ls"); + }); + + test("does not add extra t prefix", () => { + const out = substituteInlineRefs("[[1:read]] x", refs, names); + expect(out).toBe("`t1` x"); + expect(out).not.toContain("tt1"); + }); + + test("name match is trimmed and case-insensitive", () => { + expect(substituteInlineRefs("- [[1:Read]] x", refs, names)).toBe("- `t1` x"); + }); + + test("name mismatch strips label", () => { + expect(substituteInlineRefs("- [[2:read]] x", refs, names)).toBe("- x"); + }); + + test("out of range strips label", () => { + expect(substituteInlineRefs("- [[9:read]] x", refs, names)).toBe("- x"); + }); + + test("absent label leaves line unchanged", () => { + expect(substituteInlineRefs("- plain bullet", refs, names)).toBe("- plain bullet"); + }); + + test("duplicate labels on separate lines both substituted", () => { + expect(substituteInlineRefs("- [[2:bash]] first\n- [[2:bash]] second", refs, names)).toBe( + "- `t2` first\n- `t2` second", + ); + }); + + test("single-bracket lookalikes are untouched", () => { + const input = "used argv[1] and items[0] here"; + expect(substituteInlineRefs(input, refs, names)).toBe(input); + }); + + test("mid-line well-formed label token is stripped (leak guard)", () => { + expect(substituteInlineRefs("used argv[1] and [[1:read]] mid", refs, names)).toBe( + "used argv[1] and mid", + ); + }); + + test("label inside fenced code block is untouched", () => { + const input = "```\n[[1:read]] literal\n```"; + expect(substituteInlineRefs(input, refs, names)).toBe(input); + }); + + test("wrapped label ([[1:read]] in **bold**) is stripped, no raw token leaked", () => { + const input = "- **[[1:read]]** x"; + expect(substituteInlineRefs(input, refs, names)).toBe("- **** x"); + }); + + test("numbered-list label leak guard: token stripped, no anchored match", () => { + expect(substituteInlineRefs("1. [[1:read]] did x", refs, names)).toBe("1. did x"); + }); + + test("blockquote label leak guard: token stripped, no anchored match", () => { + expect(substituteInlineRefs("> [[1:read]] did x", refs, names)).toBe("> did x"); + }); + + test("fenced non-first-line label is still exempt from catch-all strip", () => { + const input = "```\nfoo [[1:read]] bar\n```"; + expect(substituteInlineRefs(input, refs, names)).toBe(input); + }); + + test("range-fusion: inline ref survives fusion-input concatenation across batches", () => { + const body = substituteInlineRefs("- [[1:read]] did a thing", refs, names); + expect(body).toBe("- `t1` did a thing"); + expect(body).toContain("`t1`"); + const fusionInput = [body, body].join("\n---\n"); + expect(fusionInput).toContain("`t1`"); + }); + + test("N=0 is out of range and strips label", () => { + expect(substituteInlineRefs("- [[0:read]] x", refs, names)).toBe("- x"); + }); + + test("empty input returns empty string", () => { + expect(substituteInlineRefs("", refs, names)).toBe(""); + }); + + test("collapses multiple trailing spaces to one", () => { + expect(substituteInlineRefs("- [[1:read]] x", refs, names)).toBe("- `t1` x"); + }); + + test("label at end of line has single trailing space, no phantom double", () => { + expect(substituteInlineRefs("- [[1:read]]", refs, names)).toBe("- `t1` "); + }); + + test("dotted/namespaced tool name matches and validates", () => { + const nsRefs: SummaryToolCallRef[] = [{ shortId: "t1", toolCallId: "a" }]; + const nsNames = ["server.tool"]; + expect(substituteInlineRefs("- [[1:server.tool]] did x", nsRefs, nsNames)).toBe("- `t1` did x"); + }); + + test("dotted/namespaced tool name mismatch strips label, does not leak", () => { + const nsRefs: SummaryToolCallRef[] = [{ shortId: "t1", toolCallId: "a" }]; + const nsNames = ["server.tool"]; + expect(substituteInlineRefs("- [[1:other.tool]] x", nsRefs, nsNames)).toBe("- x"); + }); + + test("composition with formatSummaryToolCallRefs footer", () => { + const body = substituteInlineRefs("- [[1:read]] a\n- [[2:bash]] b", refs, names); + const footer = formatSummaryToolCallRefs(refs); + for (const ref of refs) { + expect(body).toContain(`\`${ref.shortId}\``); + expect(footer).toContain(`\`${ref.shortId}\``); + } + }); +}); + +describe("occurrence-aware summary refs", () => { + test("buildShortToolCallRefs carries resultTimestamp through", () => { + const { refs, nextIndex } = buildShortToolCallRefs( + [ + { toolCallId: "bash_23", resultTimestamp: 2150 }, + { toolCallId: "bash_23", resultTimestamp: 3150 }, + ], + 5, + ); + expect(refs).toEqual([ + { shortId: "t5", toolCallId: "bash_23", resultTimestamp: 2150 }, + { shortId: "t6", toolCallId: "bash_23", resultTimestamp: 3150 }, + ]); + expect(nextIndex).toBe(7); + }); + + test("buildShortToolCallRefs omits resultTimestamp when absent", () => { + const { refs } = buildShortToolCallRefs([{ toolCallId: "bash_1" }], 1); + expect(refs).toEqual([{ shortId: "t1", toolCallId: "bash_1" }]); + expect("resultTimestamp" in refs[0]).toBe(false); + }); + + test("normalizeSummaryToolCallRefs preserves a numeric resultTimestamp", () => { + const refs = normalizeSummaryToolCallRefs({ + toolCallRefs: [{ shortId: "t1", toolCallId: "bash_1", resultTimestamp: 1150 }], + }); + expect(refs).toEqual([{ shortId: "t1", toolCallId: "bash_1", resultTimestamp: 1150 }]); + }); + + test("normalizeSummaryToolCallRefs drops a non-numeric resultTimestamp", () => { + const refs = normalizeSummaryToolCallRefs({ + toolCallRefs: [{ shortId: "t1", toolCallId: "bash_1", resultTimestamp: "nope" }], + }); + expect(refs).toEqual([{ shortId: "t1", toolCallId: "bash_1" }]); + expect("resultTimestamp" in refs[0]).toBe(false); + }); + + test("legacy toolCallIds-only details still normalize", () => { + expect(normalizeSummaryToolCallRefs({ toolCallIds: ["bash_1"] })).toEqual([ + { shortId: "bash_1", toolCallId: "bash_1" }, + ]); + }); +}); diff --git a/pi-condense/src/summary-refs.ts b/pi-condense/src/summary-refs.ts new file mode 100644 index 0000000..84b8c54 --- /dev/null +++ b/pi-condense/src/summary-refs.ts @@ -0,0 +1,108 @@ +import type { CapturedBatch } from "./types.js"; +import { resultTimestampOf } from "./occurrence-key.js"; + +export interface SummaryToolCallRef { + shortId: string; + toolCallId: string; + /** ToolResultMessage timestamp; combines with toolCallId into the occurrence key. */ + resultTimestamp?: number; +} + +export interface SummaryMessageDetailsLike { + toolCallRefs?: SummaryToolCallRef[]; + toolCallIds?: string[]; +} + +const SHORT_ID_PREFIX = "t"; + +export function buildShortToolCallRefs( + calls: { toolCallId: string; resultTimestamp?: number }[], + startIndex: number, +): { refs: SummaryToolCallRef[]; nextIndex: number } { + const refs = calls.map((call, offset) => ({ + shortId: `${SHORT_ID_PREFIX}${startIndex + offset}`, + toolCallId: call.toolCallId, + ...(call.resultTimestamp !== undefined ? { resultTimestamp: call.resultTimestamp } : {}), + })); + return { refs, nextIndex: startIndex + refs.length }; +} + +export function normalizeSummaryToolCallRefs(details: unknown): SummaryToolCallRef[] { + if (!details || typeof details !== "object") return []; + + const raw = details as SummaryMessageDetailsLike; + if (Array.isArray(raw.toolCallRefs)) { + return raw.toolCallRefs + .filter( + (ref): ref is SummaryToolCallRef => + !!ref && typeof ref.shortId === "string" && typeof ref.toolCallId === "string", + ) + .map((ref) => { + const resultTimestamp = resultTimestampOf((ref as any).resultTimestamp); + return { + shortId: ref.shortId, + toolCallId: ref.toolCallId, + ...(resultTimestamp !== undefined ? { resultTimestamp } : {}), + }; + }); + } + + if (Array.isArray(raw.toolCallIds)) { + return raw.toolCallIds.filter((id): id is string => typeof id === "string").map((id) => ({ shortId: id, toolCallId: id })); + } + + return []; +} + +export function formatSummaryToolCallRefs(refs: SummaryToolCallRef[]): string { + const refList = refs.map((ref) => `\`${ref.shortId}\``).join(", "); + return ( + `\n\n---\n**Summarized tool refs**: ${refList}\n` + + `Use \`context_tree_query\` with these refs to retrieve the original full outputs.` + ); +} + +export function makeSummaryDetails(batch: CapturedBatch, refs: SummaryToolCallRef[]) { + return { + toolCallRefs: refs, + toolNames: batch.toolCalls.map((tc) => tc.toolName), + turnIndex: batch.turnIndex, + timestamp: batch.timestamp, + }; +} + +/** + * Rewrites line-leading `[[N:name]]` labels emitted by the summarizer into + * inline `` `tN` `` refs. `refs` and `toolNames` are positionally aligned to + * the batch's tool-call order. The echoed name is validated against the tool + * at position N; a mismatch or out-of-range N strips the label (footer-only). + * A catch-all strip pass on non-fenced lines removes any surviving well-formed + * label token (wrapped, numbered, or blockquoted) so no raw `[[N:name]]` token + * ever leaks into context; fenced code blocks remain exempt. + */ +export function substituteInlineRefs( + text: string, + refs: SummaryToolCallRef[], + toolNames: string[], +): string { + const LABEL = /^(\s*(?:[-*]\s+)?)\[\[(\d+):([^\]\n]+)\]\]\s*/; + const lines = text.split("\n"); + let inFence = false; + for (let i = 0; i < lines.length; i++) { + if (lines[i].trimStart().startsWith("```")) { + inFence = !inFence; + continue; + } + if (inFence) continue; + lines[i] = lines[i].replace(LABEL, (_m, prefix: string, numStr: string, name: string) => { + const n = Number(numStr); + const ref = refs[n - 1]; + const expected = toolNames[n - 1]; + if (!ref || expected === undefined) return prefix; + if (name.trim().toLowerCase() !== expected.trim().toLowerCase()) return prefix; + return `${prefix}\`${ref.shortId}\` `; + }); + lines[i] = lines[i].replace(/\[\[\d+:[^\]\n]+\]\]\s*/g, ""); + } + return lines.join("\n"); +} diff --git a/pi-condense/src/test-support.ts b/pi-condense/src/test-support.ts new file mode 100644 index 0000000..86f2762 --- /dev/null +++ b/pi-condense/src/test-support.ts @@ -0,0 +1,56 @@ +import { expect } from "bun:test"; +import { sweepOrphanToolResults } from "./orphan-sweep.js"; +import { pruneMessages } from "./pruner.js"; +import { DiagnosticSink } from "./diagnostics.js"; + +/** Shared by chain-range-prune.test.ts and id-collision.integration.test.ts. */ +export function expectNoOrphanToolResults(messages: any[]): void { + let open = new Set(); + for (const m of messages) { + if (m.role === "assistant") { + open = new Set((m.content ?? []).filter((c: any) => c.type === "toolCall").map((c: any) => c.id)); + } else if (m.role === "toolResult") { + expect(open.has(m.toolCallId)).toBe(true); + open.delete(m.toolCallId); + } else { + open = new Set(); + } + } +} + +/** + * G4/C3: proof that the orphan sweep (src/pruner.ts Phase 4) is a net, not a + * crutch. Runs the real `sweepOrphanToolResults` (the exact function backing + * Phase 4's diagnostic) over an already-pruned message array and fails if it + * finds anything to sweep. Equivalent to asserting the Phase 4 diagnostic + * never fires for this output: `sweepOrphanToolResults` returns the input + * array reference and an empty `sweptIds` when nothing is orphaned, which is + * precisely the condition under which `pruneMessages` skips the + * `diagnostics?.report("orphan-sweep", ...)` call. + */ +export function expectZeroOrphanSweep(messages: any[]): void { + const { messages: swept, sweptIds } = sweepOrphanToolResults(messages); + expect(sweptIds).toEqual([]); + expect(swept).toBe(messages); +} + +/** + * G4/C3: wraps a `pruneMessages` call with a counting `DiagnosticSink` and + * fails if the `orphan-sweep` diagnostic fires. For fixtures that already go + * through the full pruner pipeline (pruner.test.ts), this is the more direct + * proof than `expectZeroOrphanSweep` since it exercises the actual Phase 4 + * call site, not just the underlying pure function. + */ +export function pruneWithZeroSweepAssertion( + messages: any[], + indexer: any, + chainCompression?: any, + errorPurge?: any, + protection?: any, + recoveryGraceTurns: number = 0, +): ReturnType { + const sink = new DiagnosticSink(() => {}); + const result = pruneMessages(messages, indexer, chainCompression, errorPurge, protection, recoveryGraceTurns, sink); + expect(sink.counts()["orphan-sweep"]).toBe(0); + return result; +} diff --git a/pi-condense/src/tree-browser.ts b/pi-condense/src/tree-browser.ts new file mode 100644 index 0000000..eac2bbb --- /dev/null +++ b/pi-condense/src/tree-browser.ts @@ -0,0 +1,383 @@ +import type { Component } from "@earendil-works/pi-tui"; +import { Markdown, getKeybindings, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"; +import { getMarkdownTheme } from "@earendil-works/pi-coding-agent"; +import type { Theme } from "@earendil-works/pi-coding-agent"; +import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent"; +import type { ToolCallRecord } from "./types.js"; +import { CUSTOM_TYPE_SUMMARY } from "./types.js"; +import { normalizeSummaryToolCallRefs } from "./summary-refs.js"; +import type { ToolCallIndexer } from "./indexer.js"; +import { occKey } from "./occurrence-key.js"; + +// ── Tree node types ───────────────────────────────────────────────────────── + +export interface TreeNode { + id: string; + label: string; + children: TreeNode[]; + expanded: boolean; + depth: number; + isLeaf: boolean; + /** Optional extra detail shown when expanded (e.g. result preview) */ + detail?: string; + /** Character count of this node's content (result text for tools, summary text for summaries) */ + charCount?: number; +} + +interface VisibleRow { + node: TreeNode; + index: number; +} + +interface SummaryOverlayState { + title: string; + text: string; + scrollOffset: number; +} + +// ── Formatting helpers ────────────────────────────────────────────────────── + +function formatChars(n: number): string { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; + if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`; + return `${n}`; +} + +function padToWidth(str: string, width: number): string { + const vis = visibleWidth(str); + if (vis >= width) return str; + return str + " ".repeat(width - vis); +} + +function isCtrlO(data: string): boolean { + return matchesKey(data, "ctrl+o") || data === "\u000f"; +} + +// ── Box drawing ───────────────────────────────────────────────────────────── + +function boxLines( + lines: string[], + width: number, + title: string, + theme: Theme, +): string[] { + const innerWidth = Math.max(0, width - 2); + const result: string[] = []; + + // Top border with title + const titlePrefix = title ? `─ ${title} ` : ""; + const titleVis = visibleWidth(titlePrefix); + const topFill = "─".repeat(Math.max(0, innerWidth - titleVis)); + result.push("┌" + titlePrefix + topFill + "┐"); + + // Content lines + for (const line of lines) { + result.push("│" + padToWidth(line, innerWidth) + "│"); + } + + // Bottom border + result.push("└" + "─".repeat(innerWidth) + "┘"); + + return result; +} + +// ── Tree data builder ─────────────────────────────────────────────────────── + +/** + * Scans the current session branch for prune-summary entries and builds a + * foldable tree where each summary is a parent node and its pruned tool calls + * are children. Tool call records are looked up via the indexer. + * + * Each node carries a `charCount` so the UI can show how many characters the + * summary replaced (making it obvious whether pruning is saving space). + */ +export function buildPruneTree( + ctx: ExtensionCommandContext, + indexer: ToolCallIndexer, +): TreeNode[] { + const branch = ctx.sessionManager.getBranch(); + const roots: TreeNode[] = []; + let summaryIndex = 0; + + for (const entry of branch) { + if (entry.type !== "custom_message") continue; + const customEntry = entry as any; + if (customEntry.customType !== CUSTOM_TYPE_SUMMARY) continue; + + const details = customEntry.details as { + toolCallRefs?: { shortId: string; toolCallId: string }[]; + toolCallIds?: string[]; + toolNames: string[]; + turnIndex: number; + timestamp: number; + } | undefined; + + const toolCallRefs = normalizeSummaryToolCallRefs(details); + const turnIndex = details?.turnIndex ?? "?"; + const timestamp = details?.timestamp + ? new Date(details.timestamp).toLocaleString() + : ""; + + const children: TreeNode[] = []; + for (const ref of toolCallRefs) { + const record = indexer.getRecord(occKey(ref.toolCallId, ref.resultTimestamp)); + if (!record) continue; + children.push(toolCallNode(record, 1)); + } + + const summaryText = + typeof customEntry.content === "string" ? customEntry.content : ""; + const summaryChars = summaryText.length; + const totalOriginalChars = children.reduce( + (sum, c) => sum + (c.charCount ?? 0), + 0, + ); + + const header = `[pruner] Turn ${turnIndex} summary (${children.length} tool${children.length === 1 ? "" : "s"} · ${formatChars(summaryChars)} chars · original ${formatChars(totalOriginalChars)})`; + const label = timestamp ? `${header} · ${timestamp}` : header; + + roots.push({ + id: `summary-${summaryIndex++}`, + label, + children, + expanded: false, + depth: 0, + isLeaf: children.length === 0, + detail: summaryText || undefined, + charCount: summaryChars, + }); + } + + return roots; +} + +function toolCallNode(record: ToolCallRecord, depth: number): TreeNode { + const argsText = Object.entries(record.args) + .map(([k, v]) => `${k}=${JSON.stringify(v)}`) + .join(", "); + const charCount = record.resultText.length || record.spillBytes || 0; + const unit = record.resultText.length ? "chars" : (record.spillBytes ? "bytes" : "chars"); + const label = `${record.toolName}(${argsText}) · ${formatChars(charCount)} ${unit}${record.isError ? " [error]" : ""}`; + const previewSource = record.resultText || record.resultPreview || ""; + const resultPreview = previewSource.slice(0, 200).replace(/\s+/g, " "); + return { + id: record.toolCallId, + label, + children: [], + expanded: false, + depth, + isLeaf: true, + detail: resultPreview, + charCount, + }; +} + +// ── TreeBrowser component ─────────────────────────────────────────────────── + +export class TreeBrowser implements Component { + private flatRows: VisibleRow[] = []; + private selectedIndex = 0; + private theme: Theme; + private onDone: () => void; + private summaryOverlay: SummaryOverlayState | null = null; + + constructor( + private readonly roots: TreeNode[], + theme: Theme, + onDone: () => void, + ) { + this.theme = theme; + this.onDone = onDone; + this.rebuildFlatRows(); + } + + invalidate(): void { + this.rebuildFlatRows(); + } + + private rebuildFlatRows(): void { + this.flatRows = []; + let index = 0; + const walk = (nodes: TreeNode[]) => { + for (const node of nodes) { + this.flatRows.push({ node, index: index++ }); + if (node.expanded && node.children.length > 0) { + walk(node.children); + } + } + }; + walk(this.roots); + if (this.selectedIndex >= this.flatRows.length) { + this.selectedIndex = Math.max(0, this.flatRows.length - 1); + } + } + + handleInput(data: string): void { + const kb = getKeybindings(); + + if (this.summaryOverlay) { + if (kb.matches(data, "tui.select.up")) { + this.summaryOverlay.scrollOffset = Math.max(0, this.summaryOverlay.scrollOffset - 1); + } else if (kb.matches(data, "tui.select.down")) { + this.summaryOverlay.scrollOffset += 1; + } else if (kb.matches(data, "tui.select.cancel") || data === "q" || isCtrlO(data)) { + this.summaryOverlay = null; + } + return; + } + + if (kb.matches(data, "tui.select.up")) { + this.selectedIndex = + this.selectedIndex === 0 + ? this.flatRows.length - 1 + : this.selectedIndex - 1; + } else if (kb.matches(data, "tui.select.down")) { + this.selectedIndex = + this.selectedIndex === this.flatRows.length - 1 + ? 0 + : this.selectedIndex + 1; + } else if (kb.matches(data, "tui.select.confirm") || data === " ") { + const row = this.flatRows[this.selectedIndex]; + if (row && !row.node.isLeaf) { + row.node.expanded = !row.node.expanded; + this.rebuildFlatRows(); + } + } else if (isCtrlO(data)) { + this.openSelectedSummary(); + } else if (kb.matches(data, "tui.select.cancel") || data === "q") { + this.onDone(); + } + } + + render(width: number): string[] { + const innerWidth = Math.max(0, width - 2); + + let baseLines: string[]; + if (this.flatRows.length === 0) { + const msg = this.theme.fg("muted", "(no pruned tool calls in this session)"); + baseLines = boxLines([msg], width, "Pruned Tool Calls", this.theme); + } else { + const contentLines: string[] = [ + this.theme.fg("dim", "Enter/Space expand • Ctrl-O open summary • Esc/q close"), + "", + ]; + for (let i = 0; i < this.flatRows.length; i++) { + const row = this.flatRows[i]; + const line = this.renderRow(row.node, innerWidth, i === this.selectedIndex); + contentLines.push(line); + } + + baseLines = boxLines(contentLines, width, "Pruned Tool Calls", this.theme); + } + + if (!this.summaryOverlay) { + return baseLines; + } + + return this.renderWithSummaryOverlay(baseLines, width); + } + + private openSelectedSummary(): void { + const row = this.flatRows[this.selectedIndex]; + if (!row || row.node.isLeaf || !row.node.detail) { + return; + } + + this.summaryOverlay = { + title: row.node.label, + text: row.node.detail, + scrollOffset: 0, + }; + } + + private renderWithSummaryOverlay(baseLines: string[], width: number): string[] { + const overlay = this.summaryOverlay; + if (!overlay) return baseLines; + + const overlayWidth = Math.max(60, width - 2); + const overlayInnerWidth = Math.max(1, overlayWidth - 2); + const markdown = new Markdown(overlay.text, 1, 0, getMarkdownTheme()); + const markdownLines = markdown.render(Math.max(1, overlayInnerWidth)); + const reservedLines = 5; + const maxContentHeight = Math.max(12, Math.min(markdownLines.length, baseLines.length + 8)); + const maxScroll = Math.max(0, markdownLines.length - maxContentHeight); + overlay.scrollOffset = Math.min(overlay.scrollOffset, maxScroll); + + const visibleContent = markdownLines.slice( + overlay.scrollOffset, + overlay.scrollOffset + maxContentHeight, + ); + + const footer = this.theme.fg( + "dim", + `↑/↓ scroll • Ctrl-O/Esc/q close${maxScroll > 0 ? ` • ${overlay.scrollOffset + 1}-${Math.min(overlay.scrollOffset + maxContentHeight, markdownLines.length)} / ${markdownLines.length}` : ""}`, + ); + const overlayLines = boxLines( + [ + this.theme.fg("accent", truncateToWidth(overlay.title, overlayInnerWidth, "…", false)), + this.theme.fg("dim", "Pruned summary message"), + "", + ...visibleContent, + "", + footer, + ], + overlayWidth, + "Pruned Summary", + this.theme, + ); + + const canvasHeight = Math.max(baseLines.length, overlayLines.length + 2); + const blankLine = " ".repeat(width); + const composed = Array.from({ length: canvasHeight }, (_, index) => baseLines[index] ?? blankLine); + const startRow = Math.max(0, Math.floor((canvasHeight - overlayLines.length) / 2)); + const leftPad = Math.max(0, Math.floor((width - overlayWidth) / 2)); + const rightPad = Math.max(0, width - leftPad - overlayWidth); + + for (let i = 0; i < overlayLines.length && startRow + i < composed.length; i++) { + composed[startRow + i] = " ".repeat(leftPad) + overlayLines[i] + " ".repeat(rightPad); + } + + return composed; + } + + private renderRow( + node: TreeNode, + width: number, + isSelected: boolean, + ): string { + const indent = " ".repeat(node.depth); + const prefix = node.isLeaf + ? " " + : node.expanded + ? "▾ " + : "▸ "; + + let text: string; + if (node.isLeaf) { + text = this.theme.fg("text", node.label); + } else { + text = this.theme.fg("accent", node.label); + } + + const fullLine = indent + prefix + text; + const plainText = indent + prefix + node.label; + const visibleLen = visibleWidth(plainText); + + let rendered: string; + if (visibleLen > width) { + rendered = truncateToWidth(fullLine, width, "…", false); + } else { + rendered = fullLine; + } + + if (isSelected) { + const padLen = width - visibleWidth(rendered); + if (padLen > 0) { + rendered += " ".repeat(padLen); + } + rendered = this.theme.bg("selectedBg", rendered); + } + + return rendered; + } +} diff --git a/pi-condense/src/types.ts b/pi-condense/src/types.ts new file mode 100644 index 0000000..71a866a --- /dev/null +++ b/pi-condense/src/types.ts @@ -0,0 +1,953 @@ +/** + * Shared types for the context-prune extension. + * + * Design decisions (Phase 1): + * + * SUMMARIZATION BATCH (Ph1 step 2): + * One batch = one completed assistant turn with tool calls, captured from + * the `turn_end` event when event.toolResults.length > 0. + * event.message = AssistantMessage (contains ToolCall content blocks with ids) + * event.toolResults = ToolResultMessage[] (one per tool call in this turn) + * + * STATE MODEL (Ph1 step 3): + * - Runtime state: Map rebuilt on session_start + * - Session metadata: pi.appendEntry("context-prune-index", IndexEntryData) + * stored once per summarized batch; NOT in LLM context + * - User config: .pi/settings.json → "contextPrune" key (JSON merge safe, + * Pi preserves unknown keys when rewriting settings files) + * + * CONFIG FORMAT (Ph1 step 4): + * { "contextPrune": { "enabled": true, "summarizerModel": "default", "showPruneStatusLine": true } } + * summarizerModel: "default" = use current active model (ctx.model) + * "provider/model-id" = explicit model via ctx.modelRegistry.find() + * + * SUMMARY MESSAGE FORMAT (Ph1 step 5): + * customType: "context-prune-summary" + * content: markdown with one bullet per tool call + short-id footer + * details: SummaryMessageDetails (toolCallRefs, toolNames, turnIndex, timestamp) + * The content itself includes short alias IDs in plain text so the model can + * reference them in future context_tree_query calls without needing details. + * + * API CONSTRAINTS (Ph1 step 6): + * - Pruning MUST happen in the `context` event via { messages: filtered }, + * never by mutating session history (pi.appendEntry / session file untouched) + * - Summary injection uses pi.sendMessage(..., { deliverAs: "steer" }) from + * inside the turn_end handler so it lands before the next LLM call + * - Original full tool outputs are preserved in IndexEntryData (session custom + * entries) and accessible via context_tree_query at any time + * - v1 prunes only ToolResultMessage entries; the AssistantMessage tool-call + * blocks (which carry the toolCallIds) are intentionally kept so the model + * can still reference them when calling context_tree_query + * - "default" summarizer = ctx.model (current active model + its credentials), + * NOT a hidden side-channel. It makes an explicit LLM call from turn_end. + */ + +import type { FallbackController } from "./summarizer-fallback.js"; + +// ── Constants ────────────────────────────────────────────────────────────── + +/** customType for summary custom_message entries (appear in LLM context) */ +export const CUSTOM_TYPE_SUMMARY = "context-prune-summary"; + +/** customType for index persistence entries (NOT in LLM context) */ +export const CUSTOM_TYPE_INDEX = "context-prune-index"; + +/** customType for stats persistence entries (NOT in LLM context) */ +export const CUSTOM_TYPE_STATS = "context-prune-stats"; + +/** customType for prune-frontier persistence entries (NOT in LLM context) */ +export const CUSTOM_TYPE_FRONTIER = "context-prune-frontier"; + +/** + * customType for content-hash dedup alias entries (NOT in LLM context). + * + * One entry per duplicate tool call detected by the pre-flush dedup pass. + * The new toolCallId is registered as an alias of an already-indexed + * original toolCallId. The original's record (in CUSTOM_TYPE_INDEX) is + * the source of truth for the result text. See + * src/content-hash.ts and src/indexer.ts for the dedup machinery. + */ +export const CUSTOM_TYPE_DEDUP_ALIAS = "context-prune-dedup-alias"; + +/** + * customType for chain-compression entries (NOT in LLM context). + * + * One entry per closed chain that has been range-dropped from LLM context. + * Rebuilt on `session_start` to repopulate the chain registry. + * Written by `chain-compressor.compressEligible` at the tail of `flushPending` and via `/pruner compact`. + */ +export const CUSTOM_TYPE_CHAIN = "context-prune-chain"; + +/** + * Written via pi.appendEntry(CUSTOM_TYPE_DIAGNOSTIC, data) when a prune-time + * invariant degrades. NOT in LLM context: zero tokens, zero cache-prefix + * change. Deduplication is a runtime concern of the diagnostic sink + * (src/diagnostics.ts), which takes a caller-supplied dedup key and never + * persists it - the persisted entry carries only `kind` plus a freeform `detail`. + */ +export const CUSTOM_TYPE_DIAGNOSTIC = "context-prune-diagnostic"; + +/** + * Per-flush-attempt observability record. Written once per non-concurrent + * flushPending invocation, regardless of outcome (including "empty" and + * "error"). Append-only log: never in LLM context, never reconstructed. + */ +export const CUSTOM_TYPE_FLUSH_METRICS = "context-prune-flush-metrics"; + +export type DiagnosticKind = "unresolved-range" | "range-id-mismatch" | "orphan-sweep" | "backfill-empty"; + +export interface DiagnosticEntryData { + kind: DiagnosticKind; + detail: string; +} + +/** The registered name of the recovery tool (src/query-tool.ts). Shared so the + * grace checks in pruner.ts / chain-compressor.ts cannot drift from registration. */ +export const QUERY_TOOL_NAME = "context_tree_query"; + +/** pi.events channel for cross-extension cost contributions (an aggregator like pi-subagents folds these into one total). */ +export const EXTERNAL_COST_CHANNEL = "cost:external"; + +/** Stable producer id for this extension's cost contributions. */ +export const EXTERNAL_COST_SOURCE = "pi-condense"; + +/** Footer status widget ID */ +export const STATUS_WIDGET_ID = "context-prune"; + +/** + * Widget ID for the live /pruner now progress panel shown above the editor. + */ +export const PROGRESS_WIDGET_ID = "context-prune-progress"; +// ── Config ───────────────────────────────────────────────────────────────── + +/** + * When summarization (and context pruning) is triggered. + * - "agent-message" : batches up turns and flushes when the agent sends a final text response + * (a turn with no tool calls), or when the agent loop ends (default) + * - "on-demand" : only when the user runs /pruner now + */ +export type PruneOn = "on-demand" | "agent-message"; + +/** + * Granularity of pruning batches. + * - "turn" : one summary per assistant turn (default; current behavior) + * - "agent-message" : one summary per full user → final-agent-message span + * (merges all turns between two consecutive user messages) + */ +export type BatchingMode = "turn" | "agent-message"; + +/** Thinking/reasoning level requested for summarizer LLM calls. */ +export type SummarizerThinking = "default" | "off" | "minimal" | "low" | "medium" | "high" | "xhigh"; + +/** Choices for the summarizer thinking setting (used by commands and settings overlay) */ +export const SUMMARIZER_THINKING_LEVELS: { value: SummarizerThinking; label: string }[] = [ + { value: "default", label: "Default" }, + { value: "off", label: "Off" }, + { value: "minimal", label: "Minimal" }, + { value: "low", label: "Low" }, + { value: "medium", label: "Medium" }, + { value: "high", label: "High" }, + { value: "xhigh", label: "XHigh" }, +]; + +/** Cycling presets for the `purgeErrors.cooldownTurns` setting. */ +export const PURGE_COOLDOWN_PRESETS: { value: string; label: string }[] = [ + { value: "1", label: "1" }, + { value: "2", label: "2 (default)" }, + { value: "3", label: "3" }, + { value: "5", label: "5" }, + { value: "10", label: "10" }, +]; + +/** Cycling presets for the `purgeErrors.minArgChars` setting. */ +export const PURGE_MIN_ARG_PRESETS: { value: string; label: string }[] = [ + { value: "100", label: "100" }, + { value: "500", label: "500 (default)" }, + { value: "1000", label: "1000" }, + { value: "5000", label: "5000" }, +]; + +/** Choices for the batching-mode setting (used by commands and settings overlay) */ +export const BATCHING_MODES: { value: BatchingMode; label: string }[] = [ + { value: "turn", label: "Per turn" }, + { value: "agent-message", label: "Per agent message" }, +]; + +/** + * Cycling preset values for the `chainCompression.rollingWindow` setting. + * Stored as strings because SettingsList cycles string values; converted to + * number when applied. + */ +export const ROLLING_WINDOW_PRESETS: { value: string; label: string }[] = [ + { value: "1", label: "1" }, + { value: "2", label: "2" }, + { value: "3", label: "3 (default)" }, + { value: "5", label: "5" }, + { value: "10", label: "10" }, +]; + +/** + * Cycling preset values for the `minBatchChars` setting in the SettingsList. + * Stored as strings because SettingsList cycles string values; converted to + * number when applied. `"0"` is the disabled sentinel. + */ +export const MIN_BATCH_CHARS_PRESETS: { value: string; label: string }[] = [ + { value: "0", label: "0 (disabled)" }, + { value: "500", label: "500" }, + { value: "1000", label: "1000 (default)" }, + { value: "2000", label: "2000" }, + { value: "5000", label: "5000" }, +]; + +/** + * Cycling presets for the `recoveryGraceTurns` setting in the SettingsList. + * Stored as strings; converted to number when applied. "0" disables the grace + * (recovery output stubs immediately, pre-feature behavior). + */ +export const RECOVERY_GRACE_PRESETS: { value: string; label: string }[] = [ + { value: "0", label: "0 (disabled)" }, + { value: "1", label: "1" }, + { value: "3", label: "3 (default)" }, + { value: "5", label: "5" }, + { value: "8", label: "8" }, +]; + +/** + * Cycling presets for `summarizerIdleTimeoutMs` (stored as strings; the + * settings UI cycles string values). "0" is the disabling sentinel. + */ +export const SUMMARIZER_IDLE_TIMEOUT_PRESETS: { value: string; label: string }[] = [ + { value: "0", label: "0 (disabled)" }, + { value: "10000", label: "10s" }, + { value: "20000", label: "20s" }, + { value: "45000", label: "45s" }, + { value: "90000", label: "90s (default)" }, +]; + +/** + * Cycling presets for `summarizerMaxTimeoutMs` (stored as strings). "0" is + * the disabling sentinel - no total-duration ceiling. + */ +export const SUMMARIZER_MAX_TIMEOUT_PRESETS: { value: string; label: string }[] = [ + { value: "0", label: "0 (disabled)" }, + { value: "120000", label: "120s" }, + { value: "180000", label: "180s" }, + { value: "300000", label: "300s (default)" }, + { value: "600000", label: "600s" }, +]; + +/** Same-model transient retries before the final error (or reserved fallback attempt). */ +export const SUMMARIZER_MAX_RETRIES_PRESETS: { value: string; label: string }[] = [ + { value: "0", label: "0 (disabled)" }, + { value: "1", label: "1" }, + { value: "2", label: "2 (default)" }, + { value: "3", label: "3" }, + { value: "5", label: "5" }, +]; + +/** Initial delay before a transient retry. Later waits use bounded exponential backoff. */ +export const SUMMARIZER_RETRY_BASE_DELAY_PRESETS: { value: string; label: string }[] = [ + { value: "0", label: "0 (immediate)" }, + { value: "500", label: "0.5s" }, + { value: "1000", label: "1s (default)" }, + { value: "2000", label: "2s" }, + { value: "5000", label: "5s" }, +]; + +/** Maximum delay for one transient retry wait. */ +export const SUMMARIZER_RETRY_MAX_DELAY_PRESETS: { value: string; label: string }[] = [ + { value: "0", label: "0 (immediate)" }, + { value: "2000", label: "2s" }, + { value: "4000", label: "4s" }, + { value: "8000", label: "8s (default)" }, + { value: "16000", label: "16s" }, +]; + +/** + * Cycling presets for the `autoBudgetThreshold` setting (stored as strings; + * the settings UI cycles string values). "0" is the disabled sentinel → null. + * Other values are 0–1 fractions of the context window (e.g. "0.8" = flush at + * 80% of the window, or at MAX_BUDGET_WINDOW tokens, whichever comes first). + */ +export const AUTO_BUDGET_PRESETS: { value: string; label: string }[] = [ + { value: "0", label: "Off" }, + { value: "0.6", label: "60%" }, + { value: "0.7", label: "70%" }, + { value: "0.735", label: "73.5% (default)" }, + { value: "0.8", label: "80%" }, + { value: "0.9", label: "90%" }, +]; + +/** Choices for the prune-on setting (used by commands and settings overlay) */ +export const PRUNE_ON_MODES: { value: PruneOn; label: string }[] = [ + { value: "agent-message", label: "On agent message" }, + { value: "on-demand", label: "On demand" }, +]; + +/** Extension config stored under the `contextPrune` key in `/settings.json` (agent-dir honors `PI_CODING_AGENT_DIR`). */ +export interface ContextPruneConfig { + /** Whether to prune raw tool outputs from future LLM context */ + enabled: boolean; + /** Whether to show the prune footer status line and queued turn messages */ + showPruneStatusLine: boolean; + /** + * Which model to use for summarization. + * "default" = current active Pi model (ctx.model) + * "provider/model-id" = explicit model (e.g. "anthropic/claude-haiku-3-5") + */ + summarizerModel: string; + /** Thinking/reasoning level to request for summarizer calls. */ + summarizerThinking: SummarizerThinking; + /** When to trigger summarization and pruning */ + pruneOn: PruneOn; + /** + * Granularity of each pruning batch. + * - "turn" : one summary per assistant turn (default) + * - "agent-message" : one summary per user → final-agent-message span + * (all turns between two user messages are merged) + */ + batchingMode: BatchingMode; + /** + * Suppress the UI notification emitted when a batch is skipped — for either + * reason: (a) the summary would have been larger than the raw tool-result + * text (oversized), or (b) the batch was below `minBatchChars` and never + * sent to the summarizer (trivial). The frontier still advances in both + * cases; only the notification is silenced. Useful for sessions dominated + * by small tool calls where one or both fire on nearly every turn. + */ + quietOversizedSkips: boolean; + /** + * Pre-flush guard. If the total raw `resultText` character count across all + * tool calls in a batch is below this threshold, the batch is skipped: no + * summarizer LLM call is made, no index entry is written, no summary + * message is injected, and the prune frontier advances past the batch so + * the same tool calls are not reconsidered on the next flush. + * + * Rationale: a short summary like "Tool X did Y" can already be 50–150 + * chars per call. For very small batches (e.g. a 200-byte file read) the + * summary is near-identical in size or even larger than the raw input, so + * calling the LLM is wasted cost. The existing post-call `skipped-oversized` + * mechanism catches this AFTER the LLM round-trip; `minBatchChars` catches + * the obvious cases BEFORE it, at zero LLM cost. + * + * Set to `0` to disable the pre-flush guard entirely (every batch is sent + * to the summarizer; oversized skipping still applies after the fact). + * + * Default: 1000. + */ + minBatchChars: number; + /** + * User-turn-groups a `context_tree_query` (recovery) output stays verbatim in + * context after recovery, before it reverts to the normal stub. Bounds the + * retrieve->re-stub->re-query loop without permanent retention. 0 disables + * (recovery output stubs immediately). Enforced at render time in pruner.ts + * (Phase 1) and chain-compressor.ts (eligibility), not at capture. + */ + recoveryGraceTurns: number; + /** + * Idle (inactivity) timeout for a single summarizer stream call, in ms. + * Reset on every received stream event; armed before the first event so it + * also bounds time-to-first-token. If no event arrives within this window + * the call is aborted and classified transient (feeds the retry/fallback + * path). 0 disables the idle timer. Default 90000. + */ + summarizerIdleTimeoutMs: number; + /** + * Total-duration ceiling for a single summarizer stream call, in ms. Armed + * once at call start, never reset - a hard upper bound catching a stream + * that keeps dribbling events but never completes. Same transient/warning + * handling as the idle timeout. 0 disables the ceiling. Default 300000. + */ + summarizerMaxTimeoutMs: number; + /** Number of transient retries after the initial attempt. Default 2 (three total attempts). */ + summarizerMaxRetries: number; + /** Delay before the first transient retry in ms. Later retries double this delay. Default 1000. */ + summarizerRetryBaseDelayMs: number; + /** Per-wait cap for exponential retry backoff in ms. 0 disables retry waiting. Default 8000. */ + summarizerRetryMaxDelayMs: number; + /** + * Tool names whose outputs must NEVER be pruned or summarized. Tool calls + * with matching `toolName` are filtered out of the pruning capture path so + * their original `ToolResultMessage` stays verbatim in future LLM context. + * + * Use for tools whose raw output the agent must keep reading verbatim + * across turns — for example `todowrite` / `todoread` carrying plan state, + * or any tool returning a structured handle the agent expects to find + * unchanged later. + * + * Default is `[]` (empty) so behavior is preserved for existing configs and + * we do not assume which skill-provided tools (e.g. todo*) the user has + * loaded. Users opt in via `/pruner protected-tools` or the settings file. + * + * Matched names are compared by exact tool name; missing / typoed names + * are silently ignored (they simply never match any captured tool call). + */ + protectedTools: string[]; + /** + * Glob patterns matched against a tool call's `args.path`. Matching calls are + * protected with identical semantics to protectedTools. Default protects + * skill files and their sibling reference docs under any `skills/` dir. + * Kill switch: set to [] in settings.json (`contextPrune.protectedPaths`). + */ + protectedPaths: string[]; + /** Chain-level range compression for old closed chains beyond the rolling window. */ + chainCompression: ChainCompressionConfig; + /** Replace failed toolCall argument bodies with compact stubs after a cooldown window. */ + purgeErrors: ErrorPurgeConfig; + /** + * Pre-flush content-hash dedup pass. When `true`, each captured tool call + * is hashed by `(toolName, normalize(resultText))` and compared against + * records already in the indexer. Matches are registered as aliases of the + * original via `CUSTOM_TYPE_DEDUP_ALIAS` and removed from the batch BEFORE + * any summarizer LLM call. The duplicate's `ToolResultMessage` is then + * stub-replaced by `pruneMessages` using the original's short ref, and + * `context_tree_query` resolves the duplicate's id back to the original + * record via the alias map. + * + * Normalization is conservative: line-ending normalization (`\r\n` → `\n`), + * per-line trailing whitespace stripping, plus a final `trim()`. Internal + * whitespace, tabs, and capitalization are preserved so hashes only match + * for exact-content duplicates. + * + * V1 deliberately dedupes only against records ALREADY in the indexer + * (i.e. from earlier flushes). Intra-flush dedup is not yet implemented to + * avoid the case where a "canonical" batch is skipped as oversized or + * trivial, leaving dangling aliases. + * + * Default: `true` — low-risk free win. Set to `false` if you want to keep + * redundant raw outputs verbatim (e.g. debugging two reads of the same + * file). + */ + dedupByContentHash: boolean; + /** + * Token-budget auto-flush trigger. A fraction in (0, 1] (a 0–1 share of the + * context window, NOT a 0–100 percentage; e.g. 0.8 = flush at 80% of the + * window, capped at 300k tokens - see below). When set, a flush of all + * pending batches is forced at the end of + * any tool-using turn once context usage reaches `threshold * contextWindow` + * tokens OR 300,000 tokens (MAX_BUDGET_WINDOW in src/budget.ts), whichever + * comes first — regardless of `pruneOn`. The ceiling keeps the setting + * reachable on huge-window models, where 0.9 of 1M would mean 900k tokens; it + * never binds on a model advertising 300k or less. An ADDITIONAL trigger on + * top of `pruneOn`, not a replacement. + * + * null = disabled. The maintained default is 0.735. Out-of-range values + * (<= 0 or > 1) normalize to the maintained default. + */ + autoBudgetThreshold: number | null; + /** Min chars (resultText.length) for a single tool result to spill to a sidecar file. */ + spillThreshold: number; + /** Head-preview size in bytes kept inline as resultPreview on a spilled record. */ + spillPreviewBytes: number; + /** + * Per-turn usage-fraction increase (0–1) that forces a flush, independent of + * autoBudgetThreshold. The fraction is measured against the effective window + * `min(contextWindow, MAX_BUDGET_WINDOW)` (300_000), so the required growth is + * `delta * min(contextWindow, MAX_BUDGET_WINDOW)` tokens - the maintained 0.04 + * means +12k tokens on any model at or above 300k, and +8k on a 200k model. + * null disables the trigger. Out-of-range values normalize to the maintained default. + */ + budgetTurnDelta: number | null; +} + +/** + * Detected (pre-decision) shape emitted by chain-detector. + * Distinct from ChainCompressionEntry (the persisted post-decision shape). + * + * NOTE: AgentMessage has no `.id` field, so chains are identified by + * `timestamp` (for user/final-assistant boundaries) and `toolCallId` sets + * (for middle tool-using turns). The chain-compressor promotes ChainRange + * into a ChainCompressionEntry by adding blockId, toolRefs, and compressedAt. + */ +export interface ChainRange { + /** Timestamp of the user message that opens the chain. */ + startUserTimestamp: number; + /** + * All toolCallIds in the chain's middle (deduplicated). Collected from both + * AssistantMessage ToolCall blocks AND matching ToolResultMessages. + * Identifies the chain's middle tool calls for detection, recovery-grace + * filtering and diagnostics. NOT used for the load-bearing indexer lookups + * (summary bodies, tool refs) - those maps are occurrence-keyed, so use + * the sibling `middleOccurrenceKeys` instead. Drops themselves are decided + * positionally by `resolveRange` in chain-range-prune.ts, not by these ids. + */ + middleToolCallIds: string[]; + /** + * Occurrence keys (`id@resultTimestamp`) for the chain's middle tool + * results, collected from the ToolResultMessages themselves. Used for + * indexer summary-body / toolRef lookups, which are occurrence-keyed. + * Optional so hand-built ChainRange fixtures need not set it. + */ + middleOccurrenceKeys?: string[]; + /** + * Subset of middleToolCallIds whose tool name ∈ protectedTools (detection-time + * fact). The detector always emits it ([] when no protected tool ran); optional + * so hand-built ChainRange fixtures need not set it. + */ + protectedToolCallIds?: string[]; + /** Timestamp of the final text-only assistant message, or null if truncated/open. */ + finalAssistantTimestamp: number | null; +} + +/** + * Persisted per chain that has been range-dropped from LLM context. + * Written via pi.appendEntry(CUSTOM_TYPE_CHAIN, entry). + * Rebuilt into the chain registry on session_start. + */ +export interface ChainCompressionEntry { + /** Stable block ID, monotonic per session: "b1", "b2", ... */ + blockId: string; + /** Timestamp of the user message that opens the chain. Keep raw; synthetic inserted after. */ + startUserTimestamp: number; + /** + * All toolCallIds in the chain's middle. **Diagnostic only** since the + * positional-range change: drops are decided by index range (see + * resolveRange in chain-range-prune.ts). Retained as a cross-check - a + * mismatch against the ids actually dropped emits `range-id-mismatch`. + */ + droppedToolCallIds: string[]; + /** + * Occurrence keys for the same calls as droppedToolCallIds. Load-bearing at + * render time: summaryBodies are occurrence-keyed, so the synthetic chain + * body is looked up by these. Absent on pre-upgrade entries, which fall back + * to droppedToolCallIds against their own bare-keyed summaryBodies. + */ + droppedOccurrenceKeys?: string[]; + /** + * Subset of droppedToolCallIds whose tool was user-protected. Membership is decided + * per call by tool name (every call whose name ∈ protectedTools), not a per-id allowlist. + * Their verbatim ToolResultMessage text is relocated into the synthetic + * body at render time (pulled live from the raw branch) instead + * of being dropped. Absent/empty ⇒ no protected outputs (identical to pre-feature render). + */ + protectedToolCallIds?: string[]; + /** + * Timestamp of the final text-only assistant in the chain. + * Kept in context but with thinking blocks stripped. + * Null when the chain was truncated (no text-only close found). + */ + finalAssistantTimestamp: number | null; + /** Short t refs for the tool calls in this chain, surfaced in the synthetic message's `tools="..."` attribute. */ + toolRefs: string[]; + /** Epoch ms when the compression decision was recorded. */ + compressedAt: number; + /** + * Cohesive LLM range summary fusing the chain's per-batch summaries + * (set when `chainCompression.fuseRangeSummary` is on and the span has >= 2 + * per-batch summaries to fuse). When present, the renderer uses this as the + * synthetic `` body instead of the per-batch concatenation. + * Absent on fusion failure / single-batch spans → renderer falls back to concat. + */ + rangeSummaryText?: string; + /** + * "deterministic" = zero-LLM synthetic body built by the uncovered-chain + * backfill path (rangeSummaryText holds the stub). Absent = LLM-fused or + * per-batch semantics, unchanged. + */ + bodySource?: "deterministic"; +} + +export interface ChainCompressionConfig { + enabled: boolean; + /** Number of most-recently-closed chains to keep raw (not compressed). Default 3. */ + rollingWindow: number; + /** Strip thinking blocks from the kept final text-only assistant. Default true. */ + stripFinalAssistantThinking: boolean; + /** + * Fuse a compressed chain's per-batch summaries into one cohesive LLM range + * summary (one extra summarizer call per multi-batch span at compression + * time). Off → the synthetic message keeps the per-batch concatenation. + * Default true. + */ + fuseRangeSummary: boolean; +} + +export interface ErrorPurgeConfig { + enabled: boolean; + /** Wait this many turns after the error before purging the toolCall argument body. Default 2. */ + cooldownTurns: number; + /** Only purge arg bodies larger than this many chars. Default 500. */ + minArgChars: number; +} + +/** + * my-pi maintained defaults. Missing settings use these values in memory; + * explicit user values in settings.json always win through config.normalize(). + */ +export const DEFAULT_CONFIG: ContextPruneConfig = { + enabled: true, + showPruneStatusLine: true, + summarizerModel: "default", + summarizerThinking: "default", + pruneOn: "agent-message", + batchingMode: "turn", + quietOversizedSkips: false, + minBatchChars: 1000, + recoveryGraceTurns: 3, + summarizerIdleTimeoutMs: 90000, + summarizerMaxTimeoutMs: 300000, + summarizerMaxRetries: 2, + summarizerRetryBaseDelayMs: 1000, + summarizerRetryMaxDelayMs: 8000, + protectedTools: [], + protectedPaths: ["**/skills/**/*.md"], + chainCompression: { + enabled: true, + rollingWindow: 3, + stripFinalAssistantThinking: true, + fuseRangeSummary: true, + }, + purgeErrors: { + enabled: true, + cooldownTurns: 2, + minArgChars: 500, + }, + dedupByContentHash: true, + autoBudgetThreshold: 0.735, + spillThreshold: 65536, + spillPreviewBytes: 2048, + budgetTurnDelta: 0.04, +}; + +// ── Captured batch ───────────────────────────────────────────────────────── + +/** A single tool call + its result as captured from turn_end */ +export interface CapturedToolCall { + toolCallId: string; + toolName: string; + args: Record; + resultText: string; + isError: boolean; + /** + * Timestamp of the ToolResultMessage this call was paired with. The + * occurrence discriminant (see src/occurrence-key.ts): provider ids repeat + * within a session, this does not. Optional so pre-upgrade persisted + * entries stay readable; absent => the record is legacy bare-id keyed. + */ + resultTimestamp?: number; + spillPath?: string; + spillBytes?: number; + resultPreview?: string; + contentHash?: string; +} + +/** + * One complete batch from a single turn_end event. + * Represents one assistant turn that contained tool calls. + */ +export interface CapturedBatch { + turnIndex: number; + timestamp: number; + /** Any non-tool-call text from the assistant message (may be empty) */ + assistantText: string; + toolCalls: CapturedToolCall[]; + /** + * Grouping key assigned by `captureUnindexedBatchesFromSession`. + * Increments for each user message seen while walking the branch. + * Batches from the live `turn_end` path do NOT have this field set + * (they are always emitted one-per-turn regardless of batchingMode). + * Used by `groupBatchesByMode` to merge turns within the same + * user → agent-message span when batchingMode === "agent-message". + */ + userTurnGroup?: number; +} + +// ── Index record ─────────────────────────────────────────────────────────── + +/** + * A single tool call record stored in the runtime index. + * Contains the full original tool output for context_tree_query recovery. + */ +export interface ToolCallRecord { + toolCallId: string; + toolName: string; + args: Record; + /** Full original result text. Empty ("") for spilled records — body lives in the sidecar file at spillPath. */ + resultText: string; + isError: boolean; + turnIndex: number; + timestamp: number; + /** See CapturedToolCall.resultTimestamp. */ + resultTimestamp?: number; + /** Absolute path to the sidecar blob holding the full body (set only when the result was spilled). */ + spillPath?: string; + /** Full byte length of the spilled body. */ + spillBytes?: number; + /** Head preview kept inline when spilled (resultText is "" in that case). */ + resultPreview?: string; + /** Dedup hash of the FULL body, persisted so reconstruct/addBatch skip rehashing the empty resultText. */ + contentHash?: string; +} + +// ── Session persistence types ────────────────────────────────────────────── + +/** + * Data stored via pi.appendEntry(CUSTOM_TYPE_INDEX, data). + * One entry per summarized batch; reconstructed into the runtime index on session_start. + */ +export interface IndexEntryData { + toolCalls: ToolCallRecord[]; + /** Entry written by backfillChainRecords: records must NOT seed contentHashToOriginal. */ + backfilled?: true; + /** Refs allocated at backfill time; durable carrier for alias reconstruction. */ + refs?: SummaryToolCallRef[]; +} + +/** + * Data stored via pi.appendEntry(CUSTOM_TYPE_DEDUP_ALIAS, data). + * + * Each entry maps a duplicate toolCallId to the original (already-indexed) + * toolCallId whose (toolName, normalized resultText) hash it matched. + * + * - pruneMessages stub-replaces the duplicate's ToolResultMessage using the + * original's short ref (via the indexer's toolCallIdToAlias map). + * - context_tree_query resolves the duplicate's id back to the original + * record via the indexer's dedup alias map. + * + * `hash` is optional and stored only for debugging; reconstruction works + * without it because the original record is re-hashed when its + * CUSTOM_TYPE_INDEX entry is replayed. + */ +export interface DedupAliasEntryData { + newToolCallId: string; + originalToolCallId: string; + /** Occurrence timestamps for each side; absent on pre-upgrade entries. */ + newResultTimestamp?: number; + originalResultTimestamp?: number; + hash?: string; +} + +/** + * Short alias used in the summary message text plus the real toolCallId it + * maps back to for future recovery through context_tree_query. + */ +export interface SummaryToolCallRef { + shortId: string; + toolCallId: string; + /** ToolResultMessage timestamp; with toolCallId this forms the occurrence key. */ + resultTimestamp?: number; +} + +/** + * Details stored in the custom summary message's `details` field. + * Machine-readable metadata so renderers and extensions can inspect summaries. + */ +export interface SummaryMessageDetails { + toolCallRefs: SummaryToolCallRef[]; + toolNames: string[]; + turnIndex: number; + timestamp: number; +} + +/** Snapshot of what the pruner cannot (yet) reclaim. All token values are Math.round(JSON-chars / 4). */ +export interface ContextMetricsSnapshot { + /** Est. tokens of thinking blocks retained in the trailing open segment. */ + openCycleThinkingTokens: number; + /** max(largest closed chain, open segment) chars / total branch chars, 0-100. */ + largestChainSharePct: number; + /** Est. tokens of summarization-eligible unsummarized toolResults after the frontier. */ + frontierGapTokens: number; +} + +export type FlushTrigger = "budget" | "delta" | "message-end" | "manual" | "rearmed"; + +/** Payload of CUSTOM_TYPE_FLUSH_METRICS. */ +export interface FlushMetricsEntry { + ts: number; + trigger: FlushTrigger; + /** Batches after rescan+trim, before processing. */ + capturedBatches: number; + processedBatches: number; + outcome: "summarized" | "skipped-oversized" | "skipped-deduped" | "skipped-trivial" | "empty" | "error"; + /** Computed at flush ENTRY (pre-flush pressure). */ + metrics: ContextMetricsSnapshot; +} + +// ── Summarizer stats ──────────────────────────────────────────────────────── + +/** + * Cumulative token/cost stats for summarizer LLM calls and chain compression. + * Persisted via pi.appendEntry(CUSTOM_TYPE_STATS, ...) so stats survive + * restarts and branch navigation. + */ +export interface SummarizerStats { + /** Cumulative input tokens across all summarizer calls */ + totalInputTokens: number; + /** Cumulative output tokens across all summarizer calls */ + totalOutputTokens: number; + /** Cumulative cost in USD across all summarizer calls */ + totalCost: number; + /** Number of summarizer LLM calls made */ + callCount: number; + /** Cumulative number of chains range-compressed across all flushes */ + chainsCompressed: number; + /** Cumulative number of chains given a fused LLM range summary */ + rangesSummarized: number; +} + +/** + * Cumulative-per-source cost contribution emitted on EXTERNAL_COST_CHANNEL. + * "Cumulative" = for the CURRENT session, not all-time. Idempotent: an + * aggregator keys by `source` and overwrites, so a re-emit never double-counts. + */ +export interface ExternalCostUpdate { + source: string; + totalCost: number; + inputTokens?: number; + outputTokens?: number; +} + +/** Transient before/after context-size measurement from the last prune (chars). */ +export interface LiveReclaim { + beforeChars: number; + afterChars: number; +} + +/** Outcome of the most recent completed prune attempt. */ +export type PruneFrontierOutcome = + | "summarized" + | "skipped-oversized" + | "skipped-trivial" + | "skipped-deduped"; + +/** + * Snapshot of the last successfully completed prune attempt boundary. + * + * This advances both when pruning succeeds and when a summary is rejected for + * being larger than the raw tool-result text it would replace. Operational + * failures do not advance the frontier. + */ +export interface PruneFrontier { + /** Last tool call included in the completed prune attempt */ + lastAttemptedToolCallId: string; + /** Name of the last tool call included in the completed prune attempt */ + lastAttemptedToolName: string; + /** Assistant turn index containing the last attempted tool call */ + lastAttemptedTurnIndex: number; + /** Timestamp captured when that last attempted tool call batch was recorded */ + lastAttemptedTimestamp: number; + /** Number of batches included in the completed prune attempt */ + attemptedBatchCount: number; + /** Number of tool calls included in the completed prune attempt */ + attemptedToolCallCount: number; + /** Character count of the raw tool-result text that was eligible for pruning */ + rawCharCount: number; + /** Character count of the rendered summary text that was produced */ + summaryCharCount: number; + /** Whether the attempt actually pruned or was skipped for being oversized */ + outcome: PruneFrontierOutcome; +} + +/** + * Progress callback invoked by `flushPending` when processing batches sequentially. + * Only fired when the caller passes `onProgress` in `FlushOptions` (i.e. `/pruner now`). + */ +export type ProgressCallback = ( + index: number, + total: number, + batch: CapturedBatch, + stage: "start" | "done" | "skipped", +) => void; + +/** Live text-progress callback for a batch currently being summarized. */ +export type BatchTextProgressCallback = ( + index: number, + total: number, + batch: CapturedBatch, + receivedChars: number, +) => void; + +/** Options accepted by `flushPending`. */ +export interface FlushOptions { + /** Delivery path: "runtime" uses sendMessage/steer (default); "session" writes directly to session. */ + delivery?: "runtime" | "session"; + /** + * When provided, batches are processed sequentially (one LLM call each) instead of + * in parallel, and this callback is invoked before/after each batch. Used by + * `/pruner now` to drive the multi-row progress overlay. + */ + onProgress?: ProgressCallback; + /** + * When provided, receives the number of summary characters streamed so far for + * the currently-running batch. Used by `/pruner now` to show live progress. + */ + onBatchTextProgress?: BatchTextProgressCallback; + /** + * Pre-captured batches from a prior `capturePendingBatches()` call. + * When set, `flushPending` skips the internal capture step and uses these directly. + * Avoids double-capture when the caller needs to know the batch count before + * opening the progress overlay. + */ + previewedBatches?: CapturedBatch[]; + /** + * Abort signal — when fired the in-flight summarization is cancelled and + * `flushPending` returns `{ ok: false, reason: "aborted" }` without advancing + * the frontier. All pending batches are restored so the next flush can retry. + */ + signal?: AbortSignal; + /** Which trigger initiated this flush. Defaults to "manual" when absent. */ + trigger?: FlushTrigger; + /** + * The final text-only assistant message that triggered an agent-message flush. + * pi emits `message_end` to extensions before persisting it to the session, so it + * is threaded in here to close the newest chain for compression (see + * `withClosingMessage`). Only set on the message_end path. + */ + closingMessage?: any; +} + +/** Options for a single summarizeBatch() call. */ +export interface SummarizeBatchOptions { + /** Receives the number of summary text characters streamed so far. */ + onTextProgress?: (receivedChars: number) => void; + /** + * Abort signal — when fired the in-flight stream call is cancelled and the + * batch is treated as aborted (not a summarizer failure). + */ + signal?: AbortSignal; + /** + * Session-scoped outage-fallback controller. When present AND a distinct + * fallback model exists, runSummarization routes/retries via the controller + * (see src/summarizer-fallback.ts). Absent => today's single-attempt behavior. + */ + controller?: FallbackController; +} + +/** Options for summarizeBatches() when callers want live per-batch text progress. */ +export interface SummarizeBatchesOptions { + /** Receives streamed summary text character counts for each batch. */ + onBatchTextProgress?: BatchTextProgressCallback; + /** + * Abort signal forwarded to every individual summarizeBatch() call. + * When fired, all in-flight stream calls are cancelled. + */ + signal?: AbortSignal; + /** + * Session-scoped outage-fallback controller. When present AND a distinct + * fallback model exists, runSummarization routes/retries via the controller + * (see src/summarizer-fallback.ts). Absent => today's single-attempt behavior. + */ + controller?: FallbackController; +} + +/** + * Result of a summarization call — the summary text plus LLM usage data. + */ +export interface SummarizeResult { + summaryText: string; + /** Usage data from the LLM response (tokens + cost) */ + usage: { + input: number; + output: number; + cacheRead: number; + cacheWrite: number; + totalTokens: number; + cost: { + input: number; + output: number; + cacheRead: number; + cacheWrite: number; + total: number; + }; + }; +} diff --git a/tests/condense-config.test.ts b/tests/condense-config.test.ts deleted file mode 100644 index 656a821..0000000 --- a/tests/condense-config.test.ts +++ /dev/null @@ -1,98 +0,0 @@ -import assert from "node:assert/strict"; -import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import test from "node:test"; - -import { ensureCondenseDefaults } from "../extensions/condense-config.ts"; - -const EXPECTED_DEFAULTS = { - enabled: true, - pruneOn: "agent-message", - batchingMode: "turn", - autoBudgetThreshold: 0.735, - budgetTurnDelta: 0.04, - summarizerModel: "default", - summarizerThinking: "default", - summarizerIdleTimeoutMs: 90_000, - summarizerMaxTimeoutMs: 300_000, - quietOversizedSkips: false, -}; - -async function withAgentDir(run: (agentDir: string) => Promise): Promise { - const agentDir = await mkdtemp(join(tmpdir(), "my-pi-condense-")); - try { - await run(agentDir); - } finally { - await rm(agentDir, { recursive: true, force: true }); - } -} - -test("pi-condense writes the complete bundle defaults when contextPrune is absent", async () => { - await withAgentDir(async (agentDir) => { - assert.equal(ensureCondenseDefaults(agentDir), "updated"); - assert.deepEqual(JSON.parse(await readFile(join(agentDir, "settings.json"), "utf8")), { - contextPrune: EXPECTED_DEFAULTS, - }); - }); -}); - -test("pi-condense fills only missing defaults and preserves unrelated settings", async () => { - await withAgentDir(async (agentDir) => { - const path = join(agentDir, "settings.json"); - await writeFile( - path, - JSON.stringify({ theme: "dark", contextPrune: { minBatchChars: 4000, budgetTurnDelta: 0.03 } }), - "utf8", - ); - assert.equal(ensureCondenseDefaults(agentDir), "updated"); - assert.deepEqual(JSON.parse(await readFile(path, "utf8")), { - theme: "dark", - contextPrune: { ...EXPECTED_DEFAULTS, minBatchChars: 4000, budgetTurnDelta: 0.03 }, - }); - }); -}); - -test("pi-condense preserves an explicit disabled choice while filling other defaults", async () => { - await withAgentDir(async (agentDir) => { - const path = join(agentDir, "settings.json"); - await writeFile(path, JSON.stringify({ contextPrune: { enabled: false } }), "utf8"); - assert.equal(ensureCondenseDefaults(agentDir), "updated"); - assert.deepEqual(JSON.parse(await readFile(path, "utf8")), { - contextPrune: { ...EXPECTED_DEFAULTS, enabled: false }, - }); - }); -}); - -test("pi-condense does not rewrite a complete explicit configuration", async () => { - await withAgentDir(async (agentDir) => { - const path = join(agentDir, "settings.json"); - const original = `${JSON.stringify( - { contextPrune: { ...EXPECTED_DEFAULTS, enabled: false, quietOversizedSkips: true } }, - null, - 2, - )}\n`; - await writeFile(path, original, "utf8"); - assert.equal(ensureCondenseDefaults(agentDir), "unchanged"); - assert.equal(await readFile(path, "utf8"), original); - }); -}); - -test("pi-condense defaults do not overwrite malformed settings", async () => { - await withAgentDir(async (agentDir) => { - const path = join(agentDir, "settings.json"); - await writeFile(path, "{not-json", "utf8"); - assert.equal(ensureCondenseDefaults(agentDir), "skipped-invalid"); - assert.equal(await readFile(path, "utf8"), "{not-json"); - }); -}); - -test("pi-condense defaults reject a non-object contextPrune value", async () => { - await withAgentDir(async (agentDir) => { - const path = join(agentDir, "settings.json"); - const original = `${JSON.stringify({ contextPrune: false }, null, 2)}\n`; - await writeFile(path, original, "utf8"); - assert.equal(ensureCondenseDefaults(agentDir), "skipped-invalid"); - assert.equal(await readFile(path, "utf8"), original); - }); -}); diff --git a/tests/extension-load.test.ts b/tests/extension-load.test.ts index 195ec96..f71f94a 100644 --- a/tests/extension-load.test.ts +++ b/tests/extension-load.test.ts @@ -46,6 +46,17 @@ test("all package extensions load together without global registration conflicts packageJson.pi.extensions.includes("./pi-extension-codex-fast-mode/index.ts"), "the bundle must load the locally maintained Codex Fast mode extension", ); + assert.equal(packageJson.dependencies["pi-condense"], "file:./pi-condense"); + assert.ok(packageJson.files.includes("pi-condense"), "the packed bundle must include maintained pi-condense source"); + assert.ok( + packageJson.pi.extensions.includes("./pi-condense/index.ts"), + "the bundle must load maintained pi-condense directly", + ); + assert.equal( + packageJson.pi.extensions.includes("./extensions/condense.ts"), + false, + "the removed config-injection wrapper must not be loaded in parallel", + ); assert.ok(packageJson.pi.extensions.includes("./pi-ssh/index.ts"), "the bundle must load the locally maintained SSH extension"); assert.equal(packageJson.dependencies["pi-ssh"], "file:./pi-ssh"); assert.equal(packageJson.dependencies.ssh2, "1.17.0", "the packed bundle must install pi-ssh's runtime transport");