bookmark-research 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/.agents/plugins/marketplace.json +1 -1
  2. package/.codex-plugin/plugin.json +4 -1
  3. package/README.md +15 -9
  4. package/README.zh.md +16 -9
  5. package/config/providers.json +26 -0
  6. package/config/settings.example.json +36 -0
  7. package/docs/bookmark-research-architecture.md +2 -2
  8. package/docs/canvas-tree-reading-acceptance.md +98 -0
  9. package/docs/configuration.en.md +70 -0
  10. package/docs/configuration.md +70 -0
  11. package/docs/details.en.md +10 -6
  12. package/docs/details.md +3 -3
  13. package/docs/harness-compatibility.md +1 -1
  14. package/docs/installation.en.md +4 -2
  15. package/docs/installation.md +4 -2
  16. package/docs/instructions.en.md +1 -0
  17. package/docs/instructions.md +1 -0
  18. package/docs/user-guide.en.md +1 -1
  19. package/docs/user-guide.md +1 -1
  20. package/hosts/dsh/plugin.js +3 -0
  21. package/package.json +1 -1
  22. package/scripts/bench_sync.py +191 -0
  23. package/scripts/bench_tools_list.py +129 -0
  24. package/scripts/evaluate_ladder.py +149 -0
  25. package/scripts/export_bundle.py +2 -0
  26. package/scripts/host_install.py +4 -1
  27. package/scripts/install.py +120 -28
  28. package/skills/bookmark-research/SKILL.md +28 -9
  29. package/skills/bookmark-research/references/package-semantics.md +56 -6
  30. package/skills/bookmark-research/references/research-workflow.md +5 -2
  31. package/skills/bookmark-research/references/settings-and-archive.md +5 -3
  32. package/skills/bookmark-research/references/wiki.md +3 -1
  33. package/skills/bookmark-research/references/zh/package-semantics.md +56 -6
  34. package/skills/bookmark-research/references/zh/research-workflow.md +5 -2
  35. package/skills/bookmark-research/references/zh/settings-and-archive.md +5 -3
  36. package/skills/bookmark-research/references/zh/skill-guide.md +20 -8
  37. package/skills/bookmark-research/references/zh/wiki.md +3 -1
  38. package/src/archive.py +100 -0
  39. package/src/bookmark_index.py +808 -41
  40. package/src/browser_client.py +342 -0
  41. package/src/cli.py +298 -14
  42. package/src/credentials.py +2 -1
  43. package/src/fetch_cache.py +60 -0
  44. package/src/grok_search.py +272 -0
  45. package/src/jobs.py +349 -0
  46. package/src/junk.py +33 -0
  47. package/src/locate.py +862 -0
  48. package/src/mcp_dispatch.py +22 -6
  49. package/src/mcp_server.py +573 -23
  50. package/src/netscape.py +124 -0
  51. package/src/onboarding.py +59 -9
  52. package/src/perplexity.py +78 -0
  53. package/src/raw_library.py +183 -45
  54. package/src/readiness.py +40 -5
  55. package/src/ref_codec.py +70 -0
  56. package/src/research.py +125 -12
  57. package/src/research_plan.py +11 -1
  58. package/src/routing.py +5 -2
  59. package/src/search_results.py +80 -2
  60. package/src/settings.py +61 -16
  61. package/src/source_inputs.py +109 -3
  62. package/src/source_manager.py +71 -12
  63. package/src/source_versions.py +20 -2
  64. package/src/source_watcher.py +4 -2
  65. package/src/storage.py +60 -0
  66. package/src/url_classify.py +45 -0
  67. package/src/web_search.py +106 -10
  68. package/src/wiki.py +269 -18
  69. package/src/wiki_search.py +119 -27
@@ -52,9 +52,11 @@ The Codex branch manages `bookmark-research@bookmark-research`. If already insta
52
52
 
53
53
  ## Guided setup and readiness
54
54
 
55
- After installation the wizard asks only a few questions: research depth (`auto` by default, which picks a depth from each request), answer language, search and page-reading services (one ordered multiselect; unselected providers become the fallback search tier), where archives and research results are stored (default data folder, one custom folder for both, or research reports beside the bookmark file/folder with fallback to the central folder; new tasks only), and optional professional APIs. Wiki follow-up, the pre-research availability check and connectivity checks are enabled by default (suggest and ask first, 15-minute cache, automatic) and can be changed later with `setup` or `config set`. Enter keeps displayed preferences. API-key input is masked; each key is checked immediately and can be re-entered if the check fails. Keys are saved separately in `credentials.json` next to settings, with mode `0600`; this local file is not encrypted. `BOOKMARK_RESEARCH_CREDENTIALS` overrides its path, and environment variables override saved keys. No keys enter preferences, packages, receipts or readiness output. A ChatGPT/Codex login is separate from an OpenAI API key.
55
+ After installation, the terminal wizard asks for research depth, answer language, search sources, storage location and optional professional APIs, and can configure a custom model endpoint. In the API-key step, select the keys needed by enabled services with Space, press Enter, then enter each value at the hidden prompt. You can skip the step. Keys are saved separately in `credentials.json` next to settings, with mode `0600`; this local file is unencrypted. Environment variables override saved keys, and `BOOKMARK_RESEARCH_CREDENTIALS` changes the credential path. No key values enter preferences, packages, receipts or readiness output. A ChatGPT/Codex login is separate from an OpenAI API key.
56
56
 
