refactor(hermes_cli): session-export/projects pass 2 — fold export renderers, projects_db row/meta helpers, projects_cmd str-return handlers

This commit is contained in:
Teknium
2026-09-03 00:06:57 -07:00
parent 4e1338bd19
commit c4693838c6
7 changed files with 178 additions and 303 deletions
+29 -53
View File
@@ -22,18 +22,15 @@ def build_parser(parent_subparsers: argparse._SubParsersAction) -> argparse.Argu
), ),
) )
sub = parser.add_subparsers(dest="project_action") sub = parser.add_subparsers(dest="project_action")
p_create = sub.add_parser("create", help="Create a new project") p_create = sub.add_parser("create", help="Create a new project")
p_create.add_argument("name", help="Human name, e.g. 'Hermes Agent'") p_create.add_argument("name", help="Human name, e.g. 'Hermes Agent'")
p_create.add_argument("folders", nargs="*", help="Folder paths to include (first = primary)") p_create.add_argument("folders", nargs="*", help="Folder paths to include (first = primary)")
p_create.add_argument("--slug", default=None, help="Explicit slug override") p_create.add_argument("--slug", default=None, help="Explicit slug override")
p_create.add_argument("--primary", default=None, metavar="PATH", help="Primary repo path") p_create.add_argument("--primary", default=None, metavar="PATH", help="Primary repo path")
p_create.add_argument("--description", default=None) for opt in ("--description", "--icon", "--color"):
p_create.add_argument("--icon", default=None) p_create.add_argument(opt, default=None)
p_create.add_argument("--color", default=None)
p_create.add_argument("--board", default=None, metavar="SLUG", help="Bind a kanban board") p_create.add_argument("--board", default=None, metavar="SLUG", help="Bind a kanban board")
p_create.add_argument("--use", action="store_true", help="Set as the active project") p_create.add_argument("--use", action="store_true", help="Set as the active project")
p_list = sub.add_parser("list", aliases=["ls"], help="List projects") p_list = sub.add_parser("list", aliases=["ls"], help="List projects")
p_list.add_argument("--all", action="store_true", dest="include_archived", help="Include archived projects") p_list.add_argument("--all", action="store_true", dest="include_archived", help="Include archived projects")
@@ -43,27 +40,20 @@ def build_parser(parent_subparsers: argparse._SubParsersAction) -> argparse.Argu
return sp return sp
project_sub("show", "Show a project's details") project_sub("show", "Show a project's details")
p_add = project_sub("add-folder", "Add a folder to a project") p_add = project_sub("add-folder", "Add a folder to a project")
p_add.add_argument("path", help="Folder path") p_add.add_argument("path", help="Folder path")
p_add.add_argument("--label", default=None) p_add.add_argument("--label", default=None)
p_add.add_argument("--primary", action="store_true", help="Mark as primary repo") p_add.add_argument("--primary", action="store_true", help="Mark as primary repo")
project_sub("remove-folder", "Remove a folder from a project").add_argument("path", help="Folder path") project_sub("remove-folder", "Remove a folder from a project").add_argument("path", help="Folder path")
project_sub("rename", "Rename a project").add_argument("name", help="New name") project_sub("rename", "Rename a project").add_argument("name", help="New name")
project_sub("set-primary", "Set the primary folder").add_argument( project_sub("set-primary", "Set the primary folder").add_argument("path", help="Folder path (must already be in project)")
"path", help="Folder path (must already be in project)"
)
p_use = sub.add_parser("use", help="Set the active project") p_use = sub.add_parser("use", help="Set the active project")
p_use.add_argument("project", nargs="?", default=None, help="Project id or slug (omit to clear)") p_use.add_argument("project", nargs="?", default=None, help="Project id or slug (omit to clear)")
project_sub("archive", "Archive a project") project_sub("archive", "Archive a project")
project_sub("restore", "Restore an archived project") project_sub("restore", "Restore an archived project")
project_sub("bind-board", "Bind a kanban board to a project").add_argument( project_sub("bind-board", "Bind a kanban board to a project").add_argument(
"board", nargs="?", default="", help="Board slug (omit to unbind)" "board", nargs="?", default="", help="Board slug (omit to unbind)"
) )
parser.set_defaults(_project_parser=parser) parser.set_defaults(_project_parser=parser)
return parser return parser
@@ -76,10 +66,7 @@ def projects_command(args: argparse.Namespace) -> int:
if parser is not None: if parser is not None:
parser.print_help() parser.print_help()
else: else:
print( print("usage: hermes project <action> [options]\nRun 'hermes project --help' for the full list.", file=sys.stderr)
"usage: hermes project <action> [options]\nRun 'hermes project --help' for the full list.",
file=sys.stderr,
)
return 0 return 0
handler = _HANDLERS.get(action) handler = _HANDLERS.get(action)
if handler is None: if handler is None:
@@ -101,16 +88,21 @@ def _resolve(conn, ident: str):
def _db_command(fn): def _db_command(fn):
"""Open the DB and run ``fn(args, conn)``; a ``ValueError`` prints ``project: …`` and exits 2.""" """Open the DB and run ``fn(args, conn)``; a ``str`` result is printed (rc 0), an ``int`` is the rc;
a ``ValueError`` prints ``project: …`` and exits 2."""
@functools.wraps(fn) @functools.wraps(fn)
def wrapper(args: argparse.Namespace) -> int: def wrapper(args: argparse.Namespace) -> int:
try: try:
with pdb.connect_closing() as conn: with pdb.connect_closing() as conn:
return fn(args, conn) out = fn(args, conn)
except ValueError as exc: except ValueError as exc:
print(f"project: {exc}", file=sys.stderr) print(f"project: {exc}", file=sys.stderr)
return 2 return 2
if isinstance(out, str):
print(out)
return 0
return out
return wrapper return wrapper
@@ -119,7 +111,7 @@ def _with_project(fn):
"""Like ``_db_command`` but also resolves ``args.project`` into ``fn(args, conn, proj)``.""" """Like ``_db_command`` but also resolves ``args.project`` into ``fn(args, conn, proj)``."""
@functools.wraps(fn) @functools.wraps(fn)
def wrapper(args: argparse.Namespace, conn) -> int: def wrapper(args: argparse.Namespace, conn):
proj = _resolve(conn, args.project) proj = _resolve(conn, args.project)
return 1 if proj is None else fn(args, conn, proj) return 1 if proj is None else fn(args, conn, proj)
@@ -156,12 +148,11 @@ def _cmd_create(args, conn) -> int:
@_db_command @_db_command
def _cmd_list(args, conn) -> int: def _cmd_list(args, conn):
active = pdb.get_active_id(conn) active = pdb.get_active_id(conn)
projs = pdb.list_projects(conn, include_archived=getattr(args, "include_archived", False)) projs = pdb.list_projects(conn, include_archived=getattr(args, "include_archived", False))
if not projs: if not projs:
print("No projects yet. Create one with `hermes project create <name>`.") return "No projects yet. Create one with `hermes project create <name>`."
return 0
for p in projs: for p in projs:
flags = " (archived)" if p.archived else "" flags = " (archived)" if p.archived else ""
print(f"{'*' if p.id == active else ' '} {p.slug:<24} {p.name}{flags} [{len(p.folders)} folder(s)]") print(f"{'*' if p.id == active else ' '} {p.slug:<24} {p.name}{flags} [{len(p.folders)} folder(s)]")
@@ -175,68 +166,53 @@ def _cmd_show(args, conn, proj) -> int:
@_with_project @_with_project
def _cmd_add_folder(args, conn, proj) -> int: def _cmd_add_folder(args, conn, proj) -> str:
path = pdb.add_folder(conn, proj.id, args.path, label=args.label, is_primary=args.primary) path = pdb.add_folder(conn, proj.id, args.path, label=args.label, is_primary=args.primary)
print(f"Added {path} to {proj.slug}") return f"Added {path} to {proj.slug}"
return 0
@_with_project @_with_project
def _cmd_remove_folder(args, conn, proj) -> int: def _cmd_remove_folder(args, conn, proj):
if not pdb.remove_folder(conn, proj.id, args.path): if not pdb.remove_folder(conn, proj.id, args.path):
return _err(f"folder not in project: {args.path}") return _err(f"folder not in project: {args.path}")
print(f"Removed {args.path} from {proj.slug}") return f"Removed {args.path} from {proj.slug}"
return 0
@_with_project @_with_project
def _cmd_rename(args, conn, proj) -> int: def _cmd_rename(args, conn, proj) -> str:
pdb.update_project(conn, proj.id, name=args.name) pdb.update_project(conn, proj.id, name=args.name)
print(f"Renamed {proj.slug} -> {args.name}") return f"Renamed {proj.slug} -> {args.name}"
return 0
@_with_project @_with_project
def _cmd_set_primary(args, conn, proj) -> int: def _cmd_set_primary(args, conn, proj):
if not pdb.set_primary(conn, proj.id, args.path): if not pdb.set_primary(conn, proj.id, args.path):
return _err(f"'{args.path}' is not a folder of {proj.slug}; add it first with `hermes project add-folder`.") return _err(f"'{args.path}' is not a folder of {proj.slug}; add it first with `hermes project add-folder`.")
print(f"Set primary of {proj.slug} -> {args.path}") return f"Set primary of {proj.slug} -> {args.path}"
return 0
@_db_command @_db_command
def _cmd_use(args, conn) -> int: def _cmd_use(args, conn):
if not args.project: if not args.project:
pdb.set_active(conn, None) pdb.set_active(conn, None)
print("Cleared active project") return "Cleared active project"
return 0
proj = _resolve(conn, args.project) proj = _resolve(conn, args.project)
if proj is None: if proj is None:
return 1 return 1
pdb.set_active(conn, proj.id) pdb.set_active(conn, proj.id)
print(f"Active project: {proj.slug}") return f"Active project: {proj.slug}"
return 0
def _flag_command(op: str, verb: str): def _flag_command(op: str, verb: str):
"""Handler for ``pdb.<op>(conn, proj.id)`` followed by ``"<verb> <slug>"``.""" """Handler for ``pdb.<op>(conn, proj.id)`` followed by ``"<verb> <slug>"``."""
return _with_project(lambda args, conn, proj: (getattr(pdb, op)(conn, proj.id), f"{verb} {proj.slug}")[1])
@_with_project
def handler(args, conn, proj) -> int:
getattr(pdb, op)(conn, proj.id)
print(f"{verb} {proj.slug}")
return 0
return handler
@_with_project @_with_project
def _cmd_bind_board(args, conn, proj) -> int: def _cmd_bind_board(args, conn, proj) -> str:
pdb.update_project(conn, proj.id, board_slug=args.board) pdb.update_project(conn, proj.id, board_slug=args.board)
if not args.board.strip(): if not args.board.strip():
print(f"Unbound board from {proj.slug}") return f"Unbound board from {proj.slug}"
return 0
print(f"Bound {proj.slug} -> board {args.board}")
if proj.primary_path: # best-effort: point the bound board's default_workdir at the primary repo if proj.primary_path: # best-effort: point the bound board's default_workdir at the primary repo
try: try:
from hermes_cli import kanban_db as kb from hermes_cli import kanban_db as kb
@@ -246,7 +222,7 @@ def _cmd_bind_board(args, conn, proj) -> int:
kb.write_board_metadata(slug, default_workdir=proj.primary_path) kb.write_board_metadata(slug, default_workdir=proj.primary_path)
except Exception: except Exception:
pass pass
return 0 return f"Bound {proj.slug} -> board {args.board}"
_HANDLERS = { _HANDLERS = {
+64 -114
View File
@@ -66,11 +66,19 @@ CREATE TABLE IF NOT EXISTS discovered_repos (
); );
""" """
# --- Slug + id helpers -------------------------------------------------------
# Lowercase alphanumerics, hyphens, underscores; 1-64 chars; no leading separator. Strict enough to # Lowercase alphanumerics, hyphens, underscores; 1-64 chars; no leading separator. Strict enough to
# stop traversal/path separators, loose enough for kebab-case. Display formatting lives in ``name``. # stop traversal/path separators, loose enough for kebab-case. Display formatting lives in ``name``.
_SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9\-_]{0,63}$") _SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9\-_]{0,63}$")
# Deterministic branch slug: lowercase, separators collapsed, capped.
_BRANCH_SAFE_RE = re.compile(r"[^a-z0-9._-]+")
_INITIALIZED_PATHS: set[str] = set()
# TEXT columns added to `projects` after v1; re-applied idempotently on every open so a legacy DB
# upgrades in place.
_OPTIONAL_PROJECT_COLUMNS = ("board_slug", "primary_path", "icon", "color")
# Nullable TEXT columns that may be absent from a legacy row.
_OPTIONAL_ROW_FIELDS = ("description", "icon", "color", "board_slug", "primary_path")
_ACTIVE_META_KEY = "active_id"
_DISCOVERY_POLICY_META_KEY = "repo_discovery_policy"
def _slugify(name: str) -> str: def _slugify(name: str) -> str:
@@ -81,9 +89,7 @@ def _slugify(name: str) -> str:
def normalize_slug(slug: Optional[str]) -> Optional[str]: def normalize_slug(slug: Optional[str]) -> Optional[str]:
"""Lowercase + strip a slug; validate; return ``None`` for empty.""" """Lowercase + strip a slug; validate; return ``None`` for empty."""
if slug is None: s = str(slug).strip().lower() if slug is not None else ""
return None
s = str(slug).strip().lower()
if not s: if not s:
return None return None
if not _SLUG_RE.match(s): if not _SLUG_RE.match(s):
@@ -105,15 +111,6 @@ def _normalize_path(path: str) -> str:
return p.rstrip("/\\") or p return p.rstrip("/\\") or p
# --- Connection management ---------------------------------------------------
_INITIALIZED_PATHS: set[str] = set()
# TEXT columns added to `projects` after v1; re-applied idempotently on every open so a legacy DB
# upgrades in place.
_OPTIONAL_PROJECT_COLUMNS = ("board_slug", "primary_path", "icon", "color")
def connect(db_path: Optional[Path] = None) -> sqlite3.Connection: def connect(db_path: Optional[Path] = None) -> sqlite3.Connection:
"""Open (and initialize if needed) the per-profile projects DB. """Open (and initialize if needed) the per-profile projects DB.
@@ -155,8 +152,6 @@ def connect_closing(db_path: Optional[Path] = None):
conn.close() conn.close()
# --- Dataclasses -------------------------------------------------------------
@dataclass @dataclass
class ProjectFolder: class ProjectFolder:
path: str path: str
@@ -183,42 +178,31 @@ class Project:
folders: List[ProjectFolder] = field(default_factory=list) folders: List[ProjectFolder] = field(default_factory=list)
def to_dict(self) -> dict: def to_dict(self) -> dict:
d = {k: getattr(self, k) for k in ("id", "slug", "name", "description", "icon", "color", "board_slug", "primary_path")} d = {k: getattr(self, k) for k in ("id", "slug", "name", *_OPTIONAL_ROW_FIELDS)}
return {**d, "archived": bool(self.archived), "created_at": self.created_at, "folders": [f.to_dict() for f in self.folders]} return {**d, "archived": bool(self.archived), "created_at": self.created_at, "folders": [f.to_dict() for f in self.folders]}
# Nullable TEXT columns that may be absent from a legacy row.
_OPTIONAL_ROW_FIELDS = ("description", "icon", "color", "board_slug", "primary_path")
def _load_project(conn: sqlite3.Connection, row: sqlite3.Row) -> Project: def _load_project(conn: sqlite3.Connection, row: sqlite3.Row) -> Project:
"""Materialize a ``projects`` row together with its folders.""" """Materialize a ``projects`` row together with its folders."""
keys = row.keys() keys = row.keys()
project = Project( folders = conn.execute(
"SELECT path, label, is_primary, added_at FROM project_folders WHERE project_id = ? ORDER BY is_primary DESC, added_at ASC",
(row["id"],),
).fetchall()
return Project(
id=row["id"], slug=row["slug"], name=row["name"], created_at=row["created_at"], id=row["id"], slug=row["slug"], name=row["name"], created_at=row["created_at"],
archived=bool(row["archived"]) if "archived" in keys else False, archived=bool(row["archived"]) if "archived" in keys else False,
folders=[ProjectFolder(r["path"], r["label"], bool(r["is_primary"]), r["added_at"]) for r in folders],
**{f: row[f] for f in _OPTIONAL_ROW_FIELDS if f in keys}, **{f: row[f] for f in _OPTIONAL_ROW_FIELDS if f in keys},
) )
project.folders = [
ProjectFolder(path=r["path"], label=r["label"], is_primary=bool(r["is_primary"]), added_at=r["added_at"])
for r in conn.execute(
"SELECT path, label, is_primary, added_at FROM project_folders WHERE project_id = ? ORDER BY is_primary DESC, added_at ASC",
(project.id,),
).fetchall()
]
return project
# --- CRUD --------------------------------------------------------------------
def _unique_slug(conn: sqlite3.Connection, candidate: str) -> str: def _unique_slug(conn: sqlite3.Connection, candidate: str) -> str:
"""Return ``candidate`` or ``candidate-2``, ``-3`` ... if taken.""" """Return ``candidate`` or ``candidate-2``, ``-3`` ... if taken."""
n = 1 n, slug = 1, candidate
slug = candidate
while conn.execute("SELECT 1 FROM projects WHERE slug = ?", (slug,)).fetchone() is not None: while conn.execute("SELECT 1 FROM projects WHERE slug = ?", (slug,)).fetchone() is not None:
n += 1 n += 1
suffix = f"-{n}" slug = candidate[: 64 - len(f"-{n}")].rstrip("-_") + f"-{n}"
slug = (candidate[: 64 - len(suffix)]).rstrip("-_") + suffix
return slug return slug
@@ -231,9 +215,7 @@ def find_by_primary_path(conn: sqlite3.Connection, path: str, *, include_archive
"""The first (oldest) project whose primary path matches ``path`` (separator/case normalized so """The first (oldest) project whose primary path matches ``path`` (separator/case normalized so
equivalent Windows spellings don't slip past the dedup check), else None.""" equivalent Windows spellings don't slip past the dedup check), else None."""
key = _primary_path_key(path) key = _primary_path_key(path)
if not key: for proj in list_projects(conn, include_archived=include_archived) if key else ():
return None
for proj in list_projects(conn, include_archived=include_archived):
primary = proj.primary_path or next( primary = proj.primary_path or next(
(f.path for f in proj.folders if f.is_primary), proj.folders[0].path if proj.folders else None (f.path for f in proj.folders if f.is_primary), proj.folders[0].path if proj.folders else None
) )
@@ -252,31 +234,21 @@ def create_project(
name = str(name or "").strip() name = str(name or "").strip()
if not name: if not name:
raise ValueError("project name must not be empty") raise ValueError("project name must not be empty")
slug_candidate = normalize_slug(slug) if slug else _slugify(name) slug_candidate = normalize_slug(slug) if slug else _slugify(name)
pid = "p_" + secrets.token_hex(4) pid = "p_" + secrets.token_hex(4)
now = _now() now = _now()
folder_paths = list(dict.fromkeys(p for p in map(_normalize_path, folders or []) if p))
folder_paths: List[str] = []
for f in folders or []:
norm = _normalize_path(f)
if norm and norm not in folder_paths:
folder_paths.append(norm)
primary = _normalize_path(primary_path) if primary_path else None primary = _normalize_path(primary_path) if primary_path else None
if primary and primary not in folder_paths: if primary and primary not in folder_paths:
folder_paths.insert(0, primary) folder_paths.insert(0, primary)
if primary is None and folder_paths: if primary is None and folder_paths:
primary = folder_paths[0] primary = folder_paths[0]
existing = find_by_primary_path(conn, primary) if primary and not allow_duplicate_path else None
if primary and not allow_duplicate_path: if existing is not None:
existing = find_by_primary_path(conn, primary) raise ValueError(
if existing is not None: f"folder already belongs to project '{existing.slug}' ({existing.id}); "
raise ValueError( "switch to it instead of creating a duplicate"
f"folder already belongs to project '{existing.slug}' ({existing.id}); " )
"switch to it instead of creating a duplicate"
)
with write_txn(conn): with write_txn(conn):
conn.execute( conn.execute(
"INSERT INTO projects (id, slug, name, description, icon, color, board_slug, primary_path, created_at, archived) " "INSERT INTO projects (id, slug, name, description, icon, color, board_slug, primary_path, created_at, archived) "
@@ -284,11 +256,10 @@ def create_project(
(pid, _unique_slug(conn, slug_candidate), name, description, icon, color, (pid, _unique_slug(conn, slug_candidate), name, description, icon, color,
normalize_slug(board_slug) if board_slug else None, primary, now), normalize_slug(board_slug) if board_slug else None, primary, now),
) )
for path in folder_paths: conn.executemany(
conn.execute( "INSERT INTO project_folders (project_id, path, label, is_primary, added_at) VALUES (?, ?, ?, ?, ?)",
"INSERT INTO project_folders (project_id, path, label, is_primary, added_at) VALUES (?, ?, ?, ?, ?)", [(pid, path, None, 1 if path == primary else 0, now) for path in folder_paths],
(pid, path, None, 1 if path == primary else 0, now), )
)
return pid return pid
@@ -319,18 +290,16 @@ def update_project(
if board_slug is not None: if board_slug is not None:
board_slug = normalize_slug(board_slug) if board_slug.strip() else "" board_slug = normalize_slug(board_slug) if board_slug.strip() else ""
# (column, provided value, stored value) — "" clears icon/color/board_slug to NULL. # (column, provided value, stored value) — "" clears icon/color/board_slug to NULL.
fields = ( fields = [
("name", name, name), (col, given, stored) for col, given, stored in (
("description", description, description), ("name", name, name), ("description", description, description), ("icon", icon, icon or None),
("icon", icon, icon or None), ("color", color, color or None), ("board_slug", board_slug, board_slug or None),
("color", color, color or None), ) if given is not None
("board_slug", board_slug, board_slug or None), ]
) if not fields:
sets = [f"{col} = ?" for col, given, _ in fields if given is not None]
if not sets:
return False return False
params = [stored for _, given, stored in fields if given is not None] + [project_id] sets = ", ".join(f"{col} = ?" for col, _, _ in fields)
return _execute_rowcount(conn, f"UPDATE projects SET {', '.join(sets)} WHERE id = ?", params) > 0 return _execute_rowcount(conn, f"UPDATE projects SET {sets} WHERE id = ?", [f[2] for f in fields] + [project_id]) > 0
def _execute_rowcount(conn: sqlite3.Connection, sql: str, params) -> int: def _execute_rowcount(conn: sqlite3.Connection, sql: str, params) -> int:
@@ -412,10 +381,6 @@ def delete_project(conn: sqlite3.Connection, project_id: str) -> bool:
# --- Active-project pointer + discovery policy (project_meta KV) -------------- # --- Active-project pointer + discovery policy (project_meta KV) --------------
_ACTIVE_META_KEY = "active_id"
_DISCOVERY_POLICY_META_KEY = "repo_discovery_policy"
def _upsert_meta_locked(conn: sqlite3.Connection, key: str, value: str) -> None: def _upsert_meta_locked(conn: sqlite3.Connection, key: str, value: str) -> None:
"""Upsert a project_meta row (caller already holds a write txn).""" """Upsert a project_meta row (caller already holds a write txn)."""
conn.execute( conn.execute(
@@ -446,30 +411,31 @@ def get_discovery_policy_key(conn: sqlite3.Connection) -> Optional[str]:
return _get_meta(conn, _DISCOVERY_POLICY_META_KEY) return _get_meta(conn, _DISCOVERY_POLICY_META_KEY)
def _clear_repos_locked(conn: sqlite3.Connection, clear: bool, policy_key: Optional[str]) -> None:
"""Optionally wipe the scan cache, then record the policy key when given (caller holds a write txn)."""
if clear:
conn.execute("DELETE FROM discovered_repos")
if policy_key is not None:
_upsert_meta_locked(conn, _DISCOVERY_POLICY_META_KEY, policy_key)
def reconcile_discovered_repos_policy(conn: sqlite3.Connection, policy_key: str, *, preserve_unversioned: bool = False) -> bool: def reconcile_discovered_repos_policy(conn: sqlite3.Connection, policy_key: str, *, preserve_unversioned: bool = False) -> bool:
"""Clear cached scan rows when their discovery policy changes; pre-policy rows are retained only """Clear cached scan rows when their discovery policy changes; pre-policy rows are retained only
for the backward-compatible default policy. Returns whether rows were cleared.""" for the backward-compatible default policy. Returns whether rows were cleared."""
current = get_discovery_policy_key(conn) current = get_discovery_policy_key(conn)
if current == policy_key: if current == policy_key:
return False return False
cleared = current is not None or not preserve_unversioned cleared = current is not None or not preserve_unversioned
with write_txn(conn): with write_txn(conn):
if cleared: _clear_repos_locked(conn, cleared, policy_key)
conn.execute("DELETE FROM discovered_repos")
_upsert_meta_locked(conn, _DISCOVERY_POLICY_META_KEY, policy_key)
return cleared return cleared
def clear_discovered_repos(conn: sqlite3.Connection, *, policy_key: Optional[str] = None) -> None: def clear_discovered_repos(conn: sqlite3.Connection, *, policy_key: Optional[str] = None) -> None:
with write_txn(conn): with write_txn(conn):
conn.execute("DELETE FROM discovered_repos") _clear_repos_locked(conn, True, policy_key)
if policy_key is not None:
_upsert_meta_locked(conn, _DISCOVERY_POLICY_META_KEY, policy_key)
# --- Discovered repos (filesystem scan cache) --------------------------------
def record_discovered_repos( def record_discovered_repos(
conn: sqlite3.Connection, repos: Iterable[tuple[str, Optional[str]]], *, replace: bool = False, conn: sqlite3.Connection, repos: Iterable[tuple[str, Optional[str]]], *, replace: bool = False,
policy_key: Optional[str] = None, policy_key: Optional[str] = None,
@@ -478,12 +444,10 @@ def record_discovered_repos(
return the row count. ``replace`` = authoritative fresh scan: stale rows are deleted first so old return the row count. ``replace`` = authoritative fresh scan: stale rows are deleted first so old
eval/worktree noise doesn't live forever.""" eval/worktree noise doesn't live forever."""
now = _now() now = _now()
rows = [] rows = [
for root, label in repos: (norm, label or os.path.basename(norm) or norm, now)
norm = _normalize_path(root) for norm, label in ((_normalize_path(root), label) for root, label in repos) if norm
if norm: ]
rows.append((norm, (label or os.path.basename(norm) or norm), now))
with write_txn(conn): with write_txn(conn):
if replace: if replace:
conn.execute("DELETE FROM discovered_repos") conn.execute("DELETE FROM discovered_repos")
@@ -493,19 +457,15 @@ def record_discovered_repos(
"ON CONFLICT(root) DO UPDATE SET label = excluded.label, last_seen = excluded.last_seen", "ON CONFLICT(root) DO UPDATE SET label = excluded.label, last_seen = excluded.last_seen",
rows, rows,
) )
if policy_key is not None: _clear_repos_locked(conn, False, policy_key)
_upsert_meta_locked(conn, _DISCOVERY_POLICY_META_KEY, policy_key)
return len(rows) return len(rows)
def list_discovered_repos(conn: sqlite3.Connection) -> List[dict]: def list_discovered_repos(conn: sqlite3.Connection) -> List[dict]:
"""All cached discovered repo roots, most-recently-seen first.""" """All cached discovered repo roots, most-recently-seen first."""
rows = conn.execute("SELECT root, label, last_seen FROM discovered_repos ORDER BY last_seen DESC").fetchall() return [dict(r) for r in conn.execute("SELECT root, label, last_seen FROM discovered_repos ORDER BY last_seen DESC").fetchall()]
return [dict(r) for r in rows]
# --- Resolution + naming -----------------------------------------------------
def project_for_path(conn: sqlite3.Connection, path: str, *, include_archived: bool = False) -> Optional[Project]: def project_for_path(conn: sqlite3.Connection, path: str, *, include_archived: bool = False) -> Optional[Project]:
"""Return the project owning ``path``: a folder owns it when equal or an ancestor, and the longest """Return the project owning ``path``: a folder owns it when equal or an ancestor, and the longest
folder wins so nested projects resolve to the innermost one.""" folder wins so nested projects resolve to the innermost one."""
@@ -515,28 +475,18 @@ def project_for_path(conn: sqlite3.Connection, path: str, *, include_archived: b
sql = "SELECT pf.project_id AS pid, pf.path AS folder FROM project_folders pf JOIN projects p ON p.id = pf.project_id" sql = "SELECT pf.project_id AS pid, pf.path AS folder FROM project_folders pf JOIN projects p ON p.id = pf.project_id"
if not include_archived: if not include_archived:
sql += " WHERE p.archived = 0" sql += " WHERE p.archived = 0"
best_pid: Optional[str] = None
best_len = -1 def owns(folder: str) -> bool:
for row in conn.execute(sql).fetchall():
folder = row["folder"]
stem = folder.rstrip("/\\") stem = folder.rstrip("/\\")
owns = target == folder or target.startswith(stem + os.sep) or target.startswith(stem + "/") return target == folder or target.startswith(stem + os.sep) or target.startswith(stem + "/")
if owns and len(folder) > best_len:
best_len = len(folder)
best_pid = row["pid"]
return None if best_pid is None else get_project(conn, best_pid)
owners = [row for row in conn.execute(sql).fetchall() if owns(row["folder"])]
# Deterministic branch slug: lowercase, separators collapsed, capped. return get_project(conn, max(owners, key=lambda r: len(r["folder"]))["pid"]) if owners else None
_BRANCH_SAFE_RE = re.compile(r"[^a-z0-9._-]+")
def branch_name_for(project: Project, task_id: str, *, title: str = "") -> str: def branch_name_for(project: Project, task_id: str, *, title: str = "") -> str:
"""Deterministic ``<project-slug>/<task-id>[-<title-slug>]`` branch name for a project-linked kanban """Deterministic ``<project-slug>/<task-id>[-<title-slug>]`` branch name for a project-linked kanban
task (stable and human-meaningful, replacing the random ``wt/<task-id>`` fallback).""" task (stable and human-meaningful, replacing the random ``wt/<task-id>`` fallback)."""
base = f"{project.slug or _slugify(project.name)}/{task_id}" base = f"{project.slug or _slugify(project.name)}/{task_id}"
if title: tslug = _BRANCH_SAFE_RE.sub("-", str(title).strip().lower()).strip("-")[:40].strip("-") if title else ""
tslug = _BRANCH_SAFE_RE.sub("-", str(title).strip().lower()).strip("-")[:40].strip("-") return f"{base}-{tslug}" if tslug else base
if tslug:
base = f"{base}-{tslug}"
return base
+22 -39
View File
@@ -51,20 +51,27 @@ def render_sessions_export(sessions: Iterable[Dict[str, Any]], *, fmt: str = "js
rows = iter_user_prompt_records(session_list) if prompts_only else session_list rows = iter_user_prompt_records(session_list) if prompts_only else session_list
lines = [json.dumps(row, ensure_ascii=False) for row in rows] lines = [json.dumps(row, ensure_ascii=False) for row in rows]
return ("\n".join(lines) + "\n") if lines else "" return ("\n".join(lines) + "\n") if lines else ""
# One session → its own H1 with body at H2; several → a shared H1, each session H2/H3.
if prompts_only: if prompts_only:
lines = _render_sessions_markdown( multi_title, append_body = "User prompts export", _append_prompt_records
session_list, "User prompts export", headings = (lambda s: f"User prompts for session {_heading_text(_session_id(s))}",
lambda session: f"User prompts for session {_heading_text(_session_id(session))}", lambda s: f"Session {_heading_text(_session_id(s))}")
lambda session: f"Session {_heading_text(_session_id(session))}", else:
_append_prompt_records, multi_title, append_body = "Hermes sessions export", _append_session_messages
) headings = (lambda s: f"Session: {_heading_text(_session_title_or_id(s))}",) * 2
if not session_list: lines: List[str] = []
lines += ["_No user prompts found._", ""] single = len(session_list) == 1
return _finish_markdown(lines) if not single:
heading = lambda session: f"Session: {_heading_text(_session_title_or_id(session))}" # noqa: E731 lines += [f"# {multi_title}", ""]
return _finish_markdown(_render_sessions_markdown( for session in session_list:
session_list, "Hermes sessions export", heading, heading, _append_session_messages, level = 1 if single else 2
)) lines += [f"{'#' * level} {headings[level - 1](session)}", *_session_metadata_lines(session), ""]
append_body(lines, session, heading_level=level + 1)
if prompts_only and not session_list:
lines += ["_No user prompts found._", ""]
while lines and lines[-1] == "":
lines.pop()
return "\n".join(lines) + "\n"
def export_record_count(sessions: Iterable[Dict[str, Any]], *, only: Optional[str] = None) -> Tuple[int, str]: def export_record_count(sessions: Iterable[Dict[str, Any]], *, only: Optional[str] = None) -> Tuple[int, str]:
@@ -79,11 +86,8 @@ def iter_user_prompt_records(sessions: Iterable[Dict[str, Any]]) -> Iterator[Dic
"""Yield one normalized record for each user-authored prompt.""" """Yield one normalized record for each user-authored prompt."""
for session in sessions: for session in sessions:
session_id = str(session.get("id") or session.get("session_id") or "") session_id = str(session.get("id") or session.get("session_id") or "")
index = 0 prompts = [m for m in _messages(session) if m.get("role") == "user"]
for message in _messages(session): for index, message in enumerate(prompts, start=1):
if message.get("role") != "user":
continue
index += 1
record: Dict[str, Any] = { record: Dict[str, Any] = {
"session_id": session_id, "session_id": session_id,
"index": index, "index": index,
@@ -98,21 +102,6 @@ def iter_user_prompt_records(sessions: Iterable[Dict[str, Any]]) -> Iterator[Dic
yield record yield record
def _render_sessions_markdown(sessions, multi_title, single_heading, multi_heading, append_body) -> List[str]:
"""One session → its own H1 with body at H2; several → a shared H1, each session H2/H3."""
lines: List[str] = []
if len(sessions) == 1:
session = sessions[0]
lines += [f"# {single_heading(session)}", *_session_metadata_lines(session), ""]
append_body(lines, session, heading_level=2)
else:
lines += [f"# {multi_title}", ""]
for session in sessions:
lines += [f"## {multi_heading(session)}", *_session_metadata_lines(session), ""]
append_body(lines, session, heading_level=3)
return lines
def _append_prompt_records(lines: List[str], session: Dict[str, Any], *, heading_level: int) -> None: def _append_prompt_records(lines: List[str], session: Dict[str, Any], *, heading_level: int) -> None:
prompts = list(iter_user_prompt_records([session])) prompts = list(iter_user_prompt_records([session]))
if not prompts: if not prompts:
@@ -213,12 +202,6 @@ def _fenced_text(text: str, *, language: str = "text") -> str:
return f"{fence}{language}\n{text}\n{fence}" return f"{fence}{language}\n{text}\n{fence}"
def _finish_markdown(lines: List[str]) -> str:
while lines and lines[-1] == "":
lines.pop()
return "\n".join(lines) + "\n"
# --- Current-session save helper (shared by CLI /save and gateway /save) --- # --- Current-session save helper (shared by CLI /save and gateway /save) ---
SAVE_FORMATS = ("json", "md", "html") SAVE_FORMATS = ("json", "md", "html")
+19 -23
View File
@@ -642,6 +642,7 @@ HTML_TEMPLATE = """<!DOCTYPE html>
</html> </html>
""" """
def _escape_html(text: Any) -> str: def _escape_html(text: Any) -> str:
return ( return (
str(text).replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;") str(text).replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
@@ -715,34 +716,31 @@ def _generate_messages_html(messages: List[Dict[str, Any]]) -> str:
) )
if content: if content:
escaped = _escape_html(content) escaped = _escape_html(content)
body = f"<pre><code>{escaped}</code></pre>" if role == "tool" else escaped html += f' <div class="content">{f"<pre><code>{escaped}</code></pre>" if role == "tool" else escaped}</div>'
html += f' <div class="content">{body}</div>'
if reasoning := msg.get("reasoning") or msg.get("reasoning_content"): if reasoning := msg.get("reasoning") or msg.get("reasoning_content"):
html += _collapsible( html += _collapsible(
"reasoning", ICON_SPARKLES, "Reasoning", "reasoning", ICON_SPARKLES, "Reasoning", f'<div class="content">{_escape_html(reasoning)}</div>', " " * 12,
f'<div class="content">{_escape_html(reasoning)}</div>', " " * 12,
) )
html_list.append(html + " </div></div>") html_list.append(html + " </div></div>")
return "\n".join(html_list) return "\n".join(html_list)
def _sidebar_html(sessions: List[Dict[str, Any]]) -> str: def _sidebar_item_html(s: Dict[str, Any]) -> str:
items = [] sid = str(s.get("id", "N/A"))
for s in sessions: title = s.get("title") or s.get("preview") or "Untitled Session"
sid = str(s.get("id", "N/A")) title = title[:47] + "..." if len(title) > 50 else title
title = s.get("title") or s.get("preview") or "Untitled Session" return f"""
if len(title) > 50:
title = title[:47] + "..."
date = _format_timestamp(s.get("started_at", 0)).split(" ")[0]
items.append(f"""
<a class="session-item" data-id="{_escape_html(sid)}" href="#{quote(sid, safe='')}"> <a class="session-item" data-id="{_escape_html(sid)}" href="#{quote(sid, safe='')}">
<div class="session-item-title">{_escape_html(title)}</div> <div class="session-item-title">{_escape_html(title)}</div>
<div class="session-item-meta"> <div class="session-item-meta">
<span>{_escape_html(sid[:8])}</span> <span>{_escape_html(sid[:8])}</span>
<span>{date}</span> <span>{_format_timestamp(s.get("started_at", 0)).split(" ")[0]}</span>
</div> </div>
</a> </a>
""") """
def _sidebar_html(sessions: List[Dict[str, Any]]) -> str:
return f""" return f"""
<aside class="sidebar"> <aside class="sidebar">
<div class="sidebar-header"> <div class="sidebar-header">
@@ -755,7 +753,7 @@ def _sidebar_html(sessions: List[Dict[str, Any]]) -> str:
</div> </div>
</div> </div>
<div class="session-list"> <div class="session-list">
{"".join(items)} {"".join(_sidebar_item_html(s) for s in sessions)}
</div> </div>
</aside> </aside>
""" """
@@ -763,13 +761,11 @@ def _sidebar_html(sessions: List[Dict[str, Any]]) -> str:
def _session_view_html(s: Dict[str, Any], is_multi: bool) -> str: def _session_view_html(s: Dict[str, Any], is_multi: bool) -> str:
escaped_sid = _escape_html(str(s.get("id", "N/A"))) escaped_sid = _escape_html(str(s.get("id", "N/A")))
system_html = "" system_html = _collapsible(
if system_prompt := s.get("system_prompt"): "system-prompt", ICON_SHIELD, "System Prompt (Persona)",
system_html = _collapsible( f'<div class="content">{_escape_html(s["system_prompt"])}</div>', " " * 12,
"system-prompt", ICON_SHIELD, "System Prompt (Persona)", outer_class="system-prompt-section active",
f'<div class="content">{_escape_html(system_prompt)}</div>', " " * 12, ) if s.get("system_prompt") else ""
outer_class="system-prompt-section active",
)
return f""" return f"""
<div class="{"session-view" if is_multi else "session-view active"}" id="view-{escaped_sid}"> <div class="{"session-view" if is_multi else "session-view active"}" id="view-{escaped_sid}">
<header class="fade-in"> <header class="fade-in">
+14 -27
View File
@@ -64,23 +64,19 @@ def _message_count(session: dict[str, Any]) -> int:
def _render_messages(session: dict[str, Any]) -> str: def _render_messages(session: dict[str, Any]) -> str:
parts: list[str] = ["## Messages\n"]
segments = _segments(session) segments = _segments(session)
if _message_count(session) == 0: if _message_count(session) == 0:
parts.append("_No messages in this session._\n") return "## Messages\n\n_No messages in this session._\n"
return "\n".join(parts).rstrip() + "\n" parts: list[str] = ["## Messages\n"]
multi_segment = len(segments) > 1
for segment in segments: for segment in segments:
if multi_segment: if len(segments) > 1:
parts.append(f"## Compression segment: {_session_id(segment)}\n") parts.append(f"## Compression segment: {_session_id(segment)}\n")
for message in list(segment.get("messages") or []): for message in list(segment.get("messages") or []):
parts.append(_message_heading(message) + "\n") parts.append(_message_heading(message) + "\n")
content = message.get("content") content = message.get("content")
if content is not None: rendered = "" if content is None else content.rstrip() if isinstance(content, str) else _json_block(content)
rendered = content.rstrip() if isinstance(content, str) else _json_block(content) if rendered:
if rendered: parts.append(rendered + "\n")
parts.append(rendered + "\n")
if tool_calls := message.get("tool_calls"): if tool_calls := message.get("tool_calls"):
parts.append("\n\n## Tool calls\n\n" + _json_block(tool_calls) + "\n") parts.append("\n\n## Tool calls\n\n" + _json_block(tool_calls) + "\n")
parts.append("") parts.append("")
@@ -91,7 +87,6 @@ def _export_body_without_hash(session: dict[str, Any], *, fmt: str, exported_at:
session_id = _session_id(session) session_id = _session_id(session)
exported_iso = _iso_timestamp(exported_at) exported_iso = _iso_timestamp(exported_at)
message_count = _message_count(session) message_count = _message_count(session)
fields = [ fields = [
("session_id", session_id), ("session_id", session_id),
("title", session.get("title")), ("title", session.get("title")),
@@ -105,18 +100,14 @@ def _export_body_without_hash(session: dict[str, Any], *, fmt: str, exported_at:
("archived", bool(session.get("archived"))), ("archived", bool(session.get("archived"))),
("message_count", message_count), ("message_count", message_count),
("tool_call_count", session.get("tool_call_count") or 0), ("tool_call_count", session.get("tool_call_count") or 0),
*([("lineage_session_ids", session["lineage_session_ids"])] if session.get("lineage_session_ids") else []),
("format", fmt), ("exported_at", exported_iso), ("exporter", EXPORTER_VERSION),
] ]
if session.get("lineage_session_ids"): parts = [
fields.append(("lineage_session_ids", session.get("lineage_session_ids"))) "\n".join(["---", *(_frontmatter_line(k, v) for k, v in fields), "---", ""]),
fields += [("format", fmt), ("exported_at", exported_iso), ("exporter", EXPORTER_VERSION)] f"# {session.get('title') or session_id}\n", f"Session ID: `{session_id}`\n",
frontmatter = ["---", *(_frontmatter_line(k, v) for k, v in fields), "---", ""] *([f"Source: `{session.get('source')}`\n"] if session.get("source") else []),
*([f"Working directory: `{session.get('cwd')}`\n"] if session.get("cwd") else []),
parts = ["\n".join(frontmatter), f"# {session.get('title') or session_id}\n", f"Session ID: `{session_id}`\n"]
if session.get("source"):
parts.append(f"Source: `{session.get('source')}`\n")
if session.get("cwd"):
parts.append(f"Working directory: `{session.get('cwd')}`\n")
parts += [
_render_messages(session), _render_messages(session),
f"{_VERIFICATION_HEADING}\n", f"{_VERIFICATION_HEADING}\n",
f"- Session id: `{session_id}`", f"- Session id: `{session_id}`",
@@ -192,11 +183,7 @@ def redact_session_data(session: dict[str, Any]) -> dict[str, Any]:
return {k: _clean(v) for k, v in value.items()} return {k: _clean(v) for k, v in value.items()}
return value return value
redacted = dict(session) return {**session, **{k: _clean(session[k]) for k in ("messages", "segments") if session.get(k) is not None}}
for key in ("messages", "segments"):
if redacted.get(key) is not None:
redacted[key] = _clean(redacted[key])
return redacted
def _export_dir(output_dir: Path | str) -> Path: def _export_dir(output_dir: Path | str) -> Path:
+12 -19
View File
@@ -23,8 +23,6 @@ def parse_duration_seconds(value: str) -> Optional[float]:
"""Parse ``5h`` / ``30m`` / ``2d`` / ``1w`` / ``90`` (bare = days, backward compatible with """Parse ``5h`` / ``30m`` / ``2d`` / ``1w`` / ``90`` (bare = days, backward compatible with
``--older-than 90``) into seconds. Returns None when the value doesn't look like a duration.""" ``--older-than 90``) into seconds. Returns None when the value doesn't look like a duration."""
s = str(value).strip().lower() s = str(value).strip().lower()
if not s:
return None
if re.fullmatch(r"\d+(?:\.\d+)?", s): if re.fullmatch(r"\d+(?:\.\d+)?", s):
return float(s) * 86400 return float(s) * 86400
m = _DURATION_RE.match(s) m = _DURATION_RE.match(s)
@@ -99,11 +97,10 @@ def build_prune_filters(args: Any) -> Dict[str, Any]:
``--older-than`` / ``--newer-than`` bound last activity (latest message timestamp, falling back ``--older-than`` / ``--newer-than`` bound last activity (latest message timestamp, falling back
to ``started_at`` for empty sessions); ``--before`` / ``--after`` bound session start time. to ``started_at`` for empty sessions); ``--before`` / ``--after`` bound session start time.
""" """
bounds: Dict[str, Optional[float]] = {} bounds: Dict[str, Optional[float]] = {
for key, attr, flag, _ in _TIME_BOUNDS: key: None if (raw := getattr(args, attr, None)) is None else parse_point_in_time(raw, flag)
raw = getattr(args, attr, None) for key, attr, flag, _ in _TIME_BOUNDS
bounds[key] = None if raw is None else parse_point_in_time(raw, flag) }
for lo, hi, label, lo_flag, hi_flag in _WINDOWS: for lo, hi, label, lo_flag, hi_flag in _WINDOWS:
if bounds[hi] is not None and bounds[lo] is not None and bounds[lo] >= bounds[hi]: if bounds[hi] is not None and bounds[lo] is not None and bounds[lo] >= bounds[hi]:
raise ValueError( raise ValueError(
@@ -114,20 +111,16 @@ def build_prune_filters(args: Any) -> Dict[str, Any]:
# older_than_days=None: the epoch bounds are the whole story; otherwise prune_sessions' default # older_than_days=None: the epoch bounds are the whole story; otherwise prune_sessions' default
# 90-day cutoff would silently cap an --after/--newer-than-only window. # 90-day cutoff would silently cap an --after/--newer-than-only window.
filters: Dict[str, Any] = {"older_than_days": None, **bounds} return {"older_than_days": None, **bounds, **{key: getattr(args, attr, None) for key, attr, _ in _ARG_FILTERS}}
for key, attr, _ in _ARG_FILTERS:
filters[key] = getattr(args, attr, None)
return filters
def describe_filters(filters: Dict[str, Any]) -> str: def describe_filters(filters: Dict[str, Any]) -> str:
"""Human-readable summary of active filters for confirmation prompts.""" """Human-readable summary of active filters for confirmation prompts."""
parts = [] parts = [
for key, _, _, template in _TIME_BOUNDS: template.format(v=format_epoch(filters[key])) for key, _, _, template in _TIME_BOUNDS
if (value := filters.get(key)) is not None: if filters.get(key) is not None
parts.append(template.format(v=format_epoch(value))) ] + [
for key, _, template in _ARG_FILTERS: template.format(v=filters[key]) for key, _, template in _ARG_FILTERS
value = filters.get(key) if ((filters.get(key) is not None) if key.startswith(("min_", "max_")) else bool(filters.get(key)))
if (value is not None) if key.startswith(("min_", "max_")) else bool(value): ]
parts.append(template.format(v=value))
return ", ".join(parts) if parts else "no filters (all ended sessions)" return ", ".join(parts) if parts else "no filters (all ended sessions)"
+18 -28
View File
@@ -18,24 +18,20 @@ def parse_session_listing_args(raw_args: str) -> tuple[bool, bool, str, str | No
a target so `/sessions <id-or-title>` can delegate to `/resume`. a target so `/sessions <id-or-title>` can delegate to `/resume`.
""" """
parts = shlex.split(raw_args or "") parts = shlex.split(raw_args or "")
include_all = False flags = {"all": False, "full": False}
include_unnamed = False
target_parts: list[str] = [] target_parts: list[str] = []
for i, part in enumerate(parts): for i, part in enumerate(parts):
lower = part.strip().lower() lower = part.strip().lower()
if not target_parts: if not target_parts:
if lower in _LIST_WORDS: if lower in _LIST_WORDS:
continue continue
if lower in {"all", "--all"}: if lower in {"all", "--all", "full", "--full"}:
include_all = True flags[lower.lstrip("-")] = True
continue
if lower in {"full", "--full"}:
include_unnamed = True
continue continue
if lower in _SEARCH_WORDS: if lower in _SEARCH_WORDS:
return include_all, include_unnamed, "", " ".join(parts[i + 1:]).strip() return flags["all"], flags["full"], "", " ".join(parts[i + 1:]).strip()
target_parts.append(part) target_parts.append(part)
return include_all, include_unnamed, " ".join(target_parts).strip(), None return flags["all"], flags["full"], " ".join(target_parts).strip(), None
def query_session_listing( def query_session_listing(
@@ -71,13 +67,11 @@ def query_session_listing(
result: list[dict[str, Any]] = [] result: list[dict[str, Any]] = []
for row in rows: for row in rows:
is_current = bool(current_session_id and row.get("id") == current_session_id) is_current = bool(current_session_id and row.get("id") == current_session_id)
if is_current and not include_current_session: if (is_current and not include_current_session) or (
not include_unnamed and not row.get("title") and not search and not is_current
):
continue continue
if not include_unnamed and not row.get("title") and not search and not is_current: result.append({**row, "is_current_session": True} if is_current else row)
continue
if is_current:
row = {**row, "is_current_session": True}
result.append(row)
if len(result) >= limit: if len(result) >= limit:
break break
return result return result
@@ -96,15 +90,12 @@ def format_gateway_session_listing(
(``all``) was declined, so the caller isn't left guessing why sessions are missing. (``all``) was declined, so the caller isn't left guessing why sessions are missing.
""" """
if not rows: if not rows:
parts = [ return "\n".join([
"No sessions found.\n" "No sessions found.\n"
"Use `/title My Session` to name this chat, or `/sessions full` " "Use `/title My Session` to name this chat, or `/sessions full` "
"to include unnamed sessions." "to include unnamed sessions.",
] *([notice] if notice else []),
if notice: ])
parts.append(notice)
return "\n".join(parts)
lines = [f"📋 **{title}**", ""] lines = [f"📋 **{title}**", ""]
for idx, row in enumerate(rows, start=1): for idx, row in enumerate(rows, start=1):
current_part = " (current)" if row.get("is_current_session") else "" current_part = " (current)" if row.get("is_current_session") else ""
@@ -116,9 +107,8 @@ def format_gateway_session_listing(
f"{idx}. **{row.get('title') or '—'}**{current_part}{source_part}" f"{idx}. **{row.get('title') or '—'}**{current_part}{source_part}"
f" — `{row.get('id') or ''}`{preview_part}" f" — `{row.get('id') or ''}`{preview_part}"
) )
lines.append("") return "\n".join([
if notice: *lines, "", *([notice] if notice else []),
lines.append(notice) "Resume: `/resume <session id>` or `/resume <number>` from `/resume`.",
lines.append("Resume: `/resume <session id>` or `/resume <number>` from `/resume`.") "More: `/sessions all`, `/sessions full`, `/sessions search <query>`.",
lines.append("More: `/sessions all`, `/sessions full`, `/sessions search <query>`.") ])
return "\n".join(lines)