this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Fix session summarization for malformed Haiku responses

Haiku sometimes returns double-encoded JSON where the entire response
is wrapped in a string literal with escaped quotes. This caused sessions
to show "Session details unavailable" even when valid data was returned.

Added tryRecoverMalformedResponse() that extracts valid summary data
from the error object by unescaping and regex-parsing the fields.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

alice 4b0f4142 4f01b21b

+102 -2
+102 -2
src/core/summarizer.ts
··· 14 14 15 15 const MODEL = process.env.SUMMARIZER_MODEL || 'claude-haiku-4-5-20251001'; 16 16 17 + /** 18 + * Try to recover valid JSON from malformed Haiku responses. 19 + * Haiku sometimes returns double-encoded JSON where the entire response 20 + * is wrapped in a string literal with escaped quotes. 21 + * 22 + * Common pattern: {"shortSummary":"actual summary\",\"accomplishments\": [...rest of JSON...]"} 23 + * The model puts the whole JSON inside shortSummary with escaped quotes. 24 + */ 25 + function tryRecoverMalformedResponse(error: unknown): { 26 + shortSummary?: string; 27 + accomplishments?: string[]; 28 + filesChanged?: string[]; 29 + toolsUsed?: string[]; 30 + } | null { 31 + try { 32 + // The error object may have a 'text' property with the malformed response 33 + const errorObj = error as { text?: string; cause?: { value?: unknown } }; 34 + 35 + // Try the text field first (AI_NoObjectGeneratedError) 36 + let rawText = errorObj.text; 37 + 38 + // Also try cause.value which contains the parsed (but invalid) object 39 + if (!rawText && errorObj.cause?.value) { 40 + const value = errorObj.cause.value as { shortSummary?: string }; 41 + // If shortSummary contains escaped JSON structure, extract it 42 + if (value.shortSummary && value.shortSummary.includes('"accomplishments"')) { 43 + rawText = value.shortSummary; 44 + } 45 + } 46 + 47 + if (!rawText || typeof rawText !== 'string') return null; 48 + 49 + // Unescape the content 50 + const unescaped = rawText 51 + .replace(/\\n/g, '\n') 52 + .replace(/\\"/g, '"') 53 + .replace(/\\\\/g, '\\'); 54 + 55 + // Pattern: the model returned JSON with shortSummary containing the rest 56 + // Extract: shortSummary ends at ",\n"accomplishments" or similar 57 + const summaryMatch = unescaped.match(/"shortSummary"\s*:\s*"([^"]+)"/); 58 + const accomplishmentsMatch = unescaped.match(/"accomplishments"\s*:\s*\[([\s\S]*?)\]/); 59 + const filesMatch = unescaped.match(/"filesChanged"\s*:\s*\[([\s\S]*?)\]/); 60 + const toolsMatch = unescaped.match(/"toolsUsed"\s*:\s*\[([\s\S]*?)\]/); 61 + 62 + if (summaryMatch) { 63 + const parseArray = (match: RegExpMatchArray | null): string[] => { 64 + if (!match) return []; 65 + try { 66 + return JSON.parse(`[${match[1]}]`); 67 + } catch { 68 + // Extract strings manually 69 + const items: string[] = []; 70 + const re = /"([^"]+)"/g; 71 + let m; 72 + while ((m = re.exec(match[1])) !== null) { 73 + items.push(m[1]); 74 + } 75 + return items; 76 + } 77 + }; 78 + 79 + return { 80 + shortSummary: summaryMatch[1], 81 + accomplishments: parseArray(accomplishmentsMatch), 82 + filesChanged: parseArray(filesMatch), 83 + toolsUsed: parseArray(toolsMatch), 84 + }; 85 + } 86 + 87 + // Last resort: try to parse as complete JSON object 88 + try { 89 + const jsonMatch = unescaped.match(/\{[\s\S]*"shortSummary"[\s\S]*\}/); 90 + if (jsonMatch) { 91 + const parsed = JSON.parse(jsonMatch[0]); 92 + if (parsed.shortSummary && Array.isArray(parsed.accomplishments)) { 93 + return parsed; 94 + } 95 + } 96 + } catch {} 97 + } catch { 98 + // Recovery failed, will fall back to placeholder 99 + } 100 + return null; 101 + } 102 + 17 103 // Zod schema for session summaries - using .describe() for better LLM understanding 18 104 const sessionSummarySchema = z.object({ 19 105 shortSummary: z ··· 77 163 ? object.toolsUsed 78 164 : Object.keys(session.stats.toolCalls), 79 165 }; 80 - } catch (error) { 81 - // Haiku sometimes returns malformed output - just use fallback 166 + } catch (error: unknown) { 167 + // Haiku sometimes returns double-encoded JSON (valid JSON wrapped in a string) 168 + // Try to recover by parsing the text field from the error 169 + const recovered = tryRecoverMalformedResponse(error); 170 + if (recovered) { 171 + return { 172 + shortSummary: recovered.shortSummary || 'Session completed', 173 + accomplishments: recovered.accomplishments || [], 174 + filesChanged: recovered.filesChanged || [], 175 + toolsUsed: recovered.toolsUsed?.length > 0 176 + ? recovered.toolsUsed 177 + : Object.keys(session.stats.toolCalls), 178 + }; 179 + } 180 + 82 181 // Return a basic summary on failure 182 + console.error('Summarization error (unrecoverable):', (error as Error).message); 83 183 return { 84 184 shortSummary: `Worked on ${session.projectName}`, 85 185 accomplishments: ['Session details unavailable'],