iac-code 0.1.0__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 (184) hide show
  1. iac_code/__init__.py +2 -0
  2. iac_code/acp/__init__.py +97 -0
  3. iac_code/acp/convert.py +423 -0
  4. iac_code/acp/http_sse.py +448 -0
  5. iac_code/acp/mcp.py +54 -0
  6. iac_code/acp/metrics.py +71 -0
  7. iac_code/acp/server.py +662 -0
  8. iac_code/acp/session.py +446 -0
  9. iac_code/acp/slash_registry.py +125 -0
  10. iac_code/acp/state.py +99 -0
  11. iac_code/acp/tools.py +112 -0
  12. iac_code/acp/types.py +13 -0
  13. iac_code/acp/version.py +26 -0
  14. iac_code/agent/__init__.py +19 -0
  15. iac_code/agent/agent_loop.py +640 -0
  16. iac_code/agent/agent_tool.py +269 -0
  17. iac_code/agent/agent_types.py +87 -0
  18. iac_code/agent/message.py +153 -0
  19. iac_code/agent/system_prompt.py +313 -0
  20. iac_code/cli/__init__.py +3 -0
  21. iac_code/cli/headless.py +114 -0
  22. iac_code/cli/main.py +246 -0
  23. iac_code/cli/output_formats.py +125 -0
  24. iac_code/commands/__init__.py +93 -0
  25. iac_code/commands/auth.py +1055 -0
  26. iac_code/commands/clear.py +34 -0
  27. iac_code/commands/compact.py +43 -0
  28. iac_code/commands/debug.py +45 -0
  29. iac_code/commands/effort.py +116 -0
  30. iac_code/commands/exit.py +10 -0
  31. iac_code/commands/help.py +49 -0
  32. iac_code/commands/model.py +130 -0
  33. iac_code/commands/registry.py +245 -0
  34. iac_code/commands/resume.py +49 -0
  35. iac_code/commands/tasks.py +41 -0
  36. iac_code/config.py +304 -0
  37. iac_code/i18n/__init__.py +141 -0
  38. iac_code/i18n/locales/zh/LC_MESSAGES/messages.po +1355 -0
  39. iac_code/memory/__init__.py +1 -0
  40. iac_code/memory/memory_manager.py +92 -0
  41. iac_code/memory/memory_tools.py +88 -0
  42. iac_code/providers/__init__.py +1 -0
  43. iac_code/providers/anthropic_provider.py +284 -0
  44. iac_code/providers/base.py +128 -0
  45. iac_code/providers/dashscope_provider.py +47 -0
  46. iac_code/providers/deepseek_provider.py +36 -0
  47. iac_code/providers/manager.py +399 -0
  48. iac_code/providers/openai_provider.py +344 -0
  49. iac_code/providers/retry.py +58 -0
  50. iac_code/providers/stream_watchdog.py +47 -0
  51. iac_code/providers/thinking.py +164 -0
  52. iac_code/services/__init__.py +1 -0
  53. iac_code/services/agent_factory.py +127 -0
  54. iac_code/services/cloud_credentials.py +22 -0
  55. iac_code/services/context_manager.py +221 -0
  56. iac_code/services/providers/__init__.py +1 -0
  57. iac_code/services/providers/aliyun.py +232 -0
  58. iac_code/services/session_index.py +281 -0
  59. iac_code/services/session_storage.py +245 -0
  60. iac_code/services/telemetry/__init__.py +66 -0
  61. iac_code/services/telemetry/attributes.py +84 -0
  62. iac_code/services/telemetry/client.py +330 -0
  63. iac_code/services/telemetry/config.py +76 -0
  64. iac_code/services/telemetry/constants.py +75 -0
  65. iac_code/services/telemetry/content_serializer.py +124 -0
  66. iac_code/services/telemetry/events.py +42 -0
  67. iac_code/services/telemetry/fallback.py +59 -0
  68. iac_code/services/telemetry/identity.py +73 -0
  69. iac_code/services/telemetry/metrics.py +62 -0
  70. iac_code/services/telemetry/names.py +199 -0
  71. iac_code/services/telemetry/sanitize.py +88 -0
  72. iac_code/services/telemetry/sink.py +67 -0
  73. iac_code/services/telemetry/tracing.py +38 -0
  74. iac_code/services/telemetry/types.py +13 -0
  75. iac_code/services/token_budget.py +54 -0
  76. iac_code/services/token_counter.py +76 -0
  77. iac_code/skills/__init__.py +1 -0
  78. iac_code/skills/bundled/__init__.py +94 -0
  79. iac_code/skills/bundled/iac_aliyun/SKILL.md +192 -0
  80. iac_code/skills/bundled/iac_aliyun/__init__.py +16 -0
  81. iac_code/skills/bundled/iac_aliyun/references/cloud-products/ecs.md +167 -0
  82. iac_code/skills/bundled/iac_aliyun/references/cloud-products/oss.md +69 -0
  83. iac_code/skills/bundled/iac_aliyun/references/cloud-products/rds.md +95 -0
  84. iac_code/skills/bundled/iac_aliyun/references/cloud-products/redis.md +100 -0
  85. iac_code/skills/bundled/iac_aliyun/references/cloud-products/slb.md +60 -0
  86. iac_code/skills/bundled/iac_aliyun/references/cloud-products/vpc.md +54 -0
  87. iac_code/skills/bundled/iac_aliyun/references/ros-template.md +155 -0
  88. iac_code/skills/bundled/iac_aliyun/references/template-parameters.md +206 -0
  89. iac_code/skills/bundled/iac_aliyun/references/terraform-template.md +101 -0
  90. iac_code/skills/bundled/iac_aliyun/scripts/tf2ros.py +77 -0
  91. iac_code/skills/bundled/simplify.py +28 -0
  92. iac_code/skills/discovery.py +136 -0
  93. iac_code/skills/frontmatter.py +119 -0
  94. iac_code/skills/listing.py +92 -0
  95. iac_code/skills/loader.py +42 -0
  96. iac_code/skills/processor.py +81 -0
  97. iac_code/skills/renderer.py +157 -0
  98. iac_code/skills/skill_definition.py +82 -0
  99. iac_code/skills/skill_tool.py +261 -0
  100. iac_code/state/__init__.py +5 -0
  101. iac_code/state/app_state.py +122 -0
  102. iac_code/tasks/__init__.py +1 -0
  103. iac_code/tasks/notification_queue.py +28 -0
  104. iac_code/tasks/task_state.py +66 -0
  105. iac_code/tasks/task_tools.py +114 -0
  106. iac_code/tools/__init__.py +8 -0
  107. iac_code/tools/base.py +226 -0
  108. iac_code/tools/bash.py +133 -0
  109. iac_code/tools/cloud/__init__.py +0 -0
  110. iac_code/tools/cloud/aliyun/__init__.py +0 -0
  111. iac_code/tools/cloud/aliyun/aliyun_api.py +510 -0
  112. iac_code/tools/cloud/aliyun/aliyun_doc_search.py +145 -0
  113. iac_code/tools/cloud/aliyun/endpoints.yml +343 -0
  114. iac_code/tools/cloud/aliyun/ros_client.py +56 -0
  115. iac_code/tools/cloud/aliyun/ros_stack.py +633 -0
  116. iac_code/tools/cloud/aliyun/ros_stack_instances.py +247 -0
  117. iac_code/tools/cloud/base_api.py +162 -0
  118. iac_code/tools/cloud/base_stack.py +242 -0
  119. iac_code/tools/cloud/registry.py +20 -0
  120. iac_code/tools/cloud/types.py +105 -0
  121. iac_code/tools/edit_file.py +121 -0
  122. iac_code/tools/glob.py +103 -0
  123. iac_code/tools/grep.py +254 -0
  124. iac_code/tools/list_files.py +104 -0
  125. iac_code/tools/read_file.py +127 -0
  126. iac_code/tools/result_storage.py +39 -0
  127. iac_code/tools/tool_executor.py +165 -0
  128. iac_code/tools/web_fetch.py +177 -0
  129. iac_code/tools/write_file.py +88 -0
  130. iac_code/types/__init__.py +40 -0
  131. iac_code/types/permissions.py +26 -0
  132. iac_code/types/skill_source.py +11 -0
  133. iac_code/types/stream_events.py +227 -0
  134. iac_code/ui/__init__.py +5 -0
  135. iac_code/ui/banner.py +110 -0
  136. iac_code/ui/components/__init__.py +0 -0
  137. iac_code/ui/components/dialog.py +142 -0
  138. iac_code/ui/components/divider.py +20 -0
  139. iac_code/ui/components/fuzzy_picker.py +308 -0
  140. iac_code/ui/components/progress_bar.py +54 -0
  141. iac_code/ui/components/search_box.py +165 -0
  142. iac_code/ui/components/select.py +319 -0
  143. iac_code/ui/components/status_icon.py +42 -0
  144. iac_code/ui/components/tabs.py +128 -0
  145. iac_code/ui/core/__init__.py +0 -0
  146. iac_code/ui/core/in_place_render.py +129 -0
  147. iac_code/ui/core/input_history.py +118 -0
  148. iac_code/ui/core/key_event.py +41 -0
  149. iac_code/ui/core/prompt_input.py +507 -0
  150. iac_code/ui/core/raw_input.py +302 -0
  151. iac_code/ui/core/screen.py +80 -0
  152. iac_code/ui/dialogs/__init__.py +0 -0
  153. iac_code/ui/dialogs/global_search.py +178 -0
  154. iac_code/ui/dialogs/history_search.py +100 -0
  155. iac_code/ui/dialogs/model_picker.py +280 -0
  156. iac_code/ui/dialogs/quick_open.py +108 -0
  157. iac_code/ui/dialogs/resume_picker.py +749 -0
  158. iac_code/ui/keybindings/__init__.py +0 -0
  159. iac_code/ui/keybindings/manager.py +124 -0
  160. iac_code/ui/renderer.py +1535 -0
  161. iac_code/ui/repl.py +772 -0
  162. iac_code/ui/spinner.py +112 -0
  163. iac_code/ui/suggestions/__init__.py +0 -0
  164. iac_code/ui/suggestions/aggregator.py +171 -0
  165. iac_code/ui/suggestions/command_provider.py +43 -0
  166. iac_code/ui/suggestions/directory_provider.py +95 -0
  167. iac_code/ui/suggestions/file_provider.py +121 -0
  168. iac_code/ui/suggestions/shell_history_provider.py +108 -0
  169. iac_code/ui/suggestions/token_extractor.py +77 -0
  170. iac_code/ui/suggestions/types.py +45 -0
  171. iac_code/ui/transcript_view.py +199 -0
  172. iac_code/utils/__init__.py +0 -0
  173. iac_code/utils/background_housekeeping.py +53 -0
  174. iac_code/utils/cleanup.py +68 -0
  175. iac_code/utils/json_utils.py +60 -0
  176. iac_code/utils/log.py +150 -0
  177. iac_code/utils/project_paths.py +74 -0
  178. iac_code/utils/tool_input_parser.py +62 -0
  179. iac_code-0.1.0.dist-info/LICENSE +201 -0
  180. iac_code-0.1.0.dist-info/METADATA +64 -0
  181. iac_code-0.1.0.dist-info/RECORD +184 -0
  182. iac_code-0.1.0.dist-info/WHEEL +5 -0
  183. iac_code-0.1.0.dist-info/entry_points.txt +2 -0
  184. iac_code-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,127 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import uuid
