Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ All keys below are currently **ACTIVE**.
| `ACP_MODEL_CONTEXT_LIMIT` | Override the context limit (takes highest precedence). |
| `ACP_DEBUG` | Set to `1` / `true` to enable debug logging. |
| `ACP_LOG_FILE` | Override the log file path (default `~/.pi/acp.log`). |
| `ACP_COOPERATIVE_PROXY` | Set to `0` to disable cooperative proxy mode (auto-detected from a `/bili/` model baseUrl or the launcher's `BILLION_CONTEXT_PROXY` env). |

> **Only the documented keys are read from `acp.json`.** Other tuning knobs (`preserveRecentMessages`, `protectedTools`) are code-level and not user-overridable. The three compression thresholds form a three-tier escalation: growth-driven soft nudges → forced nudges at `compress.maxContextLimit` → emergency truncation at `compress.emergencyThresholdPercent`.

Expand Down Expand Up @@ -326,3 +327,10 @@ Environment variables take precedence over the JSON config files. They are usefu
- **Default:** `~/.pi/acp.log`
- **Status:** 🟢 ACTIVE
- **Description:** Override the path to the log file. By default, structured logs are written to `~/.pi/acp.log` (the file rotates to `~/.pi/acp.log.old` at 10 MB). Point this at a different location to keep per-project or per-run logs separate.

### `ACP_COOPERATIVE_PROXY`

- **Type:** string flag
- **Default:** *(unset — cooperative mode auto-detects from the model baseUrl or launcher env)*
- **Status:** 🟢 ACTIVE
- **Description:** Set to `0` to **disable** cooperative proxy mode. Cooperative mode engages when the model's `baseUrl` routes through a billion-context proxy (the `/bili/` zero-config prefix) **or** when the billion-context launcher started pi (`BILLION_CONTEXT_PROXY` is exported next to `HTTPS_PROXY` in MITM mode). The proxy then owns compression (state, folding, ref tags, philosophy prompt, nudges — injected at the wire level), while this extension registers the 4 tools natively and forwards their execution to the proxy's `POST /__bili/plugin/tool` endpoint. Session identity is pi's real session id (sent as `x-bili-plugin-conversation`), which also fixes multi-session collisions; the model's context window is reported via `x-bili-plugin-context-window` and becomes the authoritative nudge denominator on the proxy side. Setting `ACP_COOPERATIVE_PROXY=0` forces the standalone in-process behavior even behind a proxy (then make sure the proxy's own tool injection handles compression — double compression is otherwise possible). Protocol spec: [PLUGIN.md](https://github.com/ranxianglei/billion-context/blob/master/PLUGIN.md).
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,19 @@ Each message gets an invisible `<acp>` ref tag (`m00001`, `m00002`, ...) visible

Pi's built-in auto-compaction is cancelled — billion-context is the sole context manager.

## Cooperative proxy mode (内外呼应)

When pi's model `baseUrl` routes through a [billion-context](https://github.com/ranxianglei/billion-context) proxy (the `/bili/` zero-config prefix), the extension automatically switches to **cooperative mode**: the proxy owns compression end-to-end (session state, history folding, ref tags, philosophy prompt, nudges — all injected at the wire level), while the extension becomes the "inside" half of [the plugin protocol](https://github.com/ranxianglei/billion-context/blob/master/PLUGIN.md):

- the 4 tools (`compress` / `decompress` / `search_context` / `acp_status`) stay natively registered in pi — native tool UX, permissions and audit — and their execution is forwarded to the proxy (`POST /__bili/plugin/tool`), which runs them under its session lock;
- every model request carries `x-bili-plugin: pi` + `x-bili-plugin-conversation: <pi session id>`, so the proxy keys state by pi's **real** session identity (no more content-fingerprint collisions) and suppresses its own wire-level tool injection (no double compression);
- the model's context window is reported from inside pi (`x-bili-plugin-context-window`) — pinned/overridden values the proxy's registry can't know become the authoritative nudge denominator;
- the extension's in-process pipeline (processTurn, nudges, philosophy prompt) is skipped for that session — the proxy does all of it.

Detection works in both proxy modes: the `/bili/` prefix in the model baseUrl, **and MITM transparent mode** — `bili pi` (the billion-context launcher) exports `BILLION_CONTEXT_PROXY` next to `HTTPS_PROXY`, and the extension trusts it directly, so `bili pi` now gives you the native-tool cooperative experience too.

Without a proxy (or with `ACP_COOPERATIVE_PROXY=0`) behavior is byte-identical to the standalone extension.

## Plugin compatibility & ordering

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.
Expand Down
31 changes: 31 additions & 0 deletions devlog/2026-08-16_cooperative-proxy-mode/DESIGN.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# DESIGN - Cooperative proxy mode

- Task ID: `2026-08-16_cooperative-proxy-mode`
- Home Repo: `billion-context-pi`
- Created: 2026-08-16
- Status: Accepted

## 1. Goals & Non-Goals

- **Goals**: proxy-owns-compression when behind the proxy; native tools forwarded; real session identity; zero change when standalone.
- **Non-Goals**: MITM detection; config keys; changes to delegate tools (they keep working — their prompt is injected locally even in cooperative mode).

## 2. Mechanism

Detection is **stateless and per-call**: `proxyBaseForContext(ctx)` reads `ctx.model.baseUrl` (typed on pi-ai's `Model`) and matches a `bili` path segment (`http://host[:port]/bili/...` → `http://host[:port]`). No cached mode flag → model switches mid-session are picked up on the very next call. `ACP_COOPERATIVE_PROXY=0` short-circuits detection off.

Four integration points:

1. **`before_provider_headers`** (pi SDK hook, types.d.ts:869 — headers mutate-in-place): sets `x-bili-plugin: pi` + `x-bili-plugin-conversation: ctx.sessionManager.getSessionId()`. No-op unless proxied.
2. **`context` event**: early return `{ messages: event.messages }` (identity) — the proxy runs processTurn (tags/folding/nudges) at the wire level; running it locally too would double-transform.
3. **`before_agent_start`**: skip `buildAcpSystemPrompt` (the proxy injects the philosophy); keep `ACP_DELEGATE_PROMPT` (pi-side feature) when delegate enabled; return `undefined` otherwise.
4. **Tool `execute` (×4)**: `tryForwardTool(name, params, ctx)` → POST `${proxyBase}/__bili/plugin/tool` `{conversationId, tool, args}` → return `result` as the native tool-result text. `undefined` return = not proxied → local handler runs unchanged. Proxy errors throw (consistent with local handlers, which re-throw after logThrow).

`session_before_compact` cancel stays unconditional: pi's compaction must never run — the proxy manages context in cooperative mode too.

## 3. Alternatives considered

- **Cached mode flag set on `session_start`/`model_select`**: rejected — stale on mid-session model switches; per-call read is O(len(url)) and always fresh.
- **Fetch patching for headers**: rejected — pi-ai owns the HTTP stack; the SDK provides the exact hook (`before_provider_headers`).
- **Forwarding inside the `handleX` functions**: rejected — the forward must bypass local state entirely (the proxy executes against ITS remembered view); putting it at the top of `execute` makes that structural.
- **Keeping local compression when proxied**: rejected — double compression; exactly what the protocol exists to prevent.
25 changes: 25 additions & 0 deletions devlog/2026-08-16_cooperative-proxy-mode/REQ.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# REQ - Cooperative proxy mode (内外呼应)

- Task ID: `2026-08-16_cooperative-proxy-mode`
- Home Repo: `billion-context-pi`
- Created: 2026-08-16
- Status: Done
- Priority: P1
- Owner: awork
- References: dog/billion-context#1, ranxianglei/billion-context#161 (protocol half), PLUGIN.md spec in billion-context

## 1. Background & Problem Statement

- **Context**: billion-context issue #1 asks for inside/outside cooperation: keep the external proxy but install a plugin inside the agent so the combination feels native. The proxy-side protocol (`/__bili/plugin/manifest`, `/__bili/plugin/tool`, `x-bili-plugin` headers) landed in billion-context PR #161. This repo is the fully in-process implementation — the natural first "inside" adopter.
- **Current behavior (symptom)**: the extension is either/or: it runs its own in-process pipeline, and the only proxy-related behavior is mutual exclusion by convention (users must disable one side manually).
- **Expected behavior**: when the model baseUrl routes through the proxy (`/bili/` prefix), the extension keeps its 4 native tools but forwards execution to the proxy, skips its own in-process pipeline, and announces pi's real session identity. Without a proxy, byte-identical standalone behavior.
- **Impact**: native tool UX + real session identity + zero schema drift (proxy serves schemas), with the proxy as single compression authority.

## 2. Reproduction

N/A (feature).

## 3. Constraints & Non-Goals

- **Constraints**: no behavior change without a `/bili/` baseUrl; no `as any`; no comments unless necessary; pi SDK hooks only (no fetch patching).
- **Non-Goals**: changing the delegate subsystem (kept working in cooperative mode via its own prompt); MITM-mode proxy detection (no `/bili/` prefix to detect — out of scope for v1); a config-file key for cooperative mode (env kill-switch suffices for v1).
40 changes: 40 additions & 0 deletions devlog/2026-08-16_cooperative-proxy-mode/WORKLOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# WORKLOG - Cooperative proxy mode

- Task ID: `2026-08-16_cooperative-proxy-mode`
- Home Repo: `billion-context-pi`
- Status: Done
- Updated: 2026-08-16 02:55

## 1. Summary

- **What was done**: Added cooperative proxy mode ("有外用外"): when the model baseUrl routes through a billion-context proxy (`/bili/` prefix), the extension forwards its 4 native tools to the proxy's tool endpoint, announces pi's session identity via `x-bili-plugin*` headers, and skips its own in-process pipeline. Standalone behavior unchanged.
- **Why**: billion-context issue #1 (内外呼应) — native tool UX + real session identity with the proxy as single compression authority. First adopter of the plugin protocol shipped in ranxianglei/billion-context#161.
- **Behavior / compatibility changes**: Yes, additive. Without `/bili/` in the model baseUrl (or with `ACP_COOPERATIVE_PROXY=0`) every path is byte-identical to before.
- **Risk level**: Low (detection is a pure URL check per call; all gated branches early-return).

## 2. Change Log

### Key Files

- `src/cooperative.ts` (new) — `proxyBaseFromUrl` / `proxyBaseForContext` (stateless per-call detection), `forwardToolToProxy` (POST `/__bili/plugin/tool`, error surfaces), `tryForwardTool` (undefined = run local handler).
- `src/index.ts` — `wireProviderHeaders` (`before_provider_headers` hook); cooperative early-return in `wireContextTransform` (identity messages) and `wireSystemPrompt` (philosophy from proxy, delegate prompt kept).
- `src/compress-tool.ts` / `src/decompress-tool.ts` / `src/search-tool.ts` / `src/status-tool.ts` — `tryForwardTool` branch at top of `execute`.
- `tests/cooperative.test.ts` (new, 7 tests) — URL detection matrix; header injection (+absence without proxy); context identity passthrough; system-prompt ownership; live HTTP forward (conversation id, tool name, args passthrough, result text); proxy error propagation; `ACP_COOPERATIVE_PROXY=0` kill switch (local pipeline runs).
- `README.md` — "Cooperative proxy mode (内外呼应)" section.
- `CONFIGURATION.md` — `ACP_COOPERATIVE_PROXY` env var (summary table + full section).

## 3. Verification

- `npm run typecheck` clean.
- `npm test`: 300 tests, 299 pass + 1 **pre-existing failure on pristine master** (`e2e-compress-config.test.ts`: "a 2w limit fires the compress nudge at 2w tokens" — verified failing on clean master checkout before my changes; unrelated to this feature).
- `npm run build` OK (dist 477 KB).
- New suite: 7/7.

## 4. Notes / Follow-ups

- ~~MITM-mode (transparent proxy, no `/bili/` prefix) is NOT detected~~ — **landed in this PR (follow-up commit)**: the billion-context launcher now exports `BILLION_CONTEXT_PROXY` next to `HTTPS_PROXY` for `bili pi`/`codex`/`claude`; `proxyBaseForContext` falls back to it (trusted without probing — a stale value surfaces as a tool-forward error). MITM cooperative mode works end-to-end.
- `x-bili-plugin-context-window` (new): `before_provider_headers` reports `ctx.model.contextWindow` from inside pi; the proxy treats it as the authoritative native window (outranks its table/registry; operator `compress.modelContextLimit` still wins).
- `GET /__bili/plugin/status?conversationId=` (new proxy endpoint): context-level visibility (contextTokens = input+cache-read, contextLimit, blocks, requests) for plugin status UIs.
- Fixed a usage-application race in the proxy's plugin stream passthrough: usage is applied BEFORE `res.end()` so the client's next request (status fetch / follow-up turn reading `lastInputTokens` for nudges) always sees it.
- `before_provider_headers` exists in the pi SDK as of this version; if an older pi host lacks it, registration is ignored harmlessly (extension `on` overloads).
- Cross-repo dependency: the proxy half must merge first (ranxianglei/billion-context#161) — the endpoints this forwards to only exist there. No code-level version coupling (protocol is HTTP + manifest).
5 changes: 5 additions & 0 deletions src/compress-tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type {
} from "@earendil-works/pi-coding-agent";
import type { AcpRuntime } from "./runtime.js";
import { debug, logError, logInfo, logThrow } from "./log.js";
import { tryForwardTool } from "./cooperative.js";
import { estimateTokens, collectCoveredMessageIds } from "./tokens.js";
import { defaultCountTokens } from "acp-kernel";
import { getSystemPromptText } from "./compat.js";
Expand Down Expand Up @@ -44,6 +45,10 @@ export function makeCompressTool(runtime: AcpRuntime): ToolDefinition<typeof Com
],
parameters: CompressParams,
async execute(toolCallId, params, _signal, _onUpdate, ctx): Promise<AgentToolResult<unknown>> {
const forwarded = await tryForwardTool("compress", params, ctx);
if (forwarded !== undefined) {
return { details: undefined, content: [{ type: "text", text: forwarded }] };
}
let result: string;
try {
result = await handleCompress(params as CompressArgs, runtime, ctx, toolCallId);
Expand Down
79 changes: 79 additions & 0 deletions src/cooperative.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
import { logInfo } from "./log.js";

// Cooperative proxy mode ("内外呼应", billion-context issue #1): when the
// model's baseUrl routes through a billion-context proxy (the `/bili/`
// zero-config prefix), the proxy owns compression end-to-end (state, folding,
// ref tags, philosophy prompt, nudges — it injects them at the wire level).
// This extension then becomes the "inside" half: it announces itself on every
// provider request (x-bili-plugin headers), skips its own in-process
// pipeline, and forwards the 4 native tools to the proxy's
// POST /__bili/plugin/tool. Protocol spec: billion-context PLUGIN.md.
// Without a `/bili/` baseUrl (or with ACP_COOPERATIVE_PROXY=0) behavior is
// byte-identical to the standalone extension.

export const PLUGIN_AGENT_NAME = "pi";

const BILI_SEGMENT = "bili";

export function proxyBaseFromUrl(baseUrl: string | undefined): string | undefined {
if (!baseUrl) return undefined;
try {
const url = new URL(baseUrl);
const segments = url.pathname.split("/").filter((s) => s.length > 0);
if (!segments.includes(BILI_SEGMENT)) return undefined;
return `${url.protocol}//${url.host}`;
} catch {
return undefined;
}
}

export function proxyBaseForContext(ctx: ExtensionContext): string | undefined {
if (process.env.ACP_COOPERATIVE_PROXY === "0") return undefined;
return proxyBaseFromUrl(ctx.model?.baseUrl) ?? proxyBaseFromEnv();
}

/** MITM transparent-proxy mode has no `/bili/` prefix to detect (the baseUrl
* is the real provider URL). The proxy's own launcher (`bili pi`) exports
* BILLION_CONTEXT_PROXY alongside HTTPS_PROXY + the CA vars; trusting it
* requires no probe — a stale value surfaces as a tool-forward error. */
export function proxyBaseFromEnv(): string | undefined {
const raw = process.env.BILLION_CONTEXT_PROXY?.trim();
if (!raw) return undefined;
try {
const url = new URL(raw);
return url.protocol === "http:" || url.protocol === "https:" ? `${url.protocol}//${url.host}` : undefined;
} catch {
return undefined;
}
}

export async function forwardToolToProxy(proxyBase: string, conversationId: string, tool: string, args: unknown): Promise<string> {
const resp = await fetch(`${proxyBase}/__bili/plugin/tool`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ conversationId, tool, args }),
});
const text = await resp.text();
let json: { ok?: boolean; result?: string; error?: string };
try {
json = JSON.parse(text) as { ok?: boolean; result?: string; error?: string };
} catch {
throw new Error(`bili proxy tool ${tool} failed (${resp.status}): ${text.slice(0, 200)}`);
}
if (!resp.ok || !json.ok) {
throw new Error(`bili proxy tool ${tool} failed (${resp.status}): ${json.error ?? "unknown error"}`);
}
return json.result ?? "";
}

/** Forward a tool execution to the proxy when this session is in cooperative
* mode. Returns undefined when NOT behind a proxy (caller runs the local
* handler); returns the proxy's result text otherwise. */
export async function tryForwardTool(tool: string, params: unknown, ctx: ExtensionContext): Promise<string | undefined> {
const proxyBase = proxyBaseForContext(ctx);
if (proxyBase === undefined) return undefined;
const conversationId = ctx.sessionManager.getSessionId();
logInfo("cooperative", { event: "tool-forward", tool, conversationId, proxyBase });
return forwardToolToProxy(proxyBase, conversationId, tool, params);
}
5 changes: 5 additions & 0 deletions src/decompress-tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Type, type Static } from "typebox";
import type { AgentToolResult, ExtensionContext, ToolDefinition } from "@earendil-works/pi-coding-agent";
import type { AcpRuntime } from "./runtime.js";
import { debug, logError, logInfo, logThrow } from "./log.js";
import { tryForwardTool } from "./cooperative.js";
import { parseBlockIdArg, collectBlockContent, type CompressionBlock } from "acp-kernel";
import { entriesToCoreMessages } from "./messages.js";
import { writeFile, mkdir } from "node:fs/promises";
Expand Down Expand Up @@ -45,6 +46,10 @@ export function makeDecompressTool(runtime: AcpRuntime): ToolDefinition<typeof D
],
parameters: DecompressParams,
async execute(_toolCallId, params, _signal, _onUpdate, ctx): Promise<AgentToolResult<unknown>> {
const forwarded = await tryForwardTool("decompress", params, ctx);
if (forwarded !== undefined) {
return { details: undefined, content: [{ type: "text", text: forwarded }] };
}
let result: string;
try {
result = await handleDecompress(params as DecompressArgs, runtime, ctx);
Expand Down
Loading
Loading