thincoder 0.12.21 → 0.12.23

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
@@ -15,7 +15,7 @@ Design philosophy (the entire meaning of the name): if the Node standard library
15
15
  - **Fix-verify loop**: file changes without `verify` get pushed back — syntax check + tests must pass before the agent can claim completion (auto-repair up to 3 rounds)
16
16
  - **Checkpoint system**: auto-snapshot before every user task, `list`/`create`/`rewind` tools for the model, single-file restore — rewinding itself is reversible (pre-rewind state auto-saved)
17
17
  - **Codebase understanding** ⭐0.5.0: `repo_outline` (dependency outline, auto-injected at startup), `code_search` (source FTS5 + vectors + JSDoc extraction), `doc_search` (docs chunked by ## headings) — background indexing, auto-incremental updates on file writes, three tools guided by "structure → intent → details"
18
- - **Model adaptation** ⭐: top-tier only, latest only. Built-in flagship models from twelve providers — DeepSeek / Kimi / GLM / Qwen / MiniMax / OpenAI / Claude / Gemini / Grok / Mistral / Volcengine Ark (豆包) / Hunyuan (腾讯混元) / SiliconFlow (硅基流动) / OpenRouter / Groq. No legacy model compatibility, no local model support. Auto-matched context windows, truncation-resume protocols (prefix/partial), thinking-mode APIs (thinking.type / reasoning_effort), reasoning_content echo strategies (reasoningEcho), output limits, temperature range clamping — all deeply adapted.
18
+ - **Model adaptation** ⭐: top-tier only, latest only. Built-in flagship models from seventeen providers — DeepSeek / Kimi / Kimi For Coding / GLM / Qwen / Qwen Token Plan / MiniMax / OpenAI / Claude / Gemini / Grok / Mistral / Volcengine Ark (豆包) / Hunyuan (腾讯混元) / SiliconFlow (硅基流动) / OpenRouter / Groq. No legacy model compatibility, no local model support. Auto-matched context windows, truncation-resume protocols (prefix/partial), thinking-mode APIs (thinking.type / reasoning_effort), reasoning_content echo strategies (reasoningEcho), output limits, temperature range clamping — all deeply adapted.
19
19
  - **Toolset**: `read` / `write` / `edit` / `bash` / `glob` (supports `**`) / `grep` / `websearch` / `ls` / `fetch` + `read_image` (image/video paste) + three retrieval tools + MCP — all zero-dependency, file tools confined to the working directory
20
20
  - **Memory system**: three layers (personal/project/team), FTS5 + vector RRF hybrid retrieval, git-friendly markdown format
21
21
  - **Two-phase tool scheduling**: permission prompts serialized, read-only tools parallelized, side-effect tools serialized
@@ -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.23",
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
  /**
@@ -40,6 +40,11 @@ export function createProvider(config) {
40
40
 
41
41
  /** Send a streaming chat completion request with automatic continuation on truncation */
42
42
  export async function chat(provider, { messages, tools, onToken, onReasoning, onWait, signal, streamRules, firedPatterns }) {
43
+ // Sanitize BEFORE format dispatch — image poisoning bricks anthropic/google sessions
44
+ // the same way it bricks OpenAI-format ones (all raster-only).
45
+ const spec = specForModel(provider.model)
46
+ messages = stripImagesForTextModel(messages, spec)
47
+
43
48
  // Format dispatch: delegate to non-OpenAI transports
44
49
  if (provider.format === "anthropic") {
45
50
  const { chat: anthropicChat } = await import("./anthropic.mjs")
@@ -62,8 +67,7 @@ export async function chat(provider, { messages, tools, onToken, onReasoning, on
62
67
  return result
63
68
  }
64
69
 
65
- const spec = specForModel(provider.model)
66
- messages = normalizeToolPairing(stripImagesForTextModel(messages, spec))
70
+ messages = normalizeToolPairing(messages)
67
71
  // Compile string-pattern rules to RegExp at call time
68
72
  const rules = compileStreamRules(streamRules)
69
73
  const body = {
@@ -181,23 +185,37 @@ export async function chat(provider, { messages, tools, onToken, onReasoning, on
181
185
  }
182
186
 
183
187
  /**
184
- * Replace image parts with text placeholders when the model has no vision support.
185
- * History may contain image_url parts (e.g. a session resumed after switching from a vision model
186
- * to a text-only one); text-only APIs like DeepSeek reject the ENTIRE request with 400 if any
187
- * message contains an image part, which bricks the conversation. Sanitize at send time — history
188
- * itself is left untouched, so switching back to a vision model restores the images.
188
+ * Replace image parts with text placeholders when they would 400 the request:
189
+ * - the model has no vision support at all (history may carry image_url parts from a
190
+ * session resumed after switching from a vision model text-only APIs like DeepSeek
191
+ * reject the ENTIRE request, bricking the conversation);
192
+ * - the model IS vision-capable but the data URL is not a raster format it can ingest
193
+ * (Kimi/Anthropic/OpenAI/Gemini are all raster-only — Kimi 400s "unsupported image
194
+ * format" on EVERY subsequent request once an svg/bmp part sits in history).
195
+ * Sanitize at send time — history itself is left untouched, so switching back to a
196
+ * capable model/format restores the images. Non-data-URL image refs (http) pass through.
189
197
  */
198
+ const RASTER_IMAGE_URL = /^data:image\/(png|jpe?g|gif|webp);base64,/
199
+
190
200
  export function stripImagesForTextModel(messages, spec) {
191
- if (spec.multimodal) return messages
192
201
  let changed = false
193
202
  const out = messages.map((m) => {
194
203
  if (!Array.isArray(m.content) || !m.content.some((p) => p?.type === "image_url")) return m
204
+ let msgChanged = false
205
+ const parts = m.content.map((p) => {
206
+ if (p?.type !== "image_url") return p
207
+ const url = p.image_url?.url || ""
208
+ if (!url.startsWith("data:")) return p
209
+ if (spec.multimodal && RASTER_IMAGE_URL.test(url)) return p
210
+ msgChanged = true
211
+ const reason = spec.multimodal
212
+ ? `unsupported format ${url.match(/^data:([^;,]+)/)?.[1] || "unknown"}`
213
+ : "this model does not support image input"
214
+ return { type: "text", text: `[image omitted — ${reason}]` }
215
+ })
216
+ if (!msgChanged) return m
195
217
  changed = true
196
- return {
197
- ...m,
198
- content: m.content.map((p) =>
199
- p?.type === "image_url" ? { type: "text", text: "[image omitted — this model does not support image input]" } : p),
200
- }
218
+ return { ...m, content: parts }
201
219
  })
202
220
  return changed ? out : messages
203
221
  }
@@ -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
@@ -75,7 +75,10 @@ export const readTool = {
75
75
 
76
76
  // ---------------------------------------------------------------- read_image
77
77
 
78
- const IMAGE_EXTENSIONS = { png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp", bmp: "image/bmp", svg: "image/svg+xml" }
78
+ // Raster formats only every mainstream vision API (Kimi, Anthropic, OpenAI, Gemini)
79
+ // rejects svg/bmp. svg is served as text source below; bmp is refused with a hint.
80
+ const IMAGE_EXTENSIONS = { png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp" }
81
+ const MAX_SVG_CHARS = 100_000
79
82
 
80
83
  export const readImageTool = {
81
84
  name: "read_image",
@@ -83,7 +86,7 @@ export const readImageTool = {
83
86
  parameters: {
84
87
  type: "object",
85
88
  properties: {
86
- path: { type: "string", description: "Path to image file (relative to cwd or absolute). Supports png, jpg, gif, webp, bmp, svg." },
89
+ path: { type: "string", description: "Path to image file (relative to cwd or absolute). Supports png, jpg, gif, webp. svg files are returned as text source (no vision API accepts svg)." },
87
90
  },
88
91
  required: ["path"],
89
92
  },
@@ -91,6 +94,19 @@ export const readImageTool = {
91
94
  multimodal: true, // returns JSON { text, images } — agent loop converts to multimodal user message
92
95
  /** Returns JSON: { text, images }, for the agent layer to convert into multimodal user messages */
93
96
  async execute(args, ctx) {
97
+ const abs = resolveInCwd(ctx, args.path)
98
+ const ext = abs.slice(abs.lastIndexOf(".") + 1).toLowerCase()
99
+
100
+ // SVG is text markup — return the source directly. Works with text-only models too
101
+ // (no vision gate), and never poisons history with an image part the API will 400 on.
102
+ if (ext === "svg") {
103
+ const st = await stat(abs).catch(() => null)
104
+ if (st && st.size > MAX_IMAGE_BYTES) throw new Error(`Image too large: ${Math.round(st.size / 1_000_000)}MB (max 15MB)`)
105
+ const src = normalizeEOL(await readFile(abs, "utf8"))
106
+ return `[read_image: ${args.path} (svg source, ${src.length} chars — no vision API accepts image/svg+xml, showing markup instead)]\n` +
107
+ truncate(src, MAX_SVG_CHARS)
108
+ }
109
+
94
110
  // Vision capability gate: injecting an image into a text-only model's history poisons the whole
95
111
  // conversation (every subsequent request 400s on the image part). Refuse before reading the file.
96
112
  const model = ctx.agent?.provider?.model
@@ -100,10 +116,11 @@ export const readImageTool = {
100
116
  `Verify visual output programmatically (file size, dimensions, pixel checks via code) or ask the user to switch to a vision-capable provider.`
101
117
  )
102
118
  }
103
- const abs = resolveInCwd(ctx, args.path)
104
- const ext = abs.slice(abs.lastIndexOf(".") + 1).toLowerCase()
105
119
  const mime = IMAGE_EXTENSIONS[ext]
106
- if (!mime) throw new Error(`Unsupported image format: .${ext}. Supported: ${Object.keys(IMAGE_EXTENSIONS).join(", ")}`)
120
+ if (!mime) {
121
+ const hint = ext === "bmp" ? " Convert it to PNG first (no mainstream vision API accepts BMP)." : ""
122
+ throw new Error(`Unsupported image format: .${ext}. Supported: ${Object.keys(IMAGE_EXTENSIONS).join(", ")}, svg (as text source).${hint}`)
123
+ }
107
124
  // Check size before reading — prevent huge images from blowing up memory (20MB base64 ≈ 15MB raw)
108
125
  const imgStat = await stat(abs).catch(() => null)
109
126
  if (imgStat && imgStat.size > MAX_IMAGE_BYTES) throw new Error(`Image too large: ${Math.round(imgStat.size / 1_000_000)}MB (max 15MB)`)
@@ -1,7 +1,8 @@
1
- Read an image file and return it as multimodal content visible to the model. Use this to view screenshots, UI mockups, diagrams, or any visual content. The model only sees images through this tool — it cannot "see" files directly. Supports png, jpg, gif, webp, bmp, svg. The image is base64-encoded and included in the response. Large images (>20MB) are rejected.
1
+ Read an image file and return it as multimodal content visible to the model. Use this to view screenshots, UI mockups, diagrams, or any visual content. The model only sees images through this tool — it cannot "see" files directly. Supports png, jpg, gif, webp. The image is base64-encoded and included in the response. Large images (>20MB) are rejected.
2
2
 
3
3
  Parameters:
4
- - path (required): Path to image file (relative to cwd or absolute). Supports png, jpg, gif, webp, bmp, svg.
4
+ - path (required): Path to image file (relative to cwd or absolute). Supports png, jpg, gif, webp; svg is returned as text source.
5
5
 
6
6
  Notes:
7
- - This tool only works with models that support vision/image input (Kimi K3, Qwen3.7, MiniMax M3). Pure text models (DeepSeek V4, GLM-5) will receive an error.
7
+ - Raster formats only (png/jpg/gif/webp): no mainstream vision API (Kimi, Anthropic, OpenAI, Gemini) accepts svg or bmp, and an unsupported image part in history makes every subsequent request fail with 400. svg files are returned as text source instead (readable by any model); bmp is rejected — convert to PNG first.
8
+ - This tool only works with models that support vision/image input (Kimi K3, Qwen3.7, MiniMax M3). Pure text models (DeepSeek V4, GLM-5) will receive an error — except svg, which needs no vision support since it is read as text.
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()