@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 +21 -0
- package/README.md +9 -43
- package/package.json +38 -8
- package/session-trim.ts +457 -0
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
|
-
#
|
|
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
|
-
|
|
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` | Keep Pi compaction file lists scoped to the current summary window. |
|
|
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
|
+
| [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. |
|
|
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,10 +1,40 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
}
|
package/session-trim.ts
ADDED
|
@@ -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
|
+
}
|