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.
@@ -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))
@@ -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