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
@@ -1,10 +1,10 @@
1
1
  /**
2
- * memory/code-index.mjs — 代码和文档的分块、语言检测、符号提取
2
+ * memory/code-index.mjs — code and document chunking, language detection, symbol extraction
3
3
  */
4
4
 
5
5
  import { segmentCJK, CODE_EXTS, DOC_EXTS, SKIP_DIRS, BIG_FILE_LINES } from "./schema.mjs"
6
6
 
7
- /** 推断文件语言(按扩展名) */
7
+ /** Infer file language by extension */
8
8
  export function detectLanguage(filename) {
9
9
  const ext = filename.slice(filename.lastIndexOf(".")).toLowerCase()
10
10
  const map = {
@@ -21,8 +21,8 @@ export function detectLanguage(filename) {
21
21
  }
22
22
 
23
23
  /**
24
- * 用正则提取 JS/TS 文件的顶层符号声明(函数、类、const 导出等)。
25
- * 返回 [{ name, line, kind }]
24
+ * Extract top-level symbol declarations (functions, classes, const exports, etc.)
25
+ * from JS/TS files using regex. Returns [{ name, line, kind }].
26
26
  */
27
27
  export function extractSymbols(lines, ext) {
28
28
  const jsish = new Set([".mjs", ".js", ".ts", ".jsx", ".tsx"])
@@ -41,7 +41,7 @@ export function extractSymbols(lines, ext) {
41
41
  return symbols
42
42
  }
43
43
 
44
- /** 提取 Python 文件的顶层 def/class */
44
+ /** Extract top-level def/class from Python files. */
45
45
  export function extractPySymbols(lines) {
46
46
  const symbols = []
47
47
  const re = /^(?:async\s+)?(?:def|class)\s+(\w+)/gm
@@ -54,8 +54,9 @@ export function extractPySymbols(lines) {
54
54
  }
55
55
 
56
56
  /**
57
- * 将一个文件拆成代码块。小文件整文件一块;大文件按符号切分,符号间的内容并入前一个符号块。
58
- * 每个块会额外带上符号前的 JSDoc / docstring 注释,提升搜索质量。
57
+ * Split a file into code chunks. Small files become a single chunk;
58
+ * large files are split by symbol, with inter-symbol content merged into the preceding symbol chunk.
59
+ * Each chunk includes the JSDoc/docstring before its symbol to improve search quality.
59
60
  */
60
61
  export function chunkCode(lines, filepath) {
61
62
  const ext = filepath.slice(filepath.lastIndexOf(".")).toLowerCase()
@@ -91,9 +92,9 @@ export function chunkCode(lines, filepath) {
91
92
  }
92
93
 
93
94
  /**
94
- * 提取指定行之前的 JSDoc / docstring 注释。
95
- * JS/TS: 向前扫描 /** ... *​/ // 连续注释行
96
- * Python: 符号定义行的下一行开始找 """...""" docstring
95
+ * Extract the JSDoc/docstring comment preceding a given line.
96
+ * JS/TS: scan backwards for JSDoc block comments or consecutive // comment lines
97
+ * Python: look for a """...""" docstring on the line after the symbol definition
97
98
  */
98
99
  export function extractLeadingDoc(lines, lineNum, ext) {
99
100
  if (ext === ".py") {
@@ -139,12 +140,12 @@ export function extractLeadingDoc(lines, lineNum, ext) {
139
140
  return text.length > 0 && text.length < 300 ? text : ""
140
141
  }
141
142
 
142
- /** 将控制权交还给事件循环一个 tick(让键盘输入有机会被处理) */
143
+ /** Yield control to the event loop for one tick (allows keyboard input to be processed) */
143
144
  export function yieldTick() {
144
145
  return new Promise((r) => setTimeout(r, 0))
145
146
  }
146
147
 
147
- /** 单文件入索引:删除旧块分块插入新块 */
148
+ /** Index a single file: delete old chunks chunkinsert new chunks */
148
149
  export function _upsertCodeFile(memory, origin, rel, lines, lang, mtimeMs) {
149
150
  const chunks = chunkCode(lines, rel)
150
151
  memory.db.exec("BEGIN")
@@ -166,8 +167,8 @@ export function _upsertCodeFile(memory, origin, rel, lines, lang, mtimeMs) {
166
167
  }
167
168
 
168
169
  /**
169
- * ## 标题切分 markdown 文件。每个 ## section 独立入索引,
170
- * 标题路径做 heading(如 "README.md > 部署 > Docker"),方便检索定位。
170
+ * Split a markdown file by ## headings. Each ## section is indexed independently,
171
+ * with the heading path as the heading label (e.g. "README.md > Deployment > Docker") for easy retrieval.
171
172
  */
172
173
  export function chunkMarkdown(lines, filepath) {
173
174
  const chunks = []
@@ -190,6 +191,7 @@ export function chunkMarkdown(lines, filepath) {
190
191
  return chunks.filter((c) => c.content)
191
192
  }
192
193
 
194
+ /** Upsert a documentation file's chunks into the doc_chunks table within a transaction */
193
195
  export function _upsertDocFile(memory, origin, rel, lines, mtimeMs) {
194
196
  const chunks = chunkMarkdown(lines, rel)
195
197
  const lang = rel.endsWith(".rst") ? "rst" : rel.endsWith(".adoc") ? "asciidoc" : rel.endsWith(".txt") ? "text" : "markdown"
@@ -1,18 +1,22 @@
1
1
  /**
2
- * memory/code-sync.mjs — 代码索引同步、检索、增量更新
2
+ * memory/code-sync.mjs — code index sync, retrieval, incremental update
3
3
  */
4
4
 
5
5
  import { readFile, stat } from "node:fs/promises"
6
6
  import { join, relative } from "node:path"
7
7
  import { embed, cosine, toBlob, fromBlob } from "../embedding.mjs"
8
8
  import { CODE_EXTS, DOC_EXTS, SKIP_DIRS } from "./schema.mjs"
9
- import { buildFtsQuery, ensureEmbeddings } from "./core.mjs"
9
+ import { buildFtsQuery, ensureEmbeddings, EMBED_TEXT_MAX_LEN } from "./core.mjs"
10
10
  import { detectLanguage, _upsertCodeFile, _upsertDocFile, yieldTick } from "./code-index.mjs"
11
11
 
12
+ const DIFF_FULL_SYNC_THRESHOLD = 200
13
+ const CODE_EMBED_BATCH = 64
14
+
12
15
  /**
13
- * git 驱动增量索引:用 git diff 找出上次索引以来的变更文件,
14
- * 只重建这些文件的 FTS5 块(不碰向量)。比全量 mtime 扫描快一个数量级。
15
- * 返回 { updated, removed, skipped } null(git 不可用)。
16
+ * git-driven incremental indexing: use git diff to find files changed since
17
+ * the last index, and only rebuild FTS5 chunks for those files (vectors are untouched).
18
+ * An order of magnitude faster than full mtime scanning.
19
+ * Returns { updated, removed, skipped } or null (git unavailable).
16
20
  */
17
21
  export async function gitSync(memory, dir, { onProgress } = {}) {
18
22
  const { execSync, execFileSync } = await import("node:child_process")
@@ -34,8 +38,8 @@ export async function gitSync(memory, dir, { onProgress } = {}) {
34
38
  return null
35
39
  }
36
40
 
37
- if (diffOut.length > 200) {
38
- // diff 太大,增量没意义——回退全量同步并更新锚点
41
+ if (diffOut.length > DIFF_FULL_SYNC_THRESHOLD) {
42
+ // diff too large, incremental is useless — fall back to full sync and update anchor
39
43
  await codeSync(memory, dir, { onProgress })
40
44
  const { docSync } = await import("./docs.mjs")
41
45
  await docSync(memory, dir, { onProgress })
@@ -60,11 +64,11 @@ export async function gitSync(memory, dir, { onProgress } = {}) {
60
64
  if (CODE_EXTS.has(ext)) {
61
65
  const lang = detectLanguage(abs)
62
66
  let mtimeMs = 0
63
- try { mtimeMs = Math.floor((await stat(abs)).mtimeMs) } catch { /* 新文件 */ }
67
+ try { mtimeMs = Math.floor((await stat(abs)).mtimeMs) } catch { /* new file */ }
64
68
  _upsertCodeFile(memory, dir, rel, lines, lang, mtimeMs)
65
69
  } else {
66
70
  let mtimeMs = 0
67
- try { mtimeMs = Math.floor((await stat(abs)).mtimeMs) } catch { /* 新文件 */ }
71
+ try { mtimeMs = Math.floor((await stat(abs)).mtimeMs) } catch { /* new file */ }
68
72
  _upsertDocFile(memory, dir, rel, lines, mtimeMs)
69
73
  }
70
74
  updated++
@@ -94,8 +98,8 @@ export async function gitSync(memory, dir, { onProgress } = {}) {
94
98
  }
95
99
 
96
100
  /**
97
- * 同步代码索引:扫描 dir 下所有源文件 分块 → upsert code_chunks
98
- * mtime 增量——只重建变更过的文件块。
101
+ * Sync code index: scan all source files under dir → chunk → upsert into code_chunks.
102
+ * Incremental by mtime — only rebuilds chunks for files that have changed.
99
103
  */
100
104
  export async function codeSync(memory, dir, { onProgress } = {}) {
101
105
  const files = []
@@ -165,19 +169,19 @@ export async function codeSync(memory, dir, { onProgress } = {}) {
165
169
  return { updated, removed, skipped, failed, errors, total: files.length }
166
170
  }
167
171
 
168
- /** 记录当前 HEAD 作为索引锚点(gitSync 增量 diff 基准);非 git 仓库静默跳过 */
172
+ /** Record current HEAD as the index anchor (gitSync incremental diff baseline); silently skip non-git repos */
169
173
  export async function markIndexedCommit(memory, dir) {
170
174
  try {
171
175
  const { execSync } = await import("node:child_process")
172
176
  const head = execSync("git rev-parse HEAD", { cwd: dir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5000 }).trim()
173
177
  memory.db.prepare(`INSERT INTO meta (key, value) VALUES ('last_indexed_commit', ?)
174
178
  ON CONFLICT (key) DO UPDATE SET value = excluded.value`).run(head)
175
- } catch { /* git 仓库或 git 不可用,跳过 */ }
179
+ } catch { /* not a git repo or git unavailable, skip */ }
176
180
  }
177
181
 
178
182
  /**
179
- * 代码检索:FTS5(BM25) + 可选向量余弦,RRF 合并。
180
- * embedder 时退化为纯 FTS;ftsQuery 为空且有 embedder 时退化为纯向量。
183
+ * Code search: FTS5(BM25) + optional vector cosine, RRF merged.
184
+ * Falls back to pure FTS when no embedder; falls back to pure vector when ftsQuery is empty and embedder is present.
181
185
  */
182
186
  export async function codeSearch(memory, query, { limit = 5 } = {}) {
183
187
  const ftsQuery = buildFtsQuery(query)
@@ -219,14 +223,20 @@ export async function codeSearch(memory, query, { limit = 5 } = {}) {
219
223
  const fetchChunk = memory.db.prepare(`
220
224
  SELECT path, language, symbol_name, content, line_start, line_end FROM code_chunks WHERE rowid = ?
221
225
  `)
222
- return [...scores.entries()]
226
+ const sorted = [...scores.entries()]
223
227
  .sort((a, b) => b[1] - a[1])
224
228
  .slice(0, limit)
225
- .map(([rowid]) => fetchChunk.get(rowid))
229
+ return sorted
230
+ .map(([rowid, score]) => {
231
+ const chunk = fetchChunk.get(rowid)
232
+ if (!chunk) return null
233
+ chunk._score = Math.round(score * 100) / 100
234
+ return chunk
235
+ })
226
236
  .filter(Boolean)
227
237
  }
228
238
 
229
- /** 惰性补算 code_chunks 缺失的向量 */
239
+ /** Lazily backfill missing vectors for code_chunks */
230
240
  export async function ensureCodeEmbeddings(memory) {
231
241
  if (!memory.embedder) return
232
242
  const modelKey = memory.embedder.model
@@ -237,17 +247,17 @@ export async function ensureCodeEmbeddings(memory) {
237
247
  ON CONFLICT (key) DO UPDATE SET value = excluded.value`).run(modelKey)
238
248
  }
239
249
 
240
- const pending = memory.db.prepare(`SELECT rowid, path, symbol_name, content FROM code_chunks WHERE embedding IS NULL LIMIT 64`).all()
250
+ const pending = memory.db.prepare(`SELECT rowid, path, symbol_name, content FROM code_chunks WHERE embedding IS NULL LIMIT ${CODE_EMBED_BATCH}`).all()
241
251
  if (pending.length === 0) return
242
252
 
243
- const texts = pending.map((r) => `${r.path}${r.symbol_name ? " :: " + r.symbol_name : ""}\n${r.content.slice(0, 2000)}`)
253
+ const texts = pending.map((r) => `${r.path}${r.symbol_name ? " :: " + r.symbol_name : ""}\n${r.content.slice(0, EMBED_TEXT_MAX_LEN)}`)
244
254
  const vecs = await embed(memory.embedder, texts)
245
255
 
246
256
  const update = memory.db.prepare(`UPDATE code_chunks SET embedding = ? WHERE rowid = ?`)
247
257
  pending.forEach((r, i) => update.run(toBlob(vecs[i]), r.rowid))
248
258
  }
249
259
 
250
- /** 生成 code_search 工具(只读)。 */
260
+ /** Generate the code_search tool (read-only). */
251
261
  export function codeSearchTool(memory) {
252
262
  return {
253
263
  name: "code_search",
@@ -266,14 +276,14 @@ export function codeSearchTool(memory) {
266
276
  const results = await codeSearch(memory, args.query, { limit: args.limit ?? 5 })
267
277
  if (results.length === 0) return "(no matching code)"
268
278
  return results.map((r) =>
269
- `${r.path}${r.symbol_name ? ` :: ${r.symbol_name}` : ""} (L${r.line_start}-L${r.line_end}):\n${r.content.slice(0, 2000)}`
279
+ `${r.path}${r.symbol_name ? ` :: ${r.symbol_name}` : ""} (L${r.line_start}-L${r.line_end}, relevance ${r._score?.toFixed(2) ?? "?"}):\n${r.content.slice(0, 2000)}`
270
280
  ).join("\n\n---\n\n")
271
281
  },
272
282
  }
273
283
  }
274
284
 
275
285
  /**
276
- * 单文件增量重索引:write/edit/delete 后调用,只重建这一条路径。
286
+ * Single-file incremental reindex: called after write/edit/delete, only rebuilds this one path.
277
287
  */
278
288
  export async function reindexFile(memory, cwd, absPath) {
279
289
  const ext = absPath.slice(absPath.lastIndexOf(".")).toLowerCase()
@@ -293,14 +303,14 @@ export async function reindexFile(memory, cwd, absPath) {
293
303
  if (CODE_EXTS.has(ext)) {
294
304
  const lang = detectLanguage(absPath)
295
305
  let mtimeMs = 0
296
- try { mtimeMs = Math.floor((await stat(absPath)).mtimeMs) } catch { /* 新文件 */ }
306
+ try { mtimeMs = Math.floor((await stat(absPath)).mtimeMs) } catch { /* new file */ }
297
307
  _upsertCodeFile(memory, cwd, rel, lines, lang, mtimeMs)
298
308
  } else if (DOC_EXTS.has(ext)) {
299
309
  let mtimeMs = 0
300
- try { mtimeMs = Math.floor((await stat(absPath)).mtimeMs) } catch { /* 新文件 */ }
310
+ try { mtimeMs = Math.floor((await stat(absPath)).mtimeMs) } catch { /* new file */ }
301
311
  _upsertDocFile(memory, cwd, rel, lines, mtimeMs)
302
312
  }
303
313
  if (memory.embedder) {
304
- try { await ensureEmbeddings(memory) } catch { /* embedding 失败不阻塞 */ }
314
+ try { await ensureEmbeddings(memory) } catch { /* embedding failure is non-blocking */ }
305
315
  }
306
316
  }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * memory/core.mjs — 记忆 CRUD、混合检索、embedding 管理
2
+ * memory/core.mjs — memory CRUD, hybrid retrieval, embedding management
3
3
  */
4
4
 
5
5
  import { parseEntry, serializeEntry, entryFilename } from "../markdown.mjs"
@@ -8,9 +8,14 @@ import { readFile, stat, readdir, writeFile, mkdir } from "node:fs/promises"
8
8
  import { join } from "node:path"
9
9
  import { segmentCJK, VALID_TYPES, SCHEMA_VERSION } from "./schema.mjs"
10
10
 
11
+ const EMBED_BATCH_SIZE = 256
12
+ export const EMBED_TEXT_MAX_LEN = 2000
13
+ const FTS_TOKEN_MAX = 16
14
+ const DEFAULT_LIST_LIMIT = 50
15
+
11
16
  /**
12
- * 写入一条记忆。entry: { type, title, content, tags? }
13
- * 返回新条目 id
17
+ * Write a memory entry. entry: { type, title, content, tags? }
18
+ * Returns the new entry id.
14
19
  */
15
20
  export async function put(memory, { type, title, content, tags = "" }) {
16
21
  if (!VALID_TYPES.has(type)) {
@@ -27,9 +32,9 @@ export async function put(memory, { type, title, content, tags = "" }) {
27
32
  }
28
33
 
29
34
  /**
30
- * 混合检索:FTS5(BM25) + 向量余弦,RRF(k=60) 合并排序。
31
- * embedder 时退化为纯 FTS。结果带 layer 标记。
32
- * 返回 [{ id, layer, type, title, content, tags, rank }]
35
+ * Hybrid retrieval: FTS5(BM25) + vector cosine, RRF(k=60) merged ranking.
36
+ * Falls back to pure FTS when no embedder. Results include layer label.
37
+ * Returns [{ id, layer, type, title, content, tags, rank }]
33
38
  */
34
39
  export async function search(memory, query, { limit = 5 } = {}) {
35
40
  const ftsQuery = buildFtsQuery(query)
@@ -37,7 +42,7 @@ export async function search(memory, query, { limit = 5 } = {}) {
37
42
 
38
43
  if (!memory.embedder) return ftsList.slice(0, limit)
39
44
 
40
- // ---- 向量通道 ----
45
+ // ---- vector channel ----
41
46
  try { await ensureEmbeddings(memory) } catch (e) {
42
47
  console.error(`[memory] embedding ensure failed, falling back to FTS-only: ${e.message}`)
43
48
  return ftsList.slice(0, limit)
@@ -59,7 +64,7 @@ export async function search(memory, query, { limit = 5 } = {}) {
59
64
  .sort((a, b) => b.score - a.score)
60
65
  .slice(0, Math.max(limit * 4, 20))
61
66
 
62
- // ---- RRF 合并 ----
67
+ // ---- RRF merge ----
63
68
  const K = 60
64
69
  const scores = new Map()
65
70
  ftsList.forEach((r, i) => scores.set(r.id, (scores.get(r.id) ?? 0) + 1 / (K + i + 1)))
@@ -75,7 +80,7 @@ export async function search(memory, query, { limit = 5 } = {}) {
75
80
  .filter(Boolean)
76
81
  }
77
82
 
78
- /** FTS 检索(两表合并,按 bm25 排序),RRF 的位置输入 */
83
+ /** Pure FTS search (two-table merge, sorted by bm25), used as the positional input for RRF */
79
84
  export function ftsSearch(memory, ftsQuery, limit) {
80
85
  const personal = memory.db.prepare(`
81
86
  SELECT e.id, e.type, e.title, e.content, e.tags, bm25(entries_fts) AS rank
@@ -96,23 +101,23 @@ export function ftsSearch(memory, ftsQuery, limit) {
96
101
  return [...personal, ...files].sort((a, b) => a.rank - b.rank).slice(0, limit)
97
102
  }
98
103
 
99
- /** 按统一 id 取完整条目(personal:<n> / project:<origin>:<path> / team:<origin>:<path>)
100
- * 注意:v9 files PK (layer, origin, path),同一 layer+path 可能跨多个 origin。
101
- * project 层优先返回 projectOrigin 命中的行,team 层取任意一行(跨团队仓库同名时取首条)。 */
104
+ /** Fetch a full entry by unified id (personal:<n> / project:<origin>:<path> / team:<origin>:<path>)
105
+ * Note: since v9 the files table PK is (layer, origin, path); the same layer+path may span multiple origins.
106
+ * For project layer, prefers the row matching projectOrigin; for team layer, returns any row (first match when multiple team repos share a path). */
102
107
  export function fetchEntry(memory, uid) {
103
108
  const [layer, ...rest] = uid.split(":")
104
109
  if (layer === "personal") {
105
110
  const r = memory.db.prepare(`SELECT id, type, title, content, tags FROM entries WHERE id = ?`).get(Number(rest[0]))
106
111
  return r ? { ...r, layer, id: uid } : null
107
112
  }
108
- // rest = [origin, ...pathParts]origin 可能为空字符串(旧格式兼容)
113
+ // rest = [origin, ...pathParts]; origin may be empty string (compat with old format)
109
114
  const origin = rest[0] ?? ""
110
115
  const path = rest.slice(1).join(":")
111
116
  if (layer === "project" && memory.projectOrigin) {
112
117
  const r = memory.db.prepare(`SELECT type, title, content, tags, author FROM files WHERE layer = ? AND origin = ? AND path = ?`).get(layer, origin || memory.projectOrigin, path)
113
118
  if (r) return { ...r, layer, id: uid }
114
119
  }
115
- // team 层或 project 兜底:按 origin+path 查,origin 为空时退化为只按 path(兼容旧 UID
120
+ // team layer or project fallback: query by origin+path; when origin is empty, degrade to path-only (compat with old UID)
116
121
  if (origin) {
117
122
  const r = memory.db.prepare(`SELECT type, title, content, tags, author FROM files WHERE layer = ? AND origin = ? AND path = ?`).get(layer, origin, path)
118
123
  if (r) return { ...r, layer, id: uid }
@@ -122,14 +127,14 @@ export function fetchEntry(memory, uid) {
122
127
  }
123
128
 
124
129
  /**
125
- * 惰性 embedding:把还没有向量的条目批量补算落库(首次慢、后续零成本)。
126
- * 检测到 embedding 模型变更时,清空全部向量重建。
130
+ * Lazy embedding: batch-compute vectors for entries that don't have them yet (slow first time, zero cost thereafter).
131
+ * When the embedding model changes, clear all vectors and rebuild.
127
132
  */
128
133
  export async function ensureEmbeddings(memory) {
129
134
  const modelKey = memory.embedder.model
130
135
  const stored = memory.db.prepare(`SELECT value FROM meta WHERE key = 'embedding_model'`).get()?.value
131
136
  if (stored !== modelKey) {
132
- // 统一失效三个表 + 三个 meta key,防维度不匹配的陈旧向量残留
137
+ // Invalidate all three tables + three meta keys in one go, to prevent stale vectors from dimension mismatch
133
138
  memory.db.prepare(`UPDATE entries SET embedding = NULL`).run()
134
139
  memory.db.prepare(`UPDATE files SET embedding = NULL`).run()
135
140
  memory.db.prepare(`UPDATE code_chunks SET embedding = NULL`).run()
@@ -140,17 +145,17 @@ export async function ensureEmbeddings(memory) {
140
145
  upsert.run("doc_embedding_model", modelKey)
141
146
  }
142
147
 
143
- const pendingEntries = memory.db.prepare(`SELECT id, title, content FROM entries WHERE embedding IS NULL LIMIT 256`).all()
144
- const pendingFiles = memory.db.prepare(`SELECT rowid, title, content FROM files WHERE embedding IS NULL LIMIT 256`).all()
148
+ const pendingEntries = memory.db.prepare(`SELECT id, title, content FROM entries WHERE embedding IS NULL LIMIT ${EMBED_BATCH_SIZE}`).all()
149
+ const pendingFiles = memory.db.prepare(`SELECT rowid, title, content FROM files WHERE embedding IS NULL LIMIT ${EMBED_BATCH_SIZE}`).all()
145
150
  if (pendingEntries.length + pendingFiles.length === 0) {
146
- // 记忆不算 pending,也补一下代码和文档块的向量
151
+ // No pending memory entries — also backfill code and doc chunk vectors
147
152
  await (await import("./code-sync.mjs")).ensureCodeEmbeddings(memory)
148
153
  await (await import("./docs.mjs")).ensureDocEmbeddings(memory)
149
154
  return
150
155
  }
151
156
 
152
157
  const items = [...pendingEntries, ...pendingFiles]
153
- const texts = items.map((r) => `${r.title}\n${r.content.slice(0, 2000)}`)
158
+ const texts = items.map((r) => `${r.title}\n${r.content.slice(0, EMBED_TEXT_MAX_LEN)}`)
154
159
  const vecs = await embed(memory.embedder, texts)
155
160
 
156
161
  const updateEntry = memory.db.prepare(`UPDATE entries SET embedding = ? WHERE id = ?`)
@@ -158,16 +163,16 @@ export async function ensureEmbeddings(memory) {
158
163
  const updateFile = memory.db.prepare(`UPDATE files SET embedding = ? WHERE rowid = ?`)
159
164
  pendingFiles.forEach((r, i) => updateFile.run(toBlob(vecs[pendingEntries.length + i]), r.rowid))
160
165
 
161
- // 每批嵌入后也补一下代码和文档块
166
+ // After each batch of embeddings, also backfill code and doc chunks
162
167
  await (await import("./code-sync.mjs")).ensureCodeEmbeddings(memory)
163
168
  await (await import("./docs.mjs")).ensureDocEmbeddings(memory)
164
169
  }
165
170
 
166
171
  /**
167
- * 写入一条 markdown 记忆到指定层目录(project/team),并即时索引。
168
- * 只写文件——project 层绝不替用户的项目仓库做 git 操作;
169
- * team 层的 commit+push gitmem.mjs 负责。
170
- * 返回文件名。
172
+ * Write a markdown memory entry to the specified layer directory (project/team) and index it immediately.
173
+ * Writes the file only — the project layer never performs git operations on the user's project repo;
174
+ * team layer commit+push is handled by gitmem.mjs.
175
+ * Returns the filename.
171
176
  */
172
177
  export async function putMarkdown(memory, { layer, dir, type, title, content, tags = [], author = "unknown" }) {
173
178
  if (layer !== "project" && layer !== "team") throw new Error(`invalid markdown layer: ${layer}`)
@@ -180,7 +185,8 @@ export async function putMarkdown(memory, { layer, dir, type, title, content, ta
180
185
  }
181
186
 
182
187
  /**
183
- * 同步一个 markdown 目录到索引:新增/变更(按 mtime)重建索引,消失的条目从索引删除。
188
+ * Sync a markdown directory to the index: new/changed (by mtime) entries are re-indexed,
189
+ * vanished entries are removed from the index.
184
190
  */
185
191
  export async function syncDir(memory, { layer, dir }) {
186
192
  let names = []
@@ -221,7 +227,7 @@ export async function syncDir(memory, { layer, dir }) {
221
227
  return { added, updated, removed, skipped }
222
228
  }
223
229
 
224
- /** 解析单个 .md upsert files */
230
+ /** Parse a single .md and upsert into the files table */
225
231
  export async function indexMarkdownFile(memory, { layer, dir, filename, mtimeMs }) {
226
232
  const abs = join(dir, filename)
227
233
  const mtime = mtimeMs ?? Math.floor((await stat(abs)).mtimeMs)
@@ -241,8 +247,8 @@ export async function indexMarkdownFile(memory, { layer, dir, filename, mtimeMs
241
247
  )
242
248
  }
243
249
 
244
- /** 列出新条目,可按 type 过滤 */
245
- export async function list(memory, { type, limit = 50 } = {}) {
250
+ /** List entries, optionally filtered by type */
251
+ export async function list(memory, { type, limit = DEFAULT_LIST_LIMIT } = {}) {
246
252
  if (type) {
247
253
  if (!VALID_TYPES.has(type)) throw new Error(`Invalid memory type "${type}"`)
248
254
  return memory.db
@@ -254,23 +260,24 @@ export async function list(memory, { type, limit = 50 } = {}) {
254
260
  .all(limit)
255
261
  }
256
262
 
257
- /** 删除一条记忆。返回是否删除成功 */
263
+ /** Delete a memory entry. Returns whether deletion succeeded */
258
264
  export async function remove(memory, id) {
259
265
  const info = memory.db.prepare(`DELETE FROM entries WHERE id = ?`).run(id)
260
266
  return info.changes > 0
261
267
  }
262
268
 
263
269
  /**
264
- * 构造 FTS5 查询:先按空白/标点切词,再对每个词做 CJK 分字。
265
- * 这样 CJK 多字词保持为 FTS5 短语("分号" "分 号" → 短语查询,精确匹配相邻字),
266
- * 而不同词之间用 OR 连接("命名 规范" → " " OR "规 范",两个短语各需相邻匹配)。
270
+ * Build an FTS5 query: first split by whitespace/punctuation into tokens,
271
+ * then apply CJK character segmentation to each token.
272
+ * This keeps multi-character CJK words as FTS5 phrases ("分号" → " " phrase query, exact adjacency match),
273
+ * while different tokens are joined with OR ("命名 规范" → "命 名" OR "规 范", each phrase requires its own adjacency).
267
274
  */
268
275
  export function buildFtsQuery(query) {
269
276
  const terms = query
270
277
  .split(/[\s,,。、;;!!??()()"`]+/)
271
278
  .map((t) => t.trim())
272
279
  .filter(Boolean)
273
- .slice(0, 16)
280
+ .slice(0, FTS_TOKEN_MAX)
274
281
  .map((t) => segmentCJK(t))
275
282
  if (terms.length === 0) return ""
276
283
  return terms.map((t) => `"${t.replaceAll('"', '""')}"`).join(" OR ")
@@ -1,5 +1,5 @@
1
1
  /**
2
- * memory/docs.mjs — 文档索引同步、检索、agent 工具生成
2
+ * memory/docs.mjs — doc index sync, retrieval, agent tool generation
3
3
  */
4
4
 
5
5
  import { readFile, readdir, stat } from "node:fs/promises"
@@ -11,9 +11,12 @@ import { buildFtsQuery, put, search, putMarkdown } from "./core.mjs"
11
11
  import { _upsertDocFile, yieldTick } from "./code-index.mjs"
12
12
  import { markIndexedCommit } from "./code-sync.mjs"
13
13
 
14
+ const DOC_EMBED_BATCH = 64
15
+ const EMBED_TEXT_MAX_LEN = 2000
16
+
14
17
  /**
15
- * 同步文档索引:扫描 dir 下所有 .md/.mdc/.txt/.rst/.adoc → 分块 → upsert doc_chunks
16
- * mtime 增量。
18
+ * Sync doc index: scan all .md/.mdc/.txt/.rst/.adoc under dir chunk → upsert into doc_chunks.
19
+ * Incremental by mtime.
17
20
  */
18
21
  export async function docSync(memory, dir, { onProgress } = {}) {
19
22
  const files = []
@@ -82,8 +85,8 @@ export async function docSync(memory, dir, { onProgress } = {}) {
82
85
  }
83
86
 
84
87
  /**
85
- * 文档检索:FTS5(BM25) + 可选向量余弦,RRF 合并。
86
- * embedder 时退化为纯 FTS;ftsQuery 为空且有 embedder 时退化为纯向量。
88
+ * Doc search: FTS5(BM25) + optional vector cosine, RRF merged.
89
+ * Falls back to pure FTS when no embedder; falls back to pure vector when ftsQuery is empty and embedder is present.
87
90
  */
88
91
  export async function docSearch(memory, query, { limit = 5 } = {}) {
89
92
  const ftsQuery = buildFtsQuery(query)
@@ -125,14 +128,20 @@ export async function docSearch(memory, query, { limit = 5 } = {}) {
125
128
  const fetchChunk = memory.db.prepare(`
126
129
  SELECT path, language, heading, content, line_start, line_end FROM doc_chunks WHERE rowid = ?
127
130
  `)
128
- return [...scores.entries()]
131
+ const sorted = [...scores.entries()]
129
132
  .sort((a, b) => b[1] - a[1])
130
133
  .slice(0, limit)
131
- .map(([rowid]) => fetchChunk.get(rowid))
134
+ return sorted
135
+ .map(([rowid, score]) => {
136
+ const chunk = fetchChunk.get(rowid)
137
+ if (!chunk) return null
138
+ chunk._score = Math.round(score * 100) / 100
139
+ return chunk
140
+ })
132
141
  .filter(Boolean)
133
142
  }
134
143
 
135
- /** 惰性补算 doc_chunks 缺失的向量 */
144
+ /** Lazily backfill missing vectors for doc_chunks */
136
145
  export async function ensureDocEmbeddings(memory) {
137
146
  if (!memory.embedder) return
138
147
  const modelKey = memory.embedder.model
@@ -143,17 +152,17 @@ export async function ensureDocEmbeddings(memory) {
143
152
  ON CONFLICT (key) DO UPDATE SET value = excluded.value`).run(modelKey)
144
153
  }
145
154
 
146
- const pending = memory.db.prepare(`SELECT rowid, path, heading, content FROM doc_chunks WHERE embedding IS NULL LIMIT 64`).all()
155
+ const pending = memory.db.prepare(`SELECT rowid, path, heading, content FROM doc_chunks WHERE embedding IS NULL LIMIT ${DOC_EMBED_BATCH}`).all()
147
156
  if (pending.length === 0) return
148
157
 
149
- const texts = pending.map((r) => `${r.heading || r.path}\n${r.content.slice(0, 2000)}`)
158
+ const texts = pending.map((r) => `${r.heading || r.path}\n${r.content.slice(0, EMBED_TEXT_MAX_LEN)}`)
150
159
  const vecs = await embed(memory.embedder, texts)
151
160
 
152
161
  const update = memory.db.prepare(`UPDATE doc_chunks SET embedding = ? WHERE rowid = ?`)
153
162
  pending.forEach((r, i) => update.run(toBlob(vecs[i]), r.rowid))
154
163
  }
155
164
 
156
- /** 生成 doc_search 工具(只读)。 */
165
+ /** Generate the doc_search tool (read-only). */
157
166
  export function docSearchTool(memory) {
158
167
  return {
159
168
  name: "doc_search",
@@ -172,17 +181,17 @@ export function docSearchTool(memory) {
172
181
  const results = await docSearch(memory, args.query, { limit: args.limit ?? 5 })
173
182
  if (results.length === 0) return "(no matching documentation)"
174
183
  return results.map((r) =>
175
- `${r.path}${r.heading ? ` > ${r.heading}` : ""} (L${r.line_start}-L${r.line_end}):\n${r.content.slice(0, 2000)}`
184
+ `${r.path}${r.heading ? ` > ${r.heading}` : ""} (L${r.line_start}-L${r.line_end}, relevance ${r._score?.toFixed(2) ?? "?"}):\n${r.content.slice(0, 2000)}`
176
185
  ).join("\n\n---\n\n")
177
186
  },
178
187
  }
179
188
  }
180
189
 
181
- // ---------------------------------------------------------------- agent 工具
190
+ // ---------------------------------------------------------------- agent tools
182
191
 
183
192
  /**
184
- * 生成记忆相关的两个 agent 工具(遵循 tools.mjs 的工具形状)。
185
- * memory_put 是有副作用工具(需权限确认),memory_search 只读。
193
+ * Generate the two memory-related agent tools (following the tools.mjs tool shape).
194
+ * memory_put is a side-effecting tool (needs permission confirmation), memory_search is read-only.
186
195
  * opts: { cwd, projectDir, author, team: { dir, name } | null }
187
196
  */
188
197
  export function memoryTools(memory, opts = {}) {