pi-fast-resume 1.1.3 → 1.3.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 +6 -6
- package/fast-resume.ts +113 -8
- package/package.json +1 -1
- package/src/index.ts +17 -2
- package/src/scanner.ts +326 -117
- package/src/search.ts +5 -4
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 just enough of each file to show the title row — first results in **6ms**._
|
|
8
8
|
|
|
9
9
|
[](https://github.com/earendil-works/pi-coding-agent)
|
|
10
10
|
[](./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
|
|
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 (
|
|
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 ──────►
|
|
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. **
|
|
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
|
|
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
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
|
40
|
-
*
|
|
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
|
|
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";
|
|
@@ -71,6 +72,8 @@ import {
|
|
|
71
72
|
scanAllSessionDirs,
|
|
72
73
|
scanSessionDir,
|
|
73
74
|
loadSessionHeaders,
|
|
75
|
+
loadSessionHeadersForward,
|
|
76
|
+
resolveSessionName,
|
|
74
77
|
sortByModified,
|
|
75
78
|
sortByModifiedDesc,
|
|
76
79
|
filterByCwd,
|
|
@@ -132,7 +135,7 @@ function loadCurrentSessionsImmediate(
|
|
|
132
135
|
): SessionHeader[] {
|
|
133
136
|
if (!sessionDir) return [];
|
|
134
137
|
const metas = sortByModifiedDesc(scanSessionDir(sessionDir));
|
|
135
|
-
let headers =
|
|
138
|
+
let headers = loadSessionHeadersForward(metas);
|
|
136
139
|
if (!usesDefaultSessionDir) {
|
|
137
140
|
// Custom session dirs may contain sessions from multiple cwds; filter to
|
|
138
141
|
// the current one, matching SessionManager.list behavior.
|
|
@@ -674,6 +677,17 @@ class FastResumePicker extends Container {
|
|
|
674
677
|
private loadingAbort: AbortController | null = null;
|
|
675
678
|
private allLoadSeq = 0;
|
|
676
679
|
|
|
680
|
+
// Deferred rename-name resolution. The picker displays rows immediately
|
|
681
|
+
// with forward-only headers (fast: ~80ms for 2.5k sessions); the latest rename
|
|
682
|
+
// name (which pi appends at EOF, past the forward stop) is resolved per file
|
|
683
|
+
// in the background and applied in-place, so a row's name pops in without
|
|
684
|
+
// blocking the initial render. See resolveSessionName in scanner.ts.
|
|
685
|
+
private metaByPath = new Map<string, SessionFileMeta>();
|
|
686
|
+
private nameResolveQueue: SessionFileMeta[] = [];
|
|
687
|
+
private nameResolveScheduled = false;
|
|
688
|
+
private nameResolveSeq = 0;
|
|
689
|
+
private nameResolvedPaths = new Set<string>();
|
|
690
|
+
|
|
677
691
|
private mode: "list" | "rename" = "list";
|
|
678
692
|
private renameTargetPath: string | null = null;
|
|
679
693
|
|
|
@@ -756,16 +770,19 @@ class FastResumePicker extends Container {
|
|
|
756
770
|
this.sessionList.onSelect = (sessionPath) => {
|
|
757
771
|
this.header.clearStatusTimeout();
|
|
758
772
|
this.loadingAbort?.abort();
|
|
773
|
+
this.nameResolveSeq++; // cancel any pending name-resolution ticks
|
|
759
774
|
this.done({ sessionPath, cancelled: false });
|
|
760
775
|
};
|
|
761
776
|
this.sessionList.onCancel = () => {
|
|
762
777
|
this.header.clearStatusTimeout();
|
|
763
778
|
this.loadingAbort?.abort();
|
|
779
|
+
this.nameResolveSeq++;
|
|
764
780
|
this.done({ cancelled: true });
|
|
765
781
|
};
|
|
766
782
|
this.sessionList.onExit = () => {
|
|
767
783
|
this.header.clearStatusTimeout();
|
|
768
784
|
this.loadingAbort?.abort();
|
|
785
|
+
this.nameResolveSeq++;
|
|
769
786
|
this.done({ cancelled: true });
|
|
770
787
|
};
|
|
771
788
|
this.sessionList.onToggleScope = () => this.toggleScope();
|
|
@@ -817,6 +834,17 @@ class FastResumePicker extends Container {
|
|
|
817
834
|
// Build layout
|
|
818
835
|
this.buildBaseLayout(this.sessionList);
|
|
819
836
|
|
|
837
|
+
// Build the path → meta lookup from allMetas (which is a superset of the
|
|
838
|
+
// current-scope metas both for the default and custom-dir cases), then
|
|
839
|
+
// enqueue the current-scope sessions for background rename-name resolution.
|
|
840
|
+
// Rows are already visible with the correct firstMessage; names populate
|
|
841
|
+
// in-place as their tails resolve.
|
|
842
|
+
for (const m of allMetas) this.metaByPath.set(m.path, m);
|
|
843
|
+
const currentMetas = initialCurrentSessions
|
|
844
|
+
.map((s) => this.metaByPath.get(s.path))
|
|
845
|
+
.filter((m): m is SessionFileMeta => !!m);
|
|
846
|
+
this.enqueueNameResolution(currentMetas);
|
|
847
|
+
|
|
820
848
|
// Start loading current sessions (mark as loaded since we already have them)
|
|
821
849
|
this.currentLoading = false;
|
|
822
850
|
this.header.loading = false;
|
|
@@ -956,7 +984,10 @@ class FastResumePicker extends Container {
|
|
|
956
984
|
|
|
957
985
|
let headers: SessionHeader[];
|
|
958
986
|
try {
|
|
959
|
-
|
|
987
|
+
// Forward-only: the rename name will resolve in the background via
|
|
988
|
+
// resolveSessionName (enqueued below), so rows appear with the correct
|
|
989
|
+
// firstMessage immediately and names populate in-place.
|
|
990
|
+
headers = loadSessionHeadersForward(batch);
|
|
960
991
|
} catch (err) {
|
|
961
992
|
const message = err instanceof Error ? err.message : String(err);
|
|
962
993
|
this.allLoading = false;
|
|
@@ -969,6 +1000,10 @@ class FastResumePicker extends Container {
|
|
|
969
1000
|
}
|
|
970
1001
|
|
|
971
1002
|
allParsed.push(...headers);
|
|
1003
|
+
// Enqueue this batch's metas for background rename-name resolution.
|
|
1004
|
+
// Names will be applied in-place as they resolve; if the user is viewing
|
|
1005
|
+
// "all" scope, newly-named rows also reflect in the active list.
|
|
1006
|
+
this.enqueueNameResolution(batch);
|
|
972
1007
|
|
|
973
1008
|
// If we're currently showing "all" scope, update progress
|
|
974
1009
|
if (this.scope === "all") {
|
|
@@ -985,6 +1020,76 @@ class FastResumePicker extends Container {
|
|
|
985
1020
|
setImmediate(loadBatch);
|
|
986
1021
|
}
|
|
987
1022
|
|
|
1023
|
+
// Enqueue session file metas for background rename-name resolution. Each
|
|
1024
|
+
// path is resolved at most once (deduped via nameResolvedPaths); repeated
|
|
1025
|
+
// enqueues for the same path are no-ops. Safe to call for the current-scope
|
|
1026
|
+
// sessions at construction and for each batch of the all-scope background load.
|
|
1027
|
+
private enqueueNameResolution(metas: SessionFileMeta[]): void {
|
|
1028
|
+
for (const m of metas) {
|
|
1029
|
+
if (this.nameResolvedPaths.has(m.path)) continue;
|
|
1030
|
+
this.nameResolvedPaths.add(m.path);
|
|
1031
|
+
this.nameResolveQueue.push(m);
|
|
1032
|
+
}
|
|
1033
|
+
this.scheduleNameResolution();
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
private scheduleNameResolution(): void {
|
|
1037
|
+
if (this.nameResolveScheduled) return;
|
|
1038
|
+
this.nameResolveScheduled = true;
|
|
1039
|
+
setImmediate(() => this.drainNameResolution());
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
// Resolve one cooperative batch of rename names (up to 50 per tick), apply
|
|
1043
|
+
// any found names in-place, and re-render once for the whole batch. Yields
|
|
1044
|
+
// between batches so input stays responsive even while thousands of tail
|
|
1045
|
+
// reads resolve. Aborts cleanly on select/cancel/exit via nameResolveSeq.
|
|
1046
|
+
private drainNameResolution(): void {
|
|
1047
|
+
this.nameResolveScheduled = false;
|
|
1048
|
+
if (this.loadingAbort?.signal.aborted) return;
|
|
1049
|
+
const seq = this.nameResolveSeq;
|
|
1050
|
+
const BATCH = 50;
|
|
1051
|
+
const batch = this.nameResolveQueue.splice(0, BATCH);
|
|
1052
|
+
if (batch.length === 0) return;
|
|
1053
|
+
|
|
1054
|
+
let updatedAny = false;
|
|
1055
|
+
for (const meta of batch) {
|
|
1056
|
+
if (seq !== this.nameResolveSeq) return; // stale — picker exited/aborted
|
|
1057
|
+
const result = resolveSessionName(meta);
|
|
1058
|
+
if (result.found && this.applyNameUpdate(meta.path, result.name)) {
|
|
1059
|
+
updatedAny = true;
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
if (updatedAny) {
|
|
1064
|
+
const sessions = this.scope === "all" ? (this.allSessions ?? []) : (this.currentSessions ?? []);
|
|
1065
|
+
const showCwd = this.scope === "all";
|
|
1066
|
+
this.sessionList.setSessions(sessions, showCwd);
|
|
1067
|
+
this.tuiRequestRender();
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
if (this.nameResolveQueue.length > 0) this.scheduleNameResolution();
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
// Apply a resolved name to the session with the given path in both the
|
|
1074
|
+
// current- and all-scope caches. The same logical session may appear as
|
|
1075
|
+
// distinct objects in the two caches, so both are updated. Returns whether a
|
|
1076
|
+
// session was found and updated (so the caller can batch re-renders).
|
|
1077
|
+
private applyNameUpdate(path: string, name: string | undefined): boolean {
|
|
1078
|
+
let updated = false;
|
|
1079
|
+
const updateArr = (arr: SessionHeader[] | null) => {
|
|
1080
|
+
if (!arr) return;
|
|
1081
|
+
for (const s of arr) {
|
|
1082
|
+
if (s.path === path) {
|
|
1083
|
+
s.name = name;
|
|
1084
|
+
updated = true;
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
};
|
|
1088
|
+
updateArr(this.currentSessions);
|
|
1089
|
+
updateArr(this.allSessions);
|
|
1090
|
+
return updated;
|
|
1091
|
+
}
|
|
1092
|
+
|
|
988
1093
|
private toggleScope(): void {
|
|
989
1094
|
if (this.scope === "current") {
|
|
990
1095
|
this.scope = "all";
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
|
-
export {
|
|
2
|
-
|
|
1
|
+
export {
|
|
2
|
+
parseSessionFromBuffer,
|
|
3
|
+
scanTailForSessionInfo,
|
|
4
|
+
loadSessionHeader,
|
|
5
|
+
loadSessionHeaderForward,
|
|
6
|
+
loadSessionHeaders,
|
|
7
|
+
loadSessionHeadersForward,
|
|
8
|
+
resolveSessionName,
|
|
9
|
+
scanAllSessionDirs,
|
|
10
|
+
scanSessionDir,
|
|
11
|
+
sortByModified,
|
|
12
|
+
sortByModifiedDesc,
|
|
13
|
+
filterByCwd,
|
|
14
|
+
matchQuery,
|
|
15
|
+
canonicalizePath,
|
|
16
|
+
} from "./scanner.js";
|
|
17
|
+
export type { SessionHeader, SessionFileMeta, TailSessionInfo } from "./scanner.js";
|
|
3
18
|
export {
|
|
4
19
|
parseSearchQuery,
|
|
5
20
|
matchSession,
|
package/src/scanner.ts
CHANGED
|
@@ -21,27 +21,76 @@ export interface SessionFileMeta {
|
|
|
21
21
|
size: number;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
//
|
|
33
|
-
|
|
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
|
|
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
|
-
//
|
|
39
|
-
// name is undefined if that entry explicitly cleared
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
}
|
|
113
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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 (
|
|
128
|
-
// Partial read — stat mtime is the
|
|
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
|
-
|
|
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:
|
|
153
|
-
firstMessage:
|
|
154
|
-
name
|
|
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,109 @@ export function scanSessionDir(
|
|
|
267
397
|
return results;
|
|
268
398
|
}
|
|
269
399
|
|
|
400
|
+
// Forward-only load: reads complete lines from the start and stops at the
|
|
401
|
+
// first user message (which is all the title row needs). This reads exactly as
|
|
402
|
+
// many bytes as the first user message requires — a few KB for a normal
|
|
403
|
+
// session, ~19KB for a <skill> injection, more for a base64 image — and never
|
|
404
|
+
// truncates a line mid-JSON the way a fixed byte window would. So oversized
|
|
405
|
+
// first user messages (the cases that used to show "(no messages)") are parsed
|
|
406
|
+
// correctly.
|
|
407
|
+
//
|
|
408
|
+
// No tail read — the returned header's name reflects only session_info entries
|
|
409
|
+
// seen within the forward window. For sessions whose latest rename lives past
|
|
410
|
+
// the forward stop point (the common case for renamed large sessions), pair
|
|
411
|
+
// this with resolveSessionName() run in the background; the name then populates
|
|
412
|
+
// in-place without blocking the picker's initial render.
|
|
413
|
+
export function loadSessionHeaderForward(
|
|
414
|
+
meta: SessionFileMeta,
|
|
415
|
+
): SessionHeader | null {
|
|
416
|
+
let fd: number | undefined;
|
|
417
|
+
try {
|
|
418
|
+
fd = openSync(meta.path, "r");
|
|
419
|
+
const acc = newAccumulator();
|
|
420
|
+
const { reachedEof } = forEachLineForward(fd, meta.size, (line) => {
|
|
421
|
+
processEntry(acc, line);
|
|
422
|
+
if (acc.header && acc.foundFirstUser) return false;
|
|
423
|
+
return true;
|
|
424
|
+
});
|
|
425
|
+
return buildHeader(acc, meta.path, meta.mtimeMs, reachedEof);
|
|
426
|
+
} catch {
|
|
427
|
+
return null;
|
|
428
|
+
} finally {
|
|
429
|
+
if (fd !== undefined) closeSync(fd);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// Resolve the latest session_info (the rename name) from a bounded tail at EOF,
|
|
434
|
+
// independent of any forward pass. Returns found:false when no session_info
|
|
435
|
+
// lives in the tail region (keep whatever name the forward pass produced);
|
|
436
|
+
// found:true means a session_info was seen — its name (or explicit clear)
|
|
437
|
+
// overrides the forward name (it is later in file order).
|
|
438
|
+
//
|
|
439
|
+
// This is the deferred half of loadSessionHeader, exposed so callers can show
|
|
440
|
+
// a row immediately with the forward name and resolve the rename name in the
|
|
441
|
+
// background. Reading up to TAIL_READ_SIZE bytes from EOF may overlap the
|
|
442
|
+
// forward region for small files; that is a redundant re-read of a small range
|
|
443
|
+
// (no correctness impact — the latest session_info wins either way).
|
|
444
|
+
export function resolveSessionName(meta: SessionFileMeta): TailSessionInfo {
|
|
445
|
+
if (meta.size <= 0) return { found: false };
|
|
446
|
+
let fd: number | undefined;
|
|
447
|
+
try {
|
|
448
|
+
fd = openSync(meta.path, "r");
|
|
449
|
+
const tailReadSize = Math.min(TAIL_READ_SIZE, meta.size);
|
|
450
|
+
const tailBuf = Buffer.alloc(tailReadSize);
|
|
451
|
+
const tailOffset = meta.size - tailReadSize;
|
|
452
|
+
const tailBytesRead = readSync(fd, tailBuf, 0, tailReadSize, tailOffset);
|
|
453
|
+
return scanTailForSessionInfo(tailBuf, tailBytesRead);
|
|
454
|
+
} catch {
|
|
455
|
+
return { found: false };
|
|
456
|
+
} finally {
|
|
457
|
+
if (fd !== undefined) closeSync(fd);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// Load a session header using a streaming forward read plus a bounded tail read
|
|
462
|
+
// — forward + tail in one shared fd. Equivalent to loadSessionHeaderForward
|
|
463
|
+
// followed by resolveSessionName, but bounds the tail below by the forward stop
|
|
464
|
+
// offset so it never re-reads already-covered bytes. Use this when the full
|
|
465
|
+
// header (including rename name) is needed synchronously.
|
|
270
466
|
export function loadSessionHeader(
|
|
271
467
|
meta: SessionFileMeta,
|
|
272
468
|
): SessionHeader | null {
|
|
273
469
|
let fd: number | undefined;
|
|
274
470
|
try {
|
|
275
471
|
fd = openSync(meta.path, "r");
|
|
276
|
-
const
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
472
|
+
const acc = newAccumulator();
|
|
473
|
+
|
|
474
|
+
// Forward pass: read complete lines, stopping at the first user message.
|
|
475
|
+
const { reachedEof: forwardReachedEof, consumedBytes } = forEachLineForward(
|
|
476
|
+
fd,
|
|
477
|
+
meta.size,
|
|
478
|
+
(line) => {
|
|
479
|
+
processEntry(acc, line);
|
|
480
|
+
if (acc.header && acc.foundFirstUser) return false;
|
|
481
|
+
return true;
|
|
482
|
+
},
|
|
483
|
+
);
|
|
484
|
+
|
|
485
|
+
// If the forward pass stopped before EOF, recover the latest session_info
|
|
486
|
+
// from a bounded tail at EOF. Bounded below by consumedBytes so it never
|
|
487
|
+
// re-parses already-seen entries; the tail wins over the forward name
|
|
488
|
+
// (later in file order). A failure here falls back to the forward name.
|
|
287
489
|
let tailInfo: TailSessionInfo | undefined;
|
|
288
|
-
if (
|
|
490
|
+
if (!forwardReachedEof) {
|
|
289
491
|
try {
|
|
290
|
-
const tailReadSize = Math.min(TAIL_READ_SIZE, meta.size -
|
|
492
|
+
const tailReadSize = Math.min(TAIL_READ_SIZE, meta.size - consumedBytes);
|
|
291
493
|
const tailBuf = Buffer.alloc(tailReadSize);
|
|
292
494
|
const tailOffset = meta.size - tailReadSize;
|
|
293
495
|
const tailBytesRead = readSync(fd, tailBuf, 0, tailReadSize, tailOffset);
|
|
294
496
|
tailInfo = scanTailForSessionInfo(tailBuf, tailBytesRead);
|
|
295
497
|
} catch {
|
|
296
|
-
// Tail read failed — fall back to
|
|
498
|
+
// Tail read failed — fall back to forward-only name
|
|
297
499
|
}
|
|
298
500
|
}
|
|
299
501
|
|
|
300
|
-
return
|
|
301
|
-
buf,
|
|
302
|
-
bytesRead,
|
|
303
|
-
meta.path,
|
|
304
|
-
meta.mtimeMs,
|
|
305
|
-
meta.size > PARTIAL_READ_SIZE,
|
|
306
|
-
tailInfo,
|
|
307
|
-
);
|
|
502
|
+
return buildHeader(acc, meta.path, meta.mtimeMs, forwardReachedEof, tailInfo);
|
|
308
503
|
} catch {
|
|
309
504
|
return null;
|
|
310
505
|
} finally {
|
|
@@ -323,6 +518,20 @@ export function loadSessionHeaders(
|
|
|
323
518
|
return results;
|
|
324
519
|
}
|
|
325
520
|
|
|
521
|
+
// Forward-only batch load — see loadSessionHeaderForward. Use for the picker's
|
|
522
|
+
// immediate display path: rows appear instantly with the correct firstMessage,
|
|
523
|
+
// and rename names resolve in the background via resolveSessionName().
|
|
524
|
+
export function loadSessionHeadersForward(
|
|
525
|
+
metas: SessionFileMeta[],
|
|
526
|
+
): SessionHeader[] {
|
|
527
|
+
const results: SessionHeader[] = [];
|
|
528
|
+
for (const meta of metas) {
|
|
529
|
+
const header = loadSessionHeaderForward(meta);
|
|
530
|
+
if (header) results.push(header);
|
|
531
|
+
}
|
|
532
|
+
return results;
|
|
533
|
+
}
|
|
534
|
+
|
|
326
535
|
export function sortByModified(sessions: SessionHeader[]): SessionHeader[] {
|
|
327
536
|
return sessions.sort(
|
|
328
537
|
(a, b) => b.modified.getTime() - a.modified.getTime(),
|
|
@@ -370,4 +579,4 @@ export function matchQuery(
|
|
|
370
579
|
if (session.cwd.toLowerCase().includes(q)) return true;
|
|
371
580
|
if (session.id.toLowerCase().includes(q)) return true;
|
|
372
581
|
return false;
|
|
373
|
-
}
|
|
582
|
+
}
|
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
|
|
42
|
-
*
|
|
43
|
-
* queries like `fix oauth` won't match a session where
|
|
44
|
-
* the 5th message but not the 1st. Name, id, and cwd
|
|
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
|
*/
|