research-assistant 0.1.0
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.
- package/LICENSE +21 -0
- package/README-ZH.md +115 -0
- package/README.md +124 -0
- package/npm/bin/research-assistant.js +70 -0
- package/npm/scripts/postinstall.js +112 -0
- package/package.json +38 -0
- package/pyproject.toml +84 -0
- package/src/research_assistant/__init__.py +7 -0
- package/src/research_assistant/__main__.py +6 -0
- package/src/research_assistant/assets/researcher_persona.md +94 -0
- package/src/research_assistant/assets/skills/research-assistant/SKILL.md +248 -0
- package/src/research_assistant/cli.py +155 -0
- package/src/research_assistant/commands/__init__.py +7 -0
- package/src/research_assistant/commands/ask.py +54 -0
- package/src/research_assistant/commands/doctor.py +201 -0
- package/src/research_assistant/commands/fetch.py +99 -0
- package/src/research_assistant/commands/locate.py +71 -0
- package/src/research_assistant/commands/search.py +161 -0
- package/src/research_assistant/commands/setup.py +249 -0
- package/src/research_assistant/commands/skills.py +60 -0
- package/src/research_assistant/config.py +258 -0
- package/src/research_assistant/errors.py +106 -0
- package/src/research_assistant/fetch/__init__.py +20 -0
- package/src/research_assistant/fetch/browser.py +613 -0
- package/src/research_assistant/fetch/cfbypass.py +318 -0
- package/src/research_assistant/fetch/html2md.py +44 -0
- package/src/research_assistant/fetch/normal.py +69 -0
- package/src/research_assistant/fetch/search_engine.py +324 -0
- package/src/research_assistant/fetch/snapshot.py +50 -0
- package/src/research_assistant/http.py +118 -0
- package/src/research_assistant/installer.py +270 -0
- package/src/research_assistant/locate/__init__.py +5 -0
- package/src/research_assistant/locate/engine.py +289 -0
- package/src/research_assistant/loginstate/__init__.py +10 -0
- package/src/research_assistant/loginstate/daemon.py +145 -0
- package/src/research_assistant/loginstate/daemon_cli.py +21 -0
- package/src/research_assistant/loginstate/daemonctl.py +116 -0
- package/src/research_assistant/loginstate/extension/background.js +167 -0
- package/src/research_assistant/loginstate/extension/manifest.json +15 -0
- package/src/research_assistant/loginstate/extension/popup.html +31 -0
- package/src/research_assistant/loginstate/extension/popup.js +33 -0
- package/src/research_assistant/output.py +140 -0
- package/src/research_assistant/providers/__init__.py +10 -0
- package/src/research_assistant/providers/base.py +93 -0
- package/src/research_assistant/providers/browser.py +91 -0
- package/src/research_assistant/providers/context7.py +171 -0
- package/src/research_assistant/providers/exa.py +353 -0
- package/src/research_assistant/providers/firecrawl.py +644 -0
- package/src/research_assistant/providers/openai_compat.py +293 -0
- package/src/research_assistant/providers/registry.py +62 -0
- package/src/research_assistant/providers/tavily.py +346 -0
- package/src/research_assistant/proxy.py +58 -0
- package/src/research_assistant/targets.py +66 -0
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: research-assistant
|
|
3
|
+
description: Search the open web, fetch official library docs, pull page content past logins or anti-bot challenges, and locate passages in long documents. This skill is a set of concurrent CLI tools that gather candidate sources and pages; it does not synthesize answers. It covers web search for sources, library/framework/SDK/CLI/cloud docs lookup, fetching pages behind Cloudflare or a login, and locating where a topic sits in a long document.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# research-assistant
|
|
7
|
+
|
|
8
|
+
`research-assistant` is a set of concurrent CLI atomic tools for gathering external
|
|
9
|
+
information: web search providers, official library docs, cross-tool page fetch, and
|
|
10
|
+
anchor-based locate for long local documents. It is **middleware**. It collects sources and
|
|
11
|
+
pages; you read them and synthesize your own answer.
|
|
12
|
+
|
|
13
|
+
## Output boundary
|
|
14
|
+
|
|
15
|
+
These tools gather sources and pages. They do not synthesize answers, do not rank which source
|
|
16
|
+
to trust when sources disagree, and do not decide correctness. You read what they return and
|
|
17
|
+
draw your own conclusions.
|
|
18
|
+
|
|
19
|
+
## How to read this skill
|
|
20
|
+
|
|
21
|
+
The Capability map and the Commands list describe what each tool is good at and when to use
|
|
22
|
+
it. Treat those as guidance for picking a tool, not as a fixed routing order. The tier numbers
|
|
23
|
+
(Tier 1/2/3) express a preference for the faster, cheaper tool first, not a hard rule: if you
|
|
24
|
+
already know a page needs a browser, go straight to `fetch`. Anything in a command's inline
|
|
25
|
+
comment is a strength hint, not a contract.
|
|
26
|
+
|
|
27
|
+
## Primary tools: fetch, search, ask
|
|
28
|
+
|
|
29
|
+
Three commands do the heavy lifting and need no configuration to start. Reach for them first.
|
|
30
|
+
|
|
31
|
+
### `fetch <url>...`: cross-tool page fetch with automatic escalation
|
|
32
|
+
|
|
33
|
+
`fetch` tries the cheap path first and climbs only on failure: the normal APIs (tavily extract,
|
|
34
|
+
firecrawl scrape), then a headed real browser with Cloudflare auto-detect. Login cookies are
|
|
35
|
+
injected through the bundled extension bridge, so logged-in pages work without re-entering
|
|
36
|
+
credentials. It works out of the box: with zero provider config the normal APIs simply fail and
|
|
37
|
+
the browser fallback still returns the page.
|
|
38
|
+
|
|
39
|
+
### `search "<query>"`: aggregated multi-source search, browser included by default
|
|
40
|
+
|
|
41
|
+
`search` fans the query across the configured search APIs (exa, tavily) and the browser search
|
|
42
|
+
engine, then merges and de-duplicates the candidates. One source failing does not block the
|
|
43
|
+
others. The browser source is always in the default set, so `search` works with zero provider
|
|
44
|
+
config: with nothing configured it still runs the headless browser search engine and returns
|
|
45
|
+
candidates. Pass `--providers exa,tavily` to restrict to the fast APIs only.
|
|
46
|
+
|
|
47
|
+
### When to reach for browser search
|
|
48
|
+
|
|
49
|
+
Reach for `browser search`, or the browser source inside `search`, when the APIs come back empty
|
|
50
|
+
or shallow. It is slower than the APIs, but it runs against the user's real Edge/Chromium and
|
|
51
|
+
searches the way a person does, so sites that hide behind strong anti-bot become reachable, and
|
|
52
|
+
those sites usually hide because their content is worth protecting. It carries the user's login
|
|
53
|
+
cookies and search history, so the results track their habits and the search engine's
|
|
54
|
+
personalization model. When exa and tavily give you nothing useful on a niche or protected topic,
|
|
55
|
+
fall back to browser search before you give up. It runs headless, so it does not steal focus from
|
|
56
|
+
the user's daily browser.
|
|
57
|
+
|
|
58
|
+
### `ask "<question>"`: natural-language answer from a web-enabled LLM
|
|
59
|
+
|
|
60
|
+
`ask` sends your question to the configured `openai_compat` LLM (pick a model with built-in web
|
|
61
|
+
search) and returns its markdown answer plus an extracted citations list. Use it for a direct
|
|
62
|
+
answer to a small question. Treat the prose as a lead, not a fact: for anything that matters,
|
|
63
|
+
`fetch` the cited page and read it yourself.
|
|
64
|
+
|
|
65
|
+
## Capability map (pick by what the task needs; no hardcoded routing)
|
|
66
|
+
|
|
67
|
+
Four independent domains. Each tool has a distinct strength; choose per task, not by a fixed
|
|
68
|
+
order. Tool selection is YOUR decision.
|
|
69
|
+
|
|
70
|
+
### A. Web search and page fetch, tiered by anti-bot strength
|
|
71
|
+
|
|
72
|
+
When you need the open web, prefer the **lowest tier that can do the job**. API tools are
|
|
73
|
+
fastest because they skip browser rendering, and they cost the least. Escalate only when a
|
|
74
|
+
tier genuinely cannot return the content.
|
|
75
|
+
|
|
76
|
+
| Tier | Tool | Strength | Use when |
|
|
77
|
+
|------|------|----------|----------|
|
|
78
|
+
| 1. API | `exa`, `tavily` | Structured search APIs; clean JSON, no rendering. `tavily extract` returns ready markdown, `tavily map`/`crawl` cover a whole site; `exa` adds `similar`, `contents`, `answer`, and async `research`. Fast and cheap. | Simple or public pages; search with snippets; fast fetch of a known URL; site map or crawl. |
|
|
79
|
+
| 2. Firecrawl | `firecrawl` | Residential or home-bandwidth infrastructure; `scrape` and `search` are **keyless**, the other endpoints (`map`, `crawl`, `extract`, `interact`, `agent`, `monitor`, `parse`) need a key. More likely than Tier 1 to succeed on lightly protected pages, and the only tier with site-wide crawl and structured extraction. | A page refuses raw HTTP but shows no hard Cloudflare challenge. Batch scrape many URLs, crawl a whole site, or extract structured data with a schema. |
|
|
80
|
+
| 3. Browser | `fetch`, `browser` | Drives the user's real Edge/Chromium, same engine as the daily browser so login cookies stay valid. `fetch` auto-escalates: normal API, then a headed browser with Cloudflare auto-detect (no challenge means solve returns instantly). `browser` is the direct browser platform when you already know you want the browser: `browser fetch` (headed + CF auto-detect, skips the API) and `browser search` (headless search engine: Bing CN/intl, Google). | Everything above failed; or the page is behind Cloudflare, needs JS, or needs login cookies; or you want a browser search engine directly. |
|
|
81
|
+
|
|
82
|
+
Notes:
|
|
83
|
+
- Escalation is **automatic inside `fetch`**: normal API, then a headed browser with CF
|
|
84
|
+
auto-detect (no CF challenge means solve returns instantly). You usually just call `fetch`
|
|
85
|
+
and let it climb.
|
|
86
|
+
- `browser` is the direct browser platform, used when you already know the page needs a
|
|
87
|
+
browser: `browser fetch` skips the API and goes straight to the headed browser with CF
|
|
88
|
+
auto-detect; `browser search` runs a headless search engine (default Bing international,
|
|
89
|
+
since CN Bing filters some sensitive terms).
|
|
90
|
+
- `fetch` and `browser fetch` inject login cookies through the bundled MV3 extension bridge,
|
|
91
|
+
so logged-in pages work without re-entering credentials.
|
|
92
|
+
- The Cloudflare bypass runs on DrissionPage against the user's real browser. If that runtime
|
|
93
|
+
dependency is missing, `fetch` and `browser fetch` report a clear install hint (exit code 5,
|
|
94
|
+
antibot) instead of silently failing.
|
|
95
|
+
|
|
96
|
+
### B. Official docs, Context7 (`ctx7`), an independent domain
|
|
97
|
+
|
|
98
|
+
`ctx7 library "<name>" "<question>"` resolves a library id. `ctx7 docs /org/repo
|
|
99
|
+
"<question>"` returns up-to-date official docs as markdown. It hits the authoritative source
|
|
100
|
+
directly, so it is fast and accurate.
|
|
101
|
+
|
|
102
|
+
**For any library, framework, SDK, CLI, or cloud-service question, prefer `ctx7` over web
|
|
103
|
+
search.** Do not grep the open web for API syntax, config keys, or version-migration notes
|
|
104
|
+
that Context7 can serve from the source.
|
|
105
|
+
|
|
106
|
+
### C. Ask a web-enabled LLM (`ask`), natural-language Q&A, LOW trust on the answer
|
|
107
|
+
|
|
108
|
+
`ask "<question>"` sends your natural-language question to the configured `openai_compat`
|
|
109
|
+
LLM (model must have built-in web search). It returns the model's markdown answer with
|
|
110
|
+
inline source links, plus an extracted citations list. Use it for a direct answer to a small
|
|
111
|
+
question; this differs from `search`, which aggregates the exa/tavily search APIs and returns
|
|
112
|
+
only URLs.
|
|
113
|
+
|
|
114
|
+
Why low trust on the answer: the LLM is great for **breadth** and for fuzzy or niche
|
|
115
|
+
questions, but its synthesized prose is unreliable. Expect context pollution, hallucinated
|
|
116
|
+
URLs, and stale facts. Treat the answer as a lead; for any actual fact, `fetch` the cited
|
|
117
|
+
page and read it yourself.
|
|
118
|
+
|
|
119
|
+
### D. Long local documents, `locate`
|
|
120
|
+
|
|
121
|
+
`locate <md_path> "<query>"` does anchor-based relevance scanning: it chunks the document,
|
|
122
|
+
scores chunks with a small model, then matches code strings back to exact line numbers. Use
|
|
123
|
+
it to **pre-screen which parts of a long page or doc matter** before reading, so you avoid
|
|
124
|
+
blind full reads of 10k-line files.
|
|
125
|
+
|
|
126
|
+
## Core principle: consume sources, do not inherit synthesis
|
|
127
|
+
|
|
128
|
+
Every search and locate tool returns **candidate sources or locations**, not a conclusion to
|
|
129
|
+
trust verbatim. Read the fetched sources yourself and synthesize your own answer. Never
|
|
130
|
+
fabricate a source you did not fetch.
|
|
131
|
+
|
|
132
|
+
## Use high concurrency; this is the point of the toolkit
|
|
133
|
+
|
|
134
|
+
These tools are cheap and independent. Fire many in parallel; do not serialize.
|
|
135
|
+
|
|
136
|
+
- **One question, many sources at once.** In a single turn, issue `exa search`, `tavily
|
|
137
|
+
search`, `ctx7 docs`, and `ask` together (parallel tool calls); merge and de-duplicate
|
|
138
|
+
the candidate URLs, then `fetch` the best ones. This is far faster than search, read,
|
|
139
|
+
search, read.
|
|
140
|
+
- **Batch fetch.** `fetch url1 url2 url3` fetches all concurrently (one browser, many pages,
|
|
141
|
+
`--concurrency N`). `firecrawl scrape u1 u2 u3` batches too. Never loop one URL at a time
|
|
142
|
+
when a batch call exists.
|
|
143
|
+
- **Many pages, one locate.** Run `locate` once on the whole document to pin the relevant
|
|
144
|
+
sections, then read only those.
|
|
145
|
+
|
|
146
|
+
Reserve serial work for genuine dependencies (you need URL X's content to formulate the next
|
|
147
|
+
query). Otherwise, fan out.
|
|
148
|
+
|
|
149
|
+
## Commands (JSON on stdout by default; `--output markdown` for readable markdown)
|
|
150
|
+
|
|
151
|
+
Pick a tool by what the task needs. Each command's comment states its strength and when to
|
|
152
|
+
use it. Async endpoints (crawl, extract, research, agent) block until done or `--poll-timeout`,
|
|
153
|
+
and accept `--async-submit` to return the job id immediately instead of waiting.
|
|
154
|
+
|
|
155
|
+
### A. Web search and page fetch, escalate only when the lower tier fails
|
|
156
|
+
|
|
157
|
+
```sh
|
|
158
|
+
# Tier 1: structured search APIs. Clean JSON, fastest, cheapest. Start here for simple or public pages.
|
|
159
|
+
research-assistant exa search "<query>" [--num-results N] [--type auto|keyword|neural|fast] [--text] [--highlights] [--category CAT] [--start-date YYYY-MM-DD] [--end-date YYYY-MM-DD] [--include-domains D ...] [--exclude-domains D ...]
|
|
160
|
+
research-assistant exa similar <url> [--num-results N] # pages semantically similar to a URL
|
|
161
|
+
research-assistant exa contents <id>... [--text] [--highlights] # fetch body for Exa IDs or URLs
|
|
162
|
+
research-assistant exa answer "<question>" [--text] # LLM answer grounded in Exa results
|
|
163
|
+
research-assistant exa research "<instructions>" [--model exa-research|exa-research-pro] [--output-schema JSON | --infer-schema] [--poll-timeout N] [--async-submit] # async in-depth research
|
|
164
|
+
|
|
165
|
+
research-assistant tavily search "<query>" [--depth ultra-fast|fast|basic|advanced] [--max-results N] [--topic general|news|finance] [--time-range day|week|month|year] [--include-answer basic|true|advanced] [--country C] [--start-date YYYY-MM-DD] [--end-date YYYY-MM-DD] [--chunks-per-source N] [--include-raw-content markdown|text] [--include-images] [--include-domains D ...] [--exclude-domains D ...] [--auto-parameters]
|
|
166
|
+
research-assistant tavily extract <url>... [--extract-depth basic|advanced] [--format markdown|text] # ready markdown, no rendering
|
|
167
|
+
research-assistant tavily map <url> [--max-depth N] [--max-breadth N] [--limit N] [--select-paths REGEX ...] [--instructions TEXT] [--timeout N] # list a site's reachable URLs
|
|
168
|
+
research-assistant tavily crawl <url> [--max-depth N] [--limit N] [--instructions TEXT] [--extract-depth basic|advanced] [--no-external] [--timeout N] # recursive crawl, markdown per page
|
|
169
|
+
|
|
170
|
+
# Tier 2: Firecrawl. Residential bandwidth. scrape and search are keyless; the rest need a key.
|
|
171
|
+
research-assistant firecrawl scrape <url>... [--format markdown|html] [--only-main-content] [--wait-for MS] # batch markdown, keyless
|
|
172
|
+
research-assistant firecrawl search "<query>" [--limit N] [--sources web|news] [--scrape] # keyless; --scrape returns full markdown per result
|
|
173
|
+
research-assistant firecrawl map <url> [--limit N] [--include-subdomains] # needs key
|
|
174
|
+
research-assistant firecrawl crawl <url> [--limit N] [--max-depth N] [--include-paths REGEX ...] [--allow-subdomains] [--prompt TEXT] [--poll-timeout N] [--async-submit] # async recursive crawl
|
|
175
|
+
research-assistant firecrawl extract <url>... [--prompt TEXT | --schema JSON] [--enable-web-search] [--agent] [--poll-timeout N] [--async-submit] # LLM structured extraction, async
|
|
176
|
+
research-assistant firecrawl interact "<prompt>" [--scrape-id ID | --url URL] [--code TEXT --language node|python|bash] [--timeout S] [--stop] # live browser session bound to a scrape
|
|
177
|
+
research-assistant firecrawl agent "<prompt>" [--model spark-1-mini|spark-1-pro] [--urls U ...] [--schema JSON] [--max-credits N] [--strict] [--poll-timeout N] [--async-submit] # autonomous extraction
|
|
178
|
+
research-assistant firecrawl monitor # list active crawl jobs
|
|
179
|
+
research-assistant firecrawl parse <file> [--format markdown|html|json ...] [--pdf-mode fast|auto|ocr] [--max-pages N] # parse a local document
|
|
180
|
+
|
|
181
|
+
# Tier 3: real browser. fetch auto-escalates (normal API, then headed browser with CF auto-detect).
|
|
182
|
+
research-assistant fetch <url> [<url>...] [--concurrency N] [--login|--no-login] [--no-browser] [--output PATH]
|
|
183
|
+
|
|
184
|
+
# browser platform: direct browser, no API attempt. fetch = headed + CF auto-detect; search = headless engine.
|
|
185
|
+
research-assistant browser fetch <url>... [--no-login] [--concurrency N] [--write PATH] # skip the API, straight to the headed browser (--write, not global --output)
|
|
186
|
+
research-assistant browser search "<query>" [--engine bing-cn|bing-intl|google] [--limit N] [--max-pages N] # headless search engine (default bing-intl)
|
|
187
|
+
|
|
188
|
+
# Aggregate the search sources in one call (default: configured exa,tavily + browser; browser is
|
|
189
|
+
# always in the default set and works with zero config). Keywords, not an LLM.
|
|
190
|
+
research-assistant search "<query>" [--providers exa,tavily,firecrawl,browser] [--limit N]
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
### B. Official docs, Context7 (authoritative; prefer over web search for library/SDK/CLI/cloud)
|
|
194
|
+
|
|
195
|
+
```sh
|
|
196
|
+
research-assistant ctx7 library "<name>" "<question>" # resolve a library id
|
|
197
|
+
research-assistant ctx7 docs /org/repo "<question>" # up-to-date official docs
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
### C. Ask a web-enabled LLM in natural language (`ask`)
|
|
201
|
+
|
|
202
|
+
`ask` sends your question to the configured `openai_compat` provider (config `[providers.openai_compat]`: base_url, api_key, model). Pick a model with built-in web search; the CLI sends a plain chat request and the model searches on its own, returning a markdown answer with inline source links. The command returns that markdown plus an extracted citations list. Use it for a direct answer to a small question; to gather URLs from the search APIs instead, use `search`.
|
|
203
|
+
|
|
204
|
+
```sh
|
|
205
|
+
research-assistant ask "<natural-language question>" [--system TEXT]
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
### D. Long-doc locating, pre-screen which parts of a 10k-line file matter before reading
|
|
209
|
+
|
|
210
|
+
```sh
|
|
211
|
+
research-assistant locate <md_path> "<query>" [--top N] [--scope lines|paragraph] [--context N] [--concurrency N]
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
### Setup and management
|
|
215
|
+
|
|
216
|
+
```sh
|
|
217
|
+
research-assistant setup [--non-interactive | --config-inline TOML] [--provider TYPE[,k=v,...]] ... [--proxy URL] [--browser-channel msedge|chrome] [--daemon-port N] [--install-skills claude,codex,...] # configure providers/proxy/browser; --install-skills writes the skill+agent files
|
|
218
|
+
research-assistant doctor [--show-config] [--target <name>] # connectivity diagnostics; --show-config prints config (masked), --target checks one provider or builtin
|
|
219
|
+
research-assistant skills status [--targets claude,codex,...] [--skills-root PATH] # managed skill/agent freshness
|
|
220
|
+
research-assistant skills update [--targets claude,codex,...] [--skills-root PATH] # refresh managed files
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
### Global flags (allowed anywhere on the command line)
|
|
224
|
+
|
|
225
|
+
```sh
|
|
226
|
+
research-assistant [--config PATH] [--output json|markdown] [--proxy URL|none] [--verbose] [--version] <command> ...
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
Every command also takes `-h`/`--help`: `research-assistant --help` lists all commands; `research-assistant <command> [--subcommand] --help` shows that command's arguments.
|
|
230
|
+
|
|
231
|
+
## Research workflow (you decide the steps)
|
|
232
|
+
|
|
233
|
+
1. Fan out search and docs (`exa` + `tavily` + `ctx7` + `ask`) to get candidate sources.
|
|
234
|
+
2. `fetch` the most promising URLs in one batch to get clean local markdown.
|
|
235
|
+
3. For long pages, `locate` to pin relevant passages before reading.
|
|
236
|
+
4. Synthesize YOUR answer from what you actually fetched; cite URLs.
|
|
237
|
+
|
|
238
|
+
For large, multi-source investigations, delegate to the `researcher` sub-agent. It runs this
|
|
239
|
+
workflow in an isolated context, writes a full report to `tmp-doc/`, and returns only a
|
|
240
|
+
summary, keeping the main context clean.
|
|
241
|
+
|
|
242
|
+
## Output and errors
|
|
243
|
+
|
|
244
|
+
Output defaults to JSON on stdout (parseable). Pass `--output markdown` for human or AI
|
|
245
|
+
readable markdown. Errors always surface as
|
|
246
|
+
`{"error":{"code","message","provider?","details?"}}` on stdout, plus a one-line
|
|
247
|
+
`error: ...` on stderr, with exit codes:
|
|
248
|
+
`0` ok, `1` internal, `2` args, `3` config, `4` network, `5` antibot.
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""CLI 入口:全局 flag 预解析 + 动态命令路由 + 统一分发/错误处理。
|
|
2
|
+
|
|
3
|
+
命令树(api-contract.md §2):
|
|
4
|
+
research-assistant [全局flags] <tool|cmd> <subcommand> [args] [flags]
|
|
5
|
+
|
|
6
|
+
provider 命令(从 registry 动态生成,ADR-0011):
|
|
7
|
+
ctx7/exa/tavily/firecrawl + openai_compat 的各自子命令
|
|
8
|
+
research-assistant 增值命令:fetch / locate / search
|
|
9
|
+
管理命令:setup / skills / doctor
|
|
10
|
+
|
|
11
|
+
全局 flags 可出现在任意位置(预解析抽离):
|
|
12
|
+
--config <path> --output json|markdown --proxy <url> --verbose --version
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import asyncio
|
|
19
|
+
import sys
|
|
20
|
+
from typing import Any, Sequence
|
|
21
|
+
|
|
22
|
+
from . import __version__
|
|
23
|
+
from .config import load as load_config
|
|
24
|
+
from .errors import ArgsError, ResearchAssistantError
|
|
25
|
+
from .output import emit, emit_error, emit_json
|
|
26
|
+
from .providers import all_provider_classes
|
|
27
|
+
from . import proxy as proxy_mod
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
# 命令分发 handler 契约:async(args_ns, config) -> dict
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _wrap_provider_handler(bound_handler: Any) -> Any:
|
|
36
|
+
"""把 provider 的 bound handler(self 已绑定) 适配成 (args_ns, config) 契约。"""
|
|
37
|
+
|
|
38
|
+
async def runner(args_ns: argparse.Namespace, config: Any) -> Any:
|
|
39
|
+
return await bound_handler(args_ns)
|
|
40
|
+
|
|
41
|
+
return runner
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _build_parser(config: Any) -> argparse.ArgumentParser:
|
|
45
|
+
parser = argparse.ArgumentParser(
|
|
46
|
+
prog="research-assistant",
|
|
47
|
+
description="搜索调研子 Agent + 可并发的搜索/爬取/定位 CLI 原子工具。",
|
|
48
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
49
|
+
)
|
|
50
|
+
subs = parser.add_subparsers(dest="command", required=True, metavar="<command>")
|
|
51
|
+
|
|
52
|
+
# ---- provider 命令(动态,从 registry 生成)----
|
|
53
|
+
for ptype, pclass in sorted(all_provider_classes().items()):
|
|
54
|
+
provider = pclass(config)
|
|
55
|
+
cap_list = provider.capabilities()
|
|
56
|
+
if not cap_list:
|
|
57
|
+
continue
|
|
58
|
+
names = [provider.command] + list(provider.aliases or [])
|
|
59
|
+
names = [n for n in names if n]
|
|
60
|
+
if not names:
|
|
61
|
+
continue
|
|
62
|
+
p_parser = subs.add_parser(
|
|
63
|
+
names[0],
|
|
64
|
+
aliases=names[1:],
|
|
65
|
+
help=pclass.help,
|
|
66
|
+
description=pclass.help,
|
|
67
|
+
)
|
|
68
|
+
p_sub = p_parser.add_subparsers(dest="subcommand", required=True, metavar="<subcommand>")
|
|
69
|
+
for cap in cap_list:
|
|
70
|
+
c_parser = p_sub.add_parser(cap.name, help=cap.help, description=cap.help)
|
|
71
|
+
for arg in cap.args:
|
|
72
|
+
arg.add_to(c_parser)
|
|
73
|
+
c_parser.set_defaults(_handler=_wrap_provider_handler(cap.handler))
|
|
74
|
+
|
|
75
|
+
# ---- 原生命令 ----
|
|
76
|
+
from .commands import ask as ask_cmd
|
|
77
|
+
from .commands import fetch as fetch_cmd
|
|
78
|
+
from .commands import locate as locate_cmd
|
|
79
|
+
from .commands import search as search_cmd
|
|
80
|
+
from .commands import setup as setup_cmd
|
|
81
|
+
from .commands import skills as skills_cmd
|
|
82
|
+
from .commands import doctor as doctor_cmd
|
|
83
|
+
|
|
84
|
+
for cmd in (fetch_cmd, locate_cmd, search_cmd, ask_cmd, setup_cmd, skills_cmd, doctor_cmd):
|
|
85
|
+
cmd.register(subs)
|
|
86
|
+
|
|
87
|
+
return parser
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _parse_globals(argv: Sequence[str]) -> tuple[argparse.Namespace, list[str]]:
|
|
91
|
+
"""预解析全局 flags(允许出现在命令任意位置),返回 (globals_ns, remaining_argv)。"""
|
|
92
|
+
pre = argparse.ArgumentParser(add_help=False)
|
|
93
|
+
pre.add_argument("--config", default=None, dest="config_path")
|
|
94
|
+
pre.add_argument("--output", default="json", choices=["json", "markdown"])
|
|
95
|
+
pre.add_argument("--proxy", default=None, help="Proxy URL override (横切); 'none' = force direct, no proxy.")
|
|
96
|
+
pre.add_argument("--verbose", action="store_true")
|
|
97
|
+
pre.add_argument("--version", action="store_true")
|
|
98
|
+
ns, remaining = pre.parse_known_args(list(argv))
|
|
99
|
+
return ns, remaining
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
103
|
+
if argv is None:
|
|
104
|
+
argv = sys.argv[1:]
|
|
105
|
+
g_ns, remaining = _parse_globals(argv)
|
|
106
|
+
|
|
107
|
+
if g_ns.version:
|
|
108
|
+
print(f"research-assistant {__version__}")
|
|
109
|
+
return 0
|
|
110
|
+
|
|
111
|
+
# 代理横切覆盖(CLI --proxy 优先级最高,ADR-0007)
|
|
112
|
+
proxy_mod.set_override(g_ns.proxy)
|
|
113
|
+
|
|
114
|
+
# 加载配置(文件不存在=首次使用,返回空 Config)
|
|
115
|
+
try:
|
|
116
|
+
config = load_config(g_ns.config_path)
|
|
117
|
+
except ResearchAssistantError as e:
|
|
118
|
+
emit_json(e.to_json())
|
|
119
|
+
emit_error(e.message)
|
|
120
|
+
return e.exit_code()
|
|
121
|
+
|
|
122
|
+
# 构建并解析子命令树
|
|
123
|
+
parser = _build_parser(config)
|
|
124
|
+
try:
|
|
125
|
+
args_ns = parser.parse_args(remaining)
|
|
126
|
+
except SystemExit as e: # argparse 解析失败/--help 已退出
|
|
127
|
+
code = e.code if isinstance(e.code, int) else 2
|
|
128
|
+
return code
|
|
129
|
+
|
|
130
|
+
handler = getattr(args_ns, "_handler", None)
|
|
131
|
+
if handler is None: # 只有顶层命令没有 sub(理论上 required=True 已挡)
|
|
132
|
+
parser.print_help(sys.stderr)
|
|
133
|
+
return 2
|
|
134
|
+
|
|
135
|
+
try:
|
|
136
|
+
result = asyncio.run(handler(args_ns, config))
|
|
137
|
+
except ResearchAssistantError as e:
|
|
138
|
+
emit_json(e.to_json())
|
|
139
|
+
emit_error(e.message)
|
|
140
|
+
return e.exit_code()
|
|
141
|
+
except KeyboardInterrupt:
|
|
142
|
+
emit_error("已中断")
|
|
143
|
+
return 1
|
|
144
|
+
except Exception as e: # 兜底:未预期异常 → INTERNAL
|
|
145
|
+
err = ResearchAssistantError(f"内部错误: {e}", details={"type": type(e).__name__})
|
|
146
|
+
emit_json(err.to_json())
|
|
147
|
+
emit_error(err.message)
|
|
148
|
+
return err.exit_code()
|
|
149
|
+
|
|
150
|
+
emit(result, g_ns.output)
|
|
151
|
+
return 0
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
if __name__ == "__main__":
|
|
155
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""ask 命令:自然语言问一个自带 web 搜索的 LLM,拿它的 markdown 回答 + 抽出的信源。
|
|
2
|
+
|
|
3
|
+
research-assistant ask "<natural-language question>"
|
|
4
|
+
|
|
5
|
+
和 search(聚合 exa/tavily 搜索 API)的区别:ask 走 openai_compat(你配的、model 自带
|
|
6
|
+
web 搜索的 LLM)。你发自然语言意图,LLM 理解后自己搜、自己组织答案;返回 LLM 的
|
|
7
|
+
markdown 回答(引用链接嵌在正文里)+ 抽出的 citations 列表。适合主 agent 遇到小问题、
|
|
8
|
+
不想自己调一堆工具、直接发自然语言拿结果的轻量场景。
|
|
9
|
+
|
|
10
|
+
model 必须自带 web 搜索(如 grok 类):CLI 只发普通 chat 请求,搜索由 model 自身能力
|
|
11
|
+
完成;若 model 不会搜,content 里不会有真实信源、citations 为空。
|
|
12
|
+
|
|
13
|
+
响应:{content(str, markdown), citations[]{url, title?, snippet?}}
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import argparse
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from ..config import Config
|
|
22
|
+
from ..providers import openai_compat
|
|
23
|
+
|
|
24
|
+
NAME = "ask"
|
|
25
|
+
ALIASES: list[str] = []
|
|
26
|
+
HELP = "Ask a web-enabled LLM in natural language; returns its markdown answer plus sources."
|
|
27
|
+
|
|
28
|
+
_ASK_SYSTEM_PROMPT = (
|
|
29
|
+
"You are a web research assistant. Search the web to answer the user's question. "
|
|
30
|
+
"Answer in clear markdown and cite sources inline as links. "
|
|
31
|
+
"Prefer official documentation and reputable sites."
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def register(subparsers: argparse._SubParsersAction) -> None:
|
|
36
|
+
p = subparsers.add_parser(NAME, aliases=ALIASES, help=HELP, description=HELP)
|
|
37
|
+
p.add_argument("query", help="Natural-language question.")
|
|
38
|
+
p.add_argument("--system", help="Override the system prompt.")
|
|
39
|
+
p.set_defaults(_handler=run)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
async def run(args: argparse.Namespace, config: Config) -> dict[str, Any]:
|
|
43
|
+
messages: list[dict[str, str]] = [
|
|
44
|
+
{"role": "system", "content": args.system or _ASK_SYSTEM_PROMPT},
|
|
45
|
+
{"role": "user", "content": args.query},
|
|
46
|
+
]
|
|
47
|
+
response = await openai_compat.chat_completion(config, messages, temperature=0.0)
|
|
48
|
+
msg = openai_compat.extract_message(response)
|
|
49
|
+
content = openai_compat.strip_think(msg.get("content") or "")
|
|
50
|
+
citations = openai_compat.extract_citations(response, msg)
|
|
51
|
+
out: dict[str, Any] = {"content": content}
|
|
52
|
+
if citations:
|
|
53
|
+
out["citations"] = citations
|
|
54
|
+
return out
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""doctor 命令:连通性诊断(api-contract §2.3 / 场景 E)。
|
|
2
|
+
|
|
3
|
+
research-assistant doctor [--show-config] [--target <name>]
|
|
4
|
+
|
|
5
|
+
对每个已配置 provider 做最小请求;locate 模型 ping;浏览器+cookie daemon 探测。
|
|
6
|
+
--show-config 展示配置(密钥遮蔽)。退出码 0=诊断完成(可达性体现在 report)。
|
|
7
|
+
响应:{checks[]{target, reachable, latency_ms, error?}, config_complete, config?}
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import time
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from .. import providers as providers_pkg
|
|
17
|
+
from ..config import Config, mask_secret
|
|
18
|
+
from ..errors import ResearchAssistantError
|
|
19
|
+
from ..providers import openai_compat
|
|
20
|
+
|
|
21
|
+
NAME = "doctor"
|
|
22
|
+
ALIASES: list[str] = ["diag"]
|
|
23
|
+
HELP = "Run connectivity diagnostics across configured providers."
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def register(subparsers: argparse._SubParsersAction) -> None:
|
|
27
|
+
p = subparsers.add_parser(NAME, aliases=ALIASES, help=HELP, description=HELP)
|
|
28
|
+
p.add_argument("--show-config", action="store_true", help="Show config (secrets masked).")
|
|
29
|
+
p.add_argument("--target", help="Only check a single provider/builtin target.")
|
|
30
|
+
p.set_defaults(_handler=run)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
async def run(args: argparse.Namespace, config: Config) -> dict[str, Any]:
|
|
34
|
+
checks: list[dict[str, Any]] = []
|
|
35
|
+
configured_types = {p.type for p in config.providers}
|
|
36
|
+
|
|
37
|
+
targets: list[str] = []
|
|
38
|
+
if args.target:
|
|
39
|
+
targets = [args.target]
|
|
40
|
+
else:
|
|
41
|
+
# 已配置的 provider + 浏览器;firecrawl 免 key 层永远可用,始终检查
|
|
42
|
+
configured = set(configured_types)
|
|
43
|
+
targets = [t for t in ("openai_compat", "locate", "exa", "tavily", "context7") if t in configured]
|
|
44
|
+
targets.append("firecrawl")
|
|
45
|
+
targets.append("browser")
|
|
46
|
+
|
|
47
|
+
for target in targets:
|
|
48
|
+
checks.append(await _check(target, config))
|
|
49
|
+
|
|
50
|
+
config_complete = _config_complete(config)
|
|
51
|
+
result: dict[str, Any] = {"checks": checks, "config_complete": config_complete}
|
|
52
|
+
if args.show_config:
|
|
53
|
+
result["config"] = _masked_config(config)
|
|
54
|
+
return result
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _config_complete(config: Config) -> bool:
|
|
58
|
+
"""配置是否足以做完整调研。
|
|
59
|
+
|
|
60
|
+
搜索源:firecrawl 免 key 层永远可用,故搜索能力始终具备(exa/tavily/context7 是增强)。
|
|
61
|
+
LLM:openai_compat(驱动 search)或 locate(驱动 locate)至少一个就绪才算完整。
|
|
62
|
+
"""
|
|
63
|
+
pc = config.provider("openai_compat")
|
|
64
|
+
has_llm = bool(pc and pc.api_key and pc.base_url and pc.model)
|
|
65
|
+
lc = config.provider("locate")
|
|
66
|
+
has_locate = bool(lc and lc.api_key and lc.base_url and lc.model)
|
|
67
|
+
return has_llm or has_locate
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
async def _check(target: str, config: Config) -> dict[str, Any]:
|
|
71
|
+
start = time.perf_counter()
|
|
72
|
+
try:
|
|
73
|
+
if target == "browser":
|
|
74
|
+
ok, msg = await _check_browser(config)
|
|
75
|
+
elif target == "openai_compat":
|
|
76
|
+
await openai_compat.chat_completion(
|
|
77
|
+
config, [{"role": "user", "content": "ping"}], provider_type="openai_compat"
|
|
78
|
+
)
|
|
79
|
+
ok, msg = True, "ok"
|
|
80
|
+
elif target == "locate":
|
|
81
|
+
await openai_compat.chat_completion(
|
|
82
|
+
config, [{"role": "user", "content": "ping"}], provider_type="locate"
|
|
83
|
+
)
|
|
84
|
+
ok, msg = True, "ok"
|
|
85
|
+
else:
|
|
86
|
+
ok, msg = await _check_provider(target, config)
|
|
87
|
+
except ResearchAssistantError as e:
|
|
88
|
+
ok, msg = False, e.message
|
|
89
|
+
except Exception as e: # noqa: BLE001
|
|
90
|
+
ok, msg = False, f"{type(e).__name__}: {e}"
|
|
91
|
+
latency_ms = round((time.perf_counter() - start) * 1000, 1)
|
|
92
|
+
item: dict[str, Any] = {"target": target, "reachable": ok, "latency_ms": latency_ms}
|
|
93
|
+
if not ok:
|
|
94
|
+
item["error"] = msg
|
|
95
|
+
return item
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
async def _check_provider(ptype: str, config: Config) -> tuple[bool, str]:
|
|
99
|
+
"""用 provider 的最小能力做存活探测(复用插件实例与真实代码路径)。"""
|
|
100
|
+
classes = providers_pkg.all_provider_classes()
|
|
101
|
+
pclass = classes.get(ptype)
|
|
102
|
+
if pclass is None:
|
|
103
|
+
return False, f"provider '{ptype}' 未注册"
|
|
104
|
+
provider = pclass(config)
|
|
105
|
+
caps = provider.capabilities()
|
|
106
|
+
# 选一个轻量能力:search / library / map
|
|
107
|
+
preferred = next((c for c in caps if c.name in ("search", "library", "map")), None)
|
|
108
|
+
if preferred is None:
|
|
109
|
+
return False, f"{ptype}: 无可用探测能力"
|
|
110
|
+
ns = _minimal_namespace(preferred.name)
|
|
111
|
+
await preferred.handler(ns)
|
|
112
|
+
return True, "ok"
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _minimal_namespace(cap_name: str) -> argparse.Namespace:
|
|
116
|
+
"""构造各 provider 最小查询所需的 args(带全部默认值,避免 AttributeError)。"""
|
|
117
|
+
base = {
|
|
118
|
+
"query": "test",
|
|
119
|
+
"name": "test",
|
|
120
|
+
"library_id": "/facebook/react",
|
|
121
|
+
"num_results": 1,
|
|
122
|
+
"max_results": 1,
|
|
123
|
+
"limit": 1,
|
|
124
|
+
"type": "auto",
|
|
125
|
+
"depth": "basic",
|
|
126
|
+
"topic": "general",
|
|
127
|
+
"text": False,
|
|
128
|
+
"highlights": False,
|
|
129
|
+
"include_domains": None,
|
|
130
|
+
"exclude_domains": None,
|
|
131
|
+
"category": None,
|
|
132
|
+
"start_date": None,
|
|
133
|
+
"time_range": None,
|
|
134
|
+
"include_answer": None,
|
|
135
|
+
"scrape": False,
|
|
136
|
+
"sources": None,
|
|
137
|
+
"include_subdomains": False,
|
|
138
|
+
"only_main_content": False,
|
|
139
|
+
"wait_for": None,
|
|
140
|
+
"extract_depth": "basic",
|
|
141
|
+
"format": "markdown",
|
|
142
|
+
"tokens": 200,
|
|
143
|
+
"ids": [],
|
|
144
|
+
"url": "https://example.com",
|
|
145
|
+
"urls": ["https://example.com"],
|
|
146
|
+
}
|
|
147
|
+
return argparse.Namespace(**base)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
async def _check_browser(config: Config) -> tuple[bool, str]:
|
|
151
|
+
"""探测本地浏览器(Playwright channel)与 cookie daemon。"""
|
|
152
|
+
# cookie daemon 状态(HTTP /status)
|
|
153
|
+
daemon_msg = await _check_daemon(config)
|
|
154
|
+
# 浏览器可启动性(仅探测 channel 可执行是否存在,不真启动以省时)
|
|
155
|
+
from ..fetch import browser_probe
|
|
156
|
+
|
|
157
|
+
browser_msg = browser_probe(config.browser.channel)
|
|
158
|
+
ok = "可用" in browser_msg
|
|
159
|
+
msg = f"browser={browser_msg}; daemon={daemon_msg}"
|
|
160
|
+
return ok, msg
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
async def _check_daemon(config: Config) -> str:
|
|
164
|
+
import aiohttp
|
|
165
|
+
|
|
166
|
+
url = f"http://127.0.0.1:{config.browser.daemon_port}/status"
|
|
167
|
+
try:
|
|
168
|
+
async with aiohttp.ClientSession() as session:
|
|
169
|
+
async with session.get(url, timeout=aiohttp.ClientTimeout(total=3)) as resp:
|
|
170
|
+
if resp.status == 200:
|
|
171
|
+
data = await resp.json()
|
|
172
|
+
return f"在线(扩展已连={data.get('extConnected', False)})"
|
|
173
|
+
return f"HTTP {resp.status}"
|
|
174
|
+
except Exception:
|
|
175
|
+
return "离线(未启动或扩展未装;fetch 时会按需拉起)"
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _masked_config(config: Config) -> dict[str, Any]:
|
|
179
|
+
return {
|
|
180
|
+
"config_path": config.path,
|
|
181
|
+
"schema_version": config.schema_version,
|
|
182
|
+
"providers": [
|
|
183
|
+
{
|
|
184
|
+
"type": p.type,
|
|
185
|
+
"base_url": p.base_url,
|
|
186
|
+
"api_key": mask_secret(p.api_key),
|
|
187
|
+
"model": p.model,
|
|
188
|
+
"timeout": p.timeout,
|
|
189
|
+
**({"concurrency": p.concurrency} if p.type == "locate" else {}),
|
|
190
|
+
}
|
|
191
|
+
for p in config.providers
|
|
192
|
+
],
|
|
193
|
+
"proxy": {"url": config.proxy.url or "(空=自动检测)"},
|
|
194
|
+
"browser": {
|
|
195
|
+
"channel": config.browser.channel,
|
|
196
|
+
"extension_status": config.browser.extension_status,
|
|
197
|
+
"daemon_port": config.browser.daemon_port,
|
|
198
|
+
"profile_strategy": config.browser.profile_strategy,
|
|
199
|
+
"max_browser_instances": config.browser.max_browser_instances,
|
|
200
|
+
},
|
|
201
|
+
}
|