world-model-optimizer 0.2.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.
Files changed (308) hide show
  1. llm_waterfall/LICENSE +21 -0
  2. llm_waterfall/__init__.py +53 -0
  3. llm_waterfall/adapters/__init__.py +36 -0
  4. llm_waterfall/adapters/anthropic.py +105 -0
  5. llm_waterfall/adapters/aws_mantle.py +47 -0
  6. llm_waterfall/adapters/azure_openai.py +71 -0
  7. llm_waterfall/adapters/base.py +51 -0
  8. llm_waterfall/adapters/bedrock.py +309 -0
  9. llm_waterfall/adapters/openai.py +130 -0
  10. llm_waterfall/classify.py +184 -0
  11. llm_waterfall/pricing.py +110 -0
  12. llm_waterfall/py.typed +0 -0
  13. llm_waterfall/types.py +295 -0
  14. llm_waterfall/waterfall.py +255 -0
  15. wmo/__init__.py +38 -0
  16. wmo/agents/__init__.py +7 -0
  17. wmo/agents/default.py +29 -0
  18. wmo/agents/meta.py +55 -0
  19. wmo/agents/optimizer.py +55 -0
  20. wmo/agents/project.py +928 -0
  21. wmo/cli/__init__.py +5 -0
  22. wmo/cli/agent_session.py +1123 -0
  23. wmo/cli/app.py +2489 -0
  24. wmo/cli/e2b_cmds.py +212 -0
  25. wmo/cli/eval_closed_loop.py +207 -0
  26. wmo/cli/harness_app.py +1147 -0
  27. wmo/cli/harness_distill.py +659 -0
  28. wmo/cli/hosted_session.py +880 -0
  29. wmo/cli/ingest_cmd.py +165 -0
  30. wmo/cli/model_roles.py +82 -0
  31. wmo/cli/platform_cmds.py +372 -0
  32. wmo/cli/route_app.py +274 -0
  33. wmo/cli/session_state.py +243 -0
  34. wmo/cli/ui.py +1107 -0
  35. wmo/cli/workspace_sync.py +504 -0
  36. wmo/config/__init__.py +60 -0
  37. wmo/config/card.py +129 -0
  38. wmo/config/config.py +367 -0
  39. wmo/config/dotenv.py +67 -0
  40. wmo/config/settings.py +128 -0
  41. wmo/config/store.py +177 -0
  42. wmo/conftest.py +19 -0
  43. wmo/connect/__init__.py +88 -0
  44. wmo/connect/apps.py +78 -0
  45. wmo/connect/brave.py +284 -0
  46. wmo/connect/connector.py +79 -0
  47. wmo/connect/credentials.py +164 -0
  48. wmo/connect/github.py +321 -0
  49. wmo/connect/google.py +627 -0
  50. wmo/connect/notion.py +790 -0
  51. wmo/connect/oauth.py +461 -0
  52. wmo/connect/slack.py +555 -0
  53. wmo/connect/store.py +199 -0
  54. wmo/connect/types.py +156 -0
  55. wmo/core/__init__.py +21 -0
  56. wmo/core/parsing.py +281 -0
  57. wmo/core/render.py +271 -0
  58. wmo/core/text.py +40 -0
  59. wmo/core/types.py +116 -0
  60. wmo/distill/__init__.py +14 -0
  61. wmo/distill/agents.py +140 -0
  62. wmo/distill/config.py +1006 -0
  63. wmo/distill/cost.py +437 -0
  64. wmo/distill/data.py +921 -0
  65. wmo/distill/deadlines.py +254 -0
  66. wmo/distill/fake_tinker.py +734 -0
  67. wmo/distill/gate.py +122 -0
  68. wmo/distill/loop.py +3499 -0
  69. wmo/distill/renderers.py +399 -0
  70. wmo/distill/rendering.py +620 -0
  71. wmo/distill/rollouts.py +726 -0
  72. wmo/distill/samples.py +195 -0
  73. wmo/distill/store.py +829 -0
  74. wmo/distill/teacher.py +714 -0
  75. wmo/distill/tokens.py +535 -0
  76. wmo/distill/tracking.py +552 -0
  77. wmo/distill/tripwire.py +411 -0
  78. wmo/distill/xtoken/byte_offsets.py +152 -0
  79. wmo/distill/xtoken/chunks.py +457 -0
  80. wmo/distill/xtoken/prompt_logprobs.py +475 -0
  81. wmo/distill/xtoken/teacher_render.py +346 -0
  82. wmo/engine/__init__.py +28 -0
  83. wmo/engine/autoconfig.py +367 -0
  84. wmo/engine/build.py +346 -0
  85. wmo/engine/demo.py +77 -0
  86. wmo/engine/eval_suites.py +245 -0
  87. wmo/engine/grounding.py +491 -0
  88. wmo/engine/knowledge.py +291 -0
  89. wmo/engine/loader.py +36 -0
  90. wmo/engine/play.py +92 -0
  91. wmo/engine/prompts.py +99 -0
  92. wmo/engine/replay.py +443 -0
  93. wmo/engine/reporting.py +58 -0
  94. wmo/engine/workspace.py +468 -0
  95. wmo/engine/world_model.py +568 -0
  96. wmo/env/__init__.py +22 -0
  97. wmo/env/base.py +121 -0
  98. wmo/env/closed_loop.py +229 -0
  99. wmo/env/episode.py +107 -0
  100. wmo/env/llm_agent.py +93 -0
  101. wmo/env/scenarios.py +73 -0
  102. wmo/evals/__init__.py +52 -0
  103. wmo/evals/agreement.py +110 -0
  104. wmo/evals/base.py +45 -0
  105. wmo/evals/closed_loop.py +480 -0
  106. wmo/evals/failover.py +96 -0
  107. wmo/evals/gold.py +127 -0
  108. wmo/evals/grid.py +394 -0
  109. wmo/evals/grid_plot.py +205 -0
  110. wmo/evals/harbor/__init__.py +27 -0
  111. wmo/evals/harbor/agent.py +573 -0
  112. wmo/evals/harbor/ctrf.py +171 -0
  113. wmo/evals/harbor/e2b_environment.py +587 -0
  114. wmo/evals/harbor/e2b_template_policy.py +144 -0
  115. wmo/evals/harbor/scorer.py +875 -0
  116. wmo/evals/harbor/tasks.py +140 -0
  117. wmo/evals/open_loop.py +194 -0
  118. wmo/evals/tasks.py +53 -0
  119. wmo/harness/__init__.py +51 -0
  120. wmo/harness/code_runtime.py +288 -0
  121. wmo/harness/create.py +1191 -0
  122. wmo/harness/delta.py +220 -0
  123. wmo/harness/doc.py +556 -0
  124. wmo/harness/e2b_ledger.py +342 -0
  125. wmo/harness/e2b_reap.py +476 -0
  126. wmo/harness/e2b_sandbox.py +350 -0
  127. wmo/harness/environment.py +35 -0
  128. wmo/harness/live_session.py +543 -0
  129. wmo/harness/mutate.py +343 -0
  130. wmo/harness/pi_e2b.py +1710 -0
  131. wmo/harness/pi_entry/entry.ts +268 -0
  132. wmo/harness/pi_entry/runner_frames.ts +92 -0
  133. wmo/harness/pi_entry/runner_live.ts +587 -0
  134. wmo/harness/pi_entry/runner_service.ts +270 -0
  135. wmo/harness/pi_entry/runner_stdio.ts +374 -0
  136. wmo/harness/pi_entry/runner_termination.ts +142 -0
  137. wmo/harness/pi_local.py +262 -0
  138. wmo/harness/pi_runtime.py +495 -0
  139. wmo/harness/pi_vendor.py +65 -0
  140. wmo/harness/population.py +509 -0
  141. wmo/harness/project_proposer.py +569 -0
  142. wmo/harness/proposer.py +977 -0
  143. wmo/harness/runner_link.py +619 -0
  144. wmo/harness/runtime.py +389 -0
  145. wmo/harness/scoring.py +247 -0
  146. wmo/harness/skills.py +116 -0
  147. wmo/harness/source_tree.py +319 -0
  148. wmo/harness/store.py +176 -0
  149. wmo/harness/tools.py +105 -0
  150. wmo/harness/vendor/manifest.sha256 +58 -0
  151. wmo/harness/vendor/pi-agent/CHANGELOG.md +556 -0
  152. wmo/harness/vendor/pi-agent/LICENSE +21 -0
  153. wmo/harness/vendor/pi-agent/README.md +488 -0
  154. wmo/harness/vendor/pi-agent/VENDOR.md +39 -0
  155. wmo/harness/vendor/pi-agent/docs/agent-harness.md +486 -0
  156. wmo/harness/vendor/pi-agent/docs/durable-harness.md +212 -0
  157. wmo/harness/vendor/pi-agent/docs/hooks.md +445 -0
  158. wmo/harness/vendor/pi-agent/docs/models.md +966 -0
  159. wmo/harness/vendor/pi-agent/docs/observability.md +376 -0
  160. wmo/harness/vendor/pi-agent/package.json +60 -0
  161. wmo/harness/vendor/pi-agent/src/agent-loop.ts +748 -0
  162. wmo/harness/vendor/pi-agent/src/agent.ts +575 -0
  163. wmo/harness/vendor/pi-agent/src/harness/agent-harness.ts +1029 -0
  164. wmo/harness/vendor/pi-agent/src/harness/compaction/branch-summarization.ts +261 -0
  165. wmo/harness/vendor/pi-agent/src/harness/compaction/compaction.ts +747 -0
  166. wmo/harness/vendor/pi-agent/src/harness/compaction/utils.ts +144 -0
  167. wmo/harness/vendor/pi-agent/src/harness/env/nodejs.ts +550 -0
  168. wmo/harness/vendor/pi-agent/src/harness/messages.ts +164 -0
  169. wmo/harness/vendor/pi-agent/src/harness/prompt-templates.ts +267 -0
  170. wmo/harness/vendor/pi-agent/src/harness/session/jsonl-repo.ts +177 -0
  171. wmo/harness/vendor/pi-agent/src/harness/session/jsonl-storage.ts +293 -0
  172. wmo/harness/vendor/pi-agent/src/harness/session/memory-repo.ts +50 -0
  173. wmo/harness/vendor/pi-agent/src/harness/session/memory-storage.ts +131 -0
  174. wmo/harness/vendor/pi-agent/src/harness/session/repo-utils.ts +51 -0
  175. wmo/harness/vendor/pi-agent/src/harness/session/session.ts +267 -0
  176. wmo/harness/vendor/pi-agent/src/harness/session/uuid.ts +54 -0
  177. wmo/harness/vendor/pi-agent/src/harness/skills.ts +375 -0
  178. wmo/harness/vendor/pi-agent/src/harness/system-prompt.ts +34 -0
  179. wmo/harness/vendor/pi-agent/src/harness/types.ts +836 -0
  180. wmo/harness/vendor/pi-agent/src/harness/utils/shell-output.ts +135 -0
  181. wmo/harness/vendor/pi-agent/src/harness/utils/truncate.ts +344 -0
  182. wmo/harness/vendor/pi-agent/src/index.ts +44 -0
  183. wmo/harness/vendor/pi-agent/src/node.ts +2 -0
  184. wmo/harness/vendor/pi-agent/src/proxy.ts +367 -0
  185. wmo/harness/vendor/pi-agent/src/types.ts +428 -0
  186. wmo/harness/vendor/pi-agent/test/agent-loop.test.ts +1351 -0
  187. wmo/harness/vendor/pi-agent/test/agent.test.ts +699 -0
  188. wmo/harness/vendor/pi-agent/test/e2e.test.ts +404 -0
  189. wmo/harness/vendor/pi-agent/test/harness/agent-harness-stream.test.ts +213 -0
  190. wmo/harness/vendor/pi-agent/test/harness/agent-harness.test.ts +608 -0
  191. wmo/harness/vendor/pi-agent/test/harness/compaction.test.ts +655 -0
  192. wmo/harness/vendor/pi-agent/test/harness/nodejs-env.test.ts +321 -0
  193. wmo/harness/vendor/pi-agent/test/harness/prompt-templates.test.ts +90 -0
  194. wmo/harness/vendor/pi-agent/test/harness/repo.test.ts +68 -0
  195. wmo/harness/vendor/pi-agent/test/harness/resource-formatting.test.ts +24 -0
  196. wmo/harness/vendor/pi-agent/test/harness/session-test-utils.ts +55 -0
  197. wmo/harness/vendor/pi-agent/test/harness/session-uuid.test.ts +50 -0
  198. wmo/harness/vendor/pi-agent/test/harness/session.test.ts +156 -0
  199. wmo/harness/vendor/pi-agent/test/harness/skills.test.ts +116 -0
  200. wmo/harness/vendor/pi-agent/test/harness/storage.test.ts +299 -0
  201. wmo/harness/vendor/pi-agent/test/harness/system-prompt.test.ts +66 -0
  202. wmo/harness/vendor/pi-agent/test/harness/truncate.test.ts +169 -0
  203. wmo/harness/vendor/pi-agent/test/scratch/simple.ts +72 -0
  204. wmo/harness/vendor/pi-agent/test/utils/calculate.ts +32 -0
  205. wmo/harness/vendor/pi-agent/test/utils/get-current-time.ts +46 -0
  206. wmo/harness/vendor/pi-agent/tsconfig.build.json +13 -0
  207. wmo/harness/vendor/pi-agent/vitest.config.ts +19 -0
  208. wmo/harness/vendor/pi-agent/vitest.harness.config.ts +28 -0
  209. wmo/harness/vendor/vendor_pi.sh +59 -0
  210. wmo/harness/workspace_patch.py +270 -0
  211. wmo/ingest/__init__.py +47 -0
  212. wmo/ingest/adapter.py +72 -0
  213. wmo/ingest/base.py +114 -0
  214. wmo/ingest/braintrust.py +339 -0
  215. wmo/ingest/detect.py +126 -0
  216. wmo/ingest/langfuse.py +291 -0
  217. wmo/ingest/langsmith.py +444 -0
  218. wmo/ingest/mastra.py +330 -0
  219. wmo/ingest/messages.py +170 -0
  220. wmo/ingest/normalize.py +679 -0
  221. wmo/ingest/otel_genai.py +69 -0
  222. wmo/ingest/otel_writer.py +100 -0
  223. wmo/ingest/phoenix.py +150 -0
  224. wmo/ingest/postgres.py +246 -0
  225. wmo/ingest/posthog.py +320 -0
  226. wmo/ingest/quality.py +28 -0
  227. wmo/ingest/stream.py +209 -0
  228. wmo/ingest/testdata/sample_otlp.json +60 -0
  229. wmo/ingest/testdata/sample_spans.jsonl +3 -0
  230. wmo/optimize/__init__.py +25 -0
  231. wmo/optimize/base.py +143 -0
  232. wmo/optimize/gepa.py +806 -0
  233. wmo/optimize/judge.py +262 -0
  234. wmo/optimize/judge_quality.py +359 -0
  235. wmo/optimize/knn.py +468 -0
  236. wmo/optimize/numeric.py +152 -0
  237. wmo/optimize/outcomes.py +103 -0
  238. wmo/optimize/policy.py +669 -0
  239. wmo/optimize/report.py +231 -0
  240. wmo/optimize/reward.py +129 -0
  241. wmo/optimize/routing.py +373 -0
  242. wmo/platform/__init__.py +6 -0
  243. wmo/platform/auth.py +115 -0
  244. wmo/platform/client.py +551 -0
  245. wmo/platform/credentials.py +126 -0
  246. wmo/platform/transfer.py +158 -0
  247. wmo/providers/__init__.py +40 -0
  248. wmo/providers/_bedrock_chat.py +155 -0
  249. wmo/providers/_openai_common.py +182 -0
  250. wmo/providers/_responses_common.py +472 -0
  251. wmo/providers/anthropic.py +134 -0
  252. wmo/providers/azure_openai.py +296 -0
  253. wmo/providers/base.py +300 -0
  254. wmo/providers/bedrock.py +312 -0
  255. wmo/providers/models.py +205 -0
  256. wmo/providers/openai.py +143 -0
  257. wmo/providers/openai_responses.py +240 -0
  258. wmo/providers/pool.py +170 -0
  259. wmo/providers/registry.py +73 -0
  260. wmo/providers/retry.py +151 -0
  261. wmo/providers/tinker.py +936 -0
  262. wmo/providers/waterfall.py +336 -0
  263. wmo/research/__init__.py +81 -0
  264. wmo/research/ablation.py +133 -0
  265. wmo/research/concurrency_plot.py +523 -0
  266. wmo/research/concurrency_run.py +240 -0
  267. wmo/research/concurrency_scaling.py +270 -0
  268. wmo/research/gepa_scaling.py +274 -0
  269. wmo/research/pipeline.py +198 -0
  270. wmo/research/scaling_split.py +82 -0
  271. wmo/research/scenario_fidelity.py +198 -0
  272. wmo/research/scenario_recovery.py +92 -0
  273. wmo/research/seed_stability.py +90 -0
  274. wmo/research/trace_scaling.py +348 -0
  275. wmo/retrieval/__init__.py +6 -0
  276. wmo/retrieval/embedders.py +105 -0
  277. wmo/retrieval/leakfree.py +52 -0
  278. wmo/retrieval/retriever.py +173 -0
  279. wmo/scenarios/__init__.py +58 -0
  280. wmo/scenarios/builder.py +152 -0
  281. wmo/scenarios/mining/__init__.py +27 -0
  282. wmo/scenarios/mining/clustering.py +171 -0
  283. wmo/scenarios/mining/facets.py +226 -0
  284. wmo/scenarios/mining/selection.py +220 -0
  285. wmo/scenarios/synthesis/__init__.py +6 -0
  286. wmo/scenarios/synthesis/scenario_set.py +63 -0
  287. wmo/scenarios/synthesis/synthesizer.py +85 -0
  288. wmo/scenarios/verification/__init__.py +17 -0
  289. wmo/scenarios/verification/judge.py +97 -0
  290. wmo/scenarios/verification/verify.py +135 -0
  291. wmo/serving/__init__.py +5 -0
  292. wmo/serving/builds.py +451 -0
  293. wmo/serving/chat.py +878 -0
  294. wmo/serving/endpoint_config.py +64 -0
  295. wmo/serving/savings.py +250 -0
  296. wmo/serving/server.py +553 -0
  297. wmo/serving/traces_source.py +206 -0
  298. wmo/telemetry.py +213 -0
  299. wmo/tracking/__init__.py +36 -0
  300. wmo/tracking/clock.py +24 -0
  301. wmo/tracking/metered.py +125 -0
  302. wmo/tracking/pricing.py +99 -0
  303. wmo/tracking/store.py +31 -0
  304. wmo/tracking/tracker.py +149 -0
  305. world_model_optimizer-0.2.0.dist-info/METADATA +203 -0
  306. world_model_optimizer-0.2.0.dist-info/RECORD +308 -0
  307. world_model_optimizer-0.2.0.dist-info/WHEEL +4 -0
  308. world_model_optimizer-0.2.0.dist-info/entry_points.txt +2 -0
