thincoder 0.8.2 → 0.8.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/README.md +2 -1
  2. package/bin/thincoder.mjs +25 -25
  3. package/package.json +1 -1
  4. package/src/agent/dispatch.mjs +17 -12
  5. package/src/agent/helpers.mjs +22 -11
  6. package/src/agent/setup.mjs +32 -10
  7. package/src/agent-tools/goal.mjs +12 -11
  8. package/src/agent-tools/plan.mjs +5 -6
  9. package/src/agent-tools/recent-changes.mjs +3 -3
  10. package/src/agent-tools/skill.mjs +6 -6
  11. package/src/agent-tools/subagent.mjs +19 -18
  12. package/src/agent-tools/task.mjs +7 -26
  13. package/src/agent-tools/verify.mjs +21 -19
  14. package/src/agent-tools.mjs +3 -3
  15. package/src/agent.mjs +82 -92
  16. package/src/cli/distill-command.mjs +2 -2
  17. package/src/cli/make-agent.mjs +9 -9
  18. package/src/cli/memory-command.mjs +1 -1
  19. package/src/cli/permission.mjs +3 -3
  20. package/src/cli/setup-wizard.mjs +15 -15
  21. package/src/config.mjs +56 -53
  22. package/src/context.mjs +59 -61
  23. package/src/distill.mjs +35 -35
  24. package/src/embedding.mjs +17 -17
  25. package/src/git/checkpoint.mjs +211 -38
  26. package/src/git/gitmem.mjs +21 -20
  27. package/src/markdown.mjs +13 -13
  28. package/src/mcp/helpers.mjs +6 -1
  29. package/src/mcp/transport-http.mjs +2 -1
  30. package/src/mcp/transport-stdio.mjs +3 -2
  31. package/src/mcp/transport-ws.mjs +3 -2
  32. package/src/mcp.mjs +5 -2
  33. package/src/memory/code-index.mjs +16 -14
  34. package/src/memory/code-sync.mjs +36 -26
  35. package/src/memory/core.mjs +42 -35
  36. package/src/memory/docs.mjs +24 -15
  37. package/src/memory/schema.mjs +28 -27
  38. package/src/memory.mjs +2 -2
  39. package/src/prompts/coder.md +0 -6
  40. package/src/prompts/discipline.md +9 -0
  41. package/src/prompts/main.md +24 -24
  42. package/src/prompts/system.md +21 -16
  43. package/src/provider/core.mjs +11 -6
  44. package/src/provider/index.mjs +2 -2
  45. package/src/provider/rate.mjs +11 -11
  46. package/src/session.mjs +73 -42
  47. package/src/skills.mjs +17 -17
  48. package/src/tools/checkpoint.md +6 -2
  49. package/src/tools/file.mjs +20 -14
  50. package/src/tools/git.mjs +83 -12
  51. package/src/tools/index.mjs +1 -1
  52. package/src/tools/ls.md +1 -1
  53. package/src/tools/patch.mjs +20 -18
  54. package/src/tools/repomap-parse.mjs +17 -17
  55. package/src/tools/repomap.mjs +29 -29
  56. package/src/tools/shared.mjs +55 -28
  57. package/src/tools/system.mjs +163 -118
  58. package/src/tools/web.mjs +6 -6
  59. package/src/tui/agent-turn.mjs +82 -26
  60. package/src/tui/ansi.mjs +5 -3
  61. package/src/tui/clipboard.mjs +2 -2
  62. package/src/tui/cmd-auto.mjs +3 -12
  63. package/src/tui/cmd-clear.mjs +1 -1
  64. package/src/tui/cmd-config.mjs +108 -23
  65. package/src/tui/cmd-exit.mjs +2 -2
  66. package/src/tui/cmd-extract.mjs +11 -3
  67. package/src/tui/cmd-goal.mjs +3 -12
  68. package/src/tui/cmd-help.mjs +2 -2
  69. package/src/tui/cmd-init.mjs +3 -3
  70. package/src/tui/cmd-mcp.mjs +38 -25
  71. package/src/tui/cmd-model.mjs +1 -1
  72. package/src/tui/cmd-new.mjs +33 -13
  73. package/src/tui/cmd-plan.mjs +3 -12
  74. package/src/tui/cmd-reindex.mjs +2 -2
  75. package/src/tui/cmd-restore.mjs +1 -1
  76. package/src/tui/cmd-session.mjs +1 -1
  77. package/src/tui/cmd-skills.mjs +1 -1
  78. package/src/tui/cmd-think.mjs +4 -11
  79. package/src/tui/config-helpers.mjs +6 -6
  80. package/src/tui/distill-cmd.mjs +4 -3
  81. package/src/tui/index.mjs +131 -72
  82. package/src/tui/interaction.mjs +13 -12
  83. package/src/tui/key-handler.mjs +34 -26
  84. package/src/tui/layout.mjs +22 -17
  85. package/src/tui/pickers.mjs +19 -19
  86. package/src/tui/render-frame.mjs +37 -11
  87. package/src/tui/render.mjs +25 -24
  88. package/src/tui/slash-commands.mjs +22 -22
  89. package/src/tui/startup.mjs +15 -14
  90. package/src/tui/wizard.mjs +11 -11
  91. package/src/tui.mjs +2 -2
  92. package/bin/thincoder.js +0 -4
  93. package/src/tools/bash.mjs +0 -144
  94. package/src/tools/glob.mjs +0 -51
  95. package/src/tools/grep.mjs +0 -100
  96. package/src/tools/ls.mjs +0 -36
  97. package/src/tools.mjs +0 -2
  98. package/src/tui-render.mjs +0 -4
