@allenyolk/pi-compaction 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +201 -2
- package/package.json +46 -4
- package/src/config.ts +141 -0
- package/src/degradation.ts +93 -0
- package/src/index.ts +264 -0
- package/src/preparation.ts +97 -0
- package/src/summarizer.ts +279 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 AllenYolk
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,202 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @allenyolk/pi-compaction
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Pi's compaction summarises with the **conversation model** at the **session's thinking level**, and
|
|
4
|
+
there is no way to change either. A long session therefore pays for its summary with the most
|
|
5
|
+
expensive model it has, at whatever thinking level interactive work needed.
|
|
6
|
+
|
|
7
|
+
This package lets you choose both.
|
|
8
|
+
|
|
9
|
+
- The **summarizer** — the request that produces the compaction summary — gets its own model and its
|
|
10
|
+
own thinking level, chosen independently of the conversation model and the session's thinking level.
|
|
11
|
+
- The **kept window** — the most recent entries a compaction retains verbatim — is the package's own
|
|
12
|
+
token target rather than the host's recent-token setting, so the cut point is a number you control.
|
|
13
|
+
- The summary is **never shortened, never truncated and never replaced**. This package sends no
|
|
14
|
+
output ceiling and does not touch tool results. A summary it cannot vouch for is refused rather
|
|
15
|
+
than persisted.
|
|
16
|
+
|
|
17
|
+
The package ships **inert**. Installing it changes nothing until you enable it.
|
|
18
|
+
|
|
19
|
+
## What it does not do
|
|
20
|
+
|
|
21
|
+
It does **not** bound the summary. The host sends no `max_output_tokens` on its summarization request
|
|
22
|
+
and neither does this package, so the summary is as long as the host's prompt and the model make it —
|
|
23
|
+
the same as stock Pi. On the profile this package was built against that meant 26,019 tokens by the
|
|
24
|
+
sixteenth compaction and a base context of 93,152.
|
|
25
|
+
|
|
26
|
+
That is deliberate, and [ADR 0005](docs/adr/0005-bound-by-not-bounding.md) records why. Every
|
|
27
|
+
mechanism that could shorten a summary produces one that ends before the model finished, and the
|
|
28
|
+
host's prompt puts `## Next Steps` and `## Critical Context` — the two sections a continuing model
|
|
29
|
+
most needs — at the very end, so a cut removes them first. A shorter summary is not worth a wrong one.
|
|
30
|
+
|
|
31
|
+
It also does not prune. Replacing old tool results with placeholders would change what the summarizer
|
|
32
|
+
reads, which makes the summary this package's responsibility rather than the host's.
|
|
33
|
+
|
|
34
|
+
**What it improves is speed**: the same summary request, on a model and a thinking level chosen for
|
|
35
|
+
the job rather than inherited from the conversation.
|
|
36
|
+
|
|
37
|
+
## Configuration
|
|
38
|
+
|
|
39
|
+
One JSON file in the Pi agent directory, at `extensions/pi-compaction/config.json`. There are no
|
|
40
|
+
project-level overrides, and `/reload` picks up a change without restarting.
|
|
41
|
+
|
|
42
|
+
```json
|
|
43
|
+
{
|
|
44
|
+
"enabled": true,
|
|
45
|
+
"summarizerModel": "minimax-cn/MiniMax-M3.1-Flash",
|
|
46
|
+
"thinking": "off",
|
|
47
|
+
"keptWindowTokens": 16000
|
|
48
|
+
}
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
| Field | Meaning |
|
|
52
|
+
| --- | --- |
|
|
53
|
+
| `enabled` | Whether the package acts at all. Defaults to `false`. |
|
|
54
|
+
| `summarizerModel` | `provider/model`, or `null` — the default — for the conversation model. `"conversation"` is accepted as a spelling of `null`. |
|
|
55
|
+
| `thinking` | `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`. Defaults to `medium`, and is clamped to what the chosen model supports. |
|
|
56
|
+
| `keptWindowTokens` | Token target for the kept window. Defaults to `16000`. |
|
|
57
|
+
|
|
58
|
+
An invalid value is rejected whole: the package reports the file and the field, and compaction stays
|
|
59
|
+
stock Pi rather than half-applying a configuration.
|
|
60
|
+
|
|
61
|
+
### Choosing a summarizer
|
|
62
|
+
|
|
63
|
+
`summarizerModel` defaults to `null` — the conversation model — so installing the package never
|
|
64
|
+
silently lowers your summary quality. Choosing a cheaper or faster summarizer is explicit:
|
|
65
|
+
|
|
66
|
+
```sh
|
|
67
|
+
/compaction model minimax-cn/MiniMax-M3.1-Flash
|
|
68
|
+
/compaction thinking off
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
The chosen model must be one this host offers with configured credentials; if it is not, the package
|
|
72
|
+
says so and hands compaction back to Pi rather than failing the compaction.
|
|
73
|
+
|
|
74
|
+
**Check `Thinking:` in `/compaction` before assuming `off` took effect.** A model whose
|
|
75
|
+
`thinkingLevelMap` marks `off` unsupported is clamped to its lowest level instead, and it then
|
|
76
|
+
reasons anyway — the status view reports the level that was actually used, not the one you asked for.
|
|
77
|
+
|
|
78
|
+
## The one thing that is refused
|
|
79
|
+
|
|
80
|
+
A **partial summary** is never persisted.
|
|
81
|
+
|
|
82
|
+
The host rejects a summarization response that stopped with `error` or `length`, and one that tried to
|
|
83
|
+
call a tool. For any **other** stop reason it returns the partially written summary unchanged —
|
|
84
|
+
`aborted` being the case the specification names.
|
|
85
|
+
|
|
86
|
+
This matters more than it looks. A summary the model did not finish is a silent change to the
|
|
87
|
+
session's history: the next compaction is told to preserve everything in the previous summary, so an
|
|
88
|
+
incomplete one propagates, and nothing in the transcript says the checkpoint is short. **Reading the
|
|
89
|
+
stop reason requires owning the stream function**, which is why this package wraps it rather than
|
|
90
|
+
handing `compact()` a model and walking away — a wrapper that only swaps the model never sees a
|
|
91
|
+
response at all.
|
|
92
|
+
|
|
93
|
+
The package reads every response the request produced, not only the last one:
|
|
94
|
+
|
|
95
|
+
- A split turn makes **two** summarization requests through the same stream function and the host
|
|
96
|
+
concatenates both into one summary, so a clean second response says nothing about a partial first
|
|
97
|
+
one. Both are checked.
|
|
98
|
+
- An attempt whose responses were never observed at all is refused — it proves nothing.
|
|
99
|
+
|
|
100
|
+
A length stop is refused too. Since this package sends no ceiling, a length stop means the summarizer
|
|
101
|
+
hit the model's own output limit — a genuinely incomplete summary, not a budget working as intended.
|
|
102
|
+
|
|
103
|
+
A cancellation stops everything and answers `{cancel: true}`: no fallback compaction begins behind
|
|
104
|
+
your back.
|
|
105
|
+
|
|
106
|
+
## Degradation
|
|
107
|
+
|
|
108
|
+
Degradation is handing compaction back to stock Pi after the package's own attempt fails. It is never
|
|
109
|
+
silent, and it is the same announcement whatever caused it: a host seam mismatch, an unavailable
|
|
110
|
+
summarizer model, missing credentials, a model error, a partial response, or a cut point that leaves
|
|
111
|
+
no history to summarise.
|
|
112
|
+
|
|
113
|
+
```text
|
|
114
|
+
pi-compaction: degraded — compaction handed back to stock Pi
|
|
115
|
+
Summarizer: mafia-o/gpt-6.1-sol (openai-responses)
|
|
116
|
+
Why: the summarizer reported an error (Summarization failed: 503 model_unavailable)
|
|
117
|
+
Now: stock Pi compacts with the conversation model and the session's own thinking level, not the
|
|
118
|
+
summarizer configured here. The summary length is unaffected — neither path sends an output
|
|
119
|
+
ceiling — but the model and the thinking level are the whole of what this package controls, so a
|
|
120
|
+
handback gives both of them up.
|
|
121
|
+
Next: /compaction model provider/model to summarise with a different model, /compaction thinking
|
|
122
|
+
off|minimal|low|medium|high to change how hard it thinks, or /compaction disable to make stock Pi
|
|
123
|
+
the default.
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
### How it reaches you
|
|
127
|
+
|
|
128
|
+
A notification raised from inside a compaction does not paint: the host clears and rebuilds the
|
|
129
|
+
transcript when the compaction ends, and anything raised before that is lost. A single stderr line
|
|
130
|
+
scrolls away. So the announcement is delivered three ways, and only one of them is the terminal:
|
|
131
|
+
|
|
132
|
+
| Surface | Survives the repaint | Survives a restart |
|
|
133
|
+
| --- | --- | --- |
|
|
134
|
+
| `console.error` and `ctx.ui.notify`, immediately | no — the repaint takes it | no |
|
|
135
|
+
| A **widget pinned above the editor**, set once the compaction has settled | **yes** — it is chrome the repaint does not touch, and it cannot scroll away | no |
|
|
136
|
+
| A **custom session entry**, `pi-compaction-degradation`, read back by `/compaction` | yes | **yes** |
|
|
137
|
+
|
|
138
|
+
The pinned widget is cleared by the next compaction that completes, and at session start and
|
|
139
|
+
shutdown. The session entry is the durable record: custom entries carry no LLM context, so it never
|
|
140
|
+
enters the summary or the model's context.
|
|
141
|
+
|
|
142
|
+
## `/compaction`
|
|
143
|
+
|
|
144
|
+
`/compaction` on its own shows the effective configuration, what the summarizer will actually run at,
|
|
145
|
+
the kept-window target, whether the package is enabled, and the result of the host-seam check. It does
|
|
146
|
+
not shadow the host's `/compact`.
|
|
147
|
+
|
|
148
|
+
```text
|
|
149
|
+
Config: /home/you/.pi/agent/extensions/pi-compaction/config.json
|
|
150
|
+
Enabled: yes
|
|
151
|
+
Summarizer model: minimax-cn/MiniMax-M3.1-Flash (anthropic-messages)
|
|
152
|
+
Thinking: off
|
|
153
|
+
Kept window: 16000 tokens
|
|
154
|
+
Summary length: not bounded by this package — the host sends no output ceiling and neither do we
|
|
155
|
+
Host seam: ok — compact() arity 12; our stream function received {apiKey, cacheRetention, env, headers, sessionId, signal} and its result() was awaited
|
|
156
|
+
Last degradation: none in this session
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
Every knob is settable from it, and each change is written back to the configuration file:
|
|
160
|
+
|
|
161
|
+
```text
|
|
162
|
+
/compaction enable | disable
|
|
163
|
+
/compaction model provider/model | conversation
|
|
164
|
+
/compaction thinking off | minimal | low | medium | high | xhigh | max
|
|
165
|
+
/compaction window <tokens>
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
## Host-seam check
|
|
169
|
+
|
|
170
|
+
Three seams this package depends on are not documented extension APIs: the argument list of the host's
|
|
171
|
+
`compact()`, the point at which it calls its stream function, and that it awaits `result()` on
|
|
172
|
+
whatever that function returns. All three are verified at activation — the arity, and an actual call
|
|
173
|
+
through a stream function the package supplies, which needs no credentials and makes no request. A
|
|
174
|
+
mismatch degrades to the host instead of letting the package misbehave, and the status view reports
|
|
175
|
+
it. Tested signatures decide compatibility, not version text.
|
|
176
|
+
|
|
177
|
+
The third is the one the package reads a response's stop reason through, which is how it refuses to
|
|
178
|
+
persist a partial summary. Losing it would not break compaction — summaries would still be produced,
|
|
179
|
+
just unchecked — so it is probed rather than assumed.
|
|
180
|
+
|
|
181
|
+
## Install
|
|
182
|
+
|
|
183
|
+
```sh
|
|
184
|
+
pi install npm:@allenyolk/pi-compaction
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
Or take it from the [pi-extensions collection](../../README.md#install), or load it for one run:
|
|
188
|
+
|
|
189
|
+
```sh
|
|
190
|
+
pi -e ./packages/pi-compaction/src/index.ts
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
## Status
|
|
194
|
+
|
|
195
|
+
Its development host, and the host its checks run against, is
|
|
196
|
+
`@earendil-works/pi-coding-agent` 1.5.1 — the version whose seams were inspected. It adds no tools,
|
|
197
|
+
so it does not change the host's prompt and tool declarations, and it never patches the host, edits
|
|
198
|
+
an installed Pi or changes the host's compaction settings. It has no runtime dependencies and no peer
|
|
199
|
+
dependency beyond the host itself.
|
|
200
|
+
|
|
201
|
+
Tested platforms, known limits, rollback instructions, independent-review status and the list of what
|
|
202
|
+
is *not* established live in [validation](docs/validation.md).
|
package/package.json
CHANGED
|
@@ -1,6 +1,48 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@allenyolk/pi-compaction",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
|
|
6
|
-
}
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"publishConfig": {
|
|
5
|
+
"access": "public"
|
|
6
|
+
},
|
|
7
|
+
"description": "Choose the model and thinking level Pi compacts with, and never persist a summary that is not whole.",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/AllenYolk/pi-extensions.git",
|
|
11
|
+
"directory": "packages/pi-compaction"
|
|
12
|
+
},
|
|
13
|
+
"type": "module",
|
|
14
|
+
"license": "MIT",
|
|
15
|
+
"files": [
|
|
16
|
+
"src",
|
|
17
|
+
"README.md",
|
|
18
|
+
"LICENSE"
|
|
19
|
+
],
|
|
20
|
+
"exports": "./src/index.ts",
|
|
21
|
+
"pi": {
|
|
22
|
+
"extensions": [
|
|
23
|
+
"./src/index.ts"
|
|
24
|
+
]
|
|
25
|
+
},
|
|
26
|
+
"keywords": [
|
|
27
|
+
"pi-package",
|
|
28
|
+
"pi",
|
|
29
|
+
"pi-coding-agent",
|
|
30
|
+
"compaction",
|
|
31
|
+
"summarizer",
|
|
32
|
+
"context"
|
|
33
|
+
],
|
|
34
|
+
"engines": {
|
|
35
|
+
"node": ">=22.0.0"
|
|
36
|
+
},
|
|
37
|
+
"scripts": {
|
|
38
|
+
"typecheck": "tsc --noEmit",
|
|
39
|
+
"test": "node --test test/*.test.mjs",
|
|
40
|
+
"check": "npm run typecheck && npm test"
|
|
41
|
+
},
|
|
42
|
+
"peerDependencies": {
|
|
43
|
+
"@earendil-works/pi-coding-agent": "*"
|
|
44
|
+
},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"@earendil-works/pi-coding-agent": "1.5.1"
|
|
47
|
+
}
|
|
48
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
/** The word `/compaction model` and a hand-written config file use for the conversation model. */
|
|
5
|
+
export const CONVERSATION_MODEL = 'conversation';
|
|
6
|
+
|
|
7
|
+
export type ThinkingLevel = 'off' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'max';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* What a compaction summarises with. `null` is the conversation model, so the default path never
|
|
11
|
+
* depends on a second provider being signed in.
|
|
12
|
+
*/
|
|
13
|
+
export interface Tuple {
|
|
14
|
+
model: string | null;
|
|
15
|
+
thinking: ThinkingLevel;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const DEFAULT_THINKING: ThinkingLevel = 'medium';
|
|
19
|
+
|
|
20
|
+
export const DEFAULT_KEPT_WINDOW_TOKENS = 16_000;
|
|
21
|
+
|
|
22
|
+
/** The file as written. */
|
|
23
|
+
export interface Config {
|
|
24
|
+
enabled: boolean;
|
|
25
|
+
/** `null` — the default — is the conversation model. */
|
|
26
|
+
summarizerModel: string | null;
|
|
27
|
+
thinking: ThinkingLevel;
|
|
28
|
+
keptWindowTokens: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** What a compaction actually runs with. */
|
|
32
|
+
export interface Effective {
|
|
33
|
+
enabled: boolean;
|
|
34
|
+
tuple: Tuple;
|
|
35
|
+
keptWindowTokens: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const LEVELS: ThinkingLevel[] = ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'];
|
|
39
|
+
const FIELDS = ['enabled', 'summarizerModel', 'thinking', 'keptWindowTokens'];
|
|
40
|
+
const MAX_TOKENS = 1_000_000;
|
|
41
|
+
|
|
42
|
+
export const defaults = (): Config => ({
|
|
43
|
+
enabled: false,
|
|
44
|
+
summarizerModel: null,
|
|
45
|
+
thinking: DEFAULT_THINKING,
|
|
46
|
+
keptWindowTokens: DEFAULT_KEPT_WINDOW_TOKENS,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
export function configPath(agentDir: string): string {
|
|
50
|
+
return join(agentDir, 'extensions', 'pi-compaction', 'config.json');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function integer(value: unknown, label: string, min: number): number {
|
|
54
|
+
if (typeof value !== 'number' || !Number.isInteger(value) || value < min || value > MAX_TOKENS) {
|
|
55
|
+
throw new Error(`${label} must be an integer from ${min} to ${MAX_TOKENS}`);
|
|
56
|
+
}
|
|
57
|
+
return value;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function parse(raw: unknown): Config {
|
|
61
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new Error('config must be an object');
|
|
62
|
+
const record = raw as Record<string, unknown>;
|
|
63
|
+
for (const key of Object.keys(record)) {
|
|
64
|
+
if (!FIELDS.includes(key)) throw new Error(`unknown field ${key}`);
|
|
65
|
+
}
|
|
66
|
+
const config = defaults();
|
|
67
|
+
if ('enabled' in record) {
|
|
68
|
+
if (typeof record.enabled !== 'boolean') throw new Error('enabled must be boolean');
|
|
69
|
+
config.enabled = record.enabled;
|
|
70
|
+
}
|
|
71
|
+
if ('summarizerModel' in record && record.summarizerModel !== null) {
|
|
72
|
+
const model = record.summarizerModel;
|
|
73
|
+
if (model === CONVERSATION_MODEL) {
|
|
74
|
+
config.summarizerModel = null;
|
|
75
|
+
} else if (typeof model !== 'string' || !/^[^/\s]+\/[^/\s]+$/.test(model)) {
|
|
76
|
+
throw new Error(`summarizerModel must be "provider/model", "${CONVERSATION_MODEL}" or null`);
|
|
77
|
+
} else {
|
|
78
|
+
config.summarizerModel = model;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if ('thinking' in record) {
|
|
82
|
+
if (!LEVELS.includes(record.thinking as ThinkingLevel)) throw new Error(`thinking must be ${LEVELS.join(', ')}`);
|
|
83
|
+
config.thinking = record.thinking as ThinkingLevel;
|
|
84
|
+
}
|
|
85
|
+
if ('keptWindowTokens' in record) config.keptWindowTokens = integer(record.keptWindowTokens, 'keptWindowTokens', 0);
|
|
86
|
+
return config;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* A rejected configuration leaves compaction alone rather than half-applying it: the caller stays
|
|
91
|
+
* inert and reports the diagnostic.
|
|
92
|
+
*/
|
|
93
|
+
export function loadConfig(agentDir: string): { config?: Config; diagnostic?: string } {
|
|
94
|
+
const path = configPath(agentDir);
|
|
95
|
+
let text: string;
|
|
96
|
+
try {
|
|
97
|
+
text = readFileSync(path, 'utf8');
|
|
98
|
+
} catch (error) {
|
|
99
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { config: defaults() };
|
|
100
|
+
return { diagnostic: `${path}: ${String(error)}; compaction stays stock Pi` };
|
|
101
|
+
}
|
|
102
|
+
try {
|
|
103
|
+
return { config: parse(JSON.parse(text)) };
|
|
104
|
+
} catch (error) {
|
|
105
|
+
return { diagnostic: `${path}: ${String(error)}; compaction stays stock Pi` };
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The persisted shape: no absent keys, so a reload reads back exactly what was written. */
|
|
110
|
+
function toRaw(config: Config): Record<string, unknown> {
|
|
111
|
+
return {
|
|
112
|
+
enabled: config.enabled,
|
|
113
|
+
summarizerModel: config.summarizerModel ?? null,
|
|
114
|
+
thinking: config.thinking,
|
|
115
|
+
keptWindowTokens: config.keptWindowTokens,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** The same validation the file goes through, for a configuration assembled by the command. */
|
|
120
|
+
export function validateConfig(config: Config): string | undefined {
|
|
121
|
+
try {
|
|
122
|
+
parse(toRaw(config));
|
|
123
|
+
return undefined;
|
|
124
|
+
} catch (error) {
|
|
125
|
+
return error instanceof Error ? error.message : String(error);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function saveConfig(agentDir: string, config: Config): void {
|
|
130
|
+
const path = configPath(agentDir);
|
|
131
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
132
|
+
writeFileSync(path, `${JSON.stringify(toRaw(config), null, 2)}\n`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function effective(config: Config): Effective {
|
|
136
|
+
return {
|
|
137
|
+
enabled: config.enabled,
|
|
138
|
+
keptWindowTokens: config.keptWindowTokens,
|
|
139
|
+
tuple: { model: config.summarizerModel, thinking: config.thinking },
|
|
140
|
+
};
|
|
141
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type { AttemptOutcome } from './summarizer.ts';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The announcement that ends a compaction, and the words it uses.
|
|
5
|
+
*
|
|
6
|
+
* A compaction this package could not complete hands back to stock Pi. That is the owner's explicit
|
|
7
|
+
* choice — availability over control, with a loud warning — so the announcement names the summarizer,
|
|
8
|
+
* the reason and what the session's behaviour now is, rather than printing a line and hoping.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** The custom session entry a degradation is recorded as. Custom entries carry no LLM context. */
|
|
12
|
+
export const DEGRADATION_ENTRY = 'pi-compaction-degradation';
|
|
13
|
+
|
|
14
|
+
/** The widget the announcement is pinned under, so a later compaction can take it down. */
|
|
15
|
+
export const DEGRADATION_WIDGET = 'pi-compaction-degradation';
|
|
16
|
+
|
|
17
|
+
/** Why the attempt failed, in the package's own words rather than the host's. */
|
|
18
|
+
export function describeFailure(outcome: AttemptOutcome | undefined, error: unknown): string {
|
|
19
|
+
if (outcome?.toolCall) return 'the summarizer tried to call a tool instead of writing a summary';
|
|
20
|
+
const reason = outcome?.stopReason;
|
|
21
|
+
if (reason === 'error') return `the summarizer reported an error (${quote(messageOf(error))})`;
|
|
22
|
+
if (reason) {
|
|
23
|
+
return `the summarizer's response stopped with "${quote(reason)}" instead of finishing, `
|
|
24
|
+
+ 'so the summary may be partial';
|
|
25
|
+
}
|
|
26
|
+
// No stop reason this package recognises: either the provider sent none, or it sent something not
|
|
27
|
+
// modelled here. A thrown error is then the only diagnostic there is, and quoting it beats printing
|
|
28
|
+
// the string "undefined" at the user.
|
|
29
|
+
if (error !== undefined) return `the summarizer did not produce a summary (${quote(messageOf(error))})`;
|
|
30
|
+
return outcome
|
|
31
|
+
? "the summarizer's response carried no stop reason, so the package cannot prove it finished"
|
|
32
|
+
: 'the summarizer did not produce a summary';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const messageOf = (error: unknown): string => (error instanceof Error ? error.message : String(error));
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Provider-originated text, reduced to what it is being quoted for.
|
|
39
|
+
*
|
|
40
|
+
* An error message reaches the announcement, which is written to the terminal and pinned as a widget.
|
|
41
|
+
* Control characters there can rewrite the display, a newline breaks the one-fact-per-line shape the
|
|
42
|
+
* announcement depends on, and an unbounded message would push the rest of it off the screen.
|
|
43
|
+
*/
|
|
44
|
+
function quote(text: string): string {
|
|
45
|
+
const flattened = text
|
|
46
|
+
.replace(/[\u0000-\u001F\u007F-\u009F]/g, ' ')
|
|
47
|
+
.replace(/\s+/g, ' ')
|
|
48
|
+
.trim();
|
|
49
|
+
return flattened.length > 300 ? `${flattened.slice(0, 300)}…` : flattened;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Why an attempt whose responses were never observed is refused. Distinct from `describeFailure`'s
|
|
54
|
+
* unobserved case, which has a thrown error to quote: here `compact()` handed back a summary and this
|
|
55
|
+
* package never saw the responses that produced it, so it has proved nothing about it.
|
|
56
|
+
*/
|
|
57
|
+
export const UNOBSERVED_RESPONSE =
|
|
58
|
+
"the summarizer's responses were never observed, so the package cannot prove the summary is complete";
|
|
59
|
+
|
|
60
|
+
export interface Degradation {
|
|
61
|
+
/** What the compaction was to summarise with, or what there was instead. */
|
|
62
|
+
summarizer: string;
|
|
63
|
+
/** Why the attempt failed. */
|
|
64
|
+
reason: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* What a handback costs. It is not a change in summary length: this package sends no output ceiling
|
|
69
|
+
* and neither does stock Pi, so both produce a summary as long as the host's prompt and the model
|
|
70
|
+
* make it. What a handback gives up is the three things this package controls — the model, the
|
|
71
|
+
* thinking level, and the cut point.
|
|
72
|
+
*/
|
|
73
|
+
const HANDBACK_COST =
|
|
74
|
+
'stock Pi compacts with the conversation model, the session\'s own thinking level, and the host\'s '
|
|
75
|
+
+ 'recent-token setting rather than this package\'s kept-window target. The summary length is '
|
|
76
|
+
+ 'unaffected — neither path sends an output ceiling — but those three are the whole of what this '
|
|
77
|
+
+ 'package controls, so a handback gives all of them up.';
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The announcement, as the lines the user reads. Line one is the headline; the rest name the
|
|
81
|
+
* summarizer, the reason, what the session's behaviour now is and what to do about it.
|
|
82
|
+
*/
|
|
83
|
+
export function announcement(degradation: Degradation): string[] {
|
|
84
|
+
return [
|
|
85
|
+
'degraded — compaction handed back to stock Pi',
|
|
86
|
+
`Summarizer: ${degradation.summarizer}`,
|
|
87
|
+
`Why: ${degradation.reason}`,
|
|
88
|
+
`Now: ${HANDBACK_COST}`,
|
|
89
|
+
'Next: /compaction model provider/model to summarise with a different model, /compaction '
|
|
90
|
+
+ 'thinking off|minimal|low|medium|high|xhigh|max to change how hard it thinks, /compaction '
|
|
91
|
+
+ 'window <tokens> to change the cut point, or /compaction disable to make stock Pi the default.',
|
|
92
|
+
];
|
|
93
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import { compact, getAgentDir } from '@earendil-works/pi-coding-agent';
|
|
2
|
+
import type {
|
|
3
|
+
ExtensionAPI, ExtensionCommandContext, ExtensionContext, SessionBeforeCompactEvent, SessionBeforeCompactResult,
|
|
4
|
+
} from '@earendil-works/pi-coding-agent';
|
|
5
|
+
import {
|
|
6
|
+
configPath,
|
|
7
|
+
CONVERSATION_MODEL,
|
|
8
|
+
defaults,
|
|
9
|
+
effective,
|
|
10
|
+
loadConfig,
|
|
11
|
+
saveConfig,
|
|
12
|
+
validateConfig,
|
|
13
|
+
type Config,
|
|
14
|
+
type ThinkingLevel,
|
|
15
|
+
} from './config.ts';
|
|
16
|
+
import { prepareCompaction } from './preparation.ts';
|
|
17
|
+
import {
|
|
18
|
+
announcement,
|
|
19
|
+
DEGRADATION_ENTRY,
|
|
20
|
+
DEGRADATION_WIDGET,
|
|
21
|
+
describeFailure,
|
|
22
|
+
UNOBSERVED_RESPONSE,
|
|
23
|
+
type Degradation,
|
|
24
|
+
} from './degradation.ts';
|
|
25
|
+
import {
|
|
26
|
+
checkHostSeam,
|
|
27
|
+
hostRetryPolicy,
|
|
28
|
+
observingStream,
|
|
29
|
+
resolveSummarizerModel,
|
|
30
|
+
summarizerThinkingLevel,
|
|
31
|
+
type AttemptOutcome,
|
|
32
|
+
type SeamResult,
|
|
33
|
+
} from './summarizer.ts';
|
|
34
|
+
|
|
35
|
+
export default function piCompaction(pi: ExtensionAPI): void {
|
|
36
|
+
let config: Config = defaults();
|
|
37
|
+
let diagnostic: string | undefined;
|
|
38
|
+
let seam: Promise<SeamResult> | undefined;
|
|
39
|
+
/** The degradation raised inside a compaction, waiting for the compaction to settle. */
|
|
40
|
+
let pending: Degradation | undefined;
|
|
41
|
+
|
|
42
|
+
const reload = () => {
|
|
43
|
+
const loaded = loadConfig(getAgentDir());
|
|
44
|
+
config = loaded.config ?? defaults();
|
|
45
|
+
diagnostic = loaded.diagnostic;
|
|
46
|
+
};
|
|
47
|
+
const seamResult = () => (seam ??= checkHostSeam());
|
|
48
|
+
const report = (ctx: ExtensionContext, message: string, type: 'info' | 'warning' | 'error' = 'warning') => {
|
|
49
|
+
const line = `pi-compaction: ${message}`;
|
|
50
|
+
if (ctx.hasUI) ctx.ui.notify(line, type);
|
|
51
|
+
// A notification raised from inside a compaction does not always reach the terminal before the
|
|
52
|
+
// host repaints over it, and a warning that can be missed is not a warning.
|
|
53
|
+
console.error(line);
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The durable half of a degradation, written once the compaction has settled.
|
|
58
|
+
*
|
|
59
|
+
* Anything raised inside `session_before_compact` is lost to the repaint: the host clears and
|
|
60
|
+
* rebuilds the transcript when the compaction ends, and a notification raised before that never
|
|
61
|
+
* reaches the terminal. So the announcement is pinned above the editor — chrome the repaint does
|
|
62
|
+
* not touch — and recorded as a custom session entry, which carries no LLM context and survives a
|
|
63
|
+
* restart. `/compaction` reads that entry back, which is the part that stays visible afterwards.
|
|
64
|
+
*/
|
|
65
|
+
const settle = (ctx: ExtensionContext) => {
|
|
66
|
+
const degradation = pending;
|
|
67
|
+
pending = undefined;
|
|
68
|
+
if (!degradation) {
|
|
69
|
+
// A compaction that completed resolves whatever an earlier one degraded into.
|
|
70
|
+
if (ctx.hasUI) ctx.ui.setWidget(DEGRADATION_WIDGET, undefined);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
pi.appendEntry(DEGRADATION_ENTRY, degradation);
|
|
74
|
+
if (ctx.hasUI) ctx.ui.setWidget(DEGRADATION_WIDGET, announcement(degradation));
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
pi.on('session_start', (_event, ctx) => {
|
|
78
|
+
reload();
|
|
79
|
+
seam = undefined;
|
|
80
|
+
pending = undefined;
|
|
81
|
+
if (ctx.hasUI) ctx.ui.setWidget(DEGRADATION_WIDGET, undefined);
|
|
82
|
+
// Activation-time check: the status view reports it, and the hook awaits the same promise.
|
|
83
|
+
void seamResult();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
pi.on('session_compact', (_event, ctx) => settle(ctx));
|
|
87
|
+
pi.on('session_compact_failed', (_event, ctx) => settle(ctx));
|
|
88
|
+
|
|
89
|
+
pi.on('session_shutdown', (_event, ctx) => {
|
|
90
|
+
config = defaults();
|
|
91
|
+
diagnostic = undefined;
|
|
92
|
+
seam = undefined;
|
|
93
|
+
pending = undefined;
|
|
94
|
+
if (ctx.hasUI) ctx.ui.setWidget(DEGRADATION_WIDGET, undefined);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
pi.on('session_before_compact', async (event: SessionBeforeCompactEvent, ctx: ExtensionContext): Promise<SessionBeforeCompactResult | void> => {
|
|
98
|
+
const settings = effective(config);
|
|
99
|
+
const tuple = settings.tuple;
|
|
100
|
+
// Inert until enabled: installing the package changes nothing.
|
|
101
|
+
if (!settings.enabled) return;
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Every handback goes through here, so a degradation is announced the same way whatever caused
|
|
105
|
+
* it. A cancellation is answered first: handing back would start a fresh compaction behind the
|
|
106
|
+
* user, which is the one thing a cancellation must never do.
|
|
107
|
+
*/
|
|
108
|
+
const handBack = (summarizer: string, reason: string): SessionBeforeCompactResult | void => {
|
|
109
|
+
if (event.signal.aborted) return { cancel: true };
|
|
110
|
+
pending = { summarizer, reason };
|
|
111
|
+
report(ctx, announcement(pending).join('\n'), 'error');
|
|
112
|
+
return undefined;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const checked = await seamResult();
|
|
116
|
+
if (!checked.ok) return handBack('none — the seam was never reached', `host seam mismatch (${checked.detail})`);
|
|
117
|
+
|
|
118
|
+
const model = resolveSummarizerModel(tuple, ctx);
|
|
119
|
+
if (!model) {
|
|
120
|
+
const wanted = tuple.model === null ? 'the conversation model' : `summarizer model ${tuple.model}`;
|
|
121
|
+
return handBack(tuple.model ?? CONVERSATION_MODEL, `this host does not offer ${wanted}`);
|
|
122
|
+
}
|
|
123
|
+
const named = `${model.provider}/${model.id} (${model.api})`;
|
|
124
|
+
if (!ctx.modelRegistry.hasConfiguredAuth(model)) return handBack(named, 'the host has no configured credentials for it');
|
|
125
|
+
|
|
126
|
+
const prepared = prepareCompaction(event.branchEntries, event.preparation, settings.keptWindowTokens);
|
|
127
|
+
if (!prepared) {
|
|
128
|
+
return handBack(
|
|
129
|
+
named,
|
|
130
|
+
'the cut point leaves no history to summarise, so the host would carry the previous summary '
|
|
131
|
+
+ 'forward and this package could not choose what produced it',
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// One request. The model and its thinking level are two of the three things this package changes;
|
|
136
|
+
// the summary is otherwise the host's, at whatever length the host's prompt and the model produce.
|
|
137
|
+
//
|
|
138
|
+
// Responses are grouped by the context they came from. The host's retry policy calls the stream
|
|
139
|
+
// function again with the same context after a transient failure, and only the response it
|
|
140
|
+
// finally returns is the one `compact()` acted on — so a later response replaces an earlier one,
|
|
141
|
+
// and a failure that was retried away must not be read as this attempt's outcome. A split turn
|
|
142
|
+
// summarises two ranges through two contexts, and both of those have to be clean.
|
|
143
|
+
const outcomes = new Map<object, AttemptOutcome>();
|
|
144
|
+
let result;
|
|
145
|
+
try {
|
|
146
|
+
result = await compact(
|
|
147
|
+
prepared,
|
|
148
|
+
model,
|
|
149
|
+
undefined,
|
|
150
|
+
undefined,
|
|
151
|
+
event.customInstructions,
|
|
152
|
+
event.signal,
|
|
153
|
+
summarizerThinkingLevel(model, tuple) as never,
|
|
154
|
+
observingStream(ctx.modelRegistry, (group, seen) => { outcomes.set(group, seen); }),
|
|
155
|
+
undefined,
|
|
156
|
+
hostRetryPolicy(pi.getSettings()),
|
|
157
|
+
undefined,
|
|
158
|
+
ctx.sessionManager.getSessionId(),
|
|
159
|
+
);
|
|
160
|
+
} catch (error) {
|
|
161
|
+
if (event.signal.aborted) return { cancel: true };
|
|
162
|
+
return handBack(named, describeFailure([...outcomes.values()].at(-1), error));
|
|
163
|
+
}
|
|
164
|
+
if (event.signal.aborted) return { cancel: true };
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The whole of this package's quality guarantee: a summary it cannot vouch for is never
|
|
168
|
+
* persisted. The host throws on an error and on a tool call, so those never reach here; it
|
|
169
|
+
* returns a partially written summary **unchanged** for any other stop reason, which is what this
|
|
170
|
+
* check exists to refuse.
|
|
171
|
+
*
|
|
172
|
+
* Every response the attempt kept is checked, not only the last one — see the grouping above —
|
|
173
|
+
* and a response with no stop reason this package recognises is refused too, because an
|
|
174
|
+
* unrecognised response is not a proven-complete one.
|
|
175
|
+
*/
|
|
176
|
+
const responses = [...outcomes.values()];
|
|
177
|
+
const unclean = responses.find((seen) => seen.stopReason !== 'stop');
|
|
178
|
+
if (responses.length === 0 || unclean) {
|
|
179
|
+
return handBack(named, unclean ? describeFailure(unclean, undefined) : UNOBSERVED_RESPONSE);
|
|
180
|
+
}
|
|
181
|
+
return { compaction: result };
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
pi.registerCommand('compaction', {
|
|
185
|
+
description: 'Show or change which model compacts this session, and how hard it thinks',
|
|
186
|
+
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
187
|
+
const [verb, value] = args.trim().split(/\s+/, 2);
|
|
188
|
+
if (!verb || verb === 'status') {
|
|
189
|
+
ctx.ui.notify(await status(ctx), 'info');
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
const next: Config = { ...config };
|
|
193
|
+
switch (verb) {
|
|
194
|
+
case 'enable': next.enabled = true; break;
|
|
195
|
+
case 'disable': next.enabled = false; break;
|
|
196
|
+
case 'model':
|
|
197
|
+
if (!value) return void ctx.ui.notify(`usage: /compaction model provider/model | ${CONVERSATION_MODEL}`, 'error');
|
|
198
|
+
next.summarizerModel = value === CONVERSATION_MODEL ? null : value;
|
|
199
|
+
break;
|
|
200
|
+
case 'thinking':
|
|
201
|
+
if (!value) return void ctx.ui.notify('usage: /compaction thinking off|minimal|low|medium|high|xhigh|max', 'error');
|
|
202
|
+
next.thinking = value as ThinkingLevel;
|
|
203
|
+
break;
|
|
204
|
+
case 'window':
|
|
205
|
+
if (!value || !/^\d+$/.test(value)) return void ctx.ui.notify('usage: /compaction window <tokens>', 'error');
|
|
206
|
+
next.keptWindowTokens = Number(value);
|
|
207
|
+
break;
|
|
208
|
+
default:
|
|
209
|
+
ctx.ui.notify('unknown setting; try model, thinking, window, enable, disable', 'error');
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
const invalid = validateConfig(next);
|
|
213
|
+
if (invalid) {
|
|
214
|
+
ctx.ui.notify(`pi-compaction: nothing changed; ${invalid}`, 'error');
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
saveConfig(getAgentDir(), next);
|
|
218
|
+
config = next;
|
|
219
|
+
ctx.ui.notify(await status(ctx), 'info');
|
|
220
|
+
},
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
async function status(ctx: ExtensionCommandContext): Promise<string> {
|
|
224
|
+
const settings = effective(config);
|
|
225
|
+
const lines = [diagnostic ? `Config rejected: ${diagnostic}` : `Config: ${configPath(getAgentDir())}`];
|
|
226
|
+
lines.push(`Enabled: ${settings.enabled ? 'yes' : 'no'}`);
|
|
227
|
+
const tuple = settings.tuple;
|
|
228
|
+
const model = resolveSummarizerModel(tuple, ctx);
|
|
229
|
+
const wanted = tuple.model === null ? `${CONVERSATION_MODEL} model` : tuple.model;
|
|
230
|
+
const described = model ? `${model.provider}/${model.id} (${model.api})` : `${wanted} — not available on this host`;
|
|
231
|
+
lines.push(`Summarizer model: ${described}`);
|
|
232
|
+
if (model) {
|
|
233
|
+
const thinking = summarizerThinkingLevel(model, tuple);
|
|
234
|
+
// A model whose `thinkingLevelMap` marks the requested level unsupported gets the host's own
|
|
235
|
+
// clamp, and it then thinks anyway — so the level that was used is what gets reported.
|
|
236
|
+
const why = thinking === tuple.thinking ? ''
|
|
237
|
+
: tuple.thinking === 'off'
|
|
238
|
+
? ` (clamped from off; this model has no off, so its reasoning phase still runs)`
|
|
239
|
+
: ` (clamped from ${tuple.thinking})`;
|
|
240
|
+
lines.push(`Thinking: ${thinking}${why}`);
|
|
241
|
+
} else {
|
|
242
|
+
lines.push(`Thinking: ${tuple.thinking}`);
|
|
243
|
+
}
|
|
244
|
+
lines.push(`Kept window: ${settings.keptWindowTokens} tokens`);
|
|
245
|
+
lines.push('Summary length: not bounded by this package — the host sends no output ceiling and neither do we');
|
|
246
|
+
const checked = await seamResult();
|
|
247
|
+
lines.push(`Host seam: ${checked.ok ? 'ok' : 'MISMATCH'} — ${checked.detail}`);
|
|
248
|
+
const degraded = lastDegradation(ctx);
|
|
249
|
+
lines.push(degraded
|
|
250
|
+
? `Last degradation: summarizer ${degraded.summarizer} — ${degraded.reason}`
|
|
251
|
+
: 'Last degradation: none in this session');
|
|
252
|
+
return lines.join('\n');
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** The most recent degradation this package recorded in the session, if there is one. */
|
|
256
|
+
function lastDegradation(ctx: ExtensionCommandContext): Degradation | undefined {
|
|
257
|
+
const branch = ctx.sessionManager.getBranch();
|
|
258
|
+
for (let index = branch.length - 1; index >= 0; index--) {
|
|
259
|
+
const entry = branch[index];
|
|
260
|
+
if (entry.type === 'custom' && entry.customType === DEGRADATION_ENTRY) return entry.data as Degradation;
|
|
261
|
+
}
|
|
262
|
+
return undefined;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { buildSessionProjection, findCutPoint } from '@earendil-works/pi-coding-agent';
|
|
2
|
+
import type { FileOperations, SessionEntry, SessionProjection } from '@earendil-works/pi-coding-agent';
|
|
3
|
+
import type { SummarizationPreparation } from './summarizer.ts';
|
|
4
|
+
|
|
5
|
+
type AgentMessage = SessionProjection['messages'][number];
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Cap for each of the two file blocks `compact()` appends to the summary. The blocks are an index
|
|
9
|
+
* of what a compaction covered; past a few dozen paths they are noise, and unbounded they are what
|
|
10
|
+
* grew a real session's last summary to 196 lines.
|
|
11
|
+
*/
|
|
12
|
+
export const MAX_FILE_LIST_ENTRIES = 40;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Our own preparation: the host's compaction with our cut point.
|
|
16
|
+
*
|
|
17
|
+
* The host's recent-token setting bounds its preparation, not its result — the hook's
|
|
18
|
+
* `firstKeptEntryId` is used verbatim — so we choose the cut point ourselves and then own the
|
|
19
|
+
* consequence: the range we summarise has to reach our cut point, or the entries between the
|
|
20
|
+
* host's summarised range and our cut point would be dropped from the session's context without
|
|
21
|
+
* ever being summarised.
|
|
22
|
+
*/
|
|
23
|
+
export function prepareCompaction(
|
|
24
|
+
branchEntries: SessionEntry[],
|
|
25
|
+
host: SummarizationPreparation,
|
|
26
|
+
keptWindowTokens: number,
|
|
27
|
+
): SummarizationPreparation | undefined {
|
|
28
|
+
const projected = buildSessionProjection(branchEntries).entries;
|
|
29
|
+
const previousIndex = projected.findIndex((entry) => entry.sourceEntry.type === 'compaction' && entry.messages.length > 0);
|
|
30
|
+
const boundaryStart = previousIndex >= 0 ? previousIndex + 1 : 0;
|
|
31
|
+
// shortcut: findCutPoint() measures the raw entries rather than the projected ones the host's own
|
|
32
|
+
// preparation uses, so an entry carrying a context edit counts at its full size and the kept window
|
|
33
|
+
// lands smaller than its target — the safe direction for base context. Correcting it means
|
|
34
|
+
// reimplementing the host's cut-point search over projected sizes, which is more duplication than
|
|
35
|
+
// the overshoot costs; do it if a measured kept window proves too thin.
|
|
36
|
+
const sources = projected.map((entry) => entry.sourceEntry);
|
|
37
|
+
const cut = findCutPoint(sources, boundaryStart, sources.length, keptWindowTokens);
|
|
38
|
+
const firstKept = projected[cut.firstKeptEntryIndex]?.sourceEntry;
|
|
39
|
+
if (!firstKept?.id) return undefined;
|
|
40
|
+
|
|
41
|
+
// Everything from the previous compaction's boundary up to our cut point, in one of the two
|
|
42
|
+
// ranges the host summarises. Nothing between them may fall through.
|
|
43
|
+
const collect = (from: number, to: number): AgentMessage[] =>
|
|
44
|
+
projected.slice(from, to).flatMap((entry) =>
|
|
45
|
+
entry.sourceEntry.type === 'compaction' ? [] : entry.messages.filter((message) => message.role !== 'system'),
|
|
46
|
+
);
|
|
47
|
+
const historyEnd = cut.isSplitTurn ? cut.turnStartIndex : cut.firstKeptEntryIndex;
|
|
48
|
+
const messagesToSummarize = collect(boundaryStart, historyEnd);
|
|
49
|
+
const turnPrefixMessages = cut.isSplitTurn ? collect(cut.turnStartIndex, cut.firstKeptEntryIndex) : [];
|
|
50
|
+
// Nothing to summarise. On a split turn with an empty history range the host carries the previous
|
|
51
|
+
// summary forward verbatim and appends a fresh turn prefix to it, so the result can only grow and
|
|
52
|
+
// no budget this package sets can bound it. Declining is the only honest answer.
|
|
53
|
+
if (messagesToSummarize.length === 0) return undefined;
|
|
54
|
+
|
|
55
|
+
const previous = previousIndex >= 0 ? projected[previousIndex].sourceEntry : undefined;
|
|
56
|
+
return {
|
|
57
|
+
firstKeptEntryId: firstKept.id,
|
|
58
|
+
messagesToSummarize,
|
|
59
|
+
turnPrefixMessages,
|
|
60
|
+
isSplitTurn: cut.isSplitTurn,
|
|
61
|
+
tokensBefore: host.tokensBefore,
|
|
62
|
+
previousSummary: host.previousSummary,
|
|
63
|
+
fileOps: curateFileOps(host.fileOps, previous),
|
|
64
|
+
settings: host.settings,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Bounded file lists, and no inheritance.
|
|
70
|
+
*
|
|
71
|
+
* The host seeds its file operations from the previous compaction's `details` unless that
|
|
72
|
+
* compaction came from a hook, which is how the lists used to compound across compactions. We drop
|
|
73
|
+
* that seed ourselves so the first compaction after a native one does not carry its lists either.
|
|
74
|
+
*
|
|
75
|
+
* shortcut: the host extracted these from its own summarised range, which ends at its cut point
|
|
76
|
+
* rather than ours, so paths touched only in the last few thousand tokens before our cut point are
|
|
77
|
+
* not listed. Extract from our own range when the summary's file index proves too thin.
|
|
78
|
+
*/
|
|
79
|
+
function curateFileOps(hostOps: FileOperations, previous: SessionEntry | undefined): FileOperations {
|
|
80
|
+
const inherited = previous?.type === 'compaction' && !previous.fromHook ? readDetails(previous.details) : undefined;
|
|
81
|
+
const cap = (values: Set<string>, drop: string[] | undefined): Set<string> => {
|
|
82
|
+
const excluded = new Set(drop ?? []);
|
|
83
|
+
return new Set([...values].filter((value) => !excluded.has(value)).sort().slice(0, MAX_FILE_LIST_ENTRIES));
|
|
84
|
+
};
|
|
85
|
+
return {
|
|
86
|
+
read: cap(hostOps.read, inherited?.readFiles),
|
|
87
|
+
written: cap(hostOps.written, inherited?.modifiedFiles),
|
|
88
|
+
edited: cap(hostOps.edited, inherited?.modifiedFiles),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function readDetails(details: unknown): { readFiles?: string[]; modifiedFiles?: string[] } | undefined {
|
|
93
|
+
if (!details || typeof details !== 'object') return undefined;
|
|
94
|
+
const record = details as { readFiles?: unknown; modifiedFiles?: unknown };
|
|
95
|
+
const strings = (value: unknown) => (Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : undefined);
|
|
96
|
+
return { readFiles: strings(record.readFiles), modifiedFiles: strings(record.modifiedFiles) };
|
|
97
|
+
}
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import { compact, DEFAULT_COMPACTION_SETTINGS } from '@earendil-works/pi-coding-agent';
|
|
2
|
+
import type { ExtensionContext, ModelRegistry } from '@earendil-works/pi-coding-agent';
|
|
3
|
+
import type { ThinkingLevel, Tuple } from './config.ts';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Every type this module needs is derived from `compact()`'s own signature. That signature is one of
|
|
7
|
+
* the three seams checked at activation, so deriving from it keeps the check and the call in step.
|
|
8
|
+
*/
|
|
9
|
+
type CompactArguments = Parameters<typeof compact>;
|
|
10
|
+
export type SummarizerModel = CompactArguments[1];
|
|
11
|
+
export type SummarizerStream = NonNullable<CompactArguments[7]>;
|
|
12
|
+
/** The host does not export this name; `compact()`'s first parameter is the same type. */
|
|
13
|
+
export type SummarizationPreparation = CompactArguments[0];
|
|
14
|
+
type StreamOptions = NonNullable<Parameters<SummarizerStream>[2]>;
|
|
15
|
+
|
|
16
|
+
const LEVELS: ThinkingLevel[] = ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'];
|
|
17
|
+
|
|
18
|
+
export function resolveSummarizerModel(tuple: Tuple, ctx: ExtensionContext): SummarizerModel | undefined {
|
|
19
|
+
if (tuple.model === null) return ctx.model;
|
|
20
|
+
const separator = tuple.model.indexOf('/');
|
|
21
|
+
return ctx.modelRegistry.find(tuple.model.slice(0, separator), tuple.model.slice(separator + 1));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The host's own clamping: prefer a higher supported level, then a lower one. */
|
|
25
|
+
export function clampThinkingLevel(model: SummarizerModel, level: ThinkingLevel): ThinkingLevel {
|
|
26
|
+
const available = supportedThinkingLevels(model);
|
|
27
|
+
if (available.includes(level)) return level;
|
|
28
|
+
const requested = LEVELS.indexOf(level);
|
|
29
|
+
if (requested === -1) return available[0] ?? 'off';
|
|
30
|
+
for (let index = requested; index < LEVELS.length; index++) {
|
|
31
|
+
if (available.includes(LEVELS[index]!)) return LEVELS[index]!;
|
|
32
|
+
}
|
|
33
|
+
for (let index = requested - 1; index >= 0; index--) {
|
|
34
|
+
if (available.includes(LEVELS[index]!)) return LEVELS[index]!;
|
|
35
|
+
}
|
|
36
|
+
return available[0] ?? 'off';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function supportedThinkingLevels(model: SummarizerModel): ThinkingLevel[] {
|
|
40
|
+
const map = (model as { reasoning?: boolean; thinkingLevelMap?: Record<string, unknown> });
|
|
41
|
+
if (!map.reasoning) return ['off'];
|
|
42
|
+
return LEVELS.filter((level) => {
|
|
43
|
+
const mapped = map.thinkingLevelMap?.[level];
|
|
44
|
+
if (mapped === null) return false;
|
|
45
|
+
return level === 'xhigh' || level === 'max' ? mapped !== undefined : true;
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The level the summarizer runs at.
|
|
51
|
+
*
|
|
52
|
+
* A model that does not support the requested level gets the host's own clamp, so a preset can never
|
|
53
|
+
* produce a request the model rejects. A model whose `thinkingLevelMap` marks `off` unsupported
|
|
54
|
+
* therefore reasons even when `off` was asked for, and the status view reports the level that was
|
|
55
|
+
* actually used rather than the one that was requested.
|
|
56
|
+
*/
|
|
57
|
+
export function summarizerThinkingLevel(model: SummarizerModel, tuple: Tuple): ThinkingLevel {
|
|
58
|
+
return clampThinkingLevel(model, tuple.thinking);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** What the provider's own response said about a summarization attempt. */
|
|
62
|
+
export interface AttemptOutcome {
|
|
63
|
+
/** The response's `stopReason`; undefined when the provider sent none we recognise. */
|
|
64
|
+
stopReason?: string;
|
|
65
|
+
/** Whether the response tried to call a tool instead of writing a summary. */
|
|
66
|
+
toolCall: boolean;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The stream function this package owns.
|
|
71
|
+
*
|
|
72
|
+
* It exists for two reasons and injects nothing. The registry's own `streamSimple` resolves
|
|
73
|
+
* credentials for a summarizer on another provider, which `compact()`'s explicit api-key arguments
|
|
74
|
+
* cannot do for us. And it is where the response is read: the host turns an error and a tool call
|
|
75
|
+
* into thrown errors whose prose would otherwise have to be string-matched, and it returns a
|
|
76
|
+
* partially written summary **unchanged** for any other stop reason — `aborted` being the case the
|
|
77
|
+
* specification names. Refusing to persist one is this package's entire quality guarantee, so the
|
|
78
|
+
* stop reason has to be read from the response rather than inferred.
|
|
79
|
+
*
|
|
80
|
+
* That read rests on a third host seam: the host awaits `result()` on whatever this function returns,
|
|
81
|
+
* so the replacement installed below is the one it calls. Without it no outcome is recorded at all —
|
|
82
|
+
* a silent, quality-only failure, which is why `checkHostSeam` probes for it.
|
|
83
|
+
*/
|
|
84
|
+
export function observingStream(
|
|
85
|
+
registry: ModelRegistry,
|
|
86
|
+
observe?: (group: object, outcome: AttemptOutcome) => void,
|
|
87
|
+
): SummarizerStream {
|
|
88
|
+
return ((model: SummarizerModel, context: unknown, options?: StreamOptions) => {
|
|
89
|
+
const stream = registry.streamSimple(model, context as never, options) as { result: () => Promise<unknown> };
|
|
90
|
+
if (!observe) return stream;
|
|
91
|
+
const result = stream.result.bind(stream);
|
|
92
|
+
stream.result = async () => {
|
|
93
|
+
const message = await result();
|
|
94
|
+
// The group is the context this response came from, and it is what separates a retry from a
|
|
95
|
+
// split turn. The host's retry policy calls the stream function again with the *same* context
|
|
96
|
+
// after a transient failure, and only the response it finally returns is the one `compact()`
|
|
97
|
+
// acts on — so a later response replaces an earlier one in that group, and a discarded failure
|
|
98
|
+
// must not be read as the attempt's outcome. A split turn summarises two ranges through two
|
|
99
|
+
// different contexts, and each of those has to be clean on its own.
|
|
100
|
+
observe(context as object, outcomeOf(message));
|
|
101
|
+
return message;
|
|
102
|
+
};
|
|
103
|
+
return stream;
|
|
104
|
+
}) as unknown as SummarizerStream;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function outcomeOf(message: unknown): AttemptOutcome {
|
|
108
|
+
const response = (message ?? {}) as { stopReason?: unknown; content?: unknown };
|
|
109
|
+
const content = Array.isArray(response.content) ? response.content : [];
|
|
110
|
+
return {
|
|
111
|
+
...(typeof response.stopReason === 'string' ? { stopReason: response.stopReason } : {}),
|
|
112
|
+
toolCall: content.some((block) => (block as { type?: unknown } | null)?.type === 'toolCall'),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export interface RetryPolicy {
|
|
117
|
+
enabled: boolean;
|
|
118
|
+
maxRetries: number;
|
|
119
|
+
baseDelayMs: number;
|
|
120
|
+
maxAgentDelayMs: number;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The host's resolved retry policy for a summarization request.
|
|
125
|
+
*
|
|
126
|
+
* shortcut: this repeats `SettingsManager.getRetrySettings()` — the same four defaults over the raw
|
|
127
|
+
* merged settings — because an extension is handed merged rather than resolved settings. Passing no
|
|
128
|
+
* policy at all is worse than it looks: the host wraps every summarization call in
|
|
129
|
+
* `retryAssistantCall()` precisely so a transient stream drop does not fail the compaction, and that
|
|
130
|
+
* wrapper never retries without a policy. On a summary request that runs for minutes, a dropped
|
|
131
|
+
* socket would then fail the whole compaction on the first attempt — strictly worse than the native
|
|
132
|
+
* path it wraps. Read the resolved policy from the extension API when the host exposes one.
|
|
133
|
+
*/
|
|
134
|
+
export function hostRetryPolicy(settings: { retry?: Partial<RetryPolicy> }): RetryPolicy {
|
|
135
|
+
return {
|
|
136
|
+
enabled: settings.retry?.enabled ?? true,
|
|
137
|
+
maxRetries: settings.retry?.maxRetries ?? 3,
|
|
138
|
+
baseDelayMs: settings.retry?.baseDelayMs ?? 2_000,
|
|
139
|
+
maxAgentDelayMs: settings.retry?.maxAgentDelayMs ?? 60_000,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export interface SeamResult {
|
|
144
|
+
ok: boolean;
|
|
145
|
+
detail: string;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Three things the host does are not documented extension APIs: `compact()`'s argument list, the
|
|
150
|
+
* point at which it calls its stream function, and that it awaits `result()` on whatever that
|
|
151
|
+
* function returns. All three are checked rather than assumed — with an actual call through a stream
|
|
152
|
+
* function we supply, which needs no credentials and makes no request. A mismatch hands compaction
|
|
153
|
+
* back to the host instead of letting the package misbehave.
|
|
154
|
+
*/
|
|
155
|
+
export async function checkHostSeam(compactFn: typeof compact = compact): Promise<SeamResult> {
|
|
156
|
+
try {
|
|
157
|
+
return await probeSeam(compactFn);
|
|
158
|
+
} catch (error) {
|
|
159
|
+
return { ok: false, detail: `the seam check itself failed: ${String(error)}` };
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function probeSeam(compactFn: typeof compact): Promise<SeamResult> {
|
|
164
|
+
if (compactFn.length !== 12) {
|
|
165
|
+
return { ok: false, detail: `compact() takes ${compactFn.length} arguments, not 12` };
|
|
166
|
+
}
|
|
167
|
+
const seen: unknown[] = [];
|
|
168
|
+
let awaited = 0;
|
|
169
|
+
let produced = 0;
|
|
170
|
+
const stream: SummarizerStream = ((_model: unknown, _context: unknown, options?: StreamOptions) => {
|
|
171
|
+
seen.push(options);
|
|
172
|
+
return {
|
|
173
|
+
result: async () => {
|
|
174
|
+
awaited += 1;
|
|
175
|
+
produced += 1;
|
|
176
|
+
// The first attempt fails retryably, so a retry policy that reached the host's own wrapper
|
|
177
|
+
// shows up as a second call. Arity alone cannot prove the tenth argument is still the retry
|
|
178
|
+
// policy: every argument this package passes after the stream function used to be `undefined`,
|
|
179
|
+
// so a host that reordered them was harmless, and this is the first one where a wrong position
|
|
180
|
+
// would silently misroute a value the package depends on.
|
|
181
|
+
return produced === 1 ? probeErrorResponse() : probeResponse();
|
|
182
|
+
},
|
|
183
|
+
} as never;
|
|
184
|
+
}) as unknown as SummarizerStream;
|
|
185
|
+
const retry: RetryPolicy = { enabled: true, maxRetries: 1, baseDelayMs: 1, maxAgentDelayMs: 1 };
|
|
186
|
+
try {
|
|
187
|
+
await compactFn(
|
|
188
|
+
probePreparation(), probeModel(), undefined, undefined, undefined, undefined, 'off', stream,
|
|
189
|
+
undefined, retry, undefined, 'pi-compaction-seam-probe',
|
|
190
|
+
);
|
|
191
|
+
} catch (error) {
|
|
192
|
+
return { ok: false, detail: `compact() rejected a supplied stream function: ${String(error)}` };
|
|
193
|
+
}
|
|
194
|
+
const options = seen[0];
|
|
195
|
+
if (seen.length === 0 || !options || typeof options !== 'object') {
|
|
196
|
+
return { ok: false, detail: `the supplied stream function was called ${seen.length} times with ${typeof options} options` };
|
|
197
|
+
}
|
|
198
|
+
// The third seam. Without it no outcome is ever observed, so a partial summary cannot be told from
|
|
199
|
+
// a complete one and would be persisted.
|
|
200
|
+
if (awaited === 0) {
|
|
201
|
+
return {
|
|
202
|
+
ok: false,
|
|
203
|
+
detail: "compact() never awaited result() on the object our stream function returned, so a response's stopReason cannot be read",
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
if (produced < 2) {
|
|
207
|
+
return {
|
|
208
|
+
ok: false,
|
|
209
|
+
detail: 'compact() did not apply the retry policy passed in its tenth argument, so a host that '
|
|
210
|
+
+ 'reordered its parameters would go undetected and the policy would be misrouted',
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
return {
|
|
214
|
+
ok: true,
|
|
215
|
+
detail: `compact() arity 12; our stream function received {${Object.keys(options).sort().join(', ')}}, `
|
|
216
|
+
+ `its result() was awaited, and the retry policy we passed drove a retry (${produced} attempts)`,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* A retryable failure. The message has to match the host's retryable-error pattern, or the probe's
|
|
222
|
+
* second call never happens and the retry-policy check would pass for the wrong reason.
|
|
223
|
+
*/
|
|
224
|
+
function probeErrorResponse() {
|
|
225
|
+
return {
|
|
226
|
+
role: 'assistant',
|
|
227
|
+
content: [],
|
|
228
|
+
stopReason: 'error',
|
|
229
|
+
errorMessage: 'server error',
|
|
230
|
+
provider: 'pi-compaction-seam-probe',
|
|
231
|
+
model: 'pi-compaction-seam-probe',
|
|
232
|
+
timestamp: Date.now(),
|
|
233
|
+
usage: {
|
|
234
|
+
input: 0, output: 0, cacheRead: 0, cacheWrite: 0,
|
|
235
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
236
|
+
},
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function probeModel(): SummarizerModel {
|
|
241
|
+
return {
|
|
242
|
+
id: 'pi-compaction-seam-probe',
|
|
243
|
+
provider: 'pi-compaction-seam-probe',
|
|
244
|
+
name: 'pi-compaction seam probe',
|
|
245
|
+
api: 'pi-compaction-seam-probe',
|
|
246
|
+
reasoning: false,
|
|
247
|
+
input: ['text'],
|
|
248
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
249
|
+
contextWindow: 8_000,
|
|
250
|
+
maxTokens: 1_000,
|
|
251
|
+
} as unknown as SummarizerModel;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function probePreparation(): SummarizationPreparation {
|
|
255
|
+
return {
|
|
256
|
+
firstKeptEntryId: 'pi-compaction-seam-probe',
|
|
257
|
+
messagesToSummarize: [{ role: 'user', content: 'seam probe', timestamp: Date.now() }],
|
|
258
|
+
turnPrefixMessages: [],
|
|
259
|
+
isSplitTurn: false,
|
|
260
|
+
tokensBefore: 0,
|
|
261
|
+
fileOps: { read: new Set<string>(), written: new Set<string>(), edited: new Set<string>() },
|
|
262
|
+
settings: DEFAULT_COMPACTION_SETTINGS,
|
|
263
|
+
} as SummarizationPreparation;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function probeResponse() {
|
|
267
|
+
return {
|
|
268
|
+
role: 'assistant',
|
|
269
|
+
content: [{ type: 'text', text: 'seam probe' }],
|
|
270
|
+
stopReason: 'stop',
|
|
271
|
+
provider: 'pi-compaction-seam-probe',
|
|
272
|
+
model: 'pi-compaction-seam-probe',
|
|
273
|
+
timestamp: Date.now(),
|
|
274
|
+
usage: {
|
|
275
|
+
input: 0, output: 0, cacheRead: 0, cacheWrite: 0,
|
|
276
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
277
|
+
},
|
|
278
|
+
};
|
|
279
|
+
}
|