| 1 | import { createReadStream } from 'node:fs'; |
| 2 | import { createInterface } from 'node:readline'; |
| 3 | import { truncate } from './util.js'; |
| 4 | import { TreetraceError, ExitCode } from './util.js'; |
| 5 | |
| 6 | const DAG_TYPES = new Set(['user', 'assistant', 'system', 'attachment']); |
| 7 | |
| 8 | |
| 9 | const USER_DECLINED_TOOL_RE = |
| 10 | /\bthe user (?:doesn'?t|does not|didn'?t|did not) want to proceed with this tool use\b|\bthe user (?:wants?|wanted) (?:you|me|the agent) to\b|\buser (?:rejected|declined|cancelled|canceled) (?:this|the) tool(?: use)?\b|\buser chose to reject\b/i; |
| 11 | |
| 12 | const PERMISSION_DENIED_RE = |
| 13 | /\bpermission denied\b|\boperation not permitted\b|\bEACCES\b|\bEPERM\b|\bcommand not found\b|\bOperation cancelled\b|\baccess is denied\b|\brequires? elevation\b/i; |
| 14 | |
| 15 | const REFUSAL_TEXT_RE = |
| 16 | /\b(?:i (?:can(?:'|no)t|am (?:unable|not able|not permitted) to|won['']?t|cannot|do not|don['']?t (?:think i (?:should|can)|feel comfortable)|'?m not (?:able|allowed|going) to)|(?:sorry|apolog(?:y|ies|ize))[,.]? i (?:can(?:'|no)t|am unable|won['']?t|cannot)|as (?:an? )?(?:ai|language model|assistant)[, ]+(?:i |we )?(?:can(?:'|no)t|cannot|am unable|won['']?t)|i'?m programmed (?:to decline|not to)|against my (?:guidelines|policies|programming))\b/i; |
| 17 | |
| 18 | const USER_TEXT_DECLINE_RE = |
| 19 | /^(?:no(?:pe)?\s*[,.)]?\s+|stop\s*[,.)]?\s+|cancel\s*[,.)]?\s+|don'?t\s+|do not\s+|don'?t do (?:that|this|it)\b|stop (?:that|this|it|doing)\b|scrap (?:that|this|it|the)\b|revert\b|undo\b|roll\s?back\b|rip (?:that|this|it|the)\b|back (?:it|that|this) out\b|take (?:it|that|this) out\b|that'?s not it\b|that is not it\b|not that one\b|not quite\b|scratch that\b|nevermind\b|never mind\b)/i; |
| 20 | |
| 21 | const DECLINE_INTERJECTION_RE = |
| 22 | /^(?:(?:whoa|wait|hold on|hold up|hold the phone|hmm+|ugh+|argh+|actually|no wait|ok wait|wait wait|yikes)[\s,!.:;-]+)+/i; |
| 23 | |
| 24 | const IMPERATIVE_REVERSAL_RE = |
| 25 | /\b(?:stop|undo|revert|yank|rip|kill|scrap|nix|roll\s?back|back(?:\s+(?:it|that|this))?\s+out|take(?:\s+(?:it|that|this))?\s+out)\b/i; |
| 26 | const BARE_STOP_RE = |
| 27 | /^(?:stop|undo|revert|yank|nix|scrap|rip|kill|roll\s?back)\s*(?:it|that|this|the\b[^.]*)?[.!,;:\s]*$/i; |
| 28 | const BACKREF_DEMONSTRATIVE_RE = /\b(?:that|this|those|these|it)\b/i; |
| 29 | |
| 30 | const BENIGN_DECLINE_OPENER_RE = |
| 31 | /^(?:no\s+(?:problem|worries|worry|rush|need|thanks|biggie|prob(?:lem)?s?|issue)\b|nope?\s+(?:problem|worries)\b|don'?t\s+(?:forget|hesitate|worry|bother|stop)\b|stop\s+(?:being|saying|telling|apologi[sz]|with the|the apolog))/i; |
| 32 | |
| 33 | const COMPLIANT_WONT_RE = |
| 34 | /\bi\s+(?:won['']?t|will not|promise not to)\s+(?:touch|change|modify|alter|edit|delete|remove|drop|break|add|introduce|expose|leak|hardcode|hard-code|commit|push|overwrite|override|re-?add|reintroduce)\b/i; |
| 35 | const HARD_REFUSAL_RE = |
| 36 | /\bi\s+can(?:'|no)?t\b|\b(?:am|'?m)\s+(?:unable|not able|not permitted|not allowed)\b|\bagainst my (?:guidelines|policies|programming)\b|\bas an? (?:ai|language model|assistant)\b/i; |
| 37 | |
| 38 | function classifyToolResultRejection(content) { |
| 39 | const text = typeof content === 'string' ? content : ''; |
| 40 | if (!text) return { kind: 'tool_execution_error', confidence: 0.85, evidence: null }; |
| 41 | if (USER_DECLINED_TOOL_RE.test(text)) { |
| 42 | return { kind: 'user_declined_tool', confidence: 1.0, evidence: truncate(text, 160) }; |
| 43 | } |
| 44 | if (PERMISSION_DENIED_RE.test(text)) { |
| 45 | return { kind: 'permission_denied', confidence: 0.85, evidence: truncate(text, 160) }; |
| 46 | } |
| 47 | return { kind: 'tool_execution_error', confidence: 0.9, evidence: truncate(text, 160) }; |
| 48 | } |
| 49 | |
| 50 | export function looksLikeRefusal(text) { |
| 51 | if (typeof text !== 'string' || text.length > 4000) return false; |
| 52 | if (COMPLIANT_WONT_RE.test(text) && !HARD_REFUSAL_RE.test(text)) return false; |
| 53 | return REFUSAL_TEXT_RE.test(text); |
| 54 | } |
| 55 | |
| 56 | const NOVEL_REFUSAL_RE = |
| 57 | /\bi(?:'|’)?m\s+going\s+to\s+decline\b|\bi(?:'|’)?ll\s+decline\b|\bi\s+decline\s+(?:this|that|to)\b|\bi(?:'|’)?d\s+rather\s+not\b|\bi(?:'|’)?m\s+not\s+(?:comfortable|willing|going)\s+to?\b|\bthat(?:'|’)?s\s+not\s+something\s+i(?:'|’)?(?:ll|m)?\s*(?:can|will|would|want|going)\b|\bnot\s+something\s+i\s+can\s+help\s+with\b|\bcrosses\s+a\s+line\s+i\s+won(?:'|’)?t\s+cross\b|\bi(?:'|’)?m\s+not\s+going\s+to\s+(?:do|build|implement|write|add)\b/i; |
| 58 | |
| 59 | function looksLikeRefusalStructural(text) { |
| 60 | if (typeof text !== 'string' || text.length > 4000) return false; |
| 61 | if (COMPLIANT_WONT_RE.test(text) && !HARD_REFUSAL_RE.test(text)) return false; |
| 62 | return REFUSAL_TEXT_RE.test(text) || NOVEL_REFUSAL_RE.test(text); |
| 63 | } |
| 64 | |
| 65 | function looksLikeUserTextDecline(text) { |
| 66 | let t = typeof text === 'string' ? text.trim() : ''; |
| 67 | if (!t || t.length > 240) return false; |
| 68 | t = t.replace(DECLINE_INTERJECTION_RE, '').trim(); |
| 69 | if (BENIGN_DECLINE_OPENER_RE.test(t)) return false; |
| 70 | return USER_TEXT_DECLINE_RE.test(t); |
| 71 | } |
| 72 | |
| 73 | function backRefsPriorAssistant(clause, priorAssistant) { |
| 74 | if (BACKREF_DEMONSTRATIVE_RE.test(clause)) return true; |
| 75 | if (priorAssistant && priorAssistant.tokens && priorAssistant.tokens.size) { |
| 76 | const low = clause.toLowerCase(); |
| 77 | for (const tok of priorAssistant.tokens) { |
| 78 | if (tok.length >= 4 && low.includes(tok)) return true; |
| 79 | } |
| 80 | } |
| 81 | return false; |
| 82 | } |
| 83 | |
| 84 | const DESTRUCTIVE_ATTR_RE = |
| 85 | /\byou\b[^.!?;]{0,40}\b(?:blew\s+away|blow\s+away|nuked?|wiped?|truncated?|dropped?|deleted?|destroyed?|clobbered?|ripped?\s+(?:out|away))\b|\b(?:that|this|the)\b[^.!?;]{0,30}\b(?:drop[\s-]?and[\s-]?recreate[d]?|blew\s+away|truncated?|wiped?)\b/i; |
| 86 | const DESTRUCTIVE_REDIRECT_CUE_RE = |
| 87 | /\bnon[\s-]?destructive\b|\badditive\b|\binstead\b|\bstop\b|\bdon'?t\b[^.!?;]{0,30}\b(?:drop|truncate|wipe|recreate|delete|blow)\b|\bnever\b[^.!?;]{0,30}\b(?:drop|truncate|wipe|recreate|delete)\b|\bmake\b[^.!?;]{0,30}\b(?:migration|change|it)\b[^.!?;]{0,20}\b(?:additive|non[\s-]?destructive|safe)\b/i; |
| 88 | |
| 89 | function looksLikeStructuralDecline(text, priorAssistant) { |
| 90 | let t = typeof text === 'string' ? text.trim() : ''; |
| 91 | if (!t || t.length > 240) return false; |
| 92 | t = t.replace(DECLINE_INTERJECTION_RE, '').trim(); |
| 93 | if (BENIGN_DECLINE_OPENER_RE.test(t)) return false; |
| 94 | const clauses = t.split(/[.!?;\n]/); |
| 95 | for (const rawClause of clauses) { |
| 96 | const clause = rawClause.trim(); |
| 97 | if (!clause) continue; |
| 98 | const m = clause.match(IMPERATIVE_REVERSAL_RE); |
| 99 | if (!m) continue; |
| 100 | const idx = clause.toLowerCase().indexOf(m[0].toLowerCase()); |
| 101 | const lead = clause.slice(0, idx).replace(/[,\s]+$/, '').trim(); |
| 102 | if (lead && !/^(?:no|nope|ok|okay|please|hey|and|so|then|wait|hold on|also)\b[\s,]*$/i.test(lead)) { |
| 103 | continue; |
| 104 | } |
| 105 | if (BARE_STOP_RE.test(clause)) return true; |
| 106 | if (backRefsPriorAssistant(clause.slice(idx), priorAssistant)) return true; |
| 107 | } |
| 108 | if ( |
| 109 | DESTRUCTIVE_ATTR_RE.test(t) && |
| 110 | DESTRUCTIVE_REDIRECT_CUE_RE.test(t) && |
| 111 | backRefsPriorAssistant(t, priorAssistant) |
| 112 | ) { |
| 113 | return true; |
| 114 | } |
| 115 | return false; |
| 116 | } |
| 117 | |
| 118 | const STRUCT_REDIRECT_STOPTOKENS = new Set([ |
| 119 | 'with', 'into', 'just', 'back', 'goal', 'under', 'over', 'this', 'that', 'these', 'those', |
| 120 | 'then', 'than', 'them', 'they', 'your', 'have', 'will', 'from', 'about', 'across', 'after', |
| 121 | 'approach', 'instead', 'reverting', 'switching', 'collapsing', 'understood', 'reorienting', |
| 122 | 'misread', 'deleting', 'returning', 'added', 'done', 'made', 'built', 'rebuilt', 'using', |
| 123 | 'thanks', 'later', 'minimize', 'maximize', 'target', 'budget', 'local', 'bench', |
| 124 | ]); |
| 125 | const NEGATED_RESTATEMENT_RE = |
| 126 | /,\s*not\b|\b(?:wanted|want|cared|care|asked|meant|need|needed|expected|after)\b[^.]{0,40}\bnot\b|\bnot\b[^.]{0,30}\b(?:but|instead)\b/i; |
| 127 | const GOAL_MISMATCH_SELF_RE = |
| 128 | /\byou\s+(?:solved|built|did|made|gave|chose|used|wrote|created|went|took|picked|implemented|optimi[sz]ed|focused|targeted)\b[^.]{0,40}\bwrong\b/i; |
| 129 | const GOAL_MISMATCH_RE = |
| 130 | /\bwrong\s+(?:problem|thing|goal|approach|direction|track|path|idea|feature|task|tool|one|axis|shape)\b/i; |
| 131 | const STRUCT_REVERSAL_RE = |
| 132 | /\b(?:nix|scrap|revert|undo|yank)\b|\b(?:rip|tear|take|strip|pull|gut)\b[^.]{0,30}\bout\b/i; |
| 133 | const PERMISSIVE_FRAMING_RE = |
| 134 | /\b(?:feel free to|go ahead and|go ahead|you (?:can|could|may|might)|if you (?:want|like|prefer)|whenever you|happy for you to|fine to)\b/i; |
| 135 | const SCOPE_AFFIRMATION_RE = |
| 136 | /\bi (?:just|only) (?:want|need|wanted|needed)\b|\bthat'?s all\b|\bjust (?:want|keep) (?:it|that)\b/i; |
| 137 | |
| 138 | function looksLikeStructuralRedirect(text, priorAssistant) { |
| 139 | let t = typeof text === 'string' ? text.trim() : ''; |
| 140 | if (!t || t.length > 600) return false; |
| 141 | if (!priorAssistant || !priorAssistant.tokens || !priorAssistant.tokens.size) return false; |
| 142 | t = t.replace(DECLINE_INTERJECTION_RE, '').trim(); |
| 143 | if (BENIGN_DECLINE_OPENER_RE.test(t)) return false; |
| 144 | if (PERMISSIVE_FRAMING_RE.test(t) || SCOPE_AFFIRMATION_RE.test(t)) return false; |
| 145 | if (GOAL_MISMATCH_SELF_RE.test(t)) return true; |
| 146 | const hasCue = |
| 147 | NEGATED_RESTATEMENT_RE.test(t) || GOAL_MISMATCH_RE.test(t) || STRUCT_REVERSAL_RE.test(t); |
| 148 | if (!hasCue) return false; |
| 149 | const low = t.toLowerCase(); |
| 150 | for (const tok of priorAssistant.tokens) { |
| 151 | if (tok.length < 4 || STRUCT_REDIRECT_STOPTOKENS.has(tok)) continue; |
| 152 | if (new RegExp(`\\b${tok.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`).test(low)) return true; |
| 153 | } |
| 154 | return false; |
| 155 | } |
| 156 | |
| 157 | function structuralRedirectIsDecline(text, priorAssistant) { |
| 158 | let t = typeof text === 'string' ? text.trim() : ''; |
| 159 | if (!t || t.length > 600) return false; |
| 160 | if (!priorAssistant || !priorAssistant.tokens || !priorAssistant.tokens.size) return false; |
| 161 | t = t.replace(DECLINE_INTERJECTION_RE, '').trim(); |
| 162 | if (BENIGN_DECLINE_OPENER_RE.test(t)) return false; |
| 163 | if (PERMISSIVE_FRAMING_RE.test(t)) return false; |
| 164 | if (!STRUCT_REVERSAL_RE.test(t)) return false; |
| 165 | const low = t.toLowerCase(); |
| 166 | for (const tok of priorAssistant.tokens) { |
| 167 | if (tok.length < 4 || STRUCT_REDIRECT_STOPTOKENS.has(tok)) continue; |
| 168 | if (new RegExp(`\\b${tok.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`).test(low)) return true; |
| 169 | } |
| 170 | return false; |
| 171 | } |
| 172 | |
| 173 | const GOAL_MISMATCH_FRAME_RE = |
| 174 | /\bthat'?s not what i (?:asked|wanted|meant|said|requested)\b|\bthe whole point (?:is|was|of)\b|\bwhat i (?:actually|really) (?:wanted|asked|meant|need(?:ed)?)\b|\bmissed the (?:point|goal)\b|\bnot what i'?m after\b/i; |
| 175 | const ROOT_GOAL_STOPTOKENS = new Set([ |
| 176 | 'support', 'update', 'updates', 'feature', 'features', 'system', 'systems', 'please', 'should', |
| 177 | 'would', 'could', 'about', 'these', 'those', 'their', 'there', 'which', 'while', 'where', 'thing', |
| 178 | 'things', 'something', 'devices', 'device', 'images', 'image', 'field', 'pull', 'make', 'build', |
| 179 | 'built', 'using', 'with', 'from', 'into', 'over', 'they', 'them', 'this', 'that', 'have', 'need', |
| 180 | 'needs', 'want', 'wants', 'when', 'then', 'than', 'your', 'each', 'able', 'code', 'work', 'works', |
| 181 | ]); |
| 182 | function extractRootGoalTokens(text) { |
| 183 | const out = new Set(); |
| 184 | const low = String(text || '').toLowerCase(); |
| 185 | for (const w of low.match(/[a-z][a-z0-9_-]{3,}/g) || []) { |
| 186 | if (w.length >= 4 && !ROOT_GOAL_STOPTOKENS.has(w)) out.add(w); |
| 187 | } |
| 188 | for (const phrase of low.match(/[a-z]{2,}(?:-[a-z]{2,}){1,}/g) || []) { |
| 189 | if (phrase.length >= 6) out.add(phrase); |
| 190 | } |
| 191 | return out; |
| 192 | } |
| 193 | function looksLikeGoalMismatchRedirect(text, rootGoalTokens) { |
| 194 | let t = typeof text === 'string' ? text.trim() : ''; |
| 195 | if (!t || t.length > 600) return false; |
| 196 | if (!rootGoalTokens || !rootGoalTokens.size) return false; |
| 197 | if (!GOAL_MISMATCH_FRAME_RE.test(t)) return false; |
| 198 | const low = t.toLowerCase(); |
| 199 | for (const tok of rootGoalTokens) { |
| 200 | if (tok.length < 4) continue; |
| 201 | if (new RegExp(`\\b${tok.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`).test(low)) return true; |
| 202 | } |
| 203 | return false; |
| 204 | } |
| 205 | |
| 206 | function buildPriorAssistantSnapshot(files, narration) { |
| 207 | const tokens = new Set(); |
| 208 | for (const f of files) { |
| 209 | const base = String(f).split(/[\\/]/).pop(); |
| 210 | if (base && base.length >= 4) tokens.add(base.toLowerCase()); |
| 211 | for (const seg of String(f).toLowerCase().split(/[\\/.+_-]+/)) { |
| 212 | if (seg.length >= 4) tokens.add(seg); |
| 213 | } |
| 214 | } |
| 215 | for (const w of String(narration || '').toLowerCase().match(/[a-z][a-z0-9_-]{3,}/g) || []) { |
| 216 | tokens.add(w); |
| 217 | } |
| 218 | return { tokens }; |
| 219 | } |
| 220 | |
| 221 | export async function parseSessionFile(path, sessionMeta = {}) { |
| 222 | const session = { |
| 223 | sessionId: sessionMeta.sessionId || null, |
| 224 | path, |
| 225 | title: null, |
| 226 | customTitle: null, |
| 227 | version: null, |
| 228 | cwd: null, |
| 229 | gitBranch: null, |
| 230 | firstTs: null, |
| 231 | lastTs: null, |
| 232 | prompts: [], |
| 233 | index: new Map(), |
| 234 | leafUuid: null, |
| 235 | activeLeafUuid: null, |
| 236 | stats: { |
| 237 | userLines: 0, |
| 238 | assistantLines: 0, |
| 239 | toolUses: 0, |
| 240 | models: new Set(), |
| 241 | filesTouched: new Set(), |
| 242 | inputTokens: 0, |
| 243 | outputTokens: 0, |
| 244 | interruptions: 0, |
| 245 | rejections: 0, |
| 246 | rejectionsByKind: Object.create(null), |
| 247 | }, |
| 248 | isContinuation: false, |
| 249 | _usageByMsgId: new Map(), |
| 250 | _pendingInterruption: false, |
| 251 | _currentPrompt: null, |
| 252 | _priorAssistant: null, |
| 253 | _rootGoalTokens: null, |
| 254 | }; |
| 255 | |
| 256 | const stream = createReadStream(path, { encoding: 'utf8' }); |
| 257 | const rl = createInterface({ input: stream, crlfDelay: Infinity }); |
| 258 | |
| 259 | for await (const line of rl) { |
| 260 | if (!line || line.charCodeAt(0) !== 123 ) continue; |
| 261 | let rec; |
| 262 | try { |
| 263 | rec = JSON.parse(line); |
| 264 | } catch { |
| 265 | continue; |
| 266 | } |
| 267 | try { |
| 268 | ingestRecord(session, rec); |
| 269 | } catch { |
| 270 | continue; |
| 271 | } |
| 272 | } |
| 273 | rl.close(); |
| 274 | |
| 275 | for (const usage of session._usageByMsgId.values()) { |
| 276 | session.stats.inputTokens += usage.input_tokens || 0; |
| 277 | session.stats.outputTokens += usage.output_tokens || 0; |
| 278 | } |
| 279 | session._usageByMsgId = null; |
| 280 | |
| 281 | if (session.customTitle) session.title = session.customTitle; |
| 282 | session.stats.models = [...session.stats.models]; |
| 283 | session.stats.filesTouched = [...session.stats.filesTouched]; |
| 284 | session.stats.rejectionsByKind = { ...session.stats.rejectionsByKind }; |
| 285 | return session; |
| 286 | } |
| 287 | |
| 288 | function ingestRecord(session, rec) { |
| 289 | switch (rec.type) { |
| 290 | case 'user': |
| 291 | ingestUser(session, rec); |
| 292 | break; |
| 293 | case 'assistant': |
| 294 | ingestAssistant(session, rec); |
| 295 | break; |
| 296 | case 'system': |
| 297 | indexDagNode(session, rec, { |
| 298 | |
| 299 | parentOverride: |
| 300 | rec.subtype === 'compact_boundary' && rec.logicalParentUuid |
| 301 | ? rec.logicalParentUuid |
| 302 | : undefined, |
| 303 | }); |
| 304 | break; |
| 305 | case 'attachment': |
| 306 | indexDagNode(session, rec); |
| 307 | break; |
| 308 | case 'summary': |
| 309 | if (rec.summary && !session.title) session.title = rec.summary; |
| 310 | break; |
| 311 | case 'ai-title': |
| 312 | if (rec.aiTitle || rec.title) session.title = rec.aiTitle || rec.title; |
| 313 | break; |
| 314 | case 'custom-title': |
| 315 | if (rec.customTitle) session.customTitle = rec.customTitle; |
| 316 | break; |
| 317 | case 'last-prompt': |
| 318 | if (rec.leafUuid) session.activeLeafUuid = rec.leafUuid; |
| 319 | break; |
| 320 | default: |
| 321 | |
| 322 | break; |
| 323 | } |
| 324 | |
| 325 | if (!session.sessionId && rec.sessionId) session.sessionId = rec.sessionId; |
| 326 | if (!session.version && rec.version) session.version = rec.version; |
| 327 | if (!session.cwd && rec.cwd) session.cwd = rec.cwd; |
| 328 | if (!session.gitBranch && rec.gitBranch) session.gitBranch = rec.gitBranch; |
| 329 | if (rec.timestamp && DAG_TYPES.has(rec.type)) { |
| 330 | if (!session.firstTs) session.firstTs = rec.timestamp; |
| 331 | session.lastTs = rec.timestamp; |
| 332 | } |
| 333 | } |
| 334 | |
| 335 | function indexDagNode(session, rec, { parentOverride } = {}) { |
| 336 | if (!rec.uuid) return; |
| 337 | session.index.set(rec.uuid, { |
| 338 | parentUuid: parentOverride !== undefined ? parentOverride : rec.parentUuid || null, |
| 339 | type: rec.type, |
| 340 | ts: rec.timestamp || null, |
| 341 | }); |
| 342 | if (!rec.isSidechain) session.leafUuid = rec.uuid; |
| 343 | } |
| 344 | |
| 345 | function attachRejection(session, rejection) { |
| 346 | if (!rejection || typeof rejection.kind !== 'string') return; |
| 347 | let prompt = session._currentPrompt; |
| 348 | if (!prompt) { |
| 349 | prompt = { |
| 350 | uuid: null, |
| 351 | parentUuid: session.leafUuid || null, |
| 352 | ts: rejection.ts || null, |
| 353 | text: '', |
| 354 | hasImage: false, |
| 355 | hadToolResultContext: true, |
| 356 | afterInterruption: false, |
| 357 | actions: [], |
| 358 | thinking: 0, |
| 359 | rejections: [], |
| 360 | isRejectionOnly: true, |
| 361 | }; |
| 362 | session.prompts.push(prompt); |
| 363 | session._currentPrompt = prompt; |
| 364 | } |
| 365 | if (!Array.isArray(prompt.rejections)) prompt.rejections = []; |
| 366 | prompt.rejections.push(rejection); |
| 367 | session.stats.rejections = (session.stats.rejections || 0) + 1; |
| 368 | session.stats.rejectionsByKind = session.stats.rejectionsByKind || Object.create(null); |
| 369 | session.stats.rejectionsByKind[rejection.kind] = (session.stats.rejectionsByKind[rejection.kind] || 0) + 1; |
| 370 | } |
| 371 | |
| 372 | function ingestUser(session, rec) { |
| 373 | |
| 374 | if (rec.isSidechain || rec.agentId) return; |
| 375 | indexDagNode(session, rec); |
| 376 | session.stats.userLines++; |
| 377 | |
| 378 | if (rec.toolUseResult !== undefined || rec.sourceToolAssistantUUID !== undefined) return; |
| 379 | |
| 380 | if (rec.isMeta) return; |
| 381 | if (rec.isCompactSummary) { |
| 382 | session.isContinuation = true; |
| 383 | return; |
| 384 | } |
| 385 | if (rec.promptSource === 'system' || rec.promptSource === 'sdk') return; |
| 386 | if (rec.origin && rec.origin.kind === 'task-notification') return; |
| 387 | |
| 388 | const msg = rec.message || {}; |
| 389 | const { text, hasImage, hasToolResult, hasOnlyToolResult, toolResults } = flattenUserContent(msg.content); |
| 390 | |
| 391 | if (hasOnlyToolResult) { |
| 392 | for (const tr of toolResults) { |
| 393 | if (tr && tr.isError) { |
| 394 | const cls = classifyToolResultRejection(tr.content); |
| 395 | attachRejection(session, { |
| 396 | kind: cls.kind, |
| 397 | source: 'tool_result', |
| 398 | confidence: cls.confidence, |
| 399 | toolUseId: tr.toolUseId || null, |
| 400 | tool: null, |
| 401 | ts: rec.timestamp || null, |
| 402 | evidence: cls.evidence, |
| 403 | }); |
| 404 | } |
| 405 | } |
| 406 | return; |
| 407 | } |
| 408 | |
| 409 | if (hasToolResult && Array.isArray(toolResults)) { |
| 410 | for (const tr of toolResults) { |
| 411 | if (tr && tr.isError) { |
| 412 | const cls = classifyToolResultRejection(tr.content); |
| 413 | attachRejection(session, { |
| 414 | kind: cls.kind, |
| 415 | source: 'tool_result', |
| 416 | confidence: cls.confidence, |
| 417 | toolUseId: tr.toolUseId || null, |
| 418 | tool: null, |
| 419 | ts: rec.timestamp || null, |
| 420 | evidence: cls.evidence, |
| 421 | }); |
| 422 | } |
| 423 | } |
| 424 | } |
| 425 | |
| 426 | let trimmed = (text || '').trim(); |
| 427 | |
| 428 | if (/^\[Request interrupted by user/i.test(trimmed)) { |
| 429 | session.stats.interruptions++; |
| 430 | session._pendingInterruption = true; |
| 431 | attachRejection(session, { |
| 432 | kind: 'user_interrupt', |
| 433 | source: 'text', |
| 434 | confidence: 1.0, |
| 435 | toolUseId: null, |
| 436 | tool: null, |
| 437 | ts: rec.timestamp || null, |
| 438 | evidence: truncate(trimmed, 160) || '[Request interrupted by user]', |
| 439 | }); |
| 440 | return; |
| 441 | } |
| 442 | |
| 443 | const classification = classifySpecialUserText(trimmed); |
| 444 | if (classification === 'meta') { |
| 445 | const recovered = stripWrapperMeta(trimmed); |
| 446 | if (!recovered || recovered === trimmed) return; |
| 447 | trimmed = recovered; |
| 448 | } |
| 449 | if (classification === 'compact-continuation') { |
| 450 | session.isContinuation = true; |
| 451 | return; |
| 452 | } |
| 453 | if (classification === 'command') { |
| 454 | |
| 455 | const invocation = extractCommandInvocation(trimmed); |
| 456 | if (!invocation) return; |
| 457 | trimmed = invocation; |
| 458 | } |
| 459 | |
| 460 | if (!trimmed && hasImage) trimmed = '[image-only prompt: screenshot/annotated feedback]'; |
| 461 | if (!trimmed) return; |
| 462 | |
| 463 | if (session._rootGoalTokens === null && !session.isContinuation) { |
| 464 | session._rootGoalTokens = extractRootGoalTokens(trimmed); |
| 465 | } |
| 466 | |
| 467 | const isGoalMismatchRedirect = looksLikeGoalMismatchRedirect(trimmed, session._rootGoalTokens); |
| 468 | const isStructDecline = looksLikeStructuralDecline(trimmed, session._priorAssistant); |
| 469 | const isStructRedirect = |
| 470 | looksLikeStructuralRedirect(trimmed, session._priorAssistant) || |
| 471 | isGoalMismatchRedirect || |
| 472 | isStructDecline; |
| 473 | if ( |
| 474 | looksLikeUserTextDecline(trimmed) || |
| 475 | isStructDecline || |
| 476 | structuralRedirectIsDecline(trimmed, session._priorAssistant) || |
| 477 | isGoalMismatchRedirect |
| 478 | ) { |
| 479 | attachRejectionToText(session, rec, trimmed, 'user_text_decline', 'text', 0.8, isStructRedirect); |
| 480 | session._pendingInterruption = false; |
| 481 | return; |
| 482 | } |
| 483 | |
| 484 | const prompt = { |
| 485 | uuid: rec.uuid || null, |
| 486 | parentUuid: rec.parentUuid || null, |
| 487 | ts: rec.timestamp || null, |
| 488 | text: trimmed, |
| 489 | hasImage, |
| 490 | hadToolResultContext: hasToolResult, |
| 491 | afterInterruption: Boolean(session._pendingInterruption), |
| 492 | actions: [], |
| 493 | thinking: 0, |
| 494 | rejections: [], |
| 495 | structuralRedirect: looksLikeStructuralRedirect(trimmed, session._priorAssistant), |
| 496 | _priorTokens: session._priorAssistant, |
| 497 | }; |
| 498 | session.prompts.push(prompt); |
| 499 | session._currentPrompt = prompt; |
| 500 | session._pendingInterruption = false; |
| 501 | } |
| 502 | |
| 503 | function attachRejectionToText(session, rec, text, kind, source, confidence, structuralRedirect = false) { |
| 504 | const placeholder = { |
| 505 | uuid: rec.uuid || null, |
| 506 | parentUuid: rec.parentUuid || null, |
| 507 | ts: rec.timestamp || null, |
| 508 | text, |
| 509 | hasImage: false, |
| 510 | hadToolResultContext: false, |
| 511 | afterInterruption: Boolean(session._pendingInterruption), |
| 512 | actions: [], |
| 513 | thinking: 0, |
| 514 | rejections: [], |
| 515 | structuralRedirect, |
| 516 | _priorTokens: session._priorAssistant, |
| 517 | }; |
| 518 | session.prompts.push(placeholder); |
| 519 | session._currentPrompt = placeholder; |
| 520 | attachRejection(session, { |
| 521 | kind, |
| 522 | source, |
| 523 | confidence, |
| 524 | toolUseId: null, |
| 525 | tool: null, |
| 526 | ts: rec.timestamp || null, |
| 527 | evidence: truncate(text, 160), |
| 528 | }); |
| 529 | } |
| 530 | |
| 531 | function ingestAssistant(session, rec) { |
| 532 | if (rec.isSidechain || rec.agentId) return; |
| 533 | indexDagNode(session, rec); |
| 534 | session.stats.assistantLines++; |
| 535 | |
| 536 | const msg = rec.message || {}; |
| 537 | const synthetic = msg.model === '<synthetic>' || rec.isApiErrorMessage; |
| 538 | |
| 539 | if (msg.model && !synthetic) session.stats.models.add(msg.model); |
| 540 | |
| 541 | if (msg.usage && !synthetic && (msg.usage.input_tokens || msg.usage.output_tokens)) { |
| 542 | session._usageByMsgId.set(msg.id || rec.uuid, msg.usage); |
| 543 | } |
| 544 | |
| 545 | const current = session._currentPrompt; |
| 546 | const content = Array.isArray(msg.content) ? msg.content : []; |
| 547 | let narration = ''; |
| 548 | const touchedFiles = new Set(); |
| 549 | for (const block of content) { |
| 550 | if (block && block.type === 'text' && typeof block.text === 'string') { |
| 551 | narration += (narration ? ' ' : '') + block.text; |
| 552 | } |
| 553 | } |
| 554 | let refusalClause = null; |
| 555 | let toolUsesThisTurn = 0; |
| 556 | for (const block of content) { |
| 557 | if (!block) continue; |
| 558 | if (block.type === 'text') { |
| 559 | if (refusalClause === null && looksLikeRefusalStructural(block.text)) { |
| 560 | refusalClause = typeof block.text === 'string' ? block.text : ''; |
| 561 | } |
| 562 | } else if (block.type === 'tool_use') { |
| 563 | toolUsesThisTurn++; |
| 564 | session.stats.toolUses++; |
| 565 | const input = block.input || {}; |
| 566 | const file = input.file_path || input.notebook_path || null; |
| 567 | if (typeof file === 'string') { |
| 568 | session.stats.filesTouched.add(file); |
| 569 | touchedFiles.add(file); |
| 570 | } |
| 571 | if (block.name === 'Bash' && typeof input.command === 'string') { |
| 572 | for (const p of shellFilePaths(input.command)) { |
| 573 | session.stats.filesTouched.add(p); |
| 574 | touchedFiles.add(p); |
| 575 | } |
| 576 | } |
| 577 | if (current) { |
| 578 | current.actions.push({ |
| 579 | tool: block.name || null, |
| 580 | file: typeof file === 'string' ? file : null, |
| 581 | command: block.name === 'Bash' && typeof input.command === 'string' ? input.command : null, |
| 582 | input: summarizeToolInput(block.name, input), |
| 583 | narration: narration || null, |
| 584 | model: synthetic ? null : msg.model || null, |
| 585 | }); |
| 586 | } |
| 587 | } else if (block.type === 'thinking' || block.type === 'redacted_thinking') { |
| 588 | if (current) current.thinking++; |
| 589 | } |
| 590 | } |
| 591 | |
| 592 | if (refusalClause !== null && toolUsesThisTurn === 0 && msg.stop_reason !== 'refusal') { |
| 593 | attachRejection(session, { |
| 594 | kind: 'model_refusal', |
| 595 | source: 'text_heuristic', |
| 596 | confidence: 0.7, |
| 597 | toolUseId: null, |
| 598 | tool: null, |
| 599 | ts: rec.timestamp || null, |
| 600 | evidence: truncate(refusalClause, 160), |
| 601 | }); |
| 602 | } |
| 603 | |
| 604 | if (msg.stop_reason === 'refusal') { |
| 605 | attachRejection(session, { |
| 606 | kind: 'model_refusal', |
| 607 | source: 'stop_reason', |
| 608 | confidence: 0.95, |
| 609 | toolUseId: null, |
| 610 | tool: null, |
| 611 | ts: rec.timestamp || null, |
| 612 | evidence: null, |
| 613 | }); |
| 614 | } |
| 615 | |
| 616 | if (touchedFiles.size || narration) { |
| 617 | session._priorAssistant = buildPriorAssistantSnapshot(touchedFiles, narration); |
| 618 | } |
| 619 | } |
| 620 | |
| 621 | const SHELL_PATH_RE = /(?:^|(?<=\s|[=,;|&`()]))(\$\{[^}]*\}|\$[A-Za-z_][A-Za-z0-9_]*|(\.{0,2}\/[^\s'"\\,;|&`()\[\]{}<>$!?*#]+))/g; |
| 622 | |
| 623 | function shellFilePaths(cmd) { |
| 624 | if (typeof cmd !== 'string' || !cmd) return []; |
| 625 | const seen = new Set(); |
| 626 | const out = []; |
| 627 | for (const m of cmd.matchAll(SHELL_PATH_RE)) { |
| 628 | const tok = m[2]; |
| 629 | if (!tok) continue; |
| 630 | const cleaned = tok.replace(/['">]+$/, ''); |
| 631 | if (!cleaned || cleaned.endsWith('/') || seen.has(cleaned)) continue; |
| 632 | seen.add(cleaned); |
| 633 | out.push(cleaned); |
| 634 | } |
| 635 | return out; |
| 636 | } |
| 637 | |
| 638 | const INPUT_CAP = 300; |
| 639 | |
| 640 | function summarizeToolInput(tool, input) { |
| 641 | if (!input || typeof input !== 'object') return null; |
| 642 | let raw; |
| 643 | switch (tool) { |
| 644 | case 'Bash': |
| 645 | raw = typeof input.command === 'string' ? input.command : compactJson(input); |
| 646 | break; |
| 647 | case 'Edit': |
| 648 | raw = typeof input.new_string === 'string' ? input.new_string : compactJson(input); |
| 649 | break; |
| 650 | case 'Write': |
| 651 | raw = typeof input.content === 'string' ? input.content : compactJson(input); |
| 652 | break; |
| 653 | case 'WebFetch': |
| 654 | raw = [input.url, input.prompt].filter((v) => typeof v === 'string').join(' ') || compactJson(input); |
| 655 | break; |
| 656 | default: |
| 657 | raw = compactJson(input); |
| 658 | } |
| 659 | if (!raw) return null; |
| 660 | raw = raw.replace(/\s+/g, ' ').trim(); |
| 661 | if (!raw) return null; |
| 662 | return raw.length > INPUT_CAP ? `${raw.slice(0, INPUT_CAP)}...` : raw; |
| 663 | } |
| 664 | |
| 665 | function compactJson(value) { |
| 666 | try { |
| 667 | return JSON.stringify(value); |
| 668 | } catch { |
| 669 | return null; |
| 670 | } |
| 671 | } |
| 672 | |
| 673 | function flattenUserContent(content) { |
| 674 | if (typeof content === 'string') { |
| 675 | return { text: content, hasImage: false, hasToolResult: false, hasOnlyToolResult: false, toolResults: [] }; |
| 676 | } |
| 677 | if (!Array.isArray(content)) { |
| 678 | return { text: '', hasImage: false, hasToolResult: false, hasOnlyToolResult: false, toolResults: [] }; |
| 679 | } |
| 680 | let text = ''; |
| 681 | const toolResults = []; |
| 682 | let others = 0; |
| 683 | let images = 0; |
| 684 | for (const block of content) { |
| 685 | if (!block || typeof block !== 'object') continue; |
| 686 | if (block.type === 'text' && typeof block.text === 'string') { |
| 687 | text += (text ? '\n' : '') + block.text; |
| 688 | others++; |
| 689 | } else if (block.type === 'tool_result') { |
| 690 | const raw = block.content; |
| 691 | let blockText = ''; |
| 692 | if (typeof raw === 'string') blockText = raw; |
| 693 | else if (Array.isArray(raw)) { |
| 694 | for (const part of raw) { |
| 695 | if (part && typeof part === 'object' && typeof part.text === 'string') { |
| 696 | blockText += (blockText ? '\n' : '') + part.text; |
| 697 | } else if (typeof part === 'string') { |
| 698 | blockText += (blockText ? '\n' : '') + part; |
| 699 | } |
| 700 | } |
| 701 | } |
| 702 | toolResults.push({ |
| 703 | toolUseId: typeof block.tool_use_id === 'string' ? block.tool_use_id : null, |
| 704 | isError: block.is_error === true, |
| 705 | content: blockText, |
| 706 | contentType: typeof raw === 'string' ? 'string' : Array.isArray(raw) ? 'array' : 'other', |
| 707 | }); |
| 708 | } else if (block.type === 'image') { |
| 709 | images++; |
| 710 | } else { |
| 711 | others++; |
| 712 | } |
| 713 | } |
| 714 | return { |
| 715 | text, |
| 716 | hasImage: images > 0, |
| 717 | hasToolResult: toolResults.length > 0, |
| 718 | hasOnlyToolResult: toolResults.length > 0 && others === 0 && images === 0, |
| 719 | toolResults, |
| 720 | }; |
| 721 | } |
| 722 | |
| 723 | const COMPACT_CONTINUATION_RE = |
| 724 | /^this session is being continued from a previous conversation/i; |
| 725 | |
| 726 | function stripWrapperMeta(text) { |
| 727 | return String(text || '') |
| 728 | .replace(/<system-reminder>[\s\S]*?<\/system-reminder>/gi, '') |
| 729 | .replace(/<task-notification>[\s\S]*?<\/task-notification>/gi, '') |
| 730 | .replace(/<system-reminder>[\s\S]*$/i, '') |
| 731 | .replace(/<task-notification>[\s\S]*$/i, '') |
| 732 | .trim(); |
| 733 | } |
| 734 | |
| 735 | export function classifySpecialUserText(text) { |
| 736 | if (COMPACT_CONTINUATION_RE.test(text)) return 'compact-continuation'; |
| 737 | if ( |
| 738 | text.startsWith('<command-name>') || |
| 739 | text.startsWith('<command-message>') || |
| 740 | text.startsWith('<local-command-stdout>') || |
| 741 | text.startsWith('<bash-input>') || |
| 742 | text.startsWith('<bash-stdout>') || |
| 743 | text.startsWith('<bash-stderr>') |
| 744 | ) { |
| 745 | return 'command'; |
| 746 | } |
| 747 | if ( |
| 748 | text.startsWith('<system-reminder>') || |
| 749 | text.startsWith('<task-notification>') || |
| 750 | text.startsWith('<local-command-caveat>') || |
| 751 | text.startsWith('Caveat: The messages below') |
| 752 | ) { |
| 753 | return 'meta'; |
| 754 | } |
| 755 | return 'prompt'; |
| 756 | } |
| 757 | |
| 758 | export function extractCommandInvocation(text) { |
| 759 | const name = text.match(/<command-name>([^<]*)<\/command-name>/)?.[1]?.trim(); |
| 760 | const args = text.match(/<command-args>([\s\S]*?)<\/command-args>/)?.[1]?.trim(); |
| 761 | if (!args) return null; |
| 762 | return `${name || '(command)'} ${args}`; |
| 763 | } |
| 764 | |
| 765 | export function parsePlainTranscript(text, label = 'pasted-transcript') { |
| 766 | const lines = text.split(/\r?\n/); |
| 767 | const markers = |
| 768 | /^(?:#{1,4}\s*)?(?:\*\*)?(user|human|me|you|prompt)(?:\*\*)?\s*[:--]?\s*$|^(?:#{1,4}\s*)?(?:\*\*)?(user|human|me|prompt)(?:\*\*)?\s*[:-]\s*(.+)$/i; |
| 769 | const assistantMarkers = |
| 770 | /^(?:#{1,4}\s*)?(?:\*\*)?(assistant|ai|chatgpt|claude|gpt|gemini|model|response)(?:\*\*)?\s*[:--]?\s*/i; |
| 771 | |
| 772 | const prompts = []; |
| 773 | let current = null; |
| 774 | let assistantBuf = null; |
| 775 | let sawMarkers = false; |
| 776 | let assistantLines = 0; |
| 777 | let rejectionCount = 0; |
| 778 | const rejectionsByKind = Object.create(null); |
| 779 | |
| 780 | const record = (target, rejection) => { |
| 781 | if (!target) return; |
| 782 | if (!Array.isArray(target.rejections)) target.rejections = []; |
| 783 | target.rejections.push(rejection); |
| 784 | rejectionCount++; |
| 785 | rejectionsByKind[rejection.kind] = (rejectionsByKind[rejection.kind] || 0) + 1; |
| 786 | }; |
| 787 | |
| 788 | const flushAssistant = () => { |
| 789 | if (assistantBuf == null) return; |
| 790 | const atext = assistantBuf.trim(); |
| 791 | if (atext) { |
| 792 | assistantLines++; |
| 793 | if (looksLikeRefusal(atext)) { |
| 794 | record(prompts[prompts.length - 1], { |
| 795 | kind: 'model_refusal', |
| 796 | source: 'text_heuristic', |
| 797 | confidence: 0.7, |
| 798 | toolUseId: null, |
| 799 | tool: null, |
| 800 | ts: null, |
| 801 | evidence: truncate(atext, 160), |
| 802 | }); |
| 803 | } |
| 804 | } |
| 805 | assistantBuf = null; |
| 806 | }; |
| 807 | |
| 808 | const flushUser = () => { |
| 809 | if (current && current.text.trim()) { |
| 810 | const utext = current.text.trim(); |
| 811 | if (looksLikeUserTextDecline(utext)) { |
| 812 | record(current, { |
| 813 | kind: 'user_text_decline', |
| 814 | source: 'text', |
| 815 | confidence: 0.8, |
| 816 | toolUseId: null, |
| 817 | tool: null, |
| 818 | ts: null, |
| 819 | evidence: truncate(utext, 160), |
| 820 | }); |
| 821 | } |
| 822 | prompts.push(current); |
| 823 | } |
| 824 | current = null; |
| 825 | }; |
| 826 | |
| 827 | for (const line of lines) { |
| 828 | const userMatch = line.match(markers); |
| 829 | if (userMatch) { |
| 830 | sawMarkers = true; |
| 831 | flushAssistant(); |
| 832 | flushUser(); |
| 833 | current = { text: userMatch[3] ? `${userMatch[3]}\n` : '', uuid: null, parentUuid: null, ts: null, rejections: [] }; |
| 834 | continue; |
| 835 | } |
| 836 | const assistantMatch = line.match(assistantMarkers); |
| 837 | if (assistantMatch) { |
| 838 | sawMarkers = true; |
| 839 | flushAssistant(); |
| 840 | flushUser(); |
| 841 | const inline = line.slice(assistantMatch[0].length); |
| 842 | assistantBuf = inline ? `${inline}\n` : ''; |
| 843 | continue; |
| 844 | } |
| 845 | if (current) current.text += `${line}\n`; |
| 846 | else if (assistantBuf != null) assistantBuf += `${line}\n`; |
| 847 | } |
| 848 | flushAssistant(); |
| 849 | flushUser(); |
| 850 | |
| 851 | if (!sawMarkers) { |
| 852 | throw new TreetraceError( |
| 853 | 'could not find user/assistant turn markers in the transcript. ' + |
| 854 | 'Expected lines like "User:", "## User", "Human:", "Assistant:" separating turns.', |
| 855 | ExitCode.NO_DATA |
| 856 | ); |
| 857 | } |
| 858 | |
| 859 | return { |
| 860 | sessionId: label, |
| 861 | path: label, |
| 862 | title: null, |
| 863 | version: null, |
| 864 | cwd: null, |
| 865 | gitBranch: null, |
| 866 | firstTs: null, |
| 867 | lastTs: null, |
| 868 | prompts: prompts.map((p) => ({ ...p, text: p.text.trim(), actions: [], thinking: 0, rejections: p.rejections || [] })), |
| 869 | index: new Map(), |
| 870 | leafUuid: null, |
| 871 | activeLeafUuid: null, |
| 872 | stats: { |
| 873 | userLines: prompts.length, |
| 874 | assistantLines, |
| 875 | toolUses: 0, |
| 876 | models: [], |
| 877 | filesTouched: [], |
| 878 | inputTokens: 0, |
| 879 | outputTokens: 0, |
| 880 | interruptions: 0, |
| 881 | rejections: rejectionCount, |
| 882 | rejectionsByKind: { ...rejectionsByKind }, |
| 883 | }, |
| 884 | isContinuation: false, |
| 885 | }; |
| 886 | } |