@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.
Files changed (112) hide show
  1. package/README.md +154 -57
  2. package/README.zh-CN.md +126 -47
  3. package/dist/agents.d.ts +5 -1
  4. package/dist/agents.d.ts.map +1 -1
  5. package/dist/agents.js +37 -11
  6. package/dist/agents.js.map +1 -1
  7. package/dist/approval-gate.d.ts +54 -16
  8. package/dist/approval-gate.d.ts.map +1 -1
  9. package/dist/approval-gate.js +163 -42
  10. package/dist/approval-gate.js.map +1 -1
  11. package/dist/host-hooks.d.ts +115 -0
  12. package/dist/host-hooks.d.ts.map +1 -0
  13. package/dist/host-hooks.js +270 -0
  14. package/dist/host-hooks.js.map +1 -0
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +68 -4
  17. package/dist/index.js.map +1 -1
  18. package/dist/prompts/lead.d.ts +1 -1
  19. package/dist/prompts/lead.d.ts.map +1 -1
  20. package/dist/prompts/lead.js +88 -6
  21. package/dist/prompts/lead.js.map +1 -1
  22. package/dist/prompts/shared.d.ts +1 -1
  23. package/dist/prompts/shared.d.ts.map +1 -1
  24. package/dist/prompts/shared.js +203 -119
  25. package/dist/prompts/shared.js.map +1 -1
  26. package/dist/prompts/specialists.d.ts +2 -2
  27. package/dist/prompts/specialists.d.ts.map +1 -1
  28. package/dist/prompts/specialists.js +73 -23
  29. package/dist/prompts/specialists.js.map +1 -1
  30. package/dist/tm/args-schema.d.ts +31 -1
  31. package/dist/tm/args-schema.d.ts.map +1 -1
  32. package/dist/tm/args-schema.js +155 -39
  33. package/dist/tm/args-schema.js.map +1 -1
  34. package/dist/tm/bash-timeout.d.ts +72 -0
  35. package/dist/tm/bash-timeout.d.ts.map +1 -0
  36. package/dist/tm/bash-timeout.js +100 -0
  37. package/dist/tm/bash-timeout.js.map +1 -0
  38. package/dist/tm/browser.d.ts +250 -20
  39. package/dist/tm/browser.d.ts.map +1 -1
  40. package/dist/tm/browser.js +1200 -143
  41. package/dist/tm/browser.js.map +1 -1
  42. package/dist/tm/config.d.ts +129 -0
  43. package/dist/tm/config.d.ts.map +1 -1
  44. package/dist/tm/config.js +117 -1
  45. package/dist/tm/config.js.map +1 -1
  46. package/dist/tm/dispatch.d.ts +95 -0
  47. package/dist/tm/dispatch.d.ts.map +1 -0
  48. package/dist/tm/dispatch.js +369 -0
  49. package/dist/tm/dispatch.js.map +1 -0
  50. package/dist/tm/index.d.ts +32 -7
  51. package/dist/tm/index.d.ts.map +1 -1
  52. package/dist/tm/index.js +74 -5
  53. package/dist/tm/index.js.map +1 -1
  54. package/dist/tm/memory.d.ts +88 -10
  55. package/dist/tm/memory.d.ts.map +1 -1
  56. package/dist/tm/memory.js +881 -99
  57. package/dist/tm/memory.js.map +1 -1
  58. package/dist/tm/pipelines.d.ts +26 -3
  59. package/dist/tm/pipelines.d.ts.map +1 -1
  60. package/dist/tm/pipelines.js +167 -11
  61. package/dist/tm/pipelines.js.map +1 -1
  62. package/dist/tm/ptc/budgets.d.ts +22 -1
  63. package/dist/tm/ptc/budgets.d.ts.map +1 -1
  64. package/dist/tm/ptc/budgets.js +87 -8
  65. package/dist/tm/ptc/budgets.js.map +1 -1
  66. package/dist/tm/ptc/contract.d.ts +49 -9
  67. package/dist/tm/ptc/contract.d.ts.map +1 -1
  68. package/dist/tm/ptc/contract.js +41 -3
  69. package/dist/tm/ptc/contract.js.map +1 -1
  70. package/dist/tm/ptc/driver.d.ts +5 -2
  71. package/dist/tm/ptc/driver.d.ts.map +1 -1
  72. package/dist/tm/ptc/driver.js +47 -15
  73. package/dist/tm/ptc/driver.js.map +1 -1
  74. package/dist/tm/ptc/engines.d.ts +15 -4
  75. package/dist/tm/ptc/engines.d.ts.map +1 -1
  76. package/dist/tm/ptc/engines.js +281 -28
  77. package/dist/tm/ptc/engines.js.map +1 -1
  78. package/dist/tm/ptc/index.d.ts +4 -4
  79. package/dist/tm/ptc/index.d.ts.map +1 -1
  80. package/dist/tm/ptc/index.js +2 -2
  81. package/dist/tm/ptc/index.js.map +1 -1
  82. package/dist/tm/ptc/pscan.d.ts +20 -1
  83. package/dist/tm/ptc/pscan.d.ts.map +1 -1
  84. package/dist/tm/ptc/pscan.js +328 -14
  85. package/dist/tm/ptc/pscan.js.map +1 -1
  86. package/dist/tm/ptc/summary.d.ts.map +1 -1
  87. package/dist/tm/ptc/summary.js +23 -1
  88. package/dist/tm/ptc/summary.js.map +1 -1
  89. package/dist/tm/ptc/tool.d.ts +34 -3
  90. package/dist/tm/ptc/tool.d.ts.map +1 -1
  91. package/dist/tm/ptc/tool.js +117 -20
  92. package/dist/tm/ptc/tool.js.map +1 -1
  93. package/dist/tm/pty.d.ts +70 -0
  94. package/dist/tm/pty.d.ts.map +1 -0
  95. package/dist/tm/pty.js +242 -0
  96. package/dist/tm/pty.js.map +1 -0
  97. package/dist/tm/search.d.ts +128 -15
  98. package/dist/tm/search.d.ts.map +1 -1
  99. package/dist/tm/search.js +556 -47
  100. package/dist/tm/search.js.map +1 -1
  101. package/dist/tm/shell-bridge.d.ts +14 -1
  102. package/dist/tm/shell-bridge.d.ts.map +1 -1
  103. package/dist/tm/shell-bridge.js +57 -10
  104. package/dist/tm/shell-bridge.js.map +1 -1
  105. package/dist/tm/webfetch.d.ts +29 -7
  106. package/dist/tm/webfetch.d.ts.map +1 -1
  107. package/dist/tm/webfetch.js +117 -15
  108. package/dist/tm/webfetch.js.map +1 -1
  109. package/dist/types.d.ts +16 -0
  110. package/dist/types.d.ts.map +1 -1
  111. package/docs/installation.md +216 -17
  112. 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 hit list
