thincoder 0.12.12 → 0.12.14

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/advisor.mjs CHANGED
@@ -44,10 +44,11 @@
44
44
  import { readFileSync } from "node:fs"
45
45
  import { join, dirname } from "node:path"
46
46
  import { fileURLToPath } from "node:url"
47
- import { extractPriorIssueTable, extractAgentResponseTable } from "./advisor/history.mjs"
48
- import { buildAdvisorUserMessage, buildConvergenceInstructions, resolveScopeFiles } from "./advisor/messages.mjs"
47
+ import { extractAgentResponseTable } from "./advisor/history.mjs"
48
+ import { buildAdvisorUserMessage, resolveScopeFiles } from "./advisor/messages.mjs"
49
+ import { buildConvergenceBody } from "./advisor/convergence.mjs"
49
50
  // Re-export for run.mjs and tests (keeps their imports from "../advisor.mjs" stable)
50
- export { ADVISOR_MD_PATH, extractPriorIssueTable, extractAgentResponseTable, extractConversationBackground } from "./advisor/history.mjs"
51
+ export { ADVISOR_MD_PATH, extractAgentResponseTable, extractConversationBackground } from "./advisor/history.mjs"
51
52
  export { buildAdvisorUserMessage } from "./advisor/messages.mjs"
52
53
 
53
54
  const __dirname = dirname(fileURLToPath(import.meta.url))
@@ -72,8 +73,7 @@ const ADVISOR_ROUND1 = loadPrompt("advisor-round1.md", "advisor-round1.md")
72
73
  const ADVISOR_ROUND2 = loadPrompt("advisor-round2.md", "advisor-round2.md")
73
74
  const ADVISOR_ROUND3 = loadPrompt("advisor-round3.md", "advisor-round3.md")
74
75
  // Fallback when advisor-design.md is missing — keep in sync with the real
75
- // file (table format + workflow steps; a drifted fallback would also break
76
- // extractPriorIssueTable's DESIGN_TABLE_HEADER matching).
76
+ // file (table format + workflow steps).
77
77
  const ADVISOR_DESIGN_FALLBACK = `You are an independent design reviewer for an engineering-mode project. Review the design document in the changes below. Evaluate: completeness, feasibility, clarity, scope, acceptance criteria. Read METHODOLOGY.md if provided. Produce a review table with | # | Category | Severity | Issue | Suggestion | format.`
78
78
  let ADVISOR_DESIGN = ""
79
79
  // Design review is OPTIONAL (engineering mode only) — silent fallback to the