5
+ from dataclasses import dataclass
6
+ from typing import Any
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class AgentFactoryOptions:
11
+ model: str
12
+ session_id: str | None = None
13
+ cwd: str | None = None
14
+ max_turns: int = 100
15
+
16
+
17
+ @dataclass
18
+ class AgentRuntime:
19
+ agent_loop: Any
20
+ session_id: str
21
+ tool_registry: Any
22
+ provider_manager: Any
23
+ command_registry: Any
24
+ task_manager: Any
25
+ memory_manager: Any
26
+
27
+
28
+ def create_agent_runtime(options: AgentFactoryOptions) -> AgentRuntime:
29
+ from loguru import logger
30
+
31
+ from iac_code.agent.agent_loop import AgentLoop
32
+ from iac_code.agent.agent_tool import AgentTool
33
+ from iac_code.agent.system_prompt import build_system_prompt
34
+ from iac_code.commands import create_default_registry
35
+ from iac_code.commands.registry import PromptCommand
36
+ from iac_code.config import get_config_dir, load_credentials
37
+ from iac_code.memory.memory_manager import MemoryManager
38
+ from iac_code.memory.memory_tools import ReadMemoryTool, WriteMemoryTool
39
+ from iac_code.providers.manager import ProviderManager
40
+ from iac_code.services.cloud_credentials import CloudCredentials
41
+ from iac_code.services.session_storage import SessionStorage
42
+ from iac_code.skills.bundled import init_bundled_skills
43
+ from iac_code.skills.discovery import discover_all_skills, skill_to_command
44
+ from iac_code.skills.listing import build_skill_listing
45
+ from iac_code.skills.skill_tool import SkillTool
46
+ from iac_code.tasks.notification_queue import NotificationQueue
47
+ from iac_code.tasks.task_state import TaskManager
48
+ from iac_code.tasks.task_tools import TaskGetTool, TaskListTool, TaskStopTool
49
+ from iac_code.tools.base import ToolRegistry
50
+ from iac_code.tools.cloud.registry import register_cloud_tools
51
+
52
+ cwd = options.cwd or os.getcwd()
53
+ session_id = options.session_id or str(uuid.uuid4())[:8]
54
+
55
+ credentials = load_credentials()
56
+
57
+ provider_manager = ProviderManager(model=options.model, credentials=credentials)
58
+
59
+ tool_registry = ToolRegistry()
60
+ tool_registry.register_default_tools()
61
+ register_cloud_tools(tool_registry, CloudCredentials())
62
+
63
+ session_storage = SessionStorage()
64
+
65
+ memory_manager = MemoryManager(memory_dir=str(get_config_dir() / "memory"))
66
+ memory_content = memory_manager.get_prompt_content()
67
+ tool_registry.register(ReadMemoryTool(memory_manager))
68
+ tool_registry.register(WriteMemoryTool(memory_manager))
69
+
70
+ task_manager = TaskManager()
71
+ tool_registry.register(TaskListTool(task_manager))
72
+ tool_registry.register(TaskGetTool(task_manager))
73
+ tool_registry.register(TaskStopTool(task_manager))
74
+
75
+ notification_queue = NotificationQueue()
76
+ base_system_prompt = build_system_prompt(cwd=cwd, memory_content=memory_content)
77
+ tool_registry.register(
78
+ AgentTool(
79
+ task_manager=task_manager,
80
+ provider_manager=provider_manager,
81
+ tool_registry=tool_registry,
82
+ system_prompt=base_system_prompt,
83
+ notification_queue=notification_queue,
84
+ )
85
+ )
86
+
87
+ init_bundled_skills()
88
+ command_registry = create_default_registry()
89
+ for skill in discover_all_skills(cwd):
90
+ cmd = skill_to_command(skill)
91
+ existing = command_registry.get(cmd.name)
92
+ if existing is not None and not isinstance(existing, PromptCommand):
93
+ logger.warning("Skill '{}' skipped: conflicts with built-in command", cmd.name)
94
+ continue
95
+ command_registry.register(cmd)
96
+
97
+ tool_registry.register(
98
+ SkillTool(
99
+ command_registry=command_registry,
100
+ session_id=session_id,
101
+ cwd=cwd,
102
+ provider_manager=provider_manager,
103
+ tool_registry=tool_registry,
104
+ system_prompt=base_system_prompt,
105
+ )
106
+ )
107
+
108
+ skill_listing = build_skill_listing(command_registry.get_model_invocable_skills())
109
+ agent_loop = AgentLoop(
110
+ provider_manager=provider_manager,
111
+ system_prompt=build_system_prompt(cwd=cwd, memory_content=memory_content, skill_listing=skill_listing),
112
+ tool_registry=tool_registry,
113
+ session_storage=session_storage,
114
+ session_id=session_id,
115
+ max_turns=options.max_turns,
116
+ cwd=cwd,
117
+ )
118
+
119
+ return AgentRuntime(
120
+ agent_loop=agent_loop,
121
+ session_id=session_id,
122
+ tool_registry=tool_registry,
123
+ provider_manager=provider_manager,
124
+ command_registry=command_registry,
125
+ task_manager=task_manager,
126
+ memory_manager=memory_manager,
127
+ )
@@ -0,0 +1,22 @@
1
+ from iac_code.services.providers.aliyun import AliyunCredential, AliyunCredentials
2
+
3
+
4
+ class CloudCredentials:
5
+ def __init__(self, aliyun_config_path: str | None = None) -> None:
6
+ self._aliyun_config_path = aliyun_config_path
7
+
8
+ def has_provider(self, name: str) -> bool:
9
+ if name == "aliyun":
10
+ return AliyunCredentials.is_configured(config_path=self._aliyun_config_path)
11
+ return False
12
+
13
+ def get_provider(self, name: str) -> AliyunCredential | None:
14
+ if name == "aliyun":
15
+ return AliyunCredentials.load(config_path=self._aliyun_config_path)
16
+ return None
17
+
18
+ def list_providers(self) -> list[str]:
19
+ result: list[str] = []
20
+ if self.has_provider("aliyun"):
21
+ result.append("aliyun")
22
+ return result
@@ -0,0 +1,221 @@
1
+ """Context manager for conversation history, token tracking, and segmented compaction."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Any
7
+
8
+ from loguru import logger
9
+
10
+ from iac_code.agent.message import ContentBlock, Conversation, Message, ToolResultBlock
11
+ from iac_code.services.token_counter import TokenCounter
12
+
13
+
14
+ @dataclass
15
+ class ContextWindowConfig:
16
+ """Model-specific context window configuration."""
17
+
18
+ context_window: int
19
+ max_output_tokens: int
20
+ compact_buffer: int
21
+ compact_threshold: float
22
+ preserve_recent_turns: int
23
+
24
+
25
+ _MODEL_CONFIGS: dict[str, ContextWindowConfig] = {
26
+ "claude": ContextWindowConfig(200_000, 8_192, 20_000, 0.93, 3),
27
+ "gpt-5": ContextWindowConfig(200_000, 8_192, 20_000, 0.93, 3),
28
+ "gpt-4": ContextWindowConfig(128_000, 8_192, 15_000, 0.93, 3),
29
+ "qwen": ContextWindowConfig(131_072, 8_192, 15_000, 0.93, 3),
30
+ "qwq": ContextWindowConfig(131_072, 8_192, 15_000, 0.93, 3),
31
+ "o3": ContextWindowConfig(200_000, 8_192, 20_000, 0.93, 3),
32
+ "o4": ContextWindowConfig(200_000, 8_192, 20_000, 0.93, 3),
33
+ }
34
+ _DEFAULT_CONFIG = ContextWindowConfig(128_000, 8_192, 15_000, 0.93, 3)
35
+
36
+
37
+ def get_context_window_config(model: str) -> ContextWindowConfig:
38
+ model_lower = model.lower()
39
+ for prefix, config in _MODEL_CONFIGS.items():
40
+ if model_lower.startswith(prefix):
41
+ return config
42
+ return _DEFAULT_CONFIG
43
+
44
+
45
+ class ContextManager:
46
+ def __init__(
47
+ self,
48
+ system_prompt: str,
49
+ model: str = "",
50
+ ) -> None:
51
+ self._system_prompt = system_prompt
52
+ self._conversation = Conversation()
53
+ self._model = model
54
+ self._token_counter = TokenCounter(model=model)
55
+ self._config = get_context_window_config(model)
56
+ self._system_prompt_tokens = self._token_counter.count_text(system_prompt)
57
+
58
+ @property
59
+ def system_prompt(self) -> str:
60
+ return self._system_prompt
61
+
62
+ @property
63
+ def preserve_recent_turns(self) -> int:
64
+ return self._config.preserve_recent_turns
65
+
66
+ @property
67
+ def context_window(self) -> int:
68
+ """Total context-window size in tokens for the current model."""
69
+ return self._config.context_window
70
+
71
+ def set_model(self, model: str) -> None:
72
+ """Switch tokenizer/context-window config for a model change.
73
+
74
+ Recomputes cached token counts so compaction thresholds stay
75
+ accurate after a `/model` or `/auth` switch.
76
+ """
77
+ if model == self._model:
78
+ return
79
+ self._model = model
80
+ self._token_counter = TokenCounter(model=model)
81
+ self._config = get_context_window_config(model)
82
+ self._system_prompt_tokens = self._token_counter.count_text(self._system_prompt)
83
+ for msg in self._conversation.messages:
84
+ msg.token_count = self._token_counter.count_message(msg.to_api_format())
85
+
86
+ def set_system_prompt(self, system_prompt: str) -> None:
87
+ """Replace the system prompt and refresh its cached token count."""
88
+ if system_prompt == self._system_prompt:
89
+ return
90
+ self._system_prompt = system_prompt
91
+ self._system_prompt_tokens = self._token_counter.count_text(system_prompt)
92
+
93
+ def add_user_message(self, content: str) -> Message:
94
+ msg = self._conversation.add_user_message(content)
95
+ msg.token_count = self._token_counter.count_message(msg.to_api_format())
96
+ return msg
97
+
98
+ def add_assistant_message(self, content: str | list[ContentBlock]) -> Message:
99
+ msg = self._conversation.add_assistant_message(content)
100
+ msg.token_count = self._token_counter.count_message(msg.to_api_format())
101
+ return msg
102
+
103
+ def add_tool_results(self, tool_results: list[ToolResultBlock]) -> Message:
104
+ msg = self._conversation.add_tool_results(tool_results)
105
+ msg.token_count = self._token_counter.count_message(msg.to_api_format())
106
+ return msg
107
+
108
+ def add_raw_message(self, raw_msg: dict[str, Any]) -> Message:
109
+ """Add a raw message dict (e.g. from ToolResult.new_messages) to the conversation."""
110
+ role = raw_msg.get("role", "user")
111
+ content = raw_msg.get("content", "")
112
+ msg = Message(role=role, content=content)
113
+ self._conversation.messages.append(msg)
114
+ msg.token_count = self._token_counter.count_message(msg.to_api_format())
115
+ return msg
116
+
117
+ def load_messages(self, messages: list[Message]) -> None:
118
+ """Inject pre-existing messages (e.g. from a resumed session)."""
119
+ for msg in messages:
120
+ self._conversation.messages.append(msg)
121
+ if msg.token_count == 0:
122
+ msg.token_count = self._token_counter.count_message(msg.to_api_format())
123
+
124
+ def get_messages(self) -> list[Message]:
125
+ return self._conversation.messages
126
+
127
+ def get_api_messages(self) -> list[dict[str, Any]]:
128
+ return self._conversation.to_api_messages()
129
+
130
+ def get_total_tokens(self) -> int:
131
+ return self._system_prompt_tokens + self._conversation.get_total_tokens()
132
+
133
+ def get_usage(self) -> dict[str, Any]:
134
+ """Return detailed token usage breakdown by category."""
135
+ user_tokens = 0
136
+ assistant_tokens = 0
137
+ tool_result_tokens = 0
138
+
139
+ for msg in self._conversation.messages:
140
+ if msg.role == "user":
141
+ if isinstance(msg.content, list) and any(isinstance(b, ToolResultBlock) for b in msg.content):
142
+ tool_result_tokens += msg.token_count
143
+ else:
144
+ user_tokens += msg.token_count
145
+ elif msg.role == "assistant":
146
+ assistant_tokens += msg.token_count
147
+
148
+ total = self._system_prompt_tokens + user_tokens + assistant_tokens + tool_result_tokens
149
+ return {
150
+ "system_prompt_tokens": self._system_prompt_tokens,
151
+ "user_message_tokens": user_tokens,
152
+ "assistant_message_tokens": assistant_tokens,
153
+ "tool_result_tokens": tool_result_tokens,
154
+ "total_tokens": total,
155
+ "context_window": self._config.context_window,
156
+ "usage_percent": (total / self._config.context_window * 100) if self._config.context_window > 0 else 0,
157
+ "message_count": len(self._conversation.messages),
158
+ }
159
+
160
+ def needs_compaction(self) -> bool:
161
+ total = self.get_total_tokens()
162
+ threshold = self._config.context_window * self._config.compact_threshold
163
+ return total > threshold
164
+
165
+ def _split_messages_for_compaction(self) -> tuple[list[Message], list[Message]]:
166
+ """Split messages into [old_messages, recent_messages].
167
+
168
+ A "turn" is a user+assistant message pair. We preserve the last
169
+ `preserve_recent_turns` turns (counting from the end).
170
+ """
171
+ messages = self._conversation.messages
172
+ preserve_count = self._config.preserve_recent_turns * 2
173
+
174
+ if len(messages) <= preserve_count:
175
+ return [], messages
176
+
177
+ split_point = len(messages) - preserve_count
178
+ return messages[:split_point], messages[split_point:]
179
+
180
+ def build_compaction_prompt(self) -> str:
181
+ """Build compaction prompt from old messages only (recent are preserved)."""
182
+ old_messages, _recent = self._split_messages_for_compaction()
183
+ if not old_messages:
184
+ return ""
185
+
186
+ conversation_text = []
187
+ for msg in old_messages:
188
+ role = msg.role.upper()
189
+ text = msg.get_text()
190
+ if text:
191
+ conversation_text.append(f"{role}: {text}")
192
+
193
+ joined = "\n".join(conversation_text)
194
+ return (
195
+ "Please provide a concise summary of this conversation so far. "
196
+ "Focus on:\n"
197
+ "1. Key decisions made\n"
198
+ "2. Important code changes or file modifications\n"
199
+ "3. Current task status and next steps\n"
200
+ "4. Any errors encountered and how they were resolved\n\n"
201
+ "Keep the summary focused and actionable. Preserve specific file paths, "
202
+ "function names, and technical details that are needed to continue the work.\n\n"
203
+ f"Conversation:\n{joined}"
204
+ )
205
+
206
+ def apply_compaction(self, summary: str) -> tuple[int, int]:
207
+ """Replace old messages with summary, keep recent messages intact."""
208
+ original_tokens = self._conversation.get_total_tokens()
209
+
210
+ _old, recent = self._split_messages_for_compaction()
211
+
212
+ summary_msg = Message(role="user", content=f"[Conversation Summary]\n{summary}")
213
+ summary_msg.token_count = self._token_counter.count_message(summary_msg.to_api_format())
214
+
215
+ self._conversation.replace_messages([summary_msg] + recent)
216
+ new_tokens = self._conversation.get_total_tokens()
217
+ logger.info(f"Compaction: {original_tokens} -> {new_tokens} tokens")
218
+ return (original_tokens, new_tokens)
219
+
220
+ def reset(self) -> None:
221
+ self._conversation = Conversation()
@@ -0,0 +1 @@
1
+ __all__: list[str] = []
@@ -0,0 +1,232 @@
1
+ import json
2
+ import os
3
+ from dataclasses import dataclass, field
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from iac_code.config import _load_yaml, _save_yaml, get_cloud_credentials_path
8
+
9
+ DEFAULT_REGION = "cn-hangzhou"
10
+ DEFAULT_ALIYUN_CLI_CONFIG_PATH = os.path.expanduser("~/.aliyun/config.json")
11
+
12
+ # Credential modes matching aliyun CLI
13
+ CREDENTIAL_MODES = ["AK", "StsToken", "RamRoleArn"]
14
+
15
+ # Fields definition for each credential mode
16
+ # Each field: (name, label, sensitive)
17
+ MODE_FIELDS: dict[str, list[tuple[str, str, bool]]] = {
18
+ "AK": [
19
+ ("access_key_id", "AccessKey ID", True),
20
+ ("access_key_secret", "AccessKey Secret", True),
21
+ ],
22
+ "StsToken": [
23
+ ("access_key_id", "AccessKey ID", True),
24
+ ("access_key_secret", "AccessKey Secret", True),
25
+ ("sts_token", "STS Token", True),
26
+ ],
27
+ "RamRoleArn": [
28
+ ("access_key_id", "AccessKey ID", True),
29
+ ("access_key_secret", "AccessKey Secret", True),
30
+ ("ram_role_arn", "RAM Role ARN", False),
31
+ ("ram_session_name", "Session Name", False),
32
+ ],
33
+ }
34
+
35
+ # Display names for credential modes (English, translatable via i18n)
36
+ MODE_DISPLAY_NAMES: dict[str, str] = {
37
+ "AK": "AccessKey",
38
+ "StsToken": "STS Token",
39
+ "RamRoleArn": "RAM Role",
40
+ }
41
+
42
+
43
+ @dataclass
44
+ class AliyunCredential:
45
+ mode: str = "AK"
46
+ access_key_id: str = ""
47
+ access_key_secret: str = ""
48
+ region_id: str = field(default=DEFAULT_REGION)
49
+ sts_token: str = ""
50
+ ram_role_arn: str = ""
51
+ ram_session_name: str = ""
52
+
53
+
54
+ def mask_sensitive(value: str) -> str:
55
+ """Mask a sensitive value with '*' characters of the same length."""
56
+ if not value:
57
+ return ""
58
+ return "*" * len(value)
59
+
60
+
61
+ class AliyunCredentials:
62
+ @staticmethod
63
+ def load(config_path: str | None = None) -> AliyunCredential | None:
64
+ """Load credentials with priority: env vars > iac-code config > aliyun CLI config."""
65
+ # Try environment variables first
66
+ access_key_id = os.environ.get("ALIBABA_CLOUD_ACCESS_KEY_ID")
67
+ access_key_secret = os.environ.get("ALIBABA_CLOUD_ACCESS_KEY_SECRET")
68
+ if access_key_id and access_key_secret:
69
+ region_id = os.environ.get("ALIBABA_CLOUD_REGION_ID")
70
+ if not region_id:
71
+ # Env vars don't specify region — walk the file fallback chain:
72
+ # iac-code config → aliyun CLI config → DEFAULT_REGION.
73
+ iac_cred = AliyunCredentials._load_from_iac_code_config()
74
+ if iac_cred and iac_cred.region_id:
75
+ region_id = iac_cred.region_id
76
+ else:
77
+ cli_cred = AliyunCredentials._load_from_aliyun_cli(config_path)
78
+ region_id = (cli_cred.region_id if cli_cred else None) or DEFAULT_REGION
79
+ sts_token = os.environ.get("ALIBABA_CLOUD_SECURITY_TOKEN", "")
80
+ mode = "StsToken" if sts_token else "AK"
81
+ return AliyunCredential(
82
+ mode=mode,
83
+ access_key_id=access_key_id,
84
+ access_key_secret=access_key_secret,
85
+ region_id=region_id,
86
+ sts_token=sts_token,
87
+ )
88
+
89
+ # Try iac-code config
90
+ if config_path is None:
91
+ cred = AliyunCredentials._load_from_iac_code_config()
92
+ if cred is not None:
93
+ return cred
94
+
95
+ # Fall back to aliyun CLI config
96
+ return AliyunCredentials._load_from_aliyun_cli(config_path)
97
+
98
+ @staticmethod
99
+ def _load_from_iac_code_config() -> AliyunCredential | None:
100
+ """Load credentials from ~/.iac-code/.cloud-credentials.yml."""
101
+ cloud_creds = _load_yaml(get_cloud_credentials_path())
102
+ aliyun_data = cloud_creds.get("aliyun")
103
+ if not aliyun_data or not isinstance(aliyun_data, dict):
104
+ return None
105
+
106
+ mode = aliyun_data.get("mode", "AK")
107
+ if mode not in CREDENTIAL_MODES:
108
+ return None
109
+
110
+ return AliyunCredential(
111
+ mode=mode,
112
+ access_key_id=aliyun_data.get("access_key_id", ""),
113
+ access_key_secret=aliyun_data.get("access_key_secret", ""),
114
+ region_id=aliyun_data.get("region_id", DEFAULT_REGION),
115
+ sts_token=aliyun_data.get("sts_token", ""),
116
+ ram_role_arn=aliyun_data.get("ram_role_arn", ""),
117
+ ram_session_name=aliyun_data.get("ram_session_name", ""),
118
+ )
119
+
120
+ @staticmethod
121
+ def _load_from_aliyun_cli(config_path: str | None = None) -> AliyunCredential | None:
122
+ """Load credentials from aliyun CLI config file (~/.aliyun/config.json)."""
123
+ path = Path(config_path) if config_path else Path(DEFAULT_ALIYUN_CLI_CONFIG_PATH)
124
+ if not path.exists():
125
+ return None
126
+
127
+ try:
128
+ data = json.loads(path.read_text())
129
+ except (json.JSONDecodeError, OSError):
130
+ return None
131
+
132
+ profiles = {p["name"]: p for p in data.get("profiles", [])}
133
+ profile = profiles.get("default")
134
+ if not profile:
135
+ return None
136
+
137
+ mode = profile.get("mode", "AK")
138
+ return AliyunCredential(
139
+ mode=mode,
140
+ access_key_id=profile.get("access_key_id", ""),
141
+ access_key_secret=profile.get("access_key_secret", ""),
142
+ region_id=profile.get("region_id", DEFAULT_REGION),
143
+ sts_token=profile.get("sts_token", ""),
144
+ ram_role_arn=profile.get("ram_role_arn", ""),
145
+ ram_session_name=profile.get("ram_session_name", ""),
146
+ )
147
+
148
+ @staticmethod
149
+ def load_from_aliyun_cli(config_path: str | None = None) -> AliyunCredential | None:
150
+ """Public method to load credentials from aliyun CLI config only.
151
+
152
+ Used by auth UI to display existing aliyun CLI config with masking.
153
+ """
154
+ return AliyunCredentials._load_from_aliyun_cli(config_path)
155
+
156
+ @staticmethod
157
+ def save(
158
+ credential: AliyunCredential,
159
+ config_path: str | None = None,
160
+ ) -> None:
161
+ """Save credentials to ~/.iac-code/.cloud-credentials.yml."""
162
+ if config_path is not None:
163
+ # For testing: save to specified path in aliyun CLI format
164
+ AliyunCredentials._save_to_aliyun_cli_format(credential, config_path)
165
+ return
166
+
167
+ path = get_cloud_credentials_path()
168
+ cloud_creds = _load_yaml(path)
169
+
170
+ aliyun_data: dict[str, Any] = {
171
+ "mode": credential.mode,
172
+ "region_id": credential.region_id,
173
+ }
174
+
175
+ # Save fields relevant to the credential mode
176
+ mode_fields = MODE_FIELDS.get(credential.mode, [])
177
+ for field_name, _label, _sensitive in mode_fields:
178
+ aliyun_data[field_name] = getattr(credential, field_name, "")
179
+
180
+ cloud_creds["aliyun"] = aliyun_data
181
+ _save_yaml(path, cloud_creds)
182
+
183
+ @staticmethod
184
+ def _save_to_aliyun_cli_format(credential: AliyunCredential, config_path: str) -> None:
185
+ """Save credentials in aliyun CLI JSON format (for testing)."""
186
+ from typing import cast
187
+
188
+ path = Path(config_path)
189
+ path.parent.mkdir(parents=True, exist_ok=True)
190
+
191
+ data: dict[str, object] = {"current": "default", "profiles": []}
192
+ if path.exists():
193
+ try:
194
+ loaded = json.loads(path.read_text())
195
+ if isinstance(loaded, dict):
196
+ data = loaded
197
+ except (json.JSONDecodeError, OSError):
198
+ pass
199
+
200
+ updated_profile: dict[str, str] = {
201
+ "name": "default",
202
+ "mode": credential.mode,
203
+ "access_key_id": credential.access_key_id,
204
+ "access_key_secret": credential.access_key_secret,
205
+ "region_id": credential.region_id,
206
+ "sts_token": credential.sts_token,
207
+ "ram_role_arn": credential.ram_role_arn,
208
+ "ram_session_name": credential.ram_session_name,
209
+ }
210
+
211
+ raw_profiles = data.get("profiles")
212
+ profiles: list[dict[str, str]] = (
213
+ cast(list[dict[str, str]], raw_profiles) if isinstance(raw_profiles, list) else []
214
+ )
215
+
216
+ for i, profile in enumerate(profiles):
217
+ if isinstance(profile, dict) and profile.get("name") == "default":
218
+ profiles[i] = updated_profile
219
+ break
220
+ else:
221
+ profiles.append(updated_profile)
222
+
223
+ data["profiles"] = profiles
224
+ if "current" not in data:
225
+ data["current"] = "default"
226
+
227
+ path.write_text(json.dumps(data, indent=2, ensure_ascii=False))
228
+
229
+ @staticmethod
230
+ def is_configured(config_path: str | None = None) -> bool:
231
+ """Check if credentials are available."""
232
+ return AliyunCredentials.load(config_path=config_path) is not None