'use strict';
const crypto = require('crypto');
const fsp = require('fs/promises');
const EventEmitter = require('events');
const { looksLikeCompactContinuation } = require('./compact-stitch');
const { codexMessageFromEntry, createCodexUserDeduper } = require('./session-history');
const { isProviderGeneratedUserContextText } = require('./provider-user-context');

const MAX_READ_BYTES = 1024 * 1024; // 1MB cap per incremental read
const MAX_TEXT_LEN = 50 * 1024;     // 50KB text cap per event
const INITIAL_TAIL_BYTES = 2 * 1024 * 1024;
const INITIAL_TAIL_MAX_CHUNKS = Math.ceil(INITIAL_TAIL_BYTES / MAX_READ_BYTES) + 1;
const DEFAULT_RING_SIZE = 200;
const PTY_ACTIVE_WINDOW_MS = 5000;
const JSONL_ACTIVE_WINDOW_MS = 10000;
const IDLE_THRESHOLD_MS = 30000;
const SUMMARY_DEBOUNCE_MS = 2000;
const SUMMARY_RETRY_COOLDOWN_MS = 60000;
const PROMPT_CACHE_TEXT_LIMIT = 4000;
const SUMMARY_TEXT_LIMIT = 220;
const PROGRESS_BULLET_LIMIT = 3;
const STANDUP_RECENT_TURN_LIMIT = 6;
const STANDUP_RECENT_TURN_TEXT_LIMIT = 500;

function positiveInteger(value, fallback) {
  const parsed = Number(value);
  return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback;
}

// --- JsonlTailer: byte-offset tracking for incremental JSONL reads ---

class JsonlTailer {
  constructor() {
    this._cursors = new Map(); // filePath -> { offset, partial }
  }

  /**
   * Read new bytes from a JSONL file since last read.
   * Returns parsed JSON objects (one per complete line).
   */
  async readIncremental(filePath) {
    let cursor = this._cursors.get(filePath);
    if (!cursor) {
      cursor = { offset: 0, partial: '' };
      this._cursors.set(filePath, cursor);
    }

    let stat;
    try { stat = await fsp.stat(filePath); } catch { return []; }

    // File truncated (rotated to .bak) — reset
    if (stat.size < cursor.offset) {
      cursor.offset = 0;
      cursor.partial = '';
    }

    if (stat.size <= cursor.offset) return [];

    const readSize = Math.min(stat.size - cursor.offset, MAX_READ_BYTES);
    let fh;
    try {
      fh = await fsp.open(filePath, 'r');
      const buf = Buffer.alloc(readSize);
      await fh.read(buf, 0, readSize, cursor.offset);
      const raw = cursor.partial + buf.toString('utf8');
      cursor.offset += readSize;

      // Handle partial last line (no trailing newline)
      const lastNewline = raw.lastIndexOf('\n');
      let complete;
      if (lastNewline === -1) {
        cursor.partial = raw;
        return [];
      }
      complete = raw.slice(0, lastNewline);
      cursor.partial = raw.slice(lastNewline + 1);

      const entries = [];
      for (const line of complete.split('\n')) {
        if (!line) continue;
        try { entries.push(JSON.parse(line)); } catch {}
      }
      return entries;
    } catch { return []; }
    finally { if (fh) await fh.close(); }
  }

  /**
   * Read all currently available complete JSONL entries, while preserving the
   * 1MB per-read cap used by readIncremental().
   */
  async readAvailable(filePath, { maxChunks = 256 } = {}) {
    const all = [];
    for (let i = 0; i < maxChunks; i++) {
      const before = this._cursors.get(filePath)?.offset || 0;
      const entries = await this.readIncremental(filePath);
      if (entries.length) all.push(...entries);

      let stat;
      try { stat = await fsp.stat(filePath); } catch { break; }
      const cursor = this._cursors.get(filePath);
      if (!cursor || stat.size <= cursor.offset) break;
      if (cursor.offset <= before && entries.length === 0) break;
    }
    return all;
  }

  /** Remove cursor for a file path */
  removeCursor(filePath) {
    this._cursors.delete(filePath);
  }

  /** True if this file already has a tracked read cursor */
  hasCursor(filePath) {
    return this._cursors.has(filePath);
  }

  /** Get all tracked file paths */
  getTrackedPaths() {
    return [...this._cursors.keys()];
  }

  /** Get current read cursor for a file path */
  getCursor(filePath) {
    const cursor = this._cursors.get(filePath);
    return cursor ? { offset: cursor.offset, partial: cursor.partial } : null;
  }

  /** Reset cursor to re-read from a given offset */
  resetCursor(filePath, offset = 0) {
    this._cursors.set(filePath, { offset, partial: '' });
  }
}

// --- StreamEvent helpers ---

function extractText(content) {
  if (typeof content === 'string') return content;
  if (!Array.isArray(content)) return '';
  const parts = [];
  let imageCount = 0;
  for (const block of content) {
    if (!block || typeof block !== 'object') continue;
    if ((block.type === 'text' || block.type === 'input_text' || block.type === 'output_text') && block.text) {
      parts.push(block.text);
    } else if (block.type === 'image' || block.type === 'input_image' || block.type === 'image_ref') {
      parts.push(`[Image #${++imageCount}]`);
    }
  }
  return parts.join('\n');
}

function stripBase64Blobs(contentBlocks) {
  if (!Array.isArray(contentBlocks)) return contentBlocks;
  return contentBlocks.map(b => {
    if (b.type === 'image' && b.source?.type === 'base64') {
      return { type: 'image_ref', media_type: b.source.media_type, size: (b.source.data || '').length };
    }
    return b;
  });
}

function extractToolUses(contentBlocks) {
  if (!Array.isArray(contentBlocks)) return [];
  return contentBlocks
    .filter(b => b.type === 'tool_use')
    .map(b => ({ name: b.name, id: b.id }));
}

function eventTimestampMs(entry) {
  if (!entry || !entry.timestamp) return Date.now();
  const ts = new Date(entry.timestamp).getTime();
  return Number.isFinite(ts) ? ts : Date.now();
}

function truncateText(text, limit) {
  const t = String(text || '').trim();
  if (!t || !limit || t.length <= limit) return t;
  return t.slice(0, Math.max(0, limit - 3)).trimEnd() + '...';
}

function roleForEventType(type) {
  if (type === 'user' || type === 'assistant') return type;
  if (type === 'tool_result') return 'tool';
  if (type === 'summary') return 'system';
  return 'event';
}

function captureKindForEventType(type) {
  if (type === 'user' || type === 'assistant') return 'message';
  if (type === 'tool_result') return 'tool_result';
  if (type === 'summary') return 'summary';
  return 'event';
}

function decorateStreamEvent(evt, source = 'jsonl') {
  if (!evt || typeof evt !== 'object') return evt;
  const out = {
    ...evt,
    schemaVersion: 1,
    eventId: evt.eventId || `${evt.sessionId || 'session'}:${evt.seq ?? 'x'}:${evt.type || 'event'}`,
    captureKind: evt.captureKind || captureKindForEventType(evt.type),
    role: evt.role || roleForEventType(evt.type),
    source: evt.source || source,
    agentSessionId: evt.agentSessionId || evt.sessionId,
  };
  if (out.text == null && out.data && typeof out.data.text === 'string') out.text = out.data.text;
  return out;
}

// --- SessionStream: EventEmitter-based structured event stream ---

class SessionStream extends EventEmitter {
  /**
   * @param {Object} opts
   * @param {import('./fs-watcher').JsonlWatcher} opts.jsonlWatcher
   * @param {Map} opts.sessions - CTM in-memory sessions map
   * @param {number} [opts.ringSize=200]
   */
  constructor({ jsonlWatcher, sessions, ringSize = DEFAULT_RING_SIZE, dbModule, summaryProvider } = {}) {
    super();
    this._jsonlWatcher = jsonlWatcher;
    this._sessions = sessions;
    this._ringSize = ringSize;
    this._dbModule = dbModule || null;
    this._summaryProvider = typeof summaryProvider === 'function' ? summaryProvider : null;
    this._tailer = new JsonlTailer();
    this._stopped = false;

    // Per-session state: agentSessionId -> SessionState
    this._state = new Map();

    // CTM session ID -> agent session ID mapping
    this._ctmToAgent = new Map();

    // Summary debounce timers
    this._summaryTimers = new Map();

    // Coalesce overlapping drains for the same JSONL. fs.watch, startup link,
    // and reconciliation can all notice the same large file at once; parsing it
    // concurrently blocks the Node event loop and starves terminal input.
    this._readInFlight = new Map();
    this._readPending = new Map();
    this._reconcileCursor = 0;

    // Bound handlers for cleanup
    this._onFileNew = this._handleFileNew.bind(this);
    this._onFileChange = this._handleFileChange.bind(this);
  }

  start() {
    if (this._stopped) return;
    if (this._jsonlWatcher) {
      this._jsonlWatcher.on('file-new', this._onFileNew);
      this._jsonlWatcher.on('file-change', this._onFileChange);
    }
  }

  stop() {
    this._stopped = true;
    if (this._jsonlWatcher) {
      this._jsonlWatcher.off('file-new', this._onFileNew);
      this._jsonlWatcher.off('file-change', this._onFileChange);
    }
    for (const timer of this._summaryTimers.values()) clearTimeout(timer);
    this._summaryTimers.clear();
    this._readPending.clear();
  }

