@trac3er/oh-my-god 1.0.2

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 (229) hide show
  1. package/.claude-plugin/marketplace.json +36 -0
  2. package/.claude-plugin/plugin.json +23 -0
  3. package/.claude-plugin/scripts/install.sh +49 -0
  4. package/.claude-plugin/scripts/uninstall.sh +80 -0
  5. package/.claude-plugin/scripts/update.sh +84 -0
  6. package/.mcp.json +20 -0
  7. package/LICENSE +21 -0
  8. package/OMG-setup.sh +1093 -0
  9. package/README.md +335 -0
  10. package/THIRD_PARTY_NOTICES.md +24 -0
  11. package/UPSTREAM_DIFF.md +20 -0
  12. package/agents/__init__.py +1 -0
  13. package/agents/_model_roles.yaml +26 -0
  14. package/agents/designer.md +67 -0
  15. package/agents/explore.md +60 -0
  16. package/agents/model_roles.py +196 -0
  17. package/agents/omg-api-builder.md +23 -0
  18. package/agents/omg-architect-mode.md +43 -0
  19. package/agents/omg-architect.md +13 -0
  20. package/agents/omg-backend-engineer.md +43 -0
  21. package/agents/omg-critic.md +16 -0
  22. package/agents/omg-database-engineer.md +43 -0
  23. package/agents/omg-escalation-router.md +17 -0
  24. package/agents/omg-executor.md +12 -0
  25. package/agents/omg-frontend-designer.md +42 -0
  26. package/agents/omg-implement-mode.md +50 -0
  27. package/agents/omg-infra-engineer.md +43 -0
  28. package/agents/omg-qa-tester.md +16 -0
  29. package/agents/omg-research-mode.md +43 -0
  30. package/agents/omg-security-auditor.md +43 -0
  31. package/agents/omg-testing-engineer.md +43 -0
  32. package/agents/plan.md +80 -0
  33. package/agents/quick_task.md +64 -0
  34. package/agents/reviewer.md +83 -0
  35. package/agents/task.md +71 -0
  36. package/commands/OMG:ccg.md +22 -0
  37. package/commands/OMG:compat.md +57 -0
  38. package/commands/OMG:crazy.md +125 -0
  39. package/commands/OMG:domain-init.md +11 -0
  40. package/commands/OMG:escalate.md +52 -0
  41. package/commands/OMG:health-check.md +45 -0
  42. package/commands/OMG:init.md +134 -0
  43. package/commands/OMG:mode.md +44 -0
  44. package/commands/OMG:project-init.md +11 -0
  45. package/commands/OMG:ralph-start.md +43 -0
  46. package/commands/OMG:ralph-stop.md +23 -0
  47. package/commands/OMG:teams.md +39 -0
  48. package/commands/ai-commit.md +113 -0
  49. package/commands/ccg.md +9 -0
  50. package/commands/create-agent.md +183 -0
  51. package/commands/omc-teams.md +9 -0
  52. package/commands/session-branch.md +85 -0
  53. package/commands/session-fork.md +53 -0
  54. package/commands/session-merge.md +134 -0
  55. package/commands/theme.md +44 -0
  56. package/config/lsp_languages.yaml +324 -0
  57. package/config/themes/catppuccin-frappe.yaml +14 -0
  58. package/config/themes/catppuccin-latte.yaml +14 -0
  59. package/config/themes/catppuccin-macchiato.yaml +14 -0
  60. package/config/themes/catppuccin-mocha.yaml +14 -0
  61. package/config/themes/dracula.yaml +14 -0
  62. package/config/themes/gruvbox-dark.yaml +14 -0
  63. package/config/themes/nord.yaml +14 -0
  64. package/config/themes/one-dark.yaml +14 -0
  65. package/config/themes/solarized-dark.yaml +14 -0
  66. package/config/themes/tokyo-night.yaml +14 -0
  67. package/control_plane/__init__.py +2 -0
  68. package/control_plane/openapi.yaml +109 -0
  69. package/control_plane/server.py +107 -0
  70. package/control_plane/service.py +148 -0
  71. package/crates/omg-natives/Cargo.toml +17 -0
  72. package/crates/omg-natives/src/clipboard.rs +5 -0
  73. package/crates/omg-natives/src/glob.rs +15 -0
  74. package/crates/omg-natives/src/grep.rs +15 -0
  75. package/crates/omg-natives/src/highlight.rs +15 -0
  76. package/crates/omg-natives/src/html.rs +14 -0
  77. package/crates/omg-natives/src/image.rs +5 -0
  78. package/crates/omg-natives/src/keys.rs +5 -0
  79. package/crates/omg-natives/src/lib.rs +36 -0
  80. package/crates/omg-natives/src/prof.rs +5 -0
  81. package/crates/omg-natives/src/ps.rs +5 -0
  82. package/crates/omg-natives/src/shell.rs +5 -0
  83. package/crates/omg-natives/src/task.rs +5 -0
  84. package/crates/omg-natives/src/text.rs +14 -0
  85. package/hooks/_agent_registry.py +421 -0
  86. package/hooks/_budget.py +31 -0
  87. package/hooks/_common.py +476 -0
  88. package/hooks/_learnings.py +126 -0
  89. package/hooks/_memory.py +103 -0
  90. package/hooks/circuit-breaker.py +270 -0
  91. package/hooks/config-guard.py +163 -0
  92. package/hooks/context_pressure.py +53 -0
  93. package/hooks/credential_store.py +801 -0
  94. package/hooks/fetch-rate-limits.py +212 -0
  95. package/hooks/firewall.py +48 -0
  96. package/hooks/hashline-formatter-bridge.py +224 -0
  97. package/hooks/hashline-injector.py +273 -0
  98. package/hooks/hashline-validator.py +216 -0
  99. package/hooks/idle-detector.py +95 -0
  100. package/hooks/intentgate-keyword-detector.py +188 -0
  101. package/hooks/magic-keyword-router.py +195 -0
  102. package/hooks/policy_engine.py +310 -0
  103. package/hooks/post-tool-failure.py +19 -0
  104. package/hooks/post-write.py +199 -0
  105. package/hooks/pre-compact.py +204 -0
  106. package/hooks/pre-tool-inject.py +98 -0
  107. package/hooks/prompt-enhancer.py +672 -0
  108. package/hooks/quality-runner.py +191 -0
  109. package/hooks/secret-guard.py +47 -0
  110. package/hooks/session-end-capture.py +137 -0
  111. package/hooks/session-start.py +275 -0
  112. package/hooks/shadow_manager.py +297 -0
  113. package/hooks/state_migration.py +209 -0
  114. package/hooks/stop-gate.py +7 -0
  115. package/hooks/stop_dispatcher.py +929 -0
  116. package/hooks/test-validator.py +138 -0
  117. package/hooks/todo-state-tracker.py +114 -0
  118. package/hooks/tool-ledger.py +126 -0
  119. package/hooks/trust_review.py +524 -0
  120. package/install.sh +9 -0
  121. package/omg_natives/__init__.py +186 -0
  122. package/omg_natives/_bindings.py +165 -0
  123. package/omg_natives/clipboard.py +36 -0
  124. package/omg_natives/glob.py +42 -0
  125. package/omg_natives/grep.py +61 -0
  126. package/omg_natives/highlight.py +54 -0
  127. package/omg_natives/html.py +157 -0
  128. package/omg_natives/image.py +51 -0
  129. package/omg_natives/keys.py +46 -0
  130. package/omg_natives/prof.py +39 -0
  131. package/omg_natives/ps.py +93 -0
  132. package/omg_natives/shell.py +58 -0
  133. package/omg_natives/task.py +41 -0
  134. package/omg_natives/text.py +50 -0
  135. package/package.json +26 -0
  136. package/plugins/README.md +82 -0
  137. package/plugins/advanced/commands/OMG:code-review.md +114 -0
  138. package/plugins/advanced/commands/OMG:deep-plan.md +221 -0
  139. package/plugins/advanced/commands/OMG:handoff.md +115 -0
  140. package/plugins/advanced/commands/OMG:learn.md +110 -0
  141. package/plugins/advanced/commands/OMG:maintainer.md +31 -0
  142. package/plugins/advanced/commands/OMG:ralph-start.md +43 -0
  143. package/plugins/advanced/commands/OMG:ralph-stop.md +23 -0
  144. package/plugins/advanced/commands/OMG:security-review.md +119 -0
  145. package/plugins/advanced/commands/OMG:sequential-thinking.md +20 -0
  146. package/plugins/advanced/commands/OMG:ship.md +46 -0
  147. package/plugins/advanced/plugin.json +96 -0
  148. package/plugins/core/plugin.json +82 -0
  149. package/pytest.ini +5 -0
  150. package/registry/__init__.py +1 -0
  151. package/registry/verify_artifact.py +90 -0
  152. package/rules/contextual/architect-mode.md +9 -0
  153. package/rules/contextual/big-picture.md +20 -0
  154. package/rules/contextual/code-hygiene.md +26 -0
  155. package/rules/contextual/context-management.md +19 -0
  156. package/rules/contextual/context-minimization.md +32 -0
  157. package/rules/contextual/ddd-sdd.md +28 -0
  158. package/rules/contextual/dependency-safety.md +16 -0
  159. package/rules/contextual/doc-check.md +13 -0
  160. package/rules/contextual/implement-mode.md +9 -0
  161. package/rules/contextual/infra-safety.md +14 -0
  162. package/rules/contextual/outside-in.md +13 -0
  163. package/rules/contextual/persistent-mode.md +24 -0
  164. package/rules/contextual/research-mode.md +9 -0
  165. package/rules/contextual/security-domains.md +25 -0
  166. package/rules/contextual/vision-detection.md +27 -0
  167. package/rules/contextual/web-search.md +25 -0
  168. package/rules/contextual/write-verify.md +23 -0
  169. package/rules/core/00-truth.md +20 -0
  170. package/rules/core/01-surgical.md +19 -0
  171. package/rules/core/02-circuit-breaker.md +22 -0
  172. package/rules/core/03-ensemble.md +28 -0
  173. package/rules/core/04-testing.md +30 -0
  174. package/runtime/__init__.py +32 -0
  175. package/runtime/adapters/__init__.py +13 -0
  176. package/runtime/adapters/claude.py +60 -0
  177. package/runtime/adapters/gpt.py +53 -0
  178. package/runtime/adapters/local.py +53 -0
  179. package/runtime/business_workflow.py +220 -0
  180. package/runtime/compat.py +1299 -0
  181. package/runtime/custom_agent_loader.py +366 -0
  182. package/runtime/dispatcher.py +47 -0
  183. package/runtime/ecosystem.py +371 -0
  184. package/runtime/legacy_compat.py +7 -0
  185. package/runtime/omc_compat.py +7 -0
  186. package/runtime/omc_contract_snapshot.json +916 -0
  187. package/runtime/omg_compat_contract_snapshot.json +916 -0
  188. package/runtime/subagent_dispatcher.py +362 -0
  189. package/runtime/team_router.py +838 -0
  190. package/scripts/check-omc-contract-snapshot.py +12 -0
  191. package/scripts/check-omg-compat-contract-snapshot.py +137 -0
  192. package/scripts/check-omg-standalone-clean.py +102 -0
  193. package/scripts/legacy_to_omg_migrate.py +29 -0
  194. package/scripts/migrate-omc.py +464 -0
  195. package/scripts/omc_to_omg_migrate.py +12 -0
  196. package/scripts/omg.py +493 -0
  197. package/scripts/settings-merge.py +224 -0
  198. package/scripts/verify-no-omc.sh +5 -0
  199. package/scripts/verify-standalone.sh +21 -0
  200. package/templates/idea.yml +30 -0
  201. package/templates/policy.yaml +15 -0
  202. package/templates/profile.yaml +25 -0
  203. package/templates/runtime.yaml +12 -0
  204. package/templates/working-memory.md +17 -0
  205. package/tools/__init__.py +2 -0
  206. package/tools/browser_consent.py +289 -0
  207. package/tools/browser_stealth.py +481 -0
  208. package/tools/browser_tool.py +448 -0
  209. package/tools/changelog_generator.py +268 -0
  210. package/tools/commit_splitter.py +361 -0
  211. package/tools/config_discovery.py +151 -0
  212. package/tools/config_merger.py +449 -0
  213. package/tools/git_inspector.py +298 -0
  214. package/tools/lsp_client.py +275 -0
  215. package/tools/lsp_discovery.py +231 -0
  216. package/tools/lsp_operations.py +392 -0
  217. package/tools/python_repl.py +656 -0
  218. package/tools/python_sandbox.py +609 -0
  219. package/tools/search_providers/__init__.py +77 -0
  220. package/tools/search_providers/brave.py +115 -0
  221. package/tools/search_providers/exa.py +116 -0
  222. package/tools/search_providers/jina.py +104 -0
  223. package/tools/search_providers/perplexity.py +139 -0
  224. package/tools/search_providers/synthetic.py +74 -0
  225. package/tools/session_snapshot.py +736 -0
  226. package/tools/ssh_manager.py +912 -0
  227. package/tools/theme_engine.py +294 -0
  228. package/tools/theme_selector.py +137 -0
  229. package/tools/web_search.py +622 -0
