thincoder 0.7.8 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -13
- package/bin/thincoder.js +4 -0
- package/bin/thincoder.mjs +27 -346
- package/package.json +2 -2
- package/src/agent/dispatch.mjs +98 -0
- package/src/agent/helpers.mjs +185 -0
- package/src/agent/setup.mjs +117 -0
- package/src/agent-tools/goal.mjs +71 -0
- package/src/agent-tools/plan.mjs +31 -0
- package/src/agent-tools/recent-changes.mjs +23 -0
- package/src/agent-tools/skill.mjs +46 -0
- package/src/agent-tools/subagent.mjs +113 -0
- package/src/agent-tools/task.mjs +67 -0
- package/src/agent-tools/verify.mjs +198 -0
- package/src/agent-tools.mjs +12 -0
- package/src/agent.mjs +90 -1040
- package/src/cli/distill-command.mjs +85 -0
- package/src/cli/make-agent.mjs +85 -0
- package/src/cli/memory-command.mjs +63 -0
- package/src/cli/permission.mjs +41 -0
- package/src/cli/setup-wizard.mjs +70 -0
- package/src/config.mjs +5 -8
- package/src/context.mjs +10 -13
- package/src/distill.mjs +4 -3
- package/src/embedding.mjs +4 -2
- package/src/{checkpoint.mjs → git/checkpoint.mjs} +1 -1
- package/src/mcp/helpers.mjs +37 -0
- package/src/mcp/transport-http.mjs +176 -0
- package/src/mcp/transport-stdio.mjs +84 -0
- package/src/mcp/transport-ws.mjs +87 -0
- package/src/mcp.mjs +4 -428
- package/src/memory/code-index.mjs +211 -0
- package/src/memory/code-sync.mjs +306 -0
- package/src/memory/core.mjs +277 -0
- package/src/memory/docs.mjs +262 -0
- package/src/memory/schema.mjs +426 -0
- package/src/memory.mjs +12 -1403
- package/src/provider/core.mjs +239 -0
- package/src/provider/index.mjs +6 -0
- package/src/provider/rate.mjs +104 -0
- package/src/session.mjs +18 -5
- package/src/tools/bash.mjs +144 -0
- package/src/tools/file.mjs +205 -0
- package/src/tools/git.mjs +166 -0
- package/src/tools/glob.mjs +51 -0
- package/src/tools/grep.mjs +100 -0
- package/src/tools/index.mjs +22 -0
- package/src/tools/ls.mjs +36 -0
- package/src/tools/patch.mjs +226 -0
- package/src/tools/repomap-parse.mjs +168 -0
- package/src/tools/shared.mjs +257 -0
- package/src/tools/system.mjs +336 -0
- package/src/tools/web.mjs +121 -0
- package/src/tools.mjs +2 -1194
- package/src/tui/agent-turn.mjs +254 -0
- package/src/tui/ansi.mjs +32 -0
- package/src/tui/clipboard.mjs +48 -0
- package/src/tui/cmd-auto.mjs +21 -0
- package/src/tui/cmd-clear.mjs +26 -0
- package/src/tui/cmd-config.mjs +72 -0
- package/src/tui/cmd-exit.mjs +5 -0
- package/src/tui/cmd-extract.mjs +5 -0
- package/src/tui/cmd-goal.mjs +47 -0
- package/src/tui/cmd-help.mjs +25 -0
- package/src/tui/cmd-init.mjs +91 -0
- package/src/tui/cmd-mcp.mjs +146 -0
- package/src/tui/cmd-model.mjs +7 -0
- package/src/tui/cmd-new.mjs +18 -0
- package/src/tui/cmd-plan.mjs +21 -0
- package/src/tui/cmd-reindex.mjs +44 -0
- package/src/tui/cmd-restore.mjs +39 -0
- package/src/tui/cmd-session.mjs +42 -0
- package/src/tui/cmd-skills.mjs +17 -0
- package/src/tui/cmd-think.mjs +56 -0
- package/src/tui/config-helpers.mjs +34 -0
- package/src/tui/distill-cmd.mjs +45 -0
- package/src/tui/index.mjs +330 -0
- package/src/tui/interaction.mjs +79 -0
- package/src/tui/key-handler.mjs +267 -0
- package/src/tui/layout.mjs +115 -0
- package/src/tui/pickers.mjs +279 -0
- package/src/tui/render-frame.mjs +304 -0
- package/src/tui/render.mjs +205 -0
- package/src/tui/slash-commands.mjs +138 -0
- package/src/tui/startup.mjs +113 -0
- package/src/tui/wizard.mjs +168 -0
- package/src/tui-render.mjs +4 -0
- package/src/tui.mjs +3 -2566
- package/src/provider.mjs +0 -383
- /package/src/{gitmem.mjs → git/gitmem.mjs} +0 -0
- /package/src/{coder-overlay.md → prompts/coder.md} +0 -0
- /package/src/{discipline-rules.md → prompts/discipline.md} +0 -0
- /package/src/{explore-overlay.md → prompts/explore.md} +0 -0
- /package/src/{main-overlay.md → prompts/main.md} +0 -0
- /package/src/{plan-overlay.md → prompts/plan.md} +0 -0
- /package/src/{SYSTEM_PROMPT.md → prompts/system.md} +0 -0
- /package/src/{repomap.mjs → tools/repomap.mjs} +0 -0
package/src/memory.mjs
CHANGED
|
@@ -1,1410 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* memory.mjs —
|
|
3
|
-
*
|
|
4
|
-
* v2 团队版:同一接口 + git 同步层 + 向量检索 + RRF,调用方无感。
|
|
5
|
-
*
|
|
6
|
-
* entry.type ∈ rule | knowledge | decision | pattern(对齐团队记忆四类内容)
|
|
7
|
-
*
|
|
8
|
-
* 中文检索方案:FTS5 unicode61 分词 + CJK 逐字加空格(写入和查询两侧同样处理)。
|
|
9
|
-
* 效果:中文按字索引,"分号" 这类双字词也能命中;ASCII 仍按整词。
|
|
10
|
-
* 语义层面的匹配(如 "规范" vs "风格")留给 v2 向量检索。
|
|
2
|
+
* memory.mjs — 记忆系统(重新导出中心)
|
|
3
|
+
* 子模块在 src/memory/ 目录下,按职责拆分。
|
|
11
4
|
*/
|
|
12
5
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
import { readFile, readdir, stat, writeFile, mkdir } from "node:fs/promises"
|
|
16
|
-
import { dirname, join, relative } from "node:path"
|
|
17
|
-
import { parseEntry, serializeEntry, entryFilename } from "./markdown.mjs"
|
|
18
|
-
import { embed, cosine, toBlob, fromBlob } from "./embedding.mjs"
|
|
19
|
-
import { commitAndPush } from "./gitmem.mjs"
|
|
6
|
+
// schema + constants
|
|
7
|
+
export { createMemory, migrate, segmentCJK, VALID_TYPES, SCHEMA_VERSION, CODE_EXTS, DOC_EXTS, SKIP_DIRS, BIG_FILE_LINES } from "./memory/schema.mjs"
|
|
20
8
|
|
|
21
|
-
|
|
22
|
-
|
|
9
|
+
// CRUD + search + ensureEmbeddings
|
|
10
|
+
export { put, search, ftsSearch, fetchEntry, ensureEmbeddings, putMarkdown, syncDir, indexMarkdownFile, list, remove, buildFtsQuery } from "./memory/core.mjs"
|
|
23
11
|
|
|
24
|
-
//
|
|
25
|
-
|
|
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
|
|
12
|
+
// code chunking + markdown chunking
|
|
13
|
+
export { detectLanguage, extractSymbols, extractPySymbols, chunkCode, extractLeadingDoc, yieldTick, _upsertCodeFile, chunkMarkdown, _upsertDocFile } from "./memory/code-index.mjs"
|
|
32
14
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
* 返回的 memory 对象即接口,后续函数的第一个参数都是它。
|
|
36
|
-
*/
|
|
37
|
-
export function createMemory({ dbPath }) {
|
|
38
|
-
mkdirSync(dirname(dbPath), { recursive: true })
|
|
39
|
-
const db = new DatabaseSync(dbPath)
|
|
40
|
-
// WAL 读写不互锁(TUI 检索和后台索引可并发);busy_timeout 防多进程同库直接 SQLITE_BUSY
|
|
41
|
-
db.exec(`PRAGMA journal_mode = WAL`)
|
|
42
|
-
db.exec(`PRAGMA busy_timeout = 3000`)
|
|
43
|
-
|
|
44
|
-
db.exec(`
|
|
45
|
-
CREATE TABLE IF NOT EXISTS entries (
|
|
46
|
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
47
|
-
type TEXT NOT NULL CHECK(type IN ('rule','knowledge','decision','pattern')),
|
|
48
|
-
title TEXT NOT NULL,
|
|
49
|
-
content TEXT NOT NULL,
|
|
50
|
-
tags TEXT NOT NULL DEFAULT '',
|
|
51
|
-
seg_title TEXT NOT NULL DEFAULT '',
|
|
52
|
-
seg_content TEXT NOT NULL DEFAULT '',
|
|
53
|
-
seg_tags TEXT NOT NULL DEFAULT '',
|
|
54
|
-
created_at INTEGER NOT NULL,
|
|
55
|
-
updated_at INTEGER NOT NULL
|
|
56
|
-
)
|
|
57
|
-
`)
|
|
58
|
-
|
|
59
|
-
migrate(db)
|
|
60
|
-
return { db }
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/** 按 user_version 逐步迁移。整体单事务——任何一步失败都回滚,不留半成品 schema */
|
|
64
|
-
function migrate(db) {
|
|
65
|
-
const { user_version: version } = db.prepare(`PRAGMA user_version`).get()
|
|
66
|
-
if (version >= SCHEMA_VERSION) return
|
|
67
|
-
|
|
68
|
-
db.exec("BEGIN IMMEDIATE")
|
|
69
|
-
try {
|
|
70
|
-
if (version < 2) {
|
|
71
|
-
// v1(trigram) 或空库 → v2(unicode61 + CJK 逐字):重建 FTS 和触发器
|
|
72
|
-
db.exec(`
|
|
73
|
-
DROP TRIGGER IF EXISTS entries_ai;
|
|
74
|
-
DROP TRIGGER IF EXISTS entries_ad;
|
|
75
|
-
DROP TRIGGER IF EXISTS entries_au;
|
|
76
|
-
DROP TABLE IF EXISTS entries_fts;
|
|
77
|
-
`)
|
|
78
|
-
// 老库(v1)没有 seg 列,补上
|
|
79
|
-
const columns = db.prepare(`PRAGMA table_info(entries)`).all().map((c) => c.name)
|
|
80
|
-
for (const col of ["seg_title", "seg_content", "seg_tags"]) {
|
|
81
|
-
if (!columns.includes(col)) {
|
|
82
|
-
db.exec(`ALTER TABLE entries ADD COLUMN ${col} TEXT NOT NULL DEFAULT ''`)
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
// 回填 seg 列(JS 侧分字,SQL 做不了)
|
|
86
|
-
const rows = db.prepare(`SELECT id, title, content, tags FROM entries`).all()
|
|
87
|
-
const update = db.prepare(`UPDATE entries SET seg_title = ?, seg_content = ?, seg_tags = ? WHERE id = ?`)
|
|
88
|
-
for (const r of rows) {
|
|
89
|
-
update.run(segmentCJK(r.title), segmentCJK(r.content), segmentCJK(r.tags), r.id)
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
db.exec(`
|
|
93
|
-
CREATE VIRTUAL TABLE entries_fts USING fts5(
|
|
94
|
-
seg_title, seg_content, seg_tags,
|
|
95
|
-
content='entries', content_rowid='id',
|
|
96
|
-
tokenize='unicode61'
|
|
97
|
-
)
|
|
98
|
-
`)
|
|
99
|
-
db.exec(`
|
|
100
|
-
CREATE TRIGGER entries_ai AFTER INSERT ON entries BEGIN
|
|
101
|
-
INSERT INTO entries_fts(rowid, seg_title, seg_content, seg_tags)
|
|
102
|
-
VALUES (new.id, new.seg_title, new.seg_content, new.seg_tags);
|
|
103
|
-
END;
|
|
104
|
-
CREATE TRIGGER entries_ad AFTER DELETE ON entries BEGIN
|
|
105
|
-
INSERT INTO entries_fts(entries_fts, rowid, seg_title, seg_content, seg_tags)
|
|
106
|
-
VALUES ('delete', old.id, old.seg_title, old.seg_content, old.seg_tags);
|
|
107
|
-
END;
|
|
108
|
-
CREATE TRIGGER entries_au AFTER UPDATE ON entries BEGIN
|
|
109
|
-
INSERT INTO entries_fts(entries_fts, rowid, seg_title, seg_content, seg_tags)
|
|
110
|
-
VALUES ('delete', old.id, old.seg_title, old.seg_content, old.seg_tags);
|
|
111
|
-
INSERT INTO entries_fts(rowid, seg_title, seg_content, seg_tags)
|
|
112
|
-
VALUES (new.id, new.seg_title, new.seg_content, new.seg_tags);
|
|
113
|
-
END;
|
|
114
|
-
`)
|
|
115
|
-
db.exec(`INSERT INTO entries_fts(entries_fts) VALUES('rebuild')`)
|
|
116
|
-
db.exec(`PRAGMA user_version = 2`)
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
if (version < 3) {
|
|
120
|
-
// v3:markdown 层(project/team)的 files 表 + FTS + 触发器
|
|
121
|
-
db.exec(`
|
|
122
|
-
CREATE TABLE IF NOT EXISTS files (
|
|
123
|
-
layer TEXT NOT NULL CHECK(layer IN ('project','team')),
|
|
124
|
-
path TEXT NOT NULL,
|
|
125
|
-
type TEXT NOT NULL CHECK(type IN ('rule','knowledge','decision','pattern')),
|
|
126
|
-
title TEXT NOT NULL,
|
|
127
|
-
content TEXT NOT NULL,
|
|
128
|
-
tags TEXT NOT NULL DEFAULT '',
|
|
129
|
-
author TEXT NOT NULL DEFAULT '',
|
|
130
|
-
embedding BLOB,
|
|
131
|
-
mtime_ms INTEGER NOT NULL DEFAULT 0,
|
|
132
|
-
seg_title TEXT NOT NULL DEFAULT '',
|
|
133
|
-
seg_content TEXT NOT NULL DEFAULT '',
|
|
134
|
-
seg_tags TEXT NOT NULL DEFAULT '',
|
|
135
|
-
updated_at INTEGER NOT NULL,
|
|
136
|
-
PRIMARY KEY (layer, path)
|
|
137
|
-
)
|
|
138
|
-
`)
|
|
139
|
-
db.exec(`
|
|
140
|
-
CREATE VIRTUAL TABLE IF NOT EXISTS files_fts USING fts5(
|
|
141
|
-
seg_title, seg_content, seg_tags,
|
|
142
|
-
content='files', content_rowid='rowid',
|
|
143
|
-
tokenize='unicode61'
|
|
144
|
-
)
|
|
145
|
-
`)
|
|
146
|
-
db.exec(`
|
|
147
|
-
CREATE TRIGGER files_ai AFTER INSERT ON files BEGIN
|
|
148
|
-
INSERT INTO files_fts(rowid, seg_title, seg_content, seg_tags)
|
|
149
|
-
VALUES (new.rowid, new.seg_title, new.seg_content, new.seg_tags);
|
|
150
|
-
END;
|
|
151
|
-
CREATE TRIGGER files_ad AFTER DELETE ON files BEGIN
|
|
152
|
-
INSERT INTO files_fts(files_fts, rowid, seg_title, seg_content, seg_tags)
|
|
153
|
-
VALUES ('delete', old.rowid, old.seg_title, old.seg_content, old.seg_tags);
|
|
154
|
-
END;
|
|
155
|
-
CREATE TRIGGER files_au AFTER UPDATE ON files BEGIN
|
|
156
|
-
INSERT INTO files_fts(files_fts, rowid, seg_title, seg_content, seg_tags)
|
|
157
|
-
VALUES ('delete', old.rowid, old.seg_title, old.seg_content, old.seg_tags);
|
|
158
|
-
INSERT INTO files_fts(rowid, seg_title, seg_content, seg_tags)
|
|
159
|
-
VALUES (new.rowid, new.seg_title, new.seg_content, new.seg_tags);
|
|
160
|
-
END;
|
|
161
|
-
`)
|
|
162
|
-
db.exec(`PRAGMA user_version = 3`)
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
if (version < 4) {
|
|
166
|
-
// v4:personal 的 entries 表加向量列(files 表在 v3 已带);meta 表存 embedding 模型名
|
|
167
|
-
const columns = db.prepare(`PRAGMA table_info(entries)`).all().map((c) => c.name)
|
|
168
|
-
if (!columns.includes("embedding")) {
|
|
169
|
-
db.exec(`ALTER TABLE entries ADD COLUMN embedding BLOB`)
|
|
170
|
-
}
|
|
171
|
-
db.exec(`CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT)`)
|
|
172
|
-
db.exec(`PRAGMA user_version = 4`)
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
if (version < 5) {
|
|
176
|
-
// v5:files 表加 origin 列(项目绝对路径),防止跨项目记忆串台
|
|
177
|
-
const columns = db.prepare(`PRAGMA table_info(files)`).all().map((c) => c.name)
|
|
178
|
-
if (!columns.includes("origin")) {
|
|
179
|
-
db.exec(`ALTER TABLE files ADD COLUMN origin TEXT NOT NULL DEFAULT ''`)
|
|
180
|
-
}
|
|
181
|
-
db.exec(`PRAGMA user_version = 5`)
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
if (version < 6) {
|
|
185
|
-
// v6:代码索引——code_chunks 表 + FTS5(与 files 表同样模式)
|
|
186
|
-
db.exec(`
|
|
187
|
-
CREATE TABLE IF NOT EXISTS code_chunks (
|
|
188
|
-
path TEXT NOT NULL,
|
|
189
|
-
language TEXT NOT NULL,
|
|
190
|
-
chunk_type TEXT NOT NULL CHECK(chunk_type IN ('file','symbol')),
|
|
191
|
-
symbol_name TEXT NOT NULL DEFAULT '',
|
|
192
|
-
content TEXT NOT NULL,
|
|
193
|
-
line_start INTEGER NOT NULL DEFAULT 0,
|
|
194
|
-
line_end INTEGER NOT NULL DEFAULT 0,
|
|
195
|
-
mtime_ms INTEGER NOT NULL DEFAULT 0,
|
|
196
|
-
embedding BLOB,
|
|
197
|
-
seg_content TEXT NOT NULL DEFAULT '',
|
|
198
|
-
PRIMARY KEY (path, line_start)
|
|
199
|
-
)
|
|
200
|
-
`)
|
|
201
|
-
db.exec(`
|
|
202
|
-
CREATE VIRTUAL TABLE IF NOT EXISTS code_chunks_fts USING fts5(
|
|
203
|
-
path, symbol_name, seg_content,
|
|
204
|
-
content='code_chunks', content_rowid='rowid',
|
|
205
|
-
tokenize='unicode61'
|
|
206
|
-
)
|
|
207
|
-
`)
|
|
208
|
-
db.exec(`
|
|
209
|
-
CREATE TRIGGER code_chunks_ai AFTER INSERT ON code_chunks BEGIN
|
|
210
|
-
INSERT INTO code_chunks_fts(rowid, path, symbol_name, seg_content)
|
|
211
|
-
VALUES (new.rowid, new.path, new.symbol_name, new.seg_content);
|
|
212
|
-
END;
|
|
213
|
-
CREATE TRIGGER code_chunks_ad AFTER DELETE ON code_chunks BEGIN
|
|
214
|
-
INSERT INTO code_chunks_fts(code_chunks_fts, rowid, path, symbol_name, seg_content)
|
|
215
|
-
VALUES ('delete', old.rowid, old.path, old.symbol_name, old.seg_content);
|
|
216
|
-
END;
|
|
217
|
-
CREATE TRIGGER code_chunks_au AFTER UPDATE ON code_chunks BEGIN
|
|
218
|
-
INSERT INTO code_chunks_fts(code_chunks_fts, rowid, path, symbol_name, seg_content)
|
|
219
|
-
VALUES ('delete', old.rowid, old.path, old.symbol_name, old.seg_content);
|
|
220
|
-
INSERT INTO code_chunks_fts(rowid, path, symbol_name, seg_content)
|
|
221
|
-
VALUES (new.rowid, new.path, new.symbol_name, new.seg_content);
|
|
222
|
-
END;
|
|
223
|
-
`)
|
|
224
|
-
db.exec(`PRAGMA user_version = 6`)
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
if (version < 7) {
|
|
228
|
-
// v7:文档索引——doc_chunks 表 + FTS5(与 code_chunks 同模式),markdown 按 ## 标题分块
|
|
229
|
-
db.exec(`
|
|
230
|
-
CREATE TABLE IF NOT EXISTS doc_chunks (
|
|
231
|
-
path TEXT NOT NULL,
|
|
232
|
-
language TEXT NOT NULL DEFAULT 'markdown',
|
|
233
|
-
heading TEXT NOT NULL DEFAULT '',
|
|
234
|
-
content TEXT NOT NULL,
|
|
235
|
-
line_start INTEGER NOT NULL DEFAULT 0,
|
|
236
|
-
line_end INTEGER NOT NULL DEFAULT 0,
|
|
237
|
-
mtime_ms INTEGER NOT NULL DEFAULT 0,
|
|
238
|
-
embedding BLOB,
|
|
239
|
-
seg_content TEXT NOT NULL DEFAULT '',
|
|
240
|
-
PRIMARY KEY (path, line_start)
|
|
241
|
-
)
|
|
242
|
-
`)
|
|
243
|
-
db.exec(`
|
|
244
|
-
CREATE VIRTUAL TABLE IF NOT EXISTS doc_chunks_fts USING fts5(
|
|
245
|
-
path, heading, seg_content,
|
|
246
|
-
content='doc_chunks', content_rowid='rowid',
|
|
247
|
-
tokenize='unicode61'
|
|
248
|
-
)
|
|
249
|
-
`)
|
|
250
|
-
db.exec(`
|
|
251
|
-
CREATE TRIGGER doc_chunks_ai AFTER INSERT ON doc_chunks BEGIN
|
|
252
|
-
INSERT INTO doc_chunks_fts(rowid, path, heading, seg_content)
|
|
253
|
-
VALUES (new.rowid, new.path, new.heading, new.seg_content);
|
|
254
|
-
END;
|
|
255
|
-
CREATE TRIGGER doc_chunks_ad AFTER DELETE ON doc_chunks BEGIN
|
|
256
|
-
INSERT INTO doc_chunks_fts(doc_chunks_fts, rowid, path, heading, seg_content)
|
|
257
|
-
VALUES ('delete', old.rowid, old.path, old.heading, old.seg_content);
|
|
258
|
-
END;
|
|
259
|
-
CREATE TRIGGER doc_chunks_au AFTER UPDATE ON doc_chunks BEGIN
|
|
260
|
-
INSERT INTO doc_chunks_fts(doc_chunks_fts, rowid, path, heading, seg_content)
|
|
261
|
-
VALUES ('delete', old.rowid, old.path, old.heading, old.seg_content);
|
|
262
|
-
INSERT INTO doc_chunks_fts(rowid, path, heading, seg_content)
|
|
263
|
-
VALUES (new.rowid, new.path, new.heading, new.seg_content);
|
|
264
|
-
END;
|
|
265
|
-
`)
|
|
266
|
-
db.exec(`PRAGMA user_version = 7`)
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
if (version < 8) {
|
|
270
|
-
// v8:code_chunks/doc_chunks 加 origin 列(项目根目录绝对路径),主键改为 (origin, path, line_start)。
|
|
271
|
-
// 旧主键不含 origin,多项目共用一个记忆库时同相对路径互相覆盖、codeSync(B) 会把 A 的块当 stale 清掉。
|
|
272
|
-
// SQLite 不能 ALTER 主键,且索引是易失品 → 直接删表重建(下次 codeSync/docSync 自动重索引)。
|
|
273
|
-
db.exec(`
|
|
274
|
-
DROP TRIGGER IF EXISTS code_chunks_ai;
|
|
275
|
-
DROP TRIGGER IF EXISTS code_chunks_ad;
|
|
276
|
-
DROP TRIGGER IF EXISTS code_chunks_au;
|
|
277
|
-
DROP TABLE IF EXISTS code_chunks_fts;
|
|
278
|
-
DROP TABLE IF EXISTS code_chunks;
|
|
279
|
-
DROP TRIGGER IF EXISTS doc_chunks_ai;
|
|
280
|
-
DROP TRIGGER IF EXISTS doc_chunks_ad;
|
|
281
|
-
DROP TRIGGER IF EXISTS doc_chunks_au;
|
|
282
|
-
DROP TABLE IF EXISTS doc_chunks_fts;
|
|
283
|
-
DROP TABLE IF EXISTS doc_chunks;
|
|
284
|
-
`)
|
|
285
|
-
db.exec(`
|
|
286
|
-
CREATE TABLE code_chunks (
|
|
287
|
-
origin TEXT NOT NULL DEFAULT '',
|
|
288
|
-
path TEXT NOT NULL,
|
|
289
|
-
language TEXT NOT NULL,
|
|
290
|
-
chunk_type TEXT NOT NULL CHECK(chunk_type IN ('file','symbol')),
|
|
291
|
-
symbol_name TEXT NOT NULL DEFAULT '',
|
|
292
|
-
content TEXT NOT NULL,
|
|
293
|
-
line_start INTEGER NOT NULL DEFAULT 0,
|
|
294
|
-
line_end INTEGER NOT NULL DEFAULT 0,
|
|
295
|
-
mtime_ms INTEGER NOT NULL DEFAULT 0,
|
|
296
|
-
embedding BLOB,
|
|
297
|
-
seg_content TEXT NOT NULL DEFAULT '',
|
|
298
|
-
PRIMARY KEY (origin, path, line_start)
|
|
299
|
-
)
|
|
300
|
-
`)
|
|
301
|
-
db.exec(`
|
|
302
|
-
CREATE VIRTUAL TABLE code_chunks_fts USING fts5(
|
|
303
|
-
path, symbol_name, seg_content,
|
|
304
|
-
content='code_chunks', content_rowid='rowid',
|
|
305
|
-
tokenize='unicode61'
|
|
306
|
-
)
|
|
307
|
-
`)
|
|
308
|
-
db.exec(`
|
|
309
|
-
CREATE TRIGGER code_chunks_ai AFTER INSERT ON code_chunks BEGIN
|
|
310
|
-
INSERT INTO code_chunks_fts(rowid, path, symbol_name, seg_content)
|
|
311
|
-
VALUES (new.rowid, new.path, new.symbol_name, new.seg_content);
|
|
312
|
-
END;
|
|
313
|
-
CREATE TRIGGER code_chunks_ad AFTER DELETE ON code_chunks BEGIN
|
|
314
|
-
INSERT INTO code_chunks_fts(code_chunks_fts, rowid, path, symbol_name, seg_content)
|
|
315
|
-
VALUES ('delete', old.rowid, old.path, old.symbol_name, old.seg_content);
|
|
316
|
-
END;
|
|
317
|
-
CREATE TRIGGER code_chunks_au AFTER UPDATE ON code_chunks BEGIN
|
|
318
|
-
INSERT INTO code_chunks_fts(code_chunks_fts, rowid, path, symbol_name, seg_content)
|
|
319
|
-
VALUES ('delete', old.rowid, old.path, old.symbol_name, old.seg_content);
|
|
320
|
-
INSERT INTO code_chunks_fts(rowid, path, symbol_name, seg_content)
|
|
321
|
-
VALUES (new.rowid, new.path, new.symbol_name, new.seg_content);
|
|
322
|
-
END;
|
|
323
|
-
`)
|
|
324
|
-
db.exec(`
|
|
325
|
-
CREATE TABLE doc_chunks (
|
|
326
|
-
origin TEXT NOT NULL DEFAULT '',
|
|
327
|
-
path TEXT NOT NULL,
|
|
328
|
-
language TEXT NOT NULL DEFAULT 'markdown',
|
|
329
|
-
heading TEXT NOT NULL DEFAULT '',
|
|
330
|
-
content TEXT NOT NULL,
|
|
331
|
-
line_start INTEGER NOT NULL DEFAULT 0,
|
|
332
|
-
line_end INTEGER NOT NULL DEFAULT 0,
|
|
333
|
-
mtime_ms INTEGER NOT NULL DEFAULT 0,
|
|
334
|
-
embedding BLOB,
|
|
335
|
-
seg_content TEXT NOT NULL DEFAULT '',
|
|
336
|
-
PRIMARY KEY (origin, path, line_start)
|
|
337
|
-
)
|
|
338
|
-
`)
|
|
339
|
-
db.exec(`
|
|
340
|
-
CREATE VIRTUAL TABLE doc_chunks_fts USING fts5(
|
|
341
|
-
path, heading, seg_content,
|
|
342
|
-
content='doc_chunks', content_rowid='rowid',
|
|
343
|
-
tokenize='unicode61'
|
|
344
|
-
)
|
|
345
|
-
`)
|
|
346
|
-
db.exec(`
|
|
347
|
-
CREATE TRIGGER doc_chunks_ai AFTER INSERT ON doc_chunks BEGIN
|
|
348
|
-
INSERT INTO doc_chunks_fts(rowid, path, heading, seg_content)
|
|
349
|
-
VALUES (new.rowid, new.path, new.heading, new.seg_content);
|
|
350
|
-
END;
|
|
351
|
-
CREATE TRIGGER doc_chunks_ad AFTER DELETE ON doc_chunks BEGIN
|
|
352
|
-
INSERT INTO doc_chunks_fts(doc_chunks_fts, rowid, path, heading, seg_content)
|
|
353
|
-
VALUES ('delete', old.rowid, old.path, old.heading, old.seg_content);
|
|
354
|
-
END;
|
|
355
|
-
CREATE TRIGGER doc_chunks_au AFTER UPDATE ON doc_chunks BEGIN
|
|
356
|
-
INSERT INTO doc_chunks_fts(doc_chunks_fts, rowid, path, heading, seg_content)
|
|
357
|
-
VALUES ('delete', old.rowid, old.path, old.heading, old.seg_content);
|
|
358
|
-
INSERT INTO doc_chunks_fts(rowid, path, heading, seg_content)
|
|
359
|
-
VALUES (new.rowid, new.path, new.heading, new.seg_content);
|
|
360
|
-
END;
|
|
361
|
-
`)
|
|
362
|
-
db.exec(`PRAGMA user_version = 8`)
|
|
363
|
-
}
|
|
364
|
-
db.exec("COMMIT")
|
|
365
|
-
} catch (err) {
|
|
366
|
-
db.exec("ROLLBACK")
|
|
367
|
-
throw err
|
|
368
|
-
}
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
/**
|
|
372
|
-
* CJK 逐字加空格:让 unicode61 把每个汉字/日韩字当独立 token。
|
|
373
|
-
* 写入和查询必须使用同一处理,检索才能对上。
|
|
374
|
-
*/
|
|
375
|
-
function segmentCJK(text) {
|
|
376
|
-
return text.replace(
|
|
377
|
-
/[-ヿ㐀-䶿一-鿿豈-가-]+/g,
|
|
378
|
-
(run) => [...run].join(" "),
|
|
379
|
-
)
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
/**
|
|
383
|
-
* 写入一条记忆。entry: { type, title, content, tags? }
|
|
384
|
-
* 返回新条目 id。
|
|
385
|
-
*/
|
|
386
|
-
export async function put(memory, { type, title, content, tags = "" }) {
|
|
387
|
-
if (!VALID_TYPES.has(type)) {
|
|
388
|
-
throw new Error(`Invalid memory type "${type}"; expected one of: ${[...VALID_TYPES].join(", ")}`)
|
|
389
|
-
}
|
|
390
|
-
if (!title || !content) throw new Error("memory entry requires title and content")
|
|
391
|
-
const now = Date.now()
|
|
392
|
-
const stmt = memory.db.prepare(
|
|
393
|
-
`INSERT INTO entries (type, title, content, tags, seg_title, seg_content, seg_tags, created_at, updated_at)
|
|
394
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
395
|
-
)
|
|
396
|
-
const info = stmt.run(type, title, content, tags, segmentCJK(title), segmentCJK(content), segmentCJK(tags), now, now)
|
|
397
|
-
return Number(info.lastInsertRowid)
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
/**
|
|
401
|
-
* 混合检索:FTS5(BM25) + 向量余弦,RRF(k=60) 合并排序。
|
|
402
|
-
* 无 embedder 时退化为纯 FTS。结果带 layer 标记。
|
|
403
|
-
* 返回 [{ id, layer, type, title, content, tags, rank }]
|
|
404
|
-
*/
|
|
405
|
-
export async function search(memory, query, { limit = 5 } = {}) {
|
|
406
|
-
const ftsQuery = buildFtsQuery(query)
|
|
407
|
-
const ftsList = ftsQuery ? ftsSearch(memory, ftsQuery, Math.max(limit * 4, 20)) : []
|
|
408
|
-
|
|
409
|
-
if (!memory.embedder) return ftsList.slice(0, limit)
|
|
410
|
-
|
|
411
|
-
// ---- 向量通道 ----
|
|
412
|
-
await ensureEmbeddings(memory)
|
|
413
|
-
const [qvec] = await embed(memory.embedder, [query])
|
|
414
|
-
const vecFilter = memory.projectOrigin ? `AND (layer = 'team' OR origin = ?)` : ""
|
|
415
|
-
const vecParams = memory.projectOrigin ? [memory.projectOrigin] : []
|
|
416
|
-
const rows = memory.db.prepare(`
|
|
417
|
-
SELECT 'personal:' || id AS uid, embedding FROM entries WHERE embedding IS NOT NULL
|
|
418
|
-
UNION ALL
|
|
419
|
-
SELECT layer || ':' || path AS uid, embedding FROM files WHERE embedding IS NOT NULL ${vecFilter}
|
|
420
|
-
`).all(...vecParams)
|
|
421
|
-
const vecList = rows
|
|
422
|
-
.map((r) => ({ id: r.uid, score: cosine(qvec, fromBlob(r.embedding)) }))
|
|
423
|
-
.sort((a, b) => b.score - a.score)
|
|
424
|
-
.slice(0, Math.max(limit * 4, 20))
|
|
425
|
-
|
|
426
|
-
// ---- RRF 合并 ----
|
|
427
|
-
const K = 60
|
|
428
|
-
const scores = new Map()
|
|
429
|
-
ftsList.forEach((r, i) => scores.set(r.id, (scores.get(r.id) ?? 0) + 1 / (K + i + 1)))
|
|
430
|
-
vecList.forEach((r, i) => scores.set(r.id, (scores.get(r.id) ?? 0) + 1 / (K + i + 1)))
|
|
431
|
-
|
|
432
|
-
return [...scores.entries()]
|
|
433
|
-
.sort((a, b) => b[1] - a[1])
|
|
434
|
-
.slice(0, limit)
|
|
435
|
-
.map(([id, score]) => {
|
|
436
|
-
const entry = fetchEntry(memory, id)
|
|
437
|
-
return entry ? { ...entry, rrf: score } : null // fetchEntry 为 null 时不能展开(会漏出 { rrf } 空壳)
|
|
438
|
-
})
|
|
439
|
-
.filter(Boolean)
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
/** 纯 FTS 检索(两表合并,按 bm25 排序),RRF 的位置输入 */
|
|
443
|
-
function ftsSearch(memory, ftsQuery, limit) {
|
|
444
|
-
const personal = memory.db.prepare(`
|
|
445
|
-
SELECT e.id, e.type, e.title, e.content, e.tags, bm25(entries_fts) AS rank
|
|
446
|
-
FROM entries_fts JOIN entries e ON e.id = entries_fts.rowid
|
|
447
|
-
WHERE entries_fts MATCH ?
|
|
448
|
-
ORDER BY rank LIMIT ?
|
|
449
|
-
`).all(ftsQuery, limit).map((r) => ({ ...r, layer: "personal", id: `personal:${r.id}` }))
|
|
450
|
-
|
|
451
|
-
// projectOrigin 设置时只返回本项目的 project 条目(team 层不过滤);未设置时(全局 CLI)不过滤
|
|
452
|
-
const originFilter = memory.projectOrigin ? `AND (f.layer = 'team' OR f.origin = ?)` : ""
|
|
453
|
-
const originParams = memory.projectOrigin ? [ftsQuery, memory.projectOrigin, limit] : [ftsQuery, limit]
|
|
454
|
-
const files = memory.db.prepare(`
|
|
455
|
-
SELECT f.layer, f.path, f.type, f.title, f.content, f.tags, f.author, bm25(files_fts) AS rank
|
|
456
|
-
FROM files_fts JOIN files f ON f.rowid = files_fts.rowid
|
|
457
|
-
WHERE files_fts MATCH ? ${originFilter}
|
|
458
|
-
ORDER BY rank LIMIT ?
|
|
459
|
-
`).all(...originParams).map((r) => ({ ...r, id: `${r.layer}:${r.path}` }))
|
|
460
|
-
|
|
461
|
-
return [...personal, ...files].sort((a, b) => a.rank - b.rank).slice(0, limit)
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
/** 按统一 id 取完整条目(personal:<n> / project:<path> / team:<path>) */
|
|
465
|
-
function fetchEntry(memory, uid) {
|
|
466
|
-
const [layer, ...rest] = uid.split(":")
|
|
467
|
-
const key = rest.join(":")
|
|
468
|
-
if (layer === "personal") {
|
|
469
|
-
const r = memory.db.prepare(`SELECT id, type, title, content, tags FROM entries WHERE id = ?`).get(Number(key))
|
|
470
|
-
return r ? { ...r, layer, id: uid } : null
|
|
471
|
-
}
|
|
472
|
-
const r = memory.db.prepare(`SELECT type, title, content, tags, author FROM files WHERE layer = ? AND path = ?`).get(layer, key)
|
|
473
|
-
return r ? { ...r, layer, id: uid } : null
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
/**
|
|
477
|
-
* 惰性 embedding:把还没有向量的条目批量补算落库(首次慢、后续零成本)。
|
|
478
|
-
* 检测到 embedding 模型变更时,清空全部向量重建。
|
|
479
|
-
*/
|
|
480
|
-
async function ensureEmbeddings(memory) {
|
|
481
|
-
const modelKey = memory.embedder.model
|
|
482
|
-
const stored = memory.db.prepare(`SELECT value FROM meta WHERE key = 'embedding_model'`).get()?.value
|
|
483
|
-
if (stored !== modelKey) {
|
|
484
|
-
memory.db.prepare(`UPDATE entries SET embedding = NULL`).run()
|
|
485
|
-
memory.db.prepare(`UPDATE files SET embedding = NULL`).run()
|
|
486
|
-
memory.db.prepare(`INSERT INTO meta (key, value) VALUES ('embedding_model', ?)
|
|
487
|
-
ON CONFLICT (key) DO UPDATE SET value = excluded.value`).run(modelKey)
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
const pendingEntries = memory.db.prepare(`SELECT id, title, content FROM entries WHERE embedding IS NULL LIMIT 256`).all()
|
|
491
|
-
const pendingFiles = memory.db.prepare(`SELECT rowid, title, content FROM files WHERE embedding IS NULL LIMIT 256`).all()
|
|
492
|
-
if (pendingEntries.length + pendingFiles.length === 0) {
|
|
493
|
-
// 记忆不算 pending,也补一下代码和文档块的向量
|
|
494
|
-
await ensureCodeEmbeddings(memory)
|
|
495
|
-
await ensureDocEmbeddings(memory)
|
|
496
|
-
return
|
|
497
|
-
}
|
|
498
|
-
|
|
499
|
-
const items = [...pendingEntries, ...pendingFiles]
|
|
500
|
-
const texts = items.map((r) => `${r.title}\n${r.content.slice(0, 2000)}`)
|
|
501
|
-
const vecs = await embed(memory.embedder, texts)
|
|
502
|
-
|
|
503
|
-
const updateEntry = memory.db.prepare(`UPDATE entries SET embedding = ? WHERE id = ?`)
|
|
504
|
-
pendingEntries.forEach((r, i) => updateEntry.run(toBlob(vecs[i]), r.id))
|
|
505
|
-
const updateFile = memory.db.prepare(`UPDATE files SET embedding = ? WHERE rowid = ?`)
|
|
506
|
-
pendingFiles.forEach((r, i) => updateFile.run(toBlob(vecs[pendingEntries.length + i]), r.rowid))
|
|
507
|
-
|
|
508
|
-
// 每批嵌入后也补一下代码和文档块
|
|
509
|
-
await ensureCodeEmbeddings(memory)
|
|
510
|
-
await ensureDocEmbeddings(memory)
|
|
511
|
-
}
|
|
512
|
-
|
|
513
|
-
/**
|
|
514
|
-
* 写入一条 markdown 记忆到指定层目录(project/team),并即时索引。
|
|
515
|
-
* 只写文件——project 层绝不替用户的项目仓库做 git 操作;
|
|
516
|
-
* team 层的 commit+push 由 gitmem.mjs 负责(M8)。
|
|
517
|
-
* 返回文件名。
|
|
518
|
-
*/
|
|
519
|
-
export async function putMarkdown(memory, { layer, dir, type, title, content, tags = [], author = "unknown" }) {
|
|
520
|
-
if (layer !== "project" && layer !== "team") throw new Error(`invalid markdown layer: ${layer}`)
|
|
521
|
-
const filename = entryFilename(title)
|
|
522
|
-
const markdown = serializeEntry({ type, title, tags, author }, content)
|
|
523
|
-
await mkdir(dir, { recursive: true })
|
|
524
|
-
await writeFile(join(dir, filename), markdown, "utf8")
|
|
525
|
-
await indexMarkdownFile(memory, { layer, dir, filename })
|
|
526
|
-
return filename
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
/**
|
|
530
|
-
* 同步一个 markdown 目录到索引:新增/变更(按 mtime)重建索引,消失的条目从索引删除。
|
|
531
|
-
* 返回 { added, updated, removed }
|
|
532
|
-
*/
|
|
533
|
-
export async function syncDir(memory, { layer, dir }) {
|
|
534
|
-
let names = []
|
|
535
|
-
try {
|
|
536
|
-
names = (await readdir(dir)).filter((n) => n.endsWith(".md"))
|
|
537
|
-
} catch {
|
|
538
|
-
names = [] // 目录不存在 = 这层没内容
|
|
539
|
-
}
|
|
540
|
-
|
|
541
|
-
const indexed = new Map(
|
|
542
|
-
memory.db.prepare(`SELECT path, mtime_ms FROM files WHERE layer = ?`).all(layer).map((r) => [r.path, r.mtime_ms]),
|
|
543
|
-
)
|
|
544
|
-
|
|
545
|
-
let added = 0, updated = 0, skipped = 0
|
|
546
|
-
for (const filename of names) {
|
|
547
|
-
const mtimeMs = Math.floor((await stat(join(dir, filename))).mtimeMs)
|
|
548
|
-
const old = indexed.get(filename)
|
|
549
|
-
const isNew = old === undefined
|
|
550
|
-
if (!isNew && old === mtimeMs) continue
|
|
551
|
-
try {
|
|
552
|
-
await indexMarkdownFile(memory, { layer, dir, filename, mtimeMs })
|
|
553
|
-
} catch {
|
|
554
|
-
skipped++ // 无 frontmatter 的非条目文件(README 等)跳过,不入索引
|
|
555
|
-
indexed.delete(filename)
|
|
556
|
-
continue
|
|
557
|
-
}
|
|
558
|
-
if (isNew) added++
|
|
559
|
-
else updated++
|
|
560
|
-
indexed.delete(filename)
|
|
561
|
-
}
|
|
562
|
-
|
|
563
|
-
// 索引里剩下的是磁盘上已消失的
|
|
564
|
-
let removed = 0
|
|
565
|
-
for (const stale of indexed.keys()) {
|
|
566
|
-
memory.db.prepare(`DELETE FROM files WHERE layer = ? AND path = ?`).run(layer, stale)
|
|
567
|
-
removed++
|
|
568
|
-
}
|
|
569
|
-
return { added, updated, removed, skipped }
|
|
570
|
-
}
|
|
571
|
-
|
|
572
|
-
/** 解析单个 .md 并 upsert 进 files 表 */
|
|
573
|
-
async function indexMarkdownFile(memory, { layer, dir, filename, mtimeMs }) {
|
|
574
|
-
const abs = join(dir, filename)
|
|
575
|
-
const mtime = mtimeMs ?? Math.floor((await stat(abs)).mtimeMs)
|
|
576
|
-
const { meta, content } = parseEntry(await readFile(abs, "utf8"))
|
|
577
|
-
const tags = meta.tags.join(" ")
|
|
578
|
-
memory.db.prepare(`
|
|
579
|
-
INSERT INTO files (layer, path, type, title, content, tags, author, mtime_ms, origin, seg_title, seg_content, seg_tags, updated_at)
|
|
580
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
581
|
-
ON CONFLICT (layer, path) DO UPDATE SET
|
|
582
|
-
type=excluded.type, title=excluded.title, content=excluded.content, tags=excluded.tags,
|
|
583
|
-
author=excluded.author, mtime_ms=excluded.mtime_ms, origin=excluded.origin,
|
|
584
|
-
seg_title=excluded.seg_title, seg_content=excluded.seg_content, seg_tags=excluded.seg_tags,
|
|
585
|
-
updated_at=excluded.updated_at
|
|
586
|
-
`).run(
|
|
587
|
-
layer, filename, meta.type, meta.title, content, tags, meta.author, mtime, dir,
|
|
588
|
-
segmentCJK(meta.title), segmentCJK(content), segmentCJK(tags), Date.now(),
|
|
589
|
-
)
|
|
590
|
-
}
|
|
591
|
-
|
|
592
|
-
/** 列出新条目,可按 type 过滤 */
|
|
593
|
-
export async function list(memory, { type, limit = 50 } = {}) {
|
|
594
|
-
if (type) {
|
|
595
|
-
if (!VALID_TYPES.has(type)) throw new Error(`Invalid memory type "${type}"`)
|
|
596
|
-
return memory.db
|
|
597
|
-
.prepare(`SELECT id, type, title, content, tags, updated_at FROM entries WHERE type = ? ORDER BY updated_at DESC LIMIT ?`)
|
|
598
|
-
.all(type, limit)
|
|
599
|
-
}
|
|
600
|
-
return memory.db
|
|
601
|
-
.prepare(`SELECT id, type, title, content, tags, updated_at FROM entries ORDER BY updated_at DESC LIMIT ?`)
|
|
602
|
-
.all(limit)
|
|
603
|
-
}
|
|
604
|
-
|
|
605
|
-
/** 删除一条记忆。返回是否删除成功 */
|
|
606
|
-
export async function remove(memory, id) {
|
|
607
|
-
const info = memory.db.prepare(`DELETE FROM entries WHERE id = ?`).run(id)
|
|
608
|
-
return info.changes > 0
|
|
609
|
-
}
|
|
610
|
-
|
|
611
|
-
/**
|
|
612
|
-
* 构造 FTS5 查询:先对查询做同样的 CJK 分字,再按空白/标点切词,
|
|
613
|
-
* 每个词加引号,OR 连接(AND 太严格:自然语言查询一词不中全灭)。
|
|
614
|
-
*/
|
|
615
|
-
function buildFtsQuery(query) {
|
|
616
|
-
const terms = segmentCJK(query)
|
|
617
|
-
.split(/[\s,,。、;;!!??()()"`]+/)
|
|
618
|
-
.map((t) => t.trim())
|
|
619
|
-
.filter(Boolean)
|
|
620
|
-
.slice(0, 16)
|
|
621
|
-
if (terms.length === 0) return ""
|
|
622
|
-
return terms.map((t) => `"${t.replaceAll('"', '""')}"`).join(" OR ")
|
|
623
|
-
}
|
|
624
|
-
|
|
625
|
-
// ========== 代码索引 ==========
|
|
626
|
-
|
|
627
|
-
/** 推断文件语言(按扩展名) */
|
|
628
|
-
function detectLanguage(filename) {
|
|
629
|
-
const ext = filename.slice(filename.lastIndexOf(".")).toLowerCase()
|
|
630
|
-
const map = {
|
|
631
|
-
".mjs": "javascript", ".js": "javascript", ".jsx": "jsx", ".ts": "typescript", ".tsx": "tsx",
|
|
632
|
-
".py": "python", ".rs": "rust", ".go": "go", ".java": "java",
|
|
633
|
-
".c": "c", ".h": "c", ".cpp": "cpp", ".hpp": "cpp",
|
|
634
|
-
".rb": "ruby", ".swift": "swift", ".kt": "kotlin",
|
|
635
|
-
".sh": "bash", ".bash": "bash", ".sql": "sql",
|
|
636
|
-
".yaml": "yaml", ".yml": "yaml", ".toml": "toml", ".json": "json",
|
|
637
|
-
".css": "css", ".html": "html", ".vue": "vue", ".svelte": "svelte",
|
|
638
|
-
".md": "markdown", ".mdc": "markdown",
|
|
639
|
-
}
|
|
640
|
-
return map[ext] ?? ext.slice(1)
|
|
641
|
-
}
|
|
642
|
-
|
|
643
|
-
/**
|
|
644
|
-
* 用正则提取 JS/TS 文件的顶层符号声明(函数、类、const 导出等)。
|
|
645
|
-
* 返回 [{ name, line, kind }]。
|
|
646
|
-
* 不解析 AST——够识别 "在哪里定义了什么" 就够用。
|
|
647
|
-
*/
|
|
648
|
-
function extractSymbols(lines, ext) {
|
|
649
|
-
const jsish = new Set([".mjs", ".js", ".ts", ".jsx", ".tsx"])
|
|
650
|
-
if (!jsish.has(ext)) return []
|
|
651
|
-
|
|
652
|
-
const symbols = []
|
|
653
|
-
const text = lines.join("\n")
|
|
654
|
-
// 顶层 export/函数/类(不在注释内的简化匹配)
|
|
655
|
-
const re = /(?:export\s+)?(?:(?:async\s+)?function\s+(\w+)|class\s+(\w+)|(?:export\s+)?(?:const|let|var)\s+(\w+))/gm
|
|
656
|
-
let m
|
|
657
|
-
while ((m = re.exec(text))) {
|
|
658
|
-
const name = m[1] || m[2] || m[3]
|
|
659
|
-
if (!name || name[0] !== name[0].toLowerCase() && name.length < 2) continue // 跳过全大写常量(噪声)
|
|
660
|
-
const line = text.slice(0, m.index).split("\n").length
|
|
661
|
-
symbols.push({ name, line, kind: m[1] ? "function" : m[2] ? "class" : "variable" })
|
|
662
|
-
}
|
|
663
|
-
return symbols
|
|
664
|
-
}
|
|
665
|
-
|
|
666
|
-
/**
|
|
667
|
-
* 提取 Python 文件的顶层 def/class。
|
|
668
|
-
*/
|
|
669
|
-
function extractPySymbols(lines) {
|
|
670
|
-
const symbols = []
|
|
671
|
-
const re = /^(?:async\s+)?(?:def|class)\s+(\w+)/gm
|
|
672
|
-
const text = lines.join("\n")
|
|
673
|
-
let m
|
|
674
|
-
while ((m = re.exec(text))) {
|
|
675
|
-
symbols.push({ name: m[1], line: text.slice(0, m.index).split("\n").length, kind: text[m.index] === "c" ? "class" : "function" })
|
|
676
|
-
}
|
|
677
|
-
return symbols
|
|
678
|
-
}
|
|
679
|
-
|
|
680
|
-
/**
|
|
681
|
-
* 将一个文件拆成代码块。小文件整文件一块;大文件按符号切分,符号间的内容并入前一个符号块。
|
|
682
|
-
* 每个块会额外带上符号前的 JSDoc / docstring 注释,提升搜索质量。
|
|
683
|
-
*/
|
|
684
|
-
function chunkCode(lines, filepath) {
|
|
685
|
-
const ext = filepath.slice(filepath.lastIndexOf(".")).toLowerCase()
|
|
686
|
-
const chunks = []
|
|
687
|
-
|
|
688
|
-
if (lines.length <= BIG_FILE_LINES) {
|
|
689
|
-
// 小文件:整文件一块;提取文件头注释作为前缀
|
|
690
|
-
const doc = extractLeadingDoc(lines, 1, ext)
|
|
691
|
-
const content = (doc ? doc + "\n" : "") + lines.join("\n").trimEnd()
|
|
692
|
-
chunks.push({ name: filepath, line_start: 1, line_end: lines.length, content })
|
|
693
|
-
return chunks
|
|
694
|
-
}
|
|
695
|
-
|
|
696
|
-
// 大文件:按符号切分
|
|
697
|
-
const symbols = ext === ".py" ? extractPySymbols(lines) : extractSymbols(lines, ext)
|
|
698
|
-
if (symbols.length <= 1) {
|
|
699
|
-
const doc = extractLeadingDoc(lines, 1, ext)
|
|
700
|
-
const content = (doc ? doc + "\n" : "") + lines.join("\n").trimEnd()
|
|
701
|
-
chunks.push({ name: filepath, line_start: 1, line_end: lines.length, content })
|
|
702
|
-
return chunks
|
|
703
|
-
}
|
|
704
|
-
|
|
705
|
-
// 符号间切分:每个符号从自己的行开始到下一个符号前一行结束
|
|
706
|
-
for (let i = 0; i < symbols.length; i++) {
|
|
707
|
-
const sym = symbols[i]
|
|
708
|
-
const start = sym.line
|
|
709
|
-
const end = i + 1 < symbols.length ? symbols[i + 1].line - 1 : lines.length
|
|
710
|
-
if (start > end) continue
|
|
711
|
-
const doc = extractLeadingDoc(lines, start, ext)
|
|
712
|
-
const body = lines.slice(start - 1, end).join("\n").trimEnd()
|
|
713
|
-
const content = (doc ? doc + "\n" : "") + body
|
|
714
|
-
if (!content) continue
|
|
715
|
-
chunks.push({ name: `${filepath}:${sym.name}`, line_start: start, line_end: end, content })
|
|
716
|
-
}
|
|
717
|
-
return chunks
|
|
718
|
-
}
|
|
719
|
-
|
|
720
|
-
/**
|
|
721
|
-
* 提取指定行之前的 JSDoc / docstring 注释。
|
|
722
|
-
* JS/TS: 向前扫描 /** ... */ 或 // 连续注释行
|
|
723
|
-
* Python: 符号定义行的下一行开始找 """...""" docstring
|
|
724
|
-
* 没有则返回空字符串。
|
|
725
|
-
*/
|
|
726
|
-
function extractLeadingDoc(lines, lineNum, ext) {
|
|
727
|
-
if (ext === ".py") {
|
|
728
|
-
// Python: docstring 在 def/class 的下一行
|
|
729
|
-
if (lineNum >= lines.length) return ""
|
|
730
|
-
const next = lines[lineNum] // lineNum 是 1-based,下一行 index = lineNum
|
|
731
|
-
const m = next?.match(/^\s*"""(.+?)"""\s*$/)
|
|
732
|
-
if (m) return m[1].trim()
|
|
733
|
-
// 多行 docstring
|
|
734
|
-
if (/^\s*"""\s*$/.test(next)) {
|
|
735
|
-
const parts = []
|
|
736
|
-
for (let i = lineNum + 1; i < lines.length && i < lineNum + 8; i++) {
|
|
737
|
-
if (/^\s*"""\s*$/.test(lines[i])) break
|
|
738
|
-
parts.push(lines[i].trim())
|
|
739
|
-
}
|
|
740
|
-
const text = parts.join(" ").trim()
|
|
741
|
-
return text.length > 0 && text.length < 300 ? text : ""
|
|
742
|
-
}
|
|
743
|
-
return ""
|
|
744
|
-
}
|
|
745
|
-
|
|
746
|
-
// JS/TS: 向前扫描 /** ... */ 或连续 // 行
|
|
747
|
-
const jsish = new Set([".mjs", ".js", ".ts", ".jsx", ".tsx"])
|
|
748
|
-
if (!jsish.has(ext)) return ""
|
|
749
|
-
|
|
750
|
-
const parts = []
|
|
751
|
-
let i = lineNum - 2 // lineNum 是 1-based,前一行 index = lineNum-2
|
|
752
|
-
// 先看紧邻的 JSDoc 块
|
|
753
|
-
if (i >= 0 && /^\s*\*\/\s*$/.test(lines[i])) {
|
|
754
|
-
// 找到 JSDoc 结尾,反向找开头
|
|
755
|
-
while (i >= 0) {
|
|
756
|
-
const line = lines[i].trim()
|
|
757
|
-
if (/^\s*\/\*\*/.test(line)) {
|
|
758
|
-
parts.unshift(line.replace(/^\s*\/\*\*\s*/, "").replace(/\s*\*\/\s*$/, "").trim())
|
|
759
|
-
break
|
|
760
|
-
}
|
|
761
|
-
parts.unshift(line.replace(/^\s*\*\s?/, "").trim())
|
|
762
|
-
i--
|
|
763
|
-
}
|
|
764
|
-
} else {
|
|
765
|
-
// 收集连续 // 注释行
|
|
766
|
-
while (i >= 0 && /^\s*\/\//.test(lines[i])) {
|
|
767
|
-
parts.unshift(lines[i].replace(/^\s*\/\/\s*/, "").trim())
|
|
768
|
-
i--
|
|
769
|
-
}
|
|
770
|
-
}
|
|
771
|
-
|
|
772
|
-
const text = parts.join(" ").trim()
|
|
773
|
-
return text.length > 0 && text.length < 300 ? text : ""
|
|
774
|
-
}
|
|
775
|
-
|
|
776
|
-
/** 单文件入索引:删除旧块 → 分块 → 插入新块(codeSync 和 reindexFile 共用) */
|
|
777
|
-
/** 将控制权交还给事件循环一个 tick(让键盘输入有机会被处理) */
|
|
778
|
-
function yieldTick() {
|
|
779
|
-
return new Promise((r) => setTimeout(r, 0))
|
|
780
|
-
}
|
|
781
|
-
|
|
782
|
-
function _upsertCodeFile(memory, origin, rel, lines, lang, mtimeMs) {
|
|
783
|
-
const chunks = chunkCode(lines, rel)
|
|
784
|
-
memory.db.exec("BEGIN")
|
|
785
|
-
try {
|
|
786
|
-
memory.db.prepare(`DELETE FROM code_chunks WHERE origin = ? AND path = ?`).run(origin, rel)
|
|
787
|
-
const insert = memory.db.prepare(`
|
|
788
|
-
INSERT INTO code_chunks (origin, path, language, chunk_type, symbol_name, content, line_start, line_end, mtime_ms, seg_content)
|
|
789
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
790
|
-
`)
|
|
791
|
-
for (const c of chunks) {
|
|
792
|
-
const isFile = c.name === rel
|
|
793
|
-
insert.run(origin, rel, lang, isFile ? "file" : "symbol", isFile ? "" : c.name.slice(rel.length + 1), c.content, c.line_start, c.line_end, mtimeMs, segmentCJK(c.content))
|
|
794
|
-
}
|
|
795
|
-
memory.db.exec("COMMIT")
|
|
796
|
-
} catch (e) {
|
|
797
|
-
memory.db.exec("ROLLBACK")
|
|
798
|
-
throw e
|
|
799
|
-
}
|
|
800
|
-
}
|
|
801
|
-
|
|
802
|
-
function _upsertDocFile(memory, origin, rel, lines, mtimeMs) {
|
|
803
|
-
const chunks = chunkMarkdown(lines, rel)
|
|
804
|
-
const lang = rel.endsWith(".rst") ? "rst" : rel.endsWith(".adoc") ? "asciidoc" : rel.endsWith(".txt") ? "text" : "markdown"
|
|
805
|
-
memory.db.exec("BEGIN")
|
|
806
|
-
try {
|
|
807
|
-
memory.db.prepare(`DELETE FROM doc_chunks WHERE origin = ? AND path = ?`).run(origin, rel)
|
|
808
|
-
const insert = memory.db.prepare(`
|
|
809
|
-
INSERT INTO doc_chunks (origin, path, language, heading, content, line_start, line_end, mtime_ms, seg_content)
|
|
810
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
811
|
-
`)
|
|
812
|
-
for (const c of chunks) {
|
|
813
|
-
insert.run(origin, rel, lang, c.heading, c.content, c.line_start, c.line_end, mtimeMs, segmentCJK(c.content))
|
|
814
|
-
}
|
|
815
|
-
memory.db.exec("COMMIT")
|
|
816
|
-
} catch (e) {
|
|
817
|
-
memory.db.exec("ROLLBACK")
|
|
818
|
-
throw e
|
|
819
|
-
}
|
|
820
|
-
}
|
|
821
|
-
|
|
822
|
-
/**
|
|
823
|
-
* git 驱动增量索引:用 git diff 找出上次索引以来的变更文件,
|
|
824
|
-
* 只重建这些文件的 FTS5 块(不碰向量)。比全量 mtime 扫描快一个数量级。
|
|
825
|
-
* 返回 { updated, removed, skipped } 或 null(git 不可用时,调用方退到 codeSync)。
|
|
826
|
-
*/
|
|
827
|
-
export async function gitSync(memory, dir, { onProgress } = {}) {
|
|
828
|
-
const { execSync } = await import("node:child_process")
|
|
829
|
-
const opts = { cwd: dir, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 10000 }
|
|
15
|
+
// code sync + search + reindex
|
|
16
|
+
export { gitSync, codeSync, markIndexedCommit, codeSearch, ensureCodeEmbeddings, codeSearchTool, reindexFile } from "./memory/code-sync.mjs"
|
|
830
17
|
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
const stored = memory.db.prepare(`SELECT value FROM meta WHERE key = 'last_indexed_commit'`).get()?.value
|
|
835
|
-
if (!stored) return null // 首次运行,走全量 codeSync
|
|
836
|
-
|
|
837
|
-
// 取两个 diff 的并集:已提交的变更(pull/merge)+ 工作区脏文件(用户在外部编辑器改的)
|
|
838
|
-
let diffOut
|
|
839
|
-
try {
|
|
840
|
-
// 包含 D(删除):外部删文件也要清理索引块
|
|
841
|
-
const committed = execSync(`git diff --name-only --diff-filter=ACMRTD ${stored} HEAD`, opts).trim()
|
|
842
|
-
const dirty = execSync(`git diff --name-only --diff-filter=ACMRTD`, opts).trim()
|
|
843
|
-
const lines = [...new Set([...committed.split("\n").filter(Boolean), ...dirty.split("\n").filter(Boolean)])]
|
|
844
|
-
diffOut = lines
|
|
845
|
-
} catch {
|
|
846
|
-
// rebase / shallow clone 导致旧 commit 不可达 → 退到全量
|
|
847
|
-
return null
|
|
848
|
-
}
|
|
849
|
-
|
|
850
|
-
if (diffOut.length > 200) {
|
|
851
|
-
// 大范围变更(分支切换等)→ 退到 codeSync,它有更好的进度反馈
|
|
852
|
-
return null
|
|
853
|
-
}
|
|
854
|
-
|
|
855
|
-
let updated = 0, removed = 0, skipped = 0, failed = 0
|
|
856
|
-
const errors = []
|
|
857
|
-
for (let i = 0; i < diffOut.length; i++) {
|
|
858
|
-
const rel = diffOut[i].replaceAll("\\", "/")
|
|
859
|
-
const abs = join(dir, rel)
|
|
860
|
-
const ext = rel.slice(rel.lastIndexOf(".")).toLowerCase()
|
|
861
|
-
|
|
862
|
-
// 跳过隐藏目录和 SKIP_DIRS 里的文件
|
|
863
|
-
const pathDirs = rel.split("/")
|
|
864
|
-
if (pathDirs.some((d) => SKIP_DIRS.has(d) || d.startsWith("."))) continue
|
|
865
|
-
|
|
866
|
-
if (!CODE_EXTS.has(ext) && !DOC_EXTS.has(ext)) { skipped++; continue }
|
|
867
|
-
|
|
868
|
-
try {
|
|
869
|
-
const text = await readFile(abs, "utf8")
|
|
870
|
-
const lines = text.split("\n")
|
|
871
|
-
if (CODE_EXTS.has(ext)) {
|
|
872
|
-
const lang = detectLanguage(abs)
|
|
873
|
-
let mtimeMs = 0
|
|
874
|
-
try { mtimeMs = Math.floor((await stat(abs)).mtimeMs) } catch { /* 新文件 */ }
|
|
875
|
-
_upsertCodeFile(memory, dir, rel, lines, lang, mtimeMs)
|
|
876
|
-
} else {
|
|
877
|
-
let mtimeMs = 0
|
|
878
|
-
try { mtimeMs = Math.floor((await stat(abs)).mtimeMs) } catch { /* 新文件 */ }
|
|
879
|
-
_upsertDocFile(memory, dir, rel, lines, mtimeMs)
|
|
880
|
-
}
|
|
881
|
-
updated++
|
|
882
|
-
} catch (e) {
|
|
883
|
-
// readFile 失败 = 文件已删 → 清理索引块;
|
|
884
|
-
// 其他错误(权限/SQLite)不推进锚点——下次 gitSync 会重试
|
|
885
|
-
const isDeleted = e.code === "ENOENT"
|
|
886
|
-
if (isDeleted) {
|
|
887
|
-
if (CODE_EXTS.has(ext)) memory.db.prepare(`DELETE FROM code_chunks WHERE origin = ? AND path = ?`).run(dir, rel)
|
|
888
|
-
else memory.db.prepare(`DELETE FROM doc_chunks WHERE origin = ? AND path = ?`).run(dir, rel)
|
|
889
|
-
removed++
|
|
890
|
-
} else {
|
|
891
|
-
failed++
|
|
892
|
-
if (errors.length < 5) errors.push(`${rel}: ${e.message}`)
|
|
893
|
-
}
|
|
894
|
-
}
|
|
895
|
-
if (onProgress && i % 5 === 0) {
|
|
896
|
-
onProgress({ phase: "index", current: i + 1, total: diffOut.length, updated, removed, skipped })
|
|
897
|
-
}
|
|
898
|
-
}
|
|
899
|
-
|
|
900
|
-
// 更新锚点(有 failed 时不推进——下次 gitSync 会重试这些文件)
|
|
901
|
-
if (failed === 0) {
|
|
902
|
-
memory.db.prepare(`INSERT INTO meta (key, value) VALUES ('last_indexed_commit', ?)
|
|
903
|
-
ON CONFLICT (key) DO UPDATE SET value = excluded.value`).run(head)
|
|
904
|
-
}
|
|
905
|
-
|
|
906
|
-
onProgress?.({ phase: "done", total: diffOut.length, updated, removed, skipped, failed })
|
|
907
|
-
return { updated, removed, skipped, failed, errors }
|
|
908
|
-
}
|
|
909
|
-
|
|
910
|
-
/**
|
|
911
|
-
* 同步代码索引:扫描 dir 下所有源文件 → 分块 → upsert 到 code_chunks。
|
|
912
|
-
* 按 mtime 增量——只重建变更过的文件块。
|
|
913
|
-
* onProgress({ phase, current, total }) 可选回调,用于 UI 进度展示。
|
|
914
|
-
*/
|
|
915
|
-
export async function codeSync(memory, dir, { onProgress } = {}) {
|
|
916
|
-
// 收集所有源文件
|
|
917
|
-
const files = []
|
|
918
|
-
async function walk(d) {
|
|
919
|
-
let entries
|
|
920
|
-
try { entries = await readdir(d, { withFileTypes: true }) } catch { return }
|
|
921
|
-
for (const e of entries) {
|
|
922
|
-
if (e.isDirectory()) {
|
|
923
|
-
if (SKIP_DIRS.has(e.name) || e.name.startsWith(".")) continue
|
|
924
|
-
await walk(join(d, e.name))
|
|
925
|
-
} else if (e.isFile()) {
|
|
926
|
-
const ext = e.name.slice(e.name.lastIndexOf(".")).toLowerCase()
|
|
927
|
-
if (CODE_EXTS.has(ext)) files.push(join(d, e.name))
|
|
928
|
-
}
|
|
929
|
-
}
|
|
930
|
-
}
|
|
931
|
-
await walk(dir)
|
|
932
|
-
|
|
933
|
-
// 取已索引文件的 mtime 快照(只看本 origin,别的项目的块不归这里管)
|
|
934
|
-
const indexed = new Map(
|
|
935
|
-
memory.db.prepare(`SELECT path, mtime_ms FROM code_chunks WHERE origin = ?`).all(dir).map((r) => [r.path, r.mtime_ms])
|
|
936
|
-
)
|
|
937
|
-
const seen = new Set()
|
|
938
|
-
|
|
939
|
-
onProgress?.({ phase: "scan", total: files.length })
|
|
940
|
-
|
|
941
|
-
let updated = 0, removed = 0, skipped = 0, failed = 0
|
|
942
|
-
const errors = []
|
|
943
|
-
for (let i = 0; i < files.length; i++) {
|
|
944
|
-
const abs = files[i]
|
|
945
|
-
const rel = abs.slice(dir.length + 1).replaceAll("\\", "/")
|
|
946
|
-
seen.add(rel)
|
|
947
|
-
|
|
948
|
-
let mtimeMs
|
|
949
|
-
try { mtimeMs = Math.floor((await stat(abs)).mtimeMs) } catch { continue }
|
|
950
|
-
if (indexed.get(rel) === mtimeMs) {
|
|
951
|
-
skipped++
|
|
952
|
-
continue
|
|
953
|
-
}
|
|
954
|
-
|
|
955
|
-
// 单文件失败不拖垮整轮同步:记下错误继续,调用方看 failed/errors
|
|
956
|
-
try {
|
|
957
|
-
const text = await readFile(abs, "utf8")
|
|
958
|
-
const lines = text.split("\n")
|
|
959
|
-
const lang = detectLanguage(abs)
|
|
960
|
-
_upsertCodeFile(memory, dir, rel, lines, lang, mtimeMs)
|
|
961
|
-
updated++
|
|
962
|
-
} catch (e) {
|
|
963
|
-
failed++
|
|
964
|
-
if (errors.length < 5) errors.push(`${rel}: ${e.message}`)
|
|
965
|
-
}
|
|
966
|
-
await yieldTick()
|
|
967
|
-
|
|
968
|
-
if (onProgress && i % 10 === 0) {
|
|
969
|
-
onProgress({ phase: "index", current: i + 1, total: files.length, updated, removed, skipped, failed })
|
|
970
|
-
}
|
|
971
|
-
}
|
|
972
|
-
|
|
973
|
-
// 清理磁盘上已消失的文件块(仅本 origin)
|
|
974
|
-
for (const stale of indexed.keys()) {
|
|
975
|
-
if (!seen.has(stale)) {
|
|
976
|
-
memory.db.prepare(`DELETE FROM code_chunks WHERE origin = ? AND path = ?`).run(dir, stale)
|
|
977
|
-
removed++
|
|
978
|
-
}
|
|
979
|
-
}
|
|
980
|
-
|
|
981
|
-
onProgress?.({ phase: "done", total: files.length, updated, removed, skipped, failed })
|
|
982
|
-
// 全量同步成功后记录 git 锚点(下次启动 gitSync 用它做增量 diff 基准)
|
|
983
|
-
markIndexedCommit(memory, dir)
|
|
984
|
-
return { updated, removed, skipped, failed, errors, total: files.length }
|
|
985
|
-
}
|
|
986
|
-
|
|
987
|
-
/** 记录当前 HEAD 作为索引锚点(gitSync 增量 diff 基准);非 git 仓库静默跳过 */
|
|
988
|
-
async function markIndexedCommit(memory, dir) {
|
|
989
|
-
try {
|
|
990
|
-
const { execSync } = await import("node:child_process")
|
|
991
|
-
const head = execSync("git rev-parse HEAD", { cwd: dir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5000 }).trim()
|
|
992
|
-
memory.db.prepare(`INSERT INTO meta (key, value) VALUES ('last_indexed_commit', ?)
|
|
993
|
-
ON CONFLICT (key) DO UPDATE SET value = excluded.value`).run(head)
|
|
994
|
-
} catch { /* 非 git 仓库或 git 不可用,跳过 */ }
|
|
995
|
-
}
|
|
996
|
-
|
|
997
|
-
/**
|
|
998
|
-
* 代码检索:FTS5(BM25) + 可选向量余弦,RRF 合并。
|
|
999
|
-
* 无 embedder 时退化为纯 FTS;ftsQuery 为空(纯标点查询)且有 embedder 时退化为纯向量。
|
|
1000
|
-
* 返回 [{ path, language, symbol_name, content, line_start, line_end }]
|
|
1001
|
-
*/
|
|
1002
|
-
export async function codeSearch(memory, query, { limit = 5 } = {}) {
|
|
1003
|
-
const ftsQuery = buildFtsQuery(query)
|
|
1004
|
-
if (!ftsQuery && !memory.embedder) return []
|
|
1005
|
-
|
|
1006
|
-
// codeOrigin 设置时只检索本项目(与 files 表的 projectOrigin 过滤同模式);未设置时不过滤
|
|
1007
|
-
const ftsOriginFilter = memory.codeOrigin ? `AND c.origin = ?` : ""
|
|
1008
|
-
const vecOriginFilter = memory.codeOrigin ? `AND origin = ?` : ""
|
|
1009
|
-
const originParams = memory.codeOrigin ? [memory.codeOrigin] : []
|
|
1010
|
-
|
|
1011
|
-
const ftsList = ftsQuery ? memory.db.prepare(`
|
|
1012
|
-
SELECT c.rowid, c.path, c.language, c.symbol_name, c.content, c.line_start, c.line_end, bm25(code_chunks_fts) AS rank
|
|
1013
|
-
FROM code_chunks_fts JOIN code_chunks c ON c.rowid = code_chunks_fts.rowid
|
|
1014
|
-
WHERE code_chunks_fts MATCH ? ${ftsOriginFilter}
|
|
1015
|
-
ORDER BY rank LIMIT ?
|
|
1016
|
-
`).all(ftsQuery, ...originParams, Math.max(limit * 4, 20)) : []
|
|
1017
|
-
|
|
1018
|
-
if (!memory.embedder) return ftsList.slice(0, limit)
|
|
1019
|
-
|
|
1020
|
-
// 向量通道
|
|
1021
|
-
await ensureEmbeddings(memory)
|
|
1022
|
-
const [qvec] = await embed(memory.embedder, [query])
|
|
1023
|
-
const rows = memory.db.prepare(`SELECT rowid, embedding FROM code_chunks WHERE embedding IS NOT NULL ${vecOriginFilter}`).all(...originParams)
|
|
1024
|
-
const vecList = rows
|
|
1025
|
-
.map((r) => ({ rowid: r.rowid, score: cosine(qvec, fromBlob(r.embedding)) }))
|
|
1026
|
-
.sort((a, b) => b.score - a.score)
|
|
1027
|
-
.slice(0, Math.max(limit * 4, 20))
|
|
1028
|
-
|
|
1029
|
-
// RRF 合并(按 rowid 对齐两个通道;解析结果回表取,纯向量命中的块也能浮现)
|
|
1030
|
-
const K = 60
|
|
1031
|
-
const scores = new Map()
|
|
1032
|
-
ftsList.forEach((r, i) => scores.set(r.rowid, (scores.get(r.rowid) ?? 0) + 1 / (K + i + 1)))
|
|
1033
|
-
vecList.forEach((r, i) => scores.set(r.rowid, (scores.get(r.rowid) ?? 0) + 1 / (K + i + 1)))
|
|
1034
|
-
|
|
1035
|
-
const fetchChunk = memory.db.prepare(`
|
|
1036
|
-
SELECT path, language, symbol_name, content, line_start, line_end FROM code_chunks WHERE rowid = ?
|
|
1037
|
-
`)
|
|
1038
|
-
return [...scores.entries()]
|
|
1039
|
-
.sort((a, b) => b[1] - a[1])
|
|
1040
|
-
.slice(0, limit)
|
|
1041
|
-
.map(([rowid]) => fetchChunk.get(rowid))
|
|
1042
|
-
.filter(Boolean)
|
|
1043
|
-
}
|
|
1044
|
-
|
|
1045
|
-
/** 惰性补算 code_chunks 缺失的向量 */
|
|
1046
|
-
async function ensureCodeEmbeddings(memory) {
|
|
1047
|
-
if (!memory.embedder) return
|
|
1048
|
-
const modelKey = memory.embedder.model
|
|
1049
|
-
const stored = memory.db.prepare(`SELECT value FROM meta WHERE key = 'code_embedding_model'`).get()?.value
|
|
1050
|
-
if (stored !== modelKey) {
|
|
1051
|
-
memory.db.prepare(`UPDATE code_chunks SET embedding = NULL`).run()
|
|
1052
|
-
memory.db.prepare(`INSERT INTO meta (key, value) VALUES ('code_embedding_model', ?)
|
|
1053
|
-
ON CONFLICT (key) DO UPDATE SET value = excluded.value`).run(modelKey)
|
|
1054
|
-
}
|
|
1055
|
-
|
|
1056
|
-
const pending = memory.db.prepare(`SELECT rowid, path, symbol_name, content FROM code_chunks WHERE embedding IS NULL LIMIT 64`).all()
|
|
1057
|
-
if (pending.length === 0) return
|
|
1058
|
-
|
|
1059
|
-
const texts = pending.map((r) => `${r.path}${r.symbol_name ? " :: " + r.symbol_name : ""}\n${r.content.slice(0, 2000)}`)
|
|
1060
|
-
const vecs = await embed(memory.embedder, texts)
|
|
1061
|
-
|
|
1062
|
-
const update = memory.db.prepare(`UPDATE code_chunks SET embedding = ? WHERE rowid = ?`)
|
|
1063
|
-
pending.forEach((r, i) => update.run(toBlob(vecs[i]), r.rowid))
|
|
1064
|
-
}
|
|
1065
|
-
|
|
1066
|
-
/**
|
|
1067
|
-
* 生成 code_search 工具(只读,与 memory_search 同模式)。
|
|
1068
|
-
*/
|
|
1069
|
-
export function codeSearchTool(memory) {
|
|
1070
|
-
return {
|
|
1071
|
-
name: "code_search",
|
|
1072
|
-
description:
|
|
1073
|
-
"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.",
|
|
1074
|
-
parameters: {
|
|
1075
|
-
type: "object",
|
|
1076
|
-
properties: {
|
|
1077
|
-
query: { type: "string", description: "Natural language or code snippet to search for" },
|
|
1078
|
-
limit: { type: "number", description: "Max results (default 5)" },
|
|
1079
|
-
},
|
|
1080
|
-
required: ["query"],
|
|
1081
|
-
},
|
|
1082
|
-
readonly: true,
|
|
1083
|
-
async execute(args) {
|
|
1084
|
-
const results = await codeSearch(memory, args.query, { limit: args.limit ?? 5 })
|
|
1085
|
-
if (results.length === 0) return "(no matching code)"
|
|
1086
|
-
return results.map((r) =>
|
|
1087
|
-
`${r.path}${r.symbol_name ? ` :: ${r.symbol_name}` : ""} (L${r.line_start}-L${r.line_end}):\n${r.content.slice(0, 2000)}`
|
|
1088
|
-
).join("\n\n---\n\n")
|
|
1089
|
-
},
|
|
1090
|
-
}
|
|
1091
|
-
}
|
|
1092
|
-
|
|
1093
|
-
/**
|
|
1094
|
-
* 单文件增量重索引:write/edit/delete 后调用,只重建这一条路径。
|
|
1095
|
-
* 不影响其他文件(比全量 codeSync/docSync 快几个数量级)。
|
|
1096
|
-
*/
|
|
1097
|
-
export async function reindexFile(memory, cwd, absPath) {
|
|
1098
|
-
const ext = absPath.slice(absPath.lastIndexOf(".")).toLowerCase()
|
|
1099
|
-
const rel = relative(cwd, absPath).replaceAll("\\", "/")
|
|
1100
|
-
// 越界路径拒索引:必须是 ".." 或 "../..." 才算越界——文件名以 .. 开头(如 ..foo.js)不算
|
|
1101
|
-
if (rel === ".." || rel.startsWith("../")) return
|
|
1102
|
-
// 与 walk 策略一致:跳过隐藏目录和 SKIP_DIRS 里的文件(node_modules/.git/dist…),
|
|
1103
|
-
// 否则这些文件虽然全量扫描时被跳过,单文件增量更新却会漏进来
|
|
1104
|
-
const dirs = rel.split("/").slice(0, -1)
|
|
1105
|
-
if (dirs.some((d) => SKIP_DIRS.has(d) || d.startsWith("."))) return
|
|
1106
|
-
|
|
1107
|
-
let text
|
|
1108
|
-
try { text = await readFile(absPath, "utf8") } catch {
|
|
1109
|
-
// 文件已删:清理索引
|
|
1110
|
-
if (CODE_EXTS.has(ext)) memory.db.prepare(`DELETE FROM code_chunks WHERE origin = ? AND path = ?`).run(cwd, rel)
|
|
1111
|
-
else if (DOC_EXTS.has(ext)) memory.db.prepare(`DELETE FROM doc_chunks WHERE origin = ? AND path = ?`).run(cwd, rel)
|
|
1112
|
-
return
|
|
1113
|
-
}
|
|
1114
|
-
const lines = text.split("\n")
|
|
1115
|
-
|
|
1116
|
-
if (CODE_EXTS.has(ext)) {
|
|
1117
|
-
const lang = detectLanguage(absPath)
|
|
1118
|
-
let mtimeMs = 0
|
|
1119
|
-
try { mtimeMs = Math.floor((await stat(absPath)).mtimeMs) } catch { /* 新文件 */ }
|
|
1120
|
-
_upsertCodeFile(memory, cwd, rel, lines, lang, mtimeMs)
|
|
1121
|
-
} else if (DOC_EXTS.has(ext)) {
|
|
1122
|
-
let mtimeMs = 0
|
|
1123
|
-
try { mtimeMs = Math.floor((await stat(absPath)).mtimeMs) } catch { /* 新文件 */ }
|
|
1124
|
-
_upsertDocFile(memory, cwd, rel, lines, mtimeMs)
|
|
1125
|
-
}
|
|
1126
|
-
// 立即补算向量,不等惰性检索(刚改的文件应该有语义搜索能力)
|
|
1127
|
-
if (memory.embedder) {
|
|
1128
|
-
try { await ensureEmbeddings(memory) } catch { /* embedding 失败不阻塞 */ }
|
|
1129
|
-
}
|
|
1130
|
-
}
|
|
1131
|
-
|
|
1132
|
-
// ========== 文档索引 ==========
|
|
1133
|
-
|
|
1134
|
-
/**
|
|
1135
|
-
* 按 ## 标题切分 markdown 文件。每个 ## section 独立入索引,
|
|
1136
|
-
* 标题路径做 heading(如 "README.md > 部署 > Docker"),方便检索定位。
|
|
1137
|
-
*/
|
|
1138
|
-
function chunkMarkdown(lines, filepath) {
|
|
1139
|
-
const chunks = []
|
|
1140
|
-
let start = 1
|
|
1141
|
-
let heading = filepath
|
|
1142
|
-
|
|
1143
|
-
for (let i = 0; i < lines.length; i++) {
|
|
1144
|
-
const m = lines[i].match(/^(#{1,4})\s+(.+)/)
|
|
1145
|
-
if (m) {
|
|
1146
|
-
if (i > start) {
|
|
1147
|
-
chunks.push({ heading, line_start: start, line_end: i, content: lines.slice(start - 1, i).join("\n").trimEnd() })
|
|
1148
|
-
}
|
|
1149
|
-
heading = `${filepath} > ${m[2].trim()}`
|
|
1150
|
-
start = i + 1
|
|
1151
|
-
}
|
|
1152
|
-
}
|
|
1153
|
-
// tail
|
|
1154
|
-
if (start <= lines.length) {
|
|
1155
|
-
chunks.push({ heading, line_start: start, line_end: lines.length, content: lines.slice(start - 1).join("\n").trimEnd() })
|
|
1156
|
-
}
|
|
1157
|
-
return chunks.filter((c) => c.content)
|
|
1158
|
-
}
|
|
1159
|
-
|
|
1160
|
-
/**
|
|
1161
|
-
* 同步文档索引:扫描 dir 下所有 .md/.mdc/.txt/.rst/.adoc → 分块 → upsert 到 doc_chunks。
|
|
1162
|
-
* 按 mtime 增量。
|
|
1163
|
-
*/
|
|
1164
|
-
export async function docSync(memory, dir, { onProgress } = {}) {
|
|
1165
|
-
const files = []
|
|
1166
|
-
async function walk(d) {
|
|
1167
|
-
let entries
|
|
1168
|
-
try { entries = await readdir(d, { withFileTypes: true }) } catch { return }
|
|
1169
|
-
for (const e of entries) {
|
|
1170
|
-
if (e.isDirectory()) {
|
|
1171
|
-
if (SKIP_DIRS.has(e.name) || e.name.startsWith(".")) continue
|
|
1172
|
-
await walk(join(d, e.name))
|
|
1173
|
-
} else if (e.isFile()) {
|
|
1174
|
-
const ext = e.name.slice(e.name.lastIndexOf(".")).toLowerCase()
|
|
1175
|
-
if (DOC_EXTS.has(ext)) files.push(join(d, e.name))
|
|
1176
|
-
}
|
|
1177
|
-
}
|
|
1178
|
-
}
|
|
1179
|
-
await walk(dir)
|
|
1180
|
-
|
|
1181
|
-
const indexed = new Map(
|
|
1182
|
-
memory.db.prepare(`SELECT path, mtime_ms FROM doc_chunks WHERE origin = ?`).all(dir).map((r) => [r.path, r.mtime_ms])
|
|
1183
|
-
)
|
|
1184
|
-
const seen = new Set()
|
|
1185
|
-
|
|
1186
|
-
onProgress?.({ phase: "scan", total: files.length })
|
|
1187
|
-
|
|
1188
|
-
let updated = 0, removed = 0, skipped = 0, failed = 0
|
|
1189
|
-
const errors = []
|
|
1190
|
-
for (let i = 0; i < files.length; i++) {
|
|
1191
|
-
const abs = files[i]
|
|
1192
|
-
const rel = abs.slice(dir.length + 1).replaceAll("\\", "/")
|
|
1193
|
-
seen.add(rel)
|
|
1194
|
-
|
|
1195
|
-
let mtimeMs
|
|
1196
|
-
try { mtimeMs = Math.floor((await stat(abs)).mtimeMs) } catch { continue }
|
|
1197
|
-
if (indexed.get(rel) === mtimeMs) {
|
|
1198
|
-
skipped++
|
|
1199
|
-
continue
|
|
1200
|
-
}
|
|
1201
|
-
|
|
1202
|
-
// 单文件失败不拖垮整轮同步(与 codeSync 同策略)
|
|
1203
|
-
try {
|
|
1204
|
-
const text = await readFile(abs, "utf8")
|
|
1205
|
-
const lines = text.split("\n")
|
|
1206
|
-
_upsertDocFile(memory, dir, rel, lines, mtimeMs)
|
|
1207
|
-
updated++
|
|
1208
|
-
} catch (e) {
|
|
1209
|
-
failed++
|
|
1210
|
-
if (errors.length < 5) errors.push(`${rel}: ${e.message}`)
|
|
1211
|
-
}
|
|
1212
|
-
await yieldTick()
|
|
1213
|
-
|
|
1214
|
-
if (onProgress && i % 10 === 0) {
|
|
1215
|
-
onProgress({ phase: "index", current: i + 1, total: files.length, updated, removed, skipped, failed })
|
|
1216
|
-
}
|
|
1217
|
-
}
|
|
1218
|
-
|
|
1219
|
-
// 清理磁盘上消失的文件(仅本 origin)
|
|
1220
|
-
for (const stale of indexed.keys()) {
|
|
1221
|
-
if (!seen.has(stale)) {
|
|
1222
|
-
memory.db.prepare(`DELETE FROM doc_chunks WHERE origin = ? AND path = ?`).run(dir, stale)
|
|
1223
|
-
removed++
|
|
1224
|
-
}
|
|
1225
|
-
}
|
|
1226
|
-
|
|
1227
|
-
onProgress?.({ phase: "done", total: files.length, updated, removed, skipped, failed })
|
|
1228
|
-
markIndexedCommit(memory, dir)
|
|
1229
|
-
return { updated, removed, skipped, failed, errors, total: files.length }
|
|
1230
|
-
}
|
|
1231
|
-
|
|
1232
|
-
/**
|
|
1233
|
-
* 文档检索:FTS5(BM25) + 可选向量余弦,RRF 合并。
|
|
1234
|
-
* 无 embedder 时退化为纯 FTS;ftsQuery 为空(纯标点查询)且有 embedder 时退化为纯向量。
|
|
1235
|
-
* 返回 [{ path, language, heading, content, line_start, line_end }]
|
|
1236
|
-
*/
|
|
1237
|
-
export async function docSearch(memory, query, { limit = 5 } = {}) {
|
|
1238
|
-
const ftsQuery = buildFtsQuery(query)
|
|
1239
|
-
if (!ftsQuery && !memory.embedder) return []
|
|
1240
|
-
|
|
1241
|
-
// codeOrigin 设置时只检索本项目(与 codeSearch 同模式);未设置时不过滤
|
|
1242
|
-
const ftsOriginFilter = memory.codeOrigin ? `AND d.origin = ?` : ""
|
|
1243
|
-
const vecOriginFilter = memory.codeOrigin ? `AND origin = ?` : ""
|
|
1244
|
-
const originParams = memory.codeOrigin ? [memory.codeOrigin] : []
|
|
1245
|
-
|
|
1246
|
-
const ftsList = ftsQuery ? memory.db.prepare(`
|
|
1247
|
-
SELECT d.rowid, d.path, d.language, d.heading, d.content, d.line_start, d.line_end, bm25(doc_chunks_fts) AS rank
|
|
1248
|
-
FROM doc_chunks_fts JOIN doc_chunks d ON d.rowid = doc_chunks_fts.rowid
|
|
1249
|
-
WHERE doc_chunks_fts MATCH ? ${ftsOriginFilter}
|
|
1250
|
-
ORDER BY rank LIMIT ?
|
|
1251
|
-
`).all(ftsQuery, ...originParams, Math.max(limit * 4, 20)) : []
|
|
1252
|
-
|
|
1253
|
-
if (!memory.embedder) return ftsList.slice(0, limit)
|
|
1254
|
-
|
|
1255
|
-
// 向量通道
|
|
1256
|
-
await ensureDocEmbeddings(memory)
|
|
1257
|
-
const [qvec] = await embed(memory.embedder, [query])
|
|
1258
|
-
const rows = memory.db.prepare(`SELECT rowid, embedding FROM doc_chunks WHERE embedding IS NOT NULL ${vecOriginFilter}`).all(...originParams)
|
|
1259
|
-
const vecList = rows
|
|
1260
|
-
.map((r) => ({ rowid: r.rowid, score: cosine(qvec, fromBlob(r.embedding)) }))
|
|
1261
|
-
.sort((a, b) => b.score - a.score)
|
|
1262
|
-
.slice(0, Math.max(limit * 4, 20))
|
|
1263
|
-
|
|
1264
|
-
// RRF 合并(按 rowid 对齐两个通道;解析结果回表取,纯向量命中的块也能浮现)
|
|
1265
|
-
const K = 60
|
|
1266
|
-
const scores = new Map()
|
|
1267
|
-
ftsList.forEach((r, i) => scores.set(r.rowid, (scores.get(r.rowid) ?? 0) + 1 / (K + i + 1)))
|
|
1268
|
-
vecList.forEach((r, i) => scores.set(r.rowid, (scores.get(r.rowid) ?? 0) + 1 / (K + i + 1)))
|
|
1269
|
-
|
|
1270
|
-
const fetchChunk = memory.db.prepare(`
|
|
1271
|
-
SELECT path, language, heading, content, line_start, line_end FROM doc_chunks WHERE rowid = ?
|
|
1272
|
-
`)
|
|
1273
|
-
return [...scores.entries()]
|
|
1274
|
-
.sort((a, b) => b[1] - a[1])
|
|
1275
|
-
.slice(0, limit)
|
|
1276
|
-
.map(([rowid]) => fetchChunk.get(rowid))
|
|
1277
|
-
.filter(Boolean)
|
|
1278
|
-
}
|
|
1279
|
-
|
|
1280
|
-
/** 惰性补算 doc_chunks 缺失的向量 */
|
|
1281
|
-
async function ensureDocEmbeddings(memory) {
|
|
1282
|
-
if (!memory.embedder) return
|
|
1283
|
-
const modelKey = memory.embedder.model
|
|
1284
|
-
const stored = memory.db.prepare(`SELECT value FROM meta WHERE key = 'doc_embedding_model'`).get()?.value
|
|
1285
|
-
if (stored !== modelKey) {
|
|
1286
|
-
memory.db.prepare(`UPDATE doc_chunks SET embedding = NULL`).run()
|
|
1287
|
-
memory.db.prepare(`INSERT INTO meta (key, value) VALUES ('doc_embedding_model', ?)
|
|
1288
|
-
ON CONFLICT (key) DO UPDATE SET value = excluded.value`).run(modelKey)
|
|
1289
|
-
}
|
|
1290
|
-
|
|
1291
|
-
const pending = memory.db.prepare(`SELECT rowid, path, heading, content FROM doc_chunks WHERE embedding IS NULL LIMIT 64`).all()
|
|
1292
|
-
if (pending.length === 0) return
|
|
1293
|
-
|
|
1294
|
-
const texts = pending.map((r) => `${r.heading || r.path}\n${r.content.slice(0, 2000)}`)
|
|
1295
|
-
const vecs = await embed(memory.embedder, texts)
|
|
1296
|
-
|
|
1297
|
-
const update = memory.db.prepare(`UPDATE doc_chunks SET embedding = ? WHERE rowid = ?`)
|
|
1298
|
-
pending.forEach((r, i) => update.run(toBlob(vecs[i]), r.rowid))
|
|
1299
|
-
}
|
|
1300
|
-
|
|
1301
|
-
/**
|
|
1302
|
-
* 生成 doc_search 工具(只读)。
|
|
1303
|
-
*/
|
|
1304
|
-
export function docSearchTool(memory) {
|
|
1305
|
-
return {
|
|
1306
|
-
name: "doc_search",
|
|
1307
|
-
description:
|
|
1308
|
-
"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.",
|
|
1309
|
-
parameters: {
|
|
1310
|
-
type: "object",
|
|
1311
|
-
properties: {
|
|
1312
|
-
query: { type: "string", description: "Natural language search query" },
|
|
1313
|
-
limit: { type: "number", description: "Max results (default 5)" },
|
|
1314
|
-
},
|
|
1315
|
-
required: ["query"],
|
|
1316
|
-
},
|
|
1317
|
-
readonly: true,
|
|
1318
|
-
async execute(args) {
|
|
1319
|
-
const results = await docSearch(memory, args.query, { limit: args.limit ?? 5 })
|
|
1320
|
-
if (results.length === 0) return "(no matching documentation)"
|
|
1321
|
-
return results.map((r) =>
|
|
1322
|
-
`${r.path}${r.heading ? ` > ${r.heading}` : ""} (L${r.line_start}-L${r.line_end}):\n${r.content.slice(0, 2000)}`
|
|
1323
|
-
).join("\n\n---\n\n")
|
|
1324
|
-
},
|
|
1325
|
-
}
|
|
1326
|
-
}
|
|
1327
|
-
|
|
1328
|
-
// ---------------------------------------------------------------- agent 工具
|
|
1329
|
-
|
|
1330
|
-
/**
|
|
1331
|
-
* 生成记忆相关的两个 agent 工具(遵循 tools.mjs 的工具形状)。
|
|
1332
|
-
* memory_put 是有副作用工具(需权限确认),memory_search 只读。
|
|
1333
|
-
* opts: { cwd, projectDir, author, team: { dir, name } | null }
|
|
1334
|
-
*/
|
|
1335
|
-
export function memoryTools(memory, opts = {}) {
|
|
1336
|
-
const projectDir = opts.projectDir ? join(opts.cwd ?? process.cwd(), opts.projectDir) : null
|
|
1337
|
-
return [
|
|
1338
|
-
{
|
|
1339
|
-
name: "memory_put",
|
|
1340
|
-
description:
|
|
1341
|
-
"Save a piece of knowledge to long-term memory. Use when you learn something worth remembering across sessions: a project convention, a debugging insight, an architecture decision. Types: rule (coding standards), knowledge (project facts), decision (architecture decisions), pattern (debugging/workflow patterns). Scopes: personal (default, private to you), project (shared via this repo's .thincoder/memory/), team (org-wide team repo, if configured).",
|
|
1342
|
-
parameters: {
|
|
1343
|
-
type: "object",
|
|
1344
|
-
properties: {
|
|
1345
|
-
type: { type: "string", enum: ["rule", "knowledge", "decision", "pattern"] },
|
|
1346
|
-
title: { type: "string", description: "Short title" },
|
|
1347
|
-
content: { type: "string", description: "Full content to remember" },
|
|
1348
|
-
tags: { type: "string", description: "Space-separated tags" },
|
|
1349
|
-
scope: { type: "string", enum: ["personal", "project", "team"], description: "Where to save (default personal)" },
|
|
1350
|
-
},
|
|
1351
|
-
required: ["type", "title", "content"],
|
|
1352
|
-
},
|
|
1353
|
-
readonly: false,
|
|
1354
|
-
async execute(args) {
|
|
1355
|
-
const scope = args.scope ?? "personal"
|
|
1356
|
-
if (scope === "personal") {
|
|
1357
|
-
const id = await put(memory, args)
|
|
1358
|
-
return `Saved to personal memory (id=${id}): [${args.type}] ${args.title}`
|
|
1359
|
-
}
|
|
1360
|
-
if (scope === "project") {
|
|
1361
|
-
if (!projectDir) throw new Error("project scope unavailable: no project directory configured")
|
|
1362
|
-
const filename = await putMarkdown(memory, {
|
|
1363
|
-
layer: "project",
|
|
1364
|
-
dir: projectDir,
|
|
1365
|
-
type: args.type,
|
|
1366
|
-
title: args.title,
|
|
1367
|
-
content: args.content,
|
|
1368
|
-
tags: (args.tags ?? "").split(/\s+/).filter(Boolean),
|
|
1369
|
-
author: opts.author ?? "unknown",
|
|
1370
|
-
})
|
|
1371
|
-
return `Saved to project memory (${filename}): [${args.type}] ${args.title}\nNote: file written to the repo; commit it yourself when ready.`
|
|
1372
|
-
}
|
|
1373
|
-
// team:写文件 + 索引 + commit + push(team 仓库是 ThinCoder 自管设施,可以自动提交)
|
|
1374
|
-
if (!opts.team?.dir) {
|
|
1375
|
-
throw new Error("team scope not configured: set memory.team in ~/.thincoder/config.json")
|
|
1376
|
-
}
|
|
1377
|
-
const filename = await putMarkdown(memory, {
|
|
1378
|
-
layer: "team",
|
|
1379
|
-
dir: opts.team.dir,
|
|
1380
|
-
type: args.type,
|
|
1381
|
-
title: args.title,
|
|
1382
|
-
content: args.content,
|
|
1383
|
-
tags: (args.tags ?? "").split(/\s+/).filter(Boolean),
|
|
1384
|
-
author: opts.author ?? "unknown",
|
|
1385
|
-
})
|
|
1386
|
-
await commitAndPush(opts.team.dir, filename, `memory: [${args.type}] ${args.title}`)
|
|
1387
|
-
return `Saved to team memory and pushed (${filename}): [${args.type}] ${args.title}`
|
|
1388
|
-
},
|
|
1389
|
-
},
|
|
1390
|
-
{
|
|
1391
|
-
name: "memory_search",
|
|
1392
|
-
description:
|
|
1393
|
-
"Search long-term memory across all layers (personal/project/team) for relevant knowledge saved in previous sessions. Use the same language as the memories being searched.",
|
|
1394
|
-
parameters: {
|
|
1395
|
-
type: "object",
|
|
1396
|
-
properties: {
|
|
1397
|
-
query: { type: "string", description: "Natural language search query" },
|
|
1398
|
-
limit: { type: "number", description: "Max results (default 5)" },
|
|
1399
|
-
},
|
|
1400
|
-
required: ["query"],
|
|
1401
|
-
},
|
|
1402
|
-
readonly: true,
|
|
1403
|
-
async execute(args) {
|
|
1404
|
-
const results = await search(memory, args.query, { limit: args.limit ?? 5 })
|
|
1405
|
-
if (results.length === 0) return "(no matching memories)"
|
|
1406
|
-
return results.map((r) => `[${r.layer}][${r.type}] ${r.title}\n${r.content}`).join("\n\n")
|
|
1407
|
-
},
|
|
1408
|
-
},
|
|
1409
|
-
]
|
|
1410
|
-
}
|
|
18
|
+
// doc sync + search + memoryTools
|
|
19
|
+
export { docSync, docSearch, ensureDocEmbeddings, docSearchTool, memoryTools } from "./memory/docs.mjs"
|