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