/ src / utils / sessionTitle.ts
sessionTitle.ts
  1  /**
  2   * Session title generation via Haiku.
  3   *
  4   * Standalone module with minimal dependencies so it can be imported from
  5   * print.ts (SDK control request handler) without pulling in the React/chalk/
  6   * git dependency chain that teleport.tsx carries.
  7   *
  8   * This is the single source of truth for AI-generated session titles across
  9   * all surfaces. Previously there were separate Haiku title generators:
 10   * - teleport.tsx generateTitleAndBranch (6-word title + branch for CCR)
 11   * - rename/generateSessionName.ts (kebab-case name for /rename)
 12   * Each remains for backwards compat; new callers should use this module.
 13   */
 14  
 15  import { z } from 'zod/v4'
 16  import { getIsNonInteractiveSession } from '../bootstrap/state.js'
 17  import { logEvent } from '../services/analytics/index.js'
 18  import { queryHaiku } from '../services/api/claude.js'
 19  import type { Message } from '../types/message.js'
 20  import { logForDebugging } from './debug.js'
 21  import { safeParseJSON } from './json.js'
 22  import { lazySchema } from './lazySchema.js'
 23  import { extractTextContent } from './messages.js'
 24  import { asSystemPrompt } from './systemPromptType.js'
 25  
 26  const MAX_CONVERSATION_TEXT = 1000
 27  
 28  /**
 29   * Flatten a message array into a single text string for Haiku title input.
 30   * Skips meta/non-human messages. Tail-slices to the last 1000 chars so
 31   * recent context wins when the conversation is long.
 32   */
 33  export function extractConversationText(messages: Message[]): string {
 34    const parts: string[] = []
 35    for (const msg of messages) {
 36      if (msg.type !== 'user' && msg.type !== 'assistant') continue
 37      if ('isMeta' in msg && msg.isMeta) continue
 38      if ('origin' in msg && msg.origin && msg.origin.kind !== 'human') continue
 39      const content = msg.message.content
 40      if (typeof content === 'string') {
 41        parts.push(content)
 42      } else if (Array.isArray(content)) {
 43        for (const block of content) {
 44          if ('type' in block && block.type === 'text' && 'text' in block) {
 45            parts.push(block.text as string)
 46          }
 47        }
 48      }
 49    }
 50    const text = parts.join('\n')
 51    return text.length > MAX_CONVERSATION_TEXT
 52      ? text.slice(-MAX_CONVERSATION_TEXT)
 53      : text
 54  }
 55  
 56  const SESSION_TITLE_PROMPT = `Generate a concise, sentence-case title (3-7 words) that captures the main topic or goal of this coding session. The title should be clear enough that the user recognizes the session in a list. Use sentence case: capitalize only the first word and proper nouns.
 57  
 58  Return JSON with a single "title" field.
 59  
 60  Good examples:
 61  {"title": "Fix login button on mobile"}
 62  {"title": "Add OAuth authentication"}
 63  {"title": "Debug failing CI tests"}
 64  {"title": "Refactor API client error handling"}
 65  
 66  Bad (too vague): {"title": "Code changes"}
 67  Bad (too long): {"title": "Investigate and fix the issue where the login button does not respond on mobile devices"}
 68  Bad (wrong case): {"title": "Fix Login Button On Mobile"}`
 69  
 70  const titleSchema = lazySchema(() => z.object({ title: z.string() }))
 71  
 72  /**
 73   * Generate a sentence-case session title from a description or first message.
 74   * Returns null on error or if Haiku returns an unparseable response.
 75   *
 76   * @param description - The user's first message or a description of the session
 77   * @param signal - Abort signal for cancellation
 78   */
 79  export async function generateSessionTitle(
 80    description: string,
 81    signal: AbortSignal,
 82  ): Promise<string | null> {
 83    const trimmed = description.trim()
 84    if (!trimmed) return null
 85  
 86    try {
 87      const result = await queryHaiku({
 88        systemPrompt: asSystemPrompt([SESSION_TITLE_PROMPT]),
 89        userPrompt: trimmed,
 90        outputFormat: {
 91          type: 'json_schema',
 92          schema: {
 93            type: 'object',
 94            properties: {
 95              title: { type: 'string' },
 96            },
 97            required: ['title'],
 98            additionalProperties: false,
 99          },
100        },
101        signal,
102        options: {
103          querySource: 'generate_session_title',
104          agents: [],
105          // Reflect the actual session mode — this module is called from
106          // both the SDK print path (non-interactive) and the CCR remote
107          // session path via useRemoteSession (interactive).
108          isNonInteractiveSession: getIsNonInteractiveSession(),
109          hasAppendSystemPrompt: false,
110          mcpTools: [],
111        },
112      })
113  
114      const text = extractTextContent(result.message.content)
115  
116      const parsed = titleSchema().safeParse(safeParseJSON(text))
117      const title = parsed.success ? parsed.data.title.trim() || null : null
118  
119      logEvent('tengu_session_title_generated', { success: title !== null })
120  
121      return title
122    } catch (error) {
123      logForDebugging(`generateSessionTitle failed: ${error}`, {
124        level: 'error',
125      })
126      logEvent('tengu_session_title_generated', { success: false })
127      return null
128    }
129  }