thincoder 0.8.10 → 0.8.12

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 (52) hide show
  1. package/README.md +16 -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/helpers.mjs +1 -1
  7. package/src/agent/setup.mjs +21 -7
  8. package/src/agent-tools/subagent.mjs +1 -1
  9. package/src/agent-tools/timer.mjs +41 -0
  10. package/src/agent-tools/verify.mjs +165 -56
  11. package/src/agent-tools.mjs +1 -0
  12. package/src/agent.mjs +128 -20
  13. package/src/auto-think.mjs +83 -0
  14. package/src/cli/make-agent.mjs +9 -0
  15. package/src/config.mjs +18 -18
  16. package/src/git/checkpoint.mjs +2 -1
  17. package/src/git/gitmem.mjs +8 -2
  18. package/src/markdown.mjs +1 -1
  19. package/src/mcp/transport-http.mjs +8 -2
  20. package/src/memory/code-index.mjs +2 -2
  21. package/src/memory/code-sync.mjs +92 -35
  22. package/src/memory/core.mjs +10 -1
  23. package/src/memory/docs.mjs +24 -26
  24. package/src/memory/schema.mjs +16 -3
  25. package/src/prompts/coder.md +7 -4
  26. package/src/prompts/discipline.md +55 -16
  27. package/src/prompts/main.md +15 -11
  28. package/src/prompts/system.md +33 -7
  29. package/src/provider/core.mjs +186 -20
  30. package/src/provider/index.mjs +1 -1
  31. package/src/rules.mjs +53 -0
  32. package/src/session.mjs +1 -1
  33. package/src/tools/checklist.md +7 -0
  34. package/src/tools/checklist.mjs +114 -0
  35. package/src/tools/file.mjs +82 -1
  36. package/src/tools/hashline_edit.md +12 -0
  37. package/src/tools/index.mjs +7 -3
  38. package/src/tools/linter.md +13 -0
  39. package/src/tools/linter.mjs +146 -0
  40. package/src/tools/read.md +3 -2
  41. package/src/tools/repomap.mjs +14 -9
  42. package/src/tui/agent-turn.mjs +9 -2
  43. package/src/tui/ansi.mjs +1 -0
  44. package/src/tui/cmd-advisor.mjs +68 -0
  45. package/src/tui/cmd-think.mjs +36 -10
  46. package/src/tui/index.mjs +2 -1
  47. package/src/tui/key-handler.mjs +36 -1
  48. package/src/tui/layout.mjs +3 -1
  49. package/src/tui/pickers.mjs +15 -15
  50. package/src/tui/render-frame.mjs +17 -8
  51. package/src/tui/slash-commands.mjs +3 -0
  52. 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
 
@@ -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*(.*)$/)
@@ -155,7 +155,10 @@ export function httpTransport(baseURL, extraHeaders = {}) {
155
155
  headers: headers(),
156
156
  body: JSON.stringify({ jsonrpc: "2.0", method, params }),
157
157
  signal: AbortSignal.timeout(10_000),
158
- }).catch(() => {})
158
+ }).catch((e) => {
159
+ // notify is fire-and-forget by design, but log network errors for debugging
160
+ if (e.name !== "AbortError") console.error(`[mcp] notify failed: ${e.message}`)
161
+ })
159
162
  }
160
163
 
161
164
  const close = () => {
@@ -166,7 +169,10 @@ export function httpTransport(baseURL, extraHeaders = {}) {
166
169
  method: "DELETE",
167
170
  headers: { "Mcp-Session-Id": sessionId, ...extraHeaders },
168
171
  signal: AbortSignal.timeout(5_000),
169
- }).catch(() => {})
172
+ }).catch((e) => {
173
+ // close is best-effort cleanup; log but don't throw
174
+ if (e.name !== "AbortError") console.error(`[mcp] close DELETE failed: ${e.message}`)
175
+ })
170
176
  sessionId = null
171
177
  }
172
178
  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,14 +2,14 @@
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"
9
+ import { DOC_EXTS, SKIP_DIRS, MAX_DOC_FILE_BYTES } from "./schema.mjs"
10
10
  import { buildFtsQuery, put, search, putMarkdown } 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
15
  const EMBED_TEXT_MAX_LEN = 2000
@@ -19,38 +19,29 @@ const EMBED_TEXT_MAX_LEN = 2000
19
19
  * Incremental by mtime.
20
20
  */
21
21
  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
- }
22
+ const entries = await listProjectFiles(dir, DOC_EXTS)
23
+ const files = [] // { abs, rel, mtimeMs }
24
+ let overSizeSkipped = 0
25
+ for (const { abs, rel } of entries) {
26
+ let st
27
+ try { st = await stat(abs) } catch { continue }
28
+ if (st.size > MAX_DOC_FILE_BYTES) { overSizeSkipped++; continue }
29
+ files.push({ abs, rel, mtimeMs: Math.floor(st.mtimeMs) })
35
30
  }
36
- await walk(dir)
37
31
 