  // --- Public API ---

  /** Get recent events from ring buffer */
  getRecentEvents(sessionId, limit = 50) {
    const st = this._state.get(sessionId);
    if (!st) return [];
    return st.ring.slice(-limit);
  }

  /**
   * Get summary with two-layer merge: ring buffer (hot) + session_conversations DB (warm).
   * Returns legacy fields plus structured intent/progress for hovercards.
   */
  getSummary(sessionId, turns = 3) {
    const st = this._state.get(sessionId);
    if (!st) return null;

    // Layer 1: Extract turns from ring buffer
    const ringTurns = [];
    for (let i = st.ring.length - 1; i >= 0 && ringTurns.length < turns; i--) {
      const evt = st.ring[i];
      if (evt.type === 'user' || evt.type === 'assistant') {
        ringTurns.unshift({ role: evt.type, text: evt.data.text, timestamp: evt.timestamp });
      }
    }

    // Layer 2: resolve the durable conversation row used by the Conversation
    // tab. Cheap prompt columns are always considered; the larger messages JSON
    // is parsed when the hot ring is incomplete or older than the durable row.
    const dbRow = this._dbConversationRowForSummary(sessionId);
    const dbTurns = this._shouldLoadDbTurnsForSummary(ringTurns, turns, dbRow)
      ? this._dbTurnsForSummary(sessionId, Math.max(turns, STANDUP_RECENT_TURN_LIMIT), dbRow)
      : [];
    const mergedTurns = this._mergeTurns(ringTurns, dbTurns).slice(-Math.max(turns, STANDUP_RECENT_TURN_LIMIT));

    // Build one prompt ledger from hot prompt cache + durable user turns. This
    // is the single freshness source for lastPrompt, currentTask, and AI-summary
    // staleness checks.
    let promptEntries = this._mergePromptEntries(
      st.userPromptCache || [],
      this._dbPromptEntriesForSummary(sessionId, dbRow),
      this._promptEntriesFromTurns(mergedTurns)
    );

    let lastPromptEntry = promptEntries[promptEntries.length - 1] || null;
    // displayPrompt: a content-rich pick used when no LLM summary is ready
    // yet. Skips ack-only prompts ("yes", "go ahead", "/cmd ok") that have
    // no informational value about what the session is doing.
    let displayPromptEntry = this._pickContentRichPromptEntry(promptEntries) || lastPromptEntry;
    if (!lastPromptEntry) {
      for (let i = mergedTurns.length - 1; i >= 0; i--) {
        if (mergedTurns[i].role === 'user' && mergedTurns[i].text) {
          const cleaned = this._cleanUserText(mergedTurns[i].text);
          if (cleaned) {
            lastPromptEntry = this._promptEntry(cleaned, mergedTurns[i].timestamp || 0);
            if (!displayPromptEntry && this._isContentRich(cleaned)) displayPromptEntry = lastPromptEntry;
            break;
          }
        }
      }
    }

    if (!displayPromptEntry) displayPromptEntry = lastPromptEntry;
    if (promptEntries.length) {
      st.userPromptCache = promptEntries.slice(-10);
      promptEntries = st.userPromptCache;
    }
    const promptSnapshot = this._promptCacheSnapshotFromEntries(promptEntries);
    const latestPrompt = this._buildPromptPayload(lastPromptEntry);
    const displayPrompt = this._buildPromptPayload(displayPromptEntry);
    const summaryRecord = this._usableSummary(st.cachedSummary);
    const aiSummary = this._buildAiSummary(st.cachedSummary, summaryRecord, promptSnapshot, sessionId);
    const currentTask = this._buildCurrentTask(aiSummary, latestPrompt, displayPrompt);
    const summary = aiSummary.text || (st.cachedSummary?.model === 'fallback' ? st.cachedSummary.text : null);
    const intent = this._buildIntent(currentTask, displayPromptEntry, lastPromptEntry);
    const progress = this._buildProgress(st, intent);
    const recentTurns = this._standupRecentTurns(mergedTurns);
    if (aiSummary.status === 'fallback' || aiSummary.status === 'stale' || aiSummary.status === 'rejected') {
      this._maybeRetryFallbackSummary(sessionId, st);
    }

    return {
      sessionId,
      ctmSessionId: st.ctmSessionId,
      status: this._computeStatus(st),
      summary,
      summarySource: summaryRecord?.model || null,
      lastPrompt: this._promptText(lastPromptEntry),
      displayPrompt: this._promptText(displayPromptEntry),
      latestPrompt,
      aiSummary,
      currentTask,
      intent,
      progress,
      recentTurns,
      turnCount: mergedTurns.length,
      lastActivity: st.lastActivity,
    };
  }

  /** Get current status string */
  getStatus(sessionId) {
    const st = this._state.get(sessionId);
    if (!st) return 'unknown';
    return this._computeStatus(st);
  }

  /** Get all session statuses */
  getAllStatuses() {
    const result = [];
    for (const [sessionId, st] of this._state) {
      result.push({
        sessionId,
        ctmSessionId: st.ctmSessionId,
        status: this._computeStatus(st),
        lastActivity: st.lastActivity,
        eventCount: st.ring.length,
      });
    }
    return result;
  }

  /** Subscribe to a specific session's events. Returns unsubscribe fn. */
  subscribe(sessionId, cb) {
    const eventName = `session:${sessionId}`;
    this.on(eventName, cb);
    return () => this.off(eventName, cb);
  }

  /** Subscribe to all session events. Returns unsubscribe fn. */
  subscribeAll(cb) {
    this.on('event', cb);
    return () => this.off('event', cb);
  }

  /** Signal PTY activity for a CTM session */
  feedPtyActivity(ctmSessionId) {
    const agentId = this._ctmToAgent.get(ctmSessionId);
    if (!agentId) return;
    const st = this._state.get(agentId);
    if (!st) return;
    const now = Date.now();
    const oldStatus = this._computeStatus(st);
    st.lastPtyActivity = now;
    st.lastActivity = now;
    const newStatus = this._computeStatus(st);
    if (oldStatus !== newStatus) {
      this._emitStatus(agentId, st, oldStatus, newStatus, 'pty-activity');
    }
  }

  /** Register CTM <-> agent session mapping */
  linkSession(ctmSessionId, agentSessionId, jsonlPath) {
    const previousAgentId = ctmSessionId ? this._ctmToAgent.get(ctmSessionId) : null;
    if (previousAgentId && previousAgentId !== agentSessionId) {
      const previousState = this._state.get(previousAgentId);
      if (previousState && previousState.ctmSessionId === ctmSessionId) {
        previousState.ctmSessionId = null;
      }
    }
    this._ctmToAgent.set(ctmSessionId, agentSessionId);
    let st = this._state.get(agentSessionId);
    if (!st) {
      st = this._createSessionState(agentSessionId, ctmSessionId, jsonlPath);
      this._state.set(agentSessionId, st);
    } else {
      st.ctmSessionId = ctmSessionId;
      if (jsonlPath) st.jsonlPath = jsonlPath;
    }
    // Perform initial read to populate the hot ring buffer. Existing/restored
    // sessions can have hundreds of MB of durable JSONL history; durable history
    // lives in DB/cache, while SessionStream only needs the live tail.
    if (jsonlPath) {
      const initial = !this._tailer.hasCursor(jsonlPath);
      this._readAndProcess(agentSessionId, jsonlPath, { drain: true, rememberStat: true, initial }).catch(() => {});
    }
    this.emit('link', { ctmSessionId, agentSessionId, jsonlPath });
  }

  /** Clean up on session exit */
  removeSession(agentSessionId) {
    const st = this._state.get(agentSessionId);
    if (!st) return;
    const ctmSessionId = st.ctmSessionId;
    // Emit final status
    const oldStatus = this._computeStatus(st);
    if (oldStatus !== 'exited') {
      this._emitStatus(agentSessionId, st, oldStatus, 'exited', 'session-exit');
    }
    // Clean up
    if (st.jsonlPath) this._tailer.removeCursor(st.jsonlPath);
    if (st.ctmSessionId) this._ctmToAgent.delete(st.ctmSessionId);
    const timer = this._summaryTimers.get(agentSessionId);
    if (timer) { clearTimeout(timer); this._summaryTimers.delete(agentSessionId); }
    this._state.delete(agentSessionId);
    this.emit('unlink', { ctmSessionId, agentSessionId });
  }

  /** Get the set of actively tracked session IDs */
  getTrackedSessions() {
    return [...this._state.keys()];
  }

