teleportation-cli 1.1.5 → 1.2.1

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.
Files changed (42) hide show
  1. package/.claude/hooks/permission_request.mjs +326 -59
  2. package/.claude/hooks/post_tool_use.mjs +90 -0
  3. package/.claude/hooks/pre_tool_use.mjs +212 -293
  4. package/.claude/hooks/session-register.mjs +89 -104
  5. package/.claude/hooks/session_end.mjs +41 -42
  6. package/.claude/hooks/session_start.mjs +45 -60
  7. package/.claude/hooks/stop.mjs +752 -99
  8. package/.claude/hooks/user_prompt_submit.mjs +26 -3
  9. package/lib/cli/daemon-commands.js +1 -1
  10. package/lib/cli/teleport-commands.js +469 -0
  11. package/lib/daemon/daemon-v2.js +104 -0
  12. package/lib/daemon/lifecycle.js +56 -171
  13. package/lib/daemon/services/index.js +3 -0
  14. package/lib/daemon/services/polling-service.js +173 -0
  15. package/lib/daemon/services/queue-service.js +318 -0
  16. package/lib/daemon/services/session-service.js +115 -0
  17. package/lib/daemon/state.js +35 -0
  18. package/lib/daemon/task-executor-v2.js +413 -0
  19. package/lib/daemon/task-executor.js +270 -96
  20. package/lib/daemon/teleportation-daemon.js +709 -126
  21. package/lib/daemon/timeline-analyzer.js +215 -0
  22. package/lib/daemon/transcript-ingestion.js +696 -0
  23. package/lib/daemon/utils.js +91 -0
  24. package/lib/install/installer.js +184 -20
  25. package/lib/install/uhr-installer.js +136 -0
  26. package/lib/remote/providers/base-provider.js +46 -0
  27. package/lib/remote/providers/daytona-provider.js +58 -0
  28. package/lib/remote/providers/provider-factory.js +90 -19
  29. package/lib/remote/providers/sprites-provider.js +711 -0
  30. package/lib/teleport/exporters/claude-exporter.js +302 -0
  31. package/lib/teleport/exporters/gemini-exporter.js +307 -0
  32. package/lib/teleport/exporters/index.js +93 -0
  33. package/lib/teleport/exporters/interface.js +153 -0
  34. package/lib/teleport/fork-tracker.js +415 -0
  35. package/lib/teleport/git-committer.js +337 -0
  36. package/lib/teleport/index.js +48 -0
  37. package/lib/teleport/manager.js +620 -0
  38. package/lib/teleport/session-capture.js +282 -0
  39. package/package.json +6 -2
  40. package/teleportation-cli.cjs +488 -453
  41. package/.claude/hooks/heartbeat.mjs +0 -396
  42. package/lib/daemon/pid-manager.js +0 -183
