thincoder 0.7.7 → 0.8.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.
Files changed (97) hide show
  1. package/README.md +36 -13
  2. package/bin/thincoder.mjs +27 -346
  3. package/package.json +1 -1
  4. package/src/agent/dispatch.mjs +98 -0
  5. package/src/agent/helpers.mjs +185 -0
  6. package/src/agent/setup.mjs +117 -0
  7. package/src/agent-tools/goal.mjs +71 -0
  8. package/src/agent-tools/plan.mjs +31 -0
  9. package/src/agent-tools/recent-changes.mjs +23 -0
  10. package/src/agent-tools/skill.mjs +46 -0
  11. package/src/agent-tools/subagent.mjs +113 -0
  12. package/src/agent-tools/task.mjs +67 -0
  13. package/src/agent-tools/verify.mjs +198 -0
  14. package/src/agent-tools.mjs +12 -0
  15. package/src/agent.mjs +90 -1040
  16. package/src/cli/distill-command.mjs +85 -0
  17. package/src/cli/make-agent.mjs +85 -0
  18. package/src/cli/memory-command.mjs +63 -0
  19. package/src/cli/permission.mjs +41 -0
  20. package/src/cli/setup-wizard.mjs +70 -0
  21. package/src/config.mjs +5 -8
  22. package/src/context.mjs +10 -13
  23. package/src/distill.mjs +4 -3
  24. package/src/embedding.mjs +4 -2
  25. package/src/{checkpoint.mjs → git/checkpoint.mjs} +1 -1
  26. package/src/mcp/helpers.mjs +37 -0
  27. package/src/mcp/transport-http.mjs +176 -0
  28. package/src/mcp/transport-stdio.mjs +84 -0
  29. package/src/mcp/transport-ws.mjs +87 -0
  30. package/src/mcp.mjs +4 -428
  31. package/src/memory/code-index.mjs +211 -0
  32. package/src/memory/code-sync.mjs +306 -0
  33. package/src/memory/core.mjs +277 -0
  34. package/src/memory/docs.mjs +262 -0
  35. package/src/memory/schema.mjs +426 -0
  36. package/src/memory.mjs +12 -1403
  37. package/src/provider/core.mjs +239 -0
  38. package/src/provider/index.mjs +6 -0
  39. package/src/provider/rate.mjs +104 -0
  40. package/src/session.mjs +18 -5
  41. package/src/tools/bash.md +1 -0
  42. package/src/tools/bash.mjs +144 -0
  43. package/src/tools/file.mjs +205 -0
  44. package/src/tools/git.mjs +166 -0
  45. package/src/tools/glob.mjs +51 -0
  46. package/src/tools/grep.mjs +100 -0
  47. package/src/tools/index.mjs +22 -0
  48. package/src/tools/ls.mjs +36 -0
  49. package/src/tools/patch.mjs +226 -0
  50. package/src/tools/repomap-parse.mjs +168 -0
  51. package/src/tools/shared.mjs +257 -0
  52. package/src/tools/system.mjs +336 -0
  53. package/src/tools/web.mjs +121 -0
  54. package/src/tools.mjs +2 -1188
  55. package/src/tui/agent-turn.mjs +254 -0
  56. package/src/tui/ansi.mjs +32 -0
  57. package/src/tui/clipboard.mjs +48 -0
  58. package/src/tui/cmd-auto.mjs +21 -0
  59. package/src/tui/cmd-clear.mjs +26 -0
  60. package/src/tui/cmd-config.mjs +72 -0
  61. package/src/tui/cmd-exit.mjs +5 -0
  62. package/src/tui/cmd-extract.mjs +5 -0
  63. package/src/tui/cmd-goal.mjs +47 -0
  64. package/src/tui/cmd-help.mjs +25 -0
  65. package/src/tui/cmd-init.mjs +91 -0
  66. package/src/tui/cmd-mcp.mjs +146 -0
  67. package/src/tui/cmd-model.mjs +7 -0
  68. package/src/tui/cmd-new.mjs +18 -0
  69. package/src/tui/cmd-plan.mjs +21 -0
  70. package/src/tui/cmd-reindex.mjs +44 -0
  71. package/src/tui/cmd-restore.mjs +39 -0
  72. package/src/tui/cmd-session.mjs +42 -0
  73. package/src/tui/cmd-skills.mjs +17 -0
  74. package/src/tui/cmd-think.mjs +56 -0
  75. package/src/tui/config-helpers.mjs +34 -0
  76. package/src/tui/distill-cmd.mjs +45 -0
  77. package/src/tui/index.mjs +330 -0
  78. package/src/tui/interaction.mjs +79 -0
  79. package/src/tui/key-handler.mjs +267 -0
  80. package/src/tui/layout.mjs +115 -0
  81. package/src/tui/pickers.mjs +279 -0
  82. package/src/tui/render-frame.mjs +304 -0
  83. package/src/tui/render.mjs +205 -0
  84. package/src/tui/slash-commands.mjs +138 -0
  85. package/src/tui/startup.mjs +113 -0
  86. package/src/tui/wizard.mjs +168 -0
  87. package/src/tui-render.mjs +4 -0
  88. package/src/tui.mjs +3 -2546
  89. package/src/provider.mjs +0 -383
  90. /package/src/{gitmem.mjs → git/gitmem.mjs} +0 -0
  91. /package/src/{coder-overlay.md → prompts/coder.md} +0 -0
  92. /package/src/{discipline-rules.md → prompts/discipline.md} +0 -0
  93. /package/src/{explore-overlay.md → prompts/explore.md} +0 -0
  94. /package/src/{main-overlay.md → prompts/main.md} +0 -0
  95. /package/src/{plan-overlay.md → prompts/plan.md} +0 -0
  96. /package/src/{SYSTEM_PROMPT.md → prompts/system.md} +0 -0
  97. /package/src/{repomap.mjs → tools/repomap.mjs} +0 -0