  /** Run reconciliation: stat all tracked files, read if changed */
  async reconcile(opts = {}) {
    const maxSessions = positiveInteger(opts.maxSessions, 12);
    const maxChunksPerSession = positiveInteger(opts.maxChunksPerSession, 4);
    const tracked = [...this._state.entries()]
      .filter(([, st]) => !!st.jsonlPath)
      .sort(([a], [b]) => String(a).localeCompare(String(b)));
    if (!tracked.length) {
      this._reconcileCursor = 0;
      return { checked: 0, total: 0, partial: false };
    }

    const start = Math.min(this._reconcileCursor, Math.max(0, tracked.length - 1));
    const end = Math.min(start + maxSessions, tracked.length);
    const batch = tracked.slice(start, end);
    this._reconcileCursor = end >= tracked.length ? 0 : end;

    let checked = 0;
    let changed = 0;
    for (const [agentId, st] of batch) {
      checked++;
      try {
        const stat = await fsp.stat(st.jsonlPath);
        if (stat.size !== st.lastKnownSize || stat.mtimeMs !== st.lastKnownMtime) {
          changed++;
          await this._readAndProcess(agentId, st.jsonlPath, {
            drain: true,
            rememberStat: true,
            maxChunks: maxChunksPerSession,
          });
        }
      } catch {}
      await new Promise(resolve => setImmediate(resolve));
    }
    return { checked, changed, total: tracked.length, partial: this._reconcileCursor !== 0 };
  }

  /** Clean up stale non-CTM sessions inactive for longer than maxAge */
  cleanStale(maxAgeMs = 30 * 60 * 1000) {
    const now = Date.now();
    let cleaned = 0;
    for (const [agentId, st] of this._state) {
      if (st.ctmSessionId) continue; // Skip CTM-linked sessions
      if (now - st.lastActivity > maxAgeMs) {
        this.removeSession(agentId);
        cleaned++;
      }
    }
    return cleaned;
  }

  // --- Internal ---

  _createSessionState(agentSessionId, ctmSessionId, jsonlPath) {
    return {
      agentSessionId,
      ctmSessionId: ctmSessionId || null,
      jsonlPath: jsonlPath || null,
      ring: [],
      userPromptCache: [],  // Last 10 cleaned user text prompts (survives ring buffer churn)
      codexUserDeduper: createCodexUserDeduper(),
      seq: 0,
      lastActivity: 0,
      lastPtyActivity: 0,
      lastJsonlActivity: 0,
      lastAssistantParent: null,
      cachedSummary: null,
      lastSummaryAttempt: 0,
      lastKnownSize: 0,
      lastKnownMtime: 0,
    };
  }

  _handleFileNew({ filePath, sessionId }) {
    if (this._stopped) return;
    // Only track if this session is already linked or if it matches a CTM session
    let st = this._state.get(sessionId);
    if (!st) {
      // Check if any CTM session is waiting for this
      for (const [ctmId, session] of this._sessions) {
        if (session._claudeSessionId === sessionId) {
          st = this._createSessionState(sessionId, ctmId, filePath);
          this._state.set(sessionId, st);
          this._ctmToAgent.set(ctmId, sessionId);
          break;
        }
      }
    }
    if (st && !st.jsonlPath) st.jsonlPath = filePath;
    if (st) this._readAndProcess(sessionId, filePath, { drain: true, rememberStat: true }).catch(() => {});
  }

  _handleFileChange({ filePath, sessionId }) {
    if (this._stopped) return;
    const st = this._state.get(sessionId);
    if (!st) return;
    this._readAndProcess(sessionId, filePath, { drain: true, rememberStat: true }).catch(() => {});
  }

  async _readAndProcess(agentSessionId, filePath, opts = {}) {
    const key = filePath || agentSessionId;
    if (this._readInFlight.has(key)) {
      this._readPending.set(key, {
        agentSessionId,
        filePath,
        opts: { ...opts, drain: true, rememberStat: true, initial: false },
      });
      return this._readInFlight.get(key);
    }

    const promise = this._drainReadQueue(key, agentSessionId, filePath, opts)
      .finally(() => {
        this._readInFlight.delete(key);
      });
    this._readInFlight.set(key, promise);
    return promise;
  }

  async _drainReadQueue(key, agentSessionId, filePath, opts = {}) {
    let next = { agentSessionId, filePath, opts };
    while (next) {
      await this._readAndProcessOnce(next.agentSessionId, next.filePath, next.opts);
      next = this._readPending.get(key) || null;
      this._readPending.delete(key);
    }
  }

  async _primeInitialCursor(filePath) {
    if (!filePath || this._tailer.hasCursor(filePath)) return;
    try {
      const stat = await fsp.stat(filePath);
      const offset = Math.max(0, stat.size - INITIAL_TAIL_BYTES);
      this._tailer.resetCursor(filePath, offset);
    } catch {}
  }

  async _readAndProcessOnce(agentSessionId, filePath, opts = {}) {
    if (opts.initial) await this._primeInitialCursor(filePath);
    const entries = opts.drain
      ? await this._tailer.readAvailable(filePath, {
          ...opts,
          maxChunks: opts.initial ? INITIAL_TAIL_MAX_CHUNKS : opts.maxChunks,
        })
      : await this._tailer.readIncremental(filePath);
    const st = this._state.get(agentSessionId);
    if (!st) return;

    if (opts.rememberStat) await this._rememberFileStat(st, filePath);
    if (entries.length === 0) return;

    for (const entry of entries) {
      this._processEntry(agentSessionId, st, entry);
    }
  }

  async _rememberFileStat(st, filePath) {
    try {
      const stat = await fsp.stat(filePath);
      const cursor = this._tailer.getCursor(filePath);
      st.lastKnownSize = cursor && cursor.offset < stat.size ? cursor.offset : stat.size;
      st.lastKnownMtime = stat.mtimeMs;
    } catch {}
  }

  _markJsonlActivity(st, timestampMs) {
    const ts = Number.isFinite(timestampMs) ? timestampMs : Date.now();
    st.lastActivity = Math.max(st.lastActivity || 0, ts);
    st.lastJsonlActivity = Math.max(st.lastJsonlActivity || 0, ts);
    return ts;
  }

  _processEntry(agentSessionId, st, entry) {
    if (this._processCodexEntry(agentSessionId, st, entry)) return;

    if (entry.type === 'user' && entry.message?.role === 'user') {
      const timestamp = eventTimestampMs(entry);
      const content = entry.message.content;
      let text = extractText(content);
      if (text.length > MAX_TEXT_LEN) text = text.slice(0, MAX_TEXT_LEN) + `...truncated (${text.length} chars)`;
      if (isProviderGeneratedUserContextText(text)) return;

      const evt = decorateStreamEvent({
        type: 'user',
        sessionId: agentSessionId,
        ctmSessionId: st.ctmSessionId,
        timestamp,
        seq: st.seq++,
        data: {
          text,
          contentBlocks: stripBase64Blobs(Array.isArray(content) ? content : [{ type: 'text', text }]),
          cwd: entry.cwd || undefined,
          gitBranch: entry.gitBranch || undefined,
          parentUuid: entry.uuid || undefined,
        },
      }, 'claude-jsonl');
      st.ring.push(evt);
      if (st.ring.length > this._ringSize) st.ring.shift();
      this._markJsonlActivity(st, timestamp);
      this._emitEvent(agentSessionId, evt);
      // Cache meaningful user prompts separately (survives ring buffer churn from tool_results)
      if (text) this._cacheUserPrompt(agentSessionId, st, text, timestamp);

    } else if (entry.type === 'assistant' && entry.message?.role === 'assistant') {
      const timestamp = eventTimestampMs(entry);
      const content = entry.message.content;
      if (!Array.isArray(content)) return;

      let text = extractText(content);
      if (text.length > MAX_TEXT_LEN) text = text.slice(0, MAX_TEXT_LEN) + `...truncated (${text.length} chars)`;
      const toolUses = extractToolUses(content);
      const parentUuid = entry.parentUuid || undefined;

      // Deduplication: same parentUuid = streaming update
      if (parentUuid && parentUuid === st.lastAssistantParent) {
        // Find and replace last assistant event with this parentUuid
        for (let i = st.ring.length - 1; i >= 0; i--) {
          if (st.ring[i].type === 'assistant' && st.ring[i].data.parentUuid === parentUuid) {
            st.ring[i] = decorateStreamEvent({
              ...st.ring[i],
              timestamp,
              data: {
                text,
                contentBlocks: stripBase64Blobs(content),
                model: entry.model || undefined,
                toolUses: toolUses.length ? toolUses : undefined,
                parentUuid,
              },
            }, 'claude-jsonl');
            this._markJsonlActivity(st, timestamp);
            this._emitEvent(agentSessionId, { ...st.ring[i], _update: true }, 'event:update');
            return;
          }
        }
      }

      st.lastAssistantParent = parentUuid;
      const evt = decorateStreamEvent({
        type: 'assistant',
        sessionId: agentSessionId,
        ctmSessionId: st.ctmSessionId,
        timestamp,
        seq: st.seq++,
        data: {
          text,
          contentBlocks: stripBase64Blobs(content),
          model: entry.model || undefined,
          toolUses: toolUses.length ? toolUses : undefined,
          parentUuid,
        },
      }, 'claude-jsonl');
      st.ring.push(evt);
      if (st.ring.length > this._ringSize) st.ring.shift();

      // Update status based on tool use
      const oldStatus = this._computeStatus(st);
      this._markJsonlActivity(st, timestamp);
      this._emitEvent(agentSessionId, evt);
      const newStatus = this._computeStatus(st);
      if (oldStatus !== newStatus) {
        this._emitStatus(agentSessionId, st, oldStatus, newStatus, 'jsonl-event');
      }

    } else if (entry.type === 'tool_result') {
      const timestamp = eventTimestampMs(entry);
      const content = entry.message?.content;
      let text = extractText(content || '');
      if (text.length > MAX_TEXT_LEN) text = text.slice(0, MAX_TEXT_LEN) + `...truncated (${text.length} chars)`;

      const evt = decorateStreamEvent({
        type: 'tool_result',
        sessionId: agentSessionId,
        ctmSessionId: st.ctmSessionId,
        timestamp,
        seq: st.seq++,
        data: {
          text,
          contentBlocks: stripBase64Blobs(Array.isArray(content) ? content : []),
        },
      }, 'claude-jsonl');
      st.ring.push(evt);
      if (st.ring.length > this._ringSize) st.ring.shift();
      this._markJsonlActivity(st, timestamp);
      this._emitEvent(agentSessionId, evt);
    }
  }

