billion-context-pi-lean 0.1.56-lean.2
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/LICENSE +21 -0
- package/README.md +81 -0
- package/README.zh-CN.md +79 -0
- package/index.ts +266 -0
- package/package.json +32 -0
- package/tests/facade.test.mjs +307 -0
- package/tsconfig.json +14 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 kunkun9527
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# billion-context-pi-lean
|
|
2
|
+
|
|
3
|
+
[简体中文](README.zh-CN.md)
|
|
4
|
+
|
|
5
|
+
A lightweight Pi wrapper for [`billion-context-pi`](https://github.com/ranxianglei/billion-context-pi). It retains the upstream context compression engine while eliminating persistent system prompt bloat and redundant tool schemas.
|
|
6
|
+
|
|
7
|
+
## Core Features
|
|
8
|
+
|
|
9
|
+
* Full compression engine: Keeps upstream context compression, hierarchical summaries, nudges, overflow recovery, retry handling, and tool protection.
|
|
10
|
+
* Streamlined tool surface: Exposes the high-frequency `compress` tool directly, while consolidating `decompress`, `search_context`, and `acp_status` into an on-demand `acp_context` interface.
|
|
11
|
+
* Focused and clean: Disables built-in delegation and auto-updates by design. For delegation workflows, pair with a dedicated subagent extension.
|
|
12
|
+
|
|
13
|
+
Upstream dependency is pinned to `billion-context-pi@0.1.56` for reliable behavior.
|
|
14
|
+
|
|
15
|
+
## Context Savings
|
|
16
|
+
|
|
17
|
+
In a local comparison against `billion-context-pi@0.1.52` with delegation disabled, persistent ACP prompt and tool metadata dropped from roughly 22,645 characters to 2,859 characters, achieving an approximate 87% reduction in static text overhead. Actual token savings may vary based on model tokenizer and prompt caching behavior.
|
|
18
|
+
|
|
19
|
+
## Installation
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pi install git:github.com/kunkun9527/billion-context-pi-lean
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Or install from a local clone:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
git clone https://github.com/kunkun9527/billion-context-pi-lean.git
|
|
29
|
+
cd billion-context-pi-lean
|
|
30
|
+
npm install
|
|
31
|
+
pi install ./
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Do not load this wrapper alongside another `billion-context-pi` extension to prevent registering duplicate tools or hooks.
|
|
35
|
+
|
|
36
|
+
## Usage
|
|
37
|
+
|
|
38
|
+
The model interacts with two tools:
|
|
39
|
+
|
|
40
|
+
```text
|
|
41
|
+
compress
|
|
42
|
+
acp_context
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
`acp_context` supports `decompress`, `search_context`, `acp_status`, and `help`.
|
|
46
|
+
|
|
47
|
+
```json
|
|
48
|
+
{
|
|
49
|
+
"op": "search_context",
|
|
50
|
+
"args": {
|
|
51
|
+
"query": "authentication"
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Use `help` only when you need to inspect the full upstream schema.
|
|
57
|
+
|
|
58
|
+
## Context Footprint Benchmark
|
|
59
|
+
|
|
60
|
+
With only this extension enabled, its recurring initialization overhead in the model context is:
|
|
61
|
+
|
|
62
|
+
| Item | Lean | Upstream `billion-context-pi@0.1.52` |
|
|
63
|
+
| --- | ---: | ---: |
|
|
64
|
+
| `compress` | 231 | 549 |
|
|
65
|
+
| Context operations | `acp_context`: 90 | `decompress` + `search_context` + `acp_status`: 1,095 |
|
|
66
|
+
| System prompt additions | 369 | 4,417 |
|
|
67
|
+
| **Total** | **690** | **6,061** |
|
|
68
|
+
|
|
69
|
+
This saves **5,371 tokens (88.6%)** compared to the pinned upstream package.
|
|
70
|
+
The benchmark was measured on Pi 0.84.4 with `pi-context-view@0.4.3` in a fresh isolated session, excluding built-in tools, skills, context files, and unrelated extensions. Context View estimates tokens as `ceil(characters / 4)`. Pure runtime UI elements and slash commands are excluded as they are not sent to the model.
|
|
71
|
+
|
|
72
|
+
## Development
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
npm ci
|
|
76
|
+
npm run check
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## License
|
|
80
|
+
|
|
81
|
+
MIT. This wrapper builds on the MIT-licensed [`billion-context-pi`](https://github.com/ranxianglei/billion-context-pi).
|
package/README.zh-CN.md
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# billion-context-pi-lean
|
|
2
|
+
|
|
3
|
+
[English](README.md)
|
|
4
|
+
|
|
5
|
+
基于 [`billion-context-pi`](https://github.com/ranxianglei/billion-context-pi) 的精简封装。在完整保留上下文压缩引擎的同时,大幅剔除系统提示词与工具 Schema 中的冗余文本,显著降低上下文初始开销。
|
|
6
|
+
|
|
7
|
+
## 核心特性
|
|
8
|
+
|
|
9
|
+
* 完整上下文引擎:保留上游的对话历史压缩、分层摘要、主动提示(Nudge)、溢出恢复、重试机制与工具保护能力。
|
|
10
|
+
* 优化工具布局:高频使用的 `compress` 工具直接对外暴露,其余 `decompress`、`search_context` 与 `acp_status` 操作整合为按需调用的 `acp_context` 入口。
|
|
11
|
+
* 纯粹专注:主动移除了内置的 Delegation 代理分发与自动更新逻辑;如需多 Agent 协作,建议搭配独立的 Subagent 扩展使用。上游依赖版本锁定为 `billion-context-pi@0.1.56`,确保运行稳定可靠。
|
|
12
|
+
|
|
13
|
+
## 精简优化成效
|
|
14
|
+
|
|
15
|
+
在关闭 Delegation 的情况下,与原版 `billion-context-pi@0.1.52` 本地对比:固定的 ACP 系统提示词与工具元数据字符量从约 22,645 字符大幅缩减至 2,859 字符,常驻静态文本减少约 87%。实际 Token 节省情况会因模型分词器及 Prompt 缓存机制而略有差异。
|
|
16
|
+
|
|
17
|
+
## 安装
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
pi install git:github.com/kunkun9527/billion-context-pi-lean
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
也可以通过本地克隆进行安装:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
git clone https://github.com/kunkun9527/billion-context-pi-lean.git
|
|
27
|
+
cd billion-context-pi-lean
|
|
28
|
+
npm install
|
|
29
|
+
pi install ./
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
请勿与其它 `billion-context-pi` 扩展同时加载,以防重复注册 ACP 工具与生命周期 Hooks。
|
|
33
|
+
|
|
34
|
+
## 使用方法
|
|
35
|
+
|
|
36
|
+
模型可见工具包括:
|
|
37
|
+
|
|
38
|
+
```text
|
|
39
|
+
compress
|
|
40
|
+
acp_context
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
`acp_context` 支持 `decompress`、`search_context`、`acp_status` 和 `help`。
|
|
44
|
+
|
|
45
|
+
```json
|
|
46
|
+
{
|
|
47
|
+
"op": "search_context",
|
|
48
|
+
"args": {
|
|
49
|
+
"query": "authentication"
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
仅在确需查看完整上游 Schema 时调用 `help`。
|
|
55
|
+
|
|
56
|
+
## 初始化上下文占用对比
|
|
57
|
+
|
|
58
|
+
单独启用本扩展时,注入到模型初始上下文中的 Token 占用实测如下:
|
|
59
|
+
|
|
60
|
+
| 项目 | Lean 精简版 | 原版 `billion-context-pi@0.1.52` |
|
|
61
|
+
| --- | ---: | ---: |
|
|
62
|
+
| `compress` | 231 | 549 |
|
|
63
|
+
| 上下文检索与操作 | `acp_context`: 90 | `decompress` + `search_context` + `acp_status`: 1,095 |
|
|
64
|
+
| 系统提示词增量 | 369 | 4,417 |
|
|
65
|
+
| **合计** | **690** | **6,061** |
|
|
66
|
+
|
|
67
|
+
相比固定版本的上游扩展,初始开销减少了 **5,371 tokens(88.6%)**。
|
|
68
|
+
测试环境为 Pi 0.84.4 与 `pi-context-view@0.4.3` 独立会话,排除了 Pi 内置工具、Skills、上下文文件与无关扩展。Context View 按 `ceil(字符数 / 4)` 估算。未计入不会发送给模型的纯运行时 UI 与 Slash 命令。
|
|
69
|
+
|
|
70
|
+
## 本地开发
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
npm ci
|
|
74
|
+
npm run check
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## 开源协议与致谢
|
|
78
|
+
|
|
79
|
+
MIT 协议。本包装层基于采用 MIT 协议的 [`billion-context-pi`](https://github.com/ranxianglei/billion-context-pi)。
|
package/index.ts
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
// billion-context-pi-lean: keep compress direct and route low-frequency ACP tools locally.
|
|
2
|
+
import type {
|
|
3
|
+
ExtensionAPI,
|
|
4
|
+
ToolDefinition,
|
|
5
|
+
} from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { createAcpExtension } from "billion-context-pi";
|
|
7
|
+
import { Type } from "typebox";
|
|
8
|
+
import { Check, Errors } from "typebox/value";
|
|
9
|
+
|
|
10
|
+
const COLLAPSED_DISPLAY_SERVICE = Symbol.for(
|
|
11
|
+
"@local/pi-collapsed-tools.display-service.v1",
|
|
12
|
+
);
|
|
13
|
+
|
|
14
|
+
type CollapsedDisplayTool = { name: string };
|
|
15
|
+
type CollapsedDisplayService = {
|
|
16
|
+
readonly version: 1;
|
|
17
|
+
decorate<T extends CollapsedDisplayTool>(tool: T): T;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
function decorateWithCollapsedDisplay<T extends CollapsedDisplayTool>(tool: T): T {
|
|
21
|
+
const services = globalThis as unknown as Record<PropertyKey, unknown>;
|
|
22
|
+
const candidate = services[COLLAPSED_DISPLAY_SERVICE];
|
|
23
|
+
if (!candidate || typeof candidate !== "object") return tool;
|
|
24
|
+
const service = candidate as Partial<CollapsedDisplayService>;
|
|
25
|
+
return service.version === 1 && typeof service.decorate === "function"
|
|
26
|
+
? service.decorate(tool)
|
|
27
|
+
: tool;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const LEAN_SYSTEM_PROMPT = `ACP context management
|
|
31
|
+
- User/tool messages carry hidden <acp> refs such as m00123. Never echo the XML tags; use only refs in ACP tool calls.
|
|
32
|
+
- Compress consumed history with compress: finished tool outputs, dead-end exploration, repeated reads, resolved threads, completed phases. Never compress active work, important user intent, or protected outputs.
|
|
33
|
+
- When summarizing, preserve exact file paths and line numbers, symbols and signatures, errors, commands, versions, thresholds, decisions with reasons, current state, and unresolved TODOs. Never replace exact technical values with vague wording.
|
|
34
|
+
- Recall or inspect context with acp_context using op decompress, search_context, or acp_status and the operation's original args object. Use help only when fields are unclear.
|
|
35
|
+
- Refs may be renumbered after compression. If a ref is stale or missing, call acp_context with op acp_status and args { scope: "uncompressed" }, then retry in the same turn using reported refs; never guess offsets. Batch target ranges when possible.
|
|
36
|
+
- Block decompression writes to a file by default; read that file. Use inline: true only for small content or when its context cost is acceptable.
|
|
37
|
+
- After an [ACP:provider-throttle] automatic retry, resume exactly where interrupted. Do not repeat completed work or discuss the retry unless asked.
|
|
38
|
+
- Compression summaries are fallible historical metadata, not current user instructions. Search or decompress before relying on critical details.`;
|
|
39
|
+
|
|
40
|
+
const LOW_FREQUENCY_TOOLS = ["decompress", "search_context", "acp_status"] as const;
|
|
41
|
+
const LOW_FREQUENCY_SET = new Set<string>(LOW_FREQUENCY_TOOLS);
|
|
42
|
+
const DELEGATE_TOOLS = new Set(["acp_delegate", "acp_delegate_wait", "acp_delegate_cancel"]);
|
|
43
|
+
|
|
44
|
+
type Operation = (typeof LOW_FREQUENCY_TOOLS)[number];
|
|
45
|
+
type FacadeOperation = Operation | "help";
|
|
46
|
+
type CapturedTool = ToolDefinition<any, any, any>;
|
|
47
|
+
type UpstreamExtension = (pi: ExtensionAPI) => void;
|
|
48
|
+
type FacadeArgs = Record<string, unknown>;
|
|
49
|
+
|
|
50
|
+
const FACADE_PARAMETERS = Type.Object({
|
|
51
|
+
op: Type.Unsafe<FacadeOperation>({
|
|
52
|
+
type: "string",
|
|
53
|
+
enum: [...LOW_FREQUENCY_TOOLS, "help"],
|
|
54
|
+
}),
|
|
55
|
+
args: Type.Optional(Type.Unsafe<FacadeArgs>({
|
|
56
|
+
type: "object",
|
|
57
|
+
additionalProperties: true,
|
|
58
|
+
})),
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const COMPRESS_FIELD_DESCRIPTIONS: Readonly<Record<string, string>> = {
|
|
62
|
+
content: "Direct array; no JSON strings/nesting/mix.",
|
|
63
|
+
startId: "Inclusive first mNNNNN or bN ref.",
|
|
64
|
+
endId: "Inclusive last mNNNNN or bN ref.",
|
|
65
|
+
summary: "Self-contained replacement preserving exact technical details.",
|
|
66
|
+
topic: "Short label; a per-range label overrides the top-level fallback.",
|
|
67
|
+
summaryMaxChars: "Optional summary length limit override.",
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
function compactCompressSchemaDescriptions(
|
|
71
|
+
value: unknown,
|
|
72
|
+
propertyName?: string,
|
|
73
|
+
seen = new Set<object>(),
|
|
74
|
+
): void {
|
|
75
|
+
if (value === null || typeof value !== "object" || seen.has(value)) return;
|
|
76
|
+
seen.add(value);
|
|
77
|
+
const record = value as Record<string, unknown>;
|
|
78
|
+
delete record.description;
|
|
79
|
+
const concise = propertyName ? COMPRESS_FIELD_DESCRIPTIONS[propertyName] : undefined;
|
|
80
|
+
if (concise) record.description = concise;
|
|
81
|
+
|
|
82
|
+
for (const [key, child] of Object.entries(record)) {
|
|
83
|
+
if (key === "properties" && child !== null && typeof child === "object") {
|
|
84
|
+
for (const [name, schema] of Object.entries(child as Record<string, unknown>)) {
|
|
85
|
+
compactCompressSchemaDescriptions(schema, name, seen);
|
|
86
|
+
}
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
compactCompressSchemaDescriptions(child, undefined, seen);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function appendSystemPrompt(base: unknown): string {
|
|
94
|
+
const text = Array.isArray(base) ? base.join("\n") : typeof base === "string" ? base : "";
|
|
95
|
+
return `${text}\n\n${LEAN_SYSTEM_PROMPT}`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function rewriteText(text: string): string {
|
|
99
|
+
return text
|
|
100
|
+
.replace(
|
|
101
|
+
/\b(search_context|decompress|acp_status)\((\{[^\r\n]*\})\)/g,
|
|
102
|
+
(_, op: string, args: string) => `acp_context({ op: "${op}", args: ${args} })`,
|
|
103
|
+
)
|
|
104
|
+
.replace(
|
|
105
|
+
/\brun acp_status\b/gi,
|
|
106
|
+
'call acp_context({ op: "acp_status", args: {} })',
|
|
107
|
+
)
|
|
108
|
+
.replace(
|
|
109
|
+
/\buse search_context or decompress\b/gi,
|
|
110
|
+
'use acp_context with op "search_context" or "decompress"',
|
|
111
|
+
)
|
|
112
|
+
.replace(
|
|
113
|
+
/\buse search_context\b/gi,
|
|
114
|
+
'use acp_context with op "search_context"',
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function rewriteModelFacingText(value: unknown, seen = new WeakMap<object, unknown>()): unknown {
|
|
119
|
+
if (typeof value === "string") return rewriteText(value);
|
|
120
|
+
if (value === null || typeof value !== "object") return value;
|
|
121
|
+
const cached = seen.get(value);
|
|
122
|
+
if (cached) return cached;
|
|
123
|
+
|
|
124
|
+
if (Array.isArray(value)) {
|
|
125
|
+
const copy: unknown[] = [];
|
|
126
|
+
seen.set(value, copy);
|
|
127
|
+
for (const item of value) copy.push(rewriteModelFacingText(item, seen));
|
|
128
|
+
return copy;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const copy: Record<string, unknown> = {};
|
|
132
|
+
seen.set(value, copy);
|
|
133
|
+
for (const [key, item] of Object.entries(value)) {
|
|
134
|
+
copy[key] = rewriteModelFacingText(item, seen);
|
|
135
|
+
}
|
|
136
|
+
return copy;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function validationError(tool: CapturedTool, args: FacadeArgs): string | undefined {
|
|
140
|
+
if (!tool.parameters || Check(tool.parameters, args)) return undefined;
|
|
141
|
+
const first = Errors(tool.parameters, args)[0];
|
|
142
|
+
const location = first?.instancePath || "/";
|
|
143
|
+
const reason = first?.message || "arguments do not match the upstream schema";
|
|
144
|
+
return `${location}: ${reason}`;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function helpResult(args: FacadeArgs, tools: Map<string, CapturedTool>) {
|
|
148
|
+
const requested = typeof args.tool === "string" ? args.tool : "";
|
|
149
|
+
if (!requested) {
|
|
150
|
+
return {
|
|
151
|
+
content: [{
|
|
152
|
+
type: "text" as const,
|
|
153
|
+
text: "Operations: search_context({query, limit?}), decompress({blockId, full?, toFile?, inline?}), acp_status({scope?, view?, tool?, sort?, limit?}). For a full schema, use op help with args {tool: operationName}.",
|
|
154
|
+
}],
|
|
155
|
+
details: { operation: "help" },
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
if (!LOW_FREQUENCY_SET.has(requested)) {
|
|
159
|
+
throw new Error(`acp_context help operation is unknown: ${requested}`);
|
|
160
|
+
}
|
|
161
|
+
const tool = tools.get(requested);
|
|
162
|
+
if (!tool) throw new Error(`acp_context could not find upstream tool: ${requested}`);
|
|
163
|
+
return {
|
|
164
|
+
content: [{
|
|
165
|
+
type: "text" as const,
|
|
166
|
+
text: [
|
|
167
|
+
requested,
|
|
168
|
+
tool.description,
|
|
169
|
+
"Pass args matching this schema:",
|
|
170
|
+
JSON.stringify(tool.parameters, null, 2),
|
|
171
|
+
].filter(Boolean).join("\n\n"),
|
|
172
|
+
}],
|
|
173
|
+
details: { operation: requested },
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function wrapCompress(tool: CapturedTool): CapturedTool {
|
|
178
|
+
return {
|
|
179
|
+
...tool,
|
|
180
|
+
description: "Replace consumed conversation ranges with self-contained summaries using mNNNNN or bN refs.",
|
|
181
|
+
promptSnippet: "",
|
|
182
|
+
promptGuidelines: [],
|
|
183
|
+
async execute(callId, params, signal, onUpdate, ctx) {
|
|
184
|
+
const forwardUpdate = onUpdate
|
|
185
|
+
? (update: unknown) => onUpdate(rewriteModelFacingText(update) as any)
|
|
186
|
+
: undefined;
|
|
187
|
+
const result = await tool.execute(callId, params, signal, forwardUpdate, ctx);
|
|
188
|
+
return rewriteModelFacingText(result) as any;
|
|
189
|
+
},
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function facadeTool(tools: Map<string, CapturedTool>): ToolDefinition<typeof FACADE_PARAMETERS, unknown, unknown> {
|
|
194
|
+
return {
|
|
195
|
+
name: "acp_context",
|
|
196
|
+
label: "ACP Context",
|
|
197
|
+
description: 'Search, restore, or inspect ACP context. Pass original operation args; if unsure call { op: "help", args: { tool: "search_context" } } (replace tool as needed).',
|
|
198
|
+
promptSnippet: "",
|
|
199
|
+
promptGuidelines: [],
|
|
200
|
+
parameters: FACADE_PARAMETERS,
|
|
201
|
+
async execute(callId, params, signal, onUpdate, ctx) {
|
|
202
|
+
if (params.op === "help") return helpResult(params.args ?? {}, tools);
|
|
203
|
+
const tool = tools.get(params.op);
|
|
204
|
+
if (!tool) throw new Error(`acp_context could not find upstream tool: ${params.op}`);
|
|
205
|
+
const args = params.args ?? {};
|
|
206
|
+
const invalid = validationError(tool, args);
|
|
207
|
+
if (invalid) {
|
|
208
|
+
throw new Error(
|
|
209
|
+
`acp_context ${params.op} args invalid at ${invalid}. Use op help with args {"tool":"${params.op}"} for the full schema.`,
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
const forwardUpdate = onUpdate
|
|
213
|
+
? (update: unknown) => onUpdate(rewriteModelFacingText(update) as any)
|
|
214
|
+
: undefined;
|
|
215
|
+
const result = await tool.execute(callId, args, signal, forwardUpdate, ctx);
|
|
216
|
+
return rewriteModelFacingText(result) as any;
|
|
217
|
+
},
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export function createLeanAcpExtension(
|
|
222
|
+
upstream: UpstreamExtension = createAcpExtension({ delegate: false, autoUpdate: false }),
|
|
223
|
+
): (pi: ExtensionAPI) => void {
|
|
224
|
+
return (pi: ExtensionAPI): void => {
|
|
225
|
+
const tools = new Map<string, CapturedTool>();
|
|
226
|
+
let promptHookRegistered = false;
|
|
227
|
+
const leanPi = new Proxy(pi, {
|
|
228
|
+
get(target, property, receiver) {
|
|
229
|
+
if (property === "registerTool") {
|
|
230
|
+
return (tool: CapturedTool) => {
|
|
231
|
+
if (DELEGATE_TOOLS.has(tool.name)) return;
|
|
232
|
+
if (LOW_FREQUENCY_SET.has(tool.name)) {
|
|
233
|
+
tools.set(tool.name, tool);
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
if (tool.name === "compress") {
|
|
237
|
+
compactCompressSchemaDescriptions(tool.parameters);
|
|
238
|
+
target.registerTool(decorateWithCollapsedDisplay(wrapCompress(tool)));
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
target.registerTool(decorateWithCollapsedDisplay(tool));
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
if (property === "on") {
|
|
245
|
+
return (eventName: string, handler: (...args: unknown[]) => unknown) => {
|
|
246
|
+
if (eventName !== "before_agent_start") {
|
|
247
|
+
return Reflect.apply(target.on, target, [eventName, handler]);
|
|
248
|
+
}
|
|
249
|
+
if (promptHookRegistered) return;
|
|
250
|
+
promptHookRegistered = true;
|
|
251
|
+
return Reflect.apply(target.on, target, [eventName, (event: { systemPrompt?: unknown }) => ({
|
|
252
|
+
systemPrompt: appendSystemPrompt(event.systemPrompt),
|
|
253
|
+
})]);
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
const value = Reflect.get(target, property, receiver);
|
|
257
|
+
return typeof value === "function" ? value.bind(target) : value;
|
|
258
|
+
},
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
upstream(leanPi);
|
|
262
|
+
pi.registerTool(decorateWithCollapsedDisplay(facadeTool(tools)));
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export default createLeanAcpExtension();
|
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "billion-context-pi-lean",
|
|
3
|
+
"version": "0.1.56-lean.2",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Token-lean Pi facade for billion-context-pi with the full upstream context engine.",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/kunkun9527/billion-context-pi-lean.git"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/kunkun9527/billion-context-pi-lean#readme",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/kunkun9527/billion-context-pi-lean/issues"
|
|
14
|
+
},
|
|
15
|
+
"scripts": {
|
|
16
|
+
"test": "node --test tests/*.test.mjs",
|
|
17
|
+
"typecheck": "tsc -p tsconfig.json",
|
|
18
|
+
"check": "npm run typecheck && npm test"
|
|
19
|
+
},
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"billion-context-pi": "0.1.56",
|
|
22
|
+
"typebox": "1.3.7"
|
|
23
|
+
},
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"typescript": "5.9.3"
|
|
26
|
+
},
|
|
27
|
+
"pi": {
|
|
28
|
+
"extensions": [
|
|
29
|
+
"./index.ts"
|
|
30
|
+
]
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { createJiti } from "../node_modules/@earendil-works/pi-coding-agent/node_modules/jiti/lib/jiti.mjs";
|
|
4
|
+
|
|
5
|
+
const jiti = createJiti(import.meta.url, { moduleCache: false });
|
|
6
|
+
const extensionModule = await jiti.import("../index.ts");
|
|
7
|
+
const extension = extensionModule.default ?? extensionModule;
|
|
8
|
+
const { createLeanAcpExtension } = extensionModule;
|
|
9
|
+
|
|
10
|
+
function createPi() {
|
|
11
|
+
const tools = [];
|
|
12
|
+
const handlers = new Map();
|
|
13
|
+
const noOp = () => undefined;
|
|
14
|
+
return new Proxy({
|
|
15
|
+
tools,
|
|
16
|
+
handlers,
|
|
17
|
+
registerTool(tool) {
|
|
18
|
+
tools.push(tool);
|
|
19
|
+
},
|
|
20
|
+
on(event, handler) {
|
|
21
|
+
const listeners = handlers.get(event) ?? [];
|
|
22
|
+
listeners.push(handler);
|
|
23
|
+
handlers.set(event, listeners);
|
|
24
|
+
},
|
|
25
|
+
registerCommand: noOp,
|
|
26
|
+
registerShortcut: noOp,
|
|
27
|
+
registerMessageRenderer: noOp,
|
|
28
|
+
registerProvider: noOp,
|
|
29
|
+
registerFlag: noOp,
|
|
30
|
+
getFlag: () => undefined,
|
|
31
|
+
getActiveTools: () => [],
|
|
32
|
+
getAllTools: () => [],
|
|
33
|
+
setActiveTools: noOp,
|
|
34
|
+
appendEntry: noOp,
|
|
35
|
+
sendMessage: noOp,
|
|
36
|
+
events: { on: noOp, emit: noOp },
|
|
37
|
+
}, {
|
|
38
|
+
get(target, property) {
|
|
39
|
+
return property in target ? target[property] : noOp;
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const schemas = {
|
|
45
|
+
compress: {
|
|
46
|
+
type: "object",
|
|
47
|
+
description: "large outer description",
|
|
48
|
+
properties: {
|
|
49
|
+
topic: { type: "string", description: "large top-level topic description" },
|
|
50
|
+
content: {
|
|
51
|
+
type: "array",
|
|
52
|
+
description: "large nested description",
|
|
53
|
+
items: {
|
|
54
|
+
type: "object",
|
|
55
|
+
properties: {
|
|
56
|
+
startId: { type: "string", description: "large start description" },
|
|
57
|
+
endId: { type: "string", description: "large end description" },
|
|
58
|
+
summary: { type: "string", description: "large summary description" },
|
|
59
|
+
topic: { type: "string", description: "large range topic description" },
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
summaryMaxChars: { type: "number", description: "large limit description" },
|
|
64
|
+
},
|
|
65
|
+
required: ["content"],
|
|
66
|
+
},
|
|
67
|
+
decompress: {
|
|
68
|
+
type: "object",
|
|
69
|
+
properties: {
|
|
70
|
+
blockId: { type: "string" },
|
|
71
|
+
full: { type: "boolean" },
|
|
72
|
+
toFile: { type: "string" },
|
|
73
|
+
inline: { type: "boolean" },
|
|
74
|
+
},
|
|
75
|
+
required: ["blockId"],
|
|
76
|
+
},
|
|
77
|
+
search_context: {
|
|
78
|
+
type: "object",
|
|
79
|
+
properties: {
|
|
80
|
+
query: { type: "string" },
|
|
81
|
+
limit: { type: "number" },
|
|
82
|
+
},
|
|
83
|
+
required: ["query"],
|
|
84
|
+
},
|
|
85
|
+
acp_status: {
|
|
86
|
+
type: "object",
|
|
87
|
+
properties: {
|
|
88
|
+
scope: { type: "string" },
|
|
89
|
+
view: { type: "string" },
|
|
90
|
+
tool: { type: "string" },
|
|
91
|
+
sort: { type: "string" },
|
|
92
|
+
limit: { type: "number" },
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
function fakeUpstream(calls = [], executions = { count: 0 }) {
|
|
98
|
+
return (pi) => {
|
|
99
|
+
for (const name of ["compress", "decompress", "search_context", "acp_status"]) {
|
|
100
|
+
pi.registerTool({
|
|
101
|
+
name,
|
|
102
|
+
label: name,
|
|
103
|
+
description: `${name} full upstream description`,
|
|
104
|
+
parameters: structuredClone(schemas[name]),
|
|
105
|
+
async execute(...args) {
|
|
106
|
+
executions.count += 1;
|
|
107
|
+
calls.push([name, ...args]);
|
|
108
|
+
return {
|
|
109
|
+
content: [{
|
|
110
|
+
type: "text",
|
|
111
|
+
text: name === "search_context"
|
|
112
|
+
? 'result → decompress({ blockId: "b12" })'
|
|
113
|
+
: name === "compress"
|
|
114
|
+
? 'Nothing to do — Run acp_status, then call compress again. Use search_context or decompress to retrieve details. Try search_context({ query: "auth token" }).'
|
|
115
|
+
: `${name} result`,
|
|
116
|
+
}],
|
|
117
|
+
};
|
|
118
|
+
},
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
pi.registerTool({ name: "acp_delegate", async execute() {} });
|
|
122
|
+
pi.on("before_agent_start", () => ({ systemPrompt: "upstream prompt must be suppressed" }));
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function getTool(pi, name) {
|
|
127
|
+
return pi.tools.find((tool) => tool.name === name);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
test("registers only compress and acp_context provider tools", () => {
|
|
131
|
+
const pi = createPi();
|
|
132
|
+
createLeanAcpExtension(fakeUpstream())(pi);
|
|
133
|
+
assert.deepEqual(pi.tools.map((tool) => tool.name), ["compress", "acp_context"]);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("cooperates with an optional collapsed-display service without a local file dependency", () => {
|
|
137
|
+
const serviceKey = Symbol.for("@local/pi-collapsed-tools.display-service.v1");
|
|
138
|
+
const previous = globalThis[serviceKey];
|
|
139
|
+
const decorated = [];
|
|
140
|
+
globalThis[serviceKey] = {
|
|
141
|
+
version: 1,
|
|
142
|
+
decorate(tool) {
|
|
143
|
+
decorated.push(tool.name);
|
|
144
|
+
return tool;
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
try {
|
|
149
|
+
const pi = createPi();
|
|
150
|
+
createLeanAcpExtension(fakeUpstream())(pi);
|
|
151
|
+
assert.deepEqual(decorated, ["compress", "acp_context"]);
|
|
152
|
+
} finally {
|
|
153
|
+
if (previous === undefined) delete globalThis[serviceKey];
|
|
154
|
+
else globalThis[serviceKey] = previous;
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("keeps compress direct, retains concise critical descriptions, and rewrites stale status advice", async () => {
|
|
159
|
+
const calls = [];
|
|
160
|
+
const pi = createPi();
|
|
161
|
+
createLeanAcpExtension(fakeUpstream(calls))(pi);
|
|
162
|
+
const tool = getTool(pi, "compress");
|
|
163
|
+
const result = await tool.execute(
|
|
164
|
+
"compress-1",
|
|
165
|
+
{ content: [{ startId: "m1", endId: "m2", summary: "done" }] },
|
|
166
|
+
undefined,
|
|
167
|
+
undefined,
|
|
168
|
+
{ cwd: "C:/work" },
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
const schema = tool.parameters;
|
|
172
|
+
const entries = schema.properties.content.items.properties;
|
|
173
|
+
assert.equal(schema.description, undefined);
|
|
174
|
+
assert.match(schema.properties.content.description, /direct array/i);
|
|
175
|
+
assert.match(schema.properties.content.description, /JSON strings|JSON-stringify/i);
|
|
176
|
+
assert.match(schema.properties.content.description, /mix/i);
|
|
177
|
+
assert.match(schema.properties.topic.description, /label/i);
|
|
178
|
+
assert.match(schema.properties.summaryMaxChars.description, /length/i);
|
|
179
|
+
assert.match(entries.startId.description, /first/i);
|
|
180
|
+
assert.match(entries.endId.description, /last/i);
|
|
181
|
+
assert.match(entries.summary.description, /exact technical details/i);
|
|
182
|
+
assert.match(entries.topic.description, /label/i);
|
|
183
|
+
assert.doesNotMatch(JSON.stringify(schema), /large .* description/);
|
|
184
|
+
const rewritten = result.content[0].text;
|
|
185
|
+
assert.match(rewritten, /acp_context\(\{ op: "acp_status", args: \{\} \}\)/);
|
|
186
|
+
assert.match(rewritten, /use acp_context with op "search_context" or "decompress"/i);
|
|
187
|
+
assert.match(rewritten, /acp_context\(\{ op: "search_context", args: \{ query: "auth token" \} \}\)/);
|
|
188
|
+
assert.doesNotMatch(rewritten, /\b(?:search_context|decompress|acp_status)\s*\(/);
|
|
189
|
+
assert.doesNotMatch(rewritten, /\brun acp_status\b/i);
|
|
190
|
+
assert.doesNotMatch(rewritten, /\buse search_context\b/i);
|
|
191
|
+
assert.equal(calls[0][0], "compress");
|
|
192
|
+
assert.deepEqual(calls[0][2], { content: [{ startId: "m1", endId: "m2", summary: "done" }] });
|
|
193
|
+
});
|
|
194
|
+
test("routes all three low-frequency operations with original args and execution context", async () => {
|
|
195
|
+
const cases = [
|
|
196
|
+
["search_context", { query: "architecture", limit: 8 }],
|
|
197
|
+
["decompress", { blockId: "b11", toFile: "R:/Temp/history.txt", full: true }],
|
|
198
|
+
["acp_status", { scope: "uncompressed", view: "messages", sort: "size", limit: 30 }],
|
|
199
|
+
];
|
|
200
|
+
|
|
201
|
+
for (const [op, args] of cases) {
|
|
202
|
+
const calls = [];
|
|
203
|
+
const pi = createPi();
|
|
204
|
+
createLeanAcpExtension(fakeUpstream(calls))(pi);
|
|
205
|
+
const signal = new AbortController().signal;
|
|
206
|
+
const context = { cwd: "C:/work" };
|
|
207
|
+
await getTool(pi, "acp_context").execute(
|
|
208
|
+
`call-${op}`,
|
|
209
|
+
{ op, args },
|
|
210
|
+
signal,
|
|
211
|
+
undefined,
|
|
212
|
+
context,
|
|
213
|
+
);
|
|
214
|
+
assert.equal(calls[0][0], op);
|
|
215
|
+
assert.equal(calls[0][1], `call-${op}`);
|
|
216
|
+
assert.deepEqual(calls[0][2], args);
|
|
217
|
+
assert.equal(calls[0][3], signal);
|
|
218
|
+
assert.equal(calls[0][5], context);
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test("rewrites search results to the facade call syntax", async () => {
|
|
223
|
+
const pi = createPi();
|
|
224
|
+
createLeanAcpExtension(fakeUpstream())(pi);
|
|
225
|
+
const result = await getTool(pi, "acp_context").execute(
|
|
226
|
+
"search-1",
|
|
227
|
+
{ op: "search_context", args: { query: "needle" } },
|
|
228
|
+
);
|
|
229
|
+
assert.equal(
|
|
230
|
+
result.content[0].text,
|
|
231
|
+
'result → acp_context({ op: "decompress", args: { blockId: "b12" } })',
|
|
232
|
+
);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test("facade advertises help syntax and discloses an upstream schema without executing it", async () => {
|
|
236
|
+
const executions = { count: 0 };
|
|
237
|
+
const pi = createPi();
|
|
238
|
+
createLeanAcpExtension(fakeUpstream([], executions))(pi);
|
|
239
|
+
const tool = getTool(pi, "acp_context");
|
|
240
|
+
assert.match(
|
|
241
|
+
tool.description,
|
|
242
|
+
/\{ op: "help", args: \{ tool: "search_context" \} \}/,
|
|
243
|
+
);
|
|
244
|
+
const result = await tool.execute(
|
|
245
|
+
"help-1",
|
|
246
|
+
{ op: "help", args: { tool: "decompress" } },
|
|
247
|
+
);
|
|
248
|
+
assert.equal(executions.count, 0);
|
|
249
|
+
assert.match(result.content[0].text, /blockId/);
|
|
250
|
+
assert.match(result.content[0].text, /toFile/);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test("invalid facade args fail locally with an on-demand help hint", async () => {
|
|
254
|
+
const executions = { count: 0 };
|
|
255
|
+
const pi = createPi();
|
|
256
|
+
createLeanAcpExtension(fakeUpstream([], executions))(pi);
|
|
257
|
+
await assert.rejects(
|
|
258
|
+
getTool(pi, "acp_context").execute(
|
|
259
|
+
"invalid-1",
|
|
260
|
+
{ op: "decompress", args: { inline: true } },
|
|
261
|
+
),
|
|
262
|
+
/args invalid.*op help.*decompress/,
|
|
263
|
+
);
|
|
264
|
+
assert.equal(executions.count, 0);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
test("suppresses the upstream prompt and keeps concise summary-fidelity rules", async () => {
|
|
268
|
+
const pi = createPi();
|
|
269
|
+
createLeanAcpExtension(fakeUpstream())(pi);
|
|
270
|
+
const hooks = pi.handlers.get("before_agent_start");
|
|
271
|
+
assert.equal(hooks.length, 1);
|
|
272
|
+
const result = await hooks[0]({ systemPrompt: "base prompt" });
|
|
273
|
+
assert.match(result.systemPrompt, /^base prompt/);
|
|
274
|
+
assert.match(result.systemPrompt, /Compress consumed history with compress/);
|
|
275
|
+
assert.match(result.systemPrompt, /exact file paths and line numbers/i);
|
|
276
|
+
assert.match(result.systemPrompt, /symbols and signatures/i);
|
|
277
|
+
assert.match(result.systemPrompt, /errors, commands, versions, thresholds/i);
|
|
278
|
+
assert.match(result.systemPrompt, /decisions with reasons, current state, and unresolved TODOs/i);
|
|
279
|
+
assert.match(result.systemPrompt, /Refs may be renumbered after compression/i);
|
|
280
|
+
assert.match(result.systemPrompt, /Block decompression writes to a file by default/i);
|
|
281
|
+
assert.match(result.systemPrompt, /provider-throttle.*resume exactly where interrupted/i);
|
|
282
|
+
assert.match(result.systemPrompt, /acp_context/);
|
|
283
|
+
assert.doesNotMatch(result.systemPrompt, /upstream prompt must be suppressed/);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
test("real upstream registration exposes the intended two-tool surface", () => {
|
|
287
|
+
const pi = createPi();
|
|
288
|
+
extension(pi);
|
|
289
|
+
assert.deepEqual(pi.tools.map((tool) => tool.name), ["compress", "acp_context"]);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test("provider-facing metadata stays within the lean budget", () => {
|
|
293
|
+
const pi = createPi();
|
|
294
|
+
extension(pi);
|
|
295
|
+
const metadata = pi.tools.map((tool) => ({
|
|
296
|
+
name: tool.name,
|
|
297
|
+
description: tool.description,
|
|
298
|
+
parameters: tool.parameters,
|
|
299
|
+
promptSnippet: tool.promptSnippet,
|
|
300
|
+
promptGuidelines: tool.promptGuidelines,
|
|
301
|
+
}));
|
|
302
|
+
const serialized = JSON.stringify(metadata);
|
|
303
|
+
assert.ok(
|
|
304
|
+
serialized.length <= 1450,
|
|
305
|
+
`lean metadata grew to ${serialized.length} characters`,
|
|
306
|
+
);
|
|
307
|
+
});
|
package/tsconfig.json
ADDED