decorated-pi 0.7.3 → 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/README.md CHANGED
@@ -14,7 +14,9 @@ pi install /path/to/decorated-pi
14
14
 
15
15
  ### 1. Token Efficiency
16
16
 
17
- Multiple layers of token savings that compound across every session.
17
+ Multiple layers of token savings that compound across every session.
18
+
19
+ **Talk Normal Prompt** — injects a compact response-style prompt adapted from [talk-normal](https://github.com/hexiecs/talk-normal), trimming filler, summary stamps, conditional follow-up menus, and verbose framing. This reduces assistant output tokens and keeps visible reasoning / explanation blocks tighter.
18
20
 
19
21
  **RTK** — integrates [RTK](https://github.com/rtk-ai/rtk) to compress bash output into structured summaries, so the LLM never sees raw noise. **Just install the CLI, zero config**.
20
22
 
package/commands/usage.ts CHANGED
@@ -401,7 +401,11 @@ export function formatCost(c: number): string {
401
401
 
402
402
  export function formatHitRate(hitRate: number, turns: number): string {
403
403
  if (turns === 0) return "—";
404
- return `${hitRate.toFixed(1)}%`;
404
+ // Truncate (not round) beyond 1 decimal place: 99.97 → "99.9%", not
405
+ // "100.0%". A true 100.0 (every input-side token served from cache) is
406
+ // the only way to reach "100.0%".
407
+ const truncated = Math.floor(hitRate * 10) / 10;
408
+ return `${truncated.toFixed(1)}%`;
405
409
  }
406
410
 
407
411
  export type ColumnId = "input" | "output" | "cacheRead" | "cacheWrite" | "hitRate" | "cost";
@@ -1,5 +1,5 @@
1
1
  /**
2
- * compaction — custom compaction model + auto-resume.
2
+ * compaction — custom compaction model.
3
3
  *
4
4
  * On `session_before_compact`, runs pi-coding-agent's `compact()` against
5
5
  * the model configured in settings (rather than the agent's current model)
@@ -7,82 +7,25 @@
7
7
  * default. If the configured model is missing, auth fails, or the call
8
8
  * throws, we fall through (return undefined) and pi runs its own compaction.
9
9
  *
10
- * On `session_compact`, if the compaction was auto-triggered, sends a
11
- * "continue" message to resume the agent loop.
10
+ * Auto-retry/resume is handled by pi natively; `reason` and `willRetry`
11
+ * on compaction events describe manual, threshold, and overflow flows.
12
12
  */
13
13
 
14
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
15
14
  import { compact } from "@earendil-works/pi-coding-agent";
16
15
  import type { Model } from "@earendil-works/pi-ai";
17
- import { isContextOverflow } from "@earendil-works/pi-ai";
18
- import * as fs from "node:fs";
19
- import * as os from "node:os";
20
- import { resolve } from "node:path";
21
16
  import { getCompactModelKey } from "../settings.js";
22
17
  import { parseModelKey } from "../settings.js";
23
18
  import type { Module, Skeleton } from "./skeleton.js";
24
19
 
25
- interface PiCompactionSettings {
26
- enabled: boolean;
27
- }
28
-
29
- interface AutoCompactionCandidate {
30
- messages: any[];
31
- usage: { tokens: number | null; contextWindow: number } | undefined;
32
- }
33
-
34
- const DEFAULT_PI_COMPACTION_SETTINGS: PiCompactionSettings = {
35
- enabled: true,
36
- };
37
-
38
- function readJsonObject(filePath: string): any | undefined {
39
- try {
40
- if (!fs.existsSync(filePath)) return undefined;
41
- const parsed = JSON.parse(fs.readFileSync(filePath, "utf-8"));
42
- return parsed && typeof parsed === "object" ? parsed : undefined;
43
- } catch {
44
- return undefined;
45
- }
46
- }
20
+ type CompactionReason = "manual" | "threshold" | "overflow";
47
21
 
48
- function loadPiCompactionSettings(cwd: string): PiCompactionSettings {
49
- const globalSettings = readJsonObject(resolve(os.homedir(), ".pi", "agent", "settings.json"));
50
- const projectSettings = readJsonObject(resolve(cwd, ".pi", "settings.json"));
51
- const merged = {
52
- ...DEFAULT_PI_COMPACTION_SETTINGS,
53
- ...(globalSettings?.compaction ?? {}),
54
- ...(projectSettings?.compaction ?? {}),
55
- };
56
- return {
57
- enabled: merged.enabled !== false,
58
- };
22
+ interface CompactionEventMetadata {
23
+ reason: CompactionReason;
24
+ willRetry: boolean;
59
25
  }
60
26
 
61
- function getLastAssistantMessage(messages: any[]): any | undefined {
62
- for (let i = messages.length - 1; i >= 0; i--) {
63
- if (messages[i]?.role === "assistant") return messages[i];
64
- }
65
- return undefined;
66
- }
67
-
68
- /** Mirror pi's _runAutoCompaction(reason, willRetry) decision:
69
- * - overflow → willRetry=true → auto-resume the agent
70
- * - threshold → willRetry=false → no auto-resume (user continues manually)
71
- * - manual /compact → no resume
72
- * Detected from the post-agent-end candidate: overflow shows up as a
73
- * context-overflow error on the last assistant message; threshold is a
74
- * pre-emptive compaction and we intentionally skip auto-resume for it. */
75
- function shouldAutoResumeCompaction(
76
- candidate: AutoCompactionCandidate | null,
77
- settings: PiCompactionSettings,
78
- customInstructions?: string,
79
- ): boolean {
80
- if (customInstructions !== undefined) return false;
81
- if (!candidate || !settings.enabled) return false;
82
- const lastAssistant = getLastAssistantMessage(candidate.messages);
83
- if (!lastAssistant) return false;
84
- const contextWindow = candidate.usage?.contextWindow ?? 0;
85
- return contextWindow > 0 && isContextOverflow(lastAssistant, contextWindow);
27
+ function formatCompactionMode(event: CompactionEventMetadata): string {
28
+ return event.willRetry ? `${event.reason}, retrying` : event.reason;
86
29
  }
87
30
 
88
31
  function getConfiguredCompactModel(registry: any): Model<any> | null {
@@ -93,71 +36,11 @@ function getConfiguredCompactModel(registry: any): Model<any> | null {
93
36
  return registry.find(parsed.provider, parsed.modelId) ?? null;
94
37
  }
95
38
 
96
- /** Per-session state. Indexed by sessionId (from ctx.sessionManager) so
97
- * two concurrent pi sessions don't trample each other's flags. */
98
- interface SessionState {
99
- postAgentEndCandidate: AutoCompactionCandidate | null;
100
- currentCompactionIsAuto: boolean;
101
- }
102
-
103
- const sessionStates = new Map<string, SessionState>();
104
-
105
- function getSessionState(sessionId: string): SessionState {
106
- let s = sessionStates.get(sessionId);
107
- if (!s) {
108
- s = { postAgentEndCandidate: null, currentCompactionIsAuto: false };
109
- sessionStates.set(sessionId, s);
110
- }
111
- return s;
112
- }
113
-
114
39
  export const compactionModule: Module = {
115
40
  name: "compaction",
116
41
  hooks: {
117
- session_shutdown: [
118
- (_event, ctx) => {
119
- // Clean up state when the session ends so the Map doesn't grow.
120
- sessionStates.delete(ctx.sessionManager.getSessionId());
121
- },
122
- ],
123
- input: [
124
- (_event, ctx) => {
125
- getSessionState(ctx.sessionManager.getSessionId()).postAgentEndCandidate = null;
126
- },
127
- ],
128
- before_agent_start: [
129
- (_event, ctx) => {
130
- getSessionState(ctx.sessionManager.getSessionId()).postAgentEndCandidate = null;
131
- },
132
- ],
133
- agent_start: [
134
- (_event, ctx) => {
135
- getSessionState(ctx.sessionManager.getSessionId()).postAgentEndCandidate = null;
136
- },
137
- ],
138
- agent_end: [
139
- (event, ctx) => {
140
- getSessionState(ctx.sessionManager.getSessionId()).postAgentEndCandidate = {
141
- messages: event.messages,
142
- usage: ctx.getContextUsage(),
143
- };
144
- },
145
- ],
146
42
  session_before_compact: [
147
43
  async (event, ctx, pi) => {
148
- const sessionState = getSessionState(ctx.sessionManager.getSessionId());
149
- const compactionSettings = loadPiCompactionSettings(ctx.cwd);
150
- // Pi only auto-compacts after agent_end (see _checkCompaction in
151
- // agent-session.js), so we detect "auto" via the post-agent-end
152
- // overflow heuristic. Manual /compact carries customInstructions
153
- // and skips auto-resume.
154
- const isAutoResume = shouldAutoResumeCompaction(
155
- sessionState.postAgentEndCandidate,
156
- compactionSettings,
157
- event.customInstructions,
158
- );
159
- sessionState.postAgentEndCandidate = null;
160
-
161
44
  const model = getConfiguredCompactModel(ctx.modelRegistry);
162
45
  if (!model) return; // No custom compact model configured → let pi run its default compaction.
163
46
  const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
@@ -168,7 +51,7 @@ export const compactionModule: Module = {
168
51
  const { preparation, customInstructions, signal } = event;
169
52
  if (ctx.hasUI) {
170
53
  ctx.ui.notify(
171
- `🗜️ Compacting with ${model.id} (${preparation.tokensBefore.toLocaleString()} tokens)...`,
54
+ `📦 Compacting with ${model.id} (${formatCompactionMode(event)}, ${preparation.tokensBefore.toLocaleString()} tokens)...`,
172
55
  "info",
173
56
  );
174
57
  }
@@ -189,11 +72,6 @@ export const compactionModule: Module = {
189
72
  signal,
190
73
  thinkingLevel,
191
74
  );
192
- // Only mark as auto-resumed if OUR hook did the compaction. If we
193
- // had fallen through to pi's default path above, pi's own
194
- // _runAutoCompaction would call agent.continue() on overflow;
195
- // marking here would cause a duplicate resume message on top.
196
- sessionState.currentCompactionIsAuto = isAutoResume;
197
75
  return { compaction: result };
198
76
  } catch (err) {
199
77
  if (signal.aborted) return;
@@ -209,19 +87,6 @@ export const compactionModule: Module = {
209
87
  }
210
88
  },
211
89
  ],
212
- session_compact: [
213
- (_event, ctx, pi) => {
214
- const sessionState = getSessionState(ctx.sessionManager.getSessionId());
215
- const shouldResume = sessionState.currentCompactionIsAuto;
216
- sessionState.currentCompactionIsAuto = false;
217
- if (!shouldResume) return;
218
- pi.sendMessage({
219
- customType: "auto_compact_resume",
220
- content: "The context was just auto-compacted. Continue the current task based on the summary above. Do not repeat completed work. If unsure about progress, briefly summarize current state then continue.",
221
- display: false,
222
- }, { triggerTurn: true });
223
- },
224
- ],
225
90
  },
226
91
  };
227
92
 
@@ -230,5 +95,5 @@ export function setupCompaction(sk: Skeleton): void {
230
95
  }
231
96
 
232
97
  export const __modelIntegrationTest = {
233
- shouldAutoResumeCompaction,
98
+ formatCompactionMode,
234
99
  };
package/hooks/skeleton.ts CHANGED
@@ -25,7 +25,8 @@ export type HookEvent =
25
25
  | "agent_end"
26
26
  | "input"
27
27
  | "tool_call"
28
- | "tool_result";
28
+ | "tool_result"
29
+ | "message_end";
29
30
 
30
31
  // ─── Handler modes ─────────────────────────────────────────────────────────
31
32
 
@@ -67,6 +68,7 @@ export interface Module {
67
68
  input?: ParallelHandler<"input">[];
68
69
  tool_call?: ComposeHandler<"tool_call">[];
69
70
  tool_result?: ComposeHandler<"tool_result">[];
71
+ message_end?: ComposeHandler<"message_end">[];
70
72
  };
71
73
  }
72
74
 
@@ -99,6 +101,7 @@ const COMPOSE_EVENTS = new Set<HookEvent>([
99
101
  "before_agent_start",
100
102
  "tool_call",
101
103
  "tool_result",
104
+ "message_end",
102
105
  ]);
103
106
 
104
107
  /** Events whose handler return value is propagated to the extension runner
package/hooks/smart-at.ts CHANGED
@@ -6,6 +6,10 @@
6
6
  * frecency + git status, and returns ranked results. We create one
7
7
  * FileFinder per session and query it directly for every @ prefix.
8
8
  *
9
+ * Result handling trusts FFF's native score: re-sort by score
10
+ * descending (shorter path breaks ties), no substring or git-ignore
11
+ * post-filtering.
12
+ *
9
13
  * KNOWN FFF LIMITATION (v0.9.4):
10
14
  * FFF only returns directories that directly contain files. Intermediate
11
15
  * folders that only hold subdirectories (e.g. product/module/apmanage/ where
@@ -24,7 +28,6 @@ import type { Module, Skeleton } from "./skeleton.js";
24
28
  const AT_BOUNDARY = new Set<string>([" ", "\t", "(", "["]);
25
29
 
26
30
  const AUTOCOMPLETE_LIMIT = 20;
27
- const FFF_SUPERSET = 100;
28
31
  const WIDGET_FOOTER = "\x1b[2mpowered by fff\x1b[0m";
29
32
 
30
33
  interface AutocompleteItem {
@@ -45,10 +48,6 @@ function atPrefix(text: string): string | null {
45
48
  return null;
46
49
  }
47
50
 
48
- function isGitIgnored(item: MixedItem): boolean {
49
- return item.type === "file" && item.item.gitStatus === "ignored";
50
- }
51
-
52
51
  function toAutocompleteItem(item: MixedItem): AutocompleteItem {
53
52
  const path = item.item.relativePath;
54
53
  const label = item.type === "file"
@@ -65,23 +64,20 @@ interface BuiltResult {
65
64
  items: AutocompleteItem[];
66
65
  }
67
66
 
68
- /** Take FFF's ranked results, drop git-ignored files, and for non-empty
69
- * queries keep only paths that contain the query as a substring. This
70
- * stops FFF's loose fuzzy matching from ranking every file in a directory
71
- * that happens to share a few letters (e.g. "mm/" for query "mmc"). */
72
- function buildResult(items: MixedItem[], lowerQuery: string): BuiltResult | null {
73
- const filtered = items
74
- .filter((it) => !isGitIgnored(it))
75
- .filter(
76
- (it) =>
77
- !lowerQuery || it.item.relativePath.toLowerCase().includes(lowerQuery),
78
- )
67
+ /** Take FFF's mixed-search results and re-sort by native score descending
68
+ * (score ties broken by shorter path first), then map to autocomplete
69
+ * items. Trust FFF's fuzzy+frecency score; no substring or git-ignore
70
+ * post-filtering. */
71
+ function buildResult(items: MixedItem[], scores: { total: number }[]): BuiltResult | null {
72
+ const ranked = items
73
+ .map((item, index) => ({ item, score: scores[index]?.total ?? 0 }))
74
+ .sort((a, b) => b.score - a.score || a.item.item.relativePath.length - b.item.item.relativePath.length)
79
75
  .slice(0, AUTOCOMPLETE_LIMIT)
80
- .map(toAutocompleteItem);
81
- return filtered.length ? { items: filtered } : null;
76
+ .map((entry) => toAutocompleteItem(entry.item));
77
+ return ranked.length ? { items: ranked } : null;
82
78
  }
83
79
 
84
- export const __smartAtTest = { atPrefix };
80
+ export const __smartAtTest = { atPrefix, buildResult };
85
81
 
86
82
  /** Active FileFinder for the current session; freed in session_shutdown
87
83
  * to avoid leaking FFF's native handle + LMDB mmap regions. */
@@ -148,9 +144,8 @@ export const smartAtModule: Module = {
148
144
  }
149
145
 
150
146
  const query = prefix.slice(1);
151
- const lowerQuery = query.toLowerCase();
152
- const r = finder.mixedSearch(lowerQuery, {
153
- pageSize: lowerQuery ? FFF_SUPERSET : AUTOCOMPLETE_LIMIT,
147
+ const r = finder.mixedSearch(query.toLowerCase(), {
148
+ pageSize: AUTOCOMPLETE_LIMIT,
154
149
  });
155
150
  if (!r.ok) {
156
151
  ctx.ui.setWidget("smart-at", undefined);
@@ -178,7 +173,7 @@ export const smartAtModule: Module = {
178
173
  return null;
179
174
  }
180
175
 
181
- const result = buildResult(r.value.items, lowerQuery);
176
+ const result = buildResult(r.value.items, r.value.scores);
182
177
  if (!result) {
183
178
  ctx.ui.setWidget("smart-at", undefined);
184
179
  return null;
@@ -0,0 +1,79 @@
1
+ /**
2
+ * thinking-label-strip — remove stray <thinking></thinking> tags from
3
+ * assistant thinking blocks.
4
+ *
5
+ * Some models wrap their own reasoning text in literal <thinking></thinking>
6
+ * tags inside the `thinking` field of a ThinkingContent block. The tag
7
+ * itself is noise — pi already marks the block as type "thinking", so the
8
+ * tag is redundant. When it leaks into the context it confuses downstream
9
+ * consumers (compaction, other models that echo it back) and pollutes the
10
+ * session transcript.
11
+ *
12
+ * This hook intercepts `message_end` for assistant messages and strips
13
+ * every <thinking></thinking> pair from each ThinkingContent block's text,
14
+ * preserving the reasoning between the tags. The edit is persistent: the
15
+ * stored message is replaced, so the context sent to subsequent LLM calls
16
+ * no longer contains the literal tag.
17
+ */
18
+
19
+ import type { Module } from "./skeleton.js";
20
+
21
+ const THINKING_OPEN = "<thinking>";
22
+ const THINKING_CLOSE = "</thinking>";
23
+
24
+ /** Strip a leading <thinking> and/or trailing </thinking> from `text`.
25
+ * Models often emit these tags as delimiters inside the `thinking` field;
26
+ * pi already marks the block type, so the tags are noise that leaks into
27
+ * the context and the TUI. We only trim at the head/tail — tags buried in
28
+ * the middle are part of the reasoning text and left alone, which avoids
29
+ * corrupting the model's argument if it literally discusses the tag. */
30
+ export function stripThinkingLabels(text: string): string {
31
+ if (!text.includes(THINKING_OPEN) && !text.includes(THINKING_CLOSE)) {
32
+ return text;
33
+ }
34
+ let out = text;
35
+ // trimStart would loop-strip repeating opens; we want a single leading
36
+ // open tag (optionally with whitespace before it) removed.
37
+ const leadingOpen = out.match(/^\s*<thinking>/);
38
+ if (leadingOpen) {
39
+ out = out.slice(leadingOpen[0].length);
40
+ }
41
+ const trailingClose = out.match(/<\/thinking>\s*$/);
42
+ if (trailingClose) {
43
+ out = out.slice(0, out.length - trailingClose[0].length);
44
+ }
45
+ return out;
46
+ }
47
+
48
+ /** Return a new content array with thinking blocks cleaned, or undefined
49
+ * if nothing changed. */
50
+ function cleanContent(content: any[]): any[] | undefined {
51
+ let changed = false;
52
+ const next = content.map((block) => {
53
+ if (block && block.type === "thinking" && typeof block.thinking === "string") {
54
+ const cleaned = stripThinkingLabels(block.thinking);
55
+ if (cleaned !== block.thinking) {
56
+ changed = true;
57
+ return { ...block, thinking: cleaned };
58
+ }
59
+ }
60
+ return block;
61
+ });
62
+ return changed ? next : undefined;
63
+ }
64
+
65
+ export const thinkingLabelStripModule: Module = {
66
+ name: "thinking-label-strip",
67
+ hooks: {
68
+ message_end: [
69
+ (event: any) => {
70
+ const message = event?.message;
71
+ if (!message || message.role !== "assistant") return undefined;
72
+ if (!Array.isArray(message.content)) return undefined;
73
+ const cleaned = cleanContent(message.content);
74
+ if (!cleaned) return undefined;
75
+ return { message: { ...message, content: cleaned } };
76
+ },
77
+ ],
78
+ },
79
+ };
package/index.ts CHANGED
@@ -23,8 +23,12 @@ import { createSkeleton } from "./hooks/skeleton.js";
23
23
  import { setupRedact, REDACT_GUIDANCE } from "./hooks/redact.js";
24
24
  import { externalizeModule } from "./hooks/externalize.js";
25
25
  import { normalizeCodeblocksModule } from "./hooks/normalize-codeblocks.js";
26
+ import { thinkingLabelStripModule } from "./hooks/thinking-label-strip.js";
26
27
  import { trackMtimeModule } from "./hooks/track-mtime.js";
27
- import { injectAgentsMdModule, INJECT_AGENTS_MD_GUIDANCE } from "./hooks/inject-agents-md.js";
28
+ import {
29
+ injectAgentsMdModule,
30
+ INJECT_AGENTS_MD_GUIDANCE,
31
+ } from "./hooks/inject-agents-md.js";
28
32
  import { imageVisionModule } from "./hooks/image-vision.js";
29
33
  import { smartAtModule } from "./hooks/smart-at.js";
30
34
  import { sessionTitleModule } from "./hooks/session-title.js";
@@ -39,7 +43,11 @@ import { registerLspTools } from "./tools/lsp/tools.js";
39
43
  import { LspServerManager } from "./tools/lsp/manager.js";
40
44
  import { collectLspDependencyStatuses } from "./tools/lsp/servers.js";
41
45
  import { registerAskTool } from "./tools/ask/index.js";
42
- import { resolveMcpConfigs, migrateLegacyGlobalMcpConfig, collectMcpDependencyStatuses } from "./tools/mcp/config.js";
46
+ import {
47
+ resolveMcpConfigs,
48
+ migrateLegacyGlobalMcpConfig,
49
+ collectMcpDependencyStatuses,
50
+ } from "./tools/mcp/config.js";
43
51
  import { ensureMcpServerReady } from "./hooks/mcp.js";
44
52
 
45
53
  import { registerDpModelCommand } from "./commands/dp-model.js";
@@ -56,80 +64,98 @@ import { captureModuleSnapshot, isModuleEnabled } from "./settings.js";
56
64
  // its constant and pushing it here. No priority / sort logic — just push.
57
65
 
58
66
  const BASE_GUIDANCE = [
59
- "## Decorated Pi Guidance",
60
- "",
61
- "### Workflow, how to approach tasks",
62
- "- Before acting on a prompt, do sufficient research on the existing state — read files, search, investigate — and only proceed once you have a clear picture.",
63
- "- Exercise caution when performing any **write** operations, especially when you are in a research or exploration phase.",
64
- "- Before modifying code, match the user's existing code style (naming, formatting, patterns). Do not re-modify lines the user has manually edited since your last change.",
65
- "",
66
- "### Filesystem Safety, where NOT to write",
67
- "- CAUTION: Do not perform write operations in the following directories unless explicitly instructed: `node_modules`, `venv`, `env`, `__pycache__`, `.git` or any other hidden directories.",
67
+ "## Decorated Pi Guidance",
68
+ "",
69
+ "### Workflow, how to approach tasks",
70
+ "- Before acting on a prompt, do sufficient research on the existing state — read files, search, investigate — and only proceed once you have a clear picture.",
71
+ "- Exercise caution when performing any **write** operations, especially when you are in a research or exploration phase.",
72
+ "- Before modifying code, match the user's existing code style (naming, formatting, patterns). Do not re-modify lines the user has manually edited since your last change.",
73
+ "",
74
+ "### Filesystem Safety, where NOT to write",
75
+ "- CAUTION: Do not perform write operations in the following directories unless explicitly instructed: `node_modules`, `venv`, `env`, `__pycache__`, `.git` or any other hidden directories.",
76
+ ].join("\n");
77
+
78
+ // Adapted from hexiecs/talk-normal (MIT), prompt-chatgpt.md v0.6.2.
79
+ const TALK_NORMAL_GUIDANCE = [
80
+ "## Your talking style",
81
+ "",
82
+ "- Be direct and informative. No filler, no fluff, but give enough to be useful.",
83
+ "- Lead with the answer; add context only if it helps.",
84
+ "- Prefer direct positive claims. Avoid negation-based contrastive phrasing like `不是X,而是Y` / `it's not X, it's Y`; state the positive claim directly.",
85
+ "- Kill filler: `I'd be happy to`, `Great question`, `It's worth noting`, `Certainly`, `Of course`, `首先`, `值得注意的是`, `综上所述`.",
86
+ "- Never restate the question.",
87
+ "- Yes/no questions: answer first, then give one sentence of reasoning.",
88
+ "- Comparisons: give a recommendation with brief reasoning, not a balanced essay.",
89
+ "- Code: give the code plus a usage example when non-trivial. Skip preambles like `Certainly! Here is...`.",
90
+ "- Use bullets or numbered lists only when the content has real parallel or sequential structure.",
91
+ "- Do not end with conditional follow-up offers like `If you want, I can...` / `如果你愿意,我还可以...`; take the real next step or name it directly.",
92
+ "- Do not use summary-stamp closings like `In summary`, `Hope this helps`, `一句话总结`, `总结一下`, `简而言之`; state the final claim directly.",
68
93
  ].join("\n");
69
94
 
70
95
  /** Remove the injected Pi documentation block from the base system prompt.
71
96
  * Matches a line containing "Pi documentation" and deletes it plus all
72
97
  * following non-empty lines, stopping at the first blank line. */
73
98
  export function stripPiDocsBlock(prompt: string): string {
74
- const lines = prompt.split("\n");
75
- const out: string[] = [];
76
- let i = 0;
77
- while (i < lines.length) {
78
- const line = lines[i];
79
- if (line.includes("Pi documentation")) {
80
- i++;
81
- while (i < lines.length && lines[i].trim() !== "") i++;
82
- // Drop the terminating blank line as well so we don't leave orphan whitespace.
83
- if (i < lines.length && lines[i].trim() === "") i++;
84
- continue;
99
+ const lines = prompt.split("\n");
100
+ const out: string[] = [];
101
+ let i = 0;
102
+ while (i < lines.length) {
103
+ const line = lines[i];
104
+ if (line.includes("Pi documentation")) {
105
+ i++;
106
+ while (i < lines.length && lines[i].trim() !== "") i++;
107
+ // Drop the terminating blank line as well so we don't leave orphan whitespace.
108
+ if (i < lines.length && lines[i].trim() === "") i++;
109
+ continue;
110
+ }
111
+ out.push(line);
112
+ i++;
85
113
  }
86
- out.push(line);
87
- i++;
88
- }
89
- return out.join("\n");
114
+ return out.join("\n");
90
115
  }
91
116
 
92
117
  /** Sort the <available_skills> block in the system prompt by skill name.
93
118
  * Pi core appends extension-provided skills after user/project skills and does
94
119
  * not sort the XML; this makes the final prompt stable and cache-friendly. */
95
120
  export function sortSkillsInSystemPrompt(prompt: string): string {
96
- const startMarker = "\n<available_skills>";
97
- const endMarker = "</available_skills>";
98
- const startIdx = prompt.indexOf(startMarker);
99
- if (startIdx === -1) return prompt;
100
- const endIdx = prompt.indexOf(endMarker, startIdx);
101
- if (endIdx === -1) return prompt;
102
-
103
- const before = prompt.slice(0, startIdx + startMarker.length);
104
- const after = prompt.slice(endIdx);
105
- const inner = prompt.slice(startIdx + startMarker.length, endIdx);
106
-
107
- const chunks: string[][] = [];
108
- let current: string[] = [];
109
- for (const line of inner.split("\n")) {
110
- const trimmed = line.trim();
111
- if (trimmed === "<skill>") {
112
- current = [line];
113
- } else if (trimmed === "</skill>") {
114
- current.push(line);
115
- chunks.push(current);
116
- current = [];
117
- } else if (current.length > 0) {
118
- current.push(line);
121
+ const startMarker = "\n<available_skills>";
122
+ const endMarker = "</available_skills>";
123
+ const startIdx = prompt.indexOf(startMarker);
124
+ if (startIdx === -1) return prompt;
125
+ const endIdx = prompt.indexOf(endMarker, startIdx);
126
+ if (endIdx === -1) return prompt;
127
+
128
+ const before = prompt.slice(0, startIdx + startMarker.length);
129
+ const after = prompt.slice(endIdx);
130
+ const inner = prompt.slice(startIdx + startMarker.length, endIdx);
131
+
132
+ const chunks: string[][] = [];
133
+ let current: string[] = [];
134
+ for (const line of inner.split("\n")) {
135
+ const trimmed = line.trim();
136
+ if (trimmed === "<skill>") {
137
+ current = [line];
138
+ } else if (trimmed === "</skill>") {
139
+ current.push(line);
140
+ chunks.push(current);
141
+ current = [];
142
+ } else if (current.length > 0) {
143
+ current.push(line);
144
+ }
119
145
  }
120
- }
121
146
 
122
- const nameOf = (chunk: string[]) => {
123
- const line = chunk.find((l) => l.trim().startsWith("<name>"));
124
- if (!line) return "";
125
- const t = line.trim();
126
- return t.slice(6, t.indexOf("</name>"));
127
- };
147
+ const nameOf = (chunk: string[]) => {
148
+ const line = chunk.find((l) => l.trim().startsWith("<name>"));
149
+ if (!line) return "";
150
+ const t = line.trim();
151
+ return t.slice(6, t.indexOf("</name>"));
152
+ };
128
153
 
129
- chunks.sort((a, b) => nameOf(a).localeCompare(nameOf(b)));
154
+ chunks.sort((a, b) => nameOf(a).localeCompare(nameOf(b)));
130
155
 
131
- const sortedInner = "\n" + chunks.map((chunk) => chunk.join("\n")).join("\n") + "\n";
132
- return before + sortedInner + after;
156
+ const sortedInner =
157
+ "\n" + chunks.map((chunk) => chunk.join("\n")).join("\n") + "\n";
158
+ return before + sortedInner + after;
133
159
  }
134
160
 
135
161
  /** Build the list of guideline strings to inject, in prompt order.
@@ -139,146 +165,153 @@ export function sortSkillsInSystemPrompt(prompt: string): string {
139
165
  * injected). The mcp module check lives in `resolveMcpConfigs`, so this
140
166
  * code only needs to look at the server's own `enabled` flag. */
141
167
  function buildGuidelines(): string[] {
142
- const out: string[] = [
143
- BASE_GUIDANCE,
144
- INJECT_AGENTS_MD_GUIDANCE, // from hooks/inject-agents-md.ts — always on
145
- ];
146
- if (isModuleEnabled("secretRedaction")) out.push(REDACT_GUIDANCE);
147
- return out;
168
+ const out: string[] = [
169
+ BASE_GUIDANCE,
170
+ TALK_NORMAL_GUIDANCE,
171
+ INJECT_AGENTS_MD_GUIDANCE, // from hooks/inject-agents-md.ts — always on
172
+ ];
173
+ if (isModuleEnabled("secretRedaction")) out.push(REDACT_GUIDANCE);
174
+ return out;
148
175
  }
149
176
 
150
- function canRegisterMcpServer(config: { name: string; command?: string }, deps: Array<{ module: string; state: string }>): boolean {
151
- if (!config.command) return true;
152
- const dep = deps.find((d) => d.module === `mcp:${config.name}`);
153
- return dep ? dep.state === "ok" : true;
177
+ function canRegisterMcpServer(
178
+ config: { name: string; command?: string },
179
+ deps: Array<{ module: string; state: string }>,
180
+ ): boolean {
181
+ if (!config.command) return true;
182
+ const dep = deps.find((d) => d.module === `mcp:${config.name}`);
183
+ return dep ? dep.state === "ok" : true;
154
184
  }
155
185
 
156
186
  /** Absolute path to the plugin's builtin skills directory.
157
187
  * Used by `resources_discover` so the skill travels with the plugin
158
188
  * regardless of which project pi is running in. */
159
189
  export function getBuiltinSkillPaths(): string[] {
160
- return [fileURLToPath(new URL("./skills", import.meta.url))];
190
+ return [fileURLToPath(new URL("./skills", import.meta.url))];
161
191
  }
162
192
 
163
193
  /** Register the plugin's builtin skill paths with Pi core. */
164
194
  function installBuiltinSkills(pi: ExtensionAPI): void {
165
- pi.on("resources_discover", async (_event: any) => ({
166
- skillPaths: getBuiltinSkillPaths(),
167
- }));
195
+ pi.on("resources_discover", async (_event: any) => ({
196
+ skillPaths: getBuiltinSkillPaths(),
197
+ }));
168
198
  }
169
199
 
170
200
  /** Install a single before_agent_start handler that appends every
171
201
  * guideline in order, stripping the volatile "Current date: …" line
172
202
  * for cache stability. Idempotent — re-injection is a no-op via marker. */
173
203
  function installGuidelines(pi: ExtensionAPI): void {
174
- const blocks = buildGuidelines();
175
- const joined = blocks.join("\n\n");
176
- const marker = "## Decorated Pi Guidance";
177
-
178
- pi.on("before_agent_start", async (event: any) => {
179
- if (!event.systemPrompt) return undefined;
180
- let prompt: string = stripPiDocsBlock(event.systemPrompt);
181
- prompt = sortSkillsInSystemPrompt(prompt);
182
- prompt = prompt.replace(/\nCurrent date: \d{4}-\d{2}-\d{2}/, "");
183
- if (prompt.includes(marker)) return undefined; // already injected this turn
184
- return { systemPrompt: `${prompt}\n\n${joined}` };
185
- });
204
+ const blocks = buildGuidelines();
205
+ const joined = blocks.join("\n\n");
206
+ const marker = "## Decorated Pi Guidance";
207
+
208
+ pi.on("before_agent_start", async (event: any) => {
209
+ if (!event.systemPrompt) return undefined;
210
+ let prompt: string = stripPiDocsBlock(event.systemPrompt);
211
+ prompt = sortSkillsInSystemPrompt(prompt);
212
+ prompt = prompt.replace(/\nCurrent date: \d{4}-\d{2}-\d{2}/, "");
213
+ if (prompt.includes(marker)) return undefined; // already injected this turn
214
+ return { systemPrompt: `${prompt}\n\n${joined}` };
215
+ });
186
216
  }
187
217
 
188
218
  export default async function (pi: ExtensionAPI) {
189
- // Snapshot the module settings that pi is about to load. /dp-settings
190
- // compares against this to avoid prompting for reload when the user
191
- // has only returned the settings to the currently-loaded state.
192
- captureModuleSnapshot();
193
-
194
- // ── Skeleton (hooks) ───────────────────────────────────────────────────
195
- const sk = createSkeleton();
196
-
197
- // Order matters for tool_result compose chain:
198
- // 1. redact → normalize-codeblocks → externalize → track-mtime → inject-agents-md → image-vision → wakatime
199
- // The first module registered for a given event runs first (compose chain).
200
- if (isModuleEnabled("secretRedaction")) setupRedact(sk);
201
- sk.register(normalizeCodeblocksModule);
202
- sk.register(externalizeModule);
203
- sk.register(trackMtimeModule);
204
- sk.register(injectAgentsMdModule);
205
- sk.register(imageVisionModule);
206
-
207
- // session_start handlers (parallel)
208
- // pi-tool-filter must register first so native tools are dropped before
209
- // anything else inspects the tool list.
210
- sk.register(piToolFilterModule);
211
- sk.register(sessionTitleModule);
212
- if (isModuleEnabled("atOverride")) sk.register(smartAtModule);
213
- if (isModuleEnabled("wakatime")) sk.register(wakatimeModule);
214
-
215
- // Compaction + RTK (these also install their own pi.on via setup<>()).
216
- setupCompaction(sk);
217
- if (isModuleEnabled("rtk")) setupRtk(sk);
218
- if (isModuleEnabled("wakatime")) setupWakatime(sk, pi);
219
-
220
- // ── Tools (conditional on module switches) ────────────────────────────
221
- if (isModuleEnabled("patchOverrideEdit")) registerPatchTool(pi);
222
- if (isModuleEnabled("lsp")) {
223
- const lspDeps = collectLspDependencyStatuses(process.cwd());
224
- if (lspDeps.some((d) => d.state === "ok")) {
225
- registerLspTools(pi, new LspServerManager());
226
- }
227
- for (const dep of lspDeps) {
228
- if (dep.state !== "ok") {
229
- sk.declareMissing({
230
- name: dep.label,
231
- module: "lsp",
232
- hint: dep.detail,
233
- });
234
- }
235
- }
236
- }
237
- if (isModuleEnabled("ask")) registerAskTool(pi);
238
-
239
- // MCP: hook, tools, and /mcp command are gated together. Disabling the
240
- // module means no session_start handler runs, no tools register, no
241
- // /mcp command is available, and no background connections are attempted.
242
- if (isModuleEnabled("mcp")) {
243
- // One-time migration: legacy global MCP configs in
244
- // ~/.pi/agent/decorated-pi.json move to ~/.pi/agent/mcp.json. Run
245
- // explicitly here so `loadGlobalMcpConfigs` stays pure.
246
- migrateLegacyGlobalMcpConfig();
247
- sk.register(mcpModule);
248
- const mcpDeps = collectMcpDependencyStatuses(process.cwd());
249
- for (const dep of mcpDeps) {
250
- if (dep.state !== "ok") {
251
- sk.declareMissing({
252
- name: dep.label, // binary name (e.g. "codegraph")
253
- module: "mcp",
254
- hint: dep.detail,
255
- });
256
- }
219
+ // Snapshot the module settings that pi is about to load. /dp-settings
220
+ // compares against this to avoid prompting for reload when the user
221
+ // has only returned the settings to the currently-loaded state.
222
+ captureModuleSnapshot();
223
+
224
+ // ── Skeleton (hooks) ───────────────────────────────────────────────────
225
+ const sk = createSkeleton();
226
+
227
+ // Order matters for tool_result compose chain:
228
+ // 1. redact → normalize-codeblocks → externalize → track-mtime → inject-agents-md → image-vision → wakatime
229
+ // The first module registered for a given event runs first (compose chain).
230
+ if (isModuleEnabled("secretRedaction")) setupRedact(sk);
231
+ sk.register(normalizeCodeblocksModule);
232
+ sk.register(thinkingLabelStripModule);
233
+ sk.register(externalizeModule);
234
+ sk.register(trackMtimeModule);
235
+ sk.register(injectAgentsMdModule);
236
+ sk.register(imageVisionModule);
237
+
238
+ // session_start handlers (parallel)
239
+ // pi-tool-filter must register first so native tools are dropped before
240
+ // anything else inspects the tool list.
241
+ sk.register(piToolFilterModule);
242
+ sk.register(sessionTitleModule);
243
+ if (isModuleEnabled("atOverride")) sk.register(smartAtModule);
244
+ if (isModuleEnabled("wakatime")) sk.register(wakatimeModule);
245
+
246
+ // Compaction + RTK (these also install their own pi.on via setup<>()).
247
+ setupCompaction(sk);
248
+ if (isModuleEnabled("rtk")) setupRtk(sk);
249
+ if (isModuleEnabled("wakatime")) setupWakatime(sk, pi);
250
+
251
+ // ── Tools (conditional on module switches) ────────────────────────────
252
+ if (isModuleEnabled("patchOverrideEdit")) registerPatchTool(pi);
253
+ if (isModuleEnabled("lsp")) {
254
+ const lspDeps = collectLspDependencyStatuses(process.cwd());
255
+ if (lspDeps.some((d) => d.state === "ok")) {
256
+ registerLspTools(pi, new LspServerManager());
257
+ }
258
+ for (const dep of lspDeps) {
259
+ if (dep.state !== "ok") {
260
+ sk.declareMissing({
261
+ name: dep.label,
262
+ module: "lsp",
263
+ hint: dep.detail,
264
+ });
265
+ }
266
+ }
257
267
  }
258
- const configs = resolveMcpConfigs(process.cwd()).filter(s => s.enabled);
259
- // Per-server readiness: cache hit → register from cache (fast).
260
- // Cache miss connect synchronously, write cache, then register
261
- // live tools. This blocks startup only for cache-miss servers.
262
- // Skip servers whose binary is missing (dependency not met).
263
- for (const config of configs) {
264
- if (!canRegisterMcpServer(config, mcpDeps)) continue;
265
- await ensureMcpServerReady(pi, config, process.cwd());
268
+ if (isModuleEnabled("ask")) registerAskTool(pi);
269
+
270
+ // MCP: hook, tools, and /mcp command are gated together. Disabling the
271
+ // module means no session_start handler runs, no tools register, no
272
+ // /mcp command is available, and no background connections are attempted.
273
+ if (isModuleEnabled("mcp")) {
274
+ // One-time migration: legacy global MCP configs in
275
+ // ~/.pi/agent/decorated-pi.json move to ~/.pi/agent/mcp.json. Run
276
+ // explicitly here so `loadGlobalMcpConfigs` stays pure.
277
+ migrateLegacyGlobalMcpConfig();
278
+ sk.register(mcpModule);
279
+ const mcpDeps = collectMcpDependencyStatuses(process.cwd());
280
+ for (const dep of mcpDeps) {
281
+ if (dep.state !== "ok") {
282
+ sk.declareMissing({
283
+ name: dep.label, // binary name (e.g. "codegraph")
284
+ module: "mcp",
285
+ hint: dep.detail,
286
+ });
287
+ }
288
+ }
289
+ const configs = resolveMcpConfigs(process.cwd()).filter(
290
+ (s) => s.enabled,
291
+ );
292
+ // Per-server readiness: cache hit → register from cache (fast).
293
+ // Cache miss → connect synchronously, write cache, then register
294
+ // live tools. This blocks startup only for cache-miss servers.
295
+ // Skip servers whose binary is missing (dependency not met).
296
+ for (const config of configs) {
297
+ if (!canRegisterMcpServer(config, mcpDeps)) continue;
298
+ await ensureMcpServerReady(pi, config, process.cwd());
299
+ }
300
+ registerMcpStatusCommand(pi);
266
301
  }
267
- registerMcpStatusCommand(pi);
268
- }
269
302
 
270
- // ── Builtin skills (travel with the plugin in every project) ─────────────
271
- installBuiltinSkills(pi);
303
+ // ── Builtin skills (travel with the plugin in every project) ─────────────
304
+ installBuiltinSkills(pi);
272
305
 
273
- // ── System-prompt guidelines (single handler, array order = prompt order) ──
274
- installGuidelines(pi);
306
+ // ── System-prompt guidelines (single handler, array order = prompt order) ──
307
+ installGuidelines(pi);
275
308
 
276
- // ── Commands ──────────────────────────────────────────────────────────
277
- registerDpModelCommand(pi);
278
- registerDpSettingsCommand(pi);
279
- if (isModuleEnabled("retry")) registerRetryCommand(pi);
280
- if (isModuleEnabled("usage")) registerUsageCommand(pi);
309
+ // ── Commands ──────────────────────────────────────────────────────────
310
+ registerDpModelCommand(pi);
311
+ registerDpSettingsCommand(pi);
312
+ if (isModuleEnabled("retry")) registerRetryCommand(pi);
313
+ if (isModuleEnabled("usage")) registerUsageCommand(pi);
281
314
 
282
- // ── Install skeleton (last) ────────────────────────────────────────────
283
- sk.install(pi);
315
+ // ── Install skeleton (last) ────────────────────────────────────────────
316
+ sk.install(pi);
284
317
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "decorated-pi",
3
- "version": "0.7.3",
3
+ "version": "0.8.0",
4
4
  "description": "decorated-pi is a practical enhancement pack for pi coding agent — token-efficient workflow, cache-friendly design, and smarter tools.",
5
5
  "keywords": [
6
6
  "pi",
@@ -26,7 +26,9 @@
26
26
  "dependencies": {
27
27
  "@ff-labs/fff-node": "^0.9.4",
28
28
  "@modelcontextprotocol/sdk": "^1.29.0",
29
+ "chardet": "^2.2.0",
29
30
  "file-type": "^21.3.4",
31
+ "iconv-lite": "^0.7.2",
30
32
  "openai": "^6.37.0"
31
33
  },
32
34
  "peerDependencies": {
@@ -17,7 +17,7 @@ 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
22
  });
23
23
 
@@ -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
  };
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Encoding detection and conversion for patch.
3
+ *
4
+ * - chardet analyses raw bytes (BOM + histogram heuristics) → encoding name.
5
+ * - iconv-lite decodes/encodes between Buffer and string.
6
+ *
7
+ * For UTF-8 (the overwhelmingly common case) we bypass iconv-lite and use
8
+ * Node's native string<->Buffer conversion — zero behaviour change vs the
9
+ * pre-encoding-aware tool, and no iconv runtime cost for ASCII/UTF-8 files.
10
+ */
11
+
12
+ import * as fs from "node:fs";
13
+ import chardet, { type Match } from "chardet";
14
+ import * as iconv from "iconv-lite";
15
+
16
+ export interface FileEncoding {
17
+ /** iconv-lite-compatible encoding name (e.g. "utf-8", "gb18030", "utf-16le"). */
18
+ encoding: string;
19
+ /** True when the file starts with a BOM that should be preserved on write. */
20
+ hasBOM: boolean;
21
+ /** True when the file is UTF-8 (with or without BOM). Native path is used. */
22
+ isUtf8: boolean;
23
+ }
24
+
25
+ // chardet emits "UTF-8" for plain UTF-8. iconv-lite accepts any case.
26
+ const UTF8_ALIASES = new Set(["utf-8", "utf8", "ascii"]);
27
+ const UTF8_BOM = Buffer.from([0xef, 0xbb, 0xbf]);
28
+ const UTF16LE_BOM = Buffer.from([0xff, 0xfe]);
29
+ const UTF16BE_BOM = Buffer.from([0xfe, 0xff]);
30
+
31
+ /** Returns true if `buf` is valid UTF-8 (every byte parses, no U+FFFD
32
+ * replacement). Pure-ASCII and any well-formed UTF-8 qualify. */
33
+ function isValidUtf8(buf: Buffer): boolean {
34
+ // Writing then reading back would corrupt invalid sequences into U+FFFD;
35
+ // detect that by comparing round-trip byte lengths. The cheap, correct
36
+ // check is Node's built-in: toString('utf8') replaces bad sequences with
37
+ // U+FFFD, so we scan the decoded string for it.
38
+ if (buf.length === 0) return true;
39
+ const s = buf.toString("utf8");
40
+ return !s.includes("\uFFFD");
41
+ }
42
+
43
+ function looksLikeUtf8Bom(buf: Buffer): boolean {
44
+ return buf.length >= 3 && buf[0] === 0xef && buf[1] === 0xbb && buf[2] === 0xbf;
45
+ }
46
+
47
+ function looksLikeUtf16LeBom(buf: Buffer): boolean {
48
+ return buf.length >= 2 && buf[0] === 0xff && buf[1] === 0xfe;
49
+ }
50
+
51
+ function looksLikeUtf16BeBom(buf: Buffer): boolean {
52
+ return buf.length >= 2 && buf[0] === 0xfe && buf[1] === 0xff;
53
+ }
54
+
55
+ /**
56
+ * Detect a file's encoding by reading its bytes.
57
+ * Falls back to UTF-8 if detection fails or the detected encoding is not
58
+ * supported by iconv-lite.
59
+ */
60
+ export function detectFileEncoding(filePath: string): FileEncoding {
61
+ const buf = fs.readFileSync(filePath);
62
+
63
+ // BOM takes precedence for the UTF-16/UTF-32 family (endianness matters).
64
+ if (looksLikeUtf8Bom(buf)) {
65
+ return { encoding: "utf-8", hasBOM: true, isUtf8: true };
66
+ }
67
+ if (looksLikeUtf16LeBom(buf)) {
68
+ return { encoding: "utf-16le", hasBOM: true, isUtf8: false };
69
+ }
70
+ if (looksLikeUtf16BeBom(buf)) {
71
+ return { encoding: "utf-16be", hasBOM: true, isUtf8: false };
72
+ }
73
+
74
+ // Any well-formed UTF-8 (including pure ASCII) is UTF-8. This short-circuits
75
+ // chardet's tendency to mis-classify short or ASCII-only buffers as exotic
76
+ // encodings (e.g. 2-byte "x\n" as utf-32le).
77
+ if (isValidUtf8(buf)) {
78
+ return { encoding: "utf-8", hasBOM: false, isUtf8: true };
79
+ }
80
+
81
+ // Not valid UTF-8 — trust chardet's heuristic for the legacy encoding.
82
+ // chardet mis-classifies short or mixed CJK samples (it often ties GBK,
83
+ // Big5, Shift_JIS, EUC-JP, EUC-KR at the same low confidence). Use the
84
+ // full ranked list and prefer Chinese encodings — GB18030 is a superset
85
+ // of GBK/GB2312 and the most common non-UTF-8 encoding for Chinese text,
86
+ // which is the primary use case for this tool.
87
+ const candidates = chardet.analyse(buf);
88
+ let encoding = pickLegacyEncoding(candidates);
89
+ let hasBOM = false;
90
+
91
+ // Verify iconv-lite actually ships a codec for this label; otherwise fall
92
+ // back to UTF-8 (the previous behaviour) instead of throwing mid-edit.
93
+ if (!iconv.encodingExists(encoding)) {
94
+ encoding = "utf-8";
95
+ hasBOM = false;
96
+ }
97
+
98
+ // Last-resort safety net: if the chosen encoding still produces U+FFFD
99
+ // replacement chars on decode, the file is not actually in that encoding
100
+ // and we would corrupt it on write-back. Fall back to ISO-8859-1 (Latin1),
101
+ // which is a lossless 1:1 byte<->code-point mapping for 0x00–0xFF and can
102
+ // never introduce U+FFFD — round-tripping is byte-identical.
103
+ if (!UTF8_ALIASES.has(encoding) && iconv.decode(buf, encoding).includes("\uFFFD")) {
104
+ encoding = "iso-8859-1";
105
+ hasBOM = false;
106
+ }
107
+
108
+ return {
109
+ encoding,
110
+ hasBOM,
111
+ isUtf8: UTF8_ALIASES.has(encoding),
112
+ };
113
+ }
114
+
115
+ /** Preference order for breaking chardet ties. Chinese first (GB18030 is a
116
+ * superset of GBK/GB2312), then other CJK, then everything else. */
117
+ const ENCODING_PRIORITY: string[] = [
118
+ "gb18030", "gbk", "gb2312", // Chinese (mainland)
119
+ "big5", // Chinese (traditional)
120
+ "shift_jis", "euc-jp", // Japanese
121
+ "euc-kr", "windows-949", // Korean
122
+ "windows-1252", "iso-8859-1", // Western (rarely reached: isValidUtf8 short-circuits)
123
+ ];
124
+
125
+ function pickLegacyEncoding(candidates: Match[]): string {
126
+ if (candidates.length === 0) return "iso-8859-1";
127
+ const top = candidates[0]!.confidence;
128
+ // Keep only candidates tied with the top confidence (±5 tolerance —
129
+ // chardet's scores are coarse).
130
+ const tied = candidates.filter((c) => Math.abs(c.confidence - top) <= 5);
131
+ for (const pref of ENCODING_PRIORITY) {
132
+ const hit = tied.find((c) => c.name.toLowerCase() === pref);
133
+ if (hit) return pref;
134
+ }
135
+ // We only reach here when isValidUtf8 already failed (there are high
136
+ // bytes), so "ascii" / "UTF-8" from chardet are wrong. ISO-8859-1 is the
137
+ // safe single-byte fallback — lossless for 0x00–0xFF, no U+FFFD.
138
+ return "iso-8859-1";
139
+ }
140
+
141
+ /** Read a file as a string, decoding via the detected encoding. */
142
+ export function readFileDecoded(filePath: string, enc: FileEncoding): string {
143
+ const buf = fs.readFileSync(filePath);
144
+ if (enc.isUtf8) {
145
+ // Native path: identical to the old fs.readFileSync(p, "utf8").
146
+ // For UTF-8 with BOM, slice off the BOM so it does not leak into content
147
+ // matching (it would prefix the first line and break old_str lookups).
148
+ const slice = enc.hasBOM ? buf.subarray(3) : buf;
149
+ return slice.toString("utf8");
150
+ }
151
+ return iconv.decode(buf, enc.encoding, { stripBOM: true });
152
+ }
153
+
154
+ /** Write a string back to a file, encoding via the detected encoding. */
155
+ export function writeFileEncoded(
156
+ filePath: string,
157
+ content: string,
158
+ enc: FileEncoding,
159
+ ): void {
160
+ if (enc.isUtf8) {
161
+ if (enc.hasBOM) {
162
+ const body = Buffer.from(content, "utf8");
163
+ const out = Buffer.concat([UTF8_BOM, body]);
164
+ fs.writeFileSync(filePath, out);
165
+ return;
166
+ }
167
+ fs.writeFileSync(filePath, content, "utf8");
168
+ return;
169
+ }
170
+ const buf = iconv.encode(content, enc.encoding, { addBOM: enc.hasBOM });
171
+ fs.writeFileSync(filePath, buf);
172
+ }