  _assistantTextKey(text) {
    const normalized = String(text || '').replace(/\s+/g, ' ').trim();
    if (!normalized) return '';
    return crypto.createHash('sha1').update(normalized).digest('hex');
  }

  _hasRecentAssistantText(st, text) {
    const key = this._assistantTextKey(text);
    if (!key || !st || !Array.isArray(st.ring)) return false;
    for (let i = st.ring.length - 1, seen = 0; i >= 0 && seen < 20; i--) {
      const evt = st.ring[i];
      if (evt?.type !== 'assistant') continue;
      seen++;
      if (this._assistantTextKey(evt.data?.text || evt.text || '') === key) return true;
    }
    return false;
  }

  _appendCodexAssistantEvent(agentSessionId, st, { timestamp, text, data = {}, source = 'codex-jsonl' }) {
    const oldStatus = this._computeStatus(st);
    const evt = decorateStreamEvent({
      type: 'assistant',
      sessionId: agentSessionId,
      ctmSessionId: st.ctmSessionId,
      timestamp,
      seq: st.seq++,
      data: {
        text,
        contentBlocks: [{ type: 'text', text }],
        ...data,
      },
    }, source);
    st.ring.push(evt);
    if (st.ring.length > this._ringSize) st.ring.shift();
    this._markJsonlActivity(st, timestamp);
    this._emitEvent(agentSessionId, evt);
    const newStatus = this._computeStatus(st);
    if (oldStatus !== newStatus) {
      this._emitStatus(agentSessionId, st, oldStatus, newStatus, 'codex-jsonl-event');
    }
    return evt;
  }

  _processCodexEntry(agentSessionId, st, entry) {
    if (entry?.type === 'event_msg' && entry.payload?.type === 'task_complete' && entry.payload.last_agent_message) {
      const timestamp = eventTimestampMs(entry);
      const rawText = String(entry.payload.last_agent_message || '').trim();
      const text = rawText.length > MAX_TEXT_LEN ? rawText.slice(0, MAX_TEXT_LEN) + `...truncated (${rawText.length} chars)` : rawText;
      if (text && !this._hasRecentAssistantText(st, text)) {
        this._appendCodexAssistantEvent(agentSessionId, st, {
          timestamp,
          text,
          data: {
            finalSource: 'task_complete',
            turnId: entry.payload.turn_id || '',
            durationMs: entry.payload.duration_ms || 0,
          },
        });
      }
      const evt = decorateStreamEvent({
        type: 'turn_complete',
        sessionId: agentSessionId,
        ctmSessionId: st.ctmSessionId,
        timestamp,
        seq: st.seq++,
        data: {
          text,
          turnId: entry.payload.turn_id || '',
          durationMs: entry.payload.duration_ms || 0,
        },
      }, 'codex-jsonl');
      // Do not append this lifecycle event to the ring buffer. The assistant
      // text above is the replayable summary anchor; this event exists for
      // Codex terminal recovery subscribers.
      this._emitEvent(agentSessionId, evt);
      return true;
    }

    const msg = codexMessageFromEntry(entry);
    if (!msg) return false;

    if (msg.role === 'user') {
      if (!st.codexUserDeduper) st.codexUserDeduper = createCodexUserDeduper();
      if (!st.codexUserDeduper.remember(msg.text, msg.timestamp)) return true;
      const timestamp = eventTimestampMs({ timestamp: msg.timestamp });
      const evt = decorateStreamEvent({
        type: 'user',
        sessionId: agentSessionId,
        ctmSessionId: st.ctmSessionId,
        timestamp,
        seq: st.seq++,
        data: {
          text: msg.text.length > MAX_TEXT_LEN ? msg.text.slice(0, MAX_TEXT_LEN) + `...truncated (${msg.text.length} chars)` : msg.text,
          contentBlocks: [{ type: 'text', text: msg.text }],
        },
      }, 'codex-jsonl');
      st.ring.push(evt);
      if (st.ring.length > this._ringSize) st.ring.shift();
      this._markJsonlActivity(st, timestamp);
      this._emitEvent(agentSessionId, evt);
      this._cacheUserPrompt(agentSessionId, st, msg.text, timestamp);
      return true;
    }

    if (msg.role === 'assistant') {
      const timestamp = eventTimestampMs({ timestamp: msg.timestamp });
      const text = msg.text.length > MAX_TEXT_LEN ? msg.text.slice(0, MAX_TEXT_LEN) + `...truncated (${msg.text.length} chars)` : msg.text;
      this._appendCodexAssistantEvent(agentSessionId, st, { timestamp, text });
      return true;
    }

    return false;
  }

  _emitEvent(agentSessionId, evt, eventName = 'event') {
    this.emit(eventName, evt);
    this.emit(`session:${agentSessionId}`, evt);
  }

  _emitStatus(agentSessionId, st, oldStatus, newStatus, reason) {
    const statusEvt = {
      sessionId: agentSessionId,
      ctmSessionId: st.ctmSessionId,
      oldStatus,
      newStatus,
      reason,
      timestamp: Date.now(),
      lastActivity: st.lastActivity || 0,
      lastPtyActivity: st.lastPtyActivity || 0,
      lastJsonlActivity: st.lastJsonlActivity || 0,
    };
    this.emit('status', statusEvt);
  }

  _cacheUserPrompt(agentSessionId, st, text, timestamp) {
    const cleaned = this._cleanUserText(text);
    if (!cleaned) return;
    st.userPromptCache.push(this._promptEntry(cleaned, timestamp));
    if (st.userPromptCache.length > 10) st.userPromptCache.shift();
    this._debounceSummary(agentSessionId, st);
  }

  _promptEntry(text, timestamp = 0) {
    const cleaned = truncateText(text, PROMPT_CACHE_TEXT_LIMIT);
    if (!cleaned) return null;
    return {
      text: cleaned,
      timestamp: Number.isFinite(timestamp) ? timestamp : 0,
    };
  }

  _promptText(entry) {
    if (!entry) return '';
    return typeof entry === 'string' ? entry : (entry.text || '');
  }

  _promptTimestamp(entry) {
    if (!entry || typeof entry === 'string') return 0;
    return Number.isFinite(entry.timestamp) ? entry.timestamp : 0;
  }

  _promptEntryKey(entry) {
    const text = this._promptText(entry);
    if (!text) return '';
    return `${this._promptTimestamp(entry) || 0}\u0000${text}`;
  }

  _buildPromptPayload(entry) {
    const text = this._promptText(entry);
    const timestamp = this._promptTimestamp(entry);
    return {
      text: text || null,
      timestamp,
      isContentRich: this._isContentRich(text),
    };
  }

  _promptCacheSnapshotFromEntries(entries) {
    const normalizedEntries = (entries || [])
      .filter(entry => this._promptText(entry))
      .map(entry => ({
        text: this._promptText(entry),
        timestamp: this._promptTimestamp(entry),
      }));
    const prompts = normalizedEntries.map(entry => entry.text);
    const latestEntry = normalizedEntries[normalizedEntries.length - 1] || null;
    return {
      entries: normalizedEntries,
      prompts,
      latestEntry,
      latestText: latestEntry?.text || '',
      latestTimestamp: latestEntry?.timestamp || 0,
      promptCount: prompts.length,
      promptHash: this._promptCacheHash(prompts),
    };
  }

  _promptCacheSnapshot(st) {
    return this._promptCacheSnapshotFromEntries(st?.userPromptCache || []);
  }

  _dbConversationRowForSummary(sessionId) {
    if (!this._dbModule || typeof this._dbModule.getSessionConversation !== 'function') return null;
    try {
      const sourceIds = typeof this._dbModule.getSessionConversationSourceIds === 'function'
        ? this._dbModule.getSessionConversationSourceIds(sessionId, { includeCtmFallback: true })
        : [sessionId];
      // Prefer the blob-free meta row — the summary path only parses `messages` when the
      // hot ring is incomplete (lazy via _dbMessagesForRow), so pulling the multi-MB blob
      // here on every broadcast was pure waste (~20s of boot CPU, profiled).
      const fetchRow = typeof this._dbModule.getSessionConversationMeta === 'function'
        ? (id) => this._dbModule.getSessionConversationMeta(id)
        : (id) => this._dbModule.getSessionConversation(id);
      return sourceIds
        .map(sourceId => fetchRow(sourceId))
        .find(row => row && (row.last_user_content || row.messages_len || row.messages || row.first_message)) || null;
    } catch {
      return null;
    }
  }

