teleportation-cli 1.3.0 → 1.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/hooks/permission_request.mjs +11 -4
- package/.claude/hooks/post_tool_use.mjs +1 -3
- package/.claude/hooks/pre_tool_use.mjs +255 -289
- package/.claude/hooks/session-register.mjs +44 -29
- package/.claude/hooks/session_end.mjs +29 -3
- package/.claude/hooks/session_start.mjs +57 -1
- package/.claude/hooks/stop.mjs +245 -242
- package/.claude/hooks/user_prompt_submit.mjs +1 -3
- package/lib/config/manager.js +45 -1
- package/lib/daemon/session-file-registry.js +207 -0
- package/lib/daemon/task-executor-v2.js +239 -29
- package/lib/daemon/teleportation-daemon.js +469 -29
- package/lib/daemon/timeline-analyzer.js +19 -13
- package/lib/daemon/transcript-ingestion.js +310 -51
- package/lib/daemon/utils.js +0 -9
- package/lib/install/installer.js +126 -3
- package/lib/install/uhr-installer.js +32 -18
- package/lib/intelligence/benchmark.js +240 -0
- package/lib/intelligence/index.js +29 -0
- package/lib/intelligence/rebuild-policies.js +169 -0
- package/lib/intelligence/schema.js +259 -0
- package/lib/intelligence/transcript-mine.js +339 -0
- package/lib/session/metadata.js +23 -5
- package/lib/transcript-sync/lifecycle.js +88 -0
- package/lib/transcript-sync/repo-context.js +45 -0
- package/lib/transcript-sync/worker.js +233 -0
- package/lib/utils/log-sanitizer.js +65 -0
- package/package.json +2 -1
- package/scripts/sync-transcripts.sh +272 -0
- package/teleportation-cli.cjs +295 -4
package/.claude/hooks/stop.mjs
CHANGED
|
@@ -75,6 +75,9 @@ function basicSanitizer(text) {
|
|
|
75
75
|
* @param {Function} [log] - Optional logging function for warnings
|
|
76
76
|
* @returns {Promise<Function>} - The sanitizer function
|
|
77
77
|
*/
|
|
78
|
+
// Shared sanitizeEventData from lib/utils/log-sanitizer.js (loaded alongside sanitizeForLog)
|
|
79
|
+
let sanitizeEventDataFn = null;
|
|
80
|
+
|
|
78
81
|
async function getSanitizer(log = () => {}) {
|
|
79
82
|
if (sanitizeForLog) return sanitizeForLog;
|
|
80
83
|
|
|
@@ -90,6 +93,7 @@ async function getSanitizer(log = () => {}) {
|
|
|
90
93
|
try {
|
|
91
94
|
const mod = await import(path);
|
|
92
95
|
sanitizeForLog = mod.sanitizeForLog;
|
|
96
|
+
sanitizeEventDataFn = mod.sanitizeEventData || null;
|
|
93
97
|
if (sanitizeForLog) return sanitizeForLog;
|
|
94
98
|
} catch (err) {
|
|
95
99
|
// Log import failures for debugging (only in DEBUG mode to avoid noise)
|
|
@@ -127,9 +131,10 @@ const isValidSessionId = (id) => {
|
|
|
127
131
|
/**
|
|
128
132
|
* Truncation length constants - intentionally different for different message types
|
|
129
133
|
*
|
|
130
|
-
* ASSISTANT_RESPONSE_MAX_LENGTH (
|
|
134
|
+
* ASSISTANT_RESPONSE_MAX_LENGTH (50000 chars / ~50KB):
|
|
131
135
|
* - Used for assistant conversational responses
|
|
132
|
-
* -
|
|
136
|
+
* - Matches the relay API's MAX_EVENT_DATA_SIZE limit per field
|
|
137
|
+
* - Responses are primary output users want to read in full
|
|
133
138
|
* - Less frequent (typically 1 per user turn)
|
|
134
139
|
*
|
|
135
140
|
* MAX_SYSTEM_MESSAGE_LENGTH (1000 chars):
|
|
@@ -138,7 +143,7 @@ const isValidSessionId = (id) => {
|
|
|
138
143
|
* - Thinking blocks can occur many times per response
|
|
139
144
|
* - Optimizes storage while maintaining useful context
|
|
140
145
|
*/
|
|
141
|
-
const ASSISTANT_RESPONSE_MAX_LENGTH =
|
|
146
|
+
const ASSISTANT_RESPONSE_MAX_LENGTH = 50000;
|
|
142
147
|
const MAX_SYSTEM_MESSAGE_LENGTH = 1000;
|
|
143
148
|
|
|
144
149
|
// Max size for full transcript (bytes) - 500KB
|
|
@@ -268,42 +273,49 @@ const extractMessageContent = (msg) => {
|
|
|
268
273
|
|
|
269
274
|
/**
|
|
270
275
|
* Sanitize event data to remove sensitive information before storing in timeline.
|
|
271
|
-
*
|
|
276
|
+
* Delegates to the shared sanitizeEventData from lib/utils/log-sanitizer.js when
|
|
277
|
+
* available, otherwise falls back to a local implementation.
|
|
272
278
|
*
|
|
273
279
|
* @param {Object} eventData - The event data object
|
|
274
280
|
* @param {Function} sanitizer - The sanitization function
|
|
275
281
|
* @returns {Object} - Sanitized event data
|
|
276
282
|
*/
|
|
277
283
|
const sanitizeEventData = (eventData, sanitizer) => {
|
|
284
|
+
// Use shared module if loaded by getSanitizer()
|
|
285
|
+
if (sanitizeEventDataFn) {
|
|
286
|
+
return sanitizeEventDataFn(eventData, sanitizer);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Fallback: local implementation (kept as safety net if shared module unavailable)
|
|
278
290
|
if (!eventData || typeof eventData !== 'object') return eventData;
|
|
279
291
|
if (!sanitizer) return eventData;
|
|
280
292
|
|
|
281
293
|
const sanitized = { ...eventData };
|
|
282
|
-
|
|
283
|
-
// Sanitize known text fields
|
|
284
|
-
const textFields = ['message', 'summary', 'thinking', 'result', 'error'];
|
|
294
|
+
const textFields = ['message', 'summary', 'thinking', 'result', 'error', 'stdout', 'stderr', 'content'];
|
|
285
295
|
for (const field of textFields) {
|
|
286
296
|
if (typeof sanitized[field] === 'string') {
|
|
287
297
|
sanitized[field] = sanitizer(sanitized[field]);
|
|
288
298
|
}
|
|
289
299
|
}
|
|
290
300
|
|
|
291
|
-
// Sanitize tool_input if it's a string or contains sensitive data
|
|
292
301
|
if (sanitized.tool_input) {
|
|
293
302
|
if (typeof sanitized.tool_input === 'string') {
|
|
294
303
|
sanitized.tool_input = sanitizer(sanitized.tool_input);
|
|
295
304
|
} else if (typeof sanitized.tool_input === 'object') {
|
|
296
|
-
//
|
|
297
|
-
const
|
|
298
|
-
const
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
} catch {
|
|
303
|
-
// If parsing fails, use sanitized string
|
|
304
|
-
sanitized.tool_input = sanitizedStr;
|
|
305
|
+
// SYNC_WITH: lib/utils/log-sanitizer.js SENSITIVE_KEY_RE
|
|
306
|
+
const sensitiveKeyRe = /^(password|passwd|pwd|secret|token|api[_-]?key|authorization|credentials?)$/i;
|
|
307
|
+
const sanitizeValues = (obj, parentKey) => {
|
|
308
|
+
if (typeof obj === 'string') {
|
|
309
|
+
if (parentKey && sensitiveKeyRe.test(parentKey)) return '***';
|
|
310
|
+
return sanitizer(obj);
|
|
305
311
|
}
|
|
306
|
-
|
|
312
|
+
if (typeof obj !== 'object' || obj === null) return obj;
|
|
313
|
+
if (Array.isArray(obj)) return obj.map(item => sanitizeValues(item));
|
|
314
|
+
const result = {};
|
|
315
|
+
for (const [k, v] of Object.entries(obj)) result[k] = sanitizeValues(v, k);
|
|
316
|
+
return result;
|
|
317
|
+
};
|
|
318
|
+
sanitized.tool_input = sanitizeValues(sanitized.tool_input);
|
|
307
319
|
}
|
|
308
320
|
}
|
|
309
321
|
|
|
@@ -479,58 +491,65 @@ const extractLastAssistantMessage = async (transcriptPath, log) => {
|
|
|
479
491
|
};
|
|
480
492
|
|
|
481
493
|
/**
|
|
482
|
-
*
|
|
483
|
-
*
|
|
484
|
-
*
|
|
494
|
+
* Read and parse transcript file once for sharing across multiple consumers.
|
|
495
|
+
* Handles JSON array and JSONL formats.
|
|
496
|
+
*
|
|
497
|
+
* @param {string} transcriptPath - Path to transcript file
|
|
498
|
+
* @param {Function} log - Logging function
|
|
499
|
+
* @returns {Promise<Array|null>} - Parsed transcript array, or null on failure
|
|
485
500
|
*/
|
|
486
|
-
const
|
|
501
|
+
const readAndParseTranscript = async (transcriptPath, log) => {
|
|
502
|
+
if (!transcriptPath) {
|
|
503
|
+
log('No transcript_path provided');
|
|
504
|
+
return null;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
let content;
|
|
487
508
|
try {
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
509
|
+
content = await readFile(transcriptPath, 'utf8');
|
|
510
|
+
} catch (e) {
|
|
511
|
+
if (e.code === 'ENOENT') {
|
|
512
|
+
log(`Transcript file not found: ${transcriptPath}`);
|
|
513
|
+
} else if (e.code === 'EACCES' || e.code === 'EPERM') {
|
|
514
|
+
log(`Permission denied reading transcript: ${transcriptPath}`);
|
|
515
|
+
} else {
|
|
516
|
+
log(`Error reading transcript: ${e.code || e.message}`);
|
|
491
517
|
}
|
|
518
|
+
return null;
|
|
519
|
+
}
|
|
492
520
|
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
return null;
|
|
505
|
-
}
|
|
506
|
-
log(`Error reading transcript for full extraction: ${e.code || e.message}`);
|
|
507
|
-
return null;
|
|
508
|
-
}
|
|
521
|
+
let transcript;
|
|
522
|
+
try {
|
|
523
|
+
transcript = JSON.parse(content);
|
|
524
|
+
log('Parsed transcript as JSON array');
|
|
525
|
+
} catch (e) {
|
|
526
|
+
const lines = content.trim().split('\n').filter(l => l.trim());
|
|
527
|
+
transcript = lines.map(line => {
|
|
528
|
+
try { return JSON.parse(line); } catch { return null; }
|
|
529
|
+
}).filter(Boolean);
|
|
530
|
+
log(`Parsed transcript as JSONL (${transcript.length} messages)`);
|
|
531
|
+
}
|
|
509
532
|
|
|
510
|
-
|
|
533
|
+
if (!Array.isArray(transcript)) {
|
|
534
|
+
log(`Transcript is not an array: ${typeof transcript}`);
|
|
535
|
+
return null;
|
|
536
|
+
}
|
|
511
537
|
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
transcript = JSON.parse(content);
|
|
515
|
-
log('Parsed transcript as JSON array for full extraction');
|
|
516
|
-
} catch (e) {
|
|
517
|
-
// Try parsing as JSONL (newline-delimited JSON)
|
|
518
|
-
log('JSON parse failed for full extraction, trying JSONL format');
|
|
519
|
-
const lines = content.trim().split('\n').filter(l => l.trim());
|
|
520
|
-
transcript = lines.map(line => {
|
|
521
|
-
try {
|
|
522
|
-
return JSON.parse(line);
|
|
523
|
-
} catch {
|
|
524
|
-
return null;
|
|
525
|
-
}
|
|
526
|
-
}).filter(Boolean);
|
|
527
|
-
log(`Parsed transcript as JSONL (${transcript.length} messages) for full extraction`);
|
|
528
|
-
}
|
|
538
|
+
return transcript;
|
|
539
|
+
};
|
|
529
540
|
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
541
|
+
/**
|
|
542
|
+
* Extract the full conversation transcript from the transcript file
|
|
543
|
+
* Returns all user and assistant messages with turn_index
|
|
544
|
+
* @param {string} transcriptPath - Path to transcript file
|
|
545
|
+
* @param {Function} log - Logging function
|
|
546
|
+
* @param {Array|null} preReadTranscript - Pre-parsed transcript array (avoids re-reading file)
|
|
547
|
+
* @returns {Object|null} - { messages: [{ role, content, turn_index }], total_turns: number, truncated: boolean, original_size: number } or null
|
|
548
|
+
*/
|
|
549
|
+
const extractFullTranscript = async (transcriptPath, log, preReadTranscript = null) => {
|
|
550
|
+
try {
|
|
551
|
+
const transcript = preReadTranscript || await readAndParseTranscript(transcriptPath, log);
|
|
552
|
+
if (!transcript) return null;
|
|
534
553
|
|
|
535
554
|
log(`Full transcript has ${transcript.length} raw messages`);
|
|
536
555
|
|
|
@@ -657,12 +676,13 @@ const extractFullTranscript = async (transcriptPath, log) => {
|
|
|
657
676
|
* @param {string} relayApiUrl - Relay API URL
|
|
658
677
|
* @param {string} relayApiKey - Relay API key
|
|
659
678
|
* @param {Function} log - Logging function
|
|
679
|
+
* @param {Array|null} preReadTranscript - Pre-parsed transcript array (avoids re-reading file)
|
|
660
680
|
* @returns {Promise<{events: Array<{type: string, data: object}>, cursor: object|null}>}
|
|
661
681
|
*/
|
|
662
|
-
const parseTranscriptToEvents = async (transcriptPath, sessionId, relayApiUrl, relayApiKey, log) => {
|
|
682
|
+
const parseTranscriptToEvents = async (transcriptPath, sessionId, relayApiUrl, relayApiKey, log, preReadTranscript = null) => {
|
|
663
683
|
try {
|
|
664
|
-
if (!transcriptPath) {
|
|
665
|
-
log('No transcript_path provided for parsing');
|
|
684
|
+
if (!transcriptPath && !preReadTranscript) {
|
|
685
|
+
log('No transcript_path or pre-read transcript provided for parsing');
|
|
666
686
|
return { events: [], cursor: null };
|
|
667
687
|
}
|
|
668
688
|
|
|
@@ -721,36 +741,9 @@ const parseTranscriptToEvents = async (transcriptPath, sessionId, relayApiUrl, r
|
|
|
721
741
|
log(`Timeline query failed: ${e.message} - will process all messages`);
|
|
722
742
|
}
|
|
723
743
|
|
|
724
|
-
// 2.
|
|
725
|
-
|
|
726
|
-
|
|
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
|
-
}
|
|
744
|
+
// 2. Use pre-read transcript or read fresh
|
|
745
|
+
const transcript = preReadTranscript || await readAndParseTranscript(transcriptPath, log);
|
|
746
|
+
if (!transcript) return { events: [], cursor: null };
|
|
754
747
|
|
|
755
748
|
// 3. Filter using local cursor first, then timeline timestamp
|
|
756
749
|
const localCursor = await loadStopCursor(sessionId, log);
|
|
@@ -1150,10 +1143,22 @@ const extractSystemMessages = async (transcriptPath, sessionId, log) => {
|
|
|
1150
1143
|
|
|
1151
1144
|
log('=== Stop Hook invoked ===');
|
|
1152
1145
|
|
|
1153
|
-
|
|
1146
|
+
// PARALLEL: Read stdin + load config simultaneously (independent operations)
|
|
1147
|
+
const [raw, config] = await Promise.all([
|
|
1148
|
+
readStdin(),
|
|
1149
|
+
(async () => {
|
|
1150
|
+
try {
|
|
1151
|
+
const { loadConfig } = await import('./config-loader.mjs');
|
|
1152
|
+
return await loadConfig();
|
|
1153
|
+
} catch (e) {
|
|
1154
|
+
return { relayApiUrl: env.RELAY_API_URL || '', relayApiKey: env.RELAY_API_KEY || '' };
|
|
1155
|
+
}
|
|
1156
|
+
})()
|
|
1157
|
+
]);
|
|
1158
|
+
|
|
1154
1159
|
let input;
|
|
1155
|
-
try {
|
|
1156
|
-
input = JSON.parse(raw || '{}');
|
|
1160
|
+
try {
|
|
1161
|
+
input = JSON.parse(raw || '{}');
|
|
1157
1162
|
} catch (e) {
|
|
1158
1163
|
log(`ERROR: Invalid JSON: ${e.message}`);
|
|
1159
1164
|
return exit(0);
|
|
@@ -1162,22 +1167,7 @@ const extractSystemMessages = async (transcriptPath, sessionId, log) => {
|
|
|
1162
1167
|
const { session_id, transcript_path, stop_hook_active } = input || {};
|
|
1163
1168
|
log(`Session: ${session_id}, Transcript: ${transcript_path}, stop_hook_active: ${stop_hook_active}`);
|
|
1164
1169
|
|
|
1165
|
-
|
|
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
|
-
|
|
1170
|
-
// Load config
|
|
1171
|
-
let config;
|
|
1172
|
-
try {
|
|
1173
|
-
const { loadConfig } = await import('./config-loader.mjs');
|
|
1174
|
-
config = await loadConfig();
|
|
1175
|
-
} catch (e) {
|
|
1176
|
-
config = {
|
|
1177
|
-
relayApiUrl: env.RELAY_API_URL || '',
|
|
1178
|
-
relayApiKey: env.RELAY_API_KEY || '',
|
|
1179
|
-
};
|
|
1180
|
-
}
|
|
1170
|
+
const source = 'cli_interactive';
|
|
1181
1171
|
|
|
1182
1172
|
const RELAY_API_URL = env.RELAY_API_URL || config.relayApiUrl || '';
|
|
1183
1173
|
const RELAY_API_KEY = env.RELAY_API_KEY || config.relayApiKey || '';
|
|
@@ -1207,150 +1197,163 @@ const extractSystemMessages = async (transcriptPath, sessionId, log) => {
|
|
|
1207
1197
|
}
|
|
1208
1198
|
}
|
|
1209
1199
|
|
|
1210
|
-
//
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
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;
|
|
1200
|
+
// Read and parse transcript ONCE (shared across all consumers)
|
|
1201
|
+
const transcript = (!stop_hook_active && !skipHeavyUpload && transcript_path)
|
|
1202
|
+
? await readAndParseTranscript(transcript_path, log)
|
|
1203
|
+
: null;
|
|
1204
|
+
|
|
1205
|
+
// PARALLEL: Parse events + prepare full transcript simultaneously
|
|
1206
|
+
// Both use the shared pre-read transcript to avoid duplicate file reads
|
|
1207
|
+
let parsedResult = { events: [], cursor: null };
|
|
1208
|
+
let transcriptData = null;
|
|
1209
|
+
let cursor = null;
|
|
1210
|
+
|
|
1211
|
+
if (!stop_hook_active && !skipHeavyUpload && transcript) {
|
|
1212
|
+
[parsedResult, transcriptData] = await Promise.all([
|
|
1213
|
+
parseTranscriptToEvents(transcript_path, session_id, RELAY_API_URL, RELAY_API_KEY, log, transcript),
|
|
1214
|
+
extractFullTranscript(transcript_path, log, transcript)
|
|
1215
|
+
]);
|
|
1216
|
+
} else if (stop_hook_active) {
|
|
1217
|
+
log('Skipping batch event log (stop_hook_active=true)');
|
|
1218
|
+
} else if (!STOP_FULL_INGEST_ENABLED) {
|
|
1219
|
+
log('Skipping batch event log (full ingest disabled)');
|
|
1220
|
+
} else if (skipHeavyUpload) {
|
|
1221
|
+
log('Skipping batch event log (timeline probe unhealthy)');
|
|
1222
|
+
}
|
|
1250
1223
|
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
log(`❌ Batch ${i + 1}/${chunks.length} failed: ${e.message}`);
|
|
1254
|
-
totalFailed += chunk.length;
|
|
1255
|
-
}
|
|
1256
|
-
}
|
|
1224
|
+
const events = parsedResult.events || [];
|
|
1225
|
+
cursor = parsedResult.cursor || null;
|
|
1257
1226
|
|
|
1258
|
-
|
|
1227
|
+
// PARALLEL: Fire all HTTP calls together using Promise.allSettled
|
|
1228
|
+
// This avoids sequential waiting for: batch events, transcript storage, pending messages
|
|
1229
|
+
const httpTasks = [];
|
|
1259
1230
|
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
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
|
-
}
|
|
1273
|
-
}
|
|
1274
|
-
} catch (e) {
|
|
1275
|
-
log(`Failed to batch log events after ${MAX_RETRIES} retries: ${e.message}`);
|
|
1276
|
-
// Don't fail the hook - continue with other functionality
|
|
1231
|
+
// 1. Batch log events
|
|
1232
|
+
if (events.length > 0) {
|
|
1233
|
+
const MAX_BATCH_SIZE = 1000;
|
|
1234
|
+
const chunks = [];
|
|
1235
|
+
for (let i = 0; i < events.length; i += MAX_BATCH_SIZE) {
|
|
1236
|
+
chunks.push(events.slice(i, i + MAX_BATCH_SIZE));
|
|
1277
1237
|
}
|
|
1278
|
-
|
|
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})`);
|
|
1285
|
-
}
|
|
1286
|
-
|
|
1287
|
-
// 2. Store full transcript for session (new feature: PRD-0011 Phase 2)
|
|
1288
|
-
// Only store if not in a recursive stop hook call
|
|
1289
|
-
if (!stop_hook_active && !skipHeavyUpload) {
|
|
1290
|
-
try {
|
|
1291
|
-
const transcriptData = await extractFullTranscript(transcript_path, log);
|
|
1238
|
+
log(`📦 Sending ${events.length} events in ${chunks.length} batch(es)`);
|
|
1292
1239
|
|
|
1293
|
-
|
|
1294
|
-
|
|
1240
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
1241
|
+
const chunk = chunks[i];
|
|
1242
|
+
const batchIndex = i;
|
|
1243
|
+
httpTasks.push(
|
|
1244
|
+
fetchJsonWithRetry(`${RELAY_API_URL}/api/sessions/${session_id}/events/batch`, {
|
|
1295
1245
|
method: 'POST',
|
|
1296
1246
|
headers: {
|
|
1297
1247
|
'Content-Type': 'application/json',
|
|
1298
1248
|
'Authorization': `Bearer ${RELAY_API_KEY}`
|
|
1299
1249
|
},
|
|
1300
|
-
body: JSON.stringify({
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
stored_at: Date.now()
|
|
1250
|
+
body: JSON.stringify({ events: chunk })
|
|
1251
|
+
}, log, 0, BATCH_FETCH_TIMEOUT_MS)
|
|
1252
|
+
.then(response => {
|
|
1253
|
+
log(`✅ Batch ${batchIndex + 1}/${chunks.length}: ${response.success || 0} success, ${response.failed || 0} failed`);
|
|
1254
|
+
return { type: 'batch', success: response.success || 0, failed: response.failed || 0 };
|
|
1306
1255
|
})
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1256
|
+
.catch(e => {
|
|
1257
|
+
log(`❌ Batch ${batchIndex + 1}/${chunks.length} failed: ${e.message}`);
|
|
1258
|
+
return { type: 'batch', success: 0, failed: chunk.length };
|
|
1259
|
+
})
|
|
1260
|
+
);
|
|
1261
|
+
}
|
|
1262
|
+
} else if (!stop_hook_active && !skipHeavyUpload) {
|
|
1263
|
+
log('No events to log from transcript');
|
|
1264
|
+
// No events but cursor may have advanced (all filtered) — safe to save
|
|
1265
|
+
if (cursor) {
|
|
1266
|
+
await saveStopCursor(session_id, cursor, log);
|
|
1313
1267
|
}
|
|
1314
|
-
} else {
|
|
1315
|
-
const reason = !STOP_FULL_INGEST_ENABLED ? 'full ingest disabled' : 'timeline probe unhealthy';
|
|
1316
|
-
log(`Skipping transcript storage because ${reason}`);
|
|
1317
1268
|
}
|
|
1318
1269
|
|
|
1319
|
-
//
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1270
|
+
// 2. Store full transcript
|
|
1271
|
+
if (transcriptData && transcriptData.messages && transcriptData.messages.length > 0) {
|
|
1272
|
+
httpTasks.push(
|
|
1273
|
+
fetchJsonWithRetry(`${RELAY_API_URL}/api/sessions/${session_id}/transcript`, {
|
|
1274
|
+
method: 'POST',
|
|
1275
|
+
headers: {
|
|
1276
|
+
'Content-Type': 'application/json',
|
|
1277
|
+
'Authorization': `Bearer ${RELAY_API_KEY}`
|
|
1278
|
+
},
|
|
1279
|
+
body: JSON.stringify({
|
|
1280
|
+
messages: transcriptData.messages,
|
|
1281
|
+
total_turns: transcriptData.total_turns,
|
|
1282
|
+
truncated: transcriptData.truncated,
|
|
1283
|
+
original_size: transcriptData.original_size,
|
|
1284
|
+
stored_at: Date.now()
|
|
1285
|
+
})
|
|
1286
|
+
}, log)
|
|
1287
|
+
.then(() => {
|
|
1288
|
+
log(`Stored full transcript (${transcriptData.messages.length} messages, ${transcriptData.total_turns} total turns, truncated: ${transcriptData.truncated})`);
|
|
1289
|
+
return { type: 'transcript', success: true };
|
|
1290
|
+
})
|
|
1291
|
+
.catch(e => {
|
|
1292
|
+
log(`Failed to store full transcript: ${e.message}`);
|
|
1293
|
+
return { type: 'transcript', success: false };
|
|
1294
|
+
})
|
|
1295
|
+
);
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
// 3. Check for pending messages from mobile app
|
|
1299
|
+
httpTasks.push(
|
|
1300
|
+
fetchWithTimeout(`${RELAY_API_URL}/api/messages/pending?session_id=${encodeURIComponent(session_id)}`, {
|
|
1332
1301
|
headers: { 'Authorization': `Bearer ${RELAY_API_KEY}` }
|
|
1333
|
-
})
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1302
|
+
})
|
|
1303
|
+
.then(async res => {
|
|
1304
|
+
if (res.ok) {
|
|
1305
|
+
const msg = await res.json();
|
|
1306
|
+
if (msg && msg.id && msg.text) {
|
|
1307
|
+
log(`Found pending message: ${String(msg.text).slice(0, 50)}...`);
|
|
1308
|
+
try {
|
|
1309
|
+
await fetchWithTimeout(`${RELAY_API_URL}/api/messages/${msg.id}/ack`, {
|
|
1310
|
+
method: 'POST',
|
|
1311
|
+
headers: { 'Authorization': `Bearer ${RELAY_API_KEY}` }
|
|
1312
|
+
});
|
|
1313
|
+
} catch {}
|
|
1314
|
+
log(`Acknowledged pending message (mobile message injection not supported in stop hook)`);
|
|
1315
|
+
return { type: 'messages', found: true };
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
return { type: 'messages', found: false };
|
|
1319
|
+
})
|
|
1320
|
+
.catch(e => {
|
|
1321
|
+
log(`Error checking pending messages: ${e.message}`);
|
|
1322
|
+
return { type: 'messages', found: false };
|
|
1323
|
+
})
|
|
1324
|
+
);
|
|
1325
|
+
|
|
1326
|
+
// Wait for all HTTP calls to complete
|
|
1327
|
+
const results = await Promise.allSettled(httpTasks);
|
|
1328
|
+
|
|
1329
|
+
// Surface any unexpected rejections (each task has .catch(), but defense-in-depth)
|
|
1330
|
+
const rejected = results.filter(r => r.status === 'rejected');
|
|
1331
|
+
if (rejected.length > 0) {
|
|
1332
|
+
log(`⚠️ ${rejected.length} HTTP task(s) rejected unexpectedly: ${rejected.map(r => r.reason?.message || r.reason).join(', ')}`);
|
|
1333
|
+
}
|
|
1344
1334
|
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1335
|
+
// Log batch totals
|
|
1336
|
+
const batchResults = results
|
|
1337
|
+
.filter(r => r.status === 'fulfilled' && r.value?.type === 'batch')
|
|
1338
|
+
.map(r => r.value);
|
|
1339
|
+
if (batchResults.length > 0) {
|
|
1340
|
+
const totalSuccess = batchResults.reduce((sum, r) => sum + r.success, 0);
|
|
1341
|
+
const totalFailed = batchResults.reduce((sum, r) => sum + r.failed, 0);
|
|
1342
|
+
log(`✅ Total: ${totalSuccess} success, ${totalFailed} failed (${batchResults.length} batch(es))`);
|
|
1343
|
+
|
|
1344
|
+
// Save cursor AFTER successful upload — prevents reprocessing on next stop
|
|
1345
|
+
// Only save if all events succeeded (partial failures should retry next time)
|
|
1346
|
+
if (cursor && totalFailed === 0) {
|
|
1347
|
+
await saveStopCursor(session_id, cursor, log);
|
|
1348
|
+
} else if (cursor && totalFailed > 0) {
|
|
1349
|
+
log(`⚠️ Skipping cursor save due to ${totalFailed} failed events (will retry next stop)`);
|
|
1351
1350
|
}
|
|
1352
|
-
}
|
|
1353
|
-
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
// Log transcript storage failure for visibility
|
|
1354
|
+
const transcriptResult = results.find(r => r.status === 'fulfilled' && r.value?.type === 'transcript');
|
|
1355
|
+
if (transcriptResult && !transcriptResult.value.success) {
|
|
1356
|
+
log(`⚠️ Transcript storage failed - timeline events were still logged separately`);
|
|
1354
1357
|
}
|
|
1355
1358
|
|
|
1356
1359
|
log('Stop hook completed');
|
|
@@ -46,9 +46,7 @@ const fetchJson = async (url, opts) => {
|
|
|
46
46
|
|
|
47
47
|
const { session_id, prompt } = input;
|
|
48
48
|
|
|
49
|
-
|
|
50
|
-
const isAutonomousTask = env.TELEPORTATION_TASK_MODE === 'true' || !!env.TELEPORTATION_PARENT_SESSION_ID;
|
|
51
|
-
const source = isAutonomousTask ? 'autonomous_task' : 'cli_interactive';
|
|
49
|
+
const source = 'cli_interactive';
|
|
52
50
|
|
|
53
51
|
// Clear away mode only on actual user activity (prompt submit), not on tool attempts.
|
|
54
52
|
// Also support /away and /back here in case they are handled as prompts.
|