feat(session-search): give the agent a link to hand back

Asked to link to a session, the agent had no way to know the @session
reference syntax exists — every mention in the tool schema described
consuming a link the user dropped, never writing one — so it answered
with the title and timestamp as prose and the desktop had nothing to
render.

Every result now carries a ready-to-copy `link`, and the schema says to
write it inline instead of restating the title around it. The profile
segment is omitted when the active profile can't be named confidently;
a bare id still resolves.

Also skip linkifying a ref a model already wrapped in a markdown link,
which would otherwise rewrite into a nested link.
This commit is contained in:
Brooklyn Nicholson 2026-07-24 23:30:25 -05:00
parent 92439be351
commit 99ab036291
4 changed files with 115 additions and 7 deletions

View file

@ -90,6 +90,12 @@ describe('linkifySessionRefs', () => {
expect(linkifySessionRefs('me@session:abc')).toBe('me@session:abc')
expect(linkifySessionRefs('a/@session:abc')).toBe('a/@session:abc')
})
it('leaves a ref a model already wrapped in a markdown link alone', () => {
const text = '[that chat](@session:work/abc123)'
expect(linkifySessionRefs(text)).toBe(text)
})
})
// The agent-authored path: assistant markdown runs through preprocessMarkdown

View file

@ -8,8 +8,9 @@
/** Mirrors the composer/transcript form in `directive-text.tsx`: a bare value,
* or one fenced in backticks/quotes so a value with spaces survives. The
* lookbehind keeps `foo@session:` and URL paths from matching. */
export const SESSION_REF_RE = /(?<![\w/])@session:(`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|\S+)/g
* lookbehinds keep `foo@session:` and URL paths from matching, and skip a ref
* a model already wrapped in a markdown link rewriting that would nest. */
export const SESSION_REF_RE = /(?<![\w/])(?<!]\()@session:(`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|\S+)/g
const TRAILING_PUNCTUATION_RE = /[,.;:!?)\]}]+$/

View file

@ -20,6 +20,7 @@ from tools.session_search_tool import (
_is_compacted_message,
_is_compression_ended,
_resolve_to_parent,
_session_link,
session_search,
)
@ -492,6 +493,69 @@ class TestReadShape:
assert len(result["messages"]) == 30 # head 20 + tail 10
# =========================================================================
# Session links — the value the agent writes to point the user at a session
# =========================================================================
def _linked_session_id(link: str) -> str:
"""Recover the session id from an `@session:[<profile>/]<id>` value."""
assert link.startswith("@session:"), link
value = link[len("@session:"):]
return value.rsplit("/", 1)[-1]
class TestSessionLink:
def test_link_carries_the_named_profile(self):
assert _session_link("s_oldest", "work") == "@session:work/s_oldest"
def test_link_falls_back_to_a_bare_id_when_the_profile_is_unknown(self, monkeypatch):
monkeypatch.setattr(
"hermes_cli.profiles.get_active_profile_name",
lambda: "custom",
)
assert _session_link("s_oldest") == "@session:s_oldest"
def test_link_survives_a_failure_to_resolve_the_profile(self, monkeypatch):
def boom():
raise RuntimeError("no profile")
monkeypatch.setattr("hermes_cli.profiles.get_active_profile_name", boom)
assert _session_link("s_oldest") == "@session:s_oldest"
def test_read_result_links_to_the_session_it_read(self, db):
_seed_modpack_sessions(db)
result = json.loads(session_search(session_id="s_oldest", db=db))
assert _linked_session_id(result["link"]) == result["session_id"]
def test_every_discovery_result_links_to_its_own_session(self, db):
_seed_modpack_sessions(db)
result = json.loads(session_search(query="modpack", limit=5, db=db))
assert result["results"]
for entry in result["results"]:
assert _linked_session_id(entry["link"]) == entry["session_id"]
def test_every_browse_result_links_to_its_own_session(self, db):
_seed_modpack_sessions(db)
result = json.loads(session_search(db=db))
assert result["results"]
for entry in result["results"]:
assert _linked_session_id(entry["link"]) == entry["session_id"]
def test_link_splits_the_way_the_tool_parses_it_back(self):
"""The agent hands its own link back as session_id; the value has to
survive the profile/id partition in session_search."""
value = _session_link("s_middle", "work")[len("@session:"):]
profile, _, session_id = value.partition("/")
assert (profile, session_id) == ("work", "s_middle")
# =========================================================================
# Cross-profile read — `profile` swaps in another profile's DB (read-only)
# =========================================================================

View file

