@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 +21 -0
- package/README.md +2 -1
- package/package.json +3 -2
- package/session-trim.ts +237 -126
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) | [](https://www.npmjs.com/package/@qusic/pi-compact-filter) | `pi install npm:@qusic/pi-compact-filter` |
|
|
7
|
+
| [compact-filter](./compact-filter.ts) | [](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) | [](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) | [](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) | [](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) | [](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.
|
|
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
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
-
//
|
|
23
|
-
//
|
|
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
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
|
216
|
+
// Projections: what pi derives from the session, mirroring core/session-manager.ts and the
|
|
217
|
+
// replayed state declared above.
|
|
104
218
|
|
|
105
|
-
const
|
|
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
|
-
//
|
|
113
|
-
//
|
|
114
|
-
//
|
|
115
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
167
|
-
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
//
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
|
|
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
|
|
209
|
-
const compactions = path.filter(
|
|
210
|
-
if (compactions.length < KEEP_GENERATIONS) return
|
|
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
|
|
313
|
+
if (cutIndex < 0) return "a compaction refers to an entry missing from this branch";
|
|
214
314
|
|
|
215
|
-
//
|
|
216
|
-
//
|
|
217
|
-
//
|
|
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
|
|
320
|
+
const outside = (meta: Meta) => !inKept.has(meta.id);
|
|
222
321
|
const byIndex = (a: Meta, b: Meta) => a.index - b.index;
|
|
223
|
-
const
|
|
224
|
-
|
|
225
|
-
.
|
|
226
|
-
|
|
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
|
-
|
|
231
|
-
if (
|
|
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
|
-
|
|
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
|
-
//
|
|
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,
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
401
|
+
if (!session) return "unsupported session format";
|
|
293
402
|
const plan = planTrim(session.metas);
|
|
294
|
-
|
|
295
|
-
if (
|
|
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
|
|
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 (
|
|
322
|
-
ctx.ui.notify(
|
|
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 =
|
|
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 = `${
|
|
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;
|