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 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
@@ -0,0 +1,5 @@
1
+ """Entry point for `python -m moment_cli`."""
2
+
3
+ from moment_cli.cli import app
4
+
5
+ app()
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)