eightstatecli 0.4.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.
- eightstatecli-0.4.0.dist-info/METADATA +177 -0
- eightstatecli-0.4.0.dist-info/RECORD +18 -0
- eightstatecli-0.4.0.dist-info/WHEEL +4 -0
- eightstatecli-0.4.0.dist-info/entry_points.txt +2 -0
- eightstatecli-0.4.0.dist-info/licenses/LICENSE +21 -0
- escli/__init__.py +837 -0
- escli/__main__.py +5 -0
- escli/commands/__init__.py +0 -0
- escli/commands/audio.py +438 -0
- escli/commands/docs.py +354 -0
- escli/commands/research.py +597 -0
- escli/commands/search.py +286 -0
- escli/commands/social.py +243 -0
- escli/commands/usage.py +428 -0
- escli/services/__init__.py +0 -0
- escli/services/credentials.py +117 -0
- escli/services/describe.py +186 -0
- escli/services/output.py +168 -0
|
@@ -0,0 +1,597 @@
|
|
|
1
|
+
"""
|
|
2
|
+
escli research — web research tasks via Parallel Task API.
|
|
3
|
+
|
|
4
|
+
Supports the full Task API surface: deep research, data enrichment,
|
|
5
|
+
structured output, source policies, interactions, and all processors.
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
escli research "query" -o report.md Deep research → markdown
|
|
9
|
+
escli research "query" -o report.md -p ultra Higher quality processor
|
|
10
|
+
escli research "query" --schema schema.json -o out.json Structured JSON output
|
|
11
|
+
escli research "query" --include-domains sec.gov,wsj.com Only use specific sources
|
|
12
|
+
escli research status <run-id> Check task status
|
|
13
|
+
escli research result <run-id> -o output.md Fetch completed result
|
|
14
|
+
|
|
15
|
+
Processors (ascending quality/cost/time):
|
|
16
|
+
lite, base, core, core2x, pro, ultra, ultra2x, ultra4x, ultra8x
|
|
17
|
+
Add -fast suffix for speed-optimized variants
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import argparse
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import pathlib
|
|
24
|
+
import sys
|
|
25
|
+
import time
|
|
26
|
+
import urllib.request
|
|
27
|
+
import urllib.error
|
|
28
|
+
from datetime import datetime, timezone
|
|
29
|
+
|
|
30
|
+
from ..services.credentials import get_key_for_service
|
|
31
|
+
|
|
32
|
+
API_BASE = "https://api.parallel.ai"
|
|
33
|
+
SSE_BETA = "events-sse-2025-07-24"
|
|
34
|
+
MAX_POLL_WAIT = 7200 # 2 hours (ultra8x can take up to 2h)
|
|
35
|
+
POLL_INTERVAL = 10
|
|
36
|
+
|
|
37
|
+
PROCESSORS = [
|
|
38
|
+
"lite", "base", "core", "core2x", "pro", "ultra",
|
|
39
|
+
"ultra2x", "ultra4x", "ultra8x",
|
|
40
|
+
"lite-fast", "base-fast", "core-fast", "core2x-fast",
|
|
41
|
+
"pro-fast", "ultra-fast", "ultra2x-fast", "ultra4x-fast", "ultra8x-fast",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _get_api_key() -> str:
|
|
46
|
+
key = get_key_for_service("parallel", "PARALLEL_API_KEY")
|
|
47
|
+
if not key:
|
|
48
|
+
print(" ✗ no Parallel API key. Set PARALLEL_API_KEY or add one via the dashboard.", file=sys.stderr)
|
|
49
|
+
sys.exit(1)
|
|
50
|
+
return key
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _api_request(method: str, path: str, api_key: str,
|
|
54
|
+
body: dict | None = None, extra_headers: dict | None = None) -> dict:
|
|
55
|
+
url = f"{API_BASE}{path}"
|
|
56
|
+
hdrs = {"x-api-key": api_key, "Content-Type": "application/json"}
|
|
57
|
+
if extra_headers:
|
|
58
|
+
hdrs.update(extra_headers)
|
|
59
|
+
|
|
60
|
+
data = json.dumps(body).encode() if body else None
|
|
61
|
+
req = urllib.request.Request(url, data=data, headers=hdrs, method=method)
|
|
62
|
+
|
|
63
|
+
try:
|
|
64
|
+
with urllib.request.urlopen(req, timeout=60) as resp:
|
|
65
|
+
return json.loads(resp.read().decode())
|
|
66
|
+
except urllib.error.HTTPError as e:
|
|
67
|
+
try:
|
|
68
|
+
err_body = json.loads(e.read().decode())
|
|
69
|
+
except Exception:
|
|
70
|
+
err_body = {"error": {"message": str(e)}}
|
|
71
|
+
|
|
72
|
+
msg = err_body.get("error", {}).get("message", str(e))
|
|
73
|
+
if e.code == 401:
|
|
74
|
+
print(f" ✗ auth failed (401): {msg}", file=sys.stderr); sys.exit(1)
|
|
75
|
+
elif e.code == 429:
|
|
76
|
+
print(f" ✗ rate limited (429): {msg}", file=sys.stderr); sys.exit(2)
|
|
77
|
+
else:
|
|
78
|
+
print(f" ✗ API error ({e.code}): {msg}", file=sys.stderr); sys.exit(2)
|
|
79
|
+
except urllib.error.URLError as e:
|
|
80
|
+
print(f" ✗ network error: {e.reason}", file=sys.stderr); sys.exit(2)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# ── SSE streaming ────────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
def _stream_sse(api_key: str, run_id: str, quiet: bool = False) -> dict | None:
|
|
86
|
+
url = f"{API_BASE}/v1beta/tasks/runs/{run_id}/events"
|
|
87
|
+
seen = set()
|
|
88
|
+
|
|
89
|
+
for attempt in range(25):
|
|
90
|
+
if attempt > 0 and not quiet:
|
|
91
|
+
print(f" [sse] reconnecting ({attempt + 1})...", file=sys.stderr)
|
|
92
|
+
|
|
93
|
+
req = urllib.request.Request(url, headers={
|
|
94
|
+
"x-api-key": api_key,
|
|
95
|
+
"Accept": "text/event-stream",
|
|
96
|
+
"parallel-beta": SSE_BETA,
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
try:
|
|
100
|
+
resp = urllib.request.urlopen(req, timeout=600)
|
|
101
|
+
except Exception as e:
|
|
102
|
+
if not quiet:
|
|
103
|
+
print(f" [sse] failed: {e}", file=sys.stderr)
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
try:
|
|
107
|
+
for raw_line in resp:
|
|
108
|
+
line = raw_line.decode("utf-8", errors="replace").rstrip("\n\r")
|
|
109
|
+
if not line.startswith("data: "):
|
|
110
|
+
continue
|
|
111
|
+
try:
|
|
112
|
+
event = json.loads(line[6:])
|
|
113
|
+
except json.JSONDecodeError:
|
|
114
|
+
continue
|
|
115
|
+
|
|
116
|
+
etype = event.get("type", "")
|
|
117
|
+
|
|
118
|
+
if etype == "task_run.progress_msg.exec_status" and not quiet:
|
|
119
|
+
msg = event.get("message", "")
|
|
120
|
+
if ("progress", msg) not in seen:
|
|
121
|
+
seen.add(("progress", msg))
|
|
122
|
+
print(f" [progress] {msg}", file=sys.stderr)
|
|
123
|
+
|
|
124
|
+
elif etype == "task_run.progress_stats" and not quiet:
|
|
125
|
+
meter = event.get("progress_meter", "")
|
|
126
|
+
if ("stats", str(meter)) not in seen:
|
|
127
|
+
seen.add(("stats", str(meter)))
|
|
128
|
+
stats = event.get("source_stats", {})
|
|
129
|
+
read = stats.get("num_sources_read", 0)
|
|
130
|
+
print(f" [progress] {meter}% — {read} sources read", file=sys.stderr)
|
|
131
|
+
|
|
132
|
+
elif etype == "task_run.state":
|
|
133
|
+
run = event.get("run", {})
|
|
134
|
+
status = run.get("status", "")
|
|
135
|
+
if status == "completed":
|
|
136
|
+
return event.get("output")
|
|
137
|
+
elif status == "failed":
|
|
138
|
+
print(f" ✗ task failed: {run.get('error', '')}", file=sys.stderr)
|
|
139
|
+
sys.exit(2)
|
|
140
|
+
|
|
141
|
+
elif etype == "error":
|
|
142
|
+
return None
|
|
143
|
+
except Exception:
|
|
144
|
+
pass
|
|
145
|
+
finally:
|
|
146
|
+
resp.close()
|
|
147
|
+
|
|
148
|
+
return None
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _poll_until_complete(api_key: str, run_id: str, quiet: bool = False) -> dict:
|
|
152
|
+
start = time.time()
|
|
153
|
+
while time.time() - start < MAX_POLL_WAIT:
|
|
154
|
+
result = _api_request("GET", f"/v1/tasks/runs/{run_id}", api_key)
|
|
155
|
+
status = result.get("status", "")
|
|
156
|
+
if not quiet:
|
|
157
|
+
print(f" [poll] status={status}", file=sys.stderr)
|
|
158
|
+
|
|
159
|
+
if status == "completed":
|
|
160
|
+
result = _api_request("GET", f"/v1/tasks/runs/{run_id}/result", api_key)
|
|
161
|
+
return result.get("output")
|
|
162
|
+
elif status in ("failed", "cancelled"):
|
|
163
|
+
print(f" ✗ task {status}: {result.get('error', '')}", file=sys.stderr)
|
|
164
|
+
sys.exit(2)
|
|
165
|
+
|
|
166
|
+
time.sleep(POLL_INTERVAL)
|
|
167
|
+
|
|
168
|
+
print(" ✗ timed out.", file=sys.stderr); sys.exit(3)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
# ── Markdown formatting ──────────────────────────────────────────
|
|
172
|
+
|
|
173
|
+
def _format_markdown(query: str, processor: str, run_id: str, output: dict | None,
|
|
174
|
+
created_at: str, include_basis: bool = True) -> str:
|
|
175
|
+
now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
176
|
+
lines = [
|
|
177
|
+
"---", f'query: "{query}"', f"processor: {processor}",
|
|
178
|
+
f"run_id: {run_id}", f"created_at: {created_at}", f"retrieved_at: {now}",
|
|
179
|
+
"---", "", f"# Research: {query}", "",
|
|
180
|
+
]
|
|
181
|
+
|
|
182
|
+
if output is None:
|
|
183
|
+
lines.append("*No output returned.*")
|
|
184
|
+
return "\n".join(lines)
|
|
185
|
+
|
|
186
|
+
content = output.get("content")
|
|
187
|
+
basis = output.get("basis", [])
|
|
188
|
+
|
|
189
|
+
if isinstance(content, str):
|
|
190
|
+
# Could be JSON string or plain text
|
|
191
|
+
try:
|
|
192
|
+
parsed = json.loads(content)
|
|
193
|
+
if isinstance(parsed, dict):
|
|
194
|
+
_render_dict(lines, parsed)
|
|
195
|
+
else:
|
|
196
|
+
lines.append(content)
|
|
197
|
+
except (json.JSONDecodeError, TypeError):
|
|
198
|
+
lines.append(content)
|
|
199
|
+
elif isinstance(content, dict):
|
|
200
|
+
_render_dict(lines, content)
|
|
201
|
+
elif content is not None:
|
|
202
|
+
lines.append(str(content))
|
|
203
|
+
|
|
204
|
+
if include_basis and basis:
|
|
205
|
+
lines.extend(["", "## Research Basis", ""])
|
|
206
|
+
for entry in basis:
|
|
207
|
+
field = entry.get("field", "unknown")
|
|
208
|
+
reasoning = entry.get("reasoning", "")
|
|
209
|
+
confidence = entry.get("confidence", "")
|
|
210
|
+
citations = entry.get("citations", [])
|
|
211
|
+
lines.append(f"### {field}")
|
|
212
|
+
if confidence:
|
|
213
|
+
lines.append(f"**Confidence:** {confidence}")
|
|
214
|
+
if reasoning:
|
|
215
|
+
lines.append(f"\n{reasoning}")
|
|
216
|
+
if citations:
|
|
217
|
+
lines.append("")
|
|
218
|
+
for cite in citations:
|
|
219
|
+
url = cite.get("url", "")
|
|
220
|
+
title = cite.get("title", url)
|
|
221
|
+
lines.append(f"- [{title}]({url})")
|
|
222
|
+
for exc in cite.get("excerpts", []):
|
|
223
|
+
lines.append(f" > {exc}")
|
|
224
|
+
lines.append("")
|
|
225
|
+
|
|
226
|
+
return "\n".join(lines)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _render_dict(lines: list[str], d: dict):
|
|
230
|
+
for key, value in d.items():
|
|
231
|
+
lines.append(f"## {key.replace('_', ' ').title()}")
|
|
232
|
+
lines.append("")
|
|
233
|
+
if isinstance(value, str):
|
|
234
|
+
lines.append(value)
|
|
235
|
+
elif isinstance(value, list):
|
|
236
|
+
if value and all(isinstance(i, dict) for i in value):
|
|
237
|
+
_render_table(lines, value)
|
|
238
|
+
else:
|
|
239
|
+
for item in value:
|
|
240
|
+
if isinstance(item, dict):
|
|
241
|
+
for k, v in item.items():
|
|
242
|
+
lines.append(f"**{k.replace('_', ' ').title()}:** {v}")
|
|
243
|
+
lines.append("")
|
|
244
|
+
else:
|
|
245
|
+
lines.append(f"- {item}")
|
|
246
|
+
elif isinstance(value, dict):
|
|
247
|
+
for k, v in value.items():
|
|
248
|
+
lines.append(f"**{k.replace('_', ' ').title()}:** {v}")
|
|
249
|
+
else:
|
|
250
|
+
lines.append(str(value))
|
|
251
|
+
lines.append("")
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _render_table(lines: list[str], items: list[dict]):
|
|
255
|
+
keys = []
|
|
256
|
+
for item in items:
|
|
257
|
+
for k in item:
|
|
258
|
+
if k not in keys:
|
|
259
|
+
keys.append(k)
|
|
260
|
+
headers = [k.replace("_", " ").title() for k in keys]
|
|
261
|
+
lines.append("| " + " | ".join(headers) + " |")
|
|
262
|
+
lines.append("| " + " | ".join("---" for _ in keys) + " |")
|
|
263
|
+
for item in items:
|
|
264
|
+
row = [str(item.get(k, "")).replace("\n", " ").replace("|", "\\|") for k in keys]
|
|
265
|
+
lines.append("| " + " | ".join(row) + " |")
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
# ── Commands ─────────────────────────────────────────────────────
|
|
269
|
+
|
|
270
|
+
def cmd_run(args):
|
|
271
|
+
"""Create and execute a research task."""
|
|
272
|
+
api_key = _get_api_key()
|
|
273
|
+
query = " ".join(args.query)
|
|
274
|
+
processor = args.processor
|
|
275
|
+
|
|
276
|
+
if not args.quiet:
|
|
277
|
+
print(f" ▸ submitting task (processor={processor})...", file=sys.stderr)
|
|
278
|
+
|
|
279
|
+
# Build request body
|
|
280
|
+
body: dict = {
|
|
281
|
+
"processor": processor,
|
|
282
|
+
"input": query,
|
|
283
|
+
"enable_events": True,
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
# Input: JSON object from file or --input-json
|
|
287
|
+
if getattr(args, "input_json", None):
|
|
288
|
+
try:
|
|
289
|
+
body["input"] = json.loads(args.input_json)
|
|
290
|
+
except json.JSONDecodeError:
|
|
291
|
+
print(" ✗ --input-json must be valid JSON", file=sys.stderr); return 1
|
|
292
|
+
|
|
293
|
+
if getattr(args, "input_file", None):
|
|
294
|
+
path = pathlib.Path(args.input_file)
|
|
295
|
+
if not path.exists():
|
|
296
|
+
print(f" ✗ input file not found: {path}", file=sys.stderr); return 1
|
|
297
|
+
body["input"] = json.loads(path.read_text())
|
|
298
|
+
|
|
299
|
+
# Output schema
|
|
300
|
+
if getattr(args, "schema", None):
|
|
301
|
+
schema_path = pathlib.Path(args.schema)
|
|
302
|
+
if not schema_path.exists():
|
|
303
|
+
print(f" ✗ schema file not found: {schema_path}", file=sys.stderr); return 1
|
|
304
|
+
schema_data = json.loads(schema_path.read_text())
|
|
305
|
+
body["task_spec"] = {"output_schema": {"type": "json", "json_schema": schema_data}}
|
|
306
|
+
elif getattr(args, "output_schema", None):
|
|
307
|
+
body["task_spec"] = {"output_schema": args.output_schema}
|
|
308
|
+
elif getattr(args, "text", False):
|
|
309
|
+
body["task_spec"] = {"output_schema": {"type": "text"}}
|
|
310
|
+
# else: auto (default for pro+ processors)
|
|
311
|
+
|
|
312
|
+
# Source policy
|
|
313
|
+
source_policy = {}
|
|
314
|
+
if getattr(args, "include_domains", None):
|
|
315
|
+
source_policy["include_domains"] = [d.strip() for d in args.include_domains.split(",")]
|
|
316
|
+
if getattr(args, "exclude_domains", None):
|
|
317
|
+
source_policy["exclude_domains"] = [d.strip() for d in args.exclude_domains.split(",")]
|
|
318
|
+
if getattr(args, "after_date", None):
|
|
319
|
+
source_policy["after_date"] = args.after_date
|
|
320
|
+
if source_policy:
|
|
321
|
+
body["source_policy"] = source_policy
|
|
322
|
+
|
|
323
|
+
# Advanced settings
|
|
324
|
+
if getattr(args, "location", None):
|
|
325
|
+
body["advanced_settings"] = {"location": args.location}
|
|
326
|
+
|
|
327
|
+
# Metadata
|
|
328
|
+
if getattr(args, "metadata", None):
|
|
329
|
+
try:
|
|
330
|
+
body["metadata"] = json.loads(args.metadata)
|
|
331
|
+
except json.JSONDecodeError:
|
|
332
|
+
# Parse key=value pairs
|
|
333
|
+
meta = {}
|
|
334
|
+
for pair in args.metadata.split(","):
|
|
335
|
+
if "=" in pair:
|
|
336
|
+
k, v = pair.split("=", 1)
|
|
337
|
+
meta[k.strip()] = v.strip()
|
|
338
|
+
body["metadata"] = meta
|
|
339
|
+
|
|
340
|
+
# Follow-up on previous interaction
|
|
341
|
+
if getattr(args, "follow_up", None):
|
|
342
|
+
body["previous_interaction_id"] = args.follow_up
|
|
343
|
+
|
|
344
|
+
# Submit
|
|
345
|
+
headers = {"parallel-beta": SSE_BETA}
|
|
346
|
+
task = _api_request("POST", "/v1/tasks/runs", api_key, body=body, extra_headers=headers)
|
|
347
|
+
run_id = task["run_id"]
|
|
348
|
+
created_at = task.get("created_at", "")
|
|
349
|
+
|
|
350
|
+
if not args.quiet:
|
|
351
|
+
print(f" · run_id: {run_id}", file=sys.stderr)
|
|
352
|
+
|
|
353
|
+
# Stream progress + wait for result
|
|
354
|
+
if not args.quiet:
|
|
355
|
+
print(" ░░░░░░░░░░░░░░░░░░░░ researching...", file=sys.stderr)
|
|
356
|
+
|
|
357
|
+
output = _stream_sse(api_key, run_id, args.quiet)
|
|
358
|
+
if output is None:
|
|
359
|
+
if not args.quiet:
|
|
360
|
+
print(" [fallback] polling...", file=sys.stderr)
|
|
361
|
+
output = _poll_until_complete(api_key, run_id, args.quiet)
|
|
362
|
+
|
|
363
|
+
# Output
|
|
364
|
+
out_path = pathlib.Path(args.output) if args.output else None
|
|
365
|
+
|
|
366
|
+
if args.json:
|
|
367
|
+
result = {
|
|
368
|
+
"success": True, "run_id": run_id, "processor": processor,
|
|
369
|
+
"output": output,
|
|
370
|
+
}
|
|
371
|
+
if out_path:
|
|
372
|
+
result["path"] = str(out_path.resolve())
|
|
373
|
+
text = json.dumps(result, indent=2)
|
|
374
|
+
if out_path:
|
|
375
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
376
|
+
out_path.write_text(text, encoding="utf-8")
|
|
377
|
+
print(text)
|
|
378
|
+
else:
|
|
379
|
+
md = _format_markdown(query, processor, run_id, output, created_at,
|
|
380
|
+
include_basis=not getattr(args, "no_basis", False))
|
|
381
|
+
if out_path:
|
|
382
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
383
|
+
out_path.write_text(md, encoding="utf-8")
|
|
384
|
+
if not args.quiet:
|
|
385
|
+
print(f"\n ✓ {out_path}", file=sys.stderr)
|
|
386
|
+
if args.quiet:
|
|
387
|
+
print(str(out_path.resolve()))
|
|
388
|
+
else:
|
|
389
|
+
print(md)
|
|
390
|
+
|
|
391
|
+
return 0
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def cmd_status(args):
|
|
395
|
+
"""Check task run status."""
|
|
396
|
+
api_key = _get_api_key()
|
|
397
|
+
result = _api_request("GET", f"/v1/tasks/runs/{args.run_id}", api_key)
|
|
398
|
+
|
|
399
|
+
if args.json:
|
|
400
|
+
print(json.dumps({"success": True, **result}))
|
|
401
|
+
else:
|
|
402
|
+
status = result.get("status", "unknown")
|
|
403
|
+
print(f" {args.run_id}: {status}")
|
|
404
|
+
if result.get("error"):
|
|
405
|
+
print(f" error: {result['error']}")
|
|
406
|
+
if result.get("warnings"):
|
|
407
|
+
for w in result["warnings"]:
|
|
408
|
+
print(f" warning: {w.get('message', '')}")
|
|
409
|
+
return 0
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def cmd_processors(args):
|
|
413
|
+
"""List available processor tiers."""
|
|
414
|
+
STANDARD = [
|
|
415
|
+
("lite", "10s – 60s", "Basic metadata, low latency", "~2"),
|
|
416
|
+
("base", "15s – 100s", "Reliable standard enrichments", "~5"),
|
|
417
|
+
("core", "60s – 5min", "Cross-referenced, moderate complexity", "~10"),
|
|
418
|
+
("core2x", "60s – 10min", "High complexity cross-referenced", "~10"),
|
|
419
|
+
("pro", "2min – 10min", "Exploratory web research", "~20"),
|
|
420
|
+
("ultra", "5min – 25min", "Advanced multi-source deep research", "~20"),
|
|
421
|
+
("ultra2x", "5min – 50min", "Difficult deep research", "~25"),
|
|
422
|
+
("ultra4x", "5min – 90min", "Very difficult deep research", "~25"),
|
|
423
|
+
("ultra8x", "5min – 2hr", "Most difficult deep research", "~25"),
|
|
424
|
+
]
|
|
425
|
+
FAST = [
|
|
426
|
+
("lite-fast", "10s – 20s", "Lowest latency", "~2"),
|
|
427
|
+
("base-fast", "15s – 50s", "Fast standard enrichments", "~5"),
|
|
428
|
+
("core-fast", "15s – 100s", "Fast cross-referenced", "~10"),
|
|
429
|
+
("core2x-fast", "15s – 3min", "Fast high complexity", "~10"),
|
|
430
|
+
("pro-fast", "30s – 5min", "Fast exploratory research", "~20"),
|
|
431
|
+
("ultra-fast", "1min – 10min", "Fast deep research", "~20"),
|
|
432
|
+
("ultra2x-fast", "1min – 20min", "Fast difficult research", "~25"),
|
|
433
|
+
("ultra4x-fast", "1min – 40min", "Fast very difficult research", "~25"),
|
|
434
|
+
("ultra8x-fast", "1min – 1hr", "Fast most difficult research", "~25"),
|
|
435
|
+
]
|
|
436
|
+
|
|
437
|
+
if args.json:
|
|
438
|
+
all_procs = []
|
|
439
|
+
for name, latency, desc, fields in STANDARD:
|
|
440
|
+
all_procs.append({"name": name, "variant": "standard", "latency": latency, "description": desc, "max_fields": fields})
|
|
441
|
+
for name, latency, desc, fields in FAST:
|
|
442
|
+
all_procs.append({"name": name, "variant": "fast", "latency": latency, "description": desc, "max_fields": fields})
|
|
443
|
+
print(json.dumps({"success": True, "processors": all_procs}))
|
|
444
|
+
return 0
|
|
445
|
+
|
|
446
|
+
print("\n Standard processors (prioritize data freshness):\n")
|
|
447
|
+
print(f" {'PROCESSOR':<12} {'LATENCY':<16} {'MAX FIELDS':<12} {'STRENGTHS'}")
|
|
448
|
+
print(f" {'─' * 75}")
|
|
449
|
+
for name, latency, desc, fields in STANDARD:
|
|
450
|
+
print(f" {name:<12} {latency:<16} {fields:<12} {desc}")
|
|
451
|
+
|
|
452
|
+
print("\n Fast processors (prioritize speed, 2-5x faster):\n")
|
|
453
|
+
print(f" {'PROCESSOR':<16} {'LATENCY':<16} {'MAX FIELDS':<12} {'STRENGTHS'}")
|
|
454
|
+
print(f" {'─' * 75}")
|
|
455
|
+
for name, latency, desc, fields in FAST:
|
|
456
|
+
print(f" {name:<16} {latency:<16} {fields:<12} {desc}")
|
|
457
|
+
|
|
458
|
+
print(f"""
|
|
459
|
+
Notes:
|
|
460
|
+
· Standard processors prioritize freshness — best for accuracy-critical tasks
|
|
461
|
+
· Fast processors are 2-5x faster — best for interactive/agent workflows
|
|
462
|
+
· 'auto' output schema enables Deep Research for pro and above
|
|
463
|
+
· Max fields are approximate — complex fields use more capacity
|
|
464
|
+
· Pricing: docs.parallel.ai/getting-started/pricing
|
|
465
|
+
""")
|
|
466
|
+
return 0
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def cmd_result(args):
|
|
470
|
+
"""Fetch completed task result."""
|
|
471
|
+
api_key = _get_api_key()
|
|
472
|
+
|
|
473
|
+
# Check status first
|
|
474
|
+
run = _api_request("GET", f"/v1/tasks/runs/{args.run_id}", api_key)
|
|
475
|
+
if run.get("status") != "completed":
|
|
476
|
+
if args.json:
|
|
477
|
+
print(json.dumps({"success": False, "status": run.get("status"), "error": run.get("error")}))
|
|
478
|
+
else:
|
|
479
|
+
print(f" ✗ task not complete: {run.get('status')}", file=sys.stderr)
|
|
480
|
+
return 1
|
|
481
|
+
|
|
482
|
+
result = _api_request("GET", f"/v1/tasks/runs/{args.run_id}/result", api_key)
|
|
483
|
+
output = result.get("output")
|
|
484
|
+
out_path = pathlib.Path(args.output) if args.output else None
|
|
485
|
+
|
|
486
|
+
if args.json:
|
|
487
|
+
text = json.dumps({"success": True, "run_id": args.run_id, "output": output}, indent=2)
|
|
488
|
+
if out_path:
|
|
489
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
490
|
+
out_path.write_text(text, encoding="utf-8")
|
|
491
|
+
print(text)
|
|
492
|
+
else:
|
|
493
|
+
md = _format_markdown("(retrieved)", run.get("processor", ""), args.run_id,
|
|
494
|
+
output, run.get("created_at", ""),
|
|
495
|
+
include_basis=not getattr(args, "no_basis", False))
|
|
496
|
+
if out_path:
|
|
497
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
498
|
+
out_path.write_text(md, encoding="utf-8")
|
|
499
|
+
if not args.quiet:
|
|
500
|
+
print(f" ✓ {out_path}", file=sys.stderr)
|
|
501
|
+
else:
|
|
502
|
+
print(md)
|
|
503
|
+
|
|
504
|
+
return 0
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
# ── Parser ───────────────────────────────────────────────────────
|
|
508
|
+
|
|
509
|
+
def register(subparsers):
|
|
510
|
+
"""Register the research subcommand."""
|
|
511
|
+
F = argparse.RawDescriptionHelpFormatter
|
|
512
|
+
|
|
513
|
+
p = subparsers.add_parser(
|
|
514
|
+
"research", aliases=["r"], help="Web research tasks (Parallel Task API)",
|
|
515
|
+
formatter_class=F,
|
|
516
|
+
epilog="""modes:
|
|
517
|
+
escli research "query" -o report.md Run a research task (default)
|
|
518
|
+
escli research --processors List available processor tiers
|
|
519
|
+
escli research --status <run-id> Check task status
|
|
520
|
+
escli research --result <run-id> -o out.md Fetch completed result
|
|
521
|
+
|
|
522
|
+
examples:
|
|
523
|
+
escli research "HVAC industry market report" -o hvac.md
|
|
524
|
+
escli research "Stripe" --output-schema "founding year and total funding" -p base
|
|
525
|
+
escli research "AI startups 2026" -o ai.md -p ultra --after-date 2026-01-01
|
|
526
|
+
escli research "competitive analysis of CRM" -o crm.md --text --include-domains g2.com,gartner.com
|
|
527
|
+
escli research "Stripe" --schema enrichment.json -o stripe.json -p core
|
|
528
|
+
escli research "follow-up on API?" -o followup.md --follow-up trun_xxx
|
|
529
|
+
|
|
530
|
+
processors (ascending quality):
|
|
531
|
+
lite → base → core → core2x → pro → ultra → ultra2x → ultra4x → ultra8x
|
|
532
|
+
Append -fast for speed (e.g. pro-fast, ultra-fast)
|
|
533
|
+
|
|
534
|
+
output modes:
|
|
535
|
+
(default) Auto — processor determines structure (deep research for pro+)
|
|
536
|
+
--text Markdown report with inline citations
|
|
537
|
+
--schema FILE Structured JSON output per your JSON Schema file
|
|
538
|
+
--output-schema S Inline schema description string
|
|
539
|
+
""")
|
|
540
|
+
|
|
541
|
+
# Query (positional, optional — not needed for --status/--result/--processors)
|
|
542
|
+
p.add_argument("query", nargs="*", help="Research question or topic")
|
|
543
|
+
|
|
544
|
+
# Mode flags (mutually exclusive with running a query)
|
|
545
|
+
mode_g = p.add_argument_group("modes")
|
|
546
|
+
mode_g.add_argument("--processors", action="store_true", help="List available processor tiers")
|
|
547
|
+
mode_g.add_argument("--status", default=None, metavar="RUN_ID", help="Check task run status")
|
|
548
|
+
mode_g.add_argument("--result", default=None, metavar="RUN_ID", help="Fetch completed task result")
|
|
549
|
+
|
|
550
|
+
# Run options
|
|
551
|
+
p.add_argument("-o", "--output", default=None, help="Output file path (markdown or JSON)")
|
|
552
|
+
p.add_argument("-p", "--processor", default="pro", choices=PROCESSORS, help="Processor tier (default: pro)")
|
|
553
|
+
|
|
554
|
+
# Output schema
|
|
555
|
+
schema_g = p.add_argument_group("output schema")
|
|
556
|
+
schema_g.add_argument("--text", action="store_true", help="Markdown report format")
|
|
557
|
+
schema_g.add_argument("--schema", default=None, metavar="FILE", help="JSON Schema file for structured output")
|
|
558
|
+
schema_g.add_argument("--output-schema", default=None, metavar="STR", help="Inline output schema description")
|
|
559
|
+
|
|
560
|
+
# Input
|
|
561
|
+
input_g = p.add_argument_group("input")
|
|
562
|
+
input_g.add_argument("--input-json", default=None, metavar="JSON", help="JSON object as input (instead of text)")
|
|
563
|
+
input_g.add_argument("--input-file", default=None, metavar="FILE", help="JSON file as input")
|
|
564
|
+
|
|
565
|
+
# Source policy
|
|
566
|
+
source_g = p.add_argument_group("source policy")
|
|
567
|
+
source_g.add_argument("--include-domains", default=None, metavar="D1,D2", help="Only use these domains")
|
|
568
|
+
source_g.add_argument("--exclude-domains", default=None, metavar="D1,D2", help="Exclude these domains")
|
|
569
|
+
source_g.add_argument("--after-date", default=None, metavar="YYYY-MM-DD", help="Only content after this date")
|
|
570
|
+
|
|
571
|
+
# Advanced
|
|
572
|
+
adv_g = p.add_argument_group("advanced")
|
|
573
|
+
adv_g.add_argument("--location", default=None, metavar="CC", help="ISO country code for geo-targeted results")
|
|
574
|
+
adv_g.add_argument("--metadata", default=None, help="Metadata as JSON or key=val,key=val")
|
|
575
|
+
adv_g.add_argument("--follow-up", default=None, metavar="RUN_ID", help="Follow-up on a previous task run")
|
|
576
|
+
adv_g.add_argument("--no-basis", action="store_true", help="Exclude citations and reasoning")
|
|
577
|
+
|
|
578
|
+
p.set_defaults(func=_dispatch)
|
|
579
|
+
|
|
580
|
+
return p
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def _dispatch(args):
|
|
584
|
+
"""Route to the right handler based on flags."""
|
|
585
|
+
if getattr(args, "processors", False):
|
|
586
|
+
return cmd_processors(args)
|
|
587
|
+
if getattr(args, "status", None):
|
|
588
|
+
args.run_id = args.status
|
|
589
|
+
return cmd_status(args)
|
|
590
|
+
if getattr(args, "result", None):
|
|
591
|
+
args.run_id = args.result
|
|
592
|
+
return cmd_result(args)
|
|
593
|
+
if not args.query:
|
|
594
|
+
print(" usage: escli research \"query\" -o output.md", file=sys.stderr)
|
|
595
|
+
print(" run 'escli research --processors' to see available tiers", file=sys.stderr)
|
|
596
|
+
return 2
|
|
597
|
+
return cmd_run(args)
|