pi-cliffcompaction 0.1.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/CHANGELOG.md +13 -0
- package/LICENSE +21 -0
- package/README.md +291 -0
- package/config.example.json +12 -0
- package/index.ts +185 -0
- package/lib/cliff.ts +134 -0
- package/lib/config.ts +225 -0
- package/lib/decode.ts +103 -0
- package/lib/dialects/anthropic.ts +364 -0
- package/lib/dialects/base.ts +79 -0
- package/lib/dialects/index.ts +48 -0
- package/lib/dialects/openai-chat.ts +225 -0
- package/lib/dialects/openai-responses.ts +331 -0
- package/lib/dialects/pi.ts +249 -0
- package/lib/engine.ts +558 -0
- package/lib/hashing.ts +31 -0
- package/lib/images.ts +268 -0
- package/lib/index.ts +62 -0
- package/lib/json.ts +256 -0
- package/lib/pi-hook.ts +253 -0
- package/lib/store.ts +94 -0
- package/package.json +76 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0
|
|
4
|
+
|
|
5
|
+
First release. Port of CliffCompaction (Nguyen, Cho, Chen & Dettmers,
|
|
6
|
+
arXiv:2609.26779) and the reference implementation at
|
|
7
|
+
https://github.com/nguyenvuthientrang/cliffcompaction to a Pi/OMP package.
|
|
8
|
+
|
|
9
|
+
- Mechanical compaction: truncate or drop, never rephrase
|
|
10
|
+
- Never compact a compaction: each pass operates on original live-session turns
|
|
11
|
+
- Dialects: Anthropic Messages, OpenAI Chat Completions, OpenAI Responses, Pi
|
|
12
|
+
- Prefix-store engine with hash chain, image-aware chars/4 estimates, escalation ladder
|
|
13
|
+
- Pi hook: `session_before_compact` replaces the LLM summarizer
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 AdityaVG13
|
|
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
|
|
21
|
+
THE SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
# pi-cliffcompaction
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/pi-cliffcompaction)
|
|
4
|
+
[](https://github.com/AdityaVG13/pi-stack/blob/main/packages/pi-cliffcompaction/LICENSE)
|
|
5
|
+
[](https://nodejs.org)
|
|
6
|
+
[](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/packages.md)
|
|
7
|
+
|
|
8
|
+
Mechanical autocompaction for [Pi](https://pi.dev) and [OMP](https://omp.sh). When the session hits Pi's compact trigger, this package **does not call a model**. It keeps the last few turns verbatim and replaces the rest with excerpts: truncate or drop, never rephrase, never compact a compaction.
|
|
9
|
+
|
|
10
|
+
TypeScript port of **CliffCompaction** (Nguyen, Cho, Chen, and Dettmers):
|
|
11
|
+
|
|
12
|
+
- Paper: [CliffCompaction: Cost-Efficient Compaction for Long-Horizon Coding Agents](https://arxiv.org/abs/2609.26779) ([PDF](https://arxiv.org/pdf/2609.26779))
|
|
13
|
+
- Reference implementation: [nguyenvuthientrang/cliffcompaction](https://github.com/nguyenvuthientrang/cliffcompaction)
|
|
14
|
+
|
|
15
|
+
Needs Pi 0.82+ (or OMP) and Node 22+. No Python runtime.
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pi install npm:pi-cliffcompaction
|
|
19
|
+
omp install npm:pi-cliffcompaction
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
After install, **fully restart** Pi/OMP. `/reload` can keep old JavaScript modules loaded.
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
## Why use it
|
|
27
|
+
|
|
28
|
+
Default Pi compact asks a model to write a structured memo (`Goal`, `Progress`, `Key Decisions`, file lists). Tool dumps are truncated for that summarizer, then rewritten. The last ~20k tokens stay verbatim. The previous memo is fed into the next one.
|
|
29
|
+
|
|
30
|
+
This package keeps the same *when* (token window, overflow, `/compact`) and replaces *what*. Last **3 assistant-step turns** stay raw. Older bulk is cut by content class. The previous cliff is discarded, not re-summarized.
|
|
31
|
+
|
|
32
|
+
| | Regular Pi compact | CliffCompaction |
|
|
33
|
+
|---|---|---|
|
|
34
|
+
| Compact cost | Extra LLM call (latency + tokens) | Instant, no summarizer call |
|
|
35
|
+
| What the working model gets | A story of the session | Cuts of the session |
|
|
36
|
+
| Hallucination | Can invent progress or drop numbers | Cannot rephrase |
|
|
37
|
+
| Drift over many cliffs | Summary of a summary | Always from the live tail |
|
|
38
|
+
| Recent verbatim | ~20k tokens | 3 turns (configurable) |
|
|
39
|
+
| Long tool output | Summarizer may extract a fact from ~2k chars | Dropped if longer than 500 chars |
|
|
40
|
+
| `/compact focus on X` | Honored | Ignored (no LLM to instruct) |
|
|
41
|
+
| File lists (`read` / `modified`) | Carried in the memo | Not carried (hook summaries skip Pi's file tracker) |
|
|
42
|
+
| `/tree` branch summary | Default LLM | Untouched |
|
|
43
|
+
|
|
44
|
+
**Worth it** for long agent loops whose context is mostly huge `read` / `bash` dumps, where the next turn needs the last few raw turns plus "what was called." That is the paper's workload.
|
|
45
|
+
|
|
46
|
+
**Use regular compact instead** when the fact you need later lives *inside* a long tool result (a signature, an error buried in 8k of log). The LLM memo can keep that sentence. Cliff will have dropped the dump. Also better if you want a human-readable "where were we?" or you pass instructions to `/compact`.
|
|
47
|
+
|
|
48
|
+
---
|
|
49
|
+
|
|
50
|
+
## Install
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
pi install npm:pi-cliffcompaction
|
|
54
|
+
omp install npm:pi-cliffcompaction
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
From a clone of [AdityaVG13/pi-stack](https://github.com/AdityaVG13/pi-stack), inside the repo:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
pi install ./packages/pi-cliffcompaction
|
|
61
|
+
omp install ./packages/pi-cliffcompaction
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Library only (no Pi extension):
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
npm install pi-cliffcompaction
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
```js
|
|
71
|
+
import { compact, Engine, makeConfig } from "pi-cliffcompaction/lib";
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Installing this package **takes over** `session_before_compact`. Pi still decides when to compact (`compaction.reserveTokens` / `compaction.keepRecentTokens`). This package decides what the summary contains and which suffix stays verbatim. Load only one compaction extension.
|
|
75
|
+
|
|
76
|
+
---
|
|
77
|
+
|
|
78
|
+
## Use
|
|
79
|
+
|
|
80
|
+
Activation is automatic. There is no tool to call.
|
|
81
|
+
|
|
82
|
+
| Trigger | What happens |
|
|
83
|
+
|---|---|
|
|
84
|
+
| Context crosses `contextWindow - reserveTokens` (Pi default reserve: 16384) | Mechanical cliff instead of an LLM rewrite |
|
|
85
|
+
| `/compact` | Same, on demand. Extra instructions are ignored |
|
|
86
|
+
| Provider overflow / length recovery | Same, with a keep-recent=1 ladder |
|
|
87
|
+
|
|
88
|
+
Confirm it loaded:
|
|
89
|
+
|
|
90
|
+
```text
|
|
91
|
+
/cliff status
|
|
92
|
+
/cliff config
|
|
93
|
+
/cliff reload
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
`/cliff status` prints the knobs and, after a cliff, the last event. The footer shows `cliff · threshold · kept N` (or `overflow` / `manual`).
|
|
97
|
+
|
|
98
|
+
A brand-new empty chat will not compact (nothing to gain; fail-open). Force a cliff with `/compact` after a few tool turns.
|
|
99
|
+
|
|
100
|
+
To restore Pi's LLM summarizer without uninstalling:
|
|
101
|
+
|
|
102
|
+
```json
|
|
103
|
+
{ "enabled": false }
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
in the config file below, then `/cliff reload` (or restart).
|
|
107
|
+
|
|
108
|
+
---
|
|
109
|
+
|
|
110
|
+
## How it works
|
|
111
|
+
|
|
112
|
+
Context grows append-only until Pi fires compact. Then:
|
|
113
|
+
|
|
114
|
+
1. **Head** (system + the first user/task messages before the first assistant turn) is preserved. Pi has no hole in the provider transcript, so that text is folded into the summary string.
|
|
115
|
+
2. **Last `keepRecent` assistant-step turns** stay verbatim (`firstKeptEntryId`).
|
|
116
|
+
3. **The middle** becomes one summary message, built by content class.
|
|
117
|
+
4. **A previous CliffCompaction summary is dropped**, not nested. The next pass compresses only original messages since the last kept boundary.
|
|
118
|
+
|
|
119
|
+
That is the cliff: a sharp drop to roughly the same floor after every compaction, with KV-cache reuse *between* cliffs.
|
|
120
|
+
|
|
121
|
+
### Content classes
|
|
122
|
+
|
|
123
|
+
Defaults match the [GitHub proxy](https://github.com/nguyenvuthientrang/cliffcompaction), not the paper's 300-character thought cap. Set `"thoughtMaxChars": 300` to match Algorithm 1.
|
|
124
|
+
|
|
125
|
+
| Content | Treatment |
|
|
126
|
+
|---|---|
|
|
127
|
+
| Tool results | Kept iff length <= 500 chars (`resultMaxChars`), else dropped |
|
|
128
|
+
| Tool calls | One-line signatures: `[name] {truncated args}` (150 chars, `cmdMaxChars`) |
|
|
129
|
+
| Assistant text | Full by default (`thoughtMaxChars: 0`). Paper Algorithm 1 used 300 |
|
|
130
|
+
| Thinking / reasoning | Kept as **text** by default; signatures / encrypted blocks are never re-sent. `keepThinking: false` drops it. Independent cap: `thinkingMaxChars` |
|
|
131
|
+
| Human text | Verbatim, sanity-capped at 20000 chars |
|
|
132
|
+
| Images | Dropped from summaries; still verbatim in head and recent turns |
|
|
133
|
+
| Prior summary | Dropped entirely |
|
|
134
|
+
|
|
135
|
+
No auxiliary LLM call. If the mechanical pass cannot shrink the history, the handler returns and Pi's default path runs. Shadow mode cancels compaction so the original history is forwarded unchanged.
|
|
136
|
+
|
|
137
|
+
### Escalation (overflow / strict)
|
|
138
|
+
|
|
139
|
+
If the default floor is still over budget:
|
|
140
|
+
|
|
141
|
+
1. `keepRecent = 1`
|
|
142
|
+
2. Cap assistant text at 300 and drop thinking
|
|
143
|
+
3. (strict) Truncate the summary itself, newest parts kept
|
|
144
|
+
|
|
145
|
+
---
|
|
146
|
+
|
|
147
|
+
## Paper vs GitHub vs this package
|
|
148
|
+
|
|
149
|
+
Algorithm 1 in the paper is the research writeup. The GitHub proxy is the executable algorithm. This package gold-matches GitHub.
|
|
150
|
+
|
|
151
|
+
| Piece | Paper Alg. 1 | GitHub (gold) | This package |
|
|
152
|
+
|---|---|---|---|
|
|
153
|
+
| Keep-recent | last 2K messages (K turn pairs) | last K assistant-step turns (default 3) | GitHub |
|
|
154
|
+
| Thought cap | 300 chars | 0 = unlimited | GitHub; set `thoughtMaxChars: 300` for the paper |
|
|
155
|
+
| Tool result | keep iff <= 500 | same | same |
|
|
156
|
+
| Tool signature | 150 chars | same | same |
|
|
157
|
+
| Prior summary | skip | skip | same |
|
|
158
|
+
| Head | `messages[0], messages[1]` in QUERY | everything before first assistant | same in `compact()`; Pi adapter folds head into the summary string |
|
|
159
|
+
| Never compact a compaction | discard previous cliff | drop previous summary; compact only live turns | same |
|
|
160
|
+
|
|
161
|
+
Human text is not in the pseudocode loop. Section 2.2 of the paper says keep it verbatim. GitHub and this package do that.
|
|
162
|
+
|
|
163
|
+
Pi cannot keep a non-contiguous head+tail in the provider transcript. The Python proxy can leave original head messages as separate objects. The cut rules are the same; cache shape is not.
|
|
164
|
+
|
|
165
|
+
This is not a network proxy. It does not speak Anthropic/OpenAI HTTP, install launchd/systemd, or replace `/tree` branch summarization.
|
|
166
|
+
|
|
167
|
+
---
|
|
168
|
+
|
|
169
|
+
## Config
|
|
170
|
+
|
|
171
|
+
Optional. Defaults match the open-source proxy (assistant text unlimited, `keepRecent: 3`).
|
|
172
|
+
|
|
173
|
+
| Host | File |
|
|
174
|
+
|---|---|
|
|
175
|
+
| Pi | `~/.pi/agent/cliffcompaction.json` |
|
|
176
|
+
| OMP | `~/.omp/agent/cliffcompaction.json` |
|
|
177
|
+
|
|
178
|
+
Override path: `PI_CLIFF_CONFIG` / `OMP_CLIFF_CONFIG`, or `PI_CONFIG_DIR` / `OMP_CONFIG_DIR`. `CLIFF_*` env vars override the file. See [config.example.json](./config.example.json).
|
|
179
|
+
|
|
180
|
+
```json
|
|
181
|
+
{
|
|
182
|
+
"enabled": true,
|
|
183
|
+
"keepRecent": 3,
|
|
184
|
+
"thoughtMaxChars": 0,
|
|
185
|
+
"thinkingMaxChars": 0,
|
|
186
|
+
"keepThinking": true,
|
|
187
|
+
"cmdMaxChars": 150,
|
|
188
|
+
"resultMaxChars": 500,
|
|
189
|
+
"humanMaxChars": 20000,
|
|
190
|
+
"shadow": false,
|
|
191
|
+
"strict": false
|
|
192
|
+
}
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
| Setting | Default | Meaning |
|
|
196
|
+
|---|---|---|
|
|
197
|
+
| `enabled` | `true` | `false` restores Pi's LLM summarizer |
|
|
198
|
+
| `keepRecent` | `3` | Verbatim assistant-step turns at the tail |
|
|
199
|
+
| `thoughtMaxChars` | `0` (unlimited) | Cap on assistant text in the summary |
|
|
200
|
+
| `thinkingMaxChars` | `0` | Cap on thinking text (independent) |
|
|
201
|
+
| `keepThinking` | `true` | Fold thinking as text; `false` drops it |
|
|
202
|
+
| `cmdMaxChars` | `150` | Tool-call signature budget |
|
|
203
|
+
| `resultMaxChars` | `500` | Longer tool results are dropped |
|
|
204
|
+
| `humanMaxChars` | `20000` | Sanity cap on user text in the summary |
|
|
205
|
+
| `shadow` | `false` | Cancel compaction; log what would have happened |
|
|
206
|
+
| `strict` | `false` | Walk summary truncation (rung 3) when still over the library threshold |
|
|
207
|
+
| `thresholdTokens` | `200000` | Engine/library trigger (chars/4). Pi's own trigger is separate |
|
|
208
|
+
|
|
209
|
+
After edits: `/cliff reload`.
|
|
210
|
+
|
|
211
|
+
Tuning *when* cliffs fire is a Pi setting, not this file:
|
|
212
|
+
|
|
213
|
+
```json
|
|
214
|
+
{
|
|
215
|
+
"compaction": {
|
|
216
|
+
"enabled": true,
|
|
217
|
+
"reserveTokens": 16384,
|
|
218
|
+
"keepRecentTokens": 20000
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
This package then overrides the kept suffix to `keepRecent` **turns**, not `keepRecentTokens`.
|
|
224
|
+
|
|
225
|
+
---
|
|
226
|
+
|
|
227
|
+
## Library
|
|
228
|
+
|
|
229
|
+
The Pi extension is a thin adapter. The algorithm is importable with no Pi host:
|
|
230
|
+
|
|
231
|
+
| Export | Role |
|
|
232
|
+
|---|---|
|
|
233
|
+
| `compact(messages, dialect, cfg)` | Algorithm 1. Returns `{ messages, headLen, summary, cut }` or `null` if there is nothing to gain. Kept messages are the original objects. |
|
|
234
|
+
| `Engine.prepare(body, dialect)` | Reference proxy pipeline: hash-chain, longest stored prefix, compact over `thresholdTokens`, store under the original chain hash. |
|
|
235
|
+
| `Engine.reactive(ctx)` | Context-length error ladder. |
|
|
236
|
+
| Dialects | `anthropic`, `openai` (Chat Completions), `openai-responses`, `pi` |
|
|
237
|
+
|
|
238
|
+
Token estimate is chars/4 on `json.dumps`-style serialization, except images which are priced from PNG/JPEG/GIF/WebP dimensions (Anthropic 28x28 patches, cap 4784).
|
|
239
|
+
|
|
240
|
+
---
|
|
241
|
+
|
|
242
|
+
## Invariants
|
|
243
|
+
|
|
244
|
+
- Summaries contain only excerpts of original text, never a paraphrase.
|
|
245
|
+
- Exactly one summary header after compaction; re-compaction does not nest.
|
|
246
|
+
- Head messages and kept tail messages are identity-equal to the input objects (library `compact` / `Engine`).
|
|
247
|
+
- A mutated history fails to match the prefix store and is forwarded verbatim.
|
|
248
|
+
- Image token cost does not track base64 length.
|
|
249
|
+
|
|
250
|
+
## Error model
|
|
251
|
+
|
|
252
|
+
- `compact` returns `null` when there is no assistant turn, not enough turns to keep, or the rewrite would not shrink the list.
|
|
253
|
+
- `Engine.prepare` never throws on a well-formed body; store misses and inconsistent entries fail-open to passthrough.
|
|
254
|
+
- The Pi hook catches handler errors and returns undefined (Pi default compaction).
|
|
255
|
+
- `strict: true` on the engine marks `overBudget` after the ladder; the Pi hook uses rung 3 truncation when `strict` is set.
|
|
256
|
+
|
|
257
|
+
## Tests
|
|
258
|
+
|
|
259
|
+
```bash
|
|
260
|
+
npm test --prefix packages/pi-cliffcompaction
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
119 tests, including `test/reference-gold.test.mjs`: bit-level checks against the Python reference on shared fixtures (compact output, hash chains, billable chars, `Engine.prepare` cuts/estimates, published image-token table).
|
|
264
|
+
|
|
265
|
+
## No-claim boundaries
|
|
266
|
+
|
|
267
|
+
- This package does **not** claim the paper's SWE-bench / Terminal-Bench / KernelBench scores. Those were measured on other scaffolds with this algorithm.
|
|
268
|
+
- Trigger timing is Pi's (`compaction.reserveTokens`). The GitHub proxy default of 200k tokens is a library default, not what Pi uses unless you set Pi's reserve so the remaining window matches.
|
|
269
|
+
- Selector / Soft Group Verification from the paper is out of scope.
|
|
270
|
+
|
|
271
|
+
## Gotchas
|
|
272
|
+
|
|
273
|
+
- Other extensions that also handle `session_before_compact` will race. Load one.
|
|
274
|
+
- `/compact` with extra instructions is ignored.
|
|
275
|
+
- Shadow mode on overflow cancels recovery compaction; the overflowing request is left as-is (fail-open).
|
|
276
|
+
- Long tool results older than `keepRecent` turns are gone. If the model starts re-reading files it already had, that is the expected miss, not a bug in the cut.
|
|
277
|
+
|
|
278
|
+
## Citation
|
|
279
|
+
|
|
280
|
+
```bibtex
|
|
281
|
+
@article{nguyen2026cliffcompaction,
|
|
282
|
+
title = {CliffCompaction: Cost-Efficient Compaction for Long-Horizon Coding Agents},
|
|
283
|
+
author = {Nguyen, Trang and Cho, Eulrang and Chen, Bingqing and Dettmers, Tim},
|
|
284
|
+
journal = {arXiv preprint arXiv:2609.26779},
|
|
285
|
+
year = {2026}
|
|
286
|
+
}
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
## License
|
|
290
|
+
|
|
291
|
+
MIT. Algorithm MIT from [nguyenvuthientrang/cliffcompaction](https://github.com/nguyenvuthientrang/cliffcompaction). Package source: [AdityaVG13/pi-stack](https://github.com/AdityaVG13/pi-stack/tree/main/packages/pi-cliffcompaction).
|
package/index.ts
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-cliffcompaction -- Pi extension.
|
|
3
|
+
*
|
|
4
|
+
* Replaces Pi's LLM summarizer with CliffCompaction (arxiv:2609.26779):
|
|
5
|
+
* grow until the host triggers compaction, then keep head + last K turns
|
|
6
|
+
* verbatim and replace the middle with a mechanical, never-rephrased
|
|
7
|
+
* summary. Prior summaries are discarded (never compact a compaction).
|
|
8
|
+
*
|
|
9
|
+
* Fail-open: if the mechanical pass has nothing to gain, the handler
|
|
10
|
+
* returns undefined and Pi's default path runs. Shadow mode cancels
|
|
11
|
+
* compaction so the original history is sent unchanged.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { ExtensionAPI, SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
15
|
+
import { convertToLlm } from "@earendil-works/pi-coding-agent";
|
|
16
|
+
import { loadConfig, type Config } from "./lib/config.ts";
|
|
17
|
+
import { isRecord, type JsonObject, type JsonValue } from "./lib/decode.ts";
|
|
18
|
+
import { compactSession, liveFromEntries, type CliffDetails, type HookEntry } from "./lib/pi-hook.ts";
|
|
19
|
+
|
|
20
|
+
type LastCliff = {
|
|
21
|
+
reason: string;
|
|
22
|
+
at: string;
|
|
23
|
+
details: CliffDetails;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
function jsonObjectFrom(value: JsonValue): JsonObject {
|
|
27
|
+
// SAFETY: LLM Message is JSON-serializable; reparse to JsonObject.
|
|
28
|
+
const parsed: JsonValue = JSON.parse(JSON.stringify(value));
|
|
29
|
+
|
|
30
|
+
if (isRecord(parsed)) {
|
|
31
|
+
return parsed;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return {};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function hookEntriesFromBranch(branchEntries: readonly SessionEntry[]): HookEntry[] {
|
|
38
|
+
const out: HookEntry[] = [];
|
|
39
|
+
|
|
40
|
+
for (const entry of branchEntries) {
|
|
41
|
+
if (entry.type === "message") {
|
|
42
|
+
const converted = convertToLlm([entry.message]);
|
|
43
|
+
const msg = converted[0];
|
|
44
|
+
|
|
45
|
+
if (msg) {
|
|
46
|
+
// SAFETY: convertToLlm returns a JSON-serializable Message.
|
|
47
|
+
const asJson: JsonValue = JSON.parse(JSON.stringify(msg));
|
|
48
|
+
out.push({ id: entry.id, kind: "message", message: jsonObjectFrom(asJson) });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (entry.type === "custom_message") {
|
|
55
|
+
const content = entry.content;
|
|
56
|
+
out.push({
|
|
57
|
+
id: entry.id,
|
|
58
|
+
kind: "message",
|
|
59
|
+
message: { role: "user", content: isRecord(content) || Array.isArray(content) ? JSON.parse(JSON.stringify(content)) : content },
|
|
60
|
+
});
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (entry.type === "compaction") {
|
|
65
|
+
out.push({
|
|
66
|
+
id: entry.id,
|
|
67
|
+
kind: "compaction",
|
|
68
|
+
firstKeptEntryId: entry.firstKeptEntryId,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function formatStatus(cfg: Config, last: LastCliff | null): string {
|
|
77
|
+
const lines = [
|
|
78
|
+
"CliffCompaction " + (cfg.enabled ? "on" : "off") + (cfg.shadow ? " (shadow)" : ""),
|
|
79
|
+
"keepRecent=" + cfg.keepRecent + " resultMaxChars=" + cfg.resultMaxChars + " cmdMaxChars=" + cfg.cmdMaxChars,
|
|
80
|
+
"thoughtMaxChars=" + cfg.thoughtMaxChars + " thinkingMaxChars=" + cfg.thinkingMaxChars + " keepThinking=" + cfg.keepThinking,
|
|
81
|
+
"humanMaxChars=" + cfg.humanMaxChars + " strict=" + cfg.strict,
|
|
82
|
+
];
|
|
83
|
+
|
|
84
|
+
if (last) {
|
|
85
|
+
lines.push(
|
|
86
|
+
"last: " +
|
|
87
|
+
last.reason +
|
|
88
|
+
" @ " +
|
|
89
|
+
last.at +
|
|
90
|
+
" live=" +
|
|
91
|
+
last.details.liveMessages +
|
|
92
|
+
" kept=" +
|
|
93
|
+
last.details.keptMessages +
|
|
94
|
+
" rung=" +
|
|
95
|
+
last.details.rung,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return lines.join("\n");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export default function registerCliffCompaction(pi: ExtensionAPI) {
|
|
103
|
+
let cfg = loadConfig();
|
|
104
|
+
let last: LastCliff | null = null;
|
|
105
|
+
|
|
106
|
+
pi.registerCommand("cliff", {
|
|
107
|
+
description: "CliffCompaction status and config (mechanical autocompaction)",
|
|
108
|
+
handler: async (args, ctx) => {
|
|
109
|
+
const trimmed = (args || "").trim();
|
|
110
|
+
const space = trimmed.indexOf(" ");
|
|
111
|
+
const sub = (space === -1 ? trimmed : trimmed.slice(0, space)) || "status";
|
|
112
|
+
|
|
113
|
+
if (sub === "reload") {
|
|
114
|
+
cfg = loadConfig();
|
|
115
|
+
ctx.ui.notify("CliffCompaction config reloaded", "info");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (sub === "config") {
|
|
119
|
+
ctx.ui.notify(JSON.stringify(cfg, null, 2), "info");
|
|
120
|
+
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
ctx.ui.notify(formatStatus(cfg, last), "info");
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
pi.on("session_before_compact", async (event, ctx) => {
|
|
129
|
+
if (!cfg.enabled) {
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const { preparation, branchEntries, reason } = event;
|
|
134
|
+
|
|
135
|
+
try {
|
|
136
|
+
const entries = hookEntriesFromBranch(branchEntries);
|
|
137
|
+
const live = liveFromEntries(entries);
|
|
138
|
+
|
|
139
|
+
const result = compactSession({
|
|
140
|
+
live,
|
|
141
|
+
tokensBefore: preparation.tokensBefore,
|
|
142
|
+
fallbackFirstKeptEntryId: preparation.firstKeptEntryId,
|
|
143
|
+
reason,
|
|
144
|
+
cfg,
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
if (cfg.shadow) {
|
|
148
|
+
ctx.ui.setStatus(
|
|
149
|
+
"cliff",
|
|
150
|
+
result
|
|
151
|
+
? "cliff shadow · would keep " + result.details.keptMessages + " msgs"
|
|
152
|
+
: "cliff shadow · nothing to compact",
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
return { cancel: true };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (result === null) {
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
last = { reason, at: new Date().toISOString(), details: result.details };
|
|
163
|
+
ctx.ui.setStatus("cliff", "cliff · " + reason + " · kept " + result.details.keptMessages);
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
compaction: {
|
|
167
|
+
summary: result.summary,
|
|
168
|
+
firstKeptEntryId: result.firstKeptEntryId,
|
|
169
|
+
tokensBefore: result.tokensBefore,
|
|
170
|
+
details: result.details,
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
} catch {
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
pi.on("session_compact", (event, ctx) => {
|
|
179
|
+
if (!event.fromExtension) {
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
ctx.ui.setStatus("cliff", "cliff · compacted (" + event.reason + ")");
|
|
184
|
+
});
|
|
185
|
+
}
|
package/lib/cliff.ts
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core compaction: keep head + last keepRecent assistant-step turns
|
|
3
|
+
* verbatim, replace the middle with one mechanical summary. Never
|
|
4
|
+
* rephrase. Never nest a previous summary.
|
|
5
|
+
*
|
|
6
|
+
* Algorithm 1 of Nguyen, Cho, Chen & Dettmers, arXiv:2609.26779.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { Config } from "./config.ts";
|
|
10
|
+
import type { JsonObject } from "./decode.ts";
|
|
11
|
+
import { SUMMARY_HEADER, type Dialect } from "./dialects/base.ts";
|
|
12
|
+
|
|
13
|
+
export type CompactResult = {
|
|
14
|
+
messages: JsonObject[];
|
|
15
|
+
headLen: number;
|
|
16
|
+
summary: JsonObject;
|
|
17
|
+
cut: number;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export function groupTurns(body: JsonObject[], dialect: Dialect): JsonObject[][] {
|
|
21
|
+
if (dialect.groupTurns !== null) {
|
|
22
|
+
return dialect.groupTurns(body);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const turns: JsonObject[][] = [];
|
|
26
|
+
let current: JsonObject[] | null = null;
|
|
27
|
+
|
|
28
|
+
for (const msg of body) {
|
|
29
|
+
if (dialect.isAssistant(msg)) {
|
|
30
|
+
if (current !== null) {
|
|
31
|
+
turns.push(current);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
current = [msg];
|
|
35
|
+
} else if (current === null) {
|
|
36
|
+
current = [msg];
|
|
37
|
+
} else {
|
|
38
|
+
current.push(msg);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (current !== null) {
|
|
43
|
+
turns.push(current);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return turns;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Compact a message list. Returns null if there is nothing to gain.
|
|
51
|
+
* Does not mutate the input; kept messages are passed by reference.
|
|
52
|
+
*/
|
|
53
|
+
export function compact(
|
|
54
|
+
messages: JsonObject[],
|
|
55
|
+
dialect: Dialect,
|
|
56
|
+
cfg: Config,
|
|
57
|
+
): CompactResult | null {
|
|
58
|
+
let firstAssistant: number | null = null;
|
|
59
|
+
|
|
60
|
+
for (let i = 0; i < messages.length; i++) {
|
|
61
|
+
if (dialect.isAssistant(messages[i])) {
|
|
62
|
+
firstAssistant = i;
|
|
63
|
+
break;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (firstAssistant === null) {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
let headLen = firstAssistant;
|
|
72
|
+
|
|
73
|
+
while (
|
|
74
|
+
headLen > 0 &&
|
|
75
|
+
(dialect.isSummaryMessage(messages[headLen - 1]) ||
|
|
76
|
+
(dialect.trimFromHead !== null && dialect.trimFromHead(messages[headLen - 1])))
|
|
77
|
+
) {
|
|
78
|
+
headLen -= 1;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const body = messages.slice(headLen);
|
|
82
|
+
const turns = groupTurns(body, dialect);
|
|
83
|
+
const keepRecent = Math.max(0, cfg.keepRecent);
|
|
84
|
+
|
|
85
|
+
if (turns.length <= keepRecent) {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const toCompact = turns.slice(0, turns.length - keepRecent);
|
|
90
|
+
const toKeep = turns.slice(turns.length - keepRecent);
|
|
91
|
+
const parts: string[] = [];
|
|
92
|
+
|
|
93
|
+
for (const turn of toCompact) {
|
|
94
|
+
for (const msg of turn) {
|
|
95
|
+
const piece = dialect.summarizeMessage(msg, cfg);
|
|
96
|
+
|
|
97
|
+
for (const p of piece) {
|
|
98
|
+
parts.push(p);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const summaryText =
|
|
104
|
+
parts.length > 0 ? SUMMARY_HEADER + "\n\n" + parts.join("\n\n---\n\n") : SUMMARY_HEADER;
|
|
105
|
+
|
|
106
|
+
const summary = dialect.userMessage(summaryText);
|
|
107
|
+
const kept: JsonObject[] = [];
|
|
108
|
+
|
|
109
|
+
for (const turn of toKeep) {
|
|
110
|
+
for (const msg of turn) {
|
|
111
|
+
kept.push(msg);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const newMessages: JsonObject[] = [];
|
|
116
|
+
|
|
117
|
+
for (let i = 0; i < headLen; i++) {
|
|
118
|
+
newMessages.push(messages[i]);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
newMessages.push(summary);
|
|
122
|
+
|
|
123
|
+
for (const msg of kept) {
|
|
124
|
+
newMessages.push(msg);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (newMessages.length >= messages.length) {
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const cut = messages.length - kept.length;
|
|
132
|
+
|
|
133
|
+
return { messages: newMessages, headLen, summary, cut };
|
|
134
|
+
}
|