agent-trace-cli 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.
- agent_trace/__init__.py +3 -0
- agent_trace/blame.py +471 -0
- agent_trace/blame_git.py +133 -0
- agent_trace/blame_meta.py +167 -0
- agent_trace/cli.py +2680 -0
- agent_trace/commit_link.py +226 -0
- agent_trace/config.py +137 -0
- agent_trace/context.py +385 -0
- agent_trace/conversations.py +358 -0
- agent_trace/git_notes.py +491 -0
- agent_trace/hooks/__init__.py +163 -0
- agent_trace/hooks/base.py +233 -0
- agent_trace/hooks/claude.py +483 -0
- agent_trace/hooks/codex.py +232 -0
- agent_trace/hooks/cursor.py +278 -0
- agent_trace/hooks/git.py +144 -0
- agent_trace/ledger.py +955 -0
- agent_trace/models.py +618 -0
- agent_trace/record.py +417 -0
- agent_trace/registry.py +230 -0
- agent_trace/remote.py +673 -0
- agent_trace/rewrite.py +176 -0
- agent_trace/rules.py +209 -0
- agent_trace/schemas/commit-link.schema.json +34 -0
- agent_trace/schemas/git-note.schema.json +88 -0
- agent_trace/schemas/ledger.schema.json +97 -0
- agent_trace/schemas/remotes.schema.json +30 -0
- agent_trace/schemas/sync-state.schema.json +49 -0
- agent_trace/schemas/trace-record.schema.json +130 -0
- agent_trace/session.py +136 -0
- agent_trace/storage.py +229 -0
- agent_trace/summary.py +465 -0
- agent_trace/summary_presets.py +188 -0
- agent_trace/sync.py +1182 -0
- agent_trace/telemetry.py +142 -0
- agent_trace/trace.py +460 -0
- agent_trace_cli-0.1.0.dist-info/METADATA +535 -0
- agent_trace_cli-0.1.0.dist-info/RECORD +42 -0
- agent_trace_cli-0.1.0.dist-info/WHEEL +5 -0
- agent_trace_cli-0.1.0.dist-info/entry_points.txt +2 -0
- agent_trace_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
- agent_trace_cli-0.1.0.dist-info/top_level.txt +1 -0
agent_trace/telemetry.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Opt-in anonymous CLI telemetry (stdlib only).
|
|
3
|
+
|
|
4
|
+
No third-party services in M0: the collect URL points at an invalid endpoint until a
|
|
5
|
+
backend exists; failures are always swallowed so tracing never affects user commands.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import urllib.error
|
|
13
|
+
import urllib.request
|
|
14
|
+
import uuid
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from .config import get_global_config, save_global_config
|
|
18
|
+
|
|
19
|
+
# Placeholder until a real collector ships (see docs/concepts/telemetry.md).
|
|
20
|
+
TELEMETRY_COLLECT_URL = "http://127.0.0.1:0/"
|
|
21
|
+
|
|
22
|
+
_ENV = "AGENT_TRACE_TELEMETRY"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _parse_env_override(raw: str | None) -> bool | None:
|
|
26
|
+
"""Return True/False if env forces telemetry on/off; None if unset or unknown."""
|
|
27
|
+
if raw is None or not str(raw).strip():
|
|
28
|
+
return None
|
|
29
|
+
v = str(raw).strip().lower()
|
|
30
|
+
if v in {"0", "false", "no", "n", "off", "disable", "disabled"}:
|
|
31
|
+
return False
|
|
32
|
+
if v in {"1", "true", "yes", "y", "on", "enable", "enabled"}:
|
|
33
|
+
return True
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def telemetry_env_override() -> bool | None:
|
|
38
|
+
return _parse_env_override(os.environ.get(_ENV))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def telemetry_config_enabled() -> bool:
|
|
42
|
+
cfg = get_global_config()
|
|
43
|
+
tel = cfg.get("telemetry")
|
|
44
|
+
if not isinstance(tel, dict):
|
|
45
|
+
return False
|
|
46
|
+
return bool(tel.get("enabled"))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def telemetry_effective_enabled() -> bool:
|
|
50
|
+
env = telemetry_env_override()
|
|
51
|
+
if env is not None:
|
|
52
|
+
return env
|
|
53
|
+
return telemetry_config_enabled()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def ensure_install_id(cfg: dict | None = None) -> str:
|
|
57
|
+
"""Return persistent anonymous install id; creates one under global config if missing.
|
|
58
|
+
|
|
59
|
+
Pass ``cfg`` when mutating the same in-memory dict as another helper (e.g.
|
|
60
|
+
`set_telemetry_enabled`) so a reload from disk does not drop unsaved fields.
|
|
61
|
+
"""
|
|
62
|
+
cfg = get_global_config() if cfg is None else cfg
|
|
63
|
+
tel = cfg.get("telemetry")
|
|
64
|
+
if not isinstance(tel, dict):
|
|
65
|
+
tel = {}
|
|
66
|
+
cfg["telemetry"] = tel
|
|
67
|
+
iid = tel.get("install_id")
|
|
68
|
+
if isinstance(iid, str) and len(iid) >= 8:
|
|
69
|
+
return iid
|
|
70
|
+
tel["install_id"] = str(uuid.uuid4())
|
|
71
|
+
save_global_config(cfg)
|
|
72
|
+
return tel["install_id"]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def set_telemetry_enabled(enabled: bool) -> None:
|
|
76
|
+
cfg = get_global_config()
|
|
77
|
+
tel = cfg.setdefault("telemetry", {})
|
|
78
|
+
if not isinstance(tel, dict):
|
|
79
|
+
tel = {}
|
|
80
|
+
cfg["telemetry"] = tel
|
|
81
|
+
tel["enabled"] = bool(enabled)
|
|
82
|
+
if enabled:
|
|
83
|
+
ensure_install_id(cfg)
|
|
84
|
+
save_global_config(cfg)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def telemetry_status_lines() -> list[str]:
|
|
88
|
+
"""Human-readable lines for `agent-trace --telemetry status`."""
|
|
89
|
+
cfg_on = telemetry_config_enabled()
|
|
90
|
+
env_raw = os.environ.get(_ENV)
|
|
91
|
+
env = telemetry_env_override()
|
|
92
|
+
eff = telemetry_effective_enabled()
|
|
93
|
+
lines = [
|
|
94
|
+
"Telemetry (anonymous CLI usage metrics)",
|
|
95
|
+
f" Config file preference: {'on' if cfg_on else 'off'}",
|
|
96
|
+
f" {_ENV}: {repr(env_raw) if env_raw is not None else '(not set)'}",
|
|
97
|
+
]
|
|
98
|
+
if env is None and env_raw is not None and str(env_raw).strip():
|
|
99
|
+
lines.append(" (environment value is not a recognised boolean — ignored)")
|
|
100
|
+
lines.append(f" Effective (what the CLI uses): {'on' if eff else 'off'}")
|
|
101
|
+
lines.append("")
|
|
102
|
+
lines.append(
|
|
103
|
+
"When on, each command may POST anonymous JSON: "
|
|
104
|
+
"install_id, version, command, exit_code, duration_ms. "
|
|
105
|
+
"See docs/concepts/telemetry.md.",
|
|
106
|
+
)
|
|
107
|
+
return lines
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def maybe_report_cli_run(
|
|
111
|
+
*,
|
|
112
|
+
version: str,
|
|
113
|
+
command: str,
|
|
114
|
+
exit_code: int,
|
|
115
|
+
duration_ms: int,
|
|
116
|
+
) -> None:
|
|
117
|
+
"""Fire-and-forget telemetry; never raises."""
|
|
118
|
+
try:
|
|
119
|
+
if not telemetry_effective_enabled():
|
|
120
|
+
return
|
|
121
|
+
install_id = ensure_install_id()
|
|
122
|
+
payload: dict[str, Any] = {
|
|
123
|
+
"install_id": install_id,
|
|
124
|
+
"version": version,
|
|
125
|
+
"command": command,
|
|
126
|
+
"exit_code": int(exit_code),
|
|
127
|
+
"duration_ms": int(duration_ms),
|
|
128
|
+
}
|
|
129
|
+
body = json.dumps(payload).encode("utf-8")
|
|
130
|
+
req = urllib.request.Request(
|
|
131
|
+
TELEMETRY_COLLECT_URL,
|
|
132
|
+
data=body,
|
|
133
|
+
headers={"Content-Type": "application/json"},
|
|
134
|
+
method="POST",
|
|
135
|
+
)
|
|
136
|
+
with urllib.request.urlopen(req, timeout=2.0) as resp:
|
|
137
|
+
resp.read()
|
|
138
|
+
except (urllib.error.URLError, urllib.error.HTTPError, OSError, ValueError, TypeError):
|
|
139
|
+
pass
|
|
140
|
+
except socket.timeout:
|
|
141
|
+
pass
|
|
142
|
+
|
agent_trace/trace.py
ADDED
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Trace record construction helpers.
|
|
3
|
+
|
|
4
|
+
Builds trace records from hook event data following the agent-trace spec.
|
|
5
|
+
Resolution is file-anchored: the git repo owning the edited file, not the
|
|
6
|
+
agent's launch directory (Phase 1b).
|
|
7
|
+
|
|
8
|
+
No external dependencies — stdlib only.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import hashlib
|
|
14
|
+
import os
|
|
15
|
+
import subprocess
|
|
16
|
+
import uuid
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from datetime import datetime, timezone
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# -------------------------------------------------------------------
|
|
22
|
+
# Project resolution (Phase 1b)
|
|
23
|
+
# -------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class ProjectResolution:
|
|
28
|
+
"""Owning repo (or detached bucket) for a path."""
|
|
29
|
+
|
|
30
|
+
repo_root: str
|
|
31
|
+
project_id: str
|
|
32
|
+
rel_path: str
|
|
33
|
+
vcs: dict | None
|
|
34
|
+
is_detached: bool = False
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _git_out(args: list[str], cwd: str, timeout: float = 10.0) -> str | None:
|
|
38
|
+
try:
|
|
39
|
+
r = subprocess.run(
|
|
40
|
+
["git", *args],
|
|
41
|
+
capture_output=True,
|
|
42
|
+
text=True,
|
|
43
|
+
cwd=cwd,
|
|
44
|
+
timeout=timeout,
|
|
45
|
+
)
|
|
46
|
+
if r.returncode == 0:
|
|
47
|
+
return r.stdout.strip()
|
|
48
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
49
|
+
pass
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def git_repo_root_for_path(path: str) -> str | None:
|
|
54
|
+
"""Innermost git work tree containing ``path`` (``git -C <dir> rev-parse --show-toplevel``)."""
|
|
55
|
+
p = os.path.abspath(path)
|
|
56
|
+
if os.path.isfile(p):
|
|
57
|
+
d = os.path.dirname(p)
|
|
58
|
+
else:
|
|
59
|
+
d = p if os.path.isdir(p) else os.path.dirname(p)
|
|
60
|
+
if not os.path.isdir(d):
|
|
61
|
+
return None
|
|
62
|
+
out = _git_out(["rev-parse", "--show-toplevel"], d)
|
|
63
|
+
if not out:
|
|
64
|
+
return None
|
|
65
|
+
return os.path.realpath(out)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _is_pseudo_path(file_path: str) -> bool:
|
|
69
|
+
"""Hook placeholder paths (not real files)."""
|
|
70
|
+
if not file_path or file_path.startswith("/"):
|
|
71
|
+
return False
|
|
72
|
+
norm = file_path.replace("\\", "/").strip("./")
|
|
73
|
+
return norm in (
|
|
74
|
+
"shell-history",
|
|
75
|
+
"sessions",
|
|
76
|
+
"unknown",
|
|
77
|
+
) or file_path in (".shell-history", ".sessions", ".unknown")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def resolve_file_project(
|
|
81
|
+
file_path: str,
|
|
82
|
+
*,
|
|
83
|
+
anchor_path: str | None = None,
|
|
84
|
+
) -> ProjectResolution | None:
|
|
85
|
+
"""Resolve repo + project_id from the file (or anchor), not session cwd.
|
|
86
|
+
|
|
87
|
+
``anchor_path`` is used when ``file_path`` is a pseudo path (e.g. ``.shell-history``)
|
|
88
|
+
or a relative path: it should be cwd or a file/dir inside the intended repo.
|
|
89
|
+
"""
|
|
90
|
+
from .config import get_global_config
|
|
91
|
+
from .registry import lookup_or_create_project_id
|
|
92
|
+
|
|
93
|
+
fp = file_path
|
|
94
|
+
if not os.path.isabs(fp) and anchor_path and not _is_pseudo_path(fp):
|
|
95
|
+
an = os.path.abspath(anchor_path)
|
|
96
|
+
base = os.path.dirname(an) if os.path.isfile(an) else an
|
|
97
|
+
fp = os.path.normpath(os.path.join(base, fp))
|
|
98
|
+
|
|
99
|
+
if _is_pseudo_path(file_path):
|
|
100
|
+
if anchor_path:
|
|
101
|
+
an = os.path.abspath(anchor_path)
|
|
102
|
+
path_for_git = os.path.dirname(an) if os.path.isfile(an) else an
|
|
103
|
+
else:
|
|
104
|
+
path_for_git = os.getcwd()
|
|
105
|
+
rel = file_path.replace(os.sep, "/")
|
|
106
|
+
else:
|
|
107
|
+
abs_fp = os.path.abspath(fp)
|
|
108
|
+
path_for_git = abs_fp
|
|
109
|
+
if os.path.isfile(abs_fp):
|
|
110
|
+
pass
|
|
111
|
+
elif os.path.isdir(abs_fp):
|
|
112
|
+
path_for_git = os.path.join(abs_fp, ".")
|
|
113
|
+
else:
|
|
114
|
+
d = os.path.dirname(abs_fp)
|
|
115
|
+
path_for_git = d if d else abs_fp
|
|
116
|
+
|
|
117
|
+
repo_root = git_repo_root_for_path(path_for_git)
|
|
118
|
+
if repo_root:
|
|
119
|
+
if not _is_pseudo_path(file_path):
|
|
120
|
+
real_file = os.path.realpath(os.path.abspath(fp))
|
|
121
|
+
try:
|
|
122
|
+
rel = os.path.relpath(real_file, repo_root)
|
|
123
|
+
except ValueError:
|
|
124
|
+
rel = os.path.basename(real_file)
|
|
125
|
+
else:
|
|
126
|
+
rel = file_path.replace(os.sep, "/")
|
|
127
|
+
pid = lookup_or_create_project_id(repo_root)
|
|
128
|
+
vcs = get_vcs_info(repo_root)
|
|
129
|
+
return ProjectResolution(
|
|
130
|
+
repo_root=repo_root,
|
|
131
|
+
project_id=pid,
|
|
132
|
+
rel_path=rel.replace(os.sep, "/"),
|
|
133
|
+
vcs=vcs,
|
|
134
|
+
is_detached=False,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
# No git repo — optional detached capture
|
|
138
|
+
cfg = get_global_config()
|
|
139
|
+
if not cfg.get("capture_detached_edits"):
|
|
140
|
+
return None
|
|
141
|
+
|
|
142
|
+
if anchor_path:
|
|
143
|
+
an = os.path.abspath(anchor_path)
|
|
144
|
+
parent = os.path.dirname(an) if os.path.isfile(an) else an
|
|
145
|
+
else:
|
|
146
|
+
parent = os.path.dirname(os.path.realpath(os.path.abspath(fp)))
|
|
147
|
+
parent = os.path.realpath(parent or os.getcwd())
|
|
148
|
+
h = hashlib.sha256(parent.encode("utf-8")).hexdigest()[:16]
|
|
149
|
+
pid = f"detached:{h}"
|
|
150
|
+
from .storage import get_detached_base_dir
|
|
151
|
+
|
|
152
|
+
base = os.path.realpath(str(get_detached_base_dir() / h))
|
|
153
|
+
os.makedirs(base, exist_ok=True)
|
|
154
|
+
rel_name = file_path.replace(os.sep, "/")
|
|
155
|
+
if not _is_pseudo_path(file_path):
|
|
156
|
+
rel_name = os.path.basename(os.path.realpath(os.path.abspath(fp)))
|
|
157
|
+
return ProjectResolution(
|
|
158
|
+
repo_root=base,
|
|
159
|
+
project_id=pid,
|
|
160
|
+
rel_path=rel_name,
|
|
161
|
+
vcs=None,
|
|
162
|
+
is_detached=True,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def get_workspace_root() -> str:
|
|
167
|
+
"""Best-effort git root from current working directory (session-level hints).
|
|
168
|
+
|
|
169
|
+
Does not use CURSOR/CLAUDE env vars for routing — prefer :func:`resolve_file_project`
|
|
170
|
+
for file-backed traces.
|
|
171
|
+
"""
|
|
172
|
+
g = git_repo_root_for_path(os.getcwd())
|
|
173
|
+
if g:
|
|
174
|
+
return g
|
|
175
|
+
return os.getcwd()
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def get_tool_info() -> dict:
|
|
179
|
+
"""Detect which AI coding tool invoked the hook.
|
|
180
|
+
|
|
181
|
+
Walks the adapter registry — each ``CodingAgentAdapter`` may
|
|
182
|
+
implement ``detect_tool_info`` to return ``{name, version}`` based
|
|
183
|
+
on env vars or marker files. The first adapter that matches wins.
|
|
184
|
+
"""
|
|
185
|
+
try:
|
|
186
|
+
from .hooks import iter_adapters
|
|
187
|
+
|
|
188
|
+
for adapter in iter_adapters():
|
|
189
|
+
info = adapter.detect_tool_info()
|
|
190
|
+
if info:
|
|
191
|
+
return info
|
|
192
|
+
except Exception:
|
|
193
|
+
# Adapter detection must never crash recording.
|
|
194
|
+
pass
|
|
195
|
+
return {"name": "unknown"}
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def get_vcs_info(cwd: str | None = None) -> dict | None:
|
|
199
|
+
"""Current git revision, or None."""
|
|
200
|
+
root = cwd or os.getcwd()
|
|
201
|
+
try:
|
|
202
|
+
result = subprocess.run(
|
|
203
|
+
["git", "rev-parse", "HEAD"],
|
|
204
|
+
capture_output=True,
|
|
205
|
+
text=True,
|
|
206
|
+
cwd=root,
|
|
207
|
+
timeout=5,
|
|
208
|
+
)
|
|
209
|
+
if result.returncode == 0:
|
|
210
|
+
return {"type": "git", "revision": result.stdout.strip()}
|
|
211
|
+
except Exception:
|
|
212
|
+
pass
|
|
213
|
+
return None
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
# -------------------------------------------------------------------
|
|
217
|
+
# Utilities
|
|
218
|
+
# -------------------------------------------------------------------
|
|
219
|
+
|
|
220
|
+
def to_relative_path(absolute_path: str, root: str) -> str:
|
|
221
|
+
try:
|
|
222
|
+
return os.path.relpath(absolute_path, root)
|
|
223
|
+
except ValueError:
|
|
224
|
+
return absolute_path
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def normalize_model_id(model: str | None) -> str | None:
|
|
228
|
+
"""Add provider prefix to bare model names."""
|
|
229
|
+
if not model:
|
|
230
|
+
return None
|
|
231
|
+
if "/" in model:
|
|
232
|
+
return model
|
|
233
|
+
prefixes = {
|
|
234
|
+
"claude-": "anthropic",
|
|
235
|
+
"gpt-": "openai",
|
|
236
|
+
"o1": "openai",
|
|
237
|
+
"o3": "openai",
|
|
238
|
+
"gemini-": "google",
|
|
239
|
+
}
|
|
240
|
+
for prefix, provider in prefixes.items():
|
|
241
|
+
if model.startswith(prefix):
|
|
242
|
+
return f"{provider}/{model}"
|
|
243
|
+
return model
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def compute_line_hashes(content: str) -> list[dict]:
|
|
247
|
+
"""Compute per-line full SHA-256 hashes.
|
|
248
|
+
|
|
249
|
+
Each line is hashed individually so the ledger can match committed lines
|
|
250
|
+
back to the trace that produced them, even if surrounding lines shifted.
|
|
251
|
+
Full 256-bit hashes guarantee cryptographic uniqueness, so no separate
|
|
252
|
+
line content is stored — the hash alone is the line's identity.
|
|
253
|
+
|
|
254
|
+
Returns a list of ``{"line_offset": 0, "hash": "sha256:..."}``.
|
|
255
|
+
"""
|
|
256
|
+
lines = content.split("\n")
|
|
257
|
+
if lines and lines[-1] == "":
|
|
258
|
+
lines = lines[:-1]
|
|
259
|
+
result: list[dict] = []
|
|
260
|
+
for i, line in enumerate(lines):
|
|
261
|
+
h = hashlib.sha256(line.encode("utf-8")).hexdigest()
|
|
262
|
+
result.append({
|
|
263
|
+
"line_offset": i,
|
|
264
|
+
"hash": f"sha256:{h}",
|
|
265
|
+
})
|
|
266
|
+
return result
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def compute_content_hash(content: str) -> str:
|
|
270
|
+
"""Full SHA-256 hash of normalized content for dedup / verification.
|
|
271
|
+
|
|
272
|
+
Normalization: CRLF/CR → LF, and trailing newline stripped so that the
|
|
273
|
+
same logical content hashes identically whether stored (e.g. tool
|
|
274
|
+
new_string with trailing \\n) or matched (e.g. \"\\n\".join(blame lines)).
|
|
275
|
+
"""
|
|
276
|
+
normalized = content.replace("\r\n", "\n").replace("\r", "\n").rstrip("\n")
|
|
277
|
+
h = hashlib.sha256(normalized.encode("utf-8")).hexdigest()
|
|
278
|
+
return f"sha256:{h}"
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def compute_range_positions(
|
|
282
|
+
edits: list[dict],
|
|
283
|
+
file_content: str | None = None,
|
|
284
|
+
) -> list[dict]:
|
|
285
|
+
"""Derive line-range positions from a list of edits."""
|
|
286
|
+
positions: list[dict] = []
|
|
287
|
+
for edit in edits:
|
|
288
|
+
new_string = edit.get("new_string", "")
|
|
289
|
+
if not new_string:
|
|
290
|
+
continue
|
|
291
|
+
|
|
292
|
+
old_string = edit.get("old_string", "")
|
|
293
|
+
if not old_string:
|
|
294
|
+
# File creation, full replace, or prepend — attribute from line 1
|
|
295
|
+
line_count = new_string.count("\n") + 1
|
|
296
|
+
positions.append({"start_line": 1, "end_line": line_count})
|
|
297
|
+
continue
|
|
298
|
+
|
|
299
|
+
rng = edit.get("range")
|
|
300
|
+
if rng:
|
|
301
|
+
positions.append({
|
|
302
|
+
"start_line": rng.get("start_line_number", 1),
|
|
303
|
+
"end_line": rng.get("end_line_number", 1),
|
|
304
|
+
})
|
|
305
|
+
elif file_content:
|
|
306
|
+
idx = file_content.find(new_string)
|
|
307
|
+
line_count = new_string.count("\n") + 1
|
|
308
|
+
if idx != -1:
|
|
309
|
+
start = file_content[:idx].count("\n") + 1
|
|
310
|
+
positions.append({"start_line": start, "end_line": start + line_count - 1})
|
|
311
|
+
else:
|
|
312
|
+
positions.append({"start_line": 1, "end_line": line_count})
|
|
313
|
+
else:
|
|
314
|
+
line_count = new_string.count("\n") + 1
|
|
315
|
+
positions.append({"start_line": 1, "end_line": line_count})
|
|
316
|
+
return positions
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
# -------------------------------------------------------------------
|
|
320
|
+
# Trace construction
|
|
321
|
+
# -------------------------------------------------------------------
|
|
322
|
+
|
|
323
|
+
def create_trace(
|
|
324
|
+
contributor_type: str,
|
|
325
|
+
file_path: str,
|
|
326
|
+
*,
|
|
327
|
+
model: str | None = None,
|
|
328
|
+
range_positions: list[dict] | None = None,
|
|
329
|
+
range_contents: list[str] | None = None,
|
|
330
|
+
transcript: str | None = None,
|
|
331
|
+
metadata: dict | None = None,
|
|
332
|
+
edit_sequence: int | None = None,
|
|
333
|
+
anchor_path: str | None = None,
|
|
334
|
+
resolution: ProjectResolution | None = None,
|
|
335
|
+
) -> dict | None:
|
|
336
|
+
"""Build a trace record dict, or None if the path is not traceable (no git and no detached config)."""
|
|
337
|
+
res = resolution or resolve_file_project(file_path, anchor_path=anchor_path)
|
|
338
|
+
if res is None:
|
|
339
|
+
return None
|
|
340
|
+
|
|
341
|
+
root = res.repo_root
|
|
342
|
+
model_id = normalize_model_id(model)
|
|
343
|
+
|
|
344
|
+
# Snapshot the live transcript into the per-project content-addressed
|
|
345
|
+
# cache and record an opaque conversation_id + content_sha256 on the
|
|
346
|
+
# trace. The raw filesystem path never enters the record itself.
|
|
347
|
+
conversation_id: str | None = None
|
|
348
|
+
content_sha256: str | None = None
|
|
349
|
+
if transcript:
|
|
350
|
+
from .conversations import (
|
|
351
|
+
compute_conversation_id,
|
|
352
|
+
snapshot_transcript_to_cache,
|
|
353
|
+
)
|
|
354
|
+
conversation_id = compute_conversation_id(transcript)
|
|
355
|
+
snap = snapshot_transcript_to_cache(res.project_id, transcript)
|
|
356
|
+
if snap is not None:
|
|
357
|
+
content_sha256, _ = snap
|
|
358
|
+
|
|
359
|
+
# Build ranges
|
|
360
|
+
ranges: list[dict] = []
|
|
361
|
+
if range_positions:
|
|
362
|
+
for i, pos in enumerate(range_positions):
|
|
363
|
+
r = {"start_line": pos["start_line"], "end_line": pos["end_line"]}
|
|
364
|
+
if range_contents and i < len(range_contents) and range_contents[i]:
|
|
365
|
+
r["content_hash"] = compute_content_hash(range_contents[i])
|
|
366
|
+
r["line_hashes"] = compute_line_hashes(range_contents[i])
|
|
367
|
+
ranges.append(r)
|
|
368
|
+
if not ranges:
|
|
369
|
+
ranges = [{"start_line": 1, "end_line": 1}]
|
|
370
|
+
|
|
371
|
+
# Conversation entry
|
|
372
|
+
conversation: dict = {
|
|
373
|
+
"contributor": {"type": contributor_type},
|
|
374
|
+
"ranges": ranges,
|
|
375
|
+
}
|
|
376
|
+
if model_id:
|
|
377
|
+
conversation["contributor"]["model_id"] = model_id
|
|
378
|
+
if conversation_id:
|
|
379
|
+
conversation["id"] = conversation_id
|
|
380
|
+
if content_sha256:
|
|
381
|
+
conversation["content_sha256"] = content_sha256
|
|
382
|
+
|
|
383
|
+
trace: dict = {
|
|
384
|
+
"version": "2.0",
|
|
385
|
+
"id": str(uuid.uuid4()),
|
|
386
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
387
|
+
"tool": get_tool_info(),
|
|
388
|
+
"files": [
|
|
389
|
+
{
|
|
390
|
+
"path": res.rel_path,
|
|
391
|
+
"conversations": [conversation],
|
|
392
|
+
}
|
|
393
|
+
],
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
if res.vcs:
|
|
397
|
+
trace["vcs"] = res.vcs
|
|
398
|
+
|
|
399
|
+
meta: dict = {}
|
|
400
|
+
if metadata:
|
|
401
|
+
meta = {k: v for k, v in metadata.items() if v is not None}
|
|
402
|
+
meta["project_id"] = res.project_id
|
|
403
|
+
meta["repo_root"] = root
|
|
404
|
+
if edit_sequence is not None:
|
|
405
|
+
meta["edit_sequence"] = edit_sequence
|
|
406
|
+
if meta:
|
|
407
|
+
trace["metadata"] = meta
|
|
408
|
+
|
|
409
|
+
return trace
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def cli_resolve_project_root(project_arg: str | None, cwd: str | None = None) -> str:
|
|
413
|
+
"""Resolve ``--project`` (path or known project_id) or cwd to a git root directory."""
|
|
414
|
+
from .registry import get_project_record
|
|
415
|
+
|
|
416
|
+
base = os.path.abspath(cwd or os.getcwd())
|
|
417
|
+
if project_arg:
|
|
418
|
+
p = os.path.abspath(os.path.expanduser(project_arg.strip()))
|
|
419
|
+
if os.path.isdir(p):
|
|
420
|
+
gr = git_repo_root_for_path(p)
|
|
421
|
+
if gr:
|
|
422
|
+
return gr
|
|
423
|
+
rec = get_project_record(project_arg)
|
|
424
|
+
if rec:
|
|
425
|
+
cr = rec.get("canonical_root")
|
|
426
|
+
if cr and os.path.isdir(cr):
|
|
427
|
+
return os.path.realpath(cr)
|
|
428
|
+
roots = rec.get("known_roots") or []
|
|
429
|
+
for r in roots:
|
|
430
|
+
if r and os.path.isdir(r):
|
|
431
|
+
return os.path.realpath(r)
|
|
432
|
+
raise SystemExit(
|
|
433
|
+
f"agent-trace: cannot resolve --project {project_arg!r} to a directory",
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
gr = git_repo_root_for_path(base)
|
|
437
|
+
if gr:
|
|
438
|
+
return gr
|
|
439
|
+
raise SystemExit(
|
|
440
|
+
"agent-trace: not inside a git repository; pass --project <path|id>",
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def discover_ambiguous_repo_roots(cwd: str | None = None) -> list[str]:
|
|
445
|
+
"""If ``cwd`` is not in a git repo but contains multiple git checkouts, return their roots."""
|
|
446
|
+
base = os.path.abspath(cwd or os.getcwd())
|
|
447
|
+
if git_repo_root_for_path(base):
|
|
448
|
+
return []
|
|
449
|
+
found: list[str] = []
|
|
450
|
+
try:
|
|
451
|
+
for name in sorted(os.listdir(base)):
|
|
452
|
+
sub = os.path.join(base, name)
|
|
453
|
+
if not os.path.isdir(sub):
|
|
454
|
+
continue
|
|
455
|
+
g = git_repo_root_for_path(sub)
|
|
456
|
+
if g and g not in found:
|
|
457
|
+
found.append(g)
|
|
458
|
+
except OSError:
|
|
459
|
+
pass
|
|
460
|
+
return found
|