thincoder 0.8.11 → 0.8.13
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 +27 -0
- package/bin/thincoder.mjs +115 -0
- package/package.json +1 -1
- package/src/advisor.mjs +105 -0
- package/src/agent/dispatch.mjs +35 -0
- package/src/agent/setup.mjs +9 -10
- package/src/agent-tools/subagent.mjs +1 -1
- package/src/agent-tools/timer.mjs +41 -0
- package/src/agent-tools/verify.mjs +165 -56
- package/src/agent-tools.mjs +1 -0
- package/src/agent.mjs +128 -21
- package/src/auto-think.mjs +83 -0
- package/src/cli/make-agent.mjs +9 -0
- package/src/config.mjs +18 -18
- package/src/context.mjs +3 -1
- package/src/distill.mjs +19 -4
- package/src/embedding.mjs +3 -1
- package/src/git/checkpoint.mjs +2 -1
- package/src/git/gitmem.mjs +8 -2
- package/src/markdown.mjs +1 -1
- package/src/mcp/transport-http.mjs +11 -4
- package/src/memory/code-index.mjs +2 -2
- package/src/memory/code-sync.mjs +92 -35
- package/src/memory/core.mjs +10 -1
- package/src/memory/docs.mjs +25 -28
- package/src/memory/schema.mjs +16 -3
- package/src/prompts/coder.md +7 -4
- package/src/prompts/discipline.md +47 -15
- package/src/prompts/main.md +15 -11
- package/src/prompts/system.md +33 -7
- package/src/provider/core.mjs +142 -15
- package/src/provider/index.mjs +1 -1
- package/src/rules.mjs +53 -0
- package/src/session.mjs +9 -3
- package/src/tools/file.mjs +114 -5
- package/src/tools/hashline_edit.md +12 -0
- package/src/tools/index.mjs +6 -4
- package/src/tools/linter.md +13 -0
- package/src/tools/linter.mjs +146 -0
- package/src/tools/patch.mjs +7 -3
- package/src/tools/read.md +3 -2
- package/src/tools/repomap.mjs +19 -10
- package/src/tools/shared.mjs +7 -0
- package/src/tools/system.mjs +18 -4
- package/src/tui/agent-turn.mjs +17 -2
- package/src/tui/ansi.mjs +5 -0
- package/src/tui/cmd-advisor.mjs +68 -0
- package/src/tui/cmd-think.mjs +36 -10
- package/src/tui/index.mjs +167 -54
- package/src/tui/key-handler.mjs +36 -1
- package/src/tui/layout.mjs +6 -4
- package/src/tui/pickers.mjs +15 -15
- package/src/tui/render-frame.mjs +240 -167
- package/src/tui/slash-commands.mjs +3 -0
- package/src/tools/repomap-parse.mjs +0 -168
package/src/config.mjs
CHANGED
|
@@ -18,7 +18,7 @@ export const PROVIDER_PRESETS = {
|
|
|
18
18
|
kimi: { baseURL: "https://api.moonshot.cn/v1", model: "kimi-k3", thinking: null, reasoningEffort: "max", maxTokens: 131072, desc: "Kimi / Moonshot" },
|
|
19
19
|
glm: { baseURL: "https://open.bigmodel.cn/api/paas/v4", model: "glm-5.2", thinking: { type: "enabled" }, reasoningEffort: "max", maxTokens: 131072, desc: "Zhipu GLM" },
|
|
20
20
|
qwen: { baseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1", model: "qwen3.7-max", maxTokens: 131072, desc: "Qwen / Alibaba" },
|
|
21
|
-
minimax: { baseURL: "https://api.
|
|
21
|
+
minimax: { baseURL: "https://api.minimaxi.com/v1", model: "MiniMax-M3", thinking: { type: "adaptive" }, maxTokens: 131072, desc: "MiniMax" },
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
// Default provider matches deepseek preset (strip the desc display field)
|
|
@@ -30,8 +30,12 @@ const DEFAULTS = {
|
|
|
30
30
|
agent: {
|
|
31
31
|
maxTurns: 100,
|
|
32
32
|
subagentTurns: 100,
|
|
33
|
+
goalTurns: 200,
|
|
33
34
|
compactThreshold: 100000,
|
|
34
35
|
verifyGuard: false, // push model back to verify when files were mutated but verify not run (opt-in)
|
|
36
|
+
streamRules: [], // time-traveling stream rules: [{ pattern: "regex", message: "reminder", action: "abort"|"warn", repeat: "always"|"once" }]
|
|
37
|
+
advisor: { enabled: false }, // automated code review after each tool-execution turn; optionally: { enabled: true, provider: "deepseek", model: "deepseek-chat" }
|
|
38
|
+
autoThink: false, // auto-classify task difficulty and set reasoning effort per-turn
|
|
35
39
|
},
|
|
36
40
|
memory: {
|
|
37
41
|
dbPath: join(configDir, "memory.db"),
|
|
@@ -59,6 +63,7 @@ const DEFAULTS = {
|
|
|
59
63
|
* multimodal: whether multimodal (image/vision input supported)
|
|
60
64
|
* cacheMode: context caching mode: "auto"=automatic / "prompt"=needs explicit / "none"=unsupported
|
|
61
65
|
* thinkApi: thinking API type: "type"=thinking.type field / "effort"=reasoning_effort field
|
|
66
|
+
* thinkOnValue: when thinkApi is "type", the value used to enable thinking (default "enabled"; MiniMax uses "adaptive")
|
|
62
67
|
* reasoningEcho: reasoning_content cross-turn echo strategy: "required"=must echo (error if missing) / "optional"=echo optional (default: don't echo)
|
|
63
68
|
* reasoningEffortEnum: valid reasoning_effort enum values (if undeclared, no validation — passed through as-is)
|
|
64
69
|
* tempRange: valid temperature range [min, max] (if undeclared, no clamping)
|
|
@@ -66,11 +71,11 @@ const DEFAULTS = {
|
|
|
66
71
|
const MODEL_SPECS = [
|
|
67
72
|
// DeepSeek V4 series
|
|
68
73
|
["deepseek-v4-pro", { context: 1_000_000, maxOutput: 384_000, thinking: true, prefixMode: true, cacheMode: "prompt", thinkApi: "type", reasoningEcho: "required", reasoningEffortEnum: ["high", "max"], tempRange: [0, 2] }],
|
|
69
|
-
["deepseek-v4-flash", { context:
|
|
74
|
+
["deepseek-v4-flash", { context: 1_000_000, maxOutput: 384_000, thinking: true, prefixMode: true, cacheMode: "prompt", thinkApi: "type", reasoningEcho: "required", reasoningEffortEnum: ["high", "max"], tempRange: [0, 2] }],
|
|
70
75
|
["deepseek-reasoner", { context: 256_000, maxOutput: 384_000, thinking: true, prefixMode: true, cacheMode: "prompt", thinkApi: "type", reasoningEcho: "required", reasoningEffortEnum: ["high", "max"], tempRange: [0, 2] }],
|
|
71
76
|
["deepseek-chat", { context: 256_000, maxOutput: 384_000, thinking: false, prefixMode: true, cacheMode: "prompt", thinkApi: "type", reasoningEcho: "required", reasoningEffortEnum: ["high", "max"], tempRange: [0, 2] }],
|
|
72
77
|
// Kimi series
|
|
73
|
-
["kimi-k3", { context: 1_000_000, maxOutput: 128_000, thinking: true, partialMode: true, multimodal: true, cacheMode: "
|
|
78
|
+
["kimi-k3", { context: 1_000_000, maxOutput: 128_000, thinking: true, partialMode: true, multimodal: true, cacheMode: "auto", thinkApi: "effort", reasoningEcho: "required", reasoningEffortEnum: ["low", "high", "max"] }],
|
|
74
79
|
["kimi-k2", { context: 256_000, maxOutput: 128_000, thinking: false, partialMode: true, multimodal: true, cacheMode: "none" }],
|
|
75
80
|
["moonshot", { context: 128_000, maxOutput: 32_000, thinking: false, cacheMode: "none" }],
|
|
76
81
|
// GLM series
|
|
@@ -88,18 +93,18 @@ const MODEL_SPECS = [
|
|
|
88
93
|
["qwen-plus", { context: 1_000_000, maxOutput: 32_000, thinking: false, partialMode: true, multimodal: true, cacheMode: "none", thinkApi: "effort", tempRange: [0, 2] }],
|
|
89
94
|
["qwen", { context: 1_000_000, maxOutput: 128_000, thinking: false, partialMode: true, multimodal: true, cacheMode: "none", thinkApi: "effort", tempRange: [0, 2] }],
|
|
90
95
|
// MiniMax series
|
|
91
|
-
["MiniMax-M3", { context: 1_000_000, maxOutput: 128_000, thinking: true, multimodal: true, cacheMode: "auto", thinkApi: "type", tempRange: [0, 2] }],
|
|
92
|
-
["minimax-m3", { context: 1_000_000, maxOutput: 128_000, thinking: true, multimodal: true, cacheMode: "auto", thinkApi: "type", tempRange: [0, 2] }],
|
|
96
|
+
["MiniMax-M3", { context: 1_000_000, maxOutput: 128_000, thinking: true, multimodal: true, cacheMode: "auto", thinkApi: "type", thinkOnValue: "adaptive", tempRange: [0, 2] }],
|
|
97
|
+
["minimax-m3", { context: 1_000_000, maxOutput: 128_000, thinking: true, multimodal: true, cacheMode: "auto", thinkApi: "type", thinkOnValue: "adaptive", tempRange: [0, 2] }],
|
|
93
98
|
["minimax-m1", { context: 256_000, maxOutput: 128_000, thinking: false, cacheMode: "auto" }],
|
|
94
99
|
]
|
|
95
100
|
const DEFAULT_SPEC = { context: 128_000, maxOutput: 32_000, cacheMode: "none" }
|
|
96
|
-
// Window utilization cap: 0.
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
|
|
101
|
-
const COMPACT_RATIO = 0.8
|
|
101
|
+
// Window utilization cap: 0.6 — triggers earlier (reserving 40% headroom) because
|
|
102
|
+
// injected context (directory tree, git context, outline, project instructions, memory/doc
|
|
103
|
+
// search results) can consume 30-50K tokens each turn; waiting until 80% leaves no room.
|
|
104
|
+
// For 1M-window models: 600K is still too high → cap at 300K.
|
|
105
|
+
const COMPACT_RATIO = 0.6
|
|
102
106
|
const COMPACT_CAP_TOKENS = 300_000
|
|
107
|
+
const COMPACT_FLOOR = 40_000
|
|
103
108
|
|
|
104
109
|
/** Look up spec by model name prefix (case-insensitive), conservative default for unknown models */
|
|
105
110
|
export function specForModel(model) {
|
|
@@ -110,18 +115,13 @@ export function specForModel(model) {
|
|
|
110
115
|
return DEFAULT_SPEC
|
|
111
116
|
}
|
|
112
117
|
|
|
113
|
-
/** Return the context window size for a given model name */
|
|
114
|
-
export function contextWindowForModel(model) {
|
|
115
|
-
return specForModel(model).context
|
|
116
|
-
}
|
|
117
|
-
|
|
118
118
|
/** Derive compaction threshold; explicit is the value explicitly set in config file (takes priority), otherwise auto-computed from model */
|
|
119
119
|
export function resolveCompactThreshold(explicit, model) {
|
|
120
120
|
if (explicit != null) return { value: explicit, auto: false }
|
|
121
121
|
const spec = specForModel(model)
|
|
122
122
|
const ratioBased = Math.floor(spec.context * COMPACT_RATIO)
|
|
123
|
-
//
|
|
124
|
-
const value = Math.min(ratioBased, COMPACT_CAP_TOKENS)
|
|
123
|
+
// Cap for large-window models (1M) and floor for small-window models (<64K, should not compact too aggressively)
|
|
124
|
+
const value = Math.max(Math.min(ratioBased, COMPACT_CAP_TOKENS), COMPACT_FLOOR)
|
|
125
125
|
return { value, auto: true }
|
|
126
126
|
}
|
|
127
127
|
|
package/src/context.mjs
CHANGED
|
@@ -143,7 +143,7 @@ function applyCompression(agent, headEnd, tailStart, note) {
|
|
|
143
143
|
* Only called at safe points in the loop (history ends with user or tool message — a complete exchange boundary).
|
|
144
144
|
* Automatically re-injects task list state after compaction.
|
|
145
145
|
*/
|
|
146
|
-
export async function compressIfNeeded(agent, threshold) {
|
|
146
|
+
export async function compressIfNeeded(agent, threshold, callbacks) {
|
|
147
147
|
const history = agent.history
|
|
148
148
|
// Prefer the real baseline: the last response's prompt_tokens is the measured value for the full context (system+tools+history).
|
|
149
149
|
// Subsequent appended messages use estimation as increment; when no measured value exists (first turn / after restore / right after compaction), fall back to pure estimation
|
|
@@ -174,6 +174,8 @@ export async function compressIfNeeded(agent, threshold) {
|
|
|
174
174
|
// The summary is a plain-text task, no reasoning needed — passing thinking to the compaction provider wastes tokens
|
|
175
175
|
const summary = await chat({ ...agent.provider, thinking: null, reasoningEffort: null }, {
|
|
176
176
|
messages: [{ role: "user", content: SUMMARIZE_PROMPT + serialized }],
|
|
177
|
+
onToken: callbacks?.onToken,
|
|
178
|
+
onReasoning: callbacks?.onReasoning,
|
|
177
179
|
})
|
|
178
180
|
|
|
179
181
|
applyCompression(agent, split.headEnd, split.tailStart, COMPACTION_PREFIX + summary.content)
|
package/src/distill.mjs
CHANGED
|
@@ -47,11 +47,26 @@ export async function extractCandidates(provider, transcript) {
|
|
|
47
47
|
const res = await chat(provider, {
|
|
48
48
|
messages: [{ role: "user", content: DISTILL_PROMPT + transcript }],
|
|
49
49
|
})
|
|
50
|
-
//
|
|
51
|
-
|
|
52
|
-
|
|
50
|
+
// Balanced-bracket extraction: find the first '[' and track depth through nested
|
|
51
|
+
// brackets (tags arrays, nested objects, etc.) until the matching ']'.
|
|
52
|
+
// Non-greedy regex (/\[[\s\S]*?\]/) stops at the FIRST ']', which is wrong when
|
|
53
|
+
// LLM output contains nested arrays like `"tags": ["a", "b"]`.
|
|
54
|
+
const start = res.content.indexOf("[")
|
|
55
|
+
if (start === -1) return []
|
|
56
|
+
let depth = 0
|
|
57
|
+
let end = -1
|
|
58
|
+
for (let i = start; i < res.content.length; i++) {
|
|
59
|
+
const ch = res.content[i]
|
|
60
|
+
if (ch === "[" && (i === start || res.content[i - 1] !== "\\")) depth++
|
|
61
|
+
else if (ch === "]" && res.content[i - 1] !== "\\") {
|
|
62
|
+
depth--
|
|
63
|
+
if (depth === 0) { end = i + 1; break }
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (end === -1) return []
|
|
67
|
+
const jsonText = res.content.slice(start, end)
|
|
53
68
|
try {
|
|
54
|
-
const parsed = JSON.parse(
|
|
69
|
+
const parsed = JSON.parse(jsonText)
|
|
55
70
|
if (!Array.isArray(parsed)) return []
|
|
56
71
|
return parsed.filter((c) => c?.type && c?.title && c?.content)
|
|
57
72
|
} catch {
|
package/src/embedding.mjs
CHANGED
|
@@ -84,7 +84,9 @@ async function requestWithRetry(embedder, input, signal) {
|
|
|
84
84
|
Authorization: `Bearer ${embedder.apiKey}`,
|
|
85
85
|
},
|
|
86
86
|
body: JSON.stringify({ model: embedder.model, input }),
|
|
87
|
-
signal
|
|
87
|
+
signal: signal
|
|
88
|
+
? AbortSignal.any([signal, AbortSignal.timeout(60_000)])
|
|
89
|
+
: AbortSignal.timeout(60_000),
|
|
88
90
|
})
|
|
89
91
|
} catch (error) {
|
|
90
92
|
if (error.name === "AbortError") throw error
|
package/src/git/checkpoint.mjs
CHANGED
|
@@ -78,7 +78,8 @@ export async function createCheckpoint(cwd) {
|
|
|
78
78
|
const src = join(cwd, rel)
|
|
79
79
|
const dst = join(dir, "untracked", rel)
|
|
80
80
|
await mkdir(dirname(dst), { recursive: true })
|
|
81
|
-
|
|
81
|
+
// Copy failed (socket/device file etc.) — skip, but log in case it's unexpected
|
|
82
|
+
await copyFile(src, dst).catch((e) => console.error(`[checkpoint] skipping ${rel}: ${e.message}`))
|
|
82
83
|
}
|
|
83
84
|
|
|
84
85
|
await writeFile(join(dir, "meta.json"), JSON.stringify({
|
package/src/git/gitmem.mjs
CHANGED
|
@@ -51,11 +51,17 @@ export async function pullTeam(dir) {
|
|
|
51
51
|
return true
|
|
52
52
|
} catch (error) {
|
|
53
53
|
if (await hasConflict(dir)) {
|
|
54
|
-
|
|
54
|
+
let abortFailed = false
|
|
55
|
+
try { await git(dir, ["rebase", "--abort"]) } catch {
|
|
56
|
+
abortFailed = true
|
|
57
|
+
}
|
|
55
58
|
throw new Error(
|
|
56
59
|
`Team memory sync conflict: local and remote modified the same entry.\n` +
|
|
57
60
|
`Please resolve manually in ${dir} with \`git pull\`, then re-run \`thincoder sync\`.\n` +
|
|
58
|
-
|
|
61
|
+
(abortFailed
|
|
62
|
+
? `(WARNING: git rebase --abort also failed — the repo may be in a conflicted state. ` +
|
|
63
|
+
`Run \`cd ${dir} && git rebase --abort\` manually to clean up.)`
|
|
64
|
+
: `(The local repo has been restored to its pre-sync state — nothing was lost.)`),
|
|
59
65
|
)
|
|
60
66
|
}
|
|
61
67
|
throw error
|
package/src/markdown.mjs
CHANGED
|
@@ -85,7 +85,7 @@ function oneLine(v) {
|
|
|
85
85
|
* Minimal YAML subset parser: only supports `key: value` and `key: [a, b, c]`.
|
|
86
86
|
* Our frontmatter is self-generated, no need for full YAML.
|
|
87
87
|
*/
|
|
88
|
-
function parseFrontmatter(text) {
|
|
88
|
+
export function parseFrontmatter(text) {
|
|
89
89
|
const meta = {}
|
|
90
90
|
for (const line of text.split(/\r?\n/)) {
|
|
91
91
|
const m = line.match(/^(\w[\w-]*)\s*:\s*(.*)$/)
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* mcp/transport-http.mjs — MCP HTTP + SSE transport (Streamable HTTP)
|
|
3
3
|
*/
|
|
4
|
-
import { rpcId, CALL_TIMEOUT_MS, ENDPOINT_WAIT_MS, withTimeout } from "./helpers.mjs"
|
|
4
|
+
import { rpcId, CALL_TIMEOUT_MS, ENDPOINT_WAIT_MS, INIT_TIMEOUT_MS, withTimeout } from "./helpers.mjs"
|
|
5
5
|
|
|
6
6
|
/** Create an MCP HTTP+SSE transport for Streamable HTTP servers */
|
|
7
7
|
export function httpTransport(baseURL, extraHeaders = {}) {
|
|
@@ -56,10 +56,11 @@ export function httpTransport(baseURL, extraHeaders = {}) {
|
|
|
56
56
|
if (closed) return
|
|
57
57
|
abortController?.abort()
|
|
58
58
|
abortController = new AbortController()
|
|
59
|
+
const signal = AbortSignal.any([abortController.signal, AbortSignal.timeout(INIT_TIMEOUT_MS)])
|
|
59
60
|
const resp = await fetch(url, {
|
|
60
61
|
method: "GET",
|
|
61
62
|
headers: { Accept: "text/event-stream", ...extraHeaders },
|
|
62
|
-
signal
|
|
63
|
+
signal,
|
|
63
64
|
})
|
|
64
65
|
if (!resp.ok) throw new Error(`SSE connect failed: HTTP ${resp.status}`)
|
|
65
66
|
eventSource = parseSSE(resp)
|
|
@@ -155,7 +156,10 @@ export function httpTransport(baseURL, extraHeaders = {}) {
|
|
|
155
156
|
headers: headers(),
|
|
156
157
|
body: JSON.stringify({ jsonrpc: "2.0", method, params }),
|
|
157
158
|
signal: AbortSignal.timeout(10_000),
|
|
158
|
-
}).catch(() => {
|
|
159
|
+
}).catch((e) => {
|
|
160
|
+
// notify is fire-and-forget by design, but log network errors for debugging
|
|
161
|
+
if (e.name !== "AbortError") console.error(`[mcp] notify failed: ${e.message}`)
|
|
162
|
+
})
|
|
159
163
|
}
|
|
160
164
|
|
|
161
165
|
const close = () => {
|
|
@@ -166,7 +170,10 @@ export function httpTransport(baseURL, extraHeaders = {}) {
|
|
|
166
170
|
method: "DELETE",
|
|
167
171
|
headers: { "Mcp-Session-Id": sessionId, ...extraHeaders },
|
|
168
172
|
signal: AbortSignal.timeout(5_000),
|
|
169
|
-
}).catch(() => {
|
|
173
|
+
}).catch((e) => {
|
|
174
|
+
// close is best-effort cleanup; log but don't throw
|
|
175
|
+
if (e.name !== "AbortError") console.error(`[mcp] close DELETE failed: ${e.message}`)
|
|
176
|
+
})
|
|
170
177
|
sessionId = null
|
|
171
178
|
}
|
|
172
179
|
for (const [, resolve] of pending) resolve({ id: null, error: { code: -32000, message: "Connection closed" } })
|
|
@@ -140,9 +140,9 @@ export function extractLeadingDoc(lines, lineNum, ext) {
|
|
|
140
140
|
return text.length > 0 && text.length < 300 ? text : ""
|
|
141
141
|
}
|
|
142
142
|
|
|
143
|
-
/** Yield control to the event loop for one tick (allows keyboard input to be processed) */
|
|
143
|
+
/** Yield control to the event loop for one tick (allows keyboard input to be processed). Uses setImmediate for lower latency than setTimeout(0). */
|
|
144
144
|
export function yieldTick() {
|
|
145
|
-
return new Promise((r) =>
|
|
145
|
+
return new Promise((r) => setImmediate(r))
|
|
146
146
|
}
|
|
147
147
|
|
|
148
148
|
/** Index a single file: delete old chunks → chunk → insert new chunks */
|
package/src/memory/code-sync.mjs
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* memory/code-sync.mjs — code index sync, retrieval, incremental update
|
|
3
3
|
*/
|
|
4
|
-
|
|
5
4
|
import { readFile, stat } from "node:fs/promises"
|
|
6
5
|
import { join, relative } from "node:path"
|
|
7
6
|
import { embed, cosine, toBlob, fromBlob } from "../embedding.mjs"
|
|
8
|
-
import { CODE_EXTS, DOC_EXTS, SKIP_DIRS } from "./schema.mjs"
|
|
7
|
+
import { CODE_EXTS, DOC_EXTS, SKIP_DIRS, MAX_CODE_FILE_BYTES, MAX_DOC_FILE_BYTES } from "./schema.mjs"
|
|
9
8
|
import { buildFtsQuery, ensureEmbeddings, EMBED_TEXT_MAX_LEN } from "./core.mjs"
|
|
10
9
|
import { detectLanguage, _upsertCodeFile, _upsertDocFile, yieldTick } from "./code-index.mjs"
|
|
11
10
|
|
|
@@ -19,20 +18,25 @@ const CODE_EMBED_BATCH = 64
|
|
|
19
18
|
* Returns { updated, removed, skipped } or null (git unavailable).
|
|
20
19
|
*/
|
|
21
20
|
export async function gitSync(memory, dir, { onProgress } = {}) {
|
|
22
|
-
const {
|
|
23
|
-
const
|
|
21
|
+
const { execFile: _execFile } = await import("node:child_process")
|
|
22
|
+
const gitRun = (args) => new Promise((resolve, reject) => {
|
|
23
|
+
_execFile("git", args, { cwd: dir, encoding: "utf8", timeout: 10000, windowsHide: true }, (err, stdout) => {
|
|
24
|
+
if (err) reject(err); else resolve(stdout)
|
|
25
|
+
})
|
|
26
|
+
})
|
|
27
|
+
const mergeDiff = (text) => text.trim().split("\n").filter(Boolean)
|
|
24
28
|
|
|
25
29
|
let head
|
|
26
|
-
try { head =
|
|
30
|
+
try { head = (await gitRun(["rev-parse", "HEAD"])).trim() } catch { return null }
|
|
27
31
|
|
|
28
32
|
const stored = memory.db.prepare(`SELECT value FROM meta WHERE key = 'last_indexed_commit'`).get()?.value
|
|
29
33
|
if (!stored) return null
|
|
30
34
|
|
|
31
35
|
let diffOut
|
|
32
36
|
try {
|
|
33
|
-
const committed =
|
|
34
|
-
const dirty =
|
|
35
|
-
const lines = [...new Set([...committed
|
|
37
|
+
const committed = mergeDiff(await gitRun(["diff", "--name-only", "--diff-filter=ACMRTD", stored, "HEAD"]))
|
|
38
|
+
const dirty = mergeDiff(await gitRun(["diff", "--name-only", "--diff-filter=ACMRTD"]))
|
|
39
|
+
const lines = [...new Set([...committed, ...dirty])]
|
|
36
40
|
diffOut = lines
|
|
37
41
|
} catch {
|
|
38
42
|
return null
|
|
@@ -83,6 +87,7 @@ export async function gitSync(memory, dir, { onProgress } = {}) {
|
|
|
83
87
|
if (errors.length < 5) errors.push(`${rel}: ${e.message}`)
|
|
84
88
|
}
|
|
85
89
|
}
|
|
90
|
+
await yieldTick()
|
|
86
91
|
if (onProgress && i % 5 === 0) {
|
|
87
92
|
onProgress({ phase: "index", current: i + 1, total: diffOut.length, updated, removed, skipped })
|
|
88
93
|
}
|
|
@@ -97,44 +102,79 @@ export async function gitSync(memory, dir, { onProgress } = {}) {
|
|
|
97
102
|
return { updated, removed, skipped, failed, errors }
|
|
98
103
|
}
|
|
99
104
|
|
|
105
|
+
/**
|
|
106
|
+
* List project files matching the given extensions.
|
|
107
|
+
* Only indexes git repos — if dir isn't inside a git worktree, returns [].
|
|
108
|
+
* Uses `git ls-files --cached --others --exclude-standard` to get the file list
|
|
109
|
+
* (tracked + untracked-not-ignored, respecting .gitignore).
|
|
110
|
+
* Returns an array of { abs, rel } pairs (abs = full path, rel = path relative to dir).
|
|
111
|
+
*/
|
|
112
|
+
export async function listProjectFiles(dir, exts) {
|
|
113
|
+
const { execFile: _execFile } = await import("node:child_process")
|
|
114
|
+
const { join: joinPath } = await import("node:path")
|
|
115
|
+
|
|
116
|
+
// Only index git repos — if this isn't one, return empty
|
|
117
|
+
let gitTop
|
|
118
|
+
try {
|
|
119
|
+
gitTop = (await new Promise((resolve, reject) => {
|
|
120
|
+
_execFile("git", ["rev-parse", "--show-toplevel"], { cwd: dir, encoding: "utf8", timeout: 5000, windowsHide: true },
|
|
121
|
+
(err, stdout) => { if (err) reject(err); else resolve(stdout.trim()) })
|
|
122
|
+
})).replace(/\\/g, "/")
|
|
123
|
+
} catch {
|
|
124
|
+
return [] // not a git repo → nothing to index
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const files = []
|
|
128
|
+
try {
|
|
129
|
+
const raw = await new Promise((resolve, reject) => {
|
|
130
|
+
_execFile("git", ["ls-files", "--cached", "--others", "--exclude-standard"],
|
|
131
|
+
{ cwd: dir, encoding: "utf8", timeout: 15000, windowsHide: true, maxBuffer: 10 * 1024 * 1024 },
|
|
132
|
+
(err, stdout) => { if (err) reject(err); else resolve(stdout) })
|
|
133
|
+
})
|
|
134
|
+
for (const line of raw.trim().split("\n")) {
|
|
135
|
+
const p = line.trim()
|
|
136
|
+
if (!p) continue
|
|
137
|
+
const ext = p.slice(p.lastIndexOf(".")).toLowerCase()
|
|
138
|
+
if (!exts.has(ext)) continue
|
|
139
|
+
const abs = joinPath(dir, p)
|
|
140
|
+
const rel = p.replace(/\\/g, "/")
|
|
141
|
+
if (rel.split("/").some((seg) => SKIP_DIRS.has(seg) || seg.startsWith("."))) continue
|
|
142
|
+
files.push({ abs, rel })
|
|
143
|
+
}
|
|
144
|
+
} catch { /* ls-files failed */ }
|
|
145
|
+
|
|
146
|
+
return files
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
|
|
100
150
|
/**
|
|
101
151
|
* Sync code index: scan all source files under dir → chunk → upsert into code_chunks.
|
|
102
152
|
* Incremental by mtime — only rebuilds chunks for files that have changed.
|
|
103
153
|
*/
|
|
104
154
|
export async function codeSync(memory, dir, { onProgress } = {}) {
|
|
105
|
-
const
|
|
106
|
-
const {
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
await walk(join(d, e.name))
|
|
114
|
-
} else if (e.isFile()) {
|
|
115
|
-
const ext = e.name.slice(e.name.lastIndexOf(".")).toLowerCase()
|
|
116
|
-
if (CODE_EXTS.has(ext)) files.push(join(d, e.name))
|
|
117
|
-
}
|
|
118
|
-
}
|
|
155
|
+
const entries = await listProjectFiles(dir, CODE_EXTS)
|
|
156
|
+
const files = [] // { abs, rel, mtimeMs }
|
|
157
|
+
let overSizeSkipped = 0
|
|
158
|
+
for (const { abs, rel } of entries) {
|
|
159
|
+
let st
|
|
160
|
+
try { st = await stat(abs) } catch { continue }
|
|
161
|
+
if (st.size > MAX_CODE_FILE_BYTES) { overSizeSkipped++; continue }
|
|
162
|
+
files.push({ abs, rel, mtimeMs: Math.floor(st.mtimeMs) })
|
|
119
163
|
}
|
|
120
|
-
await walk(dir)
|
|
121
164
|
|
|
122
165
|
const indexed = new Map(
|
|
123
166
|
memory.db.prepare(`SELECT path, mtime_ms FROM code_chunks WHERE origin = ?`).all(dir).map((r) => [r.path, r.mtime_ms])
|
|
124
167
|
)
|
|
125
168
|
const seen = new Set()
|
|
126
169
|
|
|
127
|
-
onProgress?.({ phase: "scan", total: files.length })
|
|
170
|
+
onProgress?.({ phase: "scan", total: files.length, overSizeSkipped })
|
|
128
171
|
|
|
129
172
|
let updated = 0, removed = 0, skipped = 0, failed = 0
|
|
130
173
|
const errors = []
|
|
131
174
|
for (let i = 0; i < files.length; i++) {
|
|
132
|
-
const abs = files[i]
|
|
133
|
-
const rel = abs.slice(dir.length + 1).replaceAll("\\", "/")
|
|
175
|
+
const { abs, rel, mtimeMs } = files[i]
|
|
134
176
|
seen.add(rel)
|
|
135
177
|
|
|
136
|
-
let mtimeMs
|
|
137
|
-
try { mtimeMs = Math.floor((await stat(abs)).mtimeMs) } catch { continue }
|
|
138
178
|
if (indexed.get(rel) === mtimeMs) {
|
|
139
179
|
skipped++
|
|
140
180
|
continue
|
|
@@ -164,18 +204,22 @@ export async function codeSync(memory, dir, { onProgress } = {}) {
|
|
|
164
204
|
}
|
|
165
205
|
}
|
|
166
206
|
|
|
167
|
-
onProgress?.({ phase: "done", total: files.length, updated, removed, skipped, failed })
|
|
207
|
+
onProgress?.({ phase: "done", total: files.length, updated, removed, skipped, failed, overSizeSkipped })
|
|
168
208
|
markIndexedCommit(memory, dir)
|
|
169
|
-
return { updated, removed, skipped, failed, errors, total: files.length }
|
|
209
|
+
return { updated, removed, skipped, failed, errors, total: files.length, overSizeSkipped }
|
|
170
210
|
}
|
|
171
211
|
|
|
172
212
|
/** Record current HEAD as the index anchor (gitSync incremental diff baseline); silently skip non-git repos */
|
|
173
213
|
export async function markIndexedCommit(memory, dir) {
|
|
174
214
|
try {
|
|
175
|
-
const {
|
|
176
|
-
const head =
|
|
215
|
+
const { execFile } = await import("node:child_process")
|
|
216
|
+
const head = await new Promise((resolve, reject) => {
|
|
217
|
+
execFile("git", ["rev-parse", "HEAD"], { cwd: dir, encoding: "utf8", timeout: 5000, windowsHide: true }, (err, stdout) => {
|
|
218
|
+
if (err) reject(err); else resolve(stdout)
|
|
219
|
+
})
|
|
220
|
+
})
|
|
177
221
|
memory.db.prepare(`INSERT INTO meta (key, value) VALUES ('last_indexed_commit', ?)
|
|
178
|
-
ON CONFLICT (key) DO UPDATE SET value = excluded.value`).run(head)
|
|
222
|
+
ON CONFLICT (key) DO UPDATE SET value = excluded.value`).run(head.trim())
|
|
179
223
|
} catch { /* not a git repo or git unavailable, skip */ }
|
|
180
224
|
}
|
|
181
225
|
|
|
@@ -236,8 +280,15 @@ export async function codeSearch(memory, query, { limit = 5 } = {}) {
|
|
|
236
280
|
.filter(Boolean)
|
|
237
281
|
}
|
|
238
282
|
|
|
239
|
-
/** Lazily backfill missing vectors for code_chunks */
|
|
240
|
-
|
|
283
|
+
/** Lazily backfill missing vectors for code_chunks. Guarded against concurrent calls. */
|
|
284
|
+
let _codeEmbedLock = null
|
|
285
|
+
export function ensureCodeEmbeddings(memory) {
|
|
286
|
+
if (_codeEmbedLock) return _codeEmbedLock
|
|
287
|
+
_codeEmbedLock = _runEnsureCodeEmbeddings(memory).finally(() => { _codeEmbedLock = null })
|
|
288
|
+
return _codeEmbedLock
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
async function _runEnsureCodeEmbeddings(memory) {
|
|
241
292
|
if (!memory.embedder) return
|
|
242
293
|
const modelKey = memory.embedder.model
|
|
243
294
|
const stored = memory.db.prepare(`SELECT value FROM meta WHERE key = 'code_embedding_model'`).get()?.value
|
|
@@ -292,6 +343,12 @@ export async function reindexFile(memory, cwd, absPath) {
|
|
|
292
343
|
const dirs = rel.split("/").slice(0, -1)
|
|
293
344
|
if (dirs.some((d) => SKIP_DIRS.has(d) || d.startsWith("."))) return
|
|
294
345
|
|
|
346
|
+
// skip oversized files (minified bundles, test fixtures, generated code)
|
|
347
|
+
const maxBytes = CODE_EXTS.has(ext) ? MAX_CODE_FILE_BYTES : DOC_EXTS.has(ext) ? MAX_DOC_FILE_BYTES : 0
|
|
348
|
+
if (maxBytes > 0) {
|
|
349
|
+
try { const st = await stat(absPath); if (st.size > maxBytes) return } catch { /* can't stat, proceed */ }
|
|
350
|
+
}
|
|
351
|
+
|
|
295
352
|
let text
|
|
296
353
|
try { text = await readFile(absPath, "utf8") } catch {
|
|
297
354
|
if (CODE_EXTS.has(ext)) memory.db.prepare(`DELETE FROM code_chunks WHERE origin = ? AND path = ?`).run(cwd, rel)
|
package/src/memory/core.mjs
CHANGED
|
@@ -129,8 +129,17 @@ export function fetchEntry(memory, uid) {
|
|
|
129
129
|
/**
|
|
130
130
|
* Lazy embedding: batch-compute vectors for entries that don't have them yet (slow first time, zero cost thereafter).
|
|
131
131
|
* When the embedding model changes, clear all vectors and rebuild.
|
|
132
|
+
* Guarded by a module-level lock — concurrent fire-and-forget callers share the same promise,
|
|
133
|
+
* so embedding API calls are never duplicated.
|
|
132
134
|
*/
|
|
133
|
-
|
|
135
|
+
let _embedLock = null
|
|
136
|
+
export function ensureEmbeddings(memory) {
|
|
137
|
+
if (_embedLock) return _embedLock
|
|
138
|
+
_embedLock = _runEnsureEmbeddings(memory).finally(() => { _embedLock = null })
|
|
139
|
+
return _embedLock
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function _runEnsureEmbeddings(memory) {
|
|
134
143
|
const modelKey = memory.embedder.model
|
|
135
144
|
const stored = memory.db.prepare(`SELECT value FROM meta WHERE key = 'embedding_model'`).get()?.value
|
|
136
145
|
if (stored !== modelKey) {
|
package/src/memory/docs.mjs
CHANGED
|
@@ -2,55 +2,45 @@
|
|
|
2
2
|
* memory/docs.mjs — doc index sync, retrieval, agent tool generation
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import { readFile,
|
|
5
|
+
import { readFile, stat } from "node:fs/promises"
|
|
6
6
|
import { join } from "node:path"
|
|
7
7
|
import { embed, cosine, toBlob, fromBlob } from "../embedding.mjs"
|
|
8
8
|
import { commitAndPush } from "../git/gitmem.mjs"
|
|
9
|
-
import { DOC_EXTS, SKIP_DIRS } from "./schema.mjs"
|
|
10
|
-
import { buildFtsQuery, put, search, putMarkdown } from "./core.mjs"
|
|
9
|
+
import { DOC_EXTS, SKIP_DIRS, MAX_DOC_FILE_BYTES } from "./schema.mjs"
|
|
10
|
+
import { buildFtsQuery, put, search, putMarkdown, EMBED_TEXT_MAX_LEN } from "./core.mjs"
|
|
11
11
|
import { _upsertDocFile, yieldTick } from "./code-index.mjs"
|
|
12
|
-
import { markIndexedCommit } from "./code-sync.mjs"
|
|
12
|
+
import { markIndexedCommit, listProjectFiles } from "./code-sync.mjs"
|
|
13
13
|
|
|
14
14
|
const DOC_EMBED_BATCH = 64
|
|
15
|
-
const EMBED_TEXT_MAX_LEN = 2000
|
|
16
15
|
|
|
17
16
|
/**
|
|
18
17
|
* Sync doc index: scan all .md/.mdc/.txt/.rst/.adoc under dir → chunk → upsert into doc_chunks.
|
|
19
18
|
* Incremental by mtime.
|
|
20
19
|
*/
|
|
21
20
|
export async function docSync(memory, dir, { onProgress } = {}) {
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
} else if (e.isFile()) {
|
|
31
|
-
const ext = e.name.slice(e.name.lastIndexOf(".")).toLowerCase()
|
|
32
|
-
if (DOC_EXTS.has(ext)) files.push(join(d, e.name))
|
|
33
|
-
}
|
|
34
|
-
}
|
|
21
|
+
const entries = await listProjectFiles(dir, DOC_EXTS)
|
|
22
|
+
const files = [] // { abs, rel, mtimeMs }
|
|
23
|
+
let overSizeSkipped = 0
|
|
24
|
+
for (const { abs, rel } of entries) {
|
|
25
|
+
let st
|
|
26
|
+
try { st = await stat(abs) } catch { continue }
|
|
27
|
+
if (st.size > MAX_DOC_FILE_BYTES) { overSizeSkipped++; continue }
|
|
28
|
+
files.push({ abs, rel, mtimeMs: Math.floor(st.mtimeMs) })
|
|
35
29
|
}
|
|
36
|
-
await walk(dir)
|
|
37
30
|
|
|
38
31
|
const indexed = new Map(
|
|
39
32
|
memory.db.prepare(`SELECT path, mtime_ms FROM doc_chunks WHERE origin = ?`).all(dir).map((r) => [r.path, r.mtime_ms])
|
|
40
33
|
)
|
|
41
34
|
const seen = new Set()
|
|
42
35
|
|
|
43
|
-
onProgress?.({ phase: "scan", total: files.length })
|
|
36
|
+
onProgress?.({ phase: "scan", total: files.length, overSizeSkipped })
|
|
44
37
|
|
|
45
38
|
let updated = 0, removed = 0, skipped = 0, failed = 0
|
|
46
39
|
const errors = []
|
|
47
40
|
for (let i = 0; i < files.length; i++) {
|
|
48
|
-
const abs = files[i]
|
|
49
|
-
const rel = abs.slice(dir.length + 1).replaceAll("\\", "/")
|
|
41
|
+
const { abs, rel, mtimeMs } = files[i]
|
|
50
42
|
seen.add(rel)
|
|
51
43
|
|
|
52
|
-
let mtimeMs
|
|
53
|
-
try { mtimeMs = Math.floor((await stat(abs)).mtimeMs) } catch { continue }
|
|
54
44
|
if (indexed.get(rel) === mtimeMs) {
|
|
55
45
|
skipped++
|
|
56
46
|
continue
|
|
@@ -79,9 +69,9 @@ export async function docSync(memory, dir, { onProgress } = {}) {
|
|
|
79
69
|
}
|
|
80
70
|
}
|
|
81
71
|
|
|
82
|
-
onProgress?.({ phase: "done", total: files.length, updated, removed, skipped, failed })
|
|
72
|
+
onProgress?.({ phase: "done", total: files.length, updated, removed, skipped, failed, overSizeSkipped })
|
|
83
73
|
markIndexedCommit(memory, dir)
|
|
84
|
-
return { updated, removed, skipped, failed, errors, total: files.length }
|
|
74
|
+
return { updated, removed, skipped, failed, errors, total: files.length, overSizeSkipped }
|
|
85
75
|
}
|
|
86
76
|
|
|
87
77
|
/**
|
|
@@ -141,8 +131,15 @@ export async function docSearch(memory, query, { limit = 5 } = {}) {
|
|
|
141
131
|
.filter(Boolean)
|
|
142
132
|
}
|
|
143
133
|
|
|
144
|
-
/** Lazily backfill missing vectors for doc_chunks */
|
|
145
|
-
|
|
134
|
+
/** Lazily backfill missing vectors for doc_chunks. Guarded against concurrent calls. */
|
|
135
|
+
let _docEmbedLock = null
|
|
136
|
+
export function ensureDocEmbeddings(memory) {
|
|
137
|
+
if (_docEmbedLock) return _docEmbedLock
|
|
138
|
+
_docEmbedLock = _runEnsureDocEmbeddings(memory).finally(() => { _docEmbedLock = null })
|
|
139
|
+
return _docEmbedLock
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function _runEnsureDocEmbeddings(memory) {
|
|
146
143
|
if (!memory.embedder) return
|
|
147
144
|
const modelKey = memory.embedder.model
|
|
148
145
|
const stored = memory.db.prepare(`SELECT value FROM meta WHERE key = 'doc_embedding_model'`).get()?.value
|
package/src/memory/schema.mjs
CHANGED
|
@@ -18,9 +18,22 @@ export const SQLITE_BUSY_TIMEOUT = 3000
|
|
|
18
18
|
export const CODE_EXTS = new Set([".mjs", ".js", ".ts", ".tsx", ".jsx", ".py", ".rs", ".go", ".java", ".c", ".h", ".cpp", ".hpp", ".rb", ".swift", ".kt", ".sh", ".bash", ".sql", ".yaml", ".yml", ".toml", ".json", ".css", ".html", ".vue", ".svelte"])
|
|
19
19
|
// Doc index: markdown / plain text (separate index makes it easier for LLM to distinguish "design specs" from "existing code")
|
|
20
20
|
export const DOC_EXTS = new Set([".md", ".mdc", ".txt", ".rst", ".adoc"])
|
|
21
|
-
// Directory names always skipped
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
// Directory names always skipped during code/doc indexing
|
|
22
|
+
// NOTE: these are case-sensitive basename matches; add common platform-specific dirs
|
|
23
|
+
export const SKIP_DIRS = new Set([
|
|
24
|
+
"node_modules", ".git", "dist", "build", ".turbo", "coverage",
|
|
25
|
+
"__pycache__", ".venv", "venv", "target", ".next", ".nuxt", ".svelte-kit",
|
|
26
|
+
// Windows user profile directories (never contain project code)
|
|
27
|
+
"AppData", "Application Data", "Desktop", "Documents", "Downloads",
|
|
28
|
+
"Music", "Pictures", "Videos", "OneDrive", "Contacts", "Favorites",
|
|
29
|
+
"Links", "Saved Games", "Searches",
|
|
30
|
+
// Other common non-code directories
|
|
31
|
+
"Program Files", "Program Files (x86)", "Windows", "$Recycle.Bin",
|
|
32
|
+
])
|
|
33
|
+
// Files larger than these limits are skipped during bulk indexing
|
|
34
|
+
// (minified bundles, test fixtures, generated code, etc.)
|
|
35
|
+
export const MAX_CODE_FILE_BYTES = 1024 * 1024 // 1 MB
|
|
36
|
+
export const MAX_DOC_FILE_BYTES = 512 * 1024 // 512 KB
|
|
24
37
|
export const BIG_FILE_LINES = 2000
|
|
25
38
|
|
|
26
39
|
/**
|