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
@@ -0,0 +1,79 @@
1
+ """ContextConnector protocol + a small registry (mirrors `wmo.ingest.adapter`).
2
+
3
+ A connector owns one service end to end: the interactive auth flow, a cheap credential check,
4
+ and pulling content normalized into `ContextItem`s. Connectors register themselves on import and
5
+ are looked up by name (`get_connector`) or listed (`list_connectors`).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Callable
11
+ from dataclasses import dataclass
12
+ from typing import Protocol, runtime_checkable
13
+
14
+ from wmo.connect.types import ConnectorAuth, ContextItem, PullQuery
15
+
16
+
17
+ @dataclass
18
+ class ConnectUI:
19
+ """Presentation callbacks a connector uses during interactive auth.
20
+
21
+ Connectors never print: the CLI layer builds one of these over its rich Console; tests pass
22
+ recording lambdas.
23
+
24
+ Attributes:
25
+ open_url: Show and/or open the browser authorization URL.
26
+ present_code: Show a device-flow verification URI and user code.
27
+ prompt_secret: Ask the user for a secret (e.g. a pasted API token); returns the value.
28
+ info: Show a short status message.
29
+ """
30
+
31
+ open_url: Callable[[str], None]
32
+ present_code: Callable[[str, str], None]
33
+ prompt_secret: Callable[[str], str]
34
+ info: Callable[[str], None]
35
+
36
+
37
+ @runtime_checkable
38
+ class ContextConnector(Protocol):
39
+ """One service's auth + pull logic, producing normalized `ContextItem`s.
40
+
41
+ Attributes:
42
+ name: Registry key and `ContextItem.source` value (e.g. "github").
43
+ label: Human name shown in pickers (e.g. "GitHub").
44
+ """
45
+
46
+ name: str
47
+ label: str
48
+
49
+ def connect(self, ui: ConnectUI) -> ConnectorAuth:
50
+ """Run the interactive auth flow (OAuth or token prompt) and return the credential."""
51
+ ...
52
+
53
+ def verify(self, auth: ConnectorAuth) -> str:
54
+ """Cheap API call returning a human identity string; raises ConnectError on bad auth."""
55
+ ...
56
+
57
+ def pull(self, auth: ConnectorAuth, query: PullQuery) -> list[ContextItem]:
58
+ """Fetch content matching `query`, normalized and capped at `query.limit` items."""
59
+ ...
60
+
61
+
62
+ _CONNECTORS: dict[str, ContextConnector] = {}
63
+
64
+
65
+ def register_connector(connector: ContextConnector) -> None:
66
+ """Register a connector under its `name` (typically at module import time)."""
67
+ _CONNECTORS[connector.name] = connector
68
+
69
+
70
+ def get_connector(name: str) -> ContextConnector:
71
+ """Look up a registered connector by name."""
72
+ if name not in _CONNECTORS:
73
+ raise ValueError(f"no context connector registered for {name!r}; have {list(_CONNECTORS)}")
74
+ return _CONNECTORS[name]
75
+
76
+
77
+ def list_connectors() -> list[str]:
78
+ """Names of all registered connectors, sorted (what the CLI picker shows)."""
79
+ return sorted(_CONNECTORS)
@@ -0,0 +1,164 @@
1
+ """Connector credentials, stored once per user.
2
+
3
+ Like the platform credential (`wmo/platform/credentials.py`), connector tokens are user-global:
4
+ `~/.wmo/connectors.toml` (directory overridable via `$WMO_HOME`, exact file overridable via
5
+ `$WMO_CONNECTORS_PATH`), one TOML table per connector name. A `WMO_<NAME>_TOKEN` environment
6
+ variable injects a token-kind credential without a file, so CI and headless runs never write one
7
+ to disk; a few connectors additionally honor a service-native variable (`ENV_TOKEN_ALIASES`)
8
+ that deployments already carry.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ import tempfile
15
+ import tomllib
16
+ from pathlib import Path
17
+
18
+ import tomli_w
19
+ from pydantic import ValidationError
20
+
21
+ from wmo.connect.types import ConnectError, ConnectorAuth
22
+ from wmo.core.types import JsonObject
23
+ from wmo.platform.credentials import wmo_home
24
+
25
+ ENV_CONNECTORS_PATH = "WMO_CONNECTORS_PATH"
26
+
27
+ CONNECTORS_FILENAME = "connectors.toml"
28
+
29
+
30
+ def connectors_path() -> Path:
31
+ """Where the connector credential file lives (`$WMO_CONNECTORS_PATH` wins over `$WMO_HOME`)."""
32
+ override = os.environ.get(ENV_CONNECTORS_PATH)
33
+ return Path(override) if override else wmo_home() / CONNECTORS_FILENAME
34
+
35
+
36
+ def token_env_var(name: str) -> str:
37
+ """The env var that injects a plain token for connector `name`: WMO_<NAME>_TOKEN."""
38
+ return f"WMO_{name.upper().replace('-', '_')}_TOKEN"
39
+
40
+
41
+ # Service-native env vars accepted as token sources per connector, consulted after the generic
42
+ # WMO_<NAME>_TOKEN override. BRAVE_SEARCH_API_KEY is the key the grounding engine already uses
43
+ # (wmo/engine/grounding.py) and deployments already carry, so the brave connector honors it too.
44
+ ENV_TOKEN_ALIASES: dict[str, tuple[str, ...]] = {"brave": ("BRAVE_SEARCH_API_KEY",)}
45
+
46
+
47
+ def token_env_vars(name: str) -> list[str]:
48
+ """Every env var that can inject a token for connector `name`, in precedence order."""
49
+ return [token_env_var(name), *ENV_TOKEN_ALIASES.get(name, ())]
50
+
51
+
52
+ def resolve_env_token(name: str) -> tuple[str, str] | None:
53
+ """The first set env token for connector `name` as (var, token), or None when none is set.
54
+
55
+ Set-but-empty vars are treated as unset.
56
+ """
57
+ for var in token_env_vars(name):
58
+ token = os.environ.get(var)
59
+ if token:
60
+ return var, token
61
+ return None
62
+
63
+
64
+ def load_connector_auth(name: str) -> ConnectorAuth | None:
65
+ """Load the stored credential for one connector, or None when not connected.
66
+
67
+ A non-empty env token (`WMO_<NAME>_TOKEN`, then any `ENV_TOKEN_ALIASES` entry) takes
68
+ precedence over the file and yields a token-kind auth (a set-but-empty var is treated as
69
+ unset).
70
+ """
71
+ resolved = resolve_env_token(name)
72
+ if resolved is not None:
73
+ return ConnectorAuth(kind="token", access_token=resolved[1])
74
+ section = _read_sections().get(name)
75
+ if section is None:
76
+ return None
77
+ return _validate_section(name, section)
78
+
79
+
80
+ def save_connector_auth(name: str, auth: ConnectorAuth) -> Path:
81
+ """Persist one connector's credential, keeping every other connector's table intact.
82
+
83
+ Mirrors the platform credential writer: refuses symlinks, writes through a 0600 mkstemp,
84
+ and swaps into place atomically.
85
+
86
+ Raises:
87
+ ValueError: If the target path is a symlink.
88
+ """
89
+ sections = _read_sections()
90
+ sections[name] = {
91
+ key: value for key, value in auth.model_dump(mode="json").items() if value is not None
92
+ }
93
+ return _write_sections(sections)
94
+
95
+
96
+ def delete_connector_auth(name: str) -> bool:
97
+ """Remove one connector's credential; returns whether one existed.
98
+
99
+ Deleting the last entry removes the file entirely.
100
+ """
101
+ sections = _read_sections()
102
+ if name not in sections:
103
+ return False
104
+ del sections[name]
105
+ if sections:
106
+ _write_sections(sections)
107
+ else:
108
+ connectors_path().unlink()
109
+ return True
110
+
111
+
112
+ def list_connected() -> dict[str, ConnectorAuth]:
113
+ """Every connector with a stored credential, by name (sorted).
114
+
115
+ Only reads the file: connections injected purely via `WMO_<NAME>_TOKEN` env vars are not
116
+ enumerable and do not appear here (per-name lookups still see them via
117
+ `load_connector_auth`).
118
+ """
119
+ return {
120
+ name: _validate_section(name, section) for name, section in sorted(_read_sections().items())
121
+ }
122
+
123
+
124
+ def _read_sections() -> dict[str, JsonObject]:
125
+ """All connector tables in the credential file (empty when the file is absent)."""
126
+ path = connectors_path()
127
+ if not path.exists():
128
+ return {}
129
+ document = tomllib.loads(path.read_text(encoding="utf-8"))
130
+ return {name: section for name, section in document.items() if isinstance(section, dict)}
131
+
132
+
133
+ def _validate_section(name: str, section: JsonObject) -> ConnectorAuth:
134
+ """Parse one connector table, turning pydantic errors into an actionable ConnectError."""
135
+ try:
136
+ return ConnectorAuth.model_validate(section)
137
+ except ValidationError as exc:
138
+ raise ConnectError(
139
+ f"invalid [{name}] entry in {connectors_path()}: {exc}; "
140
+ f"the stored credential for {name} is malformed; provide a valid token or "
141
+ "reauthorize the connection"
142
+ ) from exc
143
+
144
+
145
+ def _write_sections(sections: dict[str, JsonObject]) -> Path:
146
+ """Atomically rewrite the credential file with owner-only permissions."""
147
+ path = connectors_path()
148
+ if path.is_symlink():
149
+ msg = (
150
+ f"refusing to write connector credentials through the symlink {path}; "
151
+ f"remove the link or point ${ENV_CONNECTORS_PATH} elsewhere"
152
+ )
153
+ raise ValueError(msg)
154
+ path.parent.mkdir(parents=True, exist_ok=True)
155
+ # mkstemp creates the file 0600: that IS the owner-only mechanism.
156
+ fd, tmp_name = tempfile.mkstemp(dir=path.parent, prefix=f"{path.name}.")
157
+ try:
158
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
159
+ fh.write(tomli_w.dumps(sections))
160
+ os.replace(tmp_name, path)
161
+ except BaseException:
162
+ os.unlink(tmp_name)
163
+ raise
164
+ return path
wmo/connect/github.py ADDED
@@ -0,0 +1,321 @@
1
+ """GitHub context connector: device-flow OAuth, then repo issues, pull requests, and the README.
2
+
3
+ `connect` runs the RFC 8628 device flow against the shared "github" OAuth app (client id only,
4
+ no secret needed), `verify` resolves the token to its login via `GET /user`, and `pull` fetches
5
+ one repository's issues and pull requests (the combined listing, newest first) plus its README,
6
+ normalized into `ContextItem`s. A `PullQuery.query` switches the listing to the GitHub search
7
+ API; `PullQuery.since` maps to the listing's `since` parameter (or an `updated:>=` qualifier in
8
+ search).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import base64
14
+ import logging
15
+ import re
16
+ from datetime import UTC, datetime
17
+ from typing import cast
18
+
19
+ import httpx
20
+
21
+ from wmo.connect.apps import get_app
22
+ from wmo.connect.connector import ConnectUI, register_connector
23
+ from wmo.connect.oauth import run_device_flow
24
+ from wmo.connect.types import (
25
+ ConnectError,
26
+ ConnectorAuth,
27
+ ContextItem,
28
+ ItemKind,
29
+ PullQuery,
30
+ opt_str,
31
+ transport_errors,
32
+ )
33
+ from wmo.core.types import JsonObject, JsonValue
34
+
35
+ logger = logging.getLogger(__name__)
36
+
37
+ _API_BASE = "https://api.github.com"
38
+ _API_HOST = "api.github.com"
39
+ _API_VERSION = "2022-11-28"
40
+ _PER_PAGE = 100
41
+ _TIMEOUT_SECONDS = 30.0
42
+ _SOURCE = "github"
43
+
44
+ _TARGET_RE = re.compile(r"^([^/\s]+)/([^/\s]+)$")
45
+
46
+ # GitHub App tokens only reach repositories where the app is installed; users pick those
47
+ # repos on their app's installation page after connecting (a PAT needs no installation).
48
+ _INSTALL_HINT = (
49
+ "GitHub App tokens only reach repositories the app is installed on: install yours on the "
50
+ "target repos from https://github.com/settings/apps (your app > Install App)"
51
+ )
52
+
53
+
54
+ def _parse_target(target: str | None) -> tuple[str, str]:
55
+ """Split an "owner/repo" pull target, raising an actionable error on anything else."""
56
+ match = _TARGET_RE.match(target.strip()) if target else None
57
+ if match is None:
58
+ raise ConnectError(
59
+ f"github pull needs a repository target like 'owner/repo' (got {target!r}); "
60
+ "pass --target <owner>/<repo>"
61
+ )
62
+ return match.group(1), match.group(2)
63
+
64
+
65
+ def _format_reset(reset: str) -> str:
66
+ """A rate-limit reset epoch as ISO-8601 UTC (falls back to the raw header value)."""
67
+ try:
68
+ moment = datetime.fromtimestamp(int(reset), tz=UTC)
69
+ except (OSError, OverflowError, ValueError):
70
+ return reset
71
+ return moment.isoformat(timespec="seconds")
72
+
73
+
74
+ def _raise_for_response(response: httpx.Response, *, doing: str, repo: str | None = None) -> None:
75
+ """Turn GitHub error statuses into ConnectErrors that say what to do next."""
76
+ status = response.status_code
77
+ if status < 400:
78
+ return
79
+ if status == 401:
80
+ raise ConnectError(
81
+ f"github rejected the stored credential during {doing} (HTTP 401); "
82
+ "the token is invalid or expired and the connection must be reauthorized"
83
+ )
84
+ if status == 403 and response.headers.get("X-RateLimit-Remaining") == "0":
85
+ reset = response.headers.get("X-RateLimit-Reset", "unknown")
86
+ raise ConnectError(
87
+ f"github rate limit exceeded during {doing}; it resets at {_format_reset(reset)} "
88
+ f"(X-RateLimit-Reset {reset}); wait for the reset or connect a higher-limit account"
89
+ )
90
+ if status == 404 and repo is not None:
91
+ raise ConnectError(
92
+ f"github repository {repo!r} was not found during {doing} (HTTP 404): it does not "
93
+ "exist, the connected account cannot see it, or (GitHub App auth) the app is not "
94
+ "installed on it; check the owner/repo spelling, install your app on the repo "
95
+ "(https://github.com/settings/apps > your app > Install App), or reauthorize the "
96
+ "connection with an account that has access"
97
+ )
98
+ raise ConnectError(
99
+ f"github {doing} failed (HTTP {status}): {response.text[:200]}; "
100
+ "check the request (token scopes, target, query) and retry"
101
+ )
102
+
103
+
104
+ def _json_object(response: httpx.Response) -> JsonObject:
105
+ """The response body as a JSON object ({} when it is anything else)."""
106
+ try:
107
+ raw = response.json()
108
+ except ValueError:
109
+ return {}
110
+ return cast(JsonObject, raw) if isinstance(raw, dict) else {}
111
+
112
+
113
+ def _page_rows(response: httpx.Response, *, nested: bool) -> list[JsonObject]:
114
+ """One page of issue rows: the raw JSON list, or the search wrapper's `items` list."""
115
+ try:
116
+ raw = response.json()
117
+ except ValueError:
118
+ return []
119
+ rows = raw.get("items") if nested and isinstance(raw, dict) else raw
120
+ if not isinstance(rows, list):
121
+ return []
122
+ return [cast(JsonObject, row) for row in rows if isinstance(row, dict)]
123
+
124
+
125
+ def _paginate(
126
+ client: httpx.Client,
127
+ url: str,
128
+ params: dict[str, str],
129
+ *,
130
+ limit: int,
131
+ repo: str,
132
+ doing: str,
133
+ nested: bool,
134
+ ) -> list[JsonObject]:
135
+ """Follow `Link: rel="next"` pagination, stopping as soon as `limit` rows are in hand."""
136
+ rows: list[JsonObject] = []
137
+ next_url: str | None = url
138
+ next_params: dict[str, str] | None = params
139
+ while next_url is not None and len(rows) < limit:
140
+ response = client.get(next_url, params=next_params)
141
+ _raise_for_response(response, doing=doing, repo=repo)
142
+ page = _page_rows(response, nested=nested)
143
+ rows.extend(page)
144
+ next_url = response.links.get("next", {}).get("url")
145
+ next_params = None # the next URL already carries its query string
146
+ if not page and next_url:
147
+ break # an empty page that still advertises a next link would loop forever
148
+ return rows[:limit]
149
+
150
+
151
+ def _issue_item(row: JsonObject, *, owner: str, repo: str) -> ContextItem:
152
+ """Normalize one combined-listing (or search) row into an issue or pull-request item."""
153
+ number = row.get("number")
154
+ suffix = str(number) if isinstance(number, int) else str(row.get("id") or "unknown")
155
+ title = opt_str(row.get("title")) or "(untitled)"
156
+ body = row.get("body")
157
+ user = row.get("user")
158
+ author = opt_str(user.get("login")) if isinstance(user, dict) else None
159
+ labels_value = row.get("labels")
160
+ labels: list[JsonValue] = []
161
+ if isinstance(labels_value, list):
162
+ labels = [
163
+ label["name"]
164
+ for label in labels_value
165
+ if isinstance(label, dict) and isinstance(label.get("name"), str)
166
+ ]
167
+ comments = row.get("comments")
168
+ return ContextItem(
169
+ id=f"{owner}/{repo}#{suffix}",
170
+ source=_SOURCE,
171
+ kind=ItemKind.PULL_REQUEST if "pull_request" in row else ItemKind.ISSUE,
172
+ title=f"#{suffix} {title}",
173
+ body=body if isinstance(body, str) else "",
174
+ url=opt_str(row.get("html_url")),
175
+ created_at=opt_str(row.get("created_at")),
176
+ updated_at=opt_str(row.get("updated_at")),
177
+ metadata={
178
+ "state": opt_str(row.get("state")),
179
+ "labels": labels,
180
+ "author": author,
181
+ "comments": comments if isinstance(comments, int) else 0,
182
+ },
183
+ )
184
+
185
+
186
+ class GitHubConnector:
187
+ """GitHub connector: device-flow OAuth plus repository issue/PR/README pulls.
188
+
189
+ Args:
190
+ transport: Injected httpx transport threaded into every HTTP call (tests pass
191
+ `httpx.MockTransport`); None means the real network.
192
+ """
193
+
194
+ name = _SOURCE
195
+ label = "GitHub"
196
+
197
+ def __init__(self, *, transport: httpx.BaseTransport | None = None) -> None:
198
+ self._transport = transport
199
+
200
+ def connect(self, ui: ConnectUI) -> ConnectorAuth:
201
+ """Authorize via the RFC 8628 device flow and stamp the credential with its identity."""
202
+ app = get_app(self.name)
203
+ auth = run_device_flow(
204
+ app, scopes=app.scopes, present=ui.present_code, transport=self._transport
205
+ )
206
+ account = self.verify(auth)
207
+ ui.info(f"connected to GitHub as {account}")
208
+ ui.info(_INSTALL_HINT)
209
+ return auth.model_copy(update={"account": account})
210
+
211
+ def verify(self, auth: ConnectorAuth) -> str:
212
+ """Resolve the credential to its GitHub identity via the cheapest call, `GET /user`."""
213
+ with self._client(auth) as client, transport_errors(_API_HOST):
214
+ response = client.get("/user")
215
+ _raise_for_response(response, doing="the GitHub identity check")
216
+ payload = _json_object(response)
217
+ login = opt_str(payload.get("login"))
218
+ if login is None:
219
+ raise ConnectError(
220
+ "github's /user endpoint returned no login for the stored credential; "
221
+ "the token is invalid or expired and the connection must be reauthorized"
222
+ )
223
+ name = opt_str(payload.get("name"))
224
+ return f"{login} ({name})" if name else login
225
+
226
+ def pull(self, auth: ConnectorAuth, query: PullQuery) -> list[ContextItem]:
227
+ """Fetch a repository's issues, pull requests, and README, newest first, up to the limit.
228
+
229
+ `query.target` must be "owner/repo". Issues and pull requests come from the combined
230
+ `GET /repos/{owner}/{repo}/issues` listing (sorted by update time, descending, honoring
231
+ `query.since`); when `query.query` is set, the GitHub search API replaces that listing.
232
+ The README is appended as one DOCUMENT item when the limit leaves room for it.
233
+ """
234
+ owner, repo = _parse_target(query.target)
235
+ with self._client(auth) as client, transport_errors(_API_HOST):
236
+ rows = self._issue_rows(client, owner, repo, query)
237
+ items = [_issue_item(row, owner=owner, repo=repo) for row in rows]
238
+ if len(items) < query.limit:
239
+ readme = self._readme_item(client, owner, repo)
240
+ if readme is not None:
241
+ items.append(readme)
242
+ logger.debug("pulled %d github items from %s/%s", len(items), owner, repo)
243
+ return items
244
+
245
+ def _client(self, auth: ConnectorAuth) -> httpx.Client:
246
+ """An API client carrying the credential and GitHub's versioned-media headers."""
247
+ return httpx.Client(
248
+ base_url=_API_BASE,
249
+ headers={
250
+ "Authorization": f"Bearer {auth.access_token}",
251
+ "Accept": "application/vnd.github+json",
252
+ "X-GitHub-Api-Version": _API_VERSION,
253
+ },
254
+ timeout=_TIMEOUT_SECONDS,
255
+ transport=self._transport,
256
+ )
257
+
258
+ def _issue_rows(
259
+ self, client: httpx.Client, owner: str, repo: str, query: PullQuery
260
+ ) -> list[JsonObject]:
261
+ """Issue/PR rows, newest first: the combined listing, or search when a query is set."""
262
+ target = f"{owner}/{repo}"
263
+ per_page = str(min(query.limit, _PER_PAGE))
264
+ if query.query:
265
+ q = f"repo:{target} {query.query}"
266
+ if query.since:
267
+ q += f" updated:>={query.since}"
268
+ if query.until:
269
+ q += f" updated:<={query.until}"
270
+ params = {"q": q, "sort": "updated", "order": "desc", "per_page": per_page}
271
+ return _paginate(
272
+ client,
273
+ "/search/issues",
274
+ params,
275
+ limit=query.limit,
276
+ repo=target,
277
+ doing="the GitHub issue search",
278
+ nested=True,
279
+ )
280
+ params = {"state": "all", "sort": "updated", "direction": "desc", "per_page": per_page}
281
+ if query.since:
282
+ params["since"] = query.since
283
+ return _paginate(
284
+ client,
285
+ f"/repos/{owner}/{repo}/issues",
286
+ params,
287
+ limit=query.limit,
288
+ repo=target,
289
+ doing="the GitHub issue listing",
290
+ nested=False,
291
+ )
292
+
293
+ def _readme_item(self, client: httpx.Client, owner: str, repo: str) -> ContextItem | None:
294
+ """The repo README as one DOCUMENT item, or None when the repo has no README (404)."""
295
+ response = client.get(f"/repos/{owner}/{repo}/readme")
296
+ if response.status_code == 404:
297
+ logger.debug("no README in %s/%s; skipping", owner, repo)
298
+ return None
299
+ _raise_for_response(response, doing="the GitHub README fetch", repo=f"{owner}/{repo}")
300
+ payload = _json_object(response)
301
+ content = payload.get("content")
302
+ body = ""
303
+ if isinstance(content, str) and content:
304
+ try:
305
+ body = base64.b64decode(content).decode("utf-8", errors="replace")
306
+ except ValueError:
307
+ logger.debug("undecodable README content in %s/%s; empty body", owner, repo)
308
+ name = opt_str(payload.get("name")) or "README"
309
+ path = opt_str(payload.get("path")) or name
310
+ return ContextItem(
311
+ id=f"{owner}/{repo}:{path}",
312
+ source=_SOURCE,
313
+ kind=ItemKind.DOCUMENT,
314
+ title=name,
315
+ body=body,
316
+ url=opt_str(payload.get("html_url")),
317
+ metadata={"path": path},
318
+ )
319
+
320
+
321
+ register_connector(GitHubConnector())