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.
- package/.claude/hooks/permission_request.mjs +326 -59
- package/.claude/hooks/post_tool_use.mjs +90 -0
- package/.claude/hooks/pre_tool_use.mjs +212 -293
- package/.claude/hooks/session-register.mjs +89 -104
- package/.claude/hooks/session_end.mjs +41 -42
- package/.claude/hooks/session_start.mjs +45 -60
- package/.claude/hooks/stop.mjs +752 -99
- package/.claude/hooks/user_prompt_submit.mjs +26 -3
- package/lib/cli/daemon-commands.js +1 -1
- package/lib/cli/teleport-commands.js +469 -0
- package/lib/daemon/daemon-v2.js +104 -0
- package/lib/daemon/lifecycle.js +56 -171
- package/lib/daemon/services/index.js +3 -0
- package/lib/daemon/services/polling-service.js +173 -0
- package/lib/daemon/services/queue-service.js +318 -0
- package/lib/daemon/services/session-service.js +115 -0
- package/lib/daemon/state.js +35 -0
- package/lib/daemon/task-executor-v2.js +413 -0
- package/lib/daemon/task-executor.js +270 -96
- package/lib/daemon/teleportation-daemon.js +709 -126
- package/lib/daemon/timeline-analyzer.js +215 -0
- package/lib/daemon/transcript-ingestion.js +696 -0
- package/lib/daemon/utils.js +91 -0
- package/lib/install/installer.js +184 -20
- package/lib/install/uhr-installer.js +136 -0
- package/lib/remote/providers/base-provider.js +46 -0
- package/lib/remote/providers/daytona-provider.js +58 -0
- package/lib/remote/providers/provider-factory.js +90 -19
- package/lib/remote/providers/sprites-provider.js +711 -0
- package/lib/teleport/exporters/claude-exporter.js +302 -0
- package/lib/teleport/exporters/gemini-exporter.js +307 -0
- package/lib/teleport/exporters/index.js +93 -0
- package/lib/teleport/exporters/interface.js +153 -0
- package/lib/teleport/fork-tracker.js +415 -0
- package/lib/teleport/git-committer.js +337 -0
- package/lib/teleport/index.js +48 -0
- package/lib/teleport/manager.js +620 -0
- package/lib/teleport/session-capture.js +282 -0
- package/package.json +9 -5
- package/teleportation-cli.cjs +488 -453
- package/.claude/hooks/heartbeat.mjs +0 -396
- package/lib/daemon/pid-manager.js +0 -183
package/.claude/hooks/stop.mjs
CHANGED
|
@@ -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
|
|
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
|
-
*
|
|
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
|
|
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
|
|
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(
|
|
1048
|
+
log(`🔧 Built tool_use lookup with ${toolUseLookup.size} entries`);
|
|
426
1049
|
|
|
427
|
-
|
|
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
|
-
|
|
507
|
-
|
|
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
|
-
|
|
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
|
-
|
|
567
|
-
if (!stop_hook_active) {
|
|
1212
|
+
if (!stop_hook_active && !skipHeavyUpload) {
|
|
568
1213
|
try {
|
|
569
|
-
const
|
|
1214
|
+
const { events, cursor } = await parseTranscriptToEvents(transcript_path, session_id, RELAY_API_URL, RELAY_API_KEY, log);
|
|
570
1215
|
|
|
571
|
-
if (
|
|
572
|
-
|
|
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
|
-
|
|
575
|
-
|
|
576
|
-
|
|
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
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
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
|
}
|