@@ -0,0 +1,306 @@
1
+ /**
2
+ * memory/code-sync.mjs — 代码索引同步、检索、增量更新
3
+ */
4
+
5
+ import { readFile, stat } from "node:fs/promises"
6
+ import { join, relative } from "node:path"
7
+ import { embed, cosine, toBlob, fromBlob } from "../embedding.mjs"
8
+ import { CODE_EXTS, DOC_EXTS, SKIP_DIRS } from "./schema.mjs"
9
+ import { buildFtsQuery, ensureEmbeddings } from "./core.mjs"
10
+ import { detectLanguage, _upsertCodeFile, _upsertDocFile, yieldTick } from "./code-index.mjs"
11
+
12
+ /**
13
+ * git 驱动增量索引:用 git diff 找出上次索引以来的变更文件,
14
+ * 只重建这些文件的 FTS5 块(不碰向量)。比全量 mtime 扫描快一个数量级。
15
+ * 返回 { updated, removed, skipped } 或 null(git 不可用)。
16
+ */
17
+ export async function gitSync(memory, dir, { onProgress } = {}) {
18
+ const { execSync, execFileSync } = await import("node:child_process")
19
+ const opts = { cwd: dir, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 10000 }
20
+
21
+ let head
22
+ try { head = execSync("git rev-parse HEAD", opts).trim() } catch { return null }
23
+
24
+ const stored = memory.db.prepare(`SELECT value FROM meta WHERE key = 'last_indexed_commit'`).get()?.value
25
+ if (!stored) return null
26
+
27
+ let diffOut
28
+ try {
29
+ const committed = execFileSync("git", ["diff", "--name-only", "--diff-filter=ACMRTD", stored, "HEAD"], { cwd: dir, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 10000 }).trim()
30
+ const dirty = execSync(`git diff --name-only --diff-filter=ACMRTD`, opts).trim()
31
+ const lines = [...new Set([...committed.split("\n").filter(Boolean), ...dirty.split("\n").filter(Boolean)])]
32
+ diffOut = lines
33
+ } catch {
34
+ return null
35
+ }
36
+
37
+ if (diffOut.length > 200) {
38
+ // diff 太大,增量没意义——回退全量同步并更新锚点
39
+ await codeSync(memory, dir, { onProgress })
40
+ const { docSync } = await import("./docs.mjs")
41
+ await docSync(memory, dir, { onProgress })
42
+ return { updated: -1, removed: 0, skipped: 0, failed: 0, errors: [], fallback: true }
43
+ }
44
+
45
+ let updated = 0, removed = 0, skipped = 0, failed = 0
46
+ const errors = []
47
+ for (let i = 0; i < diffOut.length; i++) {
48
+ const rel = diffOut[i].replaceAll("\\", "/")
49
+ const abs = join(dir, rel)
50
+ const ext = rel.slice(rel.lastIndexOf(".")).toLowerCase()
51
+
52
+ const pathDirs = rel.split("/")
53
+ if (pathDirs.some((d) => SKIP_DIRS.has(d) || d.startsWith("."))) continue
54
+
55
+ if (!CODE_EXTS.has(ext) && !DOC_EXTS.has(ext)) { skipped++; continue }
56
+
57
+ try {
58
+ const text = await readFile(abs, "utf8")
59
+ const lines = text.split("\n")
60
+ if (CODE_EXTS.has(ext)) {
61
+ const lang = detectLanguage(abs)
62
+ let mtimeMs = 0
63
+ try { mtimeMs = Math.floor((await stat(abs)).mtimeMs) } catch { /* 新文件 */ }
64
+ _upsertCodeFile(memory, dir, rel, lines, lang, mtimeMs)
65
+ } else {
66
+ let mtimeMs = 0
67
+ try { mtimeMs = Math.floor((await stat(abs)).mtimeMs) } catch { /* 新文件 */ }
68
+ _upsertDocFile(memory, dir, rel, lines, mtimeMs)
69
+ }
70
+ updated++
71
+ } catch (e) {
72
+ const isDeleted = e.code === "ENOENT"
73
+ if (isDeleted) {
74
+ if (CODE_EXTS.has(ext)) memory.db.prepare(`DELETE FROM code_chunks WHERE origin = ? AND path = ?`).run(dir, rel)
75
+ else memory.db.prepare(`DELETE FROM doc_chunks WHERE origin = ? AND path = ?`).run(dir, rel)
76
+ removed++
77
+ } else {
78
+ failed++
79
+ if (errors.length < 5) errors.push(`${rel}: ${e.message}`)
80
+ }
81
+ }
82
+ if (onProgress && i % 5 === 0) {
83
+ onProgress({ phase: "index", current: i + 1, total: diffOut.length, updated, removed, skipped })
84
+ }
85
+ }
86
+
87
+ if (failed === 0) {
88
+ memory.db.prepare(`INSERT INTO meta (key, value) VALUES ('last_indexed_commit', ?)
89
+ ON CONFLICT (key) DO UPDATE SET value = excluded.value`).run(head)
90
+ }
91
+
92
+ onProgress?.({ phase: "done", total: diffOut.length, updated, removed, skipped, failed })
93
+ return { updated, removed, skipped, failed, errors }
94
+ }
95
+
96
+ /**
97
+ * 同步代码索引:扫描 dir 下所有源文件 → 分块 → upsert 到 code_chunks。
98
+ * 按 mtime 增量——只重建变更过的文件块。
99
+ */
100
+ export async function codeSync(memory, dir, { onProgress } = {}) {
101
+ const files = []
102
+ const { readdir } = await import("node:fs/promises")
103
+ async function walk(d) {
104
+ let entries
105
+ try { entries = await readdir(d, { withFileTypes: true }) } catch { return }
106
+ for (const e of entries) {
107
+ if (e.isDirectory()) {
108
+ if (SKIP_DIRS.has(e.name) || e.name.startsWith(".")) continue
109
+ await walk(join(d, e.name))
110
+ } else if (e.isFile()) {
111
+ const ext = e.name.slice(e.name.lastIndexOf(".")).toLowerCase()
112
+ if (CODE_EXTS.has(ext)) files.push(join(d, e.name))
113
+ }
114
+ }
115
+ }
116
+ await walk(dir)
117
+
118
+ const indexed = new Map(
119
+ memory.db.prepare(`SELECT path, mtime_ms FROM code_chunks WHERE origin = ?`).all(dir).map((r) => [r.path, r.mtime_ms])
120
+ )
121
+ const seen = new Set()
122
+
123
+ onProgress?.({ phase: "scan", total: files.length })
124
+
125
+ let updated = 0, removed = 0, skipped = 0, failed = 0
126
+ const errors = []
127
+ for (let i = 0; i < files.length; i++) {
128
+ const abs = files[i]
129
+ const rel = abs.slice(dir.length + 1).replaceAll("\\", "/")
130
+ seen.add(rel)
131
+
132
+ let mtimeMs
133
+ try { mtimeMs = Math.floor((await stat(abs)).mtimeMs) } catch { continue }
134
+ if (indexed.get(rel) === mtimeMs) {
135
+ skipped++
136
+ continue
137
+ }
138
+
139
+ try {
140
+ const text = await readFile(abs, "utf8")
141
+ const lines = text.split("\n")
142
+ const lang = detectLanguage(abs)
143
+ _upsertCodeFile(memory, dir, rel, lines, lang, mtimeMs)
144
+ updated++
145
+ } catch (e) {
146
+ failed++
147
+ if (errors.length < 5) errors.push(`${rel}: ${e.message}`)
148
+ }
149
+ await yieldTick()
150
+
151
+ if (onProgress && i % 10 === 0) {
152
+ onProgress({ phase: "index", current: i + 1, total: files.length, updated, removed, skipped, failed })
153
+ }
154
+ }
155
+
156
+ for (const stale of indexed.keys()) {
157
+ if (!seen.has(stale)) {
158
+ memory.db.prepare(`DELETE FROM code_chunks WHERE origin = ? AND path = ?`).run(dir, stale)
159
+ removed++
160
+ }
161
+ }
162
+
163
+ onProgress?.({ phase: "done", total: files.length, updated, removed, skipped, failed })
164
+ markIndexedCommit(memory, dir)
165
+ return { updated, removed, skipped, failed, errors, total: files.length }
166
+ }
167
+
168
+ /** 记录当前 HEAD 作为索引锚点(gitSync 增量 diff 基准);非 git 仓库静默跳过 */
169
+ export async function markIndexedCommit(memory, dir) {
170
+ try {
171
+ const { execSync } = await import("node:child_process")
172
+ const head = execSync("git rev-parse HEAD", { cwd: dir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5000 }).trim()
173
+ memory.db.prepare(`INSERT INTO meta (key, value) VALUES ('last_indexed_commit', ?)
174
+ ON CONFLICT (key) DO UPDATE SET value = excluded.value`).run(head)
175
+ } catch { /* 非 git 仓库或 git 不可用,跳过 */ }
176
+ }
177
+
178
+ /**
179
+ * 代码检索:FTS5(BM25) + 可选向量余弦,RRF 合并。
180
+ * 无 embedder 时退化为纯 FTS;ftsQuery 为空且有 embedder 时退化为纯向量。
181
+ */
182
+ export async function codeSearch(memory, query, { limit = 5 } = {}) {
183
+ const ftsQuery = buildFtsQuery(query)
184
+ if (!ftsQuery && !memory.embedder) return []
185
+
186
+ const ftsOriginFilter = memory.codeOrigin ? `AND c.origin = ?` : ""
187
+ const vecOriginFilter = memory.codeOrigin ? `AND origin = ?` : ""
188
+ const originParams = memory.codeOrigin ? [memory.codeOrigin] : []
189
+
190
+ const ftsList = ftsQuery ? memory.db.prepare(`
191
+ SELECT c.rowid, c.path, c.language, c.symbol_name, c.content, c.line_start, c.line_end, bm25(code_chunks_fts) AS rank
192
+ FROM code_chunks_fts JOIN code_chunks c ON c.rowid = code_chunks_fts.rowid
193
+ WHERE code_chunks_fts MATCH ? ${ftsOriginFilter}
194
+ ORDER BY rank LIMIT ?
195
+ `).all(ftsQuery, ...originParams, Math.max(limit * 4, 20)) : []
196
+
197
+ if (!memory.embedder) return ftsList.slice(0, limit)
198
+
199
+ try { await ensureEmbeddings(memory) } catch (e) {
200
+ console.error(`[code] embedding ensure failed, falling back to FTS-only: ${e.message}`)
201
+ return ftsList.slice(0, limit)
202
+ }
203
+ let qvec
204
+ try { [qvec] = await embed(memory.embedder, [query]) } catch (e) {
205
+ console.error(`[code] query embedding failed, falling back to FTS-only: ${e.message}`)
206
+ return ftsList.slice(0, limit)
207
+ }
208
+ const rows = memory.db.prepare(`SELECT rowid, embedding FROM code_chunks WHERE embedding IS NOT NULL ${vecOriginFilter}`).all(...originParams)
209
+ const vecList = rows
210
+ .map((r) => ({ rowid: r.rowid, score: cosine(qvec, fromBlob(r.embedding)) }))
211
+ .sort((a, b) => b.score - a.score)
212
+ .slice(0, Math.max(limit * 4, 20))
213
+
214
+ const K = 60
215
+ const scores = new Map()
216
+ ftsList.forEach((r, i) => scores.set(r.rowid, (scores.get(r.rowid) ?? 0) + 1 / (K + i + 1)))
217
+ vecList.forEach((r, i) => scores.set(r.rowid, (scores.get(r.rowid) ?? 0) + 1 / (K + i + 1)))
218
+
219
+ const fetchChunk = memory.db.prepare(`
220
+ SELECT path, language, symbol_name, content, line_start, line_end FROM code_chunks WHERE rowid = ?
221
+ `)
222
+ return [...scores.entries()]
223
+ .sort((a, b) => b[1] - a[1])
224
+ .slice(0, limit)
225
+ .map(([rowid]) => fetchChunk.get(rowid))
226
+ .filter(Boolean)
227
+ }
228
+
229
+ /** 惰性补算 code_chunks 缺失的向量 */
230
+ export async function ensureCodeEmbeddings(memory) {
231
+ if (!memory.embedder) return
232
+ const modelKey = memory.embedder.model
233
+ const stored = memory.db.prepare(`SELECT value FROM meta WHERE key = 'code_embedding_model'`).get()?.value
234
+ if (stored !== modelKey) {
235
+ memory.db.prepare(`UPDATE code_chunks SET embedding = NULL`).run()
236
+ memory.db.prepare(`INSERT INTO meta (key, value) VALUES ('code_embedding_model', ?)
237
+ ON CONFLICT (key) DO UPDATE SET value = excluded.value`).run(modelKey)
238
+ }
239
+
240
+ const pending = memory.db.prepare(`SELECT rowid, path, symbol_name, content FROM code_chunks WHERE embedding IS NULL LIMIT 64`).all()
241
+ if (pending.length === 0) return
242
+
243
+ const texts = pending.map((r) => `${r.path}${r.symbol_name ? " :: " + r.symbol_name : ""}\n${r.content.slice(0, 2000)}`)
244
+ const vecs = await embed(memory.embedder, texts)
245
+
246
+ const update = memory.db.prepare(`UPDATE code_chunks SET embedding = ? WHERE rowid = ?`)
247
+ pending.forEach((r, i) => update.run(toBlob(vecs[i]), r.rowid))
248
+ }
249
+
250
+ /** 生成 code_search 工具(只读)。 */
251
+ export function codeSearchTool(memory) {
252
+ return {
253
+ name: "code_search",
254
+ description:
255
+ "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.",
256
+ parameters: {
257
+ type: "object",
258
+ properties: {
259
+ query: { type: "string", description: "Natural language or code snippet to search for" },
260
+ limit: { type: "number", description: "Max results (default 5)" },
261
+ },
262
+ required: ["query"],
263
+ },
264
+ readonly: true,
265
+ async execute(args) {
266
+ const results = await codeSearch(memory, args.query, { limit: args.limit ?? 5 })
267
+ if (results.length === 0) return "(no matching code)"
268
+ 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)}`
270
+ ).join("\n\n---\n\n")
271
+ },
272
+ }
273
+ }
274
+
275
+ /**
276
+ * 单文件增量重索引:write/edit/delete 后调用,只重建这一条路径。
277
+ */
278
+ export async function reindexFile(memory, cwd, absPath) {
279
+ const ext = absPath.slice(absPath.lastIndexOf(".")).toLowerCase()
280
+ const rel = relative(cwd, absPath).replaceAll("\\", "/")
281
+ if (rel === ".." || rel.startsWith("../")) return
282
+ const dirs = rel.split("/").slice(0, -1)
283
+ if (dirs.some((d) => SKIP_DIRS.has(d) || d.startsWith("."))) return
284
+
285
+ let text
286
+ try { text = await readFile(absPath, "utf8") } catch {
287
+ if (CODE_EXTS.has(ext)) memory.db.prepare(`DELETE FROM code_chunks WHERE origin = ? AND path = ?`).run(cwd, rel)
288
+ else if (DOC_EXTS.has(ext)) memory.db.prepare(`DELETE FROM doc_chunks WHERE origin = ? AND path = ?`).run(cwd, rel)
289
+ return
290
+ }
291
+ const lines = text.split("\n")
292
+
293
+ if (CODE_EXTS.has(ext)) {
294
+ const lang = detectLanguage(absPath)
295
+ let mtimeMs = 0
296
+ try { mtimeMs = Math.floor((await stat(absPath)).mtimeMs) } catch { /* 新文件 */ }
297
+ _upsertCodeFile(memory, cwd, rel, lines, lang, mtimeMs)
298
+ } else if (DOC_EXTS.has(ext)) {
299
+ let mtimeMs = 0
300
+ try { mtimeMs = Math.floor((await stat(absPath)).mtimeMs) } catch { /* 新文件 */ }
301
+ _upsertDocFile(memory, cwd, rel, lines, mtimeMs)
302
+ }
303
+ if (memory.embedder) {
304
+ try { await ensureEmbeddings(memory) } catch { /* embedding 失败不阻塞 */ }
305
+ }
306
+ }
@@ -0,0 +1,277 @@
1
+ /**
2
+ * memory/core.mjs — 记忆 CRUD、混合检索、embedding 管理
3
+ */
4
+
5
+ import { parseEntry, serializeEntry, entryFilename } from "../markdown.mjs"
6
+ import { embed, cosine, toBlob, fromBlob } from "../embedding.mjs"
7
+ import { readFile, stat, readdir, writeFile, mkdir } from "node:fs/promises"
8
+ import { join } from "node:path"
9
+ import { segmentCJK, VALID_TYPES, SCHEMA_VERSION } from "./schema.mjs"
10
+
11
+ /**
12
+ * 写入一条记忆。entry: { type, title, content, tags? }
13
+ * 返回新条目 id。
14
+ */
15
+ export async function put(memory, { type, title, content, tags = "" }) {
16
+ if (!VALID_TYPES.has(type)) {
17
+ throw new Error(`Invalid memory type "${type}"; expected one of: ${[...VALID_TYPES].join(", ")}`)
18
+ }
19
+ if (!title || !content) throw new Error("memory entry requires title and content")
20
+ const now = Date.now()
21
+ const stmt = memory.db.prepare(
22
+ `INSERT INTO entries (type, title, content, tags, seg_title, seg_content, seg_tags, created_at, updated_at)
23
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
24
+ )
25
+ const info = stmt.run(type, title, content, tags, segmentCJK(title), segmentCJK(content), segmentCJK(tags), now, now)
26
+ return Number(info.lastInsertRowid)
27
+ }
28
+
29
+ /**
30
+ * 混合检索:FTS5(BM25) + 向量余弦,RRF(k=60) 合并排序。
31
+ * 无 embedder 时退化为纯 FTS。结果带 layer 标记。
32
+ * 返回 [{ id, layer, type, title, content, tags, rank }]
33
+ */
34
+ export async function search(memory, query, { limit = 5 } = {}) {
35
+ const ftsQuery = buildFtsQuery(query)
36
+ const ftsList = ftsQuery ? ftsSearch(memory, ftsQuery, Math.max(limit * 4, 20)) : []
37
+
38
+ if (!memory.embedder) return ftsList.slice(0, limit)
39
+
40
+ // ---- 向量通道 ----
41
+ try { await ensureEmbeddings(memory) } catch (e) {
42
+ console.error(`[memory] embedding ensure failed, falling back to FTS-only: ${e.message}`)
43
+ return ftsList.slice(0, limit)
44
+ }
45
+ let qvec
46
+ try { [qvec] = await embed(memory.embedder, [query]) } catch (e) {
47
+ console.error(`[memory] query embedding failed, falling back to FTS-only: ${e.message}`)
48
+ return ftsList.slice(0, limit)
49
+ }
50
+ const vecFilter = memory.projectOrigin ? `AND (layer = 'team' OR origin = ?)` : ""
51
+ const vecParams = memory.projectOrigin ? [memory.projectOrigin] : []
52
+ const rows = memory.db.prepare(`
53
+ SELECT 'personal:' || id AS uid, embedding FROM entries WHERE embedding IS NOT NULL
54
+ UNION ALL
55
+ SELECT layer || ':' || COALESCE(origin, '') || ':' || path AS uid, embedding FROM files WHERE embedding IS NOT NULL ${vecFilter}
56
+ `).all(...vecParams)
57
+ const vecList = rows
58
+ .map((r) => ({ id: r.uid, score: cosine(qvec, fromBlob(r.embedding)) }))
59
+ .sort((a, b) => b.score - a.score)
60
+ .slice(0, Math.max(limit * 4, 20))
61
+
62
+ // ---- RRF 合并 ----
63
+ const K = 60
64
+ const scores = new Map()
65
+ ftsList.forEach((r, i) => scores.set(r.id, (scores.get(r.id) ?? 0) + 1 / (K + i + 1)))
66
+ vecList.forEach((r, i) => scores.set(r.id, (scores.get(r.id) ?? 0) + 1 / (K + i + 1)))
67
+
68
+ return [...scores.entries()]
69
+ .sort((a, b) => b[1] - a[1])
70
+ .slice(0, limit)
71
+ .map(([id, score]) => {
72
+ const entry = fetchEntry(memory, id)
73
+ return entry ? { ...entry, rrf: score } : null
74
+ })
75
+ .filter(Boolean)
76
+ }
77
+
78
+ /** 纯 FTS 检索(两表合并,按 bm25 排序),RRF 的位置输入 */
79
+ export function ftsSearch(memory, ftsQuery, limit) {
80
+ const personal = memory.db.prepare(`
81
+ SELECT e.id, e.type, e.title, e.content, e.tags, bm25(entries_fts) AS rank
82
+ FROM entries_fts JOIN entries e ON e.id = entries_fts.rowid
83
+ WHERE entries_fts MATCH ?
84
+ ORDER BY rank LIMIT ?
85
+ `).all(ftsQuery, limit).map((r) => ({ ...r, layer: "personal", id: `personal:${r.id}` }))
86
+
87
+ const originFilter = memory.projectOrigin ? `AND (f.layer = 'team' OR f.origin = ?)` : ""
88
+ const originParams = memory.projectOrigin ? [ftsQuery, memory.projectOrigin, limit] : [ftsQuery, limit]
89
+ const files = memory.db.prepare(`
90
+ SELECT f.layer, f.path, f.type, f.title, f.content, f.tags, f.author, bm25(files_fts) AS rank
91
+ FROM files_fts JOIN files f ON f.rowid = files_fts.rowid
92
+ WHERE files_fts MATCH ? ${originFilter}
93
+ ORDER BY rank LIMIT ?
94
+ `).all(...originParams).map((r) => ({ ...r, id: `${r.layer}:${r.origin ?? ""}:${r.path}` }))
95
+
96
+ return [...personal, ...files].sort((a, b) => a.rank - b.rank).slice(0, limit)
97
+ }
98
+
99
+ /** 按统一 id 取完整条目(personal:<n> / project:<origin>:<path> / team:<origin>:<path>)
100
+ * 注意:v9 起 files 表 PK 为 (layer, origin, path),同一 layer+path 可能跨多个 origin。
101
+ * project 层优先返回 projectOrigin 命中的行,team 层取任意一行(跨团队仓库同名时取首条)。 */
102
+ export function fetchEntry(memory, uid) {
103
+ const [layer, ...rest] = uid.split(":")
104
+ if (layer === "personal") {
105
+ const r = memory.db.prepare(`SELECT id, type, title, content, tags FROM entries WHERE id = ?`).get(Number(rest[0]))
106
+ return r ? { ...r, layer, id: uid } : null
107
+ }
108
+ // rest = [origin, ...pathParts];origin 可能为空字符串(旧格式兼容)
109
+ const origin = rest[0] ?? ""
110
+ const path = rest.slice(1).join(":")
111
+ if (layer === "project" && memory.projectOrigin) {
112
+ 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
+ if (r) return { ...r, layer, id: uid }
114
+ }
115
+ // team 层或 project 兜底:按 origin+path 查,origin 为空时退化为只按 path(兼容旧 UID)
116
+ if (origin) {
117
+ const r = memory.db.prepare(`SELECT type, title, content, tags, author FROM files WHERE layer = ? AND origin = ? AND path = ?`).get(layer, origin, path)
118
+ if (r) return { ...r, layer, id: uid }
119
+ }
120
+ const r = memory.db.prepare(`SELECT type, title, content, tags, author FROM files WHERE layer = ? AND path = ?`).get(layer, path || rest.join(":"))
121
+ return r ? { ...r, layer, id: uid } : null
122
+ }
123
+
124
+ /**
125
+ * 惰性 embedding:把还没有向量的条目批量补算落库(首次慢、后续零成本)。
126
+ * 检测到 embedding 模型变更时,清空全部向量重建。
127
+ */
128
+ export async function ensureEmbeddings(memory) {
129
+ const modelKey = memory.embedder.model
130
+ const stored = memory.db.prepare(`SELECT value FROM meta WHERE key = 'embedding_model'`).get()?.value
131
+ if (stored !== modelKey) {
132
+ // 统一失效三个表 + 三个 meta key,防维度不匹配的陈旧向量残留
133
+ memory.db.prepare(`UPDATE entries SET embedding = NULL`).run()
134
+ memory.db.prepare(`UPDATE files SET embedding = NULL`).run()
135
+ memory.db.prepare(`UPDATE code_chunks SET embedding = NULL`).run()
136
+ memory.db.prepare(`UPDATE doc_chunks SET embedding = NULL`).run()
137
+ const upsert = memory.db.prepare(`INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value`)
138
+ upsert.run("embedding_model", modelKey)
139
+ upsert.run("code_embedding_model", modelKey)
140
+ upsert.run("doc_embedding_model", modelKey)
141
+ }
142
+
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()
145
+ if (pendingEntries.length + pendingFiles.length === 0) {
146
+ // 记忆不算 pending,也补一下代码和文档块的向量
147
+ await (await import("./code-sync.mjs")).ensureCodeEmbeddings(memory)
148
+ await (await import("./docs.mjs")).ensureDocEmbeddings(memory)
149
+ return
150
+ }
151
+
152
+ const items = [...pendingEntries, ...pendingFiles]
153
+ const texts = items.map((r) => `${r.title}\n${r.content.slice(0, 2000)}`)
154
+ const vecs = await embed(memory.embedder, texts)
155
+
156
+ const updateEntry = memory.db.prepare(`UPDATE entries SET embedding = ? WHERE id = ?`)
157
+ pendingEntries.forEach((r, i) => updateEntry.run(toBlob(vecs[i]), r.id))
158
+ const updateFile = memory.db.prepare(`UPDATE files SET embedding = ? WHERE rowid = ?`)
159
+ pendingFiles.forEach((r, i) => updateFile.run(toBlob(vecs[pendingEntries.length + i]), r.rowid))
160
+
161
+ // 每批嵌入后也补一下代码和文档块
162
+ await (await import("./code-sync.mjs")).ensureCodeEmbeddings(memory)
163
+ await (await import("./docs.mjs")).ensureDocEmbeddings(memory)
164
+ }
165
+
166
+ /**
167
+ * 写入一条 markdown 记忆到指定层目录(project/team),并即时索引。
168
+ * 只写文件——project 层绝不替用户的项目仓库做 git 操作;
169
+ * team 层的 commit+push 由 gitmem.mjs 负责。
170
+ * 返回文件名。
171
+ */
172
+ export async function putMarkdown(memory, { layer, dir, type, title, content, tags = [], author = "unknown" }) {
173
+ if (layer !== "project" && layer !== "team") throw new Error(`invalid markdown layer: ${layer}`)
174
+ const filename = entryFilename(title)
175
+ const markdown = serializeEntry({ type, title, tags, author }, content)
176
+ await mkdir(dir, { recursive: true })
177
+ await writeFile(join(dir, filename), markdown, "utf8")
178
+ await indexMarkdownFile(memory, { layer, dir, filename })
179
+ return filename
180
+ }
181
+
182
+ /**
183
+ * 同步一个 markdown 目录到索引:新增/变更(按 mtime)重建索引,消失的条目从索引删除。
184
+ */
185
+ export async function syncDir(memory, { layer, dir }) {
186
+ let names = []
187
+ try {
188
+ names = (await readdir(dir)).filter((n) => n.endsWith(".md"))
189
+ } catch {
190
+ names = []
191
+ }
192
+
193
+ const indexed = new Map(
194
+ memory.db.prepare(`SELECT path, mtime_ms FROM files WHERE layer = ? AND origin = ?`).all(layer, dir).map((r) => [r.path, r.mtime_ms]),
195
+ )
196
+
197
+ let added = 0, updated = 0, skipped = 0
198
+ for (const filename of names) {
199
+ const mtimeMs = Math.floor((await stat(join(dir, filename))).mtimeMs)
200
+ const old = indexed.get(filename)
201
+ const isNew = old === undefined
202
+ if (!isNew && old === mtimeMs) continue
203
+ try {
204
+ await indexMarkdownFile(memory, { layer, dir, filename, mtimeMs })
205
+ } catch (e) {
206
+ console.error(`[memory] skip ${layer}/${filename}: ${e.message}`)
207
+ skipped++
208
+ indexed.delete(filename)
209
+ continue
210
+ }
211
+ if (isNew) added++
212
+ else updated++
213
+ indexed.delete(filename)
214
+ }
215
+
216
+ let removed = 0
217
+ for (const stale of indexed.keys()) {
218
+ memory.db.prepare(`DELETE FROM files WHERE layer = ? AND origin = ? AND path = ?`).run(layer, dir, stale)
219
+ removed++
220
+ }
221
+ return { added, updated, removed, skipped }
222
+ }
223
+
224
+ /** 解析单个 .md 并 upsert 进 files 表 */
225
+ export async function indexMarkdownFile(memory, { layer, dir, filename, mtimeMs }) {
226
+ const abs = join(dir, filename)
227
+ const mtime = mtimeMs ?? Math.floor((await stat(abs)).mtimeMs)
228
+ const { meta, content } = parseEntry(await readFile(abs, "utf8"))
229
+ const tags = meta.tags.join(" ")
230
+ memory.db.prepare(`
231
+ INSERT INTO files (layer, origin, path, type, title, content, tags, author, mtime_ms, seg_title, seg_content, seg_tags, updated_at)
232
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
233
+ ON CONFLICT(layer, origin, path) DO UPDATE SET
234
+ type=excluded.type, title=excluded.title, content=excluded.content, tags=excluded.tags,
235
+ author=excluded.author, mtime_ms=excluded.mtime_ms, origin=excluded.origin,
236
+ seg_title=excluded.seg_title, seg_content=excluded.seg_content, seg_tags=excluded.seg_tags,
237
+ updated_at=excluded.updated_at
238
+ `).run(
239
+ layer, dir, filename, meta.type, meta.title, content, tags, meta.author, mtime,
240
+ segmentCJK(meta.title), segmentCJK(content), segmentCJK(tags), Date.now(),
241
+ )
242
+ }
243
+
244
+ /** 列出新条目,可按 type 过滤 */
245
+ export async function list(memory, { type, limit = 50 } = {}) {
246
+ if (type) {
247
+ if (!VALID_TYPES.has(type)) throw new Error(`Invalid memory type "${type}"`)
248
+ return memory.db
249
+ .prepare(`SELECT id, type, title, content, tags, updated_at FROM entries WHERE type = ? ORDER BY updated_at DESC LIMIT ?`)
250
+ .all(type, limit)
251
+ }
252
+ return memory.db
253
+ .prepare(`SELECT id, type, title, content, tags, updated_at FROM entries ORDER BY updated_at DESC LIMIT ?`)
254
+ .all(limit)
255
+ }
256
+
257
+ /** 删除一条记忆。返回是否删除成功 */
258
+ export async function remove(memory, id) {
259
+ const info = memory.db.prepare(`DELETE FROM entries WHERE id = ?`).run(id)
260
+ return info.changes > 0
261
+ }
262
+
263
+ /**
264
+ * 构造 FTS5 查询:先按空白/标点切词,再对每个词做 CJK 分字。
265
+ * 这样 CJK 多字词保持为 FTS5 短语("分号" → "分 号" → 短语查询,精确匹配相邻字),
266
+ * 而不同词之间用 OR 连接("命名 规范" → "命 名" OR "规 范",两个短语各需相邻匹配)。
267
+ */
268
+ export function buildFtsQuery(query) {
269
+ const terms = query
270
+ .split(/[\s,,。、;;!!??()()"`]+/)
271
+ .map((t) => t.trim())
272
+ .filter(Boolean)
273
+ .slice(0, 16)
274
+ .map((t) => segmentCJK(t))
275
+ if (terms.length === 0) return ""
276
+ return terms.map((t) => `"${t.replaceAll('"', '""')}"`).join(" OR ")
277
+ }