thincoder 0.3.0 → 0.5.0

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/memory.mjs CHANGED
@@ -19,7 +19,16 @@ import { embed, cosine, toBlob, fromBlob } from "./embedding.mjs"
19
19
  import { commitAndPush } from "./gitmem.mjs"
20
20
 
21
21
  const VALID_TYPES = new Set(["rule", "knowledge", "decision", "pattern"])
22
- const SCHEMA_VERSION = 5
22
+ const SCHEMA_VERSION = 7
23
+
24
+ // 代码索引:源码文件扩展名
25
+ const CODE_EXTS = new Set([".mjs", ".js", ".ts", ".tsx", ".jsx", ".py", ".rs", ".go", ".java", ".c", ".h", ".cpp", ".hpp", ".rb", ".swift", ".kt", ".sh", ".bash", ".sql", ".yaml", ".yml", ".toml", ".json", ".css", ".html", ".vue", ".svelte"])
26
+ // 文档索引:markdown / 纯文本(分开索引,便于 LLM 区分"设计规范"和"现存代码")
27
+ const DOC_EXTS = new Set([".md", ".mdc", ".txt", ".rst", ".adoc"])
28
+ // 总是跳过的目录名
29
+ const SKIP_DIRS = new Set(["node_modules", ".git", "dist", "build", ".turbo", "coverage", "__pycache__", ".venv", "venv", "target", ".next", ".nuxt", ".svelte-kit"])
30
+ // 大文件阈值(行数):超过此行数按符号分块,否则整文件入索引
31
+ const BIG_FILE_LINES = 2000
23
32
 
