thincoder 0.11.0 → 0.12.0

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 (51) hide show
  1. package/README.md +8 -0
  2. package/package.json +1 -1
  3. package/src/advisor.mjs +535 -72
  4. package/src/agent/helpers.mjs +18 -5
  5. package/src/agent/setup.mjs +2 -2
  6. package/src/agent-tools/advisor.mjs +36 -0
  7. package/src/agent-tools/plan.mjs +53 -2
  8. package/src/agent-tools/subagent.mjs +7 -1
  9. package/src/agent-tools/timer.mjs +1 -1
  10. package/src/agent-tools/verify.mjs +1 -0
  11. package/src/agent-tools.mjs +1 -0
  12. package/src/agent.mjs +80 -21
  13. package/src/auto-think.mjs +23 -5
  14. package/src/cli/make-agent.mjs +20 -0
  15. package/src/config.mjs +1 -1
  16. package/src/mcp/transport-stdio.mjs +4 -3
  17. package/src/mcp.mjs +1 -1
  18. package/src/prompts/advisor-round1.md +23 -0
  19. package/src/prompts/advisor-round2.md +26 -0
  20. package/src/prompts/advisor-round3.md +24 -0
  21. package/src/prompts/coder.md +1 -0
  22. package/src/prompts/discipline.md +15 -1
  23. package/src/prompts/explore.md +2 -0
  24. package/src/prompts/plan.md +2 -0
  25. package/src/prompts/system.md +5 -1
  26. package/src/provider/anthropic.mjs +4 -4
  27. package/src/provider/core.mjs +6 -126
  28. package/src/provider/google.mjs +4 -2
  29. package/src/provider/sse.mjs +112 -0
  30. package/src/skills.mjs +67 -31
  31. package/src/tools/bash.md +8 -0
  32. package/src/tools/codemode.mjs +5 -16
  33. package/src/tools/edit.md +8 -0
  34. package/src/tools/git.mjs +9 -6
  35. package/src/tools/read.md +7 -0
  36. package/src/tools/shared.mjs +43 -2
  37. package/src/tools/system.mjs +14 -10
  38. package/src/tools/web.mjs +21 -16
  39. package/src/tui/agent-turn.mjs +130 -73
  40. package/src/tui/cmd-advisor.mjs +237 -41
  41. package/src/tui/cmd-auto.mjs +6 -8
  42. package/src/tui/cmd-mcp.mjs +4 -2
  43. package/src/tui/cmd-plan.mjs +6 -8
  44. package/src/tui/cmd-think.mjs +93 -70
  45. package/src/tui/index.mjs +9 -188
  46. package/src/tui/interaction.mjs +2 -1
  47. package/src/tui/key-handler.mjs +8 -4
  48. package/src/tui/layout.mjs +3 -2
  49. package/src/tui/render-conversation.mjs +92 -0
  50. package/src/tui/render-frame.mjs +94 -168
  51. package/src/tui/render-loop.mjs +110 -0
