thincoder 0.12.21 → 0.12.22

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 CHANGED
@@ -152,6 +152,12 @@ Environment variables: `THINCODER_API_KEY` (or `DEEPSEEK_API_KEY` / `OPENAI_API_
152
152
  },
153
153
  ],
154
154
  },
155
+ "websearch": {
156
+ // optional: Tavily structured search (stable JSON API, no HTML scraping).
157
+ // Empty apiKey → silently falls back to Bing HTML extraction (zero-config).
158
+ "provider": "tavily",
159
+ "apiKey": "tvly-...", // https://tavily.com — has a free monthly tier
160
+ },
155
161
  }
156
162
  ```
157
163
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thincoder",
3
- "version": "0.12.21",
3
+ "version": "0.12.22",
4
4
  "description": "Thin coding agent - zero dependencies, no build step, Node.js native. Sharp code, zero bloat.",
5
5
  "keywords": [
6
6
  "ai",
package/src/config.mjs CHANGED
@@ -66,6 +66,10 @@ const DEFAULTS = {
66
66
  mcp: {
67
67
  servers: [],
68
68
  },
69
+ websearch: {
70
+ provider: "tavily", // structured search API; empty apiKey → fall back to Bing HTML scraping
71
+ apiKey: "", // Tavily key (tvly-...) — optional
72
+ },
69
73
  }
70
74
 
71
75
  /**
@@ -2,6 +2,24 @@
2
2
  * provider/sse.mjs — SSE stream reader
3
3
  * Extracted from core.mjs. Parses Server-Sent Events for LLM chat responses.
4
4
  */
5
+
6
+ /**
7
+ * Normalize provider cache fields into DeepSeek-style prompt_cache_hit/miss_tokens.
8
+ * DeepSeek already returns these; OpenAI/Kimi report the cache hit as
9
+ * prompt_tokens_details.cached_tokens; a few providers put cached_tokens at the
10
+ * usage top level. Miss is derived as prompt_tokens - hit when not reported.
11
+ */
12
+ export function normalizeUsageCache(u) {
13
+ if (!u || u.prompt_cache_hit_tokens !== undefined) return u
14
+ const cached = u.prompt_tokens_details?.cached_tokens ?? u.cached_tokens
15
+ if (cached === undefined) return u
16
+ u.prompt_cache_hit_tokens = cached
17
+ if (u.prompt_cache_miss_tokens === undefined && typeof u.prompt_tokens === "number") {
18
+ u.prompt_cache_miss_tokens = Math.max(0, u.prompt_tokens - cached)
19
+ }
20
+ return u
21
+ }
22
+
5
23
  export async function readSSE(response, { onToken, onReasoning, rules, signal, firedPatterns: sharedFired }) {
6
24
  // Early intercept: non-SSE responses — either error bodies (HTTP >= 400) or
7
25
  // valid single-chunk JSON completions (some APIs return JSON despite stream:true).
@@ -28,7 +46,7 @@ export async function readSSE(response, { onToken, onReasoning, rules, signal, f
28
46
  const parsed = JSON.parse(body)
29
47
  const choice = parsed.choices?.[0]
30
48
  if (choice) {
31
- const result = { content: "", reasoning: "", toolCalls: [], usage: parsed.usage ?? null, finishReason: null }
49
+ const result = { content: "", reasoning: "", toolCalls: [], usage: normalizeUsageCache(parsed.usage ?? null), finishReason: null }
32
50
  const delta = choice.delta ?? {}
33
51
  result.content = delta.content ?? ""
34
52
  result.reasoning = delta.reasoning_content ?? ""
@@ -63,7 +81,7 @@ export async function readSSE(response, { onToken, onReasoning, rules, signal, f
63
81
  let json
64
82
  try { json = JSON.parse(data) } catch { continue }
65
83
 
66
- if (json.usage) result.usage = json.usage
84
+ if (json.usage) result.usage = normalizeUsageCache(json.usage)
67
85
  const choice = json.choices?.[0]
68
86
  if (!choice) continue
69
87
  hasChoices = true
package/src/tools/web.mjs CHANGED
@@ -40,6 +40,30 @@ function bingUrl(query, page) {
40
40
  const ENGINES = [{ name: "bing", label: "Bing", url: bingUrl, extract: extractBing, ua: UA }]
41
41
  const ENGINE_NAMES = ENGINES.map(e => e.name)
42
42
 
43
+ /** Structured search via Tavily (optional — config.websearch.apiKey). Returns
44
+ * { engine, results } or null to fall back to Bing HTML scraping. */
45
+ async function fetchTavily(query, limit, ctx) {
46
+ const apiKey = ctx?.agent?.config?.websearch?.apiKey
47
+ if (!apiKey) return null
48
+ const ctrl = new AbortController()
49
+ const timer = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT)
50
+ try {
51
+ const response = await proxyFetch("https://api.tavily.com/search", {
52
+ method: "POST",
53
+ headers: { "Content-Type": "application/json", "Authorization": `Bearer ${apiKey}` },
54
+ body: JSON.stringify({ query, search_depth: "basic", max_results: limit, include_answer: false, include_raw_content: false }),
55
+ signal: ctrl.signal,
56
+ }, resolveWebProxy(ctx))
57
+ if (!response.ok) return null
58
+ const data = await response.json()
59
+ const results = (Array.isArray(data.results) ? data.results : []).map((r) => ({
60
+ href: r.url, title: r.title ?? "", snippet: r.content ?? "", _engine: "tavily",
61
+ }))
62
+ return { engine: "tavily", results }
63
+ } catch { return null }
64
+ finally { clearTimeout(timer) }
65
+ }
66
+
43
67
  async function fetchEngine(engine, query, page, ctx) {
44
68
  const ctrl = new AbortController()
45
69
  const timer = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT)
@@ -73,6 +97,12 @@ export const websearchTool = {
73
97
  async execute(args, ctx) {
74
98
  const limit = Math.min(args.limit ?? 8, 20)
75
99
  const page = Math.max(1, args.page ?? 1)
100
+ // Structured search first when a Tavily key is configured — stable, dated,
101
+ // no HTML scraping. Falls back to Bing silently.
102
+ const tavily = await fetchTavily(args.query, limit, ctx)
103
+ if (tavily && tavily.results.length > 0) {
104
+ return truncate(tavily.results.slice(0, limit).map((r, i) => `${i + 1}. [tavily] ${r.title}\n ${r.href}\n ${r.snippet}`).join("\n\n"))
105
+ }
76
106
  if (args.engine) {
77
107
  const engine = ENGINES.find(e => e.name === args.engine)
78
108
  if (!engine) return `Unknown engine '${args.engine}'. Available: ${ENGINE_NAMES.join(", ")}`
@@ -115,6 +145,17 @@ function headerOf(res, name) {
115
145
  return h[name.toLowerCase()] ?? null
116
146
  }
117
147
 
148
+ /** Validate a redirect target: resolve relative → absolute, http/https only, and
149
+ * SSRF-checked. Returns { target } on success or { error } (never follows into a
150
+ * private host — the redirect SSRF bypass). */
151
+ export function resolveRedirectTarget(loc, baseUrl) {
152
+ let target
153
+ try { target = new URL(loc, baseUrl).toString() } catch { return { error: "invalid redirect location" } }
154
+ if (!/^https?:\/\//.test(target)) return { error: "redirect target must be http/https" }
155
+ if (isPrivateUrl(target)) return { error: "redirect target is internal/private/metadata" }
156
+ return { target }
157
+ }
158
+
118
159
  export const fetchTool = {
119
160
  name: "fetch",
120
161
  description: DESC("fetch"),
@@ -130,7 +171,11 @@ export const fetchTool = {
130
171
  if ([301, 302, 307, 308].includes(response.status)) {
131
172
  const loc = headerOf(response, "location")
132
173
  if (loc) {
133
- const r2 = await proxyFetch(loc, { headers: { "User-Agent": UA } }, proxyUri)
174
+ // SSRF-check the redirect target (resolve relative absolute) before
175
+ // following — a 3xx must not bounce a public URL into a private host.
176
+ const r = resolveRedirectTarget(loc, args.url)
177
+ if (r.error) throw new Error(`fetch failed: ${r.error}`)
178
+ const r2 = await proxyFetch(r.target, { headers: { "User-Agent": UA } }, proxyUri)
134
179
  if (!r2.ok) throw new Error(`fetch failed: HTTP ${r2.status}`)
135
180
  const ct2 = headerOf(r2, "content-type") ?? ""
136
181
  const b2 = await r2.text()