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,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
|
+
}
|