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.
Files changed (106) hide show
  1. package/.agents/plugins/marketplace.json +20 -0
  2. package/.codex-plugin/plugin.json +46 -0
  3. package/LICENSE +674 -0
  4. package/README.md +128 -0
  5. package/README.zh.md +126 -0
  6. package/bin/bookmark-research.js +30 -0
  7. package/config/providers.json +98 -0
  8. package/docs/bookmark-research-architecture.md +124 -0
  9. package/docs/cli-launcher-plan.md +58 -0
  10. package/docs/details.en.md +272 -0
  11. package/docs/details.md +93 -0
  12. package/docs/harness-compatibility.md +304 -0
  13. package/docs/harness-sources.json +342 -0
  14. package/docs/host-validation.json +298 -0
  15. package/docs/install-design-0.2.0.md +57 -0
  16. package/docs/installation.en.md +197 -0
  17. package/docs/installation.md +316 -0
  18. package/docs/instructions.en.md +42 -0
  19. package/docs/instructions.md +42 -0
  20. package/docs/mcp-aggregation-0.2.0.md +92 -0
  21. package/docs/multi-host-installation-plan.md +163 -0
  22. package/docs/prompt-reference.en.md +104 -0
  23. package/docs/prompt-reference.md +104 -0
  24. package/docs/provider-smoke-0.2.0.json +235 -0
  25. package/docs/release-notes-0.5.0-beta.1.md +33 -0
  26. package/docs/research-0.2.0.md +140 -0
  27. package/docs/research-sources-0.2.0.json +9360 -0
  28. package/docs/user-guide.en.md +74 -0
  29. package/docs/user-guide.md +74 -0
  30. package/docs/validation-0.2.0.md +126 -0
  31. package/docs/validation-0.4.0.md +154 -0
  32. package/docs/wiki-quality.md +105 -0
  33. package/docs/wiki-quality.zh.md +105 -0
  34. package/hosts/claude/runtime.js +13 -0
  35. package/hosts/codex/delegate.md +14 -0
  36. package/hosts/codex/prepare.py +61 -0
  37. package/hosts/dsh/plugin.js +36 -0
  38. package/hosts/dsh/runtime.js +14 -0
  39. package/hosts/dsh/workflow-call.py +45 -0
  40. package/hosts/pi/register-workflow.py +72 -0
  41. package/hosts/pi/runtime.js +18 -0
  42. package/hosts/shared/research-call.py +76 -0
  43. package/hosts/shared/research-flow.js +208 -0
  44. package/hosts/shared/workflow.json +15 -0
  45. package/install.sh +310 -0
  46. package/package.json +12 -0
  47. package/scripts/build_zip.py +143 -0
  48. package/scripts/export_bundle.py +496 -0
  49. package/scripts/host_assets.py +70 -0
  50. package/scripts/host_clients.py +196 -0
  51. package/scripts/host_install.py +277 -0
  52. package/scripts/install.py +593 -0
  53. package/scripts/launcher.py +186 -0
  54. package/scripts/verify_fixture.py +220 -0
  55. package/scripts/verify_live.py +62 -0
  56. package/scripts/verify_quality.py +87 -0
  57. package/skills/bookmark-research/SKILL.md +80 -0
  58. package/skills/bookmark-research/agents/openai.yaml +3 -0
  59. package/skills/bookmark-research/references/cli.md +150 -0
  60. package/skills/bookmark-research/references/deep-research.md +164 -0
  61. package/skills/bookmark-research/references/github-and-sync.md +30 -0
  62. package/skills/bookmark-research/references/host-workflows.md +101 -0
  63. package/skills/bookmark-research/references/package-semantics.md +78 -0
  64. package/skills/bookmark-research/references/research-methods.md +51 -0
  65. package/skills/bookmark-research/references/research-services.md +72 -0
  66. package/skills/bookmark-research/references/research-workflow.md +57 -0
  67. package/skills/bookmark-research/references/settings-and-archive.md +104 -0
  68. package/skills/bookmark-research/references/source-lifecycle.md +75 -0
  69. package/skills/bookmark-research/references/wiki-and-evaluation.md +63 -0
  70. package/skills/bookmark-research/references/zh/cli.md +150 -0
  71. package/skills/bookmark-research/references/zh/deep-research.md +164 -0
  72. package/skills/bookmark-research/references/zh/github-and-sync.md +30 -0
  73. package/skills/bookmark-research/references/zh/host-workflows.md +101 -0
  74. package/skills/bookmark-research/references/zh/package-semantics.md +78 -0
  75. package/skills/bookmark-research/references/zh/research-methods.md +51 -0
  76. package/skills/bookmark-research/references/zh/research-services.md +70 -0
  77. package/skills/bookmark-research/references/zh/research-workflow.md +57 -0
  78. package/skills/bookmark-research/references/zh/settings-and-archive.md +104 -0
  79. package/skills/bookmark-research/references/zh/skill-guide.md +75 -0
  80. package/skills/bookmark-research/references/zh/source-lifecycle.md +75 -0
  81. package/skills/bookmark-research/references/zh/wiki-and-evaluation.md +63 -0
  82. package/src/archive.py +302 -0
  83. package/src/bookmark_index.py +913 -0
  84. package/src/cli.py +435 -0
  85. package/src/credentials.py +82 -0
  86. package/src/evaluation.py +337 -0
  87. package/src/jina.py +96 -0
  88. package/src/mcp_server.py +641 -0
  89. package/src/onboarding.py +352 -0
  90. package/src/provider_adapters.py +152 -0
  91. package/src/readiness.py +283 -0
  92. package/src/remote_mcp.py +295 -0
  93. package/src/research.py +1334 -0
  94. package/src/research_coverage.py +160 -0
  95. package/src/research_services.py +684 -0
  96. package/src/routing.py +202 -0
  97. package/src/search_results.py +244 -0
  98. package/src/service_http.py +139 -0
  99. package/src/settings.py +247 -0
  100. package/src/source_inputs.py +201 -0
  101. package/src/source_manager.py +348 -0
  102. package/src/source_watcher.py +68 -0
  103. package/src/textio.py +9 -0
  104. package/src/tui.py +586 -0
  105. package/src/web_search.py +603 -0
  106. 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))}