# pi-web-access 新增自建搜索 Provider 方案

- 状态：待评审
- 日期：2026-08-12
- 涉及范围：**不属于 felo-growth-claw 仓库本身**，属于本机全局 npm 依赖 `pi-web-access`（Pi coding agent 的搜索扩展包），本文档记录方案供个人评审留档，非本项目代码变更

## 1. 背景

当前 Pi 使用的 `pi-web-access`（版本 0.21.0，全局安装在 `~/.pi/agent/npm/node_modules/pi-web-access`）内置了 20+ 个第三方搜索 provider（OpenAI、Exa、Brave、Tavily、Perplexity、Gemini 等），但没有对接公司自建的搜索服务。

公司内部已有一套基于 CloudsWay Google 的自建搜索服务实现（参考 `/Users/liulong/Downloads/cloudsway_google.py`），走的是：

- `GET` 请求 + `Authorization: Bearer <key>` 认证
- 请求参数：`q`（查询词）、`count`（返回条数，默认 10）
- 响应结构：`{ "webPages": { "value": [{ "name", "url", "snippet" }] } }`
- 已有的容错处理：429 限流单独识别、非 200 状态码报错、超时重试

## 1.1 接口验证结果（已用真实 key 测试）

方案阶段已用用户提供的 SmartSearch key 实测连通性，结论如下：

- **Endpoint 路径规律**：路径必须包含 `/search/` 前缀，少写直接 404。正确格式统一是：`https://searchapi.cloudsway.net/search/<workspace-token>/<product-suffix>`（`serp`/`image`/`videos`/`read`/`smart` 等）
- **认证方式确认**：`Authorization: Bearer <key>` 有效，与 `cloudsway_google.py` 假设一致
- **返回结构确认**：`{ "webPages": { "value": [{ "name", "url", "snippet", "datePublished", "score", ... }] } }`，字段名和 `cloudsway_google.py` 里解析的 `name`/`url`/`snippet` 完全一致，可以直接复用现有解析逻辑
- **测试用的两个 SmartSearch 端点/key 组合均可用**（key 已从本文档中移除，不落地为明文，仅记录在本机环境变量/密码管理工具中）：
  - `https://searchapi.cloudsway.net/search/<workspace-token>/smart`
  - `https://searchapi.cloudsway.net/search/<workspace-token>/serp`（Litesearch 文搜文）同一个 key 也能通，说明 key 的鉴权是按 workspace 而非按单个端点绑定的

## 2. 目标

1. 在 `pi-web-access` 里新增一个 provider，对接上述自建搜索服务
2. 让 Pi 默认使用这个新 provider 进行搜索（通过显式配置 `provider` 字段实现，不改动现有 auto 模式的自动回退优先级链）
3. 所有敏感/环境相关配置（endpoint、key、超时时间）**全部从环境变量读取**，不写入配置文件或硬编码
4. 支持 `domainFilter`（站点过滤），做成可配置项

## 3. 非目标（本次不做）

- 不改动 `pi-web-access` 官方的 auto 模式优先级链（`gemini-search.ts` 里 `search()` 函数的 if-chain），即新 provider 不会在用户没有显式指定 `provider` 时被自动优先选中
- 不发布到公共 npm registry，只做本地 fork + 本地路径引用（除非评审后决定要发布到公司私有 registry）
- 不修改 felo-growth-claw 仓库任何代码——这是纯粹针对本机 Pi 工具链的改动

---

## 4. 技术方案概述

不直接修改 `~/.pi/agent/npm/node_modules/pi-web-access` 里的源码（会被包升级/重装覆盖），改为 **fork 一份完整源码到自己的 npm 包**，在 fork 包里新增 provider 文件并修改两处注册逻辑，再让 Pi 的 `settings.json` 指向 fork 包。

fork 方式：先用本地绝对路径引用（Pi 官方支持直接在 `packages` 里写本地路径，不需要 `file:` 前缀，详见第 8 节），不急于发布到 npm registry；如后续需要多机器同步，再评估是否发布到公司私有 registry。

