bookmark-research 0.5.0-beta.1
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/.agents/plugins/marketplace.json +20 -0
- package/.codex-plugin/plugin.json +46 -0
- package/LICENSE +674 -0
- package/README.md +128 -0
- package/README.zh.md +126 -0
- package/bin/bookmark-research.js +30 -0
- package/config/providers.json +98 -0
- package/docs/bookmark-research-architecture.md +124 -0
- package/docs/cli-launcher-plan.md +58 -0
- package/docs/details.en.md +272 -0
- package/docs/details.md +93 -0
- package/docs/harness-compatibility.md +304 -0
- package/docs/harness-sources.json +342 -0
- package/docs/host-validation.json +298 -0
- package/docs/install-design-0.2.0.md +57 -0
- package/docs/installation.en.md +197 -0
- package/docs/installation.md +316 -0
- package/docs/instructions.en.md +42 -0
- package/docs/instructions.md +42 -0
- package/docs/mcp-aggregation-0.2.0.md +92 -0
- package/docs/multi-host-installation-plan.md +163 -0
- package/docs/prompt-reference.en.md +104 -0
- package/docs/prompt-reference.md +104 -0
- package/docs/provider-smoke-0.2.0.json +235 -0
- package/docs/release-notes-0.5.0-beta.1.md +33 -0
- package/docs/research-0.2.0.md +140 -0
- package/docs/research-sources-0.2.0.json +9360 -0
- package/docs/user-guide.en.md +74 -0
- package/docs/user-guide.md +74 -0
- package/docs/validation-0.2.0.md +126 -0
- package/docs/validation-0.4.0.md +154 -0
- package/docs/wiki-quality.md +105 -0
- package/docs/wiki-quality.zh.md +105 -0
- package/hosts/claude/runtime.js +13 -0
- package/hosts/codex/delegate.md +14 -0
- package/hosts/codex/prepare.py +61 -0
- package/hosts/dsh/plugin.js +36 -0
- package/hosts/dsh/runtime.js +14 -0
- package/hosts/dsh/workflow-call.py +45 -0
- package/hosts/pi/register-workflow.py +72 -0
- package/hosts/pi/runtime.js +18 -0
- package/hosts/shared/research-call.py +76 -0
- package/hosts/shared/research-flow.js +208 -0
- package/hosts/shared/workflow.json +15 -0
- package/install.sh +310 -0
- package/package.json +12 -0
- package/scripts/build_zip.py +143 -0
- package/scripts/export_bundle.py +496 -0
- package/scripts/host_assets.py +70 -0
- package/scripts/host_clients.py +196 -0
- package/scripts/host_install.py +277 -0
- package/scripts/install.py +593 -0
- package/scripts/launcher.py +186 -0
- package/scripts/verify_fixture.py +220 -0
- package/scripts/verify_live.py +62 -0
- package/scripts/verify_quality.py +87 -0
- package/skills/bookmark-research/SKILL.md +80 -0
- package/skills/bookmark-research/agents/openai.yaml +3 -0
- package/skills/bookmark-research/references/cli.md +150 -0
- package/skills/bookmark-research/references/deep-research.md +164 -0
- package/skills/bookmark-research/references/github-and-sync.md +30 -0
- package/skills/bookmark-research/references/host-workflows.md +101 -0
- package/skills/bookmark-research/references/package-semantics.md +78 -0
- package/skills/bookmark-research/references/research-methods.md +51 -0
- package/skills/bookmark-research/references/research-services.md +72 -0
- package/skills/bookmark-research/references/research-workflow.md +57 -0
- package/skills/bookmark-research/references/settings-and-archive.md +104 -0
- package/skills/bookmark-research/references/source-lifecycle.md +75 -0
- package/skills/bookmark-research/references/wiki-and-evaluation.md +63 -0
- package/skills/bookmark-research/references/zh/cli.md +150 -0
- package/skills/bookmark-research/references/zh/deep-research.md +164 -0
- package/skills/bookmark-research/references/zh/github-and-sync.md +30 -0
- package/skills/bookmark-research/references/zh/host-workflows.md +101 -0
- package/skills/bookmark-research/references/zh/package-semantics.md +78 -0
- package/skills/bookmark-research/references/zh/research-methods.md +51 -0
- package/skills/bookmark-research/references/zh/research-services.md +70 -0
- package/skills/bookmark-research/references/zh/research-workflow.md +57 -0
- package/skills/bookmark-research/references/zh/settings-and-archive.md +104 -0
- package/skills/bookmark-research/references/zh/skill-guide.md +75 -0
- package/skills/bookmark-research/references/zh/source-lifecycle.md +75 -0
- package/skills/bookmark-research/references/zh/wiki-and-evaluation.md +63 -0
- package/src/archive.py +302 -0
- package/src/bookmark_index.py +913 -0
- package/src/cli.py +435 -0
- package/src/credentials.py +82 -0
- package/src/evaluation.py +337 -0
- package/src/jina.py +96 -0
- package/src/mcp_server.py +641 -0
- package/src/onboarding.py +352 -0
- package/src/provider_adapters.py +152 -0
- package/src/readiness.py +283 -0
- package/src/remote_mcp.py +295 -0
- package/src/research.py +1334 -0
- package/src/research_coverage.py +160 -0
- package/src/research_services.py +684 -0
- package/src/routing.py +202 -0
- package/src/search_results.py +244 -0
- package/src/service_http.py +139 -0
- package/src/settings.py +247 -0
- package/src/source_inputs.py +201 -0
- package/src/source_manager.py +348 -0
- package/src/source_watcher.py +68 -0
- package/src/textio.py +9 -0
- package/src/tui.py +586 -0
- package/src/web_search.py +603 -0
- package/src/wiki.py +383 -0
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
"""Human CLI setup and the equivalent JSON-driven agent path."""
|
|
2
|
+
|
|
3
|
+
from contextlib import contextmanager
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import shlex
|
|
7
|
+
import shutil
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from credentials import Credentials
|
|
12
|
+
from readiness import Readiness
|
|
13
|
+
from settings import Settings
|
|
14
|
+
from tui import Option, Prompter
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
HOSTS = (("codex", "Codex", "Codex"), ("claude", "Claude Code", "Claude Code"), ("pi", "Pi", "Pi"),
|
|
18
|
+
("dsh", "DSH / DeepSeek Harness", "DSH / DeepSeek Harness"))
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _profile_problem(value):
|
|
22
|
+
if not value or value.startswith(".") or not all(char.isalnum() or char in "._-" for char in value):
|
|
23
|
+
return ("Use letters, numbers, dots, underscores or hyphens; do not start with a dot.",
|
|
24
|
+
"仅可使用字母、数字、点、下划线或连字符,且不能以点开头。")
|
|
25
|
+
return None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _directory_problem(value):
|
|
29
|
+
if not value or not Path(value).expanduser().is_dir():
|
|
30
|
+
return ("Enter an existing directory.", "请输入已存在的目录。")
|
|
31
|
+
return None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Console(Prompter):
|
|
35
|
+
@staticmethod
|
|
36
|
+
def resolve_language(language="auto"):
|
|
37
|
+
if language != "auto":
|
|
38
|
+
return language
|
|
39
|
+
locale = os.environ.get("LC_ALL") or os.environ.get("LC_MESSAGES") or os.environ.get("LANG", "en")
|
|
40
|
+
return "zh" if locale.lower().startswith("zh") else "en"
|
|
41
|
+
|
|
42
|
+
def __init__(self, reader, writer, language="en", rich=None):
|
|
43
|
+
super().__init__(reader, writer, language, rich)
|
|
44
|
+
|
|
45
|
+
# Backward-compatible helpers built on the Prompter primitives.
|
|
46
|
+
def ask(self, english, chinese, default=""):
|
|
47
|
+
return self.input(english, chinese, "" if default is None else str(default))
|
|
48
|
+
|
|
49
|
+
def choice(self, english, chinese, options, default):
|
|
50
|
+
return self.select(english, chinese, [Option(value, en, zh) for value, en, zh in options], default)
|
|
51
|
+
|
|
52
|
+
def yes(self, english, chinese, default=False):
|
|
53
|
+
return self.confirm(english, chinese, default)
|
|
54
|
+
|
|
55
|
+
def providers(self, english, chinese, default, optional=False):
|
|
56
|
+
return self.multiselect(english, chinese, [Option(name, name) for name in Settings.PROVIDERS],
|
|
57
|
+
default, required=not optional)
|
|
58
|
+
|
|
59
|
+
@classmethod
|
|
60
|
+
@contextmanager
|
|
61
|
+
def open(cls, mode="auto", language="en"):
|
|
62
|
+
if mode == "never" or (mode == "auto" and not sys.stderr.isatty()):
|
|
63
|
+
yield None
|
|
64
|
+
return
|
|
65
|
+
try:
|
|
66
|
+
# curl | bash consumes stdin. Prompts must use the controlling tty,
|
|
67
|
+
# never read shell source bytes or an agent's piped JSON input.
|
|
68
|
+
names = ("CONIN$", "CONOUT$") if os.name == "nt" else ("/dev/tty", "/dev/tty")
|
|
69
|
+
reader = open(names[0], "r", encoding="utf-8")
|
|
70
|
+
try:
|
|
71
|
+
writer = open(names[1], "w", encoding="utf-8", buffering=1)
|
|
72
|
+
except OSError:
|
|
73
|
+
reader.close()
|
|
74
|
+
raise
|
|
75
|
+
except OSError:
|
|
76
|
+
if sys.stdin.isatty():
|
|
77
|
+
yield cls(sys.stdin, sys.stderr, language)
|
|
78
|
+
elif mode == "always":
|
|
79
|
+
raise ValueError("Interactive setup needs a terminal; use --non-interactive with --host and --preferences") from None
|
|
80
|
+
else:
|
|
81
|
+
yield None
|
|
82
|
+
else:
|
|
83
|
+
with reader, writer:
|
|
84
|
+
yield cls(reader, writer, language)
|
|
85
|
+
|
|
86
|
+
def install_targets(self, args):
|
|
87
|
+
self.intro("Bookmark Research — guided installation", "Bookmark Research — 引导安装")
|
|
88
|
+
requested = args.host
|
|
89
|
+
if isinstance(requested, str):
|
|
90
|
+
requested = [requested]
|
|
91
|
+
if requested:
|
|
92
|
+
hosts = list(dict.fromkeys(requested))
|
|
93
|
+
else:
|
|
94
|
+
found = {name: bool(shutil.which(getattr(args, name, None) or name)) for name, _, _ in HOSTS}
|
|
95
|
+
if not any(found.values()):
|
|
96
|
+
self.warn("No supported host CLI (codex, claude, pi, dsh) was found on PATH.",
|
|
97
|
+
"PATH 中未找到任何受支持的宿主 CLI(codex、claude、pi、dsh)。")
|
|
98
|
+
raise ValueError("No host CLI found; install Codex, Claude Code, Pi or DSH, "
|
|
99
|
+
"or pass --codex/--claude/--pi/--dsh PATH to point at its executable")
|
|
100
|
+
hints = {name: ("", None) if found[name] else ("CLI not found", "未找到 CLI") for name in found}
|
|
101
|
+
if found["dsh"] and not shutil.which("pnpm"):
|
|
102
|
+
# dsh plugin forwards to pnpm; offering DSH without it only fails later.
|
|
103
|
+
found["dsh"], hints["dsh"] = False, ("needs pnpm: npm install -g pnpm", "需要 pnpm:npm install -g pnpm")
|
|
104
|
+
hosts = self.multiselect("Install into which hosts?", "安装到哪些宿主?", [
|
|
105
|
+
Option(name, en, zh, *hints[name], disabled=not found[name]) for name, en, zh in HOSTS],
|
|
106
|
+
[name for name, _, _ in HOSTS if found[name]])
|
|
107
|
+
targets = []
|
|
108
|
+
for host in hosts:
|
|
109
|
+
label = next((en for name, en, _ in HOSTS if name == host), host)
|
|
110
|
+
target = {"host": host, "scope": None, "project": None, "profile": None}
|
|
111
|
+
if host in ("claude", "pi"):
|
|
112
|
+
scope = args.scope
|
|
113
|
+
if scope is None:
|
|
114
|
+
options = [Option("user", "This user", "当前用户"), Option("project", "This project", "指定项目")]
|
|
115
|
+
if host == "claude":
|
|
116
|
+
options.append(Option("local", "This project, only for me", "指定项目(仅自己)"))
|
|
117
|
+
scope = self.select(label + ": installation scope", label + ":安装范围", options, "user")
|
|
118
|
+
target["scope"] = scope
|
|
119
|
+
if scope in ("project", "local"):
|
|
120
|
+
target["project"] = args.project or self.input(
|
|
121
|
+
label + ": project directory", label + ":项目目录", str(Path.cwd()), _directory_problem)
|
|
122
|
+
elif host == "dsh":
|
|
123
|
+
# Never guess a DSH profile: it selects a distinct composed runtime.
|
|
124
|
+
target["profile"] = args.profile or self.input(
|
|
125
|
+
"DSH profile name (for example web)", "DSH profile 名称(例如 web)", "", _profile_problem)
|
|
126
|
+
targets.append(target)
|
|
127
|
+
lines = []
|
|
128
|
+
for target in targets:
|
|
129
|
+
label = next((en for name, en, _ in HOSTS if name == target["host"]), target["host"])
|
|
130
|
+
detail = ", ".join(key + "=" + target[key] for key in ("scope", "project", "profile") if target[key])
|
|
131
|
+
lines.append((label + (": " + detail if detail else ""), label + (":" + detail if detail else "")))
|
|
132
|
+
self.note("Installation plan", "安装计划", lines)
|
|
133
|
+
if not self.confirm("Install now?", "现在安装?", True):
|
|
134
|
+
raise KeyboardInterrupt
|
|
135
|
+
first = targets[0]
|
|
136
|
+
args.host, args.scope, args.project, args.profile = first["host"], first["scope"], first["project"], first["profile"]
|
|
137
|
+
return targets
|
|
138
|
+
|
|
139
|
+
def install_target(self, args):
|
|
140
|
+
return self.install_targets(args)[0]
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class Onboarding:
|
|
144
|
+
def __init__(self, settings=None, console=None):
|
|
145
|
+
self.settings = settings if settings is not None else Settings()
|
|
146
|
+
self.console = console
|
|
147
|
+
self.credentials = Credentials(self.settings)
|
|
148
|
+
|
|
149
|
+
def _preferences(self):
|
|
150
|
+
console, current = self.console, self.settings.load()
|
|
151
|
+
console.step("Research preferences (Enter keeps the highlighted value)", "研究偏好(回车保留当前选项)")
|
|
152
|
+
depth = console.select("Default research depth", "默认研究深度", [
|
|
153
|
+
Option("auto", "Auto", "自动", "choose from the question", "根据问题自动选择"),
|
|
154
|
+
Option("quick", "Quick", "快速", "quick source check", "快速查证"),
|
|
155
|
+
Option("agentic", "Agentic", "多轮", "iterative search and reading", "多轮搜索与阅读"),
|
|
156
|
+
Option("deep", "Deep", "深度", "deep research with retained evidence", "保留证据的深度研究")],
|
|
157
|
+
current["research"]["depth"])
|
|
158
|
+
language = console.select("Answer and report language", "答复和报告语言", [
|
|
159
|
+
Option("auto", "Follow the question", "跟随提问语言"), Option("zh", "Chinese", "中文"),
|
|
160
|
+
Option("en", "English", "英文")], current["research"]["response_language"])
|
|
161
|
+
hints = {"exa": ("Exa search and reading", "Exa 搜索与阅读"), "parallel": ("Parallel search and extract", "Parallel 搜索与提取"),
|
|
162
|
+
"tavily": ("Tavily search and extract", "Tavily 搜索与提取"),
|
|
163
|
+
"jina": ("Jina Reader; search needs a key", "Jina Reader;搜索需要密钥")}
|
|
164
|
+
def options(names=Settings.PROVIDERS):
|
|
165
|
+
return [Option(name, name, name, *hints.get(name, ("", None))) for name in names]
|
|
166
|
+
providers = console.multiselect("Primary search providers (run concurrently)", "主要搜索服务(并发查询)",
|
|
167
|
+
options(), current["search"]["providers"])
|
|
168
|
+
fallbacks = console.multiselect("Fallback search providers (used when primaries return nothing)",
|
|
169
|
+
"备用搜索服务(主要服务无结果时使用)", options(),
|
|
170
|
+
current["search"]["fallback_providers"], required=False)
|
|
171
|
+
existing = Settings.fetch_providers(current)
|
|
172
|
+
primary = console.select("Primary page reader", "首选正文读取服务", options(), existing[0] if existing else None)
|
|
173
|
+
rest = [name for name in Settings.PROVIDERS if name != primary]
|
|
174
|
+
extra = console.multiselect("Fallback page readers", "备用正文读取服务", options(rest),
|
|
175
|
+
[name for name in existing if name != primary], required=False)
|
|
176
|
+
extra_ordered = [name for name in existing if name in extra] + [name for name in extra if name not in existing]
|
|
177
|
+
readers = [primary] + extra_ordered
|
|
178
|
+
archive = console.confirm("Archive ordinary page reads? Research task evidence is always retained.",
|
|
179
|
+
"自动归档普通网页读取?研究任务的证据始终保留。", current["archive"]["enabled"])
|
|
180
|
+
directory = current["archive"]["directory"]
|
|
181
|
+
if archive:
|
|
182
|
+
def check(value):
|
|
183
|
+
try:
|
|
184
|
+
Settings.external_path(value, "Archive directory")
|
|
185
|
+
except ValueError:
|
|
186
|
+
return ("Use an absolute directory outside plugins and canvas packages.", "请使用插件与画布包以外的绝对目录。")
|
|
187
|
+
return None
|
|
188
|
+
directory = console.input("Archive directory", "归档目录", directory, check)
|
|
189
|
+
mode = console.select("Check availability before web research", "联网研究前的可用性检查", [
|
|
190
|
+
Option("cached", "Cached", "缓存", "refresh on first use, changes or expiry (15 minutes by default)", "首次、配置变化或过期时检查(默认 15 分钟)"),
|
|
191
|
+
Option("always", "Always", "总是", "check for every new research question", "每次开始研究问题时检查"),
|
|
192
|
+
Option("manual", "Manual", "手动", "only when explicitly requested", "仅手动检查")], current["readiness"]["mode"])
|
|
193
|
+
config = current["professional_research"]
|
|
194
|
+
service = console.select("Optional professional API (host-led research works without it)",
|
|
195
|
+
"可选专业研究 API(宿主主导的研究不需要它)", [
|
|
196
|
+
Option("none", "Disabled", "关闭", "keep professional APIs disabled", "关闭专业研究 API"),
|
|
197
|
+
Option("per_task", "Per task", "按任务选择", "enable APIs; choose the provider per task", "启用 API,每次任务再选服务"),
|
|
198
|
+
Option("openai", "OpenAI Deep Research", "OpenAI Deep Research"),
|
|
199
|
+
Option("parallel", "Parallel Task API", "Parallel Task API")],
|
|
200
|
+
(config["provider"] or "per_task") if config["enabled"] else "none")
|
|
201
|
+
services = {"enabled": service != "none", "provider": service if service in ("openai", "parallel") else None}
|
|
202
|
+
if service == "openai":
|
|
203
|
+
model = console.select("OpenAI research model", "OpenAI 研究模型", [
|
|
204
|
+
Option("o4-mini-deep-research", "o4-mini-deep-research"),
|
|
205
|
+
Option("o3-deep-research", "o3-deep-research")], config["openai"]["model"])
|
|
206
|
+
value = console.input("Maximum tool calls per run (not a monetary cap)", "每次任务最多工具调用数(不是金额上限)",
|
|
207
|
+
str(config["openai"]["max_tool_calls"]),
|
|
208
|
+
lambda value: None if value.isdigit() and 1 <= int(value) <= 1000
|
|
209
|
+
else ("Enter a number from 1 to 1000.", "请输入 1 到 1000 的整数。"))
|
|
210
|
+
services["openai"] = {"model": model, "max_tool_calls": int(value)}
|
|
211
|
+
elif service == "parallel":
|
|
212
|
+
console.say("Parallel processor remains " + config["parallel"]["processor"] + "; change it with config set if needed.",
|
|
213
|
+
"Parallel processor 沿用 " + config["parallel"]["processor"] + ";可通过 config set 修改。")
|
|
214
|
+
return {"research": {"depth": depth, "response_language": language}, "search": {"providers": providers, "fallback_providers": fallbacks},
|
|
215
|
+
"fetch": {"provider": readers[0], "providers": readers},
|
|
216
|
+
"archive": {"enabled": archive, "directory": directory}, "readiness": {"mode": mode}, "professional_research": services}
|
|
217
|
+
|
|
218
|
+
def _keys(self, defaults=()):
|
|
219
|
+
console = self.console
|
|
220
|
+
console.note("API keys", "API 密钥", [
|
|
221
|
+
("Retrieval adapters are included; anonymous search/reading works where the provider permits it.",
|
|
222
|
+
"检索适配器已包含在插件中;服务方允许时可使用匿名搜索和阅读。"),
|
|
223
|
+
("Host MCP OAuth login stays in the host. An OpenAI API key is separate from a ChatGPT/Codex login.",
|
|
224
|
+
"宿主 MCP 的 OAuth 登录由宿主管理;OpenAI API Key 与 ChatGPT/Codex 登录分开。"),
|
|
225
|
+
("Keys are hidden and saved in a local, unencrypted file readable only by your user: " + str(self.credentials.path),
|
|
226
|
+
"密钥隐藏输入,并保存为仅当前用户可读的本地明文文件:" + str(self.credentials.path)),
|
|
227
|
+
("Environment variables override saved keys. Select nothing to configure credentials later.",
|
|
228
|
+
"环境变量优先于保存的密钥;不选择即可稍后配置。"),
|
|
229
|
+
("Guidance: exa.ai/docs/reference/exa-mcp · docs.parallel.ai · docs.tavily.com · jina.ai/reader · platform.openai.com/api-keys",
|
|
230
|
+
"说明:exa.ai/docs/reference/exa-mcp · docs.parallel.ai · docs.tavily.com · jina.ai/reader · platform.openai.com/api-keys")])
|
|
231
|
+
status = {"environment": ("set in environment (overrides saved key)", "环境变量已设置(优先于保存的密钥)"),
|
|
232
|
+
"private_file": ("configured, not yet verified", "已配置,尚未验证"), None: ("missing", "未配置")}
|
|
233
|
+
options = [Option(row["name"], row["name"].split("_")[0].lower(), None, *status[row["source"] if row["configured"] else None], hint_always=True)
|
|
234
|
+
for row in self.credentials.describe()["credentials"]]
|
|
235
|
+
names = console.multiselect("Keys to add or replace", "新增或替换哪些密钥", options, defaults, required=False)
|
|
236
|
+
for name in names:
|
|
237
|
+
while True:
|
|
238
|
+
value = console.password(name + " (hidden; blank skips)", name + "(隐藏输入;留空跳过)")
|
|
239
|
+
if not value:
|
|
240
|
+
break
|
|
241
|
+
try:
|
|
242
|
+
result = self.credentials.save(name, value)
|
|
243
|
+
except ValueError as error:
|
|
244
|
+
console.error(str(error), "密钥或文件不符合要求,请检查输入及文件权限。")
|
|
245
|
+
continue
|
|
246
|
+
console.success(name + " saved.", name + " 已保存。")
|
|
247
|
+
if result["environment_takes_precedence"]:
|
|
248
|
+
console.warn("The current environment still overrides this value.", "当前环境变量仍会覆盖此值。")
|
|
249
|
+
break
|
|
250
|
+
|
|
251
|
+
def _integrations(self, readiness):
|
|
252
|
+
console = self.console
|
|
253
|
+
integrations = readiness["host_integrations"]
|
|
254
|
+
console.say("\nOptional host research MCPs are separate from the included search/read adapters.",
|
|
255
|
+
"\n宿主原生研究 MCP 是可选功能,与已包含的搜索/阅读适配器分开。")
|
|
256
|
+
console.say("Their login must be checked in the host session. A missing optional MCP does not prevent ordinary or deep host-led research.",
|
|
257
|
+
"这些 MCP 的登录状态需在宿主会话中检查;缺少可选 MCP 不影响宿主自己搜索、阅读或深度研究。")
|
|
258
|
+
labels = {"needs_host_session": "待宿主会话检查", "observed": "会话工具可见,授权待验证", "not_observed": "当前会话未发现"}
|
|
259
|
+
for row in integrations["native_research_mcps"]:
|
|
260
|
+
console.say(row["provider"] + ": " + row["status"], row["provider"] + ":" + labels[row["status"]])
|
|
261
|
+
selected = console.select("Show connection and login steps for an optional research MCP?",
|
|
262
|
+
"查看哪个可选研究 MCP 的接入与授权步骤?", [
|
|
263
|
+
Option("none", "Continue with the included adapters", "继续使用已包含的适配器"),
|
|
264
|
+
Option("exa", "Exa Agent"), Option("parallel", "Parallel Task MCP"),
|
|
265
|
+
Option("tavily", "Tavily Research")], "none")
|
|
266
|
+
if selected != "none":
|
|
267
|
+
row = next(row for row in integrations["native_research_mcps"] if row["provider"] == selected)
|
|
268
|
+
setup = row["setup"]
|
|
269
|
+
console.say("Inspect existing registrations first. Reuse an existing server name; run the add command only if absent.",
|
|
270
|
+
"先查看已有登记,复用现有服务名;确认未添加时才执行添加命令。")
|
|
271
|
+
for field in ("inspect_command", "add_command", "login_command"):
|
|
272
|
+
if field in setup:
|
|
273
|
+
console.say(" " + shlex.join(setup[field]), " " + shlex.join(setup[field]))
|
|
274
|
+
if "mcp_config" in setup:
|
|
275
|
+
rendered = json.dumps(setup["mcp_config"], ensure_ascii=False)
|
|
276
|
+
console.say(rendered, rendered)
|
|
277
|
+
console.say("Use " + setup["credential_env"] + " through " + setup["credential_header"] + " in the profile's headers.",
|
|
278
|
+
"在 profile 的 headers 中通过 " + setup["credential_header"] + " 引用环境变量 " + setup["credential_env"] + "。")
|
|
279
|
+
console.say(setup["login_instruction"], {
|
|
280
|
+
"codex": "在支持 OAuth 的服务上运行 login,随后新建会话并用 /mcp 检查。使用已有登记时,将命令中的服务名换成实际名称。",
|
|
281
|
+
"claude_code": "在 Claude Code 中打开 /mcp 并按提示授权;添加命令的 scope 应与目标用户或项目对应。",
|
|
282
|
+
"dsh": "在所选 profile 中配置环境变量引用的认证 header;官方桥接器的 OAuth 支持尚未确认。",
|
|
283
|
+
"pi": "Pi 基础研究使用同包 CLI;原生研究 MCP 需自行选择 MCP 扩展并在扩展中授权。",
|
|
284
|
+
"unknown": "重新运行 setup --host 指定实际宿主,或打开宿主的 MCP 设置。",
|
|
285
|
+
}[integrations["host"]])
|
|
286
|
+
console.say("Provider: " + row["docs"] + "\nHost: " + setup["reference"],
|
|
287
|
+
"服务说明:" + row["docs"] + "\n宿主说明:" + setup["reference"])
|
|
288
|
+
console.say("These steps were displayed, not executed. Saved plugin API keys do not automatically authenticate a separate host MCP.",
|
|
289
|
+
"以上步骤仅展示,尚未执行。插件保存的 API Key 不会自动授权另行添加的宿主 MCP。")
|
|
290
|
+
|
|
291
|
+
LABELS = {"unchecked": "尚未检查", "catalog_reachable": "工具目录可达,实际调用待验证",
|
|
292
|
+
"http_unchecked": "HTTP 接口待实际调用验证", "retrieval_verified": "实际检索已通过(按操作查看结果)",
|
|
293
|
+
"model_access_verified": "模型查询授权已通过,研究任务未测试",
|
|
294
|
+
"task_mcp_access_verified": "Task MCP 授权已通过,Task API 未测试",
|
|
295
|
+
"disabled": "未启用(可选)", "missing_credentials": "缺少密钥", "failed": "检查失败"}
|
|
296
|
+
FAILED = ("failed", "missing_credentials")
|
|
297
|
+
WARNING = ("unchecked", "disabled", "http_unchecked")
|
|
298
|
+
|
|
299
|
+
def _check(self, checks, test_retrieval, host, observed_tools):
|
|
300
|
+
def run():
|
|
301
|
+
return Readiness(settings=self.settings).check(refresh=checks, offline=not checks,
|
|
302
|
+
test_retrieval=test_retrieval, host=host, observed_tools=observed_tools)
|
|
303
|
+
if not self.console:
|
|
304
|
+
return run()
|
|
305
|
+
with self.console.spinner("Checking configuration and selected services…", "正在检查配置与所选服务……"):
|
|
306
|
+
readiness = run()
|
|
307
|
+
for row in readiness["checks"]:
|
|
308
|
+
english = row["id"] + ": " + row["status"]
|
|
309
|
+
chinese = row["id"] + ":" + self.LABELS.get(row["status"], row["status"])
|
|
310
|
+
if row["status"] in self.FAILED:
|
|
311
|
+
self.console.error(english, chinese)
|
|
312
|
+
self.console.say(" " + row["next_step"] + " " + row["docs"],
|
|
313
|
+
" 检查密钥、权限和连接后重新运行 setup;服务文档:" + row["docs"])
|
|
314
|
+
elif row["status"] in self.WARNING:
|
|
315
|
+
self.console.warn(english, chinese)
|
|
316
|
+
else:
|
|
317
|
+
self.console.success(english, chinese)
|
|
318
|
+
return readiness
|
|
319
|
+
|
|
320
|
+
def run(self, preferences=None, checks=True, test_retrieval=False, host="unknown", observed_tools=None):
|
|
321
|
+
if not checks and test_retrieval:
|
|
322
|
+
raise ValueError("--test-retrieval cannot be used with --skip-checks")
|
|
323
|
+
if preferences is not None:
|
|
324
|
+
self.settings.update(preferences)
|
|
325
|
+
console = self.console
|
|
326
|
+
if console:
|
|
327
|
+
console.intro("Bookmark Research setup", "Bookmark Research 配置")
|
|
328
|
+
self.settings.update(self._preferences())
|
|
329
|
+
self._keys()
|
|
330
|
+
if checks and not test_retrieval:
|
|
331
|
+
test_retrieval = console.confirm(
|
|
332
|
+
"Also test retrieval? Each provider gets one search and one example.com read; provider quota may be used.",
|
|
333
|
+
"同时进行实际检索自检?每家服务一次搜索和一次 example.com 阅读,可能使用服务额度。", False)
|
|
334
|
+
readiness = self._check(checks, test_retrieval, host, observed_tools)
|
|
335
|
+
if console and checks:
|
|
336
|
+
failed = [row for row in readiness["checks"] if row["status"] in self.FAILED]
|
|
337
|
+
if failed and console.confirm("Re-enter keys for failed services?", "为失败的服务重新输入密钥?", False):
|
|
338
|
+
names = []
|
|
339
|
+
for row in failed:
|
|
340
|
+
provider = row["id"].split(":")[-1].upper() + "_API_KEY"
|
|
341
|
+
if provider in Credentials.NAMES and provider not in names:
|
|
342
|
+
names.append(provider)
|
|
343
|
+
self._keys(names)
|
|
344
|
+
readiness = self._check(checks, test_retrieval, host, observed_tools)
|
|
345
|
+
result = {"settings": self.settings.describe(), "credentials": self.credentials.describe(), "readiness": readiness,
|
|
346
|
+
"needs_attention": [row["id"] for row in readiness["checks"] if row["status"] in self.FAILED],
|
|
347
|
+
"next_step": "Start a new host session and call research_readiness with the actually observed host tools. Rerun setup to change preferences or credentials."}
|
|
348
|
+
if console:
|
|
349
|
+
self._integrations(readiness)
|
|
350
|
+
console.outro("Setup saved. Optional MCP login must be checked in a new host session. No research job was started.",
|
|
351
|
+
"设置已保存。可选 MCP 的登录状态需在新宿主会话中确认;本次没有启动研究任务。")
|
|
352
|
+
return result
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Explicit mappings for observed Exa, Parallel and Tavily MCP schemas.
|
|
2
|
+
|
|
3
|
+
Only search and extraction are routed. Catalog annotations never authorize an
|
|
4
|
+
unknown tool, and unsupported required fields fail before a billable call.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import math
|
|
8
|
+
import re
|
|
9
|
+
|
|
10
|
+
from remote_mcp import McpError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ProviderAdapter:
|
|
14
|
+
def __init__(self, provider, info):
|
|
15
|
+
self.provider, self.info = provider, info
|
|
16
|
+
|
|
17
|
+
@staticmethod
|
|
18
|
+
def _schema_error(message):
|
|
19
|
+
return McpError("Provider tool schema changed; " + message, "schema_mismatch")
|
|
20
|
+
|
|
21
|
+
def select(self, purpose, tools):
|
|
22
|
+
for name in self.info[purpose + "_tools"]:
|
|
23
|
+
for tool in tools:
|
|
24
|
+
if isinstance(tool, dict) and tool.get("name") == name:
|
|
25
|
+
return tool
|
|
26
|
+
raise McpError("Provider does not expose its expected %s tool" % purpose,
|
|
27
|
+
"capability_unavailable")
|
|
28
|
+
|
|
29
|
+
@classmethod
|
|
30
|
+
def _validate(cls, schema, value, path="arguments", depth=0):
|
|
31
|
+
"""Check the emitted JSON-schema subset, rejecting unknown constraints."""
|
|
32
|
+
if not isinstance(schema, dict) or depth > 12:
|
|
33
|
+
raise cls._schema_error("invalid or overly nested input schema")
|
|
34
|
+
unsupported = {"$ref", "$dynamicRef", "not", "if", "then", "else", "dependentSchemas",
|
|
35
|
+
"dependentRequired", "pattern", "patternProperties", "unevaluatedProperties",
|
|
36
|
+
"contains", "prefixItems", "propertyNames"} & set(schema)
|
|
37
|
+
if unsupported:
|
|
38
|
+
raise cls._schema_error("unsupported input constraint")
|
|
39
|
+
for combinator in ("anyOf", "oneOf", "allOf"):
|
|
40
|
+
if combinator in schema:
|
|
41
|
+
branches = schema[combinator]
|
|
42
|
+
if not isinstance(branches, list) or not branches or len(branches) > 16:
|
|
43
|
+
raise cls._schema_error("invalid union schema")
|
|
44
|
+
matches = 0
|
|
45
|
+
for branch in branches:
|
|
46
|
+
try:
|
|
47
|
+
cls._validate(branch, value, path, depth + 1)
|
|
48
|
+
matches += 1
|
|
49
|
+
except McpError:
|
|
50
|
+
pass
|
|
51
|
+
expected = len(branches) if combinator == "allOf" else 1
|
|
52
|
+
if matches < expected or (combinator == "oneOf" and matches != 1):
|
|
53
|
+
raise cls._schema_error(path + " does not match its input schema")
|
|
54
|
+
kinds = schema.get("type")
|
|
55
|
+
if kinds is not None:
|
|
56
|
+
kinds = kinds if isinstance(kinds, list) else [kinds]
|
|
57
|
+
tests = {"string": isinstance(value, str), "array": isinstance(value, list),
|
|
58
|
+
"object": isinstance(value, dict), "boolean": type(value) is bool,
|
|
59
|
+
"integer": type(value) is int, "number": type(value) in (int, float),
|
|
60
|
+
"null": value is None}
|
|
61
|
+
if not kinds or any(not isinstance(kind, str) or kind not in tests for kind in kinds):
|
|
62
|
+
raise cls._schema_error("invalid field type")
|
|
63
|
+
if not any(tests[kind] for kind in kinds):
|
|
64
|
+
raise cls._schema_error(path + " has an unsupported type")
|
|
65
|
+
if "enum" in schema:
|
|
66
|
+
choices = schema["enum"]
|
|
67
|
+
if not isinstance(choices, list) or value not in choices:
|
|
68
|
+
raise cls._schema_error(path + " is outside the supported enum")
|
|
69
|
+
if "const" in schema and value != schema["const"]:
|
|
70
|
+
raise cls._schema_error(path + " does not match the required constant")
|
|
71
|
+
if isinstance(value, dict):
|
|
72
|
+
properties, required = schema.get("properties", {}), schema.get("required", [])
|
|
73
|
+
if not isinstance(properties, dict) or not isinstance(required, list) or any(not isinstance(k, str) for k in required):
|
|
74
|
+
raise cls._schema_error("invalid properties or required-fields schema")
|
|
75
|
+
missing = set(required) - set(value)
|
|
76
|
+
if missing:
|
|
77
|
+
# Report ordinary field names, never arbitrary upstream text.
|
|
78
|
+
labels = [k if re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]{0,63}", k) else "<unknown>" for k in sorted(missing)]
|
|
79
|
+
raise cls._schema_error("unsupported required fields: " + ", ".join(labels[:12]))
|
|
80
|
+
if schema.get("additionalProperties") is False and set(value) - set(properties):
|
|
81
|
+
raise cls._schema_error("unsupported extra arguments")
|
|
82
|
+
for key, item in value.items():
|
|
83
|
+
if key in properties:
|
|
84
|
+
cls._validate(properties[key], item, key, depth + 1)
|
|
85
|
+
if isinstance(value, list):
|
|
86
|
+
for bound, valid in (("minItems", lambda n: len(value) >= n), ("maxItems", lambda n: len(value) <= n)):
|
|
87
|
+
if bound in schema and (type(schema[bound]) is not int or not valid(schema[bound])):
|
|
88
|
+
raise cls._schema_error(path + " violates array limits")
|
|
89
|
+
if schema.get("uniqueItems") is True and any(value[i] in value[:i] for i in range(len(value))):
|
|
90
|
+
raise cls._schema_error(path + " requires unique items")
|
|
91
|
+
if "items" in schema:
|
|
92
|
+
for item in value:
|
|
93
|
+
cls._validate(schema["items"], item, path + "[]", depth + 1)
|
|
94
|
+
if isinstance(value, str):
|
|
95
|
+
for bound, valid in (("minLength", lambda n: len(value) >= n), ("maxLength", lambda n: len(value) <= n)):
|
|
96
|
+
if bound in schema and (type(schema[bound]) is not int or not valid(schema[bound])):
|
|
97
|
+
raise cls._schema_error(path + " violates string limits")
|
|
98
|
+
if type(value) in (int, float):
|
|
99
|
+
if not math.isfinite(value):
|
|
100
|
+
raise cls._schema_error(path + " must be finite")
|
|
101
|
+
for bound, valid in (("minimum", lambda n: value >= n), ("maximum", lambda n: value <= n),
|
|
102
|
+
("exclusiveMinimum", lambda n: value > n), ("exclusiveMaximum", lambda n: value < n)):
|
|
103
|
+
if bound in schema and (type(schema[bound]) not in (int, float) or not valid(schema[bound])):
|
|
104
|
+
raise cls._schema_error(path + " violates numeric limits")
|
|
105
|
+
if "multipleOf" in schema:
|
|
106
|
+
unit = schema["multipleOf"]
|
|
107
|
+
if type(unit) not in (int, float) or unit <= 0 or not math.isfinite(unit) or value % unit:
|
|
108
|
+
raise cls._schema_error(path + " violates numeric step")
|
|
109
|
+
|
|
110
|
+
def arguments(self, tool, purpose, query=None, urls=None, limit=5,
|
|
111
|
+
max_characters=12000, session_id=None):
|
|
112
|
+
schema = tool.get("inputSchema")
|
|
113
|
+
if not isinstance(schema, dict) or not isinstance(schema.get("properties"), dict):
|
|
114
|
+
raise self._schema_error("invalid input schema")
|
|
115
|
+
properties = schema["properties"]
|
|
116
|
+
if purpose == "search":
|
|
117
|
+
candidates = {
|
|
118
|
+
"exa": {"query": query, "objective": query, "numResults": limit},
|
|
119
|
+
"parallel": {"objective": query, "search_queries": [query], "session_id": session_id},
|
|
120
|
+
"tavily": {"query": query, "max_results": limit, "search_depth": "basic", "include_raw_content": False},
|
|
121
|
+
}[self.provider]
|
|
122
|
+
core_fields = {"objective", "search_queries"} if self.provider == "parallel" else {"query"}
|
|
123
|
+
elif purpose == "fetch":
|
|
124
|
+
candidates = {
|
|
125
|
+
"exa": {"urls": urls, "maxCharacters": max_characters},
|
|
126
|
+
"parallel": {"urls": urls, "full_content": True, "session_id": session_id},
|
|
127
|
+
"tavily": {"urls": urls, "extract_depth": "basic", "format": "markdown"},
|
|
128
|
+
}[self.provider]
|
|
129
|
+
core_fields = {"urls"}
|
|
130
|
+
if "urls" not in properties and "url" in properties and urls and len(urls) == 1:
|
|
131
|
+
candidates = {**candidates, "url": urls[0]}
|
|
132
|
+
core_fields = {"url"}
|
|
133
|
+
else:
|
|
134
|
+
raise ValueError("Unknown provider operation")
|
|
135
|
+
arguments = {key: value for key, value in candidates.items() if key in properties and value is not None}
|
|
136
|
+
# Check required fields first to make new required arguments actionable.
|
|
137
|
+
self._validate(schema, arguments)
|
|
138
|
+
if not core_fields <= set(arguments):
|
|
139
|
+
raise self._schema_error("no supported %s fields" % purpose)
|
|
140
|
+
return arguments
|
|
141
|
+
|
|
142
|
+
def capabilities(self, tools, session_id=None):
|
|
143
|
+
result = {}
|
|
144
|
+
for purpose in ("search", "fetch"):
|
|
145
|
+
try:
|
|
146
|
+
tool = self.select(purpose, tools)
|
|
147
|
+
self.arguments(tool, purpose, query="provider capability probe",
|
|
148
|
+
urls=["https://example.com/"], session_id=session_id)
|
|
149
|
+
result[purpose] = {"status": "supported", "tool": tool["name"], "execution_verified": False}
|
|
150
|
+
except McpError as error:
|
|
151
|
+
result[purpose] = {"status": "unsupported", **error.details(), "execution_verified": False}
|
|
152
|
+
return result
|