thincoder 0.12.3 → 0.12.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -89,6 +89,8 @@ Slash commands in the TUI: `/help`, `/model` (two-level picker: first select pro
89
89
 
90
90
  Environment variables: `THINCODER_API_KEY` (or `DEEPSEEK_API_KEY` / `OPENAI_API_KEY`), `THINCODER_BASE_URL`, `THINCODER_MODEL`, `SILICONFLOW_API_KEY`.
91
91
 
92
+ > **Kimi note**: Kimi has **two separate platforms with non-interchangeable API keys** — Moonshot (`https://api.moonshot.cn/v1`, keys `sk-...`, platform.moonshot.cn) and **Kimi For Coding** (`https://api.kimi.com/coding/v1`, keys `sk-kimi-...`, platform.kimi.com, model ID `k3`). Use the `kimi` preset for Moonshot and `kimi-code` for Kimi For Coding — putting one platform's key on the other's endpoint fails with 401 (a hint is appended when the key/baseURL look mismatched).
93
+
92
94
  ## Configuration
93
95
 
94
96
  `~/.thincoder/config.json`:
@@ -171,7 +173,8 @@ src/
171
173
  distill.mjs session knowledge extraction (candidates + human confirmation)
172
174
  config.mjs config loading
173
175
  tui/ bare-ANSI terminal UI — index.mjs (startTUI), render.mjs (drawing primitives),
174
- render-frame.mjs (frame layout), ansi.mjs
176
+ render-frame.mjs (frame layout), render-conversation.mjs (conversation panel),
177
+ markdown.mjs (lightweight inline markdown → ANSI), ansi.mjs
175
178
  tui.mjs re-export shim → src/tui/index.mjs
176
179
  tui-render.mjs re-export shim → src/tui/render.mjs
177
180
  prompts/ prompt texts — system.md (core), discipline.md (coding/testing rules),
@@ -205,6 +208,13 @@ Code conventions: pure `.mjs`, no semicolons, no npm dependencies allowed (inclu
205
208
 
206
209
  ## Changelog
207
210
 
211
+ ### 0.12.4 (2026-08)
212
+ - **Context compaction unified spec** — CLI/VS Code now share one compaction semantics (`docs/design/CONTEXT-COMPACTION.md`): window-adaptive tail size (`max(10, ctx/100K×30)`, ≤40% of history), measured prompt-token baseline preferred, pure-estimation path includes system+tools overhead, head/tail tool-call pairing protection on both sides, 3-tier fallback (LLM summary → deterministic truncation after 3 failures → per-message shrink), compaction summaries are silent to the frontend (no streaming into the conversation), task re-injection deduplicated. Unknown model names now warn once instead of silently degrading to the 128K default
213
+ - **Kimi For Coding support** — new `kimi-code` preset (`api.kimi.com/coding/v1`, model `k3`, platform.kimi.com, `sk-kimi-` keys — NOT interchangeable with Moonshot); `MODEL_SPECS` gains the `k3` alias (1M context / multimodal / partialMode / reasoningEcho); 401 errors hint at the two-platform key mismatch; README documents the split
214
+ - **Ctrl+C double-confirm** — idle-state first Ctrl+C only warns (3s window), second press exits; picker-cancel and in-flight abort semantics unchanged
215
+ - **Empty-response auto-retry** — a transient empty LLM response (reasoning exhausted / truncated output) injects a retry reminder instead of aborting the whole turn; after 2 consecutive empties the original error surfaces (with the `/think` lowering hint)
216
+ - **Lightweight inline markdown display** — model replies render `**bold**`, `` `code` `` (reverse video), `~~strike~~`, and `# headings` (marker-stripped + bold) via ANSI instead of showing literal markers; code spans are not re-interpreted; streaming-safe on unclosed markers; copied text comes out clean
217
+
208
218
  ### 0.12.3 (2026-08)
209
219
  - **Fix: user-level skills loading** — skills in `~/.thincoder/skills/` are now properly discovered and loaded alongside project-level skills. Project-level skills with the same name take priority. Both `skill list` and `skill load` support both directories.
210
220
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thincoder",
3
- "version": "0.12.3",
3
+ "version": "0.12.4",
4
4
  "description": "Thin coding agent - zero dependencies, no build step, Node.js native. Sharp code, zero bloat.",
5
5
  "keywords": [
6
6
  "ai",
@@ -30,8 +30,8 @@
30
30
  "url": "https://gitee.com/shanghai-xinbo/thincoder.git"
31
31
  },
32
32
  "scripts": {
33
- "test": "node --test",
34
- "prepublishOnly": "node --test"
33
+ "test": "node --test \"test/*.mjs\"",
34
+ "prepublishOnly": "node --test \"test/*.mjs\""
35
35
  },
36
36
  "author": "liwei <liwei@51marine.com> (上海新舶)",
37
37
  "license": "MIT"
@@ -5,6 +5,7 @@
5
5
  * Returns { action: 'continue' | 'done', content?, guardPushbacks, honestReminderInjected, advisorPushbacks }
6
6
  */
7
7
  import { isDocFile } from "../advisor/repos.mjs"
8
+ import { pushReal } from "../context.mjs"
8
9
 
9
10
  /** True when this run mutated at least one CODE file. Mirrors agent.mjs:hasCodeMutations. */
10
11
  function hasCodeMutations(agent) {
@@ -16,6 +17,7 @@ function hasCodeMutations(agent) {
16
17
  const MAX_VERIFY_PUSHBACKS = 2
17
18
  const MAX_VERIFY_RETRIES = 3
18
19
  const MAX_ADVISOR_PUSHBACKS = 3
20
+ const MAX_EMPTY_RETRIES = 2
19
21
 
20
22
  /**
21
23
  * Handle a model turn with zero tool calls. May push back (verify/advisor/pending tasks)
@@ -32,6 +34,19 @@ const MAX_ADVISOR_PUSHBACKS = 3
32
34
  */
33
35
  export function handleCompletion(agent, response, depth, turn, guardPushbacks, honestReminderInjected, advisorPushbacks, callbacks) {
34
36
  if (!response.content) {
37
+ // Transient empty response (reasoning exhausted / output truncated): instead of
38
+ // aborting the whole turn, inject a reminder and let the model respond again.
39
+ // Bounded — after MAX_EMPTY_RETRIES consecutive empties, surface the original error.
40
+ const retries = agent._emptyRetries ?? 0
41
+ if (retries < MAX_EMPTY_RETRIES) {
42
+ agent._emptyRetries = retries + 1
43
+ agent.history.push({
44
+ role: "user",
45
+ content: "[System reminder: your last response was empty — the provider returned no content (likely reasoning was exhausted or output was truncated). Respond again, continuing your work from where you left off.]",
46
+ })
47
+ callbacks.onTurnEnd?.(agent, turn)
48
+ return { action: "continue", guardPushbacks, honestReminderInjected, advisorPushbacks }
49
+ }
35
50
  throw new Error(
36
51
  "LLM returned empty response (likely reasoning exhausted or output truncated). " +
37
52
  "Try lowering reasoning effort if this persists (/think in TUI). " +
@@ -42,7 +57,7 @@ export function handleCompletion(agent, response, depth, turn, guardPushbacks, h
42
57
  // Pending tasks: remind the model before it declares itself done
43
58
  if (depth === 0 && agent.tasks.some((t) => t.status === "pending")) {
44
59
  const pending = agent.tasks.filter((t) => t.status === "pending").map((t) => t.title).join(", ")
45
- agent.history.push({ role: "assistant", content: response.content })
60
+ pushReal(agent, { role: "assistant", content: response.content })
46
61
  agent.history.push({
47
62
  role: "user",
48
63
  content: `[System reminder: you still have pending tasks: ${pending}. Update their status with the task tool before finishing — if they're done, mark them done; if they're not applicable, remove them.]`,
@@ -60,7 +75,7 @@ export function handleCompletion(agent, response, depth, turn, guardPushbacks, h
60
75
  // Not verified yet → pushback to run verify
61
76
  if (agent._mutatedThisRun && !agent._verifiedThisRun && hasCodeMutations(agent) && guardPushbacks < MAX_VERIFY_PUSHBACKS) {
62
77
  guardPushbacks++
63
- agent.history.push({ role: "assistant", content: response.content })
78
+ pushReal(agent, { role: "assistant", content: response.content })
64
79
  agent.history.push({
65
80
  role: "user",
66
81
  content: "[System reminder: you modified files in this run but have not verified the changes. Before finishing: call the verify tool to run syntax checks and tests. If verify reports failures, fix them and run verify again. If verification is genuinely impossible here, say so explicitly in your reply.]",
@@ -71,7 +86,7 @@ export function handleCompletion(agent, response, depth, turn, guardPushbacks, h
71
86
  // Verified but still failing → pushback to fix (up to MAX_VERIFY_RETRIES)
72
87
  if (agent._verifiedThisRun && agent._verifyPassed === false && agent._verifyRetries < MAX_VERIFY_RETRIES) {
73
88
  agent._verifyRetries++
74
- agent.history.push({ role: "assistant", content: response.content })
89
+ pushReal(agent, { role: "assistant", content: response.content })
75
90
  agent.history.push({
76
91
  role: "user",
77
92
  content: `[System reminder: verify reported test failures (retry ${agent._verifyRetries}/${MAX_VERIFY_RETRIES}). Review the failures, fix the issues, then run verify again. If you cannot fix after ${MAX_VERIFY_RETRIES} attempts, explain honestly what's blocking you.]`,
@@ -82,11 +97,11 @@ export function handleCompletion(agent, response, depth, turn, guardPushbacks, h
82
97
  // Exhausted retries — inject honesty reminder once
83
98
  if (agent._verifiedThisRun && agent._verifyPassed === false && agent._verifyRetries >= MAX_VERIFY_RETRIES) {
84
99
  if (honestReminderInjected) {
85
- agent.history.push({ role: "assistant", content: response.content })
100
+ pushReal(agent, { role: "assistant", content: response.content })
86
101
  return { action: "done", content: response.content, guardPushbacks, honestReminderInjected, advisorPushbacks }
87
102
  }
88
103
  honestReminderInjected = true
89
- agent.history.push({ role: "assistant", content: response.content })
104
+ pushReal(agent, { role: "assistant", content: response.content })
90
105
  agent.history.push({
91
106
  role: "user",
92
107
  content: `[System reminder: ${MAX_VERIFY_RETRIES} verify attempts exhausted and tests are still failing. In your response to the user, you MUST state explicitly: (1) what tests are still failing, (2) what you tried, (3) what you believe the root cause is. Do not present this as complete — the user needs to know the work is unfinished.]`,
@@ -104,7 +119,7 @@ export function handleCompletion(agent, response, depth, turn, guardPushbacks, h
104
119
  if (agent._mutatedThisRun && !agent._calledAdvisorThisRun && hasCodeMutations(agent)
105
120
  && advisorPushbacks < MAX_ADVISOR_PUSHBACKS) {
106
121
  advisorPushbacks++
107
- agent.history.push({ role: "assistant", content: response.content })
122
+ pushReal(agent, { role: "assistant", content: response.content })
108
123
  agent.history.push({
109
124
  role: "user",
110
125
  content: `[System reminder: you changed code in this run and MUST get an advisor review before finishing (round ${agent._advisorRound + 1}). Call the \`advisor\` tool now. This is required, not optional — do not skip it even if you believe the changes are trivial — the review will be quick either way. After the review, produce a response table for every issue found (see discipline rules for format).]`,
@@ -114,6 +129,6 @@ export function handleCompletion(agent, response, depth, turn, guardPushbacks, h
114
129
  }
115
130
  }
116
131
 
117
- agent.history.push({ role: "assistant", content: response.content })
132
+ pushReal(agent, { role: "assistant", content: response.content })
118
133
  return { action: "done", content: response.content, guardPushbacks, honestReminderInjected, advisorPushbacks }
119
134
  }
@@ -2,6 +2,7 @@
2
2
  * agent/setup.mjs — runAgent pre-flight setup: context injection, system prompt construction, tool injection
3
3
  */
4
4
  import { search as memorySearch, docSearch } from "../memory.mjs"
5
+ import { pushReal } from "../context.mjs"
5
6
  import { toOpenAISchema } from "../tools/index.mjs"
6
7
  import { loadSkills, formatSkillListing } from "../skills.mjs"
7
8
  import {
@@ -137,7 +138,7 @@ export async function prepareRun(agent, input, callbacks, {
137
138
  }
138
139
  } catch { /* checklist not available — suppress error */ }
139
140
  }
140
- agent.history.push({ role: "user", content: input })
141
+ pushReal(agent, { role: "user", content: input })
141
142
  }
142
143
 
143
144
  if (agent._pendingReminders.length > 0) {
package/src/agent.mjs CHANGED
@@ -3,7 +3,8 @@
3
3
  * LLM ↔ tool-call loop, until the task is done.
4
4
  */
5
5
  import { chat } from "./provider/index.mjs"
6
- import { compressIfNeeded, compressFallback, COMPRESS_FAILURE_LIMIT } from "./context.mjs"
6
+ import { estimateText } from "./provider/rate.mjs"
7
+ import { compressIfNeeded, compressFallback, COMPRESS_FAILURE_LIMIT, pushReal } from "./context.mjs"
7
8
  import { specForModel } from "./config.mjs"
8
9
  import { readFileSync } from "node:fs"
9
10
  import { join, dirname } from "node:path"
@@ -131,6 +132,8 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
131
132
  agent._advisorRound = 0
132
133
  agent._advisorSession = null // advisor session is per-run: discard when the task ends, next task starts fresh
133
134
  agent._advisorLastSnapshotHash = null // dedup baseline is per-run too — stale snapshot could wrongly suppress a diff refresh
135
+ agent._emptyRetries = 0 // empty-response retry budget is per-run: a fresh user turn restarts from zero
136
+ agent._compressFailures = 0 // compaction summary-failure counter is per-run: a fresh user turn restarts from zero
134
137
  }
135
138
  // eng-coder authorization is set by subagent.mjs AFTER token validation but BEFORE runAgent —
136
139
  // only reset for the top-level agent (depth 0); child runs must keep their granted authorization
@@ -145,6 +148,16 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
145
148
  // this set survives across chat() calls (rule abort-retry, tool loop) within the turn.
146
149
  const streamRuleFired = new Set()
147
150
 
151
+ // Compaction overhead for the pure-estimation path: system prompt + tools schema are
152
+ // part of every request but not in history — without them the first-turn/restored/just-
153
+ // compacted estimate under-counts and may never trigger compaction. Measured baseline
154
+ // path already includes both (prompt_tokens is the full context), so this only applies
155
+ // when _lastPromptTokens is null.
156
+ const compactionOverhead = {
157
+ systemPrompt,
158
+ tools: toolSchemas,
159
+ }
160
+
148
161
  for (let turn = 0; turn < maxTurns; turn++) {
149
162
  // Update turn counter for status bar display
150
163
  agent._currentTurn = turn + 1
@@ -153,7 +166,7 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
153
166
  const lastRole = agent.history.at(-1)?.role
154
167
  if (lastRole === "user" || lastRole === "tool") {
155
168
  try {
156
- if (await compressIfNeeded(agent, threshold, callbacks)) {
169
+ if (await compressIfNeeded(agent, threshold, callbacks, compactionOverhead)) {
157
170
  agent._compressFailures = 0
158
171
  agent._planReminderAtLen = 0 // After compression history shrinks, reset cadence so reminders resume
159
172
  recentCallSigs.length = 0 // After compression history is rebuilt, reset stall detection counter
@@ -236,7 +249,7 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
236
249
  // inject rule's message as a reminder, and retry from the same context.
237
250
  if (response.ruleTriggered) {
238
251
  if (response.content) {
239
- agent.history.push({ role: "assistant", content: response.content })
252
+ pushReal(agent, { role: "assistant", content: response.content })
240
253
  }
241
254
  const label = response.ruleName ? ` — stream rule "${response.ruleName}"` : ""
242
255
  agent.history.push({
@@ -262,7 +275,7 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
262
275
  // the outer loop to recreate the controller and resume.
263
276
  if (response.interrupted) {
264
277
  if (response.content) {
265
- agent.history.push({ role: "assistant", content: response.content })
278
+ pushReal(agent, { role: "assistant", content: response.content })
266
279
  }
267
280
  agent.history.push({
268
281
  role: "user",
@@ -306,7 +319,7 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
306
319
  // abort after chat completes, before committing history: don't commit a half-finished turn
307
320
  if (signal?.aborted) throw new DOMException("Aborted", "AbortError")
308
321
 
309
- agent.history.push({
322
+ pushReal(agent, {
310
323
  role: "assistant",
311
324
  content: response.content || null,
312
325
  tool_calls: response.toolCalls.map((tc) => ({
@@ -335,6 +348,12 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
335
348
  guardPushbacks = 0
336
349
  advisorPushbacks = 0
337
350
 
351
+ // Multimodal user messages (injected images / not-injected reminders) must NOT be pushed
352
+ // between tool results of parallel calls — strict providers (DeepSeek) 400 when a tool
353
+ // message does not immediately follow its assistant tool_calls. Defer to after the loop.
354
+ // real: image injections are real messages (pushReal → _fullHistory); reminders stay machine-only.
355
+ const deferredUserMsgs = []
356
+
338
357
  for (const { toolCall, result, ok } of results) {
339
358
  const tool = toolByName.get(toolCall.name)
340
359
  // Multimodal tools return JSON { text, images } — inject as multimodal user message
@@ -343,26 +362,32 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
343
362
  const parsed = JSON.parse(result)
344
363
  if (parsed.images?.length) {
345
364
  // tool message first — closes the tool_call pairing (OpenAI API requires tool result immediately after assistant with tool_calls)
346
- agent.history.push({ role: "tool", tool_call_id: toolCall.id, content: parsed.text })
365
+ pushReal(agent, { role: "tool", tool_call_id: toolCall.id, content: parsed.text })
347
366
  if (specForModel(agent.provider.model).multimodal) {
348
367
  // then inject multimodal user message with base64 images for the model to actually "see" them on the next turn
349
- agent.history.push({
350
- role: "user",
351
- content: [{ type: "text", text: parsed.text }, ...parsed.images],
368
+ deferredUserMsgs.push({
369
+ real: true,
370
+ msg: {
371
+ role: "user",
372
+ content: [{ type: "text", text: parsed.text }, ...parsed.images],
373
+ },
352
374
  })
353
375
  } else {
354
376
  // Non-vision model: image parts must never enter history — text-only APIs 400 on them on EVERY
355
377
  // subsequent request, poisoning the conversation. (read_image itself already refuses; this is defense-in-depth.)
356
- agent.history.push({
357
- role: "user",
358
- content: `[System reminder: the image returned by ${toolCall.name} was NOT injected — model ${agent.provider.model} does not support image input. Do not call ${toolCall.name} again under this provider; verify visual output programmatically instead.]`,
378
+ deferredUserMsgs.push({
379
+ real: false,
380
+ msg: {
381
+ role: "user",
382
+ content: `[System reminder: the image returned by ${toolCall.name} was NOT injected — model ${agent.provider.model} does not support image input. Do not call ${toolCall.name} again under this provider; verify visual output programmatically instead.]`,
383
+ },
359
384
  })
360
385
  }
361
386
  continue
362
387
  }
363
388
  } catch { /* Parse failure doesn't affect normal tool messages */ }
364
389
  }
365
- agent.history.push({ role: "tool", tool_call_id: toolCall.id, content: result })
390
+ pushReal(agent, { role: "tool", tool_call_id: toolCall.id, content: result })
366
391
  if (tool && ok) {
367
392
  if (FILE_MUTATORS.has(toolCall.name)) {
368
393
  // Direct file edit — code was changed.
@@ -415,6 +440,12 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
415
440
  }
416
441
  }
417
442
 
443
+ // All tool results committed — now safe to inject deferred multimodal user messages
444
+ for (const { real, msg } of deferredUserMsgs) {
445
+ if (real) pushReal(agent, msg)
446
+ else agent.history.push(msg)
447
+ }
448
+
418
449
  injectPostTurn(agent, results, recentCallSigs, callbacks, turn)
419
450
  }
420
451
 
package/src/config.mjs CHANGED
@@ -16,8 +16,10 @@ export const configPath = join(configDir, "config.json")
16
16
  export const PROVIDER_PRESETS = {
17
17
  deepseek: { baseURL: "https://api.deepseek.com", model: "deepseek-v4-pro", thinking: { type: "enabled" }, reasoningEffort: "max", maxTokens: 393216, desc: "DeepSeek" },
18
18
  kimi: { baseURL: "https://api.moonshot.cn/v1", model: "kimi-k3", thinking: null, reasoningEffort: "max", maxTokens: 131072, desc: "Kimi / Moonshot" },
19
+ "kimi-code": { baseURL: "https://api.kimi.com/coding/v1", model: "k3", thinking: null, reasoningEffort: "max", maxTokens: 131072, desc: "Kimi For Coding (platform.kimi.com — sk-kimi- keys; NOT interchangeable with Moonshot)" },
19
20
  glm: { baseURL: "https://open.bigmodel.cn/api/paas/v4", model: "glm-5.2", thinking: { type: "enabled" }, reasoningEffort: "max", maxTokens: 128000, desc: "Zhipu GLM" },
20
21
  qwen: { baseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1", model: "qwen3.7-max", reasoningEffort: "high", maxTokens: 131072, desc: "Qwen / Alibaba" },
22
+ qwenplan: { baseURL: "https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1", model: "qwen3.7-max", reasoningEffort: "high", maxTokens: 131072, desc: "Qwen Token Plan (百炼套餐)" },
21
23
  minimax: { baseURL: "https://api.minimaxi.com/v1", model: "MiniMax-M3", thinking: { type: "adaptive" }, maxTokens: 128000, chatPath: "/text/chatcompletion_v2", desc: "MiniMax" },
22
24
  openai: { baseURL: "https://api.openai.com/v1", model: "gpt-4o", desc: "OpenAI" },
23
25
  claude: { baseURL: "https://api.anthropic.com/v1", model: "claude-sonnet-4", format: "anthropic", maxTokens: 8192, desc: "Claude (Anthropic)" },
@@ -88,6 +90,8 @@ const MODEL_SPECS = [
88
90
  ["deepseek-chat", { context: 256_000, maxOutput: 384_000, thinking: false, prefixMode: true, cacheMode: "prompt", thinkApi: "type", reasoningEcho: "required", reasoningEffortEnum: ["high", "max"], tempRange: [0, 2] }],
89
91
  // Kimi series
90
92
  ["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"] }],
93
+ // Kimi For Coding endpoint uses the short model ID "k3" (same specs as kimi-k3) — IK5VGJ
94
+ ["k3", { context: 1_000_000, maxOutput: 131_072, thinking: true, partialMode: true, multimodal: true, cacheMode: "auto", thinkApi: "effort", reasoningEcho: "required", reasoningEffortEnum: ["low", "high", "max"] }],
91
95
  ["kimi-k2", { context: 256_000, maxOutput: 128_000, thinking: false, partialMode: true, multimodal: true, cacheMode: "none" }],
92
96
  ["moonshot", { context: 128_000, maxOutput: 32_000, thinking: false, cacheMode: "none" }],
93
97
  // GLM series
@@ -99,7 +103,8 @@ const MODEL_SPECS = [
99
103
  ["gpt-4o", { context: 128_000, maxOutput: 16_000, thinking: false, multimodal: true, cacheMode: "prompt" }],
100
104
  // Qwen series
101
105
  ["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] }],
102
- ["qwen3.7-max", { context: 1_000_000, maxOutput: 128_000, thinking: true, partialMode: true, multimodal: true, cacheMode: "none", thinkApi: "effort", reasoningEffortEnum: ["xhigh", "high"], tempRange: [0, 2] }],
106
+ // qwen3.7-max rejects image parts outright (DashScope 400 "Unexpected item type in content") text-only
107
+ ["qwen3.7-max", { context: 1_000_000, maxOutput: 128_000, thinking: true, partialMode: true, cacheMode: "none", thinkApi: "effort", reasoningEffortEnum: ["xhigh", "high"], tempRange: [0, 2] }],
103
108
  ["qwen3.8-max", { context: 1_000_000, maxOutput: 128_000, thinking: true, partialMode: true, multimodal: true, cacheMode: "none", thinkApi: "effort", reasoningEffortEnum: ["xhigh", "high"], tempRange: [0, 2] }],
104
109
  ["qwen-max", { context: 1_000_000, maxOutput: 128_000, thinking: false, partialMode: true, multimodal: true, cacheMode: "none", thinkApi: "effort", tempRange: [0, 2] }],
105
110
  ["qwen-plus", { context: 1_000_000, maxOutput: 32_000, thinking: false, partialMode: true, multimodal: true, cacheMode: "none", thinkApi: "effort", tempRange: [0, 2] }],
@@ -130,11 +135,18 @@ const DEFAULT_SPEC = { context: 128_000, maxOutput: 32_000, cacheMode: "none" }
130
135
  const COMPACT_RATIO = 0.6
131
136
 
132
137
  /** Look up spec by model name prefix (case-insensitive), conservative default for unknown models */
138
+ const warnedModels = new Set() // warn once per model name — specForModel is a hot path (every request)
133
139
  export function specForModel(model) {
134
140
  const m = (model ?? "").toLowerCase()
135
141
  for (const [prefix, spec] of [...MODEL_SPECS].sort((a,b) => b[0].length - a[0].length)) {
136
142
  if (m.startsWith(prefix.toLowerCase())) return spec
137
143
  }
144
+ // Unknown model: warn ONCE (not per request) so a typo'd ID or a missing alias surfaces
145
+ // instead of silently degrading to the 128K default (IK5VGJ).
146
+ if (m && !warnedModels.has(m)) {
147
+ warnedModels.add(m)
148
+ console.warn(`[config] model "${model}" not found in MODEL_SPECS — using default spec (128K context, 32K output). Check the model ID or add an alias.`)
149
+ }
138
150
  return DEFAULT_SPEC
139
151
  }
140
152
 
package/src/context.mjs CHANGED
@@ -7,8 +7,9 @@
7
7
 
8
8
  import { chat } from "./provider/index.mjs"
9
9
  import { estimateText } from "./provider/rate.mjs"
10
+ import { specForModel } from "./config.mjs"
10
11
 
11
- const IMAGE_TOKEN_ESTIMATE = 256 // rough estimate for image placeholder tokens
12
+ const IMAGE_TOKEN_ESTIMATE = 2000 // rough estimate for image content tokens (CLI legacy 256 underestimated real image costs, delaying compaction)
12
13
 
13
14
  /** Rough token count for a list of messages (body + reasoning + tool_calls params) */
14
15
  export function estimateTokens(messages) {
@@ -30,7 +31,13 @@ export function estimateTokens(messages) {
30
31
  }
31
32
 
32
33
  const KEEP_HEAD = 2 // Keep the earliest user intent — must not lose it
33
- const KEEP_TAIL = 10 // Keep the most recent work context must not lose it
34
+ // Tail size scales with the model context window (~30 messages per 100K tokens),
35
+ // capped at 40% of history so small histories don't over-reserve. Window-adaptive
36
+ // replaces the old fixed 10: on a 1M window, 10 messages is too thin for recent work.
37
+ function keepTailSize(provider, historyLen) {
38
+ const ctxWindow = specForModel(provider?.model ?? "").context
39
+ return Math.min(Math.max(10, Math.floor((ctxWindow / 100_000) * 30)), Math.floor(historyLen * 0.4))
40
+ }
34
41
 
35
42
  const SUMMARIZE_PROMPT = `You are a conversation compressor. Summarize the following agent work log into a compact summary for use as context in the ongoing conversation.
36
43
  Requirements:
@@ -69,15 +76,15 @@ const FALLBACK_NOTE =
69
76
  * The tail boundary must include any assistant whose tool results are in the tail — if the assistant is in the middle,
70
77
  * the summary swallows it, leaving orphan tool results → protocol 400.
71
78
  */
72
- function splitHistory(history) {
73
- if (history.length <= KEEP_HEAD + KEEP_TAIL + 1) return null
79
+ function splitHistory(history, keepTail) {
80
+ if (history.length <= KEEP_HEAD + keepTail + 1) return null
74
81
  let headEnd = KEEP_HEAD
75
82
  // head must not end with dangling tool_calls: when assistant declares tool_calls, all its tool results must stay in head.
76
83
  // Parallel calls: one assistant followed by multiple tool messages — accepting only one still causes 400, must collect all
77
84
  if (history[headEnd - 1]?.role === "assistant" && history[headEnd - 1].tool_calls?.length) {
78
85
  while (headEnd < history.length && history[headEnd].role === "tool") headEnd++
79
86
  }
80
- let tailStart = history.length - KEEP_TAIL
87
+ let tailStart = history.length - keepTail
81
88
 
82
89
  // Tool messages in the tail region whose assistant tool_calls are in the middle: the summary would swallow the assistant,
83
90
  // leaving orphan tool results → protocol 400. Collect tool_call_ids from the tail, find their owner assistants and pull them into tail
@@ -101,8 +108,25 @@ function splitHistory(history) {
101
108
  return { headEnd, tailStart }
102
109
  }
103
110
 
111
+ /**
112
+ * pushReal — the single entry point for REAL conversation messages.
113
+ * A real message (user input, assistant reply, tool result, multimodal image) is appended to BOTH:
114
+ * agent.history — the machine context (compaction shrinks this)
115
+ * agent._fullHistory — the NEVER-COMPACTED human-readable record (persistence source)
116
+ * Machine-only messages ([System reminder:...], compaction notes, task/plan/checkpoint re-injections)
117
+ * are pushed directly to agent.history WITHOUT going through here, so they never enter _fullHistory.
118
+ * The two lines are written independently at the source — no after-the-fact delta sync.
119
+ */
120
+ export function pushReal(agent, msg) {
121
+ if (!Array.isArray(agent._fullHistory)) agent._fullHistory = []
122
+ agent._fullHistory.push(msg)
123
+ agent.history.push(msg)
124
+ }
125
+
104
126
  /** Replace middle with a note, then re-inject task/plan state (shared by LLM summary and truncation fallback) */
105
127
  function applyCompression(agent, headEnd, tailStart, note) {
128
+ // _fullHistory already holds every real message (written at the source via pushReal),
129
+ // so compaction only shrinks the machine line — nothing to preserve here.
106
130
  const head = agent.history.slice(0, headEnd)
107
131
  const tail = agent.history.slice(tailStart)
108
132
  agent.history = [
@@ -142,20 +166,30 @@ function applyCompression(agent, headEnd, tailStart, note) {
142
166
  * If history exceeds threshold, compact it. Returns whether compaction happened.
143
167
  * Only called at safe points in the loop (history ends with user or tool message — a complete exchange boundary).
144
168
  * Automatically re-injects task list state after compaction.
169
+ * @param {object} agent
170
+ * @param {number} threshold - compaction threshold in tokens
171
+ * @param {object} callbacks - { onToken, onReasoning, onCompress } — summary generation is SILENT
172
+ * (never forwards onToken/onReasoning: the compaction process is an internal mechanism, not a model reply)
173
+ * @param {object} extras - { systemPrompt?, tools? } — estimated overhead for the pure-estimation
174
+ * path (no measured baseline); the measured path already includes system+tools in prompt_tokens.
145
175
  */
146
- export async function compressIfNeeded(agent, threshold, callbacks) {
176
+ export async function compressIfNeeded(agent, threshold, callbacks, extras = {}) {
147
177
  const history = agent.history
148
178
  // Prefer the real baseline: the last response's prompt_tokens is the measured value for the full context (system+tools+history).
149
179
  // Subsequent appended messages use estimation as increment; when no measured value exists (first turn / after restore / right after compaction), fall back to pure estimation
180
+ const overhead =
181
+ (extras.systemPrompt ? estimateText(extras.systemPrompt) : 0) +
182
+ (extras.tools ? estimateText(JSON.stringify(extras.tools)) : 0)
150
183
  const tokens =
151
184
  agent._lastPromptTokens != null
152
185
  ? agent._lastPromptTokens + estimateTokens(history.slice(agent._usageAtLen ?? history.length))
153
- : estimateTokens(history)
186
+ : estimateTokens(history) + overhead
154
187
  if (tokens <= threshold) return false
155
188
 
156
- const split = splitHistory(history)
189
+ const keepTail = keepTailSize(agent.provider, history.length)
190
+ const split = splitHistory(history, keepTail)
157
191
  if (!split) {
158
- // History is too short (≤13 messages) to find a middle section, but tokens exceed threshold — typically a single giant message
192
+ // History is too short (≤KEEP_HEAD+keepTail+1 messages) to find a middle section, but tokens exceed threshold — typically a single giant message
159
193
  // (large paste / huge injection). When summarization has no room, degrade to deterministic shrinking to ensure context always reduces
160
194
  return shrinkOversized(agent)
161
195
  }
@@ -171,11 +205,10 @@ export async function compressIfNeeded(agent, threshold, callbacks) {
171
205
  })
172
206
  .join("\n")
173
207
 
174
- // The summary is a plain-text task, no reasoning needed — passing thinking to the compaction provider wastes tokens
208
+ // The summary is a plain-text task, no reasoning needed — passing thinking to the compaction provider wastes tokens.
209
+ // Silent by design (D11): no onToken/onReasoning — the compaction process must not stream to the frontend.
175
210
  const summary = await chat({ ...agent.provider, thinking: null, reasoningEffort: null }, {
176
211
  messages: [{ role: "user", content: SUMMARIZE_PROMPT + serialized }],
177
- onToken: callbacks?.onToken,
178
- onReasoning: callbacks?.onReasoning,
179
212
  })
180
213
 
181
214
  // Auto-checkpoint before compaction: snapshot current state so the model can
@@ -205,7 +238,8 @@ export async function compressIfNeeded(agent, threshold, callbacks) {
205
238
  * Drops the middle so the task can continue. Returns whether truncation happened.
206
239
  */
207
240
  export function compressFallback(agent) {
208
- const split = splitHistory(agent.history)
241
+ const keepTail = keepTailSize(agent.provider, agent.history.length)
242
+ const split = splitHistory(agent.history, keepTail)
209
243
  if (!split) return false
210
244
  applyCompression(agent, split.headEnd, split.tailStart, FALLBACK_NOTE)
211
245
  return true
@@ -0,0 +1,44 @@
1
+ /**
2
+ * generate-title.mjs — LLM-generated session titles (CLI side)
3
+ * Called after the first user message to auto-title the session.
4
+ * Mirrors thincoder-vscode/src/extension/generate-title.mjs but uses the CLI provider shape.
5
+ */
6
+
7
+ /** Generate a session title from the first user message using an LLM. Returns title string or null. */
8
+ export async function generateTitle(userContent, provider) {
9
+ // Extract text even from multimodal content (array of parts)
10
+ const userText = Array.isArray(userContent)
11
+ ? userContent.find((p) => p.type === "text")?.text || ""
12
+ : userContent
13
+ if (typeof userText !== "string" || userText.length < 10) return null
14
+ if (!provider?.apiKey || !provider?.baseURL || !provider?.model) return null
15
+
16
+ try {
17
+ const body = JSON.stringify({
18
+ model: provider.model,
19
+ messages: [
20
+ { role: "system", content: "Generate a concise title (max 40 chars, no quotes) for this conversation. Reply ONLY with the title." },
21
+ { role: "user", content: userText.slice(0, 200) },
22
+ ],
23
+ max_tokens: 30,
24
+ stream: false,
25
+ })
26
+ const chatPath = provider.chatPath ?? "/chat/completions"
27
+ const url = `${provider.baseURL.replace(/\/+$/, "")}${chatPath}`
28
+ const opts = {
29
+ method: "POST",
30
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${provider.apiKey}` },
31
+ body,
32
+ signal: AbortSignal.timeout(10000),
33
+ }
34
+ const res = provider.proxyUri
35
+ ? await (await import("../proxy.mjs")).proxyFetch(url, opts, provider.proxyUri)
36
+ : await fetch(url, opts)
37
+ if (!res.ok) return null
38
+ const data = await res.json()
39
+ const title = data.choices?.[0]?.message?.content?.trim().slice(0, 40)
40
+ return title || null
41
+ } catch {
42
+ return null
43
+ }
44
+ }
@@ -7,7 +7,7 @@ Reply, reason, and ask in the user's language. If they switch languages mid-sess
7
7
  Programming is collaborative labor between you and the human. The human decides direction and makes the final call. You own the code — the entire project is your code. What you confirm is your contract.
8
8
 
9
9
  **How you work — before you write any code:**
10
- - **Read design docs first.** Use `doc_search` to find relevant design docs, AGENTS.md, and architecture decisions. Code without design context is guesswork. If docs conflict with code, docs are right.
10
+ - **Read design docs first.** Use `doc_search` to find relevant design docs, AGENTS.md, and architecture decisions. Code without design context is guesswork. If docs conflict with code, docs are right. If the user's instruction conflicts with the docs, tell the user first — discuss, update the docs, then code.
11
11
  - **Check existing code.** Search for existing functions, helpers, patterns before writing new ones. Duplicates are technical debt.
12
12
  - **Understand intent.** Ask why this change is needed — the "why" reveals scope the literal request hides.
13
13
  - **Confirm understanding.** State what you believe the user asked for and what you plan to deliver. 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.
@@ -63,7 +63,7 @@ export async function chat(provider, { messages, tools, onToken, onReasoning, on
63
63
  }
64
64
 
65
65
  const spec = specForModel(provider.model)
66
- messages = stripImagesForTextModel(messages, spec)
66
+ messages = normalizeToolPairing(stripImagesForTextModel(messages, spec))
67
67
  // Compile string-pattern rules to RegExp at call time
68
68
  const rules = compileStreamRules(streamRules)
69
69
  const body = {
@@ -199,6 +199,50 @@ export function stripImagesForTextModel(messages, spec) {
199
199
  return changed ? out : messages
200
200
  }
201
201
 
202
+ /**
203
+ * Enforce the OpenAI tool-message protocol on the outgoing payload: every tool message must
204
+ * immediately follow the assistant message declaring its tool_call_id, and every declared
205
+ * tool_call must have a result. Strict providers (DeepSeek) reject the whole request with 400
206
+ * ("Messages with role 'tool' must be a response to a preceding message with 'tool_calls'").
207
+ * History can legitimately violate this — parallel read_image injects a user message between
208
+ * tool results, compaction splits, interrupted sessions leave dangling tool_calls — so sanitize
209
+ * at send time. History itself is left untouched.
210
+ */
211
+ export function normalizeToolPairing(messages) {
212
+ // Detach all tool messages; reinsert each right after its owner assistant.
213
+ const toolById = new Map()
214
+ const rest = []
215
+ for (const m of messages) {
216
+ if (m.role === "tool") {
217
+ if (!toolById.has(m.tool_call_id)) toolById.set(m.tool_call_id, m)
218
+ } else {
219
+ rest.push(m)
220
+ }
221
+ }
222
+ if (toolById.size === 0) return messages // no tool messages — nothing to enforce
223
+ const out = []
224
+ for (const m of rest) {
225
+ out.push(m)
226
+ if (m.role !== "assistant" || !m.tool_calls?.length) continue
227
+ for (const tc of m.tool_calls) {
228
+ const t = toolById.get(tc.id)
229
+ if (t) {
230
+ toolById.delete(tc.id)
231
+ out.push(t)
232
+ } else {
233
+ // Declared tool_call with no recorded result (interrupted session / compaction split)
234
+ out.push({
235
+ role: "tool",
236
+ tool_call_id: tc.id,
237
+ content: "[Tool result missing: the call was interrupted or its result was dropped by context compaction]",
238
+ })
239
+ }
240
+ }
241
+ }
242
+ // Leftovers in toolById are orphans (owner assistant compacted away or never recorded) — dropped
243
+ return out
244
+ }
245
+
202
246
  /** List available model IDs from the provider's /models endpoint */
203
247
  export async function listModels(provider, { signal } = {}) {
204
248
  const response = await fetch(`${provider.baseURL}/models`, {
@@ -247,7 +291,19 @@ async function requestWithRetry(provider, body, signal, onWait) {
247
291
  if (response.ok) return response
248
292
 
249
293
  const text = await response.text().catch(() => "")
250
- const message = `LLM API error ${response.status}: ${text}`
294
+ let message = `LLM API error ${response.status}: ${text}`
295
+ // Kimi has TWO separate platforms with non-interchangeable keys (IK5VGJ):
296
+ // Moonshot (api.moonshot.cn, sk-...) vs Kimi For Coding (api.kimi.com/coding/v1, sk-kimi-...).
297
+ // A 401 on either endpoint is usually a wrong-platform key — say so instead of a bare 401.
298
+ if (response.status === 401) {
299
+ const key = String(provider.apiKey ?? "").trim()
300
+ const base = String(provider.baseURL ?? "").toLowerCase()
301
+ const kimiCodeKey = /^sk-kimi-/i.test(key)
302
+ const kimiCodeUrl = base.includes("api.kimi.com")
303
+ if (kimiCodeKey || kimiCodeUrl) {
304
+ message += " — tip: Kimi has two separate platforms with NON-interchangeable API keys: Moonshot (api.moonshot.cn/v1, sk-...) and Kimi For Coding (api.kimi.com/coding/v1, sk-kimi-...). Your key or baseURL looks mismatched — check which platform issued it."
305
+ }
306
+ }
251
307
  lastStatus = response.status
252
308
  if (isNonRetryableError(response.status, text)) throw new Error(message)
253
309
  if (response.status === 429) {