@8monkey/pi-session-gzip 0.1.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 +53 -0
- package/package.json +42 -0
- package/src/gzip.ts +52 -0
- package/src/index.ts +59 -0
- package/src/session-paths.ts +72 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Infinite Monkey AI
|
|
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
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# pi-session-gzip
|
|
2
|
+
|
|
3
|
+
Gzip Pi session files at rest. When a session closes it's compressed to a `.jsonl.gz` file alongside the original and the plain file is removed; `/resume-compressed` restores and reopens it on demand. Plain JSONL stays Pi's working format — nothing changes during a live session.
|
|
4
|
+
|
|
5
|
+
Built for anyone whose `~/.pi/agent/sessions/` has grown large and wants closed sessions to sit compressed without losing the ability to reopen them.
|
|
6
|
+
|
|
7
|
+
## How it works
|
|
8
|
+
|
|
9
|
+
- **Compress on shutdown.** Quitting Pi compresses the closed session's `session.jsonl` to `session.jsonl.gz` and removes the plain file.
|
|
10
|
+
- **Restore on demand.** Run `/resume-compressed` to pick from this project's compressed sessions (newest first); your choice is decompressed and reopened in place. Pass an id or path to skip the picker.
|
|
11
|
+
|
|
12
|
+
Zero runtime dependencies. Pi loads the TypeScript directly, so there's no build step. Runs under Node or Bun.
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
pi install npm:@8monkey/pi-session-gzip
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
That's it — the extension loads on the next `pi` launch. Update with `pi update`.
|
|
21
|
+
|
|
22
|
+
For development against a local clone, point pi at the file directly in `~/.pi/agent/settings.json`:
|
|
23
|
+
|
|
24
|
+
```json
|
|
25
|
+
{
|
|
26
|
+
"extensions": [
|
|
27
|
+
"~/pi-session-gzip/src/index.ts"
|
|
28
|
+
]
|
|
29
|
+
}
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Command
|
|
33
|
+
|
|
34
|
+
| Command | Description |
|
|
35
|
+
|---|---|
|
|
36
|
+
| `/resume-compressed [id\|path]` | Restore a compressed session and reopen it. With no argument, shows a picker of this project's compressed sessions (newest first). Accepts a session id (exact or prefix) or a file path. |
|
|
37
|
+
|
|
38
|
+
## Behaviour notes
|
|
39
|
+
|
|
40
|
+
- Compresses on quit only; live sessions, reloads, and switches are left untouched.
|
|
41
|
+
- Restoring is safe to repeat — running compress or restore twice is a no-op.
|
|
42
|
+
- Ephemeral (`--no-session`) and empty sessions are skipped.
|
|
43
|
+
- The `.gz` sits next to the original; the sessions layout is never reorganized.
|
|
44
|
+
|
|
45
|
+
## Development
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
node --test
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## License
|
|
52
|
+
|
|
53
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@8monkey/pi-session-gzip",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Pi extension that gzips session files on shutdown and restores them on demand.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/8monkey-ai/pi-session-gzip.git"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/8monkey-ai/pi-session-gzip#readme",
|
|
12
|
+
"bugs": "https://github.com/8monkey-ai/pi-session-gzip/issues",
|
|
13
|
+
"files": [
|
|
14
|
+
"src",
|
|
15
|
+
"README.md",
|
|
16
|
+
"LICENSE"
|
|
17
|
+
],
|
|
18
|
+
"pi": {
|
|
19
|
+
"extensions": [
|
|
20
|
+
"./src/index.ts"
|
|
21
|
+
]
|
|
22
|
+
},
|
|
23
|
+
"publishConfig": {
|
|
24
|
+
"access": "public"
|
|
25
|
+
},
|
|
26
|
+
"scripts": {
|
|
27
|
+
"test": "node --test"
|
|
28
|
+
},
|
|
29
|
+
"peerDependencies": {
|
|
30
|
+
"@earendil-works/pi-coding-agent": "*"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"@types/node": "^22",
|
|
34
|
+
"typescript": "^5"
|
|
35
|
+
},
|
|
36
|
+
"keywords": [
|
|
37
|
+
"pi",
|
|
38
|
+
"pi-extension",
|
|
39
|
+
"gzip",
|
|
40
|
+
"sessions"
|
|
41
|
+
]
|
|
42
|
+
}
|
package/src/gzip.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import {
|
|
2
|
+
closeSync,
|
|
3
|
+
existsSync,
|
|
4
|
+
fsyncSync,
|
|
5
|
+
openSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
renameSync,
|
|
8
|
+
rmSync,
|
|
9
|
+
writeSync,
|
|
10
|
+
} from "node:fs";
|
|
11
|
+
import { gunzipSync, gzipSync } from "node:zlib";
|
|
12
|
+
|
|
13
|
+
export const GZ_SUFFIX = ".gz";
|
|
14
|
+
|
|
15
|
+
// Write via a temp sibling + fsync + atomic rename so readers never observe a
|
|
16
|
+
// half-written file, even across a crash.
|
|
17
|
+
function writeFileDurable(targetPath: string, data: Buffer): void {
|
|
18
|
+
const tmpPath = `${targetPath}.tmp-${process.pid}`;
|
|
19
|
+
const fd = openSync(tmpPath, "w");
|
|
20
|
+
try {
|
|
21
|
+
writeSync(fd, data);
|
|
22
|
+
fsyncSync(fd);
|
|
23
|
+
} finally {
|
|
24
|
+
closeSync(fd);
|
|
25
|
+
}
|
|
26
|
+
renameSync(tmpPath, targetPath);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Compress `jsonlPath` to a `.gz` file beside it and remove the plain file. The .gz
|
|
30
|
+
// is durably in place before the plain file is unlinked, so a crash leaves at
|
|
31
|
+
// least one intact copy. Returns the .gz path, or null when there is nothing to
|
|
32
|
+
// compress (a session quit before it was ever persisted, or an empty file).
|
|
33
|
+
export function gzipFile(jsonlPath: string): string | null {
|
|
34
|
+
if (!existsSync(jsonlPath)) return null;
|
|
35
|
+
|
|
36
|
+
const plain = readFileSync(jsonlPath);
|
|
37
|
+
if (plain.length === 0) return null;
|
|
38
|
+
|
|
39
|
+
const gzPath = `${jsonlPath}${GZ_SUFFIX}`;
|
|
40
|
+
writeFileDurable(gzPath, gzipSync(plain));
|
|
41
|
+
|
|
42
|
+
rmSync(jsonlPath, { force: true });
|
|
43
|
+
return gzPath;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Decompress `gzPath` back to its `.jsonl`, leaving the `.gz` in place. Returns
|
|
47
|
+
// the restored path. Throws (ENOENT) if the .gz is missing.
|
|
48
|
+
export function gunzipFile(gzPath: string): string {
|
|
49
|
+
const jsonlPath = gzPath.slice(0, -GZ_SUFFIX.length);
|
|
50
|
+
writeFileDurable(jsonlPath, gunzipSync(readFileSync(gzPath)));
|
|
51
|
+
return jsonlPath;
|
|
52
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { gunzipFile, gzipFile } from "./gzip.ts";
|
|
3
|
+
import { listCompressedSessions, resolveGzPath } from "./session-paths.ts";
|
|
4
|
+
|
|
5
|
+
export default function (pi: ExtensionAPI) {
|
|
6
|
+
pi.on("session_shutdown", async (event, ctx) => {
|
|
7
|
+
// Only "quit" means pi is done with the file. reload/new/resume/fork
|
|
8
|
+
// reopen or switch files, so compressing then could remove a file pi
|
|
9
|
+
// still reads.
|
|
10
|
+
if (event.reason !== "quit") return;
|
|
11
|
+
|
|
12
|
+
const file = ctx.sessionManager.getSessionFile();
|
|
13
|
+
if (!file) return;
|
|
14
|
+
|
|
15
|
+
const gz = gzipFile(file);
|
|
16
|
+
if (gz && ctx.hasUI) {
|
|
17
|
+
ctx.ui.notify(`Compressed session to ${gz.split("/").pop()}.`, "info");
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
pi.registerCommand("resume-compressed", {
|
|
22
|
+
description: "Decompress a compressed session (.jsonl.gz) and resume it",
|
|
23
|
+
handler: async (args, ctx) => {
|
|
24
|
+
const sessionDir = ctx.sessionManager.getSessionDir();
|
|
25
|
+
const arg = args.trim();
|
|
26
|
+
const gzPath = arg ? resolveGzPath(arg, sessionDir) : (await pickCompressedSession(ctx, sessionDir))?.gzPath;
|
|
27
|
+
if (gzPath) await decompressAndResume(gzPath, ctx);
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function pickCompressedSession(ctx: ExtensionCommandContext, sessionDir: string) {
|
|
33
|
+
const sessions = listCompressedSessions(sessionDir);
|
|
34
|
+
if (sessions.length === 0) {
|
|
35
|
+
ctx.ui.notify("No compressed sessions in this project.", "info");
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const labels = sessions.map((s) => {
|
|
40
|
+
const when = new Date(s.modified).toISOString().slice(0, 16).replace("T", " ");
|
|
41
|
+
const preview = s.preview.replace(/\s+/g, " ").slice(0, 60) || "(no messages)";
|
|
42
|
+
return `${when} ${preview}`;
|
|
43
|
+
});
|
|
44
|
+
const choice = await ctx.ui.select("Resume compressed session", labels);
|
|
45
|
+
return choice === undefined ? undefined : sessions[labels.indexOf(choice)];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function decompressAndResume(gzPath: string, ctx: ExtensionCommandContext) {
|
|
49
|
+
let restored: string;
|
|
50
|
+
try {
|
|
51
|
+
restored = gunzipFile(gzPath);
|
|
52
|
+
} catch (err) {
|
|
53
|
+
ctx.ui.notify(`Failed to decompress session: ${(err as Error).message}.`, "warning");
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const { cancelled } = await ctx.switchSession(restored);
|
|
58
|
+
if (cancelled) ctx.ui.notify("Resume cancelled.", "info");
|
|
59
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
2
|
+
import { isAbsolute, join, resolve } from "node:path";
|
|
3
|
+
import { gunzipSync } from "node:zlib";
|
|
4
|
+
import { GZ_SUFFIX } from "./gzip.ts";
|
|
5
|
+
|
|
6
|
+
function extractText(content: unknown) {
|
|
7
|
+
if (typeof content === "string") return content;
|
|
8
|
+
if (Array.isArray(content)) {
|
|
9
|
+
return content
|
|
10
|
+
.map((part) => (part && typeof part === "object" && "text" in part ? String((part as { text: unknown }).text) : ""))
|
|
11
|
+
.join(" ")
|
|
12
|
+
.trim();
|
|
13
|
+
}
|
|
14
|
+
return "";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function looksLikePath(arg: string) {
|
|
18
|
+
return arg.includes("/") || arg.includes("\\") || arg.endsWith(".jsonl") || arg.endsWith(".jsonl.gz");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function gzPathFromArg(arg: string, base: string) {
|
|
22
|
+
const abs = isAbsolute(arg) ? arg : resolve(base, arg);
|
|
23
|
+
return abs.endsWith(GZ_SUFFIX) ? abs : `${abs}${GZ_SUFFIX}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function gzPathsIn(dir: string) {
|
|
27
|
+
if (!existsSync(dir)) return [];
|
|
28
|
+
return readdirSync(dir)
|
|
29
|
+
.filter((f) => f.endsWith(`.jsonl${GZ_SUFFIX}`))
|
|
30
|
+
.map((f) => join(dir, f));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function toInfo(gzPath: string) {
|
|
34
|
+
try {
|
|
35
|
+
const lines = gunzipSync(readFileSync(gzPath)).toString("utf8").split("\n");
|
|
36
|
+
const header = JSON.parse(lines[0]) as { type?: string; id?: unknown };
|
|
37
|
+
if (header.type !== "session" || typeof header.id !== "string") return undefined;
|
|
38
|
+
|
|
39
|
+
let preview = "";
|
|
40
|
+
for (let i = 1; i < lines.length && !preview; i++) {
|
|
41
|
+
if (!lines[i]) continue;
|
|
42
|
+
try {
|
|
43
|
+
const entry = JSON.parse(lines[i]) as { type?: string; message?: { role?: string; content?: unknown } };
|
|
44
|
+
if (entry.type === "message" && entry.message?.role === "user") preview = extractText(entry.message.content);
|
|
45
|
+
} catch {
|
|
46
|
+
// Skip malformed line.
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return { gzPath, id: header.id, preview, modified: statSync(gzPath).mtimeMs };
|
|
50
|
+
} catch {
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Compressed sessions in `sessionDir` (the live session's directory), newest first.
|
|
56
|
+
export function listCompressedSessions(sessionDir: string) {
|
|
57
|
+
return gzPathsIn(sessionDir)
|
|
58
|
+
.map(toInfo)
|
|
59
|
+
.filter((s) => s !== undefined)
|
|
60
|
+
.sort((a, b) => b.modified - a.modified);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Resolve a `<id|path>` argument to a compressed session's `.gz` path in
|
|
64
|
+
// `sessionDir`. A path resolves directly; an id matches a session header
|
|
65
|
+
// (exact, else prefix). Returns null when no id matches.
|
|
66
|
+
export function resolveGzPath(arg: string, sessionDir: string): string | null {
|
|
67
|
+
const trimmed = arg.trim();
|
|
68
|
+
if (looksLikePath(trimmed)) return gzPathFromArg(trimmed, sessionDir);
|
|
69
|
+
|
|
70
|
+
const sessions = listCompressedSessions(sessionDir);
|
|
71
|
+
return (sessions.find((s) => s.id === trimmed) ?? sessions.find((s) => s.id.startsWith(trimmed)))?.gzPath ?? null;
|
|
72
|
+
}
|