24
33
  /**
25
34
  * 打开/初始化记忆库。dbPath 不存在会自动创建。
@@ -166,6 +175,91 @@ function migrate(db) {
166
175
  }
167
176
  db.exec(`PRAGMA user_version = 5`)
168
177
  }
178
+
179
+ if (version < 6) {
180
+ // v6:代码索引——code_chunks 表 + FTS5(与 files 表同样模式)
181
+ db.exec(`
182
+ CREATE TABLE IF NOT EXISTS code_chunks (
183
+ path TEXT NOT NULL,
184
+ language TEXT NOT NULL,
185
+ chunk_type TEXT NOT NULL CHECK(chunk_type IN ('file','symbol')),
186
+ symbol_name TEXT NOT NULL DEFAULT '',
187
+ content TEXT NOT NULL,
188
+ line_start INTEGER NOT NULL DEFAULT 0,
189
+ line_end INTEGER NOT NULL DEFAULT 0,
190
+ mtime_ms INTEGER NOT NULL DEFAULT 0,
191
+ embedding BLOB,
192
+ seg_content TEXT NOT NULL DEFAULT '',
193
+ PRIMARY KEY (path, line_start)
194
+ )
195
+ `)
196
+ db.exec(`
197
+ CREATE VIRTUAL TABLE IF NOT EXISTS code_chunks_fts USING fts5(
198
+ path, symbol_name, seg_content,
199
+ content='code_chunks', content_rowid='rowid',
200
+ tokenize='unicode61'
201
+ )
202
+ `)
203
+ db.exec(`
204
+ CREATE TRIGGER code_chunks_ai AFTER INSERT ON code_chunks BEGIN
205
+ INSERT INTO code_chunks_fts(rowid, path, symbol_name, seg_content)
206
+ VALUES (new.rowid, new.path, new.symbol_name, new.seg_content);
207
+ END;
208
+ CREATE TRIGGER code_chunks_ad AFTER DELETE ON code_chunks BEGIN
209
+ INSERT INTO code_chunks_fts(code_chunks_fts, rowid, path, symbol_name, seg_content)
210
+ VALUES ('delete', old.rowid, old.path, old.symbol_name, old.seg_content);
211
+ END;
212
+ CREATE TRIGGER code_chunks_au AFTER UPDATE ON code_chunks BEGIN
213
+ INSERT INTO code_chunks_fts(code_chunks_fts, rowid, path, symbol_name, seg_content)
214
+ VALUES ('delete', old.rowid, old.path, old.symbol_name, old.seg_content);
215
+ INSERT INTO code_chunks_fts(rowid, path, symbol_name, seg_content)
216
+ VALUES (new.rowid, new.path, new.symbol_name, new.seg_content);
217
+ END;
218
+ `)
219
+ db.exec(`PRAGMA user_version = 6`)
220
+ }
221
+
222
+ if (version < 7) {
223
+ // v7:文档索引——doc_chunks 表 + FTS5(与 code_chunks 同模式),markdown 按 ## 标题分块
224
+ db.exec(`
225
+ CREATE TABLE IF NOT EXISTS doc_chunks (
226
+ path TEXT NOT NULL,
227
+ language TEXT NOT NULL DEFAULT 'markdown',
228
+ heading TEXT NOT NULL DEFAULT '',
229
+ content TEXT NOT NULL,
230
+ line_start INTEGER NOT NULL DEFAULT 0,
231
+ line_end INTEGER NOT NULL DEFAULT 0,
232
+ mtime_ms INTEGER NOT NULL DEFAULT 0,
233
+ embedding BLOB,
234
+ seg_content TEXT NOT NULL DEFAULT '',
235
+ PRIMARY KEY (path, line_start)
236
+ )
237
+ `)
238
+ db.exec(`
239
+ CREATE VIRTUAL TABLE IF NOT EXISTS doc_chunks_fts USING fts5(
240
+ path, heading, seg_content,
241
+ content='doc_chunks', content_rowid='rowid',
242
+ tokenize='unicode61'
243
+ )
244
+ `)
245
+ db.exec(`
246
+ CREATE TRIGGER doc_chunks_ai AFTER INSERT ON doc_chunks BEGIN
247
+ INSERT INTO doc_chunks_fts(rowid, path, heading, seg_content)
248
+ VALUES (new.rowid, new.path, new.heading, new.seg_content);
249
+ END;
250
+ CREATE TRIGGER doc_chunks_ad AFTER DELETE ON doc_chunks BEGIN
251
+ INSERT INTO doc_chunks_fts(doc_chunks_fts, rowid, path, heading, seg_content)
252
+ VALUES ('delete', old.rowid, old.path, old.heading, old.seg_content);
253
+ END;
254
+ CREATE TRIGGER doc_chunks_au AFTER UPDATE ON doc_chunks BEGIN
255
+ INSERT INTO doc_chunks_fts(doc_chunks_fts, rowid, path, heading, seg_content)
256
+ VALUES ('delete', old.rowid, old.path, old.heading, old.seg_content);
257
+ INSERT INTO doc_chunks_fts(rowid, path, heading, seg_content)
258
+ VALUES (new.rowid, new.path, new.heading, new.seg_content);
259
+ END;
260
+ `)
261
+ db.exec(`PRAGMA user_version = 7`)
262
+ }
169
263
  }
170
264
 
171
265
  /**
@@ -286,7 +380,12 @@ async function ensureEmbeddings(memory) {
286
380
 
287
381
  const pendingEntries = memory.db.prepare(`SELECT id, title, content FROM entries WHERE embedding IS NULL LIMIT 256`).all()
288
382
  const pendingFiles = memory.db.prepare(`SELECT rowid, title, content FROM files WHERE embedding IS NULL LIMIT 256`).all()
289
- if (pendingEntries.length + pendingFiles.length === 0) return
383
+ if (pendingEntries.length + pendingFiles.length === 0) {
384
+ // 记忆不算 pending,也补一下代码和文档块的向量
385
+ await ensureCodeEmbeddings(memory)
386
+ await ensureDocEmbeddings(memory)
387
+ return
388
+ }
290
389
 
291
390
  const items = [...pendingEntries, ...pendingFiles]
292
391
  const texts = items.map((r) => `${r.title}\n${r.content.slice(0, 2000)}`)
@@ -296,6 +395,10 @@ async function ensureEmbeddings(memory) {
296
395
  pendingEntries.forEach((r, i) => updateEntry.run(toBlob(vecs[i]), r.id))
297
396
  const updateFile = memory.db.prepare(`UPDATE files SET embedding = ? WHERE rowid = ?`)
298
397
  pendingFiles.forEach((r, i) => updateFile.run(toBlob(vecs[pendingEntries.length + i]), r.rowid))
398
+
399
+ // 每批嵌入后也补一下代码和文档块
400
+ await ensureCodeEmbeddings(memory)
401
+ await ensureDocEmbeddings(memory)
299
402
  }
300
403
 
301
404
  /**
@@ -402,7 +505,7 @@ export async function remove(memory, id) {
402
505
  */