@ -291,6 +291,28 @@ def _resolve_profile_db(profile: str):
return SessionDB(db_path=profiles_mod.get_profile_dir(canon) / "state.db", read_only=True)
def _session_link(session_id: str, profile: str = None) -> str:
"""The reference the agent writes to point the user at a session.
Same value the desktop composer emits when a session is dragged into a
message, so the desktop renders it as a link carrying the session's title.
The profile segment is omitted when we can't name it confidently — a bare
id still resolves, it just can't disambiguate across profiles.
"""
name = (profile or "").strip()
if not name:
try:
from hermes_cli.profiles import get_active_profile_name
resolved = get_active_profile_name()
name = "" if resolved == "custom" else resolved
except Exception:
logging.debug("get_active_profile_name failed for session link", exc_info=True)
name = ""
return f"@session:{name}/{session_id}" if name else f"@session:{session_id}"
def _locate_session_db(session_id: str):
"""Scan every profile's ``state.db`` (read-only) for a session id.
@ -335,7 +357,7 @@ def _locate_session_db(session_id: str):
return None, None
def _read_session(db, session_id: str, head: int = 20, tail: int = 10) -> str:
def _read_session(db, session_id: str, head: int = 20, tail: int = 10, link_profile: str = None) -> str:
"""Read shape: dump a whole session by id (head + tail when large).
Serves the linked-session case the user dropped an @session reference and
@ -366,6 +388,7 @@ def _read_session(db, session_id: str, head: int = 20, tail: int = 10) -> str:
"success": True,
"mode": "read",
"session_id": session_id,
"link": _session_link(session_id, link_profile),
"session_meta": {
"when": _format_timestamp(meta.get("started_at")),
"source": meta.get("source"),
@ -384,7 +407,7 @@ def _read_session(db, session_id: str, head: int = 20, tail: int = 10) -> str:
return json.dumps(response, ensure_ascii=False)
def _list_recent_sessions(db, limit: int, current_session_id: str = None) -> str:
def _list_recent_sessions(db, limit: int, current_session_id: str = None, link_profile: str = None) -> str:
"""Return metadata for the most recent sessions (no LLM calls, no FTS5)."""
try:
sessions = db.list_sessions_rich(
@ -405,6 +428,7 @@ def _list_recent_sessions(db, limit: int, current_session_id: str = None) -> str
continue
results.append({
"session_id": sid,
"link": _session_link(sid, link_profile),
"title": s.get("title") or None,
"source": s.get("source", ""),
"started_at": s.get("started_at", ""),
@ -630,6 +654,7 @@ def _discover(
limit: int,
sort: Optional[str],
current_session_id: str = None,
link_profile: str = None,
) -> str:
"""Discovery shape: FTS5 + anchored window + bookends per hit. Single call."""
role_list = role_filter if role_filter else ["user", "assistant"]
@ -764,6 +789,9 @@ def _discover(
entry["parent_session_id"] = lineage_root
results.append(entry)
for entry in results:
entry["link"] = _session_link(entry["session_id"], link_profile)
_final_payload = {
"success": True,
"mode": "discover",
@ -848,7 +876,7 @@ def session_search(
# Read shape: a session_id with no anchor → dump the whole session.
if isinstance(session_id, str) and session_id.strip():
sid = session_id.strip()
result = _read_session(db, sid)
result = _read_session(db, sid, link_profile=profile)
if json.loads(result).get("success"):
return result
@ -858,7 +886,7 @@ def session_search(
located, owner = _locate_session_db(sid)
if located is not None:
try:
found = json.loads(_read_session(located, sid))
found = json.loads(_read_session(located, sid, link_profile=owner))
finally:
located.close()
if found.get("success"):
@ -876,7 +904,7 @@ def session_search(
# Browse shape: no query → recent sessions.
if not query or not isinstance(query, str) or not query.strip():
return _list_recent_sessions(db, limit, current_session_id)
return _list_recent_sessions(db, limit, current_session_id, link_profile=profile)
# Parse role_filter
role_list: Optional[List[str]] = None
@ -897,6 +925,7 @@ def session_search(
limit=limit,
sort=sort_norm,
current_session_id=current_session_id,
link_profile=profile,
)
@ -962,6 +991,14 @@ SESSION_SEARCH_SCHEMA = {
" session_search()\n"
" Returns recent sessions chronologically: titles, previews, timestamps. "
"Use when the user asks \"what was I working on\" without naming a topic.\n\n"
"LINKING THE USER TO A SESSION\n\n"
" When you refer the user to a session, write its `link` value inline in "
"your reply — every result carries one, e.g. "
"`@session:default/20260722_204335_d62c16`. Copy it verbatim; do not "
"reformat it as a markdown link or wrap it in backticks. Hermes renders "
"it as the session's title, so there is no need to also spell out the "
"title, date, or id around it — write \"picked up in @session:... \" "
"rather than restating the metadata.\n\n"
"FTS5 SYNTAX\n\n"
" AND is the default — multi-word queries require all terms. Use OR explicitly "
"for broader recall (`alpha OR beta OR gamma`), quoted phrases for exact match "