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.
- package/.agents/plugins/marketplace.json +1 -1
- package/.codex-plugin/plugin.json +4 -1
- package/README.md +15 -9
- package/README.zh.md +16 -9
- package/config/providers.json +26 -0
- package/config/settings.example.json +36 -0
- package/docs/bookmark-research-architecture.md +2 -2
- package/docs/canvas-tree-reading-acceptance.md +98 -0
- package/docs/configuration.en.md +70 -0
- package/docs/configuration.md +70 -0
- package/docs/details.en.md +10 -6
- package/docs/details.md +3 -3
- package/docs/harness-compatibility.md +1 -1
- package/docs/installation.en.md +4 -2
- package/docs/installation.md +4 -2
- package/docs/instructions.en.md +1 -0
- package/docs/instructions.md +1 -0
- package/docs/user-guide.en.md +1 -1
- package/docs/user-guide.md +1 -1
- package/hosts/dsh/plugin.js +3 -0
- package/package.json +1 -1
- package/scripts/bench_sync.py +191 -0
- package/scripts/bench_tools_list.py +129 -0
- package/scripts/evaluate_ladder.py +149 -0
- package/scripts/export_bundle.py +2 -0
- package/scripts/host_install.py +4 -1
- package/scripts/install.py +120 -28
- package/skills/bookmark-research/SKILL.md +28 -9
- package/skills/bookmark-research/references/package-semantics.md +56 -6
- package/skills/bookmark-research/references/research-workflow.md +5 -2
- package/skills/bookmark-research/references/settings-and-archive.md +5 -3
- package/skills/bookmark-research/references/wiki.md +3 -1
- package/skills/bookmark-research/references/zh/package-semantics.md +56 -6
- package/skills/bookmark-research/references/zh/research-workflow.md +5 -2
- package/skills/bookmark-research/references/zh/settings-and-archive.md +5 -3
- package/skills/bookmark-research/references/zh/skill-guide.md +20 -8
- package/skills/bookmark-research/references/zh/wiki.md +3 -1
- package/src/archive.py +100 -0
- package/src/bookmark_index.py +808 -41
- package/src/browser_client.py +342 -0
- package/src/cli.py +298 -14
- package/src/credentials.py +2 -1
- package/src/fetch_cache.py +60 -0
- package/src/grok_search.py +272 -0
- package/src/jobs.py +349 -0
- package/src/junk.py +33 -0
- package/src/locate.py +862 -0
- package/src/mcp_dispatch.py +22 -6
- package/src/mcp_server.py +573 -23
- package/src/netscape.py +124 -0
- package/src/onboarding.py +59 -9
- package/src/perplexity.py +78 -0
- package/src/raw_library.py +183 -45
- package/src/readiness.py +40 -5
- package/src/ref_codec.py +70 -0
- package/src/research.py +125 -12
- package/src/research_plan.py +11 -1
- package/src/routing.py +5 -2
- package/src/search_results.py +80 -2
- package/src/settings.py +61 -16
- package/src/source_inputs.py +109 -3
- package/src/source_manager.py +71 -12
- package/src/source_versions.py +20 -2
- package/src/source_watcher.py +4 -2
- package/src/storage.py +60 -0
- package/src/url_classify.py +45 -0
- package/src/web_search.py +106 -10
- package/src/wiki.py +269 -18
- package/src/wiki_search.py +119 -27
package/docs/installation.en.md
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
package/docs/installation.md
CHANGED
|
@@ -69,9 +69,11 @@ Codex 分支管理 `bookmark-research@bookmark-research`。已通过 `personal`
|
|
|
69
69
|
|
|
70
70
|
## 向导与就绪检查
|
|
71
71
|
|
|
72
|
-
|
|
72
|
+
安装后的终端向导会询问研究深度、答复语言、搜索来源、存放位置和可选专业 API,并可配置自定义模型端点。之后的 API Key 步骤先列出 Key 名称与状态:用空格勾选已启用服务所需的 Key,按回车后在隐藏输入框逐个输入;留空或不选都可以跳过。Key 会单独保存到配置旁权限为 `0600` 的 `credentials.json`,这是未加密的本地文件;也可使用环境变量,环境变量优先。`BOOKMARK_RESEARCH_CREDENTIALS` 可指定凭据路径。Key 不会进入偏好 JSON、安装包、安装记录或就绪检查输出。ChatGPT/Codex 登录与 OpenAI API Key 分开。
|
|
73
73
|
|
|
74
|
-
|
|
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
|
package/docs/instructions.en.md
CHANGED
|
@@ -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) |
|
package/docs/instructions.md
CHANGED
|
@@ -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) |
|
package/docs/user-guide.en.md
CHANGED
|
@@ -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
|
|
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
|
|
package/docs/user-guide.md
CHANGED
|
@@ -52,7 +52,7 @@ MCP 进程运行期间及查询前会检查 live 目录。一次性 CLI 命令
|
|
|
52
52
|
|
|
53
53
|
搜索结果条数不限制数据包范围;长度参数不保证完整正文。API 结果与完整审计的任务证据独立保存,不受普通网页归档开关影响。
|
|
54
54
|
|
|
55
|
-
可运行 `python3 <插件根目录>/src/cli.py setup`
|
|
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
|
|
package/hosts/dsh/plugin.js
CHANGED
package/package.json
CHANGED
|
@@ -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())
|
package/scripts/export_bundle.py
CHANGED
package/scripts/host_install.py
CHANGED
|
@@ -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
|
-
|
|
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")
|