acra-cli 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (169) hide show
  1. acra/__init__.py +7 -0
  2. acra/__main__.py +4 -0
  3. acra/agents/__init__.py +0 -0
  4. acra/agents/__pycache__/__init__.cpython-312.pyc +0 -0
  5. acra/agents/__pycache__/llm.cpython-312.pyc +0 -0
  6. acra/agents/__pycache__/llm_utils.cpython-312.pyc +0 -0
  7. acra/agents/coder/__init__.py +0 -0
  8. acra/agents/coder/__pycache__/__init__.cpython-312.pyc +0 -0
  9. acra/agents/coder/__pycache__/coder_agent.cpython-312.pyc +0 -0
  10. acra/agents/coder/__pycache__/coder_chain.cpython-312.pyc +0 -0
  11. acra/agents/coder/coder_agent.py +235 -0
  12. acra/agents/coder/coder_chain.py +204 -0
  13. acra/agents/critic/__init__.py +0 -0
  14. acra/agents/critic/__pycache__/__init__.cpython-312.pyc +0 -0
  15. acra/agents/critic/__pycache__/critic_agent.cpython-312.pyc +0 -0
  16. acra/agents/critic/__pycache__/critic_chain.cpython-312.pyc +0 -0
  17. acra/agents/critic/critic_agent.py +199 -0
  18. acra/agents/critic/critic_chain.py +268 -0
  19. acra/agents/executor/__init__.py +0 -0
  20. acra/agents/executor/__pycache__/__init__.cpython-312.pyc +0 -0
  21. acra/agents/executor/__pycache__/docker_runner.cpython-312.pyc +0 -0
  22. acra/agents/executor/__pycache__/executor_agent.cpython-312.pyc +0 -0
  23. acra/agents/executor/__pycache__/sandbox_runner.cpython-312.pyc +0 -0
  24. acra/agents/executor/docker_runner.py +114 -0
  25. acra/agents/executor/executor_agent.py +142 -0
  26. acra/agents/executor/sandbox_runner.py +144 -0
  27. acra/agents/human/__init__.py +0 -0
  28. acra/agents/human/__pycache__/__init__.cpython-312.pyc +0 -0
  29. acra/agents/human/__pycache__/human_node.cpython-312.pyc +0 -0
  30. acra/agents/human/human_node.py +37 -0
  31. acra/agents/llm.py +263 -0
  32. acra/agents/llm_utils.py +161 -0
  33. acra/agents/memory/__init__.py +0 -0
  34. acra/agents/memory/__pycache__/__init__.cpython-312.pyc +0 -0
  35. acra/agents/memory/__pycache__/memory_agent.cpython-312.pyc +0 -0
  36. acra/agents/memory/__pycache__/memory_manager.cpython-312.pyc +0 -0
  37. acra/agents/memory/__pycache__/retrieval.cpython-312.pyc +0 -0
  38. acra/agents/memory/memory_agent.py +239 -0
  39. acra/agents/memory/memory_manager.py +273 -0
  40. acra/agents/memory/retrieval.py +355 -0
  41. acra/agents/planner/__init__.py +0 -0
  42. acra/agents/planner/__pycache__/__init__.cpython-312.pyc +0 -0
  43. acra/agents/planner/__pycache__/planner_agent.cpython-312.pyc +0 -0
  44. acra/agents/planner/__pycache__/planner_chain.cpython-312.pyc +0 -0
  45. acra/agents/planner/planner_agent.py +97 -0
  46. acra/agents/planner/planner_chain.py +160 -0
  47. acra/agents/researcher/__init__.py +1 -0
  48. acra/agents/researcher/__pycache__/__init__.cpython-312.pyc +0 -0
  49. acra/agents/researcher/__pycache__/researcher_agent.cpython-312.pyc +0 -0
  50. acra/agents/researcher/__pycache__/researcher_chain.cpython-312.pyc +0 -0
  51. acra/agents/researcher/researcher_agent.py +231 -0
  52. acra/agents/researcher/researcher_chain.py +193 -0
  53. acra/brain/__init__.py +6 -0
  54. acra/brain/model_registry.py +19 -0
  55. acra/brain/provider_manager.py +21 -0
  56. acra/cli.py +52 -0
  57. acra/commands/__init__.py +29 -0
  58. acra/commands/ask.py +49 -0
  59. acra/commands/brain.py +58 -0
  60. acra/commands/config.py +25 -0
  61. acra/commands/context.py +30 -0
  62. acra/commands/graph.py +18 -0
  63. acra/commands/keys.py +40 -0
  64. acra/commands/logs.py +13 -0
  65. acra/commands/memory.py +27 -0
  66. acra/commands/plugin.py +18 -0
  67. acra/commands/research.py +65 -0
  68. acra/commands/serve.py +13 -0
  69. acra/commands/session.py +20 -0
  70. acra/commands/utils.py +12 -0
  71. acra/config/__init__.py +36 -0
  72. acra/config/__pycache__/__init__.cpython-312.pyc +0 -0
  73. acra/config/__pycache__/defaults.cpython-312.pyc +0 -0
  74. acra/config/__pycache__/profile_manager.cpython-312.pyc +0 -0
  75. acra/config/defaults.py +88 -0
  76. acra/config/profile_manager.py +87 -0
  77. acra/execution/__init__.py +1 -0
  78. acra/execution/logs/__init__.py +1 -0
  79. acra/execution/logs/error_logs/__init__.py +1 -0
  80. acra/execution/logs/execution_logs/__init__.py +1 -0
  81. acra/execution/sandbox/__init__.py +1 -0
  82. acra/execution/sandbox/docker_executor.py +209 -0
  83. acra/execution/sandbox/isolated_runner.py +148 -0
  84. acra/execution/sandbox/sandbox_manager.py +178 -0
  85. acra/graph/__init__.py +0 -0
  86. acra/graph/checkpoint.py +85 -0
  87. acra/graph/conditional_edges.py +103 -0
  88. acra/graph/edges.py +85 -0
  89. acra/graph/graph_visualizer.py +175 -0
  90. acra/graph/nodes.py +68 -0
  91. acra/graph/router.py +69 -0
  92. acra/graph/state.py +93 -0
  93. acra/graph/workflow.py +117 -0
  94. acra/memory/__init__.py +1 -0
  95. acra/memory/checkpoints/__init__.py +1 -0
  96. acra/memory/checkpoints/data/__init__.py +1 -0
  97. acra/memory/checkpoints/postgres_checkpoint.py +247 -0
  98. acra/memory/checkpoints/sqlite_checkpoint.py +309 -0
  99. acra/memory/chroma_db/__init__.py +1 -0
  100. acra/memory/conversation_memory/__init__.py +1 -0
  101. acra/memory/conversation_memory/long_term.py +229 -0
  102. acra/memory/conversation_memory/short_term.py +158 -0
  103. acra/memory/storage/__init__.py +1 -0
  104. acra/memory/vector_store/__init__.py +0 -0
  105. acra/memory/vector_store/chroma_store.py +260 -0
  106. acra/memory/vector_store/embeddings.py +43 -0
  107. acra/observability/__init__.py +1 -0
  108. acra/observability/langsmith_tracing.py +91 -0
  109. acra/observability/metrics.py +189 -0
  110. acra/observability/monitoring.py +162 -0
  111. acra/observability/token_tracking.py +131 -0
  112. acra/prompts/__init__.py +1 -0
  113. acra/schemas/__init__.py +0 -0
  114. acra/schemas/__pycache__/__init__.cpython-312.pyc +0 -0
  115. acra/schemas/__pycache__/coder_schema.cpython-312.pyc +0 -0
  116. acra/schemas/__pycache__/critic_schema.cpython-312.pyc +0 -0
  117. acra/schemas/__pycache__/execution_schema.cpython-312.pyc +0 -0
  118. acra/schemas/__pycache__/planner_schema.cpython-312.pyc +0 -0
  119. acra/schemas/__pycache__/researcher_schema.cpython-312.pyc +0 -0
  120. acra/schemas/__pycache__/shared_schema.cpython-312.pyc +0 -0
  121. acra/schemas/coder_schema.py +257 -0
  122. acra/schemas/critic_schema.py +177 -0
  123. acra/schemas/execution_schema.py +74 -0
  124. acra/schemas/planner_schema.py +129 -0
  125. acra/schemas/researcher_schema.py +220 -0
  126. acra/schemas/shared_schema.py +329 -0
  127. acra/tools/__init__.py +1 -0
  128. acra/tools/code_tools/__init__.py +1 -0
  129. acra/tools/code_tools/file_reader.py +156 -0
  130. acra/tools/code_tools/file_writer.py +108 -0
  131. acra/tools/code_tools/python_repl.py +72 -0
  132. acra/tools/code_tools/terminal_runner.py +98 -0
  133. acra/tools/github_tools/__init__.py +1 -0
  134. acra/tools/github_tools/commit_generator.py +91 -0
  135. acra/tools/github_tools/github_search.py +113 -0
  136. acra/tools/github_tools/repo_analyzer.py +138 -0
  137. acra/tools/integration_test_helper.py +370 -0
  138. acra/tools/validation_tools/__init__.py +1 -0
  139. acra/tools/validation_tools/dependency_checker.py +101 -0
  140. acra/tools/validation_tools/environment_config_validator.py +353 -0
  141. acra/tools/validation_tools/http_validator.py +336 -0
  142. acra/tools/validation_tools/response_validator.py +407 -0
  143. acra/tools/validation_tools/security_checker.py +93 -0
  144. acra/tools/validation_tools/syntax_checker.py +75 -0
  145. acra/tools/validation_tools/web_framework_validator.py +382 -0
  146. acra/tools/web_tools/__init__.py +1 -0
  147. acra/tools/web_tools/arxiv_search.py +110 -0
  148. acra/tools/web_tools/serpapi_search.py +106 -0
  149. acra/tools/web_tools/tavily_search.py +96 -0
  150. acra/ui/__init__.py +8 -0
  151. acra/ui/__pycache__/__init__.cpython-312.pyc +0 -0
  152. acra/ui/__pycache__/banner.cpython-312.pyc +0 -0
  153. acra/ui/__pycache__/components.cpython-312.pyc +0 -0
  154. acra/ui/__pycache__/shell.cpython-312.pyc +0 -0
  155. acra/ui/__pycache__/theme.cpython-312.pyc +0 -0
  156. acra/ui/banner.py +40 -0
  157. acra/ui/components.py +32 -0
  158. acra/ui/shell.py +91 -0
  159. acra/ui/theme.py +28 -0
  160. acra/utils/__init__.py +21 -0
  161. acra/utils/context_manager.py +20 -0
  162. acra/utils/keyring_manager.py +83 -0
  163. acra/utils/output_formatter.py +18 -0
  164. acra/utils/session_manager.py +59 -0
  165. acra_cli-0.1.1.dist-info/METADATA +616 -0
  166. acra_cli-0.1.1.dist-info/RECORD +169 -0
  167. acra_cli-0.1.1.dist-info/WHEEL +5 -0
  168. acra_cli-0.1.1.dist-info/licenses/LICENSE +661 -0
  169. acra_cli-0.1.1.dist-info/top_level.txt +1 -0
