osintengine 1.0.2__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 (103) hide show
  1. osintengine-1.0.2.dist-info/METADATA +311 -0
  2. osintengine-1.0.2.dist-info/RECORD +103 -0
  3. osintengine-1.0.2.dist-info/WHEEL +5 -0
  4. osintengine-1.0.2.dist-info/entry_points.txt +2 -0
  5. osintengine-1.0.2.dist-info/licenses/LICENSE +202 -0
  6. osintengine-1.0.2.dist-info/top_level.txt +2 -0
  7. src/watson/__init__.py +10 -0
  8. src/watson/agent/__init__.py +96 -0
  9. src/watson/agents/__init__.py +101 -0
  10. src/watson/agents/protocol.py +196 -0
  11. src/watson/auth/__init__.py +68 -0
  12. src/watson/auth/store.py +145 -0
  13. src/watson/conversation.py +68 -0
  14. src/watson/core/__init__.py +0 -0
  15. src/watson/core/models.py +96 -0
  16. src/watson/ethics.py +205 -0
  17. src/watson/exports.py +182 -0
  18. src/watson/graph/__init__.py +69 -0
  19. src/watson/graph/entities.py +207 -0
  20. src/watson/graph/graph.py +489 -0
  21. src/watson/graph/osint_framework.py +266 -0
  22. src/watson/graph/relationships.py +116 -0
  23. src/watson/graph/transforms.py +787 -0
  24. src/watson/infra/__init__.py +43 -0
  25. src/watson/infra/cache.py +171 -0
  26. src/watson/infra/ratelimit.py +125 -0
  27. src/watson/infra/resilience.py +239 -0
  28. src/watson/infra/retry.py +186 -0
  29. src/watson/metrics.py +128 -0
  30. src/watson/opsec/__init__.py +290 -0
  31. src/watson/orchestration/__init__.py +23 -0
  32. src/watson/orchestration/engine.py +5587 -0
  33. src/watson/orchestration/executor.py +96 -0
  34. src/watson/orchestration/intent.py +139 -0
  35. src/watson/orchestration/intent_classifier.py +45 -0
  36. src/watson/orchestration/llm_config.py +160 -0
  37. src/watson/orchestration/resolution.py +555 -0
  38. src/watson/orchestration/scraper.py +94 -0
  39. src/watson/orchestration/synthesis.py +726 -0
  40. src/watson/orchestration/target_profile.py +748 -0
  41. src/watson/persistence/__init__.py +18 -0
  42. src/watson/persistence/models.py +161 -0
  43. src/watson/persistence/store.py +230 -0
  44. src/watson/pipeline/__init__.py +16 -0
  45. src/watson/pipeline/pre_synthesis.py +621 -0
  46. src/watson/search.py +103 -0
  47. src/watson/serializers/stix.py +451 -0
  48. src/watson/tools/__init__.py +31 -0
  49. src/watson/tools/base.py +97 -0
  50. src/watson/tools/blockchain.py +359 -0
  51. src/watson/tools/captcha.py +276 -0
  52. src/watson/tools/conflict.py +107 -0
  53. src/watson/tools/corporate.py +287 -0
  54. src/watson/tools/darkweb.py +144 -0
  55. src/watson/tools/geolocation.py +347 -0
  56. src/watson/tools/image_video.py +108 -0
  57. src/watson/tools/marinetraffic.py +142 -0
  58. src/watson/tools/people.py +476 -0
  59. src/watson/tools/registry.py +56 -0
  60. src/watson/tools/satellite.py +118 -0
  61. src/watson/tools/scraper.py +745 -0
  62. src/watson/tools/shodan.py +129 -0
  63. src/watson/tools/social_media.py +160 -0
  64. src/watson/tools/websites.py +291 -0
  65. src/watson/tools/wikidata.py +398 -0
  66. src/watson/utils/__init__.py +1 -0
  67. src/watson/utils/helpers.py +49 -0
  68. src/watson/utils/http.py +119 -0
  69. src/watson/verification/__init__.py +245 -0
  70. watson/__init__.py +6 -0
  71. watson/agents/__init__.py +20 -0
  72. watson/agents/base.py +103 -0
  73. watson/agents/direct.py +225 -0
  74. watson/agents/hermes.py +275 -0
  75. watson/agents/hermes_mcp.py +292 -0
  76. watson/api_keys.py +176 -0
  77. watson/browser_scraper.py +481 -0
  78. watson/cli.py +836 -0
  79. watson/crossref.py +175 -0
  80. watson/ethics.py +20 -0
  81. watson/graph.py +406 -0
  82. watson/mcp_server.py +601 -0
  83. watson/memory.py +552 -0
  84. watson/neo4j_graph.py +124 -0
  85. watson/opsec/__init__.py +307 -0
  86. watson/reporter.py +537 -0
  87. watson/scheduler.py +486 -0
  88. watson/serializers/stix.py +451 -0
  89. watson/terminal.py +410 -0
  90. watson/toolkit.py +252 -0
  91. watson/toolkit_api.py +347 -0
  92. watson/toolkit_automation.py +95 -0
  93. watson/toolkit_registry.py +345 -0
  94. watson/verification/__init__.py +251 -0
  95. watson/web/__init__.py +1 -0
  96. watson/web/app.py +1650 -0
  97. watson/web/middleware.py +301 -0
  98. watson/web/static/assets/index-B7hPOc0z.js +278 -0
  99. watson/web/static/assets/index-CJ6FP8Mp.css +1 -0
  100. watson/web/static/assets/watson-logo-Dk9tawHb.png +0 -0
  101. watson/web/static/index.html +14 -0
  102. watson/web/templates/chat.html +2 -0
  103. watson/web/templates/investigation-map.html +429 -0
