pi-fast-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/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "pi-fast-resume",
3
+ "version": "1.0.0",
4
+ "description": "Fast session picker for pi — reads headers + first messages from 16KB partial reads instead of full-file parsing",
5
+ "type": "module",
6
+ "author": "Tom X Nguyen",
7
+ "license": "MIT",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/monotykamary/pi-fast-resume.git"
11
+ },
12
+ "homepage": "https://github.com/monotykamary/pi-fast-resume#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/monotykamary/pi-fast-resume/issues"
15
+ },
16
+ "keywords": [
17
+ "pi-package",
18
+ "pi",
19
+ "pi-coding-agent",
20
+ "extension",
21
+ "resume",
22
+ "session",
23
+ "fast",
24
+ "picker",
25
+ "performance"
26
+ ],
27
+ "files": [
28
+ "*.ts",
29
+ "src/",
30
+ "README.md"
31
+ ],
32
+ "scripts": {
33
+ "test": "vitest run",
34
+ "test:watch": "vitest",
35
+ "test:coverage": "vitest run --coverage",
36
+ "typecheck": "tsc --noEmit",
37
+ "lint:dead": "knip --no-gitignore"
38
+ },
39
+ "devDependencies": {
40
+ "@earendil-works/pi-coding-agent": "0.75.4",
41
+ "@earendil-works/pi-tui": "0.75.4",
42
+ "@types/node": "25.9.1",
43
+ "@vitest/coverage-v8": "4.1.7",
44
+ "knip": "6.14.1",
45
+ "typescript": "6.0.3",
46
+ "vitest": "4.1.7"
47
+ },
48
+ "pi": {
49
+ "extensions": [
50
+ "./fast-resume.ts"
51
+ ]
52
+ },
53
+ "overrides": {
54
+ "brace-expansion": "5.0.6"
55
+ }
56
+ }
package/src/index.ts ADDED
@@ -0,0 +1,23 @@
1
+ export { parseSessionFromBuffer, loadSessionHeader, loadSessionHeaders, scanAllSessionDirs, scanSessionDir, sortByModified, sortByModifiedDesc, filterByCwd, matchQuery, canonicalizePath } from "./scanner.js";
2
+ export type { SessionHeader, SessionFileMeta } from "./scanner.js";
3
+ export { createInitialState, setScope, setSessions, setFilter, moveSelection } from "./picker-state.js";
4
+ export type { PickerScope, PickerState } from "./picker-state.js";
5
+ export {
6
+ parseSearchQuery,
7
+ matchSession,
8
+ hasSessionName,
9
+ filterAndSortSessions,
10
+ buildSessionTree,
11
+ flattenSessionTree,
12
+ buildTreePrefix,
13
+ } from "./search.js";
14
+ export type {
15
+ SearchToken,
16
+ SearchTokenKind,
17
+ ParsedSearch,
18
+ MatchResult,
19
+ SortMode,
20
+ NameFilter,
21
+ SessionTreeNode,
22
+ FlatSessionNode,
23
+ } from "./search.js";
@@ -0,0 +1,95 @@
1
+ import type { SessionHeader } from "./scanner.js";
2
+
3
+ export type PickerScope = "current" | "all";
4
+
5
+ export interface PickerState {
6
+ scope: PickerScope;
7
+ sessions: SessionHeader[];
8
+ filteredSessions: SessionHeader[];
9
+ selectedIndex: number;
10
+ filterQuery: string;
11
+ loadingDone: boolean;
12
+ loadedCount: number;
13
+ totalCount: number;
14
+ }
15
+
16
+ export function createInitialState(
17
+ initialSessions: SessionHeader[],
18
+ totalCount: number,
19
+ loadingDone: boolean,
20
+ ): PickerState {
21
+ return {
22
+ scope: "current",
23
+ sessions: initialSessions,
24
+ filteredSessions: initialSessions,
25
+ selectedIndex: 0,
26
+ filterQuery: "",
27
+ loadingDone,
28
+ loadedCount: initialSessions.length,
29
+ totalCount,
30
+ };
31
+ }
32
+
33
+ export function setScope(state: PickerState, scope: PickerScope): PickerState {
34
+ return {
35
+ ...state,
36
+ scope,
37
+ filterQuery: "",
38
+ selectedIndex: 0,
39
+ };
40
+ }
41
+
42
+ export function setSessions(
43
+ state: PickerState,
44
+ sessions: SessionHeader[],
45
+ loadingDone: boolean,
46
+ ): PickerState {
47
+ const filtered = applyFilter(sessions, state.filterQuery);
48
+ return {
49
+ ...state,
50
+ sessions,
51
+ filteredSessions: filtered,
52
+ loadingDone,
53
+ loadedCount: sessions.length,
54
+ selectedIndex: Math.min(state.selectedIndex, Math.max(0, filtered.length - 1)),
55
+ };
56
+ }
57
+
58
+ export function setFilter(
59
+ state: PickerState,
60
+ query: string,
61
+ ): PickerState {
62
+ const filtered = applyFilter(state.sessions, query);
63
+ return {
64
+ ...state,
65
+ filterQuery: query,
66
+ filteredSessions: filtered,
67
+ selectedIndex: Math.min(state.selectedIndex, Math.max(0, filtered.length - 1)),
68
+ };
69
+ }
70
+
71
+ export function moveSelection(
72
+ state: PickerState,
73
+ delta: number,
74
+ ): PickerState {
75
+ const max = Math.max(0, state.filteredSessions.length - 1);
76
+ return {
77
+ ...state,
78
+ selectedIndex: Math.max(0, Math.min(max, state.selectedIndex + delta)),
79
+ };
80
+ }
81
+
82
+ function applyFilter(
83
+ sessions: SessionHeader[],
84
+ query: string,
85
+ ): SessionHeader[] {
86
+ if (!query) return sessions;
87
+ const q = query.toLowerCase();
88
+ return sessions.filter(
89
+ (s) =>
90
+ s.firstMessage.toLowerCase().includes(q) ||
91
+ (s.name?.toLowerCase().includes(q) ?? false) ||
92
+ s.cwd.toLowerCase().includes(q) ||
93
+ s.id.toLowerCase().includes(q),
94
+ );
95
+ }
package/src/scanner.ts ADDED
@@ -0,0 +1,291 @@
1
+ import { openSync, readSync, closeSync, readdirSync, statSync, realpathSync } from "node:fs";
2
+ import { join, resolve } from "node:path";
3
+ import { homedir } from "node:os";
4
+ import { StringDecoder } from "node:string_decoder";
5
+
6
+ export interface SessionHeader {
7
+ path: string;
8
+ id: string;
9
+ cwd: string;
10
+ parentSessionPath?: string;
11
+ created: Date;
12
+ modified: Date;
13
+ messageCount: number;
14
+ firstMessage: string;
15
+ name?: string;
16
+ }
17
+
18
+ export interface SessionFileMeta {
19
+ path: string;
20
+ mtimeMs: number;
21
+ size: number;
22
+ }
23
+
24
+ const PARTIAL_READ_SIZE = 16_384;
25
+
26
+ function extractTextFromContent(
27
+ content: string | Array<{ type: string; text?: string }>,
28
+ ): string {
29
+ if (typeof content === "string") return content;
30
+ return content
31
+ .filter((b) => b.type === "text" && b.text)
32
+ .map((b) => b.text!)
33
+ .join(" ");
34
+ }
35
+
36
+ export function parseSessionFromBuffer(
37
+ buf: Buffer,
38
+ bytesRead: number,
39
+ filePath: string,
40
+ mtimeMs: number,
41
+ partial = false,
42
+ ): SessionHeader | null {
43
+ const decoder = new StringDecoder("utf8");
44
+ const text = decoder.write(buf.subarray(0, bytesRead)) + decoder.end();
45
+ const lines = text.split("\n");
46
+
47
+ let header: { id: string; timestamp: string; cwd?: string; parentSession?: string } | null = null;
48
+ let firstUserMsg = "";
49
+ let name = "";
50
+ let msgCount = 0;
51
+ let lastActivityTime: number | undefined;
52
+
53
+ for (const line of lines) {
54
+ const trimmed = line.trim();
55
+ if (!trimmed) continue;
56
+ try {
57
+ const entry = JSON.parse(trimmed);
58
+ if (entry.type === "session") {
59
+ header = entry;
60
+ continue;
61
+ }
62
+ if (entry.type === "session_info") {
63
+ const n = entry.name?.trim();
64
+ if (n) name = n;
65
+ }
66
+ if (entry.type === "message") {
67
+ msgCount++;
68
+
69
+ // Track last activity time from user/assistant messages
70
+ // Matches pi-core's getMessageActivityTime priority:
71
+ // message.timestamp (number) > entry.timestamp (date string)
72
+ const msg = entry.message;
73
+ if (msg?.role === "user" || msg?.role === "assistant") {
74
+ const msgTimestamp = msg.timestamp;
75
+ if (typeof msgTimestamp === "number" && msgTimestamp > 0) {
76
+ lastActivityTime = Math.max(lastActivityTime ?? 0, msgTimestamp);
77
+ } else if (typeof entry.timestamp === "string") {
78
+ const t = new Date(entry.timestamp).getTime();
79
+ if (!Number.isNaN(t)) {
80
+ lastActivityTime = Math.max(lastActivityTime ?? 0, t);
81
+ }
82
+ }
83
+ }
84
+
85
+ if (!firstUserMsg && msg?.role === "user") {
86
+ try {
87
+ firstUserMsg = extractTextFromContent(msg.content);
88
+ } catch {
89
+ // Malformed content, skip
90
+ }
91
+ }
92
+ }
93
+ } catch {
94
+ // Incomplete/truncated JSON at buffer boundary, skip
95
+ }
96
+ }
97
+
98
+ if (!header) return null;
99
+
100
+ // Determine modified time:
101
+ // - Full read: use pi-core's priority (message timestamp > header timestamp > stat mtime).
102
+ // lastActivityTime is accurate since we saw every message.
103
+ // - Partial read: stat mtime is more reliable than a partial lastActivityTime,
104
+ // which only reflects messages in the first 16KB and may severely underestimate
105
+ // the true last activity for large sessions.
106
+ const headerTime = typeof header.timestamp === "string" ? new Date(header.timestamp).getTime() : NaN;
107
+ let modified: Date;
108
+ if (partial) {
109
+ // Partial read — stat mtime is the most reliable signal
110
+ modified = new Date(mtimeMs);
111
+ } else {
112
+ // Full read — pi-core's priority chain
113
+ modified =
114
+ typeof lastActivityTime === "number" && lastActivityTime > 0
115
+ ? new Date(lastActivityTime)
116
+ : !Number.isNaN(headerTime)
117
+ ? new Date(headerTime)
118
+ : new Date(mtimeMs);
119
+ }
120
+
121
+ return {
122
+ path: filePath,
123
+ id: header.id,
124
+ cwd: header.cwd ?? "",
125
+ parentSessionPath: header.parentSession || undefined,
126
+ created: new Date(header.timestamp),
127
+ modified,
128
+ messageCount: msgCount,
129
+ firstMessage: firstUserMsg || "",
130
+ name: name || undefined,
131
+ };
132
+ }
133
+
134
+ const HOME = homedir();
135
+ const DEFAULT_SESSIONS_DIR = join(HOME, ".pi", "agent", "sessions");
136
+
137
+ /**
138
+ * Scan ALL session directories under the root (~/.pi/agent/sessions/).
139
+ * Each subdirectory contains .jsonl files for a specific cwd.
140
+ */
141
+ export function scanAllSessionDirs(
142
+ sessionsDir: string = DEFAULT_SESSIONS_DIR,
143
+ ): SessionFileMeta[] {
144
+ const results: SessionFileMeta[] = [];
145
+ let dirs: string[];
146
+
147
+ try {
148
+ dirs = readdirSync(sessionsDir, { withFileTypes: true })
149
+ .filter((d) => d.isDirectory())
150
+ .map((d) => d.name);
151
+ } catch {
152
+ return results;
153
+ }
154
+
155
+ for (const dir of dirs) {
156
+ const subDir = join(sessionsDir, dir);
157
+ let files: string[];
158
+ try {
159
+ files = readdirSync(subDir);
160
+ } catch {
161
+ continue;
162
+ }
163
+ for (const file of files) {
164
+ if (!file.endsWith(".jsonl")) continue;
165
+ const path = join(subDir, file);
166
+ try {
167
+ const stat = statSync(path);
168
+ results.push({
169
+ path,
170
+ mtimeMs: stat.mtimeMs,
171
+ size: stat.size,
172
+ });
173
+ } catch {
174
+ // File disappeared between readdir and stat, skip
175
+ }
176
+ }
177
+ }
178
+
179
+ return results;
180
+ }
181
+
182
+ /**
183
+ * Scan only the session directory that corresponds to a given cwd.
184
+ * Matches pi's built-in SessionManager.list() behavior.
185
+ */
186
+ export function scanSessionDir(
187
+ sessionDir: string,
188
+ ): SessionFileMeta[] {
189
+ const results: SessionFileMeta[] = [];
190
+
191
+ let files: string[];
192
+ try {
193
+ files = readdirSync(sessionDir);
194
+ } catch {
195
+ return results;
196
+ }
197
+
198
+ for (const file of files) {
199
+ if (!file.endsWith(".jsonl")) continue;
200
+ const path = join(sessionDir, file);
201
+ try {
202
+ const stat = statSync(path);
203
+ results.push({
204
+ path,
205
+ mtimeMs: stat.mtimeMs,
206
+ size: stat.size,
207
+ });
208
+ } catch {
209
+ // File disappeared
210
+ }
211
+ }
212
+
213
+ return results;
214
+ }
215
+
216
+ export function loadSessionHeader(
217
+ meta: SessionFileMeta,
218
+ ): SessionHeader | null {
219
+ let fd: number | undefined;
220
+ try {
221
+ fd = openSync(meta.path, "r");
222
+ const readSize = Math.min(PARTIAL_READ_SIZE, meta.size);
223
+ const buf = Buffer.alloc(readSize);
224
+ const bytesRead = readSync(fd, buf, 0, readSize, 0);
225
+ return parseSessionFromBuffer(buf, bytesRead, meta.path, meta.mtimeMs, meta.size > PARTIAL_READ_SIZE);
226
+ } catch {
227
+ return null;
228
+ } finally {
229
+ if (fd !== undefined) closeSync(fd);
230
+ }
231
+ }
232
+
233
+ export function loadSessionHeaders(
234
+ metas: SessionFileMeta[],
235
+ ): SessionHeader[] {
236
+ const results: SessionHeader[] = [];
237
+ for (const meta of metas) {
238
+ const header = loadSessionHeader(meta);
239
+ if (header) results.push(header);
240
+ }
241
+ return results;
242
+ }
243
+
244
+ export function sortByModified(sessions: SessionHeader[]): SessionHeader[] {
245
+ return sessions.sort(
246
+ (a, b) => b.modified.getTime() - a.modified.getTime(),
247
+ );
248
+ }
249
+
250
+ export function sortByModifiedDesc(metas: SessionFileMeta[]): SessionFileMeta[] {
251
+ return metas.sort((a, b) => b.mtimeMs - a.mtimeMs);
252
+ }
253
+
254
+ /**
255
+ * Filter sessions by cwd, matching pi's sessionCwdMatches behavior:
256
+ * resolves both paths before comparing so symlinks don't cause mismatches.
257
+ */
258
+ export function filterByCwd(
259
+ sessions: SessionHeader[],
260
+ cwd: string,
261
+ ): SessionHeader[] {
262
+ const resolvedCwd = resolve(cwd);
263
+ return sessions.filter((s) => {
264
+ if (!s.cwd) return false;
265
+ return resolve(s.cwd) === resolvedCwd;
266
+ });
267
+ }
268
+
269
+ /**
270
+ * Canonicalize a file path by resolving symlinks.
271
+ * Matches pi-core's canonicalizePath behavior (realpathSync with fallback).
272
+ */
273
+ export function canonicalizePath(path: string): string {
274
+ try {
275
+ return realpathSync(path);
276
+ } catch {
277
+ return path;
278
+ }
279
+ }
280
+
281
+ export function matchQuery(
282
+ session: SessionHeader,
283
+ query: string,
284
+ ): boolean {
285
+ const q = query.toLowerCase();
286
+ if (session.firstMessage.toLowerCase().includes(q)) return true;
287
+ if (session.name?.toLowerCase().includes(q)) return true;
288
+ if (session.cwd.toLowerCase().includes(q)) return true;
289
+ if (session.id.toLowerCase().includes(q)) return true;
290
+ return false;
291
+ }