@@ -0,0 +1,696 @@
1
+ /**
2
+ * Transcript Ingestion
3
+ *
4
+ * Reads Claude Code transcript and pushes events to timeline.
5
+ * Ensures timeline completeness even if hooks fail to log events.
6
+ *
7
+ * @module lib/daemon/transcript-ingestion
8
+ */
9
+
10
+ import { readFile, readdir } from 'fs/promises';
11
+ import { homedir, tmpdir } from 'os';
12
+ import { join } from 'path';
13
+
14
+ // ============================================================================
15
+ // Configuration Constants
16
+ // ============================================================================
17
+
18
+ /**
19
+ * Maximum events to process in real-time mode (post_tool_use hook)
20
+ *
21
+ * Rationale for 10 events:
22
+ * - Fast execution: Hook runs after each tool, must not block Claude
23
+ * - Low latency: ~100ms total (10 events * 10ms/event)
24
+ * - Recent context: Most relevant events are the last few
25
+ * - Hook timeout: Well under Claude's hook timeout limits
26
+ *
27
+ * Tuning:
28
+ * - Increase to 20 if seeing missing events in timeline
29
+ * - Decrease to 5 if hooks are timing out
30
+ */
31
+ const DEFAULT_MAX_EVENTS_REALTIME = 10;
32
+
33
+ /**
34
+ * Maximum events to process in daemon mode (polling loop)
35
+ *
36
+ * Rationale for 100 events:
37
+ * - Thorough: Catches most backlog from inactive periods
38
+ * - Non-blocking: Daemon polls every 5 seconds, 100 events = ~1-2 seconds
39
+ * - Memory: 100 events * ~5KB = ~500KB (acceptable footprint)
40
+ * - Balance: Processes recent activity without freezing on huge backlogs
41
+ *
42
+ * Tuning:
43
+ * - Increase to 200 if daemon falls behind on active sessions
44
+ * - Decrease to 50 if daemon is spending too long on ingestion
45
+ */
46
+ const DEFAULT_MAX_EVENTS_DAEMON = 100;
47
+
48
+ /**
49
+ * Batch chunk size for timeline event ingestion
50
+ *
51
+ * Rationale for 50 events/batch:
52
+ * - Network efficiency: 100 events = 2 batches (98% reduction from 100 individual POSTs)
53
+ * - Resilience: Batch failure only affects 50 events (not entire backlog)
54
+ * - API limits: ~250KB per batch (50 events * 5KB) - well under typical limits
55
+ * - Timeout risk: ~500ms per batch (50 * 10ms) - low timeout probability
56
+ * - Memory: Small footprint, safe for daemon/hook contexts
57
+ * - Fallback: Individual POST fallback ensures no data loss on batch failure
58
+ *
59
+ * Performance comparison (100 events):
60
+ * - Individual POSTs: 100 requests * 50ms = 5,000ms (5 seconds)
61
+ * - Batched (size=50): 2 requests * 500ms = 1,000ms (1 second) ← 80% faster
62
+ * - Batched (size=100): 1 request * 1,000ms = 1,000ms (same speed, but higher risk)
63
+ *
64
+ * Tuning considerations:
65
+ * - Increase to 100: Better network efficiency, but batch timeout risk increases
66
+ * - Decrease to 20: More resilient, but more HTTP overhead (100 events = 5 batches)
67
+ * - Monitor: Check relay API `/api/timeline/batch` endpoint for failures/timeouts
68
+ *
69
+ * @see pushEventsInChunks for implementation details
70
+ */
71
+ const BATCH_CHUNK_SIZE = 50;
72
+
73
+ /**
74
+ * Maximum length for assistant response text in timeline events
75
+ *
76
+ * Rationale for 2000 characters:
77
+ * - Matches stop hook truncation limit (consistency)
78
+ * - Provides enough context for timeline viewing
79
+ * - Keeps event payload sizes reasonable (<10KB)
80
+ * - Prevents oversized events from breaking timeline queries
81
+ *
82
+ * @see extractTimelineEvents
83
+ */
84
+ const MAX_ASSISTANT_RESPONSE_LENGTH = 2000;
85
+
86
+ /**
87
+ * Timestamp validation range: ±1 year from current time
88
+ *
89
+ * Rationale for 1 year:
90
+ * - Prevents clock skew issues (system time drift, NTP sync)
91
+ * - Catches obviously invalid dates (epoch=0, far future timestamps)
92
+ * - Allows timezone differences and daylight saving time adjustments
93
+ * - Lenient enough for legitimate use cases (resuming old sessions)
94
+ * - Stricter than necessary (could be ±1 week) but safe for edge cases
95
+ *
96
+ * @see parseTimestamp
97
+ */
98
+ const TIMESTAMP_MAX_AGE_MS = 365 * 24 * 60 * 60 * 1000;
99
+
100
+ /**
101
+ * Timeline query limit for deduplication
102
+ *
103
+ * Rationale for 50 events:
104
+ * - Catches recent duplicates: Hook and daemon running concurrently
105
+ * - Small enough: Minimal API overhead (~5KB response)
106
+ * - Large enough: Covers typical concurrent ingestion window
107
+ * - Prevents race conditions: If hook posts events 1-10, daemon queries last 50 to detect overlap
108
+ *
109
+ * @see ingestTranscriptToTimeline timeline query logic
110
+ */
111
+ const TIMELINE_QUERY_LIMIT = 50;
112
+
113
+ /**
114
+ * Network error retry configuration
115
+ *
116
+ * MAX_RETRIES: Maximum retry attempts for individual event POSTs
117
+ * - Handles transient network errors (timeout, connection reset, DNS failures)
118
+ * - Non-network errors (4xx, 5xx) are not retried (permanent failures)
119
+ *
120
+ * RETRY_BASE_DELAY_MS: Base delay for exponential backoff
121
+ * - Retry 1: 100ms
122
+ * - Retry 2: 200ms
123
+ * - Retry 3: 400ms
124
+ * - Total max wait: 700ms for 3 retries
125
+ */
126
+ const MAX_RETRIES = 3;
127
+ const RETRY_BASE_DELAY_MS = 100;
128
+
129
+ /**
130
+ * Debug mode flag
131
+ * Set TELEPORTATION_DEBUG=true to enable verbose logging to tmpdir()
132
+ */
133
+ const DEBUG = process.env.TELEPORTATION_DEBUG === 'true';
134
+
135
+ /**
136
+ * Sleep helper for retry backoff
137
+ * @param {number} ms - Milliseconds to sleep
138
+ * @returns {Promise<void>}
139
+ */
140
+ function sleep(ms) {
141
+ return new Promise(resolve => setTimeout(resolve, ms));
142
+ }
143
+
144
+ /**
145
+ * Derive Claude Code project slug from working directory
146
+ * Matches Claude Code's slug generation logic
147
+ * @param {string} cwd - Working directory
148
+ * @returns {string} Project slug
149
+ */
150
+ function getProjectSlug(cwd) {
151
+ // Claude Code converts absolute path to slug by replacing separators with dashes
152
+ const absPath = cwd || process.cwd();
153
+ return '-' + absPath.replace(/[\\/]/g, '-');
154
+ }
155
+
156
+ /**
157
+ * Search all project directories for transcript (fallback)
158
+ * @param {string} claude_session_id - Claude Code session ID
159
+ * @returns {Promise<Array>} Transcript messages or empty array
160
+ */
161
+ async function searchForTranscript(claude_session_id) {
162
+ const projectsDir = join(homedir(), '.claude', 'projects');
163
+
164
+ try {
165
+ const projects = await readdir(projectsDir);
166
+
167
+ for (const project of projects) {
168
+ const transcriptPath = join(projectsDir, project, `${claude_session_id}.jsonl`);
169
+ try {
170
+ const content = await readFile(transcriptPath, 'utf8');
171
+ console.log(`[transcript] Found transcript in project: ${project}`);
172
+ const lines = content.trim().split('\n').filter(Boolean);
173
+ return lines.map(line => JSON.parse(line));
174
+ } catch (e) {
175
+ // Not in this project, continue searching
176
+ }
177
+ }
178
+ } catch (error) {
179
+ console.error(`[transcript] Failed to search projects: ${error.message}`);
180
+ }
181
+
182
+ return [];
183
+ }
184
+
185
+ /**
186
+ * Read transcript file for a Claude session
187
+ * @param {string} claude_session_id - Claude Code session ID
188
+ * @param {string} cwd - Working directory (to derive project slug)
189
+ * @returns {Promise<Array>} Transcript messages
190
+ */
191
+ async function readTranscript(claude_session_id, cwd) {
192
+ // Try reading from derived project slug first
193
+ const projectSlug = getProjectSlug(cwd);
194
+ const transcriptPath = join(homedir(), '.claude', 'projects', projectSlug, `${claude_session_id}.jsonl`);
195
+
196
+ try {
197
+ const content = await readFile(transcriptPath, 'utf8');
198
+ const lines = content.trim().split('\n').filter(Boolean);
199
+ return lines.map(line => JSON.parse(line));
200
+ } catch (error) {
201
+ // If not found at derived path, search all project directories
202
+ console.log(`[transcript] Not found at ${transcriptPath}, searching all projects...`);
203
+ return await searchForTranscript(claude_session_id);
204
+ }
205
+ }
206
+
207
+ /**
208
+ * Push events to timeline using chunked batching with individual fallback
209
+ * @param {Array} events - Timeline events to push
210
+ * @param {string} relayApiUrl - Relay API URL
211
+ * @param {string} apiKey - API key
212
+ * @param {string} parent_session_id - Parent session ID
213
+ * @param {string} task_id - Task ID (optional)
214
+ * @param {string} claude_session_id - Claude session ID
215
+ * @param {Function} log - Logging function
216
+ * @returns {Promise<{successCount: number, failCount: number}>}
217
+ */
218
+ async function pushEventsInChunks(events, relayApiUrl, apiKey, parent_session_id, task_id, claude_session_id, log) {
219
+ let successCount = 0;
220
+ let failCount = 0;
221
+
222
+ // Split events into chunks
223
+ for (let i = 0; i < events.length; i += BATCH_CHUNK_SIZE) {
224
+ const chunk = events.slice(i, i + BATCH_CHUNK_SIZE);
225
+
226
+ // Try batch POST first
227
+ try {
228
+ const response = await fetch(`${relayApiUrl}/api/timeline/batch`, {
229
+ method: 'POST',
230
+ headers: {
231
+ 'Content-Type': 'application/json',
232
+ 'Authorization': `Bearer ${apiKey}`
233
+ },
234
+ body: JSON.stringify({
235
+ session_id: parent_session_id,
236
+ events: chunk.map(event => ({
237
+ type: event.type,
238
+ source: event.source,
239
+ data: {
240
+ ...event.meta,
241
+ task_id,
242
+ claude_session_id,
243
+ timestamp: event.timestamp,
244
+ }
245
+ }))
246
+ })
247
+ });
248
+
249
+ if (response.ok) {
250
+ successCount += chunk.length;
251
+ log(`✓ Batch ${Math.floor(i / BATCH_CHUNK_SIZE) + 1}: ${chunk.length} events pushed`);
252
+ continue; // Success, move to next chunk
253
+ } else {
254
+ log(`✗ Batch ${Math.floor(i / BATCH_CHUNK_SIZE) + 1} failed (${response.status}), falling back to individual posts`);
255
+ }
256
+ } catch (error) {
257
+ log(`✗ Batch ${Math.floor(i / BATCH_CHUNK_SIZE) + 1} error: ${error.message}, falling back to individual posts`);
258
+ }
259
+
260
+ // Fallback: Post individually if batch failed
261
+ for (const event of chunk) {
262
+ let lastError = null;
263
+ let success = false;
264
+
265
+ // Retry loop with exponential backoff for network errors
266
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
267
+ try {
268
+ const response = await fetch(`${relayApiUrl}/api/timeline`, {
269
+ method: 'POST',
270
+ headers: {
271
+ 'Content-Type': 'application/json',
272
+ 'Authorization': `Bearer ${apiKey}`
273
+ },
274
+ body: JSON.stringify({
275
+ session_id: parent_session_id,
276
+ type: event.type,
277
+ source: event.source,
278
+ data: {
279
+ ...event.meta,
280
+ task_id,
281
+ claude_session_id,
282
+ timestamp: event.timestamp,
283
+ }
284
+ })
285
+ });
286
+
287
+ if (response.ok) {
288
+ successCount++;
289
+ log(` ✓ ${event.type}${attempt > 0 ? ` (retry ${attempt})` : ''}`);
290
+ success = true;
291
+ break; // Success, exit retry loop
292
+ } else {
293
+ // HTTP errors (4xx, 5xx) are permanent failures - don't retry
294
+ failCount++;
295
+ const errorText = await response.text();
296
+ log(` ✗ ${event.type}: ${response.status} - ${errorText}`);
297
+ success = false;
298
+ break; // Don't retry HTTP errors
299
+ }
300
+ } catch (error) {
301
+ // Network errors (timeout, DNS, connection reset) - retry with backoff
302
+ lastError = error;
303
+ if (attempt < MAX_RETRIES) {
304
+ const delay = RETRY_BASE_DELAY_MS * Math.pow(2, attempt);
305
+ log(` ⟳ ${event.type}: ${error.message} (retry ${attempt + 1}/${MAX_RETRIES} after ${delay}ms)`);
306
+ await sleep(delay);
307
+ } else {
308
+ // Max retries exceeded
309
+ failCount++;
310
+ log(` ✗ ${event.type}: ${error.message} (max retries exceeded)`);
311
+ }
312
+ }
313
+ }
314
+ }
315
+ }
316
+
317
+ return { successCount, failCount };
318
+ }
319
+
320
+ /**
321
+ * Validate and parse timestamp from transcript entry
322
+ * @param {Object} entry - Transcript entry
323
+ * @returns {number} Timestamp in milliseconds
324
+ */
325
+ function parseTimestamp(entry) {
326
+ if (!entry.timestamp) {
327
+ console.warn('[transcript] Missing timestamp in entry, using current time');
328
+ return Date.now();
329
+ }
330
+
331
+ const parsed = new Date(entry.timestamp).getTime();
332
+
333
+ // Validate timestamp is reasonable (not NaN, not in distant past/future)
334
+ if (isNaN(parsed)) {
335
+ console.warn(`[transcript] Invalid timestamp: ${entry.timestamp}, using current time`);
336
+ return Date.now();
337
+ }
338
+
339
+ const now = Date.now();
340
+ const oneYearAgo = now - TIMESTAMP_MAX_AGE_MS;
341
+ const oneYearFromNow = now + TIMESTAMP_MAX_AGE_MS;
342
+
343
+ if (parsed < oneYearAgo || parsed > oneYearFromNow) {
344
+ console.warn(`[transcript] Timestamp out of reasonable range: ${entry.timestamp}, using current time`);
345
+ return Date.now();
346
+ }
347
+
348
+ return parsed;
349
+ }
350
+
351
+ /**
352
+ * Extract events from transcript that should be in timeline
353
+ * Supports both old format (message at root) and new format (message nested)
354
+ * @param {Array} transcript - Transcript messages
355
+ * @param {number} fromIndex - Start extracting from this index (to avoid duplicates)
356
+ * @returns {Array} Timeline events to push
357
+ */
358
+ function extractTimelineEvents(transcript, fromIndex = 0) {
359
+ const events = [];
360
+
361
+ console.log(`[transcript] extractTimelineEvents: processing ${transcript.length - fromIndex} messages from index ${fromIndex}`);
362
+
363
+ // Build a lookup map from tool_use_id → { name, input } so that
364
+ // tool_completed/tool_failed events can include the tool name and input
365
+ // (transcript tool_result blocks only carry tool_use_id, not the tool name)
366
+ const toolUseLookup = new Map();
367
+ for (let i = fromIndex; i < transcript.length; i++) {
368
+ const entry = transcript[i];
369
+ const message = entry.message || entry;
370
+ if (message.role === 'assistant' && Array.isArray(message.content)) {
371
+ for (const block of message.content) {
372
+ if (block.type === 'tool_use' && block.id) {
373
+ toolUseLookup.set(block.id, { name: block.name, input: block.input });
374
+ }
375
+ }
376
+ }
377
+ }
378
+
379
+ for (let i = fromIndex; i < transcript.length; i++) {
380
+ const entry = transcript[i];
381
+
382
+ // Handle both old format (message at root) and new format (message nested)
383
+ const message = entry.message || entry;
384
+ const role = message.role;
385
+ const content = message.content;
386
+ const timestamp = parseTimestamp(entry);
387
+
388
+ // Extract assistant responses
389
+ // Schema matches stop hook batch: data.message (canonical field name)
390
+ if (role === 'assistant' && content) {
391
+ const textContent = Array.isArray(content)
392
+ ? content.find(c => c.type === 'text')?.text
393
+ : content;
394
+
395
+ const trimmedContent = textContent ? textContent.trim() : '';
396
+ if (trimmedContent) {
397
+ events.push({
398
+ type: 'assistant_response',
399
+ source: entry.type === 'autonomous_task' ? 'autonomous_task' : 'cli_interactive',
400
+ timestamp,
401
+ meta: {
402
+ message: trimmedContent.slice(0, MAX_ASSISTANT_RESPONSE_LENGTH),
403
+ full_length: trimmedContent.length,
404
+ truncated: trimmedContent.length > MAX_ASSISTANT_RESPONSE_LENGTH,
405
+ stop_reason: message.stop_reason || null,
406
+ model: message.model || null,
407
+ }
408
+ });
409
+ }
410
+ }
411
+
412
+ // Extract tool uses
413
+ if (role === 'assistant' && content) {
414
+ const toolUses = Array.isArray(content)
415
+ ? content.filter(c => c.type === 'tool_use')
416
+ : [];
417
+
418
+ for (const toolUse of toolUses) {
419
+ events.push({
420
+ type: 'tool_use',
421
+ source: entry.type === 'autonomous_task' ? 'autonomous_task' : 'cli_interactive',
422
+ timestamp,
423
+ meta: {
424
+ tool_name: toolUse.name,
425
+ tool_use_id: toolUse.id,
426
+ tool_input: toolUse.input,
427
+ }
428
+ });
429
+ }
430
+ }
431
+
432
+ // Extract tool results as tool_completed/tool_failed events
433
+ // Schema matches post_tool_use hook canonical format:
434
+ // tool_name, tool_input, exit_code, stdout, stderr, tool_use_id, is_error
435
+ // Note: Transcripts don't carry exit_code or stderr, so we infer exit_code
436
+ // from is_error (0 for success, 1 for failure) and put content into stdout.
437
+ if (role === 'user' && content) {
438
+ const toolResults = Array.isArray(content)
439
+ ? content.filter(c => c.type === 'tool_result')
440
+ : [];
441
+
442
+ for (const toolResult of toolResults) {
443
+ const isError = toolResult.is_error || false;
444
+ const toolInfo = toolUseLookup.get(toolResult.tool_use_id);
445
+ const resultContent = typeof toolResult.content === 'string'
446
+ ? toolResult.content.slice(0, 1000)
447
+ : JSON.stringify(toolResult.content).slice(0, 1000);
448
+
449
+ events.push({
450
+ type: isError ? 'tool_failed' : 'tool_completed',
451
+ source: entry.type === 'autonomous_task' ? 'autonomous_task' : 'cli_interactive',
452
+ timestamp,
453
+ meta: {
454
+ tool_use_id: toolResult.tool_use_id,
455
+ tool_name: toolInfo?.name || null,
456
+ tool_input: toolInfo?.input || null,
457
+ exit_code: isError ? 1 : 0,
458
+ stdout: isError ? null : resultContent,
459
+ stderr: isError ? resultContent : null,
460
+ is_error: isError,
461
+ }
462
+ });
463
+ }
464
+ }
465
+ }
466
+
467
+ console.log(`[transcript] extractTimelineEvents: found ${events.length} events`);
468
+ return events;
469
+ }
470
+
471
+ /**
472
+ * Fetch task metadata including last ingested transcript index
473
+ * @param {string} task_id - Task ID
474
+ * @param {string} session_id - Session ID
475
+ * @param {Object} config - Config with relayApiUrl, apiKey
476
+ * @returns {Promise<Object|null>} Task object or null
477
+ */
478
+ async function fetchTask(task_id, session_id, config) {
479
+ const { relayApiUrl, apiKey } = config;
480
+
481
+ try {
482
+ const response = await fetch(
483
+ `${relayApiUrl}/api/sessions/${session_id}/tasks/${task_id}/status`,
484
+ {
485
+ headers: { 'Authorization': `Bearer ${apiKey}` }
486
+ }
487
+ );
488
+
489
+ if (!response.ok) {
490
+ return null;
491
+ }
492
+
493
+ return await response.json();
494
+ } catch (error) {
495
+ console.error(`[transcript] Failed to fetch task: ${error.message}`);
496
+ return null;
497
+ }
498
+ }
499
+
500
+ /**
501
+ * Update task's last ingested transcript index
502
+ * @param {string} task_id - Task ID
503
+ * @param {string} session_id - Session ID
504
+ * @param {number} lastIndex - Last ingested index
505
+ * @param {Object} config - Config with relayApiUrl, apiKey
506
+ */
507
+ async function updateLastIngestedIndex(task_id, session_id, lastIndex, config) {
508
+ const { relayApiUrl, apiKey } = config;
509
+
510
+ try {
511
+ await fetch(`${relayApiUrl}/api/sessions/${session_id}/tasks/${task_id}`, {
512
+ method: 'PATCH',
513
+ headers: {
514
+ 'Content-Type': 'application/json',
515
+ 'Authorization': `Bearer ${apiKey}`
516
+ },
517
+ body: JSON.stringify({ last_transcript_index: lastIndex })
518
+ });
519
+ } catch (error) {
520
+ console.error(`[transcript] Failed to update last index: ${error.message}`);
521
+ }
522
+ }
523
+
524
+ /**
525
+ * Push transcript events to timeline via relay API
526
+ * Called by daemon after each turn execution
527
+ *
528
+ * @param {Object} options
529
+ * @param {string} options.claude_session_id - Claude session ID
530
+ * @param {string} options.parent_session_id - Teleportation session ID (for timeline)
531
+ * @param {string} options.task_id - Task ID (for event metadata)
532
+ * @param {string} options.cwd - Working directory (to derive project slug)
533
+ * @param {Object} options.config - Config with relayApiUrl, apiKey
534
+ * @param {boolean} options.realTimeMode - If true, only process last 10 events (fast, for hooks)
535
+ * @param {number} options.maxEvents - Maximum events to process (default: 100 for daemon, 10 for realTime)
536
+ * @returns {Promise<Object>} Result { events_pushed: number }
537
+ */
538
+ export async function ingestTranscriptToTimeline(options) {
539
+ const { claude_session_id, parent_session_id, task_id, cwd, config, realTimeMode = false, maxEvents } = options;
540
+ const { relayApiUrl, apiKey } = config;
541
+
542
+ // Determine max events based on mode
543
+ const effectiveMaxEvents = maxEvents || (realTimeMode ? DEFAULT_MAX_EVENTS_REALTIME : DEFAULT_MAX_EVENTS_DAEMON);
544
+
545
+ // File logging for debugging (cross-platform)
546
+ const { appendFileSync } = await import('node:fs');
547
+ const logFile = join(tmpdir(), 'daemon-transcript-ingestion.log');
548
+ const log = (msg) => {
549
+ if (!DEBUG) return; // Only log when DEBUG mode is enabled
550
+ try {
551
+ appendFileSync(logFile, `[${new Date().toISOString()}] ${msg}\n`);
552
+ } catch (e) {
553
+ console.error(`[transcript] Failed to write to log: ${e.message}`);
554
+ }
555
+ };
556
+
557
+ log(`===== INGESTION START (${realTimeMode ? 'REAL-TIME' : 'DAEMON'}, max=${effectiveMaxEvents}) =====`);
558
+ log(`claude_session: ${claude_session_id}, parent: ${parent_session_id}, task: ${task_id}`);
559
+
560
+ console.log(`[transcript] ===== INGESTION START =====`);
561
+ console.log(`[transcript] claude_session: ${claude_session_id}, parent: ${parent_session_id}, task: ${task_id}`);
562
+
563
+ // 1. Read transcript with cwd for project slug derivation
564
+ const transcript = await readTranscript(claude_session_id, cwd);
565
+ log(`Read transcript: ${transcript.length} messages`);
566
+ if (transcript.length === 0) {
567
+ log(`No transcript found or empty - returning`);
568
+ console.log(`[transcript] No transcript found or empty`);
569
+ return { events_pushed: 0 };
570
+ }
571
+
572
+ // 2. Determine starting index for event extraction
573
+ let fromIndex = 0;
574
+
575
+ if (task_id) {
576
+ // For tasks: Use task's last ingested index
577
+ const task = await fetchTask(task_id, parent_session_id, config);
578
+ fromIndex = task?.last_transcript_index || 0;
579
+ console.log(`[transcript] Using task's last ingested index: ${fromIndex}`);
580
+ } else {
581
+ // For regular sessions: Query timeline to find recent events for deduplication
582
+ // Queries last 50 events (not just 1) to catch concurrent hook/daemon ingestion
583
+ // This prevents duplicates when hook and daemon run simultaneously
584
+ console.log(`[transcript] Querying timeline for session ${parent_session_id} to find recent events...`);
585
+ try {
586
+ const timelineResponse = await fetch(
587
+ `${relayApiUrl}/api/sessions/${parent_session_id}/timeline?limit=${TIMELINE_QUERY_LIMIT}`,
588
+ {
589
+ headers: { 'Authorization': `Bearer ${apiKey}` }
590
+ }
591
+ );
592
+
593
+ console.log(`[transcript] Timeline query status: ${timelineResponse.status}`);
594
+
595
+ if (timelineResponse.ok) {
596
+ const response = await timelineResponse.json();
597
+ // PRD-0022: Timeline API returns { events: [...], pagination: {...} } format
598
+ const timelineEvents = Array.isArray(response) ? response : (response.events || []);
599
+ console.log(`[transcript] Timeline returned ${timelineEvents.length} events`);
600
+
601
+ if (timelineEvents.length > 0 && timelineEvents[0].timestamp) {
602
+ const lastTimestamp = timelineEvents[0].timestamp;
603
+ console.log(`[transcript] Last timeline event timestamp: ${lastTimestamp} (${new Date(lastTimestamp).toISOString()})`);
604
+
605
+ // Sample first and last transcript timestamps
606
+ if (transcript.length > 0) {
607
+ const firstMsg = transcript[0];
608
+ const lastMsg = transcript[transcript.length - 1];
609
+ console.log(`[transcript] First transcript msg: ${firstMsg.timestamp}`);
610
+ console.log(`[transcript] Last transcript msg: ${lastMsg.timestamp}`);
611
+ }
612
+
613
+ // Find the index of the first message after this timestamp
614
+ fromIndex = transcript.findIndex(msg => {
615
+ const msgTimestamp = new Date(msg.timestamp || 0).getTime();
616
+ return msgTimestamp > lastTimestamp;
617
+ });
618
+
619
+ // If not found, start from end of transcript (all messages are older)
620
+ if (fromIndex === -1) {
621
+ log(`All messages older than last timeline event (${new Date(lastTimestamp).toISOString()}) - no new events`);
622
+ console.log(`[transcript] All ${transcript.length} messages are older than last timeline event - no new events to ingest`);
623
+ fromIndex = transcript.length;
624
+ } else {
625
+ log(`Found ${transcript.length - fromIndex} new messages starting from index ${fromIndex}/${transcript.length}`);
626
+ console.log(`[transcript] Found ${transcript.length - fromIndex} new messages starting from index ${fromIndex}/${transcript.length}`);
627
+ }
628
+ } else {
629
+ console.log(`[transcript] No timeline events found - will process all transcript messages`);
630
+ fromIndex = 0;
631
+ }
632
+ } else {
633
+ console.log(`[transcript] Timeline query failed with status ${timelineResponse.status} - processing all messages`);
634
+ fromIndex = 0;
635
+ }
636
+ } catch (error) {
637
+ console.log(`[transcript] Failed to query timeline: ${error.message}, processing all messages`);
638
+ fromIndex = 0;
639
+ }
640
+ }
641
+
642
+ // 3. Extract only NEW events from determined index
643
+ const allEvents = extractTimelineEvents(transcript, fromIndex);
644
+
645
+ log(`Extracted ${allEvents.length} events from transcript`);
646
+ if (allEvents.length === 0) {
647
+ log(`No new events since index ${fromIndex} - returning`);
648
+ console.log(`[transcript] No new events since index ${fromIndex} (transcript length: ${transcript.length})`);
649
+ return { events_pushed: 0 };
650
+ }
651
+
652
+ // 4. Limit events to process (most recent N events)
653
+ // This prevents blocking the daemon or hooks with large backlogs
654
+ const events = allEvents.slice(-effectiveMaxEvents);
655
+ const skipped = allEvents.length - events.length;
656
+
657
+ if (skipped > 0) {
658
+ log(`Limited to most recent ${events.length} events (skipped ${skipped} older events)`);
659
+ console.log(`[transcript] Limited to most recent ${events.length}/${allEvents.length} events`);
660
+ } else {
661
+ log(`Processing all ${events.length} events`);
662
+ console.log(`[transcript] Processing all ${events.length} events`);
663
+ }
664
+
665
+ // 5. Push events using chunked batching (with individual fallback on failure)
666
+ // This balances network efficiency (fewer requests) with resilience (individual fallback)
667
+ const { successCount, failCount } = await pushEventsInChunks(
668
+ events,
669
+ relayApiUrl,
670
+ apiKey,
671
+ parent_session_id,
672
+ task_id,
673
+ claude_session_id,
674
+ log
675
+ );
676
+
677
+ log(`===== INGESTION COMPLETE: ${successCount}/${events.length} events pushed (${failCount} failed) =====`);
678
+ console.log(`[transcript] Pushed ${successCount}/${events.length} events (${failCount} failed)`);
679
+
680
+ // 6. Update last ingested index to prevent duplicates on next call (tasks only)
681
+ if (task_id && successCount > 0) {
682
+ await updateLastIngestedIndex(task_id, parent_session_id, transcript.length, config);
683
+ }
684
+
685
+ return { events_pushed: successCount, events_failed: failCount };
686
+ }
687
+
688
+ /**
689
+ * Get transcript length to track ingestion progress
690
+ * @param {string} claude_session_id - Claude session ID
691
+ * @returns {Promise<number>} Number of messages in transcript
692
+ */
693
+ export async function getTranscriptLength(claude_session_id) {
694
+ const transcript = await readTranscript(claude_session_id);
695
+ return transcript.length;
696
+ }