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
package/src/cli.py
ADDED
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""CLI shared by the Skill, native harness adapters, and stdio MCP."""
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import json
|
|
6
|
+
import sqlite3
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from bookmark_index import BookmarkIndex
|
|
11
|
+
from search_results import fuse_results
|
|
12
|
+
from settings import Settings
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _database(value):
|
|
16
|
+
if value:
|
|
17
|
+
path = Path(value).expanduser().resolve()
|
|
18
|
+
else:
|
|
19
|
+
path = Settings.data_directory() / "index.sqlite3"
|
|
20
|
+
plugin_root = Path(__file__).resolve().parent.parent
|
|
21
|
+
if path == plugin_root or plugin_root in path.parents:
|
|
22
|
+
raise ValueError("Keep the database outside the plugin directory; pass --db or BOOKMARK_RESEARCH_DATA_DIR")
|
|
23
|
+
return path
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _read_json(path):
|
|
27
|
+
text = sys.stdin.read() if path == "-" else Path(path).read_text(encoding="utf-8")
|
|
28
|
+
return json.loads(text)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _checked_input(path, tool, extra=None):
|
|
32
|
+
from mcp_server import TOOL_SCHEMAS, RpcError, _validate
|
|
33
|
+
value = _read_json(path)
|
|
34
|
+
if not isinstance(value, dict):
|
|
35
|
+
raise ValueError("Input must be a JSON object")
|
|
36
|
+
extra = extra or {}
|
|
37
|
+
if set(extra) & set(value):
|
|
38
|
+
raise ValueError("Positional identifiers must not be repeated in the input file")
|
|
39
|
+
value = {**value, **extra}
|
|
40
|
+
try:
|
|
41
|
+
_validate(TOOL_SCHEMAS[tool], value)
|
|
42
|
+
except RpcError as error:
|
|
43
|
+
raise ValueError(str(error)) from None
|
|
44
|
+
return value
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _parser():
|
|
48
|
+
parser = argparse.ArgumentParser(description="Bookmark Canvas queries and aggregated web research")
|
|
49
|
+
parser.add_argument("--db", help="Persistent SQLite file outside the original package and plugin")
|
|
50
|
+
parser.add_argument("--config", help="User settings JSON file; otherwise BOOKMARK_RESEARCH_CONFIG or XDG_CONFIG_HOME")
|
|
51
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
52
|
+
commands.add_parser("doctor", help="Check local runtime; does not access the network")
|
|
53
|
+
setup = commands.add_parser("setup", help="Guided preferences, credentials and service checks")
|
|
54
|
+
interaction = setup.add_mutually_exclusive_group()
|
|
55
|
+
interaction.add_argument("--interactive", dest="interaction", action="store_const", const="always")
|
|
56
|
+
interaction.add_argument("--non-interactive", dest="interaction", action="store_const", const="never")
|
|
57
|
+
setup.set_defaults(interaction="auto")
|
|
58
|
+
setup.add_argument("--lang", choices=("auto", "en", "zh"), default="auto")
|
|
59
|
+
setup.add_argument("--input", help="Partial preferences JSON file, or '-' for stdin; never put API keys here")
|
|
60
|
+
setup.add_argument("--skip-checks", action="store_true", help="Only inspect local configuration and existing cache")
|
|
61
|
+
setup.add_argument("--test-retrieval", action="store_true", help="One search and sample page read per provider; uses retrieval quota")
|
|
62
|
+
setup.add_argument("--host", choices=("codex", "claude_code", "pi", "dsh", "unknown"), default="unknown")
|
|
63
|
+
setup.add_argument("--tool", action="append", help="Tool actually observed in the current host session; repeatable")
|
|
64
|
+
readiness = commands.add_parser("readiness", help="Check capabilities and auth before web research; respects saved cache policy")
|
|
65
|
+
readiness.add_argument("--provider", action="append", choices=Settings.PROVIDERS)
|
|
66
|
+
readiness.add_argument("--service", choices=("openai", "parallel"))
|
|
67
|
+
readiness.add_argument("--refresh", action="store_true")
|
|
68
|
+
readiness.add_argument("--offline", action="store_true")
|
|
69
|
+
readiness.add_argument("--test-retrieval", action="store_true", help="Also spend retrieval quota on a sample search/read")
|
|
70
|
+
readiness.add_argument("--host", choices=("codex", "claude_code", "pi", "dsh", "unknown"), default="unknown")
|
|
71
|
+
readiness.add_argument("--tool", action="append", help="Tool actually observed in the current host session; repeatable")
|
|
72
|
+
config = commands.add_parser("config", help="View or update persistent user preferences")
|
|
73
|
+
config_commands = config.add_subparsers(dest="config_command", required=True)
|
|
74
|
+
config_commands.add_parser("show")
|
|
75
|
+
configure = config_commands.add_parser("set")
|
|
76
|
+
configure.add_argument("--input", help="Partial settings JSON file, or '-' for stdin")
|
|
77
|
+
configure.add_argument("--archive", choices=("true", "false"))
|
|
78
|
+
configure.add_argument("--archive-dir")
|
|
79
|
+
configure.add_argument("--search-provider", action="append", choices=Settings.PROVIDERS)
|
|
80
|
+
configure.add_argument("--search-fallback-provider", action="append", choices=Settings.PROVIDERS)
|
|
81
|
+
configure.add_argument("--fetch-provider", choices=Settings.PROVIDERS)
|
|
82
|
+
configure.add_argument("--fetch-fallback-provider", action="append", choices=Settings.PROVIDERS)
|
|
83
|
+
configure.add_argument("--max-characters", type=int)
|
|
84
|
+
sync = commands.add_parser("sync", help="Import a directory, ZIP, or single card; keep a reusable snapshot")
|
|
85
|
+
sync.add_argument("package")
|
|
86
|
+
sync.add_argument("--source-id")
|
|
87
|
+
sync.add_argument("--mode", choices=("snapshot", "live"), help="New exports default to snapshot; Git directories to live; existing sources retain their mode")
|
|
88
|
+
sync.add_argument("--completeness", choices=("partial", "complete"), help="partial retains absent cards; complete reconciles a full directory")
|
|
89
|
+
status = commands.add_parser("status")
|
|
90
|
+
status.add_argument("source", nargs="?")
|
|
91
|
+
history = commands.add_parser("history", help="List reusable source snapshot versions")
|
|
92
|
+
history.add_argument("source")
|
|
93
|
+
history.add_argument("--limit", type=int, default=20)
|
|
94
|
+
history.add_argument("--offset", type=int, default=0)
|
|
95
|
+
watch = commands.add_parser("watch", help="Monitor live sources in this foreground process (MCP starts this automatically)")
|
|
96
|
+
watch.add_argument("--interval", type=float, default=1.0)
|
|
97
|
+
watch.add_argument("--debounce", type=float, default=2.0)
|
|
98
|
+
watch.add_argument("--deletion-grace", type=float, default=5.0)
|
|
99
|
+
search = commands.add_parser("search", help="Literal structured bookmark search")
|
|
100
|
+
search.add_argument("source")
|
|
101
|
+
search.add_argument("--target", action="append", default=[])
|
|
102
|
+
search.add_argument("--section")
|
|
103
|
+
search.add_argument("--group")
|
|
104
|
+
search.add_argument("--folder")
|
|
105
|
+
search.add_argument("--tag", action="append", default=[])
|
|
106
|
+
search.add_argument("--limit", type=int, default=20)
|
|
107
|
+
search.add_argument("--offset", type=int, default=0)
|
|
108
|
+
search.add_argument("--no-refresh", action="store_true", help="Read the last synchronized index without refreshing")
|
|
109
|
+
context = commands.add_parser("context")
|
|
110
|
+
context.add_argument("source")
|
|
111
|
+
context.add_argument("--section")
|
|
112
|
+
context.add_argument("--group")
|
|
113
|
+
context.add_argument("--item")
|
|
114
|
+
context.add_argument("--no-refresh", action="store_true")
|
|
115
|
+
providers = commands.add_parser("providers")
|
|
116
|
+
providers.add_argument("--probe", action="store_true", help="Connect to selected official MCP endpoints")
|
|
117
|
+
providers.add_argument("--provider", action="append")
|
|
118
|
+
providers.add_argument("--timeout", type=int)
|
|
119
|
+
web = commands.add_parser("search-web")
|
|
120
|
+
web.add_argument("--input", help="JSON {targets:[{target,query}],providers?,limit_per_target?}; '-' for stdin")
|
|
121
|
+
web.add_argument("--target", action="append", help="Use a string as both target label and query")
|
|
122
|
+
web.add_argument("--provider", action="append")
|
|
123
|
+
web.add_argument("--limit", type=int)
|
|
124
|
+
web.add_argument("--timeout", type=int)
|
|
125
|
+
fetch = commands.add_parser("fetch-web")
|
|
126
|
+
fetch.add_argument("urls", nargs="+")
|
|
127
|
+
fetch.add_argument("--provider", choices=Settings.PROVIDERS)
|
|
128
|
+
fetch.add_argument("--timeout", type=int, help="Per provider HTTP request timeout; discovery and fallback can take longer overall")
|
|
129
|
+
fetch.add_argument("--max-characters", type=int)
|
|
130
|
+
fetch.add_argument("--raw", action="store_true", help="Return full provider envelopes instead of one selected text per URL")
|
|
131
|
+
archival = fetch.add_mutually_exclusive_group()
|
|
132
|
+
archival.add_argument("--archive", dest="archive", action="store_true", default=None)
|
|
133
|
+
archival.add_argument("--no-archive", dest="archive", action="store_false")
|
|
134
|
+
merge = commands.add_parser("merge-results", help="Fuse normalized batches from existing MCPs")
|
|
135
|
+
merge.add_argument("input", help="JSON input file, or '-' for stdin")
|
|
136
|
+
research = commands.add_parser("research", help="Durable host-led deep research, evidence and reports")
|
|
137
|
+
research.add_argument("--directory", help="Research root outside source packages and plugin; default in the data directory")
|
|
138
|
+
research_commands = research.add_subparsers(dest="research_command", required=True)
|
|
139
|
+
begin = research_commands.add_parser("start", help="Save a brief, questions and budgets without contacting providers")
|
|
140
|
+
begin.add_argument("--input", required=True, help="JSON {brief,questions:[{id,question}],urls? or source_ids?,budget?,providers?,scope?}")
|
|
141
|
+
inspect = research_commands.add_parser("status", help="List sessions or inspect progress without network calls")
|
|
142
|
+
inspect.add_argument("research_id", nargs="?")
|
|
143
|
+
inspect.add_argument("--section", choices=("questions", "claims", "sources", "operations", "conflicts", "bookmark_context", "events", "inventory", "inventory_reviews", "external_runs"))
|
|
144
|
+
inspect.add_argument("--offset", type=int, default=0)
|
|
145
|
+
inspect.add_argument("--limit", type=int, default=20)
|
|
146
|
+
for name in ("search", "fetch", "record", "import-evidence"):
|
|
147
|
+
step = research_commands.add_parser(name)
|
|
148
|
+
step.add_argument("research_id")
|
|
149
|
+
step.add_argument("--input", required=True, help="Action JSON or '-' for stdin; research_id comes from the positional argument")
|
|
150
|
+
source = research_commands.add_parser("source", help="Read and verify saved source text")
|
|
151
|
+
source.add_argument("research_id")
|
|
152
|
+
source.add_argument("source_id")
|
|
153
|
+
source.add_argument("--offset", type=int, default=0)
|
|
154
|
+
source.add_argument("--limit", type=int, default=12000)
|
|
155
|
+
finish = research_commands.add_parser("finish", help="Validate evidence and write report.md plus sources.json")
|
|
156
|
+
finish.add_argument("research_id")
|
|
157
|
+
finish.add_argument("--summary", required=True)
|
|
158
|
+
finish.add_argument("--status", choices=("completed", "incomplete", "cancelled"), default="completed")
|
|
159
|
+
finish.add_argument("--limitation", action="append")
|
|
160
|
+
for name in ("inventory", "coverage"):
|
|
161
|
+
view = research_commands.add_parser(name, help="Read the frozen source scope or remaining evidence gaps")
|
|
162
|
+
view.add_argument("research_id")
|
|
163
|
+
view.add_argument("--offset", type=int, default=0)
|
|
164
|
+
view.add_argument("--limit", type=int, default=100)
|
|
165
|
+
if name == "inventory":
|
|
166
|
+
view.add_argument("--inventory-id", action="append")
|
|
167
|
+
else:
|
|
168
|
+
view.add_argument("--filter", choices=("all", "missing", "unread", "unreviewed", "blocked", "excluded", "reviewed"))
|
|
169
|
+
route = research_commands.add_parser("route", help="Select from observed host research capabilities; does not execute")
|
|
170
|
+
route.add_argument("--input", help="JSON current host/tools/commands, or '-' for stdin")
|
|
171
|
+
route.add_argument("--host", choices=("codex", "claude_code", "pi", "dsh", "unknown"), default="unknown")
|
|
172
|
+
route.add_argument("--depth", choices=("auto", "quick", "agentic", "deep"))
|
|
173
|
+
route.add_argument("--task-shape", choices=("lookup", "investigation", "batch_research"), default="investigation")
|
|
174
|
+
route.add_argument("--tool", action="append")
|
|
175
|
+
route.add_argument("--available-command", action="append")
|
|
176
|
+
route.add_argument("--extension", action="append")
|
|
177
|
+
route.add_argument("--failed-route", action="append")
|
|
178
|
+
route.add_argument("--provider", choices=("openai", "parallel"))
|
|
179
|
+
service = research_commands.add_parser("service", help="Prepare/start/observe existing professional research runs")
|
|
180
|
+
service_commands = service.add_subparsers(dest="service_command", required=True)
|
|
181
|
+
describe = service_commands.add_parser("describe")
|
|
182
|
+
describe.add_argument("--provider", choices=("openai", "parallel"))
|
|
183
|
+
for name in ("prepare", "start", "attach"):
|
|
184
|
+
action = service_commands.add_parser(name)
|
|
185
|
+
action.add_argument("--input", required=True, help="Explicit request JSON, including research_id")
|
|
186
|
+
for name in ("status", "result", "cancel", "import"):
|
|
187
|
+
action = service_commands.add_parser(name)
|
|
188
|
+
action.add_argument("external_id")
|
|
189
|
+
if name in ("status", "result"):
|
|
190
|
+
action.add_argument("--refresh", action="store_true", help="Observe the same provider run once")
|
|
191
|
+
if name == "result":
|
|
192
|
+
action.add_argument("--offset", type=int, default=0)
|
|
193
|
+
action.add_argument("--limit", type=int, default=12000)
|
|
194
|
+
if name == "import":
|
|
195
|
+
action.add_argument("--operation-id", required=True)
|
|
196
|
+
action.add_argument("--question-id")
|
|
197
|
+
wiki = commands.add_parser("wiki", help="Author and search evidence-linked knowledge outside source packages")
|
|
198
|
+
wiki.add_argument("--directory")
|
|
199
|
+
wiki.add_argument("--research-directory")
|
|
200
|
+
wiki_commands = wiki.add_subparsers(dest="wiki_command", required=True)
|
|
201
|
+
write = wiki_commands.add_parser("write")
|
|
202
|
+
write.add_argument("page_id")
|
|
203
|
+
write.add_argument("--input", required=True, help="JSON {page,change_note,expected_revision?}")
|
|
204
|
+
get = wiki_commands.add_parser("get")
|
|
205
|
+
get.add_argument("page_id")
|
|
206
|
+
get.add_argument("--revision", type=int)
|
|
207
|
+
lint = wiki_commands.add_parser("lint")
|
|
208
|
+
lint.add_argument("--page-id")
|
|
209
|
+
for name in ("list", "search"):
|
|
210
|
+
view = wiki_commands.add_parser(name)
|
|
211
|
+
if name == "search":
|
|
212
|
+
view.add_argument("query")
|
|
213
|
+
view.add_argument("--offset", type=int, default=0)
|
|
214
|
+
view.add_argument("--limit", type=int, default=20)
|
|
215
|
+
evaluation = commands.add_parser("evaluate", help="Compute research metrics from explicit benchmark outputs and judgments")
|
|
216
|
+
evaluation.add_argument("--input", required=True, help="JSON {suite,runs,judgments?}; '-' for stdin")
|
|
217
|
+
commands.add_parser("serve", help="Run the stdio MCP tool server")
|
|
218
|
+
return parser
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def main(argv=None):
|
|
222
|
+
from textio import utf8_stdio
|
|
223
|
+
utf8_stdio()
|
|
224
|
+
args = _parser().parse_args(argv)
|
|
225
|
+
try:
|
|
226
|
+
settings = Settings(args.config)
|
|
227
|
+
if args.command == "serve":
|
|
228
|
+
from mcp_server import serve
|
|
229
|
+
serve(str(_database(args.db)), settings=settings)
|
|
230
|
+
return 0
|
|
231
|
+
if args.command == "watch":
|
|
232
|
+
from source_watcher import SourceWatcher
|
|
233
|
+
watcher = SourceWatcher(_database(args.db), interval=args.interval, debounce=args.debounce,
|
|
234
|
+
deletion_grace=args.deletion_grace,
|
|
235
|
+
on_change=lambda value: print(json.dumps(value, ensure_ascii=False), flush=True))
|
|
236
|
+
try:
|
|
237
|
+
watcher.run()
|
|
238
|
+
except KeyboardInterrupt:
|
|
239
|
+
pass
|
|
240
|
+
finally:
|
|
241
|
+
watcher.close()
|
|
242
|
+
if watcher.last_error:
|
|
243
|
+
raise RuntimeError(watcher.last_error)
|
|
244
|
+
return 0
|
|
245
|
+
if args.command == "setup":
|
|
246
|
+
from onboarding import Console, Onboarding
|
|
247
|
+
if args.skip_checks and args.test_retrieval:
|
|
248
|
+
raise ValueError("--test-retrieval cannot be used with --skip-checks")
|
|
249
|
+
with Console.open(args.interaction, Console.resolve_language(args.lang)) as console:
|
|
250
|
+
result = Onboarding(settings, console).run(_read_json(args.input) if args.input else None,
|
|
251
|
+
checks=not args.skip_checks, test_retrieval=args.test_retrieval, host=args.host, observed_tools=args.tool)
|
|
252
|
+
elif args.command == "readiness":
|
|
253
|
+
from readiness import Readiness
|
|
254
|
+
result = Readiness(settings).check(providers=args.provider, service=args.service, refresh=args.refresh,
|
|
255
|
+
offline=args.offline, test_retrieval=args.test_retrieval, host=args.host, observed_tools=args.tool)
|
|
256
|
+
elif args.command == "research":
|
|
257
|
+
from research import ResearchSessions
|
|
258
|
+
sessions = ResearchSessions(directory=args.directory, settings=settings, db_path=_database(args.db))
|
|
259
|
+
action = args.research_command
|
|
260
|
+
if action == "route":
|
|
261
|
+
from routing import ResearchRouting
|
|
262
|
+
payload = _checked_input(args.input, "research_route") if args.input else {
|
|
263
|
+
"host": args.host, "depth": args.depth, "task_shape": args.task_shape,
|
|
264
|
+
"observed_tools": args.tool, "available_commands": args.available_command,
|
|
265
|
+
"installed_extensions": args.extension, "provider": args.provider, "failed_routes": args.failed_route}
|
|
266
|
+
result = ResearchRouting(settings).route(**payload)
|
|
267
|
+
elif action == "service":
|
|
268
|
+
from research_services import ResearchServices
|
|
269
|
+
services = ResearchServices(settings=settings, research_sessions=sessions)
|
|
270
|
+
service_action = args.service_command
|
|
271
|
+
if service_action in ("prepare", "start", "attach"):
|
|
272
|
+
payload = _checked_input(args.input, "research_service_" + service_action)
|
|
273
|
+
result = getattr(services, service_action)(**payload)
|
|
274
|
+
elif service_action == "describe":
|
|
275
|
+
result = services.describe(args.provider)
|
|
276
|
+
elif service_action == "status":
|
|
277
|
+
result = services.status(args.external_id, args.refresh)
|
|
278
|
+
elif service_action == "result":
|
|
279
|
+
result = services.result(args.external_id, args.refresh, args.offset, args.limit)
|
|
280
|
+
elif service_action == "cancel":
|
|
281
|
+
result = services.cancel(args.external_id)
|
|
282
|
+
else:
|
|
283
|
+
result = services.import_result(args.external_id, args.operation_id, args.question_id)
|
|
284
|
+
elif action == "inventory":
|
|
285
|
+
result = sessions.inventory(args.research_id, args.inventory_id, args.offset, args.limit)
|
|
286
|
+
elif action == "coverage":
|
|
287
|
+
result = sessions.coverage(args.research_id, args.filter, args.offset, args.limit)
|
|
288
|
+
elif action == "import-evidence":
|
|
289
|
+
result = sessions.import_evidence(**_checked_input(args.input, "research_import_evidence", {"research_id": args.research_id}))
|
|
290
|
+
elif action in ("start", "search", "fetch", "record"):
|
|
291
|
+
payload = _read_json(args.input)
|
|
292
|
+
if not isinstance(payload, dict):
|
|
293
|
+
raise ValueError("Research input must be an object")
|
|
294
|
+
allowed = {"start": {"brief", "questions", "budget", "providers", "scope", "source_ids", "bookmark_refs", "scope_mode", "inventory_ids", "urls"},
|
|
295
|
+
"search": {"operation_id", "queries", "providers", "limit_per_target"},
|
|
296
|
+
"fetch": {"operation_id", "question_id", "urls", "provider", "max_characters"}}
|
|
297
|
+
required = {"start": {"brief", "questions"}, "search": {"operation_id", "queries"},
|
|
298
|
+
"fetch": {"operation_id", "question_id", "urls"}}
|
|
299
|
+
if action != "record" and (set(payload) - allowed[action] or required[action] - set(payload)):
|
|
300
|
+
raise ValueError("Research input has missing or unknown fields for " + action)
|
|
301
|
+
if action == "start":
|
|
302
|
+
result = sessions.start(**payload)
|
|
303
|
+
elif action == "search":
|
|
304
|
+
result = sessions.search(args.research_id, **payload)
|
|
305
|
+
elif action == "fetch":
|
|
306
|
+
result = sessions.fetch(args.research_id, **payload)
|
|
307
|
+
elif "kind" in payload:
|
|
308
|
+
result = sessions.record(args.research_id, payload)
|
|
309
|
+
else:
|
|
310
|
+
if set(payload) - {"entry", "entries", "batch_id"}:
|
|
311
|
+
raise ValueError("Research record input has unknown fields")
|
|
312
|
+
result = sessions.record(args.research_id, **payload)
|
|
313
|
+
elif action == "status":
|
|
314
|
+
result = sessions.status(args.research_id, args.section, args.offset, args.limit)
|
|
315
|
+
elif action == "source":
|
|
316
|
+
result = sessions.source(args.research_id, args.source_id, args.offset, args.limit)
|
|
317
|
+
else:
|
|
318
|
+
result = sessions.finish(args.research_id, args.summary, args.status, args.limitation)
|
|
319
|
+
elif args.command == "wiki":
|
|
320
|
+
from research import ResearchSessions
|
|
321
|
+
from wiki import WikiStore
|
|
322
|
+
sessions = ResearchSessions(directory=args.research_directory, settings=settings, db_path=_database(args.db))
|
|
323
|
+
store = WikiStore(directory=args.directory, settings=settings, research_sessions=sessions)
|
|
324
|
+
if args.wiki_command == "write":
|
|
325
|
+
result = store.write(**_checked_input(args.input, "wiki_write", {"page_id": args.page_id}))
|
|
326
|
+
elif args.wiki_command == "get":
|
|
327
|
+
result = store.get(args.page_id, args.revision)
|
|
328
|
+
elif args.wiki_command == "list":
|
|
329
|
+
result = store.list(args.offset, args.limit)
|
|
330
|
+
elif args.wiki_command == "search":
|
|
331
|
+
result = store.search(args.query, args.offset, args.limit)
|
|
332
|
+
else:
|
|
333
|
+
result = store.lint(args.page_id)
|
|
334
|
+
elif args.command == "evaluate":
|
|
335
|
+
from evaluation import evaluate
|
|
336
|
+
result = evaluate(**_checked_input(args.input, "evaluate_research"))
|
|
337
|
+
elif args.command == "config":
|
|
338
|
+
if args.config_command == "show":
|
|
339
|
+
result = settings.describe()
|
|
340
|
+
else:
|
|
341
|
+
changes = _read_json(args.input) if args.input else {}
|
|
342
|
+
if not isinstance(changes, dict):
|
|
343
|
+
raise ValueError("Settings input must be an object")
|
|
344
|
+
for section, field, value in (
|
|
345
|
+
("archive", "enabled", None if args.archive is None else args.archive == "true"),
|
|
346
|
+
("archive", "directory", args.archive_dir),
|
|
347
|
+
("search", "providers", args.search_provider),
|
|
348
|
+
("search", "fallback_providers", args.search_fallback_provider),
|
|
349
|
+
("fetch", "provider", args.fetch_provider),
|
|
350
|
+
("fetch", "providers", args.fetch_fallback_provider),
|
|
351
|
+
("fetch", "max_characters", args.max_characters)):
|
|
352
|
+
if value is not None:
|
|
353
|
+
if not isinstance(changes.get(section, {}), dict):
|
|
354
|
+
raise ValueError("Settings section must be an object: " + section)
|
|
355
|
+
changes.setdefault(section, {})[field] = value
|
|
356
|
+
result = settings.update(changes)
|
|
357
|
+
elif args.command == "doctor":
|
|
358
|
+
connection = sqlite3.connect(":memory:")
|
|
359
|
+
try:
|
|
360
|
+
connection.execute("CREATE VIRTUAL TABLE probe USING fts5(text)")
|
|
361
|
+
fts5 = True
|
|
362
|
+
except sqlite3.OperationalError:
|
|
363
|
+
fts5 = False
|
|
364
|
+
finally:
|
|
365
|
+
connection.close()
|
|
366
|
+
result = {"python": sys.version.split()[0], "sqlite": sqlite3.sqlite_version,
|
|
367
|
+
"fts5": fts5, "database": str(_database(args.db)),
|
|
368
|
+
"config_path": str(settings.path),
|
|
369
|
+
"database_created": False, "network_checked": False,
|
|
370
|
+
"dependencies": "Python standard library"}
|
|
371
|
+
elif args.command in ("providers", "search-web", "fetch-web"):
|
|
372
|
+
from web_search import SearchProviders
|
|
373
|
+
engine = SearchProviders(timeout=args.timeout, settings=settings)
|
|
374
|
+
if args.command == "providers":
|
|
375
|
+
result = engine.probe(args.provider) if args.probe else engine.describe(args.provider)
|
|
376
|
+
elif args.command == "fetch-web":
|
|
377
|
+
result = engine.fetch(args.urls, args.provider, archive=args.archive, max_characters=args.max_characters)
|
|
378
|
+
if not args.raw:
|
|
379
|
+
from archive import SourceArchive
|
|
380
|
+
result = SourceArchive.compact_fetch(result)
|
|
381
|
+
else:
|
|
382
|
+
payload = _read_json(args.input) if args.input else {
|
|
383
|
+
"targets": [{"target": q, "query": q} for q in (args.target or [])],
|
|
384
|
+
"providers": args.provider, "limit_per_target": args.limit}
|
|
385
|
+
if not isinstance(payload, dict) or set(payload) - {"targets", "providers", "limit_per_target"}:
|
|
386
|
+
raise ValueError("Search input accepts targets, providers and limit_per_target only")
|
|
387
|
+
result = engine.search(payload.get("targets"), args.provider if args.provider is not None else payload.get("providers"),
|
|
388
|
+
args.limit if args.limit is not None else payload.get("limit_per_target"))
|
|
389
|
+
elif args.command == "merge-results":
|
|
390
|
+
result = fuse_results(_read_json(args.input))
|
|
391
|
+
else:
|
|
392
|
+
from source_manager import SourceManager
|
|
393
|
+
with BookmarkIndex(str(_database(args.db))) as index:
|
|
394
|
+
sources = SourceManager(index)
|
|
395
|
+
if args.command == "sync":
|
|
396
|
+
result = sources.sync(args.package, args.source_id, mode=args.mode, completeness=args.completeness)
|
|
397
|
+
elif args.command == "status":
|
|
398
|
+
result = sources.status(args.source)
|
|
399
|
+
elif args.command == "history":
|
|
400
|
+
result = sources.history(args.source, limit=args.limit, offset=args.offset)
|
|
401
|
+
else:
|
|
402
|
+
refresh = None if args.no_refresh else sources.refresh(args.source)
|
|
403
|
+
if refresh and refresh["state"] in ("error", "unavailable"):
|
|
404
|
+
raise ValueError(refresh["error"] + "; use --no-refresh to read the saved index")
|
|
405
|
+
with index.read_snapshot():
|
|
406
|
+
if args.command == "search":
|
|
407
|
+
result = index.search(args.source, targets=args.target, section=args.section,
|
|
408
|
+
group_id=args.group, folder_id=args.folder, tags=args.tag,
|
|
409
|
+
limit=args.limit, offset=args.offset)
|
|
410
|
+
else:
|
|
411
|
+
result = index.context(args.source, section=args.section, group_id=args.group,
|
|
412
|
+
item_id=args.item)
|
|
413
|
+
result["source"] = sources.status(args.source)["source"]
|
|
414
|
+
result["refresh"] = refresh
|
|
415
|
+
result["stored_snapshot_only"] = args.no_refresh
|
|
416
|
+
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
417
|
+
if args.command == "providers" and args.probe and result["successful_provider_count"] == 0:
|
|
418
|
+
return 1
|
|
419
|
+
if args.command == "search-web" and result.get("successful_provider_count") == 0:
|
|
420
|
+
return 1
|
|
421
|
+
if args.command == "fetch-web" and result.get("status") == "error":
|
|
422
|
+
return 1
|
|
423
|
+
if args.command == "research" and result.get("status") == "error":
|
|
424
|
+
return 1
|
|
425
|
+
return 0
|
|
426
|
+
except KeyboardInterrupt:
|
|
427
|
+
print(json.dumps({"cancelled": True, "error": "Setup cancelled; previously saved preferences and keys are retained."}))
|
|
428
|
+
return 130
|
|
429
|
+
except (ValueError, OSError, RuntimeError, sqlite3.Error) as error:
|
|
430
|
+
print(json.dumps({"error": str(error), "type": type(error).__name__}, ensure_ascii=False))
|
|
431
|
+
return 1
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
if __name__ == "__main__":
|
|
435
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Optional private credential file; process environment always takes precedence."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import stat
|
|
7
|
+
import tempfile
|
|
8
|
+
|
|
9
|
+
from settings import Settings
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Credentials:
|
|
13
|
+
NAMES = ("EXA_API_KEY", "PARALLEL_API_KEY", "TAVILY_API_KEY", "JINA_API_KEY", "OPENAI_API_KEY")
|
|
14
|
+
|
|
15
|
+
def __init__(self, settings=None):
|
|
16
|
+
settings = settings if settings is not None else Settings()
|
|
17
|
+
self.path = Path(os.environ.get("BOOKMARK_RESEARCH_CREDENTIALS") or
|
|
18
|
+
settings.path.with_name("credentials.json")).expanduser().absolute()
|
|
19
|
+
|
|
20
|
+
def _read(self):
|
|
21
|
+
if self.path.is_symlink():
|
|
22
|
+
raise ValueError("Credential file must not be a symlink")
|
|
23
|
+
try:
|
|
24
|
+
descriptor = os.open(self.path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
|
|
25
|
+
except FileNotFoundError:
|
|
26
|
+
return {}
|
|
27
|
+
with os.fdopen(descriptor, encoding="utf-8") as stream:
|
|
28
|
+
info = os.fstat(stream.fileno())
|
|
29
|
+
if not stat.S_ISREG(info.st_mode) or (os.name != "nt" and info.st_mode & 0o077):
|
|
30
|
+
raise ValueError("Credential file must be a private regular file (chmod 600)")
|
|
31
|
+
if info.st_size > 64000:
|
|
32
|
+
raise ValueError("Credential file exceeds the size limit")
|
|
33
|
+
try:
|
|
34
|
+
value = json.load(stream, object_pairs_hook=Settings._unique_object)
|
|
35
|
+
except (ValueError, UnicodeError):
|
|
36
|
+
raise ValueError("Credential file is not valid JSON") from None
|
|
37
|
+
self._validate(value)
|
|
38
|
+
return value
|
|
39
|
+
|
|
40
|
+
@classmethod
|
|
41
|
+
def _validate(cls, value):
|
|
42
|
+
if not isinstance(value, dict) or set(value) - set(cls.NAMES):
|
|
43
|
+
raise ValueError("Credential file contains unsupported fields")
|
|
44
|
+
if any(not isinstance(key, str) or not 1 <= len(key) <= 8192
|
|
45
|
+
or any(not 33 <= ord(char) <= 126 for char in key) for key in value.values()):
|
|
46
|
+
raise ValueError("Credentials must be nonempty printable API keys without whitespace")
|
|
47
|
+
|
|
48
|
+
def get(self, name):
|
|
49
|
+
if name not in self.NAMES:
|
|
50
|
+
raise ValueError("Unsupported credential name")
|
|
51
|
+
return os.environ.get(name) or self._read().get(name)
|
|
52
|
+
|
|
53
|
+
def describe(self):
|
|
54
|
+
saved = self._read()
|
|
55
|
+
return {"path": str(self.path), "storage": "private_file_not_encrypted",
|
|
56
|
+
"credentials": [{"name": name, "configured": bool(os.environ.get(name) or saved.get(name)),
|
|
57
|
+
"source": "environment" if os.environ.get(name) else "private_file" if saved.get(name) else None}
|
|
58
|
+
for name in self.NAMES]}
|
|
59
|
+
|
|
60
|
+
def save(self, name, value):
|
|
61
|
+
self._validate({name: value})
|
|
62
|
+
if self.path.is_symlink():
|
|
63
|
+
raise ValueError("Credential file must not be a symlink")
|
|
64
|
+
path = Settings.external_path(str(self.path), "Credential file")
|
|
65
|
+
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
66
|
+
with Settings._update_lock(path):
|
|
67
|
+
saved = {**self._read(), name: value}
|
|
68
|
+
temporary = None
|
|
69
|
+
try:
|
|
70
|
+
with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", newline="", dir=path.parent,
|
|
71
|
+
prefix=".credentials-", delete=False) as stream:
|
|
72
|
+
temporary = Path(stream.name)
|
|
73
|
+
os.chmod(temporary, 0o600)
|
|
74
|
+
json.dump(saved, stream, ensure_ascii=False)
|
|
75
|
+
stream.write("\n")
|
|
76
|
+
stream.flush()
|
|
77
|
+
os.fsync(stream.fileno())
|
|
78
|
+
temporary.replace(path)
|
|
79
|
+
finally:
|
|
80
|
+
if temporary is not None:
|
|
81
|
+
temporary.unlink(missing_ok=True)
|
|
82
|
+
return {"name": name, "saved": True, "environment_takes_precedence": bool(os.environ.get(name))}
|