billion-context-pi 0.0.6 → 0.1.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,9 +1,9 @@
1
- # pai-acp
1
+ # billion-context-pi
2
2
 
3
3
  [English](./README.md) | [中文](./README.zh-CN.md)
4
4
 
5
5
  <p align="center">
6
- <strong>Active Context Pruning</strong> for <a href="https://pi.dev">Pi</a>
6
+ <strong>Billion-Context</strong> for <a href="https://pi.dev">Pi</a>
7
7
  <br />
8
8
  The model decides <em>when</em> and <em>what</em> to compress — not a hard limit.
9
9
  </p>
@@ -11,22 +11,22 @@ The model decides <em>when</em> and <em>what</em> to compress — not a hard lim
11
11
  ---
12
12
 
13
13
  <p align="center">
14
- <a href="https://www.npmjs.com/package/pai-acp"><img src="https://img.shields.io/npm/v/pai-acp.svg?style=flat-square" alt="npm"></a>
15
- <a href="https://github.com/ranxianglei/pai-acp/blob/master/LICENSE"><img src="https://img.shields.io/npm/l/pai-acp.svg?style=flat-square" alt="license"></a>
16
- <a href="https://github.com/ranxianglei/pai-acp"><img src="https://img.shields.io/badge/GitHub-ranxianglei%2Fpai--acp-181717?style=flat-square&logo=github" alt="GitHub"></a>
14
+ <a href="https://www.npmjs.com/package/billion-context-pi"><img src="https://img.shields.io/npm/v/billion-context-pi.svg?style=flat-square" alt="npm"></a>
15
+ <a href="https://github.com/ranxianglei/billion-context-pi/blob/master/LICENSE"><img src="https://img.shields.io/npm/l/billion-context-pi.svg?style=flat-square" alt="license"></a>
16
+ <a href="https://github.com/ranxianglei/billion-context-pi"><img src="https://img.shields.io/badge/GitHub-ranxianglei%2Fbillion--context--pi-181717?style=flat-square&logo=github" alt="GitHub"></a>
17
17
  </p>
18
18
 
19
19
  <p align="center">
20
- <code>pi install npm:pai-acp</code>
20
+ <code>pi install npm:billion-context-pi</code>
21
21
  </p>
22
22
 
23
23
  ---
24
24
 
25
25
  ## Why?
26
26
 
27
- When conversations get long, the model runs out of context. Most tools hard-truncate — silently dropping earlier messages. **ACP** gives the model a `compress` tool: the LLM decides **when** and **what** to compress into high-fidelity summaries, preserving critical details (file paths, decisions, error strings) while reclaiming context space.
27
+ When conversations get long, the model runs out of context. Most tools hard-truncate — silently dropping earlier messages. **billion-context** gives the model a `compress` tool: the LLM decides **when** and **what** to compress into high-fidelity summaries, preserving critical details (file paths, decisions, error strings) while reclaiming context space.
28
28
 
29
- Unlike Pi's built-in auto-compaction (which replaces everything with a single summary), ACP:
29
+ Unlike Pi's built-in auto-compaction (which replaces everything with a single summary), billion-context:
30
30
  - **Preserves structure** — compressed ranges become labeled blocks you can decompress later
31
31
  - **Multi-tier** — summaries can be further distilled (T1 → T2 → T3) as sessions grow
32
32
  - **Searchable** — `search_context` finds information inside compressed blocks without decompressing
@@ -40,19 +40,19 @@ This means:
40
40
  ## Install
41
41
 
42
42
  ```bash
43
- pi install npm:pai-acp
43
+ pi install npm:billion-context-pi
44
44
  ```
45
45
 
46
46
  That's it. The extension auto-loads on next Pi startup. No configuration needed — it reads your model's context window automatically.
47
47
 
48
- > **Uninstall `pi-subagents` first (optional, recommended).** pai-acp ships its own `acp_delegate` sub-agent tool (see below) that replaces pi-subagents at a fraction of the context cost (~600 tok vs ~7K tok/turn). If you have pi-subagents installed, remove it to avoid duplicate delegation tools:
48
+ > **Uninstall `pi-subagents` first (optional, recommended).** billion-context-pi ships its own `acp_delegate` sub-agent tool (see below) that replaces pi-subagents at a fraction of the context cost (~600 tok vs ~7K tok/turn). If you have pi-subagents installed, remove it to avoid duplicate delegation tools:
49
49
  > ```bash
50
50
  > pi remove npm:pi-subagents
51
51
  > ```
52
52
 
53
53
  ## How it works
54
54
 
55
- ACP intercepts Pi's `context` event (fired before each LLM call) and runs an 8-stage pipeline:
55
+ billion-context intercepts Pi's `context` event (fired before each LLM call) and runs an 8-stage pipeline:
56
56
 
