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
@@ -1,25 +1,59 @@
1
1
  import { repairHistory, listWorkDir } from "../agent.mjs"
2
- import { execSync, spawn } from "node:child_process"
2
+ import { execSync, spawn, spawnSync } from "node:child_process"
3
3
  import { readFileSync, existsSync } from "node:fs"
4
4
  import { join } from "node:path"
5
5
 
6
+ /**
7
+ * Source module → test file mapping. Heuristic: the FIRST path component
8
+ * after src/ determines the module. Map it to the test file that imports from it.
9
+ * Modules without dedicated tests map to null.
10
+ */
11
+ const MODULE_TO_TEST = {
12
+ tools: "test/tools.test.mjs",
13
+ "agent-tools": "test/tools.test.mjs",
14
+ agent: "test/agent.test.mjs",
15
+ memory: "test/memory.test.mjs",
16
+ tui: "test/tui.test.mjs",
17
+ provider: "test/integration-provider.mjs",
18
+ config: "test/integration-provider.mjs",
19
+ skills: "test/tools.test.mjs",
20
+ distill: "test/tools.test.mjs",
21
+ markdown: "test/agent.test.mjs",
22
+ mcp: null,
23
+ prompts: null,
24
+ context: null,
25
+ session: null,
26
+ }
27
+
28
+ /**
29
+ * Extract module name from a source path.
30
+ * "src/tools/bash.mjs" → "tools", "src/agent.mjs" → "agent", "src/agent/helpers.mjs" → "agent"
31
+ */
32
+ function moduleName(srcPath) {
33
+ const rel = srcPath.replace(/^src[/\\]/, "")
34
+ const firstSlash = rel.search(/[/\\]/)
35
+ if (firstSlash === -1) return rel.replace(/\.mjs$/, "")
36
+ return rel.slice(0, firstSlash)
37
+ }
38
+
6
39
  /**
7
40
  * verify tool: pre-completion self-check. When called:
8
41
  * 1. git diff --stat — changed file list
9
42
  * 2. node --check — syntax check all changed .mjs/.js files
10
- * 3. npm test — run project tests only when full=true
11
- * 4. task list + self-review checklist
12
- * Default does syntax checks only (fast); full=true runs the full test suite.
43
+ * 3. Related tests — run test files that cover the changed modules (default)
44
+ * 4. npm test run ALL project tests (only when full=true)
45
+ * 5. task list + self-review checklist
46
+ * Default runs syntax checks + related tests; full=true runs the entire test suite.
13
47
  * Agent must not say "done" before verify passes. Fix-verify loop at most MAX_VERIFY_RETRIES rounds.
14
48
  */
15
49
  export const verifyTool = {
16
50
  name: "verify",
17
51
  description:
18
- "Run a pre-completion self-check. By default runs syntax checks on changed files, shows git diff and task list, and displays a self-review checklist. Set full=true to also run the project's full test suite (npm test). Call this BEFORE declaring any coding task complete — do not say 'done' until verify passes.",
52
+ "Run a pre-completion self-check. By default runs syntax checks on changed files AND any test files related to the changed modules, shows git diff and task list, and displays a self-review checklist. Set full=true to run the project's full test suite (npm test) instead of just related tests. Call this BEFORE declaring any coding task complete — do not say 'done' until verify passes.",
19
53
  parameters: {
20
54
  type: "object",
21
55
  properties: {
22
- full: { type: "boolean", description: "Also run the full test suite (npm test). Default false — use sparingly, per the testing discipline rules." },
56
+ full: { type: "boolean", description: "Run the full test suite (npm test) instead of just related tests. Default false — use sparingly, per the testing discipline rules." },
23
57
  },
24
58
  },
25
59
  readonly: true,
@@ -57,11 +91,13 @@ export const verifyTool = {
57
91
  const abs = join(cwd, f)
58
92
  if (!existsSync(abs)) continue // skip deleted files
59
93
  try {
60
- execSync(`node --check "${f}"`, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 10000 })
94
+ const result = spawnSync("node", ["--check", abs], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 10000 })
95
+ if (result.status !== 0) throw result
61
96
  lines.push(` ✓ ${f}`)
62
97
  } catch (e) {
63
98
  syntaxFailed = true
64
- const errMsg = (e.stderr || e.stdout || e.message || "").toString().split("\n").slice(0, 3).join("\n")
99
+ const errOutput = (e.stderr || e.stdout || e.message || "").toString()
100
+ const errMsg = errOutput.split("\n").slice(0, 3).join("\n")
65
101
  lines.push(` ✗ ${f} — syntax error`)
66
102
  lines.push(` ${errMsg.replace(/\n/g, "\n ")}`)
67
103
  }