7
- * before governance — the agent never sees raw SERP chrome. Two engines
8
- * return structured JSON (npm registry search, GitHub repo search API) and
9
- * render with versions/stars/descriptions.
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
- * Engine set is chosen for mainland-China reachability without API keys:
12
- * bing CN + international (ensearch=1), sogou, 360 (so.com), baidu,
13
- * bilibili, plus the npm/GitHub data sources. Baidu is the flakiest
14
- * (anti-bot) and stays in the table because it is sometimes the only
15
- * engine indexing CN-specific content — failures name the alternatives.
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
- /** The engine table — keys are the tm_search `engine` arg values. */
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
- alt: "bing-int / sogou / so",
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
- so: {
47
- name: "so",
48
- kind: "html",
49
- buildUrl: (q) => `https://www.so.com/s?q=${q}`,
50
- alt: "bing / sogou / baidu",
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
- baidu: {
53
- name: "baidu",
54
- kind: "html",
55
- buildUrl: (q) => `https://www.baidu.com/s?wd=${q}`,
56
- alt: "bing / sogou / so(baidu 反爬最凶,空结果属常态)",
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
- alt: "10-15 分钟后重试(匿名限额 10 次/分钟)",
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
- /** Collapse an HTML SERP into the compact hit list; null when the engine
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, SEARCH_MAX_HITS);
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
- - engines: bing (cn.bing.com, default) · bing-int (international results, ensearch=1) · sogou · so (360) · baidu · bilibili · moegirl (MediaWiki API: entry titles + snippets, structured) · npm (registry search: name@version + description, structured) · github (repo search API: stars + description, structured)
164
- - Returns an extracted title+URL hit list (max 10), NOT the raw page — output rides the same governance as the other tm_* tools.
165
- - Baidu/sogou sometimes serve anti-bot shells; an empty result names the alternative engines — switch, don't retry the same one.
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
- const allowlist = cfg.webfetchAllowedDomains;
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: ${SEARCH_ENGINE_NAMES.join("|")} (optional, default bing)`,
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 ?? "bing").trim().toLowerCase() || "bing";
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)}" —— 可用: ${SEARCH_ENGINE_NAMES.join(", ")}`));
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 target = new URL(engine.buildUrl(encodeURIComponent(query)));
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;