diff --git a/hermes_cli/tips.py b/hermes_cli/tips.py index d62bc35fb8b1..6f365771a1b3 100644 --- a/hermes_cli/tips.py +++ b/hermes_cli/tips.py @@ -433,7 +433,7 @@ TIPS = [ "hermes chat --source tool tags programmatic chats so they don't clutter hermes sessions list.", 'hermes dump --show-keys includes redacted API key fingerprints for deeper support debugging.', 'hermes sessions rename "new title" renames any past session; hermes sessions delete removes one.', - 'hermes import restores a session export or profile archive produced by sessions export or profile export.', + 'hermes import restores a full Hermes backup zip; session JSON/JSONL exports import from the dashboard Sessions page.', 'hermes fallback manages the fallback_model chain interactively — no hand-editing config.yaml.', 'hermes pairing rotates the DM pairing token — the first messager after rotation claims access to the bot.', 'hermes setup walks first-time users through provider, keys, and platform wiring in one interactive flow.', diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index a9a4a0e3f746..e7c18eae8def 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -9569,6 +9569,11 @@ class BulkDeleteSessions(BaseModel): profile: Optional[str] = None +class SessionImport(BaseModel): + sessions: List[Dict[str, Any]] + profile: Optional[str] = None + + @app.post("/api/sessions/bulk-delete") async def bulk_delete_sessions_endpoint(body: BulkDeleteSessions): """Delete every session in ``body.ids`` in a single DB transaction. @@ -9619,6 +9624,27 @@ async def bulk_delete_sessions_endpoint(body: BulkDeleteSessions): db.close() +@app.post("/api/sessions/import") +async def import_sessions_endpoint(body: SessionImport): + """Import one or more sessions exported from the dashboard or CLI. + + This is intentionally separate from ``/api/ops/import``: that endpoint + restores a whole Hermes backup archive, while this endpoint is scoped to + session rows/messages and is safe to use from the Sessions page. + """ + db = _open_session_db_for_profile(body.profile) + try: + result = db.import_sessions(body.sessions) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + finally: + db.close() + + if not result.get("ok", False): + raise HTTPException(status_code=400, detail=result) + return result + + @app.get("/api/sessions/empty/count") async def count_empty_sessions_endpoint(profile: Optional[str] = None): """Return the number of empty, ended, non-archived sessions. diff --git a/hermes_state.py b/hermes_state.py index 80ab6dc10115..0d3e87f735b5 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -5352,6 +5352,241 @@ class SessionDB: results.append({**session, "messages": messages}) return results + @staticmethod + def _json_text_or_none(value: Any) -> Optional[str]: + if value is None: + return None + if isinstance(value, str): + return value + try: + return json.dumps(value) + except (TypeError, ValueError): + return str(value) + + @staticmethod + def _float_or_none(value: Any) -> Optional[float]: + if value is None: + return None + try: + return float(value) + except (TypeError, ValueError): + return None + + @staticmethod + def _int_or_default(value: Any, default: int = 0) -> int: + if value is None: + return default + try: + return int(value) + except (TypeError, ValueError): + return default + + @staticmethod + def _reasoning_json_value(value: Any) -> Any: + if not isinstance(value, str): + return value + try: + return json.loads(value) + except (json.JSONDecodeError, TypeError): + return value + + def import_sessions(self, sessions: List[Dict[str, Any]]) -> Dict[str, Any]: + """Import sessions exported by :meth:`export_session` or ``export_all``. + + Existing session IDs are skipped. Imported child sessions keep their + parent only when that parent already exists or is included in the same + import payload; otherwise the child is detached so partial imports don't + fail foreign-key validation. Gateway routing, handoff, rewind, and other + live runtime state are intentionally reset: this restores conversation + history, not ownership of a live channel or process. + """ + if not isinstance(sessions, list): + raise ValueError("sessions must be a list") + if len(sessions) > 500: + raise ValueError("sessions must contain at most 500 entries") + + normalized: List[Dict[str, Any]] = [] + errors: List[Dict[str, Any]] = [] + seen_ids: set[str] = set() + + for index, raw in enumerate(sessions): + if not isinstance(raw, dict): + errors.append({"index": index, "error": "session must be an object"}) + continue + session_id = str(raw.get("id") or "").strip() + if not session_id: + errors.append({"index": index, "error": "session id is required"}) + continue + if session_id in seen_ids: + errors.append( + {"index": index, "session_id": session_id, "error": "duplicate session id"} + ) + continue + messages = raw.get("messages") or [] + if not isinstance(messages, list): + errors.append( + {"index": index, "session_id": session_id, "error": "messages must be a list"} + ) + continue + if any(not isinstance(msg, dict) for msg in messages): + errors.append( + { + "index": index, + "session_id": session_id, + "error": "messages must contain only objects", + } + ) + continue + seen_ids.add(session_id) + normalized.append({"index": index, "session": raw, "messages": messages}) + + if errors: + return { + "ok": False, + "imported": 0, + "skipped": 0, + "detached": 0, + "errors": errors, + } + + def _do(conn): + imported_ids: List[str] = [] + skipped_ids: List[str] = [] + parent_updates: List[tuple[str, str]] = [] + detached = 0 + + for item in normalized: + raw = item["session"] + messages = item["messages"] + session_id = str(raw.get("id") or "").strip() + exists = conn.execute( + "SELECT 1 FROM sessions WHERE id = ? LIMIT 1", + (session_id,), + ).fetchone() + if exists: + skipped_ids.append(session_id) + continue + + started_at = self._float_or_none(raw.get("started_at")) + if started_at is None: + started_at = time.time() + archived = 1 if raw.get("archived") else 0 + + conn.execute( + """INSERT INTO sessions ( + id, source, user_id, model, model_config, system_prompt, + parent_session_id, started_at, ended_at, end_reason, + message_count, tool_call_count, input_tokens, output_tokens, + cache_read_tokens, cache_write_tokens, reasoning_tokens, + cwd, git_branch, git_repo_root, + billing_provider, billing_base_url, billing_mode, + estimated_cost_usd, actual_cost_usd, cost_status, cost_source, + pricing_version, title, api_call_count, archived + ) + VALUES ( + :id, :source, :user_id, :model, :model_config, + :system_prompt, NULL, :started_at, :ended_at, + :end_reason, 0, 0, :input_tokens, :output_tokens, + :cache_read_tokens, :cache_write_tokens, + :reasoning_tokens, :cwd, :git_branch, :git_repo_root, + :billing_provider, :billing_base_url, :billing_mode, + :estimated_cost_usd, :actual_cost_usd, :cost_status, + :cost_source, :pricing_version, :title, + :api_call_count, :archived + )""", + { + "id": session_id, + "source": str(raw.get("source") or "import"), + "user_id": raw.get("user_id"), + "model": raw.get("model"), + "model_config": self._json_text_or_none(raw.get("model_config")), + "system_prompt": raw.get("system_prompt"), + "started_at": started_at, + "ended_at": self._float_or_none(raw.get("ended_at")), + "end_reason": raw.get("end_reason"), + "input_tokens": self._int_or_default(raw.get("input_tokens")), + "output_tokens": self._int_or_default(raw.get("output_tokens")), + "cache_read_tokens": self._int_or_default( + raw.get("cache_read_tokens") + ), + "cache_write_tokens": self._int_or_default( + raw.get("cache_write_tokens") + ), + "reasoning_tokens": self._int_or_default( + raw.get("reasoning_tokens") + ), + "cwd": raw.get("cwd"), + "git_branch": raw.get("git_branch"), + "git_repo_root": raw.get("git_repo_root"), + "billing_provider": raw.get("billing_provider"), + "billing_base_url": raw.get("billing_base_url"), + "billing_mode": raw.get("billing_mode"), + "estimated_cost_usd": self._float_or_none( + raw.get("estimated_cost_usd") + ), + "actual_cost_usd": self._float_or_none( + raw.get("actual_cost_usd") + ), + "cost_status": raw.get("cost_status"), + "cost_source": raw.get("cost_source"), + "pricing_version": raw.get("pricing_version"), + "title": raw.get("title"), + "api_call_count": self._int_or_default(raw.get("api_call_count")), + "archived": archived, + }, + ) + + sanitized_messages: List[Dict[str, Any]] = [] + for msg in messages: + clean = dict(msg) + for key in ( + "reasoning_details", + "codex_reasoning_items", + "codex_message_items", + ): + clean[key] = self._reasoning_json_value(clean.get(key)) + sanitized_messages.append(clean) + + total_messages, total_tool_calls = self._insert_message_rows( + conn, + session_id, + sanitized_messages, + ) + conn.execute( + "UPDATE sessions SET message_count = ?, tool_call_count = ? WHERE id = ?", + (total_messages, total_tool_calls, session_id), + ) + + parent_id = str(raw.get("parent_session_id") or "").strip() + if parent_id and parent_id != session_id: + parent_updates.append((session_id, parent_id)) + imported_ids.append(session_id) + + for session_id, parent_id in parent_updates: + parent_exists = conn.execute( + "SELECT 1 FROM sessions WHERE id = ? LIMIT 1", + (parent_id,), + ).fetchone() + if parent_exists: + conn.execute( + "UPDATE sessions SET parent_session_id = ? WHERE id = ?", + (parent_id, session_id), + ) + else: + detached += 1 + + return { + "ok": True, + "imported": len(imported_ids), + "skipped": len(skipped_ids), + "detached": detached, + "imported_ids": imported_ids, + "skipped_ids": skipped_ids, + "errors": [], + } + + return self._execute_write(_do) + def clear_messages(self, session_id: str) -> None: """Delete all messages for a session and reset its counters.""" def _do(conn): diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index 16c13450534a..16b1962726ed 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -1189,6 +1189,53 @@ class TestWebServerEndpoints: resp = self.client.patch("/api/sessions/does-not-exist", json={"title": "x"}) assert resp.status_code == 404 + def test_import_sessions_endpoint_imports_exported_json(self): + from hermes_state import SessionDB + + payload = { + "id": "imported-web-session", + "source": "cli", + "title": "Imported from dashboard", + "started_at": 100.0, + "ended_at": 110.0, + "end_reason": "complete", + "messages": [ + {"role": "user", "content": "hello", "timestamp": 101.0}, + {"role": "assistant", "content": "hi", "timestamp": 102.0}, + ], + } + + resp = self.client.post("/api/sessions/import", json={"sessions": [payload]}) + assert resp.status_code == 200 + data = resp.json() + assert data["imported"] == 1 + assert data["skipped"] == 0 + + db = SessionDB() + try: + session = db.get_session("imported-web-session") + assert session["title"] == "Imported from dashboard" + assert session["message_count"] == 2 + assert [m["content"] for m in db.get_messages("imported-web-session")] == [ + "hello", + "hi", + ] + finally: + db.close() + + duplicate = self.client.post("/api/sessions/import", json={"sessions": [payload]}) + assert duplicate.status_code == 200 + assert duplicate.json()["skipped_ids"] == ["imported-web-session"] + + invalid = self.client.post( + "/api/sessions/import", + json={"sessions": [{"source": "cli", "messages": []}]}, + ) + assert invalid.status_code == 400 + assert invalid.json()["detail"]["errors"] == [ + {"index": 0, "error": "session id is required"} + ] + def test_archive_session_via_patch(self): """PATCH archived=true soft-hides a session; archived=false restores it.""" from hermes_state import SessionDB diff --git a/tests/test_hermes_state.py b/tests/test_hermes_state.py index ada5d12c86a7..cbc2286cb6ed 100644 --- a/tests/test_hermes_state.py +++ b/tests/test_hermes_state.py @@ -2168,6 +2168,111 @@ class TestDeleteAndExport: assert len(exports) == 1 assert exports[0]["source"] == "cli" + def test_import_exported_session_round_trips(self, db, tmp_path): + db.create_session( + session_id="s1", + source="cli", + model="test-model", + model_config={"temperature": 0.2}, + user_id="user-1", + cwd="/workspace", + ) + db.set_session_title("s1", "Imported session") + db.update_session_cwd( + "s1", + "/workspace/project", + git_branch="feature/import", + git_repo_root="/workspace/project", + ) + db.append_message("s1", role="user", content="Hello", timestamp=10) + db.append_message( + "s1", + role="assistant", + content="Hi", + timestamp=11, + tool_calls=[{"id": "call-1", "function": {"name": "noop"}}], + reasoning_details=[{"type": "summary", "text": "short"}], + ) + db.end_session("s1", "complete") + + exported = db.export_session("s1") + exported["handoff_state"] = "active" + exported["handoff_platform"] = "telegram" + exported["handoff_error"] = "stale runtime state" + exported["rewind_count"] = 3 + target = SessionDB(db_path=tmp_path / "target_state.db") + try: + result = target.import_sessions([exported]) + assert result["ok"] is True + assert result["imported"] == 1 + assert result["skipped"] == 0 + + imported = target.get_session("s1") + assert imported["title"] == "Imported session" + assert imported["source"] == "cli" + assert imported["model"] == "test-model" + assert imported["cwd"] == "/workspace/project" + assert imported["git_branch"] == "feature/import" + assert imported["git_repo_root"] == "/workspace/project" + assert imported["message_count"] == 2 + assert imported["tool_call_count"] == 1 + assert imported["handoff_state"] is None + assert imported["handoff_platform"] is None + assert imported["handoff_error"] is None + assert imported["rewind_count"] == 0 + + messages = target.get_messages("s1") + assert [m["role"] for m in messages] == ["user", "assistant"] + assert messages[0]["content"] == "Hello" + assert messages[1]["tool_calls"][0]["id"] == "call-1" + + duplicate = target.import_sessions([exported]) + assert duplicate["imported"] == 0 + assert duplicate["skipped"] == 1 + assert duplicate["skipped_ids"] == ["s1"] + finally: + target.close() + + def test_import_sessions_restores_valid_parents_and_detaches_missing(self, db): + result = db.import_sessions( + [ + { + "id": "child", + "source": "cli", + "parent_session_id": "parent", + "messages": [], + }, + {"id": "parent", "source": "cli", "messages": []}, + { + "id": "orphan", + "source": "cli", + "parent_session_id": "missing", + "messages": [], + }, + ] + ) + + assert result["ok"] is True + assert result["imported"] == 3 + assert result["detached"] == 1 + assert db.get_session("child")["parent_session_id"] == "parent" + assert db.get_session("orphan")["parent_session_id"] is None + + def test_import_sessions_rejects_invalid_batch_atomically(self, db): + result = db.import_sessions( + [ + {"id": "valid", "source": "cli", "messages": []}, + {"source": "cli", "messages": []}, + ] + ) + + assert result["ok"] is False + assert result["imported"] == 0 + assert result["errors"] == [ + {"index": 1, "error": "session id is required"} + ] + assert db.get_session("valid") is None + # ========================================================================= # Prune diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 457eb10bb93f..1adcd184e3a3 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -403,6 +403,15 @@ export const api = { fetchJSON(appendProfileParam("/api/sessions/stats", profile)), exportSessionUrl: (id: string, profile = getManagementProfile()) => appendProfileParam(`/api/sessions/${encodeURIComponent(id)}/export`, profile), + importSessions: ( + sessions: Array>, + profile = getManagementProfile(), + ) => + fetchJSON("/api/sessions/import", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sessions, profile: profile || undefined }), + }), pruneSessions: ( older_than_days: number, source?: string, @@ -1298,6 +1307,16 @@ export interface SessionStoreStats { by_source: Record; } +export interface SessionImportResponse { + ok: boolean; + imported: number; + skipped: number; + detached: number; + imported_ids: string[]; + skipped_ids: string[]; + errors: Array>; +} + export interface SkillHubResult { name: string; description: string; diff --git a/web/src/lib/session-import.test.ts b/web/src/lib/session-import.test.ts new file mode 100644 index 000000000000..04f07debd988 --- /dev/null +++ b/web/src/lib/session-import.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; + +import { importSummary, parseImportSessions } from "./session-import"; + +describe("parseImportSessions", () => { + it("accepts a single exported session", () => { + expect(parseImportSessions('{"id":"session-1","messages":[]}')).toEqual([ + { id: "session-1", messages: [] }, + ]); + }); + + it("accepts arrays and wrapped session exports", () => { + const sessions = [{ id: "one" }, { id: "two" }]; + expect(parseImportSessions(JSON.stringify(sessions))).toEqual(sessions); + expect(parseImportSessions(JSON.stringify({ sessions }))).toEqual(sessions); + }); + + it("accepts JSONL session exports", () => { + expect(parseImportSessions('{"id":"one"}\n\n{"id":"two"}\n')).toEqual([ + { id: "one" }, + { id: "two" }, + ]); + }); + + it("rejects empty files and non-object entries", () => { + expect(() => parseImportSessions(" \n")).toThrow("File is empty"); + expect(() => parseImportSessions('[{"id":"one"},42]')).toThrow( + "Expected exported session JSON or JSONL", + ); + }); +}); + +describe("importSummary", () => { + it("includes skipped and detached counts only when present", () => { + expect( + importSummary({ + ok: true, + imported: 2, + skipped: 1, + detached: 1, + imported_ids: ["one", "two"], + skipped_ids: ["existing"], + errors: [], + }), + ).toBe("2 imported; 1 skipped; 1 detached from missing parents"); + }); +}); diff --git a/web/src/lib/session-import.ts b/web/src/lib/session-import.ts new file mode 100644 index 000000000000..49d09fd67c80 --- /dev/null +++ b/web/src/lib/session-import.ts @@ -0,0 +1,46 @@ +import type { SessionImportResponse } from "@/lib/api"; + +export type ImportableSession = Record; + +function normalizeImportSessions(value: unknown): ImportableSession[] { + const candidate = + value && + typeof value === "object" && + !Array.isArray(value) && + Array.isArray((value as { sessions?: unknown }).sessions) + ? (value as { sessions: unknown[] }).sessions + : Array.isArray(value) + ? value + : [value]; + + const sessions = candidate.filter( + (item): item is ImportableSession => + !!item && typeof item === "object" && !Array.isArray(item), + ); + if (sessions.length !== candidate.length) { + throw new Error("Expected exported session JSON or JSONL"); + } + return sessions; +} + +export function parseImportSessions(text: string): ImportableSession[] { + const trimmed = text.trim(); + if (!trimmed) throw new Error("File is empty"); + + try { + return normalizeImportSessions(JSON.parse(trimmed)); + } catch (jsonError) { + const lines = trimmed.split(/\r?\n/).filter((line) => line.trim()); + if (lines.length <= 1) throw jsonError; + return normalizeImportSessions(lines.map((line) => JSON.parse(line))); + } +} + +export function importSummary(result: SessionImportResponse): string { + const parts = [`${result.imported} imported`]; + if (result.skipped > 0) parts.push(`${result.skipped} skipped`); + if (result.detached > 0) { + parts.push(`${result.detached} detached from missing parents`); + } + return parts.join("; "); +} diff --git a/web/src/pages/SessionsPage.tsx b/web/src/pages/SessionsPage.tsx index 1746cc481843..b721821ac32b 100644 --- a/web/src/pages/SessionsPage.tsx +++ b/web/src/pages/SessionsPage.tsx @@ -25,12 +25,17 @@ import { Play, Eraser, Download, + Upload, Pencil, Check, Archive, } from "lucide-react"; import { api } from "@/lib/api"; import { shouldRefreshSessions } from "@/lib/session-refresh"; +import { + importSummary, + parseImportSessions, +} from "@/lib/session-import"; import type { SessionInfo, SessionMessage, @@ -387,7 +392,6 @@ function SessionRow({ resumeInChatEnabled, }: SessionRowProps) { const [messages, setMessages] = useState(null); - const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [renaming, setRenaming] = useState(false); const [renameValue, setRenameValue] = useState(session.title ?? ""); @@ -396,15 +400,20 @@ function SessionRow({ const navigate = useNavigate(); useEffect(() => { - if (isExpanded && messages === null && !loading) { - setLoading(true); - api - .getSessionMessages(session.id) - .then((resp) => setMessages(resp.messages)) - .catch((err) => setError(String(err))) - .finally(() => setLoading(false)); - } - }, [isExpanded, session.id, messages, loading]); + if (!isExpanded || messages !== null) return; + let cancelled = false; + api + .getSessionMessages(session.id) + .then((resp) => { + if (!cancelled) setMessages(resp.messages); + }) + .catch((err) => { + if (!cancelled) setError(String(err)); + }); + return () => { + cancelled = true; + }; + }, [isExpanded, session.id, messages]); const sourceInfo = (session.source ? SOURCE_CONFIG[session.source] @@ -639,7 +648,7 @@ function SessionRow({ {isExpanded && (
- {loading && ( + {messages === null && !error && (
@@ -725,6 +734,7 @@ export default function SessionsPage() { >(null); const [searching, setSearching] = useState(false); const debounceRef = useRef>(null); + const importInputRef = useRef(null); const logScrollRef = useRef(null); const [status, setStatus] = useState(null); const [overviewSessions, setOverviewSessions] = useState([]); @@ -757,6 +767,7 @@ export default function SessionsPage() { const [pruneOpen, setPruneOpen] = useState(false); const [pruneDays, setPruneDays] = useState("90"); const [pruning, setPruning] = useState(false); + const [importingSessions, setImportingSessions] = useState(false); const { toast, showToast } = useToast(); const { t } = useI18n(); const { setAfterTitle, setEnd } = usePageHeader(); @@ -831,6 +842,37 @@ export default function SessionsPage() { .catch(() => {}); }, []); + const handleImportSessions = useCallback( + async (files: FileList | null) => { + const file = files?.[0]; + if (!file) return; + setImportingSessions(true); + try { + const text = await file.text(); + const importedSessions = parseImportSessions(text); + const result = await api.importSessions(importedSessions); + showToast(`Import complete: ${importSummary(result)}`, "success"); + clearSelection(); + loadSessions(page, true); + loadStats(); + refreshEmptyCount(); + } catch (error) { + showToast(`Import failed: ${error}`, "error"); + } finally { + setImportingSessions(false); + if (importInputRef.current) importInputRef.current.value = ""; + } + }, + [ + clearSelection, + loadSessions, + loadStats, + page, + refreshEmptyCount, + showToast, + ], + ); + useEffect(() => { loadStats(); }, [loadStats]); @@ -842,11 +884,21 @@ export default function SessionsPage() { // baseline without triggering a redundant reload (mount already loads). const newestSeenRef = useRef(null); const pageRef = useRef(page); - pageRef.current = page; useEffect(() => { - loadSessions(page); - refreshEmptyCount(); + pageRef.current = page; + }, [page]); + + useEffect(() => { + let cancelled = false; + queueMicrotask(() => { + if (cancelled) return; + loadSessions(page); + refreshEmptyCount(); + }); + return () => { + cancelled = true; + }; }, [loadSessions, page, refreshEmptyCount]); useEffect(() => { @@ -902,6 +954,7 @@ export default function SessionsPage() { const updateSearch = useCallback( (value: string) => { setSearch(value); + if (value.trim()) setView("list"); clearSelection(); }, [clearSelection], @@ -919,13 +972,15 @@ export default function SessionsPage() { if (debounceRef.current) clearTimeout(debounceRef.current); if (!search.trim()) { - setSearchResults(null); - setSearching(false); + debounceRef.current = setTimeout(() => { + setSearchResults(null); + setSearching(false); + }, 0); return; } - setSearching(true); debounceRef.current = setTimeout(() => { + setSearching(true); api .searchSessions(search.trim()) .then((resp) => setSearchResults(resp.results)) @@ -1210,10 +1265,6 @@ export default function SessionsPage() { const showList = view === "list" || isSearching || !showOverviewTab; const showPagination = showList && !searchResults && total > PAGE_SIZE; - useEffect(() => { - if (isSearching) setView("list"); - }, [isSearching]); - const alerts: { message: string; detail?: string }[] = []; if (status) { if (status.gateway_state === "startup_failed") { @@ -1249,6 +1300,13 @@ export default function SessionsPage() {
+ void handleImportSessions(event.currentTarget.files)} + /> )} + + {!isSearching && ( + + )}
{showPagination && (