thincoder 0.12.11 → 0.12.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/convergence.mjs +80 -0
- package/src/advisor/history.mjs +20 -55
- package/src/advisor/messages.mjs +182 -36
- package/src/advisor/repos.mjs +40 -0
- package/src/advisor/run.mjs +201 -95
- package/src/advisor.mjs +192 -99
- package/src/agent/completion.mjs +10 -10
- 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 +25 -32
- package/src/config.mjs +13 -5
- package/src/context.mjs +32 -9
- package/src/prompts/advisor-round1.md +9 -3
- package/src/prompts/advisor-round2.md +10 -8
- package/src/prompts/advisor-round3.md +14 -14
- 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
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* advisor/convergence.mjs — shared convergence-round message building (round 2+).
|
|
3
|
+
* SINGLE source for the round-2+ sections used by BOTH the normal flow
|
|
4
|
+
* (buildAdvisorFollowUp in advisor.mjs) and the legacy path (buildAdvisorUserMessage
|
|
5
|
+
* in messages.mjs) — fixes must not be replicated in two places. Lives in its
|
|
6
|
+
* own module to avoid the messages.mjs ↔ advisor.mjs import cycle.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Shared convergence-round instructions — single source for BOTH paths
|
|
11
|
+
* (buildAdvisorUserMessage's legacy convergence block and
|
|
12
|
+
* buildAdvisorFollowUp), so the wording cannot diverge.
|
|
13
|
+
* Round 2 may flag obvious new issues; round 3+ is strict verification.
|
|
14
|
+
* @param {number} round — convergence round number (2+)
|
|
15
|
+
* @param {string[]|null} scopeFiles — optional file list for the no-response fallback
|
|
16
|
+
* @returns {string[]} the numbered instruction lines (callers spread them)
|
|
17
|
+
*/
|
|
18
|
+
export function buildConvergenceInstructions(round, scopeFiles = null) {
|
|
19
|
+
const fileList = scopeFiles?.length
|
|
20
|
+
? ` The review surface is: ${scopeFiles.slice(0, 10).join(", ")}.`
|
|
21
|
+
: ""
|
|
22
|
+
return [
|
|
23
|
+
`1. IMPORTANT: verify EVERY item of the prior review output against the CURRENT FILE STATE with \`read\` — never decide based on earlier snapshots alone.${fileList}`,
|
|
24
|
+
"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.",
|
|
25
|
+
"3. You have no git tool; git output in earlier messages is historical and untrustworthy (committed fixes never show in a diff).",
|
|
26
|
+
"4. `read` the files named in the prior review output (or the review surface above) in full — ALWAYS. Batch reads/greps in a single reply.",
|
|
27
|
+
"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.",
|
|
28
|
+
"6. Produce your verification table. Do not re-read content you already have.",
|
|
29
|
+
round === 2
|
|
30
|
+
? "7. You may flag obvious NEW issues introduced by the fixes (crashes, data loss, logic errors — not style)."
|
|
31
|
+
: "7. Do NOT look for new issues.",
|
|
32
|
+
]
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Shared convergence body — SINGLE source for the round-2+ message sections
|
|
37
|
+
* (decision 2026-08-08): the FULL verbatim prior review output is the only
|
|
38
|
+
* complete verification list; the agent response table is a focus aid only.
|
|
39
|
+
* Used by buildAdvisorFollowUp (the normal flow) and the legacy path in
|
|
40
|
+
* messages.mjs (direct external callers of buildAdvisorUserMessage) — fixes
|
|
41
|
+
* must not be replicated in two places.
|
|
42
|
+
* @param {string} p — full prior review output (verbatim)
|
|
43
|
+
* @param {string} response — agent fix-claims table (or fallback text)
|
|
44
|
+
* @param {number} round — next round number (>= 2)
|
|
45
|
+
* @param {string[]|null} [scopeFiles] — review surface for instructions
|
|
46
|
+
* @returns {string} the convergence message
|
|
47
|
+
*/
|
|
48
|
+
export function buildConvergenceBody(p, response, round, scopeFiles) {
|
|
49
|
+
const label = round === 2 ? "Verify Prior Table + Flag New Issues" : "Strict Verification"
|
|
50
|
+
const reminder = round === 2
|
|
51
|
+
? "verify every item in the prior review output and flag only obvious new issues introduced by the fixes"
|
|
52
|
+
: "strictly verify only the prior review output — do NOT look for new issues"
|
|
53
|
+
const parts = [
|
|
54
|
+
`## Round ${round} — ${label}`,
|
|
55
|
+
"",
|
|
56
|
+
`[System reminder: this is round ${round} of the convergence protocol. ` +
|
|
57
|
+
`The system prompt for this round has already narrowed the review scope — follow it: ${reminder}.]`,
|
|
58
|
+
"",
|
|
59
|
+
// Prior review output IS in the context (decision 2026-08-08): the FULL
|
|
60
|
+
// verbatim output of the last review — the only complete verification list.
|
|
61
|
+
// The agent response table covers only issues the agent chose to answer,
|
|
62
|
+
// so issues the agent skipped would silently escape convergence without
|
|
63
|
+
// the prior output. The model understands the review output directly —
|
|
64
|
+
// no table/header/phrase parsing. Restatement risk is handled
|
|
65
|
+
// mechanically: host-verified citations reject references that do not
|
|
66
|
+
// match the CURRENT disk state, and fresh sessions exclude old read data.
|
|
67
|
+
// The agent response table stays as a focus aid ("I fixed X"), not as the
|
|
68
|
+
// to-verify list.
|
|
69
|
+
"## Prior Review Output (verify every item it raises)",
|
|
70
|
+
p,
|
|
71
|
+
"",
|
|
72
|
+
"## Agent Response (fix claims — reference only)",
|
|
73
|
+
response,
|
|
74
|
+
"",
|
|
75
|
+
"## Instructions",
|
|
76
|
+
...buildConvergenceInstructions(round, scopeFiles),
|
|
77
|
+
"",
|
|
78
|
+
]
|
|
79
|
+
return parts.join("\n")
|
|
80
|
+
}
|
package/src/advisor/history.mjs
CHANGED
|
@@ -1,14 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* advisor/history.mjs — advisor history extraction:
|
|
2
|
+
* advisor/history.mjs — advisor history extraction: agent response table and conversation background.
|
|
3
3
|
*/
|
|
4
4
|
import { readFileSync } from "node:fs"
|
|
5
5
|
import { join } from "node:path"
|
|
6
6
|
|
|
7
7
|
export const ADVISOR_MD_PATH = ".thincoder/advisor.md"
|
|
8
|
-
export const ADVISOR_TABLE_HEADER = "| # | File | Severity | Issue | Suggestion |"
|
|
9
|
-
const CONVERGENCE_TABLE_HEADER = "| # | Orig# | File | Severity | Status | Notes |"
|
|
10
8
|
const AGENT_RESPONSE_HEADER = "| # | Action | Detail |"
|
|
11
|
-
export const LEGACY_ADVISOR_HEADER = "| # | 文件 | 严重程度 | 问题描述 | 建议修复 |"
|
|
12
9
|
|
|
13
10
|
const DEFAULT_CRITERIA = `Review the code changes, focusing on:
|
|
14
11
|
1. Correctness: logic errors, edge cases, off-by-one, incomplete modifications
|
|
@@ -18,60 +15,28 @@ const DEFAULT_CRITERIA = `Review the code changes, focusing on:
|
|
|
18
15
|
5. Maintainability: vague naming, missing comments, overly complex logic`
|
|
19
16
|
|
|
20
17
|
/**
|
|
21
|
-
* Extract the
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
// Negative signals: a table listing SOME items as unfixed is NOT all-clear.
|
|
34
|
-
// Applied when the message carries a Status column (English or Chinese convergence
|
|
35
|
-
// format) — "failed"/"❌" in a round-1 Issue description must NOT trigger it.
|
|
36
|
-
const partiallyFixedRe = /\bunfixed\b|未修复|❌|\bfailed\b/i
|
|
37
|
-
const entries = Array.isArray(history) ? history : []
|
|
38
|
-
|
|
39
|
-
for (let i = entries.length - 1; i >= 0; i--) {
|
|
40
|
-
const m = entries[i]
|
|
41
|
-
if (m.role !== "tool" || typeof m.content !== "string") continue
|
|
42
|
-
// Only review outputs carry one of these table headers. Matched at LINE START:
|
|
43
|
-
// an `includes()` match would also fire on advisor output that quotes the
|
|
44
|
-
// header constants' own source code (e.g. history.mjs), producing a phantom
|
|
45
|
-
// "prior issue table" and re-opening convergence rounds against stale data.
|
|
46
|
-
if (!lineHasHeader(m.content, ADVISOR_TABLE_HEADER)
|
|
47
|
-
&& !lineHasHeader(m.content, CONVERGENCE_TABLE_HEADER)
|
|
48
|
-
&& !lineHasHeader(m.content, LEGACY_ADVISOR_HEADER)) continue
|
|
49
|
-
const text = m.content
|
|
50
|
-
const lower = text.toLowerCase()
|
|
51
|
-
// Has a Status column? (English convergence format or any table with a Status-like
|
|
52
|
-
// column) — Chinese legacy tables lack it, but they are issue tables, not convergence.
|
|
53
|
-
const hasStatusColumn = lineHasHeader(text, CONVERGENCE_TABLE_HEADER)
|
|
54
|
-
|| /Status|状态/.test(text.slice(0, text.indexOf("\n") + 1))
|
|
55
|
-
if (hasStatusColumn && partiallyFixedRe.test(lower)) return { text, sinceIdx: i }
|
|
56
|
-
if (allClear.some((s) => lower.includes(s))) return null
|
|
57
|
-
// sinceIdx = the advisor call's own index; extractAgentResponseTable skips it (role !== assistant)
|
|
58
|
-
return { text, sinceIdx: i }
|
|
59
|
-
}
|
|
60
|
-
return null
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/** True when some line of `text` starts with `header` — table headers always sit at line start. */
|
|
64
|
-
function lineHasHeader(text, header) {
|
|
65
|
-
return text.split("\n").some((l) => l.trimStart().startsWith(header))
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
/**
|
|
69
|
-
* Extract the agent's response table (| # | Action | Detail |) that follows
|
|
70
|
-
* the advisor review. Returns null when missing or no advisor review precedes.
|
|
18
|
+
* Extract the agent's response table (| # | Action | Detail |) — the fix-claims
|
|
19
|
+
* reference for convergence rounds.
|
|
20
|
+
* Semantics (decision 2026-08-08): without sinceIdx, scan BACKWARD for the
|
|
21
|
+
* MOST RECENT response table (no prior-table index is carried anymore — the
|
|
22
|
+
* agent response is a focus aid only; format drift falls back to the
|
|
23
|
+
* no-response text and never drives control flow). With sinceIdx, scan
|
|
24
|
+
* FORWARD from it (legacy callers/tests).
|
|
25
|
+
* @param {Array} history — message history
|
|
26
|
+
* @param {number} [sinceIdx] — legacy: start scanning forward from this index
|
|
27
|
+
* @returns {string|null} the response table content, or null
|
|
71
28
|
*/
|
|
72
29
|
export function extractAgentResponseTable(history, sinceIdx) {
|
|
73
30
|
const entries = Array.isArray(history) ? history : []
|
|
74
|
-
|
|
31
|
+
if (sinceIdx !== undefined) {
|
|
32
|
+
for (let i = sinceIdx; i < entries.length; i++) {
|
|
33
|
+
const m = entries[i]
|
|
34
|
+
if (m.role !== "assistant" || typeof m.content !== "string") continue
|
|
35
|
+
if (m.content.includes(AGENT_RESPONSE_HEADER)) return m.content
|
|
36
|
+
}
|
|
37
|
+
return null
|
|
38
|
+
}
|
|
39
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
75
40
|
const m = entries[i]
|
|
76
41
|
if (m.role !== "assistant" || typeof m.content !== "string") continue
|
|
77
42
|
if (m.content.includes(AGENT_RESPONSE_HEADER)) return m.content
|