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.
- package/README.md +8 -0
- package/package.json +1 -1
- package/src/advisor.mjs +535 -72
- package/src/agent/helpers.mjs +18 -5
- package/src/agent/setup.mjs +2 -2
- package/src/agent-tools/advisor.mjs +36 -0
- package/src/agent-tools/plan.mjs +53 -2
- package/src/agent-tools/subagent.mjs +7 -1
- package/src/agent-tools/timer.mjs +1 -1
- package/src/agent-tools/verify.mjs +1 -0
- package/src/agent-tools.mjs +1 -0
- package/src/agent.mjs +80 -21
- package/src/auto-think.mjs +23 -5
- package/src/cli/make-agent.mjs +20 -0
- package/src/config.mjs +1 -1
- package/src/mcp/transport-stdio.mjs +4 -3
- package/src/mcp.mjs +1 -1
- package/src/prompts/advisor-round1.md +23 -0
- package/src/prompts/advisor-round2.md +26 -0
- package/src/prompts/advisor-round3.md +24 -0
- package/src/prompts/coder.md +1 -0
- package/src/prompts/discipline.md +15 -1
- package/src/prompts/explore.md +2 -0
- package/src/prompts/plan.md +2 -0
- package/src/prompts/system.md +5 -1
- package/src/provider/anthropic.mjs +4 -4
- package/src/provider/core.mjs +6 -126
- package/src/provider/google.mjs +4 -2
- package/src/provider/sse.mjs +112 -0
- package/src/skills.mjs +67 -31
- package/src/tools/bash.md +8 -0
- package/src/tools/codemode.mjs +5 -16
- package/src/tools/edit.md +8 -0
- package/src/tools/git.mjs +9 -6
- package/src/tools/read.md +7 -0
- package/src/tools/shared.mjs +43 -2
- package/src/tools/system.mjs +14 -10
- package/src/tools/web.mjs +21 -16
- package/src/tui/agent-turn.mjs +130 -73
- package/src/tui/cmd-advisor.mjs +237 -41
- package/src/tui/cmd-auto.mjs +6 -8
- package/src/tui/cmd-mcp.mjs +4 -2
- package/src/tui/cmd-plan.mjs +6 -8
- package/src/tui/cmd-think.mjs +93 -70
- package/src/tui/index.mjs +9 -188
- package/src/tui/interaction.mjs +2 -1
- package/src/tui/key-handler.mjs +8 -4
- package/src/tui/layout.mjs +3 -2
- package/src/tui/render-conversation.mjs +92 -0
- package/src/tui/render-frame.mjs +94 -168
- 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
package/src/advisor.mjs
CHANGED
|
@@ -1,105 +1,568 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* advisor.mjs —
|
|
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
|
-
*
|
|
5
|
-
*
|
|
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
|
|
41
|
+
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
15
42
|
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
*
|
|
25
|
-
*
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
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
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
if (
|
|
104
|
-
|
|
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
|
}
|