notrack-cli-agent 0.6.0__py3-none-any.whl

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.

Potentially problematic release.


This version of notrack-cli-agent might be problematic. Click here for more details.

@@ -0,0 +1,2 @@
1
+ """NoTrack CLI Package."""
2
+ __version__ = "0.6.0"
notrack_cli/client.py ADDED
@@ -0,0 +1,196 @@
1
+ """NoTrack web-chat transport.
2
+
3
+ Uses the observed dispatch route and SSE-style responses. Payload shape is
4
+ configurable because NoTrack's private web protocol may change.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import os
10
+ import time
11
+ import uuid
12
+ from typing import Iterator, Any
13
+
14
+ import requests
15
+ from requests.adapters import HTTPAdapter
16
+ from urllib3.util.retry import Retry
17
+
18
+ DEFAULT_BASE_URL = "https://notrack.ai"
19
+ DEFAULT_ENDPOINT = "/api/dispatch"
20
+
21
+
22
+ class NoTrackAPIError(RuntimeError):
23
+ """Remote endpoint returned an error or an unsupported response."""
24
+
25
+
26
+ class NoTrackClient:
27
+ def __init__(self, *, base_url: str | None = None, endpoint: str | None = None,
28
+ model: str | None = None, mode: str | None = None,
29
+ timeout: tuple[int, int] = (15, 180)):
30
+ self.base_url = (base_url or os.getenv("NOTRACK_BASE_URL", DEFAULT_BASE_URL)).rstrip("/")
31
+ self.endpoint_path = endpoint or os.getenv("NOTRACK_DISPATCH_PATH", DEFAULT_ENDPOINT)
32
+ self.endpoint = self.base_url + "/" + self.endpoint_path.lstrip("/")
33
+ self.model = model or os.getenv("NOTRACK_MODEL", "C")
34
+ self.mode = mode or os.getenv("NOTRACK_MODE", "usual")
35
+ self.persona = mode or os.getenv("NOTRACK_PERSONA", "normal")
36
+ self.timeout = timeout
37
+ self.session = self._build_session()
38
+ self.chat_id: str | None = None
39
+ self.user_id: str | None = None
40
+ self.last_meta: dict[str, Any] = {}
41
+ # New anonymous web-style identity for this CLI process.
42
+ self._session_id = uuid.uuid4().hex
43
+ self._uid = str(uuid.uuid4())
44
+ self.session.cookies.set("si_usr_id", self._session_id, domain="notrack.ai")
45
+ self.session.cookies.set("si_ses_id", self._session_id, domain="notrack.ai")
46
+ self.session.cookies.set("uid", self._uid, domain="notrack.ai")
47
+
48
+ def _build_session(self) -> requests.Session:
49
+ session = requests.Session()
50
+ retry = Retry(total=2, connect=2, read=0, status=2, backoff_factor=0.6,
51
+ status_forcelist=(429, 500, 502, 503, 504),
52
+ allowed_methods=frozenset({"GET"}), raise_on_status=False)
53
+ adapter = HTTPAdapter(max_retries=retry)
54
+ session.mount("https://", adapter)
55
+ session.mount("http://", adapter)
56
+ session.headers.update({
57
+ "Accept": "text/event-stream, application/json",
58
+ "Content-Type": "application/json",
59
+ "Origin": self.base_url,
60
+ "Referer": self.base_url + "/chat",
61
+ "User-Agent": "NoTrack-CLI/0.6 (Python requests)",
62
+ })
63
+ return session
64
+
65
+ def new_chat(self) -> None:
66
+ self.chat_id = None
67
+
68
+ def list_chats(self) -> list[dict[str, Any]]:
69
+ """Read chats using the same HTTP session/cookies as dispatch."""
70
+ url = self.base_url + "/api/chats"
71
+ response = self.session.get(url, timeout=self.timeout[0])
72
+ if response.status_code >= 400:
73
+ raise NoTrackAPIError(f"GET /api/chats: HTTP {response.status_code}: {response.text[:500]}")
74
+ try:
75
+ data = response.json()
76
+ except ValueError as exc:
77
+ raise NoTrackAPIError("GET /api/chats did not return JSON") from exc
78
+ if isinstance(data, dict):
79
+ self.user_id = data.get("user_id") or self.user_id
80
+ chats = data.get("chats", [])
81
+ return chats if isinstance(chats, list) else []
82
+ if isinstance(data, list):
83
+ return data
84
+ return []
85
+
86
+ def send_message_stream(self, user_input: str, mode: str | None = None,
87
+ persona: str = "normal") -> Iterator[dict[str, Any]]:
88
+ """Send one prompt and yield normalized SSE/JSON events.
89
+
90
+ The field set matches the project's prior dispatch attempt, but is not
91
+ represented as a verified/current NoTrack contract.
92
+ """
93
+ payload: dict[str, Any] = {
94
+ "user_input": user_input,
95
+ "mode": mode or self.mode,
96
+ "model": self.model,
97
+ "persona": self.persona,
98
+ "max_turns": 6,
99
+ "attachments": [],
100
+ "regenerate": False,
101
+ }
102
+ if self.chat_id:
103
+ payload["chat_id"] = self.chat_id
104
+
105
+ try:
106
+ response = self.session.post(self.endpoint, json=payload, stream=True,
107
+ timeout=self.timeout)
108
+ except requests.RequestException as exc:
109
+ raise NoTrackAPIError(f"Could not connect to {self.endpoint}: {exc}") from exc
110
+
111
+ with response:
112
+ if response.status_code >= 400:
113
+ excerpt = response.text[:1200]
114
+ raise NoTrackAPIError(f"POST {self.endpoint_path}: HTTP {response.status_code}: {excerpt}")
115
+ content_type = response.headers.get("Content-Type", "").lower()
116
+ if "json" in content_type and "event-stream" not in content_type:
117
+ try:
118
+ obj = response.json()
119
+ except ValueError as exc:
120
+ raise NoTrackAPIError("Endpoint returned invalid JSON") from exc
121
+ if isinstance(obj, dict) and obj.get("type") == "error":
122
+ raise NoTrackAPIError(str(obj.get("content") or obj))
123
+ if isinstance(obj, dict):
124
+ yield from self._handle_event(obj)
125
+ else:
126
+ yield {"type": "message", "content": json.dumps(obj, ensure_ascii=False)}
127
+ return
128
+
129
+ for event in self._iter_sse(response):
130
+ if event.get("type") == "error":
131
+ raise NoTrackAPIError(str(event.get("content") or event))
132
+ yield from self._handle_event(event)
133
+
134
+ def _handle_event(self, event: dict[str, Any]) -> Iterator[dict[str, Any]]:
135
+ kind = str(event.get("type", ""))
136
+ if kind in {"chat_meta", "metadata", "meta"}:
137
+ self.last_meta.update(event)
138
+ if event.get("chat_id"):
139
+ self.chat_id = str(event["chat_id"])
140
+ if event.get("user_id"):
141
+ self.user_id = str(event["user_id"])
142
+ yield event
143
+ elif kind in {"delta", "token", "text_delta", "content_block_delta"}:
144
+ chunk = event.get("chunk", event.get("content", event.get("text", "")))
145
+ if isinstance(chunk, dict):
146
+ chunk = chunk.get("text", "")
147
+ if chunk:
148
+ yield {"type": "delta", "chunk": str(chunk), "raw": event}
149
+ elif kind in {"message", "final", "done", "response"}:
150
+ content = event.get("content", event.get("message", event.get("text", "")))
151
+ if isinstance(content, dict):
152
+ content = content.get("content", content.get("text", ""))
153
+ if content:
154
+ yield {"type": "message", "content": str(content), "raw": event}
155
+ else:
156
+ yield event
157
+ elif kind:
158
+ yield event
159
+ elif any(k in event for k in ("content", "text", "chunk")):
160
+ content = event.get("chunk", event.get("content", event.get("text", "")))
161
+ if content:
162
+ yield {"type": "delta", "chunk": str(content), "raw": event}
163
+
164
+ @staticmethod
165
+ def _iter_sse(response: requests.Response) -> Iterator[dict[str, Any]]:
166
+ """Read data lines, allowing multiple data: lines per SSE event."""
167
+ data_lines: list[str] = []
168
+ for raw in response.iter_lines(decode_unicode=True):
169
+ if raw is None:
170
+ continue
171
+ line = raw if isinstance(raw, str) else raw.decode("utf-8", errors="replace")
172
+ if not line:
173
+ if data_lines:
174
+ parsed = NoTrackClient._decode_sse_data("\n".join(data_lines))
175
+ if parsed is not None:
176
+ yield parsed
177
+ data_lines.clear()
178
+ continue
179
+ if line.startswith(":"):
180
+ continue
181
+ if line.startswith("data:"):
182
+ data_lines.append(line[5:].lstrip())
183
+ if data_lines:
184
+ parsed = NoTrackClient._decode_sse_data("\n".join(data_lines))
185
+ if parsed is not None:
186
+ yield parsed
187
+
188
+ @staticmethod
189
+ def _decode_sse_data(raw: str) -> dict[str, Any] | None:
190
+ if not raw or raw == "[DONE]":
191
+ return None
192
+ try:
193
+ obj = json.loads(raw)
194
+ except ValueError:
195
+ return {"type": "delta", "chunk": raw}
196
+ return obj if isinstance(obj, dict) else {"type": "message", "content": str(obj)}