thincoder 0.8.2 → 0.8.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/bin/thincoder.mjs +25 -25
- package/package.json +1 -1
- package/src/agent/dispatch.mjs +17 -12
- package/src/agent/helpers.mjs +22 -11
- package/src/agent/setup.mjs +32 -10
- package/src/agent-tools/goal.mjs +12 -11
- package/src/agent-tools/plan.mjs +5 -6
- package/src/agent-tools/recent-changes.mjs +3 -3
- package/src/agent-tools/skill.mjs +6 -6
- package/src/agent-tools/subagent.mjs +19 -18
- package/src/agent-tools/task.mjs +7 -26
- package/src/agent-tools/verify.mjs +21 -19
- package/src/agent-tools.mjs +3 -3
- package/src/agent.mjs +82 -92
- package/src/cli/distill-command.mjs +2 -2
- package/src/cli/make-agent.mjs +9 -9
- package/src/cli/memory-command.mjs +1 -1
- package/src/cli/permission.mjs +3 -3
- package/src/cli/setup-wizard.mjs +15 -15
- package/src/config.mjs +56 -53
- package/src/context.mjs +59 -61
- package/src/distill.mjs +35 -35
- package/src/embedding.mjs +17 -17
- package/src/git/checkpoint.mjs +211 -38
- package/src/git/gitmem.mjs +21 -20
- package/src/markdown.mjs +13 -13
- package/src/mcp/helpers.mjs +6 -1
- package/src/mcp/transport-http.mjs +2 -1
- package/src/mcp/transport-stdio.mjs +3 -2
- package/src/mcp/transport-ws.mjs +3 -2
- package/src/mcp.mjs +5 -2
- package/src/memory/code-index.mjs +16 -14
- package/src/memory/code-sync.mjs +36 -26
- package/src/memory/core.mjs +42 -35
- package/src/memory/docs.mjs +24 -15
- package/src/memory/schema.mjs +28 -27
- package/src/memory.mjs +2 -2
- package/src/prompts/coder.md +0 -6
- package/src/prompts/discipline.md +9 -0
- package/src/prompts/main.md +24 -24
- package/src/prompts/system.md +21 -16
- package/src/provider/core.mjs +11 -6
- package/src/provider/index.mjs +2 -2
- package/src/provider/rate.mjs +11 -11
- package/src/session.mjs +73 -42
- package/src/skills.mjs +17 -17
- package/src/tools/checkpoint.md +6 -2
- package/src/tools/file.mjs +20 -14
- package/src/tools/git.mjs +83 -12
- package/src/tools/index.mjs +1 -1
- package/src/tools/ls.md +1 -1
- package/src/tools/patch.mjs +20 -18
- package/src/tools/repomap-parse.mjs +17 -17
- package/src/tools/repomap.mjs +29 -29
- package/src/tools/shared.mjs +55 -28
- package/src/tools/system.mjs +163 -118
- package/src/tools/web.mjs +6 -6
- package/src/tui/agent-turn.mjs +82 -26
- package/src/tui/ansi.mjs +5 -3
- package/src/tui/clipboard.mjs +2 -2
- package/src/tui/cmd-auto.mjs +3 -12
- package/src/tui/cmd-clear.mjs +1 -1
- package/src/tui/cmd-config.mjs +108 -23
- package/src/tui/cmd-exit.mjs +2 -2
- package/src/tui/cmd-extract.mjs +11 -3
- package/src/tui/cmd-goal.mjs +3 -12
- package/src/tui/cmd-help.mjs +2 -2
- package/src/tui/cmd-init.mjs +3 -3
- package/src/tui/cmd-mcp.mjs +38 -25
- package/src/tui/cmd-model.mjs +1 -1
- package/src/tui/cmd-new.mjs +33 -13
- package/src/tui/cmd-plan.mjs +3 -12
- package/src/tui/cmd-reindex.mjs +2 -2
- package/src/tui/cmd-restore.mjs +1 -1
- package/src/tui/cmd-session.mjs +1 -1
- package/src/tui/cmd-skills.mjs +1 -1
- package/src/tui/cmd-think.mjs +4 -11
- package/src/tui/config-helpers.mjs +6 -6
- package/src/tui/distill-cmd.mjs +4 -3
- package/src/tui/index.mjs +131 -72
- package/src/tui/interaction.mjs +13 -12
- package/src/tui/key-handler.mjs +34 -26
- package/src/tui/layout.mjs +22 -17
- package/src/tui/pickers.mjs +19 -19
- package/src/tui/render-frame.mjs +37 -11
- package/src/tui/render.mjs +25 -24
- package/src/tui/slash-commands.mjs +22 -22
- package/src/tui/startup.mjs +15 -14
- package/src/tui/wizard.mjs +11 -11
- package/src/tui.mjs +2 -2
- package/bin/thincoder.js +0 -4
- package/src/tools/bash.mjs +0 -144
- package/src/tools/glob.mjs +0 -51
- package/src/tools/grep.mjs +0 -100
- package/src/tools/ls.mjs +0 -36
- package/src/tools.mjs +0 -2
- package/src/tui-render.mjs +0 -4
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* memory/code-index.mjs —
|
|
2
|
+
* memory/code-index.mjs — code and document chunking, language detection, symbol extraction
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { segmentCJK, CODE_EXTS, DOC_EXTS, SKIP_DIRS, BIG_FILE_LINES } from "./schema.mjs"
|
|
6
6
|
|
|
7
|
-
/**
|
|
7
|
+
/** Infer file language by extension */
|
|
8
8
|
export function detectLanguage(filename) {
|
|
9
9
|
const ext = filename.slice(filename.lastIndexOf(".")).toLowerCase()
|
|
10
10
|
const map = {
|
|
@@ -21,8 +21,8 @@ export function detectLanguage(filename) {
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
/**
|
|
24
|
-
*
|
|
25
|
-
*
|
|
24
|
+
* Extract top-level symbol declarations (functions, classes, const exports, etc.)
|
|
25
|
+
* from JS/TS files using regex. Returns [{ name, line, kind }].
|
|
26
26
|
*/
|
|
27
27
|
export function extractSymbols(lines, ext) {
|
|
28
28
|
const jsish = new Set([".mjs", ".js", ".ts", ".jsx", ".tsx"])
|
|
@@ -41,7 +41,7 @@ export function extractSymbols(lines, ext) {
|
|
|
41
41
|
return symbols
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
/**
|
|
44
|
+
/** Extract top-level def/class from Python files. */
|
|
45
45
|
export function extractPySymbols(lines) {
|
|
46
46
|
const symbols = []
|
|
47
47
|
const re = /^(?:async\s+)?(?:def|class)\s+(\w+)/gm
|
|
@@ -54,8 +54,9 @@ export function extractPySymbols(lines) {
|
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
/**
|
|
57
|
-
*
|
|
58
|
-
*
|
|
57
|
+
* Split a file into code chunks. Small files become a single chunk;
|
|
58
|
+
* large files are split by symbol, with inter-symbol content merged into the preceding symbol chunk.
|
|
59
|
+
* Each chunk includes the JSDoc/docstring before its symbol to improve search quality.
|
|
59
60
|
*/
|
|
60
61
|
export function chunkCode(lines, filepath) {
|
|
61
62
|
const ext = filepath.slice(filepath.lastIndexOf(".")).toLowerCase()
|
|
@@ -91,9 +92,9 @@ export function chunkCode(lines, filepath) {
|
|
|
91
92
|
}
|
|
92
93
|
|
|
93
94
|
/**
|
|
94
|
-
*
|
|
95
|
-
* JS/TS:
|
|
96
|
-
* Python:
|
|
95
|
+
* Extract the JSDoc/docstring comment preceding a given line.
|
|
96
|
+
* JS/TS: scan backwards for JSDoc block comments or consecutive // comment lines
|
|
97
|
+
* Python: look for a """...""" docstring on the line after the symbol definition
|
|
97
98
|
*/
|
|
98
99
|
export function extractLeadingDoc(lines, lineNum, ext) {
|
|
99
100
|
if (ext === ".py") {
|
|
@@ -139,12 +140,12 @@ export function extractLeadingDoc(lines, lineNum, ext) {
|
|
|
139
140
|
return text.length > 0 && text.length < 300 ? text : ""
|
|
140
141
|
}
|
|
141
142
|
|
|
142
|
-
/**
|
|
143
|
+
/** Yield control to the event loop for one tick (allows keyboard input to be processed) */
|
|
143
144
|
export function yieldTick() {
|
|
144
145
|
return new Promise((r) => setTimeout(r, 0))
|
|
145
146
|
}
|
|
146
147
|
|
|
147
|
-
/**
|
|
148
|
+
/** Index a single file: delete old chunks → chunk → insert new chunks */
|
|
148
149
|
export function _upsertCodeFile(memory, origin, rel, lines, lang, mtimeMs) {
|
|
149
150
|
const chunks = chunkCode(lines, rel)
|
|
150
151
|
memory.db.exec("BEGIN")
|
|
@@ -166,8 +167,8 @@ export function _upsertCodeFile(memory, origin, rel, lines, lang, mtimeMs) {
|
|
|
166
167
|
}
|
|
167
168
|
|
|
168
169
|
/**
|
|
169
|
-
*
|
|
170
|
-
*
|
|
170
|
+
* Split a markdown file by ## headings. Each ## section is indexed independently,
|
|
171
|
+
* with the heading path as the heading label (e.g. "README.md > Deployment > Docker") for easy retrieval.
|
|
171
172
|
*/
|
|
172
173
|
export function chunkMarkdown(lines, filepath) {
|
|
173
174
|
const chunks = []
|
|
@@ -190,6 +191,7 @@ export function chunkMarkdown(lines, filepath) {
|
|
|
190
191
|
return chunks.filter((c) => c.content)
|
|
191
192
|
}
|
|
192
193
|
|
|
194
|
+
/** Upsert a documentation file's chunks into the doc_chunks table within a transaction */
|
|
193
195
|
export function _upsertDocFile(memory, origin, rel, lines, mtimeMs) {
|
|
194
196
|
const chunks = chunkMarkdown(lines, rel)
|
|
195
197
|
const lang = rel.endsWith(".rst") ? "rst" : rel.endsWith(".adoc") ? "asciidoc" : rel.endsWith(".txt") ? "text" : "markdown"
|
package/src/memory/code-sync.mjs
CHANGED
|
@@ -1,18 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* memory/code-sync.mjs —
|
|
2
|
+
* memory/code-sync.mjs — code index sync, retrieval, incremental update
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { readFile, stat } from "node:fs/promises"
|
|
6
6
|
import { join, relative } from "node:path"
|
|
7
7
|
import { embed, cosine, toBlob, fromBlob } from "../embedding.mjs"
|
|
8
8
|
import { CODE_EXTS, DOC_EXTS, SKIP_DIRS } from "./schema.mjs"
|
|
9
|
-
import { buildFtsQuery, ensureEmbeddings } from "./core.mjs"
|
|
9
|
+
import { buildFtsQuery, ensureEmbeddings, EMBED_TEXT_MAX_LEN } from "./core.mjs"
|
|
10
10
|
import { detectLanguage, _upsertCodeFile, _upsertDocFile, yieldTick } from "./code-index.mjs"
|
|
11
11
|
|
|
12
|
+
const DIFF_FULL_SYNC_THRESHOLD = 200
|
|
13
|
+
const CODE_EMBED_BATCH = 64
|
|
14
|
+
|
|
12
15
|
/**
|
|
13
|
-
* git
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
+
* git-driven incremental indexing: use git diff to find files changed since
|
|
17
|
+
* the last index, and only rebuild FTS5 chunks for those files (vectors are untouched).
|
|
18
|
+
* An order of magnitude faster than full mtime scanning.
|
|
19
|
+
* Returns { updated, removed, skipped } or null (git unavailable).
|
|
16
20
|
*/
|
|
17
21
|
export async function gitSync(memory, dir, { onProgress } = {}) {
|
|
18
22
|
const { execSync, execFileSync } = await import("node:child_process")
|
|
@@ -34,8 +38,8 @@ export async function gitSync(memory, dir, { onProgress } = {}) {
|
|
|
34
38
|
return null
|
|
35
39
|
}
|
|
36
40
|
|
|
37
|
-
if (diffOut.length >
|
|
38
|
-
// diff
|
|
41
|
+
if (diffOut.length > DIFF_FULL_SYNC_THRESHOLD) {
|
|
42
|
+
// diff too large, incremental is useless — fall back to full sync and update anchor
|
|
39
43
|
await codeSync(memory, dir, { onProgress })
|
|
40
44
|
const { docSync } = await import("./docs.mjs")
|
|
41
45
|
await docSync(memory, dir, { onProgress })
|
|
@@ -60,11 +64,11 @@ export async function gitSync(memory, dir, { onProgress } = {}) {
|
|
|
60
64
|
if (CODE_EXTS.has(ext)) {
|
|
61
65
|
const lang = detectLanguage(abs)
|
|
62
66
|
let mtimeMs = 0
|
|
63
|
-
try { mtimeMs = Math.floor((await stat(abs)).mtimeMs) } catch { /*
|
|
67
|
+
try { mtimeMs = Math.floor((await stat(abs)).mtimeMs) } catch { /* new file */ }
|
|
64
68
|
_upsertCodeFile(memory, dir, rel, lines, lang, mtimeMs)
|
|
65
69
|
} else {
|
|
66
70
|
let mtimeMs = 0
|
|
67
|
-
try { mtimeMs = Math.floor((await stat(abs)).mtimeMs) } catch { /*
|
|
71
|
+
try { mtimeMs = Math.floor((await stat(abs)).mtimeMs) } catch { /* new file */ }
|
|
68
72
|
_upsertDocFile(memory, dir, rel, lines, mtimeMs)
|
|
69
73
|
}
|
|
70
74
|
updated++
|
|
@@ -94,8 +98,8 @@ export async function gitSync(memory, dir, { onProgress } = {}) {
|
|
|
94
98
|
}
|
|
95
99
|
|
|
96
100
|
/**
|
|
97
|
-
*
|
|
98
|
-
*
|
|
101
|
+
* Sync code index: scan all source files under dir → chunk → upsert into code_chunks.
|
|
102
|
+
* Incremental by mtime — only rebuilds chunks for files that have changed.
|
|
99
103
|
*/
|
|
100
104
|
export async function codeSync(memory, dir, { onProgress } = {}) {
|
|
101
105
|
const files = []
|
|
@@ -165,19 +169,19 @@ export async function codeSync(memory, dir, { onProgress } = {}) {
|
|
|
165
169
|
return { updated, removed, skipped, failed, errors, total: files.length }
|
|
166
170
|
}
|
|
167
171
|
|
|
168
|
-
/**
|
|
172
|
+
/** Record current HEAD as the index anchor (gitSync incremental diff baseline); silently skip non-git repos */
|
|
169
173
|
export async function markIndexedCommit(memory, dir) {
|
|
170
174
|
try {
|
|
171
175
|
const { execSync } = await import("node:child_process")
|
|
172
176
|
const head = execSync("git rev-parse HEAD", { cwd: dir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5000 }).trim()
|
|
173
177
|
memory.db.prepare(`INSERT INTO meta (key, value) VALUES ('last_indexed_commit', ?)
|
|
174
178
|
ON CONFLICT (key) DO UPDATE SET value = excluded.value`).run(head)
|
|
175
|
-
} catch { /*
|
|
179
|
+
} catch { /* not a git repo or git unavailable, skip */ }
|
|
176
180
|
}
|
|
177
181
|
|
|
178
182
|
/**
|
|
179
|
-
*
|
|
180
|
-
*
|
|
183
|
+
* Code search: FTS5(BM25) + optional vector cosine, RRF merged.
|
|
184
|
+
* Falls back to pure FTS when no embedder; falls back to pure vector when ftsQuery is empty and embedder is present.
|
|
181
185
|
*/
|
|
182
186
|
export async function codeSearch(memory, query, { limit = 5 } = {}) {
|
|
183
187
|
const ftsQuery = buildFtsQuery(query)
|
|
@@ -219,14 +223,20 @@ export async function codeSearch(memory, query, { limit = 5 } = {}) {
|
|
|
219
223
|
const fetchChunk = memory.db.prepare(`
|
|
220
224
|
SELECT path, language, symbol_name, content, line_start, line_end FROM code_chunks WHERE rowid = ?
|
|
221
225
|
`)
|
|
222
|
-
|
|
226
|
+
const sorted = [...scores.entries()]
|
|
223
227
|
.sort((a, b) => b[1] - a[1])
|
|
224
228
|
.slice(0, limit)
|
|
225
|
-
|
|
229
|
+
return sorted
|
|
230
|
+
.map(([rowid, score]) => {
|
|
231
|
+
const chunk = fetchChunk.get(rowid)
|
|
232
|
+
if (!chunk) return null
|
|
233
|
+
chunk._score = Math.round(score * 100) / 100
|
|
234
|
+
return chunk
|
|
235
|
+
})
|
|
226
236
|
.filter(Boolean)
|
|
227
237
|
}
|
|
228
238
|
|
|
229
|
-
/**
|
|
239
|
+
/** Lazily backfill missing vectors for code_chunks */
|
|
230
240
|
export async function ensureCodeEmbeddings(memory) {
|
|
231
241
|
if (!memory.embedder) return
|
|
232
242
|
const modelKey = memory.embedder.model
|
|
@@ -237,17 +247,17 @@ export async function ensureCodeEmbeddings(memory) {
|
|
|
237
247
|
ON CONFLICT (key) DO UPDATE SET value = excluded.value`).run(modelKey)
|
|
238
248
|
}
|
|
239
249
|
|
|
240
|
-
const pending = memory.db.prepare(`SELECT rowid, path, symbol_name, content FROM code_chunks WHERE embedding IS NULL LIMIT
|
|
250
|
+
const pending = memory.db.prepare(`SELECT rowid, path, symbol_name, content FROM code_chunks WHERE embedding IS NULL LIMIT ${CODE_EMBED_BATCH}`).all()
|
|
241
251
|
if (pending.length === 0) return
|
|
242
252
|
|
|
243
|
-
const texts = pending.map((r) => `${r.path}${r.symbol_name ? " :: " + r.symbol_name : ""}\n${r.content.slice(0,
|
|
253
|
+
const texts = pending.map((r) => `${r.path}${r.symbol_name ? " :: " + r.symbol_name : ""}\n${r.content.slice(0, EMBED_TEXT_MAX_LEN)}`)
|
|
244
254
|
const vecs = await embed(memory.embedder, texts)
|
|
245
255
|
|
|
246
256
|
const update = memory.db.prepare(`UPDATE code_chunks SET embedding = ? WHERE rowid = ?`)
|
|
247
257
|
pending.forEach((r, i) => update.run(toBlob(vecs[i]), r.rowid))
|
|
248
258
|
}
|
|
249
259
|
|
|
250
|
-
/**
|
|
260
|
+
/** Generate the code_search tool (read-only). */
|
|
251
261
|
export function codeSearchTool(memory) {
|
|
252
262
|
return {
|
|
253
263
|
name: "code_search",
|
|
@@ -266,14 +276,14 @@ export function codeSearchTool(memory) {
|
|
|
266
276
|
const results = await codeSearch(memory, args.query, { limit: args.limit ?? 5 })
|
|
267
277
|
if (results.length === 0) return "(no matching code)"
|
|
268
278
|
return results.map((r) =>
|
|
269
|
-
`${r.path}${r.symbol_name ? ` :: ${r.symbol_name}` : ""} (L${r.line_start}-L${r.line_end}):\n${r.content.slice(0, 2000)}`
|
|
279
|
+
`${r.path}${r.symbol_name ? ` :: ${r.symbol_name}` : ""} (L${r.line_start}-L${r.line_end}, relevance ${r._score?.toFixed(2) ?? "?"}):\n${r.content.slice(0, 2000)}`
|
|
270
280
|
).join("\n\n---\n\n")
|
|
271
281
|
},
|
|
272
282
|
}
|
|
273
283
|
}
|
|
274
284
|
|
|
275
285
|
/**
|
|
276
|
-
*
|
|
286
|
+
* Single-file incremental reindex: called after write/edit/delete, only rebuilds this one path.
|
|
277
287
|
*/
|
|
278
288
|
export async function reindexFile(memory, cwd, absPath) {
|
|
279
289
|
const ext = absPath.slice(absPath.lastIndexOf(".")).toLowerCase()
|
|
@@ -293,14 +303,14 @@ export async function reindexFile(memory, cwd, absPath) {
|
|
|
293
303
|
if (CODE_EXTS.has(ext)) {
|
|
294
304
|
const lang = detectLanguage(absPath)
|
|
295
305
|
let mtimeMs = 0
|
|
296
|
-
try { mtimeMs = Math.floor((await stat(absPath)).mtimeMs) } catch { /*
|
|
306
|
+
try { mtimeMs = Math.floor((await stat(absPath)).mtimeMs) } catch { /* new file */ }
|
|
297
307
|
_upsertCodeFile(memory, cwd, rel, lines, lang, mtimeMs)
|
|
298
308
|
} else if (DOC_EXTS.has(ext)) {
|
|
299
309
|
let mtimeMs = 0
|
|
300
|
-
try { mtimeMs = Math.floor((await stat(absPath)).mtimeMs) } catch { /*
|
|
310
|
+
try { mtimeMs = Math.floor((await stat(absPath)).mtimeMs) } catch { /* new file */ }
|
|
301
311
|
_upsertDocFile(memory, cwd, rel, lines, mtimeMs)
|
|
302
312
|
}
|
|
303
313
|
if (memory.embedder) {
|
|
304
|
-
try { await ensureEmbeddings(memory) } catch { /* embedding
|
|
314
|
+
try { await ensureEmbeddings(memory) } catch { /* embedding failure is non-blocking */ }
|
|
305
315
|
}
|
|
306
316
|
}
|
package/src/memory/core.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* memory/core.mjs —
|
|
2
|
+
* memory/core.mjs — memory CRUD, hybrid retrieval, embedding management
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { parseEntry, serializeEntry, entryFilename } from "../markdown.mjs"
|
|
@@ -8,9 +8,14 @@ import { readFile, stat, readdir, writeFile, mkdir } from "node:fs/promises"
|
|
|
8
8
|
import { join } from "node:path"
|
|
9
9
|
import { segmentCJK, VALID_TYPES, SCHEMA_VERSION } from "./schema.mjs"
|
|
10
10
|
|
|
11
|
+
const EMBED_BATCH_SIZE = 256
|
|
12
|
+
export const EMBED_TEXT_MAX_LEN = 2000
|
|
13
|
+
const FTS_TOKEN_MAX = 16
|
|
14
|
+
const DEFAULT_LIST_LIMIT = 50
|
|
15
|
+
|
|
11
16
|
/**
|
|
12
|
-
*
|
|
13
|
-
*
|
|
17
|
+
* Write a memory entry. entry: { type, title, content, tags? }
|
|
18
|
+
* Returns the new entry id.
|
|
14
19
|
*/
|
|
15
20
|
export async function put(memory, { type, title, content, tags = "" }) {
|
|
16
21
|
if (!VALID_TYPES.has(type)) {
|
|
@@ -27,9 +32,9 @@ export async function put(memory, { type, title, content, tags = "" }) {
|
|
|
27
32
|
}
|
|
28
33
|
|
|
29
34
|
/**
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
35
|
+
* Hybrid retrieval: FTS5(BM25) + vector cosine, RRF(k=60) merged ranking.
|
|
36
|
+
* Falls back to pure FTS when no embedder. Results include layer label.
|
|
37
|
+
* Returns [{ id, layer, type, title, content, tags, rank }]
|
|
33
38
|
*/
|
|
34
39
|
export async function search(memory, query, { limit = 5 } = {}) {
|
|
35
40
|
const ftsQuery = buildFtsQuery(query)
|
|
@@ -37,7 +42,7 @@ export async function search(memory, query, { limit = 5 } = {}) {
|
|
|
37
42
|
|
|
38
43
|
if (!memory.embedder) return ftsList.slice(0, limit)
|
|
39
44
|
|
|
40
|
-
// ----
|
|
45
|
+
// ---- vector channel ----
|
|
41
46
|
try { await ensureEmbeddings(memory) } catch (e) {
|
|
42
47
|
console.error(`[memory] embedding ensure failed, falling back to FTS-only: ${e.message}`)
|
|
43
48
|
return ftsList.slice(0, limit)
|
|
@@ -59,7 +64,7 @@ export async function search(memory, query, { limit = 5 } = {}) {
|
|
|
59
64
|
.sort((a, b) => b.score - a.score)
|
|
60
65
|
.slice(0, Math.max(limit * 4, 20))
|
|
61
66
|
|
|
62
|
-
// ---- RRF
|
|
67
|
+
// ---- RRF merge ----
|
|
63
68
|
const K = 60
|
|
64
69
|
const scores = new Map()
|
|
65
70
|
ftsList.forEach((r, i) => scores.set(r.id, (scores.get(r.id) ?? 0) + 1 / (K + i + 1)))
|
|
@@ -75,7 +80,7 @@ export async function search(memory, query, { limit = 5 } = {}) {
|
|
|
75
80
|
.filter(Boolean)
|
|
76
81
|
}
|
|
77
82
|
|
|
78
|
-
/**
|
|
83
|
+
/** Pure FTS search (two-table merge, sorted by bm25), used as the positional input for RRF */
|
|
79
84
|
export function ftsSearch(memory, ftsQuery, limit) {
|
|
80
85
|
const personal = memory.db.prepare(`
|
|
81
86
|
SELECT e.id, e.type, e.title, e.content, e.tags, bm25(entries_fts) AS rank
|
|
@@ -96,23 +101,23 @@ export function ftsSearch(memory, ftsQuery, limit) {
|
|
|
96
101
|
return [...personal, ...files].sort((a, b) => a.rank - b.rank).slice(0, limit)
|
|
97
102
|
}
|
|
98
103
|
|
|
99
|
-
/**
|
|
100
|
-
*
|
|
101
|
-
* project
|
|
104
|
+
/** Fetch a full entry by unified id (personal:<n> / project:<origin>:<path> / team:<origin>:<path>)
|
|
105
|
+
* Note: since v9 the files table PK is (layer, origin, path); the same layer+path may span multiple origins.
|
|
106
|
+
* For project layer, prefers the row matching projectOrigin; for team layer, returns any row (first match when multiple team repos share a path). */
|
|
102
107
|
export function fetchEntry(memory, uid) {
|
|
103
108
|
const [layer, ...rest] = uid.split(":")
|
|
104
109
|
if (layer === "personal") {
|
|
105
110
|
const r = memory.db.prepare(`SELECT id, type, title, content, tags FROM entries WHERE id = ?`).get(Number(rest[0]))
|
|
106
111
|
return r ? { ...r, layer, id: uid } : null
|
|
107
112
|
}
|
|
108
|
-
// rest = [origin, ...pathParts]
|
|
113
|
+
// rest = [origin, ...pathParts]; origin may be empty string (compat with old format)
|
|
109
114
|
const origin = rest[0] ?? ""
|
|
110
115
|
const path = rest.slice(1).join(":")
|
|
111
116
|
if (layer === "project" && memory.projectOrigin) {
|
|
112
117
|
const r = memory.db.prepare(`SELECT type, title, content, tags, author FROM files WHERE layer = ? AND origin = ? AND path = ?`).get(layer, origin || memory.projectOrigin, path)
|
|
113
118
|
if (r) return { ...r, layer, id: uid }
|
|
114
119
|
}
|
|
115
|
-
// team
|
|
120
|
+
// team layer or project fallback: query by origin+path; when origin is empty, degrade to path-only (compat with old UID)
|
|
116
121
|
if (origin) {
|
|
117
122
|
const r = memory.db.prepare(`SELECT type, title, content, tags, author FROM files WHERE layer = ? AND origin = ? AND path = ?`).get(layer, origin, path)
|
|
118
123
|
if (r) return { ...r, layer, id: uid }
|
|
@@ -122,14 +127,14 @@ export function fetchEntry(memory, uid) {
|
|
|
122
127
|
}
|
|
123
128
|
|
|
124
129
|
/**
|
|
125
|
-
*
|
|
126
|
-
*
|
|
130
|
+
* Lazy embedding: batch-compute vectors for entries that don't have them yet (slow first time, zero cost thereafter).
|
|
131
|
+
* When the embedding model changes, clear all vectors and rebuild.
|
|
127
132
|
*/
|
|
128
133
|
export async function ensureEmbeddings(memory) {
|
|
129
134
|
const modelKey = memory.embedder.model
|
|
130
135
|
const stored = memory.db.prepare(`SELECT value FROM meta WHERE key = 'embedding_model'`).get()?.value
|
|
131
136
|
if (stored !== modelKey) {
|
|
132
|
-
//
|
|
137
|
+
// Invalidate all three tables + three meta keys in one go, to prevent stale vectors from dimension mismatch
|
|
133
138
|
memory.db.prepare(`UPDATE entries SET embedding = NULL`).run()
|
|
134
139
|
memory.db.prepare(`UPDATE files SET embedding = NULL`).run()
|
|
135
140
|
memory.db.prepare(`UPDATE code_chunks SET embedding = NULL`).run()
|
|
@@ -140,17 +145,17 @@ export async function ensureEmbeddings(memory) {
|
|
|
140
145
|
upsert.run("doc_embedding_model", modelKey)
|
|
141
146
|
}
|
|
142
147
|
|
|
143
|
-
const pendingEntries = memory.db.prepare(`SELECT id, title, content FROM entries WHERE embedding IS NULL LIMIT
|
|
144
|
-
const pendingFiles = memory.db.prepare(`SELECT rowid, title, content FROM files WHERE embedding IS NULL LIMIT
|
|
148
|
+
const pendingEntries = memory.db.prepare(`SELECT id, title, content FROM entries WHERE embedding IS NULL LIMIT ${EMBED_BATCH_SIZE}`).all()
|
|
149
|
+
const pendingFiles = memory.db.prepare(`SELECT rowid, title, content FROM files WHERE embedding IS NULL LIMIT ${EMBED_BATCH_SIZE}`).all()
|
|
145
150
|
if (pendingEntries.length + pendingFiles.length === 0) {
|
|
146
|
-
//
|
|
151
|
+
// No pending memory entries — also backfill code and doc chunk vectors
|
|
147
152
|
await (await import("./code-sync.mjs")).ensureCodeEmbeddings(memory)
|
|
148
153
|
await (await import("./docs.mjs")).ensureDocEmbeddings(memory)
|
|
149
154
|
return
|
|
150
155
|
}
|
|
151
156
|
|
|
152
157
|
const items = [...pendingEntries, ...pendingFiles]
|
|
153
|
-
const texts = items.map((r) => `${r.title}\n${r.content.slice(0,
|
|
158
|
+
const texts = items.map((r) => `${r.title}\n${r.content.slice(0, EMBED_TEXT_MAX_LEN)}`)
|
|
154
159
|
const vecs = await embed(memory.embedder, texts)
|
|
155
160
|
|
|
156
161
|
const updateEntry = memory.db.prepare(`UPDATE entries SET embedding = ? WHERE id = ?`)
|
|
@@ -158,16 +163,16 @@ export async function ensureEmbeddings(memory) {
|
|
|
158
163
|
const updateFile = memory.db.prepare(`UPDATE files SET embedding = ? WHERE rowid = ?`)
|
|
159
164
|
pendingFiles.forEach((r, i) => updateFile.run(toBlob(vecs[pendingEntries.length + i]), r.rowid))
|
|
160
165
|
|
|
161
|
-
//
|
|
166
|
+
// After each batch of embeddings, also backfill code and doc chunks
|
|
162
167
|
await (await import("./code-sync.mjs")).ensureCodeEmbeddings(memory)
|
|
163
168
|
await (await import("./docs.mjs")).ensureDocEmbeddings(memory)
|
|
164
169
|
}
|
|
165
170
|
|
|
166
171
|
/**
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
* team
|
|
170
|
-
*
|
|
172
|
+
* Write a markdown memory entry to the specified layer directory (project/team) and index it immediately.
|
|
173
|
+
* Writes the file only — the project layer never performs git operations on the user's project repo;
|
|
174
|
+
* team layer commit+push is handled by gitmem.mjs.
|
|
175
|
+
* Returns the filename.
|
|
171
176
|
*/
|
|
172
177
|
export async function putMarkdown(memory, { layer, dir, type, title, content, tags = [], author = "unknown" }) {
|
|
173
178
|
if (layer !== "project" && layer !== "team") throw new Error(`invalid markdown layer: ${layer}`)
|
|
@@ -180,7 +185,8 @@ export async function putMarkdown(memory, { layer, dir, type, title, content, ta
|
|
|
180
185
|
}
|
|
181
186
|
|
|
182
187
|
/**
|
|
183
|
-
*
|
|
188
|
+
* Sync a markdown directory to the index: new/changed (by mtime) entries are re-indexed,
|
|
189
|
+
* vanished entries are removed from the index.
|
|
184
190
|
*/
|
|
185
191
|
export async function syncDir(memory, { layer, dir }) {
|
|
186
192
|
let names = []
|
|
@@ -221,7 +227,7 @@ export async function syncDir(memory, { layer, dir }) {
|
|
|
221
227
|
return { added, updated, removed, skipped }
|
|
222
228
|
}
|
|
223
229
|
|
|
224
|
-
/**
|
|
230
|
+
/** Parse a single .md and upsert into the files table */
|
|
225
231
|
export async function indexMarkdownFile(memory, { layer, dir, filename, mtimeMs }) {
|
|
226
232
|
const abs = join(dir, filename)
|
|
227
233
|
const mtime = mtimeMs ?? Math.floor((await stat(abs)).mtimeMs)
|
|
@@ -241,8 +247,8 @@ export async function indexMarkdownFile(memory, { layer, dir, filename, mtimeMs
|
|
|
241
247
|
)
|
|
242
248
|
}
|
|
243
249
|
|
|
244
|
-
/**
|
|
245
|
-
export async function list(memory, { type, limit =
|
|
250
|
+
/** List entries, optionally filtered by type */
|
|
251
|
+
export async function list(memory, { type, limit = DEFAULT_LIST_LIMIT } = {}) {
|
|
246
252
|
if (type) {
|
|
247
253
|
if (!VALID_TYPES.has(type)) throw new Error(`Invalid memory type "${type}"`)
|
|
248
254
|
return memory.db
|
|
@@ -254,23 +260,24 @@ export async function list(memory, { type, limit = 50 } = {}) {
|
|
|
254
260
|
.all(limit)
|
|
255
261
|
}
|
|
256
262
|
|
|
257
|
-
/**
|
|
263
|
+
/** Delete a memory entry. Returns whether deletion succeeded */
|
|
258
264
|
export async function remove(memory, id) {
|
|
259
265
|
const info = memory.db.prepare(`DELETE FROM entries WHERE id = ?`).run(id)
|
|
260
266
|
return info.changes > 0
|
|
261
267
|
}
|
|
262
268
|
|
|
263
269
|
/**
|
|
264
|
-
*
|
|
265
|
-
*
|
|
266
|
-
*
|
|
270
|
+
* Build an FTS5 query: first split by whitespace/punctuation into tokens,
|
|
271
|
+
* then apply CJK character segmentation to each token.
|
|
272
|
+
* This keeps multi-character CJK words as FTS5 phrases ("分号" → "分 号" → phrase query, exact adjacency match),
|
|
273
|
+
* while different tokens are joined with OR ("命名 规范" → "命 名" OR "规 范", each phrase requires its own adjacency).
|
|
267
274
|
*/
|
|
268
275
|
export function buildFtsQuery(query) {
|
|
269
276
|
const terms = query
|
|
270
277
|
.split(/[\s,,。、;;!!??()()"`]+/)
|
|
271
278
|
.map((t) => t.trim())
|
|
272
279
|
.filter(Boolean)
|
|
273
|
-
.slice(0,
|
|
280
|
+
.slice(0, FTS_TOKEN_MAX)
|
|
274
281
|
.map((t) => segmentCJK(t))
|
|
275
282
|
if (terms.length === 0) return ""
|
|
276
283
|
return terms.map((t) => `"${t.replaceAll('"', '""')}"`).join(" OR ")
|
package/src/memory/docs.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* memory/docs.mjs —
|
|
2
|
+
* memory/docs.mjs — doc index sync, retrieval, agent tool generation
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { readFile, readdir, stat } from "node:fs/promises"
|
|
@@ -11,9 +11,12 @@ import { buildFtsQuery, put, search, putMarkdown } from "./core.mjs"
|
|
|
11
11
|
import { _upsertDocFile, yieldTick } from "./code-index.mjs"
|
|
12
12
|
import { markIndexedCommit } from "./code-sync.mjs"
|
|
13
13
|
|
|
14
|
+
const DOC_EMBED_BATCH = 64
|
|
15
|
+
const EMBED_TEXT_MAX_LEN = 2000
|
|
16
|
+
|
|
14
17
|
/**
|
|
15
|
-
*
|
|
16
|
-
*
|
|
18
|
+
* Sync doc index: scan all .md/.mdc/.txt/.rst/.adoc under dir → chunk → upsert into doc_chunks.
|
|
19
|
+
* Incremental by mtime.
|
|
17
20
|
*/
|
|
18
21
|
export async function docSync(memory, dir, { onProgress } = {}) {
|
|
19
22
|
const files = []
|
|
@@ -82,8 +85,8 @@ export async function docSync(memory, dir, { onProgress } = {}) {
|
|
|
82
85
|
}
|
|
83
86
|
|
|
84
87
|
/**
|
|
85
|
-
*
|
|
86
|
-
*
|
|
88
|
+
* Doc search: FTS5(BM25) + optional vector cosine, RRF merged.
|
|
89
|
+
* Falls back to pure FTS when no embedder; falls back to pure vector when ftsQuery is empty and embedder is present.
|
|
87
90
|
*/
|
|
88
91
|
export async function docSearch(memory, query, { limit = 5 } = {}) {
|
|
89
92
|
const ftsQuery = buildFtsQuery(query)
|
|
@@ -125,14 +128,20 @@ export async function docSearch(memory, query, { limit = 5 } = {}) {
|
|
|
125
128
|
const fetchChunk = memory.db.prepare(`
|
|
126
129
|
SELECT path, language, heading, content, line_start, line_end FROM doc_chunks WHERE rowid = ?
|
|
127
130
|
`)
|
|
128
|
-
|
|
131
|
+
const sorted = [...scores.entries()]
|
|
129
132
|
.sort((a, b) => b[1] - a[1])
|
|
130
133
|
.slice(0, limit)
|
|
131
|
-
|
|
134
|
+
return sorted
|
|
135
|
+
.map(([rowid, score]) => {
|
|
136
|
+
const chunk = fetchChunk.get(rowid)
|
|
137
|
+
if (!chunk) return null
|
|
138
|
+
chunk._score = Math.round(score * 100) / 100
|
|
139
|
+
return chunk
|
|
140
|
+
})
|
|
132
141
|
.filter(Boolean)
|
|
133
142
|
}
|
|
134
143
|
|
|
135
|
-
/**
|
|
144
|
+
/** Lazily backfill missing vectors for doc_chunks */
|
|
136
145
|
export async function ensureDocEmbeddings(memory) {
|
|
137
146
|
if (!memory.embedder) return
|
|
138
147
|
const modelKey = memory.embedder.model
|
|
@@ -143,17 +152,17 @@ export async function ensureDocEmbeddings(memory) {
|
|
|
143
152
|
ON CONFLICT (key) DO UPDATE SET value = excluded.value`).run(modelKey)
|
|
144
153
|
}
|
|
145
154
|
|
|
146
|
-
const pending = memory.db.prepare(`SELECT rowid, path, heading, content FROM doc_chunks WHERE embedding IS NULL LIMIT
|
|
155
|
+
const pending = memory.db.prepare(`SELECT rowid, path, heading, content FROM doc_chunks WHERE embedding IS NULL LIMIT ${DOC_EMBED_BATCH}`).all()
|
|
147
156
|
if (pending.length === 0) return
|
|
148
157
|
|
|
149
|
-
const texts = pending.map((r) => `${r.heading || r.path}\n${r.content.slice(0,
|
|
158
|
+
const texts = pending.map((r) => `${r.heading || r.path}\n${r.content.slice(0, EMBED_TEXT_MAX_LEN)}`)
|
|
150
159
|
const vecs = await embed(memory.embedder, texts)
|
|
151
160
|
|
|
152
161
|
const update = memory.db.prepare(`UPDATE doc_chunks SET embedding = ? WHERE rowid = ?`)
|
|
153
162
|
pending.forEach((r, i) => update.run(toBlob(vecs[i]), r.rowid))
|
|
154
163
|
}
|
|
155
164
|
|
|
156
|
-
/**
|
|
165
|
+
/** Generate the doc_search tool (read-only). */
|
|
157
166
|
export function docSearchTool(memory) {
|
|
158
167
|
return {
|
|
159
168
|
name: "doc_search",
|
|
@@ -172,17 +181,17 @@ export function docSearchTool(memory) {
|
|
|
172
181
|
const results = await docSearch(memory, args.query, { limit: args.limit ?? 5 })
|
|
173
182
|
if (results.length === 0) return "(no matching documentation)"
|
|
174
183
|
return results.map((r) =>
|
|
175
|
-
`${r.path}${r.heading ? ` > ${r.heading}` : ""} (L${r.line_start}-L${r.line_end}):\n${r.content.slice(0, 2000)}`
|
|
184
|
+
`${r.path}${r.heading ? ` > ${r.heading}` : ""} (L${r.line_start}-L${r.line_end}, relevance ${r._score?.toFixed(2) ?? "?"}):\n${r.content.slice(0, 2000)}`
|
|
176
185
|
).join("\n\n---\n\n")
|
|
177
186
|
},
|
|
178
187
|
}
|
|
179
188
|
}
|
|
180
189
|
|
|
181
|
-
// ---------------------------------------------------------------- agent
|
|
190
|
+
// ---------------------------------------------------------------- agent tools
|
|
182
191
|
|
|
183
192
|
/**
|
|
184
|
-
*
|
|
185
|
-
* memory_put
|
|
193
|
+
* Generate the two memory-related agent tools (following the tools.mjs tool shape).
|
|
194
|
+
* memory_put is a side-effecting tool (needs permission confirmation), memory_search is read-only.
|
|
186
195
|
* opts: { cwd, projectDir, author, team: { dir, name } | null }
|
|
187
196
|
*/
|
|
188
197
|
export function memoryTools(memory, opts = {}) {
|