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,77 @@
1
+ """Token extractor for suggestion triggers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ from iac_code.ui.suggestions.types import CompletionToken
8
+
9
+ # Characters that can form part of a token
10
+ _TOKEN_CHARS = re.compile(r"[\w._\-/\\~@#!]")
11
+
12
+
13
+ def _is_token_char(ch: str) -> bool:
14
+ return bool(_TOKEN_CHARS.match(ch))
15
+
16
+
17
+ class TokenExtractor:
18
+ """Extracts completion tokens from input text based on cursor position."""
19
+
20
+ def extract(self, text: str, cursor_pos: int) -> CompletionToken | None:
21
+ """Walk backwards from cursor_pos to find a completion token.
22
+
23
+ Returns a CompletionToken if a valid trigger is found, else None.
24
+ """
25
+ if not text or cursor_pos == 0:
26
+ return None
27
+
28
+ # Clamp cursor_pos to valid range
29
+ end = min(cursor_pos, len(text))
30
+
31
+ # Walk backwards to find start of token
32
+ token_start = end
33
+ while token_start > 0 and _is_token_char(text[token_start - 1]):
34
+ token_start -= 1
35
+
36
+ if token_start == end:
37
+ # No token characters before cursor
38
+ return None
39
+
40
+ token_text = text[token_start:end]
41
+
42
+ if not token_text:
43
+ return None
44
+
45
+ first_char = token_text[0]
46
+
47
+ if first_char == "/":
48
+ # "/" trigger: only valid at line start or after whitespace
49
+ if token_start == 0 or text[token_start - 1] in (" ", "\t", "\n"):
50
+ return CompletionToken(
51
+ text=token_text,
52
+ start=token_start,
53
+ end=end,
54
+ trigger="/",
55
+ )
56
+ return None
57
+
58
+ if first_char == "@":
59
+ return CompletionToken(
60
+ text=token_text,
61
+ start=token_start,
62
+ end=end,
63
+ trigger="@",
64
+ )
65
+
66
+ if first_char == "!":
67
+ # "!" trigger: only valid at line start
68
+ if token_start == 0:
69
+ return CompletionToken(
70
+ text=token_text,
71
+ start=token_start,
72
+ end=end,
73
+ trigger="!",
74
+ )
75
+ return None
76
+
77
+ return None
@@ -0,0 +1,45 @@
1
+ """Suggestion system types."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+ from dataclasses import dataclass
7
+
8
+
9
+ @dataclass(frozen=True, slots=True)
10
+ class CompletionToken:
11
+ """A token extracted from user input that triggers suggestions."""
12
+
13
+ text: str # e.g. "/mod" or "@src/u"
14
+ start: int # start position in input
15
+ end: int # end position in input
16
+ trigger: str # "/" | "@" | "!"
17
+
18
+
19
+ @dataclass(slots=True)
20
+ class SuggestionItem:
21
+ """A single suggestion shown in the overlay."""
22
+
23
+ id: str # e.g. "cmd:model", "file:src/ui/input.py"
24
+ display_text: str
25
+ completion: str # full text after completion
26
+ description: str
27
+ icon: str # "/" command, "+" file, "◇" directory, "↑" history
28
+ source: str # "command" | "file" | "directory" | "shell"
29
+ score: float
30
+ arg_hint: str | None = None # inline ghost-text hint shown after the full command
31
+
32
+
33
+ class SuggestionProvider(ABC):
34
+ """Base class for suggestion providers."""
35
+
36
+ @property
37
+ @abstractmethod
38
+ def trigger(self) -> str:
39
+ """The trigger character(s) for this provider."""
40
+ ...
41
+
42
+ @abstractmethod
43
+ def provide(self, token: CompletionToken) -> list[SuggestionItem]:
44
+ """Return suggestions for the given token."""
45
+ ...
@@ -0,0 +1,199 @@
1
+ """Alternate-screen transcript viewer for Ctrl+O.
2
+
3
+ Renders the whole conversation with all tool calls expanded (sub-agent
4
+ children fully listed, subagent prompts shown) while keeping tool *results*
5
+ compact — no full file dumps. Ctrl+O enters, Ctrl+O/Esc/Ctrl+C exits, no
6
+ scrolling. If the content overflows the viewport, the oldest rows are
7
+ dropped first.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import TYPE_CHECKING
13
+
14
+ from rich.text import Text
15
+
16
+ from iac_code.i18n import _
17
+ from iac_code.ui.core.key_event import KeyEvent
18
+ from iac_code.ui.core.raw_input import RawInputCapture
19
+ from iac_code.ui.core.screen import ScreenManager
20
+
21
+ if TYPE_CHECKING:
22
+ from iac_code.ui.renderer import Renderer, _Segment, _ToolCallRecord
23
+
24
+
25
+ class TranscriptView:
26
+ """Modal transcript view rendered in the alternate screen buffer."""
27
+
28
+ def __init__(
29
+ self,
30
+ renderer: "Renderer",
31
+ current_segments: "list[_Segment] | None" = None,
32
+ ) -> None:
33
+ self._renderer = renderer
34
+ self._console = renderer.console
35
+ self._screen = ScreenManager(self._console)
36
+ # Segments of the in-progress turn that haven't been archived yet
37
+ # (typically present when Ctrl+O is pressed mid-stream).
38
+ self._current_segments = list(current_segments) if current_segments else []
39
+
40
+ # ── Public entry ──────────────────────────────────────────────────
41
+
42
+ def run(self) -> None:
43
+ lines = self._render_lines()
44
+ if not lines:
45
+ return
46
+
47
+ self._screen.enter_alternate_screen()
48
+ try:
49
+ with RawInputCapture() as cap:
50
+ self._draw(lines)
51
+ while True:
52
+ event = cap.read_key(timeout=None)
53
+ if event is None:
54
+ continue
55
+ if self._should_exit(event):
56
+ break
57
+ finally:
58
+ self._screen.leave_alternate_screen()
59
+
60
+ # ── Rendering ─────────────────────────────────────────────────────
61
+
62
+ def _render_lines(self) -> list[str]:
63
+ """Render every turn and return a list of terminal rows."""
64
+ r = self._renderer
65
+ with self._console.capture() as cap:
66
+ first = True
67
+ for turn in r._message_history:
68
+ if not first:
69
+ self._console.print()
70
+ first = False
71
+ if turn.role == "user":
72
+ line = Text()
73
+ line.append("❯ ", style="bold cyan")
74
+ line.append(turn.text)
75
+ self._console.print(line)
76
+ else:
77
+ self._render_assistant_turn(turn.segments)
78
+
79
+ # Un-archived live segments from the currently streaming turn.
80
+ if self._current_segments:
81
+ # Only emit a separator if there's prior history AND the
82
+ # last history turn wasn't already an assistant turn being
83
+ # extended (we'd end up double-spacing otherwise).
84
+ if not first:
85
+ last = r._message_history[-1] if r._message_history else None
86
+ if last is None or last.role == "user":
87
+ self._console.print()
88
+ self._render_assistant_turn(self._current_segments)
89
+
90
+ raw = cap.get()
91
+ lines = raw.split("\n")
92
+ if lines and lines[-1] == "":
93
+ lines.pop()
94
+ return lines
95
+
96
+ def _render_assistant_turn(self, segments: "list[_Segment]") -> None:
97
+ r = self._renderer
98
+ has_content = False
99
+ text_flushed = False
100
+ for seg in segments:
101
+ if seg.kind == "text" and seg.text:
102
+ if has_content:
103
+ self._console.print()
104
+ for part in r._render_text_block(seg.text, continuation=text_flushed):
105
+ self._console.print(part)
106
+ text_flushed = True
107
+ has_content = True
108
+ elif seg.kind == "tool" and seg.tool:
109
+ if has_content:
110
+ self._console.print()
111
+ self._render_tool(seg.tool)
112
+ has_content = True
113
+ text_flushed = False
114
+
115
+ def _render_tool(self, rec: "_ToolCallRecord") -> None:
116
+ """Print one tool call: verbose header (all children), compact result.
117
+
118
+ For agent-style tools the sub-agent prompt is inserted between the
119
+ tool-name line and the child-tool tree so the reader sees *what was
120
+ asked* before *what ran*.
121
+ """
122
+ r = self._renderer
123
+ # Header with verbose=True so every sub-agent child is listed (not
124
+ # capped at 3) and tool-use detail is fully shown.
125
+ saved = r._verbose
126
+ r._verbose = True
127
+ try:
128
+ header = r._render_tool_header(rec)
129
+ finally:
130
+ r._verbose = saved
131
+
132
+ # _render_tool_header returns a single Text with embedded newlines —
133
+ # first line is "● Tool(detail)", the rest are child-tool rows.
134
+ # Split so we can slide the prompt block in between.
135
+ header_lines = header.split("\n")
136
+ if header_lines:
137
+ self._console.print(header_lines[0])
138
+ self._render_subagent_prompt(rec)
139
+ for line in header_lines[1:]:
140
+ self._console.print(line)
141
+
142
+ # Result stays compact so we never dump full file contents.
143
+ result_line = r._render_tool_result(rec)
144
+ if result_line:
145
+ self._console.print(result_line)
146
+
147
+ def _render_subagent_prompt(self, rec: "_ToolCallRecord") -> None:
148
+ """For agent-style tools, print the prompt handed to the subagent."""
149
+ prompt = ""
150
+ if isinstance(rec.tool_input, dict):
151
+ raw = rec.tool_input.get("prompt")
152
+ if isinstance(raw, str):
153
+ prompt = raw.strip()
154
+ if not prompt:
155
+ return
156
+ label = Text()
157
+ label.append(" ⎿ ", style="dim")
158
+ label.append(_("Prompt:"), style="bold dim")
159
+ self._console.print(label)
160
+ for raw_line in prompt.splitlines() or [""]:
161
+ row = Text(" ", style="dim")
162
+ row.append(raw_line, style="dim")
163
+ self._console.print(row)
164
+
165
+ # ── Drawing ───────────────────────────────────────────────────────
166
+
167
+ def _draw(self, lines: list[str]) -> None:
168
+ _cols, rows = self._screen.get_size()
169
+ # Last row is the footer; leave one blank row above it as a spacer.
170
+ content_rows = max(1, rows - 2)
171
+ visible = lines[-content_rows:] if len(lines) > content_rows else lines
172
+
173
+ out = self._console.file
174
+ out.write("\x1b[H\x1b[2J")
175
+ for line in visible:
176
+ out.write(line)
177
+ out.write("\r\n")
178
+ for _i in range(content_rows - len(visible)):
179
+ out.write("\r\n")
180
+ # Spacer row before the footer.
181
+ out.write("\r\n")
182
+ out.write(self._footer(rows))
183
+ out.flush()
184
+
185
+ def _footer(self, rows: int) -> str:
186
+ hint = _("Showing transcript · ctrl+o to toggle")
187
+ # `\x1b[K` clears the rest of the row so no left-over characters
188
+ # remain after the hint (simpler + CJK-safe than padding with spaces,
189
+ # which len() measures wrong for wide glyphs).
190
+ return f"\x1b[{rows};1H\x1b[2K\x1b[2m{hint}\x1b[0m"
191
+
192
+ # ── Input ─────────────────────────────────────────────────────────
193
+
194
+ def _should_exit(self, event: KeyEvent) -> bool:
195
+ if event.ctrl and event.key in ("o", "c"):
196
+ return True
197
+ if event.key == "escape":
198
+ return True
199
+ return False
File without changes
@@ -0,0 +1,53 @@
1
+ """Background housekeeping — delayed cleanup of old tool result files."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import threading
6
+ import time
7
+
8
+ from loguru import logger
9
+
10
+ from iac_code.utils.cleanup import cleanup_old_session_files
11
+
12
+ # Delay before running cleanup after session starts (seconds).
13
+ DELAY_SECONDS = 10 * 60 # 10 minutes
14
+
15
+ _BASE_DIR = None
16
+
17
+
18
+ def _get_default_base_dir() -> str:
19
+ from iac_code.config import get_config_dir
20
+
21
+ return _BASE_DIR or str(get_config_dir() / "tool-results")
22
+
23
+
24
+ def _run_cleanup(base_dir: str, delay_seconds: float) -> None:
25
+ time.sleep(delay_seconds)
26
+ try:
27
+ result = cleanup_old_session_files(base_dir)
28
+ if result["deleted"] > 0:
29
+ logger.debug(
30
+ "Background cleanup: deleted {} expired tool result file(s)",
31
+ result["deleted"],
32
+ )
33
+ except Exception:
34
+ logger.opt(exception=True).debug("Background cleanup failed")
35
+
36
+
37
+ def start_background_housekeeping(
38
+ base_dir: str | None = None,
39
+ delay_seconds: float = DELAY_SECONDS,
40
+ ) -> threading.Thread:
41
+ """Start a daemon thread that cleans up old tool result files after a delay.
42
+
43
+ Returns the thread so callers can join() in tests.
44
+ """
45
+ target_dir = base_dir or _get_default_base_dir()
46
+ thread = threading.Thread(
47
+ target=_run_cleanup,
48
+ args=(target_dir, delay_seconds),
49
+ daemon=True,
50
+ name="iac-code-housekeeping",
51
+ )
52
+ thread.start()
53
+ return thread
@@ -0,0 +1,68 @@
1
+ """Clean up old tool result files from previous sessions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import time
7
+
8
+ DEFAULT_CLEANUP_PERIOD_DAYS = 30
9
+
10
+
11
+ def cleanup_old_session_files(
12
+ base_dir: str,
13
+ max_age_days: int = DEFAULT_CLEANUP_PERIOD_DAYS,
14
+ ) -> dict[str, int]:
15
+ """Delete tool result files older than *max_age_days* under *base_dir*.
16
+
17
+ Directory layout expected::
18
+
19
+ base_dir/
20
+ <session_id>/
21
+ <tool_use_id>.txt
22
+ ...
23
+
24
+ Returns a dict with ``deleted`` and ``errors`` counts.
25
+ """
26
+ result: dict[str, int] = {"deleted": 0, "errors": 0}
27
+ cutoff = time.time() - max_age_days * 86400
28
+
29
+ try:
30
+ session_names = os.listdir(base_dir)
31
+ except FileNotFoundError:
32
+ return result
33
+
34
+ for session_name in session_names:
35
+ session_dir = os.path.join(base_dir, session_name)
36
+ if not os.path.isdir(session_dir):
37
+ continue
38
+
39
+ try:
40
+ filenames = os.listdir(session_dir)
41
+ except OSError:
42
+ result["errors"] += 1
43
+ continue
44
+
45
+ for filename in filenames:
46
+ file_path = os.path.join(session_dir, filename)
47
+ if not os.path.isfile(file_path):
48
+ continue
49
+ try:
50
+ if os.stat(file_path).st_mtime < cutoff:
51
+ os.remove(file_path)
52
+ result["deleted"] += 1
53
+ except OSError:
54
+ result["errors"] += 1
55
+
56
+ # Remove empty session directory
57
+ try:
58
+ os.rmdir(session_dir)
59
+ except OSError:
60
+ pass # not empty or already removed
61
+
62
+ # Remove base_dir if empty
63
+ try:
64
+ os.rmdir(base_dir)
65
+ except OSError:
66
+ pass
67
+
68
+ return result
@@ -0,0 +1,60 @@
1
+ """Safe JSON parsing utilities.
2
+
3
+ Design:
4
+ - safe_parse_json() never raises exceptions
5
+ - Returns None on failure or empty/None input (caller decides fallback)
6
+ - Logs debug when non-empty input fails to parse (callers handle warning)
7
+ - parse_concatenated_json() handles model edge case where multiple JSON
8
+ objects are concatenated (e.g. '{"a":1}{"b":2}'), indicating the model
9
+ intended parallel tool calls with different parameters.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ from typing import Any
16
+
17
+ from loguru import logger
18
+
19
+
20
+ def safe_parse_json(raw: str | None) -> Any | None:
21
+ """Parse a JSON string safely, never raises.
22
+
23
+ Returns:
24
+ Parsed value on success, None on failure or empty/None input.
25
+ """
26
+ if not raw:
27
+ return None
28
+ try:
29
+ return json.loads(raw)
30
+ except (json.JSONDecodeError, ValueError):
31
+ logger.error("Failed to parse JSON, raw=%s", raw[:200])
32
+ return None
33
+
34
+
35
+ def parse_concatenated_json(raw: str) -> list[dict[str, Any]]:
36
+ """Parse concatenated JSON objects like '{"a":1}{"b":2}' into a list.
37
+
38
+ Uses json.JSONDecoder.raw_decode to read one object at a time.
39
+
40
+ Returns:
41
+ List of parsed dicts. Empty list if nothing could be parsed.
42
+ """
43
+ decoder = json.JSONDecoder()
44
+ results: list[dict[str, Any]] = []
45
+ pos = 0
46
+ length = len(raw)
47
+ while pos < length:
48
+ # Skip whitespace
49
+ while pos < length and raw[pos] in " \t\n\r":
50
+ pos += 1
51
+ if pos >= length:
52
+ break
53
+ try:
54
+ obj, end_pos = decoder.raw_decode(raw, pos)
55
+ if isinstance(obj, dict):
56
+ results.append(obj)
57
+ pos = end_pos
58
+ except json.JSONDecodeError:
59
+ break
60
+ return results
iac_code/utils/log.py ADDED
@@ -0,0 +1,150 @@
1
+ """Logging setup for iac-code using loguru."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from pathlib import Path
7
+
8
+ from loguru import logger
9
+
10
+ from iac_code.config import get_config_dir
11
+
12
+ _LOG_FORMAT = "{time:YYYY-MM-DDTHH:mm:ss.SSS} [{level:<5}] {name}:{function}:{line} - {message}"
13
+
14
+ _startup_handler_id: int | None = None
15
+ _runtime_debug_handler_ids: list[int] = []
16
+ _debug_enabled: bool = False
17
+ _current_log_file: Path | None = None
18
+
19
+
20
+ class _StdlibToLoguruHandler(logging.Handler):
21
+ """Route stdlib logging records to loguru so OTel SDK logs are visible."""
22
+
23
+ _LEVEL_MAP = {
24
+ logging.DEBUG: "DEBUG",
25
+ logging.INFO: "INFO",
26
+ logging.WARNING: "WARNING",
27
+ logging.ERROR: "ERROR",
28
+ logging.CRITICAL: "CRITICAL",
29
+ }
30
+
31
+ def emit(self, record: logging.LogRecord) -> None:
32
+ level = self._LEVEL_MAP.get(record.levelno, "INFO")
33
+ logger.opt(depth=6, exception=record.exc_info).log(level, record.getMessage())
34
+
35
+
36
+ def setup_logging(
37
+ session_id: str,
38
+ debug: bool = False,
39
+ ) -> None:
40
+ """Configure loguru for the application.
41
+
42
+ Args:
43
+ session_id: Current session ID, used in log filenames.
44
+ debug: Enable debug file logging.
45
+ """
46
+ global _startup_handler_id, _runtime_debug_handler_ids, _debug_enabled, _current_log_file
47
+
48
+ logger.remove()
49
+ _runtime_debug_handler_ids = []
50
+
51
+ log_dir = get_config_dir() / "logs"
52
+ log_dir.mkdir(parents=True, exist_ok=True)
53
+ log_file = log_dir / f"{session_id}.log"
54
+ level = "DEBUG" if debug else "INFO"
55
+
56
+ _startup_handler_id = logger.add(
57
+ str(log_file),
58
+ level=level,
59
+ format=_LOG_FORMAT,
60
+ encoding="utf-8",
61
+ )
62
+ _debug_enabled = debug
63
+ _current_log_file = log_file
64
+
65
+ latest = log_dir / "latest.log"
66
+ latest.unlink(missing_ok=True)
67
+ latest.symlink_to(log_file.name)
68
+
69
+ _install_stdlib_bridge()
70
+
71
+
72
+ def _install_stdlib_bridge() -> None:
73
+ """Install the stdlib → loguru bridge on key namespaces."""
74
+ handler = _StdlibToLoguruHandler()
75
+ for name in ("opentelemetry", "iac_code"):
76
+ stdlib_logger = logging.getLogger(name)
77
+ if not any(isinstance(h, _StdlibToLoguruHandler) for h in stdlib_logger.handlers):
78
+ stdlib_logger.addHandler(handler)
79
+ stdlib_logger.setLevel(logging.DEBUG)
80
+
81
+
82
+ def enable_debug_at_runtime(session_id: str) -> Path:
83
+ """Enable debug logging mid-session (for /debug command).
84
+
85
+ Returns:
86
+ Path to the log file.
87
+ """
88
+ global _debug_enabled, _current_log_file
89
+
90
+ log_dir = get_config_dir() / "logs"
91
+ log_dir.mkdir(parents=True, exist_ok=True)
92
+ log_file = log_dir / f"{session_id}.log"
93
+ _current_log_file = log_file
94
+
95
+ if _debug_enabled:
96
+ return log_file
97
+
98
+ handler_id = logger.add(
99
+ str(log_file),
100
+ level="DEBUG",
101
+ format=_LOG_FORMAT,
102
+ encoding="utf-8",
103
+ )
104
+ _runtime_debug_handler_ids.append(handler_id)
105
+ _debug_enabled = True
106
+
107
+ latest = log_dir / "latest.log"
108
+ latest.unlink(missing_ok=True)
109
+ latest.symlink_to(log_file.name)
110
+
111
+ return log_file
112
+
113
+
114
+ def disable_debug_at_runtime() -> None:
115
+ """Disable debug logging mid-session (for /debug off)."""
116
+ global _debug_enabled, _startup_handler_id, _runtime_debug_handler_ids
117
+
118
+ if not _debug_enabled:
119
+ return
120
+
121
+ for hid in _runtime_debug_handler_ids:
122
+ try:
123
+ logger.remove(hid)
124
+ except ValueError:
125
+ pass
126
+ _runtime_debug_handler_ids = []
127
+
128
+ if _startup_handler_id is not None and _current_log_file is not None:
129
+ try:
130
+ logger.remove(_startup_handler_id)
131
+ except ValueError:
132
+ pass
133
+ _startup_handler_id = logger.add(
134
+ str(_current_log_file),
135
+ level="INFO",
136
+ format=_LOG_FORMAT,
137
+ encoding="utf-8",
138
+ )
139
+
140
+ _debug_enabled = False
141
+
142
+
143
+ def is_debug_enabled() -> bool:
144
+ """Return whether debug-level logging is currently active."""
145
+ return _debug_enabled
146
+
147
+
148
+ def current_log_file() -> Path | None:
149
+ """Return the current session log file path, if setup_logging has been called."""
150
+ return _current_log_file