@qusic/pi-session-trim 0.0.0 → 0.20261103.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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Bang Lee
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,45 +1,11 @@
1
- # @qusic/pi-session-trim
1
+ # Pi extensions
2
2
 
3
- ## ⚠️ IMPORTANT NOTICE ⚠️
3
+ Small [Pi](https://github.com/earendil-works/pi) extensions for personal use, published separately for easy installation across machines.
4
4
 
5
- **This package is created solely for the purpose of setting up OIDC (OpenID Connect) trusted publishing with npm.**
6
-
7
- This is **NOT** a functional package and contains **NO** code or functionality beyond the OIDC setup configuration.
8
-
9
- ## Purpose
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) | [![npm](https://img.shields.io/npm/v/%40qusic%2Fpi-compact-filter)](https://www.npmjs.com/package/@qusic/pi-compact-filter) | `pi install npm:@qusic/pi-compact-filter` | Keep Pi compaction file lists scoped to the current summary window. |
8
+ | [copilot-sync](./copilot-sync.ts) | [![npm](https://img.shields.io/npm/v/%40qusic%2Fpi-copilot-sync)](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) | [![npm](https://img.shields.io/npm/v/%40qusic%2Fpi-copilot2api)](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
+ | [dynamic-mcp](./dynamic-mcp.ts) | [![npm](https://img.shields.io/npm/v/%40qusic%2Fpi-dynamic-mcp)](https://www.npmjs.com/package/@qusic/pi-dynamic-mcp) | `pi install npm:@qusic/pi-dynamic-mcp` | Dynamically manage MCP servers for the current Pi session. |
11
+ | [session-trim](./session-trim.ts) | [![npm](https://img.shields.io/npm/v/%40qusic%2Fpi-session-trim)](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,40 @@
1
1
  {
2
- "name": "@qusic/pi-session-trim",
3
- "version": "0.0.0",
4
- "description": "OIDC trusted publishing setup package for @qusic/pi-session-trim",
5
- "keywords": [
6
- "oidc",
7
- "trusted-publishing",
8
- "setup"
9
- ]
2
+ "name": "@qusic/pi-session-trim",
3
+ "version": "0.20261103.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
+ "typebox": "*"
24
+ },
25
+ "description": "Speed up loading large Pi sessions by pruning superseded history.",
26
+ "keywords": [
27
+ "pi-package",
28
+ "pi-extension",
29
+ "pi",
30
+ "pi-coding-agent",
31
+ "session",
32
+ "session-management",
33
+ "session-file",
34
+ "session-loading",
35
+ "compaction",
36
+ "performance",
37
+ "history-pruning",
38
+ "cleanup"
39
+ ]
10
40
  }
@@ -0,0 +1,457 @@
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 whole session file when it loads a session, including history
6
+ * that compaction superseded and branches that were left behind, so long sessions open
7
+ * more and more slowly. /session-trim rewrites the file without that history.
8
+ *
9
+ * A session file is JSONL: a header line, then one entry per line. Entries form a tree
10
+ * through `id` and `parentId`. The last entry in the file is the current leaf, and its path
11
+ * to the root is the active branch; branches left behind by /tree stay in the file. A
12
+ * compaction entry summarizes the entries before it: the model context is the newest
13
+ * compaction, the entries from its `firstKeptEntryId` on, and everything after it. Pi also
14
+ * derives state from entries the context no longer shows, such as the tools, model and
15
+ * thinking level to restore (see "Replayed state").
16
+ *
17
+ * The trim cuts the active branch at the first entry the second-to-last compaction kept
18
+ * (see KEEP_GENERATIONS) and keeps everything from there verbatim. Of the older entries and
19
+ * the other branches, only the state pi still reads is carried over; then all survivors are
20
+ * re-linked into one chain. The file is rewritten only if everything pi derives from it
21
+ * stays the same, and the session is re-opened afterwards.
22
+ */
23
+
24
+ import { getCurrentSystemMessage, getCurrentTools, type SystemMessage } from "@earendil-works/pi-ai";
25
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
26
+ import { closeSync, createReadStream, openSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
27
+ import { createInterface } from "node:readline";
28
+
29
+ const SESSION_VERSION = 3;
30
+ // Compaction generations whose history stays in full. 1 would leave only the newest summary
31
+ // and the entries it kept, so trimming right after a compaction would erase the visible history.
32
+ const KEEP_GENERATIONS = 2;
33
+
34
+ // One parsed line of the session file.
35
+ type Entry = Record<string, any>;
36
+
37
+ // Replayed state
38
+ //
39
+ // What pi derives from entries outside the context. Each kind is declared by how a later
40
+ // entry combines with an earlier one, which decides how it is carried across the cut: the
41
+ // latest entry per key wins (LATEST), or every entry adds to the state (FOLDS). Paths below
42
+ // are in github.com/earendil-works/pi/packages/coding-agent/src.
43
+
44
+ // Branch state is read along the active branch, file state across every entry in the file.
45
+ type Scope = "branch" | "file";
46
+
47
+ const isAssistant = (entry: Entry) => entry.type === "message" && entry.message?.role === "assistant";
48
+
49
+ // The latest entry per key wins, so the winning entries are carried over unchanged.
50
+ const LATEST: Record<Scope, ((entry: Entry) => string | undefined)[]> = {
51
+ branch: [
52
+ // The thinking level and model that buildSessionContext() reports:
53
+ // getSessionContextSettings() in core/session-manager.ts. core/sdk.ts also checks that
54
+ // the branch has a thinking level entry at all before restoring it.
55
+ (entry) => (entry.type === "thinking_level_change" ? "thinking" : undefined),
56
+ (entry) => (entry.type === "model_change" || isAssistant(entry) ? "model" : undefined),
57
+ // The model pi selects on resume: getBranchSelection() in core/virtual-models.ts. A
58
+ // virtual model is a router an extension registers, and responses name the physical
59
+ // model it picked, so the latest physical response decides, unless the model_change
60
+ // before it selected a virtual model.
61
+ (entry) => (entry.type === "model_change" ? "model_change" : undefined),
62
+ (entry) => (isAssistant(entry) && entry.message.api !== "pi-virtual" ? "response" : undefined),
63
+ // The state each virtual model's router keeps: getVirtualModelState() in
64
+ // core/virtual-models.ts.
65
+ (entry) =>
66
+ entry.type === "custom" && entry.customType === "pi.virtual-model-state"
67
+ ? `virtual:${entry.data?.provider}/${entry.data?.modelId}`
68
+ : undefined,
69
+ ],
70
+ // The session name and the label (bookmark) of each entry: getSessionName() and getLabel()
71
+ // in core/session-manager.ts.
72
+ file: [
73
+ (entry) => (entry.type === "session_info" ? "name" : undefined),
74
+ (entry) => (entry.type === "label" ? `label:${entry.targetId}` : undefined),
75
+ ],
76
+ };
77
+
78
+ // Every entry adds to the state, applied in order from the root, so those before the cut are
79
+ // merged into the first of them. `read` mirrors what pi derives from the values, and `merge`
80
+ // returns one value that reads the same as all of them. Both kinds are branch state.
81
+ interface Fold<T> {
82
+ field: "message" | "data";
83
+ match: (entry: Entry) => boolean;
84
+ read: (values: readonly T[]) => string;
85
+ merge: (values: readonly T[]) => T;
86
+ }
87
+
88
+ // System messages: the session records the system prompt and the tools offered to the model
89
+ // as message entries with role "system". The first holds the whole prompt and tool list;
90
+ // later ones are patches that replace prompt sections or list toolsAdded/toolsRemoved. The
91
+ // model context replays only those after the newest compaction, whose entry stores a
92
+ // snapshot of the rest (`systemMessage`). Restoring the tools on resume and /tree replays
93
+ // every one on the branch instead: _restoreToolsFromTranscript() in core/agent-session.ts
94
+ // activates getCurrentTools() of them all, plus any extension tool they never mention, as
95
+ // newly installed. So losing one changes the tools.
96
+ //
97
+ // getCurrentSystemMessage() merges them the way pi builds that snapshot, but keeps only the
98
+ // current tools, so the removed ones are listed as toolsRemoved to stay mentioned.
99
+ const SYSTEM_MESSAGES: Fold<SystemMessage> = {
100
+ field: "message",
101
+ match: (entry) => entry.type === "message" && entry.message?.role === "system",
102
+ // A branch without system messages keeps the current tools.
103
+ read: (messages) =>
104
+ messages.length === 0 ? "" : JSON.stringify([getCurrentTools(messages), [...mentionedTools(messages)].sort()]),
105
+ merge: (messages) => {
106
+ const folded: SystemMessage = getCurrentSystemMessage(messages) ?? { role: "system", content: "", timestamp: 0 };
107
+ const current = new Set(folded.toolsAdded?.map((tool) => tool.name));
108
+ const removed = [...mentionedTools(messages)].filter((name) => !current.has(name)).map((name) => ({ name }));
109
+ return removed.length > 0 ? { ...folded, toolsRemoved: removed } : folded;
110
+ },
111
+ };
112
+
113
+ function mentionedTools(messages: readonly SystemMessage[]): Set<string> {
114
+ const tools = messages.flatMap((message) => [...(message.toolsAdded ?? []), ...(message.toolsRemoved ?? [])]);
115
+ return new Set(tools.map((tool) => tool.name));
116
+ }
117
+
118
+ // codemode store: scripts keep values with store(), and each run that changes some appends a
119
+ // "codemode-store" custom entry with the keys it set and deleted. readCodemodeStore() in
120
+ // extensions/codemode/execute.ts applies them from the root, deletes before writes; it is not
121
+ // exported, so this mirrors it.
122
+ interface StoreWrite {
123
+ set: Record<string, unknown>;
124
+ delete: string[];
125
+ }
126
+
127
+ const CODEMODE_STORE: Fold<StoreWrite> = {
128
+ field: "data",
129
+ match: (entry) => entry.type === "custom" && entry.customType === "codemode-store" && isStoreWrite(entry.data),
130
+ read: (writes) => JSON.stringify(replayStore(writes)),
131
+ merge: (writes) => ({ set: replayStore(writes), delete: [] }),
132
+ };
133
+
134
+ function isStoreWrite(data: any): data is StoreWrite {
135
+ return (
136
+ typeof data?.set === "object" &&
137
+ data.set !== null &&
138
+ Array.isArray(data.delete) &&
139
+ data.delete.every((key: unknown) => typeof key === "string")
140
+ );
141
+ }
142
+
143
+ function replayStore(writes: readonly StoreWrite[]): Record<string, unknown> {
144
+ const store = new Map<string, unknown>();
145
+ for (const write of writes) {
146
+ for (const key of write.delete) store.delete(key);
147
+ for (const [key, value] of Object.entries(write.set)) store.set(key, value);
148
+ }
149
+ return Object.fromEntries(store);
150
+ }
151
+
152
+ const FOLDS: Fold<any>[] = [SYSTEM_MESSAGES, CODEMODE_STORE];
153
+
154
+ // Reading
155
+
156
+ // One session entry, reduced to what the trim needs. Bodies stay on disk, except the values
157
+ // a fold merges.
158
+ interface Meta {
159
+ index: number; // position among the entries, which addresses it in the rewrite
160
+ bytes: number;
161
+ id: string;
162
+ parentId: string | null;
163
+ type: string;
164
+ firstKeptEntryId?: string; // compaction
165
+ latest: Record<Scope, string[]>; // the LATEST keys it writes
166
+ fold?: Fold<any>; // the FOLDS kind it adds `value` to
167
+ value?: unknown;
168
+ }
169
+
170
+ // The header gets index -1, so readSession() and writeTrimmed() number entries alike.
171
+ async function* sessionLines(file: string): AsyncGenerator<[line: string, entry: Entry, index: number]> {
172
+ const input = createReadStream(file, { encoding: "utf8" });
173
+ let index = -1;
174
+ try {
175
+ for await (const line of createInterface({ input, crlfDelay: Number.POSITIVE_INFINITY })) {
176
+ if (!line.trim()) continue;
177
+ try {
178
+ const entry = JSON.parse(line);
179
+ yield [line, entry, index++];
180
+ } catch {
181
+ // pi skips malformed lines on load; stay consistent with it.
182
+ }
183
+ }
184
+ } finally {
185
+ input.destroy();
186
+ }
187
+ }
188
+
189
+ async function readSession(file: string): Promise<{ header: string; metas: Meta[] } | undefined> {
190
+ let header: string | undefined;
191
+ const metas: Meta[] = [];
192
+ for await (const [line, entry, index] of sessionLines(file)) {
193
+ if (index < 0) {
194
+ if (entry.type !== "session" || entry.version !== SESSION_VERSION) return undefined;
195
+ header = line;
196
+ continue;
197
+ }
198
+ if (typeof entry.id !== "string") return undefined;
199
+ const fold = FOLDS.find((candidate) => candidate.match(entry));
200
+ const keys = (scope: Scope) => LATEST[scope].flatMap((keyOf) => keyOf(entry) ?? []);
201
+ metas.push({
202
+ index,
203
+ bytes: Buffer.byteLength(line) + 1,
204
+ id: entry.id,
205
+ parentId: entry.parentId ?? null,
206
+ type: entry.type,
207
+ firstKeptEntryId: entry.firstKeptEntryId,
208
+ latest: { branch: keys("branch"), file: keys("file") },
209
+ fold,
210
+ value: fold && entry[fold.field],
211
+ });
212
+ }
213
+ return header === undefined ? undefined : { header, metas };
214
+ }
215
+
216
+ // Projections: what pi derives from the session, mirroring core/session-manager.ts and the
217
+ // replayed state declared above.
218
+
219
+ const isCompaction = (meta: Meta) => meta.type === "compaction";
220
+
221
+ // The entry types that shape the context: sessionEntryToContextMessages() turns these into
222
+ // messages, and buildSessionProjection() applies each context_edit to the entry it targets.
223
+ // The others (session_info, model_change, thinking_level_change, label, custom) are state or
224
+ // display entries, so moving or dropping them cannot change the context.
225
+ const PROJECTED_TYPES = new Set(["message", "custom_message", "branch_summary", "compaction", "context_edit"]);
226
+
227
+ // getBranch: pi restores the leaf as the last entry in file order.
228
+ function branchOf(metas: readonly Meta[]): Meta[] | undefined {
229
+ const byId = new Map(metas.map((meta) => [meta.id, meta]));
230
+ const seen = new Set<string>();
231
+ const path: Meta[] = [];
232
+ // A dangling parentId ends the branch, exactly as pi's own walk does; a cycle would
233
+ // hang that walk, so treat it as a corrupt file instead of trimming it.
234
+ let current: Meta | undefined = metas[metas.length - 1];
235
+ while (current) {
236
+ if (seen.has(current.id)) return undefined;
237
+ seen.add(current.id);
238
+ path.push(current);
239
+ current = current.parentId ? byId.get(current.parentId) : undefined;
240
+ }
241
+ return path.reverse();
242
+ }
243
+
244
+ // buildContextEntries: the newest compaction, then its kept entries, then the rest. Kept
245
+ // system messages are left out, since the compaction's snapshot covers them.
246
+ function contextOf(path: readonly Meta[]): string {
247
+ const visible: Meta[] = [];
248
+ const compaction = path.filter(isCompaction).at(-1);
249
+ if (!compaction) visible.push(...path);
250
+ else {
251
+ const index = path.indexOf(compaction);
252
+ visible.push(compaction);
253
+ let kept = false;
254
+ for (let i = 0; i < index; i++) {
255
+ if (path[i].id === compaction.firstKeptEntryId) kept = true;
256
+ if (kept && path[i].fold !== SYSTEM_MESSAGES) visible.push(path[i]);
257
+ }
258
+ visible.push(...path.slice(index + 1));
259
+ }
260
+ return visible
261
+ .filter((meta) => PROJECTED_TYPES.has(meta.type))
262
+ .map((meta) => meta.id)
263
+ .join(",");
264
+ }
265
+
266
+ function winners(metas: readonly Meta[], scope: Scope): Map<string, Meta> {
267
+ const result = new Map<string, Meta>();
268
+ for (const meta of metas) for (const key of meta.latest[scope]) result.set(key, meta);
269
+ return result;
270
+ }
271
+
272
+ function latestOf(metas: readonly Meta[], scope: Scope): string {
273
+ return JSON.stringify([...winners(metas, scope)].map(([key, meta]) => `${key}=${meta.id}`).sort());
274
+ }
275
+
276
+ function foldsOf(path: readonly Meta[]): string {
277
+ const values = (fold: Fold<any>) => path.filter((meta) => meta.fold === fold).map((meta) => meta.value);
278
+ return JSON.stringify(FOLDS.map((fold) => fold.read(values(fold))));
279
+ }
280
+
281
+ // Everything pi derives from a session, given its active branch and all of its entries.
282
+ function derived(branch: readonly Meta[], file: readonly Meta[]): string {
283
+ return JSON.stringify([contextOf(branch), foldsOf(branch), latestOf(branch, "branch"), latestOf(file, "file")]);
284
+ }
285
+
286
+ // Planning
287
+
288
+ interface TrimPlan {
289
+ chain: Meta[]; // survivors in file order: carried branch state, then the kept range
290
+ tail: Meta[]; // carried file state, written last so it stays the latest write
291
+ folded: Meta[]; // per fold, its first entry before the cut, rewritten to hold all of them
292
+ total: number; // entries the file held when the plan was made
293
+ remaining: number; // entries the rewritten file will hold
294
+ bytes: number; // estimated size of those entries
295
+ }
296
+
297
+ // The first of a fold's entries, rewritten to hold the value of all of them.
298
+ function foldInto(fold: Fold<any>, members: readonly Meta[]): Meta {
299
+ const [first] = members;
300
+ const value = fold.merge(members.map((meta) => meta.value));
301
+ const delta = Buffer.byteLength(JSON.stringify(value)) - Buffer.byteLength(JSON.stringify(first.value));
302
+ return { ...first, value, bytes: first.bytes + delta };
303
+ }
304
+
305
+ // A plan, or why there is none.
306
+ function planTrim(metas: readonly Meta[]): TrimPlan | string {
307
+ const path = branchOf(metas);
308
+ if (!path) return "its entries form a cycle";
309
+ const compactions = path.filter(isCompaction);
310
+ if (compactions.length < KEEP_GENERATIONS) return `fewer than ${KEEP_GENERATIONS} compactions on this branch`;
311
+ const cut = compactions[compactions.length - KEEP_GENERATIONS];
312
+ const cutIndex = path.findIndex((meta) => meta.id === cut.firstKeptEntryId);
313
+ if (cutIndex < 0) return "a compaction refers to an entry missing from this branch";
314
+
315
+ // Carried over: the winners outside the kept range, branch state in front of the cut in its
316
+ // original order and file state after everything, so each stays the latest write; and each
317
+ // fold's entries before the cut, merged into one. The context sees none of them.
318
+ const kept = path.slice(cutIndex);
319
+ const inKept = new Set(kept.map((meta) => meta.id));
320
+ const outside = (meta: Meta) => !inKept.has(meta.id);
321
+ const byIndex = (a: Meta, b: Meta) => a.index - b.index;
322
+ const older = path.slice(0, cutIndex);
323
+ const folded = FOLDS.flatMap((fold) => {
324
+ const members = older.filter((meta) => meta.fold === fold);
325
+ return members.length > 0 ? [foldInto(fold, members)] : [];
326
+ });
327
+ // One entry can win several keys.
328
+ const head = [...new Set(winners(path, "branch").values())].filter(outside).concat(folded).sort(byIndex);
329
+ const tail = [...new Set(winners(metas, "file").values())].filter(outside).sort(byIndex);
330
+
331
+ const chain = [...head, ...kept];
332
+ const trimmed = [...chain, ...tail];
333
+ // The rewritten file is one chain, so all of it is its active branch.
334
+ if (derived(trimmed, trimmed) !== derived(path, metas)) return "trimming would change what pi restores from it";
335
+ return {
336
+ chain,
337
+ tail,
338
+ folded,
339
+ total: metas.length,
340
+ remaining: trimmed.length,
341
+ bytes: trimmed.reduce((total, meta) => total + meta.bytes, 0),
342
+ };
343
+ }
344
+
345
+ // Writing
346
+
347
+ // Write the survivors as one chain, re-linking parentIds. Ids never change, so entries pi
348
+ // appends later still link up.
349
+ async function writeTrimmed(file: string, header: string, plan: TrimPlan): Promise<void> {
350
+ const keep = new Set(plan.chain.map((meta) => meta.index));
351
+ const rewrites = new Map(plan.folded.map((meta) => [meta.index, meta]));
352
+ const holdBack = new Set(plan.tail.map((meta) => meta.index));
353
+ const held = new Map<number, Entry>();
354
+ const temp = `${file}.trim`;
355
+ try {
356
+ const fd = openSync(temp, "w");
357
+ let seen = 0;
358
+ let parentId: string | null = null;
359
+ const emit = (entry: Entry) => {
360
+ entry.parentId = parentId;
361
+ parentId = entry.id;
362
+ writeFileSync(fd, `${JSON.stringify(entry)}\n`);
363
+ };
364
+ try {
365
+ writeFileSync(fd, `${header}\n`);
366
+ for await (const [, entry, index] of sessionLines(file)) {
367
+ if (index < 0) continue;
368
+ seen++;
369
+ const rewrite = rewrites.get(index);
370
+ if (rewrite?.fold) emit({ ...entry, [rewrite.fold.field]: rewrite.value });
371
+ else if (keep.has(index)) emit(entry);
372
+ else if (holdBack.has(index)) held.set(index, entry);
373
+ }
374
+ for (const meta of plan.tail) emit(held.get(meta.index)!);
375
+ } finally {
376
+ closeSync(fd);
377
+ }
378
+ // The plan addresses entries by line, so anything appended since it was made would be
379
+ // dropped. Publish nothing and let the user re-run instead.
380
+ if (seen !== plan.total) throw new Error("the session grew while trimming, so nothing was rewritten");
381
+ renameSync(temp, file);
382
+ } catch (error) {
383
+ rmSync(temp, { force: true });
384
+ throw error;
385
+ }
386
+ }
387
+
388
+ // Command
389
+
390
+ interface Analysis {
391
+ header: string;
392
+ plan: TrimPlan;
393
+ before: number; // current file size
394
+ after: number; // estimated size once rewritten
395
+ entries: number; // current entry count
396
+ }
397
+
398
+ // An analysis, or why there is nothing to trim.
399
+ async function analyze(file: string): Promise<Analysis | string> {
400
+ const session = await readSession(file);
401
+ if (!session) return "unsupported session format";
402
+ const plan = planTrim(session.metas);
403
+ if (typeof plan === "string") return plan;
404
+ if (plan.remaining === session.metas.length) {
405
+ return `nothing older than the last ${KEEP_GENERATIONS} compactions, and no abandoned branches`;
406
+ }
407
+ return {
408
+ header: session.header,
409
+ plan,
410
+ before: statSync(file).size,
411
+ after: plan.bytes + Buffer.byteLength(session.header) + 1,
412
+ entries: session.metas.length,
413
+ };
414
+ }
415
+
416
+ const formatSize = (bytes: number) =>
417
+ bytes < 1024 * 1024 ? `${(bytes / 1024).toFixed(0)} KB` : `${(bytes / 1024 / 1024).toFixed(1)} MB`;
418
+
419
+ export default function (pi: ExtensionAPI): void {
420
+ pi.registerCommand("session-trim", {
421
+ description: "Drop superseded pre-compaction history from this session",
422
+ handler: async (_args, ctx) => {
423
+ const file = ctx.sessionManager.getSessionFile();
424
+ if (!file) {
425
+ ctx.ui.notify("This session is not persisted, so there is nothing to trim.", "error");
426
+ return;
427
+ }
428
+ try {
429
+ // Never rewrite the file underneath a running turn.
430
+ await ctx.waitForIdle();
431
+ const analysis = await analyze(file);
432
+ if (typeof analysis === "string") {
433
+ ctx.ui.notify(`Session not trimmed: ${analysis}.`, "info");
434
+ return;
435
+ }
436
+
437
+ const { header, plan, before, after, entries } = analysis;
438
+ const reclaimed = formatSize(before - after);
439
+ // The report doubles as the prompt: re-opening the session clears the transcript,
440
+ // and the interactive "Resumed session" status would overwrite anything shown after.
441
+ const report = `${formatSize(before)} → ${formatSize(after)}, ${entries} → ${plan.remaining} entries; history older than the last ${KEEP_GENERATIONS} compactions and all abandoned branches are dropped.`;
442
+ if (!ctx.hasUI) {
443
+ ctx.ui.notify(`Session trim would reclaim ${reclaimed}: ${report}`, "info");
444
+ return;
445
+ }
446
+ if (!(await ctx.ui.confirm("Trim session", `Reclaim ${reclaimed}?\n${report}`))) return;
447
+
448
+ await writeTrimmed(file, header, plan);
449
+ // pi still holds the untrimmed entries in memory, and a later fork would write them
450
+ // back, so re-open the file. switchSession() reloads it from disk.
451
+ await ctx.switchSession(file);
452
+ } catch (error) {
453
+ ctx.ui.notify(`Session trim failed: ${(error as Error).message}`, "error");
454
+ }
455
+ },
456
+ });
457
+ }