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
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
|
+
)
|
package/src/file_lock.py
ADDED
|
@@ -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}
|
package/src/payloads.py
ADDED
|
@@ -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)]
|