@ekkos/cli 0.2.7 → 0.2.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,658 @@
1
+ "use strict";
2
+ /**
3
+ * JSONL Stream Tailer for Claude Code transcripts
4
+ *
5
+ * Tails ~/.claude/projects/{encoded-path}/{session-uuid}.jsonl
6
+ * and extracts assistant_stream + tool events in real-time.
7
+ *
8
+ * This is the core component for "near-zero context loss" on /continue.
9
+ */
10
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
11
+ if (k2 === undefined) k2 = k;
12
+ var desc = Object.getOwnPropertyDescriptor(m, k);
13
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
14
+ desc = { enumerable: true, get: function() { return m[k]; } };
15
+ }
16
+ Object.defineProperty(o, k2, desc);
17
+ }) : (function(o, m, k, k2) {
18
+ if (k2 === undefined) k2 = k;
19
+ o[k2] = m[k];
20
+ }));
21
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
22
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
23
+ }) : function(o, v) {
24
+ o["default"] = v;
25
+ });
26
+ var __importStar = (this && this.__importStar) || (function () {
27
+ var ownKeys = function(o) {
28
+ ownKeys = Object.getOwnPropertyNames || function (o) {
29
+ var ar = [];
30
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
31
+ return ar;
32
+ };
33
+ return ownKeys(o);
34
+ };
35
+ return function (mod) {
36
+ if (mod && mod.__esModule) return mod;
37
+ var result = {};
38
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
39
+ __setModuleDefault(result, mod);
40
+ return result;
41
+ };
42
+ })();
43
+ Object.defineProperty(exports, "__esModule", { value: true });
44
+ exports.StreamTailer = void 0;
45
+ exports.reconstructTurnFromEvents = reconstructTurnFromEvents;
46
+ exports.middleTruncate = middleTruncate;
47
+ exports.detectOpenLoops = detectOpenLoops;
48
+ const fs = __importStar(require("fs"));
49
+ const path = __importStar(require("path"));
50
+ const events_1 = require("events");
51
+ const crypto = __importStar(require("crypto"));
52
+ const POLL_INTERVAL_ACTIVE = 100; // ms while actively receiving
53
+ const POLL_INTERVAL_IDLE = 500; // ms when idle
54
+ const IDLE_THRESHOLD = 5000; // ms without activity = idle
55
+ const CHECKPOINT_INTERVAL = 5000; // Save checkpoint every 5 seconds
56
+ const HEAD_SIZE = 512; // First N chars to keep
57
+ const TAIL_SIZE = 8192; // Last N chars to keep (8KB)
58
+ class StreamTailer extends events_1.EventEmitter {
59
+ /**
60
+ * Generate a stable event_id for idempotency
61
+ *
62
+ * CRITICAL: event_id must be stable for the same underlying transcript data.
63
+ * Using transcript_line_uuid + block_index ensures:
64
+ * - Same ID after tailer restart
65
+ * - De-dupe when reading overlapping transcript ranges
66
+ * - True idempotency for append operations
67
+ *
68
+ * Format: {kind_prefix}_{transcript_line_uuid}_{block_index}_{content_hash}
69
+ */
70
+ generateEventId(kind, turnId, transcriptLineUuid, blockIndex, contentHash) {
71
+ // Fallback to counter if no UUID (legacy compatibility)
72
+ if (!transcriptLineUuid) {
73
+ const counter = ++this.eventCounter;
74
+ const hash = crypto.createHash('sha256')
75
+ .update(`${this.state.sessionId}:${kind}:${turnId}:${counter}`)
76
+ .digest('hex')
77
+ .slice(0, 12);
78
+ return `${kind.slice(0, 3)}_${counter}_${hash}`;
79
+ }
80
+ // Stable ID from transcript identity
81
+ const uuidShort = transcriptLineUuid.slice(0, 8);
82
+ const blockIdx = blockIndex ?? 0;
83
+ const hashPart = contentHash
84
+ ? contentHash.slice(0, 8)
85
+ : crypto.createHash('sha256')
86
+ .update(`${transcriptLineUuid}:${kind}:${blockIdx}`)
87
+ .digest('hex')
88
+ .slice(0, 8);
89
+ return `${kind.slice(0, 3)}_${uuidShort}_${blockIdx}_${hashPart}`;
90
+ }
91
+ /**
92
+ * Generate content hash for delta events
93
+ */
94
+ hashContent(content) {
95
+ return crypto.createHash('sha256')
96
+ .update(content)
97
+ .digest('hex')
98
+ .slice(0, 12);
99
+ }
100
+ /**
101
+ * Initialize state machine with default values
102
+ */
103
+ initStateMachine() {
104
+ return {
105
+ session_id: this.state.sessionId,
106
+ session_name: this.state.sessionName,
107
+ state: 'idle',
108
+ state_entered_at: new Date().toISOString(),
109
+ last_complete_turn_id: 0,
110
+ current_turn_id: 0,
111
+ in_progress_text_head: '',
112
+ in_progress_text_tail: '',
113
+ in_progress_total_chars: 0,
114
+ open_loops: [],
115
+ last_event_ts: new Date().toISOString(),
116
+ last_checkpoint_ts: new Date().toISOString(),
117
+ stream_bytes_captured: 0,
118
+ events_captured: 0,
119
+ };
120
+ }
121
+ /**
122
+ * Update state machine state
123
+ */
124
+ updateState(newState) {
125
+ if (this.stateMachine.state !== newState) {
126
+ this.stateMachine.state = newState;
127
+ this.stateMachine.state_entered_at = new Date().toISOString();
128
+ }
129
+ }
130
+ /**
131
+ * Update in-progress text head/tail
132
+ */
133
+ updateInProgressText(delta) {
134
+ this.inProgressText += delta;
135
+ this.stateMachine.in_progress_total_chars = this.inProgressText.length;
136
+ this.stateMachine.in_progress_text_head = this.inProgressText.slice(0, HEAD_SIZE);
137
+ this.stateMachine.in_progress_text_tail = this.inProgressText.slice(-TAIL_SIZE);
138
+ }
139
+ /**
140
+ * Clear in-progress text (on turn seal)
141
+ */
142
+ clearInProgressText() {
143
+ this.inProgressText = '';
144
+ this.stateMachine.in_progress_text_head = '';
145
+ this.stateMachine.in_progress_text_tail = '';
146
+ this.stateMachine.in_progress_total_chars = 0;
147
+ }
148
+ /**
149
+ * Add open loop
150
+ */
151
+ addOpenLoop(type, id, name) {
152
+ this.stateMachine.open_loops.push({
153
+ type,
154
+ id,
155
+ name,
156
+ started_at: new Date().toISOString(),
157
+ });
158
+ }
159
+ /**
160
+ * Close open loop
161
+ */
162
+ closeOpenLoop(id) {
163
+ this.stateMachine.open_loops = this.stateMachine.open_loops.filter(l => l.id !== id);
164
+ }
165
+ /**
166
+ * Save state machine to disk
167
+ */
168
+ saveStateMachine() {
169
+ try {
170
+ this.stateMachine.last_event_ts = new Date().toISOString();
171
+ fs.writeFileSync(this.stateMachinePath, JSON.stringify(this.stateMachine, null, 2));
172
+ }
173
+ catch (err) {
174
+ this.emit('persist_error', err);
175
+ }
176
+ }
177
+ /**
178
+ * Save checkpoint
179
+ */
180
+ saveCheckpoint() {
181
+ if (this.stateMachine.state !== 'assistant_streaming' && this.stateMachine.state !== 'tool_running') {
182
+ // Only checkpoint during active work
183
+ return;
184
+ }
185
+ try {
186
+ const checkpoint = {
187
+ session_id: this.state.sessionId,
188
+ turn_id: this.state.currentTurnId,
189
+ checkpoint_ts: new Date().toISOString(),
190
+ text_head: this.stateMachine.in_progress_text_head,
191
+ text_tail: this.stateMachine.in_progress_text_tail,
192
+ state: this.stateMachine.state,
193
+ open_loops: this.stateMachine.open_loops.map(l => ({
194
+ type: l.type,
195
+ id: l.id,
196
+ name: l.name,
197
+ started_at: l.started_at,
198
+ })),
199
+ total_chars: this.stateMachine.in_progress_total_chars,
200
+ events_since_last_checkpoint: this.eventsSinceCheckpoint,
201
+ };
202
+ fs.writeFileSync(this.checkpointPath, JSON.stringify(checkpoint, null, 2));
203
+ this.stateMachine.last_checkpoint_ts = checkpoint.checkpoint_ts;
204
+ this.eventsSinceCheckpoint = 0;
205
+ }
206
+ catch (err) {
207
+ this.emit('persist_error', err);
208
+ }
209
+ }
210
+ constructor(options) {
211
+ super();
212
+ this.pollTimer = null;
213
+ this.checkpointTimer = null;
214
+ this.running = false;
215
+ this.eventCounter = 0; // Monotonic counter for event_id generation
216
+ this.inProgressText = ''; // Full in-progress text for head/tail extraction
217
+ this.eventsSinceCheckpoint = 0;
218
+ // Idempotency tracking - set of seen event_ids
219
+ this.seenEventIds = new Set();
220
+ this.state = {
221
+ transcriptPath: options.transcriptPath,
222
+ sessionId: options.sessionId,
223
+ sessionName: options.sessionName,
224
+ readOffset: 0,
225
+ pendingBuffer: '',
226
+ currentTurnId: 0,
227
+ currentTurnStatus: 'complete',
228
+ lastActivityTs: Date.now(),
229
+ };
230
+ this.cacheDir = options.cacheDir;
231
+ this.eventLogPath = path.join(this.cacheDir, `${options.sessionId}.stream.jsonl`);
232
+ this.stateMachinePath = path.join(this.cacheDir, `${options.sessionId}.state.json`);
233
+ this.checkpointPath = path.join(this.cacheDir, `${options.sessionId}.checkpoint.json`);
234
+ this.onEventCallback = options.onEvent;
235
+ this.stateMachine = this.initStateMachine();
236
+ }
237
+ /**
238
+ * Start tailing the transcript file
239
+ */
240
+ async start() {
241
+ if (this.running)
242
+ return;
243
+ this.running = true;
244
+ // Ensure cache directory exists
245
+ await fs.promises.mkdir(this.cacheDir, { recursive: true });
246
+ // Emit session start event
247
+ this.emitEvent({
248
+ kind: 'session_start',
249
+ event_id: this.generateEventId('session_start', 0),
250
+ session_id: this.state.sessionId,
251
+ session_name: this.state.sessionName,
252
+ ts: new Date().toISOString(),
253
+ });
254
+ // Save initial state machine
255
+ this.saveStateMachine();
256
+ // Start checkpoint timer
257
+ this.checkpointTimer = setInterval(() => {
258
+ this.saveCheckpoint();
259
+ this.saveStateMachine();
260
+ }, CHECKPOINT_INTERVAL);
261
+ // Start polling
262
+ this.schedulePoll();
263
+ this.emit('started', { sessionId: this.state.sessionId });
264
+ }
265
+ /**
266
+ * Stop tailing
267
+ */
268
+ stop() {
269
+ this.running = false;
270
+ if (this.pollTimer) {
271
+ clearTimeout(this.pollTimer);
272
+ this.pollTimer = null;
273
+ }
274
+ if (this.checkpointTimer) {
275
+ clearInterval(this.checkpointTimer);
276
+ this.checkpointTimer = null;
277
+ }
278
+ // Save final state
279
+ this.updateState('idle');
280
+ this.saveStateMachine();
281
+ this.saveCheckpoint();
282
+ this.emit('stopped', { sessionId: this.state.sessionId });
283
+ }
284
+ /**
285
+ * Get current state (for debugging/status)
286
+ */
287
+ getState() {
288
+ return { ...this.state };
289
+ }
290
+ schedulePoll() {
291
+ if (!this.running)
292
+ return;
293
+ const timeSinceActivity = Date.now() - this.state.lastActivityTs;
294
+ const interval = timeSinceActivity > IDLE_THRESHOLD
295
+ ? POLL_INTERVAL_IDLE
296
+ : POLL_INTERVAL_ACTIVE;
297
+ this.pollTimer = setTimeout(() => this.poll(), interval);
298
+ }
299
+ async poll() {
300
+ if (!this.running)
301
+ return;
302
+ try {
303
+ await this.readNewContent();
304
+ }
305
+ catch (err) {
306
+ // File might not exist yet or be temporarily unavailable
307
+ if (err.code !== 'ENOENT') {
308
+ this.emit('error', err);
309
+ }
310
+ }
311
+ this.schedulePoll();
312
+ }
313
+ async readNewContent() {
314
+ const stat = await fs.promises.stat(this.state.transcriptPath);
315
+ const fileSize = stat.size;
316
+ // Handle file truncation (rotation/reset)
317
+ if (fileSize < this.state.readOffset) {
318
+ this.state.readOffset = 0;
319
+ this.state.pendingBuffer = '';
320
+ this.emit('truncated', { sessionId: this.state.sessionId });
321
+ }
322
+ // No new content
323
+ if (fileSize <= this.state.readOffset) {
324
+ return;
325
+ }
326
+ // Read new bytes
327
+ const fd = await fs.promises.open(this.state.transcriptPath, 'r');
328
+ try {
329
+ const bytesToRead = fileSize - this.state.readOffset;
330
+ const buffer = Buffer.alloc(bytesToRead);
331
+ await fd.read(buffer, 0, bytesToRead, this.state.readOffset);
332
+ this.state.readOffset = fileSize;
333
+ // Append to pending buffer and process complete lines
334
+ this.state.pendingBuffer += buffer.toString('utf-8');
335
+ this.processBuffer();
336
+ this.state.lastActivityTs = Date.now();
337
+ }
338
+ finally {
339
+ await fd.close();
340
+ }
341
+ }
342
+ processBuffer() {
343
+ // Split on newlines, keep incomplete line in buffer
344
+ const lines = this.state.pendingBuffer.split('\n');
345
+ this.state.pendingBuffer = lines.pop() || ''; // Last element is incomplete or empty
346
+ for (const line of lines) {
347
+ if (!line.trim())
348
+ continue;
349
+ this.processLine(line);
350
+ }
351
+ }
352
+ processLine(line) {
353
+ let parsed;
354
+ try {
355
+ parsed = JSON.parse(line);
356
+ }
357
+ catch {
358
+ // Malformed line, skip
359
+ this.emit('parse_error', { line: line.slice(0, 100) });
360
+ return;
361
+ }
362
+ // Skip non-message types (file-history-snapshot, etc.)
363
+ if (!parsed.type || !['user', 'assistant'].includes(parsed.type)) {
364
+ return;
365
+ }
366
+ const ts = parsed.timestamp || new Date().toISOString();
367
+ if (parsed.type === 'user') {
368
+ this.handleUserEvent(parsed, ts);
369
+ }
370
+ else if (parsed.type === 'assistant') {
371
+ this.handleAssistantEvent(parsed, ts);
372
+ }
373
+ }
374
+ handleUserEvent(line, ts) {
375
+ // Seal any in_progress assistant turn
376
+ if (this.state.currentTurnStatus === 'in_progress') {
377
+ // Use the incoming user message UUID as the seal trigger (stable)
378
+ this.emitEvent({
379
+ kind: 'seal_turn',
380
+ event_id: this.generateEventId('seal_turn', this.state.currentTurnId, line.uuid, // Use user message UUID as seal trigger
381
+ 0, 'user_boundary'),
382
+ turn_id: this.state.currentTurnId,
383
+ reason: 'user_boundary',
384
+ ts,
385
+ });
386
+ this.state.currentTurnStatus = 'complete';
387
+ // Update state machine
388
+ this.stateMachine.last_complete_turn_id = this.state.currentTurnId;
389
+ this.clearInProgressText();
390
+ this.stateMachine.open_loops = []; // Clear open loops on turn seal
391
+ }
392
+ // Start new turn
393
+ this.state.currentTurnId++;
394
+ // Update state machine
395
+ this.stateMachine.current_turn_id = this.state.currentTurnId;
396
+ this.updateState('idle');
397
+ // Extract user query
398
+ let query = '';
399
+ if (typeof line.message.content === 'string') {
400
+ query = line.message.content;
401
+ }
402
+ else if (Array.isArray(line.message.content)) {
403
+ // Extract text blocks
404
+ query = line.message.content
405
+ .filter((b) => b.type === 'text')
406
+ .map((b) => b.text || '')
407
+ .join('\n');
408
+ }
409
+ // Skip meta messages (command caveats, etc.) unless they have real content
410
+ if (line.isMeta && !query.replace(/<[^>]*>/g, '').trim()) {
411
+ return;
412
+ }
413
+ // Use transcript_line_uuid for stable event_id
414
+ this.emitEvent({
415
+ kind: 'user_query',
416
+ event_id: this.generateEventId('user_query', this.state.currentTurnId, line.uuid, 0, this.hashContent(query)),
417
+ turn_id: this.state.currentTurnId,
418
+ query,
419
+ ts,
420
+ transcript_line_uuid: line.uuid,
421
+ });
422
+ this.emit('user_turn', { turnId: this.state.currentTurnId, query });
423
+ }
424
+ handleAssistantEvent(line, ts) {
425
+ // Mark turn as in_progress
426
+ this.state.currentTurnStatus = 'in_progress';
427
+ const content = line.message.content;
428
+ if (!Array.isArray(content))
429
+ return;
430
+ let textDelta = '';
431
+ let deltaOffset = 0;
432
+ let blockIndex = 0; // Track block index within this transcript line for stable event_id
433
+ for (const block of content) {
434
+ switch (block.type) {
435
+ case 'text':
436
+ if (block.text) {
437
+ textDelta += block.text;
438
+ // Update state machine
439
+ this.updateState('assistant_streaming');
440
+ this.updateInProgressText(block.text);
441
+ this.eventsSinceCheckpoint++;
442
+ // Generate stable event_id using transcript_line_uuid + block_index + content_hash
443
+ const contentHash = this.hashContent(block.text);
444
+ this.emitEvent({
445
+ kind: 'assistant_text_delta',
446
+ event_id: this.generateEventId('assistant_text_delta', this.state.currentTurnId, line.uuid, blockIndex, contentHash),
447
+ turn_id: this.state.currentTurnId,
448
+ delta: block.text,
449
+ offset: deltaOffset,
450
+ ts,
451
+ transcript_line_uuid: line.uuid,
452
+ });
453
+ deltaOffset += block.text.length;
454
+ }
455
+ break;
456
+ case 'tool_use':
457
+ if (block.id && block.name) {
458
+ // Update state machine - add open loop
459
+ this.updateState('tool_running');
460
+ this.addOpenLoop('tool', block.id, block.name);
461
+ this.eventsSinceCheckpoint++;
462
+ // tool_use.id is stable, use it for idempotency
463
+ this.emitEvent({
464
+ kind: 'tool_use',
465
+ event_id: this.generateEventId('tool_use', this.state.currentTurnId, line.uuid, blockIndex, block.id // tool_use.id is already stable
466
+ ),
467
+ turn_id: this.state.currentTurnId,
468
+ id: block.id,
469
+ name: block.name,
470
+ input: block.input,
471
+ ts,
472
+ transcript_line_uuid: line.uuid,
473
+ });
474
+ }
475
+ break;
476
+ case 'tool_result':
477
+ if (block.tool_use_id) {
478
+ // Update state machine - close open loop
479
+ this.closeOpenLoop(block.tool_use_id);
480
+ if (this.stateMachine.open_loops.length === 0) {
481
+ this.updateState('assistant_streaming');
482
+ }
483
+ this.eventsSinceCheckpoint++;
484
+ // tool_use_id is stable, use it for idempotency
485
+ this.emitEvent({
486
+ kind: 'tool_result',
487
+ event_id: this.generateEventId('tool_result', this.state.currentTurnId, line.uuid, blockIndex, block.tool_use_id // tool_use_id is already stable
488
+ ),
489
+ turn_id: this.state.currentTurnId,
490
+ id: block.tool_use_id,
491
+ output: block.content,
492
+ is_error: block.is_error,
493
+ ts,
494
+ transcript_line_uuid: line.uuid,
495
+ });
496
+ }
497
+ break;
498
+ case 'thinking':
499
+ // Ignore thinking blocks (encrypted, not needed for restore)
500
+ break;
501
+ }
502
+ blockIndex++; // Increment for each block processed
503
+ }
504
+ // Update metrics
505
+ this.stateMachine.stream_bytes_captured += textDelta.length;
506
+ this.stateMachine.events_captured++;
507
+ if (textDelta) {
508
+ this.emit('assistant_delta', {
509
+ turnId: this.state.currentTurnId,
510
+ delta: textDelta,
511
+ });
512
+ }
513
+ }
514
+ emitEvent(event) {
515
+ // Idempotency check - skip if we've already seen this event_id
516
+ if (this.seenEventIds.has(event.event_id)) {
517
+ return; // De-dupe: event already processed
518
+ }
519
+ this.seenEventIds.add(event.event_id);
520
+ // Prune old event_ids to prevent unbounded memory growth
521
+ // Keep last 10000 event_ids (more than enough for a session)
522
+ if (this.seenEventIds.size > 10000) {
523
+ const idsArray = Array.from(this.seenEventIds);
524
+ this.seenEventIds = new Set(idsArray.slice(-5000));
525
+ }
526
+ // Append to local event log (sync for reliability)
527
+ try {
528
+ fs.appendFileSync(this.eventLogPath, JSON.stringify(event) + '\n', 'utf-8');
529
+ }
530
+ catch (err) {
531
+ this.emit('persist_error', err);
532
+ }
533
+ // Callback if provided
534
+ if (this.onEventCallback) {
535
+ this.onEventCallback(event);
536
+ }
537
+ // Emit on EventEmitter
538
+ this.emit('event', event);
539
+ }
540
+ }
541
+ exports.StreamTailer = StreamTailer;
542
+ /**
543
+ * Utility: Reconstruct StreamTurn from event log
544
+ */
545
+ async function reconstructTurnFromEvents(eventLogPath, turnId) {
546
+ const turns = new Map();
547
+ let latestTurnId = 0;
548
+ try {
549
+ const content = await fs.promises.readFile(eventLogPath, 'utf-8');
550
+ const lines = content.split('\n').filter(l => l.trim());
551
+ for (const line of lines) {
552
+ try {
553
+ const event = JSON.parse(line);
554
+ if (event.kind === 'user_query') {
555
+ if (!turns.has(event.turn_id)) {
556
+ turns.set(event.turn_id, {
557
+ user_query: '',
558
+ assistant_stream: '',
559
+ tools: [],
560
+ status: 'in_progress',
561
+ });
562
+ }
563
+ turns.get(event.turn_id).user_query = event.query;
564
+ latestTurnId = Math.max(latestTurnId, event.turn_id);
565
+ }
566
+ if (event.kind === 'assistant_text_delta') {
567
+ if (!turns.has(event.turn_id)) {
568
+ turns.set(event.turn_id, {
569
+ user_query: '',
570
+ assistant_stream: '',
571
+ tools: [],
572
+ status: 'in_progress',
573
+ });
574
+ }
575
+ turns.get(event.turn_id).assistant_stream += event.delta;
576
+ latestTurnId = Math.max(latestTurnId, event.turn_id);
577
+ }
578
+ if (event.kind === 'tool_use') {
579
+ if (turns.has(event.turn_id)) {
580
+ turns.get(event.turn_id).tools.push({
581
+ kind: 'tool_use',
582
+ id: event.id,
583
+ name: event.name,
584
+ ts: event.ts,
585
+ });
586
+ }
587
+ }
588
+ if (event.kind === 'tool_result') {
589
+ if (turns.has(event.turn_id)) {
590
+ turns.get(event.turn_id).tools.push({
591
+ kind: 'tool_result',
592
+ id: event.id,
593
+ ts: event.ts,
594
+ });
595
+ }
596
+ }
597
+ if (event.kind === 'seal_turn') {
598
+ if (turns.has(event.turn_id)) {
599
+ turns.get(event.turn_id).status = 'complete';
600
+ }
601
+ }
602
+ }
603
+ catch {
604
+ // Skip malformed lines
605
+ }
606
+ }
607
+ }
608
+ catch {
609
+ // File doesn't exist or read error
610
+ }
611
+ // Filter to specific turn if requested
612
+ if (turnId !== undefined) {
613
+ for (const [id] of turns) {
614
+ if (id !== turnId)
615
+ turns.delete(id);
616
+ }
617
+ }
618
+ return { turns, latestTurnId };
619
+ }
620
+ /**
621
+ * Utility: Middle-truncate text (keep head + tail)
622
+ */
623
+ function middleTruncate(text, opts = {}) {
624
+ const { headChars = 2000, tailChars = 8000, maxTotal = 12000 } = opts;
625
+ if (text.length <= maxTotal)
626
+ return text;
627
+ const head = text.slice(0, headChars);
628
+ const tail = text.slice(-tailChars);
629
+ const truncatedCount = text.length - headChars - tailChars;
630
+ return `${head}\n\n[...${truncatedCount} chars truncated...]\n\n${tail}`;
631
+ }
632
+ /**
633
+ * Utility: Detect open loops (incomplete tools)
634
+ */
635
+ function detectOpenLoops(tools) {
636
+ const openLoops = [];
637
+ const toolUseIds = new Set();
638
+ const toolResultIds = new Set();
639
+ for (const tool of tools) {
640
+ if (tool.kind === 'tool_use') {
641
+ toolUseIds.add(tool.id);
642
+ }
643
+ if (tool.kind === 'tool_result') {
644
+ toolResultIds.add(tool.id);
645
+ }
646
+ }
647
+ // Find tool_use without corresponding tool_result
648
+ for (const id of toolUseIds) {
649
+ if (!toolResultIds.has(id)) {
650
+ const tool = tools.find(t => t.kind === 'tool_use' && t.id === id);
651
+ openLoops.push({
652
+ type: 'incomplete_tool',
653
+ detail: `Tool ${tool?.name || 'unknown'} (${id}) started but did not complete`,
654
+ });
655
+ }
656
+ }
657
+ return openLoops;
658
+ }