57
57
  ```
58
58
  assign refs → sync blocks → prune → filter → hide calls → recommend → nudge → emergency truncate
@@ -60,7 +60,17 @@ assign refs → sync blocks → prune → filter → hide calls → recommend
60
60
 
61
61
  Each message gets an invisible `<acp>` ref tag (`m00001`, `m00002`, ...) visible to the model but not the user. The model uses these refs to specify compression ranges.
62
62
 
63
- Pi's built-in auto-compaction is cancelled — ACP is the sole context manager.
63
+ Pi's built-in auto-compaction is cancelled — billion-context is the sole context manager.
64
+
65
+ ## Plugin compatibility & ordering
66
+
67
+ billion-context takes over context management by intercepting Pi's `context` event. **Pi has no plugin priority mechanism** — when multiple extensions register handlers for the same event, they run in a fixed sequence (load order), with no `priority`/`weight` field and no way for the user to control the order. The `context` event specifically is a *pipeline*: every handler receives the previous handler's output, there is no short-circuit, and the **last** handler has the final say over what reaches the model.
68
+
69
+ This has two practical implications:
70
+
71
+ 1. **Keep exactly one context-compression plugin installed.** If you run two compression plugins together (e.g. billion-context-pi alongside another), both will rewrite the message list and clobber each other's work — compressed ranges can be re-expanded or corrupted. Pi's built-in auto-compaction is already cancelled automatically by billion-context-pi, but any *third-party* compression/compaction extension should be uninstalled.
72
+
73
+ 2. **Even with a single compression plugin, interference is still possible in rare cases.** Load order under Pi is determined by filesystem discovery order (`fs.readdirSync` over `.pi/extensions/` → global → packages), which is not fully deterministic. If another (non-compression) extension also hooks the `context` event and happens to load *after* billion-context-pi, it could modify the compressed output. billion-context-pi rebuilds its working set from the session log rather than the chained input, which makes it robust to handlers that run *before* it — but it cannot defend against a handler that runs *after* it. This is a limitation of Pi's extension model; if you observe unexpected context behavior, check whether other installed extensions intercept the `context` event.
64
74
 
65
75
  ## Model-facing tools
66
76
 
@@ -101,7 +111,7 @@ Rich status display for the user:
101
111
  ╭─────────────────────────────────────────────╮
102
112
  │ ACP Context Analysis │
103
113
  ╰─────────────────────────────────────────────╯
104
- pai-acp@0.1.14
114
+ billion-context-pi@0.1.14
105
115
 
106
116
  Context: 12% (120K / 1.0M)
107
117
  Growth: +15K since last nudge
@@ -121,7 +131,7 @@ Blocks: 3 active (3.7K summary, 15.2K original compressed)
121
131
 
122
132
  ## Configuration
123
133
 
124
- pai-acp works out of the box with no configuration. Three optional keys can be set in a JSON config file.
134
+ billion-context-pi works out of the box with no configuration. Three optional keys can be set in a JSON config file.
125
135
 
126
136
  ### Config file
127
137
 
@@ -132,7 +142,9 @@ Create `~/.pi/acp.json` (global) and/or `<project>/.pi/acp.json` (project-local,
132
142
  "debug": false,
133
143
  "autoUpdate": true,
134
144
  "modelContextLimit": 200000,
135
- "delegate": true
145
+ "delegate": true,
146
+ "toolBashDefaultTimeout": 60,
147
+ "toolOutputMaxBytes": 200000
136
148
  }
137
149
  ```
138
150
 
