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/src/config.py ADDED
@@ -0,0 +1,90 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime, timezone
4
+ import json
5
+ import os
6
+ from pathlib import Path
7
+ from typing import Any
8
+ from uuid import uuid4
9
+
10
+ DEFAULT_CONFIG_PATH = Path(
11
+ os.environ.get("PROMPTHUB_CONFIG_PATH", "~/.prompthub/config.json")
12
+ ).expanduser()
13
+ DEFAULT_APP_URL = os.environ.get("PROMPTHUB_APP_URL", "http://127.0.0.1:5173")
14
+ DEFAULT_API_URL = os.environ.get("PROMPTHUB_API_URL", "http://127.0.0.1:8011")
15
+ DEFAULT_UPLOADER_PID_PATH = Path(
16
+ os.environ.get("PROMPTHUB_UPLOADER_PID_PATH", "~/.prompthub/uploader.pid")
17
+ ).expanduser()
18
+ DEFAULT_UPLOADER_LOG_PATH = Path(
19
+ os.environ.get("PROMPTHUB_UPLOADER_LOG_PATH", "~/.prompthub/uploader.log")
20
+ ).expanduser()
21
+
22
+
23
+ def utc_now_iso() -> str:
24
+ return datetime.now(timezone.utc).isoformat()
25
+
26
+
27
+ def config_path(path: str | Path | None = None) -> Path:
28
+ return Path(path).expanduser() if path else DEFAULT_CONFIG_PATH
29
+
30
+
31
+ def read_config(path: str | Path | None = None) -> dict[str, Any]:
32
+ target = config_path(path)
33
+ if not target.exists():
34
+ return {}
35
+
36
+ try:
37
+ with target.open("r", encoding="utf-8") as file:
38
+ payload = json.load(file)
39
+ except (OSError, json.JSONDecodeError):
40
+ return {}
41
+
42
+ return payload if isinstance(payload, dict) else {}
43
+
44
+
45
+ def write_config(values: dict[str, Any], path: str | Path | None = None) -> dict[str, Any]:
46
+ target = config_path(path)
47
+ existing = read_config(target)
48
+ merged = {**existing, **values, "updated_at": utc_now_iso()}
49
+ target.parent.mkdir(parents=True, exist_ok=True)
50
+ tmp_path = target.with_suffix(f"{target.suffix}.{uuid4()}.tmp")
51
+ with tmp_path.open("w", encoding="utf-8") as file:
52
+ json.dump(merged, file, ensure_ascii=False, indent=2, sort_keys=True)
53
+ file.write("\n")
54
+ tmp_path.replace(target)
55
+ try:
56
+ target.chmod(0o600)
57
+ except OSError:
58
+ pass
59
+ return merged
60
+
61
+
62
+ def resolve_app_url(value: str | None = None, path: str | Path | None = None) -> str:
63
+ if value:
64
+ return value.rstrip("/")
65
+ if os.environ.get("PROMPTHUB_APP_URL"):
66
+ return os.environ["PROMPTHUB_APP_URL"].rstrip("/")
67
+ configured = read_config(path).get("app_url")
68
+ if isinstance(configured, str) and configured.strip():
69
+ return configured.rstrip("/")
70
+ return DEFAULT_APP_URL.rstrip("/")
71
+
72
+
73
+ def resolve_api_url(value: str | None = None, path: str | Path | None = None) -> str:
74
+ if value:
75
+ return value.rstrip("/")
76
+ if os.environ.get("PROMPTHUB_API_URL"):
77
+ return os.environ["PROMPTHUB_API_URL"].rstrip("/")
78
+ configured = read_config(path).get("api_url")
79
+ if isinstance(configured, str) and configured.strip():
80
+ return configured.rstrip("/")
81
+ return DEFAULT_API_URL.rstrip("/")
82
+
83
+
84
+ def resolve_token(value: str | None = None, path: str | Path | None = None) -> str | None:
85
+ if value:
86
+ return value
87
+ if os.environ.get("PROMPTHUB_API_TOKEN"):
88
+ return os.environ["PROMPTHUB_API_TOKEN"]
89
+ configured = read_config(path).get("token")
90
+ return configured if isinstance(configured, str) and configured.strip() else None
package/src/events.py ADDED
@@ -0,0 +1,313 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import asdict, dataclass, field
4
+ from datetime import datetime, timezone
5
+ import os
6
+ from pathlib import Path
7
+ from typing import Any, Literal
8
+ from uuid import NAMESPACE_DNS, UUID, uuid4, uuid5
9
+
10
+ SupportedTool = Literal["claude-code", "codex-cli", "cursor", "gemini-cli"]
11
+ EventType = Literal[
12
+ "SessionStarted",
13
+ "PromptSubmitted",
14
+ "ResponseReceived",
15
+ "FilesChanged",
16
+ "CommitCreated",
17
+ "SessionEnded",
18
+ ]
19
+
20
+ SUPPORTED_TOOLS: tuple[SupportedTool, ...] = (
21
+ "claude-code",
22
+ "codex-cli",
23
+ "cursor",
24
+ "gemini-cli",
25
+ )
26
+ SUPPORTED_EVENT_TYPES: tuple[EventType, ...] = (
27
+ "SessionStarted",
28
+ "PromptSubmitted",
29
+ "ResponseReceived",
30
+ "FilesChanged",
31
+ "CommitCreated",
32
+ "SessionEnded",
33
+ )
34
+ EVENT_TYPE_ALIASES: dict[str, EventType] = {
35
+ "SessionStart": "SessionStarted",
36
+ "SessionEnd": "SessionEnded",
37
+ "Stop": "ResponseReceived",
38
+ "SESSION_STARTED": "SessionStarted",
39
+ "PROMPT_SENT": "PromptSubmitted",
40
+ "PROMPT_RESPONSE": "ResponseReceived",
41
+ "FILES_CHANGED": "FilesChanged",
42
+ "COMMIT_CREATED": "CommitCreated",
43
+ "SESSION_ENDED": "SessionEnded",
44
+ "session_started": "SessionStarted",
45
+ "prompt_sent": "PromptSubmitted",
46
+ "prompt_submitted": "PromptSubmitted",
47
+ "response_received": "ResponseReceived",
48
+ "prompt_response": "ResponseReceived",
49
+ "files_changed": "FilesChanged",
50
+ "commit_created": "CommitCreated",
51
+ "session_ended": "SessionEnded",
52
+ }
53
+
54
+ TOOL_ALIASES: dict[str, SupportedTool] = {
55
+ "claude": "claude-code",
56
+ "claude-code": "claude-code",
57
+ "codex": "codex-cli",
58
+ "codex-cli": "codex-cli",
59
+ "cursor": "cursor",
60
+ "gemini": "gemini-cli",
61
+ "gemini-cli": "gemini-cli",
62
+ }
63
+
64
+
65
+ def utc_now_iso() -> str:
66
+ return datetime.now(timezone.utc).isoformat()
67
+
68
+
69
+ @dataclass(slots=True)
70
+ class PayloadBase:
71
+ def to_dict(self) -> dict[str, Any]:
72
+ return {key: value for key, value in asdict(self).items() if value is not None}
73
+
74
+
75
+ @dataclass(slots=True)
76
+ class SessionStartedPayload(PayloadBase):
77
+ cwd: str | None = None
78
+ branch: str | None = None
79
+ git_remote: str | None = None
80
+ github_url: str | None = None
81
+ model: str | None = None
82
+ permission_mode: str | None = None
83
+ session_id: str | None = None
84
+
85
+
86
+ @dataclass(slots=True)
87
+ class PromptSubmittedPayload(PayloadBase):
88
+ prompt: str
89
+ cwd: str | None = None
90
+ model: str | None = None
91
+ permission_mode: str | None = None
92
+ transcript_path: str | None = None
93
+ turn_id: str | int | None = None
94
+ session_id: str | None = None
95
+ branch: str | None = None
96
+ git_remote: str | None = None
97
+ github_url: str | None = None
98
+ hook_event_name: str | None = None
99
+ approval_policy: str | None = None
100
+ sandbox_mode: str | None = None
101
+
102
+
103
+ @dataclass(slots=True)
104
+ class ResponseReceivedPayload(PayloadBase):
105
+ response: str | None = None
106
+ response_truncated: bool = False
107
+ response_original_length: int | None = None
108
+ response_storage_limit: int | None = None
109
+ response_source: str | None = None
110
+ transcript_path: str | None = None
111
+ turn_id: str | int | None = None
112
+ duration_ms: int | None = None
113
+ success: bool | None = None
114
+ model: str | None = None
115
+ session_id: str | None = None
116
+
117
+
118
+ @dataclass(slots=True)
119
+ class FilesChangedPayload(PayloadBase):
120
+ files: list[str] = field(default_factory=list)
121
+ cwd: str | None = None
122
+ session_id: str | None = None
123
+ prompt_event_id: str | None = None
124
+ turn_id: str | int | None = None
125
+ git_root: str | None = None
126
+ branch: str | None = None
127
+ git_remote: str | None = None
128
+ github_url: str | None = None
129
+ base_commit: str | None = None
130
+ head_commit: str | None = None
131
+ baseline_captured_at: str | None = None
132
+ detected_at: str | None = None
133
+ source: str | None = None
134
+ summary: dict[str, Any] | None = None
135
+ changes: list[dict[str, Any]] = field(default_factory=list)
136
+ change_detection_complete: bool | None = None
137
+ no_changes: bool | None = None
138
+
139
+
140
+ @dataclass(slots=True)
141
+ class CommitCreatedPayload(PayloadBase):
142
+ hash: str | None = None
143
+ message: str | None = None
144
+ branch: str | None = None
145
+ git_remote: str | None = None
146
+ github_url: str | None = None
147
+ cwd: str | None = None
148
+ session_id: str | None = None
149
+
150
+
151
+ @dataclass(slots=True)
152
+ class SessionEndedPayload(PayloadBase):
153
+ reason: str | None = None
154
+ duration: int | None = None
155
+ session_id: str | None = None
156
+
157
+
158
+ EventPayload = (
159
+ SessionStartedPayload
160
+ | PromptSubmittedPayload
161
+ | ResponseReceivedPayload
162
+ | FilesChangedPayload
163
+ | CommitCreatedPayload
164
+ | SessionEndedPayload
165
+ )
166
+
167
+
168
+ @dataclass(slots=True)
169
+ class BaseEvent:
170
+ tool: SupportedTool
171
+ event_type: EventType
172
+ payload: EventPayload
173
+ project_id: str
174
+ session_id: str
175
+ sequence: int
176
+ id: str = field(default_factory=lambda: str(uuid4()))
177
+ timestamp: str = field(default_factory=utc_now_iso)
178
+ schema_version: int = 1
179
+
180
+ def to_dict(self) -> dict[str, Any]:
181
+ return {
182
+ "id": self.id,
183
+ "schema_version": self.schema_version,
184
+ "project_id": self.project_id,
185
+ "session_id": self.session_id,
186
+ "sequence": self.sequence,
187
+ "tool": self.tool,
188
+ "event_type": self.event_type,
189
+ "timestamp": self.timestamp,
190
+ "payload": self.payload.to_dict(),
191
+ }
192
+
193
+
194
+ def normalize_tool(tool: str) -> SupportedTool:
195
+ normalized = TOOL_ALIASES.get(tool)
196
+ if normalized is None:
197
+ raise ValueError(f"Unsupported tool: {tool}")
198
+ return normalized
199
+
200
+
201
+ def normalize_event_type(event_type: str) -> EventType:
202
+ if event_type not in SUPPORTED_EVENT_TYPES:
203
+ alias = EVENT_TYPE_ALIASES.get(event_type)
204
+ if alias:
205
+ return alias
206
+ raise ValueError(f"Unsupported event type: {event_type}")
207
+ return event_type # type: ignore[return-value]
208
+
209
+
210
+ def coerce_uuid(value: Any) -> str | None:
211
+ if value is None:
212
+ return None
213
+ try:
214
+ return str(UUID(str(value)))
215
+ except ValueError:
216
+ return None
217
+
218
+
219
+ def stable_uuid(namespace_name: str, value: Any) -> str:
220
+ namespace = uuid5(NAMESPACE_DNS, namespace_name)
221
+ return str(uuid5(namespace, str(value)))
222
+
223
+
224
+ def find_project_root(path_value: Any) -> str:
225
+ path = Path(str(path_value)).expanduser()
226
+ if not path.exists():
227
+ return str(path_value)
228
+ try:
229
+ candidate = path if path.is_dir() else path.parent
230
+ candidate = candidate.resolve()
231
+ except OSError:
232
+ return str(path_value)
233
+
234
+ for current in (candidate, *candidate.parents):
235
+ if (current / ".git").exists():
236
+ return str(current)
237
+ return str(candidate)
238
+
239
+
240
+ def resolve_project_id(raw_payload: dict[str, Any], event_payload: dict[str, Any]) -> str:
241
+ explicit_project_id = (
242
+ coerce_uuid(raw_payload.get("project_id"))
243
+ or coerce_uuid(event_payload.get("project_id"))
244
+ or coerce_uuid(os.environ.get("PROMPTHUB_PROJECT_ID"))
245
+ )
246
+ if explicit_project_id:
247
+ return explicit_project_id
248
+
249
+ project_seed = (
250
+ event_payload.get("cwd")
251
+ or raw_payload.get("cwd")
252
+ or raw_payload.get("workspace")
253
+ or os.getcwd()
254
+ )
255
+ return stable_uuid("prompthub.project", find_project_root(project_seed))
256
+
257
+
258
+ def resolve_session_id(
259
+ tool: SupportedTool,
260
+ project_id: str,
261
+ raw_payload: dict[str, Any],
262
+ event_payload: dict[str, Any],
263
+ ) -> str:
264
+ explicit_session_id = (
265
+ coerce_uuid(raw_payload.get("session_id"))
266
+ or coerce_uuid(event_payload.get("session_id"))
267
+ or coerce_uuid(os.environ.get("PROMPTHUB_SESSION_ID"))
268
+ )
269
+ if explicit_session_id:
270
+ return explicit_session_id
271
+
272
+ session_seed = (
273
+ raw_payload.get("session_id")
274
+ or raw_payload.get("conversation_id")
275
+ or raw_payload.get("thread_id")
276
+ or event_payload.get("session_id")
277
+ or f"{project_id}:{tool}"
278
+ )
279
+ return stable_uuid("prompthub.session", f"{project_id}:{tool}:{session_seed}")
280
+
281
+
282
+ def resolve_sequence(raw_payload: dict[str, Any], event_payload: dict[str, Any]) -> int:
283
+ for key in ("sequence", "seq", "turn_id", "turn", "message_index"):
284
+ value = raw_payload.get(key)
285
+ if value is None:
286
+ value = event_payload.get(key)
287
+ if isinstance(value, int) and value > 0:
288
+ return value
289
+ if isinstance(value, str) and value.isdigit():
290
+ return int(value)
291
+ return 0
292
+
293
+
294
+ def build_event(
295
+ *,
296
+ tool: SupportedTool,
297
+ event_type: EventType,
298
+ payload: EventPayload,
299
+ raw_payload: dict[str, Any],
300
+ ) -> BaseEvent:
301
+ event_payload = payload.to_dict()
302
+ project_id = resolve_project_id(raw_payload, event_payload)
303
+ session_id = resolve_session_id(tool, project_id, raw_payload, event_payload)
304
+ sequence = resolve_sequence(raw_payload, event_payload)
305
+
306
+ return BaseEvent(
307
+ project_id=project_id,
308
+ session_id=session_id,
309
+ sequence=sequence,
310
+ tool=tool,
311
+ event_type=event_type,
312
+ payload=payload,
313
+ )
@@ -0,0 +1,38 @@
1
+ from __future__ import annotations
2
+
3
+ from contextlib import contextmanager
4
+ from pathlib import Path
5
+ from typing import Iterator, TextIO
6
+
7
+ try:
8
+ import fcntl
9
+ except ImportError: # pragma: no cover - Windows
10
+ fcntl = None
11
+
12
+ try:
13
+ import msvcrt
14
+ except ImportError: # pragma: no cover - POSIX
15
+ msvcrt = None
16
+
17
+
18
+ @contextmanager
19
+ def locked_file(path: Path) -> Iterator[TextIO]:
20
+ path.parent.mkdir(parents=True, exist_ok=True)
21
+ with path.open("a+", encoding="utf-8") as lock_file:
22
+ if fcntl is not None:
23
+ fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX)
24
+ elif msvcrt is not None:
25
+ lock_file.seek(0, 2)
26
+ if lock_file.tell() == 0:
27
+ lock_file.write("\0")
28
+ lock_file.flush()
29
+ lock_file.seek(0)
30
+ msvcrt.locking(lock_file.fileno(), msvcrt.LK_LOCK, 1)
31
+ try:
32
+ yield lock_file
33
+ finally:
34
+ if fcntl is not None:
35
+ fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
36
+ elif msvcrt is not None:
37
+ lock_file.seek(0)
38
+ msvcrt.locking(lock_file.fileno(), msvcrt.LK_UNLCK, 1)
@@ -0,0 +1,69 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from pathlib import Path
5
+ import re
6
+ import subprocess
7
+
8
+ GIT_TIMEOUT_SECONDS = float(os.environ.get("PROMPTHUB_GIT_TIMEOUT", "5"))
9
+
10
+
11
+ def _run_git(args: list[str], cwd: str | Path, timeout: float = GIT_TIMEOUT_SECONDS) -> str | None:
12
+ try:
13
+ result = subprocess.run(
14
+ ["git", *args],
15
+ cwd=str(cwd),
16
+ check=False,
17
+ capture_output=True,
18
+ text=True,
19
+ timeout=timeout,
20
+ )
21
+ except (OSError, subprocess.TimeoutExpired):
22
+ return None
23
+
24
+ if result.returncode != 0:
25
+ return None
26
+ return result.stdout.strip() or None
27
+
28
+
29
+ def resolve_git_root(cwd: str | Path | None = None) -> str | None:
30
+ start = Path(cwd or os.getcwd()).expanduser()
31
+ output = _run_git(["rev-parse", "--show-toplevel"], start)
32
+ return output or None
33
+
34
+
35
+ def normalize_github_url(remote_url: str | None) -> str | None:
36
+ if not remote_url:
37
+ return None
38
+
39
+ value = remote_url.strip()
40
+ patterns = (
41
+ r"^git@github\.com:(?P<owner>[^/]+)/(?P<repo>[^/]+?)(?:\.git)?$",
42
+ r"^ssh://git@github\.com/(?P<owner>[^/]+)/(?P<repo>[^/]+?)(?:\.git)?$",
43
+ r"^https://github\.com/(?P<owner>[^/]+)/(?P<repo>[^/]+?)(?:\.git)?/?$",
44
+ )
45
+ for pattern in patterns:
46
+ match = re.match(pattern, value)
47
+ if match:
48
+ return f"https://github.com/{match.group('owner')}/{match.group('repo')}"
49
+
50
+ return value if value.startswith("https://github.com/") else None
51
+
52
+
53
+ def git_context(cwd: str | Path | None = None) -> dict[str, str]:
54
+ git_root = resolve_git_root(cwd)
55
+ if git_root is None:
56
+ return {}
57
+
58
+ remote_url = (
59
+ _run_git(["remote", "get-url", "origin"], git_root)
60
+ or _run_git(["config", "--get", "remote.origin.url"], git_root)
61
+ )
62
+ branch = _run_git(["branch", "--show-current"], git_root)
63
+ context = {
64
+ "git_root": git_root,
65
+ "branch": branch,
66
+ "git_remote": remote_url,
67
+ "github_url": normalize_github_url(remote_url),
68
+ }
69
+ return {key: value for key, value in context.items() if value}
@@ -0,0 +1,58 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Mapping
4
+ from typing import Any
5
+
6
+ SESSION_ID_KEYS = ("session_id", "conversation_id", "thread_id")
7
+ WORKSPACE_KEYS = ("cwd", "working_directory", "workspace")
8
+ PROJECT_CONTEXT_KEYS = ("project_id", *WORKSPACE_KEYS)
9
+ TURN_ID_KEYS = ("turn_id", "turn", "message_index")
10
+
11
+
12
+ def payload_views(payload: Mapping[str, Any]) -> tuple[Mapping[str, Any], ...]:
13
+ nested_payload = payload.get("payload")
14
+ if isinstance(nested_payload, Mapping):
15
+ return nested_payload, payload
16
+ return (payload,)
17
+
18
+
19
+ def get_first_value(payload: Mapping[str, Any], keys: tuple[str, ...]) -> Any:
20
+ return first_value(payload_views(payload), keys)
21
+
22
+
23
+ def get_first_string(payload: Mapping[str, Any], keys: tuple[str, ...]) -> str | None:
24
+ return first_string(payload_views(payload), keys)
25
+
26
+
27
+ def first_value(payloads: tuple[Mapping[str, Any], ...], keys: tuple[str, ...]) -> Any:
28
+ for payload in payloads:
29
+ for key in keys:
30
+ if key in payload and payload[key] is not None:
31
+ return payload[key]
32
+ return None
33
+
34
+
35
+ def first_string(payloads: tuple[Mapping[str, Any], ...], keys: tuple[str, ...]) -> str | None:
36
+ value = first_value(payloads, keys)
37
+ return value if isinstance(value, str) and value else None
38
+
39
+
40
+ def first_int(payloads: tuple[Mapping[str, Any], ...], keys: tuple[str, ...]) -> int | None:
41
+ value = first_value(payloads, keys)
42
+ if isinstance(value, int):
43
+ return value
44
+ if isinstance(value, str) and value.isdigit():
45
+ return int(value)
46
+ return None
47
+
48
+
49
+ def first_bool(payloads: tuple[Mapping[str, Any], ...], keys: tuple[str, ...]) -> bool | None:
50
+ value = first_value(payloads, keys)
51
+ return value if isinstance(value, bool) else None
52
+
53
+
54
+ def string_list(payloads: tuple[Mapping[str, Any], ...], keys: tuple[str, ...]) -> list[str]:
55
+ value = first_value(payloads, keys)
56
+ if not isinstance(value, list):
57
+ return []
58
+ return [item for item in value if isinstance(item, str)]