Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
244 changes: 129 additions & 115 deletions HERMES.md

Large diffs are not rendered by default.

20 changes: 20 additions & 0 deletions api/agent_sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,7 @@ def compression_tip(row: dict) -> tuple[dict | None, int]:
for key in (
'id', 'model', 'message_count', 'actual_message_count',
'ended_at', 'end_reason', 'last_activity',
'first_user_content', 'last_user_content',
):
if key in tip:
merged[key] = tip[key]
Expand Down Expand Up @@ -220,9 +221,26 @@ def read_importable_agent_session_rows(
)
return []

cur.execute("PRAGMA table_info(messages)")
message_cols = {row[1] for row in cur.fetchall()}

parent_expr = _optional_col('parent_session_id', session_cols)
ended_expr = _optional_col('ended_at', session_cols)
end_reason_expr = _optional_col('end_reason', session_cols)
if {'role', 'content', 'timestamp'}.issubset(message_cols):
first_user_expr = (
"(SELECT m1.content FROM messages m1 "
"WHERE m1.session_id = s.id AND m1.role = 'user' "
"ORDER BY m1.timestamp ASC LIMIT 1) AS first_user_content"
)
last_user_expr = (
"(SELECT m2.content FROM messages m2 "
"WHERE m2.session_id = s.id AND m2.role = 'user' "
"ORDER BY m2.timestamp DESC LIMIT 1) AS last_user_content"
)
else:
first_user_expr = "NULL AS first_user_content"
last_user_expr = "NULL AS last_user_content"

