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,199 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from payloads import first_string, first_value, payload_views
|
|
10
|
+
|
|
11
|
+
RESPONSE_CAPTURE_MAX_CHARS = int(
|
|
12
|
+
os.environ.get("PROMPTHUB_RESPONSE_CAPTURE_MAX_CHARS", "50000")
|
|
13
|
+
)
|
|
14
|
+
TRANSCRIPT_CAPTURE_MAX_BYTES = int(
|
|
15
|
+
os.environ.get("PROMPTHUB_RESPONSE_TRANSCRIPT_MAX_BYTES", "1048576")
|
|
16
|
+
)
|
|
17
|
+
RESPONSE_KEYS = (
|
|
18
|
+
"response",
|
|
19
|
+
"assistant_response",
|
|
20
|
+
"assistant_message",
|
|
21
|
+
"last_assistant_message",
|
|
22
|
+
"answer",
|
|
23
|
+
"completion",
|
|
24
|
+
"output",
|
|
25
|
+
"output_text",
|
|
26
|
+
"result",
|
|
27
|
+
"text",
|
|
28
|
+
"message",
|
|
29
|
+
"content",
|
|
30
|
+
)
|
|
31
|
+
TRANSCRIPT_PATH_KEYS = ("transcript_path", "transcript")
|
|
32
|
+
ASSISTANT_ROLES = {"assistant", "ai", "model"}
|
|
33
|
+
SKIPPED_CONTENT_TYPES = {"tool_call", "tool_result", "tool_use", "function_call"}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def response_payload_fields(raw_payload: Mapping[str, Any]) -> dict[str, Any]:
|
|
37
|
+
payloads = payload_views(raw_payload)
|
|
38
|
+
response_source: str | None = None
|
|
39
|
+
response_text = _direct_response_text(payloads)
|
|
40
|
+
if response_text:
|
|
41
|
+
response_source = "hook_payload"
|
|
42
|
+
|
|
43
|
+
transcript_path = first_string(payloads, TRANSCRIPT_PATH_KEYS)
|
|
44
|
+
if not response_text and transcript_path:
|
|
45
|
+
response_text = _response_text_from_transcript(transcript_path)
|
|
46
|
+
if response_text:
|
|
47
|
+
response_source = "transcript"
|
|
48
|
+
|
|
49
|
+
fields: dict[str, Any] = {}
|
|
50
|
+
if transcript_path:
|
|
51
|
+
fields["transcript_path"] = transcript_path
|
|
52
|
+
if not response_text:
|
|
53
|
+
return fields
|
|
54
|
+
|
|
55
|
+
original_length = len(response_text)
|
|
56
|
+
limit = max(RESPONSE_CAPTURE_MAX_CHARS, 1)
|
|
57
|
+
fields.update(
|
|
58
|
+
{
|
|
59
|
+
"response": response_text[:limit],
|
|
60
|
+
"response_original_length": original_length,
|
|
61
|
+
"response_source": response_source,
|
|
62
|
+
"response_storage_limit": limit,
|
|
63
|
+
"response_truncated": original_length > limit,
|
|
64
|
+
}
|
|
65
|
+
)
|
|
66
|
+
return fields
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _direct_response_text(payloads: tuple[Mapping[str, Any], ...]) -> str | None:
|
|
70
|
+
for payload in payloads:
|
|
71
|
+
for key in RESPONSE_KEYS:
|
|
72
|
+
value = first_value((payload,), (key,))
|
|
73
|
+
text = _extract_text(value)
|
|
74
|
+
if text:
|
|
75
|
+
return text
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _response_text_from_transcript(path_value: str) -> str | None:
|
|
80
|
+
transcript_path = Path(path_value).expanduser()
|
|
81
|
+
if not transcript_path.is_file():
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
try:
|
|
85
|
+
content = _read_transcript_tail(transcript_path)
|
|
86
|
+
except OSError:
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
if not content.strip():
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
parsed = _parse_json_document(content)
|
|
93
|
+
if parsed is not None:
|
|
94
|
+
text = _extract_assistant_text(parsed)
|
|
95
|
+
if text:
|
|
96
|
+
return text
|
|
97
|
+
|
|
98
|
+
records = []
|
|
99
|
+
for line in content.splitlines():
|
|
100
|
+
if not line.strip():
|
|
101
|
+
continue
|
|
102
|
+
try:
|
|
103
|
+
record = json.loads(line)
|
|
104
|
+
except json.JSONDecodeError:
|
|
105
|
+
continue
|
|
106
|
+
records.append(record)
|
|
107
|
+
|
|
108
|
+
for record in reversed(records):
|
|
109
|
+
text = _extract_assistant_text(record)
|
|
110
|
+
if text:
|
|
111
|
+
return text
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _read_transcript_tail(path: Path) -> str:
|
|
116
|
+
size = path.stat().st_size
|
|
117
|
+
max_bytes = max(TRANSCRIPT_CAPTURE_MAX_BYTES, 1)
|
|
118
|
+
with path.open("rb") as file:
|
|
119
|
+
if size > max_bytes:
|
|
120
|
+
file.seek(size - max_bytes)
|
|
121
|
+
file.readline()
|
|
122
|
+
return file.read(max_bytes).decode("utf-8", errors="ignore")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _parse_json_document(content: str) -> Any:
|
|
126
|
+
try:
|
|
127
|
+
return json.loads(content)
|
|
128
|
+
except json.JSONDecodeError:
|
|
129
|
+
return None
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _extract_assistant_text(value: Any) -> str | None:
|
|
133
|
+
if isinstance(value, list):
|
|
134
|
+
for item in reversed(value):
|
|
135
|
+
text = _extract_assistant_text(item)
|
|
136
|
+
if text:
|
|
137
|
+
return text
|
|
138
|
+
return None
|
|
139
|
+
|
|
140
|
+
if not isinstance(value, dict):
|
|
141
|
+
return None
|
|
142
|
+
|
|
143
|
+
role = _role(value)
|
|
144
|
+
if role in ASSISTANT_ROLES:
|
|
145
|
+
return _extract_text(value)
|
|
146
|
+
|
|
147
|
+
value_type = value.get("type")
|
|
148
|
+
if isinstance(value_type, str) and "assistant" in value_type.lower():
|
|
149
|
+
text = _extract_text(value)
|
|
150
|
+
if text:
|
|
151
|
+
return text
|
|
152
|
+
|
|
153
|
+
for key in ("message", "payload", "item", "data", "response", "event"):
|
|
154
|
+
text = _extract_assistant_text(value.get(key))
|
|
155
|
+
if text:
|
|
156
|
+
return text
|
|
157
|
+
|
|
158
|
+
for key in ("messages", "items", "events", "responses", "output"):
|
|
159
|
+
text = _extract_assistant_text(value.get(key))
|
|
160
|
+
if text:
|
|
161
|
+
return text
|
|
162
|
+
|
|
163
|
+
return None
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _role(value: Mapping[str, Any]) -> str | None:
|
|
167
|
+
role = value.get("role") or value.get("author")
|
|
168
|
+
if isinstance(role, str):
|
|
169
|
+
return role.lower()
|
|
170
|
+
if isinstance(role, Mapping):
|
|
171
|
+
nested_role = role.get("role") or role.get("name")
|
|
172
|
+
if isinstance(nested_role, str):
|
|
173
|
+
return nested_role.lower()
|
|
174
|
+
return None
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _extract_text(value: Any) -> str | None:
|
|
178
|
+
if isinstance(value, str):
|
|
179
|
+
text = value.strip()
|
|
180
|
+
return text or None
|
|
181
|
+
|
|
182
|
+
if isinstance(value, list):
|
|
183
|
+
parts = [_extract_text(item) for item in value]
|
|
184
|
+
text = "\n".join(part for part in parts if part)
|
|
185
|
+
return text.strip() or None
|
|
186
|
+
|
|
187
|
+
if not isinstance(value, dict):
|
|
188
|
+
return None
|
|
189
|
+
|
|
190
|
+
value_type = value.get("type")
|
|
191
|
+
if isinstance(value_type, str) and value_type in SKIPPED_CONTENT_TYPES:
|
|
192
|
+
return None
|
|
193
|
+
|
|
194
|
+
for key in ("text", "output_text", "content", "message", "body", "response"):
|
|
195
|
+
text = _extract_text(value.get(key))
|
|
196
|
+
if text:
|
|
197
|
+
return text
|
|
198
|
+
|
|
199
|
+
return None
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import secrets
|
|
8
|
+
import shlex
|
|
9
|
+
import shutil
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
|
|
13
|
+
from file_lock import locked_file
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
RUNTIME_MODULES: tuple[str, ...] = (
|
|
17
|
+
"change_tracking.py",
|
|
18
|
+
"cli.py",
|
|
19
|
+
"config.py",
|
|
20
|
+
"events.py",
|
|
21
|
+
"file_lock.py",
|
|
22
|
+
"git_context.py",
|
|
23
|
+
"payloads.py",
|
|
24
|
+
"response_capture.py",
|
|
25
|
+
"runtime_install.py",
|
|
26
|
+
"sequence.py",
|
|
27
|
+
"session_index.py",
|
|
28
|
+
)
|
|
29
|
+
RUNTIME_PACKAGES: tuple[str, ...] = ("adapters", "uploader")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def runtime_home() -> Path:
|
|
33
|
+
return Path(os.environ.get("PROMTY_HOME", "~/.promty")).expanduser().resolve()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _runtime_python_command() -> str:
|
|
37
|
+
configured = os.environ.get("PROMPTHUB_HOOK_PYTHON")
|
|
38
|
+
if configured:
|
|
39
|
+
return configured
|
|
40
|
+
promty_python = os.environ.get("PROMTY_PYTHON")
|
|
41
|
+
if promty_python:
|
|
42
|
+
if os.name == "nt":
|
|
43
|
+
return subprocess.list2cmdline([promty_python])
|
|
44
|
+
return shlex.quote(promty_python)
|
|
45
|
+
if os.name == "nt":
|
|
46
|
+
return subprocess.list2cmdline([sys.executable])
|
|
47
|
+
return shlex.quote(sys.executable)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _source_files() -> tuple[tuple[Path, Path], ...]:
|
|
51
|
+
source_root = Path(__file__).resolve().parent
|
|
52
|
+
files: list[tuple[Path, Path]] = []
|
|
53
|
+
for module_name in RUNTIME_MODULES:
|
|
54
|
+
source_path = source_root / module_name
|
|
55
|
+
if not source_path.is_file():
|
|
56
|
+
raise FileNotFoundError(f"Promty runtime source is missing: {source_path}")
|
|
57
|
+
files.append((Path(module_name), source_path))
|
|
58
|
+
|
|
59
|
+
for package_name in RUNTIME_PACKAGES:
|
|
60
|
+
package_root = source_root / package_name
|
|
61
|
+
package_files = sorted(package_root.rglob("*.py"))
|
|
62
|
+
if not package_files:
|
|
63
|
+
raise FileNotFoundError(f"Promty runtime package is missing: {package_root}")
|
|
64
|
+
files.extend(
|
|
65
|
+
(source_path.relative_to(source_root), source_path) for source_path in package_files
|
|
66
|
+
)
|
|
67
|
+
return tuple(files)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _digest(files: tuple[tuple[Path, Path], ...]) -> str:
|
|
71
|
+
digest = hashlib.sha256()
|
|
72
|
+
for relative_path, source_path in files:
|
|
73
|
+
digest.update(relative_path.as_posix().encode("utf-8"))
|
|
74
|
+
digest.update(b"\0")
|
|
75
|
+
digest.update(source_path.read_bytes())
|
|
76
|
+
digest.update(b"\0")
|
|
77
|
+
return digest.hexdigest()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _is_complete(
|
|
81
|
+
runtime_path: Path,
|
|
82
|
+
files: tuple[tuple[Path, Path], ...],
|
|
83
|
+
expected_digest: str,
|
|
84
|
+
) -> bool:
|
|
85
|
+
runtime_files = tuple(
|
|
86
|
+
(relative_path, runtime_path / relative_path) for relative_path, _ in files
|
|
87
|
+
)
|
|
88
|
+
if not all(path.is_file() for _, path in runtime_files):
|
|
89
|
+
return False
|
|
90
|
+
return _digest(runtime_files) == expected_digest
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _install_files() -> Path:
|
|
94
|
+
files = _source_files()
|
|
95
|
+
digest = _digest(files)
|
|
96
|
+
runtime_root = runtime_home() / "runtime"
|
|
97
|
+
runtime_path = runtime_root / digest
|
|
98
|
+
runtime_root.mkdir(parents=True, exist_ok=True)
|
|
99
|
+
with locked_file(runtime_root / ".install.lock"):
|
|
100
|
+
if _is_complete(runtime_path, files, digest):
|
|
101
|
+
return runtime_path
|
|
102
|
+
if runtime_path.exists():
|
|
103
|
+
shutil.rmtree(runtime_path)
|
|
104
|
+
|
|
105
|
+
staging_path = runtime_root / f".{digest}.{secrets.token_hex(4)}.tmp"
|
|
106
|
+
try:
|
|
107
|
+
for relative_path, source_path in files:
|
|
108
|
+
destination = staging_path / relative_path
|
|
109
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
110
|
+
shutil.copy2(source_path, destination)
|
|
111
|
+
(staging_path / "runtime.json").write_text(
|
|
112
|
+
json.dumps(
|
|
113
|
+
{
|
|
114
|
+
"digest": digest,
|
|
115
|
+
"files": [path.as_posix() for path, _ in files],
|
|
116
|
+
},
|
|
117
|
+
indent=2,
|
|
118
|
+
sort_keys=True,
|
|
119
|
+
)
|
|
120
|
+
+ "\n",
|
|
121
|
+
encoding="utf-8",
|
|
122
|
+
)
|
|
123
|
+
staging_path.replace(runtime_path)
|
|
124
|
+
finally:
|
|
125
|
+
if staging_path.exists():
|
|
126
|
+
shutil.rmtree(staging_path)
|
|
127
|
+
return runtime_path
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def launcher_path() -> Path:
|
|
131
|
+
name = "promty.cmd" if os.name == "nt" else "promty"
|
|
132
|
+
return runtime_home() / "bin" / name
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _write_launcher(runtime_path: Path) -> Path:
|
|
136
|
+
target = launcher_path()
|
|
137
|
+
cli_path = runtime_path / "cli.py"
|
|
138
|
+
if os.name == "nt":
|
|
139
|
+
content = f'@echo off\r\n{_runtime_python_command()} "{cli_path}" %*\r\n'
|
|
140
|
+
else:
|
|
141
|
+
content = f'#!/bin/sh\nexec {_runtime_python_command()} {shlex.quote(str(cli_path))} "$@"\n'
|
|
142
|
+
|
|
143
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
144
|
+
temporary_path = target.with_name(f".{target.name}.{secrets.token_hex(4)}.tmp")
|
|
145
|
+
try:
|
|
146
|
+
temporary_path.write_text(content, encoding="utf-8", newline="")
|
|
147
|
+
if os.name != "nt":
|
|
148
|
+
temporary_path.chmod(0o755)
|
|
149
|
+
temporary_path.replace(target)
|
|
150
|
+
finally:
|
|
151
|
+
temporary_path.unlink(missing_ok=True)
|
|
152
|
+
return target
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def install_runtime() -> Path:
|
|
156
|
+
return _write_launcher(_install_files())
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def quote_command_path(path: Path) -> str:
|
|
160
|
+
if os.name == "nt":
|
|
161
|
+
return subprocess.list2cmdline([str(path)])
|
|
162
|
+
return shlex.quote(str(path))
|
package/src/sequence.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from contextlib import contextmanager
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from uuid import uuid4
|
|
8
|
+
|
|
9
|
+
from events import BaseEvent
|
|
10
|
+
from file_lock import locked_file
|
|
11
|
+
|
|
12
|
+
DEFAULT_SEQUENCE_PATH = Path(
|
|
13
|
+
os.environ.get("PROMPTHUB_SEQUENCE_PATH", "~/.prompthub/sequences.json")
|
|
14
|
+
).expanduser()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SequenceStore:
|
|
18
|
+
def __init__(self, path: str | Path | None = None) -> None:
|
|
19
|
+
self.path = Path(path).expanduser() if path else DEFAULT_SEQUENCE_PATH
|
|
20
|
+
self.lock_path = self.path.with_suffix(f"{self.path.suffix}.lock")
|
|
21
|
+
|
|
22
|
+
def assign(self, event: BaseEvent) -> BaseEvent:
|
|
23
|
+
with self._locked():
|
|
24
|
+
if event.sequence > 0:
|
|
25
|
+
self._observe(event.session_id, event.sequence)
|
|
26
|
+
return event
|
|
27
|
+
|
|
28
|
+
sequences = self._read()
|
|
29
|
+
next_sequence = sequences.get(event.session_id, 0) + 1
|
|
30
|
+
sequences[event.session_id] = next_sequence
|
|
31
|
+
self._write(sequences)
|
|
32
|
+
event.sequence = next_sequence
|
|
33
|
+
return event
|
|
34
|
+
|
|
35
|
+
@contextmanager
|
|
36
|
+
def _locked(self):
|
|
37
|
+
with locked_file(self.lock_path):
|
|
38
|
+
yield
|
|
39
|
+
|
|
40
|
+
def _observe(self, session_id: str, sequence: int) -> None:
|
|
41
|
+
sequences = self._read()
|
|
42
|
+
if sequences.get(session_id, 0) >= sequence:
|
|
43
|
+
return
|
|
44
|
+
sequences[session_id] = sequence
|
|
45
|
+
self._write(sequences)
|
|
46
|
+
|
|
47
|
+
def _read(self) -> dict[str, int]:
|
|
48
|
+
if not self.path.exists():
|
|
49
|
+
return {}
|
|
50
|
+
|
|
51
|
+
with self.path.open("r", encoding="utf-8") as file:
|
|
52
|
+
payload = json.load(file)
|
|
53
|
+
|
|
54
|
+
if not isinstance(payload, dict):
|
|
55
|
+
return {}
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
key: value
|
|
59
|
+
for key, value in payload.items()
|
|
60
|
+
if isinstance(key, str) and isinstance(value, int)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
def _write(self, sequences: dict[str, int]) -> None:
|
|
64
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
65
|
+
tmp_path = self.path.with_suffix(f"{self.path.suffix}.{uuid4()}.tmp")
|
|
66
|
+
with tmp_path.open("w", encoding="utf-8") as file:
|
|
67
|
+
json.dump(sequences, file, ensure_ascii=False)
|
|
68
|
+
tmp_path.replace(self.path)
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from contextlib import contextmanager
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
from uuid import uuid4
|
|
9
|
+
|
|
10
|
+
from events import BaseEvent, SupportedTool, utc_now_iso
|
|
11
|
+
from file_lock import locked_file
|
|
12
|
+
|
|
13
|
+
DEFAULT_SESSION_INDEX_PATH = Path(
|
|
14
|
+
os.environ.get("PROMPTHUB_SESSION_INDEX_PATH", "~/.prompthub/session-index.json")
|
|
15
|
+
).expanduser()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class SessionIndex:
|
|
19
|
+
def __init__(self, path: str | Path | None = None) -> None:
|
|
20
|
+
self.path = Path(path).expanduser() if path else DEFAULT_SESSION_INDEX_PATH
|
|
21
|
+
self.lock_path = self.path.with_suffix(f"{self.path.suffix}.lock")
|
|
22
|
+
|
|
23
|
+
def lookup(self, tool: SupportedTool, external_session_id: str) -> dict[str, Any] | None:
|
|
24
|
+
with self._locked():
|
|
25
|
+
record = self._read().get(self._key(tool, external_session_id))
|
|
26
|
+
return record if isinstance(record, dict) else None
|
|
27
|
+
|
|
28
|
+
def observe(
|
|
29
|
+
self,
|
|
30
|
+
tool: SupportedTool,
|
|
31
|
+
external_session_id: str,
|
|
32
|
+
event: BaseEvent,
|
|
33
|
+
cwd: str | None = None,
|
|
34
|
+
) -> None:
|
|
35
|
+
with self._locked():
|
|
36
|
+
records = self._read()
|
|
37
|
+
key = self._key(tool, external_session_id)
|
|
38
|
+
existing = records.get(key)
|
|
39
|
+
existing_cwd = existing.get("cwd") if isinstance(existing, dict) else None
|
|
40
|
+
records[key] = {
|
|
41
|
+
"tool": tool,
|
|
42
|
+
"external_session_id": external_session_id,
|
|
43
|
+
"project_id": event.project_id,
|
|
44
|
+
"session_id": event.session_id,
|
|
45
|
+
"cwd": cwd or existing_cwd,
|
|
46
|
+
"updated_at": utc_now_iso(),
|
|
47
|
+
}
|
|
48
|
+
self._write(records)
|
|
49
|
+
|
|
50
|
+
def _key(self, tool: SupportedTool, external_session_id: str) -> str:
|
|
51
|
+
return f"{tool}:{external_session_id}"
|
|
52
|
+
|
|
53
|
+
@contextmanager
|
|
54
|
+
def _locked(self):
|
|
55
|
+
with locked_file(self.lock_path):
|
|
56
|
+
yield
|
|
57
|
+
|
|
58
|
+
def _read(self) -> dict[str, Any]:
|
|
59
|
+
if not self.path.exists():
|
|
60
|
+
return {}
|
|
61
|
+
|
|
62
|
+
try:
|
|
63
|
+
with self.path.open("r", encoding="utf-8") as file:
|
|
64
|
+
payload = json.load(file)
|
|
65
|
+
except (OSError, json.JSONDecodeError):
|
|
66
|
+
return {}
|
|
67
|
+
|
|
68
|
+
return payload if isinstance(payload, dict) else {}
|
|
69
|
+
|
|
70
|
+
def _write(self, records: dict[str, Any]) -> None:
|
|
71
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
72
|
+
tmp_path = self.path.with_suffix(f"{self.path.suffix}.{uuid4()}.tmp")
|
|
73
|
+
with tmp_path.open("w", encoding="utf-8") as file:
|
|
74
|
+
json.dump(records, file, ensure_ascii=False)
|
|
75
|
+
tmp_path.replace(self.path)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any
|
|
5
|
+
from urllib import request
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class PromptHubUploader:
|
|
9
|
+
def __init__(self, api_url: str, token: str | None = None, timeout: float = 10) -> None:
|
|
10
|
+
self.api_url = api_url.rstrip("/")
|
|
11
|
+
self.token = token
|
|
12
|
+
self.timeout = timeout
|
|
13
|
+
|
|
14
|
+
def upload_events(self, events: list[dict[str, Any]]) -> list[str]:
|
|
15
|
+
if not events:
|
|
16
|
+
return []
|
|
17
|
+
|
|
18
|
+
body = json.dumps({"events": events}).encode("utf-8")
|
|
19
|
+
headers = {"Content-Type": "application/json"}
|
|
20
|
+
if self.token:
|
|
21
|
+
headers["Authorization"] = f"Bearer {self.token}"
|
|
22
|
+
|
|
23
|
+
req = request.Request(
|
|
24
|
+
f"{self.api_url}/api/events/batch",
|
|
25
|
+
data=body,
|
|
26
|
+
headers=headers,
|
|
27
|
+
method="POST",
|
|
28
|
+
)
|
|
29
|
+
with request.urlopen(req, timeout=self.timeout) as response:
|
|
30
|
+
payload = json.loads(response.read().decode("utf-8"))
|
|
31
|
+
|
|
32
|
+
event_ids = payload.get("event_ids")
|
|
33
|
+
if isinstance(event_ids, list):
|
|
34
|
+
return [event_id for event_id in event_ids if isinstance(event_id, str)]
|
|
35
|
+
return [event["id"] for event in events if isinstance(event.get("id"), str)]
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from contextlib import contextmanager
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
from uuid import uuid4
|
|
9
|
+
|
|
10
|
+
from events import BaseEvent
|
|
11
|
+
from file_lock import locked_file
|
|
12
|
+
|
|
13
|
+
DEFAULT_QUEUE_ROOT = Path(
|
|
14
|
+
os.environ.get("PROMPTHUB_QUEUE_DIR", "~/.prompthub/events")
|
|
15
|
+
).expanduser()
|
|
16
|
+
LEGACY_QUEUE_PATH = Path("~/.prompthub/events.jsonl").expanduser()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _safe_path_part(value: Any) -> str:
|
|
20
|
+
safe_value = "".join(
|
|
21
|
+
char for char in str(value) if char.isalnum() or char in ("-", "_")
|
|
22
|
+
)
|
|
23
|
+
return safe_value or "unknown"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class JSONLQueue:
|
|
27
|
+
def __init__(self, path: str | Path | None = None) -> None:
|
|
28
|
+
configured_path = path or os.environ.get("PROMPTHUB_QUEUE_PATH")
|
|
29
|
+
self.root = None if configured_path else DEFAULT_QUEUE_ROOT
|
|
30
|
+
self.path = Path(configured_path).expanduser() if configured_path else DEFAULT_QUEUE_ROOT
|
|
31
|
+
self.lock_path = (
|
|
32
|
+
self.path.with_suffix(f"{self.path.suffix}.lock")
|
|
33
|
+
if self.root is None
|
|
34
|
+
else self.path / ".lock"
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
def push(self, event: BaseEvent) -> None:
|
|
38
|
+
with self._locked():
|
|
39
|
+
queue_file = self._event_path(event)
|
|
40
|
+
queue_file.parent.mkdir(parents=True, exist_ok=True)
|
|
41
|
+
with queue_file.open("a", encoding="utf-8") as file:
|
|
42
|
+
file.write(json.dumps(event.to_dict(), ensure_ascii=False))
|
|
43
|
+
file.write("\n")
|
|
44
|
+
|
|
45
|
+
def read_batch(self, limit: int = 100) -> list[dict[str, Any]]:
|
|
46
|
+
with self._locked():
|
|
47
|
+
events: list[dict[str, Any]] = []
|
|
48
|
+
for queue_file in self._queue_files():
|
|
49
|
+
with queue_file.open("r", encoding="utf-8") as file:
|
|
50
|
+
for line in file:
|
|
51
|
+
if len(events) >= limit:
|
|
52
|
+
return events
|
|
53
|
+
if not line.strip():
|
|
54
|
+
continue
|
|
55
|
+
try:
|
|
56
|
+
event = json.loads(line)
|
|
57
|
+
except json.JSONDecodeError:
|
|
58
|
+
continue
|
|
59
|
+
if isinstance(event, dict):
|
|
60
|
+
events.append(event)
|
|
61
|
+
return events
|
|
62
|
+
|
|
63
|
+
def ack(self, event_ids: set[str]) -> None:
|
|
64
|
+
if not event_ids:
|
|
65
|
+
return
|
|
66
|
+
|
|
67
|
+
with self._locked():
|
|
68
|
+
for queue_file in self._queue_files():
|
|
69
|
+
self._ack_file(queue_file, event_ids)
|
|
70
|
+
|
|
71
|
+
def _event_path(self, event: BaseEvent) -> Path:
|
|
72
|
+
if self.root is None:
|
|
73
|
+
return self.path
|
|
74
|
+
return (
|
|
75
|
+
self.root
|
|
76
|
+
/ _safe_path_part(event.project_id)
|
|
77
|
+
/ _safe_path_part(event.session_id)
|
|
78
|
+
/ "events.jsonl"
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
def _queue_files(self) -> list[Path]:
|
|
82
|
+
if self.root is None:
|
|
83
|
+
return [self.path] if self.path.exists() else []
|
|
84
|
+
|
|
85
|
+
queue_files: list[Path] = []
|
|
86
|
+
if LEGACY_QUEUE_PATH.exists():
|
|
87
|
+
queue_files.append(LEGACY_QUEUE_PATH)
|
|
88
|
+
if self.root.exists():
|
|
89
|
+
queue_files.extend(sorted(self.root.glob("*/*/events.jsonl")))
|
|
90
|
+
return queue_files
|
|
91
|
+
|
|
92
|
+
def _ack_file(self, queue_file: Path, event_ids: set[str]) -> None:
|
|
93
|
+
if not queue_file.exists():
|
|
94
|
+
return
|
|
95
|
+
|
|
96
|
+
remaining: list[str] = []
|
|
97
|
+
with queue_file.open("r", encoding="utf-8") as file:
|
|
98
|
+
for line in file:
|
|
99
|
+
if not line.strip():
|
|
100
|
+
continue
|
|
101
|
+
try:
|
|
102
|
+
event = json.loads(line)
|
|
103
|
+
except json.JSONDecodeError:
|
|
104
|
+
continue
|
|
105
|
+
if not isinstance(event, dict):
|
|
106
|
+
continue
|
|
107
|
+
if event.get("id") not in event_ids:
|
|
108
|
+
remaining.append(line)
|
|
109
|
+
|
|
110
|
+
tmp_path = queue_file.with_suffix(f"{queue_file.suffix}.{uuid4()}.tmp")
|
|
111
|
+
if remaining:
|
|
112
|
+
with tmp_path.open("w", encoding="utf-8") as file:
|
|
113
|
+
file.writelines(remaining)
|
|
114
|
+
tmp_path.replace(queue_file)
|
|
115
|
+
return
|
|
116
|
+
|
|
117
|
+
try:
|
|
118
|
+
queue_file.unlink()
|
|
119
|
+
except OSError:
|
|
120
|
+
pass
|
|
121
|
+
if tmp_path.exists():
|
|
122
|
+
try:
|
|
123
|
+
tmp_path.unlink()
|
|
124
|
+
except OSError:
|
|
125
|
+
pass
|
|
126
|
+
|
|
127
|
+
@contextmanager
|
|
128
|
+
def _locked(self):
|
|
129
|
+
with locked_file(self.lock_path):
|
|
130
|
+
yield
|