thincoder 0.12.11 → 0.12.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -0
- package/bin/thincoder.mjs +11 -1
- package/package.json +1 -1
- package/src/acp/bridge.mjs +229 -0
- package/src/acp/session.mjs +46 -0
- package/src/acp/transport.mjs +155 -0
- package/src/acp.mjs +335 -0
- package/src/advisor/citations.mjs +77 -0
- package/src/advisor/history.mjs +25 -6
- package/src/advisor/messages.mjs +76 -22
- package/src/advisor/run.mjs +185 -91
- package/src/advisor.mjs +205 -83
- package/src/agent/completion.mjs +9 -2
- package/src/agent/dispatch.mjs +14 -0
- package/src/agent-tools/advisor.mjs +11 -10
- package/src/agent-tools/subagent.mjs +26 -8
- package/src/agent.mjs +5 -5
- package/src/prompts/advisor-round1.md +8 -2
- package/src/prompts/advisor-round2.md +6 -4
- package/src/prompts/advisor-round3.md +6 -4
- package/src/prompts/discipline.md +1 -1
- package/src/session.mjs +19 -0
- package/src/tools/file.mjs +30 -0
- package/src/tools/insert_after.md +1 -0
- package/src/tools/patch.mjs +4 -0
- package/src/tools/shared.mjs +68 -57
- package/src/tui/agent-turn.mjs +73 -121
- package/src/tui/index.mjs +1 -1
- package/src/tui/markdown.mjs +26 -8
- package/src/tui/render-conversation.mjs +90 -39
- package/src/tui/tool-summaries.mjs +113 -0
package/src/acp.mjs
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* acp.mjs — `thincoder acp` entry: expose the thincoder agent over the
|
|
3
|
+
* Agent Client Protocol (schema v1) on stdio, so ACP clients (Zed, JetBrains
|
|
4
|
+
* AI Chat, Paseo) can drive sessions directly.
|
|
5
|
+
*
|
|
6
|
+
* M1 scope (see docs/design/ACP-CLIENT.md §9):
|
|
7
|
+
* initialize / authenticate / session/new / session/prompt / session/cancel / session/close
|
|
8
|
+
* M2: tools + request_permission + fs reverse-RPC. M3: session load/resume/list/delete + config options.
|
|
9
|
+
*
|
|
10
|
+
* Auth: reuse the terminal config (~/.thincoder/config.json) — a resolvable
|
|
11
|
+
* provider API key means "configured". No account system; `logout` is absent.
|
|
12
|
+
*
|
|
13
|
+
* `isConfigured` / `createSession` are injectable for tests.
|
|
14
|
+
*/
|
|
15
|
+
import { readFileSync } from "node:fs"
|
|
16
|
+
import { resolve } from "node:path"
|
|
17
|
+
import { loadConfig } from "./config.mjs"
|
|
18
|
+
import { assembleAgent } from "./cli/make-agent.mjs"
|
|
19
|
+
import { createAcpServer, ACP_ERRORS } from "./acp/transport.mjs"
|
|
20
|
+
import { createAcpSession } from "./acp/session.mjs"
|
|
21
|
+
import { replayHistory } from "./acp/bridge.mjs"
|
|
22
|
+
import { listSlots, applySession, deleteSlot, sessionPath, normalizeCwd, isLegacyTransient } from "./session.mjs"
|
|
23
|
+
|
|
24
|
+
const VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version
|
|
25
|
+
|
|
26
|
+
/** Load a specific slot file (not the active one) — session/load by id.
|
|
27
|
+
* Same validation as loadSession: version 1/2, cwd match, legacy-transient
|
|
28
|
+
* filtering (pre-filtering slot files must not leak machine lines into replay). */
|
|
29
|
+
function loadSlotFile(cwd, slot) {
|
|
30
|
+
const path = `${sessionPath(cwd)}.${slot}`
|
|
31
|
+
try {
|
|
32
|
+
const data = JSON.parse(readFileSync(path, "utf8"))
|
|
33
|
+
if (data?.version !== 1 && data?.version !== 2) return null
|
|
34
|
+
if (!Array.isArray(data.history)) return null
|
|
35
|
+
if (data.cwd && normalizeCwd(data.cwd).toLowerCase() !== normalizeCwd(cwd).toLowerCase()) return null
|
|
36
|
+
data.history = data.history.filter((m) => !isLegacyTransient(m))
|
|
37
|
+
return data
|
|
38
|
+
} catch {
|
|
39
|
+
return null
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Apply a session-level config option to the agent instance (memory only —
|
|
45
|
+
* the session's own runtime state, last-write-wins; not persisted to config.json).
|
|
46
|
+
* Returns true when the configId is known.
|
|
47
|
+
*/
|
|
48
|
+
function applyConfigOption(agent, configId, value) {
|
|
49
|
+
switch (configId) {
|
|
50
|
+
case "model": {
|
|
51
|
+
if (typeof value !== "string" || !value.trim()) return false
|
|
52
|
+
if (!agent.provider) return false // nothing to configure
|
|
53
|
+
// Split on the FIRST colon only — model names may contain colons.
|
|
54
|
+
const ci = value.indexOf(":")
|
|
55
|
+
const provider = ci >= 0 ? value.slice(0, ci) : null
|
|
56
|
+
const model = (ci >= 0 ? value.slice(ci + 1) : value).trim()
|
|
57
|
+
if (provider && provider !== agent.provider.name) agent.provider.name = provider
|
|
58
|
+
agent.provider.model = model
|
|
59
|
+
return true
|
|
60
|
+
}
|
|
61
|
+
case "thinking": {
|
|
62
|
+
if (typeof value !== "boolean" || !agent.provider) return false
|
|
63
|
+
agent.provider.thinking = value ? { type: "enabled" } : { type: "disabled" }
|
|
64
|
+
return true
|
|
65
|
+
}
|
|
66
|
+
case "mode": {
|
|
67
|
+
if (value !== "plan" && value !== "normal") return false
|
|
68
|
+
agent.planMode = value === "plan"
|
|
69
|
+
return true
|
|
70
|
+
}
|
|
71
|
+
default:
|
|
72
|
+
return false
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Config is "configured" when the active provider has a resolvable API key (env fallback included). */
|
|
77
|
+
export function defaultIsConfigured() {
|
|
78
|
+
try {
|
|
79
|
+
return !!loadConfig().provider?.apiKey?.trim()
|
|
80
|
+
} catch {
|
|
81
|
+
return false
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** M1/M2 session factory: one agent per session, built from the process cwd (single-cwd model).
|
|
86
|
+
* `id` is the ACP session id — it is baked into the callbacks at construction time
|
|
87
|
+
* (buildAcpCallbacks closure), so it must be known BEFORE createAcpSession runs.
|
|
88
|
+
* `request` is the transport's reverse-RPC channel (permissions + fs routing). */
|
|
89
|
+
export async function defaultCreateSession({ id, notify, request, log }) {
|
|
90
|
+
const agent = await assembleAgent()
|
|
91
|
+
return createAcpSession({ id, agent, notify, request, log })
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Build the ACP method handlers. Returns { handlers, sessions, notifyRef } —
|
|
96
|
+
* notifyRef.current is set by runAcpServer once the transport exists; sessions
|
|
97
|
+
* are created lazily (session/new), by which time the reference is live.
|
|
98
|
+
* @param {{ version?: string, notify?: (method, params) => void, log?: (s: string) => void,
|
|
99
|
+
* isConfigured?: () => boolean, createSession?: (ctx) => Promise<object> }} deps
|
|
100
|
+
*/
|
|
101
|
+
export function buildAcpHandlers({
|
|
102
|
+
version = VERSION,
|
|
103
|
+
notify = () => {},
|
|
104
|
+
log = () => {},
|
|
105
|
+
isConfigured = defaultIsConfigured,
|
|
106
|
+
createSession = defaultCreateSession,
|
|
107
|
+
cwd = () => process.cwd(),
|
|
108
|
+
}) {
|
|
109
|
+
const getCwd = cwd
|
|
110
|
+
const notifyRef = { current: notify }
|
|
111
|
+
const requestRef = { current: async () => { throw new Error("no request channel") } }
|
|
112
|
+
const sessions = new Map()
|
|
113
|
+
let nextId = 1
|
|
114
|
+
let authenticated = false
|
|
115
|
+
const findSession = (params) => {
|
|
116
|
+
const s = sessions.get(String(params?.sessionId))
|
|
117
|
+
if (!s) return { error: { ...ACP_ERRORS.INVALID_PARAMS, message: `unknown session ${params?.sessionId}` } }
|
|
118
|
+
return { session: s }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
handlers: {
|
|
123
|
+
initialize: () => ({
|
|
124
|
+
protocolVersion: 1,
|
|
125
|
+
agentInfo: { name: "thincoder", version },
|
|
126
|
+
authMethods: ["terminal"],
|
|
127
|
+
capabilities: { fs: { read: true, write: true }, terminal: false },
|
|
128
|
+
}),
|
|
129
|
+
|
|
130
|
+
authenticate: () => {
|
|
131
|
+
if (!isConfigured()) return { error: ACP_ERRORS.AUTH_REQUIRED }
|
|
132
|
+
authenticated = true
|
|
133
|
+
return { authenticated: true }
|
|
134
|
+
},
|
|
135
|
+
|
|
136
|
+
"session/new": async (params) => {
|
|
137
|
+
if (!authenticated) return { error: ACP_ERRORS.AUTH_REQUIRED }
|
|
138
|
+
if (params.cwd !== undefined && typeof params.cwd !== "string") {
|
|
139
|
+
return { error: { ...ACP_ERRORS.INVALID_PARAMS, message: "cwd must be a string" } }
|
|
140
|
+
}
|
|
141
|
+
// Normalized comparison: resolve() collapses trailing slashes, and
|
|
142
|
+
// normalizeCwd().toLowerCase() makes the check case-insensitive on
|
|
143
|
+
// Windows (drive letter + path — a client sending "c:\users\…" vs
|
|
144
|
+
// process.cwd() "C:\Users\…" must match). The ternary guards against
|
|
145
|
+
// resolve(undefined) — it would coerce undefined to the literal
|
|
146
|
+
// "undefined" and resolve a nonsense path. Note: `requested` never
|
|
147
|
+
// feeds any path operation — the agent always runs in getCwd() — so
|
|
148
|
+
// a case-insensitive match on case-sensitive platforms is harmless.
|
|
149
|
+
const norm = (p) => normalizeCwd(p).toLowerCase()
|
|
150
|
+
const requested = params.cwd ? resolve(params.cwd) : getCwd()
|
|
151
|
+
if (norm(requested) !== norm(getCwd())) {
|
|
152
|
+
return { error: { ...ACP_ERRORS.INVALID_PARAMS, message: `v1: cwd must equal the process working directory (${getCwd()})` } }
|
|
153
|
+
}
|
|
154
|
+
if (params.mcpServers?.length) {
|
|
155
|
+
log(`[acp] MCP forwarding is M2 scope — ignoring ${params.mcpServers.length} server(s)`)
|
|
156
|
+
}
|
|
157
|
+
try {
|
|
158
|
+
const id = String(nextId++)
|
|
159
|
+
const session = await createSession({ id, notify: notifyRef.current, request: requestRef.current, log })
|
|
160
|
+
// `id` is immutable after construction (baked into the callbacks) — never reassign.
|
|
161
|
+
sessions.set(id, session)
|
|
162
|
+
return { id, configOptions: [{ configId: "model" }, { configId: "thinking" }, { configId: "mode" }] }
|
|
163
|
+
} catch (e) {
|
|
164
|
+
return { error: { code: ACP_ERRORS.INTERNAL.code, message: `failed to create session: ${e.message}` } }
|
|
165
|
+
}
|
|
166
|
+
},
|
|
167
|
+
|
|
168
|
+
"session/prompt": async (params) => {
|
|
169
|
+
if (!authenticated) return { error: ACP_ERRORS.AUTH_REQUIRED }
|
|
170
|
+
const found = findSession(params)
|
|
171
|
+
if (found.error) return found
|
|
172
|
+
const blocks = Array.isArray(params?.content) ? params.content : []
|
|
173
|
+
const text = blocks.find((b) => b?.type === "text")?.text ?? ""
|
|
174
|
+
if (!text) return { error: { ...ACP_ERRORS.INVALID_PARAMS, message: "prompt requires a text content block" } }
|
|
175
|
+
try {
|
|
176
|
+
await found.session.run(text)
|
|
177
|
+
return { stopReason: "end_turn" }
|
|
178
|
+
} catch (e) {
|
|
179
|
+
// Cancelled/interrupted turns are not errors on the wire.
|
|
180
|
+
if (e?.name === "AbortError" || e?.code === "ABORT_ERR") return { stopReason: "cancelled" }
|
|
181
|
+
return { error: { code: ACP_ERRORS.INTERNAL.code, message: e?.message ?? String(e) } }
|
|
182
|
+
}
|
|
183
|
+
},
|
|
184
|
+
|
|
185
|
+
"session/cancel": (params) => {
|
|
186
|
+
const found = findSession(params)
|
|
187
|
+
if (found.error) return found
|
|
188
|
+
found.session.cancel()
|
|
189
|
+
return {}
|
|
190
|
+
},
|
|
191
|
+
|
|
192
|
+
"session/close": (params) => {
|
|
193
|
+
const found = findSession(params)
|
|
194
|
+
if (!found.error) {
|
|
195
|
+
// Abort any in-flight turn first — the client is gone, the agent must
|
|
196
|
+
// stop consuming LLM tokens and emitting notifications.
|
|
197
|
+
found.session.cancel()
|
|
198
|
+
sessions.delete(String(params.sessionId))
|
|
199
|
+
log(`session ${params.sessionId} closed by client`)
|
|
200
|
+
}
|
|
201
|
+
return {}
|
|
202
|
+
},
|
|
203
|
+
|
|
204
|
+
// ─── M3: persisted slots (thincoder session archive) + config options ───
|
|
205
|
+
|
|
206
|
+
"session/list": () => {
|
|
207
|
+
if (!authenticated) return { error: ACP_ERRORS.AUTH_REQUIRED }
|
|
208
|
+
const slots = listSlots(getCwd())
|
|
209
|
+
return {
|
|
210
|
+
sessions: slots.map((s) => ({
|
|
211
|
+
id: String(s.slot),
|
|
212
|
+
cwd: getCwd(), // single-cwd model (design §4.5)
|
|
213
|
+
updatedAt: s.updatedAt ?? 0,
|
|
214
|
+
title: s.title ?? "",
|
|
215
|
+
messageCount: s.messageCount ?? 0,
|
|
216
|
+
})),
|
|
217
|
+
}
|
|
218
|
+
},
|
|
219
|
+
|
|
220
|
+
"session/load": async (params) => {
|
|
221
|
+
if (!authenticated) return { error: ACP_ERRORS.AUTH_REQUIRED }
|
|
222
|
+
const slot = Number(params.sessionId)
|
|
223
|
+
if (!Number.isInteger(slot) || slot < 1) {
|
|
224
|
+
return { error: { ...ACP_ERRORS.INVALID_PARAMS, message: `invalid session id: ${params.sessionId}` } }
|
|
225
|
+
}
|
|
226
|
+
const data = loadSlotFile(getCwd(), slot)
|
|
227
|
+
if (!data) {
|
|
228
|
+
return { error: { ...ACP_ERRORS.INVALID_PARAMS, message: `session ${slot} not found (corrupt or deleted)` } }
|
|
229
|
+
}
|
|
230
|
+
try {
|
|
231
|
+
const id = String(nextId++)
|
|
232
|
+
const session = await createSession({ id, notify: notifyRef.current, request: requestRef.current, log })
|
|
233
|
+
applySession(session.agent, data)
|
|
234
|
+
sessions.set(id, session)
|
|
235
|
+
// Replay the human line (role → chunk mapping, design §4.5) so the
|
|
236
|
+
// client renders the restored conversation.
|
|
237
|
+
replayHistory({ sessionId: id, notify: notifyRef.current, history: data.history, log })
|
|
238
|
+
log(`session ${slot} loaded as session ${id} (${data.history?.length ?? 0} messages replayed)`)
|
|
239
|
+
return { id, cwd: getCwd(), configOptions: [{ configId: "model" }, { configId: "thinking" }, { configId: "mode" }] }
|
|
240
|
+
} catch (e) {
|
|
241
|
+
return { error: { code: ACP_ERRORS.INTERNAL.code, message: `failed to load session ${slot}: ${e.message}` } }
|
|
242
|
+
}
|
|
243
|
+
},
|
|
244
|
+
|
|
245
|
+
"session/resume": async (params) => {
|
|
246
|
+
if (!authenticated) return { error: ACP_ERRORS.AUTH_REQUIRED }
|
|
247
|
+
const slot = Number(params.sessionId)
|
|
248
|
+
if (!Number.isInteger(slot) || slot < 1) {
|
|
249
|
+
return { error: { ...ACP_ERRORS.INVALID_PARAMS, message: `invalid session id: ${params.sessionId}` } }
|
|
250
|
+
}
|
|
251
|
+
const data = loadSlotFile(getCwd(), slot)
|
|
252
|
+
if (!data) {
|
|
253
|
+
return { error: { ...ACP_ERRORS.INVALID_PARAMS, message: `session ${slot} not found (corrupt or deleted)` } }
|
|
254
|
+
}
|
|
255
|
+
try {
|
|
256
|
+
const id = String(nextId++)
|
|
257
|
+
const session = await createSession({ id, notify: notifyRef.current, request: requestRef.current, log })
|
|
258
|
+
applySession(session.agent, data)
|
|
259
|
+
sessions.set(id, session)
|
|
260
|
+
// resume: no history replay — the client keeps its own rendering.
|
|
261
|
+
log(`session ${slot} resumed as session ${id} (no replay)`)
|
|
262
|
+
return { id, cwd: getCwd(), configOptions: [{ configId: "model" }, { configId: "thinking" }, { configId: "mode" }] }
|
|
263
|
+
} catch (e) {
|
|
264
|
+
return { error: { code: ACP_ERRORS.INTERNAL.code, message: `failed to resume session ${slot}: ${e.message}` } }
|
|
265
|
+
}
|
|
266
|
+
},
|
|
267
|
+
|
|
268
|
+
"session/delete": (params) => {
|
|
269
|
+
if (!authenticated) return { error: ACP_ERRORS.AUTH_REQUIRED }
|
|
270
|
+
const slot = Number(params.sessionId)
|
|
271
|
+
if (!Number.isInteger(slot) || slot < 1) {
|
|
272
|
+
return { error: { ...ACP_ERRORS.INVALID_PARAMS, message: `invalid session id: ${params.sessionId}` } }
|
|
273
|
+
}
|
|
274
|
+
// Only the persisted archive is removed; an active in-memory session
|
|
275
|
+
// with the same id keeps running (design §4.5).
|
|
276
|
+
if (!deleteSlot(getCwd(), slot)) {
|
|
277
|
+
return { error: { ...ACP_ERRORS.INVALID_PARAMS, message: `session ${slot} not found` } }
|
|
278
|
+
}
|
|
279
|
+
log(`session ${slot} archive deleted`)
|
|
280
|
+
return {}
|
|
281
|
+
},
|
|
282
|
+
|
|
283
|
+
"session/set_config_option": (params) => {
|
|
284
|
+
if (!authenticated) return { error: ACP_ERRORS.AUTH_REQUIRED }
|
|
285
|
+
const found = findSession(params)
|
|
286
|
+
if (found.error) return found
|
|
287
|
+
const { configId, value } = params
|
|
288
|
+
if (!configId || value === undefined) {
|
|
289
|
+
return { error: { ...ACP_ERRORS.INVALID_PARAMS, message: "set_config_option requires configId and value" } }
|
|
290
|
+
}
|
|
291
|
+
const applied = applyConfigOption(found.session.agent, configId, value)
|
|
292
|
+
if (!applied) {
|
|
293
|
+
return { error: { ...ACP_ERRORS.INVALID_PARAMS, message: `unknown configId: ${configId}` } }
|
|
294
|
+
}
|
|
295
|
+
// Last-write-wins on the internal state; notify the client of the change.
|
|
296
|
+
notifyRef.current("session/update", {
|
|
297
|
+
sessionId: String(params.sessionId),
|
|
298
|
+
update: { sessionUpdate: "config_option_update", configId, value },
|
|
299
|
+
})
|
|
300
|
+
return {}
|
|
301
|
+
},
|
|
302
|
+
|
|
303
|
+
"session/set_mode": (params) => {
|
|
304
|
+
if (!authenticated) return { error: ACP_ERRORS.AUTH_REQUIRED }
|
|
305
|
+
const found = findSession(params)
|
|
306
|
+
if (found.error) return found
|
|
307
|
+
if (params.mode !== "plan" && params.mode !== "normal") {
|
|
308
|
+
return { error: { ...ACP_ERRORS.INVALID_PARAMS, message: "mode must be plan or normal" } }
|
|
309
|
+
}
|
|
310
|
+
found.session.agent.planMode = params.mode === "plan"
|
|
311
|
+
notifyRef.current("session/update", {
|
|
312
|
+
sessionId: String(params.sessionId),
|
|
313
|
+
update: { sessionUpdate: "current_mode_update", mode: params.mode },
|
|
314
|
+
})
|
|
315
|
+
return {}
|
|
316
|
+
},
|
|
317
|
+
},
|
|
318
|
+
sessions,
|
|
319
|
+
notifyRef,
|
|
320
|
+
requestRef,
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** `thincoder acp` — start the server and block until the client closes the pipe. */
|
|
325
|
+
export async function runAcpServer() {
|
|
326
|
+
const log = (...a) => process.stderr.write(a.join(" ") + "\n")
|
|
327
|
+
// Build handlers first, then wire the transport — no window where requests
|
|
328
|
+
// hit an empty handler map. notifyRef/requestRef become live with the server.
|
|
329
|
+
const built = buildAcpHandlers({ log })
|
|
330
|
+
const server = createAcpServer(built.handlers, { log })
|
|
331
|
+
built.notifyRef.current = server.notify
|
|
332
|
+
built.requestRef.current = server.request
|
|
333
|
+
log(`[acp] thincoder ${VERSION} — ACP v1 over stdio, waiting for initialize`)
|
|
334
|
+
server.start()
|
|
335
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* citations.mjs — host-verified citation checking (decision d698434).
|
|
3
|
+
* Extracted from advisor/run.mjs (file-size split). The evidence rule becomes
|
|
4
|
+
* a host fact: every `file:line: content` reference in a review is mechanically
|
|
5
|
+
* checked against the CURRENT disk state; mismatches mark the finding
|
|
6
|
+
* unverified and cannot support a push-back.
|
|
7
|
+
*/
|
|
8
|
+
import { readFileSync, realpathSync } from "node:fs"
|
|
9
|
+
import { resolve, sep } from "node:path"
|
|
10
|
+
|
|
11
|
+
// `file:line: content` citations — the file group is narrowed to source/config
|
|
12
|
+
// extensions so URLs (`example.com:8080: …`) don't become false-positive
|
|
13
|
+
// citations that fail as "file unreadable" in the verification report.
|
|
14
|
+
// Single-letter extensions (c/h) are kept — false positives (e.g. "a.c:1: x")
|
|
15
|
+
// are rare and only add a failed-citation line to the report; the report is
|
|
16
|
+
// advisory for the parent agent, never a crash path.
|
|
17
|
+
const CITATION_RE = /([\w./\\-]+\.(?:mjs|cjs|js|ts|jsx|tsx|mts|cts|py|rs|go|c|h|cpp|hpp|java|rb|php|sh|bash|json|md|markdown|mdx|yaml|yml|toml|css|html)):(\d+):\s*([^`\n]{4,})/g
|
|
18
|
+
|
|
19
|
+
/** Extract `file:line: content` citations from a review text. */
|
|
20
|
+
export function extractCitations(text) {
|
|
21
|
+
const out = []
|
|
22
|
+
for (const m of text.matchAll(CITATION_RE)) {
|
|
23
|
+
out.push({ file: m[1], line: Number(m[2]), content: m[3].trim() })
|
|
24
|
+
}
|
|
25
|
+
return out
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Mechanically verify citations against the CURRENT file state: read the file,
|
|
30
|
+
* take the exact line, check it CONTAINS the quoted content. Reports
|
|
31
|
+
* N/M matched + the mismatches. Unverified citations cannot support a
|
|
32
|
+
* push-back — the evidence rule becomes a host fact, not a prompt wish.
|
|
33
|
+
*/
|
|
34
|
+
export function verifyCitations(text, cwd) {
|
|
35
|
+
const citations = extractCitations(text)
|
|
36
|
+
const matched = []
|
|
37
|
+
const failed = []
|
|
38
|
+
const root = resolve(cwd) + sep
|
|
39
|
+
for (const c of citations) {
|
|
40
|
+
try {
|
|
41
|
+
// Path confinement: citation paths are LLM-generated — never trust them.
|
|
42
|
+
// A hallucinated "../config.json" would otherwise read (and leak via the
|
|
43
|
+
// report) files outside the project, including API-key configs.
|
|
44
|
+
// realpathSync resolves symlinks too — a link inside the project that
|
|
45
|
+
// points outside must not pass the prefix check.
|
|
46
|
+
const resolved = realpathSync(resolve(cwd, c.file))
|
|
47
|
+
if (!resolved.startsWith(root)) {
|
|
48
|
+
failed.push({ ...c, reason: "path traversal" })
|
|
49
|
+
continue
|
|
50
|
+
}
|
|
51
|
+
const line = readFileSync(resolved, "utf8").split("\n")[c.line - 1] ?? ""
|
|
52
|
+
if (line.includes(c.content)) matched.push(c)
|
|
53
|
+
else failed.push(c)
|
|
54
|
+
} catch {
|
|
55
|
+
failed.push({ ...c, reason: "file unreadable" })
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return { total: citations.length, matched, failed }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Append the verification report to the review text (visible to the parent agent). */
|
|
62
|
+
export function appendCitationReport(text, cwd) {
|
|
63
|
+
const { total, matched, failed } = verifyCitations(text, cwd)
|
|
64
|
+
if (total === 0) return text // no citations — nothing to verify
|
|
65
|
+
const lines = [
|
|
66
|
+
"",
|
|
67
|
+
"---",
|
|
68
|
+
`[host-verified] ${matched.length}/${total} citations match current file state.`,
|
|
69
|
+
]
|
|
70
|
+
if (failed.length > 0) {
|
|
71
|
+
lines.push("Citations that do NOT match the current file state (treat their claims as unverified):")
|
|
72
|
+
for (const f of failed.slice(0, 10)) {
|
|
73
|
+
lines.push(`- ${f.file}:${f.line}: ${f.content.slice(0, 80)}${f.reason ? ` (${f.reason})` : ""}`)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return text + lines.join("\n")
|
|
77
|
+
}
|
package/src/advisor/history.mjs
CHANGED
|
@@ -6,6 +6,8 @@ import { join } from "node:path"
|
|
|
6
6
|
|
|
7
7
|
export const ADVISOR_MD_PATH = ".thincoder/advisor.md"
|
|
8
8
|
export const ADVISOR_TABLE_HEADER = "| # | File | Severity | Issue | Suggestion |"
|
|
9
|
+
// Design-review table header (advisor-design.md round 1): | # | Category | Severity | Issue | Suggestion |
|
|
10
|
+
const DESIGN_TABLE_HEADER = "| # | Category | Severity | Issue | Suggestion |"
|
|
9
11
|
const CONVERGENCE_TABLE_HEADER = "| # | Orig# | File | Severity | Status | Notes |"
|
|
10
12
|
const AGENT_RESPONSE_HEADER = "| # | Action | Detail |"
|
|
11
13
|
export const LEGACY_ADVISOR_HEADER = "| # | 文件 | 严重程度 | 问题描述 | 建议修复 |"
|
|
@@ -25,11 +27,19 @@ const DEFAULT_CRITERIA = `Review the code changes, focusing on:
|
|
|
25
27
|
* Returns null when: no advisor call, empty output, or the last review is
|
|
26
28
|
* all-clear (nothing to follow up on).
|
|
27
29
|
*/
|
|
30
|
+
// All-clear phrases the prompts instruct the advisor to use on a clean review.
|
|
31
|
+
// Used ONLY by extractPriorIssueTable (issue-table verdict — a phrase-free
|
|
32
|
+
// issue table with rows is never all-clear). The round-reset guard no longer
|
|
33
|
+
// depends on model output at all: prepareAdvisorMessages decides by the
|
|
34
|
+
// deterministic _mutatedThisRun flag (user decision 2026-08-05). "no new
|
|
35
|
+
// issues" is DELIBERATELY absent — verification-table outputs (round 2+)
|
|
36
|
+
// commonly conclude with it (round 3+ instructions even SAY "do not look for
|
|
37
|
+
// new issues"); treating it as all-clear would reset the convergence budget
|
|
38
|
+
// after every round-2 review (the observed "always round 2" bug: prior → null
|
|
39
|
+
// → _advisorRound reset → 1→2→1…).
|
|
40
|
+
const ALL_CLEAR_PHRASES = ["no 🔴", "all clear", "全部通过", "review passed", "no issues found", "everything is fine"]
|
|
41
|
+
|
|
28
42
|
export function extractPriorIssueTable(history) {
|
|
29
|
-
// allClear: exact phrases the prompts instruct the advisor to use on a clean review.
|
|
30
|
-
// NOTE: "已修复" (Fixed) is NOT here — it's a per-row Status value in convergence tables,
|
|
31
|
-
// and a mixed table must continue convergence even if some rows are Fixed.
|
|
32
|
-
const allClear = ["no 🔴", "all clear", "全部通过", "review passed", "no issues found", "no new issues"]
|
|
33
43
|
// Negative signals: a table listing SOME items as unfixed is NOT all-clear.
|
|
34
44
|
// Applied when the message carries a Status column (English or Chinese convergence
|
|
35
45
|
// format) — "failed"/"❌" in a round-1 Issue description must NOT trigger it.
|
|
@@ -44,6 +54,7 @@ export function extractPriorIssueTable(history) {
|
|
|
44
54
|
// header constants' own source code (e.g. history.mjs), producing a phantom
|
|
45
55
|
// "prior issue table" and re-opening convergence rounds against stale data.
|
|
46
56
|
if (!lineHasHeader(m.content, ADVISOR_TABLE_HEADER)
|
|
57
|
+
&& !lineHasHeader(m.content, DESIGN_TABLE_HEADER)
|
|
47
58
|
&& !lineHasHeader(m.content, CONVERGENCE_TABLE_HEADER)
|
|
48
59
|
&& !lineHasHeader(m.content, LEGACY_ADVISOR_HEADER)) continue
|
|
49
60
|
const text = m.content
|
|
@@ -52,8 +63,16 @@ export function extractPriorIssueTable(history) {
|
|
|
52
63
|
// column) — Chinese legacy tables lack it, but they are issue tables, not convergence.
|
|
53
64
|
const hasStatusColumn = lineHasHeader(text, CONVERGENCE_TABLE_HEADER)
|
|
54
65
|
|| /Status|状态/.test(text.slice(0, text.indexOf("\n") + 1))
|
|
55
|
-
if (hasStatusColumn
|
|
56
|
-
|
|
66
|
+
if (hasStatusColumn) {
|
|
67
|
+
// Convergence/verification table: pass/fail is row-level — free-text
|
|
68
|
+
// phrases like "no new issues found" / "review passed" are partial or
|
|
69
|
+
// boilerplate statements in verification outputs. Some row unfixed →
|
|
70
|
+
// convergence continues; every row fixed → all clear (fresh cycle).
|
|
71
|
+
if (partiallyFixedRe.test(lower)) return { text, sinceIdx: i }
|
|
72
|
+
return null
|
|
73
|
+
}
|
|
74
|
+
// Issue table (round 1 / design): phrase-based all-clear detection.
|
|
75
|
+
if (ALL_CLEAR_PHRASES.some((s) => lower.includes(s))) return null
|
|
57
76
|
// sinceIdx = the advisor call's own index; extractAgentResponseTable skips it (role !== assistant)
|
|
58
77
|
return { text, sinceIdx: i }
|
|
59
78
|
}
|
package/src/advisor/messages.mjs
CHANGED
|
@@ -4,30 +4,31 @@
|
|
|
4
4
|
* (.thincoder/advisor.md). System prompts live in advisor.mjs / prompts/.
|
|
5
5
|
*/
|
|
6
6
|
import { readFileSync } from "node:fs"
|
|
7
|
-
import { resolve } from "node:path"
|
|
7
|
+
import { resolve, join, relative } from "node:path"
|
|
8
8
|
import { findReviewRepos, collectRepoSnapshots, collectChangedFiles } from "./repos.mjs"
|
|
9
9
|
import { loadAdvisorMd, extractConversationBackground, extractAgentResponseTable, extractPriorIssueTable } from "./history.mjs"
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
12
|
* Build the user message for an advisor review session.
|
|
13
13
|
* @param {Object} agent — the parent agent
|
|
14
|
-
* @param {Object|null} [
|
|
14
|
+
* @param {Object|null} [prior] — prior issue table
|
|
15
15
|
* @param {string} [reviewType] — "design" or "code" (default)
|
|
16
16
|
* @param {string|null} [designToken] — token injected into the design-review prompt; the advisor echoes it only on approval
|
|
17
17
|
* @param {string[]|null} [documents] — design review only: explicit list of doc paths to review (requirements + design + referenced docs).
|
|
18
18
|
* When set, the review input is built from this list ONLY — no git-diff change-set collection.
|
|
19
19
|
* When absent, the legacy git-diff-based scope is kept (backward compatible).
|
|
20
|
+
* @param {string[]|null} [paths] — code review only: explicit list of file/dir paths to review (deduped; shown under Review Scope)
|
|
20
21
|
* @returns {string} the user message
|
|
21
22
|
*/
|
|
22
|
-
export function buildAdvisorUserMessage(agent,
|
|
23
|
-
const
|
|
23
|
+
export function buildAdvisorUserMessage(agent, prior, reviewType, designToken = null, documents = null, paths = null) {
|
|
24
|
+
const p = prior ?? extractPriorIssueTable(agent.history)
|
|
24
25
|
|
|
25
26
|
const parts = []
|
|
26
27
|
const docList = Array.isArray(documents) ? documents.filter((d) => typeof d === "string" && d.trim()) : []
|
|
27
|
-
const pathList = Array.isArray(paths) ? paths.filter((p) => typeof p === "string" && p.trim()) : []
|
|
28
|
+
const pathList = Array.isArray(paths) ? [...new Set(paths.filter((p) => typeof p === "string" && p.trim()))] : []
|
|
28
29
|
|
|
29
30
|
// Design review: simplified message — focus on the design doc, not code
|
|
30
|
-
if (reviewType === "design") {
|
|
31
|
+
if (reviewType === "design" && (agent._advisorRound || 0) === 0) {
|
|
31
32
|
const repos = findReviewRepos(agent)
|
|
32
33
|
parts.push("## Design Review")
|
|
33
34
|
if (docList.length > 0) {
|
|
@@ -95,25 +96,35 @@ export function buildAdvisorUserMessage(agent, _prior, reviewType, designToken =
|
|
|
95
96
|
return parts.join("\n")
|
|
96
97
|
}
|
|
97
98
|
|
|
98
|
-
// Convergence data (round 2+).
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
99
|
+
// Convergence data (round 2+). LEGACY COMPATIBILITY PATH: the normal advisor
|
|
100
|
+
// flow routes convergence rounds through buildAdvisorFollowUp (fresh session,
|
|
101
|
+
// decision d698434); this block only fires for direct external callers of
|
|
102
|
+
// buildAdvisorUserMessage with a prior table. Kept to avoid breaking those.
|
|
103
|
+
// Same rule as buildAdvisorFollowUp: prior table IS injected (decision
|
|
104
|
+
// 2026-08-05, reversed) — it is the only complete verification list.
|
|
105
|
+
if (p && (agent._advisorRound || 0) > 0) {
|
|
106
|
+
const scopeFiles = resolveScopeFiles(agent, paths)
|
|
107
|
+
const response = extractAgentResponseTable(agent.history, p.sinceIdx)
|
|
108
|
+
|| (scopeFiles?.length
|
|
109
|
+
? "(Agent did not provide a response table — perform a fresh review of: " + scopeFiles.slice(0, 10).join(", ") + ")"
|
|
110
|
+
: "(Agent did not provide a response table — perform a fresh review of the files named in the system prompt context)")
|
|
102
111
|
const round = (agent._advisorRound || 0) + 1
|
|
103
112
|
const label = round === 2 ? "Verify Prior Table + Flag New Issues" : "Strict Verification"
|
|
104
113
|
parts.push(`## Round ${round} — ${label}`)
|
|
105
114
|
parts.push("")
|
|
106
|
-
parts.push("## Prior Issue Table")
|
|
107
|
-
parts.push(
|
|
115
|
+
parts.push("## Prior Issue Table (verify every item)")
|
|
116
|
+
parts.push(p.text)
|
|
108
117
|
parts.push("")
|
|
109
|
-
parts.push("## Agent Response")
|
|
118
|
+
parts.push("## Agent Response (fix claims — reference only)")
|
|
110
119
|
parts.push(response)
|
|
111
120
|
parts.push("")
|
|
112
121
|
parts.push("---")
|
|
113
122
|
parts.push("")
|
|
114
123
|
}
|
|
115
124
|
|
|
116
|
-
|
|
125
|
+
if (pathList.length > 0 || docList.length > 0) {
|
|
126
|
+
parts.push("## Review Scope")
|
|
127
|
+
}
|
|
117
128
|
if (pathList.length > 0) {
|
|
118
129
|
parts.push("Review these code files/directories — read them in full for context:")
|
|
119
130
|
parts.push("")
|
|
@@ -159,18 +170,15 @@ export function buildAdvisorUserMessage(agent, _prior, reviewType, designToken =
|
|
|
159
170
|
}
|
|
160
171
|
|
|
161
172
|
// Instructions — round-aware: re-reviews skip convention discovery entirely
|
|
162
|
-
const isReReview =
|
|
173
|
+
const isReReview = p && (agent._advisorRound || 0) > 0
|
|
163
174
|
parts.push("## Instructions")
|
|
164
|
-
parts.push("1. IMPORTANT:
|
|
175
|
+
parts.push("1. IMPORTANT: the review scope lists the files under review — always verify current file state with `read` before judging. Never decide based on earlier snapshots alone.")
|
|
165
176
|
if (isReReview) {
|
|
166
|
-
|
|
167
|
-
parts.push(
|
|
168
|
-
parts.push("4. `read` the files in the Review Scope in full — ALWAYS, regardless of what `git diff` shows. Batch reads/greps in a single reply.")
|
|
169
|
-
parts.push("5. Evidence rule: every 'Unfixed'/'New' finding MUST quote the exact line content from THIS round's `read` output (e.g. `run.mjs:180: timeoutId = setTimeout(...)`). Line numbers alone are NOT evidence — they may come from the stale prior table. Findings without a fresh quoted line are treated as unverified and will not be accepted.")
|
|
170
|
-
parts.push("6. Produce your verification table. Do not re-read content you already have.")
|
|
177
|
+
const round = (agent._advisorRound || 0) + 1
|
|
178
|
+
parts.push(...buildConvergenceInstructions(round, pathList))
|
|
171
179
|
} else {
|
|
172
180
|
parts.push("2. Read `AGENTS.md` / design docs only if they exist (check once; do not re-probe with multiple patterns).")
|
|
173
|
-
parts.push("3. `read`
|
|
181
|
+
parts.push("3. `read` the files in the Review Scope in full — they define exactly what to inspect. Batch independent reads/greps in a single reply instead of one call per round-trip.")
|
|
174
182
|
parts.push("4. Use `grep` or `lsp` to trace callers, imports, and dependencies — only where the diff leaves genuine doubt.")
|
|
175
183
|
parts.push("5. Produce your review table based on the review criteria above. Do not re-read content you already have.")
|
|
176
184
|
parts.push("6. You may also flag other issues: crashes, data loss, logic errors — anything obvious. This is the convergence protocol: round 1 is the full review, later rounds only re-verify.")
|
|
@@ -180,3 +188,49 @@ export function buildAdvisorUserMessage(agent, _prior, reviewType, designToken =
|
|
|
180
188
|
|
|
181
189
|
return parts.join("\n")
|
|
182
190
|
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Resolve the review surface for the convergence fallback: explicit `paths`
|
|
194
|
+
* win; otherwise the runtime mutation record (_touchedFiles, ABSOLUTE) is
|
|
195
|
+
* normalized to cwd-relative so the fallback list matches the relative-path
|
|
196
|
+
* norm the reviewer sees everywhere else. Paths outside cwd are relativized
|
|
197
|
+
* with path.relative — never a mixed absolute/relative list.
|
|
198
|
+
*/
|
|
199
|
+
export function resolveScopeFiles(agent, paths) {
|
|
200
|
+
const normalize = (p) => {
|
|
201
|
+
const abs = p.startsWith(agent.cwd) ? p : join(agent.cwd, p)
|
|
202
|
+
return relative(agent.cwd, abs)
|
|
203
|
+
}
|
|
204
|
+
if (Array.isArray(paths)) return [...new Set(paths.map(normalize))]
|
|
205
|
+
if (agent._touchedFiles?.length) {
|
|
206
|
+
return [...new Set(agent._touchedFiles.map(normalize))]
|
|
207
|
+
}
|
|
208
|
+
return null
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Shared convergence-round instructions — single source for BOTH paths
|
|
213
|
+
* (buildAdvisorUserMessage's legacy convergence block and
|
|
214
|
+
* buildAdvisorFollowUp), so the wording cannot diverge.
|
|
215
|
+
* Round 2 may flag obvious new issues; round 3+ is strict verification.
|
|
216
|
+
* @param {number} round — convergence round number (2+)
|
|
217
|
+
* @param {string[]|null} scopeFiles — optional file list for the no-response fallback
|
|
218
|
+
* @returns {string[]} the numbered instruction lines (callers spread them)
|
|
219
|
+
*/
|
|
220
|
+
export function buildConvergenceInstructions(round, scopeFiles = null) {
|
|
221
|
+
const fileList = scopeFiles?.length
|
|
222
|
+
? ` The review surface is: ${scopeFiles.slice(0, 10).join(", ")}.`
|
|
223
|
+
: ""
|
|
224
|
+
return [
|
|
225
|
+
`1. IMPORTANT: verify EVERY item of the prior issue table against the CURRENT FILE STATE with \`read\` — never decide based on earlier snapshots alone.${fileList}`,
|
|
226
|
+
"2. STALE-CONTEXT WARNING: any diff or file content from earlier messages is a historical snapshot — treat it as expired. Only fresh `read` results describe the current state.",
|
|
227
|
+
"3. You have no git tool; git output in earlier messages is historical and untrustworthy (committed fixes never show in a diff).",
|
|
228
|
+
"4. `read` the files named in the prior table (or the review surface above) in full — ALWAYS. Batch reads/greps in a single reply.",
|
|
229
|
+
"5. Evidence rule: every 'Unfixed'/'New' finding MUST quote the exact line content from THIS round's `read` output (e.g. `run.mjs:180: timeoutId = setTimeout(...)`). Line numbers alone are NOT evidence — they may be stale or fabricated. Findings without a fresh quoted line are treated as unverified and will not be accepted.",
|
|
230
|
+
"6. Produce your verification table. Do not re-read content you already have.",
|
|
231
|
+
round === 2
|
|
232
|
+
? "7. You may flag obvious NEW issues introduced by the fixes (crashes, data loss, logic errors — not style)."
|
|
233
|
+
: "7. Do NOT look for new issues.",
|
|
234
|
+
]
|
|
235
|
+
}
|
|
236
|
+
|