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
|
@@ -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,9 +7,12 @@
|
|
|
7
7
|
* @module lib/daemon/transcript-ingestion
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import { readFile, readdir } from 'fs/promises';
|
|
10
|
+
import { readFile, readdir, writeFile, mkdir, appendFile } from 'fs/promises';
|
|
11
11
|
import { homedir, tmpdir } from 'os';
|
|
12
12
|
import { join } from 'path';
|
|
13
|
+
import { createHash } from 'node:crypto';
|
|
14
|
+
import { sanitizeEventData } from '../utils/log-sanitizer.js';
|
|
15
|
+
import { normalizeTranscriptEvents, normalizeTranscriptEntry } from '../intelligence/schema.js';
|
|
13
16
|
|
|
14
17
|
// ============================================================================
|
|
15
18
|
// Configuration Constants
|
|
@@ -131,6 +134,69 @@ const RETRY_BASE_DELAY_MS = 100;
|
|
|
131
134
|
* Set TELEPORTATION_DEBUG=true to enable verbose logging to tmpdir()
|
|
132
135
|
*/
|
|
133
136
|
const DEBUG = process.env.TELEPORTATION_DEBUG === 'true';
|
|
137
|
+
const ENABLE_REMOTE_INTELLIGENCE_INGEST = process.env.TELEPORTATION_INTELLIGENCE_REMOTE_INGEST === 'true';
|
|
138
|
+
const ENABLE_LOCAL_INTELLIGENCE_SPOOL = process.env.TELEPORTATION_INTELLIGENCE_LOCAL_SPOOL !== 'false';
|
|
139
|
+
const INTELLIGENCE_SPOOL_DIR = join(tmpdir(), 'teleportation-intelligence');
|
|
140
|
+
const INTELLIGENCE_BATCH_PATH = '/api/intelligence/transcripts/batch';
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Generate a deterministic UUID-formatted event ID from an input string and event index.
|
|
144
|
+
* Uses the same sha256 + UUID-dash format as stop.mjs for consistency, enabling
|
|
145
|
+
* server-side deduplication via ON CONFLICT (id) DO NOTHING.
|
|
146
|
+
*
|
|
147
|
+
* Format: 8-4-4-4-12 hex characters (matches stop.mjs deterministicEventId)
|
|
148
|
+
*
|
|
149
|
+
* @param {string} input - Unique input (msg UUID or session:msg:event composite key)
|
|
150
|
+
* @param {number} eventIndex - Event index within the message
|
|
151
|
+
* @returns {string} UUID-formatted hex string (36 characters with dashes)
|
|
152
|
+
*/
|
|
153
|
+
function deterministicEventId(input, eventIndex) {
|
|
154
|
+
const hash = createHash('sha256').update(`${input}:${eventIndex}`).digest('hex');
|
|
155
|
+
return [hash.slice(0, 8), hash.slice(8, 12), hash.slice(12, 16), hash.slice(16, 20), hash.slice(20, 32)].join('-');
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Local cursor directory for tracking last-processed transcript index.
|
|
160
|
+
* Persists across daemon restarts so we don't reprocess the entire transcript.
|
|
161
|
+
*/
|
|
162
|
+
const CURSOR_DIR = join(tmpdir(), 'teleportation-cursors');
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Read the local ingestion cursor for a session.
|
|
166
|
+
* Returns the last successfully processed transcript message count.
|
|
167
|
+
* @param {string} sessionId - Session ID
|
|
168
|
+
* @returns {Promise<number>} Last processed message count (0 if no cursor)
|
|
169
|
+
*/
|
|
170
|
+
async function readCursor(sessionId) {
|
|
171
|
+
try {
|
|
172
|
+
const cursorPath = join(CURSOR_DIR, `${sessionId}.json`);
|
|
173
|
+
const data = JSON.parse(await readFile(cursorPath, 'utf8'));
|
|
174
|
+
return data.lastMessageCount || 0;
|
|
175
|
+
} catch {
|
|
176
|
+
return 0;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Write the local ingestion cursor after successful push.
|
|
182
|
+
* @param {string} sessionId - Session ID
|
|
183
|
+
* @param {number} messageCount - Total transcript messages processed
|
|
184
|
+
* @param {number} eventsPushed - Events successfully pushed this cycle
|
|
185
|
+
*/
|
|
186
|
+
async function writeCursor(sessionId, messageCount, eventsPushed) {
|
|
187
|
+
try {
|
|
188
|
+
await mkdir(CURSOR_DIR, { recursive: true });
|
|
189
|
+
const cursorPath = join(CURSOR_DIR, `${sessionId}.json`);
|
|
190
|
+
await writeFile(cursorPath, JSON.stringify({
|
|
191
|
+
lastMessageCount: messageCount,
|
|
192
|
+
lastEventsPushed: eventsPushed,
|
|
193
|
+
updatedAt: new Date().toISOString()
|
|
194
|
+
}));
|
|
195
|
+
} catch (e) {
|
|
196
|
+
// Non-fatal — next cycle will just reprocess some events
|
|
197
|
+
console.warn(`[transcript] Failed to write cursor for ${sessionId}: ${e.message}`);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
134
200
|
|
|
135
201
|
/**
|
|
136
202
|
* Sleep helper for retry backoff
|
|
@@ -236,6 +302,10 @@ async function pushEventsInChunks(events, relayApiUrl, apiKey, parent_session_id
|
|
|
236
302
|
events: chunk.map(event => ({
|
|
237
303
|
type: event.type,
|
|
238
304
|
source: event.source,
|
|
305
|
+
// Top-level timestamp and id for relay batch endpoint
|
|
306
|
+
// (logTimelineEventsBatch accepts clientTimestamp and clientId)
|
|
307
|
+
timestamp: event.timestamp,
|
|
308
|
+
id: event.id || undefined,
|
|
239
309
|
data: {
|
|
240
310
|
...event.meta,
|
|
241
311
|
task_id,
|
|
@@ -275,11 +345,16 @@ async function pushEventsInChunks(events, relayApiUrl, apiKey, parent_session_id
|
|
|
275
345
|
session_id: parent_session_id,
|
|
276
346
|
type: event.type,
|
|
277
347
|
source: event.source,
|
|
348
|
+
// Top-level timestamp and id kept for forward-compat; also inside data
|
|
349
|
+
// because relay's /api/timeline passes only `data` to logTimelineEvent()
|
|
350
|
+
timestamp: event.timestamp,
|
|
351
|
+
id: event.id || undefined,
|
|
278
352
|
data: {
|
|
279
353
|
...event.meta,
|
|
280
354
|
task_id,
|
|
281
355
|
claude_session_id,
|
|
282
356
|
timestamp: event.timestamp,
|
|
357
|
+
id: event.id || undefined,
|
|
283
358
|
}
|
|
284
359
|
})
|
|
285
360
|
});
|
|
@@ -328,7 +403,31 @@ function parseTimestamp(entry) {
|
|
|
328
403
|
return Date.now();
|
|
329
404
|
}
|
|
330
405
|
|
|
331
|
-
const
|
|
406
|
+
const ts = entry.timestamp;
|
|
407
|
+
|
|
408
|
+
// Handle numeric string timestamps (epoch ms as string, e.g. "1771211826406")
|
|
409
|
+
// new Date("1771211826406") returns Invalid Date, so detect and convert first
|
|
410
|
+
if (typeof ts === 'string' && /^\d+$/.test(ts)) {
|
|
411
|
+
const numeric = Number(ts);
|
|
412
|
+
const now = Date.now();
|
|
413
|
+
if (numeric > now - TIMESTAMP_MAX_AGE_MS && numeric < now + TIMESTAMP_MAX_AGE_MS) {
|
|
414
|
+
return numeric;
|
|
415
|
+
}
|
|
416
|
+
console.warn(`[transcript] Numeric string timestamp out of range: ${ts}, using current time`);
|
|
417
|
+
return Date.now();
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// Handle numeric timestamps directly
|
|
421
|
+
if (typeof ts === 'number') {
|
|
422
|
+
const now = Date.now();
|
|
423
|
+
if (ts > now - TIMESTAMP_MAX_AGE_MS && ts < now + TIMESTAMP_MAX_AGE_MS) {
|
|
424
|
+
return ts;
|
|
425
|
+
}
|
|
426
|
+
console.warn(`[transcript] Numeric timestamp out of range: ${ts}, using current time`);
|
|
427
|
+
return Date.now();
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const parsed = new Date(ts).getTime();
|
|
332
431
|
|
|
333
432
|
// Validate timestamp is reasonable (not NaN, not in distant past/future)
|
|
334
433
|
if (isNaN(parsed)) {
|
|
@@ -353,9 +452,10 @@ function parseTimestamp(entry) {
|
|
|
353
452
|
* Supports both old format (message at root) and new format (message nested)
|
|
354
453
|
* @param {Array} transcript - Transcript messages
|
|
355
454
|
* @param {number} fromIndex - Start extracting from this index (to avoid duplicates)
|
|
356
|
-
* @
|
|
455
|
+
* @param {string} sessionId - Session ID for deterministic fallback IDs (when entry has no uuid)
|
|
456
|
+
* @returns {Array} Timeline events to push (each with deterministic `id` field)
|
|
357
457
|
*/
|
|
358
|
-
function extractTimelineEvents(transcript, fromIndex = 0) {
|
|
458
|
+
function extractTimelineEvents(transcript, fromIndex = 0, sessionId = '') {
|
|
359
459
|
const events = [];
|
|
360
460
|
|
|
361
461
|
console.log(`[transcript] extractTimelineEvents: processing ${transcript.length - fromIndex} messages from index ${fromIndex}`);
|
|
@@ -385,6 +485,13 @@ function extractTimelineEvents(transcript, fromIndex = 0) {
|
|
|
385
485
|
const content = message.content;
|
|
386
486
|
const timestamp = parseTimestamp(entry);
|
|
387
487
|
|
|
488
|
+
// Deterministic ID generation (Bug 3):
|
|
489
|
+
// Use entry.uuid (Claude Code transcript UUID) when available,
|
|
490
|
+
// fall back to entry.message.uuid, or a composite key from sessionId:messageIndex.
|
|
491
|
+
const msgUuid = entry.uuid || message.uuid || null;
|
|
492
|
+
const msgKey = msgUuid || `${sessionId}:${i}`;
|
|
493
|
+
let msgEventIndex = 0;
|
|
494
|
+
|
|
388
495
|
// Extract assistant responses
|
|
389
496
|
// Schema matches stop hook batch: data.message (canonical field name)
|
|
390
497
|
if (role === 'assistant' && content) {
|
|
@@ -396,8 +503,9 @@ function extractTimelineEvents(transcript, fromIndex = 0) {
|
|
|
396
503
|
if (trimmedContent) {
|
|
397
504
|
events.push({
|
|
398
505
|
type: 'assistant_response',
|
|
399
|
-
source:
|
|
506
|
+
source: 'cli_interactive',
|
|
400
507
|
timestamp,
|
|
508
|
+
id: deterministicEventId(msgKey, msgEventIndex++),
|
|
401
509
|
meta: {
|
|
402
510
|
message: trimmedContent.slice(0, MAX_ASSISTANT_RESPONSE_LENGTH),
|
|
403
511
|
full_length: trimmedContent.length,
|
|
@@ -418,8 +526,9 @@ function extractTimelineEvents(transcript, fromIndex = 0) {
|
|
|
418
526
|
for (const toolUse of toolUses) {
|
|
419
527
|
events.push({
|
|
420
528
|
type: 'tool_use',
|
|
421
|
-
source:
|
|
529
|
+
source: 'cli_interactive',
|
|
422
530
|
timestamp,
|
|
531
|
+
id: deterministicEventId(msgKey, msgEventIndex++),
|
|
423
532
|
meta: {
|
|
424
533
|
tool_name: toolUse.name,
|
|
425
534
|
tool_use_id: toolUse.id,
|
|
@@ -448,8 +557,9 @@ function extractTimelineEvents(transcript, fromIndex = 0) {
|
|
|
448
557
|
|
|
449
558
|
events.push({
|
|
450
559
|
type: isError ? 'tool_failed' : 'tool_completed',
|
|
451
|
-
source:
|
|
560
|
+
source: 'cli_interactive',
|
|
452
561
|
timestamp,
|
|
562
|
+
id: deterministicEventId(msgKey, msgEventIndex++),
|
|
453
563
|
meta: {
|
|
454
564
|
tool_use_id: toolResult.tool_use_id,
|
|
455
565
|
tool_name: toolInfo?.name || null,
|
|
@@ -465,7 +575,29 @@ function extractTimelineEvents(transcript, fromIndex = 0) {
|
|
|
465
575
|
}
|
|
466
576
|
|
|
467
577
|
console.log(`[transcript] extractTimelineEvents: found ${events.length} events`);
|
|
468
|
-
|
|
578
|
+
|
|
579
|
+
// Sanitize all event meta to redact secrets (API keys, tokens, passwords, etc.)
|
|
580
|
+
return events.map(event => ({
|
|
581
|
+
...event,
|
|
582
|
+
meta: sanitizeEventData(event.meta),
|
|
583
|
+
}));
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* Convert timeline events to canonical transcript intelligence events.
|
|
588
|
+
* Returns [] on normalization failures to preserve ingestion behavior.
|
|
589
|
+
*
|
|
590
|
+
* @param {Array} events - Raw timeline events from extractTimelineEvents
|
|
591
|
+
* @param {Object} context - Normalization context
|
|
592
|
+
* @returns {Array} Normalized intelligence events
|
|
593
|
+
*/
|
|
594
|
+
function normalizeTimelineEventsForIntelligence(events, context = {}) {
|
|
595
|
+
try {
|
|
596
|
+
return normalizeTranscriptEvents(events, context);
|
|
597
|
+
} catch (error) {
|
|
598
|
+
console.warn(`[transcript] Intelligence normalization failed: ${error.message}`);
|
|
599
|
+
return [];
|
|
600
|
+
}
|
|
469
601
|
}
|
|
470
602
|
|
|
471
603
|
/**
|
|
@@ -531,12 +663,13 @@ async function updateLastIngestedIndex(task_id, session_id, lastIndex, config) {
|
|
|
531
663
|
* @param {string} options.task_id - Task ID (for event metadata)
|
|
532
664
|
* @param {string} options.cwd - Working directory (to derive project slug)
|
|
533
665
|
* @param {Object} options.config - Config with relayApiUrl, apiKey
|
|
666
|
+
* @param {Function} [options.onNormalizedEvents] - Optional callback for normalized intelligence events
|
|
534
667
|
* @param {boolean} options.realTimeMode - If true, only process last 10 events (fast, for hooks)
|
|
535
668
|
* @param {number} options.maxEvents - Maximum events to process (default: 100 for daemon, 10 for realTime)
|
|
536
669
|
* @returns {Promise<Object>} Result { events_pushed: number }
|
|
537
670
|
*/
|
|
538
671
|
export async function ingestTranscriptToTimeline(options) {
|
|
539
|
-
const { claude_session_id, parent_session_id, task_id, cwd, config, realTimeMode = false, maxEvents } = options;
|
|
672
|
+
const { claude_session_id, parent_session_id, task_id, cwd, config, onNormalizedEvents, realTimeMode = false, maxEvents } = options;
|
|
540
673
|
const { relayApiUrl, apiKey } = config;
|
|
541
674
|
|
|
542
675
|
// Determine max events based on mode
|
|
@@ -573,14 +706,22 @@ export async function ingestTranscriptToTimeline(options) {
|
|
|
573
706
|
let fromIndex = 0;
|
|
574
707
|
|
|
575
708
|
if (task_id) {
|
|
576
|
-
// For tasks: Use task's last ingested index
|
|
709
|
+
// For tasks: Use task's last ingested index, fall back to local cursor if fetchTask fails
|
|
577
710
|
const task = await fetchTask(task_id, parent_session_id, config);
|
|
578
|
-
|
|
579
|
-
|
|
711
|
+
if (task?.last_transcript_index != null) {
|
|
712
|
+
fromIndex = task.last_transcript_index;
|
|
713
|
+
console.log(`[transcript] Using task's last ingested index: ${fromIndex}`);
|
|
714
|
+
} else {
|
|
715
|
+
// fetchTask returned null (network error) or task has no index — use local cursor
|
|
716
|
+
// Use claude_session_id as cursor key for tasks (each task has unique child session)
|
|
717
|
+
fromIndex = await readCursor(claude_session_id);
|
|
718
|
+
console.log(`[transcript] Task fetch failed or no index — using local cursor: ${fromIndex}`);
|
|
719
|
+
}
|
|
580
720
|
} else {
|
|
581
|
-
// For regular sessions:
|
|
582
|
-
//
|
|
583
|
-
|
|
721
|
+
// For regular sessions: Use local cursor as primary deduplication,
|
|
722
|
+
// with timeline query as validation
|
|
723
|
+
const cursorMessageCount = await readCursor(parent_session_id);
|
|
724
|
+
console.log(`[transcript] Local cursor: ${cursorMessageCount} messages previously processed`);
|
|
584
725
|
console.log(`[transcript] Querying timeline for session ${parent_session_id} to find recent events...`);
|
|
585
726
|
try {
|
|
586
727
|
const timelineResponse = await fetch(
|
|
@@ -599,54 +740,93 @@ export async function ingestTranscriptToTimeline(options) {
|
|
|
599
740
|
console.log(`[transcript] Timeline returned ${timelineEvents.length} events`);
|
|
600
741
|
|
|
601
742
|
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;
|
|
743
|
+
// Timeline API returns timestamps as string epoch ms (e.g. "1771211826406")
|
|
744
|
+
// Must parse as number — new Date("1771211826406") returns Invalid Date
|
|
745
|
+
const rawTimestamp = timelineEvents[0].timestamp;
|
|
746
|
+
const lastTimestamp = typeof rawTimestamp === 'string' && /^\d+$/.test(rawTimestamp)
|
|
747
|
+
? Number(rawTimestamp)
|
|
748
|
+
: typeof rawTimestamp === 'number'
|
|
749
|
+
? rawTimestamp
|
|
750
|
+
: new Date(rawTimestamp).getTime();
|
|
751
|
+
|
|
752
|
+
if (isNaN(lastTimestamp)) {
|
|
753
|
+
console.log(`[transcript] Could not parse timeline timestamp: ${rawTimestamp} - using local cursor`);
|
|
754
|
+
fromIndex = cursorMessageCount;
|
|
624
755
|
} else {
|
|
625
|
-
log(`
|
|
626
|
-
|
|
756
|
+
console.log(`[transcript] Last timeline event timestamp: ${lastTimestamp} (${new Date(lastTimestamp).toISOString()})`);
|
|
757
|
+
|
|
758
|
+
// Find the index of the first message after this timestamp
|
|
759
|
+
fromIndex = transcript.findIndex(msg => {
|
|
760
|
+
const msgTs = msg.timestamp;
|
|
761
|
+
// Transcript timestamps are ISO strings like "2026-02-15T16:10:42.035Z"
|
|
762
|
+
// or may be missing (undefined)
|
|
763
|
+
const msgTimestamp = !msgTs ? 0
|
|
764
|
+
: typeof msgTs === 'number' ? msgTs
|
|
765
|
+
: typeof msgTs === 'string' && /^\d+$/.test(msgTs) ? Number(msgTs)
|
|
766
|
+
: new Date(msgTs).getTime() || 0;
|
|
767
|
+
return msgTimestamp > lastTimestamp;
|
|
768
|
+
});
|
|
769
|
+
|
|
770
|
+
// If not found, start from end of transcript (all messages are older)
|
|
771
|
+
if (fromIndex === -1) {
|
|
772
|
+
log(`All messages older than last timeline event (${new Date(lastTimestamp).toISOString()}) - no new events`);
|
|
773
|
+
console.log(`[transcript] All ${transcript.length} messages are older than last timeline event - no new events to ingest`);
|
|
774
|
+
fromIndex = transcript.length;
|
|
775
|
+
} else {
|
|
776
|
+
log(`Found ${transcript.length - fromIndex} new messages starting from index ${fromIndex}/${transcript.length}`);
|
|
777
|
+
console.log(`[transcript] Found ${transcript.length - fromIndex} new messages starting from index ${fromIndex}/${transcript.length}`);
|
|
778
|
+
}
|
|
627
779
|
}
|
|
628
780
|
} else {
|
|
629
|
-
|
|
630
|
-
fromIndex =
|
|
781
|
+
// No timeline events — use local cursor to avoid reprocessing
|
|
782
|
+
fromIndex = cursorMessageCount;
|
|
783
|
+
if (cursorMessageCount > 0) {
|
|
784
|
+
console.log(`[transcript] No timeline events found - using local cursor (${cursorMessageCount})`);
|
|
785
|
+
} else {
|
|
786
|
+
console.log(`[transcript] No timeline events and no cursor - processing all transcript messages`);
|
|
787
|
+
}
|
|
631
788
|
}
|
|
632
789
|
} else {
|
|
633
|
-
|
|
634
|
-
|
|
790
|
+
fromIndex = cursorMessageCount;
|
|
791
|
+
console.log(`[transcript] Timeline query failed (${timelineResponse.status}) - using local cursor (${cursorMessageCount})`);
|
|
635
792
|
}
|
|
636
793
|
} catch (error) {
|
|
637
|
-
|
|
638
|
-
|
|
794
|
+
fromIndex = cursorMessageCount;
|
|
795
|
+
console.log(`[transcript] Failed to query timeline: ${error.message} - using local cursor (${cursorMessageCount})`);
|
|
639
796
|
}
|
|
640
797
|
}
|
|
641
798
|
|
|
799
|
+
// Clamp fromIndex to transcript length to prevent stale cursors from
|
|
800
|
+
// skipping past the end of a shorter transcript (e.g., cursor=500, transcript has 100 messages)
|
|
801
|
+
if (fromIndex > transcript.length) {
|
|
802
|
+
console.warn(`[transcript] Clamping fromIndex from ${fromIndex} to transcript length ${transcript.length} — cursor may be stale or transcript truncated`);
|
|
803
|
+
fromIndex = transcript.length;
|
|
804
|
+
}
|
|
805
|
+
|
|
642
806
|
// 3. Extract only NEW events from determined index
|
|
643
|
-
const allEvents = extractTimelineEvents(transcript, fromIndex);
|
|
807
|
+
const allEvents = extractTimelineEvents(transcript, fromIndex, parent_session_id);
|
|
644
808
|
|
|
645
809
|
log(`Extracted ${allEvents.length} events from transcript`);
|
|
810
|
+
|
|
646
811
|
if (allEvents.length === 0) {
|
|
647
812
|
log(`No new events since index ${fromIndex} - returning`);
|
|
648
813
|
console.log(`[transcript] No new events since index ${fromIndex} (transcript length: ${transcript.length})`);
|
|
649
|
-
return { events_pushed: 0 };
|
|
814
|
+
return { events_pushed: 0, normalized_events: 0 };
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
const normalizedEvents = normalizeTimelineEventsForIntelligence(allEvents, {
|
|
818
|
+
session_id: parent_session_id,
|
|
819
|
+
task_id,
|
|
820
|
+
provider: 'claude-code',
|
|
821
|
+
});
|
|
822
|
+
|
|
823
|
+
log(`Normalized ${normalizedEvents.length} intelligence events`);
|
|
824
|
+
if (typeof onNormalizedEvents === 'function') {
|
|
825
|
+
try {
|
|
826
|
+
await onNormalizedEvents(normalizedEvents);
|
|
827
|
+
} catch (error) {
|
|
828
|
+
log(`Failed to deliver normalized events: ${error.message}`);
|
|
829
|
+
}
|
|
650
830
|
}
|
|
651
831
|
|
|
652
832
|
// 4. Limit events to process (most recent N events)
|
|
@@ -677,12 +857,19 @@ export async function ingestTranscriptToTimeline(options) {
|
|
|
677
857
|
log(`===== INGESTION COMPLETE: ${successCount}/${events.length} events pushed (${failCount} failed) =====`);
|
|
678
858
|
console.log(`[transcript] Pushed ${successCount}/${events.length} events (${failCount} failed)`);
|
|
679
859
|
|
|
680
|
-
// 6. Update
|
|
681
|
-
if (
|
|
682
|
-
|
|
860
|
+
// 6. Update cursors to prevent duplicates on next call
|
|
861
|
+
if (successCount > 0) {
|
|
862
|
+
// Local cursor — use claude_session_id for tasks (unique per child session),
|
|
863
|
+
// parent_session_id for regular sessions (maps to transcript file)
|
|
864
|
+
const cursorKey = task_id ? claude_session_id : parent_session_id;
|
|
865
|
+
await writeCursor(cursorKey, transcript.length, successCount);
|
|
866
|
+
// Remote cursor (tasks only) — persists in relay
|
|
867
|
+
if (task_id) {
|
|
868
|
+
await updateLastIngestedIndex(task_id, parent_session_id, transcript.length, config);
|
|
869
|
+
}
|
|
683
870
|
}
|
|
684
871
|
|
|
685
|
-
return { events_pushed: successCount, events_failed: failCount };
|
|
872
|
+
return { events_pushed: successCount, events_failed: failCount, normalized_events: normalizedEvents.length };
|
|
686
873
|
}
|
|
687
874
|
|
|
688
875
|
/**
|
|
@@ -694,3 +881,75 @@ export async function getTranscriptLength(claude_session_id) {
|
|
|
694
881
|
const transcript = await readTranscript(claude_session_id);
|
|
695
882
|
return transcript.length;
|
|
696
883
|
}
|
|
884
|
+
|
|
885
|
+
// Export internals for unit testing
|
|
886
|
+
/**
|
|
887
|
+
* Extract normalized transcript entries for the intelligence pipeline.
|
|
888
|
+
* Operates on raw transcript messages (not timeline events).
|
|
889
|
+
*/
|
|
890
|
+
function extractNormalizedTranscriptEntries(transcript, fromIndex = 0, sessionId = '', harness = 'claude-code') {
|
|
891
|
+
const normalized = [];
|
|
892
|
+
for (let i = fromIndex; i < transcript.length; i++) {
|
|
893
|
+
normalized.push(normalizeTranscriptEntry(transcript[i], {
|
|
894
|
+
sessionId,
|
|
895
|
+
messageIndex: i,
|
|
896
|
+
harness,
|
|
897
|
+
}));
|
|
898
|
+
}
|
|
899
|
+
return normalized;
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
function toSessionFilename(sessionId) {
|
|
903
|
+
const safe = typeof sessionId === 'string' && sessionId.trim()
|
|
904
|
+
? sessionId.trim().replace(/[^a-zA-Z0-9._-]/g, '-')
|
|
905
|
+
: 'unknown-session';
|
|
906
|
+
return `${safe}.jsonl`;
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
/**
|
|
910
|
+
* Append normalized transcript entries to a local JSONL spool file.
|
|
911
|
+
* Enabled by default; disable with TELEPORTATION_INTELLIGENCE_LOCAL_SPOOL=false.
|
|
912
|
+
*/
|
|
913
|
+
async function appendLocalIntelligenceSpool(sessionId, entries, log = () => {}) {
|
|
914
|
+
if (!ENABLE_LOCAL_INTELLIGENCE_SPOOL || entries.length === 0) {
|
|
915
|
+
return { written: 0, skipped: true };
|
|
916
|
+
}
|
|
917
|
+
await mkdir(INTELLIGENCE_SPOOL_DIR, { recursive: true });
|
|
918
|
+
const filePath = join(INTELLIGENCE_SPOOL_DIR, toSessionFilename(sessionId));
|
|
919
|
+
const lines = entries.map(entry => JSON.stringify(entry)).join('\n') + '\n';
|
|
920
|
+
await appendFile(filePath, lines, 'utf8');
|
|
921
|
+
log(`Wrote ${entries.length} normalized entries to local spool: ${filePath}`);
|
|
922
|
+
return { written: entries.length, skipped: false, filePath };
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
/**
|
|
926
|
+
* Push normalized transcript entries to the relay intelligence endpoint.
|
|
927
|
+
* Off by default; enable with TELEPORTATION_INTELLIGENCE_REMOTE_INGEST=true.
|
|
928
|
+
*/
|
|
929
|
+
async function pushNormalizedEntriesRemote(entries, relayApiUrl, apiKey, sessionId, log = () => {}) {
|
|
930
|
+
if (!ENABLE_REMOTE_INTELLIGENCE_INGEST || entries.length === 0) {
|
|
931
|
+
return { pushed: 0, skipped: true };
|
|
932
|
+
}
|
|
933
|
+
try {
|
|
934
|
+
const response = await fetch(`${relayApiUrl}${INTELLIGENCE_BATCH_PATH}`, {
|
|
935
|
+
method: 'POST',
|
|
936
|
+
headers: {
|
|
937
|
+
'Content-Type': 'application/json',
|
|
938
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
939
|
+
},
|
|
940
|
+
body: JSON.stringify({ session_id: sessionId, entries }),
|
|
941
|
+
});
|
|
942
|
+
if (!response.ok) {
|
|
943
|
+
const body = await response.text().catch(() => '');
|
|
944
|
+
console.warn(`[transcript] Intelligence remote ingest failed: ${response.status} ${body}`);
|
|
945
|
+
return { pushed: 0, skipped: false, failed: true, status: response.status };
|
|
946
|
+
}
|
|
947
|
+
log(`Pushed ${entries.length} normalized entries to remote intelligence endpoint`);
|
|
948
|
+
return { pushed: entries.length, skipped: false };
|
|
949
|
+
} catch (error) {
|
|
950
|
+
console.warn(`[transcript] Intelligence remote ingest error: ${error.message}`);
|
|
951
|
+
return { pushed: 0, skipped: false, failed: true, error: error.message };
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
export { parseTimestamp, extractTimelineEvents, extractNormalizedTranscriptEntries, readCursor, writeCursor, pushEventsInChunks, deterministicEventId, normalizeTimelineEventsForIntelligence, appendLocalIntelligenceSpool, pushNormalizedEntriesRemote };
|
package/lib/daemon/utils.js
CHANGED
|
@@ -33,15 +33,6 @@ export function truncateOutput(output, label) {
|
|
|
33
33
|
return output.slice(0, MAX_OUTPUT_SIZE) + `\n\n... (output truncated, total size: ${output.length} characters) ...`;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
/**
|
|
37
|
-
* Sanitize for log (remove sensitive info)
|
|
38
|
-
*/
|
|
39
|
-
export function sanitizeForLog(data) {
|
|
40
|
-
if (!data) return data;
|
|
41
|
-
// Simple sanitization - in a real app, use a more robust library
|
|
42
|
-
return String(data).replace(/Bearer\s+[a-zA-Z0-9._-]+/g, 'Bearer [REDACTED]');
|
|
43
|
-
}
|
|
44
|
-
|
|
45
36
|
/**
|
|
46
37
|
* Validation helpers
|
|
47
38
|
*/
|