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/slack.py ADDED
@@ -0,0 +1,555 @@
1
+ """Slack context connector: paste-token auth (default) or BYO OAuth, channel history pulls.
2
+
3
+ The default connect path prompts for a pasted user token (xoxp-...) created by installing a
4
+ workspace app; the caller supplies that user token. When `WMO_SLACK_CLIENT_ID` and
5
+ `WMO_SLACK_CLIENT_SECRET` resolve a complete OAuth app, connect runs the browser loopback flow
6
+ instead (advanced: Slack only accepts HTTPS redirect URLs, so a plain localhost callback needs
7
+ extra setup on the app side). Pulls read one channel's history, fold each thread (parent plus
8
+ replies) into a single item, and normalize everything into `ContextItem`s.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import logging
14
+ import re
15
+ from datetime import UTC, datetime
16
+ from typing import cast
17
+ from urllib.parse import urlsplit
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 OAuthApp, run_loopback_flow
24
+ from wmo.connect.types import (
25
+ ConnectError,
26
+ ConnectorAuth,
27
+ ContextItem,
28
+ ItemKind,
29
+ PullQuery,
30
+ transport_errors,
31
+ )
32
+ from wmo.core.types import JsonObject, JsonValue
33
+
34
+ logger = logging.getLogger(__name__)
35
+
36
+ _API_BASE = "https://slack.com/api"
37
+ _API_HOST = "slack.com"
38
+
39
+ _TIMEOUT_SECONDS = 30.0
40
+
41
+ # Slack's page-size ceiling for cursor-paginated list endpoints.
42
+ _PAGE_SIZE = 200
43
+
44
+ # User scopes the connector needs: read public/private channel history and resolve user names.
45
+ USER_SCOPES = (
46
+ "channels:history",
47
+ "channels:read",
48
+ "groups:history",
49
+ "groups:read",
50
+ "users:read",
51
+ )
52
+
53
+ _TITLE_SNIPPET_CHARS = 60
54
+
55
+ # Slack error codes that mean the credential itself is bad (the 200-with-ok:false 401 analogs).
56
+ _AUTH_ERRORS = frozenset(
57
+ {"invalid_auth", "not_authed", "account_inactive", "token_revoked", "token_expired"}
58
+ )
59
+
60
+ _MENTION_RE = re.compile(r"<@([A-Z0-9]+)(?:\|[^>]*)?>")
61
+
62
+
63
+ class SlackConnector:
64
+ """Pulls Slack channel history (threads folded into single items) as context.
65
+
66
+ Auth paths, in order of preference at connect time:
67
+ 1. Pasted user token (default): the caller supplies a user (xoxp-) token from a
68
+ workspace-internal app (or injects it via `WMO_SLACK_TOKEN`); see
69
+ docs/reference/connect-library.md.
70
+ 2. BYO OAuth app (advanced): when `WMO_SLACK_CLIENT_ID` and `WMO_SLACK_CLIENT_SECRET`
71
+ are both set, connect runs the browser loopback flow requesting `USER_SCOPES` via
72
+ Slack's `user_scope` parameter.
73
+ """
74
+
75
+ name = "slack"
76
+ label = "Slack"
77
+
78
+ def __init__(self, transport: httpx.BaseTransport | None = None) -> None:
79
+ """`transport` is injected into every httpx call; None means the real network."""
80
+ self._transport = transport
81
+ self._users_cache: dict[str, dict[str, str]] = {}
82
+
83
+ # -- auth -----------------------------------------------------------------------------------
84
+
85
+ def connect(self, ui: ConnectUI) -> ConnectorAuth:
86
+ """Run the paste-token flow (default) or the BYO OAuth loopback flow.
87
+
88
+ Either way the credential is validated with `auth.test` and enriched with the human
89
+ identity (`account`) plus the team id/domain (`extra`), which `pull` needs to build
90
+ message permalinks.
91
+
92
+ Raises:
93
+ ConnectError: When no token is entered or Slack rejects the credential.
94
+ """
95
+ app = self._byo_app()
96
+ auth = self._connect_oauth(app, ui) if app is not None else self._connect_paste(ui)
97
+ with self._client() as client:
98
+ identity = self._auth_test(client, auth.access_token)
99
+ account, extra = _identity_fields(identity)
100
+ return auth.model_copy(update={"account": account, "extra": {**auth.extra, **extra}})
101
+
102
+ def verify(self, auth: ConnectorAuth) -> str:
103
+ """Check the credential with `auth.test` and return "user @ team".
104
+
105
+ Raises:
106
+ ConnectError: When Slack rejects the credential (with re-connect guidance) or the
107
+ call is missing a scope (naming the scope).
108
+ """
109
+ with self._client() as client:
110
+ payload = self._auth_test(client, auth.access_token)
111
+ account, _ = _identity_fields(payload)
112
+ return account
113
+
114
+ def _byo_app(self) -> OAuthApp | None:
115
+ """The user's own OAuth app, only when both a client id and secret resolve."""
116
+ try:
117
+ app = get_app(self.name)
118
+ except ConnectError:
119
+ return None
120
+ return app if app.client_secret else None
121
+
122
+ def _connect_paste(self, ui: ConnectUI) -> ConnectorAuth:
123
+ """Prompt for a pasted user token (the default path)."""
124
+ token = ui.prompt_secret(
125
+ "Slack user token (xoxp-...): create and install a workspace app, then paste its "
126
+ "User OAuth Token (see docs/reference/connect-library.md)"
127
+ ).strip()
128
+ if not token:
129
+ raise ConnectError(
130
+ "no Slack token entered; a user OAuth token must be supplied by the caller "
131
+ "(see docs/reference/connect-library.md)"
132
+ )
133
+ return ConnectorAuth(kind="token", access_token=token)
134
+
135
+ def _connect_oauth(self, app: OAuthApp, ui: ConnectUI) -> ConnectorAuth:
136
+ """Run the loopback flow against the user's own Slack OAuth app (advanced).
137
+
138
+ Slack quirks handled here: user scopes travel in the `user_scope` authorize parameter
139
+ (never `scope`), and the token response nests the user token under
140
+ `authed_user.access_token`, which `_UserTokenTransport` hoists into the standard shape
141
+ the shared oauth helpers expect.
142
+ """
143
+ ui.info(
144
+ "Using your Slack OAuth app (WMO_SLACK_CLIENT_ID is set). Slack only accepts "
145
+ "HTTPS redirect URLs, so this browser flow is advanced setup; the pasted-token "
146
+ "path in docs/reference/connect-library.md is the supported default."
147
+ )
148
+ flow_app = app.model_copy(
149
+ update={
150
+ "extra_auth_params": {**app.extra_auth_params, "user_scope": ",".join(USER_SCOPES)}
151
+ }
152
+ )
153
+ transport = _UserTokenTransport(self._transport, token_url=app.token_url)
154
+ return run_loopback_flow(flow_app, scopes=[], open_url=ui.open_url, transport=transport)
155
+
156
+ # -- pull -----------------------------------------------------------------------------------
157
+
158
+ def pull(self, auth: ConnectorAuth, query: PullQuery) -> list[ContextItem]:
159
+ """Pull one channel's history, folding threads into single items.
160
+
161
+ `query.target` names the channel (with or without `#`) or gives its id; `query.since`
162
+ and `query.until` become the history `oldest`/`latest` epoch bounds; `query.limit`
163
+ caps the item count (a thread and its replies count as one item).
164
+
165
+ Raises:
166
+ ConnectError: On a missing/unknown target, an unparseable since/until, a rejected
167
+ credential, a missing scope, or a rate limit.
168
+ """
169
+ if not query.target:
170
+ raise ConnectError(
171
+ "slack pull needs a channel: pass a target like '#general' or a channel id "
172
+ "(e.g. C0123456789)"
173
+ )
174
+ if query.limit <= 0:
175
+ return []
176
+ oldest = _epoch_param(query.since, field="since") if query.since else None
177
+ latest = _epoch_param(query.until, field="until") if query.until else None
178
+ with self._client() as client:
179
+ token = auth.access_token
180
+ channel_id, channel_name = self._resolve_channel(client, token, query.target)
181
+ users = self._user_names(client, token)
182
+ items: list[ContextItem] = []
183
+ cursor: str | None = None
184
+ while len(items) < query.limit:
185
+ params = {
186
+ "channel": channel_id,
187
+ "limit": str(min(_PAGE_SIZE, query.limit - len(items))),
188
+ }
189
+ if oldest:
190
+ params["oldest"] = oldest
191
+ if latest:
192
+ params["latest"] = latest
193
+ if cursor:
194
+ params["cursor"] = cursor
195
+ payload = self._api(client, token, "conversations.history", params)
196
+ for value in _as_list(payload.get("messages")):
197
+ if len(items) >= query.limit:
198
+ break
199
+ message = _as_object(value)
200
+ item = self._normalize_message(
201
+ client, token, auth, channel_id, channel_name, message, users
202
+ )
203
+ if item is not None:
204
+ items.append(item)
205
+ cursor = _next_cursor(payload)
206
+ if not cursor:
207
+ break
208
+ logger.debug("pulled %d slack items from #%s", len(items), channel_name)
209
+ return items
210
+
211
+ def _resolve_channel(self, client: httpx.Client, token: str, target: str) -> tuple[str, str]:
212
+ """Resolve a channel name (with or without `#`) or id to `(id, name)`.
213
+
214
+ Paginates `conversations.list` over public and private channels; a miss raises with a
215
+ sample of the names the token can actually see.
216
+ """
217
+ wanted = target.lstrip("#")
218
+ available: list[str] = []
219
+ total = 0
220
+ cursor: str | None = None
221
+ while True:
222
+ params = {"types": "public_channel,private_channel", "limit": str(_PAGE_SIZE)}
223
+ if cursor:
224
+ params["cursor"] = cursor
225
+ payload = self._api(client, token, "conversations.list", params)
226
+ for value in _as_list(payload.get("channels")):
227
+ channel = _as_object(value)
228
+ channel_id = _as_str(channel.get("id"))
229
+ channel_name = _as_str(channel.get("name"))
230
+ if not channel_id:
231
+ continue
232
+ total += 1
233
+ if channel_id == target or (channel_name and channel_name == wanted):
234
+ return channel_id, channel_name or channel_id
235
+ if channel_name and len(available) < 5:
236
+ available.append(f"#{channel_name}")
237
+ cursor = _next_cursor(payload)
238
+ if not cursor:
239
+ break
240
+ listing = ", ".join(available) if available else "none visible to this token"
241
+ more = f" (and {total - len(available)} more)" if total > len(available) else ""
242
+ raise ConnectError(
243
+ f"no slack channel matching {target!r}; channels visible to this token include: "
244
+ f"{listing}{more}; pass the exact #name or channel id"
245
+ )
246
+
247
+ def _user_names(self, client: httpx.Client, token: str) -> dict[str, str]:
248
+ """User id -> display name for the whole workspace (one pass, cached per token)."""
249
+ cached = self._users_cache.get(token)
250
+ if cached is not None:
251
+ return cached
252
+ names: dict[str, str] = {}
253
+ cursor: str | None = None
254
+ while True:
255
+ params = {"limit": str(_PAGE_SIZE)}
256
+ if cursor:
257
+ params["cursor"] = cursor
258
+ payload = self._api(client, token, "users.list", params)
259
+ for value in _as_list(payload.get("members")):
260
+ member = _as_object(value)
261
+ user_id = _as_str(member.get("id"))
262
+ if not user_id:
263
+ continue
264
+ profile = _as_object(member.get("profile"))
265
+ names[user_id] = (
266
+ _as_str(profile.get("display_name"))
267
+ or _as_str(profile.get("real_name"))
268
+ or _as_str(member.get("real_name"))
269
+ or _as_str(member.get("name"))
270
+ or user_id
271
+ )
272
+ cursor = _next_cursor(payload)
273
+ if not cursor:
274
+ break
275
+ self._users_cache[token] = names
276
+ return names
277
+
278
+ def _normalize_message(
279
+ self,
280
+ client: httpx.Client,
281
+ token: str,
282
+ auth: ConnectorAuth,
283
+ channel_id: str,
284
+ channel_name: str,
285
+ message: JsonObject,
286
+ users: dict[str, str],
287
+ ) -> ContextItem | None:
288
+ """One history message -> a thread item (parent + replies) or a standalone MESSAGE."""
289
+ ts = _as_str(message.get("ts"))
290
+ if not ts:
291
+ return None
292
+ text = _humanize(_as_str(message.get("text")), users)
293
+ title = f"#{channel_name}: {_snippet(text)}"
294
+ url = _permalink(auth.extra, channel_id, ts)
295
+ reply_count = message.get("reply_count")
296
+ if isinstance(reply_count, int) and not isinstance(reply_count, bool) and reply_count > 0:
297
+ lines = self._thread_lines(client, token, channel_id, message, users)
298
+ latest_reply = _as_str(message.get("latest_reply"))
299
+ return ContextItem(
300
+ id=f"{channel_id}:{ts}",
301
+ source=self.name,
302
+ kind=ItemKind.THREAD,
303
+ title=title,
304
+ body="\n".join(lines),
305
+ url=url,
306
+ created_at=_iso(ts),
307
+ updated_at=_iso(latest_reply) if latest_reply else None,
308
+ metadata={"channel": channel_name, "reply_count": reply_count},
309
+ )
310
+ return ContextItem(
311
+ id=f"{channel_id}:{ts}",
312
+ source=self.name,
313
+ kind=ItemKind.MESSAGE,
314
+ title=title,
315
+ body=self._line(message, users),
316
+ url=url,
317
+ created_at=_iso(ts),
318
+ metadata={"channel": channel_name},
319
+ )
320
+
321
+ def _thread_lines(
322
+ self,
323
+ client: httpx.Client,
324
+ token: str,
325
+ channel_id: str,
326
+ parent: JsonObject,
327
+ users: dict[str, str],
328
+ ) -> list[str]:
329
+ """The parent line plus one line per reply (first `_PAGE_SIZE` replies)."""
330
+ parent_ts = _as_str(parent.get("ts"))
331
+ lines = [self._line(parent, users)]
332
+ params = {"channel": channel_id, "ts": parent_ts, "limit": str(_PAGE_SIZE)}
333
+ payload = self._api(client, token, "conversations.replies", params)
334
+ for value in _as_list(payload.get("messages")):
335
+ message = _as_object(value)
336
+ if _as_str(message.get("ts")) == parent_ts:
337
+ continue # the replies listing repeats the parent first
338
+ lines.append(self._line(message, users))
339
+ return lines
340
+
341
+ def _line(self, message: JsonObject, users: dict[str, str]) -> str:
342
+ """Render one message as a "[@name at HH:MM] text" line."""
343
+ ts = _as_str(message.get("ts"))
344
+ stamp = _hhmm(ts) if ts else "??:??"
345
+ user_id = _as_str(message.get("user"))
346
+ name = users.get(user_id) or _as_str(message.get("username")) or user_id or "unknown"
347
+ text = _humanize(_as_str(message.get("text")), users)
348
+ return f"[@{name} at {stamp}] {text}"
349
+
350
+ # -- transport ------------------------------------------------------------------------------
351
+
352
+ def _client(self) -> httpx.Client:
353
+ return httpx.Client(base_url=_API_BASE, timeout=_TIMEOUT_SECONDS, transport=self._transport)
354
+
355
+ def _auth_test(self, client: httpx.Client, token: str) -> JsonObject:
356
+ """POST `auth.test` and return its payload (the cheapest identity/credential check)."""
357
+ with transport_errors(_API_HOST):
358
+ response = client.post("/auth.test", headers=_bearer(token))
359
+ return _payload_or_raise("auth.test", response)
360
+
361
+ def _api(
362
+ self, client: httpx.Client, token: str, method: str, params: dict[str, str]
363
+ ) -> JsonObject:
364
+ """GET one Web API method and return its ok payload."""
365
+ with transport_errors(_API_HOST):
366
+ response = client.get(f"/{method}", params=params, headers=_bearer(token))
367
+ return _payload_or_raise(method, response)
368
+
369
+
370
+ class _UserTokenTransport(httpx.BaseTransport):
371
+ """Hoists Slack's nested user token so the shared oauth helpers see a standard response.
372
+
373
+ Slack's `oauth.v2.access` puts the user token at `authed_user.access_token` (the top level
374
+ carries the bot token, absent for user-scope-only installs), while the generic
375
+ `run_loopback_flow` expects a flat RFC 6749 payload. This transport wraps the real (or
376
+ injected) transport and flattens only the token-endpoint response.
377
+ """
378
+
379
+ def __init__(self, inner: httpx.BaseTransport | None, *, token_url: str) -> None:
380
+ self._inner = inner if inner is not None else httpx.HTTPTransport()
381
+ self._token_url = token_url
382
+
383
+ def handle_request(self, request: httpx.Request) -> httpx.Response:
384
+ response = self._inner.handle_request(request)
385
+ if str(request.url) != self._token_url:
386
+ return response
387
+ response.read()
388
+ try:
389
+ raw = response.json()
390
+ except ValueError:
391
+ return response
392
+ if not isinstance(raw, dict):
393
+ return response
394
+ payload = cast(JsonObject, raw)
395
+ authed_user = payload.get("authed_user")
396
+ if not isinstance(authed_user, dict):
397
+ return response
398
+ token = authed_user.get("access_token")
399
+ if not isinstance(token, str) or not token:
400
+ return response
401
+ flattened: JsonObject = {**payload, "access_token": token}
402
+ for key in ("scope", "refresh_token", "expires_in", "token_type"):
403
+ value = authed_user.get(key)
404
+ if value is not None:
405
+ flattened[key] = value
406
+ return httpx.Response(response.status_code, json=flattened, request=request)
407
+
408
+
409
+ def _bearer(token: str) -> dict[str, str]:
410
+ return {"Authorization": f"Bearer {token}"}
411
+
412
+
413
+ def _payload_or_raise(method: str, response: httpx.Response) -> JsonObject:
414
+ """Parse one Web API response, turning every Slack failure mode into a ConnectError.
415
+
416
+ Slack returns HTTP 200 with `ok: false` plus an error code on most failures, HTTP 429 with
417
+ a Retry-After header on rate limits, and plain 401/403 on rejected transport auth.
418
+ """
419
+ if response.status_code == 429:
420
+ wait = response.headers.get("Retry-After", "60")
421
+ raise ConnectError(
422
+ f"slack rate-limited {method} (HTTP 429): wait {wait}s, then re-run the command; "
423
+ "note commercially distributed non-Marketplace apps are capped at 1 request/min "
424
+ "on history endpoints (a workspace-internal pasted-token app avoids that cap)"
425
+ )
426
+ if response.status_code in (401, 403):
427
+ raise ConnectError(
428
+ f"slack rejected the credential on {method} (HTTP {response.status_code}); "
429
+ "the token is invalid or expired and the connection must be reauthorized"
430
+ )
431
+ if response.status_code != 200:
432
+ raise ConnectError(
433
+ f"slack {method} returned HTTP {response.status_code}: {response.text[:200]}; "
434
+ "retry, and check https://status.slack.com if it persists"
435
+ )
436
+ try:
437
+ raw = response.json()
438
+ except ValueError:
439
+ raw = None
440
+ if not isinstance(raw, dict):
441
+ raise ConnectError(
442
+ f"slack {method} returned a non-JSON body: {response.text[:200]}; "
443
+ "retry, and check https://status.slack.com if it persists"
444
+ )
445
+ payload = cast(JsonObject, raw)
446
+ if payload.get("ok") is not True:
447
+ raise _slack_error(method, payload)
448
+ return payload
449
+
450
+
451
+ def _slack_error(method: str, payload: JsonObject) -> ConnectError:
452
+ """An `ok: false` payload -> a ConnectError with per-code guidance."""
453
+ error = _as_str(payload.get("error")) or "unknown_error"
454
+ if error in _AUTH_ERRORS:
455
+ return ConnectError(
456
+ f"slack rejected the credential on {method} ({error}); "
457
+ "the token is invalid or expired and the connection must be reauthorized"
458
+ )
459
+ if error == "missing_scope":
460
+ needed = _as_str(payload.get("needed")) or "an additional"
461
+ return ConnectError(
462
+ f"slack {method} is missing the {needed!r} scope; add it to the app's user scopes, "
463
+ "re-install the app to the workspace, then supply a token with that scope"
464
+ )
465
+ if error == "not_in_channel":
466
+ return ConnectError(
467
+ f"slack {method} failed: the connected user is not a member of that channel; "
468
+ "join the channel in Slack and re-run the pull"
469
+ )
470
+ return ConnectError(
471
+ f"slack {method} failed: {error}; see https://api.slack.com/methods/{method}#errors"
472
+ )
473
+
474
+
475
+ def _identity_fields(payload: JsonObject) -> tuple[str, JsonObject]:
476
+ """`auth.test` payload -> ("user @ team", extras with the team id and domain)."""
477
+ user = _as_str(payload.get("user")) or "unknown"
478
+ team = _as_str(payload.get("team")) or "unknown team"
479
+ extra: JsonObject = {}
480
+ team_id = _as_str(payload.get("team_id"))
481
+ if team_id:
482
+ extra["team_id"] = team_id
483
+ host = urlsplit(_as_str(payload.get("url"))).hostname
484
+ if host and host.endswith(".slack.com"):
485
+ extra["team_domain"] = host.split(".", 1)[0]
486
+ return f"{user} @ {team}", extra
487
+
488
+
489
+ def _permalink(extra: JsonObject, channel_id: str, ts: str) -> str | None:
490
+ """The archives permalink, when the team domain is known from the stored auth."""
491
+ domain = extra.get("team_domain")
492
+ if not isinstance(domain, str) or not domain:
493
+ return None
494
+ return f"https://{domain}.slack.com/archives/{channel_id}/p{ts.replace('.', '')}"
495
+
496
+
497
+ def _humanize(text: str, users: dict[str, str]) -> str:
498
+ """Replace `<@U...>` mention encodings with the users' display names."""
499
+
500
+ def replace(match: re.Match[str]) -> str:
501
+ name = users.get(match.group(1))
502
+ return f"@{name}" if name else match.group(0)
503
+
504
+ return _MENTION_RE.sub(replace, text)
505
+
506
+
507
+ def _snippet(text: str) -> str:
508
+ """The title snippet: whitespace-flattened first `_TITLE_SNIPPET_CHARS` chars."""
509
+ flattened = " ".join(text.split())
510
+ return flattened[:_TITLE_SNIPPET_CHARS] if flattened else "(no text)"
511
+
512
+
513
+ def _iso(ts: str) -> str:
514
+ """A Slack epoch ts string ("1512085950.000216") as ISO-8601 UTC."""
515
+ return datetime.fromtimestamp(float(ts), UTC).isoformat(timespec="seconds")
516
+
517
+
518
+ def _hhmm(ts: str) -> str:
519
+ """A Slack epoch ts string as an HH:MM (UTC) stamp for message lines."""
520
+ return datetime.fromtimestamp(float(ts), UTC).strftime("%H:%M")
521
+
522
+
523
+ def _epoch_param(value: str, *, field: str) -> str:
524
+ """An ISO-8601 date/datetime -> the epoch-seconds string Slack's history bounds expect."""
525
+ try:
526
+ parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
527
+ except ValueError as exc:
528
+ raise ConnectError(
529
+ f"could not parse {field}={value!r} as an ISO-8601 date or datetime; "
530
+ "use e.g. 2026-07-01 or 2026-07-01T12:00:00Z"
531
+ ) from exc
532
+ if parsed.tzinfo is None:
533
+ parsed = parsed.replace(tzinfo=UTC)
534
+ return f"{parsed.timestamp():.6f}"
535
+
536
+
537
+ def _next_cursor(payload: JsonObject) -> str | None:
538
+ """The `response_metadata.next_cursor` of a paginated response ("" and absent -> None)."""
539
+ cursor = _as_str(_as_object(payload.get("response_metadata")).get("next_cursor"))
540
+ return cursor or None
541
+
542
+
543
+ def _as_object(value: JsonValue | None) -> JsonObject:
544
+ return value if isinstance(value, dict) else {}
545
+
546
+
547
+ def _as_list(value: JsonValue | None) -> list[JsonValue]:
548
+ return value if isinstance(value, list) else []
549
+
550
+
551
+ def _as_str(value: JsonValue | None) -> str:
552
+ return value if isinstance(value, str) else ""
553
+
554
+
555
+ register_connector(SlackConnector())