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,281 @@
1
+ """Lite session metadata index for the /resume picker.
2
+
3
+ Reads only the first and last 64 KiB of each session JSONL file and
4
+ extracts metadata via string-search — never parses the whole file.
5
+ This keeps the picker fast even when individual sessions grow into the
6
+ megabytes.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ import re
14
+ from dataclasses import dataclass
15
+ from pathlib import Path
16
+
17
+ from iac_code.utils.project_paths import get_project_dir, get_projects_dir, sanitize_path
18
+
19
+ LITE_READ_BUF_SIZE = 64 * 1024
20
+
21
+
22
+ @dataclass
23
+ class LiteMetadata:
24
+ cwd: str | None = None
25
+ git_branch: str | None = None
26
+ last_prompt: str | None = None
27
+ first_prompt: str | None = None
28
+
29
+
30
+ @dataclass
31
+ class SessionEntry:
32
+ session_id: str
33
+ cwd: str
34
+ project_name: str
35
+ git_branch: str | None
36
+ title: str
37
+ mtime: float
38
+ size_bytes: int
39
+
40
+
41
+ # ---------------------------------------------------------------------------
42
+ # Field extraction helpers (string-search; tolerant of truncated chunks)
43
+ # ---------------------------------------------------------------------------
44
+
45
+
46
+ def _decode_json_string(raw: str) -> str:
47
+ """Decode a JSON string body, tolerating partial input.
48
+
49
+ ``raw`` is the substring between the opening and closing ``"`` of a
50
+ JSON string. We round-trip via :func:`json.loads` to honour escapes,
51
+ falling back to a manual unescape if the string was truncated.
52
+ """
53
+ try:
54
+ return json.loads(f'"{raw}"')
55
+ except json.JSONDecodeError:
56
+ return raw.replace(r"\n", "\n").replace(r"\t", "\t").replace(r"\"", '"').replace(r"\\", "\\")
57
+
58
+
59
+ def _scan_string_field(chunk: str, field: str, *, last: bool) -> str | None:
60
+ """Locate a JSON string field by name and return its decoded value."""
61
+ needle = f'"{field}":'
62
+ pos = chunk.rfind(needle) if last else chunk.find(needle)
63
+ if pos < 0:
64
+ return None
65
+ i = pos + len(needle)
66
+ n = len(chunk)
67
+ while i < n and chunk[i] in " \t":
68
+ i += 1
69
+ if i >= n or chunk[i] != '"':
70
+ return None
71
+ i += 1
72
+ start = i
73
+ while i < n:
74
+ ch = chunk[i]
75
+ if ch == "\\":
76
+ i += 2
77
+ continue
78
+ if ch == '"':
79
+ return _decode_json_string(chunk[start:i])
80
+ i += 1
81
+ # Unterminated (chunk truncated) — return what we have.
82
+ return _decode_json_string(chunk[start:])
83
+
84
+
85
+ def extract_first_json_string_field(chunk: str, field: str) -> str | None:
86
+ return _scan_string_field(chunk, field, last=False)
87
+
88
+
89
+ def extract_last_json_string_field(chunk: str, field: str) -> str | None:
90
+ return _scan_string_field(chunk, field, last=True)
91
+
92
+
93
+ # ---------------------------------------------------------------------------
94
+ # Head + tail file reader
95
+ # ---------------------------------------------------------------------------
96
+
97
+
98
+ def read_head_and_tail(path: Path, size: int | None = None) -> tuple[str, str]:
99
+ """Read the first and last :data:`LITE_READ_BUF_SIZE` bytes.
100
+
101
+ For files smaller than the buffer, ``head == tail`` and the whole
102
+ content is returned twice. Decoding is best-effort UTF-8 — partial
103
+ multibyte sequences at chunk edges are replaced.
104
+ """
105
+ actual_size = path.stat().st_size if size is None else size
106
+ with open(path, "rb") as f:
107
+ head_bytes = f.read(LITE_READ_BUF_SIZE)
108
+ if actual_size <= LITE_READ_BUF_SIZE:
109
+ tail_bytes = head_bytes
110
+ else:
111
+ f.seek(max(0, actual_size - LITE_READ_BUF_SIZE))
112
+ tail_bytes = f.read(LITE_READ_BUF_SIZE)
113
+ head = head_bytes.decode("utf-8", errors="replace")
114
+ tail = tail_bytes.decode("utf-8", errors="replace")
115
+ return head, tail
116
+
117
+
118
+ # ---------------------------------------------------------------------------
119
+ # First-user-message scanner (for fallback title)
120
+ # ---------------------------------------------------------------------------
121
+
122
+ _USER_ROLE_PATTERNS = (re.compile(r'"role"\s*:\s*"user"'),)
123
+
124
+
125
+ def _extract_first_user_text(head: str) -> str | None:
126
+ """Find the first user message's text in a head chunk.
127
+
128
+ Skips lite-meta rows (no ``role``), tool_result-only messages, and
129
+ rows whose content can't be parsed.
130
+ """
131
+ for line in head.split("\n"):
132
+ line = line.strip()
133
+ if not line:
134
+ continue
135
+ if not any(p.search(line) for p in _USER_ROLE_PATTERNS):
136
+ continue
137
+ try:
138
+ obj = json.loads(line)
139
+ except json.JSONDecodeError:
140
+ continue
141
+ if not isinstance(obj, dict) or obj.get("role") != "user":
142
+ continue
143
+ content = obj.get("content")
144
+ if isinstance(content, str) and content.strip():
145
+ return content
146
+ if isinstance(content, list):
147
+ texts: list[str] = []
148
+ has_user_text = False
149
+ for block in content:
150
+ if not isinstance(block, dict):
151
+ continue
152
+ btype = block.get("type")
153
+ if btype == "tool_result":
154
+ continue
155
+ if btype == "text":
156
+ text = block.get("text") or ""
157
+ if text:
158
+ texts.append(text)
159
+ has_user_text = True
160
+ if has_user_text:
161
+ return " ".join(texts)
162
+ return None
163
+
164
+
165
+ # ---------------------------------------------------------------------------
166
+ # Public LiteMetadata extraction
167
+ # ---------------------------------------------------------------------------
168
+
169
+
170
+ def read_lite_metadata(path: Path) -> LiteMetadata:
171
+ """Extract LiteMetadata from a session file via head + tail scan."""
172
+ try:
173
+ head, tail = read_head_and_tail(path)
174
+ except OSError:
175
+ return LiteMetadata()
176
+ cwd = extract_first_json_string_field(head, "cwd")
177
+ git_branch = extract_last_json_string_field(tail, "git_branch") or extract_first_json_string_field(
178
+ head, "git_branch"
179
+ )
180
+ last_prompt = extract_last_json_string_field(tail, "last_prompt")
181
+ first_prompt = _extract_first_user_text(head)
182
+ return LiteMetadata(
183
+ cwd=cwd,
184
+ git_branch=git_branch,
185
+ last_prompt=last_prompt,
186
+ first_prompt=first_prompt,
187
+ )
188
+
189
+
190
+ # ---------------------------------------------------------------------------
191
+ # SessionIndex — list / search session entries across projects
192
+ # ---------------------------------------------------------------------------
193
+
194
+
195
+ def _trim_title(text: str, max_len: int = 200) -> str:
196
+ flat = text.replace("\n", " ").strip()
197
+ if len(flat) <= max_len:
198
+ return flat
199
+ return flat[:max_len].rstrip() + "…"
200
+
201
+
202
+ def _build_entry(path: Path, fallback_cwd: str) -> SessionEntry | None:
203
+ try:
204
+ stat = path.stat()
205
+ except OSError:
206
+ return None
207
+ meta = read_lite_metadata(path)
208
+ cwd = meta.cwd or fallback_cwd
209
+ title_raw = meta.last_prompt or meta.first_prompt or ""
210
+ title = _trim_title(title_raw) if title_raw else "(empty)"
211
+ return SessionEntry(
212
+ session_id=path.stem,
213
+ cwd=cwd,
214
+ project_name=os.path.basename(cwd) if cwd else "?",
215
+ git_branch=meta.git_branch,
216
+ title=title,
217
+ mtime=stat.st_mtime,
218
+ size_bytes=stat.st_size,
219
+ )
220
+
221
+
222
+ class SessionIndex:
223
+ """List/search session entries across all known project directories."""
224
+
225
+ def __init__(self, projects_dir: Path | None = None) -> None:
226
+ self._projects_dir = projects_dir if projects_dir is not None else get_projects_dir()
227
+
228
+ # ------------------------------------------------------------------
229
+ # Public API
230
+ # ------------------------------------------------------------------
231
+
232
+ def list_for_cwd(self, cwd: str) -> list[SessionEntry]:
233
+ """List entries that belong to ``cwd``, mtime-descending."""
234
+ if self._projects_dir == get_projects_dir():
235
+ project_dir = get_project_dir(cwd)
236
+ else:
237
+ project_dir = self._projects_dir / sanitize_path(cwd)
238
+ if not project_dir.exists():
239
+ return []
240
+ entries: list[SessionEntry] = []
241
+ for jsonl in project_dir.glob("*.jsonl"):
242
+ entry = _build_entry(jsonl, fallback_cwd=cwd)
243
+ if entry is not None:
244
+ entries.append(entry)
245
+ entries.sort(key=lambda e: e.mtime, reverse=True)
246
+ return entries
247
+
248
+ def list_all_projects(self) -> list[SessionEntry]:
249
+ """List entries across every known project, mtime-descending."""
250
+ if not self._projects_dir.exists():
251
+ return []
252
+ entries: list[SessionEntry] = []
253
+ for proj_dir in self._projects_dir.iterdir():
254
+ if not proj_dir.is_dir():
255
+ continue
256
+ for jsonl in proj_dir.glob("*.jsonl"):
257
+ entry = _build_entry(jsonl, fallback_cwd="")
258
+ if entry is not None:
259
+ entries.append(entry)
260
+ entries.sort(key=lambda e: e.mtime, reverse=True)
261
+ return entries
262
+
263
+ def find_by_id_or_prefix(self, arg: str) -> SessionEntry | None:
264
+ """Locate a single entry by exact session id or unique id prefix."""
265
+ if not self._projects_dir.exists() or not arg:
266
+ return None
267
+ matches: list[SessionEntry] = []
268
+ for proj_dir in self._projects_dir.iterdir():
269
+ if not proj_dir.is_dir():
270
+ continue
271
+ for jsonl in proj_dir.glob("*.jsonl"):
272
+ sid = jsonl.stem
273
+ if sid == arg:
274
+ return _build_entry(jsonl, fallback_cwd="")
275
+ if sid.startswith(arg):
276
+ entry = _build_entry(jsonl, fallback_cwd="")
277
+ if entry is not None:
278
+ matches.append(entry)
279
+ if len(matches) == 1:
280
+ return matches[0]
281
+ return None
@@ -0,0 +1,245 @@
1
+ """Project-partitioned JSONL session storage.
2
+
3
+ Layout::
4
+
5
+ ~/.iac-code/projects/<sanitize(cwd)>/<session_id>.jsonl
6
+
7
+ Each session file is a stream of two kinds of JSONL lines:
8
+
9
+ * **Message rows** — one per :class:`Message`, with extra stamp fields
10
+ (``session_id``, ``cwd``, ``git_branch``, ``version``) appended at write
11
+ time. ``Message.from_dict`` ignores unknown fields, so loading is
12
+ schema-agnostic.
13
+
14
+ * **Lite-meta rows** — special rows without a ``role``, identified by a
15
+ ``type`` field (``last-prompt``, …). They are appended for the picker
16
+ to read via tail-scan, without being part of the conversation.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+ from iac_code import __version__
26
+ from iac_code.agent.message import ContentBlock, Message, ToolResultBlock
27
+ from iac_code.utils.project_paths import (
28
+ get_project_dir,
29
+ get_projects_dir,
30
+ get_session_path,
31
+ )
32
+
33
+
34
+ class SessionStorage:
35
+ """Persist conversation sessions partitioned by working directory."""
36
+
37
+ def __init__(self, projects_dir: Path | str | None = None) -> None:
38
+ self._projects_dir = Path(projects_dir) if projects_dir is not None else get_projects_dir()
39
+
40
+ # ------------------------------------------------------------------
41
+ # Internal path helpers
42
+ # ------------------------------------------------------------------
43
+
44
+ def _session_path(self, cwd: str, session_id: str) -> Path:
45
+ if self._projects_dir == get_projects_dir():
46
+ return get_session_path(cwd, session_id)
47
+ from iac_code.utils.project_paths import sanitize_path
48
+
49
+ return self._projects_dir / sanitize_path(cwd) / f"{session_id}.jsonl"
50
+
51
+ def _project_dir_for(self, cwd: str) -> Path:
52
+ if self._projects_dir == get_projects_dir():
53
+ return get_project_dir(cwd)
54
+ from iac_code.utils.project_paths import sanitize_path
55
+
56
+ return self._projects_dir / sanitize_path(cwd)
57
+
58
+ def session_path(self, cwd: str, session_id: str) -> Path:
59
+ """Public accessor for the on-disk JSONL path of a session."""
60
+ return self._session_path(cwd, session_id)
61
+
62
+ # ------------------------------------------------------------------
63
+ # Stamp helpers
64
+ # ------------------------------------------------------------------
65
+
66
+ @staticmethod
67
+ def _stamp(data: dict[str, Any], cwd: str, session_id: str, git_branch: str | None) -> dict[str, Any]:
68
+ data["session_id"] = session_id
69
+ data["cwd"] = cwd
70
+ if git_branch is not None:
71
+ data["git_branch"] = git_branch
72
+ data["version"] = __version__
73
+ return data
74
+
75
+ # ------------------------------------------------------------------
76
+ # Write
77
+ # ------------------------------------------------------------------
78
+
79
+ def append(
80
+ self,
81
+ cwd: str,
82
+ session_id: str,
83
+ message: Message,
84
+ *,
85
+ git_branch: str | None = None,
86
+ ) -> None:
87
+ """Append a single message (real-time persistence)."""
88
+ path = self._session_path(cwd, session_id)
89
+ path.parent.mkdir(parents=True, exist_ok=True)
90
+ data = self._stamp(message.to_dict(), cwd, session_id, git_branch)
91
+ with open(path, "a", encoding="utf-8") as f:
92
+ f.write(json.dumps(data, ensure_ascii=False) + "\n")
93
+
94
+ def append_meta(self, cwd: str, session_id: str, meta_entry: dict[str, Any]) -> None:
95
+ """Append a lite-meta row (no ``role``, distinguished by ``type``)."""
96
+ if "type" not in meta_entry:
97
+ raise ValueError("meta_entry must include a 'type' field")
98
+ path = self._session_path(cwd, session_id)
99
+ path.parent.mkdir(parents=True, exist_ok=True)
100
+ entry = dict(meta_entry)
101
+ entry["session_id"] = session_id
102
+ with open(path, "a", encoding="utf-8") as f:
103
+ f.write(json.dumps(entry, ensure_ascii=False) + "\n")
104
+
105
+ def save(
106
+ self,
107
+ cwd: str,
108
+ session_id: str,
109
+ messages: list[Message],
110
+ *,
111
+ git_branch: str | None = None,
112
+ ) -> None:
113
+ """Overwrite the session file with the given messages."""
114
+ path = self._session_path(cwd, session_id)
115
+ path.parent.mkdir(parents=True, exist_ok=True)
116
+ with open(path, "w", encoding="utf-8") as f:
117
+ for msg in messages:
118
+ data = self._stamp(msg.to_dict(), cwd, session_id, git_branch)
119
+ f.write(json.dumps(data, ensure_ascii=False) + "\n")
120
+
121
+ # ------------------------------------------------------------------
122
+ # Read
123
+ # ------------------------------------------------------------------
124
+
125
+ def load(self, cwd: str, session_id: str) -> list[Message]:
126
+ """Return the conversation messages, skipping lite-meta rows."""
127
+ path = self._session_path(cwd, session_id)
128
+ if not path.exists():
129
+ return []
130
+ messages: list[Message] = []
131
+ with open(path, encoding="utf-8") as f:
132
+ for line in f:
133
+ line = line.strip()
134
+ if not line:
135
+ continue
136
+ try:
137
+ obj = json.loads(line)
138
+ except json.JSONDecodeError:
139
+ continue
140
+ if not isinstance(obj, dict):
141
+ continue
142
+ if "role" not in obj:
143
+ # Lite-meta or unknown row — skip.
144
+ continue
145
+ try:
146
+ messages.append(Message.from_dict(obj))
147
+ except Exception:
148
+ continue
149
+ return messages
150
+
151
+ def exists(self, cwd: str, session_id: str) -> bool:
152
+ return self._session_path(cwd, session_id).exists()
153
+
154
+ # ------------------------------------------------------------------
155
+ # Cross-project lookups (used by CLI --resume / --continue)
156
+ # ------------------------------------------------------------------
157
+
158
+ def find_session_anywhere(self, session_id: str) -> tuple[str, Path] | None:
159
+ """Locate a session file across all known project dirs.
160
+
161
+ Returns ``(cwd, path)`` where ``cwd`` is the *original* working
162
+ directory of the session (read back from the first stamped
163
+ message), or ``None`` if the file isn't found.
164
+ """
165
+ if not self._projects_dir.exists():
166
+ return None
167
+ for proj_dir in self._projects_dir.iterdir():
168
+ if not proj_dir.is_dir():
169
+ continue
170
+ candidate = proj_dir / f"{session_id}.jsonl"
171
+ if candidate.exists():
172
+ cwd = self._read_cwd_from_file(candidate) or ""
173
+ return cwd, candidate
174
+ return None
175
+
176
+ def get_latest_session_anywhere(self) -> tuple[str, str] | None:
177
+ """Return ``(cwd, session_id)`` for the most-recently-modified session."""
178
+ if not self._projects_dir.exists():
179
+ return None
180
+ latest: tuple[float, Path] | None = None
181
+ for proj_dir in self._projects_dir.iterdir():
182
+ if not proj_dir.is_dir():
183
+ continue
184
+ for jsonl in proj_dir.glob("*.jsonl"):
185
+ mtime = jsonl.stat().st_mtime
186
+ if latest is None or mtime > latest[0]:
187
+ latest = (mtime, jsonl)
188
+ if latest is None:
189
+ return None
190
+ path = latest[1]
191
+ cwd = self._read_cwd_from_file(path) or ""
192
+ session_id = path.stem
193
+ return cwd, session_id
194
+
195
+ @staticmethod
196
+ def _read_cwd_from_file(path: Path) -> str | None:
197
+ """Read the first message-row's ``cwd`` stamp from a session file."""
198
+ try:
199
+ with open(path, encoding="utf-8") as f:
200
+ for line in f:
201
+ line = line.strip()
202
+ if not line:
203
+ continue
204
+ try:
205
+ obj = json.loads(line)
206
+ except json.JSONDecodeError:
207
+ continue
208
+ if isinstance(obj, dict) and "cwd" in obj:
209
+ cwd = obj["cwd"]
210
+ if isinstance(cwd, str):
211
+ return cwd
212
+ except OSError:
213
+ return None
214
+ return None
215
+
216
+ # ------------------------------------------------------------------
217
+ # Interruption repair
218
+ # ------------------------------------------------------------------
219
+
220
+ @staticmethod
221
+ def detect_interruption(messages: list[Message]) -> bool:
222
+ """True if the session ends mid-tool-execution (assistant tool_use without results)."""
223
+ if not messages:
224
+ return False
225
+ last = messages[-1]
226
+ return last.role == "assistant" and last.has_tool_use()
227
+
228
+ @classmethod
229
+ def repair_interrupted(cls, messages: list[Message]) -> list[Message]:
230
+ """Append synthetic error tool_results for any orphaned tool_use blocks."""
231
+ if not cls.detect_interruption(messages):
232
+ return messages
233
+ last_msg = messages[-1]
234
+ tool_uses = last_msg.get_tool_use_blocks()
235
+ repair_results: list[ContentBlock] = [
236
+ ToolResultBlock(
237
+ tool_use_id=tu.id,
238
+ content="Session interrupted before tool execution completed.",
239
+ is_error=True,
240
+ )
241
+ for tu in tool_uses
242
+ ]
243
+ repaired = list(messages)
244
+ repaired.append(Message(role="user", content=repair_results))
245
+ return repaired
@@ -0,0 +1,66 @@
1
+ """iac-code telemetry package — zero-dependency public facade."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from iac_code.services.telemetry.client import TelemetryClient
8
+
9
+ __all__ = [
10
+ "log_event",
11
+ "add_metric",
12
+ "start_span",
13
+ "bootstrap_telemetry",
14
+ "graceful_shutdown",
15
+ "get_client",
16
+ "set_client",
17
+ "get_session_id",
18
+ "get_user_id",
19
+ ]
20
+
21
+ _client: TelemetryClient | None = None
22
+
23
+
24
+ def get_client() -> TelemetryClient:
25
+ """Return the singleton client, creating it on first call."""
26
+ global _client
27
+ if _client is None:
28
+ _client = TelemetryClient()
29
+ return _client
30
+
31
+
32
+ def set_client(client: TelemetryClient | None) -> None:
33
+ """Replace (or clear) the singleton. Useful for tests."""
34
+ global _client
35
+ _client = client
36
+
37
+
38
+ def log_event(event_name: str, metadata: dict[str, Any] | None = None) -> None:
39
+ get_client().log_event(event_name, metadata)
40
+
41
+
42
+ def add_metric(name: str, value: int | float, attributes: dict[str, Any] | None = None) -> None:
43
+ get_client().add_metric(name, value, attributes)
44
+
45
+
46
+ def start_span(name: str, attributes: dict[str, Any] | None = None):
47
+ return get_client().start_span(name, attributes)
48
+
49
+
50
+ def bootstrap_telemetry(session_id: str | None = None) -> None:
51
+ global _client
52
+ if _client is None:
53
+ _client = TelemetryClient(session_id=session_id)
54
+ get_client().bootstrap()
55
+
56
+
57
+ def graceful_shutdown() -> None:
58
+ get_client().shutdown()
59
+
60
+
61
+ def get_session_id() -> str:
62
+ return get_client().get_session_id()
63
+
64
+
65
+ def get_user_id() -> str:
66
+ return get_client().get_user_id()
@@ -0,0 +1,84 @@
1
+ """AttributeBuilder — build resource and per-event attribute dicts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import itertools
6
+ import os
7
+ import platform
8
+ import socket
9
+ import sys
10
+ from datetime import datetime, timezone
11
+ from threading import Lock
12
+
13
+ from iac_code.services.telemetry.identity import Identity
14
+ from iac_code.services.telemetry.names import (
15
+ ARMS_FEATURE_GENAI_APP,
16
+ FRAMEWORK_IAC_CODE,
17
+ ArmsResourceAttr,
18
+ )
19
+
20
+
21
+ def _detect_service_version() -> str:
22
+ """Look up installed version; fallback to 0.0.0 for dev."""
23
+ try:
24
+ from importlib.metadata import version
25
+
26
+ return version("iac-code")
27
+ except Exception:
28
+ return "0.0.0"
29
+
30
+
31
+ def _detect_host_name() -> str:
32
+ try:
33
+ return socket.gethostname() or "unknown"
34
+ except Exception:
35
+ return "unknown"
36
+
37
+
38
+ class AttributeBuilder:
39
+ """Assembles the attribute dicts attached to every signal.
40
+
41
+ Resource attributes are identity + app + device fields. Event attributes
42
+ wrap event.name with a timestamp and a monotonic sequence.
43
+ """
44
+
45
+ def __init__(
46
+ self,
47
+ identity: Identity,
48
+ service_name: str,
49
+ service_version: str | None = None,
50
+ ) -> None:
51
+ self._identity = identity
52
+ self._service_name = service_name
53
+ self._service_version = service_version or _detect_service_version()
54
+ self._sequence = itertools.count(1)
55
+ self._sequence_lock = Lock()
56
+
57
+ def build_resource(self) -> dict[str, str]:
58
+ """Identity + app + device attributes. Called once at startup, usually."""
59
+ attrs: dict[str, str] = {
60
+ "service.name": self._service_name,
61
+ "service.version": self._service_version,
62
+ "os.type": sys.platform,
63
+ "host.arch": platform.machine() or "unknown",
64
+ "host.name": _detect_host_name(),
65
+ "deployment.environment": os.environ.get("IAC_CODE_ENV", "production"),
66
+ ArmsResourceAttr.CMS_WORKSPACE: FRAMEWORK_IAC_CODE,
67
+ ArmsResourceAttr.SERVICE_FEATURE: ARMS_FEATURE_GENAI_APP,
68
+ "user.id": self._identity.get_user_id(),
69
+ "session.id": self._identity.get_session_id(),
70
+ }
71
+ tenant = self._identity.get_tenant_id()
72
+ if tenant is not None:
73
+ attrs["tenant.id"] = tenant
74
+ return attrs
75
+
76
+ def build_event(self, event_name: str) -> dict[str, str | int]:
77
+ """Per-event envelope."""
78
+ with self._sequence_lock:
79
+ seq = next(self._sequence)
80
+ return {
81
+ "event.name": event_name,
82
+ "event.timestamp": datetime.now(timezone.utc).isoformat(),
83
+ "event.sequence": seq,
84
+ }