@@ -142,8 +154,10 @@ Create `~/.pi/acp.json` (global) and/or `<project>/.pi/acp.json` (project-local,
142
154
  | `autoUpdate` | `true` | On Pi startup, check npm for a newer version and auto-install it (throttled to one check per 3 minutes). Disable to avoid all startup network calls. |
143
155
  | `modelContextLimit` | *(auto)* | Override the context limit (in tokens). Defaults to the model's `contextWindow`. |
144
156
  | `delegate` | `true` | Enable the `acp_delegate` tools (delegate/wait/cancel) and their system-prompt section. Set `false` to skip registering them (e.g. you use a different sub-agent extension, or run headless where async injection adds no value). |
157
+ | `toolBashDefaultTimeout` | `60` | Seconds injected into the `bash` tool when the model omits `timeout`. Pi has **no** default of its own, so without this a forgotten timeout can hang for thousands of seconds. On timeout the model is guided to re-run with a larger `timeout`. `0` restores Pi's unbounded behavior. |
158
+ | `toolOutputMaxBytes` | `200000` | Hard byte cap on tool result text (~5000 lines at ~40 B/line; applied via the `tool_result` hook). Stops runaway output that Pi's own 50KB/2000-line cap can't catch (e.g. tools Pi doesn't cap). When it fires the model is told how to see the full output — for `bash` the full output is in its temp file (`BashToolDetails.fullOutputPath`); set lower (e.g. `8192`) for a tighter context budget, or `0` to disable. |
145
159
 
146
- > **Only these four keys are read from `acp.json`.** Other tuning knobs (`preserveRecentMessages`, `protectedTools`, nudge thresholds) are code-level and not user-overridable.
160
+ > **Only these six keys are read from `acp.json`.** Other tuning knobs (`preserveRecentMessages`, `protectedTools`, nudge thresholds) are code-level and not user-overridable.
147
161
 
148
162
  ### Environment variables
149
163
 
@@ -159,7 +173,7 @@ The model receives detailed guidance (in its system prompt) on **when** to compr
159
173
 
160
174
  ### What gets protected
161
175
 
162
- ACP protects three categories of content from compression:
176
+ billion-context protects three categories of content from compression:
163
177
 
164
178
  1. **Always-protected tools** — `compress` calls are hard-protected (they're load-bearing metadata; compressing them breaks decompress and the "summary is historical" contract).
165
179
  2. **Soft recent-zone** — the last N messages (default 5) and last ~5K tokens are soft-protected so the model keeps its working set. Tool results from `decompress`, `search_context`, `read`, and `bash` are **excluded** from this zone: they're large and meant to be compressible once consumed, so they don't eat the protected budget.
package/README.zh-CN.md CHANGED
@@ -1,7 +1,7 @@
1
1
  [English](./README.md) | [中文](./README.zh-CN.md)
2
2
 
3
3
  <p align="center">
4
- <strong>主动上下文剪枝</strong> — <a href="https://pi.dev">Pi</a> 的 ACP 插件
4
+ <strong>Billion-Context</strong> — <a href="https://pi.dev">Pi</a> 的上下文压缩插件
5
5
  <br />
6
6
  由模型决定<em>何时</em>压缩、压缩<em>什么</em> — 而非硬性截断。
7
7
  </p>
@@ -9,22 +9,22 @@
9
9
  ---
10
10
 
11
11
  <p align="center">
12
- <a href="https://www.npmjs.com/package/pai-acp"><img src="https://img.shields.io/npm/v/pai-acp.svg?style=flat-square" alt="npm"></a>
13
- <a href="https://github.com/ranxianglei/pai-acp/blob/master/LICENSE"><img src="https://img.shields.io/npm/l/pai-acp.svg?style=flat-square" alt="license"></a>
14
- <a href="https://github.com/ranxianglei/pai-acp"><img src="https://img.shields.io/badge/GitHub-ranxianglei%2Fpai--acp-181717?style=flat-square&logo=github" alt="GitHub"></a>
12
+ <a href="https://www.npmjs.com/package/billion-context-pi"><img src="https://img.shields.io/npm/v/billion-context-pi.svg?style=flat-square" alt="npm"></a>
13
+ <a href="https://github.com/ranxianglei/billion-context-pi/blob/master/LICENSE"><img src="https://img.shields.io/npm/l/billion-context-pi.svg?style=flat-square" alt="license"></a>
14
+ <a href="https://github.com/ranxianglei/billion-context-pi"><img src="https://img.shields.io/badge/GitHub-ranxianglei%2Fbillion--context--pi-181717?style=flat-square&logo=github" alt="GitHub"></a>
15
15
  </p>
16
16
 
17
17
  <p align="center">
18
- <code>pi install npm:pai-acp</code>
18
+ <code>pi install npm:billion-context-pi</code>
19
19
  </p>
20
20
 
21
21
  ---
22
22
 
23
- ## 为什么选择 ACP
23
+ ## 为什么选择 billion-context
24
24
 
25
- 当对话变长,模型的上下文会耗尽。多数工具采用硬截断 —— 静默丢弃早期消息。**ACP** 把 `compress` 工具交给模型:由 LLM 决定**何时**压缩、压缩**什么**,将内容压缩成高保真摘要,在回收上下文空间的同时保留关键细节(文件路径、决策、错误字符串)。
25
+ 当对话变长,模型的上下文会耗尽。多数工具采用硬截断 —— 静默丢弃早期消息。**billion-context** 把 `compress` 工具交给模型:由 LLM 决定**何时**压缩、压缩**什么**,将内容压缩成高保真摘要,在回收上下文空间的同时保留关键细节(文件路径、决策、错误字符串)。
26
26
 
27
- 与 Pi 内置的自动压缩(把所有内容替换成单个摘要)不同,ACP:
27
+ 与 Pi 内置的自动压缩(把所有内容替换成单个摘要)不同,billion-context:
28
28
 
29
29
  - **保留结构** — 压缩的范围变成带标签的块,可后续解压
30
30
  - **多级压缩** — 摘要可被进一步蒸馏(T1 → T2 → T3),随会话增长保持有界
@@ -39,19 +39,19 @@
39
39
  ## 安装
40
40
 
41
41
  ```bash
42
- pi install npm:pai-acp
42
+ pi install npm:billion-context-pi
43
43
  ```
44
44
 
45
45
  完成。扩展在下次 Pi 启动时自动加载。无需配置 —— 它会自动读取模型的上下文窗口。
46
46
 
47
- > **建议先卸载 `pi-subagents`(可选,推荐)。** pai-acp 自带 `acp_delegate` 子代理工具(见下文),以极低的上下文成本(~600 tok vs ~7K tok/轮)替代 pi-subagents。如果你已安装 pi-subagents,卸载它以避免重复的委派工具:
47
+ > **建议先卸载 `pi-subagents`(可选,推荐)。** billion-context-pi 自带 `acp_delegate` 子代理工具(见下文),以极低的上下文成本(~600 tok vs ~7K tok/轮)替代 pi-subagents。如果你已安装 pi-subagents,卸载它以避免重复的委派工具:
48
48
  > ```bash
49
49
  > pi remove npm:pi-subagents
50
50
  > ```
51
51
 
52
52
  ## 工作原理
53
53
 
54
- ACP 拦截 Pi 的 `context` 事件(每次 LLM 调用前触发),运行一个 8 阶段管线:
54
+ billion-context 拦截 Pi 的 `context` 事件(每次 LLM 调用前触发),运行一个 8 阶段管线:
55
55
 
56
56
  ```
57
57
  assign refs → sync blocks → prune → filter → hide calls → recommend → nudge → emergency truncate
@@ -59,7 +59,17 @@ assign refs → sync blocks → prune → filter → hide calls → recommend
59
59
 
60
60
  每条消息获得一个不可见的 `<acp>` 引用标签(`m00001`、`m00002`、...),对模型可见但用户不可见。模型用这些引用来指定压缩范围。
61
61
 
62
- Pi 内置的自动压缩会被取消 —— ACP 是唯一的上下文管理者。
62
+ Pi 内置的自动压缩会被取消 —— billion-context 是唯一的上下文管理者。
63
+
64
+ ## 插件兼容性与排序
65
+
66
+ billion-context 通过拦截 Pi 的 `context` 事件接管上下文管理。**Pi 没有插件优先级机制** —— 当多个扩展为同一个事件注册 handler 时,它们按固定顺序(加载顺序)执行,没有 `priority`/`weight` 字段,用户也无法控制顺序。`context` 事件尤其是一个*管线*:每个 handler 都接收上一个 handler 的输出,没有短路,**最后一个** handler 对发给模型的内容拥有最终决定权。
67
+
68
+ 这带来两个实际影响:
69
+
70
+ 1. **只保留一个上下文压缩插件。** 如果同时运行两个压缩插件(例如 billion-context-pi 和另一个),它们都会改写消息列表、互相覆盖 —— 已压缩的范围可能被重新展开或破坏。Pi 的内置自动压缩已由 billion-context-pi 自动取消,但任何*第三方*压缩/compaction 扩展都应卸载。
71
+
72
+ 2. **即使只有一个压缩插件,在少数情况下仍可能出现干扰。** Pi 下的加载顺序由文件系统发现顺序(`fs.readdirSync` 遍历 `.pi/extensions/` → 全局 → 包)决定,并不完全确定。如果另一个(非压缩类)扩展也 hook 了 `context` 事件、且恰好加载在 billion-context-pi *之后*,它可能修改压缩后的输出。billion-context-pi 从会话日志重建工作集(而非链式输入),这让它对*排在它之前*的 handler 鲁棒 —— 但无法防御*排在它之后*的 handler。这是 Pi 扩展模型的固有限制;若你观察到上下文行为异常,请检查是否有其他已安装扩展拦截了 `context` 事件。
63
73
 
64
74
  ## 模型工具
65
75
 
@@ -100,7 +110,7 @@ Pi 内置的自动压缩会被取消 —— ACP 是唯一的上下文管理者
100
110
  ╭─────────────────────────────────────────────╮
101
111
  │ ACP Context Analysis │
102
112
  ╰─────────────────────────────────────────────╯
103
- pai-acp@0.1.14
113
+ billion-context-pi@0.1.14
104
114
 
105
115
  Context: 12% (120K / 1.0M)
106
116
  Growth: +15K since last nudge
@@ -120,7 +130,7 @@ Blocks: 3 active (3.7K summary, 15.2K original compressed)
120
130
 
121
131
  ## 配置
122
132
 
123
- pai-acp 开箱即用,无需任何配置。可以在 JSON 配置文件中设置三个可选 key。
133
+ billion-context-pi 开箱即用,无需任何配置。可以在 JSON 配置文件中设置三个可选 key。
124
134
 
125
135
  ### 配置文件
126
136
 
@@ -131,7 +141,9 @@ pai-acp 开箱即用,无需任何配置。可以在 JSON 配置文件中设置
131
141
  "debug": false,
132
142
  "autoUpdate": true,
133
143
  "modelContextLimit": 200000,
134
- "delegate": true
144
+ "delegate": true,
145
+ "toolBashDefaultTimeout": 60,
146
+ "toolOutputMaxBytes": 200000
135
147
  }
136
148
  ```
137
149
 
@@ -141,8 +153,10 @@ pai-acp 开箱即用,无需任何配置。可以在 JSON 配置文件中设置
141
153
  | `autoUpdate` | `true` | Pi 启动时检查 npm 是否有更新版本并自动安装(限频:每 3 分钟最多一次检查)。禁用以避免所有启动时的网络请求。 |
142
154
  | `modelContextLimit` | *(自动)* | 覆盖上下文上限(token 数)。默认为模型的 `contextWindow`。 |
143
155
  | `delegate` | `true` | 启用 `acp_delegate` 工具(delegate/wait/cancel)及其系统提示词段落。设为 `false` 则不注册这些工具(例如你用了别的子代理扩展,或跑 headless 场景异步注入没有意义)。 |
156
+ | `toolBashDefaultTimeout` | `60` | 当模型未指定 `timeout` 时注入 `bash` 工具的超时秒数。Pi **本身没有默认超时**,不加这个,一次遗漏的超时可能挂起几千秒。超时后会提示模型用更大的 `timeout` 重跑。设为 `0` 恢复 Pi 的无界行为。 |
157
+ | `toolOutputMaxBytes` | `200000` | 工具结果文本硬上限(字节,约 5000 行 @ ~40 字节/行,通过 `tool_result` hook 应用)。用于兜住 Pi 自身 50KB/2000 行截断管不到的输出(例如 Pi 未加限制的工具)。触发截断时会告诉模型如何查看完整输出——对 `bash`,完整输出在其临时文件(`BashToolDetails.fullOutputPath`)中;设更小(如 `8192`)可更省上下文,设 `0` 关闭。 |
144
158
 
145
- > **只有这四个 key 会被 `acp.json` 读取。** 其他调优参数(`preserveRecentMessages`、`protectedTools`、nudge 阈值)是代码级的,不向用户开放。
159
+ > **只有这六个 key 会被 `acp.json` 读取。** 其他调优参数(`preserveRecentMessages`、`protectedTools`、nudge 阈值)是代码级的,不向用户开放。
146
160
 
147
161
  ### 环境变量
148
162
 
@@ -158,7 +172,7 @@ pai-acp 开箱即用,无需任何配置。可以在 JSON 配置文件中设置
158
172
 
159
173
  ### 哪些内容会被保护
160
174
 
161
- ACP 保护三类内容不被压缩:
175
+ billion-context 保护三类内容不被压缩:
162
176
 
163
177
  1. **永久保护的工具** — `compress` 调用被硬保护(它们是承载关键元数据的;压缩它们会破坏 decompress 和"摘要是历史"的契约)。
164
178
  2. **软近期区** — 最后 N 条消息(默认 5)和最后约 5K token 被软保护,让模型保留工作集。来自 `decompress`、`search_context`、`read`、`bash` 的工具结果被**排除**出此区:它们体量大、消费后就该能压缩,所以不该占用保护预算。
package/dist/config.d.ts CHANGED
@@ -20,6 +20,24 @@ export interface AdapterConfig {
20
20
  * section. Default: true. Set `delegate: false` (adapter config or
21
21
  * ~/.pi/acp.json) to skip registering them. */
22
22
  delegate?: boolean;
23
+ /** Default timeout in seconds injected into the bash tool when the model
24
+ * omits `timeout`. Pi has NO built-in default, so without this a command
25
+ * that the model forgets to time out can hang for thousands of seconds.
26
+ * Default: 60 (catches hangs quickly). On timeout the model is guided to
27
+ * re-run with a larger `timeout`. Set to 0 to disable (restore Pi's
28
+ * unbounded behavior). */
29
+ toolBashDefaultTimeout?: number;
30
+ /** Hard byte cap applied to tool result text via the `tool_result` hook.
31
+ * Default: 200000 (~200KB, roughly 5000 lines at ~40 bytes/line) — a
32
+ * generous ceiling that stops runaway output. Pi already caps bash/read/grep
33
+ * at 50KB/2000 lines (bash full output is saved to a temp file), so this
34
+ * default mainly caps tools Pi doesn't cap. Set lower (e.g. 8192) for a
35
+ * tighter context budget, or 0 to disable. When capped, oversized text is
36
+ * head-truncated with a notice telling the model how to see the full output
37
+ * (bash: read BashToolDetails.fullOutputPath). */
38
+ toolOutputMaxBytes?: number;
23
39
  coreOverrides?: Partial<Config>;
24
40
  }
41
+ export declare const DEFAULT_TOOL_BASH_TIMEOUT = 60;
42
+ export declare const DEFAULT_TOOL_OUTPUT_MAX_BYTES = 200000;
25
43
  export declare function resolveConfig(adapter: AdapterConfig, liveContextLimit: number): Config;
package/dist/index.js CHANGED
@@ -611,9 +611,13 @@ var TAG_CLOSE = LT + "/acp" + GT;
611
611
  function acpTag(ref, tokens, type) {
612
612
  return TAG_OPEN + 'tokens="' + formatTokens(tokens) + '" type="' + type + '"' + GT + ref + TAG_CLOSE;
613
613
  }
614
- function renderMessage(message, map, countTokens) {
614
+ function renderMessage(message, map, countTokens, strategy) {
615
615
  const ref = refForRaw(map, message.id);
616
616
  if (!ref || ref === BLOCKED_REF) return message;
617
+ if (strategy === "none") return message;
618
+ if (strategy === "text-only" && message.contentType !== "text") {
619
+ return message;
620
+ }
617
621
  const ownTagRe = new RegExp(
618
622
  "^" + escapeRegex(TAG_OPEN) + "[^>]*" + GT + escapeRegex(ref) + escapeRegex(TAG_CLOSE) + "\\n?"
619
623
  );
@@ -624,21 +628,24 @@ function renderMessage(message, map, countTokens) {
624
628
  if (!cleanText) return { ...message, text: prefix };
625
629
  return { ...message, text: prefix + cleanText };
626
630
  }
627
- function renderVisibleRefs(messages, state, countTokens = (text) => Math.ceil(text.length / 4)) {
631
+ function renderVisibleRefs(messages, state, countTokens = (text) => Math.ceil(text.length / 4), strategy = "all") {
628
632
  const map = state.messageRefs;
629
633
  return messages.map(
630
- (message) => renderMessage(message, map, countTokens)
634
+ (message) => renderMessage(message, map, countTokens, strategy)
631
635
  );
632
636
  }
633
- var renderRefsNode = {
634
- name: "render-refs",
635
- run(io, ctx) {
636
- return {
637
- ...io,
638
- messages: renderVisibleRefs(io.messages, io.state, ctx.countTokens)
639
- };
640
- }
641
- };
637
+ function createRenderRefsNode(strategy) {
638
+ return {
639
+ name: "render-refs",
640
+ run(io, ctx) {
641
+ return {
642
+ ...io,
643
+ messages: renderVisibleRefs(io.messages, io.state, ctx.countTokens, strategy)
644
+ };
645
+ }
646
+ };
647
+ }
648
+ var renderRefsNode = createRenderRefsNode("all");
642
649
  var ALWAYS_PROTECTED_TOOLS = ["compress"];
643
650
  var NEVER_PRESERVE_RECENT_TOOLS = [
644
651
  "decompress",
@@ -1009,7 +1016,9 @@ function createCore(ports = {}) {
1009
1016
  state: input.state,
1010
1017
  effects: {}
1011
1018
  };
1012
- const result = runPipeline(defaultNodes(), initial, ctx);
1019
+ const strategy = input.renderTags ?? "all";
1020
+ const nodes = buildNodes(strategy);
1021
+ const result = runPipeline(nodes, initial, ctx);
1013
1022
  return {
1014
1023
  messages: result.messages,
1015
1024
  state: result.state,
@@ -1039,7 +1048,10 @@ function createCore(ports = {}) {
1039
1048
  };
1040
1049
  }
1041
1050
  function defaultNodes() {
1042
- return [
1051
+ return buildNodes("all");
1052
+ }
1053
+ function buildNodes(strategy) {
1054
+ const base = [
1043
1055
  assignRefsNode,
1044
1056
  syncBlocksNode,
1045
1057
  pruneNode,
@@ -1047,9 +1059,10 @@ function createCore(ports = {}) {
1047
1059
  hideCompressCallsNode,
1048
1060
  recommendNode,
1049
1061
  nudgeNode,
1050
- emergencyTruncateNode,
1051
- renderRefsNode
1062
+ emergencyTruncateNode
1052
1063
  ];
1064
+ if (strategy === "none") return base;
1065
+ return [...base, createRenderRefsNode(strategy)];
1053
1066
  }
1054
1067
  return { processTurn, applyCompression, defaultNodes, decompress, search, status };
1055
1068
  }
@@ -2342,6 +2355,8 @@ function makePreview(text, query, len) {
2342
2355
  }
2343
2356
 
2344
2357
  // src/config.ts
2358
+ var DEFAULT_TOOL_BASH_TIMEOUT = 60;
2359
+ var DEFAULT_TOOL_OUTPUT_MAX_BYTES = 2e5;
2345
2360
  function resolveConfig(adapter, liveContextLimit) {
2346
2361
  const envLimit = process.env.ACP_MODEL_CONTEXT_LIMIT;
2347
2362
  const envLimitNum = envLimit ? Number(envLimit) : NaN;
@@ -2359,7 +2374,15 @@ var REF_TAG = new RegExp("^(?:<acp\\s[^>]*>m\\d{5}</acp>|\\[m\\d{1,5}\\])\\s?\\n
2359
2374
  function entriesToCoreMessages(entries) {
2360
2375
  const out = [];
2361
2376
  for (const entry of entries) {
2362
- if (entry.type !== "message") continue;
2377
+ if (entry.type !== "message") {
2378
+ if (entry.type === "custom_message") {
2379
+ const text = extractText(entry.content);
2380
+ if (text.length > 0) {
2381
+ out.push({ id: entry.id, role: "user", contentType: "text", text });
2382
+ }
2383
+ }
2384
+ continue;
2385
+ }
2363
2386
  const cores = projectMessage(entry.message, entry.id);
2364
2387
  out.push(...cores);
2365
2388
  }
@@ -7183,7 +7206,7 @@ async function handleCompress(args, runtime, ctx, toolCallId) {
7183
7206
  config
7184
7207
  });
7185
7208
  await runtime.save(applied.state, ctx);
7186
- const { blocksCreated, tokensCompressed, errors } = applied.result;
7209
+ const { blocksCreated, tokensCompressed, errors, warnings } = applied.result;
7187
7210
  const afterTokens = Math.max(0, beforeTokens - tokensCompressed);
7188
7211
  const newBlocks = applied.state.blocks.slice(-blocksCreated);
7189
7212
  debug.event("compress-out", {
@@ -7200,6 +7223,7 @@ async function handleCompress(args, runtime, ctx, toolCallId) {
7200
7223
  newBlocks: newBlocks.map((b) => ({ blockId: b.blockId, tier: b.tier, summaryLen: b.summary.length, directMsgCount: b.directMessageIds.length, effectiveMsgCount: b.effectiveMessageIds.length, summary: b.summary }))
7201
7224
  });
7202
7225
  const lines = [`\u25A3 ACP | ${formatK2(beforeTokens)} \u2192 ${formatK2(afterTokens)} tokens (~${formatK2(tokensCompressed)} reclaimed, ${blocksCreated} block${blocksCreated > 1 ? "s" : ""})`];
7226
+ if (warnings.length > 0) lines.push("\u26A0\uFE0F " + warnings.join("; "));
7203
7227
  if (errors.length > 0) lines.push("Errors: " + errors.join("; "));
7204
7228
  return lines.join("\n");
7205
7229
  }
@@ -8185,7 +8209,7 @@ async function statusReport(runtime, ctx) {
8185
8209
  const activeBlocksList = state.blocks.filter((b) => b.active);
8186
8210
  const totalBlocksList = state.blocks;
8187
8211
  const lines = [];
8188
- const versionStr = "0.0.6" ? `billion-context-pi@${"0.0.6"}` : "";
8212
+ const versionStr = "0.1.25" ? `billion-context-pi@${"0.1.25"}` : "";
8189
8213
  lines.push("\u256D\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256E");
8190
8214
  lines.push("\u2502 ACP Context Analysis \u2502");
8191
8215
  lines.push("\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256F");
@@ -8327,6 +8351,116 @@ When a background delegate finishes, an automated completion notification is inj
8327
8351
  - Arrive asynchronously: if you have moved on to other work, only act on a notification if it is relevant to the current task; otherwise note it and continue.
8328
8352
  `;
8329
8353
 
8354
+ // src/tool-guardrails.ts
8355
+ import {
8356
+ isBashToolResult,
8357
+ isToolCallEventType
8358
+ } from "@earendil-works/pi-coding-agent";
8359
+ function resolveBashTimeout(input, defaultTimeout) {
8360
+ if (input.timeout !== void 0) return void 0;
8361
+ const d = defaultTimeout ?? DEFAULT_TOOL_BASH_TIMEOUT;
8362
+ if (!Number.isFinite(d) || d <= 0) return void 0;
8363
+ return d;
8364
+ }
8365
+ function capToolOutput(content, maxBytes, fullPath) {
8366
+ const max = maxBytes ?? DEFAULT_TOOL_OUTPUT_MAX_BYTES;
8367
+ if (!Number.isFinite(max) || max <= 0) return void 0;
8368
+ const kept = [];
8369
+ const texts = [];
8370
+ for (const c of content) {
8371
+ if (c.type === "text") texts.push(c.text);
8372
+ else kept.push(c);
8373
+ }
8374
+ if (texts.length === 0) return void 0;
8375
+ const combined = texts.join("\n");
8376
+ const total = Buffer.byteLength(combined, "utf8");
8377
+ if (total <= max) return void 0;
8378
+ const head = keepHead(combined, max);
8379
+ const dropped = total - Buffer.byteLength(head, "utf8");
8380
+ kept.push({ type: "text", text: head + buildCapNotice(dropped, max, fullPath) });
8381
+ return kept;
8382
+ }
8383
+ var TIMEOUT_RE = /Command timed out after (\d+) seconds/;
8384
+ function detectBashTimeout(content) {
8385
+ for (const c of content) {
8386
+ if (c.type !== "text") continue;
8387
+ const m = c.text.match(TIMEOUT_RE);
8388
+ if (m) return Number(m[1]);
8389
+ }
8390
+ return void 0;
8391
+ }
8392
+ function appendTimeoutNotice(content, secs) {
8393
+ const notice = buildTimeoutNotice(secs);
8394
+ const next = [...content];
8395
+ for (let i = next.length - 1; i >= 0; i--) {
8396
+ const part = next[i];
8397
+ if (part && part.type === "text") {
8398
+ next[i] = { type: "text", text: part.text + notice };
8399
+ return next;
8400
+ }
8401
+ }
8402
+ next.push({ type: "text", text: notice });
8403
+ return next;
8404
+ }
8405
+ function keepHead(str, maxBytes) {
8406
+ const buf = Buffer.from(str, "utf8");
8407
+ if (buf.length <= maxBytes) return str;
8408
+ let end = maxBytes;
8409
+ while (end > 0) {
8410
+ const b = buf[end];
8411
+ if (b === void 0 || (b & 192) !== 128) break;
8412
+ end--;
8413
+ }
8414
+ let head = buf.subarray(0, end).toString("utf8");
8415
+ const nl = head.lastIndexOf("\n");
8416
+ if (nl >= Math.floor(maxBytes / 2)) head = head.slice(0, nl);
8417
+ return head;
8418
+ }
8419
+ function buildCapNotice(dropped, maxBytes, fullPath) {
8420
+ const where = fullPath ? `Full output saved to: ${fullPath} \u2014 read it to see everything.` : "To see more, narrow the query or redirect output to a file and read the relevant slice.";
8421
+ return `
8422
+
8423
+ [ACP guardrail: output capped at ${formatBytes(maxBytes)} (~${formatBytes(dropped)} dropped). ${where}]`;
8424
+ }
8425
+ function buildTimeoutNotice(secs) {
8426
+ const suggested = Math.min(Math.max(Math.ceil(secs * 2), 120), 3600);
8427
+ return `
8428
+
8429
+ [ACP guardrail: command killed after ${secs}s. To give it more time, re-run the bash tool with a larger \`timeout\` argument (e.g. \`"timeout": ${suggested}\`).]`;
8430
+ }
8431
+ function formatBytes(n) {
8432
+ return n >= 1024 ? `${(n / 1024).toFixed(1)}KB` : `${n}B`;
8433
+ }
8434
+ function wireToolGuardrails(pi, runtime) {
8435
+ pi.on("tool_call", (event) => {
8436
+ if (!isToolCallEventType("bash", event)) return;
8437
+ const t = resolveBashTimeout(event.input, runtime.adapter.toolBashDefaultTimeout);
8438
+ if (t !== void 0) {
8439
+ event.input.timeout = t;
8440
+ debug.event("guardrail-bash-timeout", { applied: t });
8441
+ }
8442
+ });
8443
+ pi.on("tool_result", (event) => {
8444
+ const isBash = isBashToolResult(event);
8445
+ const fullPath = isBash ? event.details?.fullOutputPath : void 0;
8446
+ const timeoutSecs = isBash && event.isError ? detectBashTimeout(event.content) : void 0;
8447
+ let modified;
8448
+ const max = runtime.adapter.toolOutputMaxBytes;
8449
+ if (max !== void 0 && max > 0) {
8450
+ const next = capToolOutput(event.content, max, fullPath);
8451
+ if (next) {
8452
+ modified = next;
8453
+ debug.event("guardrail-output-cap", { max, hadPath: !!fullPath });
8454
+ }
8455
+ }
8456
+ if (timeoutSecs !== void 0) {
8457
+ modified = appendTimeoutNotice(modified ?? event.content, timeoutSecs);
8458
+ debug.event("guardrail-bash-timeout-notice", { secs: timeoutSecs });
8459
+ }
8460
+ if (modified) return { content: modified };
8461
+ });
8462
+ }
8463
+
8330
8464
  // src/update.ts
8331
8465
  import { readFile, writeFile as writeFile3, mkdir as mkdir3 } from "fs/promises";
8332
8466
  import { join as join5, dirname as dirname3 } from "path";
@@ -8433,7 +8567,7 @@ async function checkForUpdate(autoUpdate, notify) {
8433
8567
  const data = await res.json();
8434
8568
  const latest = data.version;
8435
8569
  if (!latest) return;
8436
- const current = runtimeVersion ?? "0.0.6";
8570
+ const current = runtimeVersion ?? "0.1.25";
8437
8571
  debug.event("update-check", {
8438
8572
  current,
8439
8573
  latest,
@@ -8613,7 +8747,7 @@ async function loadUserConfig(cwd) {
8613
8747
  function join8(...parts) {
8614
8748
  return path3.join(...parts);
8615
8749
  }
8616
- var KNOWN = /* @__PURE__ */ new Set(["debug", "autoUpdate", "modelContextLimit", "delegate"]);
8750
+ var KNOWN = /* @__PURE__ */ new Set(["debug", "autoUpdate", "modelContextLimit", "delegate", "toolBashDefaultTimeout", "toolOutputMaxBytes"]);
8617
8751
  function pickKnown(parsed) {
8618
8752
  const out = {};
8619
8753
  for (const [k, v] of Object.entries(parsed)) {
@@ -8641,6 +8775,7 @@ function createAcpExtension(adapter = {}) {
8641
8775
  wireSessionLifecycle(pi, runtime);
8642
8776
  wireContextTransform(pi, runtime);
8643
8777
  wireSystemPrompt(pi, runtime);
8778
+ wireToolGuardrails(pi, runtime);
8644
8779
  pi.registerTool(makeCompressTool(runtime));
8645
8780
  pi.registerTool(makeDecompressTool(runtime));
8646
8781
  pi.registerTool(makeSearchTool(runtime));
@@ -8767,7 +8902,12 @@ ${prompt}` };
8767
8902
  function collectOriginals(entries) {
8768
8903
  const map = /* @__PURE__ */ new Map();
8769
8904
  for (const entry of entries) {
8770
- if (entry.type === "message") map.set(entry.id, entry.message);
8905
+ if (entry.type === "message") {
8906
+ map.set(entry.id, entry.message);
8907
+ } else if (entry.type === "custom_message") {
8908
+ const content = typeof entry.content === "string" ? [{ type: "text", text: entry.content }] : entry.content;
8909
+ map.set(entry.id, { role: "user", content });
8910
+ }
8771
8911
  }
8772
8912
  return map;
8773
8913
  }