thincoder 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/tools/web.mjs CHANGED
@@ -1,11 +1,48 @@
1
- import {
2
- DESC,
3
- truncate,
4
- readBodyText,
5
- stripTags,
6
- htmlToText
7
- } from "./shared.mjs";
8
- import { join } from "node:path";
1
+ import { DESC, truncate, stripTags, htmlToText } from "./shared.mjs";
2
+ import { URL } from "node:url";
3
+ import { resolveWebProxy, proxyFetch } from "../proxy.mjs";
4
+
5
+ export const UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
6
+ const FETCH_TIMEOUT = 15_000
7
+
8
+ // ── Web search (Bing; direct by default, through proxy when configured and web toggle on) ──
9
+
10
+ function extractBing(html) {
11
+ const results = []
12
+ const blocks = html.split('<li class="b_algo"').slice(1)
13
+ for (const block of blocks) {
14
+ const link = block.match(/<h2[^>]*><a[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/)
15
+ if (!link) continue
16
+ const snippet = block.match(/<p[^>]*>([\s\S]*?)<\/p>/)
17
+ results.push({ href: link[1], title: stripTags(link[2]), snippet: snippet ? stripTags(snippet[1]) : "" })
18
+ }
19
+ return results
20
+ }
21
+
22
+ function bingUrl(query, page) {
23
+ let u = `https://www.bing.com/search?q=${encodeURIComponent(query)}&setlang=en&setmkt=en-US`
24
+ if (page > 1) u += `&first=${(page - 1) * 10 + 1}`
25
+ return u
26
+ }
27
+
28
+ const ENGINES = [{ name: "bing", label: "Bing", url: bingUrl, extract: extractBing, ua: UA }]
29
+ const ENGINE_NAMES = ENGINES.map(e => e.name)
30
+
31
+ async function fetchEngine(engine, query, page, ctx) {
32
+ const ctrl = new AbortController()
33
+ const timer = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT)
34
+ try {
35
+ const response = await proxyFetch(engine.url(query, page), {
36
+ headers: { "User-Agent": engine.ua, "Accept-Language": "en-US,en;q=0.9,zh-CN;q=0.8" },
37
+ signal: ctrl.signal,
38
+ }, resolveWebProxy(ctx))
39
+ if (!response.ok) return null
40
+ const html = await response.text()
41
+ const results = engine.extract(html)
42
+ return { engine: engine.name, results }
43
+ } catch { return null }
44
+ finally { clearTimeout(timer) }
45
+ }
9
46
 