  _dbRowTimestamp(row) {
    if (!row || typeof row !== 'object') return 0;
    for (const value of [row.imported_at, row.updated_at, row.session_created_at]) {
      const ms = value ? Date.parse(String(value)) : 0;
      if (Number.isFinite(ms) && ms > 0) return ms;
    }
    return 0;
  }

  // Resolve the `messages` JSON blob for a summary row, loading it lazily. A blob-free
  // meta row (from getSessionConversationMeta) carries messages_len but not messages, so
  // we fetch the blob on demand here — only when the caller actually needs the turns.
  _dbMessagesForRow(row) {
    if (!row || typeof row !== 'object') return null;
    if (row.messages) return row.messages; // legacy/full row or test mock
    // Memoize per row so the prompt-entries and turns paths share one blob load per
    // getSummary (a meta row from getSessionConversationMeta has messages_len but no blob).
    if (Object.prototype.hasOwnProperty.call(row, '_lazyMessages')) return row._lazyMessages;
    if (!row.messages_len) { row._lazyMessages = null; return null; }
    const id = row.ctm_session_id;
    let blob = null;
    if (id && this._dbModule) {
      if (typeof this._dbModule.getSessionConversationMessages === 'function') {
        try { blob = this._dbModule.getSessionConversationMessages(id) || null; } catch { blob = null; }
      } else if (typeof this._dbModule.getSessionConversation === 'function') {
        try { blob = this._dbModule.getSessionConversation(id)?.messages || null; } catch { blob = null; }
      }
    }
    row._lazyMessages = blob;
    return blob;
  }

  _latestTurnTimestamp(turns) {
    let latest = 0;
    for (const turn of Array.isArray(turns) ? turns : []) {
      const ts = Number(turn?.timestamp || 0);
      if (Number.isFinite(ts) && ts > latest) latest = ts;
    }
    return latest;
  }

  _shouldLoadDbTurnsForSummary(ringTurns, turns, dbRow) {
    if (!dbRow || !(dbRow.messages_len || dbRow.messages)) return Array.isArray(ringTurns) ? ringTurns.length < turns : true;
    if (!Array.isArray(ringTurns) || ringTurns.length < turns) return true;
    const dbTimestamp = this._dbRowTimestamp(dbRow);
    const ringTimestamp = this._latestTurnTimestamp(ringTurns);
    return !!(dbTimestamp && (!ringTimestamp || dbTimestamp > ringTimestamp));
  }

  _dbPromptEntriesForSummary(sessionId, row) {
    if (arguments.length < 2) row = this._dbConversationRowForSummary(sessionId);
    if (!row) return [];
    const timestamp = this._dbRowTimestamp(row);
    const entries = [];
    const add = (text, ts = timestamp) => {
      const cleaned = this._cleanUserText(text || '');
      if (!cleaned) return;
      const entry = this._promptEntry(cleaned, ts);
      if (entry) entries.push(entry);
    };

    // Reuse the blob only if it was ALREADY loaded for this summary (the turns path
    // loads it when the hot ring is incomplete and memoizes it on the row). Don't
    // trigger a fresh multi-MB load just for the prompt ledger — last_user_content
    // carries the same text below, and the hot prompt cache is the authority when the
    // ring is rich. This keeps the blob to at most one load per getSummary.
    const rowMessages = row.messages || row._lazyMessages || null;
    if (rowMessages) {
      try {
        const dbMessages = JSON.parse(rowMessages);
        if (Array.isArray(dbMessages)) {
          for (let i = dbMessages.length - 1; i >= 0; i--) {
            const m = dbMessages[i];
            if (!m || m.role !== 'user') continue;
            const ts = m.timestamp ? Date.parse(String(m.timestamp)) : timestamp;
            add(m.text || m.content || '', Number.isFinite(ts) ? ts : timestamp);
            if (entries.length) return entries;
          }
        }
      } catch {}
    }

    // last_user_content is a preview/title accelerator. When messages exist,
    // prefer the real message timestamp above so an old preview row imported
    // later cannot outrank a newer hot stream prompt in hovercards/Overview.
    if (row.last_user_content) {
      add(row.last_user_content);
      return entries;
    }

    if (row.first_message) add(row.first_message);
    return entries;
  }

  _dbTurnsForSummary(sessionId, limit = 6, dbRow) {
    if (arguments.length < 3) dbRow = this._dbConversationRowForSummary(sessionId);
    const rowMessages = this._dbMessagesForRow(dbRow);
    if (!rowMessages) return [];
    try {
      const maxTurns = Math.max(1, Number(limit) || 6);
      const dbMessages = JSON.parse(rowMessages);
      if (!Array.isArray(dbMessages)) return [];
      return dbMessages
        .filter(m => m && (m.role === 'user' || m.role === 'assistant'))
        .slice(-maxTurns)
        .map(m => ({
          role: m.role,
          text: m.text || m.content || '',
          timestamp: m.timestamp ? new Date(m.timestamp).getTime() : 0,
        }));
    } catch {
      return [];
    }
  }

  _mergeTurns(...turnLists) {
    const items = [];
    let order = 0;
    for (const turns of turnLists) {
      for (const turn of Array.isArray(turns) ? turns : []) {
        const role = turn?.role === 'assistant' ? 'assistant' : turn?.role === 'user' ? 'user' : '';
        const text = String(turn?.text || '').trim();
        if (!role || !text) continue;
        const timestamp = Number.isFinite(turn.timestamp) ? turn.timestamp : 0;
        items.push({ role, text, timestamp, order: order++ });
      }
    }
    const seen = new Set();
    return items
      .filter(item => {
        const key = `${item.role}\u0000${item.timestamp || ''}\u0000${item.text}`;
        if (seen.has(key)) return false;
        seen.add(key);
        return true;
      })
      .sort((a, b) => {
        if (a.timestamp && b.timestamp && a.timestamp !== b.timestamp) return a.timestamp - b.timestamp;
        if (a.timestamp && !b.timestamp) return 1;
        if (!a.timestamp && b.timestamp) return -1;
        return a.order - b.order;
      })
      .map(({ order: _order, ...turn }) => turn);
  }

  _promptEntriesFromTurns(turns) {
    const entries = [];
    for (const turn of Array.isArray(turns) ? turns : []) {
      if (turn?.role !== 'user') continue;
      const cleaned = this._cleanUserText(turn.text || '');
      if (!cleaned) continue;
      const entry = this._promptEntry(cleaned, Number.isFinite(turn.timestamp) ? turn.timestamp : 0);
      if (entry) entries.push(entry);
    }
    return entries;
  }

  _mergePromptEntries(...entryLists) {
    const items = [];
    let order = 0;
    for (const entries of entryLists) {
      for (const entry of Array.isArray(entries) ? entries : []) {
        const normalized = this._promptEntry(this._promptText(entry), this._promptTimestamp(entry));
        if (!normalized) continue;
        items.push({ ...normalized, order: order++ });
      }
    }
    const seen = new Set();
    return items
      .filter(entry => {
        const key = this._promptEntryKey(entry);
        if (!key || seen.has(key)) return false;
        seen.add(key);
        return true;
      })
      .sort((a, b) => {
        if (a.timestamp && b.timestamp && a.timestamp !== b.timestamp) return a.timestamp - b.timestamp;
        if (a.timestamp && !b.timestamp) return 1;
        if (!a.timestamp && b.timestamp) return -1;
        return a.order - b.order;
      })
      .map(({ order: _order, ...entry }) => entry);
  }

  _promptCacheHash(prompts) {
    const list = Array.isArray(prompts) ? prompts.filter(Boolean) : [];
    if (list.length === 0) return '';
    return crypto.createHash('sha1').update(list.join('\u0000')).digest('hex');
  }

  _standupRecentTurns(turns) {
    const out = [];
    for (const turn of (Array.isArray(turns) ? turns : []).slice(-STANDUP_RECENT_TURN_LIMIT)) {
      const role = turn?.role === 'assistant' ? 'assistant' : turn?.role === 'user' ? 'user' : '';
      if (!role) continue;
      const raw = String(turn.text || '').replace(/\s+/g, ' ').trim();
      const cleaned = role === 'user' ? this._cleanUserText(raw) : raw;
      const text = truncateText(cleaned, STANDUP_RECENT_TURN_TEXT_LIMIT);
      if (!text) continue;
      out.push({
        role,
        text,
        timestamp: Number.isFinite(turn.timestamp) ? turn.timestamp : 0,
      });
    }
    return out;
  }

  _computeStatus(st) {
    const now = Date.now();
    // PTY activity within 5s = running
    if (st.lastPtyActivity && (now - st.lastPtyActivity) < PTY_ACTIVE_WINDOW_MS) return 'running';
    // JSONL activity within 10s = running
    if (st.lastJsonlActivity && (now - st.lastJsonlActivity) < JSONL_ACTIVE_WINDOW_MS) return 'running';
    // Check if last event was assistant with no tool_use = waiting
    if (st.ring.length > 0) {
      const lastEvt = st.ring[st.ring.length - 1];
      if (lastEvt.type === 'assistant' && (!lastEvt.data.toolUses || lastEvt.data.toolUses.length === 0)) {
        if ((now - st.lastActivity) > PTY_ACTIVE_WINDOW_MS) return 'waiting';
      }
    }
    // Idle if no activity for 30s
    if ((now - st.lastActivity) > IDLE_THRESHOLD_MS) return 'idle';
    return 'running';
  }

