Skip to content
Merged
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
50 changes: 50 additions & 0 deletions agent/skill_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,21 @@
_BUNDLE_USER_INSTRUCTION = "\nUser instruction: "
_BUNDLE_FIRST_SKILL_BLOCK = "\n\n[Loaded as part of the "

# The skill name sits in the first quoted span of the activation note, for both
# the single-skill and the bundle header ("work" / "/clean /work").
_SKILL_NAME_RE = re.compile(re.escape(_SKILL_INVOCATION_PREFIX) + r'"([^"]*)"')

# SQL LIKE pattern matching a skill-expanded turn, for listing queries that
# have to recognize scaffolding before the row reaches Python. The prefix
# contains no LIKE wildcards (`%`, `_`), so it needs no ESCAPE clause.
SKILL_SCAFFOLD_SQL_LIKE = _SKILL_INVOCATION_PREFIX + "%"

# Marks where a preview query joined the head and tail of a long scaffolded
# message. ``describe_skill_invocation`` may hand back a span that runs across
# the joint (a bundle instruction cut off by the head window); callers cut the
# description there rather than show the skill body on the far side.
SKILL_EXCERPT_JOINT = "\x1e"


def extract_user_instruction_from_skill_message(content: Any) -> Optional[str]:
"""Recover the user's instruction from a slash-skill-expanded turn.
Expand Down Expand Up @@ -82,6 +97,41 @@ def extract_user_instruction_from_skill_message(content: Any) -> Optional[str]:
return None


def describe_skill_invocation(content: Any) -> Optional[str]:
"""Render a slash-skill-expanded turn the way the user typed it.

The expanded message embeds the whole skill body, so any surface that
summarizes a user turn from its raw content — session titles, sidebar
previews, the ``/rewind`` picker — otherwise shows the skill's own prose
as if the user had written it. That is how a skill's opening line ends up
as a session title.

Returns ``"/work — fix the title leak"``, or ``"/work"`` for a bare
invocation, or ``None`` when *content* is not skill scaffolding (the
caller should then summarize it as an ordinary message).
"""
if not isinstance(content, str) or not content.startswith(_SKILL_INVOCATION_PREFIX):
return None

match = _SKILL_NAME_RE.match(content)
name = (match.group(1) if match else "").strip()
# Bundle headers already carry their typed "/a /b" keys; a single skill is
# a bare name.
label = name if name.startswith("/") else f"/{name}"

instruction = extract_user_instruction_from_skill_message(content)
if instruction and instruction is not content:
# An excerpted message (head + tail, joined by SKILL_EXCERPT_JOINT) can
# put the joint inside the matched span — keep only the side the
# instruction marker was found on.
instruction = instruction.split(SKILL_EXCERPT_JOINT)[0]
instruction = " ".join(instruction.split())
if instruction:
return f"{label} — {instruction}" if name else instruction

return label if name else None


def _extract_single_skill_user_instruction(message: str) -> Optional[str]:
# Single-skill format appends the user instruction after the skill body, so
# the last occurrence is the user-provided one; the body may quote this text.
Expand Down
28 changes: 27 additions & 1 deletion agent/title_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,27 @@ def _auto_title_enabled() -> bool:
return True


def _summarize_user_message(user_message: str) -> str:
"""Collapse a slash-skill-expanded turn back to what the user typed.

