thincoder 0.6.0 → 0.7.1

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/src/config.mjs CHANGED
@@ -5,7 +5,7 @@
5
5
  * API key 可用环境变量兜底(未在 providers 中配置时)。
6
6
  */
7
7
 
8
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
8
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
9
9
  import { homedir } from "node:os"
10
10
  import { join } from "node:path"
11
11
 
@@ -49,41 +49,45 @@ const DEFAULTS = {
49
49
  * 已知模型的能力规格表(前缀匹配,长的在前)。
50
50
  * 用于压缩阈值推导、截断续写协议选择、能力感知优化。
51
51
  *
52
- * context: 上下文窗口(tokens)
53
- * maxOutput: 最大输出 tokens(默认 context)
54
- * thinking: 是否支持思考/推理模式
55
- * partialMode: Kimi/Qwen Partial Mode 截断续写(assistant 消息带 partial:true)
56
- * prefixMode: DeepSeek Prefix Completion 截断续写(走 /beta 端点,带 prefix:true)
57
- * multimodal: 是否多模态(支持图片/视觉输入)
58
- * cacheMode: 上下文缓存方式:"auto"=自动/"prompt"=需显式/"none"=不支持
59
- * thinkApi: 思考模式 API 类型:"type"=thinking.type 字段 / "effort"=reasoning_effort 字段
52
+ * context: 上下文窗口(tokens)
53
+ * maxOutput: 最大输出 tokens(默认 context)
54
+ * thinking: 是否支持思考/推理模式
55
+ * partialMode: Kimi/Qwen Partial Mode 截断续写(assistant 消息带 partial:true)
56
+ * prefixMode: DeepSeek Prefix Completion 截断续写(走 /beta 端点,带 prefix:true)
57
+ * multimodal: 是否多模态(支持图片/视觉输入)
58
+ * cacheMode: 上下文缓存方式:"auto"=自动/"prompt"=需显式/"none"=不支持
59
+ * thinkApi: 思考模式 API 类型:"type"=thinking.type 字段 / "effort"=reasoning_effort 字段
60
+ * reasoningEcho: reasoning_content 跨轮回传策略:"required"=必须回传(缺失报错)/"optional"=回传可选(默认不回传)
61
+ * reasoningEffortEnum: reasoning_effort 合法枚举值(未声明则不校验,原样透传)
62
+ * tempRange: temperature 合法范围 [min, max](未声明则不裁剪)
60
63
  */
61
64
  const MODEL_SPECS = [
62
65
  // DeepSeek V4 系列
63
- ["deepseek-v4-pro", { context: 1_000_000, maxOutput: 384_000, thinking: true, prefixMode: true, cacheMode: "prompt", thinkApi: "type" }],
64
- ["deepseek-v4-flash", { context: 256_000, maxOutput: 384_000, thinking: false, prefixMode: true, cacheMode: "prompt", thinkApi: "type" }],
65
- ["deepseek-reasoner", { context: 256_000, maxOutput: 384_000, thinking: true, prefixMode: true, cacheMode: "prompt", thinkApi: "type" }],
66
- ["deepseek-chat", { context: 256_000, maxOutput: 384_000, thinking: false, prefixMode: true, cacheMode: "prompt", thinkApi: "type" }],
66
+ ["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
+ ["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
+ ["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
+ ["deepseek-chat", { context: 256_000, maxOutput: 384_000, thinking: false, prefixMode: true, cacheMode: "prompt", thinkApi: "type", reasoningEcho: "required", reasoningEffortEnum: ["high", "max"], tempRange: [0, 2] }],
67
70
  // Kimi 系列
68
- ["kimi-k3", { context: 1_000_000, maxOutput: 128_000, thinking: true, partialMode: true, multimodal: true, cacheMode: "prompt", thinkApi: "effort" }],
71
+ ["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"] }],
69
72
  ["kimi-k2", { context: 256_000, maxOutput: 128_000, thinking: false, partialMode: true, multimodal: true, cacheMode: "none" }],
70
73
  ["moonshot", { context: 128_000, maxOutput: 32_000, thinking: false, cacheMode: "none" }],
71
74
  // GLM 系列
72
- ["glm-5.2", { context: 1_000_000, maxOutput: 128_000, thinking: true, cacheMode: "auto", thinkApi: "type" }],
73
- ["glm-5", { context: 1_000_000, maxOutput: 128_000, thinking: true, cacheMode: "auto", thinkApi: "type" }],
74
- ["glm-4", { context: 128_000, maxOutput: 32_000, thinking: true, cacheMode: "auto", thinkApi: "type" }],
75
+ ["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
+ ["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
+ ["glm-4", { context: 128_000, maxOutput: 32_000, thinking: true, cacheMode: "auto", thinkApi: "type", reasoningEcho: "optional", tempRange: [0, 1] }],
75
78
  // GPT 系列
76
79
  ["gpt-4.1", { context: 1_000_000, maxOutput: 128_000, thinking: false, cacheMode: "prompt" }],
77
80
  ["gpt-4o", { context: 128_000, maxOutput: 16_000, thinking: false, multimodal: true, cacheMode: "prompt" }],
78
81
  // Qwen 系列
79
- ["qwen3.7-max", { context: 1_000_000, maxOutput: 128_000, thinking: false, partialMode: true, cacheMode: "none", thinkApi: "effort" }],
80
- ["qwen3.8-max", { context: 1_000_000, maxOutput: 128_000, thinking: false, partialMode: true, cacheMode: "none", thinkApi: "effort" }],
81
- ["qwen-max", { context: 1_000_000, maxOutput: 128_000, thinking: false, partialMode: true, cacheMode: "none", thinkApi: "effort" }],
82
- ["qwen-plus", { context: 1_000_000, maxOutput: 32_000, thinking: false, partialMode: true, cacheMode: "none", thinkApi: "effort" }],
83
- ["qwen", { context: 1_000_000, maxOutput: 128_000, thinking: false, partialMode: true, cacheMode: "none", thinkApi: "effort" }],
82
+ ["qwen3.8-max-preview", { context: 1_000_000, maxOutput: 128_000, thinking: false, partialMode: true, cacheMode: "none", thinkApi: "effort", reasoningEffortEnum: ["xhigh", "medium", "low"], tempRange: [0, 2] }],
83
+ ["qwen3.7-max", { context: 1_000_000, maxOutput: 128_000, thinking: false, partialMode: true, cacheMode: "none", thinkApi: "effort", tempRange: [0, 2] }],
84
+ ["qwen3.8-max", { context: 1_000_000, maxOutput: 128_000, thinking: false, partialMode: true, cacheMode: "none", thinkApi: "effort", tempRange: [0, 2] }],
85
+ ["qwen-max", { context: 1_000_000, maxOutput: 128_000, thinking: false, partialMode: true, cacheMode: "none", thinkApi: "effort", tempRange: [0, 2] }],
86
+ ["qwen-plus", { context: 1_000_000, maxOutput: 32_000, thinking: false, partialMode: true, cacheMode: "none", thinkApi: "effort", tempRange: [0, 2] }],
87
+ ["qwen", { context: 1_000_000, maxOutput: 128_000, thinking: false, partialMode: true, cacheMode: "none", thinkApi: "effort", tempRange: [0, 2] }],
84
88
  // MiniMax 系列
85
- ["MiniMax-M3", { context: 1_000_000, maxOutput: 128_000, thinking: false, cacheMode: "auto" }],
86
- ["minimax-m3", { context: 1_000_000, maxOutput: 128_000, thinking: false, cacheMode: "auto" }],
89
+ ["MiniMax-M3", { context: 1_000_000, maxOutput: 128_000, thinking: true, cacheMode: "auto", thinkApi: "type", tempRange: [0, 2] }],
90
+ ["minimax-m3", { context: 1_000_000, maxOutput: 128_000, thinking: true, cacheMode: "auto", thinkApi: "type", tempRange: [0, 2] }],
87
91
  ["minimax-m1", { context: 256_000, maxOutput: 128_000, thinking: false, cacheMode: "auto" }],
88
92
  ]
89
93
  const DEFAULT_SPEC = { context: 128_000, maxOutput: 32_000, cacheMode: "none" }
@@ -95,7 +99,7 @@ const COMPACT_RATIO = 0.8
95
99
  export function specForModel(model) {
96
100
  const m = (model ?? "").toLowerCase()
97
101
  for (const [prefix, spec] of [...MODEL_SPECS].sort((a,b) => b[0].length - a[0].length)) {
98
- if (m.startsWith(prefix)) return spec
102
+ if (m.startsWith(prefix.toLowerCase())) return spec
99
103
  }
100
104
  return DEFAULT_SPEC
101
105
  }
@@ -111,12 +115,16 @@ export function resolveCompactThreshold(explicit, model) {
111
115
  }
112
116
 
113
117
  /**
114
- * 从 providers[] 中按 name 查找,找不到返回第一个
118
+ * 从 providers[] 中按 name 查找。
119
+ * name 非空但找不到时抛错——activeProvider 打错字静默落到第一个 provider,会拿错 key 打错端点。
120
+ * name 为空时返回第一个。
115
121
  */
116
122
  export function findProvider(providers, name) {
117
123
  if (name) {
118
124
  const found = providers.find((p) => p.name === name)
119
125
  if (found) return found
126
+ const available = providers.map((p) => p.name).join(", ") || "(空)"
127
+ throw new Error(`activeProvider "${name}" 不在 providers 列表中(可用: ${available}),请检查配置是否打错字: ${configPath}`)
120
128
  }
121
129
  return providers[0] ?? { name: "default", baseURL: "", model: "" }
122
130
  }
@@ -202,5 +210,7 @@ export function loadConfig() {
202
210
  */
203
211
  export function saveConfig(config) {
204
212
  mkdirSync(configDir, { recursive: true })
205
- writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf8")
213
+ // 0600:config.json API key,不能世界可读(POSIX;Windows chmod 尽力而为)
214
+ writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", { encoding: "utf8", mode: 0o600 })
215
+ try { chmodSync(configPath, 0o600) } catch { /* Windows 上可能失败,忽略 */ }
206
216
  }
package/src/context.mjs CHANGED
@@ -69,13 +69,10 @@ const FALLBACK_NOTE =
69
69
  function splitHistory(history) {
70
70
  if (history.length <= KEEP_HEAD + KEEP_TAIL + 1) return null
71
71
  let headEnd = KEEP_HEAD
72
- while (
73
- headEnd < history.length &&
74
- history[headEnd - 1].role === "assistant" &&
75
- history[headEnd - 1].tool_calls?.length &&
76
- history[headEnd].role === "tool"
77
- ) {
78
- headEnd++
72
+ // head 不能以断头 tool_calls 结尾:assistant 声明了 tool_calls,其 tool 结果必须全部留在 head。
73
+ // 并行调用时一个 assistant 后面跟多条 tool 消息——只收一条照样 400,必须一次收完
74
+ if (history[headEnd - 1]?.role === "assistant" && history[headEnd - 1].tool_calls?.length) {
75
+ while (headEnd < history.length && history[headEnd].role === "tool") headEnd++
79
76
  }
80
77
  let tailStart = history.length - KEEP_TAIL
81
78
 
@@ -158,7 +155,11 @@ export async function compressIfNeeded(agent, threshold) {
158
155
  if (tokens <= threshold) return false
159
156
 
160
157
  const split = splitHistory(history)
161
- if (!split) return false
158
+ if (!split) {
159
+ // 历史太短(≤13 条)切不出中间段,但 token 已超阈值——典型是一条巨型消息
160
+ // (大段粘贴/超大注入)。摘要无路可走时退化为确定性瘦身,保证上下文总能减下去
161
+ return shrinkOversized(agent)
162
+ }
162
163
 
163
164
  const middle = history.slice(split.headEnd, split.tailStart)
164
165
  const serialized = middle
@@ -189,3 +190,31 @@ export function compressFallback(agent) {
189
190
  applyCompression(agent, split.headEnd, split.tailStart, FALLBACK_NOTE)
190
191
  return true
191
192
  }
193
+
194
+ /** 单条消息正文的硬截断长度:超过且在压缩无法切分时截断换桩(防一条巨消息卡死压缩) */
195
+ const OVERSIZE_CONTENT_LIMIT = 8_000
196
+
197
+ /**
198
+ * 确定性瘦身:splitHistory 切不出中间段(历史太短)但已超阈值时的最后手段,无 LLM 调用。
199
+ * 把超过 OVERSIZE_CONTENT_LIMIT 的 user/tool 正文截断换桩(保留首尾);
200
+ * 不动 reasoning_content(DeepSeek/Kimi 回传协议)与 tool_calls 配对结构,无协议 400 风险。
201
+ * 只在 compressIfNeeded 判定超阈值后调用。返回是否有消息被截断。
202
+ */
203
+ export function shrinkOversized(agent) {
204
+ let shrunk = false
205
+ for (const m of agent.history) {
206
+ if ((m.role !== "user" && m.role !== "tool") || typeof m.content !== "string") continue
207
+ if (m.content.length <= OVERSIZE_CONTENT_LIMIT) continue
208
+ m.content =
209
+ m.content.slice(0, 4_000) +
210
+ `\n[... ${m.content.length - 6_000} chars truncated — single message too large for context window ...]\n` +
211
+ m.content.slice(-2_000)
212
+ shrunk = true
213
+ }
214
+ if (shrunk) {
215
+ // 与压缩同理:实测 token 基准随被改动的历史失效,退回估算直到下次响应
216
+ agent._lastPromptTokens = null
217
+ agent._usageAtLen = null
218
+ }
219
+ return shrunk
220
+ }
package/src/distill.mjs CHANGED
@@ -67,7 +67,7 @@ export function historyToTranscript(history, { maxChars = 30_000 } = {}) {
67
67
  if (m.role === "tool") {
68
68
  lines.push(`[工具结果] ${(m.content ?? "").slice(0, 500)}`)
69
69
  } else if (m.tool_calls?.length) {
70
- const calls = m.tool_calls.map((tc) => `${tc.function.name}(${tc.function.arguments?.slice(0, 200) ?? ""})`).join(", ")
70
+ const calls = m.tool_calls.map((tc) => `${tc.function?.name ?? "?"}(${tc.function?.arguments?.slice(0, 200) ?? ""})`).join(", ")
71
71
  lines.push(`[assistant] ${m.content ?? ""}\n[调用工具] ${calls}`)
72
72
  } else {
73
73
  lines.push(`[${m.role}] ${m.content ?? ""}`)
@@ -88,7 +88,11 @@ export function historyToTranscript(history, { maxChars = 30_000 } = {}) {
88
88
  */
89
89
  export async function saveCandidate(memory, candidate, opts = {}) {
90
90
  const scope = candidate.scope ?? "personal"
91
- const tags = Array.isArray(candidate.tags) ? candidate.tags : (candidate.tags ?? "").split(/\s+/).filter(Boolean)
91
+ // tags 来自 LLM 输出(不可信):非数组时先 String 化再按逗号/空白切分——
92
+ // 直接对非字符串调 .split 会崩,模型也常给 "a, b" 这种逗号串
93
+ const tags = Array.isArray(candidate.tags)
94
+ ? candidate.tags.map((t) => String(t)).filter(Boolean)
95
+ : String(candidate.tags ?? "").split(/[\s,]+/).filter(Boolean)
92
96
 
93
97
  if (scope === "personal") {
94
98
  const id = await put(memory, { type: candidate.type, title: candidate.title, content: candidate.content, tags: tags.join(" ") })
package/src/embedding.mjs CHANGED
@@ -31,8 +31,15 @@ export async function embed(embedder, texts, { signal } = {}) {
31
31
  for (let i = 0; i < texts.length; i += BATCH_SIZE) {
32
32
  const batch = texts.slice(i, i + BATCH_SIZE)
33
33
  const data = await requestWithRetry(embedder, batch, signal)
34
- // API 按 data[].embedding 返回,顺序与输入一致
35
- for (const item of data.data) {
34
+ // 数量不符直接报错——静默接受会让向量与文本错位,污染整个索引
35
+ if (!Array.isArray(data.data) || data.data.length !== batch.length) {
36
+ throw new Error(`Embedding API returned ${data.data?.length ?? 0} vectors for ${batch.length} inputs`)
37
+ }
38
+ // 规范上 data[] 顺序与输入一致,但以 index 字段为准排序(有的话),不赌服务端实现
39
+ const items = data.data.every((d) => typeof d.index === "number")
40
+ ? [...data.data].sort((a, b) => a.index - b.index)
41
+ : data.data
42
+ for (const item of items) {
36
43
  vectors.push(normalize(Float32Array.from(item.embedding)))
37
44
  }
38
45
  }
@@ -54,6 +61,8 @@ export function toBlob(vec) {
54
61
 
55
62
  /** sqlite BLOB → Float32Array */
56
63
  export function fromBlob(buf) {
64
+ // BLOB 可能来自 Buffer 池,byteOffset 不保证 4 对齐,直接建视图会 RangeError——先复制对齐
65
+ if (buf.byteOffset % 4 !== 0) buf = new Uint8Array(buf)
57
66
  return new Float32Array(buf.buffer, buf.byteOffset, buf.byteLength / 4)
58
67
  }
59
68
 
package/src/gitmem.mjs CHANGED
@@ -67,7 +67,9 @@ export async function pullTeam(dir) {
67
67
  */
68
68
  export async function commitAndPush(dir, filename, message) {
69
69
  await git(dir, ["add", filename])
70
- await git(dir, ["commit", "-m", message])
70
+ // 内容没变化时 commit 会以 exit 1 报 "nothing to commit"——这是正常的幂等结果,不是错误
71
+ const dirty = await git(dir, ["status", "--porcelain", "--", filename])
72
+ if (dirty) await git(dir, ["commit", "-m", message])
71
73
  try {
72
74
  await git(dir, ["push"])
73
75
  } catch {
@@ -80,7 +82,9 @@ export async function commitAndPush(dir, filename, message) {
80
82
  async function hasConflict(dir) {
81
83
  try {
82
84
  const out = await git(dir, ["status", "--porcelain"])
83
- return out.split("\n").some((l) => l.startsWith("UU") || l.startsWith("AA") || l.startsWith("DD"))
85
+ // 未合并状态共 7 种:DD AU UD UA DU AA UU——只看 UU/AA/DD 会漏掉带 U 的四种,
86
+ // 漏判就不 abort,仓库留在冲突中间态(与"保持仓库干净"的承诺相悖)
87
+ return out.split("\n").some((l) => l[0] === "U" || l[1] === "U" || l.startsWith("AA") || l.startsWith("DD"))
84
88
  } catch {
85
89
  return false
86
90
  }
package/src/markdown.mjs CHANGED
@@ -41,14 +41,16 @@ export function parseEntry(text) {
41
41
  export function serializeEntry(meta, content) {
42
42
  if (!VALID_TYPES.has(meta.type)) throw new Error(`invalid type "${meta.type}"`)
43
43
  if (!meta.title) throw new Error("meta.title is required")
44
- const tags = (meta.tags ?? []).map((t) => `${t}`).join(", ")
44
+ // frontmatter 标量必须单行:title/author 含换行会注入伪 frontmatter
45
+ // (如 title "x\ntype: rule" 解析时覆盖真实 type),tags 含换行/逗号同理
46
+ const tags = (meta.tags ?? []).map((t) => oneLine(t).replaceAll(",", " ")).join(", ")
45
47
  const lines = [
46
48
  "---",
47
49
  `type: ${meta.type}`,
48
- `title: ${meta.title}`,
50
+ `title: ${oneLine(meta.title)}`,
49
51
  `tags: [${tags}]`,
50
- `author: ${meta.author ?? "unknown"}`,
51
- `created: ${meta.created ?? new Date().toISOString().slice(0, 10)}`,
52
+ `author: ${oneLine(meta.author ?? "unknown")}`,
53
+ `created: ${oneLine(meta.created ?? new Date().toISOString().slice(0, 10))}`,
52
54
  ]
53
55
  if (meta.embedding) lines.push(`embedding: ${meta.embedding}`)
54
56
  lines.push("---", "", content.trim(), "")
@@ -74,6 +76,11 @@ export function entryFilename(title, date = new Date()) {
74
76
 
75
77
  // ---------------------------------------------------------------- 内部
76
78
 
79
+ /** 压成单行(frontmatter 标量用):换行折叠为空格,防注入伪字段行 */
80
+ function oneLine(v) {
81
+ return String(v).replace(/\s*\r?\n\s*/g, " ").trim()
82
+ }
83
+
77
84
  /**
78
85
  * 极简 YAML 子集解析:只支持 `key: value` 和 `key: [a, b, c]`。
79
86
  * 我们的 frontmatter 是自己生成的,不需要完整 YAML。