where_clauses = ["s.source IS NOT NULL", "s.source != 'webui'"]
params: list[str] = []
Expand All @@ -240,6 +258,8 @@ def read_importable_agent_session_rows(
{parent_expr},
{ended_expr},
{end_reason_expr},
{first_user_expr},
{last_user_expr},
COUNT(m.id) AS actual_message_count,
MAX(m.timestamp) AS last_activity
FROM sessions s
Expand Down
103 changes: 93 additions & 10 deletions api/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import json
import logging
import os
import re
import threading
import time
import uuid
Expand Down Expand Up @@ -809,16 +810,91 @@ def all_sessions():


def title_from(messages, fallback: str='Untitled'):
"""Derive a session title from the first user message."""
"""Derive a compact, human-readable title from user messages.

Strategy (inspired by craft-agents style title hygiene):
1) collect user messages
2) drop trailing low-signal turns ("ok", "thanks", "hello")
3) prefer first substantive message, else latest substantive
4) sanitize markdown/preamble noise and clamp length
"""
user_texts = []
for m in messages:
if m.get('role') == 'user':
c = m.get('content', '')
if isinstance(c, list):
c = ' '.join(p.get('text', '') for p in c if isinstance(p, dict) and p.get('type') == 'text')
text = str(c).strip()
if text:
return text[:64]
return fallback
if m.get('role') != 'user':
continue
text = _flatten_message_text(m.get('content', ''))
if text:
user_texts.append(text)
derived = _derive_title_from_texts(user_texts, fallback='')
return derived or fallback


def _flatten_message_text(content) -> str:
"""Flatten message content (str or Anthropic-style parts) into plain text."""
if isinstance(content, str):
return content.strip()
if isinstance(content, list):
parts = []
for p in content:
if isinstance(p, dict) and p.get('type') == 'text':
parts.append(str(p.get('text', '')).strip())
return ' '.join(x for x in parts if x).strip()
return str(content or '').strip()


def _is_low_signal(text: str) -> bool:
"""Heuristic for acknowledgements/placeholder inputs that make ugly titles."""
t = (text or '').strip().lower()
if not t:
return True
if '?' in t:
return False
# Short single-turn acknowledgements in common chat styles.
low_signal_tokens = {
'ok', 'okay', 'sure', 'yes', 'no', 'hi', 'hello', 'hey', 'yo',
'thanks', 'thank you', 'thx', 'cool', 'nice', 'done', 'start',
'继续', '好的', '好', '收到', '谢谢', '你好', '嗨',
}
if t in low_signal_tokens:
return True
if len(t) <= 12 and len(t.split()) <= 2:
return True
return False


def _clean_title_candidate(text: str) -> str:
t = str(text or '').strip()
if not t:
return ''
# Remove common markdown/preamble wrappers.
t = re.sub(r'^[#\-\*]+\s+', '', t)
t = re.sub(r'^\s*(title|topic)\s*:\s*', '', t, flags=re.IGNORECASE)
if t.startswith('**') and t.endswith('**') and len(t) > 4:
t = t[2:-2].strip()
t = re.sub(r'\s+', ' ', t).strip()
if (t.startswith('"') and t.endswith('"')) or (t.startswith("'") and t.endswith("'")):
t = t[1:-1].strip()
# Bound length and avoid overly long run-on titles.
words = t.split()
if len(words) > 10:
t = ' '.join(words[:10])
return t[:80].strip()


def _derive_title_from_texts(texts, fallback: str = '') -> str:
candidates = [str(x or '').strip() for x in (texts or []) if str(x or '').strip()]
if not candidates:
return fallback
trimmed = list(candidates)
while len(trimmed) > 1 and _is_low_signal(trimmed[-1]):
trimmed.pop()
substantive = [t for t in trimmed if not _is_low_signal(t)]
if substantive:
seed = substantive[0]
else:
seed = trimmed[-1] if trimmed else candidates[0]
cleaned = _clean_title_candidate(seed)
return cleaned or fallback


# ── Project helpers ──────────────────────────────────────────────────────────
Expand Down Expand Up @@ -928,7 +1004,14 @@ def get_cli_sessions() -> list:
break
except Exception:
pass # degrade gracefully
_display_title = _title or f'{_source.title()} Session'
_display_title = _title
if not _display_title:
_display_title = _derive_title_from_texts(
[row.get('first_user_content'), row.get('last_user_content')],
fallback='',
)
if not _display_title:
_display_title = f'{_source.title()} Session'
cli_sessions.append({
'session_id': sid,
'title': _display_title,
Expand Down
53 changes: 52 additions & 1 deletion static/boot.js
Original file line number Diff line number Diff line change
Expand Up @@ -531,7 +531,7 @@ document.addEventListener('keydown',async e=>{
// If the current session has no messages, just focus the composer rather than
// creating another empty session that will clutter the sidebar list (#1171).
if(S.session&&(S.session.message_count||0)===0){$('msg').focus();return;}
if(!S.busy){await newSession();await renderSessionList();closeMobileSidebar();$('msg').focus();}
await newSession();await renderSessionList();closeMobileSidebar();$('msg').focus();
}
if(e.key==='Escape'){
// Close onboarding overlay if open (skip/dismiss the wizard)
Expand Down Expand Up @@ -639,6 +639,7 @@ const _SKINS=[
{name:'Poseidon', colors:['#0EA5E9','#0284C7','#0369A1']},
{name:'Sisyphus', colors:['#A78BFA','#8B5CF6','#7C3AED']},
{name:'Charizard',colors:['#FB923C','#F97316','#EA580C']},
{name:'Claude', colors:['#D97757','#C06A49','#9A523A']},
];
const _VALID_THEMES=new Set((_THEMES||[]).map(t=>t.value));
const _VALID_SKINS=new Set((_SKINS||[]).map(s=>s.name.toLowerCase()));
Expand Down Expand Up @@ -994,3 +995,53 @@ window.addEventListener('pageshow', (event) => {
// Restart the gateway SSE watcher — the persisted connection is dead after bfcache
if (typeof startGatewaySSE === 'function') try { startGatewaySSE(); } catch (_) {}
});

// ── Hermes hero splash — populate stats on the blank "new conversation" screen.
// Called from boot, and again whenever the settings panel or profile switcher
// changes a relevant value. Failures are silent: the hero shows '—' by default,
// which reads cleanly as "info unavailable" rather than a broken state.
async function refreshHermesHero(){
const set=(id,val)=>{const el=document.getElementById(id);if(el&&val!=null&&val!=='') el.textContent=String(val);};
// Version: _bootSettings was captured during boot; fall back to a fresh fetch.
try{
const s=(typeof _bootSettings==='object'&&_bootSettings)||await api('/api/settings');
if(s&&s.webui_version) set('heroVersion','v'+s.webui_version);
}catch(_){ }
// Profile: live from the chip, or from S.activeProfile
try{
const pchip=document.getElementById('profileChipLabel');
const pname=(pchip&&pchip.textContent||'').trim()||(window.S&&S.activeProfile)||'default';
set('heroProfile',pname);
}catch(_){ }
// Model: live from the model chip (populated by populateModelDropdown)
try{
const mchip=document.getElementById('composerModelLabel');
const mname=(mchip&&mchip.textContent||'').trim();
if(mname) set('heroModel',mname);
}catch(_){ }
// Skills count — /api/skills is cheap and already used by the skills panel.
api('/api/skills').then(d=>{
const n=(d&&Array.isArray(d.skills))?d.skills.length:null;
if(n!=null) set('heroStatSkills',n);
}).catch(()=>{});
// Sessions count — /api/sessions returns the full list; take .length.
api('/api/sessions').then(d=>{
const arr=(d&&(d.sessions||d.items))||null;
if(Array.isArray(arr)) set('heroStatSessions',arr.length);
}).catch(()=>{});
// Workspaces count — /api/workspaces returns saved workspace entries.
api('/api/workspaces').then(d=>{
const arr=(d&&(d.workspaces||d.items))||null;
if(Array.isArray(arr)) set('heroStatWorkspaces',arr.length);
}).catch(()=>{});
// Profiles count — /api/profiles returns agent profile entries.
api('/api/profiles').then(d=>{
const arr=(d&&(d.profiles||d.items))||null;
if(Array.isArray(arr)) set('heroStatProfiles',arr.length);
}).catch(()=>{});
}
window.refreshHermesHero=refreshHermesHero;
// Run once shortly after DOM is live so boot IIFE has a chance to populate chips.
setTimeout(()=>{ try{ refreshHermesHero(); }catch(_){ } }, 400);
// Refresh when switching back to an empty conversation
window.addEventListener('visibilitychange',()=>{ if(!document.hidden){ try{ refreshHermesHero(); }catch(_){ } } });
Loading