  // --- Content Cleaning ---

  /** Strip Claude Code system-injected XML from user message text.
   *  Handles skill invocations (extracts args), strips system tags, and
   *  filters framework preamble. */
  _cleanUserText(text) {
    if (!text) return '';
    if (isProviderGeneratedUserContextText(text)) return '';

    // /compact synthetic user message: discard the entire block, not just the
    // first preamble line. The body that follows ("Summary:\n1. Primary...")
    // is auto-generated context, not user intent — feeding it back into the
    // LLM summarizer makes Haiku echo "Summary:" as the cached summary.
    if (looksLikeCompactContinuation(text)) return '';

    // Slash-command body message: when the user invokes a slash command, the
    // CLI emits TWO user JSONL entries — first the meta (with command-message
    // tags, handled below), then the entire skill body inlined as a separate
    // user message starting with "Base directory for this skill:". The body
    // is CLI-injected, not user intent, and its size (often 10-50KB) drowns
    // out real prompts in the tooltip. Discard wholesale.
    if (/^\s*Base directory for this skill:/i.test(text)) return '';

    // Skill invocations: extract user's actual intent
    const argsMatch = text.match(/<command-args>([\s\S]*?)<\/command-args>/);
    const cmdMatch = text.match(/<command-message>(.*?)<\/command-message>/);
    if (cmdMatch) {
      // Skill invocation detected
      if (argsMatch && argsMatch[1].trim()) {
        // Skill with args: return "/<skill> <args>"
        const skillName = cmdMatch[1];
        const args = argsMatch[1].trim().split('\n')[0].trim();
        return args.length >= 3 ? ('/' + skillName + ' ' + args) : '';
      }
      // Bare skill invocation (no args) — not meaningful for display
      return '';
    }

    let cleaned = text
      // Remove matched open/close tag pairs with hyphenated names (system tags)
      .replace(/<([a-z][a-z0-9]*(?:-[a-z0-9]+)+)[^>]*>[\s\S]*?<\/\1>/gi, '')
      // Remove any remaining orphaned hyphenated system tags
      .replace(/<\/?[a-z][a-z0-9]*(?:-[a-z0-9]+)+[^>]*>/gi, '')
      .trim();

    if (!cleaned) return '';

    // Strip known framework preamble patterns (may appear as plain text after XML removal)
    const preambleRe = /^(Base directory for this skill:.*|This session is being continued from a previous conversation.*|Continue from where you left off.*|The following skills are available.*|Caveat: The messages below were generated.*|ARGUMENTS:\s.*|# (?:PUA|TL Commit|System).*|Note: .*was modified, either by the user.*|The following (?:new )?diagnostic.*|Set model to .*|Compacted\b.*|\[Request interrupted.*|Tip: You have access to.*)$/gmi;
    cleaned = cleaned.replace(preambleRe, '').trim();

    const dollarSkill = cleaned.match(/^\$[a-z][a-z0-9-]*(?::[a-z][a-z0-9-]*)?(?:\s+([\s\S]*))?$/i);
    if (dollarSkill) {
      const args = this._stripLeadingAckPrefix((dollarSkill[1] || '').trim());
      return args.length >= 3 ? args : '';
    }

    return this._stripLeadingAckPrefix(cleaned);
  }

  _stripLeadingAckPrefix(text) {
    return String(text || '')
      .trim()
      .replace(/^(?:yes|ok(?:ay)?|sure|sounds good|go ahead|continue|proceed|do it)[\s:;,.!?-]+(?=\S)/i, '')
      .trim();
  }

  // --- Content-rich prompt picker ---

  /**
   * Walk back through the cached user prompts and return the most recent one
   * that has informational substance — skips bare acks ("yes", "go ahead",
   * "ok continue", "/skill yes") and very short fragments. Used as a
   * better-than-nothing display when the LLM summary hasn't generated yet
   * and the latest prompt happens to be a useless continuation.
   *
   * Returns the picked prompt entry, or null if no cached prompt has substance.
   */
  _pickContentRichPromptEntry(userPromptCache) {
    if (!Array.isArray(userPromptCache) || userPromptCache.length === 0) return null;
    for (let i = userPromptCache.length - 1; i >= 0; i--) {
      const entry = userPromptCache[i];
      const t = this._promptText(entry);
      if (this._isContentRich(t)) {
        return entry;
      }
    }
    return null;
  }

  _pickContentRichPrompt(userPromptCache) {
    return this._promptText(this._pickContentRichPromptEntry(userPromptCache)) || null;
  }

  /**
   * "Content-rich" = at least 4 word tokens AND not entirely composed of
   * filler/ack tokens. Skill prefixes ("/foo:bar ") are stripped first so
   * that "/cmd:sub yes, go ahead" reduces to "yes, go ahead" — three ack
   * tokens, all in the ack set, → not content-rich. The cleaner has
   * already stripped XML tags and slash-skill bodies before this runs;
   * here we only need to filter post-clean text.
   */
  _isContentRich(text) {
    if (!text) return false;
    let t = String(text).trim();
    if (!t) return false;
    t = t.replace(/^[$/][a-z][a-z0-9-]*(?::[a-z][a-z0-9-]*)?\s+/i, '');
    if (!t) return false;
    const tokens = t.toLowerCase().split(/[\s,.!?;:'"`()\[\]{}/]+/).filter(Boolean);
    if (tokens.length === 0) return false;
    if (tokens.every((tok) => SessionStream._ACK_TOKENS.has(tok))) return false;
    return tokens.length >= 4;
  }

  _usableSummary(cachedSummary) {
    if (!cachedSummary || cachedSummary.model === 'fallback') return null;
    const text = this._sanitizeSummary(cachedSummary.text || '');
    if (!text || this._looksLikeRawPrompt(text)) return null;
    return { ...cachedSummary, text };
  }

  _looksLikeRawPrompt(text) {
    const t = String(text || '').trim();
    if (!t) return false;
    if (/^(?:[$/]|<command-|<local-command-|<task-)/i.test(t)) return true;
    if (/\[Image #?\d*(?::[^\]]*)?\]/i.test(t)) return true;
    if (/^(?:why|how|can you|please|take a look|use \$|let's|lets|go ahead|continue)\b/i.test(t)) return true;
    if (/\b(?:go ahead|use \$ctm-dev|use \$high-agency)\b/i.test(t)) return true;
    if (/^continue[.!?]?$/i.test(t)) return true;
    return false;
  }

  _buildAiSummary(cachedSummary, summaryRecord, promptSnapshot, agentSessionId) {
    const timerPending = this._summaryTimers.has(agentSessionId);
    const latestTimestamp = promptSnapshot?.latestTimestamp || 0;
    const currentHash = promptSnapshot?.promptHash || '';
    const currentCount = promptSnapshot?.promptCount || 0;

    if (summaryRecord?.text) {
      const coveredTimestamp = Number.isFinite(summaryRecord.promptTimestamp)
        ? summaryRecord.promptTimestamp
        : (summaryRecord.timestamp || 0);
      const hasHash = !!summaryRecord.promptHash;
      const fresh = hasHash
        ? summaryRecord.promptHash === currentHash
        : (!latestTimestamp || (summaryRecord.timestamp || 0) >= latestTimestamp);
      const status = fresh ? 'fresh' : (timerPending ? 'updating' : 'stale');
      return {
        text: summaryRecord.text,
        source: 'ai-summary',
        status,
        freshness: status,
        model: summaryRecord.model || null,
        updatedAt: summaryRecord.timestamp || 0,
        promptTimestamp: coveredTimestamp || 0,
        promptCount: summaryRecord.promptCount || summaryRecord.turnCount || 0,
        currentPromptCount: currentCount,
        promptHash: summaryRecord.promptHash || null,
      };
    }

    if (cachedSummary?.model === 'fallback') {
      return {
        text: this._sanitizeSummary(cachedSummary.text || '') || null,
        source: 'fallback',
        status: timerPending ? 'updating' : 'fallback',
        freshness: timerPending ? 'updating' : 'fallback',
        model: 'fallback',
        updatedAt: cachedSummary.timestamp || 0,
        promptTimestamp: cachedSummary.promptTimestamp || 0,
        promptCount: cachedSummary.promptCount || cachedSummary.turnCount || 0,
        currentPromptCount: currentCount,
        promptHash: cachedSummary.promptHash || null,
      };
    }

    if (cachedSummary && cachedSummary.model) {
      return {
        text: null,
        source: 'ai-summary',
        status: 'rejected',
        freshness: 'rejected',
        model: cachedSummary.model || null,
        updatedAt: cachedSummary.timestamp || 0,
        promptTimestamp: cachedSummary.promptTimestamp || 0,
        promptCount: cachedSummary.promptCount || cachedSummary.turnCount || 0,
        currentPromptCount: currentCount,
        promptHash: cachedSummary.promptHash || null,
      };
    }

    return {
      text: null,
      source: 'none',
      status: timerPending ? 'updating' : 'missing',
      freshness: timerPending ? 'updating' : 'missing',
      model: null,
      updatedAt: 0,
      promptTimestamp: 0,
      promptCount: 0,
      currentPromptCount: currentCount,
      promptHash: null,
    };
  }

  _buildCurrentTask(aiSummary, latestPrompt, displayPrompt) {
    const freshAi = aiSummary?.text && aiSummary.status === 'fresh';
    const prompt = (latestPrompt?.isContentRich || !displayPrompt?.text)
      ? latestPrompt
      : displayPrompt;
    const promptText = prompt?.text || null;
    const promptTimestamp = prompt?.timestamp || 0;

    if (freshAi) {
      return {
        text: aiSummary.text,
        source: 'ai-summary',
        confidence: 'high',
        freshness: 'fresh',
        prompt: promptText,
        fullPrompt: promptText,
        timestamp: promptTimestamp,
        promptTimestamp,
        updatedAt: aiSummary.updatedAt || 0,
        aiUpdatedAt: aiSummary.updatedAt || 0,
      };
    }

    if (promptText) {
      const staleAiPresent = aiSummary?.source === 'ai-summary' && aiSummary?.text && aiSummary.status !== 'fresh';
      const source = staleAiPresent && prompt === latestPrompt ? 'latest-prompt' : 'prompt-fallback';
      const freshness = staleAiPresent
        ? (aiSummary.status === 'updating' ? 'updating' : 'stale')
        : (aiSummary?.status === 'updating' ? 'updating' : 'fallback');
      return {
        text: promptText,
        source,
        confidence: this._isContentRich(promptText) ? 'medium' : 'low',
        freshness,
        prompt: promptText,
        fullPrompt: promptText,
        timestamp: promptTimestamp,
        promptTimestamp,
        updatedAt: promptTimestamp,
        aiUpdatedAt: aiSummary?.updatedAt || 0,
      };
    }

    return {
      text: null,
      source: 'missing',
      confidence: 'low',
      freshness: aiSummary?.status || 'missing',
      prompt: null,
      fullPrompt: null,
      timestamp: 0,
      promptTimestamp: 0,
      updatedAt: 0,
      aiUpdatedAt: aiSummary?.updatedAt || 0,
    };
  }

  _buildIntent(currentTask, displayPromptEntry, lastPromptEntry) {
    const promptEntry = displayPromptEntry || lastPromptEntry;
    const promptText = this._promptText(promptEntry);
    if (currentTask?.text) {
      return {
        text: currentTask.text,
        source: currentTask.source || 'missing',
        confidence: currentTask.confidence || 'low',
        freshness: currentTask.freshness || 'missing',
        prompt: currentTask.prompt || promptText || null,
        fullPrompt: currentTask.fullPrompt || promptText || null,
        timestamp: currentTask.timestamp || this._promptTimestamp(promptEntry),
        updatedAt: currentTask.updatedAt || currentTask.timestamp || 0,
        aiUpdatedAt: currentTask.aiUpdatedAt || 0,
      };
    }
    if (promptText) {
      return {
        text: promptText,
        source: 'prompt-fallback',
        confidence: this._isContentRich(promptText) ? 'medium' : 'low',
        freshness: 'fallback',
        prompt: promptText,
        fullPrompt: promptText,
        timestamp: this._promptTimestamp(promptEntry),
        updatedAt: this._promptTimestamp(promptEntry),
        aiUpdatedAt: 0,
      };
    }
    return {
      text: null,
      source: 'missing',
      confidence: 'low',
      freshness: 'missing',
      prompt: null,
      fullPrompt: null,
      timestamp: 0,
      updatedAt: 0,
      aiUpdatedAt: 0,
    };
  }

  _buildProgress(st, intent) {
    const since = intent?.timestamp || 0;
    const candidates = [];
    const nextCandidates = [];
    for (const evt of st.ring || []) {
      if (!evt || evt.type !== 'assistant') continue;
      if (since && evt.timestamp && evt.timestamp < since) continue;
      const text = this._progressCandidateText(evt.data?.text || '');
      if (text) candidates.push({ text, timestamp: evt.timestamp || 0 });
      const nextText = this._nextCandidateText(evt.data?.text || '');
      if (nextText) nextCandidates.push({ text: nextText, timestamp: evt.timestamp || 0 });
    }
    const picked = candidates.slice(-PROGRESS_BULLET_LIMIT);
    const bullets = picked.map(p => p.text);
    const latest = picked[picked.length - 1] || candidates[candidates.length - 1] || null;
    const nextCandidate = [...nextCandidates].reverse().find(p => !latest || p.text !== latest.text);
    return {
      phase: this._progressPhase(latest?.text || '', this._computeStatus(st)),
      summary: latest?.text || null,
      bullets,
      next: nextCandidate && nextCandidate !== latest ? nextCandidate.text : null,
      source: bullets.length ? 'assistant-events' : 'none',
      updatedAt: latest?.timestamp || 0,
    };
  }

  _progressLines(text) {
    if (!text) return [];
    return String(text)
      .replace(/```[\s\S]*?```/g, ' ')
      .split(/\n+/)
      .map(line => line.replace(/^\s*(?:[-*•]|\d+[.)])\s*/, '').trim())
      .filter(Boolean);
  }

  _cleanProgressLine(line) {
    return String(line || '')
        .replace(/^\[战果\]\s*/i, '')
        .replace(/^\[校准\][\s\S]*$/i, '')
        .trim();
  }

  _isOperationalProgressLine(line) {
    const t = String(line || '').trim();
    if (!t) return true;
    if (/^(?:using [`"]?\$?high-agency|using [`"]?ctm-dev|i(?:'|\u2019)?ll use|calibration\b)/i.test(t)) return true;
    if (/\b(?:high-agency|ctm-dev|ux-designer|tl-commit)\b/i.test(t)) return true;
    if (/\b(?:lsof|curl|localhost|port pair|dev server|dev copy|sandbox|escalat|staged diff|tl scan|git diff|git status|git commit)\b/i.test(t)) return true;
    if (/\b(?:committing|staging|pre-commit|secret scan|summary endpoint|api probe|isolated ctm dev instance)\b/i.test(t)) return true;
    return false;
  }

  _hasProgressVerb(line) {
    return /\b(?:confirm|confirms|confirmed|root cause|narrowed|identified|found|added|implemented|changed|fixed|verified|passed|failed|reproduced|blocked|waiting)\b/i.test(line)
      || /(?:^|[\s:;,.])(?:running|testing)\b/i.test(line);
  }

  _looksLikeFutureStep(line) {
    return /\b(?:next|going to|will|i'll|i(?:'|\u2019)ll|i will)\b/i.test(line);
  }

  _progressCandidateText(text) {
    for (const rawLine of this._progressLines(text)) {
      const line = this._cleanProgressLine(rawLine);
      if (!line || this._isOperationalProgressLine(line)) continue;
      if (this._looksLikeFutureStep(line)) continue;
      if (!this._hasProgressVerb(line)) continue;
      if (line.split(/\s+/).length < 5) continue;
      return truncateText(line, 220);
    }
    return '';
  }

  _nextCandidateText(text) {
    for (const rawLine of this._progressLines(text)) {
      const line = this._cleanProgressLine(rawLine);
      if (!line || this._isOperationalProgressLine(line)) continue;
      if (!this._looksLikeFutureStep(line)) continue;
      if (line.split(/\s+/).length < 5) continue;
      return truncateText(line, 220);
    }
    return '';
  }

  _progressPhase(text, status) {
    const t = String(text || '').toLowerCase();
    if (!t && status === 'waiting') return 'waiting';
    if (/\b(?:passed|verified|browser proof|done|completed)\b/.test(t)) return 'verifying';
    if (/\b(?:running|test|verify|check)\b/.test(t)) return 'verifying';
    if (/\b(?:implemented|added|changed|fixed|patch)\b/.test(t)) return 'implementing';
    if (/\b(?:confirmed|root cause|narrowed|identified|found|reproduced)\b/.test(t)) return 'investigating';
    return status || 'unknown';
  }

  // --- LLM Summary ---

  _debounceSummary(agentSessionId, st) {
    const existing = this._summaryTimers.get(agentSessionId);
    if (existing) clearTimeout(existing);
    this._summaryTimers.set(agentSessionId, setTimeout(() => {
      this._summaryTimers.delete(agentSessionId);
      this._generateSummary(agentSessionId, st).catch(() => {});
    }, SUMMARY_DEBOUNCE_MS));
  }

  _maybeRetryFallbackSummary(agentSessionId, st) {
    if (!this._summaryProvider || !st) return;
    if (this._summaryTimers.has(agentSessionId)) return;
    const now = Date.now();
    const lastAttempt = st.lastSummaryAttempt || st.cachedSummary?.timestamp || 0;
    if (lastAttempt && now - lastAttempt < SUMMARY_RETRY_COOLDOWN_MS) return;
    this._debounceSummary(agentSessionId, st);
  }

  async _generateSummary(agentSessionId, st) {
    st.lastSummaryAttempt = Date.now();
    const summaryInput = this._buildSummaryInput(st);
    if (!summaryInput) return;
    const { turnsText, sysPrompt, promptSnapshot } = summaryInput;

    // Tier 0: configured Wall-E provider (DeepSeek/OpenAI/Claude/Gemini/etc.).
    let summaryText = '';
    let usedModel = null;

    const configured = await this._tryConfiguredSummary(turnsText, sysPrompt);
    if (configured) { summaryText = configured.text; usedModel = configured.model; }

    // Tier 1: legacy cloud Anthropic (when API key is present)
    if (!summaryText) {
      const cloud = await this._tryCloudSummary(turnsText, sysPrompt);
      if (cloud) { summaryText = cloud.text; usedModel = cloud.model; }
    }

    // Tier 2: local Ollama fallback (when cloud is unavailable or failed)
    if (!summaryText) {
      const local = await this._tryOllamaSummary(turnsText, sysPrompt);
      if (local) { summaryText = local.text; usedModel = local.model; }
    }

    if (summaryText) {
      st.cachedSummary = {
        text: truncateText(summaryText, SUMMARY_TEXT_LIMIT),
        turnCount: promptSnapshot.promptCount,
        promptCount: promptSnapshot.promptCount,
        promptHash: promptSnapshot.promptHash,
        promptTimestamp: promptSnapshot.latestTimestamp || 0,
        model: usedModel,
        timestamp: Date.now(),
      };
      const summaryEvt = decorateStreamEvent({
        type: 'summary',
        sessionId: agentSessionId,
        ctmSessionId: st.ctmSessionId,
        timestamp: Date.now(),
        seq: st.seq++,
        data: st.cachedSummary,
      }, 'summary');
      st.ring.push(summaryEvt);
      if (st.ring.length > this._ringSize) st.ring.shift();
      this._emitEvent(agentSessionId, summaryEvt);
    } else {
      // Tier 3: heuristic fallback — picks the most recent content-rich prompt
      st.cachedSummary = this._fallbackSummary(promptSnapshot.prompts, promptSnapshot);
    }
  }

  _buildSummaryInput(st) {
    const promptSnapshot = this._promptCacheSnapshot(st);
    if (promptSnapshot.prompts.length === 0) return null;

    const latest = promptSnapshot.latestText;
    const recentContext = promptSnapshot.prompts.slice(0, -1).slice(-3);
    const parts = ['Latest prompt:', latest];
    if (recentContext.length) {
      parts.push('', 'Recent context:');
      recentContext.forEach((text, idx) => {
        parts.push(`${idx + 1}. ${text}`);
      });
    }

    return {
      turnsText: parts.join('\n'),
      sysPrompt: "Summarize the user's current task. Prioritize the latest prompt. Use older prompts only as context. Return 8-14 words. Return only the summary, no quotes or prefix.",
      promptSnapshot,
    };
  }

  /** Tier 0: CTM-injected Wall-E provider. Returns { text, model } or null. */
  async _tryConfiguredSummary(turnsText, sysPrompt) {
    if (!this._summaryProvider) return null;
    try {
      const result = await this._summaryProvider({ turnsText, sysPrompt });
      const text = this._sanitizeSummary(result?.text || '');
      return text ? { text, model: result?.model || 'configured' } : null;
    } catch (e) {
      console.warn('[session-stream] configured summary failed:', e.message);
      return null;
    }
  }

  /** Tier 1: Anthropic cloud. Returns { text, model } or null. */
  async _tryCloudSummary(turnsText, sysPrompt) {
    const apiKey = process.env.ANTHROPIC_API_KEY || '';
    if (!apiKey) return null;
    // OAuth tokens (sk-ant-oat-...) only work via Claude Code's proxy, not
    // the public Anthropic API — they 401 with "OAuth not supported" when
    // sent as x-api-key. Skip the cloud tier for those and let Ollama handle it.
    if (/^sk-ant-oat/i.test(apiKey)) return null;
    const baseUrl = process.env.ANTHROPIC_BASE_URL || 'https://api.anthropic.com';

    let customHeaders = {};
    try {
      const headerStr = process.env.ANTHROPIC_CUSTOM_HEADERS || '';
      if (headerStr) {
        for (const pair of headerStr.split(',')) {
          const [k, ...v] = pair.split(':');
          if (k && v.length) customHeaders[k.trim()] = v.join(':').trim();
        }
      }
    } catch {}

    try {
      const resp = await fetch(`${baseUrl}/messages`, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'x-api-key': apiKey,
          'anthropic-version': '2023-06-01',
          ...customHeaders,
        },
        body: JSON.stringify({
          model: 'claude-haiku-4-5-20251001',
          max_tokens: 50,
          system: sysPrompt,
          messages: [{ role: 'user', content: turnsText }],
        }),
        signal: AbortSignal.timeout(10000),
      });
      if (!resp.ok) return null;
      const body = await resp.json();
      const text = this._sanitizeSummary(body.content?.[0]?.text || '');
      return text ? { text, model: 'claude-haiku-4-5-20251001' } : null;
    } catch { return null; }
  }

  /** Tier 2: local Ollama at localhost:11434. Returns { text, model } or null. */
  async _tryOllamaSummary(turnsText, sysPrompt) {
    const baseUrl = process.env.OLLAMA_BASE_URL || 'http://localhost:11434';
    let model;
    try {
      const tagsResp = await fetch(`${baseUrl}/api/tags`, { signal: AbortSignal.timeout(2000) });
      if (!tagsResp.ok) return null;
      const tags = await tagsResp.json();
      const names = (tags.models || []).map(m => m.name);
      if (names.length === 0) return null;
      // Prefer small/fast models for tooltip latency. Embedding models can't
      // chat — skip them.
      const preferred = ['gemma4:e4b', 'gemma2:2b', 'gemma3:4b', 'llama3.2:3b', 'qwen2.5:3b', 'phi3:mini'];
      const isChatModel = (n) => !/embed|reranker/i.test(n);
      const chatNames = names.filter(isChatModel);
      if (chatNames.length === 0) return null;
      model = preferred.find(p => chatNames.some(n => n.startsWith(p))) || chatNames[0];
    } catch { return null; }

    try {
      const resp = await fetch(`${baseUrl}/api/chat`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          model,
          messages: [
            { role: 'system', content: sysPrompt },
            { role: 'user', content: turnsText },
          ],
          stream: false,
          options: { temperature: 0.3, num_predict: 60 },
        }),
        signal: AbortSignal.timeout(15000),
      });
      if (!resp.ok) return null;
      const body = await resp.json();
      const text = this._sanitizeSummary(body.message?.content || '');
      return text ? { text, model: `ollama:${model}` } : null;
    } catch { return null; }
  }

  _fallbackSummary(userPrompts, promptSnapshot = null) {
    // Prefer a content-rich prompt over the latest one — "/cmd yes go ahead"
    // tells the user nothing about what the session is doing.
    const picked = this._pickContentRichPrompt(userPrompts) || userPrompts[userPrompts.length - 1] || '';
    const text = this._sanitizeSummary(picked.split('\n')[0].slice(0, 80));
    return {
      text,
      turnCount: userPrompts.length,
      promptCount: promptSnapshot?.promptCount || userPrompts.length,
      promptHash: promptSnapshot?.promptHash || this._promptCacheHash(userPrompts),
      promptTimestamp: promptSnapshot?.latestTimestamp || 0,
      model: 'fallback',
      timestamp: Date.now(),
    };
  }

  /**
   * Strip degenerate LLM/fallback summary outputs. Haiku occasionally returns
   * "Summary:" or 'Summary: "..."' as a prefix despite the system prompt
   * forbidding it; truly bare "Summary:" with no body is the empty-summary
   * UI bug. Returns '' for any output that's purely the prefix or quote
   * shells with no content.
   */
  _sanitizeSummary(text) {
    if (!text) return '';
    let t = String(text).trim();
    // Strip a leading "Summary:" prefix (with or without trailing space/quote).
    t = t.replace(/^summary\s*:\s*/i, '').trim();
    // Strip wrapping quotes the model sometimes adds.
    if ((t.startsWith('"') && t.endsWith('"')) || (t.startsWith('“') && t.endsWith('”'))) {
      t = t.slice(1, -1).trim();
    }
    // If, after stripping, nothing meaningful is left, return empty so the
    // tooltip falls through to lastPrompt instead of showing "Summary:".
    if (!t || /^[:.\s\-—]*$/.test(t)) return '';
    return t;
  }
}

// Tokens that appear in pure-ack utterances. A prompt that decomposes
// entirely into these (after stripping a leading skill prefix) carries
// no information about what the session is doing.
SessionStream._ACK_TOKENS = new Set([
  // Affirmations
  'yes', 'yep', 'yeah', 'yup', 'ok', 'okay', 'sure', 'fine',
  'cool', 'great', 'nice', 'good', 'alright', 'right', 'exactly', 'correct',
  // Continuation / let's-go
  'continue', 'go', 'next', 'done', 'do', 'it', 'ahead', 'proceed',
  'let', 'lets', "let's", 'sounds', 'works',
  // Approval verbs (when standalone — "merge to main" should also filter)
  'approved', 'approve', 'merge', 'commit', 'push', 'deploy', 'run',
  // Politeness
  'please', 'thanks', 'thank', 'thx', 'ty',
  // Common filler that survives split
  'a', 'an', 'the', 'to', 'main', 'that', 'this',
]);

module.exports = { SessionStream, JsonlTailer, decorateStreamEvent };
