opencode-headroom-plugin 1.0.0
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 +143 -0
- package/assets/workflow-current-implementation.svg +1 -0
- package/assets/workflow-naive-proxy.svg +1 -0
- package/index.js +12 -0
- package/package.json +45 -0
- package/scripts/install.sh +40 -0
- package/scripts/uninstall.sh +26 -0
- package/src/bridge.js +275 -0
- package/src/client.js +73 -0
- package/src/config.js +84 -0
- package/src/guard.js +134 -0
- package/src/index.js +178 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 pietro partescano
|
|
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,143 @@
|
|
|
1
|
+
# opencode-headroom-plugin
|
|
2
|
+
|
|
3
|
+
A plugin for **OpenCode v2** that transparently compresses **large tool results** in a session via **Headroom**, without touching the provider configuration already set up in OpenCode and without any provider-specific limitations (it also works with **AWS Bedrock**).
|
|
4
|
+
|
|
5
|
+
It doesn't modify `opencode.json`/`opencode.jsonc`, doesn't create fake providers, and doesn't route provider HTTP traffic through a proxy: it hooks into `session.hook("context", ...)`, the point where OpenCode assembles the session's messages **before** translating them into the provider's native format (Anthropic Messages, OpenAI Chat, Bedrock Converse, ...) and before signing the request. Only tool results (`role: "tool"`) are sent to Headroom for compression — never user/assistant text, never system messages.
|
|
6
|
+
|
|
7
|
+
## Prerequisites
|
|
8
|
+
|
|
9
|
+
- **Node.js 20.3+** (or Bun — OpenCode loads `.js` files directly; the plugin uses `AbortSignal.any`).
|
|
10
|
+
- **Headroom** installed and **its proxy already running** before starting OpenCode:
|
|
11
|
+
```bash
|
|
12
|
+
pip install "headroom-ai[proxy]"
|
|
13
|
+
headroom proxy # defaults to http://127.0.0.1:8787
|
|
14
|
+
```
|
|
15
|
+
The plugin **never** starts or manages the Headroom process, and doesn't route traffic through it: it only calls its stateless `POST /v1/compress` endpoint (compresses and nothing else, never contacts any provider itself) and, when needed, `GET /v1/retrieve/{hash}`.
|
|
16
|
+
|
|
17
|
+
## Installation
|
|
18
|
+
|
|
19
|
+
1. Clone/download this repository and install the dependency:
|
|
20
|
+
```bash
|
|
21
|
+
npm install
|
|
22
|
+
```
|
|
23
|
+
2. Wire the plugin into OpenCode with one of the methods below.
|
|
24
|
+
|
|
25
|
+
### A. Global, for every project (recommended)
|
|
26
|
+
|
|
27
|
+
Create a symlink in OpenCode's global plugins directory — no `opencode.json` edit required:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
npm run plugin:install
|
|
31
|
+
# equivalent to: bash scripts/install.sh
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
To remove it:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
npm run plugin:uninstall
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
The script links this folder to `~/.config/opencode/plugins/headroom-context-compression` (override with the `OPENCODE_CONFIG_DIR` variable). OpenCode automatically loads every plugin package found there.
|
|
41
|
+
|
|
42
|
+
### B. Only for a specific project
|
|
43
|
+
|
|
44
|
+
Copy or symlink the plugin folder into the project's `.opencode/plugins/`:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
mkdir -p /path/to/project/.opencode/plugins
|
|
48
|
+
ln -s /path/to/this/repo /path/to/project/.opencode/plugins/headroom-context-compression
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### C. Via `opencode.json(c)` (if you prefer an explicit entry)
|
|
52
|
+
|
|
53
|
+
```jsonc
|
|
54
|
+
{
|
|
55
|
+
"$schema": "https://opencode.ai/config.json",
|
|
56
|
+
"plugins": [
|
|
57
|
+
{
|
|
58
|
+
"package": "/absolute/path/to/this/repo",
|
|
59
|
+
"options": {
|
|
60
|
+
"proxyUrl": "http://127.0.0.1:8787"
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
]
|
|
64
|
+
}
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
After any of these methods, restart the OpenCode server (`opencode service restart`) or simply start a new session.
|
|
68
|
+
|
|
69
|
+
## If the plugin doesn't show up as active
|
|
70
|
+
|
|
71
|
+
1. **Restart the OpenCode server**, not just the client/TUI: `opencode service restart`. Plugins load at server startup.
|
|
72
|
+
2. **Check `opencode plugin list`**: `headroom-context-compression` should appear. If it doesn't appear at all, the config file is probably not being read (wrong path, or a JSON syntax error — watch out for missing commas between properties).
|
|
73
|
+
3. **Verify the plugin folder has an `index.js` at its root** (not only inside `src/`). OpenCode resolves plugins referenced by local path (both in `plugins` and via auto-discovery) by conventionally looking for `index.*`/`server.*` at the folder root, and **ignores `package.json`'s `exports`** in that case. This repository already ships a root `index.js` for exactly that reason.
|
|
74
|
+
|
|
75
|
+
## Configuration
|
|
76
|
+
|
|
77
|
+
No option is required: with no configuration the plugin points at `http://127.0.0.1:8787` (the `headroom proxy` default), only compresses tool results ≥ 2000 characters, and never compresses if the proxy isn't local (see "Security" below).
|
|
78
|
+
|
|
79
|
+
| Plugin option (`options`) | Environment variable | Default | Meaning |
|
|
80
|
+
| --- | --- | --- | --- |
|
|
81
|
+
| `enabled` | `HEADROOM_ENABLED` | `true` | Fully disables compression. |
|
|
82
|
+
| `proxyUrl` | `HEADROOM_PROXY_URL` | `http://127.0.0.1:8787` | Address of `headroom proxy`. |
|
|
83
|
+
| `allowRemote` | `HEADROOM_ALLOW_REMOTE` | `false` | Allows a non-local `proxyUrl`. See "Security". |
|
|
84
|
+
| `minContextTokens` | `HEADROOM_MIN_CONTEXT_TOKENS` | `10000` | Minimum estimated size (tokens) of the whole session context before attempting compression at all. |
|
|
85
|
+
| `minMessageChars` | `HEADROOM_MIN_MESSAGE_CHARS` | `2000` | Minimum size (characters) a tool result must reach before it's sent to Headroom. |
|
|
86
|
+
| `timeoutMs` | `HEADROOM_TIMEOUT_MS` | `40000` | Timeout for the `/v1/compress` call. Headroom gives itself an internal budget of 30s for the compression step (observed in its own startup log: `compression=30.0s`); on large contexts (hundreds of messages) compression can take 20-25s, so the default leaves margin above that budget. |
|
|
87
|
+
| `renameToolCalls` | — | `true` | Renames tool calls in the payload sent to Headroom, so its `DEFAULT_EXCLUDE_TOOLS` list (which protects `read`/`grep` by name) doesn't block compression of large results from those tools **that aren't already protected** by the point below. |
|
|
88
|
+
| `excludeProviders` | — | `[]` | List of `providerID`s (e.g. `"amazon-bedrock"`) to fully exclude from compression, in case it's ever needed. Empty by default: Bedrock works without any exceptions. |
|
|
89
|
+
|
|
90
|
+
## Protected tools (never compressed)
|
|
91
|
+
|
|
92
|
+
`read`, `write`, `edit`, `patch`, `skill`, and `headroom_retrieve` **never become compression candidates**, regardless of size — this isn't configurable. Reason: their output serves as an anchor for a later exact match (an `edit` operating on text read by `read`, for example) or is directive text meant to be followed literally (`skill`); compressing it would risk breaking that match or that directive. This rule is carried over from [`noheadroom`](https://github.com/raquezha/noheadroom) (a Headroom bridge for Pi-Agent), from which this plugin inherits the conservative setting. Other tools (`grep`, `glob`, `bash`, `webfetch`, ...) remain compressible.
|
|
93
|
+
|
|
94
|
+
## Cross-turn compression and loop guards
|
|
95
|
+
|
|
96
|
+
OpenCode doesn't persist the mutations made by the `context` hook into the session's history: on the next turn the same tool result comes back uncompressed. To avoid calling Headroom again from scratch on the same content every time, the plugin keeps, per session:
|
|
97
|
+
|
|
98
|
+
- a **replay cache** (tool-call id + tool name + original text → already-compressed, validated text): if the exact same tool result shows up again, it's reapplied without contacting the proxy again;
|
|
99
|
+
- some **loop guards** (already-seen content, request unchanged since the last attempt, a 3-second throttle between attempts) to avoid redundant calls when the `context` hook fires multiple times for the same turn.
|
|
100
|
+
|
|
101
|
+
This mechanism is also carried over from `noheadroom`, which solves the same problem for Pi-Agent.
|
|
102
|
+
|
|
103
|
+
## Security
|
|
104
|
+
|
|
105
|
+
Compressing sends the tool results' content to the configured `proxyUrl`. Because of this, if `proxyUrl` doesn't point at `localhost`/`127.0.0.1`/`::1`, the plugin **disables itself automatically** until you explicitly set `allowRemote: true` — only do that if you trust that proxy.
|
|
106
|
+
|
|
107
|
+
## Retrieving the original content
|
|
108
|
+
|
|
109
|
+
When Headroom compresses a tool result, it leaves a marker shaped like `[N items compressed to M. Retrieve more: hash=...]` (or `<<ccr:hash...>>`). The plugin registers a **`headroom_retrieve(hash)`** tool that the model can call to fetch the full original from Headroom's CCR cache (TTL 1800s) — no need to install Headroom's official MCP server.
|
|
110
|
+
|
|
111
|
+
## How it works
|
|
112
|
+
|
|
113
|
+
### ❌ What doesn't work: a plain HTTP proxy
|
|
114
|
+
|
|
115
|
+
The obvious approach — and the one the official `headroom-opencode` package uses for OpenCode v1 — is to rewrite the native HTTP request's URL so it goes through the Headroom proxy, leaving path/method/headers/body otherwise intact. That works for Anthropic/OpenAI/Google Vertex, but **breaks AWS Bedrock**: OpenCode's `amazon-bedrock` provider calls AWS's **Converse** API, and the request is signed with **AWS SigV4** *before* it reaches the proxy. If the proxy then compresses the body, the signature no longer matches the (now different) content, and AWS rejects the request outright.
|
|
116
|
+
|
|
117
|
+

|
|
118
|
+
|
|
119
|
+
There's no clean fix within that approach: re-signing the request after compression would need a second, provider-aware hop (essentially a full SigV4-capable gateway) just for Bedrock — not transparent, not generalizable to whatever provider comes next.
|
|
120
|
+
|
|
121
|
+
### ✅ Current implementation: compression at the message level
|
|
122
|
+
|
|
123
|
+
Instead of touching HTTP traffic, the plugin hooks into `session.hook("context", ...)` — the point where OpenCode assembles the session's messages in its **provider-agnostic** shape, before "lowering" them to the provider's native protocol (Converse, Chat Completions, GenerateContent, ...) and, crucially, **before** that request is signed. Only `role: "tool"` parts are extracted, filtered (size, protected tools) and sent to Headroom's stateless `POST /v1/compress`; the compressed text is written back into the same `ToolResultPart` only after validating that role and `tool_call_id` didn't change. Everything downstream — provider lowering, SigV4 signing, transport — happens exactly as before, just on an already-compressed body.
|
|
124
|
+
|
|
125
|
+

