@qusic/pi-session-trim 0.0.0 → 0.20260915.0

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