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