teleportation-cli 1.3.0 → 1.4.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 +11 -4
- package/.claude/hooks/post_tool_use.mjs +1 -3
- package/.claude/hooks/pre_tool_use.mjs +216 -287
- package/.claude/hooks/session-register.mjs +36 -28
- package/.claude/hooks/session_end.mjs +1 -3
- package/.claude/hooks/stop.mjs +215 -224
- package/.claude/hooks/user_prompt_submit.mjs +1 -3
- package/lib/daemon/task-executor-v2.js +208 -27
- package/lib/daemon/teleportation-daemon.js +182 -12
- package/lib/daemon/timeline-analyzer.js +19 -13
- package/lib/daemon/transcript-ingestion.js +152 -44
- package/package.json +1 -1
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
export async function fetchTimeline(session_id, config) {
|
|
17
17
|
const { relayApiUrl, apiKey } = config;
|
|
18
18
|
|
|
19
|
-
const response = await fetch(`${relayApiUrl}/api/
|
|
19
|
+
const response = await fetch(`${relayApiUrl}/api/sessions/${session_id}/timeline`, {
|
|
20
20
|
headers: { 'Authorization': `Bearer ${apiKey}` }
|
|
21
21
|
});
|
|
22
22
|
|
|
@@ -36,7 +36,7 @@ export async function fetchTimeline(session_id, config) {
|
|
|
36
36
|
*/
|
|
37
37
|
export function analyzeTaskState(events, task_id) {
|
|
38
38
|
// Filter events for this specific task
|
|
39
|
-
const taskEvents = events.filter(e => e.
|
|
39
|
+
const taskEvents = events.filter(e => e.data?.task_id === task_id);
|
|
40
40
|
|
|
41
41
|
if (taskEvents.length === 0) {
|
|
42
42
|
return {
|
|
@@ -53,16 +53,17 @@ export function analyzeTaskState(events, task_id) {
|
|
|
53
53
|
|
|
54
54
|
const lastEvent = taskEvents[taskEvents.length - 1];
|
|
55
55
|
|
|
56
|
-
// Count turns (each assistant_response indicates a completed turn)
|
|
56
|
+
// Count turns (each assistant_response with matching task_id indicates a completed turn)
|
|
57
|
+
// Note: relay stores source inside data object, not at event top level
|
|
57
58
|
const turn_count = taskEvents.filter(e =>
|
|
58
|
-
e.type === 'assistant_response'
|
|
59
|
+
e.type === 'assistant_response'
|
|
59
60
|
).length;
|
|
60
61
|
|
|
61
62
|
// Find the latest claude_session_id from task execution
|
|
62
63
|
let claude_session_id = null;
|
|
63
64
|
for (let i = taskEvents.length - 1; i >= 0; i--) {
|
|
64
|
-
if (taskEvents[i].
|
|
65
|
-
claude_session_id = taskEvents[i].
|
|
65
|
+
if (taskEvents[i].data?.claude_session_id) {
|
|
66
|
+
claude_session_id = taskEvents[i].data.claude_session_id;
|
|
66
67
|
break;
|
|
67
68
|
}
|
|
68
69
|
}
|
|
@@ -72,7 +73,7 @@ export function analyzeTaskState(events, task_id) {
|
|
|
72
73
|
e.type === 'approval_requested' &&
|
|
73
74
|
!taskEvents.some(later =>
|
|
74
75
|
later.type === 'approval_decided' &&
|
|
75
|
-
later.
|
|
76
|
+
later.data?.approval_id === e.data?.approval_id
|
|
76
77
|
)
|
|
77
78
|
);
|
|
78
79
|
|
|
@@ -93,7 +94,7 @@ export function analyzeTaskState(events, task_id) {
|
|
|
93
94
|
state: 'paused',
|
|
94
95
|
turn_count,
|
|
95
96
|
ready_for_execution: false,
|
|
96
|
-
reason: lastEvent.
|
|
97
|
+
reason: lastEvent.data?.reason || 'Task paused',
|
|
97
98
|
claude_session_id,
|
|
98
99
|
waiting_for_user_message: true,
|
|
99
100
|
};
|
|
@@ -105,7 +106,7 @@ export function analyzeTaskState(events, task_id) {
|
|
|
105
106
|
state: 'stopped',
|
|
106
107
|
turn_count,
|
|
107
108
|
ready_for_execution: false,
|
|
108
|
-
reason: lastEvent.
|
|
109
|
+
reason: lastEvent.data?.reason || 'Task stopped',
|
|
109
110
|
claude_session_id,
|
|
110
111
|
};
|
|
111
112
|
}
|
|
@@ -134,7 +135,7 @@ export function analyzeTaskState(events, task_id) {
|
|
|
134
135
|
// If last event is assistant_response, ready for next turn
|
|
135
136
|
// Use stop_reason to determine if Claude is done (works like CLI)
|
|
136
137
|
if (lastEvent.type === 'assistant_response') {
|
|
137
|
-
const stopReason = lastEvent.
|
|
138
|
+
const stopReason = lastEvent.data?.stop_reason;
|
|
138
139
|
|
|
139
140
|
// Claude uses "end_turn" when it's done with the current turn and waiting for input
|
|
140
141
|
// This is the natural stopping point, just like in the CLI
|
|
@@ -183,9 +184,14 @@ export function getNextPrompt(state, task) {
|
|
|
183
184
|
return task.task;
|
|
184
185
|
}
|
|
185
186
|
|
|
186
|
-
// If
|
|
187
|
-
if (task.
|
|
188
|
-
return task.
|
|
187
|
+
// If resumed with a user answer/message, use that as the prompt
|
|
188
|
+
if (task.pending_answer) {
|
|
189
|
+
return task.pending_answer;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// If redirected with new instructions, use those
|
|
193
|
+
if (task.pending_redirect) {
|
|
194
|
+
return task.pending_redirect;
|
|
189
195
|
}
|
|
190
196
|
|
|
191
197
|
// Default continuation prompt
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* @module lib/daemon/transcript-ingestion
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import { readFile, readdir } from 'fs/promises';
|
|
10
|
+
import { readFile, readdir, writeFile, mkdir } from 'fs/promises';
|
|
11
11
|
import { homedir, tmpdir } from 'os';
|
|
12
12
|
import { join } from 'path';
|
|
13
13
|
|
|
@@ -132,6 +132,49 @@ const RETRY_BASE_DELAY_MS = 100;
|
|
|
132
132
|
*/
|
|
133
133
|
const DEBUG = process.env.TELEPORTATION_DEBUG === 'true';
|
|
134
134
|
|
|
135
|
+
/**
|
|
136
|
+
* Local cursor directory for tracking last-processed transcript index.
|
|
137
|
+
* Persists across daemon restarts so we don't reprocess the entire transcript.
|
|
138
|
+
*/
|
|
139
|
+
const CURSOR_DIR = join(tmpdir(), 'teleportation-cursors');
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Read the local ingestion cursor for a session.
|
|
143
|
+
* Returns the last successfully processed transcript message count.
|
|
144
|
+
* @param {string} sessionId - Session ID
|
|
145
|
+
* @returns {Promise<number>} Last processed message count (0 if no cursor)
|
|
146
|
+
*/
|
|
147
|
+
async function readCursor(sessionId) {
|
|
148
|
+
try {
|
|
149
|
+
const cursorPath = join(CURSOR_DIR, `${sessionId}.json`);
|
|
150
|
+
const data = JSON.parse(await readFile(cursorPath, 'utf8'));
|
|
151
|
+
return data.lastMessageCount || 0;
|
|
152
|
+
} catch {
|
|
153
|
+
return 0;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Write the local ingestion cursor after successful push.
|
|
159
|
+
* @param {string} sessionId - Session ID
|
|
160
|
+
* @param {number} messageCount - Total transcript messages processed
|
|
161
|
+
* @param {number} eventsPushed - Events successfully pushed this cycle
|
|
162
|
+
*/
|
|
163
|
+
async function writeCursor(sessionId, messageCount, eventsPushed) {
|
|
164
|
+
try {
|
|
165
|
+
await mkdir(CURSOR_DIR, { recursive: true });
|
|
166
|
+
const cursorPath = join(CURSOR_DIR, `${sessionId}.json`);
|
|
167
|
+
await writeFile(cursorPath, JSON.stringify({
|
|
168
|
+
lastMessageCount: messageCount,
|
|
169
|
+
lastEventsPushed: eventsPushed,
|
|
170
|
+
updatedAt: new Date().toISOString()
|
|
171
|
+
}));
|
|
172
|
+
} catch (e) {
|
|
173
|
+
// Non-fatal — next cycle will just reprocess some events
|
|
174
|
+
console.warn(`[transcript] Failed to write cursor for ${sessionId}: ${e.message}`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
135
178
|
/**
|
|
136
179
|
* Sleep helper for retry backoff
|
|
137
180
|
* @param {number} ms - Milliseconds to sleep
|
|
@@ -328,7 +371,31 @@ function parseTimestamp(entry) {
|
|
|
328
371
|
return Date.now();
|
|
329
372
|
}
|
|
330
373
|
|
|
331
|
-
const
|
|
374
|
+
const ts = entry.timestamp;
|
|
375
|
+
|
|
376
|
+
// Handle numeric string timestamps (epoch ms as string, e.g. "1771211826406")
|
|
377
|
+
// new Date("1771211826406") returns Invalid Date, so detect and convert first
|
|
378
|
+
if (typeof ts === 'string' && /^\d+$/.test(ts)) {
|
|
379
|
+
const numeric = Number(ts);
|
|
380
|
+
const now = Date.now();
|
|
381
|
+
if (numeric > now - TIMESTAMP_MAX_AGE_MS && numeric < now + TIMESTAMP_MAX_AGE_MS) {
|
|
382
|
+
return numeric;
|
|
383
|
+
}
|
|
384
|
+
console.warn(`[transcript] Numeric string timestamp out of range: ${ts}, using current time`);
|
|
385
|
+
return Date.now();
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// Handle numeric timestamps directly
|
|
389
|
+
if (typeof ts === 'number') {
|
|
390
|
+
const now = Date.now();
|
|
391
|
+
if (ts > now - TIMESTAMP_MAX_AGE_MS && ts < now + TIMESTAMP_MAX_AGE_MS) {
|
|
392
|
+
return ts;
|
|
393
|
+
}
|
|
394
|
+
console.warn(`[transcript] Numeric timestamp out of range: ${ts}, using current time`);
|
|
395
|
+
return Date.now();
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const parsed = new Date(ts).getTime();
|
|
332
399
|
|
|
333
400
|
// Validate timestamp is reasonable (not NaN, not in distant past/future)
|
|
334
401
|
if (isNaN(parsed)) {
|
|
@@ -396,7 +463,7 @@ function extractTimelineEvents(transcript, fromIndex = 0) {
|
|
|
396
463
|
if (trimmedContent) {
|
|
397
464
|
events.push({
|
|
398
465
|
type: 'assistant_response',
|
|
399
|
-
source:
|
|
466
|
+
source: 'cli_interactive',
|
|
400
467
|
timestamp,
|
|
401
468
|
meta: {
|
|
402
469
|
message: trimmedContent.slice(0, MAX_ASSISTANT_RESPONSE_LENGTH),
|
|
@@ -418,7 +485,7 @@ function extractTimelineEvents(transcript, fromIndex = 0) {
|
|
|
418
485
|
for (const toolUse of toolUses) {
|
|
419
486
|
events.push({
|
|
420
487
|
type: 'tool_use',
|
|
421
|
-
source:
|
|
488
|
+
source: 'cli_interactive',
|
|
422
489
|
timestamp,
|
|
423
490
|
meta: {
|
|
424
491
|
tool_name: toolUse.name,
|
|
@@ -448,7 +515,7 @@ function extractTimelineEvents(transcript, fromIndex = 0) {
|
|
|
448
515
|
|
|
449
516
|
events.push({
|
|
450
517
|
type: isError ? 'tool_failed' : 'tool_completed',
|
|
451
|
-
source:
|
|
518
|
+
source: 'cli_interactive',
|
|
452
519
|
timestamp,
|
|
453
520
|
meta: {
|
|
454
521
|
tool_use_id: toolResult.tool_use_id,
|
|
@@ -573,14 +640,22 @@ export async function ingestTranscriptToTimeline(options) {
|
|
|
573
640
|
let fromIndex = 0;
|
|
574
641
|
|
|
575
642
|
if (task_id) {
|
|
576
|
-
// For tasks: Use task's last ingested index
|
|
643
|
+
// For tasks: Use task's last ingested index, fall back to local cursor if fetchTask fails
|
|
577
644
|
const task = await fetchTask(task_id, parent_session_id, config);
|
|
578
|
-
|
|
579
|
-
|
|
645
|
+
if (task?.last_transcript_index != null) {
|
|
646
|
+
fromIndex = task.last_transcript_index;
|
|
647
|
+
console.log(`[transcript] Using task's last ingested index: ${fromIndex}`);
|
|
648
|
+
} else {
|
|
649
|
+
// fetchTask returned null (network error) or task has no index — use local cursor
|
|
650
|
+
// Use claude_session_id as cursor key for tasks (each task has unique child session)
|
|
651
|
+
fromIndex = await readCursor(claude_session_id);
|
|
652
|
+
console.log(`[transcript] Task fetch failed or no index — using local cursor: ${fromIndex}`);
|
|
653
|
+
}
|
|
580
654
|
} else {
|
|
581
|
-
// For regular sessions:
|
|
582
|
-
//
|
|
583
|
-
|
|
655
|
+
// For regular sessions: Use local cursor as primary deduplication,
|
|
656
|
+
// with timeline query as validation
|
|
657
|
+
const cursorMessageCount = await readCursor(parent_session_id);
|
|
658
|
+
console.log(`[transcript] Local cursor: ${cursorMessageCount} messages previously processed`);
|
|
584
659
|
console.log(`[transcript] Querying timeline for session ${parent_session_id} to find recent events...`);
|
|
585
660
|
try {
|
|
586
661
|
const timelineResponse = await fetch(
|
|
@@ -599,46 +674,69 @@ export async function ingestTranscriptToTimeline(options) {
|
|
|
599
674
|
console.log(`[transcript] Timeline returned ${timelineEvents.length} events`);
|
|
600
675
|
|
|
601
676
|
if (timelineEvents.length > 0 && timelineEvents[0].timestamp) {
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
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;
|
|
677
|
+
// Timeline API returns timestamps as string epoch ms (e.g. "1771211826406")
|
|
678
|
+
// Must parse as number — new Date("1771211826406") returns Invalid Date
|
|
679
|
+
const rawTimestamp = timelineEvents[0].timestamp;
|
|
680
|
+
const lastTimestamp = typeof rawTimestamp === 'string' && /^\d+$/.test(rawTimestamp)
|
|
681
|
+
? Number(rawTimestamp)
|
|
682
|
+
: typeof rawTimestamp === 'number'
|
|
683
|
+
? rawTimestamp
|
|
684
|
+
: new Date(rawTimestamp).getTime();
|
|
685
|
+
|
|
686
|
+
if (isNaN(lastTimestamp)) {
|
|
687
|
+
console.log(`[transcript] Could not parse timeline timestamp: ${rawTimestamp} - using local cursor`);
|
|
688
|
+
fromIndex = cursorMessageCount;
|
|
624
689
|
} else {
|
|
625
|
-
log(`
|
|
626
|
-
|
|
690
|
+
console.log(`[transcript] Last timeline event timestamp: ${lastTimestamp} (${new Date(lastTimestamp).toISOString()})`);
|
|
691
|
+
|
|
692
|
+
// Find the index of the first message after this timestamp
|
|
693
|
+
fromIndex = transcript.findIndex(msg => {
|
|
694
|
+
const msgTs = msg.timestamp;
|
|
695
|
+
// Transcript timestamps are ISO strings like "2026-02-15T16:10:42.035Z"
|
|
696
|
+
// or may be missing (undefined)
|
|
697
|
+
const msgTimestamp = !msgTs ? 0
|
|
698
|
+
: typeof msgTs === 'number' ? msgTs
|
|
699
|
+
: typeof msgTs === 'string' && /^\d+$/.test(msgTs) ? Number(msgTs)
|
|
700
|
+
: new Date(msgTs).getTime() || 0;
|
|
701
|
+
return msgTimestamp > lastTimestamp;
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
// If not found, start from end of transcript (all messages are older)
|
|
705
|
+
if (fromIndex === -1) {
|
|
706
|
+
log(`All messages older than last timeline event (${new Date(lastTimestamp).toISOString()}) - no new events`);
|
|
707
|
+
console.log(`[transcript] All ${transcript.length} messages are older than last timeline event - no new events to ingest`);
|
|
708
|
+
fromIndex = transcript.length;
|
|
709
|
+
} else {
|
|
710
|
+
log(`Found ${transcript.length - fromIndex} new messages starting from index ${fromIndex}/${transcript.length}`);
|
|
711
|
+
console.log(`[transcript] Found ${transcript.length - fromIndex} new messages starting from index ${fromIndex}/${transcript.length}`);
|
|
712
|
+
}
|
|
627
713
|
}
|
|
628
714
|
} else {
|
|
629
|
-
|
|
630
|
-
fromIndex =
|
|
715
|
+
// No timeline events — use local cursor to avoid reprocessing
|
|
716
|
+
fromIndex = cursorMessageCount;
|
|
717
|
+
if (cursorMessageCount > 0) {
|
|
718
|
+
console.log(`[transcript] No timeline events found - using local cursor (${cursorMessageCount})`);
|
|
719
|
+
} else {
|
|
720
|
+
console.log(`[transcript] No timeline events and no cursor - processing all transcript messages`);
|
|
721
|
+
}
|
|
631
722
|
}
|
|
632
723
|
} else {
|
|
633
|
-
|
|
634
|
-
|
|
724
|
+
fromIndex = cursorMessageCount;
|
|
725
|
+
console.log(`[transcript] Timeline query failed (${timelineResponse.status}) - using local cursor (${cursorMessageCount})`);
|
|
635
726
|
}
|
|
636
727
|
} catch (error) {
|
|
637
|
-
|
|
638
|
-
|
|
728
|
+
fromIndex = cursorMessageCount;
|
|
729
|
+
console.log(`[transcript] Failed to query timeline: ${error.message} - using local cursor (${cursorMessageCount})`);
|
|
639
730
|
}
|
|
640
731
|
}
|
|
641
732
|
|
|
733
|
+
// Clamp fromIndex to transcript length to prevent stale cursors from
|
|
734
|
+
// skipping past the end of a shorter transcript (e.g., cursor=500, transcript has 100 messages)
|
|
735
|
+
if (fromIndex > transcript.length) {
|
|
736
|
+
console.warn(`[transcript] Clamping fromIndex from ${fromIndex} to transcript length ${transcript.length} — cursor may be stale or transcript truncated`);
|
|
737
|
+
fromIndex = transcript.length;
|
|
738
|
+
}
|
|
739
|
+
|
|
642
740
|
// 3. Extract only NEW events from determined index
|
|
643
741
|
const allEvents = extractTimelineEvents(transcript, fromIndex);
|
|
644
742
|
|
|
@@ -677,9 +775,16 @@ export async function ingestTranscriptToTimeline(options) {
|
|
|
677
775
|
log(`===== INGESTION COMPLETE: ${successCount}/${events.length} events pushed (${failCount} failed) =====`);
|
|
678
776
|
console.log(`[transcript] Pushed ${successCount}/${events.length} events (${failCount} failed)`);
|
|
679
777
|
|
|
680
|
-
// 6. Update
|
|
681
|
-
if (
|
|
682
|
-
|
|
778
|
+
// 6. Update cursors to prevent duplicates on next call
|
|
779
|
+
if (successCount > 0) {
|
|
780
|
+
// Local cursor — use claude_session_id for tasks (unique per child session),
|
|
781
|
+
// parent_session_id for regular sessions (maps to transcript file)
|
|
782
|
+
const cursorKey = task_id ? claude_session_id : parent_session_id;
|
|
783
|
+
await writeCursor(cursorKey, transcript.length, successCount);
|
|
784
|
+
// Remote cursor (tasks only) — persists in relay
|
|
785
|
+
if (task_id) {
|
|
786
|
+
await updateLastIngestedIndex(task_id, parent_session_id, transcript.length, config);
|
|
787
|
+
}
|
|
683
788
|
}
|
|
684
789
|
|
|
685
790
|
return { events_pushed: successCount, events_failed: failCount };
|
|
@@ -694,3 +799,6 @@ export async function getTranscriptLength(claude_session_id) {
|
|
|
694
799
|
const transcript = await readTranscript(claude_session_id);
|
|
695
800
|
return transcript.length;
|
|
696
801
|
}
|
|
802
|
+
|
|
803
|
+
// Export internals for unit testing
|
|
804
|
+
export { parseTimestamp, extractTimelineEvents, readCursor, writeCursor };
|