@@ -88,24 +88,28 @@ try { ADVISOR_DESIGN = readFileSync(join(__dirname, "prompts", "advisor-design.m
88
88
  /**
89
89
  * Build the system prompt for an advisor review session.
90
90
  * @param {Object} agent — the parent agent
91
- * @param {Object|null} [prior] — prior issue table (from extractPriorIssueTable)
91
+ * @param {Object|null} [prior] — prior review output (full text; decision 2026-08-08)
92
92
  * @param {string} [reviewType] — "design" for design review, undefined/"code" for code review
93
93
  * @returns {string} the system prompt
94
94
  */
95
95
  export function buildAdvisorSystemPrompt(agent, prior, reviewType) {
96
+ // Round decision is DETERMINISTIC (decision 2026-08-08): _advisorRound > 0
97
+ // with a stored review output means convergence (round 2+); 0 means round 1.
98
+ // No prior-table parsing, no all-clear phrase matching — the round counter
99
+ // and the stored output are the only inputs. A restarted process has
100
+ // _advisorRound 0 → conservative full re-review.
101
+ const hasPrior = (agent._advisorRound || 0) > 0 && (prior ?? agent._lastAdvisorOutput)
96
102
  // Design review: round 1 uses the dedicated design-review prompt (full scope +
97
103
  // approval token); rounds 2+ converge like code reviews (verify agent fix claims).
98
104
  if (reviewType === "design") {
99
- const p = prior ?? extractPriorIssueTable(agent.history)
100
- if (!p || (agent._advisorRound || 0) === 0) {
105
+ if (!hasPrior) {
101
106
  return ADVISOR_DESIGN || ADVISOR_DESIGN_FALLBACK
102
107
  }
103
108
  const round = (agent._advisorRound || 0) + 1
104
109
  if (round === 2) return ADVISOR_ROUND2
105
110
  return ADVISOR_ROUND3
106
111
  }
107
- const p = prior ?? extractPriorIssueTable(agent.history)
108
- if (!p || (agent._advisorRound || 0) === 0) return ADVISOR_ROUND1
112
+ if (!hasPrior) return ADVISOR_ROUND1
109
113
  const round = (agent._advisorRound || 0) + 1
110
114
  if (round === 2) return ADVISOR_ROUND2
111
115
  return ADVISOR_ROUND3
@@ -134,12 +138,11 @@ export function buildAdvisorSystemPrompt(agent, prior, reviewType) {
134
138
  * otherwise scan history from index 0 and could match an unrelated stale table)
135
139
  */
136
140
  export function buildAdvisorFollowUp(agent, prior, scopeFiles = null) {
137
- // Convergence follow-up REQUIRES a prior review record. The caller usually
138
- // passes it; fall back to extracting it from history (compat for direct
139
- // callers/tests). If there is genuinely no prior review, a nullish table
140
- // would make the response-table extraction scan from index 0 and possibly
141
- // match an unrelated stale table — return a round-1-style message instead.
142
- const p = prior ?? extractPriorIssueTable(agent.history)
141
+ // Convergence follow-up REQUIRES a prior review record the full output of
142
+ // the last review, injected VERBATIM (decision 2026-08-08: the model
143
+ // understands the review output; no table/header/phrase parsing). The caller
144
+ // usually passes it; fall back to the stored agent._lastAdvisorOutput.
145
+ const p = prior ?? agent._lastAdvisorOutput
143
146
  if (!p) {
144
147
  // Plain "System reminder:" prefix (no brackets) — same convention as the
145
148
  // round-1 path (some OpenAI-compatible servers parse '['-prefixed content
@@ -155,38 +158,9 @@ export function buildAdvisorFollowUp(agent, prior, scopeFiles = null) {
155
158
  const noResponseFallback = scopeFiles?.length
156
159
  ? "(Agent did not provide a response table — perform a fresh review of: " + scopeFiles.slice(0, 10).join(", ") + ")"
157
160
  : "(Agent did not provide a response table — perform a fresh full review; the review surface is unknown, ask the user for the file list)"
158
- const response = extractAgentResponseTable(agent.history, p.sinceIdx) || noResponseFallback
161
+ const response = extractAgentResponseTable(agent.history) || noResponseFallback
159
162
  const round = (agent._advisorRound || 0) + 1
160
- const label = round === 2 ? "Verify Prior Table + Flag New Issues" : "Strict Verification"
161
-
162
- const reminder = round === 2
163
- ? "verify every item in the prior issue table and flag only obvious new issues introduced by the fixes"
164
- : "strictly verify only the prior issue table — do NOT look for new issues"
165
- const parts = [
166
- `## Round ${round} — ${label}`,
167
- "",
168
- `[System reminder: this is round ${round} of the convergence protocol. ` +
169
- `The system prompt for this round has already narrowed the review scope — follow it: ${reminder}.]`,
170
- "",
171
- // Prior issue table IS in the context (decision 2026-08-05, reversed):
172
- // it is the ONLY complete verification list — the agent response table
173
- // covers only issues the agent chose to answer, so issues the agent
174
- // skipped would silently escape convergence. Restatement risk is handled
175
- // mechanically: host-verified citations reject references that do not
176
- // match the CURRENT disk state, and fresh sessions exclude old read data.
177
- // The agent response table stays as a focus aid ("I fixed X"), not as the
178
- // to-verify list.
179
- "## Prior Issue Table (verify every item)",
180
- p.text,
181
- "",
182
- "## Agent Response (fix claims — reference only)",
183
- response,
184
- "",
185
- "## Instructions",
186
- ...buildConvergenceInstructions(round, scopeFiles),
187
- "",
188
- ]
189
- return parts.join("\n")
163
+ return buildConvergenceBody(p, response, round, scopeFiles)
190
164
  }
191
165
 
192
166
  /**
@@ -229,11 +203,15 @@ export function escapeLiteralEscapes(text) {
229
203
  * @param {string[]|null} [documents] — design review only: explicit list of doc paths to review (passed through to buildAdvisorUserMessage)
230
204
  * @param {string[]|null} [paths] — code review only: explicit list of file/dir paths to review
231
205
  */
232
- export function prepareAdvisorMessages(agent, reviewType, designToken = null, documents = null, paths = null) {
233
- const prior = extractPriorIssueTable(agent.history)
206
+ export function prepareAdvisorMessages(agent, reviewType, designToken = null, documents = null, paths = null, priorParam = null) {
207
+ // Deterministic convergence state (decision 2026-08-08): round 2+ requires
208
+ // _advisorRound > 0 AND a stored prior review output. No history parsing.
209
+ // priorParam (direct callers) wins over the stored output — same derivation
210
+ // as buildAdvisorSystemPrompt (single source of truth for round semantics).
211
+ const prior = (agent._advisorRound || 0) > 0 ? (priorParam ?? agent._lastAdvisorOutput) : null
234
212
 
235
213
  // Design review round 1: the dedicated full-scope review with the approval
236
- // token (an independent gate — it runs even when a prior table exists, e.g.
214
+ // token (an independent gate — it runs even when a prior review exists, e.g.
237
215
  // after a failed design review). Fresh session.
238
216
  if (reviewType === "design" && (agent._advisorRound || 0) === 0) {
239
217
  return [
@@ -248,21 +226,14 @@ export function prepareAdvisorMessages(agent, reviewType, designToken = null, do
248
226
  // re-reading) and a token sink. The agent response table (fix claims) is
249
227
  // injected through buildAdvisorFollowUp instead; the system prompt carries
250
228
  // the round (ROUND2/ROUND3) via buildAdvisorSystemPrompt.
251
- // Guard matches buildAdvisorSystemPrompt's ROUND1 condition
252
- // (`!prior || _advisorRound === 0`): a stale prior table with _advisorRound 0
253
- // (history persists across runAgent calls) must yield a fresh round-1 review —
254
- // ROUND1 system prompt + full-scope user message, never the convergence
255
- // follow-up (which would contradict the ROUND1 system prompt). The
256
- // _advisorRound===0 half was lost in the _mutatedThisRun refactor and is
257
- // restored here (regression 67ac851 → 6e15a6b window).
258
- // No prior table: reset ONLY when this run made no code changes (user
229
+ // No prior review output: reset ONLY when this run made no code changes (user
259
230
  // decision 2026-08-05: any loop that modified code must NOT reset — the
260
231
  // advisor guard WILL push back, so the convergence round must keep advancing
261
232
  // toward the cap; a run with no mutations has no push-back risk and a reset
262
233
  // is safe). Deterministic runtime state (`_mutatedThisRun`) decides — never
263
234
  // model output (phrases/table headers drift; three rounds of false reports
264
- // proved it). Either way the message is a fresh full review (no issue list
265
- // exists without a prior table) — only the round counter differs.
235
+ // proved it). Either way the message is a fresh full review (no prior output
236
+ // exists without a completed review) — only the round counter differs.
266
237
  if (!prior || (agent._advisorRound || 0) === 0) {
267
238
  if (!(agent._mutatedThisRun ?? false)) {
268
239
  // New review cycle (first review, all-clear, or no code changes): reset
@@ -4,17 +4,10 @@
4
4
  * Checks: pending tasks, verify guard, advisor guard.
5
5
  * Returns { action: 'continue' | 'done', content?, guardPushbacks, honestReminderInjected, advisorPushbacks }
6
6
  */
7
- import { isDocFile } from "../advisor/repos.mjs"
7
+ import { hasCodeMutations } from "../advisor/repos.mjs"
8
8
  import { pushReal } from "../context.mjs"
9
9
  import { MAX_ADVISOR_ROUNDS } from "../advisor/run.mjs"
10
10
 
11
- /** True when this run mutated at least one CODE file. Mirrors agent.mjs:hasCodeMutations. */
12
- function hasCodeMutations(agent) {
13
- const files = agent._touchedFiles ?? []
14
- if (files.length === 0) return agent._mutatedThisRun
15
- return files.some((p) => /(?:^|[\\/])src[\\/]/.test(p) || !isDocFile(p))
16
- }
17
-
18
11
  const MAX_VERIFY_PUSHBACKS = 2
19
12
  const MAX_VERIFY_RETRIES = 3
20
13
  const MAX_ADVISOR_PUSHBACKS = 3
@@ -50,23 +50,14 @@ export function validateDesignToken(token) {
50
50
  return signature === expectedSig
51
51
  }
52
52
 
53
- /** Extract UUID from signed token for regex matching */
54
- function extractTokenUUID(token) {
55
- const parts = token.split(":")
56
- return parts.length >= 1 ? parts[0] : token
57
- }
58
-
59
- /** Build a [DESIGN-TOKEN:...] regex; escapes special chars as a safety net even though UUIDs contain only hex/hyphens.
60
- * Flexible matching: allows token to be on its own line, in a code block, or surrounded by whitespace.
61
- * Rejects partial matches by requiring word boundaries or brackets around the token. */
53
+ /** Build a [DESIGN-TOKEN:...] regex; escapes special chars as a safety net.
54
+ * Matches the FULL token (uuid:expiresAt:signature) — prompt tells advisor
55
+ * to echo the complete token verbatim, not just the UUID segment.
56
+ * Flexible matching: allows token to be on its own line, in a code block,
57
+ * or surrounded by whitespace. */
62
58
  const makeDesignTokenRegex = (token, flags = "") => {
63
- // Extract UUID from signed token (format: uuid:expiresAt:signature)
64
- const uuid = extractTokenUUID(token)
65
- const escaped = uuid.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
66
- // Match [DESIGN-TOKEN: <uuid>] with flexible surrounding context:
67
- // - Allow leading/trailing whitespace and newlines
68
- // - Allow being inside code blocks (```...```)
69
- // - Require complete token (not truncated)
59
+ // Escape the entire token, not just UUID advisor echoes [DESIGN-TOKEN:uuid:expiresAt:signature]
60
+ const escaped = token.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
70
61
  return new RegExp(
71
62
  `(?:^|\\s|\`|\\*)\\[DESIGN-TOKEN:\\s*${escaped}\\s*\\](?:\\s|$|\`|\\*)`,
72
63
  flags + "ms"
@@ -165,7 +156,6 @@ export const advisorTool = {
165
156
  if (agent._role === "eng-coder") agent._engDesignReviewed = true
166
157
  // Strip the bracketed token so only ONE unambiguous format (plain UUID) reaches the main agent
167
158
  const cleanResult = result.replace(makeDesignTokenRegex(designToken, "g"), "").trim()
168
- const tokenUUID = extractTokenUUID(designToken)
169
159
  return `${cleanResult}\n\nApproved. Pass this exact token to eng-coder (designToken parameter): ${designToken}`
170
160
  }
171
161
  // Review failed (or advisor chose not to pass) → invalidate any previously-issued token.
package/src/agent.mjs CHANGED
@@ -13,7 +13,6 @@ import { executeToolCalls } from "./agent/dispatch.mjs"
13
13
  import { prepareRun } from "./agent/setup.mjs"
14
14
  import { injectPostTurn, STALL_WINDOW_SIZE, STALL_THRESHOLD, GOAL_BUDGET_WARN_RATIO } from "./agent/post-turn.mjs"
15
15
  import { handleCompletion } from "./agent/completion.mjs"
16
- import { isDocFile } from "./advisor/repos.mjs"
17
16
  import {
18
17
  escapeXml, tryCanonicalize, repairHistory, listWorkDir,
19
18
  readonlyToolNames, collectGitContext, loadProjectInstructions,
@@ -56,23 +55,8 @@ export const ENG_ON_REMINDER =
56
55
  "subagents only. Advisor calls are NOT per-turn-mandatory — call only at " +
57
56
  "flow nodes or when the user asks.]"
58
57
 
59
- /**
60
- * True when this run mutated at least one CODE file. Doc-only changes
61
- * (docs/, *.md, LICENSE…) must NOT trigger the advisor/verify guards — the
62
- * design phase edits docs/ and must not be pushed to a code review.
63
- * Mutations without a known path (tools outside FILE_MUTATORS) are treated as
64
- * code — cannot tell, so guard conservatively.
65
- * Product-code semantics match isProductCode: anything under src/ (incl.
66
- * src/prompts/*.md) is code; anything else that isn't a doc file is code.
67
- * NOTE: _touchedFiles stores ABSOLUTE paths (join(cwd, p)), so the src/ check
68
- * matches a path component (works for "src/..." and "D:\...\src\..." alike),
69
- * not a bare ^src prefix — the literal ^src[\\/] form would be dead code here.
70
- */
71
- export function hasCodeMutations(agent) {
72
- const files = agent._touchedFiles ?? []
73
- if (files.length === 0) return agent._mutatedThisRun
74
- return files.some((p) => /(?:^|[\\/])src[\\/]/.test(p) || !isDocFile(p))
75
- }
58
+ // Re-exported for API compatibility (single source of truth: advisor/repos.mjs)
59
+ export { hasCodeMutations } from "./advisor/repos.mjs"
76
60
 
77
61
  /** Engineering-mode status injection — one reminder when engineering mode is ON. */
78
62
  function injectEngineeringReminder(agent) {
@@ -101,6 +85,7 @@ export function createAgent({
101
85
  _engDesignReviewed: false, // eng-coder: design review gate passed (hard gate in dispatch.mjs)
102
86
  _engDesignToken: null, // issued by advisor(type="design"); required to spawn eng-coder
103
87
  _touchedFiles: [], _verifyRetries: 0, _advisorRound: 0, _advisorSession: null,
88
+ _lastAdvisorOutput: null, // full review output from the most recent advisor call (convergence rounds inject it verbatim)
104
89
  _lastEngState: false,
105
90
  _pendingReminders: [],
106
91
  _pendingTimers: [],
@@ -165,7 +150,7 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
165
150
  const lastRole = agent.history.at(-1)?.role
166
151
  if (lastRole === "user" || lastRole === "tool") {
167
152
  try {
168
- if (await compressIfNeeded(agent, threshold, callbacks, compactionOverhead)) {
153
+ if (await compressIfNeeded(agent, threshold, callbacks, compactionOverhead, signal)) {
169
154
  agent._compressFailures = 0
170
155
  agent._planReminderAtLen = 0 // After compression history shrinks, reset cadence so reminders resume
171
156
  recentCallSigs.length = 0 // After compression history is rebuilt, reset stall detection counter
@@ -220,8 +205,8 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
220
205
  await classifyAndApply(agent, turn).catch(() => {})
221
206
  }
222
207
 
223
- try {
224
- response = await chat(agent.provider, {
208
+ if (process.env.ADVISOR_DEBUG) console.error("[chat-call]", JSON.stringify({ turn, histLen: agent.history.length, lastRole: agent.history.at(-1)?.role }))
209
+ try { response = await chat(agent.provider, {
225
210
  messages, tools: toolSchemas,
226
211
  onToken: callbacks.onToken,
227
212
  onReasoning: callbacks.onReasoning,
@@ -389,13 +374,21 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
389
374
  pushReal(agent, { role: "tool", tool_call_id: toolCall.id, content: result })
390
375
  if (tool && ok) {
391
376
  if (FILE_MUTATORS.has(toolCall.name)) {
392
- // Direct file edit — code was changed.
377
+ // Direct file edit — code was changed. The prior advisor review and
378
+ // verify are stale: a review that ran before the edit no longer
379
+ // covers the current file state.
393
380
  agent._mutatedThisRun = true
394
- }
395
- if (!tool.readonly && !tool.sideEffectExempt) {
396
- // Any side-effect tool (bash, git, etc.) invalidates prior review/verify.
397
- // Code may not have changed, but the environment did.
398
- if (agent._calledAdvisorThisRun) agent._calledAdvisorThisRun = false
381
+ agent._calledAdvisorThisRun = false
382
+ agent._verifiedThisRun = false
383
+ agent._verifyPassed = undefined
384
+ } else if (!tool.readonly && !tool.sideEffectExempt) {
385
+ // Non-mutating side-effect tools (bash, git): do NOT invalidate the
386
+ // advisor review — a review is triggered by CODE MUTATIONS only
387
+ // (user decision 2026-08-08: the guard rule is "review after code
388
+ // changes", not "review after any environment change"; bash is
389
+ // barred from writing files, so it cannot change the reviewed code).
390
+ // Verify IS invalidated: its state snapshot (git diff, file list)
391
+ // may be stale after git/shell operations.
399
392
  if (agent._verifiedThisRun) {
400
393
  agent._verifiedThisRun = false
401
394
  agent._verifyPassed = undefined
package/src/config.mjs CHANGED
@@ -139,9 +139,12 @@ const COMPACT_RATIO = 0.6
139
139
 
140
140
  /** Look up spec by model name prefix (case-insensitive), conservative default for unknown models */
141
141
  const warnedModels = new Set() // warn once per model name — specForModel is a hot path (every request)
142
+ // Pre-sorted once at module scope — specForModel runs on every request (agent, provider core,
143
+ // context, auto-think, TUI rendering); re-sorting per call was wasteful.
144
+ const SORTED_SPECS = [...MODEL_SPECS].sort((a, b) => b[0].length - a[0].length)
142
145
  export function specForModel(model) {
143
146
  const m = (model ?? "").toLowerCase()
144
- for (const [prefix, spec] of [...MODEL_SPECS].sort((a,b) => b[0].length - a[0].length)) {
147
+ for (const [prefix, spec] of SORTED_SPECS) {
145
148
  if (m.startsWith(prefix.toLowerCase())) return spec
146
149
  }
147
150
  // Unknown model: warn ONCE (not per request) so a typo'd ID or a missing alias surfaces
@@ -189,6 +192,8 @@ export function normalizeProxy(proxy) {
189
192
  * Load configuration.
190
193
  * Env var priority: THINCODER_ACTIVE_PROVIDER > config file activeProvider
191
194
  * THINCODER_API_KEY / THINCODER_BASE_URL / THINCODER_MODEL override the current active provider's corresponding fields
195
+ * THINCODER_ACTIVE_MODEL overrides the active model (wins over THINCODER_MODEL — see loadConfig)
196
+ * Provider-specific key fallbacks (when providers[] lacks a key): DEEPSEEK_API_KEY / OPENAI_API_KEY
192
197
  */
193
198
  export function loadConfig() {
194
199
  let config = {}
@@ -203,7 +208,7 @@ export function loadConfig() {
203
208
  const merged = {
204
209
  ...DEFAULTS,
205
210
  ...config,
206
- providers: config.providers?.length ? config.providers : DEFAULTS.providers,
211
+ providers: Array.isArray(config.providers) && config.providers.length ? config.providers.map((p) => ({ ...p })) : DEFAULTS.providers.map((p) => ({ ...p })),
207
212
  activeProvider: config.activeProvider ?? DEFAULTS.activeProvider,
208
213
  agent: { ...DEFAULTS.agent, ...config.agent },
209
214
  memory: { ...DEFAULTS.memory, ...config.memory },
@@ -247,6 +252,8 @@ export function loadConfig() {
247
252
 
248
253
  // apiKey also falls back to env vars (when providers doesn't include a key)
249
254
  // Provider-specific env vars only apply to the matching provider name, preventing keys from leaking to wrong endpoints
255
+ // NOTE: only deepseek/openai have provider-specific fallbacks by design — the other presets
256
+ // intentionally rely on THINCODER_API_KEY or keys stored in config.json (no silent env pickup).
250
257
  if (!runtimeProvider.apiKey?.trim()) {
251
258
  const envMap = { deepseek: "DEEPSEEK_API_KEY", openai: "OPENAI_API_KEY" }
252
259
  const keyVar = envMap[merged.activeProvider]
@@ -278,9 +285,10 @@ export function loadConfig() {
278
285
  */
279
286
  export function saveConfig(config) {
280
287
  mkdirSync(configDir, { recursive: true })
281
- // Inject $schema for editor autocompletion/validation (strip on load)
282
- config.$schema = "https://thincoder.dev/schemas/config.json"
288
+ // Inject $schema for editor autocompletion/validation (strip on load) — write a copy,
289
+ // never mutate the caller's object.
290
+ const out = { ...config, $schema: "https://thincoder.dev/schemas/config.json" }
283
291
  // 0600: config.json contains API keys, must not be world-readable (POSIX; chmod is best-effort on Windows)
284
- writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", { encoding: "utf8", mode: 0o600 })
292
+ writeFileSync(configPath, JSON.stringify(out, null, 2) + "\n", { encoding: "utf8", mode: 0o600 })
285
293
  try { chmodSync(configPath, 0o600) } catch { /* may fail on Windows, ignore */ }
286
294
  }
package/src/context.mjs CHANGED
@@ -2,7 +2,11 @@
2
2
  * context.mjs — Context management and compaction
3
3
  * When no measured token count is available, use estimation as fallback (ASCII/4 + non-ASCII/1, no tokenizer dependency).
4
4
  * When a measured value exists (response usage.prompt_tokens), trust it — estimation underestimates CJK by 3-4x and relying solely on it may never trigger compaction.
5
- * Compaction strategy: keep earliest 2 + latest N messages, summarize the middle into one via LLM (inspired by kimi-code, simplified).
5
+ * Compaction strategy: summarize everything before the tail into one LLM note, keep the latest N messages verbatim.
6
+ * NOTE: no dedicated head is kept (KEEP_HEAD = 0) — in multi-task sessions the earliest messages are
7
+ * typically a COMPLETED earlier task; preserving them verbatim anchored the model's attention on stale
8
+ * work after compaction. The earliest messages now go into the summary (which distinguishes completed
9
+ * vs in-progress work), so the post-compaction context anchors on the current task (recent tail) only.
6
10
  */
7
11
 
8
12
  import { chat } from "./provider/index.mjs"
@@ -30,11 +34,16 @@ export function estimateTokens(messages) {
30
34
  return tokens
31
35
  }
32
36
 
33
- const KEEP_HEAD = 2 // Keep the earliest user intent must not lose it
37
+ const KEEP_HEAD = 0 // No dedicated head: earliest messages may be a COMPLETED earlier task in multi-task
38
+ // sessions — keeping them verbatim anchored attention on stale work. Everything before the tail is
39
+ // summarized (the summary itself distinguishes completed vs in-progress work; see SUMMARIZE_PROMPT).
34
40
  // Tail size scales with the model context window (~30 messages per 100K tokens),
35
41
  // capped at 40% of history so small histories don't over-reserve. Window-adaptive
36
42
  // replaces the old fixed 10: on a 1M window, 10 messages is too thin for recent work.
37
43
  function keepTailSize(provider, historyLen) {
44
+ // provider is guaranteed at every call site (runAgent always builds one); specForModel
45
+ // degrades to DEFAULT_SPEC (128K) only if provider/model is somehow absent — acceptable
46
+ // because the 40% history cap still bounds the tail.
38
47
  const ctxWindow = specForModel(provider?.model ?? "").context
39
48
  return Math.min(Math.max(10, Math.floor((ctxWindow / 100_000) * 30)), Math.floor(historyLen * 0.4))
40
49
  }
@@ -43,7 +52,9 @@ const SUMMARIZE_PROMPT = `You are a conversation compressor. Summarize the follo
43
52
  Requirements:
44
53
  - Write in first person, present tense — these are "my" handover notes, continuing my own train of thought
45
54
  - Most important: preserve design decisions and their reasons — architecture choices, API contracts, naming conventions, trade-off rationale. These are the anchors the subsequent code must not deviate from
46
- - Keep: the user's original request, files modified and why, unresolved issues, next steps
55
+ - Distinguish COMPLETED vs IN-PROGRESS work: completed tasks get a ONE-LINE recap each (what was done, key outcome); spend the detail budget on unresolved issues, next steps, and the CURRENT task
56
+ - The user's most recent request defines the current task — anchor on it. Earlier requests are likely already completed and only need the one-line recap; do NOT preserve them at full fidelity
57
+ - Keep: files modified and why, unresolved issues, next steps
47
58
  - Drop: pleasantries, repetition, fine-grained tool output details
48
59
  - Honestly mark uncertain items: anything not actually verified must say "unverified"; do not present guesses as facts
49
60
  - Use bullet-point output; aim for information completeness, not a hard word limit (old 500-char cap is deprecated; in a 1M-context era, err on the long side)
@@ -71,8 +82,8 @@ const FALLBACK_NOTE =
71
82
 
72
83
  /**
73
84
  * Split history into head / middle (to be summarized) / tail; return null if no middle to compress.
74
- * The head boundary must avoid orphan tool_calls: when an assistant message has tool_calls, all its tool responses must stay in head,
75
- * otherwise compressing them to plain text violates the protocol (tool_calls must be followed by tool messages).
85
+ * head is normally empty (KEEP_HEAD = 0 earliest messages go into the summary); the
86
+ * tool_calls-extension logic below is defensive for future KEEP_HEAD > 0.
76
87
  * The tail boundary must include any assistant whose tool results are in the tail — if the assistant is in the middle,
77
88
  * the summary swallows it, leaving orphan tool results → protocol 400.
78
89
  */
@@ -101,7 +112,10 @@ function splitHistory(history, keepTail) {
101
112
  }
102
113
 
103
114
  // skip orphan tool messages at the new tail boundary (tool whose assistant was pulled in above)
104
- while (tailStart > headEnd && history[tailStart].role === "tool") {
115
+ // NOTE: single-assistant assumption the backwards scan pulls the nearest owner only; in
116
+ // practice a tail spans at most one assistant→tools cycle (parallel calls share one assistant).
117
+ // Bounds-guarded so an all-tool tail cannot push tailStart past history.length.
118
+ while (tailStart < history.length && tailStart > headEnd && history[tailStart].role === "tool") {
105
119
  tailStart++
106
120
  }
107
121
  if (tailStart <= headEnd) return null
@@ -127,6 +141,9 @@ export function pushReal(agent, msg) {
127
141
  function applyCompression(agent, headEnd, tailStart, note) {
128
142
  // _fullHistory already holds every real message (written at the source via pushReal),
129
143
  // so compaction only shrinks the machine line — nothing to preserve here.
144
+ // head is normally empty (KEEP_HEAD = 0) — the summary note becomes the first message,
145
+ // which is exactly the intent: post-compaction context anchors on the current task, not on
146
+ // possibly-completed earlier requests.
130
147
  const head = agent.history.slice(0, headEnd)
131
148
  const tail = agent.history.slice(tailStart)
132
149
  agent.history = [
@@ -173,7 +190,7 @@ function applyCompression(agent, headEnd, tailStart, note) {
173
190
  * @param {object} extras - { systemPrompt?, tools? } — estimated overhead for the pure-estimation
174
191
  * path (no measured baseline); the measured path already includes system+tools in prompt_tokens.
175
192
  */
176
- export async function compressIfNeeded(agent, threshold, callbacks, extras = {}) {
193
+ export async function compressIfNeeded(agent, threshold, callbacks, extras = {}, signal) {
177
194
  const history = agent.history
178
195
  // Prefer the real baseline: the last response's prompt_tokens is the measured value for the full context (system+tools+history).
179
196
  // Subsequent appended messages use estimation as increment; when no measured value exists (first turn / after restore / right after compaction), fall back to pure estimation
@@ -200,15 +217,21 @@ export async function compressIfNeeded(agent, threshold, callbacks, extras = {})
200
217
  const toolNote = m.tool_calls ? ` [called tools: ${m.tool_calls.map((t) => t.function.name).join(", ")}]` : ""
201
218
  // user messages get a wider cap (8000): cutting off a long user-pasted requirement loses original intent; tool/assistant capped at 2000 is enough
202
219
  const cap = m.role === "user" ? 8000 : 2000
203
- const content = typeof m.content === "string" ? m.content.slice(0, cap) : ""
204
- return `[${m.role}]${toolNote} ${content}`
220
+ // Multimodal messages (array content): extract the TEXT parts the image itself can't be
221
+ // summarized, but any accompanying text (e.g. "看这张图" + image) must not be silently lost
222
+ let text = ""
223
+ if (typeof m.content === "string") text = m.content
224
+ else if (Array.isArray(m.content)) text = m.content.filter((p) => p?.type === "text").map((p) => p.text ?? "").join(" ")
225
+ return `[${m.role}]${toolNote} ${text.slice(0, cap)}`
205
226
  })
206
227
  .join("\n")
207
228
 
208
229
  // The summary is a plain-text task, no reasoning needed — passing thinking to the compaction provider wastes tokens.
209
230
  // Silent by design (D11): no onToken/onReasoning — the compaction process must not stream to the frontend.
231
+ // signal propagates user cancellation (Ctrl+C) to the in-flight summary call.
210
232
  const summary = await chat({ ...agent.provider, thinking: null, reasoningEffort: null }, {
211
233
  messages: [{ role: "user", content: SUMMARIZE_PROMPT + serialized }],
234
+ signal,
212
235
  })
213
236
 
214
237
  applyCompression(agent, split.headEnd, split.tailStart, COMPACTION_PREFIX + summary.content)
@@ -5,7 +5,7 @@ You have a budget of 30 tool rounds (chat turns) — plan your exploration accor
5
5
 
6
6
  Review workflow:
7
7
  1. The files to review are listed in the review scope. Read them in full. The review scope defines exactly which files to inspect.
8
- 2. Read AGENTS.md / design docs once if present, to understand project conventions, version requirements, and architecture decisions.
8
+ 2. **READ THE PROJECT GUIDE FIRST** — the `## Project Guide (AGENTS.md)` section in the review context maps the project's structure and tells you where its requirements/design documents live. Read the requirements documents it points to (whatever the guide names — no fixed file names are assumed). **The user's requirements live in those documents; the conversation background is only a supplement.** If the guide says none exist, judge from the conversation background and say so explicitly if requirements are unclear.
9
9
  3. Read the specified files for full context. **Batch independent `read` calls in a SINGLE reply** — do not read files one at a time. Each round-trip counts against your limit.
10
10
  4. Use grep or lsp to trace callers, imports, and dependencies — only where genuinely needed.
11
11
  5. Produce your review table.
@@ -20,8 +20,8 @@ Rules:
20
20
  - First judge the task from the conversation background: if the changes are clearly non-code and cannot affect runtime behavior, reply immediately with the all-clear phrase — `"All clear — no code changes to review."` (the host recognizes it via the "all clear" / "no 🔴" / "review passed" / "no issues found" markers, matched case-insensitively) — do NOT spend tool calls exploring. This applies to static docs, README, and CHANGELOG files. Prompts and configs that shape behaviour are NOT exempt — review them normally.
21
21
  - **Requirement fit**: check the implementation against what the user actually asked for — a review is not only about "is the code correct" but also "is this what the user wanted". Two comparisons:
22
22
  - (a) **Claim vs implementation**: the implementer's stated intent (conversation background / response table / commit message) vs what the implementation actually does — claiming X but delivering Y is a gap.
23
- - (b) **Expectation vs shape**: explicit user expectations in the background vs the delivered shape — "asked for A, got B" (e.g. "the record must keep the real order" vs a summary appended at the end) is a gap.
24
- - **Known limit**: the conversation background only includes the last 3 user–assistant exchanges — older user expectations may not be visible. (a) is the primary check (needs only recent context); (b) is best-effort — check what the background shows, do NOT treat an invisible expectation as a gap.
23
+ - (b) **Expectation vs shape**: the requirements documents named by the Project Guide (AGENTS.md) and explicit user expectations vs the delivered shape — "asked for A, got B" (e.g. "the record must keep the real order" vs a summary appended at the end) is a gap. **The requirements documents are the primary reference — read them (workflow step 2) before judging fit; do not judge against expectations you cannot see.**
24
+ - **Known limit**: the conversation background only includes the last 3 user–assistant exchanges — older user expectations may not be visible, which is why the requirements documents are the primary reference. (a) is the primary check (needs only recent context); (b) is best-effort — check what the docs/background show, do NOT treat an invisible expectation as a gap.
25
25
  - **Severity**: 🔴 = the user's explicit request was not fulfilled; 🟡 = fulfilled but in a suboptimal or misleading way. Flag gaps by impact and state in the Issue: what the user asked for, what was delivered, and where they diverge. Claims must cite evidence (the user's own words or the implementation lines) — a "requirement gap" without evidence is 🔵 at most.
26
26
  - Reply in the same language as the conversation background.
27
27
  - Respect the project's stated platform requirements — do not flag features as errors if they are valid under the project's target environment.
@@ -1,22 +1,22 @@
1
1
  You are an independent review advisor.
2
- Verify the prior issue table (provided in the review context).
2
+ Verify the prior review output (provided in the review context).
3
3
  You may note obvious new issues introduced by the fixes.
4
4
  You have read-only tools to explore the codebase.
5
5
  You have a budget of 30 tool rounds (chat turns). Hard mechanical cap: 100 rounds.
6
6
 
7
7
  Review workflow:
8
- 1. The affected files are named in the prior issue table — read them in full. The prior issue table is HISTORY from a previous review, not current state.
8
+ 1. The prior review output above is the COMPLETE output of the last review — read it and understand every issue it raises. The affected files are named in it — read them in full. The prior review output is HISTORY from a previous review, not current state.
9
9
  2. STALE-CONTEXT WARNING: any content from earlier messages is a historical snapshot — treat it as expired. Only fresh `read` results describe the current state.
10
- 3. Project conventions were established in round 1 — do NOT re-read AGENTS.md / design docs unless a prior-table item names them or a fix appears to contradict the task itself.
11
- 4. **ALWAYS verify current file content with `read` before judging a prior-table item as fixed or unfixed — never decide based on the prior table alone.** Fixes may already be committed — `read` the files named in the prior table regardless. (Note: you have NO git tool this round; any git output in earlier messages is historical and untrustworthy.) Batch independent tool calls in one reply.
10
+ 3. Project conventions were established in round 1 — do NOT re-read AGENTS.md / design docs unless a prior-review item names them or a fix appears to contradict the task itself.
11
+ 4. **ALWAYS verify current file content with `read` before judging an item as fixed or unfixed — never decide based on the prior review output alone.** Fixes may already be committed — `read` the files named there regardless. (Note: you have NO git tool this round; any git output in earlier messages is historical and untrustworthy.) Batch independent tool calls in one reply.
12
12
  5. Use grep or lsp to trace callers, imports, and dependencies — only where genuinely needed.
13
13
  6. Produce your review table.
14
14
 
15
- Budget: read only the files named in the prior-table items. If at 15 rounds you have not yet verified all items, wrap up.
15
+ Budget: read only the files named in the prior-review items. If at 15 rounds you have not yet verified all items, wrap up.
16
16
 
17
17
  Rules:
18
18
  - Respect the project's stated platform requirements — do not flag features as errors if they are valid under the project's target environment.
19
- - Primarily check fix status of items in the prior issue table.
19
+ - Primarily check fix status of items in the prior review output.
20
20
  - For items marked "fixed": verify they were actually fixed.
21
21
  - For items marked "not an issue": evaluate whether the reasoning is sound.
22
22
  - Every "Unfixed" or "New" entry 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 fabricated or stale. Findings without a fresh quoted line are treated as unverified and will not be accepted.
@@ -1,31 +1,29 @@
1
1
  You are an independent review advisor.
2
- Strictly verify only the prior issue table (provided in the review context).
3
- Do NOT look for new issues.
2
+ Strictly verify only the prior review output (provided in the review context).
4
3
  You have read-only tools to explore the codebase.
5
4
  You have a budget of 30 tool rounds (chat turns). Hard mechanical cap: 100 rounds.
6
5
 
7
6
  Review workflow:
8
- 1. The affected files are named in the prior issue table — read them in full. The prior issue table is HISTORY from a previous review, not current state.
7
+ 1. The prior review output above is the COMPLETE output of the last review — read it and understand every issue it raises. The affected files are named in it — read them in full. The prior review output is HISTORY from a previous review, not current state.
9
8
  2. STALE-CONTEXT WARNING: any content from earlier messages is a historical snapshot — treat it as expired. Only fresh `read` results describe the current state.
10
- 3. Project conventions were established in round 1 — do NOT re-read AGENTS.md / design docs unless a prior-table item names them or a fix appears to contradict the task itself.
11
- 4. **ALWAYS verify current file content with `read` before judging a prior-table item as fixed or unfixed — never decide based on the prior table alone.** Fixes may already be committed — `read` the files named in the prior table regardless. (Note: you have NO git tool this round; any git output in earlier messages is historical and untrustworthy.) Batch independent tool calls in one reply.
12
- 5. Verify fix status of each item in the prior issue table.
9
+ 3. Project conventions were established in round 1 — do NOT re-read AGENTS.md / design docs unless a prior-review item names them.
10
+ 4. **ALWAYS verify current file content with `read` before judging an item as fixed or unfixed — never decide based on the prior review output alone.** Fixes may already be committed — `read` the files named there regardless. (Note: you have NO git tool this round; any git output in earlier messages is historical and untrustworthy.) Batch independent tool calls in one reply.
11
+ 5. Use grep or lsp to trace callers, imports, and dependencies — only where genuinely needed.
13
12
  6. Produce your review table.
14
13
 
15
- Budget: read only the files named in the prior-table items. If at 15 rounds you have not yet verified all items, wrap up.
14
+ Budget: read only the files named in the prior-review items. If at 15 rounds you have not yet verified all items, wrap up.
16
15
 
17
16
  Rules:
18
17
  - Respect the project's stated platform requirements — do not flag features as errors if they are valid under the project's target environment.
19
- - Only check fix status of items in the prior issue table.
20
- - For items marked "fixed": verify they were actually fixed.
21
- - For items marked "not an issue": evaluate whether the reasoning is sound.
22
- - Every "Unfixed" entry 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 fabricated or stale. Findings without a fresh quoted line are treated as unverified and will not be accepted.
18
+ - Only check fix status of items in the prior review output.
19
+ - Every "Unfixed" or "New" entry 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 fabricated or stale. Findings without a fresh quoted line are treated as unverified and will not be accepted.
23
20
  - **Host verification**: your `file:line: content` citations are mechanically checked against the CURRENT file state — quote exactly what `read` returned; a mismatch marks the finding unverified.
24
21
  - **Fresh context**: this round's conversation contains NO read output from earlier rounds — every file must be re-read this round.
25
- - Output a Markdown table. Only list items that still have problems:
22
+ - Do NOT look for new issues. This round exists ONLY to verify that the items from the prior review output are resolved.
23
+ - Do NOT nitpick style or naming.
24
+ - Output a Markdown table listing all remaining problems:
26
25
  | # | Orig# | File | Severity | Status | Notes |
27
26
  |---|-------|------|----------|--------|-------|
28
27
  | 1 | 3 | src/x.mjs | 🔴 | Unfixed | ... |
29
- | 2 | 5 | src/y.mjs | 🟡 | Reasoning invalid | ... |
30
28
  - If all 🔴 issues are resolved and remaining items are only 🟡/🔵, the review passes (🟡/🔵 do not block approval). If any 🔴 issue persists, do not claim it passed.
31
29
  - Stop calling tools once you are ready to produce the review table.
@@ -47,6 +47,14 @@ export function translateShiftEnter(text) {
47
47
  return text.replace(/\x1b\[13;2u/g, "\x1b\r").replace(/\x1b\[27;2;13~/g, "\x1b\r")
48
48
  }
49
49
 
50
+ /** Strip keyboard protocol CSI sequences that readline in raw mode does not recognize.
51
+ * Kitty CSI u: \x1b[key;modu — regular keys (e.g. Ctrl+C → \x1b[99;5u)
52
+ * modifyOtherKeys: \x1b[27;mod;key~ — function keys
53
+ * Call AFTER translateShiftEnter (which already handles Shift+Enter). */
54
+ export function stripKeyboardProtocol(text) {
55
+ return text.replace(/\x1b\[\d+;\d+u/g, "").replace(/\x1b\[27;\d+;\d+~/g, "")
56
+ }
57
+
50
58
  /** Ctrl+V / Alt+V: read clipboard image → write temp file in working directory → insert read_image command into input box.
51
59
  * Extracted from index.mjs.
52
60
  * ctx: { agent, state, pushLine, render } */