pi-fast-resume 1.1.3 → 1.2.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 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 the head + tail of each file instead of the full JSONL — first results in **6ms**._
7
+ _Reads just enough of each file to show the title row — 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 the head + tail of each file instead of the full JSONL — first results
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 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)).
19
+ Same picker UI and keybindings as `/resume`. The difference is pi-fast-resume never parses the full JSONL. For each file it streams complete lines forward just until the first user message (the title), then reads a bounded tail near EOF to recover the latest session name (which pi appends 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
  ──────────────────────────────────────────────────────────
@@ -54,7 +54,7 @@ Tested with **1,771 sessions, 1.46 GB** of JSONL data on disk.
54
54
  | ------------------------------------------- | --------- | ------------------------------------------ |
55
55
  | `SessionManager.listAll()` (current) | ~5,600 ms | Full parse of every file |
56
56
  | DuckDB `read_ndjson` full query | ~2,560 ms | Still reads all 1.46 GB, but multithreaded |
57
- | Node.js partial read (16 KB/file) | ~730 ms | All 1,771 sessions |
57
+ | Node.js streaming partial read (this extension) | ~370 ms | All ~2,550 sessions; stops at first user message + bounded tail |
58
58
  | DuckDB persistent index (query all) | ~49 ms | After one-time build |
59
59
  | `node:sqlite` persistent index (query) | ~52 ms | Zero external deps |
60
60
  | **pi-fast-resume, first 30 sessions** | **~6 ms** | **Streaming display** |
@@ -165,7 +165,7 @@ Press `Tab` to switch to **all sessions** — shows every session pi knows about
165
165
  ## How it works
166
166
 
167
167
  ```
168
- stat() all .jsonl files ──────► sort by mtime ──────► read 16KB of top 30
168
+ stat() all .jsonl files ──────► sort by mtime ──────► stream top 30 forward
169
169
  (~100ms) (recent first) (~6ms)
170
170
 
171
171
 
@@ -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 head (16KB) + tail (≤8KB)** of the top 30 files extract header, first user message, and latest session name (~6 ms)
183
+ 3. **Stream each file forward** line by line until the first user message, then read a bounded tail at EOF for the latest rename 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
@@ -248,7 +248,7 @@ None optimize the `/resume` picker itself — they either still fully parse ever
248
248
 
249
249
  ## Known Limitations
250
250
 
251
- The 16KB partial-read tradeoff that gives pi-fast-resume its speed comes with one functional gap vs. the built-in `/resume`:
251
+ The partial-read tradeoff that gives pi-fast-resume its speed comes with functional gaps vs. the built-in `/resume`. The forward stream reads exactly as many bytes as the first user message needs (no fixed window), so oversized first messages — `<skill>` injections, long pastes, base64 images — are parsed correctly. A rename is recovered only if it lives within a bounded tail near EOF (32 KB by default); a rename buried under more continued activity than that falls back to `firstMessage`.
252
252
 
253
253
  | Area | Built-in `/resume` | pi-fast-resume | Impact |
254
254
  | ---- | ------------------ | -------------- | ------ |
package/fast-resume.ts CHANGED
@@ -1,9 +1,10 @@
1
1
  /**
2
2
  * pi-fast-resume — Fast session picker for pi
3
3
  *
4
- * Reads only the first 16KB of each session file (header + first messages)
5
- * instead of parsing the entire JSONL. Shows results instantly with
6
- * incremental background loading.
4
+ * Reads only enough of each session file to render the title row — streaming
5
+ * forward line by line until the first user message, plus a bounded tail near
6
+ * EOF for the latest rename name — instead of parsing the entire JSONL. Shows
7
+ * results instantly with incremental background loading.
7
8
  *
8
9
  * Mirrors the exact TUI layout and keybindings of pi's built-in /resume.
9
10
  *
@@ -36,10 +37,10 @@
36
37
  * exact phrase "node cve" case-insensitive substring
37
38
  * regex re:<pattern> RegExp search (case-insensitive)
38
39
  *
39
- * Note on search depth: pi-fast-resume only reads the first 16KB of each
40
- * session file, so search matches against id + name + firstMessage + cwd.
40
+ * Note on search depth: pi-fast-resume stops reading each file at the first
41
+ * user message, so search matches against id + name + firstMessage + cwd.
41
42
  * Upstream /resume matches against all messages (allMessagesText). This
42
- * tradeoff is by design — the 6ms load time depends on partial reads.
43
+ * tradeoff is by design — the fast load time depends on partial reads.
43
44
  */
44
45
 
45
46
  import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-fast-resume",
3
- "version": "1.1.3",
3
+ "version": "1.2.0",
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",
package/src/scanner.ts CHANGED
@@ -21,27 +21,76 @@ export interface SessionFileMeta {
21
21
  size: number;
22
22
  }
23
23
 
24
- const PARTIAL_READ_SIZE = 16_384;
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;
24
+ // I/O granularity for the streaming forward reader. This is pure performance
25
+ // tuning — it does NOT affect correctness. The reader assembles complete lines
26
+ // across chunks (a line is never truncated mid-JSON), so a line of any size is
27
+ // parsed correctly regardless of this value. It only controls how many bytes
28
+ // each readSync call fetches; smaller = more syscalls for big reads, larger =
29
+ // over-read for tiny sessions that stop early. 16KB is a reasonable middle.
30
+ const READ_CHUNK_SIZE = 16_384;
31
+
32
+ // Tail read for recovering the latest session_info (the rename name) near EOF.
33
+ //
34
+ // Why a tail read exists at all: pi appends session_info as a new line on every
35
+ // /rename (appendSessionInfo → appendFileSync). The forward pass stops at the
36
+ // first user message, which can be very early in a large file — so the latest
37
+ // rename often lives past the forward pass's stop point and must be recovered
38
+ // from the end of the file.
39
+ //
40
+ // Why the bound is this size and not "scan to EOF": scanning the whole file
41
+ // backward to find a session_info that may not exist (98% of sessions are never
42
+ // renamed) collapses pi-fast-resume's perf to pi-core's (~100× slower). So the
43
+ // tail is bounded. The bound only needs to cover "the rename line itself plus
44
+ // any continued activity written after the rename before reopening."
45
+ //
46
+ // Measurement (across 47 real renamed sessions on this system): the latest
47
+ // session_info was at EOF in 100% of cases — the rename was the last write
48
+ // before the session was reopened, every single time. 32KB therefore covers all
49
+ // observed renames with ~32,000× margin, and also covers a rename followed by
50
+ // up to ~32KB of continued activity (dozens of typical message turns) before
51
+ // reopening. A rename followed by more than this much continued activity is
52
+ // missed and falls back to firstMessage — the documented tradeoff vs a
53
+ // full-file scan.
54
+ const TAIL_READ_SIZE = 32_768;
55
+
56
+ // Defensive guard against a single pathological line with no newline (e.g. a
57
+ // corrupted file). Real pi sessions are newline-terminated JSONL, so this never
58
+ // triggers on valid input. It only caps memory for malformed input.
59
+ const MAX_LINE_BYTES = 256 * 1024 * 1024;
34
60
 
35
61
  // 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.
62
+ // found: false → no session_info seen in the tail; fall back to the forward name.
37
63
  // 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.
64
+ // the forward pass saw, since the tail starts past the forward
65
+ // stop); name is undefined if that entry explicitly cleared it.
40
66
  export interface TailSessionInfo {
41
67
  found: boolean;
42
68
  name?: string;
43
69
  }
44
70
 
71
+ // Accumulator state while processing complete session entries line by line.
72
+ // Shared by the pure parseSessionFromBuffer and the streaming loadSessionHeader
73
+ // so the per-entry logic exists in exactly one place.
74
+ interface SessionAccumulator {
75
+ header: { id: string; timestamp: string; cwd?: string; parentSession?: string } | null;
76
+ firstUserMessage: string;
77
+ messageCount: number;
78
+ name: string | undefined;
79
+ lastActivityTime: number | undefined;
80
+ foundFirstUser: boolean;
81
+ }
82
+
83
+ function newAccumulator(): SessionAccumulator {
84
+ return {
85
+ header: null,
86
+ firstUserMessage: "",
87
+ messageCount: 0,
88
+ name: undefined,
89
+ lastActivityTime: undefined,
90
+ foundFirstUser: false,
91
+ };
92
+ }
93
+
45
94
  function extractTextFromContent(
46
95
  content: string | Array<{ type: string; text?: string }>,
47
96
  ): string {
@@ -52,96 +101,88 @@ function extractTextFromContent(
52
101
  .join(" ");
53
102
  }
54
103
 
55
- export function parseSessionFromBuffer(
56
- buf: Buffer,
57
- bytesRead: number,
58
- filePath: string,
59
- mtimeMs: number,
60
- partial = false,
61
- tailInfo?: TailSessionInfo,
62
- ): SessionHeader | null {
63
- const decoder = new StringDecoder("utf8");
64
- const text = decoder.write(buf.subarray(0, bytesRead)) + decoder.end();
65
- const lines = text.split("\n");
66
-
67
- let header: { id: string; timestamp: string; cwd?: string; parentSession?: string } | null = null;
68
- let firstUserMsg = "";
69
- let name: string | undefined;
70
- let msgCount = 0;
71
- let lastActivityTime: number | undefined;
72
-
73
- for (const line of lines) {
74
- const trimmed = line.trim();
75
- if (!trimmed) continue;
76
- try {
77
- const entry = JSON.parse(trimmed);
78
- if (entry.type === "session") {
79
- header = entry;
80
- continue;
81
- }
82
- if (entry.type === "session_info") {
83
- name = entry.name?.trim() || undefined;
84
- }
85
- if (entry.type === "message") {
86
- msgCount++;
87
-
88
- // Track last activity time from user/assistant messages
89
- // Matches pi-core's getMessageActivityTime priority:
90
- // message.timestamp (number) > entry.timestamp (date string)
91
- const msg = entry.message;
92
- if (msg?.role === "user" || msg?.role === "assistant") {
93
- const msgTimestamp = msg.timestamp;
94
- if (typeof msgTimestamp === "number" && msgTimestamp > 0) {
95
- lastActivityTime = Math.max(lastActivityTime ?? 0, msgTimestamp);
96
- } else if (typeof entry.timestamp === "string") {
97
- const t = new Date(entry.timestamp).getTime();
98
- if (!Number.isNaN(t)) {
99
- lastActivityTime = Math.max(lastActivityTime ?? 0, t);
100
- }
101
- }
102
- }
104
+ // Process one complete entry line. Pure: mutates only `acc`. Malformed JSON (a
105
+ // line truncated at a read boundary, or genuinely corrupt input) is swallowed
106
+ // by the try/catch — callers only ever feed complete lines, so a parse failure
107
+ // here means the line is malformed and skipping it is correct.
108
+ function processEntry(acc: SessionAccumulator, line: string): void {
109
+ const trimmed = line.trim();
110
+ if (!trimmed) return;
111
+ let entry: any;
112
+ try {
113
+ entry = JSON.parse(trimmed);
114
+ } catch {
115
+ return;
116
+ }
117
+ if (typeof entry !== "object" || entry === null) return;
103
118
 
104
- if (!firstUserMsg && msg?.role === "user") {
105
- try {
106
- firstUserMsg = extractTextFromContent(msg.content);
107
- } catch {
108
- // Malformed content, skip
109
- }
119
+ if (entry.type === "session") {
120
+ acc.header = entry;
121
+ return;
122
+ }
123
+ if (entry.type === "session_info") {
124
+ // Latest session_info in file order wins, including explicit clears
125
+ // (empty/whitespace name → undefined). Matches pi-core's getSessionName().
126
+ acc.name = entry.name?.trim() || undefined;
127
+ return;
128
+ }
129
+ if (entry.type === "message") {
130
+ acc.messageCount++;
131
+
132
+ // Track last activity time. Matches pi-core's getMessageActivityTime
133
+ // priority: message.timestamp (number) > entry.timestamp (date string).
134
+ const msg = entry.message;
135
+ if (msg?.role === "user" || msg?.role === "assistant") {
136
+ const msgTimestamp = msg.timestamp;
137
+ if (typeof msgTimestamp === "number" && msgTimestamp > 0) {
138
+ acc.lastActivityTime = Math.max(acc.lastActivityTime ?? 0, msgTimestamp);
139
+ } else if (typeof entry.timestamp === "string") {
140
+ const t = Date.parse(entry.timestamp);
141
+ if (!Number.isNaN(t)) {
142
+ acc.lastActivityTime = Math.max(acc.lastActivityTime ?? 0, t);
110
143
  }
111
144
  }
112
- } catch {
113
- // Incomplete/truncated JSON at buffer boundary, skip
145
+ }
146
+
147
+ if (!acc.foundFirstUser && msg?.role === "user") {
148
+ acc.firstUserMessage = extractTextFromContent(msg.content);
149
+ acc.foundFirstUser = true;
114
150
  }
115
151
  }
152
+ }
116
153
 
154
+ // Build the SessionHeader from an accumulator. `reachedEof` is whether the
155
+ // forward pass consumed all input — when false (it stopped early at the first
156
+ // user message), lastActivityTime only reflects entries seen and is unreliable,
157
+ // so stat mtime is used instead (pi updates it on every append, so it tracks
158
+ // the true last write time). `tailInfo`, if present, carries the latest
159
+ // session_info from a tail read and wins over the forward name (later in file
160
+ // order), including explicit name clears.
161
+ function buildHeader(
162
+ acc: SessionAccumulator,
163
+ filePath: string,
164
+ mtimeMs: number,
165
+ reachedEof: boolean,
166
+ tailInfo?: TailSessionInfo,
167
+ ): SessionHeader | null {
168
+ const header = acc.header;
117
169
  if (!header) return null;
118
170
 
119
- // Determine modified time:
120
- // - Full read: use pi-core's priority (message timestamp > header timestamp > stat mtime).
121
- // lastActivityTime is accurate since we saw every message.
122
- // - Partial read: stat mtime is more reliable than a partial lastActivityTime,
123
- // which only reflects messages in the first 16KB and may severely underestimate
124
- // the true last activity for large sessions.
125
- const headerTime = typeof header.timestamp === "string" ? new Date(header.timestamp).getTime() : NaN;
171
+ const name = tailInfo?.found ? tailInfo.name : acc.name;
172
+
173
+ const headerTime = Date.parse(header.timestamp);
126
174
  let modified: Date;
127
- if (partial) {
128
- // Partial read — stat mtime is the most reliable signal
175
+ if (!reachedEof) {
176
+ // Partial read — stat mtime is the only reliable signal.
129
177
  modified = new Date(mtimeMs);
178
+ } else if (typeof acc.lastActivityTime === "number" && acc.lastActivityTime > 0) {
179
+ modified = new Date(acc.lastActivityTime);
180
+ } else if (!Number.isNaN(headerTime)) {
181
+ modified = new Date(headerTime);
130
182
  } else {
131
- // Full read — pi-core's priority chain
132
- modified =
133
- typeof lastActivityTime === "number" && lastActivityTime > 0
134
- ? new Date(lastActivityTime)
135
- : !Number.isNaN(headerTime)
136
- ? new Date(headerTime)
137
- : new Date(mtimeMs);
183
+ modified = new Date(mtimeMs);
138
184
  }
139
185
 
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
-
145
186
  return {
146
187
  path: filePath,
147
188
  id: header.id,
@@ -149,12 +190,39 @@ export function parseSessionFromBuffer(
149
190
  parentSessionPath: header.parentSession || undefined,
150
191
  created: new Date(header.timestamp),
151
192
  modified,
152
- messageCount: msgCount,
153
- firstMessage: firstUserMsg || "(no messages)",
154
- name: finalName,
193
+ messageCount: acc.messageCount,
194
+ firstMessage: acc.firstUserMessage || "(no messages)",
195
+ name,
155
196
  };
156
197
  }
157
198
 
199
+ // Pure parser over a buffer containing complete (or complete-prefix) entry
200
+ // lines. Splits on \n and runs processEntry on each line; the last line may be
201
+ // truncated at the buffer boundary (its JSON.parse fails and it is skipped).
202
+ // `partial` means the buffer does not contain the whole file — when true,
203
+ // modified time falls back to stat mtime (the buffer's lastActivityTime only
204
+ // reflects the prefix). `tailInfo` carries a tail-read latest session_info that
205
+ // overrides the buffer's name.
206
+ //
207
+ // Kept as a pure, synchronous, fd-free function for direct testing and
208
+ // callers that already have the bytes. loadSessionHeader (the production path)
209
+ // uses the streaming reader below so it never truncates a line mid-JSON.
210
+ export function parseSessionFromBuffer(
211
+ buf: Buffer,
212
+ bytesRead: number,
213
+ filePath: string,
214
+ mtimeMs: number,
215
+ partial = false,
216
+ tailInfo?: TailSessionInfo,
217
+ ): SessionHeader | null {
218
+ const decoder = new StringDecoder("utf8");
219
+ const text = decoder.write(buf.subarray(0, bytesRead)) + decoder.end();
220
+ const lines = text.split("\n");
221
+ const acc = newAccumulator();
222
+ for (const line of lines) processEntry(acc, line);
223
+ return buildHeader(acc, filePath, mtimeMs, !partial, tailInfo);
224
+ }
225
+
158
226
  // Scan a tail chunk (read from near EOF) for session_info entries and return
159
227
  // the latest name, matching pi-core's getSessionName() semantics: the latest
160
228
  // session_info in file order wins, including explicit clears (empty name).
@@ -174,7 +242,7 @@ export function scanTailForSessionInfo(
174
242
  if (!trimmed) continue;
175
243
  try {
176
244
  const entry = JSON.parse(trimmed);
177
- if (entry.type === "session_info") {
245
+ if (typeof entry === "object" && entry !== null && entry.type === "session_info") {
178
246
  found = true;
179
247
  name = entry.name?.trim() || undefined;
180
248
  }
@@ -185,6 +253,68 @@ export function scanTailForSessionInfo(
185
253
  return { found, name };
186
254
  }
187
255
 
256
+ // Read complete lines forward from `fd` starting at offset 0. Calls onLine for
257
+ // each complete (newline-terminated) line. Reading stops when onLine returns
258
+ // false, or at EOF. Returns whether EOF was reached (i.e. the caller did not
259
+ // stop early) and the byte offset just past the last emitted line's newline —
260
+ // the caller uses this as the lower bound for any tail scan so the tail never
261
+ // re-reads already-covered bytes.
262
+ //
263
+ // Chunk-based I/O with a StringDecoder so multi-byte UTF-8 sequences split
264
+ // across chunk boundaries decode correctly. The in-memory line buffer grows to
265
+ // fit the longest single line (bounded by MAX_LINE_BYTES against malformed
266
+ // input); real entries are newline-terminated so this is unbounded only for
267
+ // corrupt files.
268
+ function forEachLineForward(
269
+ fd: number,
270
+ size: number,
271
+ onLine: (line: string) => boolean | void,
272
+ ): { reachedEof: boolean; consumedBytes: number } {
273
+ const decoder = new StringDecoder("utf8");
274
+ const chunk = Buffer.alloc(READ_CHUNK_SIZE);
275
+ let lineBuf = "";
276
+ let offset = 0;
277
+ let consumedBytes = 0;
278
+
279
+ const flushLine = (line: string): boolean | void => {
280
+ consumedBytes += Buffer.byteLength(line, "utf8") + 1; // +1 for \n
281
+ return onLine(line);
282
+ };
283
+
284
+ while (offset < size) {
285
+ const toRead = Math.min(READ_CHUNK_SIZE, size - offset);
286
+ const bytesRead = readSync(fd, chunk, 0, toRead, offset);
287
+ if (bytesRead <= 0) break;
288
+ offset += bytesRead;
289
+
290
+ const text = decoder.write(chunk.subarray(0, bytesRead));
291
+ let start = 0;
292
+ let nl: number;
293
+ while ((nl = text.indexOf("\n", start)) !== -1) {
294
+ const line = lineBuf + text.slice(start, nl);
295
+ lineBuf = "";
296
+ if (flushLine(line) === false) {
297
+ return { reachedEof: false, consumedBytes };
298
+ }
299
+ start = nl + 1;
300
+ }
301
+ lineBuf += text.slice(start);
302
+
303
+ // Defensive: bound memory for a single pathological line.
304
+ if (lineBuf.length > MAX_LINE_BYTES) {
305
+ lineBuf = "";
306
+ }
307
+ }
308
+ // Flush decoder + any trailing line without a final newline.
309
+ const tail = decoder.end();
310
+ if (tail) lineBuf += tail;
311
+ if (lineBuf.length > 0) {
312
+ consumedBytes += Buffer.byteLength(lineBuf, "utf8"); // no trailing \n
313
+ onLine(lineBuf);
314
+ }
315
+ return { reachedEof: true, consumedBytes };
316
+ }
317
+
188
318
  const HOME = homedir();
189
319
  const DEFAULT_SESSIONS_DIR = join(HOME, ".pi", "agent", "sessions");
190
320
 
@@ -267,44 +397,57 @@ export function scanSessionDir(
267
397
  return results;
268
398
  }
269
399
 
400
+ // Load a session header from disk using a streaming forward read plus a bounded
401
+ // tail read — no fixed head window.
402
+ //
403
+ // Forward pass: reads complete lines from the start and stops at the first user
404
+ // message (which is all the title row needs). This reads exactly as many bytes
405
+ // as the first user message requires — a few KB for a normal session, ~19KB
406
+ // for a <skill> injection, more for a base64 image — and never truncates a line
407
+ // mid-JSON the way a fixed byte window would. So oversized first user messages
408
+ // (the cases that used to show "(no messages)") are now parsed correctly.
409
+ //
410
+ // Tail pass (only when the forward pass stopped before EOF): reads up to
411
+ // TAIL_READ_SIZE bytes from EOF and recovers the latest session_info (the
412
+ // rename name), bounded below by the forward stop offset so it never re-reads
413
+ // covered bytes. See TAIL_READ_SIZE for the documented tradeoff.
270
414
  export function loadSessionHeader(
271
415
  meta: SessionFileMeta,
272
416
  ): SessionHeader | null {
273
417
  let fd: number | undefined;
274
418
  try {
275
419
  fd = openSync(meta.path, "r");
276
- const readSize = Math.min(PARTIAL_READ_SIZE, meta.size);
277
- const buf = Buffer.alloc(readSize);
278
- const bytesRead = readSync(fd, buf, 0, readSize, 0);
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.
420
+ const acc = newAccumulator();
421
+
422
+ // Forward pass: read complete lines, stopping at the first user message.
423
+ const { reachedEof: forwardReachedEof, consumedBytes } = forEachLineForward(
424
+ fd,
425
+ meta.size,
426
+ (line) => {
427
+ processEntry(acc, line);
428
+ if (acc.header && acc.foundFirstUser) return false;
429
+ return true;
430
+ },
431
+ );
432
+
433
+ // If the forward pass stopped before EOF, recover the latest session_info
434
+ // from a bounded tail at EOF. Bounded below by consumedBytes so it never
435
+ // re-parses already-seen entries; the tail wins over the forward name
436
+ // (later in file order). A failure here falls back to the forward name.
287
437
  let tailInfo: TailSessionInfo | undefined;
288
- if (meta.size > PARTIAL_READ_SIZE) {
438
+ if (!forwardReachedEof) {
289
439
  try {
290
- const tailReadSize = Math.min(TAIL_READ_SIZE, meta.size - PARTIAL_READ_SIZE);
440
+ const tailReadSize = Math.min(TAIL_READ_SIZE, meta.size - consumedBytes);
291
441
  const tailBuf = Buffer.alloc(tailReadSize);
292
442
  const tailOffset = meta.size - tailReadSize;
293
443
  const tailBytesRead = readSync(fd, tailBuf, 0, tailReadSize, tailOffset);
294
444
  tailInfo = scanTailForSessionInfo(tailBuf, tailBytesRead);
295
445
  } catch {
296
- // Tail read failed — fall back to head-only parse
446
+ // Tail read failed — fall back to forward-only name
297
447
  }
298
448
  }
299
449
 
300
- return parseSessionFromBuffer(
301
- buf,
302
- bytesRead,
303
- meta.path,
304
- meta.mtimeMs,
305
- meta.size > PARTIAL_READ_SIZE,
306
- tailInfo,
307
- );
450
+ return buildHeader(acc, meta.path, meta.mtimeMs, forwardReachedEof, tailInfo);
308
451
  } catch {
309
452
  return null;
310
453
  } finally {
@@ -370,4 +513,4 @@ export function matchQuery(
370
513
  if (session.cwd.toLowerCase().includes(q)) return true;
371
514
  if (session.id.toLowerCase().includes(q)) return true;
372
515
  return false;
373
- }
516
+ }
package/src/search.ts CHANGED
@@ -38,10 +38,11 @@ function normalizeWhitespaceLower(text: string): string {
38
38
  * Build the searchable text for a session.
39
39
  *
40
40
  * IMPORTANT LIMITATION: pi-core's /resume builds `allMessagesText` from the
41
- * full file, so search matches against every message in the session. We only
42
- * read the first 16KB, so we search against `firstMessage` instead. This means
43
- * queries like `fix oauth` won't match a session where that phrase appears in
44
- * the 5th message but not the 1st. Name, id, and cwd matches are unaffected.
41
+ * full file, so search matches against every message in the session. We stop
42
+ * reading at the first user message, so we search against `firstMessage`
43
+ * instead. This means queries like `fix oauth` won't match a session where
44
+ * that phrase appears in the 5th message but not the 1st. Name, id, and cwd
45
+ * matches are unaffected.
45
46
  *
46
47
  * See README "Known Limitations" section for the user-facing explanation.
47
48
  */