@qusic/pi-session-trim 0.20260915.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
@@ -4,7 +4,8 @@ Small [Pi](https://github.com/earendil-works/pi) extensions for personal use, pu
4
4
 
5
5
  | Extension | npm | Install | Description |
6
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` | Prune stale tracked paths from Pi's compaction context. |
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
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
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. |
10
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,6 +1,6 @@
1
1
  {
2
2
  "name": "@qusic/pi-session-trim",
3
- "version": "0.20260915.0",
3
+ "version": "0.20261103.0",
4
4
  "homepage": "https://github.com/Qusic/pi-extensions#readme",
5
5
  "bugs": {
6
6
  "url": "https://github.com/Qusic/pi-extensions/issues"
@@ -19,7 +19,8 @@
19
19
  "peerDependencies": {
20
20
  "@earendil-works/pi-ai": "*",
21
21
  "@earendil-works/pi-agent-core": "*",
22
- "@earendil-works/pi-coding-agent": "*"
22
+ "@earendil-works/pi-coding-agent": "*",
23
+ "typebox": "*"
23
24
  },
24
25
  "description": "Speed up loading large Pi sessions by pruning superseded history.",
25
26
  "keywords": [
package/session-trim.ts CHANGED
@@ -2,54 +2,173 @@
2
2
  // npm-keywords: session, session-management, session-file, session-loading, compaction, performance, history-pruning, cleanup
3
3
 
4
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.
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
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.
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.
13
22
  */
14
23
 
24
+ import { getCurrentSystemMessage, getCurrentTools, type SystemMessage } from "@earendil-works/pi-ai";
15
25
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
16
26
  import { closeSync, createReadStream, openSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
17
27
  import { createInterface } from "node:readline";
18
28
 
19
- // Schema
20
-
21
29
  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.
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.
24
32
  const KEEP_GENERATIONS = 2;
25
33
 
26
- // One session entry, reduced to what the trim needs. Bodies stay on disk.
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.
27
158
  interface Meta {
28
- index: number;
159
+ index: number; // position among the entries, which addresses it in the rewrite
29
160
  bytes: number;
30
161
  id: string;
31
162
  parentId: string | null;
32
163
  type: string;
33
164
  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
165
+ latest: Record<Scope, string[]>; // the LATEST keys it writes
166
+ fold?: Fold<any>; // the FOLDS kind it adds `value` to
167
+ value?: unknown;
47
168
  }
48
169
 
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]> {
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]> {
53
172
  const input = createReadStream(file, { encoding: "utf8" });
54
173
  let index = -1;
55
174
  try {
@@ -67,7 +186,6 @@ async function* sessionLines(file: string): AsyncGenerator<[line: string, entry:
67
186
  }
68
187
  }
69
188
 
70
- // Read entry metadata without holding message bodies in memory.
71
189
  async function readSession(file: string): Promise<{ header: string; metas: Meta[] } | undefined> {
72
190
  let header: string | undefined;
73
191
  const metas: Meta[] = [];
@@ -78,6 +196,8 @@ async function readSession(file: string): Promise<{ header: string; metas: Meta[
78
196
  continue;
79
197
  }
80
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) ?? []);
81
201
  metas.push({
82
202
  index,
83
203
  bytes: Buffer.byteLength(line) + 1,
@@ -85,34 +205,24 @@ async function readSession(file: string): Promise<{ header: string; metas: Meta[
85
205
  parentId: entry.parentId ?? null,
86
206
  type: entry.type,
87
207
  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,
208
+ latest: { branch: keys("branch"), file: keys("file") },
209
+ fold,
210
+ value: fold && entry[fold.field],
98
211
  });
99
212
  }
100
213
  return header === undefined ? undefined : { header, metas };
101
214
  }
102
215
 
103
- // Projections, mirroring pi's session-manager
216
+ // Projections: what pi derives from the session, mirroring core/session-manager.ts and the
217
+ // replayed state declared above.
104
218
 
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
- };
219
+ const isCompaction = (meta: Meta) => meta.type === "compaction";
111
220
 
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"]);
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"]);
116
226
 
117
227
  // getBranch: pi restores the leaf as the last entry in file order.
118
228
  function branchOf(metas: readonly Meta[]): Meta[] | undefined {
@@ -131,10 +241,11 @@ function branchOf(metas: readonly Meta[]): Meta[] | undefined {
131
241
  return path.reverse();
132
242
  }
133
243
 
134
- // buildContextEntries: the newest compaction, then its kept entries, then the rest.
135
- function contextIds(path: readonly Meta[]): string {
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 {
136
247
  const visible: Meta[] = [];
137
- const compaction = findLast(path, (meta) => meta.type === "compaction");
248
+ const compaction = path.filter(isCompaction).at(-1);
138
249
  if (!compaction) visible.push(...path);
139
250
  else {
140
251
  const index = path.indexOf(compaction);
@@ -142,7 +253,7 @@ function contextIds(path: readonly Meta[]): string {
142
253
  let kept = false;
143
254
  for (let i = 0; i < index; i++) {
144
255
  if (path[i].id === compaction.firstKeptEntryId) kept = true;
145
- if (kept) visible.push(path[i]);
256
+ if (kept && path[i].fold !== SYSTEM_MESSAGES) visible.push(path[i]);
146
257
  }
147
258
  visible.push(...path.slice(index + 1));
148
259
  }
@@ -152,105 +263,100 @@ function contextIds(path: readonly Meta[]): string {
152
263
  .join(",");
153
264
  }
154
265
 
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}`;
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;
164
270
  }
