Zion Boggan
repos/TreeTrace/src/parse.js
zionboggan.com ↗
886 lines · javascript
History for this file →
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
}