包本身是直接执行 `.ts` 源码（通过 jiti 加载，无编译步骤），改完文件重启 Pi 即可生效，不需要额外 build。

## 5. Fork 包结构

```
felo-pi-web-access/                # fork 出来的包目录（本地路径，暂不发布）
├── package.json                   # 复制官方 package.json，仅改 name 字段
├── index.ts                       # 官方源码原样复制，仅在注册处新增 2 个 case
├── gemini-search.ts               # 官方源码原样复制，新增 provider 枚举/dispatch/availability
├── felo-search.ts                 # 新建：自建搜索服务的 provider 实现（provider id: felo，显示名: Felo Search，已确认）
└── ...                            # 其余官方 .ts 文件原样复制，不修改
```

`package.json` 里 `pi.extensions` 字段保持指向 `./index.ts`，无需改动。

## 6. 新 Provider 实现规范（`felo-search.ts`）

参照包内 `tavily.ts` / `duckduckgo.ts` 的既有模式，保持风格一致，避免引入新的抽象。

### 6.1 导出函数

- `isFeloSearchAvailable(): boolean`
  用 `credential-source.ts` 的 `hasCredentialSource()` 判断 key 是否配置，来源仅看环境变量（见 6.3）
- `searchWithFeloSearch(query: string, options: SearchOptions & { domainFilter?: string[] }): Promise<SearchResponse>`
  核心搜索逻辑

### 6.2 请求逻辑

- Method：`GET`
- Endpoint：取自环境变量（见第 8 节配置项），不做默认值兜底，未配置直接判定 provider 不可用
- Query 参数：
  - `q`：查询词
  - `count`：结果条数，复用包内统一的 `normalizeCount()` 写法（默认 5，取 `options.numResults`，上限对齐其它 provider 的 20）
- Header：`Authorization: Bearer <key>`，key 通过 `resolveCredential()` 从环境变量解析（对应 cloudsway_google.py 里的 `CLOUDS_WAY_SEARCH_KEY`）
- 超时：**已确认做成独立环境变量** `CLOUDS_WAY_SEARCH_TIMEOUT_MS`（见第 8 节），未设置时兜底默认值 `30000`（30 秒，对齐包内 `tavily.ts`/`duckduckgo.ts` 等大多数 provider 的默认超时量级）。用 `AbortSignal.timeout()` 结合 `options.signal` 做 `AbortSignal.any()` 组合（沿用 `tavily.ts` 里 `requestSignal()` 的写法），解析环境变量时做数字校验，非法值（非数字、负数）也回退到默认值，不抛错

### 6.3 认证与配置来源

与包内其它 provider 不同的一点：其它 provider 的 key 是"配置文件优先，环境变量兜底"（`loadConfig().xxxApiKey ?? process.env.XXX_API_KEY`），本次按你的要求**只走环境变量**，不读取 `web-search.json` 里的同名字段，实现上更简单：

```
resolveCredential({
  provider: "FeloSearch",
  configuredValue: undefined,   // 不支持配置文件来源
  environmentValue: process.env.CLOUDS_WAY_SEARCH_KEY,
  signal,
})
```

Endpoint 同理，直接读 `process.env.CLOUDS_WAY_SEARCH_ENDPOINT`，不做 `loadConfig()` 查询。

### 6.4 响应解析

```
response.json().webPages.value  // 数组
  → 逐条映射为 { title: item.name, url: item.url, snippet: item.snippet }
  → 过滤 url 为空的条目
```

`answer` 字段：**已确认采用拼接 snippet 方案**，参照 `duckduckgo.ts` 的写法：`{snippet}\nSource: {title} ({url})`，多条结果之间用空行分隔。理由：CloudsWay 不返回 AI 摘要，如果留空，`web_search` 工具的调用方（模型）必须自己去解析 `results` 数组才能获得可读摘要，等于把格式化工作转嫁给调用方；拼接方式能让新 provider 和包内其它 provider（Tavily 的 AI 摘要、DuckDuckGo 的拼接展示）保持一致的调用体验，不需要模型端做特殊适配。