403
506
  function buildFtsQuery(query) {
404
507
  const terms = segmentCJK(query)
405
- .split(/[\s,,。、;;!!??()()"'`]+/)
508
+ .split(/[\s,,。、;;!!??()()"`]+/)
406
509
  .map((t) => t.trim())
407
510
  .filter(Boolean)
408
511
  .slice(0, 16)
@@ -410,6 +513,577 @@ function buildFtsQuery(query) {
410
513
  return terms.map((t) => `"${t.replaceAll('"', '""')}"`).join(" OR ")
411
514
  }
412
515
 
516
+ // ========== 代码索引 ==========
517
+
518
+ /** 推断文件语言(按扩展名) */
519
+ function detectLanguage(filename) {
520
+ const ext = filename.slice(filename.lastIndexOf(".")).toLowerCase()
521
+ const map = {
522
+ ".mjs": "javascript", ".js": "javascript", ".jsx": "jsx", ".ts": "typescript", ".tsx": "tsx",
523
+ ".py": "python", ".rs": "rust", ".go": "go", ".java": "java",
524
+ ".c": "c", ".h": "c", ".cpp": "cpp", ".hpp": "cpp",
525
+ ".rb": "ruby", ".swift": "swift", ".kt": "kotlin",
526
+ ".sh": "bash", ".bash": "bash", ".sql": "sql",
527
+ ".yaml": "yaml", ".yml": "yaml", ".toml": "toml", ".json": "json",
528
+ ".css": "css", ".html": "html", ".vue": "vue", ".svelte": "svelte",
529
+ ".md": "markdown", ".mdc": "markdown",
530
+ }
531
+ return map[ext] ?? ext.slice(1)
532
+ }
533
+
534
+ /**
535
+ * 用正则提取 JS/TS 文件的顶层符号声明(函数、类、const 导出等)。
536
+ * 返回 [{ name, line, kind }]。
537
+ * 不解析 AST——够识别 "在哪里定义了什么" 就够用。
538
+ */
539
+ function extractSymbols(lines, ext) {
540
+ const jsish = new Set([".mjs", ".js", ".ts", ".jsx", ".tsx"])
541
+ if (!jsish.has(ext)) return []
542
+
543
+ const symbols = []
544
+ const text = lines.join("\n")
545
+ // 顶层 export/函数/类(不在注释内的简化匹配)
546
+ const re = /(?:export\s+)?(?:(?:async\s+)?function\s+(\w+)|class\s+(\w+)|(?:export\s+)?(?:const|let|var)\s+(\w+))/gm
547
+ let m
548
+ while ((m = re.exec(text))) {
549
+ const name = m[1] || m[2] || m[3]
550
+ if (!name || name[0] !== name[0].toLowerCase() && name.length < 2) continue // 跳过全大写常量(噪声)
551
+ const line = text.slice(0, m.index).split("\n").length
552
+ symbols.push({ name, line, kind: m[1] ? "function" : m[2] ? "class" : "variable" })
553
+ }
554
+ return symbols
555
+ }
556
+
557
+ /**
558
+ * 提取 Python 文件的顶层 def/class。
559
+ */
560
+ function extractPySymbols(lines) {
561
+ const symbols = []
562
+ const re = /^(?:async\s+)?(?:def|class)\s+(\w+)/gm
563
+ const text = lines.join("\n")
564
+ let m
565
+ while ((m = re.exec(text))) {
566
+ symbols.push({ name: m[1], line: text.slice(0, m.index).split("\n").length, kind: text[m.index] === "c" ? "class" : "function" })
567
+ }
568
+ return symbols
569
+ }
570
+
571
+ /**
572
+ * 将一个文件拆成代码块。小文件整文件一块;大文件按符号切分,符号间的内容并入前一个符号块。
573
+ * 每个块会额外带上符号前的 JSDoc / docstring 注释,提升搜索质量。
574
+ */
575
+ function chunkCode(lines, filepath) {
576
+ const ext = filepath.slice(filepath.lastIndexOf(".")).toLowerCase()
577
+ const chunks = []
578
+
579
+ if (lines.length <= BIG_FILE_LINES) {
580
+ // 小文件:整文件一块;提取文件头注释作为前缀
581
+ const doc = extractLeadingDoc(lines, 1, ext)
582
+ const content = (doc ? doc + "\n" : "") + lines.join("\n").trimEnd()
583
+ chunks.push({ name: filepath, line_start: 1, line_end: lines.length, content })
584
+ return chunks
585
+ }
586
+
587
+ // 大文件:按符号切分
588
+ const symbols = ext === ".py" ? extractPySymbols(lines) : extractSymbols(lines, ext)
589
+ if (symbols.length <= 1) {
590
+ const doc = extractLeadingDoc(lines, 1, ext)
591
+ const content = (doc ? doc + "\n" : "") + lines.join("\n").trimEnd()
592
+ chunks.push({ name: filepath, line_start: 1, line_end: lines.length, content })
593
+ return chunks
594
+ }
595
+
596
+ // 符号间切分:每个符号从自己的行开始到下一个符号前一行结束
597
+ for (let i = 0; i < symbols.length; i++) {
598
+ const sym = symbols[i]
599
+ const start = sym.line
600
+ const end = i + 1 < symbols.length ? symbols[i + 1].line - 1 : lines.length
601
+ if (start > end) continue
602
+ const doc = extractLeadingDoc(lines, start, ext)
603
+ const body = lines.slice(start - 1, end).join("\n").trimEnd()
604
+ const content = (doc ? doc + "\n" : "") + body
605
+ if (!content) continue
606
+ chunks.push({ name: `${filepath}:${sym.name}`, line_start: start, line_end: end, content })
607
+ }
608
+ return chunks
609
+ }
610
+
611
+ /**
612
+ * 提取指定行之前的 JSDoc / docstring 注释。
613
+ * JS/TS: 向前扫描 /** ... *​/ 或 // 连续注释行
614
+ * Python: 符号定义行的下一行开始找 """...""" docstring
615
+ * 没有则返回空字符串。
616
+ */
617
+ function extractLeadingDoc(lines, lineNum, ext) {
618
+ if (ext === ".py") {
619
+ // Python: docstring 在 def/class 的下一行
620
+ if (lineNum >= lines.length) return ""
621
+ const next = lines[lineNum] // lineNum 是 1-based,下一行 index = lineNum
622
+ const m = next?.match(/^\s*"""(.+?)"""\s*$/)
623
+ if (m) return m[1].trim()
624
+ // 多行 docstring
625
+ if (/^\s*"""\s*$/.test(next)) {
626
+ const parts = []
627
+ for (let i = lineNum + 1; i < lines.length && i < lineNum + 8; i++) {
628
+ if (/^\s*"""\s*$/.test(lines[i])) break
629
+ parts.push(lines[i].trim())
630
+ }
631
+ const text = parts.join(" ").trim()
632
+ return text.length > 0 && text.length < 300 ? text : ""
633
+ }
634
+ return ""
635
+ }
636
+
637
+ // JS/TS: 向前扫描 /** ... *​/ 或连续 // 行
638
+ const jsish = new Set([".mjs", ".js", ".ts", ".jsx", ".tsx"])
639
+ if (!jsish.has(ext)) return ""
640
+
641
+ const parts = []
642
+ let i = lineNum - 2 // lineNum 是 1-based,前一行 index = lineNum-2
643
+ // 先看紧邻的 JSDoc 块
644
+ if (i >= 0 && /^\s*\*\/\s*$/.test(lines[i])) {
645
+ // 找到 JSDoc 结尾,反向找开头
646
+ while (i >= 0) {
647
+ const line = lines[i].trim()
648
+ if (/^\s*\/\*\*/.test(line)) {
649
+ parts.unshift(line.replace(/^\s*\/\*\*\s*/, "").replace(/\s*\*\/\s*$/, "").trim())
650
+ break
651
+ }
652
+ parts.unshift(line.replace(/^\s*\*\s?/, "").trim())
653
+ i--
654
+ }
655
+ } else {
656
+ // 收集连续 // 注释行
657
+ while (i >= 0 && /^\s*\/\//.test(lines[i])) {
658
+ parts.unshift(lines[i].replace(/^\s*\/\/\s*/, "").trim())
659
+ i--
660
+ }
661
+ }
662
+
663
+ const text = parts.join(" ").trim()
664
+ return text.length > 0 && text.length < 300 ? text : ""
665
+ }
666
+
667
+ /**
668
+ * 同步代码索引:扫描 dir 下所有源文件 → 分块 → upsert 到 code_chunks。
669
+ * 按 mtime 增量——只重建变更过的文件块。
670
+ * onProgress({ phase, current, total }) 可选回调,用于 UI 进度展示。
671
+ */
672
+ export async function codeSync(memory, dir, { onProgress } = {}) {
673
+ // 收集所有源文件
674
+ const files = []
675
+ async function walk(d) {
676
+ let entries
677
+ try { entries = await readdir(d, { withFileTypes: true }) } catch { return }
678
+ for (const e of entries) {
679
+ if (e.isDirectory()) {
680
+ if (SKIP_DIRS.has(e.name) || e.name.startsWith(".")) continue
681
+ await walk(join(d, e.name))
682
+ } else if (e.isFile()) {
683
+ const ext = e.name.slice(e.name.lastIndexOf(".")).toLowerCase()
684
+ if (CODE_EXTS.has(ext)) files.push(join(d, e.name))
685
+ }
686
+ }
687
+ }
688
+ await walk(dir)
689
+
690
+ // 取已索引文件的 mtime 快照
691
+ const indexed = new Map(
692
+ memory.db.prepare(`SELECT path, mtime_ms FROM code_chunks`).all().map((r) => [r.path, r.mtime_ms])
693
+ )
694
+ const seen = new Set()
695
+
696
+ onProgress?.({ phase: "scan", total: files.length })
697
+
698
+ let updated = 0, removed = 0, skipped = 0
699
+ for (let i = 0; i < files.length; i++) {
700
+ const abs = files[i]
701
+ const rel = abs.slice(dir.length + 1).replaceAll("\\", "/")
702
+ seen.add(rel)
703
+
704
+ let mtimeMs
705
+ try { mtimeMs = Math.floor((await stat(abs)).mtimeMs) } catch { continue }
706
+ if (indexed.get(rel) === mtimeMs) {
707
+ skipped++
708
+ continue
709
+ }
710
+
711
+ // 读文件并分块
712
+ let text
713
+ try { text = await readFile(abs, "utf8") } catch { continue }
714
+ const lines = text.split("\n")
715
+ const lang = detectLanguage(abs)
716
+ const chunks = chunkCode(lines, rel)
717
+
718
+ // 删除该文件的旧块,插入新块
719
+ memory.db.prepare(`DELETE FROM code_chunks WHERE path = ?`).run(rel)
720
+ const insert = memory.db.prepare(`
721
+ INSERT INTO code_chunks (path, language, chunk_type, symbol_name, content, line_start, line_end, mtime_ms, seg_content)
722
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
723
+ `)
724
+ for (const c of chunks) {
725
+ const isFile = c.name === rel
726
+ insert.run(
727
+ rel,
728
+ lang,
729
+ isFile ? "file" : "symbol",
730
+ isFile ? "" : c.name.slice(rel.length + 1), // 去掉 "filepath:" 前缀
731
+ c.content,
732
+ c.line_start,
733
+ c.line_end,
734
+ mtimeMs,
735
+ segmentCJK(c.content),
736
+ )
737
+ }
738
+ updated++
739
+
740
+ if (onProgress && i % 10 === 0) {
741
+ onProgress({ phase: "index", current: i + 1, total: files.length, updated, removed, skipped })
742
+ }
743
+ }
744
+
745
+ // 清理磁盘上已消失的文件块
746
+ for (const stale of indexed.keys()) {
747
+ if (!seen.has(stale)) {
748
+ memory.db.prepare(`DELETE FROM code_chunks WHERE path = ?`).run(stale)
749
+ removed++
750
+ }
751
+ }
752
+
753
+ onProgress?.({ phase: "done", total: files.length, updated, removed, skipped })
754
+ return { updated, removed, skipped, total: files.length }
755
+ }
756
+
757
+ /**
758
+ * 代码检索:FTS5(BM25) + 可选向量余弦,RRF 合并。
759
+ * 无 embedder 时退化为纯 FTS。
760
+ * 返回 [{ path, language, symbol_name, content, line_start, line_end, rank }]
761
+ */
762
+ export async function codeSearch(memory, query, { limit = 5 } = {}) {
763
+ const ftsQuery = buildFtsQuery(query)
764
+ if (!ftsQuery) return []
765
+
766
+ const ftsList = memory.db.prepare(`
767
+ SELECT c.path, c.language, c.symbol_name, c.content, c.line_start, c.line_end, bm25(code_chunks_fts) AS rank
768
+ FROM code_chunks_fts JOIN code_chunks c ON c.rowid = code_chunks_fts.rowid
769
+ WHERE code_chunks_fts MATCH ?
770
+ ORDER BY rank LIMIT ?
771
+ `).all(ftsQuery, Math.max(limit * 4, 20))
772
+
773
+ if (!memory.embedder) return ftsList.slice(0, limit)
774
+
775
+ // 向量通道
776
+ await ensureEmbeddings(memory)
777
+ const [qvec] = await embed(memory.embedder, [query])
778
+ const rows = memory.db.prepare(`SELECT path, line_start, embedding FROM code_chunks WHERE embedding IS NOT NULL`).all()
779
+ const vecList = rows
780
+ .map((r) => ({ key: `${r.path}:${r.line_start}`, score: cosine(qvec, fromBlob(r.embedding)) }))
781
+ .sort((a, b) => b.score - a.score)
782
+ .slice(0, Math.max(limit * 4, 20))
783
+
784
+ // RRF 合并
785
+ const K = 60
786
+ const scores = new Map()
787
+ ftsList.forEach((r, i) => scores.set(`${r.path}:${r.line_start}`, (scores.get(`${r.path}:${r.line_start}`) ?? 0) + 1 / (K + i + 1)))
788
+ vecList.forEach((r, i) => scores.set(r.key, (scores.get(r.key) ?? 0) + 1 / (K + i + 1)))
789
+
790
+ return [...scores.entries()]
791
+ .sort((a, b) => b[1] - a[1])
792
+ .slice(0, limit)
793
+ .map(([key]) => {
794
+ const [path, lineStr] = key.split(/:(?=\d+$)/)
795
+ return ftsList.find((r) => r.path === path && String(r.line_start) === lineStr)
796
+ })
797
+ .filter(Boolean)
798
+ }
799
+
800
+ /** 惰性补算 code_chunks 缺失的向量 */
801
+ async function ensureCodeEmbeddings(memory) {
802
+ if (!memory.embedder) return
803
+ const modelKey = memory.embedder.model
804
+ const stored = memory.db.prepare(`SELECT value FROM meta WHERE key = 'code_embedding_model'`).get()?.value
805
+ if (stored !== modelKey) {
806
+ memory.db.prepare(`UPDATE code_chunks SET embedding = NULL`).run()
807
+ memory.db.prepare(`INSERT INTO meta (key, value) VALUES ('code_embedding_model', ?)
808
+ ON CONFLICT (key) DO UPDATE SET value = excluded.value`).run(modelKey)
809
+ }
810
+
811
+ const pending = memory.db.prepare(`SELECT rowid, path, symbol_name, content FROM code_chunks WHERE embedding IS NULL LIMIT 64`).all()
812
+ if (pending.length === 0) return
813
+
814
+ const texts = pending.map((r) => `${r.path}${r.symbol_name ? " :: " + r.symbol_name : ""}\n${r.content.slice(0, 2000)}`)
815
+ const vecs = await embed(memory.embedder, texts)
816
+
817
+ const update = memory.db.prepare(`UPDATE code_chunks SET embedding = ? WHERE rowid = ?`)
818
+ pending.forEach((r, i) => update.run(toBlob(vecs[i]), r.rowid))
819
+ }
820
+
821
+ /**
822
+ * 生成 code_search 工具(只读,与 memory_search 同模式)。
823
+ */
824
+ export function codeSearchTool(memory) {
825
+ return {
826
+ name: "code_search",
827
+ description:
828
+ "Search the project's source code for relevant code. Use this to find functions, classes, or code patterns across the codebase. Supports natural language queries and code snippets. Returns matching code chunks with file paths and line numbers.",
829
+ parameters: {
830
+ type: "object",
831
+ properties: {
832
+ query: { type: "string", description: "Natural language or code snippet to search for" },
833
+ limit: { type: "number", description: "Max results (default 5)" },
834
+ },
835
+ required: ["query"],
836
+ },
837
+ readonly: true,
838
+ async execute(args) {
839
+ const results = await codeSearch(memory, args.query, { limit: args.limit ?? 5 })
840
+ if (results.length === 0) return "(no matching code)"
841
+ return results.map((r) =>
842
+ `${r.path}${r.symbol_name ? ` :: ${r.symbol_name}` : ""} (L${r.line_start}-L${r.line_end}):\n${r.content.slice(0, 2000)}`
843
+ ).join("\n\n---\n\n")
844
+ },
845
+ }
846
+ }
847
+
848
+ /**
849
+ * 单文件增量重索引:write/edit/delete 后调用,只重建这一条路径。
850
+ * 不影响其他文件(比全量 codeSync/docSync 快几个数量级)。
851
+ */
852
+ export async function reindexFile(memory, cwd, absPath) {
853
+ const ext = absPath.slice(absPath.lastIndexOf(".")).toLowerCase()
854
+ const rel = absPath.slice(cwd.length + 1).replaceAll("\\", "/")
855
+
856
+ if (CODE_EXTS.has(ext)) {
857
+ // 文件已删?清理索引
858
+ let text
859
+ try { text = await readFile(absPath, "utf8") } catch {
860
+ memory.db.prepare(`DELETE FROM code_chunks WHERE path = ?`).run(rel)
861
+ return
862
+ }
863
+ const lines = text.split("\n")
864
+ const lang = detectLanguage(absPath)
865
+ const chunks = chunkCode(lines, rel)
866
+ memory.db.prepare(`DELETE FROM code_chunks WHERE path = ?`).run(rel)
867
+ const insert = memory.db.prepare(`
868
+ INSERT INTO code_chunks (path, language, chunk_type, symbol_name, content, line_start, line_end, mtime_ms, seg_content)
869
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
870
+ `)
871
+ let mtimeMs = 0
872
+ try { mtimeMs = Math.floor((await stat(absPath)).mtimeMs) } catch { /* 新文件 */ }
873
+ for (const c of chunks) {
874
+ const isFile = c.name === rel
875
+ insert.run(rel, lang, isFile ? "file" : "symbol", isFile ? "" : c.name.slice(rel.length + 1), c.content, c.line_start, c.line_end, mtimeMs, segmentCJK(c.content))
876
+ }
877
+ } else if (DOC_EXTS.has(ext)) {
878
+ let text
879
+ try { text = await readFile(absPath, "utf8") } catch {
880
+ memory.db.prepare(`DELETE FROM doc_chunks WHERE path = ?`).run(rel)
881
+ return
882
+ }
883
+ const lines = text.split("\n")
884
+ const chunks = chunkMarkdown(lines, rel)
885
+ memory.db.prepare(`DELETE FROM doc_chunks WHERE path = ?`).run(rel)
886
+ const insert = memory.db.prepare(`
887
+ INSERT INTO doc_chunks (path, language, heading, content, line_start, line_end, mtime_ms, seg_content)
888
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
889
+ `)
890
+ const lang = rel.endsWith(".rst") ? "rst" : rel.endsWith(".adoc") ? "asciidoc" : rel.endsWith(".txt") ? "text" : "markdown"
891
+ let mtimeMs = 0
892
+ try { mtimeMs = Math.floor((await stat(absPath)).mtimeMs) } catch { /* 新文件 */ }
893
+ for (const c of chunks) {
894
+ insert.run(rel, lang, c.heading, c.content, c.line_start, c.line_end, mtimeMs, segmentCJK(c.content))
895
+ }
896
+ }
897
+ }
898
+
899
+ // ========== 文档索引 ==========
900
+
901
+ /**
902
+ * 按 ## 标题切分 markdown 文件。每个 ## section 独立入索引,
903
+ * 标题路径做 heading(如 "README.md > 部署 > Docker"),方便检索定位。
904
+ */
905
+ function chunkMarkdown(lines, filepath) {
906
+ const chunks = []
907
+ let start = 1
908
+ let heading = filepath
909
+
910
+ for (let i = 0; i < lines.length; i++) {
911
+ const m = lines[i].match(/^(#{1,4})\s+(.+)/)
912
+ if (m) {
913
+ if (i > start) {
914
+ chunks.push({ heading, line_start: start, line_end: i, content: lines.slice(start - 1, i).join("\n").trimEnd() })
915
+ }
916
+ heading = `${filepath} > ${m[2].trim()}`
917
+ start = i + 1
918
+ }
919
+ }
920
+ // tail
921
+ if (start <= lines.length) {
922
+ chunks.push({ heading, line_start: start, line_end: lines.length, content: lines.slice(start - 1).join("\n").trimEnd() })
923
+ }
924
+ return chunks.filter((c) => c.content)
925
+ }
926
+
927
+ /**
928
+ * 同步文档索引:扫描 dir 下所有 .md/.mdc/.txt/.rst/.adoc → 分块 → upsert 到 doc_chunks。
929
+ * 按 mtime 增量。
930
+ */
931
+ export async function docSync(memory, dir, { onProgress } = {}) {
932
+ const files = []
933
+ async function walk(d) {
934
+ let entries
935
+ try { entries = await readdir(d, { withFileTypes: true }) } catch { return }
936
+ for (const e of entries) {
937
+ if (e.isDirectory()) {
938
+ if (SKIP_DIRS.has(e.name) || e.name.startsWith(".")) continue
939
+ await walk(join(d, e.name))
940
+ } else if (e.isFile()) {
941
+ const ext = e.name.slice(e.name.lastIndexOf(".")).toLowerCase()
942
+ if (DOC_EXTS.has(ext)) files.push(join(d, e.name))
943
+ }
944
+ }
945
+ }
946
+ await walk(dir)
947
+
948
+ const indexed = new Map(
949
+ memory.db.prepare(`SELECT path, mtime_ms FROM doc_chunks`).all().map((r) => [r.path, r.mtime_ms])
950
+ )
951
+ const seen = new Set()
952
+
953
+ onProgress?.({ phase: "scan", total: files.length })
954
+
955
+ let updated = 0, removed = 0, skipped = 0
956
+ for (let i = 0; i < files.length; i++) {
957
+ const abs = files[i]
958
+ const rel = abs.slice(dir.length + 1).replaceAll("\\", "/")
959
+ seen.add(rel)
960
+
961
+ let mtimeMs
962
+ try { mtimeMs = Math.floor((await stat(abs)).mtimeMs) } catch { continue }
963
+ if (indexed.get(rel) === mtimeMs) {
964
+ skipped++
965
+ continue
966
+ }
967
+
968
+ let text
969
+ try { text = await readFile(abs, "utf8") } catch { continue }
970
+ const lines = text.split("\n")
971
+ const chunks = chunkMarkdown(lines, rel)
972
+
973
+ memory.db.prepare(`DELETE FROM doc_chunks WHERE path = ?`).run(rel)
974
+ const insert = memory.db.prepare(`
975
+ INSERT INTO doc_chunks (path, language, heading, content, line_start, line_end, mtime_ms, seg_content)
976
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
977
+ `)
978
+ const lang = rel.endsWith(".rst") ? "rst" : rel.endsWith(".adoc") ? "asciidoc" : rel.endsWith(".txt") ? "text" : "markdown"
979
+ for (const c of chunks) {
980
+ insert.run(rel, lang, c.heading, c.content, c.line_start, c.line_end, mtimeMs, segmentCJK(c.content))
981
+ }
982
+ updated++
983
+
984
+ if (onProgress && i % 10 === 0) {
985
+ onProgress({ phase: "index", current: i + 1, total: files.length, updated, removed, skipped })
986
+ }
987
+ }
988
+
989
+ // 清理磁盘上消失的文件
990
+ for (const stale of indexed.keys()) {
991
+ if (!seen.has(stale)) {
992
+ memory.db.prepare(`DELETE FROM doc_chunks WHERE path = ?`).run(stale)
993
+ removed++
994
+ }
995
+ }
996
+
997
+ onProgress?.({ phase: "done", total: files.length, updated, removed, skipped })
998
+ return { updated, removed, skipped, total: files.length }
999
+ }
1000
+
1001
+ /**
1002
+ * 文档检索:FTS5(BM25) + 可选向量余弦,RRF 合并。
1003
+ */
1004
+ export async function docSearch(memory, query, { limit = 5 } = {}) {
1005
+ const ftsQuery = buildFtsQuery(query)
1006
+ if (!ftsQuery) return []
1007
+
1008
+ const ftsList = memory.db.prepare(`
1009
+ SELECT d.path, d.language, d.heading, d.content, d.line_start, d.line_end, bm25(doc_chunks_fts) AS rank
1010
+ FROM doc_chunks_fts JOIN doc_chunks d ON d.rowid = doc_chunks_fts.rowid
1011
+ WHERE doc_chunks_fts MATCH ?
1012
+ ORDER BY rank LIMIT ?
1013
+ `).all(ftsQuery, Math.max(limit * 4, 20))
1014
+
1015
+ if (!memory.embedder) return ftsList.slice(0, limit)
1016
+
1017
+ const [qvec] = await embed(memory.embedder, [query])
1018
+ const rows = memory.db.prepare(`SELECT path, line_start, embedding FROM doc_chunks WHERE embedding IS NOT NULL`).all()
1019
+ const vecList = rows
1020
+ .map((r) => ({ key: `${r.path}:${r.line_start}`, score: cosine(qvec, fromBlob(r.embedding)) }))
1021
+ .sort((a, b) => b.score - a.score)
1022
+ .slice(0, Math.max(limit * 4, 20))
1023
+
1024
+ const K = 60
1025
+ const scores = new Map()
1026
+ ftsList.forEach((r, i) => scores.set(`${r.path}:${r.line_start}`, (scores.get(`${r.path}:${r.line_start}`) ?? 0) + 1 / (K + i + 1)))
1027
+ vecList.forEach((r, i) => scores.set(r.key, (scores.get(r.key) ?? 0) + 1 / (K + i + 1)))
1028
+
1029
+ return [...scores.entries()]
1030
+ .sort((a, b) => b[1] - a[1])
1031
+ .slice(0, limit)
1032
+ .map(([key]) => {
1033
+ const [path, lineStr] = key.split(/:(?=\d+$)/)
1034
+ return ftsList.find((r) => r.path === path && String(r.line_start) === lineStr)
1035
+ })
1036
+ .filter(Boolean)
1037
+ }
1038
+
1039
+ /** 惰性补算 doc_chunks 缺失的向量 */
1040
+ async function ensureDocEmbeddings(memory) {
1041
+ if (!memory.embedder) return
1042
+ const modelKey = memory.embedder.model
1043
+ const stored = memory.db.prepare(`SELECT value FROM meta WHERE key = 'doc_embedding_model'`).get()?.value
1044
+ if (stored !== modelKey) {
1045
+ memory.db.prepare(`UPDATE doc_chunks SET embedding = NULL`).run()
1046
+ memory.db.prepare(`INSERT INTO meta (key, value) VALUES ('doc_embedding_model', ?)
1047
+ ON CONFLICT (key) DO UPDATE SET value = excluded.value`).run(modelKey)
1048
+ }
1049
+
1050
+ const pending = memory.db.prepare(`SELECT rowid, path, heading, content FROM doc_chunks WHERE embedding IS NULL LIMIT 64`).all()
1051
+ if (pending.length === 0) return
1052
+
1053
+ const texts = pending.map((r) => `${r.heading || r.path}\n${r.content.slice(0, 2000)}`)
1054
+ const vecs = await embed(memory.embedder, texts)
1055
+
1056
+ const update = memory.db.prepare(`UPDATE doc_chunks SET embedding = ? WHERE rowid = ?`)
1057
+ pending.forEach((r, i) => update.run(toBlob(vecs[i]), r.rowid))
1058
+ }
1059
+
1060
+ /**
1061
+ * 生成 doc_search 工具(只读)。
1062
+ */
1063
+ export function docSearchTool(memory) {
1064
+ return {
1065
+ name: "doc_search",
1066
+ description:
1067
+ "Search the project's documentation (README, design docs, guides, markdown files) for relevant information. Use this to find design decisions, coding conventions, architecture docs, or project rules. Prefer this over code_search when you need to understand the project's intended design rather than existing implementation.",
1068
+ parameters: {
1069
+ type: "object",
1070
+ properties: {
1071
+ query: { type: "string", description: "Natural language search query" },
1072
+ limit: { type: "number", description: "Max results (default 5)" },
1073
+ },
1074
+ required: ["query"],
1075
+ },
1076
+ readonly: true,
1077
+ async execute(args) {
1078
+ const results = await docSearch(memory, args.query, { limit: args.limit ?? 5 })
1079
+ if (results.length === 0) return "(no matching documentation)"
1080
+ return results.map((r) =>
1081
+ `${r.path}${r.heading ? ` > ${r.heading}` : ""} (L${r.line_start}-L${r.line_end}):\n${r.content.slice(0, 2000)}`
1082
+ ).join("\n\n---\n\n")
1083
+ },
1084
+ }
1085
+ }
1086
+
413
1087
  // ---------------------------------------------------------------- agent 工具
414
1088
 
415
1089
  /**