From 6ce932df6023e2b8138b04bd14fbb21bd8466535 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=91=E6=9C=8D=E5=8A=A1=E9=83=A8-=E5=8F=B6=E6=9E=97?= =?UTF-8?q?=E7=AB=8B?= <1361666059@qq.com> Date: Wed, 19 Aug 2026 15:06:40 +0800 Subject: [PATCH] feat: add pi-condense with default enablement --- AGENTS.md | 2 ++ README.md | 9 ++++++ extensions/condense-config.ts | 33 +++++++++++++++++++++ extensions/condense.ts | 12 ++++++++ package-lock.json | 20 +++++++++++++ package.json | 2 ++ tests/condense-config.test.ts | 56 +++++++++++++++++++++++++++++++++++ 7 files changed, 134 insertions(+) create mode 100644 extensions/condense-config.ts create mode 100644 extensions/condense.ts create mode 100644 tests/condense-config.test.ts diff --git a/AGENTS.md b/AGENTS.md index 402fb76..aee9553 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,6 +29,7 @@ - `pi-lsp@0.1.7`:提供声明式 LSP 接入;组合包内置 `typescript-language-server@5.3.0` + `typescript@6.0.3`,并配置机器级 `kotlin-lsp --stdio` 与 `jdtls`。 - `pi-hermes-memory@0.9.6`:提供持久记忆、会话搜索、后台学习和 secret scanning,默认使用 policy-only 模式。 - `@ogulcancelik/pi-codex-compaction@0.1.3`:为 `openai-codex` 提供原生远程 compaction,默认阈值为 90%。 +- `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` 改为指向仓库内 `pi-permission-system/` 的本地 `file:` 依赖。 - `install.sh`:先安装根组合包,再交互检查 Kitty/Solarized Dark、Oh My Zsh/Powerlevel10k/Zsh 插件、CodeGraph、Kotlin LSP、Java 21+ 和 JDT LS;缺失项目只在用户明确选择 `Y` 后安装或配置。Powerlevel10k 默认配置来自仓库内置的 `config/p10k.zsh`(当前 Rainbow/ASCII 单行紧凑主题)。 - `update.sh`:先通过 `pi update` 升级根组合包,再只升级当前已安装的终端环境和机器级依赖;未安装项直接跳过。升级前先查询并比较本地与远端版本,只有版本不同时才下载或替换;Powerlevel10k 配置优先从 `pi update` 后的已安装组合包读取,并与 `.zshrc` 受管块一起按内容比较后增量同步。 @@ -51,6 +52,7 @@ - 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` 时写入 `true`,必须保留用户显式设置的 `false`,配置无效时不得覆盖原文件。其他参数沿用上游默认,包括 `agent-message` 触发模式和 skill 路径保护。 - Codex fast mode 只为符合条件的 `openai-codex-responses` 请求设置 priority service tier,由 `/fast-mode` 在会话内控制。 - 权限策略默认允许常规工具,允许 FFF 工具;拒绝 Bash 直搜和敏感凭据路径;Git 非只读操作、包管理、外部目录、文件/系统/网络高风险操作与普通 MCP 调用先由 `pi-permission-system` 判为 `ask`。 - `pi-permission-auto-review` 不是独立 `tool_call` gate,而是 `pi-permission-system` authorizer chain 中名为 `auto-review` 的链路;只复核权限基线产生的 `ask`,不会重复处理已 `allow` 或已 `deny` 的请求。 diff --git a/README.md b/README.md index 8ffda34..338cfe9 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ - `pi-lsp@0.1.7`:为 TypeScript/JavaScript、Kotlin 和 Java 提供 LSP 诊断、跳转与符号工具;TypeScript 后端由组合包内置。 - `pi-hermes-memory@0.9.6`:提供持久记忆、会话搜索和 secret scanning。 - `@ogulcancelik/pi-codex-compaction@0.1.3`:为 `openai-codex` 提供原生远程压缩。 +- `pi-condense@2.9.1`:把已完成的工具调用批次总结为可恢复的短摘要,并通过 `context_tree_query` 按需取回原始输出;组合包首次加载时默认开启。 - 本仓库维护的 `pi-permission-auto-review`:作为 `pi-permission-system` authorizer,使用 Codex Guardian 风格策略自动复核 `ask` 请求。 - 本仓库维护的 `pi-permission-system`:从 `@gotgenes/pi-permission-system@26.2.1` 源码导入,负责工具、路径、MCP、硬拒绝和兜底权限基线。 - `extensions/tool-routing.ts`:保留 Pi 默认系统提示词,并按当前激活工具追加简短的工具与搜索路由规则;提供 `/dump-system-prompt` 导出当前有效提示词。 @@ -256,6 +257,14 @@ Hermes Memory 使用上游默认的 `policy-only` 模式:不把完整记忆直 达到 90% 上下文占用时触发,并写入 Pi 原生 compaction boundary。失败时保持原历史且不静默 回退到文本摘要;checkpoint 与创建它的 Codex model 绑定。 +### Pi Condense + +组合包首次加载时会在 `contextPrune.enabled` 尚未配置的情况下写入 `true`,因此 `pi-condense` 默认开启;用户通过 `/pruner off` 写入的显式 `false` 会被保留,后续启动或升级不会重新覆盖。它使用上游默认的 `agent-message` 触发模式:同一用户任务中的多轮工具调用会在最终文本回复后统一触发整理,默认仍按每个 assistant turn 分别生成摘要;后续上下文只保留摘要和 `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`。 + +它与 Context Mode 的职责不同:Context Mode 避免大型原始输出进入当前模型上下文,Pi Condense 则压缩已经进入会话、且已完成使用的历史工具结果。它也不替代 Codex 原生远程 compaction;不希望自动整理历史时可运行 `/pruner off`。 + ### Codex fast mode 该扩展只在 `openai-codex` provider 的 Responses 请求上加入 diff --git a/extensions/condense-config.ts b/extensions/condense-config.ts new file mode 100644 index 0000000..bfe56d6 --- /dev/null +++ b/extensions/condense-config.ts @@ -0,0 +1,33 @@ +import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; + +export type CondenseDefaultResult = "updated" | "unchanged" | "skipped-invalid"; + +function isObject(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +/** Enable pi-condense only when the user has not already chosen an enabled state. */ +export function ensureCondenseEnabledDefault(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"; + if (isObject(existing) && Object.hasOwn(existing, "enabled")) return "unchanged"; + + settings.contextPrune = { ...(existing ?? {}), enabled: true }; + 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 new file mode 100644 index 0000000..2c84227 --- /dev/null +++ b/extensions/condense.ts @@ -0,0 +1,12 @@ +import { getAgentDir, type ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import { ensureCondenseEnabledDefault } 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 = ensureCondenseEnabledDefault(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 3743890..f4175b7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "@ogulcancelik/pi-codex-compaction": "0.1.3", "@tavily/pi-extension": "0.1.2", "context-mode": "1.0.169", + "pi-condense": "2.9.1", "pi-context-view": "0.4.2", "pi-hashline-edit": "0.8.3", "pi-hermes-memory": "0.9.6", @@ -5624,6 +5625,25 @@ "dev": true, "license": "MIT" }, + "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": "*" + } + }, "node_modules/pi-context-view": { "version": "0.4.2", "resolved": "https://registry.npmjs.org/pi-context-view/-/pi-context-view-0.4.2.tgz", diff --git a/package.json b/package.json index 3d93448..7e456ff 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "./node_modules/@firstpick/pi-extension-codex-fast-mode/index.ts", "./node_modules/pi-hermes-memory/src/index.ts", "./node_modules/@ogulcancelik/pi-codex-compaction/index.ts", + "./extensions/condense.ts", "./pi-permission-auto-review/index.ts", "./extensions/permission-system.ts", "./extensions/tool-routing.ts" @@ -60,6 +61,7 @@ "@ogulcancelik/pi-codex-compaction": "0.1.3", "@tavily/pi-extension": "0.1.2", "context-mode": "1.0.169", + "pi-condense": "2.9.1", "pi-context-view": "0.4.2", "pi-hashline-edit": "0.8.3", "pi-hermes-memory": "0.9.6", diff --git a/tests/condense-config.test.ts b/tests/condense-config.test.ts new file mode 100644 index 0000000..21d414c --- /dev/null +++ b/tests/condense-config.test.ts @@ -0,0 +1,56 @@ +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 { ensureCondenseEnabledDefault } from "../extensions/condense-config.ts"; + +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 defaults to enabled when contextPrune is absent", async () => { + await withAgentDir(async (agentDir) => { + assert.equal(ensureCondenseEnabledDefault(agentDir), "updated"); + assert.deepEqual(JSON.parse(await readFile(join(agentDir, "settings.json"), "utf8")), { + contextPrune: { enabled: true }, + }); + }); +}); + +test("pi-condense default preserves existing settings and contextPrune fields", async () => { + await withAgentDir(async (agentDir) => { + const path = join(agentDir, "settings.json"); + await writeFile(path, JSON.stringify({ theme: "dark", contextPrune: { minBatchChars: 4000 } }), "utf8"); + assert.equal(ensureCondenseEnabledDefault(agentDir), "updated"); + assert.deepEqual(JSON.parse(await readFile(path, "utf8")), { + theme: "dark", + contextPrune: { minBatchChars: 4000, enabled: true }, + }); + }); +}); + +test("pi-condense default respects an explicit enabled choice", async () => { + await withAgentDir(async (agentDir) => { + const path = join(agentDir, "settings.json"); + const original = `${JSON.stringify({ contextPrune: { enabled: false } }, null, 2)}\n`; + await writeFile(path, original, "utf8"); + assert.equal(ensureCondenseEnabledDefault(agentDir), "unchanged"); + assert.equal(await readFile(path, "utf8"), original); + }); +}); + +test("pi-condense default does not overwrite malformed settings", async () => { + await withAgentDir(async (agentDir) => { + const path = join(agentDir, "settings.json"); + await writeFile(path, "{not-json", "utf8"); + assert.equal(ensureCondenseEnabledDefault(agentDir), "skipped-invalid"); + assert.equal(await readFile(path, "utf8"), "{not-json"); + }); +});