wmo/connect/google.py ADDED
@@ -0,0 +1,627 @@
1
+ """Google context connectors: Calendar, Drive, and Gmail read-only pulls.
2
+
3
+ All three connectors share one Google OAuth app (`get_app("google")`) and the browser loopback
4
+ flow, differing only in the scope they request and the account label they report. Each keeps its
5
+ own stored credential (one consent per service in v1) and refreshes it through `ensure_fresh`
6
+ before every pull so refresh tokens keep working unattended.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import base64
12
+ import logging
13
+ from datetime import UTC, datetime, timedelta
14
+ from typing import cast
15
+ from urllib.parse import quote
16
+
17
+ import httpx
18
+
19
+ from wmo.connect.apps import get_app
20
+ from wmo.connect.connector import ConnectUI, register_connector
21
+ from wmo.connect.oauth import ensure_fresh, run_loopback_flow
22
+ from wmo.connect.types import (
23
+ ConnectError,
24
+ ConnectorAuth,
25
+ ContextItem,
26
+ ItemKind,
27
+ PullQuery,
28
+ capped,
29
+ opt_str,
30
+ strip_html,
31
+ transport_errors,
32
+ )
33
+ from wmo.core.types import JsonObject, JsonValue
34
+
35
+ logger = logging.getLogger(__name__)
36
+
37
+ _APP_NAME = "google"
38
+ _TIMEOUT_SECONDS = 30.0
39
+
40
+ _CALENDAR_BASE = "https://www.googleapis.com/calendar/v3"
41
+ _DRIVE_BASE = "https://www.googleapis.com/drive/v3"
42
+ _GMAIL_BASE = "https://gmail.googleapis.com/gmail/v1"
43
+
44
+ CALENDAR_SCOPE = "https://www.googleapis.com/auth/calendar.readonly"
45
+ DRIVE_SCOPE = "https://www.googleapis.com/auth/drive.readonly"
46
+ GMAIL_SCOPE = "https://www.googleapis.com/auth/gmail.readonly"
47
+
48
+ # Per-request page sizes, bounded well within each API's documented maximum.
49
+ _CALENDAR_PAGE_MAX = 250
50
+ _DRIVE_PAGE_MAX = 100
51
+ _GMAIL_PAGE_MAX = 100
52
+
53
+ # Calendar pulls default to a window around now when since/until are not given.
54
+ _DEFAULT_LOOKBACK = timedelta(days=30)
55
+ _DEFAULT_LOOKAHEAD = timedelta(days=60)
56
+
57
+ _DRIVE_LIST_FIELDS = (
58
+ "nextPageToken,files(id,name,mimeType,modifiedTime,createdTime,webViewLink,size)"
59
+ )
60
+
61
+ # Google-native types Drive can export as text, mapped to the export mime each one supports.
62
+ # Spreadsheets have no text/plain export; text/csv (the first sheet only) is the text form.
63
+ _EXPORT_MIME_BY_TYPE: dict[str, str] = {
64
+ "application/vnd.google-apps.document": "text/plain",
65
+ "application/vnd.google-apps.presentation": "text/plain",
66
+ "application/vnd.google-apps.spreadsheet": "text/csv",
67
+ }
68
+
69
+
70
+ class _GoogleConnector:
71
+ """Shared auth, refresh, and HTTP plumbing for the three Google service connectors.
72
+
73
+ Subclasses set `name`/`label`/`scope` and implement `verify` and `pull`; everything that
74
+ talks OAuth or carries the bearer token lives here.
75
+ """
76
+
77
+ name: str
78
+ label: str
79
+ scope: str
80
+
81
+ def __init__(self, transport: httpx.BaseTransport | None = None) -> None:
82
+ """`transport` is injected into every httpx call; None means the real network."""
83
+ self._transport = transport
84
+
85
+ def connect(self, ui: ConnectUI) -> ConnectorAuth:
86
+ """Run the browser loopback OAuth flow for this service's single read-only scope.
87
+
88
+ Args:
89
+ ui: Presentation callbacks; only `open_url` and `info` are used.
90
+
91
+ Returns:
92
+ The oauth-kind credential with `account` set to the verified identity.
93
+ """
94
+ app = get_app(_APP_NAME)
95
+ ui.info(f"Requesting read-only Google access for {self.label} ({self.scope}).")
96
+ auth = run_loopback_flow(
97
+ app, scopes=[self.scope], open_url=ui.open_url, transport=self._transport
98
+ )
99
+ account = self.verify(auth)
100
+ ui.info(f"Connected {self.label} as {account}.")
101
+ return auth.model_copy(update={"account": account})
102
+
103
+ def verify(self, auth: ConnectorAuth) -> str:
104
+ """Cheap identity check; implemented per service."""
105
+ raise NotImplementedError
106
+
107
+ def pull(self, auth: ConnectorAuth, query: PullQuery) -> list[ContextItem]:
108
+ """Fetch normalized items; implemented per service."""
109
+ raise NotImplementedError
110
+
111
+ def _fresh(self, auth: ConnectorAuth) -> ConnectorAuth:
112
+ """Refresh the credential when it is about to expire, persisting the result.
113
+
114
+ Consults the OAuth app config only when a refresh is actually possible, so plain
115
+ env-injected tokens work without any Google OAuth client configured.
116
+
117
+ Raises:
118
+ ConnectError: When the provider rejects the refresh (e.g. `invalid_grant` after
119
+ the user revoked access); the message says the connection must be reauthorized.
120
+ """
121
+ if not auth.refresh_token or not auth.expires_at:
122
+ return auth
123
+ try:
124
+ return ensure_fresh(get_app(_APP_NAME), self.name, auth, transport=self._transport)
125
+ except ConnectError as exc:
126
+ raise ConnectError(
127
+ f"could not refresh the {self.label} credential ({exc}); "
128
+ "the token is invalid or expired and the connection must be reauthorized"
129
+ ) from exc
130
+
131
+ def _client(self) -> httpx.Client:
132
+ """A short-lived HTTP client carrying the injected transport."""
133
+ return httpx.Client(timeout=_TIMEOUT_SECONDS, transport=self._transport)
134
+
135
+ def _get(
136
+ self,
137
+ client: httpx.Client,
138
+ auth: ConnectorAuth,
139
+ url: str,
140
+ *,
141
+ params: dict[str, str] | None = None,
142
+ ) -> httpx.Response:
143
+ """One authorized GET; 401s and transport failures become actionable ConnectErrors."""
144
+ with transport_errors(httpx.URL(url).host):
145
+ response = client.get(
146
+ url, params=params, headers={"Authorization": f"Bearer {auth.access_token}"}
147
+ )
148
+ if response.status_code == 401:
149
+ raise ConnectError(
150
+ f"{self.label} rejected the stored credential (HTTP 401); "
151
+ "the token is invalid or expired and the connection must be reauthorized"
152
+ )
153
+ return response
154
+
155
+ def _get_json(
156
+ self,
157
+ client: httpx.Client,
158
+ auth: ConnectorAuth,
159
+ url: str,
160
+ *,
161
+ params: dict[str, str] | None = None,
162
+ ) -> JsonObject:
163
+ """An authorized GET that must return a JSON object, else raises ConnectError."""
164
+ response = self._get(client, auth, url, params=params)
165
+ if response.status_code != 200:
166
+ raise ConnectError(
167
+ f"{self.label} request to {url} failed "
168
+ f"(HTTP {response.status_code}): {response.text[:200]}; "
169
+ "check the target/query and retry"
170
+ )
171
+ try:
172
+ raw = response.json()
173
+ except ValueError:
174
+ raw = None
175
+ if not isinstance(raw, dict):
176
+ raise ConnectError(
177
+ f"{self.label} returned an unexpected non-object response from {url}; retry later"
178
+ )
179
+ return cast(JsonObject, raw)
180
+
181
+
182
+ class GoogleCalendarConnector(_GoogleConnector):
183
+ """Pulls calendar events, normalized as EVENT items with a human-readable detail block."""
184
+
185
+ name = "google-calendar"
186
+ label = "Google Calendar"
187
+ scope = CALENDAR_SCOPE
188
+
189
+ def verify(self, auth: ConnectorAuth) -> str:
190
+ """The primary calendar's summary (usually the account's email address)."""
191
+ with self._client() as client:
192
+ payload = self._get_json(client, auth, f"{_CALENDAR_BASE}/calendars/primary")
193
+ return opt_str(payload.get("summary")) or "primary calendar"
194
+
195
+ def pull(self, auth: ConnectorAuth, query: PullQuery) -> list[ContextItem]:
196
+ """List events from `query.target` (default "primary") within the query window.
197
+
198
+ Defaults to now-30d .. now+60d when `since`/`until` are unset; expands recurring
199
+ events (`singleEvents=true`) and paginates `nextPageToken` up to `query.limit`.
200
+ """
201
+ auth = self._fresh(auth)
202
+ calendar_id = query.target or "primary"
203
+ now = datetime.now(UTC)
204
+ time_min = (
205
+ _rfc3339(query.since, field="since") if query.since else _iso(now - _DEFAULT_LOOKBACK)
206
+ )
207
+ time_max = (
208
+ _rfc3339(query.until, field="until") if query.until else _iso(now + _DEFAULT_LOOKAHEAD)
209
+ )
210
+ url = f"{_CALENDAR_BASE}/calendars/{quote(calendar_id, safe='@.')}/events"
211
+ items: list[ContextItem] = []
212
+ page_token: str | None = None
213
+ with self._client() as client:
214
+ while len(items) < query.limit:
215
+ params: dict[str, str] = {
216
+ "singleEvents": "true",
217
+ "orderBy": "startTime",
218
+ "timeMin": time_min,
219
+ "timeMax": time_max,
220
+ "maxResults": str(min(query.limit - len(items), _CALENDAR_PAGE_MAX)),
221
+ }
222
+ if query.query:
223
+ params["q"] = query.query
224
+ if page_token:
225
+ params["pageToken"] = page_token
226
+ payload = self._get_json(client, auth, url, params=params)
227
+ for event in _dicts(payload.get("items")):
228
+ item = self._event_item(event, calendar_id)
229
+ if item is not None:
230
+ items.append(item)
231
+ if len(items) >= query.limit:
232
+ break
233
+ page_token = opt_str(payload.get("nextPageToken"))
234
+ if not page_token:
235
+ break
236
+ return items
237
+
238
+ def _event_item(self, event: JsonObject, calendar_id: str) -> ContextItem | None:
239
+ """Normalize one API event; events without an id are skipped."""
240
+ event_id = opt_str(event.get("id"))
241
+ if not event_id:
242
+ logger.debug("skipping calendar event without an id: %r", event)
243
+ return None
244
+ metadata: JsonObject = {"calendar": calendar_id}
245
+ status = opt_str(event.get("status"))
246
+ if status:
247
+ metadata["status"] = status
248
+ return ContextItem(
249
+ id=event_id,
250
+ source=self.name,
251
+ kind=ItemKind.EVENT,
252
+ title=opt_str(event.get("summary")) or "(no title)",
253
+ body=_event_body(event),
254
+ url=opt_str(event.get("htmlLink")),
255
+ created_at=opt_str(event.get("created")),
256
+ updated_at=opt_str(event.get("updated")),
257
+ metadata=metadata,
258
+ )
259
+
260
+
261
+ class GoogleDriveConnector(_GoogleConnector):
262
+ """Pulls Drive files: Google docs exported as text, text blobs downloaded, binaries listed."""
263
+
264
+ name = "google-drive"
265
+ label = "Google Drive"
266
+ scope = DRIVE_SCOPE
267
+
268
+ def verify(self, auth: ConnectorAuth) -> str:
269
+ """The Drive user's display name and email address."""
270
+ with self._client() as client:
271
+ payload = self._get_json(
272
+ client, auth, f"{_DRIVE_BASE}/about", params={"fields": "user"}
273
+ )
274
+ user = payload.get("user")
275
+ user_obj = cast(JsonObject, user) if isinstance(user, dict) else {}
276
+ name = opt_str(user_obj.get("displayName"))
277
+ email = opt_str(user_obj.get("emailAddress"))
278
+ if name and email:
279
+ return f"{name} ({email})"
280
+ return name or email or "Google Drive user"
281
+
282
+ def pull(self, auth: ConnectorAuth, query: PullQuery) -> list[ContextItem]:
283
+ """Search files (newest modified first) and fetch readable content per file.
284
+
285
+ The Drive `q` is derived from the query: `trashed=false` always, `fullText contains`
286
+ for free text, `'<target>' in parents` for a folder id, and `modifiedTime` bounds for
287
+ since/until. Google-native files are exported as text (Docs/Slides as plain text,
288
+ Sheets as CSV); `text/*` and JSON blobs are downloaded (all capped at 200000
289
+ characters); other binaries become body-less FILE items.
290
+ """
291
+ auth = self._fresh(auth)
292
+ q = _drive_search_terms(query)
293
+ items: list[ContextItem] = []
294
+ page_token: str | None = None
295
+ with self._client() as client:
296
+ while len(items) < query.limit:
297
+ params: dict[str, str] = {
298
+ "q": q,
299
+ "orderBy": "modifiedTime desc",
300
+ "pageSize": str(min(query.limit - len(items), _DRIVE_PAGE_MAX)),
301
+ "fields": _DRIVE_LIST_FIELDS,
302
+ }
303
+ if page_token:
304
+ params["pageToken"] = page_token
305
+ payload = self._get_json(client, auth, f"{_DRIVE_BASE}/files", params=params)
306
+ for file in _dicts(payload.get("files")):
307
+ item = self._file_item(client, auth, file)
308
+ if item is not None:
309
+ items.append(item)
310
+ if len(items) >= query.limit:
311
+ break
312
+ page_token = opt_str(payload.get("nextPageToken"))
313
+ if not page_token:
314
+ break
315
+ return items
316
+
317
+ def _file_item(
318
+ self, client: httpx.Client, auth: ConnectorAuth, file: JsonObject
319
+ ) -> ContextItem | None:
320
+ """Normalize one Drive file, fetching its content when it has a readable form."""
321
+ file_id = opt_str(file.get("id"))
322
+ if not file_id:
323
+ logger.debug("skipping drive file without an id: %r", file)
324
+ return None
325
+ mime = opt_str(file.get("mimeType")) or ""
326
+ body, kind, fetch_error = self._file_body(client, auth, file_id, mime)
327
+ metadata: JsonObject = {"mimeType": mime}
328
+ size = opt_str(file.get("size"))
329
+ if size:
330
+ metadata["size"] = size
331
+ if fetch_error:
332
+ metadata["fetch_error"] = fetch_error
333
+ return ContextItem(
334
+ id=file_id,
335
+ source=self.name,
336
+ kind=kind,
337
+ title=opt_str(file.get("name")) or file_id,
338
+ body=body,
339
+ url=opt_str(file.get("webViewLink")),
340
+ created_at=opt_str(file.get("createdTime")),
341
+ updated_at=opt_str(file.get("modifiedTime")),
342
+ metadata=metadata,
343
+ )
344
+
345
+ def _file_body(
346
+ self, client: httpx.Client, auth: ConnectorAuth, file_id: str, mime: str
347
+ ) -> tuple[str, ItemKind, str | None]:
348
+ """(body, kind, fetch_error) for one file; unreadable binaries stay body-less FILEs.
349
+
350
+ A failed per-file content fetch (except 401) degrades to an empty body with the
351
+ failure recorded in metadata instead of aborting the whole pull.
352
+ """
353
+ export_mime = _EXPORT_MIME_BY_TYPE.get(mime)
354
+ if export_mime is not None:
355
+ response = self._get(
356
+ client,
357
+ auth,
358
+ f"{_DRIVE_BASE}/files/{quote(file_id, safe='')}/export",
359
+ params={"mimeType": export_mime},
360
+ )
361
+ elif mime.startswith("text/") or mime == "application/json":
362
+ response = self._get(
363
+ client,
364
+ auth,
365
+ f"{_DRIVE_BASE}/files/{quote(file_id, safe='')}",
366
+ params={"alt": "media"},
367
+ )
368
+ else:
369
+ return "", ItemKind.FILE, None
370
+ if response.status_code != 200:
371
+ logger.warning(
372
+ "could not fetch drive file %s content (HTTP %s); leaving its body empty",
373
+ file_id,
374
+ response.status_code,
375
+ )
376
+ return "", ItemKind.DOCUMENT, f"HTTP {response.status_code}"
377
+ return capped(response.text), ItemKind.DOCUMENT, None
378
+
379
+
380
+ class GmailConnector(_GoogleConnector):
381
+ """Pulls Gmail messages as EMAIL items: header block plus the decoded text body."""
382
+
383
+ name = "gmail"
384
+ label = "Gmail"
385
+ scope = GMAIL_SCOPE
386
+
387
+ def verify(self, auth: ConnectorAuth) -> str:
388
+ """The mailbox's email address."""
389
+ with self._client() as client:
390
+ payload = self._get_json(client, auth, f"{_GMAIL_BASE}/users/me/profile")
391
+ return opt_str(payload.get("emailAddress")) or "Gmail user"
392
+
393
+ def pull(self, auth: ConnectorAuth, query: PullQuery) -> list[ContextItem]:
394
+ """Search messages, then fetch each match in full and normalize it.
395
+
396
+ `query.query` passes through as Gmail search syntax; `since`/`until` become
397
+ `after:`/`before:` date operators. Message ids are collected across `nextPageToken`
398
+ pages up to `query.limit`, then each id costs one `format=full` fetch.
399
+ """
400
+ auth = self._fresh(auth)
401
+ q = _gmail_search_terms(query)
402
+ message_ids: list[str] = []
403
+ items: list[ContextItem] = []
404
+ with self._client() as client:
405
+ page_token: str | None = None
406
+ while len(message_ids) < query.limit:
407
+ params: dict[str, str] = {
408
+ "maxResults": str(min(query.limit - len(message_ids), _GMAIL_PAGE_MAX))
409
+ }
410
+ if q:
411
+ params["q"] = q
412
+ if page_token:
413
+ params["pageToken"] = page_token
414
+ payload = self._get_json(
415
+ client, auth, f"{_GMAIL_BASE}/users/me/messages", params=params
416
+ )
417
+ for ref in _dicts(payload.get("messages")):
418
+ ref_id = opt_str(ref.get("id"))
419
+ if ref_id:
420
+ message_ids.append(ref_id)
421
+ if len(message_ids) >= query.limit:
422
+ break
423
+ page_token = opt_str(payload.get("nextPageToken"))
424
+ if not page_token:
425
+ break
426
+ for message_id in message_ids:
427
+ message = self._get_json(
428
+ client,
429
+ auth,
430
+ f"{_GMAIL_BASE}/users/me/messages/{quote(message_id, safe='')}",
431
+ params={"format": "full"},
432
+ )
433
+ items.append(self._message_item(message, message_id))
434
+ return items
435
+
436
+ def _message_item(self, message: JsonObject, message_id: str) -> ContextItem:
437
+ """Normalize one full-format message into an EMAIL item."""
438
+ payload = message.get("payload")
439
+ payload_obj = cast(JsonObject, payload) if isinstance(payload, dict) else {}
440
+ headers = payload_obj.get("headers")
441
+ sender = _gmail_header(headers, "From")
442
+ to = _gmail_header(headers, "To")
443
+ date = _gmail_header(headers, "Date")
444
+ text = _find_part_text(payload_obj, "text/plain")
445
+ if text is None:
446
+ markup = _find_part_text(payload_obj, "text/html")
447
+ text = strip_html(markup) if markup else ""
448
+ body = f"From: {sender}\nTo: {to}\nDate: {date}"
449
+ if text.strip():
450
+ body = f"{body}\n\n{text.strip()}"
451
+ metadata: JsonObject = {"from": sender, "to": to}
452
+ label_ids = message.get("labelIds")
453
+ if isinstance(label_ids, list):
454
+ metadata["labelIds"] = [label for label in label_ids if isinstance(label, str)]
455
+ thread_id = opt_str(message.get("threadId"))
456
+ if thread_id:
457
+ metadata["threadId"] = thread_id
458
+ return ContextItem(
459
+ id=message_id,
460
+ source=self.name,
461
+ kind=ItemKind.EMAIL,
462
+ title=_gmail_header(headers, "Subject") or "(no subject)",
463
+ body=body,
464
+ url=f"https://mail.google.com/mail/u/0/#all/{message_id}",
465
+ created_at=_from_epoch_ms(message.get("internalDate")),
466
+ metadata=metadata,
467
+ )
468
+
469
+
470
+ # -- shared normalization helpers ---------------------------------------------------------------
471
+
472
+
473
+ def _dicts(value: JsonValue | None) -> list[JsonObject]:
474
+ """The dict entries of a JSON list (non-lists and non-dict entries are dropped)."""
475
+ if not isinstance(value, list):
476
+ return []
477
+ return [cast(JsonObject, entry) for entry in value if isinstance(entry, dict)]
478
+
479
+
480
+ def _parse_iso(value: str, *, field: str) -> datetime:
481
+ """Parse an ISO-8601 date or datetime; naive values are taken as UTC.
482
+
483
+ Raises:
484
+ ConnectError: When the value is not ISO-8601.
485
+ """
486
+ try:
487
+ parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
488
+ except ValueError:
489
+ raise ConnectError(
490
+ f"could not parse {field}={value!r} as ISO-8601; use YYYY-MM-DD or a full timestamp"
491
+ ) from None
492
+ return parsed if parsed.tzinfo else parsed.replace(tzinfo=UTC)
493
+
494
+
495
+ def _rfc3339(value: str, *, field: str) -> str:
496
+ """An ISO-8601 query bound as the RFC 3339 timestamp Google APIs expect."""
497
+ return _parse_iso(value, field=field).isoformat()
498
+
499
+
500
+ def _iso(moment: datetime) -> str:
501
+ """A datetime as a seconds-precision ISO-8601 string."""
502
+ return moment.isoformat(timespec="seconds")
503
+
504
+
505
+ # -- calendar helpers ---------------------------------------------------------------------------
506
+
507
+
508
+ def _event_body(event: JsonObject) -> str:
509
+ """A human-readable block: when, where, who, then the event description."""
510
+ lines: list[str] = []
511
+ start = _event_moment(event.get("start"))
512
+ end = _event_moment(event.get("end"))
513
+ if start or end:
514
+ lines.append(f"When: {start or 'unknown'} to {end or 'unknown'}")
515
+ location = opt_str(event.get("location"))
516
+ if location:
517
+ lines.append(f"Location: {location}")
518
+ attendees = [label for a in _dicts(event.get("attendees")) if (label := _attendee_label(a))]
519
+ if attendees:
520
+ lines.append("Attendees: " + ", ".join(attendees))
521
+ description = opt_str(event.get("description"))
522
+ if description:
523
+ lines.append("")
524
+ lines.append(description)
525
+ return "\n".join(lines)
526
+
527
+
528
+ def _event_moment(value: JsonValue | None) -> str | None:
529
+ """An event boundary's dateTime (timed) or date (all-day), whichever is present."""
530
+ if not isinstance(value, dict):
531
+ return None
532
+ return opt_str(value.get("dateTime")) or opt_str(value.get("date"))
533
+
534
+
535
+ def _attendee_label(attendee: JsonObject) -> str:
536
+ """ "Name <email>" when both are known, else whichever one is."""
537
+ name = opt_str(attendee.get("displayName"))
538
+ email = opt_str(attendee.get("email"))
539
+ if name and email:
540
+ return f"{name} <{email}>"
541
+ return name or email or ""
542
+
543
+
544
+ # -- drive helpers ------------------------------------------------------------------------------
545
+
546
+
547
+ def _drive_search_terms(query: PullQuery) -> str:
548
+ """The Drive files.list `q` derived from a PullQuery (always excludes trashed files)."""
549
+ terms = ["trashed=false"]
550
+ if query.query:
551
+ terms.append(f"fullText contains '{_drive_escape(query.query)}'")
552
+ if query.target:
553
+ terms.append(f"'{_drive_escape(query.target)}' in parents")
554
+ if query.since:
555
+ terms.append(f"modifiedTime > '{_rfc3339(query.since, field='since')}'")
556
+ if query.until:
557
+ terms.append(f"modifiedTime <= '{_rfc3339(query.until, field='until')}'")
558
+ return " and ".join(terms)
559
+
560
+
561
+ def _drive_escape(value: str) -> str:
562
+ """Escape backslashes and single quotes for a Drive query string literal."""
563
+ return value.replace("\\", "\\\\").replace("'", "\\'")
564
+
565
+
566
+ # -- gmail helpers ------------------------------------------------------------------------------
567
+
568
+
569
+ def _gmail_search_terms(query: PullQuery) -> str:
570
+ """The Gmail `q` string: the user's search syntax plus after:/before: date operators."""
571
+ terms: list[str] = []
572
+ if query.query:
573
+ terms.append(query.query)
574
+ if query.since:
575
+ terms.append(f"after:{_parse_iso(query.since, field='since').strftime('%Y/%m/%d')}")
576
+ if query.until:
577
+ terms.append(f"before:{_parse_iso(query.until, field='until').strftime('%Y/%m/%d')}")
578
+ return " ".join(terms)
579
+
580
+
581
+ def _gmail_header(headers: JsonValue | None, name: str) -> str:
582
+ """The first header value with the given case-insensitive name, or ""."""
583
+ for entry in _dicts(headers):
584
+ if (opt_str(entry.get("name")) or "").lower() == name.lower():
585
+ return opt_str(entry.get("value")) or ""
586
+ return ""
587
+
588
+
589
+ def _find_part_text(part: JsonObject, mime: str) -> str | None:
590
+ """Depth-first search of a message payload tree for the first decodable `mime` part."""
591
+ if opt_str(part.get("mimeType")) == mime:
592
+ body = part.get("body")
593
+ if isinstance(body, dict):
594
+ decoded = _decode_base64url(body.get("data"))
595
+ if decoded:
596
+ return decoded
597
+ for child in _dicts(part.get("parts")):
598
+ found = _find_part_text(child, mime)
599
+ if found is not None:
600
+ return found
601
+ return None
602
+
603
+
604
+ def _decode_base64url(data: JsonValue | None) -> str | None:
605
+ """Decode Gmail's unpadded base64url part data; undecodable data becomes None."""
606
+ if not isinstance(data, str) or not data:
607
+ return None
608
+ padded = data + "=" * (-len(data) % 4)
609
+ try:
610
+ return base64.urlsafe_b64decode(padded).decode("utf-8", errors="replace")
611
+ except ValueError:
612
+ logger.debug("undecodable base64url message part (%d chars)", len(data))
613
+ return None
614
+
615
+
616
+ def _from_epoch_ms(value: JsonValue | None) -> str | None:
617
+ """A Gmail internalDate (milliseconds since the epoch) as ISO-8601, or None."""
618
+ try:
619
+ ms = int(str(value))
620
+ except (TypeError, ValueError):
621
+ return None
622
+ return datetime.fromtimestamp(ms / 1000, tz=UTC).isoformat(timespec="seconds")
623
+
624
+
625
+ register_connector(GoogleCalendarConnector())
626
+ register_connector(GoogleDriveConnector())
627
+ register_connector(GmailConnector())