57
- Rerun setup at any time with `python3 <installed path>/src/cli.py setup`; the exact command (`getting_started.setup_command` in the JSON result) is printed after installation. From a checkout:
57
+ The multiselect controls search-source primary and fallback tiers. Perplexity returns structured search results but cannot read pages, so page reading still uses its separate configured provider. Setup can also configure a custom OpenAI-compatible model Base URL, model and protocol, with `CUSTOM_AGENTIC_API_KEY` entered in the hidden-key step. It is called separately through `agentic_search`, outside URL fusion. Native Responses web search is requested only when explicitly selected and supported upstream; a Chat Completions prompt cannot guarantee search.
58
+
59
+ Install and update receipts print the configuration JSON guide and the command to reopen setup. Updates retain existing keys and preferences and do not prompt for keys again. To add or replace keys, run the printed `setup` command or `bookmark-research setup`. You can import partial preferences with `bookmark-research config set --input <preferences.json>`; see the [user settings JSON guide](configuration.en.md) for the schema boundary and example. The absolute setup command is also included in `getting_started.setup_command` in JSON output. From a checkout:
58
60
 
59
61
  ```sh
60
62
  python3 src/cli.py setup --host claude_code --lang en
@@ -69,9 +69,11 @@ Codex 分支管理 `bookmark-research@bookmark-research`。已通过 `personal`
69
69
 
70
70
  ## 向导与就绪检查
71
71
 
72
- 安装成功后只需回答少数问题:研究深度(默认 `auto`,按每次请求自动定档)、答复语言、搜索与正文读取服务(一个有序多选列表;未选中的服务自动作为备用搜索层)、归档与研究结果的存放位置(默认数据目录、自定义统一目录,或研究报告放在书签文件/文件夹旁边、放不了时退回统一目录;只影响新任务),以及可选专业 API。Wiki 整理、联网前的可用性检查与连通性检查都按默认值自动启用(分别为「建议并先询问」「15 分钟缓存」和自动检查),可随时在后续 `setup` 或 `config set` 中修改。回车保留显示的偏好。API Key 掩码输入并立即检查,失败可重新输入;密钥单独保存为配置旁权限 `0600` 的 `credentials.json`,是本地明文文件;可用 `BOOKMARK_RESEARCH_CREDENTIALS` 指定位置。环境变量优先于保存的密钥,密钥不进入偏好 JSON、安装包、安装记录或就绪检查输出。ChatGPT/Codex 登录与 OpenAI API Key 分开。
72
+ 安装后的终端向导会询问研究深度、答复语言、搜索来源、存放位置和可选专业 API,并可配置自定义模型端点。之后的 API Key 步骤先列出 Key 名称与状态:用空格勾选已启用服务所需的 Key,按回车后在隐藏输入框逐个输入;留空或不选都可以跳过。Key 会单独保存到配置旁权限为 `0600` 的 `credentials.json`,这是未加密的本地文件;也可使用环境变量,环境变量优先。`BOOKMARK_RESEARCH_CREDENTIALS` 可指定凭据路径。Key 不会进入偏好 JSON、安装包、安装记录或就绪检查输出。ChatGPT/Codex 登录与 OpenAI API Key 分开。
73
73
 
74
- 以后随时运行 `python3 <安装路径>/src/cli.py setup` 重开向导,完整命令(JSON 结果中的 `getting_started.setup_command`)会在安装结束时打印;在源码中可运行:
74
+ 多选项配置的是搜索来源主层与备用层;Perplexity 只提供结构化搜索结果,不能读取页面,正文读取仍使用单独配置的 provider。setup 也可配置自定义 OpenAI 兼容模型端点的 Base URL、模型和协议,并在密钥步骤设置 `CUSTOM_AGENTIC_API_KEY`。该端点经独立 `agentic_search` 调用,不参与普通 URL 聚合;原生 Responses 搜索工具需明确选择且由上游支持,普通 Chat Completions 提示不能保证联网。
75
+
76
+ 安装或升级收据会打印配置 JSON 指南链接和重开向导的命令。升级会保留当前 Key 和偏好,不会自动重新询问 Key;要新增、替换 Key 或改偏好,运行收据给出的 `setup` 命令,或 `bookmark-research setup`。以后也可运行 `bookmark-research config set --input <偏好文件.json>` 导入部分偏好;字段说明与可复制示例见[用户配置 JSON 指南](configuration.md)。完整安装路径命令也会在 JSON 结果的 `getting_started.setup_command` 中提供。在源码中可运行:
75
77
 
76
78
  ```sh
77
79
  python3 src/cli.py setup --host claude_code --lang zh
@@ -18,6 +18,7 @@ Bookmark Research has one execution Skill and one runtime. The execution Skill a
18
18
  | Host workflows | [Read](../skills/bookmark-research/references/host-workflows.md) | [阅读](../skills/bookmark-research/references/zh/host-workflows.md) |
19
19
  | Professional services | [Read](../skills/bookmark-research/references/research-services.md) | [阅读](../skills/bookmark-research/references/zh/research-services.md) |
20
20
  | Settings and archives | [Read](../skills/bookmark-research/references/settings-and-archive.md) | [阅读](../skills/bookmark-research/references/zh/settings-and-archive.md) |
