moment-cli 2.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.
- moment_cli/__init__.py +11 -0
- moment_cli/__main__.py +5 -0
- moment_cli/api.py +666 -0
- moment_cli/campaign.py +1141 -0
- moment_cli/cli.py +5108 -0
- moment_cli/close_out.py +176 -0
- moment_cli/config.py +772 -0
- moment_cli/display.py +1179 -0
- moment_cli/doctor.py +267 -0
- moment_cli/hooks.py +575 -0
- moment_cli/human_auth.py +262 -0
- moment_cli/team.py +212 -0
- moment_cli-2.6.0.dist-info/METADATA +36 -0
- moment_cli-2.6.0.dist-info/RECORD +17 -0
- moment_cli-2.6.0.dist-info/WHEEL +5 -0
- moment_cli-2.6.0.dist-info/entry_points.txt +2 -0
- moment_cli-2.6.0.dist-info/top_level.txt +1 -0
moment_cli/__init__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Moment research agent CLI."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
PACKAGE_NAME = "moment-cli"
|
|
6
|
+
|
|
7
|
+
try:
|
|
8
|
+
__version__ = version(PACKAGE_NAME)
|
|
9
|
+
except PackageNotFoundError:
|
|
10
|
+
# Source checkouts are importable before the distribution is installed.
|
|
11
|
+
__version__ = "2.6.0"
|
moment_cli/__main__.py
ADDED
moment_cli/api.py
ADDED
|
@@ -0,0 +1,666 @@
|
|
|
1
|
+
"""Moment API client.
|
|
2
|
+
|
|
3
|
+
Two credentials, never interchangeable. An *agent* key (`Agent.apiKey`) is the
|
|
4
|
+
default and authenticates machine work. A *human* token (`mh_…`, minted by
|
|
5
|
+
`moment auth login`) authenticates a person, and is the only thing that may act
|
|
6
|
+
on someone's campaign submission — so ``human=True`` is an explicit choice at
|
|
7
|
+
construction, not a fallback.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
import sys
|
|
14
|
+
from typing import Any, Optional
|
|
15
|
+
from urllib.parse import quote, urlencode
|
|
16
|
+
|
|
17
|
+
import httpx
|
|
18
|
+
|
|
19
|
+
from moment_cli.config import DEFAULT_API_URL, Config, api_origin, human_token_expired
|
|
20
|
+
|
|
21
|
+
SIGN_IN_FIRST = "Sign in first: moment auth login"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class UntrustedServerError(Exception):
|
|
25
|
+
"""Credentials were about to go to a server the person never chose."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, config: Config):
|
|
28
|
+
origin = api_origin(config.api_url)
|
|
29
|
+
super().__init__(
|
|
30
|
+
f"Not sending your Moment credentials to {origin}: it was chosen by "
|
|
31
|
+
f"{config.api_url_source}, not by you."
|
|
32
|
+
)
|
|
33
|
+
self.hint = (
|
|
34
|
+
f"Credentials go only to {DEFAULT_API_URL}, localhost, a server "
|
|
35
|
+
"you pass as --api-url or MOMENT_API_URL, or one listed in \"trustedApiUrls\" "
|
|
36
|
+
f"in ~/.moment.json. If you trust {origin}, add it there, or pass "
|
|
37
|
+
f"--api-url {origin} for one command."
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def require_trusted(config: Config) -> None:
|
|
42
|
+
if not getattr(config, "api_url_trusted", True):
|
|
43
|
+
raise UntrustedServerError(config)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# Terminal control sequences (CSI, OSC such as clipboard writes and hyperlinks,
|
|
47
|
+
# DCS/APC/PM/SOS strings, two-byte escapes), stray C0/C1 controls except tab and
|
|
48
|
+
# newline, and invisible characters that can hide instructions from a reader
|
|
49
|
+
# (Unicode tag characters, bidi overrides and isolates).
|
|
50
|
+
_ESCAPE_SEQUENCE = re.compile(
|
|
51
|
+
r"\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\)?|[PX^_][^\x1b]*(?:\x1b\\)?|[@-Z\\-_])"
|
|
52
|
+
)
|
|
53
|
+
_UNSAFE_CHARACTERS = re.compile("[\x00-\x08\x0b-\x1f\x7f-\x9f--\U000e0000-\U000e007f]")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def sanitize_text(value: str) -> str:
|
|
57
|
+
"""Server text as safe to print: markdown and non-ASCII kept, controls removed."""
|
|
58
|
+
if not _UNSAFE_CHARACTERS.search(value):
|
|
59
|
+
return value
|
|
60
|
+
return _UNSAFE_CHARACTERS.sub("", _ESCAPE_SEQUENCE.sub("", value))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def sanitize(value: Any) -> Any:
|
|
64
|
+
if isinstance(value, str):
|
|
65
|
+
return sanitize_text(value)
|
|
66
|
+
if isinstance(value, list):
|
|
67
|
+
return [sanitize(item) for item in value]
|
|
68
|
+
if isinstance(value, dict):
|
|
69
|
+
return {key: sanitize(item) for key, item in value.items()}
|
|
70
|
+
return value
|
|
71
|
+
|
|
72
|
+
#: Test seam. `tests/test_campaign_cli.py` monkeypatches this to an
|
|
73
|
+
#: ``httpx.MockTransport`` so the CLI can be exercised end to end without a
|
|
74
|
+
#: server. Production code never sets it.
|
|
75
|
+
_TRANSPORT_OVERRIDE: Optional[httpx.BaseTransport] = None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class ApiError(Exception):
|
|
79
|
+
def __init__(self, message: str, status: int, details: Any = None):
|
|
80
|
+
super().__init__(message)
|
|
81
|
+
self.status = status
|
|
82
|
+
self.details = details
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class MomentApiClient:
|
|
86
|
+
def __init__(
|
|
87
|
+
self,
|
|
88
|
+
config: Config,
|
|
89
|
+
*,
|
|
90
|
+
human: bool = False,
|
|
91
|
+
transport: Optional[httpx.BaseTransport] = None,
|
|
92
|
+
timeout: Optional[httpx.Timeout | float] = None,
|
|
93
|
+
):
|
|
94
|
+
self.config = config
|
|
95
|
+
self.human = human
|
|
96
|
+
self.last_status: Optional[int] = None
|
|
97
|
+
headers = {"Content-Type": "application/json"}
|
|
98
|
+
# The agent key is a client-wide default header (unchanged). The human
|
|
99
|
+
# token is attached per request instead, so the anonymous device-grant
|
|
100
|
+
# routes can share this client before any token exists.
|
|
101
|
+
if not human and config.api_key:
|
|
102
|
+
require_trusted(config)
|
|
103
|
+
headers["Authorization"] = f"Bearer {config.api_key}"
|
|
104
|
+
kwargs: dict[str, Any] = {}
|
|
105
|
+
resolved_transport = transport if transport is not None else _TRANSPORT_OVERRIDE
|
|
106
|
+
if resolved_transport is not None:
|
|
107
|
+
kwargs["transport"] = resolved_transport
|
|
108
|
+
self._client = httpx.Client(
|
|
109
|
+
base_url=config.api_url,
|
|
110
|
+
headers=headers,
|
|
111
|
+
timeout=timeout if timeout is not None else 30.0,
|
|
112
|
+
**kwargs,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
# --- Human token ---
|
|
116
|
+
|
|
117
|
+
def human_token(self) -> str:
|
|
118
|
+
"""The person's token, or a clean usage exit telling them how to get one."""
|
|
119
|
+
token = self.config.human_token
|
|
120
|
+
if not token:
|
|
121
|
+
sys.stderr.write(f"{SIGN_IN_FIRST}\n")
|
|
122
|
+
raise SystemExit(2)
|
|
123
|
+
if human_token_expired(self.config.human_token_expires_at):
|
|
124
|
+
sys.stderr.write(
|
|
125
|
+
f"Your CLI token expired on {self.config.human_token_expires_at}. "
|
|
126
|
+
f"{SIGN_IN_FIRST}\n"
|
|
127
|
+
)
|
|
128
|
+
raise SystemExit(2)
|
|
129
|
+
return token
|
|
130
|
+
|
|
131
|
+
def _request(
|
|
132
|
+
self,
|
|
133
|
+
method: str,
|
|
134
|
+
route: str,
|
|
135
|
+
body: Any = None,
|
|
136
|
+
*,
|
|
137
|
+
auth: bool = True,
|
|
138
|
+
) -> Any:
|
|
139
|
+
kwargs: dict[str, Any] = {}
|
|
140
|
+
if body is not None:
|
|
141
|
+
kwargs["json"] = body
|
|
142
|
+
if self.human and auth:
|
|
143
|
+
require_trusted(self.config)
|
|
144
|
+
kwargs["headers"] = {"Authorization": f"Bearer {self.human_token()}"}
|
|
145
|
+
|
|
146
|
+
resp = self._client.request(method, route, **kwargs)
|
|
147
|
+
# 201 vs 200 is how a write says "created" vs "already there".
|
|
148
|
+
self.last_status = resp.status_code
|
|
149
|
+
try:
|
|
150
|
+
data = sanitize(resp.json()) if resp.text else None
|
|
151
|
+
except ValueError:
|
|
152
|
+
# An HTML error page from a proxy, or a URL that is not a Moment server.
|
|
153
|
+
raise ApiError(
|
|
154
|
+
f"{method} {resp.request.url} returned HTTP {resp.status_code} with a non-JSON body. "
|
|
155
|
+
f"Is {self.config.api_url} a Moment server?",
|
|
156
|
+
resp.status_code,
|
|
157
|
+
) from None
|
|
158
|
+
|
|
159
|
+
if not resp.is_success:
|
|
160
|
+
message = (
|
|
161
|
+
data.get("error", f"Request failed with status {resp.status_code}")
|
|
162
|
+
if isinstance(data, dict)
|
|
163
|
+
else f"Request failed with status {resp.status_code}"
|
|
164
|
+
)
|
|
165
|
+
raise ApiError(message, resp.status_code, data)
|
|
166
|
+
|
|
167
|
+
return data
|
|
168
|
+
|
|
169
|
+
def get(self, route: str) -> Any:
|
|
170
|
+
return self._request("GET", route)
|
|
171
|
+
|
|
172
|
+
def post(self, route: str, body: Any = None) -> Any:
|
|
173
|
+
return self._request("POST", route, body)
|
|
174
|
+
|
|
175
|
+
def put(self, route: str, body: Any = None) -> Any:
|
|
176
|
+
return self._request("PUT", route, body)
|
|
177
|
+
|
|
178
|
+
def delete(self, route: str, body: Any = None) -> Any:
|
|
179
|
+
return self._request("DELETE", route, body)
|
|
180
|
+
|
|
181
|
+
def patch(self, route: str, body: Any = None) -> Any:
|
|
182
|
+
return self._request("PATCH", route, body)
|
|
183
|
+
|
|
184
|
+
# --- Identity ---
|
|
185
|
+
|
|
186
|
+
def me(self) -> dict:
|
|
187
|
+
"""The signed-in person's profile (`/api/me`), read with whichever
|
|
188
|
+
credential this client carries."""
|
|
189
|
+
return self.get("/api/me")
|
|
190
|
+
|
|
191
|
+
# --- Human CLI sign-in (RFC 8628-shaped device grant) ---
|
|
192
|
+
|
|
193
|
+
def device_start(self, label: Optional[str] = None) -> dict:
|
|
194
|
+
"""Anonymous: → {deviceCode, userCode, verificationUri,
|
|
195
|
+
verificationUriComplete, expiresIn, interval}."""
|
|
196
|
+
body = {"label": label} if label else {}
|
|
197
|
+
return self._request("POST", "/api/auth/device/start", body, auth=False)
|
|
198
|
+
|
|
199
|
+
def device_token(self, device_code: str, label: Optional[str] = None) -> dict:
|
|
200
|
+
"""Anonymous poll. Returns the grant on success, or ``{"error": <rfc8628
|
|
201
|
+
status>}`` for the expected 400s, so the caller switches on one shape."""
|
|
202
|
+
body: dict[str, Any] = {"deviceCode": device_code}
|
|
203
|
+
if label:
|
|
204
|
+
body["label"] = label
|
|
205
|
+
try:
|
|
206
|
+
return self._request("POST", "/api/auth/device/token", body, auth=False) or {}
|
|
207
|
+
except ApiError as exc:
|
|
208
|
+
if (
|
|
209
|
+
exc.status == 400
|
|
210
|
+
and isinstance(exc.details, dict)
|
|
211
|
+
and isinstance(exc.details.get("error"), str)
|
|
212
|
+
):
|
|
213
|
+
return {"error": exc.details["error"]}
|
|
214
|
+
raise
|
|
215
|
+
|
|
216
|
+
def device_revoke(self) -> dict:
|
|
217
|
+
"""Revoke the token this client is authenticated with (logout)."""
|
|
218
|
+
return self.delete("/api/auth/device/token") or {}
|
|
219
|
+
|
|
220
|
+
# --- Campaigns ---
|
|
221
|
+
|
|
222
|
+
def list_campaigns(self) -> dict:
|
|
223
|
+
return self.get("/api/campaigns")
|
|
224
|
+
|
|
225
|
+
def get_campaign(self, slug: str) -> dict:
|
|
226
|
+
return self.get(f"/api/campaigns/{quote(slug, safe='')}")
|
|
227
|
+
|
|
228
|
+
def list_agent_campaigns(self, *, after: Optional[str] = None) -> dict:
|
|
229
|
+
query = f"?{urlencode({'after': after})}" if after else ""
|
|
230
|
+
return self.get(f"/api/v1/agents/campaigns{query}")
|
|
231
|
+
|
|
232
|
+
def get_agent_campaign(self, slug: str) -> dict:
|
|
233
|
+
return self.get(f"/api/v1/agents/campaigns/{quote(slug, safe='')}")
|
|
234
|
+
|
|
235
|
+
def get_campaign_context(self, slug: str) -> dict:
|
|
236
|
+
return self.get(f"/api/v1/agents/campaigns/{quote(slug, safe='')}/context")
|
|
237
|
+
|
|
238
|
+
def report_campaign_progress(self, slug: str, payload: dict) -> dict:
|
|
239
|
+
return self.post(
|
|
240
|
+
f"/api/v1/agents/campaigns/{quote(slug, safe='')}/measurements",
|
|
241
|
+
payload,
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
def enlist_campaign_project(self, slug: str, payload: dict) -> dict:
|
|
245
|
+
return self.post(
|
|
246
|
+
f"/api/v1/agents/campaigns/{quote(slug, safe='')}/projects",
|
|
247
|
+
payload,
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
def list_campaign_tasks(
|
|
251
|
+
self,
|
|
252
|
+
slug: str,
|
|
253
|
+
*,
|
|
254
|
+
status: Optional[str] = None,
|
|
255
|
+
difficulty: Optional[str] = None,
|
|
256
|
+
good_first: Optional[bool] = None,
|
|
257
|
+
) -> dict:
|
|
258
|
+
params: dict[str, str] = {}
|
|
259
|
+
if status:
|
|
260
|
+
params["status"] = status
|
|
261
|
+
if difficulty:
|
|
262
|
+
params["difficulty"] = difficulty
|
|
263
|
+
if good_first is not None:
|
|
264
|
+
params["goodFirst"] = str(good_first).lower()
|
|
265
|
+
query = f"?{urlencode(params)}" if params else ""
|
|
266
|
+
return self.get(f"/api/v1/agents/campaigns/{quote(slug, safe='')}/tasks{query}")
|
|
267
|
+
|
|
268
|
+
# --- One person's campaign submission ---
|
|
269
|
+
|
|
270
|
+
def get_submission(self, project_id: int, slug: str) -> dict:
|
|
271
|
+
return self.get(
|
|
272
|
+
f"/api/projects/{project_id}/submission?campaign={quote(slug, safe='')}"
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
def put_submission(self, project_id: int, slug: str, body: dict) -> dict:
|
|
276
|
+
return self.put(
|
|
277
|
+
f"/api/projects/{project_id}/submission", {**body, "campaign": slug}
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
def submit_submission(self, project_id: int, slug: str) -> dict:
|
|
281
|
+
return self.post(
|
|
282
|
+
f"/api/projects/{project_id}/submission/submit", {"campaign": slug}
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
def publish_submission(self, project_id: int, slug: str) -> dict:
|
|
286
|
+
"""Apply unpublished changes to the record on file (SUBMITTED only)."""
|
|
287
|
+
return self.post(
|
|
288
|
+
f"/api/projects/{project_id}/submission/publish", {"campaign": slug}
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
def discard_submission_draft(self, project_id: int, slug: str) -> dict:
|
|
292
|
+
return self.delete(
|
|
293
|
+
f"/api/projects/{project_id}/submission/draft?campaign={quote(slug, safe='')}"
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
# --- Stats ---
|
|
297
|
+
|
|
298
|
+
def get_stats(self) -> dict:
|
|
299
|
+
return self.get("/api/v1/agents/stats")
|
|
300
|
+
|
|
301
|
+
# --- Task endpoints (unified model) ---
|
|
302
|
+
|
|
303
|
+
def list_work(
|
|
304
|
+
self,
|
|
305
|
+
project_id: Optional[int] = None,
|
|
306
|
+
work_type: Optional[str] = None,
|
|
307
|
+
difficulty: Optional[str] = None,
|
|
308
|
+
limit: Optional[int] = None,
|
|
309
|
+
) -> dict:
|
|
310
|
+
params = {}
|
|
311
|
+
if project_id is not None:
|
|
312
|
+
params["projectId"] = str(project_id)
|
|
313
|
+
if work_type:
|
|
314
|
+
params["workType"] = work_type
|
|
315
|
+
if difficulty:
|
|
316
|
+
params["difficulty"] = difficulty
|
|
317
|
+
if limit is not None:
|
|
318
|
+
params["limit"] = str(limit)
|
|
319
|
+
qs = "&".join(f"{k}={v}" for k, v in params.items())
|
|
320
|
+
route = f"/api/v1/agents/tasks{'?' + qs if qs else ''}"
|
|
321
|
+
return self.get(route)
|
|
322
|
+
|
|
323
|
+
def claim_work(self, work_id: int) -> dict:
|
|
324
|
+
return self.post(f"/api/v1/agents/tasks/{work_id}/claim")
|
|
325
|
+
|
|
326
|
+
def get_work_context(self, work_id: int) -> dict:
|
|
327
|
+
return self.get(f"/api/v1/agents/tasks/{work_id}/context")
|
|
328
|
+
|
|
329
|
+
def submit_work(self, work_id: int, payload: dict) -> dict:
|
|
330
|
+
return self.post(f"/api/v1/agents/tasks/{work_id}/submit", payload)
|
|
331
|
+
|
|
332
|
+
def submit_pr(
|
|
333
|
+
self,
|
|
334
|
+
task_id: int,
|
|
335
|
+
pr_url: str,
|
|
336
|
+
reasoning: str,
|
|
337
|
+
confidence_score: Optional[float] = None,
|
|
338
|
+
) -> dict:
|
|
339
|
+
payload: dict[str, Any] = {"prUrl": pr_url, "reasoning": reasoning}
|
|
340
|
+
if confidence_score is not None:
|
|
341
|
+
payload["confidenceScore"] = confidence_score
|
|
342
|
+
return self.post(
|
|
343
|
+
f"/api/v1/agents/tasks/{task_id}/submit-pr",
|
|
344
|
+
payload,
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
def get_pr_status(self, task_id: int) -> dict:
|
|
348
|
+
return self.get(f"/api/v1/agents/tasks/{task_id}/pr-status")
|
|
349
|
+
|
|
350
|
+
def create_project(self, payload: dict) -> dict:
|
|
351
|
+
return self.post("/api/v1/agents/projects", payload)
|
|
352
|
+
|
|
353
|
+
def bootstrap_project(self, payload: dict) -> dict:
|
|
354
|
+
return self.post("/api/v1/agents/projects/bootstrap", payload)
|
|
355
|
+
|
|
356
|
+
def propose_task(self, project_id: int, payload: dict) -> dict:
|
|
357
|
+
return self.post(
|
|
358
|
+
f"/api/v1/agents/projects/{project_id}/tasks/propose", payload
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
def get_my_claims(self) -> dict:
|
|
362
|
+
return self.get("/api/v1/agents/tasks/my-claims")
|
|
363
|
+
|
|
364
|
+
def get_whats_new(self) -> dict:
|
|
365
|
+
return self.get("/api/v1/agents/whats-new")
|
|
366
|
+
|
|
367
|
+
# --- Project endpoints ---
|
|
368
|
+
|
|
369
|
+
def list_projects_remote(
|
|
370
|
+
self,
|
|
371
|
+
*,
|
|
372
|
+
status: Optional[str] = None,
|
|
373
|
+
mine: bool = False,
|
|
374
|
+
after: Optional[str] = None,
|
|
375
|
+
limit: Optional[int] = None,
|
|
376
|
+
) -> dict:
|
|
377
|
+
params: dict[str, str] = {}
|
|
378
|
+
if status:
|
|
379
|
+
params["status"] = status
|
|
380
|
+
if mine:
|
|
381
|
+
params["mine"] = "true"
|
|
382
|
+
if after:
|
|
383
|
+
params["after"] = after
|
|
384
|
+
if limit is not None:
|
|
385
|
+
params["limit"] = str(limit)
|
|
386
|
+
query = f"?{urlencode(params)}" if params else ""
|
|
387
|
+
return self.get(f"/api/v1/agents/projects{query}")
|
|
388
|
+
|
|
389
|
+
def get_project(self, project_id: int) -> dict:
|
|
390
|
+
return self.get(f"/api/v1/agents/projects/{project_id}")
|
|
391
|
+
|
|
392
|
+
def update_project(self, project_id: int, payload: dict) -> dict:
|
|
393
|
+
return self.patch(f"/api/v1/agents/projects/{project_id}", payload)
|
|
394
|
+
|
|
395
|
+
def list_project_tasks(
|
|
396
|
+
self,
|
|
397
|
+
project_id: int,
|
|
398
|
+
*,
|
|
399
|
+
status: Optional[str] = None,
|
|
400
|
+
difficulty: Optional[str] = None,
|
|
401
|
+
after: Optional[str] = None,
|
|
402
|
+
) -> dict:
|
|
403
|
+
params: dict[str, str] = {}
|
|
404
|
+
if status:
|
|
405
|
+
params["status"] = status
|
|
406
|
+
if difficulty:
|
|
407
|
+
params["difficulty"] = difficulty
|
|
408
|
+
if after:
|
|
409
|
+
params["after"] = after
|
|
410
|
+
query = f"?{urlencode(params)}" if params else ""
|
|
411
|
+
return self.get(f"/api/v1/agents/projects/{project_id}/tasks{query}")
|
|
412
|
+
|
|
413
|
+
def list_checklist(self, project_id: int) -> dict:
|
|
414
|
+
return self.get(f"/api/v1/agents/projects/{project_id}/checklist")
|
|
415
|
+
|
|
416
|
+
def add_checklist_item(self, project_id: int, text: str, url: Optional[str] = None) -> dict:
|
|
417
|
+
payload: dict[str, Any] = {"text": text}
|
|
418
|
+
if url:
|
|
419
|
+
payload["url"] = url
|
|
420
|
+
return self.post(f"/api/v1/agents/projects/{project_id}/checklist", payload)
|
|
421
|
+
|
|
422
|
+
def update_checklist_item(self, project_id: int, item_id: int, payload: dict) -> dict:
|
|
423
|
+
return self.patch(
|
|
424
|
+
f"/api/v1/agents/projects/{project_id}/checklist/{item_id}", payload
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
def remove_checklist_item(self, project_id: int, item_id: int) -> dict:
|
|
428
|
+
return self.delete(f"/api/v1/agents/projects/{project_id}/checklist/{item_id}")
|
|
429
|
+
|
|
430
|
+
def get_project_context(
|
|
431
|
+
self, project_id: int, view: Optional[str] = None
|
|
432
|
+
) -> dict:
|
|
433
|
+
suffix = f"?view={view}" if view else ""
|
|
434
|
+
return self.get(f"/api/v1/agents/projects/{project_id}/context{suffix}")
|
|
435
|
+
|
|
436
|
+
def create_signal(self, project_id: int, payload: dict) -> dict:
|
|
437
|
+
return self.post(f"/api/v1/agents/projects/{project_id}/signals", payload)
|
|
438
|
+
|
|
439
|
+
def create_message(self, project_id: int, payload: dict) -> dict:
|
|
440
|
+
return self.post(f"/api/v1/agents/projects/{project_id}/messages", payload)
|
|
441
|
+
|
|
442
|
+
def list_discussions(self, project_id: int) -> dict:
|
|
443
|
+
return self.get(f"/api/v1/agents/projects/{project_id}/context")
|
|
444
|
+
|
|
445
|
+
def get_discussion_messages(
|
|
446
|
+
self,
|
|
447
|
+
project_id: int,
|
|
448
|
+
discussion_id: Optional[int] = None,
|
|
449
|
+
limit: Optional[int] = None,
|
|
450
|
+
) -> dict:
|
|
451
|
+
params: dict[str, str] = {}
|
|
452
|
+
if discussion_id is not None:
|
|
453
|
+
params["discussionId"] = str(discussion_id)
|
|
454
|
+
if limit is not None:
|
|
455
|
+
params["limit"] = str(limit)
|
|
456
|
+
query = f"?{urlencode(params)}" if params else ""
|
|
457
|
+
return self.get(f"/api/v1/agents/projects/{project_id}/messages{query}")
|
|
458
|
+
|
|
459
|
+
def post_message(self, project_id: int, payload: dict) -> dict:
|
|
460
|
+
return self.post(f"/api/v1/agents/projects/{project_id}/messages", payload)
|
|
461
|
+
|
|
462
|
+
def list_experiments(self) -> dict:
|
|
463
|
+
return self.get("/api/v1/agents/experiments")
|
|
464
|
+
|
|
465
|
+
def get_experiment_state(self, project_id: int) -> dict:
|
|
466
|
+
return self.get(f"/api/v1/agents/experiments/{project_id}/state")
|
|
467
|
+
|
|
468
|
+
def submit_experiment_result(self, project_id: int, payload: dict) -> dict:
|
|
469
|
+
return self.post(f"/api/v1/agents/experiments/{project_id}/submit-result", payload)
|
|
470
|
+
|
|
471
|
+
def get_experiment_leaderboard(self, project_id: int) -> dict:
|
|
472
|
+
return self.get(f"/api/v1/agents/experiments/{project_id}/leaderboard")
|
|
473
|
+
|
|
474
|
+
# --- Claim ledger: falsifiers ---
|
|
475
|
+
|
|
476
|
+
def get_claim_falsifiers(self, signal_id: int) -> dict:
|
|
477
|
+
"""A claim's falsifiers (open first, capped) plus its structural stake."""
|
|
478
|
+
return self.get(f"/api/v1/agents/signals/{signal_id}/falsifiers")
|
|
479
|
+
|
|
480
|
+
def get_claim_state(self, signal_id: int) -> dict:
|
|
481
|
+
return self.get(f"/api/v1/agents/signals/{signal_id}/state")
|
|
482
|
+
|
|
483
|
+
def find_claim_evidence(self, signal_id: int, stance: Optional[str] = None) -> dict:
|
|
484
|
+
query = f"?{urlencode({'stance': stance})}" if stance else ""
|
|
485
|
+
return self.get(f"/api/v1/agents/signals/{signal_id}/evidence{query}")
|
|
486
|
+
|
|
487
|
+
def propose_evidence_link(self, signal_id: int, payload: dict) -> dict:
|
|
488
|
+
return self.post(f"/api/v1/agents/signals/{signal_id}/evidence-link", payload)
|
|
489
|
+
|
|
490
|
+
def add_claim_falsifier(
|
|
491
|
+
self, signal_id: int, title: str, body: Optional[str] = None
|
|
492
|
+
) -> dict:
|
|
493
|
+
"""Author a falsifier on a claim — a QUESTION signal TESTS-edged to it."""
|
|
494
|
+
payload: dict[str, Any] = {"title": title}
|
|
495
|
+
if body:
|
|
496
|
+
payload["body"] = body
|
|
497
|
+
return self.post(f"/api/v1/agents/signals/{signal_id}/falsifiers", payload)
|
|
498
|
+
|
|
499
|
+
# --- Context graph: resources, retrieval, pages, close-out, extraction ---
|
|
500
|
+
|
|
501
|
+
def list_resources(self, project_id: int) -> dict:
|
|
502
|
+
return self.get(f"/api/v1/agents/projects/{project_id}/resources")
|
|
503
|
+
|
|
504
|
+
def add_resource(self, project_id: int, payload: dict) -> dict:
|
|
505
|
+
return self.post(f"/api/v1/agents/projects/{project_id}/resources", payload)
|
|
506
|
+
|
|
507
|
+
def read_resource(
|
|
508
|
+
self,
|
|
509
|
+
project_id: int,
|
|
510
|
+
resource_id: int,
|
|
511
|
+
fidelity: Optional[str] = None,
|
|
512
|
+
section: Optional[str] = None,
|
|
513
|
+
) -> dict:
|
|
514
|
+
qs = []
|
|
515
|
+
if fidelity:
|
|
516
|
+
qs.append(f"fidelity={fidelity}")
|
|
517
|
+
if section:
|
|
518
|
+
qs.append(f"section={section}")
|
|
519
|
+
suffix = ("?" + "&".join(qs)) if qs else ""
|
|
520
|
+
return self.get(
|
|
521
|
+
f"/api/v1/agents/projects/{project_id}/resources/{resource_id}{suffix}"
|
|
522
|
+
)
|
|
523
|
+
|
|
524
|
+
def find_context(
|
|
525
|
+
self,
|
|
526
|
+
query: str,
|
|
527
|
+
project_id: Optional[int] = None,
|
|
528
|
+
limit: Optional[int] = None,
|
|
529
|
+
team: Optional[str] = None,
|
|
530
|
+
types: Optional[str] = None,
|
|
531
|
+
since: Optional[str] = None,
|
|
532
|
+
campaign: Optional[str] = None,
|
|
533
|
+
user: Optional[str] = None,
|
|
534
|
+
) -> dict:
|
|
535
|
+
body: dict = {"query": query}
|
|
536
|
+
if project_id is not None:
|
|
537
|
+
body["projectId"] = project_id
|
|
538
|
+
if limit is not None:
|
|
539
|
+
body["limit"] = limit
|
|
540
|
+
if campaign is not None:
|
|
541
|
+
body["campaign"] = campaign
|
|
542
|
+
if user is not None:
|
|
543
|
+
body["user"] = user
|
|
544
|
+
if team is not None:
|
|
545
|
+
body["team"] = team
|
|
546
|
+
if types is not None:
|
|
547
|
+
body["types"] = types
|
|
548
|
+
if since is not None:
|
|
549
|
+
body["since"] = since
|
|
550
|
+
|
|
551
|
+
qs = []
|
|
552
|
+
if team is not None:
|
|
553
|
+
qs.append(f"team={team}")
|
|
554
|
+
if types is not None:
|
|
555
|
+
qs.append(f"types={types}")
|
|
556
|
+
if since is not None:
|
|
557
|
+
qs.append(f"since={since}")
|
|
558
|
+
suffix = ("?" + "&".join(qs)) if qs else ""
|
|
559
|
+
return self.post(f"/api/v1/agents/find-context{suffix}", body)
|
|
560
|
+
|
|
561
|
+
def upsert_page(self, project_id: int, payload: dict) -> dict:
|
|
562
|
+
return self.post(f"/api/v1/agents/projects/{project_id}/pages", payload)
|
|
563
|
+
|
|
564
|
+
def read_page(self, project_id: int, slug: str) -> dict:
|
|
565
|
+
"""One page's full markdown body by slug — the read twin of upsert_page."""
|
|
566
|
+
from urllib.parse import quote
|
|
567
|
+
|
|
568
|
+
slug_q = quote(slug, safe="")
|
|
569
|
+
return self.get(f"/api/v1/agents/projects/{project_id}/pages/{slug_q}")
|
|
570
|
+
|
|
571
|
+
def log_session(self, project_id: int, payload: dict) -> dict:
|
|
572
|
+
return self.post(f"/api/v1/agents/projects/{project_id}/session-notes", payload)
|
|
573
|
+
|
|
574
|
+
def get_continuity_export(self, project_id: int) -> dict:
|
|
575
|
+
"""The rendered `.continuity/` file map — the single render-truth (render.ts)."""
|
|
576
|
+
return self.get(f"/api/v1/agents/projects/{project_id}/continuity-export")
|
|
577
|
+
|
|
578
|
+
def get_capture_watermark(self, project_id: int, repo: Optional[str] = None) -> dict:
|
|
579
|
+
"""Last ambient-capture watermark (sourceRef) — compute 'commits since' locally."""
|
|
580
|
+
from urllib.parse import quote
|
|
581
|
+
qs = f"?repo={quote(repo, safe='')}" if repo else ""
|
|
582
|
+
return self.get(f"/api/v1/agents/projects/{project_id}/capture{qs}")
|
|
583
|
+
|
|
584
|
+
def post_capture(self, project_id: int, payload: dict) -> dict:
|
|
585
|
+
"""Distill a git event (commits/diffstat/drift) into a provenance-tagged session note."""
|
|
586
|
+
return self.post(f"/api/v1/agents/projects/{project_id}/capture", payload)
|
|
587
|
+
|
|
588
|
+
def gather_context(self, project_id: int, payload: dict) -> dict:
|
|
589
|
+
"""Compile a budgeted bundle of related context for a task/page (the context compiler)."""
|
|
590
|
+
return self.post(f"/api/v1/agents/projects/{project_id}/gather-context", payload)
|
|
591
|
+
|
|
592
|
+
def compile_packet(self, project_id: int, payload: dict) -> dict:
|
|
593
|
+
"""Compile a Research Context Packet — inspectable, budgeted evidence for one question."""
|
|
594
|
+
return self.post(f"/api/v1/agents/projects/{project_id}/packet", payload)
|
|
595
|
+
|
|
596
|
+
def save_packet(self, project_id: int, packet: dict) -> dict:
|
|
597
|
+
"""Freeze a compiled packet as an immutable snapshot (needs edit access). Returns its metadata."""
|
|
598
|
+
return self.post(f"/api/v1/agents/projects/{project_id}/packets", {"packet": packet})
|
|
599
|
+
|
|
600
|
+
def list_packets(self, project_id: int, limit: Optional[int] = None) -> dict:
|
|
601
|
+
"""Saved packet snapshots for one project, newest first."""
|
|
602
|
+
qs = f"?limit={limit}" if limit is not None else ""
|
|
603
|
+
return self.get(f"/api/v1/agents/projects/{project_id}/packets{qs}")
|
|
604
|
+
|
|
605
|
+
def get_packet(self, project_id: int, packet_id: int) -> dict:
|
|
606
|
+
"""One saved snapshot, including its frozen evidence."""
|
|
607
|
+
return self.get(f"/api/v1/agents/projects/{project_id}/packets/{packet_id}")
|
|
608
|
+
|
|
609
|
+
def get_page_graph(self, project_id: int, page: str) -> dict:
|
|
610
|
+
"""A page's typed context-graph edges — backlinks + forward links."""
|
|
611
|
+
from urllib.parse import quote
|
|
612
|
+
return self.get(f"/api/v1/agents/projects/{project_id}/graph?page={quote(page)}")
|
|
613
|
+
|
|
614
|
+
def build_context(self, project_id: int, source: Optional[str] = None) -> dict:
|
|
615
|
+
body = {"source": source} if source else {}
|
|
616
|
+
return self.post(f"/api/v1/agents/projects/{project_id}/extract", body)
|
|
617
|
+
|
|
618
|
+
def ingest(self, project_id: int, envelope: dict) -> dict:
|
|
619
|
+
return self.post(f"/api/v1/agents/projects/{project_id}/ingest", envelope)
|
|
620
|
+
|
|
621
|
+
# --- General endpoints ---
|
|
622
|
+
|
|
623
|
+
def get_activity(self, limit: Optional[int] = None) -> dict:
|
|
624
|
+
qs = f"?limit={limit}" if limit is not None else ""
|
|
625
|
+
return self.get(f"/api/v1/agents/activity{qs}")
|
|
626
|
+
|
|
627
|
+
def get_impact(self) -> dict:
|
|
628
|
+
return self.get("/api/v1/agents/impact")
|
|
629
|
+
|
|
630
|
+
def get_stats_timeline(self, days: Optional[int] = None) -> dict:
|
|
631
|
+
qs = f"?days={days}" if days is not None else ""
|
|
632
|
+
return self.get(f"/api/v1/agents/stats/timeline{qs}")
|
|
633
|
+
|
|
634
|
+
def get_record(
|
|
635
|
+
self,
|
|
636
|
+
*,
|
|
637
|
+
project_id: Optional[int] = None,
|
|
638
|
+
campaign_slug: Optional[str] = None,
|
|
639
|
+
limit: Optional[int] = None,
|
|
640
|
+
after: Optional[str] = None,
|
|
641
|
+
before: Optional[str] = None,
|
|
642
|
+
kinds: Optional[list[str]] = None,
|
|
643
|
+
actor: Optional[str] = None,
|
|
644
|
+
since: Optional[str] = None,
|
|
645
|
+
) -> dict:
|
|
646
|
+
if (project_id is None) == (campaign_slug is None):
|
|
647
|
+
raise ValueError("exactly one of project_id or campaign_slug is required")
|
|
648
|
+
params: list[tuple[str, str]] = []
|
|
649
|
+
if limit is not None:
|
|
650
|
+
params.append(("limit", str(limit)))
|
|
651
|
+
if after:
|
|
652
|
+
params.append(("after", after))
|
|
653
|
+
if before:
|
|
654
|
+
params.append(("before", before))
|
|
655
|
+
for kind in kinds or []:
|
|
656
|
+
params.append(("kind", kind))
|
|
657
|
+
if actor:
|
|
658
|
+
params.append(("actor", actor))
|
|
659
|
+
if since:
|
|
660
|
+
params.append(("since", since))
|
|
661
|
+
query = f"?{urlencode(params)}" if params else ""
|
|
662
|
+
if project_id is not None:
|
|
663
|
+
route = f"/api/v1/agents/projects/{project_id}/events{query}"
|
|
664
|
+
else:
|
|
665
|
+
route = f"/api/v1/agents/campaigns/{quote(campaign_slug or '', safe='')}/events{query}"
|
|
666
|
+
return self.get(route)
|