bookmark-research 0.5.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/plugins/marketplace.json +20 -0
- package/.codex-plugin/plugin.json +46 -0
- package/LICENSE +674 -0
- package/README.md +128 -0
- package/README.zh.md +126 -0
- package/bin/bookmark-research.js +30 -0
- package/config/providers.json +98 -0
- package/docs/bookmark-research-architecture.md +124 -0
- package/docs/cli-launcher-plan.md +58 -0
- package/docs/details.en.md +272 -0
- package/docs/details.md +93 -0
- package/docs/harness-compatibility.md +304 -0
- package/docs/harness-sources.json +342 -0
- package/docs/host-validation.json +298 -0
- package/docs/install-design-0.2.0.md +57 -0
- package/docs/installation.en.md +197 -0
- package/docs/installation.md +316 -0
- package/docs/instructions.en.md +42 -0
- package/docs/instructions.md +42 -0
- package/docs/mcp-aggregation-0.2.0.md +92 -0
- package/docs/multi-host-installation-plan.md +163 -0
- package/docs/prompt-reference.en.md +104 -0
- package/docs/prompt-reference.md +104 -0
- package/docs/provider-smoke-0.2.0.json +235 -0
- package/docs/release-notes-0.5.0-beta.1.md +33 -0
- package/docs/research-0.2.0.md +140 -0
- package/docs/research-sources-0.2.0.json +9360 -0
- package/docs/user-guide.en.md +74 -0
- package/docs/user-guide.md +74 -0
- package/docs/validation-0.2.0.md +126 -0
- package/docs/validation-0.4.0.md +154 -0
- package/docs/wiki-quality.md +105 -0
- package/docs/wiki-quality.zh.md +105 -0
- package/hosts/claude/runtime.js +13 -0
- package/hosts/codex/delegate.md +14 -0
- package/hosts/codex/prepare.py +61 -0
- package/hosts/dsh/plugin.js +36 -0
- package/hosts/dsh/runtime.js +14 -0
- package/hosts/dsh/workflow-call.py +45 -0
- package/hosts/pi/register-workflow.py +72 -0
- package/hosts/pi/runtime.js +18 -0
- package/hosts/shared/research-call.py +76 -0
- package/hosts/shared/research-flow.js +208 -0
- package/hosts/shared/workflow.json +15 -0
- package/install.sh +310 -0
- package/package.json +12 -0
- package/scripts/build_zip.py +143 -0
- package/scripts/export_bundle.py +496 -0
- package/scripts/host_assets.py +70 -0
- package/scripts/host_clients.py +196 -0
- package/scripts/host_install.py +277 -0
- package/scripts/install.py +593 -0
- package/scripts/launcher.py +186 -0
- package/scripts/verify_fixture.py +220 -0
- package/scripts/verify_live.py +62 -0
- package/scripts/verify_quality.py +87 -0
- package/skills/bookmark-research/SKILL.md +80 -0
- package/skills/bookmark-research/agents/openai.yaml +3 -0
- package/skills/bookmark-research/references/cli.md +150 -0
- package/skills/bookmark-research/references/deep-research.md +164 -0
- package/skills/bookmark-research/references/github-and-sync.md +30 -0
- package/skills/bookmark-research/references/host-workflows.md +101 -0
- package/skills/bookmark-research/references/package-semantics.md +78 -0
- package/skills/bookmark-research/references/research-methods.md +51 -0
- package/skills/bookmark-research/references/research-services.md +72 -0
- package/skills/bookmark-research/references/research-workflow.md +57 -0
- package/skills/bookmark-research/references/settings-and-archive.md +104 -0
- package/skills/bookmark-research/references/source-lifecycle.md +75 -0
- package/skills/bookmark-research/references/wiki-and-evaluation.md +63 -0
- package/skills/bookmark-research/references/zh/cli.md +150 -0
- package/skills/bookmark-research/references/zh/deep-research.md +164 -0
- package/skills/bookmark-research/references/zh/github-and-sync.md +30 -0
- package/skills/bookmark-research/references/zh/host-workflows.md +101 -0
- package/skills/bookmark-research/references/zh/package-semantics.md +78 -0
- package/skills/bookmark-research/references/zh/research-methods.md +51 -0
- package/skills/bookmark-research/references/zh/research-services.md +70 -0
- package/skills/bookmark-research/references/zh/research-workflow.md +57 -0
- package/skills/bookmark-research/references/zh/settings-and-archive.md +104 -0
- package/skills/bookmark-research/references/zh/skill-guide.md +75 -0
- package/skills/bookmark-research/references/zh/source-lifecycle.md +75 -0
- package/skills/bookmark-research/references/zh/wiki-and-evaluation.md +63 -0
- package/src/archive.py +302 -0
- package/src/bookmark_index.py +913 -0
- package/src/cli.py +435 -0
- package/src/credentials.py +82 -0
- package/src/evaluation.py +337 -0
- package/src/jina.py +96 -0
- package/src/mcp_server.py +641 -0
- package/src/onboarding.py +352 -0
- package/src/provider_adapters.py +152 -0
- package/src/readiness.py +283 -0
- package/src/remote_mcp.py +295 -0
- package/src/research.py +1334 -0
- package/src/research_coverage.py +160 -0
- package/src/research_services.py +684 -0
- package/src/routing.py +202 -0
- package/src/search_results.py +244 -0
- package/src/service_http.py +139 -0
- package/src/settings.py +247 -0
- package/src/source_inputs.py +201 -0
- package/src/source_manager.py +348 -0
- package/src/source_watcher.py +68 -0
- package/src/textio.py +9 -0
- package/src/tui.py +586 -0
- package/src/web_search.py +603 -0
- package/src/wiki.py +383 -0
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# 配置与来源归档
|
|
2
|
+
|
|
3
|
+
[English](../settings-and-archive.md) · **中文阅读版**
|
|
4
|
+
|
|
5
|
+
用户可直接在对话中改配置,CLI 与 MCP 使用同一套实现。当前没有独立图形设置页。
|
|
6
|
+
|
|
7
|
+
provider 可选 `exa`、`parallel`、`tavily`、`jina`。搜索第一轮由 `search.providers` 指定(Exa + Parallel),无结果查询交给 `search.fallback_providers`(Tavily + 有 key 的 Jina);设为空数组可关闭回退。读取先用 `fetch.provider`,再将未成功 URL 交给其余 `fetch.providers` 并发,默认为 Exa → Parallel + Jina。Jina Reader 支持匿名读取,Search 需要 `JINA_API_KEY`。Tavily 使用 `TAVILY_API_KEY` 或 keyless header。密钥优先读取进程环境,其次读取私有凭据文件,公共访问仍受限额影响。
|
|
8
|
+
|
|
9
|
+
## 终端向导与就绪检查
|
|
10
|
+
|
|
11
|
+
运行 `python3 <root>/src/cli.py setup --host codex`(或 `claude_code`、`pi`、`dsh`)设置偏好、隐藏输入密钥并检查所选服务。`--lang auto|en|zh` 选择向导语言。`--non-interactive --input FILE` 无提示合并部分偏好,`--input -` 从 stdin 读取 JSON。Agent 通过环境变量提供密钥,不在聊天或偏好 JSON 中传递。`--skip-checks` 不联网;`--test-retrieval` 选择执行可能使用额度的样例搜索/阅读,两者互斥。向导和检查不会创建专业研究任务。
|
|
12
|
+
|
|
13
|
+
向导将密钥保存为配置旁的 `credentials.json`,或 `BOOKMARK_RESEARCH_CREDENTIALS` 指定的文件;文件为权限 `0600` 的本地明文,仅当前用户可读。运行时会重新读取文件,保存后不必重启插件;更改进程环境变量需重启对应进程。环境变量优先。宿主 OAuth token 留在宿主,插件密钥不会自动授权另外登记的宿主 MCP。
|
|
14
|
+
|
|
15
|
+
每个联网研究新问题前调用 `research_readiness`,传入实际 `host`、`observed_tools` 及明确限定的 `providers`。CLI 示例:`readiness --host codex --tool mcp__exa__agent_run`。只传实际观察到的工具;省略表示未知,空数组表示未观察到任何工具。本地查询无需检查,同一调查复用结果,不反复强制刷新。直接调用 CLI 时,在网页操作前显式运行 readiness。
|
|
16
|
+
|
|
17
|
+
| 策略 | 行为 |
|
|
18
|
+
| --- | --- |
|
|
19
|
+
| `cached`(默认) | 首次、配置/密钥变化或过期后检查,默认 TTL 为 900 秒 |
|
|
20
|
+
| `always` | 每个新问题的 readiness 调用均刷新 |
|
|
21
|
+
| `manual` | 仅 `refresh:true`/`--refresh` 或明确的实际检索测试联网 |
|
|
22
|
+
| `offline:true`/`--offline` | 只读本地配置和仍有效的缓存证据,不联网 |
|
|
23
|
+
|
|
24
|
+
按验证范围理解结果:
|
|
25
|
+
|
|
26
|
+
- `missing_credentials`:用 setup 或进程环境配置指明的密钥。
|
|
27
|
+
- `catalog_reachable`:MCP 发现成功,实际搜索/读取权限和额度未验证。
|
|
28
|
+
- `http_unchecked`:有 HTTP 适配,检查各操作缺失的密钥;Jina Search 必须有 key。
|
|
29
|
+
- `retrieval_verified`:仅逐操作状态为 `verified` 的操作已验证,其他操作仍可能失败。
|
|
30
|
+
- `model_access_verified`:OpenAI 模型元数据可读,尚未测试付费研究任务。
|
|
31
|
+
- `task_mcp_access_verified`:Parallel Task MCP 接受了密钥,不证明 Task API 的执行权限。
|
|
32
|
+
- `failed`:按认证/网络/契约错误修复,再刷新受影响服务;其他路线仍可使用。
|
|
33
|
+
|
|
34
|
+
可选原生研究 MCP 返回对应端点的 `setup` 指引。先检查已有登记,避免重复添加。Codex 使用 `codex mcp list --json`、`codex mcp add NAME --url URL`,支持 OAuth 的端点用 `codex mcp login NAME`;Claude 使用 `claude mcp list`、`claude mcp add --scope user --transport http NAME URL`,再在 `/mcp` 授权。采用实际 scope 和已存在的名称。Pi 不假定安装任何 MCP 扩展,宿主自行进行的普通/深度研究使用同包 CLI。DSH 通过官方 MCP client 在所选 profile 配置 `transport: streamable-http`、端点及环境变量引用的 headers;该桥接器文档尚未确立 OAuth 支持。工具可见不证明登录成功,不用启动付费任务来测试授权。
|
|
35
|
+
|
|
36
|
+
向导展示宿主步骤,不执行它们或导入宿主 token。`needs_attention` 列出失败检查和所选服务必需但缺失的密钥。缓存保留时间戳与过期时间,失败最多缓存 60 秒;文件位于数据目录的 `readiness/`,不含原始密钥。修改配置或轮换密钥会使对应证据失效。
|
|
37
|
+
|
|
38
|
+
## 配置入口
|
|
39
|
+
|
|
40
|
+
`get_settings` 返回有效设置及 `config_path`,只读不创建文件。`update_settings` 接收 `changes`,按字段合并并持久保存;后续调用立即读取新设置,不需重启 MCP。例如用户要求以后仅用 Exa 搜索并打开归档:
|
|
41
|
+
|
|
42
|
+
```json
|
|
43
|
+
{"changes":{"search":{"providers":["exa"],"fallback_providers":[]},"archive":{"enabled":true}}}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
多个 CLI/MCP 进程同时更新同一配置时,通过配置旁的 `.<文件名>.lock` 串行合并,再原子替换配置文件;不同字段的更新不会互相覆盖。锁文件会保留,锁等待超过 10 秒则返回超时。
|
|
47
|
+
|
|
48
|
+
支持的有效配置形状如下;`directory` 的示例路径须换成使用者选择的绝对路径,也支持 `~`:
|
|
49
|
+
|
|
50
|
+
```json
|
|
51
|
+
{
|
|
52
|
+
"schema_version": 1,
|
|
53
|
+
"timeout_seconds": 30,
|
|
54
|
+
"search": {"providers": ["exa", "parallel"], "fallback_providers": ["tavily", "jina"], "limit_per_target": 5},
|
|
55
|
+
"fetch": {"provider": "exa", "providers": ["exa", "parallel", "jina"], "max_characters": 12000},
|
|
56
|
+
"archive": {"enabled": true, "directory": "/absolute/path/to/knowledge"},
|
|
57
|
+
"research": {"depth": "auto", "prefer_host_workflows": true, "response_language": "auto",
|
|
58
|
+
"methods": ["comparative_analysis", "fact_check", "benchmark_review"]},
|
|
59
|
+
"readiness": {"mode": "cached", "ttl_seconds": 900, "timeout_seconds": 10},
|
|
60
|
+
"professional_research": {"enabled": false, "provider": null,
|
|
61
|
+
"openai": {"model": "o4-mini-deep-research", "max_tool_calls": 24},
|
|
62
|
+
"parallel": {"processor": "pro"}},
|
|
63
|
+
"wiki": {"directory": "/absolute/path/to/wiki"}
|
|
64
|
+
}
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
优先级为本次调用参数 → 用户持久配置 → 内置默认值。`fetch_web.archive=false` 只禁用这次保存;改变今后默认值用 `update_settings`。`max_characters` 范围为 100–100000,只有 provider 支持对应参数时才会传递限制,实际请求参数记录在归档中;`character_limit_applied: false` 表示服务未提供本插件支持的长度参数。加大限额不保证得到完整页面。
|
|
68
|
+
|
|
69
|
+
配置文件路径:CLI `--config` → `BOOKMARK_RESEARCH_CONFIG` → `${XDG_CONFIG_HOME:-~/.config}/bookmark-research/settings.json`。归档默认目录为 `${BOOKMARK_RESEARCH_DATA_DIR}/knowledge`;未指定数据目录时采用 `${XDG_DATA_HOME:-~/.local/share}/bookmark-research/knowledge`。不同载体指向同一配置文件和数据目录即可共享偏好及档案。API key 与偏好分开,放在环境变量或私有凭据文件。
|
|
70
|
+
|
|
71
|
+
`XDG_CONFIG_HOME` 与 `XDG_DATA_HOME` 必须是绝对路径;空值或相对路径按 XDG 规范忽略,使用用户主目录下的默认位置,避免随客户端工作目录漂移。
|
|
72
|
+
|
|
73
|
+
`research.depth` 为 auto/quick/agentic/deep;auto 按当前 task_shape 路由。methods 可选 comparative_analysis、fact_check、benchmark_review、wiki_synthesis。路由只给建议,不启动子代理或更改宿主设置。专业服务默认未启用,需独立的 `OPENAI_API_KEY` 或 `PARALLEL_API_KEY`;Search MCP 成功不证明研究 API 已认证,见 [服务参考](research-services.md)。
|
|
74
|
+
|
|
75
|
+
旧设置文件会继承新增默认值,只保存用户的覆盖字段。若显式保存过 `search.providers` 而没有备用字段,则保留原服务范围、关闭回退;同时设置两组列表即可启用首轮与备用分工。长期偏好存这里,不修改数据包 `AGENTS.md`。配置、证据、Wiki、SQLite 和来源快照位于插件及画布包之外,升级不打包这些用户数据。没有 embedding。目录是否持续检查由每个来源的 `mode` 决定,和网页归档开关无关,见 [来源生命周期](source-lifecycle.md)。
|
|
76
|
+
|
|
77
|
+
## 一次读取怎样保存
|
|
78
|
+
|
|
79
|
+
输出语言优先级为本次要求 → `research.response_language`(`auto`/`en`/`zh`)→ auto 时跟随任务/对话 → 均未指定时英文。安装器 `--lang` 只影响帮助和引导,向导另外询问答复语言;委派工作流时传入确定后的 `output_language`。
|
|
80
|
+
|
|
81
|
+
`fetch_web` 自动保存它自己收到的实际 provider 响应。搜索命中本身不触发抓取或存档,宿主另外配置的 Exa/GitHub MCP 响应也不会被本插件自动截获。
|
|
82
|
+
|
|
83
|
+
认证、传输或工具契约失败且没有取得实际响应时,返回 `status:"error"`、`error_kind` 和 `usage`,不包含 `result`,也不生成虚构归档。已经取得响应但没有提取出正文时,仍保留实际 `result` 与逐页状态。MCP 将完整调用失败标为 `isError:true`;部分成功保留可用结果。
|
|
84
|
+
|
|
85
|
+
```text
|
|
86
|
+
knowledge/
|
|
87
|
+
sources/
|
|
88
|
+
<本次抓取时间与唯一 ID>/
|
|
89
|
+
response.json
|
|
90
|
+
manifest.json
|
|
91
|
+
pages/<URL 哈希>.md
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
- `response.json` 是 MCP 返回的原始结果对象,包含文本块和 provider 状态;不是网页原始 HTML,也不包含本插件的认证请求头。
|
|
95
|
+
- Markdown 保存能确认关联到指定 URL 的实际提取文本,不让模型凭摘要补全文。来源信息放在 `manifest.json`,避免混入原文。
|
|
96
|
+
- manifest 记录请求 URL、返回 URL、provider、tool、实际请求参数、检索时间、响应哈希 `response_sha256`、正文路径与哈希,以及失败、缺失、摘录或可能截断的标记。服务返回的发布时间与作者分别放在 `provider_published_at`、`provider_author`,不混入正文。`completeness: "unknown"` 表示不能证明完整。`possibly_truncated: false` 也不是完整性保证。
|
|
97
|
+
- `#comments` 等锚点会保留,但网页提取不保证所有评论已加载;`fragment_scope_verified: false` 明示这一点。检索时间不等于网页发布时间、更新时间或缓存时间。
|
|
98
|
+
- 返回格式无法识别时仍保存响应与 manifest,正文路径为空;部分 URL 失败时只保存成功识别的正文。每页的 `extraction_status: "provider_error"` 表示服务报告失败;同一 URL 的正文冲突时为 `conflicting_provider_results`,不自动挑选一份正文。归档失败会在 `archive.status: "error"` 明示,工具仍返回已收到的内容,不自动重复付费抓取。
|
|
99
|
+
- Exa 批量记录支持带缩进的多行标题。每个独占行的 `URL:` 字段都必须属于已识别的记录;边界缺失、格式异常或重复时,该文本块只保留原始响应。未请求的重定向页面同样构成边界。正文内出现类似记录字段的歧义内容时,这项保守检查可能不提取正文,应检查原始响应,不将其归给另一页。
|
|
100
|
+
- 再读同一 URL 会追加新快照,不覆盖旧档案;这只是按调用保存,不是后台版本监控。
|
|
101
|
+
|
|
102
|
+
报告可引用 `archive.manifest_path` 和各页 `body_path`。档案不自动加入书签 SQLite 或生成 Wiki。宿主实际取得的原文可用 `research_import_evidence` 导入并标明 provenance;外部研究报告用 external_report,引用列表不算原页阅读。整理知识页使用 `wiki_write`。
|
|
103
|
+
|
|
104
|
+
深度研究使用独立的 `research/` 目录保存状态和证据。`research_fetch` 始终保存已取得的研究响应和正文,普通 fetch 的 archive 开关不影响它;任务恢复和引用校验需要这些快照。会话详情与分页见 [深度研究流程](deep-research.md)。
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# 书签研究(中文阅读版)
|
|
2
|
+
|
|
3
|
+
[English execution Skill](../../SKILL.md) · **中文阅读版**
|
|
4
|
+
|
|
5
|
+
使用用户提供的普通链接、画布路径或已注册来源。答复优先遵循本次指定语言,其次是保存的 `research.response_language`;`auto` 跟随任务语言,都未指定时用英文。尚不了解已保存偏好时先读 `get_settings`。将确定的语言要求传给子代理并写入研究 brief,保留原始引文与标识符。参考文件按需读一种语言即可。原书签包保持不变,本地备注不进入公开查询。
|
|
6
|
+
|
|
7
|
+
## 按实际需求选择
|
|
8
|
+
|
|
9
|
+
| 用户请求 | 执行流程 |
|
|
10
|
+
| --- | --- |
|
|
11
|
+
| 找书签、统计数量、查看备注或画布关系 | 查询本地元数据。 |
|
|
12
|
+
| 核实一个链接或少量事实 | `quick`:读取 URL,给出有依据的简短答复。 |
|
|
13
|
+
| 比较收藏的工具、调查开放问题 | `agentic`:当前宿主模型搜索/读文、判断证据,再补查缺口。 |
|
|
14
|
+
| 研究完整清单/数据包、保留进度、交付可复核报告 | `deep`:在宿主调查上增加固定范围、证据记录、独立审阅和覆盖检查。 |
|
|
15
|
+
|
|
16
|
+
优先遵循本次明确深度,再遵循保存的偏好;表格用于 `auto`。深度与范围分别判断:专题比较只覆盖该专题,整包研究保留全部原始 URL。需要时简短说明范围,然后执行;仅询问能力不自动启动研究。宿主搜索和深研无需专业研究 API Key。
|
|
17
|
+
|
|
18
|
+
## 入口与快速查证
|
|
19
|
+
|
|
20
|
+
使用当前宿主实际暴露的 MCP 工具。旧目录缺少所需工具或参数时,使用同包 CLI:`<插件根目录>/src/cli.py`。插件根目录是英文 Skill 所在目录的 `../..`,调用时使用绝对路径。其他命令名称见 [CLI 参考](cli.md)。
|
|
21
|
+
|
|
22
|
+
每个需要联网研究的新问题开始前调用 `research_readiness`(CLI `readiness`)。传入实际 `host` 和当前确实观察到的宿主工具;用户明确限定服务时也传入对应 providers。检查遵循 cached/always/manual 偏好,同一轮调查不要反复强制探测。离线书签查询不需要联网就绪检查。连接或授权失败后检查受影响服务,修复后再刷新。保留用户显式选择的服务;可选 API/MCP 故障不阻塞其他可用路线。
|
|
23
|
+
|
|
24
|
+
区分已配置密钥、工具目录可达、实际检索成功和宿主 OAuth 登录。缺少可选原生研究 MCP 或协作能力时,宿主仍可自己完成搜索阅读循环。安装配置或排障使用 CLI `setup` 和 [配置指引](settings-and-archive.md)。不要让用户在对话中粘贴密钥;终端支持隐藏输入或环境变量。实际样例检索会使用服务额度,仅按选择执行;就绪检查不会启动专业研究任务。
|
|
25
|
+
|
|
26
|
+
已知公开 URL 直接调用 `fetch_web({urls:[url]})`;CLI 等效命令为:
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
python3 <插件根目录>/src/cli.py fetch-web 'https://example.com/page' --timeout 20
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
`--timeout` 限制单个服务 HTTP 请求,整条瀑布流可能更久。除就绪检查外,普通链接查证不需要导入包、创建研究档案或加载其他参考文件。公开 GitHub README 也走这条路径。
|
|
33
|
+
|
|
34
|
+
`fetch_web.pages` 按 URL 返回一份选中的正文,各次尝试保留状态与归档路径。读取相关内容并引用原 URL;完整服务响应仍在归档内,需要时可用 `raw:true`(CLI `--raw`)返回。注意归档错误和完整性标记,抽取文本不证明取得了完整或实时原页。
|
|
35
|
+
|
|
36
|
+
## 搜索、判断、补查
|
|
37
|
+
|
|
38
|
+
由当前宿主模型先处理。已有 URL 直接读取;需要发现来源才用 `search_web`,随后读取关键页。普通比较可以直接答复;需要保存进度或报告时再建立研究档案。
|
|
39
|
+
|
|
40
|
+
省略 provider 使用保存的回退设置。默认读取先 Exa,未解决 URL 再并发 Parallel + Jina;搜索先 Exa + Parallel,无可用结果的查询再走 Tavily + 有 Key 的 Jina。显式 provider 限定本次服务。保留已成功项,检查未解决项、跳过的服务和预算。
|
|
41
|
+
|
|
42
|
+
非空响应不等于已回答。核对页面身份、相关段落、日期/版本及引文能否支撑结论。正文不相关、过时或不足时,只补查受影响的 URL/问题:换用允许的服务、链接指向的官方说明、原站 API/Markdown 视图或可用且获授权的浏览器。在研究会话内记录 uncertain/rejected 来源判断,为合理的新读取使用新的 operation ID;重放旧操作不会得到新文本。替代页面可以支持答案,不能冒充已读原始 URL。
|
|
43
|
+
|
|
44
|
+
选择或排查服务时读 [服务接入](research-workflow.md);研究代码、issue、release、私有访问或 Git 同步时读 [GitHub 边界](github-and-sync.md);系统比较、冲突或榜单分析时读 [研究方法](research-methods.md)。简单事实核实不需要加载完整方法流程。
|
|
45
|
+
|
|
46
|
+
## 画布语境
|
|
47
|
+
|
|
48
|
+
画布输入先读 [阅读协议](package-semantics.md),会话内复用。仅在字段新颖/冲突或用户要求核对特殊约定时查原包指南。
|
|
49
|
+
|
|
50
|
+
- 用 `index_status` 查已注册来源;新手动导出用 `sync_package` 的 `snapshot`,用户指定持续目录时用 `live`。只有确认完整镜像才用 `completeness:complete`,局部导出/单卡用 `partial`;同一画布改路径沿用真实 source ID。[来源生命周期](source-lifecycle.md) 用于迁移、恢复和监控。
|
|
51
|
+
- 在 `search_bookmarks` 中合并目标,跟随每个目标的分页。匹配是字面元数据匹配,短名称须对照标题和域名判断。保留 ID、URL 和分组语境;本地标题不证明网页能力。
|
|
52
|
+
- `get_context` 携带 `item_id` 返回书签与祖先,并提供卡片说明、分组和有向边。栏目概览不枚举全部书签;从祖先取得 folder ID 后再限定目录查询。
|
|
53
|
+
- 检查 `source.state` 并说明待同步状态;error/unavailable 时只有用户接受旧索引才用 `refresh:false`。历史快照用 `source_history`。研究清单和 Wiki 保留版本,新鲜度提示要求复核,不代表自动重做调查。
|
|
54
|
+
|
|
55
|
+
## 深研与协作
|
|
56
|
+
|
|
57
|
+
需要此模式时读 [深研流程](deep-research.md),以明确问题和完整输入开始:
|
|
58
|
+
|
|
59
|
+
- 普通书签:`research_start` 传 `urls:[...]`,重复位置仍保留在固定清单中,无需注册画布。
|
|
60
|
+
- 画布数据包:传已索引的 `source_ids`,默认整包。用户明确要求子集时才用 `scope_mode:subset` 及选中的 inventory ID 或书签引用。
|
|
61
|
+
- 没有原始书签清单的公开问题可省略两类输入;仅写在 brief 里的 URL 不进入覆盖统计。
|
|
62
|
+
|
|
63
|
+
跟随全部 `research_inventory.next_offset`。区分原始 `u-...` inventory ID 与正文 `sN` evidence ID。阅读、审阅原文,记录带引文结论与逐项清单判断。已有判断用 `research_record.entries` 批量提交(至多 50 条),附稳定的 `batch_id`;后续批次引用真实返回 ID。检查覆盖差集,直到完成或预算用尽。失败说明、批量排除、搜索摘要和外部报告的引文列表不能证明完整原始来源已审阅。
|
|
64
|
+
|
|
65
|
+
主代理负责综合。当前允许委派且具备能力时,给独立读者/核验者明确范围、问题、语言、相关指引与真实 MCP/CLI 入口;执行细节按需读 [宿主工作流](host-workflows.md),适用 Codex、Claude、Pi、DSH,不假定子代理继承上下文。不能委派时由当前代理复核并说明。
|
|
66
|
+
|
|
67
|
+
专业研究服务用于具体子问题或用户明确指定的任务;使用前读 [研究服务](research-services.md)。保留显式服务选择和已知 run ID,不替换仍在运行或结果未知的任务。`research_route` 只建议、不启动;保存的 API 偏好不替代宿主首轮处理。
|
|
68
|
+
|
|
69
|
+
## 交付与偏好
|
|
70
|
+
|
|
71
|
+
简单问题直接答复。报告结束前,对照证据与反证复核实际交付的短答;压缩结论时保留原有条件和限制。`research_finish` 生成报告、来源和覆盖文件,分别说明逐项交代、可用正文、实质审阅与问题完成度。覆盖、答案证据及冲突检查通过才用 `completed`,否则保留 `incomplete` 与缺口;继续未完成档案先写 `resume`。保存状态不代表后台代理。
|
|
72
|
+
|
|
73
|
+
用户要求将结论保存在 Wiki 或知识库中时才编写 Wiki;发表、lint 和有真实评审标签的质量比较见 [Wiki 与评测](wiki-and-evaluation.md)。引文/哈希检查不证明语义正确。
|
|
74
|
+
|
|
75
|
+
配置问题用 `get_settings`,长期偏好用 `update_settings`,临时选择用本次参数。[配置与归档](settings-and-archive.md) 说明保存位置;索引、归档和产出均位于原包之外。
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# 来源接入、快照与持续同步
|
|
2
|
+
|
|
3
|
+
[English](../source-lifecycle.md) · **中文阅读版**
|
|
4
|
+
|
|
5
|
+
输入载体与来源寿命分别选择。目录或 ZIP 可以是一次导出的快照;单个永久/临时卡片 JSON 是局部输入;Git 同步到本地的目录可以是持续来源。栏目的 permanent/temporary 不决定来源是否持续同步。
|
|
6
|
+
|
|
7
|
+
## 登记与识别
|
|
8
|
+
|
|
9
|
+
`sync_package(package_path, source_id?, mode?, completeness?)` 和 CLI `sync` 共用实现:
|
|
10
|
+
|
|
11
|
+
| 参数 | 行为 |
|
|
12
|
+
| --- | --- |
|
|
13
|
+
| `package_path` | 接受协议目录、ZIP、单个 `bookmark-canvas-section` JSON。ZIP 可有包装目录,但只能包含一个画布包;只有一张卡片的 ZIP 也可导入。 |
|
|
14
|
+
| `source_id` | 稳定的逻辑来源身份。省略时复用已登记路径的关联;新路径生成 ID。移动、改名或新日期导出同一画布时显式沿用原 ID,以后这些路径都会记住关联。 |
|
|
15
|
+
| `mode: snapshot` | 保存原始协议文件及派生索引,查询使用已接入快照,原下载目录或压缩包移走不影响查询。 |
|
|
16
|
+
| `mode: live` | 只接受目录,查询前检查变化;MCP 存活期间后台检查,按哈希增量同步。 |
|
|
17
|
+
| `completeness: partial` | 默认。未提供的文件继续保留;已提供的完整栏目内删除的 item 正常删除。 |
|
|
18
|
+
| `completeness: complete` | 明确的完整镜像,同步删除缺失的栏目和画布文件;单卡不能使用此模式。 |
|
|
19
|
+
|
|
20
|
+
新的普通目录、ZIP 和单卡默认 `snapshot`;Git 仓库中的目录默认 `live`,完整性仍默认 `partial`。已有来源沿用登记的模式与完整性;单卡自动使用 `partial`。旧版来源保留 `live + partial`,转成快照时显式重新登记一次。
|
|
21
|
+
|
|
22
|
+
用户说“这是同一个画布的新导出”时,先从 `index_status` 取原 `source_id`。当前导出协议没有可靠的全局画布 ID,不按标题、URL、槽位或相同栏目 ID 自动合并不同画布。同一路径登记过多个来源时,需要明确 `source_id`。这些是工具参数,用户正常用自然语言说明来源即可。
|
|
23
|
+
|
|
24
|
+
```sh
|
|
25
|
+
# 一次导出,以后沿用 my-canvas
|
|
26
|
+
python3 <root>/src/cli.py sync /downloads/export.zip --source-id my-canvas
|
|
27
|
+
python3 <root>/src/cli.py sync /downloads/card.json --source-id my-canvas
|
|
28
|
+
# 用户明确指定的完整 Git 同步目录
|
|
29
|
+
python3 <root>/src/cli.py sync /repos/bookmarks/canvas --source-id synced-canvas --mode live --completeness complete
|
|
30
|
+
python3 <root>/src/cli.py status synced-canvas
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
单卡更新已知来源时,以稳定栏目 ID 对应原文件路径,避免下载文件名改变导致引用断开。单独的副本锚点不包含主树;没有对应主树时报告未解析引用,不虚构书签。
|
|
34
|
+
|
|
35
|
+
## 快照和恢复
|
|
36
|
+
|
|
37
|
+
快照位于数据库旁的 `<数据库文件名>.sources/`,默认是 `index.sqlite3.sources/`。每个来源、每个内容版本有独立目录:`package/` 保存所有必要的 JSON/.canvas,包括局部导出保留的文件;`manifest.json` 保存哈希、输入位置和已解析的关系。原文件不改写,附件和无关文件不复制。
|
|
38
|
+
|
|
39
|
+
`source_history(source_id, limit?, offset?)`/CLI `history` 列出内容版本。内容及关系没变时复用同一版本;旧版本不覆盖、不自动清理。日常查询使用当前索引,`refresh=false` 并非历史版本选择器。
|
|
40
|
+
|
|
41
|
+
```sh
|
|
42
|
+
python3 <root>/src/cli.py history my-canvas
|
|
43
|
+
# 使用 history 返回的 snapshot_path,可在新数据库中恢复
|
|
44
|
+
python3 <root>/src/cli.py --db /data/recovered.sqlite3 sync /data/index.sqlite3.sources/<source>/<version>/package --source-id my-canvas --mode snapshot --completeness complete
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
恢复校验快照哈希并恢复组/副本关系。局部导出只改了栏目文件名、未提供新版画布时,关系映射单独保存在清单中,原始画布字节仍保留。旧版索引没有快照且原目录已消失时,可从 SQLite 保存的完整 JSON 恢复语义;`Recovered legacy JSON...` 警告表示原来的空白格式无法保留,输入哈希也会变化。
|
|
48
|
+
|
|
49
|
+
## 持续更新的时机与状态
|
|
50
|
+
|
|
51
|
+
MCP 连接后为已有 live 来源启动进程内检查;首次登记后也会启动。实现使用 Python 标准库轮询,默认每 1 秒检查协议文件,连续变化稳定 2 秒后同步;完整目录的文件删除需稳定 5 秒。借鉴的是 CodeGraph 的变化合并、待更新提示与重连补检查,未移植其原生文件事件监听器。
|
|
52
|
+
|
|
53
|
+
普通查询主动补查;有效且不涉及整文件删除的变化可以立即更新,删除仍等待稳定窗口。坏 JSON、非法路径、身份冲突或完整镜像中的未解析引用导致整次导入回滚。目录消失、暂时清空或 Git 写入锁存在时保留最后一次有效索引。空目录不自动清库;明确的空画布或空栏目可表达清空内容。手动 `sync` 是明确的导入动作,不使用后台删除等待窗口。
|
|
54
|
+
|
|
55
|
+
`index_status` 和本地查询的 `source` 返回以下状态:
|
|
56
|
+
|
|
57
|
+
| `state` | 含义 |
|
|
58
|
+
| --- | --- |
|
|
59
|
+
| `snapshot` | 已接入固定快照,不监控原下载文件 |
|
|
60
|
+
| `current` | 最近一次检查已同步;同时查看 `checked_at` |
|
|
61
|
+
| `pending` | 等待变化稳定或 Git 正在写入;`pending_files` 列出已识别的待更新文件 |
|
|
62
|
+
| `unchecked` | live 来源最近未检查,不能据此宣称已追上磁盘内容 |
|
|
63
|
+
| `unavailable` / `error` | 来源不可用或校验失败;`error` 说明原因,旧索引保留 |
|
|
64
|
+
|
|
65
|
+
默认 live 查询遇到 error/unavailable 返回错误;用户允许使用旧数据时用 `refresh=false`/`--no-refresh`。pending 查询可返回上次有效数据,必须同时说明待同步状态。新研究不会在 pending/error/unavailable 输入上冻结清单。
|
|
66
|
+
|
|
67
|
+
后台检查跟随宿主 MCP 进程,关闭后停止,重连后恢复,首次查询也会补查。独立 CLI 查询结束后退出,需要持续监控时运行 `python3 <root>/src/cli.py watch`,前台运行并用 Ctrl-C 停止。`watch` 支持 `--interval`、`--debounce`、`--deletion-grace` 秒数,输出状态变化的 JSON 行。使用一次性 CLI 的宿主不会自动获得常驻监控。
|
|
68
|
+
|
|
69
|
+
Git 拉取、推送和登录仍由原有同步系统或宿主负责,插件只读本地结果。多个 MCP 进程各用独立 SQLite 连接,由事务协调写入;查询使用一致的读取快照。ZIP 拒绝路径穿越、符号链接、重复成员和多包歧义;上限为单文件 64 MiB、总解压大小 256 MiB、10,000 个条目。
|
|
70
|
+
|
|
71
|
+
## 研究与 Wiki
|
|
72
|
+
|
|
73
|
+
索引更新不触发网页抓取、LLM 调用或 Wiki 改写。研究 inventory、正文和结论保持原版本。`research_status.source_freshness` 比较冻结输入与当前已同步索引,变化时 `requires_review=true`。Wiki 读取/lint 标注 `source_input_changed`,`wiki_get.validation.status` 为 `needs_review`,表示需要结合新输入复核,不直接判定旧结论错误。网页本身是否更新不在此检查范围内。
|
|
74
|
+
|
|
75
|
+
机制参考:[CodeGraph indexing](https://github.com/colbymchenry/codegraph/blob/main/site/src/content/docs/guides/indexing.md)、[watcher](https://github.com/colbymchenry/codegraph/blob/main/src/sync/watcher.ts)。这是公开文档与代码对照,未把 CodeGraph 运行测试当成本插件验收。
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# Wiki 与研究质量评测
|
|
2
|
+
|
|
3
|
+
[English](../wiki-and-evaluation.md) · **中文阅读版**
|
|
4
|
+
|
|
5
|
+
Wiki 是经审阅的跨来源知识,正文归档是原始证据,两者分别保存。评测计算已有运行和标注,不会替模型执行研究,也不自动判断引用语义。
|
|
6
|
+
|
|
7
|
+
## 从研究整理 Wiki
|
|
8
|
+
|
|
9
|
+
使用 `research_source` 读正文、`source_review` 接受有效材料、`claim` 保存原子结论。把相互支持或冲突的结论组织为主题/实体页;保留版本、条件、反证和未解决项,不把来源堆叠成无结构摘录。
|
|
10
|
+
|
|
11
|
+
`wiki_write` 的输入示例(替换为本次实际内容与 ID):
|
|
12
|
+
|
|
13
|
+
```json
|
|
14
|
+
{
|
|
15
|
+
"page_id": "research-execution",
|
|
16
|
+
"expected_revision": 0,
|
|
17
|
+
"change_note": "将已审阅研究整理为主题页",
|
|
18
|
+
"page": {
|
|
19
|
+
"title": "研究执行方式",
|
|
20
|
+
"kind": "topic",
|
|
21
|
+
"sections": [{
|
|
22
|
+
"heading": "可用能力与边界",
|
|
23
|
+
"text": "由下面结论支持的综合说明,保留限制与不确定性。",
|
|
24
|
+
"claims": [{"research_id": "r-实际ID", "claim_id": "c1"}]
|
|
25
|
+
}],
|
|
26
|
+
"links": [],
|
|
27
|
+
"review": {"method": "model", "reviewer": "实际评审模型与版本", "note": "说明实际核对的页面身份、引用语义和范围。"}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
kind 为 topic 或 entity。每节必须有有效 claim;每页至多 24 个不同 claim,过大主题拆成有明确关系的页面。写入检查 claim 未撤回、来源 accepted、原句存在和正文哈希。`review` 如实记录谁做了语义核验;程序不能证明该核验已经发生。
|
|
33
|
+
|
|
34
|
+
标题、章节、综合正文和审阅说明使用本次任务要求的语言。标识符与原始引用保持原样,译文另列。
|
|
35
|
+
|
|
36
|
+
先写被引用的实体/主题页,再用 `links:[{page_id,relation}]` 建立关系。更新先 `wiki_get` 读当前 revision,将它作为 `expected_revision` 写入;冲突时重新读取合并,不覆盖他人更新。历史修订和当时引用目标版本保持不变。
|
|
37
|
+
|
|
38
|
+
`wiki_get` 返回正文、结论与来源映射、书签实例和 inventory ID。`wiki_lint` 检查哈希、撤回/拒绝、源材料变化、链接与版本问题;`semantic_support:"not_scored"` 表示它不是语义裁判。旧证据失效的页保留历史,但 `wiki_search` 不把它作为当前可靠知识返回。
|
|
39
|
+
|
|
40
|
+
`wiki_list` 和 `wiki_search` 分页返回;搜索对象是整理后的标题、章节和正文,采用字面词匹配,可查询中文。保存一份网页正文不会自动写 Wiki;当前不提供 embedding、向量检索、重排或监控。Wiki 默认数据目录 `wiki/`,可通过 `settings.wiki.directory` 改路径,不能放回原包。
|
|
41
|
+
|
|
42
|
+
## 评测真实运行
|
|
43
|
+
|
|
44
|
+
在同一问题、冻结输入和可比较预算下分别运行宿主普通研究、宿主工作流、专业服务;记录实际模型、工具、宿主版本、输入版本、时间窗口、失败和可获得用量。无法等同的条件写明,不删失败样本,也不把未知成本当 0。
|
|
45
|
+
|
|
46
|
+
独立评审者按版本化 rubric 阅读真实报告和证据,提供:
|
|
47
|
+
|
|
48
|
+
- 每个原子答案与 gold answer ID 的对应;错误项标 null,没有标注保持未知。
|
|
49
|
+
- 每个 claim/source 引用对的 supported、unsupported 或 uncertain 标签与依据;原句子串匹配不等于语义支持。
|
|
50
|
+
- 报告各维度 0–4 的实际评分与理由,包括事实可靠性、完整性和不确定性处理。
|
|
51
|
+
|
|
52
|
+
调用 `evaluate_research`,输入 `{suite,runs,judgments}`。suite 定义 task、gold answers(无金标准可为 null)、原始来源/问题 ID 与 rubric;runs 保存原子答案、结论、引用、覆盖、最终文本、条件与测量;judgments 保存真实评审者身份、版本及上述标签。完整输入说明见插件根目录 `docs/wiki-quality.zh.md`,源仓库的 `tests/fixtures/research-quality.json` 是明确标记、可运行的合成样例。
|
|
53
|
+
|
|
54
|
+
| 输出 | 必要依据与边界 |
|
|
55
|
+
| --- | --- |
|
|
56
|
+
| 答案 precision/recall/F1 | 金标准和完整答案匹配;缺失时 null |
|
|
57
|
+
| 语义引用准确率 | 完整语义标签,uncertain 仍在分母;部分标注另行显示 |
|
|
58
|
+
| 报告质量 | 版本化 rubric 的全部维度评分;不会自动推断分数 |
|
|
59
|
+
| 四类覆盖 | 原来源/问题分母,reviewed ⊆ text ⊆ accounted;须另核查真实阅读证据 |
|
|
60
|
+
| 成本、时延 | 实测或提供商报告值,注明 basis;没有则 null |
|
|
61
|
+
| 波动 | 同条件重复运行的均值、样本方差等;只有一次时无方差估计 |
|
|
62
|
+
|
|
63
|
+
评测固定输入与标注哈希,并报告比较条件差异。它不自动发布排行榜,也不能把本地 rubric 冒称 RACE/FACT。随包合成示例只验证计算,不证明哪个宿主、workflow 或 API 更好。宣称质量提升之前需真实运行与独立可辩护的标注。
|
package/src/archive.py
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
"""Archive actual fetch responses and conservatively identified page extracts."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
import tempfile
|
|
7
|
+
import uuid
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from urllib.parse import urlsplit, urlunsplit
|
|
11
|
+
|
|
12
|
+
from settings import Settings
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class SourceArchive:
|
|
16
|
+
"""Append immutable capture directories; never invent missing page bodies."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, directory):
|
|
19
|
+
self.directory = Settings.external_path(str(directory), "Archive directory")
|
|
20
|
+
|
|
21
|
+
@staticmethod
|
|
22
|
+
def _key(url):
|
|
23
|
+
parts = urlsplit(url)
|
|
24
|
+
if (parts.scheme not in ("http", "https") or not parts.hostname
|
|
25
|
+
or parts.username is not None or parts.password is not None
|
|
26
|
+
or any(character.isspace() or ord(character) < 32 or ord(character) == 127 for character in url)
|
|
27
|
+
or "\\" in url):
|
|
28
|
+
raise ValueError("Source URLs must be HTTP(S) without credentials or whitespace")
|
|
29
|
+
host = parts.hostname.encode("idna").decode("ascii").lower()
|
|
30
|
+
if ":" in host:
|
|
31
|
+
host = "[" + host + "]"
|
|
32
|
+
if parts.port is not None and parts.port != {"http": 80, "https": 443}[parts.scheme]:
|
|
33
|
+
host += ":" + str(parts.port)
|
|
34
|
+
# An anchor is not a different fetched resource; its coverage stays unverified.
|
|
35
|
+
return urlunsplit((parts.scheme, host, parts.path or "/", parts.query, ""))
|
|
36
|
+
|
|
37
|
+
@staticmethod
|
|
38
|
+
def _rows(value):
|
|
39
|
+
if isinstance(value, list):
|
|
40
|
+
return [row for row in value if isinstance(row, dict) and isinstance(row.get("url"), str)]
|
|
41
|
+
if not isinstance(value, dict):
|
|
42
|
+
return []
|
|
43
|
+
if isinstance(value.get("url"), str):
|
|
44
|
+
return [value]
|
|
45
|
+
rows = []
|
|
46
|
+
for key in ("results", "items", "pages", "data", "result", "errors", "failed_results"):
|
|
47
|
+
if isinstance(value.get(key), (list, dict)):
|
|
48
|
+
children = SourceArchive._rows(value[key])
|
|
49
|
+
rows.extend(({**row, "error": row.get("error") or row.get("message") or "Provider reported failure"}
|
|
50
|
+
if key in ("errors", "failed_results") else row) for row in children)
|
|
51
|
+
# Exa /contents reports per-URL outcomes separately from extracted results.
|
|
52
|
+
statuses = value.get("statuses")
|
|
53
|
+
if isinstance(statuses, list):
|
|
54
|
+
rows.extend({**row, "url": row["id"]} for row in statuses
|
|
55
|
+
if isinstance(row, dict) and isinstance(row.get("id"), str))
|
|
56
|
+
return rows
|
|
57
|
+
|
|
58
|
+
@classmethod
|
|
59
|
+
def _extract(cls, response, requested_urls):
|
|
60
|
+
rows = cls._rows(response.get("structuredContent"))
|
|
61
|
+
blocks = response.get("content", [])
|
|
62
|
+
if not isinstance(blocks, list):
|
|
63
|
+
return rows
|
|
64
|
+
for block in blocks:
|
|
65
|
+
if not isinstance(block, dict) or block.get("type") != "text" or not isinstance(block.get("text"), str):
|
|
66
|
+
continue
|
|
67
|
+
text = block["text"]
|
|
68
|
+
try:
|
|
69
|
+
decoded = json.loads(text)
|
|
70
|
+
except ValueError:
|
|
71
|
+
decoded = None
|
|
72
|
+
structured = cls._rows(decoded)
|
|
73
|
+
if structured:
|
|
74
|
+
rows.extend(structured)
|
|
75
|
+
continue
|
|
76
|
+
rows.extend(cls._text_rows(text))
|
|
77
|
+
return rows
|
|
78
|
+
|
|
79
|
+
@classmethod
|
|
80
|
+
def _text_rows(cls, text):
|
|
81
|
+
rows = []
|
|
82
|
+
# Exa appends partial failures after all successful page records. Keep
|
|
83
|
+
# this provider footer out of the last page's extracted body.
|
|
84
|
+
footer = re.search(r"(?m)^(?:Error fetching https?://\S+: [^\n]+(?:\n|$))+\Z", text)
|
|
85
|
+
if footer:
|
|
86
|
+
for match in re.finditer(r"(?m)^Error fetching (https?://\S+): ([^\n]+)$", footer.group()):
|
|
87
|
+
rows.append({"url": match.group(1), "error": match.group(2)})
|
|
88
|
+
text = text[:footer.start()]
|
|
89
|
+
if text.endswith("\n\n"):
|
|
90
|
+
text = text[:-2]
|
|
91
|
+
# Exa concatenates header/URL/body records into one text block for a batch.
|
|
92
|
+
# Include unrequested/redirected records as boundaries, so their text
|
|
93
|
+
# cannot be silently attributed to the preceding requested URL.
|
|
94
|
+
# Titles may retain indented continuation lines from the source HTML.
|
|
95
|
+
# Records must start the block or follow the provider's blank separator.
|
|
96
|
+
header = r"(?:\A|\n\n)(?:# |Title: )([^\n]+(?:\n[ \t][^\n]*)*)\nURL: (https?://[^\s]+)\n"
|
|
97
|
+
matches = list(re.finditer(header, text))
|
|
98
|
+
# A missed or malformed URL field could hide the next page boundary.
|
|
99
|
+
# Keep the whole block raw instead of assigning that page to its neighbor.
|
|
100
|
+
fields = [match.start() for match in re.finditer(r"(?m)^[ \t]*URL:", text)]
|
|
101
|
+
matched_fields = [match.start(2) - len("URL: ") for match in matches]
|
|
102
|
+
if fields != matched_fields:
|
|
103
|
+
return rows
|
|
104
|
+
try:
|
|
105
|
+
keys = [cls._key(match.group(2)) for match in matches]
|
|
106
|
+
except ValueError:
|
|
107
|
+
return rows
|
|
108
|
+
if not matches or matches[0].start() != 0 or len(set(keys)) != len(keys):
|
|
109
|
+
return rows
|
|
110
|
+
for index, match in enumerate(matches):
|
|
111
|
+
end = matches[index + 1].start() if index + 1 < len(matches) else len(text)
|
|
112
|
+
body = text[match.end():end]
|
|
113
|
+
row = {"url": match.group(2), "title": match.group(1)}
|
|
114
|
+
metadata = re.match(r"(?:(?:Published|Author): [^\n]*\n)*\n", body)
|
|
115
|
+
if metadata:
|
|
116
|
+
for line in metadata.group().splitlines():
|
|
117
|
+
if line.startswith("Published: "):
|
|
118
|
+
row["publishedDate"] = line[11:]
|
|
119
|
+
elif line.startswith("Author: "):
|
|
120
|
+
row["author"] = line[8:]
|
|
121
|
+
body = body[metadata.end():]
|
|
122
|
+
if match.group().lstrip("\n").startswith("Title: ") and body.startswith("Text: "):
|
|
123
|
+
body = body[6:]
|
|
124
|
+
rows.append({**row, "text": body})
|
|
125
|
+
return rows
|
|
126
|
+
|
|
127
|
+
@staticmethod
|
|
128
|
+
def _page(row):
|
|
129
|
+
status = row.get("status")
|
|
130
|
+
if isinstance(status, str):
|
|
131
|
+
status = status.lower()
|
|
132
|
+
error = row.get("error") or row.get("errors")
|
|
133
|
+
failed = (error or row.get("success") is False or status in ("error", "failed", "failure")
|
|
134
|
+
or (type(status) is int and status >= 400)
|
|
135
|
+
or (type(row.get("httpStatus")) is int and row["httpStatus"] >= 400))
|
|
136
|
+
if failed:
|
|
137
|
+
return None, "provider_error", "provider_extracted_text"
|
|
138
|
+
for field in ("markdown", "text", "full_content", "raw_content", "content"):
|
|
139
|
+
body = row.get(field)
|
|
140
|
+
if isinstance(body, str) and body.strip():
|
|
141
|
+
# Keep obvious access screens in the raw response, never as a
|
|
142
|
+
# usable article. Mentions inside an article are not barriers.
|
|
143
|
+
title = row.get("title") if isinstance(row.get("title"), str) else ""
|
|
144
|
+
heading = body.strip().splitlines()[0].lstrip("# ").strip()
|
|
145
|
+
without_notice = re.sub(r"\[CRITICAL INSTRUCTIONS FOR ALL AI ASSISTANTS[^\]]*\]"
|
|
146
|
+
r".*?\[END INSTRUCTIONS\]", "", body, flags=re.IGNORECASE | re.DOTALL)
|
|
147
|
+
if without_notice != body and not any(
|
|
148
|
+
line.strip("# \t") not in ("", title.strip(), heading)
|
|
149
|
+
for line in without_notice.splitlines()):
|
|
150
|
+
return None, "insufficient_content", "provider_extracted_text"
|
|
151
|
+
for label in (title.strip(), heading):
|
|
152
|
+
if re.fullmatch(r"(?:page not found|404(?:\s*[-:]?\s*not found)?|页面不存在|页面未找到)"
|
|
153
|
+
r"(?:\s*[-|–—].*)?", label, re.IGNORECASE):
|
|
154
|
+
return None, "not_found", "provider_extracted_text"
|
|
155
|
+
if re.fullmatch(r"(?:just a moment|access denied|verify you are human|"
|
|
156
|
+
r"checking your browser|attention required|请完成安全验证)"
|
|
157
|
+
r"[.!…]*(?:\s*[-|–—:].*)?", label, re.IGNORECASE):
|
|
158
|
+
return None, "access_challenge", "provider_extracted_text"
|
|
159
|
+
if re.fullmatch(r"(?:log in|login|sign in|登录|登入|请先登录)"
|
|
160
|
+
r"(?:\s*[-|–—:].*| to .*)?", label, re.IGNORECASE):
|
|
161
|
+
return None, "login_required", "provider_extracted_text"
|
|
162
|
+
return body, "extracted", "provider_extracted_text"
|
|
163
|
+
excerpts = row.get("excerpts")
|
|
164
|
+
if isinstance(excerpts, list) and all(isinstance(item, str) for item in excerpts) and any(excerpts):
|
|
165
|
+
return "\n\n".join(excerpts), "extracted", "provider_excerpts"
|
|
166
|
+
return None, "no_extract", "provider_extracted_text"
|
|
167
|
+
|
|
168
|
+
@classmethod
|
|
169
|
+
def _select(cls, rows):
|
|
170
|
+
if not rows:
|
|
171
|
+
return None, None, "unrecognized_or_not_returned", "unknown"
|
|
172
|
+
candidates = [(row, *cls._page(row)) for row in rows]
|
|
173
|
+
failures = [candidate for candidate in candidates if candidate[2] == "provider_error"]
|
|
174
|
+
if failures:
|
|
175
|
+
return failures[0]
|
|
176
|
+
bodies = [candidate for candidate in candidates if candidate[1] is not None]
|
|
177
|
+
if len({(candidate[1], candidate[3]) for candidate in bodies}) > 1:
|
|
178
|
+
return rows[0], None, "conflicting_provider_results", "unknown"
|
|
179
|
+
return (bodies or candidates)[0]
|
|
180
|
+
|
|
181
|
+
@classmethod
|
|
182
|
+
def compact_fetch(cls, fetched):
|
|
183
|
+
"""Present one selected extract per URL, retaining every attempt's provenance.
|
|
184
|
+
|
|
185
|
+
Raw MCP responses may repeat a body in text, structured data and excerpts.
|
|
186
|
+
Keep those responses in their archives (or explicit raw output), without
|
|
187
|
+
repeating them in the model's ordinary reading response.
|
|
188
|
+
"""
|
|
189
|
+
if "result" not in fetched and "attempts" not in fetched:
|
|
190
|
+
return fetched
|
|
191
|
+
attempts = fetched.get("attempts", [fetched])
|
|
192
|
+
selected = {row["url"]: row for row in fetched.get("per_url", [])}
|
|
193
|
+
pages = {}
|
|
194
|
+
for attempt in attempts:
|
|
195
|
+
response = attempt.get("result")
|
|
196
|
+
if not isinstance(response, dict):
|
|
197
|
+
continue
|
|
198
|
+
by_url = {}
|
|
199
|
+
for row in cls._extract(response, attempt["urls"]):
|
|
200
|
+
try:
|
|
201
|
+
key = cls._key(row["url"])
|
|
202
|
+
except (ValueError, UnicodeError):
|
|
203
|
+
continue
|
|
204
|
+
by_url.setdefault(key, []).append(row)
|
|
205
|
+
archive = attempt.get("archive", {})
|
|
206
|
+
saved = {row["requested_url"]: row for row in archive.get("pages", [])}
|
|
207
|
+
for url in attempt["urls"]:
|
|
208
|
+
provider = attempt["provider"]
|
|
209
|
+
if selected.get(url, {}).get("provider", provider) != provider:
|
|
210
|
+
continue
|
|
211
|
+
row, body, extraction, kind = cls._select(by_url.get(cls._key(url)))
|
|
212
|
+
if response.get("isError"):
|
|
213
|
+
body, extraction = None, "provider_error"
|
|
214
|
+
metadata = row or {}
|
|
215
|
+
pages[url] = {"url": url, "provider": provider,
|
|
216
|
+
"returned_url": row.get("url") if row else None,
|
|
217
|
+
"title": row.get("title") if row else None,
|
|
218
|
+
"extraction_status": extraction, "content_kind": kind,
|
|
219
|
+
"completeness": "unknown", "retrieved_at": attempt.get("retrieved_at"),
|
|
220
|
+
"characters": len(body) if body is not None else 0,
|
|
221
|
+
"possibly_truncated": bool(metadata.get("truncated") or (body is not None and
|
|
222
|
+
attempt.get("character_limit_applied") and len(body) >= attempt["requested_max_characters"] - 2)),
|
|
223
|
+
"provider_published_at": metadata.get("publishedDate") or metadata.get("published_at") or metadata.get("publishedTime"),
|
|
224
|
+
"provider_crawled_at": metadata.get("crawled_at") or metadata.get("crawledAt"),
|
|
225
|
+
**saved.get(url, {}), "text": body,
|
|
226
|
+
"manifest_path": archive.get("manifest_path")}
|
|
227
|
+
result = {key: value for key, value in fetched.items() if key not in ("result", "attempts")}
|
|
228
|
+
if "attempts" in fetched:
|
|
229
|
+
result["attempts"] = [{key: value for key, value in attempt.items() if key != "result"}
|
|
230
|
+
for attempt in attempts]
|
|
231
|
+
result.update(output_format="compact", pages=[pages[url] for url in fetched.get("urls", []) if url in pages])
|
|
232
|
+
return result
|
|
233
|
+
|
|
234
|
+
@staticmethod
|
|
235
|
+
def _write_json(path, value):
|
|
236
|
+
path.write_bytes((json.dumps(value, ensure_ascii=False, allow_nan=False, indent=2) + "\n").encode("utf-8"))
|
|
237
|
+
|
|
238
|
+
def save(self, fetch):
|
|
239
|
+
response = fetch["result"]
|
|
240
|
+
rows = self._extract(response, fetch["urls"])
|
|
241
|
+
# URL association is explicit. Unknown shapes or redirects are retained raw.
|
|
242
|
+
by_url = {}
|
|
243
|
+
for row in rows:
|
|
244
|
+
try:
|
|
245
|
+
key = self._key(row["url"])
|
|
246
|
+
except ValueError:
|
|
247
|
+
continue
|
|
248
|
+
by_url.setdefault(key, []).append(row)
|
|
249
|
+
archived_at = datetime.now(timezone.utc)
|
|
250
|
+
capture_id = archived_at.strftime("%Y%m%dT%H%M%S%fZ-") + uuid.uuid4().hex[:12]
|
|
251
|
+
captures = self.directory / "sources"
|
|
252
|
+
Settings.external_path(str(captures), "Archive directory")
|
|
253
|
+
captures.mkdir(parents=True, exist_ok=True)
|
|
254
|
+
destination = captures / capture_id
|
|
255
|
+
manifest = {"schema_version": 1, "capture_id": capture_id,
|
|
256
|
+
"archived_at": archived_at.isoformat(),
|
|
257
|
+
"provider": fetch["provider"], "tool": fetch["tool"], "retrieved_at": fetch["retrieved_at"],
|
|
258
|
+
"requested_urls": fetch["urls"], "request_arguments": fetch["request_arguments"],
|
|
259
|
+
"requested_max_characters": fetch["requested_max_characters"],
|
|
260
|
+
"character_limit_applied": any(key in fetch["request_arguments"] for key in ("maxCharacters", "max_chars")),
|
|
261
|
+
"response_file": "response.json", "pages": [],
|
|
262
|
+
"origin_freshness": "unknown", "forced_live_fetch": False,
|
|
263
|
+
"note": "Saved provider response and extracts, not original HTML or verified complete pages. Retrieval time is not publication or update time."}
|
|
264
|
+
with tempfile.TemporaryDirectory(prefix=".capture-", dir=captures) as temporary:
|
|
265
|
+
stage = Path(temporary) / "capture"
|
|
266
|
+
stage.mkdir()
|
|
267
|
+
self._write_json(stage / "response.json", response)
|
|
268
|
+
manifest["response_sha256"] = hashlib.sha256((stage / "response.json").read_bytes()).hexdigest()
|
|
269
|
+
for url in dict.fromkeys(fetch["urls"]):
|
|
270
|
+
row, body, status, kind = self._select(by_url.get(self._key(url)))
|
|
271
|
+
if response.get("isError"):
|
|
272
|
+
body, status = None, "provider_error"
|
|
273
|
+
entry = {"requested_url": url, "returned_url": row.get("url") if row else None,
|
|
274
|
+
"title": row.get("title") if row else None, "extraction_status": status,
|
|
275
|
+
"content_kind": kind, "body_file": None, "page_body_archived": False,
|
|
276
|
+
"completeness": "unknown", "provider_crawled_at": None,
|
|
277
|
+
"fragment_scope_verified": False, "sha256": None}
|
|
278
|
+
if row:
|
|
279
|
+
entry["provider_crawled_at"] = row.get("crawled_at") or row.get("crawledAt")
|
|
280
|
+
entry["provider_published_at"] = row.get("publishedDate") or row.get("published_at") or row.get("publishedTime")
|
|
281
|
+
entry["provider_author"] = row.get("author")
|
|
282
|
+
entry["provider_status"] = row.get("status", row.get("httpStatus"))
|
|
283
|
+
if row.get("error") or row.get("errors"):
|
|
284
|
+
entry["provider_error"] = row.get("error") or row.get("errors")
|
|
285
|
+
if body is not None:
|
|
286
|
+
relative = "pages/" + hashlib.sha256(url.encode("utf-8")).hexdigest()[:24] + ".md"
|
|
287
|
+
target = stage / relative
|
|
288
|
+
target.parent.mkdir(exist_ok=True)
|
|
289
|
+
target.write_bytes(body.encode("utf-8"))
|
|
290
|
+
entry.update(body_file=relative, page_body_archived=True,
|
|
291
|
+
sha256=hashlib.sha256(body.encode("utf-8")).hexdigest(), characters=len(body))
|
|
292
|
+
limited = manifest["character_limit_applied"]
|
|
293
|
+
entry["possibly_truncated"] = bool((row and row.get("truncated")) or
|
|
294
|
+
(limited and len(body) >= fetch["requested_max_characters"] - 2))
|
|
295
|
+
manifest["pages"].append(entry)
|
|
296
|
+
self._write_json(stage / "manifest.json", manifest)
|
|
297
|
+
stage.rename(destination)
|
|
298
|
+
return {"status": "saved", "capture_id": capture_id, "directory": str(destination),
|
|
299
|
+
"manifest_path": str(destination / "manifest.json"),
|
|
300
|
+
"response_path": str(destination / "response.json"),
|
|
301
|
+
"pages": [{**row, "body_path": str(destination / row["body_file"]) if row["body_file"] else None}
|
|
302
|
+
for row in manifest["pages"]]}
|