main
  1#!/usr/bin/env node
  2/**
  3 * Fetch and parse pi-share (shittycodingagent.ai/buildwithpi.ai/buildwithpi.com) session exports.
  4 * 
  5 * Usage:
  6 *   node fetch-session.mjs <url-or-gist-id> [--header] [--entries] [--system] [--tools] [--human-summary] [--no-cache]
  7 * 
  8 * Options:
  9 *   (no flag)        Output full session data JSON
 10 *   --header         Output just the session header
 11 *   --entries        Output entries as JSON lines (one per line)
 12 *   --system         Output the system prompt
 13 *   --tools          Output tool definitions
 14 *   --human-summary  Summarize what the human did in this session (uses haiku-4-5)
 15 *   --no-cache       Bypass cache and fetch fresh
 16 */
 17
 18import { mkdirSync, readFileSync, writeFileSync, existsSync } from 'fs';
 19import { tmpdir } from 'os';
 20import { join } from 'path';
 21import { spawnSync } from 'child_process';
 22
 23const CACHE_DIR = join(tmpdir(), 'pi-share-cache');
 24
 25const args = process.argv.slice(2);
 26const input = args.find(a => !a.startsWith('--'));
 27const flags = new Set(args.filter(a => a.startsWith('--')));
 28
 29if (!input) {
 30  console.error('Usage: node fetch-session.mjs <url-or-gist-id> [--header|--entries|--system|--tools]');
 31  process.exit(1);
 32}
 33
 34// Cache functions
 35function getCachePath(gistId) {
 36  return join(CACHE_DIR, `${gistId}.json`);
 37}
 38
 39function readCache(gistId) {
 40  const path = getCachePath(gistId);
 41  if (existsSync(path)) {
 42    return JSON.parse(readFileSync(path, 'utf-8'));
 43  }
 44  return null;
 45}
 46
 47function writeCache(gistId, data) {
 48  mkdirSync(CACHE_DIR, { recursive: true });
 49  writeFileSync(getCachePath(gistId), JSON.stringify(data));
 50}
 51
 52// Extract gist ID from URL or use directly
 53function extractGistId(input) {
 54  // Handle full URLs like https://shittycodingagent.ai/session/?<id>
 55  const queryMatch = input.match(/[?&]([a-f0-9]{32})/i);
 56  if (queryMatch) return queryMatch[1];
 57
 58  // Handle path-based URLs like https://buildwithpi.ai/session/<id>
 59  const pathMatch = input.match(/\/session\/?([a-f0-9]{32})/i);
 60  if (pathMatch) return pathMatch[1];
 61  
 62  // Handle direct gist ID
 63  if (/^[a-f0-9]{32}$/i.test(input)) return input;
 64  
 65  // Handle gist.github.com URLs
 66  const gistMatch = input.match(/gist\.github\.com\/[^/]+\/([a-f0-9]+)/i);
 67  if (gistMatch) return gistMatch[1];
 68  
 69  throw new Error(`Cannot extract gist ID from: ${input}`);
 70}
 71
 72// Fetch session HTML from gist
 73async function fetchSessionHtml(gistId) {
 74  const gistRes = await fetch(`https://api.github.com/gists/${gistId}`);
 75  if (!gistRes.ok) {
 76    if (gistRes.status === 404) throw new Error('Session not found (gist deleted or invalid ID)');
 77    throw new Error(`GitHub API error: ${gistRes.status}`);
 78  }
 79  
 80  const gist = await gistRes.json();
 81  const file = gist.files?.['session.html'];
 82  if (!file) {
 83    const available = Object.keys(gist.files || {}).join(', ') || 'none';
 84    throw new Error(`No session.html in gist. Available: ${available}`);
 85  }
 86  
 87  // Fetch raw content if truncated
 88  if (file.truncated && file.raw_url) {
 89    const rawRes = await fetch(file.raw_url);
 90    if (!rawRes.ok) throw new Error('Failed to fetch raw content');
 91    return rawRes.text();
 92  }
 93  
 94  return file.content;
 95}
 96
 97// Extract base64 session data from HTML
 98function extractSessionData(html) {
 99  // New format: <script id="session-data" type="application/json">BASE64</script>
100  const match = html.match(/<script[^>]*id="session-data"[^>]*>([^<]+)<\/script>/);
101  if (match) {
102    const base64 = match[1].trim();
103    const json = Buffer.from(base64, 'base64').toString('utf-8');
104    return JSON.parse(json);
105  }
106  
107  throw new Error('No session data found in HTML. This may be an older export format without embedded data.');
108}
109
110// Truncate text to maxLen, adding ellipsis if truncated
111function truncate(text, maxLen = 150) {
112  if (!text || text.length <= maxLen) return text;
113  return text.slice(0, maxLen) + '...';
114}
115
116// Extract condensed session data for human summary
117function extractForSummary(data) {
118  const turns = [];
119  let turnNumber = 0;
120  
121  for (const entry of data.entries) {
122    if (entry.type !== 'message') continue;
123    
124    const msg = entry.message;
125    if (!msg || !msg.role) continue;
126    
127    if (msg.role === 'user') {
128      turnNumber++;
129      // Extract user text
130      const textParts = (msg.content || [])
131        .filter(c => c.type === 'text')
132        .map(c => c.text)
133        .join('\n');
134      
135      if (textParts.trim()) {
136        turns.push({
137          turn: turnNumber,
138          role: 'human',
139          text: textParts
140        });
141      }
142    } else if (msg.role === 'assistant') {
143      // Extract condensed assistant info: brief text + tool summary
144      const textParts = [];
145      const toolCalls = [];
146      
147      for (const block of (msg.content || [])) {
148        if (block.type === 'text' && block.text) {
149          // Just first 200 chars of assistant text for context
150          textParts.push(truncate(block.text, 200));
151        } else if (block.type === 'toolCall') {
152          // Condense tool call: name + truncated key info
153          let summary = block.toolName;
154          if (block.args) {
155            if (block.args.path) {
156              summary += `: ${truncate(block.args.path, 100)}`;
157            } else if (block.args.command) {
158              summary += `: ${truncate(block.args.command, 100)}`;
159            } else {
160              // Generic args truncation
161              const argsStr = JSON.stringify(block.args);
162              summary += `: ${truncate(argsStr, 100)}`;
163            }
164          }
165          toolCalls.push(summary);
166        }
167      }
168      
169      if (textParts.length || toolCalls.length) {
170        turns.push({
171          turn: turnNumber,
172          role: 'assistant',
173          text: textParts.length ? textParts[0] : null,
174          tools: toolCalls.length ? toolCalls : null
175        });
176      }
177    } else if (msg.role === 'toolResult') {
178      // Just note if there was an error
179      const hasError = (msg.content || []).some(c => c.isError);
180      if (hasError) {
181        turns.push({
182          turn: turnNumber,
183          role: 'tool_error',
184          text: 'Tool returned an error'
185        });
186      }
187    }
188  }
189  
190  return {
191    sessionId: data.header?.id,
192    timestamp: data.header?.timestamp,
193    cwd: data.header?.cwd,
194    turns
195  };
196}
197
198// Format condensed data as text for the summarizer
199function formatForSummary(condensed) {
200  const lines = [];
201  
202  lines.push(`Session: ${condensed.sessionId || 'unknown'}`);
203  lines.push(`Time: ${condensed.timestamp || 'unknown'}`);
204  lines.push(`Directory: ${condensed.cwd || 'unknown'}`);
205  lines.push('');
206  lines.push('=== Conversation ===');
207  lines.push('');
208  
209  for (const turn of condensed.turns) {
210    if (turn.role === 'human') {
211      lines.push(`[Turn ${turn.turn}] HUMAN:`);
212      lines.push(turn.text);
213      lines.push('');
214    } else if (turn.role === 'assistant') {
215      lines.push(`[Turn ${turn.turn}] ASSISTANT (condensed):`);
216      if (turn.text) {
217        lines.push(`  Response: ${turn.text}`);
218      }
219      if (turn.tools && turn.tools.length) {
220        lines.push(`  Tools used: ${turn.tools.join(', ')}`);
221      }
222      lines.push('');
223    } else if (turn.role === 'tool_error') {
224      lines.push(`[Turn ${turn.turn}] ⚠️ Tool error occurred`);
225      lines.push('');
226    }
227  }
228  
229  return lines.join('\n');
230}
231
232// Generate human summary using haiku via pi
233async function generateHumanSummary(data) {
234  const condensed = extractForSummary(data);
235  const formatted = formatForSummary(condensed);
236  
237  const prompt = `You are analyzing a coding agent session transcript. Your task is to summarize what the HUMAN did, not what the AI agent did.
238
239Focus on:
2401. What was the human's initial goal/request?
2412. How many times did they have to re-prompt or steer the agent?
2423. What kind of steering did they do? (corrections, clarifications, changes of direction, expressing frustration, etc.)
2434. Did the human have to intervene when things went wrong?
2445. How specific vs vague were their instructions?
245
246Write a ~300 word summary in third person ("The user asked...", "They then had to clarify...").
247Include a brief note about what domain/tools were involved for context, but keep focus on the human's actions and experience.
248
249Here is the condensed session transcript:
250
251${formatted}`;
252
253  try {
254    const result = spawnSync('pi', [
255      '--provider', 'anthropic',
256      '--model', 'claude-haiku-4-5',
257      '--no-tools',
258      '--no-session',
259      '-p',
260      prompt
261    ], {
262      encoding: 'utf-8',
263      maxBuffer: 10 * 1024 * 1024,
264      timeout: 60000
265    });
266    
267    if (result.error) {
268      throw result.error;
269    }
270    if (result.status !== 0) {
271      throw new Error(result.stderr || 'pi command failed');
272    }
273    
274    return result.stdout.trim();
275  } catch (err) {
276    throw new Error(`Failed to generate summary: ${err.message}`);
277  }
278}
279
280// Main
281async function main() {
282  try {
283    const gistId = extractGistId(input);
284    
285    // Check cache first (unless --no-cache)
286    let data = null;
287    if (!flags.has('--no-cache')) {
288      data = readCache(gistId);
289    }
290    
291    if (!data) {
292      const html = await fetchSessionHtml(gistId);
293      data = extractSessionData(html);
294      writeCache(gistId, data);
295    }
296    
297    if (flags.has('--header')) {
298      console.log(JSON.stringify(data.header));
299    } else if (flags.has('--entries')) {
300      // Output as JSON lines - one entry per line
301      for (const entry of data.entries) {
302        console.log(JSON.stringify(entry));
303      }
304    } else if (flags.has('--system')) {
305      console.log(data.systemPrompt || '');
306    } else if (flags.has('--tools')) {
307      console.log(JSON.stringify(data.tools || []));
308    } else if (flags.has('--human-summary')) {
309      // Generate human-centric summary using haiku
310      const summary = await generateHumanSummary(data);
311      console.log(summary);
312    } else {
313      // Default: full session data
314      console.log(JSON.stringify(data));
315    }
316  } catch (err) {
317    console.error(err.message);
318    process.exit(1);
319  }
320}
321
322main();