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 +1 -1
- package/src/advisor.mjs +4 -22
- package/src/agent-tools/consult.mjs +48 -9
- package/src/agent-tools/verify.mjs +13 -8
- package/src/config.mjs +17 -5
- package/src/context.mjs +1 -1
- package/src/escape.mjs +49 -0
- package/src/generate-title.mjs +12 -2
- package/src/prompts/discipline.md +8 -1
- package/src/prompts/engineering.md +13 -0
- package/src/prompts/system.md +2 -1
- package/src/provider/core.mjs +5 -0
- package/src/provider/sse.mjs +56 -14
- package/src/session-migrate.mjs +42 -0
- package/src/session.mjs +1 -38
- package/src/tools/bash.md +1 -0
- package/src/tools/codemode.mjs +125 -134
- package/src/tools/exec-prelude.mjs +84 -0
- package/src/tools/execute.md +14 -2
- package/src/tools/file_ops.md +16 -0
- package/src/tools/get_current_time.md +6 -0
- package/src/tools/git.md +10 -4
- package/src/tools/git.mjs +60 -8
- package/src/tools/grep.md +3 -1
- package/src/tools/index.mjs +7 -1
- package/src/tools/ls.md +1 -0
- package/src/tools/ops.mjs +142 -0
- package/src/tools/process.md +10 -0
- package/src/tools/sleep.md +5 -0
- package/src/tools/system.mjs +28 -6
- package/src/tools/tree.md +13 -0
- package/src/tools/tree.mjs +66 -0
- package/src/tui/clipboard.mjs +53 -1
- package/src/tui/cmd-advisor.mjs +4 -5
- package/src/tui/cmd-config.mjs +14 -2
- package/src/tui/cmd-copy.mjs +29 -0
- package/src/tui/math.mjs +309 -0
- package/src/tui/render-conversation.mjs +11 -3
- package/src/tui/render-frame.mjs +3 -3
- package/src/tui/slash-commands.mjs +4 -1
package/package.json
CHANGED
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
|
-
|
|
176
|
-
|
|
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: {
|
|
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
|
|
239
|
-
if (!Array.isArray(
|
|
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 (
|
|
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:
|
|
248
|
-
models:
|
|
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
|
|
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(
|
|
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(
|
|
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
|
-
*
|
|
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:
|
|
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:
|
|
116
|
-
["qwen3.8-max", { context: 1_000_000, maxOutput:
|
|
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
|
|
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
|
+
}
|
package/src/generate-title.mjs
CHANGED
|
@@ -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
|
-
|
|
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).
|
|
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 —
|
package/src/prompts/system.md
CHANGED
|
@@ -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
|
|
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:**
|
package/src/provider/core.mjs
CHANGED
|
@@ -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 = {
|
package/src/provider/sse.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
+
}
|