promty-collector 0.1.0
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.
- package/bin/promty.js +17 -0
- package/package.json +18 -0
- package/src/adapters/__init__.py +32 -0
- package/src/adapters/claude/__init__.py +1 -0
- package/src/adapters/claude/hook.py +11 -0
- package/src/adapters/codex/__init__.py +1 -0
- package/src/adapters/codex/hook.py +11 -0
- package/src/adapters/common.py +193 -0
- package/src/adapters/cursor/__init__.py +1 -0
- package/src/adapters/cursor/hook.py +11 -0
- package/src/adapters/gemini/__init__.py +1 -0
- package/src/adapters/gemini/hook.py +11 -0
- package/src/change_tracking.py +696 -0
- package/src/cli.py +1110 -0
- package/src/config.py +90 -0
- package/src/events.py +313 -0
- package/src/file_lock.py +38 -0
- package/src/git_context.py +69 -0
- package/src/payloads.py +58 -0
- package/src/response_capture.py +199 -0
- package/src/runtime_install.py +162 -0
- package/src/sequence.py +68 -0
- package/src/session_index.py +75 -0
- package/src/uploader/__init__.py +1 -0
- package/src/uploader/client.py +35 -0
- package/src/uploader/queue.py +130 -0
|
@@ -0,0 +1,696 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from contextlib import contextmanager
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from datetime import datetime, timedelta, timezone
|
|
6
|
+
import difflib
|
|
7
|
+
import fnmatch
|
|
8
|
+
import hashlib
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
import subprocess
|
|
13
|
+
from typing import Any
|
|
14
|
+
from uuid import uuid4
|
|
15
|
+
|
|
16
|
+
from events import BaseEvent
|
|
17
|
+
from file_lock import locked_file
|
|
18
|
+
from git_context import normalize_github_url
|
|
19
|
+
from payloads import TURN_ID_KEYS, get_first_value
|
|
20
|
+
|
|
21
|
+
DEFAULT_CHANGE_BASELINE_PATH = Path(
|
|
22
|
+
os.environ.get("PROMPTHUB_CHANGE_BASELINE_PATH", "~/.prompthub/change-baselines.json")
|
|
23
|
+
).expanduser()
|
|
24
|
+
GIT_TIMEOUT_SECONDS = float(os.environ.get("PROMPTHUB_GIT_TIMEOUT", "5"))
|
|
25
|
+
FINGERPRINT_MAX_BYTES = int(os.environ.get("PROMPTHUB_FINGERPRINT_MAX_BYTES", "2097152"))
|
|
26
|
+
PATCH_CONTENT_MAX_BYTES = int(os.environ.get("PROMPTHUB_PATCH_CONTENT_MAX_BYTES", "524288"))
|
|
27
|
+
PATCH_MAX_BYTES = int(os.environ.get("PROMPTHUB_PATCH_MAX_BYTES", "262144"))
|
|
28
|
+
UNTRACKED_LINE_COUNT_MAX_BYTES = int(
|
|
29
|
+
os.environ.get("PROMPTHUB_UNTRACKED_LINE_COUNT_MAX_BYTES", "1048576")
|
|
30
|
+
)
|
|
31
|
+
BASELINE_TTL_HOURS = float(os.environ.get("PROMPTHUB_BASELINE_TTL_HOURS", "24"))
|
|
32
|
+
CONSUMED_BASELINE_TTL_HOURS = float(
|
|
33
|
+
os.environ.get("PROMPTHUB_CONSUMED_BASELINE_TTL_HOURS", "1")
|
|
34
|
+
)
|
|
35
|
+
BASELINE_MAX_RECORDS = int(os.environ.get("PROMPTHUB_BASELINE_MAX_RECORDS", "500"))
|
|
36
|
+
PATCH_EXCLUDED_DIRS = {
|
|
37
|
+
".git",
|
|
38
|
+
".hg",
|
|
39
|
+
".svn",
|
|
40
|
+
".venv",
|
|
41
|
+
"__pycache__",
|
|
42
|
+
"build",
|
|
43
|
+
"coverage",
|
|
44
|
+
"dist",
|
|
45
|
+
"node_modules",
|
|
46
|
+
}
|
|
47
|
+
PATCH_SENSITIVE_PATTERNS = (
|
|
48
|
+
".env",
|
|
49
|
+
".env.*",
|
|
50
|
+
"*.key",
|
|
51
|
+
"*.pem",
|
|
52
|
+
"*.p12",
|
|
53
|
+
"*.pfx",
|
|
54
|
+
"*id_rsa*",
|
|
55
|
+
"*secret*",
|
|
56
|
+
"*token*",
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(slots=True)
|
|
61
|
+
class ChangeDetectionResult:
|
|
62
|
+
baseline: dict[str, Any]
|
|
63
|
+
current: dict[str, Any]
|
|
64
|
+
payload: dict[str, Any]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def utc_now_iso() -> str:
|
|
68
|
+
return datetime.now(timezone.utc).isoformat()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _parse_datetime(value: Any) -> datetime | None:
|
|
72
|
+
if not isinstance(value, str) or not value:
|
|
73
|
+
return None
|
|
74
|
+
try:
|
|
75
|
+
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
76
|
+
except ValueError:
|
|
77
|
+
return None
|
|
78
|
+
if parsed.tzinfo is None:
|
|
79
|
+
return parsed.replace(tzinfo=timezone.utc)
|
|
80
|
+
return parsed
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _run_git(args: list[str], cwd: str | Path, timeout: float = GIT_TIMEOUT_SECONDS) -> str | None:
|
|
84
|
+
try:
|
|
85
|
+
result = subprocess.run(
|
|
86
|
+
["git", *args],
|
|
87
|
+
cwd=str(cwd),
|
|
88
|
+
check=False,
|
|
89
|
+
capture_output=True,
|
|
90
|
+
text=True,
|
|
91
|
+
timeout=timeout,
|
|
92
|
+
)
|
|
93
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
if result.returncode != 0:
|
|
97
|
+
return None
|
|
98
|
+
return result.stdout
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _run_git_bytes(
|
|
102
|
+
args: list[str],
|
|
103
|
+
cwd: str | Path,
|
|
104
|
+
timeout: float = GIT_TIMEOUT_SECONDS,
|
|
105
|
+
) -> bytes | None:
|
|
106
|
+
try:
|
|
107
|
+
result = subprocess.run(
|
|
108
|
+
["git", *args],
|
|
109
|
+
cwd=str(cwd),
|
|
110
|
+
check=False,
|
|
111
|
+
capture_output=True,
|
|
112
|
+
timeout=timeout,
|
|
113
|
+
)
|
|
114
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
115
|
+
return None
|
|
116
|
+
|
|
117
|
+
if result.returncode != 0:
|
|
118
|
+
return None
|
|
119
|
+
return result.stdout
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def resolve_git_root(cwd: str | Path | None) -> str | None:
|
|
123
|
+
start = Path(cwd or os.getcwd()).expanduser()
|
|
124
|
+
output = _run_git(["rev-parse", "--show-toplevel"], start)
|
|
125
|
+
if output is None:
|
|
126
|
+
return None
|
|
127
|
+
return output.strip() or None
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _patch_omitted_reason(path: str) -> str | None:
|
|
131
|
+
parts = [part for part in path.replace("\\", "/").split("/") if part]
|
|
132
|
+
if any(part in PATCH_EXCLUDED_DIRS for part in parts):
|
|
133
|
+
return "excluded_path"
|
|
134
|
+
name = parts[-1] if parts else path
|
|
135
|
+
lowered_path = path.lower()
|
|
136
|
+
lowered_name = name.lower()
|
|
137
|
+
for pattern in PATCH_SENSITIVE_PATTERNS:
|
|
138
|
+
if fnmatch.fnmatch(lowered_name, pattern) or fnmatch.fnmatch(lowered_path, pattern):
|
|
139
|
+
return "sensitive_path"
|
|
140
|
+
return None
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _decode_text(data: bytes | None) -> str | None:
|
|
144
|
+
if data is None or b"\0" in data or len(data) > PATCH_CONTENT_MAX_BYTES:
|
|
145
|
+
return None
|
|
146
|
+
try:
|
|
147
|
+
return data.decode("utf-8")
|
|
148
|
+
except UnicodeDecodeError:
|
|
149
|
+
return None
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _read_worktree_text(git_root: str, path: str) -> str | None:
|
|
153
|
+
if _patch_omitted_reason(path):
|
|
154
|
+
return None
|
|
155
|
+
file_path = Path(git_root) / path
|
|
156
|
+
try:
|
|
157
|
+
stat = file_path.stat()
|
|
158
|
+
except OSError:
|
|
159
|
+
return None
|
|
160
|
+
if not file_path.is_file() or stat.st_size > PATCH_CONTENT_MAX_BYTES:
|
|
161
|
+
return None
|
|
162
|
+
try:
|
|
163
|
+
return _decode_text(file_path.read_bytes())
|
|
164
|
+
except OSError:
|
|
165
|
+
return None
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _git_show_text(git_root: str, commit: str | None, path: str) -> str | None:
|
|
169
|
+
if not commit or _patch_omitted_reason(path):
|
|
170
|
+
return None
|
|
171
|
+
return _decode_text(_run_git_bytes(["show", f"{commit}:{path}"], git_root))
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _parse_num(value: str) -> int | None:
|
|
175
|
+
if value == "-":
|
|
176
|
+
return None
|
|
177
|
+
try:
|
|
178
|
+
return int(value)
|
|
179
|
+
except ValueError:
|
|
180
|
+
return None
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _parse_numstat(output: str | None) -> dict[str, dict[str, Any]]:
|
|
184
|
+
if not output:
|
|
185
|
+
return {}
|
|
186
|
+
|
|
187
|
+
entries: dict[str, dict[str, Any]] = {}
|
|
188
|
+
for line in output.splitlines():
|
|
189
|
+
parts = line.split("\t")
|
|
190
|
+
if len(parts) < 3:
|
|
191
|
+
continue
|
|
192
|
+
insertions = _parse_num(parts[0])
|
|
193
|
+
deletions = _parse_num(parts[1])
|
|
194
|
+
path = parts[-1]
|
|
195
|
+
entries[path] = {
|
|
196
|
+
"insertions": insertions,
|
|
197
|
+
"deletions": deletions,
|
|
198
|
+
"binary": insertions is None or deletions is None,
|
|
199
|
+
}
|
|
200
|
+
return entries
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _parse_status(output: str | None) -> dict[str, dict[str, Any]]:
|
|
204
|
+
if not output:
|
|
205
|
+
return {}
|
|
206
|
+
|
|
207
|
+
entries: dict[str, dict[str, Any]] = {}
|
|
208
|
+
for line in output.splitlines():
|
|
209
|
+
if len(line) < 4:
|
|
210
|
+
continue
|
|
211
|
+
code = line[:2]
|
|
212
|
+
path = line[3:]
|
|
213
|
+
old_path = None
|
|
214
|
+
if " -> " in path:
|
|
215
|
+
old_path, path = path.split(" -> ", 1)
|
|
216
|
+
entries[path] = {
|
|
217
|
+
"code": code,
|
|
218
|
+
"old_path": old_path,
|
|
219
|
+
}
|
|
220
|
+
return entries
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _file_fingerprint(path: Path) -> dict[str, Any] | None:
|
|
224
|
+
if not path.exists() or not path.is_file():
|
|
225
|
+
return None
|
|
226
|
+
|
|
227
|
+
try:
|
|
228
|
+
stat = path.stat()
|
|
229
|
+
except OSError:
|
|
230
|
+
return None
|
|
231
|
+
|
|
232
|
+
fingerprint: dict[str, Any] = {
|
|
233
|
+
"size": stat.st_size,
|
|
234
|
+
}
|
|
235
|
+
if stat.st_size > FINGERPRINT_MAX_BYTES:
|
|
236
|
+
fingerprint["mtime_ns"] = stat.st_mtime_ns
|
|
237
|
+
fingerprint["truncated"] = True
|
|
238
|
+
return fingerprint
|
|
239
|
+
|
|
240
|
+
try:
|
|
241
|
+
digest = hashlib.sha256(path.read_bytes()).hexdigest()
|
|
242
|
+
except OSError:
|
|
243
|
+
return fingerprint
|
|
244
|
+
|
|
245
|
+
fingerprint["sha256"] = digest
|
|
246
|
+
return fingerprint
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _count_text_lines(path: Path) -> int | None:
|
|
250
|
+
try:
|
|
251
|
+
stat = path.stat()
|
|
252
|
+
except OSError:
|
|
253
|
+
return None
|
|
254
|
+
|
|
255
|
+
if stat.st_size > UNTRACKED_LINE_COUNT_MAX_BYTES:
|
|
256
|
+
return None
|
|
257
|
+
|
|
258
|
+
try:
|
|
259
|
+
data = path.read_bytes()
|
|
260
|
+
except OSError:
|
|
261
|
+
return None
|
|
262
|
+
|
|
263
|
+
if b"\0" in data:
|
|
264
|
+
return None
|
|
265
|
+
if not data:
|
|
266
|
+
return 0
|
|
267
|
+
return data.count(b"\n") + (0 if data.endswith(b"\n") else 1)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def capture_git_snapshot(cwd: str | Path | None) -> dict[str, Any] | None:
|
|
271
|
+
git_root = resolve_git_root(cwd)
|
|
272
|
+
if git_root is None:
|
|
273
|
+
return None
|
|
274
|
+
|
|
275
|
+
status = _parse_status(
|
|
276
|
+
_run_git(["status", "--porcelain=v1", "--untracked-files=all"], git_root)
|
|
277
|
+
)
|
|
278
|
+
numstat = _parse_numstat(_run_git(["diff", "--numstat", "HEAD", "--"], git_root))
|
|
279
|
+
signatures: dict[str, dict[str, Any] | None] = {}
|
|
280
|
+
contents: dict[str, dict[str, Any]] = {}
|
|
281
|
+
|
|
282
|
+
for path, entry in status.items():
|
|
283
|
+
signatures[path] = _file_fingerprint(Path(git_root) / path)
|
|
284
|
+
text = _read_worktree_text(git_root, path)
|
|
285
|
+
if text is not None:
|
|
286
|
+
contents[path] = {
|
|
287
|
+
"content": text,
|
|
288
|
+
"captured_at": utc_now_iso(),
|
|
289
|
+
}
|
|
290
|
+
if entry.get("code") == "??" and path not in numstat:
|
|
291
|
+
line_count = _count_text_lines(Path(git_root) / path)
|
|
292
|
+
numstat[path] = {
|
|
293
|
+
"insertions": line_count,
|
|
294
|
+
"deletions": 0 if line_count is not None else None,
|
|
295
|
+
"binary": line_count is None,
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
return {
|
|
299
|
+
"git_root": git_root,
|
|
300
|
+
"head_commit": (_run_git(["rev-parse", "HEAD"], git_root) or "").strip() or None,
|
|
301
|
+
"branch": (_run_git(["branch", "--show-current"], git_root) or "").strip() or None,
|
|
302
|
+
"git_remote": (_run_git(["remote", "get-url", "origin"], git_root) or "").strip() or None,
|
|
303
|
+
"status": status,
|
|
304
|
+
"numstat": numstat,
|
|
305
|
+
"signatures": signatures,
|
|
306
|
+
"contents": contents,
|
|
307
|
+
"captured_at": utc_now_iso(),
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _status_from_code(code: str | None, baseline_present: bool) -> str:
|
|
312
|
+
if code is None:
|
|
313
|
+
return "cleaned" if baseline_present else "modified"
|
|
314
|
+
if code == "??":
|
|
315
|
+
return "added"
|
|
316
|
+
if "R" in code:
|
|
317
|
+
return "renamed"
|
|
318
|
+
if "D" in code:
|
|
319
|
+
return "deleted"
|
|
320
|
+
if "A" in code:
|
|
321
|
+
return "added"
|
|
322
|
+
return "modified"
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _metric_delta(
|
|
326
|
+
current: dict[str, Any] | None,
|
|
327
|
+
baseline: dict[str, Any] | None,
|
|
328
|
+
key: str,
|
|
329
|
+
) -> int | None:
|
|
330
|
+
current_value = (current or {}).get(key)
|
|
331
|
+
baseline_value = (baseline or {}).get(key)
|
|
332
|
+
if current_value is None and baseline_value is None:
|
|
333
|
+
return None
|
|
334
|
+
return (current_value or 0) - (baseline_value or 0)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _snapshot_content(snapshot: dict[str, Any], path: str | None) -> str | None:
|
|
338
|
+
if not path:
|
|
339
|
+
return None
|
|
340
|
+
contents = snapshot.get("contents")
|
|
341
|
+
if not isinstance(contents, dict):
|
|
342
|
+
return None
|
|
343
|
+
entry = contents.get(path)
|
|
344
|
+
if not isinstance(entry, dict):
|
|
345
|
+
return None
|
|
346
|
+
content = entry.get("content")
|
|
347
|
+
return content if isinstance(content, str) else None
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _before_text(
|
|
351
|
+
baseline_snapshot: dict[str, Any],
|
|
352
|
+
path: str,
|
|
353
|
+
old_path: str | None,
|
|
354
|
+
) -> str | None:
|
|
355
|
+
baseline_status = baseline_snapshot.get("status") or {}
|
|
356
|
+
baseline_entry = baseline_status.get(path) or (baseline_status.get(old_path) if old_path else None)
|
|
357
|
+
baseline_code = (baseline_entry or {}).get("code")
|
|
358
|
+
if baseline_code and ("A" in baseline_code or baseline_code == "??"):
|
|
359
|
+
return _snapshot_content(baseline_snapshot, path) or _snapshot_content(baseline_snapshot, old_path)
|
|
360
|
+
|
|
361
|
+
snapshot_text = _snapshot_content(baseline_snapshot, path) or _snapshot_content(baseline_snapshot, old_path)
|
|
362
|
+
if snapshot_text is not None:
|
|
363
|
+
return snapshot_text
|
|
364
|
+
|
|
365
|
+
git_root = baseline_snapshot.get("git_root")
|
|
366
|
+
if not isinstance(git_root, str):
|
|
367
|
+
return None
|
|
368
|
+
return _git_show_text(git_root, baseline_snapshot.get("head_commit"), old_path or path)
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _after_text(current_snapshot: dict[str, Any], path: str, status: str) -> str | None:
|
|
372
|
+
if status == "deleted":
|
|
373
|
+
return None
|
|
374
|
+
git_root = current_snapshot.get("git_root")
|
|
375
|
+
if not isinstance(git_root, str):
|
|
376
|
+
return None
|
|
377
|
+
return _read_worktree_text(git_root, path)
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _build_patch(
|
|
381
|
+
*,
|
|
382
|
+
after: str | None,
|
|
383
|
+
before: str | None,
|
|
384
|
+
old_path: str | None,
|
|
385
|
+
path: str,
|
|
386
|
+
) -> tuple[str | None, bool]:
|
|
387
|
+
before_lines = [] if before is None else before.splitlines(keepends=True)
|
|
388
|
+
after_lines = [] if after is None else after.splitlines(keepends=True)
|
|
389
|
+
patch = "".join(
|
|
390
|
+
difflib.unified_diff(
|
|
391
|
+
before_lines,
|
|
392
|
+
after_lines,
|
|
393
|
+
fromfile=f"a/{old_path or path}",
|
|
394
|
+
tofile=f"b/{path}",
|
|
395
|
+
)
|
|
396
|
+
)
|
|
397
|
+
if not patch:
|
|
398
|
+
return None, False
|
|
399
|
+
encoded = patch.encode("utf-8")
|
|
400
|
+
if len(encoded) <= PATCH_MAX_BYTES:
|
|
401
|
+
return patch, False
|
|
402
|
+
truncated = encoded[:PATCH_MAX_BYTES].decode("utf-8", errors="ignore")
|
|
403
|
+
return f"{truncated}\n\n[Promty patch truncated]", True
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _attach_patch(
|
|
407
|
+
change: dict[str, Any],
|
|
408
|
+
*,
|
|
409
|
+
baseline_snapshot: dict[str, Any],
|
|
410
|
+
current_snapshot: dict[str, Any],
|
|
411
|
+
) -> dict[str, Any]:
|
|
412
|
+
path = change["path"]
|
|
413
|
+
old_path = change.get("old_path")
|
|
414
|
+
reason = _patch_omitted_reason(path)
|
|
415
|
+
if reason is not None:
|
|
416
|
+
change["patch_omitted_reason"] = reason
|
|
417
|
+
return change
|
|
418
|
+
if change.get("binary") is True:
|
|
419
|
+
change["patch_omitted_reason"] = "binary"
|
|
420
|
+
return change
|
|
421
|
+
|
|
422
|
+
before = _before_text(baseline_snapshot, path, old_path if isinstance(old_path, str) else None)
|
|
423
|
+
after = _after_text(current_snapshot, path, str(change.get("status") or "modified"))
|
|
424
|
+
if before is None and after is None:
|
|
425
|
+
change["patch_omitted_reason"] = "content_unavailable"
|
|
426
|
+
return change
|
|
427
|
+
|
|
428
|
+
patch, truncated = _build_patch(before=before, after=after, old_path=old_path, path=path)
|
|
429
|
+
if patch:
|
|
430
|
+
change["patch"] = patch
|
|
431
|
+
change["patch_truncated"] = truncated
|
|
432
|
+
else:
|
|
433
|
+
change["patch_omitted_reason"] = "empty_patch"
|
|
434
|
+
return change
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _changed_paths(
|
|
438
|
+
baseline_snapshot: dict[str, Any],
|
|
439
|
+
current_snapshot: dict[str, Any],
|
|
440
|
+
) -> list[dict[str, Any]]:
|
|
441
|
+
baseline_status = baseline_snapshot.get("status") or {}
|
|
442
|
+
current_status = current_snapshot.get("status") or {}
|
|
443
|
+
baseline_numstat = baseline_snapshot.get("numstat") or {}
|
|
444
|
+
current_numstat = current_snapshot.get("numstat") or {}
|
|
445
|
+
baseline_signatures = baseline_snapshot.get("signatures") or {}
|
|
446
|
+
current_signatures = current_snapshot.get("signatures") or {}
|
|
447
|
+
paths = sorted(
|
|
448
|
+
set(baseline_status)
|
|
449
|
+
| set(current_status)
|
|
450
|
+
| set(baseline_numstat)
|
|
451
|
+
| set(current_numstat)
|
|
452
|
+
| set(baseline_signatures)
|
|
453
|
+
| set(current_signatures)
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
changes: list[dict[str, Any]] = []
|
|
457
|
+
for path in paths:
|
|
458
|
+
before_status = baseline_status.get(path)
|
|
459
|
+
after_status = current_status.get(path)
|
|
460
|
+
before_numstat = baseline_numstat.get(path)
|
|
461
|
+
after_numstat = current_numstat.get(path)
|
|
462
|
+
before_signature = baseline_signatures.get(path)
|
|
463
|
+
after_signature = current_signatures.get(path)
|
|
464
|
+
|
|
465
|
+
if (
|
|
466
|
+
before_status == after_status
|
|
467
|
+
and before_numstat == after_numstat
|
|
468
|
+
and before_signature == after_signature
|
|
469
|
+
):
|
|
470
|
+
continue
|
|
471
|
+
|
|
472
|
+
insertions_delta = _metric_delta(after_numstat, before_numstat, "insertions")
|
|
473
|
+
deletions_delta = _metric_delta(after_numstat, before_numstat, "deletions")
|
|
474
|
+
change = {
|
|
475
|
+
"path": path,
|
|
476
|
+
"status": _status_from_code(
|
|
477
|
+
(after_status or {}).get("code"),
|
|
478
|
+
baseline_present=before_status is not None or before_numstat is not None,
|
|
479
|
+
),
|
|
480
|
+
"old_path": (after_status or before_status or {}).get("old_path"),
|
|
481
|
+
"before_path": (after_status or before_status or {}).get("old_path"),
|
|
482
|
+
"git_status": (after_status or {}).get("code"),
|
|
483
|
+
"insertions": (after_numstat or {}).get("insertions"),
|
|
484
|
+
"deletions": (after_numstat or {}).get("deletions"),
|
|
485
|
+
"insertions_delta": insertions_delta,
|
|
486
|
+
"deletions_delta": deletions_delta,
|
|
487
|
+
"additions": insertions_delta,
|
|
488
|
+
"binary": bool((after_numstat or {}).get("binary")),
|
|
489
|
+
}
|
|
490
|
+
if deletions_delta is not None:
|
|
491
|
+
change["removals"] = deletions_delta
|
|
492
|
+
change = _attach_patch(
|
|
493
|
+
change,
|
|
494
|
+
baseline_snapshot=baseline_snapshot,
|
|
495
|
+
current_snapshot=current_snapshot,
|
|
496
|
+
)
|
|
497
|
+
changes.append({key: value for key, value in change.items() if value is not None})
|
|
498
|
+
|
|
499
|
+
return changes
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def _summarize_changes(changes: list[dict[str, Any]]) -> dict[str, Any]:
|
|
503
|
+
summary = {
|
|
504
|
+
"total": len(changes),
|
|
505
|
+
"files_changed": len(changes),
|
|
506
|
+
"files": len(changes),
|
|
507
|
+
"added": 0,
|
|
508
|
+
"modified": 0,
|
|
509
|
+
"deleted": 0,
|
|
510
|
+
"renamed": 0,
|
|
511
|
+
"cleaned": 0,
|
|
512
|
+
"additions": 0,
|
|
513
|
+
"deletions": 0,
|
|
514
|
+
"insertions_delta": 0,
|
|
515
|
+
"deletions_delta": 0,
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
for change in changes:
|
|
519
|
+
status = change.get("status")
|
|
520
|
+
if status in summary:
|
|
521
|
+
summary[status] += 1
|
|
522
|
+
additions = change.get("insertions_delta") or 0
|
|
523
|
+
deletions = change.get("deletions_delta") or 0
|
|
524
|
+
summary["additions"] += additions
|
|
525
|
+
summary["deletions"] += deletions
|
|
526
|
+
summary["insertions_delta"] += additions
|
|
527
|
+
summary["deletions_delta"] += deletions
|
|
528
|
+
return summary
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def detect_changes(record: dict[str, Any], cwd: str | Path | None = None) -> ChangeDetectionResult | None:
|
|
532
|
+
baseline_snapshot = record.get("snapshot")
|
|
533
|
+
if not isinstance(baseline_snapshot, dict):
|
|
534
|
+
return None
|
|
535
|
+
|
|
536
|
+
current_snapshot = capture_git_snapshot(cwd or record.get("cwd") or baseline_snapshot.get("git_root"))
|
|
537
|
+
if current_snapshot is None:
|
|
538
|
+
return None
|
|
539
|
+
|
|
540
|
+
if current_snapshot.get("git_root") != baseline_snapshot.get("git_root"):
|
|
541
|
+
return None
|
|
542
|
+
|
|
543
|
+
changes = _changed_paths(baseline_snapshot, current_snapshot)
|
|
544
|
+
payload = {
|
|
545
|
+
"files": [change["path"] for change in changes],
|
|
546
|
+
"cwd": record.get("cwd"),
|
|
547
|
+
"session_id": record.get("external_session_id"),
|
|
548
|
+
"prompt_event_id": record.get("prompt_event_id"),
|
|
549
|
+
"turn_id": record.get("turn_id"),
|
|
550
|
+
"git_root": baseline_snapshot.get("git_root"),
|
|
551
|
+
"branch": current_snapshot.get("branch") or baseline_snapshot.get("branch"),
|
|
552
|
+
"git_remote": current_snapshot.get("git_remote") or baseline_snapshot.get("git_remote"),
|
|
553
|
+
"github_url": normalize_github_url(
|
|
554
|
+
current_snapshot.get("git_remote") or baseline_snapshot.get("git_remote")
|
|
555
|
+
),
|
|
556
|
+
"base_commit": baseline_snapshot.get("head_commit"),
|
|
557
|
+
"head_commit": current_snapshot.get("head_commit"),
|
|
558
|
+
"baseline_captured_at": baseline_snapshot.get("captured_at"),
|
|
559
|
+
"detected_at": current_snapshot.get("captured_at"),
|
|
560
|
+
"source": "git",
|
|
561
|
+
"summary": _summarize_changes(changes),
|
|
562
|
+
"changes": changes,
|
|
563
|
+
"change_detection_complete": True,
|
|
564
|
+
"no_changes": not changes,
|
|
565
|
+
}
|
|
566
|
+
return ChangeDetectionResult(
|
|
567
|
+
baseline=record,
|
|
568
|
+
current=current_snapshot,
|
|
569
|
+
payload={key: value for key, value in payload.items() if value is not None},
|
|
570
|
+
)
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
class ChangeBaselineStore:
|
|
574
|
+
def __init__(self, path: str | Path | None = None) -> None:
|
|
575
|
+
self.path = Path(path).expanduser() if path else DEFAULT_CHANGE_BASELINE_PATH
|
|
576
|
+
self.lock_path = self.path.with_suffix(f"{self.path.suffix}.lock")
|
|
577
|
+
|
|
578
|
+
def observe_prompt(
|
|
579
|
+
self,
|
|
580
|
+
*,
|
|
581
|
+
tool: str,
|
|
582
|
+
event: BaseEvent,
|
|
583
|
+
raw_payload: dict[str, Any],
|
|
584
|
+
external_session_id: str | None,
|
|
585
|
+
cwd: str | None,
|
|
586
|
+
) -> None:
|
|
587
|
+
snapshot = capture_git_snapshot(cwd)
|
|
588
|
+
if snapshot is None:
|
|
589
|
+
return
|
|
590
|
+
|
|
591
|
+
record = {
|
|
592
|
+
"id": event.id,
|
|
593
|
+
"tool": tool,
|
|
594
|
+
"prompt_event_id": event.id,
|
|
595
|
+
"project_id": event.project_id,
|
|
596
|
+
"session_id": event.session_id,
|
|
597
|
+
"external_session_id": external_session_id,
|
|
598
|
+
"turn_id": get_first_value(raw_payload, TURN_ID_KEYS),
|
|
599
|
+
"cwd": cwd,
|
|
600
|
+
"created_at": utc_now_iso(),
|
|
601
|
+
"consumed_at": None,
|
|
602
|
+
"snapshot": snapshot,
|
|
603
|
+
}
|
|
604
|
+
with self._locked():
|
|
605
|
+
data = self._read()
|
|
606
|
+
data.setdefault("records", {})[event.id] = record
|
|
607
|
+
self._prune(data)
|
|
608
|
+
self._write(data)
|
|
609
|
+
|
|
610
|
+
def find_latest(
|
|
611
|
+
self,
|
|
612
|
+
*,
|
|
613
|
+
tool: str,
|
|
614
|
+
external_session_id: str | None,
|
|
615
|
+
cwd: str | None,
|
|
616
|
+
) -> dict[str, Any] | None:
|
|
617
|
+
git_root = resolve_git_root(cwd) if cwd else None
|
|
618
|
+
with self._locked():
|
|
619
|
+
records = list((self._read().get("records") or {}).values())
|
|
620
|
+
|
|
621
|
+
candidates: list[dict[str, Any]] = []
|
|
622
|
+
for record in records:
|
|
623
|
+
if not isinstance(record, dict) or record.get("consumed_at"):
|
|
624
|
+
continue
|
|
625
|
+
if record.get("tool") != tool:
|
|
626
|
+
continue
|
|
627
|
+
if external_session_id and record.get("external_session_id") == external_session_id:
|
|
628
|
+
candidates.append(record)
|
|
629
|
+
continue
|
|
630
|
+
record_git_root = ((record.get("snapshot") or {}).get("git_root"))
|
|
631
|
+
if git_root and record_git_root == git_root:
|
|
632
|
+
candidates.append(record)
|
|
633
|
+
|
|
634
|
+
candidates.sort(key=lambda record: str(record.get("created_at") or ""))
|
|
635
|
+
return candidates[-1] if candidates else None
|
|
636
|
+
|
|
637
|
+
def mark_consumed(self, record_id: str) -> None:
|
|
638
|
+
with self._locked():
|
|
639
|
+
data = self._read()
|
|
640
|
+
record = (data.get("records") or {}).get(record_id)
|
|
641
|
+
if isinstance(record, dict):
|
|
642
|
+
record["consumed_at"] = utc_now_iso()
|
|
643
|
+
self._prune(data)
|
|
644
|
+
self._write(data)
|
|
645
|
+
|
|
646
|
+
def _prune(self, data: dict[str, Any]) -> None:
|
|
647
|
+
records = data.get("records")
|
|
648
|
+
if not isinstance(records, dict):
|
|
649
|
+
data["records"] = {}
|
|
650
|
+
return
|
|
651
|
+
|
|
652
|
+
now = datetime.now(timezone.utc)
|
|
653
|
+
baseline_cutoff = now - timedelta(hours=BASELINE_TTL_HOURS)
|
|
654
|
+
consumed_cutoff = now - timedelta(hours=CONSUMED_BASELINE_TTL_HOURS)
|
|
655
|
+
|
|
656
|
+
retained: list[tuple[str, dict[str, Any], datetime]] = []
|
|
657
|
+
for record_id, record in records.items():
|
|
658
|
+
if not isinstance(record_id, str) or not isinstance(record, dict):
|
|
659
|
+
continue
|
|
660
|
+
created_at = _parse_datetime(record.get("created_at")) or now
|
|
661
|
+
consumed_at = _parse_datetime(record.get("consumed_at"))
|
|
662
|
+
if consumed_at is not None and consumed_at < consumed_cutoff:
|
|
663
|
+
continue
|
|
664
|
+
if consumed_at is None and created_at < baseline_cutoff:
|
|
665
|
+
continue
|
|
666
|
+
retained.append((record_id, record, created_at))
|
|
667
|
+
|
|
668
|
+
retained.sort(key=lambda item: item[2])
|
|
669
|
+
if len(retained) > BASELINE_MAX_RECORDS:
|
|
670
|
+
retained = retained[-BASELINE_MAX_RECORDS:]
|
|
671
|
+
data["records"] = {record_id: record for record_id, record, _ in retained}
|
|
672
|
+
|
|
673
|
+
@contextmanager
|
|
674
|
+
def _locked(self):
|
|
675
|
+
with locked_file(self.lock_path):
|
|
676
|
+
yield
|
|
677
|
+
|
|
678
|
+
def _read(self) -> dict[str, Any]:
|
|
679
|
+
if not self.path.exists():
|
|
680
|
+
return {"records": {}}
|
|
681
|
+
try:
|
|
682
|
+
with self.path.open("r", encoding="utf-8") as file:
|
|
683
|
+
data = json.load(file)
|
|
684
|
+
except (OSError, json.JSONDecodeError):
|
|
685
|
+
return {"records": {}}
|
|
686
|
+
if not isinstance(data, dict):
|
|
687
|
+
return {"records": {}}
|
|
688
|
+
data.setdefault("records", {})
|
|
689
|
+
return data
|
|
690
|
+
|
|
691
|
+
def _write(self, data: dict[str, Any]) -> None:
|
|
692
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
693
|
+
tmp_path = self.path.with_suffix(f"{self.path.suffix}.{uuid4()}.tmp")
|
|
694
|
+
with tmp_path.open("w", encoding="utf-8") as file:
|
|
695
|
+
json.dump(data, file, ensure_ascii=False)
|
|
696
|
+
tmp_path.replace(self.path)
|