10
47
  export const websearchTool = {
11
48
  name: "websearch",
@@ -14,108 +51,92 @@ export const websearchTool = {
14
51
  type: "object",
15
52
  properties: {
16
53
  query: { type: "string", description: "Search query" },
17
- limit: { type: "number", description: "Max results (default 8)" },
54
+ limit: { type: "number", description: "Max results (default 8, max 20)" },
55
+ engine: { type: "string", enum: ENGINE_NAMES, description: "Specific engine — \"bing\" (Bing). Omit to search all engines concurrently." },
56
+ page: { type: "number", description: "Page number for pagination (1-based, default 1). Only used when engine is specified." },
18
57
  },
19
58
  required: ["query"],
20
59
  },
21
60
  readonly: true,
22
61
  async execute(args, ctx) {
23
- const limit = args.limit ?? 8
24
- const url = `https://www.bing.com/search?q=${encodeURIComponent(args.query)}`
25
- let html
26
- try {
27
- const response = await fetch(url, {
28
- headers: { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" },
29
- signal: ctx?.signal
30
- ? AbortSignal.any([ctx.signal, AbortSignal.timeout(15_000)])
31
- : AbortSignal.timeout(15_000),
32
- })
33
- if (!response.ok) throw new Error(`HTTP ${response.status}`)
34
- html = await readBodyText(response)
35
- } catch (error) {
36
- throw new Error(`websearch request failed: ${error.cause?.code ?? error.message}`)
62
+ const limit = Math.min(args.limit ?? 8, 20)
63
+ const page = Math.max(1, args.page ?? 1)
64
+ if (args.engine) {
65
+ const engine = ENGINES.find(e => e.name === args.engine)
66
+ if (!engine) return `Unknown engine '${args.engine}'. Available: ${ENGINE_NAMES.join(", ")}`
67
+ const fetched = await fetchEngine(engine, args.query, page, ctx)
68
+ if (!fetched || fetched.results.length === 0) return `(no results from ${engine.label})`
69
+ return truncate(fetched.results.slice(0, limit).map((r, i) => `${i + 1}. ${r.title}\n ${r.href}\n ${r.snippet}`).join("\n\n"))
37
70
  }
38
-
39
- // Result block <li class="b_algo">: <h2><a href>title</a></h2> + <p>snippet</p>
40
- const blocks = html.split('<li class="b_algo"').slice(1)
41
- const results = []
42
- for (const block of blocks) {
43
- const link = block.match(/<h2[^>]*><a[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/)
44
- if (!link) continue
45
- const snippet = block.match(/<p[^>]*>([\s\S]*?)<\/p>/)
46
- results.push({
47
- href: link[1],
48
- title: stripTags(link[2]),
49
- snippet: snippet ? stripTags(snippet[1]) : "",
50
- })
51
- if (results.length >= limit) break
71
+ const promises = ENGINES.map(e => fetchEngine(e, args.query, 1, ctx))
72
+ const fetched = (await Promise.all(promises)).filter(Boolean)
73
+ if (fetched.length === 0) return "(no results — all search engines failed)"
74
+ const merged = [], indexes = fetched.map(() => 0)
75
+ let done = false
76
+ while (!done && merged.length < limit) {
77
+ done = true
78
+ for (let i = 0; i < fetched.length; i++) {
79
+ if (indexes[i] < fetched[i].results.length) {
80
+ merged.push({ ...fetched[i].results[indexes[i]], _engine: fetched[i].engine })
81
+ indexes[i]++; done = false
82
+ if (merged.length >= limit) break
83
+ }
84
+ }
52
85
  }
53
- if (results.length === 0) return "(no results)"
54
- return truncate(
55
- results.map((r, i) => `${i + 1}. ${r.title}\n ${r.href}\n ${r.snippet}`).join("\n\n"),
56
- )
86
+ return truncate(merged.slice(0, limit).map((r, i) => `${i + 1}. [${r._engine}] ${r.title}\n ${r.href}\n ${r.snippet}`).join("\n\n"))
57
87
  },
58
88
  }
59
89
 
90
+ // ── Fetch tool (with proxy support) ──────
60
91
 
61
- // ---------------------------------------------------------------- ls
62
-
63
- /** SSRF protection: block internal private-network/metadata endpoints (localhost allowed — user's dev server, tests depend on it) */
64
92
  function isPrivateUrl(urlStr) {
65
- let u
66
- try { u = new URL(urlStr) } catch { return true }
93
+ let u; try { u = new URL(urlStr) } catch { return true }
67
94
  const host = u.hostname.toLowerCase()
68
- // localhost / 127.x allowed (user's dev server on local machine, test mock server)
69
95
  if (host === "localhost" || host === "0.0.0.0" || host.endsWith(".localhost")) return false
70
96
  if (host === "127.0.0.1" || host.startsWith("127.")) return false
71
- // cloud metadata endpoint
72
97
  if (host === "169.254.169.254" || host === "metadata.google.internal") return true
73
- // IPv4 private ranges
74
98
  const m = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/)
75
- if (m) {
76
- const [a, b] = [Number(m[1]), Number(m[2])]
77
- if (a === 10) return true
78
- if (a === 172 && b >= 16 && b <= 31) return true
79
- if (a === 192 && b === 168) return true
80
- if (a === 169 && b === 254) return true
81
- if (a === 0) return true
82
- }
83
- // IPv6 loopback / link-local / unique local addresses
99
+ if (m) { const [a, b] = [Number(m[1]), Number(m[2])]; if (a === 10||a === 172&&b>=16&&b<=31||a === 192&&b===168||a === 169&&b===254||a===0) return true }
84
100
  if (host === "::1" || host === "fe80::1" || host.startsWith("fc") || host.startsWith("fd")) return true
85
101
  return false
86
102
  }
87
103
 
104
+ // proxyFetch returns a native Response (Headers object, needs .get()) without proxy,
105
+ // but a Response-like with a plain lowercase-keyed Record through the CONNECT tunnel — handle both.
106
+ function headerOf(res, name) {
107
+ const h = res.headers
108
+ if (!h) return null
109
+ if (typeof h.get === "function") return h.get(name)
110
+ return h[name.toLowerCase()] ?? null
111
+ }
112
+
88
113
  export const fetchTool = {
89
114
  name: "fetch",
90
115
  description: DESC("fetch"),
91
- parameters: {
92
- type: "object",
93
- properties: {
94
- url: { type: "string", description: "http/https URL" },
95
- },
96
- required: ["url"],
97
- },
116
+ parameters: { type: "object", properties: { url: { type: "string", description: "http/https URL" } }, required: ["url"] },
98
117
  readonly: true,
99
118
  async execute(args, ctx) {
100
119
  if (!/^https?:\/\//.test(args.url)) throw new Error("url must start with http:// or https://")
101
120
  if (isPrivateUrl(args.url)) throw new Error("fetch blocked: internal/private/metadata addresses are not allowed")
102
- let response
103
121
  try {
104
- response = await fetch(args.url, {
105
- headers: { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" },
106
- redirect: "follow",
107
- signal: ctx?.signal
108
- ? AbortSignal.any([ctx.signal, AbortSignal.timeout(20_000)])
109
- : AbortSignal.timeout(20_000),
110
- })
111
- } catch (error) {
112
- throw new Error(`fetch failed: ${error.cause?.code ?? error.message}`)
113
- }
114
- if (!response.ok) throw new Error(`fetch failed: HTTP ${response.status}`)
115
-
116
- const contentType = response.headers.get("content-type") ?? ""
117
- const body = await readBodyText(response)
118
- if (!contentType.includes("text/html")) return truncate(body)
119
- return truncate(htmlToText(body))
122
+ const proxyUri = resolveWebProxy(ctx)
123
+ const response = await proxyFetch(args.url, { headers: { "User-Agent": UA } }, proxyUri)
124
+ if (!response.ok) {
125
+ if ([301, 302, 307, 308].includes(response.status)) {
126
+ const loc = headerOf(response, "location")
127
+ if (loc) {
128
+ const r2 = await proxyFetch(loc, { headers: { "User-Agent": UA } }, proxyUri)
129
+ if (!r2.ok) throw new Error(`fetch failed: HTTP ${r2.status}`)
130
+ const ct2 = headerOf(r2, "content-type") ?? ""
131
+ const b2 = await r2.text()
132
+ return ct2.includes("text/html") ? truncate(htmlToText(b2)) : truncate(b2)
133
+ }
134
+ }
135
+ throw new Error(`fetch failed: HTTP ${response.status}`)
136
+ }
137
+ const ct = headerOf(response, "content-type") ?? ""
138
+ const body = await response.text()
139
+ return ct.includes("text/html") ? truncate(htmlToText(body)) : truncate(body)
140
+ } catch (e) { throw new Error(`fetch failed: ${e.cause?.code ?? e.message}`) }
120
141
  },
121
142
  }
@@ -1,11 +1,13 @@
1
- Search the web (Bing). Returns result titles, URLs, and snippets. Use for looking up current information, docs, error messages.
1
+ Search the web via Bing. Returns result titles, URLs, and snippets. Use for looking up current information, docs, error messages.
2
2
 
3
3
  Parameters:
4
4
  - query (required): Search query
5
- - limit: Max results (default 8)
5
+ - limit: Max results (default 8, max 20)
6
+ - engine: Specific engine to use — "bing" (Bing). Omit to search all engines concurrently.
7
+ - page: Page number for pagination (1-based, default 1). Only used when engine is specified.
6
8
 
7
9
  Notes:
8
10
  - Before searching the web, call `memory_search` first — you may already know the answer from a previous session. Only reach for websearch if memory comes up empty.
9
11
  - Use this for information that is NOT in the local codebase — current docs, error messages, API references
10
12
  - Follow up with `fetch` to read full pages from the results
11
- - Results are scraped from Bing HTML some formatting may be imperfect
13
+ - Proxy support: set `"proxy": {"uri": "http://host:port", "web": true}` in config.json
@@ -10,6 +10,9 @@ import { ansi, C } from "./ansi.mjs"
10
10
  * handleSlash, summarize } */
11
11
  export async function runAgentTurn(ctx, text) {
12
12
  const { agent, state, pushLine, pushLabel, render, scheduleRender, ensureAssistantLabel, askPermission, askQuestion, handleSlash, summarize } = ctx
13
+ // 可注入覆盖(测试用);默认走真实实现
14
+ const runAgentImpl = ctx.runAgent ?? runAgent
15
+ const saveSessionImpl = ctx.saveSession ?? saveSession
13
16
  pushLabel(`❯ You:`, ansi.bold + C.user)
14
17
  pushLine(text, C.text)
15
18
 
@@ -205,14 +208,14 @@ export async function runAgentTurn(ctx, text) {
205
208
  let n = 0
206
209
  return () => {
207
210
  if (++n % 5 !== 0) return
208
- try { saveSession(agent, state.lines) } catch (e) { console.error(`[session] incremental save failed: ${e.message}`) }
211
+ try { saveSessionImpl(agent, state.lines) } catch (e) { console.error(`[session] incremental save failed: ${e.message}`) }
209
212
  }
210
213
  })(),
211
214
  }
212
215
 
213
216
  for (let resume = false; ; resume = true) {
214
217
  try {
215
- await runAgent(agent, text, callbacks, { signal: state.controller.signal, resume })
218
+ await runAgentImpl(agent, text, callbacks, { signal: state.controller.signal, resume })
216
219
  flushStream()
217
220
  break // Normal completion, exit loop
218
221
  } catch (error) {
@@ -268,28 +271,24 @@ export async function runAgentTurn(ctx, text) {
268
271
  }
269
272
  // Save session after every turn (survives crashes)
270
273
  try {
271
- saveSession(agent, state.lines)
274
+ saveSessionImpl(agent, state.lines)
272
275
  } catch {
273
276
  // Save failure doesn't interrupt usage
274
277
  }
275
278
  render()
276
279
 
277
280
  // Queued messages: auto-process next one
278
- if (state.queue.length > 0) {
281
+ while (state.queue.length > 0 && !state.processing) {
279
282
  const next = state.queue.shift()
280
- // Queued slash commands execute directly
283
+ // Queued slash commands execute directly — check every item, not just the first
281
284
  if (next.text.startsWith("/")) {
282
285
  await handleSlash(next.text)
283
286
  render()
284
- // After slash command completes, continue checking queue
285
- if (state.queue.length > 0 && !state.processing) {
286
- const next2 = state.queue.shift()
287
- await runAgentTurn(ctx, next2.text)
288
- }
289
- } else {
290
- pushLabel(`❯ You: (from queue)`, ansi.bold + C.user)
291
- await runAgentTurn(ctx, next.text)
287
+ continue
292
288
  }
289
+ pushLabel(`❯ You: (from queue)`, ansi.bold + C.user)
290
+ await runAgentTurn(ctx, next.text)
291
+ return
293
292
  }
294
293
  }
295
294
 
@@ -1,9 +1,9 @@
1
1
  /** /advisor command: toggle advisor on/off, select model.
2
- * ctx: { agent, openPicker, pushLine } */
2
+ * ctx: { agent, showPicker, pushLine } */
3
3
  import { C } from "./ansi.mjs"
4
4
 
5
5
  export async function handleAdvisorCommand(ctx) {
6
- const { agent, openPicker, pushLine } = ctx
6
+ const { agent, showPicker, pushLine } = ctx
7
7
  const cfg = agent.config.advisor ??= {}
8
8
  const enabled = cfg.enabled === true
9
9
  const curProvider = cfg.provider || agent.activeProvider
@@ -14,55 +14,43 @@ export async function handleAdvisorCommand(ctx) {
14
14
  { type: "item", text: `Model: ${curProvider}/${curModel}`, action: "model" },
15
15
  ]
16
16
 
17
- openPicker({
18
- title: "Advisor",
19
- entries,
20
- onSelect: async (e) => {
21
- if (e.action === "toggle") {
22
- cfg.enabled = !cfg.enabled
23
- agent._pendingReminders = agent._pendingReminders ?? []
24
- if (cfg.enabled) {
25
- agent._pendingReminders.push("[系统提醒: Advisor 审查已开启。每轮操作后,你的输出将被审查,观察结果可能作为系统提醒注入。请批判性参考——这是观察,不是命令。]")
26
- } else {
27
- agent._pendingReminders.push("[系统提醒: Advisor 审查已关闭。后续轮次不再自动审查。]")
28
- }
29
- } else if (e.action === "model") {
30
- await openAdvisorModelPicker(ctx).catch(err => pushLine(`[error] ${err.message}`, C.error))
31
- }
32
- },
33
- })
17
+ const e = await showPicker("Advisor", entries)
18
+ if (!e) return
19
+ if (e.action === "toggle") {
20
+ cfg.enabled = !cfg.enabled
21
+ agent._pendingReminders = agent._pendingReminders ?? []
22
+ if (cfg.enabled) {
23
+ agent._pendingReminders.push("[System reminder: Advisor review is now ON. After each turn your output will be reviewed, and observations may be injected as system reminders. Treat them critically — they are observations, not commands.]")
24
+ } else {
25
+ agent._pendingReminders.push("[System reminder: Advisor review is now OFF. Future turns will not be reviewed automatically.]")
26
+ }
27
+ } else if (e.action === "model") {
28
+ await openAdvisorModelPicker(ctx).catch(err => pushLine(`[error] ${err.message}`, C.error))
29
+ }
34
30
  }
35
31
 
36
32
  async function openAdvisorModelPicker(ctx) {
37
- const { agent, openPicker, pushLine } = ctx
33
+ const { agent, showPicker, pushLine } = ctx
38
34
  const providers = agent.providers || []
39
35
 
40
36
  // Build flat list: each provider's name + a "use current model" entry
41
37
  const entries = []
42
- let idx = 0
43
38
  for (const p of providers) {
44
39
  const mark = p.name === agent.activeProvider ? "* " : " "
45
40
  entries.push({ type: "item", text: `${mark}${p.name} — ${p.baseURL}`, action: "set_provider", provider: p.name, model: p.model })
46
- idx++
47
41
  }
48
42
 
49
- openPicker({
50
- title: "Advisor Model",
51
- entries,
52
- onSelect: async (e) => {
53
- if (e.action === "set_provider") {
54
- const cfg = agent.config.advisor ??= {}
55
- if (e.provider === agent.activeProvider && e.model === agent.provider.model) {
56
- // Same as main — clear override (use main pool)
57
- delete cfg.provider
58
- delete cfg.model
59
- pushLine(`Advisor: 使用主模型 (${agent.activeProvider}/${agent.provider.model})`, C.dim)
60
- } else {
61
- cfg.provider = e.provider
62
- cfg.model = e.model
63
- pushLine(`Advisor: ${e.provider}/${e.model}`, C.dim)
64
- }
65
- }
66
- },
67
- })
43
+ const e = await showPicker("Advisor Model", entries)
44
+ if (e?.action !== "set_provider") return
45
+ const cfg = agent.config.advisor ??= {}
46
+ if (e.provider === agent.activeProvider && e.model === agent.provider.model) {
47
+ // Same as main — clear override (use main pool)
48
+ delete cfg.provider
49
+ delete cfg.model
50
+ pushLine(`Advisor: 使用主模型 (${agent.activeProvider}/${agent.provider.model})`, C.dim)
51
+ } else {
52
+ cfg.provider = e.provider
53
+ cfg.model = e.model
54
+ pushLine(`Advisor: ${e.provider}/${e.model}`, C.dim)
55
+ }
68
56
  }
@@ -1,23 +1,17 @@
1
1
  /** /clear command: clear screen (confirm to prevent accidental trigger).
2
- * ctx: { state, openPicker, render } */
2
+ * ctx: { state, showPicker, render } */
3
3
  export async function handleClearCommand(ctx) {
4
- const { state, openPicker, render } = ctx
4
+ const { state, showPicker, render } = ctx
5
5
  if (state.lines.length > 0) {
6
- openPicker({
7
- title: "Clear screen?",
8
- entries: [
9
- { type: "item", text: "Yes, clear all conversation output", action: "yes" },
10
- { type: "item", text: "Cancel", action: "no" },
11
- ],
12
- defaultIndex: 1,
13
- onSelect: (e) => {
14
- if (e.action === "yes") {
15
- state.lines = []
16
- state.streaming = ""
17
- render()
18
- }
19
- },
20
- })
6
+ const e = await showPicker("Clear screen?", [
7
+ { type: "item", text: "Yes, clear all conversation output", action: "yes" },
8
+ { type: "item", text: "Cancel", action: "no" },
9
+ ], { defaultIndex: 1 })
10
+ if (e?.action === "yes") {
11
+ state.lines = []
12
+ state.streaming = ""
13
+ render()
14
+ }
21
15
  return
22
16
  }
23
17
  state.lines = []