acra/ui/banner.py ADDED
@@ -0,0 +1,40 @@
1
+ """Welcome banner and UI panels for acra."""
2
+
3
+ from rich.panel import Panel
4
+ from rich.console import Console
5
+
6
+ MASCOT = r"""
7
+ ____ ____ ____
8
+ / __ \ / __ \/ __ \
9
+ | | | | | | | | | |
10
+ | | | | | | | | | |
11
+ | |__| | |__| | |__| |
12
+ \____/ \____/ \____/
13
+ """
14
+
15
+ WELCOME_TEXT = "acra: agentic CLI for LLM task workflows"
16
+
17
+
18
+ def render_banner(provider: str, model: str, profile: str, workspace: str, theme: dict) -> Panel:
19
+ console = Console()
20
+ lines = [
21
+ f"[bold {theme['accent']}]acra v0.1.0[/bold {theme['accent']}]",
22
+ f"Provider: {provider} · Model: {model}",
23
+ f"Profile: {profile} · Workspace: {workspace}",
24
+ ]
25
+ content = "\n".join(lines)
26
+ return Panel(content, title=WELCOME_TEXT, subtitle=MASCOT, style=theme["border"], border_style=theme["border"])
27
+
28
+
29
+ def render_tips(theme: dict) -> Panel:
30
+ tips = (
31
+ "• Run acra init to set up your workspace\n"
32
+ "• Run acra brain models to list available models\n"
33
+ "• Use / to open commands palette\n"
34
+ "• Use @ to pick files for context"
35
+ )
36
+ return Panel(tips, title="Getting Started", style=theme["border"], border_style=theme["accent"])
37
+
38
+
39
+ # Compatibility alias for older imports and public package API.
40
+ welcome_banner = render_banner
acra/ui/components.py ADDED
@@ -0,0 +1,32 @@
1
+ """Shared UI render components for acra."""
2
+
3
+ from rich.console import Console
4
+ from rich.panel import Panel
5
+ from rich.table import Table
6
+ from typing import Any
7
+
8
+ console = Console()
9
+
10
+
11
+ def render_panel(content: Any, title: str = "acra") -> None:
12
+ if isinstance(content, list):
13
+ table = Table(show_header=True, header_style="bold magenta")
14
+ table.add_column("Name")
15
+ table.add_column("Status")
16
+ table.add_column("Value")
17
+ for item in content:
18
+ if isinstance(item, dict):
19
+ table.add_row(
20
+ str(item.get("name", "")),
21
+ str(item.get("status", "")),
22
+ str(item.get("value", "")),
23
+ )
24
+ else:
25
+ table.add_row(str(item), "", "")
26
+ console.print(Panel(table, title=title))
27
+ else:
28
+ console.print(Panel(str(content), title=title))
29
+
30
+
31
+ def render_message(message: str) -> None:
32
+ console.print(message)
acra/ui/shell.py ADDED
@@ -0,0 +1,91 @@
1
+ """Interactive shell implementation for acra."""
2
+
3
+ import os
4
+ import shlex
5
+ import subprocess
6
+ import sys
7
+
8
+ from prompt_toolkit import PromptSession
9
+ from prompt_toolkit.completion import Completer, Completion
10
+ from prompt_toolkit.shortcuts import CompleteStyle
11
+ from acra.ui.banner import render_banner, render_tips
12
+ from acra.ui.theme import get_theme
13
+ from acra.config.profile_manager import ProfileManager
14
+ from rich.console import Console
15
+ from rich.panel import Panel
16
+
17
+ console = Console()
18
+
19
+
20
+ def _slash_completions():
21
+ commands = ["serve", "init", "config show", "keys list", "research", "memory list", "session list", "graph show", "brain models"]
22
+ for command in commands:
23
+ yield Completion(command, start_position=0)
24
+
25
+
26
+ class SlashCompleter(Completer):
27
+ def get_completions(self, document, complete_event):
28
+ text = document.text_before_cursor
29
+ if text.startswith("/"):
30
+ for completion in _slash_completions():
31
+ yield completion
32
+
33
+
34
+ def launch_shell(profile: str = None, workspace: str = None):
35
+ config = ProfileManager().load_profile(profile)
36
+ theme = get_theme(config.get("theme", "dark-orange"))
37
+ banner = render_banner(
38
+ config.get("provider", "gemini"),
39
+ config.get("model", "gemini-2.5-flash"),
40
+ profile or "default",
41
+ workspace or config.get("workspace", "."),
42
+ theme,
43
+ )
44
+ console.print(banner)
45
+ console.print(render_tips(theme))
46
+ session = PromptSession(
47
+ "> "
48
+ )
49
+ while True:
50
+ try:
51
+ user_input = session.prompt(
52
+ "> ",
53
+ completer=SlashCompleter(),
54
+ complete_style=CompleteStyle.COLUMN,
55
+ )
56
+ if user_input in ("exit", "quit", "q", "Ctrl+C"):
57
+ break
58
+ if not user_input.strip():
59
+ continue
60
+
61
+ command_text = user_input.strip()
62
+ if command_text.startswith("acra "):
63
+ command_text = command_text[len("acra "):]
64
+
65
+ if command_text in ("exit", "quit", "q"):
66
+ break
67
+
68
+ console.print(f"[dim]→[/dim] {user_input}")
69
+
70
+ cmd = [sys.executable, "-m", "acra"] + shlex.split(command_text)
71
+ completed = subprocess.run(
72
+ cmd,
73
+ cwd=os.getcwd(),
74
+ stdin=sys.stdin,
75
+ stdout=subprocess.PIPE,
76
+ stderr=subprocess.STDOUT,
77
+ text=True,
78
+ )
79
+ if completed.stdout:
80
+ console.print(completed.stdout, soft_wrap=True)
81
+ if completed.returncode != 0:
82
+ if completed.stdout and "Missing command" in completed.stdout:
83
+ console.print("[dim]Tip:[/dim] try a subcommand such as 'brain models' or 'config init'.")
84
+ else:
85
+ console.print(f"[bold red]Command failed with exit code {completed.returncode}.[/bold red]")
86
+ except KeyboardInterrupt:
87
+ break
88
+ except EOFError:
89
+ break
90
+
91
+ console.print(Panel("Goodbye", title="acra"))
acra/ui/theme.py ADDED
@@ -0,0 +1,28 @@
1
+ """acra theme registry."""
2
+
3
+ from typing import Dict
4
+
5
+ THEMES: Dict[str, Dict[str, str]] = {
6
+ "dark-orange": {
7
+ "accent": "bright_yellow",
8
+ "border": "dark_orange",
9
+ "text": "white",
10
+ "info": "bright_blue",
11
+ },
12
+ "dark-blue": {
13
+ "accent": "bright_cyan",
14
+ "border": "blue",
15
+ "text": "white",
16
+ "info": "bright_white",
17
+ },
18
+ "dark-green": {
19
+ "accent": "green",
20
+ "border": "bright_green",
21
+ "text": "white",
22
+ "info": "bright_white",
23
+ },
24
+ }
25
+
26
+
27
+ def get_theme(name: str) -> Dict[str, str]:
28
+ return THEMES.get(name, THEMES["dark-orange"])
acra/utils/__init__.py ADDED
@@ -0,0 +1,21 @@
1
+ """Utility helpers for acra."""
2
+
3
+ from .keyring_manager import (
4
+ delete_key,
5
+ get_key,
6
+ get_key_status,
7
+ get_research_key_status,
8
+ list_keys,
9
+ set_key,
10
+ )
11
+ from .output_formatter import format_research_report
12
+
13
+ __all__ = [
14
+ "delete_key",
15
+ "get_key",
16
+ "get_key_status",
17
+ "get_research_key_status",
18
+ "list_keys",
19
+ "set_key",
20
+ "format_research_report",
21
+ ]
@@ -0,0 +1,20 @@
1
+ """Context management utilities for acra."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import List
6
+
7
+
8
+ class ContextManager:
9
+ """Simple context storage for acra CLI commands."""
10
+
11
+ _context_items: List[str] = []
12
+
13
+ def add(self, item: str) -> None:
14
+ self._context_items.append(item)
15
+
16
+ def list(self) -> List[str]:
17
+ return list(self._context_items)
18
+
19
+ def clear(self) -> None:
20
+ self._context_items.clear()
@@ -0,0 +1,83 @@
1
+ """Key management using OS keyring for acra."""
2
+
3
+ import os
4
+ from typing import Dict, List, Optional
5
+
6
+ try:
7
+ import keyring
8
+ except ImportError: # pragma: no cover
9
+ keyring = None
10
+
11
+ SERVICE_MAIN = "acra"
12
+ SERVICE_RESEARCH = "acra-research"
13
+ KNOWN_RESEARCH_SOURCES = ["web", "github", "docs", "arxiv"]
14
+
15
+
16
+ def _service_and_username(name: str) -> (str, str):
17
+ if name.startswith("research."):
18
+ return SERVICE_RESEARCH, name.split("research.", 1)[1]
19
+ return SERVICE_MAIN, name
20
+
21
+
22
+ def _mask_value(value: Optional[str]) -> str:
23
+ if not value:
24
+ return "not set"
25
+ if len(value) <= 10:
26
+ return value
27
+ return f"{value[:6]}...{value[-4:]}"
28
+
29
+
30
+ def _env_key(name: str) -> Optional[str]:
31
+ if name.startswith("research."):
32
+ source = name.split("research.", 1)[1].upper()
33
+ return os.getenv(f"ACRA_RESEARCH_{source}_KEY")
34
+ if name == "provider":
35
+ return os.getenv("ACRA_PROVIDER_KEY")
36
+ return os.getenv(name.upper())
37
+
38
+
39
+ def set_key(name: str, value: str) -> None:
40
+ if keyring is None:
41
+ raise RuntimeError("keyring is required for storing secrets")
42
+ service, username = _service_and_username(name)
43
+ keyring.set_password(service, username, value)
44
+
45
+
46
+ def get_key(name: str) -> Optional[str]:
47
+ if keyring is not None:
48
+ service, username = _service_and_username(name)
49
+ value = keyring.get_password(service, username)
50
+ if value:
51
+ return value
52
+ return _env_key(name)
53
+
54
+
55
+ def list_keys() -> List[Dict[str, str]]:
56
+ statuses = []
57
+ names = ["provider"] + [f"research.{source}" for source in KNOWN_RESEARCH_SOURCES]
58
+ for name in names:
59
+ value = get_key(name)
60
+ status = "configured" if value else "not set"
61
+ statuses.append({"name": name, "status": status, "value": _mask_value(value)})
62
+ return statuses
63
+
64
+
65
+ def delete_key(name: str) -> None:
66
+ if keyring is None:
67
+ raise RuntimeError("keyring is required for deleting secrets")
68
+ service, username = _service_and_username(name)
69
+ keyring.delete_password(service, username)
70
+
71
+
72
+ def get_key_status(name: str) -> Dict[str, str]:
73
+ value = get_key(name)
74
+ return {
75
+ "name": name,
76
+ "configured": bool(value),
77
+ "masked": _mask_value(value),
78
+ }
79
+
80
+
81
+ def get_research_key_status(name: str) -> Dict[str, str]:
82
+ """Alias for research-specific key status lookup."""
83
+ return get_key_status(name)
@@ -0,0 +1,18 @@
1
+ """Output formatting utilities for acra."""
2
+
3
+ from typing import Any, Dict
4
+
5
+
6
+ def format_research_report(result: Dict[str, Any], output_format: str = "citations") -> Dict[str, Any]:
7
+ report = {
8
+ "query": result.get("user_request"),
9
+ "summary": result.get("research_summary", ""),
10
+ "findings": result.get("research_data", []),
11
+ "sources": result.get("research_sources", []),
12
+ "status": result.get("research_status", "unknown"),
13
+ }
14
+ if output_format == "summary":
15
+ report = {"query": report["query"], "summary": report["summary"]}
16
+ elif output_format == "detailed":
17
+ report["details"] = result
18
+ return report
@@ -0,0 +1,59 @@
1
+ """Session persistence for acra."""
2
+
3
+ import json
4
+ import os
5
+ import uuid
6
+ from datetime import datetime
7
+ from pathlib import Path
8
+ from typing import Any, Dict, List, Optional
9
+
10
+ SESSION_DIR = Path.home() / ".acra" / "sessions"
11
+ SESSION_DIR.mkdir(parents=True, exist_ok=True)
12
+
13
+
14
+ def _session_path(session_id: str) -> Path:
15
+ return SESSION_DIR / f"{session_id}.json"
16
+
17
+
18
+ def create_session(data: Dict[str, Any], name: Optional[str] = None) -> str:
19
+ session_id = uuid.uuid4().hex[:8]
20
+ now = datetime.utcnow().isoformat() + "Z"
21
+ payload = {
22
+ "id": session_id,
23
+ "name": name or f"session-{session_id}",
24
+ "created_at": now,
25
+ "updated_at": now,
26
+ "data": data,
27
+ }
28
+ with open(_session_path(session_id), "w", encoding="utf-8") as f:
29
+ json.dump(payload, f, indent=2)
30
+ return session_id
31
+
32
+
33
+ def list_sessions() -> List[Dict[str, Any]]:
34
+ sessions = []
35
+ for path in sorted(SESSION_DIR.glob("*.json")):
36
+ try:
37
+ with open(path, "r", encoding="utf-8") as f:
38
+ sessions.append(json.load(f))
39
+ except Exception:
40
+ continue
41
+ return sessions
42
+
43
+
44
+ def load_session(session_id: str) -> Optional[Dict[str, Any]]:
45
+ path = _session_path(session_id)
46
+ if not path.exists():
47
+ return None
48
+ with open(path, "r", encoding="utf-8") as f:
49
+ return json.load(f)
50
+
51
+
52
+ def save_session(session_id: str, data: Dict[str, Any]) -> None:
53
+ payload = load_session(session_id)
54
+ if not payload:
55
+ raise FileNotFoundError(f"Session not found: {session_id}")
56
+ payload["data"] = data
57
+ payload["updated_at"] = datetime.utcnow().isoformat() + "Z"
58
+ with open(_session_path(session_id), "w", encoding="utf-8") as f:
59
+ json.dump(payload, f, indent=2)