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/README.md +219 -0
- package/fast-resume.ts +1072 -0
- package/package.json +56 -0
- package/src/index.ts +23 -0
- package/src/picker-state.ts +95 -0
- package/src/scanner.ts +291 -0
- package/src/search.ts +310 -0
- package/vitest.config.ts +15 -0
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
|
+
}
|