thincoder 0.12.36 → 0.12.38

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thincoder",
3
- "version": "0.12.36",
3
+ "version": "0.12.38",
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/advisor.mjs CHANGED
@@ -47,6 +47,7 @@ import { fileURLToPath } from "node:url"
47
47
  import { extractAgentResponseTable } from "./advisor/history.mjs"
48
48
  import { buildAdvisorUserMessage, resolveScopeFiles } from "./advisor/messages.mjs"
49
49
  import { buildConvergenceBody } from "./advisor/convergence.mjs"
50
+ import { escapeLiteralEscapes } from "./escape.mjs"
50
51
  // Re-export for run.mjs and tests (keeps their imports from "../advisor.mjs" stable)
51
52
  export { ADVISOR_MD_PATH, extractAgentResponseTable, extractConversationBackground } from "./advisor/history.mjs"
52
53
  export { buildAdvisorUserMessage } from "./advisor/messages.mjs"
@@ -171,28 +172,9 @@ export function buildAdvisorFollowUp(agent, prior, scopeFiles = null) {
171
172
  * messages.mjs so the legacy path shares it (see there).
172
173
  */
173
174
 
174
- /**
175
- * Neutralize literal backslash escape sequences ("\x", "\u") that some
176
- * OpenAI-compatible servers interpret inside message content ("unexpected end
177
- * of hex escape" → 400 — observed 2026-08-06 when the conversation background
178
- * quoted "\x" literals). Only sequences that would be INVALID when expanded
179
- * are doubled ("\\x" → literal "\x" after server expansion); well-formed
180
- * "\xNN" / "\uNNNN" pass through untouched (they expand to a byte/codepoint).
181
- */
182
- export function escapeLiteralEscapes(text) {
183
- // (?<!\\) — only a SINGLE backslash counts ("\\x" already doubles the
184
- // escape and must pass through untouched); lookbehind is fine on Node 24.
185
- // Known limitation (documented, accepted): an ODD backslash run of 3+ (e.g.
186
- // "\\\x") leaves the trailing "\x" un-doubled — vanishingly rare in real
187
- // conversation text, and the sequence is still valid JSON either way.
188
- // The lookahead treats "\x" followed by AT LEAST 2 hex as valid (servers
189
- // expand only the first two: "\x1b3" → ESC + "3"); only truncated runs
190
- // ("\x" + <2 hex) are doubled.
191
- text = String(text ?? "")
192
- return text
193
- .replace(/(?<!\\)\\(x)(?![0-9a-fA-F]{2})/g, "\\\\$1")
194
- .replace(/(?<!\\)\\(u)(?![0-9a-fA-F]{4})/g, "\\\\$1")
195
- }
175
+ // escapeLiteralEscapes 已抽到 ./escape.mjs(advisor 与主 agent 发送路径共用),
176
+ // 这里 re-export 保持既有 import 稳定。
177
+ export { escapeLiteralEscapes }
196
178
 
197
179
 
198
180
  /**
@@ -19,6 +19,36 @@ function consultLabel(m) {
19
19
  return `${m.provider}:${m.model}`
20
20
  }
21
21
 
22
+ /** Narrow the configured consultModels pool to a requested subset.
23
+ * Each selector is "provider:model", a bare provider name, or a bare model name
24
+ * (case-insensitive). Returns { models, error } — error set when a selector matches
25
+ * nothing (surface the typo rather than silently dropping it). Absent/empty selectors
26
+ * → the full pool. */
27
+ function selectConsultModels(pool, selectors) {
28
+ if (selectors == null || (Array.isArray(selectors) && selectors.length === 0)) return { models: pool, error: null }
29
+ const list = Array.isArray(selectors) ? selectors : [selectors] // coerce a bare string → [string]
30
+ const selected = []
31
+ const seen = new Set()
32
+ const unknowns = []
33
+ for (const raw of list) {
34
+ const s = String(raw).trim().toLowerCase()
35
+ const matches = pool.filter((m) =>
36
+ consultLabel(m).toLowerCase() === s ||
37
+ String(m.provider ?? "").toLowerCase() === s ||
38
+ String(m.model ?? "").toLowerCase() === s,
39
+ )
40
+ if (matches.length === 0) unknowns.push(String(raw))
41
+ else for (const m of matches) {
42
+ const key = consultLabel(m)
43
+ if (!seen.has(key)) { seen.add(key); selected.push(m) }
44
+ }
45
+ }
46
+ if (unknowns.length > 0) {
47
+ return { models: null, error: `unknown consult model selector(s): ${unknowns.join(", ")} — choose from: ${pool.map(consultLabel).join(", ")}` }
48
+ }
49
+ return { models: selected, error: null }
50
+ }
51
+
22
52
  /** Read-only tool injected into consultation children (via createAgent's tools).
23
53
  * Lets the consultant pull the main agent's conversation history on demand —
24
54
  * the failure trail is first-class evidence, not a retelling. */
@@ -225,31 +255,40 @@ export const consultStartTool = {
225
255
  "arrives, judge/verify it yourself with your own tools, and call consult_stop(id) once a reply is good enough.\n" +
226
256
  "Parameters:\n" +
227
257
  "- problem (required): a brief — the symptom, what you already tried (failure trail), and entry-point files. " +
228
- "Do NOT paste raw error logs; consultants pull the main session history themselves via their main_history tool.",
258
+ "Do NOT paste raw error logs; consultants pull the main session history themselves via their main_history tool.\n" +
259
+ "- models (optional): subset of agent.consultModels to run — an array of \"provider:model\", bare provider, or bare model names (case-insensitive). Omit to run all.",
229
260
  parameters: {
230
261
  type: "object",
231
- properties: { problem: { type: "string", description: "Problem brief (symptom + failure trail + entry files)" } },
262
+ properties: {
263
+ problem: { type: "string", description: "Problem brief (symptom + failure trail + entry files)" },
264
+ models: { type: "array", items: { type: "string" }, description: 'Optional subset of agent.consultModels to run (default: all). Each entry is "provider:model", a bare provider name, or a bare model name (case-insensitive).' },
265
+ },
232
266
  required: ["problem"],
233
267
  },
234
- async execute({ problem }, ctx) {
268
+ async execute({ problem, models }, ctx) {
235
269
  if (typeof problem !== "string" || !problem.trim()) return "Error: problem is required and must be a non-empty string"
236
270
  const agent = ctx.agent
237
271
  if (!agent) return "Error: consult requires an agent context"
238
- const models = agent.config?.agent?.consultModels ?? []
239
- if (!Array.isArray(models) || models.length === 0)
272
+ const pool = agent.config?.agent?.consultModels ?? []
273
+ if (!Array.isArray(pool) || pool.length === 0)
240
274
  return "Consultation is not configured — add agent.consultModels ([{ provider, model }], up to 5) to ~/.thincoder/config.json"
241
- if (models.length > 5) return `Error: consultModels supports at most 5 models (got ${models.length})`
275
+ if (pool.length > 5) return `Error: consultModels supports at most 5 models (got ${pool.length})`
276
+
277
+ // `models` (optional) narrows the pool to a subset; absent/empty → run the whole pool.
278
+ const picked = selectConsultModels(pool, models)
279
+ if (picked.error) return picked.error
280
+ const run = picked.models
242
281
 
243
282
  agent._consultSessions ??= new Map()
244
283
  const id = String((agent._consultIdCounter = (agent._consultIdCounter ?? 0) + 1))
245
284
  const session = {
246
285
  id, controllers: [], replies: [], pending: 0, waiters: [],
247
- failed: 0, terminated: 0, stopped: false, received: 0, total: models.length,
248
- models: models.map(consultLabel),
286
+ failed: 0, terminated: 0, stopped: false, received: 0, total: run.length,
287
+ models: run.map(consultLabel),
249
288
  }
250
289
  agent._consultSessions.set(id, session)
251
290
 
252
- for (const m of models) {
291
+ for (const m of run) {
253
292
  session.pending++
254
293
  const ctrl = new AbortController()
255
294
  session.controllers.push(ctrl)
@@ -2,7 +2,7 @@ import { repairHistory, listWorkDir } from "../agent.mjs"
2
2
  import { isDocFile } from "../advisor/repos.mjs"
3
3
  import { execSync, spawn, spawnSync } from "node:child_process"
4
4
  import { readFileSync, existsSync } from "node:fs"
5
- import { join } from "node:path"
5
+ import { join, resolve } from "node:path"
6
6
 
7
7
  /**
8
8
  * Source module → test file mapping. Heuristic: the FIRST path component
@@ -58,12 +58,17 @@ export const verifyTool = {
58
58
  type: "object",
59
59
  properties: {
60
60
  full: { type: "boolean", description: "Run the full test suite (npm test) instead of just related tests. Default false — use sparingly, per the testing discipline rules." },
61
+ workdir: { type: "string", description: "Optional: run verify in this subdirectory (relative to cwd or absolute) — for monorepos" },
62
+ filter: { type: "string", description: "Optional: limit the test run to matching test names (node --test-name-pattern / npm test -- --test-name-pattern)" },
61
63
  },
62
64
  },
63
65
  readonly: true,
64
66
  outputPanel: true, // stream test output to a panel instead of inline
65
67
  async execute(args, ctx) {
66
68
  const cwd = ctx.agent.cwd
69
+ // workdir only relocates WHERE tests (and package.json) live — changed-file
70
+ // resolution (git diff) stays anchored to the project root.
71
+ const testCwd = args.workdir ? resolve(cwd, args.workdir) : cwd
67
72
  const lines = []
68
73
  lines.push("=== VERIFICATION REPORT ===")
69
74
  lines.push("")
@@ -130,7 +135,7 @@ export const verifyTool = {
130
135
  const relatedTests = [...new Set(modules.map((m) => MODULE_TO_TEST[m]).filter(Boolean))]
131
136
 
132
137
  // 4. Run tests
133
- const pkgPath = join(cwd, "package.json")
138
+ const pkgPath = join(testCwd, "package.json")
134
139
  const hasTestScript = existsSync(pkgPath) && (() => { try { return !!JSON.parse(readFileSync(pkgPath, "utf8")).scripts?.test } catch { return false } })()
135
140
 
136
141
  if (args.full) {
@@ -138,7 +143,7 @@ export const verifyTool = {
138
143
  if (hasTestScript) {
139
144
  lines.push("")
140
145
  lines.push("Tests (full suite):")
141
- const result = await runTestSuite(cwd, ctx)
146
+ const result = await runTestSuite(testCwd, ctx, args.filter)
142
147
  if (result.passed) {
143
148
  lines.push("✓ All tests passed.")
144
149
  ctx.agent._verifyPassed = !syntaxFailed
@@ -163,7 +168,7 @@ export const verifyTool = {
163
168
  continue
164
169
  }
165
170
  try {
166
- const result = await runTestFile(cwd, testFile, ctx)
171
+ const result = await runTestFile(cwd, testFile, ctx, args.filter)
167
172
  if (result.passed) {
168
173
  lines.push(` ✓ ${testFile}`)
169
174
  } else {
@@ -248,9 +253,9 @@ export const verifyTool = {
248
253
  * Run a single test file with node --test, no maxBuffer limit.
249
254
  * Returns { passed: boolean, tail: string } — the last few lines of output.
250
255
  */
251
- function runTestFile(cwd, testPath, ctx) {
256
+ function runTestFile(cwd, testPath, ctx, filter) {
252
257
  return new Promise((resolve, reject) => {
253
- const child = spawn("node", ["--test", testPath], {
258
+ const child = spawn("node", filter ? ["--test", "--test-name-pattern", filter, testPath] : ["--test", testPath], {
254
259
  cwd, stdio: ["ignore", "pipe", "pipe"],
255
260
  env: { ...process.env, FORCE_COLOR: "0" },
256
261
  })
@@ -288,9 +293,9 @@ function runTestFile(cwd, testPath, ctx) {
288
293
  * Test output is streamed through ctx.callbacks.onToolOutput (TUI can display progress in real time).
289
294
  * Returns { passed: boolean, tail: string }.
290
295
  */
291
- function runTestSuite(cwd, ctx) {
296
+ function runTestSuite(cwd, ctx, filter) {
292
297
  return new Promise((resolve, reject) => {
293
- const child = spawn("npm", ["test"], {
298
+ const child = spawn("npm", filter ? ["test", "--", `--test-name-pattern=${filter}`] : ["test"], {
294
299
  cwd, shell: true, stdio: ["ignore", "pipe", "pipe"],
295
300
  env: { ...process.env, FORCE_COLOR: "0" },
296
301
  })
package/src/config.mjs CHANGED
@@ -36,7 +36,7 @@ export const PROVIDER_PRESETS = {
36
36
  groq: { baseURL: "https://api.groq.com/openai/v1", model: "llama-3.3-70b-versatile", maxTokens: 32768, desc: "Groq" },
37
37
  }
38
38
 
39
- const DEFAULTS = {
39
+ export const DEFAULTS = {
40
40
  activeModel: null, // optional: override provider.model (set via /model picker or /model provider:model)
41
41
  agent: {
42
42
  maxTurns: 100,
@@ -87,7 +87,7 @@ const DEFAULTS = {
87
87
  * multimodal: whether multimodal (image/vision input supported)
88
88
  * cacheMode: context caching mode: "auto"=automatic / "prompt"=needs explicit / "none"=unsupported
89
89
  * thinkApi: thinking API type: "type"=thinking.type field / "effort"=reasoning_effort field
90
- * thinkOnValue: when thinkApi is "type", the value used to enable thinking (default "enabled"; MiniMax uses "adaptive")
90
+ * thinkEnabledValue: when thinkApi is "type", the value used to enable thinking (default "enabled"; MiniMax uses "adaptive")
91
91
  * reasoningEcho: reasoning_content cross-turn echo strategy: "required"=must echo (error if missing) / "optional"=echo optional (default: don't echo)
92
92
  * reasoningEffortEnum: valid reasoning_effort enum values (if undeclared, no validation — passed through as-is)
93
93
  * tempRange: valid temperature range [min, max] (if undeclared, no clamping)
@@ -96,6 +96,8 @@ const MODEL_SPECS = [
96
96
  // DeepSeek V4 series
97
97
  ["deepseek-v4-pro", { context: 1_000_000, maxOutput: 384_000, thinking: true, prefixMode: true, cacheMode: "auto", thinkApi: "type", reasoningEcho: "required", reasoningEffortEnum: ["low", "high", "max"], tempRange: [0, 2] }],
98
98
  ["deepseek-v4-flash", { context: 1_000_000, maxOutput: 384_000, thinking: true, prefixMode: true, cacheMode: "auto", thinkApi: "type", reasoningEcho: "required", reasoningEffortEnum: ["low", "high", "max"], tempRange: [0, 2] }],
99
+ // DeepSeek V4 Flash Vision (experimental) — image input on top of the full V4-Flash stack
100
+ ["deepseek-v4-flash-vision-exp", { context: 1_000_000, maxOutput: 384_000, thinking: true, prefixMode: true, cacheMode: "auto", thinkApi: "type", reasoningEcho: "required", reasoningEffortEnum: ["low", "high", "max"], tempRange: [0, 2], multimodal: true }],
99
101
  // Kimi series
100
102
  ["kimi-k3", { context: 1_000_000, maxOutput: 131_072, thinking: true, partialMode: true, multimodal: true, cacheMode: "auto", thinkApi: "effort", reasoningEcho: "required", reasoningEffortEnum: ["low", "high", "max"] }],
101
103
  // Qwen router prefixes model IDs with provider namespace: kimi/kimi-k3 → kimi-k3 (IK7K4V)
@@ -103,17 +105,22 @@ const MODEL_SPECS = [
103
105
  // Kimi For Coding endpoint uses the short model ID "k3" (same specs as kimi-k3) — IK5VGJ
104
106
  ["k3", { context: 1_000_000, maxOutput: 131_072, thinking: true, partialMode: true, multimodal: true, cacheMode: "auto", thinkApi: "effort", reasoningEcho: "required", reasoningEffortEnum: ["low", "high", "max"] }],
105
107
  // GLM series
108
+ // GLM-5.3: thinking always-on (no "disabled"); effort converges to low/high/max — NOT the
109
+ // 7-level glm-5.2 enum (verified vs docs.bigmodel.cn GLM-5.3 page, 2026-08)
110
+ ["glm-5.3", { context: 1_000_000, maxOutput: 128_000, thinking: true, cacheMode: "auto", thinkApi: "type", reasoningEcho: "optional", reasoningEffortEnum: ["low", "high", "max"], tempRange: [0, 1], noUsageStream: true }],
106
111
  ["glm-5.2", { context: 1_000_000, maxOutput: 128_000, thinking: true, cacheMode: "auto", thinkApi: "type", reasoningEcho: "optional", reasoningEffortEnum: ["max", "xhigh", "high", "medium", "low", "minimal", "none"], tempRange: [0, 1], noUsageStream: true }],
107
112
  ["glm-5", { context: 1_000_000, maxOutput: 128_000, thinking: true, cacheMode: "auto", thinkApi: "type", reasoningEcho: "optional", reasoningEffortEnum: ["max", "xhigh", "high", "medium", "low", "minimal", "none"], tempRange: [0, 1], noUsageStream: true }],
108
113
  ["glm-4", { context: 128_000, maxOutput: 32_000, thinking: true, cacheMode: "auto", thinkApi: "type", reasoningEcho: "optional", tempRange: [0, 1], noUsageStream: true }],
109
114
  // GPT series
115
+ ["gpt-5.6-sol", { context: 1_050_000, maxOutput: 128_000, thinking: false, multimodal: true, cacheMode: "prompt" }],
116
+ ["gpt-5.6", { context: 1_050_000, maxOutput: 128_000, thinking: false, multimodal: true, cacheMode: "prompt" }],
110
117
  ["gpt-4.1", { context: 1_000_000, maxOutput: 128_000, thinking: false, cacheMode: "prompt" }],
111
118
  ["gpt-4o", { context: 128_000, maxOutput: 16_000, thinking: false, multimodal: true, cacheMode: "prompt" }],
112
119
  // Qwen series
113
- ["qwen3.8-max-preview", { context: 1_000_000, maxOutput: 128_000, thinking: true, partialMode: true, multimodal: true, cacheMode: "none", thinkApi: "effort", reasoningEffortEnum: ["xhigh", "high"], tempRange: [0, 2] }],
120
+ ["qwen3.8-max-preview", { context: 1_000_000, maxOutput: 131_072, thinking: true, partialMode: true, multimodal: true, cacheMode: "none", thinkApi: "effort", reasoningEffortEnum: ["xhigh", "medium", "low"], tempRange: [0, 2] }],
114
121
  // qwen3.7-max rejects image parts outright (DashScope 400 "Unexpected item type in content") — text-only
115
- ["qwen3.7-max", { context: 1_000_000, maxOutput: 128_000, thinking: true, partialMode: true, cacheMode: "none", thinkApi: "effort", reasoningEffortEnum: ["xhigh", "high"], tempRange: [0, 2] }],
116
- ["qwen3.8-max", { context: 1_000_000, maxOutput: 128_000, thinking: true, partialMode: true, multimodal: true, cacheMode: "none", thinkApi: "effort", reasoningEffortEnum: ["xhigh", "medium", "low"], tempRange: [0, 2] }],
122
+ ["qwen3.7-max", { context: 1_000_000, maxOutput: 131_072, thinking: true, partialMode: true, cacheMode: "none", thinkApi: "effort", reasoningEffortEnum: ["xhigh", "high"], tempRange: [0, 2] }],
123
+ ["qwen3.8-max", { context: 1_000_000, maxOutput: 131_072, thinking: true, partialMode: true, multimodal: true, cacheMode: "none", thinkApi: "effort", reasoningEffortEnum: ["xhigh", "medium", "low"], tempRange: [0, 2] }],
117
124
  ["qwen-max", { context: 1_000_000, maxOutput: 131_072, thinking: false, partialMode: true, multimodal: true, cacheMode: "none", thinkApi: "effort", tempRange: [0, 2] }],
118
125
  ["qwen-plus", { context: 1_000_000, maxOutput: 131_072, thinking: false, partialMode: true, multimodal: true, cacheMode: "none", thinkApi: "effort", tempRange: [0, 2] }],
119
126
  ["qwen", { context: 1_000_000, maxOutput: 131_072, thinking: false, partialMode: true, multimodal: true, cacheMode: "none", thinkApi: "effort", tempRange: [0, 2] }],
@@ -127,6 +134,8 @@ const MODEL_SPECS = [
127
134
  ["minimax-m3", { context: 1_000_000, maxOutput: 128_000, thinking: true, multimodal: true, cacheMode: "auto", thinkApi: "type", thinkEnabledValue: "adaptive", tempRange: [0, 2], noUsageStream: true }],
128
135
  ["minimax-m1", { context: 256_000, maxOutput: 128_000, thinking: false, cacheMode: "auto", noUsageStream: true }],
129
136
  // Grok series (xAI — OpenAI-compatible)
137
+ // grok-4.x: 500K context per xAI Grok 4.6 spec (corrected 2026-08; earlier entries said 1M)
138
+ ["grok-4.6", { context: 500_000, maxOutput: 64_000, thinking: false, multimodal: true, tempRange: [0, 2] }],
130
139
  ["grok-4.5", { context: 500_000, maxOutput: 64_000, thinking: false, multimodal: true, tempRange: [0, 2] }],
131
140
  ["grok-4", { context: 500_000, maxOutput: 64_000, thinking: false, multimodal: true, tempRange: [0, 2] }],
132
141
  ["grok-4-mini", { context: 128_000, maxOutput: 16_000, thinking: false, tempRange: [0, 2] }],
@@ -134,10 +143,13 @@ const MODEL_SPECS = [
134
143
  ["mistral-large", { context: 128_000, maxOutput: 32_000, thinking: false, multimodal: true, tempRange: [0, 2] }],
135
144
  ["codestral", { context: 256_000, maxOutput: 32_000, thinking: false, tempRange: [0, 2] }],
136
145
  // Claude series (Anthropic)
146
+ ["claude-opus-5", { context: 1_000_000, maxOutput: 128_000, thinking: false, multimodal: true, cacheMode: "none", format: "anthropic" }],
147
+ ["claude-sonnet-5", { context: 1_000_000, maxOutput: 128_000, thinking: false, multimodal: true, cacheMode: "none", format: "anthropic" }],
137
148
  ["claude-opus-4", { context: 200_000, maxOutput: 32_000, thinking: false, multimodal: true, cacheMode: "none", format: "anthropic" }],
138
149
  ["claude-sonnet-4", { context: 200_000, maxOutput: 32_000, thinking: false, multimodal: true, cacheMode: "none", format: "anthropic" }],
139
150
  ["claude-3.5-haiku", { context: 200_000, maxOutput: 8_192, thinking: false, cacheMode: "none", format: "anthropic" }],
140
151
  // Gemini series (Google)
152
+ ["gemini-3-pro", { context: 1_000_000, maxOutput: 64_000, thinking: false, multimodal: true, cacheMode: "none", format: "google", noUsageStream: true }],
141
153
  ["gemini-2.5-pro", { context: 2_000_000, maxOutput: 64_000, thinking: false, multimodal: true, cacheMode: "none", format: "google", noUsageStream: true }],
142
154
  ["gemini-2.5-flash", { context: 1_000_000, maxOutput: 64_000, thinking: false, multimodal: true, cacheMode: "none", format: "google", noUsageStream: true }],
143
155
  ]
package/src/context.mjs CHANGED
@@ -214,7 +214,7 @@ export async function compressIfNeeded(agent, threshold, callbacks, extras = {},
214
214
  const middle = history.slice(split.headEnd, split.tailStart)
215
215
  const serialized = middle
216
216
  .map((m) => {
217
- const toolNote = m.tool_calls ? ` [called tools: ${m.tool_calls.map((t) => t.function.name).join(", ")}]` : ""
217
+ const toolNote = m.tool_calls ? ` [called tools: ${m.tool_calls.map((t) => t.function?.name).join(", ")}]` : ""
218
218
  // user messages get a wider cap (8000): cutting off a long user-pasted requirement loses original intent; tool/assistant capped at 2000 is enough
219
219
  const cap = m.role === "user" ? 8000 : 2000
220
220
  // Multimodal messages (array content): extract the TEXT parts — the image itself can't be
package/src/escape.mjs ADDED
@@ -0,0 +1,49 @@
1
+ /**
2
+ * escape.mjs — 中和 OpenAI 兼容服务端在 message content 内做的非标二次转义解析。
3
+ *
4
+ * 某些服务端(Kimi 等)会把 content 里的字面 "\x" / "\u" 当作 hex escape 再解释一遍,
5
+ * 遇到 "\x" 后不足 2 个 hex(或 "\u" 后不足 4 个 hex)时服务端报
6
+ * "unexpected end of hex escape" → 400(首次观察于 2026-08-06,见 advisor.mjs 历史)。
7
+ *
8
+ * 对策:把这类"一旦被服务端二次展开就会非法"的字面序列提前 double 成 "\\x" / "\\u",
9
+ * 服务端二次解析后还原为字面量;合法完整的 "\xNN" / "\uNNNN" 原样放过(它们能展开成
10
+ * 一个字节/码点)。JSON.stringify 层面的反斜杠转义由发送方负责,本模块不碰。
11
+ */
12
+
13
+ /** 中和单段文本里的非法字面转义序列。 */
14
+ export function escapeLiteralEscapes(text) {
15
+ text = String(text ?? "")
16
+ return text
17
+ // (?<!\\) — 只有单个反斜杠才处理("\\x" 已经是 double 的,必须原样放过)
18
+ // 前瞻:\x 后至少 2 个 hex 视为合法(服务端只展开前两个),只有不足 2 个的才 double
19
+ .replace(/(?<!\\)\\(x)(?![0-9a-fA-F]{2})/g, "\\\\$1")
20
+ .replace(/(?<!\\)\\(u)(?![0-9a-fA-F]{4})/g, "\\\\$1")
21
+ }
22
+
23
+ /** 对单条消息的 content 应用 escapeLiteralEscapes(支持字符串或 OpenAI 多模态 part 数组)。 */
24
+ export function escapeMessageContent(message) {
25
+ const content = message?.content
26
+ if (typeof content === "string") {
27
+ return { ...message, content: escapeLiteralEscapes(content) }
28
+ }
29
+ if (Array.isArray(content)) {
30
+ let changed = false
31
+ const parts = content.map((p) => {
32
+ if (p && typeof p === "object" && p.type === "text" && typeof p.text === "string") {
33
+ const escaped = escapeLiteralEscapes(p.text)
34
+ if (escaped !== p.text) {
35
+ changed = true
36
+ return { ...p, text: escaped }
37
+ }
38
+ }
39
+ return p
40
+ })
41
+ return changed ? { ...message, content: parts } : message
42
+ }
43
+ return message
44
+ }
45
+
46
+ /** 对整个 messages 数组逐条应用 escapeMessageContent。 */
47
+ export function escapeMessages(messages) {
48
+ return messages.map(escapeMessageContent)
49
+ }
@@ -2,8 +2,13 @@
2
2
  * generate-title.mjs — LLM-generated session titles (CLI side)
3
3
  * Called after the first user message to auto-title the session.
4
4
  * Mirrors thincoder-vscode/src/extension/generate-title.mjs but uses the CLI provider shape.
5
+ *
6
+ * CLI is OpenAI-compatible ONLY: a single direct fetch (no anthropic/google format dispatch) —
7
+ * see docs/design/SESSION.md §IK9UZ8-D.
5
8
  */
6
9
 
10
+ const MAX_TITLE_TOKENS = 100
11
+
7
12
  /** Generate a session title from the first user message using an LLM. Returns title string or null. */
8
13
  export async function generateTitle(userContent, provider) {
9
14
  // Extract text even from multimodal content (array of parts)
@@ -20,7 +25,12 @@ export async function generateTitle(userContent, provider) {
20
25
  { role: "system", content: "Generate a concise title (max 40 chars, no quotes) for this conversation. Reply ONLY with the title." },
21
26
  { role: "user", content: userText.slice(0, 200) },
22
27
  ],
23
- max_tokens: 30,
28
+ // Disable thinking so reasoning_content doesn't consume the whole output budget and
29
+ // leave content empty (IK9UZ8). Providers that don't accept the field ignore it
30
+ // (OpenAI-compatible convention). A 40-char title wants ~60–80 tokens, so 100 is
31
+ // ~2.5x headroom (design decision — docs/design/SESSION.md §IK9UZ8-D).
32
+ thinking: { type: "disabled" },
33
+ max_tokens: MAX_TITLE_TOKENS,
24
34
  stream: false,
25
35
  })
26
36
  const chatPath = provider.chatPath ?? "/chat/completions"
@@ -41,4 +51,4 @@ export async function generateTitle(userContent, provider) {
41
51
  } catch {
42
52
  return null
43
53
  }
44
- }
54
+ }
@@ -17,4 +17,11 @@ UI & interface design:
17
17
  - Free-text is correct ONLY when the input is genuinely open-ended (a name, a path, a message).
18
18
 
19
19
  Review discipline (standard mode only — engineering mode has its own review timing rules):
20
- - **Advisor:** call after changing code. Must provide scope: `paths` (files/dirs to review) or `documents` (context). Response table: `| # | Action | Detail |`. Round 2 verifies the prior issue table + flags obvious new issues; round 3+ strictly verifies only the prior issue table (no new-issue hunting). Max 5 rounds total.
20
+ - **Advisor:** call after changing code. Must provide scope: `paths` (files/dirs to review) or `documents` (context).
21
+ - **After each advisor review, reply with a response table** — exact header `| # | Action | Detail |` (the runtime extracts this header; keep it verbatim). One row per issue; `#` = the advisor's issue number (`Orig#` on rounds 2+).
22
+ - `Action` is one of exactly three values: `Fixed` (you edited the code), `Not an issue` (technical rebuttal with evidence), `Deferred` (admitted, not fixed now — with a reason).
23
+ - `Detail` = what changed and where (file:line), or your evidence/reason.
24
+ - **No "pre-existing" cop-out.** You own the whole code. "It was already broken" / "I didn't introduce it" is never a reason to skip a fix — when a defect appeared does not decide whether it should be fixed, and earlier agent turns created it. Rebut only on technical grounds, otherwise fix it.
25
+ - **Do not bury 🔴.** A 🔴 you neither fix nor rebut blocks convergence. `Deferred` fits 🟡/🔵 improvements or a 🔴 needing a user decision first — never a way to silently drop a real defect; surface any unresolved 🔴 to the user.
26
+ - Round 2 verifies the prior table + flags obvious new issues; round 3+ strictly verifies only the prior table (no new-issue hunting). Max 5 rounds total.
27
+ - When the advisor reports all clear (no 🔴 remaining), run `verify`.
@@ -122,6 +122,19 @@ cannot enumerate. When using the `question` tool:
122
122
  `/advisor` toggle state. Use `advisor`'s configured model if set; otherwise
123
123
  the main model is used automatically. The key property is independent
124
124
  context — every review runs in a fresh isolated session.
125
+ - **Advisor response table.** After each advisor review you run, reply with a
126
+ response table — exact header `| # | Action | Detail |`, one row per issue;
127
+ `#` = the advisor's issue number (`Orig#` on rounds 2+).
128
+ - `Action` is one of exactly three values: `Fixed` (you edited the code), `Not an issue` (technical rebuttal with evidence), `Deferred` (admitted, not fixed now — with a reason).
129
+ - `Detail` = what changed and where (file:line), or your evidence/reason.
130
+ - No "pre-existing" cop-out: "it was already broken" is never a reason to drop
131
+ a finding — you own the whole design/code, and when a defect appeared does
132
+ not decide whether it should be fixed. If a finding is outside the approved
133
+ design's scope, surface it or propose a design update — do not silently
134
+ ignore it.
135
+ - A 🔴 you neither fix nor surface blocks convergence. `Deferred` fits 🟡/🔵
136
+ improvements or a 🔴 needing a user decision first — never a way to silently
137
+ drop a real defect; surface any unresolved 🔴 to the user.
125
138
  - **Review timing**: do NOT call advisor unprompted or repeatedly. Reviews
126
139
  happen only when: the user explicitly asks, the system pushes back, or a
127
140
  mandatory flow node requires it (the eng-coder self-reviews before delivery —
@@ -12,7 +12,8 @@ Programming is collaborative labor between you and the human. The human decides
12
12
  - **Check existing code.** Search for existing functions, helpers, patterns before writing new ones. Duplicates are technical debt.
13
13
  - **Understand intent.** Ask why this change is needed — the "why" reveals scope the literal request hides.
14
14
  - **Confirm understanding.** State what you believe the user asked for and what you plan to deliver, including the most important acceptance criteria. Wait for confirmation. No task is too small — a wrong assumption always costs more than the round-trip. Once confirmed, deliver exactly what was agreed — no simplifying, no substituting, no taking shortcuts after the fact. Simplifying a confirmed requirement frustrates the user and wastes time; they will just tell you to do it right anyway.
15
- - **Confirm before any file-writing action — no exemptions.** Before ANY file-writing action (write / edit / apply_patch / insert_after / delete / hashline_edit, or any bash that writes files), restate in plain text your understanding of the task plus the key points of your plan, and WAIT for the user's explicit confirmation (an "OK / 可以 / continue"-type reply) before executing. No confirmation, silence, or the user answering with a new question or a new requirement → do not touch anything, no matter how small or obvious the change seems. Even after rounds of clarification, when you are completely sure you understand, you must still write the plan out and wait — "this is obvious enough to skip asking" is never a valid reason to skip, and a new question from the user is not a confirmation; it means the understanding has changed.
15
+ - **Confirm before any file-writing action.** Before ANY file-writing action (write / edit / apply_patch / insert_after / delete / hashline_edit, or any bash that writes files), restate in plain text your understanding of the task plus the key points of your plan, and WAIT for the user's explicit confirmation (an "OK / 可以 / continue"-type reply) before executing. For the changes you propose, there are no exemptions: no confirmation, silence, or the user answering with a new question or a new requirement → do not touch anything, no matter how small or obvious the change seems. Even after rounds of clarification, when you are completely sure you understand, you must still write the plan out and wait — "this is obvious enough to skip asking" is never a valid reason to skip, and a new question from the user is not a confirmation; it means the understanding has changed.
16
+ - **Doc/code consistency outranks this gate (the one carve-out).** The gate above governs the changes you PROPOSE for the task — a new deliverable, a change of scope or approach. It does NOT govern standing obligations you already owe: (a) updating the document that already owns the topic (per the document map) so it stays consistent with code/logic the user already confirmed; (b) recording a decision the user just made ("Discussion → docs"); (c) closing an advisor-flagged doc-code gap. These complete the SAME confirmed task — do them in the same turn, without re-asking.
16
17
  - **Re-confirm when the requirement changes.** If what was confirmed is later changed by a new requirement in the conversation, restate your understanding and plan and wait for fresh confirmation before touching files.
17
18
 
18
19
  **How you work — while coding:**
@@ -6,6 +6,7 @@
6
6
 
7
7
  import { specForModel } from "../config.mjs"
8
8
  import { proxyFetch } from "../proxy.mjs"
9
+ import { escapeMessages } from "../escape.mjs"
9
10
  import { readSSE } from "./sse.mjs"
10
11
  export { readSSE } from "./sse.mjs"
11
12
  import {
@@ -68,6 +69,10 @@ export async function chat(provider, { messages, tools, onToken, onReasoning, on
68
69
  }
69
70
 
70
71
  messages = normalizeToolPairing(messages)
72
+ // 中和服务端的非标二次转义:会话里若出现字面 "\x"/"\u"(如讨论转义、grep 到含
73
+ // 转义的代码),Kimi 等会把它们当 hex escape 再解析 → "unexpected end of hex escape" 400。
74
+ // 发送前统一 double 掉会形成非法转义的序列(合法 \xNN/\uNNNN 不受影响)。
75
+ messages = escapeMessages(messages)
71
76
  // Compile string-pattern rules to RegExp at call time
72
77
  const rules = compileStreamRules(streamRules)
73
78
  const body = {
@@ -19,6 +19,56 @@ export function normalizeUsageCache(u) {
19
19
  }
20
20
  return u
21
21
  }
22
+ /** Defensive tool-call merge (PROVIDER.md §10): skip null/malformed elements and count them;
23
+ * merge slots by index / id / name / tail, accumulate arguments. */
24
+ function mergeToolCalls(result, delta) {
25
+ for (const tc of delta.tool_calls ?? []) {
26
+ if (!tc || typeof tc !== "object") { result.droppedToolCalls++; continue }
27
+ let slot
28
+ if (Number.isInteger(tc.index) && tc.index >= 0) {
29
+ slot = (result.toolCalls[tc.index] ??= { id: "", name: "", arguments: "" })
30
+ } else if (tc.id) {
31
+ slot = result.toolCalls.find((s) => s && s.id === tc.id)
32
+ if (!slot) { slot = { id: tc.id, name: "", arguments: "" }; result.toolCalls.push(slot) }
33
+ } else if (tc.function?.name) {
34
+ slot = { id: "", name: "", arguments: "" }
35
+ result.toolCalls.push(slot)
36
+ } else {
37
+ slot = result.toolCalls[result.toolCalls.length - 1]
38
+ if (!slot) { result.droppedToolCalls++; continue }
39
+ }
40
+ if (tc.id && !slot.id) slot.id = tc.id
41
+ if (tc.function?.name && !slot.name) slot.name = tc.function.name
42
+ const arg = tc.function?.arguments
43
+ if (typeof arg === "string") slot.arguments += arg
44
+ else if (arg != null) slot.arguments += JSON.stringify(arg)
45
+ }
46
+ }
47
+
48
+ /** Finalize tool calls (PROVIDER.md §10): drop nameless slots, synthesize missing ids,
49
+ * count drops, and surface a machine-line warning via the existing `_warnings` channel. */
50
+ function finalizeToolCalls(result) {
51
+ const entries = result.toolCalls.filter((tc) => tc) // drop sparse holes (rule-1 index jumps)
52
+ const kept = entries.filter((tc) => tc.name) // drop nameless slots
53
+ result.droppedToolCalls = (result.droppedToolCalls ?? 0) + (entries.length - kept.length)
54
+ result.toolCalls = kept
55
+ const used = new Set(kept.map((tc) => tc.id).filter(Boolean))
56
+ let seq = 0
57
+ for (const tc of kept) {
58
+ if (!tc.id) {
59
+ let id
60
+ do { id = `call_${seq++}` } while (used.has(id))
61
+ tc.id = id
62
+ used.add(id)
63
+ }
64
+ }
65
+ if (result.droppedToolCalls > 0) {
66
+ const existing = (result._warnings ??= [])
67
+ if (!existing.some((w) => w.name === "malformed-tool-calls")) {
68
+ existing.push({ name: "malformed-tool-calls", message: `${result.droppedToolCalls} malformed tool_calls dropped from provider response` })
69
+ }
70
+ }
71
+ }
22
72
 
23
73
  export async function readSSE(response, { onToken, onReasoning, rules, signal, firedPatterns: sharedFired }) {
24
74
  // Early intercept: non-SSE responses — either error bodies (HTTP >= 400) or
@@ -46,19 +96,15 @@ export async function readSSE(response, { onToken, onReasoning, rules, signal, f
46
96
  const parsed = JSON.parse(body)
47
97
  const choice = parsed.choices?.[0]
48
98
  if (choice) {
49
- const result = { content: "", reasoning: "", toolCalls: [], usage: normalizeUsageCache(parsed.usage ?? null), finishReason: null }
99
+ const result = { content: "", reasoning: "", toolCalls: [], droppedToolCalls: 0, usage: normalizeUsageCache(parsed.usage ?? null), finishReason: null }
50
100
  const delta = choice.delta ?? {}
51
101
  result.content = delta.content ?? ""
52
102
  result.reasoning = delta.reasoning_content ?? ""
53
103
  result.finishReason = choice.finish_reason ?? null
54
- for (const tc of delta.tool_calls ?? []) {
55
- const slot = (result.toolCalls[tc.index ?? result.toolCalls.length] ??= { id: "", name: "", arguments: "" })
56
- if (tc.id) slot.id = tc.id
57
- if (tc.function?.name && !slot.name) slot.name = tc.function.name
58
- if (tc.function?.arguments) slot.arguments += tc.function.arguments
59
- }
104
+ mergeToolCalls(result, delta)
60
105
  if (result.content) onToken?.(result.content)
61
106
  if (result.reasoning) onReasoning?.(result.reasoning)
107
+ finalizeToolCalls(result)
62
108
  return result
63
109
  }
64
110
  } catch { /* not parseable JSON */ }
@@ -66,7 +112,7 @@ export async function readSSE(response, { onToken, onReasoning, rules, signal, f
66
112
  throw new Error(`API error: HTTP ${response.status} — unexpected non-SSE response: ${body.slice(0, 200)}`)
67
113
  }
68
114
 
69
- const result = { content: "", reasoning: "", toolCalls: [], usage: null, finishReason: null }
115
+ const result = { content: "", reasoning: "", toolCalls: [], droppedToolCalls: 0, usage: null, finishReason: null }
70
116
  const decoder = new TextDecoder()
71
117
  let buffer = ""
72
118
  let hasChoices = false
@@ -96,12 +142,7 @@ export async function readSSE(response, { onToken, onReasoning, rules, signal, f
96
142
  result.content += delta.content
97
143
  onToken?.(delta.content)
98
144
  }
99
- for (const tc of delta.tool_calls ?? []) {
100
- const slot = (result.toolCalls[tc.index] ??= { id: "", name: "", arguments: "" })
101
- if (tc.id) slot.id = tc.id
102
- if (tc.function?.name && !slot.name) slot.name = tc.function.name
103
- if (tc.function?.arguments) slot.arguments += tc.function.arguments
104
- }
145
+ mergeToolCalls(result, delta)
105
146
  }
106
147
  }
107
148
 
@@ -165,5 +206,6 @@ export async function readSSE(response, { onToken, onReasoning, rules, signal, f
165
206
  throw new Error(`API error: ${errorMsg}`)
166
207
  }
167
208
 
209
+ finalizeToolCalls(result)
168
210
  return result
169
211
  }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * session-migrate.mjs — one-time migration of legacy short-hash session files to the
3
+ * full 40-char sha1 hash. Extracted from session.mjs (file-size split).
4
+ */
5
+ import { createHash } from "node:crypto"
6
+ import { renameSync, existsSync } from "node:fs"
7
+ import { join } from "node:path"
8
+ import { configDir } from "./config.mjs"
9
+
10
+ /** One-time migration: rename legacy short-hash session files to the full 40-char hash.
11
+ * Idempotent; runs on first access per cwd.
12
+ * Historical hash algorithms (all sha1, none normalized the drive letter):
13
+ * - CLI: sha1(cwd).slice(0, 12) — cwd comes from process.cwd() (uppercase drive on Windows)
14
+ * - VS Code: sha1(cwd).slice(0, 16) — cwd comes from uri.fsPath (LOWERCASE drive on Windows)
15
+ * Plus the previous migration attempt's assumption (normalized 12 = first 12 of the full hash).
16
+ * Every combination is tried — a migration that only checks one candidate misses real
17
+ * legacy files (drive-letter case differs between CLI and VS Code historical paths). */
18
+ export function migrateHashLength(cwd, fullHash) {
19
+ const dir = join(configDir, "sessions")
20
+ const lower = cwd.replace(/^([A-Z]):/, (_, d) => d.toLowerCase() + ":")
21
+ const candidates = [
22
+ createHash("sha1").update(cwd).digest("hex").slice(0, 12),
23
+ createHash("sha1").update(cwd).digest("hex").slice(0, 16),
24
+ createHash("sha1").update(lower).digest("hex").slice(0, 12),
25
+ createHash("sha1").update(lower).digest("hex").slice(0, 16),
26
+ fullHash.slice(0, 12),
27
+ ]
28
+ const newBase = join(dir, `${fullHash}.json`)
29
+ let migrated = false
30
+ for (const short of new Set(candidates)) {
31
+ const legacyBase = join(dir, `${short}.json`)
32
+ if (!existsSync(legacyBase) && !existsSync(`${legacyBase}.manifest`) && !existsSync(`${legacyBase}.1`)) continue
33
+ migrated = true
34
+ try {
35
+ for (const suffix of ["", ".manifest", ...Array.from({ length: 64 }, (_, i) => `.${i + 1}`)]) {
36
+ const from = legacyBase + suffix
37
+ if (existsSync(from) && !existsSync(newBase + suffix)) renameSync(from, newBase + suffix)
38
+ }
39
+ } catch { /* best-effort; leave files in place on failure */ }
40
+ }
41
+ return migrated
42
+ }