21
+ | User settings JSON | [Read](configuration.en.md) | [阅读](configuration.md) |
21
22
  | GitHub and sync | [Read](../skills/bookmark-research/references/github-and-sync.md) | [阅读](../skills/bookmark-research/references/zh/github-and-sync.md) |
22
23
  | Wiki | [Read](../skills/bookmark-research/references/wiki.md) | [阅读](../skills/bookmark-research/references/zh/wiki.md) |
23
24
  | Host prompt reference | [Read](prompt-reference.en.md) | [阅读](prompt-reference.md) |
@@ -18,6 +18,7 @@ Bookmark Research 共用一个执行 Skill 和一套运行时。执行 Skill 及
18
18
  | 宿主工作流 | [Read](../skills/bookmark-research/references/host-workflows.md) | [阅读](../skills/bookmark-research/references/zh/host-workflows.md) |
19
19
  | 专业研究服务 | [Read](../skills/bookmark-research/references/research-services.md) | [阅读](../skills/bookmark-research/references/zh/research-services.md) |
20
20
  | 配置与归档 | [Read](../skills/bookmark-research/references/settings-and-archive.md) | [阅读](../skills/bookmark-research/references/zh/settings-and-archive.md) |
21
+ | 用户配置 JSON | [Read](configuration.en.md) | [阅读](configuration.md) |
21
22
  | GitHub 与同步 | [Read](../skills/bookmark-research/references/github-and-sync.md) | [阅读](../skills/bookmark-research/references/zh/github-and-sync.md) |
22
23
  | Wiki | [Read](../skills/bookmark-research/references/wiki.md) | [阅读](../skills/bookmark-research/references/zh/wiki.md) |
23
24
  | 宿主提示完整说明 | [Read](prompt-reference.en.md) | [阅读](prompt-reference.md) |
