thincoder 0.8.2 → 0.8.3
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 +7 -6
- package/src/agent-tools/plan.mjs +3 -3
- 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 +4 -4
- package/src/agent-tools/verify.mjs +21 -19
- package/src/agent-tools.mjs +3 -3
- package/src/agent.mjs +58 -37
- 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 +55 -53
- package/src/context.mjs +59 -56
- 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 +1 -1
- package/src/prompts/discipline.md +3 -0
- package/src/prompts/main.md +1 -2
- 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 -40
- package/src/skills.mjs +17 -17
- package/src/tools/checkpoint.md +6 -2
- package/src/tools/file.mjs +16 -12
- package/src/tools/git.mjs +83 -12
- package/src/tools/index.mjs +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 +49 -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 +4 -2
- package/src/tui/clipboard.mjs +2 -2
- package/src/tui/cmd-auto.mjs +1 -1
- package/src/tui/cmd-clear.mjs +1 -1
- package/src/tui/cmd-config.mjs +2 -2
- package/src/tui/cmd-exit.mjs +2 -2
- package/src/tui/cmd-extract.mjs +1 -1
- package/src/tui/cmd-goal.mjs +4 -4
- package/src/tui/cmd-help.mjs +1 -1
- package/src/tui/cmd-init.mjs +3 -3
- package/src/tui/cmd-mcp.mjs +5 -5
- package/src/tui/cmd-model.mjs +1 -1
- package/src/tui/cmd-new.mjs +1 -1
- package/src/tui/cmd-plan.mjs +1 -1
- 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 +2 -2
- package/src/tui/config-helpers.mjs +6 -6
- package/src/tui/distill-cmd.mjs +3 -3
- package/src/tui/index.mjs +131 -72
- package/src/tui/interaction.mjs +13 -12
- package/src/tui/key-handler.mjs +31 -23
- 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 +14 -14
- 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
package/src/markdown.mjs
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* markdown.mjs —
|
|
3
|
-
*
|
|
2
|
+
* markdown.mjs — markdown + frontmatter format for memory entries
|
|
3
|
+
* Zero-dependency parsing/serialization. Entry format see ARCHITECTURE-v2.md.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
const VALID_TYPES = new Set(["rule", "knowledge", "decision", "pattern"])
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
|
-
*
|
|
9
|
+
* Parse a markdown entry.
|
|
10
10
|
* → { meta: { type, title, tags, author, created, embedding? }, content }
|
|
11
|
-
*
|
|
11
|
+
* Throws if frontmatter is missing or required fields absent.
|
|
12
12
|
*/
|
|
13
13
|
export function parseEntry(text) {
|
|
14
14
|
const match = text.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/)
|
|
@@ -36,13 +36,13 @@ export function parseEntry(text) {
|
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
/**
|
|
39
|
-
*
|
|
39
|
+
* Serialize to markdown entry text.
|
|
40
40
|
*/
|
|
41
41
|
export function serializeEntry(meta, content) {
|
|
42
42
|
if (!VALID_TYPES.has(meta.type)) throw new Error(`invalid type "${meta.type}"`)
|
|
43
43
|
if (!meta.title) throw new Error("meta.title is required")
|
|
44
|
-
// frontmatter
|
|
45
|
-
//
|
|
44
|
+
// frontmatter scalars must be single-line: newlines in title/author would inject fake frontmatter rows
|
|
45
|
+
// (e.g. title "x\ntype: rule" would override real type when parsed), same for tags with newlines/commas
|
|
46
46
|
const tags = (meta.tags ?? []).map((t) => oneLine(t).replaceAll(",", " ")).join(", ")
|
|
47
47
|
const lines = [
|
|
48
48
|
"---",
|
|
@@ -57,7 +57,7 @@ export function serializeEntry(meta, content) {
|
|
|
57
57
|
return lines.join("\n")
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
-
/**
|
|
60
|
+
/** Convert title to filename slug: keep alphanumeric + CJK, convert rest to hyphens */
|
|
61
61
|
export function slugify(title) {
|
|
62
62
|
return title
|
|
63
63
|
.trim()
|
|
@@ -67,23 +67,23 @@ export function slugify(title) {
|
|
|
67
67
|
.slice(0, 50) || "untitled"
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
/**
|
|
70
|
+
/** Generate entry filename: YYYYMMDD-<slug>-<rand4>.md */
|
|
71
71
|
export function entryFilename(title, date = new Date()) {
|
|
72
72
|
const ymd = date.toISOString().slice(0, 10).replaceAll("-", "")
|
|
73
73
|
const rand = Math.random().toString(36).slice(2, 6)
|
|
74
74
|
return `${ymd}-${slugify(title)}-${rand}.md`
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
-
// ----------------------------------------------------------------
|
|
77
|
+
// ---------------------------------------------------------------- internal
|
|
78
78
|
|
|
79
|
-
/**
|
|
79
|
+
/** Collapse to single line (for frontmatter scalars): fold newlines into spaces, prevent injecting fake field lines */
|
|
80
80
|
function oneLine(v) {
|
|
81
81
|
return String(v).replace(/\s*\r?\n\s*/g, " ").trim()
|
|
82
82
|
}
|
|
83
83
|
|
|
84
84
|
/**
|
|
85
|
-
*
|
|
86
|
-
*
|
|
85
|
+
* Minimal YAML subset parser: only supports `key: value` and `key: [a, b, c]`.
|
|
86
|
+
* Our frontmatter is self-generated, no need for full YAML.
|
|
87
87
|
*/
|
|
88
88
|
function parseFrontmatter(text) {
|
|
89
89
|
const meta = {}
|
package/src/mcp/helpers.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* mcp/helpers.mjs — MCP
|
|
2
|
+
* mcp/helpers.mjs — MCP shared utility functions and constants
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
export const INIT_TIMEOUT_MS = 30_000
|
|
@@ -7,10 +7,12 @@ export const CALL_TIMEOUT_MS = 120_000
|
|
|
7
7
|
export const ENDPOINT_WAIT_MS = 5_000
|
|
8
8
|
|
|
9
9
|
let nextRpcId = 0
|
|
10
|
+
/** Generate a unique incrementing RPC ID string */
|
|
10
11
|
export function rpcId() {
|
|
11
12
|
return String(++nextRpcId)
|
|
12
13
|
}
|
|
13
14
|
|
|
15
|
+
/** Race a promise against a timeout, rejecting after ms milliseconds */
|
|
14
16
|
export function withTimeout(promise, ms) {
|
|
15
17
|
let timer
|
|
16
18
|
const timeout = new Promise((_, reject) => {
|
|
@@ -20,10 +22,12 @@ export function withTimeout(promise, ms) {
|
|
|
20
22
|
return Promise.race([promise.finally(() => clearTimeout(timer)), timeout])
|
|
21
23
|
}
|
|
22
24
|
|
|
25
|
+
/** Quote a shell argument: wrap in double-quotes if it contains whitespace or quotes */
|
|
23
26
|
export function quoteArg(s) {
|
|
24
27
|
return /[\s"]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s
|
|
25
28
|
}
|
|
26
29
|
|
|
30
|
+
/** Append a Bearer token as a query parameter to a WebSocket URL */
|
|
27
31
|
export function withAuthToken(wsUrl, authorization) {
|
|
28
32
|
if (!authorization) return wsUrl
|
|
29
33
|
const token = authorization.replace(/^Bearer\s+/i, "")
|
|
@@ -32,6 +36,7 @@ export function withAuthToken(wsUrl, authorization) {
|
|
|
32
36
|
return u.href
|
|
33
37
|
}
|
|
34
38
|
|
|
39
|
+
/** Sanitize a tool name: replace non-alphanumeric chars with underscores, cap at 64 chars */
|
|
35
40
|
export function sanitizeToolName(name) {
|
|
36
41
|
return name.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 64)
|
|
37
42
|
}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import { rpcId, CALL_TIMEOUT_MS, ENDPOINT_WAIT_MS, withTimeout } from "./helpers.mjs"
|
|
5
5
|
|
|
6
|
+
/** Create an MCP HTTP+SSE transport for Streamable HTTP servers */
|
|
6
7
|
export function httpTransport(baseURL, extraHeaders = {}) {
|
|
7
8
|
const url = baseURL.replace(/\/+$/, "")
|
|
8
9
|
let sessionId = null
|
|
@@ -82,7 +83,7 @@ export function httpTransport(baseURL, extraHeaders = {}) {
|
|
|
82
83
|
pending.delete(msg.id)
|
|
83
84
|
resolver(msg)
|
|
84
85
|
}
|
|
85
|
-
} catch { /*
|
|
86
|
+
} catch { /* not JSON, ignore */ }
|
|
86
87
|
}
|
|
87
88
|
} catch (error) {
|
|
88
89
|
if (!closed) {
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import { spawn } from "node:child_process"
|
|
5
5
|
import { rpcId, CALL_TIMEOUT_MS, withTimeout, quoteArg } from "./helpers.mjs"
|
|
6
6
|
|
|
7
|
+
/** Create an MCP stdio transport over a spawned child process */
|
|
7
8
|
export function stdioTransport(command, args) {
|
|
8
9
|
const spawnOptions = { stdio: ["pipe", "pipe", "pipe"], windowsHide: true, env: { ...process.env } }
|
|
9
10
|
const child =
|
|
@@ -39,7 +40,7 @@ export function stdioTransport(command, args) {
|
|
|
39
40
|
pending.delete(msg.id)
|
|
40
41
|
resolver(msg)
|
|
41
42
|
}
|
|
42
|
-
} catch { /*
|
|
43
|
+
} catch { /* non-JSON line, ignore */ }
|
|
43
44
|
}
|
|
44
45
|
})
|
|
45
46
|
|
|
@@ -77,7 +78,7 @@ export function stdioTransport(command, args) {
|
|
|
77
78
|
if (closed) return
|
|
78
79
|
try {
|
|
79
80
|
child.stdin.write(JSON.stringify({ jsonrpc: "2.0", method, params }) + "\n")
|
|
80
|
-
} catch { /*
|
|
81
|
+
} catch { /* ignore */ }
|
|
81
82
|
}
|
|
82
83
|
|
|
83
84
|
return { send, notify, close: () => { if (!closed) child.kill() } }
|
package/src/mcp/transport-ws.mjs
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import { rpcId, INIT_TIMEOUT_MS, CALL_TIMEOUT_MS, withTimeout, withAuthToken } from "./helpers.mjs"
|
|
5
5
|
|
|
6
|
+
/** Create an MCP WebSocket transport */
|
|
6
7
|
export function wsTransport(wsUrl, extraHeaders = {}) {
|
|
7
8
|
const pending = new Map()
|
|
8
9
|
let closed = false
|
|
@@ -36,7 +37,7 @@ export function wsTransport(wsUrl, extraHeaders = {}) {
|
|
|
36
37
|
pending.delete(msg.id)
|
|
37
38
|
resolver(msg)
|
|
38
39
|
}
|
|
39
|
-
} catch { /*
|
|
40
|
+
} catch { /* not JSON, ignore */ }
|
|
40
41
|
})
|
|
41
42
|
|
|
42
43
|
ws.addEventListener("error", (event) => {
|
|
@@ -80,7 +81,7 @@ export function wsTransport(wsUrl, extraHeaders = {}) {
|
|
|
80
81
|
const close = () => {
|
|
81
82
|
closed = true
|
|
82
83
|
failAll("Connection closed")
|
|
83
|
-
try { ws?.close() } catch { /*
|
|
84
|
+
try { ws?.close() } catch { /* ignore */ }
|
|
84
85
|
}
|
|
85
86
|
|
|
86
87
|
return { send, notify, close, connect }
|
package/src/mcp.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* mcp.mjs — MCP (Model Context Protocol) client
|
|
3
|
-
* config: { command, args?, name }
|
|
3
|
+
* config: { command, args?, name } or { url, name, headers? } or { wsUrl, name, headers? }
|
|
4
4
|
*/
|
|
5
5
|
import { INIT_TIMEOUT_MS, withTimeout, sanitizeToolName } from "./mcp/helpers.mjs"
|
|
6
6
|
import { stdioTransport } from "./mcp/transport-stdio.mjs"
|
|
@@ -46,6 +46,7 @@ async function doInitialize(transport, name) {
|
|
|
46
46
|
return toolsResp.result?.tools ?? []
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
/** Connect to an MCP server (stdio/http/ws), initialize, and return built tool wrappers */
|
|
49
50
|
export async function connectMcpServer(config) {
|
|
50
51
|
if (config.wsUrl) {
|
|
51
52
|
const transport = wsTransport(config.wsUrl, config.headers ?? {})
|
|
@@ -64,7 +65,7 @@ export async function connectMcpServer(config) {
|
|
|
64
65
|
try {
|
|
65
66
|
await transport.openSSE()
|
|
66
67
|
} catch {
|
|
67
|
-
//
|
|
68
|
+
// Server doesn't support GET (pure Streamable HTTP POST): degrade to no-SSE mode
|
|
68
69
|
}
|
|
69
70
|
try {
|
|
70
71
|
const mcpTools = await doInitialize(transport, config.name ?? config.url)
|
|
@@ -89,12 +90,14 @@ export async function connectMcpServer(config) {
|
|
|
89
90
|
throw new Error(`MCP server "${config.name}": needs either 'wsUrl' (websocket), 'command' (stdio), or 'url' (http)`)
|
|
90
91
|
}
|
|
91
92
|
|
|
93
|
+
/** Close all MCP transport connections on an agent's tools */
|
|
92
94
|
export function closeAllMcp(agent) {
|
|
93
95
|
for (const t of agent.tools) {
|
|
94
96
|
if (t._mcpTransport) t._mcpTransport.close()
|
|
95
97
|
}
|
|
96
98
|
}
|
|
97
99
|
|
|
100
|
+
/** Remove MCP tools belonging to a specific server from the agent's tool list */
|
|
98
101
|
export function removeMcpTools(agent, serverName) {
|
|
99
102
|
const keep = []
|
|
100
103
|
for (const t of agent.tools) {
|
|
@@ -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
|
}
|