38
32
  const indexed = new Map(
39
33
  memory.db.prepare(`SELECT path, mtime_ms FROM doc_chunks WHERE origin = ?`).all(dir).map((r) => [r.path, r.mtime_ms])
40
34
  )
41
35
  const seen = new Set()
42
36
 
43
- onProgress?.({ phase: "scan", total: files.length })
37
+ onProgress?.({ phase: "scan", total: files.length, overSizeSkipped })
44
38
 
45
39
  let updated = 0, removed = 0, skipped = 0, failed = 0
46
40
  const errors = []
47
41
  for (let i = 0; i < files.length; i++) {
48
- const abs = files[i]
49
- const rel = abs.slice(dir.length + 1).replaceAll("\\", "/")
42
+ const { abs, rel, mtimeMs } = files[i]
50
43
  seen.add(rel)
51
44
 
52
- let mtimeMs
53
- try { mtimeMs = Math.floor((await stat(abs)).mtimeMs) } catch { continue }
54
45
  if (indexed.get(rel) === mtimeMs) {
55
46
  skipped++
56
47
  continue
@@ -79,9 +70,9 @@ export async function docSync(memory, dir, { onProgress } = {}) {
79
70
  }
80
71
  }
81
72
 
82
- onProgress?.({ phase: "done", total: files.length, updated, removed, skipped, failed })
73
+ onProgress?.({ phase: "done", total: files.length, updated, removed, skipped, failed, overSizeSkipped })
83
74
  markIndexedCommit(memory, dir)
84
- return { updated, removed, skipped, failed, errors, total: files.length }
75
+ return { updated, removed, skipped, failed, errors, total: files.length, overSizeSkipped }
85
76
  }
86
77
 
87
78
  /**
@@ -141,8 +132,15 @@ export async function docSearch(memory, query, { limit = 5 } = {}) {
141
132
  .filter(Boolean)
142
133
  }
143
134
 
144
- /** Lazily backfill missing vectors for doc_chunks */
145
- export async function ensureDocEmbeddings(memory) {
135
+ /** Lazily backfill missing vectors for doc_chunks. Guarded against concurrent calls. */
136
+ let _docEmbedLock = null
137
+ export function ensureDocEmbeddings(memory) {
138
+ if (_docEmbedLock) return _docEmbedLock
139
+ _docEmbedLock = _runEnsureDocEmbeddings(memory).finally(() => { _docEmbedLock = null })
140
+ return _docEmbedLock
141
+ }
142
+
143
+ async function _runEnsureDocEmbeddings(memory) {
146
144
  if (!memory.embedder) return
147
145
  const modelKey = memory.embedder.model
148
146
  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
  /**
@@ -1,16 +1,17 @@
1
1
  You are a coding subagent. The parent agent dispatched you to handle a self-contained coding task. The parent CANNOT see your context — it only sees your final report.
2
2
 
3
3
  Guidelines:
4
- - Work independently: use doc_search to learn project conventions and design, repo_outline to understand structure, then code_search to find implementations. Don't write code until you know what the project intends.
4
+ - Work independently: use doc_search to learn project conventions and design, repo_outline to understand structure, then code_search to find implementations.
5
+ Don't write code until you know what the project intends.
5
6
  - Write code in small, verified steps — don't write multiple files at once without checking each along the way:
6
7
  1. After every write/edit of a file: run a syntax/lint check to catch parse errors immediately
7
8
  2. After a logical group of changes: run the relevant tests to confirm behavior
8
- 3. Before finishing entirely: run the full test suite and confirm it passes
9
+ 3. Before finishing: run tests relevant to your changes; run the full test suite only if you changed core infrastructure (agent loop, provider protocol, config schema, tool execution, memory schema)
9
10
  - Be thorough: include what you did, which files you changed, why, and any caveats
10
11
  - If the task is ambiguous, note the ambiguity in your report; do not ask the user
11
12
  - It is always OK to say "this is too hard for me." Bad work is worse than no work — you will not be penalized for escalating
12
13
  - BEFORE finishing, do a final review of your work:
13
- 1. Run the test suite — confirm all tests pass
14
+ 1. Run relevant tests — confirm all pass
14
15
  2. If no existing test covers your change, add at least one test
15
16
  3. Read every file you changed — catch leftover debug code, stale comments, or incomplete edits
16
17
  4. Check that comments and docstrings match what the code actually does
@@ -18,4 +19,6 @@ Guidelines:
18
19
  - Your last message IS the report the parent sees — make it complete and self-contained
19
20
  - List every file you changed (with paths), why you changed it, and whether tests passed
20
21
 
21
- IMPORTANT — Tool permissions: when you see "permission denied by user" for a tool, it means the parent has not granted that tool. This is expected: your job is to write a detailed report of what SHOULD be done, not to force tool execution. Describe the needed changes clearly in your report so the parent agent can apply them.
22
+ IMPORTANT — Tool permissions: when you see "permission denied by user" for a tool, it means the parent has not granted that tool.
23
+ This is expected: your job is to write a detailed report of what SHOULD be done, not to force tool execution.
24
+ Describe the needed changes clearly in your report so the parent agent can apply them.