@@ -52,7 +52,7 @@ Ask “Show Bookmark Research settings” or run `python3 src/cli.py config show
52
52
 
53
53
  The search result limit is not a limit on package scope. Requested text length does not guarantee a complete page. Deep research always preserves its task evidence, independently of the ordinary page archive preference.
54
54
 
55
- Run `python3 <plugin-root>/src/cli.py setup` for guided preferences and hidden key entry. Keys come from the host environment first, then a private `credentials.json` next to settings: `EXA_API_KEY`, `PARALLEL_API_KEY`, `TAVILY_API_KEY`, `JINA_API_KEY`, and, for professional OpenAI research, `OPENAI_API_KEY`. Access and quotas depend on each provider. Host-led research needs no additional professional-service key.
55
+ Run `python3 <plugin-root>/src/cli.py setup` for guided preferences, an optional custom OpenAI-compatible model endpoint, and hidden key entry. Keys come from the host environment first, then a private `credentials.json` next to settings, including `CUSTOM_AGENTIC_API_KEY`. Configure the custom Base URL, model and Chat Completions/Responses protocol; Responses native `web_search` is requested only when explicitly selected and supported upstream. Prompt mode cannot guarantee the upstream searches. Custom models and Grok return separate model answers and citations through `agentic_search`; citations are not independently verified or URL-fused. Perplexity Search is a structured URL-source provider. `search_web` fuses only Bookmark Research providers. The host model calls other search MCP tools visible in its current session and compares their evidence.
56
56
 
57
57
  When access needs diagnosis, the advanced `research_readiness` tool checks selected services and observed host tools. Cached/always/manual modes control frequency; local queries stay offline. Optional native MCP connection/login steps are shown by setup, with actual OAuth handled in the host. Keys, visible tools and successfully tested operations have distinct status; checks never create a paid research job. See [settings and readiness](../skills/bookmark-research/references/settings-and-archive.md).
58
58
 
@@ -52,7 +52,7 @@ MCP 进程运行期间及查询前会检查 live 目录。一次性 CLI 命令
52
52
 
53
53
  搜索结果条数不限制数据包范围;长度参数不保证完整正文。API 结果与完整审计的任务证据独立保存,不受普通网页归档开关影响。
54
54
 
55
- 可运行 `python3 <插件根目录>/src/cli.py setup` 引导设置偏好并隐藏输入密钥。密钥优先从宿主环境读取,其次读取配置旁的私有 `credentials.json`:`EXA_API_KEY`、`PARALLEL_API_KEY`、`TAVILY_API_KEY`、`JINA_API_KEY`;OpenAI 专业研究使用 `OPENAI_API_KEY`。访问和额度取决于服务方。宿主自身研究无需另配专业研究服务密钥。
55
+ 可运行 `python3 <插件根目录>/src/cli.py setup` 引导配置偏好、可选自定义 OpenAI 兼容模型端点并隐藏输入密钥。密钥优先从宿主环境读取,其次读取配置旁的私有 `credentials.json`,包括 `CUSTOM_AGENTIC_API_KEY`。自定义端点配置 Base URL、模型和 Chat Completions/Responses 协议;Responses 原生 `web_search` 需明确启用且上游支持,提示模式也不能保证上游实际联网。自定义模型与 Grok 通过 `agentic_search` 独立返回回答和引用,不参与 URL 排名;引用不会独立核验。Perplexity Search 是结构化 URL 来源。`search_web` 只聚合 Bookmark Research 自己配置的来源;宿主模型负责调用当前会话可见的其他搜索 MCP 并综合证据。
56
56
 
57
57
  需要诊断访问问题时,可用高级 `research_readiness` 检查所选服务和已观察到的宿主工具;cached/always/manual 控制频率,本地查询保持离线。setup 展示可选原生 MCP 的接入/授权步骤,实际 OAuth 由宿主管理。已填密钥、工具可见与实际操作成功分别报告,检查不会创建付费研究任务。详见[配置与就绪检查](../skills/bookmark-research/references/zh/settings-and-archive.md)。
58
58
 
@@ -38,6 +38,9 @@ const FORWARDED_ENV = [
38
38
  "TAVILY_API_KEY",
39
39
  "JINA_API_KEY",
40
40
  "DEEPSEEK_API_KEY",
41
+ "PERPLEXITY_API_KEY",
42
+ "XAI_API_KEY",
43
+ "CUSTOM_AGENTIC_API_KEY",
41
44
  "OPENAI_API_KEY",
42
45
  "SystemRoot",
43
46
  "windir",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bookmark-research",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Check bookmark URLs or Canvas packages, compare sources with host-led research, and retain verifiable reports.",
5
5
  "license": "GPL-3.0-only",
6
6
  "repository": {
@@ -0,0 +1,191 @@
1
+ #!/usr/bin/env python3
2
+ """Benchmark synchronization performance across full import, no-change rescan, and single-file edit.
3
+
4
+ Accepts any Canvas package directory, ZIP, or creates a synthetic package if omitted.
5
+ Runs all tests in an isolated temporary directory without touching original data.
6
+
7
+ Examples:
8
+ python3 scripts/bench_sync.py
9
+ python3 scripts/bench_sync.py /path/to/Bookmark-Canvas/bookmark/书签画布
10
+ python3 scripts/bench_sync.py --json
11
+ python3 scripts/bench_sync.py --threshold 0.05
12
+
13
+ Without a path (and without BOOKMARK_RESEARCH_BENCH_PACKAGE) it benchmarks a synthetic package.
14
+ """
15
+
16
+ import argparse
17
+ import json
18
+ import os
19
+ import shutil
20
+ import sys
21
+ import tempfile
22
+ import time
23
+ from pathlib import Path
24
+
25
+ ROOT = Path(__file__).resolve().parents[1]
26
+ sys.path.insert(0, str(ROOT / "src"))
27
+
28
+ from bookmark_index import BookmarkIndex
29
+ from source_manager import SourceManager
30
+ from source_inputs import read_input
31
+
32
+
33
+ DEFAULT_PACKAGE = (Path(os.environ["BOOKMARK_RESEARCH_BENCH_PACKAGE"]).expanduser()
34
+ if os.environ.get("BOOKMARK_RESEARCH_BENCH_PACKAGE") else None)
35
+
36
+
37
+ def make_synthetic_package(root, sections=10, items_per_section=50):
38
+ root.mkdir(parents=True, exist_ok=True)
39
+ canvas = root / "canvas.canvas"
40
+ canvas.write_text(json.dumps({"nodes": [], "edges": []}, ensure_ascii=False), encoding="utf-8")
41
+ for s in range(sections):
42
+ section_dir = root / "临时栏目" / "常规链式"
43
+ section_dir.mkdir(parents=True, exist_ok=True)
44
+ items = [{"id": f"item-{s}-{i}", "sectionId": f"temp-section-{s}",
45
+ "type": "bookmark", "title": f"Bookmark {s}-{i}",
46
+ "url": f"https://example.test/{s}/{i}"} for i in range(items_per_section)]
47
+ data = {
48
+ "format": "bookmark-canvas-section",
49
+ "schemaVersion": 2,
50
+ "sectionType": "temporary",
51
+ "id": f"temp-section-{s}",
52
+ "label": f"T-{s}",
53
+ "title": f"Test Card {s}",
54
+ "tempKind": "regular",
55
+ "items": items,
56
+ }
57
+ (section_dir / f"card-{s:03d}.json").write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
58
+ return root
59
+
60
+
61
+ def run_benchmark(package_path, threshold=0.05):
62
+ with tempfile.TemporaryDirectory(prefix="bookmark-bench-sync-") as tmpdir:
63
+ work_dir = Path(tmpdir)
64
+ db_path = work_dir / "index.sqlite3"
65
+ pkg_copy = work_dir / "package"
66
+
67
+ is_synthetic = False
68
+ if package_path and Path(package_path).exists():
69
+ orig = Path(package_path).expanduser().resolve()
70
+ if orig.is_dir():
71
+ shutil.copytree(orig, pkg_copy)
72
+ else:
73
+ shutil.copyfile(orig, pkg_copy)
74
+ elif DEFAULT_PACKAGE is not None and DEFAULT_PACKAGE.exists():
75
+ shutil.copytree(DEFAULT_PACKAGE, pkg_copy)
76
+ else:
77
+ make_synthetic_package(pkg_copy)
78
+ is_synthetic = True
79
+
80
+ prepared = read_input(pkg_copy)
81
+ total_files = len(prepared["files"])
82
+
83
+ with BookmarkIndex(db_path) as index:
84
+ manager = SourceManager(index)
85
+
86
+ # 1. Full initial import
87
+ t0 = time.perf_counter()
88
+ full_result = manager.sync(pkg_copy, "bench_source", mode="live", completeness="complete")
89
+ full_duration = time.perf_counter() - t0
90
+
91
+ # 2. No-change rescan
92
+ t0 = time.perf_counter()
93
+ nochange_result = manager.refresh("bench_source", force=False)
94
+ nochange_duration = time.perf_counter() - t0
95
+
96
+ # 3. Single-file modification
97
+ # Pick one json file in the package copy and append an item
98
+ target_json = None
99
+ if pkg_copy.is_dir():
100
+ for p in sorted(pkg_copy.rglob("*.json")):
101
+ if not p.is_symlink() and p.is_file():
102
+ target_json = p
103
+ break
104
+
105
+ if target_json is not None:
106
+ content = json.loads(target_json.read_text(encoding="utf-8"))
107
+ items = content.setdefault("items", [])
108
+ items.append({
109
+ "id": "item-bench-mod-1",
110
+ "sectionId": content.get("id", "bench-section"),
111
+ "type": "bookmark",
112
+ "title": "Bench Added Bookmark",
113
+ "url": "https://example.test/bench-added"
114
+ })
115
+ time.sleep(0.01)
116
+ target_json.write_text(json.dumps(content, ensure_ascii=False, indent=2), encoding="utf-8")
117
+
118
+ t0 = time.perf_counter()
119
+ single_result = manager.refresh("bench_source", force=True)
120
+ single_duration = time.perf_counter() - t0
121
+
122
+ threshold_exceeded = nochange_duration > threshold
123
+
124
+ return {
125
+ "package_path": str(package_path or (pkg_copy if is_synthetic else DEFAULT_PACKAGE or "")),
126
+ "is_synthetic": is_synthetic,
127
+ "total_files": total_files,
128
+ "threshold_seconds": threshold,
129
+ "full_import": {
130
+ "duration_seconds": round(full_duration, 4),
131
+ "changed_files": full_result.get("changed_files", 0),
132
+ "skipped_files": full_result.get("skipped_files", 0),
133
+ "items_inserted": full_result.get("items_inserted", 0),
134
+ "counts": full_result.get("counts", {}),
135
+ },
136
+ "no_change_rescan": {
137
+ "duration_seconds": round(nochange_duration, 6),
138
+ "changed_files": nochange_result.get("changed_files", 0),
139
+ "state": nochange_result.get("state"),
140
+ "threshold_exceeded": threshold_exceeded,
141
+ },
142
+ "single_file_change": {
143
+ "duration_seconds": round(single_duration, 4),
144
+ "changed_files": single_result.get("changed_files", 0),
145
+ "items_updated": single_result.get("items_updated", 0),
146
+ "items_inserted": single_result.get("items_inserted", 0),
147
+ }
148
+ }
149
+
150
+
151
+ def main():
152
+ parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
153
+ parser.add_argument("package", nargs="?", default=None, help="Canvas package path (directory or ZIP)")
154
+ parser.add_argument("--threshold", type=float, default=0.05,
155
+ help="No-change rescan threshold in seconds (default 0.05 s / 50 ms)")
156
+ parser.add_argument("--json", action="store_true", help="Output machine-readable JSON")
157
+ args = parser.parse_args()
158
+
159
+ results = run_benchmark(args.package, threshold=args.threshold)
160
+
161
+ if args.json:
162
+ print(json.dumps(results, ensure_ascii=False, indent=2))
163
+ else:
164
+ full = results["full_import"]
165
+ nochange = results["no_change_rescan"]
166
+ single = results["single_file_change"]
167
+
168
+ print("=" * 66)
169
+ print("Bookmark Research — Synchronization Benchmark")
170
+ print(f"Target: {results['package_path']}")
171
+ print(f"Total protocol files: {results['total_files']}")
172
+ print("-" * 66)
173
+ print(f"1. Full Import: {full['duration_seconds']:8.4f} s "
174
+ f"(files: {full['changed_files']}, inserted: {full['items_inserted']})")
175
+
176
+ nc_status = "WARNING: EXCEEDS THRESHOLD" if nochange["threshold_exceeded"] else "OK"
177
+ print(f"2. No-change Rescan: {nochange['duration_seconds']:8.6f} s "
178
+ f"(changed: {nochange['changed_files']}, state: {nochange['state']}) [{nc_status}]")
179
+ if nochange["threshold_exceeded"]:
180
+ print(f" [!] No-change rescan took {nochange['duration_seconds']*1000:.2f} ms, "
181
+ f"exceeding threshold {args.threshold*1000:.1f} ms!")
182
+
183
+ print(f"3. Single-file Change: {single['duration_seconds']:8.4f} s "
184
+ f"(changed: {single['changed_files']}, inserted: {single['items_inserted']}, updated: {single['items_updated']})")
185
+ print("=" * 66)
186
+
187
+ return 1 if results["no_change_rescan"]["threshold_exceeded"] else 0
188
+
189
+
190
+ if __name__ == "__main__":
191
+ sys.exit(main())
@@ -0,0 +1,129 @@
1
+ #!/usr/bin/env python3
2
+ """Capture the production stdio tools/list response and measure its length.
3
+
4
+ This deliberately drives ``serve`` instead of serializing TOOL_SCHEMAS itself.
5
+ The resulting JSON artifact records the exact response line, tool names and
6
+ serializer so schema-size estimates cannot silently become the regression gate.
7
+ """
8
+
9
+ import argparse
10
+ import hashlib
11
+ import io
12
+ import json
13
+ import os
14
+ import sys
15
+ import tempfile
16
+ from pathlib import Path
17
+
18
+
19
+ ROOT = Path(__file__).resolve().parents[1]
20
+ sys.path.insert(0, str(ROOT / "src"))
21
+
22
+ import mcp_server # noqa: E402
23
+ from mcp_server import _legacy_compatibility_expired, serve # noqa: E402
24
+ from settings import Settings # noqa: E402
25
+
26
+
27
+ # Frozen 2026-11-06 quick catalog for before/after host comparisons.
28
+ LEGACY_TOOLS = (
29
+ "sync_package", "index_status", "search_bookmarks", "lookup_url", "get_context",
30
+ "traverse_graph", "plan_research", "search_web", "merge_search_results",
31
+ "agentic_search", "fetch_web", "search_archive", "wiki_write", "wiki_get",
32
+ "wiki_list", "wiki_search", "research_services", "research_service_prepare",
33
+ "research_service_start", "research_service_status", "research_service_result",
34
+ "research_service_cancel", "research_service_attach", "get_settings", "update_settings",
35
+ )
36
+ PRESETS = ("quick", "legacy", "deep", "all")
37
+
38
+
39
+ def _request(method, request_id=None, params=None):
40
+ value = {"jsonrpc": "2.0", "method": method}
41
+ if request_id is not None:
42
+ value["id"] = request_id
43
+ if params is not None:
44
+ value["params"] = params
45
+ return value
46
+
47
+
48
+ def capture(preset, db_path=":memory:", excluded=()):
49
+ """Return the parsed tools/list response and its exact emitted line."""
50
+ if preset == "legacy" and _legacy_compatibility_expired(mcp_server.release_version()):
51
+ raise ValueError("The legacy tool surface ended at 0.8.0")
52
+ temporary = tempfile.TemporaryDirectory(prefix="bookmark-tools-list-")
53
+ old_scope = os.environ.get("BOOKMARK_RESEARCH_TOOL_SCOPE")
54
+ try:
55
+ # Use an isolated settings file and an explicit environment scope so a
56
+ # measurement cannot rewrite the user's config or inherit its preset.
57
+ settings = Settings(Path(temporary.name) / "settings.json")
58
+ if preset == "quick":
59
+ names = [name for name in Settings.QUICK_TOOLS if name not in excluded]
60
+ os.environ["BOOKMARK_RESEARCH_TOOL_SCOPE"] = ",".join(names)
61
+ elif preset == "legacy":
62
+ names = [name for name in LEGACY_TOOLS if name not in excluded]
63
+ os.environ["BOOKMARK_RESEARCH_TOOL_SCOPE"] = ",".join(names)
64
+ elif excluded:
65
+ from mcp_server import TOOL_SCHEMAS
66
+ names = [name for name in TOOL_SCHEMAS if name not in excluded]
67
+ os.environ["BOOKMARK_RESEARCH_TOOL_SCOPE"] = ",".join(names)
68
+ else:
69
+ # Empty override deliberately exposes the full catalog for all/deep.
70
+ os.environ["BOOKMARK_RESEARCH_TOOL_SCOPE"] = ""
71
+ incoming = "\n".join([
72
+ json.dumps(_request("initialize", 1, {"protocolVersion": "2025-11-25",
73
+ "capabilities": {},
74
+ "clientInfo": {"name": "bench-tools-list", "version": "1"}}),
75
+ ensure_ascii=False),
76
+ json.dumps(_request("notifications/initialized"), ensure_ascii=False),
77
+ json.dumps(_request("tools/list", 2), ensure_ascii=False),
78
+ "",
79
+ ])
80
+ stdout, stderr = io.StringIO(), io.StringIO()
81
+ serve(db_path, stdin=io.StringIO(incoming), stdout=stdout, stderr=stderr,
82
+ settings=settings, workers=1, max_pending=1)
83
+ lines = stdout.getvalue().splitlines(keepends=True)
84
+ response_line = next((line for line in lines
85
+ if json.loads(line).get("id") == 2), None)
86
+ if response_line is None:
87
+ raise RuntimeError("stdio tools/list response was not emitted: " + stdout.getvalue())
88
+ response = json.loads(response_line)
89
+ if "error" in response:
90
+ raise RuntimeError("tools/list failed: " + response["error"].get("message", "unknown error"))
91
+ tools = response["result"]["tools"]
92
+ return response, response_line, tools
93
+ finally:
94
+ if old_scope is None:
95
+ os.environ.pop("BOOKMARK_RESEARCH_TOOL_SCOPE", None)
96
+ else:
97
+ os.environ["BOOKMARK_RESEARCH_TOOL_SCOPE"] = old_scope
98
+ temporary.cleanup()
99
+
100
+
101
+ def main(argv=None):
102
+ parser = argparse.ArgumentParser(description=__doc__)
103
+ parser.add_argument("--preset", choices=PRESETS, default="quick")
104
+ parser.add_argument("--db", default=":memory:")
105
+ parser.add_argument("--exclude", action="append", default=[], help="Exclude a tool for a pre-change comparison")
106
+ parser.add_argument("--out", type=Path, default=Path(".bench/tools-list.json"))
107
+ args = parser.parse_args(argv)
108
+ response, line, tools = capture(args.preset, db_path=args.db, excluded=args.exclude)
109
+ serializer = "json.dumps(ensure_ascii=False, allow_nan=False, separators=(',', ':')) + '\\n'"
110
+ artifact = {
111
+ "preset": args.preset,
112
+ "excluded": args.exclude,
113
+ "tool_count": len(tools),
114
+ "tool_names": [tool["name"] for tool in tools],
115
+ "response_characters": len(line),
116
+ "response_sha256": hashlib.sha256(line.encode("utf-8")).hexdigest(),
117
+ "serializer": serializer,
118
+ "response": response,
119
+ }
120
+ args.out.parent.mkdir(parents=True, exist_ok=True)
121
+ args.out.write_text(json.dumps(artifact, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
122
+ print(json.dumps({key: artifact[key] for key in
123
+ ("preset", "excluded", "tool_count", "response_characters", "response_sha256")},
124
+ ensure_ascii=False))
125
+ return 0
126
+
127
+
128
+ if __name__ == "__main__":
129
+ raise SystemExit(main())
@@ -0,0 +1,149 @@
1
+ #!/usr/bin/env python3
2
+ """Validate and summarize a 15-question research ladder capture.
3
+
4
+ The harness-specific runs are supplied as JSON so Codex/Claude/dsh can capture
5
+ their own traces without the evaluator pretending to run those hosts. Each
6
+ question needs three runs, Q1 and Q3-Q5 counts per run, and human Q2 claim
7
+ annotations for the median-step run.
8
+ """
9
+
10
+ import argparse
11
+ import json
12
+ import statistics
13
+ import sys
14
+ from pathlib import Path
15
+
16
+
17
+ CATEGORIES = ("L1", "L2", "L3", "L4", "L5")
18
+
19
+
20
+ def template():
21
+ return {
22
+ "schema_version": 1,
23
+ "baseline_id": "",
24
+ "tool_surface": "",
25
+ "questions": [
26
+ {"id": "%s-%s" % (category, number), "category": category,
27
+ "prompt": "", "target_refs": [], "runs": []}
28
+ for category in CATEGORIES for number in range(1, 4)
29
+ ],
30
+ }
31
+
32
+
33
+ def evaluate(data):
34
+ if not isinstance(data, dict) or not isinstance(data.get("questions"), list):
35
+ raise ValueError("input must contain a questions array")
36
+ questions = data["questions"]
37
+ if len(questions) != 15:
38
+ raise ValueError("the ladder must contain exactly 15 questions")
39
+ ids, categories = set(), {category: 0 for category in CATEGORIES}
40
+ all_runs = []
41
+ q2_missing = []
42
+ for question in questions:
43
+ if not isinstance(question, dict) or not question.get("id"):
44
+ raise ValueError("each question needs an id")
45
+ if question["id"] in ids:
46
+ raise ValueError("question IDs must be unique")
47
+ ids.add(question["id"])
48
+ category = question.get("category")
49
+ if category not in categories:
50
+ raise ValueError("question category must be one of " + ", ".join(CATEGORIES))
51
+ categories[category] += 1
52
+ if not isinstance(question.get("prompt"), str) or not question["prompt"].strip():
53
+ raise ValueError("question %s has no frozen prompt" % question["id"])
54
+ runs = question.get("runs")
55
+ if not isinstance(runs, list) or len(runs) < 3:
56
+ raise ValueError("question %s needs at least three runs" % question["id"])
57
+ for run in runs:
58
+ if not isinstance(run, dict):
59
+ raise ValueError("runs must be objects")
60
+ for field in ("steps", "wall_seconds", "q1_matches", "q1_citations",
61
+ "q3_content_state_misses", "q4_silent_truncations", "q5_coverage_gaps"):
62
+ if field not in run:
63
+ raise ValueError("run for %s is missing %s" % (question["id"], field))
64
+ if type(run["steps"]) is not int or run["steps"] < 0:
65
+ raise ValueError("steps must be a non-negative integer")
66
+ if not isinstance(run["wall_seconds"], (int, float)) or run["wall_seconds"] < 0:
67
+ raise ValueError("wall_seconds must be non-negative")
68
+ if type(run["q1_matches"]) is not int or type(run["q1_citations"]) is not int:
69
+ raise ValueError("Q1 counts must be integers")
70
+ if run["q1_matches"] < 0 or run["q1_citations"] < run["q1_matches"]:
71
+ raise ValueError("invalid Q1 match counts")
72
+ for field in ("q3_content_state_misses", "q4_silent_truncations", "q5_coverage_gaps"):
73
+ if type(run[field]) is not int or run[field] < 0:
74
+ raise ValueError(field + " must be a non-negative integer")
75
+ all_runs.append(run)
76
+ median_steps = statistics.median(run["steps"] for run in runs)
77
+ median_run = next(run for run in runs if run["steps"] == median_steps)
78
+ if "q2_claims" not in median_run:
79
+ q2_missing.append(question["id"])
80
+ elif not isinstance(median_run["q2_claims"], list):
81
+ raise ValueError("q2_claims must be a list of human annotations")
82
+ else:
83
+ for claim in median_run["q2_claims"]:
84
+ if not isinstance(claim, dict) or claim.get("supported") not in (True, False):
85
+ raise ValueError("each Q2 annotation needs a boolean supported field")
86
+ if not isinstance(claim.get("claim"), str) or not isinstance(claim.get("source_ids"), list):
87
+ raise ValueError("each Q2 annotation needs claim text and source_ids")
88
+ if any(count != 3 for count in categories.values()):
89
+ raise ValueError("the ladder requires exactly three questions in each L1-L5 category")
90
+ citations = sum(run["q1_citations"] for run in all_runs)
91
+ q1_matches = sum(run["q1_matches"] for run in all_runs)
92
+ claim_rows = [claim for question in questions for run in question["runs"]
93
+ if run["steps"] == statistics.median(r["steps"] for r in question["runs"])
94
+ for claim in run.get("q2_claims", [])]
95
+ supported = sum(claim["supported"] is True for claim in claim_rows)
96
+ summary = {
97
+ "baseline_id": data.get("baseline_id"),
98
+ "tool_surface": data.get("tool_surface"),
99
+ "question_count": len(questions),
100
+ "run_count": len(all_runs),
101
+ "category_counts": categories,
102
+ "median_steps": statistics.median(statistics.median(run["steps"] for run in q["runs"])
103
+ for q in questions),
104
+ "median_wall_seconds": statistics.median(statistics.median(run["wall_seconds"] for run in q["runs"])
105
+ for q in questions),
106
+ "q1": {"matches": q1_matches, "citations": citations,
107
+ "rate": q1_matches / citations if citations else None},
108
+ "q2": {"supported_claims": supported, "claims": len(claim_rows),
109
+ "rate": supported / len(claim_rows) if claim_rows else None,
110
+ "missing_median_run_annotations": q2_missing},
111
+ "q3_content_state_misses": sum(run["q3_content_state_misses"] for run in all_runs),
112
+ "q4_silent_truncations": sum(run["q4_silent_truncations"] for run in all_runs),
113
+ "q5_coverage_gaps": sum(run["q5_coverage_gaps"] for run in all_runs),
114
+ "gate_pass": bool(citations and not q2_missing and q1_matches == citations and
115
+ not any(run["q3_content_state_misses"] or run["q4_silent_truncations"] or
116
+ run["q5_coverage_gaps"] for run in all_runs)),
117
+ }
118
+ return summary
119
+
120
+
121
+ def main(argv=None):
122
+ parser = argparse.ArgumentParser(description=__doc__)
123
+ parser.add_argument("input", type=Path, nargs="?", help="Harness ladder JSON with frozen questions and run traces")
124
+ parser.add_argument("--template", action="store_true", help="Write an empty L1-L5, 15-question capture template")
125
+ parser.add_argument("--out", type=Path, default=Path(".bench/ladder-summary.json"))
126
+ args = parser.parse_args(argv)
127
+ try:
128
+ if args.template:
129
+ if args.input is None:
130
+ raise ValueError("provide an output path as input with --template")
131
+ args.input.parent.mkdir(parents=True, exist_ok=True)
132
+ args.input.write_text(json.dumps(template(), ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
133
+ print(json.dumps({"template": str(args.input), "questions": 15}, ensure_ascii=False))
134
+ return 0
135
+ if args.input is None:
136
+ raise ValueError("provide a ladder JSON input, or use --template <path>")
137
+ data = json.loads(args.input.read_text(encoding="utf-8"))
138
+ result = evaluate(data)
139
+ args.out.parent.mkdir(parents=True, exist_ok=True)
140
+ args.out.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
141
+ print(json.dumps(result, ensure_ascii=False))
142
+ return 0 if result["gate_pass"] else 2
143
+ except (OSError, ValueError, json.JSONDecodeError) as error:
144
+ print(json.dumps({"error": str(error)}, ensure_ascii=False), file=sys.stderr)
145
+ return 1
146
+
147
+
148
+ if __name__ == "__main__":
149
+ raise SystemExit(main())
@@ -30,6 +30,8 @@ REQUIRED_FILES = (
30
30
  "src/bookmark_index.py",
31
31
  "src/cli.py",
32
32
  "src/credentials.py",
33
+ "src/perplexity.py",
34
+ "src/grok_search.py",
33
35
  "src/onboarding.py",
34
36
  "src/package_paths.py",
35
37
  "src/readiness.py",
@@ -127,7 +127,10 @@ def runtime_check(root, timeout):
127
127
  raise RuntimeError("Exported MCP runtime failed to start")
128
128
  responses = {row.get("id"): row for row in map(json.loads, mcp.stdout.splitlines())}
129
129
  names = sorted(row["name"] for row in responses.get(2, {}).get("result", {}).get("tools", []))
130
- if "result" not in responses.get(1, {}) or not {"search_bookmarks", "search_web", "fetch_web"} <= set(names):
130
+ available = set(names)
131
+ has_runtime_surface = ({"locate", "search", "fetch"} <= available or
132
+ {"search_bookmarks", "search_web", "fetch_web"} <= available)
133
+ if "result" not in responses.get(1, {}) or not has_runtime_surface:
131
134
  raise RuntimeError("Exported MCP runtime did not expose the expected tools")
132
135
  if (data / "data/index.sqlite3").exists():
133
136
  raise RuntimeError("Runtime discovery unexpectedly created a database")