165
271
 
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];
272
+ function latestOf(metas: readonly Meta[], scope: Scope): string {
273
+ return JSON.stringify([...winners(metas, scope)].map(([key, meta]) => `${key}=${meta.id}`).sort());
274
+ }
168
275
 
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;
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))));
178
279
  }
179
280
 
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(",");
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")]);
188
284
  }
189
285
 
190
286
  // Planning
191
287
 
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
- );
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 };
204
303
  }
205
304
 
206
- function planTrim(metas: readonly Meta[]): TrimPlan | undefined {
305
+ // A plan, or why there is none.
306
+ function planTrim(metas: readonly Meta[]): TrimPlan | string {
207
307
  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;
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`;
211
311
  const cut = compactions[compactions.length - KEEP_GENERATIONS];
212
312
  const cutIndex = path.findIndex((meta) => meta.id === cut.firstKeptEntryId);
213
- if (cutIndex < 0) return undefined;
313
+ if (cutIndex < 0) return "a compaction refers to an entry missing from this branch";
214
314
 
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.
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.
219
318
  const kept = path.slice(cutIndex);
220
319
  const inKept = new Set(kept.map((meta) => meta.id));
221
- const outside = (meta: Meta | undefined): meta is Meta => meta !== undefined && !inKept.has(meta.id);
320
+ const outside = (meta: Meta) => !inKept.has(meta.id);
222
321
  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);
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);
227
330
 
228
331
  const chain = [...head, ...kept];
229
332
  const trimmed = [...chain, ...tail];
230
- const survivors = new Set(trimmed.map((meta) => meta.id));
231
- if (!preservesProjections(trimmed, path, metas, survivors)) return undefined;
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";
232
335
  return {
233
336
  chain,
234
337
  tail,
338
+ folded,
235
339
  total: metas.length,
236
- kept: trimmed.length,
340
+ remaining: trimmed.length,
237
341
  bytes: trimmed.reduce((total, meta) => total + meta.bytes, 0),
238
342
  };
239
343
  }
240
344
 
241
345
  // Writing
242
346
 
243
- // Re-chain survivors: ids never change, so entries appended later still link up.
347
+ // Write the survivors as one chain, re-linking parentIds. Ids never change, so entries pi
348
+ // appends later still link up.
244
349
  async function writeTrimmed(file: string, header: string, plan: TrimPlan): Promise<void> {
245
350
  const keep = new Set(plan.chain.map((meta) => meta.index));
351
+ const rewrites = new Map(plan.folded.map((meta) => [meta.index, meta]));
246
352
  const holdBack = new Set(plan.tail.map((meta) => meta.index));
247
- const held = new Map<number, Record<string, any>>();
353
+ const held = new Map<number, Entry>();
248
354
  const temp = `${file}.trim`;
249
355
  try {
250
356
  const fd = openSync(temp, "w");
251
357
  let seen = 0;
252
358
  let parentId: string | null = null;
253
- const emit = (entry: Record<string, any>) => {
359
+ const emit = (entry: Entry) => {
254
360
  entry.parentId = parentId;
255
361
  parentId = entry.id;
256
362
  writeFileSync(fd, `${JSON.stringify(entry)}\n`);
@@ -260,7 +366,9 @@ async function writeTrimmed(file: string, header: string, plan: TrimPlan): Promi
260
366
  for await (const [, entry, index] of sessionLines(file)) {
261
367
  if (index < 0) continue;
262
368
  seen++;
263
- if (keep.has(index)) emit(entry);
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);
264
372
  else if (holdBack.has(index)) held.set(index, entry);
265
373
  }
266
374
  for (const meta of plan.tail) emit(held.get(meta.index)!);
@@ -287,12 +395,15 @@ interface Analysis {
287
395
  entries: number; // current entry count
288
396
  }
289
397
 
290
- async function analyze(file: string): Promise<Analysis | undefined> {
398
+ // An analysis, or why there is nothing to trim.
399
+ async function analyze(file: string): Promise<Analysis | string> {
291
400
  const session = await readSession(file);
292
- if (!session) return undefined;
401
+ if (!session) return "unsupported session format";
293
402
  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;
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
+ }
296
407
  return {
297
408
  header: session.header,
298
409
  plan,
@@ -302,7 +413,7 @@ async function analyze(file: string): Promise<Analysis | undefined> {
302
413
  };
303
414
  }
304
415
 
305
- const size = (bytes: number) =>
416
+ const formatSize = (bytes: number) =>
306
417
  bytes < 1024 * 1024 ? `${(bytes / 1024).toFixed(0)} KB` : `${(bytes / 1024 / 1024).toFixed(1)} MB`;
307
418
 
308
419
  export default function (pi: ExtensionAPI): void {
@@ -318,16 +429,16 @@ export default function (pi: ExtensionAPI): void {
318
429
  // Never rewrite the file underneath a running turn.
319
430
  await ctx.waitForIdle();
320
431
  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");
432
+ if (typeof analysis === "string") {
433
+ ctx.ui.notify(`Session not trimmed: ${analysis}.`, "info");
323
434
  return;
324
435
  }
325
436
 
326
437
  const { header, plan, before, after, entries } = analysis;
327
- const reclaimed = size(before - after);
438
+ const reclaimed = formatSize(before - after);
328
439
  // The report doubles as the prompt: re-opening the session clears the transcript,
329
440
  // 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.`;
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.`;
331
442
  if (!ctx.hasUI) {
332
443
  ctx.ui.notify(`Session trim would reclaim ${reclaimed}: ${report}`, "info");
333
444
  return;