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 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 16KB per file instead of the full JSONL — first results in **6ms**._
7
+ _Reads the head + tail of each file instead of the full JSONL — first results in **6ms**._
8
8
 
9
9
  [![pi extension](https://img.shields.io/badge/pi-extension-blueviolet)](https://github.com/earendil-works/pi-coding-agent)
10
10
  [![license](https://img.shields.io/badge/license-MIT-blue)](./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 never reads beyond the first 16KB of any session file. Headers, names, first messages — they all live in the first few lines. Everything after that is full message history the picker never shows. Search matches against the first message only (see [Known Limitations](#known-limitations)).
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 first 16KB** of the top 30 files — extract header, name, first user message (~6 ms)
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.2",
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
- return parseSessionFromBuffer(buf, bytesRead, meta.path, meta.mtimeMs, meta.size > PARTIAL_READ_SIZE);
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 {