@ekkos/cli 0.2.6 → 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.
- package/dist/cache/types.d.ts +11 -2
- package/dist/capture/index.d.ts +8 -0
- package/dist/capture/index.js +24 -0
- package/dist/capture/stream-tailer.d.ts +138 -0
- package/dist/capture/stream-tailer.js +658 -0
- package/dist/capture/types.d.ts +227 -0
- package/dist/capture/types.js +5 -0
- package/dist/commands/run.js +147 -10
- package/dist/commands/stream.d.ts +19 -0
- package/dist/commands/stream.js +340 -0
- package/dist/index.js +58 -1
- package/dist/restore/RestoreOrchestrator.d.ts +6 -0
- package/dist/restore/RestoreOrchestrator.js +174 -22
- package/dist/utils/state.d.ts +39 -1
- package/dist/utils/state.js +152 -2
- package/package.json +1 -1
|
@@ -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
|
+
}
|