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.
@@ -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/timeline/${session_id}`, {
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.meta?.task_id === task_id);
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' && e.source === 'autonomous_task'
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].meta?.claude_session_id) {
65
- claude_session_id = taskEvents[i].meta.claude_session_id;
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.meta?.approval_id === e.meta?.approval_id
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.meta?.reason || 'Task paused',
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.meta?.reason || 'Task stopped',
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.meta?.stop_reason;
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 paused and resumed with user message, use that message
187
- if (task.pending_question) {
188
- return task.pending_question;
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 parsed = new Date(entry.timestamp).getTime();
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
- * @returns {Array} Timeline events to push
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: entry.type === 'autonomous_task' ? 'autonomous_task' : 'cli_interactive',
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: entry.type === 'autonomous_task' ? 'autonomous_task' : 'cli_interactive',
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: entry.type === 'autonomous_task' ? 'autonomous_task' : 'cli_interactive',
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
- return events;
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
- fromIndex = task?.last_transcript_index || 0;
579
- console.log(`[transcript] Using task's last ingested index: ${fromIndex}`);
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: 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
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
- 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;
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(`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}`);
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
- console.log(`[transcript] No timeline events found - will process all transcript messages`);
630
- fromIndex = 0;
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
- console.log(`[transcript] Timeline query failed with status ${timelineResponse.status} - processing all messages`);
634
- fromIndex = 0;
790
+ fromIndex = cursorMessageCount;
791
+ console.log(`[transcript] Timeline query failed (${timelineResponse.status}) - using local cursor (${cursorMessageCount})`);
635
792
  }
636
793
  } catch (error) {
637
- console.log(`[transcript] Failed to query timeline: ${error.message}, processing all messages`);
638
- fromIndex = 0;
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 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);
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 };
@@ -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
  */