@qusic/pi-session-trim 0.0.0 → 0.20260915.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/README.md +8 -43
- package/package.json +37 -8
- package/session-trim.ts +346 -0
package/README.md
CHANGED
|
@@ -1,45 +1,10 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Pi extensions
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Small [Pi](https://github.com/earendil-works/pi) extensions for personal use, published separately for easy installation across machines.
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
This package exists to:
|
|
12
|
-
1. Configure OIDC trusted publishing for the package name `@qusic/pi-session-trim`
|
|
13
|
-
2. Enable secure, token-less publishing from CI/CD workflows
|
|
14
|
-
3. Establish provenance for packages published under this name
|
|
15
|
-
|
|
16
|
-
## What is OIDC Trusted Publishing?
|
|
17
|
-
|
|
18
|
-
OIDC trusted publishing allows package maintainers to publish packages directly from their CI/CD workflows without needing to manage npm access tokens. Instead, it uses OpenID Connect to establish trust between the CI/CD provider (like GitHub Actions) and npm.
|
|
19
|
-
|
|
20
|
-
## Setup Instructions
|
|
21
|
-
|
|
22
|
-
To properly configure OIDC trusted publishing for this package:
|
|
23
|
-
|
|
24
|
-
1. Go to [npmjs.com](https://www.npmjs.com/) and navigate to your package settings
|
|
25
|
-
2. Configure the trusted publisher (e.g., GitHub Actions)
|
|
26
|
-
3. Specify the repository and workflow that should be allowed to publish
|
|
27
|
-
4. Use the configured workflow to publish your actual package
|
|
28
|
-
|
|
29
|
-
## DO NOT USE THIS PACKAGE
|
|
30
|
-
|
|
31
|
-
This package is a placeholder for OIDC configuration only. It:
|
|
32
|
-
- Contains no executable code
|
|
33
|
-
- Provides no functionality
|
|
34
|
-
- Should not be installed as a dependency
|
|
35
|
-
- Exists only for administrative purposes
|
|
36
|
-
|
|
37
|
-
## More Information
|
|
38
|
-
|
|
39
|
-
For more details about npm's trusted publishing feature, see:
|
|
40
|
-
- [npm Trusted Publishing Documentation](https://docs.npmjs.com/generating-provenance-statements)
|
|
41
|
-
- [GitHub Actions OIDC Documentation](https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect)
|
|
42
|
-
|
|
43
|
-
---
|
|
44
|
-
|
|
45
|
-
**Maintained for OIDC setup purposes only**
|
|
5
|
+
| Extension | npm | Install | Description |
|
|
6
|
+
| --- | --- | --- | --- |
|
|
7
|
+
| [compact-filter](./compact-filter.ts) | [](https://www.npmjs.com/package/@qusic/pi-compact-filter) | `pi install npm:@qusic/pi-compact-filter` | Prune stale tracked paths from Pi's compaction context. |
|
|
8
|
+
| [copilot-sync](./copilot-sync.ts) | [](https://www.npmjs.com/package/@qusic/pi-copilot-sync) | `pi install npm:@qusic/pi-copilot-sync` | Sync live GitHub Copilot model capabilities into Pi. |
|
|
9
|
+
| [copilot2api](./copilot2api.ts) | [](https://www.npmjs.com/package/@qusic/pi-copilot2api) | `pi install npm:@qusic/pi-copilot2api` | Add copilot2api as a native Pi provider with live model discovery. |
|
|
10
|
+
| [session-trim](./session-trim.ts) | [](https://www.npmjs.com/package/@qusic/pi-session-trim) | `pi install npm:@qusic/pi-session-trim` | Speed up loading large Pi sessions by pruning superseded history. |
|
package/package.json
CHANGED
|
@@ -1,10 +1,39 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
2
|
+
"name": "@qusic/pi-session-trim",
|
|
3
|
+
"version": "0.20260915.0",
|
|
4
|
+
"homepage": "https://github.com/Qusic/pi-extensions#readme",
|
|
5
|
+
"bugs": {
|
|
6
|
+
"url": "https://github.com/Qusic/pi-extensions/issues"
|
|
7
|
+
},
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/Qusic/pi-extensions.git"
|
|
11
|
+
},
|
|
12
|
+
"license": "MIT",
|
|
13
|
+
"type": "module",
|
|
14
|
+
"pi": {
|
|
15
|
+
"extensions": [
|
|
16
|
+
"./*.ts"
|
|
17
|
+
]
|
|
18
|
+
},
|
|
19
|
+
"peerDependencies": {
|
|
20
|
+
"@earendil-works/pi-ai": "*",
|
|
21
|
+
"@earendil-works/pi-agent-core": "*",
|
|
22
|
+
"@earendil-works/pi-coding-agent": "*"
|
|
23
|
+
},
|
|
24
|
+
"description": "Speed up loading large Pi sessions by pruning superseded history.",
|
|
25
|
+
"keywords": [
|
|
26
|
+
"pi-package",
|
|
27
|
+
"pi-extension",
|
|
28
|
+
"pi",
|
|
29
|
+
"pi-coding-agent",
|
|
30
|
+
"session",
|
|
31
|
+
"session-management",
|
|
32
|
+
"session-file",
|
|
33
|
+
"session-loading",
|
|
34
|
+
"compaction",
|
|
35
|
+
"performance",
|
|
36
|
+
"history-pruning",
|
|
37
|
+
"cleanup"
|
|
38
|
+
]
|
|
10
39
|
}
|
package/session-trim.ts
ADDED
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
// npm-description: Speed up loading large Pi sessions by pruning superseded history.
|
|
2
|
+
// npm-keywords: session, session-management, session-file, session-loading, compaction, performance, history-pruning, cleanup
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Pi reads and scans the entire JSONL file when loading a session, including history
|
|
6
|
+
* superseded by compaction and abandoned branches that are no longer replayed. Large,
|
|
7
|
+
* long-running sessions can therefore become progressively slower to open.
|
|
8
|
+
*
|
|
9
|
+
* /session-trim drops entries older than the Nth-from-last compaction on the active
|
|
10
|
+
* branch and every abandoned branch, then re-opens the session so Pi's in-memory entries
|
|
11
|
+
* match the file. The trim is refused unless the projected context, resolved model and
|
|
12
|
+
* thinking level, session name, and surviving labels are provably unchanged.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
16
|
+
import { closeSync, createReadStream, openSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
17
|
+
import { createInterface } from "node:readline";
|
|
18
|
+
|
|
19
|
+
// Schema
|
|
20
|
+
|
|
21
|
+
const SESSION_VERSION = 3;
|
|
22
|
+
// Generations of compaction to keep. 1 would leave only the summary and the current
|
|
23
|
+
// tail, so trimming right after a compaction would erase the visible history.
|
|
24
|
+
const KEEP_GENERATIONS = 2;
|
|
25
|
+
|
|
26
|
+
// One session entry, reduced to what the trim needs. Bodies stay on disk.
|
|
27
|
+
interface Meta {
|
|
28
|
+
index: number;
|
|
29
|
+
bytes: number;
|
|
30
|
+
id: string;
|
|
31
|
+
parentId: string | null;
|
|
32
|
+
type: string;
|
|
33
|
+
firstKeptEntryId?: string; // compaction
|
|
34
|
+
targetId?: string; // label
|
|
35
|
+
label?: string; // label
|
|
36
|
+
name?: string; // session_info
|
|
37
|
+
thinking?: string; // thinking_level_change
|
|
38
|
+
model?: string; // model_change and assistant messages
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface TrimPlan {
|
|
42
|
+
chain: Meta[]; // survivors in file order: rescued branch state, then the kept range
|
|
43
|
+
tail: Meta[]; // file-scoped state winners, re-emitted last so they still win
|
|
44
|
+
total: number; // entries the file held when the plan was made
|
|
45
|
+
kept: number; // entries the rewritten file will hold
|
|
46
|
+
bytes: number; // estimated size of those entries
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Reading
|
|
50
|
+
|
|
51
|
+
// Entry index is -1 for the header, so both passes number entries identically.
|
|
52
|
+
async function* sessionLines(file: string): AsyncGenerator<[line: string, entry: Record<string, any>, index: number]> {
|
|
53
|
+
const input = createReadStream(file, { encoding: "utf8" });
|
|
54
|
+
let index = -1;
|
|
55
|
+
try {
|
|
56
|
+
for await (const line of createInterface({ input, crlfDelay: Number.POSITIVE_INFINITY })) {
|
|
57
|
+
if (!line.trim()) continue;
|
|
58
|
+
try {
|
|
59
|
+
const entry = JSON.parse(line);
|
|
60
|
+
yield [line, entry, index++];
|
|
61
|
+
} catch {
|
|
62
|
+
// pi skips malformed lines on load; stay consistent with it.
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
} finally {
|
|
66
|
+
input.destroy();
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Read entry metadata without holding message bodies in memory.
|
|
71
|
+
async function readSession(file: string): Promise<{ header: string; metas: Meta[] } | undefined> {
|
|
72
|
+
let header: string | undefined;
|
|
73
|
+
const metas: Meta[] = [];
|
|
74
|
+
for await (const [line, entry, index] of sessionLines(file)) {
|
|
75
|
+
if (index < 0) {
|
|
76
|
+
if (entry.type !== "session" || entry.version !== SESSION_VERSION) return undefined;
|
|
77
|
+
header = line;
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (typeof entry.id !== "string") return undefined;
|
|
81
|
+
metas.push({
|
|
82
|
+
index,
|
|
83
|
+
bytes: Buffer.byteLength(line) + 1,
|
|
84
|
+
id: entry.id,
|
|
85
|
+
parentId: entry.parentId ?? null,
|
|
86
|
+
type: entry.type,
|
|
87
|
+
firstKeptEntryId: entry.firstKeptEntryId,
|
|
88
|
+
targetId: entry.targetId,
|
|
89
|
+
label: entry.label,
|
|
90
|
+
name: entry.type === "session_info" ? entry.name : undefined,
|
|
91
|
+
thinking: entry.type === "thinking_level_change" ? entry.thinkingLevel : undefined,
|
|
92
|
+
model:
|
|
93
|
+
entry.type === "model_change"
|
|
94
|
+
? `${entry.provider}/${entry.modelId}`
|
|
95
|
+
: entry.type === "message" && entry.message?.role === "assistant"
|
|
96
|
+
? `${entry.message.provider}/${entry.message.model}`
|
|
97
|
+
: undefined,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
return header === undefined ? undefined : { header, metas };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Projections, mirroring pi's session-manager
|
|
104
|
+
|
|
105
|
+
const findLast = (metas: readonly Meta[], match: (meta: Meta) => boolean): Meta | undefined => {
|
|
106
|
+
for (let i = metas.length - 1; i >= 0; i--) {
|
|
107
|
+
if (match(metas[i])) return metas[i];
|
|
108
|
+
}
|
|
109
|
+
return undefined;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
// sessionEntryToContextMessages: only these entry types become a message. The others
|
|
113
|
+
// (session_info, model_change, thinking_level_change, label, custom) are state or
|
|
114
|
+
// display entries, so moving or dropping them cannot change what pi replays.
|
|
115
|
+
const PROJECTED_TYPES = new Set(["message", "custom_message", "branch_summary", "compaction"]);
|
|
116
|
+
|
|
117
|
+
// getBranch: pi restores the leaf as the last entry in file order.
|
|
118
|
+
function branchOf(metas: readonly Meta[]): Meta[] | undefined {
|
|
119
|
+
const byId = new Map(metas.map((meta) => [meta.id, meta]));
|
|
120
|
+
const seen = new Set<string>();
|
|
121
|
+
const path: Meta[] = [];
|
|
122
|
+
// A dangling parentId ends the branch, exactly as pi's own walk does; a cycle would
|
|
123
|
+
// hang that walk, so treat it as a corrupt file instead of trimming it.
|
|
124
|
+
let current: Meta | undefined = metas[metas.length - 1];
|
|
125
|
+
while (current) {
|
|
126
|
+
if (seen.has(current.id)) return undefined;
|
|
127
|
+
seen.add(current.id);
|
|
128
|
+
path.push(current);
|
|
129
|
+
current = current.parentId ? byId.get(current.parentId) : undefined;
|
|
130
|
+
}
|
|
131
|
+
return path.reverse();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// buildContextEntries: the newest compaction, then its kept entries, then the rest.
|
|
135
|
+
function contextIds(path: readonly Meta[]): string {
|
|
136
|
+
const visible: Meta[] = [];
|
|
137
|
+
const compaction = findLast(path, (meta) => meta.type === "compaction");
|
|
138
|
+
if (!compaction) visible.push(...path);
|
|
139
|
+
else {
|
|
140
|
+
const index = path.indexOf(compaction);
|
|
141
|
+
visible.push(compaction);
|
|
142
|
+
let kept = false;
|
|
143
|
+
for (let i = 0; i < index; i++) {
|
|
144
|
+
if (path[i].id === compaction.firstKeptEntryId) kept = true;
|
|
145
|
+
if (kept) visible.push(path[i]);
|
|
146
|
+
}
|
|
147
|
+
visible.push(...path.slice(index + 1));
|
|
148
|
+
}
|
|
149
|
+
return visible
|
|
150
|
+
.filter((meta) => PROJECTED_TYPES.has(meta.type))
|
|
151
|
+
.map((meta) => meta.id)
|
|
152
|
+
.join(",");
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// getSessionContextSettings: last writer along the branch wins.
|
|
156
|
+
function settingsOf(path: readonly Meta[]): string {
|
|
157
|
+
let thinking = "off";
|
|
158
|
+
let model = "";
|
|
159
|
+
for (const meta of path) {
|
|
160
|
+
if (meta.thinking !== undefined) thinking = meta.thinking;
|
|
161
|
+
if (meta.model !== undefined) model = meta.model;
|
|
162
|
+
}
|
|
163
|
+
return `${thinking}|${model}`;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// State pi resolves by walking the branch: the last writer wins.
|
|
167
|
+
const BRANCH_STATE = [(meta: Meta) => meta.thinking !== undefined, (meta: Meta) => meta.model !== undefined];
|
|
168
|
+
|
|
169
|
+
// State pi resolves by replaying the whole file, abandoned branches included:
|
|
170
|
+
// getSessionName takes the last session_info, getLabel the last write per target.
|
|
171
|
+
function fileStateWinners(metas: readonly Meta[]): Map<string, Meta> {
|
|
172
|
+
const winners = new Map<string, Meta>();
|
|
173
|
+
for (const meta of metas) {
|
|
174
|
+
if (meta.type === "session_info") winners.set("name", meta);
|
|
175
|
+
else if (meta.type === "label" && meta.targetId) winners.set(`label:${meta.targetId}`, meta);
|
|
176
|
+
}
|
|
177
|
+
return winners;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// The values those winners resolve to: an empty label clears the bookmark, and a label
|
|
181
|
+
// dies with its target, so callers pass the ids that survive the trim.
|
|
182
|
+
function fileStateOf(metas: readonly Meta[], targets: ReadonlySet<string>): string {
|
|
183
|
+
return [...fileStateWinners(metas)]
|
|
184
|
+
.filter(([key, meta]) => key === "name" || (meta.label && targets.has(meta.targetId!)))
|
|
185
|
+
.map(([key, meta]) => `${key}=${meta.name ?? meta.label}`)
|
|
186
|
+
.sort()
|
|
187
|
+
.join(",");
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Planning
|
|
191
|
+
|
|
192
|
+
// The rewrite is only safe if every projection pi derives stays identical.
|
|
193
|
+
function preservesProjections(
|
|
194
|
+
trimmed: readonly Meta[],
|
|
195
|
+
path: readonly Meta[],
|
|
196
|
+
metas: readonly Meta[],
|
|
197
|
+
survivors: ReadonlySet<string>,
|
|
198
|
+
): boolean {
|
|
199
|
+
return (
|
|
200
|
+
contextIds(trimmed) === contextIds(path) &&
|
|
201
|
+
settingsOf(trimmed) === settingsOf(path) &&
|
|
202
|
+
fileStateOf(trimmed, survivors) === fileStateOf(metas, survivors)
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function planTrim(metas: readonly Meta[]): TrimPlan | undefined {
|
|
207
|
+
const path = branchOf(metas);
|
|
208
|
+
if (!path) return undefined;
|
|
209
|
+
const compactions = path.filter((meta) => meta.type === "compaction");
|
|
210
|
+
if (compactions.length < KEEP_GENERATIONS) return undefined;
|
|
211
|
+
const cut = compactions[compactions.length - KEEP_GENERATIONS];
|
|
212
|
+
const cutIndex = path.findIndex((meta) => meta.id === cut.firstKeptEntryId);
|
|
213
|
+
if (cutIndex < 0) return undefined;
|
|
214
|
+
|
|
215
|
+
// Everything from the cut on is kept verbatim. Only state pi replays has to be carried
|
|
216
|
+
// over, and only when the entry that currently wins falls outside that range: branch
|
|
217
|
+
// state goes in front of the cut so it stays on the path, file state goes last so it
|
|
218
|
+
// stays the final write. Neither placement is visible to the compaction's context.
|
|
219
|
+
const kept = path.slice(cutIndex);
|
|
220
|
+
const inKept = new Set(kept.map((meta) => meta.id));
|
|
221
|
+
const outside = (meta: Meta | undefined): meta is Meta => meta !== undefined && !inKept.has(meta.id);
|
|
222
|
+
const byIndex = (a: Meta, b: Meta) => a.index - b.index;
|
|
223
|
+
const head = BRANCH_STATE.map((defines) => findLast(path, defines))
|
|
224
|
+
.filter(outside)
|
|
225
|
+
.sort(byIndex);
|
|
226
|
+
const tail = [...fileStateWinners(metas).values()].filter(outside).sort(byIndex);
|
|
227
|
+
|
|
228
|
+
const chain = [...head, ...kept];
|
|
229
|
+
const trimmed = [...chain, ...tail];
|
|
230
|
+
const survivors = new Set(trimmed.map((meta) => meta.id));
|
|
231
|
+
if (!preservesProjections(trimmed, path, metas, survivors)) return undefined;
|
|
232
|
+
return {
|
|
233
|
+
chain,
|
|
234
|
+
tail,
|
|
235
|
+
total: metas.length,
|
|
236
|
+
kept: trimmed.length,
|
|
237
|
+
bytes: trimmed.reduce((total, meta) => total + meta.bytes, 0),
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Writing
|
|
242
|
+
|
|
243
|
+
// Re-chain survivors: ids never change, so entries appended later still link up.
|
|
244
|
+
async function writeTrimmed(file: string, header: string, plan: TrimPlan): Promise<void> {
|
|
245
|
+
const keep = new Set(plan.chain.map((meta) => meta.index));
|
|
246
|
+
const holdBack = new Set(plan.tail.map((meta) => meta.index));
|
|
247
|
+
const held = new Map<number, Record<string, any>>();
|
|
248
|
+
const temp = `${file}.trim`;
|
|
249
|
+
try {
|
|
250
|
+
const fd = openSync(temp, "w");
|
|
251
|
+
let seen = 0;
|
|
252
|
+
let parentId: string | null = null;
|
|
253
|
+
const emit = (entry: Record<string, any>) => {
|
|
254
|
+
entry.parentId = parentId;
|
|
255
|
+
parentId = entry.id;
|
|
256
|
+
writeFileSync(fd, `${JSON.stringify(entry)}\n`);
|
|
257
|
+
};
|
|
258
|
+
try {
|
|
259
|
+
writeFileSync(fd, `${header}\n`);
|
|
260
|
+
for await (const [, entry, index] of sessionLines(file)) {
|
|
261
|
+
if (index < 0) continue;
|
|
262
|
+
seen++;
|
|
263
|
+
if (keep.has(index)) emit(entry);
|
|
264
|
+
else if (holdBack.has(index)) held.set(index, entry);
|
|
265
|
+
}
|
|
266
|
+
for (const meta of plan.tail) emit(held.get(meta.index)!);
|
|
267
|
+
} finally {
|
|
268
|
+
closeSync(fd);
|
|
269
|
+
}
|
|
270
|
+
// The plan addresses entries by line, so anything appended since it was made would be
|
|
271
|
+
// dropped. Publish nothing and let the user re-run instead.
|
|
272
|
+
if (seen !== plan.total) throw new Error("the session grew while trimming, so nothing was rewritten");
|
|
273
|
+
renameSync(temp, file);
|
|
274
|
+
} catch (error) {
|
|
275
|
+
rmSync(temp, { force: true });
|
|
276
|
+
throw error;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Command
|
|
281
|
+
|
|
282
|
+
interface Analysis {
|
|
283
|
+
header: string;
|
|
284
|
+
plan: TrimPlan;
|
|
285
|
+
before: number; // current file size
|
|
286
|
+
after: number; // estimated size once rewritten
|
|
287
|
+
entries: number; // current entry count
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async function analyze(file: string): Promise<Analysis | undefined> {
|
|
291
|
+
const session = await readSession(file);
|
|
292
|
+
if (!session) return undefined;
|
|
293
|
+
const plan = planTrim(session.metas);
|
|
294
|
+
// An already-trimmed session still plans cleanly; it just drops nothing.
|
|
295
|
+
if (!plan || plan.kept === session.metas.length) return undefined;
|
|
296
|
+
return {
|
|
297
|
+
header: session.header,
|
|
298
|
+
plan,
|
|
299
|
+
before: statSync(file).size,
|
|
300
|
+
after: plan.bytes + Buffer.byteLength(session.header) + 1,
|
|
301
|
+
entries: session.metas.length,
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const size = (bytes: number) =>
|
|
306
|
+
bytes < 1024 * 1024 ? `${(bytes / 1024).toFixed(0)} KB` : `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
|
307
|
+
|
|
308
|
+
export default function (pi: ExtensionAPI): void {
|
|
309
|
+
pi.registerCommand("session-trim", {
|
|
310
|
+
description: "Drop superseded pre-compaction history from this session",
|
|
311
|
+
handler: async (_args, ctx) => {
|
|
312
|
+
const file = ctx.sessionManager.getSessionFile();
|
|
313
|
+
if (!file) {
|
|
314
|
+
ctx.ui.notify("This session is not persisted, so there is nothing to trim.", "error");
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
try {
|
|
318
|
+
// Never rewrite the file underneath a running turn.
|
|
319
|
+
await ctx.waitForIdle();
|
|
320
|
+
const analysis = await analyze(file);
|
|
321
|
+
if (!analysis) {
|
|
322
|
+
ctx.ui.notify("Nothing to trim: fewer than two compactions, or an unsupported session layout.", "info");
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const { header, plan, before, after, entries } = analysis;
|
|
327
|
+
const reclaimed = size(before - after);
|
|
328
|
+
// The report doubles as the prompt: re-opening the session clears the transcript,
|
|
329
|
+
// and the interactive "Resumed session" status would overwrite anything shown after.
|
|
330
|
+
const report = `${size(before)} → ${size(after)}, ${entries} → ${plan.kept} entries; history older than the last ${KEEP_GENERATIONS} compactions and all abandoned branches are dropped.`;
|
|
331
|
+
if (!ctx.hasUI) {
|
|
332
|
+
ctx.ui.notify(`Session trim would reclaim ${reclaimed}: ${report}`, "info");
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
if (!(await ctx.ui.confirm("Trim session", `Reclaim ${reclaimed}?\n${report}`))) return;
|
|
336
|
+
|
|
337
|
+
await writeTrimmed(file, header, plan);
|
|
338
|
+
// pi still holds the untrimmed entries in memory, and a later fork would write them
|
|
339
|
+
// back, so re-open the file. switchSession() reloads it from disk.
|
|
340
|
+
await ctx.switchSession(file);
|
|
341
|
+
} catch (error) {
|
|
342
|
+
ctx.ui.notify(`Session trim failed: ${(error as Error).message}`, "error");
|
|
343
|
+
}
|
|
344
|
+
},
|
|
345
|
+
});
|
|
346
|
+
}
|