teleportation-cli 1.1.5 → 1.2.0

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 +9 -5
  40. package/teleportation-cli.cjs +488 -453
  41. package/.claude/hooks/heartbeat.mjs +0 -396
  42. package/lib/daemon/pid-manager.js +0 -183
@@ -1,17 +1,115 @@
1
1
  #!/usr/bin/env bun
2
2
  /**
3
3
  * Stop Hook
4
- *
4
+ *
5
5
  * This hook fires when Claude Code finishes responding.
6
- *
6
+ *
7
7
  * Purpose:
8
8
  * 1. Check for pending messages from the mobile app (existing functionality)
9
9
  * 2. Extract Claude's last response from the transcript and log it to timeline
10
10
  */
11
11
 
12
12
  import { stdin, stdout, stderr, exit, env } from 'node:process';
13
- import { readFile } from 'node:fs/promises';
14
- import { appendFileSync } from 'node:fs';
13
+ import { readFile, mkdir, writeFile } from 'node:fs/promises';
14
+ import { appendFileSync, existsSync } from 'node:fs';
15
+ import { homedir } from 'node:os';
16
+ import { join, dirname } from 'node:path';
17
+ import { fileURLToPath } from 'node:url';
18
+ import { createHash } from 'node:crypto';
19
+
20
+ /**
21
+ * Generate a deterministic UUID-formatted event ID from message UUID and event index.
22
+ * This ensures the same transcript message always produces the same event IDs,
23
+ * enabling server-side deduplication via ON CONFLICT (id) DO NOTHING.
24
+ */
25
+ const deterministicEventId = (msgUuid, eventIndex) => {
26
+ if (!msgUuid) return null;
27
+ const hash = createHash('sha256').update(`${msgUuid}:${eventIndex}`).digest('hex');
28
+ return [hash.slice(0,8), hash.slice(8,12), hash.slice(12,16), hash.slice(16,20), hash.slice(20,32)].join('-');
29
+ };
30
+
31
+ // Dynamic import for sanitizer (handle both installed and dev paths)
32
+ const __filename = fileURLToPath(import.meta.url);
33
+ const __dirname = dirname(__filename);
34
+
35
+ // Sanitizer is cached per hook execution. Each hook runs as a separate process
36
+ // spawned by Claude Code, so there's no concurrency risk within a single execution.
37
+ // The cache persists for the duration of the hook process (typically <1 second).
38
+ let sanitizeForLog = null;
39
+ let sanitizerWarningLogged = false;
40
+
41
+ /**
42
+ * Basic regex-based sanitizer used as fallback when log-sanitizer.js is not available.
43
+ * Covers the most common sensitive patterns to prevent data leakage.
44
+ *
45
+ * @param {string} text - Text to sanitize
46
+ * @returns {string} - Sanitized text with sensitive data redacted
47
+ */
48
+ function basicSanitizer(text) {
49
+ if (!text || typeof text !== 'string') return text;
50
+
51
+ return text
52
+ // JWT tokens (must come first as they start with 'eyJ' and could be matched by other patterns)
53
+ .replace(/eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g, '[JWT_REDACTED]')
54
+ // API keys (various formats)
55
+ .replace(/api[_-]?key["']?\s*[:=]\s*["']?([A-Za-z0-9_-]{20,})["']?/gi, 'api_key: [REDACTED]')
56
+ // Bearer tokens
57
+ .replace(/Bearer\s+[A-Za-z0-9_-]+/gi, 'Bearer [REDACTED]')
58
+ // Authorization headers
59
+ .replace(/Authorization["']?\s*[:=]\s*["']?([A-Za-z0-9_-]{20,})["']?/gi, 'Authorization: [REDACTED]')
60
+ // Passwords
61
+ .replace(/(password|passwd|pwd)["']?\s*[:=]\s*["']?([^\s"']+)["']?/gi, '$1: [REDACTED]')
62
+ // Secrets
63
+ .replace(/secret["']?\s*[:=]\s*["']?([^\s"']+)["']?/gi, 'secret: [REDACTED]')
64
+ // Generic tokens (must come after JWT to avoid partial matching)
65
+ .replace(/token["']?\s*[:=]\s*["']?([A-Za-z0-9_-]{20,})["']?/gi, 'token: [REDACTED]')
66
+ // AWS keys
67
+ .replace(/AKIA[A-Z0-9]{16}/g, 'AKIA[REDACTED]');
68
+ }
69
+
70
+ /**
71
+ * Get the sanitizer function for removing sensitive data from text.
72
+ * Tries multiple paths to find the log-sanitizer module.
73
+ * Falls back to basicSanitizer if the full module is not found.
74
+ *
75
+ * @param {Function} [log] - Optional logging function for warnings
76
+ * @returns {Promise<Function>} - The sanitizer function
77
+ */
78
+ async function getSanitizer(log = () => {}) {
79
+ if (sanitizeForLog) return sanitizeForLog;
80
+
81
+ const possiblePaths = [
82
+ // Installed location
83
+ join(homedir(), '.teleportation', 'lib', 'utils', 'log-sanitizer.js'),
84
+ // Development mode - relative to hooks directory
85
+ join(__dirname, '..', '..', 'lib', 'utils', 'log-sanitizer.js'),
86
+ ];
87
+
88
+ for (const path of possiblePaths) {
89
+ if (!existsSync(path)) continue;
90
+ try {
91
+ const mod = await import(path);
92
+ sanitizeForLog = mod.sanitizeForLog;
93
+ if (sanitizeForLog) return sanitizeForLog;
94
+ } catch (err) {
95
+ // Log import failures for debugging (only in DEBUG mode to avoid noise)
96
+ if (env.DEBUG) {
97
+ log(`[Sanitizer] Failed to import from ${path}: ${err.message}`);
98
+ }
99
+ continue;
100
+ }
101
+ }
102
+
103
+ // Fallback: use basic regex sanitizer instead of identity function
104
+ // This ensures sensitive data is still redacted even if full sanitizer is unavailable
105
+ if (!sanitizerWarningLogged) {
106
+ sanitizerWarningLogged = true;
107
+ log('⚠️ Full sanitizer not found - using basic regex fallback. ' +
108
+ 'Some sensitive patterns may not be caught.');
109
+ }
110
+ sanitizeForLog = basicSanitizer;
111
+ return sanitizeForLog;
112
+ }
15
113
 
16
114
  const readStdin = () => new Promise((resolve, reject) => {
17
115
  let data = '';
@@ -47,8 +145,81 @@ const MAX_SYSTEM_MESSAGE_LENGTH = 1000;
47
145
  const MAX_TRANSCRIPT_SIZE = 500 * 1024;
48
146
 
49
147
  // Retry configuration
148
+ // Note: With MAX_RETRIES=3 and exponential backoff, max total time per request is ~36 seconds:
149
+ // Attempt 1: 10s timeout + 1s delay = 11s
150
+ // Attempt 2: 10s timeout + 2s delay = 12s
151
+ // Attempt 3: 10s timeout + 3s delay = 13s
152
+ // Total max: ~36 seconds
50
153
  const MAX_RETRIES = 3;
51
154
  const RETRY_DELAY_MS = 1000;
155
+ const FETCH_TIMEOUT_MS = 10000; // 10 second timeout for each fetch
156
+ const BATCH_FETCH_TIMEOUT_MS = 60000; // 60 second timeout for batch uploads (larger payloads)
157
+ const HEALTH_PROBE_TIMEOUT_MS = parseInt(env.TELEPORTATION_STOP_HEALTH_PROBE_TIMEOUT_MS || '2500', 10);
158
+ const MAX_MESSAGES_ON_DEGRADED_BOOTSTRAP = parseInt(env.TELEPORTATION_STOP_DEGRADED_BOOTSTRAP_LIMIT || '250', 10);
159
+ const STOP_FULL_INGEST_ENABLED = env.TELEPORTATION_STOP_FULL_INGEST === 'true';
160
+ const STOP_CURSOR_DIR = join(homedir(), '.teleportation', '.stop_hook_cursor_v2');
161
+
162
+ const getStopCursorPath = (sessionId) => join(STOP_CURSOR_DIR, `${sessionId}.json`);
163
+
164
+ const toTimestampMs = (timestamp) => {
165
+ if (typeof timestamp === 'number' && Number.isFinite(timestamp)) return timestamp;
166
+ if (typeof timestamp === 'string' && timestamp) {
167
+ const parsed = new Date(timestamp).getTime();
168
+ if (Number.isFinite(parsed)) return parsed;
169
+ }
170
+ return 0;
171
+ };
172
+
173
+ const loadStopCursor = async (sessionId, log) => {
174
+ if (!sessionId) return null;
175
+ try {
176
+ const path = getStopCursorPath(sessionId);
177
+ const raw = await readFile(path, 'utf8');
178
+ const parsed = JSON.parse(raw);
179
+ if (!parsed || typeof parsed !== 'object') return null;
180
+ return {
181
+ last_uuid: typeof parsed.last_uuid === 'string' ? parsed.last_uuid : null,
182
+ last_timestamp_ms: Number.isFinite(parsed.last_timestamp_ms) ? parsed.last_timestamp_ms : 0,
183
+ updated_at: Number.isFinite(parsed.updated_at) ? parsed.updated_at : 0
184
+ };
185
+ } catch {
186
+ return null;
187
+ }
188
+ };
189
+
190
+ const saveStopCursor = async (sessionId, cursor, log) => {
191
+ if (!sessionId || !cursor) return;
192
+ try {
193
+ await mkdir(STOP_CURSOR_DIR, { recursive: true, mode: 0o700 });
194
+ await writeFile(
195
+ getStopCursorPath(sessionId),
196
+ JSON.stringify({
197
+ last_uuid: cursor.last_uuid || null,
198
+ last_timestamp_ms: Number.isFinite(cursor.last_timestamp_ms) ? cursor.last_timestamp_ms : 0,
199
+ updated_at: Date.now()
200
+ }),
201
+ { mode: 0o600 }
202
+ );
203
+ } catch (e) {
204
+ log(`Failed to persist stop cursor: ${e.message}`);
205
+ }
206
+ };
207
+
208
+ const probeTimelineEndpoint = async (relayApiUrl, relayApiKey, sessionId, log) => {
209
+ try {
210
+ const res = await fetchWithTimeout(`${relayApiUrl}/api/sessions/${sessionId}/timeline?limit=1`, {
211
+ headers: { 'Authorization': `Bearer ${relayApiKey}` }
212
+ }, HEALTH_PROBE_TIMEOUT_MS);
213
+ if (!res.ok) {
214
+ log(`Health probe failed with HTTP ${res.status}`);
215
+ return false;
216
+ }
217
+ return true;
218
+ } catch (e) {
219
+ log(`Health probe failed: ${e.message}`);
220
+ return false;
221
+ }
222
+ };
52
223
 
53
224
  /**
54
225
  * Extract text content from a message in various formats
@@ -95,20 +266,130 @@ const extractMessageContent = (msg) => {
95
266
  return '';
96
267
  };
97
268
 
269
+ /**
270
+ * Sanitize event data to remove sensitive information before storing in timeline.
271
+ * Applies sanitization to string fields that may contain secrets.
272
+ *
273
+ * @param {Object} eventData - The event data object
274
+ * @param {Function} sanitizer - The sanitization function
275
+ * @returns {Object} - Sanitized event data
276
+ */
277
+ const sanitizeEventData = (eventData, sanitizer) => {
278
+ if (!eventData || typeof eventData !== 'object') return eventData;
279
+ if (!sanitizer) return eventData;
280
+
281
+ const sanitized = { ...eventData };
282
+
283
+ // Sanitize known text fields
284
+ const textFields = ['message', 'summary', 'thinking', 'result', 'error'];
285
+ for (const field of textFields) {
286
+ if (typeof sanitized[field] === 'string') {
287
+ sanitized[field] = sanitizer(sanitized[field]);
288
+ }
289
+ }
290
+
291
+ // Sanitize tool_input if it's a string or contains sensitive data
292
+ if (sanitized.tool_input) {
293
+ if (typeof sanitized.tool_input === 'string') {
294
+ sanitized.tool_input = sanitizer(sanitized.tool_input);
295
+ } else if (typeof sanitized.tool_input === 'object') {
296
+ // Sanitize stringified version of object fields that might contain secrets
297
+ const inputStr = JSON.stringify(sanitized.tool_input);
298
+ const sanitizedStr = sanitizer(inputStr);
299
+ if (inputStr !== sanitizedStr) {
300
+ try {
301
+ sanitized.tool_input = JSON.parse(sanitizedStr);
302
+ } catch {
303
+ // If parsing fails, use sanitized string
304
+ sanitized.tool_input = sanitizedStr;
305
+ }
306
+ }
307
+ }
308
+ }
309
+
310
+ return sanitized;
311
+ };
312
+
313
+ /**
314
+ * Fetch with timeout using AbortController
315
+ *
316
+ * Wraps native fetch with a configurable timeout. If the request takes longer
317
+ * than timeoutMs, the request is aborted and an AbortError is thrown.
318
+ *
319
+ * @param {string} url - The URL to fetch
320
+ * @param {RequestInit} opts - Fetch options (method, headers, body, etc.)
321
+ * @param {number} [timeoutMs=FETCH_TIMEOUT_MS] - Timeout in milliseconds (default: 10s)
322
+ * @returns {Promise<Response>} - The fetch Response object
323
+ * @throws {Error} - AbortError if timeout exceeded, or network errors
324
+ *
325
+ * @example
326
+ * // Basic usage with default 10s timeout
327
+ * const res = await fetchWithTimeout('https://api.example.com/data', { method: 'GET' });
328
+ *
329
+ * @example
330
+ * // Custom 60s timeout for large payloads
331
+ * const res = await fetchWithTimeout(url, { method: 'POST', body }, 60000);
332
+ */
333
+ const fetchWithTimeout = async (url, opts, timeoutMs = FETCH_TIMEOUT_MS) => {
334
+ const controller = new AbortController();
335
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
336
+ try {
337
+ const res = await fetch(url, { ...opts, signal: controller.signal });
338
+ return res;
339
+ } finally {
340
+ clearTimeout(timeoutId);
341
+ }
342
+ };
343
+
98
344
  /**
99
345
  * Fetch JSON with retry logic
346
+ *
347
+ * Retries failed requests with exponential backoff. Does NOT retry on:
348
+ * - AbortError (timeout) - to prevent duplicate requests for slow endpoints
349
+ * - 4xx client errors - these won't succeed on retry
350
+ *
351
+ * @param {string} url - The URL to fetch
352
+ * @param {RequestInit} opts - Fetch options
353
+ * @param {Function} log - Logging function
354
+ * @param {number} [retries=0] - Current retry count (internal)
355
+ * @param {number} [timeoutMs=FETCH_TIMEOUT_MS] - Timeout per attempt
356
+ * @returns {Promise<Object>} - Parsed JSON response
100
357
  */
101
- const fetchJsonWithRetry = async (url, opts, log, retries = 0) => {
358
+ const fetchJsonWithRetry = async (url, opts, log, retries = 0, timeoutMs = FETCH_TIMEOUT_MS) => {
359
+ // Extract endpoint path for logging (hide host/auth for security)
360
+ const urlPath = (() => {
361
+ try {
362
+ const parsed = new URL(url);
363
+ return parsed.pathname + parsed.search;
364
+ } catch {
365
+ return url.split('?')[0].split('/').slice(-2).join('/');
366
+ }
367
+ })();
368
+
102
369
  try {
103
- const res = await fetch(url, opts);
370
+ const res = await fetchWithTimeout(url, opts, timeoutMs);
104
371
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
105
372
  return res.json();
106
373
  } catch (error) {
374
+ // Don't retry on abort (timeout) - request may still be processing server-side
375
+ // This prevents duplicate entries for slow batch uploads
376
+ if (error.name === 'AbortError') {
377
+ log(`Request to ${urlPath} aborted (timeout after ${timeoutMs}ms) - not retrying to avoid duplicates`);
378
+ throw error;
379
+ }
380
+
381
+ // Don't retry on 4xx client errors - they won't succeed
382
+ if (error.message && error.message.startsWith('HTTP 4')) {
383
+ log(`Request to ${urlPath} failed: ${error.message} - not retrying (client error)`);
384
+ throw error;
385
+ }
386
+
107
387
  if (retries < MAX_RETRIES) {
108
- log(`Retry ${retries + 1}/${MAX_RETRIES} after error: ${error.message}`);
388
+ log(`Retry ${retries + 1}/${MAX_RETRIES} for ${urlPath} after error: ${error.message}`);
109
389
  await new Promise(r => setTimeout(r, RETRY_DELAY_MS * (retries + 1)));
110
- return fetchJsonWithRetry(url, opts, log, retries + 1);
390
+ return fetchJsonWithRetry(url, opts, log, retries + 1, timeoutMs);
111
391
  }
392
+ log(`Request to ${urlPath} failed after ${MAX_RETRIES} retries: ${error.message}`);
112
393
  throw error;
113
394
  }
114
395
  };
@@ -366,23 +647,331 @@ const extractFullTranscript = async (transcriptPath, log) => {
366
647
  }
367
648
  };
368
649
 
650
+ /**
651
+ * Parse transcript into structured events for batch logging
652
+ * Only processes messages newer than the most recent timeline event (prevents duplicates)
653
+ * Extracts event types: assistant_response, thinking, tool_use, tool_result, compact_summary
654
+ *
655
+ * @param {string} transcriptPath - Path to the transcript file
656
+ * @param {string} sessionId - Session ID for timeline query
657
+ * @param {string} relayApiUrl - Relay API URL
658
+ * @param {string} relayApiKey - Relay API key
659
+ * @param {Function} log - Logging function
660
+ * @returns {Promise<{events: Array<{type: string, data: object}>, cursor: object|null}>}
661
+ */
662
+ const parseTranscriptToEvents = async (transcriptPath, sessionId, relayApiUrl, relayApiKey, log) => {
663
+ try {
664
+ if (!transcriptPath) {
665
+ log('No transcript_path provided for parsing');
666
+ return { events: [], cursor: null };
667
+ }
668
+
669
+ // Get sanitizer for removing sensitive data from events
670
+ const sanitizer = await getSanitizer(log);
671
+
672
+ // 1. Get most recent event timestamp from timeline to find split point
673
+ let lastEventTimestamp = 0;
674
+ let lastEventIsoTimestamp = null;
675
+ let timelineQueryFailed = false;
676
+
677
+ try {
678
+ const timelineResponse = await fetchWithTimeout(`${relayApiUrl}/api/sessions/${sessionId}/timeline?limit=1`, {
679
+ headers: { 'Authorization': `Bearer ${relayApiKey}` }
680
+ });
681
+
682
+ if (timelineResponse.ok) {
683
+ const response = await timelineResponse.json();
684
+
685
+ // Validate response format and extract events
686
+ // PRD-0022: Timeline API returns { events: [...], pagination: {...} } format
687
+ // Also handle legacy array format for backward compatibility
688
+ let events;
689
+ if (Array.isArray(response)) {
690
+ events = response;
691
+ } else if (response && typeof response === 'object' && Array.isArray(response.events)) {
692
+ events = response.events;
693
+ } else {
694
+ // Unexpected format - log warning and continue with empty events
695
+ log(`⚠️ Unexpected timeline response format: ${typeof response}${response?.error ? ` (error: ${response.error})` : ''}`);
696
+ events = [];
697
+ }
698
+
699
+ if (events.length > 0 && events[0].timestamp) {
700
+ // Handle both numeric timestamps (Unix ms) and ISO string timestamps
701
+ const rawTimestamp = events[0].timestamp;
702
+ lastEventTimestamp = typeof rawTimestamp === 'number'
703
+ ? rawTimestamp
704
+ : new Date(rawTimestamp).getTime();
705
+
706
+ if (!isNaN(lastEventTimestamp) && lastEventTimestamp > 0) {
707
+ lastEventIsoTimestamp = new Date(lastEventTimestamp).toISOString();
708
+ log(`Most recent timeline event: ${lastEventIsoTimestamp} (${lastEventTimestamp}ms)`);
709
+ } else {
710
+ log(`Invalid timestamp in timeline event: ${rawTimestamp} - will process all messages`);
711
+ lastEventTimestamp = 0;
712
+ }
713
+ } else {
714
+ log('No previous events in timeline - will process all messages');
715
+ }
716
+ } else {
717
+ log(`Timeline query returned ${timelineResponse.status} - will process all messages`);
718
+ }
719
+ } catch (e) {
720
+ timelineQueryFailed = true;
721
+ log(`Timeline query failed: ${e.message} - will process all messages`);
722
+ }
723
+
724
+ // 2. Read and parse transcript (JSONL format)
725
+ let content;
726
+ try {
727
+ content = await readFile(transcriptPath, 'utf8');
728
+ } catch (e) {
729
+ log(`Error reading transcript: ${e.code || e.message}`);
730
+ return { events: [], cursor: null };
731
+ }
732
+
733
+ let transcript;
734
+ try {
735
+ transcript = JSON.parse(content);
736
+ log('Parsed transcript as JSON array');
737
+ } catch (e) {
738
+ // Try JSONL format
739
+ const lines = content.trim().split('\n').filter(l => l.trim());
740
+ transcript = lines.map(line => {
741
+ try {
742
+ return JSON.parse(line);
743
+ } catch {
744
+ return null;
745
+ }
746
+ }).filter(Boolean);
747
+ log(`Parsed transcript as JSONL (${transcript.length} total messages)`);
748
+ }
749
+
750
+ if (!Array.isArray(transcript)) {
751
+ log(`Transcript is not an array: ${typeof transcript}`);
752
+ return { events: [], cursor: null };
753
+ }
754
+
755
+ // 3. Filter using local cursor first, then timeline timestamp
756
+ const localCursor = await loadStopCursor(sessionId, log);
757
+ let newMessages = transcript;
758
+ let usedLocalCursor = false;
759
+
760
+ if (localCursor?.last_uuid) {
761
+ const idx = transcript.findIndex(msg => msg.uuid === localCursor.last_uuid);
762
+ if (idx >= 0) {
763
+ newMessages = transcript.slice(idx + 1);
764
+ usedLocalCursor = true;
765
+ log(`📍 Local cursor UUID matched at index ${idx}, processing ${newMessages.length} newer messages`);
766
+ } else {
767
+ log('⚠️ Local cursor UUID not found in transcript, falling back to timestamp cursor');
768
+ }
769
+ }
770
+
771
+ if (!usedLocalCursor && localCursor?.last_timestamp_ms > 0) {
772
+ const filtered = transcript.filter(msg => toTimestampMs(msg.timestamp) > localCursor.last_timestamp_ms);
773
+ if (filtered.length !== transcript.length) {
774
+ newMessages = filtered;
775
+ usedLocalCursor = true;
776
+ log(`📍 Local timestamp cursor applied, processing ${newMessages.length} newer messages`);
777
+ }
778
+ }
779
+
780
+ if (lastEventTimestamp > 0) {
781
+ newMessages = newMessages.filter(msg => toTimestampMs(msg.timestamp) > lastEventTimestamp);
782
+ log(`📊 Timeline cursor + local cursor reduced to ${newMessages.length} new messages`);
783
+ } else {
784
+ if (timelineQueryFailed && !usedLocalCursor && newMessages.length > MAX_MESSAGES_ON_DEGRADED_BOOTSTRAP) {
785
+ newMessages = newMessages.slice(-MAX_MESSAGES_ON_DEGRADED_BOOTSTRAP);
786
+ log(`⚠️ Timeline unavailable, limiting bootstrap parse to last ${MAX_MESSAGES_ON_DEGRADED_BOOTSTRAP} messages`);
787
+ } else if (!usedLocalCursor) {
788
+ log(`📊 Processing all ${transcript.length} messages (no previous timeline or local cursor)`);
789
+ }
790
+ }
791
+
792
+ const events = [];
793
+
794
+ // Build tool_use lookup for matching tool_result to tool_use
795
+ const toolUseLookup = new Map();
796
+ for (const msg of newMessages) {
797
+ if (msg.message?.content && Array.isArray(msg.message.content)) {
798
+ for (const block of msg.message.content) {
799
+ if (block.type === 'tool_use' && block.id && block.name) {
800
+ toolUseLookup.set(block.id, {
801
+ name: block.name,
802
+ input: block.input || null
803
+ });
804
+ }
805
+ }
806
+ }
807
+ }
808
+
809
+ // Parse each new message (only those newer than last timeline event)
810
+ for (const msg of newMessages) {
811
+ let msgEventIndex = 0; // Per-message counter — stable regardless of batch composition
812
+ const role = msg.role || msg.type || '';
813
+ const timestamp = msg.timestamp || Date.now();
814
+
815
+ // 1. Extract assistant responses (conversational text)
816
+ if (role === 'assistant' || role === 'model' || msg.isAssistant) {
817
+ const text = extractMessageContent(msg);
818
+ if (text && text.trim()) {
819
+ const preview = text.length > ASSISTANT_RESPONSE_MAX_LENGTH
820
+ ? text.slice(0, ASSISTANT_RESPONSE_MAX_LENGTH) + '...'
821
+ : text;
822
+
823
+ events.push({
824
+ type: 'assistant_response',
825
+ id: deterministicEventId(msg.uuid, msgEventIndex++),
826
+ timestamp,
827
+ data: {
828
+ message: preview,
829
+ model: msg.model || null,
830
+ full_length: text.length,
831
+ truncated: text.length > ASSISTANT_RESPONSE_MAX_LENGTH,
832
+ timestamp
833
+ }
834
+ });
835
+ }
836
+ }
837
+
838
+ // 2. Extract compact summaries
839
+ if (msg.isCompactSummary && msg.message?.content) {
840
+ const content = typeof msg.message.content === 'string'
841
+ ? msg.message.content
842
+ : JSON.stringify(msg.message.content);
843
+
844
+ const preview = content.length > MAX_SYSTEM_MESSAGE_LENGTH
845
+ ? content.slice(0, MAX_SYSTEM_MESSAGE_LENGTH) + '...'
846
+ : content;
847
+
848
+ events.push({
849
+ type: 'compact_summary',
850
+ id: deterministicEventId(msg.uuid, msgEventIndex++),
851
+ timestamp,
852
+ data: {
853
+ summary: preview,
854
+ full_length: content.length,
855
+ truncated: content.length > MAX_SYSTEM_MESSAGE_LENGTH,
856
+ timestamp
857
+ }
858
+ });
859
+ }
860
+
861
+ // 3. Extract thinking blocks and tool_use from content blocks
862
+ if (msg.message?.content && Array.isArray(msg.message.content)) {
863
+ for (const block of msg.message.content) {
864
+ // Thinking blocks
865
+ if (block.type === 'thinking' && block.thinking) {
866
+ const preview = block.thinking.length > MAX_SYSTEM_MESSAGE_LENGTH
867
+ ? block.thinking.slice(0, MAX_SYSTEM_MESSAGE_LENGTH) + '...'
868
+ : block.thinking;
869
+
870
+ events.push({
871
+ type: 'thinking',
872
+ id: deterministicEventId(msg.uuid, msgEventIndex++),
873
+ timestamp,
874
+ data: {
875
+ thinking: preview,
876
+ full_length: block.thinking.length,
877
+ truncated: block.thinking.length > MAX_SYSTEM_MESSAGE_LENGTH,
878
+ timestamp,
879
+ signature: block.signature ? '✓ verified' : null
880
+ }
881
+ });
882
+ }
883
+
884
+ // Tool use blocks
885
+ if (block.type === 'tool_use' && block.name) {
886
+ events.push({
887
+ type: 'tool_use',
888
+ id: deterministicEventId(msg.uuid, msgEventIndex++),
889
+ timestamp,
890
+ data: {
891
+ tool_name: block.name,
892
+ tool_use_id: block.id,
893
+ tool_input: block.input || null,
894
+ timestamp
895
+ }
896
+ });
897
+ }
898
+
899
+ // Tool result blocks
900
+ if (block.type === 'tool_result' && block.content) {
901
+ const contentStr = Array.isArray(block.content)
902
+ ? block.content.map(c => c.text || '').join('\n')
903
+ : String(block.content);
904
+
905
+ const preview = contentStr.length > MAX_SYSTEM_MESSAGE_LENGTH
906
+ ? contentStr.slice(0, MAX_SYSTEM_MESSAGE_LENGTH) + '...'
907
+ : contentStr;
908
+
909
+ // Look up the tool details
910
+ const toolDetails = block.tool_use_id ? toolUseLookup.get(block.tool_use_id) : null;
911
+
912
+ events.push({
913
+ type: 'tool_result',
914
+ id: deterministicEventId(msg.uuid, msgEventIndex++),
915
+ timestamp,
916
+ data: {
917
+ tool_use_id: block.tool_use_id,
918
+ tool_name: toolDetails?.name || null,
919
+ tool_input: toolDetails?.input || null,
920
+ result: preview,
921
+ full_length: contentStr.length,
922
+ truncated: contentStr.length > MAX_SYSTEM_MESSAGE_LENGTH,
923
+ timestamp,
924
+ is_error: block.is_error || false
925
+ }
926
+ });
927
+ }
928
+ }
929
+ }
930
+ }
931
+
932
+ log(`✅ Parsed ${events.length} events from transcript`);
933
+
934
+ // Sanitize all events to remove sensitive data (API keys, tokens, passwords, etc.)
935
+ const sanitizedEvents = events.map(event => ({
936
+ ...event,
937
+ data: sanitizeEventData(event.data, sanitizer)
938
+ }));
939
+
940
+ const lastMessage = newMessages.length > 0 ? newMessages[newMessages.length - 1] : null;
941
+ const nextCursor = lastMessage ? {
942
+ last_uuid: lastMessage.uuid || null,
943
+ last_timestamp_ms: toTimestampMs(lastMessage.timestamp) || Date.now()
944
+ } : localCursor;
945
+
946
+ return { events: sanitizedEvents, cursor: nextCursor };
947
+ } catch (e) {
948
+ log(`Error parsing transcript to events: ${e.message}`);
949
+ return { events: [], cursor: null };
950
+ }
951
+ };
952
+
369
953
  /**
370
954
  * Extract system messages (compact summaries, thinking blocks, tool results) from transcript
371
- * @returns {Array<{type: string, data: object}>} Array of system messages to log to timeline
955
+ * Uses UUID tracking to avoid re-processing already-logged messages
956
+ * @param {string} transcriptPath - Path to the transcript file
957
+ * @param {string} sessionId - Session ID for state tracking
958
+ * @param {Function} log - Logging function
959
+ * @returns {Promise<{messages: Array, lastUUID: string|null}>} System messages and last UUID
372
960
  */
373
- const extractSystemMessages = async (transcriptPath, log) => {
961
+ const extractSystemMessages = async (transcriptPath, sessionId, log) => {
374
962
  try {
375
963
  if (!transcriptPath) {
376
964
  log('No transcript_path provided for system messages');
377
- return [];
965
+ return { messages: [], lastUUID: null };
378
966
  }
379
967
 
968
+ // 1. Read and parse transcript
380
969
  let content;
381
970
  try {
382
971
  content = await readFile(transcriptPath, 'utf8');
383
972
  } catch (e) {
384
973
  log(`Error reading transcript for system messages: ${e.code || e.message}`);
385
- return [];
974
+ return { messages: [], lastUUID: null };
386
975
  }
387
976
 
388
977
  let transcript;
@@ -402,15 +991,49 @@ const extractSystemMessages = async (transcriptPath, log) => {
402
991
 
403
992
  if (!Array.isArray(transcript)) {
404
993
  log(`Transcript is not an array for system messages`);
405
- return [];
994
+ return { messages: [], lastUUID: null };
995
+ }
996
+
997
+ log(`📊 Transcript contains ${transcript.length} total messages`);
998
+
999
+ // 2. Load last processed state
1000
+ const stateDir = join(homedir(), '.teleportation', '.stop_hook_state');
1001
+ const stateFile = join(stateDir, `${sessionId}.json`);
1002
+
1003
+ let lastProcessedUUID = null;
1004
+ try {
1005
+ const state = JSON.parse(await readFile(stateFile, 'utf8'));
1006
+ lastProcessedUUID = state.lastUUID;
1007
+ log(`📍 Last processed UUID: ${lastProcessedUUID?.substring(0, 8)}...`);
1008
+ } catch (e) {
1009
+ log('📄 No previous state - will process all messages');
1010
+ }
1011
+
1012
+ // 3. Find last processed index and slice to get only new messages
1013
+ let startIndex = 0;
1014
+ if (lastProcessedUUID) {
1015
+ const lastIndex = transcript.findIndex(msg => msg.uuid === lastProcessedUUID);
1016
+ if (lastIndex >= 0) {
1017
+ startIndex = lastIndex + 1;
1018
+ log(`✂️ Skipping ${startIndex} already-processed messages`);
1019
+ } else {
1020
+ log(`⚠️ Last UUID not found in transcript, processing all messages`);
1021
+ }
1022
+ }
1023
+
1024
+ const newMessages = transcript.slice(startIndex);
1025
+ log(`🆕 Processing ${newMessages.length} new messages`);
1026
+
1027
+ if (newMessages.length === 0) {
1028
+ log('✓ No new messages to process');
1029
+ return { messages: [], lastUUID: lastProcessedUUID };
406
1030
  }
407
1031
 
408
1032
  const systemMessages = [];
409
-
410
- // Build a lookup map from tool_use_id to tool details (name + input)
411
- // tool_use blocks have: { type: 'tool_use', id: 'toolu_...', name: 'Bash', input: {...} }
1033
+
1034
+ // 4. Build lookup ONLY for new messages (more efficient)
412
1035
  const toolUseLookup = new Map();
413
- for (const msg of transcript) {
1036
+ for (const msg of newMessages) {
414
1037
  if (msg.message?.content && Array.isArray(msg.message.content)) {
415
1038
  for (const block of msg.message.content) {
416
1039
  if (block.type === 'tool_use' && block.id && block.name) {
@@ -422,9 +1045,10 @@ const extractSystemMessages = async (transcriptPath, log) => {
422
1045
  }
423
1046
  }
424
1047
  }
425
- log(`Built tool_use lookup with ${toolUseLookup.size} entries`);
1048
+ log(`🔧 Built tool_use lookup with ${toolUseLookup.size} entries`);
426
1049
 
427
- for (const msg of transcript) {
1050
+ // 5. Process only new messages
1051
+ for (const msg of newMessages) {
428
1052
  // Extract compact summaries
429
1053
  if (msg.isCompactSummary && msg.message?.content) {
430
1054
  const content = typeof msg.message.content === 'string'
@@ -503,11 +1127,14 @@ const extractSystemMessages = async (transcriptPath, log) => {
503
1127
  }
504
1128
  }
505
1129
 
506
- log(`Extracted ${systemMessages.length} system messages (summaries, thinking, tool results)`);
507
- return systemMessages;
1130
+ // 6. Determine last UUID from new messages
1131
+ const lastUUID = newMessages[newMessages.length - 1]?.uuid || lastProcessedUUID;
1132
+
1133
+ log(`📝 Extracted ${systemMessages.length} system messages from ${newMessages.length} new messages`);
1134
+ return { messages: systemMessages, lastUUID };
508
1135
  } catch (e) {
509
1136
  log(`Error extracting system messages: ${e.message}`);
510
- return [];
1137
+ return { messages: [], lastUUID: null };
511
1138
  }
512
1139
  };
513
1140
 
@@ -535,6 +1162,11 @@ const extractSystemMessages = async (transcriptPath, log) => {
535
1162
  const { session_id, transcript_path, stop_hook_active } = input || {};
536
1163
  log(`Session: ${session_id}, Transcript: ${transcript_path}, stop_hook_active: ${stop_hook_active}`);
537
1164
 
1165
+ // Detect message source
1166
+ const isAutonomousTask = env.TELEPORTATION_TASK_MODE === 'true' || !!env.TELEPORTATION_PARENT_SESSION_ID;
1167
+ const source = isAutonomousTask ? 'autonomous_task' : 'cli_interactive';
1168
+ log(`Message source: ${source}`);
1169
+
538
1170
  // Load config
539
1171
  let config;
540
1172
  try {
@@ -561,51 +1193,100 @@ const extractSystemMessages = async (transcriptPath, log) => {
561
1193
  return exit(0);
562
1194
  }
563
1195
 
564
- // 1. Extract and log Claude's last response to timeline
1196
+ if (!STOP_FULL_INGEST_ENABLED) {
1197
+ log('Stop full ingest disabled (TELEPORTATION_STOP_FULL_INGEST!=true) - running lightweight stop path');
1198
+ }
1199
+
1200
+ let timelineHealthy = false;
1201
+ let skipHeavyUpload = true;
1202
+ if (STOP_FULL_INGEST_ENABLED) {
1203
+ timelineHealthy = await probeTimelineEndpoint(RELAY_API_URL, RELAY_API_KEY, session_id, log);
1204
+ skipHeavyUpload = !timelineHealthy;
1205
+ if (skipHeavyUpload) {
1206
+ log('⚠️ Relay timeline probe failed - skipping batch event and transcript uploads for this stop cycle');
1207
+ }
1208
+ }
1209
+
1210
+ // 1. Parse transcript and batch log all events (NEW APPROACH)
565
1211
  // Skip if this is a continuation from a previous stop hook (stop_hook_active=true)
566
- // to avoid logging duplicate responses
567
- if (!stop_hook_active) {
1212
+ if (!stop_hook_active && !skipHeavyUpload) {
568
1213
  try {
569
- const result = await extractLastAssistantMessage(transcript_path, log);
1214
+ const { events, cursor } = await parseTranscriptToEvents(transcript_path, session_id, RELAY_API_URL, RELAY_API_KEY, log);
570
1215
 
571
- if (result && result.text) {
572
- const { text, model } = result;
1216
+ if (events.length > 0) {
1217
+ // Split into chunks of MAX_BATCH_SIZE to handle large batches
1218
+ const MAX_BATCH_SIZE = 1000;
1219
+ const chunks = [];
573
1220
 
574
- // Truncate for timeline storage
575
- const preview = text.length > ASSISTANT_RESPONSE_MAX_LENGTH
576
- ? text.slice(0, ASSISTANT_RESPONSE_MAX_LENGTH) + '...'
577
- : text;
1221
+ for (let i = 0; i < events.length; i += MAX_BATCH_SIZE) {
1222
+ chunks.push(events.slice(i, i + MAX_BATCH_SIZE));
1223
+ }
578
1224
 
579
- await fetchJsonWithRetry(`${RELAY_API_URL}/api/timeline`, {
580
- method: 'POST',
581
- headers: {
582
- 'Content-Type': 'application/json',
583
- 'Authorization': `Bearer ${RELAY_API_KEY}`
584
- },
585
- body: JSON.stringify({
586
- session_id,
587
- type: 'assistant_response',
588
- data: {
589
- message: preview,
590
- model: model, // Include the model field
591
- full_length: text.length,
592
- truncated: text.length > ASSISTANT_RESPONSE_MAX_LENGTH
593
- }
594
- })
595
- }, log);
596
- log(`Logged assistant response to timeline (${preview.length} chars, model: ${model || 'not specified'})`);
1225
+ log(`📦 Sending ${events.length} events in ${chunks.length} batch(es)`);
1226
+
1227
+ let totalSuccess = 0;
1228
+ let totalFailed = 0;
1229
+
1230
+ for (let i = 0; i < chunks.length; i++) {
1231
+ const chunk = chunks[i];
1232
+
1233
+ try {
1234
+ // Use longer timeout for batch uploads (60s) to handle large payloads
1235
+ // Don't retry on timeout to prevent duplicate events (see fetchJsonWithRetry)
1236
+ const response = await fetchJsonWithRetry(`${RELAY_API_URL}/api/sessions/${session_id}/events/batch`, {
1237
+ method: 'POST',
1238
+ headers: {
1239
+ 'Content-Type': 'application/json',
1240
+ 'Authorization': `Bearer ${RELAY_API_KEY}`
1241
+ },
1242
+ body: JSON.stringify({ events: chunk })
1243
+ }, log, 0, BATCH_FETCH_TIMEOUT_MS);
1244
+
1245
+ const success = response.success || 0;
1246
+ const failed = response.failed || 0;
1247
+
1248
+ totalSuccess += success;
1249
+ totalFailed += failed;
1250
+
1251
+ log(`✅ Batch ${i + 1}/${chunks.length}: ${success} success, ${failed} failed`);
1252
+ } catch (e) {
1253
+ log(`❌ Batch ${i + 1}/${chunks.length} failed: ${e.message}`);
1254
+ totalFailed += chunk.length;
1255
+ }
1256
+ }
1257
+
1258
+ log(`✅ Total: ${totalSuccess} success, ${totalFailed} failed (${chunks.length} batch(es))`);
1259
+
1260
+ // Save cursor AFTER successful upload — prevents reprocessing on next stop
1261
+ // Only save if all events succeeded (partial failures should retry next time)
1262
+ if (cursor && totalFailed === 0) {
1263
+ await saveStopCursor(session_id, cursor, log);
1264
+ } else if (cursor && totalFailed > 0) {
1265
+ log(`⚠️ Skipping cursor save due to ${totalFailed} failed events (will retry next stop)`);
1266
+ }
1267
+ } else {
1268
+ log('No events to log from transcript');
1269
+ // No events but cursor advanced (all filtered) — safe to save
1270
+ if (cursor) {
1271
+ await saveStopCursor(session_id, cursor, log);
1272
+ }
597
1273
  }
598
1274
  } catch (e) {
599
- log(`Failed to log assistant response after ${MAX_RETRIES} retries: ${e.message}`);
1275
+ log(`Failed to batch log events after ${MAX_RETRIES} retries: ${e.message}`);
600
1276
  // Don't fail the hook - continue with other functionality
601
1277
  }
602
1278
  } else {
603
- log('Skipping assistant response log (stop_hook_active=true)');
1279
+ const reason = stop_hook_active
1280
+ ? 'stop_hook_active=true'
1281
+ : !STOP_FULL_INGEST_ENABLED
1282
+ ? 'full ingest disabled'
1283
+ : 'timeline probe unhealthy';
1284
+ log(`Skipping batch event log (${reason})`);
604
1285
  }
605
1286
 
606
1287
  // 2. Store full transcript for session (new feature: PRD-0011 Phase 2)
607
1288
  // Only store if not in a recursive stop hook call
608
- if (!stop_hook_active) {
1289
+ if (!stop_hook_active && !skipHeavyUpload) {
609
1290
  try {
610
1291
  const transcriptData = await extractFullTranscript(transcript_path, log);
611
1292
 
@@ -630,49 +1311,24 @@ const extractSystemMessages = async (transcriptPath, log) => {
630
1311
  // Reliability: Log but don't fail hook if transcript storage fails
631
1312
  log(`Failed to store full transcript: ${e.message}`);
632
1313
  }
1314
+ } else {
1315
+ const reason = !STOP_FULL_INGEST_ENABLED ? 'full ingest disabled' : 'timeline probe unhealthy';
1316
+ log(`Skipping transcript storage because ${reason}`);
633
1317
  }
634
1318
 
635
- // 3. Extract and log system messages to timeline (compact summaries, thinking blocks, tool results)
636
- if (!stop_hook_active) {
637
- try {
638
- const systemMessages = await extractSystemMessages(transcript_path, log);
639
- const failedLogs = [];
640
-
641
- for (const msg of systemMessages) {
642
- try {
643
- await fetchJsonWithRetry(`${RELAY_API_URL}/api/timeline`, {
644
- method: 'POST',
645
- headers: {
646
- 'Content-Type': 'application/json',
647
- 'Authorization': `Bearer ${RELAY_API_KEY}`
648
- },
649
- body: JSON.stringify({
650
- session_id,
651
- type: msg.type,
652
- data: msg.data
653
- })
654
- }, log);
655
- log(`Logged ${msg.type} to timeline`);
656
- } catch (e) {
657
- failedLogs.push({ type: msg.type, error: e.message });
658
- log(`Failed to log ${msg.type}: ${e.message}`);
659
- }
660
- }
661
-
662
- // Report aggregate failures for visibility
663
- if (failedLogs.length > 0) {
664
- log(`⚠️ Failed to log ${failedLogs.length}/${systemMessages.length} system messages to timeline`);
665
- } else if (systemMessages.length > 0) {
666
- log(`✓ Successfully logged all ${systemMessages.length} system messages to timeline`);
667
- }
668
- } catch (e) {
669
- log(`Failed to extract system messages: ${e.message}`);
670
- }
671
- }
1319
+ // 3. Individual system message logging REMOVED
1320
+ // Now handled by batch event ingestion in step 1 above
1321
+ // Previously logged thinking blocks, tool results, etc. individually which caused:
1322
+ // - 27K+ tool_result events
1323
+ // - 21K+ thinking events
1324
+ // - 99.75% data loss due to mech-storage issues
1325
+ //
1326
+ // New approach: Single batch request with all events parsed from transcript
1327
+ // See: parseTranscriptToEvents() function above
672
1328
 
673
1329
  // 4. Check for pending messages from mobile app (existing functionality)
674
1330
  try {
675
- const res = await fetch(`${RELAY_API_URL}/api/messages/pending?session_id=${encodeURIComponent(session_id)}`, {
1331
+ const res = await fetchWithTimeout(`${RELAY_API_URL}/api/messages/pending?session_id=${encodeURIComponent(session_id)}`, {
676
1332
  headers: { 'Authorization': `Bearer ${RELAY_API_KEY}` }
677
1333
  });
678
1334
  if (res.ok) {
@@ -680,19 +1336,16 @@ const extractSystemMessages = async (transcriptPath, log) => {
680
1336
  if (msg && msg.id && msg.text) {
681
1337
  log(`Found pending message: ${String(msg.text).slice(0, 50)}...`);
682
1338
  try {
683
- await fetch(`${RELAY_API_URL}/api/messages/${msg.id}/ack`, {
1339
+ await fetchWithTimeout(`${RELAY_API_URL}/api/messages/${msg.id}/ack`, {
684
1340
  method: 'POST',
685
1341
  headers: { 'Authorization': `Bearer ${RELAY_API_KEY}` }
686
1342
  });
687
1343
  } catch {}
688
1344
 
689
- const out = {
690
- decision: 'block',
691
- reason: msg.text,
692
- hookSpecificOutput: { hookEventName: 'Stop' },
693
- suppressOutput: true
694
- };
695
- stdout.write(JSON.stringify(out));
1345
+ // Stop hook cannot inject messages via decision/reason - that's not supported
1346
+ // Mobile messages should be handled by a polling mechanism or different hook
1347
+ log(`Acknowledged pending message (mobile message injection not supported in stop hook)`);
1348
+ // Just exit cleanly without JSON output
696
1349
  return exit(0);
697
1350
  }
698
1351
  }