package/src/context.mjs CHANGED
@@ -1,14 +1,16 @@
1
1
  /**
2
- * context.mjs — 上下文管理与压缩
3
- * token 无实测值时用估算兜底(ASCII/4 + ASCII/1,不引 tokenizer 依赖);
4
- * 有实测值(响应 usage.prompt_tokens)以实测为准——估算对 CJK 低估 3-4 倍,靠它触发可能永远来不及压缩。
5
- * 压缩策略:保留最早 2 + 最近 N 条,中间由 LLM 摘要成一条(学 kimi-code,简化版)。
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
- /** 粗估一组消息的 token 数(正文 + 思考链 + tool_calls 参数) */
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 += 256 // 图片占位估算
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,58 @@ 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 = `你是一个对话压缩器。把下面的 agent 工作记录压缩成一份紧凑的摘要,供后续对话作为上下文使用。
34
- 要求:
35
- - 用第一人称、现在时书写——这是""的交接笔记,延续自己的思路
36
- - 最重要的:保留设计决策与原因——架构选择、API 约定、命名规范、取舍理由。这是后续代码不能偏离的锚点
37
- - 保留:用户的原始需求、修改过的文件及原因、未解决的问题、下一步计划
38
- - 丢弃:客套话、重复内容、工具输出的细枝末节
39
- - 诚实标注不确定项:没有实际验证过的事必须写"未验证",不要把猜测写成事实
40
- - 用条目式输出,以信息完整为目标,不要硬卡字数(旧 500 字限制已作废,1M 上下文时代宁长勿缺)
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
- /** 压缩后的上下文前缀,告知 agent 发生了什么 */
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
- "but re-verify transient state (open files, running processes) with tools before relying on them. " +
50
- "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
+ "but re-verify transient state with tools. Check memory_search for any missing decisions.]\n\n"
51
52
 
52
- /** 压缩摘要调用连续失败达到此次数后,降级为确定性截断(丢信息好过任务被 400 打死) */
53
+ /** After this many consecutive compaction summary failures, degrade to deterministic truncation (losing info is better than task-killing 400 errors) */
53
54
  export const COMPRESS_FAILURE_LIMIT = 3
54
55
 
55
- /** task 回注提醒前缀(压缩后重新注入前,先清掉历史里的旧版本,保持单一信息源) */
56
+ /** Task re-injection reminder prefix (after compaction, clear old versions from history first for a single source of truth) */
56
57
  const TASK_REINJECT_PREFIX = "[System reminder: your current task list after compaction:"
57
58
 
58
- /** 截断兜底笔记(摘要 LLM 连续失败时用,无 LLM 调用) */
59
+ /** Truncation fallback note (used when the summary LLM fails repeatedly; no LLM call) */
59
60
  const FALLBACK_NOTE =
60
61
  "[Context was truncated after repeated summarization failures. " +
61
62
  "The middle portion of earlier work was dropped WITHOUT a summary. " +
62
63
  "Re-verify any state you need with tools before relying on it.]\n\n"
63
64
 
64
65
  /**
65
- * 切分 head / middle(被摘要) / tail;没有可压缩的中间段返回 null
66
- * head 终点必须避开断头 tool_callsassistant 带了 tool_calls 时其 tool 响应必须留在 head
67
- * 否则响应被摘要成纯文本后协议校验 400(tool_calls must be followed by tool messages)。
68
- * tail 起点必须包含 tool 结果对应的 assistant——tool tailassistant middle 时,
69
- * 摘要会把 assistant 吞掉,留下 orphan tool 结果协议 400
66
+ * Split history into head / middle (to be summarized) / tail; return null if no middle to compress.
67
+ * The head boundary must avoid orphan tool_calls: when an assistant message has tool_calls, all its tool responses must stay in head,
68
+ * otherwise compressing them to plain text violates the protocol (tool_calls must be followed by tool messages).
69
+ * The tail boundary must include any assistant whose tool results are in the tail — if the assistant is in the middle,
70
+ * the summary swallows it, leaving orphan tool resultsprotocol 400.
70
71
  */
71
72
  function splitHistory(history) {
72
73
  if (history.length <= KEEP_HEAD + KEEP_TAIL + 1) return null
73
74
  let headEnd = KEEP_HEAD
74
- // head 不能以断头 tool_calls 结尾:assistant 声明了 tool_calls,其 tool 结果必须全部留在 head
75
- // 并行调用时一个 assistant 后面跟多条 tool 消息——只收一条照样 400,必须一次收完
75
+ // head must not end with dangling tool_calls: when assistant declares tool_calls, all its tool results must stay in head.
76
+ // Parallel calls: one assistant followed by multiple tool messages — accepting only one still causes 400, must collect all
76
77
  if (history[headEnd - 1]?.role === "assistant" && history[headEnd - 1].tool_calls?.length) {
77
78
  while (headEnd < history.length && history[headEnd].role === "tool") headEnd++
78
79
  }
79
80
  let tailStart = history.length - KEEP_TAIL
80
81
 
81
- // tail 区域内的 tool 消息对应的 assistant tool_calls 若在 middle 里,摘要会把 assistant 吞掉,
82
- // 剩下 orphan tool 结果协议 400。从 tail 收集 tool_call_id,往前找回所属 assistant 拉进 tail
82
+ // Tool messages in the tail region whose assistant tool_calls are in the middle: the summary would swallow the assistant,
83
+ // leaving orphan tool resultsprotocol 400. Collect tool_call_ids from the tail, find their owner assistants and pull them into tail
83
84
  const tailToolIds = new Set()
84
85
  for (let i = tailStart; i < history.length; i++) {
85
86
  if (history[i].role === "tool") tailToolIds.add(history[i].tool_call_id)
@@ -100,7 +101,7 @@ function splitHistory(history) {
100
101
  return { headEnd, tailStart }
101
102
  }
102
103
 
103
- /** 用一条笔记替换 middle,并回注 task/plan 状态(LLM 摘要与截断兜底共用) */
104
+ /** Replace middle with a note, then re-inject task/plan state (shared by LLM summary and truncation fallback) */
104
105
  function applyCompression(agent, headEnd, tailStart, note) {
105
106
  const head = agent.history.slice(0, headEnd)
106
107
  const tail = agent.history.slice(tailStart)
@@ -110,13 +111,13 @@ function applyCompression(agent, headEnd, tailStart, note) {
110
111
  { role: "assistant", content: "Understood. I'll continue from these notes, re-verifying anything transient." },
111
112
  ...tail,
112
113
  ]
113
- // 实测 token 基准随旧历史一起失效(prompt_tokens 对应的是压缩前的上下文),退回估算直到下次响应
114
+ // Measured token baseline is invalidated along with old history (prompt_tokens were for pre-compaction context), fall back to estimation until next response
114
115
  agent._lastPromptTokens = null
115
116
  agent._usageAtLen = null
116
117
 
117
- // 压缩后回注 task 列表(agent 需要知道自己做到哪了)。
118
- // 单一信息源:先清掉 tail 里残留的旧回注,再注入最新版本——
119
- // 不再嵌入摘要正文(会与这里重复且逐渐过时)
118
+ // After compaction, re-inject the task list (the agent needs to know what it was doing).
119
+ // Single source of truth: first remove any stale re-injections from the tail, then inject the latest version —
120
+ // no longer embedded in the summary body (would duplicate and grow stale)
120
121
  agent.history = agent.history.filter(
121
122
  (m) => !(m.role === "user" && typeof m.content === "string" && m.content.startsWith(TASK_REINJECT_PREFIX))
122
123
  )
@@ -128,11 +129,7 @@ function applyCompression(agent, headEnd, tailStart, note) {
128
129
  })
129
130
  }
130
131
 
131
- // 重置跟踪计数器(上下文已重建,从头开始计数)
132
- agent._turnsSinceTaskUpdate = 0
133
- agent._turnsInPlanMode = 0
134
-
135
- // plan mode 中压缩:重新注入 plan 模式引导
132
+ // Plan mode compaction: re-inject plan mode guidance
136
133
  if (agent.planMode) {
137
134
  agent.history.push({
138
135
  role: "user",
@@ -142,14 +139,14 @@ function applyCompression(agent, headEnd, tailStart, note) {
142
139
  }
143
140
 
144
141
  /**
145
- * 如果历史超长则压缩。返回是否发生了压缩。
146
- * 只在循环的安全点调用(history 末尾是 user tool 消息——完整交换的边界)。
147
- * 压缩后自动回注 task 列表状态。
142
+ * If history exceeds threshold, compact it. Returns whether compaction happened.
143
+ * Only called at safe points in the loop (history ends with user or tool message — a complete exchange boundary).
144
+ * Automatically re-injects task list state after compaction.
148
145
  */
149
146
  export async function compressIfNeeded(agent, threshold) {
150
147
  const history = agent.history
151
- // 真实基准优先:上次响应的 prompt_tokens 是完整上下文(system+tools+history)的实测值,
152
- // 之后追加的消息用估算补增量;无实测(首轮/恢复后/刚压缩完)退化为纯估算
148
+ // Prefer the real baseline: the last response's prompt_tokens is the measured value for the full context (system+tools+history).
149
+ // 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
150
  const tokens =
154
151
  agent._lastPromptTokens != null
155
152
  ? agent._lastPromptTokens + estimateTokens(history.slice(agent._usageAtLen ?? history.length))
@@ -158,23 +155,24 @@ export async function compressIfNeeded(agent, threshold) {
158
155
 
159
156
  const split = splitHistory(history)
160
157
  if (!split) {
161
- // 历史太短(≤13 条)切不出中间段,但 token 已超阈值——典型是一条巨型消息
162
- // (大段粘贴/超大注入)。摘要无路可走时退化为确定性瘦身,保证上下文总能减下去
158
+ // History is too short (≤13 messages) to find a middle section, but tokens exceed threshold — typically a single giant message
159
+ // (large paste / huge injection). When summarization has no room, degrade to deterministic shrinking to ensure context always reduces
163
160
  return shrinkOversized(agent)
164
161
  }
165
162
 
166
163
  const middle = history.slice(split.headEnd, split.tailStart)
167
164
  const serialized = middle
168
165
  .map((m) => {
169
- const toolNote = m.tool_calls ? ` [调用了工具: ${m.tool_calls.map((t) => t.function.name).join(", ")}]` : ""
170
- // user 消息放宽到 8000:用户粘贴的长需求被切掉会让摘要丢失原始意图;tool/assistant 2000 足够
166
+ const toolNote = m.tool_calls ? ` [called tools: ${m.tool_calls.map((t) => t.function.name).join(", ")}]` : ""
167
+ // 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
168
  const cap = m.role === "user" ? 8000 : 2000
172
169
  const content = typeof m.content === "string" ? m.content.slice(0, cap) : ""
173
170
  return `[${m.role}]${toolNote} ${content}`
174
171
  })
175
172
  .join("\n")
176
173
 
177
- const summary = await chat(agent.provider, {
174
+ // The summary is a plain-text task, no reasoning needed — passing thinking to the compaction provider wastes tokens
175
+ const summary = await chat({ ...agent.provider, thinking: null, reasoningEffort: null }, {
178
176
  messages: [{ role: "user", content: SUMMARIZE_PROMPT + serialized }],
179
177
  })
180
178
 
@@ -183,8 +181,8 @@ export async function compressIfNeeded(agent, threshold) {
183
181
  }
184
182
 
185
183
  /**
186
- * 确定性截断兜底:摘要 LLM 连续失败时调用,不碰网络。
187
- * 丢掉 middle 换任务能继续跑。返回是否发生了截断。
184
+ * Deterministic truncation fallback: called when the summary LLM fails repeatedly, no network call.
185
+ * Drops the middle so the task can continue. Returns whether truncation happened.
188
186
  */
189
187
  export function compressFallback(agent) {
190
188
  const split = splitHistory(agent.history)
@@ -193,21 +191,21 @@ export function compressFallback(agent) {
193
191
  return true
194
192
  }
195
193
 
196
- /** 单条消息正文的硬截断长度:超过且在压缩无法切分时截断换桩(防一条巨消息卡死压缩) */
194
+ /** 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
195
  const OVERSIZE_CONTENT_LIMIT = 8_000
198
196
 
199
197
  /**
200
- * 确定性瘦身:splitHistory 切不出中间段(历史太短)但已超阈值时的最后手段,无 LLM 调用。
201
- * 把超过 OVERSIZE_CONTENT_LIMIT 的 user/tool 正文截断换桩(保留首尾);
202
- * 不动 reasoning_contentDeepSeek/Kimi 回传协议)与 tool_calls 配对结构,无协议 400 风险。
203
- * 只在 compressIfNeeded 判定超阈值后调用。返回是否有消息被截断。
198
+ * Deterministic shrinking: last resort when splitHistory can't find a middle section (history too short) but threshold is exceeded. No LLM call.
199
+ * Truncates user/tool message bodies exceeding OVERSIZE_CONTENT_LIMIT to a stub (keeps head + tail);
200
+ * does not touch reasoning_content (DeepSeek/Kimi echo protocol) or tool_calls pairing structure — no protocol 400 risk.
201
+ * Only called after compressIfNeeded determines threshold is exceeded. Returns whether any message was truncated.
204
202
  */
205
203
  export function shrinkOversized(agent, limit = OVERSIZE_CONTENT_LIMIT) {
206
204
  let shrunk = false
207
205
  for (const m of agent.history) {
208
206
  if ((m.role !== "user" && m.role !== "tool") || typeof m.content !== "string") continue
209
207
  if (m.content.length <= limit) continue
210
- // 截断保留首尾,中间换桩说明;keepHead/keepTail 按比例但不超过 limit 50%/25%
208
+ // Truncate keeping head + tail, insert stub in between; keepHead/keepTail proportional but not exceeding 50%/25% of limit
211
209
  const keepHead = Math.min(Math.floor(limit * 0.5), 4000)
212
210
  const keepTail = Math.min(Math.floor(limit * 0.25), 2000)
213
211
  m.content =
@@ -217,7 +215,7 @@ export function shrinkOversized(agent, limit = OVERSIZE_CONTENT_LIMIT) {
217
215
  shrunk = true
218
216
  }
219
217
  if (shrunk) {
220
- // 与压缩同理:实测 token 基准随被改动的历史失效,退回估算直到下次响应
218
+ // Same as compaction: measured token baseline is invalidated by the changed history, fall back to estimation until next response
221
219
  agent._lastPromptTokens = null
222
220
  agent._usageAtLen = null
223
221
  }
package/src/distill.mjs CHANGED
@@ -1,53 +1,53 @@
1
1
  /**
2
- * distill.mjs — 从会话中提取知识候选条目(双轨制的"自动轨"
3
- * 原则(已定):手动触发、LLM 出候选、人工逐条确认后入库。
4
- * 绝不做会话结束后的全自动沉淀。
2
+ * distill.mjs — extract knowledge candidates from sessions (the "automatic track" of the dual-track system)
3
+ * Principle (settled): manually triggered, LLM produces candidates, human confirms each one before writing.
4
+ * Absolutely no automatic storage at session end.
5
5
  */
6
6
 
7
7
  import { chat } from "./provider/index.mjs"
8
8
  import { put, putMarkdown } from "./memory.mjs"
9
9
  import { commitAndPush } from "./git/gitmem.mjs"
10
10
 
11
- const DISTILL_PROMPT = `你是知识提取器。阅读下面的 agent 工作会话记录,提取值得跨会话长期记住的知识。
11
+ const DISTILL_PROMPT = `You are a knowledge extractor. Read the following agent work session log and extract knowledge worth remembering across sessions.
12
12
 
13
- 输出一个 JSON 数组(不要输出任何其他内容):
13
+ Output a JSON array (nothing else):
14
14
  [
15
15
  {
16
16
  "type": "rule | knowledge | decision | pattern",
17
- "title": "简短标题",
18
- "content": "完整内容,自包含,脱离会话上下文也能看懂",
17
+ "title": "Short title",
18
+ "content": "Full content, self-contained — understandable without session context",
19
19
  "tags": ["tag1", "tag2"],
20
20
  "scope": "personal | project"
21
21
  }
22
22
  ]
23
23
 
24
- 提取标准:
25
- - knowledge:项目的事实性知识(架构、部署、约定俗成的做法)
26
- - decision:会话中做出的技术决策及理由
27
- - pattern:调试经验、问题解法、可复用的工作模式
28
- - rule:编码规范类(谨慎!规范通常应由人手动撰写,只有会话中明确确立的才提取)
29
- - scope 判断:专属于当前项目的用 project;通用的或个人偏好用 personal
24
+ Extraction criteria:
25
+ - knowledge: factual project knowledge (architecture, deployment, conventions)
26
+ - decision: technical decisions made in the session and their rationale
27
+ - pattern: debugging experiences, solutions, reusable workflows
28
+ - rule: coding standards (caution! rules are usually best written manually; only extract rules explicitly established in the session)
29
+ - scope: use "project" for project-specific knowledge; use "personal" for general or personal preferences
30
30
 
31
- 不要提取:
32
- - 一次性的任务细节("今天改了某个文件的某行"
33
- - 会话中提到的临时状态(当前的 bug、进行中的工作)
34
- - 客套话和显而易见的事实
31
+ Do NOT extract:
32
+ - one-off task details ("changed line X in file Y today")
33
+ - transient state mentioned in the session (current bugs, work-in-progress)
34
+ - pleasantries and obvious facts
35
35
 
36
- 如果没有值得提取的内容,输出 []
37
- 如果会话太长,优先提取最后出现的、仍在生效的结论。
36
+ If nothing is worth extracting, output []
37
+ If the session is long, prioritize conclusions that appeared last and are still in effect.
38
38
 
39
- 会话记录:
39
+ Session log:
40
40
  `