@@ -69,53 +105,91 @@ export const verifyTool = {
69
105
  if (!syntaxFailed) lines.push(" All syntax checks passed.")
70
106
  }
71
107
 
72
- // 3. Run project tests (only when full=true)
108
+ // 3. Identify related test files for changed source modules
109
+ const srcFiles = changedFiles.filter((f) => /^src[/\\].+\.mjs$/i.test(f) && existsSync(join(cwd, f)))
110
+ const modules = [...new Set(srcFiles.map(moduleName))]
111
+ const relatedTests = [...new Set(modules.map((m) => MODULE_TO_TEST[m]).filter(Boolean))]
112
+
113
+ // 4. Run tests
114
+ const pkgPath = join(cwd, "package.json")
115
+ const hasTestScript = existsSync(pkgPath) && (() => { try { return !!JSON.parse(readFileSync(pkgPath, "utf8")).scripts?.test } catch { return false } })()
116
+
73
117
  if (args.full) {
74
- try {
75
- const pkgPath = join(cwd, "package.json")
76
- if (existsSync(pkgPath)) {
77
- const pkg = JSON.parse(readFileSync(pkgPath, "utf8"))
78
- const testCmd = pkg.scripts?.test
79
- if (testCmd) {
80
- lines.push("")
81
- lines.push(`Tests (${testCmd}):`)
82
- try {
83
- const result = await runTestSuite(cwd, ctx)
84
- const tail = result.stdout.split("\n").slice(-8).join("\n")
85
- lines.push(tail || "(tests completed)")
86
- lines.push("")
87
- lines.push("✓ Tests passed.")
88
- ctx.agent._verifyPassed = !syntaxFailed // even if tests happen to pass, syntax failure still counts as fail
89
- } catch (e) {
90
- const output = e.stdout ? (e.stdout + (e.stderr ? "\n" + e.stderr : "")) : e.message
91
- const tail = output.split("\n").slice(-15).join("\n")
92
- lines.push(tail || "(no output)")
93
- lines.push("")
94
- lines.push("✗ Tests FAILED. Review the output above, fix the issues, then run verify again.")
95
- ctx.agent._verifyPassed = false
96
- }
118
+ // Full mode: run the entire test suite
119
+ if (hasTestScript) {
120
+ lines.push("")
121
+ lines.push("Tests (full suite):")
122
+ const result = await runTestSuite(cwd, ctx)
123
+ if (result.passed) {
124
+ lines.push("✓ All tests passed.")
125
+ ctx.agent._verifyPassed = !syntaxFailed
126
+ } else {
127
+ lines.push("✗ Tests FAILED. Review the output above, fix the issues, then run verify again.")
128
+ ctx.agent._verifyPassed = false
129
+ }
130
+ } else {
131
+ lines.push("")
132
+ lines.push("Tests: no test script in package.json skipped.")
133
+ ctx.agent._verifyPassed = !syntaxFailed
134
+ }
135
+ } else if (relatedTests.length > 0) {
136
+ // Default mode: run only related test files
137
+ lines.push("")
138
+ lines.push(`Related tests (${relatedTests.length} file(s) for modules: ${modules.join(", ")}):`)
139
+ let anyTestFailed = false
140
+ for (const testFile of relatedTests) {
141
+ const abs = join(cwd, testFile)
142
+ if (!existsSync(abs)) {
143
+ lines.push(` ? ${testFile} — file not found, skipping`)
144
+ continue
145
+ }
146
+ try {
147
+ const result = await runTestFile(cwd, testFile, ctx)
148
+ if (result.passed) {
149
+ lines.push(` ✓ ${testFile}`)
97
150
  } else {
98
- lines.push("")
99
- lines.push("Tests: no test script in package.json skipped.")
100
- ctx.agent._verifyPassed = !syntaxFailed
151
+ anyTestFailed = true
152
+ lines.push(` ✗ ${testFile}FAILED`)
153
+ lines.push(` ${result.tail.replace(/\n/g, "\n ")}`)
101
154
  }
155
+ } catch (e) {
156
+ anyTestFailed = true
157
+ lines.push(` ✗ ${testFile} — error: ${e.message}`)
102
158
  }
103
- } catch {
104
- lines.push("Tests: (unable to run — no package.json or npm unavailable)")
159
+ }
160
+ if (anyTestFailed) {
161
+ lines.push("")
162
+ lines.push("✗ Related tests FAILED. Review the output above, fix the issues, then run verify again.")
163
+ ctx.agent._verifyPassed = false
164
+ } else {
165
+ lines.push(" All related tests passed.")
166
+ ctx.agent._verifyPassed = !syntaxFailed
105
167
  }
106
168
  } else {
107
- // Quick mode: skip tests but hint that full verification is available
108
- const pkgPath = join(cwd, "package.json")
109
- if (existsSync(pkgPath)) {
110
- try {
111
- const pkg = JSON.parse(readFileSync(pkgPath, "utf8"))
112
- if (pkg.scripts?.test) {
113
- lines.push("")
114
- lines.push("Tests: skipped (default quick mode). Run verify with full=true or npm test to run the full suite.")
115
- }
116
- } catch { /* ignore */ }
169
+ // No related tests found for the changed modules
170
+ const uncovered = modules.filter((m) => MODULE_TO_TEST[m] === null)
171
+ const untested = modules.filter((m) => !(m in MODULE_TO_TEST))
172
+ lines.push("")
173
+ if (uncovered.length > 0) {
174
+ lines.push(`Related tests: NONE for module(s) ${uncovered.join(", ")} — these modules have no dedicated test file.`)
175
+ lines.push("ACTION REQUIRED: write a test that covers the change you just made.")
176
+ lines.push("Do NOT proceed to 'done' a test file is required before this change is complete.")
177
+ } else if (untested.length > 0) {
178
+ lines.push(`Related tests: NONE for module(s) ${untested.join(", ")} unknown module, no test mapping exists.`)
179
+ lines.push("ACTION REQUIRED: determine which test file covers this code and add it to MODULE_TO_TEST, or write a new test.")
180
+ } else if (srcFiles.length === 0) {
181
+ lines.push("Related tests: no source .mjs files changed — nothing to test.")
182
+ ctx.agent._verifyPassed = !syntaxFailed
183
+ } else {
184
+ lines.push("Related tests: none matched. Run verify with full=true to run the full suite.")
185
+ ctx.agent._verifyPassed = !syntaxFailed
117
186
  }
118
- ctx.agent._verifyPassed = !syntaxFailed // quick mode: syntax failure must not count as pass
187
+ }
188
+
189
+ // Show full-suite hint when not run
190
+ if (!args.full && hasTestScript) {
191
+ lines.push("")
192
+ lines.push("Note: full test suite not run. Use verify full=true to run ALL tests before committing.")
119
193
  }
120
194
 
121
195
  // 4. Task list
@@ -151,10 +225,49 @@ export const verifyTool = {
151
225
  },
152
226
  }
153
227
 
228
+ /**
229
+ * Run a single test file with node --test, no maxBuffer limit.
230
+ * Returns { passed: boolean, tail: string } — the last few lines of output.
231
+ */
232
+ function runTestFile(cwd, testPath, ctx) {
233
+ return new Promise((resolve, reject) => {
234
+ const child = spawn("node", ["--test", testPath], {
235
+ cwd, stdio: ["ignore", "pipe", "pipe"],
236
+ env: { ...process.env, FORCE_COLOR: "0" },
237
+ })
238
+ let stdout = ""
239
+ let stderr = ""
240
+ child.stdout.on("data", (d) => {
241
+ const s = d.toString()
242
+ stdout += s
243
+ ctx.callbacks?.onToolOutput?.("verify", s)
244
+ })
245
+ child.stderr.on("data", (d) => {
246
+ const s = d.toString()
247
+ stderr += s
248
+ ctx.callbacks?.onToolOutput?.("verify", s)
249
+ })
250
+ const timer = setTimeout(() => {
251
+ child.kill("SIGKILL")
252
+ reject(new Error(`Test ${testPath} timed out after 120s`))
253
+ }, 120000)
254
+ child.on("error", (e) => {
255
+ clearTimeout(timer)
256
+ reject(e)
257
+ })
258
+ child.on("close", (code) => {
259
+ clearTimeout(timer)
260
+ const output = (stdout + stderr).trim()
261
+ const tail = output.split("\n").slice(-8).join("\n")
262
+ resolve({ passed: code === 0, tail })
263
+ })
264
+ })
265
+ }
266
+
154
267
  /**
155
268
  * Run npm test via spawn, no maxBuffer limit.
156
269
  * Test output is streamed through ctx.callbacks.onToolOutput (TUI can display progress in real time).
157
- * On success returns { stdout, stderr }; on non-zero exit throws (with stdout/stderr for caller to extract tail).
270
+ * Returns { passed: boolean, tail: string }.
158
271
  */
159
272
  function runTestSuite(cwd, ctx) {
160
273
  return new Promise((resolve, reject) => {
@@ -187,13 +300,9 @@ function runTestSuite(cwd, ctx) {
187
300
  })
188
301
  child.on("close", (code) => {
189
302
  clearTimeout(timer)
190
- if (code === 0) resolve({ stdout, stderr })
191
- else {
192
- const err = new Error(`Tests exited with code ${code}`)
193
- err.stdout = stdout
194
- err.stderr = stderr
195
- reject(err)
196
- }
303
+ const output = (stdout + stderr).trim()
304
+ const tail = output.split("\n").slice(-8).join("\n")
305
+ resolve({ passed: code === 0, tail })
197
306
  })
198
307
  })
199
308
  }
@@ -10,3 +10,4 @@ export { skillTool } from "./agent-tools/skill.mjs"
10
10
  export { goalTool } from "./agent-tools/goal.mjs"
11
11
  export { verifyTool } from "./agent-tools/verify.mjs"
12
12
  export { recentChangesTool } from "./agent-tools/recent-changes.mjs"
13
+ export { timerTool } from "./agent-tools/timer.mjs"
package/src/agent.mjs CHANGED
@@ -64,6 +64,7 @@ export function createAgent({
64
64
  _mutatedThisRun: false, _verifiedThisRun: false, _verifyPassed: undefined,
65
65
  _touchedFiles: [], _verifyRetries: 0,
66
66
  _pendingReminders: [],
67
+ _pendingTimers: [],
67
68
  _sessionStart: sessionStart,
68
69
  _lastPromptTokens: null, _usageAtLen: null,
69
70
  _compressFailures: 0,
@@ -91,7 +92,7 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
91
92
  const lastRole = agent.history.at(-1)?.role
92
93
  if (lastRole === "user" || lastRole === "tool") {
93
94
  try {
94
- if (await compressIfNeeded(agent, threshold)) {
95
+ if (await compressIfNeeded(agent, threshold, callbacks)) {
95
96
  agent._compressFailures = 0
96
97
  recentCallSigs.length = 0 // After compression history is rebuilt, reset stall detection counter
97
98
  callbacks.onCompress?.()
@@ -111,13 +112,74 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
111
112
  }
112
113
 
113
114
  const messages = [{ role: "system", content: systemPrompt }, ...agent.history]
114
- const response = await chat(agent.provider, {
115
- messages, tools: toolSchemas,
116
- onToken: callbacks.onToken,
117
- onReasoning: callbacks.onReasoning,
118
- onWait: callbacks.onWait,
119
- signal,
120
- })
115
+ let response
116
+
117
+ // Auto-think: classify task difficulty and set reasoning effort before the real prompt.
118
+ // Runs only on turn 0 of user input; failure is silent — falls back to current setting.
119
+ if (agent.config?.agent?.autoThink && turn === 0) {
120
+ const { classifyAndApply } = await import("./auto-think.mjs")
121
+ await classifyAndApply(agent, turn).catch(() => {})
122
+ }
123
+
124
+ try {
125
+ response = await chat(agent.provider, {
126
+ messages, tools: toolSchemas,
127
+ onToken: callbacks.onToken,
128
+ onReasoning: callbacks.onReasoning,
129
+ onWait: callbacks.onWait,
130
+ signal,
131
+ streamRules: agent.config.agent?.streamRules ?? [],
132
+ })
133
+ } catch (e) {
134
+ // User interrupt (Ctrl+I): controller.abort({ interrupt: true, message: "…" }).
135
+ // Inject the message into history and let the outer loop recreate the controller.
136
+ if (e.name === "AbortError" && signal?.reason?.interrupt) {
137
+ agent.history.push({
138
+ role: "user",
139
+ content: `[User interrupt: ${signal.reason.message}]`,
140
+ })
141
+ }
142
+ throw e
143
+ }
144
+
145
+ // Stream rule triggered mid-generation (action: "abort"): halt current output,
146
+ // inject rule's message as a reminder, and retry from the same context.
147
+ if (response.ruleTriggered) {
148
+ if (response.content) {
149
+ agent.history.push({ role: "assistant", content: response.content })
150
+ }
151
+ const label = response.ruleName ? ` — stream rule "${response.ruleName}"` : ""
152
+ agent.history.push({
153
+ role: "user",
154
+ content: `[System reminder${label}: ${response.ruleMessage}]`,
155
+ })
156
+ continue
157
+ }
158
+
159
+ // Stream rule warnings (action: "warn"): the stream completed, but one or more
160
+ // non-interrupting rules matched. Inject warnings after the turn so the model
161
+ // sees them before its next response — without aborting mid-generation.
162
+ if (response._warnings?.length) {
163
+ const deDuplicated = [...new Map(response._warnings.map(w => [w.name || w.pattern, w])).values()]
164
+ agent.history.push({
165
+ role: "user",
166
+ content: `[System reminder — stream rule warnings from your last response:\n${deDuplicated.map(w => `- ${w.name || w.pattern}: ${w.message}`).join("\n")}]`,
167
+ })
168
+ }
169
+
170
+ // User interrupted mid-generation (Ctrl+I): the SSE stream was aborted while content
171
+ // was partially generated. Commit partial output + inject user message, then signal
172
+ // the outer loop to recreate the controller and resume.
173
+ if (response.interrupted) {
174
+ if (response.content) {
175
+ agent.history.push({ role: "assistant", content: response.content })
176
+ }
177
+ agent.history.push({
178
+ role: "user",
179
+ content: `[User interrupt: ${response.interruptMessage}]`,
180
+ })
181
+ throw Object.assign(new Error("User interrupted"), { name: "AbortError" })
182
+ }
121
183
 
122
184
  if (response.usage) {
123
185
  callbacks.onUsage?.(response.usage)
@@ -127,6 +189,21 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
127
189
  }
128
190
  }
129
191
 
192
+ // Warn on abnormal finish reasons — the model stopped for a reason other than
193
+ // "stop" or "tool_calls", meaning the response may be incomplete or truncated.
194
+ if (response.finishReason && response.finishReason !== "stop" && response.finishReason !== "tool_calls") {
195
+ const reasonMap = {
196
+ length: "output token limit reached after exhausting continuations",
197
+ insufficient_system_resource: "provider inference resources exhausted — consider retrying or switching models",
198
+ content_filter: "response blocked by provider content filtering",
199
+ }
200
+ const detail = reasonMap[response.finishReason] || `unknown reason "${response.finishReason}"`
201
+ agent.history.push({
202
+ role: "user",
203
+ content: `[System reminder: the previous turn ended abnormally — ${detail}. The assistant response that follows may be incomplete.]`,
204
+ })
205
+ }
206
+
130
207
  if (response.toolCalls.length === 0) {
131
208
  if (!response.content) {
132
209
  throw new Error(
@@ -211,11 +288,20 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
211
288
  if (parsed.images?.length) {
212
289
  // tool message first — closes the tool_call pairing (OpenAI API requires tool result immediately after assistant with tool_calls)
213
290
  agent.history.push({ role: "tool", tool_call_id: toolCall.id, content: parsed.text })
214
- // then inject multimodal user message with base64 images for the model to actually "see" them on the next turn
215
- agent.history.push({
216
- role: "user",
217
- content: [{ type: "text", text: parsed.text }, ...parsed.images],
218
- })
291
+ if (specForModel(agent.provider.model).multimodal) {
292
+ // then inject multimodal user message with base64 images for the model to actually "see" them on the next turn
293
+ agent.history.push({
294
+ role: "user",
295
+ content: [{ type: "text", text: parsed.text }, ...parsed.images],
296
+ })
297
+ } else {
298
+ // Non-vision model: image parts must never enter history — text-only APIs 400 on them on EVERY
299
+ // subsequent request, poisoning the conversation. (read_image itself already refuses; this is defense-in-depth.)
300
+ agent.history.push({
301
+ role: "user",
302
+ content: `[System reminder: the image returned by ${toolCall.name} was NOT injected — model ${agent.provider.model} does not support image input. Do not call ${toolCall.name} again under this provider; verify visual output programmatically instead.]`,
303
+ })
304
+ }
219
305
  continue
220
306
  }
221
307
  } catch { /* Parse failure doesn't affect normal tool messages */ }
@@ -231,21 +317,31 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
231
317
  const abs = join(agent.cwd, p)
232
318
  agent._touchedFiles.push(abs)
233
319
  if (agent.memory) {
234
- try {
235
- if (!_reindexFile) {
236
- const mod = await import("./memory.mjs")
237
- _reindexFile = mod.reindexFile
238
- }
239
- await _reindexFile(agent.memory, agent.cwd, abs)
240
- } catch (e) { /* Index failure doesn't block agent, surface in TUI as pending reminder */
241
- agent._pendingReminders.push(`[System reminder: background indexing failed for ${toolCall.name} on ${abs}: ${e.message}. This does not affect your work — the code index will catch up on next reindex.]`)
320
+ // Fire-and-forget: don't block the agent loop on indexing.
321
+ // Reuses a single cached import; errors surface as pending reminders on next turn.
322
+ if (!_reindexFile) {
323
+ const mod = await import("./memory.mjs")
324
+ _reindexFile = mod.reindexFile
242
325
  }
326
+ _reindexFile(agent.memory, agent.cwd, abs).catch((e) => {
327
+ agent._pendingReminders.push(`[System reminder: background indexing failed for ${toolCall.name} on ${abs}: ${e.message}. This does not affect your work — the code index will catch up on next reindex.]`)
328
+ })
243
329
  }
244
330
  }
245
331
  }
246
332
  }
247
333
  }
248
334
 
335
+ // Expired timers — inject reminders when thinking budget is up
336
+ if (agent._pendingTimers.length > 0) {
337
+ const now = Date.now()
338
+ const expired = agent._pendingTimers.filter((t) => t.expiresAt <= now)
339
+ agent._pendingTimers = agent._pendingTimers.filter((t) => t.expiresAt > now)
340
+ for (const t of expired) {
341
+ agent.history.push({ role: "user", content: `[System reminder: ⏰ timer — ${t.message}]` })
342
+ }
343
+ }
344
+
249
345
  // Pending reminders
250
346
  if (agent._pendingReminders.length > 0) {
251
347
  for (const reminder of agent._pendingReminders) {
@@ -290,6 +386,17 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
290
386
  }
291
387
 
292
388
  callbacks.onTurnEnd?.(agent, turn)
389
+
390
+ // Advisor: automated code review after each tool-execution turn.
391
+ // Runs asynchronously — failure is silent, main loop continues regardless.
392
+ if (agent.config?.advisor?.enabled) {
393
+ const { runAdvisor } = await import("./advisor.mjs")
394
+ const note = await runAdvisor(agent)
395
+ if (note) {
396
+ agent.history.push({ role: "user", content: note })
397
+ callbacks.onAdvisor?.(note)
398
+ }
399
+ }
293
400
  }
294
401
 
295
402
  throw new ContinueError(maxTurns)
@@ -0,0 +1,83 @@
1
+ /**
2
+ * auto-think.mjs — automatic difficulty classification for reasoning effort.
3
+ *
4
+ * When enabled, before each user-facing turn a cheap classification call determines
5
+ * the task difficulty, then maps it to the model's reasoning effort. This replaces
6
+ * manual /think toggling with per-prompt automatic selection.
7
+ *
8
+ * Config:
9
+ * { agent: { autoThink: true } }
10
+ *
11
+ * Mechanism:
12
+ * 1. Take the last user message from history
13
+ * 2. Send a minimal classification prompt (expects one-word reply)
14
+ * 3. Map difficulty → reasoningEffort using the model's valid effort enum
15
+ * 4. Set agent.provider.reasoningEffort before the real chat() call
16
+ */
17
+ import { chat } from "./provider/core.mjs"
18
+ import { specForModel } from "./config.mjs"
19
+
20
+ const CLASSIFY_PROMPT = `Classify this coding task's difficulty: low, medium, or high.
21
+
22
+ - low — trivial: rename, typo, formatting, one-liner, direct question
23
+ - medium — localized: small feature, straightforward bug fix, moderate change
24
+ - high — complex: multi-file, debugging, design decisions, large refactor
25
+
26
+ Reply with exactly one word.`
27
+
28
+ const EFFORT_MAP = {
29
+ low: ["low", "minimal", "none", "low"],
30
+ medium: ["high", "medium", "high"],
31
+ high: ["max", "max", "xhigh", "max"],
32
+ }
33
+
34
+ /**
35
+ * Classify the difficulty of the user's prompt and adjust reasoning effort.
36
+ * Only runs on the first turn (turn === 0) of a user message.
37
+ * Returns the resolved level or null if auto-thinking is disabled or classification fails.
38
+ * @param {object} agent
39
+ * @param {number} turn
40
+ * @returns {Promise<string|null>}
41
+ */
42
+ export async function classifyAndApply(agent, turn) {
43
+ if (!agent.config?.agent?.autoThink) return null
44
+ if (turn !== 0) return null // Only classify on the first turn of user input
45
+
46
+ const spec = specForModel(agent.provider.model)
47
+ const validEfforts = spec.reasoningEffortEnum
48
+ if (!validEfforts) return null // Model doesn't support reasoning effort
49
+
50
+ // Get the last user message (should be the most recent history entry or the input)
51
+ const lastUser = [...agent.history].reverse().find(m => m.role === "user")
52
+ if (!lastUser) return null
53
+ const prompt = typeof lastUser.content === "string" ? lastUser.content : ""
54
+
55
+ // Classification call: use same provider, minimal tokens, no tools, no streaming
56
+ let level
57
+ try {
58
+ const classifierProvider = { ...agent.provider, maxTokens: 10 }
59
+ const response = await chat(classifierProvider, {
60
+ messages: [
61
+ { role: "system", content: CLASSIFY_PROMPT },
62
+ { role: "user", content: prompt.slice(0, 2000) },
63
+ ],
64
+ tools: [],
65
+ signal: AbortSignal.timeout(5_000),
66
+ })
67
+ const word = (response.content ?? "").trim().toLowerCase()
68
+ if (word.startsWith("low")) level = "low"
69
+ else if (word.startsWith("medium") || word.startsWith("med")) level = "medium"
70
+ else if (word.startsWith("high")) level = "high"
71
+ else return null // Unparseable
72
+ } catch {
73
+ return null // Classification failure → fall back to current setting
74
+ }
75
+
76
+ // Map difficulty to the closest valid reasoning effort
77
+ const candidates = EFFORT_MAP[level] || EFFORT_MAP.medium
78
+ const matched = candidates.find(e => validEfforts.includes(e))
79
+ if (!matched) return null
80
+
81
+ agent.provider.reasoningEffort = matched
82
+ return matched
83
+ }
@@ -5,6 +5,7 @@ import { loadConfig, configDir } from "../config.mjs"
5
5
  import { createMemory, memoryTools, syncDir, codeSearchTool, docSearchTool } from "../memory.mjs"
6
6
  import { repoOutlineTool } from "../tools/repomap.mjs"
7
7
  import { builtinTools } from "../tools/index.mjs"
8
+ import { discoverRules } from "../rules.mjs"
8
9
 
9
10
  /** Assemble an agent with memory, MCP tools, and code/doc indices attached (sync all layers, then return) */
10
11
  export async function assembleAgent() {
@@ -18,6 +19,14 @@ export async function assembleAgent() {
18
19
  memory.embedder = createEmbedder(config.embedding)
19
20
  }
20
21
  const cwd = process.cwd()
22
+ // Merge project-level rules (.thincoder/rules/*.md) into config; file rules take priority (first),
23
+ // config.json rules append (deduped by pattern). Users can override with explicit config rules.
24
+ const fileRules = discoverRules(cwd)
25
+ if (fileRules.length) {
26
+ const filePatterns = new Set(fileRules.map(r => r.pattern))
27
+ const configRules = (config.agent?.streamRules || []).filter(r => !filePatterns.has(r.pattern))
28
+ config.agent.streamRules = [...fileRules, ...configRules]
29
+ }
21
30
  // code/doc indices isolated by origin (project root dir): search only scoped to this project
22
31
  memory.codeOrigin = cwd
23
32
  // Project layer: sync .thincoder/memory/ dir to index on startup (sync if present, skip otherwise)