tunacode-cli 0.1.21__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.

Potentially problematic release.


This version of tunacode-cli might be problematic. Click here for more details.

Files changed (174) hide show
  1. tunacode/__init__.py +0 -0
  2. tunacode/cli/textual_repl.tcss +283 -0
  3. tunacode/configuration/__init__.py +1 -0
  4. tunacode/configuration/defaults.py +45 -0
  5. tunacode/configuration/models.py +147 -0
  6. tunacode/configuration/models_registry.json +1 -0
  7. tunacode/configuration/pricing.py +74 -0
  8. tunacode/configuration/settings.py +35 -0
  9. tunacode/constants.py +227 -0
  10. tunacode/core/__init__.py +6 -0
  11. tunacode/core/agents/__init__.py +39 -0
  12. tunacode/core/agents/agent_components/__init__.py +48 -0
  13. tunacode/core/agents/agent_components/agent_config.py +441 -0
  14. tunacode/core/agents/agent_components/agent_helpers.py +290 -0
  15. tunacode/core/agents/agent_components/message_handler.py +99 -0
  16. tunacode/core/agents/agent_components/node_processor.py +477 -0
  17. tunacode/core/agents/agent_components/response_state.py +129 -0
  18. tunacode/core/agents/agent_components/result_wrapper.py +51 -0
  19. tunacode/core/agents/agent_components/state_transition.py +112 -0
  20. tunacode/core/agents/agent_components/streaming.py +271 -0
  21. tunacode/core/agents/agent_components/task_completion.py +40 -0
  22. tunacode/core/agents/agent_components/tool_buffer.py +44 -0
  23. tunacode/core/agents/agent_components/tool_executor.py +101 -0
  24. tunacode/core/agents/agent_components/truncation_checker.py +37 -0
  25. tunacode/core/agents/delegation_tools.py +109 -0
  26. tunacode/core/agents/main.py +545 -0
  27. tunacode/core/agents/prompts.py +66 -0
  28. tunacode/core/agents/research_agent.py +231 -0
  29. tunacode/core/compaction.py +218 -0
  30. tunacode/core/prompting/__init__.py +27 -0
  31. tunacode/core/prompting/loader.py +66 -0
  32. tunacode/core/prompting/prompting_engine.py +98 -0
  33. tunacode/core/prompting/sections.py +50 -0
  34. tunacode/core/prompting/templates.py +69 -0
  35. tunacode/core/state.py +409 -0
  36. tunacode/exceptions.py +313 -0
  37. tunacode/indexing/__init__.py +5 -0
  38. tunacode/indexing/code_index.py +432 -0
  39. tunacode/indexing/constants.py +86 -0
  40. tunacode/lsp/__init__.py +112 -0
  41. tunacode/lsp/client.py +351 -0
  42. tunacode/lsp/diagnostics.py +19 -0
  43. tunacode/lsp/servers.py +101 -0
  44. tunacode/prompts/default_prompt.md +952 -0
  45. tunacode/prompts/research/sections/agent_role.xml +5 -0
  46. tunacode/prompts/research/sections/constraints.xml +14 -0
  47. tunacode/prompts/research/sections/output_format.xml +57 -0
  48. tunacode/prompts/research/sections/tool_use.xml +23 -0
  49. tunacode/prompts/sections/advanced_patterns.xml +255 -0
  50. tunacode/prompts/sections/agent_role.xml +8 -0
  51. tunacode/prompts/sections/completion.xml +10 -0
  52. tunacode/prompts/sections/critical_rules.xml +37 -0
  53. tunacode/prompts/sections/examples.xml +220 -0
  54. tunacode/prompts/sections/output_style.xml +94 -0
  55. tunacode/prompts/sections/parallel_exec.xml +105 -0
  56. tunacode/prompts/sections/search_pattern.xml +100 -0
  57. tunacode/prompts/sections/system_info.xml +6 -0
  58. tunacode/prompts/sections/tool_use.xml +84 -0
  59. tunacode/prompts/sections/user_instructions.xml +3 -0
  60. tunacode/py.typed +0 -0
  61. tunacode/templates/__init__.py +5 -0
  62. tunacode/templates/loader.py +15 -0
  63. tunacode/tools/__init__.py +10 -0
  64. tunacode/tools/authorization/__init__.py +29 -0
  65. tunacode/tools/authorization/context.py +32 -0
  66. tunacode/tools/authorization/factory.py +20 -0
  67. tunacode/tools/authorization/handler.py +58 -0
  68. tunacode/tools/authorization/notifier.py +35 -0
  69. tunacode/tools/authorization/policy.py +19 -0
  70. tunacode/tools/authorization/requests.py +119 -0
  71. tunacode/tools/authorization/rules.py +72 -0
  72. tunacode/tools/bash.py +222 -0
  73. tunacode/tools/decorators.py +213 -0
  74. tunacode/tools/glob.py +353 -0
  75. tunacode/tools/grep.py +468 -0
  76. tunacode/tools/grep_components/__init__.py +9 -0
  77. tunacode/tools/grep_components/file_filter.py +93 -0
  78. tunacode/tools/grep_components/pattern_matcher.py +158 -0
  79. tunacode/tools/grep_components/result_formatter.py +87 -0
  80. tunacode/tools/grep_components/search_result.py +34 -0
  81. tunacode/tools/list_dir.py +205 -0
  82. tunacode/tools/prompts/bash_prompt.xml +10 -0
  83. tunacode/tools/prompts/glob_prompt.xml +7 -0
  84. tunacode/tools/prompts/grep_prompt.xml +10 -0
  85. tunacode/tools/prompts/list_dir_prompt.xml +7 -0
  86. tunacode/tools/prompts/read_file_prompt.xml +9 -0
  87. tunacode/tools/prompts/todoclear_prompt.xml +12 -0
  88. tunacode/tools/prompts/todoread_prompt.xml +16 -0
  89. tunacode/tools/prompts/todowrite_prompt.xml +28 -0
  90. tunacode/tools/prompts/update_file_prompt.xml +9 -0
  91. tunacode/tools/prompts/web_fetch_prompt.xml +11 -0
  92. tunacode/tools/prompts/write_file_prompt.xml +7 -0
  93. tunacode/tools/react.py +111 -0
  94. tunacode/tools/read_file.py +68 -0
  95. tunacode/tools/todo.py +222 -0
  96. tunacode/tools/update_file.py +62 -0
  97. tunacode/tools/utils/__init__.py +1 -0
  98. tunacode/tools/utils/ripgrep.py +311 -0
  99. tunacode/tools/utils/text_match.py +352 -0
  100. tunacode/tools/web_fetch.py +245 -0
  101. tunacode/tools/write_file.py +34 -0
  102. tunacode/tools/xml_helper.py +34 -0
  103. tunacode/types/__init__.py +166 -0
  104. tunacode/types/base.py +94 -0
  105. tunacode/types/callbacks.py +53 -0
  106. tunacode/types/dataclasses.py +121 -0
  107. tunacode/types/pydantic_ai.py +31 -0
  108. tunacode/types/state.py +122 -0
  109. tunacode/ui/__init__.py +6 -0
  110. tunacode/ui/app.py +542 -0
  111. tunacode/ui/commands/__init__.py +430 -0
  112. tunacode/ui/components/__init__.py +1 -0
  113. tunacode/ui/headless/__init__.py +5 -0
  114. tunacode/ui/headless/output.py +72 -0
  115. tunacode/ui/main.py +252 -0
  116. tunacode/ui/renderers/__init__.py +41 -0
  117. tunacode/ui/renderers/errors.py +197 -0
  118. tunacode/ui/renderers/panels.py +550 -0
  119. tunacode/ui/renderers/search.py +314 -0
  120. tunacode/ui/renderers/tools/__init__.py +21 -0
  121. tunacode/ui/renderers/tools/bash.py +247 -0
  122. tunacode/ui/renderers/tools/diagnostics.py +186 -0
  123. tunacode/ui/renderers/tools/glob.py +226 -0
  124. tunacode/ui/renderers/tools/grep.py +228 -0
  125. tunacode/ui/renderers/tools/list_dir.py +198 -0
  126. tunacode/ui/renderers/tools/read_file.py +226 -0
  127. tunacode/ui/renderers/tools/research.py +294 -0
  128. tunacode/ui/renderers/tools/update_file.py +237 -0
  129. tunacode/ui/renderers/tools/web_fetch.py +182 -0
  130. tunacode/ui/repl_support.py +226 -0
  131. tunacode/ui/screens/__init__.py +16 -0
  132. tunacode/ui/screens/model_picker.py +303 -0
  133. tunacode/ui/screens/session_picker.py +181 -0
  134. tunacode/ui/screens/setup.py +218 -0
  135. tunacode/ui/screens/theme_picker.py +90 -0
  136. tunacode/ui/screens/update_confirm.py +69 -0
  137. tunacode/ui/shell_runner.py +129 -0
  138. tunacode/ui/styles/layout.tcss +98 -0
  139. tunacode/ui/styles/modals.tcss +38 -0
  140. tunacode/ui/styles/panels.tcss +81 -0
  141. tunacode/ui/styles/theme-nextstep.tcss +303 -0
  142. tunacode/ui/styles/widgets.tcss +33 -0
  143. tunacode/ui/styles.py +18 -0
  144. tunacode/ui/widgets/__init__.py +23 -0
  145. tunacode/ui/widgets/command_autocomplete.py +62 -0
  146. tunacode/ui/widgets/editor.py +402 -0
  147. tunacode/ui/widgets/file_autocomplete.py +47 -0
  148. tunacode/ui/widgets/messages.py +46 -0
  149. tunacode/ui/widgets/resource_bar.py +182 -0
  150. tunacode/ui/widgets/status_bar.py +98 -0
  151. tunacode/utils/__init__.py +0 -0
  152. tunacode/utils/config/__init__.py +13 -0
  153. tunacode/utils/config/user_configuration.py +91 -0
  154. tunacode/utils/messaging/__init__.py +10 -0
  155. tunacode/utils/messaging/message_utils.py +34 -0
  156. tunacode/utils/messaging/token_counter.py +77 -0
  157. tunacode/utils/parsing/__init__.py +13 -0
  158. tunacode/utils/parsing/command_parser.py +55 -0
  159. tunacode/utils/parsing/json_utils.py +188 -0
  160. tunacode/utils/parsing/retry.py +146 -0
  161. tunacode/utils/parsing/tool_parser.py +267 -0
  162. tunacode/utils/security/__init__.py +15 -0
  163. tunacode/utils/security/command.py +106 -0
  164. tunacode/utils/system/__init__.py +25 -0
  165. tunacode/utils/system/gitignore.py +155 -0
  166. tunacode/utils/system/paths.py +190 -0
  167. tunacode/utils/ui/__init__.py +9 -0
  168. tunacode/utils/ui/file_filter.py +135 -0
  169. tunacode/utils/ui/helpers.py +24 -0
  170. tunacode_cli-0.1.21.dist-info/METADATA +170 -0
  171. tunacode_cli-0.1.21.dist-info/RECORD +174 -0
  172. tunacode_cli-0.1.21.dist-info/WHEEL +4 -0
  173. tunacode_cli-0.1.21.dist-info/entry_points.txt +2 -0
  174. tunacode_cli-0.1.21.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,98 @@