### 6.5 domainFilter 支持

复用 `duckduckgo.ts` 里已有的 `normalizeDomain()` / `normalizeDomainFilters()` / `matchesDomainFilters()` 三个纯函数（原样搬过来，不新增抽象），在结果映射阶段过滤掉不匹配的条目。

### 6.6 错误处理

**已实测更新**：`cloudsway_google.py` 原假设的"看 HTTP 状态码"不够——CloudsWay 返回的是结构化错误体，不是空 body。实测样例：

```
# 缺少 q 参数
HTTP 400
{"error":{"code":"1103","message":"[Parameter [q] cannot be empty]"}}

# key 无效/未授权
HTTP 401
{"error":{"code":"401","message":"You are not authorized to access this resource."}}
```

因此错误处理需要同时解析状态码和 `error.code`/`error.message`，而不是只拼状态码。更新后的处理方式：

| 场景 | 处理方式 |
|---|---|
| HTTP 429 | 识别为限流，不重试，抛出错误（消息里带上 `error.message`，供上层/调用方判断），不静默返回空结果——因为 429 是"请求失败"而非"搜索无结果"，语义不应混淆（对应第 11 节待确认事项 4，倾向于此方案） |
| HTTP 400/401/其它非 200 | 优先解析 body 里的 `error.code`/`error.message` 拼进错误信息（如 `CloudsWay search error 401: You are not authorized to access this resource. (code=401)`），解析失败则回退到"状态码 + 原始 body 前 300 字符"的兜底写法（参照 `tavily.ts` 现有模式）。用 `redactCredential()` 遮蔽 key 后再抛出 |
| 请求超时 / abort | 区分 `AbortError`（正常取消）和真实超时，参照 `tavily.ts` 的 `activityMonitor.logComplete(activityId, 0)` 分支处理 |
| JSON 解析失败 | 抛出明确错误，不静默返回空数组 |

不引入 `cloudsway_google.py` 里的重试循环（`max_retries` 那段）——包内其它 provider 均不做请求级重试，遇错直接抛出交给上层（auto 链路或调用方）处理，保持与包内既有设计一致，避免引入新的重试语义。

## 7. 涉及文件改动清单

以下均基于 `pi-web-access` 0.21.0 源码的实际行号（fork 后行号不变，因为是原样复制）。

### 7.1 `gemini-search.ts`

| 位置 | 改动 |
|---|---|
| Line 29 `RESOLVED_SEARCH_PROVIDERS` 数组 | 追加新 provider id：`felo`（已确认） |
| Line 16 附近 import 区 | 新增 `import { isFeloSearchAvailable, searchWithFeloSearch } from "./felo-search.ts";` |
| Line ~289 `searchWithResolvedProvider()` | 新增一个 `if (provider === "felo") return { ...(await searchWithFeloSearch(query, options)), provider };` |
| Line ~332 `isResolvedProviderAvailable()` | 新增 `if (provider === "felo") return isFeloSearchAvailable();` |
| Line ~356 `providerLabel()` | 新增显示名：`if (provider === "felo") return "Felo Search";`（已确认） |
| Line 93 `ALL_SEARCH_PROVIDERS` | **不加入**——按官方设计，付费/内部专属 provider 不参与 `provider: "all"` 批量搜索（同 DuckDuckGo/AnySearch/xAI/BrightData/SerpBase 的处理方式），避免用户用 `all` 时意外触发内部服务调用 |
| `search()` 函数内的 auto 回退链（约 480-650 行） | **不改**，符合第 3 节非目标 |

### 7.2 `index.ts`

