feat(chat): auto-generate chat titles from first message via Haiku (#982)

Adds an opt-in chat title generator that fires on the first user message
in a session. Emits a session_titled SystemEvent so the dashboard sidebar
(and any SSE client on /chat/stream) updates in realtime. Forced on when
ee.cloud is present; OSS default is off and controlled by
POCKETPAW_CHAT_TITLE_GENERATION_ENABLED.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Rohit Kushwaha
2026-04-19 22:35:41 +05:30
committed by GitHub
parent 80fbbc84df
commit 06c2b1bbca
8 changed files with 200 additions and 0 deletions

View File

@@ -94,6 +94,10 @@
# POCKETPAW_SOUL_PATH=
# POCKETPAW_SOUL_AUTO_SAVE_INTERVAL=300
# ── Chat Title Generation (Haiku-backed auto-naming) ─────
# POCKETPAW_CHAT_TITLE_GENERATION_ENABLED=false
# POCKETPAW_CHAT_TITLE_MODEL=claude-haiku-4-5-20251001
# ── Security ──────────────────────────────────────────────
# POCKETPAW_BYPASS_PERMISSIONS=false
# POCKETPAW_LOCALHOST_AUTH_BYPASS=true

12
ee/cloud/features.py Normal file
View File

@@ -0,0 +1,12 @@
"""Cloud-mode feature overrides.
When ee.cloud is installed, certain OSS-optional features are force-enabled
because the cloud product depends on them (chat titles drive the sidebar,
outbound webhooks drive realtime UI updates, etc.).
"""
from __future__ import annotations
def chat_titles_enabled() -> bool:
return True

View File

@@ -371,6 +371,36 @@ class AgentLoop:
self._router = AgentRouter(Settings.load())
return self._router
async def _generate_and_emit_title(self, session_key: str, first_message: str) -> None:
"""Generate a chat title from ``first_message`` and publish a
``session_titled`` SystemEvent. Best-effort; never raises."""
try:
from pocketpaw.memory.titler import generate_title
title = await generate_title(
first_message,
model=self.settings.chat_title_model,
api_key=self.settings.anthropic_api_key or None,
)
if not title:
return
# session_key is "channel:chat_id" — expose the safe_key form
# ("channel_chat_id") so web clients can correlate with their
# session_id directly.
safe_key = session_key.replace(":", "_")
await self.bus.publish_system(
SystemEvent(
event_type="session_titled",
data={
"session_key": session_key,
"session_id": safe_key,
"title": title,
},
)
)
except Exception:
logger.debug("session titling failed for %s", session_key, exc_info=True)
async def process_message(self, message: InboundMessage) -> None:
"""Public entry point — run the full processing pipeline on one
message without going through the bus consumer.
@@ -740,6 +770,14 @@ class AgentLoop:
store_meta = {
k: v for k, v in (message.metadata or {}).items() if k != "pocket_system_context"
}
# Detect first-message state *before* persisting so titler can fire once.
is_first_message = False
try:
prior = await self.memory._store.get_session(session_key)
is_first_message = len(prior) == 0
except (AttributeError, TypeError):
is_first_message = False
await self.memory.add_to_session(
session_key=session_key,
role="user",
@@ -747,6 +785,17 @@ class AgentLoop:
metadata=store_meta,
)
# 1a. Fire-and-forget chat title generation on the first user message.
# Publishes a ``session_titled`` SystemEvent; persistence is the
# caller's responsibility (cloud: Mongo; OSS: in-memory/SSE only).
if is_first_message:
from pocketpaw.features import chat_titles_enabled
if chat_titles_enabled(self.settings):
asyncio.create_task(
self._generate_and_emit_title(session_key, content)
)
# 1b. Inject inbound media file paths so the agent can use them
# Also detect whether this is a voice message so we can auto-TTS the reply.
is_voice_message = any(

View File

@@ -162,6 +162,16 @@ class _APISessionBridge:
"data": {"mutation": data.get("mutation", {})},
}
)
elif evt.event_type == "session_titled":
await self.queue.put(
{
"event": "session_titled",
"data": {
"session_id": data.get("session_id", ""),
"title": data.get("title", ""),
},
}
)
elif evt.event_type == "error":
await self.queue.put(
{"event": "error", "data": {"detail": data.get("message", "")}}

View File

@@ -586,6 +586,19 @@ class Settings(BaseSettings):
default=True, description="Extend log scrubber with PII patterns (when pii_scan_enabled)"
)
# Chat Title Generation (Haiku-backed, first-message naming)
chat_title_generation_enabled: bool = Field(
default=False,
description=(
"Auto-generate a short title for a chat from its first user message."
" Uses a Haiku model; fires a session_titled SystemEvent on completion."
),
)
chat_title_model: str = Field(
default="claude-haiku-4-5-20251001",
description="Model used by the chat title generator (Anthropic).",
)
# Smart Model Routing
smart_routing_enabled: bool = Field(
default=False,

32
src/pocketpaw/features.py Normal file
View File

@@ -0,0 +1,32 @@
"""Feature flag resolution.
Reads OSS settings, but allows ``ee.cloud.features`` to force-on specific
capabilities when running in cloud mode. ee.cloud is imported lazily so OSS
builds without the ee package continue to work.
"""
from __future__ import annotations
from pocketpaw.config import Settings
def _cloud_override(name: str) -> bool | None:
"""Return True/False if ee.cloud forces a feature on/off, else None."""
try:
from ee.cloud import features as cloud_features # type: ignore[import-not-found]
except ImportError:
return None
getter = getattr(cloud_features, name, None)
if getter is None:
return None
try:
return bool(getter())
except Exception:
return None
def chat_titles_enabled(settings: Settings) -> bool:
override = _cloud_override("chat_titles_enabled")
if override is not None:
return override
return settings.chat_title_generation_enabled

View File

@@ -666,6 +666,20 @@ window.PocketPaw.Transparency = {
return;
}
// session_titled — Haiku-generated chat title; update sidebar entry
if (eventType === 'session_titled') {
const d = data.data || {};
const sid = d.session_id;
const title = d.title;
if (sid && title && this.sessions) {
const session = this.sessions.find(s => s.id === sid);
if (session) {
session.title = title;
}
}
return;
}
// AskUserQuestion — show interactive question in chat
if (eventType === 'ask_user_question') {
const d = data.data || {};

View File

@@ -0,0 +1,66 @@
"""Chat title generation from the first user message.
Uses a Haiku-class Anthropic model to produce a short (≤6 word) title. The
caller is responsible for persisting the title and emitting the
``session_titled`` SystemEvent — this module only generates.
"""
from __future__ import annotations
import logging
logger = logging.getLogger(__name__)
_PROMPT = (
"Write a concise chat title (max 6 words, Title Case, no quotes, no"
" trailing punctuation) that captures the subject of this user message.\n\n"
"Message:\n{message}\n\nTitle:"
)
_MAX_INPUT_CHARS = 2000
_MAX_TOKENS = 24
async def generate_title(
first_message: str, *, model: str, api_key: str | None = None
) -> str | None:
"""Return a short title for ``first_message`` or ``None`` on failure.
Failures (missing SDK, missing key, API error) are logged at debug and
swallowed — titling is best-effort and must never break the chat flow.
"""
text = (first_message or "").strip()
if not text:
return None
if len(text) > _MAX_INPUT_CHARS:
text = text[:_MAX_INPUT_CHARS]
try:
from anthropic import AsyncAnthropic
except ImportError:
logger.debug("anthropic SDK not installed; skipping title generation")
return None
try:
client = AsyncAnthropic(api_key=api_key) if api_key else AsyncAnthropic()
response = await client.messages.create(
model=model,
max_tokens=_MAX_TOKENS,
messages=[{"role": "user", "content": _PROMPT.format(message=text)}],
)
except Exception:
logger.debug("title generation call failed", exc_info=True)
return None
try:
raw = response.content[0].text
except (AttributeError, IndexError):
return None
title = raw.strip().strip('"').strip("'").rstrip(".").strip()
if not title:
return None
# Cap at a hard character budget in case the model ignores word-count.
if len(title) > 80:
title = title[:80].rstrip()
return title