thincoder 0.12.20 → 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 +6 -0
- package/package.json +1 -1
- package/src/config.mjs +4 -0
- package/src/provider/sse.mjs +20 -2
- package/src/tools/web.mjs +46 -1
- package/src/tui/startup.mjs +29 -5
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
package/src/config.mjs
CHANGED
package/src/provider/sse.mjs
CHANGED
|
@@ -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
|
-
|
|
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()
|
package/src/tui/startup.mjs
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { listSlots } from "../session.mjs"
|
|
2
|
-
import { sliceByWidth } from "./render.mjs"
|
|
3
2
|
import { ansi, C } from "./ansi.mjs"
|
|
4
3
|
|
|
5
4
|
/** Lazy history window (parity with VS Code HISTORY_PAGE_SIZE): first paint loads
|
|
@@ -15,6 +14,16 @@ export const HISTORY_PAGE_MESSAGES = 50
|
|
|
15
14
|
*/
|
|
16
15
|
export function historyToLines(history, startIdx, endIdx) {
|
|
17
16
|
const lines = []
|
|
17
|
+
// Cross-page turn state: if the message BEFORE this page is a tool/assistant,
|
|
18
|
+
// the page starts mid-turn and must NOT emit a fresh "❯ ThinCoder:" label
|
|
19
|
+
// (a turn gets ONE label in the live run; history stores one assistant
|
|
20
|
+
// message per LLM call, so a multi-call turn would otherwise paint a label
|
|
21
|
+
// on every segment — the reported "why so many ❯ ThinCoder:" bug).
|
|
22
|
+
let inTurn = false
|
|
23
|
+
if (startIdx > 0) {
|
|
24
|
+
const prev = history[startIdx - 1]
|
|
25
|
+
inTurn = prev?.role === "assistant" || prev?.role === "tool"
|
|
26
|
+
}
|
|
18
27
|
for (let i = startIdx; i < endIdx; i++) {
|
|
19
28
|
const m = history[i]
|
|
20
29
|
if (m.role === "user") {
|
|
@@ -22,15 +31,30 @@ export function historyToLines(history, startIdx, endIdx) {
|
|
|
22
31
|
if (lines.length > 0) lines.push({ text: "", color: C.dim })
|
|
23
32
|
lines.push({ text: "❯ You:", color: ansi.bold + C.user })
|
|
24
33
|
if (typeof m.content === "string" && m.content) lines.push({ text: m.content, color: C.text })
|
|
34
|
+
inTurn = false
|
|
25
35
|
} else if (m.role === "assistant") {
|
|
26
|
-
if (
|
|
27
|
-
|
|
36
|
+
if (!inTurn) {
|
|
37
|
+
// Turn start — the only place the assistant label is emitted.
|
|
38
|
+
if (lines.length > 0) lines.push({ text: "", color: C.dim })
|
|
39
|
+
lines.push({ text: "❯ ThinCoder:", color: ansi.bold + C.assistant })
|
|
40
|
+
}
|
|
41
|
+
inTurn = true
|
|
42
|
+
// Reasoning restored as dim lines (folded by the consecutive-dim rule when
|
|
43
|
+
// long) — matches the live thinking stream instead of vanishing on restore.
|
|
44
|
+
const reasoning = m.reasoning_content ?? m.reasoning
|
|
45
|
+
if (typeof reasoning === "string" && reasoning.trim()) {
|
|
46
|
+
for (const line of reasoning.split("\n")) lines.push({ text: " " + line, color: C.dim })
|
|
47
|
+
}
|
|
28
48
|
if (typeof m.content === "string" && m.content) lines.push({ text: m.content, color: C.text })
|
|
29
49
|
for (const tc of m.tool_calls ?? []) {
|
|
30
50
|
const toolResult = history[i + 1]
|
|
31
51
|
const hasResult = toolResult?.role === "tool" && toolResult?.tool_call_id === tc.id
|
|
32
|
-
|
|
33
|
-
|
|
52
|
+
lines.push({ text: ` [tool] ${tc.function?.name ?? "?"}`, color: C.tool })
|
|
53
|
+
if (hasResult && String(toolResult.content).trim()) {
|
|
54
|
+
// FULL tool result as dim lines (auto-folded when > 8) — the old
|
|
55
|
+
// first-line-only summary made restore feel nothing like the live run.
|
|
56
|
+
for (const line of String(toolResult.content).split("\n")) lines.push({ text: " " + line, color: C.dim })
|
|
57
|
+
}
|
|
34
58
|
}
|
|
35
59
|
}
|
|
36
60
|
}
|