pi-fast-resume 1.1.2 → 1.1.3
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 +3 -3
- package/package.json +9 -9
- package/src/scanner.ts +85 -2
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
**Instant session picker for [pi](https://github.com/earendil-works/pi-coding-agent)**
|
|
6
6
|
|
|
7
|
-
_Reads
|
|
7
|
+
_Reads the head + tail of each file instead of the full JSONL — first results in **6ms**._
|
|
8
8
|
|
|
9
9
|
[](https://github.com/earendil-works/pi-coding-agent)
|
|
10
10
|
[](./LICENSE)
|
|
@@ -16,7 +16,7 @@ _Reads 16KB per file instead of the full JSONL — first results in **6ms**._
|
|
|
16
16
|
> **`/resume` takes 5.6 seconds** when you have 1,700+ sessions.
|
|
17
17
|
> pi-fast-resume's `/fast-resume` takes **6 milliseconds**.
|
|
18
18
|
|
|
19
|
-
Same picker UI and keybindings as `/resume`. The difference is pi-fast-resume
|
|
19
|
+
Same picker UI and keybindings as `/resume`. The difference is pi-fast-resume reads at most ~24KB of each file (a 16KB head + up to 8KB tail) instead of the full JSONL. The head carries the header and first user message; the tail recovers the latest session name, which pi appends at EOF on `/rename`. Everything between is full message history the picker never shows. Search matches against the first message only (see [Known Limitations](#known-limitations)).
|
|
20
20
|
|
|
21
21
|
```
|
|
22
22
|
──────────────────────────────────────────────────────────
|
|
@@ -180,7 +180,7 @@ stat() all .jsonl files ──────► sort by mtime ──────
|
|
|
180
180
|
|
|
181
181
|
1. **`stat()` all session files** — collect paths and mtimes (~100 ms for 1,700 files)
|
|
182
182
|
2. **Sort by mtime descending** — most recent sessions first
|
|
183
|
-
3. **Read
|
|
183
|
+
3. **Read head (16KB) + tail (≤8KB)** of the top 30 files — extract header, first user message, and latest session name (~6 ms)
|
|
184
184
|
4. **Show picker** — user can navigate, filter, and select immediately
|
|
185
185
|
5. **Background load** — remaining sessions stream in batches of 50, non-blocking
|
|
186
186
|
6. **Tab to switch scope** — filter to current project or show everything
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-fast-resume",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.3",
|
|
4
4
|
"description": "Fast session picker for pi — reads headers + first messages from 16KB partial reads instead of full-file parsing",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"author": "Tom X Nguyen",
|
|
@@ -29,13 +29,6 @@
|
|
|
29
29
|
"src/",
|
|
30
30
|
"README.md"
|
|
31
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
32
|
"devDependencies": {
|
|
40
33
|
"@earendil-works/pi-coding-agent": "0.79.4",
|
|
41
34
|
"@earendil-works/pi-tui": "0.79.4",
|
|
@@ -52,5 +45,12 @@
|
|
|
52
45
|
},
|
|
53
46
|
"overrides": {
|
|
54
47
|
"brace-expansion": "5.0.6"
|
|
48
|
+
},
|
|
49
|
+
"scripts": {
|
|
50
|
+
"test": "vitest run",
|
|
51
|
+
"test:watch": "vitest",
|
|
52
|
+
"test:coverage": "vitest run --coverage",
|
|
53
|
+
"typecheck": "tsc --noEmit",
|
|
54
|
+
"lint:dead": "knip --no-gitignore"
|
|
55
55
|
}
|
|
56
|
-
}
|
|
56
|
+
}
|
package/src/scanner.ts
CHANGED
|
@@ -23,6 +23,25 @@ export interface SessionFileMeta {
|
|
|
23
23
|
|
|
24
24
|
const PARTIAL_READ_SIZE = 16_384;
|
|
25
25
|
|
|
26
|
+
// Size of the trailing read used to recover session_info entries (session
|
|
27
|
+
// names set via /rename or programmatically). These are appended at EOF by
|
|
28
|
+
// SessionManager.appendSessionInfo, so for any session larger than the head
|
|
29
|
+
// window the latest name lands past the partial read and would be invisible —
|
|
30
|
+
// showing "(no messages)" for renamed large sessions. The tail read recovers
|
|
31
|
+
// it, matching pi-core's getSessionName() semantics (latest session_info wins,
|
|
32
|
+
// including explicit name clears).
|
|
33
|
+
const TAIL_READ_SIZE = 8_192;
|
|
34
|
+
|
|
35
|
+
// Result of scanning a file tail for session_info entries.
|
|
36
|
+
// found: false → no session_info seen in the tail; fall back to the head's name.
|
|
37
|
+
// found: true → a session_info was seen (later in file order than anything
|
|
38
|
+
// in the head, since the tail starts past the head window);
|
|
39
|
+
// name is undefined if that entry explicitly cleared the name.
|
|
40
|
+
export interface TailSessionInfo {
|
|
41
|
+
found: boolean;
|
|
42
|
+
name?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
26
45
|
function extractTextFromContent(
|
|
27
46
|
content: string | Array<{ type: string; text?: string }>,
|
|
28
47
|
): string {
|
|
@@ -39,6 +58,7 @@ export function parseSessionFromBuffer(
|
|
|
39
58
|
filePath: string,
|
|
40
59
|
mtimeMs: number,
|
|
41
60
|
partial = false,
|
|
61
|
+
tailInfo?: TailSessionInfo,
|
|
42
62
|
): SessionHeader | null {
|
|
43
63
|
const decoder = new StringDecoder("utf8");
|
|
44
64
|
const text = decoder.write(buf.subarray(0, bytesRead)) + decoder.end();
|
|
@@ -117,6 +137,11 @@ export function parseSessionFromBuffer(
|
|
|
117
137
|
: new Date(mtimeMs);
|
|
118
138
|
}
|
|
119
139
|
|
|
140
|
+
// The tail scan (if any) sees session_info entries at EOF, which are later
|
|
141
|
+
// in file order than anything in the head window — so it wins over the
|
|
142
|
+
// head-derived name, including explicit clears (empty name → undefined).
|
|
143
|
+
const finalName = tailInfo?.found ? tailInfo.name : name;
|
|
144
|
+
|
|
120
145
|
return {
|
|
121
146
|
path: filePath,
|
|
122
147
|
id: header.id,
|
|
@@ -126,10 +151,40 @@ export function parseSessionFromBuffer(
|
|
|
126
151
|
modified,
|
|
127
152
|
messageCount: msgCount,
|
|
128
153
|
firstMessage: firstUserMsg || "(no messages)",
|
|
129
|
-
name,
|
|
154
|
+
name: finalName,
|
|
130
155
|
};
|
|
131
156
|
}
|
|
132
157
|
|
|
158
|
+
// Scan a tail chunk (read from near EOF) for session_info entries and return
|
|
159
|
+
// the latest name, matching pi-core's getSessionName() semantics: the latest
|
|
160
|
+
// session_info in file order wins, including explicit clears (empty name).
|
|
161
|
+
// The tail's first line may be a partial line cut off at the read-start
|
|
162
|
+
// boundary — it is skipped naturally when JSON.parse fails.
|
|
163
|
+
export function scanTailForSessionInfo(
|
|
164
|
+
buf: Buffer,
|
|
165
|
+
bytesRead: number,
|
|
166
|
+
): TailSessionInfo {
|
|
167
|
+
const decoder = new StringDecoder("utf8");
|
|
168
|
+
const text = decoder.write(buf.subarray(0, bytesRead)) + decoder.end();
|
|
169
|
+
const lines = text.split("\n");
|
|
170
|
+
let found = false;
|
|
171
|
+
let name: string | undefined;
|
|
172
|
+
for (const line of lines) {
|
|
173
|
+
const trimmed = line.trim();
|
|
174
|
+
if (!trimmed) continue;
|
|
175
|
+
try {
|
|
176
|
+
const entry = JSON.parse(trimmed);
|
|
177
|
+
if (entry.type === "session_info") {
|
|
178
|
+
found = true;
|
|
179
|
+
name = entry.name?.trim() || undefined;
|
|
180
|
+
}
|
|
181
|
+
} catch {
|
|
182
|
+
// Partial line at tail boundary — skip
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return { found, name };
|
|
186
|
+
}
|
|
187
|
+
|
|
133
188
|
const HOME = homedir();
|
|
134
189
|
const DEFAULT_SESSIONS_DIR = join(HOME, ".pi", "agent", "sessions");
|
|
135
190
|
|
|
@@ -221,7 +276,35 @@ export function loadSessionHeader(
|
|
|
221
276
|
const readSize = Math.min(PARTIAL_READ_SIZE, meta.size);
|
|
222
277
|
const buf = Buffer.alloc(readSize);
|
|
223
278
|
const bytesRead = readSync(fd, buf, 0, readSize, 0);
|
|
224
|
-
|
|
279
|
+
|
|
280
|
+
// Recover the latest session name from EOF. session_info entries are
|
|
281
|
+
// appended at EOF by SessionManager.appendSessionInfo, so for any session
|
|
282
|
+
// larger than the head window they live past the 16KB read and would be
|
|
283
|
+
// invisible. The tail read covers `meta.size - PARTIAL_READ_SIZE` bytes
|
|
284
|
+
// (capped at TAIL_READ_SIZE), starting past the head window so it never
|
|
285
|
+
// overlaps. A failure here must not lose the whole header — fall back to
|
|
286
|
+
// head-only parsing by leaving tailInfo undefined.
|
|
287
|
+
let tailInfo: TailSessionInfo | undefined;
|
|
288
|
+
if (meta.size > PARTIAL_READ_SIZE) {
|
|
289
|
+
try {
|
|
290
|
+
const tailReadSize = Math.min(TAIL_READ_SIZE, meta.size - PARTIAL_READ_SIZE);
|
|
291
|
+
const tailBuf = Buffer.alloc(tailReadSize);
|
|
292
|
+
const tailOffset = meta.size - tailReadSize;
|
|
293
|
+
const tailBytesRead = readSync(fd, tailBuf, 0, tailReadSize, tailOffset);
|
|
294
|
+
tailInfo = scanTailForSessionInfo(tailBuf, tailBytesRead);
|
|
295
|
+
} catch {
|
|
296
|
+
// Tail read failed — fall back to head-only parse
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return parseSessionFromBuffer(
|
|
301
|
+
buf,
|
|
302
|
+
bytesRead,
|
|
303
|
+
meta.path,
|
|
304
|
+
meta.mtimeMs,
|
|
305
|
+
meta.size > PARTIAL_READ_SIZE,
|
|
306
|
+
tailInfo,
|
|
307
|
+
);
|
|
225
308
|
} catch {
|
|
226
309
|
return null;
|
|
227
310
|
} finally {
|