| 位置 | 改动 |
|---|---|
| import 区 | 新增对 `felo-search.ts` 的 import |
| Line 372-403 `getProviderAvailability()` | 新增一行 `felo: isFeloSearchAvailable(),`，让 curator 交互式 UI 能识别到新 provider 的可用状态 |
| Line 429-447 `firstAvailableProvider()` | **不改**——这个函数只服务于 curator UI 在 auto 场景下的默认展示选择，改了会造成"UI 默认显示的 provider"和"文档里说明的显式 provider 配置"产生歧义。仅在 `getProviderAvailability()` 里登记可用性即可，UI 会在用户手动切换 provider 时显示这个选项 |

### 7.3 新建 `felo-search.ts`

完整实现见第 6 节规范，此处不重复。

## 8. 配置项清单

全部通过环境变量提供，不落地到 `web-search.json`：

| 环境变量 | 说明 | 对应 cloudsway_google.py |
|---|---|---|
| `CLOUDS_WAY_SEARCH_ENDPOINT` | 自建搜索服务的完整 URL，**必须包含 `/search/` 前缀**，格式为 `https://searchapi.cloudsway.net/search/<workspace-token>/<product-suffix>`（已实测确认，见第 1.1 节）。推荐 SmartSearch 产品线（`/smart` 后缀），最终以业务方确认的 token 为准 | `CLOUDS_WAY_SEARCH_ENDPOINT` |
| `CLOUDS_WAY_SEARCH_KEY` | Bearer token，只走环境变量，不落地到 `web-search.json` | `CLOUDS_WAY_SEARCH_KEY` |
| `CLOUDS_WAY_SEARCH_TIMEOUT_MS`（**已确认新增**，独立环境变量，非包内既有先例） | 请求超时时间，单位毫秒。未设置或值非法（非数字/负数）时兜底为 `30000`（30 秒） | 无对应（`SEARCH_TIMEOUT` 原值未知，本方案不沿用具体数值，改用包内其它 provider 常见量级作为默认值） |

`web-search.json` 需新增（当前该文件不存在）：

```json
{
  "provider": "felo"
}
```

这一步是让 Pi 默认使用新 provider 搜索的关键配置——`provider` 字段一旦设置为具体 provider 名（非 `auto`/`all`），`search()` 函数会直接调用该 provider，跳过整条 auto 回退链。

`~/.pi/agent/settings.json` 需修改：

```json
{
  "packages": ["/绝对路径/felo-pi-web-access"]
}
```

（原值是 `"npm:pi-web-access"`，改为指向本地 fork 包的绝对路径。已查阅 Pi 官方文档 `packages.md`——**本地路径不需要 `file:` 前缀**，直接写绝对路径或相对路径即可，pi 会按目录规则加载资源；这一点原方案里的"待确认事项 6"已明确，不再是开放问题）

## 9. 验证计划

1. ~~单元级验证~~ ——**已在方案阶段用 curl 完成**（见第 1.1 节）：正常查询、`count` 参数、错误响应结构（400/401）均已实测。剩余工作是把这些验证逻辑迁移进 `searchWithFeloSearch()` 的实现和自动化测试里，而不是重新发起裸连测试
2. **可用性判定验证**：故意不设置 `CLOUDS_WAY_SEARCH_KEY`，确认 `isFeloSearchAvailable()` 返回 `false`，且此时若 `web-search.json` 显式指定了 `provider: "felo"`，`search()` 应抛出清晰的"未配置凭证"错误（而不是静默 fallback 到别的 provider——因为一旦显式指定 provider，官方逻辑就是直连该 provider 不做 fallback）
3. **集成验证**：完成 `settings.json` 指向 fork 包 + `web-search.json` 设置 `provider: "felo"` 后，重启 Pi，实际发一次 `web_search` 工具调用，确认：
   - 返回结果来自自建搜索服务（非其它 provider）
   - `get_search_content` 等下游工具能正常配合使用（因为它们依赖 `web_search` 存下的 `responseId`，provider 切换不应影响这条链路）
