thincoder 0.8.1 → 0.8.3
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 +2 -1
- package/bin/thincoder.cjs +4 -0
- package/bin/thincoder.mjs +25 -25
- package/package.json +2 -2
- package/src/agent/dispatch.mjs +17 -12
- package/src/agent/helpers.mjs +22 -11
- package/src/agent/setup.mjs +32 -10
- package/src/agent-tools/goal.mjs +7 -6
- package/src/agent-tools/plan.mjs +3 -3
- package/src/agent-tools/recent-changes.mjs +3 -3
- package/src/agent-tools/skill.mjs +6 -6
- package/src/agent-tools/subagent.mjs +19 -18
- package/src/agent-tools/task.mjs +4 -4
- package/src/agent-tools/verify.mjs +21 -19
- package/src/agent-tools.mjs +3 -3
- package/src/agent.mjs +58 -37
- package/src/cli/distill-command.mjs +2 -2
- package/src/cli/make-agent.mjs +9 -9
- package/src/cli/memory-command.mjs +1 -1
- package/src/cli/permission.mjs +3 -3
- package/src/cli/setup-wizard.mjs +15 -15
- package/src/config.mjs +55 -53
- package/src/context.mjs +59 -56
- package/src/distill.mjs +35 -35
- package/src/embedding.mjs +17 -17
- package/src/git/checkpoint.mjs +211 -38
- package/src/git/gitmem.mjs +21 -20
- package/src/markdown.mjs +13 -13
- package/src/mcp/helpers.mjs +6 -1
- package/src/mcp/transport-http.mjs +2 -1
- package/src/mcp/transport-stdio.mjs +3 -2
- package/src/mcp/transport-ws.mjs +3 -2
- package/src/mcp.mjs +5 -2
- package/src/memory/code-index.mjs +16 -14
- package/src/memory/code-sync.mjs +36 -26
- package/src/memory/core.mjs +42 -35
- package/src/memory/docs.mjs +24 -15
- package/src/memory/schema.mjs +28 -27
- package/src/memory.mjs +2 -2
- package/src/prompts/coder.md +1 -1
- package/src/prompts/discipline.md +3 -0
- package/src/prompts/main.md +1 -2
- package/src/prompts/system.md +21 -16
- package/src/provider/core.mjs +11 -6
- package/src/provider/index.mjs +2 -2
- package/src/provider/rate.mjs +11 -11
- package/src/session.mjs +73 -40
- package/src/skills.mjs +17 -17
- package/src/tools/checkpoint.md +6 -2
- package/src/tools/file.mjs +16 -12
- package/src/tools/git.mjs +83 -12
- package/src/tools/index.mjs +1 -1
- package/src/tools/patch.mjs +20 -18
- package/src/tools/repomap-parse.mjs +17 -17
- package/src/tools/repomap.mjs +29 -29
- package/src/tools/shared.mjs +49 -28
- package/src/tools/system.mjs +163 -118
- package/src/tools/web.mjs +6 -6
- package/src/tui/agent-turn.mjs +82 -26
- package/src/tui/ansi.mjs +4 -2
- package/src/tui/clipboard.mjs +2 -2
- package/src/tui/cmd-auto.mjs +1 -1
- package/src/tui/cmd-clear.mjs +1 -1
- package/src/tui/cmd-config.mjs +2 -2
- package/src/tui/cmd-exit.mjs +2 -2
- package/src/tui/cmd-extract.mjs +1 -1
- package/src/tui/cmd-goal.mjs +4 -4
- package/src/tui/cmd-help.mjs +1 -1
- package/src/tui/cmd-init.mjs +3 -3
- package/src/tui/cmd-mcp.mjs +5 -5
- package/src/tui/cmd-model.mjs +1 -1
- package/src/tui/cmd-new.mjs +1 -1
- package/src/tui/cmd-plan.mjs +1 -1
- package/src/tui/cmd-reindex.mjs +2 -2
- package/src/tui/cmd-restore.mjs +1 -1
- package/src/tui/cmd-session.mjs +1 -1
- package/src/tui/cmd-skills.mjs +1 -1
- package/src/tui/cmd-think.mjs +2 -2
- package/src/tui/config-helpers.mjs +6 -6
- package/src/tui/distill-cmd.mjs +3 -3
- package/src/tui/index.mjs +131 -72
- package/src/tui/interaction.mjs +13 -12
- package/src/tui/key-handler.mjs +31 -23
- package/src/tui/layout.mjs +22 -17
- package/src/tui/pickers.mjs +19 -19
- package/src/tui/render-frame.mjs +37 -11
- package/src/tui/render.mjs +25 -24
- package/src/tui/slash-commands.mjs +14 -14
- package/src/tui/startup.mjs +15 -14
- package/src/tui/wizard.mjs +11 -11
- package/src/tui.mjs +2 -2
- package/bin/thincoder.js +0 -4
- package/src/tools/bash.mjs +0 -144
- package/src/tools/glob.mjs +0 -51
- package/src/tools/grep.mjs +0 -100
- package/src/tools/ls.mjs +0 -36
- package/src/tools.mjs +0 -2
- package/src/tui-render.mjs +0 -4
package/src/cli/setup-wizard.mjs
CHANGED
|
@@ -2,9 +2,9 @@ import { existsSync, readFileSync } from "node:fs"
|
|
|
2
2
|
import { createInterface } from "node:readline"
|
|
3
3
|
import { configPath, saveConfig, PROVIDER_PRESETS } from "../config.mjs"
|
|
4
4
|
|
|
5
|
-
/**
|
|
5
|
+
/** First-time setup (TTY chat / distill): ask a few questions to configure a provider, save to disk, return runtime provider. Cancel returns null. */
|
|
6
6
|
export async function setupWizard() {
|
|
7
|
-
//
|
|
7
|
+
// Buffered asker: rl.question loses lines when input is piped/fast-pasted (line arrives before question is registered)
|
|
8
8
|
const rl = createInterface({ input: process.stdin, terminal: false })
|
|
9
9
|
const buffered = []
|
|
10
10
|
let waiter = null
|
|
@@ -25,17 +25,17 @@ export async function setupWizard() {
|
|
|
25
25
|
})
|
|
26
26
|
try {
|
|
27
27
|
const presets = Object.entries(PROVIDER_PRESETS)
|
|
28
|
-
console.error("
|
|
28
|
+
console.error("First time using ThinCoder — let's configure a model provider:")
|
|
29
29
|
presets.forEach(([n, p], i) => console.error(` ${i + 1}. ${n.padEnd(10)} ${p.desc}`))
|
|
30
|
-
console.error(` ${presets.length + 1}.
|
|
31
|
-
const choice = Number((await ask(
|
|
30
|
+
console.error(` ${presets.length + 1}. Custom endpoint`)
|
|
31
|
+
const choice = Number((await ask(`Pick [1-${presets.length + 1}]: `)).trim())
|
|
32
32
|
let name, baseURL, model
|
|
33
33
|
if (choice === presets.length + 1) {
|
|
34
|
-
name = (await ask("
|
|
35
|
-
baseURL = (await ask("baseURL
|
|
36
|
-
model = (await ask("
|
|
34
|
+
name = (await ask("Name (e.g. my-openai): ")).trim()
|
|
35
|
+
baseURL = (await ask("baseURL (e.g. https://api.openai.com/v1): ")).trim().replace(/\/+$/, "")
|
|
36
|
+
model = (await ask("Model (e.g. gpt-4o): ")).trim()
|
|
37
37
|
if (!name || !/^https?:\/\//.test(baseURL) || !model) {
|
|
38
|
-
console.error("
|
|
38
|
+
console.error("Incomplete input or invalid baseURL — cancelled")
|
|
39
39
|
return null
|
|
40
40
|
}
|
|
41
41
|
} else if (choice >= 1 && choice <= presets.length) {
|
|
@@ -43,15 +43,15 @@ export async function setupWizard() {
|
|
|
43
43
|
baseURL = presets[choice - 1][1].baseURL
|
|
44
44
|
model = presets[choice - 1][1].model
|
|
45
45
|
} else {
|
|
46
|
-
console.error("
|
|
46
|
+
console.error("Invalid choice — cancelled")
|
|
47
47
|
return null
|
|
48
48
|
}
|
|
49
|
-
const apiKey = (await ask(
|
|
49
|
+
const apiKey = (await ask(`API key for ${name}: `)).trim()
|
|
50
50
|
if (!apiKey) {
|
|
51
|
-
console.error("key
|
|
51
|
+
console.error("API key cannot be empty — cancelled")
|
|
52
52
|
return null
|
|
53
53
|
}
|
|
54
|
-
const embedKey = (await ask("
|
|
54
|
+
const embedKey = (await ask("Optional: embedding API key (SiliconFlow, for vector search; press Enter to skip): ")).trim()
|
|
55
55
|
const raw = existsSync(configPath) ? JSON.parse(readFileSync(configPath, "utf8")) : {}
|
|
56
56
|
const providers = raw.providers?.length ? raw.providers : []
|
|
57
57
|
const existing = providers.find((p) => p.name === name)
|
|
@@ -61,8 +61,8 @@ export async function setupWizard() {
|
|
|
61
61
|
raw.activeProvider = name
|
|
62
62
|
if (embedKey) raw.embedding = { ...(raw.embedding ?? {}), apiKey: embedKey }
|
|
63
63
|
saveConfig(raw)
|
|
64
|
-
console.error(
|
|
65
|
-
console.error(embedKey ? "
|
|
64
|
+
console.error(`Configured: ${name} / ${model} (saved to ${configPath})`)
|
|
65
|
+
console.error(embedKey ? "Vector search enabled\n" : "(No embedding key configured: memory search will use text-only FTS. Add embedding.apiKey to config.json to enable vector search later.)\n")
|
|
66
66
|
return { name, baseURL, model, apiKey }
|
|
67
67
|
} finally {
|
|
68
68
|
rl.close()
|
package/src/config.mjs
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* config.mjs —
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* API key
|
|
2
|
+
* config.mjs — configuration loading and saving
|
|
3
|
+
* Multi-provider structure: providers[] + activeProvider
|
|
4
|
+
* Config file: ~/.thincoder/config.json
|
|
5
|
+
* API key can fall back to environment variables (when not configured in providers).
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
|
|
@@ -12,7 +12,7 @@ import { join } from "node:path"
|
|
|
12
12
|
export const configDir = join(homedir(), ".thincoder")
|
|
13
13
|
export const configPath = join(configDir, "config.json")
|
|
14
14
|
|
|
15
|
-
/**
|
|
15
|
+
/** Built-in provider presets: shared by /provider add <preset> and first-run wizard */
|
|
16
16
|
export const PROVIDER_PRESETS = {
|
|
17
17
|
deepseek: { baseURL: "https://api.deepseek.com/v1", 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" },
|
|
@@ -21,14 +21,15 @@ export const PROVIDER_PRESETS = {
|
|
|
21
21
|
minimax: { baseURL: "https://api.minimax.chat/v1", chatPath: "/text/chatcompletion_v2", model: "MiniMax-M3", maxTokens: 131072, desc: "MiniMax" },
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
//
|
|
25
|
-
const { desc:
|
|
24
|
+
// Default provider matches deepseek preset (strip the desc display field)
|
|
25
|
+
const { desc: _, ...deepseekPreset } = PROVIDER_PRESETS.deepseek
|
|
26
26
|
|
|
27
27
|
const DEFAULTS = {
|
|
28
28
|
providers: [{ name: "deepseek", ...deepseekPreset }],
|
|
29
29
|
activeProvider: "deepseek",
|
|
30
30
|
agent: {
|
|
31
31
|
maxTurns: 100,
|
|
32
|
+
subagentTurns: 100,
|
|
32
33
|
compactThreshold: 100000,
|
|
33
34
|
},
|
|
34
35
|
memory: {
|
|
@@ -46,60 +47,60 @@ const DEFAULTS = {
|
|
|
46
47
|
}
|
|
47
48
|
|
|
48
49
|
/**
|
|
49
|
-
*
|
|
50
|
-
*
|
|
50
|
+
* Known model capability spec table (prefix match, longer first).
|
|
51
|
+
* Used for compaction threshold derivation, continuation protocol selection, and capability-aware optimization.
|
|
51
52
|
*
|
|
52
|
-
* context:
|
|
53
|
-
* maxOutput:
|
|
54
|
-
* thinking:
|
|
55
|
-
* partialMode: Kimi/Qwen Partial Mode
|
|
56
|
-
* prefixMode: DeepSeek Prefix Completion
|
|
57
|
-
* multimodal:
|
|
58
|
-
* cacheMode:
|
|
59
|
-
* thinkApi:
|
|
60
|
-
* reasoningEcho: reasoning_content
|
|
61
|
-
* reasoningEffortEnum: reasoning_effort
|
|
62
|
-
* tempRange: temperature
|
|
53
|
+
* context: context window (tokens)
|
|
54
|
+
* maxOutput: max output tokens (defaults to context)
|
|
55
|
+
* thinking: whether thinking/reasoning mode is supported
|
|
56
|
+
* partialMode: Kimi/Qwen Partial Mode truncation continuation (assistant message with partial:true)
|
|
57
|
+
* prefixMode: DeepSeek Prefix Completion truncation continuation (uses /beta endpoint, with prefix:true)
|
|
58
|
+
* multimodal: whether multimodal (image/vision input supported)
|
|
59
|
+
* cacheMode: context caching mode: "auto"=automatic / "prompt"=needs explicit / "none"=unsupported
|
|
60
|
+
* thinkApi: thinking API type: "type"=thinking.type field / "effort"=reasoning_effort field
|
|
61
|
+
* reasoningEcho: reasoning_content cross-turn echo strategy: "required"=must echo (error if missing) / "optional"=echo optional (default: don't echo)
|
|
62
|
+
* reasoningEffortEnum: valid reasoning_effort enum values (if undeclared, no validation — passed through as-is)
|
|
63
|
+
* tempRange: valid temperature range [min, max] (if undeclared, no clamping)
|
|
63
64
|
*/
|
|
64
65
|
const MODEL_SPECS = [
|
|
65
|
-
// DeepSeek V4
|
|
66
|
+
// DeepSeek V4 series
|
|
66
67
|
["deepseek-v4-pro", { context: 1_000_000, maxOutput: 384_000, thinking: true, prefixMode: true, cacheMode: "prompt", thinkApi: "type", reasoningEcho: "required", reasoningEffortEnum: ["high", "max"], tempRange: [0, 2] }],
|
|
67
68
|
["deepseek-v4-flash", { context: 256_000, maxOutput: 384_000, thinking: false, prefixMode: true, cacheMode: "prompt", thinkApi: "type", reasoningEcho: "required", reasoningEffortEnum: ["high", "max"], tempRange: [0, 2] }],
|
|
68
69
|
["deepseek-reasoner", { context: 256_000, maxOutput: 384_000, thinking: true, prefixMode: true, cacheMode: "prompt", thinkApi: "type", reasoningEcho: "required", reasoningEffortEnum: ["high", "max"], tempRange: [0, 2] }],
|
|
69
70
|
["deepseek-chat", { context: 256_000, maxOutput: 384_000, thinking: false, prefixMode: true, cacheMode: "prompt", thinkApi: "type", reasoningEcho: "required", reasoningEffortEnum: ["high", "max"], tempRange: [0, 2] }],
|
|
70
|
-
// Kimi
|
|
71
|
+
// Kimi series
|
|
71
72
|
["kimi-k3", { context: 1_000_000, maxOutput: 128_000, thinking: true, partialMode: true, multimodal: true, cacheMode: "prompt", thinkApi: "effort", reasoningEcho: "required", reasoningEffortEnum: ["low", "high", "max"] }],
|
|
72
73
|
["kimi-k2", { context: 256_000, maxOutput: 128_000, thinking: false, partialMode: true, multimodal: true, cacheMode: "none" }],
|
|
73
74
|
["moonshot", { context: 128_000, maxOutput: 32_000, thinking: false, cacheMode: "none" }],
|
|
74
|
-
// GLM
|
|
75
|
+
// GLM series
|
|
75
76
|
["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] }],
|
|
76
77
|
["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] }],
|
|
77
78
|
["glm-4", { context: 128_000, maxOutput: 32_000, thinking: true, cacheMode: "auto", thinkApi: "type", reasoningEcho: "optional", tempRange: [0, 1] }],
|
|
78
|
-
// GPT
|
|
79
|
+
// GPT series
|
|
79
80
|
["gpt-4.1", { context: 1_000_000, maxOutput: 128_000, thinking: false, cacheMode: "prompt" }],
|
|
80
81
|
["gpt-4o", { context: 128_000, maxOutput: 16_000, thinking: false, multimodal: true, cacheMode: "prompt" }],
|
|
81
|
-
// Qwen
|
|
82
|
+
// Qwen series
|
|
82
83
|
["qwen3.8-max-preview", { context: 1_000_000, maxOutput: 128_000, thinking: false, partialMode: true, multimodal: true, cacheMode: "none", thinkApi: "effort", reasoningEffortEnum: ["xhigh", "medium", "low"], tempRange: [0, 2] }],
|
|
83
84
|
["qwen3.7-max", { context: 1_000_000, maxOutput: 128_000, thinking: false, partialMode: true, multimodal: true, cacheMode: "none", thinkApi: "effort", tempRange: [0, 2] }],
|
|
84
85
|
["qwen3.8-max", { context: 1_000_000, maxOutput: 128_000, thinking: false, partialMode: true, multimodal: true, cacheMode: "none", thinkApi: "effort", tempRange: [0, 2] }],
|
|
85
86
|
["qwen-max", { context: 1_000_000, maxOutput: 128_000, thinking: false, partialMode: true, multimodal: true, cacheMode: "none", thinkApi: "effort", tempRange: [0, 2] }],
|
|
86
87
|
["qwen-plus", { context: 1_000_000, maxOutput: 32_000, thinking: false, partialMode: true, multimodal: true, cacheMode: "none", thinkApi: "effort", tempRange: [0, 2] }],
|
|
87
88
|
["qwen", { context: 1_000_000, maxOutput: 128_000, thinking: false, partialMode: true, multimodal: true, cacheMode: "none", thinkApi: "effort", tempRange: [0, 2] }],
|
|
88
|
-
// MiniMax
|
|
89
|
+
// MiniMax series
|
|
89
90
|
["MiniMax-M3", { context: 1_000_000, maxOutput: 128_000, thinking: true, multimodal: true, cacheMode: "auto", thinkApi: "type", tempRange: [0, 2] }],
|
|
90
91
|
["minimax-m3", { context: 1_000_000, maxOutput: 128_000, thinking: true, multimodal: true, cacheMode: "auto", thinkApi: "type", tempRange: [0, 2] }],
|
|
91
92
|
["minimax-m1", { context: 256_000, maxOutput: 128_000, thinking: false, cacheMode: "auto" }],
|
|
92
93
|
]
|
|
93
94
|
const DEFAULT_SPEC = { context: 128_000, maxOutput: 32_000, cacheMode: "none" }
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
//
|
|
95
|
+
// Window utilization cap: 0.8 (DeepSeek internally uses full window; compaction itself costs an LLM call, premature compaction is pure waste.
|
|
96
|
+
// Reserve 20% headroom for post-compaction tail growth and output tokens)
|
|
97
|
+
// But for 1M-window models, 0.8 = 800K tokens — waiting until history grows that large would blow the TPM budget,
|
|
98
|
+
// and the compaction request itself might 429. Add caps: no more than 8× maxOutput (128K×8≈1M → still large but reasonable),
|
|
99
|
+
// no more than 300K (reasonable working ceiling for large-window models; beyond that cache hit rates drop)
|
|
99
100
|
const COMPACT_RATIO = 0.8
|
|
100
101
|
const COMPACT_CAP_TOKENS = 300_000
|
|
101
102
|
|
|
102
|
-
/**
|
|
103
|
+
/** Look up spec by model name prefix (case-insensitive), conservative default for unknown models */
|
|
103
104
|
export function specForModel(model) {
|
|
104
105
|
const m = (model ?? "").toLowerCase()
|
|
105
106
|
for (const [prefix, spec] of [...MODEL_SPECS].sort((a,b) => b[0].length - a[0].length)) {
|
|
@@ -108,24 +109,25 @@ export function specForModel(model) {
|
|
|
108
109
|
return DEFAULT_SPEC
|
|
109
110
|
}
|
|
110
111
|
|
|
112
|
+
/** Return the context window size for a given model name */
|
|
111
113
|
export function contextWindowForModel(model) {
|
|
112
114
|
return specForModel(model).context
|
|
113
115
|
}
|
|
114
116
|
|
|
115
|
-
/**
|
|
117
|
+
/** Derive compaction threshold; explicit is the value explicitly set in config file (takes priority), otherwise auto-computed from model */
|
|
116
118
|
export function resolveCompactThreshold(explicit, model) {
|
|
117
119
|
if (explicit != null) return { value: explicit, auto: false }
|
|
118
120
|
const spec = specForModel(model)
|
|
119
121
|
const ratioBased = Math.floor(spec.context * COMPACT_RATIO)
|
|
120
|
-
//
|
|
122
|
+
// Large-window models (1M) produce too-large ratio-based values; cap them — better to compact early than let history grow until it blows the TPM budget
|
|
121
123
|
const value = Math.min(ratioBased, COMPACT_CAP_TOKENS)
|
|
122
124
|
return { value, auto: true }
|
|
123
125
|
}
|
|
124
126
|
|
|
125
127
|
/**
|
|
126
|
-
*
|
|
127
|
-
* name
|
|
128
|
-
* name
|
|
128
|
+
* Find provider by name in providers[].
|
|
129
|
+
* 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.
|
|
130
|
+
* Returns the first provider when name is empty.
|
|
129
131
|
*/
|
|
130
132
|
export function findProvider(providers, name) {
|
|
131
133
|
if (name) {
|
|
@@ -138,9 +140,9 @@ export function findProvider(providers, name) {
|
|
|
138
140
|
}
|
|
139
141
|
|
|
140
142
|
/**
|
|
141
|
-
*
|
|
142
|
-
*
|
|
143
|
-
* THINCODER_API_KEY / THINCODER_BASE_URL / THINCODER_MODEL
|
|
143
|
+
* Load configuration.
|
|
144
|
+
* Env var priority: THINCODER_ACTIVE_PROVIDER > config file activeProvider
|
|
145
|
+
* THINCODER_API_KEY / THINCODER_BASE_URL / THINCODER_MODEL override the current active provider's corresponding fields
|
|
144
146
|
*/
|
|
145
147
|
export function loadConfig() {
|
|
146
148
|
let config = {}
|
|
@@ -162,29 +164,29 @@ export function loadConfig() {
|
|
|
162
164
|
embedding: { ...DEFAULTS.embedding, ...config.embedding },
|
|
163
165
|
}
|
|
164
166
|
|
|
165
|
-
// baseURL
|
|
167
|
+
// Normalize baseURL trailing slash (prevents //chat/completions)
|
|
166
168
|
for (const p of merged.providers) {
|
|
167
169
|
if (p.baseURL) p.baseURL = p.baseURL.replace(/\/+$/, "")
|
|
168
170
|
}
|
|
169
171
|
|
|
170
|
-
//
|
|
172
|
+
// Env var overrides activeProvider
|
|
171
173
|
if (process.env.THINCODER_ACTIVE_PROVIDER) {
|
|
172
174
|
merged.activeProvider = process.env.THINCODER_ACTIVE_PROVIDER
|
|
173
175
|
}
|
|
174
176
|
|
|
175
|
-
//
|
|
177
|
+
// Get the currently active provider
|
|
176
178
|
const active = findProvider(merged.providers, merged.activeProvider)
|
|
177
179
|
|
|
178
|
-
//
|
|
180
|
+
// Build runtime provider object (for agent.provider usage)
|
|
179
181
|
const runtimeProvider = { ...active }
|
|
180
182
|
|
|
181
|
-
//
|
|
183
|
+
// Env vars override current active provider's fields
|
|
182
184
|
if (process.env.THINCODER_API_KEY) runtimeProvider.apiKey = process.env.THINCODER_API_KEY
|
|
183
185
|
if (process.env.THINCODER_BASE_URL) runtimeProvider.baseURL = process.env.THINCODER_BASE_URL
|
|
184
186
|
if (process.env.THINCODER_MODEL) runtimeProvider.model = process.env.THINCODER_MODEL
|
|
185
187
|
|
|
186
|
-
// apiKey
|
|
187
|
-
//
|
|
188
|
+
// apiKey also falls back to env vars (when providers doesn't include a key)
|
|
189
|
+
// Provider-specific env vars only apply to the matching provider name, preventing keys from leaking to wrong endpoints
|
|
188
190
|
if (!runtimeProvider.apiKey?.trim()) {
|
|
189
191
|
const envMap = { deepseek: "DEEPSEEK_API_KEY", openai: "OPENAI_API_KEY" }
|
|
190
192
|
const keyVar = envMap[merged.activeProvider]
|
|
@@ -196,13 +198,13 @@ export function loadConfig() {
|
|
|
196
198
|
merged.embedding.apiKey = process.env.SILICONFLOW_API_KEY || process.env.THINCODER_EMBEDDING_API_KEY
|
|
197
199
|
}
|
|
198
200
|
|
|
199
|
-
//
|
|
201
|
+
// Compaction threshold follows the model
|
|
200
202
|
const explicitThreshold = config.agent?.compactThreshold
|
|
201
203
|
const { value, auto } = resolveCompactThreshold(explicitThreshold, runtimeProvider.model)
|
|
202
204
|
merged.agent.compactThreshold = value
|
|
203
205
|
merged.agent.compactThresholdAuto = auto
|
|
204
206
|
|
|
205
|
-
//
|
|
207
|
+
// Write back to merged for convenient access by upper layers
|
|
206
208
|
merged.provider = runtimeProvider
|
|
207
209
|
merged.providersList = merged.providers
|
|
208
210
|
|
|
@@ -210,12 +212,12 @@ export function loadConfig() {
|
|
|
210
212
|
}
|
|
211
213
|
|
|
212
214
|
/**
|
|
213
|
-
*
|
|
214
|
-
* providers[i].apiKey
|
|
215
|
+
* Save configuration. Preserves providers list structure and activeProvider pointer.
|
|
216
|
+
* providers[i].apiKey is only written when explicitly passed in (does not overwrite env-var-fallback keys).
|
|
215
217
|
*/
|
|
216
218
|
export function saveConfig(config) {
|
|
217
219
|
mkdirSync(configDir, { recursive: true })
|
|
218
|
-
// 0600
|
|
220
|
+
// 0600: config.json contains API keys, must not be world-readable (POSIX; chmod is best-effort on Windows)
|
|
219
221
|
writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", { encoding: "utf8", mode: 0o600 })
|
|
220
|
-
try { chmodSync(configPath, 0o600) } catch { /* Windows
|
|
222
|
+
try { chmodSync(configPath, 0o600) } catch { /* may fail on Windows, ignore */ }
|
|
221
223
|
}
|
package/src/context.mjs
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* context.mjs —
|
|
3
|
-
* token
|
|
4
|
-
*
|
|
5
|
-
*
|
|
2
|
+
* context.mjs — Context management and compaction
|
|
3
|
+
* When no measured token count is available, use estimation as fallback (ASCII/4 + non-ASCII/1, no tokenizer dependency).
|
|
4
|
+
* When a measured value exists (response usage.prompt_tokens), trust it — estimation underestimates CJK by 3-4x and relying solely on it may never trigger compaction.
|
|
5
|
+
* Compaction strategy: keep earliest 2 + latest N messages, summarize the middle into one via LLM (inspired by kimi-code, simplified).
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { chat } from "./provider/index.mjs"
|
|
9
9
|
import { estimateText } from "./provider/rate.mjs"
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
const IMAGE_TOKEN_ESTIMATE = 256 // rough estimate for image placeholder tokens
|
|
12
|
+
|
|
13
|
+
/** Rough token count for a list of messages (body + reasoning + tool_calls params) */
|
|
12
14
|
export function estimateTokens(messages) {
|
|
13
15
|
let tokens = 0
|
|
14
16
|
for (const m of messages) {
|
|
@@ -16,7 +18,7 @@ export function estimateTokens(messages) {
|
|
|
16
18
|
else if (Array.isArray(m.content)) {
|
|
17
19
|
for (const part of m.content) {
|
|
18
20
|
if (part.type === "text") tokens += estimateText(part.text)
|
|
19
|
-
else if (part.type === "image_url") tokens +=
|
|
21
|
+
else if (part.type === "image_url") tokens += IMAGE_TOKEN_ESTIMATE
|
|
20
22
|
}
|
|
21
23
|
}
|
|
22
24
|
if (typeof m.reasoning_content === "string") tokens += estimateText(m.reasoning_content)
|
|
@@ -27,59 +29,59 @@ export function estimateTokens(messages) {
|
|
|
27
29
|
return tokens
|
|
28
30
|
}
|
|
29
31
|
|
|
30
|
-
const KEEP_HEAD = 2 //
|
|
31
|
-
const KEEP_TAIL = 10 //
|
|
32
|
+
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
|
|
32
34
|
|
|
33
|
-
const SUMMARIZE_PROMPT =
|
|
34
|
-
|
|
35
|
-
-
|
|
36
|
-
-
|
|
37
|
-
-
|
|
38
|
-
-
|
|
39
|
-
-
|
|
40
|
-
-
|
|
35
|
+
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
|
+
Requirements:
|
|
37
|
+
- Write in first person, present tense — these are "my" handover notes, continuing my own train of thought
|
|
38
|
+
- Most important: preserve design decisions and their reasons — architecture choices, API contracts, naming conventions, trade-off rationale. These are the anchors the subsequent code must not deviate from
|
|
39
|
+
- Keep: the user's original request, files modified and why, unresolved issues, next steps
|
|
40
|
+
- Drop: pleasantries, repetition, fine-grained tool output details
|
|
41
|
+
- Honestly mark uncertain items: anything not actually verified must say "unverified"; do not present guesses as facts
|
|
42
|
+
- Use bullet-point output; aim for information completeness, not a hard word limit (old 500-char cap is deprecated; in a 1M-context era, err on the long side)
|
|
41
43
|
|
|
42
|
-
|
|
44
|
+
Work log:
|
|
43
45
|
`
|
|
44
46
|
|
|
45
|
-
/**
|
|
47
|
+
/** Context prefix after compaction, informing the agent what happened */
|
|
46
48
|
const COMPACTION_PREFIX =
|
|
47
49
|
"[Context was automatically compacted. Below is a summary of earlier work. " +
|
|
48
50
|
"Treat it as notes, not proof — trust its conclusions (don't redo what it reports as done) " +
|
|
49
51
|
"but re-verify transient state (open files, running processes) with tools before relying on them. " +
|
|
50
52
|
"Design decisions made earlier may be summarized — if you recall a decision that is missing from the summary, check memory_search or re-examine the code.]\n\n"
|
|
51
53
|
|
|
52
|
-
/**
|
|
54
|
+
/** After this many consecutive compaction summary failures, degrade to deterministic truncation (losing info is better than task-killing 400 errors) */
|
|
53
55
|
export const COMPRESS_FAILURE_LIMIT = 3
|
|
54
56
|
|
|
55
|
-
/**
|
|
57
|
+
/** Task re-injection reminder prefix (after compaction, clear old versions from history first for a single source of truth) */
|
|
56
58
|
const TASK_REINJECT_PREFIX = "[System reminder: your current task list after compaction:"
|
|
57
59
|
|
|
58
|
-
/**
|
|
60
|
+
/** Truncation fallback note (used when the summary LLM fails repeatedly; no LLM call) */
|
|
59
61
|
const FALLBACK_NOTE =
|
|
60
62
|
"[Context was truncated after repeated summarization failures. " +
|
|
61
63
|
"The middle portion of earlier work was dropped WITHOUT a summary. " +
|
|
62
64
|
"Re-verify any state you need with tools before relying on it.]\n\n"
|
|
63
65
|
|
|
64
66
|
/**
|
|
65
|
-
*
|
|
66
|
-
* head
|
|
67
|
-
*
|
|
68
|
-
* tail
|
|
69
|
-
*
|
|
67
|
+
* Split history into head / middle (to be summarized) / tail; return null if no middle to compress.
|
|
68
|
+
* The head boundary must avoid orphan tool_calls: when an assistant message has tool_calls, all its tool responses must stay in head,
|
|
69
|
+
* otherwise compressing them to plain text violates the protocol (tool_calls must be followed by tool messages).
|
|
70
|
+
* The tail boundary must include any assistant whose tool results are in the tail — if the assistant is in the middle,
|
|
71
|
+
* the summary swallows it, leaving orphan tool results → protocol 400.
|
|
70
72
|
*/
|
|
71
73
|
function splitHistory(history) {
|
|
72
74
|
if (history.length <= KEEP_HEAD + KEEP_TAIL + 1) return null
|
|
73
75
|
let headEnd = KEEP_HEAD
|
|
74
|
-
// head
|
|
75
|
-
//
|
|
76
|
+
// head must not end with dangling tool_calls: when assistant declares tool_calls, all its tool results must stay in head.
|
|
77
|
+
// Parallel calls: one assistant followed by multiple tool messages — accepting only one still causes 400, must collect all
|
|
76
78
|
if (history[headEnd - 1]?.role === "assistant" && history[headEnd - 1].tool_calls?.length) {
|
|
77
79
|
while (headEnd < history.length && history[headEnd].role === "tool") headEnd++
|
|
78
80
|
}
|
|
79
81
|
let tailStart = history.length - KEEP_TAIL
|
|
80
82
|
|
|
81
|
-
// tail
|
|
82
|
-
//
|
|
83
|
+
// Tool messages in the tail region whose assistant tool_calls are in the middle: the summary would swallow the assistant,
|
|
84
|
+
// leaving orphan tool results → protocol 400. Collect tool_call_ids from the tail, find their owner assistants and pull them into tail
|
|
83
85
|
const tailToolIds = new Set()
|
|
84
86
|
for (let i = tailStart; i < history.length; i++) {
|
|
85
87
|
if (history[i].role === "tool") tailToolIds.add(history[i].tool_call_id)
|
|
@@ -100,7 +102,7 @@ function splitHistory(history) {
|
|
|
100
102
|
return { headEnd, tailStart }
|
|
101
103
|
}
|
|
102
104
|
|
|
103
|
-
/**
|
|
105
|
+
/** Replace middle with a note, then re-inject task/plan state (shared by LLM summary and truncation fallback) */
|
|
104
106
|
function applyCompression(agent, headEnd, tailStart, note) {
|
|
105
107
|
const head = agent.history.slice(0, headEnd)
|
|
106
108
|
const tail = agent.history.slice(tailStart)
|
|
@@ -110,13 +112,13 @@ function applyCompression(agent, headEnd, tailStart, note) {
|
|
|
110
112
|
{ role: "assistant", content: "Understood. I'll continue from these notes, re-verifying anything transient." },
|
|
111
113
|
...tail,
|
|
112
114
|
]
|
|
113
|
-
//
|
|
115
|
+
// Measured token baseline is invalidated along with old history (prompt_tokens were for pre-compaction context), fall back to estimation until next response
|
|
114
116
|
agent._lastPromptTokens = null
|
|
115
117
|
agent._usageAtLen = null
|
|
116
118
|
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
//
|
|
119
|
+
// After compaction, re-inject the task list (the agent needs to know what it was doing).
|
|
120
|
+
// Single source of truth: first remove any stale re-injections from the tail, then inject the latest version —
|
|
121
|
+
// no longer embedded in the summary body (would duplicate and grow stale)
|
|
120
122
|
agent.history = agent.history.filter(
|
|
121
123
|
(m) => !(m.role === "user" && typeof m.content === "string" && m.content.startsWith(TASK_REINJECT_PREFIX))
|
|
122
124
|
)
|
|
@@ -128,11 +130,11 @@ function applyCompression(agent, headEnd, tailStart, note) {
|
|
|
128
130
|
})
|
|
129
131
|
}
|
|
130
132
|
|
|
131
|
-
//
|
|
133
|
+
// Reset tracking counters (context rebuilt, start counting from scratch)
|
|
132
134
|
agent._turnsSinceTaskUpdate = 0
|
|
133
135
|
agent._turnsInPlanMode = 0
|
|
134
136
|
|
|
135
|
-
//
|
|
137
|
+
// Plan mode compaction: re-inject plan mode guidance
|
|
136
138
|
if (agent.planMode) {
|
|
137
139
|
agent.history.push({
|
|
138
140
|
role: "user",
|
|
@@ -142,14 +144,14 @@ function applyCompression(agent, headEnd, tailStart, note) {
|
|
|
142
144
|
}
|
|
143
145
|
|
|
144
146
|
/**
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
147
|
+
* If history exceeds threshold, compact it. Returns whether compaction happened.
|
|
148
|
+
* Only called at safe points in the loop (history ends with user or tool message — a complete exchange boundary).
|
|
149
|
+
* Automatically re-injects task list state after compaction.
|
|
148
150
|
*/
|
|
149
151
|
export async function compressIfNeeded(agent, threshold) {
|
|
150
152
|
const history = agent.history
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
+
// Prefer the real baseline: the last response's prompt_tokens is the measured value for the full context (system+tools+history).
|
|
154
|
+
// Subsequent appended messages use estimation as increment; when no measured value exists (first turn / after restore / right after compaction), fall back to pure estimation
|
|
153
155
|
const tokens =
|
|
154
156
|
agent._lastPromptTokens != null
|
|
155
157
|
? agent._lastPromptTokens + estimateTokens(history.slice(agent._usageAtLen ?? history.length))
|
|
@@ -158,23 +160,24 @@ export async function compressIfNeeded(agent, threshold) {
|
|
|
158
160
|
|
|
159
161
|
const split = splitHistory(history)
|
|
160
162
|
if (!split) {
|
|
161
|
-
//
|
|
162
|
-
//
|
|
163
|
+
// History is too short (≤13 messages) to find a middle section, but tokens exceed threshold — typically a single giant message
|
|
164
|
+
// (large paste / huge injection). When summarization has no room, degrade to deterministic shrinking to ensure context always reduces
|
|
163
165
|
return shrinkOversized(agent)
|
|
164
166
|
}
|
|
165
167
|
|
|
166
168
|
const middle = history.slice(split.headEnd, split.tailStart)
|
|
167
169
|
const serialized = middle
|
|
168
170
|
.map((m) => {
|
|
169
|
-
const toolNote = m.tool_calls ? ` [
|
|
170
|
-
// user
|
|
171
|
+
const toolNote = m.tool_calls ? ` [called tools: ${m.tool_calls.map((t) => t.function.name).join(", ")}]` : ""
|
|
172
|
+
// user messages get a wider cap (8000): cutting off a long user-pasted requirement loses original intent; tool/assistant capped at 2000 is enough
|
|
171
173
|
const cap = m.role === "user" ? 8000 : 2000
|
|
172
174
|
const content = typeof m.content === "string" ? m.content.slice(0, cap) : ""
|
|
173
175
|
return `[${m.role}]${toolNote} ${content}`
|
|
174
176
|
})
|
|
175
177
|
.join("\n")
|
|
176
178
|
|
|
177
|
-
|
|
179
|
+
// The summary is a plain-text task, no reasoning needed — passing thinking to the compaction provider wastes tokens
|
|
180
|
+
const summary = await chat({ ...agent.provider, thinking: null, reasoningEffort: null }, {
|
|
178
181
|
messages: [{ role: "user", content: SUMMARIZE_PROMPT + serialized }],
|
|
179
182
|
})
|
|
180
183
|
|
|
@@ -183,8 +186,8 @@ export async function compressIfNeeded(agent, threshold) {
|
|
|
183
186
|
}
|
|
184
187
|
|
|
185
188
|
/**
|
|
186
|
-
*
|
|
187
|
-
*
|
|
189
|
+
* Deterministic truncation fallback: called when the summary LLM fails repeatedly, no network call.
|
|
190
|
+
* Drops the middle so the task can continue. Returns whether truncation happened.
|
|
188
191
|
*/
|
|
189
192
|
export function compressFallback(agent) {
|
|
190
193
|
const split = splitHistory(agent.history)
|
|
@@ -193,21 +196,21 @@ export function compressFallback(agent) {
|
|
|
193
196
|
return true
|
|
194
197
|
}
|
|
195
198
|
|
|
196
|
-
/**
|
|
199
|
+
/** Hard truncation limit for a single message body: when exceeded and the splitter can't find a middle section, truncate to a stub (prevents one giant message from blocking compaction) */
|
|
197
200
|
const OVERSIZE_CONTENT_LIMIT = 8_000
|
|
198
201
|
|
|
199
202
|
/**
|
|
200
|
-
*
|
|
201
|
-
*
|
|
202
|
-
*
|
|
203
|
-
*
|
|
203
|
+
* Deterministic shrinking: last resort when splitHistory can't find a middle section (history too short) but threshold is exceeded. No LLM call.
|
|
204
|
+
* Truncates user/tool message bodies exceeding OVERSIZE_CONTENT_LIMIT to a stub (keeps head + tail);
|
|
205
|
+
* does not touch reasoning_content (DeepSeek/Kimi echo protocol) or tool_calls pairing structure — no protocol 400 risk.
|
|
206
|
+
* Only called after compressIfNeeded determines threshold is exceeded. Returns whether any message was truncated.
|
|
204
207
|
*/
|
|
205
208
|
export function shrinkOversized(agent, limit = OVERSIZE_CONTENT_LIMIT) {
|
|
206
209
|
let shrunk = false
|
|
207
210
|
for (const m of agent.history) {
|
|
208
211
|
if ((m.role !== "user" && m.role !== "tool") || typeof m.content !== "string") continue
|
|
209
212
|
if (m.content.length <= limit) continue
|
|
210
|
-
//
|
|
213
|
+
// Truncate keeping head + tail, insert stub in between; keepHead/keepTail proportional but not exceeding 50%/25% of limit
|
|
211
214
|
const keepHead = Math.min(Math.floor(limit * 0.5), 4000)
|
|
212
215
|
const keepTail = Math.min(Math.floor(limit * 0.25), 2000)
|
|
213
216
|
m.content =
|
|
@@ -217,7 +220,7 @@ export function shrinkOversized(agent, limit = OVERSIZE_CONTENT_LIMIT) {
|
|
|
217
220
|
shrunk = true
|
|
218
221
|
}
|
|
219
222
|
if (shrunk) {
|
|
220
|
-
//
|
|
223
|
+
// Same as compaction: measured token baseline is invalidated by the changed history, fall back to estimation until next response
|
|
221
224
|
agent._lastPromptTokens = null
|
|
222
225
|
agent._usageAtLen = null
|
|
223
226
|
}
|