@@ -0,0 +1,245 @@
1
+ """LLM Verification Layer — second-pass validation to filter false positives.
2
+
3
+ After Phase 6 synthesis, each finding is re-examined by the LLM with a strict
4
+ verification prompt. The LLM checks:
5
+ 1. Does the source URL actually support the finding?
6
+ 2. Is the tier (CONFIRMED/PROBABLE/POSSIBLE) appropriate?
7
+ 3. Is anything hallucinated (fake entities, fabricated URLs, impossible claims)?
8
+ 4. Should the finding be downgraded or dropped?
9
+
10
+ Output: adjusted tier + verification_notes for each finding.
11
+
12
+ Thresholds:
13
+ - Drop findings where verification_confidence < 30
14
+ - Downgrade findings where LLM flags overconfidence
15
+ - Keep findings that pass verification unchanged
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ import logging
22
+ import re
23
+ from typing import Any
24
+
25
+ logger = logging.getLogger("watson.verify")
26
+
27
+ # ── Verification prompt ───────────────────────────────────────
28
+
29
+ _VERIFY_SYSTEM = """You are a strict OSINT quality-assurance auditor. Your job is to validate
30
+ intelligence findings and catch false positives, hallucinations, and overconfident claims.
31
+
32
+ For each finding, check:
33
+ 1. Does the source URL actually exist and support the claim? If the URL looks fabricated
34
+ (random-looking domain, 404 patterns, impossible paths), flag it.
35
+ 2. Is the confidence tier appropriate? CONFIRMED needs multi-source corroboration.
36
+ PROBABLE needs a credible source. POSSIBLE is for weak signals.
37
+ 3. Are there any hallucinated elements? Invented person names, fake organizations,
38
+ impossible technical details, fabricated numbers/SAR IDs.
39
+ 4. Is the finding logically coherent? Does the description actually match the title?
40
+
41
+ Respond with ONLY a JSON object. No markdown, no explanation outside the JSON."""
42
+
43
+ _VERIFY_PROMPT = """Review these OSINT findings for quality. Return a JSON dict with:
44
+ - "verifications": list of { "finding_id": str, "pass": bool, "adjusted_tier": str,
45
+ "verification_confidence": int (0-100), "issues": [str], "notes": str }
46
+
47
+ Findings to verify:
48
+ {findings_json}
49
+
50
+ Rules:
51
+ - CONFIRMED tier requires: real source URL + specific verifiable claim + no hallucination risk
52
+ - PROBABLE tier requires: credible source URL + plausible claim
53
+ - POSSIBLE tier: weak signal, correlation, or unverified mention
54
+ - UNLIKELY/UNVERIFIED: should be dropped (pass=false)
55
+ - If source URL doesn't look real, flag and downgrade
56
+ - If finding contains invented names/numbers, drop it
57
+ - If description is generic/vague, downgrade to POSSIBLE
58
+ - If finding is about a different entity than the target, flag it
59
+
60
+ Return ONLY valid JSON."""
61
+
62
+
63
+ # ── Verification engine ───────────────────────────────────────
64
+
65
+ class FindingVerifier:
66
+ """Second-pass LLM verification for OSINT findings."""
67
+
68
+ def __init__(self, call_llm=None):
69
+ """call_llm: async function (prompt, timeout, max_tokens) → str | None."""
70
+ self._call_llm = call_llm
71
+
72
+ async def verify(
73
+ self,
74
+ findings: list[Any],
75
+ query: str = "",
76
+ batch_size: int = 15,
77
+ ) -> list[dict]:
78
+ """Verify findings in batches. Returns list of verification results."""
79
+ if not findings or not self._call_llm:
80
+ return []
81
+
82
+ results: list[dict] = []
83
+
84
+ # Batch findings to avoid token overflow
85
+ for i in range(0, len(findings), batch_size):
86
+ batch = findings[i : i + batch_size]
87
+ batch_results = await self._verify_batch(batch, query)
88
+ results.extend(batch_results)
89
+
90
+ return results
91
+
92
+ async def _verify_batch(self, findings: list[Any], query: str) -> list[dict]:
93
+ """Verify a batch of findings."""
94
+ if not self._call_llm:
95
+ return []
96
+ # Serialize findings to compact JSON
97
+ findings_data = []
98
+ for f in findings:
99
+ fid = getattr(f, "id", str(hash(f)))
100
+ findings_data.append({
101
+ "id": fid,
102
+ "title": getattr(f, "title", "")[:200],
103
+ "description": getattr(f, "description", "")[:300],
104
+ "tier": getattr(f, "tier", "UNVERIFIED"),
105
+ "source_url": getattr(f, "source_url", "")[:200],
106
+ "source_type": getattr(f, "source_type", "osint"),
107
+ "confidence": getattr(f, "confidence", 0.5),
108
+ })
109
+
110
+ prompt = _VERIFY_PROMPT.format(
111
+ findings_json=json.dumps(findings_data, indent=2)
112
+ )
113
+
114
+ try:
115
+ raw = await self._call_llm(
116
+ prompt,
117
+ timeout=120,
118
+ max_tokens=4096,
119
+ system=_VERIFY_SYSTEM,
120
+ )
121
+ if not raw or not raw.strip():
122
+ logger.warning("verify: LLM returned empty — skipping verification")
123
+ return [{"finding_id": fd["id"], "pass": True, "verification_confidence": 50,
124
+ "adjusted_tier": fd["tier"], "issues": [], "notes": "Verification skipped (LLM unavailable)"}
125
+ for fd in findings_data]
126
+
127
+ parsed = self._parse_response(raw)
128
+ return self._merge_results(findings_data, parsed)
129
+
130
+ except Exception as e:
131
+ logger.warning("verify: batch failed: %s", e)
132
+ return [{"finding_id": fd["id"], "pass": True, "verification_confidence": 50,
133
+ "adjusted_tier": fd["tier"], "issues": [],
134
+ "notes": f"Verification error: {e}"}
135
+ for fd in findings_data]
136
+
137
+ def _parse_response(self, raw: str) -> dict:
138
+ """Parse LLM verification response."""
139
+ # Strip markdown fences
140
+ text = raw.strip()
141
+ if text.startswith("```"):
142
+ text = re.sub(r"^```(?:json)?\s*", "", text)
143
+ text = re.sub(r"\s*```$", "", text)
144
+
145
+ # Try JSON parse
146
+ try:
147
+ return json.loads(text)
148
+ except json.JSONDecodeError:
149
+ # Try to extract JSON object
150
+ m = re.search(r"\{.*\}", text, re.DOTALL)
151
+ if m:
152
+ try:
153
+ return json.loads(m.group(0))
154
+ except json.JSONDecodeError:
155
+ pass
156
+
157
+ logger.warning("verify: could not parse response: %s", raw[:200])
158
+ return {}
159
+
160
+ def _merge_results(self, findings_data: list[dict], parsed: dict) -> list[dict]:
161
+ """Merge parsed verifications with original finding data."""
162
+ verifications = parsed.get("verifications", [])
163
+ if not verifications:
164
+ # No structured verifications — pass everything
165
+ return [{"finding_id": fd["id"], "pass": True, "verification_confidence": 50,
166
+ "adjusted_tier": fd["tier"], "issues": [], "notes": "No verification data"}
167
+ for fd in findings_data]
168
+
169
+ # Build lookup
170
+ vmap: dict[str, dict] = {v.get("finding_id", ""): v for v in verifications}
171
+
172
+ results = []
173
+ for fd in findings_data:
174
+ fid = fd["id"]
175
+ v = vmap.get(fid, {})
176
+ results.append({
177
+ "finding_id": fid,
178
+ "pass": v.get("pass", True),
179
+ "verification_confidence": v.get("verification_confidence", 50),
180
+ "adjusted_tier": v.get("adjusted_tier", fd["tier"]),
181
+ "issues": v.get("issues", []),
182
+ "notes": v.get("notes", ""),
183
+ })
184
+ return results
185
+
186
+ # ── Apply verification results to findings ────────────────
187
+
188
+ @staticmethod
189
+ def apply(finding: Any, verification: dict) -> tuple[Any, bool]:
190
+ """Apply verification to a finding.
191
+
192
+ Returns (possibly_modified_finding, was_dropped).
193
+ """
194
+ if not verification:
195
+ return finding, False
196
+
197
+ vconf = verification.get("verification_confidence", 50)
198
+ passed = verification.get("pass", True)
199
+ issues = verification.get("issues", [])
200
+
201
+ # Drop findings with very low verification confidence
202
+ if vconf < 30:
203
+ logger.info("verify: dropping finding %s (confidence=%d, issues=%s)",
204
+ getattr(finding, "id", "?"), vconf, issues)
205
+ return finding, True
206
+
207
+ # Downgrade tier if LLM suggests it
208
+ adjusted = verification.get("adjusted_tier", "")
209
+ if adjusted and hasattr(finding, "tier"):
210
+ tier_order = ["CONFIRMED", "PROBABLE", "POSSIBLE", "UNLIKELY", "UNVERIFIED"]
211
+ old_idx = tier_order.index(finding.tier) if finding.tier in tier_order else 4
212
+ new_idx = tier_order.index(adjusted) if adjusted in tier_order else old_idx
213
+ if new_idx > old_idx:
214
+ logger.info("verify: downgrading finding %s %s→%s",
215
+ getattr(finding, "id", "?"), finding.tier, adjusted)
216
+ finding.tier = adjusted
217
+
218
+ # Attach verification notes
219
+ notes = verification.get("notes", "")
220
+ if notes and hasattr(finding, "description"):
221
+ finding.description = f"[Verified: {notes}] {finding.description}"
222
+
223
+ if issues and hasattr(finding, "description"):
224
+ finding.description = f"[⚠ Issues: {'; '.join(issues[:3])}] {finding.description}"
225
+
226
+ return finding, False
227
+
228
+
229
+ # ── Factory ───────────────────────────────────────────────────
230
+
231
+ def create_verifier(provider: str | None = None) -> FindingVerifier | None:
232
+ """Create a verifier using the configured LLM provider.
233
+
234
+ Returns None if no LLM is available.
235
+ """
236
+ try:
237
+ from watson.orchestration.llm_config import call_llm
238
+
239
+ async def _call(prompt, timeout=120, max_tokens=2000, system=""):
240
+ return await call_llm(prompt, timeout=timeout, max_tokens=max_tokens, system=system)
241
+
242
+ return FindingVerifier(call_llm=_call)
243
+ except ImportError:
244
+ logger.warning("verify: could not import llm_config — verification disabled")
245
+ return None
watson/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Watson — the OSINT investigation engine.
2
+
3
+ Bellingcat-inspired. Graph-native. Agent-agnostic.
4
+ """
5
+
6
+ __version__ = "0.1.0"
@@ -0,0 +1,20 @@
1
+ """Agent adapters for Watson — pluggable backends."""
2
+
3
+ from .base import AgentAdapter, BrowseResult, InvestigationResult, SearchResult, TerminalResult, VisionResult
4
+ from .hermes import HermesCLIAdapter
5
+ from .hermes_mcp import HermesMCPAdapter
6
+
7
+ # Legacy alias — kept for backward compatibility
8
+ HermesAdapter = HermesCLIAdapter
9
+
10
+ __all__ = [
11
+ "AgentAdapter",
12
+ "HermesAdapter", # Legacy alias
13
+ "HermesCLIAdapter", # CLI subprocess backend
14
+ "HermesMCPAdapter", # MCP protocol backend
15
+ "SearchResult",
16
+ "BrowseResult",
17
+ "VisionResult",
18
+ "TerminalResult",
19
+ "InvestigationResult",
20
+ ]
watson/agents/base.py ADDED
@@ -0,0 +1,103 @@
1
+ """
2
+ Agent adapter — abstract interface for pluggable agent backends.
3
+ Watson is agent-agnostic: Hermes, OpenClaw, OpenHuman, or direct LLM.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from abc import ABC, abstractmethod
9
+ from dataclasses import dataclass, field
10
+ from typing import Optional
11
+
12
+
13
+ @dataclass
14
+ class SearchResult:
15
+ title: str
16
+ url: str
17
+ snippet: str = ""
18
+ source: str = ""
19
+
20
+
21
+ @dataclass
22
+ class BrowseResult:
23
+ url: str
24
+ content: str
25
+ title: str = ""
26
+
27
+
28
+ @dataclass
29
+ class VisionResult:
30
+ description: str
31
+ objects: list[str] = field(default_factory=list)
32
+ text: str = ""
33
+
34
+
35
+ @dataclass
36
+ class TerminalResult:
37
+ output: str
38
+ exit_code: int = 0
39
+
40
+
41
+ @dataclass
42
+ class InvestigationResult:
43
+ """Structured result from an investigation angle."""
44
+ angle: str
45
+ findings: list[dict] = field(default_factory=list)
46
+ sources: list[str] = field(default_factory=list)
47
+ confidence: float = 0.0
48
+ raw: str = ""
49
+
50
+
51
+ class AgentAdapter(ABC):
52
+ """Pluggable agent backend for Watson.
53
+
54
+ Watson's engine calls these abstract methods. Each adapter
55
+ implements them for its specific agent. Watson's methodology,
56
+ dispatch logic, graph, and reporting are independent of this layer.
57
+ """
58
+
59
+ name: str = "base"
60
+ description: str = ""
61
+
62
+ @abstractmethod
63
+ async def search(self, query: str, num_results: int = 10) -> list[SearchResult]:
64
+ """Web search."""
65
+ ...
66
+
67
+ @abstractmethod
68
+ async def browse(self, url: str) -> BrowseResult:
69
+ """Navigate to and extract content from a URL."""
70
+ ...
71
+
72
+ @abstractmethod
73
+ async def vision(self, image_path: str, question: str = "Describe this image in detail.") -> VisionResult:
74
+ """Analyze an image."""
75
+ ...
76
+
77
+ @abstractmethod
78
+ async def terminal(self, command: str, timeout: int = 30) -> TerminalResult:
79
+ """Execute a shell command."""
80
+ ...
81
+
82
+ async def investigate_angle(self, angle: str, query: str) -> InvestigationResult:
83
+ """Run a single investigation angle. Default: search + browse top results.
84
+
85
+ Adapters can override this for richer investigation (multi-tool dispatch).
86
+ """
87
+ results = await self.search(query, num_results=5)
88
+ sources = [r.url for r in results if r.url]
89
+ findings = [
90
+ {"title": r.title, "url": r.url, "snippet": r.snippet}
91
+ for r in results
92
+ ]
93
+
94
+ return InvestigationResult(
95
+ angle=angle,
96
+ findings=findings,
97
+ sources=sources,
98
+ confidence=0.5,
99
+ )
100
+
101
+ async def health_check(self) -> bool:
102
+ """Check if the agent backend is reachable."""
103
+ return True
@@ -0,0 +1,225 @@
1
+ """
2
+ Direct LLM adapter — any OpenAI-compatible API.
3
+ Uses DuckDuckGo via duckduckgo_search library for real web search.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import asyncio
9
+ import json
10
+ import os
11
+ import re
12
+
13
+ import aiohttp
14
+
15
+ from .base import (
16
+ AgentAdapter,
17
+ BrowseResult,
18
+ InvestigationResult,
19
+ SearchResult,
20
+ TerminalResult,
21
+ VisionResult,
22
+ )
23
+
24
+ DEFAULT_API_BASE = "https://api.openai.com/v1"
25
+
26
+
27
+ class DirectAdapter(AgentAdapter):
28
+ """LLM-agnostic adapter — any OpenAI-compatible API + DuckDuckGo search."""
29
+
30
+ name = "direct"
31
+ description = "Any OpenAI-compatible API + DuckDuckGo search"
32
+
33
+ def __init__(
34
+ self,
35
+ api_key: str = "",
36
+ model: str = "gpt-4o",
37
+ api_base: str | None = None,
38
+ ):
39
+ self.api_key = api_key or os.environ.get("WATSON_API_KEY", "")
40
+ self.model = model or os.environ.get("WATSON_MODEL", "gpt-4o")
41
+ self.api_base = (
42
+ api_base
43
+ or os.environ.get("WATSON_API_BASE")
44
+ or DEFAULT_API_BASE
45
+ )
46
+ self._ddgs = None
47
+
48
+ @property
49
+ def ddgs(self):
50
+ """Lazy-load DuckDuckGo search client."""
51
+ if self._ddgs is None:
52
+ from ddgs import DDGS
53
+ self._ddgs = DDGS()
54
+ return self._ddgs
55
+
56
+ async def _call_llm(
57
+ self, prompt: str, system: str = "", max_tokens: int = 4000
58
+ ) -> str:
59
+ """Call the LLM API."""
60
+ messages = []
61
+ if system:
62
+ messages.append({"role": "system", "content": system})
63
+ messages.append({"role": "user", "content": prompt})
64
+
65
+ async with aiohttp.ClientSession() as session:
66
+ async with session.post(
67
+ f"{self.api_base}/chat/completions",
68
+ json={
69
+ "model": self.model,
70
+ "messages": messages,
71
+ "temperature": 0.4,
72
+ "max_tokens": max_tokens,
73
+ },
74
+ headers={
75
+ "Content-Type": "application/json",
76
+ "Authorization": f"Bearer {self.api_key}",
77
+ },
78
+ timeout=aiohttp.ClientTimeout(total=60),
79
+ ) as resp:
80
+ if resp.status != 200:
81
+ text = await resp.text()
82
+ raise RuntimeError(f"LLM API error ({resp.status}): {text}")
83
+ data = await resp.json()
84
+ return data["choices"][0]["message"]["content"]
85
+
86
+ async def _ddg_search(self, query: str, num_results: int = 10) -> list[SearchResult]:
87
+ """Real web search via DuckDuckGo."""
88
+ results: list[SearchResult] = []
89
+ try:
90
+ loop = asyncio.get_running_loop()
91
+ raw_results = await loop.run_in_executor(
92
+ None,
93
+ lambda: list(self.ddgs.text(query, max_results=num_results))
94
+ )
95
+ for r in raw_results:
96
+ results.append(SearchResult(
97
+ title=r.get("title", ""),
98
+ url=r.get("href", r.get("url", "")),
99
+ snippet=r.get("body", r.get("snippet", "")),
100
+ source="duckduckgo",
101
+ ))
102
+ except Exception:
103
+ pass
104
+ return results
105
+
106
+ async def search(self, query: str, num_results: int = 10) -> list[SearchResult]:
107
+ """Web search via DuckDuckGo only. No LLM fallback — only real data."""
108
+ return await self._ddg_search(query, num_results)
109
+
110
+ async def browse(self, url: str) -> BrowseResult:
111
+ """Fetch URL content via HTTP."""
112
+ try:
113
+ async with aiohttp.ClientSession() as session:
114
+ async with session.get(
115
+ url,
116
+ headers={"User-Agent": "Mozilla/5.0 (compatible; Watson-OSINT/1.0)"},
117
+ timeout=aiohttp.ClientTimeout(total=15),
118
+ ) as resp:
119
+ text = await resp.text()
120
+ # Strip HTML tags for plain text
121
+ clean = re.sub(r"<[^>]+>", " ", text)
122
+ clean = re.sub(r"\s+", " ", clean)
123
+ return BrowseResult(
124
+ url=url,
125
+ content=clean[:5000],
126
+ title=url,
127
+ )
128
+ except Exception:
129
+ return BrowseResult(url=url, content=f"[Could not fetch {url}]", title=url)
130
+
131
+ async def vision(self, image_path: str, question: str = "Describe this image in detail.") -> VisionResult:
132
+ """LLM cannot analyze images."""
133
+ return VisionResult(
134
+ description=f"[Direct adapter cannot analyze images. Use Hermes for vision.]",
135
+ )
136
+
137
+ async def terminal(self, command: str, timeout: int = 30) -> TerminalResult:
138
+ """Execute commands via subprocess (available in Direct mode too)."""
139
+ try:
140
+ proc = await asyncio.create_subprocess_shell(
141
+ command,
142
+ stdout=asyncio.subprocess.PIPE,
143
+ stderr=asyncio.subprocess.PIPE,
144
+ )
145
+ stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
146
+ output = stdout.decode("utf-8", errors="replace")
147
+ if stderr:
148
+ output += "\n" + stderr.decode("utf-8", errors="replace")
149
+ return TerminalResult(
150
+ output=output.strip() or "(no output)",
151
+ exit_code=proc.returncode or 0,
152
+ )
153
+ except asyncio.TimeoutError:
154
+ return TerminalResult(output="Command timed out", exit_code=1)
155
+ except Exception as e:
156
+ return TerminalResult(output=str(e), exit_code=1)
157
+
158
+ async def investigate_angle(self, angle: str, query: str) -> InvestigationResult:
159
+ """Analyze real search results for investigation findings.
160
+
161
+ Always performs DDG web search (no API key needed).
162
+ With LLM API key: enriches results with AI analysis.
163
+ Without: returns raw DDG results directly — never returns empty.
164
+ """
165
+ # Get real search results (no API key needed)
166
+ search_results = await self._ddg_search(query, num_results=5)
167
+ if not search_results:
168
+ return InvestigationResult(
169
+ angle=angle, confidence=0.0,
170
+ raw="No search results available for this angle."
171
+ )
172
+
173
+ # Without LLM, return raw DDG results as findings
174
+ if not self.api_key:
175
+ raw = "\n".join(
176
+ f"• {r.title}\n {r.snippet[:200]}\n {r.url}"
177
+ for r in search_results
178
+ )
179
+ return InvestigationResult(
180
+ angle=angle,
181
+ raw=raw,
182
+ findings=[
183
+ {"title": r.title, "snippet": r.snippet, "url": r.url}
184
+ for r in search_results
185
+ ],
186
+ sources=[r.url for r in search_results if r.url],
187
+ confidence=0.4,
188
+ )
189
+
190
+ # With LLM: enrich with AI analysis
191
+ context = "\n".join(
192
+ f"- {r.title}: {r.snippet} ({r.url})"
193
+ for r in search_results
194
+ )
195
+
196
+ try:
197
+ response = await self._call_llm(
198
+ prompt=(
199
+ f"ANALYZE these search results for an OSINT investigation.\n\n"
200
+ f"Angle: {angle}\n"
201
+ f"Query: {query}\n\n"
202
+ f"SEARCH RESULTS:\n{context}\n\n"
203
+ f"Extract the key facts from these results. Return:\n"
204
+ f"1. A 1-sentence summary of what was found\n"
205
+ f"2. 3-5 specific findings, each with the source URL\n"
206
+ f"3. Names, dates, and key facts mentioned\n"
207
+ f"4. What's missing or needs deeper investigation\n\n"
208
+ f"Be concise. Use ONLY information from the search results above."
209
+ ),
210
+ system=(
211
+ "You analyze search results for OSINT investigations. "
212
+ "Extract facts from provided results only. Do not invent or plan — analyze."
213
+ ),
214
+ max_tokens=2000,
215
+ )
216
+
217
+ urls = [r.url for r in search_results if r.url]
218
+ return InvestigationResult(
219
+ angle=angle,
220
+ raw=response,
221
+ sources=urls,
222
+ confidence=0.5 if len(response) > 200 else 0.3,
223
+ )
224
+ except Exception as e:
225
+ return InvestigationResult(angle=angle, confidence=0.0, raw=str(e))