pi-resume 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026
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,68 @@
1
+ # pi-fast-resume
2
+
3
+ Fast session resume for [pi coding agent](https://pi.dev) without reading all `.jsonl` files.
4
+
5
+ ## Problem
6
+
7
+ Built-in `/resume` reads and parses **every line** of every session file to build the picker. With hundreds of sessions this is slow.
8
+
9
+ ## Solution
10
+
11
+ Two commands that use `stat()` + lazy partial reads:
12
+
13
+ | Command | What it does | Speed |
14
+ |---------|-------------|-------|
15
+ | `/r2` | Instantly switch to the most recent session | <50ms (stat-only) |
16
+ | `/rs` | Paginated picker: last 20, with tier navigation | <200ms first page |
17
+
18
+ ## Install
19
+
20
+ ```bash
21
+ pi install npm:pi-fast-resume
22
+ ```
23
+
24
+ ## Commands
25
+
26
+ ### `/r2` — Instant Resume
27
+
28
+ Switches to the most recent session (by mtime) in one step. No picker, no parsing.
29
+
30
+ ### `/rs` — Smart Resume
31
+
32
+ Shows a paginated list of recent sessions:
33
+
34
+ - **Relative time** (e.g., "2h ago"), **file size**, **session name or first message**
35
+ - **`▼ Load more...`** — next page within current filter
36
+ - **`▼ Show 14d`** — expand to 14 days
37
+ - **`▼ Show all`** — remove day filter entirely
38
+
39
+ Auto-escalates: if 7d is empty, jumps to 14d, then all.
40
+
41
+ ### Configuration
42
+
43
+ ```
44
+ /rs set page 30 # Sessions per page (1-50, default: 20)
45
+ /rs set days 14 # Day filter for first tier (0-30, 0 = no filter, default: 7)
46
+ ```
47
+
48
+ Config is stored in `~/.pi/agent/extensions/pi-fast-resume/config.json`.
49
+
50
+ ## How it works
51
+
52
+ 1. **`/r2`**: `readdir` → `stat` each `.jsonl` → sort by mtime → `switchSession(newest)`
53
+ 2. **`/rs`**: Same stat scan, then read only the **first ~50 lines** of each file on the current page to extract session name and first user message
54
+
55
+ No full file parsing. No `buildSessionInfo()`. No reading message content beyond the first user message.
56
+
57
+ ## Development
58
+
59
+ ```bash
60
+ git clone https://github.com/spex66/pi-fast-resume.git
61
+ cd pi-fast-resume
62
+ npm install
63
+ npm test
64
+ ```
65
+
66
+ ## License
67
+
68
+ MIT
@@ -0,0 +1,158 @@
1
+ /**
2
+ * pi-fast-resume — fast session resume without reading all .jsonl files.
3
+ *
4
+ * Commands:
5
+ * /r2 — instantly switch to the most recent session (stat-only)
6
+ * /rs — paginated session picker (last 20, "Load more", tier filter)
7
+ * /rs set page N — set page size (1-50)
8
+ * /rs set days N — set maxDays filter (0-30, 0 = no limit)
9
+ */
10
+
11
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
12
+ import { statScan, scanPage, readSessionMeta } from "../src/scanner.ts";
13
+ import { formatEntry, truncate, sessionLabel } from "../src/format.ts";
14
+ import { loadConfig, saveConfig } from "../src/config.ts";
15
+ import { getSessionDir } from "../src/session-dir.ts";
16
+
17
+ const DAY_TIERS = [7, 14, 0] as const;
18
+
19
+ export default function (pi: ExtensionAPI) {
20
+ pi.registerCommand("r2", {
21
+ description: "Instantly resume the most recent session",
22
+ handler: async (_args: string, ctx: any) => {
23
+ const sessionDir = getSessionDir(ctx.cwd);
24
+ const files = await statScan(sessionDir);
25
+
26
+ if (files.length === 0) {
27
+ ctx.ui.notify("No sessions found", "error");
28
+ return;
29
+ }
30
+
31
+ const currentFile = ctx.sessionManager.getSessionFile() ?? undefined;
32
+ const target = files.find((f: { file: string }) => f.file !== currentFile);
33
+
34
+ if (!target) {
35
+ ctx.ui.notify("No other sessions to resume", "info");
36
+ return;
37
+ }
38
+
39
+ const meta = await readSessionMeta(target.file, target);
40
+ await ctx.switchSession(target.file, {
41
+ withSession: async (newCtx: any) => {
42
+ newCtx.ui.notify(`Resumed: ${truncate(sessionLabel(meta), 50)}`, "info");
43
+ },
44
+ });
45
+ },
46
+ });
47
+
48
+ pi.registerCommand("rs", {
49
+ description: "Smart resume: paginated session picker (last 20, Load more, tier filter)",
50
+ handler: async (args: string, ctx: any) => {
51
+ const parts = (args || "").trim().split(/\s+/);
52
+ const cfg = loadConfig();
53
+
54
+ // /rs set page N | /rs set days N
55
+ if (parts[0] === "set") {
56
+ const key = parts[1];
57
+ const val = parseInt(parts[2], 10);
58
+
59
+ if (key === "page" && !isNaN(val) && val >= 1 && val <= 50) {
60
+ cfg.pageSize = val;
61
+ saveConfig(cfg);
62
+ ctx.ui.notify(`Page size set to ${val}`, "info");
63
+ return;
64
+ }
65
+ if (key === "days" && !isNaN(val) && val >= 0 && val <= 30) {
66
+ cfg.maxDays = val;
67
+ saveConfig(cfg);
68
+ ctx.ui.notify(val === 0 ? "Day filter disabled" : `Max days set to ${val}`, "info");
69
+ return;
70
+ }
71
+
72
+ ctx.ui.notify("Usage: /rs set page N (1-50) | /rs set days N (0-30)", "error");
73
+ return;
74
+ }
75
+
76
+ const sessionDir = getSessionDir(ctx.cwd);
77
+ const currentFile = ctx.sessionManager.getSessionFile() ?? undefined;
78
+
79
+ let tierIndex = 0;
80
+ let offset = 0;
81
+
82
+ while (true) {
83
+ const currentDays = DAY_TIERS[tierIndex] ?? 0;
84
+ const nextTierDays = DAY_TIERS[tierIndex + 1];
85
+
86
+ const { entries, total, hasMore } = await scanPage(
87
+ sessionDir,
88
+ offset,
89
+ cfg.pageSize,
90
+ currentDays > 0 ? currentDays : undefined,
91
+ currentFile,
92
+ );
93
+
94
+ if (entries.length === 0 && offset === 0) {
95
+ if (tierIndex < DAY_TIERS.length - 1) {
96
+ tierIndex++;
97
+ continue;
98
+ }
99
+ ctx.ui.notify("No sessions found", "info");
100
+ return;
101
+ }
102
+
103
+ const items: string[] = entries.map((e) => formatEntry(e));
104
+
105
+ if (hasMore) {
106
+ const remaining = total - offset - entries.length;
107
+ items.push(`▼ Load more... (${remaining} remaining)`);
108
+ }
109
+
110
+ if (nextTierDays !== undefined) {
111
+ const tierLabel = nextTierDays > 0 ? `${nextTierDays}d` : "all";
112
+ items.push(`▼ Show ${tierLabel}`);
113
+ }
114
+
115
+ const filterLabel = currentDays > 0 ? ` (last ${currentDays}d)` : "";
116
+ const rangeLabel =
117
+ offset === 0
118
+ ? `Sessions 1-${entries.length} of ${total}${filterLabel}`
119
+ : `Sessions ${offset + 1}-${offset + entries.length} of ${total}${filterLabel}`;
120
+
121
+ const choice = await ctx.ui.select(rangeLabel, items);
122
+
123
+ if (choice === undefined || choice === null) return;
124
+
125
+ const choiceIndex = items.indexOf(choice);
126
+
127
+ if (hasMore && choiceIndex === entries.length) {
128
+ offset += cfg.pageSize;
129
+ continue;
130
+ }
131
+
132
+ if (choice.startsWith("▼ Show ") && nextTierDays !== undefined) {
133
+ tierIndex++;
134
+ offset = 0;
135
+ continue;
136
+ }
137
+
138
+ if (choiceIndex >= 0 && choiceIndex < entries.length) {
139
+ const selected = entries[choiceIndex];
140
+ if (!selected) return;
141
+
142
+ const result = await ctx.switchSession(selected.file, {
143
+ withSession: async (newCtx: any) => {
144
+ newCtx.ui.notify(`Resumed: ${truncate(sessionLabel(selected), 50)}`, "info");
145
+ },
146
+ });
147
+
148
+ if (result.cancelled) {
149
+ ctx.ui.notify("Session switch was cancelled", "info");
150
+ }
151
+ return;
152
+ }
153
+
154
+ return;
155
+ }
156
+ },
157
+ });
158
+ }
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "pi-resume",
3
+ "version": "1.0.0",
4
+ "description": "Fast session resume for pi coding agent — /r2 instant resume, /rs paginated picker",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "keywords": [
8
+ "pi-package",
9
+ "pi-extension",
10
+ "pi",
11
+ "session",
12
+ "resume"
13
+ ],
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "https://github.com/spex66/pi-fast-resume.git"
17
+ },
18
+ "files": [
19
+ "extensions",
20
+ "src",
21
+ "README.md",
22
+ "LICENSE"
23
+ ],
24
+ "pi": {
25
+ "extensions": [
26
+ "./extensions/index.ts"
27
+ ]
28
+ },
29
+ "engines": {
30
+ "node": ">=18.0.0"
31
+ },
32
+ "peerDependencies": {
33
+ "@earendil-works/pi-coding-agent": "*",
34
+ "@earendil-works/pi-tui": "*"
35
+ },
36
+ "devDependencies": {
37
+ "@earendil-works/pi-coding-agent": "*",
38
+ "@earendil-works/pi-tui": "*",
39
+ "typescript": "^5.0.0"
40
+ },
41
+ "scripts": {
42
+ "typecheck": "tsc --noEmit",
43
+ "test": "node --experimental-vm-modules --test tests/*.test.ts",
44
+ "check": "npm run typecheck && npm pack --dry-run",
45
+ "prepublishOnly": "npm run check"
46
+ }
47
+ }
package/src/config.ts ADDED
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Config management. Stores user prefs in ~/.pi/agent/extensions/pi-fast-resume/config.json.
3
+ * Creates defaults at runtime — nothing shipped in the package.
4
+ */
5
+
6
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
7
+ import { join } from "node:path";
8
+
9
+ const CONFIG_DIR = join(
10
+ process.env.PI_CODING_AGENT_DIR || join(process.env.HOME || "~", ".pi", "agent"),
11
+ "extensions",
12
+ "pi-fast-resume",
13
+ );
14
+ const CONFIG_PATH = join(CONFIG_DIR, "config.json");
15
+
16
+ export interface Config {
17
+ pageSize: number;
18
+ maxDays: number;
19
+ }
20
+
21
+ const DEFAULTS: Config = { pageSize: 20, maxDays: 7 };
22
+ const MIN_PAGE = 1;
23
+ const MAX_PAGE = 50;
24
+ const MIN_DAYS = 0;
25
+ const MAX_DAYS = 30;
26
+
27
+ export function clampPage(n: number): number {
28
+ return Math.min(MAX_PAGE, Math.max(MIN_PAGE, n));
29
+ }
30
+
31
+ export function clampDays(n: number): number {
32
+ return Math.min(MAX_DAYS, Math.max(MIN_DAYS, n));
33
+ }
34
+
35
+ export function loadConfig(): Config {
36
+ try {
37
+ if (existsSync(CONFIG_PATH)) {
38
+ const raw = JSON.parse(readFileSync(CONFIG_PATH, "utf-8"));
39
+ return {
40
+ pageSize: clampPage(Number(raw.pageSize) || DEFAULTS.pageSize),
41
+ maxDays: clampDays(Number(raw.maxDays) || DEFAULTS.maxDays),
42
+ };
43
+ }
44
+ } catch {}
45
+ return { ...DEFAULTS };
46
+ }
47
+
48
+ export function saveConfig(cfg: Config): void {
49
+ if (!existsSync(CONFIG_DIR)) mkdirSync(CONFIG_DIR, { recursive: true });
50
+ writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2) + "\n");
51
+ }
package/src/format.ts ADDED
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Pure formatting functions. No pi SDK dependency.
3
+ */
4
+
5
+ import type { SessionEntry } from "./scanner.ts";
6
+
7
+ export function formatSize(bytes: number): string {
8
+ if (bytes < 1024) return `${bytes}B`;
9
+ if (bytes < 1048576) return `${(bytes / 1024).toFixed(0)}KB`;
10
+ return `${(bytes / 1048576).toFixed(1)}MB`;
11
+ }
12
+
13
+ export function formatAge(mtime: Date): string {
14
+ const diff = Date.now() - mtime.getTime();
15
+ const mins = Math.floor(diff / 60000);
16
+ if (mins < 1) return "just now";
17
+ if (mins < 60) return `${mins}m ago`;
18
+ const hours = Math.floor(mins / 60);
19
+ if (hours < 24) return `${hours}h ago`;
20
+ const days = Math.floor(hours / 24);
21
+ return `${days}d ago`;
22
+ }
23
+
24
+ export function truncate(text: string, max: number): string {
25
+ return text.length > max ? text.slice(0, max - 3) + "..." : text;
26
+ }
27
+
28
+ export function sessionLabel(e: SessionEntry): string {
29
+ return e.name || e.firstMessage || e.id || "untitled";
30
+ }
31
+
32
+ export function formatEntry(e: SessionEntry): string {
33
+ return `${formatAge(e.mtime).padEnd(10)} ${formatSize(e.size).padEnd(8)} ${truncate(sessionLabel(e), 60)}`;
34
+ }
package/src/scanner.ts ADDED
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Fast session scanner using stat + lazy header parse.
3
+ *
4
+ * Key principle: never read full .jsonl files.
5
+ * - stat() for mtime/size sorting
6
+ * - First ~50 lines for header, session_info, first user message
7
+ */
8
+
9
+ import { readdir, stat } from "node:fs/promises";
10
+ import { createReadStream } from "node:fs";
11
+ import { createInterface } from "node:readline";
12
+ import { join } from "node:path";
13
+
14
+ export interface SessionEntry {
15
+ file: string;
16
+ mtime: Date;
17
+ size: number;
18
+ id?: string;
19
+ timestamp?: string;
20
+ cwd?: string;
21
+ name?: string;
22
+ firstMessage?: string;
23
+ }
24
+
25
+ export interface StatResult {
26
+ file: string;
27
+ mtime: Date;
28
+ size: number;
29
+ }
30
+
31
+ /**
32
+ * Fast stat-only scan: readdir + stat, sorted by mtime desc.
33
+ * No file content is read.
34
+ */
35
+ export async function statScan(sessionDir: string): Promise<StatResult[]> {
36
+ let entries: string[];
37
+ try {
38
+ entries = await readdir(sessionDir);
39
+ } catch {
40
+ return [];
41
+ }
42
+
43
+ const jsonlFiles = entries.filter((f) => f.endsWith(".jsonl"));
44
+
45
+ const results = await Promise.all(
46
+ jsonlFiles.map(async (f) => {
47
+ const fullPath = join(sessionDir, f);
48
+ try {
49
+ const s = await stat(fullPath);
50
+ if (!s.isFile()) return null;
51
+ return { file: fullPath, mtime: s.mtime, size: s.size };
52
+ } catch {
53
+ return null;
54
+ }
55
+ }),
56
+ );
57
+
58
+ const valid = results.filter((r): r is StatResult => r !== null);
59
+ valid.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
60
+ return valid;
61
+ }
62
+
63
+ /**
64
+ * Read session metadata from first ~50 lines of a .jsonl file.
65
+ * Caller provides mtime/size from prior stat() to avoid double-stat.
66
+ */
67
+ export async function readSessionMeta(
68
+ filePath: string,
69
+ known?: { mtime: Date; size: number },
70
+ ): Promise<SessionEntry> {
71
+ const entry: SessionEntry = {
72
+ file: filePath,
73
+ mtime: known?.mtime ?? new Date(0),
74
+ size: known?.size ?? 0,
75
+ };
76
+
77
+ if (!known) {
78
+ try {
79
+ const s = await stat(filePath);
80
+ entry.mtime = s.mtime;
81
+ entry.size = s.size;
82
+ } catch {}
83
+ }
84
+
85
+ const MAX_LINES = 50;
86
+ let lineCount = 0;
87
+
88
+ try {
89
+ const rl = createInterface({
90
+ input: createReadStream(filePath, { encoding: "utf8" }),
91
+ crlfDelay: Infinity,
92
+ });
93
+
94
+ for await (const line of rl) {
95
+ lineCount++;
96
+ if (lineCount > MAX_LINES) break;
97
+
98
+ try {
99
+ const parsed = JSON.parse(line);
100
+
101
+ if (parsed.type === "session" && !entry.id) {
102
+ entry.id = parsed.id;
103
+ entry.timestamp = parsed.timestamp;
104
+ entry.cwd = parsed.cwd;
105
+ continue;
106
+ }
107
+
108
+ if (parsed.type === "session_info" && parsed.name) {
109
+ entry.name = parsed.name.trim();
110
+ continue;
111
+ }
112
+
113
+ if (!entry.firstMessage && parsed.type === "message") {
114
+ const msg = parsed.message;
115
+ if (msg?.role === "user" && Array.isArray(msg.content)) {
116
+ for (const block of msg.content) {
117
+ if (block.type === "text" && block.text) {
118
+ entry.firstMessage = block.text.slice(0, 80).replace(/\n/g, " ");
119
+ break;
120
+ }
121
+ }
122
+ }
123
+ }
124
+ } catch {
125
+ // skip unparseable lines
126
+ }
127
+
128
+ if (entry.id && entry.firstMessage) break;
129
+ }
130
+
131
+ rl.close();
132
+ } catch {}
133
+
134
+ return entry;
135
+ }
136
+
137
+ /**
138
+ * Scan a page of sessions with metadata.
139
+ * Pass known mtime/size from statScan to avoid double stat().
140
+ */
141
+ export async function scanPage(
142
+ sessionDir: string,
143
+ offset: number,
144
+ limit: number,
145
+ maxDays?: number,
146
+ excludeFile?: string,
147
+ ): Promise<{ entries: SessionEntry[]; total: number; hasMore: boolean }> {
148
+ const all = await statScan(sessionDir);
149
+
150
+ let filtered = all;
151
+ if (maxDays && maxDays > 0) {
152
+ const cutoff = Date.now() - maxDays * 86400_000;
153
+ filtered = all.filter((f) => f.mtime.getTime() > cutoff);
154
+ }
155
+
156
+ if (excludeFile) {
157
+ filtered = filtered.filter((f) => f.file !== excludeFile);
158
+ }
159
+
160
+ const total = filtered.length;
161
+ const page = filtered.slice(offset, offset + limit);
162
+ const hasMore = offset + limit < total;
163
+
164
+ const entries = await Promise.all(page.map((f) => readSessionMeta(f.file, f)));
165
+
166
+ return { entries, total, hasMore };
167
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Resolve the pi session directory for a given cwd.
3
+ * Mirrors pi's internal encoding: --<path-with-dashes>--
4
+ */
5
+
6
+ import { join } from "node:path";
7
+
8
+ export function getSessionDir(cwd: string): string {
9
+ const resolved = cwd.replace(/^\//, "").replace(/[/\\:]/g, "-");
10
+ return join(
11
+ process.env.PI_CODING_AGENT_DIR || join(process.env.HOME || "~", ".pi", "agent"),
12
+ "sessions",
13
+ `--${resolved}--`,
14
+ );
15
+ }