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/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
+ }
@@ -1,14 +1,11 @@
1
1
  /**
2
- * advisor/history.mjs — advisor history extraction: issue/response tables and conversation background.
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 most recent advisor review table from history.
22
- * Returns { text, sinceIdx } where sinceIdx is the index of the advisor call's
23
- * own history entry extractAgentResponseTable skips it (role is "tool", not
24
- * "assistant") and scans forward for the agent's response table.
25
- * Returns null when: no advisor call, empty output, or the last review is
26
- * all-clear (nothing to follow up on).
27
- */
28
- 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
- // 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
- for (let i = sinceIdx ?? 0; i < entries.length; i++) {
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