A ``/skill`` invocation expands into a message that embeds the whole skill
body, so feeding it to the titler verbatim titles the session after the
*skill's* prose — "Kick off a task in a fresh isolated git worktree" — not
after the user's request. Reuse the canonical scaffolding parser so the
model sees ``/work — fix the title leak`` instead.
"""
if not user_message:
return ""
try:
from agent.skill_commands import describe_skill_invocation

described = describe_skill_invocation(user_message)
except Exception:
logger.debug("Skill-scaffolding summary failed; titling raw", exc_info=True)
return user_message
return described if described is not None else user_message


def generate_title(
user_message: str,
assistant_response: str,
Expand Down Expand Up @@ -110,7 +131,7 @@ def generate_title(
logger.debug("Title runtime validator raised; proceeding", exc_info=True)

# Truncate long messages to keep the request small
user_snippet = user_message[:500] if user_message else ""
user_snippet = _summarize_user_message(user_message)[:500]
assistant_snippet = assistant_response[:500] if assistant_response else ""

language = _title_language()
Expand Down Expand Up @@ -143,6 +164,11 @@ def generate_title(
title = title.strip('"\'')
if title.lower().startswith("title:"):
title = title[6:].strip()
# A title is one line. A model that ignores "return ONLY the title" and
# answers the prompt instead (a shell transcript, a bulleted plan) would
# otherwise be stored verbatim and truncated mid-command. Keep the first
# non-empty line — the closest thing to a title in that response.
title = next((line.strip() for line in title.splitlines() if line.strip()), "")
# Enforce reasonable length
if len(title) > 80:
title = title[:77] + "..."
Expand Down
84 changes: 84 additions & 0 deletions hermes_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -16072,6 +16072,29 @@ def _add_session_filter_args(p, default_older_help):
sessions_rename.add_argument("session_id", help="Session ID to rename")
sessions_rename.add_argument("title", nargs="+", help="New title for the session")

sessions_retitle = sessions_subparsers.add_parser(
"retitle-skills",
help="Re-title sessions whose auto-title came from a /skill's own text",
description=(
"Sessions opened with a /skill were auto-titled from the expanded "
"message, which embeds the whole skill body — so the title "
"describes the SKILL, not the request. This regenerates those "
"titles from what the user actually typed. Lists what it would "
"change unless --apply is passed."
),
)
sessions_retitle.add_argument(
"--apply",
action="store_true",
help="Write the new titles (default: dry run)",
)
sessions_retitle.add_argument(
"--limit",
type=int,
default=200,
help="Maximum sessions to examine (default: 200)",
)

sessions_browse = sessions_subparsers.add_parser(
"browse",
help="Interactive session picker — browse, search, and resume sessions",
Expand Down Expand Up @@ -16952,6 +16975,67 @@ def _export_one(session_id: str, *, include_lineage: bool = False):
except ValueError as e:
print(f"Error: {e}")

elif action == "retitle-skills":
from agent.skill_commands import describe_skill_invocation
from agent.title_generator import generate_title

limit = max(1, int(getattr(args, "limit", 200) or 200))
apply_changes = bool(getattr(args, "apply", False))

def _is_titlelike(candidate: str) -> bool:
"""Reject a candidate that isn't a title at all.

An auxiliary model occasionally answers the prompt instead of
titling it and echoes the assistant's output ('$ df -h /'). The
live path has no alternative and takes what it gets, but this is
a REPAIR — replacing a serviceable title with command output
would make things worse, so keep the old one.
"""
return bool(candidate) and candidate[0].isalnum()

candidates = db.list_skill_scaffolded_sessions(limit=limit)
if not candidates:
print("No sessions were titled from a /skill invocation.")
return

print(
f"{len(candidates)} session(s) opened with a /skill"
f"{'' if apply_changes else ' (dry run — pass --apply to write)'}:"
)
changed = 0
for row in candidates:
session_id = row["id"]
typed = describe_skill_invocation(row["content"]) or ""
first_reply = db.get_first_assistant_text(session_id) or ""
new_title = generate_title(typed, first_reply)
if not new_title or new_title == row["title"]:
continue
if not _is_titlelike(new_title):
print(f" {session_id}\n kept {row['title']!r} — got {new_title!r}")
continue
print(f" {session_id}\n {row['title']!r}\n → {new_title!r}")
changed += 1
if not apply_changes:
continue
try:
db.set_session_title(session_id, new_title)
except ValueError:
# Unique-title collision. Dedupe the same way the live
# auto-titler does (base #2, base #3, ...) rather than
# leaving the leaked title in place.
deduped = db.get_next_title_in_lineage(new_title)
try:
db.set_session_title(session_id, deduped)
print(f" (renamed to {deduped!r} — title was taken)")
except ValueError as e:
print(f" skipped: {e}")
changed -= 1

if not changed:
print(" every title already reflects the user's request.")
elif apply_changes:
print(f"✓ Re-titled {changed} session(s).")

elif action == "browse":
limit = getattr(args, "limit", 500) or 500
source = getattr(args, "source", None)
Expand Down
Loading
Loading