41
41
 
42
42
  /**
43
- * 从会话记录提取候选条目。transcript: 纯文本会话记录。
44
- * 返回 [{ type, title, content, tags, scope }],解析失败返回 []
43
+ * Extract candidates from a session transcript. transcript: plain-text session record.
44
+ * Returns [{ type, title, content, tags, scope }], or [] on parse failure.
45
45
  */
46
46
  export async function extractCandidates(provider, transcript) {
47
47
  const res = await chat(provider, {
48
48
  messages: [{ role: "user", content: DISTILL_PROMPT + transcript }],
49
49
  })
50
- // 非贪婪匹配第一个 JSON 数组(贪婪 [\s\S]* 会跨多个数组把中间文本也吃进去)
50
+ // Non-greedy match first JSON array (greedy [\s\S]* would eat across multiple arrays including interstitial text)
51
51
  const match = res.content.match(/\[[\s\S]*?\]/)
52
52
  if (!match) return []
53
53
  try {
@@ -60,37 +60,37 @@ export async function extractCandidates(provider, transcript) {
60
60
  }
61
61
 
62
62
  /**
63
- * agent OpenAI 格式 history 转成可读的会话记录文本。
63
+ * Convert agent's OpenAI-format history to readable session transcript text.
64
64
  */
65
65
  export function historyToTranscript(history, { maxChars = 30_000 } = {}) {
66
66
  const lines = []
67
67
  for (const m of history) {
68
68
  if (m.role === "tool") {
69
- lines.push(`[工具结果] ${(m.content ?? "").slice(0, 500)}`)
69
+ lines.push(`[tool result] ${(m.content ?? "").slice(0, 500)}`)
70
70
  } else if (m.tool_calls?.length) {
71
71
  const calls = m.tool_calls.map((tc) => `${tc.function?.name ?? "?"}(${tc.function?.arguments?.slice(0, 200) ?? ""})`).join(", ")
72
- lines.push(`[assistant] ${m.content ?? ""}\n[调用工具] ${calls}`)
72
+ lines.push(`[assistant] ${m.content ?? ""}\n[called tools] ${calls}`)
73
73
  } else {
74
74
  lines.push(`[${m.role}] ${m.content ?? ""}`)
75
75
  }
76
76
  }
77
77
  const text = lines.join("\n\n")
78
- // 超长时保留头尾(最早的需求 + 最新的结论最重要)
78
+ // Overlong: keep head and tail (earliest requirements + latest conclusions are most important)
79
79
  if (text.length <= maxChars) return text
80
80
  const half = Math.floor(maxChars / 2)
81
- return text.slice(0, half) + "\n\n...[中间部分省略]...\n\n" + text.slice(-half)
81
+ return text.slice(0, half) + "\n\n...[... middle portion omitted ...]...\n\n" + text.slice(-half)
82
82
  }
83
83
 
84
84
  /**
85
- * 把确认的候选条目写入指定层。
85
+ * Write confirmed candidates to the specified layer.
86
86
  * opts: { projectDir, team: { dir } | null, author }
87
- * scope=team 需要 opts.teamproject 需要 opts.projectDir
88
- * 返回写入结果描述。
87
+ * scope=team requires opts.team; project requires opts.projectDir.
88
+ * Returns write result description.
89
89
  */
90
90
  export async function saveCandidate(memory, candidate, opts = {}) {
91
91
  const scope = candidate.scope ?? "personal"
92
- // tags 来自 LLM 输出(不可信):非数组时先 String 化再按逗号/空白切分——
93
- // 直接对非字符串调 .split 会崩,模型也常给 "a, b" 这种逗号串
92
+ // tags come from LLM output (untrusted): if not an array, stringify then split by comma/whitespace —
93
+ // calling .split on a non-string would crash, and models often produce "a, b" comma strings
94
94
  const tags = Array.isArray(candidate.tags)
95
95
  ? candidate.tags.map((t) => String(t)).filter(Boolean)
96
96
  : String(candidate.tags ?? "").split(/[\s,]+/).filter(Boolean)
@@ -100,7 +100,7 @@ export async function saveCandidate(memory, candidate, opts = {}) {
100
100
  return `personal#${id}`
101
101
  }
102
102
  if (scope === "project") {
103
- if (!opts.projectDir) throw new Error("project scope unavailable")
103
+ if (!opts.projectDir) throw new Error("project scope unavailable — no project directory configured (set memory.projectDir in ~/.thincoder/config.json)")
104
104
  const filename = await putMarkdown(memory, {
105
105
  layer: "project", dir: opts.projectDir,
106
106
  type: candidate.type, title: candidate.title, content: candidate.content,
@@ -109,7 +109,7 @@ export async function saveCandidate(memory, candidate, opts = {}) {
109
109
  return `project:${filename}`
110
110
  }
111
111
  if (scope === "team") {
112
- if (!opts.team?.dir) throw new Error("team scope not configured")
112
+ if (!opts.team?.dir) throw new Error("team scope not configured — configure memory.team in ~/.thincoder/config.json")
113
113
  const filename = await putMarkdown(memory, {
114
114
  layer: "team", dir: opts.team.dir,
115
115
  type: candidate.type, title: candidate.title, content: candidate.content,
package/src/embedding.mjs CHANGED
@@ -1,19 +1,19 @@
1
1
  /**
2
- * embedding.mjs — 向量嵌入
3
- * OpenAI 兼容 /v1/embeddingsSiliconFlow bge-m3 / Ollama / OpenAI 均可),
4
- * 复用 provider.mjs fetch + 重试模式,零依赖。
5
- * 向量在入库前归一化,之后点积即余弦相似度。
2
+ * embedding.mjs — vector embeddings
3
+ * OpenAI-compatible /v1/embeddings (SiliconFlow bge-m3 / Ollama / OpenAI all supported),
4
+ * reuses provider.mjs fetch + retry pattern, zero dependencies.
5
+ * Vectors are normalized before storage; dot product then equals cosine similarity.
6
6
  */
7
7
 
8
8
  import { RETRYABLE_STATUS } from "./provider/index.mjs"
9
9
  const MAX_RETRIES = 3
10
- const BATCH_SIZE = 32 // 单次请求的文本数上限(SiliconFlow 限制内)
10
+ const BATCH_SIZE = 32 // max texts per request (within SiliconFlow limits)
11
11
 
12
- /** 创建 embedderconfig: { baseURL, apiKey, model } */
12
+ /** Create an embedder. config: { baseURL, apiKey, model } */
13
13
  export function createEmbedder(config) {
14
- if (!config?.baseURL) throw new Error("embedding config: baseURL is required")
15
- if (!config?.apiKey) throw new Error("embedding config: apiKey is required (config file or SILICONFLOW_API_KEY env)")
16
- if (!config?.model) throw new Error("embedding config: model is required")
14
+ if (!config?.baseURL) throw new Error("embedding config: baseURL is required — configure embedding.baseURL in ~/.thincoder/config.json")
15
+ if (!config?.apiKey) throw new Error("embedding config: apiKey is required set SILICONFLOW_API_KEY env or configure embedding.apiKey in ~/.thincoder/config.json")
16
+ if (!config?.model) throw new Error("embedding config: model is required — configure embedding.model in ~/.thincoder/config.json")
17
17
  return {
18
18
  baseURL: config.baseURL.replace(/\/+$/, ""),
19
19
  apiKey: config.apiKey,
@@ -22,8 +22,8 @@ export function createEmbedder(config) {
22
22
  }
23
23
 
24
24
  /**
25
- * 批量嵌入。texts: string[] → Float32Array[](已归一化)
26
- * 自动分批,失败重试(指数退避)。
25
+ * Batch embedding. texts: string[] → Float32Array[] (normalized)
26
+ * Auto-batches, retries on failure (exponential backoff).
27
27
  */
28
28
  export async function embed(embedder, texts, { signal } = {}) {
29
29
  if (texts.length === 0) return []
@@ -31,11 +31,11 @@ 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
- // 数量不符直接报错——静默接受会让向量与文本错位,污染整个索引
34
+ // Mismatched count is a hard error — silently accepting would misalign vectors with texts, poisoning the entire index
35
35
  if (!Array.isArray(data.data) || data.data.length !== batch.length) {
36
36
  throw new Error(`Embedding API returned ${data.data?.length ?? 0} vectors for ${batch.length} inputs`)
37
37
  }
38
- // 规范上 data[] 顺序与输入一致,但以 index 字段为准排序(有的话),不赌服务端实现
38
+ // Spec says data[] order matches input, but sort by index field if present — don't bet on server implementation
39
39
  const items = data.data.every((d) => typeof d.index === "number")
40
40
  ? [...data.data].sort((a, b) => a.index - b.index)
41
41
  : data.data
@@ -46,7 +46,7 @@ export async function embed(embedder, texts, { signal } = {}) {
46
46
  return vectors
47
47
  }
48
48
 
49
- /** 余弦相似度(输入均已归一化,点积即余弦) */
49
+ /** Cosine similarity (inputs are normalized, dot product equals cosine) */
50
50
  export function cosine(a, b) {
51
51
  if (a.length !== b.length) return 0
52
52
  let sum = 0
@@ -55,20 +55,20 @@ export function cosine(a, b) {
55
55
  return sum
56
56
  }
57
57
 
58
- /** Float32Array → 可存 sqlite BLOB Buffer */
58
+ /** Float32Array → Buffer suitable for sqlite BLOB storage */
59
59
  export function toBlob(vec) {
60
60
  return Buffer.from(vec.buffer, vec.byteOffset, vec.byteLength)
61
61
  }
62
62
 
63
63
  /** sqlite BLOB → Float32Array */
64
64
  export function fromBlob(buf) {
65
- // BLOB 可能来自 Buffer 池,byteOffset 不保证 4 对齐,直接建视图会 RangeError——先复制对齐
65
+ // BLOB may come from Buffer pool where byteOffset isn't 4-aligned; creating a view directly would RangeError — copy to align first
66
66
  if (buf.byteOffset % 4 !== 0) buf = new Uint8Array(buf)
67
67
  if (buf.byteLength % 4 !== 0) return new Float32Array(0)
68
68
  return new Float32Array(buf.buffer, buf.byteOffset, buf.byteLength / 4)
69
69
  }
70
70
 
71
- // ---------------------------------------------------------------- 内部
71
+ // ---------------------------------------------------------------- internal
72
72
 
73
73
  async function requestWithRetry(embedder, input, signal) {
74
74
  let lastError