package/README.md CHANGED
@@ -205,6 +205,14 @@ Code conventions: pure `.mjs`, no semicolons, no npm dependencies allowed (inclu
205
205
 
206
206
  ## Changelog
207
207
 
208
+ ### 0.12.0 (2026-07)
209
+ - **Interactive slash command UX** — `/advisor`, `/think`, `/config`, `/mcp` now use persistent menu loops with live state feedback. Toggle, change settings, and see results without re-entering the command. Cursor position is remembered across menu cycles. `/plan` and `/auto` now show immediate local feedback (`❯ Plan: ON/OFF`).
210
+ - **User-level AGENTS.md** — `~/.thincoder/AGENTS.md` is now loaded alongside the project-level `AGENTS.md`. User-level preferences (language, style, format) apply across all projects; project-level rules take priority.
211
+ - **Skill subdirectory format** — `.thincoder/skills/` now supports the standard `skill-name/SKILL.md` subdirectory convention (Claude Code / Cursor compatible). Flat `.md` files remain fully backward-compatible. Subdirectories take priority when both formats exist with the same name.
212
+ - **MCP stdio `env` field** — `env` key in MCP stdio server config is now merged into the child process environment. Enables MCP servers requiring custom environment variables (e.g. `deveco-mcp`).
213
+ - **Project-level `.mcp.json`** — `.mcp.json` in the project root is auto-loaded at startup (standard MCP client convention). Servers defined here are merged with `config.json` servers — `config.json` takes priority for same-named entries.
214
+ - **Cleaner conversations** — Removed redundant `[System reminder: ...]` injections from `/advisor`, `/plan`, `/auto`, and `/think` toggles. All feedback is now local TUI output, not conversation noise.
215
+
208
216
  ### 0.10.0 (2026-07)
209
217
  - **LSP tool** — `lsp` tool provides code intelligence via Language Server Protocol: go-to-definition, find-references, hover info, document symbols, diagnostics. Zero-dependency JSON-RPC 2.0 over stdio client. Lazy-starts language servers on first call. Configurable via `lsp.servers` in config.json (defaults: `typescript-language-server` for JS/TS, `pyright-langserver` for Python).
210
218
  - **Smart context: compaction checkpoint** — `compressIfNeeded` now auto-creates a git checkpoint before compaction. A checkpoint reference is injected after compaction so the model can reconstruct context from git diff + recent messages + task progress. Prevents information loss during long sessions.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thincoder",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "Thin coding agent - zero dependencies, no build step, Node.js native. Sharp code, zero bloat.",
5
5
  "keywords": [
6
6
  "ai",
package/src/advisor.mjs CHANGED
@@ -1,105 +1,568 @@
1
1
  /**
2
- * advisor.mjs — automated code review after each turn.
2
+ * advisor.mjs — Code review engine.
3
+ * Called by the advisor tool (agent-tools/advisor.mjs) when the agent
4
+ * explicitly requests a review at the end of a coding task.
3
5
  *
4
- * After every agent turn with tool execution, a second chat completion reviews
5
- * the turn's transcript and injects observations as a system reminder.
6
+ * The advisor runs as a read-only exploration sub-agent with tools
7
+ * (read, glob, grep, ls, git, lsp, code_search). It discovers changes
8
+ * via git diff, reads files for context, and traces callers via grep/lsp.
6
9
  *
7
10
  * Config:
8
11
  * { advisor: { enabled: true, provider: "deepseek", model: "deepseek-chat" } }
9
12
  * provider + model are optional — defaults to the main agent's provider/model.
13
+ *
14
+ * Convergence protocol:
15
+ * Round 1: full review → produces a numbered issue table.
16
+ * Agent responds with a response table per issue.
17
+ * Round 2: semi-convergence — verifies table + can flag obvious new issues.
18
+ * Round 3+: strict convergence — only checks the prior issue table.
19
+ * No hard round cap — the convergence protocol naturally limits divergence.
20
+ *
21
+ * Session memory (agent._advisorSession):
22
+ * All advisor calls within one run share a single conversation — round 2+
23
+ * just appends a follow-up (agent response + refreshed diff + round rules),
24
+ * so the advisor keeps its exploration context instead of re-discovering
25
+ * everything. The session is discarded when the run ends (runAgent resets it);
26
+ * the next task starts a fresh advisor session. After an app restart the
27
+ * in-memory session is gone — falls back to a fresh session seeded from the
28
+ * issue/response tables in the main history.
29
+ *
30
+ *
31
+ * Project customisation: .thincoder/advisor.md in the project root.
10
32
  */
11
33
  import { chat } from "./provider/core.mjs"
12
34
  import { findProvider } from "./config.mjs"
35
+ import { existsSync, readFileSync } from "node:fs"
36
+ import { join, dirname } from "node:path"
37
+ import { fileURLToPath } from "node:url"
38
+ import { execFileSync } from "node:child_process"
39
+ import { toOpenAISchema } from "./tools/index.mjs"
13
40
 
14
- const ADVISOR_PROMPT = `你是代码审查顾问。审查编程助手最近一轮操作并提供简短的观察。
41
+ const __dirname = dirname(fileURLToPath(import.meta.url))
15
42
 
16
- 规则:
17
- - 用中文回答,2-4 行,分条目
18
- - 关注:遗漏的边界情况、错误的假设、低效的模式、安全隐患
19
- - 如果一切正常,说"未发现问题"
20
- - 不要建议或调用工具 — 只做只读审查
21
- - 不要直接对用户说话 用第三人称评价助手的行为`
43
+ const ADVISOR_MD_PATH = ".thincoder/advisor.md"
44
+ const GIT_TIMEOUT = 5_000
45
+
46
+ const DEFAULT_CRITERIA = `Review the code changes, focusing on:
47
+ 1. Correctness: logic errors, edge cases, off-by-one, incomplete modifications
48
+ 2. Security: unhandled exceptions, null references, resource leaks, race conditions
49
+ 3. Consistency: alignment with existing project patterns and conventions
50
+ 4. Completeness: missing callers, imports, or follow-up changes
51
+ 5. Maintainability: vague naming, missing comments, overly complex logic`
52
+
53
+ // ────────────────────────────────────────
54
+ // Advisor's read-only tool set
55
+ // ────────────────────────────────────────
22
56
 
23
57
  /**
24
- * Run advisor review on the most recent turn.
25
- * Only fires when the agent executed tool calls (did real work).
26
- * Returns the advisor's message, or null if advisor is disabled or there's nothing to review.
58
+ * Restricted git tool: diff / status / log only.
59
+ * Checkpoint create/rewind are blocked the advisor must not mutate state.
27
60
  */
28
- export async function runAdvisor(agent) {
29
- const cfg = agent.config?.advisor
30
- if (!cfg?.enabled) return null
61
+ const { gitTool, readTool, globTool, grepTool, lsTool } = await import("./tools/index.mjs")
62
+ const { lspTool } = await import("./tools/lsp.mjs")
63
+ const { codeModeTool: codeSearchTool } = await import("./tools/codemode.mjs")
31
64
 
32
- // Only review turns where the agent did real work (tool calls executed)
33
- const lastAssistant = findLast(agent.history, (m) => m.role === "assistant")
34
- const lastTool = findLast(agent.history, (m) => m.role === "tool")
35
- if (!lastAssistant || !lastTool) return null
36
-
37
- // Build a clean transcript: last user message + assistant + tool results
38
- const lastUser = findLast(agent.history, (m) => m.role === "user")
39
- const transcript = buildTranscript(agent.history, lastUser)
40
-
41
- // Resolve provider: explicit provider name → look up from providers list;
42
- // only model → reuse main provider with different model; neither → main provider as-is.
43
- let provider
44
- if (cfg.provider) {
45
- provider = findProvider(agent.providers ?? [agent.provider], cfg.provider)
46
- if (cfg.model) provider = { ...provider, model: cfg.model }
47
- } else {
48
- provider = { ...agent.provider }
49
- if (cfg.model) provider.model = cfg.model
65
+ const advisorGitTool = {
66
+ ...gitTool,
67
+ readonly: true,
68
+ async execute(args, ctx) {
69
+ // Block checkpoint create/rewind — advisor is read-only
70
+ if (args.action === "checkpoint") {
71
+ if (args.checkpointAction === "create" || args.checkpointAction === "rewind") {
72
+ return "Error: checkpoint create/rewind is disabled in advisor mode. Use diff/status/log only."
73
+ }
74
+ }
75
+ return gitTool.execute(args, ctx)
76
+ },
77
+ }
78
+
79
+ const ADVISOR_TOOLS = [readTool, globTool, grepTool, lsTool, advisorGitTool, lspTool, codeSearchTool]
80
+ const ADVISOR_TOOL_SCHEMAS = ADVISOR_TOOLS.map(toOpenAISchema)
81
+ const ADVISOR_TOOL_BY_NAME = new Map(ADVISOR_TOOLS.map((t) => [t.name, t]))
82
+
83
+ // ────────────────────────────────────────
84
+ // Prompt files — loaded at module init
85
+ // ────────────────────────────────────────
86
+
87
+ const ADVISOR_ROUND1 = readFileSync(join(__dirname, "prompts", "advisor-round1.md"), "utf8")
88
+ const ADVISOR_ROUND2 = readFileSync(join(__dirname, "prompts", "advisor-round2.md"), "utf8")
89
+ const ADVISOR_ROUND3 = readFileSync(join(__dirname, "prompts", "advisor-round3.md"), "utf8")
90
+
91
+ // ────────────────────────────────────────
92
+ // History extraction — issue/response tables
93
+ // ────────────────────────────────────────
94
+
95
+ const ADVISOR_TABLE_HEADER = "| # | File | Severity | Issue | Suggestion |"
96
+ const CONVERGENCE_TABLE_HEADER = "| # | Orig# | File | Severity | Status | Notes |"
97
+ const AGENT_RESPONSE_HEADER = "| # | Action | Detail |"
98
+ const LEGACY_ADVISOR_HEADER = "| # | 文件 | 严重程度 | 问题描述 | 建议修复 |"
99
+ const LEGACY_CONVERGENCE_HEADER = "| # | 原# | 文件 | 严重程度 | 当前状态 | 说明 |"
100
+ const LEGACY_RESPONSE_HEADER = "| # | 处理 | 详情 |"
101
+ const ALL_CLEAR_PHRASES = [
102
+ "No issues found",
103
+ "All issues resolved",
104
+ "review passed",
105
+ "未发现问题",
106
+ "所有问题已解决",
107
+ "审查通过",
108
+ ]
109
+
110
+ export function extractPriorIssueTable(history) {
111
+ for (let i = history.length - 1; i >= 0; i--) {
112
+ const m = history[i]
113
+ if (m.role !== "tool") continue
114
+ const content = typeof m.content === "string" ? m.content : ""
115
+ if (ALL_CLEAR_PHRASES.some((p) => content.includes(p))) return null
116
+ if (content.includes(ADVISOR_TABLE_HEADER) || content.includes(CONVERGENCE_TABLE_HEADER) ||
117
+ content.includes(LEGACY_ADVISOR_HEADER) || content.includes(LEGACY_CONVERGENCE_HEADER)) {
118
+ const table = extractTableBlock(content)
119
+ if (table) return { text: table, sinceIdx: i }
120
+ return null
121
+ }
122
+ }
123
+ return null
124
+ }
125
+
126
+ export function extractAgentResponseTable(history, sinceIdx) {
127
+ for (let i = sinceIdx + 1; i < history.length; i++) {
128
+ const m = history[i]
129
+ if (m.role !== "assistant") continue
130
+ const content = typeof m.content === "string" ? m.content : ""
131
+ if (content.includes(AGENT_RESPONSE_HEADER) || content.includes(LEGACY_RESPONSE_HEADER)) {
132
+ return extractTableBlock(content)
133
+ }
134
+ }
135
+ return null
136
+ }
137
+
138
+ function extractTableBlock(text) {
139
+ const lines = text.split("\n")
140
+ let start = -1
141
+ for (let i = 0; i < lines.length; i++) {
142
+ if (lines[i].startsWith("|")) { start = i; break }
143
+ }
144
+ if (start < 0) return null
145
+ let end = start
146
+ for (let i = start + 1; i < lines.length; i++) {
147
+ if (lines[i].startsWith("|")) end = i
148
+ else break
149
+ }
150
+ return lines.slice(start, end + 1).join("\n")
151
+ }
152
+
153
+ // ────────────────────────────────────────
154
+ // Review scope — repos to review
155
+ // ────────────────────────────────────────
156
+
157
+ /**
158
+ * Find the git repository roots that contain the agent's touched files.
159
+ * Falls back to cwd if no repos found.
160
+ */
161
+ function findReviewRepos(agent) {
162
+ const touched = agent._touchedFiles ?? []
163
+ const repos = []
164
+
165
+ for (const abs of touched) {
166
+ try {
167
+ const root = execFileSync("git", ["rev-parse", "--show-toplevel"], {
168
+ cwd: dirname(abs), encoding: "utf8", timeout: GIT_TIMEOUT,
169
+ stdio: ["ignore", "pipe", "pipe"],
170
+ }).trim()
171
+ if (root && !repos.includes(root)) repos.push(root)
172
+ } catch { /* not a git repo */ }
50
173
  }
51
174
 
175
+ if (repos.length > 0) return repos
176
+
177
+ // Fallback: cwd itself
52
178
  try {
53
- const response = await chat(provider, {
54
- messages: [
55
- { role: "system", content: ADVISOR_PROMPT },
56
- { role: "user", content: transcript },
57
- ],
58
- tools: [],
59
- signal: new AbortController().signal,
60
- })
61
- if (!response.content?.trim()) return null
179
+ const root = execFileSync("git", ["rev-parse", "--show-toplevel"], {
180
+ cwd: agent.cwd, encoding: "utf8", timeout: GIT_TIMEOUT,
181
+ stdio: ["ignore", "pipe", "pipe"],
182
+ }).trim()
183
+ if (root) return [root]
184
+ } catch { /* not a git repo */ }
62
185
 
63
- return `[Advisor 审查 — 自动观察,非用户指令。请批判性参考:\n${response.content.trim()}]`
64
- } catch {
65
- // Advisor failure is non-fatal — main loop continues
66
- return null
186
+ return []
187
+ }
188
+
189
+ // ────────────────────────────────────────
190
+
191
+ /** Cap per-repo embedded diff — generous (large-context models); advisor can fetch the rest via its git tool */
192
+ const MAX_EMBEDDED_DIFF = 50_000
193
+
194
+ /**
195
+ * Collect git status + diff for each repo, embedded into the review context so
196
+ * the advisor doesn't need to spend its first tool calls discovering changes.
197
+ */
198
+ function collectRepoSnapshots(repos, cwd) {
199
+ const targets = repos.length > 0 ? repos : [cwd]
200
+ const parts = []
201
+ for (const repo of targets) {
202
+ let status = "", diff = ""
203
+ try {
204
+ status = execFileSync("git", ["status", "--porcelain"], {
205
+ cwd: repo, encoding: "utf8", timeout: GIT_TIMEOUT, stdio: ["ignore", "pipe", "pipe"],
206
+ }).trim()
207
+ diff = execFileSync("git", ["diff", "HEAD"], {
208
+ cwd: repo, encoding: "utf8", timeout: GIT_TIMEOUT, stdio: ["ignore", "pipe", "pipe"],
209
+ maxBuffer: 8 * 1024 * 1024,
210
+ })
211
+ } catch { continue /* not a git repo or git failed */ }
212
+ if (!status && !diff.trim()) continue
213
+ parts.push(`### ${repo}`)
214
+ if (status) parts.push("```", status, "```")
215
+ if (diff.trim()) {
216
+ const truncated = diff.length > MAX_EMBEDDED_DIFF
217
+ parts.push("```diff", truncated ? diff.slice(0, MAX_EMBEDDED_DIFF) : diff.trimEnd(), "```")
218
+ if (truncated) parts.push(`(diff truncated at ${MAX_EMBEDDED_DIFF} chars — use the git tool to see the rest)`)
219
+ }
67
220
  }
221
+ return parts
68
222
  }
69
223
 
70
- /** Find the last history entry matching a predicate. */
71
- function findLast(arr, fn) {
72
- for (let i = arr.length - 1; i >= 0; i--) {
73
- if (fn(arr[i])) return i
224
+ export function loadAdvisorMd(cwd) {
225
+ const path = join(cwd, ADVISOR_MD_PATH)
226
+ if (!existsSync(path)) return DEFAULT_CRITERIA
227
+ try {
228
+ const content = readFileSync(path, "utf8").trim()
229
+ return content || DEFAULT_CRITERIA
230
+ } catch {
231
+ return DEFAULT_CRITERIA
74
232
  }
75
- return -1
76
233
  }
77
234
 
78
- /** Build a compact transcript of the last turn for the advisor. */
79
- function buildTranscript(history, lastUserIdx) {
80
- const lines = []
81
- lines.push("## Last user message")
82
- lines.push(truncate(String(history[lastUserIdx]?.content ?? "")))
83
- lines.push("")
84
- lines.push("## Assistant response and tool calls")
235
+ const MAX_BACKGROUND_CHARS = 20_000
236
+ const MAX_BG_USER_CHARS = 2000
237
+ const MAX_BG_ASSISTANT_CHARS = 1500
85
238
 
86
- for (let i = lastUserIdx + 1; i < history.length; i++) {
239
+ /**
240
+ * Recent conversation context for the advisor: the last few user↔assistant
241
+ * exchanges (default 3 user turns). The last user message alone often lacks
242
+ * context ("把那个问题改一下" means nothing without the preceding turns) —
243
+ * the advisor needs the background to judge whether the changes match intent.
244
+ * Tool messages are skipped (noise); texts are truncated with generous caps
245
+ * (models have large context windows — completeness beats frugality).
246
+ */
247
+ export function extractConversationBackground(history, maxTurns = 3) {
248
+ const isNoise = (c) => c.startsWith("[System reminder:") || c.startsWith("[User interrupt:")
249
+ const picked = []
250
+ let userCount = 0
251
+ for (let i = history.length - 1; i >= 0 && userCount < maxTurns; i--) {
87
252
  const m = history[i]
88
- if (m.role === "assistant") {
89
- const content = typeof m.content === "string" ? m.content : ""
90
- const tools = m.tool_calls?.map((tc) => tc.function?.name).filter(Boolean) ?? []
91
- const label = tools.length ? ` (called: ${tools.join(", ")})` : ""
92
- lines.push(`[assistant${label}] ${truncate(content, 500)}`)
93
- } else if (m.role === "tool") {
94
- lines.push(`[tool ${m.tool_call_id}] ${truncate(String(m.content ?? ""), 300)}`)
253
+ if (m.role !== "user" && m.role !== "assistant") continue
254
+ const content = typeof m.content === "string" ? m.content.trim() : ""
255
+ if (!content || isNoise(content)) continue
256
+ picked.unshift({ role: m.role === "user" ? "User" : "Assistant", text: content })
257
+ if (m.role === "user") userCount++
258
+ }
259
+ if (picked.length === 0) return null
260
+
261
+ const lines = picked.map((e) => {
262
+ const cap = e.role === "User" ? MAX_BG_USER_CHARS : MAX_BG_ASSISTANT_CHARS
263
+ const text = e.text.length > cap ? e.text.slice(0, cap) + "…" : e.text
264
+ return `${e.role}: ${text}`
265
+ })
266
+ // Keep the most recent lines within the total budget
267
+ const out = []
268
+ let total = 0
269
+ for (let i = lines.length - 1; i >= 0; i--) {
270
+ if (out.length > 0 && total + lines[i].length > MAX_BACKGROUND_CHARS) break
271
+ total += lines[i].length
272
+ out.unshift(lines[i])
273
+ }
274
+ return out.join("\n")
275
+ }
276
+
277
+ // ────────────────────────────────────────
278
+ // System prompt routing
279
+ // ────────────────────────────────────────
280
+
281
+ export function buildAdvisorSystemPrompt(agent, _prior) {
282
+ const prior = _prior ?? extractPriorIssueTable(agent.history)
283
+ if (!prior || (agent._advisorRound || 0) === 0) return ADVISOR_ROUND1
284
+ const round = (agent._advisorRound || 0) + 1
285
+ if (round === 2) return ADVISOR_ROUND2
286
+ return ADVISOR_ROUND3
287
+ }
288
+
289
+ // ────────────────────────────────────────
290
+ // User message building
291
+ // ────────────────────────────────────────
292
+
293
+ export function buildAdvisorUserMessage(agent, _prior) {
294
+ const prior = _prior ?? extractPriorIssueTable(agent.history)
295
+
296
+ // Repos to review
297
+ const repos = findReviewRepos(agent)
298
+ const repoList = repos.length > 0
299
+ ? repos.map((r, i) => `${i + 1}. ${r}`).join("\n")
300
+ : `(no git repository — working directory: ${agent.cwd})`
301
+
302
+ const parts = []
303
+
304
+ // Convergence data (round 2+)
305
+ if (prior && (agent._advisorRound || 0) > 0) {
306
+ const response = extractAgentResponseTable(agent.history, prior.sinceIdx)
307
+ || "(Agent did not provide a response table — re-evaluate each issue)"
308
+ const round = (agent._advisorRound || 0) + 1
309
+ const label = round === 2 ? "Verify Prior Table + Flag New Issues" : "Strict Verification"
310
+ parts.push(`## Round ${round} — ${label}`)
311
+ parts.push("")
312
+ parts.push("## Prior Issue Table")
313
+ parts.push(prior.text)
314
+ parts.push("")
315
+ parts.push("## Agent Response")
316
+ parts.push(response)
317
+ parts.push("")
318
+ parts.push("---")
319
+ parts.push("")
320
+ }
321
+
322
+ // Review scope
323
+ parts.push("## Review Scope")
324
+ parts.push(`Review the following git repositor${repos.length === 1 ? "y" : "ies"}:`)
325
+ parts.push(repoList)
326
+ parts.push("")
327
+
328
+ // Pre-collected changes — saves the advisor from spending its first tool
329
+ // calls on discovery (git status / git diff) every single round.
330
+ const snapshots = collectRepoSnapshots(repos, agent.cwd)
331
+ agent._advisorLastSnapshot = snapshots.join("\n") // dedup baseline for follow-up rounds
332
+ if (snapshots.length > 0) {
333
+ parts.push("## Current Changes (git status + git diff HEAD, pre-collected)")
334
+ parts.push(...snapshots)
335
+ parts.push("")
336
+ }
337
+
338
+ // Conversation background — recent user↔assistant exchanges for intent context
339
+ const background = extractConversationBackground(agent.history)
340
+ if (background) {
341
+ parts.push("## Conversation Background (recent turns)")
342
+ parts.push(background)
343
+ parts.push("")
344
+ }
345
+
346
+ // Review criteria
347
+ const criteria = loadAdvisorMd(agent.cwd)
348
+ parts.push("## Review Criteria")
349
+ parts.push(criteria)
350
+ parts.push("")
351
+
352
+ // Instructions — round-aware: re-reviews skip convention discovery entirely
353
+ const isReReview = prior && (agent._advisorRound || 0) > 0
354
+ parts.push("## Instructions")
355
+ parts.push("1. The uncommitted changes are already provided above — do NOT re-run `git status` / `git diff` unless the embedded diff is marked truncated.")
356
+ if (isReReview) {
357
+ parts.push("2. Do NOT re-read AGENTS.md / design docs — conventions were established in round 1. Focus on verifying the prior issue table against the current diff.")
358
+ parts.push("3. `read` only the files touched by the fixes. Batch independent reads/greps in a single reply.")
359
+ parts.push("4. Produce your verification table. Do not re-read content you already have.")
360
+ } else {
361
+ parts.push("2. Read `AGENTS.md` / design docs only if they exist (check once; do not re-probe with multiple patterns).")
362
+ parts.push("3. `read` changed files for full context beyond the diff. Batch independent reads/greps in a single reply instead of one call per round-trip.")
363
+ parts.push("4. Use `grep` or `lsp` to trace callers, imports, and dependencies — only where the diff leaves genuine doubt.")
364
+ parts.push("5. Produce your review table based on the review criteria above. Do not re-read content you already have.")
365
+ }
366
+ parts.push("Do NOT flag features that are valid under the project's stated platform requirements.")
367
+
368
+ return parts.join("\n")
369
+ }
370
+
371
+ // ────────────────────────────────────────
372
+ // Session continuity — one advisor conversation per run
373
+ // ────────────────────────────────────────
374
+
375
+ /**
376
+ * Follow-up message for round 2+ in a continued advisor session.
377
+ * The advisor already has full context (its exploration, its issue table) in
378
+ * the conversation — the follow-up only carries what changed: the agent's
379
+ * response table, the fresh diff snapshot, and this round's rules.
380
+ */
381
+ export function buildAdvisorFollowUp(agent, _prior) {
382
+ const prior = _prior ?? extractPriorIssueTable(agent.history)
383
+ const round = (agent._advisorRound || 0) + 1
384
+ const response = (prior ? extractAgentResponseTable(agent.history, prior.sinceIdx) : null)
385
+ || "(Agent did not provide a response table — re-evaluate each issue)"
386
+ const rules = round === 2
387
+ ? "Verify each item in your prior issue table against the current changes. " +
388
+ "You may flag obvious NEW issues introduced by the fixes — but only crashes, data loss, or logic errors clearly visible in the diff. Do not nitpick style."
389
+ : "Strictly verify only your prior issue table against the current changes. Do NOT look for new issues."
390
+
391
+ const parts = [
392
+ `## Round ${round} — ${round === 2 ? "Verify Prior Table + Flag New Issues" : "Strict Verification"}`,
393
+ "",
394
+ rules,
395
+ "",
396
+ 'If every prior issue is resolved, say exactly: "All issues resolved — review passed."',
397
+ "",
398
+ "Do NOT re-read AGENTS.md / design docs or re-run git status/diff (current changes are below) — you already have full context from previous rounds.",
399
+ "",
400
+ "## Agent Response to Your Review",
401
+ response,
402
+ "",
403
+ ]
404
+ const snapshots = collectRepoSnapshots(findReviewRepos(agent), agent.cwd)
405
+ const snapshotText = snapshots.join("\n")
406
+ // Skip re-pushing an identical diff (e.g. advisor re-run without any file changes) —
407
+ // the previous snapshot is already in the conversation, duplicating it wastes tokens.
408
+ if (snapshotText && snapshotText === agent._advisorLastSnapshot) {
409
+ parts.push("## Current Changes", "(No changes since your previous review.)")
410
+ } else if (snapshots.length > 0) {
411
+ parts.push("## Current Changes (git status + git diff HEAD, refreshed)", ...snapshots)
412
+ }
413
+ agent._advisorLastSnapshot = snapshotText
414
+ return parts.join("\n")
415
+ }
416
+
417
+ /**
418
+ * Build or continue the advisor conversation for this run.
419
+ * First call in a run: fresh [system, user] session. Later calls: append a
420
+ * follow-up to the existing session so the advisor keeps its context.
421
+ * After an app restart (session lost), falls back to a fresh session whose
422
+ * system prompt is picked from history tables (round 2/3 style).
423
+ */
424
+ export function prepareAdvisorMessages(agent) {
425
+ const prior = extractPriorIssueTable(agent.history)
426
+ let session = agent._advisorSession
427
+ if (session) {
428
+ session.push({ role: "user", content: buildAdvisorFollowUp(agent, prior) })
429
+ return session
430
+ }
431
+ session = [
432
+ { role: "system", content: buildAdvisorSystemPrompt(agent, prior) },
433
+ { role: "user", content: buildAdvisorUserMessage(agent, prior) },
434
+ ]
435
+ return session
436
+ }
437
+
438
+ // ────────────────────────────────────────
439
+ // Advisor tool loop
440
+ // ────────────────────────────────────────
441
+
442
+ /**
443
+ * Compact one-line summary of tool args for panel progress lines.
444
+ * Picks the most identifying field; falls back to truncated JSON.
445
+ */
446
+ function summarizeToolArgs(args) {
447
+ // e.g. "git diff HEAD", "read src/x.mjs" — action first when present
448
+ const parts = [args.action, args.path ?? args.pattern ?? args.command].filter((v) => v != null)
449
+ let s = parts.length > 0 ? parts.map(String).join(" ") : JSON.stringify(args)
450
+ s = s.replace(/\s+/g, " ").trim()
451
+ return s.length > 80 ? s.slice(0, 79) + "…" : s
452
+ }
453
+
454
+ /**
455
+ * Run the advisor's tool loop: chat → execute tools → repeat.
456
+ * Stops when the model produces text without tool calls.
457
+ *
458
+ * Progress lines (→ tool args) are emitted via onOutput between model bursts so
459
+ * the panel keeps moving while the advisor explores — otherwise the panel sits
460
+ * frozen through every tool-call phase and the review appears to have stalled.
461
+ */
462
+ async function runAdvisorToolLoop(provider, messages, onOutput, signal, agent, cwd) {
463
+ // Kind-tagged wrappers: the TUI panel colors reasoning / answer / tool progress differently.
464
+ const emit = (kind) => (onOutput ? (text) => onOutput({ kind, text }) : undefined)
465
+ const onThink = emit("think")
466
+ const onText = emit("text")
467
+ while (true) {
468
+ const response = await chat(provider, {
469
+ messages,
470
+ tools: ADVISOR_TOOL_SCHEMAS,
471
+ signal: (signal && !signal.aborted) ? signal : new AbortController().signal,
472
+ onToken: onText,
473
+ onReasoning: onThink,
474
+ })
475
+
476
+ // No tool calls — this is the final review text
477
+ if (!response.toolCalls?.length) {
478
+ if (!response.content?.trim()) return "Advisor: (empty response — review was inconclusive)"
479
+ return response.content.trim()
480
+ }
481
+
482
+ // Push assistant message with tool calls
483
+ messages.push({
484
+ role: "assistant",
485
+ content: response.content || null,
486
+ tool_calls: response.toolCalls.map((tc) => ({
487
+ id: tc.id, type: "function",
488
+ function: { name: tc.name, arguments: tc.arguments },
489
+ })),
490
+ })
491
+
492
+ // Execute each tool call
493
+ for (const tc of response.toolCalls) {
494
+ const tool = ADVISOR_TOOL_BY_NAME.get(tc.name)
495
+ let args = {}
496
+ try { args = JSON.parse(tc.arguments || "{}") } catch { /* summarized as raw JSON below */ }
497
+ onOutput?.({ kind: "tool", text: `\n→ ${tc.name} ${summarizeToolArgs(args)}\n` })
498
+ let result
499
+ if (!tool) {
500
+ result = `Error: unknown tool "${tc.name}". Available: ${[...ADVISOR_TOOL_BY_NAME.keys()].join(", ")}`
501
+ } else {
502
+ try {
503
+ result = await tool.execute(args, {
504
+ cwd,
505
+ agent,
506
+ onOutput,
507
+ signal,
508
+ })
509
+ } catch (e) {
510
+ result = `Error: ${e.message}`
511
+ }
512
+ }
513
+ messages.push({ role: "tool", tool_call_id: tc.id, content: String(result) })
95
514
  }
96
515
  }
516
+ }
517
+
518
+ // ────────────────────────────────────────
519
+ // Main entry point
520
+ // ────────────────────────────────────────
97
521
 
98
- return lines.join("\n")
522
+ export function resolveAdvisorProvider(agent) {
523
+ const cfg = agent.config?.advisor
524
+ if (cfg?.provider) {
525
+ try {
526
+ const provider = findProvider(agent.providers ?? [agent.provider], cfg.provider)
527
+ const result = cfg.model ? { ...provider, model: cfg.model } : { ...provider }
528
+ if (cfg.thinking === null) result.thinking = undefined // explicitly off
529
+ else if (cfg.thinking !== undefined) result.thinking = cfg.thinking
530
+ if (cfg.reasoningEffort !== undefined) result.reasoningEffort = cfg.reasoningEffort
531
+ return result
532
+ } catch {
533
+ // Provider not found — fall back to main provider
534
+ }
535
+ }
536
+ const provider = { ...agent.provider }
537
+ if (cfg?.model) provider.model = cfg.model
538
+ if (cfg?.thinking === null) provider.thinking = undefined // explicitly off
539
+ else if (cfg?.thinking !== undefined) provider.thinking = cfg.thinking
540
+ if (cfg?.reasoningEffort !== undefined) provider.reasoningEffort = cfg.reasoningEffort
541
+ return provider
99
542
  }
100
543
 
101
- /** Truncate text to maxLen characters, adding "…" if truncated. */
102
- function truncate(text, maxLen = 1000) {
103
- if (text.length <= maxLen) return text
104
- return text.slice(0, maxLen) + "…"
544
+ export async function runAdvisorReview(agent, onOutput, signal) {
545
+ const cfg = agent.config?.advisor
546
+ if (!cfg?.enabled) return null
547
+
548
+ const repos = findReviewRepos(agent)
549
+ if ((agent._touchedFiles ?? []).length === 0) return null
550
+
551
+ const provider = resolveAdvisorProvider(agent)
552
+
553
+ // Set the advisor's cwd to the first repo (for tool context)
554
+ const advisorCwd = repos.length > 0 ? repos[0] : agent.cwd
555
+
556
+ const messages = prepareAdvisorMessages(agent)
557
+
558
+ try {
559
+ const result = await runAdvisorToolLoop(provider, messages, onOutput, signal, agent, advisorCwd)
560
+ // Persist the conversation: the next advisor call in this run continues here
561
+ // (reset by runAgent when the run ends — each task gets a fresh advisor session)
562
+ agent._advisorSession = messages
563
+ return result
564
+ } catch (e) {
565
+ if (e.name === "AbortError" && signal?.reason?.interrupt) throw e
566
+ return `Advisor: review failed — ${e.message || "unknown error"}. You may retry or proceed to verify.`
567
+ }
105
568
  }