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 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
+ [![npm](https://img.shields.io/npm/v/pi-cliffcompaction.svg)](https://www.npmjs.com/package/pi-cliffcompaction)
4
+ [![license](https://img.shields.io/npm/l/pi-cliffcompaction.svg)](https://github.com/AdityaVG13/pi-stack/blob/main/packages/pi-cliffcompaction/LICENSE)
5
+ [![node](https://img.shields.io/node/v/pi-cliffcompaction.svg)](https://nodejs.org)
6
+ [![pi-package](https://img.shields.io/badge/pi--package-extension-7aa2f7)](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).
@@ -0,0 +1,12 @@
1
+ {
2
+ "enabled": true,
3
+ "keepRecent": 3,
4
+ "thoughtMaxChars": 0,
5
+ "thinkingMaxChars": 0,
6
+ "keepThinking": true,
7
+ "cmdMaxChars": 150,
8
+ "resultMaxChars": 500,
9
+ "humanMaxChars": 20000,
10
+ "shadow": false,
11
+ "strict": false
12
+ }
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
+ }