|
|
126
|
+
|
|
127
|
+
This works identically for every provider, Bedrock included, because it never touches the native HTTP body or its signature. Full architectural details and alternatives considered in `MEMORY.md`.
|
|
128
|
+
|
|
129
|
+
## Sources
|
|
130
|
+
|
|
131
|
+
The compression logic, protected tools, replay cache, and loop guards are carried over from two Headroom extensions for **Pi-Agent** (MIT license): [`@casualjim/pi-headroom`](https://github.com/casualjim/pi-mimir) and, especially for the protected tools and the replay cache, [`@raquezha/noheadroom`](https://github.com/raquezha/noheadroom) — both derive from `@ryan_nookpi/pi-extension-headroom` (Jonghakseo).
|
|
132
|
+
|
|
133
|
+
## Tests
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
npm test
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Suite built on `node --test`, no external framework:
|
|
140
|
+
- `test/bridge.test.js`, `test/config.test.js` — pure logic, no dependency on `@opencode/plugin` or a running Headroom proxy.
|
|
141
|
+
- `test/guard.test.js` — replay cache and loop guards, pure and testable without the plugin.
|
|
142
|
+
- `test/plugin.test.js` — integration with the real `@opencode/plugin` library, `fetch` stubbed (no real network), including a test that reproduces the Bedrock scenario, one for protected tools, and one for the cross-turn replay cache.
|
|
143
|
+
- `test/resolution.test.js` — verifies that OpenCode actually resolves the plugin from this folder (real `Host.resolve`/`Host.load`).
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" contentStyleType="text/css" data-diagram-type="SEQUENCE" height="614px" preserveAspectRatio="none" style="width:1295px;height:614px;background:#FFFFFF;" version="1.1" viewBox="0 0 1295 614" width="1295px" zoomAndPan="magnify"><?plantuml 1.2026.6?><defs/><g><g class="title" data-source-line="10"><text fill="#000000" font-family="sans-serif" font-size="14" font-weight="700" lengthAdjust="spacing" textLength="665.5469" x="311.4993" y="27.9951">OK Current implementation -- message-level compression via session.hook(context)</text></g><g class="participant-lifeline" data-entity-uid="part1" data-qualified-name="OC" data-source-line="12" id="part1-lifeline"><g><title>OpenCode</title><rect fill="#000000" fill-opacity="0.00000" height="471.3906" width="8" x="44.6167" y="89.8906"/><line style="stroke:#181818;stroke-width:0.5;stroke-dasharray:5,5;" x1="48" x2="48" y1="89.8906" y2="561.2813"/></g></g><g class="participant-lifeline" data-entity-uid="part2" data-qualified-name="Plugin" data-source-line="13" id="part2-lifeline"><g><title>Plugin</title><rect fill="#000000" fill-opacity="0.00000" height="471.3906" width="8" x="379.0615" y="89.8906"/><line style="stroke:#181818;stroke-width:0.5;stroke-dasharray:5,5;" x1="383.0522" x2="383.0522" y1="89.8906" y2="561.2813"/></g></g><g class="participant-lifeline" data-entity-uid="part3" data-qualified-name="Headroom" data-source-line="14" id="part3-lifeline"><g><title>Headroom</title><rect fill="#000000" fill-opacity="0.00000" height="471.3906" width="8" x="701.7036" y="89.8906"/><line style="stroke:#181818;stroke-width:0.5;stroke-dasharray:5,5;" x1="704.8433" x2="704.8433" y1="89.8906" y2="561.2813"/></g></g><g class="participant-lifeline" data-entity-uid="part4" data-qualified-name="Provider" data-source-line="15" id="part4-lifeline"><g><title>Provider adapter</title><rect fill="#000000" fill-opacity="0.00000" height="471.3906" width="8" x="903.4585" y="89.8906"/><line style="stroke:#181818;stroke-width:0.5;stroke-dasharray:5,5;" x1="906.564" x2="906.564" y1="89.8906" y2="561.2813"/></g></g><g class="participant-lifeline" data-entity-uid="part5" data-qualified-name="API" data-source-line="16" id="part5-lifeline"><g><title>LLM API</title><rect fill="#000000" fill-opacity="0.00000" height="471.3906" width="8" x="1246.4067" y="89.8906"/><line style="stroke:#181818;stroke-width:0.5;stroke-dasharray:5,5;" x1="1250.2681" x2="1250.2681" y1="89.8906" y2="561.2813"/></g></g><g class="participant participant-head" data-entity-uid="part1" data-qualified-name="OC" data-source-line="12" id="part1-head"><rect fill="#F0FFF4" height="46.5938" rx="2.5" ry="2.5" style="stroke:#444444;stroke-width:0.5;" width="87.2334" x="5" y="42.2969"/><text fill="#000000" font-family="sans-serif" font-size="14" lengthAdjust="spacing" textLength="73.2334" x="12" y="62.292">OpenCode</text><text fill="#000000" font-family="sans-serif" font-size="14" lengthAdjust="spacing" textLength="32.71" x="32.2617" y="78.5889">Core</text></g><g class="participant participant-tail" data-entity-uid="part1" data-qualified-name="OC" data-source-line="12" id="part1-tail"><rect fill="#F0FFF4" height="46.5938" rx="2.5" ry="2.5" style="stroke:#444444;stroke-width:0.5;" width="87.2334" x="5" y="560.2813"/><text fill="#000000" font-family="sans-serif" font-size="14" lengthAdjust="spacing" textLength="73.2334" x="12" y="580.2764">OpenCode</text><text fill="#000000" font-family="sans-serif" font-size="14" lengthAdjust="spacing" textLength="32.71" x="32.2617" y="596.5732">Core</text></g><g class="participant participant-head" data-entity-uid="part2" data-qualified-name="Plugin" data-source-line="13" id="part2-head"><rect fill="#F0FFF4" height="46.5938" rx="2.5" ry="2.5" style="stroke:#444444;stroke-width:0.5;" width="176.0186" x="295.0522" y="42.2969"/><text fill="#000000" font-family="sans-serif" font-size="14" lengthAdjust="spacing" textLength="42.8545" x="361.6343" y="62.292">Plugin</text><text fill="#000000" font-family="sans-serif" font-size="14" lengthAdjust="spacing" textLength="162.0186" x="302.0522" y="78.5889">session.hook('context')</text></g><g class="participant participant-tail" data-entity-uid="part2" data-qualified-name="Plugin" data-source-line="13" id="part2-tail"><rect fill="#F0FFF4" height="46.5938" rx="2.5" ry="2.5" style="stroke:#444444;stroke-width:0.5;" width="176.0186" x="295.0522" y="560.2813"/><text fill="#000000" font-family="sans-serif" font-size="14" lengthAdjust="spacing" textLength="42.8545" x="361.6343" y="580.2764">Plugin</text><text fill="#000000" font-family="sans-serif" font-size="14" lengthAdjust="spacing" textLength="162.0186" x="302.0522" y="596.5732">session.hook('context')</text></g><g class="participant participant-head" data-entity-uid="part3" data-qualified-name="Headroom" data-source-line="14" id="part3-head"><rect fill="#F0FFF4" height="46.5938" rx="2.5" ry="2.5" style="stroke:#444444;stroke-width:0.5;" width="149.7207" x="630.8433" y="42.2969"/><text fill="#000000" font-family="sans-serif" font-size="14" lengthAdjust="spacing" textLength="73.1309" x="669.1382" y="62.292">Headroom</text><text fill="#000000" font-family="sans-serif" font-size="14" lengthAdjust="spacing" textLength="135.7207" x="637.8433" y="78.5889">POST /v1/compress</text></g><g class="participant participant-tail" data-entity-uid="part3" data-qualified-name="Headroom" data-source-line="14" id="part3-tail"><rect fill="#F0FFF4" height="46.5938" rx="2.5" ry="2.5" style="stroke:#444444;stroke-width:0.5;" width="149.7207" x="630.8433" y="560.2813"/><text fill="#000000" font-family="sans-serif" font-size="14" lengthAdjust="spacing" textLength="73.1309" x="669.1382" y="580.2764">Headroom</text><text fill="#000000" font-family="sans-serif" font-size="14" lengthAdjust="spacing" textLength="135.7207" x="637.8433" y="596.5732">POST /v1/compress</text></g><g class="participant participant-head" data-entity-uid="part4" data-qualified-name="Provider" data-source-line="15" id="part4-head"><rect fill="#F0FFF4" height="46.5938" rx="2.5" ry="2.5" style="stroke:#444444;stroke-width:0.5;" width="233.7891" x="790.564" y="42.2969"/><text fill="#000000" font-family="sans-serif" font-size="14" lengthAdjust="spacing" textLength="117.4346" x="848.7412" y="62.292">Provider adapter</text><text fill="#000000" font-family="sans-serif" font-size="14" lengthAdjust="spacing" textLength="219.7891" x="797.564" y="78.5889">(Bedrock / Vertex / OpenAI / ...)</text></g><g class="participant participant-tail" data-entity-uid="part4" data-qualified-name="Provider" data-source-line="15" id="part4-tail"><rect fill="#F0FFF4" height="46.5938" rx="2.5" ry="2.5" style="stroke:#444444;stroke-width:0.5;" width="233.7891" x="790.564" y="560.2813"/><text fill="#000000" font-family="sans-serif" font-size="14" lengthAdjust="spacing" textLength="117.4346" x="848.7412" y="580.2764">Provider adapter</text><text fill="#000000" font-family="sans-serif" font-size="14" lengthAdjust="spacing" textLength="219.7891" x="797.564" y="596.5732">(Bedrock / Vertex / OpenAI / ...)</text></g><g class="participant participant-head" data-entity-uid="part5" data-qualified-name="API" data-source-line="16" id="part5-head"><rect fill="#F0FFF4" height="30.2969" rx="2.5" ry="2.5" style="stroke:#444444;stroke-width:0.5;" width="68.2773" x="1216.2681" y="58.5938"/><text fill="#000000" font-family="sans-serif" font-size="14" lengthAdjust="spacing" textLength="54.2773" x="1223.2681" y="78.5889">LLM API</text></g><g class="participant participant-tail" data-entity-uid="part5" data-qualified-name="API" data-source-line="16" id="part5-tail"><rect fill="#F0FFF4" height="30.2969" rx="2.5" ry="2.5" style="stroke:#444444;stroke-width:0.5;" width="68.2773" x="1216.2681" y="560.2813"/><text fill="#000000" font-family="sans-serif" font-size="14" lengthAdjust="spacing" textLength="54.2773" x="1223.2681" y="580.2764">LLM API</text></g><g class="message" data-entity-1="part1" data-entity-2="part2" data-source-line="18" id="msg1"><polygon fill="#444444" points="371.0615,132.1563,381.0615,136.1563,371.0615,140.1563,375.0615,136.1563" style="stroke:#444444;stroke-width:1;stroke-linejoin:miter;stroke-miterlimit:10;"/><line style="stroke:#444444;stroke-width:1;" x1="48.6167" x2="377.0615" y1="136.1563" y2="136.1563"/><text fill="#000000" font-family="sans-serif" font-size="13" lengthAdjust="spacing" textLength="117.4697" x="157.1042" y="115.9575">context hook fires</text><text fill="#000000" font-family="sans-serif" font-size="13" lengthAdjust="spacing" textLength="310.4448" x="60.6167" y="131.0903">event.messages: Message[] (provider-agnostic)</text></g><g class="message" data-entity-1="part2" data-entity-2="part2" data-source-line="19" id="msg2"><line style="stroke:#444444;stroke-width:1;" x1="383.0615" x2="425.0615" y1="195.5547" y2="195.5547"/><line style="stroke:#444444;stroke-width:1;" x1="425.0615" x2="425.0615" y1="195.5547" y2="208.5547"/><line style="stroke:#444444;stroke-width:1;" x1="384.0615" x2="425.0615" y1="208.5547" y2="208.5547"/><polygon fill="#444444" points="394.0615,204.5547,384.0615,208.5547,394.0615,212.5547,390.0615,208.5547" style="stroke:#444444;stroke-width:1;stroke-linejoin:miter;stroke-miterlimit:10;"/><text fill="#000000" font-family="sans-serif" font-size="13" lengthAdjust="spacing" textLength="176.8013" x="402.5505" y="160.2231">keep only role='tool' parts,</text><text fill="#000000" font-family="sans-serif" font-size="13" lengthAdjust="spacing" textLength="133.0913" x="424.4055" y="175.356">skip protected tools,</text><text fill="#000000" font-family="sans-serif" font-size="13" lengthAdjust="spacing" textLength="201.7793" x="390.0615" y="190.4888">skip parts < minMessageChars</text></g><g class="message" data-entity-1="part2" data-entity-2="part3" data-source-line="20" id="msg3"><polygon fill="#444444" points="693.7036,248.8203,703.7036,252.8203,693.7036,256.8203,697.7036,252.8203" style="stroke:#444444;stroke-width:1;stroke-linejoin:miter;stroke-miterlimit:10;"/><line style="stroke:#444444;stroke-width:1;" x1="383.0615" x2="699.7036" y1="252.8203" y2="252.8203"/><text fill="#000000" font-family="sans-serif" font-size="13" lengthAdjust="spacing" textLength="126.0264" x="481.3694" y="232.6216">POST /v1/compress</text><text fill="#000000" font-family="sans-serif" font-size="13" lengthAdjust="spacing" textLength="138.1631" x="475.301" y="247.7544">(only tool-result text)</text></g><g class="message" data-entity-1="part3" data-entity-2="part2" data-source-line="21" id="msg4"><polygon fill="#444444" points="394.0615,277.9531,384.0615,281.9531,394.0615,285.9531,390.0615,281.9531" style="stroke:#444444;stroke-width:1;stroke-linejoin:miter;stroke-miterlimit:10;"/><line style="stroke:#444444;stroke-width:1;" x1="388.0615" x2="704.7036" y1="281.9531" y2="281.9531"/><text fill="#000000" font-family="sans-serif" font-size="13" lengthAdjust="spacing" textLength="214.7666" x="436.9993" y="276.8872">compressed text + retrieve hash</text></g><g class="message" data-entity-1="part2" data-entity-2="part2" data-source-line="22" id="msg5"><line style="stroke:#444444;stroke-width:1;" x1="383.0615" x2="425.0615" y1="326.2188" y2="326.2188"/><line style="stroke:#444444;stroke-width:1;" x1="425.0615" x2="425.0615" y1="326.2188" y2="339.2188"/><line style="stroke:#444444;stroke-width:1;" x1="384.0615" x2="425.0615" y1="339.2188" y2="339.2188"/><polygon fill="#444444" points="394.0615,335.2188,384.0615,339.2188,394.0615,343.2188,390.0615,339.2188" style="stroke:#444444;stroke-width:1;stroke-linejoin:miter;stroke-miterlimit:10;"/><text fill="#000000" font-family="sans-serif" font-size="13" lengthAdjust="spacing" textLength="252.2939" x="418.2356" y="306.02">validate role & tool_call_id unchanged,</text><text fill="#000000" font-family="sans-serif" font-size="13" lengthAdjust="spacing" textLength="308.6421" x="390.0615" y="321.1528">write compressed text back into ToolResultPart</text></g><g class="message" data-entity-1="part2" data-entity-2="part1" data-source-line="23" id="msg6"><polygon fill="#444444" points="59.6167,379.4844,49.6167,383.4844,59.6167,387.4844,55.6167,383.4844" style="stroke:#444444;stroke-width:1;stroke-linejoin:miter;stroke-miterlimit:10;"/><line style="stroke:#444444;stroke-width:1;" x1="53.6167" x2="382.0615" y1="383.4844" y2="383.4844"/><text fill="#000000" font-family="sans-serif" font-size="13" lengthAdjust="spacing" textLength="126.5659" x="152.5562" y="363.2856">mutated Message[]</text><text fill="#000000" font-family="sans-serif" font-size="13" lengthAdjust="spacing" textLength="252.897" x="89.3906" y="378.4185">(still provider-agnostic, not yet signed)</text></g><g class="message" data-entity-1="part1" data-entity-2="part4" data-source-line="24" id="msg7"><polygon fill="#444444" points="895.4585,423.75,905.4585,427.75,895.4585,431.75,899.4585,427.75" style="stroke:#444444;stroke-width:1;stroke-linejoin:miter;stroke-miterlimit:10;"/><line style="stroke:#444444;stroke-width:1;" x1="48.6167" x2="901.4585" y1="427.75" y2="427.75"/><text fill="#000000" font-family="sans-serif" font-size="13" lengthAdjust="spacing" textLength="214.5254" x="370.7749" y="407.5513">lower messages to native format</text><text fill="#000000" font-family="sans-serif" font-size="13" lengthAdjust="spacing" textLength="251.7036" x="352.1858" y="422.6841">(Converse / Chat / GenerateContent...)</text></g><g class="message" data-entity-1="part4" data-entity-2="part4" data-source-line="25" id="msg8"><line style="stroke:#444444;stroke-width:1;" x1="907.4585" x2="949.4585" y1="472.0156" y2="472.0156"/><line style="stroke:#444444;stroke-width:1;" x1="949.4585" x2="949.4585" y1="472.0156" y2="485.0156"/><line style="stroke:#444444;stroke-width:1;" x1="908.4585" x2="949.4585" y1="485.0156" y2="485.0156"/><polygon fill="#444444" points="918.4585,481.0156,908.4585,485.0156,918.4585,489.0156,914.4585,485.0156" style="stroke:#444444;stroke-width:1;stroke-linejoin:miter;stroke-miterlimit:10;"/><text fill="#000000" font-family="sans-serif" font-size="13" lengthAdjust="spacing" textLength="80.7104" x="1038.5774" y="451.8169">sign request</text><text fill="#000000" font-family="sans-serif" font-size="13" lengthAdjust="spacing" textLength="328.9482" x="914.4585" y="466.9497">(AWS SigV4 etc.) on the already-compressed body</text></g><g class="message" data-entity-1="part4" data-entity-2="part5" data-source-line="26" id="msg9"><polygon fill="#444444" points="1238.4067,510.1484,1248.4067,514.1484,1238.4067,518.1484,1242.4067,514.1484" style="stroke:#444444;stroke-width:1;stroke-linejoin:miter;stroke-miterlimit:10;"/><line style="stroke:#444444;stroke-width:1;" x1="907.4585" x2="1244.4067" y1="514.1484" y2="514.1484"/><text fill="#000000" font-family="sans-serif" font-size="13" lengthAdjust="spacing" textLength="85.0967" x="1036.3843" y="509.0825">send request</text></g><g class="message" data-entity-1="part5" data-entity-2="part4" data-source-line="27" id="msg10"><polygon fill="#444444" points="918.4585,539.2813,908.4585,543.2813,918.4585,547.2813,914.4585,543.2813" style="stroke:#444444;stroke-width:1;stroke-linejoin:miter;stroke-miterlimit:10;"/><line style="stroke:#444444;stroke-width:1;" x1="912.4585" x2="1249.4067" y1="543.2813" y2="543.2813"/><text fill="#000000" font-family="sans-serif" font-size="13" lengthAdjust="spacing" textLength="59.3315" x="1049.2668" y="538.2153">response</text></g><?plantuml-src ZLHXRzCm4FsUNt4Q4bt5qe4qWQW0gOi8J6oqOjFuGD7anRV4gkCBjjFIVyyvJRQa1ObyI2pVk_VkdiznAsKgOKK9hX2IDihay22qmyWzHV1NZIR3AtHEv3ZJAZUGeV5eUwYPjRH9I9E5PsVDqmzcdkmvMOdsNv25i5vbgXB6_pzGPAlSKcra2qvVfcdQ1tybZmUeZw_JDscwZ-gBlcsUAFBAQuIZ-HT8QckvPL1bfR7abV2A3CGnb3jROerhr91HMLdU2S6r4cnUivuKHAlZZDYqttvy54NLKnVCNw59IEAItnQFG3YO9qF8GjUvCaipu1krXADnaxF33FC-ev2Mg5oQnVpw1axNhqwx4fkSBhwdPccjs0uGKbHypajpV8wCorPm2hTeMPGNeUpP1IycaqbRGPitPBkyl8BPug91y3UAvWd47zfofz0s0Q4bU51SsjAmbyPFMclT5DhP-_4JZgjM8nQv8SSgusZ7rETS8LP0Hc_1aiRt8q-aHn2gSYTiuafLm3mUCuyIGl1fEs3W7PJAjA998QphQNIMJU70KlQf4GsCCM_KsaDpub6ND6nybnLA20Qy08lUAkuT2k6AlxIr5bf9uR7f2futEdUPqFfEIQXDLWYJe-HMDbOnQb_Wdcy2AEC9RZZnMrDVk4uzfNaoXRBcwUQSHzEvBJPQQpZm_WGCUTYY1yT_1PJZxcnRu1GqRNYEkfFaYi7mrU4M7yYMmZDrGcQDrY4F4blj-VC93Lek8GcJmNF0unKzZkI0FQYoQ_o3Se5gzlqQhbL-UmReiycO9m1yWI2qPVktSS-FUvBR0ISF9jEXaHrTn3j3CKwjY6_W7m00?></g></svg>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" contentStyleType="text/css" data-diagram-type="SEQUENCE" height="533px" preserveAspectRatio="none" style="width:853px;height:533px;background:#FFFFFF;" version="1.1" viewBox="0 0 853 533" width="853px" zoomAndPan="magnify"><?plantuml 1.2026.6?><defs/><g><g class="title" data-source-line="10"><text fill="#000000" font-family="sans-serif" font-size="14" font-weight="700" lengthAdjust="spacing" textLength="403.1836" x="221.7915" y="27.9951">X Naive HTTP proxy rewrite -- breaks AWS Bedrock</text></g><g class="participant-lifeline" data-entity-uid="part1" data-qualified-name="OC" data-source-line="12" id="part1-lifeline"><g><title>OpenCode</title><rect fill="#000000" fill-opacity="0.00000" height="389.8594" width="8" x="44.6167" y="89.8906"/><line style="stroke:#181818;stroke-width:0.5;stroke-dasharray:5,5;" x1="48" x2="48" y1="89.8906" y2="479.75"/></g></g><g class="participant-lifeline" data-entity-uid="part2" data-qualified-name="Provider" data-source-line="13" id="part2-lifeline"><g><title>amazon-bedrock</title><rect fill="#000000" fill-opacity="0.00000" height="389.8594" width="8" x="364.5063" y="89.8906"/><line style="stroke:#181818;stroke-width:0.5;stroke-dasharray:5,5;" x1="367.998" x2="367.998" y1="89.8906" y2="479.75"/></g></g><g class="participant-lifeline" data-entity-uid="part3" data-qualified-name="Proxy" data-source-line="14" id="part3-lifeline"><g><title>Headroom</title><rect fill="#000000" fill-opacity="0.00000" height="389.8594" width="8" x="546.1885" y="89.8906"/><line style="stroke:#181818;stroke-width:0.5;stroke-dasharray:5,5;" x1="549.2598" x2="549.2598" y1="89.8906" y2="479.75"/></g></g><g class="participant-lifeline" data-entity-uid="part4" data-qualified-name="AWS" data-source-line="15" id="part4-lifeline"><g><title>AWS Bedrock</title><rect fill="#000000" fill-opacity="0.00000" height="389.8594" width="8" x="757.311" y="89.8906"/><line style="stroke:#181818;stroke-width:0.5;stroke-dasharray:5,5;" x1="760.3174" x2="760.3174" y1="89.8906" y2="479.75"/></g></g><g class="participant participant-head" data-entity-uid="part1" data-qualified-name="OC" data-source-line="12" id="part1-head"><rect fill="#FFF3F3" height="46.5938" rx="2.5" ry="2.5" style="stroke:#444444;stroke-width:0.5;" width="87.2334" x="5" y="42.2969"/><text fill="#000000" font-family="sans-serif" font-size="14" lengthAdjust="spacing" textLength="73.2334" x="12" y="62.292">OpenCode</text><text fill="#000000" font-family="sans-serif" font-size="14" lengthAdjust="spacing" textLength="32.71" x="32.2617" y="78.5889">Core</text></g><g class="participant participant-tail" data-entity-uid="part1" data-qualified-name="OC" data-source-line="12" id="part1-tail"><rect fill="#FFF3F3" height="46.5938" rx="2.5" ry="2.5" style="stroke:#444444;stroke-width:0.5;" width="87.2334" x="5" y="478.75"/><text fill="#000000" font-family="sans-serif" font-size="14" lengthAdjust="spacing" textLength="73.2334" x="12" y="498.7451">OpenCode</text><text fill="#000000" font-family="sans-serif" font-size="14" lengthAdjust="spacing" textLength="32.71" x="32.2617" y="515.042">Core</text></g><g class="participant participant-head" data-entity-uid="part2" data-qualified-name="Provider" data-source-line="13" id="part2-head"><rect fill="#FFF3F3" height="46.5938" rx="2.5" ry="2.5" style="stroke:#444444;stroke-width:0.5;" width="135.0166" x="300.998" y="42.2969"/><text fill="#000000" font-family="sans-serif" font-size="14" lengthAdjust="spacing" textLength="117.1475" x="309.9326" y="62.292">amazon-bedrock</text><text fill="#000000" font-family="sans-serif" font-size="14" lengthAdjust="spacing" textLength="121.0166" x="307.998" y="78.5889">provider (AI SDK)</text></g><g class="participant participant-tail" data-entity-uid="part2" data-qualified-name="Provider" data-source-line="13" id="part2-tail"><rect fill="#FFF3F3" height="46.5938" rx="2.5" ry="2.5" style="stroke:#444444;stroke-width:0.5;" width="135.0166" x="300.998" y="478.75"/><text fill="#000000" font-family="sans-serif" font-size="14" lengthAdjust="spacing" textLength="117.1475" x="309.9326" y="498.7451">amazon-bedrock</text><text fill="#000000" font-family="sans-serif" font-size="14" lengthAdjust="spacing" textLength="121.0166" x="307.998" y="515.042">provider (AI SDK)</text></g><g class="participant participant-head" data-entity-uid="part3" data-qualified-name="Proxy" data-source-line="14" id="part3-head"><rect fill="#FFF3F3" height="46.5938" rx="2.5" ry="2.5" style="stroke:#444444;stroke-width:0.5;" width="93.8574" x="503.2598" y="42.2969"/><text fill="#000000" font-family="sans-serif" font-size="14" lengthAdjust="spacing" textLength="73.1309" x="513.623" y="62.292">Headroom</text><text fill="#000000" font-family="sans-serif" font-size="14" lengthAdjust="spacing" textLength="79.8574" x="510.2598" y="78.5889">HTTP Proxy</text></g><g class="participant participant-tail" data-entity-uid="part3" data-qualified-name="Proxy" data-source-line="14" id="part3-tail"><rect fill="#FFF3F3" height="46.5938" rx="2.5" ry="2.5" style="stroke:#444444;stroke-width:0.5;" width="93.8574" x="503.2598" y="478.75"/><text fill="#000000" font-family="sans-serif" font-size="14" lengthAdjust="spacing" textLength="73.1309" x="513.623" y="498.7451">Headroom</text><text fill="#000000" font-family="sans-serif" font-size="14" lengthAdjust="spacing" textLength="79.8574" x="510.2598" y="515.042">HTTP Proxy</text></g><g class="participant participant-head" data-entity-uid="part4" data-qualified-name="AWS" data-source-line="15" id="part4-head"><rect fill="#FFF3F3" height="46.5938" rx="2.5" ry="2.5" style="stroke:#444444;stroke-width:0.5;" width="107.9873" x="707.3174" y="42.2969"/><text fill="#000000" font-family="sans-serif" font-size="14" lengthAdjust="spacing" textLength="93.9873" x="714.3174" y="62.292">AWS Bedrock</text><text fill="#000000" font-family="sans-serif" font-size="14" lengthAdjust="spacing" textLength="92.374" x="715.124" y="78.5889">Converse API</text></g><g class="participant participant-tail" data-entity-uid="part4" data-qualified-name="AWS" data-source-line="15" id="part4-tail"><rect fill="#FFF3F3" height="46.5938" rx="2.5" ry="2.5" style="stroke:#444444;stroke-width:0.5;" width="107.9873" x="707.3174" y="478.75"/><text fill="#000000" font-family="sans-serif" font-size="14" lengthAdjust="spacing" textLength="93.9873" x="714.3174" y="498.7451">AWS Bedrock</text><text fill="#000000" font-family="sans-serif" font-size="14" lengthAdjust="spacing" textLength="92.374" x="715.124" y="515.042">Converse API</text></g><g class="message" data-entity-1="part1" data-entity-2="part2" data-source-line="17" id="msg1"><polygon fill="#444444" points="356.5063,132.1563,366.5063,136.1563,356.5063,140.1563,360.5063,136.1563" style="stroke:#444444;stroke-width:1;stroke-linejoin:miter;stroke-miterlimit:10;"/><line style="stroke:#444444;stroke-width:1;" x1="48.6167" x2="362.5063" y1="136.1563" y2="136.1563"/><text fill="#000000" font-family="sans-serif" font-size="13" lengthAdjust="spacing" textLength="130.5396" x="143.2917" y="115.9575">build native request</text><text fill="#000000" font-family="sans-serif" font-size="13" lengthAdjust="spacing" textLength="295.8896" x="60.6167" y="131.0903">(Converse API, /model/{id}/converse-stream)</text></g><g class="message" data-entity-1="part2" data-entity-2="part2" data-source-line="18" id="msg2"><line style="stroke:#444444;stroke-width:1;" x1="368.5063" x2="410.5063" y1="180.4219" y2="180.4219"/><line style="stroke:#444444;stroke-width:1;" x1="410.5063" x2="410.5063" y1="180.4219" y2="193.4219"/><line style="stroke:#444444;stroke-width:1;" x1="369.5063" x2="410.5063" y1="193.4219" y2="193.4219"/><polygon fill="#444444" points="379.5063,189.4219,369.5063,193.4219,379.5063,197.4219,375.5063,193.4219" style="stroke:#444444;stroke-width:1;stroke-linejoin:miter;stroke-miterlimit:10;"/><text fill="#000000" font-family="sans-serif" font-size="13" lengthAdjust="spacing" textLength="116.9937" x="375.5063" y="160.2231">sign request body</text><text fill="#000000" font-family="sans-serif" font-size="13" lengthAdjust="spacing" textLength="81.5547" x="393.2258" y="175.356">(AWS SigV4)</text></g><g class="message" data-entity-1="part2" data-entity-2="part3" data-source-line="19" id="msg3"><polygon fill="#444444" points="538.1885,233.6875,548.1885,237.6875,538.1885,241.6875,542.1885,237.6875" style="stroke:#444444;stroke-width:1;stroke-linejoin:miter;stroke-miterlimit:10;"/><line style="stroke:#444444;stroke-width:1;" x1="368.5063" x2="544.1885" y1="237.6875" y2="237.6875"/><text fill="#000000" font-family="sans-serif" font-size="13" lengthAdjust="spacing" textLength="132.355" x="393.1699" y="217.4888">send signed request</text><text fill="#000000" font-family="sans-serif" font-size="13" lengthAdjust="spacing" textLength="157.6821" x="380.5063" y="232.6216">(URL rewritten to proxy)</text></g><g class="message" data-entity-1="part3" data-entity-2="part3" data-source-line="20" id="msg4"><line style="stroke:#444444;stroke-width:1;" x1="550.1885" x2="592.1885" y1="281.9531" y2="281.9531"/><line style="stroke:#444444;stroke-width:1;" x1="592.1885" x2="592.1885" y1="281.9531" y2="294.9531"/><line style="stroke:#444444;stroke-width:1;" x1="551.1885" x2="592.1885" y1="294.9531" y2="294.9531"/><polygon fill="#444444" points="561.1885,290.9531,551.1885,294.9531,561.1885,298.9531,557.1885,294.9531" style="stroke:#444444;stroke-width:1;stroke-linejoin:miter;stroke-miterlimit:10;"/><text fill="#000000" font-family="sans-serif" font-size="13" lengthAdjust="spacing" textLength="173.3545" x="557.1885" y="261.7544">compress tool-result bytes</text><text fill="#000000" font-family="sans-serif" font-size="13" lengthAdjust="spacing" textLength="154.0703" x="566.8306" y="276.8872">inside the request body</text></g><path d="M555,307.9531 L555,347.9531 L842,347.9531 L842,317.9531 L832,307.9531 L555,307.9531" fill="#FFF9E6" style="stroke:#999999;stroke-width:0.5;"/><path d="M832,307.9531 L832,317.9531 L842,317.9531 L832,307.9531" fill="#FFF9E6" style="stroke:#999999;stroke-width:0.5;"/><text fill="#000000" font-family="sans-serif" font-size="13" lengthAdjust="spacing" textLength="92.3774" x="561" y="325.02">body changed</text><text fill="#000000" font-family="sans-serif" font-size="13" font-weight="700" lengthAdjust="spacing" textLength="35.8706" x="657.5098" y="325.02">after</text><text fill="#000000" font-family="sans-serif" font-size="13" lengthAdjust="spacing" textLength="60.4932" x="697.5127" y="325.02">signing --</text><text fill="#000000" font-family="sans-serif" font-size="13" lengthAdjust="spacing" textLength="266.7666" x="561" y="340.1528">signature no longer matches the content</text><g class="message" data-entity-1="part3" data-entity-2="part4" data-source-line="22" id="msg5"><polygon fill="#444444" points="749.311,370.3516,759.311,374.3516,749.311,378.3516,753.311,374.3516" style="stroke:#444444;stroke-width:1;stroke-linejoin:miter;stroke-miterlimit:10;"/><line style="stroke:#444444;stroke-width:1;" x1="550.1885" x2="755.311" y1="374.3516" y2="374.3516"/><text fill="#000000" font-family="sans-serif" font-size="13" lengthAdjust="spacing" textLength="163.3506" x="574.0745" y="369.2856">forward mutated request</text></g><g class="message" data-entity-1="part4" data-entity-2="part3" data-source-line="23" id="msg6"><polygon fill="#444444" points="561.1885,399.4844,551.1885,403.4844,561.1885,407.4844,557.1885,403.4844" style="stroke:#444444;stroke-width:1;stroke-linejoin:miter;stroke-miterlimit:10;"/><line style="stroke:#444444;stroke-width:1;" x1="555.1885" x2="760.311" y1="403.4844" y2="403.4844"/><text fill="#000000" font-family="sans-serif" font-size="13" lengthAdjust="spacing" textLength="187.1226" x="562.1885" y="398.4185">403 SignatureDoesNotMatch</text></g><g class="message" data-entity-1="part3" data-entity-2="part2" data-source-line="24" id="msg7"><polygon fill="#444444" points="379.5063,428.6172,369.5063,432.6172,379.5063,436.6172,375.5063,432.6172" style="stroke:#444444;stroke-width:1;stroke-linejoin:miter;stroke-miterlimit:10;"/><line style="stroke:#444444;stroke-width:1;" x1="373.5063" x2="549.1885" y1="432.6172" y2="432.6172"/><text fill="#000000" font-family="sans-serif" font-size="13" lengthAdjust="spacing" textLength="95.4497" x="411.6226" y="427.5513">error response</text></g><g class="message" data-entity-1="part2" data-entity-2="part1" data-source-line="25" id="msg8"><polygon fill="#444444" points="59.6167,457.75,49.6167,461.75,59.6167,465.75,55.6167,461.75" style="stroke:#444444;stroke-width:1;stroke-linejoin:miter;stroke-miterlimit:10;"/><line style="stroke:#444444;stroke-width:1;" x1="53.6167" x2="367.5063" y1="461.75" y2="461.75"/><text fill="#000000" font-family="sans-serif" font-size="13" lengthAdjust="spacing" textLength="89.8511" x="163.636" y="456.6841">request failed</text></g><?plantuml-src ZLDDRzf04BtxLupe9K6rg0Ig50wL22bAr2QWaduSV5cyWrbXxxYp2uHK_U-TDIQs-o5r3zRAyzwRDxCpRcjigLWLu3PAqy7O3DOgTnYv9eBVTsXJl4VdL8QJt6GMKhGUkOMQCDDXIZanl1fLfnrCFV4riKR-5sIXs9lKbChw_mUgT9inxQokmRFPR3WRji0FvF5lgAltRtz7jPDULIUAlF4vGkyRF2YpHxXzV5n0oVHq1CO36uyGnx1YL5i7awzBk4RDb6vxKLGsBg4tBz5EIMCYNyOUA0VpQHUY2lLCDbwT118hMVP63C75v0wMDnykAzQY_jlbtg8I5XM9hGmkWi4p_EdOnRPS1ZTsZ-mG9ekxYY3HA9fF8NxtaceCgvt9DLZbGmiu38FpYRrear_3e93wyi4FetyEqZeIEo-jAIwZirPNruKvglLWHVeeei7UqcHVHd-Gdex2GAih6kgMaS-VFjRFuT62fzCBLGBoK0qxfQ9acM61K1xBRPTBtgD7brXZdUG2ly6EeyZAP02RREE1rcUX485qesmcFlfzjPPDwFShOs5_uZYnuQxyZX4iGKw2P2YKJpVegYJI8Z7h6uzI-1ZMn0V56egTLxwfCGfTQUeOlHc67ftqRmYTZEzzqEvKN3SPPIrPX5n9LhQwtTJvTFnIx5gP7FKl?></g></svg>
|
package/index.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Conventional root entrypoint.
|
|
3
|
+
*
|
|
4
|
+
* OpenCode resolves a plugin referenced by filesystem path (either via
|
|
5
|
+
* `plugins` in opencode.json or through `.opencode/plugins/` auto-discovery)
|
|
6
|
+
* by looking for `index.*`/`server.*` directly in this directory — it does
|
|
7
|
+
* NOT consult `package.json`'s `exports` field for local path references
|
|
8
|
+
* (that field only applies when the plugin is installed and referenced by
|
|
9
|
+
* npm package name). Keep the real implementation in src/ and re-export it
|
|
10
|
+
* here so both resolution paths work.
|
|
11
|
+
*/
|
|
12
|
+
export { default } from "./src/index.js"
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "opencode-headroom-plugin",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "OpenCode v2 plugin that transparently compresses large tool results via Headroom at the message level — works with any provider, including AWS Bedrock.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"opencode",
|
|
7
|
+
"opencode-plugin",
|
|
8
|
+
"headroom",
|
|
9
|
+
"llm",
|
|
10
|
+
"context-compression",
|
|
11
|
+
"token-optimization",
|
|
12
|
+
"aws-bedrock"
|
|
13
|
+
],
|
|
14
|
+
"type": "module",
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"author": "pietro partescano <goldfix@users.noreply.github.com>",
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/goldfix/opencode-headroom-plugin.git"
|
|
20
|
+
},
|
|
21
|
+
"bugs": {
|
|
22
|
+
"url": "https://github.com/goldfix/opencode-headroom-plugin/issues"
|
|
23
|
+
},
|
|
24
|
+
"homepage": "https://github.com/goldfix/opencode-headroom-plugin#readme",
|
|
25
|
+
"exports": {
|
|
26
|
+
".": "./src/index.js"
|
|
27
|
+
},
|
|
28
|
+
"files": [
|
|
29
|
+
"index.js",
|
|
30
|
+
"src",
|
|
31
|
+
"scripts",
|
|
32
|
+
"assets"
|
|
33
|
+
],
|
|
34
|
+
"scripts": {
|
|
35
|
+
"test": "node --test test/",
|
|
36
|
+
"plugin:install": "bash scripts/install.sh",
|
|
37
|
+
"plugin:uninstall": "bash scripts/uninstall.sh"
|
|
38
|
+
},
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"@opencode/plugin": "^2.0.19"
|
|
41
|
+
},
|
|
42
|
+
"engines": {
|
|
43
|
+
"node": ">=20.3"
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Installs this plugin for auto-discovery by every OpenCode project on this
|
|
3
|
+
# machine, by symlinking it into OpenCode's global plugins directory.
|
|
4
|
+
# No opencode.json / opencode.jsonc edit is required (see docs/plugins.md
|
|
5
|
+
# "Discover": OpenCode loads immediate plugin package directories from
|
|
6
|
+
# ~/.config/opencode/plugins automatically).
|
|
7
|
+
#
|
|
8
|
+
# Usage:
|
|
9
|
+
# bash scripts/install.sh [--force]
|
|
10
|
+
#
|
|
11
|
+
# Env overrides:
|
|
12
|
+
# OPENCODE_CONFIG_DIR Root config directory (default: ~/.config/opencode)
|
|
13
|
+
set -euo pipefail
|
|
14
|
+
|
|
15
|
+
PLUGIN_NAME="headroom-context-compression"
|
|
16
|
+
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
17
|
+
CONFIG_DIR="${OPENCODE_CONFIG_DIR:-$HOME/.config/opencode}"
|
|
18
|
+
TARGET_DIR="$CONFIG_DIR/plugins/$PLUGIN_NAME"
|
|
19
|
+
FORCE="${1:-}"
|
|
20
|
+
|
|
21
|
+
if [ ! -d "$REPO_ROOT/node_modules/@opencode/plugin" ]; then
|
|
22
|
+
echo "--> @opencode/plugin is not installed here yet. Run 'npm install' in ${REPO_ROOT} first."
|
|
23
|
+
exit 1
|
|
24
|
+
fi
|
|
25
|
+
|
|
26
|
+
mkdir -p "$CONFIG_DIR/plugins"
|
|
27
|
+
|
|
28
|
+
if [ -e "$TARGET_DIR" ] || [ -L "$TARGET_DIR" ]; then
|
|
29
|
+
if [ "$FORCE" != "--force" ]; then
|
|
30
|
+
echo "--> ${TARGET_DIR} already exists. Re-run with --force to replace it."
|
|
31
|
+
exit 1
|
|
32
|
+
fi
|
|
33
|
+
rm -rf "$TARGET_DIR"
|
|
34
|
+
fi
|
|
35
|
+
|
|
36
|
+
ln -s "$REPO_ROOT" "$TARGET_DIR"
|
|
37
|
+
|
|
38
|
+
echo "--> Linked ${TARGET_DIR} -> ${REPO_ROOT}"
|
|
39
|
+
echo "--> Make sure 'headroom proxy' is running before starting OpenCode."
|
|
40
|
+
echo "--> Restart the OpenCode server (e.g. 'opencode service restart') to load the plugin."
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Removes the global symlink created by scripts/install.sh.
|
|
3
|
+
# Refuses to touch anything that is not a symlink, in case a user replaced
|
|
4
|
+
# it with a real directory.
|
|
5
|
+
#
|
|
6
|
+
# Usage:
|
|
7
|
+
# bash scripts/uninstall.sh
|
|
8
|
+
set -euo pipefail
|
|
9
|
+
|
|
10
|
+
PLUGIN_NAME="headroom-context-compression"
|
|
11
|
+
CONFIG_DIR="${OPENCODE_CONFIG_DIR:-$HOME/.config/opencode}"
|
|
12
|
+
TARGET_DIR="$CONFIG_DIR/plugins/$PLUGIN_NAME"
|
|
13
|
+
|
|
14
|
+
if [ ! -e "$TARGET_DIR" ] && [ ! -L "$TARGET_DIR" ]; then
|
|
15
|
+
echo "--> Nothing installed at ${TARGET_DIR}."
|
|
16
|
+
exit 0
|
|
17
|
+
fi
|
|
18
|
+
|
|
19
|
+
if [ ! -L "$TARGET_DIR" ]; then
|
|
20
|
+
echo "--> ${TARGET_DIR} exists but is not a symlink; leaving it alone. Remove it manually if needed."
|
|
21
|
+
exit 1
|
|
22
|
+
fi
|
|
23
|
+
|
|
24
|
+
rm "$TARGET_DIR"
|
|
25
|
+
echo "--> Removed ${TARGET_DIR}"
|
|
26
|
+
echo "--> Restart the OpenCode server (e.g. 'opencode service restart') to unload the plugin."
|
package/src/bridge.js
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure message-compression logic: converts OpenCode's provider-agnostic
|
|
3
|
+
* `Message`/`ToolResultPart` shape (from `@opencode/ai`, seen in
|
|
4
|
+
* `session.hook("context", ...)`) into the plain "OpenAI-shape" messages
|
|
5
|
+
* Headroom's `POST /v1/compress` expects, and applies the compressed result
|
|
6
|
+
* back — touching only `tool` role messages.
|
|
7
|
+
*
|
|
8
|
+
* Ported from `@casualjim/pi-headroom` (pi-mimir) and, for the guard rules
|
|
9
|
+
* below, from its sibling fork `@raquezha/noheadroom` (both derive from
|
|
10
|
+
* `@ryan_nookpi/pi-extension-headroom`, MIT) — adapted to OpenCode's message
|
|
11
|
+
* model. Kept free of `@opencode/plugin` so it can be unit tested without a
|
|
12
|
+
* running OpenCode server.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { createHash } from "node:crypto"
|
|
16
|
+
|
|
17
|
+
/** Only these roles ever get sent to Headroom; `system` never leaves this plugin. */
|
|
18
|
+
const CONVERTIBLE_ROLES = new Set(["user", "assistant", "tool"])
|
|
19
|
+
|
|
20
|
+
// File reads and structural edits (edit/patch) supply exact anchors for later
|
|
21
|
+
// exact-match edits; compressing their output risks corrupting those anchors,
|
|
22
|
+
// and `skill` output is directive text meant to be followed verbatim. Ported
|
|
23
|
+
// from noheadroom's `PROTECTED_TOOLS`, renamed to OpenCode's built-in tool
|
|
24
|
+
// names. These never become compression candidates, regardless of size —
|
|
25
|
+
// unlike `renameToolCalls` below, which only concerns tools we DO allow to
|
|
26
|
+
// compress but still want to shield from Headroom's own default exclusions.
|
|
27
|
+
const PROTECTED_TOOLS = new Set(["read", "write", "edit", "patch", "skill", "headroom_retrieve"])
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @param {Array} messages - `event.messages` from the `context` hook.
|
|
31
|
+
* @param {number} minMessageChars - skip tool results shorter than this.
|
|
32
|
+
* @param {{ renameToolCalls?: boolean }} [options]
|
|
33
|
+
*/
|
|
34
|
+
export function buildCompressionPayload(messages, minMessageChars, options = {}) {
|
|
35
|
+
const renameToolCalls = options.renameToolCalls !== false
|
|
36
|
+
const mappings = []
|
|
37
|
+
|
|
38
|
+
for (let messageIndex = 0; messageIndex < messages.length; messageIndex++) {
|
|
39
|
+
const message = messages[messageIndex]
|
|
40
|
+
if (!CONVERTIBLE_ROLES.has(message.role)) continue
|
|
41
|
+
|
|
42
|
+
if (message.role === "tool") {
|
|
43
|
+
const parts = Array.isArray(message.content) ? message.content : []
|
|
44
|
+
for (let partIndex = 0; partIndex < parts.length; partIndex++) {
|
|
45
|
+
const part = parts[partIndex]
|
|
46
|
+
if (!part || part.type !== "tool-result") continue
|
|
47
|
+
const text = extractToolResultText(part)
|
|
48
|
+
// `null` means the result carries non-text content (files/images) or
|
|
49
|
+
// an error — never compressed, never even sent to Headroom.
|
|
50
|
+
if (text === null) continue
|
|
51
|
+
mappings.push({
|
|
52
|
+
messageIndex,
|
|
53
|
+
partIndex,
|
|
54
|
+
toolCallId: part.id,
|
|
55
|
+
toolName: part.name,
|
|
56
|
+
applyTo: !PROTECTED_TOOLS.has(part.name) && text.length >= minMessageChars,
|
|
57
|
+
originalText: text,
|
|
58
|
+
converted: { role: "tool", content: text, tool_call_id: part.id },
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
continue
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const converted = message.role === "user" ? convertUser(message) : convertAssistant(message, renameToolCalls)
|
|
65
|
+
if (!converted) continue
|
|
66
|
+
mappings.push({
|
|
67
|
+
messageIndex,
|
|
68
|
+
partIndex: -1,
|
|
69
|
+
toolCallId: undefined,
|
|
70
|
+
toolName: undefined,
|
|
71
|
+
applyTo: false,
|
|
72
|
+
originalText: extractText(converted),
|
|
73
|
+
converted,
|
|
74
|
+
})
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
messages: mappings.map((mapping) => mapping.converted),
|
|
79
|
+
mappings,
|
|
80
|
+
candidateCount: mappings.filter((mapping) => mapping.applyTo).length,
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Applies Headroom's compressed messages back onto the original OpenCode
|
|
86
|
+
* messages, touching only entries marked `applyTo`. Anything Headroom
|
|
87
|
+
* changed outside those entries (it may rewrite context for its own
|
|
88
|
+
* cross-message analysis) is ignored — the original message is kept as is.
|
|
89
|
+
*/
|
|
90
|
+
export function applyCompressionResult(messages, mappings, compressedMessages) {
|
|
91
|
+
if (!Array.isArray(compressedMessages) || compressedMessages.length !== mappings.length) {
|
|
92
|
+
return { ok: false, reason: "message-count-changed" }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const next = messages.slice()
|
|
96
|
+
let appliedMessages = 0
|
|
97
|
+
let tokensBefore = 0
|
|
98
|
+
let tokensAfter = 0
|
|
99
|
+
|
|
100
|
+
for (let index = 0; index < mappings.length; index++) {
|
|
101
|
+
const mapping = mappings[index]
|
|
102
|
+
if (!mapping.applyTo) continue
|
|
103
|
+
|
|
104
|
+
const compressed = compressedMessages[index]
|
|
105
|
+
if (!compressed || compressed.role !== "tool") return { ok: false, reason: "role-changed" }
|
|
106
|
+
if (compressed.tool_call_id !== mapping.converted.tool_call_id) return { ok: false, reason: "tool-call-id-changed" }
|
|
107
|
+
|
|
108
|
+
const rawText = typeof compressed.content === "string" ? compressed.content : mapping.originalText
|
|
109
|
+
if (rawText === mapping.originalText) continue
|
|
110
|
+
|
|
111
|
+
const nextText = naturalizeHeadroomMarkers(rawText)
|
|
112
|
+
// Reject the whole batch on an empty result: it is never a legitimate
|
|
113
|
+
// compression outcome and likely signals a malformed proxy response.
|
|
114
|
+
if (!nextText.trim()) return { ok: false, reason: "empty-compressed-content" }
|
|
115
|
+
// A single candidate that didn't actually shrink is skipped on its own —
|
|
116
|
+
// it does not invalidate the other candidates in this batch.
|
|
117
|
+
if (estimateTokens(nextText) >= estimateTokens(mapping.originalText)) continue
|
|
118
|
+
|
|
119
|
+
const original = next[mapping.messageIndex]
|
|
120
|
+
const originalPart = Array.isArray(original.content) ? original.content[mapping.partIndex] : undefined
|
|
121
|
+
if (!originalPart || originalPart.type !== "tool-result") return { ok: false, reason: "target-unreplaceable" }
|
|
122
|
+
|
|
123
|
+
const nextContent = original.content.slice()
|
|
124
|
+
nextContent[mapping.partIndex] = { ...originalPart, result: { type: "text", value: nextText } }
|
|
125
|
+
next[mapping.messageIndex] = { ...original, content: nextContent }
|
|
126
|
+
|
|
127
|
+
tokensBefore += estimateTokens(mapping.originalText)
|
|
128
|
+
tokensAfter += estimateTokens(nextText)
|
|
129
|
+
appliedMessages++
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (appliedMessages === 0) return { ok: false, reason: "no-applicable-message-changed" }
|
|
133
|
+
const tokensSaved = Math.max(0, tokensBefore - tokensAfter)
|
|
134
|
+
if (tokensSaved === 0) return { ok: false, reason: "no-estimated-token-savings" }
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
ok: true,
|
|
138
|
+
messages: next,
|
|
139
|
+
appliedMessages,
|
|
140
|
+
appliedTokensBefore: tokensBefore,
|
|
141
|
+
appliedTokensAfter: tokensAfter,
|
|
142
|
+
appliedTokensSaved: tokensSaved,
|
|
143
|
+
appliedCompressionRatio: tokensAfter / tokensBefore,
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Headroom's bracket markers carry the CCR hash. Keep the hash and name the
|
|
149
|
+
* tool that redeems it, so the model doesn't need an MCP server to resolve
|
|
150
|
+
* "Retrieve more: hash=...]" — our own `headroom_retrieve` tool does it.
|
|
151
|
+
* Idempotent: running it on already-naturalized text is a no-op (the
|
|
152
|
+
* rewritten phrasing no longer matches the source markers), which matters
|
|
153
|
+
* because replayed/cached text has already passed through this once.
|
|
154
|
+
*/
|
|
155
|
+
export function naturalizeHeadroomMarkers(text) {
|
|
156
|
+
return text
|
|
157
|
+
.replace(
|
|
158
|
+
/\[(.*?(?:compressed|omitted).*?)\.?\s*Retrieve (?:more|original): hash=([a-f0-9]{12,24})\]/gi,
|
|
159
|
+
"[$1. Retrieve the full original with the `headroom_retrieve` tool using hash=$2.]",
|
|
160
|
+
)
|
|
161
|
+
.replace(
|
|
162
|
+
/Retrieve original: hash=([a-f0-9]{12,24})/gi,
|
|
163
|
+
"Retrieve the full original with the `headroom_retrieve` tool using hash=$1",
|
|
164
|
+
)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Deterministic per-message fingerprint of the whole `event.messages` array,
|
|
169
|
+
* used by `guard.js` to detect an unchanged or already-compressed context
|
|
170
|
+
* without re-running the (more expensive) compression payload build.
|
|
171
|
+
*/
|
|
172
|
+
export function fingerprintMessages(messages) {
|
|
173
|
+
return messages.map((message) => `${message.role}:${stableHash(fingerprintText(message)).slice(0, 16)}`).join(",")
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Rough total context size, used to gate compression on small sessions where it isn't worth a proxy round trip. */
|
|
177
|
+
export function estimateContextTokens(messages) {
|
|
178
|
+
return messages.reduce((total, message) => total + estimateTokens(fingerprintText(message)), 0)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function stableHash(value) {
|
|
182
|
+
return createHash("sha256").update(value).digest("hex")
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function estimateTokens(text) {
|
|
186
|
+
// Cheap local estimate; an exact tokenizer would add a heavy dependency for a footer number.
|
|
187
|
+
return text.length === 0 ? 0 : Math.max(1, Math.ceil(text.length / 4))
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function fingerprintText(message) {
|
|
191
|
+
if (message.role === "tool") {
|
|
192
|
+
const parts = Array.isArray(message.content) ? message.content : []
|
|
193
|
+
return parts
|
|
194
|
+
.map((part) => (part?.type === "tool-result" ? `${part.id}:${extractToolResultText(part) ?? ""}` : ""))
|
|
195
|
+
.join("|")
|
|
196
|
+
}
|
|
197
|
+
if (message.role === "user") return joinTextParts(message.content)
|
|
198
|
+
if (message.role === "assistant") {
|
|
199
|
+
const parts = Array.isArray(message.content) ? message.content : []
|
|
200
|
+
const toolCallIds = parts
|
|
201
|
+
.filter((part) => part?.type === "tool-call")
|
|
202
|
+
.map((part) => part.id)
|
|
203
|
+
.join(",")
|
|
204
|
+
return `${joinTextParts(parts)}#${toolCallIds}`
|
|
205
|
+
}
|
|
206
|
+
return ""
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function extractToolResultText(part) {
|
|
210
|
+
const result = part.result
|
|
211
|
+
if (!result) return null
|
|
212
|
+
if (result.type === "text") return typeof result.value === "string" ? result.value : safeStringify(result.value)
|
|
213
|
+
if (result.type === "json") return safeStringify(result.value)
|
|
214
|
+
if (result.type === "content") {
|
|
215
|
+
const items = Array.isArray(result.value) ? result.value : []
|
|
216
|
+
// A file/image tool result loses meaning as text; leave it untouched
|
|
217
|
+
// rather than risk destroying an attachment.
|
|
218
|
+
if (items.some((item) => item?.type !== "text")) return null
|
|
219
|
+
return items.map((item) => item.text).join("\n")
|
|
220
|
+
}
|
|
221
|
+
return null // "error" results are left untouched
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function convertUser(message) {
|
|
225
|
+
const text = joinTextParts(message.content)
|
|
226
|
+
if (text) return { role: "user", content: text }
|
|
227
|
+
const hasMedia = Array.isArray(message.content) && message.content.some((part) => part?.type === "media")
|
|
228
|
+
return hasMedia ? { role: "user", content: "[media omitted from Headroom compression payload]" } : null
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function convertAssistant(message, renameToolCalls) {
|
|
232
|
+
const parts = Array.isArray(message.content) ? message.content : []
|
|
233
|
+
const text = joinTextParts(parts)
|
|
234
|
+
const toolCalls = parts.filter((part) => part?.type === "tool-call")
|
|
235
|
+
if (!text && toolCalls.length === 0) return null
|
|
236
|
+
|
|
237
|
+
const converted = { role: "assistant", content: text || null }
|
|
238
|
+
if (toolCalls.length > 0) {
|
|
239
|
+
converted.tool_calls = toolCalls.map((call) => ({
|
|
240
|
+
id: call.id,
|
|
241
|
+
type: "function",
|
|
242
|
+
function: {
|
|
243
|
+
// Headroom protects exact tool names (read, grep, ...) from compression
|
|
244
|
+
// via its DEFAULT_EXCLUDE_TOOLS; a neutral name keeps large results compressible
|
|
245
|
+
// for tools we allow to compress (PROTECTED_TOOLS above blocks the rest regardless).
|
|
246
|
+
name: renameToolCalls ? "opencode_tool_result" : call.name,
|
|
247
|
+
arguments: renameToolCalls ? JSON.stringify({ originalToolName: call.name }) : JSON.stringify(call.input ?? {}),
|
|
248
|
+
},
|
|
249
|
+
}))
|
|
250
|
+
}
|
|
251
|
+
return converted
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function joinTextParts(content) {
|
|
255
|
+
if (typeof content === "string") return content
|
|
256
|
+
if (!Array.isArray(content)) return ""
|
|
257
|
+
return content
|
|
258
|
+
.filter((part) => part?.type === "text")
|
|
259
|
+
.map((part) => part.text)
|
|
260
|
+
.join("\n")
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function extractText(converted) {
|
|
264
|
+
if (typeof converted.content === "string") return converted.content
|
|
265
|
+
return ""
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function safeStringify(value) {
|
|
269
|
+
if (typeof value === "string") return value
|
|
270
|
+
try {
|
|
271
|
+
return JSON.stringify(value)
|
|
272
|
+
} catch {
|
|
273
|
+
return String(value)
|
|
274
|
+
}
|
|
275
|
+
}
|
package/src/client.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal HTTP client for the two Headroom proxy endpoints this plugin uses:
|
|
3
|
+
* `POST /v1/compress` (stateless compression, no upstream provider call) and
|
|
4
|
+
* `GET /v1/retrieve/{hash}` (CCR original-content lookup).
|
|
5
|
+
*
|
|
6
|
+
* Never calls anything else on the proxy, and never starts or manages the
|
|
7
|
+
* `headroom proxy` process itself.
|
|
8
|
+
*/
|
|
9
|
+
export class HeadroomClient {
|
|
10
|
+
#baseUrl
|
|
11
|
+
#timeoutMs
|
|
12
|
+
|
|
13
|
+
constructor({ baseUrl, timeoutMs }) {
|
|
14
|
+
this.#baseUrl = baseUrl.replace(/\/+$/, "")
|
|
15
|
+
this.#timeoutMs = timeoutMs
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @param {Array} messages - OpenAI-shape messages from `buildCompressionPayload`.
|
|
20
|
+
* @param {string} [model] - drives Headroom's tokenizer/context-limit resolution.
|
|
21
|
+
*/
|
|
22
|
+
async compress(messages, model, signal) {
|
|
23
|
+
const response = await fetch(`${this.#baseUrl}/v1/compress`, {
|
|
24
|
+
method: "POST",
|
|
25
|
+
// A redirect could forward full tool results to a non-local host, bypassing `allowRemote`.
|
|
26
|
+
redirect: "error",
|
|
27
|
+
headers: { "content-type": "application/json", "x-headroom-stack": "opencode-plugin" },
|
|
28
|
+
body: JSON.stringify({ messages, model: model || "gpt-4o" }),
|
|
29
|
+
signal: this.#withTimeout(signal),
|
|
30
|
+
})
|
|
31
|
+
if (!response.ok) {
|
|
32
|
+
throw new Error(`Headroom /v1/compress failed with HTTP ${response.status}`)
|
|
33
|
+
}
|
|
34
|
+
const payload = await response.json()
|
|
35
|
+
return {
|
|
36
|
+
messages: payload.messages,
|
|
37
|
+
tokensBefore: payload.tokens_before,
|
|
38
|
+
tokensAfter: payload.tokens_after,
|
|
39
|
+
tokensSaved: payload.tokens_saved,
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** @returns {Promise<{ ok: true, content: string } | { ok: false, error: string }>} */
|
|
44
|
+
async retrieve(hash, signal) {
|
|
45
|
+
try {
|
|
46
|
+
const response = await fetch(`${this.#baseUrl}/v1/retrieve/${hash}`, {
|
|
47
|
+
redirect: "error",
|
|
48
|
+
signal: this.#withTimeout(signal),
|
|
49
|
+
})
|
|
50
|
+
if (!response.ok) {
|
|
51
|
+
return {
|
|
52
|
+
ok: false,
|
|
53
|
+
error:
|
|
54
|
+
response.status === 404
|
|
55
|
+
? "Entry not found in Headroom's CCR store (TTL 1800s) — the compressed content expired."
|
|
56
|
+
: `Headroom /v1/retrieve failed with HTTP ${response.status}`,
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
const body = await response.json()
|
|
60
|
+
if (typeof body === "string") return { ok: true, content: body }
|
|
61
|
+
if (body && typeof body.original_content === "string") return { ok: true, content: body.original_content }
|
|
62
|
+
return { ok: true, content: JSON.stringify(body, null, 2) }
|
|
63
|
+
} catch (error) {
|
|
64
|
+
return { ok: false, error: `Headroom /v1/retrieve failed: ${error instanceof Error ? error.message : String(error)}` }
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
#withTimeout(signal) {
|
|
69
|
+
const timeout = AbortSignal.timeout(this.#timeoutMs)
|
|
70
|
+
if (!signal) return timeout
|
|
71
|
+
return AbortSignal.any([signal, timeout])
|
|
72
|
+
}
|
|
73
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin configuration: resolved once at `setup(ctx)` time from plugin
|
|
3
|
+
* options (`opencode.json` `plugins[].options`) layered over environment
|
|
4
|
+
* variables. No settings file, no persistence — keep it simple until a real
|
|
5
|
+
* need for `/headroom`-style runtime toggling shows up.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export const DEFAULT_BASE_URL = "http://127.0.0.1:8787" // headroom proxy's own default
|
|
9
|
+
export const DEFAULT_MIN_CONTEXT_TOKENS = 10_000
|
|
10
|
+
export const DEFAULT_MIN_MESSAGE_CHARS = 2000
|
|
11
|
+
// Headroom's own internal budget for the compression step is 30s (seen in its proxy startup
|
|
12
|
+
// log, "Anthropic pre-upstream timeouts: ... compression=30.0s"); 40s leaves margin above that
|
|
13
|
+
// for large contexts (observed: 23.4s for 183 messages / ~135k tokens) instead of aborting a
|
|
14
|
+
// request Headroom would have finished successfully.
|
|
15
|
+
export const DEFAULT_TIMEOUT_MS = 40_000
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @param {Record<string, unknown>} options - `ctx.options`
|
|
19
|
+
* @param {Record<string, string | undefined>} env
|
|
20
|
+
*/
|
|
21
|
+
export function resolveConfig(options = {}, env = {}) {
|
|
22
|
+
return {
|
|
23
|
+
enabled: parseBoolean(options.enabled, parseBoolean(env.HEADROOM_ENABLED, true)),
|
|
24
|
+
baseUrl: normalizeBaseUrl(parseString(options.proxyUrl, env.HEADROOM_PROXY_URL) || DEFAULT_BASE_URL),
|
|
25
|
+
allowRemote: parseBoolean(options.allowRemote, parseBoolean(env.HEADROOM_ALLOW_REMOTE, false)),
|
|
26
|
+
renameToolCalls: parseBoolean(options.renameToolCalls, true),
|
|
27
|
+
// Below this estimated total context size, compression isn't worth a proxy round trip.
|
|
28
|
+
minContextTokens: parseInteger(
|
|
29
|
+
options.minContextTokens,
|
|
30
|
+
parseInteger(env.HEADROOM_MIN_CONTEXT_TOKENS, DEFAULT_MIN_CONTEXT_TOKENS, 0),
|
|
31
|
+
0,
|
|
32
|
+
),
|
|
33
|
+
minMessageChars: parseInteger(
|
|
34
|
+
options.minMessageChars,
|
|
35
|
+
parseInteger(env.HEADROOM_MIN_MESSAGE_CHARS, DEFAULT_MIN_MESSAGE_CHARS, 1),
|
|
36
|
+
1,
|
|
37
|
+
),
|
|
38
|
+
timeoutMs: parseInteger(options.timeoutMs, parseInteger(env.HEADROOM_TIMEOUT_MS, DEFAULT_TIMEOUT_MS, 100), 100),
|
|
39
|
+
// Provider IDs to skip entirely (e.g. a provider known to misbehave with Headroom).
|
|
40
|
+
// Compressing at the message level (this plugin) has no known need for this today —
|
|
41
|
+
// kept as an escape hatch, not a default exclusion.
|
|
42
|
+
excludeProviders: Array.isArray(options.excludeProviders) ? options.excludeProviders : [],
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function isLocalHeadroomUrl(rawUrl) {
|
|
47
|
+
try {
|
|
48
|
+
const url = new URL(rawUrl)
|
|
49
|
+
return ["localhost", "127.0.0.1", "::1", "[::1]"].includes(url.hostname)
|
|
50
|
+
} catch {
|
|
51
|
+
return false
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Compressing sends full tool-result content to `baseUrl`; never do that to a non-local proxy silently. */
|
|
56
|
+
export function isRemoteBlocked(config) {
|
|
57
|
+
return !config.allowRemote && !isLocalHeadroomUrl(config.baseUrl)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function normalizeBaseUrl(raw) {
|
|
61
|
+
return (raw || DEFAULT_BASE_URL).trim().replace(/\/+$/, "")
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function parseString(raw, fallback) {
|
|
65
|
+
if (typeof raw === "string" && raw.trim()) return raw.trim()
|
|
66
|
+
return typeof fallback === "string" ? fallback : undefined
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function parseBoolean(raw, fallback) {
|
|
70
|
+
if (raw === undefined) return fallback
|
|
71
|
+
if (typeof raw === "boolean") return raw
|
|
72
|
+
if (typeof raw !== "string") return fallback
|
|
73
|
+
const normalized = raw.trim().toLowerCase()
|
|
74
|
+
if (["1", "true", "yes", "on"].includes(normalized)) return true
|
|
75
|
+
if (["0", "false", "no", "off"].includes(normalized)) return false
|
|
76
|
+
return fallback
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function parseInteger(raw, fallback, min) {
|
|
80
|
+
if (raw === undefined) return fallback
|
|
81
|
+
const parsed = typeof raw === "number" ? raw : typeof raw === "string" ? Number.parseInt(raw, 10) : Number.NaN
|
|
82
|
+
if (!Number.isFinite(parsed) || parsed < min) return fallback
|
|
83
|
+
return Math.trunc(parsed)
|
|
84
|
+
}
|
package/src/guard.js
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-session guard state: cross-turn replay cache and loop-prevention
|
|
3
|
+
* fingerprints. Ported from `noheadroom` (source_app/noheadroom,
|
|
4
|
+
* extensions/headroom.ts), which solves the same problem for Pi: OpenCode,
|
|
5
|
+
* like Pi, does not persist a `context` hook's mutations back into session
|
|
6
|
+
* history, so every subsequent request resends the same large tool result
|
|
7
|
+
* uncompressed. Without this module the plugin would re-call Headroom's
|
|
8
|
+
* `/v1/compress` for identical content on every turn of a multi-step tool
|
|
9
|
+
* loop.
|
|
10
|
+
*
|
|
11
|
+
* Kept free of `@opencode/plugin` for unit testing. Unlike noheadroom (one
|
|
12
|
+
* process per Pi session), an OpenCode plugin instance can see multiple
|
|
13
|
+
* concurrent sessions, so `index.js` keys one state object per `sessionID`
|
|
14
|
+
* via `createSessionGuardState()` — this module has no concept of sessions
|
|
15
|
+
* itself.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { applyCompressionResult, stableHash } from "./bridge.js"
|
|
19
|
+
|
|
20
|
+
// Bounded FIFO, not config: a safety valve, not a tunable. 512 hashes is far
|
|
21
|
+
// beyond a realistic reread-loop case while keeping memory bounded for long
|
|
22
|
+
// sessions. Mirrors noheadroom's MAX_SEEN_CANDIDATE_CONTENT_FINGERPRINTS.
|
|
23
|
+
const MAX_TRACKED_FINGERPRINTS = 512
|
|
24
|
+
// Kills rapid repeated compression attempts (e.g. several hook invocations
|
|
25
|
+
// for the same turn) without needing to reason about their exact cause.
|
|
26
|
+
const THROTTLE_MS = 3000
|
|
27
|
+
|
|
28
|
+
export function createSessionGuardState() {
|
|
29
|
+
return {
|
|
30
|
+
lastCompressionTime: 0,
|
|
31
|
+
lastInputFingerprint: null,
|
|
32
|
+
lastOutputFingerprint: null,
|
|
33
|
+
lastGuardSkipCandidateFingerprint: null,
|
|
34
|
+
seenCandidateContentFingerprints: new Set(),
|
|
35
|
+
seenCandidateContentOrder: [],
|
|
36
|
+
compressedCandidates: new Map(),
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function shouldThrottle(state, now) {
|
|
41
|
+
return now - state.lastCompressionTime < THROTTLE_MS
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Identifies a specific tool-result occurrence, independent of its position in the message list. */
|
|
45
|
+
function candidateKey(mapping) {
|
|
46
|
+
return stableHash(JSON.stringify([mapping.toolCallId ?? null, mapping.toolName ?? null, stableHash(mapping.originalText)]))
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Reapplies previously-validated compressed text for candidates whose exact
|
|
51
|
+
* (tool call id, tool name, original text) was compressed before in this
|
|
52
|
+
* session — without calling Headroom again.
|
|
53
|
+
*
|
|
54
|
+
* @returns {Array|undefined} the replayed messages, or `undefined` if nothing in the cache applies.
|
|
55
|
+
*/
|
|
56
|
+
export function replayCachedCandidates(state, messages, payload) {
|
|
57
|
+
if (state.compressedCandidates.size === 0) return undefined
|
|
58
|
+
|
|
59
|
+
const mappings = payload.mappings.map((mapping) => ({
|
|
60
|
+
...mapping,
|
|
61
|
+
applyTo: mapping.applyTo && state.compressedCandidates.has(candidateKey(mapping)),
|
|
62
|
+
}))
|
|
63
|
+
const compressed = mappings.map((mapping) =>
|
|
64
|
+
mapping.applyTo ? { ...mapping.converted, content: state.compressedCandidates.get(candidateKey(mapping)) } : mapping.converted,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
const applied = applyCompressionResult(messages, mappings, compressed)
|
|
68
|
+
return applied.ok ? applied.messages : undefined
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Records validated compressions from a successful proxy round trip so later turns can replay them. */
|
|
72
|
+
export function recordCachedCandidates(state, payload, appliedMessages) {
|
|
73
|
+
for (const mapping of payload.mappings) {
|
|
74
|
+
if (!mapping.applyTo) continue
|
|
75
|
+
const part = appliedMessages[mapping.messageIndex]?.content?.[mapping.partIndex]
|
|
76
|
+
if (!part || part.type !== "tool-result" || part.result?.type !== "text") continue
|
|
77
|
+
const text = part.result.value
|
|
78
|
+
if (text === mapping.originalText) continue
|
|
79
|
+
|
|
80
|
+
state.compressedCandidates.set(candidateKey(mapping), text)
|
|
81
|
+
if (state.compressedCandidates.size > MAX_TRACKED_FINGERPRINTS) {
|
|
82
|
+
state.compressedCandidates.delete(state.compressedCandidates.keys().next().value)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** True once every current candidate's original text has already been seen (and rejected or replayed) before. */
|
|
88
|
+
export function allCandidateContentSeen(state, payload) {
|
|
89
|
+
const seen = state.seenCandidateContentFingerprints
|
|
90
|
+
const hashes = payload.mappings.filter((mapping) => mapping.applyTo).map((mapping) => stableHash(mapping.originalText))
|
|
91
|
+
return hashes.length > 0 && hashes.every((hash) => seen.has(hash))
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Mutates `payload.mappings` in place, excluding already-seen candidates from this round's proxy request. */
|
|
95
|
+
export function ignoreSeenCandidateContent(state, payload) {
|
|
96
|
+
const seen = state.seenCandidateContentFingerprints
|
|
97
|
+
for (const mapping of payload.mappings) {
|
|
98
|
+
if (mapping.applyTo && seen.has(stableHash(mapping.originalText))) mapping.applyTo = false
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function recordSeenCandidateContent(state, payload, appliedMessages) {
|
|
103
|
+
for (const mapping of payload.mappings) {
|
|
104
|
+
if (!mapping.applyTo) continue
|
|
105
|
+
addSeenHash(state, stableHash(mapping.originalText))
|
|
106
|
+
const part = appliedMessages[mapping.messageIndex]?.content?.[mapping.partIndex]
|
|
107
|
+
if (part?.type === "tool-result" && part.result?.type === "text") addSeenHash(state, stableHash(part.result.value))
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function addSeenHash(state, hash) {
|
|
112
|
+
if (state.seenCandidateContentFingerprints.has(hash)) return
|
|
113
|
+
state.seenCandidateContentFingerprints.add(hash)
|
|
114
|
+
state.seenCandidateContentOrder.push(hash)
|
|
115
|
+
while (state.seenCandidateContentOrder.length > MAX_TRACKED_FINGERPRINTS) {
|
|
116
|
+
const oldest = state.seenCandidateContentOrder.shift()
|
|
117
|
+
state.seenCandidateContentFingerprints.delete(oldest)
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Fingerprints only the candidates eligible this round, so unrelated conversation growth doesn't defeat the guard below. */
|
|
122
|
+
export function generateCandidateFingerprint(payload) {
|
|
123
|
+
const units = payload.mappings
|
|
124
|
+
.filter((mapping) => mapping.applyTo)
|
|
125
|
+
.map((mapping) => ({
|
|
126
|
+
messageIndex: mapping.messageIndex,
|
|
127
|
+
partIndex: mapping.partIndex,
|
|
128
|
+
toolCallId: mapping.toolCallId ?? null,
|
|
129
|
+
toolName: mapping.toolName ?? null,
|
|
130
|
+
textLength: mapping.originalText.length,
|
|
131
|
+
textHash: stableHash(mapping.originalText),
|
|
132
|
+
}))
|
|
133
|
+
return stableHash(JSON.stringify(units))
|
|
134
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenCode v2 plugin: transparent Headroom context compression.
|
|
3
|
+
*
|
|
4
|
+
* Hooks `session.hook("context", ...)` — the provider-agnostic message list
|
|
5
|
+
* OpenCode assembles immediately before dispatching to the model, *before*
|
|
6
|
+
* any provider-specific lowering (Anthropic Messages, OpenAI Chat, Bedrock
|
|
7
|
+
* Converse, ...) or request signing happens. Only `tool` role messages are
|
|
8
|
+
* sent to Headroom's stateless `POST /v1/compress` for compression; user and
|
|
9
|
+
* assistant text are never touched, and PROTECTED_TOOLS (see bridge.js) are
|
|
10
|
+
* never candidates regardless of size. Because this runs above the wire
|
|
11
|
+
* protocol, it works identically for every provider OpenCode supports —
|
|
12
|
+
* including AWS Bedrock, whose native Converse API and SigV4-signed
|
|
13
|
+
* requests made an earlier `http.request`-based approach unworkable (see
|
|
14
|
+
* MEMORY.md).
|
|
15
|
+
*
|
|
16
|
+
* The guard/replay logic in guard.js follows `noheadroom`
|
|
17
|
+
* (source_app/noheadroom): a cross-turn cache avoids recompressing the same
|
|
18
|
+
* tool result on every step of a multi-step turn (OpenCode doesn't persist
|
|
19
|
+
* `context` mutations into history, so the uncompressed original reappears
|
|
20
|
+
* on the next call), and fingerprint guards throttle/skip redundant proxy
|
|
21
|
+
* calls when the eligible content hasn't changed.
|
|
22
|
+
*
|
|
23
|
+
* Prerequisite: `headroom proxy` must already be running and reachable.
|
|
24
|
+
* This plugin never starts, stops, or otherwise manages that process.
|
|
25
|
+
*
|
|
26
|
+
* @see https://docs.headroomlabs.ai/docs/proxy#post-v1compress
|
|
27
|
+
* @see https://opencode.ai/v2/docs/build/plugins
|
|
28
|
+
*/
|
|
29
|
+
import { Plugin } from "@opencode/plugin"
|
|
30
|
+
import { applyCompressionResult, buildCompressionPayload, estimateContextTokens, fingerprintMessages } from "./bridge.js"
|
|
31
|
+
import { HeadroomClient } from "./client.js"
|
|
32
|
+
import { isRemoteBlocked, resolveConfig } from "./config.js"
|
|
33
|
+
import {
|
|
34
|
+
allCandidateContentSeen,
|
|
35
|
+
createSessionGuardState,
|
|
36
|
+
generateCandidateFingerprint,
|
|
37
|
+
ignoreSeenCandidateContent,
|
|
38
|
+
recordCachedCandidates,
|
|
39
|
+
recordSeenCandidateContent,
|
|
40
|
+
replayCachedCandidates,
|
|
41
|
+
shouldThrottle,
|
|
42
|
+
} from "./guard.js"
|
|
43
|
+
|
|
44
|
+
// Bounds memory if a server process outlives many short-lived sessions; the least recently
|
|
45
|
+
// used session is evicted first, well before this is ever reached in normal interactive use.
|
|
46
|
+
const MAX_TRACKED_SESSIONS = 200
|
|
47
|
+
|
|
48
|
+
export default Plugin.define({
|
|
49
|
+
id: "headroom-context-compression",
|
|
50
|
+
async setup(ctx) {
|
|
51
|
+
const config = resolveConfig(ctx.options, process.env)
|
|
52
|
+
const client = new HeadroomClient({ baseUrl: config.baseUrl, timeoutMs: config.timeoutMs })
|
|
53
|
+
const sessionStates = new Map()
|
|
54
|
+
let proxyWarningShown = false
|
|
55
|
+
|
|
56
|
+
if (config.enabled && isRemoteBlocked(config)) {
|
|
57
|
+
console.warn(
|
|
58
|
+
`[headroom] Compression disabled: ${config.baseUrl} is not local and "allowRemote" is not set. ` +
|
|
59
|
+
"Tool results would be sent to that proxy in full before compression. Set options.allowRemote to true only for a trusted proxy.",
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
await ctx.session.hook("context", async (event) => {
|
|
64
|
+
if (!config.enabled || isRemoteBlocked(config)) return
|
|
65
|
+
if (config.excludeProviders.includes(event.model.providerID)) return
|
|
66
|
+
if (estimateContextTokens(event.messages) < config.minContextTokens) return
|
|
67
|
+
|
|
68
|
+
const state = sessionStateFor(sessionStates, event.sessionID)
|
|
69
|
+
const payload = buildCompressionPayload(event.messages, config.minMessageChars, {
|
|
70
|
+
renameToolCalls: config.renameToolCalls,
|
|
71
|
+
})
|
|
72
|
+
const replayed = replayCachedCandidates(state, event.messages, payload)
|
|
73
|
+
const bypass = () => replaceMessages(event, replayed)
|
|
74
|
+
|
|
75
|
+
if (payload.candidateCount === 0) return bypass()
|
|
76
|
+
|
|
77
|
+
const now = Date.now()
|
|
78
|
+
if (shouldThrottle(state, now)) return bypass()
|
|
79
|
+
|
|
80
|
+
const inputFingerprint = fingerprintMessages(event.messages)
|
|
81
|
+
// Our own compressed output looping back as next-turn input: nothing new to do, and no replay needed.
|
|
82
|
+
if (state.lastOutputFingerprint === inputFingerprint) return
|
|
83
|
+
if (state.lastInputFingerprint === inputFingerprint) return bypass()
|
|
84
|
+
|
|
85
|
+
const candidateFingerprint = generateCandidateFingerprint(payload)
|
|
86
|
+
if (state.lastGuardSkipCandidateFingerprint === candidateFingerprint) return bypass()
|
|
87
|
+
if (allCandidateContentSeen(state, payload)) return bypass()
|
|
88
|
+
ignoreSeenCandidateContent(state, payload)
|
|
89
|
+
|
|
90
|
+
state.lastCompressionTime = now
|
|
91
|
+
let result
|
|
92
|
+
try {
|
|
93
|
+
result = await client.compress(payload.messages, event.model.id)
|
|
94
|
+
} catch (error) {
|
|
95
|
+
if (!proxyWarningShown) {
|
|
96
|
+
proxyWarningShown = true
|
|
97
|
+
console.warn(
|
|
98
|
+
`[headroom] compression skipped, proxy unavailable at ${config.baseUrl} (further failures are silent until it recovers): ` +
|
|
99
|
+
(error instanceof Error ? error.message : String(error)),
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
return bypass()
|
|
103
|
+
}
|
|
104
|
+
proxyWarningShown = false
|
|
105
|
+
if (!result.tokensSaved || result.tokensSaved <= 0) {
|
|
106
|
+
state.lastInputFingerprint = inputFingerprint
|
|
107
|
+
state.lastGuardSkipCandidateFingerprint = candidateFingerprint
|
|
108
|
+
return bypass()
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Apply on top of the replayed messages, not the originals: candidates excluded from this
|
|
112
|
+
// round by ignoreSeenCandidateContent are the ones already compressed (and cached) earlier,
|
|
113
|
+
// and must stay compressed. noheadroom stitches the same replayed values back in.
|
|
114
|
+
const applied = applyCompressionResult(replayed ?? event.messages, payload.mappings, result.messages)
|
|
115
|
+
if (!applied.ok) {
|
|
116
|
+
state.lastInputFingerprint = inputFingerprint
|
|
117
|
+
state.lastOutputFingerprint = null
|
|
118
|
+
state.lastGuardSkipCandidateFingerprint = candidateFingerprint
|
|
119
|
+
console.warn(`[headroom] compression not applied: ${applied.reason}`)
|
|
120
|
+
return bypass()
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
state.lastInputFingerprint = inputFingerprint
|
|
124
|
+
state.lastGuardSkipCandidateFingerprint = null
|
|
125
|
+
recordSeenCandidateContent(state, payload, applied.messages)
|
|
126
|
+
recordCachedCandidates(state, payload, applied.messages)
|
|
127
|
+
state.lastOutputFingerprint = fingerprintMessages(applied.messages)
|
|
128
|
+
replaceMessages(event, applied.messages)
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
await ctx.tool.transform((editor) => {
|
|
132
|
+
editor.add({
|
|
133
|
+
name: "headroom_retrieve",
|
|
134
|
+
description:
|
|
135
|
+
"Retrieve the full original content that Headroom compressed. Use the exact hash from a compression " +
|
|
136
|
+
"marker (e.g. `Retrieve more: hash=...`) in a tool result.",
|
|
137
|
+
input: {
|
|
138
|
+
type: "object",
|
|
139
|
+
properties: { hash: { type: "string", description: "The compression marker hash (12-24 hex characters)." } },
|
|
140
|
+
required: ["hash"],
|
|
141
|
+
additionalProperties: false,
|
|
142
|
+
},
|
|
143
|
+
execute: async ({ hash }, toolContext) => {
|
|
144
|
+
const normalized = String(hash ?? "").trim().toLowerCase()
|
|
145
|
+
if (!/^[a-f0-9]{12,24}$/.test(normalized)) {
|
|
146
|
+
return { content: JSON.stringify({ error: "Invalid hash format. Expected 12-24 hex characters." }) }
|
|
147
|
+
}
|
|
148
|
+
const result = await client.retrieve(normalized, toolContext?.signal)
|
|
149
|
+
return { content: result.ok ? result.content : JSON.stringify({ error: result.error, hash: normalized }) }
|
|
150
|
+
},
|
|
151
|
+
})
|
|
152
|
+
})
|
|
153
|
+
},
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
function sessionStateFor(sessionStates, sessionID) {
|
|
157
|
+
const existing = sessionStates.get(sessionID)
|
|
158
|
+
if (existing) {
|
|
159
|
+
// Re-insert to mark as most recently used, so eviction below drops idle sessions first.
|
|
160
|
+
sessionStates.delete(sessionID)
|
|
161
|
+
sessionStates.set(sessionID, existing)
|
|
162
|
+
return existing
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (sessionStates.size >= MAX_TRACKED_SESSIONS) {
|
|
166
|
+
sessionStates.delete(sessionStates.keys().next().value)
|
|
167
|
+
}
|
|
168
|
+
const state = createSessionGuardState()
|
|
169
|
+
sessionStates.set(sessionID, state)
|
|
170
|
+
return state
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function replaceMessages(event, messages) {
|
|
174
|
+
if (!messages) return
|
|
175
|
+
messages.forEach((message, index) => {
|
|
176
|
+
event.messages[index] = message
|
|
177
|
+
})
|
|
178
|
+
}
|