teleportation-cli 1.0.2 → 1.1.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.
File without changes
@@ -101,15 +101,15 @@ const fetchJson = async (url, opts) => {
101
101
 
102
102
  const raw = await readStdin();
103
103
  let input;
104
- try {
105
- input = JSON.parse(raw || '{}');
104
+ try {
105
+ input = JSON.parse(raw || '{}');
106
106
  } catch (e) {
107
107
  log(`ERROR: Invalid JSON: ${e.message}`);
108
108
  return exit(0);
109
109
  }
110
110
 
111
- const { session_id, tool_name, tool_input, cwd } = input || {};
112
- log(`Session: ${session_id}, Tool: ${tool_name}, CWD: ${cwd}`);
111
+ const { session_id, tool_name, tool_input, cwd, tool_use_id, transcript_path } = input || {};
112
+ log(`Session: ${session_id}, Tool: ${tool_name}, CWD: ${cwd}, tool_use_id: ${tool_use_id || 'none'}`);
113
113
 
114
114
  // Validate session_id
115
115
  if (!isValidSessionId(session_id)) {
@@ -157,15 +157,10 @@ const fetchJson = async (url, opts) => {
157
157
  log(`Could not check away status: ${e.message} - using fail-safe: ${failSafe}`);
158
158
  }
159
159
 
160
- // If user is NOT away, don't create an approval - let Claude Code handle it locally
161
- // This prevents stale approvals from appearing in the mobile UI
162
- if (!isAway) {
163
- log('User is present - letting Claude Code show permission dialog (no remote approval created)');
164
- return exit(0);
165
- }
166
-
167
- // User is AWAY - create remote approval and poll for decision
168
- log(`Creating remote approval for ${tool_name}...`);
160
+ // ALWAYS create remote approval so it's visible in mobile UI
161
+ // If user is present, we'll also let Claude Code show its native prompt
162
+ // The PostToolUse hook will invalidate the approval if user approves locally
163
+ log(`Creating remote approval for ${tool_name} (away=${isAway})...`);
169
164
 
170
165
  // Extract session metadata (project name, hostname, branch, etc.)
171
166
  let meta = {};
@@ -207,7 +202,48 @@ const fetchJson = async (url, opts) => {
207
202
  log(`Warning: Failed to invalidate old approvals: ${e.message}`);
208
203
  }
209
204
 
210
- // Create approval request with metadata
205
+ // Build conversation context (PRD-0013 Phase 1)
206
+ let conversation_context = null;
207
+ if (transcript_path) {
208
+ try {
209
+ const transcriptData = await readFile(transcript_path, 'utf-8');
210
+ const transcript = JSON.parse(transcriptData);
211
+
212
+ // Find last user message
213
+ let lastUserMessage = null;
214
+ for (let i = transcript.length - 1; i >= 0; i--) {
215
+ if (transcript[i]?.role === 'user' && transcript[i]?.content) {
216
+ // Extract text from content array
217
+ const textContent = Array.isArray(transcript[i].content)
218
+ ? transcript[i].content.find(c => c?.type === 'text')?.text
219
+ : transcript[i].content;
220
+ if (textContent) {
221
+ lastUserMessage = textContent;
222
+ break;
223
+ }
224
+ }
225
+ }
226
+
227
+ if (lastUserMessage) {
228
+ conversation_context = {
229
+ user_last_message: lastUserMessage.slice(0, 500), // Truncate to 500 chars
230
+ claude_reasoning: null, // Phase 2: extract Claude's reasoning
231
+ timestamp: Date.now()
232
+ };
233
+ log(`Extracted conversation context: user_message="${lastUserMessage.slice(0, 50)}..."`);
234
+ }
235
+ } catch (e) {
236
+ log(`Warning: Failed to extract conversation context: ${e.message}`);
237
+ }
238
+ }
239
+
240
+ // Fallback: Generate tool_use_id if not provided (defensive programming)
241
+ const effective_tool_use_id = tool_use_id || `tool_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
242
+ if (!tool_use_id) {
243
+ log(`Warning: tool_use_id not provided, generated fallback: ${effective_tool_use_id}`);
244
+ }
245
+
246
+ // Create approval request with metadata and context (PRD-0013)
211
247
  let approvalId;
212
248
  try {
213
249
  const created = await fetchJson(`${RELAY_API_URL}/api/approvals`, {
@@ -216,17 +252,35 @@ const fetchJson = async (url, opts) => {
216
252
  'Content-Type': 'application/json',
217
253
  'Authorization': `Bearer ${RELAY_API_KEY}`
218
254
  },
219
- body: JSON.stringify({ session_id, tool_name, tool_input, meta })
255
+ body: JSON.stringify({
256
+ session_id,
257
+ tool_name,
258
+ tool_input,
259
+ meta,
260
+ tool_use_id: effective_tool_use_id,
261
+ conversation_context,
262
+ task_description: null, // Phase 2: infer multi-step tasks
263
+ transcript_excerpt: null // Phase 2: include recent messages
264
+ })
220
265
  });
221
266
  approvalId = created.id;
222
- log(`Approval created: ${approvalId}`);
267
+ log(`Approval created: ${approvalId} (tool_use_id: ${effective_tool_use_id})`);
223
268
  } catch (e) {
224
269
  log(`ERROR creating approval: ${e.message}`);
225
270
  return exit(0); // Let Claude Code handle it
226
271
  }
227
272
 
228
- // Poll for remote approval decision
229
- log('Polling for remote approval decision...');
273
+ // If user is PRESENT: return immediately and let Claude Code show its native prompt
274
+ // The approval is now visible in mobile UI. If user approves locally:
275
+ // - PostToolUse will invalidate the approval
276
+ // - Mobile UI will filter it out (any timeline event after approval = stale)
277
+ if (!isAway) {
278
+ log(`User is present - approval ${approvalId} created for mobile visibility, letting Claude Code handle locally`);
279
+ return exit(0);
280
+ }
281
+
282
+ // User is AWAY - poll for remote approval decision
283
+ log(`User is away - polling for remote approval decision (timeout=${FAST_APPROVAL_TIMEOUT_MS}ms)...`);
230
284
  let consecutiveFailures = 0;
231
285
  const MAX_CONSECUTIVE_FAILURES = 5;
232
286
 
@@ -241,7 +295,6 @@ const fetchJson = async (url, opts) => {
241
295
  if (status.status === 'allowed') {
242
296
  log('Remote approval: ALLOWED');
243
297
  // Acknowledge on the fast-path to prevent duplicate daemon execution.
244
- // CRITICAL: If ACK fails, we must NOT proceed with local execution to avoid duplicates.
245
298
  try {
246
299
  const ackRes = await fetch(`${RELAY_API_URL}/api/approvals/${approvalId}/ack`, {
247
300
  method: 'POST',
@@ -252,7 +305,6 @@ const fetchJson = async (url, opts) => {
252
305
  }
253
306
  } catch (e) {
254
307
  log(`ERROR: Failed to ack approval ${approvalId}: ${e.message} - aborting to prevent duplicate execution`);
255
- // Return early without allowing - let daemon handle it instead
256
308
  const out = {
257
309
  hookSpecificOutput: {
258
310
  hookEventName: 'PermissionRequest',
@@ -306,8 +358,7 @@ const fetchJson = async (url, opts) => {
306
358
  await sleep(POLLING_INTERVAL_MS);
307
359
  }
308
360
 
309
- // Fast-path timeout: do NOT fall back to local permission prompts while user is away.
310
- // Instead, hand off to daemon (background) and inform Claude.
361
+ // Timeout while user is away - hand off to daemon
311
362
  log('Fast-path approval timeout - handing off to daemon');
312
363
 
313
364
  try {
@@ -327,7 +378,7 @@ const fetchJson = async (url, opts) => {
327
378
  hookSpecificOutput: {
328
379
  hookEventName: 'PermissionRequest',
329
380
  permissionDecision: 'deny',
330
- permissionDecisionReason: '⏳ Teleportation: waiting for mobile approval timed out. This request was handed off to the daemon and will run in the background once approved. You will see a Daemon Work Update here when it completes.'
381
+ permissionDecisionReason: '⏳ Teleportation: waiting for mobile approval timed out. This request was handed off to the daemon and will run in the background once approved.'
331
382
  },
332
383
  suppressOutput: true
333
384
  };
@@ -81,7 +81,9 @@ const fetchJson = async (url, opts) => {
81
81
  }
82
82
 
83
83
  // Clear any pending approvals for this session since the tool executed successfully
84
- // This handles the case where Claude Code auto-approved the tool
84
+ // This handles the case where Claude Code auto-approved the tool locally
85
+ // We set handled_location: 'local' to explicitly mark these as locally-handled
86
+ // (not approved via mobile UI) - this prevents race conditions in the frontend
85
87
  try {
86
88
  await fetchJson(`${RELAY_API_URL}/api/approvals/invalidate`, {
87
89
  method: 'POST',
@@ -91,7 +93,8 @@ const fetchJson = async (url, opts) => {
91
93
  },
92
94
  body: JSON.stringify({
93
95
  session_id,
94
- reason: `Tool ${tool_name} executed (auto-approved by Claude Code)`
96
+ reason: `Tool ${tool_name} executed (auto-approved by Claude Code)`,
97
+ handled_location: 'local'
95
98
  })
96
99
  });
97
100
  log(`Cleared pending approvals after tool execution: ${tool_name}`);
@@ -144,6 +144,7 @@ const fetchJson = async (url, opts) => {
144
144
  // can approve/deny from their mobile device. This enables true remote control.
145
145
 
146
146
  // Helper: Format daemon work results into a human-readable message
147
+ // Enhanced for PRD-0013 Phase 1: Context Preservation
147
148
  const formatDaemonUpdate = (results) => {
148
149
  if (!results || results.length === 0) return '';
149
150
 
@@ -151,12 +152,53 @@ const fetchJson = async (url, opts) => {
151
152
  const hasBrowserTasks = results.some(r => {
152
153
  const toolName = (r.tool_name || '').toLowerCase();
153
154
  const command = (r.command || '').toLowerCase();
154
- return toolName.includes('browser') || toolName.includes('mcp') ||
155
+ return toolName.includes('browser') || toolName.includes('mcp') ||
155
156
  command.includes('browser') || command.includes('mcp');
156
157
  });
157
-
158
+
158
159
  const taskType = hasBrowserTasks ? 'browser/interactive task' : 'task';
159
- const header = `🤖 **Daemon Work Update** (${results.length} ${taskType}${results.length > 1 ? 's' : ''} completed while you were away)\n\n`;
160
+ let header = `🤖 **Daemon Work Update** (${results.length} ${taskType}${results.length > 1 ? 's' : ''} completed while you were away)\n\n`;
161
+
162
+ // PRD-0013 Phase 1: Show original context if available
163
+ // This helps Claude remember what the user originally requested
164
+ const firstResult = results[0];
165
+ if (firstResult?.original_context?.user_last_message) {
166
+ const context = firstResult.original_context;
167
+ const timeElapsed = context.timestamp
168
+ ? Date.now() - context.timestamp
169
+ : null;
170
+
171
+ // Format time elapsed
172
+ let timeAgo = '';
173
+ if (timeElapsed) {
174
+ const seconds = Math.floor(timeElapsed / 1000);
175
+ const minutes = Math.floor(seconds / 60);
176
+ const hours = Math.floor(minutes / 60);
177
+
178
+ if (hours > 0) {
179
+ timeAgo = `${hours} hour${hours > 1 ? 's' : ''} ago`;
180
+ } else if (minutes > 0) {
181
+ timeAgo = `${minutes} minute${minutes > 1 ? 's' : ''} ago`;
182
+ } else {
183
+ timeAgo = `${seconds} second${seconds > 1 ? 's' : ''} ago`;
184
+ }
185
+ }
186
+
187
+ header += `**Original Request**${timeAgo ? ` (${timeAgo})` : ''}:\n`;
188
+ header += `> ${context.user_last_message}\n\n`;
189
+
190
+ // Show original tool_use_id if available (helps with debugging/tracing)
191
+ if (firstResult.original_tool_use_id) {
192
+ header += `_Results for tool ID: ${firstResult.original_tool_use_id}_\n\n`;
193
+ }
194
+
195
+ // PRD-0013 Phase 2: Show task description and next step suggestion if available
196
+ // For Phase 1, task_description will be null, so this won't appear yet
197
+ if (context.task_description) {
198
+ header += `**Next Step:**\n`;
199
+ header += `${context.task_description}\n\n`;
200
+ }
201
+ }
160
202
 
161
203
  const formatOutput = (output, toolName) => {
162
204
  if (!output || output.trim() === '') return '(No output)';
@@ -26,8 +26,22 @@ const isValidSessionId = (id) => {
26
26
  return id && typeof id === 'string' && /^[a-zA-Z0-9-]+$/.test(id) && id.length >= 8;
27
27
  };
28
28
 
29
- // Max length for assistant response preview (characters)
29
+ /**
30
+ * Truncation length constants - intentionally different for different message types
31
+ *
32
+ * ASSISTANT_RESPONSE_MAX_LENGTH (2000 chars):
33
+ * - Used for assistant conversational responses
34
+ * - Longer because these are the primary output users want to see
35
+ * - Less frequent (typically 1 per user turn)
36
+ *
37
+ * MAX_SYSTEM_MESSAGE_LENGTH (1000 chars):
38
+ * - Used for system messages (compact summaries, thinking blocks, tool results)
39
+ * - Shorter because these are more frequent and supplementary
40
+ * - Thinking blocks can occur many times per response
41
+ * - Optimizes storage while maintaining useful context
42
+ */
30
43
  const ASSISTANT_RESPONSE_MAX_LENGTH = 2000;
44
+ const MAX_SYSTEM_MESSAGE_LENGTH = 1000;
31
45
 
32
46
  // Max size for full transcript (bytes) - 500KB
33
47
  const MAX_TRANSCRIPT_SIZE = 500 * 1024;
@@ -36,6 +50,51 @@ const MAX_TRANSCRIPT_SIZE = 500 * 1024;
36
50
  const MAX_RETRIES = 3;
37
51
  const RETRY_DELAY_MS = 1000;
38
52
 
53
+ /**
54
+ * Extract text content from a message in various formats
55
+ * Handles multiple message formats from Claude Code transcripts:
56
+ * - Direct string content
57
+ * - Content blocks array [{ type: 'text', text: '...' }]
58
+ * - Nested message.content (Claude Code format)
59
+ * - Legacy text/message fields
60
+ *
61
+ * @param {Object} msg - Message object from transcript
62
+ * @returns {string} Extracted text content, or empty string if none found
63
+ */
64
+ const extractMessageContent = (msg) => {
65
+ // Handle direct string content
66
+ if (typeof msg.content === 'string') {
67
+ return msg.content;
68
+ }
69
+
70
+ // Handle content blocks array format
71
+ if (Array.isArray(msg.content)) {
72
+ return msg.content
73
+ .filter(block => block.type === 'text' && block.text)
74
+ .map(block => block.text)
75
+ .join('\n\n');
76
+ }
77
+
78
+ // Handle nested message.content (Claude Code transcript format)
79
+ if (msg.message && typeof msg.message === 'object' && msg.message.content) {
80
+ if (typeof msg.message.content === 'string') {
81
+ return msg.message.content;
82
+ }
83
+ if (Array.isArray(msg.message.content)) {
84
+ return msg.message.content
85
+ .filter(block => block.type === 'text' && block.text)
86
+ .map(block => block.text)
87
+ .join('\n\n');
88
+ }
89
+ }
90
+
91
+ // Handle legacy formats
92
+ if (msg.text) return msg.text;
93
+ if (msg.message && typeof msg.message === 'string') return msg.message;
94
+
95
+ return '';
96
+ };
97
+
39
98
  /**
40
99
  * Fetch JSON with retry logic
41
100
  */
@@ -118,23 +177,8 @@ const extractLastAssistantMessage = async (transcriptPath, log) => {
118
177
  // Check for assistant role (various possible formats)
119
178
  const role = msg.role || msg.type || '';
120
179
  if (role === 'assistant' || role === 'model' || msg.isAssistant) {
121
- // Extract content (could be string or array of content blocks)
122
- let text = '';
123
-
124
- if (typeof msg.content === 'string') {
125
- text = msg.content;
126
- } else if (Array.isArray(msg.content)) {
127
- // Content blocks format: [{ type: 'text', text: '...' }, ...]
128
- text = msg.content
129
- .filter(block => block.type === 'text' && block.text)
130
- .map(block => block.text)
131
- .join('\n\n'); // Use double newline for paragraph separation
132
- } else if (msg.text) {
133
- text = msg.text;
134
- } else if (msg.message) {
135
- // Don't stringify objects - only use if it's a string
136
- text = typeof msg.message === 'string' ? msg.message : '';
137
- }
180
+ // Extract content using shared helper
181
+ const text = extractMessageContent(msg);
138
182
 
139
183
  if (text && text.trim()) {
140
184
  // Extract model if available
@@ -214,27 +258,24 @@ const extractFullTranscript = async (transcriptPath, log) => {
214
258
  let turnIndex = 0;
215
259
 
216
260
  for (const msg of transcript) {
261
+ // Skip system-generated messages (compact summaries, transcript-only messages)
262
+ if (msg.isCompactSummary || msg.isVisibleInTranscriptOnly) {
263
+ continue;
264
+ }
265
+
266
+ // Skip tool result messages (they appear as "user" but are system messages)
267
+ if (msg.message?.content?.[0]?.type === 'tool_result') {
268
+ continue;
269
+ }
270
+
217
271
  const role = msg.role || msg.type || '';
218
272
  const isAssistant = role === 'assistant' || role === 'model' || msg.isAssistant;
219
273
  const isUser = role === 'user' || role === 'human' || msg.isUser;
220
274
 
221
275
  if (!isAssistant && !isUser) continue;
222
276
 
223
- // Extract content
224
- let text = '';
225
- if (typeof msg.content === 'string') {
226
- text = msg.content;
227
- } else if (Array.isArray(msg.content)) {
228
- // Content blocks format: [{ type: 'text', text: '...' }, ...]
229
- text = msg.content
230
- .filter(block => block.type === 'text' && block.text)
231
- .map(block => block.text)
232
- .join('\n\n');
233
- } else if (msg.text) {
234
- text = msg.text;
235
- } else if (msg.message && typeof msg.message === 'string') {
236
- text = msg.message;
237
- }
277
+ // Extract content using shared helper
278
+ const text = extractMessageContent(msg);
238
279
 
239
280
  if (text && text.trim()) {
240
281
  messages.push({
@@ -325,6 +366,151 @@ const extractFullTranscript = async (transcriptPath, log) => {
325
366
  }
326
367
  };
327
368
 
369
+ /**
370
+ * 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
372
+ */
373
+ const extractSystemMessages = async (transcriptPath, log) => {
374
+ try {
375
+ if (!transcriptPath) {
376
+ log('No transcript_path provided for system messages');
377
+ return [];
378
+ }
379
+
380
+ let content;
381
+ try {
382
+ content = await readFile(transcriptPath, 'utf8');
383
+ } catch (e) {
384
+ log(`Error reading transcript for system messages: ${e.code || e.message}`);
385
+ return [];
386
+ }
387
+
388
+ let transcript;
389
+ try {
390
+ transcript = JSON.parse(content);
391
+ } catch (e) {
392
+ // Try JSONL format
393
+ const lines = content.trim().split('\n').filter(l => l.trim());
394
+ transcript = lines.map(line => {
395
+ try {
396
+ return JSON.parse(line);
397
+ } catch {
398
+ return null;
399
+ }
400
+ }).filter(Boolean);
401
+ }
402
+
403
+ if (!Array.isArray(transcript)) {
404
+ log(`Transcript is not an array for system messages`);
405
+ return [];
406
+ }
407
+
408
+ 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: {...} }
412
+ const toolUseLookup = new Map();
413
+ for (const msg of transcript) {
414
+ if (msg.message?.content && Array.isArray(msg.message.content)) {
415
+ for (const block of msg.message.content) {
416
+ if (block.type === 'tool_use' && block.id && block.name) {
417
+ toolUseLookup.set(block.id, {
418
+ name: block.name,
419
+ input: block.input || null
420
+ });
421
+ }
422
+ }
423
+ }
424
+ }
425
+ log(`Built tool_use lookup with ${toolUseLookup.size} entries`);
426
+
427
+ for (const msg of transcript) {
428
+ // Extract compact summaries
429
+ if (msg.isCompactSummary && msg.message?.content) {
430
+ const content = typeof msg.message.content === 'string'
431
+ ? msg.message.content
432
+ : JSON.stringify(msg.message.content);
433
+
434
+ const preview = content.length > MAX_SYSTEM_MESSAGE_LENGTH
435
+ ? content.slice(0, MAX_SYSTEM_MESSAGE_LENGTH) + '...'
436
+ : content;
437
+
438
+ systemMessages.push({
439
+ type: 'compact_summary',
440
+ data: {
441
+ summary: preview,
442
+ full_length: content.length,
443
+ truncated: content.length > MAX_SYSTEM_MESSAGE_LENGTH,
444
+ timestamp: msg.timestamp
445
+ }
446
+ });
447
+ }
448
+
449
+ // Extract thinking blocks
450
+ if (msg.message?.content && Array.isArray(msg.message.content)) {
451
+ for (const block of msg.message.content) {
452
+ if (block.type === 'thinking' && block.thinking) {
453
+ const preview = block.thinking.length > MAX_SYSTEM_MESSAGE_LENGTH
454
+ ? block.thinking.slice(0, MAX_SYSTEM_MESSAGE_LENGTH) + '...'
455
+ : block.thinking;
456
+
457
+ systemMessages.push({
458
+ type: 'thinking',
459
+ data: {
460
+ thinking: preview,
461
+ full_length: block.thinking.length,
462
+ truncated: block.thinking.length > MAX_SYSTEM_MESSAGE_LENGTH,
463
+ timestamp: msg.timestamp,
464
+ signature: block.signature ? '✓ verified' : null
465
+ }
466
+ });
467
+ }
468
+ }
469
+ }
470
+
471
+ // Extract tool results
472
+ if (msg.message?.content && Array.isArray(msg.message.content)) {
473
+ for (const block of msg.message.content) {
474
+ if (block.type === 'tool_result' && block.content) {
475
+ const contentStr = Array.isArray(block.content)
476
+ ? block.content.map(c => c.text || '').join('\n')
477
+ : String(block.content);
478
+
479
+ const preview = contentStr.length > MAX_SYSTEM_MESSAGE_LENGTH
480
+ ? contentStr.slice(0, MAX_SYSTEM_MESSAGE_LENGTH) + '...'
481
+ : contentStr;
482
+
483
+ // Look up the tool details from the corresponding tool_use block
484
+ const toolDetails = block.tool_use_id ? toolUseLookup.get(block.tool_use_id) : null;
485
+ const toolName = toolDetails?.name || null;
486
+ const toolInput = toolDetails?.input || null;
487
+
488
+ systemMessages.push({
489
+ type: 'tool_result',
490
+ data: {
491
+ tool_use_id: block.tool_use_id,
492
+ tool_name: toolName, // Include resolved tool name
493
+ tool_input: toolInput, // Include tool input (file_path, command, etc.)
494
+ result: preview,
495
+ full_length: contentStr.length,
496
+ truncated: contentStr.length > MAX_SYSTEM_MESSAGE_LENGTH,
497
+ timestamp: msg.timestamp,
498
+ is_error: block.is_error || false
499
+ }
500
+ });
501
+ }
502
+ }
503
+ }
504
+ }
505
+
506
+ log(`Extracted ${systemMessages.length} system messages (summaries, thinking, tool results)`);
507
+ return systemMessages;
508
+ } catch (e) {
509
+ log(`Error extracting system messages: ${e.message}`);
510
+ return [];
511
+ }
512
+ };
513
+
328
514
  (async () => {
329
515
  const hookLogFile = env.TELEPORTATION_HOOK_LOG || '/tmp/teleportation-hook.log';
330
516
  const log = (msg) => {
@@ -446,7 +632,45 @@ const extractFullTranscript = async (transcriptPath, log) => {
446
632
  }
447
633
  }
448
634
 
449
- // 3. Check for pending messages from mobile app (existing functionality)
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
+ }
672
+
673
+ // 4. Check for pending messages from mobile app (existing functionality)
450
674
  try {
451
675
  const res = await fetch(`${RELAY_API_URL}/api/messages/pending?session_id=${encodeURIComponent(session_id)}`, {
452
676
  headers: { 'Authorization': `Bearer ${RELAY_API_KEY}` }
File without changes