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 +3 -1
- package/commands/usage.ts +5 -1
- package/hooks/compaction.ts +11 -146
- package/hooks/skeleton.ts +4 -1
- package/hooks/smart-at.ts +18 -23
- package/hooks/thinking-label-strip.ts +79 -0
- package/index.ts +207 -174
- package/package.json +3 -1
- package/tools/ask/index.ts +1 -1
- package/tools/patch/core.ts +113 -24
- package/tools/patch/encoding.ts +172 -0
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
|
-
|
|
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";
|
package/hooks/compaction.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* compaction — custom compaction model
|
|
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
|
-
*
|
|
11
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
|
62
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
function buildResult(items: MixedItem[],
|
|
73
|
-
const
|
|
74
|
-
.
|
|
75
|
-
.
|
|
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
|
|
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
|
|
152
|
-
|
|
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,
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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.
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
154
|
+
chunks.sort((a, b) => nameOf(a).localeCompare(nameOf(b)));
|
|
130
155
|
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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(
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
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
|
-
|
|
166
|
-
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
259
|
-
|
|
260
|
-
//
|
|
261
|
-
//
|
|
262
|
-
//
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
-
|
|
271
|
-
|
|
303
|
+
// ── Builtin skills (travel with the plugin in every project) ─────────────
|
|
304
|
+
installBuiltinSkills(pi);
|
|
272
305
|
|
|
273
|
-
|
|
274
|
-
|
|
306
|
+
// ── System-prompt guidelines (single handler, array order = prompt order) ──
|
|
307
|
+
installGuidelines(pi);
|
|
275
308
|
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
309
|
+
// ── Commands ──────────────────────────────────────────────────────────
|
|
310
|
+
registerDpModelCommand(pi);
|
|
311
|
+
registerDpSettingsCommand(pi);
|
|
312
|
+
if (isModuleEnabled("retry")) registerRetryCommand(pi);
|
|
313
|
+
if (isModuleEnabled("usage")) registerUsageCommand(pi);
|
|
281
314
|
|
|
282
|
-
|
|
283
|
-
|
|
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.
|
|
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": {
|
package/tools/ask/index.ts
CHANGED
|
@@ -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
|
|
package/tools/patch/core.ts
CHANGED
|
@@ -11,7 +11,12 @@
|
|
|
11
11
|
|
|
12
12
|
import * as fs from "node:fs";
|
|
13
13
|
import * as path from "node:path";
|
|
14
|
-
import
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
291
|
-
const
|
|
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 =
|
|
396
|
-
|
|
397
|
-
|
|
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
|
-
|
|
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
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1065
|
-
|
|
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
|
+
}
|