/ extensions / md.ts
md.ts
  1  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
  2  import { execSync } from "child_process";
  3  import * as fs from "fs";
  4  import * as os from "os";
  5  import * as path from "path";
  6  import * as readline from "readline";
  7  
  8  /**
  9   * Processes Pi agent JSONL session logs into readable text format
 10   * Output directory: ~/.pi/agent/pi-sessions-extracted/
 11   */
 12  
 13  interface SessionMeta {
 14    sessionId: string;
 15    startedAt: Date | null;
 16    cwd: string;
 17  }
 18  
 19  interface ToolCallInfo {
 20    name: string;
 21    cmd: string;
 22    include: boolean;
 23  }
 24  
 25  interface ToolFilter {
 26    mode: "all" | "includeOnly";
 27    includeNames: Set<string>;
 28    excludeNames: Set<string>;
 29  }
 30  
 31  interface ExportOptions {
 32    includeThinking: boolean;
 33    toolFilter: ToolFilter | null;
 34  }
 35  
 36  interface ConversationState {
 37    meta: SessionMeta;
 38    conversation: string[];
 39    pendingToolCalls: Map<string, ToolCallInfo>;
 40  }
 41  
 42  /**
 43   * Extract text from various content formats (string, array of content blocks)
 44   */
 45  function extractTextFromContent(content: unknown): string {
 46    if (content === null || content === undefined) {
 47      return "";
 48    }
 49    if (typeof content === "string") {
 50      return content.trim();
 51    }
 52    if (Array.isArray(content)) {
 53      const parts: string[] = [];
 54      for (const item of content) {
 55        if (typeof item !== "object" || item === null) {
 56          continue;
 57        }
 58        const itemObj = item as Record<string, unknown>;
 59        if (itemObj.type !== "text") {
 60          continue;
 61        }
 62        const text = itemObj.text;
 63        if (typeof text === "string" && text.trim()) {
 64          parts.push(text.trim());
 65        }
 66      }
 67      return parts.join("\n").trim();
 68    }
 69    return "";
 70  }
 71  
 72  /**
 73   * Parse ISO-ish timestamp string to Date
 74   */
 75  function parseIsoTimestamp(ts: unknown): Date | null {
 76    if (!ts || typeof ts !== "string") {
 77      return null;
 78    }
 79    try {
 80      let normalized = ts;
 81      if (ts.endsWith("Z")) {
 82        normalized = ts.slice(0, -1) + "+00:00";
 83      }
 84      const date = new Date(normalized);
 85      return isNaN(date.getTime()) ? null : date;
 86    } catch {
 87      return null;
 88    }
 89  }
 90  
 91  /**
 92   * Create a filesystem-safe slug from a string
 93   */
 94  function slug(s: string): string {
 95    const out: string[] = [];
 96    let prevUnderscore = false;
 97    for (const ch of s) {
 98      const isAlphaNum = /[a-zA-Z0-9]/.test(ch);
 99      const isAllowed = isAlphaNum || ch === "-" || ch === "_";
100      if (isAllowed) {
101        out.push(ch);
102        prevUnderscore = false;
103      } else if (!prevUnderscore) {
104        out.push("_");
105        prevUnderscore = true;
106      }
107    }
108    const result = out.join("").replace(/^_+|_+$/g, "");
109    return result || "unknown";
110  }
111  
112  /**
113   * Iterate through JSONL file line by line, yielding parsed objects
114   */
115  async function* iterJsonl(
116    filePath: string
117  ): AsyncGenerator<Record<string, unknown>> {
118    const fileStream = fs.createReadStream(filePath, { encoding: "utf-8" });
119    const rl = readline.createInterface({
120      input: fileStream,
121      crlfDelay: Infinity,
122    });
123  
124    for await (const line of rl) {
125      const trimmed = line.trim();
126      if (!trimmed) {
127        continue;
128      }
129      try {
130        const obj = JSON.parse(trimmed);
131        if (typeof obj === "object" && obj !== null && !Array.isArray(obj)) {
132          yield obj as Record<string, unknown>;
133        }
134      } catch {
135        // Skip malformed JSON lines
136        continue;
137      }
138    }
139  }
140  
141  function normalizeToolName(name: string): string {
142    return name.trim().toLowerCase();
143  }
144  
145  function createConversationState(): ConversationState {
146    return {
147      meta: {
148        sessionId: "",
149        startedAt: null,
150        cwd: "",
151      },
152      conversation: [],
153      pendingToolCalls: new Map(),
154    };
155  }
156  
157  function formatThinkingBlock(thinking: string): string {
158    return `[thinking]\n${thinking.trim()}\n[/thinking]`;
159  }
160  
161  function extractToolCommand(args: unknown): string {
162    if (typeof args === "object" && args !== null && !Array.isArray(args)) {
163      const argsObj = args as Record<string, unknown>;
164      const cmdVal = argsObj.command || argsObj.cmd;
165      if (typeof cmdVal === "string") {
166        return cmdVal;
167      }
168      try {
169        return JSON.stringify(args, Object.keys(args).sort());
170      } catch {
171        return String(args);
172      }
173    }
174    return String(args);
175  }
176  
177  function shouldIncludeTool(toolName: string, toolFilter: ToolFilter | null): boolean {
178    if (!toolFilter) {
179      return false;
180    }
181  
182    const normalized = normalizeToolName(toolName);
183    if (toolFilter.excludeNames.has(normalized)) {
184      return false;
185    }
186    if (toolFilter.mode === "includeOnly") {
187      return toolFilter.includeNames.has(normalized);
188    }
189    return true;
190  }
191  
192  function applyMessageRecord(
193    rec: Record<string, unknown>,
194    state: ConversationState,
195    options: ExportOptions
196  ): void {
197    const rtype = rec.type;
198  
199    if (rtype === "session" && !state.meta.sessionId) {
200      state.meta.sessionId = String(rec.id || "");
201      state.meta.startedAt = parseIsoTimestamp(rec.timestamp);
202      state.meta.cwd = String(rec.cwd || "");
203      return;
204    }
205  
206    if (rtype !== "message") {
207      return;
208    }
209  
210    const msg = rec.message;
211    if (typeof msg !== "object" || msg === null || Array.isArray(msg)) {
212      return;
213    }
214  
215    const msgObj = msg as Record<string, unknown>;
216    const role = msgObj.role;
217  
218    if (role === "user") {
219      const text = extractTextFromContent(msgObj.content);
220      if (text) {
221        state.conversation.push(`USER: ${text}`);
222      }
223      return;
224    }
225  
226    if (role === "assistant") {
227      appendAssistantMessage(msgObj, state, options);
228      return;
229    }
230  
231    if (role === "toolResult") {
232      appendToolResultMessage(msgObj, state, options.toolFilter);
233    }
234  }
235  
236  function appendAssistantMessage(
237    msgObj: Record<string, unknown>,
238    state: ConversationState,
239    options: ExportOptions
240  ): void {
241    const content = msgObj.content;
242    const textParts: string[] = [];
243    const toolParts: string[] = [];
244  
245    if (typeof content === "string") {
246      if (content.trim()) {
247        textParts.push(content.trim());
248      }
249    } else if (Array.isArray(content)) {
250      for (const item of content) {
251        if (typeof item !== "object" || item === null) {
252          continue;
253        }
254        const itemObj = item as Record<string, unknown>;
255        const itemType = itemObj.type;
256  
257        if (itemType === "text") {
258          const text = itemObj.text;
259          if (typeof text === "string" && text.trim()) {
260            textParts.push(text.trim());
261          }
262          continue;
263        }
264  
265        if (itemType === "thinking") {
266          const thinking = itemObj.thinking;
267          if (
268            options.includeThinking &&
269            typeof thinking === "string" &&
270            thinking.trim()
271          ) {
272            textParts.push(formatThinkingBlock(thinking));
273          }
274          continue;
275        }
276  
277        if (itemType !== "toolCall") {
278          continue;
279        }
280  
281        const toolName = String(itemObj.name || "tool");
282        const toolId = String(itemObj.id || "");
283        const cmd = extractToolCommand(itemObj.arguments);
284        const include = shouldIncludeTool(toolName, options.toolFilter);
285  
286        if (toolId) {
287          state.pendingToolCalls.set(toolId, { name: toolName, cmd, include });
288        }
289        if (include) {
290          toolParts.push(`[tool:${toolName}] ${cmd}`.trim());
291        }
292      }
293    }
294  
295    let fullText = textParts.filter(Boolean).join("\n").trim();
296    if (toolParts.length > 0) {
297      fullText = (fullText ? `${fullText}\n` : "") + toolParts.join("\n");
298    }
299  
300    if (fullText) {
301      state.conversation.push(`ASSISTANT: ${fullText}`);
302    }
303  }
304  
305  function appendToolResultMessage(
306    msgObj: Record<string, unknown>,
307    state: ConversationState,
308    toolFilter: ToolFilter | null
309  ): void {
310    const toolName = String(msgObj.toolName || msgObj.tool_name || "tool");
311    const toolCallId = String(msgObj.toolCallId || msgObj.tool_call_id || "");
312  
313    let label = toolName;
314    let include = shouldIncludeTool(toolName, toolFilter);
315  
316    if (toolCallId && state.pendingToolCalls.has(toolCallId)) {
317      const pending = state.pendingToolCalls.get(toolCallId)!;
318      state.pendingToolCalls.delete(toolCallId);
319      label = pending.name || toolName;
320      include = pending.include;
321      if (pending.cmd) {
322        label = `${label}: ${pending.cmd}`;
323      }
324    }
325  
326    if (!include) {
327      return;
328    }
329  
330    const isError = Boolean(msgObj.isError || msgObj.is_error);
331    const out = extractTextFromContent(msgObj.content);
332    if (out && out !== "(no content)") {
333      state.conversation.push(
334        `SYSTEM [${label} output${isError ? " ERROR" : ""}]:\n${out}`
335      );
336    }
337  }
338  
339  /**
340   * Extract conversation messages from a Pi session JSONL file
341   */
342  async function extractConversation(
343    jsonlFile: string,
344    options: ExportOptions
345  ): Promise<{ meta: SessionMeta; conversation: string[] }> {
346    const state = createConversationState();
347  
348    for await (const rec of iterJsonl(jsonlFile)) {
349      applyMessageRecord(rec, state, options);
350    }
351  
352    return { meta: state.meta, conversation: state.conversation };
353  }
354  
355  /**
356   * Extract conversation messages from the *currently selected branch* (root → current leaf)
357   * using the in-memory SessionManager tree state.
358   *
359   * This intentionally includes *all* turns on the branch, including turns that may no longer
360   * be shown in the TUI after compaction.
361   */
362  function extractConversationFromBranch(
363    sessionManager: any,
364    options: ExportOptions
365  ): { meta: SessionMeta; conversation: string[]; leafId: string | null } {
366    const state = createConversationState();
367    const header =
368      typeof sessionManager.getHeader === "function"
369        ? sessionManager.getHeader()
370        : null;
371  
372    if (header && typeof header === "object") {
373      const headerObj = header as Record<string, unknown>;
374      state.meta.sessionId = typeof headerObj.id === "string" ? headerObj.id : "";
375      state.meta.startedAt = parseIsoTimestamp(headerObj.timestamp);
376      if (typeof headerObj.cwd === "string") {
377        state.meta.cwd = headerObj.cwd;
378      }
379    }
380  
381    if (!state.meta.cwd && typeof sessionManager.getCwd === "function") {
382      state.meta.cwd = String(sessionManager.getCwd() || "");
383    }
384  
385    const leafId =
386      typeof sessionManager.getLeafId === "function"
387        ? (sessionManager.getLeafId() as string | null)
388        : null;
389  
390    const branchEntries: unknown[] =
391      leafId && typeof sessionManager.getBranch === "function"
392        ? (sessionManager.getBranch(leafId) as unknown[])
393        : [];
394  
395    for (const entry of branchEntries) {
396      if (typeof entry !== "object" || entry === null) {
397        continue;
398      }
399      applyMessageRecord(entry as Record<string, unknown>, state, options);
400    }
401  
402    return { meta: state.meta, conversation: state.conversation, leafId };
403  }
404  
405  function buildMarkdownContent(
406    meta: SessionMeta,
407    sessionFile: string,
408    conversation: string[],
409    lastTurns: number | null,
410    branchInfo?: { leafId: string | null }
411  ): { content: string; filename: string } | null {
412    const finalConversation =
413      lastTurns && lastTurns > 0 ? sliceLastNTurns(conversation, lastTurns) : conversation;
414  
415    if (finalConversation.length === 0) {
416      return null;
417    }
418  
419    const project = meta.cwd ? path.basename(meta.cwd) : "unknown";
420    const projectSlug = slug(project);
421  
422    const started = meta.startedAt || new Date();
423    const stamp = formatTimestamp(started);
424    const sid = (meta.sessionId || "unknown").slice(0, 8);
425    const filename = `${projectSlug}_pi_${stamp}_${sid}.md`;
426  
427    const lines: string[] = [];
428    lines.push("PI SESSION (processed)");
429    if (branchInfo) {
430      lines.push("mode: branch");
431      lines.push(`leaf: ${branchInfo.leafId === null ? "null" : branchInfo.leafId}`);
432    }
433    if (meta.sessionId) {
434      lines.push(`id: ${meta.sessionId}`);
435    }
436    if (meta.startedAt) {
437      lines.push(`started: ${meta.startedAt.toISOString()}`);
438    }
439    if (meta.cwd) {
440      lines.push(`cwd: ${meta.cwd}`);
441    }
442    lines.push(`source: ${sessionFile}`);
443    if (lastTurns && lastTurns > 0) {
444      lines.push(`turns: last ${lastTurns}`);
445    }
446    lines.push("");
447  
448    for (const msg of finalConversation) {
449      lines.push(msg.trimEnd());
450      lines.push("");
451    }
452  
453    return { content: lines.join("\n"), filename };
454  }
455  
456  /**
457   * Generate markdown content from the current branch without writing to file
458   */
459  function generateMarkdownFromBranch(
460    sessionManager: any,
461    sessionFile: string,
462    options: ExportOptions,
463    lastTurns: number | null = null
464  ): { content: string; filename: string } | null {
465    const { meta, conversation, leafId } = extractConversationFromBranch(
466      sessionManager,
467      options
468    );
469    return buildMarkdownContent(meta, sessionFile, conversation, lastTurns, {
470      leafId,
471    });
472  }
473  
474  /**
475   * Generate markdown content from full session file without writing to file
476   */
477  async function generateMarkdownFromSession(
478    jsonlFile: string,
479    options: ExportOptions,
480    lastTurns: number | null = null
481  ): Promise<{ content: string; filename: string } | null> {
482    const { meta, conversation } = await extractConversation(jsonlFile, options);
483    return buildMarkdownContent(meta, jsonlFile, conversation, lastTurns);
484  }
485  
486  /**
487   * Copy text to clipboard (macOS)
488   */
489  function copyToClipboard(text: string): void {
490    execSync("pbcopy", { input: text });
491  }
492  
493  /**
494   * Format date as YYYYMMDD_HHMMSS
495   */
496  function formatTimestamp(date: Date): string {
497    const pad = (n: number) => String(n).padStart(2, "0");
498    return (
499      `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}_` +
500      `${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`
501    );
502  }
503  
504  /**
505   * Slice a flattened conversation array to the last N turns
506   *
507   * A turn is defined as a unit of [USER message -> ASSISTANT message], including any
508   * SYSTEM tool outputs that occur in between, until the next USER message begins.
509   */
510  function sliceLastNTurns(conversation: string[], turns: number): string[] {
511    if (!Number.isFinite(turns) || turns <= 0) {
512      return conversation;
513    }
514  
515    const userMessageIndices: number[] = [];
516    for (let i = 0; i < conversation.length; i++) {
517      if (conversation[i].startsWith("USER:")) {
518        userMessageIndices.push(i);
519      }
520    }
521  
522    if (userMessageIndices.length === 0) {
523      return conversation;
524    }
525  
526    const startIndex =
527      userMessageIndices.length <= turns
528        ? 0
529        : userMessageIndices[userMessageIndices.length - turns];
530  
531    return conversation.slice(startIndex);
532  }
533  
534  function parseToolFilter(tokensLower: string[]): ToolFilter {
535    const includeNames = new Set<string>();
536    const excludeNames = new Set<string>();
537  
538    for (const token of tokensLower) {
539      if (!["+", "-"].includes(token[0]) || token.length < 2) {
540        continue;
541      }
542  
543      const name = normalizeToolName(token.slice(1));
544      if (!name) {
545        continue;
546      }
547  
548      if (token.startsWith("+")) {
549        includeNames.add(name);
550      } else {
551        excludeNames.add(name);
552      }
553    }
554  
555    return {
556      mode: includeNames.size > 0 ? "includeOnly" : "all",
557      includeNames,
558      excludeNames,
559    };
560  }
561  
562  function hasToolFilterTokens(tokensLower: string[]): boolean {
563    return tokensLower.some((token) => /^[+-].+/.test(token));
564  }
565  
566  function describeToolFilter(toolFilter: ToolFilter | null): string | null {
567    if (!toolFilter) {
568      return null;
569    }
570  
571    const includes = [...toolFilter.includeNames].sort().map((name) => `+${name}`);
572    const excludes = [...toolFilter.excludeNames].sort().map((name) => `-${name}`);
573  
574    if (toolFilter.mode === "includeOnly") {
575      return ["tool calls", ...includes, ...excludes].join(" ");
576    }
577    if (excludes.length > 0) {
578      return ["tool calls", ...excludes].join(" ");
579    }
580    return "tool calls";
581  }
582  
583  const OUTPUT_DIR = path.join(os.homedir(), ".pi", "agent", "pi-sessions-extracted");
584  
585  export default function (pi: ExtensionAPI) {
586    pi.registerCommand("md", {
587      description:
588        "Export current session as markdown (current /tree branch) on clipboard or to file. Tool calls and thinking blocks are excluded by default. Use '/md tc' to include tool calls, optionally filtered with exact tool names like '/md tc -bash -read' or '/md tc +ask'. Use '/md t' to include thinking blocks. Use '/md all' for full file. Pass a number (e.g. '/md 2' or '/md tc t 2') to export only the last N turns.",
589      handler: async (args, ctx) => {
590        const sessionFile = ctx.sessionManager.getSessionFile();
591  
592        if (!sessionFile) {
593          ctx.ui.notify("No session file (ephemeral session)", "error");
594          return;
595        }
596  
597        const argsTrimmed = args.trim();
598        const tokens = argsTrimmed ? argsTrimmed.split(/\s+/).filter(Boolean) : [];
599        const tokensLower = tokens.map((t) => t.toLowerCase());
600  
601        const includeThinking =
602          tokensLower.includes("t") ||
603          tokensLower.includes("think") ||
604          tokensLower.includes("thinking");
605  
606        const includeToolCalls = tokensLower.includes("tc");
607        const exportAll = tokensLower.includes("all") || tokensLower.includes("file");
608        const toolFilterTokensPresent = hasToolFilterTokens(tokensLower);
609  
610        if (toolFilterTokensPresent && !includeToolCalls) {
611          ctx.ui.notify("Tool filters require 'tc' (e.g. /md tc -bash or /md tc +ask)", "error");
612          return;
613        }
614  
615        if (tokensLower.includes("+all") || tokensLower.includes("-all")) {
616          ctx.ui.notify("'+all' and '-all' are not supported; use '/md tc' or '/md tc +tool'", "error");
617          return;
618        }
619  
620        const toolFilter = includeToolCalls ? parseToolFilter(tokensLower) : null;
621        const options: ExportOptions = {
622          includeThinking,
623          toolFilter,
624        };
625  
626        const turnsToken = tokens.find((t) => /^\d+$/.test(t)) || null;
627        const lastTurns = turnsToken ? parseInt(turnsToken, 10) : null;
628        if (turnsToken && (!Number.isFinite(lastTurns) || lastTurns < 1)) {
629          ctx.ui.notify("Turn limit must be >= 1 (e.g. /md 2)", "error");
630          return;
631        }
632  
633        const turnSuffix = lastTurns ? ` (last ${lastTurns} turns)` : "";
634        const extras: string[] = [];
635        if (includeThinking) {
636          extras.push("thinking");
637        }
638        const toolFilterDescription = describeToolFilter(toolFilter);
639        if (toolFilterDescription) {
640          extras.push(toolFilterDescription);
641        }
642        const extrasSuffix = extras.length > 0 ? ` (with ${extras.join(" + ")})` : "";
643        const title = `Export session${turnSuffix}${extrasSuffix} as Markdown`;
644        const choice = await ctx.ui.select(`${title}\n\nSelect export method:`, [
645          "Copy to clipboard",
646          `Save to .md file in ${OUTPUT_DIR}/`,
647        ]);
648  
649        if (!choice) {
650          return;
651        }
652  
653        try {
654          const result = exportAll
655            ? await generateMarkdownFromSession(sessionFile, options, lastTurns)
656            : generateMarkdownFromBranch(ctx.sessionManager, sessionFile, options, lastTurns);
657  
658          if (!result) {
659            const mode = exportAll ? "session file" : "current branch";
660            ctx.ui.notify(`No meaningful conversation found in ${mode}`, "error");
661            return;
662          }
663  
664          const suffix = extrasSuffix;
665          const mode = exportAll ? " (full file)" : " (branch)";
666  
667          if (choice === "Copy to clipboard") {
668            copyToClipboard(result.content);
669            ctx.ui.notify(`Copied to clipboard${suffix}${mode}`, "success");
670            return;
671          }
672  
673          const outputFile = path.join(OUTPUT_DIR, result.filename);
674          fs.mkdirSync(OUTPUT_DIR, { recursive: true });
675          fs.writeFileSync(outputFile, result.content, "utf-8");
676          ctx.ui.notify(`Saved${suffix}${mode}: ${outputFile}`, "success");
677        } catch (err) {
678          const errMsg = err instanceof Error ? err.message : String(err);
679          ctx.ui.notify(`Export failed: ${errMsg}`, "error");
680        }
681      },
682    });
683  }