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.
Files changed (55) hide show
  1. package/README.md +27 -0
  2. package/bin/thincoder.mjs +115 -0
  3. package/package.json +1 -1
  4. package/src/advisor.mjs +105 -0
  5. package/src/agent/dispatch.mjs +35 -0
  6. package/src/agent/setup.mjs +9 -10
  7. package/src/agent-tools/subagent.mjs +1 -1
  8. package/src/agent-tools/timer.mjs +41 -0
  9. package/src/agent-tools/verify.mjs +165 -56
  10. package/src/agent-tools.mjs +1 -0
  11. package/src/agent.mjs +128 -21
  12. package/src/auto-think.mjs +83 -0
  13. package/src/cli/make-agent.mjs +9 -0
  14. package/src/config.mjs +18 -18
  15. package/src/context.mjs +3 -1
  16. package/src/distill.mjs +19 -4
  17. package/src/embedding.mjs +3 -1
  18. package/src/git/checkpoint.mjs +2 -1
  19. package/src/git/gitmem.mjs +8 -2
  20. package/src/markdown.mjs +1 -1
  21. package/src/mcp/transport-http.mjs +11 -4
  22. package/src/memory/code-index.mjs +2 -2
  23. package/src/memory/code-sync.mjs +92 -35
  24. package/src/memory/core.mjs +10 -1
  25. package/src/memory/docs.mjs +25 -28
  26. package/src/memory/schema.mjs +16 -3
  27. package/src/prompts/coder.md +7 -4
  28. package/src/prompts/discipline.md +47 -15
  29. package/src/prompts/main.md +15 -11
  30. package/src/prompts/system.md +33 -7
  31. package/src/provider/core.mjs +142 -15
  32. package/src/provider/index.mjs +1 -1
  33. package/src/rules.mjs +53 -0
  34. package/src/session.mjs +9 -3
  35. package/src/tools/file.mjs +114 -5
  36. package/src/tools/hashline_edit.md +12 -0
  37. package/src/tools/index.mjs +6 -4
  38. package/src/tools/linter.md +13 -0
  39. package/src/tools/linter.mjs +146 -0
  40. package/src/tools/patch.mjs +7 -3
  41. package/src/tools/read.md +3 -2
  42. package/src/tools/repomap.mjs +19 -10
  43. package/src/tools/shared.mjs +7 -0
  44. package/src/tools/system.mjs +18 -4
  45. package/src/tui/agent-turn.mjs +17 -2
  46. package/src/tui/ansi.mjs +5 -0
  47. package/src/tui/cmd-advisor.mjs +68 -0
  48. package/src/tui/cmd-think.mjs +36 -10
  49. package/src/tui/index.mjs +167 -54
  50. package/src/tui/key-handler.mjs +36 -1
  51. package/src/tui/layout.mjs +6 -4
  52. package/src/tui/pickers.mjs +15 -15
  53. package/src/tui/render-frame.mjs +240 -167
  54. package/src/tui/slash-commands.mjs +3 -0
  55. 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.minimax.chat/v1", chatPath: "/text/chatcompletion_v2", model: "MiniMax-M3", maxTokens: 131072, desc: "MiniMax" },
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: 256_000, maxOutput: 384_000, thinking: false, prefixMode: true, cacheMode: "prompt", thinkApi: "type", reasoningEcho: "required", reasoningEffortEnum: ["high", "max"], tempRange: [0, 2] }],
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: "prompt", thinkApi: "effort", reasoningEcho: "required", reasoningEffortEnum: ["low", "high", "max"] }],
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.8 (DeepSeek internally uses full window; compaction itself costs an LLM call, premature compaction is pure waste.
97
- // Reserve 20% headroom for post-compaction tail growth and output tokens)
98
- // But for 1M-window models, 0.8 = 800K tokens waiting until history grows that large would blow the TPM budget,
99
- // and the compaction request itself might 429. Add caps: no more than maxOutput (128K×8≈1M still large but reasonable),
100
- // no more than 300K (reasonable working ceiling for large-window models; beyond that cache hit rates drop)
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 highcap 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
- // Large-window models (1M) produce too-large ratio-based values; cap them better to compact early than let history grow until it blows the TPM budget
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
- // Non-greedy match first JSON array (greedy [\s\S]* would eat across multiple arrays including interstitial text)
51
- const match = res.content.match(/\[[\s\S]*?\]/)
52
- if (!match) return []
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(match[0])
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
@@ -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
- await copyFile(src, dst).catch(() => {}) // Copy failed (socket/device file etc.) — skip
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({
@@ -51,11 +51,17 @@ export async function pullTeam(dir) {
51
51
  return true
52
52
  } catch (error) {
53
53
  if (await hasConflict(dir)) {
54
- await git(dir, ["rebase", "--abort"]).catch(() => {})
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
- `(The local repo has been restored to its pre-sync state — nothing was lost.)`,
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: abortController.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) => setTimeout(r, 0))
145
+ return new Promise((r) => setImmediate(r))
146
146
  }
147
147
 
148
148
  /** Index a single file: delete old chunks → chunk → insert new chunks */
@@ -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 { execSync, execFileSync } = await import("node:child_process")
23
- const opts = { cwd: dir, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 10000 }
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 = execSync("git rev-parse HEAD", opts).trim() } catch { return null }
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 = execFileSync("git", ["diff", "--name-only", "--diff-filter=ACMRTD", stored, "HEAD"], { cwd: dir, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 10000 }).trim()
34
- const dirty = execSync(`git diff --name-only --diff-filter=ACMRTD`, opts).trim()
35
- const lines = [...new Set([...committed.split("\n").filter(Boolean), ...dirty.split("\n").filter(Boolean)])]
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 files = []
106
- const { readdir } = await import("node:fs/promises")
107
- async function walk(d) {
108
- let entries
109
- try { entries = await readdir(d, { withFileTypes: true }) } catch { return }
110
- for (const e of entries) {
111
- if (e.isDirectory()) {
112
- if (SKIP_DIRS.has(e.name) || e.name.startsWith(".")) continue
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 { execSync } = await import("node:child_process")
176
- const head = execSync("git rev-parse HEAD", { cwd: dir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5000 }).trim()
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
- export async function ensureCodeEmbeddings(memory) {
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)
@@ -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
- export async function ensureEmbeddings(memory) {
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) {
@@ -2,55 +2,45 @@
2
2
  * memory/docs.mjs — doc index sync, retrieval, agent tool generation
3
3
  */
4
4
 
5
- import { readFile, readdir, stat } from "node:fs/promises"
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 files = []
23
- async function walk(d) {
24
- let entries
25
- try { entries = await readdir(d, { withFileTypes: true }) } catch { return }
26
- for (const e of entries) {
27
- if (e.isDirectory()) {
28
- if (SKIP_DIRS.has(e.name) || e.name.startsWith(".")) continue
29
- await walk(join(d, e.name))
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
- export async function ensureDocEmbeddings(memory) {
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
@@ -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
- export const SKIP_DIRS = new Set(["node_modules", ".git", "dist", "build", ".turbo", "coverage", "__pycache__", ".venv", "venv", "target", ".next", ".nuxt", ".svelte-kit"])
23
- // Large file threshold (lines): above this, chunk by symbol; otherwise index entire file
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
  /**