1
+ """Prompting engine for resolving {{placeholder}} syntax in prompts."""
2
+
3
+ import os
4
+ import platform
5
+ import re
6
+ from collections.abc import Callable
7
+ from datetime import datetime
8
+
9
+
10
+ class PromptingEngine:
11
+ """Resolves {{placeholder}} syntax in prompt templates.
12
+
13
+ Built-in placeholders:
14
+ - {{CWD}}: Current working directory
15
+ - {{OS}}: Operating system name
16
+ - {{DATE}}: Current date in ISO format
17
+
18
+ Unknown placeholders are left unchanged.
19
+ """
20
+
21
+ PLACEHOLDER_PATTERN = re.compile(r"\{\{(.+?)\}\}")
22
+
23
+ def __init__(self) -> None:
24
+ self._providers: dict[str, Callable[[], str]] = {}
25
+ self._register_builtins()
26
+
27
+ def _register_builtins(self) -> None:
28
+ """Register built-in placeholder providers."""
29
+ self._providers["CWD"] = os.getcwd
30
+ self._providers["OS"] = platform.system
31
+ self._providers["DATE"] = lambda: datetime.now().isoformat()
32
+
33
+ def register(self, name: str, provider: Callable[[], str]) -> None:
34
+ """Register a custom placeholder provider.
35
+
36
+ Args:
37
+ name: Placeholder name (without braces)
38
+ provider: Callable that returns the replacement string
39
+ """
40
+ self._providers[name] = provider
41
+
42
+ def resolve(self, template: str) -> str:
43
+ """Resolve all placeholders in the template.
44
+
45
+ Args:
46
+ template: String containing {{placeholder}} syntax
47
+
48
+ Returns:
49
+ Template with resolved placeholders. Unknown placeholders
50
+ are left unchanged.
51
+ """
52
+ if not template:
53
+ return template
54
+
55
+ def replace_match(match: re.Match[str]) -> str:
56
+ name = match.group(1).strip()
57
+ provider = self._providers.get(name)
58
+ if provider is None:
59
+ return match.group(0)
60
+ return provider()
61
+
62
+ return self.PLACEHOLDER_PATTERN.sub(replace_match, template)
63
+
64
+
65
+ # Module-level singleton for convenience
66
+ _engine: PromptingEngine | None = None
67
+
68
+
69
+ def get_prompting_engine() -> PromptingEngine:
70
+ """Get the singleton prompting engine instance."""
71
+ global _engine
72
+ if _engine is None:
73
+ _engine = PromptingEngine()
74
+ return _engine
75
+
76
+
77
+ def resolve_prompt(template: str) -> str:
78
+ """Convenience function to resolve placeholders using the singleton engine."""
79
+ return get_prompting_engine().resolve(template)
80
+
81
+
82
+ def compose_prompt(template: str, sections: dict[str, str]) -> str:
83
+ """Compose a prompt by replacing section placeholders with content.
84
+
85
+ This handles the first layer of placeholder resolution (section composition).
86
+ Use resolve_prompt() afterward for dynamic values like {{CWD}}.
87
+
88
+ Args:
89
+ template: Template string with {{SECTION_NAME}} placeholders
90
+ sections: Dict mapping section names to their content
91
+
92
+ Returns:
93
+ Template with section placeholders replaced
94
+ """
95
+ result = template
96
+ for name, content in sections.items():
97
+ result = result.replace(f"{{{{{name}}}}}", content)
98
+ return result
@@ -0,0 +1,50 @@
1
+ """System prompt section definitions."""
2
+
3
+ from enum import Enum
4
+
5
+
6
+ class SystemPromptSection(str, Enum):
7
+ """Named sections for composing system prompts.
8
+
9
+ Each section corresponds to a file in the prompts/sections/ directory.
10
+ Section files can be .xml, .md, or .txt format.
11
+ """
12
+
13
+ # Core agent identity and behavior
14
+ AGENT_ROLE = "AGENT_ROLE"
15
+
16
+ # Mandatory behavior rules and constraints
17
+ CRITICAL_RULES = "CRITICAL_RULES"
18
+
19
+ # Tool descriptions and access rules
20
+ TOOL_USE = "TOOL_USE"
21
+
22
+ # GLOB->GREP->READ search pattern guidance
23
+ SEARCH_PATTERN = "SEARCH_PATTERN"
24
+
25
+ # Task completion signaling (TUNACODE DONE:)
26
+ COMPLETION = "COMPLETION"
27
+
28
+ # Parallel execution rules for read-only tools
29
+ PARALLEL_EXEC = "PARALLEL_EXEC"
30
+
31
+ # Output formatting and style guidelines
32
+ OUTPUT_STYLE = "OUTPUT_STYLE"
33
+
34
+ # Few-shot examples and workflow demonstrations
35
+ EXAMPLES = "EXAMPLES"
36
+
37
+ # Advanced usage patterns
38
+ ADVANCED_PATTERNS = "ADVANCED_PATTERNS"
39
+
40
+ # Dynamic system info (CWD, OS, DATE placeholders)
41
+ SYSTEM_INFO = "SYSTEM_INFO"
42
+
43
+ # User-provided context and instructions
44
+ USER_INSTRUCTIONS = "USER_INSTRUCTIONS"
45
+
46
+ # Research-specific: structured output format
47
+ OUTPUT_FORMAT = "OUTPUT_FORMAT"
48
+
49
+ # Research-specific: file limits and constraints
50
+ CONSTRAINTS = "CONSTRAINTS"
@@ -0,0 +1,69 @@
1
+ """Prompt templates for composing system prompts from sections."""
2
+
3
+ # Main agent template - default composition order
4
+ # SEARCH_PATTERN is placed early to ensure the agent sees the search funnel first
5
+ MAIN_TEMPLATE = """{{AGENT_ROLE}}
6
+
7
+ ====
8
+
9
+ {{SEARCH_PATTERN}}
10
+
11
+ ====
12
+
13
+ {{CRITICAL_RULES}}
14
+
15
+ ====
16
+
17
+ {{TOOL_USE}}
18
+
19
+ ====
20
+
21
+ {{COMPLETION}}
22
+
23
+ ====
24
+
25
+ {{PARALLEL_EXEC}}
26
+
27
+ ====
28
+
29
+ {{OUTPUT_STYLE}}
30
+
31
+ ====
32
+
33
+ {{EXAMPLES}}
34
+
35
+ ====
36
+
37
+ {{ADVANCED_PATTERNS}}
38
+
39
+ ====
40
+
41
+ {{SYSTEM_INFO}}
42
+
43
+ ====
44
+
45
+ {{USER_INSTRUCTIONS}}"""
46
+
47
+ # Research agent template - simpler structure focused on exploration
48
+ RESEARCH_TEMPLATE = """{{AGENT_ROLE}}
49
+
50
+ ====
51
+
52
+ {{TOOL_USE}}
53
+
54
+ ====
55
+
56
+ {{CONSTRAINTS}}
57
+
58
+ ====
59
+
60
+ {{OUTPUT_FORMAT}}"""
61
+
62
+ # Model-specific template overrides
63
+ # Key: model name prefix (e.g., "gpt-5", "claude-opus")
64
+ # Value: custom template string
65
+ TEMPLATE_OVERRIDES: dict[str, str] = {
66
+ # Example:
67
+ # "gpt-5": GPT5_TEMPLATE,
68
+ # "claude-opus": OPUS_TEMPLATE,
69
+ }
tunacode/core/state.py ADDED
@@ -0,0 +1,409 @@
1
+ """Module: tunacode.core.state
2
+
3
+ State management system for session data in TunaCode CLI.
4
+ Handles user preferences, conversation history, and runtime state.
5
+
6
+ CLAUDE_ANCHOR[state-module]: Central state management and session tracking
7
+ """
8
+
9
+ import json
10
+ import uuid
11
+ from contextlib import suppress
12
+ from dataclasses import dataclass, field
13
+ from datetime import UTC, datetime
14
+ from pathlib import Path
15
+ from typing import TYPE_CHECKING, Any, Optional
16
+
17
+ from tunacode.configuration.defaults import DEFAULT_USER_CONFIG
18
+ from tunacode.types import (
19
+ DeviceId,
20
+ InputSessions,
21
+ MessageHistory,
22
+ ModelName,
23
+ SessionId,
24
+ ToolArgs,
25
+ ToolCallId,
26
+ ToolName,
27
+ ToolProgressCallback,
28
+ UserConfig,
29
+ )
30
+ from tunacode.utils.messaging import estimate_tokens, get_message_content
31
+
32
+ if TYPE_CHECKING:
33
+ from tunacode.tools.authorization.handler import ToolHandler
34
+
35
+
36
+ @dataclass
37
+ class SessionState:
38
+ """CLAUDE_ANCHOR[session-state]: Core session state container"""
39
+
40
+ user_config: UserConfig = field(default_factory=dict)
41
+ agents: dict[str, Any] = field(
42
+ default_factory=dict
43
+ ) # Keep as dict[str, Any] for agent instances
44
+ agent_versions: dict[str, int] = field(default_factory=dict)
45
+ messages: MessageHistory = field(default_factory=list)
46
+ # Keep session default in sync with configuration default
47
+ current_model: ModelName = DEFAULT_USER_CONFIG["default_model"]
48
+ spinner: Any | None = None
49
+ tool_ignore: list[ToolName] = field(default_factory=list)
50
+ tool_progress_callback: ToolProgressCallback | None = None
51
+ yolo: bool = False
52
+ undo_initialized: bool = False
53
+ show_thoughts: bool = False
54
+ session_id: SessionId = field(default_factory=lambda: str(uuid.uuid4()))
55
+ device_id: DeviceId | None = None
56
+ input_sessions: InputSessions = field(default_factory=dict)
57
+ current_task: Any | None = None
58
+ # Persistence fields
59
+ project_id: str = ""
60
+ created_at: str = ""
61
+ last_modified: str = ""
62
+ working_directory: str = ""
63
+ # CLAUDE_ANCHOR[react-scratchpad]: Session scratchpad for ReAct tooling
64
+ react_scratchpad: dict[str, Any] = field(default_factory=lambda: {"timeline": []})
65
+ react_forced_calls: int = 0
66
+ react_guidance: list[str] = field(default_factory=list)
67
+ # CLAUDE_ANCHOR[todos]: Session todo list for task tracking
68
+ todos: list[dict[str, Any]] = field(default_factory=list)
69
+ # Operation state tracking
70
+ operation_cancelled: bool = False
71
+ # Enhanced tracking for thoughts display
72
+ files_in_context: set[str] = field(default_factory=set)
73
+ tool_calls: list[dict[str, Any]] = field(default_factory=list)
74
+ tool_call_args_by_id: dict[ToolCallId, ToolArgs] = field(default_factory=dict)
75
+ iteration_count: int = 0
76
+ current_iteration: int = 0
77
+ # Track streaming state to prevent spinner conflicts
78
+ is_streaming_active: bool = False
79
+ # Track streaming panel reference for tool handler access
80
+ streaming_panel: Any | None = None
81
+ # Context window tracking (estimation based)
82
+ total_tokens: int = 0
83
+ max_tokens: int = 0
84
+ # API usage tracking (actual from providers)
85
+ last_call_usage: dict = field(
86
+ default_factory=lambda: {
87
+ "prompt_tokens": 0,
88
+ "completion_tokens": 0,
89
+ "cost": 0.0,
90
+ }
91
+ )
92
+ session_total_usage: dict = field(
93
+ default_factory=lambda: {
94
+ "prompt_tokens": 0,
95
+ "completion_tokens": 0,
96
+ "cost": 0.0,
97
+ }
98
+ )
99
+ # Recursive execution tracking
100
+ current_recursion_depth: int = 0
101
+ max_recursion_depth: int = 5
102
+ parent_task_id: str | None = None
103
+ task_hierarchy: dict[str, Any] = field(default_factory=dict)
104
+ iteration_budgets: dict[str, int] = field(default_factory=dict)
105
+ recursive_context_stack: list[dict[str, Any]] = field(default_factory=list)
106
+ # Streaming debug instrumentation (see core/agents/agent_components/streaming.py)
107
+ _debug_events: list[str] = field(default_factory=list)
108
+ _debug_raw_stream_accum: str = ""
109
+ # Request lifecycle metadata
110
+ request_id: str = ""
111
+ original_query: str = ""
112
+ # Agent execution counters
113
+ consecutive_empty_responses: int = 0
114
+ batch_counter: int = 0
115
+
116
+ def update_token_count(self) -> None:
117
+ """Calculate total token count from conversation messages."""
118
+ total = 0
119
+ for msg in self.messages:
120
+ content = get_message_content(msg)
121
+ if content:
122
+ total += estimate_tokens(content, self.current_model)
123
+ self.total_tokens = total
124
+
125
+ def adjust_token_count(self, delta: int) -> None:
126
+ """Adjust total_tokens by delta (negative for reclaimed tokens)."""
127
+ self.total_tokens = max(0, self.total_tokens + delta)
128
+
129
+
130
+ class StateManager:
131
+ """CLAUDE_ANCHOR[state-manager]: Main state manager singleton"""
132
+
133
+ def __init__(self):
134
+ self._session = SessionState()
135
+ self._tool_handler: ToolHandler | None = None
136
+ self._load_user_configuration()
137
+
138
+ def _load_user_configuration(self) -> None:
139
+ """Load user configuration from file and merge with defaults."""
140
+ from tunacode.configuration.defaults import DEFAULT_USER_CONFIG
141
+ from tunacode.configuration.models import get_model_context_window
142
+ from tunacode.utils.config import load_config
143
+
144
+ # Load user config from file
145
+ user_config = load_config()
146
+ if user_config:
147
+ # Merge with defaults: user config takes precedence
148
+ merged_config = DEFAULT_USER_CONFIG.copy()
149
+ merged_config.update(user_config)
150
+
151
+ # Merge nested settings
152
+ if "settings" in user_config:
153
+ merged_config["settings"] = DEFAULT_USER_CONFIG["settings"].copy()
154
+ merged_config["settings"].update(user_config["settings"])
155
+
156
+ # Update session with merged configuration
157
+ self._session.user_config = merged_config
158
+ else:
159
+ # No user config file found, use defaults
160
+ self._session.user_config = DEFAULT_USER_CONFIG.copy()
161
+
162
+ # Update current_model to match the loaded user config
163
+ if self._session.user_config.get("default_model"):
164
+ self._session.current_model = self._session.user_config["default_model"]
165
+
166
+ # Initialize max_tokens from model's registry context window
167
+ self._session.max_tokens = get_model_context_window(self._session.current_model)
168
+
169
+ @property
170
+ def session(self) -> SessionState:
171
+ return self._session
172
+
173
+ @property
174
+ def tool_handler(self) -> Optional["ToolHandler"]:
175
+ return self._tool_handler
176
+
177
+ def set_tool_handler(self, handler: "ToolHandler") -> None:
178
+ self._tool_handler = handler
179
+
180
+ def push_recursive_context(self, context: dict[str, Any]) -> None:
181
+ """Push a new context onto the recursive execution stack."""
182
+ self._session.recursive_context_stack.append(context)
183
+ self._session.current_recursion_depth = (self._session.current_recursion_depth or 0) + 1
184
+
185
+ def pop_recursive_context(self) -> dict[str, Any] | None:
186
+ """Pop the current context from the recursive execution stack."""
187
+ if self._session.recursive_context_stack:
188
+ self._session.current_recursion_depth = max(
189
+ 0, self._session.current_recursion_depth - 1
190
+ )
191
+ return self._session.recursive_context_stack.pop()
192
+ return None
193
+
194
+ def set_task_iteration_budget(self, task_id: str, budget: int) -> None:
195
+ """Set the iteration budget for a specific task."""
196
+ self._session.iteration_budgets[task_id] = budget
197
+
198
+ def get_task_iteration_budget(self, task_id: str) -> int:
199
+ """Get the iteration budget for a specific task."""
200
+ return self._session.iteration_budgets.get(task_id, 10) # Default to 10
201
+
202
+ def can_recurse_deeper(self) -> bool:
203
+ """Check if we can recurse deeper without exceeding limits."""
204
+ return self._session.current_recursion_depth < self._session.max_recursion_depth
205
+
206
+ def reset_recursive_state(self) -> None:
207
+ """Reset all recursive execution state."""
208
+ self._session.current_recursion_depth = 0
209
+ self._session.parent_task_id = None
210
+ self._session.task_hierarchy.clear()
211
+ self._session.iteration_budgets.clear()
212
+ self._session.recursive_context_stack.clear()
213
+
214
+ # React scratchpad helpers
215
+ def get_react_scratchpad(self) -> dict[str, Any]:
216
+ return self._session.react_scratchpad
217
+
218
+ def append_react_entry(self, entry: dict[str, Any]) -> None:
219
+ timeline = self._session.react_scratchpad.setdefault("timeline", [])
220
+ timeline.append(entry)
221
+
222
+ def clear_react_scratchpad(self) -> None:
223
+ self._session.react_scratchpad = {"timeline": []}
224
+
225
+ # Todo list helpers
226
+ def get_todos(self) -> list[dict[str, Any]]:
227
+ """Return the current todo list."""
228
+ return self._session.todos
229
+
230
+ def set_todos(self, todos: list[dict[str, Any]]) -> None:
231
+ """Replace the entire todo list."""
232
+ self._session.todos = todos
233
+
234
+ def clear_todos(self) -> None:
235
+ """Clear the todo list."""
236
+ self._session.todos = []
237
+
238
+ def reset_session(self) -> None:
239
+ """Reset the session to a fresh state."""
240
+ self._session = SessionState()
241
+
242
+ # Session persistence methods
243
+
244
+ def _get_session_file_path(self) -> Path:
245
+ """Get the file path for current session."""
246
+ from tunacode.utils.system.paths import get_session_storage_dir
247
+
248
+ storage_dir = get_session_storage_dir()
249
+ return storage_dir / f"{self._session.project_id}_{self._session.session_id}.json"
250
+
251
+ def _serialize_messages(self) -> list[dict]:
252
+ """Serialize mixed message list to JSON-compatible dicts."""
253
+ try:
254
+ from pydantic import TypeAdapter
255
+ from pydantic_ai.messages import ModelMessage
256
+
257
+ msg_adapter = TypeAdapter(ModelMessage)
258
+ except ImportError:
259
+ msg_adapter = None
260
+
261
+ result = []
262
+ for msg in self._session.messages:
263
+ if isinstance(msg, dict):
264
+ result.append(msg)
265
+ elif msg_adapter is not None:
266
+ with suppress(TypeError, ValueError, AttributeError):
267
+ result.append(msg_adapter.dump_python(msg, mode="json"))
268
+ return result
269
+
270
+ def _deserialize_messages(self, data: list[dict]) -> list:
271
+ """Deserialize JSON dicts back to message objects."""
272
+ try:
273
+ from pydantic import TypeAdapter
274
+ from pydantic_ai.messages import ModelMessage
275
+
276
+ msg_adapter = TypeAdapter(ModelMessage)
277
+ except ImportError:
278
+ return data
279
+
280
+ result = []
281
+ for item in data:
282
+ if not isinstance(item, dict):
283
+ result.append(item)
284
+ continue
285
+
286
+ if "thought" in item:
287
+ result.append(item)
288
+ elif item.get("kind") in ("request", "response"):
289
+ try:
290
+ result.append(msg_adapter.validate_python(item))
291
+ except Exception:
292
+ result.append(item)
293
+ else:
294
+ result.append(item)
295
+ return result
296
+
297
+ def save_session(self) -> bool:
298
+ """Save current session to disk."""
299
+ if not self._session.project_id:
300
+ pass
301
+ return False
302
+
303
+ self._session.last_modified = datetime.now(UTC).isoformat()
304
+
305
+ session_data = {
306
+ "version": 1,
307
+ "session_id": self._session.session_id,
308
+ "project_id": self._session.project_id,
309
+ "created_at": self._session.created_at,
310
+ "last_modified": self._session.last_modified,
311
+ "working_directory": self._session.working_directory,
312
+ "current_model": self._session.current_model,
313
+ "total_tokens": self._session.total_tokens,
314
+ "session_total_usage": self._session.session_total_usage,
315
+ "tool_ignore": self._session.tool_ignore,
316
+ "yolo": self._session.yolo,
317
+ "react_scratchpad": self._session.react_scratchpad,
318
+ "todos": self._session.todos,
319
+ "messages": self._serialize_messages(),
320
+ }
321
+
322
+ try:
323
+ session_file = self._get_session_file_path()
324
+ session_file.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
325
+ with open(session_file, "w") as f:
326
+ json.dump(session_data, f, indent=2)
327
+ return True
328
+ except PermissionError:
329
+ return False
330
+ except OSError:
331
+ return False
332
+ except Exception:
333
+ return False
334
+
335
+ def load_session(self, session_id: str) -> bool:
336
+ """Load a session from disk."""
337
+ from tunacode.configuration.models import get_model_context_window
338
+ from tunacode.utils.system.paths import get_session_storage_dir
339
+
340
+ storage_dir = get_session_storage_dir()
341
+
342
+ session_file = None
343
+ for file in storage_dir.glob(f"*_{session_id}.json"):
344
+ session_file = file
345
+ break
346
+
347
+ if not session_file or not session_file.exists():
348
+ return False
349
+
350
+ try:
351
+ with open(session_file) as f:
352
+ data = json.load(f)
353
+
354
+ self._session.session_id = data.get("session_id", session_id)
355
+ self._session.project_id = data.get("project_id", "")
356
+ self._session.created_at = data.get("created_at", "")
357
+ self._session.last_modified = data.get("last_modified", "")
358
+ self._session.working_directory = data.get("working_directory", "")
359
+ self._session.current_model = data.get(
360
+ "current_model", DEFAULT_USER_CONFIG["default_model"]
361
+ )
362
+ # Update max_tokens based on loaded model's context window
363
+ self._session.max_tokens = get_model_context_window(self._session.current_model)
364
+ self._session.total_tokens = data.get("total_tokens", 0)
365
+ self._session.session_total_usage = data.get(
366
+ "session_total_usage",
367
+ {"prompt_tokens": 0, "completion_tokens": 0, "cost": 0.0},
368
+ )
369
+ self._session.tool_ignore = data.get("tool_ignore", [])
370
+ self._session.yolo = data.get("yolo", False)
371
+ self._session.react_scratchpad = data.get("react_scratchpad", {"timeline": []})
372
+ self._session.todos = data.get("todos", [])
373
+ self._session.messages = self._deserialize_messages(data.get("messages", []))
374
+
375
+ return True
376
+ except json.JSONDecodeError:
377
+ return False
378
+ except Exception:
379
+ return False
380
+
381
+ def list_sessions(self) -> list[dict]:
382
+ """List available sessions for current project."""
383
+ from tunacode.utils.system.paths import get_session_storage_dir
384
+
385
+ storage_dir = get_session_storage_dir()
386
+ sessions: list[dict[str, Any]] = []
387
+
388
+ if not self._session.project_id:
389
+ return sessions
390
+
391
+ for file in storage_dir.glob(f"{self._session.project_id}_*.json"):
392
+ try:
393
+ with open(file) as f:
394
+ data = json.load(f)
395
+ sessions.append(
396
+ {
397
+ "session_id": data.get("session_id", ""),
398
+ "created_at": data.get("created_at", ""),
399
+ "last_modified": data.get("last_modified", ""),
400
+ "message_count": len(data.get("messages", [])),
401
+ "current_model": data.get("current_model", ""),
402
+ "file_path": str(file),
403
+ }
404
+ )
405
+ except Exception:
406
+ pass
407
+
408
+ sessions.sort(key=lambda x: x.get("last_modified", ""), reverse=True)
409
+ return sessions