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/LICENSE +21 -0
- package/README.md +84 -0
- package/package.json +43 -0
- package/skills/session-history/SKILL.md +68 -0
- package/src/config.ts +54 -0
- package/src/embedder.ts +251 -0
- package/src/index.ts +466 -0
- package/src/parser.ts +348 -0
- package/src/reader.ts +179 -0
- package/src/session-index.ts +441 -0
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
|
+
}
|