thincoder 0.12.44 → 0.12.45

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/CHANGELOG.md CHANGED
@@ -2,6 +2,22 @@
2
2
 
3
3
  本文件记录 ThinCoder CLI 的发布历史。格式基于 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.0.0/),版本遵循[语义化版本](https://semver.org/lang/zh-CN/)。
4
4
 
5
+ ## [0.12.45] — 2026-08-26
6
+
7
+ ### Fixed
8
+
9
+ - **编辑工具 CRLF 行尾写回丢失**:`edit` / `apply_patch` / `hashline_edit` / `insert_after` 在 Windows CRLF 文件上写回全部被转成 LF(normalize 后直接落盘)——现按"首个换行符类型"检测原文件行尾并原样恢复,diff 不再整文件重写;`new_string`/`new_content` 含 CRLF 时先归一化再转换,杜绝 `\r\r\n`
10
+ - **`old_string not found` 黑盒报错**:失败时返回相似度最高的 top 3 候选行(行号+预览+LCS 相似度,阈值 0.5,多行 old_string 只对首行并标注 `old_string line 1:`)——从盲猜变导航
11
+
12
+ ### Added
13
+
14
+ - **`write` 行尾语义**:覆盖既有文件按原行尾恢复;新建文件默认 LF,同目录多数派为 CRLF 时跟随(≤20 文件嗅探)
15
+ - **`hashline_edit` 编码损坏探测**:文件含 U+FFFD(替换符)时结果追加警告(编码可能已损坏、哈希寻址可能不可靠),不阻断
16
+
17
+ ### Changed
18
+
19
+ - 候选相似度 LCS 计算复用模块级 DP 缓冲(大文件失败路径不再有每行分配的 GC 压力)
20
+
5
21
  ## [0.12.44] — 2026-08-25
6
22
 
7
23
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thincoder",
3
- "version": "0.12.44",
3
+ "version": "0.12.45",
4
4
  "description": "Thin coding agent - zero dependencies, no build step, Node.js native. Sharp code, zero bloat.",
5
5
  "keywords": [
6
6
  "ai",
@@ -7,40 +7,40 @@ import { randomUUID, createHmac } from "node:crypto"
7
7
  import { runAdvisorReview } from "../advisor/run.mjs"
8
8
  import { isDocFile } from "../advisor/repos.mjs"
9
9
 
10
- const TOKEN_EXPIRY_MS = 3600000 // 1 hour
10
+ const TOKEN_TTL_DEFAULT_MS = 7 * 24 * 3600 * 1000 // 7-day ceiling (v2 2026-08-25): multi-batch delivery must not re-review an unchanged design within a week; agent.engTokenTtlMs overrides
11
11
  const TOKEN_SECRET = process.env.THINCODER_TOKEN_SECRET || "thincoder-default-secret"
12
12
 
13
+ /** Effective token TTL: config override with runtime validation (advisor timeoutMs precedent —
14
+ * invalid values fall back to the default, never silently disable the ceiling). */
15
+ function effectiveTokenTtlMs(agent) {
16
+ const cfg = agent?.config?.agent?.engTokenTtlMs
17
+ return (Number.isFinite(cfg) && cfg > 0) ? cfg : TOKEN_TTL_DEFAULT_MS
18
+ }
19
+
13
20
  /** Generate a signed design token with expiration */
14
- function generateDesignToken() {
21
+ function generateDesignToken(agent) {
15
22
  const uuid = randomUUID()
16
- const expiresAt = Date.now() + TOKEN_EXPIRY_MS
23
+ const expiresAt = Date.now() + effectiveTokenTtlMs(agent)
17
24
  const payload = `${uuid}:${expiresAt}`
18
25
  const signature = createHmac("sha256", TOKEN_SECRET).update(payload).digest("hex").slice(0, 16)
19
26
  return `${payload}:${signature}`
20
27
  }
21
28
 
22
- /** Validate design token: check format, expiration, and signature
23
- * For backward compatibility, tokens that don't match the new format are accepted as-is
24
- */
29
+ /** Validate design token: format, expiration, signature — ALL fail-closed (v2 2026-08-25).
30
+ * The two legacy fail-open branches (parts!=3 true, NaN expiry true) were pass-through
31
+ * backdoors: any malformed string bypassed validation. Only exact signed tokens pass now.
32
+ * Format must be exactly uuid:expiresAt:hmacSig. */
25
33
  export function validateDesignToken(token) {
26
34
  if (!token || typeof token !== "string") return false
27
-
35
+
28
36
  // New format: uuid:expiresAt:signature (3 parts separated by ':')
29
37
  const parts = token.split(":")
30
- if (parts.length !== 3) {
31
- // Old format or simple token - accept for backward compatibility
32
- return true
33
- }
34
-
38
+ if (parts.length !== 3) return false // fail-closed (was: return true)
39
+
35
40
  const [uuid, expiresAt, signature] = parts
36
41
  const expTime = parseInt(expiresAt, 10)
37
-
38
- // If it looks like a new format token, validate it properly
39
- if (isNaN(expTime)) {
40
- // Not a valid new format, treat as old format
41
- return true
42
- }
43
-
42
+ if (isNaN(expTime)) return false // fail-closed (was: return true)
43
+
44
44
  // Check expiration
45
45
  if (Date.now() > expTime) return false
46
46
 
@@ -130,7 +130,7 @@ export const advisorTool = {
130
130
  // Generate the design token BEFORE the review and inject it into the advisor's prompt.
131
131
  // The advisor (LLM) decides pass/fail itself and echoes the token only on approval —
132
132
  // the gate is a mechanical string match, not fragile semantics parsing.
133
- const designToken = reviewType === "design" ? generateDesignToken() : null
133
+ const designToken = reviewType === "design" ? generateDesignToken(agent) : null
134
134
  const result = await runAdvisorReview(agent, reviewType, {
135
135
  onOutput: ctx.onOutput,
136
136
  signal: ctx.signal,
@@ -159,9 +159,13 @@ export const advisorTool = {
159
159
  return `${cleanResult}\n\nApproved. Pass this exact token to eng-coder (designToken parameter): ${designToken}`
160
160
  }
161
161
  // Review failed (or advisor chose not to pass) → invalidate any previously-issued token.
162
- // Guard: result === null means the review was skipped (advisor disabled / not engineering
163
- // mode) — a skipped review must not revoke an already-issued token.
164
- if (result !== null) agent._engDesignToken = null
162
+ // Guards (v2 2026-08-25): result === null means the review was SKIPPED (advisor disabled /
163
+ // not engineering mode) — must not revoke. An error reply (own "Advisor:" prefix — the
164
+ // error-return convention of runAdvisorReview) is a provider crash/timeout artifact, not
165
+ // a completed verdict — a network glitch must not revoke unrelated standing tokens.
166
+ // Only a COMPLETED review that did not pass revokes.
167
+ const isCompletedReview = result !== null && !result.startsWith("Advisor:")
168
+ if (isCompletedReview) agent._engDesignToken = null
165
169
  // Strip every dead token occurrence from the raw output so the main agent can't grab an invalid one
166
170
  if (result) {
167
171
  const stripped = result.replace(makeDesignTokenRegex(designToken, "g"), "").trim()
@@ -3,7 +3,7 @@
3
3
  * In engineering mode the agent follows design-before-code methodology.
4
4
  * Toggled here at session level; persisted by /eng.
5
5
  */
6
- import { ENG_ON_REMINDER } from "../agent.mjs"
6
+ import { ENG_ON_REMINDER, ENG_OFF_REMINDER } from "../agent.mjs"
7
7
 
8
8
  export const engTool = {
9
9
  name: "eng",
@@ -27,8 +27,7 @@ export const engTool = {
27
27
  ctx.agent._touchedFiles = [] // clear mutation tracking
28
28
  ctx.agent._lastEngState = false
29
29
  ctx.agent._pendingReminders = ctx.agent._pendingReminders ?? []
30
- ctx.agent._pendingReminders.push(
31
- "[System reminder: engineering mode is now OFF — standard discipline applies. Changes go through the normal workflow.]")
30
+ ctx.agent._pendingReminders.push(ENG_OFF_REMINDER)
32
31
  // 持久化工程模式状态到会话
33
32
  if (ctx.persistState) {
34
33
  await ctx.persistState({
@@ -42,8 +41,14 @@ export const engTool = {
42
41
  return "Engineering mode exited. Standard discipline now applies. You may edit files directly."
43
42
  }
44
43
  if (args.action === "enter") {
44
+ // Idempotent enter (v2 2026-08-25): already in engineering mode → no-op. The old
45
+ // unconditional token clear killed standing design tokens on a redundant defensive
46
+ // eng(enter) — only a real off→on transition requires a fresh design review.
47
+ if (ctx.agent.config.agent.engineering) {
48
+ return "Engineering mode already active. Existing design tokens stay valid."
49
+ }
45
50
  ctx.agent.config.agent.engineering = true
46
- ctx.agent._engDesignToken = null // re-entering requires a fresh design review
51
+ ctx.agent._engDesignToken = null // off→on transition requires a fresh design review
47
52
  ctx.agent._lastEngState = true
48
53
  ctx.agent._pendingReminders = ctx.agent._pendingReminders ?? []
49
54
  ctx.agent._pendingReminders.push(ENG_ON_REMINDER)
package/src/agent.mjs CHANGED
@@ -61,14 +61,19 @@ export const ENG_ON_REMINDER =
61
61
  // Re-exported for API compatibility (single source of truth: advisor/repos.mjs)
62
62
  export { hasCodeMutations } from "./advisor/repos.mjs"
63
63
 
64
- /** Engineering-mode status injectionone reminder when engineering mode is ON. */
64
+ /** Engineering mode OFF remindershared with the eng tool and the injector. */
65
+ export const ENG_OFF_REMINDER =
66
+ "[System reminder: engineering mode is now OFF — standard discipline applies. " +
67
+ "Changes go through the normal workflow: you may edit files directly, advisor/verify " +
68
+ "guards apply per config.]"
69
+
70
+ /** Engineering-mode status injection — one reminder on EVERY transition (2026-08-25:
71
+ * OFF is announced too — the model must know the gates lifted; silence after /eng-off
72
+ * left it guessing. Covers TUI /eng, resume, and any path bypassing the eng tool.) */
65
73
  function injectEngineeringReminder(agent) {
66
74
  const eng = agent.config?.agent?.engineering ?? false
67
- // Only notify on transitions into ON — OFF is silence (the system prompt
68
- // already carries the standard discipline; no need to remind the model
69
- // that it's in the default mode).
70
- if (eng && !agent._lastEngState) {
71
- agent.history.push({ role: "user", content: ENG_ON_REMINDER, transient: true })
75
+ if (eng !== agent._lastEngState) {
76
+ agent.history.push({ role: "user", content: eng ? ENG_ON_REMINDER : ENG_OFF_REMINDER, transient: true })
72
77
  }
73
78
  agent._lastEngState = eng
74
79
  }
@@ -7,6 +7,11 @@ import {
7
7
  resolveInCwd,
8
8
  resolveExternal,
9
9
  normalizeEOL,
10
+ detectFileEol,
11
+ joinWithEol,
12
+ majorityEol,
13
+ findCandidates,
14
+ FFFD_WARNING,
10
15
  } from "./shared.mjs";
11
16
  import { specForModel } from "../config.mjs";
12
17
  import { createHash } from "node:crypto";
@@ -160,7 +165,12 @@ export const writeTool = {
160
165
  await mkdir(dirname(abs), { recursive: true })
161
166
  const st = await stat(abs).catch(() => null)
162
167
  if (st?.isDirectory()) throw new Error(`Path is a directory: ${args.path}`)
163
- await writeFile(abs, args.content, "utf8")
168
+ // EOL semantics: overwriting an existing file restores ITS original EOL style (F1);
169
+ // a new file follows the directory's majority style, defaulting to LF (F2).
170
+ const prev = st ? await readFile(abs, "utf8").catch(() => null) : null
171
+ const eol = prev != null ? detectFileEol(prev) : majorityEol(dirname(abs))
172
+ const content = eol === "\r\n" ? normalizeEOL(args.content).replace(/\n/g, "\r\n") : args.content
173
+ await writeFile(abs, content, "utf8")
164
174
  markDirty(abs)
165
175
  const diff = gitDiffOne(ctx.cwd, abs)
166
176
  return `Wrote ${args.content.length} chars to ${args.path}${diff ? "\n" + diff : ""}${await autoSyntaxCheck(abs)}`
@@ -189,15 +199,28 @@ export const editTool = {
189
199
  if (!args.old_string) {
190
200
  throw new Error("old_string must not be empty (empty string matches everywhere and would corrupt the file)")
191
201
  }
192
- const content = normalizeEOL(await readFile(abs, "utf8"))
202
+ const raw = await readFile(abs, "utf8")
203
+ const content = normalizeEOL(raw)
193
204
  const occurrences = content.split(args.old_string).length - 1
194
205
  if (occurrences === 0) {
195
206
  // Give clues to help the model locate: first-line preview + common causes
196
207
  const preview = args.old_string.slice(0, 100).split("\n")[0]
208
+ // Similarity candidates (LCS, line-level, top 3, score ≥ 0.5) — turns the
209
+ // "not found" black box into a pointer at the most likely intended line.
210
+ // Multi-line old_string: only its first line is scored (marked accordingly).
211
+ const cands = findCandidates(content.split("\n"), args.old_string)
212
+ let candText = ""
213
+ if (cands.length > 0) {
214
+ const header = args.old_string.includes("\n")
215
+ ? ` similar lines (old_string line 1: "${args.old_string.split("\n")[0].slice(0, 80)}"):`
216
+ : " similar lines:"
217
+ candText = "\n" + header + "\n" + cands.map((c) => ` L${c.line}: ${c.preview} (${Math.round(c.score * 100)}%)`).join("\n")
218
+ }
197
219
  throw new Error(
198
220
  `old_string not found in ${args.path}\n` +
199
221
  ` searched: "${preview}${args.old_string.length > 100 ? "…" : ""}"\n` +
200
- ` hints: whitespace mismatch? file already changed? try reading the file first`
222
+ ` hints: whitespace mismatch? file already changed? try reading the file first` +
223
+ candText
201
224
  )
202
225
  }
203
226
  if (occurrences > 1 && !args.replace_all) {
@@ -207,7 +230,11 @@ export const editTool = {
207
230
  ? content.split(args.old_string).join(args.new_string)
208
231
  // Functional replacement: avoid $-substitution patterns in new_string (match string / backreference) being expanded
209
232
  : content.replace(args.old_string, () => args.new_string)
210
- await writeFile(abs, updated, "utf8")
233
+ // Write back in the file's ORIGINAL EOL style (first-newline rule) — a CRLF
234
+ // file must not come back as LF (that rewrites every line in the diff).
235
+ // normalizeEOL first: new_string may carry \r\n (e.g. pasted from a raw CRLF
236
+ // read); without normalizing, split leaves stray \r and CRLF join makes \r\r\n.
237
+ await writeFile(abs, joinWithEol(normalizeEOL(updated).split("\n"), raw), "utf8")
211
238
  markDirty(abs)
212
239
  const diff = gitDiffOne(ctx.cwd, abs)
213
240
  return `Edited ${args.path}: replaced ${args.replace_all ? occurrences : 1} occurrence(s)${diff ? "\n" + diff : ""}${await autoSyntaxCheck(abs)}`
@@ -244,7 +271,8 @@ export const insertAfterTool = {
244
271
  `Read the file again (read tool) to refresh line numbers, then retry insert_after.`
245
272
  )
246
273
  }
247
- const text = normalizeEOL(await readFile(abs, "utf8"))
274
+ const raw = await readFile(abs, "utf8") // original bytes — EOL detection needs the file's real line endings
275
+ const text = normalizeEOL(raw)
248
276
  const lines = text.split("\n")
249
277
 
250
278
  let targetLine
@@ -274,8 +302,10 @@ export const insertAfterTool = {
274
302
  throw new Error("Either after_line or after_regex is required")
275
303
  }
276
304
 
277
- lines.splice(targetLine, 0, args.content)
278
- const updated = lines.join("\n")
305
+ lines.splice(targetLine, 0, normalizeEOL(args.content))
306
+ // Write back in the file's ORIGINAL EOL style (review R9#2: same bug class as
307
+ // edit — a CRLF file must not silently become LF here either).
308
+ const updated = joinWithEol(lines, raw)
279
309
  await writeFile(abs, updated, "utf8")
280
310
  markDirty(abs)
281
311
  const diff = gitDiffOne(ctx.cwd, abs)
@@ -310,7 +340,11 @@ export const hashlineEditTool = {
310
340
  async execute(args, ctx) {
311
341
  const abs = resolveInCwd(ctx, args.path)
312
342
  if (!args.old_hashes?.length) throw new Error("old_hashes must not be empty — read the file with hashes=true to get line hashes")
313
- const content = normalizeEOL(await readFile(abs, "utf8"))
343
+ const raw = await readFile(abs, "utf8")
344
+ const content = normalizeEOL(raw)
345
+ // Encoding-corruption probe: U+FFFD means the file is not clean UTF-8 — hash
346
+ // addressing may be unreliable. Warn (never block).
347
+ const corrupted = content.includes("\uFFFD")
314
348
  const lines = content.split("\n")
315
349
  const fileHashes = lines.map((l) => hashLine(l))
316
350
  const target = args.old_hashes
@@ -334,7 +368,8 @@ export const hashlineEditTool = {
334
368
  const preview = target.join(" ")
335
369
  throw new Error(
336
370
  `Hash sequence not found in ${args.path}: ${preview}\n` +
337
- `The file may have been modified since you last read it. Current hashes (first ${maxShow} lines):\n${hashDump}`
371
+ `The file may have been modified since you last read it. Current hashes (first ${maxShow} lines):\n${hashDump}` +
372
+ (corrupted ? `\n${FFFD_WARNING}` : "")
338
373
  )
339
374
  }
340
375
 
@@ -359,13 +394,14 @@ export const hashlineEditTool = {
359
394
 
360
395
  const pos = matches[0]
361
396
  // Replace: remove old lines, insert new lines at the same position
362
- const newLines = args.new_content.split("\n")
397
+ const newLines = normalizeEOL(args.new_content).split("\n") // normalize: CRLF in new_content would join into \r\r\n
363
398
  lines.splice(pos, target.length, ...newLines)
364
- const updated = lines.join("\n")
399
+ // Write back in the file's original EOL style (same rule as edit / apply_patch).
400
+ const updated = joinWithEol(lines, raw)
365
401
  await writeFile(abs, updated, "utf8")
366
402
  markDirty(abs)
367
403
  const diff = gitDiffOne(ctx.cwd, abs)
368
- return `Edited ${args.path}: replaced ${target.length} line(s) at L${pos + 1} with ${newLines.length} line(s)${diff ? "\n" + diff : ""}${await autoSyntaxCheck(abs)}`
404
+ return `Edited ${args.path}: replaced ${target.length} line(s) at L${pos + 1} with ${newLines.length} line(s)${diff ? "\n" + diff : ""}${await autoSyntaxCheck(abs)}${corrupted ? `\n${FFFD_WARNING}` : ""}`
369
405
  },
370
406
  }
371
407
 
@@ -1,17 +1,16 @@
1
1
  import {
2
2
  DESC,
3
3
  autoSyntaxCheck,
4
- resolveInCwd
4
+ resolveInCwd,
5
+ normalizeEOL,
6
+ detectFileEol,
7
+ majorityEol
5
8
  } from "./shared.mjs";
6
9
  import { markDirty } from "./file.mjs";
7
10
  import { execFileSync } from "node:child_process";
8
- import { mkdir } from "node:fs/promises";
9
- import { readFile } from "node:fs/promises";
10
- import { stat, lstat } from "node:fs/promises";
11
- import { writeFile } from "node:fs/promises";
12
- import { unlink } from "node:fs/promises";
11
+ import { mkdir, readFile, lstat, writeFile, unlink } from "node:fs/promises";
13
12
  import { existsSync } from "node:fs";
14
- import { join, relative, dirname } from "node:path";
13
+ import { relative, dirname } from "node:path";
15
14
 
16
15
  /**
17
16
  * Parse a unified diff: returns [{ path, isNew, hunks: [{ ops: [{type:" "|"-"|"+", text}] }] }]
@@ -121,18 +120,22 @@ export const applyPatchTool = {
121
120
  const abs = resolveInCwd(ctx, f.path)
122
121
  if (f.isNew) {
123
122
  if (existsSync(abs)) throw new Error(`Cannot create ${f.path}: file already exists`)
124
- const content = f.hunks.flatMap((h) => h.ops.filter((o) => o.type === "+").map((o) => o.text)).join("\n") + "\n"
123
+ // New file: follow the directory's majority EOL style (default LF).
124
+ const eol = majorityEol(dirname(abs))
125
+ const content = f.hunks.flatMap((h) => h.ops.filter((o) => o.type === "+").map((o) => o.text)).join(eol) + eol
125
126
  planned.push({ abs, path: f.path, content, isNew: true })
126
127
  } else {
127
128
  const original = await readFile(abs, "utf8").catch(() => { throw new Error(`File not found: ${f.path}`) })
128
- const eol = original.includes("\r\n") ? "\r\n" : "\n"
129
- const lines = original.split("\n")
130
- applyHunks(lines, f.hunks, eol, f.path)
131
- planned.push({ abs, path: f.path, content: lines.join("\n"), isNew: false })
129
+ const eol = detectFileEol(original)
130
+ // Apply hunks in the normalized LF domain, then write back joined with the
131
+ // file's ORIGINAL EOL style — join("\n") here used to rewrite CRLF files as LF.
132
+ const lines = normalizeEOL(original).split("\n")
133
+ applyHunks(lines, f.hunks, "\n", f.path)
134
+ planned.push({ abs, path: f.path, content: lines.join(eol), isNew: false })
132
135
  }
133
136
  }
134
137
  // Multi-file write: write all to .tmp first, rename only after all succeed — failure cleans up written .tmp without affecting committed files
135
- const { rename, unlink } = await import("node:fs/promises")
138
+ const { rename } = await import("node:fs/promises") // unlink already statically imported
136
139
  const written = []
137
140
  try {
138
141
  for (const p of planned) {
@@ -4,7 +4,7 @@
4
4
  */
5
5
 
6
6
  import { spawn, execFileSync, execFile } from "node:child_process"
7
- import { readFileSync, existsSync, realpathSync } from "node:fs"
7
+ import { readFileSync, existsSync, realpathSync, readdirSync, statSync, openSync, readSync, closeSync } from "node:fs"
8
8
  import { dirname, join, resolve, relative, isAbsolute, sep } from "node:path"
9
9
  import { fileURLToPath } from "node:url"
10
10
 
@@ -55,6 +55,106 @@ export function normalizeEOL(text) {
55
55
  return text.replace(/\r\n/g, "\n")
56
56
  }
57
57
 
58
+ /** Detect a file's EOL style by the type of its FIRST newline: "\r\n" first →
59
+ * the whole file is written back as CRLF; a bare "\n" or no newline → LF.
60
+ * Never counts occurrences (mixed files follow the first line's style). */
61
+ export function detectFileEol(text) {
62
+ const i = text.indexOf("\n")
63
+ return i > 0 && text[i - 1] === "\r" ? "\r\n" : "\n"
64
+ }
65
+
66
+ /** Join lines with the EOL style detected from the original text (write-back restore). */
67
+ export function joinWithEol(lines, originalText) {
68
+ return lines.join(detectFileEol(originalText))
69
+ }
70
+
71
+ const MAJORITY_EOL_MAX_FILES = 20
72
+ const EOL_SNIFF_BYTES = 4096
73
+
74
+ /** Majority EOL style of a directory's existing files (≤20 files, first 4KB each).
75
+ * New files follow the directory's majority style; empty dir / tie / LF majority → "\n". */
76
+ export function majorityEol(dirPath) {
77
+ let names
78
+ try { names = readdirSync(dirPath) } catch { return "\n" }
79
+ let crlf = 0, lf = 0
80
+ for (const name of names) {
81
+ if (crlf + lf >= MAJORITY_EOL_MAX_FILES) break
82
+ try {
83
+ const p = join(dirPath, name)
84
+ if (!statSync(p).isFile()) continue
85
+ const fd = openSync(p, "r")
86
+ let head = ""
87
+ try {
88
+ const buf = Buffer.alloc(EOL_SNIFF_BYTES)
89
+ const n = readSync(fd, buf, 0, EOL_SNIFF_BYTES, 0)
90
+ head = buf.subarray(0, n).toString("utf8")
91
+ } finally {
92
+ closeSync(fd)
93
+ }
94
+ if (detectFileEol(head) === "\r\n") crlf++
95
+ else lf++
96
+ } catch { /* unreadable entry — skip */ }
97
+ }
98
+ return crlf > lf ? "\r\n" : "\n"
99
+ }
100
+
101
+ const CANDIDATE_MAX_LEN = 500
102
+ const CANDIDATE_PREVIEW_LEN = 80
103
+
104
+ /** Longest-common-substring length (rolling-row DP). Inputs are pre-truncated by the caller. */
105
+ function lcsLength(a, b) {
106
+ // Reused DP buffers (review R9#6): per-line allocation caused GC pressure on
107
+ // large files — hoist two module-level rows, grow to fit, swap by index.
108
+ const need = b.length + 1
109
+ if (_lcsBuf0.length < need) {
110
+ const size = Math.max(need, _lcsBuf0.length * 2)
111
+ _lcsBuf0 = new Uint16Array(size)
112
+ _lcsBuf1 = new Uint16Array(size)
113
+ }
114
+ let prev = _lcsBuf0, cur = _lcsBuf1
115
+ prev.fill(0, 0, need)
116
+ let best = 0
117
+ for (let i = 1; i <= a.length; i++) {
118
+ cur[0] = 0
119
+ const ca = a.charCodeAt(i - 1)
120
+ for (let j = 1; j < need; j++) {
121
+ if (ca === b.charCodeAt(j - 1)) {
122
+ const v = prev[j - 1] + 1
123
+ cur[j] = v
124
+ if (v > best) best = v
125
+ } else cur[j] = 0 // must reset — buffer is reused
126
+ }
127
+ const t = prev; prev = cur; cur = t
128
+ }
129
+ return best
130
+ }
131
+ let _lcsBuf0 = new Uint16Array(0), _lcsBuf1 = new Uint16Array(0)
132
+ /** Line-level similarity candidates for a failed edit: score = LCS(oldString, line) / max(len).
133
+ * Multi-line old_string matches on its FIRST line only (failures usually diverge there).
134
+ * Both sides are truncated to 500 chars before scoring so minified files can't blow the budget.
135
+ * Returns up to topN [{ line (1-based), preview, score }] with score >= threshold, best first. */
136
+ export function findCandidates(lines, oldString, topN = 3, threshold = 0.5) {
137
+ const needle = oldString.split("\n")[0].slice(0, CANDIDATE_MAX_LEN)
138
+ if (!needle) return []
139
+ const scored = []
140
+ for (let i = 0; i < lines.length; i++) {
141
+ const raw = lines[i]
142
+ if (!raw) continue
143
+ const line = raw.length > CANDIDATE_MAX_LEN ? raw.slice(0, CANDIDATE_MAX_LEN) : raw
144
+ const longer = Math.max(needle.length, line.length)
145
+ const shorter = Math.min(needle.length, line.length)
146
+ // LCS ≤ shorter side — a length ratio below the threshold can never reach it; skip the DP.
147
+ if (shorter / longer < threshold) continue
148
+ const score = lcsLength(needle, line) / longer
149
+ if (score >= threshold) scored.push({ line: i + 1, preview: raw.slice(0, CANDIDATE_PREVIEW_LEN), score })
150
+ }
151
+ scored.sort((a, b) => b.score - a.score || a.line - b.line)
152
+ return scored.slice(0, topN)
153
+ }
154
+
155
+ /** Appended to hashline_edit results when the file contains U+FFFD (encoding-corruption probe). */
156
+ export const FFFD_WARNING = "⚠ file contains U+FFFD (replacement char) — encoding may be corrupted; hash-based addressing may be unreliable. Consider fixing the file encoding first."
157
+
58
158
  /** Convert to OpenAI tools parameter format */
59
159
  export function toOpenAISchema(tool) {
60
160
  return {
@@ -7,6 +7,7 @@ import { fileURLToPath } from "node:url"
7
7
  import { ansi, C } from "./ansi.mjs"
8
8
 
9
9
  const templateDir = join(fileURLToPath(import.meta.url), "..", "..", "prompts")
10
+ import { ENG_OFF_REMINDER } from "../agent.mjs"
10
11
 
11
12
  export async function handleEngCommand(ctx) {
12
13
  const { agent, pushLine, pushLabel, persistRaw, showPicker } = ctx
@@ -31,7 +32,14 @@ export async function handleEngCommand(ctx) {
31
32
  }
32
33
 
33
34
  agent.config.agent.engineering = !agent.config.agent.engineering
34
- if (!agent.config.agent.engineering) agent._engDesignToken = null // invalidate stale token
35
+ if (!agent.config.agent.engineering) {
36
+ agent._engDesignToken = null // invalidate stale token
37
+ // OFF must reach the model too (2026-08-25): /auto pushes a reminder on toggle — the
38
+ // mode flip is invisible to the agent otherwise. (ON needs none here: the injector
39
+ // in agent.mjs already announces ON transitions on the next turn.)
40
+ agent._pendingReminders = agent._pendingReminders ?? []
41
+ agent._pendingReminders.push(ENG_OFF_REMINDER)
42
+ }
35
43
  await persistRaw((raw) => {
36
44
  raw.agent ??= {}
37
45
  raw.agent.engineering = agent.config.agent.engineering