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:
+29
-53
@@ -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
@@ -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
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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("&", "&").replace("<", "<").replace(">", ">")
|
str(text).replace("&", "&").replace("<", "<").replace(">", ">")
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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,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)
|
|
||||||
|
|||||||
Reference in New Issue
Block a user