pi-read-map 1.0.0 → 1.2.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.
@@ -36,95 +36,466 @@ function analyzeJsonLine(
36
36
  }
37
37
  }
38
38
 
39
+ // ---------------------------------------------------------------------------
40
+ // Pi session detection and parsing
41
+ // ---------------------------------------------------------------------------
42
+
43
+ interface PiSessionHeader {
44
+ type: "session";
45
+ version: number;
46
+ id: string;
47
+ timestamp: string;
48
+ cwd: string;
49
+ parentSession?: string;
50
+ }
51
+
39
52
  /**
40
- * Generate a file map for a JSON Lines file.
41
- * Each line is treated as a separate JSON record.
53
+ * Check if the first line of a JSONL file is a pi session header.
42
54
  */
43
- export async function jsonlMapper(
55
+ function parsePiSessionHeader(line: string): PiSessionHeader | null {
56
+ try {
57
+ const parsed = JSON.parse(line) as Record<string, unknown>;
58
+ if (
59
+ parsed["type"] === "session" &&
60
+ typeof parsed["version"] === "number" &&
61
+ typeof parsed["id"] === "string" &&
62
+ typeof parsed["timestamp"] === "string" &&
63
+ typeof parsed["cwd"] === "string"
64
+ ) {
65
+ return parsed as unknown as PiSessionHeader;
66
+ }
67
+ } catch {
68
+ // Not valid JSON
69
+ }
70
+ return null;
71
+ }
72
+
73
+ /** Accumulated entry type counts for a pi session. */
74
+ interface SessionCounts {
75
+ user: number;
76
+ assistant: number;
77
+ toolResult: number;
78
+ compaction: number;
79
+ branchSummary: number;
80
+ modelChange: number;
81
+ sessionInfo: number;
82
+ other: number;
83
+ }
84
+
85
+ /** A structural symbol collected while streaming session entries. */
86
+ interface SessionSymbolRecord {
87
+ symbol: FileSymbol;
88
+ /** Line where this symbol's "span" starts (used to compute endLine). */
89
+ spanStart: number;
90
+ }
91
+
92
+ /**
93
+ * Extract a short text preview from a user message content field.
94
+ * Handles both string and content-block-array formats.
95
+ */
96
+ function extractUserPreview(content: unknown, maxLength = 80): string {
97
+ let text = "";
98
+ if (typeof content === "string") {
99
+ text = content;
100
+ } else if (Array.isArray(content)) {
101
+ for (const block of content) {
102
+ if (
103
+ typeof block === "object" &&
104
+ block !== null &&
105
+ (block as Record<string, unknown>)["type"] === "text" &&
106
+ typeof (block as Record<string, unknown>)["text"] === "string"
107
+ ) {
108
+ text = (block as Record<string, unknown>)["text"] as string;
109
+ break;
110
+ }
111
+ }
112
+ }
113
+ const cleaned = text.replaceAll(/\s+/g, " ").trim();
114
+ if (cleaned.length <= maxLength) {
115
+ return cleaned;
116
+ }
117
+ return `${cleaned.slice(0, maxLength - 3)}...`;
118
+ }
119
+
120
+ const pad = (n: number) => String(n).padStart(2, "0");
121
+
122
+ /**
123
+ * Format the session timestamp for display.
124
+ * Returns "YYYY-MM-DD HH:MM UTC" or the raw string on parse failure.
125
+ */
126
+ function formatSessionTimestamp(ts: string): string {
127
+ const d = new Date(ts);
128
+ if (!Number.isFinite(d.getTime())) {
129
+ return ts;
130
+ }
131
+ return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())} UTC`;
132
+ }
133
+
134
+ /**
135
+ * Build a stats summary string from entry type counts.
136
+ */
137
+ function formatStatsSummary(counts: SessionCounts): string {
138
+ const parts: string[] = [];
139
+ if (counts.user > 0) {
140
+ parts.push(`${counts.user} user`);
141
+ }
142
+ if (counts.assistant > 0) {
143
+ parts.push(`${counts.assistant} assistant`);
144
+ }
145
+ if (counts.toolResult > 0) {
146
+ parts.push(`${counts.toolResult} tool results`);
147
+ }
148
+ if (counts.compaction > 0) {
149
+ parts.push(`${counts.compaction} compaction`);
150
+ }
151
+ if (counts.branchSummary > 0) {
152
+ parts.push(`${counts.branchSummary} branch summary`);
153
+ }
154
+ if (counts.modelChange > 0) {
155
+ parts.push(`${counts.modelChange} model change`);
156
+ }
157
+ return `Stats: ${parts.join(", ")}`;
158
+ }
159
+
160
+ /**
161
+ * Count an entry by its type, incrementing the appropriate counter.
162
+ */
163
+ function countEntryType(
164
+ counts: SessionCounts,
165
+ entry: Record<string, unknown>,
166
+ entryType: string | undefined
167
+ ): void {
168
+ if (entryType === "message") {
169
+ const msg = entry["message"] as Record<string, unknown> | undefined;
170
+ const role = msg?.["role"] as string | undefined;
171
+ if (role === "user") {
172
+ counts.user++;
173
+ } else if (role === "assistant") {
174
+ counts.assistant++;
175
+ } else if (role === "toolResult") {
176
+ counts.toolResult++;
177
+ }
178
+ } else if (entryType === "compaction") {
179
+ counts.compaction++;
180
+ } else if (entryType === "branch_summary") {
181
+ counts.branchSummary++;
182
+ } else if (entryType === "model_change") {
183
+ counts.modelChange++;
184
+ } else if (entryType === "session_info") {
185
+ counts.sessionInfo++;
186
+ } else {
187
+ counts.other++;
188
+ }
189
+ }
190
+
191
+ /**
192
+ * Parse a pi session JSONL file into a conversation-aware structural map.
193
+ *
194
+ * Streams line-by-line, producing symbols for:
195
+ * - Session header (Module)
196
+ * - Stats summary (Property)
197
+ * - User message turns (Function) — line range spans through responses
198
+ * - Compaction boundaries (Namespace)
199
+ * - Model changes (Namespace)
200
+ * - Session name (Property)
201
+ */
202
+ async function parsePiSession(
44
203
  filePath: string,
204
+ header: PiSessionHeader,
205
+ totalBytes: number,
45
206
  signal?: AbortSignal
46
207
  ): Promise<FileMap | null> {
47
- try {
48
- const stats = await stat(filePath);
49
- const totalBytes = stats.size;
208
+ const counts: SessionCounts = {
209
+ user: 0,
210
+ assistant: 0,
211
+ toolResult: 0,
212
+ compaction: 0,
213
+ branchSummary: 0,
214
+ modelChange: 0,
215
+ sessionInfo: 0,
216
+ other: 0,
217
+ };
50
218
 
51
- const samples: JsonlSample[] = [];
52
- let lineCount = 0;
53
- let schema: { keys: string[]; type: string } | null = null;
219
+ // Collect structural symbols while streaming.
220
+ // We track "open" user turns to compute their endLine once the next
221
+ // structural entry appears.
222
+ const records: SessionSymbolRecord[] = [];
223
+ let lineCount = 0;
224
+ let openUserTurn: SessionSymbolRecord | null = null;
54
225
 
55
- const stream = createReadStream(filePath, { encoding: "utf8" });
56
- const rl = createInterface({ input: stream });
226
+ const stream = createReadStream(filePath, { encoding: "utf8" });
227
+ const rl = createInterface({ input: stream });
57
228
 
58
- // Abort handling
59
- signal?.addEventListener("abort", () => {
60
- rl.close();
61
- stream.destroy();
62
- });
229
+ signal?.addEventListener("abort", () => {
230
+ rl.close();
231
+ stream.destroy();
232
+ });
63
233
 
64
- for await (const line of rl) {
65
- lineCount++;
66
-
67
- // Collect first few samples (only from first 100 lines)
68
- if (lineCount <= 100 && samples.length < 10 && line.trim()) {
69
- const lineSchema = analyzeJsonLine(line);
70
- if (lineSchema) {
71
- samples.push({
72
- lineNumber: lineCount,
73
- preview: line.slice(0, 80) + (line.length > 80 ? "..." : ""),
74
- keys: lineSchema.keys,
75
- });
76
-
77
- // Store schema from first valid line
78
- if (!schema) {
79
- schema = lineSchema;
80
- }
234
+ for await (const line of rl) {
235
+ lineCount++;
236
+
237
+ // Skip the header (line 1), already parsed
238
+ if (lineCount === 1) {
239
+ continue;
240
+ }
241
+
242
+ const trimmed = line.trim();
243
+ if (!trimmed) {
244
+ continue;
245
+ }
246
+
247
+ let entry: Record<string, unknown>;
248
+ try {
249
+ entry = JSON.parse(trimmed) as Record<string, unknown>;
250
+ } catch {
251
+ continue;
252
+ }
253
+
254
+ const entryType = entry["type"] as string | undefined;
255
+
256
+ countEntryType(counts, entry, entryType);
257
+
258
+ // Collect structural symbols
259
+ if (entryType === "message") {
260
+ const msg = entry["message"] as Record<string, unknown> | undefined;
261
+ const role = msg?.["role"] as string | undefined;
262
+
263
+ if (role === "user") {
264
+ // Close previous user turn
265
+ if (openUserTurn) {
266
+ openUserTurn.symbol.endLine = lineCount - 1;
81
267
  }
268
+
269
+ const preview = extractUserPreview(msg?.["content"]);
270
+ const record: SessionSymbolRecord = {
271
+ symbol: {
272
+ name: `[User] ${preview}`,
273
+ kind: SymbolKind.Function,
274
+ startLine: lineCount,
275
+ endLine: lineCount, // updated when next structural entry appears
276
+ },
277
+ spanStart: lineCount,
278
+ };
279
+ records.push(record);
280
+ openUserTurn = record;
281
+ }
282
+ // assistant and toolResult messages fold into the current user turn.
283
+ // Future: showing tool calls as nested child symbols under
284
+ // user turns would provide richer navigation (currently folded for simplicity).
285
+ } else if (entryType === "compaction") {
286
+ // Close previous user turn
287
+ if (openUserTurn) {
288
+ openUserTurn.symbol.endLine = lineCount - 1;
289
+ openUserTurn = null;
82
290
  }
83
291
 
84
- // Continue counting all lines (don't early exit)
85
- }
292
+ records.push({
293
+ symbol: {
294
+ name: "[Compaction]",
295
+ kind: SymbolKind.Namespace,
296
+ startLine: lineCount,
297
+ endLine: lineCount,
298
+ },
299
+ spanStart: lineCount,
300
+ });
301
+ } else if (entryType === "model_change") {
302
+ const provider = entry["provider"] as string | undefined;
303
+ const modelId = entry["modelId"] as string | undefined;
304
+ const label =
305
+ provider && modelId
306
+ ? `${provider}/${modelId}`
307
+ : (provider ?? modelId ?? "unknown");
86
308
 
87
- // Build symbols from samples
88
- const symbols: FileSymbol[] = [];
309
+ records.push({
310
+ symbol: {
311
+ name: `[Model] ${label}`,
312
+ kind: SymbolKind.Namespace,
313
+ startLine: lineCount,
314
+ endLine: lineCount,
315
+ },
316
+ spanStart: lineCount,
317
+ });
318
+ } else if (entryType === "session_info") {
319
+ const name = entry["name"] as string | undefined;
320
+ if (name) {
321
+ records.push({
322
+ symbol: {
323
+ name: `[Session] ${name}`,
324
+ kind: SymbolKind.Property,
325
+ startLine: lineCount,
326
+ endLine: lineCount,
327
+ },
328
+ spanStart: lineCount,
329
+ });
330
+ }
331
+ } else if (entryType === "branch_summary") {
332
+ // Close previous user turn
333
+ if (openUserTurn) {
334
+ openUserTurn.symbol.endLine = lineCount - 1;
335
+ openUserTurn = null;
336
+ }
89
337
 
90
- // Add schema info as first symbol
91
- if (schema) {
92
- symbols.push({
93
- name: `Schema: ${schema.type}${schema.keys.length > 0 ? ` {${schema.keys.slice(0, 5).join(", ")}${schema.keys.length > 5 ? "..." : ""}}` : ""}`,
94
- kind: SymbolKind.Class,
95
- startLine: 1,
96
- endLine: 1,
338
+ records.push({
339
+ symbol: {
340
+ name: "[Branch Summary]",
341
+ kind: SymbolKind.Namespace,
342
+ startLine: lineCount,
343
+ endLine: lineCount,
344
+ },
345
+ spanStart: lineCount,
97
346
  });
98
347
  }
348
+ }
99
349
 
100
- // Add sample entries
101
- for (const sample of samples.slice(0, 5)) {
102
- symbols.push({
103
- name: `Line ${sample.lineNumber}: ${sample.preview}`,
104
- kind: SymbolKind.Variable,
105
- startLine: sample.lineNumber,
106
- endLine: sample.lineNumber,
107
- });
350
+ // Close final open user turn
351
+ if (openUserTurn) {
352
+ openUserTurn.symbol.endLine = lineCount;
353
+ }
354
+
355
+ // Assemble symbols in line order
356
+ const symbols: FileSymbol[] = [
357
+ // Header symbol
358
+ {
359
+ name: `Pi Session: ${header.cwd} (${formatSessionTimestamp(header.timestamp)})`,
360
+ kind: SymbolKind.Module,
361
+ startLine: 1,
362
+ endLine: 1,
363
+ },
364
+ // Stats summary
365
+ {
366
+ name: formatStatsSummary(counts),
367
+ kind: SymbolKind.Property,
368
+ startLine: 1,
369
+ endLine: lineCount,
370
+ },
371
+ // Conversation symbols (already in line order from streaming)
372
+ ...records.map((r) => r.symbol),
373
+ ];
374
+
375
+ return {
376
+ path: filePath,
377
+ totalLines: lineCount,
378
+ totalBytes,
379
+ language: "Pi Session",
380
+ symbols,
381
+ imports: [],
382
+ detailLevel: DetailLevel.Full,
383
+ };
384
+ }
385
+
386
+ // ---------------------------------------------------------------------------
387
+ // Generic JSONL parsing (original logic)
388
+ // ---------------------------------------------------------------------------
389
+
390
+ /**
391
+ * Generate a generic file map for a JSON Lines file.
392
+ */
393
+ async function parseGenericJsonl(
394
+ filePath: string,
395
+ totalBytes: number,
396
+ signal?: AbortSignal
397
+ ): Promise<FileMap | null> {
398
+ const samples: JsonlSample[] = [];
399
+ let lineCount = 0;
400
+ let schema: { keys: string[]; type: string } | null = null;
401
+
402
+ const stream = createReadStream(filePath, { encoding: "utf8" });
403
+ const rl = createInterface({ input: stream });
404
+
405
+ signal?.addEventListener("abort", () => {
406
+ rl.close();
407
+ stream.destroy();
408
+ });
409
+
410
+ for await (const line of rl) {
411
+ lineCount++;
412
+
413
+ // Collect first few samples (only from first 100 lines)
414
+ if (lineCount <= 100 && samples.length < 10 && line.trim()) {
415
+ const lineSchema = analyzeJsonLine(line);
416
+ if (lineSchema) {
417
+ samples.push({
418
+ lineNumber: lineCount,
419
+ preview: line.slice(0, 80) + (line.length > 80 ? "..." : ""),
420
+ keys: lineSchema.keys,
421
+ });
422
+
423
+ if (!schema) {
424
+ schema = lineSchema;
425
+ }
426
+ }
108
427
  }
428
+ }
109
429
 
110
- // Add "more lines" indicator if applicable
111
- if (lineCount > samples.length) {
112
- symbols.push({
113
- name: `... ${lineCount - samples.length} more lines`,
114
- kind: SymbolKind.Variable,
115
- startLine: samples.length + 1,
116
- endLine: lineCount,
117
- });
430
+ const symbols: FileSymbol[] = [];
431
+
432
+ if (schema) {
433
+ symbols.push({
434
+ name: `Schema: ${schema.type}${schema.keys.length > 0 ? ` {${schema.keys.slice(0, 5).join(", ")}${schema.keys.length > 5 ? "..." : ""}}` : ""}`,
435
+ kind: SymbolKind.Class,
436
+ startLine: 1,
437
+ endLine: 1,
438
+ });
439
+ }
440
+
441
+ for (const sample of samples.slice(0, 5)) {
442
+ symbols.push({
443
+ name: `Line ${sample.lineNumber}: ${sample.preview}`,
444
+ kind: SymbolKind.Variable,
445
+ startLine: sample.lineNumber,
446
+ endLine: sample.lineNumber,
447
+ });
448
+ }
449
+
450
+ if (lineCount > samples.length) {
451
+ symbols.push({
452
+ name: `... ${lineCount - samples.length} more lines`,
453
+ kind: SymbolKind.Variable,
454
+ startLine: samples.length + 1,
455
+ endLine: lineCount,
456
+ });
457
+ }
458
+
459
+ return {
460
+ path: filePath,
461
+ totalLines: lineCount,
462
+ totalBytes,
463
+ language: "JSON Lines",
464
+ symbols,
465
+ imports: [],
466
+ detailLevel: DetailLevel.Full,
467
+ };
468
+ }
469
+
470
+ // ---------------------------------------------------------------------------
471
+ // Entry point
472
+ // ---------------------------------------------------------------------------
473
+
474
+ /**
475
+ * Generate a file map for a JSON Lines file.
476
+ *
477
+ * If the first line is a pi session header (`{"type":"session",...}`),
478
+ * produces a conversation-aware structural map. Otherwise falls back to
479
+ * generic schema + sample display.
480
+ */
481
+ export async function jsonlMapper(
482
+ filePath: string,
483
+ signal?: AbortSignal
484
+ ): Promise<FileMap | null> {
485
+ try {
486
+ const stats = await stat(filePath);
487
+ const totalBytes = stats.size;
488
+
489
+ // Read just the first line to detect pi sessions
490
+ const firstLine = await readFirstLine(filePath, signal);
491
+ if (firstLine !== null) {
492
+ const sessionHeader = parsePiSessionHeader(firstLine);
493
+ if (sessionHeader) {
494
+ return parsePiSession(filePath, sessionHeader, totalBytes, signal);
495
+ }
118
496
  }
119
497
 
120
- return {
121
- path: filePath,
122
- totalLines: lineCount,
123
- totalBytes,
124
- language: "JSON Lines",
125
- symbols,
126
- detailLevel: DetailLevel.Full,
127
- };
498
+ return parseGenericJsonl(filePath, totalBytes, signal);
128
499
  } catch (error) {
129
500
  if (signal?.aborted) {
130
501
  return null;
@@ -133,3 +504,32 @@ export async function jsonlMapper(
133
504
  return null;
134
505
  }
135
506
  }
507
+
508
+ /**
509
+ * Read the first non-empty line from a file.
510
+ */
511
+ async function readFirstLine(
512
+ filePath: string,
513
+ signal?: AbortSignal
514
+ ): Promise<string | null> {
515
+ const stream = createReadStream(filePath, { encoding: "utf8" });
516
+ const rl = createInterface({ input: stream });
517
+
518
+ signal?.addEventListener("abort", () => {
519
+ rl.close();
520
+ stream.destroy();
521
+ });
522
+
523
+ try {
524
+ for await (const line of rl) {
525
+ const trimmed = line.trim();
526
+ if (trimmed) {
527
+ return trimmed;
528
+ }
529
+ }
530
+ return null;
531
+ } finally {
532
+ rl.close();
533
+ stream.destroy();
534
+ }
535
+ }
@@ -171,6 +171,7 @@ export async function markdownMapper(
171
171
  totalBytes,
172
172
  language: "Markdown",
173
173
  symbols,
174
+ imports: [],
174
175
  detailLevel: DetailLevel.Full,
175
176
  };
176
177
  } catch (error) {
@@ -21,6 +21,8 @@ interface PythonSymbol {
21
21
  signature?: string;
22
22
  modifiers?: string[];
23
23
  children?: PythonSymbol[];
24
+ docstring?: string;
25
+ is_exported?: boolean;
24
26
  }
25
27
 
26
28
  interface PythonOutlineResult {
@@ -72,6 +74,14 @@ function convertSymbol(ps: PythonSymbol): FileSymbol {
72
74
  symbol.children = ps.children.map(convertSymbol);
73
75
  }
74
76
 
77
+ if (ps.docstring) {
78
+ symbol.docstring = ps.docstring;
79
+ }
80
+
81
+ if (ps.is_exported !== undefined) {
82
+ symbol.isExported = ps.is_exported;
83
+ }
84
+
75
85
  return symbol;
76
86
  }
77
87
 
@@ -99,6 +109,7 @@ export async function pythonMapper(
99
109
  {
100
110
  signal,
101
111
  timeout: 10_000,
112
+ maxBuffer: 5 * 1024 * 1024, // 5MB
102
113
  }
103
114
  );
104
115
 
@@ -120,13 +131,10 @@ export async function pythonMapper(
120
131
  totalBytes,
121
132
  language: "Python",
122
133
  symbols: result.symbols.map(convertSymbol),
134
+ imports: result.imports ?? [],
123
135
  detailLevel: DetailLevel.Full,
124
136
  };
125
137
 
126
- if (result.imports && result.imports.length > 0) {
127
- fileMap.imports = result.imports;
128
- }
129
-
130
138
  return fileMap;
131
139
  } catch (error) {
132
140
  if (signal?.aborted) {