@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
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-session-recall entry point: tool registration and mode dispatch.
|
|
3
|
+
*/
|
|
4
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { StringEnum } from "@earendil-works/pi-ai";
|
|
7
|
+
import { Type } from "typebox";
|
|
8
|
+
import { realpathSync } from "node:fs";
|
|
9
|
+
import { join, sep } from "node:path";
|
|
10
|
+
import { MAX_QUERY_CHARS, getSessionRows, searchIndex, syncSessions } from "./search-core.ts";
|
|
11
|
+
import { getWindow, readSession } from "./hydrate.ts";
|
|
12
|
+
import type { WindowMessage } from "./types.ts";
|
|
13
|
+
|
|
14
|
+
const dbPath = () => join(getAgentDir(), "config", "pi-session-recall", "index.db");
|
|
15
|
+
const sessionsDir = () => join(getAgentDir(), "sessions");
|
|
16
|
+
|
|
17
|
+
const OUTPUT_CHAR_BUDGET = 50_000;
|
|
18
|
+
|
|
19
|
+
function clamp(n: number | undefined, min: number, max: number, dflt: number): number {
|
|
20
|
+
if (typeof n !== "number" || !Number.isFinite(n)) return dflt;
|
|
21
|
+
return Math.max(min, Math.min(max, Math.floor(n)));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** UTF-16 surrogate halves. */
|
|
25
|
+
const isHighSurrogate = (s: string, i: number) => {
|
|
26
|
+
const u = s.charCodeAt(i);
|
|
27
|
+
return u >= 0xd800 && u <= 0xdbff;
|
|
28
|
+
};
|
|
29
|
+
const isLowSurrogate = (s: string, i: number) => {
|
|
30
|
+
const u = s.charCodeAt(i);
|
|
31
|
+
return u >= 0xdc00 && u <= 0xdfff;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export function truncateContent(msgs: WindowMessage[], maxChars: number): WindowMessage[] {
|
|
35
|
+
return msgs.map((m) => {
|
|
36
|
+
if (m.content.length <= maxChars) return m;
|
|
37
|
+
const c = m.content;
|
|
38
|
+
let head = Math.ceil(maxChars / 2);
|
|
39
|
+
const tail = Math.floor(maxChars / 2);
|
|
40
|
+
// Shift only cut points that land inside an astral character (surrogate
|
|
41
|
+
// pair); everything else keeps the exact head/tail split.
|
|
42
|
+
if (head > 0 && isHighSurrogate(c, head - 1) && isLowSurrogate(c, head)) head--;
|
|
43
|
+
let tailStart = c.length - tail;
|
|
44
|
+
if (tail > 0 && tailStart > 0 && isHighSurrogate(c, tailStart - 1) && isLowSurrogate(c, tailStart)) tailStart++;
|
|
45
|
+
return {
|
|
46
|
+
...m,
|
|
47
|
+
content: c.slice(0, head) + "…" + (tail > 0 ? c.slice(tailStart) : ""),
|
|
48
|
+
};
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Binary-search the max uniform per-message content cap whose built result fits
|
|
53
|
+
* the budget; null when even empty message arrays don't fit. */
|
|
54
|
+
function maxFittingCap(maxLen: number, budget: number, build: (cap: number) => unknown): number | null {
|
|
55
|
+
const fits = (cap: number) => JSON.stringify(build(cap)).length <= budget;
|
|
56
|
+
if (!fits(0)) return null;
|
|
57
|
+
let lo = 0;
|
|
58
|
+
for (let hi = maxLen; lo < hi; ) {
|
|
59
|
+
const mid = Math.ceil((lo + hi) / 2);
|
|
60
|
+
if (fits(mid)) lo = mid;
|
|
61
|
+
else hi = mid - 1;
|
|
62
|
+
}
|
|
63
|
+
return lo;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Build a result bounded to `budget`: largest uniform per-message cap across
|
|
67
|
+
* every WindowMessage array, or a metadata-only shape when nothing fits.
|
|
68
|
+
* build(null) must return the metadata-only variant with empty arrays. */
|
|
69
|
+
function boundContent(
|
|
70
|
+
build: (cap: number | null) => Record<string, unknown>,
|
|
71
|
+
maxLen: number,
|
|
72
|
+
budget: number,
|
|
73
|
+
): Record<string, unknown> {
|
|
74
|
+
const cap = maxFittingCap(maxLen, budget, (c) => build(c));
|
|
75
|
+
return cap === null ? build(null) : { ...build(cap), contentTruncated: true };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface ToolParams {
|
|
79
|
+
query?: string;
|
|
80
|
+
sessionId?: string;
|
|
81
|
+
aroundMessageId?: string;
|
|
82
|
+
branchTip?: string;
|
|
83
|
+
window?: number;
|
|
84
|
+
limit?: number;
|
|
85
|
+
detail?: "adaptive" | "full";
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const DESCRIPTION = `Search past Pi sessions stored on disk (FTS5-backed over a local SQLite index), or inspect one session in detail. No LLM calls — every shape returns actual messages.
|
|
89
|
+
|
|
90
|
+
FOUR CALLING SHAPES
|
|
91
|
+
|
|
92
|
+
1) DISCOVERY — pass \`query\`:
|
|
93
|
+
session_search(query="auth refactor", limit=3)
|
|
94
|
+
Runs FTS5 search and returns the top N sessions with metadata, match snippet, and messages around each match. Adaptive detail (default): the top-ranked result carries a ±5 message window plus first/last bookend messages; lower-ranked results carry only the anchor message. Pass \`detail="full"\` to hydrate every result fully.
|
|
95
|
+
|
|
96
|
+
2) SCROLL — pass \`sessionId\` + \`aroundMessageId\`:
|
|
97
|
+
session_search(sessionId="...", aroundMessageId="e07", window=10)
|
|
98
|
+
Returns ±window messages centered on the anchor (clamped to [1,20]). Use after discovery when you need more context than the default ±5 window. To scroll forward/backward, pass the last/first message entryId of the previous window back as aroundMessageId; messagesBefore/messagesAfter tell you where you are. Across forks, re-anchoring on a shared ancestor can jump branches — pass the previous response's branchTip as the branchTip argument (aroundMessageId only moves the center) to stay on that branch.
|
|
99
|
+
|
|
100
|
+
3) READ — pass \`sessionId\` only:
|
|
101
|
+
session_search(sessionId="...")
|
|
102
|
+
Returns the session's active branch (first 20 + last 10 messages when large).
|
|
103
|
+
|
|
104
|
+
4) BROWSE — no args:
|
|
105
|
+
session_search()
|
|
106
|
+
Returns recent sessions: name, cwd, start time, first-user-message preview. Use when asked "what was I working on" without a topic.
|
|
107
|
+
|
|
108
|
+
Mode is inferred from args; precedence: scroll > read > browse > discovery.
|
|
109
|
+
|
|
110
|
+
FTS5 SYNTAX
|
|
111
|
+
AND is the default — multi-word queries require all terms. Use OR for broader recall (\`alpha OR beta\`), quoted phrases for exact match (\`"docker networking"\`), NOT to exclude (\`python NOT java\`). Wildcards work only as stem expansion of tokens ≥3 chars (trigram tokenizer); very short terms fall back to substring matching. The index covers user/assistant message text only — thinking, tool calls/results are not searchable.`;
|
|
112
|
+
|
|
113
|
+
export default function (pi: ExtensionAPI): void {
|
|
114
|
+
// Best-effort sync at startup, deferred so the synchronous walk + SQLite
|
|
115
|
+
// writes never block session start. The lazy in-tool-call sync retries.
|
|
116
|
+
pi.on("session_start", (_event, _ctx) => {
|
|
117
|
+
setTimeout(() => {
|
|
118
|
+
try {
|
|
119
|
+
syncSessions(sessionsDir(), dbPath());
|
|
120
|
+
} catch {
|
|
121
|
+
// Index stays stale; next tool call retries.
|
|
122
|
+
}
|
|
123
|
+
}, 0);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
pi.registerTool({
|
|
127
|
+
name: "session_search",
|
|
128
|
+
label: "Session Search",
|
|
129
|
+
description: DESCRIPTION,
|
|
130
|
+
promptSnippet: "Search past Pi sessions for prior decisions and context",
|
|
131
|
+
parameters: Type.Object({
|
|
132
|
+
query: Type.Optional(Type.String({ description: "Search query (discovery). FTS5 syntax supported." })),
|
|
133
|
+
sessionId: Type.Optional(Type.String({ description: "Absolute path of the session file." })),
|
|
134
|
+
aroundMessageId: Type.Optional(Type.String({ description: "Anchor entry id for scroll mode — centers the window (with sessionId)." })),
|
|
135
|
+
branchTip: Type.Optional(Type.String({ description: "Branch tip entry id from a previous response — selects which branch of a forked session to scroll; aroundMessageId must lie on it." })),
|
|
136
|
+
window: Type.Optional(Type.Number({ description: "Scroll window radius, [1,20], default 5." })),
|
|
137
|
+
limit: Type.Optional(Type.Number({ description: "Max results, [1,10], default 3." })),
|
|
138
|
+
detail: Type.Optional(StringEnum(["adaptive", "full"] as const)),
|
|
139
|
+
}),
|
|
140
|
+
async execute(_toolCallId, rawParams: ToolParams, _signal, _onUpdate, ctx) {
|
|
141
|
+
try {
|
|
142
|
+
// LLMs sometimes send numeric ids/queries despite the string schema.
|
|
143
|
+
const params: ToolParams = {
|
|
144
|
+
query: rawParams.query != null ? String(rawParams.query) : undefined,
|
|
145
|
+
sessionId: rawParams.sessionId != null ? String(rawParams.sessionId) : undefined,
|
|
146
|
+
aroundMessageId: rawParams.aroundMessageId != null ? String(rawParams.aroundMessageId) : undefined,
|
|
147
|
+
branchTip: rawParams.branchTip != null ? String(rawParams.branchTip) : undefined,
|
|
148
|
+
window: rawParams.window,
|
|
149
|
+
limit: rawParams.limit,
|
|
150
|
+
detail: rawParams.detail,
|
|
151
|
+
};
|
|
152
|
+
let sessionId = params.sessionId?.trim() || undefined;
|
|
153
|
+
const anchor = params.aroundMessageId?.trim() || undefined;
|
|
154
|
+
if (sessionId) {
|
|
155
|
+
// Trust boundary: canonical target must live under the real
|
|
156
|
+
// sessions dir (realpath defeats symlink escapes).
|
|
157
|
+
try {
|
|
158
|
+
const resolved = realpathSync(sessionId);
|
|
159
|
+
const root = realpathSync(sessionsDir());
|
|
160
|
+
if (!resolved.startsWith(root + sep) || !resolved.endsWith(".jsonl")) {
|
|
161
|
+
return textResult({ success: false, message: "sessionId must be a .jsonl file under the Pi sessions directory" });
|
|
162
|
+
}
|
|
163
|
+
// Rebind to the validated canonical path so downstream reads cannot
|
|
164
|
+
// be redirected by a symlink swapped in after validation (TOCTOU).
|
|
165
|
+
sessionId = resolved;
|
|
166
|
+
} catch {
|
|
167
|
+
return textResult({ success: false, message: `session file not found: ${sessionId}` });
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// --- SCROLL ---
|
|
172
|
+
if (sessionId && anchor) {
|
|
173
|
+
const w = clamp(params.window, 1, 20, 5);
|
|
174
|
+
const branchTip = params.branchTip?.trim() || undefined;
|
|
175
|
+
const win = getWindow(sessionId, anchor, w, branchTip ? { branchTip } : undefined);
|
|
176
|
+
const base = { mode: "scroll", sessionId, branchTip: win.branchTip, messagesBefore: win.messagesBefore, messagesAfter: win.messagesAfter };
|
|
177
|
+
let result: Record<string, unknown> = { ...base, messages: win.messages };
|
|
178
|
+
if (JSON.stringify(result).length > OUTPUT_CHAR_BUDGET && win.messages.length > 0) {
|
|
179
|
+
result = boundContent(
|
|
180
|
+
(cap) => ({ ...base, messages: cap === null ? [] : truncateContent(win.messages, cap), contentTruncated: true }),
|
|
181
|
+
Math.max(...win.messages.map((m) => m.content.length), 0),
|
|
182
|
+
OUTPUT_CHAR_BUDGET,
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
return textResult(result);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// --- READ ---
|
|
189
|
+
if (sessionId) {
|
|
190
|
+
const r = readSession(sessionId);
|
|
191
|
+
let result: Record<string, unknown> = { mode: "read", sessionId, ...r };
|
|
192
|
+
if (JSON.stringify(result).length > OUTPUT_CHAR_BUDGET && r.messages.length > 0) {
|
|
193
|
+
// contentTruncated is character-level truncation, distinct from
|
|
194
|
+
// the message-count `truncated`.
|
|
195
|
+
result = boundContent(
|
|
196
|
+
(cap) => ({
|
|
197
|
+
mode: "read",
|
|
198
|
+
sessionId,
|
|
199
|
+
totalMessages: r.totalMessages,
|
|
200
|
+
truncated: r.truncated,
|
|
201
|
+
messages: cap === null ? [] : truncateContent(r.messages, cap),
|
|
202
|
+
contentTruncated: true,
|
|
203
|
+
}),
|
|
204
|
+
Math.max(...r.messages.map((m) => m.content.length), 0),
|
|
205
|
+
OUTPUT_CHAR_BUDGET,
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
return textResult(result);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Lazy sync: drains any backlog the capped startup pass left.
|
|
212
|
+
try {
|
|
213
|
+
syncSessions(sessionsDir(), dbPath());
|
|
214
|
+
} catch {
|
|
215
|
+
// Serve from the possibly stale index rather than failing.
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// --- BROWSE ---
|
|
219
|
+
if (!params.query?.trim()) {
|
|
220
|
+
const rows = getSessionRows(dbPath(), clamp(params.limit, 1, 10, 3));
|
|
221
|
+
return textResult({ mode: "browse", sessions: rows });
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// --- DISCOVERY ---
|
|
225
|
+
const limit = clamp(params.limit, 1, 10, 3);
|
|
226
|
+
const full = params.detail === "full";
|
|
227
|
+
|
|
228
|
+
// Current-session guard: suppress hits on the live branch.
|
|
229
|
+
let liveIds: Set<string> | undefined;
|
|
230
|
+
let currentSessionPath: string | undefined;
|
|
231
|
+
try {
|
|
232
|
+
currentSessionPath = ctx.sessionManager.getSessionFile();
|
|
233
|
+
liveIds = new Set(
|
|
234
|
+
ctx.sessionManager
|
|
235
|
+
.buildContextEntries()
|
|
236
|
+
.filter((e) => e.type === "message")
|
|
237
|
+
.map((e) => e.id),
|
|
238
|
+
);
|
|
239
|
+
} catch {
|
|
240
|
+
// Guard unavailable → degrade gracefully, no suppression.
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const { hits, backlogRemaining } = searchIndex(dbPath(), params.query, {
|
|
244
|
+
limit,
|
|
245
|
+
currentLiveEntryIds: liveIds,
|
|
246
|
+
currentSessionPath,
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
const resultQuery = params.query!.trim().slice(0, MAX_QUERY_CHARS);
|
|
250
|
+
// Reserve the complete response envelope and divide remaining space
|
|
251
|
+
// across hits so the first hydrated result cannot starve later metadata.
|
|
252
|
+
let used = JSON.stringify({ mode: "discovery", query: resultQuery, results: [], backlogRemaining }).length + Math.max(0, hits.length - 1);
|
|
253
|
+
const results = hits.map((hit, index) => {
|
|
254
|
+
const remaining = Math.floor((OUTPUT_CHAR_BUDGET - used) / (hits.length - index));
|
|
255
|
+
const meta = {
|
|
256
|
+
path: hit.path,
|
|
257
|
+
snippet: hit.snippet,
|
|
258
|
+
rank: hit.rank,
|
|
259
|
+
matchMessageId: hit.entryId,
|
|
260
|
+
role: hit.role,
|
|
261
|
+
timestamp: hit.timestamp,
|
|
262
|
+
cwd: hit.cwd,
|
|
263
|
+
name: hit.name,
|
|
264
|
+
startedAt: hit.startedAt,
|
|
265
|
+
};
|
|
266
|
+
const hydrateFull = full || hit.rank === 0;
|
|
267
|
+
// Every hit is sized against the cumulative remaining budget: keep
|
|
268
|
+
// as-is when it fits, else truncate to the largest uniform cap that
|
|
269
|
+
// fits across messages and bookends, else metadata-only.
|
|
270
|
+
// contentTruncated signals either case.
|
|
271
|
+
const fitOrTruncate = (
|
|
272
|
+
hitObj: Record<string, unknown>,
|
|
273
|
+
messages: WindowMessage[],
|
|
274
|
+
bookends?: { start: WindowMessage[]; end: WindowMessage[] },
|
|
275
|
+
): Record<string, unknown> => {
|
|
276
|
+
const out: Record<string, unknown> = { ...hitObj };
|
|
277
|
+
if (JSON.stringify(out).length > remaining) {
|
|
278
|
+
const pools = bookends ? [messages, bookends.start, bookends.end] : [messages];
|
|
279
|
+
const maxLen = Math.max(...pools.flatMap((a) => a.map((m) => m.content.length)), 0);
|
|
280
|
+
Object.assign(
|
|
281
|
+
out,
|
|
282
|
+
boundContent(
|
|
283
|
+
(cap) => ({
|
|
284
|
+
...hitObj,
|
|
285
|
+
messages: cap === null ? [] : truncateContent(messages, cap),
|
|
286
|
+
...(bookends && cap !== null
|
|
287
|
+
? { bookends: { start: truncateContent(bookends.start, cap), end: truncateContent(bookends.end, cap) } }
|
|
288
|
+
: bookends
|
|
289
|
+
? { bookends: { start: [], end: [] } }
|
|
290
|
+
: {}),
|
|
291
|
+
contentTruncated: true,
|
|
292
|
+
}),
|
|
293
|
+
maxLen,
|
|
294
|
+
remaining,
|
|
295
|
+
),
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
used += JSON.stringify(out).length;
|
|
299
|
+
return out;
|
|
300
|
+
};
|
|
301
|
+
const hydrationFallback = (error: unknown) =>
|
|
302
|
+
fitOrTruncate(
|
|
303
|
+
{ ...meta, detail: hydrateFull ? "full" : "compact", messages: [], bookends: { start: [], end: [] }, messagesBefore: 0, messagesAfter: 0, error: (error instanceof Error ? error.message : String(error)).slice(0, 512) },
|
|
304
|
+
[],
|
|
305
|
+
);
|
|
306
|
+
if (!hydrateFull) {
|
|
307
|
+
// Compact hits still carry the matched anchor message.
|
|
308
|
+
try {
|
|
309
|
+
const win = getWindow(hit.path, hit.entryId, 0);
|
|
310
|
+
// Mark when the fixed compact cap already removed content, so a
|
|
311
|
+
// hit that still fits the budget isn't mistaken for complete.
|
|
312
|
+
const overCompactCap = win.messages.some((m) => m.content.length > 2000);
|
|
313
|
+
return fitOrTruncate({ ...meta, detail: "compact", ...(overCompactCap ? { contentTruncated: true } : {}), messages: truncateContent(win.messages, 2000), bookends: { start: [], end: [] }, messagesBefore: win.messagesBefore, messagesAfter: win.messagesAfter }, win.messages);
|
|
314
|
+
} catch (error) {
|
|
315
|
+
return hydrationFallback(error);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
try {
|
|
319
|
+
// One bounded snapshot feeds both window and branch bookends.
|
|
320
|
+
const win = getWindow(hit.path, hit.entryId, 5);
|
|
321
|
+
// Same branch as the anchor — following the file's final leaf
|
|
322
|
+
// would attach unrelated sibling messages.
|
|
323
|
+
const bookends = { start: win.branchMessages.slice(0, 3), end: win.branchMessages.slice(-3) };
|
|
324
|
+
return fitOrTruncate(
|
|
325
|
+
{
|
|
326
|
+
...meta,
|
|
327
|
+
detail: "full" as const,
|
|
328
|
+
messages: win.messages,
|
|
329
|
+
bookends,
|
|
330
|
+
messagesBefore: win.messagesBefore,
|
|
331
|
+
messagesAfter: win.messagesAfter,
|
|
332
|
+
},
|
|
333
|
+
win.messages,
|
|
334
|
+
bookends,
|
|
335
|
+
);
|
|
336
|
+
} catch (error) {
|
|
337
|
+
// Session file unreadable/moved since indexing → anchor-only.
|
|
338
|
+
return hydrationFallback(error);
|
|
339
|
+
}
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
const result: Record<string, unknown> = { mode: "discovery", query: resultQuery, results, backlogRemaining };
|
|
343
|
+
return textResult(result);
|
|
344
|
+
} catch (error) {
|
|
345
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
346
|
+
return textResult({ success: false, error: message });
|
|
347
|
+
}
|
|
348
|
+
},
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function textResult(result: unknown) {
|
|
353
|
+
let bounded = result;
|
|
354
|
+
let text = JSON.stringify(bounded);
|
|
355
|
+
if (text.length > OUTPUT_CHAR_BUDGET && bounded && typeof bounded === "object" && !Array.isArray(bounded)) {
|
|
356
|
+
const copy: Record<string, unknown> = { ...(bounded as Record<string, unknown>), contentTruncated: true };
|
|
357
|
+
for (const key of ["results", "sessions", "messages"] as const) {
|
|
358
|
+
if (Array.isArray(copy[key])) copy[key] = [...copy[key] as unknown[]];
|
|
359
|
+
}
|
|
360
|
+
bounded = copy;
|
|
361
|
+
text = JSON.stringify(bounded);
|
|
362
|
+
while (text.length > OUTPUT_CHAR_BUDGET) {
|
|
363
|
+
const array = ["results", "sessions", "messages"]
|
|
364
|
+
.map((key) => copy[key])
|
|
365
|
+
.find((value): value is unknown[] => Array.isArray(value) && value.length > 0);
|
|
366
|
+
if (!array) {
|
|
367
|
+
bounded = { success: false, error: "session_search result metadata exceeds output budget" };
|
|
368
|
+
text = JSON.stringify(bounded);
|
|
369
|
+
break;
|
|
370
|
+
}
|
|
371
|
+
array.pop();
|
|
372
|
+
text = JSON.stringify(bounded);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
return {
|
|
376
|
+
content: [{ type: "text" as const, text }],
|
|
377
|
+
details: bounded,
|
|
378
|
+
};
|
|
379
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared shapes between the index engine (search-core) and hydration (hydrate).
|
|
3
|
+
* Keep this file types-only.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** A hydrated message returned by windows/read. */
|
|
7
|
+
export interface WindowMessage {
|
|
8
|
+
entryId: string;
|
|
9
|
+
role: string;
|
|
10
|
+
content: string;
|
|
11
|
+
timestamp: string;
|
|
12
|
+
/** True when this message is the anchor of a scroll/discovery window. */
|
|
13
|
+
anchor?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Metadata row for BROWSE mode (served from the index tables, not JSONL). */
|
|
17
|
+
export interface SessionRow {
|
|
18
|
+
path: string;
|
|
19
|
+
cwd: string;
|
|
20
|
+
name?: string;
|
|
21
|
+
startedAt?: string;
|
|
22
|
+
preview?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** One FTS discovery hit before hydration. */
|
|
26
|
+
export interface SearchHit {
|
|
27
|
+
path: string;
|
|
28
|
+
entryId: string;
|
|
29
|
+
role: string;
|
|
30
|
+
timestamp: string;
|
|
31
|
+
snippet: string;
|
|
32
|
+
/** BM25 rank position (0 = best). */
|
|
33
|
+
rank: number;
|
|
34
|
+
/** Session metadata joined from the sessions table. */
|
|
35
|
+
cwd?: string;
|
|
36
|
+
name?: string;
|
|
37
|
+
startedAt?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface SyncResult {
|
|
41
|
+
filesProcessed: number;
|
|
42
|
+
messagesIndexed: number;
|
|
43
|
+
/** Changed files still unindexed after this pass, including failures. */
|
|
44
|
+
backlogRemaining: number;
|
|
45
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@henryqw/pi-session-recall",
|
|
3
|
+
"version": "0.1.4",
|
|
4
|
+
"description": "FTS5 search over past Pi sessions: single tool, four arg-inferred modes (discovery/scroll/read/browse), zero LLM calls.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"pi-package",
|
|
7
|
+
"pi",
|
|
8
|
+
"session-recall",
|
|
9
|
+
"fts5",
|
|
10
|
+
"recall"
|
|
11
|
+
],
|
|
12
|
+
"type": "module",
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=22.19.0"
|
|
15
|
+
},
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"files": [
|
|
18
|
+
"extensions",
|
|
19
|
+
"README.md",
|
|
20
|
+
"LICENSE"
|
|
21
|
+
],
|
|
22
|
+
"scripts": {
|
|
23
|
+
"test": "node --test test/*.test.ts",
|
|
24
|
+
"typecheck": "tsc --noEmit --allowImportingTsExtensions --target ES2022 --module NodeNext --moduleResolution NodeNext --skipLibCheck extensions/*.ts test/*.test.ts",
|
|
25
|
+
"pack:check": "npm pack --dry-run"
|
|
26
|
+
},
|
|
27
|
+
"peerDependencies": {
|
|
28
|
+
"@earendil-works/pi-ai": "^0.84.2",
|
|
29
|
+
"@earendil-works/pi-coding-agent": "^0.84.2",
|
|
30
|
+
"typebox": "^1.3.15"
|
|
31
|
+
},
|
|
32
|
+
"repository": {
|
|
33
|
+
"type": "git",
|
|
34
|
+
"url": "git+https://github.com/HenryQW/pi-packages.git",
|
|
35
|
+
"directory": "packages/pi-session-recall"
|
|
36
|
+
},
|
|
37
|
+
"bugs": {
|
|
38
|
+
"url": "https://github.com/HenryQW/pi-packages/issues"
|
|
39
|
+
},
|
|
40
|
+
"publishConfig": {
|
|
41
|
+
"access": "public"
|
|
42
|
+
},
|
|
43
|
+
"pi": {
|
|
44
|
+
"extensions": [
|
|
45
|
+
"./extensions/session-recall.ts"
|
|
46
|
+
]
|
|
47
|
+
}
|
|
48
|
+
}
|