@thincoder/core 0.9.1
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/abort-provenance.mjs +116 -0
- package/advisor/citations.mjs +139 -0
- package/advisor/compaction.mjs +174 -0
- package/advisor/convergence.mjs +80 -0
- package/advisor/history.mjs +77 -0
- package/advisor/loop.mjs +288 -0
- package/advisor/messages.mjs +299 -0
- package/advisor/notice.mjs +141 -0
- package/advisor/project-context.mjs +197 -0
- package/advisor/repos.mjs +150 -0
- package/advisor/run.mjs +190 -0
- package/advisor/truncate.mjs +57 -0
- package/advisor.mjs +281 -0
- package/agent/child-marks.mjs +24 -0
- package/agent/completion.mjs +145 -0
- package/agent/dispatch.mjs +493 -0
- package/agent/family-tools.mjs +174 -0
- package/agent/helpers.mjs +412 -0
- package/agent/post-turn.mjs +70 -0
- package/agent/record-results.mjs +174 -0
- package/agent/relay-prefix.mjs +39 -0
- package/agent/run-stages.mjs +244 -0
- package/agent/setup-reminders.mjs +199 -0
- package/agent/setup.mjs +234 -0
- package/agent/spawn-child.mjs +258 -0
- package/agent/suspension.mjs +240 -0
- package/agent/write-gate.mjs +87 -0
- package/agent-tools/advisor-async.mjs +481 -0
- package/agent-tools/advisor-settle.mjs +240 -0
- package/agent-tools/advisor.mjs +280 -0
- package/agent-tools/async-discard.mjs +143 -0
- package/agent-tools/async-settle.mjs +299 -0
- package/agent-tools/batch-segment.mjs +265 -0
- package/agent-tools/child-permission.mjs +45 -0
- package/agent-tools/consult.mjs +471 -0
- package/agent-tools/design-token.mjs +117 -0
- package/agent-tools/digest-budget.mjs +76 -0
- package/agent-tools/eng.mjs +102 -0
- package/agent-tools/escalate-async.mjs +302 -0
- package/agent-tools/goal.mjs +119 -0
- package/agent-tools/panel-blocks.mjs +24 -0
- package/agent-tools/parent-channel.mjs +231 -0
- package/agent-tools/plan.mjs +86 -0
- package/agent-tools/read-history.mjs +309 -0
- package/agent-tools/recent-changes.mjs +24 -0
- package/agent-tools/review-facts.mjs +31 -0
- package/agent-tools/settings.mjs +268 -0
- package/agent-tools/skill.mjs +63 -0
- package/agent-tools/spawn-gates.mjs +109 -0
- package/agent-tools/subagent-actions.mjs +496 -0
- package/agent-tools/subagent-async.mjs +456 -0
- package/agent-tools/subagent-panel.mjs +160 -0
- package/agent-tools/subagent-run.mjs +208 -0
- package/agent-tools/subagent-scheduler.mjs +446 -0
- package/agent-tools/subagent-spawn.mjs +478 -0
- package/agent-tools/subagent.mjs +419 -0
- package/agent-tools/task.mjs +87 -0
- package/agent-tools/timer.mjs +46 -0
- package/agent-tools/verify.mjs +295 -0
- package/agent-tools.mjs +23 -0
- package/agent.mjs +430 -0
- package/auto-think.mjs +115 -0
- package/compress-form.mjs +24 -0
- package/config-io.mjs +277 -0
- package/config-migrate.mjs +178 -0
- package/config-presets.mjs +49 -0
- package/config.mjs +419 -0
- package/context.mjs +495 -0
- package/conventions.mjs +223 -0
- package/embedding.mjs +120 -0
- package/escape.mjs +152 -0
- package/expand-home.mjs +16 -0
- package/explore-distill.mjs +152 -0
- package/generate-title.mjs +123 -0
- package/git/checkpoint.mjs +448 -0
- package/git/gitmem.mjs +100 -0
- package/history-window.mjs +179 -0
- package/hooks.mjs +108 -0
- package/i18n.mjs +106 -0
- package/index-bin.mjs +48 -0
- package/index-discover.mjs +176 -0
- package/ledger-cmd.mjs +209 -0
- package/ledger-db.mjs +86 -0
- package/ledger-surface.mjs +76 -0
- package/ledger.mjs +202 -0
- package/log.mjs +195 -0
- package/manifest.mjs +338 -0
- package/markdown.mjs +106 -0
- package/mcp/helpers.mjs +51 -0
- package/mcp/transport-http.mjs +248 -0
- package/mcp/transport-stdio.mjs +140 -0
- package/mcp/transport-ws.mjs +122 -0
- package/mcp.mjs +295 -0
- package/memory/code-index.mjs +219 -0
- package/memory/code-sync.mjs +427 -0
- package/memory/core.mjs +318 -0
- package/memory/delete.mjs +242 -0
- package/memory/docs.mjs +431 -0
- package/memory/file-walk.mjs +109 -0
- package/memory/origin.mjs +24 -0
- package/memory/scan.mjs +177 -0
- package/memory/schema.mjs +460 -0
- package/memory.mjs +21 -0
- package/model-ref.mjs +66 -0
- package/model-specs.mjs +277 -0
- package/package.json +32 -0
- package/peer-domains.mjs +265 -0
- package/peer-instances.mjs +178 -0
- package/permission.mjs +79 -0
- package/process-probe.mjs +315 -0
- package/prompt-files.mjs +113 -0
- package/prompt-overlays.mjs +78 -0
- package/prompts/advisor-design.md +43 -0
- package/prompts/advisor-round1.md +41 -0
- package/prompts/advisor-round2.md +46 -0
- package/prompts/advisor-round3.md +42 -0
- package/prompts/common.md +158 -0
- package/prompts/consult-base.md +19 -0
- package/prompts/discipline-engineering.md +123 -0
- package/prompts/discipline-normal.md +206 -0
- package/prompts/persona-coder.md +21 -0
- package/prompts/persona-eng-coder.md +41 -0
- package/prompts/persona-eng-designer.md +80 -0
- package/prompts/persona-engineering.md +160 -0
- package/prompts/persona-explore.md +15 -0
- package/prompts/persona-normal.md +35 -0
- package/prompts/persona-plan.md +27 -0
- package/provider/anthropic.mjs +225 -0
- package/provider/core.mjs +491 -0
- package/provider/errors.mjs +101 -0
- package/provider/google.mjs +257 -0
- package/provider/index.mjs +7 -0
- package/provider/list-models.mjs +163 -0
- package/provider/normalize.mjs +81 -0
- package/provider/rate.mjs +168 -0
- package/provider/responses.mjs +495 -0
- package/provider/retry.mjs +88 -0
- package/provider/sse.mjs +264 -0
- package/provider/wait-status.mjs +59 -0
- package/proxy.mjs +274 -0
- package/rules.mjs +53 -0
- package/session-gc.mjs +248 -0
- package/session-guard.mjs +59 -0
- package/session-lifecycle.mjs +305 -0
- package/session-migrate.mjs +48 -0
- package/session-rename.mjs +38 -0
- package/session-segments.mjs +99 -0
- package/session-slot-write.mjs +168 -0
- package/session-slots-manifest.mjs +264 -0
- package/session-slots.mjs +298 -0
- package/session-store.mjs +441 -0
- package/session.mjs +244 -0
- package/skills.mjs +234 -0
- package/text-budget.mjs +79 -0
- package/token-ttl.mjs +285 -0
- package/tool-docs/apply_patch.md +15 -0
- package/tool-docs/bash.md +38 -0
- package/tool-docs/delete.md +13 -0
- package/tool-docs/edit.md +30 -0
- package/tool-docs/execute.md +21 -0
- package/tool-docs/fetch.md +12 -0
- package/tool-docs/file_ops.md +17 -0
- package/tool-docs/get_current_time.md +8 -0
- package/tool-docs/git.md +54 -0
- package/tool-docs/glob.md +11 -0
- package/tool-docs/grep.md +19 -0
- package/tool-docs/hashline_edit.md +14 -0
- package/tool-docs/insert_after.md +15 -0
- package/tool-docs/lint.md +10 -0
- package/tool-docs/ls.md +12 -0
- package/tool-docs/lsp.md +10 -0
- package/tool-docs/process.md +10 -0
- package/tool-docs/question.md +16 -0
- package/tool-docs/read.md +20 -0
- package/tool-docs/read_image.md +8 -0
- package/tool-docs/tree.md +14 -0
- package/tool-docs/wait_for.md +22 -0
- package/tool-docs/websearch.md +16 -0
- package/tool-docs/write.md +11 -0
- package/tools/bash.mjs +276 -0
- package/tools/edit-batch.mjs +204 -0
- package/tools/edit-diff.mjs +388 -0
- package/tools/exec-run.mjs +43 -0
- package/tools/execute.mjs +243 -0
- package/tools/file.mjs +464 -0
- package/tools/git-checkpoint.mjs +143 -0
- package/tools/git-ext.mjs +173 -0
- package/tools/git.mjs +415 -0
- package/tools/glob-dialect.mjs +130 -0
- package/tools/index.mjs +76 -0
- package/tools/linter.mjs +120 -0
- package/tools/lsp.mjs +335 -0
- package/tools/ops.mjs +293 -0
- package/tools/patch.mjs +290 -0
- package/tools/question.mjs +26 -0
- package/tools/repomap.mjs +314 -0
- package/tools/search.mjs +248 -0
- package/tools/shared.mjs +467 -0
- package/tools/tree.mjs +81 -0
- package/tools/web.mjs +224 -0
- package/tools/write-path.mjs +191 -0
- package/traces/trace-store.mjs +303 -0
- package/undo-stack.mjs +47 -0
package/provider/sse.mjs
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* provider/sse.mjs — SSE stream reader
|
|
3
|
+
* Extracted from core.mjs. Parses Server-Sent Events for LLM chat responses.
|
|
4
|
+
*/
|
|
5
|
+
import { abortError, timeoutError } from "../abort-provenance.mjs"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Normalize provider cache fields into DeepSeek-style prompt_cache_hit/miss_tokens.
|
|
9
|
+
* DeepSeek already returns these; OpenAI/Kimi report the cache hit as
|
|
10
|
+
* prompt_tokens_details.cached_tokens; a few providers put cached_tokens at the
|
|
11
|
+
* usage top level. Miss is derived as prompt_tokens - hit when not reported.
|
|
12
|
+
*/
|
|
13
|
+
export function normalizeUsageCache(u) {
|
|
14
|
+
if (!u || u.prompt_cache_hit_tokens !== undefined) return u
|
|
15
|
+
const cached = u.prompt_tokens_details?.cached_tokens ?? u.cached_tokens
|
|
16
|
+
if (cached === undefined) return u
|
|
17
|
+
u.prompt_cache_hit_tokens = cached
|
|
18
|
+
if (u.prompt_cache_miss_tokens === undefined && typeof u.prompt_tokens === "number") {
|
|
19
|
+
u.prompt_cache_miss_tokens = Math.max(0, u.prompt_tokens - cached)
|
|
20
|
+
}
|
|
21
|
+
return u
|
|
22
|
+
}
|
|
23
|
+
/** Defensive tool-call merge (PROVIDER.md §10): skip null/malformed elements and count them;
|
|
24
|
+
* merge slots by index / id / name / tail, accumulate arguments. */
|
|
25
|
+
function mergeToolCalls(result, delta) {
|
|
26
|
+
for (const tc of delta.tool_calls ?? []) {
|
|
27
|
+
if (!tc || typeof tc !== "object") { result.droppedToolCalls++; continue }
|
|
28
|
+
let slot
|
|
29
|
+
if (Number.isInteger(tc.index) && tc.index >= 0) {
|
|
30
|
+
slot = (result.toolCalls[tc.index] ??= { id: "", name: "", arguments: "" })
|
|
31
|
+
} else if (tc.id) {
|
|
32
|
+
slot = result.toolCalls.find((s) => s && s.id === tc.id)
|
|
33
|
+
if (!slot) { slot = { id: tc.id, name: "", arguments: "" }; result.toolCalls.push(slot) }
|
|
34
|
+
} else if (tc.function?.name) {
|
|
35
|
+
slot = { id: "", name: "", arguments: "" }
|
|
36
|
+
result.toolCalls.push(slot)
|
|
37
|
+
} else {
|
|
38
|
+
slot = result.toolCalls[result.toolCalls.length - 1]
|
|
39
|
+
if (!slot) { result.droppedToolCalls++; continue }
|
|
40
|
+
}
|
|
41
|
+
if (tc.id && !slot.id) slot.id = tc.id
|
|
42
|
+
if (tc.function?.name && !slot.name) slot.name = tc.function.name
|
|
43
|
+
const arg = tc.function?.arguments
|
|
44
|
+
if (typeof arg === "string") slot.arguments += arg
|
|
45
|
+
else if (arg != null) slot.arguments += JSON.stringify(arg)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Finalize tool calls (PROVIDER.md §10): drop nameless slots, synthesize missing ids,
|
|
50
|
+
* count drops, and surface a machine-line warning via the existing `_warnings` channel. */
|
|
51
|
+
function finalizeToolCalls(result) {
|
|
52
|
+
const entries = result.toolCalls.filter((tc) => tc) // drop sparse holes (rule-1 index jumps)
|
|
53
|
+
const kept = entries.filter((tc) => tc.name) // drop nameless slots
|
|
54
|
+
result.droppedToolCalls = (result.droppedToolCalls ?? 0) + (entries.length - kept.length)
|
|
55
|
+
result.toolCalls = kept
|
|
56
|
+
const used = new Set(kept.map((tc) => tc.id).filter(Boolean))
|
|
57
|
+
let seq = 0
|
|
58
|
+
for (const tc of kept) {
|
|
59
|
+
if (!tc.id) {
|
|
60
|
+
let id
|
|
61
|
+
do { id = `call_${seq++}` } while (used.has(id))
|
|
62
|
+
tc.id = id
|
|
63
|
+
used.add(id)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (result.droppedToolCalls > 0) {
|
|
67
|
+
const existing = (result._warnings ??= [])
|
|
68
|
+
if (!existing.some((w) => w.name === "malformed-tool-calls")) {
|
|
69
|
+
existing.push({ name: "malformed-tool-calls", message: `${result.droppedToolCalls} malformed tool_calls dropped from provider response` })
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function readSSE(response, { onToken, onReasoning, rules, signal, firedPatterns: sharedFired }) {
|
|
75
|
+
// Early intercept: non-SSE responses — either error bodies (HTTP >= 400) or
|
|
76
|
+
// valid single-chunk JSON completions (some APIs return JSON despite stream:true).
|
|
77
|
+
const contentType = response.headers.get("content-type") || ""
|
|
78
|
+
if (!contentType.includes("event-stream")) {
|
|
79
|
+
const body = await response.text().catch(() => "")
|
|
80
|
+
if (response.status >= 400) {
|
|
81
|
+
let errorMsg = ""
|
|
82
|
+
try {
|
|
83
|
+
const parsed = JSON.parse(body)
|
|
84
|
+
errorMsg = parsed?.error?.message
|
|
85
|
+
|| parsed?.base_resp?.status_msg
|
|
86
|
+
|| parsed?.detail
|
|
87
|
+
|| parsed?.message
|
|
88
|
+
|| parsed?.msg
|
|
89
|
+
|| (typeof parsed.error === "string" ? parsed.error : "")
|
|
90
|
+
} catch { /* not JSON */ }
|
|
91
|
+
if (!errorMsg) errorMsg = body.slice(0, 500)
|
|
92
|
+
throw new Error(`API error: HTTP ${response.status} — ${errorMsg}`)
|
|
93
|
+
}
|
|
94
|
+
// HTTP < 400 non-SSE: might be a valid single-chunk JSON response (e.g. proxy
|
|
95
|
+
// stripped SSE framing). Parse `choice.message` (full chat.completion) OR
|
|
96
|
+
// `choice.delta` (chunk shape) — 2026-08-31: gateways downgrading stream:true
|
|
97
|
+
// to a complete completion used to return EMPTY content silently (message-only shape).
|
|
98
|
+
try {
|
|
99
|
+
const parsed = JSON.parse(body)
|
|
100
|
+
const choice = parsed.choices?.[0]
|
|
101
|
+
if (choice) {
|
|
102
|
+
const result = { content: "", reasoning: "", toolCalls: [], droppedToolCalls: 0, usage: normalizeUsageCache(parsed.usage ?? null), finishReason: null }
|
|
103
|
+
const delta = choice.delta ?? choice.message ?? {}
|
|
104
|
+
result.content = delta.content ?? ""
|
|
105
|
+
result.reasoning = delta.reasoning_content ?? delta.reasoning ?? ""
|
|
106
|
+
result.finishReason = choice.finish_reason ?? null
|
|
107
|
+
mergeToolCalls(result, delta)
|
|
108
|
+
if (result.content) onToken?.(result.content)
|
|
109
|
+
if (result.reasoning) onReasoning?.(result.reasoning)
|
|
110
|
+
finalizeToolCalls(result)
|
|
111
|
+
return result
|
|
112
|
+
}
|
|
113
|
+
} catch { /* not parseable JSON */ }
|
|
114
|
+
// Not an error response but not a valid chunk either — unexpected
|
|
115
|
+
throw new Error(`API error: HTTP ${response.status} — unexpected non-SSE response: ${body.slice(0, 200)}`)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const result = { content: "", reasoning: "", toolCalls: [], droppedToolCalls: 0, usage: null, finishReason: null }
|
|
119
|
+
const decoder = new TextDecoder()
|
|
120
|
+
let buffer = ""
|
|
121
|
+
let hasChoices = false
|
|
122
|
+
const firedPatterns = sharedFired ?? new Set()
|
|
123
|
+
|
|
124
|
+
/** Process one complete SSE event (data lines joined with \n per the SSE spec).
|
|
125
|
+
* 2026-08-31: rows are buffered per event (multi-line data: support that the
|
|
126
|
+
* per-line JSON.parse used to crash on — single-line events behave identically). */
|
|
127
|
+
const handleEvent = (data) => {
|
|
128
|
+
if (!data || data === "[DONE]") return
|
|
129
|
+
let json
|
|
130
|
+
try { json = JSON.parse(data) } catch { return }
|
|
131
|
+
|
|
132
|
+
if (json.usage) result.usage = normalizeUsageCache(json.usage)
|
|
133
|
+
const choice = json.choices?.[0]
|
|
134
|
+
if (!choice) return
|
|
135
|
+
hasChoices = true
|
|
136
|
+
if (choice.finish_reason) result.finishReason = choice.finish_reason
|
|
137
|
+
|
|
138
|
+
const delta = choice.delta ?? choice.message ?? {}
|
|
139
|
+
// reasoning 方言:DeepSeek/Kimi/GLM 用 reasoning_content,OpenAI o 系和部分
|
|
140
|
+
// 路由器用 reasoning —— 两个都认(2026-08-31 会诊 #9)
|
|
141
|
+
const rDelta = delta.reasoning_content ?? delta.reasoning
|
|
142
|
+
if (rDelta) {
|
|
143
|
+
result.reasoning += rDelta
|
|
144
|
+
onReasoning?.(rDelta)
|
|
145
|
+
}
|
|
146
|
+
if (delta.content) {
|
|
147
|
+
result.content += delta.content
|
|
148
|
+
onToken?.(delta.content)
|
|
149
|
+
}
|
|
150
|
+
mergeToolCalls(result, delta)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const processLines = (lines) => {
|
|
154
|
+
let currentData = ""
|
|
155
|
+
for (const line of lines) {
|
|
156
|
+
if (line.startsWith("data:")) {
|
|
157
|
+
const v = line.slice(5).trim()
|
|
158
|
+
if (v === "[DONE]") { currentData = ""; continue }
|
|
159
|
+
// multi-line data: joins with \n (SSE spec); single-line is the common case
|
|
160
|
+
currentData = currentData ? currentData + "\n" + v : v
|
|
161
|
+
continue
|
|
162
|
+
}
|
|
163
|
+
if (line === "" && currentData) { handleEvent(currentData); currentData = "" }
|
|
164
|
+
}
|
|
165
|
+
if (currentData) handleEvent(currentData)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (!response.body) throw new Error("No stream response body")
|
|
169
|
+
// 2026-09-01 读侧 idle 超时(根因修复):原实现靠 fetch 层的 600s 绝对墙钟兜底——长上下文子代理
|
|
170
|
+
// 单次生成(或上游排队)超 10 分钟即被腰斩("The operation was aborted due to timeout" 直透)。
|
|
171
|
+
// 现改为:读侧空闲超时——body 只要有数据流动就永不超时,连续 READ_IDLE_MS 无新 chunk 才判死。
|
|
172
|
+
// 直连 fetch 与 proxyFetch 统一走这里(proxy 的 _bodyIdleMs 语义与之等价,双保险)。
|
|
173
|
+
const READ_IDLE_MS = 120_000
|
|
174
|
+
let idleTimer = null
|
|
175
|
+
const armIdle = () => {
|
|
176
|
+
if (idleTimer) clearTimeout(idleTimer)
|
|
177
|
+
idleTimer = setTimeout(() => {
|
|
178
|
+
try { response.body?.destroy(timeoutError(`SSE idle timeout: no data for ${READ_IDLE_MS / 1000}s`, "provider", "sse-idle")) } catch { /* already gone */ }
|
|
179
|
+
}, READ_IDLE_MS)
|
|
180
|
+
idleTimer.unref?.()
|
|
181
|
+
}
|
|
182
|
+
armIdle()
|
|
183
|
+
try {
|
|
184
|
+
for await (const chunk of response.body) {
|
|
185
|
+
armIdle()
|
|
186
|
+
if (signal?.aborted) {
|
|
187
|
+
throw abortError(signal, "provider", "stream-read")
|
|
188
|
+
}
|
|
189
|
+
buffer += decoder.decode(chunk, { stream: true })
|
|
190
|
+
// UTF-8 BOM 剥除(2026-08-31 会诊 #12):某些网关/负载均衡在流首注入 \uFEFF,
|
|
191
|
+
// 首行 "data:" 前缀匹配失败会被静默丢弃(首个事件整体消失)。
|
|
192
|
+
if (buffer.charCodeAt(0) === 0xfeff) buffer = buffer.slice(1)
|
|
193
|
+
const lines = buffer.split("\n")
|
|
194
|
+
buffer = lines.pop()
|
|
195
|
+
processLines(lines)
|
|
196
|
+
|
|
197
|
+
if (rules?.length && result.content && !result.toolCalls.length) {
|
|
198
|
+
for (const rule of rules) {
|
|
199
|
+
if (rule.repeat === "once" && firedPatterns.has(rule.pattern)) continue
|
|
200
|
+
if (rule._regex.test(result.content)) {
|
|
201
|
+
if (rule.repeat === "once") firedPatterns.add(rule.pattern)
|
|
202
|
+
if (rule.action === "abort") {
|
|
203
|
+
result.ruleTriggered = true
|
|
204
|
+
result.ruleMessage = rule.message
|
|
205
|
+
result.ruleName = rule.name
|
|
206
|
+
if (idleTimer) clearTimeout(idleTimer)
|
|
207
|
+
return result
|
|
208
|
+
}
|
|
209
|
+
const existing = result._warnings ??= []
|
|
210
|
+
if (!existing.some(w => w.pattern === rule.pattern)) {
|
|
211
|
+
existing.push({ name: rule.name, pattern: rule.pattern, message: rule.message })
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
buffer += decoder.decode()
|
|
218
|
+
processLines(buffer.split("\n"))
|
|
219
|
+
} catch (e) {
|
|
220
|
+
if (idleTimer) clearTimeout(idleTimer)
|
|
221
|
+
if (e.name === "AbortError" && signal?.reason?.interrupt) {
|
|
222
|
+
result.interrupted = true
|
|
223
|
+
result.interruptMessage = signal.reason.message
|
|
224
|
+
if (idleTimer) clearTimeout(idleTimer)
|
|
225
|
+
return result
|
|
226
|
+
}
|
|
227
|
+
// 2026-08-31 会诊 #2(流中断丢全部已收内容):网络级失败(ECONNRESET / 半截 EOF /
|
|
228
|
+
// proxy 断连)时若已解析出内容,把 partial 交回上层而不是整轮报废重试。
|
|
229
|
+
// 标记 partial:true + networkError(上层可决定续写/重试/展示部分结果)。
|
|
230
|
+
if (hasChoices && (result.content || result.toolCalls.length)) {
|
|
231
|
+
finalizeToolCalls(result)
|
|
232
|
+
result.partial = true
|
|
233
|
+
result.networkError = e.message ?? String(e)
|
|
234
|
+
const existing = result._warnings ??= []
|
|
235
|
+
if (!existing.some((w) => w.name === "network-partial")) {
|
|
236
|
+
existing.push({ name: "network-partial", message: `stream interrupted by network error after partial output: ${result.networkError}` })
|
|
237
|
+
}
|
|
238
|
+
return result
|
|
239
|
+
}
|
|
240
|
+
throw e
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (idleTimer) clearTimeout(idleTimer)
|
|
244
|
+
|
|
245
|
+
if (!hasChoices) {
|
|
246
|
+
// Stream started as SSE but no choices were parsed — unusual. Include status for debugging.
|
|
247
|
+
const raw = buffer.trim()
|
|
248
|
+
let errorMsg = ""
|
|
249
|
+
try {
|
|
250
|
+
const parsed = raw ? JSON.parse(raw) : null
|
|
251
|
+
errorMsg = parsed?.error?.message
|
|
252
|
+
|| parsed?.base_resp?.status_msg
|
|
253
|
+
|| parsed?.detail
|
|
254
|
+
|| parsed?.message
|
|
255
|
+
|| parsed?.msg
|
|
256
|
+
|| (typeof parsed.error === "string" ? parsed.error : "")
|
|
257
|
+
} catch { /* not JSON */ }
|
|
258
|
+
if (!errorMsg) errorMsg = `SSE stream contained no choices (HTTP ${response.status})`
|
|
259
|
+
throw new Error(`API error: ${errorMsg}`)
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
finalizeToolCalls(result)
|
|
263
|
+
return result
|
|
264
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* wait-status.mjs — `onWait` 相位 → 状态文案(核内单源映射 · 批 1 CORE-DEFECT-FIXES B3)。
|
|
3
|
+
*
|
|
4
|
+
* 背景(`docs/core/design/PROVIDER.md` §6.20):`onWait` 载荷的相位值域只在**发射侧**
|
|
5
|
+
* 收敛(五相:`gate` / `retry` / `overloaded` 带 `seconds` · `warn` / `quota` 带
|
|
6
|
+
* `message`),三个消费点(CLI TUI 状态行 / headless stderr / ACP 日志)各自枚举相位 ⇒
|
|
7
|
+
* 未列相位落 `else` 兜底,读 `seconds` 渲染出字面 `undefined`
|
|
8
|
+
* (`Rate-limited 429, retry in undefineds`),`warn` / `quota` 还被误标 429。
|
|
9
|
+
*
|
|
10
|
+
* 本档 = 相位值域 + 文案的**单源**:消费面只调 `waitStatusText(ev)`,禁各自相位枚举 /
|
|
11
|
+
* 兜底分支(结构机检 = `thincoder-cli/test/wait-status-callsites.test.mjs`)。
|
|
12
|
+
*
|
|
13
|
+
* 判据(PROVIDER.md §6.20 映射表·逐相):
|
|
14
|
+
* gate 带 seconds → `status.rateWait` 显示
|
|
15
|
+
* retry 带 seconds → `status.rateLimited` 显示
|
|
16
|
+
* overloaded 带 seconds → `status.overloaded` 显示(不得落 429 文案)
|
|
17
|
+
* quota 带 message → `status.quota` 显示(剥发射回显前缀——防双前缀)
|
|
18
|
+
* warn 前置告警 → `null`(不显示——与 VSC 面 `statusTextPayload` 同判据)
|
|
19
|
+
* 未知相位 / 秒缺失 / 无可渲染载荷 → `null`(不虚构数值、不落兜底误标)
|
|
20
|
+
*/
|
|
21
|
+
import { t } from "../i18n.mjs"
|
|
22
|
+
|
|
23
|
+
/** 发射面回显前缀(`provider/retry.mjs:60` 的 `quota exhausted: `)——由 i18n 模板去占位符
|
|
24
|
+
* 派生(核内零第二套文案字面);发射侧恒为 en 字面,故剥前缀不随 locale 变。 */
|
|
25
|
+
const QUOTA_ECHO_PREFIX = t("status.quota", { msg: "" })
|
|
26
|
+
|
|
27
|
+
/** 秒值有效性:有限数才可渲染(缺字段 / null / 非数 ⇒ 不虚构数值)。 */
|
|
28
|
+
function hasSeconds(ev) {
|
|
29
|
+
return typeof ev?.seconds === "number" && Number.isFinite(ev.seconds)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 相位 → `{ kind, seconds | message }`(纯映射);`null` = 不显示。
|
|
34
|
+
* `kind` 词表与 VSC 面 `statusTextPayload` 的 kind 同名(两端同一词表)。
|
|
35
|
+
*/
|
|
36
|
+
export function waitStatusOf(ev) {
|
|
37
|
+
const phase = ev?.phase
|
|
38
|
+
if (phase === "quota") {
|
|
39
|
+
const raw = typeof ev?.message === "string" ? ev.message : ""
|
|
40
|
+
if (!raw.trim()) return null // 无可渲染载荷 ⇒ 不显示(不渲染空/undefined)
|
|
41
|
+
const stripped = raw.startsWith(QUOTA_ECHO_PREFIX) ? raw.slice(QUOTA_ECHO_PREFIX.length) : raw
|
|
42
|
+
return { kind: "quota", message: stripped }
|
|
43
|
+
}
|
|
44
|
+
if (phase === "gate" || phase === "retry" || phase === "overloaded") {
|
|
45
|
+
if (!hasSeconds(ev)) return null
|
|
46
|
+
const kind = phase === "gate" ? "rateWait" : phase === "retry" ? "rateLimited" : "overloaded"
|
|
47
|
+
return { kind, seconds: ev.seconds }
|
|
48
|
+
}
|
|
49
|
+
return null // warn(前置告警)/ 未知相位 ⇒ 不显示
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** 相位 → 文案(文案取自核 i18n `status.*` 单源);`null` = 消费点不显示、不打印。 */
|
|
53
|
+
export function waitStatusText(ev, locale = "en") {
|
|
54
|
+
const m = waitStatusOf(ev)
|
|
55
|
+
if (!m) return null
|
|
56
|
+
return m.kind === "quota"
|
|
57
|
+
? t("status.quota", { msg: m.message }, locale)
|
|
58
|
+
: t("status." + m.kind, { s: m.seconds }, locale)
|
|
59
|
+
}
|
package/proxy.mjs
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* proxy.mjs — Shared proxy tunnel for websearch, fetch, and provider calls.
|
|
3
|
+
* Zero dependencies: Node built-ins only (net, tls, http, url).
|
|
4
|
+
*/
|
|
5
|
+
import { connect } from "node:net";
|
|
6
|
+
import { connect as tlsConnect } from "node:tls";
|
|
7
|
+
import { PassThrough } from "node:stream";
|
|
8
|
+
import { URL } from "node:url";
|
|
9
|
+
import { abortError, timeoutError } from "./abort-provenance.mjs";
|
|
10
|
+
|
|
11
|
+
const FETCH_TIMEOUT = 15_000
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Resolve proxy URI.
|
|
15
|
+
* New format: { proxy: { uri: "http://host:port", web: true, model: false } }
|
|
16
|
+
* Old format: { proxy: "http://host:port" } — backwards compatible, web=true model=false
|
|
17
|
+
* Env vars: HTTPS_PROXY, HTTP_PROXY, ALL_PROXY
|
|
18
|
+
*
|
|
19
|
+
* @returns {{ uri: string|null, web: boolean, model: boolean }}
|
|
20
|
+
*/
|
|
21
|
+
export function resolveProxyConfig(ctx) {
|
|
22
|
+
const cfgProxy = ctx?.agent?.config?.proxy
|
|
23
|
+
if (!cfgProxy) {
|
|
24
|
+
const uri = process.env.HTTPS_PROXY || process.env.HTTP_PROXY || process.env.ALL_PROXY || null
|
|
25
|
+
return { uri, web: !!uri, model: false }
|
|
26
|
+
}
|
|
27
|
+
if (typeof cfgProxy === "string") {
|
|
28
|
+
// Backward compat: bare string → web only
|
|
29
|
+
return { uri: cfgProxy, web: true, model: false }
|
|
30
|
+
}
|
|
31
|
+
return {
|
|
32
|
+
uri: cfgProxy.uri || cfgProxy.url || null,
|
|
33
|
+
web: cfgProxy.web !== false,
|
|
34
|
+
model: cfgProxy.model === true,
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Convenience: resolve proxy URI for web tools (websearch/fetch) */
|
|
39
|
+
export function resolveWebProxy(ctx) {
|
|
40
|
+
const cfg = resolveProxyConfig(ctx)
|
|
41
|
+
return (cfg.uri && cfg.web) ? cfg.uri : null
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Inject the resolved proxy URI into each provider as provider.proxyUri
|
|
46
|
+
* (consumed by chat() in provider/core.mjs).
|
|
47
|
+
* Model 请求走代理需要双重开启:per-provider `proxy: true` 且全局 `config.proxy.model === true`(默认关)。
|
|
48
|
+
*/
|
|
49
|
+
export function injectProxy(providers, config) {
|
|
50
|
+
const { uri, model } = resolveProxyConfig({ agent: { config } })
|
|
51
|
+
for (const p of providers ?? []) {
|
|
52
|
+
p.proxyUri = p.proxy && model ? (uri ?? undefined) : undefined
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 在已建立的 socket 上发 HTTP 请求,响应头到齐即 resolve(流式)。
|
|
58
|
+
* 返回 Response-like: { ok, status, headers: Headers, body: PassThrough(异步迭代), text(): Promise<string> }
|
|
59
|
+
* body 边收边吐(SSE 流式消费方可逐 chunk 读取);text() 消费流到底(非流式调用方用)。
|
|
60
|
+
* opts.signal 全程有效:abort 即 destroy socket 并 reject/终止流。
|
|
61
|
+
* absoluteForm: 请求行发绝对 URI(http:// 目标的经典代理转发用),默认发 origin-form。
|
|
62
|
+
* 2026-08-31 会诊 #4:timeout 只覆盖"响应头阶段";settle 后 body 空闲 > bodyIdleMs 即
|
|
63
|
+
* 断流(原实现头部 timer 到齐即清,流式中途停摆会无限挂起直到用户 Ctrl+C)。
|
|
64
|
+
* bodyIdleMs 默认 120s(provider 长生成用 0 即禁用,由调用方决定)。
|
|
65
|
+
* 导出供测试(裸 socket,无需 TLS/CONNECT);生产路径走 tunnelHttps / proxyFetch。
|
|
66
|
+
*/
|
|
67
|
+
export function streamHttpResponse(sock, urlStr, opts = {}, timeout = FETCH_TIMEOUT, absoluteForm = false, bodyIdleMs = 120_000) {
|
|
68
|
+
return new Promise((resolve, reject) => {
|
|
69
|
+
const target = new URL(urlStr)
|
|
70
|
+
const method = opts.method ?? "GET"
|
|
71
|
+
const headers = opts.headers ?? {}
|
|
72
|
+
const signal = opts.signal
|
|
73
|
+
|
|
74
|
+
if (signal?.aborted) { sock.destroy(); return reject(abortError(signal, "provider", "proxy")) }
|
|
75
|
+
|
|
76
|
+
const body = new PassThrough()
|
|
77
|
+
let settled = false
|
|
78
|
+
let headerBuf = ""
|
|
79
|
+
let idleTimer = null
|
|
80
|
+
|
|
81
|
+
const timer = setTimeout(() => fail(timeoutError("Response timeout", "provider", "proxy-header")), timeout)
|
|
82
|
+
const onAbort = () => { sock.destroy(); fail(abortError(signal, "provider", "proxy")) }
|
|
83
|
+
signal?.addEventListener("abort", onAbort, { once: true })
|
|
84
|
+
|
|
85
|
+
function cleanup() {
|
|
86
|
+
clearTimeout(timer)
|
|
87
|
+
clearTimeout(idleTimer)
|
|
88
|
+
signal?.removeEventListener("abort", onAbort)
|
|
89
|
+
}
|
|
90
|
+
/** 头部阶段失败 reject;resolve 后失败则终止 body 流(for-await 抛出,不挂起) */
|
|
91
|
+
function fail(err) {
|
|
92
|
+
cleanup()
|
|
93
|
+
if (!settled) { settled = true; reject(err) }
|
|
94
|
+
else body.destroy(err)
|
|
95
|
+
}
|
|
96
|
+
/** body 阶段空闲看门狗:每次数据到达重置;无数据超时 → 断流(流式消费方抛错) */
|
|
97
|
+
function armIdle() {
|
|
98
|
+
clearTimeout(idleTimer)
|
|
99
|
+
if (bodyIdleMs > 0) idleTimer = setTimeout(() => body.destroy(timeoutError("Response body timeout (idle)", "provider", "proxy-body-idle")), bodyIdleMs)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
sock.on("data", (d) => {
|
|
103
|
+
if (settled) return // 理论上不会发生(settle 后摘掉本监听器),防御
|
|
104
|
+
headerBuf += d.toString("utf8")
|
|
105
|
+
const idx = headerBuf.indexOf("\r\n\r\n")
|
|
106
|
+
if (idx < 0) return
|
|
107
|
+
|
|
108
|
+
const headerText = headerBuf.slice(0, idx)
|
|
109
|
+
const statusMatch = headerText.match(/^HTTP\/\d\.\d (\d+)/)
|
|
110
|
+
const status = statusMatch ? Number(statusMatch[1]) : 502
|
|
111
|
+
const respHeaders = {}
|
|
112
|
+
for (const line of headerText.split("\r\n").slice(1)) {
|
|
113
|
+
const ci = line.indexOf(":")
|
|
114
|
+
if (ci > 0) respHeaders[line.slice(0, ci).trim().toLowerCase()] = line.slice(ci + 1).trim()
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// 头到齐:摘掉头阶段监听,剩余字节推入 body,后续数据直接 pipe
|
|
118
|
+
sock.removeAllListeners("data")
|
|
119
|
+
settled = true
|
|
120
|
+
cleanup()
|
|
121
|
+
armIdle()
|
|
122
|
+
const remaining = headerBuf.slice(idx + 4)
|
|
123
|
+
if (remaining) body.write(Buffer.from(remaining, "utf8"))
|
|
124
|
+
sock.pipe(body)
|
|
125
|
+
// 空闲看门狗随数据到达重置。注意必须用 'readable' 而非 'data':
|
|
126
|
+
// 'data' 监听把流切成 flowing 模式,会抢走 readSSE/for-await 未来得及认领的
|
|
127
|
+
// 已写缓冲(2026-08-31 实测:分包响应头的 remaining 首段被吞,readSSE 等不到 ack)。
|
|
128
|
+
// 'readable' 不改变流模式,数据仍由消费方按需拉取。
|
|
129
|
+
body.on("readable", armIdle)
|
|
130
|
+
// body 结束后才移除 abort 监听(流式中途 abort 要能终止流);空闲看门狗一并清
|
|
131
|
+
body.on("close", () => {
|
|
132
|
+
clearTimeout(idleTimer)
|
|
133
|
+
signal?.removeEventListener("abort", onAbort)
|
|
134
|
+
})
|
|
135
|
+
signal?.addEventListener("abort", onAbort, { once: true })
|
|
136
|
+
|
|
137
|
+
resolve({
|
|
138
|
+
ok: status >= 200 && status < 400,
|
|
139
|
+
status,
|
|
140
|
+
headers: new Headers(respHeaders),
|
|
141
|
+
body,
|
|
142
|
+
text: async () => {
|
|
143
|
+
const chunks = []
|
|
144
|
+
for await (const c of body) chunks.push(c)
|
|
145
|
+
return Buffer.concat(chunks).toString("utf8")
|
|
146
|
+
},
|
|
147
|
+
})
|
|
148
|
+
})
|
|
149
|
+
sock.on("close", () => {
|
|
150
|
+
if (!settled) fail(new Error("Connection closed before response"))
|
|
151
|
+
else body.end()
|
|
152
|
+
})
|
|
153
|
+
// 头部阶段的传输错误统一包装为稳定契约(对端 RST 会抛原生 ECONNRESET,调用方难判别);
|
|
154
|
+
// resolve 后的 body 阶段保留原始错误终止流
|
|
155
|
+
sock.on("error", (e) => {
|
|
156
|
+
if (!settled) fail(new Error(`Connection closed before response (${e.code ?? e.message})`))
|
|
157
|
+
else fail(e)
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
// 写请求(absoluteForm:代理转发时请求行为绝对 URI)
|
|
161
|
+
const requestTarget = absoluteForm ? urlStr : `${target.pathname}${target.search}`
|
|
162
|
+
const lines = [`${method} ${requestTarget} HTTP/1.1`]
|
|
163
|
+
for (const [k, v] of Object.entries({ ...headers, Host: target.hostname })) lines.push(`${k}: ${v}`)
|
|
164
|
+
lines.push("Connection: close", "", "")
|
|
165
|
+
sock.write(lines.join("\r\n"))
|
|
166
|
+
if (opts.body) sock.write(opts.body)
|
|
167
|
+
})
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* HTTPS request through HTTP CONNECT proxy tunnel.
|
|
172
|
+
* CONNECT + TLS 建立后交给 streamHttpResponse — 响应头到齐即 resolve,body 为流式。
|
|
173
|
+
* 2026-08-31 会诊 #1/#4:
|
|
174
|
+
* - TLS 默认全量证书校验(rejectUnauthorized: true)——走代理的流量(含 API key)
|
|
175
|
+
* 不得在未验证链路上传输;确需自签/内网代理时 opts.insecureTls=true 显式放行。
|
|
176
|
+
* - CONNECT/TLS 阶段用 timeout(默认 15s,建隧道快);**响应头超时**用
|
|
177
|
+
* opts._headerTimeoutMs(默认 60s)——原实现共用 15s,DeepSeek 排队 TTFB>15s
|
|
178
|
+
* 即误报 "Response timeout",与直连 600s 语义割裂。
|
|
179
|
+
*/
|
|
180
|
+
export function tunnelHttps(urlStr, opts, proxyUri, timeout = FETCH_TIMEOUT) {
|
|
181
|
+
return new Promise((resolve, reject) => {
|
|
182
|
+
let target, proxy
|
|
183
|
+
try {
|
|
184
|
+
target = new URL(urlStr)
|
|
185
|
+
proxy = new URL(proxyUri)
|
|
186
|
+
} catch {
|
|
187
|
+
// 融合(§2.5 #74——取双侧并集):VSC 侧的**坏代理串友好报错**并入核内——
|
|
188
|
+
// 原生 `Invalid URL` 不解释期望形态,调用方拿到的可观测文案退化。
|
|
189
|
+
return reject(new Error(`Invalid proxy URI: "${proxyUri}" — expected http://host:port`))
|
|
190
|
+
}
|
|
191
|
+
const signal = opts?.signal
|
|
192
|
+
const headerTimeoutMs = Number.isFinite(opts?._headerTimeoutMs) ? opts._headerTimeoutMs : 60_000
|
|
193
|
+
const bodyIdleMs = opts?._bodyIdleMs ?? 120_000
|
|
194
|
+
|
|
195
|
+
if (signal?.aborted) return reject(abortError(signal, "provider", "proxy"))
|
|
196
|
+
|
|
197
|
+
const sock = connect({ host: proxy.hostname, port: Number(proxy.port) || 3128 })
|
|
198
|
+
const timer = setTimeout(() => { sock.destroy(); reject(new Error("Proxy CONNECT timeout")) }, timeout)
|
|
199
|
+
const onAbort = () => { sock.destroy(); reject(abortError(signal, "provider", "proxy")) }
|
|
200
|
+
signal?.addEventListener("abort", onAbort, { once: true })
|
|
201
|
+
sock.on("connect", () => sock.write(`CONNECT ${target.hostname}:${target.port || 443} HTTP/1.1\r\nHost: ${target.hostname}\r\n\r\n`))
|
|
202
|
+
|
|
203
|
+
let buf = ""
|
|
204
|
+
sock.on("data", d => {
|
|
205
|
+
buf += d.toString()
|
|
206
|
+
const end = buf.indexOf("\r\n\r\n")
|
|
207
|
+
if (end < 0) return
|
|
208
|
+
const statusLine = buf.slice(0, end).split("\r\n")[0]
|
|
209
|
+
buf = buf.slice(end + 4)
|
|
210
|
+
if (!statusLine.includes("200")) { sock.destroy(); clearTimeout(timer); return reject(new Error(`Proxy CONNECT: ${statusLine}`)) }
|
|
211
|
+
sock.removeAllListeners("data"); clearTimeout(timer)
|
|
212
|
+
|
|
213
|
+
// 安全默认:TLS 全量校验。企业中间人代理场景显式 opts.insecureTls=true 才放行。
|
|
214
|
+
const tlsSock = tlsConnect({ socket: sock, servername: target.hostname, rejectUnauthorized: opts?.insecureTls !== true })
|
|
215
|
+
if (buf) tlsSock.unshift(Buffer.from(buf))
|
|
216
|
+
tlsSock.on("secureConnect", () => {
|
|
217
|
+
// TLS 之后的请求/响应阶段:abort 交由 streamHttpResponse 接管
|
|
218
|
+
signal?.removeEventListener("abort", onAbort)
|
|
219
|
+
streamHttpResponse(tlsSock, urlStr, opts, headerTimeoutMs, false, bodyIdleMs).then(resolve, reject)
|
|
220
|
+
})
|
|
221
|
+
tlsSock.on("error", e => { sock.destroy(); reject(e) })
|
|
222
|
+
})
|
|
223
|
+
sock.on("error", e => { clearTimeout(timer); reject(new Error(`Proxy CONNECT failed (${e.code ?? e.message})`)) })
|
|
224
|
+
// 代理干净 FIN 关闭(无 error)时也要 reject,不能卡满超时
|
|
225
|
+
sock.on("close", () => { clearTimeout(timer); reject(new Error("Proxy connection closed before tunnel established")) })
|
|
226
|
+
})
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** TCP 直连代理(http:// 目标的经典转发用):超时/abort/对端关闭都有稳定 reject */
|
|
230
|
+
function tcpConnectProxy(proxyUri, signal, timeout) {
|
|
231
|
+
return new Promise((resolve, reject) => {
|
|
232
|
+
let proxy
|
|
233
|
+
try {
|
|
234
|
+
proxy = new URL(proxyUri)
|
|
235
|
+
} catch {
|
|
236
|
+
// 融合(§2.5 #74):同 tunnelHttps——坏代理串友好报错。
|
|
237
|
+
return reject(new Error(`Invalid proxy URI: "${proxyUri}" — expected http://host:port`))
|
|
238
|
+
}
|
|
239
|
+
const sock = connect({ host: proxy.hostname, port: Number(proxy.port) || 3128 })
|
|
240
|
+
if (signal?.aborted) { sock.destroy(); return reject(abortError(signal, "provider", "proxy")) }
|
|
241
|
+
const onAbort = () => sock.destroy()
|
|
242
|
+
signal?.addEventListener("abort", onAbort, { once: true })
|
|
243
|
+
const timer = setTimeout(() => { sock.destroy(); reject(new Error("Proxy CONNECT timeout")) }, timeout)
|
|
244
|
+
const onError = (e) => { clearTimeout(timer); reject(new Error(`Proxy CONNECT failed (${e.code ?? e.message})`)) }
|
|
245
|
+
const onClose = () => {
|
|
246
|
+
clearTimeout(timer)
|
|
247
|
+
reject(signal?.aborted ? abortError(signal, "provider", "proxy") : new Error("Proxy connection closed before tunnel established"))
|
|
248
|
+
}
|
|
249
|
+
sock.once("connect", () => {
|
|
250
|
+
clearTimeout(timer)
|
|
251
|
+
signal?.removeEventListener("abort", onAbort)
|
|
252
|
+
sock.removeListener("error", onError)
|
|
253
|
+
sock.removeListener("close", onClose)
|
|
254
|
+
resolve(sock)
|
|
255
|
+
})
|
|
256
|
+
sock.once("error", onError)
|
|
257
|
+
sock.once("close", onClose)
|
|
258
|
+
})
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Generic fetch with proxy support.
|
|
263
|
+
* No proxy → native fetch. Proxy + HTTPS → CONNECT tunnel. Proxy + HTTP → 经典代理转发(绝对 URI 请求行)。
|
|
264
|
+
*/
|
|
265
|
+
export async function proxyFetch(urlStr, opts, proxyUri) {
|
|
266
|
+
if (!proxyUri) return globalThis.fetch(urlStr, opts)
|
|
267
|
+
const target = new URL(urlStr)
|
|
268
|
+
if (target.protocol === "https:") return tunnelHttps(urlStr, opts, proxyUri)
|
|
269
|
+
// http:// 目标:TCP 直连代理,请求行发绝对 URI(GET http://host/path HTTP/1.1)
|
|
270
|
+
const sock = await tcpConnectProxy(proxyUri, opts?.signal, FETCH_TIMEOUT)
|
|
271
|
+
const headerTimeoutMs = Number.isFinite(opts?._headerTimeoutMs) ? opts._headerTimeoutMs : FETCH_TIMEOUT
|
|
272
|
+
const bodyIdleMs = opts?._bodyIdleMs ?? 120_000
|
|
273
|
+
return streamHttpResponse(sock, urlStr, opts, headerTimeoutMs, true, bodyIdleMs)
|
|
274
|
+
}
|
package/rules.mjs
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* rules.mjs — stream rule discovery from .thincoder/rules/*.md
|
|
3
|
+
*
|
|
4
|
+
* Rule file format (markdown + YAML frontmatter):
|
|
5
|
+
* ---
|
|
6
|
+
* pattern: "console\\.log"
|
|
7
|
+
* action: abort # "abort"|"warn" (default: "warn")
|
|
8
|
+
* repeat: once # "once"|"always" (default: "always")
|
|
9
|
+
* ---
|
|
10
|
+
* Use the project logger instead of console.log.
|
|
11
|
+
*
|
|
12
|
+
* The body becomes the rule message; if empty, falls back to `message` frontmatter.
|
|
13
|
+
*/
|
|
14
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs"
|
|
15
|
+
import { join } from "node:path"
|
|
16
|
+
import { parseFrontmatter } from "./markdown.mjs"
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Scan `.thincoder/rules/` in the project root for `.md` rule files.
|
|
20
|
+
* Returns an array of rule objects compatible with config.streamRules format.
|
|
21
|
+
*/
|
|
22
|
+
export function discoverRules(projectRoot) {
|
|
23
|
+
const rulesDir = join(projectRoot, ".thincoder", "rules")
|
|
24
|
+
if (!existsSync(rulesDir)) return []
|
|
25
|
+
|
|
26
|
+
const rules = []
|
|
27
|
+
let entries
|
|
28
|
+
try { entries = readdirSync(rulesDir) } catch { return rules }
|
|
29
|
+
|
|
30
|
+
for (const f of entries) {
|
|
31
|
+
if (!f.endsWith(".md")) continue
|
|
32
|
+
try {
|
|
33
|
+
const text = readFileSync(join(rulesDir, f), "utf8")
|
|
34
|
+
const fm = text.match(/^---\r?\n([\s\S]*?)\r?\n---/)
|
|
35
|
+
if (!fm) continue
|
|
36
|
+
const meta = parseFrontmatter(fm[1])
|
|
37
|
+
const body = text.slice(fm[0].length).trim()
|
|
38
|
+
if (!meta.pattern) continue
|
|
39
|
+
// Strip surrounding quotes from frontmatter values (parseFrontmatter returns raw)
|
|
40
|
+
const pattern = meta.pattern.replace(/^"(.*)"$/s, "$1").replace(/^'(.*)'$/s, "$1")
|
|
41
|
+
const message = (meta.message || "").replace(/^"(.*)"$/s, "$1").replace(/^'(.*)'$/s, "$1")
|
|
42
|
+
|
|
43
|
+
rules.push({
|
|
44
|
+
pattern,
|
|
45
|
+
message: body || message || "",
|
|
46
|
+
action: meta.action || "warn",
|
|
47
|
+
repeat: meta.repeat || "always",
|
|
48
|
+
name: f.replace(/\.md$/, ""),
|
|
49
|
+
})
|
|
50
|
+
} catch { /* skip malformed rule files silently */ }
|
|
51
|
+
}
|
|
52
|
+
return rules
|
|
53
|
+
}
|