flake-update-20260505
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();