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.
- iac_code/__init__.py +2 -0
- iac_code/acp/__init__.py +97 -0
- iac_code/acp/convert.py +423 -0
- iac_code/acp/http_sse.py +448 -0
- iac_code/acp/mcp.py +54 -0
- iac_code/acp/metrics.py +71 -0
- iac_code/acp/server.py +662 -0
- iac_code/acp/session.py +446 -0
- iac_code/acp/slash_registry.py +125 -0
- iac_code/acp/state.py +99 -0
- iac_code/acp/tools.py +112 -0
- iac_code/acp/types.py +13 -0
- iac_code/acp/version.py +26 -0
- iac_code/agent/__init__.py +19 -0
- iac_code/agent/agent_loop.py +640 -0
- iac_code/agent/agent_tool.py +269 -0
- iac_code/agent/agent_types.py +87 -0
- iac_code/agent/message.py +153 -0
- iac_code/agent/system_prompt.py +313 -0
- iac_code/cli/__init__.py +3 -0
- iac_code/cli/headless.py +114 -0
- iac_code/cli/main.py +246 -0
- iac_code/cli/output_formats.py +125 -0
- iac_code/commands/__init__.py +93 -0
- iac_code/commands/auth.py +1055 -0
- iac_code/commands/clear.py +34 -0
- iac_code/commands/compact.py +43 -0
- iac_code/commands/debug.py +45 -0
- iac_code/commands/effort.py +116 -0
- iac_code/commands/exit.py +10 -0
- iac_code/commands/help.py +49 -0
- iac_code/commands/model.py +130 -0
- iac_code/commands/registry.py +245 -0
- iac_code/commands/resume.py +49 -0
- iac_code/commands/tasks.py +41 -0
- iac_code/config.py +304 -0
- iac_code/i18n/__init__.py +141 -0
- iac_code/i18n/locales/zh/LC_MESSAGES/messages.po +1355 -0
- iac_code/memory/__init__.py +1 -0
- iac_code/memory/memory_manager.py +92 -0
- iac_code/memory/memory_tools.py +88 -0
- iac_code/providers/__init__.py +1 -0
- iac_code/providers/anthropic_provider.py +284 -0
- iac_code/providers/base.py +128 -0
- iac_code/providers/dashscope_provider.py +47 -0
- iac_code/providers/deepseek_provider.py +36 -0
- iac_code/providers/manager.py +399 -0
- iac_code/providers/openai_provider.py +344 -0
- iac_code/providers/retry.py +58 -0
- iac_code/providers/stream_watchdog.py +47 -0
- iac_code/providers/thinking.py +164 -0
- iac_code/services/__init__.py +1 -0
- iac_code/services/agent_factory.py +127 -0
- iac_code/services/cloud_credentials.py +22 -0
- iac_code/services/context_manager.py +221 -0
- iac_code/services/providers/__init__.py +1 -0
- iac_code/services/providers/aliyun.py +232 -0
- iac_code/services/session_index.py +281 -0
- iac_code/services/session_storage.py +245 -0
- iac_code/services/telemetry/__init__.py +66 -0
- iac_code/services/telemetry/attributes.py +84 -0
- iac_code/services/telemetry/client.py +330 -0
- iac_code/services/telemetry/config.py +76 -0
- iac_code/services/telemetry/constants.py +75 -0
- iac_code/services/telemetry/content_serializer.py +124 -0
- iac_code/services/telemetry/events.py +42 -0
- iac_code/services/telemetry/fallback.py +59 -0
- iac_code/services/telemetry/identity.py +73 -0
- iac_code/services/telemetry/metrics.py +62 -0
- iac_code/services/telemetry/names.py +199 -0
- iac_code/services/telemetry/sanitize.py +88 -0
- iac_code/services/telemetry/sink.py +67 -0
- iac_code/services/telemetry/tracing.py +38 -0
- iac_code/services/telemetry/types.py +13 -0
- iac_code/services/token_budget.py +54 -0
- iac_code/services/token_counter.py +76 -0
- iac_code/skills/__init__.py +1 -0
- iac_code/skills/bundled/__init__.py +94 -0
- iac_code/skills/bundled/iac_aliyun/SKILL.md +192 -0
- iac_code/skills/bundled/iac_aliyun/__init__.py +16 -0
- iac_code/skills/bundled/iac_aliyun/references/cloud-products/ecs.md +167 -0
- iac_code/skills/bundled/iac_aliyun/references/cloud-products/oss.md +69 -0
- iac_code/skills/bundled/iac_aliyun/references/cloud-products/rds.md +95 -0
- iac_code/skills/bundled/iac_aliyun/references/cloud-products/redis.md +100 -0
- iac_code/skills/bundled/iac_aliyun/references/cloud-products/slb.md +60 -0
- iac_code/skills/bundled/iac_aliyun/references/cloud-products/vpc.md +54 -0
- iac_code/skills/bundled/iac_aliyun/references/ros-template.md +155 -0
- iac_code/skills/bundled/iac_aliyun/references/template-parameters.md +206 -0
- iac_code/skills/bundled/iac_aliyun/references/terraform-template.md +101 -0
- iac_code/skills/bundled/iac_aliyun/scripts/tf2ros.py +77 -0
- iac_code/skills/bundled/simplify.py +28 -0
- iac_code/skills/discovery.py +136 -0
- iac_code/skills/frontmatter.py +119 -0
- iac_code/skills/listing.py +92 -0
- iac_code/skills/loader.py +42 -0
- iac_code/skills/processor.py +81 -0
- iac_code/skills/renderer.py +157 -0
- iac_code/skills/skill_definition.py +82 -0
- iac_code/skills/skill_tool.py +261 -0
- iac_code/state/__init__.py +5 -0
- iac_code/state/app_state.py +122 -0
- iac_code/tasks/__init__.py +1 -0
- iac_code/tasks/notification_queue.py +28 -0
- iac_code/tasks/task_state.py +66 -0
- iac_code/tasks/task_tools.py +114 -0
- iac_code/tools/__init__.py +8 -0
- iac_code/tools/base.py +226 -0
- iac_code/tools/bash.py +133 -0
- iac_code/tools/cloud/__init__.py +0 -0
- iac_code/tools/cloud/aliyun/__init__.py +0 -0
- iac_code/tools/cloud/aliyun/aliyun_api.py +510 -0
- iac_code/tools/cloud/aliyun/aliyun_doc_search.py +145 -0
- iac_code/tools/cloud/aliyun/endpoints.yml +343 -0
- iac_code/tools/cloud/aliyun/ros_client.py +56 -0
- iac_code/tools/cloud/aliyun/ros_stack.py +633 -0
- iac_code/tools/cloud/aliyun/ros_stack_instances.py +247 -0
- iac_code/tools/cloud/base_api.py +162 -0
- iac_code/tools/cloud/base_stack.py +242 -0
- iac_code/tools/cloud/registry.py +20 -0
- iac_code/tools/cloud/types.py +105 -0
- iac_code/tools/edit_file.py +121 -0
- iac_code/tools/glob.py +103 -0
- iac_code/tools/grep.py +254 -0
- iac_code/tools/list_files.py +104 -0
- iac_code/tools/read_file.py +127 -0
- iac_code/tools/result_storage.py +39 -0
- iac_code/tools/tool_executor.py +165 -0
- iac_code/tools/web_fetch.py +177 -0
- iac_code/tools/write_file.py +88 -0
- iac_code/types/__init__.py +40 -0
- iac_code/types/permissions.py +26 -0
- iac_code/types/skill_source.py +11 -0
- iac_code/types/stream_events.py +227 -0
- iac_code/ui/__init__.py +5 -0
- iac_code/ui/banner.py +110 -0
- iac_code/ui/components/__init__.py +0 -0
- iac_code/ui/components/dialog.py +142 -0
- iac_code/ui/components/divider.py +20 -0
- iac_code/ui/components/fuzzy_picker.py +308 -0
- iac_code/ui/components/progress_bar.py +54 -0
- iac_code/ui/components/search_box.py +165 -0
- iac_code/ui/components/select.py +319 -0
- iac_code/ui/components/status_icon.py +42 -0
- iac_code/ui/components/tabs.py +128 -0
- iac_code/ui/core/__init__.py +0 -0
- iac_code/ui/core/in_place_render.py +129 -0
- iac_code/ui/core/input_history.py +118 -0
- iac_code/ui/core/key_event.py +41 -0
- iac_code/ui/core/prompt_input.py +507 -0
- iac_code/ui/core/raw_input.py +302 -0
- iac_code/ui/core/screen.py +80 -0
- iac_code/ui/dialogs/__init__.py +0 -0
- iac_code/ui/dialogs/global_search.py +178 -0
- iac_code/ui/dialogs/history_search.py +100 -0
- iac_code/ui/dialogs/model_picker.py +280 -0
- iac_code/ui/dialogs/quick_open.py +108 -0
- iac_code/ui/dialogs/resume_picker.py +749 -0
- iac_code/ui/keybindings/__init__.py +0 -0
- iac_code/ui/keybindings/manager.py +124 -0
- iac_code/ui/renderer.py +1535 -0
- iac_code/ui/repl.py +772 -0
- iac_code/ui/spinner.py +112 -0
- iac_code/ui/suggestions/__init__.py +0 -0
- iac_code/ui/suggestions/aggregator.py +171 -0
- iac_code/ui/suggestions/command_provider.py +43 -0
- iac_code/ui/suggestions/directory_provider.py +95 -0
- iac_code/ui/suggestions/file_provider.py +121 -0
- iac_code/ui/suggestions/shell_history_provider.py +108 -0
- iac_code/ui/suggestions/token_extractor.py +77 -0
- iac_code/ui/suggestions/types.py +45 -0
- iac_code/ui/transcript_view.py +199 -0
- iac_code/utils/__init__.py +0 -0
- iac_code/utils/background_housekeeping.py +53 -0
- iac_code/utils/cleanup.py +68 -0
- iac_code/utils/json_utils.py +60 -0
- iac_code/utils/log.py +150 -0
- iac_code/utils/project_paths.py +74 -0
- iac_code/utils/tool_input_parser.py +62 -0
- iac_code-0.1.0.dist-info/LICENSE +201 -0
- iac_code-0.1.0.dist-info/METADATA +64 -0
- iac_code-0.1.0.dist-info/RECORD +184 -0
- iac_code-0.1.0.dist-info/WHEEL +5 -0
- iac_code-0.1.0.dist-info/entry_points.txt +2 -0
- 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
|