@te-river/opencode-team-mode 1.5.11 → 1.5.13
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/README.md +154 -57
- package/README.zh-CN.md +126 -47
- package/dist/agents.d.ts +5 -1
- package/dist/agents.d.ts.map +1 -1
- package/dist/agents.js +37 -11
- package/dist/agents.js.map +1 -1
- package/dist/approval-gate.d.ts +54 -16
- package/dist/approval-gate.d.ts.map +1 -1
- package/dist/approval-gate.js +163 -42
- package/dist/approval-gate.js.map +1 -1
- package/dist/host-hooks.d.ts +115 -0
- package/dist/host-hooks.d.ts.map +1 -0
- package/dist/host-hooks.js +270 -0
- package/dist/host-hooks.js.map +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +68 -4
- package/dist/index.js.map +1 -1
- package/dist/prompts/lead.d.ts +1 -1
- package/dist/prompts/lead.d.ts.map +1 -1
- package/dist/prompts/lead.js +88 -6
- package/dist/prompts/lead.js.map +1 -1
- package/dist/prompts/shared.d.ts +1 -1
- package/dist/prompts/shared.d.ts.map +1 -1
- package/dist/prompts/shared.js +203 -119
- package/dist/prompts/shared.js.map +1 -1
- package/dist/prompts/specialists.d.ts +2 -2
- package/dist/prompts/specialists.d.ts.map +1 -1
- package/dist/prompts/specialists.js +73 -23
- package/dist/prompts/specialists.js.map +1 -1
- package/dist/tm/args-schema.d.ts +31 -1
- package/dist/tm/args-schema.d.ts.map +1 -1
- package/dist/tm/args-schema.js +155 -39
- package/dist/tm/args-schema.js.map +1 -1
- package/dist/tm/bash-timeout.d.ts +72 -0
- package/dist/tm/bash-timeout.d.ts.map +1 -0
- package/dist/tm/bash-timeout.js +100 -0
- package/dist/tm/bash-timeout.js.map +1 -0
- package/dist/tm/browser.d.ts +250 -20
- package/dist/tm/browser.d.ts.map +1 -1
- package/dist/tm/browser.js +1200 -143
- package/dist/tm/browser.js.map +1 -1
- package/dist/tm/config.d.ts +129 -0
- package/dist/tm/config.d.ts.map +1 -1
- package/dist/tm/config.js +117 -1
- package/dist/tm/config.js.map +1 -1
- package/dist/tm/dispatch.d.ts +95 -0
- package/dist/tm/dispatch.d.ts.map +1 -0
- package/dist/tm/dispatch.js +369 -0
- package/dist/tm/dispatch.js.map +1 -0
- package/dist/tm/index.d.ts +32 -7
- package/dist/tm/index.d.ts.map +1 -1
- package/dist/tm/index.js +74 -5
- package/dist/tm/index.js.map +1 -1
- package/dist/tm/memory.d.ts +88 -10
- package/dist/tm/memory.d.ts.map +1 -1
- package/dist/tm/memory.js +881 -99
- package/dist/tm/memory.js.map +1 -1
- package/dist/tm/pipelines.d.ts +26 -3
- package/dist/tm/pipelines.d.ts.map +1 -1
- package/dist/tm/pipelines.js +167 -11
- package/dist/tm/pipelines.js.map +1 -1
- package/dist/tm/ptc/budgets.d.ts +22 -1
- package/dist/tm/ptc/budgets.d.ts.map +1 -1
- package/dist/tm/ptc/budgets.js +87 -8
- package/dist/tm/ptc/budgets.js.map +1 -1
- package/dist/tm/ptc/contract.d.ts +49 -9
- package/dist/tm/ptc/contract.d.ts.map +1 -1
- package/dist/tm/ptc/contract.js +41 -3
- package/dist/tm/ptc/contract.js.map +1 -1
- package/dist/tm/ptc/driver.d.ts +5 -2
- package/dist/tm/ptc/driver.d.ts.map +1 -1
- package/dist/tm/ptc/driver.js +47 -15
- package/dist/tm/ptc/driver.js.map +1 -1
- package/dist/tm/ptc/engines.d.ts +15 -4
- package/dist/tm/ptc/engines.d.ts.map +1 -1
- package/dist/tm/ptc/engines.js +281 -28
- package/dist/tm/ptc/engines.js.map +1 -1
- package/dist/tm/ptc/index.d.ts +4 -4
- package/dist/tm/ptc/index.d.ts.map +1 -1
- package/dist/tm/ptc/index.js +2 -2
- package/dist/tm/ptc/index.js.map +1 -1
- package/dist/tm/ptc/pscan.d.ts +20 -1
- package/dist/tm/ptc/pscan.d.ts.map +1 -1
- package/dist/tm/ptc/pscan.js +328 -14
- package/dist/tm/ptc/pscan.js.map +1 -1
- package/dist/tm/ptc/summary.d.ts.map +1 -1
- package/dist/tm/ptc/summary.js +23 -1
- package/dist/tm/ptc/summary.js.map +1 -1
- package/dist/tm/ptc/tool.d.ts +34 -3
- package/dist/tm/ptc/tool.d.ts.map +1 -1
- package/dist/tm/ptc/tool.js +117 -20
- package/dist/tm/ptc/tool.js.map +1 -1
- package/dist/tm/pty.d.ts +70 -0
- package/dist/tm/pty.d.ts.map +1 -0
- package/dist/tm/pty.js +242 -0
- package/dist/tm/pty.js.map +1 -0
- package/dist/tm/search.d.ts +128 -15
- package/dist/tm/search.d.ts.map +1 -1
- package/dist/tm/search.js +556 -47
- package/dist/tm/search.js.map +1 -1
- package/dist/tm/shell-bridge.d.ts +14 -1
- package/dist/tm/shell-bridge.d.ts.map +1 -1
- package/dist/tm/shell-bridge.js +57 -10
- package/dist/tm/shell-bridge.js.map +1 -1
- package/dist/tm/webfetch.d.ts +29 -7
- package/dist/tm/webfetch.d.ts.map +1 -1
- package/dist/tm/webfetch.js +117 -15
- package/dist/tm/webfetch.js.map +1 -1
- package/dist/types.d.ts +16 -0
- package/dist/types.d.ts.map +1 -1
- package/docs/installation.md +216 -17
- package/package.json +5 -2
package/dist/tm/search.js
CHANGED
|
@@ -3,57 +3,233 @@
|
|
|
3
3
|
*
|
|
4
4
|
* One call, one query, clean results: the engine URL is built here, fetched
|
|
5
5
|
* through the SAME pipeline as tm_webfetch (allowlist, manual redirects,
|
|
6
|
-
* 20 s timeout, 2 MB cap) and collapsed into a numbered title+URL
|
|
7
|
-
* before governance — the agent never sees raw SERP chrome.
|
|
8
|
-
*
|
|
9
|
-
* render with
|
|
6
|
+
* 20 s timeout, 2 MB cap) and collapsed into a numbered title+URL(+snippet)
|
|
7
|
+
* hit list before governance — the agent never sees raw SERP chrome. The
|
|
8
|
+
* structured engines (npm registry, GitHub repo search, StackExchange
|
|
9
|
+
* question search, HN Algolia, MediaWiki) render with stars/score/tags/
|
|
10
|
+
* snippets.
|
|
10
11
|
*
|
|
11
|
-
*
|
|
12
|
-
* bing
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
12
|
+
* T4 quality upgrade (live benchmark 2026-09-14, CN host): of the HTML
|
|
13
|
+
* SERPs only bing still returns real results — sogou/so/baidu serve
|
|
14
|
+
* anti-bot shells and bing-int (ensearch=1) is 100% dead — so they are
|
|
15
|
+
* REMOVED from the engine table (webfetch's domain whitelist keeps their
|
|
16
|
+
* hosts untouched; users can still fetch those URLs directly). In their
|
|
17
|
+
* place two no-key JSON engines: stackoverflow (api.stackexchange.com,
|
|
18
|
+
* 300 req/day/IP — quota_remaining is tracked and auto-routing degrades to
|
|
19
|
+
* bing once exhausted) and hn (hn.algolia.com).
|
|
20
|
+
*
|
|
21
|
+
* `engine: "auto"` (the new default, TM_SEARCH_DEFAULT_ENGINE) classifies
|
|
22
|
+
* the query, fans out to the matching engine set IN PARALLEL, normalizes
|
|
23
|
+
* every engine's hits to SearchHit, dedupes by host+path and re-ranks
|
|
24
|
+
* with weighted RRF (stackoverflow 0.4 / bing 0.4 / others 0.2) into a
|
|
25
|
+
* top-10 list tagged with its source engine(s). Explicit single-engine
|
|
26
|
+
* calls keep the classic per-engine render.
|
|
16
27
|
*
|
|
17
28
|
* tm_webfetch stays the "fetch a KNOWN URL" tool; tm_browser covers
|
|
18
29
|
* JS-rendered pages. All three share one domain allowlist and one
|
|
19
30
|
* pipelines instance.
|
|
20
31
|
*/
|
|
21
|
-
import { fetchWebText, extractSearchHits, renderSearchHits, hostAllowed } from "./webfetch.js";
|
|
32
|
+
import { fetchWebText, extractSearchHits, renderSearchHits, hostAllowed, seedWebfetchDomains, } from "./webfetch.js";
|
|
22
33
|
import { askFnOf, askUserForTarget } from "./perm-ask.js";
|
|
23
34
|
import { shorten } from "./config.js";
|
|
24
35
|
import { detectContentType } from "./preview.js";
|
|
25
36
|
import { tmError, toToolResult } from "./result.js";
|
|
26
|
-
|
|
37
|
+
/* ---------- query guards (T4) ---------- */
|
|
38
|
+
/**
|
|
39
|
+
* CJK phrase protection: a multi-word Chinese query gets its CORE phrase
|
|
40
|
+
* (the token holding the most CJK chars, ≥2) wrapped in double quotes —
|
|
41
|
+
* CN SERPs otherwise free-match tokens across pages and drown the result
|
|
42
|
+
* list in tangential hits. Single-word queries and already-quoted queries
|
|
43
|
+
* pass through untouched (never fabricate a phrase boundary, never fight
|
|
44
|
+
* the user's own quoting). Applied to bing via prepareQuery; JSON query
|
|
45
|
+
* APIs are skipped because they treat quotes literally.
|
|
46
|
+
*/
|
|
47
|
+
export function protectCjkPhrase(query) {
|
|
48
|
+
if (query.includes('"'))
|
|
49
|
+
return query;
|
|
50
|
+
if (!/[\u4e00-\u9fff\u3040-\u30ff\uac00-\ud7af]/.test(query))
|
|
51
|
+
return query;
|
|
52
|
+
const tokens = query.split(/\s+/).filter(Boolean);
|
|
53
|
+
if (tokens.length < 2)
|
|
54
|
+
return query;
|
|
55
|
+
let coreIdx = -1;
|
|
56
|
+
let coreLen = 0;
|
|
57
|
+
tokens.forEach((t, i) => {
|
|
58
|
+
const n = (t.match(/[\u4e00-\u9fff\u3040-\u30ff\uac00-\ud7af]/g) ?? []).length;
|
|
59
|
+
if (n > coreLen) {
|
|
60
|
+
coreLen = n;
|
|
61
|
+
coreIdx = i;
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
if (coreIdx < 0 || coreLen < 2)
|
|
65
|
+
return query;
|
|
66
|
+
const out = [...tokens];
|
|
67
|
+
out[coreIdx] = `"${out[coreIdx]}"`;
|
|
68
|
+
return out.join(" ");
|
|
69
|
+
}
|
|
70
|
+
/** GitHub search-API qualifiers whitelisted for pass-through (T4): the
|
|
71
|
+
* token stays verbatim in `q=` while the REST of the query remains free
|
|
72
|
+
* text. Other words (owner:, topic:, …) are NOT folded — they ride on as
|
|
73
|
+
* plain text, unvalidated, exactly like before. */
|
|
74
|
+
const GH_QUALIFIER_RE = /^(org|user|stars|language):(\S+)$/i;
|
|
75
|
+
export function foldGithubQualifiers(query) {
|
|
76
|
+
const tokens = query.split(/\s+/).filter(Boolean);
|
|
77
|
+
const qualifiers = [];
|
|
78
|
+
const free = [];
|
|
79
|
+
for (const t of tokens) {
|
|
80
|
+
if (GH_QUALIFIER_RE.test(t))
|
|
81
|
+
qualifiers.push(t);
|
|
82
|
+
else
|
|
83
|
+
free.push(t);
|
|
84
|
+
}
|
|
85
|
+
return { q: [...free, ...qualifiers].join(" "), qualifiers };
|
|
86
|
+
}
|
|
87
|
+
// ERR_/Exception/stack-trace markers OR a camelCase API identifier
|
|
88
|
+
// (≥2 leading lowercase chars so "iOS"/"iPhone" do NOT qualify) — the
|
|
89
|
+
// shapes where SO + GitHub carry the authoritative answers.
|
|
90
|
+
const ERROR_CODE_RE = /(ERR_[A-Z0-9_]+|\bException\b|Traceback|stack\s*trace|panic:|segfault|segmentation\s+fault|\bTypeError\b|\bReferenceError\b|\bSyntaxError\b|\bModuleNotFoundError\b|cannot\s+read\s+\w+|undefined\s+is\s+not|no\s+such\s+(?:module|file)|\b[a-z]{2,}[A-Z][a-zA-Z0-9]*\b)/;
|
|
91
|
+
// dev-ecosystem vocabulary: releases/tooling/library chatter — HN + GitHub
|
|
92
|
+
// + npm own it.
|
|
93
|
+
const DEV_ECOSYSTEM_RE = /\b(release[sd]?|changelog|roadmap|open-?source|library|framework|sdk|cli|vs\s?code|docker|kubernetes|deprecat\w*|benchmark|show\s+hn|ask\s+hn|launch|npm|pypi|crate|maven)\b/i;
|
|
94
|
+
const CJK_RE = /[\u4e00-\u9fff\u3040-\u30ff\uac00-\ud7af]/;
|
|
95
|
+
export function classifyQuery(query) {
|
|
96
|
+
if (ERROR_CODE_RE.test(query))
|
|
97
|
+
return "error-code";
|
|
98
|
+
if (DEV_ECOSYSTEM_RE.test(query))
|
|
99
|
+
return "dev-ecosystem";
|
|
100
|
+
if (CJK_RE.test(query))
|
|
101
|
+
return "cjk";
|
|
102
|
+
return "general";
|
|
103
|
+
}
|
|
104
|
+
const AUTO_ROUTES = {
|
|
105
|
+
"error-code": ["stackoverflow", "github", "bing"],
|
|
106
|
+
"dev-ecosystem": ["hn", "github", "npm"],
|
|
107
|
+
// cjk / general used to route to `bing` ALONE. A one-engine route cannot
|
|
108
|
+
// produce a consensus hit (dedupe never fires), cannot be re-ranked
|
|
109
|
+
// (nothing to fuse), and hands the agent a raw bing mirror — the exact
|
|
110
|
+
// "全是垃圾你还不能删" complaint of 2026-09-18. Every route now carries
|
|
111
|
+
// at least two independent legs so agreement between engines is possible.
|
|
112
|
+
cjk: ["bing", "moegirl", "stackoverflow", "hn"],
|
|
113
|
+
general: ["bing", "stackoverflow", "hn", "github"],
|
|
114
|
+
};
|
|
115
|
+
export const AUTO_ENGINE_KEY = "auto";
|
|
116
|
+
/** RRF fusion constants. bing LOST its 0.4 trust weight (2026-09-18): with
|
|
117
|
+
* 0.4-vs-0.2 weights and a k=60 damping range of only 61..70, bing's WORST
|
|
118
|
+
* hit (0.4/70 = 0.0057) outranked every other engine's BEST (0.2/61 =
|
|
119
|
+
* 0.0033) — a weight ratio above the whole rank-damping range means the
|
|
120
|
+
* under-weighted engine can never place a single hit. bing is still in the
|
|
121
|
+
* table (it is the only live CN HTML SERP); it now has to EARN rank by
|
|
122
|
+
* overlapping the query. Override per engine via TM_SEARCH_WEIGHTS. */
|
|
123
|
+
export const RRF_K = 60;
|
|
124
|
+
export const RRF_WEIGHTS = { stackoverflow: 0.4 };
|
|
125
|
+
export const RRF_DEFAULT_WEIGHT = 0.2;
|
|
126
|
+
/** One engine's fusion weight: a TM_SEARCH_WEIGHTS override first, then the
|
|
127
|
+
* built-in table, then the default. */
|
|
128
|
+
export function weightFor(engine, overrides = {}) {
|
|
129
|
+
const custom = overrides[engine];
|
|
130
|
+
if (typeof custom === "number" && Number.isFinite(custom) && custom > 0)
|
|
131
|
+
return custom;
|
|
132
|
+
return RRF_WEIGHTS[engine] ?? RRF_DEFAULT_WEIGHT;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Tokenize a query for the relevance floor. CJK has no word boundaries and
|
|
136
|
+
* the plugin ships no segmenter, so CJK runs index as every BIGRAM: "关键
|
|
137
|
+
* 词" contributes 关键/键词/词… which a Chinese title contains verbatim.
|
|
138
|
+
* Substring matching against that is a cheap, deterministic overlap proxy —
|
|
139
|
+
* no model, no network, no fabricated relevance.
|
|
140
|
+
*/
|
|
141
|
+
export function queryTerms(query) {
|
|
142
|
+
const text = String(query ?? "").toLowerCase();
|
|
143
|
+
if (!text.trim())
|
|
144
|
+
return [];
|
|
145
|
+
const parts = text.split(/[^a-z0-9._+\-\u4e00-\u9fff\u3040-\u30ff\uac00-\ud7af]+/).filter(Boolean);
|
|
146
|
+
const out = new Set();
|
|
147
|
+
const cjk = /[\u4e00-\u9fff\u3040-\u30ff\uac00-\ud7af]/;
|
|
148
|
+
for (const p of parts) {
|
|
149
|
+
if (cjk.test(p)) {
|
|
150
|
+
if (p.length === 1)
|
|
151
|
+
out.add(p);
|
|
152
|
+
for (let i = 0; i + 1 < p.length; i++)
|
|
153
|
+
out.add(p.slice(i, i + 2));
|
|
154
|
+
}
|
|
155
|
+
else if (p.length >= 2) {
|
|
156
|
+
out.add(p);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return [...out];
|
|
160
|
+
}
|
|
161
|
+
/** 0..1 overlap between the query and one hit (title + snippet + host). */
|
|
162
|
+
export function relevanceOf(terms, hit) {
|
|
163
|
+
if (!terms.length)
|
|
164
|
+
return 1;
|
|
165
|
+
const hay = `${hit.title ?? ""} ${hit.snippet ?? ""} ${hit.url ?? ""}`.toLowerCase();
|
|
166
|
+
let n = 0;
|
|
167
|
+
for (const t of terms)
|
|
168
|
+
if (hay.includes(t))
|
|
169
|
+
n++;
|
|
170
|
+
return Math.min(1, n / terms.length);
|
|
171
|
+
}
|
|
172
|
+
/** Mutable SO quota state (per process). api.stackexchange answers every
|
|
173
|
+
* payload with quota_remaining/quota_max; hitting 0 flips `exhausted`
|
|
174
|
+
* and engine:"auto" swaps the SO leg for bing until the process restarts
|
|
175
|
+
* (the daily quota rolls over on the server, not here). */
|
|
176
|
+
export const SO_QUOTA = {
|
|
177
|
+
max: 300,
|
|
178
|
+
remaining: null,
|
|
179
|
+
exhausted: false,
|
|
180
|
+
};
|
|
181
|
+
export function resetSoQuota() {
|
|
182
|
+
SO_QUOTA.max = 300;
|
|
183
|
+
SO_QUOTA.remaining = null;
|
|
184
|
+
SO_QUOTA.exhausted = false;
|
|
185
|
+
}
|
|
186
|
+
/** Resolve the auto route set for a query (exported for tests + PTC).
|
|
187
|
+
* `disabled` (TM_SEARCH_DISABLED_ENGINES) removes legs; if that empties a
|
|
188
|
+
* route the caller still gets bing rather than an error — a search tool
|
|
189
|
+
* that refuses to answer because of its own config is worse than a
|
|
190
|
+
* single-engine answer. */
|
|
191
|
+
export function resolveAutoRoutes(query, disabled = []) {
|
|
192
|
+
const queryClass = classifyQuery(query);
|
|
193
|
+
const notes = [];
|
|
194
|
+
let routes = [...AUTO_ROUTES[queryClass]].filter((r) => !disabled.includes(r));
|
|
195
|
+
if (SO_QUOTA.exhausted && routes.includes("stackoverflow")) {
|
|
196
|
+
routes = routes.filter((r) => r !== "stackoverflow");
|
|
197
|
+
if (!routes.includes("bing"))
|
|
198
|
+
routes.push("bing");
|
|
199
|
+
notes.push("stackoverflow 配额耗尽(quota_remaining=0),已降级为 bing");
|
|
200
|
+
}
|
|
201
|
+
if (!routes.length) {
|
|
202
|
+
routes = ["bing"];
|
|
203
|
+
notes.push("该路由的引擎都被 TM_SEARCH_DISABLED_ENGINES 关闭,已回退 bing");
|
|
204
|
+
}
|
|
205
|
+
else if (routes.length === 1) {
|
|
206
|
+
notes.push(`单引擎路由(${routes[0]}):没有跨引擎共识可用`);
|
|
207
|
+
}
|
|
208
|
+
return { routes, queryClass, notes };
|
|
209
|
+
}
|
|
210
|
+
/* ---------- engine table ---------- */
|
|
211
|
+
/** The engine table — keys are the tm_search `engine` arg values. The
|
|
212
|
+
* dead CN SERPs (sogou/so/baidu/bing-int) are GONE, not even manually
|
|
213
|
+
* selectable (2026-09-14 benchmark: anti-bot shells / 100% empty). */
|
|
27
214
|
export const SEARCH_ENGINES = {
|
|
28
215
|
bing: {
|
|
29
216
|
name: "bing",
|
|
30
217
|
kind: "html",
|
|
31
218
|
buildUrl: (q) => `https://cn.bing.com/search?q=${q}`,
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
"bing-int": {
|
|
35
|
-
name: "bing-int",
|
|
36
|
-
kind: "html",
|
|
37
|
-
buildUrl: (q) => `https://cn.bing.com/search?q=${q}&ensearch=1&setmkt=en-US`,
|
|
38
|
-
alt: "bing / sogou / so",
|
|
39
|
-
},
|
|
40
|
-
sogou: {
|
|
41
|
-
name: "sogou",
|
|
42
|
-
kind: "html",
|
|
43
|
-
buildUrl: (q) => `https://www.sogou.com/web?query=${q}`,
|
|
44
|
-
alt: "bing / so / baidu",
|
|
219
|
+
prepareQuery: protectCjkPhrase,
|
|
220
|
+
alt: "stackoverflow / github / hn(编程类)或缩小查询词",
|
|
45
221
|
},
|
|
46
|
-
|
|
47
|
-
name: "
|
|
48
|
-
kind: "
|
|
49
|
-
buildUrl: (q) => `https://
|
|
50
|
-
alt: "bing /
|
|
222
|
+
stackoverflow: {
|
|
223
|
+
name: "stackoverflow",
|
|
224
|
+
kind: "so-json",
|
|
225
|
+
buildUrl: (q) => `https://api.stackexchange.com/2.3/search/advanced?order=desc&sort=relevance&q=${q}&site=stackoverflow&pagesize=10`,
|
|
226
|
+
alt: "bing / github / hn(SO 免 key 配额 300/day/IP)",
|
|
51
227
|
},
|
|
52
|
-
|
|
53
|
-
name: "
|
|
54
|
-
kind: "
|
|
55
|
-
buildUrl: (q) => `https://
|
|
56
|
-
alt: "bing /
|
|
228
|
+
hn: {
|
|
229
|
+
name: "hn",
|
|
230
|
+
kind: "hn-json",
|
|
231
|
+
buildUrl: (q) => `https://hn.algolia.com/api/v1/search?query=${q}&tags=story&hitsPerPage=10`,
|
|
232
|
+
alt: "bing / github / stackoverflow",
|
|
57
233
|
},
|
|
58
234
|
bilibili: {
|
|
59
235
|
name: "bilibili",
|
|
@@ -77,11 +253,78 @@ export const SEARCH_ENGINES = {
|
|
|
77
253
|
name: "github",
|
|
78
254
|
kind: "github-json",
|
|
79
255
|
buildUrl: (q) => `https://api.github.com/search/repositories?q=${q}&per_page=10`,
|
|
80
|
-
|
|
256
|
+
prepareQuery: (query) => foldGithubQualifiers(query).q,
|
|
257
|
+
alt: "10-15 分钟后重试(匿名限额 10 次/分钟),或换 bing",
|
|
81
258
|
},
|
|
82
259
|
};
|
|
83
260
|
export const SEARCH_ENGINE_NAMES = Object.keys(SEARCH_ENGINES);
|
|
84
261
|
const SEARCH_MAX_HITS = 10;
|
|
262
|
+
function parseSoItems(body) {
|
|
263
|
+
let data;
|
|
264
|
+
try {
|
|
265
|
+
data = JSON.parse(body);
|
|
266
|
+
}
|
|
267
|
+
catch {
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
270
|
+
if (typeof data?.quota_remaining === "number") {
|
|
271
|
+
SO_QUOTA.remaining = data.quota_remaining;
|
|
272
|
+
SO_QUOTA.exhausted = data.quota_remaining === 0;
|
|
273
|
+
}
|
|
274
|
+
if (typeof data?.quota_max === "number")
|
|
275
|
+
SO_QUOTA.max = data.quota_max;
|
|
276
|
+
const raw = Array.isArray(data?.items) ? data.items : null;
|
|
277
|
+
if (!raw)
|
|
278
|
+
return null;
|
|
279
|
+
const items = [];
|
|
280
|
+
for (const it of raw) {
|
|
281
|
+
const o = it;
|
|
282
|
+
if (!o || typeof o.title !== "string")
|
|
283
|
+
continue;
|
|
284
|
+
items.push({
|
|
285
|
+
title: o.title,
|
|
286
|
+
link: String(o.link ?? ""),
|
|
287
|
+
score: Number(o.score ?? 0),
|
|
288
|
+
answer_count: Number(o.answer_count ?? 0),
|
|
289
|
+
is_answered: Boolean(o.is_answered),
|
|
290
|
+
tags: Array.isArray(o.tags) ? o.tags.map(String) : [],
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
return { items, total: Number(data.total ?? items.length) };
|
|
294
|
+
}
|
|
295
|
+
function soCompositeSnippet(it) {
|
|
296
|
+
const parts = [`score ${it.score} · ${it.answer_count} 回答${it.is_answered ? " · 已采纳" : ""}`];
|
|
297
|
+
if (it.tags.length)
|
|
298
|
+
parts.push(`tags: ${it.tags.slice(0, 3).join(", ")}`);
|
|
299
|
+
return parts.join(" · ");
|
|
300
|
+
}
|
|
301
|
+
function parseHnItems(body) {
|
|
302
|
+
let data;
|
|
303
|
+
try {
|
|
304
|
+
data = JSON.parse(body);
|
|
305
|
+
}
|
|
306
|
+
catch {
|
|
307
|
+
return null;
|
|
308
|
+
}
|
|
309
|
+
const raw = Array.isArray(data?.hits) ? data.hits : null;
|
|
310
|
+
if (!raw)
|
|
311
|
+
return null;
|
|
312
|
+
const items = [];
|
|
313
|
+
for (const it of raw) {
|
|
314
|
+
const o = it;
|
|
315
|
+
if (!o || typeof o.title !== "string")
|
|
316
|
+
continue;
|
|
317
|
+
items.push({
|
|
318
|
+
title: o.title,
|
|
319
|
+
url: String(o.url ?? "") || `https://news.ycombinator.com/item?id=${String(o.objectID ?? "")}`,
|
|
320
|
+
points: Number(o.points ?? 0),
|
|
321
|
+
num_comments: Number(o.num_comments ?? 0),
|
|
322
|
+
created_at: String(o.created_at ?? "").slice(0, 10),
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
return { items, total: Number(data.nbHits ?? items.length) };
|
|
326
|
+
}
|
|
327
|
+
/* ---------- structured renders (explicit single-engine path) ---------- */
|
|
85
328
|
/** npm registry search JSON → numbered package list (name@version — desc). */
|
|
86
329
|
export function renderNpmResults(query, body) {
|
|
87
330
|
let data;
|
|
@@ -153,29 +396,236 @@ export function renderWikiResults(query, body) {
|
|
|
153
396
|
lines.push("(词条正文: tm_webfetch 抓上面的 URL。)");
|
|
154
397
|
return lines.join("\n");
|
|
155
398
|
}
|
|
156
|
-
/**
|
|
399
|
+
/** StackExchange search JSON → numbered question list; the per-hit snippet
|
|
400
|
+
* is SYNTHESIZED from the engine's own metadata (score/answers/tags —
|
|
401
|
+
* search/advanced carries no body text). The quota footer reflects the
|
|
402
|
+
* tracked quota_remaining. */
|
|
403
|
+
export function renderSoResults(query, body) {
|
|
404
|
+
const parsed = parseSoItems(body);
|
|
405
|
+
if (!parsed || parsed.items.length === 0)
|
|
406
|
+
return null;
|
|
407
|
+
const lines = [`[search] stackoverflow × "${query}" → ${parsed.total} 个问题:`];
|
|
408
|
+
for (const [i, it] of parsed.items.slice(0, SEARCH_MAX_HITS).entries()) {
|
|
409
|
+
lines.push(`${i + 1}. ${it.title} — ${soCompositeSnippet(it)}`);
|
|
410
|
+
lines.push(` ${it.link}`);
|
|
411
|
+
}
|
|
412
|
+
const q = SO_QUOTA.remaining;
|
|
413
|
+
lines.push(`(SO 免 key 配额: ${q === null ? "未知" : `${q}/${SO_QUOTA.max}`}${q === null ? "" : " 今日/IP"}${SO_QUOTA.exhausted ? " —— 已耗尽,engine:\"auto\" 已降级 bing" : ""}。)`);
|
|
414
|
+
return lines.join("\n");
|
|
415
|
+
}
|
|
416
|
+
/** HN Algolia story search JSON → numbered post list; the per-hit snippet
|
|
417
|
+
* is the engine's own points/comments/date metadata. */
|
|
418
|
+
export function renderHnResults(query, body) {
|
|
419
|
+
const parsed = parseHnItems(body);
|
|
420
|
+
if (!parsed || parsed.items.length === 0)
|
|
421
|
+
return null;
|
|
422
|
+
const lines = [`[search] hn × "${query}" → ${parsed.total} 条帖子:`];
|
|
423
|
+
for (const [i, it] of parsed.items.slice(0, SEARCH_MAX_HITS).entries()) {
|
|
424
|
+
const meta = [`${it.points} 分`, `${it.num_comments} 评论`, it.created_at].filter(Boolean).join(" · ");
|
|
425
|
+
lines.push(`${i + 1}. ${it.title} — ${meta}`);
|
|
426
|
+
lines.push(` ${it.url}`);
|
|
427
|
+
}
|
|
428
|
+
lines.push("(读正文: tm_webfetch 抓上面的 URL;评论区无正文的用 tm_browser。)");
|
|
429
|
+
return lines.join("\n");
|
|
430
|
+
}
|
|
431
|
+
/** Collapse an HTML SERP into the compact hit list; [] when the engine
|
|
157
432
|
* gave nothing extractable (anti-bot shell / markup change). */
|
|
158
|
-
export function extractHtmlResults(html) {
|
|
159
|
-
return extractSearchHits(html,
|
|
433
|
+
export function extractHtmlResults(html, max = SEARCH_MAX_HITS) {
|
|
434
|
+
return extractSearchHits(html, max);
|
|
435
|
+
}
|
|
436
|
+
/* ---------- auto fusion (normalized hits → dedupe → weighted RRF) ---------- */
|
|
437
|
+
/** Normalize any engine body to SearchHit[] (the fusion currency). JSON
|
|
438
|
+
* engines keep their composite/real snippets; SO/HN metadata composes the
|
|
439
|
+
* snippet line (engine data only — nothing fabricated). */
|
|
440
|
+
function bodyToHits(engine, body, max = SEARCH_MAX_HITS) {
|
|
441
|
+
switch (engine.kind) {
|
|
442
|
+
case "html":
|
|
443
|
+
return extractSearchHits(body, max);
|
|
444
|
+
case "so-json": {
|
|
445
|
+
const parsed = parseSoItems(body);
|
|
446
|
+
return (parsed?.items ?? []).slice(0, max).map((it) => ({
|
|
447
|
+
title: it.title,
|
|
448
|
+
url: it.link,
|
|
449
|
+
snippet: soCompositeSnippet(it),
|
|
450
|
+
}));
|
|
451
|
+
}
|
|
452
|
+
case "hn-json": {
|
|
453
|
+
const parsed = parseHnItems(body);
|
|
454
|
+
return (parsed?.items ?? []).slice(0, max).map((it) => ({
|
|
455
|
+
title: it.title,
|
|
456
|
+
url: it.url,
|
|
457
|
+
snippet: `${it.points} points · ${it.num_comments} comments${it.created_at ? ` · ${it.created_at}` : ""}`,
|
|
458
|
+
}));
|
|
459
|
+
}
|
|
460
|
+
case "npm-json": {
|
|
461
|
+
try {
|
|
462
|
+
const data = JSON.parse(body);
|
|
463
|
+
return (Array.isArray(data?.objects) ? data.objects : []).slice(0, max).map((o) => {
|
|
464
|
+
const p = o?.package ?? {};
|
|
465
|
+
const name = String(p.name ?? "?");
|
|
466
|
+
return {
|
|
467
|
+
title: `${name}@${String(p.version ?? "?")}`,
|
|
468
|
+
url: `https://registry.npmjs.org/${name}/latest`,
|
|
469
|
+
snippet: String(p.description ?? "").slice(0, 140) || undefined,
|
|
470
|
+
};
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
catch {
|
|
474
|
+
return [];
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
case "github-json": {
|
|
478
|
+
try {
|
|
479
|
+
const data = JSON.parse(body);
|
|
480
|
+
return (Array.isArray(data?.items) ? data.items : []).slice(0, max).map((r) => ({
|
|
481
|
+
title: `${String(r?.full_name ?? "?")} ★${Number(r?.stargazers_count ?? 0)}`,
|
|
482
|
+
url: String(r?.html_url ?? ""),
|
|
483
|
+
snippet: String(r?.description ?? "").slice(0, 140) || undefined,
|
|
484
|
+
}));
|
|
485
|
+
}
|
|
486
|
+
catch {
|
|
487
|
+
return [];
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
case "wiki-json": {
|
|
491
|
+
try {
|
|
492
|
+
const data = JSON.parse(body);
|
|
493
|
+
return (Array.isArray(data?.query?.search) ? data.query.search : []).slice(0, max).map((r) => ({
|
|
494
|
+
title: String(r?.title ?? "?"),
|
|
495
|
+
url: `https://mobile.moegirl.org.cn/${encodeURIComponent(String(r?.title ?? ""))}`,
|
|
496
|
+
snippet: String(r?.snippet ?? "").replace(/<[^>]+>/g, "").slice(0, 120) || undefined,
|
|
497
|
+
}));
|
|
498
|
+
}
|
|
499
|
+
catch {
|
|
500
|
+
return [];
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
/** host+path identity across engines (query/fragment/trailing-slash/www
|
|
506
|
+
* ignored) — the fusion dedupe key. */
|
|
507
|
+
export function dedupeKey(url) {
|
|
508
|
+
try {
|
|
509
|
+
const u = new URL(url);
|
|
510
|
+
const host = u.hostname.toLowerCase().replace(/^www\./, "");
|
|
511
|
+
const p = u.pathname.replace(/\/+$/, "");
|
|
512
|
+
return host + p;
|
|
513
|
+
}
|
|
514
|
+
catch {
|
|
515
|
+
return url.trim();
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
/** Built-in zero-overlap multiplier (TmConfig.searchRelevanceFloor overrides). */
|
|
519
|
+
export const RELEVANCE_FLOOR_DEFAULT = 0.35;
|
|
520
|
+
/**
|
|
521
|
+
* Reciprocal Rank Fusion over the routed engines' hit lists: normalize →
|
|
522
|
+
* host+path dedupe → score = Σ (weight × relevance × fetchability)/(k+rank)
|
|
523
|
+
* → top-N, every hit tagged with its contributing engine(s).
|
|
524
|
+
*
|
|
525
|
+
* The relevance term is the fix for "auto returns a bing mirror": pure
|
|
526
|
+
* rank-position fusion lets a trusted engine's junk outrank another
|
|
527
|
+
* engine's best hit, so a hit that shares no token with the query now keeps
|
|
528
|
+
* only a floor fraction of its engine's weight. Nothing is deleted — bing
|
|
529
|
+
* still carries useful data, and an over-filtered list is unfixable from
|
|
530
|
+
* the agent side.
|
|
531
|
+
*/
|
|
532
|
+
export function fuseRrf(groups, opts = {}) {
|
|
533
|
+
const max = opts.max ?? SEARCH_MAX_HITS;
|
|
534
|
+
const floor = typeof opts.floor === "number" ? Math.min(1, Math.max(0, opts.floor)) : RELEVANCE_FLOOR_DEFAULT;
|
|
535
|
+
const terms = opts.query ? queryTerms(opts.query) : [];
|
|
536
|
+
const best = new Map();
|
|
537
|
+
for (const g of groups) {
|
|
538
|
+
const weight = weightFor(g.engine, opts.weights);
|
|
539
|
+
for (const [i, h] of g.hits.entries()) {
|
|
540
|
+
if (!h.url)
|
|
541
|
+
continue;
|
|
542
|
+
const key = dedupeKey(h.url);
|
|
543
|
+
const rel = terms.length ? relevanceOf(terms, h) : 1;
|
|
544
|
+
const canFetch = opts.fetchable ? opts.fetchable(h.url) : true;
|
|
545
|
+
const mult = floor + (1 - floor) * Math.min(1, Math.max(0, rel));
|
|
546
|
+
const add = (weight * mult * (canFetch ? 1 : 0.9)) / (RRF_K + i + 1);
|
|
547
|
+
const prev = best.get(key);
|
|
548
|
+
if (prev) {
|
|
549
|
+
prev.score += add;
|
|
550
|
+
prev.rel = Math.max(prev.rel, rel);
|
|
551
|
+
if (!prev.hit.snippet && h.snippet)
|
|
552
|
+
prev.hit.snippet = h.snippet;
|
|
553
|
+
if (!String(prev.hit.source ?? "").split("+").includes(g.engine)) {
|
|
554
|
+
prev.hit.source = [prev.hit.source, g.engine].filter(Boolean).join("+");
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
else {
|
|
558
|
+
best.set(key, {
|
|
559
|
+
hit: { ...h, title: h.title, url: h.url, source: g.engine, ...(canFetch ? {} : { fetchable: false }) },
|
|
560
|
+
score: add,
|
|
561
|
+
rel,
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
// Ties break on agreement, not on route order: higher relevance first,
|
|
567
|
+
// then the hit several engines agreed on.
|
|
568
|
+
const sources = (h) => String(h.source ?? "").split("+").filter(Boolean).length;
|
|
569
|
+
return [...best.values()]
|
|
570
|
+
.sort((a, b) => b.score - a.score || b.rel - a.rel || sources(b.hit) - sources(a.hit))
|
|
571
|
+
.slice(0, max)
|
|
572
|
+
.map((row, i) => ({ ...row.hit, rank: i + 1 }));
|
|
160
573
|
}
|
|
574
|
+
function renderFusedHits(query, routes, hits, notes) {
|
|
575
|
+
const label = routes.length === 1 ? `auto→${routes[0]}` : "auto";
|
|
576
|
+
const lines = [
|
|
577
|
+
`[search] ${label} × "${query}" → ${hits.length} 条结果(路由 ${routes.join("+")}${routes.length > 1 ? ",RRF 融合" : ""}):`,
|
|
578
|
+
];
|
|
579
|
+
hits.forEach((h, i) => {
|
|
580
|
+
lines.push(`${i + 1}. ${h.source ? `[${h.source}] ` : ""}${h.title}${h.fetchable === false ? "(域名不在白名单,需批准)" : ""}`);
|
|
581
|
+
lines.push(` ${h.url}`);
|
|
582
|
+
if (h.snippet)
|
|
583
|
+
lines.push(` ${h.snippet}`);
|
|
584
|
+
});
|
|
585
|
+
for (const n of notes)
|
|
586
|
+
lines.push(`(auto: ${n})`);
|
|
587
|
+
lines.push("(读正文: tm_webfetch 结果 URL;标注需批准的会先弹确认窗。engine 可显式指定单引擎。)");
|
|
588
|
+
return lines.join("\n");
|
|
589
|
+
}
|
|
590
|
+
/* ---------- tool ---------- */
|
|
161
591
|
const SEARCH_DESCRIPTION = `Search the web through a governed multi-engine pipeline — the FIRST choice for open-ended web lookups; tm_webfetch is for a KNOWN URL, tm_browser for JS-rendered pages.
|
|
162
592
|
|
|
163
|
-
-
|
|
164
|
-
-
|
|
165
|
-
-
|
|
593
|
+
- engine:"auto" (default): classifies the query, fans the matching legs out IN PARALLEL, dedupes by host+path and fuses them with weighted RRF into a top-N list tagged with each hit's source engine(s). Routing: error/exception/camelCase-API tokens → stackoverflow+github+bing · dev-ecosystem (release/framework/npm/open-source…) → hn+github+npm · Chinese → bing+moegirl+stackoverflow+hn · other → bing+stackoverflow+hn+github. Every route has ≥2 legs, because a hit two engines agree on is worth more than one engine's opinion of itself.
|
|
594
|
+
- Ranking is NOT raw engine trust: each hit's contribution is scaled by how many query tokens actually appear in its title/snippet/host (floor 0.35 via TM_SEARCH_RELEVANCE_FLOOR). bing is the only live CN HTML SERP so it stays in the table, but it no longer owns ranks 1-10 by weight alone. Knobs: TM_SEARCH_WEIGHTS="bing=0.3,hn=0.2" · TM_SEARCH_MAX_HITS · TM_SEARCH_DISABLED_ENGINES · TM_SEARCH_DEFAULT_ENGINE.
|
|
595
|
+
- ONE SEARCH IS A SAMPLE, NOT A SEARCH. If the list does not answer the question, do NOT start fetching: re-query 2-3 times with a narrowed or translated phrasing (a Chinese concept often has better material under its English term, and vice versa), or pin an engine for a second opinion. Say what each query was for in EVIDENCE.
|
|
596
|
+
- explicit engines: bing (cn.bing.com — the ONLY live CN HTML SERP; sogou/so/baidu/bing-int were removed after a live benchmark showed them serving anti-bot shells) · stackoverflow (api.stackexchange.com question search, no key, 300/day/IP — quota tracked, auto degrades to bing when spent) · hn (hn.algolia.com story search, no key) · github (repo search API: stars + description; qualifier pass-through whitelisted, e.g. query "vector db stars:>500 language:rust org:redis" — org:/user:/stars:/language: fold into q=, the rest stays free text) · npm (registry search: name@version + description) · moegirl (MediaWiki API: titles + snippets) · bilibili.
|
|
597
|
+
- Hits carry a 1-2 line snippet WHEN THE ENGINE PROVIDES ONE (bing b_caption when present, SO score/tags composite, HN points/comments, npm/github descriptions) — never fabricated. A hit tagged (域名不在白名单,需批准)is readable only after an approval dialog — prefer a hit you can fetch, or tm_browser it.
|
|
598
|
+
- CJK tip: multi-word Chinese queries are auto-quoted on their core phrase for bing; if still noisy, search a single canonical term first (or quote it yourself).
|
|
599
|
+
- Baidu/sogou-style anti-bot shells are gone from the table; empty results still name alternative engines — switch, don't retry the same one.
|
|
166
600
|
- Governance: engine hosts outside a custom TM_WEBFETCH_ALLOWED_DOMAINS route through the OFFICIAL confirmation dialog (approve to proceed); redirects re-checked per hop; env-file URLs and non-http(s) schemes are hard-rejected.
|
|
167
601
|
- URL-encode nothing yourself — pass the raw query; this tool encodes it.`;
|
|
168
602
|
/** Build the tm_search ToolDefinition over the SHARED main pipelines
|
|
169
603
|
* instance. `fetchImpl` is injectable for tests. */
|
|
170
604
|
export function buildTmSearchTool(deps) {
|
|
171
605
|
const { pipelines, cfg } = deps;
|
|
172
|
-
|
|
606
|
+
// T4: the DEFAULT seed carries api.stackexchange.com + hn.algolia.com
|
|
607
|
+
// (consumer-side merge — config.ts's constant is untouched).
|
|
608
|
+
const allowlist = seedWebfetchDomains(cfg.webfetchAllowedDomains);
|
|
609
|
+
const maxHits = Math.max(3, Number(cfg.searchMaxHits) || SEARCH_MAX_HITS);
|
|
610
|
+
const disabled = cfg.searchDisabledEngines ?? [];
|
|
611
|
+
const roster = SEARCH_ENGINE_NAMES.filter((n) => !disabled.includes(n));
|
|
612
|
+
/** Can tm_webfetch actually READ this hit afterwards? A hit it cannot is
|
|
613
|
+
* still shown (tagged), because "the answer is on a site you did not
|
|
614
|
+
* allowlist" is information the agent needs, not noise. */
|
|
615
|
+
const fetchable = (url) => {
|
|
616
|
+
try {
|
|
617
|
+
return hostAllowed(new URL(url).hostname, allowlist);
|
|
618
|
+
}
|
|
619
|
+
catch {
|
|
620
|
+
return false;
|
|
621
|
+
}
|
|
622
|
+
};
|
|
173
623
|
return {
|
|
174
624
|
description: SEARCH_DESCRIPTION,
|
|
175
625
|
args: deps.args ?? {
|
|
176
626
|
query: { descriptor: "query: string (required, raw text — CJK fine, encoded here)" },
|
|
177
627
|
engine: {
|
|
178
|
-
descriptor: `engine: ${
|
|
628
|
+
descriptor: `engine: ${AUTO_ENGINE_KEY}|${roster.join("|")} (optional, default ${cfg.searchDefaultEngine || AUTO_ENGINE_KEY})`,
|
|
179
629
|
},
|
|
180
630
|
},
|
|
181
631
|
execute: async (rawArgs, ctx) => {
|
|
@@ -186,14 +636,67 @@ export function buildTmSearchTool(deps) {
|
|
|
186
636
|
if (!query) {
|
|
187
637
|
return toToolResult(tmError(tool, "args", "缺少 query 参数"));
|
|
188
638
|
}
|
|
189
|
-
const engineKey = String(args.engine ??
|
|
639
|
+
const engineKey = String(args.engine ?? cfg.searchDefaultEngine ?? AUTO_ENGINE_KEY).trim().toLowerCase() || AUTO_ENGINE_KEY;
|
|
640
|
+
// ---------- auto: classify → parallel fan-out → normalize → RRF ----------
|
|
641
|
+
if (engineKey === AUTO_ENGINE_KEY) {
|
|
642
|
+
const { routes, queryClass, notes } = resolveAutoRoutes(query, disabled);
|
|
643
|
+
const stepId = pipelines.nextStepId();
|
|
644
|
+
pipelines.store.appendTrajectory({ tool, step_id: stepId, event: "call", engine: AUTO_ENGINE_KEY, routes });
|
|
645
|
+
const settled = await Promise.all(routes.map(async (key) => {
|
|
646
|
+
const engine = SEARCH_ENGINES[key];
|
|
647
|
+
const prepared = engine.prepareQuery ? engine.prepareQuery(query) : query;
|
|
648
|
+
const target = new URL(engine.buildUrl(encodeURIComponent(prepared)));
|
|
649
|
+
if (!hostAllowed(target.hostname, allowlist)) {
|
|
650
|
+
// a custom-narrowed allowlist: auto SKIPS (asking N dialogs
|
|
651
|
+
// for one search is noise — the explicit path still asks)
|
|
652
|
+
return { engine: key, hits: [], note: `${key} 主机不在白名单,已跳过` };
|
|
653
|
+
}
|
|
654
|
+
try {
|
|
655
|
+
const res = await fetchWebText(target, allowlist, { fetchImpl: deps.fetchImpl });
|
|
656
|
+
return { engine: key, hits: bodyToHits(engine, res.text, maxHits) };
|
|
657
|
+
}
|
|
658
|
+
catch (err) {
|
|
659
|
+
const e = err;
|
|
660
|
+
return { engine: key, hits: [], note: `${key} 失败(${shorten(e?.message ?? e, 80)})` };
|
|
661
|
+
}
|
|
662
|
+
}));
|
|
663
|
+
const groups = settled.filter((g) => g.hits.length > 0);
|
|
664
|
+
for (const g of settled)
|
|
665
|
+
if (g.note)
|
|
666
|
+
notes.push(g.note);
|
|
667
|
+
if (groups.length === 0) {
|
|
668
|
+
return toToolResult(tmError(tool, "execute", `auto(${queryClass}: ${routes.join("+")}) 没有返回可提取的结果${notes.length ? `(${notes.join(";")})` : ""}。换显式引擎试试: ${roster.join(", ")},或缩小/换词。`));
|
|
669
|
+
}
|
|
670
|
+
const fused = fuseRrf(groups, {
|
|
671
|
+
max: maxHits,
|
|
672
|
+
query,
|
|
673
|
+
weights: cfg.searchWeights,
|
|
674
|
+
floor: cfg.searchRelevanceFloor,
|
|
675
|
+
fetchable,
|
|
676
|
+
});
|
|
677
|
+
if (fused.length === 0) {
|
|
678
|
+
return toToolResult(tmError(tool, "execute", `auto(${routes.join("+")}) 结果为空——换显式引擎或换词`));
|
|
679
|
+
}
|
|
680
|
+
const rendered = renderFusedHits(query, routes, fused, notes);
|
|
681
|
+
return toToolResult(pipelines.govern(stepId, tool, rendered, {
|
|
682
|
+
contentType: detectContentType(rendered),
|
|
683
|
+
clue: `search auto(${queryClass}) routes=${routes.join("+")} q=${shorten(query, 80)}`,
|
|
684
|
+
}));
|
|
685
|
+
}
|
|
686
|
+
// ---------- explicit single engine ----------
|
|
190
687
|
const engine = SEARCH_ENGINES[engineKey];
|
|
191
688
|
if (!engine) {
|
|
192
|
-
return toToolResult(tmError(tool, "args", `未知引擎 "${shorten(engineKey, 40)}" —— 可用: ${
|
|
689
|
+
return toToolResult(tmError(tool, "args", `未知引擎 "${shorten(engineKey, 40)}" —— 可用: ${AUTO_ENGINE_KEY}, ${roster.join(", ")}`));
|
|
193
690
|
}
|
|
691
|
+
if (disabled.includes(engineKey)) {
|
|
692
|
+
return toToolResult(tmError(tool, "args", `引擎 "${engineKey}" 已被 TM_SEARCH_DISABLED_ENGINES 关闭 —— 可用: ${AUTO_ENGINE_KEY}, ${roster.join(", ")}`));
|
|
693
|
+
}
|
|
694
|
+
// a bad TM_SEARCH_DEFAULT_ENGINE value lands here too (no explicit
|
|
695
|
+
// engine was given) — the roster above names `auto` as the fix.
|
|
194
696
|
const stepId = pipelines.nextStepId();
|
|
195
|
-
pipelines.store.appendTrajectory({ tool, step_id: stepId, event: "call" });
|
|
196
|
-
const
|
|
697
|
+
pipelines.store.appendTrajectory({ tool, step_id: stepId, event: "call", engine: engine.name });
|
|
698
|
+
const prepared = engine.prepareQuery ? engine.prepareQuery(query) : query;
|
|
699
|
+
const target = new URL(engine.buildUrl(encodeURIComponent(prepared)));
|
|
197
700
|
// engine host outside a CUSTOM allowlist → the OFFICIAL dialog decides
|
|
198
701
|
// (seeded configs always cover every engine, so this only fires when
|
|
199
702
|
// the user narrowed TM_WEBFETCH_ALLOWED_DOMAINS)
|
|
@@ -228,6 +731,12 @@ export function buildTmSearchTool(deps) {
|
|
|
228
731
|
else if (engine.kind === "wiki-json") {
|
|
229
732
|
rendered = renderWikiResults(query, res.text);
|
|
230
733
|
}
|
|
734
|
+
else if (engine.kind === "so-json") {
|
|
735
|
+
rendered = renderSoResults(query, res.text);
|
|
736
|
+
}
|
|
737
|
+
else if (engine.kind === "hn-json") {
|
|
738
|
+
rendered = renderHnResults(query, res.text);
|
|
739
|
+
}
|
|
231
740
|
else {
|
|
232
741
|
const hits = extractHtmlResults(res.text);
|
|
233
742
|
rendered = hits.length > 0 ? renderSearchHits(query, engine.name, hits) : null;
|