@henryqw/pi-session-recall 0.1.4
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/LICENSE +21 -0
- package/README.md +56 -0
- package/extensions/hydrate.ts +253 -0
- package/extensions/search-core.ts +1123 -0
- package/extensions/session-recall.ts +379 -0
- package/extensions/types.ts +45 -0
- package/package.json +48 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Henry Wang
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# `@henryqw/pi-session-recall`
|
|
2
|
+
|
|
3
|
+
FTS5 search over past Pi sessions: single `session_search` tool with four arg-inferred modes, zero LLM calls, raw messages only.
|
|
4
|
+
|
|
5
|
+
## Why
|
|
6
|
+
|
|
7
|
+
- **Created for**: Pi users who need to recover decisions and context from prior sessions without keeping every transcript in the active prompt.
|
|
8
|
+
- **Advantage**: Local FTS5 search gives fast, private recall with zero standing context cost and no model calls.
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
pi install npm:@henryqw/pi-session-recall
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Use
|
|
17
|
+
|
|
18
|
+
| Surface | Type | Purpose |
|
|
19
|
+
| --- | --- | --- |
|
|
20
|
+
| `session_search` | tool | Search past sessions or inspect one: discovery (`query`), scroll (`sessionId` + `aroundMessageId`), read (`sessionId`), browse (no args) |
|
|
21
|
+
|
|
22
|
+
**Discovery** — BM25-ranked top sessions; top hit hydrated with a ±5 message window and first/last-3 bookends; lower hits carry the matched anchor message plus metadata (`detail:"full"` hydrates all).
|
|
23
|
+
|
|
24
|
+
**Scroll** — ±`window` messages ([1,20]) around the anchor on its branch; scroll forward/backward by re-anchoring on the last/first message id of the returned window. Across forks, pass the previous response's `branchTip` to select the branch — `aroundMessageId` only centers the window and must lie on that branch.
|
|
25
|
+
|
|
26
|
+
**Read** — whole session; head 20 + tail 10 when large, with oversized content bounded to 50k characters and flagged by `contentTruncated`.
|
|
27
|
+
|
|
28
|
+
**Browse** — recent sessions: path, name, cwd, started date, preview.
|
|
29
|
+
|
|
30
|
+
Query syntax: FTS5 over a trigram index — multi-word = AND by default, `OR` for breadth, quoted phrases for exact match, `NOT` to exclude. Wildcards only help stems ≥3 chars. Only user/assistant text is indexed; thinking blocks and tool output are not searchable. For message text over the 20,000-character indexing budget, only first/last regions are indexed and the middle is omitted; phrases and `NEAR` cannot cross those regions, but ordinary AND terms can. `sessionId` must be a `.jsonl` file under the Pi sessions directory.
|
|
31
|
+
|
|
32
|
+
Hits inside the current session's live context are suppressed; compacted-away or inactive-branch history stays discoverable. Forked sessions collapse into their parent when both match.
|
|
33
|
+
|
|
34
|
+
## Deliberate exclusions
|
|
35
|
+
|
|
36
|
+
Session directories whose encoded path starts with `--tmp-` or `--private-tmp-` (sessions run from `/tmp` or `/private/tmp`) are never indexed. Session files over 32 MiB are excluded from indexing and hydration: discovery cannot newly find them; READ/SCROLL return an explicit size error, while a stale discovery hit retained from before the file grew is returned as metadata with empty messages and that error.
|
|
37
|
+
|
|
38
|
+
## Storage & privacy
|
|
39
|
+
|
|
40
|
+
The SQLite index lives at `~/.pi/agent/config/pi-session-recall/index.db`. It is derived state: delete it and it rebuilds from your session files. Everything stays local — transcripts are read in place and nothing leaves the machine beyond what tool results already show the model.
|
|
41
|
+
|
|
42
|
+
## Remove
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pi remove npm:@henryqw/pi-session-recall
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Delete `~/.pi/agent/config/pi-session-recall/` to reclaim index disk space.
|
|
49
|
+
|
|
50
|
+
## Development
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
npm test --workspace @henryqw/pi-session-recall
|
|
54
|
+
npm run typecheck --workspace @henryqw/pi-session-recall
|
|
55
|
+
npm run pack:check --workspace @henryqw/pi-session-recall
|
|
56
|
+
```
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hydration: direct JSONL parsing of Pi session files (never SessionManager.open —
|
|
3
|
+
* it rewrites legacy files), branch-aware windows/bookends, READ head/tail.
|
|
4
|
+
* Pure functions over file paths; no SQLite access.
|
|
5
|
+
*/
|
|
6
|
+
/// <reference types="node" />
|
|
7
|
+
import { MAX_SESSION_FILE_BYTES, readBoundedSnapshot } from "./search-core.ts";
|
|
8
|
+
import type { WindowMessage } from "./types.ts";
|
|
9
|
+
|
|
10
|
+
export interface WindowResult {
|
|
11
|
+
messages: WindowMessage[];
|
|
12
|
+
/** Every message on the resolved branch, root→tip chronological. Callers
|
|
13
|
+
* derive bookends from this instead of re-parsing the transcript. */
|
|
14
|
+
branchMessages: WindowMessage[];
|
|
15
|
+
messagesBefore: number;
|
|
16
|
+
messagesAfter: number;
|
|
17
|
+
/** Tip of the branch the window was resolved on — pass back as branchTip to
|
|
18
|
+
* keep scrolling on this branch across forks. May be a non-message entry id. */
|
|
19
|
+
branchTip: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ReadResult {
|
|
23
|
+
messages: WindowMessage[];
|
|
24
|
+
totalMessages: number;
|
|
25
|
+
truncated: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface Entry {
|
|
29
|
+
id: string;
|
|
30
|
+
parentId: string | null;
|
|
31
|
+
type: string;
|
|
32
|
+
timestamp?: string;
|
|
33
|
+
message?: {
|
|
34
|
+
role?: string;
|
|
35
|
+
content?: string | Array<{ type?: string; text?: string }>;
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Parse JSONL lines; malformed lines are skipped. Header line (no id) skipped. */
|
|
40
|
+
function parseSessionEntries(sessionPath: string): Entry[] {
|
|
41
|
+
// O_NONBLOCK + descriptor validation + fixed-size snapshot read are shared
|
|
42
|
+
// with the index engine; the fd pins the inode so a concurrent append after
|
|
43
|
+
// fstat waits until the next hydration call.
|
|
44
|
+
const raw = readBoundedSnapshot(sessionPath, MAX_SESSION_FILE_BYTES);
|
|
45
|
+
const entries: Entry[] = [];
|
|
46
|
+
const seenIds = new Set<string>();
|
|
47
|
+
for (const line of raw.split("\n")) {
|
|
48
|
+
if (!line.trim()) continue;
|
|
49
|
+
let obj: unknown;
|
|
50
|
+
try {
|
|
51
|
+
obj = JSON.parse(line);
|
|
52
|
+
} catch {
|
|
53
|
+
continue; // skip malformed line
|
|
54
|
+
}
|
|
55
|
+
const e = obj as Entry;
|
|
56
|
+
if (
|
|
57
|
+
e &&
|
|
58
|
+
typeof e.id === "string" &&
|
|
59
|
+
e.id.length > 0 &&
|
|
60
|
+
e.id.length <= 256 &&
|
|
61
|
+
!seenIds.has(e.id) &&
|
|
62
|
+
(e.parentId == null || (typeof e.parentId === "string" && e.parentId.length <= 256))
|
|
63
|
+
) {
|
|
64
|
+
seenIds.add(e.id);
|
|
65
|
+
entries.push(e);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return entries;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Leaf = last entry in file order whose ancestry contains a message; detached
|
|
72
|
+
* trailing metadata must not hide the transcript.
|
|
73
|
+
* Linear time: each entry's ancestry verdict is memoized, so every parent
|
|
74
|
+
* chain segment is traversed once regardless of how many trailing candidates
|
|
75
|
+
* share ancestry. Cycle handling matches branch(): truncation at the first
|
|
76
|
+
* revisit means every path node's truncated chain is a suffix of the path,
|
|
77
|
+
* so one walk's verdict covers all its memoized nodes. */
|
|
78
|
+
function leafId(entriesById: Map<string, Entry>, entries: Entry[]): string | null {
|
|
79
|
+
const hasMessageAncestor = new Map<string, boolean>();
|
|
80
|
+
let leaf: string | null = null;
|
|
81
|
+
for (const start of entries) {
|
|
82
|
+
const path: Entry[] = [];
|
|
83
|
+
const onPath = new Set<string>();
|
|
84
|
+
let cur: Entry = start;
|
|
85
|
+
for (;;) {
|
|
86
|
+
const known = hasMessageAncestor.get(cur.id);
|
|
87
|
+
if (known !== undefined) {
|
|
88
|
+
for (const e of path) hasMessageAncestor.set(e.id, known);
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
if (onPath.has(cur.id)) {
|
|
92
|
+
// No message was found before revisiting (we exit on message first).
|
|
93
|
+
for (const e of path) hasMessageAncestor.set(e.id, false);
|
|
94
|
+
break;
|
|
95
|
+
}
|
|
96
|
+
onPath.add(cur.id);
|
|
97
|
+
path.push(cur);
|
|
98
|
+
if (cur.type === "message") {
|
|
99
|
+
// Every node above the first message has one in its ancestry too.
|
|
100
|
+
for (const e of path) hasMessageAncestor.set(e.id, true);
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
103
|
+
const parent = cur.parentId ? entriesById.get(cur.parentId) : undefined;
|
|
104
|
+
if (!parent) {
|
|
105
|
+
for (const e of path) hasMessageAncestor.set(e.id, false);
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
cur = parent;
|
|
109
|
+
}
|
|
110
|
+
if (hasMessageAncestor.get(start.id)) leaf = start.id;
|
|
111
|
+
}
|
|
112
|
+
return leaf;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Walk parentId chain from entry to root, reversed (root→entry). */
|
|
116
|
+
function branch(entriesById: Map<string, Entry>, entryId: string): Entry[] {
|
|
117
|
+
const chain: Entry[] = [];
|
|
118
|
+
let cur: Entry | undefined = entriesById.get(entryId);
|
|
119
|
+
// ponytail: cycle guard for corrupt files — revisit only if real sessions ever contain cycles
|
|
120
|
+
const seen = new Set<string>();
|
|
121
|
+
while (cur && !seen.has(cur.id)) {
|
|
122
|
+
seen.add(cur.id);
|
|
123
|
+
chain.push(cur);
|
|
124
|
+
cur = cur.parentId ? entriesById.get(cur.parentId) : undefined;
|
|
125
|
+
}
|
|
126
|
+
return chain.reverse();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Deepest entry in file order whose ancestry contains tipId (tipId itself if none). */
|
|
130
|
+
function deepestDescendant(
|
|
131
|
+
entriesById: Map<string, Entry>,
|
|
132
|
+
entries: Entry[],
|
|
133
|
+
anchorId: string,
|
|
134
|
+
): string {
|
|
135
|
+
const onBranch = new Set([anchorId]);
|
|
136
|
+
let tip = anchorId;
|
|
137
|
+
for (const e of entries) {
|
|
138
|
+
if (e.parentId && onBranch.has(e.parentId)) {
|
|
139
|
+
onBranch.add(e.id);
|
|
140
|
+
tip = e.id;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return tip;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function toWindowMessage(e: Entry): WindowMessage {
|
|
147
|
+
const m = e.message ?? {};
|
|
148
|
+
const content =
|
|
149
|
+
typeof m.content === "string"
|
|
150
|
+
? m.content
|
|
151
|
+
: Array.isArray(m.content)
|
|
152
|
+
? m.content
|
|
153
|
+
.filter((p) => p?.type === "text" && typeof p.text === "string")
|
|
154
|
+
.map((p) => p.text)
|
|
155
|
+
.join("\n")
|
|
156
|
+
: "";
|
|
157
|
+
return {
|
|
158
|
+
entryId: e.id,
|
|
159
|
+
role: typeof m.role === "string" ? m.role.slice(0, 32) : "",
|
|
160
|
+
content,
|
|
161
|
+
timestamp: typeof e.timestamp === "string" ? e.timestamp.slice(0, 128) : "",
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Active-branch messages for a given tip, root→tip chronological. */
|
|
166
|
+
function branchMessages(
|
|
167
|
+
entriesById: Map<string, Entry>,
|
|
168
|
+
tipId: string,
|
|
169
|
+
): WindowMessage[] {
|
|
170
|
+
return branch(entriesById, tipId)
|
|
171
|
+
.filter((e) => e.type === "message")
|
|
172
|
+
.map(toWindowMessage);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Resolve a scroll cursor to a message: the cursor itself when it is a
|
|
176
|
+
* message, otherwise its nearest message ancestor. Throws when the cursor is
|
|
177
|
+
* unknown or has no message ancestor. */
|
|
178
|
+
function resolveMessageCursor(
|
|
179
|
+
entriesById: Map<string, Entry>,
|
|
180
|
+
anchorEntryId: string,
|
|
181
|
+
): Entry {
|
|
182
|
+
const anchor = entriesById.get(anchorEntryId);
|
|
183
|
+
if (!anchor) throw new Error(`anchor entry ${anchorEntryId} not found in session`);
|
|
184
|
+
let cur: Entry | undefined = anchor;
|
|
185
|
+
const seen = new Set<string>();
|
|
186
|
+
while (cur && cur.type !== "message" && !seen.has(cur.id)) {
|
|
187
|
+
seen.add(cur.id);
|
|
188
|
+
cur = cur.parentId ? entriesById.get(cur.parentId) : undefined;
|
|
189
|
+
}
|
|
190
|
+
if (!cur || cur.type !== "message") throw new Error(`anchor entry ${anchorEntryId} has no message ancestor`);
|
|
191
|
+
return cur;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function getWindow(
|
|
195
|
+
sessionPath: string,
|
|
196
|
+
anchorEntryId: string,
|
|
197
|
+
windowN: number,
|
|
198
|
+
opts?: { branchTip?: string },
|
|
199
|
+
): WindowResult {
|
|
200
|
+
const n = Math.max(0, Math.min(50, windowN));
|
|
201
|
+
const entries = parseSessionEntries(sessionPath);
|
|
202
|
+
const entriesById = new Map(entries.map((e) => [e.id, e]));
|
|
203
|
+
// A non-message branch-tip cursor centers on its nearest message ancestor;
|
|
204
|
+
// the non-message tip itself stays branchTip so scrolling remains on branch.
|
|
205
|
+
const messageAnchor = resolveMessageCursor(entriesById, anchorEntryId);
|
|
206
|
+
const anchorEntryIdMsg = messageAnchor.id;
|
|
207
|
+
|
|
208
|
+
let tip: string;
|
|
209
|
+
if (opts?.branchTip) {
|
|
210
|
+
// Explicit branch selection: resolve the branch from its tip (deepest
|
|
211
|
+
// descendant), independent of where the window centers.
|
|
212
|
+
resolveMessageCursor(entriesById, opts.branchTip); // unknown tip → throw
|
|
213
|
+
tip = deepestDescendant(entriesById, entries, opts.branchTip);
|
|
214
|
+
if (!branch(entriesById, tip).some((e) => e.id === messageAnchor.id)) {
|
|
215
|
+
throw new Error(`anchor entry ${anchorEntryId} is not on branch ${opts.branchTip}`);
|
|
216
|
+
}
|
|
217
|
+
} else {
|
|
218
|
+
tip = deepestDescendant(entriesById, entries, anchorEntryId);
|
|
219
|
+
}
|
|
220
|
+
const msgs = branchMessages(entriesById, tip);
|
|
221
|
+
const idx = msgs.findIndex((m) => m.entryId === anchorEntryIdMsg);
|
|
222
|
+
const start = Math.max(0, idx - n);
|
|
223
|
+
const end = Math.min(msgs.length - 1, idx + n);
|
|
224
|
+
return {
|
|
225
|
+
branchMessages: msgs,
|
|
226
|
+
messages: msgs.slice(start, end + 1).map((m) =>
|
|
227
|
+
m.entryId === anchorEntryIdMsg ? { ...m, anchor: true } : m,
|
|
228
|
+
),
|
|
229
|
+
messagesBefore: idx,
|
|
230
|
+
messagesAfter: msgs.length - 1 - idx,
|
|
231
|
+
branchTip: tip,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export function readSession(
|
|
236
|
+
sessionPath: string,
|
|
237
|
+
head = 20,
|
|
238
|
+
tail = 10,
|
|
239
|
+
): ReadResult {
|
|
240
|
+
const entries = parseSessionEntries(sessionPath);
|
|
241
|
+
const entriesById = new Map(entries.map((e) => [e.id, e]));
|
|
242
|
+
const leaf = leafId(entriesById, entries);
|
|
243
|
+
if (!leaf) return { messages: [], totalMessages: 0, truncated: false };
|
|
244
|
+
const msgs = branchMessages(entriesById, leaf);
|
|
245
|
+
if (msgs.length > head + tail) {
|
|
246
|
+
return {
|
|
247
|
+
messages: [...msgs.slice(0, head), ...msgs.slice(-tail)],
|
|
248
|
+
totalMessages: msgs.length,
|
|
249
|
+
truncated: true,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
return { messages: msgs, totalMessages: msgs.length, truncated: false };
|
|
253
|
+
}
|