4. ~~错误场景验证~~ ——**400（缺参数）、401（无效 key）已在第 1.1 节实测**，返回结构见第 6.6 节。剩余待验证：429 限流（需要真实触发限流，测试环境 qps=3，可以短时间内连续请求 4+ 次触发）、超时（可以临时指向一个慢响应或不可达地址模拟）。确认错误信息里不泄露 key（`redactCredential` 生效）
5. **回归验证**：确认原有其它 provider（比如 Exa/Tavily，如果本机配置过的话）在不设置 `provider` 字段（即 auto 模式）时行为不受影响——因为本方案不改 auto 链路，理论上不会有回归，但仍需过一次手工确认

## 10. 风险与注意事项

| 风险 | 说明 | 缓解方式 |
|---|---|---|
| fork 包脱离官方版本演进 | 官方 `pi-web-access` 后续更新（比如新增 provider、修 bug）不会自动同步到 fork 包 | 定期手动对比官方新版本 diff，或考虑改为"轻量 wrapper 包"而非"整体 fork"（见待确认事项） |
| `npm:pi-web-access` 官方包升级会覆盖本机改动 | 这也是本方案选择 fork 而不是直改 `node_modules` 的原因 | 已通过 fork 规避，但需要注意如果误操作重新执行了 `npm install npm:pi-web-access`，`settings.json` 里的 `packages` 配置要确认没被覆盖 |
| 环境变量缺失时的静默行为 | 如果 `CLOUDS_WAY_SEARCH_ENDPOINT`/`KEY` 未设置，`isFeloSearchAvailable()` 返回 `false`，但若同时 `web-search.json` 写死了 `provider: "felo"`，用户会看到搜索直接报错而非降级 | 这是预期行为（"默认使用"意味着不做静默降级），但需要在使用文档里说明清楚，避免使用者困惑 |
| Endpoint/Key/超时时间均只能环境变量注入，无法用 `web-search.json` 覆盖 | 和其它 provider（配置文件优先，环境变量兜底）行为不一致，可能造成后续维护者困惑 | 需要在 `felo-search.ts` 顶部写清楚注释说明为什么这里只支持环境变量（对齐第 6.3 节"认证与配置来源"的决策原因） |
| 本地 fork 包只存在于当前这台机器 | 换机器或团队其它人想用同样配置，需要手动同步 fork 包目录 | 短期可接受（当前诉求是个人使用），后续如需团队共享，再评估发布到私有 npm registry |
| 测试额度有限（10 美金），默认 qps=3 | 方案验证阶段已发起若干次真实请求消耗额度；如果把这个 key 直接用作"默认 provider"的日常 key，Pi 高频调用 `web_search` 可能很快打满 10 美金测试额度，或频繁触发 429（qps=3 很容易被并发工具调用打满） | 正式接入前需要和 CloudsWay 侧确认：测试额度是否够支撑日常使用，还是需要切换到正式计费的 key；同时 429 场景下的报错体验（见第 6.6 节）要能让用户明白是限流而非功能故障 |
| 本地路径包不会随 `pi update --extensions` 自动更新 | 根据 Pi 官方文档，本地路径包"添加到 settings 但不复制"，`pi update` 主要针对 npm/git 来源做版本协调，本地路径的内容变化需要自己手动改文件 | 符合预期——fork 包本来就是手动维护，不依赖 Pi 的自动更新机制 |
| Pi 安全提示：package 运行时有完整系统权限 | 官方文档 `packages.md` 明确写了"Pi packages run with full system access. Extensions execute arbitrary code"，fork 包本身继承了官方包的所有代码，新增部分也会有同等权限 | 新增代码遵循最小权限原则（只读环境变量、只发 HTTP 请求），不引入额外的系统调用；review 时重点检查新增文件不包含无关的文件系统/进程操作 |

## 11. 待确认事项汇总

以下事项中，1/2/4/6 已拍板确认，3/5/7 是建议方案（已按建议写入正文，若需调整请直接反馈）：

