Skip to content

feat(session): persist AI-generated session titles#407

Draft
maxnoller wants to merge 3 commits intomainfrom
worktree-lucky-honking-donut
Draft

feat(session): persist AI-generated session titles#407
maxnoller wants to merge 3 commits intomainfrom
worktree-lucky-honking-donut

Conversation

@maxnoller
Copy link
Copy Markdown
Member

Summary

Generate a short title (3–7 words) via the auxiliary model after the first user→assistant exchange and persist it on the session row. The chat sidebar (/chat) and /api/chat/recent prefer the stored title, falling back to the existing first/last-message preview when none exists. Pattern mirrors Hermes Agent's agent/title_generator.py and DeerFlow's TitleMiddleware.

Why

Today the sidebar always derives a title on read from the first/last message via buildSessionBoundaryPreview (src/session/session-preview.ts) — clunky labels like "hey can you" ... "Sure — here's the…". A real, persisted title makes sessions scannable and searchable by topic.

What's in this PR

  • Schema migration V20 (src/memory/db.ts): nullable title and title_source columns on sessions via addColumnIfMissing.
  • DB helpers: setSessionTitle(id, title, source) (auto source uses WHERE title IS NULL for race-safety), getSessionTitle(id), countUserMessagesForSession(id). getRecentSessionsForUser prefers the stored title and falls back to the existing boundary preview.
  • Title generator — new src/session/session-title.ts:
    • generateSessionTitle(...) — Hermes-style "3–7 words, return only title text" prompt; normalizer strips quotes, Title: prefix, <think> tags, trailing punctuation; caps at 80 chars; rejects empty/single-char/untitled outputs.
    • maybeAutoTitleSession(...) — sync gate (userMessageCount > 1 or existing title → bail), then void (async () => …)() fire-and-forget body wrapped in withSpan('hybridclaw.session.title', …). Never blocks the user-visible reply.
  • Auxiliary task wiring: new 'session_title' task added to TASK_MODEL_KEYS and auxiliaryModels in runtime-config.ts + config.example.json (default provider: "auto", override with AUXILIARY_SESSION_TITLE_MODEL / _PROVIDER).
  • Gateway hook (src/gateway/gateway-chat-service.ts): hooked into all three recordSuccessfulTurn sites — concierge respond, version-only, main success — using the just-recorded exchange (no re-fetch, race-safe vs compaction).
  • Console: no changes — chat-sidebar.tsx:111 already renders s.title, which the gateway now resolves to stored-or-derived.

Out of scope (next iteration)

  • /title slash command for manual override (the title_source column is reserved for 'user').
  • Backfill for pre-feature sessions (they keep deriving from boundary preview — zero regression).
  • Re-titling on long sessions / topic drift.
  • Pushing fresh titles to the sidebar in real time (next refresh picks them up).

Test plan

  • npm run typecheck (root + console)
  • npm run check (biome)
  • tests/session-title.test.ts — 12 new unit tests (normalizer, generator happy/error paths, gate behavior)
  • tests/memory-service.test.ts — 5 new cases (set/get round-trip, race-safety with auto source, user override, countUserMessagesForSession, stored title surfaces through getRecentSessionsForUser)
  • npm run test:unit (root): 2874 passing — the 9 remaining failures (admin-terminal, eval-command, host-runner.*) are pre-existing flakes confirmed on main baseline
  • npm --workspace console run test: 216 / 216 passing
  • Manual smoke: start gateway + console dev, send a first message in a new session, confirm sidebar updates from derived preview to a stored 3–7 word title within ~1s of reply; restart gateway and confirm the title persists.

🤖 Generated with Claude Code

Generate a short title (3-7 words) via the auxiliary model after the
first user→assistant exchange and persist it on the session row. The
chat sidebar and `/api/chat/recent` prefer the stored title, falling
back to the existing first/last message preview when no title exists.

Schema migration adds nullable `title` + `title_source` columns; the
hook is fire-and-forget so it never blocks the user-visible reply.

Pattern mirrors Hermes Agent's `title_generator.py` and DeerFlow's
`TitleMiddleware`.
…mer)

- Replace `countUserMessagesForSession` with the already-loaded
  `turnIndex === 1` check at the gateway hook sites; drop the helper
  and its dedicated COUNT query.
- Replace the local `truncate` helper with the existing
  `trimSessionPreviewText` from `session-preview.ts`.
- Rename `userMessageCount` → `isFirstTurn` in `MaybeAutoTitleSessionParams`
  to match the new gate semantics.
- Drop one redundant `flushMicrotasks` call in the unit tests.
@maxnoller maxnoller force-pushed the worktree-lucky-honking-donut branch from 02744de to c876b57 Compare April 25, 2026 14:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant