decorated-pi 0.7.2 → 0.8.0

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/settings.ts CHANGED
@@ -7,6 +7,7 @@ import * as fs from "node:fs";
7
7
  import * as path from "node:path";
8
8
  import * as os from "node:os";
9
9
  import type { Model } from "@earendil-works/pi-ai";
10
+ import { which } from "./utils/which.js";
10
11
 
11
12
  const CONFIG_DIR = path.join(os.homedir(), ".pi", "agent");
12
13
  const CONFIG_FILE = path.join(CONFIG_DIR, "decorated-pi.json");
@@ -69,15 +70,46 @@ export interface UsageIndexEntry {
69
70
  mtime: number;
70
71
  }
71
72
 
73
+ /** Per-binary dependency override.
74
+ *
75
+ * Keyed by binary name (e.g. "rtk", "wakatime-cli", "gopls",
76
+ * "codegraph"). `path` injects an extra search location into which();
77
+ * `dontBother` silences missing-dependency notifications for this binary. */
78
+ export interface DependencySettings {
79
+ /** Absolute path to the binary (file) or a directory to search. Injected
80
+ * into which()'s extendPath, so it's tried before $PATH. */
81
+ path?: string;
82
+ /** When true, skeleton's missing-dependency notification skips this
83
+ * binary. Use for binaries the user doesn't care about. */
84
+ dontBother?: boolean;
85
+ }
86
+
87
+ export interface DependencyView extends DependencySettings {
88
+ /** Runtime-only shadow value: what the resolver actually found.
89
+ * Not persisted to decorated-pi.json and not written by /dp-settings. */
90
+ resolvedPath?: string;
91
+ /** Runtime-only shadow value: whether the resolver checked/found it. */
92
+ resolvedState?: "ok" | "missing";
93
+ }
94
+
72
95
  export interface DecoratedPiConfig {
73
96
  imageModelKey?: string | null;
74
97
  compactModelKey?: string | null;
98
+ dependencies?: Record<string, DependencySettings>;
75
99
  providers?: Record<string, ProviderCache>;
76
100
  modules?: ModuleSettings;
77
101
  mcpServers?: Record<string, McpServerEntry>;
78
102
  usageIndex?: Record<string, UsageIndexEntry>;
79
103
  }
80
104
 
105
+ /** Runtime view = real config plus in-memory shadow fields. The shadow has
106
+ * the same top-level shape as config, but may enrich selected leaves with
107
+ * runtime-only data. It is never persisted and /dp-settings writes only the
108
+ * real config through setter functions. */
109
+ export interface DecoratedPiConfigView extends Omit<DecoratedPiConfig, "dependencies"> {
110
+ dependencies?: Record<string, DependencyView>;
111
+ }
112
+
81
113
  export function loadConfig(): DecoratedPiConfig {
82
114
  try {
83
115
  if (fs.existsSync(CONFIG_FILE)) {
@@ -99,6 +131,11 @@ export function saveConfig(config: Partial<DecoratedPiConfig>) {
99
131
  fs.writeFileSync(CONFIG_FILE, JSON.stringify({ ...current, ...config }, null, 2), "utf-8");
100
132
  }
101
133
 
134
+ // Runtime-only shadow for the whole config. It mirrors DecoratedPiConfig's
135
+ // top-level shape but is never persisted; runtime modules update it, and
136
+ // /dp-settings reads the merged view for display only.
137
+ const configShadow: DecoratedPiConfigView = {};
138
+
102
139
  // ─── 辅助 ──────────────────────────────────────────────────────────────────
103
140
 
104
141
  export function formatModelKey(m: Model<any>): string {
@@ -165,6 +202,77 @@ export function getCompactModelKey(): string | null {
165
202
  return loadConfig().compactModelKey ?? null;
166
203
  }
167
204
 
205
+ /** Look up a binary's configured path override. Returns null when not set. */
206
+ export function getDependencyPath(name: string): string | null {
207
+ return loadConfig().dependencies?.[name]?.path ?? null;
208
+ }
209
+
210
+ /** Whether missing-dependency notifications are silenced for this binary. */
211
+ export function isDontBother(name: string): boolean {
212
+ return loadConfig().dependencies?.[name]?.dontBother === true;
213
+ }
214
+
215
+ export function getConfigView(): DecoratedPiConfigView {
216
+ const real = loadConfig();
217
+ const dependencyNames = new Set([
218
+ ...Object.keys(real.dependencies ?? {}),
219
+ ...Object.keys(configShadow.dependencies ?? {}),
220
+ ]);
221
+ const dependencies: Record<string, DependencyView> = {};
222
+ for (const name of dependencyNames) {
223
+ dependencies[name] = {
224
+ ...(real.dependencies?.[name] ?? {}),
225
+ ...(configShadow.dependencies?.[name] ?? {}),
226
+ };
227
+ }
228
+ return {
229
+ ...real,
230
+ ...configShadow,
231
+ dependencies: Object.keys(dependencies).length ? dependencies : undefined,
232
+ };
233
+ }
234
+
235
+ export function getDependencyView(name: string): DependencyView {
236
+ return getConfigView().dependencies?.[name] ?? {};
237
+ }
238
+
239
+ export function listDependencyViewNames(extraNames: string[] = []): string[] {
240
+ return [...new Set([
241
+ ...extraNames,
242
+ ...Object.keys(getConfigView().dependencies ?? {}),
243
+ ])].sort();
244
+ }
245
+
246
+ export function recordDependencyResolution(name: string, resolvedPath: string | null): void {
247
+ if (!configShadow.dependencies) configShadow.dependencies = {};
248
+ configShadow.dependencies[name] = {
249
+ ...(configShadow.dependencies[name] ?? {}),
250
+ ...(resolvedPath
251
+ ? { resolvedState: "ok" as const, resolvedPath }
252
+ : { resolvedState: "missing" as const, resolvedPath: undefined }),
253
+ };
254
+ }
255
+
256
+ /** Resolve a binary using dependency config plus caller-specific search
257
+ * locations. Modules call this at startup/runtime; it records a shadow
258
+ * view for /dp-settings but does not persist anything.
259
+ *
260
+ * Order:
261
+ * 1. dependencies[name].path (file or directory)
262
+ * 2. opts.extendPath entries (file or directory)
263
+ * 3. $PATH
264
+ */
265
+ export function resolveDependency(name: string, opts?: { extendPath?: string[] }): string | null {
266
+ const override = getDependencyPath(name);
267
+ const extendPath = [
268
+ ...(override ? [override] : []),
269
+ ...(opts?.extendPath ?? []),
270
+ ];
271
+ const resolved = which(name, { extendPath });
272
+ recordDependencyResolution(name, resolved);
273
+ return resolved;
274
+ }
275
+
168
276
  // ─── Setter ─────────────────────────────────────────────────────────────────
169
277
 
170
278
  export function setImageModelKey(key: string | null) {
@@ -175,6 +283,47 @@ export function setCompactModelKey(key: string | null) {
175
283
  saveConfig({ compactModelKey: key });
176
284
  }
177
285
 
286
+ /** Set or clear a binary's path override. Pass null to remove the path.
287
+ * Preserves any dontBother flag on the same entry (精准修改不覆盖). */
288
+ export function setDependencyPath(name: string, path: string | null) {
289
+ const current = loadConfig();
290
+ const deps = { ...(current.dependencies ?? {}) };
291
+ const existing = deps[name] ?? {};
292
+ if (path === null) {
293
+ const { path: _drop, ...rest } = existing;
294
+ if (Object.keys(rest).length === 0) {
295
+ delete deps[name];
296
+ } else {
297
+ deps[name] = rest;
298
+ }
299
+ } else {
300
+ deps[name] = { ...existing, path };
301
+ }
302
+ // Path edits are config writes; invalidate runtime-only resolution for
303
+ // this binary so /dp-settings never shows a stale pre-edit shadow path.
304
+ if (configShadow.dependencies?.[name]) delete configShadow.dependencies[name];
305
+ saveConfig({ dependencies: deps });
306
+ }
307
+
308
+ /** Set or clear a binary's dontBother flag. Pass false to re-enable
309
+ * missing-dependency notifications. Preserves any path override. */
310
+ export function setDontBother(name: string, dontBother: boolean) {
311
+ const current = loadConfig();
312
+ const deps = { ...(current.dependencies ?? {}) };
313
+ const existing = deps[name] ?? {};
314
+ if (dontBother) {
315
+ deps[name] = { ...existing, dontBother: true };
316
+ } else {
317
+ const { dontBother: _drop, ...rest } = existing;
318
+ if (Object.keys(rest).length === 0) {
319
+ delete deps[name];
320
+ } else {
321
+ deps[name] = rest;
322
+ }
323
+ }
324
+ saveConfig({ dependencies: deps });
325
+ }
326
+
178
327
  // ─── Module Switches ──────────────────────────────────────────────────────────
179
328
 
180
329
  const DEFAULT_MODULES: Required<ModuleSettings> = {
@@ -321,14 +470,23 @@ export function getAllModuleSettings(): Required<ModuleSettings> {
321
470
  * reload is actually necessary.
322
471
  */
323
472
  let loadedModuleSnapshot: Required<ModuleSettings> | null = null;
473
+ let loadedDependenciesSnapshot: Record<string, DependencySettings> | null = null;
324
474
 
325
475
  export function captureModuleSnapshot(): void {
326
476
  loadedModuleSnapshot = getAllModuleSettings();
477
+ loadedDependenciesSnapshot = loadConfig().dependencies ?? {};
327
478
  }
328
479
 
329
480
  export function moduleSnapshotChanged(): boolean {
330
481
  if (!loadedModuleSnapshot) return true;
331
- return JSON.stringify(loadedModuleSnapshot) !== JSON.stringify(getAllModuleSettings());
482
+ if (JSON.stringify(loadedModuleSnapshot) !== JSON.stringify(getAllModuleSettings())) return true;
483
+ // Dependencies changes don't strictly require reload (which() reads the
484
+ // config file on every call), but prompt for consistency — the user
485
+ // might be mid-session and expect the new path to be picked up by hooks
486
+ // that captured the binary path at startup (rtk/wakatime).
487
+ const currentDeps = loadConfig().dependencies ?? {};
488
+ if (JSON.stringify(loadedDependenciesSnapshot) !== JSON.stringify(currentDeps)) return true;
489
+ return false;
332
490
  }
333
491
 
334
492
  // ─── Usage index (增量同步元数据) ─────────────────────────────────────────────
@@ -17,11 +17,8 @@ const askQuestionSchema = Type.Object({
17
17
  { description: "text = free input, single = one option, multi = many options" },
18
18
  ),
19
19
  question: Type.String({ description: "Question text shown to the user." }),
20
- options: Type.Optional(Type.Array(Type.String(), { description: "Options for single or multi choice." })),
20
+ options: Type.Optional(Type.Array(Type.String(), { description: "Options for single or multi choice. Each option MUST be a plain string (not an object). Example: [\"选项A\", \"选项B\", \"选项C\"]. The user picks by index; do NOT pass {id,text} objects." })),
21
21
  default: Type.Optional(Type.String({ description: "Default answer. For multi, comma-separated values." })),
22
- allowCustom: Type.Optional(Type.Boolean({
23
- description: "For single/multi: append an \"Other\" row that toggles into a free-text input. Lets the user enter a custom answer not in the preset list.",
24
- })),
25
22
  });
26
23
 
27
24
  function formatAnswer(q: AskQuestion, value: string | string[]): string {
@@ -2,9 +2,9 @@
2
2
  * LSP Server Config — language detection, server commands, workspace roots.
3
3
  */
4
4
  import { existsSync, readdirSync, type Dirent } from "node:fs";
5
- import { spawnSync } from "node:child_process";
6
5
  import { dirname, extname, isAbsolute, join, resolve } from "node:path";
7
6
  import type { DependencyStatus } from "../../hooks/skeleton.js";
7
+ import { getDependencyPath, resolveDependency } from "../../settings.js";
8
8
 
9
9
  // ─── File extension → language mapping ────────────────────────────────────
10
10
 
@@ -97,6 +97,16 @@ export function listSupportedLanguages(): string[] {
97
97
  return Object.keys(LANGUAGE_SERVERS).sort();
98
98
  }
99
99
 
100
+ /** Unique binary names used by builtin LSP servers. Used by the
101
+ * /dp-settings Dependencies UI to know which binaries are configurable. */
102
+ export function listLspBinaryNames(): string[] {
103
+ const seen = new Set<string>();
104
+ for (const lang of Object.keys(LANGUAGE_SERVERS)) {
105
+ seen.add(LANGUAGE_SERVERS[lang].command);
106
+ }
107
+ return [...seen].sort();
108
+ }
109
+
100
110
  export function getServerConfig(
101
111
  language: string,
102
112
  cwd = process.cwd(),
@@ -104,6 +114,12 @@ export function getServerConfig(
104
114
  const base = LANGUAGE_SERVERS[language];
105
115
  if (!base) return undefined;
106
116
 
117
+ const override = getDependencyPath(base.command);
118
+ if (override) {
119
+ const resolvedOverride = resolveDependency(base.command, { extendPath: [] });
120
+ return { ...base, command: resolvedOverride ?? override, is_project_local: false };
121
+ }
122
+
107
123
  const resolved = resolveLocalBinary(base.command, cwd);
108
124
  return { ...base, command: resolved.command, is_project_local: resolved.is_project_local };
109
125
  }
@@ -147,14 +163,16 @@ export function collectLspDependencyStatuses(cwd: string): DependencyStatus[] {
147
163
  const statuses: DependencyStatus[] = [];
148
164
  const seen = new Set<string>();
149
165
  for (const language of listSupportedLanguages()) {
150
- const cfg = getServerConfig(language, cwd);
151
- if (!cfg || seen.has(cfg.command)) continue;
152
- seen.add(cfg.command);
166
+ const base = LANGUAGE_SERVERS[language];
167
+ if (!base || seen.has(base.command)) continue;
168
+ seen.add(base.command);
169
+ const path = resolveLspBinary(base.command, cwd);
153
170
  statuses.push({
154
171
  module: "lsp",
155
- label: cfg.command,
156
- state: commandExists(cfg.command) ? "ok" : "missing",
157
- detail: cfg.install_hint,
172
+ label: base.command,
173
+ state: path ? "ok" : "missing",
174
+ detail: base.install_hint,
175
+ path: path ?? undefined,
158
176
  });
159
177
  }
160
178
  return statuses;
@@ -174,14 +192,12 @@ export function findWorkspaceRoot(
174
192
 
175
193
  // ─── Internal helpers ─────────────────────────────────────────────────────
176
194
 
177
- function commandExists(command: string): boolean {
178
- if (isAbsolute(command) || command.includes("/") || command.includes("\\")) {
179
- return existsSync(command);
180
- }
181
- const result = process.platform === "win32"
182
- ? spawnSync("where", [command], { encoding: "utf-8" })
183
- : spawnSync(process.env.SHELL || "sh", ["-lc", `command -v '${command.replace(/'/g, `'"'"'`)}'`], { encoding: "utf-8" });
184
- return result.status === 0;
195
+ export function lspDependencyExtendPath(cwd = process.cwd()): string[] {
196
+ return [...ancestorDirs(cwd)].map((dir) => join(dir, "node_modules", ".bin"));
197
+ }
198
+
199
+ export function resolveLspBinary(command: string, cwd = process.cwd()): string | null {
200
+ return resolveDependency(command, { extendPath: lspDependencyExtendPath(cwd) });
185
201
  }
186
202
 
187
203
  function resolveLocalBinary(
@@ -15,11 +15,11 @@
15
15
  import * as fs from "node:fs";
16
16
  import * as os from "node:os";
17
17
  import * as path from "node:path";
18
- import { spawnSync } from "node:child_process";
19
- import { isModuleEnabled } from "../../settings.js";
18
+ import { isModuleEnabled, resolveDependency } from "../../settings.js";
20
19
  import type { DependencyStatus } from "../../hooks/skeleton.js";
21
20
  import { BUILTIN_MCP_SERVERS } from "./builtin/index.js";
22
21
 
22
+
23
23
  export { BUILTIN_MCP_SERVERS } from "./builtin/index.js";
24
24
 
25
25
  export interface McpServerConfig {
@@ -33,6 +33,8 @@ export interface McpServerConfig {
33
33
  source: "builtin" | "global" | "project";
34
34
  /** Optional predicate: return false if this server cannot be used in the given project. */
35
35
  canUseInProject?: (cwd: string) => boolean;
36
+ /** Binary/config key before dependency path resolution. */
37
+ dependencyName?: string;
36
38
  }
37
39
 
38
40
  function globalMcpJsonPath(): string {
@@ -217,33 +219,50 @@ export function resolveMcpConfigs(cwd: string): McpServerConfig[] {
217
219
  }
218
220
  }
219
221
 
220
- return [...byName.values()].filter((s) => s.url || s.command);
222
+ return [...byName.values()]
223
+ .filter((s) => s.url || s.command)
224
+ .map((s) => {
225
+ // Apply user-configured binary path override (dependencies[cmd].path)
226
+ // last, so it wins over builtin/global/project configs. Only applies
227
+ // to servers that use a command (not URL-based servers).
228
+ if (!s.command) return s;
229
+ const dependencyName = s.command;
230
+ const resolved = resolveMcpBinary(dependencyName);
231
+ return { ...s, dependencyName, command: resolved ?? s.command };
232
+ });
233
+ }
234
+
235
+ /** Unique binary names used by builtin MCP servers that have a `command`
236
+ * (URL-based servers are excluded — they don't need a binary). */
237
+ export function listMcpBinaryNames(): string[] {
238
+ const seen = new Set<string>();
239
+ for (const s of BUILTIN_MCP_SERVERS) {
240
+ if (s.command) seen.add(s.command);
241
+ }
242
+ return [...seen].sort();
221
243
  }
222
244
 
223
245
  export function collectMcpDependencyStatuses(cwd: string): DependencyStatus[] {
224
246
  const seen = new Set<string>();
225
247
  const statuses: DependencyStatus[] = [];
226
248
  for (const cfg of resolveMcpConfigs(cwd)) {
227
- if (!cfg.enabled || !cfg.command || seen.has(cfg.command)) continue;
228
- seen.add(cfg.command);
249
+ const depName = cfg.dependencyName ?? cfg.command;
250
+ if (!cfg.enabled || !cfg.command || !depName || seen.has(depName)) continue;
251
+ seen.add(depName);
252
+ const resolved = resolveMcpBinary(depName);
229
253
  statuses.push({
230
254
  module: `mcp:${cfg.name}`,
231
- label: cfg.command,
232
- state: commandExists(cfg.command) ? "ok" : "missing",
255
+ label: depName,
256
+ state: resolved ? "ok" : "missing",
233
257
  detail: `Install the MCP server command for \"${cfg.name}\" or update its config.`,
258
+ path: resolved ?? undefined,
234
259
  });
235
260
  }
236
261
  return statuses;
237
262
  }
238
263
 
239
- function commandExists(command: string): boolean {
240
- if (path.isAbsolute(command) || command.includes("/") || command.includes("\\")) {
241
- return fs.existsSync(command);
242
- }
243
- const result = process.platform === "win32"
244
- ? spawnSync("where", [command], { encoding: "utf-8" })
245
- : spawnSync(process.env.SHELL || "sh", ["-lc", `command -v '${command.replace(/'/g, `'"'"'`)}'`], { encoding: "utf-8" });
246
- return result.status === 0;
264
+ export function resolveMcpBinary(command: string): string | null {
265
+ return resolveDependency(command);
247
266
  }
248
267
 
249
268
  function readMcpJsonSafe(filePath: string): Record<string, any> | null {
@@ -11,7 +11,12 @@
11
11
 
12
12
  import * as fs from "node:fs";
13
13
  import * as path from "node:path";
14
- import * as fsPromises from "node:fs/promises";
14
+ import {
15
+ detectFileEncoding,
16
+ readFileDecoded,
17
+ writeFileEncoded,
18
+ type FileEncoding,
19
+ } from "./encoding.js";
15
20
 
16
21
  // ═══════════════════════════════════════════════════════════════════════════
17
22
  // Types
@@ -63,6 +68,12 @@ export interface ReplacementInfo {
63
68
  oldLines: string[];
64
69
  /** The new lines that replaced them */
65
70
  newLines: string[];
71
+ /** Verbatim normalized replacement text (for byte-faithful writeback). */
72
+ newStr?: string;
73
+ /** Offset in normalized content where the matched region starts (writeback). */
74
+ normStart?: number;
75
+ /** Offset one past the matched region in normalized content (writeback). */
76
+ normEnd?: number;
66
77
  /** Optional anchor text (first line only, for hunk display) */
67
78
  anchor?: string;
68
79
  /** Anchor was provided but not found, and patch fell back to global old_str search */
@@ -156,13 +167,20 @@ function applyOverwrite(
156
167
  content: string,
157
168
  result: PatchResult,
158
169
  ): void {
159
- const oldContent = fs.existsSync(absPath) ? fs.readFileSync(absPath, "utf8") : "";
170
+ // Detect encoding from the existing file so we round-trip in the same
171
+ // bytes. New files default to UTF-8 (no BOM).
172
+ const enc: FileEncoding | null = fs.existsSync(absPath)
173
+ ? detectFileEncoding(absPath)
174
+ : null;
175
+ const oldContent = enc ? readFileDecoded(absPath, enc) : "";
160
176
 
161
177
  // Write to temp file in the same directory (same filesystem → mv is atomic)
162
178
  ensureParentDir(absPath);
163
179
  const dir = path.dirname(absPath);
164
180
  const tmpName = path.join(dir, `.pi-patch-${randomId()}.tmp`);
165
- fs.writeFileSync(tmpName, content, "utf8");
181
+ // Overwrite semantics: write exactly what the caller passed, in the
182
+ // detected encoding (UTF-8 for new files).
183
+ writeFileEncoded(tmpName, content, enc ?? { encoding: "utf-8", hasBOM: false, isUtf8: true });
166
184
  fs.renameSync(tmpName, absPath);
167
185
 
168
186
  if (oldContent) {
@@ -287,8 +305,8 @@ async function applyEdits(
287
305
  throw new ApplyError(`Cannot patch directory: ${displayPath}`);
288
306
  }
289
307
 
290
- const rawContent = fs.readFileSync(absPath, "utf8");
291
- const lineEnding = detectLineEnding(rawContent);
308
+ const enc = detectFileEncoding(absPath);
309
+ const rawContent = readFileDecoded(absPath, enc);
292
310
  const originalContent = normalizeLineEndings(rawContent);
293
311
 
294
312
  // Precompute line offsets for the original file (used throughout)
@@ -349,6 +367,8 @@ async function applyEdits(
349
367
 
350
368
  const oldStartLine = lineAtOffset(lineOffsets, matchIdx - cumulativeOffset);
351
369
  const oldEndLine = lineAtOffset(lineOffsets, matchIdx - cumulativeOffset + oldNorm.length - 1);
370
+ const normStart = matchIdx - cumulativeOffset;
371
+ const normEnd = normStart + oldNorm.length;
352
372
 
353
373
  content =
354
374
  content.substring(0, matchIdx) +
@@ -364,6 +384,9 @@ async function applyEdits(
364
384
  newEndLine: 0,
365
385
  oldLines: oldNorm.split("\n").filter((l, i, arr) => !(i === arr.length - 1 && l === "")),
366
386
  newLines: newNorm.split("\n").filter((l, i, arr) => !(i === arr.length - 1 && l === "")),
387
+ newStr: newNorm,
388
+ normStart,
389
+ normEnd,
367
390
  anchor: displayAnchor ? displayAnchor.split("\n")[0] : undefined,
368
391
  anchorMissing,
369
392
  });
@@ -392,12 +415,18 @@ async function applyEdits(
392
415
  result.diff = fileDiff;
393
416
  }
394
417
 
395
- const finalContent = restoreLineEndings(content, lineEnding);
396
- if (lineEnding === "\r\n" && rawContent.includes("\r\n")) {
397
- result.warnings.push(`${displayPath}: CRLF line endings were normalized to LF during editing.`);
398
- }
418
+ const finalContent = spliceOntoRaw(
419
+ rawContent,
420
+ cleanReplacements
421
+ .map((r) => ({
422
+ normStart: r.normStart ?? 0,
423
+ normEnd: r.normEnd ?? 0,
424
+ newStr: r.newStr ?? r.newLines.join("\n"),
425
+ }))
426
+ .sort((a, b) => a.normStart - b.normStart),
427
+ );
399
428
 
400
- fs.writeFileSync(absPath, finalContent, "utf8");
429
+ writeFileEncoded(absPath, finalContent, enc);
401
430
  result.modified.push(displayPath);
402
431
  result.replacements.set(displayPath, cleanReplacements);
403
432
  return;
@@ -495,14 +524,16 @@ async function applyEdits(
495
524
  result.diff = fileDiff;
496
525
  }
497
526
 
498
- // Restore line endings
499
- const finalContent = restoreLineEndings(content, lineEnding);
500
-
501
- if (lineEnding === "\r\n" && rawContent.includes("\r\n")) {
502
- result.warnings.push(`${displayPath}: CRLF line endings were normalized to LF during editing.`);
503
- }
527
+ const finalContent = spliceOntoRaw(
528
+ rawContent,
529
+ sorted.map((p) => ({
530
+ normStart: p.matchIdx,
531
+ normEnd: p.matchIdx + p.oldNorm.length,
532
+ newStr: p.newNorm,
533
+ })),
534
+ );
504
535
 
505
- fs.writeFileSync(absPath, finalContent, "utf8");
536
+ writeFileEncoded(absPath, finalContent, enc);
506
537
  result.modified.push(displayPath);
507
538
  result.replacements.set(displayPath, replacements);
508
539
  }
@@ -541,7 +572,8 @@ export async function computePatchPreview(
541
572
  return { error: "File not found" };
542
573
  }
543
574
 
544
- const rawContent = await fsPromises.readFile(absPath, "utf8");
575
+ const enc = detectFileEncoding(absPath);
576
+ const rawContent = readFileDecoded(absPath, enc);
545
577
  const lineOffsets = buildLineOffsets(rawContent);
546
578
  const totalLines = lineOffsets.length - 1;
547
579
  let content = normalizeLineEndings(rawContent);
@@ -1053,16 +1085,68 @@ function ensureParentDir(absPath: string): void {
1053
1085
  if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
1054
1086
  }
1055
1087
 
1056
- function detectLineEnding(content: string): string {
1057
- return content.includes("\r\n") ? "\r\n" : "\n";
1058
- }
1059
-
1060
1088
  function normalizeLineEndings(text: string): string {
1061
1089
  return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
1062
1090
  }
1063
1091
 
1064
- function restoreLineEndings(text: string, ending: string): string {
1065
- return ending === "\r\n" ? text.replace(/\n/g, "\r\n") : text;
1092
+ /** A replacement expressed in coordinates of the normalized (\n-only) content,
1093
+ * paired with the exact new_str to drop in. Used by spliceOntoRaw to rebuild
1094
+ * the file byte-for-byte on the original rawContent. */
1095
+ interface RawSplice {
1096
+ /** Offset in the normalized content where the matched text starts. */
1097
+ normStart: number;
1098
+ /** Offset in the normalized content one past the matched text. */
1099
+ normEnd: number;
1100
+ /** Verbatim replacement text (already normalized to \n). */
1101
+ newStr: string;
1102
+ }
1103
+
1104
+ /** Map every index of the normalized content to its offset in rawContent.
1105
+ * The two strings differ only by `\r` bytes (CRLF→LF normalization removed
1106
+ * them), so we walk both in lockstep. O(n). */
1107
+ function buildNormToRawMap(raw: string, norm: string): Int32Array {
1108
+ const map = new Int32Array(norm.length + 1);
1109
+ let ri = 0;
1110
+ for (let ni = 0; ni <= norm.length; ni++) {
1111
+ // Skip any `\r` in raw that the normalization folded into `\n`.
1112
+ // norm[ni] corresponds to raw[ri]; when norm advances past a `\n` that
1113
+ // came from `\r\n`, raw must skip the `\r` first.
1114
+ if (ni < norm.length) {
1115
+ map[ni] = ri;
1116
+ const ch = norm.charCodeAt(ni);
1117
+ const rawCh = raw.charCodeAt(ri);
1118
+ if (ch === 10 /* \n */ && rawCh === 13 /* \r */) {
1119
+ // raw had \r\n; advance past \r then \n
1120
+ ri += 2;
1121
+ } else {
1122
+ ri += 1;
1123
+ }
1124
+ } else {
1125
+ map[ni] = raw.length;
1126
+ }
1127
+ }
1128
+ return map;
1129
+ }
1130
+
1131
+ /** Rebuild the file on top of the original rawContent: untouched regions keep
1132
+ * their original bytes (including CRLF / mixed endings), edited regions get
1133
+ * the verbatim newStr the caller supplied. Splices must be sorted by
1134
+ * normStart and non-overlapping. */
1135
+ function spliceOntoRaw(rawContent: string, splices: RawSplice[]): string {
1136
+ if (splices.length === 0) return rawContent;
1137
+ const norm = normalizeLineEndings(rawContent);
1138
+ const map = buildNormToRawMap(rawContent, norm);
1139
+ let out = "";
1140
+ let rawCursor = 0;
1141
+ for (const s of splices) {
1142
+ const rawStart = map[s.normStart] ?? 0;
1143
+ const rawEnd = map[s.normEnd] ?? rawContent.length;
1144
+ if (rawStart > rawCursor) out += rawContent.substring(rawCursor, rawStart);
1145
+ out += s.newStr;
1146
+ rawCursor = rawEnd;
1147
+ }
1148
+ if (rawCursor < rawContent.length) out += rawContent.substring(rawCursor);
1149
+ return out;
1066
1150
  }
1067
1151
 
1068
1152
  // ═══════════════════════════════════════════════════════════════════════════
@@ -1203,6 +1287,9 @@ function collapseSequentialReplacements(
1203
1287
  newEndLine: merged.oldStartLine + next.newLines.length - 1,
1204
1288
  oldLines: merged.oldLines,
1205
1289
  newLines: next.newLines,
1290
+ newStr: next.newStr,
1291
+ normStart: merged.normStart,
1292
+ normEnd: merged.normEnd,
1206
1293
  anchor: undefined,
1207
1294
  anchorMissing: merged.anchorMissing || next.anchorMissing,
1208
1295
  };
@@ -1530,4 +1617,6 @@ export const __patchCoreTest = {
1530
1617
  truncate,
1531
1618
  collapseSequentialReplacements,
1532
1619
  generateReplacementDiff,
1620
+ spliceOntoRaw,
1621
+ buildNormToRawMap,
1533
1622
  };