1. ~~新 provider 的 id/显示名~~ ——**已确认**：id `felo`，显示名 `Felo Search`
2. ~~超时时间来源~~ ——**已确认**：新增独立环境变量 `CLOUDS_WAY_SEARCH_TIMEOUT_MS`，未设置或值非法时默认 `30000`（30 秒）
3. **`answer` 字段策略**——**建议采纳拼接 snippet**（已写入第 6.4 节，理由见该节）。此项影响的是用户体感（模型看到的摘要文本质量），如果后续发现拼接效果不理想，可以随时调整为留空或改用别的拼接格式，不影响其它模块
4. ~~429 限流的具体行为~~ ——已在第 6.6 节更新为"抛出错误，不静默返回空结果"，理由见该节。已确认
5. **fork 包命名与本地路径**：包名（如 `felo-pi-web-access`）、本地目录位置（建议不放在 `felo-growth-claw` 仓库内，因为这和本项目无关，属于个人工具链配置，可以放在 `~/workspace/` 下的独立目录）——**待你在实施前拍板具体路径**
6. ~~`settings.json` 的 `packages` 字段是否支持本地路径引用~~ ——已在方案阶段查阅官方文档 `packages.md` 确认：本地路径直接写绝对/相对路径即可，不需要 `file:` 前缀，pi 按目录规则加载（若目录有 `pi` manifest 按 manifest 来，否则按 `extensions/`、`skills/` 等约定目录扫描）。**需要注意**：fork 包若沿用官方 `package.json` 的 `pi.extensions: ["./index.ts"]` 写法，属于"有 manifest"的情况，会按 manifest 精确加载 `index.ts`，这与约定目录扫描规则无关，不需要额外调整目录结构。
7. **最终选用哪个产品线端点**——**建议采纳 SmartSearch（`/smart`）**，已写入第 8 节配置项说明。理由：实测响应里 SmartSearch 带 `score`（语义相关性分数）字段，说明做了语义排序，比 Litesearch 文搜文（`/serp`，实测响应里未见排序信号）更贴合 `web_search` 工具"研究型查询"的使用场景。**这个推断只基于两次 curl 实测和产品命名，不是官方文档的定论**——如果你手上的接入文档（`Cloudsway LiteSearch 文搜文接入文档`、`小宿科技 海外智能搜索 文搜文接入文档`）里有明确的结果质量/计费差异说明，请以文档为准，并告知我更新此处。文搜图/文搜视频/Reader 这三个产品线本次方案不涉及（`web_search` 工具是文本搜索场景，`fetch_content` 工具已经覆盖了 Reader 的读取内容场景，无需重复接入）

## 12. 实施步骤（评审通过后执行顺序）

1. 复制 `~/.pi/agent/npm/node_modules/pi-web-access` 全部内容到新目录（本地路径，建议放在 `~/workspace/` 下独立于 felo-growth-claw 仓库的位置），改 `package.json` 的 `name` 字段
2. 按第 6 节规范新建 `felo-search.ts`（id/显示名/超时/answer 策略均已确认，端点默认 SmartSearch 为建议值，实施前如需调整第 11 节事项 7 请先反馈）
3. 按第 7 节清单改动 `gemini-search.ts`、`index.ts`
4. 配置环境变量（`CLOUDS_WAY_SEARCH_ENDPOINT`、`CLOUDS_WAY_SEARCH_KEY`、`CLOUDS_WAY_SEARCH_TIMEOUT_MS`，三者均已确认走环境变量，见第 8 节）
5. 新建 `~/.pi/web-search.json`，写入 `provider` 字段
6. 修改 `~/.pi/agent/settings.json`，`packages` 指向本地 fork 包绝对路径
7. 按第 9 节验证计划逐项过一遍
8. 确认无误后，视需要决定是否要在个人层面留一份变更记录（因为这是本机工具链配置，不属于 felo-growth-claw 仓库，不需要走 ADR/API 文档流程）

---

方案评审通过后，将按第 6-8 节的规范逐步实现（不在本次任务中直接写代码，等待评审反馈）。