@@ -0,0 +1,622 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Web Search Provider Abstraction for OMG
4
+
5
+ Clean provider interface for web search with credential integration.
6
+ Providers register via the abstract base class; WebSearchManager dispatches
7
+ search/fetch calls to the active provider.
8
+
9
+ Feature flag: OMG_WEB_SEARCH_ENABLED (default: False)
10
+ """
11
+
12
+ import abc
13
+ import importlib
14
+ import time
15
+ import json
16
+ import os
17
+ import sys
18
+ import urllib.error
19
+ import urllib.request
20
+ from dataclasses import asdict, dataclass, field
21
+ from typing import Any, Dict, List, Optional
22
+
23
+
24
+ # --- Lazy imports for hooks/_common.py ---
25
+
26
+ _get_feature_flag = None
27
+ _atomic_json_write = None
28
+
29
+
30
+ def _ensure_imports():
31
+ """Lazy import feature flag and atomic write from hooks/_common.py."""
32
+ global _get_feature_flag, _atomic_json_write
33
+ if _get_feature_flag is not None:
34
+ return
35
+ repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
36
+ if repo_root not in sys.path:
37
+ sys.path.insert(0, repo_root)
38
+ try:
39
+ from hooks._common import get_feature_flag as _gff
40
+ from hooks._common import atomic_json_write as _ajw
41
+ _get_feature_flag = _gff
42
+ _atomic_json_write = _ajw
43
+ except ImportError:
44
+ pass
45
+
46
+
47
+ # --- Lazy import for credential_store (OPTIONAL) ---
48
+
49
+ _credential_store = None
50
+ _has_credential_store: Optional[bool] = None
51
+ # Backward-compat test seam (patched in existing tests).
52
+ _HAS_CREDENTIAL_STORE = None
53
+
54
+
55
+ def _check_credential_store() -> bool:
56
+ """Check if credential_store is available (cached after first check)."""
57
+ global _has_credential_store, _credential_store
58
+ override = globals().get("_HAS_CREDENTIAL_STORE")
59
+ if override is not None:
60
+ return bool(override)
61
+
62
+ if _has_credential_store is None:
63
+ try:
64
+ repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
65
+ hooks_dir = os.path.join(repo_root, "hooks")
66
+ if hooks_dir not in sys.path:
67
+ sys.path.insert(0, hooks_dir)
68
+ _cs = importlib.import_module("credential_store")
69
+ _credential_store = _cs
70
+ _has_credential_store = True
71
+ except ImportError:
72
+ _has_credential_store = False
73
+ return bool(_has_credential_store)
74
+
75
+
76
+ # --- Feature flag ---
77
+
78
+ def _is_enabled() -> bool:
79
+ """Check if Web Search feature is enabled."""
80
+ # Fast path: check env var directly
81
+ env_val = os.environ.get("OMG_WEB_SEARCH_ENABLED", "").lower()
82
+ if env_val in ("0", "false", "no"):
83
+ return False
84
+ if env_val in ("1", "true", "yes"):
85
+ return True
86
+ # Fallback to hooks/_common.get_feature_flag
87
+ _ensure_imports()
88
+ if _get_feature_flag is not None:
89
+ return _get_feature_flag("WEB_SEARCH", default=False)
90
+ return False
91
+
92
+
93
+ # =============================================================================
94
+ # Data Types
95
+ # =============================================================================
96
+
97
+
98
+ @dataclass
99
+ class SearchResult:
100
+ """A single search result from a web search provider.
101
+
102
+ Attributes:
103
+ title: The title of the search result.
104
+ url: The URL of the search result.
105
+ snippet: A text snippet or summary from the result.
106
+ source: The provider name that produced this result.
107
+ """
108
+ title: str
109
+ url: str
110
+ snippet: str
111
+ source: str
112
+
113
+ def to_dict(self) -> Dict[str, str]:
114
+ """Convert to a plain dictionary."""
115
+ return asdict(self)
116
+
117
+ @classmethod
118
+ def from_dict(cls, data: Dict[str, str]) -> "SearchResult":
119
+ """Create a SearchResult from a dictionary.
120
+
121
+ Accepts dicts with at least 'title', 'url', 'snippet', 'source' keys.
122
+ Missing keys default to empty string.
123
+ """
124
+ return cls(
125
+ title=data.get("title", ""),
126
+ url=data.get("url", ""),
127
+ snippet=data.get("snippet", ""),
128
+ source=data.get("source", ""),
129
+ )
130
+
131
+
132
+ # =============================================================================
133
+ # Abstract Provider Base Class
134
+ # =============================================================================
135
+
136
+
137
+ class Provider(abc.ABC):
138
+ """Abstract base class for web search providers.
139
+
140
+ Subclasses must implement `search()` and `fetch()`.
141
+ """
142
+
143
+ @abc.abstractmethod
144
+ def search(self, query: str, **kwargs: Any) -> List[Dict[str, str]]:
145
+ """Search the web for the given query.
146
+
147
+ Args:
148
+ query: The search query string.
149
+ **kwargs: Provider-specific options (e.g., num_results, language).
150
+
151
+ Returns:
152
+ A list of dicts, each with at least 'title', 'url', 'snippet' keys.
153
+ """
154
+ ...
155
+
156
+ @abc.abstractmethod
157
+ def fetch(self, url: str) -> str:
158
+ """Fetch the content of a URL and return it as text.
159
+
160
+ Args:
161
+ url: The URL to fetch.
162
+
163
+ Returns:
164
+ The page content as a string.
165
+ """
166
+ ...
167
+
168
+ @property
169
+ def name(self) -> str:
170
+ """Provider name (defaults to class name in lowercase)."""
171
+ return self.__class__.__name__.lower()
172
+
173
+
174
+ # =============================================================================
175
+ # Credential Lookup
176
+ # =============================================================================
177
+
178
+
179
+ def get_api_key(provider_name: str) -> Optional[str]:
180
+ """Get the API key for a provider.
181
+
182
+ Resolution order:
183
+ 1. credential_store.get_active_key(provider_name) if available
184
+ 2. Environment variable {PROVIDER_NAME}_API_KEY
185
+
186
+ Args:
187
+ provider_name: The name of the provider (e.g., 'exa', 'serper').
188
+
189
+ Returns:
190
+ The API key string, or None if not found.
191
+ """
192
+ # Try credential store first
193
+ if _check_credential_store() and _credential_store is not None:
194
+ try:
195
+ key = _credential_store.get_active_key(provider_name)
196
+ if key:
197
+ return key
198
+ except Exception:
199
+ pass # Fall through to env var
200
+
201
+ # Fallback to environment variable
202
+ env_key = f"{provider_name.upper()}_API_KEY"
203
+ return os.environ.get(env_key)
204
+
205
+
206
+ # =============================================================================
207
+ # Web Search Manager
208
+ # =============================================================================
209
+
210
+
211
+ class WebSearchManager:
212
+ """Central manager for web search operations.
213
+
214
+ Manages registered providers and dispatches search/fetch calls.
215
+ All public methods return early with error/empty if the feature flag
216
+ is disabled.
217
+ """
218
+
219
+ def __init__(self) -> None:
220
+ self._providers: Dict[str, Provider] = {}
221
+ self._default_provider: Optional[str] = None
222
+
223
+ def register_provider(self, name: str, provider: Provider) -> None:
224
+ """Register a search provider.
225
+
226
+ Args:
227
+ name: A unique name for the provider (e.g., 'exa', 'serper').
228
+ provider: An instance of a Provider subclass.
229
+ """
230
+ self._providers[name] = provider
231
+ # First registered provider becomes default
232
+ if self._default_provider is None:
233
+ self._default_provider = name
234
+
235
+ def unregister_provider(self, name: str) -> bool:
236
+ """Remove a registered provider.
237
+
238
+ Args:
239
+ name: The provider name to remove.
240
+
241
+ Returns:
242
+ True if removed, False if not found.
243
+ """
244
+ if name in self._providers:
245
+ del self._providers[name]
246
+ if self._default_provider == name:
247
+ self._default_provider = (
248
+ next(iter(self._providers)) if self._providers else None
249
+ )
250
+ return True
251
+ return False
252
+
253
+ def get_providers(self) -> List[str]:
254
+ """Return a list of registered provider names.
255
+
256
+ Returns:
257
+ List of provider name strings.
258
+ """
259
+ return list(self._providers.keys())
260
+
261
+ def search(
262
+ self,
263
+ query: str,
264
+ provider: Optional[str] = None,
265
+ use_selector: bool = False,
266
+ dry_run: bool = False,
267
+ **kwargs: Any,
268
+ ) -> List[SearchResult]:
269
+ """Search using a registered provider.
270
+
271
+ Args:
272
+ query: The search query string.
273
+ provider: Provider name to use. If None, uses the default provider
274
+ (or ProviderSelector if use_selector=True).
275
+ use_selector: If True, use ProviderSelector for intelligent
276
+ provider selection with fallback and rate-limit awareness.
277
+ dry_run: If True (with use_selector), prefer synthetic provider.
278
+ **kwargs: Additional arguments passed to the provider's search().
279
+
280
+ Returns:
281
+ A list of SearchResult objects. Empty list if feature disabled,
282
+ no providers registered, or provider not found.
283
+ """
284
+ if not _is_enabled():
285
+ return []
286
+
287
+ if use_selector:
288
+ provider_name = provider_selector.select_provider(
289
+ query=query,
290
+ preferred=provider,
291
+ manager=self,
292
+ dry_run=dry_run,
293
+ )
294
+ else:
295
+ provider_name = provider or self._default_provider
296
+
297
+ if provider_name is None or provider_name not in self._providers:
298
+ return []
299
+
300
+ prov = self._providers[provider_name]
301
+ try:
302
+ raw_results = prov.search(query, **kwargs)
303
+ except Exception:
304
+ return []
305
+
306
+ results = []
307
+ for item in raw_results:
308
+ item.setdefault("source", provider_name)
309
+ results.append(SearchResult.from_dict(item))
310
+ return results
311
+
312
+ def fetch(
313
+ self,
314
+ url: str,
315
+ provider: Optional[str] = None,
316
+ ) -> str:
317
+ """Fetch URL content using a registered provider.
318
+
319
+ Args:
320
+ url: The URL to fetch.
321
+ provider: Provider name to use. If None, uses default provider.
322
+ If no provider registered, uses stdlib urllib as fallback.
323
+
324
+ Returns:
325
+ The fetched content as a string, or empty string on failure/disabled.
326
+ """
327
+ if not _is_enabled():
328
+ return ""
329
+
330
+ provider_name = provider or self._default_provider
331
+ if provider_name and provider_name in self._providers:
332
+ try:
333
+ return self._providers[provider_name].fetch(url)
334
+ except Exception:
335
+ return ""
336
+
337
+ # Fallback: use stdlib urllib if no provider available
338
+ return _stdlib_fetch(url)
339
+
340
+
341
+ # =============================================================================
342
+ # Stdlib URL Fetch (fallback)
343
+ # =============================================================================
344
+
345
+
346
+ def _stdlib_fetch(url: str, timeout: int = 15) -> str:
347
+ """Fetch URL content using stdlib urllib.request.
348
+
349
+ Args:
350
+ url: The URL to fetch.
351
+ timeout: Request timeout in seconds.
352
+
353
+ Returns:
354
+ The page content as text, or empty string on failure.
355
+ """
356
+ try:
357
+ req = urllib.request.Request(
358
+ url,
359
+ headers={"User-Agent": "OMG-WebSearch/1.0"},
360
+ )
361
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
362
+ encoding = resp.headers.get_content_charset() or "utf-8"
363
+ return resp.read().decode(encoding, errors="replace")
364
+ except Exception:
365
+ return ""
366
+
367
+ # =============================================================================
368
+ # Provider Selection Logic
369
+ # =============================================================================
370
+
371
+
372
+ # Module-level rate limit state: provider_name -> reset_timestamp
373
+ _rate_limits: Dict[str, float] = {}
374
+
375
+ # Default fallback chain order
376
+ DEFAULT_FALLBACK_CHAIN = ["exa", "brave", "perplexity", "jina", "synthetic"]
377
+
378
+
379
+ class ProviderSelector:
380
+ """Intelligent provider selection with fallback chains and rate-limit tracking.
381
+
382
+ Selection algorithm:
383
+ 1. If dry-run mode, prefer 'synthetic'
384
+ 2. If `preferred` argument given, use it (if not rate-limited)
385
+ 3. If user preference is set, use it (if not rate-limited)
386
+ 4. Iterate fallback chain, pick first not rate-limited + registered
387
+ 5. Return None if all exhausted
388
+ """
389
+
390
+ def __init__(self) -> None:
391
+ self._preference_path: Optional[str] = None
392
+
393
+ def _get_preference_path(self) -> str:
394
+ """Get the path to the preference file."""
395
+ if self._preference_path is None:
396
+ repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
397
+ self._preference_path = os.path.join(
398
+ repo_root, ".omg", "state", "search_preference.json"
399
+ )
400
+ return self._preference_path
401
+
402
+ def select_provider(
403
+ self,
404
+ query: str,
405
+ preferred: Optional[str] = None,
406
+ manager: Optional["WebSearchManager"] = None,
407
+ dry_run: bool = False,
408
+ ) -> Optional[str]:
409
+ """Pick the best available provider for a query.
410
+
411
+ Args:
412
+ query: The search query (reserved for future query-type routing).
413
+ preferred: Explicit provider preference for this call.
414
+ manager: WebSearchManager to check registered providers against.
415
+ dry_run: If True, prefer 'synthetic' provider.
416
+
417
+ Returns:
418
+ Provider name string, or None if no provider available.
419
+ """
420
+ registered = manager.get_providers() if manager else []
421
+
422
+ # Step 0: dry-run mode prefers synthetic
423
+ if dry_run and "synthetic" in registered and not self.is_rate_limited("synthetic"):
424
+ return "synthetic"
425
+
426
+ # Step 1: explicit preferred argument
427
+ if preferred and preferred in registered and not self.is_rate_limited(preferred):
428
+ return preferred
429
+
430
+ # Step 2: user preference from persisted state
431
+ user_pref = self.get_preference()
432
+ if user_pref and user_pref in registered and not self.is_rate_limited(user_pref):
433
+ return user_pref
434
+
435
+ # Step 3: iterate fallback chain
436
+ for provider_name in DEFAULT_FALLBACK_CHAIN:
437
+ if provider_name in registered and not self.is_rate_limited(provider_name):
438
+ return provider_name
439
+
440
+ # Step 4: try any remaining registered provider not in fallback chain
441
+ for provider_name in registered:
442
+ if not self.is_rate_limited(provider_name):
443
+ return provider_name
444
+
445
+ return None
446
+
447
+ def record_rate_limit(
448
+ self, provider_name: str, reset_at: Optional[float] = None
449
+ ) -> None:
450
+ """Mark a provider as rate-limited.
451
+
452
+ Args:
453
+ provider_name: The provider to mark.
454
+ reset_at: Unix timestamp when the rate limit resets.
455
+ Defaults to time.time() + 60 (1 minute).
456
+ """
457
+ if reset_at is None:
458
+ reset_at = time.time() + 60
459
+ _rate_limits[provider_name] = reset_at
460
+
461
+ def is_rate_limited(self, provider_name: str) -> bool:
462
+ """Check if a provider is currently rate-limited.
463
+
464
+ Automatically clears expired rate limits.
465
+
466
+ Args:
467
+ provider_name: The provider to check.
468
+
469
+ Returns:
470
+ True if currently rate-limited, False otherwise.
471
+ """
472
+ if provider_name not in _rate_limits:
473
+ return False
474
+ if time.time() > _rate_limits[provider_name]:
475
+ # Rate limit expired — clean up
476
+ del _rate_limits[provider_name]
477
+ return False
478
+ return True
479
+
480
+ def get_fallback_chain(self, primary_provider: str) -> List[str]:
481
+ """Return ordered fallback list starting after the primary provider.
482
+
483
+ Args:
484
+ primary_provider: The provider to build fallback chain from.
485
+
486
+ Returns:
487
+ List of provider names in fallback order (excluding primary).
488
+ """
489
+ chain = list(DEFAULT_FALLBACK_CHAIN)
490
+ if primary_provider in chain:
491
+ idx = chain.index(primary_provider)
492
+ # Everything after primary, then wrap around before it
493
+ chain = chain[idx + 1:] + chain[:idx]
494
+ return chain
495
+
496
+ def set_preference(self, provider_name: str) -> None:
497
+ """Persist user preferred provider to .omg/state/search_preference.json.
498
+
499
+ Args:
500
+ provider_name: The provider name to set as default.
501
+ """
502
+ import datetime
503
+
504
+ _ensure_imports()
505
+ data = {
506
+ "provider": provider_name,
507
+ "set_at": datetime.datetime.now().isoformat(),
508
+ }
509
+ path = self._get_preference_path()
510
+ if _atomic_json_write is not None:
511
+ _atomic_json_write(path, data)
512
+ else:
513
+ # Fallback: direct write
514
+ os.makedirs(os.path.dirname(path), exist_ok=True)
515
+ with open(path, "w") as f:
516
+ json.dump(data, f, indent=2)
517
+
518
+ def get_preference(self) -> Optional[str]:
519
+ """Read user preferred provider from persisted state.
520
+
521
+ Returns:
522
+ Provider name string, or None if no preference set.
523
+ """
524
+ path = self._get_preference_path()
525
+ try:
526
+ with open(path) as f:
527
+ data = json.load(f)
528
+ return data.get("provider")
529
+ except (FileNotFoundError, json.JSONDecodeError, KeyError):
530
+ return None
531
+
532
+
533
+ # Module-level singleton
534
+ provider_selector = ProviderSelector()
535
+
536
+
537
+ # =============================================================================
538
+ # Module-Level Singleton Instance
539
+ # =============================================================================
540
+
541
+ manager = WebSearchManager()
542
+
543
+ # Auto-register bundled providers so CLI and hooks work out of the box.
544
+ # - synthetic always registers (no API key required)
545
+ # - api-key providers register only when credentials are available
546
+ try:
547
+ _providers_mod = importlib.import_module("search_providers")
548
+ _providers_mod.register_all(manager=manager)
549
+ except Exception:
550
+ # Keep module import non-fatal if provider modules are unavailable.
551
+ pass
552
+
553
+ # =============================================================================
554
+ # CLI Interface
555
+ # =============================================================================
556
+
557
+
558
+ def _cli_main():
559
+ """CLI entry point for web_search.py."""
560
+ import argparse
561
+
562
+ parser = argparse.ArgumentParser(
563
+ description="OMG Web Search Tool — provider-based web search abstraction",
564
+ formatter_class=argparse.RawDescriptionHelpFormatter,
565
+ )
566
+ parser.add_argument("--query", help="Search query string")
567
+ parser.add_argument("--provider", help="Provider name to use")
568
+ parser.add_argument("--fetch", dest="fetch_url", help="Fetch URL content")
569
+ parser.add_argument(
570
+ "--dry-run", action="store_true",
571
+ help="Print what would be searched without making real API calls",
572
+ )
573
+ parser.add_argument(
574
+ "--list-providers", action="store_true",
575
+ help="List registered providers",
576
+ )
577
+
578
+ args = parser.parse_args()
579
+
580
+ # Check feature flag for most operations
581
+ enabled = _is_enabled()
582
+
583
+ if args.list_providers:
584
+ providers = manager.get_providers()
585
+ print(json.dumps({"providers": providers, "enabled": enabled}))
586
+ return
587
+
588
+ if args.dry_run and args.query:
589
+ print(json.dumps({
590
+ "dry_run": True,
591
+ "query": args.query,
592
+ "provider": args.provider or manager._default_provider or "(none)",
593
+ "enabled": enabled,
594
+ "registered_providers": manager.get_providers(),
595
+ "would_search": enabled and len(manager.get_providers()) > 0,
596
+ }, indent=2))
597
+ return
598
+
599
+ if not enabled:
600
+ print(json.dumps({"error": "Web search is disabled (OMG_WEB_SEARCH_ENABLED=false)"}))
601
+ sys.exit(1)
602
+
603
+ if args.fetch_url:
604
+ content = manager.fetch(args.fetch_url, provider=args.provider)
605
+ print(json.dumps({
606
+ "url": args.fetch_url,
607
+ "content_length": len(content),
608
+ "content_preview": content[:500] if content else "",
609
+ }, indent=2))
610
+ return
611
+
612
+ if args.query:
613
+ results = manager.search(args.query, provider=args.provider)
614
+ output = [r.to_dict() for r in results]
615
+ print(json.dumps({"results": output, "count": len(output)}, indent=2))
616
+ return
617
+
618
+ parser.print_help()
619
+
620
+
621
+ if __name__ == "__main__":
622
+ _cli_main()