thincoder 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +162 -0
- package/bin/thincoder.mjs +383 -0
- package/package.json +29 -0
- package/src/agent.mjs +351 -0
- package/src/checkpoint.mjs +135 -0
- package/src/config.mjs +106 -0
- package/src/context.mjs +76 -0
- package/src/distill.mjs +117 -0
- package/src/embedding.mjs +107 -0
- package/src/gitmem.mjs +87 -0
- package/src/markdown.mjs +99 -0
- package/src/memory.mjs +495 -0
- package/src/provider.mjs +153 -0
- package/src/session.mjs +53 -0
- package/src/tools.mjs +513 -0
- package/src/tui.mjs +912 -0
package/src/memory.mjs
ADDED
|
@@ -0,0 +1,495 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* memory.mjs — 记忆系统(扩展位 ⭐)
|
|
3
|
+
* v1:node:sqlite + FTS5 单机实现,BM25 排序,零依赖。
|
|
4
|
+
* v2 团队版:同一接口 + git 同步层 + 向量检索 + RRF,调用方无感。
|
|
5
|
+
*
|
|
6
|
+
* entry.type ∈ rule | knowledge | decision | pattern(对齐团队记忆四类内容)
|
|
7
|
+
*
|
|
8
|
+
* 中文检索方案:FTS5 unicode61 分词 + CJK 逐字加空格(写入和查询两侧同样处理)。
|
|
9
|
+
* 效果:中文按字索引,"分号" 这类双字词也能命中;ASCII 仍按整词。
|
|
10
|
+
* 语义层面的匹配(如 "规范" vs "风格")留给 v2 向量检索。
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { DatabaseSync } from "node:sqlite"
|
|
14
|
+
import { mkdirSync } from "node:fs"
|
|
15
|
+
import { readFile, readdir, stat, writeFile, mkdir } from "node:fs/promises"
|
|
16
|
+
import { dirname, join } 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"
|
|
20
|
+
|
|
21
|
+
const VALID_TYPES = new Set(["rule", "knowledge", "decision", "pattern"])
|
|
22
|
+
const SCHEMA_VERSION = 5
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 打开/初始化记忆库。dbPath 不存在会自动创建。
|
|
26
|
+
* 返回的 memory 对象即接口,后续函数的第一个参数都是它。
|
|
27
|
+
*/
|
|
28
|
+
export function createMemory({ dbPath }) {
|
|
29
|
+
mkdirSync(dirname(dbPath), { recursive: true })
|
|
30
|
+
const db = new DatabaseSync(dbPath)
|
|
31
|
+
|
|
32
|
+
db.exec(`
|
|
33
|
+
CREATE TABLE IF NOT EXISTS entries (
|
|
34
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
35
|
+
type TEXT NOT NULL CHECK(type IN ('rule','knowledge','decision','pattern')),
|
|
36
|
+
title TEXT NOT NULL,
|
|
37
|
+
content TEXT NOT NULL,
|
|
38
|
+
tags TEXT NOT NULL DEFAULT '',
|
|
39
|
+
seg_title TEXT NOT NULL DEFAULT '',
|
|
40
|
+
seg_content TEXT NOT NULL DEFAULT '',
|
|
41
|
+
seg_tags TEXT NOT NULL DEFAULT '',
|
|
42
|
+
created_at INTEGER NOT NULL,
|
|
43
|
+
updated_at INTEGER NOT NULL
|
|
44
|
+
)
|
|
45
|
+
`)
|
|
46
|
+
|
|
47
|
+
migrate(db)
|
|
48
|
+
return { db }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** 按 user_version 逐步迁移 */
|
|
52
|
+
function migrate(db) {
|
|
53
|
+
const { user_version: version } = db.prepare(`PRAGMA user_version`).get()
|
|
54
|
+
if (version >= SCHEMA_VERSION) return
|
|
55
|
+
|
|
56
|
+
if (version < 2) {
|
|
57
|
+
// v1(trigram) 或空库 → v2(unicode61 + CJK 逐字):重建 FTS 和触发器
|
|
58
|
+
db.exec(`
|
|
59
|
+
DROP TRIGGER IF EXISTS entries_ai;
|
|
60
|
+
DROP TRIGGER IF EXISTS entries_ad;
|
|
61
|
+
DROP TRIGGER IF EXISTS entries_au;
|
|
62
|
+
DROP TABLE IF EXISTS entries_fts;
|
|
63
|
+
`)
|
|
64
|
+
// 老库(v1)没有 seg 列,补上
|
|
65
|
+
const columns = db.prepare(`PRAGMA table_info(entries)`).all().map((c) => c.name)
|
|
66
|
+
for (const col of ["seg_title", "seg_content", "seg_tags"]) {
|
|
67
|
+
if (!columns.includes(col)) {
|
|
68
|
+
db.exec(`ALTER TABLE entries ADD COLUMN ${col} TEXT NOT NULL DEFAULT ''`)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
// 回填 seg 列(JS 侧分字,SQL 做不了)
|
|
72
|
+
const rows = db.prepare(`SELECT id, title, content, tags FROM entries`).all()
|
|
73
|
+
const update = db.prepare(`UPDATE entries SET seg_title = ?, seg_content = ?, seg_tags = ? WHERE id = ?`)
|
|
74
|
+
for (const r of rows) {
|
|
75
|
+
update.run(segmentCJK(r.title), segmentCJK(r.content), segmentCJK(r.tags), r.id)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
db.exec(`
|
|
79
|
+
CREATE VIRTUAL TABLE entries_fts USING fts5(
|
|
80
|
+
seg_title, seg_content, seg_tags,
|
|
81
|
+
content='entries', content_rowid='id',
|
|
82
|
+
tokenize='unicode61'
|
|
83
|
+
)
|
|
84
|
+
`)
|
|
85
|
+
db.exec(`
|
|
86
|
+
CREATE TRIGGER entries_ai AFTER INSERT ON entries BEGIN
|
|
87
|
+
INSERT INTO entries_fts(rowid, seg_title, seg_content, seg_tags)
|
|
88
|
+
VALUES (new.id, new.seg_title, new.seg_content, new.seg_tags);
|
|
89
|
+
END;
|
|
90
|
+
CREATE TRIGGER entries_ad AFTER DELETE ON entries BEGIN
|
|
91
|
+
INSERT INTO entries_fts(entries_fts, rowid, seg_title, seg_content, seg_tags)
|
|
92
|
+
VALUES ('delete', old.id, old.seg_title, old.seg_content, old.seg_tags);
|
|
93
|
+
END;
|
|
94
|
+
CREATE TRIGGER entries_au AFTER UPDATE ON entries BEGIN
|
|
95
|
+
INSERT INTO entries_fts(entries_fts, rowid, seg_title, seg_content, seg_tags)
|
|
96
|
+
VALUES ('delete', old.id, old.seg_title, old.seg_content, old.seg_tags);
|
|
97
|
+
INSERT INTO entries_fts(rowid, seg_title, seg_content, seg_tags)
|
|
98
|
+
VALUES (new.id, new.seg_title, new.seg_content, new.seg_tags);
|
|
99
|
+
END;
|
|
100
|
+
`)
|
|
101
|
+
db.exec(`INSERT INTO entries_fts(entries_fts) VALUES('rebuild')`)
|
|
102
|
+
db.exec(`PRAGMA user_version = 2`)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (version < 3) {
|
|
106
|
+
// v3:markdown 层(project/team)的 files 表 + FTS + 触发器
|
|
107
|
+
db.exec(`
|
|
108
|
+
CREATE TABLE IF NOT EXISTS files (
|
|
109
|
+
layer TEXT NOT NULL CHECK(layer IN ('project','team')),
|
|
110
|
+
path TEXT NOT NULL,
|
|
111
|
+
type TEXT NOT NULL CHECK(type IN ('rule','knowledge','decision','pattern')),
|
|
112
|
+
title TEXT NOT NULL,
|
|
113
|
+
content TEXT NOT NULL,
|
|
114
|
+
tags TEXT NOT NULL DEFAULT '',
|
|
115
|
+
author TEXT NOT NULL DEFAULT '',
|
|
116
|
+
embedding BLOB,
|
|
117
|
+
mtime_ms INTEGER NOT NULL DEFAULT 0,
|
|
118
|
+
seg_title TEXT NOT NULL DEFAULT '',
|
|
119
|
+
seg_content TEXT NOT NULL DEFAULT '',
|
|
120
|
+
seg_tags TEXT NOT NULL DEFAULT '',
|
|
121
|
+
updated_at INTEGER NOT NULL,
|
|
122
|
+
PRIMARY KEY (layer, path)
|
|
123
|
+
)
|
|
124
|
+
`)
|
|
125
|
+
db.exec(`
|
|
126
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS files_fts USING fts5(
|
|
127
|
+
seg_title, seg_content, seg_tags,
|
|
128
|
+
content='files', content_rowid='rowid',
|
|
129
|
+
tokenize='unicode61'
|
|
130
|
+
)
|
|
131
|
+
`)
|
|
132
|
+
db.exec(`
|
|
133
|
+
CREATE TRIGGER files_ai AFTER INSERT ON files BEGIN
|
|
134
|
+
INSERT INTO files_fts(rowid, seg_title, seg_content, seg_tags)
|
|
135
|
+
VALUES (new.rowid, new.seg_title, new.seg_content, new.seg_tags);
|
|
136
|
+
END;
|
|
137
|
+
CREATE TRIGGER files_ad AFTER DELETE ON files BEGIN
|
|
138
|
+
INSERT INTO files_fts(files_fts, rowid, seg_title, seg_content, seg_tags)
|
|
139
|
+
VALUES ('delete', old.rowid, old.seg_title, old.seg_content, old.seg_tags);
|
|
140
|
+
END;
|
|
141
|
+
CREATE TRIGGER files_au AFTER UPDATE ON files BEGIN
|
|
142
|
+
INSERT INTO files_fts(files_fts, rowid, seg_title, seg_content, seg_tags)
|
|
143
|
+
VALUES ('delete', old.rowid, old.seg_title, old.seg_content, old.seg_tags);
|
|
144
|
+
INSERT INTO files_fts(rowid, seg_title, seg_content, seg_tags)
|
|
145
|
+
VALUES (new.rowid, new.seg_title, new.seg_content, new.seg_tags);
|
|
146
|
+
END;
|
|
147
|
+
`)
|
|
148
|
+
db.exec(`PRAGMA user_version = 3`)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (version < 4) {
|
|
152
|
+
// v4:personal 的 entries 表加向量列(files 表在 v3 已带);meta 表存 embedding 模型名
|
|
153
|
+
const columns = db.prepare(`PRAGMA table_info(entries)`).all().map((c) => c.name)
|
|
154
|
+
if (!columns.includes("embedding")) {
|
|
155
|
+
db.exec(`ALTER TABLE entries ADD COLUMN embedding BLOB`)
|
|
156
|
+
}
|
|
157
|
+
db.exec(`CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT)`)
|
|
158
|
+
db.exec(`PRAGMA user_version = 4`)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (version < 5) {
|
|
162
|
+
// v5:files 表加 origin 列(项目绝对路径),防止跨项目记忆串台
|
|
163
|
+
const columns = db.prepare(`PRAGMA table_info(files)`).all().map((c) => c.name)
|
|
164
|
+
if (!columns.includes("origin")) {
|
|
165
|
+
db.exec(`ALTER TABLE files ADD COLUMN origin TEXT NOT NULL DEFAULT ''`)
|
|
166
|
+
}
|
|
167
|
+
db.exec(`PRAGMA user_version = 5`)
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* CJK 逐字加空格:让 unicode61 把每个汉字/日韩字当独立 token。
|
|
173
|
+
* 写入和查询必须使用同一处理,检索才能对上。
|
|
174
|
+
*/
|
|
175
|
+
function segmentCJK(text) {
|
|
176
|
+
return text.replace(
|
|
177
|
+
/[-ヿ㐀-䶿一-鿿豈-가-]+/g,
|
|
178
|
+
(run) => [...run].join(" "),
|
|
179
|
+
)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* 写入一条记忆。entry: { type, title, content, tags? }
|
|
184
|
+
* 返回新条目 id。
|
|
185
|
+
*/
|
|
186
|
+
export async function put(memory, { type, title, content, tags = "" }) {
|
|
187
|
+
if (!VALID_TYPES.has(type)) {
|
|
188
|
+
throw new Error(`Invalid memory type "${type}"; expected one of: ${[...VALID_TYPES].join(", ")}`)
|
|
189
|
+
}
|
|
190
|
+
if (!title || !content) throw new Error("memory entry requires title and content")
|
|
191
|
+
const now = Date.now()
|
|
192
|
+
const stmt = memory.db.prepare(
|
|
193
|
+
`INSERT INTO entries (type, title, content, tags, seg_title, seg_content, seg_tags, created_at, updated_at)
|
|
194
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
195
|
+
)
|
|
196
|
+
const info = stmt.run(type, title, content, tags, segmentCJK(title), segmentCJK(content), segmentCJK(tags), now, now)
|
|
197
|
+
return Number(info.lastInsertRowid)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* 混合检索:FTS5(BM25) + 向量余弦,RRF(k=60) 合并排序。
|
|
202
|
+
* 无 embedder 时退化为纯 FTS。结果带 layer 标记。
|
|
203
|
+
* 返回 [{ id, layer, type, title, content, tags, rank }]
|
|
204
|
+
*/
|
|
205
|
+
export async function search(memory, query, { limit = 5 } = {}) {
|
|
206
|
+
const ftsQuery = buildFtsQuery(query)
|
|
207
|
+
const ftsList = ftsQuery ? ftsSearch(memory, ftsQuery, Math.max(limit * 4, 20)) : []
|
|
208
|
+
|
|
209
|
+
if (!memory.embedder) return ftsList.slice(0, limit)
|
|
210
|
+
|
|
211
|
+
// ---- 向量通道 ----
|
|
212
|
+
await ensureEmbeddings(memory)
|
|
213
|
+
const [qvec] = await embed(memory.embedder, [query])
|
|
214
|
+
const vecFilter = memory.projectOrigin ? `AND (layer = 'team' OR origin = ?)` : ""
|
|
215
|
+
const vecParams = memory.projectOrigin ? [memory.projectOrigin] : []
|
|
216
|
+
const rows = memory.db.prepare(`
|
|
217
|
+
SELECT 'personal:' || id AS uid, embedding FROM entries WHERE embedding IS NOT NULL
|
|
218
|
+
UNION ALL
|
|
219
|
+
SELECT layer || ':' || path AS uid, embedding FROM files WHERE embedding IS NOT NULL ${vecFilter}
|
|
220
|
+
`).all(...vecParams)
|
|
221
|
+
const vecList = rows
|
|
222
|
+
.map((r) => ({ id: r.uid, score: cosine(qvec, fromBlob(r.embedding)) }))
|
|
223
|
+
.sort((a, b) => b.score - a.score)
|
|
224
|
+
.slice(0, Math.max(limit * 4, 20))
|
|
225
|
+
|
|
226
|
+
// ---- RRF 合并 ----
|
|
227
|
+
const K = 60
|
|
228
|
+
const scores = new Map()
|
|
229
|
+
ftsList.forEach((r, i) => scores.set(r.id, (scores.get(r.id) ?? 0) + 1 / (K + i + 1)))
|
|
230
|
+
vecList.forEach((r, i) => scores.set(r.id, (scores.get(r.id) ?? 0) + 1 / (K + i + 1)))
|
|
231
|
+
|
|
232
|
+
return [...scores.entries()]
|
|
233
|
+
.sort((a, b) => b[1] - a[1])
|
|
234
|
+
.slice(0, limit)
|
|
235
|
+
.map(([id, score]) => ({ ...fetchEntry(memory, id), rrf: score }))
|
|
236
|
+
.filter(Boolean)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** 纯 FTS 检索(两表合并,按 bm25 排序),RRF 的位置输入 */
|
|
240
|
+
function ftsSearch(memory, ftsQuery, limit) {
|
|
241
|
+
const personal = memory.db.prepare(`
|
|
242
|
+
SELECT e.id, e.type, e.title, e.content, e.tags, bm25(entries_fts) AS rank
|
|
243
|
+
FROM entries_fts JOIN entries e ON e.id = entries_fts.rowid
|
|
244
|
+
WHERE entries_fts MATCH ?
|
|
245
|
+
ORDER BY rank LIMIT ?
|
|
246
|
+
`).all(ftsQuery, limit).map((r) => ({ ...r, layer: "personal", id: `personal:${r.id}` }))
|
|
247
|
+
|
|
248
|
+
// projectOrigin 设置时只返回本项目的 project 条目(team 层不过滤);未设置时(全局 CLI)不过滤
|
|
249
|
+
const originFilter = memory.projectOrigin ? `AND (f.layer = 'team' OR f.origin = ?)` : ""
|
|
250
|
+
const originParams = memory.projectOrigin ? [ftsQuery, memory.projectOrigin, limit] : [ftsQuery, limit]
|
|
251
|
+
const files = memory.db.prepare(`
|
|
252
|
+
SELECT f.layer, f.path, f.type, f.title, f.content, f.tags, f.author, bm25(files_fts) AS rank
|
|
253
|
+
FROM files_fts JOIN files f ON f.rowid = files_fts.rowid
|
|
254
|
+
WHERE files_fts MATCH ? ${originFilter}
|
|
255
|
+
ORDER BY rank LIMIT ?
|
|
256
|
+
`).all(...originParams).map((r) => ({ ...r, id: `${r.layer}:${r.path}` }))
|
|
257
|
+
|
|
258
|
+
return [...personal, ...files].sort((a, b) => a.rank - b.rank).slice(0, limit)
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** 按统一 id 取完整条目(personal:<n> / project:<path> / team:<path>) */
|
|
262
|
+
function fetchEntry(memory, uid) {
|
|
263
|
+
const [layer, ...rest] = uid.split(":")
|
|
264
|
+
const key = rest.join(":")
|
|
265
|
+
if (layer === "personal") {
|
|
266
|
+
const r = memory.db.prepare(`SELECT id, type, title, content, tags FROM entries WHERE id = ?`).get(Number(key))
|
|
267
|
+
return r ? { ...r, layer, id: uid } : null
|
|
268
|
+
}
|
|
269
|
+
const r = memory.db.prepare(`SELECT type, title, content, tags, author FROM files WHERE layer = ? AND path = ?`).get(layer, key)
|
|
270
|
+
return r ? { ...r, layer, id: uid } : null
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* 惰性 embedding:把还没有向量的条目批量补算落库(首次慢、后续零成本)。
|
|
275
|
+
* 检测到 embedding 模型变更时,清空全部向量重建。
|
|
276
|
+
*/
|
|
277
|
+
async function ensureEmbeddings(memory) {
|
|
278
|
+
const modelKey = memory.embedder.model
|
|
279
|
+
const stored = memory.db.prepare(`SELECT value FROM meta WHERE key = 'embedding_model'`).get()?.value
|
|
280
|
+
if (stored !== modelKey) {
|
|
281
|
+
memory.db.prepare(`UPDATE entries SET embedding = NULL`).run()
|
|
282
|
+
memory.db.prepare(`UPDATE files SET embedding = NULL`).run()
|
|
283
|
+
memory.db.prepare(`INSERT INTO meta (key, value) VALUES ('embedding_model', ?)
|
|
284
|
+
ON CONFLICT (key) DO UPDATE SET value = excluded.value`).run(modelKey)
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const pendingEntries = memory.db.prepare(`SELECT id, title, content FROM entries WHERE embedding IS NULL LIMIT 256`).all()
|
|
288
|
+
const pendingFiles = memory.db.prepare(`SELECT rowid, title, content FROM files WHERE embedding IS NULL LIMIT 256`).all()
|
|
289
|
+
if (pendingEntries.length + pendingFiles.length === 0) return
|
|
290
|
+
|
|
291
|
+
const items = [...pendingEntries, ...pendingFiles]
|
|
292
|
+
const texts = items.map((r) => `${r.title}\n${r.content.slice(0, 2000)}`)
|
|
293
|
+
const vecs = await embed(memory.embedder, texts)
|
|
294
|
+
|
|
295
|
+
const updateEntry = memory.db.prepare(`UPDATE entries SET embedding = ? WHERE id = ?`)
|
|
296
|
+
pendingEntries.forEach((r, i) => updateEntry.run(toBlob(vecs[i]), r.id))
|
|
297
|
+
const updateFile = memory.db.prepare(`UPDATE files SET embedding = ? WHERE rowid = ?`)
|
|
298
|
+
pendingFiles.forEach((r, i) => updateFile.run(toBlob(vecs[pendingEntries.length + i]), r.rowid))
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* 写入一条 markdown 记忆到指定层目录(project/team),并即时索引。
|
|
303
|
+
* 只写文件——project 层绝不替用户的项目仓库做 git 操作;
|
|
304
|
+
* team 层的 commit+push 由 gitmem.mjs 负责(M8)。
|
|
305
|
+
* 返回文件名。
|
|
306
|
+
*/
|
|
307
|
+
export async function putMarkdown(memory, { layer, dir, type, title, content, tags = [], author = "unknown" }) {
|
|
308
|
+
if (layer !== "project" && layer !== "team") throw new Error(`invalid markdown layer: ${layer}`)
|
|
309
|
+
const filename = entryFilename(title)
|
|
310
|
+
const markdown = serializeEntry({ type, title, tags, author }, content)
|
|
311
|
+
await mkdir(dir, { recursive: true })
|
|
312
|
+
await writeFile(join(dir, filename), markdown, "utf8")
|
|
313
|
+
await indexMarkdownFile(memory, { layer, dir, filename })
|
|
314
|
+
return filename
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* 同步一个 markdown 目录到索引:新增/变更(按 mtime)重建索引,消失的条目从索引删除。
|
|
319
|
+
* 返回 { added, updated, removed }
|
|
320
|
+
*/
|
|
321
|
+
export async function syncDir(memory, { layer, dir }) {
|
|
322
|
+
let names = []
|
|
323
|
+
try {
|
|
324
|
+
names = (await readdir(dir)).filter((n) => n.endsWith(".md"))
|
|
325
|
+
} catch {
|
|
326
|
+
names = [] // 目录不存在 = 这层没内容
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const indexed = new Map(
|
|
330
|
+
memory.db.prepare(`SELECT path, mtime_ms FROM files WHERE layer = ?`).all(layer).map((r) => [r.path, r.mtime_ms]),
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
let added = 0, updated = 0, skipped = 0
|
|
334
|
+
for (const filename of names) {
|
|
335
|
+
const mtimeMs = Math.floor((await stat(join(dir, filename))).mtimeMs)
|
|
336
|
+
const old = indexed.get(filename)
|
|
337
|
+
const isNew = old === undefined
|
|
338
|
+
if (!isNew && old === mtimeMs) continue
|
|
339
|
+
try {
|
|
340
|
+
await indexMarkdownFile(memory, { layer, dir, filename, mtimeMs })
|
|
341
|
+
} catch {
|
|
342
|
+
skipped++ // 无 frontmatter 的非条目文件(README 等)跳过,不入索引
|
|
343
|
+
indexed.delete(filename)
|
|
344
|
+
continue
|
|
345
|
+
}
|
|
346
|
+
if (isNew) added++
|
|
347
|
+
else updated++
|
|
348
|
+
indexed.delete(filename)
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// 索引里剩下的是磁盘上已消失的
|
|
352
|
+
let removed = 0
|
|
353
|
+
for (const stale of indexed.keys()) {
|
|
354
|
+
memory.db.prepare(`DELETE FROM files WHERE layer = ? AND path = ?`).run(layer, stale)
|
|
355
|
+
removed++
|
|
356
|
+
}
|
|
357
|
+
return { added, updated, removed, skipped }
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** 解析单个 .md 并 upsert 进 files 表 */
|
|
361
|
+
async function indexMarkdownFile(memory, { layer, dir, filename, mtimeMs }) {
|
|
362
|
+
const abs = join(dir, filename)
|
|
363
|
+
const mtime = mtimeMs ?? Math.floor((await stat(abs)).mtimeMs)
|
|
364
|
+
const { meta, content } = parseEntry(await readFile(abs, "utf8"))
|
|
365
|
+
const tags = meta.tags.join(" ")
|
|
366
|
+
memory.db.prepare(`
|
|
367
|
+
INSERT INTO files (layer, path, type, title, content, tags, author, mtime_ms, origin, seg_title, seg_content, seg_tags, updated_at)
|
|
368
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
369
|
+
ON CONFLICT (layer, path) DO UPDATE SET
|
|
370
|
+
type=excluded.type, title=excluded.title, content=excluded.content, tags=excluded.tags,
|
|
371
|
+
author=excluded.author, mtime_ms=excluded.mtime_ms, origin=excluded.origin,
|
|
372
|
+
seg_title=excluded.seg_title, seg_content=excluded.seg_content, seg_tags=excluded.seg_tags,
|
|
373
|
+
updated_at=excluded.updated_at
|
|
374
|
+
`).run(
|
|
375
|
+
layer, filename, meta.type, meta.title, content, tags, meta.author, mtime, dir,
|
|
376
|
+
segmentCJK(meta.title), segmentCJK(content), segmentCJK(tags), Date.now(),
|
|
377
|
+
)
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** 列出新条目,可按 type 过滤 */
|
|
381
|
+
export async function list(memory, { type, limit = 50 } = {}) {
|
|
382
|
+
if (type) {
|
|
383
|
+
if (!VALID_TYPES.has(type)) throw new Error(`Invalid memory type "${type}"`)
|
|
384
|
+
return memory.db
|
|
385
|
+
.prepare(`SELECT id, type, title, content, tags, updated_at FROM entries WHERE type = ? ORDER BY updated_at DESC LIMIT ?`)
|
|
386
|
+
.all(type, limit)
|
|
387
|
+
}
|
|
388
|
+
return memory.db
|
|
389
|
+
.prepare(`SELECT id, type, title, content, tags, updated_at FROM entries ORDER BY updated_at DESC LIMIT ?`)
|
|
390
|
+
.all(limit)
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** 删除一条记忆。返回是否删除成功 */
|
|
394
|
+
export async function remove(memory, id) {
|
|
395
|
+
const info = memory.db.prepare(`DELETE FROM entries WHERE id = ?`).run(id)
|
|
396
|
+
return info.changes > 0
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* 构造 FTS5 查询:先对查询做同样的 CJK 分字,再按空白/标点切词,
|
|
401
|
+
* 每个词加引号,OR 连接(AND 太严格:自然语言查询一词不中全灭)。
|
|
402
|
+
*/
|
|
403
|
+
function buildFtsQuery(query) {
|
|
404
|
+
const terms = segmentCJK(query)
|
|
405
|
+
.split(/[\s,,。、;;!!??()()"'`]+/)
|
|
406
|
+
.map((t) => t.trim())
|
|
407
|
+
.filter(Boolean)
|
|
408
|
+
.slice(0, 16)
|
|
409
|
+
if (terms.length === 0) return ""
|
|
410
|
+
return terms.map((t) => `"${t.replaceAll('"', '""')}"`).join(" OR ")
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// ---------------------------------------------------------------- agent 工具
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* 生成记忆相关的两个 agent 工具(遵循 tools.mjs 的工具形状)。
|
|
417
|
+
* memory_put 是有副作用工具(需权限确认),memory_search 只读。
|
|
418
|
+
* opts: { cwd, projectDir, author, team: { dir, name } | null }
|
|
419
|
+
*/
|
|
420
|
+
export function memoryTools(memory, opts = {}) {
|
|
421
|
+
const projectDir = opts.projectDir ? join(opts.cwd ?? process.cwd(), opts.projectDir) : null
|
|
422
|
+
return [
|
|
423
|
+
{
|
|
424
|
+
name: "memory_put",
|
|
425
|
+
description:
|
|
426
|
+
"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).",
|
|
427
|
+
parameters: {
|
|
428
|
+
type: "object",
|
|
429
|
+
properties: {
|
|
430
|
+
type: { type: "string", enum: ["rule", "knowledge", "decision", "pattern"] },
|
|
431
|
+
title: { type: "string", description: "Short title" },
|
|
432
|
+
content: { type: "string", description: "Full content to remember" },
|
|
433
|
+
tags: { type: "string", description: "Space-separated tags" },
|
|
434
|
+
scope: { type: "string", enum: ["personal", "project", "team"], description: "Where to save (default personal)" },
|
|
435
|
+
},
|
|
436
|
+
required: ["type", "title", "content"],
|
|
437
|
+
},
|
|
438
|
+
readonly: false,
|
|
439
|
+
async execute(args) {
|
|
440
|
+
const scope = args.scope ?? "personal"
|
|
441
|
+
if (scope === "personal") {
|
|
442
|
+
const id = await put(memory, args)
|
|
443
|
+
return `Saved to personal memory (id=${id}): [${args.type}] ${args.title}`
|
|
444
|
+
}
|
|
445
|
+
if (scope === "project") {
|
|
446
|
+
if (!projectDir) throw new Error("project scope unavailable: no project directory configured")
|
|
447
|
+
const filename = await putMarkdown(memory, {
|
|
448
|
+
layer: "project",
|
|
449
|
+
dir: projectDir,
|
|
450
|
+
type: args.type,
|
|
451
|
+
title: args.title,
|
|
452
|
+
content: args.content,
|
|
453
|
+
tags: (args.tags ?? "").split(/\s+/).filter(Boolean),
|
|
454
|
+
author: opts.author ?? "unknown",
|
|
455
|
+
})
|
|
456
|
+
return `Saved to project memory (${filename}): [${args.type}] ${args.title}\nNote: file written to the repo; commit it yourself when ready.`
|
|
457
|
+
}
|
|
458
|
+
// team:写文件 + 索引 + commit + push(team 仓库是 ThinCoder 自管设施,可以自动提交)
|
|
459
|
+
if (!opts.team?.dir) {
|
|
460
|
+
throw new Error("team scope not configured: set memory.team in ~/.thincoder/config.json")
|
|
461
|
+
}
|
|
462
|
+
const filename = await putMarkdown(memory, {
|
|
463
|
+
layer: "team",
|
|
464
|
+
dir: opts.team.dir,
|
|
465
|
+
type: args.type,
|
|
466
|
+
title: args.title,
|
|
467
|
+
content: args.content,
|
|
468
|
+
tags: (args.tags ?? "").split(/\s+/).filter(Boolean),
|
|
469
|
+
author: opts.author ?? "unknown",
|
|
470
|
+
})
|
|
471
|
+
await commitAndPush(opts.team.dir, filename, `memory: [${args.type}] ${args.title}`)
|
|
472
|
+
return `Saved to team memory and pushed (${filename}): [${args.type}] ${args.title}`
|
|
473
|
+
},
|
|
474
|
+
},
|
|
475
|
+
{
|
|
476
|
+
name: "memory_search",
|
|
477
|
+
description:
|
|
478
|
+
"Search long-term memory across all layers (personal/project/team) for relevant knowledge saved in previous sessions. Query in the same language as the memories (Chinese memories need Chinese queries).",
|
|
479
|
+
parameters: {
|
|
480
|
+
type: "object",
|
|
481
|
+
properties: {
|
|
482
|
+
query: { type: "string", description: "Natural language search query" },
|
|
483
|
+
limit: { type: "number", description: "Max results (default 5)" },
|
|
484
|
+
},
|
|
485
|
+
required: ["query"],
|
|
486
|
+
},
|
|
487
|
+
readonly: true,
|
|
488
|
+
async execute(args) {
|
|
489
|
+
const results = await search(memory, args.query, { limit: args.limit ?? 5 })
|
|
490
|
+
if (results.length === 0) return "(no matching memories)"
|
|
491
|
+
return results.map((r) => `[${r.layer}][${r.type}] ${r.title}\n${r.content}`).join("\n\n")
|
|
492
|
+
},
|
|
493
|
+
},
|
|
494
|
+
]
|
|
495
|
+
}
|
package/src/provider.mjs
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* provider.mjs — LLM 调用层
|
|
3
|
+
* 原生 fetch 直连 OpenAI 兼容协议,SSE 流式,零依赖。
|
|
4
|
+
* 覆盖:OpenAI / DeepSeek / Moonshot / Ollama / 一切 OpenAI 兼容端点。
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export const RETRYABLE_STATUS = new Set([408, 409, 425, 429, 500, 502, 503, 504])
|
|
8
|
+
const MAX_RETRIES = 3
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* 创建 provider。config: { baseURL, apiKey, model, maxTokens?, temperature? }
|
|
12
|
+
*/
|
|
13
|
+
export function createProvider(config) {
|
|
14
|
+
if (!config?.baseURL) throw new Error("provider config: baseURL is required")
|
|
15
|
+
if (!config?.apiKey) throw new Error("provider config: apiKey is required (config file or THINCODER_API_KEY env)")
|
|
16
|
+
if (!config?.model) throw new Error("provider config: model is required")
|
|
17
|
+
return {
|
|
18
|
+
baseURL: config.baseURL.replace(/\/+$/, ""),
|
|
19
|
+
apiKey: config.apiKey,
|
|
20
|
+
model: config.model,
|
|
21
|
+
maxTokens: config.maxTokens,
|
|
22
|
+
temperature: config.temperature,
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 流式对话。
|
|
28
|
+
* messages: OpenAI 格式数组; tools: OpenAI tools schema(可选)
|
|
29
|
+
* onToken(text): 正文流式回调; onReasoning(text): 思考流回调(DeepSeek-R1 类模型)
|
|
30
|
+
* signal: AbortSignal(可选)
|
|
31
|
+
* 返回 { content, reasoning, toolCalls: [{id, name, arguments}], usage, finishReason }
|
|
32
|
+
* 注意:toolCalls[i].arguments 是 JSON 字符串,调用方负责 parse
|
|
33
|
+
*/
|
|
34
|
+
export async function chat(provider, { messages, tools, onToken, onReasoning, signal }) {
|
|
35
|
+
const body = {
|
|
36
|
+
model: provider.model,
|
|
37
|
+
messages,
|
|
38
|
+
stream: true,
|
|
39
|
+
stream_options: { include_usage: true },
|
|
40
|
+
}
|
|
41
|
+
if (provider.maxTokens) body.max_tokens = provider.maxTokens
|
|
42
|
+
if (provider.temperature != null) body.temperature = provider.temperature
|
|
43
|
+
if (tools?.length) body.tools = tools
|
|
44
|
+
|
|
45
|
+
const response = await requestWithRetry(provider, body, signal)
|
|
46
|
+
return readSSE(response, { onToken, onReasoning })
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* 拉取端点可用模型列表(GET /v1/models)。
|
|
51
|
+
* 返回模型 id 数组;端点不支持时抛错。
|
|
52
|
+
*/
|
|
53
|
+
export async function listModels(provider, { signal } = {}) {
|
|
54
|
+
const response = await fetch(`${provider.baseURL}/models`, {
|
|
55
|
+
headers: { Authorization: `Bearer ${provider.apiKey}` },
|
|
56
|
+
signal,
|
|
57
|
+
})
|
|
58
|
+
if (!response.ok) {
|
|
59
|
+
const text = await response.text().catch(() => "")
|
|
60
|
+
throw new Error(`GET /models failed ${response.status}: ${text}`)
|
|
61
|
+
}
|
|
62
|
+
const data = await response.json()
|
|
63
|
+
return (data.data ?? []).map((m) => m.id).filter(Boolean).sort()
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** 带重试的请求:网络错误与 429/5xx 指数退避(1s/2s/4s),其余 4xx 直接抛 */
|
|
67
|
+
async function requestWithRetry(provider, body, signal) {
|
|
68
|
+
let lastError
|
|
69
|
+
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
70
|
+
if (attempt > 0) await sleep(2 ** (attempt - 1) * 1000)
|
|
71
|
+
|
|
72
|
+
let response
|
|
73
|
+
try {
|
|
74
|
+
response = await fetch(`${provider.baseURL}/chat/completions`, {
|
|
75
|
+
method: "POST",
|
|
76
|
+
headers: {
|
|
77
|
+
"Content-Type": "application/json",
|
|
78
|
+
Authorization: `Bearer ${provider.apiKey}`,
|
|
79
|
+
},
|
|
80
|
+
body: JSON.stringify(body),
|
|
81
|
+
signal,
|
|
82
|
+
})
|
|
83
|
+
} catch (error) {
|
|
84
|
+
if (error.name === "AbortError") throw error
|
|
85
|
+
lastError = error // 网络层错误,可重试
|
|
86
|
+
continue
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (response.ok) return response
|
|
90
|
+
|
|
91
|
+
const text = await response.text().catch(() => "")
|
|
92
|
+
const message = `LLM API error ${response.status}: ${text}`
|
|
93
|
+
if (RETRYABLE_STATUS.has(response.status)) {
|
|
94
|
+
lastError = new Error(message)
|
|
95
|
+
continue
|
|
96
|
+
}
|
|
97
|
+
throw new Error(message)
|
|
98
|
+
}
|
|
99
|
+
throw lastError
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** 解析 SSE 流,累积正文/思考/tool_calls */
|
|
103
|
+
async function readSSE(response, { onToken, onReasoning }) {
|
|
104
|
+
const result = { content: "", reasoning: "", toolCalls: [], usage: null, finishReason: null }
|
|
105
|
+
const decoder = new TextDecoder()
|
|
106
|
+
let buffer = ""
|
|
107
|
+
|
|
108
|
+
for await (const chunk of response.body) {
|
|
109
|
+
buffer += decoder.decode(chunk, { stream: true })
|
|
110
|
+
const lines = buffer.split("\n")
|
|
111
|
+
buffer = lines.pop() // 最后半行留到下一轮
|
|
112
|
+
|
|
113
|
+
for (const line of lines) {
|
|
114
|
+
if (!line.startsWith("data:")) continue
|
|
115
|
+
const data = line.slice(5).trim()
|
|
116
|
+
if (!data || data === "[DONE]") continue
|
|
117
|
+
|
|
118
|
+
let json
|
|
119
|
+
try {
|
|
120
|
+
json = JSON.parse(data)
|
|
121
|
+
} catch {
|
|
122
|
+
continue // 忽略坏行,流不能因为一帧坏数据断掉
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (json.usage) result.usage = json.usage
|
|
126
|
+
const choice = json.choices?.[0]
|
|
127
|
+
if (!choice) continue
|
|
128
|
+
if (choice.finish_reason) result.finishReason = choice.finish_reason
|
|
129
|
+
|
|
130
|
+
const delta = choice.delta ?? {}
|
|
131
|
+
if (delta.reasoning_content) {
|
|
132
|
+
result.reasoning += delta.reasoning_content
|
|
133
|
+
onReasoning?.(delta.reasoning_content)
|
|
134
|
+
}
|
|
135
|
+
if (delta.content) {
|
|
136
|
+
result.content += delta.content
|
|
137
|
+
onToken?.(delta.content)
|
|
138
|
+
}
|
|
139
|
+
// tool_calls 按 index 分槽累积,name/arguments 都是分片到达的
|
|
140
|
+
for (const tc of delta.tool_calls ?? []) {
|
|
141
|
+
const slot = (result.toolCalls[tc.index] ??= { id: "", name: "", arguments: "" })
|
|
142
|
+
if (tc.id) slot.id = tc.id
|
|
143
|
+
if (tc.function?.name) slot.name += tc.function.name
|
|
144
|
+
if (tc.function?.arguments) slot.arguments += tc.function.arguments
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return result
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function sleep(ms) {
|
|
152
|
+
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
153
|
+
}
|