pi-session-search 0.1.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/src/index.ts ADDED
@@ -0,0 +1,466 @@
1
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
+ import { Type } from "@sinclair/typebox";
3
+ import { loadConfig, saveConfig, getConfigPath, getIndexDir } from "./config";
4
+ import type { Config } from "./config";
5
+ import { createEmbedder } from "./embedder";
6
+ import { SessionIndex } from "./session-index";
7
+ import { readSessionConversation } from "./reader";
8
+
9
+ export default function (pi: ExtensionAPI) {
10
+ let sessionIndex: SessionIndex | null = null;
11
+ let currentConfig: Config | null = null;
12
+ let syncTimer: ReturnType<typeof setInterval> | null = null;
13
+
14
+ const SYNC_INTERVAL_MS = 5 * 60 * 1000; // re-sync every 5 minutes
15
+
16
+ // ------------------------------------------------------------------
17
+ // Lifecycle
18
+ // ------------------------------------------------------------------
19
+
20
+ pi.on("session_start", async (_event, ctx) => {
21
+ try {
22
+ currentConfig = loadConfig();
23
+ } catch (err: any) {
24
+ ctx.ui.notify(`session-search: ${err.message}`, "warning");
25
+ return;
26
+ }
27
+
28
+ if (!currentConfig) {
29
+ // Not configured — silent until user runs setup
30
+ return;
31
+ }
32
+
33
+ await startIndex(currentConfig, ctx);
34
+ });
35
+
36
+ async function startIndex(config: Config, ctx: any) {
37
+ try {
38
+ const embedder = createEmbedder(config.embedder);
39
+ sessionIndex = new SessionIndex(
40
+ embedder,
41
+ getIndexDir(),
42
+ config.extraSessionDirs,
43
+ config.extraArchiveDirs,
44
+ );
45
+ await sessionIndex.load();
46
+
47
+ const { added, updated, removed, moved } = await sessionIndex.sync(
48
+ (msg) => ctx.ui.setStatus("session-search", msg)
49
+ );
50
+ const changes = added + updated + removed + moved;
51
+ if (changes > 0) {
52
+ const parts = [];
53
+ if (added) parts.push(`+${added}`);
54
+ if (updated) parts.push(`~${updated}`);
55
+ if (removed) parts.push(`-${removed}`);
56
+ if (moved) parts.push(`↗${moved} moved`);
57
+ ctx.ui.setStatus(
58
+ "session-search",
59
+ `Sessions: ${parts.join(" ")} (${sessionIndex.size()} total)`
60
+ );
61
+ setTimeout(() => ctx.ui.setStatus("session-search", ""), 5000);
62
+ }
63
+
64
+ // Periodic background sync to pick up new/changed sessions
65
+ syncTimer = setInterval(async () => {
66
+ if (!sessionIndex) return;
67
+ try {
68
+ const result = await sessionIndex.sync();
69
+ const changes = result.added + result.updated + result.removed + result.moved;
70
+ if (changes > 0) {
71
+ const parts = [];
72
+ if (result.added) parts.push(`+${result.added}`);
73
+ if (result.updated) parts.push(`~${result.updated}`);
74
+ if (result.removed) parts.push(`-${result.removed}`);
75
+ if (result.moved) parts.push(`↗${result.moved} moved`);
76
+ ctx.ui.setStatus(
77
+ "session-search",
78
+ `Sessions synced: ${parts.join(" ")} (${sessionIndex.size()} total)`
79
+ );
80
+ setTimeout(() => ctx.ui.setStatus("session-search", ""), 5000);
81
+ }
82
+ } catch {
83
+ // Silent — don't spam on background sync failures
84
+ }
85
+ }, SYNC_INTERVAL_MS);
86
+ } catch (err: any) {
87
+ ctx.ui.notify(`session-search init failed: ${err.message}`, "error");
88
+ }
89
+ }
90
+
91
+ pi.on("session_shutdown", async () => {
92
+ if (syncTimer) {
93
+ clearInterval(syncTimer);
94
+ syncTimer = null;
95
+ }
96
+ });
97
+
98
+ // ------------------------------------------------------------------
99
+ // Setup command
100
+ // ------------------------------------------------------------------
101
+
102
+ pi.registerCommand("session-search-setup", {
103
+ description:
104
+ "Configure session search — choose embedding provider",
105
+ handler: async (_args, ctx) => {
106
+ const providerChoice = await ctx.ui.select("Embedding provider:", [
107
+ "openai — OpenAI API (text-embedding-3-small)",
108
+ "bedrock — AWS Bedrock (Titan Embeddings v2)",
109
+ "ollama — Local Ollama (nomic-embed-text)",
110
+ ]);
111
+
112
+ if (!providerChoice) {
113
+ ctx.ui.notify("Setup cancelled.", "info");
114
+ return;
115
+ }
116
+
117
+ const providerType = providerChoice.split(" ")[0] as
118
+ | "openai"
119
+ | "bedrock"
120
+ | "ollama";
121
+
122
+ let embedder: any;
123
+
124
+ switch (providerType) {
125
+ case "openai": {
126
+ const apiKey = await ctx.ui.input(
127
+ "OpenAI API key (or env var name):",
128
+ process.env.OPENAI_API_KEY ? "(using OPENAI_API_KEY from env)" : ""
129
+ );
130
+ const model = await ctx.ui.input(
131
+ "Model:",
132
+ "text-embedding-3-small"
133
+ );
134
+ embedder = {
135
+ type: "openai" as const,
136
+ apiKey: apiKey?.startsWith("(") ? undefined : apiKey || undefined,
137
+ model: model || "text-embedding-3-small",
138
+ dimensions: 512,
139
+ };
140
+ break;
141
+ }
142
+ case "bedrock": {
143
+ const profile = await ctx.ui.input("AWS profile:", "default");
144
+ const region = await ctx.ui.input("AWS region:", "us-east-1");
145
+ const model = await ctx.ui.input(
146
+ "Model:",
147
+ "amazon.titan-embed-text-v2:0"
148
+ );
149
+ embedder = {
150
+ type: "bedrock" as const,
151
+ profile: profile || "default",
152
+ region: region || "us-east-1",
153
+ model: model || "amazon.titan-embed-text-v2:0",
154
+ dimensions: 512,
155
+ };
156
+ break;
157
+ }
158
+ case "ollama": {
159
+ const url = await ctx.ui.input(
160
+ "Ollama URL:",
161
+ "http://localhost:11434"
162
+ );
163
+ const model = await ctx.ui.input("Model:", "nomic-embed-text");
164
+ embedder = {
165
+ type: "ollama" as const,
166
+ url: url || "http://localhost:11434",
167
+ model: model || "nomic-embed-text",
168
+ };
169
+ break;
170
+ }
171
+ }
172
+
173
+ const extraDirs = await ctx.ui.input(
174
+ "Extra session directories (comma-separated, optional):",
175
+ ""
176
+ );
177
+ const extraArchive = await ctx.ui.input(
178
+ "Extra archive directories (comma-separated, optional):",
179
+ ""
180
+ );
181
+
182
+ saveConfig({
183
+ embedder,
184
+ extraSessionDirs: extraDirs
185
+ ? extraDirs.split(",").map((d: string) => d.trim()).filter(Boolean)
186
+ : undefined,
187
+ extraArchiveDirs: extraArchive
188
+ ? extraArchive.split(",").map((d: string) => d.trim()).filter(Boolean)
189
+ : undefined,
190
+ });
191
+
192
+ ctx.ui.notify(
193
+ `Config saved to ${getConfigPath()}. Run /reload to activate.`,
194
+ "success"
195
+ );
196
+ },
197
+ });
198
+
199
+ // ------------------------------------------------------------------
200
+ // Reindex command
201
+ // ------------------------------------------------------------------
202
+
203
+ pi.registerCommand("session-reindex", {
204
+ description: "Force full re-index of all session files",
205
+ handler: async (_args, ctx) => {
206
+ if (!sessionIndex) {
207
+ ctx.ui.notify(
208
+ "Not configured. Run /session-search-setup first.",
209
+ "warning"
210
+ );
211
+ return;
212
+ }
213
+ ctx.ui.notify("Re-indexing sessions...", "info");
214
+ try {
215
+ await sessionIndex.rebuild((msg) =>
216
+ ctx.ui.setStatus("session-search", msg)
217
+ );
218
+ ctx.ui.notify(
219
+ `Re-indexed: ${sessionIndex.size()} sessions`,
220
+ "success"
221
+ );
222
+ ctx.ui.setStatus("session-search", "");
223
+ } catch (err: any) {
224
+ ctx.ui.notify(`Re-index failed: ${err.message}`, "error");
225
+ }
226
+ },
227
+ });
228
+
229
+ // ------------------------------------------------------------------
230
+ // Tool: session_search
231
+ // ------------------------------------------------------------------
232
+
233
+ pi.registerTool({
234
+ name: "session_search",
235
+ label: "Session Search",
236
+ description:
237
+ "Semantic search over past pi sessions. Returns summaries of the most relevant sessions for a natural language query. Use to find previous work, decisions, debugging sessions, or code changes.",
238
+ promptSnippet:
239
+ "Semantic search over past pi sessions — find previous work, decisions, and context by topic.",
240
+ promptGuidelines: [
241
+ "Use session_search to find past coding sessions relevant to the current task (e.g. 'when did we refactor the auth module', 'previous work on Lambda timeouts').",
242
+ "Use session_list for browsing by date/project. Use session_read to dive into a specific session.",
243
+ ],
244
+ parameters: Type.Object({
245
+ query: Type.String({ description: "Natural language search query" }),
246
+ limit: Type.Optional(
247
+ Type.Number({
248
+ description: "Max results to return (default 10, max 25)",
249
+ })
250
+ ),
251
+ }),
252
+ async execute(_toolCallId, params, signal) {
253
+ if (!sessionIndex || sessionIndex.size() === 0) {
254
+ const msg = !sessionIndex
255
+ ? "session-search is not configured. The user can run /session-search-setup to set it up."
256
+ : "Session index is empty — it may still be building. Try again in a moment.";
257
+ return { content: [{ type: "text", text: msg }], details: {} };
258
+ }
259
+
260
+ const limit = Math.min(params.limit ?? 10, 25);
261
+
262
+ try {
263
+ const results = await sessionIndex.search(params.query, limit, signal);
264
+
265
+ if (results.length === 0) {
266
+ return {
267
+ content: [
268
+ {
269
+ type: "text",
270
+ text: `No relevant sessions found for: "${params.query}"`,
271
+ },
272
+ ],
273
+ details: {},
274
+ };
275
+ }
276
+
277
+ const home = process.env.HOME || "";
278
+ const output = results
279
+ .map((r, i) => {
280
+ const score = (r.score * 100).toFixed(1);
281
+ const displayFile = r.session.file.replace(home, "~");
282
+ return [
283
+ `### ${i + 1}. ${r.session.name || truncate(r.session.firstUserMessage, 80)} (${score}% match)`,
284
+ `File: ${displayFile}`,
285
+ `ID: ${r.session.id}`,
286
+ `Date: ${r.session.startedAt.split("T")[0]} | CWD: ${r.session.cwd}`,
287
+ r.summary,
288
+ ].join("\n");
289
+ })
290
+ .join("\n\n---\n\n");
291
+
292
+ const header = `Found ${results.length} sessions for "${params.query}" (${sessionIndex.size()} sessions indexed):\n\n`;
293
+
294
+ return {
295
+ content: [{ type: "text", text: header + output }],
296
+ details: { resultCount: results.length, indexSize: sessionIndex.size() },
297
+ };
298
+ } catch (err: any) {
299
+ throw new Error(`session-search failed: ${err.message}`);
300
+ }
301
+ },
302
+ });
303
+
304
+ // ------------------------------------------------------------------
305
+ // Tool: session_list
306
+ // ------------------------------------------------------------------
307
+
308
+ pi.registerTool({
309
+ name: "session_list",
310
+ label: "Session List",
311
+ description:
312
+ "List past pi sessions with optional filters by project, date range, or archive status. Returns session metadata and summaries.",
313
+ promptSnippet:
314
+ "List/filter past pi sessions by project, date, or archive status.",
315
+ parameters: Type.Object({
316
+ project: Type.Optional(
317
+ Type.String({ description: "Filter by project name or path substring" })
318
+ ),
319
+ after: Type.Optional(
320
+ Type.String({
321
+ description: "Only sessions after this date (ISO format, e.g. 2026-03-01)",
322
+ })
323
+ ),
324
+ before: Type.Optional(
325
+ Type.String({
326
+ description: "Only sessions before this date (ISO format)",
327
+ })
328
+ ),
329
+ archived: Type.Optional(
330
+ Type.Boolean({ description: "Filter by archived status" })
331
+ ),
332
+ limit: Type.Optional(
333
+ Type.Number({ description: "Max results (default 20, max 50)" })
334
+ ),
335
+ }),
336
+ async execute(_toolCallId, params) {
337
+ if (!sessionIndex || sessionIndex.size() === 0) {
338
+ const msg = !sessionIndex
339
+ ? "session-search is not configured. The user can run /session-search-setup to set it up."
340
+ : "Session index is empty.";
341
+ return { content: [{ type: "text", text: msg }], details: {} };
342
+ }
343
+
344
+ const limit = Math.min(params.limit ?? 20, 50);
345
+ const sessions = sessionIndex.list({
346
+ project: params.project,
347
+ after: params.after,
348
+ before: params.before,
349
+ archived: params.archived,
350
+ limit,
351
+ });
352
+
353
+ if (sessions.length === 0) {
354
+ return {
355
+ content: [{ type: "text", text: "No sessions match the filters." }],
356
+ details: {},
357
+ };
358
+ }
359
+
360
+ const home = process.env.HOME || "";
361
+ const output = sessions
362
+ .map((s, i) => {
363
+ const name = s.name || truncate(s.firstUserMessage, 60);
364
+ const date = s.startedAt.split("T")[0];
365
+ const tools = s.toolCalls
366
+ .slice(0, 3)
367
+ .map((t) => t.name)
368
+ .join(", ");
369
+ const arch = s.archived ? " (archived)" : "";
370
+ const displayFile = s.file.replace(home, "~");
371
+ return `${i + 1}. **${name}** — ${date}${arch}\n CWD: ${s.cwd} | ${s.userMessageCount} msgs | Tools: ${tools}\n File: ${displayFile}`;
372
+ })
373
+ .join("\n\n");
374
+
375
+ const header = `${sessions.length} sessions (${sessionIndex.size()} total indexed):\n\n`;
376
+
377
+ return {
378
+ content: [{ type: "text", text: header + output }],
379
+ details: { resultCount: sessions.length },
380
+ };
381
+ },
382
+ });
383
+
384
+ // ------------------------------------------------------------------
385
+ // Tool: session_read
386
+ // ------------------------------------------------------------------
387
+
388
+ pi.registerTool({
389
+ name: "session_read",
390
+ label: "Session Read",
391
+ description:
392
+ "Read the full conversation from a past pi session. Provide the session file path or session ID. Supports pagination for large sessions.",
393
+ promptSnippet:
394
+ "Read the full conversation from a specific past pi session by file path or ID.",
395
+ parameters: Type.Object({
396
+ session: Type.String({
397
+ description: "Session file path (from session_search/session_list results) or session UUID",
398
+ }),
399
+ offset: Type.Optional(
400
+ Type.Number({
401
+ description: "Start from this entry index (for pagination, default 0)",
402
+ })
403
+ ),
404
+ limit: Type.Optional(
405
+ Type.Number({
406
+ description: "Max entries to return (default 50, max 100)",
407
+ })
408
+ ),
409
+ include_tools: Type.Optional(
410
+ Type.Boolean({
411
+ description: "Include tool results in output (default false, verbose)",
412
+ })
413
+ ),
414
+ }),
415
+ async execute(_toolCallId, params) {
416
+ // Resolve file path
417
+ let filePath = params.session;
418
+
419
+ // If it looks like a UUID, try to find it in the index
420
+ if (
421
+ sessionIndex &&
422
+ !filePath.endsWith(".jsonl") &&
423
+ !filePath.includes("/")
424
+ ) {
425
+ const entry = sessionIndex.get(filePath);
426
+ if (entry) {
427
+ filePath = entry.session.file;
428
+ } else {
429
+ return {
430
+ content: [
431
+ {
432
+ type: "text",
433
+ text: `Session not found: "${params.session}". Use session_search or session_list to find the session file path.`,
434
+ },
435
+ ],
436
+ details: {},
437
+ };
438
+ }
439
+ }
440
+
441
+ // Expand ~
442
+ if (filePath.startsWith("~")) {
443
+ filePath = filePath.replace("~", process.env.HOME || "");
444
+ }
445
+
446
+ const limit = Math.min(params.limit ?? 50, 100);
447
+ const output = readSessionConversation(filePath, {
448
+ offset: params.offset ?? 0,
449
+ limit,
450
+ includeTools: params.include_tools ?? false,
451
+ });
452
+
453
+ return {
454
+ content: [{ type: "text", text: output }],
455
+ details: { file: filePath },
456
+ };
457
+ },
458
+ });
459
+ }
460
+
461
+ // ─── Helpers ─────────────────────────────────────────────────────────
462
+
463
+ function truncate(s: string, max: number): string {
464
+ if (s.length <= max) return s;
465
+ return s.slice(0, max) + "…";
466
+ }