pi-compaction-cache 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/LICENSE ADDED
@@ -0,0 +1,19 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ezoushen
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.
package/README.md ADDED
@@ -0,0 +1,221 @@
1
+ # pi-compaction-cache
2
+
3
+ Make Pi compaction reuse a server's prefix cache instead of rebuilding the
4
+ summarization request from a cold prompt.
5
+
6
+ Pi normally serializes the conversation into a new user message under a new system
7
+ prompt. That request shares no prefix with the live conversation. This extension
8
+ keeps the live system prompt, tools, and chronological messages, then appends Pi's
9
+ summarization instructions as the final user turn:
10
+
11
+ ```text
12
+ [ live system + live tools ][ live messages ][ summarization instructions ]
13
+ ```
14
+
15
+ The extension captures the ordinary provider payload, performs compaction from
16
+ `session_before_compact`, and rewrites the summarization payload through
17
+ `onPayload`. Pi still owns split-turn handling, file-operation metadata, usage
18
+ accounting, and persistence. Any guard failure returns control to Pi's default
19
+ compaction.
20
+
21
+ ## External contract
22
+
23
+ The serving system must provide content-addressed prefix caching and retain the
24
+ conversation prefix until compaction uses it. Some server implementations require a
25
+ retention setting for sliding-window or hybrid cache groups; this package cannot set
26
+ or detect that server policy.
27
+
28
+ Sending the longer live conversation is appropriate only when cached input is free.
29
+ With no `models` matcher, the extension therefore activates only when the current
30
+ model reports both input and cache-read prices as zero. An explicit matcher overrides
31
+ that heuristic for catalog entries whose cost metadata is absent or unsuitable.
32
+
33
+ The provider must also invoke the supplied `onPayload` callback. The extension checks
34
+ that the rewrite actually ran before accepting a summary.
35
+
36
+ ## If the contract is unmet
37
+
38
+ If the model is inapplicable, the provider does not rewrite the payload, the live
39
+ prefix is unavailable, or another correctness guard fails, Pi performs its default
40
+ compaction. The extension announces each distinct decline reason once per session.
41
+ Run `/compaction-cache-status` to see whether it applies to the current model, the
42
+ deciding rule, and every resolved setting with provenance.
43
+
44
+ In pi's print (`-p`) and json modes there is no UI to notify, so an announcement that
45
+ would otherwise be silent there is written to stderr instead, once per distinct reason
46
+ per process.
47
+
48
+ If server-side caching or retention is ineffective, compaction remains correct but
49
+ does not receive the prefill benefit. This condition is not observable through the
50
+ provider API, so validate it with server metrics using the protocol below.
51
+
52
+ ## Load order
53
+
54
+ Load `pi-prefix-stabilizer` before `pi-compaction-cache`.
55
+
56
+ The stabilizer must normalize the ordinary request before this extension captures it.
57
+ Keep the compaction package after extensions that observe compaction without supplying
58
+ one. If another handler replaces its result, `pi-compaction-cache` warns once and
59
+ disables itself for the rest of the session rather than paying for discarded work.
60
+
61
+ Install in dependency order:
62
+
63
+ ```sh
64
+ pi install npm:pi-prefix-stabilizer
65
+ pi install npm:pi-compaction-cache
66
+ ```
67
+
68
+ ## Settings
69
+
70
+ Settings resolve from `compaction-cache.json` in the global Pi config directory,
71
+ then from a trusted project's Pi config directory, then from the environment. Later
72
+ sources win. Project settings are ignored when the project is untrusted.
73
+
74
+ | JSON key | Default | Environment override | Meaning |
75
+ |---|---:|---|---|
76
+ | `enabled` | `true` | `PI_COMPACTION_CACHE=0` disables | Register normally but decline compaction work when false. |
77
+ | `models` | `[]` | `PI_COMPACTION_CACHE_MODELS='["provider/model"]'` | Non-empty minimatch patterns override the cost heuristic; patterns match `provider/modelId` or a bare `modelId`. |
78
+ | `logPath` | `""` | `PI_COMPACTION_CACHE_LOG` | Append JSON diagnostics when a path is set. |
79
+ | `debug` | `false` | `PI_COMPACTION_CACHE_DEBUG=1` | Include captured payload skeletons in the diagnostic log. |
80
+ | `scope` | `"boundary"` | `PI_COMPACTION_CACHE_SCOPE` | `boundary` sends the old slice Pi will discard; `full` sends the whole conversation. |
81
+ | `maxWords` | `1500` | `PI_COMPACTION_CACHE_MAX_WORDS` | Summary word-budget guidance; `0` disables the guidance. |
82
+
83
+ Example:
84
+
85
+ ```json
86
+ {
87
+ "models": ["local/*", "my-model"],
88
+ "scope": "boundary",
89
+ "maxWords": 1500
90
+ }
91
+ ```
92
+
93
+ ## Measurements
94
+
95
+ These results came from a large-context model behind a prefix-caching server. They
96
+ show the mechanism, not a portable capacity recommendation.
97
+
98
+ ### Server retention
99
+
100
+ Before the server retained checkpoints for its hybrid cache groups, byte-identical
101
+ repeats were cold. After retention was enabled, the same prompts reused almost all
102
+ input:
103
+
104
+ | prompt tokens | before (cold → repeat) | after (cold → repeat) | hit | tokens not reused |
105
+ |---:|---:|---:|---:|---:|
106
+ | 9,089 | 9.73s → 9.72s, no reuse | 12.91s → 0.36s | 98.6% | 129 |
107
+ | 22,914 | 23.62s → 23.71s, no reuse | 27.36s → 0.41s | 99.4% | 130 |
108
+ | 64,016 | 83.43s → 67.77s, no reuse | 69.97s → 0.44s | 99.8% | 144 |
109
+
110
+ The small un-reused tail shows checkpoint granularity, but it does not determine the
111
+ right retention value. Retention must survive the eviction pressure created by other
112
+ requests.
113
+
114
+ **Superseded history:** a value of `128` was initially recommended because it bounded
115
+ the un-reused tail near 130 tokens. That recommendation was reversed by a pressure
116
+ test: a cached 132,000-token prefix at that value was evicted by one intervening
117
+ request, producing 144.26s at 0% reuse; a larger value produced 0.61s at 99.95% reuse.
118
+ The pool was 34% utilized with 0 preemptions in both arms. A server-side retention
119
+ setting may therefore be required, but its right value is governed by eviction
120
+ pressure rather than by the un-reused tail and must be measured on the reader's own deployment.
121
+
122
+ The original recommendation also relied on a lower-pressure check: four concurrent
123
+ 27,500-token prompts completed with 0 preemptions, and one repeated afterward reached
124
+ 99.4% reuse in 0.39s. The later 132,000-token eviction result showed why that check was
125
+ not representative enough to choose a deployment setting.
126
+
127
+ As a separate control, one prompt sent under three different client-side cache keys
128
+ reached 98.9% reuse each time. Those request fields did not control the tested server's
129
+ content-addressed prefix cache.
130
+
131
+ ### Compaction reuse
132
+
133
+ One ordinary cold turn followed immediately by compaction:
134
+
135
+ | request | prompt tokens | cached | hit | TTFT |
136
+ |---|---:|---:|---:|---:|
137
+ | ordinary turn | 69,790 | 0 | 0.00% | 116.82s |
138
+ | compaction | 70,064 | 69,632 | **99.38%** | **1.14s** |
139
+
140
+ Same synthetic 125k-token snapshot compacted both ways:
141
+
142
+ | implementation | prompt tokens | cached | block hit | TTFT | wall |
143
+ |---|---:|---:|---:|---:|---:|
144
+ | Pi default | 151,148 | 0 | 0.00% | 176.5s | 193.9s |
145
+ | extension | 202,841 | 202,368 | **99.77%** | **1.55s** | **20.9s** |
146
+
147
+ Same 90,000-token repository snapshot with 54 tool schemas, compacted both ways:
148
+
149
+ | implementation | prompt tokens | cached | block hit | TTFT | wall | summary |
150
+ |---|---:|---:|---:|---:|---:|---:|
151
+ | Pi default | 3,328 | 0 | 0.00% | 6.00s | 45.3s | 4,945 chars |
152
+ | extension | 69,927 | 69,504 | **99.40%** | **1.00s** | 23.2s | 8,490 chars |
153
+
154
+ The extension sent 21 times more evidence because Pi truncates tool results in its
155
+ serialized input, yet the cached request still reached first token faster. In the
156
+ synthetic quality check, the extension recorded all eight completed segments; the
157
+ default summary incorrectly left the eighth pending because it did not see the kept
158
+ tail.
159
+
160
+ ### Boundary cut
161
+
162
+ The cache needs a prefix, not the whole conversation. Pi only discards the old slice,
163
+ so the default `boundary` scope stops after the next completed assistant turn. If the
164
+ boundary cannot be located, the extension safely falls back to the full conversation.
165
+
166
+ Same snapshot, word-budget guidance disabled:
167
+
168
+ | scope | messages sent | prompt tokens | cached | hit | wall | summary |
169
+ |---|---:|---:|---:|---:|---:|---:|
170
+ | full conversation | 16/16 | 59,696 | 59,136 | 99.06% | 282.7s | 4,125 words |
171
+ | boundary cut | **6/16** | **44,963** | 44,416 | 98.78% | **240.0s** | **3,536 words** |
172
+
173
+ The boundary cut sent 25% fewer tokens at approximately the same cache-hit rate.
174
+ With the shipped boundary and word-budget defaults, the same snapshot used 45,001
175
+ prompt tokens, reached 99.55% cache reuse and 0.77s TTFT, and produced a 2,437-word
176
+ summary in 197.7s. The earlier full version used 59,696 tokens and produced 4,125
177
+ words in 282.7s.
178
+
179
+ ### Summary budget
180
+
181
+ Controlled A/B on one large-session snapshot, changing only the word guidance:
182
+
183
+ | guidance | summary | words | wall | hit |
184
+ |---|---:|---:|---:|---:|
185
+ | none | 30,561 chars | 3,598 | 239.8s | 99.17% |
186
+ | 1,500 words | **16,574 chars** | 1,816 | **165.6s** | 99.75% |
187
+
188
+ The summary was 46% smaller and wall time was 31% lower. This was one trial, and the
189
+ word count is guidance rather than a hard cap: the result exceeded the requested
190
+ budget. On a smaller session it did not reduce output (1,289 words without guidance,
191
+ 1,345 with it).
192
+
193
+ ### Caveats
194
+
195
+ TTFT improves when the prefix is retained, but total wall time can still be dominated
196
+ by decoding a richer summary. Cache reuse is also statistical under pressure: in an
197
+ earlier 202,000-token session series, one of four runs fell to 0.6% reuse after
198
+ unrelated work filled the cache, while the other three reached 99.8–100%. The prompt
199
+ occupied 26% of the measured pool at the time.
200
+
201
+ Four successive compactions were exercised, but summary-quality decay across those
202
+ generations was not scored.
203
+
204
+ ## Reproduce the measurements
205
+
206
+ 1. Use a large-context model behind a prefix-caching server and expose server-side
207
+ prompt-token, cached-token, TTFT, cache-utilization, eviction, and preemption
208
+ metrics.
209
+ 2. Send a prompt cold, repeat it byte-for-byte, and confirm that the repeat is cached.
210
+ 3. Insert representative competing requests, repeat the original prompt, and vary the
211
+ server's retention setting. Record eviction pressure and preemptions as well as the
212
+ small un-reused tail.
213
+ 4. Save one Pi session snapshot. Compact it once with Pi's default and once with this
214
+ extension, resetting or equivalently controlling cache state between arms.
215
+ 5. Record prompt tokens, cached tokens, hit rate, TTFT, wall time, and summary size.
216
+ Repeat enough times to expose eviction variance.
217
+ 6. Confirm `fromExtension: true` and use `/compaction-cache-status` to capture the
218
+ applicability rule and settings for the run.
219
+
220
+ Do not copy a retention value from these historical measurements. Choose it from the
221
+ pressure test on the deployment that will serve real traffic.
@@ -0,0 +1,620 @@
1
+ // extensions/compaction-cache/compaction-cache.ts
2
+ import { convertToLlm } from "@earendil-works/pi-coding-agent";
3
+ import { appendFileSync, readFileSync as readFileSync2, readdirSync, realpathSync } from "node:fs";
4
+ import { dirname, join as join2 } from "node:path";
5
+ import { minimatch } from "minimatch";
6
+
7
+ // shared/settings.ts
8
+ import { existsSync, readFileSync } from "node:fs";
9
+ import { join } from "node:path";
10
+ import { CONFIG_DIR_NAME, getAgentDir } from "@earendil-works/pi-coding-agent";
11
+
12
+ // shared/announce.ts
13
+ var stderrAnnounced = /* @__PURE__ */ new Set();
14
+ function announce(ctx, message, level, reason = message) {
15
+ if (ctx?.hasUI === false) {
16
+ if (stderrAnnounced.has(reason)) return;
17
+ stderrAnnounced.add(reason);
18
+ try {
19
+ process.stderr.write(`${message}
20
+ `);
21
+ } catch {
22
+ }
23
+ return;
24
+ }
25
+ try {
26
+ ctx?.ui?.notify?.(message, level);
27
+ } catch {
28
+ }
29
+ }
30
+
31
+ // shared/settings.ts
32
+ var announcedConfigErrors = /* @__PURE__ */ new Set();
33
+ function readConfig(path, ctx) {
34
+ if (!existsSync(path)) return {};
35
+ try {
36
+ return JSON.parse(readFileSync(path, "utf8"));
37
+ } catch (error) {
38
+ if (!announcedConfigErrors.has(path)) {
39
+ announcedConfigErrors.add(path);
40
+ const message = error instanceof Error ? error.message : String(error);
41
+ announce(ctx, `settings: could not parse ${path} (${message}); using defaults.`, "warning", `settings-parse:${path}`);
42
+ }
43
+ return {};
44
+ }
45
+ }
46
+ function resolveSettings(name, definitions, context, runtime = {}) {
47
+ const environment = runtime.environment ?? process.env;
48
+ const globalPath = join(runtime.agentDir ?? getAgentDir(), `${name}.json`);
49
+ const projectPath = join(context.cwd, CONFIG_DIR_NAME, `${name}.json`);
50
+ const globalConfig = readConfig(globalPath, context);
51
+ const projectConfig = context.isProjectTrusted() ? readConfig(projectPath, context) : {};
52
+ const resolved = {};
53
+ for (const key of Object.keys(definitions)) {
54
+ const definition = definitions[key];
55
+ let value = definition.default;
56
+ let provenance = { source: "default" };
57
+ if (definition.discover) {
58
+ try {
59
+ const discovered = definition.discover();
60
+ if (discovered !== void 0) {
61
+ value = discovered.value;
62
+ provenance = { source: "discovered", name: definition.discoverName ?? "discovery" };
63
+ }
64
+ } catch {
65
+ }
66
+ }
67
+ if (Object.hasOwn(globalConfig, key)) {
68
+ value = globalConfig[key];
69
+ provenance = { source: "global", path: globalPath };
70
+ }
71
+ if (Object.hasOwn(projectConfig, key)) {
72
+ value = projectConfig[key];
73
+ provenance = { source: "project", path: projectPath };
74
+ }
75
+ const environmentValue = environment[definition.env];
76
+ if (environmentValue !== void 0) {
77
+ value = definition.parseEnv ? definition.parseEnv(environmentValue) : environmentValue;
78
+ provenance = { source: "environment", name: definition.env };
79
+ }
80
+ resolved[key] = { value, provenance };
81
+ }
82
+ return resolved;
83
+ }
84
+
85
+ // extensions/compaction-cache/compaction-cache.ts
86
+ var SETTING_DEFINITIONS = {
87
+ enabled: {
88
+ default: true,
89
+ env: "PI_COMPACTION_CACHE",
90
+ parseEnv: (value) => value !== "0"
91
+ },
92
+ models: {
93
+ default: [],
94
+ env: "PI_COMPACTION_CACHE_MODELS",
95
+ parseEnv: (value) => JSON.parse(value)
96
+ },
97
+ logPath: { default: "", env: "PI_COMPACTION_CACHE_LOG" },
98
+ debug: {
99
+ default: false,
100
+ env: "PI_COMPACTION_CACHE_DEBUG",
101
+ parseEnv: (value) => value === "1"
102
+ },
103
+ scope: { default: "boundary", env: "PI_COMPACTION_CACHE_SCOPE" },
104
+ maxWords: {
105
+ default: 1500,
106
+ env: "PI_COMPACTION_CACHE_MAX_WORDS",
107
+ parseEnv: (value) => Number(value)
108
+ }
109
+ };
110
+ var SUMMARIZATION_SYSTEM_PROMPT = `You are a context summarization assistant. Your task is to read a conversation between a user and an AI assistant, then produce a structured summary following the exact format specified.
111
+
112
+ Do NOT continue the conversation. Do NOT respond to any questions in the conversation. ONLY output the structured summary.`;
113
+ var SUMMARIZATION_PROMPT = `The messages above are a conversation to summarize. Create a structured context checkpoint summary that another LLM will use to continue the work.
114
+
115
+ Use this EXACT format:
116
+
117
+ ## Goal
118
+ [What is the user trying to accomplish? Can be multiple items if the session covers different tasks.]
119
+
120
+ ## Constraints & Preferences
121
+ - [Any constraints, preferences, or requirements mentioned by user]
122
+ - [Or "(none)" if none were mentioned]
123
+
124
+ ## Progress
125
+ ### Done
126
+ - [x] [Completed tasks/changes]
127
+
128
+ ### In Progress
129
+ - [ ] [Current work]
130
+
131
+ ### Blocked
132
+ - [Issues preventing progress, if any]
133
+
134
+ ## Key Decisions
135
+ - **[Decision]**: [Brief rationale]
136
+
137
+ ## Next Steps
138
+ 1. [Ordered list of what should happen next]
139
+
140
+ ## Critical Context
141
+ - [Any data, examples, or references needed to continue]
142
+ - [Or "(none)" if not applicable]
143
+
144
+ Keep each section concise. Preserve exact file paths, function names, and error messages.`;
145
+ var UPDATE_SUMMARIZATION_PROMPT = `The messages above include an earlier summary of this conversation followed by newer messages. Produce a single updated summary. RULES:
146
+ - PRESERVE all existing information from the earlier summary
147
+ - ADD new progress, decisions, and context from the newer messages
148
+ - UPDATE the Progress section: move items from "In Progress" to "Done" when completed
149
+ - UPDATE "Next Steps" based on what was accomplished
150
+ - PRESERVE exact file paths, function names, and error messages
151
+ - If something is no longer relevant, you may remove it
152
+
153
+ ${SUMMARIZATION_PROMPT}`;
154
+ var NO_TOOLS_NOTE = "\n\nDo not call any tool. Output only the summary text.";
155
+ var GENERATION_FIELDS = [
156
+ // `model` included deliberately: the rest of the payload is copied from the
157
+ // live request, and if the session switched models since then, live carries
158
+ // the *previous* model id while the call is routed to the current one.
159
+ // Taking it from Pi's request is always right and needs no comparison.
160
+ "model",
161
+ "max_tokens",
162
+ "max_completion_tokens",
163
+ "temperature",
164
+ "top_p",
165
+ "stop",
166
+ "stream",
167
+ "stream_options"
168
+ ];
169
+ function log(path, entry) {
170
+ if (!path) return;
171
+ try {
172
+ appendFileSync(path, JSON.stringify({ t: Date.now(), ...entry }) + "\n");
173
+ } catch {
174
+ }
175
+ }
176
+ function provenanceText(provenance) {
177
+ switch (provenance.source) {
178
+ case "default":
179
+ return "default";
180
+ case "discovered":
181
+ return `discovered: ${provenance.name}`;
182
+ case "global":
183
+ case "project":
184
+ return `${provenance.source}: ${provenance.path}`;
185
+ case "environment":
186
+ return `environment: ${provenance.name}`;
187
+ }
188
+ }
189
+ function settingValueText(value) {
190
+ return JSON.stringify(value);
191
+ }
192
+ function contentText(content) {
193
+ if (typeof content === "string") return content;
194
+ if (!Array.isArray(content)) return "";
195
+ return content.map((part) => typeof part === "string" ? part : part?.text ?? "").join("");
196
+ }
197
+ function messagesText(messages) {
198
+ return messages.map((m) => contentText(m?.content)).join("\n");
199
+ }
200
+ function checkPromptDrift(bundleDir, needles) {
201
+ if (!bundleDir) return void 0;
202
+ try {
203
+ const files = readdirSync(bundleDir).filter((f) => f.endsWith(".js"));
204
+ for (const file of files) {
205
+ const source = readFileSync2(join2(bundleDir, file), "utf8");
206
+ if (!source.includes(needles[0])) continue;
207
+ return needles.every((needle) => source.includes(needle));
208
+ }
209
+ return void 0;
210
+ } catch {
211
+ return void 0;
212
+ }
213
+ }
214
+ function findBundleDir(argv1) {
215
+ if (!argv1) return void 0;
216
+ try {
217
+ return join2(dirname(realpathSync(argv1)), "chunks");
218
+ } catch {
219
+ return void 0;
220
+ }
221
+ }
222
+ function completedAssistantAt(messages, from) {
223
+ for (let i = Math.max(0, from); i < messages.length; i++) {
224
+ const m = messages[i];
225
+ if (m?.role === "assistant" && !(m.tool_calls?.length > 0)) return i;
226
+ }
227
+ return -1;
228
+ }
229
+ function summaryBoundaryIndex(messages, summarized) {
230
+ for (let s = summarized.length - 1; s >= 0; s--) {
231
+ const text = contentText(summarized[s]?.content).trim();
232
+ if (text.length < 40) continue;
233
+ const probe = text.slice(0, 120);
234
+ for (let i = messages.length - 1; i >= 0; i--) {
235
+ if (contentText(messages[i]?.content).includes(probe)) return i;
236
+ }
237
+ }
238
+ return -1;
239
+ }
240
+ function buildRewrittenPayload(livePayload, summarizationParams, instruction, cutFrom = -1) {
241
+ if (!Array.isArray(livePayload.messages) || livePayload.messages.length === 0) return void 0;
242
+ const end = cutFrom >= 0 ? completedAssistantAt(livePayload.messages, cutFrom) : livePayload.messages.findLastIndex(
243
+ (m) => m?.role === "assistant" && !(m.tool_calls?.length > 0)
244
+ );
245
+ if (end < 0) return void 0;
246
+ const conversation = livePayload.messages.slice(0, end + 1);
247
+ const lastUser = [...conversation].reverse().find((m) => m?.role === "user");
248
+ const instructionMessage = lastUser && typeof lastUser.content !== "string" ? { role: "user", content: [{ type: "text", text: instruction }] } : { role: "user", content: instruction };
249
+ const rewritten = {
250
+ ...livePayload,
251
+ messages: [...conversation, instructionMessage]
252
+ };
253
+ for (const field of GENERATION_FIELDS) {
254
+ if (field in summarizationParams) rewritten[field] = summarizationParams[field];
255
+ else delete rewritten[field];
256
+ }
257
+ return rewritten;
258
+ }
259
+ function inputTokensAreFree(model) {
260
+ const cost = model?.cost;
261
+ if (!cost) return false;
262
+ return (cost.input ?? 0) === 0 && (cost.cacheRead ?? 0) === 0;
263
+ }
264
+ function modelApplicability(model, patterns) {
265
+ if (!model) {
266
+ return { active: false, rule: "current model", reason: "there is no current model" };
267
+ }
268
+ const fullId = `${model.provider}/${model.id}`;
269
+ if (patterns.length > 0) {
270
+ const pattern = patterns.find(
271
+ (candidate) => minimatch(fullId, candidate, { nocase: true }) || minimatch(model.id, candidate, { nocase: true })
272
+ );
273
+ return pattern ? { active: true, rule: `models matcher (${pattern})` } : {
274
+ active: false,
275
+ rule: "models matcher",
276
+ reason: `${fullId} does not match any configured model pattern`
277
+ };
278
+ }
279
+ if (!model.cost) {
280
+ return {
281
+ active: false,
282
+ rule: "zero-cost heuristic",
283
+ reason: `${fullId} has no cost metadata and no models matcher is configured`
284
+ };
285
+ }
286
+ if (!inputTokensAreFree(model)) {
287
+ return {
288
+ active: false,
289
+ rule: "zero-cost heuristic",
290
+ reason: `${fullId} has a non-zero input or cache-read price and no models matcher is configured`
291
+ };
292
+ }
293
+ return { active: true, rule: "zero-cost heuristic" };
294
+ }
295
+ function isTransient(error) {
296
+ if (!error) return false;
297
+ const name = error?.name;
298
+ if (name === "AbortError") return false;
299
+ const message = error instanceof Error ? error.message : String(error);
300
+ return /terminated|socket|ECONNRESET|ETIMEDOUT|EPIPE|fetch failed|network/i.test(message);
301
+ }
302
+ function computeFileLists(fileOps) {
303
+ const modified = /* @__PURE__ */ new Set([...fileOps?.edited ?? [], ...fileOps?.written ?? []]);
304
+ const readFiles = [...fileOps?.read ?? []].filter((f) => !modified.has(f)).sort();
305
+ return { readFiles, modifiedFiles: [...modified].sort() };
306
+ }
307
+ function formatFileOperations(readFiles, modifiedFiles) {
308
+ const sections = [];
309
+ if (readFiles.length > 0) sections.push(`<read-files>
310
+ ${readFiles.join("\n")}
311
+ </read-files>`);
312
+ if (modifiedFiles.length > 0) {
313
+ sections.push(`<modified-files>
314
+ ${modifiedFiles.join("\n")}
315
+ </modified-files>`);
316
+ }
317
+ return sections.length === 0 ? "" : `
318
+
319
+ ${sections.join("\n\n")}`;
320
+ }
321
+ function fallbackContext(messages, instruction) {
322
+ const serialized = messages.map((m) => `<${m.role}>
323
+ ${contentText(m.content)}
324
+ </${m.role}>`).join("\n");
325
+ return {
326
+ systemPrompt: SUMMARIZATION_SYSTEM_PROMPT,
327
+ messages: [
328
+ {
329
+ role: "user",
330
+ content: [
331
+ {
332
+ type: "text",
333
+ text: `<conversation>
334
+ ${serialized}
335
+ </conversation>
336
+
337
+ ${instruction}`
338
+ }
339
+ ],
340
+ timestamp: Date.now()
341
+ }
342
+ ]
343
+ };
344
+ }
345
+ function compaction_cache_default(pi) {
346
+ let live;
347
+ let suppliedCompaction = false;
348
+ let lostHandlerRace = false;
349
+ let promptDriftChecked = false;
350
+ const announcedDeclines = /* @__PURE__ */ new Set();
351
+ const settingsFor = (ctx) => resolveSettings("compaction-cache", SETTING_DEFINITIONS, {
352
+ cwd: ctx?.cwd ?? process.cwd(),
353
+ isProjectTrusted: () => ctx?.isProjectTrusted?.() ?? false,
354
+ hasUI: ctx?.hasUI,
355
+ ui: ctx?.ui
356
+ });
357
+ const decline = (ctx, settings, reason, message, details = {}) => {
358
+ log(settings.logPath.value, { skip: reason, ...details });
359
+ if (!announcedDeclines.has(reason)) {
360
+ announcedDeclines.add(reason);
361
+ announce(ctx, `compaction-cache inactive: ${message}.`, "warning", `compaction-cache:decline:${reason}`);
362
+ }
363
+ };
364
+ pi.registerCommand?.("compaction-cache-status", {
365
+ description: "Report compaction-cache applicability and resolved settings",
366
+ handler: async (_args, ctx) => {
367
+ const settings = settingsFor(ctx);
368
+ const applicability = settings.enabled.value ? modelApplicability(ctx.model, settings.models.value) : { active: false, rule: "enabled setting", reason: "the extension is disabled" };
369
+ const modelName = ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "no current model";
370
+ const lines = [
371
+ `compaction-cache: ${applicability.active ? "active" : "inactive"} for ${modelName}`,
372
+ `rule: ${applicability.rule}`
373
+ ];
374
+ if (applicability.reason) lines.push(`reason: ${applicability.reason}`);
375
+ for (const [name, setting] of Object.entries(settings)) {
376
+ lines.push(
377
+ `${name} = ${settingValueText(setting.value)} (${provenanceText(setting.provenance)})`
378
+ );
379
+ }
380
+ announce(ctx, lines.join("\n"), "info");
381
+ }
382
+ });
383
+ pi.on("before_provider_request", (event, ctx) => {
384
+ const payload = event?.payload;
385
+ if (!payload || !Array.isArray(payload.messages)) return;
386
+ if (Array.isArray(payload.tools) && payload.tools.length > 0) live = payload;
387
+ if (ctx) {
388
+ const settings = settingsFor(ctx);
389
+ if (!settings.debug.value) return;
390
+ log(settings.logPath.value, {
391
+ seen: true,
392
+ n_messages: payload.messages.length,
393
+ n_tools: Array.isArray(payload.tools) ? payload.tools.length : 0
394
+ });
395
+ }
396
+ });
397
+ pi.on("session_compact", (event, ctx) => {
398
+ const used = event?.fromExtension;
399
+ const settings = settingsFor(ctx);
400
+ log(settings.logPath.value, { finished: true, fromExtension: used, reason: event?.reason });
401
+ if (suppliedCompaction && used === false && !lostHandlerRace) {
402
+ lostHandlerRace = true;
403
+ announcedDeclines.add("lost-handler-race");
404
+ announce(
405
+ ctx,
406
+ "compaction-cache: another extension replaced this one's compaction result. Make it the LAST entry of `packages` in ~/.pi/agent/settings.json. Disabled for this session.",
407
+ "warning",
408
+ "compaction-cache:lost-handler-race"
409
+ );
410
+ }
411
+ suppliedCompaction = false;
412
+ });
413
+ pi.on("session_compact_failed", (event, ctx) => {
414
+ suppliedCompaction = false;
415
+ if (ctx) {
416
+ const settings = settingsFor(ctx);
417
+ log(settings.logPath.value, {
418
+ failed: true,
419
+ aborted: event?.aborted,
420
+ errorMessage: event?.errorMessage
421
+ });
422
+ }
423
+ });
424
+ pi.on("session_before_compact", async (event, ctx) => {
425
+ const { preparation, customInstructions, signal } = event;
426
+ const settings = settingsFor(ctx);
427
+ if (!settings.enabled.value) {
428
+ return decline(ctx, settings, "disabled", "the enabled setting is false");
429
+ }
430
+ if (lostHandlerRace) {
431
+ return decline(
432
+ ctx,
433
+ settings,
434
+ "lost-handler-race",
435
+ "another extension replaced its compaction result"
436
+ );
437
+ }
438
+ const model = ctx.model;
439
+ const applicability = modelApplicability(model, settings.models.value);
440
+ if (!applicability.active) {
441
+ return decline(
442
+ ctx,
443
+ settings,
444
+ `applicability:${applicability.reason}`,
445
+ applicability.reason,
446
+ { rule: applicability.rule, provider: model?.provider, id: model?.id }
447
+ );
448
+ }
449
+ if (!live) return decline(ctx, settings, "no-live-request", "no live provider request was captured");
450
+ if (!promptDriftChecked) {
451
+ promptDriftChecked = true;
452
+ const intact = checkPromptDrift(findBundleDir(process.argv[1]), [
453
+ SUMMARIZATION_SYSTEM_PROMPT,
454
+ SUMMARIZATION_PROMPT
455
+ ]);
456
+ log(settings.logPath.value, {
457
+ prompt_drift_check: intact === void 0 ? "unverifiable" : intact ? "match" : "DRIFTED"
458
+ });
459
+ if (intact === false) {
460
+ announce(
461
+ ctx,
462
+ "compaction-cache: pi's summarization prompts no longer match this extension's copies. The summary format may have changed upstream; update compaction-cache.ts.",
463
+ "warning",
464
+ "compaction-cache:prompt-drift"
465
+ );
466
+ }
467
+ }
468
+ const summarized = convertToLlm(preparation.messagesToSummarize ?? []);
469
+ if (summarized.length === 0) {
470
+ return decline(ctx, settings, "nothing-to-summarize", "there are no messages to summarize");
471
+ }
472
+ const liveText = messagesText(live.messages);
473
+ const probes = [];
474
+ for (const message of summarized) {
475
+ const text = contentText(message?.content).trim();
476
+ if (text.length >= 40) probes.push(text.slice(0, 120));
477
+ if (probes.length >= 5) break;
478
+ }
479
+ if (probes.length === 0) {
480
+ return decline(ctx, settings, "no-probe-text", "the messages contain no usable comparison text");
481
+ }
482
+ if (!probes.some((probe) => liveText.includes(probe))) {
483
+ return decline(
484
+ ctx,
485
+ settings,
486
+ "conversation-mismatch",
487
+ "the captured provider request belongs to a different conversation",
488
+ { probes: probes.length }
489
+ );
490
+ }
491
+ const base = preparation.previousSummary ? UPDATE_SUMMARIZATION_PROMPT : SUMMARIZATION_PROMPT;
492
+ const focus = customInstructions ? `
493
+
494
+ Additional focus: ${customInstructions}` : "";
495
+ const budgetNote = settings.maxWords.value > 0 ? `
496
+
497
+ Hard limit: the entire summary must be under ${settings.maxWords.value} words. It replaces the conversation above, so write dense, specific notes, not prose. Drop detail rather than exceed the limit.` : "";
498
+ const instruction = `${SUMMARIZATION_SYSTEM_PROMPT}
499
+
500
+ ${base}${focus}${budgetNote}${NO_TOOLS_NOTE}`;
501
+ const boundary = settings.scope.value === "full" ? -1 : summaryBoundaryIndex(live.messages, summarized);
502
+ const probe0 = buildRewrittenPayload(live, {}, instruction, boundary);
503
+ if (!probe0) {
504
+ return decline(
505
+ ctx,
506
+ settings,
507
+ "cannot-preserve-prefix",
508
+ "a prefix-preserving summarization request cannot be built",
509
+ { boundary }
510
+ );
511
+ }
512
+ log(settings.logPath.value, {
513
+ scope: settings.scope.value,
514
+ boundary,
515
+ live_messages: live.messages.length,
516
+ sent_messages: probe0.messages.length
517
+ });
518
+ const reserve = preparation.settings?.reserveTokens ?? 16384;
519
+ const maxTokens = Math.min(
520
+ Math.floor(0.8 * reserve),
521
+ model.maxTokens > 0 ? model.maxTokens : Number.POSITIVE_INFINITY
522
+ );
523
+ let applied = false;
524
+ const call = () => ctx.modelRegistry.complete(
525
+ model,
526
+ fallbackContext(summarized, `${SUMMARIZATION_SYSTEM_PROMPT}
527
+
528
+ ${base}${focus}`),
529
+ {
530
+ maxTokens,
531
+ signal,
532
+ onPayload: (params) => {
533
+ const rewritten = buildRewrittenPayload(live, params, instruction, boundary);
534
+ if (!rewritten) return void 0;
535
+ applied = true;
536
+ return rewritten;
537
+ }
538
+ }
539
+ );
540
+ try {
541
+ let response;
542
+ try {
543
+ response = await call();
544
+ } catch (error) {
545
+ if (!isTransient(error) || signal?.aborted) throw error;
546
+ log(settings.logPath.value, {
547
+ retrying: true,
548
+ message: error instanceof Error ? error.message : String(error)
549
+ });
550
+ applied = false;
551
+ response = await call();
552
+ }
553
+ if (!applied) {
554
+ return decline(
555
+ ctx,
556
+ settings,
557
+ "rewrite-not-applied",
558
+ "the provider did not apply the prefix-preserving payload rewrite"
559
+ );
560
+ }
561
+ if (response.stopReason === "error" || response.stopReason === "length") {
562
+ return decline(
563
+ ctx,
564
+ settings,
565
+ `stop-reason:${response.stopReason}`,
566
+ `the summary stopped with ${response.stopReason}`,
567
+ { stop: response.stopReason }
568
+ );
569
+ }
570
+ if ((response.content ?? []).some((c) => c?.type === "toolCall")) {
571
+ return decline(
572
+ ctx,
573
+ settings,
574
+ "model-called-a-tool",
575
+ "the summarizing model called a tool"
576
+ );
577
+ }
578
+ const text = (response.content ?? []).filter((c) => c?.type === "text").map((c) => c.text).join("\n");
579
+ if (!text.trim()) {
580
+ return decline(ctx, settings, "empty-summary", "the summarizing model returned no text");
581
+ }
582
+ const { readFiles, modifiedFiles } = computeFileLists(preparation.fileOps);
583
+ log(settings.logPath.value, {
584
+ compacted: true,
585
+ live_messages: live.messages.length,
586
+ tools: live.tools?.length ?? 0,
587
+ summary_chars: text.length,
588
+ usage: response.usage
589
+ });
590
+ suppliedCompaction = true;
591
+ return {
592
+ compaction: {
593
+ summary: text + formatFileOperations(readFiles, modifiedFiles),
594
+ firstKeptEntryId: preparation.firstKeptEntryId,
595
+ tokensBefore: preparation.tokensBefore,
596
+ usage: response.usage,
597
+ details: { readFiles, modifiedFiles }
598
+ }
599
+ };
600
+ } catch (error) {
601
+ const message = error instanceof Error ? error.message : String(error);
602
+ return decline(ctx, settings, "summarization-error", `summarization failed: ${message}`, {
603
+ message
604
+ });
605
+ }
606
+ });
607
+ }
608
+ export {
609
+ buildRewrittenPayload,
610
+ checkPromptDrift,
611
+ completedAssistantAt,
612
+ computeFileLists,
613
+ compaction_cache_default as default,
614
+ findBundleDir,
615
+ formatFileOperations,
616
+ inputTokensAreFree,
617
+ isTransient,
618
+ modelApplicability,
619
+ summaryBoundaryIndex
620
+ };
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "pi-compaction-cache",
3
+ "version": "0.1.0",
4
+ "description": "Make Pi compaction reuse the server's KV prefix cache.",
5
+ "type": "module",
6
+ "main": "./compaction-cache.js",
7
+ "exports": "./compaction-cache.js",
8
+ "files": [
9
+ "compaction-cache.js",
10
+ "README.md",
11
+ "LICENSE"
12
+ ],
13
+ "keywords": [
14
+ "pi-package"
15
+ ],
16
+ "license": "MIT",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/ezoushen/pi-extensions.git",
20
+ "directory": "extensions/compaction-cache"
21
+ },
22
+ "homepage": "https://github.com/ezoushen/pi-extensions/tree/main/extensions/compaction-cache#readme",
23
+ "bugs": {
24
+ "url": "https://github.com/ezoushen/pi-extensions/issues"
25
+ },
26
+ "dependencies": {
27
+ "minimatch": "10.2.6"
28
+ },
29
+ "peerDependencies": {
30
+ "@earendil-works/pi-coding-agent": "*"
31
+ },
32
+ "publishConfig": {
33
+ "access": "public"
34
+ },
35
+ "pi": {
36
+ "extensions": [
37
+ "./compaction-cache.js"
38
+ ]
39
+ }
40
+ }