thincoder 0.12.53 → 0.12.58
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/CHANGELOG.md +74 -0
- package/bin/thincoder.mjs +17 -3
- package/package.json +3 -7
- package/src/acp/bridge.mjs +1 -1
- package/src/acp.mjs +60 -18
- package/src/advisor/messages.mjs +4 -2
- package/src/advisor/run.mjs +2 -2
- package/src/agent/dispatch.mjs +66 -26
- package/src/agent/helpers.mjs +13 -2
- package/src/agent/setup.mjs +16 -3
- package/src/agent/spawn-child.mjs +3 -1
- package/src/agent-tools/advisor.mjs +19 -9
- package/src/agent-tools/eng.mjs +2 -0
- package/src/agent-tools/subagent-check.mjs +107 -0
- package/src/agent-tools/subagent.mjs +205 -42
- package/src/agent.mjs +68 -3
- package/src/cli/make-agent.mjs +25 -0
- package/src/cli/memory-command.mjs +28 -7
- package/src/config.mjs +120 -8
- package/src/context.mjs +28 -7
- package/src/escape.mjs +110 -22
- package/src/git/checkpoint.mjs +32 -6
- package/src/mcp/transport-http.mjs +13 -1
- package/src/mcp.mjs +52 -7
- package/src/memory/core.mjs +78 -10
- package/src/memory/docs.mjs +33 -7
- package/src/memory.mjs +1 -1
- package/src/model-specs.mjs +23 -0
- package/src/prompts/discipline.md +17 -3
- package/src/prompts/engineering.md +62 -5
- package/src/prompts/main.md +1 -0
- package/src/prompts/system.md +2 -1
- package/src/provider/anthropic.mjs +7 -5
- package/src/provider/core.mjs +90 -26
- package/src/provider/google.mjs +57 -24
- package/src/provider/normalize.mjs +1 -1
- package/src/provider/rate.mjs +0 -2
- package/src/provider/responses.mjs +8 -13
- package/src/provider/sse.mjs +20 -0
- package/src/session-migrate.mjs +6 -0
- package/src/session-slots.mjs +361 -0
- package/src/session.mjs +282 -306
- package/src/tools/apply_patch.md +2 -0
- package/src/tools/bash.md +2 -2
- package/src/tools/edit-batch.mjs +104 -0
- package/src/tools/edit.md +3 -0
- package/src/tools/execute.md +4 -4
- package/src/tools/execute.mjs +14 -22
- package/src/tools/file.mjs +17 -55
- package/src/tools/file_ops.md +1 -1
- package/src/tools/git-checkpoint.mjs +143 -0
- package/src/tools/git-ext.mjs +173 -0
- package/src/tools/git.md +21 -8
- package/src/tools/git.mjs +55 -177
- package/src/tools/lint.md +1 -1
- package/src/tools/linter.mjs +9 -37
- package/src/tools/patch.mjs +1 -1
- package/src/tools/shared.mjs +7 -20
- package/src/tui/agent-turn.mjs +3 -3
- package/src/tui/ansi.mjs +2 -0
- package/src/tui/clipboard.mjs +2 -2
- package/src/tui/cmd-eng.mjs +1 -0
- package/src/tui/cmd-mcp-form.mjs +197 -0
- package/src/tui/cmd-mcp.mjs +255 -114
- package/src/tui/cmd-new.mjs +6 -6
- package/src/tui/cmd-restore.mjs +27 -6
- package/src/tui/cmd-session.mjs +17 -4
- package/src/tui/index.mjs +28 -27
- package/src/tui/interaction.mjs +28 -1
- package/src/tui/key-handler.mjs +14 -2
- package/src/tui/layout.mjs +81 -25
- package/src/tui/mouse.mjs +41 -2
- package/src/tui/pickers.mjs +62 -4
- package/src/tui/render-conversation.mjs +36 -93
- package/src/tui/render-frame.mjs +40 -16
- package/src/tui/render-loop.mjs +1 -1
- package/src/tui/render.mjs +4 -4
- package/src/tui/startup.mjs +4 -2
- package/src/tui/subagent-blocks.mjs +119 -4
- package/src/tui/subagent-panel.mjs +81 -0
- package/src/tui/tool-events.mjs +61 -16
- package/src/tui/tui-lifecycle.mjs +45 -0
package/src/config.mjs
CHANGED
|
@@ -78,8 +78,8 @@ export const DEFAULTS = {
|
|
|
78
78
|
// Model capability table + spec lookup live in model-specs.mjs (2026-08-31
|
|
79
79
|
// extract — config.mjs had grown past the 300-line advisory). Re-exported here
|
|
80
80
|
// so the 23 existing importers keep their import paths.
|
|
81
|
-
import { specForModel } from "./model-specs.mjs"
|
|
82
|
-
export { specForModel }
|
|
81
|
+
import { specForModel, providerSpec } from "./model-specs.mjs"
|
|
82
|
+
export { specForModel, providerSpec }
|
|
83
83
|
|
|
84
84
|
|
|
85
85
|
// Window utilization threshold: compacts at 60% context, reserving 40% headroom
|
|
@@ -87,10 +87,14 @@ export { specForModel }
|
|
|
87
87
|
// memory/doc search results) which can consume 30-50K tokens each turn.
|
|
88
88
|
const COMPACT_RATIO = 0.6
|
|
89
89
|
|
|
90
|
-
/** Derive compaction threshold; explicit is the value explicitly set in config file (takes priority), otherwise auto-computed from model
|
|
91
|
-
|
|
90
|
+
/** Derive compaction threshold; explicit is the value explicitly set in config file (takes priority), otherwise auto-computed from model.
|
|
91
|
+
* Second param accepts EITHER a model name string (pure spec lookup — legacy caller:
|
|
92
|
+
* first-run wizard) OR a provider object (providerSpec — the providers[].context
|
|
93
|
+
* override in K units is honored, PROVIDER.md §15 T-C2). */
|
|
94
|
+
export function resolveCompactThreshold(explicit, modelOrProvider) {
|
|
92
95
|
if (explicit != null) return { value: explicit, auto: false }
|
|
93
|
-
const
|
|
96
|
+
const provider = typeof modelOrProvider === "string" ? { model: modelOrProvider } : (modelOrProvider ?? {})
|
|
97
|
+
const spec = providerSpec(provider)
|
|
94
98
|
const value = Math.floor(spec.context * COMPACT_RATIO)
|
|
95
99
|
return { value, auto: true }
|
|
96
100
|
}
|
|
@@ -125,6 +129,9 @@ export function resolveEnableThinking(provider, spec) {
|
|
|
125
129
|
return undefined
|
|
126
130
|
}
|
|
127
131
|
|
|
132
|
+
/** Module-level one-time warn dedupe for invalid providers[].context (PROVIDER.md §15 D-C1). */
|
|
133
|
+
const warnedContextProviders = new Set()
|
|
134
|
+
|
|
128
135
|
/**
|
|
129
136
|
* Find provider by name in providers[].
|
|
130
137
|
* Throws if name is non-empty but not found — a typo in activeProvider silently falling to the first provider would use the wrong key on the wrong endpoint.
|
|
@@ -190,6 +197,19 @@ export function loadConfig() {
|
|
|
190
197
|
embedding: { ...DEFAULTS.embedding, ...config.embedding },
|
|
191
198
|
}
|
|
192
199
|
|
|
200
|
+
// providers[].context (K units, PROVIDER.md §15 D-C1): positive integer only — invalid
|
|
201
|
+
// values (0/negative/non-numeric) are IGNORED (spec value applies) with a ONE-TIME warn
|
|
202
|
+
// per provider name (module-level dedupe, same precedent as warnedModels in model-specs.mjs).
|
|
203
|
+
for (const p of merged.providers) {
|
|
204
|
+
if (p.context === undefined) continue
|
|
205
|
+
if (Number.isInteger(Number(p.context)) && Number(p.context) > 0) { p.context = Number(p.context); continue } // 数字字符串("128")归一为数字——两端语义统一(code review #1)
|
|
206
|
+
if (!warnedContextProviders.has(p.name ?? "(unnamed)")) {
|
|
207
|
+
warnedContextProviders.add(p.name ?? "(unnamed)")
|
|
208
|
+
console.warn(`[config] provider "${p.name}" context must be a positive integer in K units (e.g. 128 = 128K) — got ${JSON.stringify(p.context)} — ignored, using the model spec value`)
|
|
209
|
+
}
|
|
210
|
+
delete p.context
|
|
211
|
+
}
|
|
212
|
+
|
|
193
213
|
// Consult/escalate pool validation (CLI parity with the plugin): up to 5 candidates.
|
|
194
214
|
const cm = merged.agent.consultModels
|
|
195
215
|
if (cm !== undefined && !Array.isArray(cm)) {
|
|
@@ -227,7 +247,16 @@ export function loadConfig() {
|
|
|
227
247
|
merged.proxy = normalizeProxy(merged.proxy)
|
|
228
248
|
|
|
229
249
|
// Get the currently active provider
|
|
230
|
-
|
|
250
|
+
// 2026-09-02 Q1(SESSION.md §8):activeProvider 指向不存在的 provider 不再抛错——runtimeProvider
|
|
251
|
+
// 置空对象,由 make-agent.mjs assembleAgent 后的校验打 `_providerInvalid` 标记 → TUI 引导重选 /
|
|
252
|
+
// headless 报可读错误(原 findProvider throw 直接击穿 loadConfig → uncaughtException 退出)。
|
|
253
|
+
// findProvider 的 throw 契约保留(advisor/run.mjs 等直接调用方仍依赖)。
|
|
254
|
+
let active
|
|
255
|
+
try {
|
|
256
|
+
active = findProvider(merged.providers, merged.activeProvider)
|
|
257
|
+
} catch {
|
|
258
|
+
active = {}
|
|
259
|
+
}
|
|
231
260
|
|
|
232
261
|
// Build runtime provider object (for agent.provider usage)
|
|
233
262
|
const runtimeProvider = { ...active }
|
|
@@ -236,20 +265,103 @@ export function loadConfig() {
|
|
|
236
265
|
if (merged.activeModel) runtimeProvider.model = merged.activeModel
|
|
237
266
|
merged.activeModel = merged.activeModel || null // normalize for agent.activeModel
|
|
238
267
|
|
|
239
|
-
// Compaction threshold follows the model
|
|
268
|
+
// Compaction threshold follows the model (provider-level context override honored — providerSpec)
|
|
240
269
|
const explicitThreshold = config.agent?.compactThreshold
|
|
241
|
-
const { value, auto } = resolveCompactThreshold(explicitThreshold, runtimeProvider
|
|
270
|
+
const { value, auto } = resolveCompactThreshold(explicitThreshold, runtimeProvider)
|
|
242
271
|
merged.agent.compactThreshold = value
|
|
243
272
|
merged.agent.compactThresholdAuto = auto
|
|
244
273
|
|
|
245
274
|
// Write back to merged for convenient access by upper layers
|
|
246
275
|
merged.provider = runtimeProvider
|
|
276
|
+
// fetch 超时可配置(2026-09-01:agent.fetchTimeoutMs——provider/core.mjs effectiveFetchTimeoutMs 消费)
|
|
277
|
+
runtimeProvider.fetchTimeoutMs = Number.isFinite(merged.agent?.fetchTimeoutMs) && merged.agent.fetchTimeoutMs > 0
|
|
278
|
+
? merged.agent.fetchTimeoutMs : undefined
|
|
247
279
|
merged.providersList = merged.providers
|
|
248
280
|
merged.advisor = { ...merged.agent.advisor } // promote for consistent access (decoupled copy)
|
|
249
281
|
|
|
250
282
|
return merged
|
|
251
283
|
}
|
|
252
284
|
|
|
285
|
+
/**
|
|
286
|
+
* MCP.md §5 D-3 (2026-09-01): re-read config.json and replace ONLY the agent's mcp section
|
|
287
|
+
* — the agent 代配 closed loop (agent edits config.json with its edit tool, /mcp picks it
|
|
288
|
+
* up). Never touches other config sections (providers/activeProvider stay as loaded).
|
|
289
|
+
*
|
|
290
|
+
* Malformed disk config → memory state kept, { ok:false, error } returned (the /mcp menu
|
|
291
|
+
* shows "⚠ disk config unreadable"). Never throws.
|
|
292
|
+
*
|
|
293
|
+
* 对账 (reconciliation, MCP.md §5 D-3 / T23): returns which disk servers CHANGED
|
|
294
|
+
* (fingerprint differs) or are DELETED from disk while still connected — fingerprint =
|
|
295
|
+
* endpoint + token + headers/env key order. Existing connections are NOT torn down (an
|
|
296
|
+
* in-use server must not be dropped): a deleted-but-connected server KEEPS its memory
|
|
297
|
+
* entry (appended after the disk list) so the /mcp list can still show the row with the
|
|
298
|
+
* "⚠ disk changed" mark. A server that is merely NEW on disk is not drift. persistRaw
|
|
299
|
+
* write + reload is idempotent (fingerprints equal → no drift mark).
|
|
300
|
+
*
|
|
301
|
+
* @param path optional config path override (tests inject a tmp file; default configPath)
|
|
302
|
+
*/
|
|
303
|
+
export function reloadMcpFromDisk(agent, path) {
|
|
304
|
+
const memoryServers = Array.isArray(agent.config?.mcp?.servers) ? agent.config.mcp.servers : []
|
|
305
|
+
const fileExists = existsSync(path ?? configPath)
|
|
306
|
+
const diskMcp = readMcpSection(path)
|
|
307
|
+
if (!diskMcp.ok) return { ok: false, error: diskMcp.error, changedNames: [] }
|
|
308
|
+
// Missing/deleted config file → keep whichever mcp servers the session had (never
|
|
309
|
+
// silently drop user servers because the file vanished — same memory-keeps policy
|
|
310
|
+
// as the malformed-disk fallback).
|
|
311
|
+
let diskServers = diskMcp.servers
|
|
312
|
+
if (diskServers.length === 0 && !fileExists) diskServers = memoryServers
|
|
313
|
+
// Drift vs the RAW disk list: fingerprint-changed or deleted-from-disk (T23 ⚠ 标记依据)
|
|
314
|
+
const diskNames = new Set(diskServers.filter((s) => s?.name).map((s) => s.name))
|
|
315
|
+
const changedNames = diffMcpServers(memoryServers, diskServers)
|
|
316
|
+
// Connected servers deleted from disk stay in the list (memory copy) — T23: the row
|
|
317
|
+
// must remain visible (marked ⚠) and its live connection untouched. They are already
|
|
318
|
+
// in changedNames (absent from disk), and stay flagged on every reload until the user
|
|
319
|
+
// reconnects (re-persists them) or removes them — real drift, honestly reported.
|
|
320
|
+
const connectedNames = new Set((agent.tools ?? []).filter((t) => t?._mcpName).map((t) => t._mcpName))
|
|
321
|
+
const keptConnected = memoryServers.filter((s) => s?.name && connectedNames.has(s.name) && !diskNames.has(s.name))
|
|
322
|
+
const finalServers = [...diskServers, ...keptConnected]
|
|
323
|
+
agent.config ??= {}
|
|
324
|
+
agent.config.mcp = { ...agent.config.mcp, servers: finalServers }
|
|
325
|
+
return { ok: true, servers: finalServers, changedNames }
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** Disk read behind reloadMcpFromDisk — bounded, never throws. */
|
|
329
|
+
function readMcpSection(path = configPath) {
|
|
330
|
+
try {
|
|
331
|
+
if (!existsSync(path)) return { ok: true, servers: [] }
|
|
332
|
+
const raw = JSON.parse(readFileSync(path, "utf8"))
|
|
333
|
+
const servers = raw?.mcp?.servers
|
|
334
|
+
if (servers !== undefined && !Array.isArray(servers)) return { ok: true, servers: [] }
|
|
335
|
+
return { ok: true, servers: Array.isArray(servers) ? servers : [] }
|
|
336
|
+
} catch (error) {
|
|
337
|
+
return { ok: false, error: error?.message ?? String(error) }
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** Fingerprint = endpoint + token + headers/env entries in key order (JSON.stringify
|
|
342
|
+
* of a normalized subset — key order included, matching connectMcpServer's
|
|
343
|
+
* configFingerprint semantics: any change the connect layer would see counts).
|
|
344
|
+
* Drift = CHANGED (fingerprint differs) or DELETED (missing from disk) — a server
|
|
345
|
+
* that is new on disk is not drift (no live connection to protect). */
|
|
346
|
+
function diffMcpServers(memoryServers, diskServers) {
|
|
347
|
+
const memFp = new Map(memoryServers.filter((s) => s?.name).map((s) => [s.name, mcpFingerprint(s)]))
|
|
348
|
+
const diskFp = new Map(diskServers.filter((s) => s?.name).map((s) => [s.name, mcpFingerprint(s)]))
|
|
349
|
+
const changed = []
|
|
350
|
+
for (const [name, fp] of diskFp) if (memFp.has(name) && memFp.get(name) !== fp) changed.push(name)
|
|
351
|
+
for (const name of memFp.keys()) if (!diskFp.has(name)) changed.push(name)
|
|
352
|
+
return changed
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function mcpFingerprint(s) {
|
|
356
|
+
return JSON.stringify([
|
|
357
|
+
s.wsUrl ?? s.url ?? s.command ?? null,
|
|
358
|
+
s.args ?? null,
|
|
359
|
+
s.token ?? null,
|
|
360
|
+
s.headers ?? null,
|
|
361
|
+
s.env ?? null,
|
|
362
|
+
])
|
|
363
|
+
}
|
|
364
|
+
|
|
253
365
|
/**
|
|
254
366
|
* Save configuration. Preserves providers list structure and activeProvider pointer.
|
|
255
367
|
* providers[i].apiKey is only written when explicitly passed in (does not overwrite env-var-fallback keys).
|
package/src/context.mjs
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
import { chat } from "./provider/index.mjs"
|
|
13
13
|
import { estimateText } from "./provider/rate.mjs"
|
|
14
|
-
import {
|
|
14
|
+
import { providerSpec } from "./config.mjs"
|
|
15
15
|
|
|
16
16
|
const IMAGE_TOKEN_ESTIMATE = 2000 // rough estimate for image content tokens (CLI legacy 256 underestimated real image costs, delaying compaction)
|
|
17
17
|
|
|
@@ -41,10 +41,11 @@ const KEEP_HEAD = 0 // No dedicated head: earliest messages may be a COMPLETED e
|
|
|
41
41
|
// capped at 40% of history so small histories don't over-reserve. Window-adaptive
|
|
42
42
|
// replaces the old fixed 10: on a 1M window, 10 messages is too thin for recent work.
|
|
43
43
|
function keepTailSize(provider, historyLen) {
|
|
44
|
-
// provider is guaranteed at every call site (runAgent always builds one);
|
|
45
|
-
// degrades to DEFAULT_SPEC (128K) only if provider
|
|
46
|
-
// because the 40% history cap still bounds the tail.
|
|
47
|
-
|
|
44
|
+
// provider is guaranteed at every call site (runAgent always builds one); providerSpec
|
|
45
|
+
// degrades to DEFAULT_SPEC (128K) only if provider is somehow absent — acceptable
|
|
46
|
+
// because the 40% history cap still bounds the tail. providers[].context override
|
|
47
|
+
// (K units) is honored here (PROVIDER.md §15 T-C2: tail formula follows the window).
|
|
48
|
+
const ctxWindow = providerSpec(provider).context
|
|
48
49
|
return Math.min(Math.max(10, Math.floor((ctxWindow / 100_000) * 30)), Math.floor(historyLen * 0.4))
|
|
49
50
|
}
|
|
50
51
|
|
|
@@ -195,8 +196,10 @@ function applyCompression(agent, headEnd, tailStart, note) {
|
|
|
195
196
|
* Automatically re-injects task list state after compaction.
|
|
196
197
|
* @param {object} agent
|
|
197
198
|
* @param {number} threshold - compaction threshold in tokens
|
|
198
|
-
* @param {object} callbacks - { onToken, onReasoning, onCompress } — summary
|
|
199
|
-
* (never forwards onToken/onReasoning: the compaction process is an
|
|
199
|
+
* @param {object} callbacks - { onToken, onReasoning, onCompress, onCompressStart } — summary
|
|
200
|
+
* generation is SILENT (never forwards onToken/onReasoning: the compaction process is an
|
|
201
|
+
* internal mechanism, not a model reply); onCompressStart fires right before the summary call
|
|
202
|
+
* (§7 D-C1, compression lifecycle visibility — panel start state)
|
|
200
203
|
* @param {object} extras - { systemPrompt?, tools? } — estimated overhead for the pure-estimation
|
|
201
204
|
* path (no measured baseline); the measured path already includes system+tools in prompt_tokens.
|
|
202
205
|
*/
|
|
@@ -239,6 +242,11 @@ export async function compressIfNeeded(agent, threshold, callbacks, extras = {},
|
|
|
239
242
|
// The summary is a plain-text task, no reasoning needed — passing thinking to the compaction provider wastes tokens.
|
|
240
243
|
// Silent by design (D11): no onToken/onReasoning — the compaction process must not stream to the frontend.
|
|
241
244
|
// signal propagates user cancellation (Ctrl+C) to the in-flight summary call.
|
|
245
|
+
// Compression visibility (CONTEXT-COMPACTION.md §7 D-C1/D-C2): the frontend learns the compression
|
|
246
|
+
// STARTED right before the LLM call ("Compressing context… / summarizing N messages" panel) — only
|
|
247
|
+
// the lifecycle is surfaced, never the summary body. N = the number of history messages being summarized.
|
|
248
|
+
callbacks?.onCompressStart?.({ messages: middle.length })
|
|
249
|
+
const startedAt = performance.now()
|
|
242
250
|
const summary = await chat({ ...agent.provider, thinking: null, reasoningEffort: null }, {
|
|
243
251
|
messages: [{ role: "user", content: SUMMARIZE_PROMPT + serialized }],
|
|
244
252
|
signal,
|
|
@@ -246,6 +254,15 @@ export async function compressIfNeeded(agent, threshold, callbacks, extras = {},
|
|
|
246
254
|
|
|
247
255
|
applyCompression(agent, split.headEnd, split.tailStart, COMPACTION_PREFIX + summary.content)
|
|
248
256
|
|
|
257
|
+
// Completion info for the compression panel (D-C2): tokens freed = the pre-compression prompt
|
|
258
|
+
// estimate (`tokens` — the value that tripped the threshold, incl. system/tools overhead on the
|
|
259
|
+
// pure-estimation path) minus the post-compression estimate on the same basis. Elapsed = the
|
|
260
|
+
// summary call + splice duration. agent.mjs forwards this to onCompress unchanged.
|
|
261
|
+
agent._lastCompressInfo = {
|
|
262
|
+
mode: "summary",
|
|
263
|
+
tokensFreed: Math.max(0, Math.round(tokens - (estimateTokens(agent.history) + overhead))),
|
|
264
|
+
elapsedMs: performance.now() - startedAt,
|
|
265
|
+
}
|
|
249
266
|
return true
|
|
250
267
|
}
|
|
251
268
|
|
|
@@ -257,7 +274,11 @@ export function compressFallback(agent) {
|
|
|
257
274
|
const keepTail = keepTailSize(agent.provider, agent.history.length)
|
|
258
275
|
const split = splitHistory(agent.history, keepTail)
|
|
259
276
|
if (!split) return false
|
|
277
|
+
const tailMessages = agent.history.length - split.tailStart
|
|
260
278
|
applyCompression(agent, split.headEnd, split.tailStart, FALLBACK_NOTE)
|
|
279
|
+
// Fallback completion info (D-C2): mode marks the deterministic-truncation path — the panel
|
|
280
|
+
// shows the degradation note ("truncated to N messages") ONLY after 3 consecutive failures.
|
|
281
|
+
agent._lastCompressInfo = { mode: "fallback", tailMessages }
|
|
261
282
|
return true
|
|
262
283
|
}
|
|
263
284
|
|
package/src/escape.mjs
CHANGED
|
@@ -1,36 +1,99 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* escape.mjs — 中和 OpenAI 兼容服务端在 message content
|
|
2
|
+
* escape.mjs — 中和 OpenAI 兼容服务端在 message content 内做的非标二次转义解析 + 孤立代理净化。
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* 遇到 "\x" 后不足 2 个 hex(或 "\u" 后不足 4 个 hex)时服务端报
|
|
6
|
-
* "unexpected end of hex escape" → 400(首次观察于 2026-08-06,见 advisor.mjs 历史)。
|
|
4
|
+
* 两个独立毒源(均真机实证):
|
|
7
5
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
6
|
+
* ① 字面 hex 转义二次解析(2026-08-06 Kimi 首观察):Kimi/deepseek 等网关会把 content 里的字面
|
|
7
|
+
* "\x5Cx" / "\x5Cu" 当作 hex escape 再解释一遍,不足位时 400("unexpected end of hex escape")。
|
|
8
|
+
* 对策:不足位序列前 double 反斜杠(\\x5CxNN 形态还原为字面量);合法完整序列放行。
|
|
9
|
+
* Known limitation(2026-09-01 v3 修复):反斜杠 run ≥3 时(如 "\\\x5Cu" 三反斜杠+u)v1 的
|
|
10
|
+
* lookbehind 只看前 1 字符会整体放行,但二次解析按配对消费后尾部的 \x5Cu 仍裸露 → 炸。
|
|
11
|
+
* 修复:按 run 奇偶判断——run 为奇数时尾部的 \x5Cu/\x5Cx 裸露(需 double),偶数已配对(放行)。
|
|
12
|
+
*
|
|
13
|
+
* ② 孤立 UTF-16 代理对(2026-09-02 deepseek 真机实锤):content 里的**真实孤立代理字符**
|
|
14
|
+
* (高代理 U+D800-DBFF 无低代理跟随,或低代理 U+DC00-DFFF 无高代理前置)——JSON.stringify
|
|
15
|
+
* 输出 \ud83d(合法 JSON),但 deepseek 解析器严格 UTF-16 解码,孤立代理 → 400
|
|
16
|
+
* ("unexpected end of hex escape" / "lone leading surrogate in hex escape")。
|
|
17
|
+
* 来源实证:doc_search 结果预览 `slice(0, N)` 按 UTF-16 码元截断,emoji 🔴(代理对)恰在
|
|
18
|
+
* 截断边界被切成孤立高代理 → 注入 system reminder → 每轮发送 → deepseek 400。
|
|
19
|
+
* 对策:发送前把孤立代理替换为 U+FFFD(任何来源安全兜底);源头截断点另修 UTF-16 安全切。
|
|
11
20
|
*/
|
|
12
21
|
|
|
13
|
-
/**
|
|
22
|
+
/** 中和非法字面 hex 转义序列(毒源①)。 */
|
|
14
23
|
export function escapeLiteralEscapes(text) {
|
|
15
24
|
text = String(text ?? "")
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
25
|
+
let out = ""
|
|
26
|
+
let i = 0
|
|
27
|
+
const n = text.length
|
|
28
|
+
while (i < n) {
|
|
29
|
+
const ch = text[i]
|
|
30
|
+
if (ch !== "\\") { out += ch; i++; continue }
|
|
31
|
+
// 数反斜杠 run 长度
|
|
32
|
+
let run = 0
|
|
33
|
+
while (i + run < n && text[i + run] === "\\") run++
|
|
34
|
+
const next = text[i + run]
|
|
35
|
+
if ((next === "x" || next === "u") && run % 2 === 1) {
|
|
36
|
+
// run 奇数 → 二次解析配对消费后尾部 \x5Cx/\x5Cu 裸露——hex 不足则网关炸 → 前插反斜杠 double
|
|
37
|
+
const need = next === "u" ? 4 : 2
|
|
38
|
+
const after = text.slice(i + run + 1, i + run + 1 + need)
|
|
39
|
+
if (!new RegExp(`^[0-9a-fA-F]{${need}}$`).test(after)) {
|
|
40
|
+
out += "\\".repeat(run + 1) + next
|
|
41
|
+
i += run + 1
|
|
42
|
+
continue
|
|
43
|
+
}
|
|
44
|
+
// 合法完整:输出全序列并跳过(hex 尾不重新扫描)
|
|
45
|
+
out += text.slice(i, i + run + 1 + need)
|
|
46
|
+
i += run + 1 + need
|
|
47
|
+
continue
|
|
48
|
+
}
|
|
49
|
+
out += "\\".repeat(run)
|
|
50
|
+
i += run
|
|
51
|
+
}
|
|
52
|
+
return out
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** 净化孤立 UTF-16 代理对(毒源②):高代理无低代理跟随 / 低代理无高代理前置 → 替换为 。 */
|
|
56
|
+
export function sanitizeLoneSurrogates(text) {
|
|
57
|
+
text = String(text ?? "")
|
|
58
|
+
let out = ""
|
|
59
|
+
let i = 0
|
|
60
|
+
const n = text.length
|
|
61
|
+
while (i < n) {
|
|
62
|
+
const cp = text.charCodeAt(i)
|
|
63
|
+
if (cp >= 0xd800 && cp <= 0xdbff) {
|
|
64
|
+
const next = text.charCodeAt(i + 1)
|
|
65
|
+
if (next >= 0xdc00 && next <= 0xdfff) { out += text[i] + text[i + 1]; i += 2; continue }
|
|
66
|
+
out += ""; i++; continue // 孤立高代理
|
|
67
|
+
}
|
|
68
|
+
if (cp >= 0xdc00 && cp <= 0xdfff) { out += ""; i++; continue } // 孤立低代理
|
|
69
|
+
out += text[i]; i++
|
|
70
|
+
}
|
|
71
|
+
return out
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** 发送前文本净化总入口:hex 转义中和 + 孤立代理净化。 */
|
|
75
|
+
export function sanitizeText(text) {
|
|
76
|
+
return sanitizeLoneSurrogates(escapeLiteralEscapes(text))
|
|
21
77
|
}
|
|
22
78
|
|
|
23
|
-
/** 对单条消息的 content 应用
|
|
79
|
+
/** 对单条消息的 content 应用 sanitizeText(支持字符串或 OpenAI 多模态 part 数组)。
|
|
80
|
+
* 2026-08-31 会诊 F5:deepseek-v4-flash 网关对 tool_calls[].function.arguments 与
|
|
81
|
+
* reasoning_content 做同样的非标二次转义解析(字面 \\x5Cx/\\x5Cu 经工具参数/思考回传 → 400,
|
|
82
|
+
* 列号确定性复现 = 毒序列在 content 之外)——这两个字符串字段同样需要中和。 */
|
|
24
83
|
export function escapeMessageContent(message) {
|
|
25
84
|
const content = message?.content
|
|
85
|
+
let changed = false
|
|
86
|
+
let next = message
|
|
26
87
|
if (typeof content === "string") {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
88
|
+
const escaped = sanitizeText(content)
|
|
89
|
+
if (escaped !== content) {
|
|
90
|
+
next = { ...next, content: escaped }
|
|
91
|
+
changed = true
|
|
92
|
+
}
|
|
93
|
+
} else if (Array.isArray(content)) {
|
|
31
94
|
const parts = content.map((p) => {
|
|
32
95
|
if (p && typeof p === "object" && p.type === "text" && typeof p.text === "string") {
|
|
33
|
-
const escaped =
|
|
96
|
+
const escaped = sanitizeText(p.text)
|
|
34
97
|
if (escaped !== p.text) {
|
|
35
98
|
changed = true
|
|
36
99
|
return { ...p, text: escaped }
|
|
@@ -38,9 +101,34 @@ export function escapeMessageContent(message) {
|
|
|
38
101
|
}
|
|
39
102
|
return p
|
|
40
103
|
})
|
|
41
|
-
|
|
104
|
+
if (changed) next = { ...next, content: parts }
|
|
105
|
+
}
|
|
106
|
+
if (Array.isArray(next.tool_calls)) {
|
|
107
|
+
let tcChanged = false
|
|
108
|
+
const tool_calls = next.tool_calls.map((tc) => {
|
|
109
|
+
const args = tc?.function?.arguments
|
|
110
|
+
if (typeof args === "string") {
|
|
111
|
+
const escaped = sanitizeText(args)
|
|
112
|
+
if (escaped !== args) {
|
|
113
|
+
tcChanged = true
|
|
114
|
+
return { ...tc, function: { ...tc.function, arguments: escaped } }
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return tc
|
|
118
|
+
})
|
|
119
|
+
if (tcChanged) {
|
|
120
|
+
next = { ...next, tool_calls }
|
|
121
|
+
changed = true
|
|
122
|
+
}
|
|
42
123
|
}
|
|
43
|
-
|
|
124
|
+
if (typeof next.reasoning_content === "string") {
|
|
125
|
+
const escaped = sanitizeText(next.reasoning_content)
|
|
126
|
+
if (escaped !== next.reasoning_content) {
|
|
127
|
+
next = { ...next, reasoning_content: escaped }
|
|
128
|
+
changed = true
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return changed ? next : message
|
|
44
132
|
}
|
|
45
133
|
|
|
46
134
|
/** IKBGX4 (2026-08-28):剥离仅本地使用的整消息标记字段(transient 等)——发送给 provider 前移除。
|
|
@@ -59,4 +147,4 @@ export function stripLocalMessageFields(messages) {
|
|
|
59
147
|
/** 对整个 messages 数组逐条应用 escapeMessageContent(先剥离本地字段,再转义)。 */
|
|
60
148
|
export function escapeMessages(messages) {
|
|
61
149
|
return stripLocalMessageFields(messages).map(escapeMessageContent)
|
|
62
|
-
}
|
|
150
|
+
}
|
package/src/git/checkpoint.mjs
CHANGED
|
@@ -16,7 +16,7 @@ import { configDir } from "../config.mjs"
|
|
|
16
16
|
|
|
17
17
|
const CWD_HASH_LEN = 12
|
|
18
18
|
|
|
19
|
-
const MAX_CHECKPOINTS =
|
|
19
|
+
const MAX_CHECKPOINTS = 100
|
|
20
20
|
|
|
21
21
|
/** Files larger than this are NOT copied (sqlite db, bundles…) — they are recorded as skipped. */
|
|
22
22
|
const MAX_FILE_BYTES = 5 * 1024 * 1024
|
|
@@ -33,8 +33,16 @@ function git(cwd, args, { allowFail = false } = {}) {
|
|
|
33
33
|
}
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
/** Normalize cwd for hashing: uppercase the Windows drive letter so the VS Code
|
|
37
|
+
* extension's uri.fsPath (lowercased) produces the SAME cwdHash12 as the CLI's
|
|
38
|
+
* process.cwd() — cross-end snapshot sharing (CHECKPOINT.md F5/T7) depends on this
|
|
39
|
+
* contract. Same normalization as session storage (session-slots.mjs normalizeCwd). */
|
|
40
|
+
function normalizeCwd(cwd) {
|
|
41
|
+
return cwd.replace(/^([a-z]):/, (_, d) => d.toUpperCase() + ":")
|
|
42
|
+
}
|
|
43
|
+
|
|
36
44
|
function checkpointRoot(cwd) {
|
|
37
|
-
const hash = createHash("sha1").update(cwd).digest("hex").slice(0, CWD_HASH_LEN)
|
|
45
|
+
const hash = createHash("sha1").update(normalizeCwd(cwd)).digest("hex").slice(0, CWD_HASH_LEN)
|
|
38
46
|
return join(configDir, "checkpoints", hash)
|
|
39
47
|
}
|
|
40
48
|
|
|
@@ -412,11 +420,29 @@ export async function catFile(cwd, id, filePath) {
|
|
|
412
420
|
}
|
|
413
421
|
}
|
|
414
422
|
|
|
415
|
-
/**
|
|
416
|
-
|
|
423
|
+
/** F6: delete ALL checkpoints for a cwd — commit = new safety baseline. Best-effort:
|
|
424
|
+
* a missing dir is a no-op; deletion failures propagate so the git tool's commit case
|
|
425
|
+
* can report "(checkpoint cleanup skipped: …)" without blocking the commit result. */
|
|
426
|
+
export async function deleteCheckpointsForCwd(cwd) {
|
|
427
|
+
await rm(checkpointRoot(cwd), { recursive: true, force: true })
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/** NF6: keep only the most recent `count` snapshots — oldest removed first (id sort =
|
|
431
|
+
* timestamp prefix, so ascending order IS oldest-first). Returns how many were deleted
|
|
432
|
+
* (0 when the cwd has no checkpoint dir). */
|
|
433
|
+
export async function deleteCheckpointsOlderThan(cwd, count) {
|
|
417
434
|
const root = checkpointRoot(cwd)
|
|
418
|
-
|
|
419
|
-
|
|
435
|
+
let ids
|
|
436
|
+
try { ids = (await readdir(root)).sort() } catch { return 0 }
|
|
437
|
+
let removed = 0
|
|
438
|
+
while (ids.length > count) {
|
|
420
439
|
await rm(join(root, ids.shift()), { recursive: true, force: true })
|
|
440
|
+
removed++
|
|
421
441
|
}
|
|
442
|
+
return removed
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** Keep only the most recent MAX_CHECKPOINTS (NF6 cap — runs at the end of every create) */
|
|
446
|
+
async function pruneCheckpoints(cwd) {
|
|
447
|
+
await deleteCheckpointsOlderThan(cwd, MAX_CHECKPOINTS)
|
|
422
448
|
}
|
|
@@ -9,6 +9,9 @@ export function httpTransport(baseURL, extraHeaders = {}) {
|
|
|
9
9
|
let sessionId = null
|
|
10
10
|
let closed = false
|
|
11
11
|
let eventSource = null
|
|
12
|
+
// 2026-09-01 MCP.md §4 D-1:GET SSE 不可用(405/不支持)降级后的纯 POST 模式标记。
|
|
13
|
+
// POST-only server(如 glm-websearch)无流可断:isAlive 不得因 eventSource == null 判死。
|
|
14
|
+
let postOnly = false
|
|
12
15
|
let abortController = null
|
|
13
16
|
let postUrl = url
|
|
14
17
|
let legacySSE = false
|
|
@@ -232,5 +235,14 @@ export function httpTransport(baseURL, extraHeaders = {}) {
|
|
|
232
235
|
pending.clear()
|
|
233
236
|
}
|
|
234
237
|
|
|
235
|
-
|
|
238
|
+
/** D-1:openSSE 降级(GET 405/不支持)后由 connect 链调用——标记纯 Streamable POST 模式 */
|
|
239
|
+
const markPostOnly = () => { postOnly = true }
|
|
240
|
+
|
|
241
|
+
return {
|
|
242
|
+
send, notify, close, openSSE, url, headers: extraHeaders,
|
|
243
|
+
// F1:POST-only 降级(postOnly)与 legacy SSE 流(eventSource)都算活连接——
|
|
244
|
+
// 不得因 eventSource == null 误判死(glm-websearch "reconnect failed" 根因)。
|
|
245
|
+
isAlive: () => !closed && (eventSource != null || postOnly),
|
|
246
|
+
onDead, markPostOnly,
|
|
247
|
+
}
|
|
236
248
|
}
|
package/src/mcp.mjs
CHANGED
|
@@ -7,6 +7,12 @@ import { stdioTransport } from "./mcp/transport-stdio.mjs"
|
|
|
7
7
|
import { httpTransport } from "./mcp/transport-http.mjs"
|
|
8
8
|
import { wsTransport } from "./mcp/transport-ws.mjs"
|
|
9
9
|
|
|
10
|
+
/** F6/D-5:config.token → `Authorization: Bearer <token>` 合成(仅当 headers 未显式给
|
|
11
|
+
* Authorization——显式优先,向后兼容)。合成发生在传给 transport 前,不写回 config。 */
|
|
12
|
+
export function withBearerToken(config) {
|
|
13
|
+
if (!config?.token || config.headers?.Authorization) return config
|
|
14
|
+
return { ...config, headers: { ...config.headers, Authorization: `Bearer ${config.token}` } }
|
|
15
|
+
}
|
|
10
16
|
|
|
11
17
|
/** Race a pending MCP request against an abort signal — a hung MCP server must not
|
|
12
18
|
* hold the turn hostage. signal absent → passthrough. */
|
|
@@ -31,8 +37,9 @@ async function sendWithSignal(promise, signal) {
|
|
|
31
37
|
|
|
32
38
|
/** 2026-08-31 MCP 会诊 P5:CLI session 注册表(serverName → session)。
|
|
33
39
|
* session.state.transport 可变(重连替换);buildTools 的 execute 动态取
|
|
34
|
-
* session.state.transport——server 崩溃后无需重建 agent.tools 即自愈。
|
|
35
|
-
|
|
40
|
+
* session.state.transport——server 崩溃后无需重建 agent.tools 即自愈。
|
|
41
|
+
* 2026-09-01 MCP.md §4:导出给 /mcp test 的零副作用断言用(probe 后 _sessions 不增)。 */
|
|
42
|
+
export const _sessions = new Map()
|
|
36
43
|
/** 退避重连进行中(serverName → promise)——与 vscode 语义对齐;延迟表见 _mcpHooks。 */
|
|
37
44
|
const _reconnecting = new Map()
|
|
38
45
|
|
|
@@ -44,7 +51,8 @@ export const _mcpHooks = {
|
|
|
44
51
|
}
|
|
45
52
|
|
|
46
53
|
/** 按 config 创建并完成握手的 transport(findTransportConfig 与 vscode 对齐)。 */
|
|
47
|
-
async function createConnectedTransport(
|
|
54
|
+
async function createConnectedTransport(rawConfig, serverName) {
|
|
55
|
+
const config = withBearerToken(rawConfig) // F6/D-5:token 合成(不写回原 config)
|
|
48
56
|
let transport
|
|
49
57
|
if (config.wsUrl) {
|
|
50
58
|
transport = wsTransport(config.wsUrl, config.headers ?? {})
|
|
@@ -54,7 +62,10 @@ async function createConnectedTransport(config, serverName) {
|
|
|
54
62
|
try {
|
|
55
63
|
await transport.openSSE()
|
|
56
64
|
} catch {
|
|
57
|
-
// Server doesn't support GET SSE — degrade to pure Streamable HTTP POST mode
|
|
65
|
+
// Server doesn't support GET SSE — degrade to pure Streamable HTTP POST mode.
|
|
66
|
+
// MCP.md §4 D-1:显式标记 postOnly——POST-only server(glm-websearch 类)isAlive
|
|
67
|
+
// 不得因 eventSource == null 误判死(否则 ensureAlive 触发无意义重连循环)。
|
|
68
|
+
transport.markPostOnly()
|
|
58
69
|
}
|
|
59
70
|
} else {
|
|
60
71
|
transport = stdioTransport(config.command, config.args ?? [], config.env)
|
|
@@ -63,6 +74,25 @@ async function createConnectedTransport(config, serverName) {
|
|
|
63
74
|
return { transport, mcpTools }
|
|
64
75
|
}
|
|
65
76
|
|
|
77
|
+
/** F4/D-2:一次性探活——createConnectedTransport(initialize + tools/list)+ 计时。
|
|
78
|
+
* 零副作用:不进 _sessions、不动 agent.tools、无 onDead 挂钩;finally close
|
|
79
|
+
* (closed=true 使 onDead 重连不会触发)。initialize 与 tools/list 同受
|
|
80
|
+
* INIT_TIMEOUT_MS 约束(见 doInitialize)。不复用 connectMcpServer(避免污染
|
|
81
|
+
* session 幂等表)。 */
|
|
82
|
+
export async function probeMcpServer(config) {
|
|
83
|
+
const start = Date.now()
|
|
84
|
+
let transport
|
|
85
|
+
let mcpTools
|
|
86
|
+
try {
|
|
87
|
+
;({ transport, mcpTools } = await createConnectedTransport(config, config.name ?? config.command ?? config.url ?? config.wsUrl))
|
|
88
|
+
return { ok: true, toolCount: mcpTools.length, latencyMs: Date.now() - start }
|
|
89
|
+
} catch (error) {
|
|
90
|
+
return { ok: false, error: error?.message ?? String(error) }
|
|
91
|
+
} finally {
|
|
92
|
+
try { transport?.close() } catch { /* ignore */ }
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
66
96
|
/** onDead → 后台退避重连;成功替换 session.state.transport(tools 闭包动态引用,
|
|
67
97
|
* agent.tools 无需重建);失败静默(下次 execute 前置检查再试)。 */
|
|
68
98
|
function scheduleReconnect(name, config) {
|
|
@@ -173,10 +203,14 @@ async function doInitialize(transport, _name) {
|
|
|
173
203
|
|
|
174
204
|
// 2026-08-31 MCP 会诊 P6:tools/list 分页被忽略(nextCursor 多页工具静默丢失)——
|
|
175
205
|
// 循环跟随 cursor 直到 server 不再返回(上限 20 页防死循环)。
|
|
206
|
+
// MCP.md §4 评审 #8:每页同受 INIT_TIMEOUT_MS 约束(否则 probe 的延迟统计无界)。
|
|
176
207
|
const tools = []
|
|
177
208
|
let cursor
|
|
178
209
|
for (let page = 0; page < 20; page++) {
|
|
179
|
-
const toolsResp = await
|
|
210
|
+
const toolsResp = await withTimeout(
|
|
211
|
+
transport.send("tools/list", cursor ? { cursor } : {}),
|
|
212
|
+
INIT_TIMEOUT_MS,
|
|
213
|
+
)
|
|
180
214
|
if (toolsResp.error) throw new Error(`tools/list failed: ${toolsResp.error.message}`)
|
|
181
215
|
tools.push(...(toolsResp.result?.tools ?? []))
|
|
182
216
|
cursor = toolsResp.result?.nextCursor
|
|
@@ -192,7 +226,9 @@ export async function connectMcpServer(config) {
|
|
|
192
226
|
if (!config || (!config.command && !config.url && !config.wsUrl))
|
|
193
227
|
throw new Error(`MCP server "${config?.name ?? ""}": needs either 'wsUrl' (websocket), 'command' (stdio), or 'url' (http)`)
|
|
194
228
|
const name = config.name ?? config.command ?? config.url ?? config.wsUrl
|
|
195
|
-
|
|
229
|
+
// MCP.md §4 D-5:fingerprint 计入 token 字段——/mcp edit 改 token → 指纹变更 →
|
|
230
|
+
// 旧连接主动关闭重建(T13)。
|
|
231
|
+
const configFingerprint = JSON.stringify([config.command ?? null, config.args ?? null, config.url ?? null, config.wsUrl ?? null, config.env ?? null, config.headers ?? null, config.token ?? null])
|
|
196
232
|
|
|
197
233
|
const existing = _sessions.get(name)
|
|
198
234
|
if (existing && !existing.closed) {
|
|
@@ -208,7 +244,16 @@ export async function connectMcpServer(config) {
|
|
|
208
244
|
}
|
|
209
245
|
}
|
|
210
246
|
|
|
211
|
-
|
|
247
|
+
let transport
|
|
248
|
+
let mcpTools
|
|
249
|
+
try {
|
|
250
|
+
;({ transport, mcpTools } = await createConnectedTransport(config, name))
|
|
251
|
+
} catch (error) {
|
|
252
|
+
// MCP.md §4 评审 #7:握手失败不泄漏 transport(GET SSE 降级成功但 POST initialize
|
|
253
|
+
// 失败时,openSSE 若开了流会留一个悬挂的 reader/请求)。
|
|
254
|
+
try { transport?.close() } catch { /* ignore */ }
|
|
255
|
+
throw error
|
|
256
|
+
}
|
|
212
257
|
const session = {
|
|
213
258
|
config, configFingerprint,
|
|
214
259
|
state: { transport, tools: null },
|