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/parser.ts ADDED
@@ -0,0 +1,348 @@
1
+ import { readFileSync, readdirSync, statSync, existsSync, openSync, readSync, closeSync } from "node:fs";
2
+ import { join, basename, dirname } from "node:path";
3
+
4
+ // ─── Types ───────────────────────────────────────────────────────────
5
+
6
+ export interface SessionHeader {
7
+ type: "session";
8
+ version: number;
9
+ id: string;
10
+ timestamp: string;
11
+ cwd: string;
12
+ parentSession?: string;
13
+ }
14
+
15
+ export interface SessionEntry {
16
+ type: string;
17
+ id: string;
18
+ parentId: string | null;
19
+ timestamp: string;
20
+ [key: string]: any;
21
+ }
22
+
23
+ export interface ParsedSession {
24
+ /** Absolute path to the .jsonl file */
25
+ file: string;
26
+ /** Session UUID */
27
+ id: string;
28
+ /** ISO timestamp of session start */
29
+ startedAt: string;
30
+ /** ISO timestamp of last entry */
31
+ endedAt: string;
32
+ /** Working directory */
33
+ cwd: string;
34
+ /** Display name (from session_info entry) */
35
+ name?: string;
36
+ /** Whether this is from the archive */
37
+ archived: boolean;
38
+ /** Project directory slug (the session folder name) */
39
+ projectSlug: string;
40
+ /** Models used */
41
+ models: string[];
42
+ /** User message count */
43
+ userMessageCount: number;
44
+ /** Assistant message count */
45
+ assistantMessageCount: number;
46
+ /** Tool calls made */
47
+ toolCalls: ToolCallSummary[];
48
+ /** Files read */
49
+ filesRead: string[];
50
+ /** Files written/edited */
51
+ filesModified: string[];
52
+ /** First user message (for display) */
53
+ firstUserMessage: string;
54
+ /** All user messages (for indexing) */
55
+ userMessages: string[];
56
+ /** All assistant text (for indexing, truncated) */
57
+ assistantText: string;
58
+ /** Compaction summaries */
59
+ compactionSummaries: string[];
60
+ /** Branch summaries */
61
+ branchSummaries: string[];
62
+ /** Total token cost */
63
+ totalCost: number;
64
+ /** Total tokens used */
65
+ totalTokens: number;
66
+ }
67
+
68
+ export interface ToolCallSummary {
69
+ name: string;
70
+ count: number;
71
+ }
72
+
73
+ // ─── Discovery ───────────────────────────────────────────────────────
74
+
75
+ const DEFAULT_SESSION_DIR = join(
76
+ process.env.HOME || "~",
77
+ ".pi",
78
+ "agent",
79
+ "sessions"
80
+ );
81
+ const DEFAULT_ARCHIVE_DIR = join(
82
+ process.env.HOME || "~",
83
+ ".pi",
84
+ "agent",
85
+ "sessions-archive"
86
+ );
87
+
88
+ /**
89
+ * Find all .jsonl session files in the default + extra directories.
90
+ */
91
+ export function discoverSessionFiles(
92
+ extraSessionDirs: string[] = [],
93
+ extraArchiveDirs: string[] = [],
94
+ ): { file: string; archived: boolean }[] {
95
+ const sDirs = [DEFAULT_SESSION_DIR, ...extraSessionDirs];
96
+ const aDirs = [DEFAULT_ARCHIVE_DIR, ...extraArchiveDirs];
97
+
98
+ const results: { file: string; archived: boolean }[] = [];
99
+
100
+ for (const dir of sDirs) {
101
+ if (!existsSync(dir)) continue;
102
+ for (const entry of walkJsonl(dir)) {
103
+ results.push({ file: entry, archived: false });
104
+ }
105
+ }
106
+
107
+ for (const dir of aDirs) {
108
+ if (!existsSync(dir)) continue;
109
+ for (const entry of walkJsonl(dir)) {
110
+ results.push({ file: entry, archived: true });
111
+ }
112
+ }
113
+
114
+ return results;
115
+ }
116
+
117
+ function walkJsonl(dir: string): string[] {
118
+ const files: string[] = [];
119
+ try {
120
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
121
+ const full = join(dir, entry.name);
122
+ if (entry.isDirectory()) {
123
+ files.push(...walkJsonl(full));
124
+ } else if (entry.name.endsWith(".jsonl") && entry.name !== "pins.json" && entry.name !== "active-sessions.json") {
125
+ files.push(full);
126
+ }
127
+ }
128
+ } catch {
129
+ // permission error or similar — skip
130
+ }
131
+ return files;
132
+ }
133
+
134
+ // ─── Header-only read ────────────────────────────────────────────────
135
+
136
+ /**
137
+ * Read just the session UUID from the JSONL header line.
138
+ * Much cheaper than a full parse — used to correlate files with index entries
139
+ * when a session has been moved (e.g. active → archive).
140
+ */
141
+ export function readSessionId(file: string): string | null {
142
+ try {
143
+ const fd = openSync(file, "r");
144
+ try {
145
+ // Read just enough for the first line (headers are ~200 bytes)
146
+ const buf = Buffer.alloc(1024);
147
+ const bytesRead = readSync(fd, buf, 0, 1024, 0);
148
+ const firstLine = buf.toString("utf8", 0, bytesRead).split("\n")[0];
149
+ if (!firstLine) return null;
150
+ const obj = JSON.parse(firstLine);
151
+ return obj.type === "session" ? obj.id : null;
152
+ } finally {
153
+ closeSync(fd);
154
+ }
155
+ } catch {
156
+ return null;
157
+ }
158
+ }
159
+
160
+ // ─── Parsing ─────────────────────────────────────────────────────────
161
+
162
+ const MAX_ASSISTANT_TEXT = 50_000; // cap assistant text for indexing
163
+
164
+ export function parseSession(
165
+ file: string,
166
+ archived: boolean
167
+ ): ParsedSession | null {
168
+ let raw: string;
169
+ try {
170
+ raw = readFileSync(file, "utf8");
171
+ } catch {
172
+ return null;
173
+ }
174
+
175
+ const lines = raw.trim().split("\n");
176
+ if (lines.length === 0) return null;
177
+
178
+ let header: SessionHeader | null = null;
179
+ const entries: SessionEntry[] = [];
180
+
181
+ for (const line of lines) {
182
+ if (!line.trim()) continue;
183
+ try {
184
+ const obj = JSON.parse(line);
185
+ if (obj.type === "session") {
186
+ header = obj as SessionHeader;
187
+ } else {
188
+ entries.push(obj as SessionEntry);
189
+ }
190
+ } catch {
191
+ // skip malformed lines
192
+ }
193
+ }
194
+
195
+ if (!header) return null;
196
+
197
+ // Determine project slug from the directory name
198
+ const parentDir = basename(dirname(file));
199
+ const projectSlug = parentDir.startsWith("--") ? parentDir : "unknown";
200
+
201
+ // Extract data
202
+ const models = new Set<string>();
203
+ const toolCallMap = new Map<string, number>();
204
+ const filesRead = new Set<string>();
205
+ const filesModified = new Set<string>();
206
+ const userMessages: string[] = [];
207
+ const compactionSummaries: string[] = [];
208
+ const branchSummaries: string[] = [];
209
+ let assistantText = "";
210
+ let name: string | undefined;
211
+ let lastTimestamp = header.timestamp;
212
+ let totalCost = 0;
213
+ let totalTokens = 0;
214
+ let userMsgCount = 0;
215
+ let assistantMsgCount = 0;
216
+
217
+ for (const entry of entries) {
218
+ if (entry.timestamp) lastTimestamp = entry.timestamp;
219
+
220
+ switch (entry.type) {
221
+ case "message": {
222
+ const msg = entry.message;
223
+ if (!msg) break;
224
+
225
+ if (msg.role === "user") {
226
+ userMsgCount++;
227
+ const text = extractTextContent(msg.content);
228
+ if (text) userMessages.push(text);
229
+ }
230
+
231
+ if (msg.role === "assistant") {
232
+ assistantMsgCount++;
233
+ if (msg.provider && msg.model) {
234
+ models.add(`${msg.provider}/${msg.model}`);
235
+ }
236
+ if (msg.usage) {
237
+ totalCost += msg.usage.cost?.total ?? 0;
238
+ totalTokens += msg.usage.totalTokens ?? 0;
239
+ }
240
+ // Extract text + tool calls
241
+ if (Array.isArray(msg.content)) {
242
+ for (const block of msg.content) {
243
+ if (block.type === "text" && assistantText.length < MAX_ASSISTANT_TEXT) {
244
+ assistantText += block.text + "\n";
245
+ }
246
+ if (block.type === "toolCall") {
247
+ const name = block.name;
248
+ toolCallMap.set(name, (toolCallMap.get(name) ?? 0) + 1);
249
+ }
250
+ }
251
+ }
252
+ }
253
+
254
+ if (msg.role === "toolResult") {
255
+ const tn = msg.toolName;
256
+ // Track file operations
257
+ if (tn === "read" || tn === "lsp_hover" || tn === "lsp_definition") {
258
+ const path = extractPathFromToolResult(entry, msg);
259
+ if (path) filesRead.add(path);
260
+ }
261
+ if (tn === "write" || tn === "edit") {
262
+ const path = extractPathFromToolResult(entry, msg);
263
+ if (path) filesModified.add(path);
264
+ }
265
+ }
266
+ break;
267
+ }
268
+
269
+ case "model_change":
270
+ if (entry.provider && entry.modelId) {
271
+ models.add(`${entry.provider}/${entry.modelId}`);
272
+ }
273
+ break;
274
+
275
+ case "compaction":
276
+ if (entry.summary) compactionSummaries.push(entry.summary);
277
+ break;
278
+
279
+ case "branch_summary":
280
+ if (entry.summary) branchSummaries.push(entry.summary);
281
+ break;
282
+
283
+ case "session_info":
284
+ if (entry.name) name = entry.name;
285
+ break;
286
+ }
287
+ }
288
+
289
+ const toolCalls = Array.from(toolCallMap.entries())
290
+ .map(([name, count]) => ({ name, count }))
291
+ .sort((a, b) => b.count - a.count);
292
+
293
+ return {
294
+ file,
295
+ id: header.id,
296
+ startedAt: header.timestamp,
297
+ endedAt: lastTimestamp,
298
+ cwd: header.cwd,
299
+ name,
300
+ archived,
301
+ projectSlug,
302
+ models: Array.from(models),
303
+ userMessageCount: userMsgCount,
304
+ assistantMessageCount: assistantMsgCount,
305
+ toolCalls,
306
+ filesRead: Array.from(filesRead).slice(0, 100),
307
+ filesModified: Array.from(filesModified).slice(0, 100),
308
+ firstUserMessage: userMessages[0] ?? "",
309
+ userMessages,
310
+ assistantText: assistantText.slice(0, MAX_ASSISTANT_TEXT),
311
+ compactionSummaries,
312
+ branchSummaries,
313
+ totalCost,
314
+ totalTokens,
315
+ };
316
+ }
317
+
318
+ // ─── Helpers ─────────────────────────────────────────────────────────
319
+
320
+ function extractTextContent(content: any): string {
321
+ if (typeof content === "string") return content;
322
+ if (Array.isArray(content)) {
323
+ return content
324
+ .filter((b: any) => b.type === "text")
325
+ .map((b: any) => b.text)
326
+ .join("\n");
327
+ }
328
+ return "";
329
+ }
330
+
331
+ /**
332
+ * Try to extract a file path from a tool result entry.
333
+ * We look at the parent assistant message's tool call arguments.
334
+ */
335
+ function extractPathFromToolResult(_entry: SessionEntry, msg: any): string | null {
336
+ // Tool results often have details with path info
337
+ if (msg.details?.path) return msg.details.path;
338
+ if (msg.details?.diff) {
339
+ // edit tool — path is in the diff header
340
+ const match = msg.details.diff?.match?.(/^ \d+ (.*)/m);
341
+ // Not reliable, skip
342
+ }
343
+ // Try content for read tool
344
+ if (msg.toolName === "read" && msg.content?.[0]?.text) {
345
+ // The content is the file content, not the path — skip
346
+ }
347
+ return null;
348
+ }
package/src/reader.ts ADDED
@@ -0,0 +1,179 @@
1
+ import { readFileSync } from "node:fs";
2
+
3
+ /**
4
+ * Read a session JSONL file and format it as a readable conversation.
5
+ * Supports offset/limit for pagination of large sessions.
6
+ */
7
+ export function readSessionConversation(
8
+ file: string,
9
+ options?: { offset?: number; limit?: number; includeTools?: boolean }
10
+ ): string {
11
+ const offset = options?.offset ?? 0;
12
+ const limit = options?.limit ?? 50;
13
+ const includeTools = options?.includeTools ?? false;
14
+
15
+ let raw: string;
16
+ try {
17
+ raw = readFileSync(file, "utf8");
18
+ } catch (err: any) {
19
+ return `Error reading session: ${err.message}`;
20
+ }
21
+
22
+ const lines = raw.trim().split("\n");
23
+ const entries: any[] = [];
24
+ let header: any = null;
25
+
26
+ for (const line of lines) {
27
+ if (!line.trim()) continue;
28
+ try {
29
+ const obj = JSON.parse(line);
30
+ if (obj.type === "session") {
31
+ header = obj;
32
+ } else {
33
+ entries.push(obj);
34
+ }
35
+ } catch {
36
+ // skip
37
+ }
38
+ }
39
+
40
+ // Filter to conversation-relevant entries
41
+ const conversationEntries = entries.filter((e) => {
42
+ if (e.type === "message") {
43
+ const role = e.message?.role;
44
+ if (role === "user") return true;
45
+ if (role === "assistant") return true;
46
+ if (role === "toolResult" && includeTools) return true;
47
+ return false;
48
+ }
49
+ if (e.type === "compaction") return true;
50
+ if (e.type === "branch_summary") return true;
51
+ if (e.type === "session_info") return true;
52
+ if (e.type === "model_change") return true;
53
+ return false;
54
+ });
55
+
56
+ const total = conversationEntries.length;
57
+ const page = conversationEntries.slice(offset, offset + limit);
58
+
59
+ const output: string[] = [];
60
+
61
+ // Header
62
+ if (header) {
63
+ output.push(
64
+ `Session: ${header.id}\nStarted: ${header.timestamp}\nCWD: ${header.cwd}`
65
+ );
66
+ output.push(`Total entries: ${total} (showing ${offset + 1}-${Math.min(offset + limit, total)})`);
67
+ output.push("---");
68
+ }
69
+
70
+ for (const entry of page) {
71
+ const ts = entry.timestamp
72
+ ? new Date(entry.timestamp).toLocaleString()
73
+ : "";
74
+
75
+ switch (entry.type) {
76
+ case "message": {
77
+ const msg = entry.message;
78
+ if (msg.role === "user") {
79
+ const text = extractText(msg.content);
80
+ output.push(`\n**User** (${ts}):\n${text}`);
81
+ } else if (msg.role === "assistant") {
82
+ const text = extractAssistantText(msg.content);
83
+ const model = msg.model ? ` [${msg.provider}/${msg.model}]` : "";
84
+ output.push(`\n**Assistant**${model} (${ts}):\n${text}`);
85
+
86
+ // Show tool calls as summaries
87
+ if (Array.isArray(msg.content)) {
88
+ const calls = msg.content.filter(
89
+ (b: any) => b.type === "toolCall"
90
+ );
91
+ if (calls.length > 0) {
92
+ const callList = calls
93
+ .map(
94
+ (c: any) =>
95
+ ` → ${c.name}(${summarizeArgs(c.arguments)})`
96
+ )
97
+ .join("\n");
98
+ output.push(callList);
99
+ }
100
+ }
101
+ } else if (msg.role === "toolResult" && includeTools) {
102
+ const text = extractText(msg.content);
103
+ const truncated =
104
+ text.length > 500 ? text.slice(0, 500) + "…" : text;
105
+ const err = msg.isError ? " ❌" : "";
106
+ output.push(
107
+ `\n **${msg.toolName}** result${err} (${ts}):\n ${truncated}`
108
+ );
109
+ }
110
+ break;
111
+ }
112
+
113
+ case "compaction":
114
+ output.push(
115
+ `\n--- Compaction (${ts}) ---\n${entry.summary?.slice(0, 1000) ?? "(no summary)"}`
116
+ );
117
+ break;
118
+
119
+ case "branch_summary":
120
+ output.push(
121
+ `\n--- Branch Summary (${ts}) ---\n${entry.summary?.slice(0, 500) ?? "(no summary)"}`
122
+ );
123
+ break;
124
+
125
+ case "model_change":
126
+ output.push(
127
+ `\n*Model changed to ${entry.provider}/${entry.modelId}* (${ts})`
128
+ );
129
+ break;
130
+
131
+ case "session_info":
132
+ output.push(`\n*Session renamed to: ${entry.name}* (${ts})`);
133
+ break;
134
+ }
135
+ }
136
+
137
+ // Pagination hint
138
+ if (offset + limit < total) {
139
+ output.push(
140
+ `\n--- ${total - offset - limit} more entries. Use offset=${offset + limit} to continue. ---`
141
+ );
142
+ }
143
+
144
+ return output.join("\n");
145
+ }
146
+
147
+ // ─── Helpers ─────────────────────────────────────────────────────────
148
+
149
+ function extractText(content: any): string {
150
+ if (typeof content === "string") return content;
151
+ if (Array.isArray(content)) {
152
+ return content
153
+ .filter((b: any) => b.type === "text")
154
+ .map((b: any) => b.text)
155
+ .join("\n");
156
+ }
157
+ return "";
158
+ }
159
+
160
+ function extractAssistantText(content: any): string {
161
+ if (!Array.isArray(content)) return String(content ?? "");
162
+ return content
163
+ .filter((b: any) => b.type === "text")
164
+ .map((b: any) => b.text)
165
+ .join("\n");
166
+ }
167
+
168
+ function summarizeArgs(args: Record<string, any>): string {
169
+ if (!args) return "";
170
+ const parts: string[] = [];
171
+ for (const [key, val] of Object.entries(args)) {
172
+ if (typeof val === "string") {
173
+ parts.push(`${key}="${val.length > 60 ? val.slice(0, 60) + "…" : val}"`);
174
+ } else {
175
+ parts.push(`${key}=${JSON.stringify(val)?.slice(0, 40)}`);
176
+ }
177
+ }
178
+ return parts.join(", ");
179
+ }